1#![deny(
21 clippy::unwrap_used,
22 clippy::expect_used,
23 clippy::indexing_slicing,
24 clippy::panic,
25 clippy::unreachable
26)]
27
28use crate::render::gpu;
29
30use super::common;
31use super::{Phase, Scene};
32use crate::dsp::AnalysisFrame;
33use crate::render::palette::{self, Palette};
34use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec, default_of};
35
36const DEFAULT_WARP: f32 = default_of(PARAMS, "warp");
38const DEFAULT_HUE: f32 = 0.0;
39const DEFAULT_ZOOM: f32 = 1.0;
40const DEFAULT_GLOW: f32 = default_of(PARAMS, "glow");
41const DEFAULT_FLASH: f32 = default_of(PARAMS, "flash");
42const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
46const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
47const DEFAULT_FIELD_SPEED: f32 = default_of(PARAMS, "field_speed");
51const DEFAULT_FOLD_SPEED: f32 = default_of(PARAMS, "fold_speed");
52
53const SHADER: &str = r#"
54struct Params {
55 // x: time (s), y: aspect, z: warp, w: hue
56 a: vec4<f32>,
57 // x: zoom, y: glow, z: flash, w: color_span
58 b: vec4<f32>,
59 // xy: pan (field-space offset, ADR-0018), z: color_center, w: saturation
60 c: vec4<f32>,
61 // x: palette_mix (A/B crossfade), y: occlude (ADR-0085),
62 // z: palette_steps (integral, quantized CPU-side), w: palette_contour (ADR-0078)
63 d: vec4<f32>,
64 // x: fold phase, y: field phase (ADR-0132) — both INTEGRATED on the CPU
65 // (`phase += rate * dt`) rather than derived here from `t * rate`. At a
66 // constant rate a phase equals `rate * t`, so the defaults reproduce the
67 // literals these replaced; what integration buys is that a rate BOUND TO
68 // AUDIO bends the motion instead of teleporting it — at t = 100 s a
69 // `warp_speed`-style multiply would move the phase fifty seconds in one
70 // frame. z: palette_contour_style (integral, rounded CPU-side),
71 // w: palette_contour_ink (ADR-0197)
72 e: vec4<f32>,
73}
74
75@group(0) @binding(0) var<uniform> params: Params;
76// The gradient LUTs sit in their own bind group (group 1), so this pipeline's
77// layout stays distinct from the screen-space kaleidoscope's single 3-entry
78// [uniform, texture, sampler] group — two byte-identical layouts mis-render when
79// they coexist on the DX12 WARP software adapter (the same quirk the shared line
80// renderer and the lazy feedback scenes work around; a WARP reading, unverified
81// on lavapipe as of 2026-10-06). Two LUTs (A/B) for the `palette_mix`
82// crossfade; one shared sampler.
83@group(1) @binding(0) var lut_a: texture_2d<f32>;
84@group(1) @binding(1) var lut_b: texture_2d<f32>;
85@group(1) @binding(2) var lut_samp: sampler;
86
87// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim):
88// scale chroma around Rec. 601 luma. 1.0 unchanged, 0.0 grayscale.
89fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
90 let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
91 return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
92}
93
94// Shared `palette_steps` (mirrors core/src/render/palette.rs::band_coord
95// verbatim, ADR-0078): snap the palette coordinate to a band centre before the
96// LUT read. Below 1.5 steps it is the exact identity, not a one-band degenerate.
97fn band_coord(t: f32, steps: f32) -> f32 {
98 if (steps < 1.5) {
99 return t;
100 }
101 return (floor(t * steps) + 0.5) / steps;
102}
103
104// Shared `palette_contour` (ADR-0078 / ADR-0133; the WGSL is the implementation,
105// copied verbatim at each fragment-stage site — palette.rs has no CPU
106// counterpart to be canonical, since `fwidth` exists only here).
107//
108// Darkens within one PIXEL of a band edge, so the line has the same weight where
109// the field is shallow and where it is steep — AND ONLY WHERE THE INK ACTUALLY
110// CHANGES (ADR-0133). It samples the two band centres either side of the nearest
111// edge and returns unchanged when they resolve to the same colour within half a
112// code value, which is below the LUT's own 8-bit quantization. On a smooth
113// palette two distinct centres always differ by at least one code value, so
114// every edge draws exactly as it did at any `palette_steps`; inside a plateau
115// the LUT is literally constant and the samples are bit-equal, so the line
116// vanishes there and survives at the run boundaries. One rule, both behaviours,
117// no new parameter.
118//
119// The two LUTs, the sampler and `palette_mix` are EXPLICIT parameters rather
120// than module-scope globals this happens to find: all six sites name them the
121// same today, so implicit capture would compile — and would silently bind the
122// shared function to whatever a future site called its textures.
123//
124// `textureSampleLevel`, not `textureSample`: the LUT has one mip, and an
125// explicit LOD keeps these reads free of the uniformity requirement that a
126// sample after a conditional return would otherwise carry.
127//
128// **What the line is drawn in is `style`** (ADR-0197): `0` the soft darkening
129// above, `1` a hard darkening over the same footprint, `2` a soft line in the
130// palette's own colour at `ink_t` and `3` a hard one. `style` arrives rounded to
131// a whole number from the CPU (`palette::band_contour_style`), so the equality
132// comparisons below are exact. The `style < 0.5` arm is the expression that
133// shipped before the other three existed, which is what keeps every golden still.
134fn band_contour_ink(
135 col: vec3<f32>,
136 t: f32,
137 steps: f32,
138 amount: f32,
139 style: f32,
140 ink_t: f32,
141 lut_a: texture_2d<f32>,
142 lut_b: texture_2d<f32>,
143 lut_samp: sampler,
144 mix_ab: f32,
145) -> vec3<f32> {
146 let f = t * steps;
147 let w = max(fwidth(f), 1e-5);
148 if (steps < 1.5 || amount <= 0.0) {
149 return col;
150 }
151 let n = round(f);
152 let m = clamp(mix_ab, 0.0, 1.0);
153 let lo = mix(
154 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
155 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
156 m
157 );
158 let hi = mix(
159 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
160 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
161 m
162 );
163 if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
164 return col;
165 }
166 let d = min(fract(f), 1.0 - fract(f));
167 if (style < 0.5) {
168 return col * (1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d)));
169 }
170 let hard = style == 1.0 || style == 3.0;
171 let cover = select(1.0 - smoothstep(0.0, w, d), f32(d < w), hard);
172 let ink_lut = mix(
173 textureSampleLevel(lut_a, lut_samp, vec2<f32>(ink_t, 0.5), 0.0).rgb,
174 textureSampleLevel(lut_b, lut_samp, vec2<f32>(ink_t, 0.5), 0.0).rgb,
175 m
176 );
177 let ink = select(vec3<f32>(0.0), ink_lut, style >= 2.0);
178 return mix(col, ink, clamp(amount, 0.0, 1.0) * cover);
179}
180
181@fragment
182fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
183 let t = params.a.x;
184 let aspect = params.a.y;
185 let warp = params.a.z;
186 let hue = params.a.w;
187 let zoom = params.b.x;
188 let glow = params.b.y;
189 let flash = params.b.z;
190 let color_span = params.b.w;
191 let pan = params.c.xy;
192 let color_center = params.c.z;
193 let saturation = params.c.w;
194 let palette_mix = params.d.x;
195 let palette_steps = params.d.z;
196 let palette_contour = params.d.w;
197 let fold_phase = params.e.x;
198 let field_phase = params.e.y;
199 let palette_contour_style = params.e.z;
200 let palette_contour_ink = params.e.w;
201
202 var uv = in.ndc;
203 uv.x = uv.x * aspect;
204
205 // Iterated sine-fold domain warp, scaled by zoom and folded by warp; `pan`
206 // slides the sampled field window (the shared ViewTransform, ADR-0018). The
207 // vignette below stays screen-anchored (uses unshifted `uv`).
208 var p = uv * zoom + pan;
209 // The fold's two rates keep their designed 0.7 : 0.6 quadrature ratio; what
210 // `fold_speed` scales is the phase they share, so slowing the fold does not
211 // flatten it the way `warp` does (ADR-0132).
212 for (var i = 0; i < 5; i = i + 1) {
213 let fi = f32(i);
214 p = p + warp * vec2<f32>(
215 sin(p.y * 1.5 + fold_phase * 0.7 + fi),
216 cos(p.x * 1.5 - fold_phase * 0.6 + fi)
217 ) / (fi + 1.0);
218 }
219
220 let field = 0.5 + 0.5 * sin(p.x + p.y + field_phase * 0.5);
221 // Field level indexes the gradient LUT: `color_span` sets the spanned range
222 // (was a fixed 0.6), `color_center`/`hue` slide the window. Linear-filtered,
223 // repeat-addressed (a hue rotation wraps like the cosine wheel).
224 let coord = field * color_span + color_center + hue;
225 // Hard bands, then the contour drawn from the SAME coordinate (ADR-0078), so
226 // the dark line follows the field's iso-lines and reads as structure rather
227 // than as an outline of the picture's brightness.
228 let banded = band_coord(coord, palette_steps);
229 // Sample both palettes and crossfade by `palette_mix` (0 = A, 1 = B). When a
230 // preset declares no [palette_b] the two LUTs are identical, so mix is a no-op.
231 let ca = textureSample(lut_a, lut_samp, vec2<f32>(banded, 0.5)).rgb;
232 let cb = textureSample(lut_b, lut_samp, vec2<f32>(banded, 0.5)).rgb;
233 var col = mix(ca, cb, clamp(palette_mix, 0.0, 1.0));
234 col = band_contour_ink(
235 col, coord, palette_steps, palette_contour, palette_contour_style,
236 palette_contour_ink, lut_a, lut_b, lut_samp, palette_mix
237 );
238 col = apply_saturation(col, saturation);
239
240 let r = length(uv);
241 col = col * (glow * (1.0 - 0.25 * r));
242 col = col + vec3<f32>(flash * 0.12);
243
244 // Alpha is `occlude` (`params.d.y`). The field covers every pixel, which is the
245 // coverage it honestly has (ADR-0056), and `occlude` scales how much backdrop
246 // survives underneath it (ADR-0085): the fold resolves `field + bg * (1 -
247 // occlude)`, so at 1 the field replaces the backdrop and at 0 the backdrop adds
248 // through at full strength. The renderer hands a value other than a literal 1.0
249 // only when the scene draws straight onto the destination — with a post stage
250 // routed, or the `over` junction live, the scene renders into a scratch and the
251 // chain's last fold owns the seam instead.
252 return vec4<f32>(col, params.d.y);
253}
254"#;
255
256#[repr(C)]
257#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
258struct Params {
259 a: [f32; 4],
260 b: [f32; 4],
261 c: [f32; 4],
262 d: [f32; 4],
263 e: [f32; 4],
264}
265
266pub struct FragmentFieldScene {
268 gpu: gpu::FullscreenScene,
273 time: f32,
275 dt: f32,
279 fold_phase: Phase,
288 field_phase: Phase,
289 field_speed: f32,
291 fold_speed: f32,
292 warp: f32,
293 colour: common::PaletteParams,
295 pan: common::PanParams,
298 zoom: f32,
299 glow: f32,
300 flash: f32,
301 color_span: f32,
302 color_center: f32,
303 occlude: f32,
308}
309
310impl FragmentFieldScene {
311 pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
313 let shader = gpu::fullscreen_shader(
314 device,
315 "fragment-field-shader",
316 gpu::FULLSCREEN_VS_NDC,
317 SHADER,
318 );
319 let parts =
320 gpu::FullscreenParts::new(device, "fragment-field", std::mem::size_of::<Params>());
321 let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
322 label: Some("fragment-field-uniform-layout"),
323 entries: &[gpu::uniform(0, wgpu::ShaderStages::FRAGMENT)],
324 });
325 let lut_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
330 label: Some("fragment-field-lut-layout"),
331 entries: &[
332 gpu::texture(0, true),
333 gpu::texture(1, true),
334 gpu::sampler(2),
335 ],
336 });
337 let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
338 label: Some("fragment-field-uniform-bg"),
339 layout: &uniform_layout,
340 entries: &[wgpu::BindGroupEntry {
341 binding: 0,
342 resource: parts.uniforms().as_entire_binding(),
343 }],
344 });
345 let lut_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
346 label: Some("fragment-field-lut-bg"),
347 layout: &lut_layout,
348 entries: &parts.luts().bind_entries(0, 1, 2),
349 });
350
351 Self {
352 gpu: parts.finish(
353 device,
354 &shader,
355 &[&uniform_layout, &lut_layout],
356 uniform_bg,
357 Some(lut_bg),
358 surface_format,
359 wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
360 "fragment-field",
361 ),
362 time: 0.0,
363 dt: crate::render::scenes::FALLBACK_DT,
364 fold_phase: Phase::default(),
365 field_phase: Phase::default(),
366 field_speed: DEFAULT_FIELD_SPEED,
367 fold_speed: DEFAULT_FOLD_SPEED,
368 warp: DEFAULT_WARP,
369 colour: common::PaletteParams::new(DEFAULT_HUE, common::DEFAULT_BRIGHTNESS),
370 pan: common::PanParams::default(),
371 zoom: DEFAULT_ZOOM,
372 glow: DEFAULT_GLOW,
373 flash: DEFAULT_FLASH,
374 color_span: DEFAULT_COLOR_SPAN,
375 color_center: DEFAULT_COLOR_CENTER,
376 occlude: crate::render::post::DEFAULT_OCCLUDE,
377 }
378 }
379}
380
381pub const PARAMS: &[ParamSpec] = &[
387 ParamSpec {
388 name: "warp",
389 default: 0.4,
390 range: Some([0.0, 1.5]),
391 doc: "Amplitude of the domain fold; 0 flattens the field into plain bands.",
392 kind: ParamKind::Modal,
393 group: ParamGroup::Shape,
394 main: true,
395 },
396 ParamSpec {
397 name: "field_speed",
398 default: 1.0,
399 range: Some([0.0, 4.0]),
400 doc: "How fast the field itself drifts, as a multiple of its base rate.",
401 kind: ParamKind::Modal,
402 group: ParamGroup::Motion,
403 main: true,
404 },
405 ParamSpec {
406 name: "fold_speed",
407 default: 1.0,
408 range: Some([0.0, 4.0]),
409 doc: "How fast the fold turns, independently of the field's own drift.",
410 kind: ParamKind::Modal,
411 group: ParamGroup::Motion,
412 main: false,
413 },
414 crate::render::scenes::common::hue(DEFAULT_HUE),
415 crate::render::scenes::common::field_zoom(DEFAULT_ZOOM),
416 ParamSpec {
417 name: "glow",
418 default: 0.7,
419 range: Some([0.0, 2.0]),
420 doc: "Overall light the field emits, before the composite sees it.",
421 kind: ParamKind::Modal,
422 group: ParamGroup::Light,
423 main: true,
424 },
425 ParamSpec {
426 name: "flash",
427 default: 0.0,
428 range: Some([0.0, 1.0]),
429 doc: "Lifts the whole field toward white, for a beat-driven blink.",
430 kind: ParamKind::Modal,
431 group: ParamGroup::Light,
432 main: false,
433 },
434 crate::render::scenes::common::PAN_X,
435 crate::render::scenes::common::PAN_Y,
436 ParamSpec {
437 name: "color_span",
438 default: 0.6,
439 range: Some([0.0, 1.0]),
440 doc: "How much of the palette the field's range covers; 0 is one flat colour.",
441 kind: ParamKind::Modal,
442 group: ParamGroup::Colour,
443 main: false,
444 },
445 ParamSpec {
446 name: "color_center",
447 default: 0.0,
448 range: Some([-1.0, 1.0]),
449 doc: "Shifts which part of the field's range lands in the middle of the palette.",
450 kind: ParamKind::Modal,
451 group: ParamGroup::Colour,
452 main: false,
453 },
454 crate::render::scenes::common::SATURATION,
455 crate::render::scenes::common::PALETTE_MIX,
456 crate::render::scenes::common::PALETTE_STEPS,
457 crate::render::scenes::common::PALETTE_CONTOUR,
458 crate::render::scenes::common::PALETTE_CONTOUR_STYLE,
459 crate::render::scenes::common::PALETTE_CONTOUR_INK,
460];
461
462impl Scene for FragmentFieldScene {
463 fn name(&self) -> &'static str {
464 "fragment field"
465 }
466
467 fn set_time(&mut self, time: f32) {
468 self.time = time;
469 }
470
471 fn advance(&mut self, dt: f32) {
472 self.dt = dt;
475 }
476
477 fn set_occlude(&mut self, occlude: f32) {
478 self.occlude = occlude;
479 }
480
481 fn set_palette(&mut self, palette: &Palette) {
482 self.gpu.set_palette(palette);
485 }
486
487 fn reset_params(&mut self) {
488 self.field_speed = DEFAULT_FIELD_SPEED;
492 self.fold_speed = DEFAULT_FOLD_SPEED;
493 self.warp = DEFAULT_WARP;
494 self.colour.reset();
495 self.pan.reset();
496 self.zoom = DEFAULT_ZOOM;
497 self.glow = DEFAULT_GLOW;
498 self.flash = DEFAULT_FLASH;
499 self.color_span = DEFAULT_COLOR_SPAN;
500 self.color_center = DEFAULT_COLOR_CENTER;
501 }
502
503 fn set_param(&mut self, name: &str, value: f32) {
504 if self.colour.set(name, value) || self.pan.set(name, value) {
507 return;
508 }
509 match name {
510 "warp" => self.warp = value,
511 "field_speed" => self.field_speed = value,
512 "fold_speed" => self.fold_speed = value,
513 "zoom" => self.zoom = value,
514 "glow" => self.glow = value,
515 "flash" => self.flash = value,
516 "color_span" => self.color_span = value,
517 "color_center" => self.color_center = value,
518 _ => {}
519 }
520 }
521
522 fn update(&mut self, _frame: &AnalysisFrame) {
523 self.fold_phase.step(self.fold_speed, self.dt);
528 self.field_phase.step(self.field_speed, self.dt);
529 }
530
531 fn render(
532 &mut self,
533 queue: &wgpu::Queue,
534 encoder: &mut wgpu::CommandEncoder,
535 view: &wgpu::TextureView,
536 aspect: f32,
537 ) {
538 self.gpu.flush_palette(queue);
541
542 let params = Params {
543 a: [self.time, aspect.max(0.1), self.warp, self.colour.hue],
544 b: [self.zoom, self.glow, self.flash, self.color_span],
545 c: [
546 self.pan.x,
547 self.pan.y,
548 self.color_center,
549 self.colour.saturation,
550 ],
551 d: [
552 self.colour.mix,
553 self.occlude,
554 palette::band_steps(self.colour.steps),
555 palette::band_contour(self.colour.contour),
556 ],
557 e: [
558 self.fold_phase.get(),
559 self.field_phase.get(),
560 palette::band_contour_style(self.colour.contour_style),
561 self.colour.contour_ink,
562 ],
563 };
564 self.gpu.write_uniform(queue, ¶ms);
565
566 self.gpu
571 .draw(encoder, "fragment-field-pass", view, wgpu::LoadOp::Load);
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
585 fn a_rate_change_advances_the_phase_by_rate_times_dt_at_any_elapsed_time() {
586 let dt = 1.0 / 60.0;
587 let (mut fold, mut field) = (Phase::default(), Phase::default());
588 for _ in 0..6_000 {
590 fold.step(1.0, dt);
591 field.step(1.0, dt);
592 }
593 let elapsed = fold.get();
594 assert!(
595 elapsed > 99.0,
596 "the fixture must be far from t = 0: {elapsed}"
597 );
598
599 let before = fold.get();
601 fold.step(1.5, dt);
602 let fold_step = fold.get() - before;
603 assert!(
604 (fold_step - 1.5 * dt).abs() < 1e-4,
605 "the fold advanced {fold_step}, not {} — scaling with elapsed time is the defect",
606 1.5 * dt
607 );
608
609 let field_before = field.get();
611 field.step(0.25, dt);
612 assert!(
615 (field.get() - field_before - 0.25 * dt).abs() < 1e-4,
616 "the field phase must follow field_speed alone: moved {}",
617 field.get() - field_before
618 );
619 }
620
621 #[test]
626 fn a_constant_rate_integrates_to_rate_times_elapsed_time() {
627 let dt = 1.0 / 60.0;
628 for rate in [1.0f32, 0.4, 2.5] {
629 let mut phase = Phase::default();
630 let mut clock = 0.0f32;
631 for _ in 0..600 {
632 phase.step(rate, dt);
633 clock += dt;
634 }
635 assert!(
636 (phase.get() - rate * clock).abs() < 1e-3,
637 "rate {rate}: integrated {} against {}",
638 phase.get(),
639 rate * clock
640 );
641 }
642 }
643
644 #[test]
648 fn the_default_rates_make_the_phase_the_clock() {
649 assert_eq!(DEFAULT_FIELD_SPEED, 1.0);
650 assert_eq!(DEFAULT_FOLD_SPEED, 1.0);
651
652 let dt = crate::render::scenes::FALLBACK_DT;
653 let (mut fold, mut field) = (Phase::default(), Phase::default());
654 let mut clock = 0.0f32;
655 for _ in 0..240 {
656 fold.step(DEFAULT_FOLD_SPEED, dt);
657 field.step(DEFAULT_FIELD_SPEED, dt);
658 clock += dt;
659 }
660 assert_eq!(
661 fold.get(),
662 clock,
663 "at rate 1.0 the accumulation must be bit-identical to the clock's"
664 );
665 assert_eq!(field.get(), clock);
666 }
667}