Water Demo
App: apps/water_demo/
Demonstrates the stylized Water pack (engine PLM-273) — the surface half of Plume3D’s water content. It renders stylized water in two variants side by side that share one core: PBR (realistic) on the left, Toon (anime / Inasa-Fujio-style) on the right, over the same waves. Like the Toon pack and the Foliage pack, the whole look is pure pack content — it touches no engine source, no descriptor layout, and no frozen contract. It is built entirely on capabilities the engine already ships: the set-5 custom material params, scene depth for mesh shaders (PLM-263, binding 5), the A#3 opaque-colour capture (binding 6), and A#4 planar reflection (binding 7).
Run from root
Section titled “Run from root”% ./plume3d water_demoA beach-sloped seabed gives a clean shallow→deep gradient under the water; rocks poke through the
surface as refraction and shoreline-foam witnesses. The left half is drawn with the PBR water
shader and the right half with the Toon shader — the same Gerstner waves run on both, so
the two surfaces line up at the x = 0 seam. The demo captures the above-water shot at frame 70,
then dips the camera below the surface for a second shot at frame 150 (see
Below the surface below), and exits at frame 170.
The shaders
Section titled “The shaders”The two variants are thin: everything geometric and physical lives in one shared include, and
each variant supplies only the style remap (the colour ramp / foam edge / specular). A shared
include must be a .slangh (not .slang) so the apps/*/shaders/src/*.slang build glob does not
try to compile it standalone.
| File | Kind | What it is |
|---|---|---|
shaders/src/water_common.slangh | shared core (mesh ABI) | The shared menu the two variants draw from. It provides: a flat vertex path and a 3-Gerstner-wave vertex path (hand-unrolled, MoltenVK-safe, with an analytic normal) steered by the global wind; the PLM-263 depth read; and the Roystan depth-foam mask (a depth-scaled cutoff over scrolling + distorted procedural noise). It also carries A#3 refraction and Schlick fresnel helpers, plus planar-reflection / caustic / rain-bloop helpers that the faithful 1:1 shaders no longer call. #include "plume3d.slang". |
shaders/src/water_pbr.slang | variant remap (realistic) | The gameidea “stylized water” look, kept clean: the same dominant depth-based 2-tone colour (lerp(shallow, deep, saturate(depthDiff / depthMaxDistance))) so waves never read as dark bands, plus gentle Gerstner motion, a light refraction into the shallows, a soft fresnel sky rim, one small sun glint, and the shared crisp Roystan depth-foam. No heavy planar-reflection mix. |
shaders/src/water_toon.slang | variant remap (anime) | A faithful 1:1 port of Roystan’s Toon Water — a flat plane with exactly two things: a depth-based 2-tone colour (lerp(shallow, deep, saturate(depthDiff / depthMaxDistance))) and crisp white depth-foam. No waves, no surface normals, no specular, no fresnel, no reflection — that flatness is the look. |
What it does
Section titled “What it does”-
Loads the two water shaders (plus a lit scene shader, a gradient sky, and a passthrough present):
_pbr = Graphics.loadShader("shaders/water_pbr")_toon = Graphics.loadShader("shaders/water_toon") -
Enables the atmosphere and the global feeds — a gradient sky (the fresnel sky-rim colour) and planar reflection (A#4 → binding 7, set up but not sampled by the faithful shaders):
Graphics.setSky("shaders/sky", 0, [/* horizon rgb */, /* zenith rgb */])Graphics.setWind(1.0, 0.0, 0.25, 0.9) // steers the Gerstner waves + ripple flowGraphics.setPlanarReflection(true, 0.0) // mirror plane at the water surface -
Builds each water surface as a subdivided plane (
MeshGen.plane— the verts the Gerstner displacement needs), marks it transparent so it renders in the post-copy phase, and sets the shared params via the set-5 custom-material path.setCustomColor’s first argument is a byte offset (0→customParam(0),16→customParam(1), …), not a slot index:var m = MeshGen.plane(100, 200, 48, 96)m.blendMode = "alpha" // classify as transparent → samples the opaque copym.setCustomColor(0, 0.10, 0.84, 0.92, 0.0) // p0 shallowColorm.setCustomColor(16, 0.02, 0.26, 0.60, 5.0) // p1 deepColor + p1.w depthMaxDistance (shallow→deep run)// … p2 foam (+p2.w foamDistance), p3 waves, p4 flow/normal, p5 refract/fresnel/surfaceNoiseScale/spec, p7 foam cutoff/scroll/distort, p8 camera planes, p9 cam pos, p10 horizon -
Each frame in
draw(): drives view/projection explicitly, enables opaque capture (the shared gate for both the A#3 opaque colour and the PLM-263 scene depth), draws the opaque seabed and rocks, then the two transparent water halves — PBR on the left, Toon on the right — and adds a passthrough post effect (the offscreen path opaque capture needs):Graphics.opaqueCaptureEnabled(true) // split opaque → copy → transparentGraphics.drawMesh(_seabed, _lit, /* sloped */) // opaque phase (depth + colour behind the water)for (r in _rocks) Graphics.drawMesh(_rock, _lit, /* … */)Graphics.drawMesh(_waterPbr, _pbr, /* x < 0 */) // transparent phase — reads the opaque copiesGraphics.drawMesh(_waterToon, _toon, /* x > 0 */)Graphics.addPostEffect("shaders/present", [])
Below the surface (underwater post)
Section titled “Below the surface (underwater post)”Once the above-water shot is captured, the demo moves the camera below the water plane and
switches its present pass to an underwater post effect (shaders/underwater) — so dipping
under the surface reads as being submerged, not just a lower camera. The post effect tints the
whole frame toward the deep-water colour, adds depth fog, a gentle screen wobble, a
caustic shimmer, and a vignette.
It is a plain Graphics.addPostEffect swap, gated
on the camera’s world Y: while the camera is below the surface it adds the underwater effect,
otherwise the usual passthrough present.
if (_camNode.getPosition()[1] < 0.0) { // p0 deepColor + fogDensity, p1 (wobble, tintStrength, near, far) Graphics.addPostEffect("shaders/underwater", [0.04, 0.22, 0.30, 0.10, 0.006, 1.0, 0.5, 120.0])} else { Graphics.addPostEffect("shaders/present", [])}Like the rest of the pack this is pure content — a post shader on the public post ABI, no
engine change. It captures water_demo_underwater.png at frame 150.
Procedural foam, caustics & rain
Section titled “Procedural foam, caustics & rain”The foam is a faithful 1:1 port of the tutorials it follows (gameidea.org stylized water
and Roystan’s toon water). As of PLM-275 it samples a
real, tileable Perlin _SurfaceNoise texture warped by a _SurfaceDistortion texture — exactly
Roystan’s setup, and the crisp, detailed cel foam — when p11.x = useFoamTex is set (the committed
water / river / buoyancy demos bind both and set it; see Foam textures
below). It falls back to an equivalent procedural fbm (no textures required) for water that has
no game-side handle to bind them — e.g. a loader-tagged Blender water surface.
The faithful toon and PBR shaders use only the depth-scaled foam below; the caustics and
rain-bloop helpers remain in the shared core but are no longer called (they were part of the
earlier, busier water that the PLM-275 faithful rewrite replaced), kept for reuse.
- Depth-scaled shoreline foam (Roystan). Instead of a fixed noise cutoff, the core scales the
cutoff with depth:
cutoff = saturate(depthDiff / foamDistance) * surfaceNoiseCutoff—0right at the shoreline (or where an object breaks the surface), rising tosurfaceNoiseCutoff(Roystan0.777,p7.x) in deeper water — wherefoamDistanceisp2.w. The shaders then dofoam = smoothstep(cutoff ∓ AA, noise)(a razor0.02AA for the smooth Perlin texture, a wider0.04for the blockier procedural fallback), so foam is solid white at contacts/shore, breaks into crisp animated lines just past them, and is gone in deep water — the exact depth-buffer shoreline foam Roystan builds, rather than uniform scum everywhere. - Churning foam edges (
_SurfaceDistortion). Roystan warps the foam-noise UV by a_SurfaceDistortiontexture so the foam edge moves like whitewater. WithuseFoamTexon, the foam samples the real_SurfaceNoisePerlin texture (custom slot 0) at a UV warped by the_SurfaceDistortiontexture (custom slot 1, RG) — that pairing is what gives the crisp, detailed cel foam. The warp strength issurfaceDistortAmount(p7.w, Roystan0.27). The procedural fallback substitutes a 3-octave scrolling fbm for_SurfaceNoiseand a second, slower noise field for_SurfaceDistortion, so its edges still churn and wobble instead of sitting as static blobs. - Procedural caustics (core helper — not used by the faithful shaders). Two opposed-scrolling
noise fields combined as
1 − |a − b|give sharp voronoi-like cell edges (the classic caustic sparkle), projected on the seen-through bottom and faded by water depth (strongest in the shallows). - Rain “bloops” (core helper — not used by the faithful shaders). A
3×3grid of expanding impact ring-ripples — each ring crest tilts the surface normal radially outward and leaves a foam speck — the water counterpart of the wet-ground splash ripples the Weather pack asked for.
All of this is still #include "plume3d.slang" mesh-ABI content — no engine change.
Tuning the look — shared set-5 custom params
Section titled “Tuning the look — shared set-5 custom params”Both variants read the same param layout (byte offset = slot × 16). The faithful Toon shader
uses p0–p2, p5.z, p7 and p8; the PBR shader additionally uses p3 (waves), the rest
of p5, p9 and p10; both read p11.x (the foam-texture toggle). All are re-tunable per mesh
with no engine recompile. The layout below
mirrors the authoritative header of engine/shaders/include/water_common.slangh.
| Slot | Meaning |
|---|---|
p0.rgb | shallow water colour |
p1.rgb, p1.w | deep water colour, depthMaxDistance — world distance over which shallow→deep runs |
p2.rgb, p2.w | foam colour, foamDistance — Roystan _FoamDistance (foam reach) |
p3 | Gerstner (waveGain, waveLength, waveSteepness, waveSpeed) — PBR only; the Toon plane is flat |
p4.zw | ripple (flowSpeed, normalStrength) |
p5 | (refractStrength, fresnelF0, surfaceNoiseScale, specPower) |
p7 | (surfaceNoiseCutoff, foamScrollX, foamScrollY, surfaceDistortAmount) — Roystan foam cutoff (0.777), scroll, and distortion amount (0.27) |
p8 | camera (near, far, screenW, screenH) — for depth linearization + the screen-UV grab |
p9.xyz | camera world position — for view direction / fresnel (engine-reserved on scene-drawn nodes) |
p10.rgb, p10.w | horizon/sky colour, infini-water fade start |
p11.x | useFoamTex — 1 = sample the real Perlin foam textures in custom slots 0 + 1; 0 = the procedural fbm fallback (PLM-275, see Foam textures) |
p6 — the old Toon stepped-glint / sparkle / band / rim knobs — and the rest of p11 (the old
rain-bloop + caustic strengths) are no longer read by the faithful shaders. p11.x is now reused
as the useFoamTex flag above.
Foam textures (real Perlin noise)
Section titled “Foam textures (real Perlin noise)”When p11.x = useFoamTex is 1, the foam samples two real, tileable textures — bound through the
mesh’s custom-texture slots, the same mechanism any pack shader uses — instead of the procedural noise:
| Custom slot | Texture | Role |
|---|---|---|
0 | water_noise.png | Roystan _SurfaceNoise — the Perlin foam pattern |
1 | water_distortion.png | Roystan _SurfaceDistortion — RG UV-warp that churns the foam edge |
_noiseTex = Texture.load("textures/water_noise.png") // slot 0 = _SurfaceNoise_distTex = Texture.load("textures/water_distortion.png") // slot 1 = _SurfaceDistortionm.setCustomTexture(0, _noiseTex)m.setCustomTexture(1, _distTex)m.setCustomColor(176, 1.0, 0.0, 0.0, 0.0) // p11.x = useFoamTex → sample the texturesBoth textures ship, generated by tools/testdata/make_water_foam_textures.py (pure-Python Pillow,
seamlessly tileable periodic Perlin — the lattice coords wrap mod the period so the scrolling foam
never seams). Loader-created water (a Blender plume3d_water surface)
has no game-side handle to bind these, so it keeps the procedural fbm fallback.
A world-space plane cannot derive its NDC from SV_Position, so the shared core carries a
clip-space ScreenPos varying and computes a resolution-free screen UV (xy / w * 0.5 + 0.5)
for the opaque-colour and reflection grab-pass.
- Mesh — custom material (set 5) — the
setCustomColor/setCustomParamFloatparams the whole look is tuned through (no engine change). Graphics.opaqueCaptureEnabled— the single gate for both the opaque colour copy (A#3, binding 6) and the opaque depth copy (PLM-263, binding 5).Graphics.setPlanarReflection— the A#4 reflection target the water samples viaplume3d_reflection(binding 7).- MeshGen —
plane(the subdivided water surfaces) andcone(the rocks). - The mesh ABI
plume3d.slang— the shipped shader include the pack is built on (plume3d_sceneDepthLoad/plume3d_linearizeDepth,plume3d_opaqueColor,plume3d_reflection,plume3d_customParam,plume3d_time,plume3d_wind).
Notes and limitations
Section titled “Notes and limitations”- Pure content, not an engine feature. The Water pack is composed entirely from already-shipped
capabilities — set-5 custom params, PLM-263 scene depth, A#3 opaque colour and A#4 planar
reflection. No engine source, no ADR, no
/security-review(the same posture as the Toon and Foliage packs). See the enabler examples: Scene Depth, Refraction, Planar Reflection. - This is the surface of the Water pack — and more has now shipped on it. Three of the
earlier follow-ups have landed: spline rivers with flow (see the
River Demo — the shared water core swept along a
Splineribbon, flowing off the wind vector), the underwater camera post (above, now part of this demo), and Jolt buoyancy (the newWaterheight query +Physics.applyBuoyancy, engine PLM-270 / PLM-271, ADR 0091 — see the Buoyancy Demo). The rain-bloop and caustic helpers remain in the shared core (see Procedural foam, caustics & rain) but the PLM-275 faithful 1:1 rewrite no longer calls them — the toon and PBR shaders are now flat depth colour + crisp depth-foam (plus the PBR extras), matching the reference tutorials. Still not yet shipped (documented PLM-273 follow-ups): a masked boat that keeps water out of an open hull (see the Water Mask demo — the mask exists, the buoyant open hull does not), and per-bend river flow. Custom water textures have partly landed: as of PLM-275 the foam optionally samples real Perlin_SurfaceNoise+_SurfaceDistortionmaps (see Foam textures); the normal and caustic stand-ins remain procedural, ready to swap. - The fresnel sky rim uses the
p10horizon colour. The faithful PBR shader no longer samples the planar-reflection pass; it tints the grazing edge toward thep10horizon colour instead — set it to match your sky. - Single fixed sun direction for the PBR sun glint (the Toon surface is flat and has none). Wiring a scene light into the pack shader is left to the consuming game.