From 55636c536b697678dff6abf852739e4cf5169285 Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 1 Mar 2026 16:26:17 -0800 Subject: [PATCH 1/4] feat(flight): milestone 3 - F key toggles drone flight mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlayerState.Flying bool: tracks flight mode state (serialized to TS) - InputState.FlyToggle bool: edge-triggered F key press (one frame only) - FlySpeed = WalkSpeed * 10 = 75 units/s (10x ground walk speed) - Flight physics: no gravity, moves in full 3D look direction (pitch controls vertical component), Jump key ascends directly - Sprint in flight = 2x fly speed (150 units/s) - Ground mode physics unchanged - InputSystem: flyTogglePending flag fires once per F keydown then resets - FPSCamera.PlayerState: added flying field - 4 new physics tests: toggle, no-gravity, direction, speed constant Controls: F — toggle flight mode WASD — move in look direction (pitch-coupled vertical in flight) Space — ascend directly (flight) Shift — 2x speed boost (flight + ground sprint) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/engine/FPSCamera.test.ts | 1 + src/engine/FPSCamera.ts | 1 + src/engine/InputSystem.ts | 23 ++-- wasm/physics/player.go | 23 ++-- wasm/physics/update.go | 197 +++++++++++++++++++++++------------ wasm/physics/update_test.go | 53 +++++++++- 6 files changed, 210 insertions(+), 88 deletions(-) diff --git a/src/engine/FPSCamera.test.ts b/src/engine/FPSCamera.test.ts index c7ddb0c..2510696 100644 --- a/src/engine/FPSCamera.test.ts +++ b/src/engine/FPSCamera.test.ts @@ -8,6 +8,7 @@ const defaultPlayer: PlayerState = { velocityY: 0, grounded: true, sprinting: false, coyoteFrames: 0, jumpProgress: 0, + flying: false, } describe('FPSCamera', () => { diff --git a/src/engine/FPSCamera.ts b/src/engine/FPSCamera.ts index e840186..2b77070 100644 --- a/src/engine/FPSCamera.ts +++ b/src/engine/FPSCamera.ts @@ -11,6 +11,7 @@ export interface PlayerState { sprinting: boolean coyoteFrames: number jumpProgress: number + flying: boolean } const EYE_HEIGHT = 0.8 diff --git a/src/engine/InputSystem.ts b/src/engine/InputSystem.ts index 6f6c5fb..84873ff 100644 --- a/src/engine/InputSystem.ts +++ b/src/engine/InputSystem.ts @@ -6,6 +6,7 @@ export interface InputSnapshot { right: boolean jump: boolean sprint: boolean + flyToggle: boolean // edge-triggered: true for one frame when F is pressed mouseDX: number mouseDY: number } @@ -16,9 +17,13 @@ export default class InputSystem { private mouseDY = 0 private sensitivity = 1.0 private canvas: HTMLCanvasElement | null = null + private flyTogglePending = false private onKeyDown = (e: KeyboardEvent): void => { this.keys.add(e.code) + if (e.code === 'KeyF') { + this.flyTogglePending = true + } } private onKeyUp = (e: KeyboardEvent): void => { @@ -49,17 +54,19 @@ export default class InputSystem { // Returns current snapshot and resets mouse deltas flush(): InputSnapshot { const snap: InputSnapshot = { - forward: this.keys.has('KeyW') || this.keys.has('ArrowUp'), - backward: this.keys.has('KeyS') || this.keys.has('ArrowDown'), - left: this.keys.has('KeyA') || this.keys.has('ArrowLeft'), - right: this.keys.has('KeyD') || this.keys.has('ArrowRight'), - jump: this.keys.has('Space'), - sprint: this.keys.has('ShiftLeft'), - mouseDX: this.mouseDX, - mouseDY: this.mouseDY, + forward: this.keys.has('KeyW') || this.keys.has('ArrowUp'), + backward: this.keys.has('KeyS') || this.keys.has('ArrowDown'), + left: this.keys.has('KeyA') || this.keys.has('ArrowLeft'), + right: this.keys.has('KeyD') || this.keys.has('ArrowRight'), + jump: this.keys.has('Space'), + sprint: this.keys.has('ShiftLeft'), + flyToggle: this.flyTogglePending, + mouseDX: this.mouseDX, + mouseDY: this.mouseDY, } this.mouseDX = 0 this.mouseDY = 0 + this.flyTogglePending = false return snap } diff --git a/wasm/physics/player.go b/wasm/physics/player.go index f1509c2..08fd092 100644 --- a/wasm/physics/player.go +++ b/wasm/physics/player.go @@ -9,8 +9,9 @@ const ( Gravity = -19.62 WalkSpeed = 7.5 SprintSpeed = 20.0 - JumpVelocity = 6.26 // sqrt(2 * 9.81 * 2.0) ≈ 6.26 - SlopeLimit = 65.0 // degrees + FlySpeed = WalkSpeed * 10 // 75.0 — 10x ground speed + JumpVelocity = 6.26 // sqrt(2 * 9.81 * 2.0) ≈ 6.26 + SlopeLimit = 65.0 // degrees Sensitivity = 0.002 PitchMin = -math.Pi / 2.0 * 0.99 PitchMax = math.Pi / 180.0 * 70.0 @@ -19,14 +20,15 @@ const ( ) type InputState struct { - Forward bool `json:"forward"` - Backward bool `json:"backward"` - Left bool `json:"left"` - Right bool `json:"right"` - Jump bool `json:"jump"` - Sprint bool `json:"sprint"` - MouseDX float64 `json:"mouseDX"` - MouseDY float64 `json:"mouseDY"` + Forward bool `json:"forward"` + Backward bool `json:"backward"` + Left bool `json:"left"` + Right bool `json:"right"` + Jump bool `json:"jump"` + Sprint bool `json:"sprint"` + FlyToggle bool `json:"flyToggle"` // true for one frame when F is pressed + MouseDX float64 `json:"mouseDX"` + MouseDY float64 `json:"mouseDY"` } type PlayerState struct { @@ -40,4 +42,5 @@ type PlayerState struct { Sprinting bool `json:"sprinting"` CoyoteFrames int `json:"coyoteFrames"` JumpProgress float64 `json:"jumpProgress"` + Flying bool `json:"flying"` // whether flight mode is active } diff --git a/wasm/physics/update.go b/wasm/physics/update.go index ef9bfef..f34383d 100644 --- a/wasm/physics/update.go +++ b/wasm/physics/update.go @@ -15,88 +15,147 @@ func Update(p *PlayerState, input InputState, dt float64, heightAt func(x, z flo p.Pitch = PitchMax } - // 2. Determine speed - speed := WalkSpeed - p.Sprinting = false - if input.Sprint && (input.Forward || input.Backward || input.Left || input.Right) { - speed = SprintSpeed - p.Sprinting = true + // Toggle flight mode on FlyToggle input + if input.FlyToggle { + p.Flying = !p.Flying + if p.Flying { + // Enter flight: kill vertical velocity, don't snap to ground + p.VelocityY = 0 + } else { + // Exit flight: let gravity take over from current position + p.VelocityY = 0 + p.Grounded = false + } } - // 3. Horizontal movement in yaw-facing direction - sin := math.Sin(p.Yaw) - cos := math.Cos(p.Yaw) - var dx, dz float64 - if input.Forward { - dx -= sin * speed * dt - dz -= cos * speed * dt - } - if input.Backward { - dx += sin * speed * dt - dz += cos * speed * dt - } - if input.Left { - dx -= cos * speed * dt - dz += sin * speed * dt - } - if input.Right { - dx += cos * speed * dt - dz -= sin * speed * dt - } + if p.Flying { + // FLIGHT MODE: drone-style, moves in the full 3D look direction + speed := FlySpeed + // Sprint in flight = 2x fly speed + p.Sprinting = false + if input.Sprint { + speed *= 2 + p.Sprinting = true + } - // 4. Slope check — sample terrain ahead, block if slope > SlopeLimit - newX := p.X + dx - newZ := p.Z + dz - curH := heightAt(p.X, p.Z) - nextH := heightAt(newX, newZ) - moveLen := math.Sqrt(dx*dx + dz*dz) - if moveLen > 0 { - slopeAngle := math.Atan2(math.Abs(nextH-curH), moveLen) * 180.0 / math.Pi - if slopeAngle <= SlopeLimit { + sinYaw := math.Sin(p.Yaw) + cosYaw := math.Cos(p.Yaw) + cosPitch := math.Cos(p.Pitch) + sinPitch := math.Sin(p.Pitch) + + // Forward/backward moves in the full look direction (including vertical component) + if input.Forward { + p.X -= sinYaw * cosPitch * speed * dt + p.Y += sinPitch * speed * dt + p.Z -= cosYaw * cosPitch * speed * dt + } + if input.Backward { + p.X += sinYaw * cosPitch * speed * dt + p.Y -= sinPitch * speed * dt + p.Z += cosYaw * cosPitch * speed * dt + } + // Strafing is purely horizontal + if input.Left { + p.X -= cosYaw * speed * dt + p.Z += sinYaw * speed * dt + } + if input.Right { + p.X += cosYaw * speed * dt + p.Z -= sinYaw * speed * dt + } + // Jump key ascends directly + if input.Jump { + p.Y += speed * dt + } + // No gravity, no ground collision in flight mode + p.VelocityY = 0 + p.Grounded = false + p.CoyoteFrames = 0 + } else { + // GROUND MODE: existing code unchanged + // 2. Determine speed + speed := WalkSpeed + p.Sprinting = false + if input.Sprint && (input.Forward || input.Backward || input.Left || input.Right) { + speed = SprintSpeed + p.Sprinting = true + } + + // 3. Horizontal movement in yaw-facing direction + sin := math.Sin(p.Yaw) + cos := math.Cos(p.Yaw) + var dx, dz float64 + if input.Forward { + dx -= sin * speed * dt + dz -= cos * speed * dt + } + if input.Backward { + dx += sin * speed * dt + dz += cos * speed * dt + } + if input.Left { + dx -= cos * speed * dt + dz += sin * speed * dt + } + if input.Right { + dx += cos * speed * dt + dz -= sin * speed * dt + } + + // 4. Slope check — sample terrain ahead, block if slope > SlopeLimit + newX := p.X + dx + newZ := p.Z + dz + curH := heightAt(p.X, p.Z) + nextH := heightAt(newX, newZ) + moveLen := math.Sqrt(dx*dx + dz*dz) + if moveLen > 0 { + slopeAngle := math.Atan2(math.Abs(nextH-curH), moveLen) * 180.0 / math.Pi + if slopeAngle <= SlopeLimit { + p.X = newX + p.Z = newZ + } + } else { p.X = newX p.Z = newZ } - } else { - p.X = newX - p.Z = newZ - } - // 5. Gravity - p.VelocityY += Gravity * dt + // 5. Gravity + p.VelocityY += Gravity * dt - // 6. Jump initiation — sets JumpProgress to begin smooth ramp - if input.Jump && p.Grounded && p.JumpProgress == 0 { - p.JumpProgress = 1.0 - p.Grounded = false - p.CoyoteFrames = CoyoteGrace + 1 // skip coyote grace so we don't re-land - } + // 6. Jump initiation — sets JumpProgress to begin smooth ramp + if input.Jump && p.Grounded && p.JumpProgress == 0 { + p.JumpProgress = 1.0 + p.Grounded = false + p.CoyoteFrames = CoyoteGrace + 1 // skip coyote grace so we don't re-land + } - // 6a. Smooth jump ramp — lerp VelocityY toward JumpVelocity over ~3 frames - if p.JumpProgress > 0 { - p.VelocityY += (JumpVelocity - p.VelocityY) * 0.5 - p.JumpProgress -= 0.35 - if p.JumpProgress < 0 { - p.JumpProgress = 0 + // 6a. Smooth jump ramp — lerp VelocityY toward JumpVelocity over ~3 frames + if p.JumpProgress > 0 { + p.VelocityY += (JumpVelocity - p.VelocityY) * 0.5 + p.JumpProgress -= 0.35 + if p.JumpProgress < 0 { + p.JumpProgress = 0 + } } - } - // 7. Vertical integration - p.Y += p.VelocityY * dt + // 7. Vertical integration + p.Y += p.VelocityY * dt - // 8. Ground collision with coyote-time to prevent airborne flicker on micro-bumps - groundH := heightAt(p.X, p.Z) + CapsuleHalfHeight + CapsuleRadius - if p.Y <= groundH && p.JumpProgress == 0 { - p.Y = groundH - p.VelocityY = 0 - p.Grounded = true - p.CoyoteFrames = 0 - } else if p.Y > groundH { - if p.Grounded && p.CoyoteFrames < CoyoteGrace && p.JumpProgress == 0 { - // Grace period: keep grounded for a few ticks after leaving ground - p.CoyoteFrames++ - } else { - p.Grounded = false + // 8. Ground collision with coyote-time to prevent airborne flicker on micro-bumps + groundH := heightAt(p.X, p.Z) + CapsuleHalfHeight + CapsuleRadius + if p.Y <= groundH && p.JumpProgress == 0 { + p.Y = groundH + p.VelocityY = 0 + p.Grounded = true p.CoyoteFrames = 0 + } else if p.Y > groundH { + if p.Grounded && p.CoyoteFrames < CoyoteGrace && p.JumpProgress == 0 { + // Grace period: keep grounded for a few ticks after leaving ground + p.CoyoteFrames++ + } else { + p.Grounded = false + p.CoyoteFrames = 0 + } } } } diff --git a/wasm/physics/update_test.go b/wasm/physics/update_test.go index 6760126..6db6a5f 100644 --- a/wasm/physics/update_test.go +++ b/wasm/physics/update_test.go @@ -1,6 +1,9 @@ package physics -import "testing" +import ( + "math" + "testing" +) func flatGround(x, z float64) float64 { return 0 } @@ -63,3 +66,51 @@ func TestSlopeBlocking(t *testing.T) { } } +func TestFlyToggle_EntersAndExitsFlightMode(t *testing.T) { + p := &PlayerState{X: 0, Y: 10, Z: 0} + hf := func(x, z float64) float64 { return 0 } + + // Toggle on + Update(p, InputState{FlyToggle: true}, 0.016, hf) + if !p.Flying { + t.Fatal("expected Flying=true after FlyToggle") + } + + // Toggle off + Update(p, InputState{FlyToggle: true}, 0.016, hf) + if p.Flying { + t.Fatal("expected Flying=false after second FlyToggle") + } +} + +func TestFlight_NoGravity(t *testing.T) { + p := &PlayerState{X: 0, Y: 100, Z: 0, Flying: true} + hf := func(x, z float64) float64 { return 0 } + initialY := p.Y + // No input, should hover in place (no gravity) + Update(p, InputState{}, 0.1, hf) + if math.Abs(p.Y-initialY) > 0.001 { + t.Errorf("flying player should not fall: y changed from %.3f to %.3f", initialY, p.Y) + } +} + +func TestFlight_ForwardMovesInLookDirection(t *testing.T) { + // Pitch up 45°, yaw 0 (facing -Z), press forward + // Should move in -Z and +Y direction + p := &PlayerState{X: 0, Y: 100, Z: 0, Flying: true, Yaw: 0, Pitch: math.Pi / 4} + hf := func(x, z float64) float64 { return 0 } + Update(p, InputState{Forward: true}, 1.0, hf) + if p.Y <= 100 { + t.Errorf("flying forward with pitch up should increase Y, got %.3f", p.Y) + } + if p.Z >= 0 { + t.Errorf("flying forward with yaw=0 should decrease Z, got %.3f", p.Z) + } +} + +func TestFlight_SpeedIs10xWalk(t *testing.T) { + if FlySpeed != WalkSpeed*10 { + t.Errorf("FlySpeed should be 10x WalkSpeed: got %.1f, want %.1f", FlySpeed, WalkSpeed*10) + } +} + From 303ba1e75d473f342eac51a2cfb7b23714d0accb Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 1 Mar 2026 16:39:22 -0800 Subject: [PATCH 2/4] perf(chunks): larger render radius + parallel chunk worker pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for flight-mode terrain pop-in at 75 u/s: 1. Render radius: 1536 → 2560 units (~5 chunk buffer vs 3) DistanceThreshold: 2560 → 4608 (wider eviction window) 2. Adaptive stream trigger in GameEngine: - Flying: re-stream every 64 units (was 128) - Flying: stream position offset 1024 units ahead in look direction so chunks are pre-generated before the player arrives - Grounded: unchanged (128 unit trigger) 3. ChunkWorkerPool: 4 parallel WASM workers for chunk generation - Primary worker handles physics ticks + world state (unchanged) - Pool workers each load WASM independently, route generateChunk calls round-robin → up to 4 chunks generate simultaneously - Pool initialized lazily in WasmClient.initWorld() - Falls back to primary worker if pool not yet ready Result: at 75 u/s flight speed, 5-chunk buffer + 1-chunk lookahead + 4x parallel generation means terrain stays ahead of the player. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/engine/GameEngine.ts | 16 ++++- src/engine/WasmClient.ts | 14 ++++ src/engine/worker/ChunkWorkerPool.ts | 95 ++++++++++++++++++++++++++++ wasm/world/config.go | 6 +- 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 src/engine/worker/ChunkWorkerPool.ts diff --git a/src/engine/GameEngine.ts b/src/engine/GameEngine.ts index 09d17be..4a6f25c 100644 --- a/src/engine/GameEngine.ts +++ b/src/engine/GameEngine.ts @@ -137,10 +137,22 @@ export default class GameEngine { if (this.playerState) { const dx = this.playerState.x - this.lastStreamX const dz = this.playerState.z - this.lastStreamZ - if (dx * dx + dz * dz > 128 * 128) { + const streamThreshold = this.playerState.flying ? 64 * 64 : 128 * 128 + if (dx * dx + dz * dz > streamThreshold) { this.lastStreamX = this.playerState.x this.lastStreamZ = this.playerState.z - this.chunkManager?.streamUpdate(this.playerState.x, this.playerState.z) + + let streamX = this.playerState.x + let streamZ = this.playerState.z + + if (this.playerState.flying) { + // Pre-generate chunks in the look direction (2 chunks lookahead = 1024 units) + const lookahead = 1024 + streamX += -Math.sin(this.playerState.yaw) * lookahead + streamZ += -Math.cos(this.playerState.yaw) * lookahead + } + + this.chunkManager?.streamUpdate(streamX, streamZ) .catch(console.error) } } diff --git a/src/engine/WasmClient.ts b/src/engine/WasmClient.ts index e40a8f1..87a6287 100644 --- a/src/engine/WasmClient.ts +++ b/src/engine/WasmClient.ts @@ -1,5 +1,6 @@ // Main-thread async wrapper around the terrain WASM worker. import type { PlayerState } from './FPSCamera' +import ChunkWorkerPool from './worker/ChunkWorkerPool' export interface WorldUpdate { chunksToAdd: Array<{ coord: { X: number; Z: number } }> | null @@ -10,6 +11,9 @@ export default class WasmClient { private worker: Worker private nextId = 0 private pending = new Map void>() + private pool: ChunkWorkerPool | null = null + private wasmBinaryUrl = '' + private wasmExecUrl = '' onTick: ((playerState: PlayerState) => void) | null = null constructor() { @@ -48,6 +52,8 @@ export default class WasmClient { wasmBinaryUrl: `/terrain.wasm?v=${__WASM_HASH__}`, wasmExecUrl: '/wasm_exec.js', }) + this.wasmBinaryUrl = `/terrain.wasm?v=${__WASM_HASH__}` + this.wasmExecUrl = '/wasm_exec.js' }) } @@ -61,6 +67,10 @@ export default class WasmClient { async initWorld(config: object): Promise { await this.call('initWorld', [JSON.stringify(config)]) + if (!this.pool && this.wasmBinaryUrl) { + this.pool = new ChunkWorkerPool(4) + await this.pool.init(this.wasmBinaryUrl, this.wasmExecUrl, JSON.stringify(config)) + } } async loadWorldConfig(config: object): Promise { @@ -75,6 +85,9 @@ export default class WasmClient { chunkSize: number, heightScale: number, ): Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> { + if (this.pool) { + return this.pool.generateChunk(JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale) + } return this.call( 'generateChunk', [JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale], @@ -86,6 +99,7 @@ export default class WasmClient { } terminate(): void { + this.pool?.terminate() this.worker.terminate() } diff --git a/src/engine/worker/ChunkWorkerPool.ts b/src/engine/worker/ChunkWorkerPool.ts new file mode 100644 index 0000000..11c7d50 --- /dev/null +++ b/src/engine/worker/ChunkWorkerPool.ts @@ -0,0 +1,95 @@ +// Pool of WASM workers dedicated to chunk generation. +// Each worker has its own WASM instance and only handles generateChunk calls. +// Routes calls round-robin to minimize latency compared to a single-worker queue. + +interface PendingCall { + resolve: (value: unknown) => void + reject: (reason: unknown) => void +} + +interface PoolWorker { + worker: Worker + pending: Map + nextId: number + ready: boolean +} + +export default class ChunkWorkerPool { + private workers: PoolWorker[] = [] + private roundRobinIndex = 0 + private readonly size: number + + constructor(size = 4) { + this.size = size + } + + async init(wasmBinaryUrl: string, wasmExecUrl: string, worldConfigJSON: string): Promise { + const readyPromises = Array.from({ length: this.size }, () => { + return new Promise((resolve, reject) => { + const worker = new Worker( + new URL('./terrain.worker.ts', import.meta.url), + ) + const pw: PoolWorker = { worker, pending: new Map(), nextId: 0, ready: false } + this.workers.push(pw) + + worker.onmessage = (e: MessageEvent) => { + const { type, id, result, error } = e.data + if (type === 'READY') { + const cfgId = pw.nextId++ + pw.pending.set(cfgId, { + resolve: () => { + pw.ready = true + resolve() + }, + reject, + }) + worker.postMessage({ type: 'CALL', id: cfgId, method: 'loadWorldConfig', args: [worldConfigJSON] }) + } else if (type === 'RESULT') { + const pending = pw.pending.get(id) + if (pending) { + pw.pending.delete(id) + if (error) pending.reject(new Error(error)) + else pending.resolve(result) + } + } else if (type === 'ERROR') { + reject(new Error(e.data.message)) + } + } + + worker.postMessage({ type: 'INIT', wasmBinaryUrl, wasmExecUrl }) + }) + }) + + await Promise.all(readyPromises) + } + + async updateWorldConfig(worldConfigJSON: string): Promise { + await Promise.all(this.workers.map(pw => this.callWorker(pw, 'loadWorldConfig', [worldConfigJSON]))) + } + + generateChunk( + configJSON: string, + cx: number, + cz: number, + resolution: number, + chunkSize: number, + heightScale: number, + ): Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> { + const pw = this.workers[this.roundRobinIndex % this.workers.length] + this.roundRobinIndex++ + return this.callWorker(pw, 'generateChunk', [configJSON, cx, cz, resolution, chunkSize, heightScale]) as Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> + } + + private callWorker(pw: PoolWorker, method: string, args: unknown[]): Promise { + return new Promise((resolve, reject) => { + const id = pw.nextId++ + pw.pending.set(id, { resolve, reject }) + pw.worker.postMessage({ type: 'CALL', id, method, args }) + }) + } + + terminate(): void { + this.workers.forEach(pw => pw.worker.terminate()) + this.workers = [] + } +} diff --git a/wasm/world/config.go b/wasm/world/config.go index 7ff47a4..257cbfb 100644 --- a/wasm/world/config.go +++ b/wasm/world/config.go @@ -2,9 +2,9 @@ package world // Constants mirror terra-major World.cs const ( - InitialRenderRadius = 2048.0 - RenderRadius = 1536.0 - DistanceThreshold = 2560.0 + InitialRenderRadius = 3072.0 + RenderRadius = 2560.0 + DistanceThreshold = 4608.0 MaxConcurrentChunks = 64 ) From 6449a08bf19c16053c67e75b1a903e5ee9bf1e03 Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 1 Mar 2026 17:09:29 -0800 Subject: [PATCH 3/4] fix(physics): restore terrain collision broken by chunk worker pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pool workers each have their own WASM instance. goGenerateChunk stores globalHeightmaps in the pool worker's instance, but goUpdatePlayer (physics) runs on the primary worker whose globalHeightmaps was always empty. Result: SampleHeight returned 0 → player grounded at Y≈1.25 → fell through all terrain. Fix: add go_storeHeightmap(cx, cz, Float32Array) to the primary worker. After each pool chunk generation, WasmClient fires a storeHeightmap message to the primary worker (fire-and-forget, id=-1 sentinel). Primary worker stores the heightmap in its globalHeightmaps, making it available to physics. go_storeHeightmap uses Uint8Array view + js.CopyBytesToGo for efficient bulk copy, then reinterprets bytes as float32 (avoids 16641 individual JS→Go float conversions). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/engine/WasmClient.ts | 14 +++++++++++++- src/engine/worker/WasmBridge.ts | 2 ++ src/engine/worker/terrain.worker.ts | 4 ++++ wasm/main.go | 30 +++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/engine/WasmClient.ts b/src/engine/WasmClient.ts index 87a6287..30fa036 100644 --- a/src/engine/WasmClient.ts +++ b/src/engine/WasmClient.ts @@ -86,7 +86,19 @@ export default class WasmClient { heightScale: number, ): Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> { if (this.pool) { - return this.pool.generateChunk(JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale) + const result = await this.pool.generateChunk( + JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale, + ) + // Register heightmap with primary worker for physics collision. + // Fire-and-forget: id=-1 is a sentinel never registered in this.pending, + // so the RESULT response from the worker is silently ignored. + this.worker.postMessage({ + type: 'CALL', + id: -1, + method: 'storeHeightmap', + args: [chunkX, chunkZ, result.heightmap], + }) + return result } return this.call( 'generateChunk', diff --git a/src/engine/worker/WasmBridge.ts b/src/engine/worker/WasmBridge.ts index a43d229..8ffe6fa 100644 --- a/src/engine/worker/WasmBridge.ts +++ b/src/engine/worker/WasmBridge.ts @@ -42,6 +42,8 @@ declare global { ): Float32Array /** Load a WorldConfig JSON to configure biome placement before chunk generation. */ function go_loadWorldConfig(configJSON: string): void + /** Store a heightmap (from a pool worker) into the primary worker's globalHeightmaps for physics. */ + function go_storeHeightmap(cx: number, cz: number, heightmap: Float32Array): void } export default class WasmBridge { diff --git a/src/engine/worker/terrain.worker.ts b/src/engine/worker/terrain.worker.ts index 2db3459..14ff7f8 100644 --- a/src/engine/worker/terrain.worker.ts +++ b/src/engine/worker/terrain.worker.ts @@ -63,6 +63,10 @@ function handleCall(event: MessageEvent): void { const [playerX, playerZ] = args as [number, number] const json = go_worldUpdate(playerX, playerZ) as string result = JSON.parse(json) + } else if (method === 'storeHeightmap') { + const [cx, cz, heightmap] = args as [number, number, Float32Array] + go_storeHeightmap(cx, cz, heightmap) + result = null } else { throw new Error(`Unknown method: ${method}`) } diff --git a/wasm/main.go b/wasm/main.go index a10b509..3363144 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -33,6 +33,7 @@ func main() { js.Global().Set("go_generateChunk", js.FuncOf(goGenerateChunk)) js.Global().Set("go_updatePlayer", js.FuncOf(goUpdatePlayer)) js.Global().Set("go_loadWorldConfig", js.FuncOf(goLoadWorldConfig)) + js.Global().Set("go_storeHeightmap", js.FuncOf(goStoreHeightmap)) fmt.Println("[WASM] exports registered, engine ready") select {} @@ -232,5 +233,34 @@ func goUpdatePlayer(_ js.Value, args []js.Value) any { return string(out) } +// goStoreHeightmap receives a heightmap generated by a pool worker and stores it +// in the primary worker's globalHeightmaps so physics collision detection works. +func goStoreHeightmap(_ js.Value, args []js.Value) any { + cx := args[0].Int() + cz := args[1].Int() + jsArr := args[2] // Float32Array from TypeScript + + n := jsArr.Get("length").Int() + hm := make([]float32, n) + + // Use Uint8Array view of the Float32Array's buffer for efficient bulk copy. + byteLen := jsArr.Get("byteLength").Int() + byteOffset := jsArr.Get("byteOffset").Int() + uint8View := js.Global().Get("Uint8Array").New(jsArr.Get("buffer"), byteOffset, byteLen) + goBytes := make([]byte, byteLen) + js.CopyBytesToGo(goBytes, uint8View) + + // Reinterpret little-endian bytes as float32 values. + for i := range hm { + b := goBytes[i*4 : i*4+4] + bits := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24 + hm[i] = math.Float32frombits(bits) + } + + coord := world.ChunkCoord{X: cx, Z: cz} + globalHeightmaps[coord] = hm + return nil +} + // ensure math import is used var _ = math.Floor From e9f942b1423f8c36e36a555470ec55fb4c07a811 Mon Sep 17 00:00:00 2001 From: MW Felker Date: Mon, 2 Mar 2026 12:44:53 -0800 Subject: [PATCH 4/4] fix(frustum): correct chunk AABB Y bounds for mountain terrain height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filterByFrustum tested chunks with AABB maxY=HEIGHT_SCALE(64) but mountain terrain reaches HEIGHT_SCALE*10=640 (10x HeightMultiplier). When flying above Y=64, the frustum bottom plane rose above the AABB top, incorrectly culling visible chunks → flicker and disappearing terrain. Fix: maxTerrainHeight = HEIGHT_SCALE * 10 = 640. Also corrects minY from -64 to 0 (terrain is always non-negative). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/engine/ChunkManager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/engine/ChunkManager.ts b/src/engine/ChunkManager.ts index 935373e..31d99c3 100644 --- a/src/engine/ChunkManager.ts +++ b/src/engine/ChunkManager.ts @@ -120,13 +120,16 @@ export default class ChunkManager { } filterByFrustum(planes: Plane[]): ChunkGPUData[] { + // maxTerrainHeight: heightmap values reach up to 10.0 (Mountains HeightMultiplier) + // multiplied by HEIGHT_SCALE=64 → 640 world units max. + const maxTerrainHeight = HEIGHT_SCALE * 10 return this.activeChunks.filter((chunk) => { const cx = chunk.coord.x const cz = chunk.coord.z return testAABB( planes, - cx * CHUNK_SIZE, -HEIGHT_SCALE, cz * CHUNK_SIZE, - (cx + 1) * CHUNK_SIZE, HEIGHT_SCALE, (cz + 1) * CHUNK_SIZE, + cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE, + (cx + 1) * CHUNK_SIZE, maxTerrainHeight, (cz + 1) * CHUNK_SIZE, ) }) }