Skip to content

Blend Water Demo

App: apps/blend_water_demo/

Demonstrates the Blender water tag (engine PLM-274 / PLM-275 + WTR2 #3, ADR 0092 + ADR 0093 + ADR 0097) — the artist-authoring on-ramp for water — driven all the way through to the underwater experience. Either tag a mesh plume3d_water (the water is the modelled surface), or — as this demo now shows — tag a box plume3d_water="realistic" plus plume3d_water_volume and Scene.loadBlendScene does three things with no Wren wiring for the water at all:

  1. resolves the Realistic variant — plume3d_water="realistic" maps to the water_realistic shader + its per-variant set-5 param block (WTR2 #3, ADR 0097; before this the tag could only reach the superseded water_pbr);
  2. generates a level water surface at the box’s top face (no water geometry authored); and
  3. registers the water volume as the active WaterSurfaceState, so when the demo dives the camera below the surface Graphics.cameraWaterState() reports submersion and the underwater post fires — with no manual Graphics.setWaterPlane call. This is the piece WTR2 #3 wired: authored .blend water now drives the underwater path end-to-end.

It is the water counterpart of the plume3d_terrain tag (PLM-254): tag an object, get a feature.

The surface draws with the shared stylized-water core (water_common.slangh — multi-octave Gerstner waves + PBR + world-anchored caustics + crest foam for the Realistic variant), the engine filling the measured camera-position param each frame while the artist’s Blender props set the look.

Terminal window
% ./plume3d blend_water_demo

The asset Models/WaterRealistic.blend (generated headless by tools/testdata/make_water_authored_fixture.py) carries three kinds of object, and one call brings them all in:

ObjectTagBecomes
Seabedplume3d_terraina render node and a static Jolt collider (instantiateTerrain); sloped so the water has a shallow → deep gradient to colour and foam against
Rock_A / Rock_B— (ordinary props)render nodes via instantiateUnits; they poke through the surface as shoreline-foam witnesses
WaterVolumeplume3d_water = "realistic" + plume3d_water_volume = 1 (+ realistic look props)a solid box from whose world top face the engine generates a level Realistic water surface (instantiateWater) with waves, depth colour, caustics and crest foam — and registers the box as the underwater volume so diving into it fires the underwater post. The box itself is never drawn as a solid prop (instantiateUnits skips plume3d_water nodes).

The mesh-tag path (a subdivided plane simply tagged plume3d_water, PLM-274) and the Toon box (Models/WaterVolume.blend, make_water_volume_fixture.py) still ship — both are kept for the loader tests; this demo now shows the Realistic box volume + underwater (WTR2 #3).

The whole level — terrain (+ collider), the tagged Realistic water surface (and its registered volume), and the rock props — loads in one call. There is no water-specific Wren: loadBlendScene composes instantiateTerrain + instantiateWater + instantiateScatter + instantiateUnits (and instantiateUnits skips the plume3d_water mesh, so nothing double-draws). The only per-frame code is reading cameraWaterState() to cross-fade the underwater post as the camera dives:

init() {
_scene = Scene.new()
Graphics.loadShader("shaders/lit") // seabed + rocks
Graphics.loadShader("shaders/water_realistic") // the water surface — named by plume3d_water="realistic"
Graphics.loadShader("shaders/underwater") // the camera-below-surface post (fired via cameraWaterState)
Graphics.loadShader("shaders/sky")
Graphics.loadShader("shaders/present")
// Whole-scene load: terrain (+collider), the box-volume water surface + REGISTERED volume, rock props — in ONE call.
var blend = _scene.loadBlendScene("Models/WaterRealistic.blend")
// Sun + sky + fog + wind + planar reflection (the water reads the reflection + sky/horizon).
// … Graphics.setAmbient / setFog / setSky / setWind / setPlanarReflection(true, 0.0) …
}
// update(dt): dive the camera from above the surface (y=6) to below it (y=-1.6), staying inside the box footprint.
draw() {
Graphics.setViewProjectionEnabled(true)
Graphics.setViewMatrix(_camera.getViewMatrix())
Graphics.setProjectionMatrix(_camera.getProjectionMatrix(aspect))
Graphics.opaqueCaptureEnabled(true) // opaque (seabed+rocks) → copy → transparent (water)
_scene.draw() // draws terrain + rocks (lit) + the tagged water (water_realistic)
// The loader REGISTERED the authored volume, so cameraWaterState just works — no setWaterPlane call.
var ws = Graphics.cameraWaterState() // [submerged, amount, level, fogR, fogG, fogB, fogDensity]
if (ws[1] > 0.02) { // submerged → cross-fade the underwater post on ws[1] (amount)
Graphics.addPostEffect("shaders/underwater", [ws[3], ws[4], ws[5], ws[6], /* … wave/caustic params … */])
} else {
Graphics.addPostEffect("shaders/present", [])
}
}

The one shader wiring the game still does is loading shaders/water_realistic + shaders/underwater (and enabling opaqueCaptureEnabled for the refraction/scene-depth copies) — the water shader is pack content, so the engine never ships or hardcodes a water look. The plume3d_water string names which loaded shader to use, and the underwater post is game-side content cross-faded on the engine-reported submersion amount; the loader supplies the registered surface it reads.

Set a string custom property plume3d_water, on either a water mesh or a box. Its value names the shader:

  • "toon" (or "" / "1" / "true" / "water") → water_toon
  • "realistic" / "pbr" → water_realistic (the WTR2 Realistic variant; "pbr" is a back-compat alias — before WTR2 #3 it wrongly resolved to the old water_pbr)
  • any other value → that value verbatim as a shader basename (a pack’s own water shader)

Box volume (this demo). Also set plume3d_water_volume (a truthy int / any non-empty string) and the engine generates a level surface at the box’s world top face — no water mesh to model or place. Two optional volume-only props tune it: plume3d_water_subdiv (an int, or [subX, subZ]; clamped 1..256 per axis) overrides the auto tessellation, and plume3d_water_level (a float) sets an absolute world-Y waterline for a partial fill.

Tune the look — without touching Wren — with optional float-array custom properties, each overwriting a set-5 custom-material slot (finite-guarded — NaN / ±inf rejected — so a subset is fine). The WaterRealistic.blend fixture tags a box plume3d_water="realistic" and, on top of the shared colour/wave props, sets the Realistic-only knobs (caustics, crest foam, underwater fog) — all range-clamped by the loader:

o["plume3d_water"] = "realistic" # -> water_realistic shader + realistic param block
o["plume3d_water_volume"] = 1 # generate the surface from this box's top face AND register the volume
o["plume3d_water_shallow"] = [0.10, 0.66, 0.70, 0.42] # p0 shallow rgb + p0.w absorptionK (Realistic reads p0.w)
o["plume3d_water_deep"] = [0.01, 0.14, 0.34] # p1 deep colour
o["plume3d_water_foam"] = [0.95, 0.98, 1.0, 3.0] # p2 foam colour + p2.w foamDistance
o["plume3d_water_waves"] = [0.6, 13.0, 0.55, 1.0] # p3 gain,len,steep,speed — keep in lockstep with the buoyancy Water.setWaves(...)
o["plume3d_water_horizon"] = [0.14, 0.30, 0.46, 90.0] # p10 horizon/sky colour + fade start
o["plume3d_water_caustic"] = [0.5, 1.0] # p6 causticScale, causticChroma (clamped 0.001..64 / 0..1)
o["plume3d_water_crest"] = [0.5, 0.7, 0.3, 1.5] # p7 crestFoamThr, crestFoamStr, heightColorAmt, heightNorm (each 0..16)
o["plume3d_water_caustic_strength"] = [1.0] # p11.y (0..8; preserves p11.x=useFoamTex)
o["plume3d_water_fog"] = [0.02, 0.16, 0.30, 0.18] # underwater fog rgb + density for the REGISTERED volume

See instantiateWater for the full tag table (both variants), the Realistic-only props (plume3d_water_pbr / _refract / _caustic / _crest / _caustic_strength / _fog), the auto-registration for underwater, and the Box/volume water contract (plume3d_water_volume / _subdiv / _level).

  • BlendResult.instantiateWater — the loader method the tag drives (the full plume3d_water* contract + the Realistic props + the auto-registration for underwater).
  • Scene.loadBlendScene — the one-call whole-scene load that composes it with terrain, scatter and props.
  • Graphics.cameraWaterState — the submersion state the underwater post reads; the loader registers the surface it reports (no manual setWaterPlane).
  • Water Demo — the stylized water surface this tag draws (the shared water_common.slangh core, both variants, the set-5 param table, the procedural foam/caustics).
  • Water Playground — the interactive dive/buoyancy capstone the same submersion API drives.
  • Water — the CPU height query, if you also need buoyancy on this surface.
  • Additive, LowTide/Lexicon-safe. The Wren surface is one additive method (BlendResult.instantiateWater) plus the plume3d_water* Blender convention and the slot-p9 camera-pos reservation (ADR 0092); the box-volume path (plume3d_water_volume / _subdiv / _level, ADR 0093) and the WTR2 #3 Realistic-variant resolution + per-variant params + auto-registration (ADR 0097) are all folded into the same method — no new Wren signature, no public C++ signature changed (the change is internal to the loader).
  • Underwater is auto-registered, not hand-wired. WTR2 #3 makes instantiateWater register one primary WaterSurfaceState — the bounded box volume here — so cameraWaterState + the underwater post fire on authored water with no manual setWaterPlane. Buoyancy stays app-side — Physics.applyBuoyancy reads a Wren Water the game builds with Water.setWaves(...) matching the authored plume3d_water_waves; loader-owned buoyancy is a follow-up (WTR2 #4).
  • Untrusted-.blend safe. instantiateWater reads only already-parsed properties (strings
    • float arrays); every write into the fixed set-5 params buffer is at a hard-coded slot, the float-array override loop is capped at four components and finite-guarded, and meshIndex is bounds-checked — no new byte parsing. The box-volume path finite-guards every box vert / transform / world corner / plume3d_water_level (a non-finite or zero-area box is skipped) and hard-clamps the subdivision to 1..256 per axis so a giant or hostile box cannot inflate the generated mesh. The WTR2 #3 Realistic props go further — the numeric knobs (specPower, heightNorm, causticScale, …) are finite-guarded and range-clamped in a pure, unit-tested guard header (water_prop_clamp.h), because a finite-but-huge value is unsafe for a pow() exponent or a UV multiplier (blocking /security-review run — ADR 0092 §Security + ADR 0093 §Security + ADR 0097).
  • Box volumes are level rectangles. The generated surface is a horizontal rectangle from the box’s world AABB. Non-rectangular volumes, a bottom-face floor tint, and tilted water surfaces are non-goals (ADR 0093); so are per-frame near/far auto-fill and flow-along-a-tagged-curve (that last is the spline river).