From 4d71bf745566c75f39400306892a00e76c8d888a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 15 Jun 2026 18:41:56 -0400 Subject: [PATCH] pmacs-gpu: wavy diagnostic squiggles (Q#W1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the straight 2px diagnostic underline bar (PR #65) with an actual sine squiggle. A dedicated SquiggleRenderer pipeline whose fragment shader computes A·sin(x·2π/λ) and alphas pixels by distance to the curve (fwidth/smoothstep — core WGSL, no MSAA or feature flag). Geometry reuses push_glyph_extent_rects unchanged; only the vertex format differs, adding a uv: absolute screen-x (so phase is continuous across separately emitted glyph-run rects) and signed px from the band centerline. The diagnostic underline is split out of the solid wash batch into its own buffer + draw, slotted under the glyphs where the bar was; washes (selection / current-line) stay solid quads. The severity palette (decoration_kind_to_underline_color) and minimap marks are untouched. DIAG_UNDERLINE_PX (2px) → DIAG_SQUIGGLE_PX (6px) so the wave fits inside the band. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/pmacs-gpu-wavy-squiggles-framing.md | 62 +++++ pmacs-gpu/src/main.rs | 276 +++++++++++++++++++++-- 2 files changed, 322 insertions(+), 16 deletions(-) create mode 100644 docs/pmacs-gpu-wavy-squiggles-framing.md diff --git a/docs/pmacs-gpu-wavy-squiggles-framing.md b/docs/pmacs-gpu-wavy-squiggles-framing.md new file mode 100644 index 0000000..8e17af6 --- /dev/null +++ b/docs/pmacs-gpu-wavy-squiggles-framing.md @@ -0,0 +1,62 @@ +# pmacs-gpu wavy squiggles — framing pass + +Date: 2026-06-15. The diagnostic-parity session (PR #65) deferred the +wavy look: diagnostics underline with a straight 2px quad bar +(`DIAG_UNDERLINE_PX`), with the framing note "wavy needs a shader or +texture and buys nothing until the straight bar is proven." The bar is +proven; this is the shader. + +## Survey facts + +- The single `QuadRenderer` (one solid-color shader) draws every quad: + selection/current-line washes, caret, minimap, status band, and the + diagnostic bars. Its vertex is `pos(2) + color(4)` = 24 bytes. +- Diagnostic bars are emitted in `collect_own_decoration_rects` via + `push_glyph_extent_rects(.., Some(DIAG_UNDERLINE_PX))` and ride the + `bg_vertices` batch (drawn *under* the text). +- `fwidth`/`smoothstep` are core WGSL — anti-aliasing needs no feature + flag or MSAA change. + +## Q#W1 — mechanism + +**Stance: a dedicated squiggle pipeline, not geometry.** A zigzag of +many tiny quads would need no shader but reads as a sawtooth and +multiplies vertex count per underline. Instead a second pipeline +(`SquiggleRenderer`) whose fragment shader computes +`A·sin(x·2π/λ)` and alphas pixels by distance to the curve +(`1 - smoothstep(thickness±fwidth, dist)`). Six vertices per glyph-run +extent, same as the bar — the geometry path (`push_glyph_extent_rects`) +is reused unchanged; only the vertex *format* differs (adds a `uv`: +absolute screen-x for continuous phase, signed px from the band +centerline for the cross-axis). The band grows from 2px to ~6px to +contain the wave. + +## Q#W2 — routing & z-order + +**Stance: split the diagnostic underline out of the wash batch; keep +it under the text.** Washes stay solid quads in `bg_vertices`; +squiggles get their own `squiggle_vertex_bytes` → own reusable buffer +→ own draw, slotted between the bg quads and the text (same z-slot the +bar had — descenders paint over the wave, the conventional +spell-check look). `decoration_kind_to_underline_color` is unchanged, +so the severity palette and the minimap marks are untouched. + +## Predicted findings (categorical bets) + +1. **Placement/amplitude tuning**: wavelength, amplitude, or the + band's vertical offset reads wrong at the default font size — one + round of "too tight / too tall / too faint" feedback. +2. **Phase seams**: a diagnostic spanning two glyph *runs* (or two + adjacent diagnostics) emits separate rects; if phase isn't keyed to + absolute screen-x the waves don't line up at the seam. (Mitigated + by using screen-x as the phase input — but worth eyeballing.) +3. **DPI/hidpi**: amplitude and thickness are in logical px; on a + fractional-scale surface the AA may thin or the wave may alias. + +## Session plan + +One commit: squiggle shader + pipeline + vertex format, route the +underline out of the wash batch, rename `DIAG_UNDERLINE_PX` → +`DIAG_SQUIGGLE_PX`, pure-fn test on the UV emission. Manual +validation: a file with an error + warning + hint on adjacent lines, +a diagnostic spanning a multi-glyph token, and a resize. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 47a755e..9edaf66 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -136,6 +136,51 @@ const QUAD_VERTEX_STRIDE: wgpu::BufferAddress = 24; const QUAD_VERTEX_ATTRS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4]; +/// Diagnostic squiggle shader (Q#W1). The vertex carries, beyond NDC +/// position and color, a `uv`: `uv.x` is the absolute screen-space +/// pixel x (so the wave's phase is continuous across separately +/// emitted glyph-run rects), `uv.y` is the signed pixel offset from +/// the band's vertical centerline. The fragment draws an +/// anti-aliased sine: alpha falls off with distance to the curve via +/// `fwidth`/`smoothstep` (both core WGSL — no MSAA or feature flag). +const SQUIGGLE_SHADER: &str = r" +struct VertexOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, +}; + +@vertex +fn vs_main( + @location(0) pos: vec2, + @location(1) uv: vec2, + @location(2) color: vec4, +) -> VertexOut { + var out: VertexOut; + out.pos = vec4(pos, 0.0, 1.0); + out.uv = uv; + out.color = color; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + let wavelength = 6.0; // px per full sine period + let amplitude = 1.4; // px peak from centerline + let thickness = 1.0; // px stroke half-width + let two_pi = 6.2831853; + let wave = amplitude * sin(in.uv.x * (two_pi / wavelength)); + let dist = abs(in.uv.y - wave); + let aa = fwidth(dist); + let alpha = 1.0 - smoothstep(thickness - aa, thickness + aa, dist); + return vec4(in.color.rgb, in.color.a * alpha); +} +"; + +const SQUIGGLE_VERTEX_STRIDE: wgpu::BufferAddress = 32; +const SQUIGGLE_VERTEX_ATTRS: [wgpu::VertexAttribute; 3] = + wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4]; + /// Text the hello-world (and attach-pre-snapshot / attach-failed) /// modes render. Once the daemon's `BufferSnapshot` arrives the /// rendered text becomes the rope contents instead. @@ -259,6 +304,7 @@ struct State { atlas: TextAtlas, text_renderer: TextRenderer, quad_renderer: QuadRenderer, + squiggle_renderer: SquiggleRenderer, buffer: Buffer, /// What the buffer is currently shaped to. Held so we can detect /// no-op updates and skip the re-shape. @@ -465,6 +511,7 @@ struct State { /// Absolute source-line index of `buffer.lines[0]`. shaped_top: usize, bg_vertex_buffer: ReusableVertexBuffer, + squiggle_vertex_buffer: ReusableVertexBuffer, caret_vertex_buffer: ReusableVertexBuffer, minimap_vertex_buffer: ReusableVertexBuffer, /// Q#S2 — the status band's one-line text. Shaped only when the @@ -1137,6 +1184,69 @@ impl QuadRenderer { } } +/// Diagnostic-squiggle pipeline (Q#W1). Parallel to [`QuadRenderer`] +/// but with the [`SQUIGGLE_SHADER`] / [`SQUIGGLE_VERTEX_ATTRS`] vertex +/// format that carries the per-fragment `uv` the sine fragment shader +/// needs. +struct SquiggleRenderer { + pipeline: wgpu::RenderPipeline, +} + +impl SquiggleRenderer { + fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pmacs-gpu squiggle shader"), + source: wgpu::ShaderSource::Wgsl(SQUIGGLE_SHADER.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pmacs-gpu squiggle pipeline layout"), + bind_group_layouts: &[], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("pmacs-gpu squiggle pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: SQUIGGLE_VERTEX_STRIDE, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &SQUIGGLE_VERTEX_ATTRS, + }], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + Self { pipeline } + } + + fn render<'pass>( + &'pass self, + pass: &mut wgpu::RenderPass<'pass>, + vertex_buffer: &'pass wgpu::Buffer, + vertex_count: u32, + ) { + pass.set_pipeline(&self.pipeline); + pass.set_vertex_buffer(0, vertex_buffer.slice(..)); + pass.draw(0..vertex_count, 0..1); + } +} + impl State { #[allow(clippy::too_many_lines)] // linear GPU/font/surface setup; splitting would obscure ordering. fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self { @@ -1207,6 +1317,7 @@ impl State { let text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, surface_format); + let squiggle_renderer = SquiggleRenderer::new(&device, surface_format); // Smaller font in attach mode (file contents tend to be more // than one line); larger only fits "hello, pmacs"-shaped @@ -1258,6 +1369,7 @@ impl State { atlas, text_renderer, quad_renderer, + squiggle_renderer, buffer, current_text: initial_text.to_owned(), current_line_starts, @@ -1297,6 +1409,7 @@ impl State { line_chunk_cache: Vec::new(), shaped_top: 0, bg_vertex_buffer: ReusableVertexBuffer::new(), + squiggle_vertex_buffer: ReusableVertexBuffer::new(), caret_vertex_buffer: ReusableVertexBuffer::new(), minimap_vertex_buffer: ReusableVertexBuffer::new(), status_buffer, @@ -2813,6 +2926,21 @@ impl State { &bg_vertices, ) .cloned(); + // Diagnostic squiggles (Q#W1): own pipeline + buffer, drawn + // between the wash quads and the text (under the glyphs, the + // z-slot the straight bar held). + let squiggle_vertices = self.squiggle_vertex_bytes(); + let squiggle_vertex_count = + (squiggle_vertices.len() / SQUIGGLE_VERTEX_STRIDE as usize) as u32; + let squiggle_buffer = self + .squiggle_vertex_buffer + .upload( + &self.device, + &self.queue, + "pmacs-gpu diagnostic squiggles", + &squiggle_vertices, + ) + .cloned(); let caret_vertices = self.caret_vertex_bytes(); let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let caret_buffer = self @@ -2956,6 +3084,11 @@ impl State { self.quad_renderer .render(&mut pass, vertex_buffer, bg_vertex_count); } + // Diagnostic squiggles under the glyphs (Q#W1). + if let Some(vertex_buffer) = squiggle_buffer.as_ref() { + self.squiggle_renderer + .render(&mut pass, vertex_buffer, squiggle_vertex_count); + } self.text_renderer .render(&self.atlas, &self.viewport, &mut pass) .expect("text_renderer render"); @@ -3076,20 +3209,47 @@ impl State { let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) else { continue; }; + // Diagnostic underlines are squiggles now, drawn by their + // own pipeline (`squiggle_vertex_bytes`); only the solid + // washes belong in this quad batch. if let Some(color) = decoration_kind_to_bg_color(d.kind) { self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None); } - if let Some(color) = decoration_kind_to_underline_color(d.kind) { + } + } + + /// Vertex bytes for diagnostic squiggles (Q#W1), drawn by the + /// dedicated [`SquiggleRenderer`] pipeline rather than the solid + /// quad batch. Geometry is the same bottom-hugging glyph-extent + /// band as the old straight bar — only the vertex *format* differs + /// (carries the `uv` the sine fragment shader needs). + fn squiggle_vertex_bytes(&self) -> Vec { + if self.current_buffer_id.is_none() { + return Vec::new(); + } + let (vstart, vend) = self.view_range; + if vend <= vstart { + return Vec::new(); + } + let slice = &self.current_text[vstart as usize..vend as usize]; + let line_offsets = line_byte_offsets(slice); + let mut rects = Vec::new(); + for d in &self.current_decorations { + let Some(color) = decoration_kind_to_underline_color(d.kind) else { + continue; + }; + if let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) { self.push_glyph_extent_rects( - rects, - line_offsets, + &mut rects, + &line_offsets, lo, hi, color, - Some(DIAG_UNDERLINE_PX), + Some(DIAG_SQUIGGLE_PX), ); } } + squiggles_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// Peer cursor-line + selection washes from `PresenceUpdate` @@ -3226,9 +3386,10 @@ impl State { if let (Some(x0), Some(x1)) = (min_x, max_x) && x1 > x0 { - // `bar_px`: an underline bar hugging the bottom of the - // line box instead of a full-height wash — the GPU's - // diagnostic squiggle (T M4.6 parity, straight-bar v1). + // `bar_px`: a band hugging the bottom of the line box + // instead of a full-height wash — the diagnostic + // squiggle's geometry (T M4.6 parity; the squiggle + // shape comes from the fragment shader, Q#W1). let (y, h) = match bar_px { Some(bar) => (TEXT_TOP + run.line_top + run.line_height - bar, bar), None => (TEXT_TOP + run.line_top, run.line_height), @@ -4351,6 +4512,48 @@ fn push_quad_vertex(bytes: &mut Vec, x: f32, y: f32, color: [f32; 4]) { } } +/// Vertex bytes for the diagnostic-squiggle pipeline (Q#W1). Same six +/// vertices per rect as a quad, but each carries a `uv`: `uv.x` is the +/// absolute screen-space pixel x of the corner (continuous phase +/// across separately emitted rects), `uv.y` is the signed pixel offset +/// from the band's vertical centerline (`±h/2`). The fragment shader +/// turns that into the sine. +fn squiggles_to_vertex_bytes(rects: &[MinimapRect], width: u32, height: u32) -> Vec { + let mut bytes = Vec::with_capacity(rects.len() * 6 * SQUIGGLE_VERTEX_STRIDE as usize); + for rect in rects { + if rect.w <= 0.0 || rect.h <= 0.0 || width == 0 || height == 0 { + continue; + } + let x0 = px_to_ndc_x(rect.x, width); + let x1 = px_to_ndc_x(rect.x + rect.w, width); + let y0 = px_to_ndc_y(rect.y, height); + let y1 = px_to_ndc_y(rect.y + rect.h, height); + let (left, right) = (rect.x, rect.x + rect.w); + let half = rect.h * 0.5; + // Top edge → centerline-relative uv.y = -half; bottom → +half. + push_squiggle_vertex(&mut bytes, x0, y0, left, -half, rect.color); + push_squiggle_vertex(&mut bytes, x1, y0, right, -half, rect.color); + push_squiggle_vertex(&mut bytes, x1, y1, right, half, rect.color); + push_squiggle_vertex(&mut bytes, x0, y0, left, -half, rect.color); + push_squiggle_vertex(&mut bytes, x1, y1, right, half, rect.color); + push_squiggle_vertex(&mut bytes, x0, y1, left, half, rect.color); + } + bytes +} + +fn push_squiggle_vertex( + bytes: &mut Vec, + x: f32, + y: f32, + uv_x: f32, + uv_y: f32, + color: [f32; 4], +) { + for value in [x, y, uv_x, uv_y, color[0], color[1], color[2], color[3]] { + bytes.extend_from_slice(&value.to_ne_bytes()); + } +} + fn px_to_ndc_x(x: f32, width: u32) -> f32 { x / width as f32 * 2.0 - 1.0 } @@ -4587,22 +4790,23 @@ fn indexed_to_glyphon(idx: u8) -> glyphon::Color { glyphon::Color::rgb(level, level, level) } -/// Height of the diagnostic underline bar, in pixels. Straight-bar -/// v1; a wavy squiggle needs shader/texture work and waits until the -/// straight bar is proven (framing Q#D1). -const DIAG_UNDERLINE_PX: f32 = 2.0; +/// Height of the diagnostic squiggle band, in pixels (Q#W1). Taller +/// than the original straight bar (2px) so the sine wave fits: the +/// fragment shader's `amplitude + thickness` (~2.4px each side) must +/// sit inside half the band. +const DIAG_SQUIGGLE_PX: f32 = 6.0; -/// Map a [`DecorationKind`] to an underline-bar color, or `None` for +/// Map a [`DecorationKind`] to an underline color, or `None` for /// kinds that don't underline. /// /// Session 5 originally rendered diagnostics by *recoloring the text /// foreground*, which clobbered the syntax color of the very token /// the diagnostic points at — the same flaw the TUI fixed with /// protocol v6's `underline_color` (T M4.6). The GPU's equivalent is -/// a [`DIAG_UNDERLINE_PX`]-tall quad hugging the bottom of the glyph -/// extent; the text keeps its syntax color. Same RGB palette the fg -/// path used (red / yellow / light blue / dim gray), so the window's -/// severity language is unchanged. +/// a wavy squiggle hugging the bottom of the glyph extent (Q#W1, was +/// a straight bar through PR #65); the text keeps its syntax color. +/// Same RGB palette the fg path used (red / yellow / light blue / +/// dim gray), so the window's severity language is unchanged. fn decoration_kind_to_underline_color(kind: DecorationKind) -> Option<[f32; 4]> { match kind { // ANSI bright red — matches TUI diagnostic-error palette. @@ -5646,4 +5850,44 @@ mod tests { assert!((f32_at(&bytes, 1) - 1.0).abs() < 0.001); assert!((f32_at(&bytes, 2) - 1.0).abs() < 0.001); } + + #[test] + fn squiggle_vertices_carry_centerline_relative_uv() { + // Q#W1: uv.x is the absolute screen pixel x (so phase is + // continuous across rects); uv.y is signed px from the band + // centerline (±h/2). Layout per vertex: [ndc_x, ndc_y, uv_x, + // uv_y, r, g, b, a] — 8 floats. + let rect = MinimapRect { + x: 10.0, + y: 20.0, + w: 30.0, + h: 6.0, + color: [0.9, 0.1, 0.2, 1.0], + }; + let bytes = squiggles_to_vertex_bytes(&[rect], 100, 100); + assert_eq!(bytes.len(), 6 * SQUIGGLE_VERTEX_STRIDE as usize); + + // Vertex 0 = top-left: uv = (left x, -h/2). + assert!((f32_at(&bytes, 2) - 10.0).abs() < 0.001, "uv.x left"); + assert!((f32_at(&bytes, 3) + 3.0).abs() < 0.001, "uv.y top = -h/2"); + // Color rides every vertex. + assert!((f32_at(&bytes, 4) - 0.9).abs() < 0.001); + assert!((f32_at(&bytes, 7) - 1.0).abs() < 0.001); + // Vertex 2 = bottom-right (8 floats in): uv = (right x, +h/2). + assert!((f32_at(&bytes, 16 + 2) - 40.0).abs() < 0.001, "uv.x right"); + assert!((f32_at(&bytes, 16 + 3) - 3.0).abs() < 0.001, "uv.y bottom"); + } + + #[test] + fn squiggles_skip_degenerate_rects() { + let zero_w = MinimapRect { + x: 0.0, + y: 0.0, + w: 0.0, + h: 6.0, + color: [1.0, 0.0, 0.0, 1.0], + }; + assert!(squiggles_to_vertex_bytes(&[zero_w], 100, 100).is_empty()); + assert!(squiggles_to_vertex_bytes(&[zero_w], 0, 100).is_empty()); + } }