fix shadows and mask filter blurs (flutter/engine#16963)

* fix shadows and mask filter blurs

* update goldens

* clarify the choice to min the shadows
This commit is contained in:
Yegor
2020-03-05 22:07:57 -08:00
committed by GitHub
parent 62ea7cc68a
commit 8b77254d97
11 changed files with 592 additions and 459 deletions

View File

@@ -1,2 +1,2 @@
repository: https://github.com/flutter/goldens.git
revision: 1699ba6fd7093a0a610f82618fa30546e7974777
revision: 8f692819e8881b7d2131dbd61d965c21d5e3e345

View File

@@ -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<html.Element> 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

View File

@@ -581,49 +581,46 @@ class _CanvasPool extends _SaveStackTracking {
void drawShadow(ui.Path path, ui.Color color, double elevation,
bool transparentOccluder) {
final List<CanvasShadow> 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();
}
}
}

View File

@@ -286,10 +286,6 @@ js.JsArray<double> makeSkiaColorStops(List<double> 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,

View File

@@ -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'

View File

@@ -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)';
/// Must be kept in sync with `flow/layers/physical_shape_layer.cc`.
const double kLightHeight = 600.0;
/// 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;
/// 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;
/// 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;
/// 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;
}
/// Disables box-shadow.
static void applyShadowNone(html.CssStyleDeclaration style) {
style.boxShadow = 'none';
}
/// 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;
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)';
}
}
/// 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);
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);
}
}
static List<CanvasShadow> computeCanvasShadows(
double elevation, ui.Color color) {
if (elevation <= 0.0) {
return const <CanvasShadow>[];
} 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);
}
}
/// 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);
}
}
static List<CanvasShadow> 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<CanvasShadow> result = <CanvasShadow>[];
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,
));
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,
));
result.add(CanvasShadow(
offsetX: 0.0,
offsetY: 9.0,
blur: 23.0,
spread: 8.0,
color: ambientShadowColor,
));
result.add(CanvasShadow(
offsetX: 0.0,
offsetY: 11.0,
blur: 7.5,
spread: -7.0,
color: umbraColor,
));
}
return result;
}
final double dx = -kLightOffsetX * elevation / kLightHeight;
final double dy = -kLightOffsetY * elevation / kLightHeight;
return ui.Offset(dx, dy);
}
class CanvasShadow {
CanvasShadow({
@required this.offsetX,
@required this.offsetY,
@required this.blur,
@required this.spread,
@required this.color,
/// 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;
}
// 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);
}
/// Information needed to render a shadow using CSS or canvas.
@immutable
class SurfaceShadowData {
const SurfaceShadowData({
@required this.blurWidth,
@required this.offset,
});
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;
/// 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;
}
/// 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;
}
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),
);
}
/// 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})';
}
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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<PaintSpreadPainter> painters = <PaintSpreadPainter>[
(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 {

View File

@@ -0,0 +1,162 @@
// 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,
);
},
timeout: const Timeout(Duration(seconds: 10)),
testOn: 'chrome',
);
}