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 `GpuRenderer` 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 (degenerate triangle fix: vertex 2 should be at (-1, 3))
124    var pos = vec4<f32>(-1.0, -1.0, 0.0, 1.0);
125    if vid == 1u {
126        pos = vec4<f32>(3.0, -1.0, 0.0, 1.0);
127    } else if vid == 2u {
128        pos = vec4<f32>(-1.0, 3.0, 0.0, 1.0);
129    }
130    var uv = vec2<f32>(0.0, 0.0);
131    if vid == 1u {
132        uv = vec2<f32>(2.0, 0.0);
133    } else if vid == 2u {
134        uv = vec2<f32>(0.0, 2.0);
135    }
136    return VertexOutput(pos, uv);
137}
138
139@fragment
140fn fs_color_blind(in: VertexOutput) -> @location(0) vec4<f32> {
141    // the 3x3 matrix in the uniform is the simulation matrix
142    // see ColorBlindMode::matrix() for the algorithm
143    let screen_uv = vec2<f32>(in.uv.x, 1.0 - in.uv.y);
144    let color = textureSample(t_screen, s_screen, screen_uv);
145    let rgb = color.rgb;
146
147    let mat = mat3x3<f32>(cb.matrix_0, cb.matrix_1, cb.matrix_2);
148    let simulated = mat * rgb;
149    let result = mix(rgb, simulated, cb.intensity);
150
151    return vec4<f32>(result, color.a);
152}
153"#
154}
155
156/// Uniform data for the color blindness shader.
157#[repr(C)]
158#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
159pub struct ColorBlindUniforms {
160    /// Column 0 of the 3x3 transformation matrix.
161    pub matrix_0: [f32; 3],
162    _pad_m0: f32, // vec3<f32> is 16-byte aligned in WGSL
163    /// Column 1.
164    pub matrix_1: [f32; 3],
165    _pad_m1: f32,
166    /// Column 2.
167    pub matrix_2: [f32; 3],
168    _pad_m2: f32,
169    /// Mode ID (for debugging).
170    pub mode: u32,
171    /// Effect intensity (0.0–1.0).
172    pub intensity: f32,
173    color_space: u32,
174    _pad0: f32,
175    _pad1: f32,
176}
177
178impl ColorBlindUniforms {
179    /// Create uniforms from a mode and intensity.
180    pub fn new(mode: ColorBlindMode, intensity: f32) -> Self {
181        let m = mode.matrix();
182        Self {
183            matrix_0: [m[0], m[1], m[2]],
184            _pad_m0: 0.0,
185            matrix_1: [m[3], m[4], m[5]],
186            _pad_m1: 0.0,
187            matrix_2: [m[6], m[7], m[8]],
188            _pad_m2: 0.0,
189            mode: mode as u32,
190            intensity: intensity.clamp(0.0, 1.0),
191            color_space: 0,
192            _pad0: 0.0,
193            _pad1: 0.0,
194        }
195    }
196}
197
198/// All available color blindness modes for iteration.
199pub const ALL_MODES: &[ColorBlindMode] = &[
200    ColorBlindMode::Normal,
201    ColorBlindMode::Protanopia,
202    ColorBlindMode::Protanomaly,
203    ColorBlindMode::Deuteranopia,
204    ColorBlindMode::Deuteranomaly,
205    ColorBlindMode::Tritanopia,
206];
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_normal_matrix_is_identity() {
214        let m = ColorBlindMode::Normal.matrix();
215        assert_eq!(m[0], 1.0);
216        assert_eq!(m[4], 1.0);
217        assert_eq!(m[8], 1.0);
218        assert_eq!(m[1], 0.0);
219    }
220
221    #[test]
222    fn test_protanopia_blue_input_isolated() {
223        let m = ColorBlindMode::Protanopia.matrix();
224        // Blue channel input should have zero contribution to R' and G' outputs
225        assert_eq!(m[2], 0.0);
226        assert_eq!(m[5], 0.0);
227    }
228
229    #[test]
230    fn test_uniforms_creation() {
231        let u = ColorBlindUniforms::new(ColorBlindMode::Deuteranopia, 0.8);
232        assert_eq!(u.intensity, 0.8);
233        assert_eq!(u.mode, 2); // Deuteranopia = index 2 in enum
234    }
235
236    #[test]
237    fn test_intensity_clamping() {
238        let u = ColorBlindUniforms::new(ColorBlindMode::Normal, 999.0);
239        assert_eq!(u.intensity, 1.0);
240        let u2 = ColorBlindUniforms::new(ColorBlindMode::Normal, -1.0);
241        assert_eq!(u2.intensity, 0.0);
242    }
243
244    #[test]
245    fn test_all_modes_have_names() {
246        for mode in ALL_MODES {
247            let name = mode.display_name();
248            assert!(!name.is_empty());
249        }
250    }
251}