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/// One segment in **3D**, drawn through the shared camera (ADR-0257): two world
241/// endpoints, an RGB colour, a width in pixels at the camera's reference depth
242/// and an alpha.
243///
244/// Drawn by the `seg3d` pipeline, which [`LineRenderer::new_3d`] builds and
245/// [`LineRenderer::draw_3d`] selects — a separate pipeline and instance buffer
246/// rather than a branch in the 2D one, so every 2D line scene keeps its bytes.
247///
248/// **Field order is shader-location order**, for the reason
249/// [`SegmentInstance::alpha`] records: a field inserted anywhere but the end
250/// re-points every attribute after it.
251#[repr(C)]
252#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
253pub struct Segment3dInstance {
254 /// First endpoint, world space, in front of the near plane.
255 pub a: [f32; 3],
256 /// Second endpoint, world space, in front of the near plane.
257 pub b: [f32; 3],
258 /// RGB light (pre-glow; additive blend sums overlaps).
259 pub color: [f32; 3],
260 /// Full stroke width in **pixels of the render target** at the camera's
261 /// reference depth; each end is scaled by perspective from there.
262 pub width: f32,
263 /// How much of the stroke is present: its light and its coverage are both
264 /// multiplied by this, so a fading edge fades out of the frame rather than
265 /// into a dark line.
266 pub alpha: f32,
267}
268
269/// **Which space the stroke is measured in** (ADR-0160) — the half-width, the
270/// join extensions and the direction all three are taken along.
271///
272/// A per-draw-call parameter rather than a per-instance field or a second
273/// pipeline: it rides the segment uniform's `v.w`, a lane that was unused, so it
274/// costs no bytes, no bind-group entry and no new resource (ADR-0058).
275///
276/// Explicit at every call site rather than inferred from which entry point the
277/// caller used, so a producer that picks the wrong one is visible in its own
278/// source rather than implicit in a pipeline.
279#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280pub enum StrokeMetric {
281 /// **World space, the isotropic one**: a world displacement `(dx, dy)` lands
282 /// on `(dx * H/2, dy * H/2)` pixels whichever axis it is on, because
283 /// dividing x by the aspect is exactly what squares the space up. So the
284 /// half-width is a thickness on screen and the extension is a length on
285 /// screen, at every orientation.
286 ///
287 /// What the four line families pass, and what makes a producer's
288 /// world-space interior angle the angle the shader extends along (ADR-0158).
289 World,
290 /// **Clip space, the anisotropic one**: one clip x unit is `aspect` times as
291 /// many pixels as one clip y unit, so a vertical stroke is `aspect` times
292 /// thicker on screen than a horizontal one at the same `width`.
293 ///
294 /// `warp_mesh` is the one surface on it, and it is a **dated deferral, not
295 /// an endorsement** — see the comment at its call site.
296 Clip,
297}
298
299impl StrokeMetric {
300 /// The `v.w` lane's value. `World` is `0.0`, so the uniform a line family
301 /// writes is byte-identical to the one written before this existed.
302 fn lane(self) -> f32 {
303 match self {
304 Self::World => 0.0,
305 Self::Clip => 1.0,
306 }
307 }
308}
309
310#[repr(C)]
311#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
312struct Uniforms {
313 // x: aspect, y: glow multiplier, z: softness (ADR-0124), w: the stroke
314 // metric (ADR-0160) — 0 world, 1 clip
315 v: [f32; 4],
316 // x: zoom, yz: pan, w: unused — the shared ViewTransform (ADR-0018)
317 view: [f32; 4],
318}
319
320/// The across-the-stroke profile, **one definition prepended to both fragment
321/// modules** (ADR-0124).
322///
323/// `u` runs 0 at the stroke edge to 1 at the centreline; `du` is that
324/// coordinate's change per pixel of the render target, from `fwidth` — so the
325/// ramp derived from it is a width in **pixels of the render target** rather
326/// than a fraction of the stroke, and no uniform has to carry a resolution.
327/// `softness` is the ramp's width as a fraction of the half-width:
328///
329/// - **`1.0` reduces the whole expression to `g = u²` term for term** — the
330/// profile every line scene drew before this parameter existed. That equality
331/// is what the golden corpus rests on, and it holds **only because `edge` is
332/// capped at 1.0**: a sub-pixel stroke drives `fwidth` above 1, where an
333/// uncapped `max(softness, edge)` would divide `u` down and *dim* the stroke
334/// instead of sharpening it. `warp_mesh`'s pin
335/// ([`MILKDROP_SOFTNESS`](crate::render::scenes::warp_mesh::MILKDROP_SOFTNESS))
336/// is byte-identical for the same reason — its `THIN` stroke is 1.0–1.35 px of
337/// half-width, exactly that regime.
338/// - `0.5` makes the inner half of the stroke solid and ramps across the outer.
339/// - `0` is a solid stroke whose coverage falls to zero across **one pixel**,
340/// whatever the stroke width, the resolution or the aspect.
341///
342/// Shared rather than written twice: two copies of a profile is a divergence
343/// that compiles, and here it would mean a mandala whose circles and interlace
344/// stop matching.
345///
346/// **`fwidth` exists only in a fragment shader**, so each caller evaluates it at
347/// the fragment's top level and passes the result in — which also keeps the call
348/// out of the arc fragment's non-uniform endpoint branch.
349const PROFILE_WGSL: &str = r#"
350fn stroke_coverage(u: f32, du: f32, softness: f32) -> f32 {
351 let edge = clamp(du, 1e-6, 1.0);
352 let ramp = max(clamp(softness, 0.0, 1.0), edge);
353 let core = clamp(u / ramp, 0.0, 1.0);
354 return core * core;
355}
356"#;
357
358/// The WGSL body, minus the shared profile — [`shader_source`] prepends that.
359const SHADER_BODY: &str = r#"
360struct Uniforms {
361 v: vec4<f32>,
362 view: vec4<f32>,
363}
364
365@group(0) @binding(0) var<uniform> u: Uniforms;
366
367struct VsOut {
368 @builtin(position) pos: vec4<f32>,
369 @location(0) side: f32,
370 @location(1) color: vec3<f32>,
371 @location(2) alpha: f32,
372}
373
374@vertex
375fn vs_main(
376 @builtin(vertex_index) vi: u32,
377 @location(0) a: vec2<f32>,
378 @location(1) b: vec2<f32>,
379 @location(2) color: vec3<f32>,
380 @location(3) width: f32,
381 @location(4) ext_a: f32,
382 @location(5) alpha: f32,
383 @location(6) ext_b: f32,
384) -> VsOut {
385 // (along, side): along runs a->b, side spans -1..1 across the width.
386 var corners = array<vec2<f32>, 6>(
387 vec2<f32>(0.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(0.0, 1.0),
388 vec2<f32>(0.0, 1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
389 );
390 let c = corners[vi];
391 let aspect = max(u.v.x, 0.1);
392 let inv_aspect = 1.0 / aspect;
393
394 // Shared ViewTransform (ADR-0018): zoom about the frame centre, then pan, in
395 // world space before the aspect divide. Endpoints move; stroke width does not.
396 let zoom = u.view.x;
397 let pan = u.view.yz;
398 let a_v = a * zoom + pan;
399 let b_v = b * zoom + pan;
400
401 // The stroke is measured in WORLD space, which is the isotropic one: a
402 // world displacement lands on the same number of pixels whichever axis it
403 // is on, because dividing x by the aspect is exactly what squares the space
404 // up. So the perpendicular offset below is a uniform on-screen thickness
405 // whatever the segment's orientation, and the extension is a length on
406 // screen (ADR-0160). The divide happens once, on the way out.
407 //
408 // `v.w` selects the space, per draw call: `warp_mesh` passes the CLIP
409 // metric, which scales x in on the way IN and leaves `out.pos` alone, so
410 // the offset lands in the anisotropic space. Multiplying by exactly 1.0 is
411 // exact in f32, so each arm carries only its own scale - and at aspect 1.0
412 // `inv_aspect` is exactly 1.0 and the two arms are the same arithmetic.
413 let clip_metric = u.v.w > 0.5;
414 let into_stroke = select(1.0, inv_aspect, clip_metric);
415 let out_of_stroke = select(inv_aspect, 1.0, clip_metric);
416 let a_s = vec2<f32>(a_v.x * into_stroke, a_v.y);
417 let b_s = vec2<f32>(b_v.x * into_stroke, b_v.y);
418 var dir = b_s - a_s;
419 let len = length(dir);
420 if (len > 1e-6) {
421 dir = dir / len;
422 } else {
423 dir = vec2<f32>(1.0, 0.0);
424 }
425 let nrm = vec2<f32>(-dir.y, dir.x);
426
427 // Join (ADR-0158): an end that continues into a neighbouring segment is
428 // pushed past that endpoint along its **own** direction by the length the
429 // producer computed for it. Adjacent quads then overlap across the shared
430 // vertex and the additive falloff fills the wedge the two divergent
431 // perpendiculars would otherwise leave. The producer is the only party that
432 // can compute it, because a segment cannot see its neighbour's direction.
433 // Each end is independent, and a free end is exactly `0.0` — `dir * 0.0` is
434 // exactly zero, so a producer that extends nothing is byte-identical.
435 let a_j = a_s - dir * ext_a;
436 let b_j = b_s + dir * ext_b;
437
438 let base = mix(a_j, b_j, c.x);
439 let pos = base + nrm * c.y * width;
440
441 var out: VsOut;
442 out.pos = vec4<f32>(pos.x * out_of_stroke, pos.y, 0.0, 1.0);
443 out.side = c.y;
444 out.color = color;
445 out.alpha = alpha;
446 return out;
447}
448
449@fragment
450fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
451 // The shared profile (ADR-0124): a solid core of width `softness`, then a
452 // quadratic ramp to the quad edge, floored at one pixel of the render
453 // target. `side` spans -1..1 across the half-width, so `1 - |side|` is the
454 // coordinate the profile takes.
455 //
456 // The derivative is read off `side` and NOT off `|side|`, whose kink at the
457 // centreline would make `fwidth` meaningless on the 2x2 quad that straddles
458 // it. Away from that quad the two are equal in magnitude.
459 let inward = max(0.0, 1.0 - abs(in.side));
460 let g = stroke_coverage(inward, fwidth(in.side), u.v.z);
461 // Premultiplied: colour AND alpha carry the same coverage `g * alpha`, so
462 // the two long edges of the quad - where the across-the-stroke falloff
463 // reaches zero - write nothing at all rather than opaque black (ADR-0056).
464 // Note the glow multiplier scales the LIGHT, not the coverage: a dimmed
465 // stroke still covers its own footprint. See
466 // `gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`.
467 //
468 // `alpha` is 1.0 for every additive producer, so this is byte-identical to
469 // the pre-Plan-0100 fragment for all of them; it is the OVER pipeline's
470 // second range that passes anything else. The colour is NOT divided by it -
471 // an over-blended producer arrives premultiplied already.
472 return vec4<f32>(in.color * g * u.v.y, g * in.alpha);
473}
474"#;
475
476/// The full WGSL: the shared profile prepended to the body.
477///
478/// **The prelude carries no constants.** An endpoint's extension is an `f32`
479/// length the shader multiplies by a direction (ADR-0158), and a float has no
480/// bit assignment that could disagree with a Rust-side numbering — so there is
481/// nothing here to generate and keep in step.
482///
483/// Prepending rather than `format!`-ing the whole body is deliberate: the body
484/// is full of braces and every one would need escaping.
485///
486/// Runs once per [`LineRenderer::new`] (pipeline build, not the hot path).
487fn shader_source() -> String {
488 format!("{PROFILE_WGSL}\n{SHADER_BODY}")
489}
490
491/// The arc pipeline's full WGSL: [`PROFILE_WGSL`] prepended to [`ARC_SHADER`].
492///
493/// The two fragments are separate modules, so **the profile is shared by
494/// construction rather than by convention** - the same reason [`shader_source`]
495/// prepends it instead of the body restating it. Two hand-kept copies of the
496/// expression would compile, render, and give a mandala whose circles and
497/// interlace stop matching.
498///
499/// Runs once per [`LineRenderer::new_with_arcs`] (pipeline build, not the hot
500/// path).
501fn arc_shader_source() -> String {
502 format!(
503 "{PROFILE_WGSL}
504{ARC_SHADER}"
505 )
506}
507
508/// The arc pipeline's WGSL, **a separate shader module** from [`SHADER_BODY`]
509/// and built only when a scene asked for arcs.
510///
511/// Separate rather than two more entry points in the one module, so a
512/// `LineRenderer` without arcs creates exactly the resources it created before
513/// this existed. Appending to the shared module would have changed what every
514/// line scene compiles, and on the WARP software adapter the golden suite
515/// captures on, a changed resource is a changed picture (ADR-0058).
516///
517/// # What the fragment computes, and in which space
518///
519/// The arc is authored in **world space**, which is isotropic on screen: the
520/// vertex shader divides x by the aspect on the way out, so a world circle is a
521/// circle in pixels. The **stroke** is measured there too (ADR-0160), which is
522/// where the segment path measures it — so the two primitives place geometry
523/// and stroke it in one space, and this arc draws the same picture as a densely
524/// sampled polyline of it.
525///
526/// So the signed distance is `length(p - c) - r`, the exact world radial
527/// distance, used as it stands. Outside the angular span it is the distance to
528/// the nearer endpoint, which is a point, so that arm is exact too.
529///
530/// **The aspect is the render target's** (ADR-0037): it arrives in the uniform
531/// `draw` was handed, and there is no internal grid, texture or second size
532/// anywhere in this shader for another one to come from. This family has
533/// shipped that bug three times, which is why the control renders at a
534/// non-16:9 target where a grid-derived aspect and the target's disagree.
535const ARC_SHADER: &str = r#"
536struct Uniforms {
537 v: vec4<f32>,
538 view: vec4<f32>,
539}
540
541@group(0) @binding(0) var<uniform> u: Uniforms;
542
543const TAU: f32 = 6.2831853071795864;
544const QUARTER_TURN: f32 = 1.5707963267948966;
545
546struct VsOut {
547 @builtin(position) pos: vec4<f32>,
548 @location(0) ndc: vec2<f32>,
549 @location(1) @interpolate(flat) centre: vec2<f32>,
550 @location(2) @interpolate(flat) radius: f32,
551 // (lo, hi): the span's two ends in increasing order, so the sweep's sign
552 // stops mattering past this point and the two endpoints are the same two
553 // points either way.
554 @location(3) @interpolate(flat) span: vec2<f32>,
555 @location(4) @interpolate(flat) color: vec3<f32>,
556 @location(5) @interpolate(flat) width: f32,
557}
558
559@vertex
560fn vs_main(
561 @builtin(vertex_index) vi: u32,
562 @location(0) centre: vec2<f32>,
563 @location(1) radius: f32,
564 @location(2) angle_start: f32,
565 @location(3) angle_sweep: f32,
566 @location(4) color: vec3<f32>,
567 @location(5) width: f32,
568) -> VsOut {
569 // The unit square, expanded to the arc's own bounding box below.
570 var corners = array<vec2<f32>, 6>(
571 vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
572 vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
573 );
574 let c = corners[vi];
575 let aspect = max(u.v.x, 0.1);
576
577 // Shared ViewTransform (ADR-0018), applied exactly as the segment shader
578 // applies it: in world space, before the aspect divide. A uniform zoom
579 // scales the radius with the centre; stroke width does not move.
580 let centre_v = centre * u.view.x + u.view.yz;
581 let radius_v = radius * u.view.x;
582
583 let a0 = angle_start;
584 let a1 = angle_start + angle_sweep;
585 let lo = min(a0, a1);
586 let hi = max(a0, a1);
587
588 // The centreline's bounding box: the two ends always, plus each axis
589 // extreme the span actually reaches. A full turn reaches all four and the
590 // box is the whole circle; a short span gets a box barely bigger than its
591 // own chord, which is what keeps the shaded area near the stroke's.
592 let end0 = vec2<f32>(cos(a0), sin(a0));
593 let end1 = vec2<f32>(cos(a1), sin(a1));
594 var lo_dir = min(end0, end1);
595 var hi_dir = max(end0, end1);
596 for (var k = 0u; k < 4u; k = k + 1u) {
597 let ang = f32(k) * QUARTER_TURN;
598 // The smallest representative of `ang` modulo TAU at or above `lo`.
599 let t = ang + TAU * ceil((lo - ang) / TAU);
600 if (t <= hi) {
601 let d = vec2<f32>(cos(ang), sin(ang));
602 lo_dir = min(lo_dir, d);
603 hi_dir = max(hi_dir, d);
604 }
605 }
606
607 // The stroke reaches `width` in WORLD units on every side (ADR-0160), so
608 // the pad is isotropic in the space this box is built in. The 2 % is slack
609 // for the distance being a first-order expression of a curved level set -
610 // see the fragment.
611 //
612 // The pad has to be at least what the stroke reaches on each axis or the
613 // box clips its own edge, and in world units that is `width` on both -
614 // scaling either axis by the aspect under-pads it on one side of 1.0.
615 let pad = vec2<f32>(width, width) * 1.02;
616 let lo_w = centre_v + radius_v * lo_dir - pad;
617 let hi_w = centre_v + radius_v * hi_dir + pad;
618 let p = mix(lo_w, hi_w, c);
619 let ndc = vec2<f32>(p.x / aspect, p.y);
620
621 var out: VsOut;
622 out.pos = vec4<f32>(ndc, 0.0, 1.0);
623 out.ndc = ndc;
624 out.centre = centre_v;
625 out.radius = radius_v;
626 out.span = vec2<f32>(lo, hi);
627 out.color = color;
628 out.width = width;
629 return out;
630}
631
632@fragment
633fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
634 let aspect = max(u.v.x, 0.1);
635 // Back into the isotropic world space the arc is authored in.
636 let p = vec2<f32>(in.ndc.x * aspect, in.ndc.y);
637 let q = p - in.centre;
638 let len = length(q);
639
640 // Inside the span, or past one of its ends? The same modulo reduction the
641 // vertex shader uses, so an arc that wraps the branch cut of atan2 is not
642 // a special case.
643 let theta = atan2(q.y, q.x);
644 let t = theta + TAU * ceil((in.span.x - theta) / TAU);
645
646 // SIGNED across the stroke, and that is load-bearing: the profile below
647 // takes `fwidth` of this, and `fwidth` of an ABSOLUTE distance is garbage
648 // on the 2x2 quad that straddles the centreline - the kink makes the finite
649 // difference near zero exactly where the stroke is brightest. The segment
650 // fragment reads `side` and not `|side|` for the same reason.
651 var sd: f32;
652 if (t <= in.span.y) {
653 // The exact world radial distance, and the stroke is measured in world
654 // units too (ADR-0160) - so it is used as it stands, with nothing
655 // converted and nothing to keep in step with the segment path.
656 sd = len - in.radius;
657 } else {
658 // Past an end: the world distance to the nearer endpoint. A point has
659 // an exact distance, so this arm approximates nothing.
660 let e0 = in.centre + in.radius * vec2<f32>(cos(in.span.x), sin(in.span.x));
661 let e1 = in.centre + in.radius * vec2<f32>(cos(in.span.y), sin(in.span.y));
662 sd = min(length(p - e0), length(p - e1));
663 }
664
665 // The segment path's profile on the arc's own distance - literally the
666 // same `stroke_coverage`, prepended to both modules (ADR-0124), so the two
667 // fragments cannot draw different strokes on the same figure.
668 //
669 // `sd` is a world distance and `width` is flat-interpolated, so the
670 // normalized coordinate is `sd / width` and its screen derivative is
671 // `fwidth(sd) / width`. One pixel of the render target is `2 / H` world
672 // units on BOTH axes - `2 * aspect / W` is that same number - so the ramp
673 // is one pixel of the target at every orientation and on any frame. Taken
674 // on the signed distance, per its declaration.
675 //
676 // Premultiplied, so colour and alpha carry the same coverage and the quad
677 // outside the stroke writes nothing at all rather than opaque black
678 // (ADR-0056). The glow multiplier scales the LIGHT, not the coverage,
679 // exactly as it does for a segment.
680 //
681 // DIVIDED, not multiplied by a reciprocal: `x / w` and `x * (1 / w)` differ
682 // in the last ulp, and byte-identity at the default is what the golden
683 // corpus rests on.
684 let width = max(in.width, 1e-8);
685 let g = stroke_coverage(max(0.0, 1.0 - abs(sd) / width), fwidth(sd) / width, u.v.z);
686 return vec4<f32>(in.color * g * u.v.y, g);
687}
688"#;
689
690/// The `seg3d` pipeline's full WGSL: [`PROFILE_WGSL`], the shared camera and
691/// [`SEG3D_SHADER`], in that order.
692///
693/// The profile is prepended here for the reason [`arc_shader_source`] gives: a
694/// 3D stroke and a 2D one fall off across their width by one definition.
695///
696/// Runs once per [`LineRenderer::new_3d`] (pipeline build, not the hot path).
697pub(crate) fn seg3d_shader_source() -> String {
698 format!(
699 "{PROFILE_WGSL}
700{}
701{SEG3D_SHADER}",
702 crate::render::camera::CAMERA_WGSL
703 )
704}
705
706/// The `seg3d` pipeline's WGSL: each [`Segment3dInstance`] projected through the
707/// shared camera and expanded into a quad **in pixels of the render target**.
708///
709/// The quad is built in pixel space after the divide, where the space is
710/// isotropic, so the stroke has the same thickness on screen at every
711/// orientation; the aspect comes from the camera's viewport, which is the
712/// render target's (ADR-0037). Each end's half-width is the stroke width carried
713/// to that end's depth by perspective, and the quad is a trapezoid between the
714/// two, so a line receding from the camera thins along its length.
715///
716/// **A half-width is floored at half a pixel, and the light is scaled down by
717/// the same ratio**, so a stroke too thin to rasterize fades rather than
718/// breaking into a dotted line, and keeps the light it would have had.
719///
720/// # Depth of field, per endpoint (ADR-0257)
721///
722/// Each end takes the camera's circle of confusion at its own depth, `coc`, and
723/// its half-width grows from `w` to `w + coc`. Its edge softness widens with
724/// it, from the stroke's own `s0` toward `1` by `coc / (w + coc)`, so a blurred
725/// end has no hard edge. Both are interpolated along the quad, so one line is
726/// sharp where it crosses the focal plane and soft at either end.
727///
728/// **The light is divided down so the stroke's integrated cross-section keeps
729/// its energy.** The profile integrates across a half-width `h` to
730/// `h * (1 - 2s/3)` — a solid core of `1 - s` plus a quadratic ramp worth
731/// `s / 3` — so each fragment's light is scaled by
732///
733/// ```text
734/// w * (1 - 2 s0 / 3) / ((w + coc) * (1 - 2 s / 3))
735/// ```
736///
737/// from the interpolated sharp half-width `w`, blurred half-width `w + coc` and
738/// softness `s`. It is evaluated per fragment rather than at the two ends and
739/// interpolated: the energy is a product of interpolated quantities, and a
740/// linear blend of the end factors overshoots it everywhere between them.
741///
742/// **A blurred stroke reads its across-the-stroke coordinate exactly.** A
743/// trapezoid is drawn as two triangles, and the corner coordinate `side`
744/// interpolated affinely over each of them bends wherever the two ends'
745/// widths differ — which a blurred end makes them do by a factor of several.
746/// The perpendicular pixel offset is affine over the whole quad, and so is the
747/// half-width along it, so their ratio is the true coordinate at every
748/// fragment.
749///
750/// A sharp stroke skips both the factor and the exact coordinate on a flat
751/// flag, so an aperture of `0` draws the bytes a pinhole camera drew.
752///
753/// The position leaves the vertex shader already divided (`w = 1`). The quad is
754/// a screen-space shape, and interpolating its varyings perspective-correctly
755/// against the endpoints' depths would bend the across-the-stroke coordinate.
756const SEG3D_SHADER: &str = r#"
757struct Stroke {
758 // x: glow multiplier, y: softness (ADR-0124), zw: unused. The vertex
759 // stage reads the softness too, to widen it toward a blurred end.
760 v: vec4<f32>,
761}
762
763@group(0) @binding(0) var<uniform> cam: Camera;
764@group(0) @binding(1) var<uniform> stroke: Stroke;
765
766struct Seg3dOut {
767 @builtin(position) pos: vec4<f32>,
768 @location(0) side: f32,
769 @location(1) color: vec3<f32>,
770 @location(2) alpha: f32,
771 // The edge softness, widened by the circle of confusion toward each end.
772 @location(3) soft: f32,
773 // The half-width the stroke would have with no blur, and the blurred one,
774 // in pixels - the two sides of the energy ratio.
775 @location(5) sharp_hw: f32,
776 @location(6) wide_hw: f32,
777 // The fragment's perpendicular offset from the centreline, in pixels.
778 @location(7) offset: f32,
779 // 1 when either end is blurred, 0 otherwise. Flat, so a sharp stroke reads
780 // the uniform softness itself rather than an interpolation of copies of it.
781 @location(4) @interpolate(flat) blurred: f32,
782}
783
784// The profile's integral across a unit half-width at softness `s`.
785fn profile_mass(s: f32) -> f32 {
786 return 1.0 - 2.0 * clamp(s, 0.0, 1.0) / 3.0;
787}
788
789// Half a pixel: the narrowest half-width a stroke is rasterized at.
790const MIN_HALF_PX: f32 = 0.5;
791
792@vertex
793fn vs_main(
794 @builtin(vertex_index) vi: u32,
795 @location(0) a: vec3<f32>,
796 @location(1) b: vec3<f32>,
797 @location(2) color: vec3<f32>,
798 @location(3) width: f32,
799 @location(4) alpha: f32,
800) -> Seg3dOut {
801 // (along, side): along runs a->b, side spans -1..1 across the width.
802 var corners = array<vec2<f32>, 6>(
803 vec2<f32>(0.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(0.0, 1.0),
804 vec2<f32>(0.0, 1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
805 );
806 let c = corners[vi];
807
808 let ca = project(cam, a);
809 let cb = project(cam, b);
810 let sa = clip_to_px(cam, ca);
811 let sb = clip_to_px(cam, cb);
812
813 let hw_a = at_depth(cam, 0.5 * width, ca.w);
814 let hw_b = at_depth(cam, 0.5 * width, cb.w);
815 let coc_a = coc(cam, ca.w);
816 let coc_b = coc(cam, cb.w);
817 let s0 = stroke.v.y;
818
819 // The blurred half-width at each end, and `c.x` is exactly 0 or 1 at every
820 // corner, so `mix` picks one end exactly.
821 let hw_true = mix(hw_a + coc_a, hw_b + coc_b, c.x);
822 let hw = max(hw_true, MIN_HALF_PX);
823
824 // Each end's softness, widened toward 1 by its share of blur. Guarded
825 // against a zero width, where there is no stroke to soften.
826 let wide_a = hw_a + coc_a;
827 let wide_b = hw_b + coc_b;
828 let soft_a = mix(s0, 1.0, select(0.0, coc_a / wide_a, wide_a > 0.0));
829 let soft_b = mix(s0, 1.0, select(0.0, coc_b / wide_b, wide_b > 0.0));
830
831 var dir = sb - sa;
832 let len = length(dir);
833 if (len > 1e-6) {
834 dir = dir / len;
835 } else {
836 dir = vec2<f32>(1.0, 0.0);
837 }
838 let nrm = vec2<f32>(-dir.y, dir.x);
839 let p = mix(sa, sb, c.x) + nrm * c.y * hw;
840
841 var out: Seg3dOut;
842 out.pos = vec4<f32>(px_to_ndc(cam, p), 0.0, 1.0);
843 out.side = c.y;
844 out.color = color * (hw_true / hw);
845 out.alpha = alpha;
846 out.soft = mix(soft_a, soft_b, c.x);
847 out.sharp_hw = mix(hw_a, hw_b, c.x);
848 out.wide_hw = hw_true;
849 out.offset = c.y * hw;
850 out.blurred = select(0.0, 1.0, max(coc_a, coc_b) > 0.0);
851 return out;
852}
853
854@fragment
855fn fs_main(in: Seg3dOut) -> @location(0) vec4<f32> {
856 // The shared profile, read off `side` rather than `|side|` for the reason
857 // the 2D fragment gives.
858 let blurred = in.blurred > 0.5;
859 let side = select(in.side, in.offset / max(in.wide_hw, 1e-6), blurred);
860 let inward = max(0.0, 1.0 - abs(side));
861 let soft = select(stroke.v.y, in.soft, blurred);
862 let g = stroke_coverage(inward, fwidth(side), soft) * in.alpha;
863 // The energy ratio, exactly 1 for a sharp stroke.
864 let spread = in.wide_hw * profile_mass(soft);
865 let keep = select(
866 1.0,
867 (in.sharp_hw * profile_mass(stroke.v.y)) / spread,
868 blurred && spread > 0.0,
869 );
870 // Premultiplied (ADR-0056): the glow scales the light, not the coverage.
871 return vec4<f32>(in.color * g * stroke.v.x * keep, g);
872}
873"#;
874
875/// The `seg3d` stroke uniform: `[glow, softness, unused, unused]`.
876#[repr(C)]
877#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
878struct Stroke3dUniform {
879 v: [f32; 4],
880}
881
882/// The `seg3d` pipeline and everything it binds — built only by
883/// [`LineRenderer::new_3d`].
884struct Seg3d {
885 pipeline: wgpu::RenderPipeline,
886 instances: wgpu::Buffer,
887 camera: wgpu::Buffer,
888 stroke: wgpu::Buffer,
889 bind_group: wgpu::BindGroup,
890 capacity: usize,
891}
892
893impl Seg3d {
894 fn new(
895 device: &wgpu::Device,
896 surface_format: wgpu::TextureFormat,
897 capacity: usize,
898 label: &str,
899 ) -> Self {
900 use crate::render::camera::CameraUniform;
901
902 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
903 label: Some(&format!("{label}-seg3d-shader")),
904 source: wgpu::ShaderSource::Wgsl(seg3d_shader_source().into()),
905 });
906 let instances = device.create_buffer(&wgpu::BufferDescriptor {
907 label: Some(&format!("{label}-seg3d-instances")),
908 size: (capacity * std::mem::size_of::<Segment3dInstance>()) as u64,
909 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
910 mapped_at_creation: false,
911 });
912 let camera = gpu::uniform_buffer(
913 device,
914 &format!("{label}-seg3d-camera"),
915 std::mem::size_of::<CameraUniform>(),
916 );
917 let stroke = gpu::uniform_buffer(
918 device,
919 &format!("{label}-seg3d-stroke"),
920 std::mem::size_of::<Stroke3dUniform>(),
921 );
922 // A shape of its own — two sized uniforms, the camera for the vertex
923 // stage and the stroke for both — so no other live layout matches it
924 // (ADR-0058).
925 let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
926 label: Some(&format!("{label}-seg3d-bind-layout")),
927 entries: &[
928 wgpu::BindGroupLayoutEntry {
929 binding: 0,
930 visibility: wgpu::ShaderStages::VERTEX,
931 ty: wgpu::BindingType::Buffer {
932 ty: wgpu::BufferBindingType::Uniform,
933 has_dynamic_offset: false,
934 min_binding_size: wgpu::BufferSize::new(
935 std::mem::size_of::<CameraUniform>() as u64,
936 ),
937 },
938 count: None,
939 },
940 wgpu::BindGroupLayoutEntry {
941 binding: 1,
942 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
943 ty: wgpu::BindingType::Buffer {
944 ty: wgpu::BufferBindingType::Uniform,
945 has_dynamic_offset: false,
946 min_binding_size: wgpu::BufferSize::new(
947 std::mem::size_of::<Stroke3dUniform>() as u64,
948 ),
949 },
950 count: None,
951 },
952 ],
953 });
954 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
955 label: Some(&format!("{label}-seg3d-bind-group")),
956 layout: &bind_layout,
957 entries: &[
958 wgpu::BindGroupEntry {
959 binding: 0,
960 resource: camera.as_entire_binding(),
961 },
962 wgpu::BindGroupEntry {
963 binding: 1,
964 resource: stroke.as_entire_binding(),
965 },
966 ],
967 });
968 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
969 label: Some(&format!("{label}-seg3d-pipeline-layout")),
970 bind_group_layouts: &[Some(&bind_layout)],
971 immediate_size: 0,
972 });
973 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
974 label: Some(&format!("{label}-seg3d-pipeline")),
975 layout: Some(&pipeline_layout),
976 vertex: wgpu::VertexState {
977 module: &shader,
978 entry_point: Some("vs_main"),
979 compilation_options: Default::default(),
980 buffers: &[Some(wgpu::VertexBufferLayout {
981 array_stride: std::mem::size_of::<Segment3dInstance>() as u64,
982 step_mode: wgpu::VertexStepMode::Instance,
983 attributes: &wgpu::vertex_attr_array![
984 0 => Float32x3,
985 1 => Float32x3,
986 2 => Float32x3,
987 3 => Float32,
988 4 => Float32,
989 ],
990 })],
991 },
992 fragment: Some(wgpu::FragmentState {
993 module: &shader,
994 entry_point: Some("fs_main"),
995 compilation_options: Default::default(),
996 targets: &[Some(wgpu::ColorTargetState {
997 format: surface_format,
998 // The seam every additive line draws through (ADR-0056).
999 blend: Some(gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE),
1000 write_mask: wgpu::ColorWrites::ALL,
1001 })],
1002 }),
1003 primitive: wgpu::PrimitiveState::default(),
1004 depth_stencil: None,
1005 multisample: wgpu::MultisampleState::default(),
1006 multiview_mask: None,
1007 cache: None,
1008 });
1009 Self {
1010 pipeline,
1011 instances,
1012 camera,
1013 stroke,
1014 bind_group,
1015 capacity,
1016 }
1017 }
1018}
1019
1020/// The share of the segment `a -> b` lying inside `[-aspect, aspect] x [-1, 1]`,
1021/// as a fraction of its own length: Liang-Barsky against the four edges.
1022///
1023/// **Exactly `1.0`** when the segment is untouched by any edge — the two
1024/// parameters start at `0.0` and `1.0` and no edge moves them — which is what
1025/// lets an unclipped figure sum to exactly its own total. **Exactly `0.0`** when
1026/// the segment is wholly outside.
1027///
1028/// A parametric clip rather than an endpoint test on purpose: the case a naive
1029/// "are both ends outside" check gets wrong is a segment whose ends are both out
1030/// but which crosses the frame between them, and that is precisely what a badly
1031/// over-scaled figure is made of.
1032fn in_frame_fraction(a: [f32; 2], b: [f32; 2], aspect: f32) -> f32 {
1033 let [ax, ay] = a;
1034 let [bx, by] = b;
1035 let (dx, dy) = (bx - ax, by - ay);
1036 let (mut t0, mut t1) = (0.0f32, 1.0f32);
1037 // (direction, distance) per edge: left, right, bottom, top.
1038 for (p, q) in [
1039 (-dx, ax + aspect),
1040 (dx, aspect - ax),
1041 (-dy, ay + 1.0),
1042 (dy, 1.0 - ay),
1043 ] {
1044 if p == 0.0 {
1045 // Parallel to this edge: wholly out if it starts outside it.
1046 if q < 0.0 {
1047 return 0.0;
1048 }
1049 continue;
1050 }
1051 let r = q / p;
1052 if p < 0.0 {
1053 if r > t1 {
1054 return 0.0;
1055 }
1056 if r > t0 {
1057 t0 = r;
1058 }
1059 } else {
1060 if r < t0 {
1061 return 0.0;
1062 }
1063 if r < t1 {
1064 t1 = r;
1065 }
1066 }
1067 }
1068 t1 - t0
1069}
1070
1071/// Sub-arcs one [`ArcInstance`] is measured in — a **power of two**, which is
1072/// load-bearing.
1073///
1074/// Each sub-arc is clipped by its own chord and weighted `1 / ARC_STEPS`, so an
1075/// arc wholly inside the frame accumulates that weight exactly `ARC_STEPS`
1076/// times. At a power of two the weight is exact in binary and the sum is
1077/// **exactly 1.0**, which is what keeps an unclipped figure measuring exactly
1078/// its own length — the property `in_frame_fraction` is written to preserve for
1079/// segments.
1080///
1081/// Sixty-four puts 5.6 degrees in a sub-arc of a full circle, whose chord
1082/// departs from it by `r * (1 - cos(2.8 deg))`, about `0.0012 * r`. The error is
1083/// a function of the angle per step alone, so it does not grow with radius.
1084const ARC_STEPS: usize = 64;
1085
1086/// The world-space length of `arc` and the share of it inside
1087/// `[-aspect, aspect] x [-1, 1]`, under the same view transform the vertex
1088/// shader applies.
1089///
1090/// Measured as [`ARC_STEPS`] sub-arcs clipped by their own chords rather than by
1091/// solving the circle against the four edges: the closed form needs the
1092/// intersection of four half-planes with a circle, which is up to four disjoint
1093/// angular components and considerably more code than the property is worth.
1094/// Both sums are taken from the arc's **own** length (`|sweep| * radius`), never
1095/// from the chords', so the sub-chord sampling changes only *where* the arc is
1096/// judged to be, never how long it is.
1097fn measure_arc(arc: &ArcInstance, aspect: f32, xform: super::ViewTransform) -> (f32, f32) {
1098 let [pan_x, pan_y] = xform.pan;
1099 let centre = [
1100 arc.centre[0] * xform.zoom + pan_x,
1101 arc.centre[1] * xform.zoom + pan_y,
1102 ];
1103 let radius = arc.radius * xform.zoom;
1104 let len = (radius * arc.angle_sweep).abs();
1105 if len <= 0.0 || !len.is_finite() {
1106 return (0.0, 0.0); // a degenerate (or non-finite) arc measures nothing
1107 }
1108 let step = 1.0 / ARC_STEPS as f32;
1109 let at = |k: usize| {
1110 let t = arc.angle_start + arc.angle_sweep * k as f32 * step;
1111 [centre[0] + radius * t.cos(), centre[1] + radius * t.sin()]
1112 };
1113 let mut inside = 0.0;
1114 for k in 0..ARC_STEPS {
1115 inside += in_frame_fraction(at(k), at(k + 1), aspect) * step;
1116 }
1117 (len, len * inside)
1118}
1119
1120/// Measure `segments` against the frame — the diagnostic's whole computation.
1121///
1122/// **The aspect is a parameter, and it is the only source of one in here**
1123/// (ADR-0037): this is a free function over the endpoints, so there is no
1124/// internal grid, no texture and no `self` for a second aspect to come from.
1125/// Its caller hands it the value `draw` was handed, which is the **render
1126/// target's**.
1127///
1128/// The view transform is applied first, exactly as the vertex shader applies it
1129/// (`a * zoom + pan`, before the aspect divide), because a figure pushed off the
1130/// frame by `zoom` or `pan_y` has overshot just as surely as one scaled off it.
1131fn measure_extent(
1132 segments: &[SegmentInstance],
1133 arcs: &[ArcInstance],
1134 aspect: f32,
1135 xform: super::ViewTransform,
1136) -> DrawExtent {
1137 let mut extent = DrawExtent::default();
1138 let [pan_x, pan_y] = xform.pan;
1139 for segment in segments {
1140 let [ax, ay] = segment.a;
1141 let [bx, by] = segment.b;
1142 let a = [ax * xform.zoom + pan_x, ay * xform.zoom + pan_y];
1143 let b = [bx * xform.zoom + pan_x, by * xform.zoom + pan_y];
1144 let ([ax, ay], [bx, by]) = (a, b);
1145 let (dx, dy) = (bx - ax, by - ay);
1146 let len = (dx * dx + dy * dy).sqrt();
1147 if len <= 0.0 || !len.is_finite() {
1148 continue; // a degenerate (or non-finite) segment measures nothing
1149 }
1150 extent.total_len += len;
1151 // `len * 1.0` is `len` exactly, so an unclipped figure adds the same
1152 // value to both sums and the fraction is exactly 1.0.
1153 extent.in_frame_len += len * in_frame_fraction(a, b, aspect);
1154 }
1155 // Arcs, into the same two sums: the fraction is over everything drawn, and
1156 // a batch of both kinds has one denominator.
1157 for arc in arcs {
1158 let (len, in_frame) = measure_arc(arc, aspect, xform);
1159 extent.total_len += len;
1160 extent.in_frame_len += in_frame;
1161 }
1162 extent
1163}
1164
1165/// Draws segment buffers as thick glowing quads. Owns its pipeline, a
1166/// fixed-capacity instance buffer, and the aspect/glow uniform.
1167pub struct LineRenderer {
1168 pipeline: wgpu::RenderPipeline,
1169 /// The same pipeline with the light composited **over** rather than added —
1170 /// see [`LineRenderer::draw_split`]. It shares the shader, the layout and the
1171 /// bind group with [`pipeline`](Self::pipeline), so ADR-0058 has nothing to
1172 /// separate: there is one layout, not two that happen to match.
1173 ///
1174 /// **`None` unless the scene asked for it** ([`LineRenderer::new_split`]),
1175 /// and that is not a micro-optimization. Building a pipeline the scene never
1176 /// binds still allocates on the device, and on the WARP software adapter a
1177 /// changed allocation order changes what a later pass resolves to — the
1178 /// hazard `core/tests/suite/composite.rs`'s header records and the golden suite
1179 /// captures on. Building this for the nine line scenes that do not use it
1180 /// moved five composite baselines while changing nothing a driver would
1181 /// render differently.
1182 over_pipeline: Option<wgpu::RenderPipeline>,
1183 /// The [`ArcInstance`] pipeline, drawn in the same additive pass from its
1184 /// own buffer — [`ARC_SHADER`] and ADR-0098.
1185 ///
1186 /// **`None` unless the scene asked for it**
1187 /// ([`LineRenderer::new_with_arcs`]), for the reason
1188 /// [`over_pipeline`](Self::over_pipeline) records and with the same
1189 /// evidence behind it: building a pipeline nobody binds still allocates on
1190 /// the device, and on WARP a changed allocation order changes what a later
1191 /// pass resolves to. It **shares the bind-group layout, the bind group and
1192 /// the pipeline layout** with the segment pipelines — one uniform, one
1193 /// layout, so ADR-0058 has nothing new to separate. Only the vertex layout
1194 /// and the shader module differ.
1195 arc_pipeline: Option<wgpu::RenderPipeline>,
1196 /// [`arc_pipeline`](Self::arc_pipeline) with the light composited **over**
1197 /// rather than added — the arc half of the opacity-preserving seam
1198 /// (ADR-0138), built exactly when [`over_pipeline`](Self::over_pipeline) and
1199 /// the arc pipeline both are.
1200 ///
1201 /// Without it, [`draw_opaque`](Self::draw_opaque) on a scene whose motifs are
1202 /// arcs would lay opaque strokes and additive circles into one picture, and
1203 /// the limited-ink guarantee would hold for half of what the scene drew.
1204 arc_over_pipeline: Option<wgpu::RenderPipeline>,
1205 instances: wgpu::Buffer,
1206 /// The arc instance buffer, `Some` exactly when
1207 /// [`arc_pipeline`](Self::arc_pipeline) is.
1208 arcs: Option<wgpu::Buffer>,
1209 uniforms: wgpu::Buffer,
1210 bind_group: wgpu::BindGroup,
1211 /// Maximum segments the instance buffer holds; extra are dropped by `draw`.
1212 capacity: usize,
1213 /// Maximum arcs the arc buffer holds; `0` when there is no arc pipeline.
1214 arc_capacity: usize,
1215 /// The 3D segment pipeline (ADR-0257), **`None` unless the scene asked for
1216 /// it** ([`LineRenderer::new_3d`]) — for the reason
1217 /// [`over_pipeline`](Self::over_pipeline) records: a pipeline nobody binds
1218 /// still allocates on the device, and on WARP a changed allocation order
1219 /// changes what a later pass resolves to.
1220 seg3d: Option<Seg3d>,
1221}
1222
1223impl LineRenderer {
1224 /// Build the pipeline and a `capacity`-segment instance buffer on `device`.
1225 /// `label` names this instance's GPU resources; it must be **unique per
1226 /// LineRenderer** — two line scenes coexist (parametric + generator), and
1227 /// distinct labels keep their pipelines/buffers unambiguous in tooling and
1228 /// captures.
1229 pub fn new(
1230 device: &wgpu::Device,
1231 surface_format: wgpu::TextureFormat,
1232 capacity: usize,
1233 label: &str,
1234 ) -> Self {
1235 Self::build(device, surface_format, capacity, label, false, 0)
1236 }
1237
1238 /// [`new`](Self::new), plus the second pipeline
1239 /// [`draw_split`](Self::draw_split) needs. Only a scene that actually splits
1240 /// its batch by blend mode should call this — see
1241 /// `over_pipeline` for why building it unconditionally
1242 /// is not free.
1243 pub fn new_split(
1244 device: &wgpu::Device,
1245 surface_format: wgpu::TextureFormat,
1246 capacity: usize,
1247 label: &str,
1248 ) -> Self {
1249 Self::build(device, surface_format, capacity, label, true, 0)
1250 }
1251
1252 /// [`new_with_arcs`](Self::new_with_arcs), plus the OVER pipelines
1253 /// [`draw_opaque`](Self::draw_opaque) needs — the constructor the shared line
1254 /// renderer takes, because any of the four line systems may ask for the
1255 /// opacity-preserving seam (ADR-0138).
1256 ///
1257 /// The pipelines are built here rather than on the first preset that asks,
1258 /// deliberately: building a GPU resource mid-run changes what a later pass
1259 /// resolves to on the DX12 software adapter, which would make the seam's
1260 /// arrival visible in scenes that never selected it.
1261 pub fn new_split_with_arcs(
1262 device: &wgpu::Device,
1263 surface_format: wgpu::TextureFormat,
1264 capacity: usize,
1265 arc_capacity: usize,
1266 label: &str,
1267 ) -> Self {
1268 Self::build(device, surface_format, capacity, label, true, arc_capacity)
1269 }
1270
1271 /// [`new`](Self::new), plus the arc pipeline and an `arc_capacity`-instance
1272 /// arc buffer ([`ArcInstance`], ADR-0098).
1273 ///
1274 /// Only a scene that actually draws arcs should call this — see
1275 /// `arc_pipeline` for why building it unconditionally
1276 /// is not free. `arc_capacity` is its own budget rather than a share of
1277 /// `capacity`: an arc replaces many segments, so the two counts are not the
1278 /// same order and sizing one from the other would waste most of it.
1279 pub fn new_with_arcs(
1280 device: &wgpu::Device,
1281 surface_format: wgpu::TextureFormat,
1282 capacity: usize,
1283 arc_capacity: usize,
1284 label: &str,
1285 ) -> Self {
1286 Self::build(device, surface_format, capacity, label, false, arc_capacity)
1287 }
1288
1289 /// A renderer for **3D segments only** (ADR-0257): the `seg3d` pipeline and
1290 /// a `capacity`-instance buffer for it, drawn with [`draw_3d`](Self::draw_3d).
1291 ///
1292 /// Its 2D half is built empty — no segment capacity, no arcs, no OVER
1293 /// pipelines — so a 3D scene owns its renderer outright and the roster's
1294 /// shared 2D renderer creates exactly the resources it did before 3D existed.
1295 pub fn new_3d(
1296 device: &wgpu::Device,
1297 surface_format: wgpu::TextureFormat,
1298 capacity: usize,
1299 label: &str,
1300 ) -> Self {
1301 let mut renderer = Self::build(device, surface_format, 0, label, false, 0);
1302 renderer.seg3d = Some(Seg3d::new(device, surface_format, capacity, label));
1303 renderer
1304 }
1305
1306 fn build(
1307 device: &wgpu::Device,
1308 surface_format: wgpu::TextureFormat,
1309 capacity: usize,
1310 label: &str,
1311 split: bool,
1312 arc_capacity: usize,
1313 ) -> Self {
1314 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1315 label: Some(&format!("{label}-shader")),
1316 source: wgpu::ShaderSource::Wgsl(shader_source().into()),
1317 });
1318 let instances = device.create_buffer(&wgpu::BufferDescriptor {
1319 label: Some(&format!("{label}-instances")),
1320 size: (capacity * std::mem::size_of::<SegmentInstance>()) as u64,
1321 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1322 mapped_at_creation: false,
1323 });
1324 let uniforms = gpu::uniform_buffer(
1325 device,
1326 &format!("{label}-uniforms"),
1327 std::mem::size_of::<Uniforms>(),
1328 );
1329 let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1330 label: Some(&format!("{label}-bind-layout")),
1331 entries: &[wgpu::BindGroupLayoutEntry {
1332 binding: 0,
1333 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
1334 ty: wgpu::BindingType::Buffer {
1335 ty: wgpu::BufferBindingType::Uniform,
1336 has_dynamic_offset: false,
1337 min_binding_size: None,
1338 },
1339 count: None,
1340 }],
1341 });
1342 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1343 label: Some(&format!("{label}-bind-group")),
1344 layout: &bind_layout,
1345 entries: &[wgpu::BindGroupEntry {
1346 binding: 0,
1347 resource: uniforms.as_entire_binding(),
1348 }],
1349 });
1350 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1351 label: Some(&format!("{label}-pipeline-layout")),
1352 bind_group_layouts: &[Some(&bind_layout)],
1353 immediate_size: 0,
1354 });
1355 // The two pipelines differ in exactly one field — the blend state — so
1356 // they are built from one closure. Anything else that diverges between
1357 // them would be a bug that renders as a difference between two ranges of
1358 // the same batch, which is close to unreadable in a capture.
1359 let make = |blend: wgpu::BlendState, suffix: &str| {
1360 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1361 label: Some(&format!("{label}-pipeline{suffix}")),
1362 layout: Some(&pipeline_layout),
1363 vertex: wgpu::VertexState {
1364 module: &shader,
1365 entry_point: Some("vs_main"),
1366 compilation_options: Default::default(),
1367 buffers: &[Some(wgpu::VertexBufferLayout {
1368 array_stride: std::mem::size_of::<SegmentInstance>() as u64,
1369 step_mode: wgpu::VertexStepMode::Instance,
1370 attributes: &wgpu::vertex_attr_array![
1371 0 => Float32x2,
1372 1 => Float32x2,
1373 2 => Float32x3,
1374 3 => Float32,
1375 4 => Float32,
1376 5 => Float32,
1377 6 => Float32,
1378 ],
1379 })],
1380 },
1381 fragment: Some(wgpu::FragmentState {
1382 module: &shader,
1383 entry_point: Some("fs_main"),
1384 compilation_options: Default::default(),
1385 targets: &[Some(wgpu::ColorTargetState {
1386 format: surface_format,
1387 blend: Some(blend),
1388 write_mask: wgpu::ColorWrites::ALL,
1389 })],
1390 }),
1391 primitive: wgpu::PrimitiveState::default(),
1392 depth_stencil: None,
1393 multisample: wgpu::MultisampleState::default(),
1394 multiview_mask: None,
1395 cache: None,
1396 })
1397 };
1398 // Additive light, saturating coverage (ADR-0056) — the same constant the
1399 // swarm's sprite pipeline takes, so the two draw seams cannot drift
1400 // apart. This is what every line scene draws through.
1401 let pipeline = make(crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE, "");
1402 // Premultiplied OVER, for a producer whose source blend *replaces* rather
1403 // than accumulates — see `draw_split`. The fragment is premultiplied
1404 // either way, which is why one shader serves both.
1405 let over_pipeline =
1406 split.then(|| make(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, "-over"));
1407
1408 // The arc pipeline (ADR-0098). Its own shader module and vertex layout,
1409 // the *same* bind layout, bind group and pipeline layout as above, and
1410 // the same additive blend — so an arc and a segment emit into one pass
1411 // through one uniform and cannot drift apart on aspect, glow or the
1412 // view transform.
1413 let arcs = (arc_capacity > 0).then(|| {
1414 device.create_buffer(&wgpu::BufferDescriptor {
1415 label: Some(&format!("{label}-arc-instances")),
1416 size: (arc_capacity * std::mem::size_of::<ArcInstance>()) as u64,
1417 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1418 mapped_at_creation: false,
1419 })
1420 });
1421 let arc_shader = arcs.is_some().then(|| {
1422 device.create_shader_module(wgpu::ShaderModuleDescriptor {
1423 label: Some(&format!("{label}-arc-shader")),
1424 source: wgpu::ShaderSource::Wgsl(arc_shader_source().into()),
1425 })
1426 });
1427 // The arc pair is built from one closure for the reason the segment pair
1428 // is: they differ in the blend state and in nothing else, and any other
1429 // divergence would render as a difference between two batches of the same
1430 // figure.
1431 let make_arc = |blend: wgpu::BlendState, suffix: &str| {
1432 let arc_shader = arc_shader.as_ref()?;
1433 Some(
1434 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1435 label: Some(&format!("{label}-arc-pipeline{suffix}")),
1436 layout: Some(&pipeline_layout),
1437 vertex: wgpu::VertexState {
1438 module: arc_shader,
1439 entry_point: Some("vs_main"),
1440 compilation_options: Default::default(),
1441 buffers: &[Some(wgpu::VertexBufferLayout {
1442 array_stride: std::mem::size_of::<ArcInstance>() as u64,
1443 step_mode: wgpu::VertexStepMode::Instance,
1444 attributes: &wgpu::vertex_attr_array![
1445 0 => Float32x2,
1446 1 => Float32,
1447 2 => Float32,
1448 3 => Float32,
1449 4 => Float32x3,
1450 5 => Float32,
1451 ],
1452 })],
1453 },
1454 fragment: Some(wgpu::FragmentState {
1455 module: arc_shader,
1456 entry_point: Some("fs_main"),
1457 compilation_options: Default::default(),
1458 targets: &[Some(wgpu::ColorTargetState {
1459 format: surface_format,
1460 blend: Some(blend),
1461 write_mask: wgpu::ColorWrites::ALL,
1462 })],
1463 }),
1464 primitive: wgpu::PrimitiveState::default(),
1465 depth_stencil: None,
1466 multisample: wgpu::MultisampleState::default(),
1467 multiview_mask: None,
1468 cache: None,
1469 }),
1470 )
1471 };
1472 let arc_pipeline = make_arc(crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE, "");
1473 let arc_over_pipeline = split
1474 .then(|| make_arc(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, "-over"))
1475 .flatten();
1476
1477 // Zero unless the pipeline exists, so `draw_all` needs no second test:
1478 // a renderer without arcs clamps every arc batch to nothing.
1479 let arc_capacity = if arc_pipeline.is_some() {
1480 arc_capacity
1481 } else {
1482 0
1483 };
1484
1485 Self {
1486 pipeline,
1487 over_pipeline,
1488 arc_pipeline,
1489 arc_over_pipeline,
1490 instances,
1491 arcs,
1492 uniforms,
1493 bind_group,
1494 capacity,
1495 arc_capacity,
1496 seg3d: None,
1497 }
1498 }
1499
1500 /// Segments the instance buffer can hold — the scene clamps its geometry to
1501 /// this and surfaces any drop at load (ADR-0007 cap must never be silent).
1502 pub fn capacity(&self) -> usize {
1503 self.capacity
1504 }
1505
1506 /// Arcs the arc buffer can hold — `0` when this renderer was not built with
1507 /// [`new_with_arcs`](Self::new_with_arcs), in which case
1508 /// [`draw_arcs`](Self::draw_arcs) draws none.
1509 pub fn arc_capacity(&self) -> usize {
1510 self.arc_capacity
1511 }
1512
1513 /// Draw `segments` as thick glowing quads at the given `aspect` and `glow`
1514 /// multiplier, under the shared `xform` camera transform (zoom/pan, ADR-0018),
1515 /// **loading** over the engine backdrop rather than clearing (Plan 0018 Phase
1516 /// 3 — the background pass owns the clear). Segments beyond `capacity` are
1517 /// dropped defensively (the scene is responsible for capping at load).
1518 ///
1519 /// `softness` is the across-the-stroke profile (`PROFILE_WGSL`, ADR-0124):
1520 /// `1.0` is the pre-Plan-0114 quadratic falloff, `0` a solid stroke with a
1521 /// one-pixel edge. **There is no default here** — one uniform serves every
1522 /// entry point, so each caller names the constant it answers to:
1523 /// [`lines::DEFAULT_SOFTNESS`](super::DEFAULT_SOFTNESS) for the four line
1524 /// families, [`warp_mesh::MILKDROP_SOFTNESS`](crate::render::scenes::warp_mesh::MILKDROP_SOFTNESS)
1525 /// for the MilkDrop surface.
1526 ///
1527 /// `metric` is the space the stroke is measured in (ADR-0160) and has no
1528 /// default either, for the same reason: [`StrokeMetric::World`] for the four
1529 /// line families, [`StrokeMetric::Clip`] for `warp_mesh`.
1530 #[allow(
1531 clippy::too_many_arguments,
1532 reason = "distinct GPU handles plus the per-frame draw parameters (aspect, glow, \
1533 softness, stroke metric, view transform); bundling them would only shuffle \
1534 the same values behind a one-use struct"
1535 )]
1536 pub fn draw(
1537 &mut self,
1538 queue: &wgpu::Queue,
1539 encoder: &mut wgpu::CommandEncoder,
1540 view: &wgpu::TextureView,
1541 aspect: f32,
1542 glow: f32,
1543 softness: f32,
1544 metric: StrokeMetric,
1545 xform: super::ViewTransform,
1546 segments: &[SegmentInstance],
1547 ) {
1548 self.draw_split(
1549 queue,
1550 encoder,
1551 view,
1552 aspect,
1553 glow,
1554 softness,
1555 metric,
1556 xform,
1557 segments,
1558 segments.len(),
1559 );
1560 }
1561
1562 /// [`draw`](Self::draw), with the batch split by blend mode: the first
1563 /// `n_additive` segments are added (ADR-0056's seam, what every line scene
1564 /// uses), and the rest are composited **over** using each segment's own
1565 /// [`alpha`](SegmentInstance::alpha).
1566 ///
1567 /// # Why a split range rather than two calls
1568 ///
1569 /// One instance buffer, one upload, one render pass, two `draw` calls that
1570 /// differ only in the pipeline bound. Two calls would mean two passes over
1571 /// the same attachment and a second buffer, and the *order* would stop being
1572 /// expressible: an over-blended stroke has to land on top of the additive
1573 /// light it covers, which a single ordered batch gives for free.
1574 ///
1575 /// The caller partitions — it is the only thing that knows which producer
1576 /// each segment came from. Passing `n_additive >= segments.len()` is exactly
1577 /// [`draw`](Self::draw).
1578 #[allow(
1579 clippy::too_many_arguments,
1580 reason = "see `draw` — this is that signature plus the partition index"
1581 )]
1582 pub fn draw_split(
1583 &mut self,
1584 queue: &wgpu::Queue,
1585 encoder: &mut wgpu::CommandEncoder,
1586 view: &wgpu::TextureView,
1587 aspect: f32,
1588 glow: f32,
1589 softness: f32,
1590 metric: StrokeMetric,
1591 xform: super::ViewTransform,
1592 segments: &[SegmentInstance],
1593 n_additive: usize,
1594 ) {
1595 self.draw_all(
1596 queue,
1597 encoder,
1598 view,
1599 aspect,
1600 glow,
1601 softness,
1602 metric,
1603 xform,
1604 segments,
1605 n_additive,
1606 &[],
1607 false,
1608 );
1609 }
1610
1611 /// [`draw`](Self::draw), plus `arcs` — [`ArcInstance`]s stroked by the
1612 /// per-pixel distance field (ADR-0098) **in the same additive pass**, from
1613 /// the same uniform, after the segments.
1614 ///
1615 /// One pass rather than two for the reason [`draw_split`](Self::draw_split)
1616 /// gives: a second pass would mean a second load of the attachment and a
1617 /// second set of uniforms to keep in step. Additive blending is
1618 /// order-independent, so "after the segments" is a statement about the
1619 /// command stream and not about the picture.
1620 ///
1621 /// Arcs beyond [`arc_capacity`](Self::arc_capacity) are dropped defensively,
1622 /// exactly as segments beyond `capacity` are; a renderer built without the
1623 /// arc pipeline has a capacity of zero and draws none.
1624 #[allow(
1625 clippy::too_many_arguments,
1626 reason = "see `draw` — this is that signature plus the arc batch"
1627 )]
1628 pub fn draw_arcs(
1629 &mut self,
1630 queue: &wgpu::Queue,
1631 encoder: &mut wgpu::CommandEncoder,
1632 view: &wgpu::TextureView,
1633 aspect: f32,
1634 glow: f32,
1635 softness: f32,
1636 metric: StrokeMetric,
1637 xform: super::ViewTransform,
1638 segments: &[SegmentInstance],
1639 arcs: &[ArcInstance],
1640 ) {
1641 self.draw_all(
1642 queue,
1643 encoder,
1644 view,
1645 aspect,
1646 glow,
1647 softness,
1648 metric,
1649 xform,
1650 segments,
1651 segments.len(),
1652 arcs,
1653 false,
1654 );
1655 }
1656
1657 /// [`draw_arcs`](Self::draw_arcs), with the **whole batch composited over**
1658 /// rather than added — the opacity-preserving seam of ADR-0138's limited-ink
1659 /// class, reached by the four line systems through `stroke_blend`.
1660 ///
1661 /// Segments and arcs both take the OVER pipeline, so a scene whose figure is
1662 /// part strokes and part circles draws one substance rather than two. Order
1663 /// inside the batch becomes the order on screen: a later stroke replaces the
1664 /// interior of what it covers instead of summing with it, which is the whole
1665 /// property. Pass an empty `arcs` from a scene that draws none.
1666 ///
1667 /// A renderer built without the OVER pipelines falls back to the additive
1668 /// ones, exactly as [`draw_split`](Self::draw_split) does — the wrong blend
1669 /// rather than a panic, and unreachable in the shipped path, where the shared
1670 /// line renderer is built with them.
1671 #[allow(
1672 clippy::too_many_arguments,
1673 reason = "see `draw` — this is that signature plus the arc batch"
1674 )]
1675 pub fn draw_opaque(
1676 &mut self,
1677 queue: &wgpu::Queue,
1678 encoder: &mut wgpu::CommandEncoder,
1679 view: &wgpu::TextureView,
1680 aspect: f32,
1681 glow: f32,
1682 softness: f32,
1683 metric: StrokeMetric,
1684 xform: super::ViewTransform,
1685 segments: &[SegmentInstance],
1686 arcs: &[ArcInstance],
1687 ) {
1688 self.draw_all(
1689 queue, encoder, view, aspect, glow, softness, metric, xform, segments, 0, arcs, true,
1690 );
1691 }
1692
1693 /// 3D segments the `seg3d` buffer can hold — `0` when this renderer was not
1694 /// built with [`new_3d`](Self::new_3d).
1695 pub fn capacity_3d(&self) -> usize {
1696 self.seg3d.as_ref().map_or(0, |seg3d| seg3d.capacity)
1697 }
1698
1699 /// Draw `segments` through the shared camera (ADR-0257), **loading** over
1700 /// the backdrop, in one additive pass of their own. Segments beyond
1701 /// [`capacity_3d`](Self::capacity_3d) are dropped defensively; a renderer
1702 /// without the pipeline draws none.
1703 ///
1704 /// Every endpoint must already be in front of the near plane: the scene
1705 /// clips on the CPU against the same view the uniform carries
1706 /// ([`CameraView::clip_near`](crate::render::camera::CameraView::clip_near)).
1707 #[allow(
1708 clippy::too_many_arguments,
1709 reason = "distinct GPU handles plus the per-frame camera and stroke values; bundling \
1710 them would only shuffle the same values behind a one-use struct"
1711 )]
1712 pub fn draw_3d(
1713 &mut self,
1714 queue: &wgpu::Queue,
1715 encoder: &mut wgpu::CommandEncoder,
1716 view: &wgpu::TextureView,
1717 camera: &crate::render::camera::CameraUniform,
1718 glow: f32,
1719 softness: f32,
1720 segments: &[Segment3dInstance],
1721 ) {
1722 let mut pass = gpu::color_pass(encoder, "seg3d-pass", view, wgpu::LoadOp::Load);
1723 let Some(seg3d) = self.seg3d.as_ref() else {
1724 return;
1725 };
1726 let count = segments.len().min(seg3d.capacity);
1727 let drawn = segments.get(..count).unwrap_or(&[]);
1728 if drawn.is_empty() {
1729 return; // nothing to stroke; the backdrop shows through
1730 }
1731 queue.write_buffer(&seg3d.instances, 0, bytemuck::cast_slice(drawn));
1732 queue.write_buffer(&seg3d.camera, 0, bytemuck::bytes_of(camera));
1733 queue.write_buffer(
1734 &seg3d.stroke,
1735 0,
1736 bytemuck::bytes_of(&Stroke3dUniform {
1737 v: [glow, softness, 0.0, 0.0],
1738 }),
1739 );
1740 pass.set_pipeline(&seg3d.pipeline);
1741 pass.set_bind_group(0, &seg3d.bind_group, &[]);
1742 pass.set_vertex_buffer(0, seg3d.instances.slice(..));
1743 pass.draw(0..6, 0..count as u32);
1744 }
1745
1746 /// The one body behind [`draw`](Self::draw),
1747 /// [`draw_split`](Self::draw_split), [`draw_arcs`](Self::draw_arcs) and
1748 /// [`draw_opaque`](Self::draw_opaque): one buffer upload per kind, one
1749 /// uniform write, one render pass.
1750 #[allow(
1751 clippy::too_many_arguments,
1752 reason = "see `draw` — this is that signature plus the partition index, \
1753 the arc batch and the arc batch's own seam"
1754 )]
1755 fn draw_all(
1756 &mut self,
1757 queue: &wgpu::Queue,
1758 encoder: &mut wgpu::CommandEncoder,
1759 view: &wgpu::TextureView,
1760 aspect: f32,
1761 glow: f32,
1762 softness: f32,
1763 metric: StrokeMetric,
1764 xform: super::ViewTransform,
1765 segments: &[SegmentInstance],
1766 n_additive: usize,
1767 arcs: &[ArcInstance],
1768 arcs_over: bool,
1769 ) {
1770 let count = segments.len().min(self.capacity);
1771 let drawn = segments.get(..count).unwrap_or(&[]);
1772 let arc_count = arcs.len().min(self.arc_capacity);
1773 let arcs_drawn = arcs.get(..arc_count).unwrap_or(&[]);
1774
1775 // The in-frame geometry diagnostic (Plan 0069, ADR-0083). Off in the
1776 // shipped path, and it reads `drawn` — the segments that actually reach
1777 // the instance buffer — without touching a GPU resource, so "off" is a
1778 // `Cell::get` and "on" changes nothing about the picture. The aspect it
1779 // measures against is the one this call was handed: the render target's
1780 // (ADR-0037), under the same `max(0.1)` clamp the uniform and the shader
1781 // apply, so the rectangle is the one the frame actually shows.
1782 if metrics::extent_diagnostic_on() {
1783 let extent = measure_extent(drawn, arcs_drawn, aspect.max(0.1), xform);
1784 metrics::record_draw_extent(extent);
1785 }
1786
1787 if !drawn.is_empty() {
1788 queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(drawn));
1789 }
1790 if let (Some(buffer), false) = (&self.arcs, arcs_drawn.is_empty()) {
1791 queue.write_buffer(buffer, 0, bytemuck::cast_slice(arcs_drawn));
1792 }
1793 queue.write_buffer(
1794 &self.uniforms,
1795 0,
1796 bytemuck::bytes_of(&Uniforms {
1797 v: [aspect.max(0.1), glow, softness, metric.lane()],
1798 view: [xform.zoom, xform.pan[0], xform.pan[1], 0.0],
1799 }),
1800 );
1801
1802 // Load over the engine backdrop (ADR-0018); additive strokes bloom over
1803 // it and the empty space reveals it.
1804 let mut pass = gpu::color_pass(encoder, "line-pass", view, wgpu::LoadOp::Load);
1805 if drawn.is_empty() && arcs_drawn.is_empty() {
1806 return; // nothing to stroke; the backdrop shows through
1807 }
1808 // Clamped to what actually reached the buffer: `n_additive` counts into
1809 // `segments`, which may be longer than `drawn`.
1810 let split = n_additive.min(drawn.len()) as u32;
1811 pass.set_bind_group(0, &self.bind_group, &[]);
1812 if split > 0 {
1813 pass.set_pipeline(&self.pipeline);
1814 pass.set_vertex_buffer(0, self.instances.slice(..));
1815 pass.draw(0..6, 0..split);
1816 }
1817 // Between the two segment ranges rather than after both: an arc is
1818 // additive light, and the OVER range has to land on top of the light it
1819 // covers. No scene draws both today; the ordering is here so that the
1820 // day one does, it is already right.
1821 let arc_pipeline = if arcs_over {
1822 self.arc_over_pipeline
1823 .as_ref()
1824 .or(self.arc_pipeline.as_ref())
1825 } else {
1826 self.arc_pipeline.as_ref()
1827 };
1828 if let (Some(pipeline), Some(buffer), false) =
1829 (arc_pipeline, &self.arcs, arcs_drawn.is_empty())
1830 {
1831 pass.set_pipeline(pipeline);
1832 pass.set_vertex_buffer(0, buffer.slice(..));
1833 pass.draw(0..6, 0..arcs_drawn.len() as u32);
1834 }
1835 if (drawn.len() as u32) > split {
1836 // Falls back to the additive pipeline when the scene did not ask for
1837 // the second one. That is the wrong blend rather than a panic, and it
1838 // is unreachable in practice: the only caller that passes a partition
1839 // is the one that built with `new_split`.
1840 pass.set_pipeline(self.over_pipeline.as_ref().unwrap_or(&self.pipeline));
1841 pass.set_vertex_buffer(0, self.instances.slice(..));
1842 pass.draw(0..6, split..drawn.len() as u32);
1843 }
1844 }
1845}
1846
1847#[cfg(test)]
1848mod tests;