From a4235a12436e0abf38d5bdebd4ee364fda7d2add Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 27 Jul 2026 14:13:35 +0300 Subject: [PATCH] fix(text): show selection highlight Render tinted SDF glyphs in the final xray pass so base text and wipeouts cannot occlude them.\n\nRefs #479 --- src/scene/pipeline/mod.rs | 64 ++++++++++++---------- src/scene/pipeline/text_gpu.rs | 97 +++++++++++++++++++--------------- 2 files changed, 91 insertions(+), 70 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 329f0d54d..fb7c16989 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -102,6 +102,8 @@ pub struct Pipeline { /// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the /// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts). text_pipeline: wgpu::RenderPipeline, + /// Depth-independent variant used by selection / rollover highlighting. + text_highlight_pipeline: wgpu::RenderPipeline, mesh_pipeline: wgpu::RenderPipeline, /// Depth-write-disabled variant of `mesh_pipeline` for non-opaque solids. mesh_transparent_pipeline: wgpu::RenderPipeline, @@ -1602,8 +1604,8 @@ impl Pipeline { // ── Text (SDF glyph quads) ───────────────────────────────────────── let text_atlas_bgl = text_gpu::TextAtlasGpu::bind_group_layout(device); - let text_pipeline = - text_gpu::create_pipeline(device, &frame_bgl, &text_atlas_bgl, format, MSAA_SAMPLES); + let (text_pipeline, text_highlight_pipeline) = + text_gpu::create_pipelines(device, &frame_bgl, &text_atlas_bgl, format, MSAA_SAMPLES); let viewcube = ViewCubePipeline::new(device, queue, format); @@ -1745,6 +1747,7 @@ impl Pipeline { hatch_compat_pipeline, image_pipeline, text_pipeline, + text_highlight_pipeline, text_atlas_bgl, text_atlas_gpu: None, text_vbuf: None, @@ -3619,14 +3622,13 @@ impl Pipeline { } // ── Pass 5c: SDF text quads (drawn over wires) ──────────────────── - // The highlight buffer (selected / hovered text, tinted) is drawn in - // the same pass right after the base text so it recolours those glyphs. + // Selection / rollover text is drawn later with the selected-wire xray + // overlay, after wipeouts, so normal text cannot hide its own tint. if let Some(atlas) = &self.text_atlas_gpu { let have_base = self.text_vbuf.is_some() && self.text_vcount > 0; - let have_hl = self.text_highlight_vbuf.is_some() && self.text_highlight_vcount > 0; let have_preview = self.text_preview_vbuf.is_some() && self.text_preview_vcount > 0; - if have_base || have_hl || have_preview { + if have_base || have_preview { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("text.render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -3660,12 +3662,6 @@ impl Pipeline { pass.draw(0..self.text_vcount, 0..1); } } - if let Some(hlbuf) = &self.text_highlight_vbuf { - if self.text_highlight_vcount > 0 { - pass.set_vertex_buffer(0, hlbuf.slice(..)); - pass.draw(0..self.text_highlight_vcount, 0..1); - } - } // Grip-drag / command-preview glyphs, drawn over the base text. if let Some(pbuf) = &self.text_preview_vbuf { if self.text_preview_vcount > 0 { @@ -3714,12 +3710,14 @@ impl Pipeline { } } - // ── Pass 7: selected wire overlay pass ─────────────────────────── - // Redraws selected wires with depth_compare=Always so they appear on - // top of all other geometry at full brightness. - if !self.gpu_selected_wires.is_empty() { + // ── Pass 7: selection overlay pass ─────────────────────────────── + // Redraws selected wires and text with depth_compare=Always so both + // appear on top of all other geometry at full brightness. + let have_text_highlight = + self.text_highlight_vbuf.is_some() && self.text_highlight_vcount > 0; + if !self.gpu_selected_wires.is_empty() || have_text_highlight { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("wire_xray.render_pass"), + label: Some("selection_xray.render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: msaa, depth_slice: None, @@ -3741,19 +3739,31 @@ impl Pipeline { occlusion_query_set: None, }); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); - pass.set_pipeline(&self.wire_xray_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); - for wire in &self.gpu_selected_wires { - if wire.instance_count > 0 { - if let Some(bg) = &wire.const_bind_group { - pass.set_bind_group(1, bg.as_ref(), &[]); + if !self.gpu_selected_wires.is_empty() { + pass.set_pipeline(&self.wire_xray_pipeline); + for wire in &self.gpu_selected_wires { + if wire.instance_count > 0 { + if let Some(bg) = &wire.const_bind_group { + pass.set_bind_group(1, bg.as_ref(), &[]); + } + pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); + pass.draw( + 0..6, + wire.first_instance..wire.first_instance + wire.instance_count, + ); } - pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); - pass.draw( - 0..6, - wire.first_instance..wire.first_instance + wire.instance_count, - ); + } + } + if let (Some(atlas), Some(hlbuf)) = + (&self.text_atlas_gpu, &self.text_highlight_vbuf) + { + if self.text_highlight_vcount > 0 { + pass.set_pipeline(&self.text_highlight_pipeline); + pass.set_bind_group(1, &atlas.bind_group, &[]); + pass.set_vertex_buffer(0, hlbuf.slice(..)); + pass.draw(0..self.text_highlight_vcount, 0..1); } } } diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 3b039e2d2..6ca752a23 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -250,16 +250,17 @@ impl TextAtlasGpu { // ── Pipeline ───────────────────────────────────────────────────────────────── -/// Build the text render pipeline. Mirrors the image pipeline: alpha blending, -/// `LessEqual` depth with write, 4x MSAA. `frame_bgl` is group 0 (shared -/// uniforms), `atlas_bgl` is group 1 (the atlas). -pub fn create_pipeline( +/// Build the base and highlight text pipelines. The base mirrors the image +/// pipeline (`LessEqual` depth with write); the highlight variant always draws +/// without changing depth, matching the selected-wire xray overlay. +/// `frame_bgl` is group 0 (shared uniforms), `atlas_bgl` is group 1 (the atlas). +pub fn create_pipelines( device: &wgpu::Device, frame_bgl: &wgpu::BindGroupLayout, atlas_bgl: &wgpu::BindGroupLayout, color_format: wgpu::TextureFormat, sample_count: u32, -) -> wgpu::RenderPipeline { +) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("text.wgsl"), source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/text.wgsl").into()), @@ -269,44 +270,54 @@ pub fn create_pipeline( bind_group_layouts: &[frame_bgl, atlas_bgl], push_constant_ranges: &[], }); - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("text.pipeline"), - layout: Some(&layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[TextVertex::layout()], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: color_format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth24PlusStencil8, - depth_write_enabled: true, - depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState { - count: sample_count, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview: None, - cache: None, - }) + let create = |label, depth_write_enabled, depth_compare| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[TextVertex::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled, + depth_compare, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview: None, + cache: None, + }) + }; + ( + create("text.pipeline", true, wgpu::CompareFunction::LessEqual), + create( + "text.highlight.pipeline", + false, + wgpu::CompareFunction::Always, + ), + ) } /// Upload a finished vertex list; `None` if empty.