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::{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, w: unused.
71 e: vec4<f32>,
72}
73
74@group(0) @binding(0) var<uniform> params: Params;
75// The gradient LUTs sit in their own bind group (group 1), so this pipeline's
76// layout stays distinct from the screen-space kaleidoscope's single 3-entry
77// [uniform, texture, sampler] group — two byte-identical layouts mis-render when
78// they coexist on the DX12 WARP software adapter (the same quirk the shared line
79// renderer and the lazy feedback scenes work around). Two LUTs (A/B) for the
80// `palette_mix` crossfade; one shared sampler.
81@group(1) @binding(0) var lut_a: texture_2d<f32>;
82@group(1) @binding(1) var lut_b: texture_2d<f32>;
83@group(1) @binding(2) var lut_samp: sampler;
84
85// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim):
86// scale chroma around Rec. 601 luma. 1.0 unchanged, 0.0 grayscale.
87fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
88 let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
89 return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
90}
91
92// Shared `palette_steps` (mirrors core/src/render/palette.rs::band_coord
93// verbatim, ADR-0078): snap the palette coordinate to a band centre before the
94// LUT read. Below 1.5 steps it is the exact identity, not a one-band degenerate.
95fn band_coord(t: f32, steps: f32) -> f32 {
96 if (steps < 1.5) {
97 return t;
98 }
99 return (floor(t * steps) + 0.5) / steps;
100}
101
102// Shared `palette_contour` (ADR-0078 / ADR-0133; the WGSL is the implementation,
103// copied verbatim at each fragment-stage site — palette.rs has no CPU
104// counterpart to be canonical, since `fwidth` exists only here).
105//
106// Darkens within one PIXEL of a band edge, so the line has the same weight where
107// the field is shallow and where it is steep — AND ONLY WHERE THE INK ACTUALLY
108// CHANGES (ADR-0133). It samples the two band centres either side of the nearest
109// edge and returns unchanged when they resolve to the same colour within half a
110// code value, which is below the LUT's own 8-bit quantization. On a smooth
111// palette two distinct centres always differ by at least one code value, so
112// every edge draws exactly as it did at any `palette_steps`; inside a plateau
113// the LUT is literally constant and the samples are bit-equal, so the line
114// vanishes there and survives at the run boundaries. One rule, both behaviours,
115// no new parameter.
116//
117// The two LUTs, the sampler and `palette_mix` are EXPLICIT parameters rather
118// than module-scope globals this happens to find: all four sites name them the
119// same today, so implicit capture would compile — and would silently bind the
120// shared function to whatever a future site called its textures.
121//
122// `textureSampleLevel`, not `textureSample`: the LUT has one mip, and an
123// explicit LOD keeps these reads free of the uniformity requirement that a
124// sample after a conditional return would otherwise carry.
125fn band_contour(
126 t: f32,
127 steps: f32,
128 amount: f32,
129 lut_a: texture_2d<f32>,
130 lut_b: texture_2d<f32>,
131 lut_samp: sampler,
132 mix_ab: f32,
133) -> f32 {
134 let f = t * steps;
135 let w = max(fwidth(f), 1e-5);
136 if (steps < 1.5 || amount <= 0.0) {
137 return 1.0;
138 }
139 let n = round(f);
140 let m = clamp(mix_ab, 0.0, 1.0);
141 let lo = mix(
142 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
143 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
144 m
145 );
146 let hi = mix(
147 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
148 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
149 m
150 );
151 if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
152 return 1.0;
153 }
154 let d = min(fract(f), 1.0 - fract(f));
155 return 1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d));
156}
157
158@fragment
159fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
160 let t = params.a.x;
161 let aspect = params.a.y;
162 let warp = params.a.z;
163 let hue = params.a.w;
164 let zoom = params.b.x;
165 let glow = params.b.y;
166 let flash = params.b.z;
167 let color_span = params.b.w;
168 let pan = params.c.xy;
169 let color_center = params.c.z;
170 let saturation = params.c.w;
171 let palette_mix = params.d.x;
172 let palette_steps = params.d.z;
173 let palette_contour = params.d.w;
174 let fold_phase = params.e.x;
175 let field_phase = params.e.y;
176
177 var uv = in.ndc;
178 uv.x = uv.x * aspect;
179
180 // Iterated sine-fold domain warp, scaled by zoom and folded by warp; `pan`
181 // slides the sampled field window (the shared ViewTransform, ADR-0018). The
182 // vignette below stays screen-anchored (uses unshifted `uv`).
183 var p = uv * zoom + pan;
184 // The fold's two rates keep their designed 0.7 : 0.6 quadrature ratio; what
185 // `fold_speed` scales is the phase they share, so slowing the fold does not
186 // flatten it the way `warp` does (ADR-0132).
187 for (var i = 0; i < 5; i = i + 1) {
188 let fi = f32(i);
189 p = p + warp * vec2<f32>(
190 sin(p.y * 1.5 + fold_phase * 0.7 + fi),
191 cos(p.x * 1.5 - fold_phase * 0.6 + fi)
192 ) / (fi + 1.0);
193 }
194
195 let field = 0.5 + 0.5 * sin(p.x + p.y + field_phase * 0.5);
196 // Field level indexes the gradient LUT: `color_span` sets the spanned range
197 // (was a fixed 0.6), `color_center`/`hue` slide the window. Linear-filtered,
198 // repeat-addressed (a hue rotation wraps like the cosine wheel).
199 let coord = field * color_span + color_center + hue;
200 // Hard bands, then the contour drawn from the SAME coordinate (ADR-0078), so
201 // the dark line follows the field's iso-lines and reads as structure rather
202 // than as an outline of the picture's brightness.
203 let banded = band_coord(coord, palette_steps);
204 // Sample both palettes and crossfade by `palette_mix` (0 = A, 1 = B). When a
205 // preset declares no [palette_b] the two LUTs are identical, so mix is a no-op.
206 let ca = textureSample(lut_a, lut_samp, vec2<f32>(banded, 0.5)).rgb;
207 let cb = textureSample(lut_b, lut_samp, vec2<f32>(banded, 0.5)).rgb;
208 var col = mix(ca, cb, clamp(palette_mix, 0.0, 1.0));
209 col = col * band_contour(
210 coord, palette_steps, palette_contour, lut_a, lut_b, lut_samp, palette_mix
211 );
212 col = apply_saturation(col, saturation);
213
214 let r = length(uv);
215 col = col * (glow * (1.0 - 0.25 * r));
216 col = col + vec3<f32>(flash * 0.12);
217
218 // Alpha 1.0: this field covers every pixel, which is the coverage it honestly
219 // has (ADR-0056). `occlude` scales how much of that the backdrop underneath
220 // resolves against (ADR-0085) — at 0 the sky adds through an opaque field.
221 // Reached only when no post stage is active; the chain owns the seam otherwise
222 // and the renderer hands a literal 1.0 here.
223 return vec4<f32>(col, params.d.y);
224}
225"#;
226
227#[repr(C)]
228#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
229struct Params {
230 a: [f32; 4],
231 b: [f32; 4],
232 c: [f32; 4],
233 d: [f32; 4],
234 e: [f32; 4],
235}
236
237pub struct FragmentFieldScene {
239 gpu: gpu::FullscreenScene,
244 time: f32,
246 dt: f32,
250 fold_phase: Phase,
259 field_phase: Phase,
260 field_speed: f32,
262 fold_speed: f32,
263 warp: f32,
264 colour: common::PaletteParams,
266 pan: common::PanParams,
269 zoom: f32,
270 glow: f32,
271 flash: f32,
272 color_span: f32,
273 color_center: f32,
274 occlude: f32,
279}
280
281impl FragmentFieldScene {
282 pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
284 let shader = gpu::fullscreen_shader(
285 device,
286 "fragment-field-shader",
287 gpu::FULLSCREEN_VS_NDC,
288 SHADER,
289 );
290 let parts =
291 gpu::FullscreenParts::new(device, "fragment-field", std::mem::size_of::<Params>());
292 let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
293 label: Some("fragment-field-uniform-layout"),
294 entries: &[gpu::uniform(0, wgpu::ShaderStages::FRAGMENT)],
295 });
296 let lut_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
300 label: Some("fragment-field-lut-layout"),
301 entries: &[
302 gpu::texture(0, true),
303 gpu::texture(1, true),
304 gpu::sampler(2),
305 ],
306 });
307 let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
308 label: Some("fragment-field-uniform-bg"),
309 layout: &uniform_layout,
310 entries: &[wgpu::BindGroupEntry {
311 binding: 0,
312 resource: parts.uniforms().as_entire_binding(),
313 }],
314 });
315 let lut_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
316 label: Some("fragment-field-lut-bg"),
317 layout: &lut_layout,
318 entries: &parts.luts().bind_entries(0, 1, 2),
319 });
320
321 Self {
322 gpu: parts.finish(
323 device,
324 &shader,
325 &[&uniform_layout, &lut_layout],
326 uniform_bg,
327 Some(lut_bg),
328 surface_format,
329 wgpu::BlendState::REPLACE,
330 "fragment-field",
331 ),
332 time: 0.0,
333 dt: crate::render::scenes::FALLBACK_DT,
334 fold_phase: Phase::default(),
335 field_phase: Phase::default(),
336 field_speed: DEFAULT_FIELD_SPEED,
337 fold_speed: DEFAULT_FOLD_SPEED,
338 warp: DEFAULT_WARP,
339 colour: common::PaletteParams::new(DEFAULT_HUE, common::DEFAULT_BRIGHTNESS),
340 pan: common::PanParams::default(),
341 zoom: DEFAULT_ZOOM,
342 glow: DEFAULT_GLOW,
343 flash: DEFAULT_FLASH,
344 color_span: DEFAULT_COLOR_SPAN,
345 color_center: DEFAULT_COLOR_CENTER,
346 occlude: crate::render::post::DEFAULT_OCCLUDE,
347 }
348 }
349}
350
351pub const PARAMS: &[ParamSpec] = &[
357 ParamSpec {
358 name: "warp",
359 default: 0.4,
360 range: Some([0.0, 1.5]),
361 doc: "Amplitude of the domain fold; 0 flattens the field into plain bands.",
362 kind: ParamKind::Modal,
363 },
364 ParamSpec {
365 name: "field_speed",
366 default: 1.0,
367 range: Some([0.0, 4.0]),
368 doc: "How fast the field itself drifts, as a multiple of its base rate.",
369 kind: ParamKind::Modal,
370 },
371 ParamSpec {
372 name: "fold_speed",
373 default: 1.0,
374 range: Some([0.0, 4.0]),
375 doc: "How fast the fold turns, independently of the field's own drift.",
376 kind: ParamKind::Modal,
377 },
378 crate::render::scenes::common::hue(DEFAULT_HUE),
379 crate::render::scenes::common::zoom(DEFAULT_ZOOM),
380 ParamSpec {
381 name: "glow",
382 default: 0.7,
383 range: Some([0.0, 2.0]),
384 doc: "Overall light the field emits, before the composite sees it.",
385 kind: ParamKind::Modal,
386 },
387 ParamSpec {
388 name: "flash",
389 default: 0.0,
390 range: Some([0.0, 1.0]),
391 doc: "Lifts the whole field toward white, for a beat-driven blink.",
392 kind: ParamKind::Modal,
393 },
394 crate::render::scenes::common::PAN_X,
395 crate::render::scenes::common::PAN_Y,
396 ParamSpec {
397 name: "color_span",
398 default: 0.6,
399 range: Some([0.0, 1.0]),
400 doc: "How much of the palette the field's range covers; 0 is one flat colour.",
401 kind: ParamKind::Modal,
402 },
403 ParamSpec {
404 name: "color_center",
405 default: 0.0,
406 range: Some([-1.0, 1.0]),
407 doc: "Shifts which part of the field's range lands in the middle of the palette.",
408 kind: ParamKind::Modal,
409 },
410 crate::render::scenes::common::SATURATION,
411 crate::render::scenes::common::PALETTE_MIX,
412 crate::render::scenes::common::PALETTE_STEPS,
413 crate::render::scenes::common::PALETTE_CONTOUR,
414];
415
416impl Scene for FragmentFieldScene {
417 fn name(&self) -> &'static str {
418 "fragment field"
419 }
420
421 fn set_time(&mut self, time: f32) {
422 self.time = time;
423 }
424
425 fn advance(&mut self, dt: f32) {
426 self.dt = dt;
429 }
430
431 fn set_occlude(&mut self, occlude: f32) {
432 self.occlude = occlude;
433 }
434
435 fn set_palette(&mut self, palette: &Palette) {
436 self.gpu.set_palette(palette);
439 }
440
441 fn reset_params(&mut self) {
442 self.field_speed = DEFAULT_FIELD_SPEED;
446 self.fold_speed = DEFAULT_FOLD_SPEED;
447 self.warp = DEFAULT_WARP;
448 self.colour.reset();
449 self.pan.reset();
450 self.zoom = DEFAULT_ZOOM;
451 self.glow = DEFAULT_GLOW;
452 self.flash = DEFAULT_FLASH;
453 self.color_span = DEFAULT_COLOR_SPAN;
454 self.color_center = DEFAULT_COLOR_CENTER;
455 }
456
457 fn set_param(&mut self, name: &str, value: f32) {
458 if self.colour.set(name, value) || self.pan.set(name, value) {
461 return;
462 }
463 match name {
464 "warp" => self.warp = value,
465 "field_speed" => self.field_speed = value,
466 "fold_speed" => self.fold_speed = value,
467 "zoom" => self.zoom = value,
468 "glow" => self.glow = value,
469 "flash" => self.flash = value,
470 "color_span" => self.color_span = value,
471 "color_center" => self.color_center = value,
472 _ => {}
473 }
474 }
475
476 fn update(&mut self, _frame: &AnalysisFrame) {
477 self.fold_phase.step(self.fold_speed, self.dt);
482 self.field_phase.step(self.field_speed, self.dt);
483 }
484
485 fn render(
486 &mut self,
487 queue: &wgpu::Queue,
488 encoder: &mut wgpu::CommandEncoder,
489 view: &wgpu::TextureView,
490 aspect: f32,
491 ) {
492 self.gpu.flush_palette(queue);
495
496 let params = Params {
497 a: [self.time, aspect.max(0.1), self.warp, self.colour.hue],
498 b: [self.zoom, self.glow, self.flash, self.color_span],
499 c: [
500 self.pan.x,
501 self.pan.y,
502 self.color_center,
503 self.colour.saturation,
504 ],
505 d: [
506 self.colour.mix,
507 self.occlude,
508 palette::band_steps(self.colour.steps),
509 palette::band_contour(self.colour.contour),
510 ],
511 e: [self.fold_phase.get(), self.field_phase.get(), 0.0, 0.0],
512 };
513 self.gpu.write_uniform(queue, ¶ms);
514
515 self.gpu
518 .draw(encoder, "fragment-field-pass", view, wgpu::LoadOp::Load);
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
532 fn a_rate_change_advances_the_phase_by_rate_times_dt_at_any_elapsed_time() {
533 let dt = 1.0 / 60.0;
534 let (mut fold, mut field) = (Phase::default(), Phase::default());
535 for _ in 0..6_000 {
537 fold.step(1.0, dt);
538 field.step(1.0, dt);
539 }
540 let elapsed = fold.get();
541 assert!(
542 elapsed > 99.0,
543 "the fixture must be far from t = 0: {elapsed}"
544 );
545
546 let before = fold.get();
548 fold.step(1.5, dt);
549 let fold_step = fold.get() - before;
550 assert!(
551 (fold_step - 1.5 * dt).abs() < 1e-4,
552 "the fold advanced {fold_step}, not {} — scaling with elapsed time is the defect",
553 1.5 * dt
554 );
555
556 let field_before = field.get();
558 field.step(0.25, dt);
559 assert!(
562 (field.get() - field_before - 0.25 * dt).abs() < 1e-4,
563 "the field phase must follow field_speed alone: moved {}",
564 field.get() - field_before
565 );
566 }
567
568 #[test]
573 fn a_constant_rate_integrates_to_rate_times_elapsed_time() {
574 let dt = 1.0 / 60.0;
575 for rate in [1.0f32, 0.4, 2.5] {
576 let mut phase = Phase::default();
577 let mut clock = 0.0f32;
578 for _ in 0..600 {
579 phase.step(rate, dt);
580 clock += dt;
581 }
582 assert!(
583 (phase.get() - rate * clock).abs() < 1e-3,
584 "rate {rate}: integrated {} against {}",
585 phase.get(),
586 rate * clock
587 );
588 }
589 }
590
591 #[test]
595 fn the_default_rates_make_the_phase_the_clock() {
596 assert_eq!(DEFAULT_FIELD_SPEED, 1.0);
597 assert_eq!(DEFAULT_FOLD_SPEED, 1.0);
598
599 let dt = crate::render::scenes::FALLBACK_DT;
600 let (mut fold, mut field) = (Phase::default(), Phase::default());
601 let mut clock = 0.0f32;
602 for _ in 0..240 {
603 fold.step(DEFAULT_FOLD_SPEED, dt);
604 field.step(DEFAULT_FIELD_SPEED, dt);
605 clock += dt;
606 }
607 assert_eq!(
608 fold.get(),
609 clock,
610 "at rate 1.0 the accumulation must be bit-identical to the clock's"
611 );
612 assert_eq!(field.get(), clock);
613 }
614}