diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 453dea768c..917b3dbc85 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -28,6 +28,7 @@ #include "CameraMath.h" #include "InstanceCompose.h" +#include "Log.h" namespace { // Orbit camera around target_. World +Z up (BIM convention). Yaw is @@ -543,3 +544,571 @@ ViewportCore::volumesPerObject( } return out; } + +// =========================================================================== +// Pipeline construction (#84-k) +// =========================================================================== + +namespace { +// WGPUStringView builder for null-terminated C strings. Used heavily by +// label fields and shader source descriptors. Tiny but worth a name. +WGPUStringView svFromCStr(const char* s) { + WGPUStringView v{}; + v.data = s; + v.length = std::strlen(s); + return v; +} + +static const char* MAIN_WGSL = R"( +struct InstanceRecord { + transform: mat4x4, + object_id: u32, + color_override: u32, + mesh_id: u32, + _pad1: u32, +}; + +struct MeshQuant { + aabb_min: vec4, + aabb_max: vec4, +}; + +struct FrameUniforms { + view_proj: mat4x4, + light_dir: vec4, + fill_dir: vec4, + sky_color: vec4, + ground_color: vec4, + clip_count: i32, + // Three scalar i32 pads instead of vec3: vec3 has 16-byte + // alignment so it would also pad the SUBSEQUENT clip_planes start + // up to offset 160. Three i32s pad to 144 with no further nudge, + // matching the tightly-packed C++ FrameUniforms (240 B). + _pad_clip_0: i32, + _pad_clip_1: i32, + _pad_clip_2: i32, + clip_planes: array, 6>, + // X-ray mode cap. fs_main clamps `out.a = min(in.color.a, xray_alpha_cap)`. + // Default 1.0 (no effect — the min returns in.color.a). Alt+X drops it + // toward ~0.3 to translucent-everything. The cull classifier also + // routes every instance into the transparent pass when this is < 1 + // so the blend stage actually fires (an opaque-pass fragment with + // capped alpha would still overwrite the back buffer). + xray_alpha_cap: f32, + _pad_xray_0: f32, + _pad_xray_1: f32, + _pad_xray_2: f32, +}; + +// Returns true if `world` lies on the positive (clipped-away) side of any +// active section plane. Each plane is (n.xyz, d) and clips where +// dot(n, world) + d > 0. Both the main and pick fragments discard with +// this predicate so cuts are visible AND consistent with selection. +fn is_section_clipped(world: vec3) -> bool { + let n = u_frame.clip_count; + if (n == 0) { return false; } + for (var i = 0; i < n; i = i + 1) { + let p = u_frame.clip_planes[i]; + if (dot(p.xyz, world) + p.w > 0.0) { return true; } + } + return false; +} + +struct VisibleDraw { + mesh_id: u32, + instance_idx: u32, + ebo_first_u32: u32, + base_vertex: u32, +}; + +struct PerModel { + draw_count: u32, + total_vertex_count: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var u_frame: FrameUniforms; +// Selection flags indexed by object_id. bit 0 = in selection, bit 1 = active. +// Sized to next_object_id_ on the CPU side; out-of-range reads can't happen +// because we cap the index by arrayLength before fetching. +@group(0) @binding(1) var sel_flags: array; + +@group(1) @binding(0) var vertices: array; +@group(1) @binding(1) var meshes: array; +@group(1) @binding(2) var instances: array; +@group(1) @binding(3) var indices: array; +@group(1) @binding(4) var visible_draws: array; +@group(1) @binding(5) var prefix_sums: array; +@group(1) @binding(6) var u_model: PerModel; + +struct VsOut { + @builtin(position) clip_pos: vec4, + @location(0) normal: vec3, + @location(1) color: vec4, + @location(2) world_pos: vec3, + @location(3) @interpolate(flat) object_id: u32, +}; + +// Sign-extend an i8 packed into the byte_idx'th byte of `packed`. +fn extractI8(packed: u32, byte_idx: u32) -> i32 { + let raw = i32((packed >> (byte_idx * 8u)) & 0xFFu); + return select(raw, raw - 256, raw >= 128); +} + +// Meyer et al. octahedral normal decode. Input in [-1,1]^2. +fn octDecode(e: vec2) -> vec3 { + var n = vec3(e.x, e.y, 1.0 - abs(e.x) - abs(e.y)); + if (n.z < 0.0) { + let tx = select(-1.0, 1.0, n.x >= 0.0); + let ty = select(-1.0, 1.0, n.y >= 0.0); + n = vec3((1.0 - abs(n.y)) * tx, (1.0 - abs(n.x)) * ty, n.z); + } + return normalize(n); +} + +// Binary search for the largest i in [0, draw_count) with prefix_sums[i] <= vid. +// prefix_sums is monotonic non-decreasing and contains draw_count+1 entries +// (prefix_sums[draw_count] == total_vertex_count). +fn find_draw(vid: u32) -> u32 { + var lo: u32 = 0u; + var hi: u32 = u_model.draw_count; + while (lo + 1u < hi) { + let mid = (lo + hi) >> 1u; + if (prefix_sums[mid] <= vid) { + lo = mid; + } else { + hi = mid; + } + } + return lo; +} + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { + // Saturate past the end (shouldn't happen given draw() count, but safe). + if (vid >= u_model.total_vertex_count) { + var degen: VsOut; + degen.clip_pos = vec4(0.0, 0.0, 0.0, 0.0); + return degen; + } + + let draw_idx = find_draw(vid); + let local_v = vid - prefix_sums[draw_idx]; + let item = visible_draws[draw_idx]; + + // Fetch the mesh-local index then the global vertex index. + let mesh_local_index = indices[item.ebo_first_u32 + local_v]; + let v_global = item.base_vertex + mesh_local_index; + + let inst = instances[item.instance_idx]; + let mq = meshes[item.mesh_id]; + + let w0 = vertices[v_global * 3u + 0u]; + let w1 = vertices[v_global * 3u + 1u]; + let w2 = vertices[v_global * 3u + 2u]; + + let px = f32(w0 & 0xFFFFu) / 65535.0; + let py = f32((w0 >> 16u) & 0xFFFFu) / 65535.0; + let pz = f32(w1 & 0xFFFFu) / 65535.0; + let pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, vec3(px, py, pz)); + + let nx = f32(extractI8(w1, 2u)) / 127.0; + let ny = f32(extractI8(w1, 3u)) / 127.0; + let n_local = octDecode(vec2(nx, ny)); + + let r = f32(w2 & 0xFFu) / 255.0; + let g = f32((w2 >> 8u) & 0xFFu) / 255.0; + let b = f32((w2 >> 16u) & 0xFFu) / 255.0; + let a = f32((w2 >> 24u) & 0xFFu) / 255.0; + + let world4 = inst.transform * vec4(pos_local, 1.0); + let rot = mat3x3(inst.transform[0].xyz, + inst.transform[1].xyz, + inst.transform[2].xyz); + let n_world = normalize(rot * n_local); + let det = determinant(rot); + let n_final = select(n_world, -n_world, det < 0.0); + + var color = vec4(r, g, b, a); + if (inst.color_override != 0u) { + let cr = f32(inst.color_override & 0xFFu) / 255.0; + let cg = f32((inst.color_override >> 8u) & 0xFFu) / 255.0; + let cb = f32((inst.color_override >> 16u) & 0xFFu) / 255.0; + let ca = f32((inst.color_override >> 24u) & 0xFFu) / 255.0; + if (ca > 0.0) { color = vec4(cr, cg, cb, ca); } + } + + var out: VsOut; + out.clip_pos = u_frame.view_proj * world4; + out.normal = n_final; + out.color = color; + out.world_pos = world4.xyz; + out.object_id = inst.object_id; + return out; +} + +// sRGB decode — used to undo wgpu's automatic linear→sRGB write encoding +// on swap-chain BGRA8Unorm so the final bytes match what the GL backend +// writes directly. The GL pipeline outputs to a non-sRGB FB and treats +// every colour input as already-linear, so its bytes are exactly its +// shader outputs. wgpu on the same swap chain auto-encodes, which makes +// everything appear ~3× brighter unless we pre-decode once. +fn srgbToLinear(s: vec3) -> vec3 { + let lo = s / 12.92; + let hi = pow((s + 0.055) / 1.055, vec3(2.4)); + return select(hi, lo, s <= vec3(0.04045)); +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + if (is_section_clipped(in.world_pos)) { discard; } + + var n = normalize(in.normal); + + // World +Z is up (BIM convention). Hemisphere ambient: faces pointing + // up read sky, faces pointing down read ground, lerp by n.z. + let hemi_t = 0.5 + 0.5 * n.z; + let ambient = mix(u_frame.ground_color.xyz, u_frame.sky_color.xyz, hemi_t); + + let key = max(dot(n, u_frame.light_dir.xyz), 0.0); + let fill = max(dot(n, u_frame.fill_dir.xyz), 0.0) * 0.35; + + var color = in.color.xyz * (ambient + (key + fill) * 0.7); + + // Cavity shading: where adjacent fragments have a sharp normal change + // (concave creases, edges where two faces meet), darken slightly so + // shape boundaries read on flat-colour models. Matches the GL shader. + let cavity = clamp(length(fwidth(n)) * 1.5, 0.0, 0.35); + color = color * (1.0 - cavity); + + // Selection tint. bit 0 = in selection (cool blue mix), bit 1 = active + // (slightly stronger blue mix). Matches the GL main shader. + if (in.object_id < arrayLength(&sel_flags)) { + let flags = sel_flags[in.object_id]; + if ((flags & 1u) != 0u) { color = mix(color, vec3(0.2, 0.6, 1.0), 0.45); } + if ((flags & 2u) != 0u) { color = mix(color, vec3(0.4, 0.8, 1.0), 0.40); } + } + + // Cancel the swap chain's implicit linear→sRGB encoding so the final + // bytes match the GL backend (see srgbToLinear above). Alpha is + // clamped to `xray_alpha_cap` (default 1.0 = no effect; X-ray sets + // it to ~0.3) so a global translucency override lands without + // touching any per-instance state. + let alpha_out = min(in.color.a, u_frame.xray_alpha_cap); + return vec4(srgbToLinear(color), alpha_out); +} + +// --------------------------- Pick pipeline --------------------------------- +// Same vertex pulling as vs_main, but VsOutPick carries only the object_id +// (flat-interpolated). Fragment writes the object_id to an R32UInt target. +// Background (no draw) reads 0 because the pick attachment is cleared to 0. + +struct VsOutPick { + @builtin(position) clip_pos: vec4, + @location(0) @interpolate(flat) object_id: u32, + @location(1) world_pos: vec3, + @location(2) normal: vec3, +}; + +// Section tool needs the actual per-fragment normal (the AABB face was +// too coarse for diagonal geometry). Two color attachments — R32UInt +// object_id at @location(0), RGBA16F packed normal at @location(1). +// We multiply-by-0.5+0.5 so unsigned half-floats keep the sign without +// extra channel allocation. +struct FsOutPick { + @location(0) object_id: u32, + @location(1) normal: vec4, +}; + +@vertex +fn vs_pick(@builtin(vertex_index) vid: u32) -> VsOutPick { + var out: VsOutPick; + if (vid >= u_model.total_vertex_count) { + out.clip_pos = vec4(0.0, 0.0, 0.0, 0.0); + out.object_id = 0u; + out.world_pos = vec3(0.0, 0.0, 0.0); + out.normal = vec3(0.0, 0.0, 1.0); + return out; + } + + let draw_idx = find_draw(vid); + let local_v = vid - prefix_sums[draw_idx]; + let item = visible_draws[draw_idx]; + let mesh_local_index = indices[item.ebo_first_u32 + local_v]; + let v_global = item.base_vertex + mesh_local_index; + let inst = instances[item.instance_idx]; + let mq = meshes[item.mesh_id]; + + let w0 = vertices[v_global * 3u + 0u]; + let w1 = vertices[v_global * 3u + 1u]; + let pos_norm = vec3( + f32(w0 & 0xFFFFu) / 65535.0, + f32((w0 >> 16u) & 0xFFFFu) / 65535.0, + f32(w1 & 0xFFFFu) / 65535.0, + ); + let pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, pos_norm); + let world4 = inst.transform * vec4(pos_local, 1.0); + + // Decode the same octahedral normal as vs_main — pick needs it so + // the section tool can drop perpendicular cuts. + let nx = f32(extractI8(w1, 2u)) / 127.0; + let ny = f32(extractI8(w1, 3u)) / 127.0; + let n_local = octDecode(vec2(nx, ny)); + let rot = mat3x3(inst.transform[0].xyz, + inst.transform[1].xyz, + inst.transform[2].xyz); + let n_world = normalize(rot * n_local); + let det = determinant(rot); + let n_final = select(n_world, -n_world, det < 0.0); + + out.clip_pos = u_frame.view_proj * world4; + out.object_id = inst.object_id; + out.world_pos = world4.xyz; + out.normal = n_final; + return out; +} + +@fragment +fn fs_pick(in: VsOutPick) -> FsOutPick { + if (is_section_clipped(in.world_pos)) { discard; } + var out: FsOutPick; + out.object_id = in.object_id; + // Pack signed normal into RGBA16F (unsigned-ish half range) as ×0.5+0.5. + out.normal = vec4(normalize(in.normal) * 0.5 + vec3(0.5), 1.0); + return out; +} +)"; +} // namespace + +bool ViewportCore::buildPipelines() { + // ---- Bind group layouts ---------------------------------------------- + WGPUBindGroupLayoutEntry frame_entries[2] = {}; + frame_entries[0].binding = 0; + frame_entries[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; + frame_entries[0].buffer.type = WGPUBufferBindingType_Uniform; + frame_entries[0].buffer.minBindingSize = sizeof(FrameUniforms); + frame_entries[1].binding = 1; + frame_entries[1].visibility = WGPUShaderStage_Fragment; + frame_entries[1].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; + + WGPUBindGroupLayoutDescriptor frame_bgl_desc = {}; + frame_bgl_desc.entryCount = 2; + frame_bgl_desc.entries = frame_entries; + frame_bgl_desc.label = svFromCStr("ifcviewer-wgpu.frame_bgl"); + frame_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &frame_bgl_desc); + + // 6 read-only storage buffers (vertices, meshes, instances, indices, + // visible_draws, prefix_sums) + 1 uniform (per-model count). All read + // in the vertex shader. WebGPU's mandatory min is 8 storage / 12 uniform + // per stage, so we're comfortably under the cap. + WGPUBindGroupLayoutEntry model_entries[7] = {}; + for (int i = 0; i < 6; ++i) { + model_entries[i].binding = uint32_t(i); + model_entries[i].visibility = WGPUShaderStage_Vertex; + model_entries[i].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; + } + model_entries[6].binding = 6; + model_entries[6].visibility = WGPUShaderStage_Vertex; + model_entries[6].buffer.type = WGPUBufferBindingType_Uniform; + model_entries[6].buffer.minBindingSize = 16; + WGPUBindGroupLayoutDescriptor model_bgl_desc = {}; + model_bgl_desc.entryCount = 7; + model_bgl_desc.entries = model_entries; + model_bgl_desc.label = svFromCStr("ifcviewer-wgpu.model_bgl"); + model_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &model_bgl_desc); + + // ---- Pipeline layout ------------------------------------------------- + WGPUBindGroupLayout bgls[2] = { frame_bgl_, model_bgl_ }; + WGPUPipelineLayoutDescriptor pl_desc = {}; + pl_desc.bindGroupLayoutCount = 2; + pl_desc.bindGroupLayouts = bgls; + pl_desc.label = svFromCStr("ifcviewer-wgpu.pipeline_layout"); + pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc); + + // ---- Shader module --------------------------------------------------- + WGPUShaderSourceWGSL wgsl_src = {}; + wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL; + wgsl_src.code = svFromCStr(MAIN_WGSL); + + WGPUShaderModuleDescriptor sm_desc = {}; + sm_desc.nextInChain = &wgsl_src.chain; + sm_desc.label = svFromCStr("ifcviewer-wgpu.main_wgsl"); + main_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc); + + // ---- Render pipeline ------------------------------------------------- + WGPUColorTargetState color_target = {}; + color_target.format = surface_format_; + color_target.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = main_shader_module_; + frag.entryPoint = svFromCStr("fs_main"); + frag.targetCount = 1; + frag.targets = &color_target; + + WGPUDepthStencilState depth = {}; + depth.format = WGPUTextureFormat_Depth32Float; + depth.depthWriteEnabled = WGPUOptionalBool_True; + depth.depthCompare = WGPUCompareFunction_Less; + depth.stencilFront.compare = WGPUCompareFunction_Always; + depth.stencilBack.compare = WGPUCompareFunction_Always; + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.main_pipeline"); + rp_desc.vertex.module = main_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 0; // vertex pulling: no IA bindings + rp_desc.fragment = &frag; + rp_desc.depthStencil = &depth; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rp_desc.primitive.cullMode = WGPUCullMode_Back; + rp_desc.primitive.frontFace = WGPUFrontFace_CCW; + rp_desc.multisample.count = kViewportSampleCount; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + main_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!main_pipeline_) { + Log::warn() << "wgpu main render pipeline creation failed"; + return false; + } + + // ---- Transparent variant of the main pipeline ---------------------- + // Same shader, same layout, same vertex pulling, same depth test — + // differs only in: + // * depth.depthWriteEnabled = False (we still depth-test against + // the opaque pass's z-buffer, but the transparent fragment's z + // doesn't write, so further-back geometry behind the glass still + // paints over) + // * color_target.blend = SrcAlpha / OneMinusSrcAlpha (standard + // porter-duff "over" — premultiplied wouldn't help because our + // vertex colours come in straight-alpha from the IFC iterator) + // No sort, no OIT — overlapping transparent surfaces of the same + // kind will produce order-dependent artefacts but for typical IFC + // glazing (panes that don't overlap much in screen space) the + // result is "good enough". + WGPUBlendState main_blend = {}; + main_blend.color.srcFactor = WGPUBlendFactor_SrcAlpha; + main_blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + main_blend.color.operation = WGPUBlendOperation_Add; + main_blend.alpha.srcFactor = WGPUBlendFactor_One; + main_blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + main_blend.alpha.operation = WGPUBlendOperation_Add; + + WGPUColorTargetState color_target_transparent = color_target; + color_target_transparent.blend = &main_blend; + + WGPUFragmentState frag_transparent = frag; + frag_transparent.targets = &color_target_transparent; + + // depthWriteEnabled stays True so the edge-detect pass (which samples + // depth_view_ to find silhouette discontinuities) can see window + // panes — leaving it False made transparent surfaces invisible to + // the edge detector, so windows ended up as edge-less "framed holes" + // and the edges of opaque geometry behind the glass painted through + // at full intensity. Trade-off: overlapping transparent surfaces + // become depth-test-occluded by the closer one, increasing order + // sensitivity. For BIM glass (panes that don't overlap in screen + // space) this is invisible; for scenes where it matters, the right + // fix is OIT or sort-by-distance, not turning depth write off. + WGPUDepthStencilState depth_transparent = depth; + + WGPURenderPipelineDescriptor rp_desc_t = rp_desc; + rp_desc_t.label = svFromCStr("ifcviewer-wgpu.main_pipeline_transparent"); + rp_desc_t.fragment = &frag_transparent; + rp_desc_t.depthStencil = &depth_transparent; + + main_pipeline_transparent_ = + wgpuDeviceCreateRenderPipeline(device_, &rp_desc_t); + if (!main_pipeline_transparent_) { + Log::warn() << "wgpu main transparent render pipeline creation failed"; + return false; + } + + // ---- Per-frame uniform buffer --------------------------------------- + WGPUBufferDescriptor fb_desc = {}; + fb_desc.size = sizeof(FrameUniforms); + fb_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; + fb_desc.label = svFromCStr("ifcviewer-wgpu.frame_uniform"); + frame_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &fb_desc); + + // frame_bind_group_ is built lazily once we have a selection_flags_ + // buffer to bind alongside the uniform — ensureSelectionFlagsBuffer + // handles both the first creation and any subsequent resize. + + return true; +} + +void ViewportCore::ensureSelectionFlagsBuffer() { + // Round up to at least 64 entries (256 B — minimum useful storage) and + // grow geometrically when next_object_id_ outruns the current capacity. + const uint32_t needed = std::max(next_object_id_, 64); + if (selection_flags_buffer_ && selection_flags_capacity_ >= needed) { + if (!frame_bind_group_) { + // First-time bind group creation after the buffer exists. + // (Should always be true here.) + } else { + return; + } + } + + // (Re)allocate. Geometric grow so we don't recreate every frame as a + // big scene streams in. + uint32_t new_cap = selection_flags_capacity_; + if (new_cap < 64) new_cap = 64; + while (new_cap < needed) new_cap *= 2; + + if (!selection_flags_buffer_ || selection_flags_capacity_ < new_cap) { + if (selection_flags_buffer_) { + wgpuBufferRelease(selection_flags_buffer_); + selection_flags_buffer_ = nullptr; + } + WGPUBufferDescriptor sb = {}; + sb.size = uint64_t(new_cap) * sizeof(uint32_t); + sb.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; + sb.label = svFromCStr("ifcviewer-wgpu.selection_flags"); + selection_flags_buffer_ = wgpuDeviceCreateBuffer(device_, &sb); + selection_flags_capacity_ = new_cap; + // Initialise to zero so any unused range reads as "not selected". + // wgpuQueueWriteBuffer with a small zero block is enough; the rest + // is created as zero-initialised by wgpu per the spec. + } + + // Rebuild the frame bind group against the (possibly new) buffer. + if (frame_bind_group_) { + wgpuBindGroupRelease(frame_bind_group_); + frame_bind_group_ = nullptr; + } + WGPUBindGroupEntry fbg_entries[2] = {}; + fbg_entries[0].binding = 0; + fbg_entries[0].buffer = frame_uniform_buffer_; + fbg_entries[0].size = sizeof(FrameUniforms); + fbg_entries[1].binding = 1; + fbg_entries[1].buffer = selection_flags_buffer_; + fbg_entries[1].size = WGPU_WHOLE_SIZE; + WGPUBindGroupDescriptor fbg_desc = {}; + fbg_desc.layout = frame_bgl_; + fbg_desc.entryCount = 2; + fbg_desc.entries = fbg_entries; + fbg_desc.label = svFromCStr("ifcviewer-wgpu.frame_bind_group"); + frame_bind_group_ = wgpuDeviceCreateBindGroup(device_, &fbg_desc); + + // Force a re-upload of the flags into the (possibly new) buffer. + selection_flags_scratch_.assign(selection_flags_capacity_, 0); + selection_.fillFlagsArray(selection_flags_scratch_, selection_flags_capacity_); + wgpuQueueWriteBuffer(queue_, selection_flags_buffer_, 0, + selection_flags_scratch_.data(), + selection_flags_scratch_.size() * sizeof(uint32_t)); + selection_.markClean(); +} + +void ViewportCore::uploadSelectionFlagsIfDirty() { + if (!selection_.dirty() || !selection_flags_buffer_) return; + selection_flags_scratch_.assign(selection_flags_capacity_, 0); + selection_.fillFlagsArray(selection_flags_scratch_, selection_flags_capacity_); + wgpuQueueWriteBuffer(queue_, selection_flags_buffer_, 0, + selection_flags_scratch_.data(), + selection_flags_scratch_.size() * sizeof(uint32_t)); + selection_.markClean(); +} diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 6ad25e1cf5..e67cfaf4e2 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -47,8 +47,43 @@ #include "InstanceCompose.h" #include "InstancedGeometry.h" #include "ModelGpuData.h" +#include "SelectionState.h" #include "StreamingThread.h" #include "ViewportHost.h" +#include "VisibilityState.h" + +// Render-loop constants shared between ViewportCore and ViewportWindow. +// Kept here (not in OverlayRenderer.h) so IfcViewerCore stays Qt-free. +// ViewportWindow.cpp asserts the section-plane cap matches +// OverlayRenderer's so the WGSL clip-plane array and the section-tool +// state vector agree by construction. +constexpr int kMaxSectionPlanes = 6; +constexpr uint32_t kViewportSampleCount = 4; + +// Per-frame uniform layout. Matches the WGSL struct the main pipeline +// declares (see ViewportCore.cpp MAIN_WGSL). Both buildPipelines (in +// ViewportCore) and updateFrameUniforms (currently in ViewportWindow) +// allocate / write this; keeping the type here makes the layout the +// single source of truth. +struct FrameUniforms { + float view_proj[16]; + float light_dir[4]; // xyz = unit dir toward light, w unused + float fill_dir[4]; // xyz = secondary fill dir + float sky_color[4]; // xyz = sky-tint ambient, w unused + float ground_color[4]; // xyz = ground-tint ambient, w unused + int clip_count; // active section-plane count (≤ kMaxSectionPlanes) + int _pad_clip[3]; // pad to 16-byte alignment for the array below + float clip_planes[kMaxSectionPlanes][4]; // xyz = world-space unit normal, w = plane offset + float xray_alpha_cap; // X-ray mode: fragment alpha clamped to min(in.color.a, cap) + float _pad_xray[3]; // pad to 16-byte alignment so the struct stays vec4-aligned +}; +static_assert(sizeof(FrameUniforms) + == 16 * sizeof(float) + + 4 * 4 * sizeof(float) + + 4 * sizeof(int) + + kMaxSectionPlanes * 4 * sizeof(float) + + 4 * sizeof(float), + "FrameUniforms must match WGSL layout"); class ViewportCore { public: @@ -171,6 +206,23 @@ public: std::vector> volumesPerObject(const std::vector& object_ids) const; + // ---- Pipeline construction -------------------------------------------- + // + // buildPipelines creates the main render pipelines (opaque + + // transparent variants), bind group layouts, the per-frame UBO, + // and the WGSL shader module. Called once after the device + queue + // come up + the surface format is picked. + // + // ensureSelectionFlagsBuffer (re)allocates the selection flags + // storage buffer geometrically as next_object_id_ grows, and + // (re)builds the frame bind group when its referenced buffers + // change. uploadSelectionFlagsIfDirty flushes + // selection_.fillFlagsArray() into the GPU when selection_'s dirty + // flag is set — called once per frame at render time. + bool buildPipelines(); + void ensureSelectionFlagsBuffer(); + void uploadSelectionFlagsIfDirty(); + // Friend access for ViewportWindow's reference proxies. As each // render method moves into ViewportCore it stops needing these // (it touches the fields directly); once everything has migrated @@ -227,6 +279,28 @@ private: // main pass since the pick fragment also vertex-pulls instance data. WGPURenderPipeline pick_pipeline_ = nullptr; + // ---- Frame uniforms + selection bind ---------------------------------- + // + // The per-frame UBO (view-proj + lighting + section planes + xray + // params) and the frame bind group it lives in alongside the + // selection flags storage buffer at group=0 binding=1. + // ensureSelectionFlagsBuffer is the only writer for the buffer + + // bind group; uploadSelectionFlagsIfDirty repopulates the flags + // from selection_ when it changes. + WGPUBuffer frame_uniform_buffer_ = nullptr; + WGPUBindGroup frame_bind_group_ = nullptr; + WGPUBuffer selection_flags_buffer_ = nullptr; + uint32_t selection_flags_capacity_ = 0; // u32 entries + std::vector selection_flags_scratch_; + + // Selection + per-element visibility state machines. Pure CPU + // bookkeeping today (no GPU touch beyond the readback uploaded via + // selection_flags_buffer_). Mutated on the main thread between + // renders; cull workers read concurrently which is safe as long + // as no concurrent writes. + SelectionState selection_; + VisibilityState visibility_; + // ---- Scene state --------------------------------------------------------- // // Sub-allocator for chunk vertex + index buffers. All per-chunk diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 0b0bb8a341..21a5db2c9c 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -53,29 +53,12 @@ // std140-ish layout: every member naturally 16-aligned, struct stride = 96. // ----------------------------------------------------------------------------- -// Section-cutting cap. Single source of truth lives in OverlayRenderer -// so the visualizer and the WGSL clip array agree by construction. -static constexpr int kMaxSectionPlanes = OverlayRenderer::kMaxSectionPlanes; - -struct FrameUniforms { - float view_proj[16]; - float light_dir[4]; // xyz = unit dir toward light, w unused - float fill_dir[4]; // xyz = secondary fill dir - float sky_color[4]; // xyz = sky-tint ambient, w unused - float ground_color[4]; // xyz = ground-tint ambient, w unused - int clip_count; // active section-plane count (≤ kMaxSectionPlanes) - int _pad_clip[3]; // pad to 16-byte alignment for the array below - float clip_planes[kMaxSectionPlanes][4]; // xyz = world-space unit normal, w = plane offset - float xray_alpha_cap; // X-ray mode: fragment alpha clamped to min(in.color.a, cap) - float _pad_xray[3]; // pad to 16-byte alignment so the struct stays vec4-aligned -}; -static_assert(sizeof(FrameUniforms) - == 16 * sizeof(float) - + 4 * 4 * sizeof(float) - + 4 * sizeof(int) - + kMaxSectionPlanes * 4 * sizeof(float) - + 4 * sizeof(float), - "FrameUniforms must match WGSL layout"); +// kMaxSectionPlanes + FrameUniforms moved to ViewportCore.h (#84-k). +// Keep this assert so OverlayRenderer's kMaxSectionPlanes (the section +// visualizer's per-plane uniform slot count) stays in sync with the +// WGSL clip array size. +static_assert(kMaxSectionPlanes == OverlayRenderer::kMaxSectionPlanes, + "section-plane cap must match OverlayRenderer's"); // Inverse of sRGB encoding. wgpu-native's Vulkan swap chain on X11 treats // BGRA8Unorm as sRGB-output (encodes shader output linear→sRGB on write, @@ -240,326 +223,7 @@ static WGPUBuffer createBufferWithData(WGPUDevice device, WGPUQueue queue, // GPU. // ----------------------------------------------------------------------------- -static const char* MAIN_WGSL = R"( -struct InstanceRecord { - transform: mat4x4, - object_id: u32, - color_override: u32, - mesh_id: u32, - _pad1: u32, -}; - -struct MeshQuant { - aabb_min: vec4, - aabb_max: vec4, -}; - -struct FrameUniforms { - view_proj: mat4x4, - light_dir: vec4, - fill_dir: vec4, - sky_color: vec4, - ground_color: vec4, - clip_count: i32, - // Three scalar i32 pads instead of vec3: vec3 has 16-byte - // alignment so it would also pad the SUBSEQUENT clip_planes start - // up to offset 160. Three i32s pad to 144 with no further nudge, - // matching the tightly-packed C++ FrameUniforms (240 B). - _pad_clip_0: i32, - _pad_clip_1: i32, - _pad_clip_2: i32, - clip_planes: array, 6>, - // X-ray mode cap. fs_main clamps `out.a = min(in.color.a, xray_alpha_cap)`. - // Default 1.0 (no effect — the min returns in.color.a). Alt+X drops it - // toward ~0.3 to translucent-everything. The cull classifier also - // routes every instance into the transparent pass when this is < 1 - // so the blend stage actually fires (an opaque-pass fragment with - // capped alpha would still overwrite the back buffer). - xray_alpha_cap: f32, - _pad_xray_0: f32, - _pad_xray_1: f32, - _pad_xray_2: f32, -}; - -// Returns true if `world` lies on the positive (clipped-away) side of any -// active section plane. Each plane is (n.xyz, d) and clips where -// dot(n, world) + d > 0. Both the main and pick fragments discard with -// this predicate so cuts are visible AND consistent with selection. -fn is_section_clipped(world: vec3) -> bool { - let n = u_frame.clip_count; - if (n == 0) { return false; } - for (var i = 0; i < n; i = i + 1) { - let p = u_frame.clip_planes[i]; - if (dot(p.xyz, world) + p.w > 0.0) { return true; } - } - return false; -} - -struct VisibleDraw { - mesh_id: u32, - instance_idx: u32, - ebo_first_u32: u32, - base_vertex: u32, -}; - -struct PerModel { - draw_count: u32, - total_vertex_count: u32, - _pad0: u32, - _pad1: u32, -}; - -@group(0) @binding(0) var u_frame: FrameUniforms; -// Selection flags indexed by object_id. bit 0 = in selection, bit 1 = active. -// Sized to next_object_id_ on the CPU side; out-of-range reads can't happen -// because we cap the index by arrayLength before fetching. -@group(0) @binding(1) var sel_flags: array; - -@group(1) @binding(0) var vertices: array; -@group(1) @binding(1) var meshes: array; -@group(1) @binding(2) var instances: array; -@group(1) @binding(3) var indices: array; -@group(1) @binding(4) var visible_draws: array; -@group(1) @binding(5) var prefix_sums: array; -@group(1) @binding(6) var u_model: PerModel; - -struct VsOut { - @builtin(position) clip_pos: vec4, - @location(0) normal: vec3, - @location(1) color: vec4, - @location(2) world_pos: vec3, - @location(3) @interpolate(flat) object_id: u32, -}; - -// Sign-extend an i8 packed into the byte_idx'th byte of `packed`. -fn extractI8(packed: u32, byte_idx: u32) -> i32 { - let raw = i32((packed >> (byte_idx * 8u)) & 0xFFu); - return select(raw, raw - 256, raw >= 128); -} - -// Meyer et al. octahedral normal decode. Input in [-1,1]^2. -fn octDecode(e: vec2) -> vec3 { - var n = vec3(e.x, e.y, 1.0 - abs(e.x) - abs(e.y)); - if (n.z < 0.0) { - let tx = select(-1.0, 1.0, n.x >= 0.0); - let ty = select(-1.0, 1.0, n.y >= 0.0); - n = vec3((1.0 - abs(n.y)) * tx, (1.0 - abs(n.x)) * ty, n.z); - } - return normalize(n); -} - -// Binary search for the largest i in [0, draw_count) with prefix_sums[i] <= vid. -// prefix_sums is monotonic non-decreasing and contains draw_count+1 entries -// (prefix_sums[draw_count] == total_vertex_count). -fn find_draw(vid: u32) -> u32 { - var lo: u32 = 0u; - var hi: u32 = u_model.draw_count; - while (lo + 1u < hi) { - let mid = (lo + hi) >> 1u; - if (prefix_sums[mid] <= vid) { - lo = mid; - } else { - hi = mid; - } - } - return lo; -} - -@vertex -fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { - // Saturate past the end (shouldn't happen given draw() count, but safe). - if (vid >= u_model.total_vertex_count) { - var degen: VsOut; - degen.clip_pos = vec4(0.0, 0.0, 0.0, 0.0); - return degen; - } - - let draw_idx = find_draw(vid); - let local_v = vid - prefix_sums[draw_idx]; - let item = visible_draws[draw_idx]; - - // Fetch the mesh-local index then the global vertex index. - let mesh_local_index = indices[item.ebo_first_u32 + local_v]; - let v_global = item.base_vertex + mesh_local_index; - - let inst = instances[item.instance_idx]; - let mq = meshes[item.mesh_id]; - - let w0 = vertices[v_global * 3u + 0u]; - let w1 = vertices[v_global * 3u + 1u]; - let w2 = vertices[v_global * 3u + 2u]; - - let px = f32(w0 & 0xFFFFu) / 65535.0; - let py = f32((w0 >> 16u) & 0xFFFFu) / 65535.0; - let pz = f32(w1 & 0xFFFFu) / 65535.0; - let pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, vec3(px, py, pz)); - - let nx = f32(extractI8(w1, 2u)) / 127.0; - let ny = f32(extractI8(w1, 3u)) / 127.0; - let n_local = octDecode(vec2(nx, ny)); - - let r = f32(w2 & 0xFFu) / 255.0; - let g = f32((w2 >> 8u) & 0xFFu) / 255.0; - let b = f32((w2 >> 16u) & 0xFFu) / 255.0; - let a = f32((w2 >> 24u) & 0xFFu) / 255.0; - - let world4 = inst.transform * vec4(pos_local, 1.0); - let rot = mat3x3(inst.transform[0].xyz, - inst.transform[1].xyz, - inst.transform[2].xyz); - let n_world = normalize(rot * n_local); - let det = determinant(rot); - let n_final = select(n_world, -n_world, det < 0.0); - - var color = vec4(r, g, b, a); - if (inst.color_override != 0u) { - let cr = f32(inst.color_override & 0xFFu) / 255.0; - let cg = f32((inst.color_override >> 8u) & 0xFFu) / 255.0; - let cb = f32((inst.color_override >> 16u) & 0xFFu) / 255.0; - let ca = f32((inst.color_override >> 24u) & 0xFFu) / 255.0; - if (ca > 0.0) { color = vec4(cr, cg, cb, ca); } - } - - var out: VsOut; - out.clip_pos = u_frame.view_proj * world4; - out.normal = n_final; - out.color = color; - out.world_pos = world4.xyz; - out.object_id = inst.object_id; - return out; -} - -// sRGB decode — used to undo wgpu's automatic linear→sRGB write encoding -// on swap-chain BGRA8Unorm so the final bytes match what the GL backend -// writes directly. The GL pipeline outputs to a non-sRGB FB and treats -// every colour input as already-linear, so its bytes are exactly its -// shader outputs. wgpu on the same swap chain auto-encodes, which makes -// everything appear ~3× brighter unless we pre-decode once. -fn srgbToLinear(s: vec3) -> vec3 { - let lo = s / 12.92; - let hi = pow((s + 0.055) / 1.055, vec3(2.4)); - return select(hi, lo, s <= vec3(0.04045)); -} - -@fragment -fn fs_main(in: VsOut) -> @location(0) vec4 { - if (is_section_clipped(in.world_pos)) { discard; } - - var n = normalize(in.normal); - - // World +Z is up (BIM convention). Hemisphere ambient: faces pointing - // up read sky, faces pointing down read ground, lerp by n.z. - let hemi_t = 0.5 + 0.5 * n.z; - let ambient = mix(u_frame.ground_color.xyz, u_frame.sky_color.xyz, hemi_t); - - let key = max(dot(n, u_frame.light_dir.xyz), 0.0); - let fill = max(dot(n, u_frame.fill_dir.xyz), 0.0) * 0.35; - - var color = in.color.xyz * (ambient + (key + fill) * 0.7); - - // Cavity shading: where adjacent fragments have a sharp normal change - // (concave creases, edges where two faces meet), darken slightly so - // shape boundaries read on flat-colour models. Matches the GL shader. - let cavity = clamp(length(fwidth(n)) * 1.5, 0.0, 0.35); - color = color * (1.0 - cavity); - - // Selection tint. bit 0 = in selection (cool blue mix), bit 1 = active - // (slightly stronger blue mix). Matches the GL main shader. - if (in.object_id < arrayLength(&sel_flags)) { - let flags = sel_flags[in.object_id]; - if ((flags & 1u) != 0u) { color = mix(color, vec3(0.2, 0.6, 1.0), 0.45); } - if ((flags & 2u) != 0u) { color = mix(color, vec3(0.4, 0.8, 1.0), 0.40); } - } - - // Cancel the swap chain's implicit linear→sRGB encoding so the final - // bytes match the GL backend (see srgbToLinear above). Alpha is - // clamped to `xray_alpha_cap` (default 1.0 = no effect; X-ray sets - // it to ~0.3) so a global translucency override lands without - // touching any per-instance state. - let alpha_out = min(in.color.a, u_frame.xray_alpha_cap); - return vec4(srgbToLinear(color), alpha_out); -} - -// --------------------------- Pick pipeline --------------------------------- -// Same vertex pulling as vs_main, but VsOutPick carries only the object_id -// (flat-interpolated). Fragment writes the object_id to an R32UInt target. -// Background (no draw) reads 0 because the pick attachment is cleared to 0. - -struct VsOutPick { - @builtin(position) clip_pos: vec4, - @location(0) @interpolate(flat) object_id: u32, - @location(1) world_pos: vec3, - @location(2) normal: vec3, -}; - -// Section tool needs the actual per-fragment normal (the AABB face was -// too coarse for diagonal geometry). Two color attachments — R32UInt -// object_id at @location(0), RGBA16F packed normal at @location(1). -// We multiply-by-0.5+0.5 so unsigned half-floats keep the sign without -// extra channel allocation. -struct FsOutPick { - @location(0) object_id: u32, - @location(1) normal: vec4, -}; - -@vertex -fn vs_pick(@builtin(vertex_index) vid: u32) -> VsOutPick { - var out: VsOutPick; - if (vid >= u_model.total_vertex_count) { - out.clip_pos = vec4(0.0, 0.0, 0.0, 0.0); - out.object_id = 0u; - out.world_pos = vec3(0.0, 0.0, 0.0); - out.normal = vec3(0.0, 0.0, 1.0); - return out; - } - - let draw_idx = find_draw(vid); - let local_v = vid - prefix_sums[draw_idx]; - let item = visible_draws[draw_idx]; - let mesh_local_index = indices[item.ebo_first_u32 + local_v]; - let v_global = item.base_vertex + mesh_local_index; - let inst = instances[item.instance_idx]; - let mq = meshes[item.mesh_id]; - - let w0 = vertices[v_global * 3u + 0u]; - let w1 = vertices[v_global * 3u + 1u]; - let pos_norm = vec3( - f32(w0 & 0xFFFFu) / 65535.0, - f32((w0 >> 16u) & 0xFFFFu) / 65535.0, - f32(w1 & 0xFFFFu) / 65535.0, - ); - let pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, pos_norm); - let world4 = inst.transform * vec4(pos_local, 1.0); - - // Decode the same octahedral normal as vs_main — pick needs it so - // the section tool can drop perpendicular cuts. - let nx = f32(extractI8(w1, 2u)) / 127.0; - let ny = f32(extractI8(w1, 3u)) / 127.0; - let n_local = octDecode(vec2(nx, ny)); - let rot = mat3x3(inst.transform[0].xyz, - inst.transform[1].xyz, - inst.transform[2].xyz); - let n_world = normalize(rot * n_local); - let det = determinant(rot); - let n_final = select(n_world, -n_world, det < 0.0); - - out.clip_pos = u_frame.view_proj * world4; - out.object_id = inst.object_id; - out.world_pos = world4.xyz; - out.normal = n_final; - return out; -} - -@fragment -fn fs_pick(in: VsOutPick) -> FsOutPick { - if (is_section_clipped(in.world_pos)) { discard; } - var out: FsOutPick; - out.object_id = in.object_id; - // Pack signed normal into RGBA16F (unsigned-ish half range) as ×0.5+0.5. - out.normal = vec4(normalize(in.normal) * 0.5 + vec3(0.5), 1.0); - return out; -} -)"; +// MAIN_WGSL moved to ViewportCore.cpp (#84-k). // Helper: build a WGPUStringView from a null-terminated C string literal. static WGPUStringView svFromCStr(const char* s) { @@ -619,7 +283,14 @@ ViewportWindow::ViewportWindow(QWindow* parent) camera_fov_y_deg_(core_.camera_fov_y_deg_), camera_near_ (core_.camera_near_), camera_far_ (core_.camera_far_), - background_color_(core_.background_color_) { + background_color_(core_.background_color_), + frame_uniform_buffer_(core_.frame_uniform_buffer_), + frame_bind_group_ (core_.frame_bind_group_), + selection_flags_buffer_ (core_.selection_flags_buffer_), + selection_flags_capacity_(core_.selection_flags_capacity_), + selection_flags_scratch_ (core_.selection_flags_scratch_), + selection_ (core_.selection_), + visibility_ (core_.visibility_) { // wgpu doesn't need a GL context; we just need a real native window // whose backing layer matches the GPU API wgpu will drive. // @@ -5516,237 +5187,14 @@ void ViewportWindow::render() { // Pipeline + bind-group layouts (built once after init) // ----------------------------------------------------------------------------- -bool ViewportWindow::buildPipelines() { - // ---- Bind group layouts ---------------------------------------------- - WGPUBindGroupLayoutEntry frame_entries[2] = {}; - frame_entries[0].binding = 0; - frame_entries[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; - frame_entries[0].buffer.type = WGPUBufferBindingType_Uniform; - frame_entries[0].buffer.minBindingSize = sizeof(FrameUniforms); - frame_entries[1].binding = 1; - frame_entries[1].visibility = WGPUShaderStage_Fragment; - frame_entries[1].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; +// buildPipelines moved to ViewportCore (#84-k). +bool ViewportWindow::buildPipelines() { return core_.buildPipelines(); } - WGPUBindGroupLayoutDescriptor frame_bgl_desc = {}; - frame_bgl_desc.entryCount = 2; - frame_bgl_desc.entries = frame_entries; - frame_bgl_desc.label = svFromCStr("ifcviewer-wgpu.frame_bgl"); - frame_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &frame_bgl_desc); +// ensureSelectionFlagsBuffer moved to ViewportCore (#84-k). +void ViewportWindow::ensureSelectionFlagsBuffer() { core_.ensureSelectionFlagsBuffer(); } - // 6 read-only storage buffers (vertices, meshes, instances, indices, - // visible_draws, prefix_sums) + 1 uniform (per-model count). All read - // in the vertex shader. WebGPU's mandatory min is 8 storage / 12 uniform - // per stage, so we're comfortably under the cap. - WGPUBindGroupLayoutEntry model_entries[7] = {}; - for (int i = 0; i < 6; ++i) { - model_entries[i].binding = uint32_t(i); - model_entries[i].visibility = WGPUShaderStage_Vertex; - model_entries[i].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; - } - model_entries[6].binding = 6; - model_entries[6].visibility = WGPUShaderStage_Vertex; - model_entries[6].buffer.type = WGPUBufferBindingType_Uniform; - model_entries[6].buffer.minBindingSize = 16; - WGPUBindGroupLayoutDescriptor model_bgl_desc = {}; - model_bgl_desc.entryCount = 7; - model_bgl_desc.entries = model_entries; - model_bgl_desc.label = svFromCStr("ifcviewer-wgpu.model_bgl"); - model_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &model_bgl_desc); - - // ---- Pipeline layout ------------------------------------------------- - WGPUBindGroupLayout bgls[2] = { frame_bgl_, model_bgl_ }; - WGPUPipelineLayoutDescriptor pl_desc = {}; - pl_desc.bindGroupLayoutCount = 2; - pl_desc.bindGroupLayouts = bgls; - pl_desc.label = svFromCStr("ifcviewer-wgpu.pipeline_layout"); - pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc); - - // ---- Shader module --------------------------------------------------- - WGPUShaderSourceWGSL wgsl_src = {}; - wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL; - wgsl_src.code = svFromCStr(MAIN_WGSL); - - WGPUShaderModuleDescriptor sm_desc = {}; - sm_desc.nextInChain = &wgsl_src.chain; - sm_desc.label = svFromCStr("ifcviewer-wgpu.main_wgsl"); - main_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc); - - // ---- Render pipeline ------------------------------------------------- - WGPUColorTargetState color_target = {}; - color_target.format = surface_format_; - color_target.writeMask = WGPUColorWriteMask_All; - - WGPUFragmentState frag = {}; - frag.module = main_shader_module_; - frag.entryPoint = svFromCStr("fs_main"); - frag.targetCount = 1; - frag.targets = &color_target; - - WGPUDepthStencilState depth = {}; - depth.format = WGPUTextureFormat_Depth32Float; - depth.depthWriteEnabled = WGPUOptionalBool_True; - depth.depthCompare = WGPUCompareFunction_Less; - depth.stencilFront.compare = WGPUCompareFunction_Always; - depth.stencilBack.compare = WGPUCompareFunction_Always; - - WGPURenderPipelineDescriptor rp_desc = {}; - rp_desc.layout = pipeline_layout_; - rp_desc.label = svFromCStr("ifcviewer-wgpu.main_pipeline"); - rp_desc.vertex.module = main_shader_module_; - rp_desc.vertex.entryPoint = svFromCStr("vs_main"); - rp_desc.vertex.bufferCount = 0; // vertex pulling: no IA bindings - rp_desc.fragment = &frag; - rp_desc.depthStencil = &depth; - rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; - rp_desc.primitive.cullMode = WGPUCullMode_Back; - rp_desc.primitive.frontFace = WGPUFrontFace_CCW; - rp_desc.multisample.count = SAMPLE_COUNT; - rp_desc.multisample.mask = 0xFFFFFFFFu; - - main_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); - if (!main_pipeline_) { - Log::warn() << "wgpu main render pipeline creation failed"; - return false; - } - - // ---- Transparent variant of the main pipeline ---------------------- - // Same shader, same layout, same vertex pulling, same depth test — - // differs only in: - // * depth.depthWriteEnabled = False (we still depth-test against - // the opaque pass's z-buffer, but the transparent fragment's z - // doesn't write, so further-back geometry behind the glass still - // paints over) - // * color_target.blend = SrcAlpha / OneMinusSrcAlpha (standard - // porter-duff "over" — premultiplied wouldn't help because our - // vertex colours come in straight-alpha from the IFC iterator) - // No sort, no OIT — overlapping transparent surfaces of the same - // kind will produce order-dependent artefacts but for typical IFC - // glazing (panes that don't overlap much in screen space) the - // result is "good enough". - WGPUBlendState main_blend = {}; - main_blend.color.srcFactor = WGPUBlendFactor_SrcAlpha; - main_blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; - main_blend.color.operation = WGPUBlendOperation_Add; - main_blend.alpha.srcFactor = WGPUBlendFactor_One; - main_blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; - main_blend.alpha.operation = WGPUBlendOperation_Add; - - WGPUColorTargetState color_target_transparent = color_target; - color_target_transparent.blend = &main_blend; - - WGPUFragmentState frag_transparent = frag; - frag_transparent.targets = &color_target_transparent; - - // depthWriteEnabled stays True so the edge-detect pass (which samples - // depth_view_ to find silhouette discontinuities) can see window - // panes — leaving it False made transparent surfaces invisible to - // the edge detector, so windows ended up as edge-less "framed holes" - // and the edges of opaque geometry behind the glass painted through - // at full intensity. Trade-off: overlapping transparent surfaces - // become depth-test-occluded by the closer one, increasing order - // sensitivity. For BIM glass (panes that don't overlap in screen - // space) this is invisible; for scenes where it matters, the right - // fix is OIT or sort-by-distance, not turning depth write off. - WGPUDepthStencilState depth_transparent = depth; - - WGPURenderPipelineDescriptor rp_desc_t = rp_desc; - rp_desc_t.label = svFromCStr("ifcviewer-wgpu.main_pipeline_transparent"); - rp_desc_t.fragment = &frag_transparent; - rp_desc_t.depthStencil = &depth_transparent; - - main_pipeline_transparent_ = - wgpuDeviceCreateRenderPipeline(device_, &rp_desc_t); - if (!main_pipeline_transparent_) { - Log::warn() << "wgpu main transparent render pipeline creation failed"; - return false; - } - - // ---- Per-frame uniform buffer --------------------------------------- - WGPUBufferDescriptor fb_desc = {}; - fb_desc.size = sizeof(FrameUniforms); - fb_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; - fb_desc.label = svFromCStr("ifcviewer-wgpu.frame_uniform"); - frame_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &fb_desc); - - // frame_bind_group_ is built lazily once we have a selection_flags_ - // buffer to bind alongside the uniform — ensureSelectionFlagsBuffer - // handles both the first creation and any subsequent resize. - - return true; -} - -void ViewportWindow::ensureSelectionFlagsBuffer() { - // Round up to at least 64 entries (256 B — minimum useful storage) and - // grow geometrically when next_object_id_ outruns the current capacity. - const uint32_t needed = std::max(next_object_id_, 64); - if (selection_flags_buffer_ && selection_flags_capacity_ >= needed) { - if (!frame_bind_group_) { - // First-time bind group creation after the buffer exists. - // (Should always be true here.) - } else { - return; - } - } - - // (Re)allocate. Geometric grow so we don't recreate every frame as a - // big scene streams in. - uint32_t new_cap = selection_flags_capacity_; - if (new_cap < 64) new_cap = 64; - while (new_cap < needed) new_cap *= 2; - - if (!selection_flags_buffer_ || selection_flags_capacity_ < new_cap) { - if (selection_flags_buffer_) { - wgpuBufferRelease(selection_flags_buffer_); - selection_flags_buffer_ = nullptr; - } - WGPUBufferDescriptor sb = {}; - sb.size = uint64_t(new_cap) * sizeof(uint32_t); - sb.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; - sb.label = svFromCStr("ifcviewer-wgpu.selection_flags"); - selection_flags_buffer_ = wgpuDeviceCreateBuffer(device_, &sb); - selection_flags_capacity_ = new_cap; - // Initialise to zero so any unused range reads as "not selected". - // wgpuQueueWriteBuffer with a small zero block is enough; the rest - // is created as zero-initialised by wgpu per the spec. - } - - // Rebuild the frame bind group against the (possibly new) buffer. - if (frame_bind_group_) { - wgpuBindGroupRelease(frame_bind_group_); - frame_bind_group_ = nullptr; - } - WGPUBindGroupEntry fbg_entries[2] = {}; - fbg_entries[0].binding = 0; - fbg_entries[0].buffer = frame_uniform_buffer_; - fbg_entries[0].size = sizeof(FrameUniforms); - fbg_entries[1].binding = 1; - fbg_entries[1].buffer = selection_flags_buffer_; - fbg_entries[1].size = WGPU_WHOLE_SIZE; - WGPUBindGroupDescriptor fbg_desc = {}; - fbg_desc.layout = frame_bgl_; - fbg_desc.entryCount = 2; - fbg_desc.entries = fbg_entries; - fbg_desc.label = svFromCStr("ifcviewer-wgpu.frame_bind_group"); - frame_bind_group_ = wgpuDeviceCreateBindGroup(device_, &fbg_desc); - - // Force a re-upload of the flags into the (possibly new) buffer. - selection_flags_scratch_.assign(selection_flags_capacity_, 0); - selection_.fillFlagsArray(selection_flags_scratch_, selection_flags_capacity_); - wgpuQueueWriteBuffer(queue_, selection_flags_buffer_, 0, - selection_flags_scratch_.data(), - selection_flags_scratch_.size() * sizeof(uint32_t)); - selection_.markClean(); -} - -void ViewportWindow::uploadSelectionFlagsIfDirty() { - if (!selection_.dirty() || !selection_flags_buffer_) return; - selection_flags_scratch_.assign(selection_flags_capacity_, 0); - selection_.fillFlagsArray(selection_flags_scratch_, selection_flags_capacity_); - wgpuQueueWriteBuffer(queue_, selection_flags_buffer_, 0, - selection_flags_scratch_.data(), - selection_flags_scratch_.size() * sizeof(uint32_t)); - selection_.markClean(); -} +// uploadSelectionFlagsIfDirty moved to ViewportCore (#84-k). +void ViewportWindow::uploadSelectionFlagsIfDirty() { core_.uploadSelectionFlagsIfDirty(); } void ViewportWindow::buildModelBindGroup(ModelGpuData& m) { if (!m.mesh_storage || !m.instance_storage) { diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 9fd9b46038..0a3f08f6d7 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -658,25 +658,14 @@ private: WGPURenderPipeline& main_pipeline_; WGPURenderPipeline& main_pipeline_transparent_; - // 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 - SelectionState selection_; - std::vector 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. - VisibilityState visibility_; + // Frame uniforms + selection flags aliases (storage in core_). + WGPUBuffer& frame_uniform_buffer_; + WGPUBindGroup& frame_bind_group_; + WGPUBuffer& selection_flags_buffer_; + uint32_t& selection_flags_capacity_; + std::vector& selection_flags_scratch_; + SelectionState& selection_; + VisibilityState& visibility_; // Depth attachment (4× MSAA), recreated on surface resize. WGPUTexture depth_texture_ = nullptr; @@ -690,7 +679,8 @@ private: WGPUTextureView msaa_color_view_ = nullptr; int msaa_w_ = 0; int msaa_h_ = 0; - static constexpr uint32_t SAMPLE_COUNT = 4; + // SAMPLE_COUNT moved to ViewportCore.h as kViewportSampleCount (#84-k). + static constexpr uint32_t SAMPLE_COUNT = kViewportSampleCount; // HiZ occlusion culling. After each frame's main render pass we // downsample MSAA depth into a small single-sample Depth32Float texture