Loading WebGPU example...
More info
Back to the demo ↑

WebGPU notes   /   42

Nanite-Style WebGPU Rendering in Rust: Meshlets, LOD, and HZB

Preprocess one animated Jax model into four meshlet LODs and 30 geometry pages, choose an adapter-scaled population, stream GPU-requested pages, reject hidden work with a two-pass depth pyramid, and submit compacted static and skinned populations through indirect draws.

Meshlet LODs
4
Geometry pages
30
Startup tiers
272–8,857

Build a WebGPU teaching renderer inspired by Nanite

The previous glTF ray-tracing article traces an imported triangle scene in compute. This example returns to rasterization and asks a different scaling question: how can the renderer choose less geometry with distance, make page residency visible to the GPU, compact surviving work, and avoid one CPU draw call per object?

The introducing Nanite commit added the Rust example, the compute and render shaders, the HZB copy and reduction shaders, screenshots, meshlet paging, HZB recovery, and indirect submission. The adapter-scaled population update replaced one maximum default with four startup tiers. The collapsible-panel change and mobile joystick fix produced the current interaction path. Rendering and application setup use sib::render.

Nanite-style is deliberate wording. This is not Unreal Engine's Nanite implementation. It uses CPU-generated grid-quantized LODs, fixed-size meshlet pages, a CPU-managed residency cache, ordinary WGSL vertex shaders, and four WebGPU indirect commands. It has no mesh shaders, persistent cluster hierarchy, GPU decompression, disk page store, or production-quality simplifier.

Thousands of animated Jax rabbit characters rendered in colored WebGPU meshlet LOD bands with a Nanite diagnostics panel.
The stored maximum-population capture shows 8,281 LOD instances, 576 full-detail instances, and cyan, green, orange, and magenta LOD tinting. Its 53 fps on an unidentified browser adapter is one capture, not a benchmark. The current panel also reports startup tier, page residency, and HZB state, rows not visible in this earlier, shorter panel capture.

Load one skinned Jax model, animation, and texture

The shared skinned glTF loader first reads jax.gltf, then batches its external binary buffer and base-color image. Browser and native builds make the same three logical asset requests:

Scroll sideways to see all table columns.

Nanite-style example runtime assets
AssetStored bytesDecoded contentRole
jax.gltf68,32459 nodes, one mesh, one skin, one animationScene, materials, skeleton, and resource URLs
jax.bin1,957,75235,880 vertices and 35,880 indicesGeometry, 46-joint skin, and animation samples
jax_base_color.png13,0081024×1024 RGB decoded to 4,194,304 RGBA bytesOne sRGB base-color texture
Download total2,039,084Three filesLoaded before renderer initialization

The model has one triangle primitive with 11,960 triangles. Its 35,880 indices are sequential and every corner has its own source vertex. A 76-byte SkinnedVertex carries position, normal, UV, RGB color, four joint IDs stored as floats, and four normalized weights. The source lacks vertex color, so the loader supplies white. The material base-color factor is also white and metallic is zero; this example consumes only base color, not a full glTF PBR material.

The single one-second animation, Walking_1, contains 138 channels: translation, rotation, and scale for each of 46 joints. Rust samples those channels and constructs a 128-matrix palette. Animation time advances by at most 1/15 second per update, limiting large jumps after a stalled frame.

The PNG is uploaded as one-mip Rgba8UnormSrgb with repeat addressing and linear minification and magnification. There is no generated mip chain, normal map, metallic-roughness texture, or separate material per meshlet.

Choose population and memory capacity from adapter heuristics

Initialization classifies the adapter before allocating its largest buffers. Mobile GPU-name markers or a mobile browser user agent choose the mobile tier. Otherwise, any of three constrained limits selects conservative: storage bindings below 128 MiB, fewer than 8,192 compute workgroups in one dimension, or a maximum 2D texture dimension below 8,192. Device type and name then distinguish maximum, balanced, and conservative.

Scroll sideways to see all table columns.

Adapter-scaled startup population tiers
TierLOD JaxFull-detail JaxTotalTypical selector
Maximum8,281 (91×91)576 (48×12)8,857Discrete GPU or recognized high-end integrated/other adapter
Balanced4,096 (64×64)192 (24×8)4,288Other integrated GPU or generic BrowserWebGPU adapter
Conservative1,024 (32×32)48 (12×4)1,072CPU, virtual GPU, constrained limits, or unrecognized adapter
Mobile256 (16×16)16 (8×2)272Mobile GPU marker or mobile browser user agent

