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.
Pipeline
Section titled “Pipeline”mesh --[sparklight-vgeo build]--> .vgeo --[Renderer3D::vgeo_add_asset]--> tables + page poolinstances (transform + materials) --[vgeo_add_instance]--> recordsframe: cull (compute) -> visible clusters per material -> one indirect draw per material -> depth prepass / colour pass / shadow cascadesOffline: the cluster DAG (sparklight_vgeo::build_asset)
Section titled “Offline: the cluster DAG (sparklight_vgeo::build_asset)”- Weld vertices, drop degenerate triangles, take the quantisation box and the UV range.
- Leaf clusters: at most 128 triangles and 128 vertices (meshopt
build_meshlets_flex, spatially coherent, with a normal cone). - 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 withsimplify_with_attributes_and_locksandLockBorder | 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). - 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.
- Repeat until one cluster remains (or a level makes no progress: the rest are roots, counted in
stalled_sections). - 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.
- 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 |
The .vgeo format
Section titled “The .vgeo format”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.
Runtime
Section titled “Runtime”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 shaderlet id = renderer.vgeo_add_instance(asset, &[material], Mat4::from_translation(p))?; // one material per material slotrenderer.vgeo_set_transform(id, new_transform); // previous transform is kept for motion vectorsrenderer.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).
Frame time per tier
Section titled “Frame time per tier”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.
Limits (explicit, not hidden)
Section titled “Limits (explicit, not hidden)”- 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_syncwaits for it (tests,vgeo.stats). - Pipelines are built on first use, synchronously (the first frame with a new material hitches;
warm_updoes not cover them yet).