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

@ -29,6 +29,11 @@ namespace IslaApocalypse.Server
private Dictionary<Vector2I, ChunkData> _activeChunks = new Dictionary<Vector2I, ChunkData>();
public int chunkSize = 24;
// Water-at-rest diagnostics (task 13), summed across the chunks generated.
private long _waterColumnsRendered = 0;
private int _chunksWithWater = 0;
private float _waterMinY = float.MaxValue, _waterMaxY = float.MinValue, _waterMaxDepth = 0f;
public override void _Ready()
{
@ -69,7 +74,8 @@ namespace IslaApocalypse.Server
// Old original png map coords
// Vector2 capitolPos = new Vector2(2405, 3296); // hardcoded for now since we know exactly where it is in this seed, which is the "paradise" biome hub
// New Manual Test point
// Vector2 capitolPos = new Vector2(4487, 4424);
GD.Print($"[Server] Capitol found at {capitolPos}. Generating chunks...");
@ -87,6 +93,15 @@ namespace IslaApocalypse.Server
}
}
// Say what was actually drawn. The water is the blueprint's, not the
// runtime's, so if this reads zero the question is which of the two
// sides went quiet — and this line answers it without a debugger.
GD.Print($"[Server] Water at rest: {_waterColumnsRendered} water columns across " +
$"{_chunksWithWater} of {_activeChunks.Count} chunks" +
(_waterColumnsRendered > 0
? $"; surface Y {_waterMinY:F1}..{_waterMaxY:F1} m, depth up to {_waterMaxDepth:F1} m."
: " — nothing to draw here (check the blueprint carries WBID/WSRF)."));
// 5. Teleport the Camera to look down at our creation!
Camera3D cam = GetNodeOrNull<Camera3D>("Camera3D");
if (cam != null)
@ -253,6 +268,46 @@ namespace IslaApocalypse.Server
newChunk.ColumnBiomes[x, z] = columnBiome;
newChunk.ColumnRoadMaterial[x, z] = roadSurface;
// --- WATER AT REST (task 13) ---------------------------------
// The blueprint is the AUTHORITY on where water is; the runtime
// only draws it. WBID decides presence (it is the water stage's
// own classification output, the same set the biome grid's
// Ocean/Lake pixels form by construction), WSRF gives the level
// per pixel, and the WBTB body table is the fallback if a column
// is flagged wet but carries the WSRF no-water sentinel.
if (_blueprint.WaterBodyIds != null)
{
ushort bodyId = _blueprint.WaterBodyIds[globalX, globalZ];
if (bodyId != 0)
{
float levelRaw = -1f;
if (_blueprint.WaterSurfaceQ != null)
{
ushort q = _blueprint.WaterSurfaceQ[globalX, globalZ];
if (q != 0) levelRaw = BlueprintFormat.DecodeWaterLevel(q);
}
if (levelRaw < 0f) levelRaw = BodyLevel(bodyId);
if (levelRaw >= 0f)
{
// Same mapping as the terrain, so the sheet and the
// seabed cannot drift apart.
newChunk.WaterSurfaceY[x, z] = Mathf.Clamp(
levelRaw * Constants.HEIGHT_SCALE, 2.0f, Constants.CHUNK_HEIGHT - 2.0f);
// TRUE depth, from blueprint units — see ChunkData.
newChunk.WaterDepthM[x, z] =
Mathf.Max(0f, (levelRaw - _blueprint.HeightMap[globalX, globalZ]) * Constants.HEIGHT_SCALE);
newChunk.HasAnyWater = true;
_waterColumnsRendered++;
float wy = newChunk.WaterSurfaceY[x, z];
if (wy < _waterMinY) _waterMinY = wy;
if (wy > _waterMaxY) _waterMaxY = wy;
if (newChunk.WaterDepthM[x, z] > _waterMaxDepth) _waterMaxDepth = newChunk.WaterDepthM[x, z];
}
}
}
float hRight = surfaceRight - exactSurfaceY;
float hFwd = surfaceFwd - exactSurfaceY;
float slopeX = hRight / Constants.VOXEL_SCALE;
@ -275,6 +330,7 @@ namespace IslaApocalypse.Server
} // End of Z loop
} // End of X loop
if (newChunk.HasAnyWater) _chunksWithWater++;
_activeChunks.Add(chunkCoord, newChunk);
var renderer = new IslaApocalypse.Client.ChunkRenderer();
@ -282,6 +338,21 @@ namespace IslaApocalypse.Server
renderer.RenderChunk(newChunk);
}
/// <summary>
/// A water body's flat surface level, from the blueprint's WBTB table. Only
/// used as the fallback when a column is flagged wet by WBID but its WSRF
/// entry is the no-water sentinel — WSRF is the per-pixel authority, this is
/// the per-body one. Returns -1 if the body is unknown, which the caller
/// reads as "leave this column dry" rather than guessing a level.
/// </summary>
private float BodyLevel(ushort bodyId)
{
if (_blueprint.WaterBodies == null) return -1f;
foreach (var body in _blueprint.WaterBodies)
if (body.Id == bodyId) return body.SurfaceLevel;
return -1f;
}
/// <summary>
/// Turns a map-pixel position into a world surface height, with bounds clamping.
/// Same mapping used everywhere else: raw 0-1 heightmap value scaled into the