TSL writes shaders as JavaScript: each call returns a node, and a material's node graph is compiled on first use to WGSL (WebGPU) or GLSL ES 3.00 (WebGL), depending on the backend. Functions come from three/tsl; node materials such as MeshStandardNodeMaterial come from three/webgpu, with slots (colorNode, normalNode, positionNode, emissiveNode and more) that replace one step of the built-in model, the job onBeforeCompile does by string surgery.
| GLSL | TSL |
|---|---|
| uniform float t; then t = 1.3 | const t = uniform(1.3), later t.value = 2 |
| texture(map, vUv).rgb | texture(map).rgb (UV defaults to uv()) |
| a * b + 0.2 | a.mul(b).add(0.2) |
| smoothstep(0.1, 0.0, abs(x)) | smoothstep(0.1, 0.0, x.abs()) |
| A GLSL function | Fn(([a, b]) => ...), with If() and Loop() for control flow |
Method chaining replaces operators because JavaScript cannot overload *; nodes carry types, and TSL converts between them. The reward is one source for two backends and built-in nodes (time, normalWorld, screenUV, noise) that would be boilerplate in GLSL.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-start; }
canvas { display: block; }
table { border-collapse: collapse; margin: 6px; background: #fff; font: 11px monospace; }
td, th { border: 1px solid #ddd; padding: 3px 5px; text-align: left; }
#backend { margin: 4px 8px; }
</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">
<table>
<tr><th>GLSL</th><th>TSL (this page)</th></tr>
<tr><td>uniform float speed;</td><td>const speed = uniform(0.25)</td></tr>
<tr><td>vUv.x + vUv.y</td><td>uv().x.add(uv().y)</td></tr>
<tr><td>fract(t * speed)</td><td>fract(time.mul(speed))</td></tr>
<tr><td>smoothstep(0.1, 0.0, abs(x))</td><td>smoothstep(0.1, 0.0, x.abs())</td></tr>
<tr><td>mix(a, b, k)</td><td>mix(a, b, k)</td></tr>
</table>
</div>
<p id="backend"></p>
<script type="module">
import * as THREE from 'three/webgpu';
import { uv, time, uniform, fract, smoothstep, mix, color, vec3 } from 'three/tsl';
// WebGPURenderer uses WebGPU when an adapter is available, WebGL 2 otherwise
const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(240, 240);
document.getElementById('row').prepend(renderer.domElement);
await renderer.init();
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 50);
camera.position.set(0.6, 0.4, 2.2);
camera.lookAt(0, 0, 0);
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);
// Each call returns a node; nothing is computed until the graph is compiled to WGSL or GLSL
const speed = uniform(0.25);
const st = uv();
const sweep = st.x.add(st.y).sub(fract(time.mul(speed)).mul(2.5)).add(0.2);
const band = smoothstep(0.12, 0.0, sweep.abs());
const base = mix(color('#1f5f8b'), color('#2a9d8f'), st.y); // a vertical gradient
const material = new THREE.MeshStandardNodeMaterial({ roughness: 0.5 });
material.colorNode = base; // replaces the base color
material.emissiveNode = vec3(0.9, 0.8, 0.5).mul(band); // added after lighting
const book = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1, 0.14), material);
scene.add(book);
document.getElementById('backend').textContent =
`Backend: ${renderer.backend.isWebGPUBackend ? 'WebGPU (WGSL)' : 'WebGL 2 (GLSL)'} · the time node is updated by the renderer.`;
renderer.setAnimationLoop((t) => {
book.rotation.y = Math.sin(t / 1500) * 0.5;
renderer.render(scene, camera);
});
</script>