diff --git a/engine/src/flutter/display_list/BUILD.gn b/engine/src/flutter/display_list/BUILD.gn index 6e78eb17be4..1326d488fe7 100644 --- a/engine/src/flutter/display_list/BUILD.gn +++ b/engine/src/flutter/display_list/BUILD.gn @@ -167,6 +167,7 @@ if (enable_unittests) { sources = [ "benchmarking/dl_complexity_unittests.cc", "display_list_unittests.cc", + "dl_canvas_unittests.cc", "dl_color_unittests.cc", "dl_paint_unittests.cc", "dl_storage_unittests.cc", @@ -180,6 +181,7 @@ if (enable_unittests) { "geometry/dl_path_unittests.cc", "geometry/dl_region_unittests.cc", "geometry/dl_rtree_unittests.cc", + "skia/dl_sk_canvas_unittests.cc", "skia/dl_sk_conversions_unittests.cc", "skia/dl_sk_paint_dispatcher_unittests.cc", "utils/dl_accumulation_rect_unittests.cc", diff --git a/engine/src/flutter/display_list/dl_canvas.cc b/engine/src/flutter/display_list/dl_canvas.cc index 15d0c253c37..b9f062770c9 100644 --- a/engine/src/flutter/display_list/dl_canvas.cc +++ b/engine/src/flutter/display_list/dl_canvas.cc @@ -4,9 +4,121 @@ #include "flutter/display_list/dl_canvas.h" -#include "flutter/display_list/geometry/dl_geometry_conversions.h" -#include "flutter/third_party/skia/include/core/SkPoint3.h" -#include "flutter/third_party/skia/include/utils/SkShadowUtils.h" +namespace { + +// ShadowBounds code adapted from SkShadowUtils using the Directional flag. + +using DlScalar = flutter::DlScalar; +using DlVector3 = flutter::DlVector3; +using DlVector2 = flutter::DlVector2; +using DlRect = flutter::DlRect; +using DlMatrix = flutter::DlMatrix; + +static constexpr DlScalar kAmbientHeightFactor = 1.0f / 128.0f; +static constexpr DlScalar kAmbientGeomFactor = 64.0f; +// Assuming that we have a light height of 600 for the spot shadow, the spot +// values will reach their maximum at a height of approximately 292.3077. +// We'll round up to 300 to keep it simple. +static constexpr DlScalar kMaxAmbientRadius = + 300.0f * kAmbientHeightFactor * kAmbientGeomFactor; + +inline DlScalar AmbientBlurRadius(DlScalar height) { + return std::min(height * kAmbientHeightFactor * kAmbientGeomFactor, + kMaxAmbientRadius); +} + +struct DrawShadowRec { + DlVector3 light_position; + DlScalar light_radius = 0.0f; + DlScalar occluder_z = 0.0f; +}; + +static inline float DivideAndClamp(float numer, + float denom, + float min, + float max) { + float result = std::clamp(numer / denom, min, max); + // ensure that clamp handled non-finites correctly + FML_DCHECK(result >= min && result <= max); + return result; +} + +inline void GetDirectionalParams(DrawShadowRec params, + DlScalar* blur_radius, + DlScalar* scale, + DlVector2* translate) { + *blur_radius = params.light_radius * params.occluder_z; + *scale = 1.0f; + // Max z-ratio is ("max expected elevation" / "min allowable z"). + constexpr DlScalar kMaxZRatio = 64.0f / flutter::kEhCloseEnough; + DlScalar zRatio = DivideAndClamp(params.occluder_z, params.light_position.z, + 0.0f, kMaxZRatio); + *translate = DlVector2(-zRatio * params.light_position.x, + -zRatio * params.light_position.y); +} + +DlRect GetLocalBounds(DlRect ambient_bounds, + const DlMatrix& matrix, + const DrawShadowRec& params) { + if (!matrix.IsInvertible() || ambient_bounds.IsEmpty()) { + return DlRect(); + } + + DlScalar ambient_blur; + DlScalar spot_blur; + DlScalar spot_scale; + DlVector2 spot_offset; + + if (matrix.HasPerspective2D()) { + // transform ambient and spot bounds into device space + ambient_bounds = ambient_bounds.TransformAndClipBounds(matrix); + + // get ambient blur (in device space) + ambient_blur = AmbientBlurRadius(params.occluder_z); + + // get spot params (in device space) + GetDirectionalParams(params, &spot_blur, &spot_scale, &spot_offset); + } else { + auto min_scale = matrix.GetMinScale2D(); + // We've already checked the matrix for perspective elements. + FML_DCHECK(min_scale.has_value()); + DlScalar device_to_local_scale = 1.0f / min_scale.value_or(1.0f); + + // get ambient blur (in local space) + DlScalar device_space_ambient_blur = AmbientBlurRadius(params.occluder_z); + ambient_blur = device_space_ambient_blur * device_to_local_scale; + + // get spot params (in local space) + GetDirectionalParams(params, &spot_blur, &spot_scale, &spot_offset); + // light dir is in device space, map spot offset back into local space + DlMatrix inverse = matrix.Invert(); + spot_offset = inverse.TransformDirection(spot_offset); + + // convert spot blur to local space + spot_blur *= device_to_local_scale; + } + + // in both cases, adjust ambient and spot bounds + DlRect spot_bounds = ambient_bounds; + ambient_bounds = ambient_bounds.Expand(ambient_blur); + spot_bounds = spot_bounds.Scale(spot_scale); + spot_bounds = spot_bounds.Shift(spot_offset); + spot_bounds = spot_bounds.Expand(spot_blur); + + // merge bounds + DlRect result = ambient_bounds.Union(spot_bounds); + // outset a bit to account for floating point error + result = result.Expand(1.0f, 1.0f); + + // if perspective, transform back to src space + if (matrix.HasPerspective2D()) { + DlMatrix inverse = matrix.Invert(); + result = result.TransformAndClipBounds(inverse); + } + return result; +} + +} // namespace namespace flutter { @@ -14,12 +126,13 @@ DlRect DlCanvas::ComputeShadowBounds(const DlPath& path, float elevation, DlScalar dpr, const DlMatrix& ctm) { - SkRect shadow_bounds(ToSkRect(path.GetBounds())); - SkShadowUtils::GetLocalBounds( - ToSkMatrix(ctm), path.GetSkPath(), SkPoint3::Make(0, 0, dpr * elevation), - SkPoint3::Make(0, -1, 1), kShadowLightRadius / kShadowLightHeight, - SkShadowFlags::kDirectionalLight_ShadowFlag, &shadow_bounds); - return ToDlRect(shadow_bounds); + return GetLocalBounds( + path.GetBounds(), ctm, + { + .light_position = DlVector3(0.0f, -1.0f, 1.0f), + .light_radius = kShadowLightRadius / kShadowLightHeight, + .occluder_z = dpr * elevation, + }); } } // namespace flutter diff --git a/engine/src/flutter/display_list/dl_canvas.h b/engine/src/flutter/display_list/dl_canvas.h index 881af6757ea..d4d8acffd39 100644 --- a/engine/src/flutter/display_list/dl_canvas.h +++ b/engine/src/flutter/display_list/dl_canvas.h @@ -195,6 +195,23 @@ class DlCanvas { DlScalar x, DlScalar y, const DlPaint& paint) = 0; + /// @brief Draws the shadow of the given |path| rendered in the provided + /// |color| (which is only consulted for its opacity) as would be + /// produced by a directional light source uniformly shining in + /// the device space direction {0, -1, 1} against a backdrop + /// which is |elevation * dpr| device coordinates below the |path| + /// in the Z direction. + /// + /// Normally the renderer might consider omitting the rendering of any + /// of the shadow pixels that fall under the |path| itself, as an + /// optimization, unless the |transparent_occluder| flag is specified + /// which would indicate that the optimization isn't appropriate. + /// + /// Note that the |elevation| and |dpr| are unique in the API for being + /// considered in pure device coordinates while the |path| is interpreted + /// relative to the current local-to-device transform. + /// + /// @see |ComputeShadowBounds| virtual void DrawShadow(const DlPath& path, const DlColor color, const DlScalar elevation, @@ -206,6 +223,16 @@ class DlCanvas { static constexpr DlScalar kShadowLightHeight = 600; static constexpr DlScalar kShadowLightRadius = 800; + /// @brief Compute the local coverage for a |DrawShadow| operation using + /// the given parameters (excluding the color and the transparent + /// occluder parameters which do not affect the bounds). + /// + /// Since the elevation is expressed in device coordinates relative to the + /// provided |dpr| value, the |ctm| of the final rendering coordinate + /// system that will be applied to the path must be provided so the two + /// sets of coordinates (path and light source) can be correlated. + /// + /// @see |DrawShadow| static DlRect ComputeShadowBounds(const DlPath& path, float elevation, DlScalar dpr, diff --git a/engine/src/flutter/display_list/dl_canvas_unittests.cc b/engine/src/flutter/display_list/dl_canvas_unittests.cc new file mode 100644 index 00000000000..8fcd62fa9f9 --- /dev/null +++ b/engine/src/flutter/display_list/dl_canvas_unittests.cc @@ -0,0 +1,57 @@ +// 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. + +#include "flutter/display_list/dl_canvas.h" + +#include "gtest/gtest.h" + +namespace flutter { +namespace testing { + +TEST(DisplayListCanvas, GetShadowBoundsScaleTranslate) { + DlMatrix matrix = + DlMatrix::MakeTranslateScale({5.0f, 7.0f, 1.0f}, {10.0f, 15.0f, 7.0f}); + DlPath path = DlPath::MakeRectLTRB(100, 100, 200, 200); + + DlRect shadow_bounds = + DlCanvas::ComputeShadowBounds(path, 5.0f, 2.0f, matrix); + + EXPECT_FLOAT_EQ(shadow_bounds.GetLeft(), 96.333336f); + EXPECT_FLOAT_EQ(shadow_bounds.GetTop(), 97.761909f); + EXPECT_FLOAT_EQ(shadow_bounds.GetRight(), 203.66667f); + EXPECT_FLOAT_EQ(shadow_bounds.GetBottom(), 205.09525f); +} + +TEST(DisplayListCanvas, GetShadowBoundsScaleTranslateRotate) { + DlMatrix matrix = + DlMatrix::MakeTranslateScale({5.0f, 7.0f, 1.0f}, {10.0f, 15.0f, 7.0f}); + matrix = matrix * DlMatrix::MakeRotationZ(DlDegrees(45)); + DlPath path = DlPath::MakeRectLTRB(100, 100, 200, 200); + + DlRect shadow_bounds = + DlCanvas::ComputeShadowBounds(path, 5.0f, 2.0f, matrix); + + EXPECT_FLOAT_EQ(shadow_bounds.GetLeft(), 97.343491f); + EXPECT_FLOAT_EQ(shadow_bounds.GetTop(), 97.343491f); + EXPECT_FLOAT_EQ(shadow_bounds.GetRight(), 204.67682f); + EXPECT_FLOAT_EQ(shadow_bounds.GetBottom(), 204.67682f); +} + +TEST(DisplayListCanvas, GetShadowBoundsScaleTranslatePerspective) { + DlMatrix matrix = + DlMatrix::MakeTranslateScale({5.0f, 7.0f, 1.0f}, {10.0f, 15.0f, 7.0f}); + matrix.m[3] = 0.001f; + DlPath path = DlPath::MakeRectLTRB(100, 100, 200, 200); + + DlRect shadow_bounds = + DlCanvas::ComputeShadowBounds(path, 5.0f, 2.0f, matrix); + + EXPECT_FLOAT_EQ(shadow_bounds.GetLeft(), 96.535324f); + EXPECT_FLOAT_EQ(shadow_bounds.GetTop(), 90.253288f); + EXPECT_FLOAT_EQ(shadow_bounds.GetRight(), 204.15054f); + EXPECT_FLOAT_EQ(shadow_bounds.GetBottom(), 223.3252f); +} + +} // namespace testing +} // namespace flutter diff --git a/engine/src/flutter/display_list/geometry/dl_geometry_types.h b/engine/src/flutter/display_list/geometry/dl_geometry_types.h index 5368b62dc60..5fa8876a914 100644 --- a/engine/src/flutter/display_list/geometry/dl_geometry_types.h +++ b/engine/src/flutter/display_list/geometry/dl_geometry_types.h @@ -20,6 +20,7 @@ using DlRadians = impeller::Radians; using DlPoint = impeller::Point; using DlVector2 = impeller::Vector2; +using DlVector3 = impeller::Vector3; using DlIPoint = impeller::IPoint32; using DlSize = impeller::Size; using DlISize = impeller::ISize32; diff --git a/engine/src/flutter/display_list/skia/dl_sk_canvas_unittests.cc b/engine/src/flutter/display_list/skia/dl_sk_canvas_unittests.cc new file mode 100644 index 00000000000..f09c6f93514 --- /dev/null +++ b/engine/src/flutter/display_list/skia/dl_sk_canvas_unittests.cc @@ -0,0 +1,80 @@ +// 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. + +#include "flutter/display_list/skia/dl_sk_canvas.h" + +#include "flutter/display_list/skia/dl_sk_conversions.h" +#include "flutter/third_party/skia/include/utils/SkShadowUtils.h" +#include "gtest/gtest.h" + +namespace flutter { +namespace testing { + +namespace { + +void TestShadowBounds(bool with_rotate, bool with_perspective) { + const SkVector3 light_position = SkVector3::Make(0.0f, -1.0f, 1.0f); + const DlScalar light_radius = + DlCanvas::kShadowLightRadius / DlCanvas::kShadowLightHeight; + + DlPath dl_path = DlPath::MakeRectLTRB(100, 100, 200, 200); + for (int dpr = 1; dpr <= 2; dpr++) { + for (int elevation = 1; elevation <= 5; elevation++) { + SkVector3 z_params = SkVector3::Make(0.0f, 0.0f, elevation * dpr); + for (int i = 1; i <= 10; i++) { + DlScalar xScale = static_cast(i); + for (int j = 1; j <= 10; j++) { + DlScalar yScale = static_cast(j); + + DlMatrix matrix = DlMatrix::MakeTranslateScale({xScale, yScale, 1.0f}, + {10.0f, 15.0f, 7.0f}); + if (with_rotate) { + matrix = matrix * DlMatrix::MakeRotationZ(DlDegrees(45)); + } + if (with_perspective) { + matrix.m[3] = 0.001f; + } + SkMatrix sk_matrix; + ASSERT_TRUE(ToSk(&matrix, sk_matrix) != nullptr); + SkMatrix sk_inverse = sk_matrix; + ASSERT_TRUE(sk_matrix.invert(&sk_inverse)); + + auto label = (std::stringstream() + << "Matrix: " << matrix << ", elevation = " << elevation + << ", dpr = " << dpr) + .str(); + + DlRect dl_bounds = + DlCanvas::ComputeShadowBounds(dl_path, elevation, dpr, matrix); + SkRect sk_bounds; + ASSERT_TRUE(SkShadowUtils::GetLocalBounds( + sk_matrix, dl_path.GetSkPath(), z_params, light_position, + light_radius, kDirectionalLight_ShadowFlag, &sk_bounds)) + << label; + EXPECT_FLOAT_EQ(dl_bounds.GetLeft(), sk_bounds.fLeft) << label; + EXPECT_FLOAT_EQ(dl_bounds.GetTop(), sk_bounds.fTop) << label; + EXPECT_FLOAT_EQ(dl_bounds.GetRight(), sk_bounds.fRight) << label; + EXPECT_FLOAT_EQ(dl_bounds.GetBottom(), sk_bounds.fBottom) << label; + } + } + } + } +} + +} // namespace + +TEST(DlSkCanvas, ShadowBoundsCompatibilityTranslateScale) { + TestShadowBounds(false, false); +} + +TEST(DlSkCanvas, ShadowBoundsCompatibilityTranslateScaleRotate) { + TestShadowBounds(true, false); +} + +TEST(DlSkCanvas, ShadowBoundsCompatibilityTranslateScalePerspective) { + TestShadowBounds(false, true); +} + +} // namespace testing +} // namespace flutter diff --git a/engine/src/flutter/impeller/geometry/matrix.cc b/engine/src/flutter/impeller/geometry/matrix.cc index ba49965498a..8a8d03ed697 100644 --- a/engine/src/flutter/impeller/geometry/matrix.cc +++ b/engine/src/flutter/impeller/geometry/matrix.cc @@ -7,6 +7,8 @@ #include #include +#include "flutter/fml/logging.h" + namespace impeller { Matrix::Matrix(const MatrixDecomposition& d) : Matrix() { @@ -358,6 +360,76 @@ std::optional Matrix::Decompose() const { return result; } +std::optional> Matrix::GetScales2D() const { + if (HasPerspective2D()) { + return std::nullopt; + } + + // We only operate on the uppermost 2x2 matrix since those are the only + // values that can induce a scale on 2D coordinates. + // [ a b ] + // [ c d ] + double a = m[0]; + double b = m[1]; + double c = m[4]; + double d = m[5]; + + if (b == 0.0f && c == 0.0f) { + return {{std::abs(a), std::abs(d)}}; + } + + if (a == 0.0f && d == 0.0f) { + return {{std::abs(b), std::abs(c)}}; + } + + // Compute eigenvalues for the matrix (transpose(A) * A): + // [ a2 b2 ] == [ a b ] [ a c ] == [ aa + bb ac + bd ] + // [ c2 d2 ] [ c d ] [ b d ] [ ac + bd cc + dd ] + // (note the reverse diagonal entries in the answer are identical) + double a2 = a * a + b * b; + double b2 = a * c + b * d; + double c2 = b2; + double d2 = c * c + d * d; + + // + // If L is an eigenvalue, then + // det(this - L*Identity) == 0 + // det([ a - L b ] + // [ c d - L ]) == 0 + // (a - L) * (d - L) - bc == 0 + // ad - aL - dL + L^2 - bc == 0 + // L^2 + (-a + -d)L + ad - bc == 0 + // + // Using quadratic equation for (Ax^2 + Bx + C): + // A == 1 + // B == -(a2 + d2) + // C == a2d2 - b2c2 + // + // (We use -B for calculations because the square is the same as B and we + // need -B for the final quadratic equation computations anyway.) + double minus_B = a2 + d2; + double C = a2 * d2 - b2 * c2; + double B_squared_minus_4AC = minus_B * minus_B - 4 * 1.0f * C; + + double quadratic_sqrt; + if (B_squared_minus_4AC <= 0.0f) { + // This test should never fail, but we might be slightly negative + FML_DCHECK(B_squared_minus_4AC + kEhCloseEnough >= 0.0f); + // Uniform scales (possibly rotated) would tend to end up here + // in which case both eigenvalues are identical + quadratic_sqrt = 0.0f; + } else { + quadratic_sqrt = std::sqrt(B_squared_minus_4AC); + } + + // Since this is returning the sqrt of the values, we can guarantee that + // the returned scales are non-negative. + FML_DCHECK(minus_B - quadratic_sqrt >= 0.0f); + FML_DCHECK(minus_B + quadratic_sqrt >= 0.0f); + return {{std::sqrt((minus_B - quadratic_sqrt) / 2.0f), + std::sqrt((minus_B + quadratic_sqrt) / 2.0f)}}; +} + uint64_t MatrixDecomposition::GetComponentsMask() const { uint64_t mask = 0; diff --git a/engine/src/flutter/impeller/geometry/matrix.h b/engine/src/flutter/impeller/geometry/matrix.h index a3a9da83a2a..90598fe42a6 100644 --- a/engine/src/flutter/impeller/geometry/matrix.h +++ b/engine/src/flutter/impeller/geometry/matrix.h @@ -320,6 +320,11 @@ struct Matrix { bool IsInvertible() const { return GetDeterminant() != 0; } + /// @brief Return the maximum scale applied specifically to either the + /// X axis or Y axis unit vectors (the bases). The matrix might + /// lengthen a non-axis-aligned vector by more than this value. + /// + /// @see |GetMaxScale2D| inline Scalar GetMaxBasisLengthXY() const { // The full basis computation requires computing the squared scaling factor // for translate/scale only matrices. This substantially limits the range of @@ -332,6 +337,54 @@ struct Matrix { e[1][0] * e[1][0] + e[1][1] * e[1][1])); } + /// @brief Return the smaller of the two non-negative scales that will + /// be applied to 2D coordinates by this matrix. If the matrix + /// has perspective components, the method will return a nullopt. + /// + /// Note that negative scale factors really represent a positive scale + /// factor with a flip, so the absolute value (the positive scale factor) + /// is returned instead so that the results can be directly applied to + /// rendering calculations to compute the potential size of an operation. + /// + /// This method differs from the "basis length" methods in that those + /// methods answer the question "how much does this transform stretch + /// perfectly horizontal or vertical source vectors, whereas this method + /// can answer "what's the smallest scale applied to any vector regardless + /// of direction". + /// + /// @see |GetScales2D| + std::optional GetMinScale2D() const { + auto scales = GetScales2D(); + if (!scales.has_value()) { + return std::nullopt; + } + return std::min(scales->first, scales->second); + } + + /// @brief Return the smaller of the two non-negative scales that will + /// be applied to 2D coordinates by this matrix. If the matrix + /// has perspective components, the method will return a nullopt. + /// + /// Note that negative scale factors really represent a positive scale + /// factor with a flip, so the absolute value (the positive scale factor) + /// is returned instead so that the results can be directly applied to + /// rendering calculations to compute the potential size of an operation. + /// + /// This method differs from the "basis length" methods in that those + /// methods answer the question "how much does this transform stretch + /// perfectly horizontal or vertical source vectors, whereas this method + /// can answer "what's the largest scale applied to any vector regardless + /// of direction". + /// + /// @see |GetScales2D| + std::optional GetMaxScale2D() const { + auto scales = GetScales2D(); + if (!scales.has_value()) { + return std::nullopt; + } + return std::max(scales->first, scales->second); + } + constexpr Vector3 GetBasisX() const { return Vector3(m[0], m[1], m[2]); } constexpr Vector3 GetBasisY() const { return Vector3(m[4], m[5], m[6]); } @@ -450,6 +503,20 @@ struct Matrix { std::optional Decompose() const; + /// @brief Compute the two non-negative scales applied by this matrix to + /// 2D coordinates and return them as an optional pair of Scalar + /// values in any order. If the matrix has perspective elements, + /// this method will return a nullopt. + /// + /// Note that negative scale factors really represent a positive scale + /// factor with a flip, so the absolute value (the positive scale factor) + /// is returned instead so that the results can be directly applied to + /// rendering calculations to compute the potential size of an operation. + /// + /// @see |GetMinScale2D| + /// @see |GetMaxScale2D| + std::optional> GetScales2D() const; + bool Equals(const Matrix& matrix, Scalar epsilon = 1e-5f) const { const Scalar* a = m; const Scalar* b = matrix.m; diff --git a/engine/src/flutter/impeller/geometry/matrix_unittests.cc b/engine/src/flutter/impeller/geometry/matrix_unittests.cc index 719affb438b..5e797d599fc 100644 --- a/engine/src/flutter/impeller/geometry/matrix_unittests.cc +++ b/engine/src/flutter/impeller/geometry/matrix_unittests.cc @@ -322,5 +322,125 @@ TEST(MatrixTest, To3x3) { EXPECT_TRUE(MatrixNear(x.To3x3(), Matrix())); } +TEST(MatrixTest, MinMaxScales2D) { + // The GetScales2D() method is allowed to return the scales in any + // order so we need to take special care in verifying the return + // value to test them in either order. + auto check_pair = [](const Matrix& matrix, Scalar scale1, Scalar scale2) { + auto pair = matrix.GetScales2D(); + EXPECT_TRUE(pair.has_value()) + << "Scales: " << scale1 << ", " << scale2 << ", " << matrix; + if (ScalarNearlyEqual(pair->first, scale1)) { + EXPECT_FLOAT_EQ(pair->first, scale1) << matrix; + EXPECT_FLOAT_EQ(pair->second, scale2) << matrix; + } else { + EXPECT_FLOAT_EQ(pair->first, scale2) << matrix; + EXPECT_FLOAT_EQ(pair->second, scale1) << matrix; + } + }; + + for (int i = 1; i < 10; i++) { + Scalar xScale = static_cast(i); + for (int j = 1; j < 10; j++) { + Scalar yScale = static_cast(j); + Scalar minScale = std::min(xScale, yScale); + Scalar maxScale = std::max(xScale, yScale); + + { + // Simple scale + Matrix matrix = Matrix::MakeScale({xScale, yScale, 1.0f}); + EXPECT_TRUE(matrix.GetMinScale2D().has_value()); + EXPECT_TRUE(matrix.GetMaxScale2D().has_value()); + EXPECT_FLOAT_EQ(matrix.GetMinScale2D().value_or(-1.0f), minScale); + EXPECT_FLOAT_EQ(matrix.GetMaxScale2D().value_or(-1.0f), maxScale); + check_pair(matrix, xScale, yScale); + } + + { + // Simple scale with Z scale + Matrix matrix = Matrix::MakeScale({xScale, yScale, 5.0f}); + EXPECT_TRUE(matrix.GetMinScale2D().has_value()); + EXPECT_TRUE(matrix.GetMaxScale2D().has_value()); + EXPECT_FLOAT_EQ(matrix.GetMinScale2D().value_or(-1.0f), minScale); + EXPECT_FLOAT_EQ(matrix.GetMaxScale2D().value_or(-1.0f), maxScale); + check_pair(matrix, xScale, yScale); + } + + { + // Simple scale + translate + Matrix matrix = Matrix::MakeTranslateScale({xScale, yScale, 1.0f}, + {10.0f, 15.0f, 2.0f}); + EXPECT_TRUE(matrix.GetMinScale2D().has_value()); + EXPECT_TRUE(matrix.GetMaxScale2D().has_value()); + EXPECT_FLOAT_EQ(matrix.GetMinScale2D().value_or(-1.0f), minScale); + EXPECT_FLOAT_EQ(matrix.GetMaxScale2D().value_or(-1.0f), maxScale); + check_pair(matrix, xScale, yScale); + } + + for (int d = 45; d < 360; d += 45) { + { + // Rotation * Scale + Matrix matrix = Matrix::MakeScale({xScale, yScale, 1.0f}) * + Matrix::MakeRotationZ(Degrees(d)); + EXPECT_TRUE(matrix.GetMinScale2D().has_value()); + EXPECT_TRUE(matrix.GetMaxScale2D().has_value()); + EXPECT_FLOAT_EQ(matrix.GetMinScale2D().value_or(-1.0f), minScale); + EXPECT_FLOAT_EQ(matrix.GetMaxScale2D().value_or(-1.0f), maxScale); + check_pair(matrix, xScale, yScale); + } + + { + // Scale * Rotation + Matrix matrix = Matrix::MakeRotationZ(Degrees(d)) * + Matrix::MakeScale({xScale, yScale, 1.0f}); + EXPECT_TRUE(matrix.GetMinScale2D().has_value()); + EXPECT_TRUE(matrix.GetMaxScale2D().has_value()); + EXPECT_FLOAT_EQ(matrix.GetMinScale2D().value_or(-1.0f), minScale); + EXPECT_FLOAT_EQ(matrix.GetMaxScale2D().value_or(-1.0f), maxScale); + check_pair(matrix, xScale, yScale); + } + } + + { + // Scale + PerspectiveX (returns invalid values) + Matrix matrix = Matrix::MakeScale({xScale, yScale, 1.0f}); + matrix.m[3] = 0.1; + EXPECT_FALSE(matrix.GetMinScale2D().has_value()); + EXPECT_FALSE(matrix.GetMaxScale2D().has_value()); + EXPECT_FALSE(matrix.GetScales2D().has_value()); + } + + { + // Scale + PerspectiveY (returns invalid values) + Matrix matrix = Matrix::MakeScale({xScale, yScale, 1.0f}); + matrix.m[7] = 0.1; + EXPECT_FALSE(matrix.GetMinScale2D().has_value()); + EXPECT_FALSE(matrix.GetMaxScale2D().has_value()); + EXPECT_FALSE(matrix.GetScales2D().has_value()); + } + + { + // Scale + PerspectiveZ (Z ignored; returns actual scales) + Matrix matrix = Matrix::MakeScale({xScale, yScale, 1.0f}); + matrix.m[11] = 0.1; + EXPECT_TRUE(matrix.GetMinScale2D().has_value()); + EXPECT_TRUE(matrix.GetMaxScale2D().has_value()); + EXPECT_FLOAT_EQ(matrix.GetMinScale2D().value_or(-1.0f), minScale); + EXPECT_FLOAT_EQ(matrix.GetMaxScale2D().value_or(-1.0f), maxScale); + check_pair(matrix, xScale, yScale); + } + + { + // Scale + PerspectiveW (returns invalid values) + Matrix matrix = Matrix::MakeScale({xScale, yScale, 1.0f}); + matrix.m[15] = 0.1; + EXPECT_FALSE(matrix.GetMinScale2D().has_value()); + EXPECT_FALSE(matrix.GetMaxScale2D().has_value()); + EXPECT_FALSE(matrix.GetScales2D().has_value()); + } + } + } +} + } // namespace testing } // namespace impeller