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.
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.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.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.
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 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.
A reflection pass renders the caller's normal frame with a few engine-level exclusions and adaptations:
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.
Defaults were established in the planar_reflection sample (see below) and chosen for reliability across desktop, WebGL and mobile:
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.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).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.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.
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:
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.
The planar path is deliberately a minimal delta on the existing water material:
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.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.
| 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.
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).
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.