Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Water Wave Animation

Per-frame vertex displacement system that animates water surfaces on the terrain grid. Selected terrain vertices receive a shared rotation-table displacement producing radial concentric ripples.

Water vertex selection

Wave displacement tests a vertex and the three corners toward negative grid X and positive engine Z. The selected vertex must have texture index {0, 30, 31}; the other three may have {0, 5, 30, 31}. Each index is masked to its lower six bits. Only the selected vertex moves. Texture 5 can border an animated vertex but is never itself selected for displacement.

The engine reverses WLD source rows when loading the terrain. In the raw row-major grid returned by WldFile::combined_map, the selected vertex is (x, row) and its neighbours are (x-1, row), (x, row-1) and (x-1, row-1). The first raw row and first column have no such complete cell.

Wave parameters and enablement

world_wave[N] contains amplitude, speed and spatial frequency, in that order. The world loader parses integers and converts them to floats. Terrain setup truncates speed and frequency toward zero into integers. Amplitude remains floating point. There is no additional speed multiplier. A zero amplitude disables displacement; world_scapeshift does not enable it.

ParameterConsole commandMeaning
AmplitudefxwaveampVertical displacement scale in engine units.
SpeedfxwavespeedPhase advance per BIOS timer tick.
Spatial frequencyfxwavefreqMultiplier for squared grid distance.

Displacement and clock

For selected engine-grid vertex (x, z), width W, height H, integer speed S, integer frequency F, amplitude A, and BIOS tick count T:

distance_sq = (x - floor(W/2))² + (z - floor(H/2))²
phase       = (distance_sq * F + T * S) & 2047
height      = base_height + wave_table[phase] * A - A * 0.5

The half-amplitude bias is stored as a 32-bit float before the vertex loop. For each selected vertex, the table-sample multiplication, base-height addition and bias subtraction retain register precision. Only the final height is stored as a 32-bit float. Rounding each intermediate operation to 32 bits can change the stored height.

The BIOS timer count is independent of rendered frames. The standard PC BIOS clock advances approximately 18.2065 times per second. The absolute phase depends on that clock’s initial value, not on scene loading. The spatial origin is the whole terrain midpoint, not the camera or the visible-window center.

base_height is the height-table value, world_scapeshift - abs(source_height), in engine coordinates with positive Y down. Exporters using positive Y up must negate the displacement and apply their engine-unit scale. The half-amplitude subtraction is a vertical bias; the range for positive amplitude is approximately [-1.5*A, 0.5*A]. It does not center the oscillation on the base height.

Wave lookup table

The shared rotation table contains samples of sin(n * 6.2831852 / 2048), stored as 32-bit floats. The angle remains in the x87 register through the sine operation; a single-precision scratch store does not replace that register value. The initializer writes 2,561 entries, including the extended quarter revolution and its endpoint. Water addresses the table starting at sample 512, so wave_table[p] is the float sample sin((p + 512) * 6.2831852 / 2048). The 2048 wave phases therefore start near one, zero, minus one, and zero at phases 0, 512, 1024, and 1536 respectively. The decimal full-period constant is part of the table construction.

Terrain window

The engine fills a 34-by-34 storage grid around its terrain camera-grid coordinates, from camera-17 through camera+16. The wave loop selects the inner 32-by-32 vertices, from camera-16 through camera+15 on each axis, and reads the four corners described above from that storage. The center on each axis is the camera’s engine coordinate divided by 256, rounded to single precision and then to the nearest integer, with ties to even. A change on either axis reloads the storage grid before displacement.

Terrain projection transforms the inner 32-by-32 vertices. Triangle submission uses only those vertices, covering 31-by-31 cells with two triangles per cell. The outer storage ring supplies neighboring heights for selection and normals; it is not drawn. This is an original rendering window, not a moving ripple origin.

Dirty normals and retained lighting

Each wave update clears the storage grid’s dirty flags. Every selected vertex marks six storage entries, even when its replacement height equals its current height. In engine-grid coordinates, the marks relative to the selected vertex are:

Relative XRelative ZFlags added
003
-103
-1+13
0+13
+102
0-12

Overlapping marks combine with bitwise OR. Positive engine Z corresponds to decreasing raw WLD row, so negate the relative Z column when using source rows.

Face-normal, vertex-normal and vertex-RGB updates all admit an entry when its whole dirty value is nonzero. They do not select different work from individual flag bits. The face pass replaces both triangle normals of an admitted cell. The vertex pass sums the six stored incident face normals using the terrain normalization rules; the RGB pass then uses the retained or replaced vertex normal.

Within the 34-by-34 storage grid, face entries cover X indices 0 through 32 and Z indices 1 through 33. A face entry at (x,z) stores the two triangles of the cell with corners (x,z-1), (x+1,z-1), (x,z) and (x+1,z). Vertex-normal and RGB entries cover indices 1 through 32 on both axes. An entry outside its pass’s range is not updated by that pass.

The diagonal neighbor at relative (+1,-1) is not marked by the selected vertex, although one of its incident faces can change. Unless another selected vertex marks it, its vertex normal and RGB retain their previous values. Recomputing every vertex normal from the displaced grid therefore produces different water-edge lighting.

When the camera’s terrain-grid coordinates change, the storage grid reloads base heights and all three passes run over their full ranges without dirty admission. Subsequent wave updates resume selective replacement. Retention lasts across wave updates until an entry is admitted or a full rebuild replaces it.

Rendering Pipeline

The wave renderer runs each frame as part of the terrain update:

1. Build vertex grid
   └─ 34×34 storage vertices, each with X, Y (height), Z, texture index
   └─ stride: 76 bytes per vertex, 2584 bytes per row

2. Wave displacement
   ├─ Clear dirty flags
   ├─ For each grid cell:
   │   ├─ Check the selected corner {0, 30, 31} and its three neighbours {0, 5, 30, 31}
   │   ├─ If water: replace vertex.y with sine_table[phase] * amplitude + height - bias
   │   └─ Set dirty flags on affected cells and neighbors
   └─ Recompute normals on displaced geometry:
       ├─ Face normals   (cross products per triangle)
       ├─ Vertex normals  (average adjacent face normals)
       └─ Vertex RGB      (lighting from retained or replaced vertex normals)

3. Lighting
   └─ Per-vertex RGB from ambient + directional dot-product, clamped to [0, 255]

4. Rasterize

The RGB inputs and directional calculation are specified in terrain-lighting.md.

Admitted normals tilt with the displaced geometry. Unmarked water-edge entries retain their earlier normals and lighting as specified above.

Terrain Vertex Layout

Each storage vertex occupies 76 bytes:

OffsetSizeTypeNameDescription
0x004f32xWorld X position (grid_x * 256.0)
0x044f32yHeight (from lookup table, or wave-displaced)
0x084f32zWorld Z position (grid_z * 256.0)
0x18–0x30face_normalsTwo face normals per cell (upper/lower triangle)
0x30–0x3Cvertex_normalAveraged vertex normal for lighting
0x484u32texture_indexTerrain texture ID (lower 6 bits used for water detection)

Grid stride: 76 bytes between adjacent X-axis vertices, 2584 bytes (34 × 76) between rows.

Console Commands

Three runtime console commands allow tuning wave parameters without restarting:

CommandSyntaxEffect
fxwaveampfxwaveamp <value>Set wave amplitude
fxwavespeedfxwavespeed <value>Set wave speed
fxwavefreqfxwavefreq <value>Set spatial frequency

These modify the same globals as the INI parameters and take effect on the next frame.

External References