facett-core 0.1.18

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
//! **TAA — temporal anti-aliasing: Halton jitter + a velocity-reprojecting resolve**
//! (GFX_V2 §5, item 7).
//!
//! MSAA does not fix thin vector lines: a 1 px road at 4× MSAA still has only four
//! coverage steps, and it shimmers under camera rotation because *which* subsamples it
//! covers changes discontinuously. TAA instead moves the sample pattern in *time* — the
//! projection is offset by a sub-pixel [`jitter_px`] each frame and the frames are
//! accumulated, so the effective sample count grows with frame count and the edge
//! converges on true coverage.
//!
//! Two halves, deliberately separated:
//!
//! * **The jitter** ([`halton`], [`jitter_px`], [`TAA_PHASES`]) is pure CPU arithmetic
//!   and therefore testable without a device. It is also the part a *caller* must apply
//!   — the resolve cannot invent it, because the jitter has to be baked into where the
//!   geometry landed. So [`TaaPass`] owns the frame counter and hands the jitter out
//!   ([`TaaPass::jitter_px`]), rather than the host and the pass each keeping a count
//!   that can drift apart.
//! * **The resolve** ([`TAA_WGSL`]) is one fullscreen pass: 3×3 neighbourhood colour
//!   box, velocity reprojection, history clamped into the box, blend.
//!
//! **The claim, stated so it can fail.** Accumulating N *jittered* frames must reduce
//! the aliasing of an edge, measured — not asserted. The device test
//! (`facett-core/tests/gpu_taa.rs`) renders a slightly tilted hard edge, fits a line to
//! the per-row coverage, and takes the RMS residual: an aliased edge's coverage is a
//! **staircase** (it can only step by whole pixels), a properly antialiased edge's is
//! **linear**. The residual is the aliasing, in luma units.
//!
//! **And why that alone would be hollow.** A plain blur also flattens that staircase.
//! So the same test measures the **transition width** — how many pixels per row sit
//! strictly between background and foreground — and requires it to stay at about one
//! pixel. Convergence with a sharp edge; a blur fails the second half, a dead pass
//! fails the first. The mutation that proves the pair is not vacuous is *zeroing the
//! jitter*: the accumulation then averages N identical aliased frames and the residual
//! does not move.

use wgpu::TextureFormat;

/// The TAA resolve shader. Not prelude-composed: it is a pure screen-space pass and
/// uses none of `common.wgsl`'s geometry maths.
pub use crate::render::wgsl::TAA_WGSL;

/// Length of the jitter cycle. The Halton(2, 3) pair is low-discrepancy at any length;
/// 8 is the usual choice because it is short enough that a *moving* camera revisits the
/// pattern before the history has decayed, and long enough that a static camera
/// resolves an edge to well under a quarter pixel.
pub const TAA_PHASES: u32 = 8;

/// Default cap on the history weight. Below 1 so the resolve keeps decaying into an
/// exponential moving average once the ramp is over — an unbounded running mean would
/// never respond to a scene change at all.
pub const TAA_FEEDBACK_CAP: f32 = 0.97;

/// The **radical-inverse (van der Corput) digit reversal** of `index` in `base` — the
/// Halton sequence's term. Returns a value in `[0, 1)`.
///
/// `index == 0` is `0.0` for every base, which is why callers offset by one: phase 0 of
/// a jitter sequence that starts at index 0 is *no offset at all*, i.e. a frame that
/// does not participate in the antialiasing.
#[must_use]
pub fn halton(index: u32, base: u32) -> f32 {
    if base < 2 {
        return 0.0;
    }
    let mut f = 1.0_f64;
    let mut r = 0.0_f64;
    let mut i = index;
    let b = f64::from(base);
    while i > 0 {
        f /= b;
        r += f * f64::from(i % base);
        i /= base;
    }
    r as f32
}

/// This frame's sub-pixel projection offset, in **pixels**, each component in
/// `[-0.5, 0.5)`.
///
/// Halton(2, 3), indexed by `frame % TAA_PHASES + 1` — the `+ 1` skips index 0, whose
/// offset is exactly `(-0.5, -0.5)` in this centring and, more importantly, whose
/// unshifted variant would be `(0, 0)`.
///
/// A caller applies it to its projection (a half-pixel NDC shift, or by offsetting
/// screen-space vertex positions). Applying it is not optional: **the resolve cannot
/// antialias frames that were all rendered at the same sample position**, which is the
/// mutation the device test uses to prove it is measuring something.
#[must_use]
pub fn jitter_px(frame: u64) -> [f32; 2] {
    let i = (frame % u64::from(TAA_PHASES)) as u32 + 1;
    [halton(i, 2) - 0.5, halton(i, 3) - 0.5]
}

