From 3ae3ad30411da2e43b9880274332fad0d3fe4a97 Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 15 Mar 2026 23:03:14 -0700 Subject: [PATCH 1/3] feat(biome): milestone 4 - biome stitching + world config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Gaussian-weighted biome blending with per-vertex climate sampling and adjacency buffering (desert↔swamp never co-dominate, buffer to grassland) - Export chunk biome transition metadata (primary, secondary, blendFactor) from Go WASM; wire through worker → WasmClient → ChunkManager uniform - Update terrain.frag.wgsl to interpolate between primary and secondary biome colors using chunk-level blend factor and elevation-based rock/snow zones - Apply biomeScale from WorldConfig during biome noise sampling so larger values produce bigger biome regions - Add World Config section in Settings panel (seed + biomeScale + Apply); wire through GameEngine.applyWorldConfig → loadWorldConfig → chunk reload - Add adjacency.go, adjacency_test.go, transition_test.go with full test coverage for buffering weights, biome transition selection, and biomeScale sampling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.tsx | 5 + src/components/GameCanvas/GameCanvas.test.tsx | 15 ++- src/components/GameCanvas/GameCanvas.tsx | 5 +- src/components/Settings/Settings.module.css | 41 ++++++ src/components/Settings/Settings.test.tsx | 10 ++ src/components/Settings/Settings.tsx | 49 +++++++ src/engine/ChunkManager.test.ts | 79 +++++++++++ src/engine/ChunkManager.ts | 49 ++++++- src/engine/GameEngine.ts | 10 ++ src/engine/WasmClient.ts | 16 ++- src/engine/worker/WasmBridge.ts | 5 +- src/engine/worker/terrain.worker.ts | 19 ++- src/shaders/terrain.frag.wgsl | 13 +- wasm/biome/adjacency.go | 36 +++++ wasm/biome/adjacency_test.go | 62 +++++++++ wasm/biome/generator.go | 89 +++++++++++-- wasm/biome/selector.go | 36 +++-- wasm/biome/transition_test.go | 123 ++++++++++++++++++ wasm/main.go | 29 +++-- 19 files changed, 642 insertions(+), 49 deletions(-) create mode 100644 wasm/biome/adjacency.go create mode 100644 wasm/biome/adjacency_test.go create mode 100644 wasm/biome/transition_test.go diff --git a/src/App.tsx b/src/App.tsx index 88729cf..9df348e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,11 @@ export default function App() { onFogDensityChange={(v: number) => engineRef.current?.setFogDensity(v)} onFovChange={(v: number) => engineRef.current?.setFov(v)} onMouseSensitivityChange={(v: number) => engineRef.current?.setMouseSensitivity(v)} + onWorldConfigApply={(config) => { + const engine = engineRef.current + if (!engine) return + engine.applyWorldConfig(config).catch(console.error) + }} />
{isReady ? '✓ WebGPU Ready' : 'Initializing WebGPU...'} {pointerLocked && ' | Click to unlock'} diff --git a/src/components/GameCanvas/GameCanvas.test.tsx b/src/components/GameCanvas/GameCanvas.test.tsx index 057fd59..0173781 100644 --- a/src/components/GameCanvas/GameCanvas.test.tsx +++ b/src/components/GameCanvas/GameCanvas.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import { createRef } from 'react' import { describe, it, expect, vi } from 'vitest' import GameCanvas from './GameCanvas' @@ -21,4 +21,17 @@ describe('GameCanvas', () => { document.dispatchEvent(new Event('pointerlockchange')) expect(onPointerLock).toHaveBeenCalledWith(false) }) + + it('calls onWorldConfigApply when settings applies world config', () => { + const ref = createRef() + const onWorldConfigApply = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /settings/i })) + fireEvent.change(screen.getByTestId('input-world-seed'), { target: { value: '9876' } }) + fireEvent.change(screen.getByTestId('input-biome-scale'), { target: { value: '3.5' } }) + fireEvent.click(screen.getByRole('button', { name: /apply world config/i })) + + expect(onWorldConfigApply).toHaveBeenCalledWith({ seed: 9876, biomeScale: 3.5 }) + }) }) diff --git a/src/components/GameCanvas/GameCanvas.tsx b/src/components/GameCanvas/GameCanvas.tsx index 5864072..6a83200 100644 --- a/src/components/GameCanvas/GameCanvas.tsx +++ b/src/components/GameCanvas/GameCanvas.tsx @@ -4,6 +4,7 @@ import styles from './GameCanvas.module.css' import HUD from '../HUD/HUD' import SettingsPanel from '../Settings/Settings' import type { PlayerState } from '../../engine/FPSCamera' +import type { WorldConfig } from '../../engine/biome/BiomeTypes' interface GameCanvasProps { ref: RefObject @@ -13,9 +14,10 @@ interface GameCanvasProps { onFogDensityChange?: (v: number) => void onFovChange?: (v: number) => void onMouseSensitivityChange?: (v: number) => void + onWorldConfigApply?: (config: WorldConfig) => void } -export default function GameCanvas({ ref, onPointerLock, playerState = null, fps = 0, onFogDensityChange, onFovChange, onMouseSensitivityChange }: GameCanvasProps) { +export default function GameCanvas({ ref, onPointerLock, playerState = null, fps = 0, onFogDensityChange, onFovChange, onMouseSensitivityChange, onWorldConfigApply }: GameCanvasProps) { const containerRef = useRef(null) useEffect(() => { @@ -56,6 +58,7 @@ export default function GameCanvas({ ref, onPointerLock, playerState = null, fps onFogDensityChange={onFogDensityChange} onFovChange={onFovChange} onMouseSensitivityChange={onMouseSensitivityChange} + onWorldConfigApply={onWorldConfigApply} />
) diff --git a/src/components/Settings/Settings.module.css b/src/components/Settings/Settings.module.css index a0c6e32..0ec09b7 100644 --- a/src/components/Settings/Settings.module.css +++ b/src/components/Settings/Settings.module.css @@ -62,6 +62,47 @@ font-size: 11px; } +.section { + margin: 12px 0 10px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.15); +} + +.sectionTitle { + margin: 0 0 8px; + font-size: 12px; + color: #fff; + letter-spacing: 0.03em; +} + +.numberInput { + width: 100%; + box-sizing: border-box; + padding: 3px 6px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(0, 0, 0, 0.4); + color: #eee; + font-family: monospace; + font-size: 12px; +} + +.apply { + margin-top: 6px; + background: rgba(106, 170, 255, 0.2); + border: 1px solid rgba(106, 170, 255, 0.5); + color: #dbeeff; + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; + font-size: 11px; + font-family: monospace; +} + +.apply:hover { + background: rgba(106, 170, 255, 0.3); +} + .reset { margin-top: 4px; background: rgba(255, 255, 255, 0.1); diff --git a/src/components/Settings/Settings.test.tsx b/src/components/Settings/Settings.test.tsx index ae58662..c95d644 100644 --- a/src/components/Settings/Settings.test.tsx +++ b/src/components/Settings/Settings.test.tsx @@ -67,6 +67,16 @@ describe('SettingsPanel', () => { expect(onSens).toHaveBeenCalledWith(0.003) }) + it('calls onWorldConfigApply when world config is applied', () => { + const onApply = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /settings/i })) + fireEvent.change(screen.getByTestId('input-world-seed'), { target: { value: '1234' } }) + fireEvent.change(screen.getByTestId('input-biome-scale'), { target: { value: '2.5' } }) + fireEvent.click(screen.getByRole('button', { name: /apply world config/i })) + expect(onApply).toHaveBeenCalledWith({ seed: 1234, biomeScale: 2.5 }) + }) + it('saves value to localStorage on slider change', () => { render() fireEvent.click(screen.getByRole('button', { name: /settings/i })) diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index dc8b054..03674c3 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -1,22 +1,27 @@ import { useState } from 'react' import { save, load, DEFAULTS } from '../../engine/Settings' +import { DEFAULT_WORLD_CONFIG, type WorldConfig } from '../../engine/biome/BiomeTypes' import styles from './Settings.module.css' interface SettingsPanelProps { onFogDensityChange?: (v: number) => void onFovChange?: (v: number) => void onMouseSensitivityChange?: (v: number) => void + onWorldConfigApply?: (config: WorldConfig) => void } export default function SettingsPanel({ onFogDensityChange, onFovChange, onMouseSensitivityChange, + onWorldConfigApply, }: SettingsPanelProps) { const [open, setOpen] = useState(false) const [fogDensity, setFogDensity] = useState(() => load('fogDensity')) const [fov, setFov] = useState(() => load('fov')) const [sensitivity, setSensitivity] = useState(() => load('mouseSensitivity')) + const [worldSeed, setWorldSeed] = useState(DEFAULT_WORLD_CONFIG.seed) + const [biomeScale, setBiomeScale] = useState(DEFAULT_WORLD_CONFIG.biomeScale) function handleFogDensity(v: number) { setFogDensity(v) @@ -42,6 +47,20 @@ export default function SettingsPanel({ handleSensitivity(DEFAULTS.mouseSensitivity) } + function handleWorldSeed(v: string) { + const parsed = Number.parseInt(v, 10) + if (!Number.isNaN(parsed)) setWorldSeed(parsed) + } + + function handleBiomeScale(v: string) { + const parsed = Number.parseFloat(v) + if (!Number.isNaN(parsed) && parsed > 0) setBiomeScale(parsed) + } + + function handleApplyWorldConfig() { + onWorldConfigApply?.({ seed: worldSeed, biomeScale }) + } + return (
+
+ )} diff --git a/src/engine/ChunkManager.test.ts b/src/engine/ChunkManager.test.ts index 7c592c1..4d9faa9 100644 --- a/src/engine/ChunkManager.test.ts +++ b/src/engine/ChunkManager.test.ts @@ -33,6 +33,7 @@ function makeFakeWasmClient(worldUpdateImpl: () => Promise) { generateChunk: vi.fn().mockResolvedValue({ heightmap: new Float32Array(129 * 129), normals: new Float32Array(129 * 129 * 3), + biomeTransition: { primaryBiomeId: 0, secondaryBiomeId: 0, blendFactor: 0 }, }), } as unknown as WasmClient } @@ -165,3 +166,81 @@ describe('ChunkManager.init', () => { expect(manager.getActiveChunks().length).toBeGreaterThan(0) }) }) + +describe('ChunkManager.reloadChunks', () => { + it('regenerates active chunks and destroys old GPU buffers', async () => { + const update: WorldUpdate = { + chunksToAdd: [{ coord: { X: 2, Z: 3 } }], + chunksToRemove: [], + } + const wasmClient = makeFakeWasmClient(() => Promise.resolve(update)) + const device = makeFakeDevice() + const manager = new ChunkManager(device, wasmClient, makeFakeBindGroupLayout()) + + await manager.streamUpdate(256, 256) + const oldChunk = manager.getActiveChunks()[0]! + + await manager.reloadChunks(256, 256) + + const reloadedChunk = manager.getActiveChunks()[0]! + expect(reloadedChunk.coord).toEqual({ x: 2, z: 3 }) + expect(reloadedChunk.vertexBuffer).not.toBe(oldChunk.vertexBuffer) + expect(reloadedChunk.indexBuffer).not.toBe(oldChunk.indexBuffer) + expect(reloadedChunk.uniformBuffer).not.toBe(oldChunk.uniformBuffer) + expect(oldChunk.vertexBuffer.destroy).toHaveBeenCalled() + expect(oldChunk.indexBuffer.destroy).toHaveBeenCalled() + expect(oldChunk.uniformBuffer.destroy).toHaveBeenCalled() + expect(wasmClient.generateChunk).toHaveBeenCalledTimes(2) + }) + + it('falls back to streamUpdate when no chunks are active', async () => { + const update: WorldUpdate = { + chunksToAdd: [{ coord: { X: 4, Z: 5 } }], + chunksToRemove: [], + } + const wasmClient = makeFakeWasmClient(() => Promise.resolve(update)) + const device = makeFakeDevice() + const manager = new ChunkManager(device, wasmClient, makeFakeBindGroupLayout()) + + await manager.reloadChunks(1024, 2048) + + expect(wasmClient.worldUpdate).toHaveBeenCalledWith(1024, 2048) + expect(wasmClient.generateChunk).toHaveBeenCalledTimes(1) + expect(manager.getActiveChunks()).toHaveLength(1) + expect(manager.getActiveChunks()[0].coord).toEqual({ x: 4, z: 5 }) + }) +}) + +describe('ChunkManager.generateChunk', () => { + it('writes biome transition metadata into biomeData uniform vec4', async () => { + const wasmClient = { + initWorld: vi.fn().mockResolvedValue(undefined), + worldUpdate: vi.fn().mockResolvedValue({ chunksToAdd: [], chunksToRemove: [] }), + generateChunk: vi.fn().mockResolvedValue({ + heightmap: new Float32Array(129 * 129), + normals: new Float32Array(129 * 129 * 3), + biomeTransition: { primaryBiomeId: 2, secondaryBiomeId: 5, blendFactor: 0.35 }, + }), + } as unknown as WasmClient + + const device = makeFakeDevice() + const manager = new ChunkManager(device, wasmClient, makeFakeBindGroupLayout()) + + const chunk = await manager.generateChunk(7, 8) + + const writeCalls = vi.mocked(device.queue.writeBuffer).mock.calls + const biomeCall = writeCalls.find(([, offset]) => offset === 128) + expect(biomeCall).toBeDefined() + const biomeData = biomeCall?.[2] as Float32Array + expect(biomeData[0]).toBe(2) + expect(biomeData[1]).toBe(5) + expect(biomeData[2]).toBeCloseTo(0.35) + expect(biomeData[3]).toBe(0) + + expect(chunk.biomeTransition).toEqual({ + primaryBiomeId: 2, + secondaryBiomeId: 5, + blendFactor: 0.35, + }) + }) +}) diff --git a/src/engine/ChunkManager.ts b/src/engine/ChunkManager.ts index 935373e..21dc783 100644 --- a/src/engine/ChunkManager.ts +++ b/src/engine/ChunkManager.ts @@ -1,4 +1,4 @@ -import WasmClient from './WasmClient' +import WasmClient, { type BiomeTransitionMeta } from './WasmClient' import type { ChunkCoord } from './worker/WasmBridge' import MeshBuilder from './MeshBuilder' import { testAABB } from './Frustum' @@ -11,7 +11,7 @@ export interface ChunkGPUData { uniformBuffer: GPUBuffer bindGroup: GPUBindGroup indexCount: number - biomeId: number + biomeTransition: BiomeTransitionMeta } const RESOLUTION = 129 @@ -64,14 +64,40 @@ export default class ChunkManager { const idx = this.activeChunks.findIndex(c => c.coord.x === cx && c.coord.z === cz) if (idx === -1) return const chunk = this.activeChunks[idx] + this.destroyChunkResources(chunk) + this.activeChunks.splice(idx, 1) + } + + private destroyChunkResources(chunk: ChunkGPUData): void { chunk.vertexBuffer.destroy() chunk.indexBuffer.destroy() chunk.uniformBuffer.destroy() - this.activeChunks.splice(idx, 1) + } + + async reloadChunks(playerX: number, playerZ: number): Promise { + const coordsToRegenerate = this.activeChunks.map(chunk => ({ + x: chunk.coord.x, + z: chunk.coord.z, + })) + + for (const chunk of this.activeChunks) { + this.destroyChunkResources(chunk) + } + this.activeChunks = [] + + if (coordsToRegenerate.length === 0) { + await this.streamUpdate(playerX, playerZ) + return + } + + const regeneratedChunks = await Promise.all( + coordsToRegenerate.map(coord => this.generateChunk(coord.x, coord.z)) + ) + this.activeChunks = regeneratedChunks } async generateChunk(cx: number, cz: number): Promise { - const { heightmap, normals, biomeId } = await this.wasmClient.generateChunk( + const { heightmap, normals, biomeTransition } = await this.wasmClient.generateChunk( {}, cx, cz, RESOLUTION, CHUNK_SIZE, HEIGHT_SCALE, ) @@ -100,7 +126,18 @@ export default class ChunkManager { const worldOffset = new Float32Array([cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE, 0]) this.device.queue.writeBuffer(uniformBuffer, 64, worldOffset) - const biomeData = new Float32Array([biomeId ?? 0, 0, 0, 0]) + const resolvedBiomeTransition: BiomeTransitionMeta = { + primaryBiomeId: biomeTransition?.primaryBiomeId ?? 0, + secondaryBiomeId: biomeTransition?.secondaryBiomeId ?? (biomeTransition?.primaryBiomeId ?? 0), + blendFactor: Math.min(1, Math.max(0, biomeTransition?.blendFactor ?? 0)), + } + + const biomeData = new Float32Array([ + resolvedBiomeTransition.primaryBiomeId, + resolvedBiomeTransition.secondaryBiomeId, + resolvedBiomeTransition.blendFactor, + 0, + ]) this.device.queue.writeBuffer(uniformBuffer, 128, biomeData) const bindGroup = this.device.createBindGroup({ @@ -115,7 +152,7 @@ export default class ChunkManager { uniformBuffer, bindGroup, indexCount, - biomeId: biomeId ?? 0, + biomeTransition: resolvedBiomeTransition, } } diff --git a/src/engine/GameEngine.ts b/src/engine/GameEngine.ts index 09d17be..99ac83c 100644 --- a/src/engine/GameEngine.ts +++ b/src/engine/GameEngine.ts @@ -9,6 +9,7 @@ import FPSCamera from './FPSCamera' import type { PlayerState } from './FPSCamera' import mat4 from './math/mat4' import { load } from './Settings' +import type { WorldConfig } from './biome/BiomeTypes' const FALLBACK_EYE: [number, number, number] = [768, 320, 768] const FALLBACK_CENTER: [number, number, number] = [256, 0, 256] @@ -102,6 +103,15 @@ export default class GameEngine { this.inputSystem?.setSensitivity(s / 0.002) } + async applyWorldConfig(config: WorldConfig): Promise { + if (!this.wasmClient || !this.chunkManager) return + + await this.wasmClient.loadWorldConfig(config) + const playerX = this.playerState?.x ?? 256 + const playerZ = this.playerState?.z ?? 256 + await this.chunkManager.reloadChunks(playerX, playerZ) + } + stop(): void { if (this.rafId !== null) { cancelAnimationFrame(this.rafId) diff --git a/src/engine/WasmClient.ts b/src/engine/WasmClient.ts index e40a8f1..ad6612f 100644 --- a/src/engine/WasmClient.ts +++ b/src/engine/WasmClient.ts @@ -6,6 +6,18 @@ export interface WorldUpdate { chunksToRemove: Array<{ X: number; Z: number }> | null } +export interface BiomeTransitionMeta { + primaryBiomeId: number + secondaryBiomeId: number + blendFactor: number +} + +export interface ChunkGenerationResult { + heightmap: Float32Array + normals: Float32Array + biomeTransition: BiomeTransitionMeta +} + export default class WasmClient { private worker: Worker private nextId = 0 @@ -74,11 +86,11 @@ export default class WasmClient { resolution: number, chunkSize: number, heightScale: number, - ): Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> { + ): Promise { return this.call( 'generateChunk', [JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale], - ) as Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> + ) as Promise } async worldUpdate(playerX: number, playerZ: number): Promise { diff --git a/src/engine/worker/WasmBridge.ts b/src/engine/worker/WasmBridge.ts index a43d229..b037c12 100644 --- a/src/engine/worker/WasmBridge.ts +++ b/src/engine/worker/WasmBridge.ts @@ -30,8 +30,9 @@ declare global { chunkSize: number, heightScale: number, ): Float32Array - /** Combined heightmap+normals+biomeId generation in pure Go. - * Returns flat Float32Array: [hm(res*res)..., normals(res*res*3)..., biomeId(1)] */ + /** Combined heightmap+normals+biome transition generation in pure Go. + * Returns flat Float32Array: + * [hm(res*res)..., normals(res*res*3)..., primaryBiomeId(1), secondaryBiomeId(1), blendFactor(1)] */ function go_generateChunk( configJSON: string, chunkX: number, diff --git a/src/engine/worker/terrain.worker.ts b/src/engine/worker/terrain.worker.ts index 2db3459..630defc 100644 --- a/src/engine/worker/terrain.worker.ts +++ b/src/engine/worker/terrain.worker.ts @@ -47,7 +47,8 @@ function handleCall(event: MessageEvent): void { // go_generateChunk runs both heightmap generation and normal computation // entirely inside Go using pure Go slices — no JS Float32Array is ever // passed between two Go WASM functions (which silently produces length 0). - // Returns flat Float32Array: [heightmap(res×res)..., normals(res×res×3)..., biomeId(1)] + // Returns flat Float32Array: + // [heightmap(res×res)..., normals(res×res×3)..., primaryBiomeId, secondaryBiomeId, blendFactor] const combined = go_generateChunk(configJSON, cx, cz, resolution, chunkSize, heightScale) if (!combined || !combined.buffer) throw new Error('go_generateChunk returned no data') @@ -55,9 +56,19 @@ function handleCall(event: MessageEvent): void { const normLen = resolution * resolution * 3 const heightmap = combined.slice(0, hmLen) // copy, own buffer const normals = combined.slice(hmLen, hmLen + normLen) // copy, own buffer - const biomeId = Math.round(combined[hmLen + normLen]) - - result = { heightmap, normals, biomeId } + const metadataStart = hmLen + normLen + const primaryBiomeId = Math.round(combined[metadataStart] ?? 0) + const secondaryBiomeId = Math.round(combined[metadataStart + 1] ?? primaryBiomeId) + const rawBlendFactor = combined[metadataStart + 2] ?? 0 + const blendFactor = Number.isFinite(rawBlendFactor) + ? Math.min(1, Math.max(0, rawBlendFactor)) + : 0 + + result = { + heightmap, + normals, + biomeTransition: { primaryBiomeId, secondaryBiomeId, blendFactor }, + } transfer = [heightmap.buffer as ArrayBuffer, normals.buffer as ArrayBuffer] } else if (method === 'worldUpdate') { const [playerX, playerZ] = args as [number, number] diff --git a/src/shaders/terrain.frag.wgsl b/src/shaders/terrain.frag.wgsl index 390b88c..7954bfa 100644 --- a/src/shaders/terrain.frag.wgsl +++ b/src/shaders/terrain.frag.wgsl @@ -38,9 +38,20 @@ fn getHeightBlendedColor(biomeId: f32, worldY: f32) -> vec3f { return mix(midColor, snowColor, snowBlend); } +fn getTransitionBlendedColor(primaryBiomeId: f32, secondaryBiomeId: f32, blendFactor: f32, worldY: f32) -> vec3f { + let primaryColor = getHeightBlendedColor(primaryBiomeId, worldY); + let secondaryColor = getHeightBlendedColor(secondaryBiomeId, worldY); + return mix(primaryColor, secondaryColor, clamp(blendFactor, 0.0, 1.0)); +} + @fragment fn fs_main(f: FragInput) -> @location(0) vec4 { - let albedo = getHeightBlendedColor(uniforms.biomeData.x, f.worldPos.y); + let albedo = getTransitionBlendedColor( + uniforms.biomeData.x, + uniforms.biomeData.y, + uniforms.biomeData.z, + f.worldPos.y, + ); let lightDir = normalize(vec3(0.5, 1.2, 0.4)); let diffuse = max(dot(normalize(f.normal), lightDir), 0.0); diff --git a/wasm/biome/adjacency.go b/wasm/biome/adjacency.go new file mode 100644 index 0000000..52bd350 --- /dev/null +++ b/wasm/biome/adjacency.go @@ -0,0 +1,36 @@ +package biome + +import "math" + +type biomeAdjacencyRule struct { + left BiomeType + right BiomeType + buffer BiomeType +} + +var biomeAdjacencyRules = [...]biomeAdjacencyRule{ + {left: Desert, right: Swamp, buffer: Grassland}, +} + +// applyAdjacencyBuffering enforces explicit biome adjacency rules by moving +// conflicting influence into an allowed buffer biome. +func applyAdjacencyBuffering(weights [6]float64) [6]float64 { + adjusted := weights + for _, rule := range biomeAdjacencyRules { + leftIdx := int(rule.left) + rightIdx := int(rule.right) + bufferIdx := int(rule.buffer) + + leftWeight := adjusted[leftIdx] + rightWeight := adjusted[rightIdx] + if leftWeight <= 0 || rightWeight <= 0 { + continue + } + + transfer := math.Min(leftWeight, rightWeight) + adjusted[leftIdx] -= transfer + adjusted[rightIdx] -= transfer + adjusted[bufferIdx] += 2 * transfer + } + return adjusted +} diff --git a/wasm/biome/adjacency_test.go b/wasm/biome/adjacency_test.go new file mode 100644 index 0000000..f4c838b --- /dev/null +++ b/wasm/biome/adjacency_test.go @@ -0,0 +1,62 @@ +package biome + +import ( + "math" + "testing" +) + +func TestApplyAdjacencyBuffering_ConflictingPairUsesBuffer(t *testing.T) { + weights := [6]float64{ + 0.08, // Grassland + 0.44, // Desert + 0.02, // Mountains + 0.02, // Valley + 0.40, // Swamp + 0.04, // Forest + } + + buffered := applyAdjacencyBuffering(weights) + + if buffered[Desert] > 0 && buffered[Swamp] > 0 { + t.Fatalf("desert and swamp still directly co-dominate after buffering: desert=%f swamp=%f", buffered[Desert], buffered[Swamp]) + } + + expectedGrassland := weights[Grassland] + 2*math.Min(weights[Desert], weights[Swamp]) + if math.Abs(buffered[Grassland]-expectedGrassland) > 1e-12 { + t.Fatalf("grassland buffer weight mismatch: got=%f want=%f", buffered[Grassland], expectedGrassland) + } + + if dominant := dominantBiomeFromWeights(buffered); dominant != Grassland { + t.Fatalf("expected buffered dominant biome to be Grassland, got %v", dominant) + } +} + +func TestGaussianBiomeWeights_AdjacencyBufferingStaysNormalized(t *testing.T) { + testPoints := [][2]float64{ + {0.82, 0.14}, + {0.72, 0.50}, + {0.62, 0.86}, + {0.50, 0.50}, + } + + for _, point := range testPoints { + weights := gaussianBiomeWeights(point[0], point[1]) + sum := 0.0 + for _, w := range weights { + sum += w + } + if math.Abs(sum-1.0) > 1e-9 { + t.Fatalf("weights should stay normalized at (%f,%f): got sum=%f", point[0], point[1], sum) + } + } +} + +func TestGaussianBiomeWeights_AdjacencyBufferingDeterministic(t *testing.T) { + first := gaussianBiomeWeights(0.72, 0.50) + for i := 0; i < 20; i++ { + next := gaussianBiomeWeights(0.72, 0.50) + if next != first { + t.Fatalf("gaussian biome weights changed between runs: first=%v next=%v", first, next) + } + } +} diff --git a/wasm/biome/generator.go b/wasm/biome/generator.go index 87d0780..123ee06 100644 --- a/wasm/biome/generator.go +++ b/wasm/biome/generator.go @@ -2,6 +2,7 @@ package biome import ( "math" + "sort" "github.com/maxfelker/terrain-webgpu/wasm/noise" "github.com/maxfelker/terrain-webgpu/wasm/terrain" @@ -25,6 +26,13 @@ var climates = [6]biomeClimate{ Forest: {0.51, 0.66, 0.20}, } +// ChunkBiomeTransition stores chunk-level biome blend metadata for rendering. +type ChunkBiomeTransition struct { + PrimaryBiomeID BiomeType + SecondaryBiomeID BiomeType + BlendFactor float32 +} + // gaussianBiomeWeights returns a normalised [6]float64 of per-biome blend weights // computed from Gaussian distance to each biome's climate center. // At any temp/humidity point, adjacent biomes receive non-zero weights so heights @@ -43,7 +51,7 @@ func gaussianBiomeWeights(temperature, humidity float64) [6]float64 { w[i] /= total } } - return w + return applyAdjacencyBuffering(w) } // sampleBiomeHeight computes the raw terrain height at (wx, wz) using the @@ -72,13 +80,58 @@ func blendedHeight(wx, wz float64, terrainSeed int, weights [6]float64) float32 // dominantBiomeFromWeights returns the BiomeType with the highest weight. func dominantBiomeFromWeights(weights [6]float64) BiomeType { - best := 0 - for i := 1; i < 6; i++ { - if weights[i] > weights[best] { - best = i + primary, _ := topTwoBiomesFromWeights(weights) + return primary +} + +func topTwoBiomesFromWeights(weights [6]float64) (BiomeType, BiomeType) { + order := [6]BiomeType{} + for i := range order { + order[i] = BiomeType(i) + } + + sort.SliceStable(order[:], func(i, j int) bool { + left := order[i] + right := order[j] + leftWeight := weights[left] + rightWeight := weights[right] + if leftWeight == rightWeight { + return left < right } + return leftWeight > rightWeight + }) + + return order[0], order[1] +} + +// ChunkBiomeTransitionFromWeights deterministically selects the top-2 biomes +// from chunk-accumulated weights and computes a normalized blend factor. +func ChunkBiomeTransitionFromWeights(weights [6]float64) ChunkBiomeTransition { + primary, secondary := topTwoBiomesFromWeights(weights) + primaryWeight := weights[primary] + secondaryWeight := weights[secondary] + + if secondaryWeight <= 0 { + secondary = primary + } + + var blend float32 + sum := primaryWeight + secondaryWeight + if sum > 0 && secondary != primary { + blend = float32(secondaryWeight / sum) + } + if math.IsNaN(float64(blend)) || blend < 0 { + blend = 0 + } + if blend > 1 { + blend = 1 + } + + return ChunkBiomeTransition{ + PrimaryBiomeID: primary, + SecondaryBiomeID: secondary, + BlendFactor: blend, } - return BiomeType(best) } // GenerateHeightmapPerVertex generates a heightmap where every vertex uses @@ -86,7 +139,13 @@ func dominantBiomeFromWeights(weights [6]float64) BiomeType { // This ensures: // - Seamless chunk boundaries (same world coord → same result on both sides) // - Smooth terrain transitions (no hard walls at biome boundaries) -func GenerateHeightmapPerVertex(chunkX, chunkZ int, cfg terrain.ChunkConfig, worldSeed int) (hm []float32, dominant BiomeType) { +func GenerateHeightmapPerVertex(chunkX, chunkZ int, cfg terrain.ChunkConfig, worldSeed int) (hm []float32, transition ChunkBiomeTransition) { + return GenerateHeightmapPerVertexWithScale(chunkX, chunkZ, cfg, worldSeed, 1.0) +} + +// GenerateHeightmapPerVertexWithScale applies biomeScale while generating a +// per-vertex blended heightmap and chunk-level transition metadata. +func GenerateHeightmapPerVertexWithScale(chunkX, chunkZ int, cfg terrain.ChunkConfig, worldSeed int, biomeScale float64) (hm []float32, transition ChunkBiomeTransition) { res := cfg.HeightmapResolution out := make([]float32, res*res) worldOriginX := float64(chunkX * cfg.Dimension) @@ -100,7 +159,7 @@ func GenerateHeightmapPerVertex(chunkX, chunkZ int, cfg terrain.ChunkConfig, wor wx := worldOriginX + float64(col)*spacing wz := worldOriginZ + float64(row)*spacing - temperature, humidity := GetBiomeParams(wx, wz, worldSeed) + temperature, humidity := GetBiomeParamsWithScale(wx, wz, worldSeed, biomeScale) weights := gaussianBiomeWeights(temperature, humidity) out[row*res+col] = blendedHeight(wx, wz, cfg.Seed, weights) @@ -111,15 +170,20 @@ func GenerateHeightmapPerVertex(chunkX, chunkZ int, cfg terrain.ChunkConfig, wor } } - // Dominant biome is whichever accumulated the most weight across all vertices. - dominant = dominantBiomeFromWeights(weightSums) - return out, dominant + transition = ChunkBiomeTransitionFromWeights(weightSums) + return out, transition } // GenerateExtendedHeightmapPerVertex generates a (resolution+2)×(resolution+2) // extended heightmap with Gaussian-blended per-vertex heights for seamless // cross-boundary normal computation. func GenerateExtendedHeightmapPerVertex(chunkX, chunkZ int, cfg terrain.ChunkConfig, worldSeed int) []float32 { + return GenerateExtendedHeightmapPerVertexWithScale(chunkX, chunkZ, cfg, worldSeed, 1.0) +} + +// GenerateExtendedHeightmapPerVertexWithScale applies biomeScale while +// generating the extended heightmap used for normal computation. +func GenerateExtendedHeightmapPerVertexWithScale(chunkX, chunkZ int, cfg terrain.ChunkConfig, worldSeed int, biomeScale float64) []float32 { res := cfg.HeightmapResolution extRes := res + 2 out := make([]float32, extRes*extRes) @@ -133,7 +197,7 @@ func GenerateExtendedHeightmapPerVertex(chunkX, chunkZ int, cfg terrain.ChunkCon wx := worldOriginX + float64(col-1)*spacing wz := worldOriginZ + float64(row-1)*spacing - temperature, humidity := GetBiomeParams(wx, wz, worldSeed) + temperature, humidity := GetBiomeParamsWithScale(wx, wz, worldSeed, biomeScale) weights := gaussianBiomeWeights(temperature, humidity) out[row*extRes+col] = blendedHeight(wx, wz, cfg.Seed, weights) @@ -141,4 +205,3 @@ func GenerateExtendedHeightmapPerVertex(chunkX, chunkZ int, cfg terrain.ChunkCon } return out } - diff --git a/wasm/biome/selector.go b/wasm/biome/selector.go index d8276d2..2cabb3e 100644 --- a/wasm/biome/selector.go +++ b/wasm/biome/selector.go @@ -1,15 +1,24 @@ package biome import ( + "math" + "github.com/maxfelker/terrain-webgpu/wasm/noise" ) const ( - biomeNoiseScale = 0.0002 // low frequency → large biome regions (~5000 units wide) - warpNoiseScale = 0.0008 // warp frequency - warpStrength = 150.0 // coordinate warp magnitude in world units + biomeNoiseScale = 0.0002 // low frequency → large biome regions (~5000 units wide) + warpNoiseScale = 0.0008 // warp frequency + warpStrength = 150.0 // coordinate warp magnitude in biome sample space ) +func normalizeBiomeScale(biomeScale float64) float64 { + if biomeScale <= 0 || math.IsNaN(biomeScale) || math.IsInf(biomeScale, 0) { + return 1.0 + } + return biomeScale +} + // GetBiomeAt returns the BiomeType for a world-space position. // Uses two low-frequency noise maps (temperature, humidity) with domain warping // to create organic, curved biome boundaries. @@ -22,12 +31,23 @@ func GetBiomeAt(worldX, worldZ float64, seed int) BiomeType { // values at a world position after applying domain warping. These continuous // values are used for smooth biome blending. func GetBiomeParams(worldX, worldZ float64, seed int) (temperature, humidity float64) { + return GetBiomeParamsWithScale(worldX, worldZ, seed, 1.0) +} + +// GetBiomeParamsWithScale returns raw temperature/humidity biome parameters while +// applying a biome region scale factor. Larger scale values produce larger biome +// regions by reducing effective sampling frequency. +func GetBiomeParamsWithScale(worldX, worldZ float64, seed int, biomeScale float64) (temperature, humidity float64) { + scale := normalizeBiomeScale(biomeScale) + scaledX := worldX / scale + scaledZ := worldZ / scale + // Domain warping: shift sample coordinates to create non-linear biome boundaries. - // Pass raw world coords; FBm handles frequency scaling internally. - wx := noise.FBm(worldX, worldZ, seed+1001, 3, warpNoiseScale, 2.0, 0.5) - wz := noise.FBm(worldX+500000, worldZ+500000, seed+1001, 3, warpNoiseScale, 2.0, 0.5) - sx := worldX + wx*warpStrength - sz := worldZ + wz*warpStrength + // Coordinates are pre-scaled so biomeScale adjusts region size. + wx := noise.FBm(scaledX, scaledZ, seed+1001, 3, warpNoiseScale, 2.0, 0.5) + wz := noise.FBm(scaledX+500000, scaledZ+500000, seed+1001, 3, warpNoiseScale, 2.0, 0.5) + sx := scaledX + wx*warpStrength + sz := scaledZ + wz*warpStrength // Temperature noise — seed offset to differ from terrain and humidity noise. tempRaw := noise.FBm(sx, sz, seed+1000, 3, biomeNoiseScale, 2.0, 0.5) diff --git a/wasm/biome/transition_test.go b/wasm/biome/transition_test.go new file mode 100644 index 0000000..2ffe835 --- /dev/null +++ b/wasm/biome/transition_test.go @@ -0,0 +1,123 @@ +package biome + +import ( + "math" + "testing" +) + +func TestChunkBiomeTransitionFromWeights_SelectsTopTwo(t *testing.T) { + weights := [6]float64{ + 0.52, // Grassland + 0.31, // Desert + 0.05, // Mountains + 0.04, // Valley + 0.03, // Swamp + 0.05, // Forest + } + + transition := ChunkBiomeTransitionFromWeights(weights) + + if transition.PrimaryBiomeID != Grassland { + t.Fatalf("expected primary biome Grassland, got %d", transition.PrimaryBiomeID) + } + if transition.SecondaryBiomeID != Desert { + t.Fatalf("expected secondary biome Desert, got %d", transition.SecondaryBiomeID) + } + + expectedBlend := float32(weights[Desert] / (weights[Grassland] + weights[Desert])) + if math.Abs(float64(transition.BlendFactor-expectedBlend)) > 1e-6 { + t.Fatalf("expected blend factor %.6f, got %.6f", expectedBlend, transition.BlendFactor) + } +} + +func TestChunkBiomeTransitionFromWeights_TieBreaksByBiomeID(t *testing.T) { + weights := [6]float64{ + 0.4, // Grassland + 0.4, // Desert + 0.1, // Mountains + 0.1, // Valley + 0.0, // Swamp + 0.0, // Forest + } + + transition := ChunkBiomeTransitionFromWeights(weights) + + if transition.PrimaryBiomeID != Grassland { + t.Fatalf("expected primary biome Grassland for tie-break, got %d", transition.PrimaryBiomeID) + } + if transition.SecondaryBiomeID != Desert { + t.Fatalf("expected secondary biome Desert for tie-break, got %d", transition.SecondaryBiomeID) + } + if math.Abs(float64(transition.BlendFactor-0.5)) > 1e-6 { + t.Fatalf("expected blend factor 0.5 for equal top-2 weights, got %.6f", transition.BlendFactor) + } +} + +func TestChunkBiomeTransitionFromWeights_NoSecondaryWeight(t *testing.T) { + weights := [6]float64{ + 0.0, // Grassland + 0.0, // Desert + 0.0, // Mountains + 1.0, // Valley + 0.0, // Swamp + 0.0, // Forest + } + + transition := ChunkBiomeTransitionFromWeights(weights) + + if transition.PrimaryBiomeID != Valley { + t.Fatalf("expected primary biome Valley, got %d", transition.PrimaryBiomeID) + } + if transition.SecondaryBiomeID != Valley { + t.Fatalf("expected secondary biome to collapse to primary, got %d", transition.SecondaryBiomeID) + } + if transition.BlendFactor != 0 { + t.Fatalf("expected blend factor 0 when no secondary weight, got %.6f", transition.BlendFactor) + } +} + +func TestGetBiomeParamsWithScale_ScalesSamplingCoordinates(t *testing.T) { + const ( + seed = 42 + worldX = 8364.5 + worldZ = -2931.75 + scale = 2.75 + epsilon = 1e-9 + ) + + scaledTemp, scaledHumid := GetBiomeParamsWithScale(worldX, worldZ, seed, scale) + equivalentTemp, equivalentHumid := GetBiomeParamsWithScale(worldX/scale, worldZ/scale, seed, 1.0) + + if math.Abs(scaledTemp-equivalentTemp) > epsilon || math.Abs(scaledHumid-equivalentHumid) > epsilon { + t.Fatalf( + "scaled sampling mismatch: got (%.9f, %.9f), want (%.9f, %.9f)", + scaledTemp, scaledHumid, equivalentTemp, equivalentHumid, + ) + } + + defaultTemp, defaultHumid := GetBiomeParamsWithScale(worldX, worldZ, seed, 1.0) + if math.Abs(scaledTemp-defaultTemp) < 1e-6 && math.Abs(scaledHumid-defaultHumid) < 1e-6 { + t.Fatal("biomeScale did not change sampled biome parameters") + } +} + +func TestGetBiomeParamsWithScale_InvalidScaleFallsBackToDefault(t *testing.T) { + const ( + seed = 99 + worldX = 2048.25 + worldZ = -1024.75 + ) + + baseTemp, baseHumid := GetBiomeParamsWithScale(worldX, worldZ, seed, 1.0) + invalidScales := []float64{0, -2, math.NaN(), math.Inf(1), math.Inf(-1)} + + for _, invalidScale := range invalidScales { + temp, humid := GetBiomeParamsWithScale(worldX, worldZ, seed, invalidScale) + if math.Abs(temp-baseTemp) > 1e-9 || math.Abs(humid-baseHumid) > 1e-9 { + t.Fatalf( + "invalid biomeScale %v should fallback to default; got (%.9f, %.9f), want (%.9f, %.9f)", + invalidScale, temp, humid, baseTemp, baseHumid, + ) + } + } +} diff --git a/wasm/main.go b/wasm/main.go index a10b509..c69398e 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -46,9 +46,15 @@ func goLoadWorldConfig(_ js.Value, args []js.Value) any { if len(args) == 0 { return js.Null() } - if err := json.Unmarshal([]byte(args[0].String()), &globalWorldCfg); err != nil { + defaultCfg := biome.DefaultWorldConfig() + cfg := defaultCfg + if err := json.Unmarshal([]byte(args[0].String()), &cfg); err != nil { return jsError(err) } + if cfg.BiomeScale <= 0 || math.IsNaN(cfg.BiomeScale) || math.IsInf(cfg.BiomeScale, 0) { + cfg.BiomeScale = defaultCfg.BiomeScale + } + globalWorldCfg = cfg return js.Null() } @@ -124,8 +130,8 @@ func goGetChunkHeight(_ js.Value, args []js.Value) any { // produce empty arrays due to syscall/js value lifecycle behaviour. // // Args: configJSON string, chunkX int, chunkZ int, resolution int, chunkSize int, heightScale float64 -// Returns: flat Float32Array [heightmap(res×res)..., normals(res×res×3)..., biomeId(1)] -// TypeScript splits via: hm = buf.subarray(0, res*res), normals = buf.subarray(res*res, res*res + res*res*3), biomeId = buf[res*res + res*res*3] +// Returns: flat Float32Array [heightmap(res×res)..., normals(res×res×3)..., primaryBiomeId(1), secondaryBiomeId(1), blendFactor(1)] +// TypeScript splits via hm/normals by fixed lengths then decodes final 3 metadata values. func goGenerateChunk(_ js.Value, args []js.Value) any { cfg := terrain.DefaultConfig() cfgStr := args[0].String() @@ -153,8 +159,9 @@ func goGenerateChunk(_ js.Value, args []js.Value) any { // Per-vertex biome sampling ensures seamless chunk boundaries. // Both sides of a shared edge compute height at the same world coordinate // → same biome → same noise config → matching heights, no gaps. - hm, biomeType := biome.GenerateHeightmapPerVertex(cx, cz, cfg, biomeSeed) - extHm := biome.GenerateExtendedHeightmapPerVertex(cx, cz, cfg, biomeSeed) + biomeScale := globalWorldCfg.BiomeScale + hm, biomeTransition := biome.GenerateHeightmapPerVertexWithScale(cx, cz, cfg, biomeSeed, biomeScale) + extHm := biome.GenerateExtendedHeightmapPerVertexWithScale(cx, cz, cfg, biomeSeed, biomeScale) // Normals are computed from the extended heightmap. The effective height scale // varies per vertex (biome height multiplier × base heightScale), but we pass @@ -169,11 +176,14 @@ func goGenerateChunk(_ js.Value, args []js.Value) any { globalWorld.SetHeight(int(heightScale)) } - // Return as a single flat Float32Array: [heightmap..., normals..., biomeId] - combined := make([]float32, len(hm)+len(normals)+1) + // Return as a single flat Float32Array: [heightmap..., normals..., primaryBiomeId, secondaryBiomeId, blendFactor] + combined := make([]float32, len(hm)+len(normals)+3) copy(combined, hm) copy(combined[len(hm):], normals) - combined[len(hm)+len(normals)] = float32(biomeType) + metadataOffset := len(hm) + len(normals) + combined[metadataOffset] = float32(biomeTransition.PrimaryBiomeID) + combined[metadataOffset+1] = float32(biomeTransition.SecondaryBiomeID) + combined[metadataOffset+2] = biomeTransition.BlendFactor return float32SliceToJS(combined) } @@ -231,6 +241,3 @@ func goUpdatePlayer(_ js.Value, args []js.Value) any { } return string(out) } - -// ensure math import is used -var _ = math.Floor From 4ce608e777c6e3714f450f6d0007ca0d0f0efc06 Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 15 Mar 2026 23:03:19 -0700 Subject: [PATCH 2/3] ci: add release workflow triggered on version tags Runs Go and TypeScript tests, then builds the production Docker image tagged with the version. Set push: true and add registry credentials to enable publishing to a container registry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0e8ca0b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: wasm/go.mod + + - name: Run Go tests + run: cd wasm && go test ./... + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Run TypeScript tests + run: npm ci && npm test + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # TODO: set push: true and add registry credentials to publish + - name: Build production Docker image + uses: docker/build-push-action@v6 + with: + context: . + target: production + push: false + tags: | + terrain-webgpu:${{ github.ref_name }} + terrain-webgpu:latest From 2b481681c9bccf1324e11ebed8639239fd069d2f Mon Sep 17 00:00:00 2001 From: MW Felker Date: Sun, 15 Mar 2026 23:23:22 -0700 Subject: [PATCH 3/3] fix(docker): add dev stage for local hot-reload development The dev service in docker-compose.yml referenced a 'dev' target stage that did not exist in the Dockerfile, causing builds to fail. - Add Stage 3 'dev': builds Go WASM in wasm-builder, installs npm deps, copies WASM artifacts to /wasm-dist, and runs 'npm run dev -- --host' on startup (copies WASM into the mounted /app/public before starting Vite) - Add anonymous /app/node_modules volume in docker-compose so the container's installed node_modules are not overridden by the host mount - Renumber production stage comment to Stage 4 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Dockerfile | 12 +++++++++++- docker-compose.yml | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index bfc2292..73fd8cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,17 @@ COPY --from=wasm-builder /usr/local/go/lib/wasm/wasm_exec.js public/wasm_exec.js RUN npm test RUN npm run build -# Stage 3: Production — serve with nginx +# Stage 3: Dev — hot-reload Vite dev server with Go WASM +FROM node:24-alpine AS dev +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY --from=wasm-builder /app/public/terrain.wasm /wasm-dist/terrain.wasm +COPY --from=wasm-builder /app/public/wasm_exec.js /wasm-dist/wasm_exec.js +EXPOSE 5173 +CMD ["sh", "-c", "mkdir -p public && cp /wasm-dist/terrain.wasm public/terrain.wasm && cp /wasm-dist/wasm_exec.js public/wasm_exec.js && npm run dev -- --host"] + +# Stage 4: Production — serve with nginx FROM nginx:alpine AS production COPY --from=web-builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/nginx.conf diff --git a/docker-compose.yml b/docker-compose.yml index d574798..fe5475e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: image: terrain-webgpu:dev volumes: - .:/app + - /app/node_modules ports: - "5173:5173" stdin_open: true