From 45c64ac76c522a8de13683190c242880f924a9df Mon Sep 17 00:00:00 2001 From: Yegor Jbanov Date: Mon, 9 Mar 2020 15:19:35 -0700 Subject: [PATCH] Revert "Revert "fix shadows and mask filter blurs (#16963)" (#17008)" This reverts commit 619acd58ca55b2a85cf70d0f7861e3c98058972d. --- lib/web_ui/dev/goldens_lock.yaml | 2 +- lib/web_ui/lib/src/engine/bitmap_canvas.dart | 21 +- lib/web_ui/lib/src/engine/canvas_pool.dart | 83 ++-- .../lib/src/engine/compositor/util.dart | 4 - lib/web_ui/lib/src/engine/dom_canvas.dart | 15 +- lib/web_ui/lib/src/engine/shadow.dart | 461 +++++------------- lib/web_ui/lib/src/engine/surface/clip.dart | 5 +- .../src/engine/surface/recording_canvas.dart | 104 ++-- .../lib/src/engine/surface/scene_builder.dart | 1 + .../engine/recording_canvas_golden_test.dart | 175 ++++++- .../engine/shadow_golden_test.dart | 161 ++++++ 11 files changed, 582 insertions(+), 450 deletions(-) create mode 100644 lib/web_ui/test/golden_tests/engine/shadow_golden_test.dart diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index 9d981a4c66700..1b73145f48a3b 100644 --- a/lib/web_ui/dev/goldens_lock.yaml +++ b/lib/web_ui/dev/goldens_lock.yaml @@ -1,2 +1,2 @@ repository: https://github.com/flutter/goldens.git -revision: 1699ba6fd7093a0a610f82618fa30546e7974777 +revision: 8f692819e8881b7d2131dbd61d965c21d5e3e345 diff --git a/lib/web_ui/lib/src/engine/bitmap_canvas.dart b/lib/web_ui/lib/src/engine/bitmap_canvas.dart index bc589172e4be8..c9fdb211b9677 100644 --- a/lib/web_ui/lib/src/engine/bitmap_canvas.dart +++ b/lib/web_ui/lib/src/engine/bitmap_canvas.dart @@ -353,17 +353,16 @@ class BitmapCanvas extends EngineCanvas { @override void drawImage(ui.Image image, ui.Offset p, SurfacePaintData paint) { - //_applyPaint(paint); - final HtmlImage htmlImage = image; - final html.ImageElement imgElement = htmlImage.cloneImageElement(); - String blendMode = _stringForBlendMode(paint.blendMode); - imgElement.style.mixBlendMode = blendMode; - _drawImage(imgElement, p); + _drawImage(image, p, paint); _childOverdraw = true; _canvasPool.allocateExtraCanvas(); } - void _drawImage(html.ImageElement imgElement, ui.Offset p) { + html.ImageElement _drawImage(ui.Image image, ui.Offset p, SurfacePaintData paint) { + final HtmlImage htmlImage = image; + final html.Element imgElement = htmlImage.cloneImageElement(); + final ui.BlendMode blendMode = paint.blendMode; + imgElement.style.mixBlendMode = _stringForBlendMode(blendMode); if (_canvasPool.isClipped) { final List clipElements = _clipContent( _canvasPool._clipStack, imgElement, p, _canvasPool.currentTransform); @@ -380,12 +379,12 @@ class BitmapCanvas extends EngineCanvas { rootElement.append(imgElement); _children.add(imgElement); } + return imgElement; } @override void drawImageRect( ui.Image image, ui.Rect src, ui.Rect dst, SurfacePaintData paint) { - final HtmlImage htmlImage = image; final bool requiresClipping = src.left != 0 || src.top != 0 || src.width != image.width || @@ -395,9 +394,6 @@ class BitmapCanvas extends EngineCanvas { !requiresClipping) { drawImage(image, dst.topLeft, paint); } else { - final html.Element imgElement = htmlImage.cloneImageElement(); - final ui.BlendMode blendMode = paint.blendMode; - imgElement.style.mixBlendMode = _stringForBlendMode(blendMode); if (requiresClipping) { save(); clipRect(dst); @@ -414,7 +410,8 @@ class BitmapCanvas extends EngineCanvas { targetTop += topMargin; } } - _drawImage(imgElement, ui.Offset(targetLeft, targetTop)); + + final html.ImageElement imgElement = _drawImage(image, ui.Offset(targetLeft, targetTop), paint); // To scale set width / height on destination image. // For clipping we need to scale according to // clipped-width/full image width and shift it according to left/top of diff --git a/lib/web_ui/lib/src/engine/canvas_pool.dart b/lib/web_ui/lib/src/engine/canvas_pool.dart index 9df8d14f9eb82..b5119afb82c10 100644 --- a/lib/web_ui/lib/src/engine/canvas_pool.dart +++ b/lib/web_ui/lib/src/engine/canvas_pool.dart @@ -581,49 +581,46 @@ class _CanvasPool extends _SaveStackTracking { void drawShadow(ui.Path path, ui.Color color, double elevation, bool transparentOccluder) { - final List shadows = - ElevationShadow.computeCanvasShadows(elevation, color); - if (shadows.isNotEmpty) { - for (final CanvasShadow shadow in shadows) { - // TODO(het): Shadows with transparent occluders are not supported - // on webkit since filter is unsupported. - if (transparentOccluder && browserEngine != BrowserEngine.webkit) { - // We paint shadows using a path and a mask filter instead of the - // built-in shadow* properties. This is because the color alpha of the - // paint is added to the shadow. The effect we're looking for is to just - // paint the shadow without the path itself, but if we use a non-zero - // alpha for the paint the path is painted in addition to the shadow, - // which is undesirable. - context.save(); - context.translate(shadow.offsetX, shadow.offsetY); - context.filter = _maskFilterToCss( - ui.MaskFilter.blur(ui.BlurStyle.normal, shadow.blur)); - context.strokeStyle = ''; - context.fillStyle = colorToCssString(shadow.color); - _runPath(context, path); - context.fill(); - context.restore(); - } else { - // TODO(het): We fill the path with this paint, then later we clip - // by the same path and fill it with a fully opaque color (we know - // the color is fully opaque because `transparentOccluder` is false. - // However, due to anti-aliasing of the clip, a few pixels of the - // path we are about to paint may still be visible after we fill with - // the opaque occluder. For that reason, we fill with the shadow color, - // and set the shadow color to fully opaque. This way, the visible - // pixels are less opaque and less noticeable. - context.save(); - context.filter = 'none'; - context.strokeStyle = ''; - context.fillStyle = colorToCssString(shadow.color); - context.shadowBlur = shadow.blur; - context.shadowColor = colorToCssString(shadow.color.withAlpha(0xff)); - context.shadowOffsetX = shadow.offsetX; - context.shadowOffsetY = shadow.offsetY; - _runPath(context, path); - context.fill(); - context.restore(); - } + final SurfaceShadowData shadow = computeShadow(path.getBounds(), elevation); + if (shadow != null) { + // TODO(het): Shadows with transparent occluders are not supported + // on webkit since filter is unsupported. + if (transparentOccluder && browserEngine != BrowserEngine.webkit) { + // We paint shadows using a path and a mask filter instead of the + // built-in shadow* properties. This is because the color alpha of the + // paint is added to the shadow. The effect we're looking for is to just + // paint the shadow without the path itself, but if we use a non-zero + // alpha for the paint the path is painted in addition to the shadow, + // which is undesirable. + context.save(); + context.translate(shadow.offset.dx, shadow.offset.dy); + context.filter = _maskFilterToCss( + ui.MaskFilter.blur(ui.BlurStyle.normal, shadow.blurWidth)); + context.strokeStyle = ''; + context.fillStyle = colorToCssString(color); + _runPath(context, path); + context.fill(); + context.restore(); + } else { + // TODO(het): We fill the path with this paint, then later we clip + // by the same path and fill it with a fully opaque color (we know + // the color is fully opaque because `transparentOccluder` is false. + // However, due to anti-aliasing of the clip, a few pixels of the + // path we are about to paint may still be visible after we fill with + // the opaque occluder. For that reason, we fill with the shadow color, + // and set the shadow color to fully opaque. This way, the visible + // pixels are less opaque and less noticeable. + context.save(); + context.filter = 'none'; + context.strokeStyle = ''; + context.fillStyle = colorToCssString(color); + context.shadowBlur = shadow.blurWidth; + context.shadowColor = colorToCssString(color.withAlpha(0xff)); + context.shadowOffsetX = shadow.offset.dx; + context.shadowOffsetY = shadow.offset.dy; + _runPath(context, path); + context.fill(); + context.restore(); } } } diff --git a/lib/web_ui/lib/src/engine/compositor/util.dart b/lib/web_ui/lib/src/engine/compositor/util.dart index 218d3a0de71db..72d53c6d9b52d 100644 --- a/lib/web_ui/lib/src/engine/compositor/util.dart +++ b/lib/web_ui/lib/src/engine/compositor/util.dart @@ -286,10 +286,6 @@ js.JsArray makeSkiaColorStops(List colorStops) { return jsColorStops; } -// These must be kept in sync with `flow/layers/physical_shape_layer.cc`. -const double kLightHeight = 600.0; -const double kLightRadius = 800.0; - void drawSkShadow( js.JsObject skCanvas, SkPath path, diff --git a/lib/web_ui/lib/src/engine/dom_canvas.dart b/lib/web_ui/lib/src/engine/dom_canvas.dart index 970e1be69010a..c774578df2525 100644 --- a/lib/web_ui/lib/src/engine/dom_canvas.dart +++ b/lib/web_ui/lib/src/engine/dom_canvas.dart @@ -81,6 +81,7 @@ class DomCanvas extends EngineCanvas with SaveElementStackTracking { }()); String effectiveTransform; final bool isStroke = paint.style == ui.PaintingStyle.stroke; + final double strokeWidth = paint.strokeWidth ?? 0.0; final double left = math.min(rect.left, rect.right); final double right = math.max(rect.left, rect.right); final double top = math.min(rect.top, rect.bottom); @@ -88,7 +89,7 @@ class DomCanvas extends EngineCanvas with SaveElementStackTracking { if (currentTransform.isIdentity()) { if (isStroke) { effectiveTransform = - 'translate(${left - (paint.strokeWidth / 2.0)}px, ${top - (paint.strokeWidth / 2.0)}px)'; + 'translate(${left - (strokeWidth / 2.0)}px, ${top - (strokeWidth / 2.0)}px)'; } else { effectiveTransform = 'translate(${left}px, ${top}px)'; } @@ -97,7 +98,7 @@ class DomCanvas extends EngineCanvas with SaveElementStackTracking { final Matrix4 translated = currentTransform.clone(); if (isStroke) { translated.translate( - left - (paint.strokeWidth / 2.0), top - (paint.strokeWidth / 2.0)); + left - (strokeWidth / 2.0), top - (strokeWidth / 2.0)); } else { translated.translate(left, top); } @@ -109,8 +110,8 @@ class DomCanvas extends EngineCanvas with SaveElementStackTracking { ..transformOrigin = '0 0 0' ..transform = effectiveTransform; - final String cssColor = paint.color == null ? '#000000' - : colorToCssString(paint.color); + final String cssColor = + paint.color == null ? '#000000' : colorToCssString(paint.color); if (paint.maskFilter != null) { style.filter = 'blur(${paint.maskFilter.webOnlySigma}px)'; @@ -118,9 +119,9 @@ class DomCanvas extends EngineCanvas with SaveElementStackTracking { if (isStroke) { style - ..width = '${right - left - paint.strokeWidth}px' - ..height = '${bottom - top - paint.strokeWidth}px' - ..border = '${paint.strokeWidth}px solid $cssColor'; + ..width = '${right - left - strokeWidth}px' + ..height = '${bottom - top - strokeWidth}px' + ..border = '${strokeWidth}px solid $cssColor'; } else { style ..width = '${right - left}px' diff --git a/lib/web_ui/lib/src/engine/shadow.dart b/lib/web_ui/lib/src/engine/shadow.dart index 5e63f74e186ea..3c46dac7681b5 100644 --- a/lib/web_ui/lib/src/engine/shadow.dart +++ b/lib/web_ui/lib/src/engine/shadow.dart @@ -5,366 +5,135 @@ // @dart = 2.6 part of engine; -/// This code is ported from the AngularDart SCSS. +/// How far is the light source from the surface of the UI. /// -/// See: https://github.com/dart-lang/angular_components/blob/master/lib/css/material/_shadow.scss -class ElevationShadow { - /// Applies a standard transition style for box-shadow to box-shadow. - static void applyShadowTransition(html.CssStyleDeclaration style) { - style.transition = 'box-shadow .28s cubic-bezier(.4, 0, .2, 1)'; - } - - /// Disables box-shadow. - static void applyShadowNone(html.CssStyleDeclaration style) { - style.boxShadow = 'none'; - } +/// Must be kept in sync with `flow/layers/physical_shape_layer.cc`. +const double kLightHeight = 600.0; - /// Applies a standard shadow to the selected element(s). - /// - /// This rule is great for things that need a static shadow. If the elevation - /// of the shadow needs to be changed dynamically, use [applyShadow]. - /// - /// Valid values: 2, 3, 4, 6, 8, 12, 16, 24 - static void applyShadowElevation(html.CssStyleDeclaration style, - {@required int dp, @required ui.Color color}) { - const double keyUmbraOpacity = 0.2; - const double keyPenumbraOpacity = 0.14; - const double ambientShadowOpacity = 0.12; +/// The radius of the light source. The positive radius creates a penumbra in +/// the shadow, which we express using a blur effect. +/// +/// Must be kept in sync with `flow/layers/physical_shape_layer.cc`. +const double kLightRadius = 800.0; - final String rgb = '${color.red}, ${color.green}, ${color.blue}'; - if (dp == 2) { - style.boxShadow = '0 2px 2px 0 rgba($rgb, $keyPenumbraOpacity), ' - '0 3px 1px -2px rgba($rgb, $ambientShadowOpacity), ' - '0 1px 5px 0 rgba($rgb, $keyUmbraOpacity)'; - } else if (dp == 3) { - style.boxShadow = '0 3px 4px 0 rgba($rgb, $keyPenumbraOpacity), ' - '0 3px 3px -2px rgba($rgb, $ambientShadowOpacity), ' - '0 1px 8px 0 rgba($rgb, $keyUmbraOpacity)'; - } else if (dp == 4) { - style.boxShadow = '0 4px 5px 0 rgba($rgb, $keyPenumbraOpacity), ' - '0 1px 10px 0 rgba($rgb, $ambientShadowOpacity), ' - '0 2px 4px -1px rgba($rgb, $keyUmbraOpacity)'; - } else if (dp == 6) { - style.boxShadow = '0 6px 10px 0 rgba($rgb, $keyPenumbraOpacity), ' - '0 1px 18px 0 rgba($rgb, $ambientShadowOpacity), ' - '0 3px 5px -1px rgba($rgb, $keyUmbraOpacity)'; - } else if (dp == 8) { - style.boxShadow = '0 8px 10px 1px rgba($rgb, $keyPenumbraOpacity), ' - '0 3px 14px 2px rgba($rgb, $ambientShadowOpacity), ' - '0 5px 5px -3px rgba($rgb, $keyUmbraOpacity)'; - } else if (dp == 12) { - style.boxShadow = '0 12px 17px 2px rgba($rgb, $keyPenumbraOpacity), ' - '0 5px 22px 4px rgba($rgb, $ambientShadowOpacity), ' - '0 7px 8px -4px rgba($rgb, $keyUmbraOpacity)'; - } else if (dp == 16) { - style.boxShadow = '0 16px 24px 2px rgba($rgb, $keyPenumbraOpacity), ' - '0 6px 30px 5px rgba($rgb, $ambientShadowOpacity), ' - '0 8px 10px -5px rgba($rgb, $keyUmbraOpacity)'; - } else { - style.boxShadow = '0 24px 38px 3px rgba($rgb, $keyPenumbraOpacity), ' - '0 9px 46px 8px rgba($rgb, $ambientShadowOpacity), ' - '0 11px 15px -7px rgba($rgb, $keyUmbraOpacity)'; - } - } +/// The X offset of the list source relative to the center of the shape. +/// +/// This shifts the shadow along the X asix as if the light beams at an angle. +const double kLightOffsetX = -200.0; - /// Applies the shadow styles to the selected element. - /// - /// Use the attributes below to control the shadow. - /// - /// - `animated` -- Whether to animate the shadow transition. - /// - `elevation` -- Z-elevation of shadow. Valid Values: 1,2,3,4,5 - static void applyShadow( - html.CssStyleDeclaration style, double elevation, ui.Color color) { - applyShadowTransition(style); +/// The Y offset of the list source relative to the center of the shape. +/// +/// This shifts the shadow along the Y asix as if the light beams at an angle. +const double kLightOffsetY = -400.0; - if (elevation <= 0.0) { - applyShadowNone(style); - } else if (elevation <= 1.0) { - applyShadowElevation(style, dp: 2, color: color); - } else if (elevation <= 2.0) { - applyShadowElevation(style, dp: 4, color: color); - } else if (elevation <= 3.0) { - applyShadowElevation(style, dp: 6, color: color); - } else if (elevation <= 4.0) { - applyShadowElevation(style, dp: 8, color: color); - } else if (elevation <= 5.0) { - applyShadowElevation(style, dp: 16, color: color); - } else { - applyShadowElevation(style, dp: 24, color: color); - } +/// Computes the offset that moves the shadow due to the light hitting the +/// shape at an angle. +/// +/// ------ light +/// \ +/// \ +/// \ +/// \ +/// \ +/// --------- shape +/// |\ +/// | \ +/// | \ +/// ------------x---x------------ +/// |<->| offset +/// +/// This is not a complete physical model. For example, this does not take into +/// account the size of the shape (this function doesn't even take the shape as +/// a parameter). It's just a good enough approximation. +ui.Offset computeShadowOffset(elevation) { + if (elevation == 0.0) { + return ui.Offset.zero; } - static List computeCanvasShadows( - double elevation, ui.Color color) { - if (elevation <= 0.0) { - return const []; - } else if (elevation <= 1.0) { - return computeShadowElevation(dp: 2, color: color); - } else if (elevation <= 2.0) { - return computeShadowElevation(dp: 4, color: color); - } else if (elevation <= 3.0) { - return computeShadowElevation(dp: 6, color: color); - } else if (elevation <= 4.0) { - return computeShadowElevation(dp: 8, color: color); - } else if (elevation <= 5.0) { - return computeShadowElevation(dp: 16, color: color); - } else { - return computeShadowElevation(dp: 24, color: color); - } - } + final double dx = -kLightOffsetX * elevation / kLightHeight; + final double dy = -kLightOffsetY * elevation / kLightHeight; + return ui.Offset(dx, dy); +} - /// Expands rect to include size of shadow. - /// - /// Computed from shadow elevation offset + spread, blur - static ui.Rect computeShadowRect(ui.Rect r, double elevation) { - // We are computing this rect by computing the maximum "reach" of the shadow - // by summing the computed shadow offset and the blur for the given - // elevation. We are assuming that a blur of '1' corresponds to 1 pixel, - // although the web spec says that this is not necessarily the case. - // However, it seems to be a good conservative estimate. - if (elevation <= 0.0) { - return r; - } else if (elevation <= 1.0) { - return ui.Rect.fromLTRB( - r.left - 2.5, r.top - 1.5, r.right + 3, r.bottom + 4); - } else if (elevation <= 2.0) { - return ui.Rect.fromLTRB(r.left - 5, r.top - 3, r.right + 6, r.bottom + 7); - } else if (elevation <= 3.0) { - return ui.Rect.fromLTRB( - r.left - 9, r.top - 8, r.right + 9, r.bottom + 11); - } else if (elevation <= 4.0) { - return ui.Rect.fromLTRB( - r.left - 10, r.top - 6, r.right + 10, r.bottom + 14); - } else if (elevation <= 5.0) { - return ui.Rect.fromLTRB( - r.left - 15, r.top - 9, r.right + 20, r.bottom + 30); - } else { - return ui.Rect.fromLTRB( - r.left - 23, r.top - 14, r.right + 23, r.bottom + 45); - } +/// Computes the rectangle that contains the penumbra of the shadow cast by +/// the [shape] that's elevated above the surface of the screen at [elevation]. +ui.Rect computePenumbraBounds(ui.Rect shape, double elevation) { + if (elevation == 0.0) { + return shape; } - static List computeShadowElevation( - {@required int dp, @required ui.Color color}) { - final int red = color.red; - final int green = color.green; - final int blue = color.blue; - - final ui.Color penumbraColor = ui.Color.fromARGB(36, red, green, blue); - final ui.Color ambientShadowColor = ui.Color.fromARGB(31, red, green, blue); - final ui.Color umbraColor = ui.Color.fromARGB(51, red, green, blue); - - final List result = []; - if (dp == 2) { - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 2.0, - blur: 1.0, - spread: 0.0, - color: penumbraColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 3.0, - blur: 0.5, - spread: -2.0, - color: ambientShadowColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 1.0, - blur: 2.5, - spread: 0.0, - color: umbraColor, - )); - } else if (dp == 3) { - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 1.5, - blur: 4.0, - spread: 0.0, - color: penumbraColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 3.0, - blur: 1.5, - spread: -2.0, - color: ambientShadowColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 1.0, - blur: 4.0, - spread: 0.0, - color: umbraColor, - )); - } else if (dp == 4) { - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 4.0, - blur: 2.5, - spread: 0.0, - color: penumbraColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 1.0, - blur: 5.0, - spread: 0.0, - color: ambientShadowColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 2.0, - blur: 2.0, - spread: -1.0, - color: umbraColor, - )); - } else if (dp == 6) { - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 6.0, - blur: 5.0, - spread: 0.0, - color: penumbraColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 1.0, - blur: 9.0, - spread: 0.0, - color: ambientShadowColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 3.0, - blur: 2.5, - spread: -1.0, - color: umbraColor, - )); - } else if (dp == 8) { - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 4.0, - blur: 10.0, - spread: 1.0, - color: penumbraColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 3.0, - blur: 7.0, - spread: 2.0, - color: ambientShadowColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 5.0, - blur: 2.5, - spread: -3.0, - color: umbraColor, - )); - } else if (dp == 12) { - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 12.0, - blur: 8.5, - spread: 2.0, - color: penumbraColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 5.0, - blur: 11.0, - spread: 4.0, - color: ambientShadowColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 7.0, - blur: 4.0, - spread: -4.0, - color: umbraColor, - )); - } else if (dp == 16) { - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 16.0, - blur: 12.0, - spread: 2.0, - color: penumbraColor, - )); - - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 6.0, - blur: 15.0, - spread: 5.0, - color: ambientShadowColor, - )); + // tangent for x + final double tx = (kLightRadius + shape.width * 0.5) / kLightHeight; + // tangent for y + final double ty = (kLightRadius + shape.height * 0.5) / kLightHeight; + final double dx = elevation * tx; + final double dy = elevation * ty; + final ui.Offset offset = computeShadowOffset(elevation); + return ui.Rect.fromLTRB( + shape.left - dx, + shape.top - dy, + shape.right + dx, + shape.bottom + dy, + ).shift(offset); +} - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 0.0, - blur: 5.0, - spread: -5.0, - color: umbraColor, - )); - } else { - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 24.0, - blur: 18.0, - spread: 3.0, - color: penumbraColor, - )); +/// Information needed to render a shadow using CSS or canvas. +@immutable +class SurfaceShadowData { + const SurfaceShadowData({ + @required this.blurWidth, + @required this.offset, + }); - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 9.0, - blur: 23.0, - spread: 8.0, - color: ambientShadowColor, - )); + /// The length in pixels of the shadow. + /// + /// This is different from the `sigma` used by blur filters. This value + /// contains the entire shadow, so, for example, to compute the shadow + /// bounds it is sufficient to add this value to the width of the shape + /// that casts it. + final double blurWidth; + + /// The offset of the shadow relative to the shape as computed by + /// [computeShadowOffset]. + final ui.Offset offset; +} - result.add(CanvasShadow( - offsetX: 0.0, - offsetY: 11.0, - blur: 7.5, - spread: -7.0, - color: umbraColor, - )); - } - return result; +/// Computes the shadow for [shape] based on its [elevation] from the surface +/// of the screen. +/// +/// The algorithm approximates the math done by the C++ implementation from +/// `physical_shape_layer.cc` but it's not exact, since on the Web we do not +/// (cannot) use Skia's shadow API directly. However, this algorithms is +/// consistent with [computePenumbraBounds] used by [RecordingCanvas] during +/// bounds estimation. +SurfaceShadowData computeShadow(ui.Rect shape, double elevation) { + if (elevation == 0.0) { + return null; } -} -class CanvasShadow { - CanvasShadow({ - @required this.offsetX, - @required this.offsetY, - @required this.blur, - @required this.spread, - @required this.color, - }); + final double penumbraTangentX = + (kLightRadius + shape.width * 0.5) / kLightHeight; + final double penumbraTangentY = + (kLightRadius + shape.height * 0.5) / kLightHeight; + final double penumbraWidth = elevation * penumbraTangentX; + final double penumbraHeight = elevation * penumbraTangentY; + return SurfaceShadowData( + // There's no way to express different blur along different dimensions, so + // we use the narrower of the two to prevent the shadow blur from being longer + // than the shape itself, using min instead of average of penumbra values. + blurWidth: math.min(penumbraWidth, penumbraHeight), + offset: computeShadowOffset(elevation), + ); +} - final double offsetX; - final double offsetY; - final double blur; - // TODO(yjbanov): is there a way to implement/emulate spread on Canvas2D? - final double spread; - final ui.Color color; +/// Applies a CSS shadow to the [shape]. +void applyCssShadow( + html.Element element, ui.Rect shape, double elevation, ui.Color color) { + final SurfaceShadowData shadow = computeShadow(shape, elevation); + if (shadow == null) { + element.style.boxShadow = 'none'; + } else { + element.style.boxShadow = '${shadow.offset.dx}px ${shadow.offset.dy}px ' + '${shadow.blurWidth}px 0px rgb(${color.red}, ${color.green}, ${color.blue})'; + } } diff --git a/lib/web_ui/lib/src/engine/surface/clip.dart b/lib/web_ui/lib/src/engine/surface/clip.dart index 55692e092ac83..43c683b21b004 100644 --- a/lib/web_ui/lib/src/engine/surface/clip.dart +++ b/lib/web_ui/lib/src/engine/surface/clip.dart @@ -162,9 +162,11 @@ class PersistedPhysicalShape extends PersistedContainerSurface this.elevation, int color, int shadowColor, this.clipBehavior) : color = ui.Color(color), shadowColor = ui.Color(shadowColor), + pathBounds = path.getBounds(), super(oldLayer); final SurfacePath path; + final ui.Rect pathBounds; final double elevation; final ui.Color color; final ui.Color shadowColor; @@ -195,7 +197,7 @@ class PersistedPhysicalShape extends PersistedContainerSurface } void _applyShadow() { - ElevationShadow.applyShadow(rootElement.style, elevation, shadowColor); + applyCssShadow(rootElement, pathBounds, elevation, shadowColor); } @override @@ -279,7 +281,6 @@ class PersistedPhysicalShape extends PersistedContainerSurface } } - final ui.Rect pathBounds = path.getBounds(); final String svgClipPath = _pathToSvgClipPath(path, offsetX: -pathBounds.left, offsetY: -pathBounds.top, diff --git a/lib/web_ui/lib/src/engine/surface/recording_canvas.dart b/lib/web_ui/lib/src/engine/surface/recording_canvas.dart index 92ce62b93e303..5b26210ae2af2 100644 --- a/lib/web_ui/lib/src/engine/surface/recording_canvas.dart +++ b/lib/web_ui/lib/src/engine/surface/recording_canvas.dart @@ -188,7 +188,7 @@ class RecordingCanvas { } void drawLine(ui.Offset p1, ui.Offset p2, SurfacePaint paint) { - final double strokeWidth = math.max(paint.strokeWidth, 1.0); + final double paintSpread = math.max(_getPaintSpread(paint), 1.0); // TODO(yjbanov): This can be optimized. Currently we create a box around // the line and then apply the transform on the box to get // the bounding box. If you have a 45-degree line and a @@ -197,10 +197,11 @@ class RecordingCanvas { // algorithm produces a square with each side of the length // matching the length of the line. _paintBounds.growLTRB( - math.min(p1.dx, p2.dx) - strokeWidth, - math.min(p1.dy, p2.dy) - strokeWidth, - math.max(p1.dx, p2.dx) + strokeWidth, - math.max(p1.dy, p2.dy) + strokeWidth); + math.min(p1.dx, p2.dx) - paintSpread, + math.min(p1.dy, p2.dy) - paintSpread, + math.max(p1.dx, p2.dx) + paintSpread, + math.max(p1.dy, p2.dy) + paintSpread, + ); _hasArbitraryPaint = true; _didDraw = true; _commands.add(PaintDrawLine(p1, p2, paint.paintData)); @@ -218,8 +219,9 @@ class RecordingCanvas { _hasArbitraryPaint = true; } _didDraw = true; - if (paint.strokeWidth != null && paint.strokeWidth != 0) { - _paintBounds.grow(rect.inflate(paint.strokeWidth / 2.0)); + final double paintSpread = _getPaintSpread(paint); + if (paintSpread != 0.0) { + _paintBounds.grow(rect.inflate(paintSpread)); } else { _paintBounds.grow(rect); } @@ -231,12 +233,11 @@ class RecordingCanvas { _hasArbitraryPaint = true; } _didDraw = true; - final double strokeWidth = - paint.strokeWidth == null ? 0 : paint.strokeWidth; - final double left = math.min(rrect.left, rrect.right) - strokeWidth; - final double right = math.max(rrect.left, rrect.right) + strokeWidth; - final double top = math.min(rrect.top, rrect.bottom) - strokeWidth; - final double bottom = math.max(rrect.top, rrect.bottom) + strokeWidth; + final double paintSpread = _getPaintSpread(paint); + final double left = math.min(rrect.left, rrect.right) - paintSpread; + final double top = math.min(rrect.top, rrect.bottom) - paintSpread; + final double right = math.max(rrect.left, rrect.right) + paintSpread; + final double bottom = math.max(rrect.top, rrect.bottom) + paintSpread; _paintBounds.growLTRB(left, top, right, bottom); _commands.add(PaintDrawRRect(rrect, paint.paintData)); } @@ -281,18 +282,22 @@ class RecordingCanvas { _hasArbitraryPaint = true; _didDraw = true; - final double strokeWidth = - paint.strokeWidth == null ? 0 : paint.strokeWidth; - _paintBounds.growLTRB(outer.left - strokeWidth, outer.top - strokeWidth, - outer.right + strokeWidth, outer.bottom + strokeWidth); + final double paintSpread = _getPaintSpread(paint); + _paintBounds.growLTRB( + outer.left - paintSpread, + outer.top - paintSpread, + outer.right + paintSpread, + outer.bottom + paintSpread, + ); _commands.add(PaintDrawDRRect(outer, inner, paint.paintData)); } void drawOval(ui.Rect rect, SurfacePaint paint) { _hasArbitraryPaint = true; _didDraw = true; - if (paint.strokeWidth != null) { - _paintBounds.grow(rect.inflate(paint.strokeWidth)); + final double paintSpread = _getPaintSpread(paint); + if (paintSpread != 0.0) { + _paintBounds.grow(rect.inflate(paintSpread)); } else { _paintBounds.grow(rect); } @@ -302,13 +307,13 @@ class RecordingCanvas { void drawCircle(ui.Offset c, double radius, SurfacePaint paint) { _hasArbitraryPaint = true; _didDraw = true; - final double strokeWidth = - paint.strokeWidth == null ? 0 : paint.strokeWidth; + final double paintSpread = _getPaintSpread(paint); _paintBounds.growLTRB( - c.dx - radius - strokeWidth, - c.dy - radius - strokeWidth, - c.dx + radius + strokeWidth, - c.dy + radius + strokeWidth); + c.dx - radius - paintSpread, + c.dy - radius - paintSpread, + c.dx + radius + paintSpread, + c.dy + radius + paintSpread, + ); _commands.add(PaintDrawCircle(c, radius, paint.paintData)); } @@ -331,8 +336,9 @@ class RecordingCanvas { _hasArbitraryPaint = true; _didDraw = true; ui.Rect pathBounds = path.getBounds(); - if (paint.strokeWidth != null) { - pathBounds = pathBounds.inflate(paint.strokeWidth); + final double paintSpread = _getPaintSpread(paint); + if (paintSpread != 0.0) { + pathBounds = pathBounds.inflate(paintSpread); } _paintBounds.grow(pathBounds); // Clone path so it can be reused for subsequent draw calls. @@ -381,7 +387,7 @@ class RecordingCanvas { _hasArbitraryPaint = true; _didDraw = true; final ui.Rect shadowRect = - ElevationShadow.computeShadowRect(path.getBounds(), elevation); + computePenumbraBounds(path.getBounds(), elevation); _paintBounds.grow(shadowRect); _commands.add(PaintDrawShadow(path, color, elevation, transparentOccluder)); } @@ -390,23 +396,23 @@ class RecordingCanvas { ui.Vertices vertices, ui.BlendMode blendMode, SurfacePaint paint) { _hasArbitraryPaint = true; _didDraw = true; - _growPaintBoundsByPoints(vertices.positions, 0); + _growPaintBoundsByPoints(vertices.positions, 0, paint); _commands.add(PaintVertices(vertices, blendMode, paint.paintData)); } void drawRawPoints( - ui.PointMode pointMode, Float32List points, ui.Paint paint) { + ui.PointMode pointMode, Float32List points, SurfacePaint paint) { if (paint.strokeWidth == null) { return; } _hasArbitraryPaint = true; _didDraw = true; - _growPaintBoundsByPoints(points, paint.strokeWidth); + _growPaintBoundsByPoints(points, paint.strokeWidth, paint); _commands .add(PaintPoints(pointMode, points, paint.strokeWidth, paint.color)); } - void _growPaintBoundsByPoints(Float32List points, double thickness) { + void _growPaintBoundsByPoints(Float32List points, double thickness, SurfacePaint paint) { double minValueX, maxValueX, minValueY, maxValueY; minValueX = maxValueX = points[0]; minValueY = maxValueY = points[1]; @@ -424,8 +430,13 @@ class RecordingCanvas { maxValueY = math.max(maxValueY, y); } final double distance = thickness / 2.0; - _paintBounds.growLTRB(minValueX - distance, minValueY - distance, - maxValueX + distance, maxValueY + distance); + final double paintSpread = _getPaintSpread(paint); + _paintBounds.growLTRB( + minValueX - distance - paintSpread, + minValueY - distance - paintSpread, + maxValueX + distance + paintSpread, + maxValueY + distance + paintSpread, + ); } int _saveCount = 1; @@ -1937,3 +1948,28 @@ class _PaintBounds { } } } + +/// Computes the length of the visual effect caused by paint parameters, such +/// as blur and stroke width. +/// +/// This paint spread should be taken into accound when estimating bounding +/// boxes for paint operations that apply the paint. +double _getPaintSpread(SurfacePaint paint) { + double spread = 0.0; + final ui.MaskFilter maskFilter = paint?.maskFilter; + if (maskFilter != null) { + // Multiply by 2 because the sigma is the standard deviation rather than + // the length of the blur. + // See also: https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/blur + spread += maskFilter.webOnlySigma * 2.0; + } + if (paint.strokeWidth != null && paint.strokeWidth != 0) { + // The multiplication by sqrt(2) is to account for line joints that + // meet at 90-degree angle. Division by 2 is because only half of the + // stroke is sticking out of the original shape. The other half is + // inside the shape. + const double sqrtOfTwoDivByTwo = 0.70710678118; + spread += paint.strokeWidth * sqrtOfTwoDivByTwo; + } + return spread; +} diff --git a/lib/web_ui/lib/src/engine/surface/scene_builder.dart b/lib/web_ui/lib/src/engine/surface/scene_builder.dart index cbd0b2913fe62..3f9a45f3d16e4 100644 --- a/lib/web_ui/lib/src/engine/surface/scene_builder.dart +++ b/lib/web_ui/lib/src/engine/surface/scene_builder.dart @@ -244,6 +244,7 @@ class SurfaceSceneBuilder implements ui.SceneBuilder { ui.Clip clipBehavior = ui.Clip.none, ui.PhysicalShapeEngineLayer oldLayer, }) { + assert(color != null, 'color must not be null'); return _pushSurface(PersistedPhysicalShape( oldLayer, path, diff --git a/lib/web_ui/test/golden_tests/engine/recording_canvas_golden_test.dart b/lib/web_ui/test/golden_tests/engine/recording_canvas_golden_test.dart index 44c9f44720357..c015a1ea4ec45 100644 --- a/lib/web_ui/test/golden_tests/engine/recording_canvas_golden_test.dart +++ b/lib/web_ui/test/golden_tests/engine/recording_canvas_golden_test.dart @@ -341,7 +341,9 @@ void main() async { path.addRect(const Rect.fromLTRB(20, 30, 100, 110)); rc.drawShadow(path, const Color(0xFFFF0000), 2.0, true); expect( - rc.computePaintBounds(), const Rect.fromLTRB(15.0, 27.0, 106.0, 117.0)); + rc.computePaintBounds(), + within(distance: 0.05, from: const Rect.fromLTRB(17.9, 28.5, 103.5, 114.1)), + ); await _checkScreenshot(rc, 'path_with_shadow'); }); @@ -440,6 +442,177 @@ void main() async { rc.restore(); await _checkScreenshot(rc, 'path_with_line_and_roundrect'); }); + + test('should include paint spread in bounds estimates', () async { + final SurfaceSceneBuilder sb = SurfaceSceneBuilder(); + + final List painters = [ + (RecordingCanvas canvas, SurfacePaint paint) { + canvas.drawLine( + const Offset(0.0, 0.0), + const Offset(20.0, 20.0), + paint, + ); + }, + (RecordingCanvas canvas, SurfacePaint paint) { + canvas.drawRect( + const Rect.fromLTRB(0.0, 0.0, 20.0, 20.0), + paint, + ); + }, + (RecordingCanvas canvas, SurfacePaint paint) { + canvas.drawRRect( + RRect.fromLTRBR(0.0, 0.0, 20.0, 20.0, Radius.circular(7.0)), + paint, + ); + }, + (RecordingCanvas canvas, SurfacePaint paint) { + canvas.drawDRRect( + RRect.fromLTRBR(0.0, 0.0, 20.0, 20.0, Radius.circular(5.0)), + RRect.fromLTRBR(4.0, 4.0, 16.0, 16.0, Radius.circular(5.0)), + paint, + ); + }, + (RecordingCanvas canvas, SurfacePaint paint) { + canvas.drawOval( + const Rect.fromLTRB(0.0, 5.0, 20.0, 15.0), + paint, + ); + }, + (RecordingCanvas canvas, SurfacePaint paint) { + canvas.drawCircle( + const Offset(10.0, 10.0), + 10.0, + paint, + ); + }, + (RecordingCanvas canvas, SurfacePaint paint) { + final SurfacePath path = SurfacePath() + ..moveTo(10, 0) + ..lineTo(20, 10) + ..lineTo(10, 20) + ..lineTo(0, 10) + ..close(); + canvas.drawPath(path, paint); + }, + + // Images are not affected by mask filter or stroke width. They use image + // filter instead. + (RecordingCanvas canvas, SurfacePaint paint) { + canvas.drawImage(_createRealTestImage(), Offset.zero, paint); + }, + (RecordingCanvas canvas, SurfacePaint paint) { + canvas.drawImageRect( + _createRealTestImage(), + const Rect.fromLTRB(0, 0, 20, 20), + const Rect.fromLTRB(5, 5, 15, 15), + paint, + ); + }, + ]; + + Picture drawBounds(Rect bounds) { + final EnginePictureRecorder recorder = EnginePictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + canvas.drawRect( + bounds, + SurfacePaint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0 + ..color = const Color.fromARGB(255, 0, 255, 0), + ); + return recorder.endRecording(); + } + + for (int i = 0; i < painters.length; i++) { + sb.pushOffset(0.0, 20.0 + 60.0 * i); + final PaintSpreadPainter painter = painters[i]; + + // Paint with zero paint spread. + { + sb.pushOffset(20.0, 0.0); + final EnginePictureRecorder recorder = EnginePictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + final SurfacePaint zeroSpreadPaint = SurfacePaint(); + painter(canvas, zeroSpreadPaint); + sb.addPicture(Offset.zero, recorder.endRecording()); + sb.addPicture(Offset.zero, drawBounds(canvas.computePaintBounds())); + sb.pop(); + } + + // Paint with a thick stroke paint. + { + sb.pushOffset(80.0, 0.0); + final EnginePictureRecorder recorder = EnginePictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + final SurfacePaint thickStrokePaint = SurfacePaint() + ..style = PaintingStyle.stroke + ..strokeWidth = 5.0; + painter(canvas, thickStrokePaint); + sb.addPicture(Offset.zero, recorder.endRecording()); + sb.addPicture(Offset.zero, drawBounds(canvas.computePaintBounds())); + sb.pop(); + } + + // Paint with a mask filter blur. + { + sb.pushOffset(140.0, 0.0); + final EnginePictureRecorder recorder = EnginePictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + final SurfacePaint maskFilterBlurPaint = SurfacePaint() + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5.0); + painter(canvas, maskFilterBlurPaint); + sb.addPicture(Offset.zero, recorder.endRecording()); + sb.addPicture(Offset.zero, drawBounds(canvas.computePaintBounds())); + sb.pop(); + } + + // Paint with a thick stroke paint and a mask filter blur. + { + sb.pushOffset(200.0, 0.0); + final EnginePictureRecorder recorder = EnginePictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + final SurfacePaint thickStrokeAndBlurPaint = SurfacePaint() + ..style = PaintingStyle.stroke + ..strokeWidth = 5.0 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5.0); + painter(canvas, thickStrokeAndBlurPaint); + sb.addPicture(Offset.zero, recorder.endRecording()); + sb.addPicture(Offset.zero, drawBounds(canvas.computePaintBounds())); + sb.pop(); + } + + sb.pop(); + } + + final html.Element sceneElement = sb.build().webOnlyRootElement; + html.document.body.append(sceneElement); + try { + await matchGoldenFile( + 'paint_spread_bounds.png', + region: const Rect.fromLTRB(0, 0, 250, 600), + maxDiffRatePercent: 0.0, + pixelComparison: PixelComparison.precise, + ); + } finally { + sceneElement.remove(); + } + }); +} + +typedef PaintSpreadPainter = void Function(RecordingCanvas canvas, SurfacePaint paint); + +const String _base64Encoded20x20TestImage = 'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAACXBIWXMAAC4jAAAuIwF4pT92AAAA' + 'B3RJTUUH5AMFFBksg4i3gQAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAj' + 'SURBVDjLY2TAC/7jlWVioACMah4ZmhnxpyHG0QAb1UyZZgBjWAIm/clP0AAAAABJRU5ErkJggg=='; + +HtmlImage _createRealTestImage() { + return HtmlImage( + html.ImageElement() + ..src = 'data:text/plain;base64,$_base64Encoded20x20TestImage', + 20, + 20, + ); } class TestImage implements Image { diff --git a/lib/web_ui/test/golden_tests/engine/shadow_golden_test.dart b/lib/web_ui/test/golden_tests/engine/shadow_golden_test.dart new file mode 100644 index 0000000000000..d5f3dc8388e7c --- /dev/null +++ b/lib/web_ui/test/golden_tests/engine/shadow_golden_test.dart @@ -0,0 +1,161 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:html' as html; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart'; +import 'package:test/test.dart'; + +import 'package:web_engine_tester/golden_tester.dart'; + +import 'scuba.dart'; + +const Color _kShadowColor = Color.fromARGB(255, 255, 0, 0); + +void main() async { + final Rect region = Rect.fromLTWH(0, 0, 550, 300); + + SurfaceSceneBuilder builder; + + setUpStableTestFonts(); + + setUp(() { + builder = SurfaceSceneBuilder(); + }); + + void _paintShapeOutline() { + final EnginePictureRecorder recorder = PictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + canvas.drawRect( + const Rect.fromLTRB(0.0, 0.0, 20.0, 20.0), + SurfacePaint() + ..color = Color.fromARGB(255, 0, 0, 255) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0, + ); + builder.addPicture(Offset.zero, recorder.endRecording()); + } + + void _paintShadowBounds(SurfacePath path, double elevation) { + final Rect shadowBounds = + computePenumbraBounds(path.getBounds(), elevation); + final EnginePictureRecorder recorder = PictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + canvas.drawRect( + shadowBounds, + SurfacePaint() + ..color = Color.fromARGB(255, 0, 255, 0) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0, + ); + builder.addPicture(Offset.zero, recorder.endRecording()); + } + + void _paintPhysicalShapeShadow(double elevation, Offset offset) { + final SurfacePath path = SurfacePath() + ..addRect(const Rect.fromLTRB(0, 0, 20, 20)); + builder.pushOffset(offset.dx, offset.dy); + builder.pushPhysicalShape( + path: path, + elevation: elevation, + shadowColor: _kShadowColor, + color: Color.fromARGB(255, 255, 255, 255), + ); + builder.pop(); // physical shape + _paintShapeOutline(); + _paintShadowBounds(path, elevation); + builder.pop(); // offset + } + + void _paintBitmapCanvasShadow(double elevation, Offset offset) { + final SurfacePath path = SurfacePath() + ..addRect(const Rect.fromLTRB(0, 0, 20, 20)); + builder.pushOffset(offset.dx, offset.dy); + + final EnginePictureRecorder recorder = PictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + canvas + .debugEnforceArbitraryPaint(); // make sure DOM canvas doesn't take over + canvas.drawShadow( + path, + _kShadowColor, + elevation, + false, + ); + builder.addPicture(Offset.zero, recorder.endRecording()); + _paintShapeOutline(); + _paintShadowBounds(path, elevation); + + builder.pop(); // offset + } + + void _paintBitmapCanvasComplexPathShadow(double elevation, Offset offset) { + final SurfacePath path = SurfacePath() + ..moveTo(10, 0) + ..lineTo(20, 10) + ..lineTo(10, 20) + ..lineTo(0, 10) + ..close(); + builder.pushOffset(offset.dx, offset.dy); + + final EnginePictureRecorder recorder = PictureRecorder(); + final RecordingCanvas canvas = recorder.beginRecording(Rect.largest); + canvas + .debugEnforceArbitraryPaint(); // make sure DOM canvas doesn't take over + canvas.drawShadow( + path, + _kShadowColor, + elevation, + false, + ); + canvas.drawPath( + path, + SurfacePaint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = Color.fromARGB(255, 0, 0, 255), + ); + builder.addPicture(Offset.zero, recorder.endRecording()); + _paintShadowBounds(path, elevation); + + builder.pop(); // offset + } + + test( + 'renders shadows correctly', + () async { + // Physical shape clips. We want to see that clipping in the screenshot. + debugShowClipLayers = false; + + builder.pushOffset(10, 20); + + for (int i = 0; i < 10; i++) { + _paintPhysicalShapeShadow(i.toDouble(), Offset(50.0 * i, 0)); + } + + for (int i = 0; i < 10; i++) { + _paintBitmapCanvasShadow(i.toDouble(), Offset(50.0 * i, 60)); + } + + for (int i = 0; i < 10; i++) { + _paintBitmapCanvasComplexPathShadow( + i.toDouble(), Offset(50.0 * i, 120)); + } + + builder.pop(); + + final html.Element sceneElement = builder.build().webOnlyRootElement; + html.document.body.append(sceneElement); + + await matchGoldenFile( + 'shadows.png', + region: region, + maxDiffRatePercent: 0.0, + pixelComparison: PixelComparison.precise, + ); + }, + testOn: 'chrome', + ); +}