Concept · Building the World

Chunk Streaming

FloraForge's world is 65 kilometres on a side — far too much terrain, vegetation and water to hold in memory, let alone draw. The engine's answer is to keep only a small moving window of the world alive: a grid of tiles that loads ahead of you and dissolves behind you as you fly. This page explains that rolling window, and the worker threads that fill it without ever making a frame stutter.

A world bigger than memory

Thanks to procedural generation, the world doesn't need to be stored — any point of it can be computed on demand. But "on demand" needs a unit of demand, and that unit is the chunk: a fixed 256 × 256 metre square of world, carrying a 129 × 129 height grid, a moisture grid, river data, and the list of plants and houses standing on it. Chunks tile the world in a regular grid, so any position maps to a chunk coordinate with a single division. A chunk that isn't loaded simply doesn't exist anywhere in memory — it's pure math, waiting to be asked.

The rolling window

Around the camera, the engine maintains a square window of live chunks: every chunk within load_radius of the one the camera stands in. At the default radius of 3 that is a 7 × 7 window — 49 chunks, about 1.8 kilometres on a side. Every frame, the streaming system recomputes which chunks the window should contain, then makes reality match: chunks entering the window are requested, chunks that fell out of it are dropped on the spot. Because the window's size never changes, you can fly in one direction forever and the engine's memory use and per-frame workload stay flat — the world rolls beneath you like a treadmill.

flight direction camera doesn't exist yet loaded & drawn in memory, mesh on the GPU generating on a background worker thread dropped freed the frame it left the window not generated pure math until it's needed the camera the window: 7 × 7 = 49 chunks, ~1.8 km square — constant wherever you fly
The rolling window from above, mid-flight. The camera is flying east: the next column of chunks is already being generated on worker threads, while the columns behind it were dropped the moment they left the window.

The code that runs this every frame is short enough to read whole. Note the order: first collect whatever the workers finished, then re-centre the window, evict, and request what's newly missing:

src/world_runtime/streaming.rs — the per-frame streaming pass
pub fn update(&mut self, camera_position: Vec3, plant_world: &PlantWorld) {
    // Collect chunks the worker threads finished since last frame.
    for mut chunk in self.loader.poll() {
        let plants = plant_world.instances_for(chunk.coord, &chunk.terrain);
        chunk.content.set_plants(plants);
        self.loaded.insert(chunk.coord, chunk);
    }

    self.center_chunk = world_to_chunk(camera_position);
    let required = required_coords(self.center_chunk, self.load_radius);

    // Drop chunks that left the window; cancel queued work for them too.
    self.loaded.retain(|coord, _| required.contains(coord));
    self.loader.cancel_outside(&required);

    for &coord in &required {
        if !self.loaded.contains_key(&coord) {
            self.loader.dispatch(coord, self.seed);
        }
    }
}

Never block the loop

Generating a chunk is real work: two 16,641-point noise fields, river carving, biome classification, and plant placement. If the game loop did that work itself, every chunk boundary you crossed would cost a visible hitch — at flying speed, a whole column of seven chunks comes due at once. So the main loop never generates anything. It only does two cheap things: dispatch requests into a pool of background worker threads (one per CPU core), and poll a channel for finished results. Both sides of that hand-off are non-blocking — if no chunk is ready this frame, poll returns empty and the frame renders with what it has. A missing chunk is never waited for; it simply pops into existence a few frames later.

src/world_runtime/chunk_loader.rs — the worker-thread loader (trimmed)
fn dispatch(&mut self, coord: IVec2, seed: u32) {
    if self.pending.contains(&coord) {
        return;
    }
    self.pending.insert(coord);
    let tx = self.sender.clone();
    // ...clone shared config, plant registry, and river field handles...
    self.pool.spawn(move || {
        let generator = ChunkGenerator::new(seed, &config, registry, rivers);
        let chunk = generator.generate_chunk(coord);
        let _ = tx.send(chunk);   // back to the main thread via channel
    });
}

fn poll(&mut self) -> Vec<ChunkData> {
    let mut completed = Vec::new();
    while let Ok(chunk) = self.receiver.try_recv() {   // never blocks
        self.pending.remove(&chunk.coord);
        completed.push(chunk);
    }
    completed
}

One subtlety repays attention: cancel_outside. If you turn around sharply, chunks that were queued but not yet started are no longer needed — they're struck from the pending set so a fast change of direction doesn't leave the workers grinding through a wake of obsolete requests.

What "loading a chunk" actually means

On the worker thread, a chunk is built in layers: the terrain layer samples the height and moisture fields and carves the river channel through them; the biome layer classifies the result; the content layer decides which plants and houses stand where. Back on the main thread, the finished chunk takes its live plant list from the world-wide plant store and is handed to the renderer — which does not receive a mesh. It receives the raw height and moisture grids, and a compute shader builds the 129 × 129 vertex mesh directly on the GPU, where it will be drawn. Vegetation goes up as per-chunk instance buffers for instanced rendering. From then on the chunk costs almost nothing until frustum culling decides each frame whether it's even on screen.

The browser does it with a budget

The WebAssembly build has no worker threads available to this system, so it swaps in a different loader behind the same interface: a simple queue that generates at most two chunks per frame, synchronously, on the main thread. The cost is bounded per frame rather than moved off the thread — a budget instead of a worker pool. The streaming logic above doesn't know the difference; dispatch and poll just mean something else underneath.

In the engine
The window logic lives in src/world_runtime/streaming.rs (StreamingWorld), and the two loader implementations in src/world_runtime/chunk_loader.rs — a rayon thread pool with an mpsc channel natively, the per-frame queue on wasm. The streaming state is live telemetry: the debug API reports loaded and pending chunk counts every frame, so you can watch the window refill as you fly.