From 61726e00a4d494e86f7809f04e4a87c35bd6fe89 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 May 2026 14:05:58 +1000 Subject: [PATCH] wgpu backend: CPU frustum cull + per-mesh draw compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 6 of the wgpu port. Replaces the one-draw-per-(mesh, instance) loop with a CPU cull pass that survives one drawIndexed per non-empty mesh with packed instanceCount. Adds to WgpuModelGpuData: - visible_buffer: u32[] storage SSBO, pre-sized to instance_count at applyCachedModel so the bind group reference never invalidates. Re-uploaded each frame via wgpuQueueWriteBuffer. - mesh_draws: per-mesh schedule (first_instance, instance_count, first_index, base_vertex, index_count). instance_count==0 means the mesh contributed nothing this frame and the draw is elided entirely. cullModelCpu per-frame: - Extract 6 frustum planes from the same VP we write into the uniform. WebGPU clip-space z is [0, 1], so near plane = matrix row 2 (not row 3 + row 2 as in GL); rest of the derivation is standard. - Per-instance AABB-vs-frustum test using the p-vertex shortcut (cheapest correct early-out for AABBs). - Bucket survivors by mesh_id; flatten into a contiguous u32 list; upload via wgpuQueueWriteBuffer. Per-mesh slice is [first_instance, first_instance + instance_count). WGSL adds @group(1) @binding(3) var visible: array and an extra indirection: instance_idx = visible[iid]; the rest of the shader is unchanged. firstInstance on each drawIndexed offsets into visible[], so each mesh reads its own slice. Verified two ways: 1. basic.ifc (3 instances, all on-screen) renders pixel-identically to pre-stage-6 — proves cull keeps everything it should. 2. basic.ifc + a synthetic instance placed at (100, 100, 100) is culled cleanly: only the cube renders, the far quad is rejected by the frustum test. Proves cull actually rejects out-of-frustum geometry rather than passing everything through. Contribution culling, HiZ, and LOD selection arrive in stages 7 and 8; they all hook into the same cullModelCpu seam. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-wgpu/WgpuModelGpuData.h | 24 ++- src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 225 +++++++++++++++++++--- src/ifcviewer-wgpu/WgpuViewportWindow.h | 6 + 3 files changed, 222 insertions(+), 33 deletions(-) diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index 1e261426f3..2dd16c8f2a 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -51,10 +51,32 @@ struct WgpuModelGpuData { // recompose from placement_transformation when stage matrices change. WGPUBuffer instance_storage = nullptr; - // Bind group binding the three storage buffers above (group=1 in the + // u32[] of visible instance indices, repacked per frame by the CPU cull + // into per-mesh contiguous slices. Sized for the worst case + // (instance_count entries) at applyCachedModel time so we never have to + // recreate it (and the bind group that references it) mid-frame. + WGPUBuffer visible_buffer = nullptr; + size_t visible_buffer_capacity = 0; // entries (u32 count), not bytes + + // Bind group binding the four storage buffers above (group=1 in the // main pipeline). Built in applyCachedModel after the buffers exist. WGPUBindGroup bind_group = nullptr; + // One per mesh, populated per frame by cullAndCompact. instance_count==0 + // means the mesh contributes nothing this frame and the draw is skipped. + struct MeshDraw { + uint32_t first_instance = 0; // offset into visible_buffer + uint32_t instance_count = 0; + uint32_t first_index = 0; // index buffer offset (in u32 indices) + int32_t base_vertex = 0; // added to every fetched vertex_index + uint32_t index_count = 0; + }; + std::vector mesh_draws; // size = meshes.size() after first frame + + // Scratch reused each frame so per-frame cull doesn't allocate. Sized + // to instance_count entries on first use; never shrunk. + std::vector visible_flat_scratch; + // Size mirrors for stats / range checks. size_t vertex_bytes = 0; uint32_t index_count = 0; diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index 9cda95be9c..3bbffc4590 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -23,6 +23,9 @@ #include #include #include +#include +#include +#include #include // wgpu-native extensions (logging, MULTI_DRAW_INDIRECT, …) @@ -53,6 +56,11 @@ static_assert(sizeof(FrameUniforms) == 16 * sizeof(float) + 4 * 4 * sizeof(float // stride in the capture path. static constexpr uint64_t WGPU_BYTES_PER_ROW_ALIGN = 256; +// Forward declaration — defined below alongside updateFrameUniforms. Used +// by render() to extract camera/frustum state without duplicating the math. +static QVector3D orbitEye(const float target[3], float dist, + float yaw_deg, float pitch_deg); + // ----------------------------------------------------------------------------- // Small helpers // ----------------------------------------------------------------------------- @@ -114,12 +122,16 @@ void releaseWgpuModelGpuData(WgpuModelGpuData& m) { if (m.index_buffer) { wgpuBufferRelease(m.index_buffer); m.index_buffer = nullptr; } if (m.mesh_storage) { wgpuBufferRelease(m.mesh_storage); m.mesh_storage = nullptr; } if (m.instance_storage) { wgpuBufferRelease(m.instance_storage); m.instance_storage = nullptr; } + if (m.visible_buffer) { wgpuBufferRelease(m.visible_buffer); m.visible_buffer = nullptr; } m.vertex_bytes = 0; m.index_count = 0; m.mesh_count = 0; m.instance_count = 0; + m.visible_buffer_capacity = 0; m.meshes.clear(); m.instances.clear(); + m.mesh_draws.clear(); + m.visible_flat_scratch.clear(); } // ----------------------------------------------------------------------------- @@ -160,6 +172,10 @@ struct FrameUniforms { @group(1) @binding(0) var vertices: array; @group(1) @binding(1) var meshes: array; @group(1) @binding(2) var instances: array; +// Per-frame compacted visible list: visible[firstInstance + i] picks the +// real instance for this draw slot. firstInstance is set per drawIndexed +// call so each mesh reads its own contiguous slice of `visible`. +@group(1) @binding(3) var visible: array; struct VsOut { @builtin(position) clip_pos: vec4, @@ -188,7 +204,8 @@ fn octDecode(e: vec2) -> vec3 { @vertex fn vs_main(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VsOut { - let inst = instances[iid]; + let inst_idx = visible[iid]; + let inst = instances[inst_idx]; let mq = meshes[inst.mesh_id]; let w0 = vertices[vid * 3u + 0u]; @@ -386,9 +403,24 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { WGPUBufferUsage_Storage, "model.instance_storage"); + // Pre-size the visible buffer for the worst case (every instance visible). + // Per-frame cull writes a u32 list into it via wgpuQueueWriteBuffer; we + // never grow it so the bind group reference stays valid for the model's + // lifetime. Minimum size 4 bytes — wgpu rejects zero-sized buffers. + const size_t visible_bytes = std::max( + size_t(data.instances.size()) * sizeof(uint32_t), 4u); + WGPUBufferDescriptor vb_desc = {}; + vb_desc.size = visible_bytes; + vb_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; + vb_desc.label = svFromCStr("model.visible_buffer"); + m.visible_buffer = wgpuDeviceCreateBuffer(device_, &vb_desc); + m.visible_buffer_capacity = std::max(data.instances.size(), 1u); + // Hand off CPU mirrors (cull / picking will need them later). m.meshes = std::move(data.meshes); m.instances = std::move(data.instances); + m.mesh_draws.assign(m.meshes.size(), WgpuModelGpuData::MeshDraw{}); + m.visible_flat_scratch.reserve(m.instances.size()); auto [inserted, _] = models_gpu_.emplace(model_id, std::move(m)); WgpuModelGpuData& mref = inserted->second; @@ -666,6 +698,120 @@ void WgpuViewportWindow::configureSurface(int width_px, int height_px) { ensureDepthTexture(width_px, height_px); } +// ----------------------------------------------------------------------------- +// CPU frustum cull + per-mesh compaction +// ----------------------------------------------------------------------------- +// +// Plane extraction follows the standard "rows of the VP matrix" derivation, +// adjusted for WebGPU's [0, 1] clip-space z (near plane = row 2, not row 3 +// + row 2 as in GL). Planes are stored as (a, b, c, d) with the convention +// a*x + b*y + c*z + d >= 0 meaning the point is inside. +// +// VP is column-major float[16] (Qt convention): element [c*4 + r] is column +// c, row r. row(i) = (vp[0*4+i], vp[1*4+i], vp[2*4+i], vp[3*4+i]). + +static inline void rowVec(const float vp[16], int row, float out[4]) { + out[0] = vp[0 * 4 + row]; + out[1] = vp[1 * 4 + row]; + out[2] = vp[2 * 4 + row]; + out[3] = vp[3 * 4 + row]; +} + +static inline void planeNormalize(float p[4]) { + const float len = std::sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]); + if (len > 0.0f) { + const float inv = 1.0f / len; + p[0] *= inv; p[1] *= inv; p[2] *= inv; p[3] *= inv; + } +} + +static void extractFrustumPlanes(const float vp[16], float planes[6][4]) { + float r0[4], r1[4], r2[4], r3[4]; + rowVec(vp, 0, r0); + rowVec(vp, 1, r1); + rowVec(vp, 2, r2); + rowVec(vp, 3, r3); + + // left = r3 + r0 + // right = r3 - r0 + // bottom = r3 + r1 + // top = r3 - r1 + // near = r2 (WebGPU clip z >= 0) + // far = r3 - r2 + for (int i = 0; i < 4; ++i) { + planes[0][i] = r3[i] + r0[i]; + planes[1][i] = r3[i] - r0[i]; + planes[2][i] = r3[i] + r1[i]; + planes[3][i] = r3[i] - r1[i]; + planes[4][i] = r2[i]; + planes[5][i] = r3[i] - r2[i]; + } + for (int p = 0; p < 6; ++p) planeNormalize(planes[p]); +} + +// Returns false iff the AABB is fully outside any one plane (early-rejects +// trivially-invisible instances). May return true for boxes that straddle +// the frustum — that's fine, those still need to draw. +static bool aabbInFrustum(const float mn[3], const float mx[3], + const float planes[6][4]) { + for (int p = 0; p < 6; ++p) { + const float a = planes[p][0], b = planes[p][1], c = planes[p][2], d = planes[p][3]; + // p-vertex: the AABB corner furthest along the plane normal. + const float px = (a >= 0.0f) ? mx[0] : mn[0]; + const float py = (b >= 0.0f) ? mx[1] : mn[1]; + const float pz = (c >= 0.0f) ? mx[2] : mn[2]; + if (a * px + b * py + c * pz + d < 0.0f) return false; + } + return true; +} + +void WgpuViewportWindow::cullModelCpu(WgpuModelGpuData& m, const float planes[6][4]) { + if (m.instances.empty() || m.meshes.empty() || !m.visible_buffer) { + for (auto& d : m.mesh_draws) d.instance_count = 0; + return; + } + + // Per-mesh visible-instance buckets. Allocated once per cull from scratch + // vectors held on the model (no fresh heap on the per-frame path). + static thread_local std::vector> per_mesh_visible; + if (per_mesh_visible.size() < m.meshes.size()) per_mesh_visible.resize(m.meshes.size()); + for (size_t mi = 0; mi < m.meshes.size(); ++mi) per_mesh_visible[mi].clear(); + + for (uint32_t i = 0; i < uint32_t(m.instances.size()); ++i) { + const auto& inst = m.instances[i]; + if (inst.mesh_id >= m.meshes.size()) continue; + if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) continue; + per_mesh_visible[inst.mesh_id].push_back(i); + } + + // Flatten into m.visible_flat_scratch and populate per-mesh draws. + m.visible_flat_scratch.clear(); + m.mesh_draws.assign(m.meshes.size(), WgpuModelGpuData::MeshDraw{}); + for (uint32_t mi = 0; mi < m.meshes.size(); ++mi) { + const MeshInfo& mesh = m.meshes[mi]; + WgpuModelGpuData::MeshDraw& d = m.mesh_draws[mi]; + d.first_instance = uint32_t(m.visible_flat_scratch.size()); + d.instance_count = uint32_t(per_mesh_visible[mi].size()); + d.first_index = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t)); + d.base_vertex = int32_t(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES); + d.index_count = mesh.index_count; + for (uint32_t inst_idx : per_mesh_visible[mi]) { + m.visible_flat_scratch.push_back(inst_idx); + } + } + + // Upload the visible list. Round up to 4-byte multiple (always true for + // u32 arrays). Empty visible list still uploads one zero so the bind + // group has something well-defined; the per-mesh loop will skip the draw. + const size_t bytes_to_upload = std::max( + m.visible_flat_scratch.size() * sizeof(uint32_t), sizeof(uint32_t)); + static const uint32_t zero = 0; + const void* src = m.visible_flat_scratch.empty() + ? static_cast(&zero) + : static_cast(m.visible_flat_scratch.data()); + wgpuQueueWriteBuffer(queue_, m.visible_buffer, 0, src, bytes_to_upload); +} + void WgpuViewportWindow::render() { WGPUSurfaceTexture surf_tex = {}; wgpuSurfaceGetCurrentTexture(surface_, &surf_tex); @@ -693,6 +839,29 @@ void WgpuViewportWindow::render() { updateFrameUniforms(); + // Per-frame cull: extract frustum planes from the same VP we just wrote + // into the uniform, then run cullModelCpu on every visible model. The + // cull writes its results directly into each model's visible_buffer via + // wgpuQueueWriteBuffer — these writes are sequenced before the draw + // commands we encode next. + { + const QVector3D target(camera_target_[0], camera_target_[1], camera_target_[2]); + const QVector3D eye = orbitEye(camera_target_, camera_distance_, + camera_yaw_deg_, camera_pitch_deg_); + QMatrix4x4 v; v.lookAt(eye, target, QVector3D(0.0f, 0.0f, 1.0f)); + const float aspect = (configured_h_ > 0) + ? float(configured_w_) / float(configured_h_) : 1.0f; + QMatrix4x4 p; p.perspective(camera_fov_y_deg_, aspect, camera_near_, camera_far_); + QMatrix4x4 z; z(2, 2) = 0.5f; z(2, 3) = 0.5f; + const QMatrix4x4 vp = z * p * v; + float planes[6][4]; + extractFrustumPlanes(vp.constData(), planes); + for (auto& [mid, m] : models_gpu_) { + if (m.hidden) continue; + cullModelCpu(m, planes); + } + } + WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr); WGPURenderPassColorAttachment color = {}; @@ -736,27 +905,20 @@ void WgpuViewportWindow::render() { WGPUIndexFormat_Uint32, 0, WGPU_WHOLE_SIZE); - // One draw per (mesh, instance) pair. firstInstance carries the - // instance's absolute index into instance_storage so the vertex - // shader can fetch its transform; baseVertex offsets vertex pulling - // into the mesh's slice of the model's vertex storage. - for (uint32_t mi = 0; mi < m.meshes.size(); ++mi) { - const MeshInfo& mesh = m.meshes[mi]; - if (mesh.index_count == 0 || mesh.instance_count == 0) continue; - - const uint32_t first_index = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t)); - const int32_t base_vertex = int32_t(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES); - - for (uint32_t i = 0; i < mesh.instance_count; ++i) { - const uint32_t inst_idx = mesh.first_instance + i; - wgpuRenderPassEncoderDrawIndexed( - pass, - mesh.index_count, - 1, // instanceCount - first_index, - base_vertex, - inst_idx); // firstInstance == @builtin(instance_index) - } + // One drawIndexed per non-empty mesh. instanceCount is the number + // of frustum-surviving instances of this mesh; firstInstance is + // their offset into m.visible_buffer (consumed by the WGSL + // `visible[iid]` indirection). All-instances-culled meshes are + // skipped — no draw call issued at all. + for (const auto& d : m.mesh_draws) { + if (d.instance_count == 0 || d.index_count == 0) continue; + wgpuRenderPassEncoderDrawIndexed( + pass, + d.index_count, + d.instance_count, + d.first_index, + d.base_vertex, + d.first_instance); } } } @@ -894,14 +1056,14 @@ bool WgpuViewportWindow::buildPipelines() { frame_bgl_desc.label = svFromCStr("ifcviewer-wgpu.frame_bgl"); frame_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &frame_bgl_desc); - WGPUBindGroupLayoutEntry model_entries[3] = {}; - for (int i = 0; i < 3; ++i) { + WGPUBindGroupLayoutEntry model_entries[4] = {}; + for (int i = 0; i < 4; ++i) { model_entries[i].binding = uint32_t(i); model_entries[i].visibility = WGPUShaderStage_Vertex; model_entries[i].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; } WGPUBindGroupLayoutDescriptor model_bgl_desc = {}; - model_bgl_desc.entryCount = 3; + model_bgl_desc.entryCount = 4; model_bgl_desc.entries = model_entries; model_bgl_desc.label = svFromCStr("ifcviewer-wgpu.model_bgl"); model_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &model_bgl_desc); @@ -989,12 +1151,12 @@ void WgpuViewportWindow::buildModelBindGroup(WgpuModelGpuData& m) { wgpuBindGroupRelease(m.bind_group); m.bind_group = nullptr; } - if (!m.vertex_storage || !m.mesh_storage || !m.instance_storage) { + if (!m.vertex_storage || !m.mesh_storage || !m.instance_storage || !m.visible_buffer) { // Empty model — no bind group needed; the draw loop will skip it. return; } - WGPUBindGroupEntry entries[3] = {}; + WGPUBindGroupEntry entries[4] = {}; entries[0].binding = 0; entries[0].buffer = m.vertex_storage; entries[0].size = WGPU_WHOLE_SIZE; @@ -1004,10 +1166,13 @@ void WgpuViewportWindow::buildModelBindGroup(WgpuModelGpuData& m) { entries[2].binding = 2; entries[2].buffer = m.instance_storage; entries[2].size = WGPU_WHOLE_SIZE; + entries[3].binding = 3; + entries[3].buffer = m.visible_buffer; + entries[3].size = WGPU_WHOLE_SIZE; WGPUBindGroupDescriptor desc = {}; desc.layout = model_bgl_; - desc.entryCount = 3; + desc.entryCount = 4; desc.entries = entries; desc.label = svFromCStr("ifcviewer-wgpu.model_bind_group"); m.bind_group = wgpuDeviceCreateBindGroup(device_, &desc); @@ -1059,10 +1224,6 @@ void WgpuViewportWindow::releaseDepthTexture() { // rotation about Z (positive = anticlockwise looking down +Z); pitch is // elevation above the XY plane. -#include -#include -#include - static QVector3D orbitEye(const float target[3], float dist, float yaw_deg, float pitch_deg) { const float yaw = qDegreesToRadians(yaw_deg); diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index dfafbce92d..c686f4eb9a 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -102,6 +102,12 @@ private: void flushPendingSidecarQueue(); bool computeSceneAabb(float mn[3], float mx[3]) const; + // Cull `m`'s instances against the supplied frustum planes (world-space, + // ax+by+cz+d >= 0 means inside), bucket survivors by mesh_id, and write + // the flat visible-index list into m.visible_buffer via wgpuQueueWriteBuffer. + // After return, m.mesh_draws is the per-mesh draw schedule for the frame. + void cullModelCpu(WgpuModelGpuData& m, const float planes[6][4]); + bool wgpu_initialized_ = false; bool surface_configured_ = false; int configured_w_ = 0;