A mobile surface above two million pixels reduces those counts further with a square-root resolution scale clamped between 0.5 and 1.0. This is a startup heuristic rather than measured adaptive quality: it does not benchmark the adapter, monitor frame time, or change capacity after resize.

Both groups form centered grids. Full-detail characters begin at Z = 7; the LOD grid starts behind the final full-detail row. Model scale is 3.0 for LOD instances and 3.24 for full detail. Changing either population slider reconstructs the active grids, keeps zero-filled inactive slots so the skinned offset remains stable, rewrites the entire tier-sized instance buffer, and invalidates the previous depth hierarchy.

Quantize four LODs and partition them into meshlets

Startup preprocessing calculates the source bound, whose center is approximately (0.0265, 1.1163, 0.0077), radius is 1.5806, and diagonal is 3.1612. LOD 0 preserves the source index stream. LODs 1 through 3 quantize positions into 64, 28, and 12 cells per model-bounds axis. Every cell keeps its first source vertex; triangles that collapse or repeat the same sorted index triple are removed.

The remaining triangle centroids receive 30-bit Morton codes, and sorting those codes improves spatial locality before fixed clustering. Each meshlet holds at most 96 triangles, or 288 indices. Eight consecutive meshlets form one page. This produces exact, deterministic statistics for the current Jax asset:

Scroll sideways to see all table columns.

CPU-generated Jax meshlet LOD hierarchy
LODQuantizationTrianglesMeshletsPagesEstimated object-space error
0Original indices11,960125160
164 cells/axis6,4746890.04939
228 cells/axis2,4242640.11290
312 cells/axis386510.26343
Total storedFour independent index sets21,24422430Diagonal divided by grid count

One 32-byte MeshletData record stores a bind-pose bounding sphere plus first index, index count, LOD level, and page ID. A page deduplicates vertices shared by its eight meshlets. The largest page still reaches 2,304 vertices and 2,304 indices, so every physical cache slot reserves those capacities.

let own_error = projected_error(
    scene.lod_errors[level], instance.position_scale.w, distance_to_center,
);
let parent_error = projected_error(
    scene.lod_errors[level + 1u], instance.position_scale.w, distance_to_center,
);
if (parent_error > scene.screen.z && own_error <= scene.screen.z) {
    return level;
}

The default threshold is 1.5 pixels. Projection uses surface height, a 56° vertical field of view, instance scale, and distance to the nearest side of the model sphere. A larger allowed error accepts coarser levels sooner. There is no temporal hysteresis or blend, so a moving camera can switch levels at a hard boundary.

Turn GPU page requests into CPU cache uploads

LOD 3's one page is pinned and uploaded during initialization, guaranteeing a resident fallback. The page table uses zero for missing and physical slot plus one for resident. During select_lod, each visible static instance requests every page belonging to its desired level. If any is missing, the shader requests the next coarser level until it finds one whose complete page set is resident.

for (var level = desired_level; level < 4u; level = level + 1u) {
    var resident = true;
    for (var local_page = 0u; local_page < page_count; local_page = local_page + 1u) {
        if (request_page(page_start + local_page) == 0u) {
            resident = false;
        }
    }
    if (resident) { return level; }
}

Thirty page IDs fit in one 32-bit request word. The command encoder copies that word to a four-byte map-read buffer and clears the GPU request bits. A later update maps the result asynchronously, queues missing pages, and uploads at most four queued pages per feedback cycle. The cache chooses an empty slot first, then the least-recently requested non-pinned slot; page-table updates use four-byte queue writes.

This is a residency demonstration, not network or disk streaming. All four LODs are generated from the already downloaded Jax mesh and retained as CPU vectors. The requested slot count is 32 but clamps to the current asset's 30 total pages. Consequently all pages fit after warm-up and the LRU eviction path should remain at zero for this asset. A larger virtual set or smaller requested cache would be needed to demonstrate sustained cache pressure.

Select LODs, compact visible records, and draw indirectly

The first 64-thread kernel assigns one LOD per active LOD instance after a whole-model frustum test and residency fallback. The static culling dispatch then spans two workgroups in X—enough for the maximum 125 meshlets—and one Y row per active instance. Threads beyond the selected level's meshlet count return. Full-detail instances use a separate one-dimensional culling dispatch.

Survivors atomically allocate entries in a 48-byte VisibleDraw array. Each record stores the instance transform and either a meshlet/page reference or the source full-detail instance index. The atomic counts live directly inside four indirect argument records:

Scroll sideways to see all table columns.

GPU-counted indirect commands per HZB-enabled steady frame
CommandAPIFixed primitive countGPU-written valueVisible-array region
Main LOD meshletsdraw_indirect288 verticesMeshlet instance countMain static
Main full detaildraw_indexed_indirect35,880 indicesJax instance countMain skinned
Recovered LOD meshletsdraw_indirect288 verticesRecovered meshlet countRecovery static
Recovered full detaildraw_indexed_indirect35,880 indicesRecovered Jax countRecovery skinned

The static vertex shader uses vertex_index to read the selected page's index and vertex storage. Short meshlets still receive the fixed 288 vertex invocations; excess lanes return an off-screen position. One indirect call can therefore draw all visible static meshlets across every LOD without a vertex buffer.

Main and recovery records occupy separate halves of the visible array. Separate shader entry points add the recovery offset because a nonzero indirect first_instance would require the optional INDIRECT_FIRST_INSTANCE feature, unavailable on some WebGPU targets. The example requests no such feature.

Use previous-frame HZB, then recover against current depth

The first frame renders frustum survivors because no depth history is valid. After the main render pass, compute copies the full-size Depth32Float image into mip 0 of an R32Float hierarchy. Each following mip takes the maximum of a clamped 2×2 footprint. At 1280×720, the pyramid has 11 levels and 1,228,763 texels, or 4,915,052 logical bytes.

On later frames, the main cull projects each candidate sphere with the previous rendered view-projection matrix. It chooses a mip from the projected diameter, samples four footprint corners, and rejects a candidate only when its nearest estimated depth lies beyond the farthest sampled occluder plus 0.0015.

A previous-frame result can be stale after camera motion. Candidates rejected by history receive a scratch flag rather than disappearing permanently. Once the current main depth pyramid exists, a recovery compute pass retests only those flags with the current view-projection matrix. Newly visible records enter the second half of the compact buffer and render in a color-and-depth load pass.

if (hzb_occluded(
    sphere_center, sphere_radius, scene.projection * scene.view,
)) {
    return;
}
let slot = atomicAdd(&draw_state.values[10], 1u);
visible_draws[scene.params2.w + slot] = recovered_draw;

The HZB is built before recovery, so it does not include geometry added by the recovery pass. Disabling the HZB checkbox stops history rejection and removes recovery, but the current implementation still builds all HZB mips every frame. Freeze culling frustum freezes the frustum planes and LOD camera; HZB projection continues to follow the rendered camera.

Share one GPU joint palette across every Jax

Rust samples the animation and uploads 128 matrices, or 8,192 bytes, every frame. Both render paths blend four matrices in the vertex stage. Full-detail Jax uses the original 76-byte vertex buffer and conventional indexed drawing. LOD pages use 96-byte storage vertices containing the same position, normal, UV, color, joint IDs, and weights padded to six vec4 values.

Every character reads the same palette, so thousands of instances walk in sync. There is no per-instance animation time, palette, pose blend, or motion vector. Disabling Animated mode stops animation advancement and skips skinning in both vertex paths, revealing the bind-pose T-pose. When animation is enabled, static meshlet bind-pose spheres could miss deformed limbs, so culling uses a conservative whole-model sphere scaled by 1.15 for every meshlet. Per-meshlet frustum and HZB spheres are used only in T-pose mode.

The fragment shader samples the sRGB base color, multiplies it by material and warm vertex tints, and adds ambient, Lambert diffuse, and a small exponent-36 Blinn highlight from a point at (28, 42, 22). When LOD colors are enabled, static instances mix 72% toward cyan, green, orange, or magenta; full-detail instances retain the textured shade. Pipelines use one sample, no face culling, no blending, and Depth32Float writes with LessEqual.

Account for tier-scaled buffers and the steady frame graph

The largest allocations scale with the selected startup tier. The visible buffer reserves the maximum 125 meshlets for every LOD instance, adds one record per full-detail instance, then doubles that capacity for main and recovery. The draw-state buffer reserves matching candidate flags plus page tables and request bits.

Scroll sideways to see all table columns.

Logical fixed GPU payload by startup tier, before depth, HZB, surface, staging, joystick, and egui
TierInstance bufferTwo-pass visible bufferDraw state + scratchAll listed fixed resources
Maximum283,424 B99,427,296 B4,176,132 B117,879,432 B (112.42 MiB)
Balanced137,216 B49,170,432 B2,065,356 B65,365,584 B (62.34 MiB)
Conservative34,304 B12,292,608 B516,492 B26,835,984 B (25.59 MiB)
Mobile8,704 B3,073,536 B129,292 B17,204,112 B (16.41 MiB)

