Skip to content

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).

Terminal window
% ./plume3d water_demo

A 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 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.

FileKindWhat it is
shaders/src/water_common.slanghshared 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.slangvariant 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.slangvariant 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.
  • 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 flow
    Graphics.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 copy
    m.setCustomColor(0, 0.10, 0.84, 0.92, 0.0) // p0 shallowColor
    m.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 → transparent
    Graphics.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 copies
    Graphics.drawMesh(_waterToon, _toon, /* x > 0 */)
    Graphics.addPostEffect("shaders/present", [])

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.

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 — 0 right at the shoreline (or where an object breaks the surface), rising to surfaceNoiseCutoff (Roystan 0.777, p7.x) in deeper water — where foamDistance is p2.w. The shaders then do foam = smoothstep(cutoff ∓ AA, noise) (a razor 0.02 AA for the smooth Perlin texture, a wider 0.04 for 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 _SurfaceDistortion texture so the foam edge moves like whitewater. With useFoamTex on, the foam samples the real _SurfaceNoise Perlin texture (custom slot 0) at a UV warped by the _SurfaceDistortion texture (custom slot 1, RG) — that pairing is what gives the crisp, detailed cel foam. The warp strength is surfaceDistortAmount (p7.w, Roystan 0.27). The procedural fallback substitutes a 3-octave scrolling fbm for _SurfaceNoise and 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×3 grid 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.

SlotMeaning
p0.rgbshallow water colour
p1.rgb, p1.wdeep water colour, depthMaxDistance — world distance over which shallow→deep runs
p2.rgb, p2.wfoam colour, foamDistance — Roystan _FoamDistance (foam reach)
p3Gerstner (waveGain, waveLength, waveSteepness, waveSpeed) — PBR only; the Toon plane is flat
p4.zwripple (flowSpeed, normalStrength)
p5(refractStrength, fresnelF0, surfaceNoiseScale, specPower)
p7(surfaceNoiseCutoff, foamScrollX, foamScrollY, surfaceDistortAmount) — Roystan foam cutoff (0.777), scroll, and distortion amount (0.27)
p8camera (near, far, screenW, screenH) — for depth linearization + the screen-UV grab
p9.xyzcamera world position — for view direction / fresnel (engine-reserved on scene-drawn nodes)
p10.rgb, p10.whorizon/sky colour, infini-water fade start
p11.xuseFoamTex — 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.

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 slotTextureRole
0water_noise.pngRoystan _SurfaceNoise — the Perlin foam pattern
1water_distortion.pngRoystan _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 = _SurfaceDistortion
m.setCustomTexture(0, _noiseTex)
m.setCustomTexture(1, _distTex)
m.setCustomColor(176, 1.0, 0.0, 0.0, 0.0) // p11.x = useFoamTex → sample the textures

Both 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 / setCustomParamFloat params 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 via plume3d_reflection (binding 7).
  • MeshGen — plane (the subdivided water surfaces) and cone (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).
  • 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 Spline ribbon, flowing off the wind vector), the underwater camera post (above, now part of this demo), and Jolt buoyancy (the new Water height 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 + _SurfaceDistortion maps (see Foam textures); the normal and caustic stand-ins remain procedural, ready to swap.
  • The fresnel sky rim uses the p10 horizon colour. The faithful PBR shader no longer samples the planar-reflection pass; it tints the grazing edge toward the p10 horizon 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.