Skip to content
Open
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
48 changes: 48 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ services:
image: terrain-webgpu:dev
volumes:
- .:/app
- /app/node_modules
ports:
- "5173:5173"
stdin_open: true
Expand Down
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}}
/>
<div className={styles.status}> {isReady ? 'βœ“ WebGPU Ready' : 'Initializing WebGPU...'}
{pointerLocked && ' | Click to unlock'}
Expand Down
15 changes: 14 additions & 1 deletion src/components/GameCanvas/GameCanvas.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<HTMLCanvasElement>()
const onWorldConfigApply = vi.fn()
render(<GameCanvas ref={ref} onWorldConfigApply={onWorldConfigApply} />)

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 })
})
})
5 changes: 4 additions & 1 deletion src/components/GameCanvas/GameCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLCanvasElement | null>
Expand All @@ -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<HTMLDivElement>(null)

useEffect(() => {
Expand Down Expand Up @@ -56,6 +58,7 @@ export default function GameCanvas({ ref, onPointerLock, playerState = null, fps
onFogDensityChange={onFogDensityChange}
onFovChange={onFovChange}
onMouseSensitivityChange={onMouseSensitivityChange}
onWorldConfigApply={onWorldConfigApply}
/>
</div>
)
Expand Down
41 changes: 41 additions & 0 deletions src/components/Settings/Settings.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/components/Settings/Settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ describe('SettingsPanel', () => {
expect(onSens).toHaveBeenCalledWith(0.003)
})

it('calls onWorldConfigApply when world config is applied', () => {
const onApply = vi.fn()
render(<SettingsPanel onWorldConfigApply={onApply} />)
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(<SettingsPanel />)
fireEvent.click(screen.getByRole('button', { name: /settings/i }))
Expand Down
49 changes: 49 additions & 0 deletions src/components/Settings/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 (
<div className={styles.container}>
<button
Expand Down Expand Up @@ -101,6 +120,36 @@ export default function SettingsPanel({
<span className={styles.value}>{sensitivity.toFixed(4)}</span>
</label>

<div className={styles.section}>
<h5 className={styles.sectionTitle}>World Config</h5>
<label className={styles.label}>
Seed
<input
type="number"
step={1}
value={worldSeed}
onChange={e => handleWorldSeed(e.target.value)}
className={styles.numberInput}
data-testid="input-world-seed"
/>
</label>

<label className={styles.label}>
Biome Scale
<input
type="number"
min={0.1}
step={0.1}
value={biomeScale}
onChange={e => handleBiomeScale(e.target.value)}
className={styles.numberInput}
data-testid="input-biome-scale"
/>
</label>

<button className={styles.apply} onClick={handleApplyWorldConfig}>Apply World Config</button>
</div>

<button className={styles.reset} onClick={handleReset}>Reset</button>
</div>
)}
Expand Down
79 changes: 79 additions & 0 deletions src/engine/ChunkManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function makeFakeWasmClient(worldUpdateImpl: () => Promise<WorldUpdate>) {
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
}
Expand Down Expand Up @@ -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,
})
})
})
Loading
Loading