Appearance
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
MeshStandardMaterialandMeshBasicMaterial(includingvertexColorsandflatShading) work unchanged. Reach for node materials only when you need custom shading. - Don't use
ShaderMaterial,onBeforeCompileor GLSL strings; they don't run on WebGPU. - Drive animated uniforms from
time.elapsed, interpolated withtime.alphaas 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
InstanceColoror anInstancedBufferAttributeon the geometry.