diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h index ef79751806..729e4df147 100644 --- a/src/ifcviewer/InstancedGeometry.h +++ b/src/ifcviewer/InstancedGeometry.h @@ -24,14 +24,36 @@ #include #include -// Per-vertex layout for instanced meshes, stored in local coordinates. -// 28 bytes per vertex: -// pos(3 float) -- 12 B -// normal(3 float) -- 12 B -// color(4 bytes RGBA8, read as GL_UNSIGNED_BYTE*4 normalized) -- 4 B -static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 28; +// Per-vertex layout for instanced meshes, stored in local coordinates, +// quantized against each mesh's local AABB. 16 bytes per vertex: +// offset 0 pos 3 x uint16 normalized -> [0,1]; dequant to +// mix(mesh.aabb_min, mesh.aabb_max, t) +// offset 6 _pad 2 bytes +// offset 8 normal 2 x int16 normalized -> [-1,1]; octahedral-decoded +// offset 12 color 4 x uint8 normalized -> [0,1] +// +// Quantization basis is per mesh, stored in the MeshGpu SSBO bound at +// binding=2. The vertex shader looks up its basis via the instance's mesh_id. +static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 16; + +// Streamer-side intermediate format: 7 floats per vertex (pos3 + normal3 + +// color-as-float). GeometryStreamer writes this into MeshChunk.vertices; +// ViewportWindow::uploadMeshChunk quantizes it down to STRIDE_BYTES on the +// way to the VBO. Not the GPU layout — purely a transfer convention. static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7; +static constexpr int INSTANCED_VERTEX_POS_OFFSET = 0; +static constexpr int INSTANCED_VERTEX_NORMAL_OFFSET = 8; +static constexpr int INSTANCED_VERTEX_COLOR_OFFSET = 12; + +// Per-mesh quantization basis, uploaded to a std430 SSBO. Two vec4s so +// std430 layout is trivial (no alignment surprises). w components unused. +struct alignas(16) MeshGpu { + float aabb_min[4]; // xyz = local AABB min; w = 0 + float aabb_max[4]; // xyz = local AABB max; w = 0 +}; +static_assert(sizeof(MeshGpu) == 32, "MeshGpu must be 32 bytes"); + // Per-mesh metadata on the CPU side. Meshes own a slice of the model's // VBO (shared across LODs) and one or more slices of the EBO, one per LOD. // @@ -61,12 +83,13 @@ static_assert(sizeof(MeshInfo) == 56, "MeshInfo must be 56 bytes"); // mat4 transform (64 B column-major) // uint object_id // uint color_override_rgba8 -- 0 = use baked vertex color, else override -// uint _pad0, _pad1 -- align to 16 for std430 +// uint mesh_id -- index into per-model MeshGpu[] +// uint _pad1 -- align to 16 for std430 struct alignas(16) InstanceGpu { float transform[16]; uint32_t object_id = 0; uint32_t color_override_rgba8 = 0; - uint32_t _pad0 = 0; + uint32_t mesh_id = 0; // index into per-model MeshGpu[] uint32_t _pad1 = 0; }; static_assert(sizeof(InstanceGpu) == 80, "InstanceGpu must be 80 bytes"); diff --git a/src/ifcviewer/LodBuilder.cpp b/src/ifcviewer/LodBuilder.cpp index 88b8c9f046..35b97df44a 100644 --- a/src/ifcviewer/LodBuilder.cpp +++ b/src/ifcviewer/LodBuilder.cpp @@ -33,9 +33,8 @@ void buildLods(SidecarData& sd, float target_error) { if (sd.meshes.empty() || sd.vertices.empty() || sd.indices.empty()) return; - const size_t vtx_stride_bytes = INSTANCED_VERTEX_STRIDE_BYTES; - const size_t vtx_stride_floats = INSTANCED_VERTEX_STRIDE_FLOATS; - const size_t total_vertex_count = sd.vertices.size() / vtx_stride_floats; + const size_t vtx_stride_bytes = INSTANCED_VERTEX_STRIDE_BYTES; + const size_t total_vertex_count = sd.vertices.size() / vtx_stride_bytes; // Env var knobs so we can tune without rebuilding. // IFC_LOD_LOCK_BORDER=1 re-enable LockBorder (off by default: BIM @@ -73,8 +72,10 @@ void buildLods(SidecarData& sd, // Scratch buffers reused across meshes so we only allocate once. std::vector simplified; std::vector shadow; + std::vector dequant_pos; // 3 floats/vertex, dequantized simplified.reserve(1024); shadow.reserve(1024); + dequant_pos.reserve(1024 * 3); int dbg_printed = 0; int dbg_rejected_savings = 0; @@ -101,8 +102,27 @@ void buildLods(SidecarData& sd, const uint32_t first_index = mesh.ebo_byte_offset / sizeof(uint32_t); if (first_index + mesh.index_count > sd.indices.size()) continue; - const float* positions = - sd.vertices.data() + base_vertex * vtx_stride_floats; + // Dequantize positions for this mesh into a temp float array. + // meshopt needs contiguous float3 positions with a known stride; + // quantized bytes aren't directly usable. + const uint8_t* quant_base = + sd.vertices.data() + base_vertex * vtx_stride_bytes; + dequant_pos.resize(static_cast(mesh.vertex_count) * 3); + const float extent[3] = { + mesh.local_aabb_max[0] - mesh.local_aabb_min[0], + mesh.local_aabb_max[1] - mesh.local_aabb_min[1], + mesh.local_aabb_max[2] - mesh.local_aabb_min[2], + }; + for (uint32_t v = 0; v < mesh.vertex_count; ++v) { + const uint16_t* p = reinterpret_cast( + quant_base + v * vtx_stride_bytes); + for (int a = 0; a < 3; ++a) { + float t = p[a] / 65535.0f; + dequant_pos[v * 3 + a] = mesh.local_aabb_min[a] + t * extent[a]; + } + } + const float* positions = dequant_pos.data(); + const size_t local_pos_stride = sizeof(float) * 3; const uint32_t* indices = sd.indices.data() + first_index; const size_t target_index_count = std::max( @@ -121,7 +141,7 @@ void buildLods(SidecarData& sd, indices, mesh.index_count, positions, mesh.vertex_count, sizeof(float) * 3, // compare only xyz - vtx_stride_bytes); + local_pos_stride); simplified.resize(mesh.index_count); float result_error = 0.0f; @@ -135,7 +155,7 @@ void buildLods(SidecarData& sd, new_index_count = meshopt_simplifySloppy( simplified.data(), indices, mesh.index_count, - positions, mesh.vertex_count, vtx_stride_bytes, + positions, mesh.vertex_count, local_pos_stride, target_index_count, target_error, &result_error); } else { @@ -144,7 +164,7 @@ void buildLods(SidecarData& sd, new_index_count = meshopt_simplify( simplified.data(), shadow.data(), mesh.index_count, - positions, mesh.vertex_count, vtx_stride_bytes, + positions, mesh.vertex_count, local_pos_stride, target_index_count, target_error, options, &result_error); } diff --git a/src/ifcviewer/LodBuilder.h b/src/ifcviewer/LodBuilder.h index a937ae4987..0147ba82f9 100644 --- a/src/ifcviewer/LodBuilder.h +++ b/src/ifcviewer/LodBuilder.h @@ -35,9 +35,10 @@ // target_ratio = 0.25 — aim for 25% of original tris // target_error = 0.05 — stop if relative error exceeds 5% // -// `sd.vertices` is read (position is the first 3 floats of each -// INSTANCED_VERTEX_STRIDE_FLOATS-wide vertex) but not modified — LOD1 -// reuses the same vertex buffer, just with a different index list. +// `sd.vertices` is raw bytes at the quantized layout; positions are +// dequantized per-mesh (using MeshInfo.local_aabb_min/max) into a temp +// float array before feeding meshoptimizer. Vertices are not modified — +// LOD1 reuses the same VBO, just with a different index list. void buildLods(SidecarData& sd, int min_triangles = 500, float target_ratio = 0.25f, diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 7dc5454700..0e8162f043 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -239,7 +239,7 @@ void MainWindow::applySidecarData(ModelId mid, SidecarData data) { qDebug("Sidecar hit: %s (%zu verts, %zu indices, %zu meshes, %zu instances, %zu elements)", model.file_path.toStdString().c_str(), - data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS, + data.vertices.size() / INSTANCED_VERTEX_STRIDE_BYTES, data.indices.size(), data.meshes.size(), data.instances.size(), diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index afa2042610..be4a69ec42 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -46,9 +46,12 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. - **Per-model GPU buffers**: each loaded model gets its own VAO/VBO/EBO/instance-SSBO/visible-SSBO/indirect-buffer. No cross-model growth copies. Removing a model frees its GPU memory immediately. -- **Local-coordinate vertex format (28 B):** position (3 floats) + normal - (3 floats) + packed RGBA8 colour (1 uint). The per-instance transform is - applied in the vertex shader via an SSBO lookup. No world-baked vertex data. +- **Quantized local-coordinate vertex format (16 B):** position as + `u16x3` normalised against each mesh's local AABB, octahedral-encoded + normal as `i16x2`, packed RGBA8 colour. Dequantisation basis is per + mesh, uploaded once in a `MeshGpu` SSBO at binding 2. The per-instance + transform is applied in the vertex shader. No world-baked vertex data. + ~43 % smaller VBO and sidecar than the previous 28 B float layout. - **Multi-draw indirect:** every frame the CPU builds a flat list of visible instance indices and one `DrawElementsIndirectCommand` per non-empty mesh, then issues a single `glMultiDrawElementsIndirect` per model. 50k visible @@ -93,7 +96,7 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. | `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records | | `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs | | `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) | -| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v5) sidecar read/write | +| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v6) sidecar read/write | | `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) | | `SettingsWindow.h/cpp` | Settings dialog | | `CMakeLists.txt` | Build configuration | @@ -270,7 +273,7 @@ while stack not empty: Depth 64 is enough for billions of items on any balanced tree. The stack is on the C++ stack, zero per-frame allocation. -#### Sidecar format (`.ifcview`, v5) +#### Sidecar format (`.ifcview`, v6) Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing. Stores everything needed to skip the `IfcGeom::Iterator` pass: @@ -278,7 +281,7 @@ Stores everything needed to skip the `IfcGeom::Iterator` pass: ``` SidecarHeader (magic "IFVW", version, endian, ...) uint64_t source_file_size -uint32_t + float[] vertex data (7 floats × N_verts, local coords) +uint32_t + uint8_t[] vertex data (16 B/vert quantized; per-mesh basis in MeshInfo) uint32_t + uint32_t[] index data (mesh-local) uint32_t + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice) uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids) @@ -298,7 +301,8 @@ Per-model state on the GPU: | Buffer | Contents | Lifetime | |--------|----------|----------| -| `VBO` | Interleaved local-coord vertex data (28 B/vert). One range per unique representation. | Grow-on-demand during streaming; static after finalize. | +| `VBO` | Quantized local-coord vertex data (16 B/vert: u16x3 pos, oct i16x2 normal, RGBA8). One range per unique representation. | Grow-on-demand during streaming; static after finalize. | +| `MeshGpu SSBO` (binding 2) | Per-mesh dequant basis (`vec4 aabb_min`, `vec4 aabb_max`). | Grow-on-demand; static after finalize. | | `EBO` | Mesh-local uint32 indices. One range per unique representation. | Same. | | `SSBO` (binding 0) | `InstanceGpu[]` (80 B each: mat4 transform, object_id, color_override, pad). | Appended during streaming, static after finalize. | | `visible SSBO` (binding 1) | `uint32[]` — flat list of visible instance indices, ordered by mesh, uploaded each frame. | Rewritten every frame. | @@ -311,7 +315,7 @@ struct DrawElementsIndirectCommand { uint32_t count; // mesh.index_count uint32_t instanceCount; // visible-list length for this mesh uint32_t firstIndex; // mesh.ebo_byte_offset / 4 - uint32_t baseVertex; // mesh.vbo_byte_offset / 28 + uint32_t baseVertex; // mesh.vbo_byte_offset / 16 uint32_t baseInstance; // offset into the flat visible-index array }; ``` diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index da3943988d..171bf4bda6 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -17,17 +17,16 @@ * * ********************************************************************************/ -// v5 layout (all multi-byte fields native-endian; endianness marker in header). -// Same sequence as v4; the only change is that MeshInfo grew two uint32_ts -// (lod1_ebo_byte_offset + lod1_index_count) and `indices` may contain extra -// appended LOD1 slices pointed at by those offsets. +// v6 layout (all multi-byte fields native-endian; endianness marker in header). +// Same sequence as v5; the only change is that vertex data is now raw bytes +// at the 16 B/vertex quantized layout (see InstancedGeometry.h). // // // SidecarHeader (16 bytes) // uint64_t source_file_size // -// uint32_t num_vertices_floats -// float[] vertex data (28 B/vertex: pos3 + normal3 + color1_packed) +// uint32_t num_vertex_bytes +// uint8_t[] vertex data (16 B/vertex: pos u16x3 + pad2 + oct-normal i16x2 + rgba8) // uint32_t num_indices // uint32_t[] index data (mesh-local indices; base_vertex applied at draw time) // diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index 332abdc802..e2e34373ab 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -37,7 +37,9 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" // v5 = MeshInfo extended with lod1_ebo_byte_offset + lod1_index_count (56 B). // sd.indices may contain an appended LOD1 index slice for each mesh // where meshoptimizer decimation produced useful output. -static constexpr uint32_t SIDECAR_VERSION = 5; +// v6 = VBO vertices quantized to 16 B/vertex (pos u16x3 + normal oct i16x2 + +// color u8x4). Dequant basis is per-mesh MeshInfo.local_aabb_min/max. +static constexpr uint32_t SIDECAR_VERSION = 6; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; // Fixed-size element record. Strings are stored as (offset, length) pairs @@ -56,10 +58,11 @@ struct PackedElementInfo { }; // Everything needed to display an already-tessellated model without -// re-running the iterator. v4 schema: instanced geometry. +// re-running the iterator. v6 schema: instanced + quantized geometry. struct SidecarData { - // Per-model GPU geometry (local coords). 28 bytes/vertex. - std::vector vertices; + // Per-model GPU geometry (local coords). Raw VBO bytes at the + // INSTANCED_VERTEX_STRIDE_BYTES layout (16 B/vertex as of v6). + std::vector vertices; std::vector indices; // Mesh dictionary and per-instance data. diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index a48ef7f6d4..4731a43186 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -44,16 +44,17 @@ static_assert(sizeof(DrawElementsIndirectCommand) == 20, "indirect cmd must be 2 // Shaders // ----------------------------------------------------------------------------- // -// Vertex layout (GL side, 28 bytes): -// location 0: vec3 a_position (local coords) -// location 1: vec3 a_normal (local) -// location 2: vec4 a_color (GL_UNSIGNED_BYTE * 4 normalized) +// Vertex layout (GL side, 16 bytes — quantized; see InstancedGeometry.h): +// location 0: vec3 a_position_q (u16x3 normalized, per-mesh AABB basis) +// location 1: vec2 a_normal_oct (i16x2 normalized, octahedral) +// location 2: vec4 a_color (u8x4 normalized) // // Per-instance record in SSBO std430 (80 bytes): // mat4 transform // uint object_id // uint color_override_rgba8 -- 0 => use baked a_color -// uint _pad0, _pad1 +// uint mesh_id -- index into per-model MeshGpu[] +// uint _pad1 // // The draw calls pass `u_instance_offset = mesh.first_instance`; the shader // reads `instances[u_instance_offset + gl_InstanceID]`. @@ -61,15 +62,16 @@ static_assert(sizeof(DrawElementsIndirectCommand) == 20, "indirect cmd must be 2 static const char* MAIN_VERTEX_SHADER = R"( #version 450 core #extension GL_ARB_shader_draw_parameters : require -layout(location = 0) in vec3 a_position; -layout(location = 1) in vec3 a_normal; +// Quantized vertex inputs — see InstancedGeometry.h for layout. +layout(location = 0) in vec3 a_position_q; // u16x3 normalized -> [0,1] +layout(location = 1) in vec2 a_normal_oct; // i16x2 normalized -> [-1,1] layout(location = 2) in vec4 a_color; struct InstanceRecord { mat4 transform; uint object_id; uint color_override; - uint _pad0; + uint mesh_id; uint _pad1; }; layout(std430, binding = 0) readonly buffer Instances { @@ -78,6 +80,10 @@ layout(std430, binding = 0) readonly buffer Instances { layout(std430, binding = 1) readonly buffer VisibleIndices { uint visible[]; }; +struct MeshQuant { vec4 aabb_min; vec4 aabb_max; }; +layout(std430, binding = 2) readonly buffer Meshes { + MeshQuant meshes[]; +}; uniform mat4 u_view_projection; uniform uint u_selected_id; @@ -87,11 +93,24 @@ out vec4 v_color; flat out uint v_object_id; flat out uint v_selected; +// Meyer et al. octahedral normal decode. Input is in [-1,1]^2. +vec3 octDecode(vec2 e) { + vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y)); + if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, + n.y >= 0.0 ? 1.0 : -1.0); + return normalize(n); +} + void main() { uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID); uint iid = visible[slot]; InstanceRecord inst = instances[iid]; - vec4 world = inst.transform * vec4(a_position, 1.0); + MeshQuant mq = meshes[inst.mesh_id]; + + // Dequantize local position against this mesh's AABB. + vec3 pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, a_position_q); + + vec4 world = inst.transform * vec4(pos_local, 1.0); gl_Position = u_view_projection * world; // Rotate the normal by the upper-3x3 of the transform. BIM placements @@ -101,8 +120,9 @@ void main() { // otherwise mirrored instances shade as if inside-out. The same // determinant sign is what GL_CULL_FACE uses to decide winding, so // keeping them in agreement means backface culling is safe to enable. + vec3 n_local = octDecode(a_normal_oct); mat3 rot = mat3(inst.transform); - vec3 n = rot * a_normal; + vec3 n = rot * n_local; if (determinant(rot) < 0.0) n = -n; v_normal = normalize(n); @@ -152,13 +172,13 @@ void main() { static const char* PICK_VERTEX_SHADER = R"( #version 450 core #extension GL_ARB_shader_draw_parameters : require -layout(location = 0) in vec3 a_position; +layout(location = 0) in vec3 a_position_q; struct InstanceRecord { mat4 transform; uint object_id; uint color_override; - uint _pad0; + uint mesh_id; uint _pad1; }; layout(std430, binding = 0) readonly buffer Instances { @@ -167,6 +187,10 @@ layout(std430, binding = 0) readonly buffer Instances { layout(std430, binding = 1) readonly buffer VisibleIndices { uint visible[]; }; +struct MeshQuant { vec4 aabb_min; vec4 aabb_max; }; +layout(std430, binding = 2) readonly buffer Meshes { + MeshQuant meshes[]; +}; uniform mat4 u_view_projection; @@ -176,7 +200,9 @@ void main() { uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID); uint iid = visible[slot]; InstanceRecord inst = instances[iid]; - gl_Position = u_view_projection * inst.transform * vec4(a_position, 1.0); + MeshQuant mq = meshes[inst.mesh_id]; + vec3 pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, a_position_q); + gl_Position = u_view_projection * inst.transform * vec4(pos_local, 1.0); v_object_id = inst.object_id; } )"; @@ -240,6 +266,51 @@ static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint fra // ----------------------------------------------------------------------------- +// Meyer et al. octahedral normal encode. Input unit vector -> [-1,1]^2. +static void octEncode(const float n[3], float out[2]) { + float ax = std::fabs(n[0]), ay = std::fabs(n[1]), az = std::fabs(n[2]); + float denom = ax + ay + az; + if (denom < 1e-12f) { out[0] = 0.0f; out[1] = 0.0f; return; } + float px = n[0] / denom; + float py = n[1] / denom; + if (n[2] < 0.0f) { + float sx = px >= 0.0f ? 1.0f : -1.0f; + float sy = py >= 0.0f ? 1.0f : -1.0f; + float nx = (1.0f - std::fabs(py)) * sx; + float ny = (1.0f - std::fabs(px)) * sy; + px = nx; py = ny; + } + out[0] = px; + out[1] = py; +} + +// Quantize a streamer-format vertex (pos3 + normal3 + color-as-float) into +// the 16 B VBO record, given the mesh's tight local AABB. `extent_recip` +// is 1/(max-min) per axis, or 0 for degenerate axes (quantum becomes 0). +static void quantizeVertex(const float src[7], + const float aabb_min[3], + const float extent_recip[3], + uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES]) { + // Position -> u16 normalized. + uint16_t* p = reinterpret_cast(dst + INSTANCED_VERTEX_POS_OFFSET); + for (int a = 0; a < 3; ++a) { + float t = (src[a] - aabb_min[a]) * extent_recip[a]; + if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; + p[a] = static_cast(t * 65535.0f + 0.5f); + } + // Normal -> oct i16x2. + float oct[2]; + octEncode(src + 3, oct); + int16_t* n = reinterpret_cast(dst + INSTANCED_VERTEX_NORMAL_OFFSET); + for (int a = 0; a < 2; ++a) { + float v = oct[a]; + if (v < -1.0f) v = -1.0f; else if (v > 1.0f) v = 1.0f; + n[a] = static_cast(std::lrintf(v * 32767.0f)); + } + // Color passes through — streamer packs 4 bytes into the 7th float slot. + std::memcpy(dst + INSTANCED_VERTEX_COLOR_OFFSET, src + 6, 4); +} + // Determinant of the upper-left 3x3 of a column-major mat4 stored as 16 floats. // Sign tells us whether the transform contains a reflection, which is what // decides which glFrontFace winding to draw the instance with. @@ -334,6 +405,7 @@ ViewportWindow::~ViewportWindow() { if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); + if (m.mesh_info_ssbo) gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); } @@ -396,19 +468,22 @@ void ViewportWindow::setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo) { gl_->glVertexArrayVertexBuffer(vao, 0, vbo, 0, INSTANCED_VERTEX_STRIDE_BYTES); gl_->glVertexArrayElementBuffer(vao, ebo); - // position (3 float @ 0) + // position (3 x u16 normalized @ 0) gl_->glEnableVertexArrayAttrib(vao, 0); - gl_->glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0); + gl_->glVertexArrayAttribFormat(vao, 0, 3, GL_UNSIGNED_SHORT, GL_TRUE, + INSTANCED_VERTEX_POS_OFFSET); gl_->glVertexArrayAttribBinding(vao, 0, 0); - // normal (3 float @ 12) + // normal oct-encoded (2 x i16 normalized @ 8) gl_->glEnableVertexArrayAttrib(vao, 1); - gl_->glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, 12); + gl_->glVertexArrayAttribFormat(vao, 1, 2, GL_SHORT, GL_TRUE, + INSTANCED_VERTEX_NORMAL_OFFSET); gl_->glVertexArrayAttribBinding(vao, 1, 0); - // color (4 ubyte @ 24, normalized) + // color (4 x u8 normalized @ 12) gl_->glEnableVertexArrayAttrib(vao, 2); - gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE, 24); + gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE, + INSTANCED_VERTEX_COLOR_OFFSET); gl_->glVertexArrayAttribBinding(vao, 2, 0); } @@ -544,8 +619,44 @@ void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { ModelGpuData& m = getOrCreateModel(chunk.model_id); - const size_t vb_size = chunk.vertices.size() * sizeof(float); - const size_t ib_size = chunk.indices.size() * sizeof(uint32_t); + // Streamer format: 7 floats/vertex (pos3 + normal3 + color-as-float). + const size_t src_stride_floats = 7; + const size_t n_verts = chunk.vertices.size() / src_stride_floats; + + // Recompute a tight local AABB from the actual vertex positions — the + // chunk-provided AABB can be slightly loose, which wastes quantization + // precision. Also derives the dequant basis we'll ship to the GPU. + float bmin[3] = { std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::infinity() }; + float bmax[3] = { -std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity() }; + for (size_t i = 0; i < n_verts; ++i) { + const float* v = chunk.vertices.data() + i * src_stride_floats; + for (int a = 0; a < 3; ++a) { + if (v[a] < bmin[a]) bmin[a] = v[a]; + if (v[a] > bmax[a]) bmax[a] = v[a]; + } + } + // Degenerate / zero-extent axis: collapse to a single quantum. The + // dequant shader will output bmin[a] for every vertex, which is correct. + float extent_recip[3]; + for (int a = 0; a < 3; ++a) { + float ext = bmax[a] - bmin[a]; + extent_recip[a] = ext > 0.0f ? 1.0f / ext : 0.0f; + } + + // Quantize into a scratch buffer sized to the destination layout. + std::vector quant(n_verts * INSTANCED_VERTEX_STRIDE_BYTES); + for (size_t i = 0; i < n_verts; ++i) { + quantizeVertex(chunk.vertices.data() + i * src_stride_floats, + bmin, extent_recip, + quant.data() + i * INSTANCED_VERTEX_STRIDE_BYTES); + } + + const size_t vb_size = quant.size(); + const size_t ib_size = chunk.indices.size() * sizeof(uint32_t); if (m.vbo_used + vb_size > m.vbo_capacity) { if (!growModelVbo(m, m.vbo_used + vb_size)) return; @@ -556,18 +667,17 @@ void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { MeshInfo info; info.vbo_byte_offset = static_cast(m.vbo_used); - info.vertex_count = static_cast( - chunk.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS); + info.vertex_count = static_cast(n_verts); info.ebo_byte_offset = static_cast(m.ebo_used); info.index_count = static_cast(chunk.indices.size()); for (int a = 0; a < 3; ++a) { - info.local_aabb_min[a] = chunk.local_aabb_min[a]; - info.local_aabb_max[a] = chunk.local_aabb_max[a]; + info.local_aabb_min[a] = bmin[a]; + info.local_aabb_max[a] = bmax[a]; } info.first_instance = 0; info.instance_count = 0; - gl_->glNamedBufferSubData(m.vbo, m.vbo_used, vb_size, chunk.vertices.data()); + gl_->glNamedBufferSubData(m.vbo, m.vbo_used, vb_size, quant.data()); gl_->glNamedBufferSubData(m.ebo, m.ebo_used, ib_size, chunk.indices.data()); m.vbo_used += vb_size; m.ebo_used += ib_size; @@ -575,6 +685,33 @@ void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { if (m.meshes.size() <= chunk.local_mesh_id) m.meshes.resize(chunk.local_mesh_id + 1); m.meshes[chunk.local_mesh_id] = info; + + // Write the matching dequant basis into the MeshGpu SSBO. Grow on + // demand; geometrically doubling keeps this amortized O(1) over streaming. + MeshGpu mg{}; + for (int a = 0; a < 3; ++a) { + mg.aabb_min[a] = bmin[a]; + mg.aabb_max[a] = bmax[a]; + } + mg.aabb_min[3] = 0.0f; + mg.aabb_max[3] = 0.0f; + + const size_t mg_offset = chunk.local_mesh_id * sizeof(MeshGpu); + if (mg_offset + sizeof(MeshGpu) > m.mesh_info_capacity) { + size_t new_cap = m.mesh_info_capacity ? m.mesh_info_capacity : 32 * sizeof(MeshGpu); + while (new_cap < mg_offset + sizeof(MeshGpu)) new_cap *= 2; + GLuint new_ssbo = 0; + gl_->glCreateBuffers(1, &new_ssbo); + gl_->glNamedBufferStorage(new_ssbo, new_cap, nullptr, GL_DYNAMIC_STORAGE_BIT); + if (m.mesh_info_ssbo && m.mesh_info_capacity > 0) { + gl_->glCopyNamedBufferSubData(m.mesh_info_ssbo, new_ssbo, 0, 0, + m.mesh_info_capacity); + gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); + } + m.mesh_info_ssbo = new_ssbo; + m.mesh_info_capacity = new_cap; + } + gl_->glNamedBufferSubData(m.mesh_info_ssbo, mg_offset, sizeof(MeshGpu), &mg); } void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { @@ -594,6 +731,15 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { m.instances.push_back(inst); m.instance_reflected.push_back(transformIsReflected(inst.transform) ? 1 : 0); + // Mirror into bvh_items so the hot cull path (which reads AABBs out of + // bvh_items even when no BVH has been built yet) stays correct during + // streaming. finalizeModel rebuilds the real BVH over these items. + BvhItem bi; + std::memcpy(bi.aabb_min, inst.world_aabb_min, sizeof(bi.aabb_min)); + std::memcpy(bi.aabb_max, inst.world_aabb_max, sizeof(bi.aabb_max)); + bi.model_id = inst.model_id; + m.bvh_items.push_back(bi); + // Append the GPU record to the instance SSBO so the model is drawable // immediately, without waiting for finalizeModel. The visible-list // architecture means SSBO order is irrelevant to correctness. @@ -601,7 +747,7 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { std::memcpy(gpu.transform, inst.transform, sizeof(gpu.transform)); gpu.object_id = inst.object_id; gpu.color_override_rgba8 = inst.color_override_rgba8; - gpu._pad0 = 0; + gpu.mesh_id = inst.mesh_id; gpu._pad1 = 0; const size_t offset = m.ssbo_instance_count * sizeof(InstanceGpu); @@ -659,9 +805,10 @@ bool ViewportWindow::snapshotModel(uint32_t model_id, SidecarData& out) const { const auto& m = it->second; if (!m.finalized) return false; - // GPU readback of the packed VBO/EBO ranges actually in use. + // GPU readback of the packed VBO/EBO ranges actually in use. VBO is + // raw bytes at the quantized layout. if (m.vbo_used > 0) { - out.vertices.resize(m.vbo_used / sizeof(float)); + out.vertices.resize(m.vbo_used); gl_->glGetNamedBufferSubData(m.vbo, 0, m.vbo_used, out.vertices.data()); } if (m.ebo_used > 0) { @@ -685,6 +832,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.vbo) gl_->glDeleteBuffers(1, &existing->second.vbo); if (existing->second.ebo) gl_->glDeleteBuffers(1, &existing->second.ebo); if (existing->second.ssbo) gl_->glDeleteBuffers(1, &existing->second.ssbo); + if (existing->second.mesh_info_ssbo) gl_->glDeleteBuffers(1, &existing->second.mesh_info_ssbo); if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); models_gpu_.erase(existing); @@ -695,7 +843,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { gl_->glCreateBuffers(1, &m.vbo); gl_->glCreateBuffers(1, &m.ebo); - const size_t vb_bytes = data.vertices.size() * sizeof(float); + const size_t vb_bytes = data.vertices.size(); const size_t ib_bytes = data.indices.size() * sizeof(uint32_t); m.vbo_capacity = std::max(vb_bytes, 1); m.ebo_capacity = std::max(ib_bytes, 1); @@ -709,8 +857,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { m.vbo_used = vb_bytes; m.ebo_used = ib_bytes; - m.vertex_count = static_cast( - data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS); + m.vertex_count = static_cast(vb_bytes / INSTANCED_VERTEX_STRIDE_BYTES); m.meshes = std::move(data.meshes); m.instances = std::move(data.instances); @@ -728,7 +875,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { std::memcpy(dst.transform, src.transform, sizeof(dst.transform)); dst.object_id = src.object_id; dst.color_override_rgba8 = src.color_override_rgba8; - dst._pad0 = 0; + dst.mesh_id = src.mesh_id; dst._pad1 = 0; } gl_->glCreateBuffers(1, &m.ssbo); @@ -738,6 +885,30 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { } m.ssbo_instance_count = static_cast(gpu.size()); + // Build and upload the per-mesh quantization SSBO from cached meshes. + { + std::vector mesh_gpu(m.meshes.size()); + for (size_t i = 0; i < m.meshes.size(); ++i) { + for (int a = 0; a < 3; ++a) { + mesh_gpu[i].aabb_min[a] = m.meshes[i].local_aabb_min[a]; + mesh_gpu[i].aabb_max[a] = m.meshes[i].local_aabb_max[a]; + } + mesh_gpu[i].aabb_min[3] = 0.0f; + mesh_gpu[i].aabb_max[3] = 0.0f; + } + const size_t mg_bytes = mesh_gpu.size() * sizeof(MeshGpu); + gl_->glCreateBuffers(1, &m.mesh_info_ssbo); + if (mg_bytes > 0) { + gl_->glNamedBufferStorage(m.mesh_info_ssbo, mg_bytes, + mesh_gpu.data(), GL_DYNAMIC_STORAGE_BIT); + m.mesh_info_capacity = mg_bytes; + } else { + gl_->glNamedBufferStorage(m.mesh_info_ssbo, sizeof(MeshGpu), + nullptr, GL_DYNAMIC_STORAGE_BIT); + m.mesh_info_capacity = sizeof(MeshGpu); + } + } + // Recompute the reflection flag from each instance's transform — the // sidecar only caches InstanceCpu, not the parallel reflection flags. m.instance_reflected.resize(m.instances.size()); @@ -754,7 +925,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { qDebug("Sidecar apply: model %u %zu verts, %zu meshes, %zu instances " "%.1f MB vram (vbo %.1f + ebo %.1f + ssbo %.1f)", - model_id, data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS, + model_id, vb_bytes / INSTANCED_VERTEX_STRIDE_BYTES, models_gpu_[model_id].meshes.size(), models_gpu_[model_id].instances.size(), (vb_bytes + ib_bytes + ssbo_bytes) / (1024.0*1024.0), @@ -803,6 +974,7 @@ void ViewportWindow::resetScene() { if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); + if (m.mesh_info_ssbo) gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); } @@ -839,6 +1011,7 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.vbo) gl_->glDeleteBuffers(1, &it->second.vbo); if (it->second.ebo) gl_->glDeleteBuffers(1, &it->second.ebo); if (it->second.ssbo) gl_->glDeleteBuffers(1, &it->second.ssbo); + if (it->second.mesh_info_ssbo) gl_->glDeleteBuffers(1, &it->second.mesh_info_ssbo); if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); models_gpu_.erase(it); @@ -1455,6 +1628,7 @@ void ViewportWindow::render() { gl_->glBindVertexArray(m.vao); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); uint32_t fwd = m.indirect_forward_count; @@ -1622,6 +1796,7 @@ void ViewportWindow::renderPickPass() { gl_->glBindVertexArray(m.vao); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); const uint32_t fwd = m.indirect_forward_count; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 0a95ede077..ed6668cc11 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -60,6 +60,8 @@ struct ModelGpuData { 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;