Skip to content

Virtual geometry

Meshes with millions of triangles and scenes with a hundred thousand instances, drawn inside a frame budget: a cluster DAG built offline, GPU culling with continuous level of detail by projected error, indirect draws shaded by the ordinary material shaders, cascaded shadows from clusters, and page streaming under a GPU memory budget. Code: crates/sparklight-vgeo (builder, .vgeo format, CPU reference of the selection, page pool), crates/sparklight-3d/src/vgeo*.rs and shaders/vgeo_*.wgsl (runtime), crates/sparklight-assets (the virtual_geometry model setting), crates/sparklight-control/src/commands/vgeo.rs (vgeo.*).

What the numbers mean (read this before comparing)

Section titled “What the numbers mean (read this before comparing)”

Every number in this file was measured on this repository’s Apple Silicon Mac (Metal) by the commands next to it. The Mac was busy with other work while measuring (load average 40-75, 10-15 in the quiet runs the tables name), so CPU milliseconds are inflated and GPU milliseconds are contended; the triangle and cluster counts do not depend on the load. Rerun the example on your machine.

mesh --[sparklight-vgeo build]--> .vgeo --[Renderer3D::vgeo_add_asset]--> tables + page pool
instances (transform + materials) --[vgeo_add_instance]--> records
frame: cull (compute) -> visible clusters per material -> one indirect draw per material -> depth prepass / colour pass / shadow cascades

Offline: the cluster DAG (sparklight_vgeo::build_asset)

Section titled “Offline: the cluster DAG (sparklight_vgeo::build_asset)”
  1. Weld vertices, drop degenerate triangles, take the quantisation box and the UV range.
  2. Leaf clusters: at most 128 triangles and 128 vertices (meshopt build_meshlets_flex, spatially coherent, with a normal cone).
  3. Levels: the clusters of a level are partitioned into groups of about four (meshopt partition_clusters: shared vertices first, then proximity). A group’s triangles are merged and simplified to half with simplify_with_attributes_and_locks and LockBorder | ErrorAbsolute: the group’s border never moves, so a group never cracks against its neighbours, whatever levels they are drawn at. The result is split into new clusters (about two per group).
  4. Every group has a LOD sphere (the union of its members’ spheres) and an error (the simplification error, never under a member’s). A cluster stores its own sphere/error (the group that produced it, 0 for a leaf) and its parent’s (the group it is a member of; infinite for a root). Errors and spheres only grow up the DAG, so projected errors do too.
  5. Repeat until one cluster remains (or a level makes no progress: the rest are roots, counted in stalled_sections).
  6. BVH: one tree of up to 8 children per level under a short spine of the level roots. A node stores bounds, a LOD sphere that contains the parent spheres below it, and the largest parent error below it.
  7. Pages: whole groups, coarsest first, ~64 KiB; each page lists the pages it depends on (the coarser clusters that replace its clusters). Vertices are quantised per cluster (positions 3 x unorm16 in the section box, normals octahedral snorm16, UVs unorm16).

