1
//! FXAA (Fast Approximate Anti-Aliasing) shader implementation.
2
//!
3
//! Post-processing AA that detects edges via luminance and selectively blurs them.
4
//! Faster than supersampling and works without hardware MSAA support.
5
//!
6
//! Shader compilation: `GlContextPtrInner.fxaa_shader` (see `core/src/gl.rs`).
7
//! FXAA pass: `apply_fxaa` / `apply_fxaa_with_config` (see `layout/src/xml/svg.rs`).
8
//!
9
//! Presets: `FxaaConfig::enabled()`, `::high_quality()`, `::balanced()`, `::performance()`
10

            
11
/// FXAA shader configuration
12
#[derive(Debug, Clone, Copy)]
13
pub struct FxaaConfig {
14
    /// Enable/disable FXAA
15
    pub enabled: bool,
16
    /// Edge detection threshold (0.063 - 0.333, default: 0.125)
17
    /// Lower = more edges detected = more AA but potential blur
18
    pub edge_threshold: f32,
19
    /// Minimum edge threshold (0.0312 - 0.0833, default: 0.0312)
20
    /// Prevents AA on very low contrast edges
21
    pub edge_threshold_min: f32,
22
}
23

            
24
impl Default for FxaaConfig {
25
282
    fn default() -> Self {
26
282
        Self {
27
282
            enabled: false, // Disabled by default for performance
28
282
            edge_threshold: 0.125,
29
282
            edge_threshold_min: 0.0312,
30
282
        }
31
282
    }
32
}
33

            
34
impl FxaaConfig {
35
    /// Create config with FXAA enabled and default quality settings
36
141
    #[must_use] pub fn enabled() -> Self {
37
141
        Self {
38
141
            enabled: true,
39
141
            ..Default::default()
40
141
        }
41
141
    }
42

            
43
    /// High quality preset - more aggressive edge detection
44
142
    #[must_use] pub const fn high_quality() -> Self {
45
142
        Self {
46
142
            enabled: true,
47
142
            edge_threshold: 0.063,
48
142
            edge_threshold_min: 0.0312,
49
142
        }
50
142
    }
51

            
52
    /// Balanced preset - default settings
53
142
    #[must_use] pub const fn balanced() -> Self {
54
142
        Self {
55
142
            enabled: true,
56
142
            edge_threshold: 0.125,
57
142
            edge_threshold_min: 0.0312,
58
142
        }
59
142
    }
60

            
61
    /// Performance preset - less aggressive, faster
62
141
    #[must_use] pub const fn performance() -> Self {
63
141
        Self {
64
141
            enabled: true,
65
141
            edge_threshold: 0.25,
66
141
            edge_threshold_min: 0.0625,
67
141
        }
68
141
    }
69
}
70

            
71
/// FXAA vertex shader - simple fullscreen quad pass-through
72
pub static FXAA_VERTEX_SHADER: &[u8] = b"#version 150
73

            
74
#if __VERSION__ != 100
75
    #define varying out
76
    #define attribute in
77
#endif
78

            
79
attribute vec2 vAttrXY;
80
varying vec2 vTexCoord;
81

            
82
void main() {
83
    vTexCoord = vAttrXY * 0.5 + 0.5; // Convert from [-1,1] to [0,1]
84
    gl_Position = vec4(vAttrXY, 0.0, 1.0);
85
}
86
";
87

            
88
/// FXAA fragment shader - implements edge-based anti-aliasing
89
pub static FXAA_FRAGMENT_SHADER: &[u8] = b"#version 150
90

            
91
precision highp float;
92

            
93
#if __VERSION__ == 100
94
    #define oFragColor gl_FragColor
95
    #define texture texture2D
96
#else
97
    out vec4 oFragColor;
98
#endif
99

            
100
#if __VERSION__ != 100
101
    #define varying in
