Skip to main content

proof_engine/render/postfx/
fxaa.rs

1//! FXAA 3.11 — Fast Approximate Anti-Aliasing.
2//!
3//! Single post-processing pass after all other effects. Detects high-contrast
4//! edges and applies directional blur along the edge, smoothing glyph edges
5//! without blurring interiors.
6//!
7//! # Quality levels
8//!
9//! - Low:    5 edge search steps — fast, adequate for most cases
10//! - Medium: 8 edge search steps — good balance (default)
11//! - High:  12 edge search steps — best quality, slight cost
12//!
13//! # When to disable
14//!
15//! - CRT scanline mode (scanlines + FXAA conflict)
16//! - Player preference for crisp pixels
17
18// ── Quality level ───────────────────────────────────────────────────────────
19
20/// FXAA quality preset.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum FxaaQuality {
23    /// 5 edge search steps.
24    Low,
25    /// 8 edge search steps (default).
26    Medium,
27    /// 12 edge search steps.
28    High,
29    /// FXAA disabled.
30    Off,
31}
32
33impl FxaaQuality {
34    /// Number of edge search iterations.
35    pub fn search_steps(&self) -> u32 {
36        match self {
37            Self::Low => 5,
38            Self::Medium => 8,
39            Self::High => 12,
40            Self::Off => 0,
41        }
42    }
43
44    /// GLSL #define value for the quality preset.
45    pub fn define_value(&self) -> u32 {
46        match self {
47            Self::Low => 10,
48            Self::Medium => 20,
49            Self::High => 39,
50            Self::Off => 0,
51        }
52    }
53
54    pub fn is_enabled(&self) -> bool { *self != Self::Off }
55}
56
57impl Default for FxaaQuality {
58    fn default() -> Self { Self::Medium }
59}
60
61// ── FXAA parameters ─────────────────────────────────────────────────────────
62
63/// Configuration for the FXAA pass.
64#[derive(Debug, Clone)]
65pub struct FxaaParams {
66    /// Quality level.
67    pub quality: FxaaQuality,
68    /// Edge detection threshold. Lower = more edges detected.
69    /// Range: 0.063 (high quality) to 0.333 (low quality, faster).
70    pub edge_threshold: f32,
71    /// Minimum edge threshold. Prevents processing dark areas.
72    /// Range: 0.0312 (visible limit) to 0.0833 (fast).
73    pub edge_threshold_min: f32,
74    /// Subpixel anti-aliasing amount. Higher = softer.
75    /// Range: 0.0 (off) to 1.0 (full).
76    pub subpixel: f32,
77}
78
79impl Default for FxaaParams {
80    fn default() -> Self {
81        Self {
82            quality: FxaaQuality::Medium,
83            edge_threshold: 0.166,
84            edge_threshold_min: 0.0625,
85            subpixel: 0.75,
86        }
87    }
88}
89
90impl FxaaParams {
91    /// High quality preset.
92    pub fn high() -> Self {
93        Self {
94            quality: FxaaQuality::High,
95            edge_threshold: 0.063,
96            edge_threshold_min: 0.0312,
97            subpixel: 1.0,
98        }
99    }
100
101    /// Low quality preset (faster).
102    pub fn low() -> Self {
103        Self {
104            quality: FxaaQuality::Low,
105            edge_threshold: 0.250,
106            edge_threshold_min: 0.0833,
107            subpixel: 0.5,
108        }
109    }
110
111    /// Disabled.
112    pub fn off() -> Self {
113        Self { quality: FxaaQuality::Off, ..Default::default() }
114    }
115
116    pub fn is_enabled(&self) -> bool { self.quality.is_enabled() }
117
118    /// Whether FXAA should be disabled due to conflicting settings.
119    pub fn should_disable_for_scanlines(&self, scanlines_enabled: bool) -> bool {
120        scanlines_enabled
121    }
122}
123
124// ── FXAA 3.11 GLSL shader ──────────────────────────────────────────────────
125
126/// FXAA fragment shader implementing Timothy Lottes' FXAA 3.11 algorithm.
127///
128/// Uniforms:
129/// - `u_scene`: sampler2D — input scene texture
130/// - `u_texel_size`: vec2 — 1.0 / resolution
131/// - `u_edge_threshold`: float
132/// - `u_edge_threshold_min`: float
133/// - `u_subpixel`: float
134/// - `u_search_steps`: int
135pub const FXAA_FRAG: &str = r#"
136#version 330 core
137
138in  vec2 f_uv;
139out vec4 frag_color;
140
141uniform sampler2D u_scene;
142uniform vec2      u_texel_size;
143uniform float     u_edge_threshold;
144uniform float     u_edge_threshold_min;
145uniform float     u_subpixel;
146uniform int       u_search_steps;
147
148const vec3 LUMA = vec3(0.299, 0.587, 0.114);
149
150float luma(vec3 c) {
151    return dot(c, LUMA);
152}
153
154void main() {
155    // ── Sample center and neighbors ─────────────────────────────────────
156    vec3 rgbM  = texture(u_scene, f_uv).rgb;
157    float lumM = luma(rgbM);
158
159    float lumNW = luma(texture(u_scene, f_uv + vec2(-1.0, -1.0) * u_texel_size).rgb);
160    float lumNE = luma(texture(u_scene, f_uv + vec2( 1.0, -1.0) * u_texel_size).rgb);
161    float lumSW = luma(texture(u_scene, f_uv + vec2(-1.0,  1.0) * u_texel_size).rgb);
162    float lumSE = luma(texture(u_scene, f_uv + vec2( 1.0,  1.0) * u_texel_size).rgb);
163    float lumN  = luma(texture(u_scene, f_uv + vec2( 0.0, -1.0) * u_texel_size).rgb);
164    float lumS  = luma(texture(u_scene, f_uv + vec2( 0.0,  1.0) * u_texel_size).rgb);
165    float lumW  = luma(texture(u_scene, f_uv + vec2(-1.0,  0.0) * u_texel_size).rgb);
166    float lumE  = luma(texture(u_scene, f_uv + vec2( 1.0,  0.0) * u_texel_size).rgb);
167
168    // ── Edge detection ──────────────────────────────────────────────────
169    float lumMin = min(lumM, min(min(lumN, lumS), min(lumW, lumE)));
170    float lumMax = max(lumM, max(max(lumN, lumS), max(lumW, lumE)));
171    float lumRange = lumMax - lumMin;
172
173    // Skip if contrast is too low (not an edge)
174    if (lumRange < max(u_edge_threshold_min, lumMax * u_edge_threshold)) {
175        frag_color = vec4(rgbM, 1.0);
176        return;
177    }
178
179    // ── Subpixel aliasing test ──────────────────────────────────────────
180    float lumL = (lumN + lumS + lumW + lumE) * 0.25;
181    float rangeL = abs(lumL - lumM);
182    float blendL = max(0.0, (rangeL / lumRange) - 0.25) * (1.0 / 0.75);
183    blendL = min(blendL * blendL, 1.0) * u_subpixel;
184
185    // ── Determine edge direction ────────────────────────────────────────
186    float edgeH = abs(lumNW + lumNE - 2.0 * lumN)
187                + abs(lumW  + lumE  - 2.0 * lumM) * 2.0
188                + abs(lumSW + lumSE - 2.0 * lumS);
189    float edgeV = abs(lumNW + lumSW - 2.0 * lumW)
190                + abs(lumN  + lumS  - 2.0 * lumM) * 2.0
191                + abs(lumNE + lumSE - 2.0 * lumE);
192
193    bool isHorizontal = edgeH >= edgeV;
194
195    // ── Choose edge endpoints ───────────────────────────────────────────
196    float stepLength = isHorizontal ? u_texel_size.y : u_texel_size.x;
197
198    float lum1 = isHorizontal ? lumN : lumW;
199    float lum2 = isHorizontal ? lumS : lumE;
200    float grad1 = lum1 - lumM;
201    float grad2 = lum2 - lumM;
202
203    bool steeper1 = abs(grad1) >= abs(grad2);
204    float gradScaled = 0.25 * max(abs(grad1), abs(grad2));
205
206    if (!steeper1) stepLength = -stepLength;
207
208    // ── Edge search along perpendicular direction ───────────────────────
209    vec2 posN = f_uv;
210    vec2 posP = f_uv;
211
212    vec2 dir = isHorizontal ? vec2(u_texel_size.x, 0.0) : vec2(0.0, u_texel_size.y);
213
214    float halfStep = isHorizontal ? u_texel_size.y * 0.5 : u_texel_size.x * 0.5;
215    if (isHorizontal) {
216        posN.y += stepLength * 0.5;
217        posP.y += stepLength * 0.5;
218    } else {
219        posN.x += stepLength * 0.5;
220        posP.x += stepLength * 0.5;
221    }
222
223    float lumEnd1 = lumM;
224    float lumEnd2 = lumM;
225    bool reached1 = false;
226    bool reached2 = false;
227
228    for (int i = 0; i < u_search_steps; ++i) {
229        if (!reached1) {
230            posN -= dir;
231            lumEnd1 = luma(texture(u_scene, posN).rgb) - lumM;
232            reached1 = abs(lumEnd1) >= gradScaled;
233        }
234        if (!reached2) {
235            posP += dir;
236            lumEnd2 = luma(texture(u_scene, posP).rgb) - lumM;
237            reached2 = abs(lumEnd2) >= gradScaled;
238        }
239        if (reached1 && reached2) break;
240    }
241
242    // ── Compute final blend ─────────────────────────────────────────────
243    float distN = isHorizontal ? (f_uv.x - posN.x) : (f_uv.y - posN.y);
244    float distP = isHorizontal ? (posP.x - f_uv.x) : (posP.y - f_uv.y);
245    float dist = min(distN, distP);
246    float spanLength = distN + distP;
247
248    bool goodSpan = (distN < distP) ? (lumEnd1 < 0.0) : (lumEnd2 < 0.0);
249    float pixelOffset = goodSpan ? (0.5 - dist / spanLength) : 0.0;
250
251    float finalBlend = max(pixelOffset, blendL);
252
253    // ── Apply ───────────────────────────────────────────────────────────
254    vec2 finalUv = f_uv;
255    if (isHorizontal) {
256        finalUv.y += finalBlend * stepLength;
257    } else {
258        finalUv.x += finalBlend * stepLength;
259    }
260
261    frag_color = vec4(texture(u_scene, finalUv).rgb, 1.0);
262}
263"#;
264
265// ── FXAA statistics ─────────────────────────────────────────────────────────
266
267/// Per-frame FXAA statistics.
268#[derive(Debug, Clone, Default)]
269pub struct FxaaStats {
270    pub enabled: bool,
271    pub quality: &'static str,
272    pub search_steps: u32,
273}
274
275impl FxaaStats {
276    pub fn from_params(params: &FxaaParams) -> Self {
277        Self {
278            enabled: params.is_enabled(),
279            quality: match params.quality {
280                FxaaQuality::Low => "Low",
281                FxaaQuality::Medium => "Medium",
282                FxaaQuality::High => "High",
283                FxaaQuality::Off => "Off",
284            },
285            search_steps: params.quality.search_steps(),
286        }
287    }
288}
289
290// ── Tests ───────────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn default_is_medium() {
298        let params = FxaaParams::default();
299        assert_eq!(params.quality, FxaaQuality::Medium);
300        assert!(params.is_enabled());
301    }
302
303    #[test]
304    fn off_is_disabled() {
305        let params = FxaaParams::off();
306        assert!(!params.is_enabled());
307        assert_eq!(params.quality.search_steps(), 0);
308    }
309
310    #[test]
311    fn quality_search_steps() {
312        assert!(FxaaQuality::Low.search_steps() < FxaaQuality::Medium.search_steps());
313        assert!(FxaaQuality::Medium.search_steps() < FxaaQuality::High.search_steps());
314    }
315
316    #[test]
317    fn scanlines_conflict() {
318        let params = FxaaParams::default();
319        assert!(params.should_disable_for_scanlines(true));
320        assert!(!params.should_disable_for_scanlines(false));
321    }
322
323    #[test]
324    fn edge_thresholds_in_range() {
325        let high = FxaaParams::high();
326        let low = FxaaParams::low();
327        assert!(high.edge_threshold < low.edge_threshold);
328        assert!(high.edge_threshold_min < low.edge_threshold_min);
329    }
330
331    #[test]
332    fn stats_from_params() {
333        let stats = FxaaStats::from_params(&FxaaParams::default());
334        assert!(stats.enabled);
335        assert_eq!(stats.quality, "Medium");
336        assert_eq!(stats.search_steps, 8);
337    }
338}