Disclaimer: This is a vibe-coding project. I used Claude Code for the vast majority of the implementation (especially the wgpu rendering pipeline and noise-based terrain generation). My primary focus was exploring the architecture around chunk management, spatial indexing, and meshing. 😊

Live Demo ¡ GitHub Repository


The Software Stack

With WebGPU maturing and WebAssembly integration in Rust becoming remarkably smooth, I wanted to see how far browser-based 3D engines could be pushed:

  • Graphics: wgpu (Targeting WebGPU via WebAssembly)
  • Linear Algebra: glam
  • GUI: egui
  • Build Tooling: Trunk

Blocks and Chunks

At the core of any voxel engine lies a hierarchy: individual voxels aggregate into manageable spatial volumes called Chunks.

A chunk is represented as a contiguous 3D array of Blocks. Storing millions of empty air voxels naively would quickly exhaust memory, especially within browser constraints. To mitigate this, chunks consisting solely of air are treated as uniform singletons and avoid individual heap allocations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum BlockType {
    Air,
    Grass,
    Dirt,
    Stone,
}

pub struct Chunk {
    blocks: [BlockType; N_BLOCKS_PER_CHUNK],
}

View Distance & The Single-Thread Bottleneck

In a traditional native engine, chunk generation and meshing are dispatched to background thread pools (e.g., via rayon). In a standard browser WASM environment, however, we are bound to the main UI and animation thread.

If a frame takes longer than 16.6 ms we fall under the holy 60 FPS mark. Because generating 3D Perlin/Simplex noise and extracting surface polygons (meshing) is computationally heavy, we cannot generate all chunks within the player’s view distance in a single frame.

Instead, we need two things:

  1. Prioritization: Chunks closest to the player must generate first.
  2. Time-slicing (Amortization): Bound the number of chunks and meshes generated per frame cycle.

1. Precomputed Spherical Offsets

Calculating Euclidean distances on every frame for dozens of surrounding chunk coordinates is wasteful. Since the relative geometric layout never changes, we can precompute the offset coordinates relative to $(0, 0, 0)$ once at startup and sort them spherically:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fn generate_offsets_in_spherical_order(radius: isize) -> Vec<(ChunkCoord, usize)> {
    let mut offsets = Vec::new();

    for x in 0..radius {
        for y in 0..radius {
            for z in 0..radius {
                let offset_x = x - radius / 2;
                let offset_y = y - radius / 2;
                let offset_z = z - radius / 2;

                // Euclidean distance relative to origin
                let dist = (offset_x.pow(2) + offset_y.pow(2) + offset_z.pow(2)).isqrt() as usize;
                offsets.push((ChunkCoord(offset_x, offset_y, offset_z), dist));
            }
        }
    }

    // Sort ascending: closest chunks first
    offsets.sort_unstable_by_key(|(_, dist)| *dist);
    offsets
}

2. The Frame Update Loop

In each render tick, the engine slides its active chunk window based on the player’s movement, and then consumes a strictly defined compute budget of chunk and mesh builds:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Called once per frame
pub fn update(
    &mut self, 
    player_position: &ChunkCoord, 
    device: &wgpu::Device, 
    max_chunk_budget: usize, 
    max_mesh_budget: usize
) -> (usize, usize) {
    // 1. Shift the logical sliding window (O(1) index updates, practically free)
    self.slide_active_window(player_position);
    
    // 2. Process pending generation within budget
    let n_chunks = self.generate_pending_chunks(player_position, max_chunk_budget);
    let n_meshes = self.generate_pending_meshes(player_position, device, max_mesh_budget);

    (n_chunks, n_meshes)
}

Chunk Management: 3D Toroidal Buffering

Rather than reallocating an entire 3D grid whenever the player crosses a chunk boundary, the engine uses a 3D Toroidal Ring Buffer (Sliding Window).

Because coordinates wrap around via modulo operations across all three axes, moving through the world requires zero data reallocations. When the player moves along an axis (say, $+X$), only the 2D plane on the opposite, trailing boundary ($-X$) is invalidated and marked for regeneration with the newly entered terrain.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/// Update loaded chunks based on player movement.
/// Only marks the "trailing" surface layer of chunks for clearance.
fn slide_active_window(&mut self, new_player_coord: &ChunkCoord) {
    let deltas = [
        new_player_coord.0 - self.previous_player_coord.0,
        new_player_coord.1 - self.previous_player_coord.1,
        new_player_coord.2 - self.previous_player_coord.2,
    ];

    for (axis, &movement_delta) in deltas.iter().enumerate() {
        if movement_delta == 0 {
            continue;
        }

        let step = if movement_delta > 0 { 1 } else { -1 };
        let mut working_base = self.previous_player_coord;

        // Process step-by-step to handle multi-chunk teleports/fast movement
        for _ in 0..movement_delta.abs() {
            let half = self.active_size[axis] as isize / 2;
            let plane_offset = -half * step;

            // Invalidate the 2D plane perpendicular to the movement direction
            for i in 0..self.active_size[(axis + 1) % 3] as isize {
                for j in 0..self.active_size[(axis + 2) % 3] as isize {
                    let chunk_coord = match axis {
                        0 => ChunkCoord(working_base.0 + plane_offset, working_base.1 + i - half, working_base.2 + j - half),
                        1 => ChunkCoord(working_base.0 + j - half, working_base.1 + plane_offset, working_base.2 + i - half),
                        _ => ChunkCoord(working_base.0 + i - half, working_base.1 + j - half, working_base.2 + plane_offset),
                    };
                    
                    self.get_active_entry_mut(&chunk_coord).unset();
                }
            }

            match axis {
                0 => working_base.0 += step,
                1 => working_base.1 += step,
                _ => working_base.2 += step,
            }
        }
    }

    self.previous_player_coord = *new_player_coord;
}

Prioritized Chunk & Mesh Generation

Generating a chunk is a two-step pipeline:

  1. Voxel Population: The 3D density noise generates raw voxel data.
  2. Mesh Extraction: Once a chunk and all its direct neighbors are populated, faces obscured by adjacent solid blocks are culled, and GPU vertex buffers are created and uploaded.

Notice how whenever a non-empty chunk finishes loading, it flags its immediate neighbors (unset_mesh()): a border chunk cannot accurately cull its outer faces until adjacent blocks are known!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
fn generate_pending_chunks(&mut self, player_chunk_position: &ChunkCoord, budget: usize) -> usize {
    let mut used_budget = 0;

    for i in 0..self.offsets.len() {
        if used_budget >= budget {
            break;
        }

        let (offset, distance) = self.offsets[i];
        let chunk_coord = player_chunk_position.add(&offset);
        
        if self.get_active_entry(&chunk_coord).is_pending() {
            // Early out for chunks strictly above terrain generation limit
            if chunk_coord.1 <= -2 || chunk_coord.1 >= 14 {
                *self.get_active_entry_mut(&chunk_coord) = ActiveEntry::Empty { coord: chunk_coord };
                continue;
            }

            let new_chunk = Chunk::new_populated(&self.density_generator, &chunk_coord);
            used_budget += 1;

            if new_chunk.is_empty() {
                self.get_active_entry_mut(&chunk_coord).set_empty(chunk_coord);
            } else {
                let required_lod = select_lod(distance);
                self.get_active_entry_mut(&chunk_coord).set_loaded(chunk_coord, new_chunk, required_lod);

                // Invalidate neighbor meshes to enforce seamless boundary culling
                for neighbor_coord in self.get_neighbor_chunk_coords(&chunk_coord) {
                    self.get_active_entry_mut(&neighbor_coord).unset_mesh();
                }
            }
        }
    }

    used_budget
}

fn generate_pending_meshes(&mut self, player_position: &ChunkCoord, device: &wgpu::Device, budget: usize) -> usize {
    let mut used_budget = 0;

    for i in 0..self.offsets.len() {
        if used_budget >= budget {
            break;
        }

        let (offset, _) = self.offsets[i];
        let chunk_coord = player_position.add(&offset);

        if self.get_active_entry(&chunk_coord).needs_remesh() {
            let chunk_borders = self.get_chunk_borders(&chunk_coord);
            self.get_active_entry_mut(&chunk_coord).generate_and_upload_mesh(device, &chunk_borders);
            used_budget += 1;
        }
    }

    used_budget
}

What’s Next?

While the engine runs at a solid 60 FPS under normal navigation, there are several exciting paths for future improvements:

  • Greedy Meshing: Combining adjacent coplanar quad faces into single larger quads to drastically reduce vertex and index buffer sizes.
  • Web Workers via SharedArrayBuffer: Offloading noise sampling to background Web Workers using Rust’s wasm-bindgen-rayon to make chunk generation truly asynchronous without stuttering the render thread.
  • Persistent Browser Storage: Currently, all edits vanish on page refresh. The browser provides powerful mechanisms to persist modified worlds:
    • OPFS (Origin Private File System): A modern, sandboxed filesystem API that allows fast, synchronous binary I/O from within Web Workers. It would enable Minecraft-style binary region files (.mca) running purely on the client.
    • IndexedDB: An alternative key-value store to save compressed diffs of player-modified chunks without touching the pristine procedurally generated baseline.
  • Floating Origin (Camera-Relative Rendering): While chunk coordinates can logically span a 32-bit integer space (billions of blocks), GPU rendering relies on 32-bit floating-point numbers (f32). At extreme coordinate distances, IEEE 754 floats lose precision, manifesting as severe vertex jitter, camera stutter, and depth-fighting artifacts (the classic “Far Lands” effect).
    An architectural fix is a floating origin: instead of translating the camera through an ever-expanding global space, the camera is pinned statically at $(0, 0, 0)$. Chunks maintain their true logical world coordinates for generation and storage, but are indexed and pushed to the GPU transformed into local camera space.