helmertMetersFromParameters builds the helmert transformation as a
meter-input/meter-output 4x4 directly from parsed parameters, bypassing
autoLocal2Global's normalisation step. This preserves
IfcMapConversionScaled.FactorX/Y/Z in the rotation block so the factor
applies to placement translations when the matrix is precomputed
per-model and composed with placements at upload time. For ordinary
IfcMapConversion (factor = 1) this is bit-identical to
autoLocal2Global; only diverges on rare surveyed models with non-unit
factors, where it is the only correct behaviour.
getMapUnit returns IfcCoordinateOperation.TargetCRS.MapUnit so callers
can resolve the unit-to-metres scale via Unit.h's siScaleFromNamedUnit.
autoLocal2Global is unchanged — kept as a clean port of the python
ifcopenshell.util.geolocation reference impl for one-shot
project-units-in / map-units-out callers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors selected helpers from ifcopenshell.util.unit: SI prefix
multipliers, the conversion-based-unit table (foot/inch/etc -> SI
metres), siScaleFromNamedUnit (walks IfcConversionBasedUnit chains
down to IfcSIUnit), getUnitAssignment / getProjectUnit /
calculateUnitScale, and convert / convertUnit. Lives in
src/ifcviewer/ for now alongside Geolocation; will move out when
ifcopenshell.util is ported to C++.
Needed by upcoming Geolocation fix (e/n/h on IfcMapConversion are
in MapUnit, must be converted to metres for the meter-by-default
iterator output) and by the federation module (display-unit
conversion when the user changes the federation unit).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors ifcopenshell.util.geolocation: HelmertTransformation parameters
(IfcMapConversion / IfcMapConversionScaled / IfcRigidOperation, plus
IFC2X3 ePSet_MapConversion), get_wcs from IfcGeometricRepresentationContext,
local2global, and auto_local2global. Lives in src/ifcviewer/ for now;
will move out when ifcopenshell.util is ported to C++.
Not yet wired into the streamer. A subsequent commit fixes the
unit handling for the iterator's meter-by-default output.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous projection-toggle commit short-circuited contribution
culling when projection_ortho_ was set — the formula
r_px = focal_px * r / dist looks like it depends on per-instance
distance, which doesn't apply in ortho. Result: every frustum-
visible object drew, including sub-pixel ones, and FPS tanked on
top-down plan views.
In ortho the projected pixel size of a bounding sphere is constant:
r_px = pixels_per_world * r, where pixels_per_world equals the
existing focal_px / camera_distance_ (the ortho box was sized to
match perspective at the pivot's distance). So the same formula
gives the right answer if we replace per-instance dist with
camera_distance_.
cullModelCpu now does that substitution for both contributionPasses
and pixelRadius (the latter feeds LOD1 selection too — sub-pixel
objects pick LOD1 in ortho the same way they do in perspective).
The "camera inside AABB" early-return is kept; it only fires in
perspective where dist→0 would otherwise blow up r_px, and is
harmless in ortho.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a per-frame depth-laplacian pass that darkens pixels at sharp
depth discontinuities — silhouettes, overlapping-surface boundaries,
section-cut edges. Catches the wall-against-wall and slab-against-
ceiling cases that the cavity hint in the lighting shader misses.
Implementation:
- New edge_depth_fbo_ / edge_depth_tex_ — single-sample D24S8 the
size of the window. After the main draw, blit the default FB
depth into it (handles MSAA resolve in the same call).
- Fullscreen triangle generated from gl_VertexID, samples four
cardinal neighbours, computes |4c - n - s - e - w| on linearized
depth. Linearization branches between perspective and ortho via
u_is_ortho. Threshold scales with depth so distant edges still
register.
- Output is multiplicatively blended (GL_DST_COLOR, GL_ZERO) so
colours just darken; no separate composite step.
- Runs before the pivot/section/axis gizmos so they aren't outlined
themselves. HiZ pyramid build still runs after, unchanged.
Per-frame cost is one MSAA depth blit + one fullscreen pass with
five depth samples. Sub-millisecond at 1080p on a mid GPU.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the flat 0.25 ambient + single-Lambert key with three cheap
shape-readability tricks, all in the fragment shader:
- Hemisphere ambient (sky/ground tint mixed by n.z) so floors,
ceilings, and walls get visibly different ambient colour even when
shadowed. +Z is world-up.
- Secondary fill light at 35% intensity from roughly the opposite
horizontal direction so backs of objects are not pitch black.
- Cavity hint: clamp(length(fwidth(n)) * 1.5, 0, 0.35) darkens
fragments where adjacent normals diverge sharply. Catches
wall-floor seams, column-slab joints, and stair edges as faint
dark lines without any post-process.
Total cost: ~8 extra ALU ops per fragment, no extra passes, no extra
buffers. No change to cull/HiZ/MDI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- P toggles ortho/perspective. The ortho box is sized so the
visible rectangle at the pivot's distance matches what the
perspective camera would show — toggling at any zoom keeps the
framing identical, and the wheel keeps working by rescaling the
box. Contribution culling is disabled in ortho since its
r_px = focal_px * r / dist formula assumes perspective; frustum
and HiZ culling still run.
- X / Y / Z snap the camera to look from +X / +Y / +Z; Shift+X /
Y / Z snap to the negative side. Yaw and pitch are set
directly so top/bottom land on exactly ±90°.
- updateCamera() picks the lookAt up vector dynamically: world +Z
except within 1° of the pole, where it switches to world +Y.
That keeps lookAt well-conditioned at the poles and gives top
views the architectural "Y as north" screen orientation.
- Pan now derives screen-right / screen-up from the real camera
basis instead of from yaw/pitch alone — the old derivation
assumed up = world +Z and silently inverted at top/bottom.
- Standard views preserve target and distance — rotate only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Convenient escape hatch when the user has stacked several cuts
and wants to start over without exiting the tool first. Also
resets the selection and drag state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires up the user-facing section-cut tool on top of the clipping
plumbing landed in the previous commit.
- K toggles the tool.
- LMB while the tool is active:
* On an existing plane's arrow gizmo (screen-space line-segment
hit test, 12 px grab radius) → select + start drag.
* Otherwise on geometry → pickSurfaceAt + addSectionPlaneAt-
Surface, select the new plane.
* Otherwise → deselect.
- LMB drag updates the plane's origin by projecting the cursor
delta onto the screen-space normal axis and converting back to
metres. d is rederived from the new origin each frame.
- Delete removes the selected plane; Esc exits the tool.
- Each plane renders a 2x2 m quad outline plus a yellow arrow
along +n at its origin. Selected plane draws cyan and
thicker.
LMB object-pick is suppressed while the tool is active so plane
creation does not also change selection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a clip-plane pipeline used by the upcoming section tool:
- Up to 8 SectionPlane{n, d} entries, AND-combined as
fragment-shader discard against world position. Main and pick
fragment shaders both honour the planes, so cut areas are
neither drawn nor selectable.
- Main vertex shader now passes v_world_pos through.
- Pick FBO grows two attachments (RGB32F world position, RGB16F
world normal) and the pick shader writes both alongside the
object id. pickSurfaceAt() does a single readback of all
three. Existing pickObjectAt() still works unchanged for
callers that just want the id.
- addSectionPlaneAtSurface(point, normal) auto-flips the normal
toward the camera so the first click immediately cuts the
camera-facing half.
No UI yet — that's the next commit (gizmo, drag, K shortcut).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
F (no modifier) re-aims the orbit camera at the selected object's
world AABB centroid and dollies camera_distance_ so the bounding
sphere fits the current viewport. Home does the same for the union
of all finalized models. Both preserve yaw/pitch so the user keeps
their orientation; both no-op in FPS mode.
Scene AABB prefers the per-model BVH root when available and falls
back to walking InstanceCpu world AABBs. Object AABB unions every
matching instance. Distance accounts for portrait windows by using
the tighter of the horizontal and vertical FOV constraints.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A small RGB axis cross is rendered at camera_target_ while the user is
orbiting, panning, or has just zoomed. Visibility toggles on
middle-mouse press/release; the wheel arms a single-shot QTimer that
hides it 750 ms after the last notch.
Drawn in two passes: GL_GREATER at 30% alpha for the occluded portion
(X-ray cue) and GL_LEQUAL at full alpha for the visible portion. Arm
length is computed from camera_distance_, fovy, and viewport height so
the cross stays ~30 px on screen across zoom levels.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Port get_prioritised_contexts from ifcopenshell.util.representation to
C++ and have GeometryStreamer iterate one context at a time, mirroring
bonsai's create_generic_element loop. Each pass sets context-ids to a
single context id; elements that yield geometry are dropped from the
include set so lower-priority contexts only pick up leftovers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Match bonsai's process_element_filter for the no-filter branch:
IfcSpatialStructureElement on IFC2X3, IfcSpatialElement otherwise.
They flow through the same net/gross split as IfcElement.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror bonsai's IfcImporter.process_element_filter so the streamer
walks only IfcElement (plus IfcProxy on IFC2X3/IFC4), drops
IfcFeatureElement except IfcSurfaceFeature, and routes elements
with more openings than the configurable void limit through a
second iterator pass with disable-opening-subtractions=true.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers the pure-logic modules with no Qt event loop or GL context: BVH
build, LOD decimation, sidecar round-trip, instanced-geometry layout
constants, and Federation save/load + relative-path policy. Each test
binary compiles only the production source(s) under test, so the unit
tier doesn't pull Qt/OpenCASCADE/IfcGeom into the test build.
Gated behind BUILD_IFCVIEWER_TESTS=OFF; default builds remain offline.
Catch2 v3.5.4 is fetched on demand via FetchContent.
The reserved uint32_t was always written as 0 and never inspected on
read. Removing it shrinks the header from 16 to 12 bytes; the version
bump makes pre-existing sidecars fail the version check cleanly rather
than misreading by 4 bytes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The struct holds per-model bookkeeping; Model describes its contents
rather than its container relationship.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Federation (JSON) tracks an ordered list of model sources plus an
optional home-view camera state. Sources are stored relative when
under the federation file's directory, absolute otherwise.
File menu now exposes New / Open / Save / Save As; Add Files moves
to Ctrl+Shift+O. View menu gains Set/Go to Home View. Window title
binds to dirty state via setWindowModified, and the close-window
prompt offers Save/Discard/Cancel.
Per-model transform (4x4 column-major) and visible round-trip
through load/save but are not yet applied at the viewport — the
georeferencing work uses them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WASD strafe, Q/E down/up, mouse-look (cursor hidden + recentered),
Shift to sprint, scrollwheel scales speed, click or Esc returns to
orbit. Exiting drops back to the same viewpoint because rotation
re-pins camera_target_ to keep camera_eye_ stationary.
Movement integrates wall-clock dt inside render() and the next frame
self-schedules via requestUpdate() while any key is held. A QTimer
would fight Qt's event loop during long swapBuffers blocks and produce
"camera pauses one frame" stalls; render-driven integration keeps
movement phase-locked to vsync and absorbs slow frames in a single
catch-up step.
IFC_FPS_HITCH_MS=<n> logs frames slower than n ms while in fly mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sidecar hits skipped opening the underlying .rdb/.ifc, so ifcFile() was
null and the property panel only showed cached name/type/guid. Now, after
a sidecar hit, a background thread opens <stem>.rdb (preferred) or
<stem>.ifc and hands the file to GeometryStreamer via setIfcFile(), with
a dataSourceReady signal so the UI refreshes the current selection.
Gated behind a new AppSettings::loadDataSource toggle (default on) so
users can opt into geometry-only viewing; when off, the sidecar-hit
thread is skipped and the stream-path ifc_file_ is released after
the sidecar write completes.
Also adds *.ifcview to the Add Files dialog filter so a cache can be
opened directly without its source file present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two upstream commits on this branch landed without updating all their
callers, leaving `import ifcopenshell.geom` unusable:
89c66f62b "Python import fixes: import from wrapper now which
inherits from mixins" moved the `file` class out of
ifcopenshell/file.py into ifcopenshell_wrapper, but missed
geom/main.py and stream.py which still did `from ..file import file`.
b022ca7e7 "Some plug-in work" dropped the SWIG exports for
`serialise`, `tesselate`, `XmlSerializer` (and other serializers)
with a `// @todo bring back serialization` marker, but left
geom/main.py referencing them at module-load time.
Fix the `file` imports to come from ifcopenshell_wrapper, and guard
the removed-serializer references behind `hasattr`, matching the
pattern already in use for the other optional serializers (gltf, hdf5,
collada, json, ttl). Revert once upstream fixes this.
The viewer can now open a .rdb directory (as produced by
RocksDbSerializer / convert_path_to_rocksdb) anywhere it accepts an
.ifc file. The full GUI gets an "Add Database..." File menu entry
that opens a directory chooser; the streamer lets the file
constructor autodetect the format and opens the store read-only so
multiple viewers can share a database without taking the exclusive
RocksDB lock.
Parallel mapping on RocksDB-backed files still produces
non-deterministic shape counts (the race is outside the instance
cache), so force num_threads=1 for the iterator when the storage is
RocksDB. Serial RocksDB (~2.6s) and parallel SPF (~0.7s) both
produce 107 shapes on AC20-FZK-Haus; @todo in-source points at the
remaining thread-safety work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously readSidecar/writeSidecar were keyed on (path, file_size) with
staleness rejected at read time. Switch to pure path-stem keying: foo.ifc
and foo.ifcdb/ both resolve to foo.ifcview, so the same cache serves either
source format. Staleness is user-managed (delete the sidecar to force a
rebuild), which also lets sidecars be copied or moved independently of the
source.
v8 header drops the source_file_size field.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Buffer viewport model mutations until the OpenGL context is initialized so loads that start before first exposure do not silently drop geometry or model state.
Generated with the assistance of an AI coding tool.
Handle streamer success, failure, and cancellation as distinct terminal states so failed or cancelled loads do not finalize as successful models. Clean up partial model/UI state in the full and minimal viewer apps when a load is cancelled or fails.
Generated with the assistance of an AI coding tool.
MainWindow and MinimalWindow each carried ~150 lines of mirrored
load-queue, sidecar-thread, streamer-wiring, and ID-rebase code. Lift
all of it into a SceneLoader QObject in the library; both apps now
consume it via signals. Sidecar writes stay on the full-app side since
they need the consumer's element metadata strings.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Turn src/ifcviewer into libIfcViewer.so holding the rendering engine +
geometry pipeline (ViewportWindow, GeometryStreamer, BvhAccel,
InstancedGeometry, SidecarCache, LodBuilder, AppSettings). Move the
existing UI shell (MainWindow, SettingsWindow, main.cpp) into
src/ifcviewer-full as the IfcViewerFull executable. Add a new
src/ifcviewer-minimal target with a MinimalWindow that hosts only the
viewport and reuses the sidecar fast-path for benchmark/debug runs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Edge-collapse decimation (meshopt_simplify) returns BIM meshes unchanged
due to per-triangle vertex duplication and non-manifold topology. The
sloppy voxel-clustering decimator is faster, needs no shadow index
welding, and produces good results at the sub-30px LOD1 threshold.
Remove the non-sloppy branch, shadow buffer, IFC_LOD_SLOPPY and
IFC_LOD_LOCK_BORDER env vars.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
Use $<TARGET_FILE_DIR:IfcGeom> instead of hardcoded
${CMAKE_BINARY_DIR}/ifcgeom/$<CONFIG> for plugin runtime dirs — the
old path was wrong on non-MSVC generators where $<CONFIG> expands
empty. Add explicit add_dependencies for kernel/mapping plugins so
IfcViewer waits for them to build, and drop the redundant direct link
against ${kernel_libraries}.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Benchmarks showed negligible gain (52 vs 51 fps) — the CPU BVH path
already culls efficiently, and the GPU path still read back to CPU for
LOD/winding/HiZ. Removes ~570 lines of dead weight: compute shader,
async readback, one-frame-late consume, per-model AABB SSBOs, and
profiling counters.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add --camera tx,ty,tz,dist,yaw,pitch and --benchmark N CLI args for
reproducible performance measurement. The benchmark orbits the camera
(0.5°/frame yaw) for N frames after a 5-frame warmup, prints
avg/median/p1/p99 frame times, then exits. Press C during interactive
use to print the current camera as a --camera argument.
Fix settle recull to fire after ANY camera motion (not just when
IFC_MIN_PX_MOTION is set), ensuring HiZ artifacts from motion frames
are always cleared when the camera stops.
Document Phase 3G (motion-adaptive culling + HiZ during motion) in
README with benchmark results from 1.06M-instance scene:
- Baseline: 16.3 fps
- IFC_MIN_PX_MOTION=10: 26.5 fps (1.6x)
- IFC_HIZ_MOTION=1: 46.6 fps (2.9x)
- Both combined: 51.0 fps (3.1x)
- + GPU_CULL: 52.0 fps (3.2x, negligible gain)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
During camera motion, use a larger pixel-radius threshold (IFC_MIN_PX_MOTION)
to aggressively cull small objects, dramatically reducing sub_draws and
improving orbit fps (e.g. 29→67 fps on 1M-instance scene). When the camera
stops, automatically re-cull at the base threshold to restore full detail.
Key behaviors:
- IFC_MIN_PX_MOTION=N sets the motion threshold (0 = disabled)
- Settle recull fires on the first still frame after motion
- HiZ pyramid invalidated on settle (stale from sparse motion frame)
- GPU cull results skipped on settle (dispatched at motion threshold)
- requestUpdate() ensures the settle frame actually runs
Also adds IFC_SUBDRAW_DIAG=1 diagnostic for sub-draw composition analysis
and documents Phase 3E/3F experiment results in README.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The HiZ pipeline had two bugs causing false occlusions:
1. The scaling depth blit (glBlitFramebuffer from window-size to HiZ-size)
produced GL_INVALID_VALUE on some drivers. Replace with a fullscreen-
triangle shader that samples the resolved depth and writes gl_FragDepth.
2. The resolve texture used GL_DEPTH_COMPONENT24 but Qt's default FBO uses
D24S8 (depth+stencil). Mismatched formats cause the MSAA resolve blit
to fail. Fix by using GL_DEPTH24_STENCIL8 for the resolve texture.
Additionally, the occlusion test was too aggressive for scenes with
compressed depth ranges (entire scene in 0.99-1.0). Change from
"max over coarse mip texels" to "reject only if ALL fine-mip texels
agree the AABB is behind them", with early-out on first non-occluding
texel and a 64-sample cap.
Also fix IFC_HIZ_MOTION=0 being treated as enabled (checked env var
existence, not value).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Only clear and emit mesh buckets that received survivors in the previous
frame, converting both phases from O(total_meshes) to O(active_meshes).
Adds per-sub-phase timing (bin/clr/class/emit) to the stats line.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the CPU BVH traversal + frustum + contribution stages with a
GPU compute path (IFC_GPU_CULL=1). A single scene-wide dispatch tests
all instances against frustum planes and screen-space contribution
threshold, compacting survivors into a flat uint32 buffer via atomicAdd.
Uses one-frame-late async readback: frame N dispatches and fences,
frame N+1 polls the fence (non-blocking) and reads the persistent-
mapped result buffer with zero GPU sync cost. CPU still handles HiZ,
LOD selection, winding bucketing, and indirect command generation from
the compact survivor list; draw path is unchanged.
On a 1M-instance / 111-model scene (GTX 1650):
GPU dispatch: 0.70 ms (frustum + contribution, brute-force)
Readback: 0.00 ms (fence already signaled, persistent map)
CPU consume: 5.7–6.7 ms (parallel emit across models)
Cull wall: 5.8–6.9 ms (vs 9.6–15.2 ms CPU-only path)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pack compute shader compacts non-empty indirect commands into
contiguous fwd/rev ranges, eliminating ~690k empty sub-draws that
dominated command-processor overhead. GL 4.6 entrypoint loaded via
getProcAddress with ARB fallback; graceful degradation to uncompacted
MDI when unavailable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two-phase compute-cull dispatch when IFC_GPU_CULL=1:
Phase 1 frustum + contribution + LOD, no HiZ → survivors
Depth render survivors depth-only into half-viewport FBO
Build GPU compute max-reduce depth → R32F mip pyramid
Phase 2 same cull + HiZ test → final survivors
Color render final survivors
The compact shader's new hizOccluded() projects 8 AABB corners to
screen space, picks the mip level where the covered rect fits in ≤2×2
texels, and rejects when the AABB's near-depth exceeds the pyramid's
max depth.
New GPU resources (per-window):
hiz_gpu_fbo_ / hiz_gpu_depth_tex_ — depth-only FBO at half viewport
hiz_gpu_pyramid_tex_ — R32F mipmapped pyramid
hiz_gpu_copy_prog_ — compute: depth → pyramid L0
hiz_gpu_reduce_prog_ — compute: max-reduce L(n-1)→L(n)
hiz_gpu_depth_prog_ — vertex + trivial fragment
On a dense 18-model BIM dataset:
survivors: 140k → 65k (HiZ rejects ~50%)
triangles: 22M → 13M
gpu_cull: 0.06ms → 22.5ms (depth pre-pass CP overhead)
The depth pre-pass suffers the same empty-sub-draws CP overhead as the
color pass (690k commands, most with instanceCount=0). Once MDI
compaction lands, both passes will be fast. For now, net FPS is flat
(savings on color ≈ cost of depth pre-pass).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The compact shader now computes per-instance pixel radius and routes
survivors to LOD1 buckets when the projected sphere falls below the
LOD1 threshold (default 30 px, same as CPU path, tunable via
IFC_LOD1_PX).
Layout expanded from 2 to 4 buckets per mesh:
[0..M) fwd_lod0 [M..2M) fwd_lod1
[2M..3M) rev_lod0 [3M..4M) rev_lod1
Two MDIs per model: CCW for [0..2M), CW for [2M..4M). Per-mesh
has_lod1 flags live in a new gpu_mesh_flags_ssbo (binding 4).
Contribution cull refactored: the compact shader now computes
pixelRadius() once and uses it for both the min_pixel_radius rejection
and LOD routing, matching the CPU path's logic.
Visible-buffer worst case is 2 × total_instances (each LOD bucket
reserves the full fwd/rev capacity per mesh, since LOD selection is
dynamic).
Tri count drops ~60% on the test dataset (53M → 22M) thanks to LOD1
decimated meshes. FPS recovers from 16 to 36 despite 690k sub_draws
(4M layout). MDI compaction remains the final perf fix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extend the GPU-cull indirect buffer from M to 2M commands: the first M
are the forward (non-reflected, CCW) bucket, the second M are the
reverse (reflected, CW) bucket. The compact shader reads flags bit 0
from the AABB SSBO and routes each survivor to the appropriate bucket
via bucket = reflected ? mesh_id + M : mesh_id.
uploadGpuCullStaticBuffers() now precomputes exact per-mesh fwd/rev
instance counts so each bucket reserves only the slots it needs
(total visible_ssbo size unchanged — sum of fwd + rev = total).
Draw loop issues two MDIs per model under IFC_GPU_CULL: first M
commands CCW, next M commands CW.
Sub-draws doubled (172k → 345k) which further regresses FPS due to
command-processor overhead from zero-instance sub-draws — the same
issue noted in 3a. MDI compaction remains the fix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Promote the compute cull from a validation shader to the actual draw
driver. With the gate on, the CPU cull fan-out is skipped and MDI
consumes gpu_indirect_buffer / gpu_visible_ssbo directly.
- uploadGpuCullStaticBuffers() pre-fills per-mesh DrawElementsIndirect
commands and a mesh_base prefix sum so the compact shader can scatter
survivors into a fixed per-mesh range. Instance count for each
command is zeroed by a tiny reset dispatch, then the compact shader
atomically writes survivors and increments instanceCount.
- Draw loop branches on the gate: single CCW MDI with all mesh
commands. Fwd/rev winding split, LOD selection, and HiZ are still
CPU-path-only; reflected instances render with wrong winding under
this gate (step 3b).
- Once-per-second readback of each model's indirect buffer populates
the survivor / visible-object / visible-triangle stats so the
[frame] line reflects what the GPU actually drew.
Known regression: sub_draws is the full mesh count per model (~172k on
the test dataset) vs the handful of non-empty commands the CPU path
produces. Command-processor overhead from zero-instance sub-draws is
what drives the FPS drop, not the cull itself (0.05 ms). Compacting
non-empty commands requires glMultiDrawElementsIndirectCount, a GL 4.6
entrypoint not exposed by Qt's QOpenGLFunctions_4_5_Core; deferring to
3a-followup so we don't bolt a getProcAddress loader into the renderer
mid-restructure.
IFC_GPU_CULL is off by default, so this does not affect normal runs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
First Phase 3E milestone: a compute shader that reads the per-instance
world-AABB SSBO added in the last commit, tests each instance against
the 6 frustum planes, and atomicAdds a global counter. No visible list
or indirect-buffer writes yet — the output is just a survivor count,
cross-checked each frame against the CPU cull's numbers in the stats
line (`gpu_cull[Xms in=A surv=B]`) so we can verify the plumbing end-
to-end before we hand the GPU responsibility for the actual render data.
Dispatched from render() after the CPU cull completes, only when
IFC_GPU_CULL=1 and the camera moved (the skipped-cull still-frame path
doesn't re-check either). The readback is synchronous — that's fine
for a validation path; it'll go away once the GPU writes indirect
commands directly.
Expected invariant: gpu_cull.surv >= cpu_cull.visible_objects, since
the GPU path does frustum-only and CPU adds contribution + HiZ cuts on
top. A large mismatch (orders of magnitude, or surv < visible) means
the SSBO upload or shader logic is wrong.
No shader/buffer bindings overlap with the draw path (compute uses
bindings 0/1, restored before drawing; draw programs rebind 0/1/2).
Scaffolding for Phase 3E (GPU compute cull). After finalizeModel /
applyCachedModel, pack each InstanceCpu's world AABB + mesh_id +
reflection bit into a std430-friendly 32 B record and push it to a
per-model aabb_ssbo. No consumer yet — the CPU cull still drives
rendering — but the next commits will point a compute shader at this
buffer and have it produce the visible list + indirect commands
directly on the GPU.
Cost: 32 B per instance, ~18 MB for the 569 k-instance test scene.
One-shot upload at finalize time; streaming-time appends aren't
mirrored (the CPU cull doesn't need the SSBO, and finalizeModel
rebuilds the whole thing in one go).