Files
IfcOpenShell/src/ifcviewer/ViewportWindow.h
T
Dion Moult bba2f30816 ifcviewer: multi-selection + box-select with active highlight
SelectionState (new) owns the multi-set, the "active" id (last single-
clicked), and a per-object_id flags SSBO bound at binding=3.  Main
shader reads sel_flags[v_object_id] for the in-set tint and a separate
u_active_id uniform for a stronger tint on the active.

Click semantics: plain replaces, Shift/Ctrl toggles.  LMB-drag past 5px
boxes the rect through a pick-pass readback — plain replaces, Shift
adds, Ctrl removes; box-select preserves the active.  Drag promotes
regardless of start point so a press on geometry doesn't disqualify it.

Sidecar fast-path bulk-loads instances, so noteObjectId is also called
from the apply path — without it the flags buffer was sized to 1 slot
while object_ids were in the 100k+ range and the in-set bit was lost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00

778 lines
35 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/********************************************************************************
* *
* 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 VIEWPORTWINDOW_H
#define VIEWPORTWINDOW_H
#include <QWindow>
#include <QOpenGLContext>
#include <QtOpenGL/QOpenGLFunctions_4_5_Core>
#include <QElapsedTimer>
#include <QMatrix4x4>
#include <QVector3D>
#include <QSet>
QT_BEGIN_NAMESPACE
class QTimer;
QT_END_NAMESPACE
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <cstdint>
#include <mutex>
#include <memory>
#include <atomic>
#include <future>
#include <deque>
#include <Eigen/Dense>
#include "BvhAccel.h"
#include "InstancedGeometry.h"
#include "OverlayRenderer.h"
#include "Selection.h"
#include "SidecarCache.h"
// Matches GL_DRAW_INDIRECT_BUFFER layout for glMultiDrawElementsIndirect.
struct DrawElementsIndirectCommand {
uint32_t count;
uint32_t instanceCount;
uint32_t firstIndex;
uint32_t baseVertex;
uint32_t baseInstance;
};
// Per-model GPU state for the instanced render path.
//
// VBO: local-coord interleaved verts (pos3 + normal3 + color1_packed) — 28 B.
// EBO: mesh-local indices (uint32).
// meshes[]: per-unique-representation metadata; indexed by local_mesh_id.
// instances[]: CPU-side per-instance records; sorted by mesh_id at finalize.
// ssbo: InstanceGpu[]; populated at finalize.
//
// A model is drawable once `finalized == true`.
struct ModelGpuData {
GLuint vao = 0;
GLuint vbo = 0;
GLuint ebo = 0;
GLuint ssbo = 0;
GLuint mesh_info_ssbo = 0; // MeshGpu[] — per-mesh quantization basis
size_t mesh_info_capacity = 0; // bytes
size_t vbo_capacity = 0;
size_t ebo_capacity = 0;
size_t ssbo_capacity = 0; // bytes
size_t vbo_used = 0;
size_t ebo_used = 0;
uint32_t vertex_count = 0; // total (across all meshes)
uint32_t total_triangles = 0;
std::vector<MeshInfo> meshes;
std::vector<InstanceCpu> instances; // unsorted
// 1:1 with instances[] — true when the instance transform has
// det < 0 (a reflection). Reflected instances need their
// triangle winding treated as reversed so GL_CULL_FACE culls
// the correct side.
std::vector<uint8_t> instance_reflected;
uint32_t ssbo_instance_count = 0;
// Stats snapshot from the last cullAndUploadVisible call. Cached so we
// can report the same numbers on skipped-cull frames (see
// have_cached_cull_ on ViewportWindow) without iterating the per-model
// scratch array again.
uint32_t cached_visible_objects = 0;
uint32_t cached_visible_triangles = 0;
// Per-instance world AABB + BVH (built at finalize). The BVH is the
// same ordering as `instances`; bvh_items[i] corresponds to instances[i].
std::vector<BvhItem> bvh_items;
ModelBvh bvh;
// Dynamic visible-instance index buffer (std430, binding = 1).
// Re-uploaded each frame from visible_flat_.
GLuint visible_ssbo = 0;
size_t visible_ssbo_capacity = 0; // bytes
// GL_DRAW_INDIRECT_BUFFER of DrawElementsIndirectCommand[], one per
// non-empty mesh. Re-uploaded each frame.
GLuint indirect_buffer = 0;
size_t indirect_capacity = 0; // bytes
uint32_t indirect_command_count = 0; // total valid commands this frame
uint32_t indirect_forward_count = 0; // first N are CCW-winding draws
// Per-model cull scratch — owned by the model so each cull job runs
// without sharing mutable state. Four buckets = {fwd, rev} × {LOD0, LOD1}.
std::vector<std::vector<uint32_t>> vis_fwd_lod0;
std::vector<std::vector<uint32_t>> vis_fwd_lod1;
std::vector<std::vector<uint32_t>> vis_rev_lod0;
std::vector<std::vector<uint32_t>> vis_rev_lod1;
std::vector<uint32_t> visible_flat;
std::vector<DrawElementsIndirectCommand> indirect_scratch;
std::vector<uint32_t> dirty_meshes;
bool finalized = false;
bool hidden = false;
// Per-model federation-pipeline matrices in metres. Default identity
// → no per-model contribution to the composed transform. See
// Federation.h for the full pipeline composition order.
Eigen::Matrix4d coordinate_operation_meters = Eigen::Matrix4d::Identity();
Eigen::Matrix4d model_transformation_meters = Eigen::Matrix4d::Identity();
};
// Rendering is event-driven: render() runs only when QEvent::UpdateRequest
// is delivered, posted via requestUpdate(). An idle scene costs zero CPU.
// INVARIANT: every public mutator that changes what should be on screen
// (camera, selection, model lifecycle, visibility) MUST call requestUpdate()
// before returning, or the viewport will go silently stale.
class ViewportWindow : public QWindow {
Q_OBJECT
public:
explicit ViewportWindow(QWindow* parent = nullptr);
~ViewportWindow();
// Streaming ingress.
void uploadMeshChunk(const MeshChunk& chunk);
void uploadInstanceChunk(const InstanceChunk& chunk);
// Called once all chunks for a model have arrived: sorts instances by
// mesh_id, assigns each mesh its contiguous range, and uploads the
// instance SSBO. The model becomes drawable.
void finalizeModel(uint32_t model_id);
void resetScene();
// Snapshot the finalised model into a SidecarData struct for caching.
// Vertices + indices are read back from the GPU; meshes/instances come
// from the CPU-side vectors. Leaves `elements` and `string_table` empty
// for the caller to fill in.
bool snapshotModel(uint32_t model_id, SidecarData& out) const;
// Restore a finalised model from a cached SidecarData struct. Replaces
// any existing state for model_id and marks it drawable.
void applyCachedModel(uint32_t model_id, SidecarData data);
// After buildLods() has extended sd.indices + populated lod1_* fields,
// push just the appended index slice + the refreshed mesh metadata onto
// the live GPU state for model_id. VBO / SSBO / instance array are left
// alone; only the EBO grows and m.meshes is replaced. No-op if the
// model isn't finalised on the viewport.
void applyLodExtension(uint32_t model_id, const SidecarData& sd);
void hideModel(uint32_t model_id);
void showModel(uint32_t model_id);
void removeModel(uint32_t model_id);
// Debug helper: walk to the currently selected object's instance and
// qInfo a sample vertex (decoded from the VBO), the placement
// transformation matrix, and the global matrix
// (CoordinateOperation · placement_transformation), in metres.
void printSelectedObjectCoords();
// Mesh-local CPU triangles read back from the VBO/EBO. Positions are
// dequantised against the mesh's local AABB; units match the streamer
// (metres). Indices are mesh-local (0..vertex_count-1).
struct MeshTriangles {
std::vector<float> positions; // 3 * vertex_count
std::vector<uint32_t> indices; // 3 * triangle_count
};
// Lazy GPU readback of one mesh's triangles. Fails if model_id /
// mesh_id aren't live, the model isn't finalised, or GL isn't ready.
// Stalls the GL pipeline for the readback — call from the main thread
// and not from inside render().
bool readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id,
MeshTriangles& out);
// Pure CPU lookup: object_id → owning model + mesh + raw streamer
// placement matrix (column-major, pre-CoordinateOperation /
// FederatedFalseOrigin / ModelTransformation).
struct InstanceLookup {
uint32_t model_id = 0;
uint32_t mesh_id = 0;
float placement_transformation[16]{};
};
bool findInstance(uint32_t object_id, InstanceLookup& out) const;
// Pick + resolve to mesh-local space. Runs pickSurfaceAt to get the
// world-space hit, then inverts the instance's composed transform
// (FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation) to express the hit in the mesh's own
// coordinates — what readbackMeshTriangles returns. Returns false if
// the click missed geometry or its object_id has no live instance.
struct MeshLocalPick {
uint32_t object_id = 0;
uint32_t model_id = 0;
uint32_t mesh_id = 0;
float mesh_local[3] = {0, 0, 0};
float world_pos[3] = {0, 0, 0};
float world_normal[3]= {0, 0, 0};
// The instance's composed (FederatedFalseOrigin · ModelTransformation
// · CoordinateOperation · placement_transformation) matrix in
// column-major form. Mesh-local positions × this = world. Caching
// by callers becomes stale if any federation matrix is later edited.
float composed_transform[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
};
bool pickMeshLocalAt(int x, int y, MeshLocalPick& out);
// CPU raycast against the per-model BVHs. Walks the BVH for each
// finalised model, transforms the world ray into mesh-local space
// for each candidate instance, reads back its triangles, and runs
// Möller-Trumbore against them. Returns the closest hit overall.
//
// `dir` MUST be a unit vector — distance is reported as the t value
// along the ray, which equals world distance only when |dir|=1.
// Stalls the GL pipeline once per unique (model, mesh) candidate
// because triangle data is read back lazily; budget ~1ms for
// typical BIM scenes.
struct RaycastHit {
uint32_t object_id = 0;
float distance = 0.0f;
float world_pos[3] = {0, 0, 0};
float world_normal[3]= {0, 0, 0};
};
bool raycast(const float origin[3], const float dir[3], RaycastHit& out);
// Measurement tool modes. While any tool is active, LMB clicks emit
// surfacePickedInTool with the click coordinates (instead of swapping
// object selection); the app interprets them per-tool. Esc exits the
// active tool. Backspace/Delete in length mode emits
// toolBackspacePressed for "remove last point" semantics.
enum class ToolMode { None, Area, Length };
Q_ENUM(ToolMode)
void toggleAreaTool();
void toggleLengthTool();
void setToolMode(ToolMode mode);
ToolMode toolMode() const { return tool_mode_; }
// Replace the overlay highlight-triangle list rendered after the main
// pass. `world_xyz` is 3 floats per vertex, 3 verts per triangle, in
// world space. Empty disables the overlay. Triggers a viewport
// update so the change becomes visible immediately.
void setHighlightTriangles(const std::vector<float>& world_xyz,
float r, float g, float b, float a);
// Replace the overlay line groups. Each group has its own segments
// + style (color/halo/width/dash) — see OverlayRenderer::LineGroup.
// Empty disables every line.
void setOverlayLines(const std::vector<OverlayRenderer::LineGroup>& groups);
// Replace the overlay-point list (3 floats per point, world space).
// pixel_size is the inner-disc diameter in physical pixels; when
// stroke_a > 0 the sprite is enlarged by 2*stroke_extra to draw a
// halo around the inner disc — proper outlined points via the
// sprite shader (no two-pass glPointSize trickery).
void setOverlayPoints(const std::vector<float>& world_xyz,
float r, float g, float b, float a,
float pixel_size,
float stroke_r, float stroke_g, float stroke_b, float stroke_a,
float stroke_extra);
// Replace the world-anchored label list — projected to screen space
// and drawn via QPainter inside the same overlay pass as the HUD.
// Used today for per-segment length readouts.
void setOverlayLabels(const std::vector<OverlayRenderer::Label>& labels);
// Multi-line HUD text drawn via QPainter on top of the GL surface at
// the end of each frame. Empty hides the HUD. Newlines split the
// string into separate rows in the same translucent box.
void setHudText(const QString& text);
// Federation pipeline: composed instance transform =
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation
// setFederatedFalseOrigin affects every model; the per-model setters
// affect a single model. Each setter rewrites the SSBO, recomputes
// world AABBs, rebuilds the BVH, and posts an update. Defaults are
// identity, so until a setter is called the composed transform equals
// placement_transformation.
void setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters);
void setModelCoordinateOperation(uint32_t model_id, const Eigen::Matrix4d& matrix_meters);
void setModelTransformation(uint32_t model_id, const Eigen::Matrix4d& matrix_meters);
// Selection. The viewport owns a SelectionState that tracks both
// the multi-set and the "active" (last single-clicked) id. External
// callers (MainWindow / tree sync) drive selection through it.
SelectionState& selection() { return selection_; }
const SelectionState& selection() const { return selection_; }
// Convenience: replace the selection with {id} (or clear if id==0).
// Used by callers that have not yet been ported off the single-id API.
void setSelectedObjectId(uint32_t id);
uint32_t pickObjectAt(int x, int y);
// Render the pick pass and collect every distinct non-zero object_id
// covered by the pixels inside `rect` (logical coords, top-left origin).
// Used by the box-select drag. Returns an empty set if rect is empty
// or off-surface.
std::unordered_set<uint32_t> picksInRect(const QRect& rect);
// Extended pick: returns the object id, world-space hit point, and
// world-space surface normal at (x, y). Renders the same pick pass as
// pickObjectAt but reads back from two extra color attachments
// (world_pos in RGB32F, world_normal in RGB16F). Returns false if the
// click missed all geometry; the out-params are then untouched.
bool pickSurfaceAt(int x, int y,
uint32_t& object_id_out,
QVector3D& world_pos_out,
QVector3D& world_normal_out);
// Section planes — fragment-shader clipping with up to MaxSectionPlanes
// active. Each plane clips the half-space dot(n, p) + d > 0. addSection-
// PlaneAtSurface auto-flips the normal toward the camera so the first
// click immediately cuts away the camera-facing side.
static constexpr int MaxSectionPlanes = 8;
struct SectionPlane {
QVector3D n; // unit world-space normal
QVector3D origin; // point on the plane — the gizmo's anchor
float d; // = -dot(n, origin); kept in sync with origin
};
int sectionPlaneCount() const { return int(section_planes_.size()); }
bool addSectionPlaneAtSurface(const QVector3D& point, const QVector3D& normal);
void removeSectionPlane(int index);
void clearSectionPlanes();
// Section tool: when active, LMB on geometry creates a new plane (using
// pickSurfaceAt for hit-point + normal); LMB on an existing plane's
// arrow gizmo selects + drags it; Delete removes the selected plane;
// Esc or another K-press exits the tool.
void toggleSectionTool();
bool sectionToolActive() const { return section_tool_active_; }
// Projection: orthographic vs perspective. In ortho mode the visible
// box is sized to match what the perspective camera would show at the
// pivot's distance, so toggling at any zoom level keeps the framing.
void toggleProjection();
bool projectionOrtho() const { return projection_ortho_; }
// Snap the camera to a canonical axis-aligned view. Yaw/pitch are
// clamped according to the orbit convention; target and distance are
// preserved (the user explicitly asked for a rotate-only behavior).
void setStandardView(float yaw_deg, float pitch_deg);
void setCamera(float tx, float ty, float tz, float dist, float yaw, float pitch);
void setBenchmarkFrames(int n);
QString cameraString() const;
// Move camera_target_ to the selected object's world-AABB centroid and
// dolly camera_distance_ so the object's bounding sphere fits the
// current viewport. Yaw/pitch are preserved. No-op if no object is
// selected or its AABB is unknown.
void focusOnSelectedObject();
// Frame the union of all finalized models. No-op if the scene is empty.
void viewAll();
struct CameraState {
QVector3D target;
float distance;
float yaw; // degrees
float pitch; // degrees
};
CameraState cameraState() const;
struct FrameStats {
float fps;
float frame_time_ms;
uint32_t total_objects;
uint32_t visible_objects;
uint32_t total_triangles;
uint32_t visible_triangles;
uint32_t unique_meshes;
uint32_t gl_draw_calls; // actual glMultiDrawElementsIndirect issues per frame
uint32_t indirect_sub_draws; // total commands packed into those indirect buffers
};
signals:
void objectPicked(uint32_t object_id);
void initialized();
void frameStatsUpdated(const ViewportWindow::FrameStats& stats);
// Emitted instead of objectPicked when any measurement tool is active.
// The app branches on toolMode() to decide what to do, calls
// pickMeshLocalAt(x, y, ...) for hit details, and accumulates.
// modifiers carries Qt::KeyboardModifiers at click time (Alt etc.).
void surfacePickedInTool(int x, int y, int modifiers);
// Emitted whenever the active tool changes (incl. on→off transitions).
// The app uses this to reset accumulator state on entry/exit.
void toolModeChanged(ViewportWindow::ToolMode mode);
// Backspace/Delete pressed while a tool is active. Used by the length
// tool to remove the last point; other tools may ignore it.
void toolBackspacePressed();
protected:
void exposeEvent(QExposeEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
bool event(QEvent* event) override;
private:
enum class PendingOpType {
UploadMeshChunk,
UploadInstanceChunk,
FinalizeModel,
ApplyCachedModel,
ApplyLodExtension,
ResetScene,
HideModel,
ShowModel,
RemoveModel,
};
struct PendingOperation {
PendingOpType type;
MeshChunk mesh_chunk;
InstanceChunk instance_chunk;
SidecarData sidecar_data;
uint32_t model_id = 0;
};
void initGL();
void flushPendingOperations();
void enqueuePendingOperation(PendingOperation op);
void render();
void renderPickPass();
void renderAxisGizmo();
void renderPivotIndicator();
void renderSectionPlanes();
void buildSectionPlaneGizmo();
// Post-process edge enhancement: resolve MSAA depth into a single-
// sample texture, then run a fullscreen pass that detects sharp
// depth-laplacian peaks and darkens the colour buffer there. Catches
// silhouettes and overlapping-surface boundaries as faint dark lines.
void renderEdgePass();
// Returns the index of the section plane whose arrow gizmo is under
// (x, y), or -1 if none. Screen-space line-segment distance test.
int hitTestSectionGizmo(int x, int y) const;
// Update section_planes_[section_drag_index_] from the current cursor
// position by projecting the move onto the plane's normal axis in
// screen space.
void updateSectionDrag(int x, int y);
void updateCamera();
// Geometry queries used by focusOnSelectedObject() / viewAll(). Both
// return false when nothing matched (caller should leave the camera
// alone). Bounds are world-space AABBs.
bool computeObjectAabb(uint32_t object_id, QVector3D& mn, QVector3D& mx) const;
bool computeSceneAabb(QVector3D& mn, QVector3D& mx) const;
// Re-aim the orbit camera so the bounding sphere of [mn, mx] just fits
// vertically and horizontally within the current FOV, with `padding`
// headroom (1.0 = tight). Yaw/pitch are preserved; only target and
// distance change.
void frameAabb(const QVector3D& mn, const QVector3D& mx, float padding);
void buildShaders();
void buildAxisGizmo();
void buildPivotIndicator();
// Show/hide the orbit-pivot marker. hide_after_ms > 0 starts a single-shot
// timer that auto-hides — used by the wheel handler to give the marker a
// short afterglow after zoom. Drag-based callers pass 0 and toggle
// visibility manually on press/release.
void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
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);
ModelGpuData& getOrCreateModel(uint32_t model_id);
// Frustum-cull m's instances (BVH if available, else linear scan),
// build the per-mesh DrawElementsIndirectCommand array + flat visible
// list, and upload both to m.indirect_buffer / m.visible_ssbo.
//
// `min_pixel_radius` controls contribution culling: instances (and BVH
// subtrees) whose projected bounding-sphere radius would be below this
// many pixels are dropped. 0 = disabled (all frustum-visible kept),
// which is what the pick pass uses so clickable targets aren't filtered.
void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4],
float focal_px, float min_pixel_radius);
// Thread-safe: CPU-only cull (frustum + contribution + HiZ + bucketing +
// emit). Writes survivors into m.vis_* / m.visible_flat / m.indirect_scratch
// and sets m.indirect_forward_count / m.indirect_command_count /
// m.cached_visible_*. Touches no GL state and no ViewportWindow mutable
// state other than the atomic counters below — safe to run on a worker.
void cullModelCpu(ModelGpuData& m, const float planes[6][4],
float focal_px, float min_pixel_radius);
// Main-thread only: uploads m.visible_flat / m.indirect_scratch into the
// model's SSBO + indirect buffer, growing them if needed.
void uploadCullResults(ModelGpuData& m);
// Compose
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation
// for one instance: writes inst.transform (composed, float) and
// recomputes inst.world_aabb_* from the composed transform + the
// mesh's local AABB. Maths runs in double; narrow to float at the end.
void composeInstanceFromPlacement(InstanceCpu& inst, const ModelGpuData& m) const;
// Walk every instance of `model_id`, recompose `transform` from
// `placement_transformation` + current stage matrices, recompute world
// AABBs, re-upload the InstanceGpu SSBO, refresh instance_reflected,
// and rebuild the BVH. Posts an update. No-op if model_id is unknown
// or GL isn't initialised yet.
void recomposeAndUploadModel(uint32_t model_id);
// Mouse interaction
void handleMousePress(QMouseEvent* event);
void handleMouseRelease(QMouseEvent* event);
void handleMouseMove(QMouseEvent* event);
void handleWheel(QWheelEvent* event);
// FPS/fly mode. Toggled with Shift+F; exits on any mouse click or Esc.
// While active, WASD translates the camera in view-space, Q/E moves
// world-down/up, mouse rotates the view (cursor hidden + recentered each
// move), Shift accelerates, and the wheel scales the base move speed
// instead of zooming. The underlying orbit state is preserved: movement
// translates camera_target_ and rotation re-pins it so camera_eye_ stays
// put, so exiting drops the user back into orbit at the same viewpoint.
//
// Movement is integrated inside render() using wall-clock dt and the
// next frame is requestUpdate()'d while any movement key is held — that
// way one long frame only produces a single catch-up step instead of
// also missing a QTimer tick.
enum class CameraMode { Orbit, Fps };
void enterFpsMode();
void exitFpsMode();
void fpsIntegrate(); // called from render()
void recenterFpsCursor();
QOpenGLContext* context_ = nullptr;
QOpenGLFunctions_4_5_Core* gl_ = nullptr;
bool gl_initialized_ = false;
std::deque<PendingOperation> pending_ops_;
// Shaders
GLuint main_program_ = 0;
GLuint pick_program_ = 0;
GLuint axis_program_ = 0;
// Axis gizmo
GLuint axis_vao_ = 0;
GLuint axis_vbo_ = 0;
// Orbit-pivot indicator: 1 center vertex + (N+1) rim vertices on the unit
// circle (last == first to close the triangle fan). Rendered as a screen-
// space disc at camera_target_, visible only while the user is navigating.
GLuint pivot_program_ = 0;
GLuint pivot_vao_ = 0;
GLuint pivot_vbo_ = 0;
int pivot_rim_count_ = 0;
// Per-model GPU data
std::unordered_map<uint32_t, ModelGpuData> models_gpu_;
// FederatedFalseOrigin matrix, in metres. Default identity → no
// contribution to the composed transform. See Federation.h.
Eigen::Matrix4d federated_false_origin_meters_ = Eigen::Matrix4d::Identity();
// Pick framebuffer. Three color attachments:
// 0: R32UI — object_id
// 1: RGB32F — world position at hit
// 2: RGB16F — world normal at hit (already flipped for reflections in
// the vertex shader)
GLuint pick_fbo_ = 0;
GLuint pick_color_tex_ = 0;
GLuint pick_pos_tex_ = 0;
GLuint pick_normal_tex_ = 0;
GLuint pick_depth_rbo_ = 0;
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_downsample_program_ = 0;
GLuint hiz_downsample_vao_ = 0;
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;
std::atomic<uint32_t> hiz_reject_count_{0}; // per-frame stat
// Cull-phase timers. Accumulated across all frames in the current
// 1-second stats window; divided by frame_count_ at print time to
// give per-frame average ms. Reset each window. Lets us see where
// CPU time actually goes: bucket clears vs BVH traversal vs emit vs
// GPU upload.
// Atomic so parallel cull workers can fetch_add into them without
// contending on a lock. clr/trv/emt are SUMS across all worker threads
// for the frame — they describe total CPU work, not wall-clock. The
// wall counter is measured once around the dispatch block in render()
// and is what actually determines frame time.
std::atomic<uint64_t> cull_clear_ns_{0};
std::atomic<uint64_t> cull_traverse_ns_{0};
std::atomic<uint64_t> cull_emit_ns_{0};
std::atomic<uint64_t> cull_upload_ns_{0};
uint64_t cull_wall_ns_ = 0; // main-thread only
uint32_t cull_skipped_frames_ = 0;
// Skip cullAndUploadVisible + buildHizPyramid when the camera and scene
// haven't changed since the last cull. The existing per-model
// indirect_buffer / visible_ssbo are still correct and just get
// redrawn. Invalidated by any function that mutates models_gpu_.
QMatrix4x4 last_cull_view_;
QMatrix4x4 last_cull_proj_;
bool have_cached_cull_ = false;
// Motion-adaptive contribution culling. During camera motion, use a
// larger pixel-radius threshold to aggressively cull small objects.
// When the camera stops, re-cull once at the base threshold.
bool last_cull_was_motion_ = false;
// Benchmark mode: render N frames, collect stats, then exit.
int benchmark_total_ = 0;
int benchmark_count_ = 0;
int benchmark_warmup_ = 5;
float benchmark_yaw_start_ = 0.0f;
float benchmark_yaw_speed_ = 0.5f; // degrees per frame
std::vector<float> benchmark_frame_times_;
// Per-frame stats
uint32_t visible_triangles_ = 0;
uint32_t visible_objects_ = 0;
uint32_t gl_draw_calls_ = 0;
uint32_t indirect_sub_draws_ = 0;
// Camera
QVector3D camera_target_{0, 0, 0};
QVector3D camera_eye_{0, 0, 0}; // world-space eye, set in updateCamera
float camera_distance_ = 50.0f;
float camera_yaw_ = 45.0f;
float camera_pitch_ = 30.0f;
float camera_fov_y_deg_ = 45.0f;
bool projection_ortho_ = false;
QMatrix4x4 view_matrix_;
QMatrix4x4 proj_matrix_;
// Mouse
Qt::MouseButton active_button_ = Qt::NoButton;
QPoint last_mouse_pos_;
// Pivot indicator visibility — true while drag-navigating, or briefly
// after a wheel notch (the timer auto-clears it).
bool pivot_indicator_visible_ = false;
QTimer* pivot_indicator_hide_timer_ = nullptr;
// FPS/fly mode state. fps_keys_held_ tracks WASD/QE/Shift between
// press+release; fps_last_tick_ gates dt inside render().
// fps_ignore_next_mouse_move_ swallows the synthetic MouseMove that
// QCursor::setPos() generates after we recenter.
CameraMode camera_mode_ = CameraMode::Orbit;
QSet<int> fps_keys_held_;
float fps_move_speed_ = 5.0f; // m/s at speed=1
QElapsedTimer fps_last_tick_;
bool fps_ignore_next_mouse_move_ = false;
// Selection — set + active id + per-object_id flags SSBO (binding=3).
SelectionState selection_;
// LMB-press state. press_pick_id_ caches the object hit at press
// time so the release path can apply click semantics without a
// second pick pass. box_select_armed_ is set only when the press
// landed on empty space — a subsequent drag past the click
// threshold then promotes to box_select_active_, so a small wobble
// on a click doesn't accidentally box-select. Modifiers captured
// at press-time decide commit semantics: plain replace, Shift add,
// Ctrl remove.
uint32_t press_pick_id_ = 0;
bool box_select_armed_ = false;
bool box_select_active_ = false;
QPoint box_select_start_pos_;
QPoint box_select_current_pos_;
Qt::KeyboardModifiers box_select_press_mods_ = Qt::NoModifier;
// Active measurement tool: see ToolMode / surfacePickedInTool.
ToolMode tool_mode_ = ToolMode::None;
// Renders any client-supplied overlay primitives (highlight triangles
// today; lines/points/labels later) in their own pass after the main
// geometry, with depth-test on / depth-write off.
OverlayRenderer overlay_renderer_;
// Active section planes. Uploaded as uniform array each frame to the
// main + pick programs; capped at MaxSectionPlanes.
std::vector<SectionPlane> section_planes_;
bool section_tool_active_ = false;
int section_plane_selected_ = -1;
bool section_drag_active_ = false;
int section_drag_index_ = -1;
QVector3D section_drag_start_origin_;
QPoint section_drag_start_mouse_;
// Push the current plane list into a freshly-bound program. No-op if
// the program does not declare u_clip_count / u_clip_planes.
void uploadClipPlaneUniforms(GLuint program);
// GL resources for the per-plane visualization (quad outline + arrow).
GLuint plane_program_ = 0;
GLuint plane_vao_ = 0;
GLuint plane_vbo_ = 0;
int plane_quad_offset_ = 0;
int plane_quad_count_ = 0;
int plane_arrow_offset_ = 0;
int plane_arrow_count_ = 0;
// Edge-enhancement pass resources. edge_depth_tex_ is a single-sample
// resolve target the size of the window; we blit the default FB depth
// into it each frame, then sample it from the fullscreen edge shader.
GLuint edge_program_ = 0;
GLuint edge_depth_fbo_ = 0;
GLuint edge_depth_tex_ = 0;
GLuint edge_vao_ = 0; // empty VAO for fullscreen-triangle draw
int edge_w_ = 0;
int edge_h_ = 0;
// FPS smoothing
int frame_count_ = 0;
float accumulated_time_ = 0.0f;
float last_fps_ = 0.0f;
};
#endif // VIEWPORTWINDOW_H