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:
parent
a6d4b870fc
commit
9c255a4fc6
5 changed files with 282 additions and 8 deletions
|
|
@ -14,22 +14,149 @@ namespace IslaApocalypse.Client
|
|||
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.VertexColorUseAsAlbedo = true;
|
||||
mat.Roughness = 0.8f;
|
||||
mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled;
|
||||
|
||||
mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled;
|
||||
|
||||
MaterialOverride = mat;
|
||||
|
||||
Position = new Vector3(
|
||||
data.ChunkPosition.X * Constants.CHUNK_SIZE_X * Constants.VOXEL_SCALE,
|
||||
0,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue