-
Notifications
You must be signed in to change notification settings - Fork 113
water_volume_color
title: Water Volume Color description: Design draft — giving water a body color independent of the reflection, so colored water (Prime Roots sap) survives realtime planar reflections published: true date: 2026-07-05T00:00:00.000Z tags: editor: markdown dateCreated: 2026-07-05T00:00:00.000Z
Design draft for reworking how water gets its color, following the realtime planar water reflection work. Status: design agreed in principle, not implemented. This page records the plan and the alternatives so it can be picked up later.
The current water composition applies the shape's color map as the final multiply over the reflection (reflection.rgb × colormap.rgb), and blends the result over the real underwater scene by alpha. Two problems:
- The color tints the wrong thing. A water body's color comes from light scattered inside the volume, under the reflection — not from tinting the mirror image. With a static envmap the difference was mostly academic; with a live planar reflection, tinting the reflected scene looks wrong.
-
Strongly colored water doesn't survive the reflection tier swap. A census of the live data (all ~220 water shapes) showed only 17 shapes carry a color map at all. The waters with a strong color identity — the Prime Roots sap and central lake (
pr_seve_envmap,pr_pointcentral_envmap) — have no color map: their green is baked into the envmap texture. Realtime planar reflection replaces the envmap with the reflected scene, so those pools lose their color entirely.
Additionally, the color map's alpha currently does double duty as shore/shape fade, but the census (and the known data-build issue) showed no properly painted shore rims exist in practice — the calculated fresnel reflectivity carries the water edge fine on its own.
Gamma-rendering games of the HL2 generation faked volumetric water color with a constant "water fog" color lerped against the reflection by fresnel, and, where a refraction render target existed, tinted the refraction toward that color (optionally scaled by depth). HL2's cheap water tier dropped refraction and went effectively opaque — fog color as the base, reflection over it. The volume is never computed; it's an authored constant, picked directly in gamma space. NeL's advantage: the real underwater scene renders anyway (water alpha-blends over it), so the "refraction" is free and real.
Compose the water as three conceptual layers — real underwater scene, body color (fake volume), reflection on top:
volume = lerp(underwaterScene, body.rgb, density)
final = lerp(volume, reflection, F) // F = calculated reflectivity
This collapses into a single pass by switching the water material to premultiplied alpha (ONE, INVSRCALPHA):
src.rgb = reflection·F + body.rgb·density·(1−F)
src.a = 1 − (1−density)·(1−F)
Exact, a couple of MADs in the fragment program, works on every driver tier including fixed function. Each input now has one job:
- body.rgb / density — the water's volume color and how strongly it occludes the bottom. Per-shape constant by default (covers ~200 shapes with no texture); optional body texture for waters whose identity is the volume (sap, goo, swamp), perturbed by the same du/dv chain as the reflection so tint features wobble coherently (one extra MAD; the offset needs a scale constant because the body texture's world-space UV mapping differs from the reflection UV space; skip perturbation on the NV20/fixed-function fallbacks).
-
F — the calculated reflectivity only: the per-shape stylized fresnel
clamp(bias + scale·(1−cosθ)^power)boosted by reflection luminance. The luma boost becomes artist-scalable (per-shape strength 0–1): at full strength it reproduces the original envmap-alpha authoring (bright sky pins opaque), at low strength it's just sun-glint — necessary so a bright sky can't bulldoze a colored body. -
The real underwater scene — stays as-is, the one layer that cannot wobble (no refraction RT), hidden behind the tint wherever density is meaningful. Refraction perturbation is acknowledged missing, lower priority — closing it means the scene-copy refraction target described under the alternatives, which can be added later as a quality tier without changing this composition (the perturbed scene sample simply replaces
underwaterScenein the same equation). - No shore special-casing. Fresnel holds the water edge; the painted shore alphas are retired from the composition.
body = sap green, density ≈ 0.9–1 (opaque volume), fresnel authored low (e.g. bias 0.05, scale 0.3), luma boost low. Result: looking down, green sap with a faint sheen; at grazing, a modest reflection; the green never depends on which reflection tier is active. A subtle perturbed body texture gives the sap flow-like life even at full density.
The NeL Material water properties get separate entries per role, so every tier reads its proper inputs:
| Asset | Role |
|---|---|
| Slots 1–4 (EnvDay/EnvNight/EnvDayUnder/EnvNightUnder) | Legacy-tier reflection source, unchanged — keeps the tinted envmaps so the same asset still renders case 1 byte-identical |
| Untinted envmap day/night pair (new named fields) |
New-composition fallback reflection source (cases 3 and 5). Selection: untinted set when populated → generic waterenvmap → tinted set as last resort (degrades to the case-3 caveat only for un-updated assets). Named texmap params beyond the 8-slot grid — the slot grid is a UI convention, the exporter reads params by name |
| Slots 5–6 (Bump/Displace) | Shared by all tiers, unchanged |
| Slot 7 (ColorAlpha) | Keeps its legacy semantics for shapes that have not opted in |
| Slot 8 (currently Unused) | Body texture (optional, perturbed) |
| New parameters | Body color (constant), body density, luma boost scale, plus a per-shape opt-in flag for the new composition |
Engine: CWaterShape serial v8, NeL Material version 16, a fragment program variant for the premultiplied composition. The composition is tier-independent; only the reflection source differs per tier (planar RT when admitted, envmap otherwise, base color on the fixed-function tier — which incidentally upgrades simple water too). Old shapes render byte-identical until opted in.
Open question: whether slots 7 and 8 coexist on opted-in shapes or slot 7 retires on the new path (leaning retire — its RGB migrates into the body texture where wanted; decide with the assets in view).
The composition follows the asset + mode pair; budget-swap pairs (2↔3, 4↔5) keep identical body layer and reflectivity so tier changes stay visually continuous:
| # | Assets | Mode | Reflection source | Coloring |
|---|---|---|---|---|
| 1 | Legacy | normal | envmap (color-baked) | Legacy: envmap alpha × colormap tint — byte-identical, the compatibility guarantee |
| 2 | Legacy | forced, admitted | planar RT | New composition (body params at defaults, calculated reflectivity) |
| 3 | Legacy | forced, fallback | untinted envmap when populated (else generic, else the tinted one), alpha ignored | New composition, continuous with 2. With only the tinted envmap available, its baked color shows in the reflection — acceptable for a dev/testing mode, fixed by populating the untinted field |
| 4 | Planar water assets | admitted | planar RT | New composition (authored body color/density/texture) |
| 5 | Planar water assets | fallback, or planar disabled/unsupported | untinted envmap fields, alpha ignored | New composition, continuous with 4 — clean because opted-in shapes populate the untinted pair |
This extends the existing calculated-reflectivity selection (artist flag || realtime-reflection flag || force) from the alpha computation to the whole composition; case 1 engages nothing new without force or opt-in.
- Status quo (tint the reflection). Wrong layer for the color; PR loses its green under planar reflections; alpha overloaded with two jobs. Rejected.
- Keep baking color into envmaps. Works only for the envmap tier by definition — the exact thing planar reflections replace. Also couples region color identity to bespoke envmap authoring. Rejected.
-
Opaque fog-color water (HL2 cheap-water style).
final = lerp(bodyColor, reflection, F)with full opacity. Simplest possible; loses the real see-through underwater scene NeL gets for free. Kept only as the effective behavior at density = 1 — sap does this naturally, so it's a special case of the plan, not a separate mode. -
Multiplicative pre-pass. A first pass tinting the destination (
dst × bodyColor), then the reflection pass over it. Period-authentic and gives multiplicative (absorption-like) volume color rather than the lerp's scattering-like color. Costs a second draw per surface, complicates the render list, and multiplicative-only color can't brighten (no milky/glowing water). The premultiplied single pass covers the useful range; revisit only if absorption-style tinting is specifically wanted. - Refraction render target with depth-scaled tint. The full HL2-expensive treatment: copy the scene, sample it with perturbed UVs, tint by depth through the water. Highest fidelity (wobbling refraction, true depth-proportional color), but needs a scene copy per frame, depth sampling, and doesn't fit the "keep the existing pipeline and drivers" constraint that shaped the reflection work. A possible future quality tier alongside per-pixel projective reflection UVs, not the baseline.
- Per-vertex water fog by real depth. Computing density per vertex from actual water depth (surface z − bottom z) instead of an authored constant/texture. The bottom depth isn't cheaply available to the water grid (the screen-space grid doesn't know terrain height per vertex without sampling the landscape), and the painted/constant density gives artists direct control anyway. Deferred.
- Realtime planar water reflections — the system this design completes
- Water shader effects — the original envmap water this all descends from
- Creating water surfaces — the artist-facing material reference that would gain the new entries