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:
- resolves the Realistic variant —
plume3d_water="realistic"maps to thewater_realisticshader + its per-variant set-5 param block (WTR2 #3, ADR 0097; before this the tag could only reach the supersededwater_pbr); - generates a level water surface at the box’s top face (no water geometry authored); and
- registers the water volume as the active
WaterSurfaceState, so when the demo dives the camera below the surfaceGraphics.cameraWaterState()reports submersion and the underwater post fires — with no manualGraphics.setWaterPlanecall. This is the piece WTR2 #3 wired: authored.blendwater 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.
Run from root
Section titled “Run from root”% ./plume3d blend_water_demoThe scene
Section titled “The scene”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:
| Object | Tag | Becomes |
|---|---|---|
Seabed | plume3d_terrain | a 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 |
WaterVolume | plume3d_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).
What it does
Section titled “What it does”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.
Authoring the tag in Blender
Section titled “Authoring the tag in Blender”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 oldwater_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 blocko["plume3d_water_volume"] = 1 # generate the surface from this box's top face AND register the volumeo["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 colouro["plume3d_water_foam"] = [0.95, 0.98, 1.0, 3.0] # p2 foam colour + p2.w foamDistanceo["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 starto["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 volumeSee 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 fullplume3d_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 manualsetWaterPlane).- Water Demo — the stylized water surface this tag draws (the shared
water_common.slanghcore, 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.
Notes and limitations
Section titled “Notes and limitations”- Additive, LowTide/Lexicon-safe. The Wren surface is one additive method
(
BlendResult.instantiateWater) plus theplume3d_water*Blender convention and the slot-p9camera-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
instantiateWaterregister one primaryWaterSurfaceState— the bounded box volume here — socameraWaterState+ the underwater post fire on authored water with no manualsetWaterPlane. Buoyancy stays app-side —Physics.applyBuoyancyreads a WrenWaterthe game builds withWater.setWaves(...)matching the authoredplume3d_water_waves; loader-owned buoyancy is a follow-up (WTR2 #4). - Untrusted-
.blendsafe.instantiateWaterreads 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
meshIndexis 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 to1..256per 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 apow()exponent or a UV multiplier (blocking/security-reviewrun — ADR 0092 §Security + ADR 0093 §Security + ADR 0097).
- 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
- 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).