From 32f06ac0d1edc223887c23366b9e918ceac8b2a4 Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 1 Mar 2026 11:39:29 -0800 Subject: [PATCH 1/3] feat: add ComputeNormalsFromExtended for cross-boundary accurate normals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses a (resolution+2)×(resolution+2) extended heightmap to compute per-vertex normals without edge clamping. Right edge of chunk(0,0) and left edge of chunk(1,0) now produce matching normals, eliminating the lighting seam at chunk boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wasm/terrain/normals.go | 41 ++++++++++++++++++++ wasm/terrain/normals_test.go | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 wasm/terrain/normals_test.go diff --git a/wasm/terrain/normals.go b/wasm/terrain/normals.go index c1c76e4..f10411f 100644 --- a/wasm/terrain/normals.go +++ b/wasm/terrain/normals.go @@ -32,3 +32,44 @@ func ComputeNormals(heightmap []float32, resolution int, chunkSize, heightScale } return out } + +// ComputeNormalsFromExtended computes per-vertex normals for a resolution×resolution chunk +// using a (resolution+2)×(resolution+2) extended heightmap that includes a 1-cell border +// from adjacent chunk space. Edge vertices get correct cross-boundary gradients (no clamping). +// +// extHeightmap: output of GenerateExtendedHeightmap — size (resolution+2)² +// Returns: flat float32 slice of size resolution*resolution*3 +func ComputeNormalsFromExtended(extHeightmap []float32, resolution int, chunkSize, heightScale float64) []float32 { + extRes := resolution + 2 + out := make([]float32, resolution*resolution*3) + spacing := chunkSize / float64(resolution-1) + + // In the extended grid, vertex (row, col) of the normal chunk sits at (row+1, col+1) + sampleH := func(extRow, extCol int) float64 { + return float64(extHeightmap[extRow*extRes+extCol]) * heightScale + } + + for row := range resolution { + for col := range resolution { + // Map to extended grid coordinates (offset by 1) + er := row + 1 + ec := col + 1 + + // Central differences — no clamping needed, border always exists + hL := sampleH(er, ec-1) + hR := sampleH(er, ec+1) + hD := sampleH(er-1, ec) + hU := sampleH(er+1, ec) + + nx := (hL - hR) / (2 * spacing) + nz := (hD - hU) / (2 * spacing) + ny := 1.0 + length := math.Sqrt(nx*nx + ny*ny + nz*nz) + idx := (row*resolution + col) * 3 + out[idx] = float32(nx / length) + out[idx+1] = float32(ny / length) + out[idx+2] = float32(nz / length) + } + } + return out +} diff --git a/wasm/terrain/normals_test.go b/wasm/terrain/normals_test.go new file mode 100644 index 0000000..58e24bc --- /dev/null +++ b/wasm/terrain/normals_test.go @@ -0,0 +1,74 @@ +package terrain_test + +import ( + "math" + "testing" + + "github.com/maxfelker/terrain-webgpu/wasm/terrain" +) + +func TestComputeNormalsFromExtended_Size(t *testing.T) { + cfg := terrain.DefaultConfig() + hm := terrain.GenerateHeightmap(0, 0, cfg) + ext := terrain.GenerateExtendedHeightmap(0, 0, cfg) + normals := terrain.ComputeNormalsFromExtended(ext, cfg.HeightmapResolution, float64(cfg.Dimension), float64(cfg.Height)) + expected := cfg.HeightmapResolution * cfg.HeightmapResolution * 3 + if len(normals) != expected { + t.Errorf("expected normals size %d, got %d", expected, len(normals)) + } + _ = hm +} + +func TestComputeNormalsFromExtended_CrossChunkConsistency(t *testing.T) { + cfg := terrain.DefaultConfig() + res := cfg.HeightmapResolution // 129 + dim := float64(cfg.Dimension) + h := float64(cfg.Height) + + // Compute extended normals for chunk (0,0) — right edge (col=res-1) + ext00 := terrain.GenerateExtendedHeightmap(0, 0, cfg) + normals00 := terrain.ComputeNormalsFromExtended(ext00, res, dim, h) + + // Compute extended normals for chunk (1,0) — left edge (col=0) + ext10 := terrain.GenerateExtendedHeightmap(1, 0, cfg) + normals10 := terrain.ComputeNormalsFromExtended(ext10, res, dim, h) + + // Right edge of chunk(0,0) normals should match left edge of chunk(1,0) normals + // (they sample the same heights via the extended border) + const tolerance = 1e-4 + for row := range res { + rightEdgeIdx := (row*res + (res - 1)) * 3 + leftEdgeIdx := (row * res) * 3 + + for c := range 3 { + n00 := normals00[rightEdgeIdx+c] + n10 := normals10[leftEdgeIdx+c] + diff := n00 - n10 + if diff < 0 { + diff = -diff + } + if diff > float32(tolerance) { + t.Errorf("normal mismatch at row=%d component=%d: chunk(0,0) right=%.5f chunk(1,0) left=%.5f", + row, c, n00, n10) + } + } + } +} + +func TestComputeNormalsFromExtended_NormalizedUnit(t *testing.T) { + cfg := terrain.DefaultConfig() + ext := terrain.GenerateExtendedHeightmap(2, 3, cfg) + normals := terrain.ComputeNormalsFromExtended(ext, cfg.HeightmapResolution, float64(cfg.Dimension), float64(cfg.Height)) + + res := cfg.HeightmapResolution + for i := range res * res { + nx := normals[i*3] + ny := normals[i*3+1] + nz := normals[i*3+2] + length := float32(math.Sqrt(float64(nx*nx + ny*ny + nz*nz))) + if length < 0.999 || length > 1.001 { + t.Errorf("normal at %d is not unit length: %.5f", i, length) + break + } + } +} From 537a0e8d010e0b9daf7ed32e45b165d67bf1bd5c Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 1 Mar 2026 11:42:46 -0800 Subject: [PATCH 2/3] feat: add GenerateExtendedHeightmap for 1-cell border sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates a (resolution+2)×(resolution+2) heightmap including a 1-cell border sampled one spacing unit beyond the chunk boundary. Used by ComputeNormalsFromExtended to compute correct cross-boundary normals without edge clamping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wasm/terrain/heightmap.go | 29 +++++++++++++++++ wasm/terrain/heightmap_test.go | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/wasm/terrain/heightmap.go b/wasm/terrain/heightmap.go index c739c5e..82bff1f 100644 --- a/wasm/terrain/heightmap.go +++ b/wasm/terrain/heightmap.go @@ -30,6 +30,35 @@ func GenerateHeightmap(chunkX, chunkZ int, cfg ChunkConfig) []float32 { return out } +// GenerateExtendedHeightmap produces a (resolution+2)×(resolution+2) heightmap +// that includes a 1-cell border sampled one spacing unit beyond the chunk boundary. +// This enables ComputeNormalsFromExtended to compute correct cross-boundary normals +// without clamping. Values are normalized to [0, 1] like GenerateHeightmap. +func GenerateExtendedHeightmap(chunkX, chunkZ int, cfg ChunkConfig) []float32 { + res := cfg.HeightmapResolution + extRes := res + 2 + out := make([]float32, extRes*extRes) + worldOriginX := float64(chunkX * cfg.Dimension) + worldOriginZ := float64(chunkZ * cfg.Dimension) + spacing := float64(cfg.Dimension) / float64(res-1) + + for row := range extRes { + for col := range extRes { + // col-1 and row-1: shift by 1 to include the border cell at index 0 + wx := worldOriginX + float64(col-1)*spacing + cfg.OffsetX + wz := worldOriginZ + float64(row-1)*spacing + cfg.OffsetZ + + sx, sz := noise.SkewXZ(wx, wz) + raw := noise.FBm(sx, sz, cfg.Seed, cfg.Octaves, cfg.Frequency, cfg.Lacunarity, cfg.Persistence) + raw *= cfg.Amplitude * cfg.Gain + + normalized := (raw + 1.0) * 0.5 + out[row*extRes+col] = float32(noise.Clamp(normalized, 0, 1)) + } + } + return out +} + // GetHeight returns the world-space height at a local normalized position [0,1] within a chunk. func GetHeight(localX, localZ float64, heightmap []float32, resolution int, heightScale float64) float64 { fx := localX * float64(resolution-1) diff --git a/wasm/terrain/heightmap_test.go b/wasm/terrain/heightmap_test.go index a9c60f4..90c9ea4 100644 --- a/wasm/terrain/heightmap_test.go +++ b/wasm/terrain/heightmap_test.go @@ -76,6 +76,65 @@ func TestComputeNormals_Size(t *testing.T) { } } +func TestGenerateExtendedHeightmap_Size(t *testing.T) { + cfg := terrain.DefaultConfig() + ext := terrain.GenerateExtendedHeightmap(0, 0, cfg) + extRes := cfg.HeightmapResolution + 2 // 131 + expected := extRes * extRes + if len(ext) != expected { + t.Errorf("expected extended heightmap size %d, got %d", expected, len(ext)) + } +} + +func TestGenerateExtendedHeightmap_InnerMatchesRegular(t *testing.T) { + cfg := terrain.DefaultConfig() + regular := terrain.GenerateHeightmap(0, 0, cfg) + ext := terrain.GenerateExtendedHeightmap(0, 0, cfg) + + res := cfg.HeightmapResolution // 129 + extRes := res + 2 // 131 + + // Inner region of extended (rows 1..res, cols 1..res) should match regular + for row := range res { + for col := range res { + regularVal := regular[row*res+col] + extVal := ext[(row+1)*extRes+(col+1)] + diff := extVal - regularVal + if diff < 0 { + diff = -diff + } + if diff > 1e-6 { + t.Errorf("mismatch at (%d,%d): regular=%.6f ext=%.6f", row, col, regularVal, extVal) + } + } + } +} + +func TestGenerateExtendedHeightmap_BorderMatchesNeighbor(t *testing.T) { + cfg := terrain.DefaultConfig() + + // Left border of chunk (1,0) extended hm should match right column of chunk (0,0) regular hm + regularLeft := terrain.GenerateHeightmap(0, 0, cfg) + extRight := terrain.GenerateExtendedHeightmap(1, 0, cfg) + + res := cfg.HeightmapResolution // 129 + extRes := res + 2 // 131 + + for row := range res { + // Second-to-last col of regular chunk(0,0): col = res-2 (one spacing left of the shared boundary) + regularVal := regularLeft[row*res+(res-2)] + // Left border of extended chunk(1,0): col=0 in extended, sampled one spacing unit left of chunk(1,0) origin + extBorderVal := extRight[(row+1)*extRes+0] + diff := extBorderVal - regularVal + if diff < 0 { + diff = -diff + } + if diff > 1e-6 { + t.Errorf("border mismatch at row %d: regularLeft secondToLastCol=%.6f extRight leftBorder=%.6f", row, regularVal, extBorderVal) + } + } +} + func TestComputeNormals_Normalized(t *testing.T) { cfg := terrain.DefaultConfig() cfg.HeightmapResolution = 9 From cbe0d1a411448abf412ff7f1ff7cbb27cb315c95 Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 1 Mar 2026 11:45:01 -0800 Subject: [PATCH 3/3] fix: use extended heightmap for chunk normals in goGenerateChunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ComputeNormals (clamped edge sampling) with: 1. GenerateExtendedHeightmap — (res+2)×(res+2) heightmap with real noise values 1 cell beyond chunk boundaries 2. ComputeNormalsFromExtended — uses extended grid for central differences, no clamping needed Edge normals now use actual neighbor terrain heights instead of repeating the boundary value, eliminating the lighting seam at chunk boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wasm/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wasm/main.go b/wasm/main.go index 92645aa..ec37bc6 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -134,7 +134,8 @@ func goGenerateChunk(_ js.Value, args []js.Value) any { cfg.Height = int(heightScale) hm := terrain.GenerateHeightmap(cx, cz, cfg) - normals := terrain.ComputeNormals(hm, resolution, float64(chunkSize), heightScale) + extHm := terrain.GenerateExtendedHeightmap(cx, cz, cfg) + normals := terrain.ComputeNormalsFromExtended(extHm, resolution, float64(chunkSize), heightScale) // Store heightmap so physics can sample terrain height for collision/spawning. coord := world.ChunkCoord{X: cx, Z: cz}