water_planar_reflection - ryzom/ryzomcore GitHub Wiki


title: Realtime Planar Water Reflections description: Architecture of the realtime planar reflection system for water surfaces — reflection manager, pass API, render targets, reflectivity model, and per-driver support published: true date: 2026-07-05T04:03:09.316Z tags: editor: markdown dateCreated: 2026-07-05T03:58:32.256Z

NeL water surfaces can reflect the actual scene in realtime, as an alternative to the static environment map reflection they have used since the original implementation. The technique is classic planar reflection as popularized by Half-Life 2 era water: the scene is rendered a second time from a camera mirrored below the water plane into a render target, and the water surface samples that texture through its existing bump-perturbed reflection lookup. The system works on all driver families — OpenGL 3, OpenGL ES 3 / WebGL, Direct3D 9, and the classic OpenGL driver — and preserves the engine's existing non-linear (gamma-space) rendering pipeline and visual balance.

This page documents the runtime architecture for engine developers and for anyone building tooling that needs to render or preview water. For authoring water surfaces as an artist, see Creating water surfaces. For the original environment-mapped water shader that this system extends, see Water shader effects and the historical water rendering postmortem.

Overview

Each frame, at most a configured number of water planes are rendered with a realtime reflection; every other water surface uses the environment map path unchanged. The pieces:

  • CWaterReflectionManager (nel/src/3d/water_reflection_manager.*), owned by CScene: collects candidate water planes, selects which ones get a reflection, owns the render targets, and sets up/restores all state around each reflection render.
  • The pass API on UScene: the caller's render loop renders each reflection pass through its own render logic, so reflections contain exactly what the application normally renders (sky, weather, landscape, entities) without the engine duplicating any of it.
  • The water surface material path: per-vertex projected texture coordinates place the reflection texture on the water, the existing EMBM bump perturbation wobbles it, and an extended fragment program computes a view-angle-dependent reflectivity.

Which surfaces are eligible is an artist decision: a per-shape flag exported from the NeL Material. UScene::setForceRealtimeWaterReflections(true) overrides the flag for tools and testing.

Frame flow

Plane selection

Water models report their plane height and projected screen coverage while the scene traverses them. The manager groups surfaces by plane, ranks planes by on-screen area, and admits up to the budget set by UScene::setMaxRealtimeWaterReflections() (−1 unlimited, 0 disabled — the Ryzom client defaults to 3). Planes admitted the previous frame get a sticky area bonus (1.25×) so two similarly-sized lakes don't flip-flop the budget between them every frame. One reflection serves all surfaces on the same plane.

The pass API

The render loop drives reflections explicitly, before the main scene render:

uint numPasses = scene->beginWaterReflectionPasses();
for (uint i = 0; i < numPasses; ++i)
{
	UWaterReflectionInfo info;
	scene->beginWaterReflectionPass(i, info);
	// render the frame through your own render logic,
	// with keepTraversals-style flags as for any replicated pass
	renderScene(...);
	scene->endWaterReflectionPass(i);
}
scene->endWaterReflectionPasses(); // safe with zero passes

beginWaterReflectionPass binds the render target (clearing it on its first pass of the frame), restricts rendering to the pass's tile, sets the scene camera to the mirrored camera and enables the water clip plane. endWaterReflectionPasses restores everything. UWaterReflectionInfo hands the caller the pass state (render target, reflected view matrix, off-center sub-frustum, and the tile's scale and bias — the active viewport is (UBias, VBias, UScale, VScale)) for content it renders outside the scene — the Ryzom client uses it to mirror the sky and canopy scene cameras.

This design deliberately reuses the replicated-pass machinery built for stereo rendering (IStereoDisplay): a reflection pass is one more replication of the caller's render loop with a camera trick, exactly like a second eye. The client announces the pass count with setSceneReflectionPasses() and the stereo display emits reflection stages before the scene stages. Reflection passes always keep traversals (they are never the frame's last render), and shadow map generation is skipped inside them (projection still applies, so reflected receivers show the eye pass's shadows).

Stereo: eyes are sensitive to reflection parallax, so each eye renders its own reflection passes from its own camera. setWaterReflectionView() selects the eye before each eye's reflection and scene renders; plane selection is shared across views, rendering and publishing is per view.

What renders inside a reflection

A reflection pass renders the caller's normal frame with a few engine-level exclusions and adaptations:

  • Water surfaces themselves are excluded (no reflection recursion).
  • A user clip plane culls geometry below the water, biased 0.25 m under the surface so bump-perturbed lookups at the waterline don't sample the clipped void. On landscape, whole underwater patches are additionally culled as an optimization.
  • The mirrored camera inherits the main camera's cluster system, and cluster visibility is resolved from the real eye position (the mirrored position is underground).
  • Load balancing does not adapt from reflection passes, and lens flares fade using the frame's first reflection render only (the largest plane) — subsequent passes reuse that fade.
  • The vegetable blend layer is skipped; screen-space effects are the caller's own business.

The rule distilled from integration: a reflection pass may only borrow state that something provably re-establishes per pass. State that is set once per frame (scissor, matrix contexts, per-frame time, eye-space clip planes against foreign views) must be saved/restored or re-derived by the pass machinery.

Render targets

Defaults were established in the planar_reflection sample (see below) and chosen for reliability across desktop, WebGL and mobile:

  • Fixed allocation (setWaterReflectionFixedSize, default on): the target size derives from the window size once, and each frame's reflection renders into an active sub-region of it sized to the water's screen coverage. The published UScale/VScale (plus the tile origin UBias/VBias, below) map the lookup into that sub-region. This avoids per-frame render target reallocation stutter; dynamic allocation remains available.
  • Tile packing (setWaterReflectionMaxTextures, default −1 = as many textures as needed): in fixed allocation mode, the passes' active sub-regions are shelf-packed as tiles into shared textures — a typical water plane only needs a fraction of the window-derived allocation, so several planes fit in one texture, separated by cleared padding for the bump-wobble margin. Each texture is cleared once on its first pass of the frame; a texture only ever holds tiles of one eye. When the texture budget is full, tiles shrink to fit rather than dropping planes (they reflect at lower resolution), so the reflection budget can be raised without one render target allocation per plane. The Ryzom client exposes this as MaxWaterReflectionTextures (default 1 per eye).
  • Power-of-two rounding rounds down (setWaterReflectionPow2, default on): the largest power of two ≤ the derived size. Rounding up would explode memory on large screens; the sub-region addressing absorbs the density difference.
  • Half resolution (setWaterReflectionHalfRes, default on): reflections are perturbed by bump maps anyway; half res halves the overdraw cost and the wobble hides it.

The reflection uses a mirrored camera, not a reflection matrix: the camera basis is mirrored about the water plane with one axis negated to keep the frame right-handed. No triangle winding flip is needed, so the same path works on fixed-function-era drivers. The reflection frustum is an off-center sub-frustum covering the water's screen AABB (mirrored in X), generalized for off-center per-eye stereo frusta.

Reflectivity

The legacy water fragment program modulates the reflection by the environment map's alpha channel, which artists painted as a content-based reflectivity mask (see the analysis notes in Creating water surfaces). A live scene render has no such authored alpha, so the system supplies reflectivity two ways:

  • Baseline: the pass clears the render target alpha to a flat 192 (≈ the original assets' mean effective reflectivity) and masks alpha writes for the whole pass, so scene content cannot stamp garbage into it. Drivers or hardware tiers without the extended fragment program get this uniform reflectivity.
  • Calculated reflectivity (fragment program variant, all four driver families): the water's blend alpha is computed per pixel as alpha = lerp(base, 1.0, luma(reflection)) — a per-vertex Fresnel-style base, boosted to opaque where the reflected scene is bright (sun glints stay pinned). The luminance boost reproduces what the original artists actually painted into their envmap alpha (bright clouds opaque, dark sky transparent); the angle term is a stylized Fresnel the original never had:
base = clamp(bias + scale * (1 - cos θ)^power)

with cos θ the view angle against the surface, computed per vertex on the CPU into the water vertex buffer's third texture coordinate component. bias, scale, power are artist parameters on the water shape (defaults 0.15 / 0.85 / 2.0; with scale = 1 − bias the water becomes a perfect mirror exactly at grazing).

The same calculated reflectivity can also be applied over the artist environment maps (ignoring their alpha channel). This engages automatically for reflection-capable shapes when they fall back to the envmap — over budget, reflections disabled, or unsupported hardware — so a surface keeps the same reflectivity behavior whether or not it won a realtime reflection that frame. Artists can also opt any always-envmap surface into it.

Water surface rendering path

The planar path is deliberately a minimal delta on the existing water material:

  • Per-vertex projected UVs: the CPU water vertex writer projects each vertex of the water's screen-space grid through the reflection camera and writes the resulting texture coordinates (plus the Fresnel base in the third component) into TexCoord0. The vertex program variants simply pass them through — no per-pixel projective math is required, which is what keeps fixed-era shader profiles compatible. Per-pixel projective UVs are a possible later quality upgrade.
  • Texture stage swap: the reflection render target replaces the environment map at its texture stage; the EMBM du/dv perturbation from the bump maps applies on top unchanged, giving the reflection its water wobble.
  • Fragment program variant: one added variant axis computes the calculated reflectivity before the diffuse multiply; the shape's shore fade (diffuse map alpha) still modulates the result.

The vertex program variants exist as nelvp sources (converted per driver as usual) and as GLSL for the modern GL pipeline; the fragment program is maintained as a Cg source (nel/src/3d/shaders/water_fp.cg) compiled to arbfp1 (classic GL) and ps_2_0 (Direct3D 9), with the GLSL variant kept in parity by hand.

Per-driver support

Driver Reflection texture path Clip plane path
OpenGL 3 (desktop) GLSL water programs (UBO) or nelvp-converted gl_ClipDistance clip variants (mega shader axis; nelvp converter clip variant; hand-written user VPs provide a clip twin)
OpenGL ES 3 / WebGL same as GL3, linked programs fragment-side discard from the eye-space position varying
Direct3D 9 ps_2_0 effect techniques (ps_1_x falls back to flat reflectivity) user clip planes, uploaded in clip space when a vertex shader is bound (world space for fixed function)
Classic OpenGL ARB fragment program variants; legacy envmap water keeps its original NV20 texture-shader-first dispatch ARB path uses native/NV_vertex_program2_option clip variants; hardware that would take the NV1-era vertex program path reports no clip plane support and is gated off reflections entirely (IDriver::supportVertexProgramClipPlanes()), keeping envmap water instead

The gating philosophy: hardware too old to clip vertex-programmed geometry against the water plane is also too slow to render the scene twice — falling back to the environment map is the better experience, and the fallback is free because it is the original pipeline.

Configuration summary

Engine (UScene): setMaxRealtimeWaterReflections, setForceRealtimeWaterReflections, setWaterReflectionHalfRes, setWaterReflectionPow2, setWaterReflectionFixedSize, setWaterReflectionView, plus getNumActiveWaterReflections/getActiveWaterReflectionInfo for debug overlays.

Ryzom client: MaxWaterReflections config variable (default 1; exposed as a 0–4 slider in the game configuration FX tab; quality presets map to 0/1/1/2) and ForceWaterReflections (development builds only).

Samples

  • nel/samples/3d/planar_reflection — the minimal standalone demo of the technique: a mirror plane rendered with the mirrored-camera + clip-plane + sub-region approach, no water material involved. This is where the technical defaults (fixed allocation, pow2-down, half-res) were validated across desktop, WebGL and mobile, and the reference for applying planar reflections to anything other than water.
  • nel/samples/3d/water — a full water surface in a UScene driving the reflection pass API exactly like the game client, including stereo pass replication. Runtime toggles for planar/envmap, half-res, pow2, fixed-size and a debug RT overlay; in browser builds these are exposed as URL query parameters and an HTML control panel, since key input does not reach NeL there.

Both samples deliberately keep a local copy of the projection/UV math for reference alongside the engine implementation.

⚠️ **GitHub.com Fallback** ⚠️