Blog · · 4 min read
Finished Three.js Journey, and why TSL is seriously powerful
I finished Three.js Journey. What stuck with me most: TSL, the new Three.js Shading Language. It got my 3D portfolio done in no time.
I finished Three.js Journey by Bruno Simon. The course takes you from a first spinning cube to physics, custom shaders and optimising your scene. A real recommendation if you want to get into 3D on the web.
What stuck with me most is TSL, the new Three.js Shading Language. It's seriously powerful. Below I'll show why with a few small pieces of code.
What is TSL?
You used to write a custom shader in GLSL, as strings inside your JavaScript. That works, but there's no autocomplete, you only see errors when the shader compiles, and combining it with the existing Three.js materials is a pain.
With TSL you just write your shader in JavaScript, built from nodes. Three.js turns that into WGSL for WebGPU, or into GLSL when the browser falls back to WebGL2.
You also don't have to write the whole shader yourself anymore. A node material has inputs like positionNode, colorNode and emissiveNode. You only change what you need. The rest (lighting, shadows, tone mapping) keeps working.
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { color, mix, normalLocal, positionLocal, sin, time, uniform, uv } from 'three/tsl'
const material = new MeshStandardNodeMaterial()
const amplitude = uniform(0.15)
// Position: push every vertex along its normal with a moving sine wave
const wave = sin(positionLocal.y.mul(4).add(time)).mul(amplitude)
material.positionNode = positionLocal.add(normalLocal.mul(wave))
// Colour: blend from deep blue to cyan over the height of the mesh
material.colorNode = mix(color(0x1e3a8a), color(0x22d3ee), uv().y)
// Emission: a soft pink glow that pulses over time
material.emissiveNode = color(0xf472b6).mul(sin(time.mul(2)).mul(0.5).add(0.5))
With a few lines the surface ripples, the colour runs from blue to cyan over the height and a pink glow pulses. No ShaderMaterial or onBeforeCompile, and you keep all the lighting of MeshStandardNodeMaterial.
Computing on the GPU with instancedArray
Where TSL really makes the difference for me is compute. With instancedArray you create a storage buffer that lives on the GPU. A compute shader updates that buffer every frame and your material reads the same buffer to draw it. So the data never has to go back to the CPU.
An example: 100,000 particles with gravity, fully on the GPU.
import { SpriteNodeMaterial, Sprite } from 'three/webgpu'
import { Fn, If, deltaTime, hash, instanceIndex, instancedArray, vec3 } from 'three/tsl'
const count = 100_000
const positions = instancedArray(count, 'vec3')
const velocities = instancedArray(count, 'vec3')
// Runs once on the GPU: scatter the particles in a 20 m cube
const init = Fn(() => {
const position = positions.element(instanceIndex)
position.assign(vec3(hash(instanceIndex), hash(instanceIndex.add(1)), hash(instanceIndex.add(2))).sub(0.5).mul(20))
})().compute(count)
// Runs every frame on the GPU: gravity, and back to the top when a particle falls out
const update = Fn(() => {
const position = positions.element(instanceIndex)
const velocity = velocities.element(instanceIndex)
velocity.addAssign(vec3(0, -9.81, 0).mul(deltaTime))
position.addAssign(velocity.mul(deltaTime))
If(position.y.lessThan(-10), () => {
position.y.assign(10)
velocity.assign(vec3(0))
})
})().compute(count)
// The material reads the same buffer: no copy back to the CPU
const material = new SpriteNodeMaterial()
material.positionNode = positions.toAttribute()
const particles = new Sprite(material)
particles.count = count
scene.add(particles)
await renderer.init()
renderer.compute(init)
renderer.setAnimationLoop(() => {
renderer.compute(update)
renderer.render(scene, camera)
})
On the CPU you'd loop over 100,000 positions every frame and then send them to the GPU again. Here the graphics card does the work in parallel and your JavaScript does almost nothing. Effects that used to be too heavy for the browser are suddenly just possible.
From course to portfolio
With this knowledge I had my new portfolio done in no time. It's a 3D universe: you fly a spaceship between planets, land and walk across the surface to the parts of my work.
The planets are generated procedurally, in a low-poly style with terraces and hard colour bands. The colours, the atmosphere and the glow are all TSL nodes. Because everything lives in JavaScript, the surface, the landing and the walking all use the same source (no separate shader files getting out of sync). And if a browser doesn't have WebGPU yet, Three.js falls back to WebGL2 automatically.
Thanks to Bruno Simon for a great course. Want to get started with 3D on the web? Start with Three.js Journey and definitely don't skip the TSL part.
Fly through the universe yourself
Check out my portfolio as a 3D universe, built with Three.js and TSL.