Skip to main content

rlx_core/render/scenes/lines/
renderer.rs

1//! The shared line primitive: a GPU helper that draws thick, glowing lines as
2//! instanced camera-facing quads. Each [`SegmentInstance`] (two endpoints, a
3//! colour, a half-width) is expanded in the vertex shader into a quad whose
4//! width is uniform *on screen* — the swarm scene's instanced-quad pipeline
5//! (ADR-0007) with segments in place of points. Additive blend, so overlapping
6//! and dense strokes bloom.
7//!
8//! Native wgpu line primitives are deliberately not used: their width is locked
9//! near 1px and varies by backend (ADR-0007). The buffer is fixed-capacity and
10//! reused every frame, so a full curve upload never allocates on the hot path.
11
12// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
13// 0003 Phase 0). `draw` runs every displayed frame.
14#![deny(
15    clippy::unwrap_used,
16    clippy::expect_used,
17    clippy::indexing_slicing,
18    clippy::panic,
19    clippy::unreachable
20)]
21
22use crate::render::gpu;
23use crate::render::metrics::{self, DrawExtent};
24
25/// The sharpest corner a miter is drawn for, as a multiple of the half-width
26/// the miter would reach (ADR-0158).
27///
28/// A corner of interior angle `theta` needs `width / sin(theta / 2)`, which
29/// grows without bound as the corner sharpens: a spike that doubles back on
30/// itself would reach to infinity and paint a spear across the frame. Past the
31/// limit the joint **reverts to the flat half-width** — a bevel, which is what
32/// `stroke-miterlimit` selects in SVG and what an unmitred joint always drew.
33/// It is a fallback and not a truncation: clamping to `MITER_LIMIT * width`
34/// instead leaves the stroke reaching four half-widths past the vertex along its
35/// own direction, which reads as a burr rather than as a corner.
36///
37/// **`4.0` is adopted, not measured.** It is SVG's `stroke-miterlimit` default,
38/// and it draws exactly every corner at or above
39/// `2 * asin(1 / 4) = 28.96 degrees` — that is the angle at which
40/// `1 / sin(theta / 2)` reaches 4, so the limit is a statement about which
41/// corners are mitred and which are bevelled, not a tuned number (ADR-0071).
42/// `diamond`'s 61.9-degree vertex needs 1.9437 and is well inside it; a Maurer
43/// chord web's near-reversals are the population outside it.
44pub const MITER_LIMIT: f32 = 4.0;
45
46/// The extension a joined end needs to reach its corner's point: the miter
47/// length at `vertex`, where the chain arrives from `prev` and leaves for
48/// `next`, clamped to [`MITER_LIMIT`] half-widths (ADR-0158).
49///
50/// # The expression, and why it carries no trigonometry
51///
52/// For an interior angle `theta` the miter is `width / sin(theta / 2)`. Writing
53/// `d1`, `d2` for the two unit directions, the turn between them is
54/// `theta_turn = pi - theta`, so
55///
56/// ```text
57/// sin(theta / 2) = cos(theta_turn / 2) = sqrt((1 + d1 · d2) / 2)
58/// ```
59///
60/// — a dot product and a square root, with no `acos` to lose precision near the
61/// straight case and no branch on the turn's sign. A straight joint has
62/// `d1 · d2 = 1` and yields exactly `width`, which is the flat half-width, so a
63/// collinear chain is byte-identical to one that extends by `width`.
64///
65/// # Homogeneous of degree 1 in `width`
66///
67/// Both `width / sin(theta / 2)` and the clamp `MITER_LIMIT * width` scale
68/// linearly, so `miter(c * w) == c * miter(w)` and the clamp cannot be engaged
69/// at one width and not another. That is what lets the cached producers compute
70/// this against `PLACEHOLDER_WIDTH` at `configure` and have
71/// `LineInstance::styled` carry it to this frame's width by the ratio. Both are
72/// crate-private, so this names them rather than linking them. `theta` survives
73/// that too: every transform between a producer and the shader — uniform scale,
74/// rotation, reflection, the mirror replication, `normalize_fit` — is a
75/// similarity, and a similarity preserves angles.
76///
77/// # Degenerate input
78///
79/// A zero-length arm has no direction, and a chain that doubles back exactly
80/// (`d1 · d2 = -1`) has no reachable point. Both fall back to `width`, the flat
81/// half-width — which is the same value a corner past [`MITER_LIMIT`] takes, so
82/// the degenerate case is the limit case rather than a separate rule.
83pub fn miter_extension(width: f32, prev: [f32; 2], vertex: [f32; 2], next: [f32; 2]) -> f32 {
84    let unit = |from: [f32; 2], to: [f32; 2]| -> Option<[f32; 2]> {
85        let (dx, dy) = (to[0] - from[0], to[1] - from[1]);
86        let len = dx.hypot(dy);
87        (len > 1e-9).then(|| [dx / len, dy / len])
88    };
89    let (Some(d1), Some(d2)) = (unit(prev, vertex), unit(vertex, next)) else {
90        return width;
91    };
92    miter_extension_between(width, d1, d2)
93}
94
95/// [`miter_extension`] for a chain that carries its own tangents rather than a
96/// third point — a fitted arc/line chain, where the direction a neighbour
97/// arrives or leaves at is a property of the piece and not of any vertex.
98///
99/// `incoming` and `outgoing` are **unit** directions of travel through the
100/// joint. A G1 joint has them equal, so this returns exactly `width` there and a
101/// tangent-continuous run is byte-identical to one extended by the flat
102/// half-width; only the chain's genuine corners move.
103pub fn miter_extension_between(width: f32, incoming: [f32; 2], outgoing: [f32; 2]) -> f32 {
104    let half = ((1.0 + incoming[0] * outgoing[0] + incoming[1] * outgoing[1]) * 0.5)
105        .max(0.0)
106        .sqrt();
107    if half <= 1.0 / MITER_LIMIT {
108        // Bevel, not a truncated miter — see `MITER_LIMIT`.
109        return width;
110    }
111    width / half
112}
113
114/// One line segment: endpoints `a`/`b` in world space (x is divided by aspect
115/// in the shader, matching the swarm's convention), an RGB colour, a
116/// half-width in world units, and the per-endpoint extension length the join
117/// needs.
118#[repr(C)]
119#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
120pub struct SegmentInstance {
121    /// First endpoint (world space).
122    pub a: [f32; 2],
123    /// Second endpoint (world space).
124    pub b: [f32; 2],
125    /// RGB colour (pre-brightness; additive blend sums overlaps).
126    pub color: [f32; 3],
127    /// Half-width in **world units** — the isotropic space, where the stroke is
128    /// the same thickness on screen at every orientation (ADR-0160). It is
129    /// numerically an NDC-y half-width: world y and NDC y are the same axis, so
130    /// the horizontal case is the same number in either space and every other
131    /// orientation agrees with it.
132    ///
133    /// **Unless the draw selected [`StrokeMetric::Clip`]**, where this is an NDC
134    /// half-width and the on-screen thickness varies with the segment's
135    /// orientation by up to the aspect. That metric is per draw call rather than
136    /// per instance, and `warp_mesh` is the one surface on it.
137    pub width: f32,
138    /// How far the quad extends **backward** past `a`, along the segment's own
139    /// direction, in the same world half-width units as [`width`](Self::width).
140    /// `0.0` is a free end (ADR-0158).
141    ///
142    /// The length is the **producer's** to compute: only it knows whether an end
143    /// is shared with a neighbour and at what interior angle, which is the same
144    /// argument that put connectivity on the producer side. `0.0` renders exactly
145    /// the geometry an unflagged end rendered, because `dir * 0.0` is exactly
146    /// zero — that is what keeps the isolated producers, `spectrum`'s `Bars` and
147    /// `RadialRing`, byte-identical.
148    ///
149    /// **A length, not a factor.** It is resolved against `width` at
150    /// the moment the producer fills the instance. Every producer in this crate
151    /// rebuilds its instance buffer per frame, so the two cannot drift; a
152    /// producer that ever caches instances across frames while animating
153    /// `thickness` would have to recompute this alongside it.
154    pub ext_a: f32,
155    /// **How much of its own footprint this stroke occupies**, on top of the
156    /// across-the-stroke falloff — the fragment's alpha is `falloff * alpha`.
157    ///
158    /// `1.0` for every producer that draws through the additive seam, and that is
159    /// the value to pass unless you know otherwise: ADR-0056's rule is that a
160    /// dimmed stroke still covers its own footprint, so brightness belongs in
161    /// [`color`](Self::color) and never here. Every line scene in this crate
162    /// passes `1.0`, which makes the fragment exactly what it was before this
163    /// field existed.
164    ///
165    /// What it is for is [`LineRenderer::draw_split`]'s second range, where the
166    /// stroke is composited **over** rather than added and the blend needs the
167    /// producer's real coverage: a MilkDrop waveform at `wave_a = 0.1` must
168    /// replace a tenth of what is under it, not all of it (Plan 0100 Phase 4).
169    ///
170    /// **Its position in this struct is load-bearing.** `vertex_attr_array!`
171    /// derives each attribute's byte offset from the order of the *shader
172    /// locations*, so a field inserted ahead of this one shifts location 5 onto
173    /// the bytes location 4 is reading and every attribute after it by one slot.
174    /// The result compiles, renders, and quietly reinterprets one field as
175    /// another's mantissa — which is what it did, moving five composite golden
176    /// baselines. **A new field goes last**, which is where
177    /// [`ext_b`](Self::ext_b) is.
178    pub alpha: f32,
179    /// How far the quad extends **forward** past `b`. The `b`-end counterpart of
180    /// [`ext_a`](Self::ext_a), in the same units and with the same `0.0`-is-free
181    /// convention.
182    ///
183    /// **Declared last**, for the reason [`alpha`](Self::alpha) records: it was
184    /// appended when the endpoint stopped carrying a flag and started carrying a
185    /// length, and appending is the only placement that re-points nothing.
186    pub ext_b: f32,
187}
188
189/// One **circular arc**: a centre and radius in world space, a signed angular
190/// span in radians, an RGB colour and a half-width in NDC-y units — the same
191/// conventions [`SegmentInstance`] uses, so the two kinds place geometry in one
192/// coordinate system and stroke it to one profile.
193///
194/// Expanded in the vertex shader to a single bounding quad and shaded by the
195/// **per-pixel distance to the arc** (ADR-0098) rather than by an
196/// interpolated across-the-stroke coordinate. So a `circle` is one instance
197/// with no vertices at any resolution, where the segment path needs one
198/// instance and one additive joint per sample.
199///
200/// **No extension fields: an arc has no interior joints**, which is the whole
201/// point of the primitive. Where two arcs in a chain meet they overlap by a
202/// half-width as any two strokes do, and the additive composite
203/// sums that overlap exactly as it does for segments — the bead is reduced by
204/// there being fewer joints, not by a joint doing anything different.
205///
206/// **No `alpha` field either.** Every arc producer draws through ADR-0056's
207/// additive seam, where the coverage a premultiplied fragment carries is the
208/// stroke's own falloff; the OVER range [`LineRenderer::draw_split`] serves is
209/// a MilkDrop waveform, which is segments. A future over-blended arc adds the
210/// field **last**, for the reason [`SegmentInstance::alpha`] records.
211///
212/// **Field order is shader-location order**, and that is load-bearing for the
213/// same reason it is on [`SegmentInstance`]: `vertex_attr_array!` derives each
214/// attribute's byte offset from the order of the locations, so a field inserted
215/// anywhere but the end silently re-points every attribute after it.
216#[repr(C)]
217#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
218pub struct ArcInstance {
219    /// Centre of curvature (world space).
220    pub centre: [f32; 2],
221    /// Radius (world space). The arc's centreline, not either stroke edge.
222    pub radius: f32,
223    /// Where the span starts, in radians, measured the usual way from `+x`.
224    pub angle_start: f32,
225    /// How far it sweeps, **signed**. `|sweep|` may exceed `PI`, and a full
226    /// circle is one instance at `sweep = TAU`.
227    pub angle_sweep: f32,
228    /// RGB colour (pre-brightness; additive blend sums overlaps).
229    pub color: [f32; 3],
230    /// Half-width in world units — the same quantity, in the same space, as
231    /// [`SegmentInstance::width`]. Named `width` to match its sibling; it is a
232    /// half-width in both.
233    ///
234    /// There is no [`StrokeMetric`] choice here: no arc producer wants the clip
235    /// metric, because `warp_mesh` — the one surface still on it — emits no
236    /// [`ArcInstance`] at all (ADR-0160).
237    pub width: f32,
238}
239
240/// **Which space the stroke is measured in** (ADR-0160) — the half-width, the
241/// join extensions and the direction all three are taken along.
242///
243/// A per-draw-call parameter rather than a per-instance field or a second
244/// pipeline: it rides the segment uniform's `v.w`, a lane that was unused, so it
245/// costs no bytes, no bind-group entry and no new resource (ADR-0058).
246///
247/// Explicit at every call site rather than inferred from which entry point the
248/// caller used, so a producer that picks the wrong one is visible in its own
249/// source rather than implicit in a pipeline.
250#[derive(Clone, Copy, Debug, PartialEq, Eq)]
251pub enum StrokeMetric {
252    /// **World space, the isotropic one**: a world displacement `(dx, dy)` lands
253    /// on `(dx * H/2, dy * H/2)` pixels whichever axis it is on, because
254    /// dividing x by the aspect is exactly what squares the space up. So the
255    /// half-width is a thickness on screen and the extension is a length on
256    /// screen, at every orientation.
257    ///
258    /// What the four line families pass, and what makes a producer's
259    /// world-space interior angle the angle the shader extends along (ADR-0158).
260    World,
261    /// **Clip space, the anisotropic one**: one clip x unit is `aspect` times as
262    /// many pixels as one clip y unit, so a vertical stroke is `aspect` times
263    /// thicker on screen than a horizontal one at the same `width`.
264    ///
265    /// `warp_mesh` is the one surface on it, and it is a **dated deferral, not
266    /// an endorsement** — see the comment at its call site.
267    Clip,
268}
269
270impl StrokeMetric {
271    /// The `v.w` lane's value. `World` is `0.0`, so the uniform a line family
272    /// writes is byte-identical to the one written before this existed.
273    fn lane(self) -> f32 {
274        match self {
275            Self::World => 0.0,
276            Self::Clip => 1.0,
277        }
278    }
279}
280
281#[repr(C)]
282#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
283struct Uniforms {
284    // x: aspect, y: glow multiplier, z: softness (ADR-0124), w: the stroke
285    // metric (ADR-0160) — 0 world, 1 clip
286    v: [f32; 4],
287    // x: zoom, yz: pan, w: unused — the shared ViewTransform (ADR-0018)
288    view: [f32; 4],
289}
290
291/// The across-the-stroke profile, **one definition prepended to both fragment
292/// modules** (ADR-0124).
293///
294/// `u` runs 0 at the stroke edge to 1 at the centreline; `du` is that
295/// coordinate's change per pixel of the render target, from `fwidth` — so the
296/// ramp derived from it is a width in **pixels of the render target** rather
297/// than a fraction of the stroke, and no uniform has to carry a resolution.
298/// `softness` is the ramp's width as a fraction of the half-width:
299///
300/// - **`1.0` reduces the whole expression to `g = u²` term for term** — the
301///   profile every line scene drew before this parameter existed. That equality
302///   is what the golden corpus rests on, and it holds **only because `edge` is
303///   capped at 1.0**: a sub-pixel stroke drives `fwidth` above 1, where an
304///   uncapped `max(softness, edge)` would divide `u` down and *dim* the stroke
305///   instead of sharpening it. `warp_mesh`'s pin
306///   ([`MILKDROP_SOFTNESS`](crate::render::scenes::warp_mesh::MILKDROP_SOFTNESS))
307///   is byte-identical for the same reason — its `THIN` stroke is 1.0–1.35 px of
308///   half-width, exactly that regime.
309/// - `0.5` makes the inner half of the stroke solid and ramps across the outer.
310/// - `0` is a solid stroke whose coverage falls to zero across **one pixel**,
311///   whatever the stroke width, the resolution or the aspect.
312///
313/// Shared rather than written twice: two copies of a profile is a divergence
314/// that compiles, and here it would mean a mandala whose circles and interlace
315/// stop matching.
316///
317/// **`fwidth` exists only in a fragment shader**, so each caller evaluates it at
318/// the fragment's top level and passes the result in — which also keeps the call
319/// out of the arc fragment's non-uniform endpoint branch.
320const PROFILE_WGSL: &str = r#"
321fn stroke_coverage(u: f32, du: f32, softness: f32) -> f32 {
322    let edge = clamp(du, 1e-6, 1.0);
323    let ramp = max(clamp(softness, 0.0, 1.0), edge);
324    let core = clamp(u / ramp, 0.0, 1.0);
325    return core * core;
326}
327"#;
328
329/// The WGSL body, minus the shared profile — [`shader_source`] prepends that.
330const SHADER_BODY: &str = r#"
331struct Uniforms {
332    v: vec4<f32>,
333    view: vec4<f32>,
334}
335
336@group(0) @binding(0) var<uniform> u: Uniforms;
337
338struct VsOut {
339    @builtin(position) pos: vec4<f32>,
340    @location(0) side: f32,
341    @location(1) color: vec3<f32>,
342    @location(2) alpha: f32,
343}
344
345@vertex
346fn vs_main(
347    @builtin(vertex_index) vi: u32,
348    @location(0) a: vec2<f32>,
349    @location(1) b: vec2<f32>,
350    @location(2) color: vec3<f32>,
351    @location(3) width: f32,
352    @location(4) ext_a: f32,
353    @location(5) alpha: f32,
354    @location(6) ext_b: f32,
355) -> VsOut {
356    // (along, side): along runs a->b, side spans -1..1 across the width.
357    var corners = array<vec2<f32>, 6>(
358        vec2<f32>(0.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(0.0, 1.0),
359        vec2<f32>(0.0, 1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
360    );
361    let c = corners[vi];
362    let aspect = max(u.v.x, 0.1);
363    let inv_aspect = 1.0 / aspect;
364
365    // Shared ViewTransform (ADR-0018): zoom about the frame centre, then pan, in
366    // world space before the aspect divide. Endpoints move; stroke width does not.
367    let zoom = u.view.x;
368    let pan = u.view.yz;
369    let a_v = a * zoom + pan;
370    let b_v = b * zoom + pan;
371
372    // The stroke is measured in WORLD space, which is the isotropic one: a
373    // world displacement lands on the same number of pixels whichever axis it
374    // is on, because dividing x by the aspect is exactly what squares the space
375    // up. So the perpendicular offset below is a uniform on-screen thickness
376    // whatever the segment's orientation, and the extension is a length on
377    // screen (ADR-0160). The divide happens once, on the way out.
378    //
379    // `v.w` selects the space, per draw call: `warp_mesh` passes the CLIP
380    // metric, which scales x in on the way IN and leaves `out.pos` alone, so
381    // the offset lands in the anisotropic space. Multiplying by exactly 1.0 is
382    // exact in f32, so each arm carries only its own scale - and at aspect 1.0
383    // `inv_aspect` is exactly 1.0 and the two arms are the same arithmetic.
384    let clip_metric = u.v.w > 0.5;
385    let into_stroke = select(1.0, inv_aspect, clip_metric);
386    let out_of_stroke = select(inv_aspect, 1.0, clip_metric);
387    let a_s = vec2<f32>(a_v.x * into_stroke, a_v.y);
388    let b_s = vec2<f32>(b_v.x * into_stroke, b_v.y);
389    var dir = b_s - a_s;
390    let len = length(dir);
391    if (len > 1e-6) {
392        dir = dir / len;
393    } else {
394        dir = vec2<f32>(1.0, 0.0);
395    }
396    let nrm = vec2<f32>(-dir.y, dir.x);
397
398    // Join (ADR-0158): an end that continues into a neighbouring segment is
399    // pushed past that endpoint along its **own** direction by the length the
400    // producer computed for it. Adjacent quads then overlap across the shared
401    // vertex and the additive falloff fills the wedge the two divergent
402    // perpendiculars would otherwise leave. The producer is the only party that
403    // can compute it, because a segment cannot see its neighbour's direction.
404    // Each end is independent, and a free end is exactly `0.0` — `dir * 0.0` is
405    // exactly zero, so a producer that extends nothing is byte-identical.
406    let a_j = a_s - dir * ext_a;
407    let b_j = b_s + dir * ext_b;
408
409    let base = mix(a_j, b_j, c.x);
410    let pos = base + nrm * c.y * width;
411
412    var out: VsOut;
413    out.pos = vec4<f32>(pos.x * out_of_stroke, pos.y, 0.0, 1.0);
414    out.side = c.y;
415    out.color = color;
416    out.alpha = alpha;
417    return out;
418}
419
420@fragment
421fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
422    // The shared profile (ADR-0124): a solid core of width `softness`, then a
423    // quadratic ramp to the quad edge, floored at one pixel of the render
424    // target. `side` spans -1..1 across the half-width, so `1 - |side|` is the
425    // coordinate the profile takes.
426    //
427    // The derivative is read off `side` and NOT off `|side|`, whose kink at the
428    // centreline would make `fwidth` meaningless on the 2x2 quad that straddles
429    // it. Away from that quad the two are equal in magnitude.
430    let inward = max(0.0, 1.0 - abs(in.side));
431    let g = stroke_coverage(inward, fwidth(in.side), u.v.z);
432    // Premultiplied: colour AND alpha carry the same coverage `g * alpha`, so
433    // the two long edges of the quad - where the across-the-stroke falloff
434    // reaches zero - write nothing at all rather than opaque black (ADR-0056).
435    // Note the glow multiplier scales the LIGHT, not the coverage: a dimmed
436    // stroke still covers its own footprint. See
437    // `gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`.
438    //
439    // `alpha` is 1.0 for every additive producer, so this is byte-identical to
440    // the pre-Plan-0100 fragment for all of them; it is the OVER pipeline's
441    // second range that passes anything else. The colour is NOT divided by it -
442    // an over-blended producer arrives premultiplied already.
443    return vec4<f32>(in.color * g * u.v.y, g * in.alpha);
444}
445"#;
446
447/// The full WGSL: the shared profile prepended to the body.
448///
449/// **The prelude carries no constants.** An endpoint's extension is an `f32`
450/// length the shader multiplies by a direction (ADR-0158), and a float has no
451/// bit assignment that could disagree with a Rust-side numbering — so there is
452/// nothing here to generate and keep in step.
453///
454/// Prepending rather than `format!`-ing the whole body is deliberate: the body
455/// is full of braces and every one would need escaping.
456///
457/// Runs once per [`LineRenderer::new`] (pipeline build, not the hot path).
458fn shader_source() -> String {
459    format!("{PROFILE_WGSL}\n{SHADER_BODY}")
460}
461
462/// The arc pipeline's full WGSL: [`PROFILE_WGSL`] prepended to [`ARC_SHADER`].
463///
464/// The two fragments are separate modules, so **the profile is shared by
465/// construction rather than by convention** - the same reason [`shader_source`]
466/// prepends it instead of the body restating it. Two hand-kept copies of the
467/// expression would compile, render, and give a mandala whose circles and
468/// interlace stop matching.
469///
470/// Runs once per [`LineRenderer::new_with_arcs`] (pipeline build, not the hot
471/// path).
472fn arc_shader_source() -> String {
473    format!(
474        "{PROFILE_WGSL}
475{ARC_SHADER}"
476    )
477}
478
479/// The arc pipeline's WGSL, **a separate shader module** from [`SHADER_BODY`]
480/// and built only when a scene asked for arcs.
481///
482/// Separate rather than two more entry points in the one module, so a
483/// `LineRenderer` without arcs creates exactly the resources it created before
484/// this existed. Appending to the shared module would have changed what every
485/// line scene compiles, and on the WARP software adapter the golden suite
486/// captures on, a changed resource is a changed picture (ADR-0058).
487///
488/// # What the fragment computes, and in which space
489///
490/// The arc is authored in **world space**, which is isotropic on screen: the
491/// vertex shader divides x by the aspect on the way out, so a world circle is a
492/// circle in pixels. The **stroke** is measured there too (ADR-0160), which is
493/// where the segment path measures it — so the two primitives place geometry
494/// and stroke it in one space, and this arc draws the same picture as a densely
495/// sampled polyline of it.
496///
497/// So the signed distance is `length(p - c) - r`, the exact world radial
498/// distance, used as it stands. Outside the angular span it is the distance to
499/// the nearer endpoint, which is a point, so that arm is exact too.
500///
501/// **The aspect is the render target's** (ADR-0037): it arrives in the uniform
502/// `draw` was handed, and there is no internal grid, texture or second size
503/// anywhere in this shader for another one to come from. This family has
504/// shipped that bug three times, which is why the control renders at a
505/// non-16:9 target where a grid-derived aspect and the target's disagree.
506const ARC_SHADER: &str = r#"
507struct Uniforms {
508    v: vec4<f32>,
509    view: vec4<f32>,
510}
511
512@group(0) @binding(0) var<uniform> u: Uniforms;
513
514const TAU: f32 = 6.2831853071795864;
515const QUARTER_TURN: f32 = 1.5707963267948966;
516
517struct VsOut {
518    @builtin(position) pos: vec4<f32>,
519    @location(0) ndc: vec2<f32>,
520    @location(1) @interpolate(flat) centre: vec2<f32>,
521    @location(2) @interpolate(flat) radius: f32,
522    // (lo, hi): the span's two ends in increasing order, so the sweep's sign
523    // stops mattering past this point and the two endpoints are the same two
524    // points either way.
525    @location(3) @interpolate(flat) span: vec2<f32>,
526    @location(4) @interpolate(flat) color: vec3<f32>,
527    @location(5) @interpolate(flat) width: f32,
528}
529
530@vertex
531fn vs_main(
532    @builtin(vertex_index) vi: u32,
533    @location(0) centre: vec2<f32>,
534    @location(1) radius: f32,
535    @location(2) angle_start: f32,
536    @location(3) angle_sweep: f32,
537    @location(4) color: vec3<f32>,
538    @location(5) width: f32,
539) -> VsOut {
540    // The unit square, expanded to the arc's own bounding box below.
541    var corners = array<vec2<f32>, 6>(
542        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
543        vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
544    );
545    let c = corners[vi];
546    let aspect = max(u.v.x, 0.1);
547
548    // Shared ViewTransform (ADR-0018), applied exactly as the segment shader
549    // applies it: in world space, before the aspect divide. A uniform zoom
550    // scales the radius with the centre; stroke width does not move.
551    let centre_v = centre * u.view.x + u.view.yz;
552    let radius_v = radius * u.view.x;
553
554    let a0 = angle_start;
555    let a1 = angle_start + angle_sweep;
556    let lo = min(a0, a1);
557    let hi = max(a0, a1);
558
559    // The centreline's bounding box: the two ends always, plus each axis
560    // extreme the span actually reaches. A full turn reaches all four and the
561    // box is the whole circle; a short span gets a box barely bigger than its
562    // own chord, which is what keeps the shaded area near the stroke's.
563    let end0 = vec2<f32>(cos(a0), sin(a0));
564    let end1 = vec2<f32>(cos(a1), sin(a1));
565    var lo_dir = min(end0, end1);
566    var hi_dir = max(end0, end1);
567    for (var k = 0u; k < 4u; k = k + 1u) {
568        let ang = f32(k) * QUARTER_TURN;
569        // The smallest representative of `ang` modulo TAU at or above `lo`.
570        let t = ang + TAU * ceil((lo - ang) / TAU);
571        if (t <= hi) {
572            let d = vec2<f32>(cos(ang), sin(ang));
573            lo_dir = min(lo_dir, d);
574            hi_dir = max(hi_dir, d);
575        }
576    }
577
578    // The stroke reaches `width` in WORLD units on every side (ADR-0160), so
579    // the pad is isotropic in the space this box is built in. The 2 % is slack
580    // for the distance being a first-order expression of a curved level set -
581    // see the fragment.
582    //
583    // The pad has to be at least what the stroke reaches on each axis or the
584    // box clips its own edge, and in world units that is `width` on both -
585    // scaling either axis by the aspect under-pads it on one side of 1.0.
586    let pad = vec2<f32>(width, width) * 1.02;
587    let lo_w = centre_v + radius_v * lo_dir - pad;
588    let hi_w = centre_v + radius_v * hi_dir + pad;
589    let p = mix(lo_w, hi_w, c);
590    let ndc = vec2<f32>(p.x / aspect, p.y);
591
592    var out: VsOut;
593    out.pos = vec4<f32>(ndc, 0.0, 1.0);
594    out.ndc = ndc;
595    out.centre = centre_v;
596    out.radius = radius_v;
597    out.span = vec2<f32>(lo, hi);
598    out.color = color;
599    out.width = width;
600    return out;
601}
602
603@fragment
604fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
605    let aspect = max(u.v.x, 0.1);
606    // Back into the isotropic world space the arc is authored in.
607    let p = vec2<f32>(in.ndc.x * aspect, in.ndc.y);
608    let q = p - in.centre;
609    let len = length(q);
610
611    // Inside the span, or past one of its ends? The same modulo reduction the
612    // vertex shader uses, so an arc that wraps the branch cut of atan2 is not
613    // a special case.
614    let theta = atan2(q.y, q.x);
615    let t = theta + TAU * ceil((in.span.x - theta) / TAU);
616
617    // SIGNED across the stroke, and that is load-bearing: the profile below
618    // takes `fwidth` of this, and `fwidth` of an ABSOLUTE distance is garbage
619    // on the 2x2 quad that straddles the centreline - the kink makes the finite
620    // difference near zero exactly where the stroke is brightest. The segment
621    // fragment reads `side` and not `|side|` for the same reason.
622    var sd: f32;
623    if (t <= in.span.y) {
624        // The exact world radial distance, and the stroke is measured in world
625        // units too (ADR-0160) - so it is used as it stands, with nothing
626        // converted and nothing to keep in step with the segment path.
627        sd = len - in.radius;
628    } else {
629        // Past an end: the world distance to the nearer endpoint. A point has
630        // an exact distance, so this arm approximates nothing.
631        let e0 = in.centre + in.radius * vec2<f32>(cos(in.span.x), sin(in.span.x));
632        let e1 = in.centre + in.radius * vec2<f32>(cos(in.span.y), sin(in.span.y));
633        sd = min(length(p - e0), length(p - e1));
634    }
635
636    // The segment path's profile on the arc's own distance - literally the
637    // same `stroke_coverage`, prepended to both modules (ADR-0124), so the two
638    // fragments cannot draw different strokes on the same figure.
639    //
640    // `sd` is a world distance and `width` is flat-interpolated, so the
641    // normalized coordinate is `sd / width` and its screen derivative is
642    // `fwidth(sd) / width`. One pixel of the render target is `2 / H` world
643    // units on BOTH axes - `2 * aspect / W` is that same number - so the ramp
644    // is one pixel of the target at every orientation and on any frame. Taken
645    // on the signed distance, per its declaration.
646    //
647    // Premultiplied, so colour and alpha carry the same coverage and the quad
648    // outside the stroke writes nothing at all rather than opaque black
649    // (ADR-0056). The glow multiplier scales the LIGHT, not the coverage,
650    // exactly as it does for a segment.
651    //
652    // DIVIDED, not multiplied by a reciprocal: `x / w` and `x * (1 / w)` differ
653    // in the last ulp, and byte-identity at the default is what the golden
654    // corpus rests on.
655    let width = max(in.width, 1e-8);
656    let g = stroke_coverage(max(0.0, 1.0 - abs(sd) / width), fwidth(sd) / width, u.v.z);
657    return vec4<f32>(in.color * g * u.v.y, g);
658}
659"#;
660
661/// The share of the segment `a -> b` lying inside `[-aspect, aspect] x [-1, 1]`,
662/// as a fraction of its own length: Liang-Barsky against the four edges.
663///
664/// **Exactly `1.0`** when the segment is untouched by any edge — the two
665/// parameters start at `0.0` and `1.0` and no edge moves them — which is what
666/// lets an unclipped figure sum to exactly its own total. **Exactly `0.0`** when
667/// the segment is wholly outside.
668///
669/// A parametric clip rather than an endpoint test on purpose: the case a naive
670/// "are both ends outside" check gets wrong is a segment whose ends are both out
671/// but which crosses the frame between them, and that is precisely what a badly
672/// over-scaled figure is made of.
673fn in_frame_fraction(a: [f32; 2], b: [f32; 2], aspect: f32) -> f32 {
674    let [ax, ay] = a;
675    let [bx, by] = b;
676    let (dx, dy) = (bx - ax, by - ay);
677    let (mut t0, mut t1) = (0.0f32, 1.0f32);
678    // (direction, distance) per edge: left, right, bottom, top.
679    for (p, q) in [
680        (-dx, ax + aspect),
681        (dx, aspect - ax),
682        (-dy, ay + 1.0),
683        (dy, 1.0 - ay),
684    ] {
685        if p == 0.0 {
686            // Parallel to this edge: wholly out if it starts outside it.
687            if q < 0.0 {
688                return 0.0;
689            }
690            continue;
691        }
692        let r = q / p;
693        if p < 0.0 {
694            if r > t1 {
695                return 0.0;
696            }
697            if r > t0 {
698                t0 = r;
699            }
700        } else {
701            if r < t0 {
702                return 0.0;
703            }
704            if r < t1 {
705                t1 = r;
706            }
707        }
708    }
709    t1 - t0
710}
711
712/// Sub-arcs one [`ArcInstance`] is measured in — a **power of two**, which is
713/// load-bearing.
714///
715/// Each sub-arc is clipped by its own chord and weighted `1 / ARC_STEPS`, so an
716/// arc wholly inside the frame accumulates that weight exactly `ARC_STEPS`
717/// times. At a power of two the weight is exact in binary and the sum is
718/// **exactly 1.0**, which is what keeps an unclipped figure measuring exactly
719/// its own length — the property `in_frame_fraction` is written to preserve for
720/// segments.
721///
722/// Sixty-four puts 5.6 degrees in a sub-arc of a full circle, whose chord
723/// departs from it by `r * (1 - cos(2.8 deg))`, about `0.0012 * r`. The error is
724/// a function of the angle per step alone, so it does not grow with radius.
725const ARC_STEPS: usize = 64;
726
727/// The world-space length of `arc` and the share of it inside
728/// `[-aspect, aspect] x [-1, 1]`, under the same view transform the vertex
729/// shader applies.
730///
731/// Measured as [`ARC_STEPS`] sub-arcs clipped by their own chords rather than by
732/// solving the circle against the four edges: the closed form needs the
733/// intersection of four half-planes with a circle, which is up to four disjoint
734/// angular components and considerably more code than the property is worth.
735/// Both sums are taken from the arc's **own** length (`|sweep| * radius`), never
736/// from the chords', so the sub-chord sampling changes only *where* the arc is
737/// judged to be, never how long it is.
738fn measure_arc(arc: &ArcInstance, aspect: f32, xform: super::ViewTransform) -> (f32, f32) {
739    let [pan_x, pan_y] = xform.pan;
740    let centre = [
741        arc.centre[0] * xform.zoom + pan_x,
742        arc.centre[1] * xform.zoom + pan_y,
743    ];
744    let radius = arc.radius * xform.zoom;
745    let len = (radius * arc.angle_sweep).abs();
746    if len <= 0.0 || !len.is_finite() {
747        return (0.0, 0.0); // a degenerate (or non-finite) arc measures nothing
748    }
749    let step = 1.0 / ARC_STEPS as f32;
750    let at = |k: usize| {
751        let t = arc.angle_start + arc.angle_sweep * k as f32 * step;
752        [centre[0] + radius * t.cos(), centre[1] + radius * t.sin()]
753    };
754    let mut inside = 0.0;
755    for k in 0..ARC_STEPS {
756        inside += in_frame_fraction(at(k), at(k + 1), aspect) * step;
757    }
758    (len, len * inside)
759}
760
761/// Measure `segments` against the frame — the diagnostic's whole computation.
762///
763/// **The aspect is a parameter, and it is the only source of one in here**
764/// (ADR-0037): this is a free function over the endpoints, so there is no
765/// internal grid, no texture and no `self` for a second aspect to come from.
766/// Its caller hands it the value `draw` was handed, which is the **render
767/// target's**.
768///
769/// The view transform is applied first, exactly as the vertex shader applies it
770/// (`a * zoom + pan`, before the aspect divide), because a figure pushed off the
771/// frame by `zoom` or `pan_y` has overshot just as surely as one scaled off it.
772fn measure_extent(
773    segments: &[SegmentInstance],
774    arcs: &[ArcInstance],
775    aspect: f32,
776    xform: super::ViewTransform,
777) -> DrawExtent {
778    let mut extent = DrawExtent::default();
779    let [pan_x, pan_y] = xform.pan;
780    for segment in segments {
781        let [ax, ay] = segment.a;
782        let [bx, by] = segment.b;
783        let a = [ax * xform.zoom + pan_x, ay * xform.zoom + pan_y];
784        let b = [bx * xform.zoom + pan_x, by * xform.zoom + pan_y];
785        let ([ax, ay], [bx, by]) = (a, b);
786        let (dx, dy) = (bx - ax, by - ay);
787        let len = (dx * dx + dy * dy).sqrt();
788        if len <= 0.0 || !len.is_finite() {
789            continue; // a degenerate (or non-finite) segment measures nothing
790        }
791        extent.total_len += len;
792        // `len * 1.0` is `len` exactly, so an unclipped figure adds the same
793        // value to both sums and the fraction is exactly 1.0.
794        extent.in_frame_len += len * in_frame_fraction(a, b, aspect);
795    }
796    // Arcs, into the same two sums: the fraction is over everything drawn, and
797    // a batch of both kinds has one denominator.
798    for arc in arcs {
799        let (len, in_frame) = measure_arc(arc, aspect, xform);
800        extent.total_len += len;
801        extent.in_frame_len += in_frame;
802    }
803    extent
804}
805
806/// Draws segment buffers as thick glowing quads. Owns its pipeline, a
807/// fixed-capacity instance buffer, and the aspect/glow uniform.
808pub struct LineRenderer {
809    pipeline: wgpu::RenderPipeline,
810    /// The same pipeline with the light composited **over** rather than added —
811    /// see [`LineRenderer::draw_split`]. It shares the shader, the layout and the
812    /// bind group with [`pipeline`](Self::pipeline), so ADR-0058 has nothing to
813    /// separate: there is one layout, not two that happen to match.
814    ///
815    /// **`None` unless the scene asked for it** ([`LineRenderer::new_split`]),
816    /// and that is not a micro-optimization. Building a pipeline the scene never
817    /// binds still allocates on the device, and on the WARP software adapter a
818    /// changed allocation order changes what a later pass resolves to — the
819    /// hazard `core/tests/composite.rs`'s header records and the golden suite
820    /// captures on. Building this for the nine line scenes that do not use it
821    /// moved five composite baselines while changing nothing a driver would
822    /// render differently.
823    over_pipeline: Option<wgpu::RenderPipeline>,
824    /// The [`ArcInstance`] pipeline, drawn in the same additive pass from its
825    /// own buffer — [`ARC_SHADER`] and ADR-0098.
826    ///
827    /// **`None` unless the scene asked for it**
828    /// ([`LineRenderer::new_with_arcs`]), for the reason
829    /// [`over_pipeline`](Self::over_pipeline) records and with the same
830    /// evidence behind it: building a pipeline nobody binds still allocates on
831    /// the device, and on WARP a changed allocation order changes what a later
832    /// pass resolves to. It **shares the bind-group layout, the bind group and
833    /// the pipeline layout** with the segment pipelines — one uniform, one
834    /// layout, so ADR-0058 has nothing new to separate. Only the vertex layout
835    /// and the shader module differ.
836    arc_pipeline: Option<wgpu::RenderPipeline>,
837    /// [`arc_pipeline`](Self::arc_pipeline) with the light composited **over**
838    /// rather than added — the arc half of the opacity-preserving seam
839    /// (ADR-0138), built exactly when [`over_pipeline`](Self::over_pipeline) and
840    /// the arc pipeline both are.
841    ///
842    /// Without it, [`draw_opaque`](Self::draw_opaque) on a scene whose motifs are
843    /// arcs would lay opaque strokes and additive circles into one picture, and
844    /// the limited-ink guarantee would hold for half of what the scene drew.
845    arc_over_pipeline: Option<wgpu::RenderPipeline>,
846    instances: wgpu::Buffer,
847    /// The arc instance buffer, `Some` exactly when
848    /// [`arc_pipeline`](Self::arc_pipeline) is.
849    arcs: Option<wgpu::Buffer>,
850    uniforms: wgpu::Buffer,
851    bind_group: wgpu::BindGroup,
852    /// Maximum segments the instance buffer holds; extra are dropped by `draw`.
853    capacity: usize,
854    /// Maximum arcs the arc buffer holds; `0` when there is no arc pipeline.
855    arc_capacity: usize,
856}
857
858impl LineRenderer {
859    /// Build the pipeline and a `capacity`-segment instance buffer on `device`.
860    /// `label` names this instance's GPU resources; it must be **unique per
861    /// LineRenderer** — two line scenes coexist (parametric + generator), and
862    /// distinct labels keep their pipelines/buffers unambiguous in tooling and
863    /// captures.
864    pub fn new(
865        device: &wgpu::Device,
866        surface_format: wgpu::TextureFormat,
867        capacity: usize,
868        label: &str,
869    ) -> Self {
870        Self::build(device, surface_format, capacity, label, false, 0)
871    }
872
873    /// [`new`](Self::new), plus the second pipeline
874    /// [`draw_split`](Self::draw_split) needs. Only a scene that actually splits
875    /// its batch by blend mode should call this — see
876    /// `over_pipeline` for why building it unconditionally
877    /// is not free.
878    pub fn new_split(
879        device: &wgpu::Device,
880        surface_format: wgpu::TextureFormat,
881        capacity: usize,
882        label: &str,
883    ) -> Self {
884        Self::build(device, surface_format, capacity, label, true, 0)
885    }
886
887    /// [`new_with_arcs`](Self::new_with_arcs), plus the OVER pipelines
888    /// [`draw_opaque`](Self::draw_opaque) needs — the constructor the shared line
889    /// renderer takes, because any of the four line systems may ask for the
890    /// opacity-preserving seam (ADR-0138).
891    ///
892    /// The pipelines are built here rather than on the first preset that asks,
893    /// deliberately: building a GPU resource mid-run changes what a later pass
894    /// resolves to on the DX12 software adapter, which would make the seam's
895    /// arrival visible in scenes that never selected it.
896    pub fn new_split_with_arcs(
897        device: &wgpu::Device,
898        surface_format: wgpu::TextureFormat,
899        capacity: usize,
900        arc_capacity: usize,
901        label: &str,
902    ) -> Self {
903        Self::build(device, surface_format, capacity, label, true, arc_capacity)
904    }
905
906    /// [`new`](Self::new), plus the arc pipeline and an `arc_capacity`-instance
907    /// arc buffer ([`ArcInstance`], ADR-0098).
908    ///
909    /// Only a scene that actually draws arcs should call this — see
910    /// `arc_pipeline` for why building it unconditionally
911    /// is not free. `arc_capacity` is its own budget rather than a share of
912    /// `capacity`: an arc replaces many segments, so the two counts are not the
913    /// same order and sizing one from the other would waste most of it.
914    pub fn new_with_arcs(
915        device: &wgpu::Device,
916        surface_format: wgpu::TextureFormat,
917        capacity: usize,
918        arc_capacity: usize,
919        label: &str,
920    ) -> Self {
921        Self::build(device, surface_format, capacity, label, false, arc_capacity)
922    }
923
924    fn build(
925        device: &wgpu::Device,
926        surface_format: wgpu::TextureFormat,
927        capacity: usize,
928        label: &str,
929        split: bool,
930        arc_capacity: usize,
931    ) -> Self {
932        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
933            label: Some(&format!("{label}-shader")),
934            source: wgpu::ShaderSource::Wgsl(shader_source().into()),
935        });
936        let instances = device.create_buffer(&wgpu::BufferDescriptor {
937            label: Some(&format!("{label}-instances")),
938            size: (capacity * std::mem::size_of::<SegmentInstance>()) as u64,
939            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
940            mapped_at_creation: false,
941        });
942        let uniforms = gpu::uniform_buffer(
943            device,
944            &format!("{label}-uniforms"),
945            std::mem::size_of::<Uniforms>(),
946        );
947        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
948            label: Some(&format!("{label}-bind-layout")),
949            entries: &[wgpu::BindGroupLayoutEntry {
950                binding: 0,
951                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
952                ty: wgpu::BindingType::Buffer {
953                    ty: wgpu::BufferBindingType::Uniform,
954                    has_dynamic_offset: false,
955                    min_binding_size: None,
956                },
957                count: None,
958            }],
959        });
960        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
961            label: Some(&format!("{label}-bind-group")),
962            layout: &bind_layout,
963            entries: &[wgpu::BindGroupEntry {
964                binding: 0,
965                resource: uniforms.as_entire_binding(),
966            }],
967        });
968        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
969            label: Some(&format!("{label}-pipeline-layout")),
970            bind_group_layouts: &[Some(&bind_layout)],
971            immediate_size: 0,
972        });
973        // The two pipelines differ in exactly one field — the blend state — so
974        // they are built from one closure. Anything else that diverges between
975        // them would be a bug that renders as a difference between two ranges of
976        // the same batch, which is close to unreadable in a capture.
977        let make = |blend: wgpu::BlendState, suffix: &str| {
978            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
979                label: Some(&format!("{label}-pipeline{suffix}")),
980                layout: Some(&pipeline_layout),
981                vertex: wgpu::VertexState {
982                    module: &shader,
983                    entry_point: Some("vs_main"),
984                    compilation_options: Default::default(),
985                    buffers: &[Some(wgpu::VertexBufferLayout {
986                        array_stride: std::mem::size_of::<SegmentInstance>() as u64,
987                        step_mode: wgpu::VertexStepMode::Instance,
988                        attributes: &wgpu::vertex_attr_array![
989                            0 => Float32x2,
990                            1 => Float32x2,
991                            2 => Float32x3,
992                            3 => Float32,
993                            4 => Float32,
994                            5 => Float32,
995                            6 => Float32,
996                        ],
997                    })],
998                },
999                fragment: Some(wgpu::FragmentState {
1000                    module: &shader,
1001                    entry_point: Some("fs_main"),
1002                    compilation_options: Default::default(),
1003                    targets: &[Some(wgpu::ColorTargetState {
1004                        format: surface_format,
1005                        blend: Some(blend),
1006                        write_mask: wgpu::ColorWrites::ALL,
1007                    })],
1008                }),
1009                primitive: wgpu::PrimitiveState::default(),
1010                depth_stencil: None,
1011                multisample: wgpu::MultisampleState::default(),
1012                multiview_mask: None,
1013                cache: None,
1014            })
1015        };
1016        // Additive light, saturating coverage (ADR-0056) — the same constant the
1017        // swarm's sprite pipeline takes, so the two draw seams cannot drift
1018        // apart. This is what every line scene draws through.
1019        let pipeline = make(crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE, "");
1020        // Premultiplied OVER, for a producer whose source blend *replaces* rather
1021        // than accumulates — see `draw_split`. The fragment is premultiplied
1022        // either way, which is why one shader serves both.
1023        let over_pipeline =
1024            split.then(|| make(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, "-over"));
1025
1026        // The arc pipeline (ADR-0098). Its own shader module and vertex layout,
1027        // the *same* bind layout, bind group and pipeline layout as above, and
1028        // the same additive blend — so an arc and a segment emit into one pass
1029        // through one uniform and cannot drift apart on aspect, glow or the
1030        // view transform.
1031        let arcs = (arc_capacity > 0).then(|| {
1032            device.create_buffer(&wgpu::BufferDescriptor {
1033                label: Some(&format!("{label}-arc-instances")),
1034                size: (arc_capacity * std::mem::size_of::<ArcInstance>()) as u64,
1035                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1036                mapped_at_creation: false,
1037            })
1038        });
1039        let arc_shader = arcs.is_some().then(|| {
1040            device.create_shader_module(wgpu::ShaderModuleDescriptor {
1041                label: Some(&format!("{label}-arc-shader")),
1042                source: wgpu::ShaderSource::Wgsl(arc_shader_source().into()),
1043            })
1044        });
1045        // The arc pair is built from one closure for the reason the segment pair
1046        // is: they differ in the blend state and in nothing else, and any other
1047        // divergence would render as a difference between two batches of the same
1048        // figure.
1049        let make_arc = |blend: wgpu::BlendState, suffix: &str| {
1050            let arc_shader = arc_shader.as_ref()?;
1051            Some(
1052                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1053                    label: Some(&format!("{label}-arc-pipeline{suffix}")),
1054                    layout: Some(&pipeline_layout),
1055                    vertex: wgpu::VertexState {
1056                        module: arc_shader,
1057                        entry_point: Some("vs_main"),
1058                        compilation_options: Default::default(),
1059                        buffers: &[Some(wgpu::VertexBufferLayout {
1060                            array_stride: std::mem::size_of::<ArcInstance>() as u64,
1061                            step_mode: wgpu::VertexStepMode::Instance,
1062                            attributes: &wgpu::vertex_attr_array![
1063                                0 => Float32x2,
1064                                1 => Float32,
1065                                2 => Float32,
1066                                3 => Float32,
1067                                4 => Float32x3,
1068                                5 => Float32,
1069                            ],
1070                        })],
1071                    },
1072                    fragment: Some(wgpu::FragmentState {
1073                        module: arc_shader,
1074                        entry_point: Some("fs_main"),
1075                        compilation_options: Default::default(),
1076                        targets: &[Some(wgpu::ColorTargetState {
1077                            format: surface_format,
1078                            blend: Some(blend),
1079                            write_mask: wgpu::ColorWrites::ALL,
1080                        })],
1081                    }),
1082                    primitive: wgpu::PrimitiveState::default(),
1083                    depth_stencil: None,
1084                    multisample: wgpu::MultisampleState::default(),
1085                    multiview_mask: None,
1086                    cache: None,
1087                }),
1088            )
1089        };
1090        let arc_pipeline = make_arc(crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE, "");
1091        let arc_over_pipeline = split
1092            .then(|| make_arc(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, "-over"))
1093            .flatten();
1094
1095        // Zero unless the pipeline exists, so `draw_all` needs no second test:
1096        // a renderer without arcs clamps every arc batch to nothing.
1097        let arc_capacity = if arc_pipeline.is_some() {
1098            arc_capacity
1099        } else {
1100            0
1101        };
1102
1103        Self {
1104            pipeline,
1105            over_pipeline,
1106            arc_pipeline,
1107            arc_over_pipeline,
1108            instances,
1109            arcs,
1110            uniforms,
1111            bind_group,
1112            capacity,
1113            arc_capacity,
1114        }
1115    }
1116
1117    /// Segments the instance buffer can hold — the scene clamps its geometry to
1118    /// this and surfaces any drop at load (ADR-0007 cap must never be silent).
1119    pub fn capacity(&self) -> usize {
1120        self.capacity
1121    }
1122
1123    /// Arcs the arc buffer can hold — `0` when this renderer was not built with
1124    /// [`new_with_arcs`](Self::new_with_arcs), in which case
1125    /// [`draw_arcs`](Self::draw_arcs) draws none.
1126    pub fn arc_capacity(&self) -> usize {
1127        self.arc_capacity
1128    }
1129
1130    /// Draw `segments` as thick glowing quads at the given `aspect` and `glow`
1131    /// multiplier, under the shared `xform` camera transform (zoom/pan, ADR-0018),
1132    /// **loading** over the engine backdrop rather than clearing (Plan 0018 Phase
1133    /// 3 — the background pass owns the clear). Segments beyond `capacity` are
1134    /// dropped defensively (the scene is responsible for capping at load).
1135    ///
1136    /// `softness` is the across-the-stroke profile (`PROFILE_WGSL`, ADR-0124):
1137    /// `1.0` is the pre-Plan-0114 quadratic falloff, `0` a solid stroke with a
1138    /// one-pixel edge. **There is no default here** — one uniform serves every
1139    /// entry point, so each caller names the constant it answers to:
1140    /// [`lines::DEFAULT_SOFTNESS`](super::DEFAULT_SOFTNESS) for the four line
1141    /// families, [`warp_mesh::MILKDROP_SOFTNESS`](crate::render::scenes::warp_mesh::MILKDROP_SOFTNESS)
1142    /// for the MilkDrop surface.
1143    ///
1144    /// `metric` is the space the stroke is measured in (ADR-0160) and has no
1145    /// default either, for the same reason: [`StrokeMetric::World`] for the four
1146    /// line families, [`StrokeMetric::Clip`] for `warp_mesh`.
1147    #[allow(
1148        clippy::too_many_arguments,
1149        reason = "distinct GPU handles plus the per-frame draw parameters (aspect, glow, \
1150                  softness, stroke metric, view transform); bundling them would only shuffle \
1151                  the same values behind a one-use struct"
1152    )]
1153    pub fn draw(
1154        &mut self,
1155        queue: &wgpu::Queue,
1156        encoder: &mut wgpu::CommandEncoder,
1157        view: &wgpu::TextureView,
1158        aspect: f32,
1159        glow: f32,
1160        softness: f32,
1161        metric: StrokeMetric,
1162        xform: super::ViewTransform,
1163        segments: &[SegmentInstance],
1164    ) {
1165        self.draw_split(
1166            queue,
1167            encoder,
1168            view,
1169            aspect,
1170            glow,
1171            softness,
1172            metric,
1173            xform,
1174            segments,
1175            segments.len(),
1176        );
1177    }
1178
1179    /// [`draw`](Self::draw), with the batch split by blend mode: the first
1180    /// `n_additive` segments are added (ADR-0056's seam, what every line scene
1181    /// uses), and the rest are composited **over** using each segment's own
1182    /// [`alpha`](SegmentInstance::alpha).
1183    ///
1184    /// # Why a split range rather than two calls
1185    ///
1186    /// One instance buffer, one upload, one render pass, two `draw` calls that
1187    /// differ only in the pipeline bound. Two calls would mean two passes over
1188    /// the same attachment and a second buffer, and the *order* would stop being
1189    /// expressible: an over-blended stroke has to land on top of the additive
1190    /// light it covers, which a single ordered batch gives for free.
1191    ///
1192    /// The caller partitions — it is the only thing that knows which producer
1193    /// each segment came from. Passing `n_additive >= segments.len()` is exactly
1194    /// [`draw`](Self::draw).
1195    #[allow(
1196        clippy::too_many_arguments,
1197        reason = "see `draw` — this is that signature plus the partition index"
1198    )]
1199    pub fn draw_split(
1200        &mut self,
1201        queue: &wgpu::Queue,
1202        encoder: &mut wgpu::CommandEncoder,
1203        view: &wgpu::TextureView,
1204        aspect: f32,
1205        glow: f32,
1206        softness: f32,
1207        metric: StrokeMetric,
1208        xform: super::ViewTransform,
1209        segments: &[SegmentInstance],
1210        n_additive: usize,
1211    ) {
1212        self.draw_all(
1213            queue,
1214            encoder,
1215            view,
1216            aspect,
1217            glow,
1218            softness,
1219            metric,
1220            xform,
1221            segments,
1222            n_additive,
1223            &[],
1224            false,
1225        );
1226    }
1227
1228    /// [`draw`](Self::draw), plus `arcs` — [`ArcInstance`]s stroked by the
1229    /// per-pixel distance field (ADR-0098) **in the same additive pass**, from
1230    /// the same uniform, after the segments.
1231    ///
1232    /// One pass rather than two for the reason [`draw_split`](Self::draw_split)
1233    /// gives: a second pass would mean a second load of the attachment and a
1234    /// second set of uniforms to keep in step. Additive blending is
1235    /// order-independent, so "after the segments" is a statement about the
1236    /// command stream and not about the picture.
1237    ///
1238    /// Arcs beyond [`arc_capacity`](Self::arc_capacity) are dropped defensively,
1239    /// exactly as segments beyond `capacity` are; a renderer built without the
1240    /// arc pipeline has a capacity of zero and draws none.
1241    #[allow(
1242        clippy::too_many_arguments,
1243        reason = "see `draw` — this is that signature plus the arc batch"
1244    )]
1245    pub fn draw_arcs(
1246        &mut self,
1247        queue: &wgpu::Queue,
1248        encoder: &mut wgpu::CommandEncoder,
1249        view: &wgpu::TextureView,
1250        aspect: f32,
1251        glow: f32,
1252        softness: f32,
1253        metric: StrokeMetric,
1254        xform: super::ViewTransform,
1255        segments: &[SegmentInstance],
1256        arcs: &[ArcInstance],
1257    ) {
1258        self.draw_all(
1259            queue,
1260            encoder,
1261            view,
1262            aspect,
1263            glow,
1264            softness,
1265            metric,
1266            xform,
1267            segments,
1268            segments.len(),
1269            arcs,
1270            false,
1271        );
1272    }
1273
1274    /// [`draw_arcs`](Self::draw_arcs), with the **whole batch composited over**
1275    /// rather than added — the opacity-preserving seam of ADR-0138's limited-ink
1276    /// class, reached by the four line systems through `stroke_blend`.
1277    ///
1278    /// Segments and arcs both take the OVER pipeline, so a scene whose figure is
1279    /// part strokes and part circles draws one substance rather than two. Order
1280    /// inside the batch becomes the order on screen: a later stroke replaces the
1281    /// interior of what it covers instead of summing with it, which is the whole
1282    /// property. Pass an empty `arcs` from a scene that draws none.
1283    ///
1284    /// A renderer built without the OVER pipelines falls back to the additive
1285    /// ones, exactly as [`draw_split`](Self::draw_split) does — the wrong blend
1286    /// rather than a panic, and unreachable in the shipped path, where the shared
1287    /// line renderer is built with them.
1288    #[allow(
1289        clippy::too_many_arguments,
1290        reason = "see `draw` — this is that signature plus the arc batch"
1291    )]
1292    pub fn draw_opaque(
1293        &mut self,
1294        queue: &wgpu::Queue,
1295        encoder: &mut wgpu::CommandEncoder,
1296        view: &wgpu::TextureView,
1297        aspect: f32,
1298        glow: f32,
1299        softness: f32,
1300        metric: StrokeMetric,
1301        xform: super::ViewTransform,
1302        segments: &[SegmentInstance],
1303        arcs: &[ArcInstance],
1304    ) {
1305        self.draw_all(
1306            queue, encoder, view, aspect, glow, softness, metric, xform, segments, 0, arcs, true,
1307        );
1308    }
1309
1310    /// The one body behind [`draw`](Self::draw),
1311    /// [`draw_split`](Self::draw_split), [`draw_arcs`](Self::draw_arcs) and
1312    /// [`draw_opaque`](Self::draw_opaque): one buffer upload per kind, one
1313    /// uniform write, one render pass.
1314    #[allow(
1315        clippy::too_many_arguments,
1316        reason = "see `draw` — this is that signature plus the partition index, \
1317                  the arc batch and the arc batch's own seam"
1318    )]
1319    fn draw_all(
1320        &mut self,
1321        queue: &wgpu::Queue,
1322        encoder: &mut wgpu::CommandEncoder,
1323        view: &wgpu::TextureView,
1324        aspect: f32,
1325        glow: f32,
1326        softness: f32,
1327        metric: StrokeMetric,
1328        xform: super::ViewTransform,
1329        segments: &[SegmentInstance],
1330        n_additive: usize,
1331        arcs: &[ArcInstance],
1332        arcs_over: bool,
1333    ) {
1334        let count = segments.len().min(self.capacity);
1335        let drawn = segments.get(..count).unwrap_or(&[]);
1336        let arc_count = arcs.len().min(self.arc_capacity);
1337        let arcs_drawn = arcs.get(..arc_count).unwrap_or(&[]);
1338
1339        // The in-frame geometry diagnostic (Plan 0069, ADR-0083). Off in the
1340        // shipped path, and it reads `drawn` — the segments that actually reach
1341        // the instance buffer — without touching a GPU resource, so "off" is a
1342        // `Cell::get` and "on" changes nothing about the picture. The aspect it
1343        // measures against is the one this call was handed: the render target's
1344        // (ADR-0037), under the same `max(0.1)` clamp the uniform and the shader
1345        // apply, so the rectangle is the one the frame actually shows.
1346        if metrics::extent_diagnostic_on() {
1347            let extent = measure_extent(drawn, arcs_drawn, aspect.max(0.1), xform);
1348            metrics::record_draw_extent(extent);
1349        }
1350
1351        if !drawn.is_empty() {
1352            queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(drawn));
1353        }
1354        if let (Some(buffer), false) = (&self.arcs, arcs_drawn.is_empty()) {
1355            queue.write_buffer(buffer, 0, bytemuck::cast_slice(arcs_drawn));
1356        }
1357        queue.write_buffer(
1358            &self.uniforms,
1359            0,
1360            bytemuck::bytes_of(&Uniforms {
1361                v: [aspect.max(0.1), glow, softness, metric.lane()],
1362                view: [xform.zoom, xform.pan[0], xform.pan[1], 0.0],
1363            }),
1364        );
1365
1366        // Load over the engine backdrop (ADR-0018); additive strokes bloom over
1367        // it and the empty space reveals it.
1368        let mut pass = gpu::color_pass(encoder, "line-pass", view, wgpu::LoadOp::Load);
1369        if drawn.is_empty() && arcs_drawn.is_empty() {
1370            return; // nothing to stroke; the backdrop shows through
1371        }
1372        // Clamped to what actually reached the buffer: `n_additive` counts into
1373        // `segments`, which may be longer than `drawn`.
1374        let split = n_additive.min(drawn.len()) as u32;
1375        pass.set_bind_group(0, &self.bind_group, &[]);
1376        if split > 0 {
1377            pass.set_pipeline(&self.pipeline);
1378            pass.set_vertex_buffer(0, self.instances.slice(..));
1379            pass.draw(0..6, 0..split);
1380        }
1381        // Between the two segment ranges rather than after both: an arc is
1382        // additive light, and the OVER range has to land on top of the light it
1383        // covers. No scene draws both today; the ordering is here so that the
1384        // day one does, it is already right.
1385        let arc_pipeline = if arcs_over {
1386            self.arc_over_pipeline
1387                .as_ref()
1388                .or(self.arc_pipeline.as_ref())
1389        } else {
1390            self.arc_pipeline.as_ref()
1391        };
1392        if let (Some(pipeline), Some(buffer), false) =
1393            (arc_pipeline, &self.arcs, arcs_drawn.is_empty())
1394        {
1395            pass.set_pipeline(pipeline);
1396            pass.set_vertex_buffer(0, buffer.slice(..));
1397            pass.draw(0..6, 0..arcs_drawn.len() as u32);
1398        }
1399        if (drawn.len() as u32) > split {
1400            // Falls back to the additive pipeline when the scene did not ask for
1401            // the second one. That is the wrong blend rather than a panic, and it
1402            // is unreachable in practice: the only caller that passes a partition
1403            // is the one that built with `new_split`.
1404            pass.set_pipeline(self.over_pipeline.as_ref().unwrap_or(&self.pipeline));
1405            pass.set_vertex_buffer(0, self.instances.slice(..));
1406            pass.draw(0..6, split..drawn.len() as u32);
1407        }
1408    }
1409}
1410
1411#[cfg(test)]
1412mod tests;