feat: render the water we already had (terrain-water task 13, Phase C1)

The blueprint has carried water since task 03 -- WBID per-pixel body id,
WBTB body table, WSRF per-pixel surface level. Nothing ever drew it. Now
the runtime does. No new water data: the blueprint is the authority on
where water is and at what level, and this only reads it.

WHY WATER IS ITS OWN MESH, not a block in the terrain field. The terrain
is a Marching-Cubes iso-surface over ChunkData.Densities. Writing WATER
into that field would not lay a sheet on top of the seabed -- it would
move the iso-surface, fusing the sea into the terrain as if it were solid
ground. A second surface is the only way water can sit at ITS level
independent of the ground under it. So ChunkRenderer builds a water
MeshInstance3D as a child of the chunk's terrain mesh.

TRANSPARENCY IS SHIPPED, NOT DEFERRED (Step 2.4's cheap branch). Because
water is a distinct MeshInstance3D it carries its own StandardMaterial3D,
so alpha is one flag and Godot sorts transparent surfaces after opaque
ones by itself. Zero mesher changes. Alpha runs 0.62 shallow -> 0.97 deep,
so the shelved coast stays readable and open ocean closes up.

WHICH DATA DROVE IT: WSRF for the level (per-pixel, quantised to 1/32768
raw ~ 7.7 mm, far under the 1 m voxel, and no table lookup), WBID for
presence (it is the water stage's own classification output -- the same
set the biome grid's Ocean/Lake pixels form by construction), WBTB as the
fallback when a column is flagged wet but carries the WSRF no-water
sentinel. A body the table does not know leaves the column DRY rather
than guessing a level.

Details worth keeping:
- Water Y uses Constants.HEIGHT_SCALE, the SAME mapping as the terrain
  surface, named so the sheet and the seabed cannot drift apart if the
  vertical band is ever retuned.
- Depth for shading comes from BLUEPRINT heights, not rendered geometry:
  the terrain render clamps its floor at Y=2, so every deep-ocean column
  would otherwise read as one flat ~36 m and the gradient would die
  exactly where the ocean gets interesting.
- A cell is drawn if ANY corner is wet, flat at the highest wet level.
  Drawing onto a partly-dry cell is deliberate -- it carries the sheet
  under the shoreline where the opaque terrain hides it. Only-fully-wet
  cells retreat the waterline a metre and leave a dry gap around every
  coast and lake.
- Non-metallic, roughness 0.35: the scene environment is minimal, and a
  metallic surface reads near-black when there is nothing to reflect.

BlockRegistry gains WATER (id 11). Wire-safe: block IDs are never
serialized -- the blueprint's section table stores heights, biome ordinals
and water data, never block IDs, and chunks are not persisted yet.

MEASURED on seed 1825907253: 241,415 water columns across 454 of 4096
chunks; surface Y 37.6..37.6 m (flat, = 0.15 x 251 -- the flat sea model,
rendered); depth to 32.3 m, matching an independent read of the blueprint
exactly. Both an ocean and a lake fall in the default view.

NO REGRESSION to the 2D pipeline, verified section by section: a
regeneration of the working seed is byte-identical to task 12's run in
TCRV, TDTL, HGTS, BIOM, WBID, WBTB, WSRF, TOWN and all four road
sections; only PRMS differs, and only in its write timestamp. All four
snapshot PNGs md5-identical.

No storms, no waves, no animation, no flow -- water at rest. Those are C2+.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-09 02:32:04 -04:00
parent a6d4b870fc
commit 9c255a4fc6
5 changed files with 282 additions and 8 deletions

View file

@ -21,6 +21,21 @@ namespace IslaApocalypse.Core
public const byte WASTELAND_DIRT = 9;
public const byte ASPHALT = 10; // For our roads!
// Water (terrain-water task 13). Wire-safe to add: block IDs are NEVER
// serialized — the blueprint's section table (BLUEPRINT_FORMAT.md) stores
// heights, biome ordinals and water data, never block IDs, and chunks are
// not persisted yet. So this ID is a runtime-only value and appending to
// the table cannot invalidate a .dat.
//
// NOTE this block is not placed into ChunkData.BlockIDs by the terrain
// path. Terrain is a Marching-Cubes iso-surface over a density field, and
// writing WATER into that field would FUSE the water into the terrain
// surface rather than lay a sheet on top of it. Water is its own mesh
// (ChunkRenderer). The entry exists so water has a real identity in the
// registry — a name and a colour — for the renderer and for whatever
// later needs to ask "what is this".
public const byte WATER = 11;
// This static constructor runs automatically the first time the registry is accessed
static BlockRegistry()
{
@ -42,6 +57,10 @@ namespace IslaApocalypse.Core
// Infrastructure
Blocks.Add(ASPHALT, new BlockData(ASPHALT, "Asphalt", true, new Color(0.15f, 0.15f, 0.15f)));
// Water — not solid (you can move through it), shallow-water colour as the
// registry's representative value; the renderer shades by depth from there.
Blocks.Add(WATER, new BlockData(WATER, "Water", false, new Color(0.24f, 0.55f, 0.62f)));
}
public static BlockData GetBlock(byte id)

View file

@ -37,6 +37,23 @@ namespace IslaApocalypse.Core
// doesn't get painted like a motorway.
public byte[,] ColumnRoadMaterial = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
// --- WATER AT REST (terrain-water task 13) ---------------------------
// Per column: the world-space Y of the water SURFACE, or Constants.NO_WATER
// where the blueprint says this column is dry. Filled by the server from
// the blueprint's WSRF/WBID/WBTB — the runtime never decides where water
// is, it only draws what the blueprint already classified.
//
// WaterDepthM is the TRUE depth in metres (water level minus seabed, both
// in blueprint units), kept separately because the rendered seabed is
// clamped at Y=2 and would flatten every deep-ocean column to the same
// value. Colour reads this; geometry reads WaterSurfaceY.
public float[,] WaterSurfaceY = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
public float[,] WaterDepthM = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
/// <summary>True if any column in this chunk carries water — lets the
/// renderer skip building a water mesh for the many inland chunks.</summary>
public bool HasAnyWater = false;
// For our future delta-save system
public bool IsPlayerProtected = false;
public bool NeedsSaving = false;
@ -44,6 +61,12 @@ namespace IslaApocalypse.Core
public ChunkData(Vector2I position)
{
ChunkPosition = position;
// float[,] defaults to 0, which is a legal-looking water height. Start
// every column explicitly dry instead.
for (int x = 0; x <= Constants.CHUNK_SIZE_X; x++)
for (int z = 0; z <= Constants.CHUNK_SIZE_Z; z++)
WaterSurfaceY[x, z] = Constants.NO_WATER;
}
}
}

