Skip to content

Shaders (TSL) ​

Kiln renders with WebGPURenderer, so custom materials are written in TSL, Three.js's node shading language, which compiles to WGSL on WebGPU and GLSL on the WebGL2 fallback. Everything below is verified on Three.js 0.186.1 by the vendored water plugin (kiln add water for a full working example).

ts
import * as THREE from 'three'
import { MeshStandardNodeMaterial } from 'three/webgpu'      // node materials come from three/webgpu
import { uniform, positionGeometry, sin, mix, smoothstep, normalize, varying,
  transformNormalToView, vec3, vec4, float, texture, uv, Fn } from 'three/tsl'

// Uniforms. Drive time from the deterministic sim clock, not TSL's wall-clock `time`.
const uTime = uniform(0)
const uHeight = uniform(0.3)
const uDeep = uniform(new THREE.Color(0x1b5e8c))
const uCrest = uniform(new THREE.Color(0x9fd8f0))
// in an Update system:  uTime.value = time.elapsed + (time.alpha - 1) * time.step

const mat = new MeshStandardNodeMaterial({ roughness: 0.3 })

// Vertex displacement: read the undisplaced vertex from positionGeometry.
const p = positionGeometry
const h = sin(p.x.mul(0.55).add(uTime.mul(1.1))).mul(uHeight)   // .add .mul .negate, JS numbers ok
mat.positionNode = vec3(p.x, p.y.add(h), p.z)

// Normals for displaced geometry are in view space; varying() computes them per vertex.
const dhdx = sin(p.x.mul(0.55).add(uTime.mul(1.1)).add(Math.PI / 2)).mul(uHeight).mul(0.55)
mat.normalNode = normalize(varying(transformNormalToView(normalize(vec3(dhdx.negate(), 1, 0)))))
// (or: new MeshStandardNodeMaterial({ flatShading: true }) for faceted normals)

// Colour: pass a vertex value to the fragment stage with varying(), blend with mix/smoothstep.
mat.colorNode = mix(uDeep, uCrest, smoothstep(uHeight.negate(), uHeight, varying(h)))

// Textures, tiled UVs, and reusable functions:
const tint = Fn(([c]: [any]) => c.mul(vec4(1, 1, float(0.5), 1)))
// mat.colorNode = tint(texture(myTexture, uv().mul(2)))

commands.spawn(Transform, MeshRef(new THREE.Mesh(new THREE.PlaneGeometry(20, 20, 64, 64).rotateX(-Math.PI / 2), mat)))

Rules ​

  • Plain MeshStandardMaterial and MeshBasicMaterial (including vertexColors and flatShading) work unchanged. Reach for node materials only when you need custom shading.
  • Don't use ShaderMaterial, onBeforeCompile or GLSL strings; they don't run on WebGPU.
  • Drive animated uniforms from time.elapsed, interpolated with time.alpha as above, so the same tick always renders the same frame.
  • Building node materials headless (Bun, scenarios) is fine; nothing compiles until the first render.
  • For per-instance data that changes every step, prefer InstanceColor or an InstancedBufferAttribute on the geometry.