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
7 changes: 5 additions & 2 deletions src/engine/ChunkManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
})
}
Expand Down
1 change: 1 addition & 0 deletions src/engine/FPSCamera.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const defaultPlayer: PlayerState = {
velocityY: 0, grounded: true,
sprinting: false,
coyoteFrames: 0, jumpProgress: 0,
flying: false,
}

describe('FPSCamera', () => {
Expand Down
1 change: 1 addition & 0 deletions src/engine/FPSCamera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface PlayerState {
sprinting: boolean
coyoteFrames: number
jumpProgress: number
flying: boolean
}

const EYE_HEIGHT = 0.8
Expand Down
16 changes: 14 additions & 2 deletions src/engine/GameEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
23 changes: 15 additions & 8 deletions src/engine/InputSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 => {
Expand Down Expand Up @@ -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
}

Expand Down
26 changes: 26 additions & 0 deletions src/engine/WasmClient.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +11,9 @@ export default class WasmClient {
private worker: Worker
private nextId = 0
private pending = new Map<number, (data: unknown) => void>()
private pool: ChunkWorkerPool | null = null
private wasmBinaryUrl = ''
private wasmExecUrl = ''
onTick: ((playerState: PlayerState) => void) | null = null

constructor() {
Expand Down Expand Up @@ -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'
})
}

Expand All @@ -61,6 +67,10 @@ export default class WasmClient {

async initWorld(config: object): Promise<void> {
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<void> {
Expand All @@ -75,6 +85,21 @@ export default class WasmClient {
chunkSize: number,
heightScale: number,
): Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> {
if (this.pool) {
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',
[JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale],
Expand All @@ -86,6 +111,7 @@ export default class WasmClient {
}

terminate(): void {
this.pool?.terminate()
this.worker.terminate()
}

Expand Down
95 changes: 95 additions & 0 deletions src/engine/worker/ChunkWorkerPool.ts
Original file line number Diff line number Diff line change
@@ -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<number, PendingCall>
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<void> {
const readyPromises = Array.from({ length: this.size }, () => {
return new Promise<void>((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<void> {
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<unknown> {
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 = []
}
}
2 changes: 2 additions & 0 deletions src/engine/worker/WasmBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/engine/worker/terrain.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}
Expand Down
30 changes: 30 additions & 0 deletions wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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
23 changes: 13 additions & 10 deletions wasm/physics/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
Loading