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
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
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 31d99c3..80ab460 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 4a6f25c..3c5d013 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 30fa036..5ec7142 100644
--- a/src/engine/WasmClient.ts
+++ b/src/engine/WasmClient.ts
@@ -7,6 +7,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
@@ -84,7 +96,7 @@ export default class WasmClient {
resolution: number,
chunkSize: number,
heightScale: number,
- ): Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> {
+ ): Promise {
if (this.pool) {
const result = await this.pool.generateChunk(
JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale,
@@ -103,7 +115,7 @@ export default class WasmClient {
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 8ffe6fa..ffe9703 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 14ff7f8..8b7ec91 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 3363144..3c1b1d8 100644
--- a/wasm/main.go
+++ b/wasm/main.go
@@ -47,9 +47,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()
}
@@ -125,8 +131,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()
@@ -154,8 +160,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
@@ -170,11 +177,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)
}