Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
29 changes: 29 additions & 0 deletions wasm/terrain/heightmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions wasm/terrain/heightmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions wasm/terrain/normals.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
74 changes: 74 additions & 0 deletions wasm/terrain/normals_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}