Skip to content

How to Build: Chunk Integration

I spent a lot of time building the Wave Function Collapse (WFC) terrain pipeline, and when it finally worked I had this beautiful grid of zone data sitting in memory -- road tiles, enemy clusters, points of interest -- but nothing on screen. The WFC system generates a GeneratedLevel, but the rendering pipeline expects vertex data in a completely different place. We need a bridge.

That bridge is chunk integration: the system that takes WFC output and feeds it into the existing rendering pipeline so build-dirty-chunks can do what it already knows how to do -- turn vertex data into GPU geometry.

The data flow

Here is the full picture of how terrain goes from abstract WFC output to pixels on screen:

generateLevel()  -->  populateLevel()  -->  vertexRegistry  -->  build-dirty-chunks  -->  GPU
     (System 1)        (this system)        (existing)           (existing)

Notice how everything after populateLevel already exists. We are not modifying the rendering pipeline at all. We are just learning to speak its language.

What populateLevel needs to do

For each chunk in the GeneratedLevel output, four things happen:

  1. Write vertex data to the global vertexRegistry Map -- positions, heights, splat weights, texture indices, and tint per vertex
  2. Assign texture channels based on the chunk's zone type -- mapping zone types to actual texture IDs from TERRAIN_TEXTURES
  3. Spawn a chunk entity with ChunkCoord, ChunkBiome, ChunkChannels, and the IsDirty tag
  4. Update MapManifest so the rest of the engine knows which chunks exist

Once entities are tagged IsDirty, the existing build-dirty-chunks system picks them up automatically. No rendering code changes required.

Key traits you will reuse

All of these already exist in the codebase -- no new traits needed:

TraitShapePurpose
ChunkCoord{ q: number, r: number }Hex chunk position
ChunkMesh() => new Mesh()Three.js mesh reference
ChunkBiome{ biomeId: string }Biome assignment per chunk
ChunkChannels{ ids: [string, string, string, string] }Texture IDs for 4 splat channels
IsDirtytagSignals chunk needs geometry rebuild
MapManifestworld trait with authored: Set<string>Registry of active chunk keys

The vertex data format

The WFC pipeline outputs TileVertexData (from the noise pass), and the vertex registry stores VertexData. The shapes are intentionally identical:

typescript
// Both have this structure:
// x, z, height, weights[4], textureIndices[4], tint[3]

The mapping is direct -- write each entry into vertexRegistry using the key format that build-dirty-chunks expects. Look at vertexKey(x, z) in utils/hex-math.ts for the exact rounding logic (rounds to 0.01 precision, produces "x_z" strings).

The zone-to-texture mapping

This is where it gets interesting. Each zone type from the WFC grid needs to map to four real texture IDs from TERRAIN_TEXTURES. This table determines the visual identity of each zone:

typescript
const ZONE_TEXTURE_CHANNELS: Record<ZoneType, [string, string, string, string]>
ZoneChannel 0Channel 1Channel 2Channel 3
roadstone_01dirt_01gravel_01stone_02
opengrass_01dirt_01rock_01grass_02
enemy_clusterdirt_01rock_01dead_grassgravel_01
poistone_01moss_01dirt_01grass_01

The exact texture IDs depend on what is registered in your TERRAIN_TEXTURES catalogue. Adjust these to match your available textures.

The key functions

populateLevel

typescript
function populateLevel(world: World, level: GeneratedLevel): void

This is the orchestrator. It clears existing chunks, writes all vertex data to the registry, spawns chunk entities with the correct texture channels, and updates MapManifest. Let's walk through each responsibility.

Clearing first: Always clear before populating. When the player dies or completes a level, we regenerate with a new seed, so stale data must go.

Writing vertices: Iterate over level.chunks (a Map of chunk key to vertex arrays) and write each vertex into vertexRegistry using vertexKey(v.x, v.z).

Spawning entities: For each grid cell, determine the zone type from the collapsed WFC state, look up the texture channels, and spawn a chunk entity:

typescript
world.spawn(
  ChunkCoord({ q: col, r: row }),
  ChunkMesh(),
  ChunkBiome({ biomeId: 'grassland' }),
  ChunkChannels({ ids: textures }),
  IsDirty,
)

Updating the manifest: Build a Set<string> of all chunk keys and set it on MapManifest. Set viewRadius to the larger dimension of the grid so all chunks remain loaded.

clearExistingChunks

typescript
function clearExistingChunks(world: World): void

Queries all entities with ChunkCoord and destroys them, then clears the vertexRegistry. Important: this must never touch the player entity -- only terrain chunks.

Watch out: chunk key format conversion

This tripped me up initially. GeneratedLevel.chunks uses "col,row" keys (comma-separated), but MapManifest.authored expects "q_r" keys (underscore-separated). The populate function must convert between these formats. It is a small detail but it will silently break chunk loading if you miss it.

Wiring it into the game

populateLevel is not a per-frame system. It runs once when a level is generated -- called from whatever triggers level generation (the level manager, a debug route, etc.). The existing frameloop already runs buildDirtyChunks every frame, which will automatically process the spawned IsDirty entities.

For testing, consider extending the /wfc-debug route to call populateLevel after generateLevel and render the result in a <Canvas> with the existing ChunksRenderer. This validates the full pipeline end-to-end: WFC to vertex registry to build-dirty-chunks to GPU.

Beta reference files

The beta codebase shows the exact patterns to follow:

  • systems/generate-chunk-data.ts -- how vertex data gets written to vertexRegistry per chunk
  • systems/load-authored-chunks.ts -- how chunk entities are spawned from manifest data
  • systems/build-dirty-chunks.ts -- the consumer of your output (no changes needed)
  • utils/vertex-registry.ts -- the vertexRegistry Map and VertexData type
  • utils/hex-math.ts -- vertexKey(x, z) for the vertex registry key format

What comes next

With chunk integration working, we can see WFC-generated terrain rendered in 3D. But the terrain will look flat and uniform -- every chunk uses the same textures with no variation. That is where biome blending comes in: noise-driven texture weights that give each depth tier its own visual identity.