From f4243038bd29fbd66bdcf70e3175a4b2368ab6ba Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 May 2026 19:54:29 +1000 Subject: [PATCH] wgpu backend: edge silhouette post-process (ported GL renderEdgePass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First piece of stage 9 — the dark outlines BonsaiViewer / the GL backend draw at depth discontinuities. Ported the GL renderEdgePass algorithm verbatim, including the three things my earlier attempt missed: 1. Linearise depth to view-space metres before the Laplacian. Raw [0,1] clip-z is heavily non-linear so a fixed-threshold edge detector only caught near-camera silhouettes. Now reverses the wgpu z-remap (z * 2 - 1 back to GL NDC) then standard reverse- perspective to view-z. 2. Threshold scales with depth: t = EDGE_THRESHOLD * c. A 4 mm gap between two surfaces reads the same whether it's 0.5 m or 50 m away from the camera. 3. Multiplicative blend (Dst, Zero) with fragment output of vec3(1 - edge). Strictly darkens, never brightens. Matches GL's (GL_DST_COLOR, GL_ZERO) blend. Constants EDGE_SCALE=6.0 / EDGE_THRESHOLD=0.004 are GL's tuned values. Camera near/far hard-coded to 0.1 / 10000 (the viewport defaults); they'll move to a small uniform when AppSettings ports across. Pipeline state: depth-attachment-less, sample count 1, blend on, no cull. Reuses depth_texture_'s TextureBinding usage that HiZ added. Render pass loads the resolved main-pass colour (LoadOp_Load) and writes back through the multiplicative blend; encoded between the main pass and the HiZ resolve so HiZ uses the same MSAA depth that produced the edges. edge_bind_group_ rebuilds lazily when depth_view_ is replaced (mirrors the HiZ bind group lifecycle). Perf cost on the 10-sidecar / 380k-instance benchmark: 0.1 ms (11.5 → 11.6 ms). Fullscreen depth-laplacian is essentially free on this GPU. Remaining stage 9 work: HUD/labels/lines/points overlay primitives, which need the QPainter-→-texture path. Lower visual priority than edges; handled in a follow-up. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 201 +++++++++++++++++++++- src/ifcviewer-wgpu/WgpuViewportWindow.h | 14 ++ 2 files changed, 214 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index 885ee15d1c..2fad991ac7 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -758,6 +758,7 @@ bool WgpuViewportWindow::initWgpu() { if (!buildPipelines()) return false; if (!buildHizPipeline()) return false; + if (!buildEdgePipeline()) return false; qInfo() << "wgpu init OK; surface format =" << int(surface_format_); return true; @@ -861,11 +862,16 @@ void WgpuViewportWindow::configureSurface(int width_px, int height_px) { ensureDepthTexture(width_px, height_px); ensureMsaaColorTexture(width_px, height_px); ensureHizTextures(width_px, height_px); - // depth_view_ was just replaced; force the HiZ bind group to rebuild. + // depth_view_ was just replaced; force the HiZ + edge bind groups to + // rebuild against the new view on next encode. if (hiz_bind_group_) { wgpuBindGroupRelease(hiz_bind_group_); hiz_bind_group_ = nullptr; } + if (edge_bind_group_) { + wgpuBindGroupRelease(edge_bind_group_); + edge_bind_group_ = nullptr; + } } // ----------------------------------------------------------------------------- @@ -995,6 +1001,191 @@ fn fs_main(in: VsOut) -> @builtin(frag_depth) f32 { } )"; +// ----------------------------------------------------------------------------- +// Edge silhouette post-process (stage 9) +// ----------------------------------------------------------------------------- +// +// Ports the GL renderEdgePass algorithm: +// 1. Sample MSAA depth (sample 0) at centre + 4 cardinal neighbours. +// 2. Linearise depth to view-space metres so the Laplacian is meaningful +// across the entire depth range (raw [0,1] z is heavily non-linear — +// a fixed threshold would only catch near-camera edges). +// 3. Threshold scales with depth (`u_threshold * c`) so a 4 mm gap reads +// the same whether it's 0.5 m or 50 m away. +// 4. Multiplicative blend (Dst·src) with src = vec3(1 - edge). Strictly +// darkens; never brightens. +// +// Constants u_scale=6.0 and u_threshold=0.004 are GL's tuned values; +// camera near/far are hard-coded to the viewport defaults (0.1 / 10000). +// They'll move to a small uniform when AppSettings ports over. + +static const char* EDGE_WGSL = R"( +@group(0) @binding(0) var src_depth: texture_depth_multisampled_2d; + +const NEAR: f32 = 0.1; +const FAR: f32 = 10000.0; +const EDGE_SCALE: f32 = 6.0; +const EDGE_THRESHOLD: f32 = 0.004; + +// Depth texture stores [0,1] z (we pre-multiply a z-remap onto Qt's GL-style +// projection in the main pipeline). Convert back to GL-NDC then reverse- +// project to view-space distance. +fn linearise(z: f32) -> f32 { + let ndc = z * 2.0 - 1.0; + return (2.0 * NEAR * FAR) / (FAR + NEAR - ndc * (FAR - NEAR)); +} + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 { + let x = f32((vid << 1u) & 2u) * 2.0 - 1.0; + let y = f32(vid & 2u) * 2.0 - 1.0; + return vec4(x, y, 0.0, 1.0); +} + +@fragment +fn fs_main(@builtin(position) frag: vec4) -> @location(0) vec4 { + let p = vec2(i32(frag.x), i32(frag.y)); + let dim = vec2(textureDimensions(src_depth)); + + let dc_raw = textureLoad(src_depth, p, 0); + // Background pixels: nothing was drawn here. Skip so we don't draw + // edges on the void / sky. + if (dc_raw >= 0.99999) { discard; } + + let c = linearise(dc_raw); + let n = linearise(textureLoad(src_depth, vec2(p.x, max(p.y - 1, 0)), 0)); + let s = linearise(textureLoad(src_depth, vec2(p.x, min(p.y + 1, dim.y - 1)), 0)); + let e = linearise(textureLoad(src_depth, vec2(min(p.x + 1, dim.x - 1), p.y), 0)); + let w = linearise(textureLoad(src_depth, vec2(max(p.x - 1, 0), p.y), 0)); + + let lap = abs(4.0 * c - n - s - e - w); + let t = EDGE_THRESHOLD * c; + let edge = clamp((lap - t) * EDGE_SCALE, 0.0, 0.6); + + // Multiplicative blend (Dst, Zero): output rgb = (1 - edge), so the + // existing surface colour is multiplied by (1 - edge) per channel. + return vec4(vec3(1.0 - edge), 1.0); +} +)"; + +bool WgpuViewportWindow::buildEdgePipeline() { + WGPUBindGroupLayoutEntry entries[1] = {}; + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Fragment; + entries[0].texture.sampleType = WGPUTextureSampleType_Depth; + entries[0].texture.viewDimension = WGPUTextureViewDimension_2D; + entries[0].texture.multisampled = 1; + + WGPUBindGroupLayoutDescriptor bgl_desc = {}; + bgl_desc.entryCount = 1; + bgl_desc.entries = entries; + bgl_desc.label = svFromCStr("ifcviewer-wgpu.edge_bgl"); + edge_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc); + + WGPUPipelineLayoutDescriptor pl_desc = {}; + pl_desc.bindGroupLayoutCount = 1; + pl_desc.bindGroupLayouts = &edge_bgl_; + pl_desc.label = svFromCStr("ifcviewer-wgpu.edge_pipeline_layout"); + edge_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc); + + WGPUShaderSourceWGSL wgsl_src = {}; + wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL; + wgsl_src.code = svFromCStr(EDGE_WGSL); + WGPUShaderModuleDescriptor sm_desc = {}; + sm_desc.nextInChain = &wgsl_src.chain; + sm_desc.label = svFromCStr("ifcviewer-wgpu.edge_wgsl"); + edge_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc); + + // Multiplicative blend (Dst, Zero): out.rgb = src.rgb * dst.rgb. + // Fragment outputs (1 - edge, 1 - edge, 1 - edge) so the existing + // surface colour is scaled per-channel — strictly darkens, never + // brightens. Matches GL's renderEdgePass (GL_DST_COLOR, GL_ZERO). + WGPUBlendState blend = {}; + blend.color.srcFactor = WGPUBlendFactor_Dst; + blend.color.dstFactor = WGPUBlendFactor_Zero; + blend.color.operation = WGPUBlendOperation_Add; + blend.alpha.srcFactor = WGPUBlendFactor_Zero; + blend.alpha.dstFactor = WGPUBlendFactor_One; + blend.alpha.operation = WGPUBlendOperation_Add; + + WGPUColorTargetState target = {}; + target.format = surface_format_; + target.blend = &blend; + target.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = edge_shader_module_; + frag.entryPoint = svFromCStr("fs_main"); + frag.targetCount = 1; + frag.targets = ⌖ + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = edge_pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.edge_pipeline"); + rp_desc.vertex.module = edge_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 0; + rp_desc.fragment = &frag; + rp_desc.depthStencil = nullptr; // no depth attachment + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = 1; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + edge_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!edge_pipeline_) { + qWarning() << "wgpu edge pipeline creation failed"; + return false; + } + return true; +} + +void WgpuViewportWindow::encodeEdgePass(WGPUCommandEncoder enc, + WGPUTextureView surface_view) { + if (!edges_enabled_ || !edge_pipeline_ || !depth_view_ || !surface_view) return; + + // Rebuild lazily when the underlying depth view was replaced (on resize + // we proactively null this out alongside the HiZ bind group). + if (!edge_bind_group_) { + WGPUBindGroupEntry entry = {}; + entry.binding = 0; + entry.textureView = depth_view_; + WGPUBindGroupDescriptor bg = {}; + bg.layout = edge_bgl_; + bg.entryCount = 1; + bg.entries = &entry; + bg.label = svFromCStr("ifcviewer-wgpu.edge_bind_group"); + edge_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg); + } + + WGPURenderPassColorAttachment color = {}; + color.view = surface_view; + color.loadOp = WGPULoadOp_Load; // preserve resolved main-pass colour + color.storeOp = WGPUStoreOp_Store; + color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + + WGPURenderPassDescriptor pass_desc = {}; + pass_desc.colorAttachmentCount = 1; + pass_desc.colorAttachments = &color; + pass_desc.depthStencilAttachment = nullptr; + pass_desc.label = svFromCStr("ifcviewer-wgpu.edge_pass"); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); + wgpuRenderPassEncoderSetPipeline(pass, edge_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, edge_bind_group_, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0); + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); +} + +void WgpuViewportWindow::releaseEdgeResources() { + if (edge_bind_group_) { wgpuBindGroupRelease(edge_bind_group_); edge_bind_group_ = nullptr; } + if (edge_pipeline_) { wgpuRenderPipelineRelease(edge_pipeline_); edge_pipeline_ = nullptr; } + if (edge_shader_module_) { wgpuShaderModuleRelease(edge_shader_module_);edge_shader_module_ = nullptr; } + if (edge_pipeline_layout_) { wgpuPipelineLayoutRelease(edge_pipeline_layout_); edge_pipeline_layout_ = nullptr; } + if (edge_bgl_) { wgpuBindGroupLayoutRelease(edge_bgl_); edge_bgl_ = nullptr; } +} + bool WgpuViewportWindow::buildHizPipeline() { // Bind group layout: MSAA depth texture + small uniform. WGPUBindGroupLayoutEntry entries[2] = {}; @@ -1736,6 +1927,13 @@ void WgpuViewportWindow::render() { wgpuRenderPassEncoderEnd(pass); wgpuRenderPassEncoderRelease(pass); + // ---- Edge silhouette post-process — reads MSAA depth, blends dark + // lines onto the resolved surface colour. Encoded before HiZ resolve + // so HiZ uses the same MSAA depth that produced the edges. + if (edges_enabled_) { + encodeEdgePass(enc, view); + } + // ---- HiZ: resolve MSAA depth → small single-sample → ping-pong slot int hiz_submitted_slot = -1; if (hiz_enabled_) { @@ -2398,6 +2596,7 @@ void WgpuViewportWindow::shutdown() { releaseDepthTexture(); releaseMsaaColorTexture(); releaseHizResources(); + releaseEdgeResources(); if (frame_bind_group_) { wgpuBindGroupRelease(frame_bind_group_); frame_bind_group_ = nullptr; } if (frame_uniform_buffer_) { wgpuBufferRelease(frame_uniform_buffer_); frame_uniform_buffer_ = nullptr; } diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index 4fb05daf56..ae66d98e02 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -121,6 +121,9 @@ private: void releaseMsaaColorTexture(); bool buildHizPipeline(); + bool buildEdgePipeline(); + void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view); + void releaseEdgeResources(); void ensureHizTextures(int viewport_w, int viewport_h); void releaseHizResources(); // Resolves the just-rendered MSAA depth into the small single-sample @@ -247,6 +250,17 @@ private: // — making the pyramid 1+ frames stale, which is fine ("slightly-stale // depth" pattern the GL backend already documents). Two slots overlap // GPU write with CPU read; we never block on the readback. + // Edge silhouette post-process (stage 9). Samples the MSAA depth + // texture in a fullscreen pass, computes a depth Laplacian, blends + // dark lines into the resolved surface colour. Matches GL's + // renderEdgePass() visually. + WGPUShaderModule edge_shader_module_ = nullptr; + WGPUBindGroupLayout edge_bgl_ = nullptr; + WGPUPipelineLayout edge_pipeline_layout_ = nullptr; + WGPURenderPipeline edge_pipeline_ = nullptr; + WGPUBindGroup edge_bind_group_ = nullptr; + bool edges_enabled_ = true; + enum class HizSlotState : uint8_t { Idle, Mapping, Mapped }; static constexpr int HIZ_SLOTS = 2; WGPUBuffer hiz_staging_buffers_[HIZ_SLOTS] = { nullptr, nullptr };