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} 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 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 + } + } +}