102
#endif
103

            
104
uniform sampler2D uTexture;
105
uniform vec2 uTexelSize; // 1.0 / texture dimensions
106
uniform float uEdgeThreshold;
107
uniform float uEdgeThresholdMin;
108

            
109
varying vec2 vTexCoord;
110

            
111
// Luminance conversion (Rec. 709)
112
float luminance(vec3 color) {
113
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
114
}
115

            
116
void main() {
117
    // Sample center and 4-neighborhood
118
    vec3 colorCenter = texture(uTexture, vTexCoord).rgb;
119
    vec3 colorN = texture(uTexture, vTexCoord + vec2(0.0, -uTexelSize.y)).rgb;
120
    vec3 colorS = texture(uTexture, vTexCoord + vec2(0.0, uTexelSize.y)).rgb;
121
    vec3 colorE = texture(uTexture, vTexCoord + vec2(uTexelSize.x, 0.0)).rgb;
122
    vec3 colorW = texture(uTexture, vTexCoord + vec2(-uTexelSize.x, 0.0)).rgb;
123
    
124
    // Calculate luminance
125
    float lumCenter = luminance(colorCenter);
126
    float lumN = luminance(colorN);
127
    float lumS = luminance(colorS);
128
    float lumE = luminance(colorE);
129
    float lumW = luminance(colorW);
130
    
131
    // Find min/max luminance in neighborhood
132
    float lumMin = min(lumCenter, min(min(lumN, lumS), min(lumE, lumW)));
133
    float lumMax = max(lumCenter, max(max(lumN, lumS), max(lumE, lumW)));
134
    float lumRange = lumMax - lumMin;
135
    
136
    // Early exit if no edge detected
137
    if (lumRange < max(uEdgeThresholdMin, lumMax * uEdgeThreshold)) {
138
        oFragColor = vec4(colorCenter, 1.0);
139
        return;
140
    }
141
    
142
    // Calculate edge direction
143
    float lumNS = lumN + lumS;
144
    float lumEW = lumE + lumW;
145
    
146
    vec2 dir;
147
    dir.x = lumNS - lumEW;
148
    dir.y = lumN - lumS;
149
    
150
    // Normalize edge direction
151
    float dirReduce = max((lumN + lumS + lumE + lumW) * 0.25 * 0.25, 0.0078125);
152
    float rcpDirMin = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);
153
    dir = min(vec2(8.0), max(vec2(-8.0), dir * rcpDirMin)) * uTexelSize;
154
    
155
    // Sample along edge direction
156
    vec3 color1 = 0.5 * (
157
        texture(uTexture, vTexCoord + dir * (1.0/3.0 - 0.5)).rgb +
158
        texture(uTexture, vTexCoord + dir * (2.0/3.0 - 0.5)).rgb
159
    );
160
    
161
    vec3 color2 = color1 * 0.5 + 0.25 * (
162
        texture(uTexture, vTexCoord + dir * -0.5).rgb +
163
        texture(uTexture, vTexCoord + dir * 0.5).rgb
164
    );
165
    
166
    float lum2 = luminance(color2);
167
    
168
    // Choose appropriate sample based on luminance range
169
    if (lum2 < lumMin || lum2 > lumMax) {
170
        oFragColor = vec4(color1, 1.0);
171
    } else {
172
        oFragColor = vec4(color2, 1.0);
173
    }