The cut rule (identical in sparklight_vgeo::select, the WGSL and the tests): a cluster is drawn iff it is resident and projected(parent error) > T and (projected(own error) <= T or its finer clusters are not resident, then it stands in for them and the finer page is requested). projected(e) = e * scale * pixel_scale / max(distance to the LOD sphere's surface, near) (perspective) or e * scale * pixel_scale (orthographic cascades). Every cluster of a group gets the same answer, so any cut is a closed surface.

The output is deterministic: the same input gives the same bytes regardless of the thread count (a test builds with 1, 3 and 4 threads).

Measured (cargo run -p sparklight-vgeo --release --example build_rock -- 8, 18 threads; re-run by task 012 on 2026-10-22 on the Apple M5 Max with a 1-minute load average of 38: counts and file size identical, “built in 1.32 s”):

source clusters (leaf / all) groups levels pages file build time
1 310 720 triangles 10 240 / 22 418 5 357 16 655 40.6 MiB (32.5 bytes per source triangle) 1.32 s

Little endian, 112-byte header (magic SLVG, version 1, counts, 16 stats words), then the section, group, cluster, link, node, page, page-dependency and page-data arrays each on a 16-byte boundary, then an FNV-1a 64 checksum. The cluster (80 B), node (48 B) and section (64 B) records are the GPU tables. The loader (VgeoAsset::from_bytes) rejects what it does not understand: E_VGEO_MAGIC, E_VGEO_VERSION, E_VGEO_LENGTH, E_VGEO_CHECKSUM, and E_VGEO_INVALID for any index out of range, a non-monotonic error, a BVH cycle, a triangle naming a vertex outside its cluster, sections that do not tile the clusters, page_words above MAX_PAGE_WORDS (4 Mi words, 16 MiB), a cycle in the page dependencies and a pinned page that depends on an unpinned one (the pool reserves slots for the pinned pages only). Every check of VgeoAsset::validate has a stable name (bad("<name>", ...), which is also the start of its error message) and crates/sparklight-vgeo/tests/loader.rs requires a planted case for every name that is rejected by exactly that check; a named check without a planted case fails every_check_has_a_planted_error (a dummy check added to format.rs made it fail). The page pool also walks dependencies iteratively and visits a page once, so even a cycle that bypassed the loader cannot overflow the stack (review 006 probe P0: a .vgeo whose pages 0 and 1 depended on each other, page 0 pinned, passed the loader and aborted the process in PagePool::register). Layout comments: crates/sparklight-vgeo/src/format.rs.

sparklight-assets: the model setting "virtual_geometry": true writes meshes/<n>.vgeo (one section per primitive, material slot = the primitive’s material index) and MeshAsset.vgeo; skinned meshes are skipped with a warning.

let asset = renderer.vgeo_add_asset(sparklight_vgeo::build_asset(&[primitive], &BuildConfig::default())?)?;
let material = renderer.create_material("scenery_sand")?; // opaque or alpha tested, any surface shader
let id = renderer.vgeo_add_instance(asset, &[material], Mat4::from_translation(p))?; // one material per material slot
renderer.vgeo_set_transform(id, new_transform); // previous transform is kept for motion vectors
renderer.settings.vgeo.pixel_error = 1.0;

Per frame (names are graph passes, GPU time is grouped by the prefix before : in FrameStats::pass_ms):

Pass Work
vgeo_shadow_cull one culling program per shadow cascade (orthographic, shadow_pixel_error, no occlusion)
prepass (existing) ordinary meshes into depth
vgeo_cull:0 seed one entry per instance section, then one dispatch per BVH level: node frustum test, LOD prune (largest parent error projected at the node’s LOD sphere), hierarchical Z test against last frame’s HZB with last frame’s matrices, cluster tests (frustum, normal cone, LOD/residency rule, HZB); visible clusters go to a list per material, what the HZB rejected goes to the retest queues
vgeo_prepass:0 one draw_indirect per material, 384 vertices x one instance per visible cluster; the vertex stage pulls vertices from the page pool
vgeo_hzb:cur hierarchical Z (min of the reversed depth) of the depth so far
vgeo_cull:1, vgeo_prepass:1 the retest queues against the new HZB; what survives is drawn
vgeo_hzb:prev HZB of the final depth, for the next frame
forward (existing) draws both lists again with the material’s colour stage (equal depth passes), or the debug colours
vgeo_feedback counters and page flags copied back (read one or two frames later)

Shading goes through the material shaders: the vgeo variant of a material’s composed WGSL (OBJECT_BATCH) gets shaders/vgeo_vertex.wgsl appended: vs_vgeo, vs_vgeo_shadow and vs_vgeo_velocity build the template’s own VsOut/VelocityOut with object_select, world_vertex and clip_position, so PBR, hooks (fog, lights, shadows, IBL), vertex_offset, alpha masks and every other surface shader work unchanged. Materials are opaque or alpha tested; at most 32 different (material, handedness) pairs are in use at once; a negative-determinant transform draws with the opposite winding.

Backends: gpu (compute + indirect draws) is used unless the renderer is on the downlevel path (Renderer3D::with_downlevel, no comparison samplers or cube arrays: the iOS simulator, GLES) or vgeo.backend says cpu. The cpu backend runs the same selection (sparklight_vgeo::select, also the test oracle of the GPU) on the CPU, uploads the visible lists and issues plain draw calls; hierarchical Z occlusion is GPU-only. The vertex stage needs 8 storage buffers per stage (checked at vgeo_add_asset: E_VGEO_UNSUPPORTED).

Streaming: the cluster, node and section tables and the page table are always resident; geometry lives in a pool of fixed slots (vgeo.pool_mib, capped by the device’s storage binding limit). The pages of the root clusters are pinned (E_VGEO_BUDGET when they do not fit). Everything that fits is loaded when the asset is added; otherwise a cluster whose finer page is missing is drawn instead (a stand-in, stand_ins) and the GPU flags the page; the CPU loads flagged pages parents first, at most max_page_loads_per_frame a frame (a pacing limit, requests carry on), evicting the least recently used page that is not pinned, not used this frame and not needed by a resident page. A request that cannot be served changes nothing and is counted and logged once per page (over_budget, over_budget_last_page).

Nothing is dropped silently: the traversal queues and the visible lists count what did not fit (queue_overflow, visible_overflow) and double for the next frame (vgeo_caps.rs, both backends: never past the settings cap or what one buffer of the device can hold: the work queue is queue_capacity * 32 bytes, a visible list segments * visible_capacity * 8 bytes, checked against the smaller of the device’s max_buffer_size and max_storage_buffer_binding_size; a start capacity that does not fit is refused with E_VGEO_DEVICE_LIMIT before anything is allocated. A buffer at its cap that still overflows logs one warning for the life of the renderer, naming the buffer and the cap that stopped it, and keeps counting the overflow (queue_overflow / visible_overflow cumulative, *_overflow_last the last frame, *_overflow_max the worst frame; the warning carries the count of the first frame at the cap). Renderer3D::vgeo_limit_buffers injects a lower limit for tests); unfinished_nodes counts entries still queued after the last level; tests assert all three are 0 after the capacities settle.

Settings (RenderSettings::vgeo, vgeo.settings)

Section titled “Settings (RenderSettings::vgeo, vgeo.settings)”
Field Default Meaning
enabled true draw registered instances
pixel_error 1.0 (tiers: low 2, medium 1.5, high 1, ultra 0.5) a cluster is fine enough when its error projects to at most this many pixels
shadow_pixel_error 2.0 the same for shadow cascades, in shadow map texels
occlusion true hierarchical Z, two passes (GPU backend)
shadows true clusters cast into the cascaded shadow maps
cone_culling true back-face culling of clusters by normal cone (off for double-sided materials and mirrored instances)
backend auto auto, gpu, cpu
pool_mib 512 geometry pool (the pool is only as large as the registered assets need, up to this)
max_page_loads_per_frame 256 pacing of page uploads
queue_capacity, visible_capacity 2^18, 2^18 starting capacities (double after an overflow)
debug off clusters (a colour per cluster), lod (a colour per DAG level, blue fine to red coarse), overdraw

Commands (docs/agent/commands.md, schemas in schemas/commands/vgeo.*)

Section titled “Commands (docs/agent/commands.md, schemas in schemas/commands/vgeo.*)”

vgeo.build (procedural source to a .vgeo; path must end in .vgeo), vgeo.info (validate and describe a file), vgeo.add, vgeo.scatter, vgeo.list, vgeo.clear, vgeo.settings, vgeo.stats (renders and reports VgeoStats), vgeo.capture (PNG, optional debug view). The source of vgeo.build, vgeo.add and vgeo.scatter is checked key by key: only the keys of its kind (rock: subdivisions 1-9, seed, amplitude 0-2, octaves 1-16; terrain: cells 2-2048, size, height, seed; file: path), integers where an integer is meant, numbers in range; a typo, a string where a number belongs or a value out of range is E_PARAMS_INVALID naming the key (the published schema is a strict object too), never the default rock. A capture (space3d.capture too) draws the placed instances with the scene. Error codes: E_VGEO_* in system.errors.

Proof (what the tests and the example measure)

Section titled “Proof (what the tests and the example measure)”

Run: cargo test -p sparklight-3d --test vgeo --test vgeo_goldens --test vgeo_stress (GPU), cargo test -p sparklight-vgeo (anywhere), cargo run -p sparklight-3d --release --example vgeo_bench -- [--scene hero|stress].

Claim Test Result
clusters draw the same picture as the mesh a_rock_is_drawn_by_clusters_and_matches_the_mesh_render, alpha_tested_and_vertex_animated_materials_draw_like_the_mesh_path, a_mirrored_instance_... mean difference 0.000 (rock), 0.12 (alpha tested dissolve_stone) of 255
LOD at 1 px stays close to full detail detail_falls_with_distance_... mean 0.112 at 3.2 m, 0.010 at 20 m, 0.000 at 200 m; triangles 33 184 -> 660
no cracks, no popping a_dolly_shows_no_cracks_and_no_popping (36 frames, 2.6 m to 140 m, a magenta background shows any hole) 0 holes inside the silhouette; error vs full detail at most 0.29; largest change of that error between frames 0.057
GPU selection = CPU selection the_cpu_backend_selects_what_the_gpu_selects identical cluster and triangle counts at 3 distances; downlevel renderer picture identical
cut of the DAG is a closed surface crates/sparklight-vgeo/tests/dag.rs no unmatched edge at 7 views, terrain borders kept
occlusion occlusion_culls_what_a_wall_hides_... 25 rocks behind a wall: 37 096 triangles -> 0, picture unchanged
shadows clusters_cast_cascaded_shadows 147 clusters in the cascades; 2 848 ground pixels darkened
streaming a_small_pool_streams_pages_and_reports_what_does_not_fit 16 of 41 pages resident, 99 stand-ins, 6 pages reported over budget, 215 clusters drawn (Mac run of task 012, 8 frames; the test asserts over_budget > 0, a named page and stand-ins)
100 000 instances a_hundred_thousand_instances_cull_inside_their_queues 128 M source triangles, 68 592 clusters / 5.5 M triangles drawn, no overflow

Goldens (goldens/3d/vgeo_*.png, 640x360): vgeo_terrain, vgeo_clusters, vgeo_lod, vgeo_overdraw, vgeo_sequence_0..3 (the dolly).

Provenance: the two tables below come from the original task and no raw run output was kept; task 012 did not re-run vgeo_bench (the Mac was at a load average above 35). Neither the counts nor the millisecond columns were re-measured by that task, so all of them are indicative only until the example is re-run on a quiet machine and its output recorded.

Hero scene (vgeo_bench --scene hero): 40 instances, 52.4 M source triangles, 1280x720.

tier pixel error clusters triangles shadow clusters GPU ms (min / median) cull ms prepass ms wall ms (min / median)
Low 2 5773 580702 9591 1.66 / 3.45 0.87 0.41 3.07 / 4.75
Medium 1.5 6607 669866 13067 1.46 / 3.99 0.66 0.41 2.52 / 3.96
High 1 8503 871644 24923 4.83 / 5.36 0.62 0.47 3.46 / 6.12
Ultra 0.5 13619 1434404 53317 6.11 / 6.31 0.50 0.51 6.18 / 6.61

Stress scene (vgeo_bench --scene stress): 100000 instances, 512.0 M source triangles, 1280x720.

tier pixel error clusters triangles shadow clusters GPU ms (min / median) cull ms prepass ms wall ms (min / median)
Low 2 69230 5563440 3166 5.22 / 5.90 0.39 1.89 5.52 / 6.03
Medium 1.5 70773 5717888 5191 5.61 / 5.86 0.39 2.14 6.96 / 9.04
High 1 75961 6243632 19278 7.03 / 7.08 0.39 2.16 9.84 / 10.43
Ultra 0.5 105427 9298160 62153 10.45 / 10.63 0.51 2.46 11.28 / 12.94

Three runs of each scene, run after run, on the shared Mac at 1-minute load averages of 66, 66 and 73 (other work was rendering and compiling); GPU ms is the profiled frame span (timestamp queries), wall ms the throughput of an unprofiled run, so both include contention. Cluster and triangle counts vary by under 0.1 percent between runs (which pages are resident and which clusters the previous frame’s depth hid differ by a frame). Same commands on a quiet machine will be lower.

  • Materials are opaque or alpha tested (blended ones are refused with E_VGEO_MATERIALS); skinned or morphing meshes are not virtualised.
  • Point and spot light shadow maps and reflection probe captures do not draw virtual geometry (cascaded sun shadows do).
  • Non-uniform scale: the largest axis scale bounds errors and radii (a flattened instance picks finer detail than it needs, never coarser).
  • No software rasteriser: triangles smaller than a pixel go through the hardware rasteriser, so a 1 px error budget costs fill rate.
  • Removing an asset frees its pool pages but keeps its (80 B per cluster) table entries until the renderer is dropped.
  • The GPU feedback (stats, page requests) arrives one or two frames late; vgeo_sync waits for it (tests, vgeo.stats).
  • Pipelines are built on first use, synchronously (the first frame with a new material hitches; warm_up does not cover them yet).