mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 02:47:48 +00:00
126d2d4c06
WGPU_SPATIAL_BUCKETS=1 swaps the applyCachedModelStreaming planner from mesh-keyed Morton+greedy to octree-style instance bucketing. Default behaviour unchanged (env var unset → mesh-keyed planner runs). Phase 1 of #55 / #56. The mesh-keyed planner produces chunks whose AABBs are the union of all instances of the chunk's meshes — for heavily-deduplicated IFC meshes (a "standard floor tile" used 800 times across a federation) the mesh's "centroid" is a mean of scattered instance positions and the chunk's AABB ends up spanning the entire model. Symptom: chunk-level frustum cull rarely fires (AABB always intersects view), and the screen-area priority metric under-rates big-AABB chunks because their corners straddle the near plane. Visible objects pop in/out as the camera tilts, even though they're fully on screen. The spatial planner bucketises INSTANCES directly. Each leaf bucket contains its instance list + the unique mesh data those instances reference. A mesh whose instances scatter into multiple buckets gets its vertex/index data uploaded into multiple pool slices — duplication is the cost for tight bucket AABBs. For IFC this is acceptable: heavily-shared meshes tend to be small (fittings, fasteners), so per-bucket duplication adds tens-of-MB not GB. Octree implementation (planSpatialChunks): - work-stack subdivision: for each (instance subset, AABB), split into 8 octants around centre and recurse - stop conditions: bucket fits WGPU_CHUNK_VERTEX_BYTES_LIMIT for union vertex bytes AND ≤ spatial_max_instances_ instances; OR single instance left; OR every instance falls into the same octant (pathological — emit as leaf rather than infinite recurse) - spatial_max_instances_ default 5000, overridable via WGPU_SPATIAL_BUCKET_MAX_INSTS env var so the prototype can be swept without rebuilding Data-model adjustment beyond what dc2927997 prepared: - Per-chunk per-mesh chunk-local offset table (chunk_mesh_offsets) built during the chunk-construction loop. The mesh-keyed per-mesh global arrays (mesh_chunk_idx etc.) still get populated for legacy reads, but under spatial bucketing they're overwritten when the same mesh appears in multiple chunks — harmless because cull reads the per-instance arrays exclusively (per dc2927997). - Post-construction, per-instance arrays are populated from chunk_mesh_offsets via (instance_to_chunk[i], inst.mesh_id) lookup. Mesh-keyed planner derives identical values to before (pixel-identical); spatial planner now writes the correct per-bucket offsets even when the mesh appears in multiple chunks. basic.ifc parity on all three paths confirmed (non-streaming mesh-keyed, streaming mesh-keyed, streaming spatial all produce 0 pixel diff vs the reference). Spatial planner produced 1 bucket on basic.ifc (3 instances, well under thresholds) as expected. Non-streaming applyCachedModel left unchanged — the prototype targets the streaming path which is where the federation-scale missing-objects issue lives. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
698 lines
36 KiB
C++
698 lines
36 KiB
C++
/********************************************************************************
|
||
* *
|
||
* This file is part of IfcOpenShell. *
|
||
* *
|
||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||
* it under the terms of the Lesser GNU General Public License as published by *
|
||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||
* (at your option) any later version. *
|
||
* *
|
||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||
* Lesser GNU General Public License for more details. *
|
||
* *
|
||
* You should have received a copy of the Lesser GNU General Public License *
|
||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||
* *
|
||
********************************************************************************/
|
||
|
||
#ifndef WGPUVIEWPORTWINDOW_H
|
||
#define WGPUVIEWPORTWINDOW_H
|
||
|
||
#include <QWindow>
|
||
#include <QColor>
|
||
#include <QElapsedTimer>
|
||
#include <QMatrix4x4>
|
||
#include <QPoint>
|
||
#include <QSet>
|
||
#include <QString>
|
||
|
||
#include <webgpu/webgpu.h>
|
||
|
||
#include <cstdint>
|
||
#include <deque>
|
||
#include <unordered_map>
|
||
|
||
#include "SidecarCache.h"
|
||
#include "WgpuBufferPool.h"
|
||
#include "WgpuModelGpuData.h"
|
||
#include "WgpuSelectionState.h"
|
||
#include "WgpuStreamingThread.h"
|
||
#include "WgpuVisibilityState.h"
|
||
|
||
// Stage-2 wgpu viewport: opens a native QWindow, brings up a wgpu instance/
|
||
// adapter/device, configures a surface against the platform-native window
|
||
// handle, and clears to background_color_ on every UpdateRequest. Models
|
||
// loaded from `.ifcview` sidecars are uploaded as wgpu buffers (no draw
|
||
// path yet — that's stage 3).
|
||
//
|
||
// Mirrors the lifecycle shape of the GL ViewportWindow so subsequent stages
|
||
// can grow this into a full IFC renderer without restructuring the host.
|
||
class WgpuViewportWindow : public QWindow {
|
||
Q_OBJECT
|
||
public:
|
||
explicit WgpuViewportWindow(QWindow* parent = nullptr);
|
||
~WgpuViewportWindow();
|
||
|
||
void setBackgroundColor(const QColor& color);
|
||
|
||
// Queue a sidecar path to be loaded after wgpu init completes. Safe to
|
||
// call before the window is exposed. The path is resolved against the
|
||
// working directory and read via SidecarCache::readSidecar (which
|
||
// normalises stem → .ifcview).
|
||
void queueLoadSidecar(const QString& path);
|
||
|
||
// Synchronous load + GPU upload. Requires wgpu init to have completed
|
||
// (i.e. the window has been exposed at least once). Returns the
|
||
// assigned model_id, or 0 on failure. Routes through the streaming
|
||
// loader when streaming_enabled_, else the legacy full-load path.
|
||
uint32_t loadSidecar(const QString& path);
|
||
|
||
// Restore a finalised model from a SidecarData struct: allocate wgpu
|
||
// buffers, upload vertex/index/mesh/instance bytes, register in
|
||
// models_gpu_. Replaces any existing state for model_id.
|
||
void applyCachedModel(uint32_t model_id, SidecarData data);
|
||
|
||
// Streaming variant: takes a StreamingSidecar (metadata only — no
|
||
// vertex / index bytes). Allocates per-chunk small buffers and the
|
||
// model-shared mesh / instance storage upfront, but leaves each
|
||
// chunk's pool ranges unclaimed and is_resident=false. The per-frame
|
||
// loader (driveStreamingLoads) sub-allocates the chunk's vertex +
|
||
// index ranges from pool_ on demand as cull flags them visible.
|
||
void applyCachedModelStreaming(uint32_t model_id,
|
||
struct StreamingSidecar metadata);
|
||
|
||
void removeModel(uint32_t model_id);
|
||
void resetScene();
|
||
|
||
size_t modelCount() const { return models_gpu_.size(); }
|
||
|
||
// Frame the union of all loaded models' world AABBs. No-op on empty
|
||
// scenes. Called automatically after the first model loads (unless
|
||
// setCamera was already invoked); clients can re-invoke to re-frame.
|
||
void viewAll();
|
||
|
||
// Explicit camera state, mirroring the GL ViewportWindow API. Suppresses
|
||
// the auto-viewAll on first load so a script-driven camera survives
|
||
// model loading. Parameters match the GL --camera tx,ty,tz,dist,yaw,pitch
|
||
// order so a pasted camera string lands the same view in both backends.
|
||
void setCamera(float tx, float ty, float tz,
|
||
float dist, float yaw_deg, float pitch_deg);
|
||
|
||
// GL-parity camera helpers. setStandardView snaps to an axis-aligned
|
||
// angle without re-framing (used by X/Y/Z keys). focusOnSelectedObject
|
||
// frames the union AABB of the current selection. toggleProjection
|
||
// flips perspective <-> orthographic. cameraString formats the current
|
||
// state for a --camera CLI arg.
|
||
void setStandardView(float yaw_deg, float pitch_deg);
|
||
void focusOnSelectedObject();
|
||
void toggleProjection();
|
||
QString cameraString() const;
|
||
|
||
// FPS / fly mode. enterFpsMode swaps the orbit camera for a WASD/QE
|
||
// free-fly camera (hotkey: Shift+F). exitFpsMode restores the orbit
|
||
// pivot and reveals the cursor. Mouse-look uses raw deltas (cursor is
|
||
// hidden and recentered each frame).
|
||
void enterFpsMode();
|
||
void exitFpsMode();
|
||
bool fpsMode() const { return fps_mode_; }
|
||
|
||
private:
|
||
// Common camera math used by render, cull, streaming, and pick. Produces
|
||
// the view matrix and a WebGPU-correct projection (z mapped to [0, 1]).
|
||
// Single helper so projection_ortho_ and the up-vector switch at near-
|
||
// vertical pitch land identically everywhere.
|
||
void buildViewProj(QMatrix4x4& view_out, QMatrix4x4& proj_out) const;
|
||
// Per-frame WASD integration when fps_mode_ is true. Called near the
|
||
// top of render() so the displayed frame already reflects movement.
|
||
void fpsIntegrate();
|
||
// Build the camera AABB for a single object across all loaded models.
|
||
bool computeObjectAabb(uint32_t object_id,
|
||
float mn[3], float mx[3]) const;
|
||
// Re-aim the orbit camera so the bounding sphere of [mn, mx] fits.
|
||
void frameAabb(const float mn[3], const float mx[3], float padding);
|
||
// Resolve nav_preset_ env var to orbit/pan bindings.
|
||
void applyNavPreset(const char* name);
|
||
|
||
// Project the chunk's world-space AABB through `vp_mat` and return the
|
||
// 2D pixel area covered on screen. This is the streaming loader's
|
||
// chunk-priority metric — extracted from driveStreamingLoads as a
|
||
// member so the click-and-track diagnostic can compare scores.
|
||
float chunkScreenAreaPx(const WgpuModelGpuData::Chunk& c,
|
||
const QMatrix4x4& vp_mat) const;
|
||
|
||
// Octree-style spatial planner. Produces (a) per-bucket mesh_ids — a
|
||
// mesh may appear in multiple buckets if its instances scattered, and
|
||
// (b) per-instance bucket assignment. Each bucket carries its own
|
||
// mesh data in its chunk pool slice (duplicated when shared). Stop
|
||
// condition: bucket's union vertex bytes ≤ WGPU_CHUNK_VERTEX_BYTES_LIMIT
|
||
// and instance count ≤ spatial_max_instances_. See task #55.
|
||
struct SpatialPlan {
|
||
std::vector<std::vector<uint32_t>> chunk_mesh_ids;
|
||
std::vector<uint32_t> instance_to_chunk;
|
||
};
|
||
SpatialPlan planSpatialChunks(const std::vector<InstanceCpu>& instances,
|
||
const std::vector<MeshInfo>& meshes) const;
|
||
|
||
public:
|
||
|
||
// Queue a one-shot framebuffer capture: the next rendered frame is
|
||
// copied back to host memory and saved to `path` as PNG. If
|
||
// `quit_after` is true, QCoreApplication::quit() is called once the
|
||
// PNG is written. Use this for headless verification and pixel-diff
|
||
// parity testing against the GL backend.
|
||
void captureNextFrameToPng(const QString& path, bool quit_after = true);
|
||
|
||
// Benchmark mode: render N timed frames (after a small warmup), yaw-
|
||
// sweeping the camera at 0.5°/frame, then print a stats block on
|
||
// stderr and QCoreApplication::quit(). Mirrors the GL minimal's
|
||
// --benchmark output format so a script can diff them line for line.
|
||
void setBenchmarkFrames(int frames);
|
||
|
||
protected:
|
||
void exposeEvent(QExposeEvent* event) override;
|
||
void resizeEvent(QResizeEvent* event) override;
|
||
bool event(QEvent* event) override;
|
||
void mousePressEvent(QMouseEvent* event) override;
|
||
void mouseReleaseEvent(QMouseEvent* event) override;
|
||
void mouseMoveEvent(QMouseEvent* event) override;
|
||
void wheelEvent(QWheelEvent* event) override;
|
||
void keyPressEvent(QKeyEvent* event) override;
|
||
void keyReleaseEvent(QKeyEvent* event) override;
|
||
|
||
private:
|
||
bool initWgpu();
|
||
bool createSurface();
|
||
void configureSurface(int width_px, int height_px);
|
||
void render();
|
||
void shutdown();
|
||
|
||
bool buildPipelines();
|
||
void buildModelBindGroup(WgpuModelGpuData& m);
|
||
void buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_idx);
|
||
// Streaming: read the chunk's vertex + index bytes from disk,
|
||
// sub-allocate ranges in pool_, queueWriteBuffer them in, build the
|
||
// chunk's bind group, flip is_resident=true. Returns true on success;
|
||
// false if either the disk read or a pool alloc fails (caller is
|
||
// expected to have already evicted enough). No-op (returns true)
|
||
// when already resident.
|
||
bool loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t chunk_idx);
|
||
// Pool-allocate + queueWriteBuffer + build bind group for a chunk
|
||
// whose vbytes/idx have already been read (by either the worker
|
||
// thread's drained result or the sync fallback). Returns false on
|
||
// pool OOM. Toggles is_resident=true / is_loading=false on success.
|
||
bool applyStreamedChunk(WgpuModelGpuData& m, size_t chunk_idx,
|
||
const std::vector<uint8_t>& vbytes,
|
||
const std::vector<uint32_t>& idx);
|
||
// Release a resident chunk's pool ranges + bind group; flip
|
||
// is_resident=false. The chunk's CPU metadata (offsets, AABB,
|
||
// visible-draw scratch) is retained so a subsequent
|
||
// loadChunkBytesAndUploadGpu can bring it back without re-planning.
|
||
void unloadChunk(WgpuModelGpuData& m, size_t chunk_idx);
|
||
// Called from render() after cull: find non-resident chunks with
|
||
// current visible draw counts > 0 and bring them resident. When the
|
||
// pool is full, evicts LRU non-visible chunks first, then falls back
|
||
// to evicting the farthest-from-camera visible chunks if a closer
|
||
// candidate needs the space. Triggers requestUpdate() if more remain.
|
||
void driveStreamingLoads();
|
||
|
||
// Discover the largest single buffer wgpu will give us by descending
|
||
// from the device's limits.maxBufferSize through OOM error scopes.
|
||
// Allocates pool_ at the discovered size. Returns false only when
|
||
// even a tiny pool can't be created (i.e. the device is unusable).
|
||
bool probeAndCreatePool();
|
||
void ensureDepthTexture(int w, int h);
|
||
void releaseDepthTexture();
|
||
void ensureMsaaColorTexture(int w, int h);
|
||
void releaseMsaaColorTexture();
|
||
|
||
bool buildHizPipeline();
|
||
bool buildEdgePipeline();
|
||
void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view);
|
||
void releaseEdgeResources();
|
||
|
||
bool buildPickPipeline();
|
||
|
||
// Make sure selection_flags_buffer_ is large enough to address every
|
||
// object_id in next_object_id_. Recreates (and rebuilds frame_bind_group_)
|
||
// if it grew. Safe to call every frame; idempotent when already sized.
|
||
void ensureSelectionFlagsBuffer();
|
||
// Repack the CPU selection into bit-flags and wgpuQueueWriteBuffer to
|
||
// the GPU. Called from render() when selection_.dirty().
|
||
void uploadSelectionFlagsIfDirty();
|
||
void ensurePickAttachments(int w, int h);
|
||
void releasePickResources();
|
||
// Synchronous pick: encodes a one-shot R32UInt render of the current
|
||
// visible_draws against the click pixel, copies the single texel back,
|
||
// waits, and returns the object_id (0 if nothing was hit). Call from
|
||
// the main thread between renders.
|
||
uint32_t pickObjectAt(int x_pixels, int y_pixels);
|
||
void ensureHizTextures(int viewport_w, int viewport_h);
|
||
void releaseHizResources();
|
||
// Resolves the just-rendered MSAA depth into the small single-sample
|
||
// HiZ texture and copies it to whichever staging slot is currently
|
||
// idle. Returns the slot index used, or -1 if both slots are still
|
||
// in flight (resolve is skipped this frame — fine, we already have
|
||
// a recent pyramid). Encoded onto `enc` so it ships in the same
|
||
// command buffer as the main draw.
|
||
int encodeHizResolve(WGPUCommandEncoder enc);
|
||
// Issues a non-blocking mapAsync on `slot` after submit, so the
|
||
// callback can fire whenever the GPU has actually finished writing.
|
||
void startHizMap(int slot, const QMatrix4x4& vp_used);
|
||
// Drains pending mapAsync callbacks (via processEvents — does NOT
|
||
// block on GPU work). For any slot that just signalled Mapped, reads
|
||
// it, unmaps it, max-reduces the mip pyramid, and updates hiz_vp_.
|
||
void drainHizReadbacks();
|
||
// Project AABB through hiz_vp_ and test against the pyramid. False
|
||
// (keep) if HiZ isn't valid yet, AABB straddles the near plane, or
|
||
// any projection is unreliable. True (cull) when AABB is provably
|
||
// behind every relevant pyramid cell.
|
||
bool aabbOccludedByHiz(const float mn[3], const float mx[3]) const;
|
||
void updateFrameUniforms();
|
||
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, lod), and
|
||
// write the flat visible-index list into m.visible_buffer via
|
||
// wgpuQueueWriteBuffer. After return, m.mesh_draws is the per-mesh,
|
||
// per-LOD draw schedule for the frame.
|
||
//
|
||
// `eye` and `forward` (forward = unit (target - eye)) are used to compute
|
||
// each instance's view-space depth for the projected-radius formula.
|
||
// `focal_px` = viewport_height / (2 * tan(fov_y / 2)).
|
||
//
|
||
// Two pixel-radius thresholds:
|
||
// `min_radius_px` — instances projected below this are dropped
|
||
// entirely (contribution culling).
|
||
// `lod1_threshold_px` — survivors projected below this get the mesh's
|
||
// LOD1 index slice when one was baked.
|
||
// min_radius_px == 0 disables contribution culling.
|
||
// CPU-only phase of cull: produces m.visible_draws_scratch /
|
||
// prefix_sums_scratch and sets total_visible_draws / total_visible_
|
||
// vertices. Touches no wgpu state, so this can run on a worker thread
|
||
// (multiple models culled in parallel). Returns the number of HiZ
|
||
// rejections accumulated (caller adds to the per-frame stat).
|
||
uint32_t cullModelCpuCompute(WgpuModelGpuData& m,
|
||
const float planes[6][4],
|
||
const float eye[3], const float forward[3],
|
||
float focal_px,
|
||
float min_radius_px,
|
||
float lod1_threshold_px,
|
||
bool hiz_enabled) const;
|
||
// Upload phase: wgpuQueueWriteBuffer for visible_draws / prefix_sums /
|
||
// per-model uniform. Main-thread only (wgpu queue ops are not all
|
||
// thread-safe).
|
||
void cullModelCpuUpload(WgpuModelGpuData& m);
|
||
|
||
bool wgpu_initialized_ = false;
|
||
bool surface_configured_ = false;
|
||
int configured_w_ = 0;
|
||
int configured_h_ = 0;
|
||
|
||
WGPUInstance instance_ = nullptr;
|
||
WGPUAdapter adapter_ = nullptr;
|
||
WGPUDevice device_ = nullptr;
|
||
WGPUQueue queue_ = nullptr;
|
||
WGPUSurface surface_ = nullptr;
|
||
WGPUTextureFormat surface_format_ = WGPUTextureFormat_Undefined;
|
||
|
||
// Render pipeline + bind group layouts (built once after init).
|
||
WGPUShaderModule main_shader_module_ = nullptr;
|
||
WGPUBindGroupLayout frame_bgl_ = nullptr; // group 0
|
||
WGPUBindGroupLayout model_bgl_ = nullptr; // group 1
|
||
WGPUPipelineLayout pipeline_layout_ = nullptr;
|
||
WGPURenderPipeline main_pipeline_ = nullptr;
|
||
|
||
// Per-frame uniform (view-proj + lighting), bound at group 0.
|
||
WGPUBuffer frame_uniform_buffer_ = nullptr;
|
||
WGPUBindGroup frame_bind_group_ = nullptr;
|
||
|
||
// Selection flags storage buffer at group=0 binding=1. u32-per-object_id,
|
||
// bit 0 = selected, bit 1 = active. Sized to next_object_id_ rounded up;
|
||
// grows when a load pushes past the current capacity. Bound in the
|
||
// frame bind group because object_ids are globally unique across models.
|
||
WGPUBuffer selection_flags_buffer_ = nullptr;
|
||
uint32_t selection_flags_capacity_ = 0; // number of u32 entries
|
||
WgpuSelectionState selection_;
|
||
std::vector<uint32_t> selection_flags_scratch_;
|
||
|
||
// Per-element visibility. Consulted in cullModelCpuCompute to drop
|
||
// hidden instances before they're added to visible_draws — keeps
|
||
// hidden geometry out of cost on every axis (no draw, no depth, no
|
||
// pick). Mutated on the main thread between renders; cull workers
|
||
// read concurrently which is safe as long as no concurrent writes.
|
||
WgpuVisibilityState visibility_;
|
||
|
||
// Depth attachment (4× MSAA), recreated on surface resize.
|
||
WGPUTexture depth_texture_ = nullptr;
|
||
WGPUTextureView depth_view_ = nullptr;
|
||
int depth_w_ = 0;
|
||
int depth_h_ = 0;
|
||
|
||
// 4× MSAA color target. Surface format-matched, recreated on resize.
|
||
// The render pass writes here, then resolves into the surface texture.
|
||
WGPUTexture msaa_color_texture_ = nullptr;
|
||
WGPUTextureView msaa_color_view_ = nullptr;
|
||
int msaa_w_ = 0;
|
||
int msaa_h_ = 0;
|
||
static constexpr uint32_t SAMPLE_COUNT = 4;
|
||
|
||
// HiZ occlusion culling. After each frame's main render pass we
|
||
// downsample MSAA depth into a small single-sample Depth32Float texture
|
||
// (hiz_resolve_texture_), copy it into a CPU-mappable staging buffer,
|
||
// wait for the map via processEvents, and max-reduce a mip pyramid on
|
||
// CPU. The cull pass in the *next* frame projects each instance's AABB
|
||
// through hiz_vp_ (the VP used to fill the pyramid) and rejects when
|
||
// the AABB's nearest projected z is behind the pyramid's coverage.
|
||
//
|
||
// GL's HiZ default is 256 wide; we match. Height tracks viewport aspect.
|
||
static constexpr uint32_t HIZ_BASE_W = 256;
|
||
|
||
WGPUShaderModule hiz_shader_module_ = nullptr;
|
||
WGPUBindGroupLayout hiz_bgl_ = nullptr;
|
||
WGPUPipelineLayout hiz_pipeline_layout_ = nullptr;
|
||
WGPURenderPipeline hiz_pipeline_ = nullptr;
|
||
WGPUBuffer hiz_uniform_buffer_ = nullptr;
|
||
WGPUBindGroup hiz_bind_group_ = nullptr;
|
||
|
||
WGPUTexture hiz_resolve_texture_ = nullptr;
|
||
WGPUTextureView hiz_resolve_view_ = nullptr;
|
||
uint32_t hiz_resolve_w_ = 0;
|
||
uint32_t hiz_resolve_h_ = 0;
|
||
uint32_t hiz_padded_bpr_ = 0; // bytes per row in the staging buffer
|
||
|
||
// Ping-pong async readback. Frame N submits a copy into slot
|
||
// hiz_write_idx_ and calls mapAsync (non-blocking) on that slot. Frame
|
||
// N+K (K ≥ 1) calls processEvents to drain callbacks; whichever slot
|
||
// signalled completion is mapped, read into hiz_pyramid_, and unmapped
|
||
// — making the pyramid 1+ frames stale, which is fine ("slightly-stale
|
||
// depth" pattern the GL backend already documents). Two slots overlap
|
||
// GPU write with CPU read; we never block on the readback.
|
||
// Edge silhouette post-process (stage 9). Samples the MSAA depth
|
||
// texture in a fullscreen pass, computes a depth Laplacian, blends
|
||
// dark lines into the resolved surface colour. Matches GL's
|
||
// renderEdgePass() visually.
|
||
WGPUShaderModule edge_shader_module_ = nullptr;
|
||
WGPUBindGroupLayout edge_bgl_ = nullptr;
|
||
WGPUPipelineLayout edge_pipeline_layout_ = nullptr;
|
||
WGPURenderPipeline edge_pipeline_ = nullptr;
|
||
WGPUBindGroup edge_bind_group_ = nullptr;
|
||
bool edges_enabled_ = true;
|
||
|
||
// Pick pass (stage 4). Single-sample R32UInt target + depth, vertex-
|
||
// pulled from the same visible_draws / instances buffers as the main
|
||
// pass — pick fragment outputs the instance's object_id. The pick
|
||
// pipeline reuses pipeline_layout_ because it needs the same set of
|
||
// bindings (frame uniform at group=0, per-model storages at group=1).
|
||
WGPURenderPipeline pick_pipeline_ = nullptr;
|
||
WGPUTexture pick_color_texture_ = nullptr;
|
||
WGPUTextureView pick_color_view_ = nullptr;
|
||
WGPUTexture pick_depth_texture_ = nullptr;
|
||
WGPUTextureView pick_depth_view_ = nullptr;
|
||
WGPUBuffer pick_staging_buffer_ = nullptr; // 256 B (single texel + bytes-per-row pad)
|
||
int pick_w_ = 0;
|
||
int pick_h_ = 0;
|
||
|
||
enum class HizSlotState : uint8_t { Idle, Mapping, Mapped };
|
||
static constexpr int HIZ_SLOTS = 2;
|
||
WGPUBuffer hiz_staging_buffers_[HIZ_SLOTS] = { nullptr, nullptr };
|
||
QMatrix4x4 hiz_slot_vp_ [HIZ_SLOTS];
|
||
HizSlotState hiz_slot_state_ [HIZ_SLOTS] = { HizSlotState::Idle,
|
||
HizSlotState::Idle };
|
||
int hiz_write_idx_ = 0;
|
||
|
||
// CPU mip pyramid (max-reduce). hiz_pyramid_[hiz_mip_offset_[L] + y*W + x].
|
||
std::vector<float> hiz_pyramid_;
|
||
std::vector<uint32_t> hiz_mip_offset_;
|
||
std::vector<uint32_t> hiz_mip_w_;
|
||
std::vector<uint32_t> hiz_mip_h_;
|
||
QMatrix4x4 hiz_vp_;
|
||
bool hiz_valid_ = false;
|
||
uint32_t hiz_reject_count_ = 0; // per-frame stat
|
||
|
||
QColor background_color_ = QColor("#202329");
|
||
|
||
// Camera (orbit, right-handed Y-up world → wait, BIM is +Z up).
|
||
// Mirrors the GL viewport's defaults; mouse navigation lands later.
|
||
float camera_target_[3] = { 0.0f, 0.0f, 0.0f };
|
||
float camera_distance_ = 50.0f;
|
||
|
||
// Perspective by default; toggleProjection() (P key) flips this. When
|
||
// true, the per-frame projection builder uses an orthographic matrix
|
||
// sized by camera_distance_ × tan(fov/2) so toggling looks like a
|
||
// smooth swap rather than a jump in apparent size.
|
||
bool projection_ortho_ = false;
|
||
|
||
// Fly / FPS-mode state. Mirrors GL ViewportWindow::CameraMode::Fps.
|
||
// While fps_mode_ is true: cursor is hidden, mouse-look uses raw
|
||
// deltas, fps_keys_held_ accumulates pressed W/A/S/D/Q/E/Shift, and
|
||
// render() integrates a movement step each frame from those keys.
|
||
// exit via Esc (also any unrelated key click) — recenter the cursor
|
||
// back at fps_press_center_ so the orbit camera resumes cleanly.
|
||
bool fps_mode_ = false;
|
||
QSet<int> fps_keys_held_;
|
||
QElapsedTimer fps_last_tick_;
|
||
QPoint fps_press_center_;
|
||
bool fps_ignore_next_mouse_move_ = false;
|
||
// Fly base speed in m/s at no-modifier (Shift gives a 5× boost). Default
|
||
// 5.0 matches GL fps_move_speed_. Scrollwheel in fly mode adjusts this
|
||
// by ×1.25 / ×0.8 per notch, Blender-style — wheel does NOT zoom while
|
||
// in fly mode (which would change camera_distance_ underneath us and
|
||
// make speed jitter if speed were distance-scaled).
|
||
float fps_move_speed_ = 5.0f;
|
||
// Per-frame [fly] dt log when WGPU_FLY_DEBUG=1. Diagnoses stutter:
|
||
// print dt of each fpsIntegrate call and the prior render's elapsed
|
||
// ms. Off by default (env-gated) so the normal log stays clean.
|
||
bool fly_debug_ = false;
|
||
QElapsedTimer fly_render_clock_;
|
||
|
||
// Click-and-track diagnostic: when a pick lands, stash the chunk
|
||
// that holds the picked object. driveStreamingLoads watches for that
|
||
// chunk's `is_resident` flipping true→false and dumps the priority
|
||
// / pool stats at the moment of eviction so we can see why it lost.
|
||
uint32_t tracked_object_id_ = 0;
|
||
uint32_t tracked_chunk_mid_ = 0;
|
||
size_t tracked_chunk_idx_ = SIZE_MAX;
|
||
bool tracked_was_resident_ = false;
|
||
|
||
// Mouse-navigation bindings — mirrors GL's NavBindings + currentNavBindings().
|
||
// Selection stays on LMB for every preset (none of the presets steal it),
|
||
// so the click-vs-drag distinction at mouseReleaseEvent's pick path keeps
|
||
// working. Set at init from WGPU_NAV_PRESET=blender|rhino|revit (default
|
||
// blender, matching GL's AppSettings::NavPreset::Blender default).
|
||
Qt::MouseButton orbit_button_ = Qt::MiddleButton;
|
||
Qt::KeyboardModifiers orbit_mods_ = Qt::NoModifier;
|
||
Qt::MouseButton pan_button_ = Qt::MiddleButton;
|
||
Qt::KeyboardModifiers pan_mods_ = Qt::ShiftModifier;
|
||
// Set by mousePressEvent based on which binding matched; consumed by
|
||
// mouseMoveEvent so mid-drag modifier changes don't switch axes.
|
||
enum class NavDrag : uint8_t { Inactive, Orbit, Pan };
|
||
NavDrag nav_drag_kind_ = NavDrag::Inactive;
|
||
float camera_yaw_deg_ = 45.0f;
|
||
float camera_pitch_deg_ = 30.0f;
|
||
float camera_fov_y_deg_ = 45.0f;
|
||
float camera_near_ = 0.1f;
|
||
float camera_far_ = 10000.0f;
|
||
|
||
// Contribution-cull thresholds. Still-frame uses min_pixel_radius_;
|
||
// when the camera changed since last frame, the bigger motion threshold
|
||
// kicks in to drop more sub-pixel detail (and slash per-frame cull cost).
|
||
//
|
||
// GL ships 2.0 / 10.0, but uses euclidean distance for projected_px
|
||
// (sqrt(dx² + dy² + dz²)) while wgpu uses view-Z distance (the
|
||
// perspective-divide-correct denominator). For off-axis instances
|
||
// view_z < euclidean, so wgpu's projected_px is larger than GL's at
|
||
// the same numeric threshold — i.e. wgpu is structurally less
|
||
// aggressive. Bumping to 3.0 / 15.0 compensates so the effective drop
|
||
// rate matches GL's; on the federation scene this lands obj/tri
|
||
// counts within ~10% of GL's across an orbit (vs ~3× without the
|
||
// bump). Override at runtime via WGPU_MIN_PX / WGPU_MIN_PX_MOTION.
|
||
float min_pixel_radius_ = 3.0f;
|
||
float motion_min_pixel_radius_ = 15.0f;
|
||
|
||
// Whether driveCull dispatches per-model work via std::async. ON by
|
||
// default; setting WGPU_CULL_THREADS=0 forces sequential cull for
|
||
// measurement (does std::async actually parallelize on this libstdc++?
|
||
// and is per-model the right granularity?).
|
||
bool cull_threads_enabled_ = true;
|
||
|
||
// WGPU_SPATIAL_BUCKETS=1 swaps the streaming chunk planner from the
|
||
// mesh-keyed Morton+greedy algorithm to an octree-style spatial
|
||
// subdivision of INSTANCES. Same mesh may appear in multiple buckets
|
||
// (data duplicated) when its instances scatter — this is the central
|
||
// trade for tight per-chunk AABBs that actually match what cull and
|
||
// priority code want. See task #55.
|
||
bool spatial_buckets_enabled_ = false;
|
||
// Stop-subdividing thresholds for the octree planner. A cell becomes a
|
||
// leaf bucket when (a) the union vertex bytes of meshes its instances
|
||
// reference fits the chunk budget, AND (b) instance count is below the
|
||
// cap. Tunable via WGPU_SPATIAL_BUCKET_MAX_INSTS env var so we can
|
||
// sweep without rebuild during the prototype phase.
|
||
uint32_t spatial_max_instances_ = 5000;
|
||
|
||
public:
|
||
// Master switch for HiZ occlusion. Set false to skip the depth resolve
|
||
// + readback + cull test entirely (matches IFC_NO_HIZ in the GL backend).
|
||
bool hiz_enabled_ = true;
|
||
|
||
// When true, initWgpu requests the WebGPU mandatory floor limits
|
||
// (maxStorageBufferBindingSize=128MB, maxBufferSize=256MB) instead of
|
||
// the adapter's actual maximum. Use this to verify on desktop that a
|
||
// scene fits through the constraints a browser will impose.
|
||
bool web_limits_ = false;
|
||
|
||
// Streaming load (task #16). When enabled, queueLoadSidecar routes
|
||
// through the metadata-only reader: mesh dict + instance dict + georef
|
||
// load immediately; per-chunk vertex bytes are read + uploaded on
|
||
// demand by the per-frame loader as chunks become frustum-visible.
|
||
// Default OFF so existing behaviour (synchronous full load) is
|
||
// preserved; --streaming opts in.
|
||
bool streaming_enabled_ = false;
|
||
|
||
// Monotonic frame counter, bumped at the top of driveStreamingLoads.
|
||
// Used as the LRU key for chunk eviction.
|
||
uint64_t streaming_frame_idx_ = 0;
|
||
|
||
// Sub-allocator for all chunk vertex + index bytes. Sized at startup
|
||
// by probeAndCreatePool() — the runtime tells us how big a single
|
||
// buffer it can actually deliver, eliminating the per-machine OOM
|
||
// ceiling that one-WGPUBuffer-per-chunk would otherwise hit. All
|
||
// chunk allocations land here; nothing else uses the pool. Replaces
|
||
// the old hand-picked streaming_vram_budget_bytes_ knob entirely.
|
||
WgpuBufferPool pool_;
|
||
|
||
// Background worker that does scatter-gather chunk reads off the
|
||
// render thread. driveStreamingLoads enqueues requests for visible
|
||
// non-resident chunks and drains completed results into the pool
|
||
// on subsequent frames. Kills the 100-300 ms per-frame stutters
|
||
// that synchronous disk reads caused during orbit.
|
||
WgpuStreamingThread streaming_thread_;
|
||
|
||
// Per-frame streaming activity, written by driveStreamingLoads,
|
||
// consumed by the benchmark harness to delay the orbit sweep until
|
||
// the initial cold-load settles. `loads` = chunks brought resident
|
||
// this frame; `more_pending` = the loader wants to keep going.
|
||
int streaming_loads_this_frame_ = 0;
|
||
bool streaming_more_pending_ = false;
|
||
|
||
// Per-frame streaming counters for WGPU_STREAM_DEBUG. Mutated inside
|
||
// driveStreamingLoads, consumed by the per-frame debug print and the
|
||
// bench-warm timeout dump.
|
||
int streaming_candidates_this_frame_ = 0;
|
||
int streaming_evictions_lru_this_frame_ = 0;
|
||
int streaming_evictions_pri_this_frame_ = 0;
|
||
int streaming_drained_this_frame_ = 0;
|
||
int streaming_blocked_oom_this_frame_ = 0;
|
||
bool streaming_debug_ = false; // WGPU_STREAM_DEBUG=1
|
||
|
||
// Bench warm-phase counters. We wait until N consecutive frames with
|
||
// 0 loads (convergence) before starting the orbit sweep, capped by
|
||
// MAX_WARM_FRAMES so chronically thrashing scenes still produce
|
||
// numbers. Both reset implicitly per bench run via setBenchmarkFrames.
|
||
int bench_warm_streak_ = 0;
|
||
int bench_warm_frames_total_ = 0;
|
||
bool bench_warm_done_ = false; // latch: once true, gate is open for this run
|
||
|
||
private:
|
||
|
||
// Switch to LOD1 when an instance's projected bounding-sphere radius
|
||
// drops below this many pixels. 0 disables (always LOD0). Defaults
|
||
// mirror AppSettings::lod1PixelThreshold() in the GL backend.
|
||
float lod1_pixel_threshold_ = 30.0f;
|
||
|
||
// Per-model state, keyed by viewport-assigned model_id.
|
||
std::unordered_map<uint32_t, WgpuModelGpuData> models_gpu_;
|
||
uint32_t next_model_id_ = 1;
|
||
// Globally-unique object_id allocator. Each applyCachedModel rebases
|
||
// the sidecar's local object_ids by base_object_id_so_far so picks
|
||
// are unambiguous across models. Selection flags index this range.
|
||
uint32_t next_object_id_ = 1;
|
||
|
||
// Sidecar paths queued before init completes.
|
||
std::deque<QString> pending_sidecars_;
|
||
|
||
// Set after the first model load triggers a viewAll(); prevents
|
||
// subsequent loads from snapping the camera away from where the
|
||
// user pointed it.
|
||
bool initial_view_applied_ = false;
|
||
|
||
// Camera state at the previous render() for motion detection. Any
|
||
// change means we apply the motion contribution threshold this frame
|
||
// (drops more sub-pixel work mid-orbit; matches GL behaviour).
|
||
float prev_camera_target_[3] = { 0, 0, 0 };
|
||
float prev_camera_distance_ = 0.0f;
|
||
float prev_camera_yaw_deg_ = 0.0f;
|
||
float prev_camera_pitch_deg_ = 0.0f;
|
||
bool has_prev_camera_ = false;
|
||
|
||
// True iff the last cull used the motion threshold. Render schedules a
|
||
// single settle frame after motion stops so the previously dropped
|
||
// sub-pixel instances reappear at the still threshold. Without this,
|
||
// event-driven rendering would leave those instances missing forever
|
||
// because no further frame is requested after the user releases the
|
||
// mouse. Matches GL's last_cull_was_motion_ behaviour.
|
||
bool last_cull_was_motion_ = false;
|
||
|
||
// Pending one-shot screenshot, captured at the end of the next render().
|
||
QString pending_screenshot_path_;
|
||
bool pending_screenshot_quit_ = false;
|
||
|
||
// Mouse navigation state. LMB drag orbits, MMB drag pans, wheel zooms.
|
||
// LMB-click-without-drag picks the object under the cursor. No
|
||
// Blender/Maya preset awareness yet — that arrives with AppSettings.
|
||
Qt::MouseButton nav_active_button_ = Qt::NoButton;
|
||
QPoint nav_last_pos_;
|
||
QPoint nav_press_pos_;
|
||
bool nav_dragged_ = false;
|
||
|
||
// Benchmark mode. setBenchmarkFrames(N) arms it; render() integrates the
|
||
// yaw, captures per-frame ms after warmup, and prints + quits when the
|
||
// target frame count is hit.
|
||
int bench_total_ = 0;
|
||
int bench_count_ = 0;
|
||
int bench_warmup_ = 5;
|
||
float bench_yaw_start_ = 0.0f;
|
||
float bench_yaw_speed_ = 0.5f; // degrees per frame
|
||
std::vector<float> bench_frame_ms_;
|
||
|
||
// Per-frame stat snapshot from the last cull. Sum of m.mesh_draws across
|
||
// visible models. Exposed via the benchmark summary; will grow into a
|
||
// proper FrameStats signal when stage 11's host integration arrives.
|
||
uint32_t last_visible_objects_ = 0;
|
||
uint32_t last_visible_triangles_ = 0;
|
||
uint32_t last_sub_draws_ = 0;
|
||
|
||
// Phase-time accumulators for benchmark mode. Each window measures a
|
||
// distinct slice of render() so we can attribute frame cost. Totals
|
||
// across the timed window are divided by bench_total_ on print.
|
||
double bench_cull_ms_total_ = 0.0;
|
||
double bench_stream_ms_total_ = 0.0; // driveStreamingLoads only
|
||
double bench_hiz_readback_ms_total_ = 0.0;
|
||
double bench_submit_ms_total_ = 0.0;
|
||
|
||
// Last-frame per-phase times. Available in interactive mode (no
|
||
// bench) so the periodic [frame] heartbeat log can show cull /
|
||
// stream cost without needing the bench averaging machinery.
|
||
double last_cull_ms_ = 0.0;
|
||
double last_cull_compute_ms_ = 0.0; // parallel per-model cull
|
||
double last_cull_upload_ms_ = 0.0; // sequential queueWriteBuffer pass
|
||
double last_stream_ms_ = 0.0;
|
||
|
||
// Tick count for the interactive (non-bench) [frame] heartbeat log.
|
||
// Increments every render() and prints stats every N frames.
|
||
int interactive_frame_count_ = 0;
|
||
|
||
// Per-frame LOD selection counts, mutated from cullModelCpuCompute
|
||
// and reset after the [frame] heartbeat prints them. Keeps an eye
|
||
// on whether LOD1 is actually firing on real scenes — early-days
|
||
// diagnostic while we trust the new code path.
|
||
mutable uint32_t lod1_dbg_count_ = 0;
|
||
mutable uint32_t lod0_dbg_eligible_count_ = 0;
|
||
mutable uint32_t lod0_dbg_no_lod1_count_ = 0;
|
||
mutable uint64_t lod1_dbg_tris_saved_ = 0;
|
||
};
|
||
|
||
#endif // WGPUVIEWPORTWINDOW_H
|