WebGPURenderer picks its backend at init(): WebGPU when navigator.gpu gives an adapter, otherwise WebGL 2, and forceWebGL: true requests WebGL 2 outright. TSL materials run on both. In r186 they also run on the classic WebGLRenderer through the WebGLNodesHandler add-on, meant for gradual migration (its source lists the gaps: no VSM shadows, MRT, transmission, storage textures or node post-processing). This page tries all three:
<script src="three-map.js"></script>
<script type="module">
import * as THREE from 'three';
import { WebGPURenderer } from 'three/webgpu';
import { WebGLNodesHandler } from 'three/addons/tsl/WebGLNodesHandler.js';
import * as BN from './bookshelf.js';
import { foilNodeMaterial, sheenTime } from './tsl-cover.js';
document.body.style.cssText = 'margin: 0; display: flex; gap: 6px';
sheenTime.value = 1.3;
const webgl = new THREE.WebGLRenderer({ antialias: true });
webgl.setNodesHandler(new WebGLNodesHandler()); // node materials on WebGLRenderer
const renderers = [new WebGPURenderer({ antialias: true }),
new WebGPURenderer({ antialias: true, forceWebGL: true }), webgl];
for (const renderer of renderers) {
const { scene, camera } = BN.createStage({ width: 216, height: 200, renderer });
const book = BN.createSpineBook(BN.BOOKS[2], 0.94); // Salt and Saffron
book.material[2] = foilNodeMaterial(book.material[2].map);
book.position.set(-0.2, -0.47, 0);
book.rotation.y = -Math.PI / 2 + 0.5;
scene.add(book);
camera.position.set(0, 0.1, 1.9);
camera.lookAt(0, 0, 0);
await renderer.init?.(); // WebGLRenderer has no init()
renderer.render(scene, camera);
if (!renderer.isWebGPURenderer) continue;
const probe = new THREE.Mesh(book.geometry, book.material[2]); // one material per mesh
const { fragmentShader } = await renderer.debug.getShaderAsync(scene, camera, probe);
const line = fragmentShader.split('\n').find((text) => text.includes('EmissiveColor ='));
console.log(renderer.backend.isWebGPUBackend ? 'WebGPU backend, WGSL:' : 'WebGL 2 backend, GLSL:',
line.trim());
}
requestAnimationFrame(() => { window.__done = true; });
</script>
WebGPU backend, WGSL: EmissiveColor = ( vec3<f32>( 0.9, 0.8, 0.5 ) * vec3<f32>( smoothstep(
0.12, 0.0, abs( ( ( ( nodeVarying6.x + nodeVarying6.y ) - ( fract( ( object.nodeUniform7 *
0.25 ) ) * 2.5 ) ) + 0.2 ) ) ) ) );
WebGL 2 backend, GLSL: EmissiveColor = ( vec3( 0.9, 0.8, 0.5 ) * vec3( smoothstep( 0.12, 0.0,
abs( ( ( ( nodeVarying6.x + nodeVarying6.y ) - ( fract( ( nodeUniform8 * 0.25 ) ) * 2.5 ) )
+ 0.2 ) ) ) ) );The books match: WebGPU, WebGL 2, then WebGLRenderer with the handler. The printed lines are the emissiveNode graph in each language, vec3<f32> against vec3, with sheenTime renamed to a generated nodeUniform. getShaderAsync() needs a mesh with one material (the book's array throws material.customProgramCacheKey is not a function), hence the probe.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
.row { display: flex; gap: 6px; flex-wrap: wrap; padding: 4px; }
figure { margin: 0; width: 290px; }
canvas { display: block; }
code { display: block; font-size: 10px; background: #fff; padding: 4px; min-height: 2.6em; word-break: break-all; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js",
"three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.tsl.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="row" id="row"></div>
<script type="module">
import * as THREE from 'three/webgpu';
import { uv, uniform, fract, smoothstep, vec3, color, mix } from 'three/tsl';
const sheenTime = uniform(1.3);
function foilNodeMaterial() {
const st = uv();
const sweep = st.x.add(st.y).sub(fract(sheenTime.mul(0.25)).mul(2.5)).add(0.2);
const material = new THREE.MeshStandardNodeMaterial({ roughness: 0.4 });
material.colorNode = mix(color('#e09a10'), color('#b5452f'), st.y.oneMinus());
material.emissiveNode = vec3(0.9, 0.8, 0.5).mul(smoothstep(0.12, 0.0, sweep.abs()));
return material;
}
// Two renderers: the default (WebGPU when there is an adapter) and forceWebGL
const configs = [['WebGPURenderer()', {}], ['WebGPURenderer({ forceWebGL: true })', { forceWebGL: true }]];
const views = [];
for (const [caption, options] of configs) {
const renderer = new THREE.WebGPURenderer({ antialias: true, ...options });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(290, 200);
await renderer.init(); // picks the backend
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.5));
const sun = new THREE.DirectionalLight('#ffffff', 2);
sun.position.set(2, 3, 4);
scene.add(sun);
const book = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.9, 0.14), foilNodeMaterial());
book.rotation.y = -0.4;
scene.add(book);
const camera = new THREE.PerspectiveCamera(35, 290 / 200, 0.1, 50);
camera.position.set(0, 0.1, 1.9);
renderer.render(scene, camera);
// The same node graph compiled to each backend's language
const { fragmentShader } = await renderer.debug.getShaderAsync(scene, camera, book);
const line = fragmentShader.split('\n').find((text) => text.includes('EmissiveColor =')) ?? '';
const figure = document.getElementById('row').appendChild(document.createElement('figure'));
const backend = renderer.backend.isWebGPUBackend ? 'WebGPU backend, WGSL' : 'WebGL 2 backend, GLSL';
figure.innerHTML = `<b>${caption}</b><br>${backend}<code></code>`;
figure.insertBefore(renderer.domElement, figure.firstChild);
figure.querySelector('code').textContent = line.trim();
views.push({ renderer, scene, camera, book });
}
// Animate both with the same shared uniform
views[0].renderer.setAnimationLoop((t) => {
sheenTime.value = t / 1000;
for (const v of views) { v.book.rotation.y = -0.4 + Math.sin(t / 1500) * 0.3; v.renderer.render(v.scene, v.camera); }
});
</script>