islaApocalypse/Client/Scripts/ChunkRenderer.cs
beezm 9c255a4fc6 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>
2026-08-09 02:32:04 -04:00

162 lines
6.2 KiB
C#

using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Client
{
// Notice this inherits from MeshInstance3D, which is Godot's visual 3D node!
public partial class ChunkRenderer : MeshInstance3D
{
public void RenderChunk(ChunkData data)
{
// 1. Pass the BlockIDs array to the generator, plus the per-column
// surface/biome/road data it needs to fade colours smoothly.
Mesh = MarchingCubes.GenerateMesh(
data.Densities, data.BlockIDs,
data.SurfaceHeights, data.ColumnBiomes, data.ColumnRoadMaterial,
Constants.ISO_LEVEL, Constants.VOXEL_SCALE);
StandardMaterial3D mat = new StandardMaterial3D();
// 2. THE FIX: Tell the material to use the colors we paint on the vertices!
mat.VertexColorUseAsAlbedo = true;
mat.Roughness = 0.8f;
mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled;
MaterialOverride = mat;
Position = new Vector3(
data.ChunkPosition.X * Constants.CHUNK_SIZE_X * Constants.VOXEL_SCALE,
0,
data.ChunkPosition.Y * Constants.CHUNK_SIZE_Z * Constants.VOXEL_SCALE
);
// 3. Water at rest (task 13) — its own mesh, its own material.
BuildWaterSurface(data);
}
/// <summary>
/// The water sheet, as a SEPARATE MeshInstance3D child.
///
/// Why separate rather than 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 water into the terrain as if the sea were solid
/// ground. A second surface is the only way to have water sit AT its own
/// level, independent of the ground beneath it.
///
/// It also makes translucency nearly free, which is why this task ships it
/// rather than deferring: a distinct MeshInstance3D carries its own
/// StandardMaterial3D, so alpha is one flag and Godot sorts transparent
/// surfaces after opaque ones on its own. No mesher change at all.
///
/// Water at rest is FLAT — one level per cell, taken from the blueprint's own
/// body levels. No waves, no displacement, no animation (those are C2+).
/// </summary>
private void BuildWaterSurface(ChunkData data)
{
if (!data.HasAnyWater) return; // most inland chunks: nothing to build
var st = new SurfaceTool();
st.Begin(Mesh.PrimitiveType.Triangles);
int cellsX = Constants.CHUNK_SIZE_X;
int cellsZ = Constants.CHUNK_SIZE_Z;
bool any = false;
for (int x = 0; x < cellsX; x++)
{
for (int z = 0; z < cellsZ; z++)
{
// A cell is drawn if ANY of its four corners is wet, and it is drawn
// FLAT at the deepest-standing level among those wet corners.
//
// Drawing on a partly-dry cell is deliberate: it carries the sheet
// right up under the shoreline, where the opaque terrain in front of
// it hides the submerged part. The alternative — only fully-wet
// cells — retreats the waterline a metre from shore and leaves a
// visible dry gap all the way around every coast and lake.
float level = Constants.NO_WATER;
float d00 = data.WaterSurfaceY[x, z];
float d10 = data.WaterSurfaceY[x + 1, z];
float d01 = data.WaterSurfaceY[x, z + 1];
float d11 = data.WaterSurfaceY[x + 1, z + 1];
if (d00 > level) level = d00;
if (d10 > level) level = d10;
if (d01 > level) level = d01;
if (d11 > level) level = d11;
if (level <= Constants.NO_WATER) continue;
// Depth for colour: the deepest wet corner of the cell, so a shelving
// coast reads as a gradient rather than a staircase of flat tiles.
float depth = Mathf.Max(
Mathf.Max(data.WaterDepthM[x, z], data.WaterDepthM[x + 1, z]),
Mathf.Max(data.WaterDepthM[x, z + 1], data.WaterDepthM[x + 1, z + 1]));
Color c = DepthColor(depth);
float s = Constants.VOXEL_SCALE;
var p00 = new Vector3(x * s, level, z * s);
var p10 = new Vector3((x + 1) * s, level, z * s);
var p01 = new Vector3(x * s, level, (z + 1) * s);
var p11 = new Vector3((x + 1) * s, level, (z + 1) * s);
// Two triangles, wound so the surface faces up.
AddVert(st, c, p00); AddVert(st, c, p01); AddVert(st, c, p11);
AddVert(st, c, p00); AddVert(st, c, p11); AddVert(st, c, p10);
any = true;
}
}
if (!any) return;
var water = new MeshInstance3D();
water.Mesh = st.Commit();
var mat = new StandardMaterial3D();
mat.VertexColorUseAsAlbedo = true;
// Vertex alpha is only honoured when the material is actually transparent.
mat.Transparency = BaseMaterial3D.TransparencyEnum.Alpha;
// Lighting note: the scene's environment is minimal (one default
// DirectionalLight3D, a blue ambient), so a metallic surface reads almost
// black — metal shows its surroundings, and there is little to show. Water
// is therefore non-metallic with moderate roughness: albedo stays visible
// under weak ambient, and it still catches a highlight when a light does
// hit it. Left deliberately in the same lighting world as the terrain so
// the two improve together when the scene gets proper lighting.
mat.Roughness = 0.35f;
mat.Metallic = 0.0f;
// Both faces: the camera starts above, but a player will swim under it.
mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled;
water.MaterialOverride = mat;
// Child of this chunk renderer, so it inherits the chunk's world offset
// and dies with the chunk.
AddChild(water);
water.Position = Vector3.Zero;
water.Name = "Water";
}
private static void AddVert(SurfaceTool st, Color c, Vector3 p)
{
// SetColor/SetNormal must be set immediately before AddVertex — same rule
// the terrain mesher follows.
st.SetColor(c);
st.SetNormal(Vector3.Up);
st.AddVertex(p);
}
/// <summary>
/// Shallow teal to deep navy, with alpha closing up as it deepens, so the
/// shelved coast stays readable and open ocean does not.
/// </summary>
private static Color DepthColor(float depthMeters)
{
float t = Mathf.Clamp(depthMeters / Constants.WATER_DEEP_METERS, 0f, 1f);
t = t * t * (3f - 2f * t); // smoothstep: hold the shallows, then fall away
Color c = Constants.WATER_SHALLOW.Lerp(Constants.WATER_DEEP, t);
c.A = Mathf.Lerp(Constants.WATER_ALPHA_SHALLOW, Constants.WATER_ALPHA_DEEP, t);
return c;
}
}
}