islaApocalypse/Server/Scripts/ServerChunkManager.cs
beezm a827595d61 whoops fix: bilinear height sampling in road carve (audit C10/Q6.B)
HeightAtPixel read a single heightmap cell after truncating the coordinate with
(int). Road path points are fractional and spaced under a metre apart, so
consecutive points truncated into the same cell (flat), then tipped into the
next one (a full inter-cell jump) — a metre-scale staircase under F2's fix.

It now reads the four surrounding cells and blends them bilinearly using the
fractional part of the coordinate, so height varies continuously with position.
Edge clamping is preserved: at the map border the second cell clamps back onto
the first, making the blend a no-op rather than an out-of-range read.

Only the sampling helper changed. Signature and both callers are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 03:27:47 -04:00

305 lines
12 KiB
C#

using Godot;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Server
{
public partial class ServerChunkManager : Node
{
private WorldBlueprint _blueprint;
private Dictionary<Vector2I, ChunkData> _activeChunks = new Dictionary<Vector2I, ChunkData>();
public int chunkSize = 24;
public override void _Ready()
{
// 1. LOAD CONFIGURATION and the seed-based blueprint data from the MapDataParser!
ConfigManager.LoadConfig();
chunkSize = ConfigManager.ChunkRadius; // Update chunk radius from config file
_blueprint = MapDataParser.LoadMapData(ConfigManager.WorldSeed.ToString());
if (_blueprint != null)
{
GD.Print("[Server] Blueprint loaded. Locating Capitol City...");
// 2. Find the Capitol in the parsed data
Vector2 capitolPos = new Vector2(2048, 2048); // Safe fallback center
foreach (var town in _blueprint.Towns) {
if (town.Tier == TownTier.Capitol) {
capitolPos = town.Position;
break;
}
}
// 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
GD.Print($"[Server] Capitol found at {capitolPos}. Generating chunks...");
// 3. Convert pixel coordinates to Chunk coordinates
int capitolChunkX = (int)(capitolPos.X / Constants.CHUNK_SIZE_X);
int capitolChunkZ = (int)(capitolPos.Y / Constants.CHUNK_SIZE_Z);
int radius = ConfigManager.ChunkRadius;
for (int x = capitolChunkX - radius; x < capitolChunkX + radius; x++)
{
for (int z = capitolChunkZ - radius; z < capitolChunkZ + radius; z++)
{
GenerateChunk(new Vector2I(x, z));
}
}
// 5. Teleport the Camera to look down at our creation!
Camera3D cam = GetNodeOrNull<Camera3D>("Camera3D");
if (cam != null)
{
// Put the camera 120 meters in the air above the Capitol
cam.GlobalPosition = new Vector3(capitolPos.X, 120f, capitolPos.Y);
// Point it straight down at the ground
cam.LookAt(new Vector3(capitolPos.X, 0, capitolPos.Y));
}
}
}
public void GenerateChunk(Vector2I chunkCoord)
{
if (_activeChunks.ContainsKey(chunkCoord)) return;
ChunkData newChunk = new ChunkData(chunkCoord);
int startX = chunkCoord.X * Constants.CHUNK_SIZE_X;
int startZ = chunkCoord.Y * Constants.CHUNK_SIZE_Z;
// --- NEW: SPATIAL CULLING FOR ROADS ---
// Create a bounding box for this chunk, plus a 15-meter padding to account for the road's dirt shoulders
Rect2 chunkBounds = new Rect2(startX - 15, startZ - 15, Constants.CHUNK_SIZE_X + 30, Constants.CHUNK_SIZE_Z + 30);
List<Vector2[]> localRoadSegments = new List<Vector2[]>();
// Filter Highways: Only save the segments that actually cross this specific chunk!
foreach (var highway in _blueprint.Highways) {
for (int i = 0; i < highway.Length - 1; i++) {
Vector2 a = highway[i];
Vector2 b = highway[i+1];
Rect2 segBounds = new Rect2(Mathf.Min(a.X, b.X), Mathf.Min(a.Y, b.Y), Mathf.Abs(b.X - a.X), Mathf.Abs(b.Y - a.Y));
if (chunkBounds.Intersects(segBounds.Grow(1.0f))) {
localRoadSegments.Add(new Vector2[] { a, b });
}
}
}
// Filter Branch Roads
foreach (var branch in _blueprint.BranchRoads) {
for (int i = 0; i < branch.Length - 1; i++) {
Vector2 a = branch[i];
Vector2 b = branch[i+1];
Rect2 segBounds = new Rect2(Mathf.Min(a.X, b.X), Mathf.Min(a.Y, b.Y), Mathf.Abs(b.X - a.X), Mathf.Abs(b.Y - a.Y));
if (chunkBounds.Intersects(segBounds.Grow(1.0f))) {
localRoadSegments.Add(new Vector2[] { a, b });
}
}
}
// ---------------------------------------
for (int x = 0; x <= Constants.CHUNK_SIZE_X; x++)
{
for (int z = 0; z <= Constants.CHUNK_SIZE_Z; z++)
{
int globalX = startX + x;
int globalZ = startZ + z;
if (globalX >= _blueprint.MapSize || globalZ >= _blueprint.MapSize || globalX < 0 || globalZ < 0)
continue;
float GetExactSurface(int gX, int gZ, out bool isRoad)
{
isRoad = false;
if (gX >= _blueprint.MapSize) gX = _blueprint.MapSize - 1;
if (gZ >= _blueprint.MapSize) gZ = _blueprint.MapSize - 1;
float raw = _blueprint.HeightMap[gX, gZ];
float baseHeight = Mathf.Clamp(raw * (Constants.CHUNK_HEIGHT - 5), 2.0f, Constants.CHUNK_HEIGHT - 2.0f);
// If no roads are in this chunk, skip the heavy math entirely!
if (localRoadSegments.Count == 0) return baseHeight;
float finalHeight = baseHeight;
float minDist = 9999f;
Vector2 currentPos = new Vector2(gX, gZ);
float roadRadius = 4.0f; // The flat asphalt part (8 meters total width)
float shoulderRadius = 12.0f; // The sloped dirt/rock carving into the mountain
float closestRoadElevation = baseHeight;
foreach (var seg in localRoadSegments)
{
// 'alongT' tells us HOW FAR ALONG this segment the nearest point is
// (0 = at the start point, 1 = at the end point).
float dist = DistanceToLineSegment(currentPos, seg[0], seg[1], out float alongT);
if (dist < minDist)
{
minDist = dist;
if (dist <= shoulderRadius)
{
// F2 FIX — the road elevation is now taken at the point on the
// segment CLOSEST TO US, not at the segment's midpoint.
//
// The old code gave every column near a segment that segment's
// single midpoint height, so each stretch of road was one flat
// plank and consecutive planks stepped up/down like a staircase.
//
// Two honest ways to read the height at our closest point:
// rampElevation - a straight line between this segment's two
// end points. Holds a grade; cuts and fills.
// landElevation - the actual terrain under that point.
// Hugs the land; inherits its bumps.
// ROAD_GRADE_SMOOTHING dials between them (see D-021).
//
// Either way it varies CONTINUOUSLY as we move along the road,
// which is what kills the steps. And because neighbouring
// segments share an end point, the height matches exactly where
// one segment hands over to the next — no seam at the joins.
float rampElevation = Mathf.Lerp(HeightAtPixel(seg[0]), HeightAtPixel(seg[1]), alongT);
float landElevation = HeightAtPixel(seg[0].Lerp(seg[1], alongT));
closestRoadElevation = Mathf.Lerp(landElevation, rampElevation, Constants.ROAD_GRADE_SMOOTHING);
}
}
}
// THE BULLDOZER: Carve the terrain
if (minDist <= roadRadius) {
finalHeight = closestRoadElevation; // Flatten it completely!
isRoad = true;
} else if (minDist <= shoulderRadius) {
// Smoothly interpolate from the flat road up/down to the natural mountain height
float t = (minDist - roadRadius) / (shoulderRadius - roadRadius);
t = t * t * (3f - 2f * t); // SmoothStep equation for a beautiful curved slope
finalHeight = Mathf.Lerp(closestRoadElevation, baseHeight, t);
}
return finalHeight;
}
// Pass the boolean out for the main voxel so we can paint it!
bool isMainRoad = false;
float exactSurfaceY = GetExactSurface(globalX, globalZ, out isMainRoad);
// We discard the 'out' variable for the normals using an underscore '_'
float surfaceRight = GetExactSurface(globalX + 1, globalZ, out _);
float surfaceFwd = GetExactSurface(globalX, globalZ + 1, out _);
Biome columnBiome = _blueprint.BiomeMap[globalX, globalZ];
// Hand the renderer the TRUE surface height for this column, plus the
// biome and road flag that go with it. The BlockIDs below still use the
// rounded height (unchanged) — but the colour no longer has to.
newChunk.SurfaceHeights[x, z] = exactSurfaceY;
newChunk.ColumnBiomes[x, z] = columnBiome;
newChunk.ColumnIsRoad[x, z] = isMainRoad;
float hRight = surfaceRight - exactSurfaceY;
float hFwd = surfaceFwd - exactSurfaceY;
float slopeX = hRight / Constants.VOXEL_SCALE;
float slopeZ = hFwd / Constants.VOXEL_SCALE;
float len = Mathf.Sqrt(slopeX * slopeX + 1.0f + slopeZ * slopeZ);
for (int y = 0; y <= Constants.CHUNK_HEIGHT; y++)
{
float verticalDist = y - exactSurfaceY;
float density = verticalDist / len;
newChunk.Densities[x, y, z] = density;
int blockY = Mathf.RoundToInt(exactSurfaceY);
// THE PAINT: Pass isMainRoad into the Biome Palette!
newChunk.BlockIDs[x, y, z] = BiomePalette.GetVoxelID(columnBiome, blockY, y, isMainRoad);
}
} // End of Z loop
} // End of X loop
_activeChunks.Add(chunkCoord, newChunk);
var renderer = new IslaApocalypse.Client.ChunkRenderer();
AddChild(renderer);
renderer.RenderChunk(newChunk);
}
/// <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
/// usable vertical band of the chunk.
///
/// The position handed in is usually FRACTIONAL (road path points land between
/// heightmap cells), so we read the four cells around it and blend — bilinear
/// interpolation — rather than snapping to whichever cell we happen to land in.
///
/// Why that matters: snapping made the height jump by a whole cell's worth the
/// instant a point crossed a cell boundary, and hold perfectly flat until it did.
/// Along a road whose points sit less than a metre apart, that produced a fine
/// metre-scale staircase — the "washboard". Blending makes the height vary
/// continuously as the position moves, so the staircase has nothing to stand on.
/// </summary>
private float HeightAtPixel(Vector2 pixel)
{
int max = _blueprint.MapSize - 1;
// The four heightmap cells surrounding this position.
// At the very edge of the map x1/y1 clamp back onto x0/y0, which makes the
// blend a harmless no-op instead of reading out of bounds.
int x0 = Mathf.Clamp(Mathf.FloorToInt(pixel.X), 0, max);
int y0 = Mathf.Clamp(Mathf.FloorToInt(pixel.Y), 0, max);
int x1 = Mathf.Clamp(x0 + 1, 0, max);
int y1 = Mathf.Clamp(y0 + 1, 0, max);
// How far between those cells we actually are, 0 to 1 on each axis.
// Clamped so a position outside the map can't push the blend past its corners.
float fx = Mathf.Clamp(pixel.X - x0, 0.0f, 1.0f);
float fy = Mathf.Clamp(pixel.Y - y0, 0.0f, 1.0f);
// Blend across X on the near row and the far row, then blend those two down Y.
float nearRow = Mathf.Lerp(_blueprint.HeightMap[x0, y0], _blueprint.HeightMap[x1, y0], fx);
float farRow = Mathf.Lerp(_blueprint.HeightMap[x0, y1], _blueprint.HeightMap[x1, y1], fx);
float raw = Mathf.Lerp(nearRow, farRow, fy);
return Mathf.Clamp(raw * (Constants.CHUNK_HEIGHT - 5), 2.0f, Constants.CHUNK_HEIGHT - 2.0f);
}
/// <summary>
/// Calculates the shortest distance from a point to a line segment defined by v and w.
/// </summary>
private float DistanceToLineSegment(Vector2 point, Vector2 v, Vector2 w)
{
return DistanceToLineSegment(point, v, w, out _);
}
/// <summary>
/// Same as above, but also reports WHERE along the segment the nearest point falls:
/// <paramref name="t"/> is 0 at v, 1 at w. The road carving needs this so it can read
/// the height at the spot next to us instead of at the segment's midpoint.
/// </summary>
private float DistanceToLineSegment(Vector2 point, Vector2 v, Vector2 w, out float t)
{
float l2 = v.DistanceSquaredTo(w);
if (l2 == 0) // v == w case
{
t = 0f;
return point.DistanceTo(v);
}
// Consider the line extending the segment, parameterized as v + t (w - v).
// We find projection of point p onto the line.
// It falls where t = [(p-v) . (w-v)] / |w-v|^2
t = Mathf.Max(0, Mathf.Min(1, (point - v).Dot(w - v) / l2));
// Projection falls on the segment
Vector2 projection = v + t * (w - v);
return point.DistanceTo(projection);
}
}
}