water
Create realistic procedural water and wet surfaces with Three.js TSL on WebGPU. Use for lakes, rivers, reservoirs, canals, puddles, drainage, fountains and wet materials.
SKILL.mdskills/materials/water/SKILL.md
GitHubWater
Target: Three.js 0.187.0, three/webgpu and TSL node materials from three/tsl; WebGPU only, no WebGL fallback, no GLSL strings.
Water response
Think in terms of:
- reflection
- transmission/refraction
- Fresnel
- depth-dependent absorption
- roughness
- normal waves
- surface displacement
- ripples
- foam
- shoreline effects
- flow direction
- interaction with objects
Do not make all water bright blue.
Scale-specific variants
Distinguish:
- large reservoir/lake water
- canal/river water
- shallow drainage
- puddles
- fountains
- wet concrete
- wet metal
Each has different wave scale, roughness, depth and reflection behaviour.
Surface
Use layered wave scales:
- large gentle waves for silhouette and broad reflection
- medium waves for visible surface structure
- small normal-scale ripples for highlights
Do not use high-frequency displacement everywhere.
Depth
Water should generally become visually deeper with depth. Use attenuation and colour response rather than a flat colour overlay.
Foam
Place foam where physically plausible:
- shorelines
- obstacles
- strong flow
- turbulence
- waterfalls
Do not distribute foam uniformly.
Wet surfaces
Wet concrete, rails and metal are not the same thing as standing water. For wet surfaces, primarily alter roughness/specular response and add subtle darkening/reflection rather than turning the entire surface transparent.
Reflections
Three ways, cheapest first. Pick by what has to appear in the water.
| Need | Use |
|---|---|
| the sky and a general sense of surroundings | scene.environment only: the PMREM from photo-sky; nothing on screen reflects |
| the quay, the boats, the bridge above: what is on screen, seen from a low camera | screen-space reflections (below) |
| a horizon view, the sky dome and a distant coast, exact reflections of hidden geometry | the planar mirror in ocean-water (one extra scene render per frame) |
Screen-space reflections on r187 are a post pass fed by a multi-target scene pass. Water is a
dielectric, so reflectNonMetals: true is required; without it the pass skips every non-metal
surface and the water stays matte. Ripples reach the reflection only through the normal
attachment, so the water material must write its perturbed normal into normalNode.
import * as THREE from 'three/webgpu';
import { pass, mrt, output, normalView, metalness, roughness, packNormalToRGB, unpackRGBToNormal, sample, vec2 } from 'three/tsl';
import { ssr } from 'three/addons/tsl/display/SSRNode.js';
const pipeline = new THREE.RenderPipeline(renderer);
const scenePass = pass(scene, camera);
scenePass.setMRT(mrt({ output, normal: packNormalToRGB(normalView), metalrough: vec2(metalness, roughness) }));
scenePass.getTexture('normal').type = THREE.UnsignedByteType; // bandwidth; optional
const color = scenePass.getTextureNode('output'), depth = scenePass.getTextureNode('depth');
const normalTex = scenePass.getTextureNode('normal'), mr = scenePass.getTextureNode('metalrough');
const normal = sample((uv) => unpackRGBToNormal(normalTex.sample(uv).rgb));
const reflections = ssr(color, depth, normal, { metalnessNode: mr.r, roughnessNode: mr.g, reflectNonMetals: true });
reflections.maxDistance.value = 1; // view units a ray may travel; raise for a wide river
reflections.thickness.value = 0.1; // depth tolerance of a hit
reflections.quality.value = 0.5; // ray-march steps, 0..1; 0.5 is enough for most scenes
pipeline.outputNode = color.add(reflections.rgb);
// each frame: pipeline.render() instead of renderer.render(scene, camera)
- Roughness is the blur. The pass blurs by the roughness attachment (
blurQuality1 to 3). Still water at 0.05 to 0.15 reads as a mirror; wind-ruffled water at 0.3 and above becomes a smear, and the mirror inocean-wateris cheaper per visible pixel there. - Rough water, better.
stochastic: truetraces GGX rays instead of one mirror ray and needs a denoiser: addvelocityto the MRT and chaintemporalReproject(..., { mode: 'specular', accumulate: false })thenrecurrentDenoise(..., { mode: 'specular', accumulate: true })fromthree/addons/tsl/display/, feeding the denoised result back withsetHistory. This is the r187 denoise example; it costs several passes and is for a hero shot, not a default. - Limits. Nothing off screen reflects: the sky beyond the frame, the camera's own side of a
bridge, the coast behind the viewer. The edge fade (
screenEdgeFade, 0.2) hides the cut andscene.environmentfills the rest. TheenvironmentNodeoption wants an equirectangular HDR with CPU-side pixels, not the PMREM, so it is of no use withphoto-sky. - Material. Use a
MeshStandardNodeMaterialorMeshPhysicalNodeMaterialfor SSR water sometalnessandroughnessreach the attachment; an unlit sheet gives the pass nothing to work with. Not run in this catalog; the values are the r187 example's defaults.