Skip to main content

cranpose_render_common/
layer_shadow.rs

1//! Elevation shadows as Jetpack Compose draws them: Android's renderer and
2//! Compose Multiplatform's both light a layer from one point above the window
3//! and hand the caster to Skia's `SkShadowUtils`, which draws an ambient and a
4//! spot shadow. The constants below are theirs.
5
6use std::ops::Range;
7
8use cranpose_ui_graphics::{Color, CornerRadii, GraphicsLayer, Rect, RoundedCornerShape};
9
10use crate::layer_transform::layer_uniform_scale;
11
12const MIN_LAYER_SHADOW_SCALE: f32 = 0.1;
13/// The share of a shadow color's alpha each pass keeps: Android's
14/// `ambientShadowAlpha` and `spotShadowAlpha` theme values, which Compose
15/// Multiplatform copies.
16const AMBIENT_SHADOW_ALPHA: f32 = 0.039;
17const SPOT_SHADOW_ALPHA: f32 = 0.19;
18/// Where the light sits, in dp: `light_y`, `light_z` and `light_radius`.
19const LIGHT_Y: f32 = 0.0;
20const LIGHT_Z: f32 = 500.0;
21const LIGHT_RADIUS: f32 = 800.0;
22/// The window size, in dp, at which the light stands at `LIGHT_Z`; larger
23/// windows raise it, smaller ones lower it, against shadow distortion.
24const LIGHT_Z_REFERENCE: f32 = 450.0;
25/// The most an ambient shadow reaches past its caster, in pixels: Skia's
26/// `kMaxAmbientRadius`.
27const MAX_AMBIENT_REACH_PIXELS: f32 = 150.0;
28/// Skia's falloff table: 128 texels of `exp(-4 (1 - x)^2) - 0.018`, read
29/// with linear filtering at the ramp's coordinate.
30const FALLOFF_TEXELS: f32 = 128.0;
31const FALLOFF_FLOOR: f32 = 0.018;
32
33/// The light elevation shadows are cast by, in the units and coordinates the
34/// shadow is collected in.
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct ShadowLight {
37    /// Horizontal position of the light, measured from the left of the window.
38    pub x: f32,
39    /// Vertical position of the light, measured from the top of the window.
40    pub y: f32,
41    /// Height of the light above the window.
42    pub z: f32,
43    /// Radius of the light source; a larger radius gives a wider penumbra.
44    pub radius: f32,
45    /// Device pixels per unit: Skia scales the ambient penumbra by the
46    /// caster's height in pixels.
47    pub pixels_per_unit: f32,
48}
49
50impl ShadowLight {
51    /// The light over a window `width × height` units in size whose origin
52    /// is the collection origin, where one dp is `units_per_dp` units. Matches
53    /// Kotlin: `ThreadedRenderer.setLightCenter` on Android and its copy in
54    /// Compose Multiplatform's `GraphicsLayerOwnerLayer.skiko.kt`.
55    pub fn for_window(width: f32, height: f32, units_per_dp: f32, pixels_per_unit: f32) -> Self {
56        let units_per_dp = units_per_dp.max(f32::EPSILON);
57        let window_dp = width.min(height).max(0.0) / units_per_dp;
58        let adjustment = (window_dp / LIGHT_Z_REFERENCE + 2.0) / 3.0;
59        Self {
60            x: width / 2.0,
61            y: LIGHT_Y * units_per_dp,
62            z: LIGHT_Z * units_per_dp * adjustment,
63            radius: LIGHT_RADIUS * units_per_dp,
64            pixels_per_unit,
65        }
66    }
67
68    /// The same light seen from a space whose origin sits at `origin` in
69    /// this one and whose units are `scale` of this one's.
70    pub fn in_space(self, origin_x: f32, origin_y: f32, scale: f32) -> Self {
71        let scale = scale.max(MIN_LAYER_SHADOW_SCALE);
72        Self {
73            x: (self.x - origin_x) / scale,
74            y: (self.y - origin_y) / scale,
75            z: self.z / scale,
76            radius: self.radius / scale,
77            pixels_per_unit: self.pixels_per_unit * scale,
78        }
79    }
80}
81
82/// One pass of a shadow as Skia's `ShadowCircularRRectOp` draws it: a round
83/// rect whose penumbra rises from its outer edge, `bounds`, toward the umbra
84/// `umbra_inset` in. Corners are fans of triangles whose offsets the GPU
85/// interpolates, so a corner is a quarter circle where the umbra meets its
86/// radius and a skewed one where the blur is wider. Every length is in the
87/// space the shadow is collected in.
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct ShadowRRect {
90    /// The penumbra's outer edge.
91    pub bounds: Rect,
92    /// The outer corner radii: top-left, top-right, bottom-right,
93    /// bottom-left.
94    pub radii: [f32; 4],
95    /// How far in from `bounds` the ramp runs, at most half the smaller side.
96    pub umbra_inset: f32,
97    /// The ramp's coordinate at the umbra: the umbra inset over the blur.
98    pub distance_correction: f32,
99    /// The part an opaque caster covers, which the shadow leaves undrawn.
100    pub hole: Option<ShadowHole>,
101}
102
103/// The round rect inside a shadow that it leaves undrawn: `bounds` of the
104/// shadow inset by `inset`, its corners rounded at `radius`.
105#[derive(Clone, Copy, Debug, PartialEq)]
106pub struct ShadowHole {
107    /// Distance in from the shadow's `bounds` to the hole's edge.
108    pub inset: f32,
109    /// Corner radius of the hole.
110    pub radius: f32,
111}
112
113impl ShadowRRect {
114    /// The shadow `outer` casts with a ramp `blur` wide: Skia's fill of a
115    /// circle or round rect, or for `inset_width` within half its smaller
116    /// side, its stroke, which leaves the middle to the caster.
117    fn new(outer: Rect, radii: [f32; 4], blur: f32, inset_width: f32) -> Option<Self> {
118        let half_side = 0.5 * outer.width.min(outer.height);
119        if half_side <= 0.0 || blur <= 0.0 {
120            return None;
121        }
122        let largest = radii.into_iter().fold(0.0, f32::max);
123        let circle = (outer.width - outer.height).abs() <= f32::EPSILON * outer.width
124            && largest >= half_side;
125        let (umbra_inset, hole) = if circle {
126            let inner = half_side - inset_width;
127            let hole = (inner > 0.0).then_some(ShadowHole {
128                inset: inset_width,
129                radius: inner,
130            });
131            (half_side, hole)
132        } else {
133            let umbra = largest.max(blur);
134            let hole = (inset_width <= half_side).then(|| ShadowHole {
135                inset: inset_width.max(umbra.min(half_side)),
136                radius: 0.0,
137            });
138            (umbra.min(half_side), hole)
139        };
140        Some(Self {
141            bounds: outer,
142            radii: if circle { [half_side; 4] } else { radii },
143            umbra_inset,
144            distance_correction: umbra_inset / blur,
145            hole: hole.filter(|hole| hole.inset < half_side),
146        })
147    }
148
149    /// The same shadow in a space whose units are `scale` of this one's.
150    pub fn scaled(self, scale: f32) -> Self {
151        Self {
152            bounds: Rect {
153                x: self.bounds.x * scale,
154                y: self.bounds.y * scale,
155                width: self.bounds.width * scale,
156                height: self.bounds.height * scale,
157            },
158            radii: self.radii.map(|radius| radius * scale),
159            umbra_inset: self.umbra_inset * scale,
160            distance_correction: self.distance_correction,
161            hole: self.hole.map(|hole| ShadowHole {
162                inset: hole.inset * scale,
163                radius: hole.radius * scale,
164            }),
165        }
166    }
167
168    /// The same shadow moved by `dx`, `dy`.
169    pub fn translated(self, dx: f32, dy: f32) -> Self {
170        Self {
171            bounds: self.bounds.translate(dx, dy),
172            ..self
173        }
174    }
175
176    /// The rect the hole spans, when there is one.
177    pub fn hole_rect(&self) -> Option<Rect> {
178        self.hole.map(|hole| Rect {
179            x: self.bounds.x + hole.inset,
180            y: self.bounds.y + hole.inset,
181            width: self.bounds.width - 2.0 * hole.inset,
182            height: self.bounds.height - 2.0 * hole.inset,
183        })
184    }
185
186    /// How much of the shadow's color the point `x`, `y` takes: the falloff
187    /// at the offset Skia's mesh interpolates there, zero outside the shadow
188    /// and in its hole.
189    pub fn coverage(&self, x: f32, y: f32) -> f32 {
190        let bounds = self.bounds;
191        let right_edge = bounds.x + bounds.width;
192        let bottom_edge = bounds.y + bounds.height;
193        if x < bounds.x || y < bounds.y || x >= right_edge || y >= bottom_edge {
194            return 0.0;
195        }
196        if self.hole_holds(x, y) {
197            return 0.0;
198        }
199        let (along, from_left) = self.along(x);
200        let (across, from_top) = self.across(y);
201        self.falloff((along, from_left), (across, from_top))
202    }
203
204    /// [`Self::coverage`] at the centres of the pixels `columns` of the row
205    /// whose centres lie at `y`, into `out`: columns alike in what their x
206    /// gives the coverage, as the umbra's middle is, compute it once.
207    pub fn row_coverage(&self, columns: Range<i32>, y: f32, out: &mut [f32]) {
208        let bounds = self.bounds;
209        let right_edge = bounds.x + bounds.width;
210        let bottom_edge = bounds.y + bounds.height;
211        let row_inside = !(y < bounds.y || y >= bottom_edge);
212        let across = self.across(y);
213        let mut last: Option<((u32, bool), f32)> = None;
214        for (slot, column) in out.iter_mut().zip(columns) {
215            let x = column as f32 + 0.5;
216            if !row_inside || x < bounds.x || x >= right_edge || self.hole_holds(x, y) {
217                *slot = 0.0;
218                continue;
219            }
220            let (along, from_left) = self.along(x);
221            let key = (along.to_bits(), from_left);
222            *slot = match last {
223                Some((seen, coverage)) if seen == key => coverage,
224                _ => {
225                    let coverage = self.falloff((along, from_left), across);
226                    last = Some((key, coverage));
227                    coverage
228                }
229            };
230        }
231    }
232
233    // Whether the hole holds the point `x`, `y`.
234    fn hole_holds(&self, x: f32, y: f32) -> bool {
235        match (self.hole, self.hole_rect()) {
236            (Some(hole), Some(rect)) => rounded_rect_contains(rect, hole.radius, x, y),
237            _ => false,
238        }
239    }
240
241    // How far `x` lies out from the umbra toward the nearer upright edge,
242    // at least 0, and whether that edge is the left one.
243    fn along(&self, x: f32) -> (f32, bool) {
244        let umbra = self.umbra_inset;
245        let left = self.bounds.x + umbra - x;
246        let right = x - (self.bounds.x + self.bounds.width - umbra);
247        (left.max(right).max(0.0), left > right)
248    }
249
250    // How far `y` lies out from the umbra toward the nearer horizontal
251    // edge, at least 0, and whether that edge is the top one.
252    fn across(&self, y: f32) -> (f32, bool) {
253        let umbra = self.umbra_inset;
254        let top = self.bounds.y + umbra - y;
255        let bottom = y - (self.bounds.y + self.bounds.height - umbra);
256        (top.max(bottom).max(0.0), top > bottom)
257    }
258
259    // The falloff at a point `along` and `across` out from the umbra, in the
260    // corner on the sides they say.
261    fn falloff(&self, (along, from_left): (f32, bool), (across, from_top): (f32, bool)) -> f32 {
262        let corner = match (from_left, from_top) {
263            (true, true) => 0,
264            (false, true) => 1,
265            (false, false) => 2,
266            (true, false) => 3,
267        };
268        let offset = fan_offset(along, across, self.umbra_inset, self.radii[corner]);
269        shadow_falloff(self.distance_correction * (1.0 - offset))
270    }
271
272    /// What a row of pixels whose centres lie at `y` gives
273    /// [`Self::coverage`] besides x: two rows with equal keys take equal
274    /// coverage at every x, so a raster computes a row once for the rows
275    /// alike that follow it.
276    pub fn row_key(&self, y: f32) -> ShadowRowKey {
277        let bounds = self.bounds;
278        let bottom_edge = bounds.y + bounds.height;
279        let (across, upper) = self.across(y);
280        let hole = match (self.hole, self.hole_rect()) {
281            (Some(hole), Some(rect)) => {
282                let dy = overhang(rect.y, rect.height, hole.radius, y);
283                // Along the hole's upright sides, which x the hole holds
284                // does not depend on how far in the row lies.
285                if dy < 0.0 {
286                    HoleRow::Straight
287                } else {
288                    HoleRow::Curved(dy.to_bits())
289                }
290            }
291            _ => HoleRow::None,
292        };
293        ShadowRowKey {
294            inside: !(y < bounds.y || y >= bottom_edge),
295            across: across.to_bits(),
296            upper,
297            hole,
298        }
299    }
300}
301
302/// What a row of a [`ShadowRRect`] passes to its coverage besides x.
303#[derive(Clone, Copy, Debug, PartialEq, Eq)]
304pub struct ShadowRowKey {
305    inside: bool,
306    // The row's distance out from the umbra toward the nearer horizontal
307    // edge, by its bits, and whether that edge is the top one.
308    across: u32,
309    upper: bool,
310    hole: HoleRow,
311}
312
313#[derive(Clone, Copy, Debug, PartialEq, Eq)]
314enum HoleRow {
315    None,
316    Straight,
317    // How far the row lies past the hole's straight sides, by its bits.
318    Curved(u32),
319}
320
321// How far `at` lies past the straight part of a side `length` long from
322// `start`, whose ends round at `radius`: negative within it.
323fn overhang(start: f32, length: f32, radius: f32, at: f32) -> f32 {
324    let half = 0.5 * length;
325    (at - start - half).abs() - (half - radius)
326}
327
328fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
329    let dx = overhang(rect.x, rect.width, radius, x);
330    let dy = overhang(rect.y, rect.height, radius, y);
331    let outside = (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt();
332    outside + dx.max(dy).min(0.0) - radius < 0.0
333}
334
335/// The length of the offset Skia's corner fan interpolates at `along` and
336/// `across`, a point's distances out from the umbra's corner toward the two
337/// edges, for an umbra inset `umbra` and a corner radius `radius`: 0 at the
338/// umbra and 1 on the outer edge. The fan's triangles meet the edge at the
339/// umbra line, at `umbra - radius` along it and at the corner, whose offsets
340/// Skia skews when the umbra is deeper than the radius.
341pub fn fan_offset(along: f32, across: f32, umbra: f32, radius: f32) -> f32 {
342    let (a, b) = if along >= across {
343        (along, across)
344    } else {
345        (across, along)
346    };
347    if a <= 0.0 || umbra <= 0.0 {
348        return 0.0;
349    }
350    let length = (2.0 * (radius * radius + umbra * umbra)).sqrt();
351    let outer = [(radius - umbra) / length, (-radius - umbra) / length];
352    let straight = umbra - radius;
353    let [x, y] = if b * umbra <= a * straight {
354        let toward_curve = if straight > 0.0 { b / straight } else { 0.0 };
355        let toward_edge = a / umbra - toward_curve;
356        [
357            toward_curve * outer[0],
358            -toward_edge + toward_curve * outer[1],
359        ]
360    } else {
361        let diagonal = umbra / (std::f32::consts::SQRT_2 * (radius - umbra) - radius);
362        let toward_curve = (a - b) / radius;
363        let toward_corner = a / umbra - toward_curve;
364        [
365            toward_curve * outer[0] + toward_corner * diagonal,
366            toward_curve * outer[1] + toward_corner * diagonal,
367        ]
368    };
369    (x * x + y * y).sqrt()
370}
371
372/// Skia's penumbra falloff at `x`, the ramp's coordinate: 0 on the outer
373/// edge, 1 where the shadow is whole, read as its table is.
374pub fn shadow_falloff(x: f32) -> f32 {
375    let texel = ((x * FALLOFF_TEXELS - 0.5) / (FALLOFF_TEXELS - 1.0)).clamp(0.0, 1.0);
376    let distance = 1.0 - texel;
377    ((-4.0 * distance * distance).exp() - FALLOFF_FLOOR).max(0.0)
378}
379
380/// The shadow passes that one layer casts.
381#[derive(Clone, Copy, Debug, Default, PartialEq)]
382pub struct LayerShadowGeometry {
383    /// The ambient shadow and the color it draws with, or `None` when the layer casts none.
384    pub ambient: Option<(ShadowRRect, Color)>,
385    /// The spot shadow and the color it draws with, or `None` when the layer casts none.
386    pub spot: Option<(ShadowRRect, Color)>,
387}
388
389impl LayerShadowGeometry {
390    /// Each pass with the color it draws, ambient first.
391    pub fn passes(&self) -> impl Iterator<Item = (ShadowRRect, Color)> {
392        self.ambient.into_iter().chain(self.spot)
393    }
394}
395
396fn outset(rect: Rect, amount: f32) -> Rect {
397    Rect {
398        x: rect.x - amount,
399        y: rect.y - amount,
400        width: rect.width + amount * 2.0,
401        height: rect.height + amount * 2.0,
402    }
403}
404
405fn corner_radii(radii: CornerRadii) -> [f32; 4] {
406    [
407        radii.top_left,
408        radii.top_right,
409        radii.bottom_right,
410        radii.bottom_left,
411    ]
412}
413
414/// `color` at the alpha Android's renderer gives a pass: the theme's
415/// `share` as a byte, times the caster's alpha, times the color's alpha
416/// byte, truncated to a byte (`multiplyAlpha` in
417/// `ReorderBarrierDrawables.cpp`).
418fn pass_color(color: Color, share: f32, caster_alpha: f32) -> Option<Color> {
419    let share_byte = (share * 255.0).floor();
420    let color_byte = (color.a().clamp(0.0, 1.0) * 255.0).round();
421    let byte = (color_byte * share_byte / 255.0 * caster_alpha).floor();
422    (byte > 0.0).then(|| Color(color.r(), color.g(), color.b(), byte / 255.0))
423}
424
425/// Whether the caster covers the middle of its shadow: an opaque one that
426/// keeps its rect upright. Skia fills the shadow of any other.
427fn caster_occludes(layer: &GraphicsLayer) -> bool {
428    layer.alpha >= 1.0
429        && layer.rotation_x.abs() <= f32::EPSILON
430        && layer.rotation_y.abs() <= f32::EPSILON
431        && layer.rotation_z.abs() <= f32::EPSILON
432}
433
434/// How far the spot shadow strays from its caster at a corner: Skia's
435/// `maxOffset`, the manhattan distance for a rect and for a round rect the
436/// corner centers' distance plus the radii's difference.
437fn spot_stray(caster: Rect, spot: Rect, caster_radius: f32, spot_radius: f32) -> f32 {
438    if caster_radius <= 0.0 {
439        return (spot.x - caster.x)
440            .abs()
441            .max((spot.y - caster.y).abs())
442            .max((spot.x + spot.width - caster.x - caster.width).abs())
443            .max((spot.y + spot.height - caster.y - caster.height).abs());
444    }
445    let grown = spot_radius - caster_radius;
446    let upper = [spot.x - caster.x + grown, spot.y - caster.y + grown];
447    let lower = [
448        spot.x + spot.width - caster.x - caster.width - grown,
449        spot.y + spot.height - caster.y - caster.height - grown,
450    ];
451    let length = |[x, y]: [f32; 2]| x * x + y * y;
452    length(upper).max(length(lower)).sqrt() + grown
453}
454
455/// The ambient and spot passes of `layer`'s shadow over `transformed_bounds`
456/// with the corners `caster` rounds it at, lit by `light`. Matches Skia's
457/// `SurfaceDrawContext::drawFastShadow` and `SkDrawShadowMetrics`.
458pub fn layer_shadow_geometry(
459    layer: &GraphicsLayer,
460    transformed_bounds: Rect,
461    caster: Option<RoundedCornerShape>,
462    light: ShadowLight,
463) -> LayerShadowGeometry {
464    if layer.shadow_elevation <= 0.0 {
465        return LayerShadowGeometry::default();
466    }
467
468    let scale = layer_uniform_scale(layer).max(MIN_LAYER_SHADOW_SCALE);
469    let height = layer.shadow_elevation * scale;
470    let caster_alpha = layer.alpha.clamp(0.0, 1.0);
471    let radii = caster.map_or([0.0; 4], |shape| corner_radii(shape.radii()));
472    let occludes = caster_occludes(layer);
473    let pixels_per_unit = light.pixels_per_unit.max(f32::EPSILON);
474
475    // The ambient shadow reaches half the height past the caster, its
476    // penumbra widened by the height in pixels.
477    let reach = (height * 0.5).min(MAX_AMBIENT_REACH_PIXELS / pixels_per_unit);
478    let ambient_outer = outset(transformed_bounds, reach);
479    let ambient = pass_color(
480        layer.ambient_shadow_color,
481        AMBIENT_SHADOW_ALPHA,
482        caster_alpha,
483    )
484    .and_then(|color| {
485        let blur = reach * (1.0 + height * pixels_per_unit / 128.0);
486        let inset = if occludes { reach } else { ambient_outer.width };
487        ShadowRRect::new(ambient_outer, radii.map(|r| r + reach), blur, inset)
488            .map(|shadow| (shadow, color))
489    });
490
491    // The spot shadow is the caster projected from the light onto the
492    // plane below it: every point moves away from the light by the height's
493    // share of the light's, and the penumbra is the light's radius times it.
494    let depth = light.z - height;
495    let z_ratio = if depth > f32::EPSILON {
496        (height / depth).clamp(0.0, 0.95)
497    } else {
498        0.95
499    };
500    let spot_scale = 1.0 + z_ratio;
501    let spot_blur = light.radius * z_ratio;
502    let projected = Rect {
503        x: transformed_bounds.x + z_ratio * (transformed_bounds.x - light.x),
504        y: transformed_bounds.y + z_ratio * (transformed_bounds.y - light.y),
505        width: transformed_bounds.width * spot_scale,
506        height: transformed_bounds.height * spot_scale,
507    };
508    let spot =
509        pass_color(layer.spot_shadow_color, SPOT_SHADOW_ALPHA, caster_alpha).and_then(|color| {
510            let caster_radius = radii.into_iter().fold(0.0, f32::max);
511            let inset = spot_blur
512                + if occludes {
513                    spot_blur.max(spot_stray(
514                        transformed_bounds,
515                        projected,
516                        caster_radius,
517                        caster_radius * spot_scale,
518                    ))
519                } else {
520                    projected.width
521                };
522            ShadowRRect::new(
523                outset(projected, spot_blur),
524                radii.map(|r| r * spot_scale + spot_blur),
525                2.0 * spot_blur,
526                inset,
527            )
528            .map(|shadow| (shadow, color))
529        });
530
531    LayerShadowGeometry { ambient, spot }
532}
533
534#[cfg(test)]
535#[path = "tests/layer_shadow_tests.rs"]
536mod tests;