Caller-driven terrain water
TerrainRenderer accepts water optics through the existing native
TerrainRenderParams wrapper. Omitted WaterSettings.optics preserves legacy
masked-water shading. WaterOpticsSettings() selects physical shading;
WaterOpticsSettings(mode="procedural") explicitly selects legacy shading.
An unset water mask leaves the land path unchanged.
from forge3d import WaterSettings, WaterOpticsSettings
from forge3d.terrain_params import make_terrain_params_config
config = make_terrain_params_config(
size_px=(512, 512), render_scale=1.0, msaa_samples=1,
terrain_span=1000.0, z_scale=1.0, exposure=1.0, domain=(0.0, 1.0),
camera_mode="mesh:zup",
water=WaterSettings(optics=WaterOpticsSettings(
surface_height=simulated_surface, # same (rows, columns) as heightmap
color=(0.24, 0.13, 0.055), # linear RGB sediment colour
roughness=0.02,
)),
)
# Pass forge3d.TerrainRenderParams(config), heightmap and water_mask to
# render_terrain_pbr_pom, render_with_aov, or the offline accumulation API.
surface_height is the absolute water elevation in heightmap units, before
z_scale; it is not a depth or a shore-distance ratio. It displaces masked grid
vertices. Normals are derived from this grid in Z-up world coordinates. Optional
surface_normals has shape (rows, columns, 3) and supplies world normals after
z_scale. Either input may be supplied independently. With neither, physical
shading uses the existing mesh geometry and its geometric normal. Both grids use
the heightmap’s row orientation; the renderer does not flip them. Values must be
finite, grids must match the heightmap, and normals must have positive Z, as a
heightfield normal does, including after float32 normalization. Inputs that lose
positive Z at GPU precision are rejected. This prevents zero normals when
neighbouring texels are interpolated. The Python
settings snapshot arrays into compact immutable float32 buffers, so later
simulation changes require a new settings object/native wrapper. asdict()
exports ordinary nested lists, preserving the JSON representation. Direct and
AOV renders upload the current snapshot each time; offline accumulation uploads
it at setup and reuses it for that accumulation. There is no native surface cache.
color=None keeps the caller’s material or overlay colour. An explicit colour
overrides the overlay on water only. Colours use finite linear RGB values in
[0, 1]. roughness is perceptual roughness in [0, 1], with no terrain roughness
floor. A supplied MaterialLayerSettings.roughness_path takes precedence through
the existing material-map mask. Land keeps its existing roughness floor.
Physical water uses dielectric Fresnel reflection (IOR 1.33), the environment sky, and a ray traversal through the original terrain grid. Terrain hits supply material/overlay colour; misses sample the sky. The sun uses a GGX glint from the same surface normal. Terrain ray visibility, CSM visibility and an enabled sun visibility map suppress that glint. The physical path does not use the legacy fixed blue colours, procedural waves or shore-distance depth tint. The existing planar-reflection settings and procedural path remain available.
The sediment/body colour receives sky irradiance and shadowed sunlight. It is
dark when both light sources are off. Reflected terrain uses those light sources
as well, rather than a fixed ambient brightness.
For direct TerrainRenderer calls, sky lighting comes from the supplied
env_maps IBL object’s intensity; set that intensity to zero to disable it.
The physical geometry path currently requires a mesh camera. Screen, clipmap and globe cameras reject physical masked-water input explicitly. Terrain reflection uses the procedural mesh grid and local material maps; it is not a general scene reflection for meshes, buildings or off-grid objects. Roughness filters the sky and reflected material textures; terrain silhouette reflection uses a single surface ray. This work supplies optics, not FLUVIUS lake geometry or the DILUVIUM flood solver.
Mesh geometry retains the renderer’s fixed 512-by-512 vertex grid; finer supplied grids improve shading normals but cannot add silhouette detail beyond that mesh. Physical mask, height and encoded normal data share the existing mask texture, so optics adds no sampled-texture binding (the assembled terrain shader remains at the baseline count of 30). Independent renders on other backends remain absent.
Run python examples/terrain_water_optics.py from the rebuilt development
environment for physical-GPU measurements and images under
artifacts/water-optics. Existing goldens, signed certificates and determinism
hashes are not regenerated. Physical water is outside the existing shader proof’s
zero-water-mask input domain; GPU execution is separate evidence from that proof.
The runtime observer records physical and masked water as excluded from that
proof domain, without aborting rendering. It still rejects failed safety checks;
observing a frame is not a claim that physical optics has a static proof.
python -m pytest tests/test_terrain_water_optics.py checks the input and JSON
contracts and runs the analytic, lighting, shadow-control and proof-exclusion
regressions when the existing terrain GPU capability gate is available. These
tests also run in the NVIDIA/Vulkan visual acceptance lane, whose existing
zero-skip check requires the GPU tests to execute. Terrain goldens on that lane
use FORGE3D_TERRAIN_GOLDEN_VARIANT=nvidia-vulkan.