/// The history weight for `frame`, capped at `cap`.
///
/// `frame / (frame + 1)` makes the first frames a true **running mean** of the jitter
/// samples seen so far, which is what converges; a fixed feedback factor is an EMA over
/// a periodic sequence and oscillates at the sequence's period forever, leaving a
/// fraction of the aliasing in every frame. The ramp then saturates at `cap` so the
/// resolve stays responsive to a scene change.
#[must_use]
pub fn history_weight(frame: u64, cap: f32) -> f32 {
    if frame == 0 {
        return 0.0;
    }
    let ramp = frame as f32 / (frame as f32 + 1.0);
    ramp.min(cap)
}

#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
struct TaaUniform {
    res: [f32; 4],
    params: [f32; 4],
}

/// The TAA resolve lane: the pipeline, a ping-pong history pair, and the frame counter
/// that owns the jitter phase.
pub struct TaaPass {
    pipeline: wgpu::RenderPipeline,
    bgl: wgpu::BindGroupLayout,
    uniform: wgpu::Buffer,
    sampler: wgpu::Sampler,
    /// 1×1 RG16Float zero — bound when the caller has no motion vectors, so the shader
    /// keeps ONE path instead of branching on whether velocity exists.
    zero_velocity: wgpu::TextureView,
    history: Option<[wgpu::Texture; 2]>,
    history_views: Option<[wgpu::TextureView; 2]>,
    /// Index of the texture holding the *previous* resolve; `1 - cur` receives this one.
    cur: usize,
    format: TextureFormat,
    size: (u32, u32),
    frame: u64,
    feedback_cap: f32,
}