The fixed totals also include a 6,912,000-byte 30-slot physical page cache, 2,870,400 bytes of full-detail Jax geometry, a 4,194,304-byte decoded base-color texture, 7,168 bytes of meshlet metadata, the 8,192-byte palette, 512-byte scene uniform, and four-byte page-feedback buffer. At 1280×720, depth and HZB add another 8,601,452 logical bytes. The maximum tier reaches about 120.62 MiB before presentation, alignment, staging, joystick, and egui resources.

Scroll sideways to see all table columns.

Default steady-frame pass order after HZB history becomes valid
OrderStagePassesSubmitted work
1Main selection and culling1 computeLOD selection, static meshlets, full-detail instances
2Main geometry1 render2 indirect draws
3Depth hierarchy11 compute at 1280×720Depth copy + 10 max reductions
4Current-frame recovery1 compute + 1 render2 recovery dispatches + 2 indirect draws
5Page feedbackCopy + clear commandsOne 32-bit request word when readback is available
6Interaction overlays2 renderJoystick, then egui

That is 13 compute passes and four render passes in a typical HZB-enabled steady frame at screenshot size. The first frame skips recovery. Before culling, a staging belt uploads 512 bytes of scene data, 8,192 palette bytes, and 80 bytes of indirect-command headers and zeroed counters: 8,784 bytes per frame before variable page, instance, joystick, or egui writes.

The panel's visibility counts are CPU calculations, not GPU counter readback. They repeat whole-instance frustum and desired-LOD selection, omit HZB rejection and page-residency fallback, and report a meshlet upper bound. They are useful diagnostics but do not state the exact indirect counts or rendered triangles.

Tune LOD, culling, population, animation, and camera

The 360 px non-resizable, collapsible egui window refreshes at most every 0.25 seconds unless input or resize makes it dirty. Defaults and behavior are:

Scroll sideways to see all table columns.

Nanite-style renderer controls
ControlDefaultRange or effect
LOD JaxSelected tier capacity0 to tier capacity; rebuilds both grids
Full-detail JaxSelected tier capacity0 to tier capacity; rebuilds both grids
LOD error1.5 px0.35–8.0, logarithmic; larger means coarser
Animate cameraOffOrbit at 0.055 rad/s and disable manual movement
Animated modeOnShared walking palette; off shows every Jax in T-pose
Freeze culling frustumOffFreeze frustum planes and LOD camera, not HZB projection
Two-pass HZB occlusionOnPrevious-frame rejection plus current-frame recovery
Show LOD colorsOnTint only the meshlet LOD population

With camera animation off, W/A/S/D move on the XZ plane and arrow keys look. Pointer or touch input creates two virtual sticks: the left half moves and the right half looks. Movement speed is 72 world units per second, look speed is 1.6 radians per second, and input delta is capped at 1/15 second. egui consumes its own input and clears any active pointer stick so a slider drag cannot continue moving the camera.

The initial manual camera inherits the orbit view. Automated orbit uses radius 98 around Z = −76 at Y = 18 and looks toward (0, 1.1, −76). Projection uses a 56° field of view, 0.1 near plane, 1,080 far plane, and a small upward clip-space shift. Resize recreates depth and every HZB view/bind group, resets history, and updates projection, but it does not choose a new population tier.

Run the example and understand its limits

Run the native example from the repository root:

cargo run --example nanite

Build the WebAssembly target and serve the generated page:

scripts/build-wasm.sh --release nanite
cargo run --bin serve

Open http://127.0.0.1:8080/nanite/ in a browser with WebGPU support. Startup downloads about 1.94 MiB across the glTF, binary, and PNG, then performs LOD, Morton sorting, meshlet, and page construction on the CPU before allocating tier-scaled GPU buffers.

The example demonstrates the architecture without claiming production parity. LODs are independent grid-collapse results rather than a crack-free cluster hierarchy; error is estimated from bounding-box diagonal rather than measured deviation; pages are uncompressed CPU memory; all 30 pages fit the current 30-slot cache; and default animation falls back to whole-model bounds instead of per-meshlet culling. It has no meshlet cone culling, material bins, instance animation phases, dynamic resolution, timestamp queries, exact GPU statistics, occluder pass, temporal LOD stability, or page-store I/O.

The maximum-tier selector also treats a per-dimension compute limit of exactly 8,192 as unconstrained even though the 8,281-row static dispatch needs at least 8,281; initialization catches that mismatch and returns an error instead of falling back. HZB generation continues when occlusion is disabled, and maximum tier reserves roughly 99.4 MB for the two-pass visible array alone. Useful extensions include a cache smaller than the virtual page set, offline compressed pages, measured-error simplification, per-instance palettes, GPU-readback diagnostics, animated cluster bounds, and frame-time-driven population selection.