Appearance
Chunk Integration: Wave Function Collapse Output to Graphics Processing Unit
The Wave Function Collapse (WFC) pipeline produces a GeneratedLevel -- a data structure full of zone grids, tile assignments, and per-vertex height and texture data. But the rendering pipeline has never heard of GeneratedLevel. It knows about vertexRegistry, chunk entities tagged IsDirty, and BufferGeometry. This entry bridges that gap.
Chunk integration is the translation layer. It takes the pure data output from WFC and feeds it into the existing Entity Component System (ECS) rendering pipeline so that buildDirtyChunks can do what it already knows how to do: turn vertex data into Graphics Processing Unit (GPU) geometry.
Goal
Write a populateLevel function that converts WFC output into the format the existing render pipeline expects. After calling it, generated terrain appears on screen through the same code path that renders hand-authored .bin chunks -- no rendering changes required.
Prerequisites
- 05 Noise Pass -- produces the
GeneratedLevelwith per-vertex data - Familiarity with the existing terrain pipeline (
vertexRegistry,buildDirtyChunks,MapManifest)
Key Concepts
The existing render pipeline
The game already has a working terrain renderer. It was built for hand-authored map data loaded from .bin files, but the data flow is general enough to accept any source of vertex data:
vertexRegistry (Map<string, VertexData>)
|
v
chunk entities with IsDirty tag
|
v
buildDirtyChunks system (rebuilds dirty chunks each frame)
|
v
BufferGeometry (position, weights, textureIndices, tint)
|
v
GPU upload → visible terrainThe vertexRegistry is a plain Map<string, VertexData> where each key is a vertex position string produced by vertexKey(x, z) (e.g. "1.50_-2.60") and each value is a single VertexData object for that vertex. The buildDirtyChunks system picks up entities with the IsDirty tag, reads their vertex data from the registry, constructs BufferGeometry, assigns a material, uploads to the GPU, and removes the IsDirty tag. That is the entire contract.
Our job is to write data into vertexRegistry and spawn tagged entities. Everything downstream handles itself.
Adapting buildDirtyChunks for hexagonal chunks
The current buildDirtyChunks iterates a CHUNK_SIZE × CHUNK_SIZE rectangular grid to collect vertices. With hexagonal chunks, it needs to iterate hexChunkCells(CHUNK_RADIUS) instead -- walking the hexagonal region of radius R centered on the chunk's axial position.
The change is localized to the inner loop that enumerates cells within a chunk:
typescript
// Before (rectangular):
for (let lq = 0; lq < CHUNK_SIZE; lq++) {
for (let lr = 0; lr < CHUNK_SIZE; lr++) {
const q = coord.q * CHUNK_SIZE + lq;
const r = coord.r * CHUNK_SIZE + lr;
// ... look up vertices
}
}
// After (hexagonal):
const cells = hexChunkCells(CHUNK_RADIUS);
for (const cell of cells) {
const q = coord.q * CHUNK_SPACING + cell.q;
const r = coord.r * CHUNK_SPACING + cell.r;
const center = hexToWorld(q, r);
const corners = hexCorners(q, r);
// Look up center + 6 corner vertices from vertexRegistry
// Build triangles: center to each pair of adjacent corners
}CHUNK_SPACING (2 * CHUNK_RADIUS + 1) is the axial distance between adjacent chunk centers. Each cell produces 7 vertices (1 center + 6 corners) and 6 triangles (center to each edge). The index buffer connects center → corner[i] → corner[(i+1) % 6] for each of the 6 triangles per cell.
This change also affects the total vertex count allocation. A rectangular chunk has CHUNK_SIZE² × 7 vertices. A hexagonal chunk has (3R² + 3R + 1) × 7 vertices -- for radius 16, that is 817 × 7 = 5,719 vertices per chunk.
The vertex data format
Each vertex in the registry carries all the information the shader needs:
typescript
interface VertexData {
x: number; // world-space X position
z: number; // world-space Z position
height: number; // Y displacement above the hex plane
weights: [number, number, number, number]; // splat weights (sum to 1.0)
textureIndices: [number, number, number, number]; // indices into TERRAIN_TEXTURES
tint: [number, number, number]; // RGB multiplier per vertex
}The WFC noise pass produces TileVertexData with an identical shape. The mapping is direct -- no transformation needed, just write each entry into the registry under the correct chunk key.
Chunk entity anatomy
A chunk entity in the ECS has this shape:
typescript
world.spawn(
ChunkCoord({ q: 0, r: 3 }), // hex chunk position
ChunkMesh(), // Three.js Mesh (starts empty)
ChunkBiome({ biomeId: 'grassland' }),
ChunkChannels({ ids: ['grass_2', 'rock_2', 'sand_1', 'grass_2'] }),
IsDirty, // signals: "rebuild my geometry"
)The traits involved are all pre-existing:
| Trait | Shape | Purpose |
|---|---|---|
ChunkCoord | { q: number, r: number } | Axial position of this chunk in the hex grid |
ChunkMesh | () => new Mesh() | Factory -- each entity gets its own Three.js Mesh instance |
ChunkBiome | { biomeId: string } | Which biome definition this chunk uses |
ChunkChannels | { ids: [string, string, string, string] } | The four texture IDs for this chunk's splat channels |
IsDirty | tag (no data) | Presence triggers buildDirtyChunks to rebuild geometry |
MapManifest | world trait | Registry of active chunk keys, view radius, ready flag |
When buildDirtyChunks picks up an entity with IsDirty, it:
- Reads
ChunkCoordto compute the registry key ("q_r") - Looks up the vertex array in
vertexRegistry - Builds a
BufferGeometrywith four attributes - Assigns the shared terrain material
- Sets the mesh on
ChunkMesh - Removes the
IsDirtytag
BufferGeometry construction
The geometry that buildDirtyChunks produces has four vertex attributes:
Attribute Type Components Purpose
───────────── ───────── ────────── ─────────────────────────
position Float32 xyz World-space vertex position
weights Float32 xyzw Splat blend weights (4 channels)
textureIndices Float32 xyzw Integer index per splat channel
tint Float32 rgb Per-vertex color multiplierPlus an index buffer for triangle faces. The hex chunk geometry is a triangulated hex grid -- each hex cell produces 6 triangles (center to each of 6 edges). The index buffer defines which vertices form each triangle.
Key format conversion
This is a subtle but critical detail. The WFC pipeline uses "col,row" keys (comma-separated) because the WFC grid is a rectangular array indexed by column and row. The render pipeline uses "q_r" keys (underscore-separated) because the hex chunk system uses axial coordinates.
For a flat rectangular grid where chunks map directly to grid cells:
WFC key: "3,7" (col=3, row=7)
Render key: "3_7" (q=3, r=7)The conversion is a string format change -- replace the comma with an underscore. The numeric values map directly because the WFC grid cells correspond to chunk positions. If your level uses an offset or rotation, adjust accordingly.
typescript
function wfcKeyToChunkKey(wfcKey: string): string {
return wfcKey.replace(',', '_');
}
function chunkKeyToCoords(key: string): { q: number; r: number } {
const [q, r] = key.split('_').map(Number);
return { q, r };
}Zone-to-texture channel mapping
Each zone type from the WFC grid needs default texture assignments for its four splat channels. This lookup table provides the initial visual identity before biome blending refines it:
typescript
const ZONE_TEXTURE_CHANNELS: Record<ZoneType, [string, string, string, string]> = {
[ZoneType.RoadStraight]: ['stone_01', 'dirt_01', 'gravel_01', 'stone_02'],
[ZoneType.RoadLeft]: ['stone_01', 'dirt_01', 'gravel_01', 'stone_02'],
[ZoneType.RoadRight]: ['stone_01', 'dirt_01', 'gravel_01', 'stone_02'],
[ZoneType.Open]: ['grass_01', 'dirt_01', 'rock_01', 'grass_02'],
};These texture IDs must exist in the TERRAIN_TEXTURES registry. The shader uses getTextureIndex(id) to convert string IDs to the integer indices stored per vertex. Adjust the IDs to match your available texture catalogue.
This mapping is intentionally coarse -- it gives each zone a distinct look so you can verify the pipeline works. Biome blending (next dev log) replaces these with noise-driven assignments.
Implementation Steps
Step 1: Create the populate-level module
Create src/level/populate-level.ts. This module exports a single function:
typescript
import type { World } from 'koota';
import type { GeneratedLevel } from '../wfc/types';
export function populateLevel(world: World, level: GeneratedLevel): void {
clearExistingChunks(world);
writeVertexData(level);
spawnChunkEntities(world, level);
updateManifest(world, level);
}The function is synchronous. It runs once when a level is generated, not per frame. The existing frameloop picks up the spawned IsDirty entities automatically.
Step 2: Implement clearExistingChunks
Before populating, remove all stale terrain data. Query for all entities with ChunkCoord and destroy them. Clear the vertexRegistry map. This ensures no leftover chunks from a previous level bleed through.
typescript
import { ChunkCoord } from '../traits';
import { vertexRegistry } from '../utils/vertex-registry';
function clearExistingChunks(world: World): void {
// Destroy all chunk entities
world.query(ChunkCoord).forEach((entity) => {
entity.destroy();
});
// Clear vertex data
vertexRegistry.clear();
}Critical: never destroy IsPlayer entities here. The query targets ChunkCoord specifically, which only terrain chunks have.
Step 3: Implement writeVertexData
Iterate over the GeneratedLevel.chunks map and write each chunk's vertex array into vertexRegistry under the converted key format:
typescript
import { vertexKey } from '../utils/vertex-registry';
function writeVertexData(level: GeneratedLevel): void {
for (const [_wfcKey, vertices] of level.chunks) {
for (const v of vertices) {
vertexRegistry.set(vertexKey(v.x, v.z), v);
}
}
}The TileVertexData objects from the noise pass are structurally identical to VertexData. If your types diverge, map the fields explicitly here.
Step 4: Implement spawnChunkEntities
For each chunk in the generated level, determine its zone type, look up the texture channels, and spawn a chunk entity:
typescript
import { ChunkCoord, ChunkMesh, ChunkBiome, ChunkChannels, IsDirty } from '../traits';
function spawnChunkEntities(world: World, level: GeneratedLevel): void {
for (const [wfcKey, _vertices] of level.chunks) {
const chunkKey = wfcKeyToChunkKey(wfcKey);
const { q, r } = chunkKeyToCoords(chunkKey);
// Determine zone type from the WFC grid state
const zoneType = getZoneType(level, q, r);
const textures = ZONE_TEXTURE_CHANNELS[zoneType] ?? ZONE_TEXTURE_CHANNELS['open'];
world.spawn(
ChunkCoord({ q, r }),
ChunkMesh(),
ChunkBiome({ biomeId: 'grassland' }),
ChunkChannels({ ids: textures }),
IsDirty,
);
}
}The getZoneType function reads from the GeneratedLevel.zoneGrid to find the zone assigned to each grid cell. The mapping from grid cell to chunk position is direct for rectangular grids.
Step 5: Implement updateManifest
Build a Set<string> of all chunk keys and update the world-level MapManifest trait:
typescript
import { MapManifest } from '../traits';
function updateManifest(world: World, level: GeneratedLevel): void {
const chunkKeys = new Set<string>();
for (const wfcKey of level.chunks.keys()) {
chunkKeys.add(wfcKeyToChunkKey(wfcKey));
}
const manifest = world.get(MapManifest);
manifest.authored = chunkKeys;
manifest.ready = true;
// Set view radius large enough to keep all chunks loaded
const maxDim = Math.max(level.zoneGrid.width, level.zoneGrid.height);
manifest.viewRadius = maxDim;
}Setting viewRadius to the grid's largest dimension prevents the streamChunks system from unloading chunks that are "too far" from the player. For procedurally generated levels that fit on screen, all chunks should stay loaded.
Step 6: Wire into a test route
For initial testing, extend the /wfc-debug route (or create a temporary route) that calls generateLevel followed by populateLevel and renders the result in a <Canvas> with the existing ChunksRenderer:
typescript
// Pseudocode for the test route
const level = generateLevel(config);
populateLevel(world, level);
// ChunksRenderer and buildDirtyChunks handle the restThis validates the full pipeline end to end: WFC solver to zone pass to tile pass to noise pass to vertex registry to GPU geometry.
Step 7: Verify triangle winding and hex alignment
After the first successful render, zoom in and inspect the hex grid pattern. Each chunk should produce a clean hex grid with no gaps between adjacent chunks and no overlapping vertices. If you see seams, check the vertex key rounding precision in vertexKey(x, z) -- it rounds to 0.01 precision and produces "x_z" strings. Shared boundary vertices must hash to the same key.
Expected hex grid pattern (top-down view):
___ ___
/ \ / \
/ 0,0 \___/ 1,0 \
\ / \ /
\___/ 0,1 \___/
/ \ / \
/ 1,0 \___/ 1,1 \
\ / \ /
\___/ \___/
No gaps, no overlaps at chunk boundaries.Visual Checkpoint
After calling populateLevel with a WFC-generated level:
- Hex chunks appear on screen as flat colored hexagons arranged in a rectangular grid pattern
- Roads are visually distinct from open ground -- different texture channels give them a different look even with placeholder textures
- The hex grid is clean -- zoom in and verify no gaps between chunks, no overlapping vertices at boundaries
- Road variants look consistent -- straight, left, and right road chunks share the same texture set and appear visually unified
- Console shows no errors -- no missing texture IDs, no undefined registry lookups
- The chunk count matches -- if you generated a 5x10 grid, you should see exactly 50 chunk entities in the ECS
If roads and open ground look identical, check that ZONE_TEXTURE_CHANNELS uses texture IDs that actually exist in TERRAIN_TEXTURES. If chunks are missing, check the key format conversion -- a comma vs underscore mismatch silently drops chunks.
What's Next
The terrain is visible, but it looks flat and uniform -- every chunk of the same zone type uses identical textures with no variation. Biome Blending replaces these placeholder texture assignments with noise-driven per-vertex weights that give the terrain organic, natural-looking transitions between materials.