wgpu: spatial instance bucketing for streaming (env-gated prototype)

WGPU_SPATIAL_BUCKETS=1 swaps the applyCachedModelStreaming planner from
mesh-keyed Morton+greedy to octree-style instance bucketing. Default
behaviour unchanged (env var unset → mesh-keyed planner runs).

Phase 1 of #55 / #56. The mesh-keyed planner produces chunks whose
AABBs are the union of all instances of the chunk's meshes — for
heavily-deduplicated IFC meshes (a "standard floor tile" used 800
times across a federation) the mesh's "centroid" is a mean of scattered
instance positions and the chunk's AABB ends up spanning the entire
model. Symptom: chunk-level frustum cull rarely fires (AABB always
intersects view), and the screen-area priority metric under-rates
big-AABB chunks because their corners straddle the near plane. Visible
objects pop in/out as the camera tilts, even though they're fully on
screen.

The spatial planner bucketises INSTANCES directly. Each leaf bucket
contains its instance list + the unique mesh data those instances
reference. A mesh whose instances scatter into multiple buckets gets
its vertex/index data uploaded into multiple pool slices — duplication
is the cost for tight bucket AABBs. For IFC this is acceptable:
heavily-shared meshes tend to be small (fittings, fasteners), so
per-bucket duplication adds tens-of-MB not GB.

Octree implementation (planSpatialChunks):
  - work-stack subdivision: for each (instance subset, AABB), split into
    8 octants around centre and recurse
  - stop conditions: bucket fits WGPU_CHUNK_VERTEX_BYTES_LIMIT for
    union vertex bytes AND ≤ spatial_max_instances_ instances; OR single
    instance left; OR every instance falls into the same octant
    (pathological — emit as leaf rather than infinite recurse)
  - spatial_max_instances_ default 5000, overridable via
    WGPU_SPATIAL_BUCKET_MAX_INSTS env var so the prototype can be
    swept without rebuilding

Data-model adjustment beyond what dc2927997 prepared:
  - Per-chunk per-mesh chunk-local offset table (chunk_mesh_offsets)
    built during the chunk-construction loop. The mesh-keyed per-mesh
    global arrays (mesh_chunk_idx etc.) still get populated for
    legacy reads, but under spatial bucketing they're overwritten when
    the same mesh appears in multiple chunks — harmless because cull
    reads the per-instance arrays exclusively (per dc2927997).
  - Post-construction, per-instance arrays are populated from
    chunk_mesh_offsets via (instance_to_chunk[i], inst.mesh_id) lookup.
    Mesh-keyed planner derives identical values to before
    (pixel-identical); spatial planner now writes the correct
    per-bucket offsets even when the mesh appears in multiple chunks.

basic.ifc parity on all three paths confirmed (non-streaming
mesh-keyed, streaming mesh-keyed, streaming spatial all produce 0
pixel diff vs the reference). Spatial planner produced 1 bucket on
basic.ifc (3 instances, well under thresholds) as expected.

Non-streaming applyCachedModel left unchanged — the prototype targets
the streaming path which is where the federation-scale missing-objects
issue lives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-29 16:19:56 +10:00
parent 1f8f6bffc4
commit 126d2d4c06
2 changed files with 248 additions and 45 deletions
+27
View File
@@ -142,6 +142,19 @@ private:
float chunkScreenAreaPx(const WgpuModelGpuData::Chunk& c,
const QMatrix4x4& vp_mat) const;
// Octree-style spatial planner. Produces (a) per-bucket mesh_ids — a
// mesh may appear in multiple buckets if its instances scattered, and
// (b) per-instance bucket assignment. Each bucket carries its own
// mesh data in its chunk pool slice (duplicated when shared). Stop
// condition: bucket's union vertex bytes ≤ WGPU_CHUNK_VERTEX_BYTES_LIMIT
// and instance count ≤ spatial_max_instances_. See task #55.
struct SpatialPlan {
std::vector<std::vector<uint32_t>> chunk_mesh_ids;
std::vector<uint32_t> instance_to_chunk;
};
SpatialPlan planSpatialChunks(const std::vector<InstanceCpu>& instances,
const std::vector<MeshInfo>& meshes) const;
public:
// Queue a one-shot framebuffer capture: the next rendered frame is
@@ -505,6 +518,20 @@ private:
// and is per-model the right granularity?).
bool cull_threads_enabled_ = true;
// WGPU_SPATIAL_BUCKETS=1 swaps the streaming chunk planner from the
// mesh-keyed Morton+greedy algorithm to an octree-style spatial
// subdivision of INSTANCES. Same mesh may appear in multiple buckets
// (data duplicated) when its instances scatter — this is the central
// trade for tight per-chunk AABBs that actually match what cull and
// priority code want. See task #55.
bool spatial_buckets_enabled_ = false;
// Stop-subdividing thresholds for the octree planner. A cell becomes a
// leaf bucket when (a) the union vertex bytes of meshes its instances
// reference fits the chunk budget, AND (b) instance count is below the
// cap. Tunable via WGPU_SPATIAL_BUCKET_MAX_INSTS env var so we can
// sweep without rebuild during the prototype phase.
uint32_t spatial_max_instances_ = 5000;
public:
// Master switch for HiZ occlusion. Set false to skip the depth resolve
// + readback + cull test entirely (matches IFC_NO_HIZ in the GL backend).