174
}
175
";
176

            
177
#[cfg(test)]
178
mod autotest_generated {
179
    use super::*;
180

            
181
    /// Documented bounds from the `FxaaConfig::edge_threshold` doc comment.
182
    const EDGE_THRESHOLD_RANGE: (f32, f32) = (0.063, 0.333);
183
    /// Documented bounds from the `FxaaConfig::edge_threshold_min` doc comment.
184
    const EDGE_THRESHOLD_MIN_RANGE: (f32, f32) = (0.0312, 0.0833);
185

            
186
    /// `f32::abs` lives in `std`, and this crate is `no_std`-capable.
187
    fn fabs(x: f32) -> f32 {
188
        if x < 0.0 { -x } else { x }
189
    }
190

            
191
    /// `FxaaConfig` derives neither `PartialEq` nor `Eq`, so compare field-wise.
192
    fn same(a: FxaaConfig, b: FxaaConfig) -> bool {
193
        a.enabled == b.enabled
194
            && a.edge_threshold.to_bits() == b.edge_threshold.to_bits()
195
            && a.edge_threshold_min.to_bits() == b.edge_threshold_min.to_bits()
196
    }
197

            
198
    fn all_presets() -> [(&'static str, FxaaConfig); 5] {
199
        [
200
            ("default", FxaaConfig::default()),
201
            ("enabled", FxaaConfig::enabled()),
202
            ("high_quality", FxaaConfig::high_quality()),
203
            ("balanced", FxaaConfig::balanced()),
204
            ("performance", FxaaConfig::performance()),
205
        ]
206
    }
207

            
208
    fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
209
        if needle.is_empty() || haystack.len() < needle.len() {
210
            return None;
211
        }
212
        (0..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
213
    }
214

            
215
    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
216
        find(haystack, needle).is_some()
217
    }
218

            
219
    // ---------------------------------------------------------------------
220
    // Preset values: exact literals, documented ranges, ordering
221
    // ---------------------------------------------------------------------
222

            
223
    #[test]
224
    fn default_is_disabled_with_documented_defaults() {
225
        let d = FxaaConfig::default();
226
        assert!(!d.enabled, "FXAA must default to off for performance");
227
        assert_eq!(d.edge_threshold, 0.125_f32);
228
        assert_eq!(d.edge_threshold_min, 0.0312_f32);
229
    }
230

            
231
    #[test]
232
    fn enabled_is_default_with_only_the_flag_flipped() {
233
        // `enabled()` is spelled `..Default::default()`; this pins that it never
234
        // silently picks up a different quality preset if Default ever changes.
235
        let e = FxaaConfig::enabled();
236
        let d = FxaaConfig::default();
237
        assert!(e.enabled);
238
        assert_eq!(e.edge_threshold.to_bits(), d.edge_threshold.to_bits());
239
        assert_eq!(
240
            e.edge_threshold_min.to_bits(),
241
            d.edge_threshold_min.to_bits()
242
        );
243
    }
244

            
245
    #[test]
246
    fn balanced_matches_enabled_default() {
247
        // "Balanced preset - default settings" — the doc claims these are the same.
248
        assert!(
249
            same(FxaaConfig::balanced(), FxaaConfig::enabled()),
250
            "balanced() drifted away from the documented default settings"
251
        );
252
    }
253

            
254
    #[test]
255
    fn every_preset_except_default_is_enabled() {
256
        for (name, cfg) in all_presets() {
257
            if name == "default" {
258
                assert!(!cfg.enabled, "{name} should be disabled");
259
            } else {
260
                assert!(cfg.enabled, "{name}() must enable FXAA");
261
            }
262
        }
263
    }
264

            
265
    #[test]
266
    fn preset_thresholds_are_within_documented_ranges() {
267
        let (lo, hi) = EDGE_THRESHOLD_RANGE;
268
        let (min_lo, min_hi) = EDGE_THRESHOLD_MIN_RANGE;
269
        for (name, cfg) in all_presets() {
270
            assert!(
271
                cfg.edge_threshold >= lo && cfg.edge_threshold <= hi,
272
                "{name}: edge_threshold {} outside documented [{lo}, {hi}]",
273
                cfg.edge_threshold
274
            );
275
            assert!(
276
                cfg.edge_threshold_min >= min_lo && cfg.edge_threshold_min <= min_hi,
277
                "{name}: edge_threshold_min {} outside documented [{min_lo}, {min_hi}]",
278
                cfg.edge_threshold_min
279
            );
280
        }
281
    }
282

            
283
    #[test]
284
    fn preset_thresholds_are_finite_and_positive() {
285
        for (name, cfg) in all_presets() {
286
            assert!(!cfg.edge_threshold.is_nan(), "{name}: edge_threshold is NaN");
287
            assert!(
288
                cfg.edge_threshold.is_finite(),
289
                "{name}: edge_threshold is not finite"
290
            );
291
            assert!(
292
                !cfg.edge_threshold_min.is_nan(),
293
                "{name}: edge_threshold_min is NaN"
294
            );
295
            assert!(
296
                cfg.edge_threshold_min.is_finite(),
297
                "{name}: edge_threshold_min is not finite"
298
            );
299
            // A zero/negative threshold makes the shader's `lumRange < max(...)`
300
            // early-exit unreachable -> AA runs on every single fragment.
301
            assert!(
302
                cfg.edge_threshold > 0.0,
303
                "{name}: edge_threshold must be > 0"
304
            );
305
            assert!(
306
                cfg.edge_threshold_min > 0.0,
307
                "{name}: edge_threshold_min must be > 0"
308
            );
309
        }
310
    }
311

            
312
    #[test]
313
    fn min_threshold_never_exceeds_edge_threshold() {
314
        // The shader computes `max(uEdgeThresholdMin, lumMax * uEdgeThreshold)`;
315
        // with lumMax <= 1.0 the relative term can only ever win if
316
        // edge_threshold >= edge_threshold_min. If min > threshold, the relative
317
        // threshold is dead code for every possible luminance.
318
        for (name, cfg) in all_presets() {
319
            assert!(
320
                cfg.edge_threshold_min <= cfg.edge_threshold,
321
                "{name}: edge_threshold_min ({}) > edge_threshold ({}) makes the \
322
                 relative threshold unreachable",
323
                cfg.edge_threshold_min,
324
                cfg.edge_threshold
325
            );
326
        }
327
    }
328

            
329
    #[test]
330
    fn presets_are_ordered_by_aggressiveness() {
331
        // high_quality = most edges detected (lowest threshold),
332
        // performance = fewest (highest threshold), balanced in between.
333
        let hq = FxaaConfig::high_quality();
334
        let bal = FxaaConfig::balanced();
335
        let perf = FxaaConfig::performance();
336
        assert!(
337
            hq.edge_threshold < bal.edge_threshold,
338
            "high_quality must detect more edges than balanced"
339
        );
340
        assert!(
341
            bal.edge_threshold < perf.edge_threshold,
342
            "balanced must detect more edges than performance"
343
        );
344
        assert!(
345
            hq.edge_threshold_min <= bal.edge_threshold_min,
346
            "high_quality min-threshold must not exceed balanced's"
347
        );
348
        assert!(
349
            bal.edge_threshold_min <= perf.edge_threshold_min,
350
            "balanced min-threshold must not exceed performance's"
351
        );
352
    }
353

            
354
    #[test]
355
    fn const_presets_are_usable_in_const_context() {
356
        // The three `const fn` presets must stay const-evaluable: shader setup
357
        // sites may store them in statics.
358
        const HQ: FxaaConfig = FxaaConfig::high_quality();
359
        const BAL: FxaaConfig = FxaaConfig::balanced();
360
        const PERF: FxaaConfig = FxaaConfig::performance();
361
        assert!(same(HQ, FxaaConfig::high_quality()));
362
        assert!(same(BAL, FxaaConfig::balanced()));
363
        assert!(same(PERF, FxaaConfig::performance()));
364
    }
365

            
366
    #[test]
367
    fn presets_are_deterministic_across_calls() {
368
        for _ in 0..64 {
369
            assert!(same(FxaaConfig::enabled(), FxaaConfig::enabled()));
370
            assert!(same(FxaaConfig::high_quality(), FxaaConfig::high_quality()));
371
            assert!(same(FxaaConfig::balanced(), FxaaConfig::balanced()));
372
            assert!(same(FxaaConfig::performance(), FxaaConfig::performance()));
373
            assert!(same(FxaaConfig::default(), FxaaConfig::default()));
374
        }
375
    }
376

            
377
    #[test]
378
    fn config_is_copy_not_aliased() {
379
        let original = FxaaConfig::high_quality();
380
        let mut copy = original;
381
        copy.enabled = false;
382
        copy.edge_threshold = f32::NAN;
383
        copy.edge_threshold_min = f32::INFINITY;
384
        assert!(!copy.enabled);
385
        assert!(copy.edge_threshold.is_nan());
386
        // `original` must be untouched (Copy, no interior mutability / no heap).
387
        assert!(original.enabled);
388
        assert_eq!(original.edge_threshold, 0.063_f32);
389
        assert_eq!(original.edge_threshold_min, 0.0312_f32);
390
    }
391

            
392
    #[test]
393
    fn threshold_bits_round_trip_through_f32_repr() {
394
        // These values are uploaded verbatim as GL float uniforms; a bit-level
395
        // round-trip guards against any lossy re-encoding in between.
396
        for (name, cfg) in all_presets() {
397
            let t = f32::from_bits(cfg.edge_threshold.to_bits());
398
            let m = f32::from_bits(cfg.edge_threshold_min.to_bits());
399
            assert_eq!(t.to_bits(), cfg.edge_threshold.to_bits(), "{name}");
400
            assert_eq!(m.to_bits(), cfg.edge_threshold_min.to_bits(), "{name}");
401
        }
402
    }
403

            
404
    // ---------------------------------------------------------------------
405
    // The shader's threshold math, replayed in Rust against every preset
406
    // ---------------------------------------------------------------------
407

            
408
    /// Mirrors the shader's early-exit predicate:
409
    /// `lumRange < max(uEdgeThresholdMin, lumMax * uEdgeThreshold)`.
410
    fn shader_skips_aa(cfg: FxaaConfig, lum_min: f32, lum_max: f32) -> bool {
411
        let lum_range = lum_max - lum_min;
412
        let threshold = if cfg.edge_threshold_min > lum_max * cfg.edge_threshold {
413
            cfg.edge_threshold_min
414
        } else {
415
            lum_max * cfg.edge_threshold
416
        };
417
        lum_range < threshold
418
    }
419

            
420
    #[test]
421
    fn flat_regions_never_trigger_aa() {
422
        // Uniform luminance (lumRange == 0) must always take the early-exit path,
423
        // for every preset and across the whole legal luminance domain.
424
        for (name, cfg) in all_presets() {
425
            for step in 0..=32u32 {
426
                let lum = f32::from(step as u16) / 32.0;
427
                assert!(
428
                    shader_skips_aa(cfg, lum, lum),
429
                    "{name}: flat region at lum={lum} would be blurred"
430
                );
431
            }
432
        }
433
    }
434

            
435
    #[test]
436
    fn maximum_contrast_edge_always_triggers_aa() {
437
        // A pure black/white edge (lumRange == 1.0) must never be skipped:
438
        // that requires edge_threshold < 1.0 AND edge_threshold_min < 1.0.
439
        for (name, cfg) in all_presets() {
440
            assert!(
441
                !shader_skips_aa(cfg, 0.0, 1.0),
442
                "{name}: a full-contrast edge would be skipped by the shader"
443
            );
444
        }
445
    }
446

            
447
    #[test]
448
    fn dark_low_contrast_edges_are_gated_by_the_min_threshold() {
449
        // Near-black gradients: `lumMax * edge_threshold` collapses toward 0, so
450
        // only edge_threshold_min prevents AA-ing sensor noise. A tiny ramp well
451
        // below the min threshold must still be skipped.
452
        for (name, cfg) in all_presets() {
453
            let lum_min = 0.0;
454
            let lum_max = cfg.edge_threshold_min * 0.5;
455
            assert!(
456
                shader_skips_aa(cfg, lum_min, lum_max),
457
                "{name}: sub-min-threshold dark gradient (range {lum_max}) would be AA'd"
458
            );
459
        }
460
    }
461

            
462
    #[test]
463
    fn threshold_predicate_is_nan_safe() {
464
        // GL_RGBA16F / GL_RGBA32F render targets can legitimately carry NaN.
465
        // The predicate must not panic and must fall through to "no AA" (all
466
        // float comparisons against NaN are false, so lumRange < t is false).
467
        for (_, cfg) in all_presets() {
468
            let skipped = shader_skips_aa(cfg, f32::NAN, f32::NAN);
469
            assert!(!skipped, "NaN luminance must not take the flat-region path");
470
            let _ = shader_skips_aa(cfg, f32::NEG_INFINITY, f32::INFINITY);
471
            let _ = shader_skips_aa(cfg, f32::MIN, f32::MAX);
472
            let _ = shader_skips_aa(cfg, f32::MAX, f32::MIN);
473
        }
474
    }
475

            
476
    #[test]
477
    fn extreme_luminance_inputs_do_not_produce_nan_thresholds() {
478
        // lumMax * edge_threshold with a huge lumMax must stay finite-or-inf,
479
        // never NaN (NaN would silently disable the early exit).
480
        for (name, cfg) in all_presets() {
481
            for &lum_max in &[0.0_f32, 1.0, 1e30, f32::MAX, f32::MIN_POSITIVE] {
482
                let t = lum_max * cfg.edge_threshold;
483
                assert!(!t.is_nan(), "{name}: threshold went NaN at lumMax={lum_max}");
484
            }
485
        }
486
    }
487

            
488
    // ---------------------------------------------------------------------
489
    // Shader source bytes: what the GL driver actually receives
490
    // ---------------------------------------------------------------------
491

            
492
    #[test]
493
    fn shaders_are_non_empty_ascii_utf8() {
494
        for (name, src) in [
495
            ("vertex", FXAA_VERTEX_SHADER),
496
            ("fragment", FXAA_FRAGMENT_SHADER),
497
        ] {
498
            assert!(!src.is_empty(), "{name} shader is empty");
499
            let text = core::str::from_utf8(src)
500
                .unwrap_or_else(|e| panic!("{name} shader is not valid UTF-8: {e}"));
501
            // GLSL 1.50 source must be ASCII outside of comments; a stray
502
            // non-ASCII byte (e.g. a smart quote from an editor) is a hard
503
            // compile error on some drivers.
504
            assert!(
505
                text.is_ascii(),
506
                "{name} shader contains non-ASCII bytes (unicode smuggled into GLSL)"
507
            );
508
        }
509
    }
510

            
511
    #[test]
512
    fn shaders_contain_no_interior_nul_byte() {
513
        // These are handed to glShaderSource; an embedded NUL truncates the
514
        // source at the driver and yields a baffling "missing main()" error.
515
        for (name, src) in [
516
            ("vertex", FXAA_VERTEX_SHADER),
517
            ("fragment", FXAA_FRAGMENT_SHADER),
518
        ] {
519
            assert!(
520
                !src.contains(&0u8),
521
                "{name} shader contains an interior NUL byte"
522
            );
523
        }
524
    }
525

            
526
    #[test]
527
    fn version_directive_is_the_first_token() {
528
        // GLSL requires #version to precede everything but comments/whitespace.
529
        for (name, src) in [
530
            ("vertex", FXAA_VERTEX_SHADER),
531
            ("fragment", FXAA_FRAGMENT_SHADER),
532
        ] {
533
            let text = core::str::from_utf8(src).expect("utf8");
534
            assert!(
535
                text.starts_with("#version 150"),
536
                "{name} shader must open with `#version 150`, got: {:?}",
537
                &text[..text.len().min(24)]
538
            );
539
        }
540
    }
541

            
542
    #[test]
543
    fn shaders_have_balanced_braces_and_parens() {
544
        for (name, src) in [
545
            ("vertex", FXAA_VERTEX_SHADER),
546
            ("fragment", FXAA_FRAGMENT_SHADER),
547
        ] {
548
            let mut braces: i32 = 0;
549
            let mut parens: i32 = 0;
550
            for &b in src {
551
                match b {
552
                    b'{' => braces += 1,
553
                    b'}' => braces -= 1,
554
                    b'(' => parens += 1,
555
                    b')' => parens -= 1,
556
                    _ => {}
557
                }
558
                assert!(braces >= 0, "{name} shader closes a brace it never opened");
559
                assert!(parens >= 0, "{name} shader closes a paren it never opened");
560
            }
561
            assert_eq!(braces, 0, "{name} shader has unbalanced braces");
562
            assert_eq!(parens, 0, "{name} shader has unbalanced parens");
563
        }
564
    }
565

            
566
    #[test]
567
    fn shaders_define_main() {
568
        assert!(contains(FXAA_VERTEX_SHADER, b"void main()"));
569
        assert!(contains(FXAA_FRAGMENT_SHADER, b"void main()"));
570
    }
571

            
572
    #[test]
573
    fn fragment_shader_declares_every_uniform_the_host_feeds() {
574
        // These names are the contract with the GL pass in layout/src/xml/svg.rs;
575
        // renaming one of them here silently turns the uniform lookup into -1.
576
        for uniform in [
577
            &b"uniform sampler2D uTexture;"[..],
578
            &b"uniform vec2 uTexelSize;"[..],
579
            &b"uniform float uEdgeThreshold;"[..],
580
            &b"uniform float uEdgeThresholdMin;"[..],
581
        ] {
582
            assert!(
583
                contains(FXAA_FRAGMENT_SHADER, uniform),
584
                "fragment shader is missing uniform declaration: {}",
585
                core::str::from_utf8(uniform).unwrap()
586
            );
587
        }
588
    }
589

            
590
    #[test]
591
    fn vertex_and_fragment_varyings_match() {
592
        // vTexCoord is written by the vertex stage and read by the fragment
593
        // stage; a name mismatch is a link error at runtime only.
594
        assert!(contains(FXAA_VERTEX_SHADER, b"varying vec2 vTexCoord;"));
595
        assert!(contains(FXAA_FRAGMENT_SHADER, b"varying vec2 vTexCoord;"));
596
        assert!(contains(FXAA_VERTEX_SHADER, b"attribute vec2 vAttrXY;"));
597
        // The fragment stage must not redeclare the vertex attribute.
598
        assert!(!contains(FXAA_FRAGMENT_SHADER, b"attribute vec2 vAttrXY;"));
599
    }
600

            
601
    #[test]
602
    fn both_shaders_guard_the_es100_compatibility_defines() {
603
        // `#define varying out` (VS) / `#define varying in` (FS) must stay behind
604
        // a `__VERSION__ != 100` guard, or ES2 builds break.
605
        assert!(contains(FXAA_VERTEX_SHADER, b"#if __VERSION__ != 100"));
606
        assert!(contains(FXAA_VERTEX_SHADER, b"#define varying out"));
607
        assert!(contains(FXAA_FRAGMENT_SHADER, b"#if __VERSION__ != 100"));
608
        assert!(contains(FXAA_FRAGMENT_SHADER, b"#define varying in"));
609
        assert!(contains(FXAA_FRAGMENT_SHADER, b"#if __VERSION__ == 100"));
610
        assert!(contains(FXAA_FRAGMENT_SHADER, b"#define oFragColor gl_FragColor"));
611
    }
612

            
613
    #[test]
614
    fn luminance_weights_are_rec709_and_sum_to_one() {
615
        // Parse the literal `dot(color, vec3(...))` weights straight out of the
616
        // shader text. Weights that don't sum to 1.0 would darken/brighten the
617
        // edge-detection luminance and desync it from the thresholds above.
618
        let text = core::str::from_utf8(FXAA_FRAGMENT_SHADER).expect("utf8");
619
        let start = find(FXAA_FRAGMENT_SHADER, b"dot(color, vec3(")
620
            .expect("fragment shader must compute luminance via dot(color, vec3(..))")
621
            + b"dot(color, vec3(".len();
622
        let rest = &text[start..];
623
        let end = rest.find(')').expect("unterminated vec3(");
624
        let mut weights = [0.0_f32; 3];
625
        let mut count = 0usize;
626
        for part in rest[..end].split(',') {
627
            let v: f32 = part
628
                .trim()
629
                .parse()
630
                .unwrap_or_else(|_| panic!("non-numeric luminance weight: {part:?}"));
631
            assert!(
632
                count < 3,
633
                "luminance vec3 has more than 3 components: {:?}",
634
                &rest[..end]
635
            );
636
            weights[count] = v;
637
            count += 1;
638
        }
639
        assert_eq!(count, 3, "luminance vec3 must have exactly 3 components");
640

            
641
        // Rec. 709 coefficients.
642
        assert_eq!(weights[0], 0.2126_f32, "R weight is not Rec.709");
643
        assert_eq!(weights[1], 0.7152_f32, "G weight is not Rec.709");
644
        assert_eq!(weights[2], 0.0722_f32, "B weight is not Rec.709");
645

            
646
        let sum = weights[0] + weights[1] + weights[2];
647
        assert!(
648
            fabs(sum - 1.0) < 1e-6,
649
            "luminance weights sum to {sum}, not 1.0 — white would not map to lum 1.0"
650
        );
651

            
652
        // Consequence the thresholds rely on: luminance(white) == 1.0, so
653
        // lumMax is bounded by 1.0 for any LDR color, which is what makes
654
        // `lumMax * edge_threshold <= edge_threshold` hold.
655
        for (name, cfg) in all_presets() {
656
            assert!(
657
                sum * cfg.edge_threshold <= cfg.edge_threshold + 1e-6,
658
                "{name}: relative threshold can exceed edge_threshold for white"
659
            );
660
        }
661
    }
662

            
663
    #[test]
664
    fn fragment_shader_writes_the_output_on_every_path() {
665
        // Both the early-exit branch and the two edge branches must assign
666
        // oFragColor; an unwritten output is undefined-value garbage.
667
        let text = core::str::from_utf8(FXAA_FRAGMENT_SHADER).expect("utf8");
668
        let writes = text.matches("oFragColor =").count();
669
        assert!(
670
            writes >= 3,
671
            "expected >= 3 oFragColor writes (early-exit + 2 branches), found {writes}"
672
        );
673
    }
674

            
675
    #[test]
676
    fn shader_direction_clamp_is_symmetric() {
677
        // `min(vec2(8.0), max(vec2(-8.0), dir * rcpDirMin))` — the FXAA span
678
        // clamp must be symmetric, otherwise edges blur asymmetrically.
679
        assert!(contains(FXAA_FRAGMENT_SHADER, b"min(vec2(8.0), max(vec2(-8.0)"));
680
        // dirReduce must be clamped away from zero, or rcpDirMin divides by 0.
681
        assert!(contains(FXAA_FRAGMENT_SHADER, b"0.0078125"));
682
        assert!(contains(FXAA_FRAGMENT_SHADER, b"max("));
683
    }
684

            
685
    #[test]
686
    fn shader_dir_reduce_never_divides_by_zero() {
687
        // Replay `1.0 / (min(|dir.x|, |dir.y|) + dirReduce)` for the worst case:
688
        // an entirely black neighborhood, where every luminance is 0.
689
        let (lum_n, lum_s, lum_e, lum_w) = (0.0_f32, 0.0, 0.0, 0.0);
690
        let dir_x = (lum_n + lum_s) - (lum_e + lum_w);
691
        let dir_y = lum_n - lum_s;
692
        let dir_reduce = {
693
            let raw = (lum_n + lum_s + lum_e + lum_w) * 0.25 * 0.25;
694
            if raw > 0.007_812_5 { raw } else { 0.007_812_5 }
695
        };
696
        let min_abs = if fabs(dir_x) < fabs(dir_y) {
697
            fabs(dir_x)
698
        } else {
699
            fabs(dir_y)
700
        };
701
        let rcp = 1.0_f32 / (min_abs + dir_reduce);
702
        assert!(
703
            rcp.is_finite(),
704
            "rcpDirMin must stay finite even for an all-black neighborhood"
705
        );
706
        assert_eq!(rcp, 128.0_f32, "1.0 / 0.0078125 == 128.0");
707
    }
708
}