Skip to main content

rdi_core/
effect.rs

1//! Device-independent shader bytecode and per-icon visual state.
2
3use crate::{AnimationCurve, Curve, DesktopError, IconId, Point};
4use std::sync::Arc;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum ShaderPipeline {
8    Sprite,
9    Particles,
10    Procedural,
11}
12
13/// Compiled Windows vertex/pixel program. GPU objects belong to the renderer.
14#[derive(Clone, Debug, PartialEq)]
15pub struct ShaderProgram {
16    pub execution: Option<Arc<crate::EffectExecution>>,
17    pub pixel_bytecode: Arc<[u8]>,
18    pub vertex_bytecode: Arc<[u8]>,
19    pub pipeline: ShaderPipeline,
20}
21
22impl ShaderProgram {
23    pub fn default_params(&self) -> [f32; 4] {
24        match self.pipeline {
25            ShaderPipeline::Sprite => [8.0, 3.0, 6.0, 30.0],
26            ShaderPipeline::Particles => [3.0, 1.5, 2.0, 0.65],
27            ShaderPipeline::Procedural => self.execution.as_ref().map_or([0.0; 4], |execution| {
28                let values = execution.defaults();
29                [values[0], values[1], values[2], values[3]]
30            }),
31        }
32    }
33
34    pub fn with_parameters(&self, values: &std::collections::BTreeMap<String, f32>) -> Result<Self, DesktopError> {
35        let mut result = self.clone();
36        let execution = result.execution.as_mut().ok_or_else(|| DesktopError::InvalidEffect("named parameters require a procedural definition".into()))?;
37        let execution = Arc::make_mut(execution);
38        for (name, value) in values {
39            let parameter = execution.parameters.iter_mut().find(|parameter| parameter.name == *name)
40                .ok_or_else(|| DesktopError::InvalidEffect(format!("unknown parameter: {name}")))?;
41            if !parameter.accepts(*value) { return Err(DesktopError::InvalidEffect(format!("invalid parameter: {name}"))); }
42            parameter.default = *value;
43        }
44        execution.validate(&execution.defaults())?;
45        Ok(result)
46    }
47}
48
49/// Immutable effect configuration, shared by preparation and rendering.
50#[derive(Clone, Debug, PartialEq)]
51pub struct Effect {
52    pub shader: ShaderProgram,
53    pub params: [f32; 4],
54    pub padding_px: u32,
55    pub envelope: Curve,
56    pub seed: f32,
57}
58
59impl Effect {
60    /// Validate resource bounds and constants before allocating GPU resources.
61    pub fn validate(&self) -> Result<(), DesktopError> {
62        if self.shader.pixel_bytecode.is_empty()
63            || self.shader.vertex_bytecode.is_empty()
64            || self.padding_px > 256
65            || !self.seed.is_finite()
66            || self.params.iter().any(|value| !value.is_finite())
67        {
68            return Err(DesktopError::InvalidEffect(
69                "empty shader, non-finite constants or padding > 256".into(),
70            ));
71        }
72        if self.shader.pipeline == ShaderPipeline::Particles
73            && (!(1.0..=32.0).contains(&self.params[0])
74                || !(0.0..=3.0).contains(&self.params[1])
75                || !(-8.0..=8.0).contains(&self.params[2])
76                || !(0.1..=2.0).contains(&self.params[3])
77                || self.seed.abs() > 65535.0)
78        {
79            return Err(DesktopError::InvalidEffect(
80                "particle pipeline requires cell_px in [1,32], radius in [0,3], turns in [-8,8], dust_size in [0.1,2], and abs(seed) <= 65535".into(),
81            ));
82        }
83        if self.shader.pipeline == ShaderPipeline::Procedural {
84            let execution = self.shader.execution.as_ref().ok_or_else(|| DesktopError::InvalidEffect("missing procedural execution".into()))?;
85            execution.validate(&self.parameter_values())?;
86            if self.seed.abs() > 65535.0 { return Err(DesktopError::InvalidEffect("abs(seed) exceeds 65535".into())); }
87        } else if self.shader.execution.is_some() {
88            return Err(DesktopError::InvalidEffect("execution requires procedural pipeline".into()));
89        }
90        Ok(())
91    }
92
93    pub fn parameter_values(&self) -> [f32; 16] {
94        let mut values = self.shader.execution.as_ref().map_or([0.0; 16], |execution| execution.defaults());
95        values[..4].copy_from_slice(&self.params);
96        values
97    }
98
99    /// Endpoints are always identity, regardless of the supplied curve.
100    pub fn strength(&self, progress: f32) -> f32 {
101        if progress <= 0.0 || progress >= 1.0 {
102            0.0
103        } else {
104            self.envelope.eval(progress).clamp(0.0, 1.0)
105        }
106    }
107}
108
109/// Per-frame state; no shader compilation or texture data travels here.
110#[derive(Clone, Debug)]
111pub struct IconFrame {
112    pub id: IconId,
113    pub position: Point,
114    pub progress: f32,
115    pub elapsed_seconds: f32,
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn procedural_requires_execution() {
124        let shader = ShaderProgram {
125            execution: None,
126            pixel_bytecode: Arc::from([1u8]),
127            vertex_bytecode: Arc::from([1u8]),
128            pipeline: ShaderPipeline::Procedural,
129        };
130        let effect = Effect {
131            params: shader.default_params(), shader, padding_px: 16,
132            envelope: Curve::linear(), seed: 3.0,
133        };
134        assert!(effect.validate().is_err());
135        assert_eq!(effect.strength(0.0), 0.0);
136        assert_eq!(effect.strength(1.0), 0.0);
137    }
138
139    #[test]
140    fn particle_parameters_are_bounded() {
141        let shader = ShaderProgram {
142            execution: None,
143            pixel_bytecode: Arc::from([1u8]),
144            vertex_bytecode: Arc::from([1u8]),
145            pipeline: ShaderPipeline::Particles,
146        };
147        let mut effect = Effect {
148            params: shader.default_params(),
149            shader,
150            padding_px: 0,
151            envelope: Curve::linear(),
152            seed: 0.0,
153        };
154        effect.validate().unwrap();
155        for (index, value) in [
156            (0, 0.0),
157            (0, 33.0),
158            (1, -1.0),
159            (1, 4.0),
160            (2, -9.0),
161            (2, 9.0),
162            (3, 0.0),
163            (3, 3.0),
164        ] {
165            let mut invalid = effect.clone();
166            invalid.params[index] = value;
167            assert!(invalid.validate().is_err());
168        }
169        effect.seed = f32::MAX;
170        assert!(effect.validate().is_err());
171    }
172
173    #[test]
174    fn envelope_endpoints_are_identity() {
175        let effect = Effect {
176            shader: ShaderProgram {
177                execution: None,
178                pixel_bytecode: Arc::from([1u8]),
179                vertex_bytecode: Arc::from([1u8]),
180                pipeline: ShaderPipeline::Sprite,
181            },
182            params: [0.0; 4],
183            padding_px: 16,
184            envelope: Curve::linear(),
185            seed: 0.0,
186        };
187        assert!(effect.validate().is_ok());
188        assert_eq!(effect.strength(0.0), 0.0);
189        assert_eq!(effect.strength(0.5), 0.5);
190        assert_eq!(effect.strength(1.0), 0.0);
191        let invalid = Effect {
192            padding_px: 257,
193            ..effect
194        };
195        assert!(invalid.validate().is_err());
196    }
197}