Proposal: Water (ponds and lakes, River3D streams, WaterMaterial3D)
Milestone: Gameplay toolkit (G8c) · Status: ⬜ planned ·
Depends on: Terrain (G8a: Terrain3D, the water layer, the edit API, MeshSurface.Custom0,
IEditorViewportTool), Rendering features (G6.1 PBR and G6.2 IBL for WaterMaterial3D;
G6.3 particles for spray; G6.6’s depth prepass), Shaders
(ADR 0144) · Related:
Procedural trees (G8b: the shared wind), Forest showcase (G8d: streams,
fog), Editor viewport tools (G7: handle style), Mobile core (TBDR rules),
Audio, ADR 0149
Problem
Section titled “Problem”The engine has no water: no water surface or material, no flowing water and no way to ask “is this point in water?”. What a water feature would build on is partial:
- No curves. A search of
MainframeEngine/andMainframeEngine.Editor/forCurve3D,Path3DandPathFollow3Dfinds nothing, and there is no 1DCurveeither (G6.3 plans one for particle scale). - Transparency is one blended list.
AlphaMode.Blendis “drawn back to front after the opaque pass, no depth writes, casts no shadow” (MainframeEngine/Src/Rendering/Resources/Material.cs:9-19,:107-119). The mesh renderer sorts blended surfaces by depth (MainframeEngine/Src/Rendering/Meshes/MeshRenderer.cs:646-647) and draws them last (:731-732,MainframeEngine/Src/Servers/RenderServer.cs:475-497). A translucent sheet is the best water this allows. - Built-in 3D materials only. The shader sets are
MeshLit,MeshObjectIdandMeshOutline(MainframeEngine/Src/Rendering/Meshes/PipelineStateCache.cs:6-16). There is no 3DShaderMaterial; new looks are built-in materials (the ADR 0132 pattern, restated by ADR 0149). - Nothing to sample behind a surface. The scene pass clears depth and discards it (
Clear/DontCare, Vulkan renderer → render passes). The frame isPrepareFrame,BeginFrame, shadows, offscreen, one scene pass (MainframeEngine/Src/Core/Engine.cs:511-541). No pass leaves scene depth or a copy of the scene colour for a later draw.RenderTargetcan keep a sampleable depth (MainframeEngine/Src/Rendering/Vulkan/RenderTarget.cs:24-29), but onlySubViewportuses it. Refraction, depth colour, soft shorelines and foam need one or both. - The sky does not reflect. Sky images are 8-bit with one mip (sky.md); IBL arrives with G6.2.
- Shader inputs. The frame set carries time (
frame.clip.z) and alinearizeDepthhelper (MainframeEngine/Content/Shaders/include/frame.slang:17,:23). The vertex is a fixed 32 B (MainframeEngine/Src/Rendering/Meshes/MeshVertex.cs:13), so per-vertex water data waits for G8a’s optionalMeshSurface.Custom0stream. - Gameplay and audio.
CharacterBody3D.Velocity(MainframeEngine/Src/Physics/3D/CharacterBody3D.cs:40) andAudioPlayer3D(MainframeEngine/Src/Audio/Nodes/AudioPlayer3D.cs:17, a point source) exist, and the audio server exposes the listener (MainframeEngine/Src/Audio/AudioServer.cs:242). Nothing slows a character in water or places a sound along a stream.
Painted ponds were first designed for a low-poly game built on the engine, modelled on TerraBrush (MIT): a water depth layer lowers the terrain bottom while the surface stays at the painted height. G8a upstreams the layer. This proposal defines what it means and how it renders in both terrain profiles, then adds flowing water and a water material.
- Ponds and lakes from the terrain’s water layer (
water.png,WaterDepthAt) with per-chunk water meshes: a flat translucent palette sheet for the Faceted profile on today’s renderer,WaterMaterial3Dfor Realistic. River3D: a spline stream with a minimalCurve3Dresource. It generates a ribbon, carves its bed as one undoable terrain edit, derives flow speed from slope, forms small falls, ends cleanly in a pond, and has a gizmo.WaterMaterial3D: a built-in Slang material: flow-advected normals, absorption and scattering, refraction, Fresnel, sky/IBL reflection with optional SSR, foam, sun specular, optional caustics, soft edges.SceneTextures: a depth prepass (shared with G6.6 SSAO) and a mipmapped copy of the opaque colour, with costs, a mobile story and a defined fallback.- Queries:
WaterDepthAt,SurfaceHeightAtandFlowAtover every water body; wading and underwater helpers. - Audio: a stream sound that follows the river’s nearest point, plus falls loops, at 0 B per frame.
- Per-frame code allocates nothing; frames without visible water are bit-identical to today’s.
Non-goals
Section titled “Non-goals”- Oceans, FFT or Gerstner waves, vertex displacement, tessellation, shallow-water simulation.
- Interactive ripples (TerraBrush’s ripple buffer), wakes, a splash system (games spawn particles).
- Swimming, drowning, an engine buoyancy node (decisions).
- Underwater rendering beyond detection; planar reflections, reflection probes, refraction of transparent surfaces.
- Compute shaders (the engine has none; everything here is raster passes).
- Painted flow on lakes in the core steps (the layer reserves channels for it).
Design
Section titled “Design”| Piece | Kind | Ships in | Renders with |
|---|---|---|---|
| Pond and lake meshes | internal MeshInstance3D per terrain chunk with water | G8c.1 | Faceted: StandardMaterial3D (Blend); Realistic: WaterMaterial3D |
Curve3D | resource | G8c.2 | — |
River3D | node, IWaterBody3D | G8c.3–G8c.5 | Faceted: the palette sheet, flat-shaded; Realistic: WaterMaterial3D |
SceneTextures | per-view render resources | G8c.6 | — |
WaterMaterial3D | built-in material, ShaderSetId.Water | G8c.7–G8c.8 | — |
WaterQueries | World3D.Water | G8c.1, G8c.3 | — |
Ponds and lakes from the terrain water layer
Section titled “Ponds and lakes from the terrain water layer”The model is the contract G8a and G8c share. water.png is RGBA8 with one texel per height vertex: R is the depth
fraction d (0 dry … 255 = MaxWaterDepth), G and B are reserved for painted flow (TerraBrush keeps flow there), A is
reserved. At a height vertex with heightmap value h:
| Quantity | Value |
|---|---|
Terrain bottom (rendered, collided, HeightAt, NormalAt, Raycast) | h − d × MaxWaterDepth |
| Water surface | h (the mesh sits ShoreDrop lower, below) |
Terrain3D.WaterDepthAt | d × MaxWaterDepth, interpolated on the same render triangle as HeightAt |
Both terms are linear on the same triangle, so HeightAt + WaterDepthAt is the water surface everywhere, exactly. G8a
applies the lowering once, in its chunk mesh builder, collision faces and queries, so they cannot disagree. A pond is
flat only where h is flat: G8a’s Water mode has a Level option (on by default) that moves h toward a picked
level while it paints depth, so a pond painted into a dip fills to the brim. MaxWaterDepth and its default live in
TerrainData’s water settings (G8a).
The water mesh. Terrain3D keeps one internal, unowned, unsaved MeshInstance3D per chunk that has water, rebuilt
on every change that touches that chunk’s Water or Height layer.
- Triangles: the chunk’s render triangles (same diagonal rule) where any vertex has
d > 0. - Vertices at
h − ShoreDrop. At a dry shore vertex the bank is athand the water just below it, so the shoreline is a clean cut with no z-fighting.ShoreDropis 0.05 m (Faceted) and 0.02 m (Realistic, where soft edges hide it). Custom0per vertex (G8a’s stream):xcolumn depth in metres,yzflow in m/s (0 until painted flow exists),wfoam.WaterMaterial3DreadsxwhenSceneTexturesis off.- Faceted: unshared vertices, so each triangle keeps its flat normal. One shared
StandardMaterial3DwithTransparency = AlphaMode.Blend, the palette’swaterswatch atWaterAlpha0.65, a strong specular, back faces culled. No new shader. The bed is painted with the terrain’s surface paint and reads as shallow water through it. - Realistic: shared vertices (smooth normals) and
WaterMaterial3D. TerrainData’s water settings gainWaterMaterial(Material?; null picks the profile default).- Cost: one triangle per wet terrain triangle; a 64 × 64 m lake at 0.5 m spacing is 32k triangles in a few chunk draws. If G8a’s Realistic chunks have LOD levels, the water mesh is built per level the same way.
Scatter (G8a) skips WaterDepthAt > 0; to skip river channels too it tests the world query
(gameplay queries).
Curve3D
Section titled “Curve3D”A resource with Godot’s Curve3D API subset plus two per-point values Godot does not have, Width and Depth. Points
are cubic Bézier anchors; in and out are control offsets relative to the point, as in Godot.
public sealed class Curve3D : Resource // new, MainframeEngine/Src/Resources/Curve3D.cs{ [Export] public float BakeInterval { get; set; } = 0.2f; // Godot's bake_interval (metres) public int PointCount { get; } public void AddPoint(Vector3 position, Vector3 @in = default, Vector3 @out = default, int index = -1); public void RemovePoint(int index); public void ClearPoints(); // Get/SetPointPosition, Get/SetPointIn, Get/SetPointOut, Get/SetPointTilt (Godot), // Get/SetPointWidth (default 1) and Get/SetPointDepth (default 0) (extensions) public Vector3 Sample(int index, float t); // Bézier on one segment public float GetBakedLength(); public ReadOnlySpan<Vector3> GetBakedPoints(); public Vector3 SampleBaked(float offset, bool cubic = false); public Transform3D SampleBakedWithRotation(float offset, bool cubic = false, bool applyTilt = false); public float SampleBakedWidth(float offset); // extension public float SampleBakedDepth(float offset); // extension public float GetClosestOffset(Vector3 toPoint); public Vector3 GetClosestPoint(Vector3 toPoint);}- Storage: one internal
[Export] float[] Points, 12 floats per point (position, in, out, tilt, width, depth): a compact JSON array, in the style ADR 0011 uses for mesh arrays. Setters callTouch(), which raisesResource.Changed(MainframeEngine/Src/Resources/Resource.cs:45). - Baking is lazy (a dirty flag) and follows Godot: segments subdivide until the chord error is under the interval, then resample at equal arc length with cumulative distances. Tilt, width and depth interpolate with smoothstep between anchors. Baked arrays grow only when the point count grows.
- Sampling is a binary search over the distances;
GetClosestOffsetprojects onto each baked segment. Neither allocates. Godot’s up vectors are left out (decision 8). - Determinism: baking uses only
+ − × ÷andMathF.Sqrt, which IEEE 754 rounds correctly and the .NET JIT never contracts into FMAs, so a curve bakes to the same bits on x64 and arm64. The carve relies on this.
River3D
Section titled “River3D”[EditorIcon("ripple", Family = EditorIconFamily.Space3D)] // Tabler "ripple", added to the icon atlaspublic class River3D : Node3D, IWaterBody3D // new{ [Export] public Curve3D? Curve { get; set; } // anchor Y = water surface height [Export] public Material? Material { get; set; } // null: WaterMaterial3D, or the Faceted terrain's sheet [Export] public bool Faceted { get; set; } // flat-shaded ribbon // Shape, Flow, Carve and Sound groups: see the table below [Export] public string CarveId { get; set; } = ""; // set by the first carve; names its record public bool CarveTerrain(); // false when no Terrain3D lies under the river public bool Recarve(); // restore the last carve's heights, then carve again public bool Uncarve(); [Signal] public event Action? Regenerated;}| Group | Exports (defaults) |
|---|---|
| Shape | SectionLength 1 m, CrossSegments 4, UvLength 4 m (flow per UV.v repeat), EnforceDownhill on |
| Flow | MinSpeed 0.3 m/s, MaxSpeed 4 m/s, SpeedPerSqrtSlope 6, SlopeWindow 6 m, FallSlope 1, FallMinHeight 0.3 m, PlungeDepth 0.4 m |
| Carve | BankWidth 2 m, ShoreLift 0.05 m |
| Sound | StreamSound, StreamVolumeDb −6, FallsSound, FallsVolumeDb −3, Bus (master), MaxFallSounds 4 |
The river regenerates when the curve raises Changed or a Shape or Flow export changes, in the editor and in games;
nothing is generated per frame. Generation is plain C# in RiverBuilder (internal, testable without a GPU) and reuses
its arrays while the section count does not grow. Its internal, unowned, unsaved children are one MeshInstance3D
(the ribbon), a GpuParticles3D per falls (G6.3) and the AudioPlayer3Ds.
Ribbon mesh
Section titled “Ribbon mesh”- Bake the curve. With
EnforceDownhill, clamp the baked surface heights so they never rise along the flow (y[i] = min(y[i], y[i−1])); the curve itself is not changed. - Place cross-sections every
SectionLengthof arc length, plus one at each falls lip and base. - Each section has a horizontal right vector
normalize(tz, 0, −tx)from the tangent (the previous one where the tangent is vertical) andCrossSegments + 1vertices across the width at the surface height, atxfrom −1 to 1. - Vertex data: normal up (or flat per triangle when
Faceted); UV.u = 0 … 1 across (left to right bank) and UV.v = arc length /UvLengthalong the flow;Custom0= column depthdepth(s) × (1 − x²), flow (tangent XZ ×speed(s)×(1 − 0.6x²), slower at the banks) and foam. - One
ArrayMeshsurface. A 500 m stream at the defaults is 501 sections × 5 vertices.
WaterMaterial3D samples its normal map in world XZ, not ribbon UV, so a river and the pond it feeds tile alike. The
ribbon UV drives foam streaks and the falls sheet.
Flow speed from slope
Section titled “Flow speed from slope”On the clamped heights, slope(s) = (y(s − W/2) − y(s + W/2)) / W with W = SlopeWindow, then
speed(s) = clamp(MinSpeed + SpeedPerSqrtSlope × √slope, MinSpeed, MaxSpeed). The square root follows Manning’s
open-channel formula (speed grows with the square root of the slope) without asking for roughness coefficients. A 1 %
slope flows at about 0.9 m/s, a 10 % slope at about 2.2 m/s. Above 2 m/s foam ramps in (w up to 0.6) as rapids.
A falls is a run of the clamped profile whose drop per horizontal metre exceeds FallSlope, with a total drop of at
least FallMinHeight. The ribbon replaces the run with a level surface to the lip, a jet (a vertical section
along x = v·t, y = −½·g·t² from the lip speed v, compressed horizontally if it would land past the base, sections
every 0.25 m, foam 1, double-sided) and a plunge (foam fading over one river width; the carve deepens the bed).
Spray is a GpuParticles3D at the base (white blended billboards, Amount = clamp(8 × drop × width, 8, 64)), so it
arrives with G6.3; without it the falls still render.
Carving the terrain bed
Section titled “Carving the terrain bed”CarveTerrain() writes the channel through G8a’s edit API as one edit, TerrainEdit.Begin(TerrainLayers.Height)
… End(), which rebuilds collision for the touched chunks. The editor wraps it in one TerrainEditAction (G8a), so
Undo restores the heights bit for bit. Games may call it at run time for procedural levels.
For every height vertex inside the river’s bounds (expanded by the widest half width plus BankWidth), in row-major
order:
- Find the nearest baked segment through the river’s grid: arc offset
s, signed lateral distancer, and the interpolated half widthhw, depthDand clamped surface heighty. - Channel (
|r| ≤ hw,x = |r| / hw):h = y + ShoreLift − (D + ShoreLift) × (1 − x²). The bed meets the surface just inside the ribbon’s edge (at about 96 % of the half width forD= 0.6 m): a clean shoreline cut, asShoreDropgives ponds. - Bank (
hw < |r| ≤ hw + BankWidth):h = lerp(y + ShoreLift, original, smoothstep((|r| − hw) / BankWidth)), raising low ground and cutting through high ground. - Plunge pools: within
hwof a falls base,h −= PlungeDepth × (1 − (d / hw)²).
Carve records. Carving current heights again would stack a second channel on the first. The first carve therefore
stores the original heights of the touched rectangle as <scene>_terrain/carve_<CarveId>.png (R16, rectangle and range
in a .meta sidecar), written by the terrain’s SaveExternalData hook (G8a). Recarve() restores the rectangle and
carves the current curve into a new record; Uncarve() only restores. Each is one edit. Sculpting inside an old channel
is lost on re-carve, and the inspector says so.
Determinism. The carve is a pure function of the baked curve, the settings and the original heights, with correctly rounded operations, a fixed loop order and no parallel reduction: the same bits on every OS and CPU (tested).
Joining a pond
Section titled “Joining a pond”The ribbon stops at the first section whose centre lies over another water body (the world query, so a pond, lake or
another River3D works). Bisection along s finds the crossing; the last section goes there at that body’s surface
height. Flow tapers to 0 over the last 3 m and a light foam band (w 0.2) hides the seam. Both surfaces use world-XZ
normals with zero flow at the joint, so the ripples line up. If the clamped river arrives below the pond surface, the
inspector warns (“the river ends 0.3 m below the pond”).
Editor
Section titled “Editor”River3DTool implements G8a’s IEditorViewportTool and is active while one River3D is selected:
- Control points (circles) drag on the terrain with snapping on (to
HeightAt − 0.3 m, so the carve makes a channel); Alt+drag moves vertically. Add (or Ctrl+click) appends a point, Insert splits the nearest segment, Delete removes the selected point. Toolbar actions are icon buttons with tooltips. - Tangent handles (diamonds) of the selected point, symmetric unless Shift is held, as in Godot’s Path3D editor.
- Width handles (squares at both banks, symmetric) and a depth handle below the point, in G7’s handle style with its drag label (“Width 3.20 m”).
- Carve and Re-carve are inspector icon buttons; Uncarve is in the overflow menu.
- Undo: a drag is one step (a snapshot of
Curve3D.Pointsbefore and after); a carve is one step.
The selected river’s centreline and cross-sections draw through SceneViewport.OverlayLines.
WaterMaterial3D
Section titled “WaterMaterial3D”public sealed class WaterMaterial3D : Material // new, built-in; ShaderSetId.Water{ [Export] public Texture2D? NormalMap { get; set; } // null: the built-in procedural normal map [Export] public Texture2D? FoamTexture { get; set; } // null: built-in procedural foam [Export] public Texture2D? CausticsTexture { get; set; } // null: no caustics [Export] public Vector3 Absorption { get; set; } = new(0.45f, 0.09f, 0.06f); // per metre, linear RGB // the groups below; setters Touch() like every material}| Group | Exports (defaults) |
|---|---|
| Surface | NormalScaleNear 3 m, NormalScaleFar 11 m (rotated, slower), NormalStrength 1, Roughness 0.06 |
| Flow | FlowCycle 1.6 s, FlowScale 1, WindDrift 0.05 |
| Colour | Absorption, ScatterColor sRGB (0.05, 0.22, 0.24), ScatterStrength 0.6 |
| Refraction | RefractionStrength 0.04, RefractionRoughness 0.15 |
| Reflection | ReflectionStrength 1, ScreenSpaceReflections on (quality from rendering.waterSsr) |
| Foam | FoamScale 2 m, ShoreFoamDistance 0.4 m, FoamStrength 1 |
| Caustics | CausticsScale 3 m, CausticsStrength 0.5, CausticsMaxDepth 3 m |
| Edges | SoftEdgeDistance 0.15 m |
The parameters fit a 112 B material UBO in set 2.
Fragment shader
Section titled “Fragment shader”Water/Water.vk.frag.slang, helpers in include/water.slang, in order:
- Flow-advected normals (the two-phase flow-map technique; Vlachos, “Water Flow in Portal 2”, SIGGRAPH 2010).
Flow is the vertex flow ×
FlowScaleplus G8b’s shared wind ×WindDrift. Witht = time / FlowCycle + noise(worldXZ), phasesp0 = frac(t)andp1 = frac(t + 0.5)offset the UVs by−flow × p × FlowCycleand blend with weights1 − |2p − 1|; the noise hides the common pulse. Two layers (near and far scale) of one normal map give four taps, combined with a whiteout blend. - Water column. With
SceneTextures: the prepass depth at the pixel gives the bed’s view position, and the column along the ray isbedDistance − surfaceDistance. Without: see the fallback. - Refraction. Offset the screen UV by
normal.xz × RefractionStrength × saturate(column), re-read depth there, and keep the unoffset UV if the offset sample is in front of the surface (no leaking of foreground objects). Sample the colour copy at mipRefractionRoughness × maxMip × saturate(column / 2 m): deeper water blurs more. - Caustics (optional): project the bed position along the sun direction to the surface, take the minimum of two
crossing-scrolled samples, add
× sun colour × shadow × CausticsStrengthto the refracted colour, faded out atCausticsMaxDepth. Terrain and other bed materials need no change. - Absorption and scattering (Beer–Lambert):
T = exp(−Absorption × column),water = refracted × T + ScatterColor × ScatterStrength × ambient × (1 − T), withambientG6.2’s diffuse IBL along up. - Reflection: G6.2’s radiance cube along the reflected vector at the mip for
Roughness, replaced by SSR where it hits. Schlick Fresnel with F0 = 0.02 (IOR 1.33):colour = lerp(water, reflection × ReflectionStrength, F). - Sun specular: G6.1’s GGX light loop (
shadeLightsPbr) atRoughness, with shadows. - Foam:
1 − saturate(column / ShoreFoamDistance)(which also rings rocks and logs that break the surface) plus the vertex foam (falls, rapids, mouths) masks the flow-scrolled foam texture, lit as diffuse white. - Soft edges: blend toward the unrefracted copy over
SoftEdgeDistanceof column, so shorelines have no hard line. - Back faces (camera under the surface): refraction and absorption only.
SSR is an option for water only (a G6 non-goal that G8c picks up): a view-space linear march along the reflected
ray against the prepass depth with binary refinement (16 + 4 steps at Low, 32 + 6 at High, 0.3 m thickness),
sampling the colour copy’s mip 0. Hits fade near screen edges, for rays toward the camera and with distance, and blend
into the IBL by confidence. rendering.waterSsr is Off | Low | High, default Low.
Pipeline, phase and bindings
Section titled “Pipeline, phase and bindings”- Shader set
Water:Water/Water.vk.vert.slang(the mesh vertex code through a shared include, plus theCustom0stream) andWater/Water.vk.frag.slang. Water casts no shadows; picking uses theMeshObjectIdset. - A water phase.
MaterialRenderStategains a phase (Opaque,Water,Transparent), andMeshViewDrawsaWaterlist sorted by material, then front to back. WithSceneTextureswater writes depth, outputs alpha 1 (it composes the refraction itself) and draws first after the opaque half of the scene pass, so particles and glass sort correctly against it. Transparent surfaces under water are not refracted (as in Godot). - Bindings.
SceneTexturesbind in set 3, only in the water pipeline layout, so opaque pipelines keep G6’s count; set 3 stays free for skinning (G1b), which water never uses. The fragment-stage budget is ≤ 16 sampled images, ≤ 16 samplers and ≤ 4 sets (mobile checklist item 10):
| Set | Images | Samplers |
|---|---|---|
| 0 frame (after G6): radiance cube, BRDF LUT, AO, decal atlas | 4 | 1 shared linear-clamp, mipmapped |
| 1 shadows | 6 | 6 |
| 2 water material: normal map, foam, caustics (1×1 fallbacks) + UBO | 3 | 1 repeat |
3 SceneTextures: prepass depth (read with Load), colour copy (set 0’s sampler) + 16 B UBO | 2 | 0 |
| Total | 15 | 8 |
One normal map sampled at two scales, rather than two textures, leaves one image of headroom.
Without SceneTextures
Section titled “Without SceneTextures”A specialization constant selects the fallback when the view has no SceneTextures (rendering.sceneTextures = Off,
or a tier that disables it):
- no refraction, caustics or SSR; the surface is alpha-blended (
alpha = 1 − T) and still writes depth; - absorption from distance only: the column is the vertex column depth (painted or carved) divided by the cosine of the view angle, so deep water and grazing views still darken;
- shore foam from the same vertex depth; normals, Fresnel, IBL reflection and sun specular are unchanged.
It is the low-tier look, not an error path, and has a render golden of its own.
Default textures. The built-in normal map, foam and caustics are generated in C# on first use (tileable, 256², deterministic, cached per process): summed tileable noise, Worley cells and a Voronoi edge pattern. No texture files and no licences to track (ADR 0149: CC0 or procedural).
SceneTextures
Section titled “SceneTextures”What lies behind a surface, for draws after the opaques: G6.6’s depth prepass and a mipmapped copy of the opaque
colour. They are created per view, lazily, only for views that draw a Water phase surface (or run SSAO, for the
depth), so frames without water stay bit-identical to today’s.
flowchart LR
P["PrepareFrame<br/>cull, sort; Water list"] --> B["BeginFrame"] --> S["Shadows, offscreen"]
S --> D["Depth prepass (G6.6)<br/>opaque + cutout runs → D32 sampled"]
D -. "SSAO on" .-> A["GTAO chain (G6.6)"]
D --> O["Scene pass A<br/>Clear colour + depth<br/>sky, opaque, cutout<br/>Store colour + depth"]
A -.-> O
O --> C["Colour copy<br/>fullscreen downsample passes<br/>mips 0 … 5 (RGBA16F)"]
C --> T["Scene pass B<br/>Load colour + depth<br/>Water phase, transparents,<br/>debug lines, OnRenderMainPass"]
T --> E["EndFrame<br/>glow, tonemap, overlay"]
- Depth prepass: G6.6’s
RenderServer.RenderPrepass, run when SSAO orSceneTexturesneeds it. If G8c lands first, G8c.6 builds that step as G6.6 specifies it. - Scene pass split. On frames whose view draws water,
RenderMainends the scene pass after the opaque and cutout runs (and the visuals withRenderPriority < 0), storing colour and depth. A second render pass,ScenePassLoad(colourLoad/Store, depthLoad/DontCare), is compatible with the scene pass (same formats and sample counts), so every scene pipeline works in both halves. Each half ends withRenderTarget.End’s explicit barrier, which MoltenVK needs between encoders. - Colour copy: fullscreen-triangle passes like
GlowEffect(MainframeEngine/Src/Rendering/Post/GlowEffect.cs:14). The first reads the scene target into mip 0 (texelFetchat full size, bilinear at half); each further pass halves the previous mip, up to 6 mips. A fragment copy needs noTRANSFER_SRCusage on the scene target and ports to M11’s backends unchanged. Per-mip views and framebuffers live in an internalMipChainTarget. - Scene pass B loads colour and depth and draws the
Waterlist (sampling set 3), the transparents, the debug lines and the legacyOnRenderMainPasshook.
SubViewport HDR targets get the same split, so the editor shows water as the game does.
Costs at 1920 × 1080 (2.07 Mpx; × 4 at 4K). Memory is per view, one copy shared by both frames in flight (ordered by the passes’ dependencies, like the scene target):
| Item | Memory | Bandwidth per frame |
|---|---|---|
| Prepass depth (D32) | 8.3 MB (none extra with SSAO on) | 8.3 MB written, plus the opaque vertex work again |
| Pass split | — | ≈ 33 MB colour and 17 MB depth of extra store and load on tile-based GPUs; ≈ 0 on desktop GPUs, where attachments live in memory anyway |
Colour copy, Full (RGBA16F, 6 mips) | 22.1 MB | ≈ 44 MB (read 16.6, write 16.6, mips ≈ 11) |
Colour copy, Half | 5.5 MB | ≈ 24 MB |
| Water sampling | — | up to ≈ 30 MB when water fills the screen |
At 60 fps the Full path moves about 5 GB/s at worst: small for a desktop GPU (200+ GB/s), large for a phone
(25–35 GB/s in total).
Mobile and MoltenVK. On tile-based GPUs the split is the costly part: it stores and reloads HDR colour and depth mid-frame and, on frames with water, rules out M12’s merged scene + tonemap pass (mobile.md → rendering); those frames take M12’s two-pass path. The copy cannot be memoryless. On MoltenVK each half and each copy pass is its own Metal encoder, kept in order by the explicit barriers above. Per checklist item 8, every new attachment states its load and store intent, and the prepass depth is the only new stored depth.
Settings and degradation. rendering.sceneTextures in project.mfproj (Off | Half | Full, default Full)
with a cost note (checklist item 9). The mobile tier table gains a row:
Off on Low and Medium, Half on High, rendering.waterSsr Off on every tier. With Off there is no prepass
(unless SSAO), no split and no copy, and water uses the fallback: no refraction, absorption
from distance only.
Gameplay queries
Section titled “Gameplay queries”Every water body registers with World3D.Water when it enters the tree: each Terrain3D with a water layer, each
River3D, and any game type that implements the interface.
public interface IWaterBody3D{ Aabb WaterBounds { get; } // world space bool TrySample(Vector3 position, out WaterSample sample); // false when the XZ position is not over this body}
public readonly record struct WaterSample(float SurfaceHeight, float ColumnDepth, Vector3 Flow);
public sealed class WaterQueries // World3D.Water (new){ public bool TrySample(Vector3 position, out WaterSample sample); // the highest surface wins on overlap public float WaterDepthAt(Vector3 position); // bed-to-surface column depth in metres; 0 when dry public float SurfaceHeightAt(Vector3 position); // float.NaN when dry public Vector3 FlowAt(Vector3 position); // surface velocity in m/s; zero when dry or still public float ImmersionAt(Vector3 position); // surface height − position.Y; ≤ 0 above the water or dry public bool IsUnderwater(Vector3 position, float margin = 0.05f); public static float WadeSpeedScale(float immersion, float start = 0.3f, float full = 1.2f, float minScale = 0.4f);}- Terrain:
TrySampleuses G8a’sWaterDepthAtandHeightAt(surface = bed + depth); flow is 0 until painted.Terrain3D.WaterDepthAtitself stays as G8a defines it (terrain layer only). - Rivers: a uniform grid of 4 m cells maps to baked-segment ranges, built at regeneration. A query projects onto
the candidate segments for
sandr; the point is in the river when|r| ≤ hw(s). Flow uses the mesh’s lateral profile. - Main thread only, like physics queries, and allocation-free. A world has a handful of bodies, so the registry is a
list tested against
WaterBoundsfirst.
Wading. WadeSpeedScale eases from 1 at start immersion to minScale at full. A character controller, such as
G8d’s FirstPersonController:
public override void OnPhysicsProcess(float delta){ var water = GetWorld3D()!.Water; var scale = WaterQueries.WadeSpeedScale(water.ImmersionAt(GlobalPosition)); Velocity = _wish * (WalkSpeed * scale) + water.FlowAt(GlobalPosition) * FlowPush; // FlowPush ≈ 0.3 MoveAndSlide();}Underwater camera. IsUnderwater(camera.GlobalPosition) is the detection; water draws back faces, so the surface
is visible from below. Underwater fog and engine buoyancy are settled in decisions; the samples above are
what a game needs to float a RigidBody3D from OnPhysicsProcess.
River3D plays StreamSound through one internal AudioPlayer3D (Loop = true, the river’s Bus), started only in
games (edit mode is silent).
- Each frame (
OnProcess) it finds the arc offset closest to the listener (AudioServer.Listener3D) through the river’s grid and moves the player to the centreline there, pushed toward the listener by up to the half width: beside a wide river, the near bank is heard. - The position moves at most 30 m/s, so where two bends are equally near the sound glides instead of jumping.
- Volume follows the local speed:
StreamVolumeDb + clamp(20·log10(speed / 1 m/s), −6, +6)dB; rapids are louder. FallsSoundloops at falls bases: up toMaxFallSoundsplayers, reassigned every 0.25 s to the falls nearest the listener, with volume from drop × width.- It is struct math and property sets on existing players: 0 B per frame (allocation-gated). Doppler stays off.
Code layout
Section titled “Code layout”| Path (new) | Contents |
|---|---|
MainframeEngine/Src/Resources/Curve3D.cs | Curve3D and its baking |
MainframeEngine/Src/Scene/Nodes3D/Water/ | River3D, RiverBuilder (ribbon, flow, falls), RiverCarver, RiverGrid, WaterQueries, IWaterBody3D, WaterSample |
| G8a’s terrain folder | TerrainWaterMesh, the water settings’ WaterMaterial |
MainframeEngine/Src/Rendering/Resources/WaterMaterial3D.cs | the material and its GPU parameters |
MainframeEngine/Src/Rendering/Water/ | SceneTextures, MipChainTarget, WaterTextures (procedural defaults) |
MainframeEngine/Content/Shaders/Water/ | Water.vk.vert.slang, Water.vk.frag.slang, SceneColorDownsample.vk.frag.slang |
MainframeEngine/Content/Shaders/include/ | water.slang (flow, absorption, foam, SSR), scene_textures.slang (set 3) |
MainframeEngine.Editor/Src/Water/ | River3DTool, the River3D inspector section (Carve, Re-carve, Uncarve) |
Shaders compile through the Slang build (ADR 0144); just shaders refreshes the .spv files and shaders.lock.
Testing
Section titled “Testing”- Unit (
Tests/MainframeEngine.Tests/Water/):Curve3DTests: Bézier values against hand-computed ones; a quarter circle’s baked length within 0.1 %;SampleBakedat the ends and past them; closest offset and point; width and depth interpolation;Pointsround trip;Changedon every setter; sampling allocates nothing; a fixed curve’s baked points match a committed hash.RiverBuilderTests: section count; UV.u 0 … 1 across, UV.v equal to arc length /UvLength; smooth or flat normals;Custom0flow along the tangent and slower at the banks; the downhill clamp; speed from slope.RiverFallsTests: a 1 m step over 0.5 m makes one falls (lip, jet, plunge) that lands at the base; a gentle slope makes none.RiverCarveTests(a G8aTerrainDatain memory): channel profile and shoreline; bank continuity; plunge pools;Recarvetwice equals once;Uncarveand undo restore bit for bit; a committed hash matches on every OS.RiverMouthTests: the ribbon ends at a pond’s shoreline at its surface height; flow tapers to 0; the low-mouth warning.WaterQueriesTests:HeightAt + WaterDepthAtequals the surface at random points; rivers inside, outside and at the edge; overlaps;NaNwhen dry;FlowAt,ImmersionAt,IsUnderwater,WadeSpeedScale; no allocation.TerrainWaterMeshTests: a triangle is wet exactly when one of its vertices is; vertices ath − ShoreDrop; dry chunks have no node; nodes are unowned and unsaved.WaterMaterial3DTests: defaults, UBO packing,.mresround trip;WaterTexturestile and are deterministic.RiverAudioTests: the emitter is at the closest centreline point, clamped to the banks and rate-limited; falls players go to the nearest falls; 0 B per frame.
- Render tests (goldens for
moltenvkandlavapipe;--fixed-fpskeepsframe.clip.zdeterministic):water-pond-faceted(on today’s renderer),water-pond(Realistic: refraction, absorption, shore foam, soft edges, caustics),water-river(a carved stream with a falls into a pond),water-fallback(the same withrendering.sceneTextures = Off),water-ssr. Every existing golden stays bit-identical: views without water skip the prepass, split and copy. - Validation gate: each scene with no warnings, including a resize with
SceneTextureson and an editorSubViewportwith water. - Allocation gate: a 200-point river with falls, a pond, a moving camera and listener: 0 B per frame.
- Benchmarks (
baseline.json):RiverBuilder.Build500m,RiverCarver.Carve500m,WaterQueries.FlowAt10k. - Editor QA (
Tests/QA): add aRiver3Dover a terrain, add and drag points, drag a width handle, Carve, Undo, Re-carve, save, reload and compare.
Acceptance
Section titled “Acceptance”- A pond painted on a Faceted terrain shows a flat translucent palette sheet with a clean shoreline; on a Realistic
terrain it shows
WaterMaterial3D. Rendering, collision andHeightAtagree on the lowered bed. - A
River3Ddrawn in the editor generates a ribbon, carves its channel as one undoable edit, flows faster on steeper ground, forms a falls with foam (and spray once G6.3 exists) and ends cleanly in a pond. WaterMaterial3Dshows flowing ripples, refraction, depth colour, sky reflection, sun glints and shore foam; SSR and caustics work when enabled; withSceneTexturesoff it falls back without errors.World3D.Wateranswers depth, surface height and flow for ponds and rivers; a character slows while wading.- The stream sound follows the listener along the river; falls loops play at the falls.
- Frames without water are bit-identical; validation and allocation gates are green; the water pipeline stays within 16 sampled images and samplers per stage and 4 sets.
- Docs updated as each step ships: a new
docs/design/water.md, materials-and-meshes.md (WaterMaterial3D, the water phase), vulkan-renderer.md and color-pipeline.md (SceneTextures, the split), shaders.md, audio.md, testing.md, mobile.md (the tier row), and a new ADR forSceneTexturesand the scene pass split.
Task list
Section titled “Task list”Ponds on Faceted first, then River3D geometry, then WaterMaterial3D after SceneTextures.
- G8c.1 Pond meshes (Faceted).
TerrainWaterMesh(wet triangles,ShoreDrop,Custom0), the palette sheet,TerrainData.WaterMaterial;World3D.Water,IWaterBody3DandWaterQueriesfor terrain water; tests; render testwater-pond-faceted. Needs G8a’s water layer and lowering. - G8c.2
Curve3D. Resource, baking, sampling, closest point,Pointsstorage, inspector display; tests. - G8c.3
River3Dgeometry.RiverBuilder(ribbon, downhill clamp, flow from slope, falls, pond join),RiverGrid, river queries, the Faceted look; tests; benchmark. - G8c.4 River editing and carving.
River3DTool(points, tangents, width and depth handles, snap, undo),RiverCarverwith carve records, Carve/Re-carve/Uncarve, therippleicon; editor tests; QA step. - G8c.5 River audio. Stream emitter and falls loops; tests; allocation gate.
- G8c.6
SceneTextures. The prepass (G6.6’s step),ScenePassLoadand the split, theMipChainTargetcopy, per-view lazy creation,rendering.sceneTextures, the mobile tier row; validation and “no water, no change” checks. Built on the M11 step 1 encoder API, like G6.6 (decision 1). - G8c.7
WaterMaterial3Dcore.ShaderSetId.Water, the water phase and list, set 2 and set 3 layouts, flow normals, absorption and scattering, refraction, Fresnel, IBL reflection, sun specular, foam, soft edges, back faces, the fallback,WaterTextures; Realistic ponds and rivers switch to it; render testswater-pond,water-river,water-fallback. Needs G6.1, G6.2 and G8c.6. - G8c.8 Extras. Caustics, SSR and
rendering.waterSsr, falls spray (G6.3), wind drift (G8b’s wind); render testwater-ssr. - G8c.9 Docs and hand-off.
docs/design/water.md, the doc updates in Acceptance, the ADR; streams and a pond in G8d’sExamples/Forest.
Decisions
Section titled “Decisions”Decided by the user on 2026-10-08, who accepted every default, and recorded in ADR 0149. Each entry keeps the question it settled; Decision: is what to build.
SceneTexturesbefore or after M11 step 1? It adds a prepass, a pass split and copy passes, the kind of change G6 parks behind M11 step 1 for SSAO. Decision: follow G6’s rule. G8c.1–G8c.5 and the fallback look need neither, so ponds and streams ship on today’s renderer while the split waits. Superseded (2026-10-09, ADR 0163): Brogan asked for SSAO and TAA ahead of M11, so the shared depth prepass exists now (Post-processing); water’sSceneTextures(the colour copy and pass split) can build on it.- Prepass or depth copy? With SSAO off, copying pass A’s depth (one fullscreen pass) is cheaper than a second geometry pass. In a forest of alpha-tested foliage, a prepass followed by an equal-depth main pass (G6’s prepass reuse question) saves more fragment work than it costs. Decision: the prepass, one code path shared with G6.6; revisit with G6’s prepass-reuse decision.
- Auto-carve on release? It keeps the channel in sync but writes the terrain and an undo step on every drag. Decision: off; Carve and Re-carve are explicit, and the inspector shows “curve changed since the last carve”.
- Painted lake flow. A flow brush in the Water mode writing the layer’s G and B channels (TerraBrush’s flow tool).
Decision: later, when a game needs moving lake water;
Custom0.yzalready carries flow. - Buoyancy. An engine
Buoyancy3Dnode (sample points, force from immersion, drag toward the flow) or a documented sample. Decision: a sample indocs/design/water.md; a node once two projects need one. - Underwater fog. Decision: not in G8c (ponds and streams are wading depth); if the showcase needs it, G8d’s fog
gets an underwater override driven by
IsUnderwater. - Animated Faceted water. Bobbing vertices or stepped palette tints for the low-poly sheet. Decision: static; it keeps G8c.1 shader-free.
Curve3Dup vectors. Godot’sup_vector_enabledmatters for roads and rails, not rivers. Decision: keep tilt in the data; add up vectors when a road or path node needs them.
Related
Section titled “Related”- Terrain (G8a), Procedural trees (G8b), Forest showcase (G8d), ADR 0149
- Rendering features (G6), Rendering backend abstraction (M11), Mobile core (M12), Editor viewport tools (G7)
- Vulkan renderer, Color pipeline, Sky, Materials & meshes, Engine lifecycle, Shaders, Audio, Physics