From a827595d617f7aa5ae99320835787d3ef0972c6b Mon Sep 17 00:00:00 2001 From: beezm Date: Wed, 5 Aug 2026 03:27:47 -0400 Subject: [PATCH] whoops fix: bilinear height sampling in road carve (audit C10/Q6.B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Server/Scripts/ServerChunkManager.cs | 32 +++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/Server/Scripts/ServerChunkManager.cs b/Server/Scripts/ServerChunkManager.cs index 5170f0e..71a24f0 100644 --- a/Server/Scripts/ServerChunkManager.cs +++ b/Server/Scripts/ServerChunkManager.cs @@ -233,13 +233,39 @@ namespace IslaApocalypse.Server /// 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. /// private float HeightAtPixel(Vector2 pixel) { - int px = Mathf.Clamp((int)pixel.X, 0, _blueprint.MapSize - 1); - int py = Mathf.Clamp((int)pixel.Y, 0, _blueprint.MapSize - 1); + 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); - float raw = _blueprint.HeightMap[px, py]; return Mathf.Clamp(raw * (Constants.CHUNK_HEIGHT - 5), 2.0f, Constants.CHUNK_HEIGHT - 2.0f); }