From 716dba224440396338f216259338b1d5c65b7b38 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 31 May 2026 08:23:51 +1000 Subject: [PATCH] wgpu: overlay points (sprite-style, quad-expanded with stroke halo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports GL OverlayRenderer's setOverlayPoints API to WgpuOverlayRenderer. Each point becomes a 6-vertex screen-space quad sized to inner_diameter + 2*stroke_extra; the fragment reads its per-vertex corner varying instead of gl_PointCoord (WebGPU has no sized-point primitive). Sharp inner/stroke transition + AA on the outer edge only, matching GL. Single uniform slot per set — colors are global to the call, not per point. Vertex buffer regrows 1.5× on demand so steady-state sets don't re-allocate. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp | 268 +++++++++++++++++++++ src/ifcviewer-wgpu/WgpuOverlayRenderer.h | 34 +++ src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 17 ++ src/ifcviewer-wgpu/WgpuViewportWindow.h | 8 +- 4 files changed, 326 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp b/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp index ae3bd90025..c9ad7e2e00 100644 --- a/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp +++ b/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp @@ -328,6 +328,53 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } )WGSL"; +// Overlay-point shader: world-space positions expanded into screen-space +// quads (sprite size = inner_diameter + 2*stroke_extra). The FS does the +// sprite-distance pick + AA — same shape as GL OverlayRenderer's POINT_FS +// but reads `corner` from a vertex varying instead of gl_PointCoord +// because WebGPU has no point primitive with a sized sprite. +static const char* OVERLAY_POINTS_WGSL = R"WGSL( +struct PointUniforms { + view_proj: mat4x4, + inner_color: vec4, + stroke_color: vec4, + viewport_size: vec2, + total_half_px: f32, // (inner_diameter + 2*stroke_extra) * 0.5 + inner_radius_norm: f32, // inner_radius / total_half ∈ (0, 1] +}; +@group(0) @binding(0) var u: PointUniforms; + +struct VsOut { + @builtin(position) clip_pos: vec4, + @location(0) corner: vec2, +}; + +@vertex +fn vs_main(@location(0) world_pos: vec3, + @location(1) corner: vec2) -> VsOut { + let clip = u.view_proj * vec4(world_pos, 1.0); + let ndc = clip.xy / clip.w; + let s = ndc * 0.5 * u.viewport_size; + let s_off = s + corner * u.total_half_px; + let ndc_out = s_off / (u.viewport_size * 0.5); + var out: VsOut; + out.clip_pos = vec4(ndc_out * clip.w, clip.z, clip.w); + out.corner = corner; + return out; +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let d = length(in.corner); + if (d > 1.0) { discard; } + var col = u.inner_color; + if (d > u.inner_radius_norm) { col = u.stroke_color; } + let aa = fwidth(d); + let outer = 1.0 - smoothstep(1.0 - aa, 1.0, d); + return vec4(col.xyz, col.w * outer); +} +)WGSL"; + // ----------------------------------------------------------------------------- // Construction / destruction // ----------------------------------------------------------------------------- @@ -348,6 +395,7 @@ bool WgpuOverlayRenderer::init(WGPUInstance instance, WGPUDevice device, if (!buildSectionVisualizer()) return false; if (!buildMarquee()) return false; if (!buildOverlayLines()) return false; + if (!buildOverlayPoints()) return false; return true; } @@ -394,6 +442,17 @@ void WgpuOverlayRenderer::destroy() { overlay_line_vertex_capacity_ = 0; overlay_line_uniform_slots_ = 0; overlay_line_draws_.clear(); + + // Overlay points + if (overlay_point_bind_group_) { wgpuBindGroupRelease(overlay_point_bind_group_); overlay_point_bind_group_ = nullptr; } + if (overlay_point_pipeline_) { wgpuRenderPipelineRelease(overlay_point_pipeline_); overlay_point_pipeline_ = nullptr; } + if (overlay_point_shader_module_) { wgpuShaderModuleRelease(overlay_point_shader_module_); overlay_point_shader_module_ = nullptr; } + if (overlay_point_pipeline_layout_) { wgpuPipelineLayoutRelease(overlay_point_pipeline_layout_); overlay_point_pipeline_layout_ = nullptr; } + if (overlay_point_bgl_) { wgpuBindGroupLayoutRelease(overlay_point_bgl_); overlay_point_bgl_ = nullptr; } + if (overlay_point_uniform_buffer_) { wgpuBufferRelease(overlay_point_uniform_buffer_); overlay_point_uniform_buffer_ = nullptr; } + if (overlay_point_vertex_buffer_) { wgpuBufferRelease(overlay_point_vertex_buffer_); overlay_point_vertex_buffer_ = nullptr; } + overlay_point_vertex_capacity_ = 0; + overlay_point_vertex_count_ = 0; } // ----------------------------------------------------------------------------- @@ -1379,3 +1438,212 @@ void WgpuOverlayRenderer::encodeOverlayLines(WGPURenderPassEncoder pass, wgpuRenderPassEncoderDraw(pass, d.vertex_count, 1, d.first_vertex, 0); } } + +// ----------------------------------------------------------------------------- +// Overlay points +// ----------------------------------------------------------------------------- + +bool WgpuOverlayRenderer::buildOverlayPoints() { + { + WGPUBufferDescriptor bdesc = {}; + bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst; + bdesc.size = 256; + bdesc.label = svFromCStr("ifcviewer-wgpu.overlay_point_vbo"); + overlay_point_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc); + overlay_point_vertex_capacity_ = 256; + } + { + // WGSL PointUniforms struct size: mat4(64) + 2×vec4(32) + vec2(8) + + // 2×f32(8) = 112 B; struct rounds up to 128 (alignOf == 16). + WGPUBufferDescriptor bdesc = {}; + bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; + bdesc.size = 128; + bdesc.label = svFromCStr("ifcviewer-wgpu.overlay_point_uniforms"); + overlay_point_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc); + } + { + WGPUBindGroupLayoutEntry entry = {}; + entry.binding = 0; + entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; + entry.buffer.type = WGPUBufferBindingType_Uniform; + entry.buffer.minBindingSize = 128; + WGPUBindGroupLayoutDescriptor bgl_desc = {}; + bgl_desc.entryCount = 1; + bgl_desc.entries = &entry; + bgl_desc.label = svFromCStr("ifcviewer-wgpu.overlay_point_bgl"); + overlay_point_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc); + } + { + WGPUPipelineLayoutDescriptor pl_desc = {}; + pl_desc.bindGroupLayoutCount = 1; + pl_desc.bindGroupLayouts = &overlay_point_bgl_; + pl_desc.label = svFromCStr("ifcviewer-wgpu.overlay_point_pipeline_layout"); + overlay_point_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc); + } + { + WGPUBindGroupEntry entry = {}; + entry.binding = 0; + entry.buffer = overlay_point_uniform_buffer_; + entry.offset = 0; + entry.size = 128; + WGPUBindGroupDescriptor bg_desc = {}; + bg_desc.layout = overlay_point_bgl_; + bg_desc.entryCount = 1; + bg_desc.entries = &entry; + bg_desc.label = svFromCStr("ifcviewer-wgpu.overlay_point_bind_group"); + overlay_point_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc); + } + { + WGPUShaderSourceWGSL wgsl_src = {}; + wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL; + wgsl_src.code = svFromCStr(OVERLAY_POINTS_WGSL); + WGPUShaderModuleDescriptor sm_desc = {}; + sm_desc.nextInChain = &wgsl_src.chain; + sm_desc.label = svFromCStr("ifcviewer-wgpu.overlay_point_wgsl"); + overlay_point_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc); + } + + // Per-vertex layout: (world_pos.xyz, corner.xy) = 5 floats = 20 bytes. + WGPUVertexAttribute attribs[2] = {}; + attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0; + attribs[1].format = WGPUVertexFormat_Float32x2; attribs[1].offset = 12; attribs[1].shaderLocation = 1; + WGPUVertexBufferLayout vbl = {}; + vbl.arrayStride = 20; + vbl.stepMode = WGPUVertexStepMode_Vertex; + vbl.attributeCount = 2; + vbl.attributes = attribs; + + WGPUBlendState blend = {}; + blend.color.srcFactor = WGPUBlendFactor_SrcAlpha; + blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + blend.color.operation = WGPUBlendOperation_Add; + blend.alpha.srcFactor = WGPUBlendFactor_One; + blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + blend.alpha.operation = WGPUBlendOperation_Add; + + WGPUColorTargetState ct = {}; + ct.format = surface_format_; + ct.blend = &blend; + ct.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = overlay_point_shader_module_; + frag.entryPoint = svFromCStr("fs_main"); + frag.targetCount = 1; + frag.targets = &ct; + + WGPUDepthStencilState depth = {}; + depth.format = WGPUTextureFormat_Depth32Float; + depth.depthWriteEnabled = WGPUOptionalBool_False; + depth.depthCompare = WGPUCompareFunction_LessEqual; + depth.stencilFront.compare = WGPUCompareFunction_Always; + depth.stencilBack.compare = WGPUCompareFunction_Always; + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = overlay_point_pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.overlay_point_pipeline"); + rp_desc.vertex.module = overlay_point_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 1; + rp_desc.vertex.buffers = &vbl; + rp_desc.fragment = &frag; + rp_desc.depthStencil = &depth; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = uint32_t(sample_count_); + rp_desc.multisample.mask = 0xFFFFFFFFu; + overlay_point_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + return overlay_point_pipeline_ != nullptr; +} + +void WgpuOverlayRenderer::setOverlayPoints(const std::vector& world_xyz, + float r, float g, float b, float a, + float pixel_size, + float stroke_r, float stroke_g, + float stroke_b, float stroke_a, + float stroke_extra) { + overlay_point_vertex_count_ = 0; + if (world_xyz.size() < 3 || pixel_size <= 0.0f) return; + + const size_t n_pts = world_xyz.size() / 3; + // Six vertices per point (two triangles), 5 floats each. + static const float CORNERS[6][2] = { + {-1.0f, -1.0f}, {+1.0f, -1.0f}, {-1.0f, +1.0f}, + {-1.0f, +1.0f}, {+1.0f, -1.0f}, {+1.0f, +1.0f}, + }; + std::vector verts; + verts.reserve(n_pts * 6 * 5); + for (size_t p = 0; p < n_pts; ++p) { + const float* w = &world_xyz[p * 3]; + for (int c = 0; c < 6; ++c) { + verts.push_back(w[0]); verts.push_back(w[1]); verts.push_back(w[2]); + verts.push_back(CORNERS[c][0]); + verts.push_back(CORNERS[c][1]); + } + } + overlay_point_vertex_count_ = uint32_t(n_pts) * 6; + + const uint64_t bytes = uint64_t(verts.size()) * sizeof(float); + if (bytes > overlay_point_vertex_capacity_) { + const uint64_t new_cap = bytes + bytes / 2; + if (overlay_point_vertex_buffer_) { + wgpuBufferRelease(overlay_point_vertex_buffer_); + } + WGPUBufferDescriptor bdesc = {}; + bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst; + bdesc.size = new_cap; + bdesc.label = svFromCStr("ifcviewer-wgpu.overlay_point_vbo"); + overlay_point_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc); + overlay_point_vertex_capacity_ = new_cap; + } + wgpuQueueWriteBuffer(queue_, overlay_point_vertex_buffer_, 0, + verts.data(), size_t(bytes)); + + // Pack the [64..120) tail of the uniform slot (inner/stroke + sprite + // geometry — view_proj and viewport_size are written per-frame in + // encodeOverlayPoints). Slot layout matches WGSL PointUniforms: + // [ 0..64) view_proj (per-frame) + // [ 64..80) inner_color + // [ 80..96) stroke_color + // [ 96..104) viewport_size (per-frame) + // [104..108) total_half_px + // [108..112) inner_radius_norm + // pixel_size is the inner full diameter; total diameter = pixel_size + // + 2*stroke_extra; inner_radius_norm = inner_radius / total_half. + const float total_diam = pixel_size + 2.0f * stroke_extra; + const float total_half = total_diam * 0.5f; + const float inner_radius = pixel_size * 0.5f; + const float inner_norm = (total_half > 1e-6f) ? (inner_radius / total_half) + : 1.0f; + uint8_t slot_tail[44] = {}; + const float inner_rgba [4] = { r, g, b, a }; + const float stroke_rgba[4] = { stroke_r, stroke_g, stroke_b, stroke_a }; + std::memcpy(slot_tail + 0, inner_rgba, 16); + std::memcpy(slot_tail + 16, stroke_rgba, 16); + std::memcpy(slot_tail + 40, &total_half, 4); + // inner_radius_norm sits at slot offset 108 → tail offset 44, but the + // tail above only spans [64..108). The norm goes in its own write. + wgpuQueueWriteBuffer(queue_, overlay_point_uniform_buffer_, 64, + slot_tail, sizeof(slot_tail)); + wgpuQueueWriteBuffer(queue_, overlay_point_uniform_buffer_, 108, + &inner_norm, sizeof(inner_norm)); +} + +void WgpuOverlayRenderer::encodeOverlayPoints(WGPURenderPassEncoder pass, + const WgpuOverlayFrame& f) { + if (!overlay_point_pipeline_ || overlay_point_vertex_count_ == 0) return; + if (f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return; + + float vp[16]; + std::memcpy(vp, f.view_proj.constData(), sizeof(vp)); + const float viewport[2] = { float(f.viewport_w_px), + float(f.viewport_h_px) }; + wgpuQueueWriteBuffer(queue_, overlay_point_uniform_buffer_, 0, vp, sizeof(vp)); + wgpuQueueWriteBuffer(queue_, overlay_point_uniform_buffer_, 96, viewport, sizeof(viewport)); + + wgpuRenderPassEncoderSetPipeline(pass, overlay_point_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, overlay_point_bind_group_, 0, nullptr); + wgpuRenderPassEncoderSetVertexBuffer(pass, 0, overlay_point_vertex_buffer_, + 0, WGPU_WHOLE_SIZE); + wgpuRenderPassEncoderDraw(pass, overlay_point_vertex_count_, 1, 0, 0); +} diff --git a/src/ifcviewer-wgpu/WgpuOverlayRenderer.h b/src/ifcviewer-wgpu/WgpuOverlayRenderer.h index 52d617f823..42d860a40e 100644 --- a/src/ifcviewer-wgpu/WgpuOverlayRenderer.h +++ b/src/ifcviewer-wgpu/WgpuOverlayRenderer.h @@ -122,6 +122,25 @@ public: void encodeOverlayLines(WGPURenderPassEncoder pass, const WgpuOverlayFrame& f); + // Replace the overlay-point set. World-space positions are CPU- + // expanded into screen-space quads at encode time. `pixel_size` is + // the inner-disc diameter (px); when `stroke_a > 0` each quad picks + // up a `stroke_extra`-pixel halo per side. Empty `world_xyz` clears + // the set. Mirrors GL OverlayRenderer::setOverlayPoints. + void setOverlayPoints(const std::vector& world_xyz, + float r, float g, float b, float a, + float pixel_size, + float stroke_r, float stroke_g, + float stroke_b, float stroke_a, + float stroke_extra); + + // Encode the most-recently set point list. Single draw covering all + // points; the shader does the sprite-distance pick + AA. Drawn + // inside the main MSAA pass so depth-test correctly hides points + // behind closer geometry. + void encodeOverlayPoints(WGPURenderPassEncoder pass, + const WgpuOverlayFrame& f); + // ---- After the edge silhouette pass, on the resolved surface ---- // Corner axis gizmo (bottom-left, 110×110 px). Independent ortho @@ -149,6 +168,7 @@ private: bool buildSectionVisualizer(); bool buildMarquee(); bool buildOverlayLines(); + bool buildOverlayPoints(); WGPUInstance instance_ = nullptr; WGPUDevice device_ = nullptr; @@ -213,6 +233,20 @@ private: uint32_t vertex_count = 0; }; std::vector overlay_line_draws_; + + // ---- Overlay points (sprite-style, quad-expanded per point) ---- + // Single draw per encode covering every point in the set. Vertex + // buffer holds 6 verts × 8 bytes per point (vec3 world + vec2 corner). + // Uniforms are global to the set (one inner + one stroke color). + WGPUShaderModule overlay_point_shader_module_ = nullptr; + WGPUBindGroupLayout overlay_point_bgl_ = nullptr; + WGPUPipelineLayout overlay_point_pipeline_layout_ = nullptr; + WGPURenderPipeline overlay_point_pipeline_ = nullptr; + WGPUBuffer overlay_point_vertex_buffer_ = nullptr; + uint64_t overlay_point_vertex_capacity_ = 0; + WGPUBuffer overlay_point_uniform_buffer_ = nullptr; + WGPUBindGroup overlay_point_bind_group_ = nullptr; + uint32_t overlay_point_vertex_count_ = 0; }; #endif // WGPUOVERLAYRENDERER_H diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index 0c12689370..e8f678f3b0 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -2837,6 +2837,18 @@ void WgpuViewportWindow::setOverlayLines( if (isExposed()) requestUpdate(); } +void WgpuViewportWindow::setOverlayPoints(const std::vector& world_xyz, + float r, float g, float b, float a, + float pixel_size, + float stroke_r, float stroke_g, + float stroke_b, float stroke_a, + float stroke_extra) { + overlays_.setOverlayPoints(world_xyz, r, g, b, a, pixel_size, + stroke_r, stroke_g, stroke_b, stroke_a, + stroke_extra); + if (isExposed()) requestUpdate(); +} + // Project a world point to LOGICAL pixel coords (Qt's mouse-event units). // Returns false if behind the camera. static bool projectWorldToLogicalScreen(const QMatrix4x4& vp, @@ -3954,6 +3966,11 @@ void WgpuViewportWindow::render() { // them on the resolved surface afterwards. overlays_.encodeOverlayLines(pass, overlay_frame); + // Overlay point sprites (measurement endpoints, snap candidates). + // Drawn after lines so the sprite halo correctly covers any line + // ends at the same world position. + overlays_.encodeOverlayPoints(pass, overlay_frame); + wgpuRenderPassEncoderEnd(pass); wgpuRenderPassEncoderRelease(pass); diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index 6fe8497b59..cdbc83f0e4 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -284,8 +284,14 @@ private: // Overlay primitives. Mirror GL ViewportWindow so the Measurement + // dimension tools can target either backend through one API. - // Empty groups clears the current set. + // Empty inputs clears the corresponding set. void setOverlayLines(const std::vector& groups); + void setOverlayPoints(const std::vector& world_xyz, + float r, float g, float b, float a, + float pixel_size, + float stroke_r, float stroke_g, + float stroke_b, float stroke_a, + float stroke_extra); void ensureHizTextures(int viewport_w, int viewport_h); void releaseHizResources();