mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 17:58:20 +00:00
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:
+117
-12
@@ -565,15 +565,120 @@ typical cost is 100–600 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 3–10× 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, 80–95 % 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 (8–9 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
|
||||
100k–500k 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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user