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}