Merge pull request #68 from levineuwirth/session-gpu-wavy-squiggles

GPU diagnostic squiggles: wavy shader + optimistic clear-on-edit
This commit is contained in:
Levi Neuwirth 2026-06-15 19:24:50 -04:00 committed by GitHub
commit fde5453721
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 382 additions and 16 deletions

View File

@ -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.

View File

@ -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<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
};
@vertex
fn vs_main(
@location(0) pos: vec2<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>,
) -> VertexOut {
var out: VertexOut;
out.pos = vec4<f32>(pos, 0.0, 1.0);
out.uv = uv;
out.color = color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
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<f32>(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<u8> {
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),
@ -4220,6 +4381,19 @@ fn translate_style_spans(spans: &mut Vec<StyleSpan>, edit: TextProjectionEdit) {
fn translate_decorations(decorations: &mut Vec<Decoration>, edit: TextProjectionEdit) {
let mut translated = Vec::with_capacity(decorations.len());
for mut decoration in decorations.drain(..) {
// Optimistic clear (diagnostics only): an edit that touches a
// diagnostic's range invalidates it locally, so drop the
// squiggle now instead of holding a stale wave over the text
// you just changed until the LSP re-analyzes and republishes.
// Scoped to the *touched* diagnostic — an error elsewhere
// still translates and holds, so the no-blink benefit of the
// producer's hold-while-stale survives. Non-diagnostic
// decorations (selection / current-line) always translate.
if decoration_kind_to_underline_color(decoration.kind).is_some()
&& edit_touches_range(decoration.range, edit)
{
continue;
}
if let Some(range) = translate_byte_range(decoration.range, edit) {
decoration.range = range;
translated.push(decoration);
@ -4228,6 +4402,14 @@ fn translate_decorations(decorations: &mut Vec<Decoration>, edit: TextProjection
*decorations = translated;
}
/// True when `edit` touches `range` in the pre-edit coordinate space.
/// An insert (`old_end == start`) touches when its point lies within
/// `[start, end]` (inclusive — typing at either edge of an error
/// token counts); a delete/replace touches when its span overlaps.
fn edit_touches_range(range: ByteRange, edit: TextProjectionEdit) -> bool {
edit.start <= range.end && range.start <= edit.old_end
}
fn translate_inline_adornments(adornments: &mut [InlineAdornment], edit: TextProjectionEdit) {
for adornment in adornments {
adornment.at = translate_byte_position(adornment.at, edit);
@ -4351,6 +4533,48 @@ fn push_quad_vertex(bytes: &mut Vec<u8>, 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<u8> {
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<u8>,
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 +4811,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.
@ -5292,6 +5517,45 @@ mod tests {
);
}
#[test]
fn editing_a_diagnostic_clears_it_optimistically_but_holds_untouched_ones() {
let diag = |start, end| Decoration {
range: ByteRange { start, end },
kind: DecorationKind::DiagnosticError,
};
// Insert the missing char right at the 1-byte widened anchor of
// an end-of-line "expected COMMA": the squiggle clears now.
let mut decos = vec![diag(11, 12), diag(40, 50)];
translate_decorations(
&mut decos,
TextProjectionEdit {
start: 11,
old_end: 11,
inserted_len: 1,
},
);
// The touched diagnostic is gone; the far one is kept (shifted
// right by the insert) — hold-while-stale still applies to it.
assert_eq!(decos.len(), 1, "only the touched diagnostic clears");
assert_eq!(decos[0].range, ByteRange { start: 41, end: 51 });
// A non-diagnostic decoration over the edited region is never
// dropped — selection / current-line translate as before.
let mut sel = vec![Decoration {
range: ByteRange { start: 8, end: 14 },
kind: DecorationKind::Selection,
}];
translate_decorations(
&mut sel,
TextProjectionEdit {
start: 10,
old_end: 10,
inserted_len: 2,
},
);
assert_eq!(sel.len(), 1, "selection survives an edit in its range");
}
#[test]
fn source_line_range_handles_empty_and_leading_newline() {
assert_eq!(source_line_range("", 0), (0, 0));
@ -5646,4 +5910,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());
}
}