roads: enable Rugged+Trail carving + per-tier character (D-022)

All four road tiers now carve into the 3D world. Rugged and Trail were
generated, exported and parsed all along, but nothing ever consumed them —
which is why county roads visible on the 2D map did not exist in 3D.

Tier identity is now carried into the carve via a RoadSegment struct, so each
tier gets its own width, shoulder and grade-smoothing from one tunable block in
Constants: highways widest and holding a grade, trails narrow and hugging the
land. Rugged and Trail surface as dirt rather than asphalt.

Because tiers now have different reach, nearest-by-distance was no longer a
sound way to pick the governing road — a nearby footpath could shadow a highway
whose shoulder still covered the column. The carve now considers only roads
whose shoulder actually reaches, and takes the closest of those.

The per-chunk cull pad is derived from the widest shoulder plus a margin, so
widening a road cannot silently truncate it at chunk edges.

Untouched: density field, Marching Cubes interpolation/welding, chunk dims,
the .dat contract, the 2D A* generation, and mesher normals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-05 03:53:32 -04:00
parent a827595d61
commit f9ea2da3f4
7 changed files with 255 additions and 130 deletions

View file

@ -4,6 +4,25 @@ using IslaApocalypse.Core;
namespace IslaApocalypse.Server
{
/// <summary>
/// One straight stretch of road, plus which tier it belongs to. The tier is what
/// decides how wide and how smoothly it gets carved (D-022) — before this existed,
/// every road was bulldozed to the same dimensions regardless of what it was.
/// </summary>
public struct RoadSegment
{
public Vector2 A;
public Vector2 B;
public RoadTier Tier;
public RoadSegment(Vector2 a, Vector2 b, RoadTier tier)
{
A = a;
B = b;
Tier = tier;
}
}
public partial class ServerChunkManager : Node
{
private WorldBlueprint _blueprint;
@ -73,33 +92,40 @@ namespace IslaApocalypse.Server
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[]>();
// --- SPATIAL CULLING FOR ROADS ---
// Bounding box for this chunk, padded so a road running just outside it still
// carves its shoulder in. The pad comes from the widest shoulder any tier has
// (plus a margin), so widening a road can't silently outgrow this and clip
// roads off at chunk edges.
float roadPad = Constants.MAX_SHOULDER_RADIUS + Constants.ROAD_CULL_MARGIN;
Rect2 chunkBounds = new Rect2(
startX - roadPad, startZ - roadPad,
Constants.CHUNK_SIZE_X + roadPad * 2, Constants.CHUNK_SIZE_Z + roadPad * 2);
// 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 });
List<RoadSegment> localRoadSegments = new List<RoadSegment>();
// Keep only the segments that actually reach this chunk, and remember which
// TIER each came from — that is what decides how wide and smooth it carves.
void CollectSegments(List<Vector2[]> paths, RoadTier tier)
{
foreach (var path in paths) {
for (int i = 0; i < path.Length - 1; i++) {
Vector2 a = path[i];
Vector2 b = path[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 RoadSegment(a, b, tier));
}
}
}
}
// All four tiers now carve. Rugged and Trail are the county roads — they were
// generated and saved all along, but nothing ever consumed them.
CollectSegments(_blueprint.Highways, RoadTier.Highway);
CollectSegments(_blueprint.BranchRoads, RoadTier.Branch);
CollectSegments(_blueprint.RuggedRoads, RoadTier.Rugged);
CollectSegments(_blueprint.TrailRoads, RoadTier.Trail);
// ---------------------------------------
for (int x = 0; x <= Constants.CHUNK_SIZE_X; x++)
@ -112,9 +138,9 @@ namespace IslaApocalypse.Server
if (globalX >= _blueprint.MapSize || globalZ >= _blueprint.MapSize || globalX < 0 || globalZ < 0)
continue;
float GetExactSurface(int gX, int gZ, out bool isRoad)
float GetExactSurface(int gX, int gZ, out byte roadMaterial)
{
isRoad = false;
roadMaterial = BlockRegistry.AIR; // 0 == "not a road"
if (gX >= _blueprint.MapSize) gX = _blueprint.MapSize - 1;
if (gZ >= _blueprint.MapSize) gZ = _blueprint.MapSize - 1;
@ -125,67 +151,78 @@ namespace IslaApocalypse.Server
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;
// Details of the nearest road that actually REACHES us. Each tier has
// its own reach, so "nearest" alone isn't enough — a footpath two
// metres away must not shadow a highway whose shoulder still covers
// us. We therefore ignore any road we're outside the shoulder of, and
// take the closest of what's left.
bool foundRoad = false;
float bestDist = float.MaxValue;
float bestRoadElevation = baseHeight;
RoadProfile bestProfile = default;
foreach (var seg in localRoadSegments)
{
RoadProfile profile = Constants.GetRoadProfile(seg.Tier);
// '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));
float dist = DistanceToLineSegment(currentPos, seg.A, seg.B, out float alongT);
closestRoadElevation = Mathf.Lerp(landElevation, rampElevation, Constants.ROAD_GRADE_SMOOTHING);
}
}
if (dist > profile.ShoulderRadius) continue; // this road doesn't reach us
if (dist >= bestDist) continue; // a closer one already won
// F2 FIX — the road elevation is 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.
// The tier's GradeSmoothing dials between them (D-021 / D-022):
// a highway holds its grade, a trail follows the ground.
//
// 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.A), HeightAtPixel(seg.B), alongT);
float landElevation = HeightAtPixel(seg.A.Lerp(seg.B, alongT));
foundRoad = true;
bestDist = dist;
bestProfile = profile;
bestRoadElevation = Mathf.Lerp(landElevation, rampElevation, profile.GradeSmoothing);
}
// 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);
// THE BULLDOZER: Carve the terrain, using the winning road's own dimensions.
if (foundRoad)
{
if (bestDist <= bestProfile.RoadRadius) {
finalHeight = bestRoadElevation; // Flatten it completely!
roadMaterial = bestProfile.SurfaceMaterial;
} else {
// Smoothly interpolate from the flat road up/down to the natural mountain height
float t = (bestDist - bestProfile.RoadRadius) / (bestProfile.ShoulderRadius - bestProfile.RoadRadius);
t = t * t * (3f - 2f * t); // SmoothStep equation for a beautiful curved slope
finalHeight = Mathf.Lerp(bestRoadElevation, 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);
// Pass the road surface out for the main voxel so we can paint it!
// 0 (AIR) means this column is not part of any road.
byte roadSurface = BlockRegistry.AIR;
float exactSurfaceY = GetExactSurface(globalX, globalZ, out roadSurface);
// We discard the 'out' variable for the normals using an underscore '_'
float surfaceRight = GetExactSurface(globalX + 1, globalZ, out _);
@ -198,7 +235,7 @@ namespace IslaApocalypse.Server
// 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;
newChunk.ColumnRoadMaterial[x, z] = roadSurface;
float hRight = surfaceRight - exactSurfaceY;
float hFwd = surfaceFwd - exactSurfaceY;
@ -216,8 +253,8 @@ namespace IslaApocalypse.Server
int blockY = Mathf.RoundToInt(exactSurfaceY);
// THE PAINT: Pass isMainRoad into the Biome Palette!
newChunk.BlockIDs[x, y, z] = BiomePalette.GetVoxelID(columnBiome, blockY, y, isMainRoad);
// THE PAINT: Pass the road surface into the Biome Palette!
newChunk.BlockIDs[x, y, z] = BiomePalette.GetVoxelID(columnBiome, blockY, y, roadSurface);
}
} // End of Z loop
} // End of X loop