An Introduction to TSL

TSL (the Three.js Shading Language) lets you write shaders as a graph of TypeScript function calls rather than as a string of GLSL or WGSL. You combine typed nodes (vec3, normalView, mix, pow and so on) and the renderer compiles them to whichever backend it is using, so one codebase supports both WebGL and WebGPU.

The practical benefit is that it is just TypeScript. You get auto-complete, type checking, and the ability to share helpers as ordinary functions. That last one sounds mundane but has been surprisingly hard to achieve for shader code, which has usually relied on fragile string manipulation to reuse chunks.

We are going to build up from a very simple example to a rim-lit torus knot using fresnel lighting. It is quite simple but already looks good. All of the examples use WebGPURenderer from three/webgpu and are rendered with React Three Fiber, though the same nodes work in plain GLSL.

A flat colour

The smallest interesting TSL program returns a constant colour. meshBasicNodeMaterial takes a colorNode, which is 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>

Note that vec3(0.2, 0.55, 0.9) is a node rather than a runtime value. TSL compiles 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 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, which is the coordinate system where the camera is at the origin looking down the positive Z axis. So normalView.z tells you how much the surface faces towards or away from the camera.

When you rotate the shape, 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 and is absorbed, scattered or 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 to the far shore, but transparent when you look down at your feet. It is also why the rim of a soap bubble glows.

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| gives us 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. Now we shape it and use it to blend between two colours. There are three steps:

  1. Wrap the logic in Fn(() => ...) so it becomes a reusable TSL function.
  2. Sharpen the fresnel falloff with pow(..., 2.5). A higher exponent pushes the bright band closer to the silhouette and makes the glow tighter.
  3. mix between 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 next?

TSL offers 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 fresnel idea here is one of the most-used shading tricks, and once you can write it as a graph of nodes you have a template for a wide range of stylised looks.

At the time of writing React Three Fiber is working on a major new version (10) that is likely to offer better out of the box support for TSL. It is currently in early alpha release.

Alternatives

I've also been playing around with TypeGPU recently. It 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 at the cost of having to use the rather heavyweight Three.js.

Learn More

  • I've been working on a fairly comprehensive book of TSL examples, with around 100 progressively complex examples covering fragment shaders, vertex shaders and techniques like SDFs.
  • The official TSL documentation is the best reference.
  • The Three.js examples include many node-material demos that are worth reading.