LOG IN
Docs
By GitHub Copilot

Water Renderer

How the W3D water system owns reflection capture, animated surface motion, shoreline blending, and water-track decals.

Generals Remastered still treats water as a distinct renderer subsystem, not just another terrain material. The split is visible immediately in startup. W3DTerrainVisual::init in W3DTerrainVisual.cpp builds the terrain heightmap, then creates a single WaterRenderObjClass and keeps it beside the terrain render object. Water is global scene infrastructure.

The gameplay-facing settings come from Water.cpp. That file parses the WaterTransparencySetting tables into the global TheWaterTransparency object, which is later read by both the water renderer and the screen-filter path. The renderer does not invent its own material parameters — it consumes the game-side water policy that already decided how deep transparency should be and whether soft water edges are active.

Resource setup

The heavy lifting lives in W3DWater.cpp. WaterRenderObjClass::init is the one-time loader for textures, shaders, bump frames, and optional mesh data. ReAcquireResources allocates device-dependent resources, including the reflection render target:

#define SEA_REFLECTION_SIZE 256  // dimensions of reflection texture

// ... later in ReAcquireResources() ...
m_pReflectionTexture = DX8Wrapper::Create_Render_Target(
    SEA_REFLECTION_SIZE, SEA_REFLECTION_SIZE);

The 256×256 reflection texture tells you what this system optimizes for: stable, cheap reflections that look good enough in an RTS camera, not mirror-accurate water. Bumping this to 512 or 1024 is a two-line change that doubles or quadruples reflection GPU cost with barely visible improvement at standard RTS zoom.

Per-frame update

WaterRenderObjClass::update runs every frame with a logic-time-scaled delta:

void WaterRenderObjClass::update()
{
    const Real timeScale = TheFramePacer->getActualLogicTimeScaleOverFpsRatio();

    constexpr const Real MagicOffset = 0.0125f * 33 / 5000;  // "work of top Munkees; do not question it"

    m_riverVOrigin += 0.002f * timeScale;
    m_riverXOffset += MagicOffset * timeScale;
    m_riverYOffset += 2 * MagicOffset * timeScale;

    // Wrap offsets back into [0, 1)
    m_riverXOffset -= (Int)m_riverXOffset;
    m_riverYOffset -= (Int)m_riverYOffset;

    m_fBumpFrame += timeScale;
    if (m_fBumpFrame >= NUM_BUMP_FRAMES)
        m_fBumpFrame = 0.0f;
    // ... mesh animation if m_doWaterGrid && m_meshInMotion ...
}

Water motion is scaled against logic time, not raw render delta, so animation remains aligned with the rest of the simulation when game speed changes. The MagicOffset comment is real — the river UV scroll rate was tuned by eye against the original 30 Hz logic clock and is sensitive enough that anyone who "cleans up" the constant breaks the look. If the water mesh is in motion, the update path runs a small damped spring simulation over the grid (WATER_DAMPENING = 0.93f, IN_MOTION bit per vertex) until velocities settle back to rest.

Reflection pass

Reflections are handled as a separate pass. The camera is updated before the main render specifically so reflection textures can see the current frame's object transforms instead of a one-frame-old snapshot; W3DView::updateView exists partly for that reason. During the reflection render, terrain and water intentionally take simpler paths. HeightMap.cpp switches shader choice for reflection-related passes, and WaterTracksRenderSystem::flush explicitly bails out when backface culling is inverted so wake marks do not stamp themselves into the reflection pass.

Soft edges and shoreline blending

Soft water edges use destination alpha, so W3DShaderManager::startRenderToTexture clears alpha to a known water-opacity baseline before the scene is rendered into the filter texture. That makes the later shoreline blend deterministic — without that setup, the water edge code would inherit whatever alpha happened to be left in the frame buffer and produce inconsistent seams.

Wake marks

The wake and wave system is a separate layer. W3DWaterTracks.cpp manages a pooled WaterTracksRenderSystem with thousands of reusable WaterTracksObj instances. bindTrack takes an object from the free list, groups active entries by wave type, and optionally resynchronizes all waves. update advances elapsed time and releases expired, unbound tracks. flush batches triangle strips into a dynamic vertex buffer, shades them with time-of-day terrain lighting, and refuses to render at all unless soft water edges and nonzero water depth are active. Water marks are therefore not just decals — they are conditioned on the same transparency setup that makes the shoreline readable.

The wave vertex color was previously tinted by terrain ambient+diffuse, which produced washed-out foam on dark maps. The original used pure white; the D3D11 path now matches that (see bug_water_wave_color_tint.md).

Quirks

  • Reflection quality is intentionally capped. SEA_REFLECTION_SIZE = 256 keeps the feature cheap and predictable, but it also means reflections are approximate by design. There is no LOD switch — the same 256×256 RT is used at all camera zooms.

  • Water motion is logic-scaled. That keeps the visuals consistent with time scaling, but also means the animation cadence is not a pure render-FPS effect. Changing game speed affects river flow and bump animation in lockstep with unit movement.

  • Wake marks only render in the main view. W3DWaterTracks.cpp skips them when backface culling is inverted, so reflections do not recursively accumulate water decals — an otherwise very expensive visual feedback loop.

  • Soft-edge water has cross-system coupling. The water renderer depends on W3DShaderManager::startRenderToTexture clearing destination alpha correctly before the main scene render. If that filter path is bypassed, shoreline blending breaks even though the water shader itself is unchanged.

  • MagicOffset = 0.0125f * 33 / 5000. Do not refactor. The comment next to it is serious — the constant was tuned by eye for the original 30 Hz clock and the arithmetic is what ships. Simplifying to a single literal subtly changes the look on scaled logic rates.

  • Mesh-in-motion runs a spring sim per-vertex. A (gridCellsX+3) × (gridCellsY+3) grid iterating with WATER_DAMPENING = 0.93f is cheap but can show a visible pattern when disturbed by multiple simultaneous events. The IN_MOTION bit skips verts at rest, so steady-state water is free.