View file

@ -75,6 +75,40 @@ namespace IslaApocalypse.Core
/// <summary>Safety margin added to the road cull, in metres.</summary>
public const float ROAD_CULL_MARGIN = 3.0f;
// --- WATER AT REST (terrain-water task 13) --------------------------
// The blueprint has carried water since task 03; this is only about
// DRAWING it. Water is a flat sheet at the body's own surface level —
// no waves, no flow, no animation. Those are C2+.
/// <summary>
/// Sentinel in ChunkData.WaterSurfaceY for "this column has no water".
/// Real surface heights are always >= 2 (the terrain clamp's floor), so a
/// negative value is unambiguous.
/// </summary>
public const float NO_WATER = -1.0f;
/// <summary>
/// Raw blueprint height -> world metres. One raw unit is this many metres,
/// and it is exactly the terrain mapping (CHUNK_HEIGHT - 5), named so the
/// water surface and the seabed cannot drift apart if the band is retuned.
/// </summary>
public const float HEIGHT_SCALE = CHUNK_HEIGHT - 5;
// Depth shading. Depth is measured from the BLUEPRINT heights, not the
// rendered geometry: the terrain render clamps its floor at Y=2, so deep
// ocean would otherwise all read as one flat ~36 m and the gradient would
// die exactly where the ocean gets interesting.
public const float WATER_DEEP_METERS = 60.0f; // depth at which the gradient bottoms out
public static readonly Color WATER_SHALLOW = new Color(0.38f, 0.76f, 0.78f);
public static readonly Color WATER_DEEP = new Color(0.05f, 0.17f, 0.42f);
/// <summary>
/// Water alpha, shallow -> deep. Shallows are clearer, so the shelved coast
/// and the seabed under it stay readable; depth closes the surface up.
/// </summary>
public const float WATER_ALPHA_SHALLOW = 0.62f;
public const float WATER_ALPHA_DEEP = 0.97f;
/// <summary>
/// Looks up the carve settings for a road tier. One place to tune.
/// </summary>