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