An Introduction to TSL
TSL — the Three.js Shading Language — is a way of writing shaders as a graph of TypeScript function calls instead of as a string of GLSL or WGSL. You combine typed nodes (vec3, normalView, mix, pow, ...) and the renderer compiles them to whichever backend it is using, so can support both WebGL or WebGPU from one codebase. And it is the same TypeScript language as the rest of your code.
You get auto-complete, type checking, and the ability to share helpers as ordinary functions. The last one might seem simple but actually has been quite tricky to achieve for shader code, which has frequently relied on ugly, fragile string manipulation to reuse chunks of shader code.
We are going to build up from a very simple example to a soft, rim-lit torus knot using a fresnel lighting. This is actually quite simple, but already looks amazing.
All of the examples will use WebGPURenderer from three/webgpu. They are rendered with React Three Fiber; however the same nodes work in plain GLSL.
A flat colour
The smallest interesting TSL program is one that returns a constant colour. meshBasicNodeMaterial takes a colorNode — any TSL node that evaluates to a vec3 — and uses that as the surface colour.
import { vec3 } from 'three/tsl'
const colorNode = vec3(0.2, 0.55, 0.9)
// ...
<mesh>
<sphereGeometry args={[1.2, 64, 64]} />
<meshBasicNodeMaterial colorNode={colorNode} />
</mesh>
vec3(0.2, 0.55, 0.9) is a node, not a runtime value — TSL will compile it into the shader as a constant.
Visualising position and normal
Here we'll look at little icosahedrons. One uses positionLocal and the other uses normalView nodes to colour the surface. positionLocal is the position of each point on the surface relative to the centre of the shape. normalView is the surface normal (a unit vector perpendicular to the surface) transformed into view space. View space is the coordinate system where the camera is at the origin looking down the positive Z axis, so normalView.z is how much the surface faces towards or away from the camera. When you rotate the shape (move the camera) this one stays locked in place (as the normals rotate with the surface) and the other stays locked to the shape as the position itself isn't changing.
Fresnel lighting
The Fresnel effect is named after Augustin-Jean Fresnel, who worked out how light behaves at the boundary between two materials. The full equations are complex, but the idea is simple: when you look straight at a surface, most of the light goes into the material (it is absorbed, scattered, transmitted). When you look at the surface at a glancing angle, more of the light is reflected back at you.
This is why a lake looks like a mirror when you stand at its edge and look across at the far shore, but transparent when you look down at your feet. It is why the rim of a soap bubble glows, and why polished snooker balls have a bright halo around the silhouette. The reflectance depends on the angle between the viewer and the surface normal.
We can use a cheap approximation in real-time graphics:
fresnel = (1 - cos(theta))^k
where theta is the angle between the view vector and the surface normal, and k is a sharpness exponent.
If we are working in view space and our view direction is (0, 0, 1), then cos(theta) is exactly the Z component of the normal. So 1 - |normalView.z| is a perfectly serviceable fresnel mask: zero where the surface faces the camera, Z near 1; and one where the surface is edge-on to the camera, Z near 0.
import { normalView, oneMinus, abs, vec3 } from 'three/tsl'
const fresnel = oneMinus(abs(normalView.z))
const colorNode = vec3(fresnel, fresnel, fresnel)
oneMinus(x) is the TSL equivalent of 1.0 - x, and abs(x) is its absolute value.
Putting it together
The mask above is the raw material; the final step is to shape it and use it as a blend between two colours. We do three things:
- Wrap the logic in
Fn(() => ...)so it becomes a reusable TSL function. - Sharpen the fresnel falloff with
pow(..., 2.5). A higher exponent pushes the bright band closer to the silhouette and makes the glow feel tighter. mixbetween a deep "core" colour (where the surface faces the camera) and a bright "rim" colour (at glancing angles). We will use a torus knot for a more interesting result.
import {
Fn,
normalView,
oneMinus,
abs,
pow,
mix,
vec3,
} from 'three/tsl'
const fresnelGlow = Fn(() => {
const core = vec3(0.02, 0.18, 0.35)
const rim = vec3(0.5, 0.95, 0.8)
const fresnel = pow(oneMinus(abs(normalView.z)), 2.5)
return mix(core, rim, fresnel)
})
const knotColorNode = fresnelGlow()
// ...
<mesh>
<torusKnotGeometry args={[1, 0.32, 220, 32]} />
<meshBasicNodeMaterial colorNode={knotColorNode} />
</mesh>
Where to next
TSL covers a lot more than colour nodes. There are positionNode and normalNode hooks for deforming geometry, attribute nodes for reading custom vertex data, and Fn lets you build up a small library of reusable shading helpers. Because it is just TypeScript, you can put your nodes in modules, test them, and share them across projects.
The official TSL documentation is the best reference, and the Three.js examples include many node-material demos that are worth reading. The fresnel idea here is one of the most-used shading tricks; once you can write it as a graph of nodes you have a template for a huge range of stylised looks.
Alternatives
I've also been playing around with TypeGPU recently however this is a much less mature project, doesn't work well with Next.js and seems a bit flakey in practice. It also only supports WebGPU, whereas TSL works with both WebGL and WebGPU, albeit with cost of having to use the rather heavyweight Three.js.