Phase 3C: Hierarchical-Z occlusion culling (CPU-side v1)

After the main draw, blit the MSAA default-framebuffer depth to a
single-sample 256×128 depth texture, read it back, and build a CPU
max-reduced mip pyramid.  Next frame's cullAndUploadVisible projects
each BVH node / instance AABB through the previous frame's VP and
compares the AABB's nearest depth against the pyramid's deepest value
at the matching mip level; strictly-beyond AABBs are rejected.

Conservative direction (aabb_near > hiz_max) — never wrongly rejects a
visible instance, so no flicker.  BVH subtree-level test lets a single
8-corner projection reject up to a leaf's worth of instances.

Tuning knobs: IFC_NO_HIZ=1 disables; IFC_HIZ_SIZE overrides base width.
New stats counter hiz_rej shows rejects/frame.

Measured: big win on interior views (GPU-bound), roughly zero net
effect on exterior overviews (CPU-bound on cull traversal, so the
saved GPU work is masked).  Tried a 3-deep PBO ring for async readback
and reverted — the extra frame of staleness produced visible flicker
on fast orbit, and the synchronous readback wasn't actually a measured
bottleneck at 256×128.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-13 23:25:33 +10:00
parent c78e16eafb
commit 8596d53a4a
3 changed files with 420 additions and 13 deletions
+117 -12
View File
@@ -565,15 +565,120 @@ typical cost is 100600 ms per model, folded into the already-visible
"finalizing" step. Cached into the sidecar afterwards, so subsequent
opens skip it entirely.
#### 3C. Hierarchical-Z occlusion culling (longer-term)
#### 3C. Hierarchical-Z occlusion culling — ✅ done (v1, CPU-side)
Render large occluders first, build a depth pyramid, test instance
AABBs against it. In dense BIM most geometry is behind other geometry
from any given interior viewpoint; historically a 310× reduction in
drawn instances. Most valuable *after* 3A+3B, which together handle
the far-away and small-detail cases. Pairs naturally with GPU-side
culling (a compute shader doing the HiZ test and writing the visible
list + indirect buffer in place).
Reject frustum-visible instances whose AABB is fully behind something
already drawn. The last drawn frame's depth buffer is the oracle — if a
region's deepest rasterised fragment is closer than an AABB's nearest
point, nothing in that AABB can win the depth test.
In dense BIM this matters most on interior views: standing inside a
building, 8095 % of the model sits behind the walls of the current
room and contributes nothing to the frame. Phase 3A drops the
*distant-and-small* geometry, 3B drops its triangle count when kept,
and 3C drops the *close-and-big-but-hidden* bulk that neither of those
can touch. On an outdoor overview shot (nothing is occluded) 3C does
almost nothing — which is fine, 3A+3B already cover that case.
##### Pipeline (v1: CPU-side, 1-frame stale)
```
render():
draw main scene into MSAA default fb
axis gizmo
buildHizPyramid(): <-- new
glBlitFramebuffer MSAA depth → single-sample depth tex (256×128)
glReadPixels depth tex → CPU
max-reduce mip chain on CPU (89 levels)
store the VP that produced this frame
swapBuffers
cullAndUploadVisible():
per BVH node: frustum ∧ contribution ∧ hiz (subtree early-out)
per instance: frustum ∧ contribution ∧ hiz
```
The pyramid is always the *previous* frame's depth. On a newly loaded
scene or after a camera jump the cull is conservatively too permissive
for a frame or two (draws the occluded stuff by accident) and then
settles. No flicker because we never *wrongly reject* a visible
instance — the comparison is `aabb_near_depth > hiz_max`, so the
worst case is a kept instance that was actually occluded.
##### Why CPU-side?
Because the readback is cheap at this resolution (~128 KB / frame,
single glReadPixels ≈ 0.5 ms on PCIe) and the test itself is trivial
— ~100 k AABBs × 8 corners × a small mip lookup is well under a
millisecond on one thread. Phase 3D will port the cull to a compute
shader reading the pyramid as a texture, eliminating the readback; but
Phase 3C's CPU implementation was small enough to do first and
measure.
No MSAA complication on the write side: we just blit the default
framebuffer's multi-sample depth into a single-sample texture (GL
handles the resolve). No separate occluder pass either — we use the
previous completed frame's depth buffer directly, which is what a
temporal-reprojection HiZ reduces to when the "occluder set" is
"everything visible last frame".
##### The test
```cpp
project 8 AABB corners through hiz_vp → NDC rect + min z
if any corner has w ≤ 0: return false // crosses near plane
if rect is outside [-1, 1]²: return false
pick mip level where rect ≤ 2×2 texels
hiz_max = max(pyramid[mip][covered texels])
return aabb_near_depth > hiz_max
```
Comparing the AABB's *closest* point against the pyramid's *deepest*
value is the conservative direction — it only rejects when the AABB
is strictly beyond everything we already drew in that region. We pick
the mip at which the rect covers ≲ 2 texels on each axis so the lookup
is O(1) regardless of AABB size.
##### BVH integration
The same test runs on interior BVH node AABBs before leaf expansion,
so an occluded subtree skips all its instances in one shot. This is
where most of the per-frame cost savings show up on interior shots —
rejecting a 500-instance BVH subtree costs one 8-corner projection.
##### Tuning knobs
| Var | Default | Effect |
|-----|---------|--------|
| `IFC_NO_HIZ` | unset | `1` disables HiZ entirely (forces the Phase-3B-only path). |
| `IFC_HIZ_SIZE` | `256` | Base pyramid width in texels; height tracks viewport aspect. Raise for more accurate near-silhouette occlusion, lower to shrink readback. |
The stats overlay gains one counter, `hiz_rej`, showing how many
instances per frame the HiZ test rejected. On outdoor overview shots
it hovers near zero; on indoor shots it climbs into the hundreds of
thousands and the frame time drops accordingly.
##### Known caveats
- **1 frame stale.** The pyramid is aligned to last frame's view, so
when you whip the camera across the scene we may draw one frame of
stuff that the new view would have occluded. Invisible in practice
at 60 fps. We tried a 3-deep PBO ring for async readback (2-frame
stale) and it produced visible flicker on fast orbits — reverted.
- **Readback syncs the GPU.** `glGetTextureImage` is blocking.
Measured cost is well under a millisecond at 256×128; not a
bottleneck on the machines tested. Phase 3D's compute-shader cull
removes it entirely.
- **Doesn't move the needle on overview shots.** Those scenes are
CPU-bound on the cull traversal itself, not GPU-bound on drawing,
so cutting the drawn-triangle count in half is invisible in the
frame time. `hiz_rej` still rises modestly on overviews (the frustum
hull contains everything behind visible walls) but saved GPU work
is masked by CPU cost. HiZ pays off on interior views, where the
GPU *was* the bottleneck. If a project never leaves overview,
`IFC_NO_HIZ=1` shaves the ~1 ms of HiZ cost.
- **Transparent geometry would need special handling**, but the
current renderer doesn't have any, so no-op for now.
#### 3D. GPU-side culling via compute (longer-term)
@@ -598,7 +703,7 @@ Scene size Bottleneck Fix
100k500k CPU cull scan BVH (Phase 2) — done
500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull
+ Phase 3B LOD (done)
multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback)
```
## Roadmap
@@ -615,10 +720,10 @@ multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
- [x] Reflection-aware two-pass draw for mirrored placements
- [x] Backface culling (user-toggleable, default on)
- [x] `reorient-shells` enabled in iterator
- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`)
- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`)
- [x] Phase 3A — screen-space contribution culling
- [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`)
- [ ] **Phase 3C — Hierarchical-Z occlusion culling** (next)
- [ ] Phase 3D — GPU-side compute-shader culling
- [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback)
- [ ] **Phase 3D — GPU-side compute-shader culling** (next; replaces the readback)
- [ ] Vulkan/MoltenVK backend for macOS
- [ ] Embedded Python scripting console
+263 -1
View File
@@ -343,6 +343,10 @@ ViewportWindow::~ViewportWindow() {
if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_);
if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_);
if (pick_depth_rbo_) gl_->glDeleteRenderbuffers(1, &pick_depth_rbo_);
if (hiz_fbo_) gl_->glDeleteFramebuffers(1, &hiz_fbo_);
if (hiz_depth_tex_) gl_->glDeleteTextures(1, &hiz_depth_tex_);
if (hiz_resolve_fbo_) gl_->glDeleteFramebuffers(1, &hiz_resolve_fbo_);
if (hiz_resolve_depth_tex_) gl_->glDeleteTextures(1, &hiz_resolve_depth_tex_);
}
context_->doneCurrent();
}
@@ -821,6 +825,240 @@ void ViewportWindow::removeModel(uint32_t model_id) {
void ViewportWindow::setSelectedObjectId(uint32_t id) { selected_object_id_ = id; }
// --- HiZ occlusion culling (Phase 3C) -----------------------------------
// Baseline HiZ resolution. 256x128 is enough to cull big occluders
// (walls, slabs) reliably; finer detail doesn't help much because we're
// sampling the pyramid at the mip level where the AABB's rect is ~2
// texels anyway. Readback cost is ~128 KB/frame ≈ negligible.
// IFC_HIZ_SIZE=<N> overrides the width; height tracks aspect.
static int hizBaseWidth() {
static const int w = []{
const char* e = std::getenv("IFC_HIZ_SIZE");
return (e && *e) ? std::max(64, std::atoi(e)) : 256;
}();
return w;
}
static bool hizEnabled() {
static const bool disabled = []{
const char* e = std::getenv("IFC_NO_HIZ");
return e && e[0] == '1';
}();
return !disabled;
}
void ViewportWindow::buildHizPyramid() {
if (!gl_initialized_) return;
const int win_w = width() * devicePixelRatio();
const int win_h = height() * devicePixelRatio();
if (win_w <= 0 || win_h <= 0) return;
const int base_w = hizBaseWidth();
const int base_h = std::max(1, (base_w * win_h) / win_w);
// Depth format must match the default FBO's depth format for the blit
// to succeed — GL spec requires identical internal formats for depth
// blits. Qt's default surface uses 24-bit depth (setDepthBufferSize(24)
// in initGL), so we match with DEPTH_COMPONENT24 on both textures.
//
// Resolve target (full window size, single sample). Needed because
// GL also forbids scale-blitting from an MSAA source: resolve at 1:1
// first, then down-blit.
if (win_w != hiz_resolve_w_ || win_h != hiz_resolve_h_) {
if (hiz_resolve_fbo_) gl_->glDeleteFramebuffers(1, &hiz_resolve_fbo_);
if (hiz_resolve_depth_tex_) gl_->glDeleteTextures(1, &hiz_resolve_depth_tex_);
gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_resolve_depth_tex_);
gl_->glTextureStorage2D(hiz_resolve_depth_tex_, 1,
GL_DEPTH_COMPONENT24, win_w, win_h);
gl_->glCreateFramebuffers(1, &hiz_resolve_fbo_);
gl_->glNamedFramebufferTexture(hiz_resolve_fbo_, GL_DEPTH_ATTACHMENT,
hiz_resolve_depth_tex_, 0);
hiz_resolve_w_ = win_w;
hiz_resolve_h_ = win_h;
}
if (base_w != hiz_base_w_ || base_h != hiz_base_h_) {
if (hiz_fbo_) gl_->glDeleteFramebuffers(1, &hiz_fbo_);
if (hiz_depth_tex_) gl_->glDeleteTextures(1, &hiz_depth_tex_);
gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_depth_tex_);
gl_->glTextureStorage2D(hiz_depth_tex_, 1, GL_DEPTH_COMPONENT24,
base_w, base_h);
gl_->glCreateFramebuffers(1, &hiz_fbo_);
gl_->glNamedFramebufferTexture(hiz_fbo_, GL_DEPTH_ATTACHMENT,
hiz_depth_tex_, 0);
hiz_base_w_ = base_w;
hiz_base_h_ = base_h;
hiz_depth_readback_.assign(base_w * base_h, 1.0f);
// Build the mip-offset table. Level 0 = base_w x base_h.
hiz_mip_offset_.clear();
hiz_mip_w_.clear();
hiz_mip_h_.clear();
uint32_t off = 0;
int mw = base_w, mh = base_h;
while (mw >= 1 && mh >= 1) {
hiz_mip_offset_.push_back(off);
hiz_mip_w_.push_back(static_cast<uint32_t>(mw));
hiz_mip_h_.push_back(static_cast<uint32_t>(mh));
off += static_cast<uint32_t>(mw) * static_cast<uint32_t>(mh);
if (mw == 1 && mh == 1) break;
mw = std::max(1, mw / 2);
mh = std::max(1, mh / 2);
}
hiz_pyramid_.assign(off, 1.0f);
}
// Two-step: MSAA default-fb → full-size SS resolve, then SS → down-scaled.
// GL forbids scaling a blit whose source is multisampled, and also
// requires matching depth internal formats — hence this dance.
gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, hiz_resolve_fbo_);
gl_->glBlitFramebuffer(0, 0, win_w, win_h,
0, 0, win_w, win_h,
GL_DEPTH_BUFFER_BIT, GL_NEAREST);
gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, hiz_resolve_fbo_);
gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, hiz_fbo_);
gl_->glBlitFramebuffer(0, 0, win_w, win_h,
0, 0, hiz_base_w_, hiz_base_h_,
GL_DEPTH_BUFFER_BIT, GL_NEAREST);
gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
// One-shot diagnostic so blit failures aren't silent. We only warn
// the first handful of times — GL errors can pile up and spam.
static int err_warn_budget = 3;
if (err_warn_budget > 0) {
GLenum e = gl_->glGetError();
if (e != GL_NO_ERROR) {
qWarning("HiZ blit/readback GL error 0x%04x (win %dx%d → %dx%d → %dx%d)",
e, win_w, win_h, win_w, win_h, hiz_base_w_, hiz_base_h_);
--err_warn_budget;
}
}
// Synchronous readback into level 0 of the pyramid. At 256x128 this
// is ~128 KB and the driver copy is fast enough not to matter in
// practice; PBO-ring async was tried and made orbiting flicker worse
// (2-frame-stale depth vs 1-frame).
gl_->glGetTextureImage(hiz_depth_tex_, 0, GL_DEPTH_COMPONENT, GL_FLOAT,
static_cast<GLsizei>(hiz_depth_readback_.size() * sizeof(float)),
hiz_depth_readback_.data());
// Copy level 0 into the pyramid, then max-reduce subsequent levels.
std::memcpy(hiz_pyramid_.data() + hiz_mip_offset_[0],
hiz_depth_readback_.data(),
hiz_depth_readback_.size() * sizeof(float));
for (size_t lvl = 1; lvl < hiz_mip_offset_.size(); ++lvl) {
const uint32_t pw = hiz_mip_w_[lvl - 1];
const uint32_t ph = hiz_mip_h_[lvl - 1];
const uint32_t cw = hiz_mip_w_[lvl];
const uint32_t ch = hiz_mip_h_[lvl];
const float* parent = hiz_pyramid_.data() + hiz_mip_offset_[lvl - 1];
float* child = hiz_pyramid_.data() + hiz_mip_offset_[lvl];
for (uint32_t y = 0; y < ch; ++y) {
const uint32_t py0 = std::min(2 * y, ph - 1);
const uint32_t py1 = std::min(2 * y + 1, ph - 1);
for (uint32_t x = 0; x < cw; ++x) {
const uint32_t px0 = std::min(2 * x, pw - 1);
const uint32_t px1 = std::min(2 * x + 1, pw - 1);
const float a = parent[py0 * pw + px0];
const float b = parent[py0 * pw + px1];
const float c = parent[py1 * pw + px0];
const float d = parent[py1 * pw + px1];
child[y * cw + x] = std::max(std::max(a, b), std::max(c, d));
}
}
}
hiz_vp_ = proj_matrix_ * view_matrix_;
hiz_vp_valid_ = true;
}
bool ViewportWindow::aabbOccludedByHiz(const float mn[3], const float mx[3]) const {
if (!hiz_vp_valid_ || hiz_pyramid_.empty()) return false;
// Project all 8 corners through the HiZ frame's VP (stored last frame).
// Track NDC min/max over x, y, z. If any corner has w <= 0, the AABB
// straddles the near plane and we skip (behaves like "not occluded").
float sx_min = std::numeric_limits<float>::infinity();
float sx_max = -std::numeric_limits<float>::infinity();
float sy_min = std::numeric_limits<float>::infinity();
float sy_max = -std::numeric_limits<float>::infinity();
float sz_min = std::numeric_limits<float>::infinity();
const float* vp = hiz_vp_.constData(); // column-major
for (int c = 0; c < 8; ++c) {
const float x = (c & 1) ? mx[0] : mn[0];
const float y = (c & 2) ? mx[1] : mn[1];
const float z = (c & 4) ? mx[2] : mn[2];
const float cx = vp[0]*x + vp[4]*y + vp[8]*z + vp[12];
const float cy = vp[1]*x + vp[5]*y + vp[9]*z + vp[13];
const float cz = vp[2]*x + vp[6]*y + vp[10]*z + vp[14];
const float cw = vp[3]*x + vp[7]*y + vp[11]*z + vp[15];
if (cw <= 1e-4f) return false; // near-plane straddle
const float inv = 1.0f / cw;
const float nx = cx * inv;
const float ny = cy * inv;
const float nz = cz * inv;
if (nx < sx_min) sx_min = nx; if (nx > sx_max) sx_max = nx;
if (ny < sy_min) sy_min = ny; if (ny > sy_max) sy_max = ny;
if (nz < sz_min) sz_min = nz;
}
if (sx_max < -1.0f || sx_min > 1.0f ||
sy_max < -1.0f || sy_min > 1.0f) return false;
if (sz_min < -1.0f) return false;
sx_min = std::max(sx_min, -1.0f);
sx_max = std::min(sx_max, 1.0f);
sy_min = std::max(sy_min, -1.0f);
sy_max = std::min(sy_max, 1.0f);
const float u_min = 0.5f * (sx_min + 1.0f);
const float u_max = 0.5f * (sx_max + 1.0f);
const float v_min = 0.5f * (sy_min + 1.0f);
const float v_max = 0.5f * (sy_max + 1.0f);
const float aabb_near_depth = 0.5f * (sz_min + 1.0f);
// Pick mip level where the projected rect covers at most 2 texels on
// each axis; sample the max over the covered texels there.
const float px_w = (u_max - u_min) * static_cast<float>(hiz_base_w_);
const float px_h = (v_max - v_min) * static_cast<float>(hiz_base_h_);
int mip = 0;
while ((int)hiz_mip_offset_.size() - 1 > mip &&
((px_w / (1 << mip)) > 2.0f || (px_h / (1 << mip)) > 2.0f)) {
++mip;
}
const uint32_t mw = hiz_mip_w_[mip];
const uint32_t mh = hiz_mip_h_[mip];
int x0 = static_cast<int>(std::floor(u_min * mw));
int x1 = static_cast<int>(std::ceil (u_max * mw));
int y0 = static_cast<int>(std::floor(v_min * mh));
int y1 = static_cast<int>(std::ceil (v_max * mh));
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > (int)mw) x1 = mw;
if (y1 > (int)mh) y1 = mh;
if (x1 <= x0 || y1 <= y0) return false;
const float* level = hiz_pyramid_.data() + hiz_mip_offset_[mip];
float hiz_max = 0.0f;
for (int y = y0; y < y1; ++y) {
const float* row = level + static_cast<size_t>(y) * mw;
for (int x = x0; x < x1; ++x) {
if (row[x] > hiz_max) hiz_max = row[x];
}
}
// AABB's closest point must be strictly farther than everything drawn
// in the region for it to be fully occluded.
return aabb_near_depth > hiz_max;
}
uint32_t ViewportWindow::pickObjectAt(int x, int y) {
if (!gl_initialized_) return 0;
context_->makeCurrent(this);
@@ -936,10 +1174,19 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6]
: std::numeric_limits<float>::infinity();
};
// HiZ occlusion is skipped entirely when the pick pass runs
// (min_pixel_radius == 0 on that path), when the user disables it via
// env var, or before the first pyramid has been built.
const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_valid_;
auto test_and_push = [&](uint32_t inst_idx) {
const InstanceCpu& inst = m.instances[inst_idx];
if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return;
if (!contributionPasses(inst.world_aabb_min, inst.world_aabb_max)) return;
if (hiz_on && aabbOccludedByHiz(inst.world_aabb_min, inst.world_aabb_max)) {
++hiz_reject_count_;
return;
}
if (inst.mesh_id >= m.meshes.size()) return;
const MeshInfo& mesh = m.meshes[inst.mesh_id];
const bool want_lod1 = mesh.lod1_index_count > 0 &&
@@ -966,6 +1213,12 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6]
// Contribution cull the whole subtree: if the node's enclosing
// sphere is below threshold, every child is too.
if (!contributionPasses(n.aabb_min, n.aabb_max)) continue;
// HiZ cull the whole subtree: if the node AABB is fully
// occluded, every leaf is too. The conservative test (AABB
// near-depth vs max pyramid depth) never rejects a visible
// parent wrongly even when some children could have peeked
// through.
if (hiz_on && aabbOccludedByHiz(n.aabb_min, n.aabb_max)) continue;
if (n.count > 0) {
for (uint32_t k = 0; k < n.count; ++k) {
uint32_t item_idx = m.bvh.item_indices[n.right_or_first + k];
@@ -1108,6 +1361,7 @@ void ViewportWindow::render() {
visible_objects_ = 0;
gl_draw_calls_ = 0;
indirect_sub_draws_ = 0;
hiz_reject_count_ = 0;
// Start each frame with CCW-is-front; the two-pass draw below flips
// back and forth. Harmless when culling is off.
@@ -1178,6 +1432,13 @@ void ViewportWindow::render() {
renderAxisGizmo();
// Build HiZ from this frame's resolved depth for next frame's cull.
// Synchronous glReadPixels inside — cost ~0.5 ms at 256x128 on a
// mid-range dGPU. Skippable via IFC_NO_HIZ=1.
if (hizEnabled()) {
buildHizPyramid();
}
context_->swapBuffers(this);
float dt = frame_clock_.restart() / 1000.0f;
@@ -1215,12 +1476,13 @@ void ViewportWindow::render() {
emit frameStatsUpdated(stats);
qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u "
"meshes %u gl_draws %u sub_draws %u "
"meshes %u gl_draws %u sub_draws %u hiz_rej %u "
"vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)",
last_fps_, 1000.0f / last_fps_,
visible_objects_, total_obj,
visible_triangles_, total_tri,
total_meshes, gl_draw_calls_, indirect_sub_draws_,
hiz_reject_count_,
(total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0),
total_vbo / (1024.0*1024.0),
total_ebo / (1024.0*1024.0),
+40
View File
@@ -172,6 +172,20 @@ private:
void buildShaders();
void buildAxisGizmo();
void setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo);
// Resolve the default framebuffer's MSAA depth into a single-sample
// texture, read it back, and max-reduce a mip pyramid on the CPU. The
// resulting pyramid is stored in hiz_pyramid_ along with the VP matrix
// used to draw it; next frame's cullAndUploadVisible can test AABBs
// against it. Synchronous readback — at 256×128 the cost is sub-ms
// and not a measured bottleneck; Phase 3D's compute-shader cull will
// eliminate the readback entirely.
void buildHizPyramid();
// True if the AABB is fully occluded by the previous frame's depth.
// Returns false when the HiZ is invalid, the AABB crosses the near
// plane, or the projection falls outside NDC.
bool aabbOccludedByHiz(const float mn[3], const float mx[3]) const;
bool growModelVbo(ModelGpuData& m, size_t needed_total);
bool growModelEbo(ModelGpuData& m, size_t needed_total);
bool growModelSsbo(ModelGpuData& m, size_t needed_total);
@@ -219,6 +233,32 @@ private:
int pick_width_ = 0;
int pick_height_ = 0;
// HiZ occlusion culling (Phase 3C).
//
// Each frame after the main draw we blit the MSAA depth buffer down
// into a single-sample depth texture (hiz_fbo_ / hiz_depth_tex_), then
// glReadPixels it into hiz_depth_readback_. We max-reduce that into a
// mip pyramid (hiz_pyramid_) and remember the VP matrix used
// (hiz_vp_ + hiz_vp_valid_) so next frame's cull can test AABBs
// against a slightly-stale depth. Skipped for the pick pass and when
// IFC_NO_HIZ=1.
GLuint hiz_fbo_ = 0;
GLuint hiz_depth_tex_ = 0;
GLuint hiz_resolve_fbo_ = 0; // full-size single-sample resolve
GLuint hiz_resolve_depth_tex_ = 0;
int hiz_resolve_w_ = 0;
int hiz_resolve_h_ = 0;
int hiz_base_w_ = 0;
int hiz_base_h_ = 0;
std::vector<float> hiz_depth_readback_; // hiz_base_w_ * hiz_base_h_ floats
std::vector<float> hiz_pyramid_; // concatenated mip levels
std::vector<uint32_t> hiz_mip_offset_; // into hiz_pyramid_
std::vector<uint32_t> hiz_mip_w_;
std::vector<uint32_t> hiz_mip_h_;
QMatrix4x4 hiz_vp_;
bool hiz_vp_valid_ = false;
uint32_t hiz_reject_count_ = 0; // per-frame stat
// Per-frame stats
uint32_t visible_triangles_ = 0;
uint32_t visible_objects_ = 0;