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.
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:
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.
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.
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.