Skip to main content

motion_core/drawing/
atmosphere.rs

1//! Explicit seeded atmospheric paint recipes and shared envelope mathematics.
2
3use crate::{
4    Color, PixelSize, Point, Time, ValidationError,
5    noise::{self, Fractal},
6};
7
8#[cfg(test)]
9mod tests;
10
11/// Maximum canvas axis in logical atmospheric pixels.
12const MAX_CANVAS_AXIS: u32 = 16384;
13
14/// Minimum normalized radius/extent whose squared shader envelope remains finite.
15const MIN_SHADER_ENVELOPE: f64 = 1e-15;
16
17/// Conservative scalar work units per Perlin fBm octave.
18const OCTAVE_WORK: u64 = 192;
19
20/// Conservative scalar work units for envelope, dust neighbors and color conversion.
21const ENVELOPE_WORK: u64 = 256;
22
23/// Spatial envelope applied to amplitude-normalized Perlin fractal noise.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub enum AtmosphereKind {
26    /// Top-to-bottom density multipliers, each in `0..=8`.
27    Fog {
28        /// Density at the top and bottom of the canvas.
29        gradient: [f64; 2],
30    },
31    /// Sparse soft discs with one possible mote per noise cell.
32    Dust {
33        /// Positive radius in canvas pixels, at most half the cell scale.
34        radius: f64,
35        /// Probability of a mote per cell in `0..=1`.
36        coverage: f64,
37    },
38    /// Elliptical edge-light envelope.
39    LightLeak {
40        /// Normalized center coordinates in `-1..=2`.
41        center: [f64; 2],
42        /// Positive normalized extents, at most four.
43        extent: [f64; 2],
44    },
45}
46
47/// Editable unvalidated atmospheric parameters; paint construction validates all fields.
48#[derive(Clone, Copy, Debug, PartialEq)]
49pub struct AtmosphereParameters {
50    /// Envelope and effect-specific parameters.
51    pub kind: AtmosphereKind,
52    /// Explicit noise and mote seed.
53    pub seed: u32,
54    /// Straight encoded-sRGB tint.
55    pub color: Color,
56    /// Alpha/density multiplier in `0..=1`.
57    pub opacity: f64,
58    /// Straight-linear RGB multiplier in `0..=64`.
59    pub intensity: f64,
60    /// Noise wavelength or dust-cell spacing in pixels, in `0.25..=1000000`.
61    pub scale: f64,
62    /// Pattern velocity in canvas pixels per second.
63    pub drift: [f64; 2],
64    /// Amplitude-normalized fractal parameters, with at most eight octaves.
65    pub fractal: Fractal,
66}
67
68impl AtmosphereParameters {
69    /// Rejects nonfinite, unsupported or out-of-range recipe parameters.
70    pub fn validate(&self) -> Result<(), ValidationError> {
71        self.fractal.validate()?;
72        let bounded = |value: f64, min, max| value.is_finite() && (min..=max).contains(&value);
73        let valid_kind = match self.kind {
74            AtmosphereKind::Fog { gradient } => {
75                gradient.into_iter().all(|value| bounded(value, 0.0, 8.0))
76            }
77            AtmosphereKind::Dust { radius, coverage } => {
78                radius.is_finite()
79                    && radius > 0.0
80                    && radius <= self.scale * 0.5
81                    && bounded(coverage, 0.0, 1.0)
82            }
83            AtmosphereKind::LightLeak { center, extent } => {
84                center.into_iter().all(|value| bounded(value, -1.0, 2.0))
85                    && extent
86                        .into_iter()
87                        .all(|value| value.is_finite() && value > 0.0 && value <= 4.0)
88            }
89        };
90        if !valid_kind
91            || !bounded(self.opacity, 0.0, 1.0)
92            || !bounded(self.intensity, 0.0, 64.0)
93            || !bounded(self.scale, 0.25, 1e6)
94            || self.drift.into_iter().any(|value| !value.is_finite())
95            || self.color.to_linear_premultiplied().iter().any(|value| {
96                !(*value * self.intensity).is_finite()
97                    || !((*value * self.intensity) as f32).is_finite()
98            })
99        {
100            return Err(ValidationError::invalid(
101                "atmosphere",
102                "parameters",
103                "Require finite bounded envelope, color, opacity, intensity, scale and drift",
104            ));
105        }
106        Ok(())
107    }
108
109    /// Evaluates straight linear RGBA at an explicit canvas pixel coordinate.
110    ///
111    /// The caller supplies canvas dimensions and advection phase; image generators
112    /// and independent paint backends use these same mathematical definitions.
113    pub fn sample(
114        &self,
115        pixel: [f64; 2],
116        canvas: PixelSize,
117        phase: [f64; 2],
118    ) -> Result<[f64; 4], ValidationError> {
119        self.validate()?;
120        if canvas.width == 0
121            || canvas.height == 0
122            || pixel
123                .into_iter()
124                .chain(phase)
125                .any(|value| !value.is_finite())
126        {
127            return Err(ValidationError::invalid(
128                "atmosphere",
129                "point",
130                "Atmosphere coordinates must be finite with a positive canvas",
131            ));
132        }
133        let point = [
134            (pixel[0] + phase[0]) / self.scale,
135            (pixel[1] + phase[1]) / self.scale,
136        ];
137        let cloud = (0.5
138            + 0.5
139                * noise::fbm(
140                    |point| noise::perlin2(point, self.seed),
141                    point,
142                    self.fractal,
143                )?)
144        .clamp(0.0, 1.0);
145        let envelope = match self.kind {
146            AtmosphereKind::Fog { gradient } => {
147                let vertical = pixel[1] / f64::from(canvas.height);
148                let density = gradient[0] * (1.0 - vertical) + gradient[1] * vertical;
149                1.0 - libm::exp(-self.opacity * density * (0.35 + cloud))
150            }
151            AtmosphereKind::Dust { radius, coverage } => {
152                self.opacity
153                    * dust(point, self.seed, radius / self.scale, coverage)
154                    * (0.7 + 0.3 * cloud)
155            }
156            AtmosphereKind::LightLeak { center, extent } => {
157                let horizontal = (pixel[0] / f64::from(canvas.width) - center[0]) / extent[0];
158                let vertical = (pixel[1] / f64::from(canvas.height) - center[1]) / extent[1];
159                self.opacity
160                    * libm::exp(-2.0 * (horizontal * horizontal + vertical * vertical))
161                    * (0.6 + 0.4 * cloud)
162            }
163        };
164        let tint = self.color.to_linear_premultiplied();
165        let alpha = tint[3];
166        let rgb = [tint[0], tint[1], tint[2]].map(|value| {
167            if alpha > 0.0 {
168                value / alpha * self.intensity
169            } else {
170                0.0
171            }
172        });
173        Ok([rgb[0], rgb[1], rgb[2], (envelope * alpha).clamp(0.0, 1.0)])
174    }
175}
176
177/// Immutable, admitted atmospheric paint with explicit time and canvas coordinates.
178#[derive(Clone, Copy, Debug, PartialEq)]
179pub struct AtmospherePaint {
180    parameters: AtmosphereParameters,
181    canvas: PixelSize,
182    time: Time,
183}
184
185impl AtmospherePaint {
186    /// Validates a recipe and its full canvas at explicit time without allocating pixels.
187    pub fn new(
188        parameters: AtmosphereParameters,
189        canvas: PixelSize,
190        time: Time,
191    ) -> Result<Self, ValidationError> {
192        let paint = Self {
193            parameters,
194            canvas,
195            time,
196        };
197        paint.validate()?;
198        Ok(paint)
199    }
200
201    /// Validated immutable recipe.
202    pub fn parameters(&self) -> &AtmosphereParameters {
203        &self.parameters
204    }
205
206    /// Canvas used for pixel wavelengths and normalized envelopes.
207    pub fn canvas(&self) -> PixelSize {
208        self.canvas
209    }
210
211    /// Explicit sample time; no playback history is retained.
212    pub fn time(&self) -> Time {
213        self.time
214    }
215
216    /// Advection offset in canvas pixels.
217    pub fn phase(&self) -> [f64; 2] {
218        self.parameters
219            .drift
220            .map(|velocity| -velocity * self.time.as_seconds())
221    }
222
223    /// Validates parameters and every octave's full-canvas lattice support, even when invisible.
224    pub fn validate(&self) -> Result<(), ValidationError> {
225        self.parameters.validate()?;
226        let supported_envelope = match self.parameters.kind {
227            AtmosphereKind::Fog { .. } => true,
228            AtmosphereKind::Dust { radius, .. } => {
229                radius / self.parameters.scale >= MIN_SHADER_ENVELOPE
230            }
231            AtmosphereKind::LightLeak { extent, .. } => {
232                extent.into_iter().all(|value| value >= MIN_SHADER_ENVELOPE)
233            }
234        };
235        if !supported_envelope {
236            return Err(ValidationError::invalid(
237                "atmosphere",
238                "envelope",
239                "Atmosphere envelope is too small for finite shader arithmetic",
240            ));
241        }
242        if self.canvas.width == 0
243            || self.canvas.height == 0
244            || self.canvas.width > MAX_CANVAS_AXIS
245            || self.canvas.height > MAX_CANVAS_AXIS
246        {
247            return Err(ValidationError::invalid(
248                "atmosphere",
249                "canvas",
250                "Canvas axes must be in 1..=16384",
251            ));
252        }
253        let phase = self.phase();
254        for horizontal in [0.0, f64::from(self.canvas.width)] {
255            for vertical in [0.0, f64::from(self.canvas.height)] {
256                let point = [
257                    (horizontal + phase[0]) / self.parameters.scale,
258                    (vertical + phase[1]) / self.parameters.scale,
259                ];
260                noise::fbm(|_| 0.0, point, self.parameters.fractal)?;
261                let mut narrowed = [
262                    (horizontal as f32 + phase[0] as f32) / self.parameters.scale as f32,
263                    (vertical as f32 + phase[1] as f32) / self.parameters.scale as f32,
264                ];
265                for _ in 0..self.parameters.fractal.octaves {
266                    if narrowed.iter().any(|value| {
267                        !value.is_finite() || *value < i32::MIN as f32 || *value >= i32::MAX as f32
268                    }) {
269                        return Err(ValidationError::invalid(
270                            "atmosphere",
271                            "point",
272                            "Atmosphere exceeds GPU lattice support",
273                        ));
274                    }
275                    narrowed =
276                        narrowed.map(|value| value * self.parameters.fractal.lacunarity as f32);
277                }
278            }
279        }
280        Ok(())
281    }
282
283    /// Evaluates normalized object coordinates into linear-premultiplied working pixels.
284    pub fn sample(&self, point: Point) -> Result<[f32; 4], ValidationError> {
285        let pixel = [
286            point.x * f64::from(self.canvas.width),
287            point.y * f64::from(self.canvas.height),
288        ];
289        let value = self.parameters.sample(pixel, self.canvas, self.phase())?;
290        Ok([
291            value[0] * value[3],
292            value[1] * value[3],
293            value[2] * value[3],
294            value[3],
295        ]
296        .map(|value| value as f32))
297    }
298
299    /// Conservative scalar work charge per evaluated pixel.
300    pub fn work_per_pixel(&self) -> u64 {
301        u64::from(self.parameters.fractal.octaves) * OCTAVE_WORK + ENVELOPE_WORK
302    }
303}
304
305impl From<AtmospherePaint> for super::Paint {
306    fn from(value: AtmospherePaint) -> Self {
307        Self::Atmosphere(value)
308    }
309}
310
311fn dust(point: [f64; 2], seed: u32, radius: f64, coverage: f64) -> f64 {
312    let cell = point.map(|value| value.floor() as i32);
313    let unit = |value: u32| f64::from(value) / f64::from(u32::MAX);
314    let mut result: f64 = 0.0;
315    for dy in -1i32..=1 {
316        for dx in -1i32..=1 {
317            let horizontal = cell[0].wrapping_add(dx);
318            let vertical = cell[1].wrapping_add(dy);
319            let hash = noise::hash2(horizontal, vertical, seed);
320            if unit(hash) >= coverage {
321                continue;
322            }
323            let center = [
324                f64::from(horizontal) + unit(noise::hash32(hash ^ 0xa511_e9b3)),
325                f64::from(vertical) + unit(noise::hash32(hash ^ 0x63d8_3595)),
326            ];
327            let radius = radius * (0.6 + 0.4 * unit(noise::hash32(hash ^ 0x9e37_79b9)));
328            let distance = (point[0] - center[0]).powi(2) + (point[1] - center[1]).powi(2);
329            result = result.max((1.0 - distance / (radius * radius)).max(0.0).powi(2));
330        }
331    }
332    result
333}