impl TaaPass {
    /// Build the resolve pipeline for a `format` history/output pair.
    #[must_use]
    pub fn new(device: &wgpu::Device, format: TextureFormat) -> Self {
        let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("l0_taa"),
            source: wgpu::ShaderSource::Wgsl(TAA_WGSL.into()),
        });
        let tex = |binding: u32| wgpu::BindGroupLayoutEntry {
            binding,
            visibility: wgpu::ShaderStages::FRAGMENT,
            ty: wgpu::BindingType::Texture {
                sample_type: wgpu::TextureSampleType::Float { filterable: true },
                view_dimension: wgpu::TextureViewDimension::D2,
                multisampled: false,
            },
            count: None,
        };
        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("l0_taa_bgl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                tex(1),
                tex(2),
                tex(3),
                wgpu::BindGroupLayoutEntry {
                    binding: 4,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
            ],
        });
        let pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("l0_taa_pll"),
            bind_group_layouts: &[Some(&bgl)],
            immediate_size: 0,
        });
        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("l0_taa_resolve"),
            layout: Some(&pll),
            vertex: wgpu::VertexState {
                module: &module,
                entry_point: Some("taa_vs"),
                compilation_options: Default::default(),
                buffers: &[],
            },
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                ..Default::default()
            },
            depth_stencil: None,
            multisample: crate::render::gpu::msaa_state(),
            fragment: Some(wgpu::FragmentState {
                module: &module,
                entry_point: Some("taa_resolve_fs"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format,
                    // The resolve IS the blend. Hardware blending on top would apply the
                    // history weight twice.
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            multiview_mask: None,
            cache: None,
        });
        let zero = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("l0_taa_zero_velocity"),
            size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
            mip_level_count: 1,
            sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
            dimension: wgpu::TextureDimension::D2,
            format: TextureFormat::Rg16Float,
            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
            view_formats: &[],
        });
        Self {
            pipeline,
            bgl,
            uniform: device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("l0_taa_uniform"),
                size: std::mem::size_of::<TaaUniform>() as u64,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            }),
            sampler: device.create_sampler(&wgpu::SamplerDescriptor {
                label: Some("l0_taa_sampler"),
                mag_filter: wgpu::FilterMode::Linear,
                min_filter: wgpu::FilterMode::Linear,
                address_mode_u: wgpu::AddressMode::ClampToEdge,
                address_mode_v: wgpu::AddressMode::ClampToEdge,
                ..Default::default()
            }),
            zero_velocity: zero.create_view(&Default::default()),
            history: None,
            history_views: None,
            cur: 0,
            format,
            size: (0, 0),
            frame: 0,
            feedback_cap: TAA_FEEDBACK_CAP,
        }
    }

    /// Override the history-weight ceiling (default [`TAA_FEEDBACK_CAP`]).
    #[must_use]
    pub fn with_feedback_cap(mut self, cap: f32) -> Self {
        self.feedback_cap = cap.clamp(0.0, 1.0);
        self
    }

    /// (Re)allocate the ping-pong history for `w × h`. Idempotent within a size.
    ///
    /// A resize **invalidates the history** — reprojecting a differently-sized previous
    /// frame would stretch it. Stated here rather than left to a caller to remember.
    pub fn ensure(&mut self, device: &wgpu::Device, w: u32, h: u32) {
        let w = w.max(1);
        let h = h.max(1);
        if self.size == (w, h) && self.history.is_some() {
            return;
        }
        let mk = |label: &str| {
            device.create_texture(&wgpu::TextureDescriptor {
                label: Some(label),
                size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
                mip_level_count: 1,
                sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
                dimension: wgpu::TextureDimension::D2,
                format: self.format,
                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
                    | wgpu::TextureUsages::TEXTURE_BINDING
                    | wgpu::TextureUsages::COPY_SRC,
                view_formats: &[],
            })
        };
        let a = mk("l0_taa_history_a");
        let b = mk("l0_taa_history_b");
        self.history_views = Some([a.create_view(&Default::default()), b.create_view(&Default::default())]);
        self.history = Some([a, b]);
        self.size = (w, h);
        self.reset();
    }

    /// Drop the accumulated history and restart the jitter sequence at phase 0.
    ///
    /// The next [`Self::record`] takes the current frame outright. Call it on a teleport
    /// / preset jump, where no reprojection is valid.
    pub fn reset(&mut self) {
        self.frame = 0;
        self.cur = 0;
    }

    /// **The jitter this frame's geometry must be rendered with.**
    ///
    /// The pass owns the phase, so the offset the caller applies and the frame the
    /// resolve accumulates cannot disagree.
    #[must_use]
    pub fn jitter_px(&self) -> [f32; 2] {
        jitter_px(self.frame)
    }

    /// Frames accumulated since the last [`Self::reset`].
    #[must_use]
    pub fn frame(&self) -> u64 {
        self.frame
    }

    /// The history weight the next [`Self::record`] will use — `0.0` on a fresh start.
    ///
    /// **There is no separate "history valid" flag, by construction.** `frame == 0` is
    /// exactly the state in which no history exists (both [`Self::reset`] and
    /// [`Self::ensure`] put it there), and [`history_weight`] is `0.0` at frame 0 — so
    /// "take the current frame outright" falls out of the ramp instead of being a second
    /// condition that has to agree with it.
    ///
    /// It was a second condition, briefly: the shader also gated on a `history_valid`
    /// uniform. Mutating that gate away left every test GREEN, because the weight was
    /// already 0 — a guard that could not report red. LAW #5 prefers one writer to two
    /// copies watched for agreement, so the flag is gone rather than tested harder.
    #[must_use]
    pub fn weight(&self) -> f32 {
        history_weight(self.frame, self.feedback_cap)
    }

    /// Resolve `current` (rendered with [`Self::jitter_px`]) against the history and
    /// advance the frame counter. `velocity` is UV-per-frame motion; `None` binds a zero
    /// texture.
    ///
    /// Returns `false` when the pass is not allocated, so a caller can tell "resolved"
    /// from "silently did nothing".
    pub fn record(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        current: &wgpu::TextureView,
        velocity: Option<&wgpu::TextureView>,
    ) -> bool {
        let Some(views) = &self.history_views else { return false };
        let (w, h) = self.size;
        let dst = 1 - self.cur;

        queue.write_buffer(
            &self.uniform,
            0,
            bytemuck::bytes_of(&TaaUniform {
                res: [w as f32, h as f32, 1.0 / w as f32, 1.0 / h as f32],
                params: {
                    let j = self.jitter_px();
                    [self.weight(), j[0], j[1], 0.0]
                },
            }),
        );

        let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("l0_taa_bind"),
            layout: &self.bgl,
            entries: &[
                wgpu::BindGroupEntry { binding: 0, resource: self.uniform.as_entire_binding() },
                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(current) },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: wgpu::BindingResource::TextureView(&views[self.cur]),
                },
                wgpu::BindGroupEntry {
                    binding: 3,
                    resource: wgpu::BindingResource::TextureView(velocity.unwrap_or(&self.zero_velocity)),
                },
                wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.sampler) },
            ],
        });

        {
            let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("l0_taa_resolve_pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &views[dst],
                    resolve_target: None,
                    depth_slice: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });
            rp.set_pipeline(&self.pipeline);
            rp.set_bind_group(0, &bind, &[]);
            rp.draw(0..3, 0..1);
        }

        self.cur = dst;
        self.frame += 1;
        true
    }

    /// The most recently resolved texture — what a caller composites or reads back.
    #[must_use]
    pub fn resolved(&self) -> Option<&wgpu::Texture> {
        self.history.as_ref().map(|h| &h[self.cur])
    }

    /// The most recently resolved view.
    #[must_use]
    pub fn resolved_view(&self) -> Option<&wgpu::TextureView> {
        self.history_views.as_ref().map(|v| &v[self.cur])
    }

    /// Which of the two ping-pong slots [`Self::resolved_view`] currently names.
    ///
    /// A caller that consumes the resolve through a **prebuilt** bind group — one per
    /// slot, built once at `ensure` rather than allocated every frame (LAW: preallocate
    /// and reuse) — cannot pick between them from a `&TextureView` alone, because two
    /// views are not comparable. This is the index, and [`Self::history_view`] is how
    /// the pair is named at build time.
    #[must_use]
    pub fn history_index(&self) -> usize {
        self.cur
    }

    /// Slot `i` of the ping-pong history (`i` is taken mod 2), for building the pair of
    /// prebuilt bind groups [`Self::history_index`] then selects between.
    #[must_use]
    pub fn history_view(&self, i: usize) -> Option<&wgpu::TextureView> {
        self.history_views.as_ref().map(|v| &v[i % 2])
    }

    /// Allocated size in physical pixels.
    #[must_use]
    pub fn size(&self) -> (u32, u32) {
        self.size
    }

    /// Whether the history is allocated.
    #[must_use]
    pub fn ready(&self) -> bool {
        self.history.is_some()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The van der Corput terms, by hand. Base 2: 1/2, 1/4, 3/4, 1/8, 5/8, 3/8, 7/8.
    /// Base 3: 1/3, 2/3, 1/9, 4/9, 7/9.
    #[test]
    fn halton_is_the_radical_inverse() {
        for (i, want) in [(1u32, 0.5), (2, 0.25), (3, 0.75), (4, 0.125), (5, 0.625), (6, 0.375), (7, 0.875)] {
            assert!((halton(i, 2) - want).abs() < 1e-6, "halton({i}, 2) = {} want {want}", halton(i, 2));
        }
        for (i, want) in [(1u32, 1.0 / 3.0), (2, 2.0 / 3.0), (3, 1.0 / 9.0), (4, 4.0 / 9.0), (5, 7.0 / 9.0)] {
            assert!((halton(i, 3) - want).abs() < 1e-6, "halton({i}, 3) = {} want {want}", halton(i, 3));
        }
        assert_eq!(halton(0, 2), 0.0, "index 0 is the origin — which is why jitter_px offsets by one");
        assert_eq!(halton(5, 1), 0.0, "base < 2 is not a sequence");
        assert_eq!(halton(5, 0), 0.0);
    }

    /// **The identity-value trap, head on.** A jitter that is all zeros — or one whose
    /// phases repeat — cannot antialias anything, and would be invisible in a test that
    /// only checked the offsets are in range. So: every phase is distinct, none is the
    /// origin, and the offsets actually spread over the pixel.
    #[test]
    fn every_jitter_phase_is_a_distinct_nonzero_subpixel_offset() {
        let phases: Vec<[f32; 2]> = (0..TAA_PHASES as u64).map(jitter_px).collect();
        for (n, j) in phases.iter().enumerate() {
            assert!(
                j[0] >= -0.5 && j[0] < 0.5 && j[1] >= -0.5 && j[1] < 0.5,
                "phase {n} offset {j:?} is inside the pixel"
            );
            assert!(
                j[0] != 0.0 || j[1] != 0.0,
                "phase {n} is the ORIGIN — a phase that does not move the sample cannot antialias"
            );
        }
        for i in 0..phases.len() {
            for j in (i + 1)..phases.len() {
                assert_ne!(phases[i], phases[j], "phases {i} and {j} are the same sample position");
            }
        }
        // And the pattern spans the pixel rather than clustering in one corner: the
        // extremes in each axis must be at least half a pixel apart.
        let xs: Vec<f32> = phases.iter().map(|j| j[0]).collect();
        let ys: Vec<f32> = phases.iter().map(|j| j[1]).collect();
        let span = |v: &[f32]| v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min);
        assert!(span(&xs) >= 0.5, "the x offsets span {} of a pixel", span(&xs));
        assert!(span(&ys) >= 0.5, "the y offsets span {} of a pixel", span(&ys));
    }

    /// The sequence cycles with period [`TAA_PHASES`], so a long run keeps revisiting
    /// the same well-spread set rather than drifting.
    #[test]
    fn the_jitter_cycles_with_the_declared_period() {
        for f in 0..40u64 {
            assert_eq!(jitter_px(f), jitter_px(f + u64::from(TAA_PHASES)), "frame {f}");
        }
        assert_ne!(jitter_px(0), jitter_px(1), "…but consecutive frames differ");
    }

    /// The weight ramp: 0 on the first frame (no history exists), a running mean while
    /// ramping, and capped thereafter.
    #[test]
    fn the_history_weight_ramps_then_saturates() {
        assert_eq!(history_weight(0, 0.97), 0.0, "frame 0 has no history to weight");
        assert!((history_weight(1, 0.97) - 0.5).abs() < 1e-6, "frame 1 averages two samples");
        assert!((history_weight(2, 0.97) - 2.0 / 3.0).abs() < 1e-6);
        assert!((history_weight(3, 0.97) - 0.75).abs() < 1e-6);
        // Ramp reaches the cap at frame 33 (33/34 > 0.97) and never exceeds it.
        for f in 0..500u64 {
            assert!(history_weight(f, 0.97) <= 0.97 + 1e-6, "frame {f} respects the cap");
        }
        assert!((history_weight(10_000, 0.97) - 0.97).abs() < 1e-6, "saturated");
        // A cap of 0 disables temporal accumulation entirely — the honest "TAA off".
        for f in 0..10u64 {
            assert_eq!(history_weight(f, 0.0), 0.0);
        }
    }

    /// Uniform layout: two `vec4<f32>`, 32 B. The OIT lane's first red on this branch
    /// was a Rust/WGSL uniform-size mismatch, so this one is checked.
    #[test]
    fn the_uniform_is_two_vec4s() {
        assert_eq!(std::mem::size_of::<TaaUniform>(), 32);
        assert!(TAA_WGSL.contains("res: vec4<f32>"), "and the shader agrees");
        assert!(TAA_WGSL.contains("params: vec4<f32>"));
        assert!(
            !TAA_WGSL.contains("vec3<"),
            "no vec3 in a uniform — it aligns to 16, not 12, and silently resizes the struct"
        );
    }

    /// The resolve must clamp the history into the current frame's neighbourhood box,
    /// and must reject a history that reprojected off screen. Both are the difference
    /// between TAA and a smear.
    ///
    /// There is deliberately NO assertion about a "history valid" gate — see
    /// [`TaaPass::weight`] for why that flag was removed rather than tested.
    #[test]
    fn the_resolve_rejects_history_it_cannot_trust() {
        assert!(TAA_WGSL.contains("clamp(hist, lo, hi)"), "history is box-clamped");
        assert!(
            TAA_WGSL.contains("huv.x >= 0.0 && huv.x <= 1.0"),
            "a pixel reprojecting off screen has no history"
        );
        assert!(
            !TAA_WGSL.contains("params.y > 0.5"),
            "the redundant history-valid gate must stay gone — frame 0's weight is already 0"
        );
    }
}