Skip to main content

oxiui_render_soft/
shadow.rs

1//! Box-shadow via separable 1-D Gaussian blur with a cached kernel.
2//!
3//! A drop shadow is rendered by:
4//! 1. Painting the shadow rectangle at `(rect + offset)` in the shadow colour.
5//! 2. Extracting the alpha channel of that rectangle.
6//! 3. Applying a separable horizontal + vertical Gaussian blur to the alpha.
7//! 4. Compositing the blurred shadow alpha under the framebuffer content.
8//!
9//! All operations are pure Rust — no SIMD, no FFT, no external crates.
10
11use std::collections::HashMap;
12
13use crate::clip::ClipRect;
14use crate::framebuffer::{pack_rgba, Framebuffer};
15use oxiui_core::Color;
16
17// ---------------------------------------------------------------------------
18// Kernel cache
19// ---------------------------------------------------------------------------
20
21/// A cache that stores pre-computed Gaussian kernels keyed by blur-radius bits.
22///
23/// This avoids recomputing the same kernel when the same `blur_radius` is used
24/// repeatedly across frames.
25#[derive(Debug, Default)]
26pub struct GaussianCache {
27    /// Maps `blur_radius.to_bits()` → normalised 1-D Gaussian kernel weights.
28    kernels: HashMap<u32, Vec<f32>>,
29}
30
31impl GaussianCache {
32    /// Create an empty cache.
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Return a reference to the 1-D Gaussian kernel for `blur_radius`.
38    ///
39    /// The kernel is half-width — centred at index 0, extending to the right.
40    /// Callers should mirror it when convolving.
41    ///
42    /// `sigma` is derived as `blur_radius / 2.0` (CSS-style).
43    pub fn kernel(&mut self, blur_radius: f32) -> &[f32] {
44        let key = blur_radius.to_bits();
45        self.kernels
46            .entry(key)
47            .or_insert_with(|| gaussian_kernel(blur_radius))
48    }
49}
50
51/// Compute a normalised 1-D Gaussian kernel with `sigma = blur_radius / 2`.
52///
53/// The kernel is stored as a half-width (non-negative taps only) because the
54/// full kernel is symmetric.
55pub fn gaussian_kernel(blur_radius: f32) -> Vec<f32> {
56    let sigma = (blur_radius / 2.0).max(0.01);
57    let radius = (sigma * 3.0).ceil() as usize;
58    let mut weights: Vec<f32> = (0..=radius)
59        .map(|i| {
60            let x = i as f32;
61            (-x * x / (2.0 * sigma * sigma)).exp()
62        })
63        .collect();
64    // Normalise: sum = weights[0] + 2 * (weights[1] + ... + weights[radius]).
65    let sum: f32 = weights[0] + 2.0 * weights[1..].iter().sum::<f32>();
66    for w in &mut weights {
67        *w /= sum;
68    }
69    weights
70}
71
72// ---------------------------------------------------------------------------
73// Separable Gaussian blur (alpha channel)
74// ---------------------------------------------------------------------------
75
76/// Apply a separable Gaussian blur to the alpha channel of a rectangular
77/// region within `alpha` (a flat `width × height` f32 buffer).
78///
79/// This is a horizontal pass followed by a vertical pass over the same buffer.
80pub fn gaussian_blur_alpha(alpha: &mut [f32], width: usize, height: usize, kernel: &[f32]) {
81    if width == 0 || height == 0 || kernel.is_empty() {
82        return;
83    }
84    // Horizontal pass.
85    let mut tmp = vec![0.0f32; width * height];
86    for y in 0..height {
87        for x in 0..width {
88            let mut acc = alpha[y * width + x] * kernel[0];
89            for (ki, &w) in kernel.iter().enumerate().skip(1) {
90                let xl = x as i64 - ki as i64;
91                if xl >= 0 {
92                    acc += alpha[y * width + xl as usize] * w;
93                }
94                let xr = x as i64 + ki as i64;
95                if xr < width as i64 {
96                    acc += alpha[y * width + xr as usize] * w;
97                }
98            }
99            tmp[y * width + x] = acc;
100        }
101    }
102    // Vertical pass.
103    for y in 0..height {
104        for x in 0..width {
105            let mut acc = tmp[y * width + x] * kernel[0];
106            for (ki, &w) in kernel.iter().enumerate().skip(1) {
107                let ya = y as i64 - ki as i64;
108                if ya >= 0 {
109                    acc += tmp[ya as usize * width + x] * w;
110                }
111                let yb = y as i64 + ki as i64;
112                if yb < height as i64 {
113                    acc += tmp[yb as usize * width + x] * w;
114                }
115            }
116            alpha[y * width + x] = acc.clamp(0.0, 1.0);
117        }
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Box shadow
123// ---------------------------------------------------------------------------
124
125/// Render a drop (or inset) box shadow for a rectangle `rect` into `fb`.
126///
127/// # Parameters
128/// * `rect` — the widget's bounding rectangle `(x, y, w, h)`.
129/// * `offset_x`, `offset_y` — shadow offset in pixels.
130/// * `blur_radius` — shadow blur radius in pixels.
131/// * `color` — shadow colour (alpha is composited).
132/// * `cache` — Gaussian kernel cache (avoids recomputation).
133///
134/// The shadow is rendered *behind* the widget: callers should draw the widget
135/// content over the shadow after this call.
136pub fn box_shadow(
137    fb: &mut Framebuffer,
138    rect: (f32, f32, f32, f32),
139    offset_x: f32,
140    offset_y: f32,
141    blur_radius: f32,
142    color: Color,
143    cache: &mut GaussianCache,
144) {
145    let (rx, ry, rw, rh) = rect;
146    if rw <= 0.0 || rh <= 0.0 {
147        return;
148    }
149    let br = blur_radius.max(0.0);
150    let kernel = cache.kernel(br).to_vec(); // clone to avoid borrow conflict
151
152    // Shadow rect (offset + blur expansion).
153    let sx = (rx + offset_x - br).floor() as i64;
154    let sy = (ry + offset_y - br).floor() as i64;
155    let sw = (rw + br * 2.0).ceil() as usize + 1;
156    let sh = (rh + br * 2.0).ceil() as usize + 1;
157
158    if sw == 0 || sh == 0 {
159        return;
160    }
161
162    // Allocate alpha buffer for the shadow rect.
163    let mut alpha = vec![0.0f32; sw * sh];
164
165    // Fill the central (non-blurred) region of the shadow.
166    let inner_x0 = (br as usize).min(sw);
167    let inner_y0 = (br as usize).min(sh);
168    let inner_x1 = (inner_x0 + rw.ceil() as usize).min(sw);
169    let inner_y1 = (inner_y0 + rh.ceil() as usize).min(sh);
170    let shadow_a = color.3 as f32 / 255.0;
171
172    for row in inner_y0..inner_y1 {
173        for col in inner_x0..inner_x1 {
174            alpha[row * sw + col] = shadow_a;
175        }
176    }
177
178    // Blur the alpha buffer.
179    if br > 0.0 {
180        gaussian_blur_alpha(&mut alpha, sw, sh, &kernel);
181    }
182
183    // Composite blurred alpha into framebuffer using straight Porter-Duff
184    // source-over. Callers should call `box_shadow` first, then draw the widget
185    // content on top, so the shadow appears underneath.
186    let Color(sr, sg, sb, base_a) = color;
187    let base_a_f = base_a as f32 / 255.0;
188    let clip = ClipRect::full(fb.width(), fb.height());
189    for row in 0..sh {
190        let fy = sy + row as i64;
191        if fy < clip.y0 || fy >= clip.y1 {
192            continue;
193        }
194        for col in 0..sw {
195            let fx = sx + col as i64;
196            if fx < clip.x0 || fx >= clip.x1 {
197                continue;
198            }
199            let a_val = alpha[row * sw + col];
200            if a_val <= 0.0 {
201                continue;
202            }
203            // Scale shadow alpha by the base colour's alpha.
204            let effective_a = (a_val * base_a_f * 255.0).round() as u8;
205            if effective_a == 0 {
206                continue;
207            }
208            let px = fx as u32;
209            let py = fy as u32;
210            fb.blend(px, py, pack_rgba(sr, sg, sb, effective_a));
211        }
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Tests
217// ---------------------------------------------------------------------------
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn kernel_sums_to_one() {
225        let k = gaussian_kernel(4.0);
226        let sum: f32 = k[0] + 2.0 * k[1..].iter().sum::<f32>();
227        assert!((sum - 1.0).abs() < 0.01, "kernel sum = {sum}");
228    }
229
230    #[test]
231    fn cache_returns_same_kernel() {
232        let mut cache = GaussianCache::new();
233        let k1 = cache.kernel(4.0).to_vec();
234        let k2 = cache.kernel(4.0).to_vec();
235        assert_eq!(k1, k2);
236    }
237
238    #[test]
239    fn blur_spreads_alpha() {
240        // A single bright pixel in the centre should spread outward after blur.
241        let w = 9;
242        let h = 9;
243        let mut alpha = vec![0.0f32; w * h];
244        alpha[4 * w + 4] = 1.0; // centre pixel
245        let kernel = gaussian_kernel(2.0);
246        gaussian_blur_alpha(&mut alpha, w, h, &kernel);
247        // After blur, the adjacent pixels should be non-zero.
248        assert!(alpha[4 * w + 5] > 0.0, "right neighbor should receive blur");
249        assert!(alpha[3 * w + 4] > 0.0, "top neighbor should receive blur");
250        // Centre should still be the maximum.
251        assert!(alpha[4 * w + 4] >= alpha[4 * w + 5]);
252    }
253
254    #[test]
255    fn shadow_energy_offset() {
256        // Shadow with +5,+5 offset: pixel near (rect+offset) should have received
257        // shadow energy; pixel far from the shadow should remain black.
258        //
259        // We use a transparent background so the shadow blends visibly.
260        let mut fb = Framebuffer::with_fill(60, 60, oxiui_core::Color(0, 0, 0, 0));
261        let mut cache = GaussianCache::new();
262        box_shadow(
263            &mut fb,
264            (10.0, 10.0, 20.0, 20.0),
265            5.0, // offset_x → shadow rect centred near (15+5, 15+5) = (20, 20)
266            5.0, // offset_y
267            3.0, // blur_radius
268            Color(200, 200, 200, 255),
269            &mut cache,
270        );
271        // Pixel inside the shadow rect (e.g., centre of the shadow @ approx 20+10, 20+10)
272        // should be non-zero.
273        let (r_near, _, _, _) = fb.get_rgba(25, 25).unwrap_or((0, 0, 0, 0));
274        assert!(
275            r_near > 0,
276            "shadow center pixel should be non-zero (r={r_near})"
277        );
278        // Pixel far from shadow (top-left corner) should remain fully transparent.
279        let (_, _, _, a_far) = fb.get_rgba(0, 0).unwrap_or((0, 0, 0, 99));
280        assert_eq!(a_far, 0, "far corner should remain transparent (a={a_far})");
281    }
282}