ifcviewer: shrink vertex format from 16 to 12 bytes (oct i8x2 normals)

Replace i16x2 octahedral normals with i8x2, filling the 2-byte padding
after position and saving 4 bytes per vertex. int8 gives ~1.4 deg
worst-case angular error — invisible for BIM geometry which is
overwhelmingly axis-aligned. 25% VBO reduction; sidecar files shrink
~15% overall (5.4 GB -> 4.6 GB on a 111-model test scene). Bumps
sidecar format to v7.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-20 12:26:29 +10:00
parent dbe68b48f2
commit 3015f758ba
4 changed files with 85 additions and 155 deletions
+9 -7
View File
@@ -25,16 +25,18 @@
#include <vector> #include <vector>
// Per-vertex layout for instanced meshes, stored in local coordinates, // Per-vertex layout for instanced meshes, stored in local coordinates,
// quantized against each mesh's local AABB. 16 bytes per vertex: // quantized against each mesh's local AABB. 12 bytes per vertex:
// offset 0 pos 3 x uint16 normalized -> [0,1]; dequant to // offset 0 pos 3 x uint16 normalized -> [0,1]; dequant to
// mix(mesh.aabb_min, mesh.aabb_max, t) // mix(mesh.aabb_min, mesh.aabb_max, t)
// offset 6 _pad 2 bytes // offset 6 normal 2 x int8 normalized -> [-1,1]; octahedral-decoded
// offset 8 normal 2 x int16 normalized -> [-1,1]; octahedral-decoded // offset 8 color 4 x uint8 normalized -> [0,1]
// offset 12 color 4 x uint8 normalized -> [0,1] //
// int8 normals give ~1.4° worst-case angular error — invisible for BIM
// geometry which is overwhelmingly axis-aligned (walls, floors, slabs).
// //
// Quantization basis is per mesh, stored in the MeshGpu SSBO bound at // Quantization basis is per mesh, stored in the MeshGpu SSBO bound at
// binding=2. The vertex shader looks up its basis via the instance's mesh_id. // binding=2. The vertex shader looks up its basis via the instance's mesh_id.
static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 16; static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 12;
// Streamer-side intermediate format: 7 floats per vertex (pos3 + normal3 + // Streamer-side intermediate format: 7 floats per vertex (pos3 + normal3 +
// color-as-float). GeometryStreamer writes this into MeshChunk.vertices; // color-as-float). GeometryStreamer writes this into MeshChunk.vertices;
@@ -43,8 +45,8 @@ static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 16;
static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7; static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7;
static constexpr int INSTANCED_VERTEX_POS_OFFSET = 0; static constexpr int INSTANCED_VERTEX_POS_OFFSET = 0;
static constexpr int INSTANCED_VERTEX_NORMAL_OFFSET = 8; static constexpr int INSTANCED_VERTEX_NORMAL_OFFSET = 6;
static constexpr int INSTANCED_VERTEX_COLOR_OFFSET = 12; static constexpr int INSTANCED_VERTEX_COLOR_OFFSET = 8;
// Per-mesh quantization basis, uploaded to a std430 SSBO. Two vec4s so // Per-mesh quantization basis, uploaded to a std430 SSBO. Two vec4s so
// std430 layout is trivial (no alignment surprises). w components unused. // std430 layout is trivial (no alignment surprises). w components unused.
+62 -136
View File
@@ -46,12 +46,17 @@ engine with a Qt6 interface and OpenGL 4.5 rendering.
- **Per-model GPU buffers**: each loaded model gets its own - **Per-model GPU buffers**: each loaded model gets its own
VAO/VBO/EBO/instance-SSBO/visible-SSBO/indirect-buffer. No cross-model VAO/VBO/EBO/instance-SSBO/visible-SSBO/indirect-buffer. No cross-model
growth copies. Removing a model frees its GPU memory immediately. growth copies. Removing a model frees its GPU memory immediately.
- **Quantized local-coordinate vertex format (16 B):** position as - **Quantized local-coordinate vertex format (12 B):** position as
`u16x3` normalised against each mesh's local AABB, octahedral-encoded `u16x3` normalised against each mesh's local AABB, octahedral-encoded
normal as `i16x2`, packed RGBA8 colour. Dequantisation basis is per normal as `i8x2`, packed RGBA8 colour. The normal fills what was
mesh, uploaded once in a `MeshGpu` SSBO at binding 2. The per-instance previously 2 bytes of padding, and shrinks from `i16x2` to `i8x2`
transform is applied in the vertex shader. No world-baked vertex data. int8 gives ~1.4° worst-case angular error, invisible for BIM geometry
~43 % smaller VBO and sidecar than the previous 28 B float layout. which is overwhelmingly axis-aligned (walls, floors, slabs encode
exactly). Dequantisation basis is per mesh, uploaded once in a
`MeshGpu` SSBO at binding 2. The per-instance transform is applied in
the vertex shader. No world-baked vertex data. 12/28 = 57 % smaller
VBO than the original 28 B float layout (sidecar files shrink ~15 %
overall since indices/instances/metadata are unchanged).
- **Multi-draw indirect:** every frame the CPU builds a flat list of visible - **Multi-draw indirect:** every frame the CPU builds a flat list of visible
instance indices and one `DrawElementsIndirectCommand` per non-empty mesh, instance indices and one `DrawElementsIndirectCommand` per non-empty mesh,
then issues a single `glMultiDrawElementsIndirect` per model. 50k visible then issues a single `glMultiDrawElementsIndirect` per model. 50k visible
@@ -111,7 +116,7 @@ engine with a Qt6 interface and OpenGL 4.5 rendering.
| `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records | | `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records |
| `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs | | `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs |
| `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) | | `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) |
| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v6) sidecar read/write | | `SidecarCache.h/cpp` | Raw binary `.ifcview` (v7) sidecar read/write |
| `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) | | `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) |
| `SettingsWindow.h/cpp` | Settings dialog | | `SettingsWindow.h/cpp` | Settings dialog |
| `CMakeLists.txt` | Build configuration | | `CMakeLists.txt` | Build configuration |
@@ -288,7 +293,7 @@ while stack not empty:
Depth 64 is enough for billions of items on any balanced tree. The stack Depth 64 is enough for billions of items on any balanced tree. The stack
is on the C++ stack, zero per-frame allocation. is on the C++ stack, zero per-frame allocation.
#### Sidecar format (`.ifcview`, v6) #### Sidecar format (`.ifcview`, v7)
Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing. Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing.
Stores everything needed to skip the `IfcGeom::Iterator` pass: Stores everything needed to skip the `IfcGeom::Iterator` pass:
@@ -296,7 +301,7 @@ Stores everything needed to skip the `IfcGeom::Iterator` pass:
``` ```
SidecarHeader (magic "IFVW", version, endian, ...) SidecarHeader (magic "IFVW", version, endian, ...)
uint64_t source_file_size uint64_t source_file_size
uint32_t + uint8_t[] vertex data (16 B/vert quantized; per-mesh basis in MeshInfo) uint32_t + uint8_t[] vertex data (12 B/vert quantized; per-mesh basis in MeshInfo)
uint32_t + uint32_t[] index data (mesh-local) uint32_t + uint32_t[] index data (mesh-local)
uint32_t + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice) uint32_t + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice)
uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids) uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids)
@@ -324,7 +329,7 @@ Per-model state on the GPU:
| Buffer | Contents | Lifetime | | Buffer | Contents | Lifetime |
|--------|----------|----------| |--------|----------|----------|
| `VBO` | Quantized local-coord vertex data (16 B/vert: u16x3 pos, oct i16x2 normal, RGBA8). One range per unique representation. | Grow-on-demand during streaming; static after finalize. | | `VBO` | Quantized local-coord vertex data (12 B/vert: u16x3 pos, oct i8x2 normal, RGBA8). One range per unique representation. | Grow-on-demand during streaming; static after finalize. |
| `MeshGpu SSBO` (binding 2) | Per-mesh dequant basis (`vec4 aabb_min`, `vec4 aabb_max`). | Grow-on-demand; static after finalize. | | `MeshGpu SSBO` (binding 2) | Per-mesh dequant basis (`vec4 aabb_min`, `vec4 aabb_max`). | Grow-on-demand; static after finalize. |
| `EBO` | Mesh-local uint32 indices. One range per unique representation. | Same. | | `EBO` | Mesh-local uint32 indices. One range per unique representation. | Same. |
| `SSBO` (binding 0) | `InstanceGpu[]` (80 B each: mat4 transform, object_id, color_override, pad). | Appended during streaming, static after finalize. | | `SSBO` (binding 0) | `InstanceGpu[]` (80 B each: mat4 transform, object_id, color_override, pad). | Appended during streaming, static after finalize. |
@@ -338,7 +343,7 @@ struct DrawElementsIndirectCommand {
uint32_t count; // mesh.index_count uint32_t count; // mesh.index_count
uint32_t instanceCount; // visible-list length for this mesh uint32_t instanceCount; // visible-list length for this mesh
uint32_t firstIndex; // mesh.ebo_byte_offset / 4 uint32_t firstIndex; // mesh.ebo_byte_offset / 4
uint32_t baseVertex; // mesh.vbo_byte_offset / 16 uint32_t baseVertex; // mesh.vbo_byte_offset / 12
uint32_t baseInstance; // offset into the flat visible-index array uint32_t baseInstance; // offset into the flat visible-index array
}; };
``` ```
@@ -742,143 +747,69 @@ The stats line now reports `cull[wall X | work: clr Y trv Z emt W upl U]`:
where CPU cycles went. `IFC_CULL_THREADS=0` forces single-threaded mode where CPU cycles went. `IFC_CULL_THREADS=0` forces single-threaded mode
for comparison. for comparison.
#### 3E. GPU compute culling — experiments, results, and current state #### 3E. GPU compute culling — experiments and removal
##### What we tried ##### What we tried
**Attempt 1: Full GPU-driven rendering (reverted).** Five commits Five iterations of GPU compute culling were explored on a 1.06 M-instance
(`4fe32b54`..`d5b7b87b`) moved the entire cull-to-draw pipeline onto / 111-model scene (GTX 1650):
the GPU: a compute shader performed frustum + contribution + HiZ
culling, selected LOD0/LOD1, handled fwd/rev winding bucketing, wrote
indirect draw commands via `glMultiDrawElementsIndirectCount`, and
drove rendering without CPU readback. This was architecturally clean
but complex — the GPU built per-model indirect command buffers with
atomic counters, prefix sums, and per-bucket compaction. It worked
correctly but introduced code smells (extension loaders for
`glMultiDrawElementsIndirectCount` not exposed by Qt6's
`QOpenGLFunctions_4_5_Core`, ad-hoc GPU readbacks for validation).
All five commits were reverted as a single block to keep the codebase
clean while preserving the AABB SSBO upload (`b2044737`) and the
frustum-only validation shader (`b17860fc`).
**Attempt 2: GPU frustum-only validation shader.** A minimal compute 1. **Full GPU-driven rendering** — compute shader doing frustum +
shader (64 threads/workgroup) testing each instance's AABB against 6 contribution + HiZ + LOD + winding + indirect command building via
frustum planes. Used as a measurement baseline — no contribution, `glMultiDrawElementsIndirectCount`. Worked but introduced code smells
HiZ, LOD, or winding. Results on a 1.06 M-instance / 111-model scene (extension loaders, ad-hoc readbacks). Reverted.
(GTX 1650):
| Metric | GPU frustum-only | CPU BVH (parallel) | 2. **GPU frustum-only validation** — minimal compute shader (64
|--------|------------------|--------------------| threads/workgroup), 0.82 ms for 1.06 M instances vs 1015 ms CPU.
| Cull time | **0.82 ms** (GPU timestamp) | 9.615.2 ms wall | Proved GPU brute-force beats CPU BVH for raw AABB-vs-frustum.
| Survivors | 279 k (frustum only) | 130 k (frustum + contribution + HiZ) |
The GPU brute-force scan of 1.06 M instances in 0.82 ms was 1218× 3. **Hybrid with synchronous readback** — added contribution culling,
faster than the CPU BVH walk despite testing every instance. read survivors back with `glGetNamedBufferSubData`. The 47 ms
pipeline stall negated all GPU savings.
**Attempt 3: Hybrid GPU cull with synchronous readback.** Added 4. **Async one-frame-late readback** — persistent-mapped buffer +
contribution culling to the GPU shader (bounding-sphere screen-space fence. Zero stalls, ~5.5 ms total vs ~5.5 ms CPU-only. Matched
radius test), then read back the compact survivor list to the CPU with but didn't beat.
`glGetNamedBufferSubData`. CPU retains HiZ, LOD selection, winding
bucketing, indirect command building, and all GL draw calls.
| Phase | Time | 5. **Dirty-mesh tracking** — reduced emit from O(total meshes) to
|-------|------| O(dirty meshes). Helped the consume phase but didn't change the
| GPU dispatch (frustum + contribution) | 0.92 ms | bottom line.
| Synchronous readback (`glGetNamedBufferSubData`) | **4.27.4 ms** |
| CPU consume (HiZ + LOD + winding + emit) | 6.49.8 ms |
| **Total wall** | **~15 ms** |
The synchronous readback pipeline-stalled the GPU, adding 47 ms of ##### Why it was removed
idle wait. Total wall time was roughly equal to the CPU-only path,
negating the GPU cull's speed advantage.
**Attempt 4: Async one-frame-late readback (committed, `30e43ffe`).** Benchmark with motion-adaptive culling + HiZ active (Phase 3G):
Replaced synchronous readback with a persistent-mapped buffer
(`GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT`) and a `glFenceSync` /
`glClientWaitSync` fence. The GPU writes survivors this frame; the
CPU reads them next frame. One frame of latency, but zero stalls.
| Phase | Time | | Path | FPS |
|-------|------| |------|-----|
| GPU dispatch | 0.690.78 ms | | CPU BVH (parallel) | 51.0 |
| Async readback (fence poll) | **0.00 ms** | | GPU cull + async readback | 52.0 |
| CPU consume | 5.06.2 ms |
| **Total wall** | **~5.5 ms** |
vs the CPU-only path at 5.26.4 ms wall on the same scene. The GPU The GPU cull added ~585 lines of code (compute shader, persistent-mapped
cull + async readback matches or slightly beats the parallel CPU BVH readback buffer, fence management, per-model AABB SSBOs, 8 profiling
path, with headroom for scenes where the CPU path can't parallelise counters, cleanup at 4 sites) for a 2% improvement that was within
(single large model). measurement noise. With HiZ + motion culling reducing the visible set
to ~11 k objects, the CPU BVH path handles the work in ~2 ms — there's
nothing left for the GPU to win.
**Attempt 5: Dirty-mesh tracking (committed, `01dd8d57`).** Profiling **Removed** in favour of keeping the codebase simple. The CPU BVH path
the CPU consume phase revealed that `clr` (clearing per-mesh visibility is now the only cull path.
buckets) and `emit` (building indirect commands) were O(total_meshes)
= O(462 k), not O(survivors). Added a dirty-mesh list so only mesh
buckets that received survivors are cleared and iterated.
Consume sub-phase breakdown (summed across parallel threads, ##### Lessons learned
~128 k survivors):
| Sub-phase | Before | After | Scales with | 1. **GPU brute-force beats CPU BVH for raw frustum tests** (0.82 ms vs
|-----------|--------|-------|-------------| 1015 ms for 1 M instances) but the advantage disappears once
| bin (model binning) | 0.11 ms | 0.18 ms | O(survivors) | higher-level culling (HiZ, contribution) reduces the working set.
| clr (bucket clear) | 2.0 ms | **1.6 ms** | O(dirty meshes) | 2. **Synchronous readback kills GPU cull.** Persistent-mapped async
| class (HiZ + LOD + winding) | 5.1 ms | 5.3 ms | O(survivors) | readback works but adds complexity for negligible gain.
| emit (indirect cmd build) | 4.2 ms | **2.2 ms** | O(dirty meshes) | 3. **Hybrid GPU/CPU paths are maintenance-heavy** for diminishing
returns when the CPU path is already fast enough.
Emit improved ~48%, clr ~20%. The dominant cost shifted to `class`
(per-survivor HiZ + LOD + winding classification).
##### What we learned
1. **GPU brute-force beats CPU BVH for frustum + contribution.**
0.82 ms for 1.06 M instances vs 1015 ms for the CPU BVH walk.
The BVH's hierarchical skip advantage is overwhelmed by the GPU's
raw parallelism — 1 M independent AABB-vs-frustum tests is a
perfect compute workload.
2. **Synchronous readback kills the advantage.** The 47 ms stall from
`glGetNamedBufferSubData` on ~1 MB of data negated all GPU savings.
A pipeline stall is worse than just doing the work on the CPU.
3. **Async one-frame-late readback works well.** Persistent mapping +
fence polling adds zero measurable overhead. The one-frame latency
is imperceptible for culling — worst case, a few objects at the
frustum edge pop in one frame late during fast camera motion.
4. **CPU consume is now the bottleneck.** With GPU dispatch at <1 ms
and readback at 0 ms, the 56 ms consume phase (HiZ test, LOD
selection, winding classification, indirect command building)
dominates. The `class` sub-phase alone is 5+ ms, scaling linearly
with survivor count.
5. **Dirty-mesh tracking helps but doesn't transform performance.**
The 462 k total meshes → ~104 k active meshes reduction cut emit
in half, but the per-survivor classification work is the true
bottleneck.
##### What remains
The hybrid path (`IFC_GPU_CULL=1`) is functional and committed. It
matches the CPU path's performance today and provides the foundation
for further GPU offload. Remaining opportunities:
- Move HiZ + LOD + winding classification to the GPU (eliminates the
5 ms `class` sub-phase entirely — the GPU already has the AABBs and
can sample the HiZ pyramid directly).
- GPU BVH traversal to reduce dispatch from O(total) to O(visible +
tree overhead) — matters when survivor ratio is low.
- GPU-driven indirect command building (eliminates CPU emit entirely).
Each of these would chip away at the consume phase, but the sub_draw
analysis below reveals a more fundamental bottleneck.
#### 3F. Sub-draw fragmentation analysis #### 3F. Sub-draw fragmentation analysis
##### The problem ##### The problem
With GPU cull solving the *culling* bottleneck, the dominant cost With the culling pipeline mature (BVH + contribution + HiZ + motion
shifts to the *drawing* side. On the 1.06 M-instance / 111-model culling), the dominant cost shifts to the *drawing* side. On the 1.06 M-instance / 111-model
scene, frame times are 4863 ms despite only 2447 M visible scene, frame times are 4863 ms despite only 2447 M visible
triangles — well within the GTX 1650's throughput. The culprit is triangles — well within the GTX 1650's throughput. The culprit is
the number of indirect sub-draws (individual `DrawElementsIndirectCommand` the number of indirect sub-draws (individual `DrawElementsIndirectCommand`
@@ -996,7 +927,6 @@ Benchmarked on 1.06 M-instance / 111-model scene, 200-frame orbit
| MIN_PX_MOTION=10 | 37.67 | 26.5 | 1.6× | 70k | 56k | 0 | | MIN_PX_MOTION=10 | 37.67 | 26.5 | 1.6× | 70k | 56k | 0 |
| HIZ_MOTION=1 | 21.44 | 46.6 | 2.9× | 33k | 17.5k | 28k | | HIZ_MOTION=1 | 21.44 | 46.6 | 2.9× | 33k | 17.5k | 28k |
| HIZ_MOTION=1 + MIN_PX_MOTION=10 | 19.62 | 51.0 | 3.1× | 11.4k | 8.7k | 11.5k | | HIZ_MOTION=1 + MIN_PX_MOTION=10 | 19.62 | 51.0 | 3.1× | 11.4k | 8.7k | 11.5k |
| GPU_CULL + HIZ + MIN_PX | 19.22 | 52.0 | 3.2× | 11.3k | 8.6k | 59k |
##### Conclusions ##### Conclusions
@@ -1010,10 +940,7 @@ Benchmarked on 1.06 M-instance / 111-model scene, 200-frame orbit
3. **Combining both gives diminishing returns** — 3.1× vs 2.9× (HiZ 3. **Combining both gives diminishing returns** — 3.1× vs 2.9× (HiZ
alone) or 1.6× (MIN_PX alone). They compete over the same objects. alone) or 1.6× (MIN_PX alone). They compete over the same objects.
4. **GPU cull adds nothing** on top of these — 52.0 vs 51.0 fps. The 4. **The ~19 ms floor is GPU rendering**, not culling. At 8.6k
CPU BVH path handles the reduced visible set in ~2 ms.
5. **The ~19 ms floor is GPU rendering**, not culling. At 8.6k
sub_draws the bottleneck shifts to draw dispatch + triangle sub_draws the bottleneck shifts to draw dispatch + triangle
rasterization. Further improvement requires reducing sub_draws rasterization. Further improvement requires reducing sub_draws
(static batching) or moving to a more efficient draw model. (static batching) or moving to a more efficient draw model.
@@ -1048,7 +975,6 @@ Scene size Bottleneck Fix
+ Phase 3B LOD (done) + Phase 3B LOD (done)
multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback) multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback)
many models, serial cull single-thread BVH trv Phase 3D parallel cull (done) many models, serial cull single-thread BVH trv Phase 3D parallel cull (done)
single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybrid, done)
orbit fps on 1M+ scenes too many vis objects Phase 3G motion culling + HiZ (done, 3.1×) orbit fps on 1M+ scenes too many vis objects Phase 3G motion culling + HiZ (done, 3.1×)
90k+ unique visible meshes per-draw GPU overhead Phase 3F static batching (next) 90k+ unique visible meshes per-draw GPU overhead Phase 3F static batching (next)
``` ```
@@ -1067,14 +993,14 @@ orbit fps on 1M+ scenes too many vis objects Phase 3G motion culling
- [x] Reflection-aware two-pass draw for mirrored placements - [x] Reflection-aware two-pass draw for mirrored placements
- [x] Backface culling (user-toggleable, default on) - [x] Backface culling (user-toggleable, default on)
- [x] `reorient-shells` enabled in iterator - [x] `reorient-shells` enabled in iterator
- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`, `IFC_MIN_PX_MOTION`, `IFC_HIZ_MOTION`, `IFC_GPU_CULL`, `IFC_SUBDRAW_DIAG`) - [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`, `IFC_MIN_PX_MOTION`, `IFC_HIZ_MOTION`, `IFC_SUBDRAW_DIAG`)
- [x] Phase 3A — screen-space contribution culling - [x] Phase 3A — screen-space contribution culling
- [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`) - [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`)
- [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback) - [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback)
- [x] Phase 3D — Parallel per-model CPU cull (`std::async` fan-out) - [x] Phase 3D — Parallel per-model CPU cull (`std::async` fan-out)
- [x] Quantized VBO (16 B/vert, sidecar v6) - [x] Quantized VBO (12 B/vert: u16x3 pos + oct i8x2 normal + RGBA8, sidecar v7)
- [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames)
- [x] Phase 3E — GPU compute-shader culling (hybrid: GPU frustum+contribution, async readback, CPU HiZ+LOD+emit) - [x] Phase 3E — GPU compute-shader culling (explored, removed — CPU BVH matches at ~585 fewer lines)
- [x] Phase 3G — Motion-adaptive culling + HiZ during motion (3.1× orbit speedup on 1M-instance scene) - [x] Phase 3G — Motion-adaptive culling + HiZ during motion (3.1× orbit speedup on 1M-instance scene)
- [x] Benchmark CLI (`--camera`, `--benchmark`, press C to capture camera) - [x] Benchmark CLI (`--camera`, `--benchmark`, press C to capture camera)
- [ ] **Phase 3F — Static batching of single-instance meshes** (next; reduces 90k+ sub_draws to hundreds) - [ ] **Phase 3F — Static batching of single-instance meshes** (next; reduces 90k+ sub_draws to hundreds)
+4 -2
View File
@@ -39,7 +39,9 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW"
// where meshoptimizer decimation produced useful output. // where meshoptimizer decimation produced useful output.
// v6 = VBO vertices quantized to 16 B/vertex (pos u16x3 + normal oct i16x2 + // v6 = VBO vertices quantized to 16 B/vertex (pos u16x3 + normal oct i16x2 +
// color u8x4). Dequant basis is per-mesh MeshInfo.local_aabb_min/max. // color u8x4). Dequant basis is per-mesh MeshInfo.local_aabb_min/max.
static constexpr uint32_t SIDECAR_VERSION = 6; // v7 = VBO vertices shrunk to 12 B/vertex (normal oct i8x2 replaces i16x2,
// eliminating 2-byte pad + saving 2 bytes on normal).
static constexpr uint32_t SIDECAR_VERSION = 7;
static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304;
// Fixed-size element record. Strings are stored as (offset, length) pairs // Fixed-size element record. Strings are stored as (offset, length) pairs
@@ -61,7 +63,7 @@ struct PackedElementInfo {
// re-running the iterator. v6 schema: instanced + quantized geometry. // re-running the iterator. v6 schema: instanced + quantized geometry.
struct SidecarData { struct SidecarData {
// Per-model GPU geometry (local coords). Raw VBO bytes at the // Per-model GPU geometry (local coords). Raw VBO bytes at the
// INSTANCED_VERTEX_STRIDE_BYTES layout (16 B/vertex as of v6). // INSTANCED_VERTEX_STRIDE_BYTES layout (12 B/vertex as of v7).
std::vector<uint8_t> vertices; std::vector<uint8_t> vertices;
std::vector<uint32_t> indices; std::vector<uint32_t> indices;
+10 -10
View File
@@ -46,9 +46,9 @@ static_assert(sizeof(DrawElementsIndirectCommand) == 20, "indirect cmd must be 2
// Shaders // Shaders
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// //
// Vertex layout (GL side, 16 bytes — quantized; see InstancedGeometry.h): // Vertex layout (GL side, 12 bytes — quantized; see InstancedGeometry.h):
// location 0: vec3 a_position_q (u16x3 normalized, per-mesh AABB basis) // location 0: vec3 a_position_q (u16x3 normalized, per-mesh AABB basis)
// location 1: vec2 a_normal_oct (i16x2 normalized, octahedral) // location 1: vec2 a_normal_oct (i8x2 normalized, octahedral)
// location 2: vec4 a_color (u8x4 normalized) // location 2: vec4 a_color (u8x4 normalized)
// //
// Per-instance record in SSBO std430 (80 bytes): // Per-instance record in SSBO std430 (80 bytes):
@@ -66,7 +66,7 @@ static const char* MAIN_VERTEX_SHADER = R"(
#extension GL_ARB_shader_draw_parameters : require #extension GL_ARB_shader_draw_parameters : require
// Quantized vertex inputs — see InstancedGeometry.h for layout. // Quantized vertex inputs — see InstancedGeometry.h for layout.
layout(location = 0) in vec3 a_position_q; // u16x3 normalized -> [0,1] layout(location = 0) in vec3 a_position_q; // u16x3 normalized -> [0,1]
layout(location = 1) in vec2 a_normal_oct; // i16x2 normalized -> [-1,1] layout(location = 1) in vec2 a_normal_oct; // i8x2 normalized -> [-1,1]
layout(location = 2) in vec4 a_color; layout(location = 2) in vec4 a_color;
struct InstanceRecord { struct InstanceRecord {
@@ -307,7 +307,7 @@ static void octEncode(const float n[3], float out[2]) {
} }
// Quantize a streamer-format vertex (pos3 + normal3 + color-as-float) into // Quantize a streamer-format vertex (pos3 + normal3 + color-as-float) into
// the 16 B VBO record, given the mesh's tight local AABB. `extent_recip` // the 12 B VBO record, given the mesh's tight local AABB. `extent_recip`
// is 1/(max-min) per axis, or 0 for degenerate axes (quantum becomes 0). // is 1/(max-min) per axis, or 0 for degenerate axes (quantum becomes 0).
static void quantizeVertex(const float src[7], static void quantizeVertex(const float src[7],
const float aabb_min[3], const float aabb_min[3],
@@ -320,14 +320,14 @@ static void quantizeVertex(const float src[7],
if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f;
p[a] = static_cast<uint16_t>(t * 65535.0f + 0.5f); p[a] = static_cast<uint16_t>(t * 65535.0f + 0.5f);
} }
// Normal -> oct i16x2. // Normal -> oct i8x2. int8 gives ~1.4° worst-case error — fine for BIM.
float oct[2]; float oct[2];
octEncode(src + 3, oct); octEncode(src + 3, oct);
int16_t* n = reinterpret_cast<int16_t*>(dst + INSTANCED_VERTEX_NORMAL_OFFSET); int8_t* n = reinterpret_cast<int8_t*>(dst + INSTANCED_VERTEX_NORMAL_OFFSET);
for (int a = 0; a < 2; ++a) { for (int a = 0; a < 2; ++a) {
float v = oct[a]; float v = oct[a];
if (v < -1.0f) v = -1.0f; else if (v > 1.0f) v = 1.0f; if (v < -1.0f) v = -1.0f; else if (v > 1.0f) v = 1.0f;
n[a] = static_cast<int16_t>(std::lrintf(v * 32767.0f)); n[a] = static_cast<int8_t>(std::lrintf(v * 127.0f));
} }
// Color passes through — streamer packs 4 bytes into the 7th float slot. // Color passes through — streamer packs 4 bytes into the 7th float slot.
std::memcpy(dst + INSTANCED_VERTEX_COLOR_OFFSET, src + 6, 4); std::memcpy(dst + INSTANCED_VERTEX_COLOR_OFFSET, src + 6, 4);
@@ -498,13 +498,13 @@ void ViewportWindow::setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo) {
INSTANCED_VERTEX_POS_OFFSET); INSTANCED_VERTEX_POS_OFFSET);
gl_->glVertexArrayAttribBinding(vao, 0, 0); gl_->glVertexArrayAttribBinding(vao, 0, 0);
// normal oct-encoded (2 x i16 normalized @ 8) // normal oct-encoded (2 x i8 normalized @ 6)
gl_->glEnableVertexArrayAttrib(vao, 1); gl_->glEnableVertexArrayAttrib(vao, 1);
gl_->glVertexArrayAttribFormat(vao, 1, 2, GL_SHORT, GL_TRUE, gl_->glVertexArrayAttribFormat(vao, 1, 2, GL_BYTE, GL_TRUE,
INSTANCED_VERTEX_NORMAL_OFFSET); INSTANCED_VERTEX_NORMAL_OFFSET);
gl_->glVertexArrayAttribBinding(vao, 1, 0); gl_->glVertexArrayAttribBinding(vao, 1, 0);
// color (4 x u8 normalized @ 12) // color (4 x u8 normalized @ 8)
gl_->glEnableVertexArrayAttrib(vao, 2); gl_->glEnableVertexArrayAttrib(vao, 2);
gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE, gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE,
INSTANCED_VERTEX_COLOR_OFFSET); INSTANCED_VERTEX_COLOR_OFFSET);