Skip to main content

cvkg_render_gpu/
color_blindness.rs

1//! Color blindness simulation post-process pass.
2//!
3//! Implements Brettel/Viénot simulation for:
4//! - **Protanopia** (no red cones) -- ~1.3% of males
5//! - **Deuteranopia** (no green cones) -- ~5.9% of males
6//! - **Tritanopia** (no blue cones) -- ~0.003% of general population
7//!
8//! The simulation transforms colors using a Daltonization matrix applied
9//! in linear RGB space. The module provides the transformation matrices,
10//! WGLSL shader source, and uniform types needed to integrate the effect
11//! into a GPU render pipeline.
12//!
13//! # Integration
14//!
15//! The `SurtrRenderer` in cvkg-render-gpu uses a multi-pass pipeline architecture.
16//! To add color blindness simulation, create a dedicated render pipeline using
17//! `shader_source()` and `ColorBlindUniforms`, then render a full-screen triangle
18//! after the main pass but before composite/present.
19
20/// Color blindness simulation modes.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[repr(u32)]
23pub enum ColorBlindMode {
24    /// Normal vision (identity transform -- no-op, useful for A/B comparison).
25    Normal,
26    /// Protanopia: absence of L (red) cones.
27    Protanopia,
28    /// Deuteranopia: absence of M (green) cones.
29    Deuteranopia,
30    /// Tritanopia: absence of S (blue) cones.
31    Tritanopia,
32    /// Protanomaly: reduced L cone sensitivity (milder form).
33    Protanomaly,
34    /// Deuteranomaly: reduced M cone sensitivity (milder form).
35    Deuteranomaly,
36}
37
38impl ColorBlindMode {
39    /// Returns the 3x3 color transformation matrix for this mode.
40    ///
41    /// Matrix is in column-major order for WGLSL, operating on linear RGB.
42    /// Values are based on the Brettel, Viénot & Mollon (1997) model.
43    pub fn matrix(&self) -> [f32; 9] {
44        match self {
45            // Identity -- no transformation
46            ColorBlindMode::Normal => [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
47            // Protanopia: L cone absent
48            // Based on Brettel et al. projection plane for protanopes
49            ColorBlindMode::Protanopia => [
50                0.567, 0.433, 0.000, // R' = 0.567R + 0.433G
51                0.558, 0.442, 0.000, // G' = 0.558R + 0.442G
52                0.000, 0.242, 0.758, // B' = 0.242G + 0.758B
53            ],
54            // Deuteranopia: M cone absent
55            ColorBlindMode::Deuteranopia => [
56                0.625, 0.375, 0.000, // R' = 0.625R + 0.375G
57                0.700, 0.300, 0.000, // G' = 0.700R + 0.300G
58                0.000, 0.300, 0.700, // B' = 0.300G + 0.700B
59            ],
60            // Tritanopia: S cone absent
61            ColorBlindMode::Tritanopia => [
62                0.950, 0.050, 0.000, // R' = 0.950R + 0.050G
63                0.000, 0.433, 0.567, // G' = 0.433G + 0.567B
64                0.000, 0.475, 0.525, // B' = 0.475G + 0.525B
65            ],
66            // Protanomaly: partial L cone loss (blend of identity + protanopia)
67            ColorBlindMode::Protanomaly => [
68                0.817, 0.183, 0.000, 0.333, 0.667, 0.000, 0.000, 0.125, 0.875,
69            ],
70            // Deuteranomaly: partial M cone loss (blend of identity + deuteranopia)
71            ColorBlindMode::Deuteranomaly => [
72                0.800, 0.200, 0.000, 0.258, 0.742, 0.000, 0.000, 0.142, 0.858,
73            ],
74        }
75    }
76
77    /// Human-readable display name.
78    pub fn display_name(&self) -> &'static str {
79        match self {
80            ColorBlindMode::Normal => "Normal Vision",
81            ColorBlindMode::Protanopia => "Protanopia (no red)",
82            ColorBlindMode::Deuteranopia => "Deuteranopia (no green)",
83            ColorBlindMode::Tritanopia => "Tritanopia (no blue)",
84            ColorBlindMode::Protanomaly => "Protanomaly (reduced red)",
85            ColorBlindMode::Deuteranomaly => "Deuteranomaly (reduced green)",
86        }
87    }
88
89    /// Whether this mode performs any actual transformation.
90    pub fn is_identity(&self) -> bool {
91        matches!(self, ColorBlindMode::Normal)
92    }
93}
94
95/// Returns the WGLSL source for the color blindness fragment shader.
96///
97/// The shader samples the screen texture and applies the 3x3 color matrix
98/// from a uniform buffer. It operates in linear space.
99pub fn shader_source() -> &'static str {
100    r#"
101struct ColorBlindUniforms {
102    matrix_0: vec3<f32>,
103    matrix_1: vec3<f32>,
104    matrix_2: vec3<f32>,
105    mode: u32,
106    intensity: f32,  // 0.0 = no effect, 1.0 = full simulation
107    color_space: u32,
108    _pad0: f32,
109    _pad1: f32,
110};
111
112@group(0) @binding(0) var t_screen: texture_2d<f32>;
113@group(0) @binding(1) var s_screen: sampler;
114@group(0) @binding(2) var<uniform> cb: ColorBlindUniforms;
115
116struct VertexOutput {
117    @builtin(position) pos: vec4<f32>,
118    @location(0) uv: vec2<f32>,
119};
120
121@vertex
122fn fs_main_vs(@builtin(vertex_index) vid: u32) -> VertexOutput {
123    // Full-screen triangle
124    let pos = vec4<f32>(
125        select(vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vid == 1u),
126        0.0,
127        1.0
128    );
129    let uv = vec2<f32>(
130        select(0.0, 2.0, vid == 1u),
131        select(0.0, 2.0, vid > 0u),
132    );
133    return VertexOutput(pos, uv);
134}
135
136@fragment
137fn fs_color_blind(in: VertexOutput) -> @location(0) vec4<f32> {
138    // the 3x3 matrix in the uniform is the simulation matrix
139    // see ColorBlindMode::matrix() for the algorithm
140    let screen_uv = vec2<f32>(in.uv.x, 1.0 - in.uv.y);
141    let color = textureSample(t_screen, s_screen, screen_uv);
142    let rgb = color.rgb;
143
144    let mat = mat3x3<f32>(cb.matrix_0, cb.matrix_1, cb.matrix_2);
145    let simulated = mat * rgb;
146    let result = mix(rgb, simulated, cb.intensity);
147
148    return vec4<f32>(result, color.a);
149}
150"#
151}
152
153/// Uniform data for the color blindness shader.
154#[repr(C)]
155#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
156pub struct ColorBlindUniforms {
157    /// Column 0 of the 3x3 transformation matrix.
158    pub matrix_0: [f32; 3],
159    _pad_m0: f32, // vec3<f32> is 16-byte aligned in WGSL
160    /// Column 1.
161    pub matrix_1: [f32; 3],
162    _pad_m1: f32,
163    /// Column 2.
164    pub matrix_2: [f32; 3],
165    _pad_m2: f32,
166    /// Mode ID (for debugging).
167    pub mode: u32,
168    /// Effect intensity (0.0–1.0).
169    pub intensity: f32,
170    color_space: u32,
171    _pad0: f32,
172    _pad1: f32,
173}
174
175impl ColorBlindUniforms {
176    /// Create uniforms from a mode and intensity.
177    pub fn new(mode: ColorBlindMode, intensity: f32) -> Self {
178        let m = mode.matrix();
179        Self {
180            matrix_0: [m[0], m[1], m[2]],
181            _pad_m0: 0.0,
182            matrix_1: [m[3], m[4], m[5]],
183            _pad_m1: 0.0,
184            matrix_2: [m[6], m[7], m[8]],
185            _pad_m2: 0.0,
186            mode: mode as u32,
187            intensity: intensity.clamp(0.0, 1.0),
188            color_space: 0,
189            _pad0: 0.0,
190            _pad1: 0.0,
191        }
192    }
193}
194
195/// All available color blindness modes for iteration.
196pub const ALL_MODES: &[ColorBlindMode] = &[
197    ColorBlindMode::Normal,
198    ColorBlindMode::Protanopia,
199    ColorBlindMode::Protanomaly,
200    ColorBlindMode::Deuteranopia,
201    ColorBlindMode::Deuteranomaly,
202    ColorBlindMode::Tritanopia,
203];
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_normal_matrix_is_identity() {
211        let m = ColorBlindMode::Normal.matrix();
212        assert_eq!(m[0], 1.0);
213        assert_eq!(m[4], 1.0);
214        assert_eq!(m[8], 1.0);
215        assert_eq!(m[1], 0.0);
216    }
217
218    #[test]
219    fn test_protanopia_blue_input_isolated() {
220        let m = ColorBlindMode::Protanopia.matrix();
221        // Blue channel input should have zero contribution to R' and G' outputs
222        assert_eq!(m[2], 0.0);
223        assert_eq!(m[5], 0.0);
224    }
225
226    #[test]
227    fn test_uniforms_creation() {
228        let u = ColorBlindUniforms::new(ColorBlindMode::Deuteranopia, 0.8);
229        assert_eq!(u.intensity, 0.8);
230        assert_eq!(u.mode, 2); // Deuteranopia = index 2 in enum
231    }
232
233    #[test]
234    fn test_intensity_clamping() {
235        let u = ColorBlindUniforms::new(ColorBlindMode::Normal, 999.0);
236        assert_eq!(u.intensity, 1.0);
237        let u2 = ColorBlindUniforms::new(ColorBlindMode::Normal, -1.0);
238        assert_eq!(u2.intensity, 0.0);
239    }
240
241    #[test]
242    fn test_all_modes_have_names() {
243        for mode in ALL_MODES {
244            let name = mode.display_name();
245            assert!(!name.is_empty());
246        }
247    }
248}