From 41b6c8d669efaa7623a3c83c48d05a3b4aae0e5d Mon Sep 17 00:00:00 2001 From: gaaclarke <30870216+gaaclarke@users.noreply.github.com> Date: Thu, 22 Aug 2024 12:36:28 -0700 Subject: [PATCH] Framework wide color (#54415) issue: https://github.com/flutter/flutter/issues/127855 integration test: https://github.com/flutter/engine/pull/54415 This does the preliminary work for implementing wide gamut colors in the Flutter framework. Here are the following changes: 1) colors now specify a colorspace with which they are to be interpreted 1) colors now store their components as floats to accommodate bit depths more than 8 The storage of this Color class is weird with float/int storage but that is a temporary solution to support a smooth transition. Here is the plan for landing this: 1) Land this PR 1) Wait for it to roll into the Framework 1) Land https://github.com/flutter/flutter/pull/153938 which will make CupertinoDynamicColor implement Color 1) Land another engine PR that rips out the int storage: https://github.com/flutter/engine/pull/54714 Here are follow up PRs: 1) https://github.com/flutter/engine/pull/54473 - changes DlColor so the wide gamut colors are rendered 1) https://github.com/flutter/engine/pull/54567 - Hooks up these changes to take advantage of wide DlColor 1) https://github.com/flutter/flutter/pull/153319 - the integration test for the framework repo There are some things that have been left as follow up PRs since they are technically breaking: 1) The math on `lerp` hasn't been updated to take advantage of the higher bit depth 1) `operator==` hasn't been updated to take advantage of the higher bit depth 1) `hashCode` hasn't been updated to take advantage of the higher bit depth 1) `alphaBlend` hasn't been updated to take advantage of the higher bit depth 1) `toString` hasn't been updated to take advantage of the higher bit depth ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide] and the [C++, Objective-C, Java style guides]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I added new tests to check the change I am making or feature I am adding, or the PR is [test-exempt]. See [testing the engine] for instructions on writing and running engine tests. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I signed the [CLA]. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style [testing the engine]: https://github.com/flutter/flutter/wiki/Testing-the-engine [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat --- lib/gpu/lib/src/render_pass.dart | 11 +- lib/ui/lerp.dart | 2 +- lib/ui/painting.dart | 388 +++++++++++++++++++++++++++---- lib/ui/painting/paint.cc | 62 +++-- lib/web_ui/lib/painting.dart | 236 +++++++++++++++++-- testing/dart/color_test.dart | 192 +++++++++++++++ 6 files changed, 811 insertions(+), 80 deletions(-) diff --git a/lib/gpu/lib/src/render_pass.dart b/lib/gpu/lib/src/render_pass.dart index 0b2cae00b8ba9..c2f712c001b6a 100644 --- a/lib/gpu/lib/src/render_pass.dart +++ b/lib/gpu/lib/src/render_pass.dart @@ -95,6 +95,15 @@ base class RenderTarget { final DepthStencilAttachment? depthStencilAttachment; } +// TODO(gaaclarke): Refactor this to support wide gamut colors. +int _colorToInt(ui.Color color) { + assert(color.colorSpace == ui.ColorSpace.sRGB); + return ((color.a * 255.0).round() << 24) | + ((color.r * 255.0).round() << 16) | + ((color.g * 255.0).round() << 8) | + ((color.b * 255.0).round() << 0); +} + base class RenderPass extends NativeFieldWrapperClass1 { /// Creates a new RenderPass. RenderPass._(CommandBuffer commandBuffer, RenderTarget renderTarget) { @@ -105,7 +114,7 @@ base class RenderPass extends NativeFieldWrapperClass1 { index, color.loadAction.index, color.storeAction.index, - color.clearValue.value, + _colorToInt(color.clearValue), color.texture, color.resolveTexture); if (error != null) { diff --git a/lib/ui/lerp.dart b/lib/ui/lerp.dart index eeb0625a83f42..e29a8322e298e 100644 --- a/lib/ui/lerp.dart +++ b/lib/ui/lerp.dart @@ -26,7 +26,7 @@ double? lerpDouble(num? a, num? b, double t) { /// /// Same as [lerpDouble] but specialized for non-null `double` type. double _lerpDouble(double a, double b, double t) { - return a * (1.0 - t) + b * t; + return a + (b - a) * t; } /// Linearly interpolate between two integers. diff --git a/lib/ui/painting.dart b/lib/ui/painting.dart index cba1b0a33b86f..006235c0e5054 100644 --- a/lib/ui/painting.dart +++ b/lib/ui/painting.dart @@ -83,7 +83,7 @@ Color _scaleAlpha(Color a, double factor) { /// * [Colors](https://api.flutter.dev/flutter/material/Colors-class.html), which /// defines the colors found in the Material Design specification. class Color { - /// Construct a color from the lower 32 bits of an [int]. + /// Construct an sRGB color from the lower 32 bits of an [int]. /// /// The bits are interpreted as follows: /// @@ -99,9 +99,32 @@ class Color { /// For example, to get a fully opaque orange, you would use `const /// Color(0xFFFF9000)` (`FF` for the alpha, `FF` for the red, `90` for the /// green, and `00` for the blue). - const Color(int value) : value = value & 0xFFFFFFFF; - - /// Construct a color from the lower 8 bits of four integers. + const Color(int value) + : _value = value & 0xFFFFFFFF, + colorSpace = ColorSpace.sRGB, + _a = null, + _r = null, + _g = null, + _b = null; + + /// Construct a color with normalized color components. + /// + /// Normalized color components allows arbitrary bit depths for color + /// components to be be supported. The values will be normalized relative to + /// the [ColorSpace] argument. + const Color.from( + {required double alpha, + required double red, + required double green, + required double blue, + this.colorSpace = ColorSpace.sRGB}) + : _value = 0, + _a = alpha, + _r = red, + _g = green, + _b = blue; + + /// Construct an sRGB color from the lower 8 bits of four integers. /// /// * `a` is the alpha value, with 0 being transparent and 255 being fully /// opaque. @@ -113,13 +136,32 @@ class Color { /// /// See also [fromRGBO], which takes the alpha value as a floating point /// value. - const Color.fromARGB(int a, int r, int g, int b) : - value = (((a & 0xff) << 24) | - ((r & 0xff) << 16) | - ((g & 0xff) << 8) | - ((b & 0xff) << 0)) & 0xFFFFFFFF; - - /// Create a color from red, green, blue, and opacity, similar to `rgba()` in CSS. + const Color.fromARGB(int a, int r, int g, int b) + : _value = (((a & 0xff) << 24) | + ((r & 0xff) << 16) | + ((g & 0xff) << 8) | + ((b & 0xff) << 0)) & + 0xFFFFFFFF, + colorSpace = ColorSpace.sRGB, + _a = null, + _r = null, + _g = null, + _b = null; + + const Color._fromARGBC( + int alpha, int red, int green, int blue, this.colorSpace) + : _value = (((alpha & 0xff) << 24) | + ((red & 0xff) << 16) | + ((green & 0xff) << 8) | + ((blue & 0xff) << 0)) & + 0xFFFFFFFF, + _a = null, + _r = null, + _g = null, + _b = null; + + /// Create an sRGB color from red, green, blue, and opacity, similar to + /// `rgba()` in CSS. /// /// * `r` is [red], from 0 to 255. /// * `g` is [green], from 0 to 255. @@ -130,11 +172,43 @@ class Color { /// Out of range values are brought into range using modulo 255. /// /// See also [fromARGB], which takes the opacity as an integer value. - const Color.fromRGBO(int r, int g, int b, double opacity) : - value = ((((opacity * 0xff ~/ 1) & 0xff) << 24) | - ((r & 0xff) << 16) | - ((g & 0xff) << 8) | - ((b & 0xff) << 0)) & 0xFFFFFFFF; + const Color.fromRGBO(int r, int g, int b, double opacity) + : _value = ((((opacity * 0xff ~/ 1) & 0xff) << 24) | + ((r & 0xff) << 16) | + ((g & 0xff) << 8) | + ((b & 0xff) << 0)) & + 0xFFFFFFFF, + colorSpace = ColorSpace.sRGB, + _a = null, + _r = null, + _g = null, + _b = null; + + /// The alpha channel of this color. + /// + /// A value of 0.0 means this color is fully transparent. A value of 1.0 means + /// this color is fully opaque. + double get a => _a ?? (alpha / 255); + final double? _a; + + /// The red channel of this color. + double get r => _r ?? (red / 255); + final double? _r; + + /// The green channel of this color. + double get g => _g ?? (green / 255); + final double? _g; + + /// The blue channel of this color. + double get b => _b ?? (blue / 255); + final double? _b; + + /// The color space of this color. + final ColorSpace colorSpace; + + static int _floatToInt8(double x) { + return ((x * 255.0).round()) & 0xff; + } /// A 32 bit value representing this color. /// @@ -144,29 +218,74 @@ class Color { /// * Bits 16-23 are the red value. /// * Bits 8-15 are the green value. /// * Bits 0-7 are the blue value. - final int value; + @Deprecated('Use component accessors like .r or .g.') + int get value { + if (_a != null && _r != null && _g != null && _b != null) { + return _floatToInt8(_a) << 24 | + _floatToInt8(_r) << 16 | + _floatToInt8(_g) << 8 | + _floatToInt8(_b) << 0; + } else { + return _value; + } + } + final int _value; /// The alpha channel of this color in an 8 bit value. /// /// A value of 0 means this color is fully transparent. A value of 255 means /// this color is fully opaque. + @Deprecated('Use .a.') int get alpha => (0xff000000 & value) >> 24; /// The alpha channel of this color as a double. /// /// A value of 0.0 means this color is fully transparent. A value of 1.0 means /// this color is fully opaque. + @Deprecated('Use .a.') double get opacity => alpha / 0xFF; /// The red channel of this color in an 8 bit value. + @Deprecated('Use .r.') int get red => (0x00ff0000 & value) >> 16; /// The green channel of this color in an 8 bit value. + @Deprecated('Use .g.') int get green => (0x0000ff00 & value) >> 8; /// The blue channel of this color in an 8 bit value. + @Deprecated('Use .b.') int get blue => (0x000000ff & value) >> 0; + /// Returns a new color that matches this color with the passed in components + /// changed. + /// + /// Changes to color components will be applied before applying changes to the + /// color space. + Color withValues( + {double? alpha, + double? red, + double? green, + double? blue, + ColorSpace? colorSpace}) { + Color? updatedComponents; + if (alpha != null || red != null || green != null || blue != null) { + updatedComponents = Color.from( + alpha: alpha ?? a, + red: red ?? r, + green: green ?? g, + blue: blue ?? b, + colorSpace: this.colorSpace); + } + if (colorSpace != null && colorSpace != this.colorSpace) { + final _ColorTransform transform = + _getColorTransform(this.colorSpace, colorSpace); + return transform.transform(updatedComponents ?? this, colorSpace); + } else { + return updatedComponents ?? this; + } + } + /// Returns a new color that matches this color with the alpha channel /// replaced with `a` (which ranges from 0 to 255). /// @@ -179,6 +298,7 @@ class Color { /// replaced with the given `opacity` (which ranges from 0.0 to 1.0). /// /// Out of range values will have unexpected effects. + @Deprecated('Use .withValues() to avoid precision loss.') Color withOpacity(double opacity) { assert(opacity >= 0.0 && opacity <= 1.0); return withAlpha((255.0 * opacity).round()); @@ -253,6 +373,10 @@ class Color { /// Values for `t` are usually obtained from an [Animation], such as /// an [AnimationController]. static Color? lerp(Color? a, Color? b, double t) { + // TODO(gaaclarke): Update math to use floats. This was already attempted + // but it leads to subtle changes that change test results. + assert(a?.colorSpace != ColorSpace.extendedSRGB); + assert(b?.colorSpace != ColorSpace.extendedSRGB); if (b == null) { if (a == null) { return null; @@ -263,11 +387,13 @@ class Color { if (a == null) { return _scaleAlpha(b, t); } else { - return Color.fromARGB( + assert(a.colorSpace == b.colorSpace); + return Color._fromARGBC( _clampInt(_lerpInt(a.alpha, b.alpha, t).toInt(), 0, 255), _clampInt(_lerpInt(a.red, b.red, t).toInt(), 0, 255), _clampInt(_lerpInt(a.green, b.green, t).toInt(), 0, 255), _clampInt(_lerpInt(a.blue, b.blue, t).toInt(), 0, 255), + a.colorSpace, ); } } @@ -282,6 +408,10 @@ class Color { /// operations for two things that are solid colors with the same shape, but /// overlay each other: instead, just paint one with the combined color. static Color alphaBlend(Color foreground, Color background) { + assert(foreground.colorSpace == background.colorSpace); + assert(foreground.colorSpace != ColorSpace.extendedSRGB); + // TODO(gaaclarke): Update math to use floats. This was already attempted + // but it leads to subtle changes that change test results. final int alpha = foreground.alpha; if (alpha == 0x00) { // Foreground completely transparent. return background; @@ -289,21 +419,23 @@ class Color { final int invAlpha = 0xff - alpha; int backAlpha = background.alpha; if (backAlpha == 0xff) { // Opaque background case - return Color.fromARGB( + return Color._fromARGBC( 0xff, (alpha * foreground.red + invAlpha * background.red) ~/ 0xff, (alpha * foreground.green + invAlpha * background.green) ~/ 0xff, (alpha * foreground.blue + invAlpha * background.blue) ~/ 0xff, + foreground.colorSpace, ); } else { // General case backAlpha = (backAlpha * invAlpha) ~/ 0xff; final int outAlpha = alpha + backAlpha; assert(outAlpha != 0x00); - return Color.fromARGB( + return Color._fromARGBC( outAlpha, (foreground.red * alpha + background.red * backAlpha) ~/ outAlpha, (foreground.green * alpha + background.green * backAlpha) ~/ outAlpha, (foreground.blue * alpha + background.blue * backAlpha) ~/ outAlpha, + foreground.colorSpace, ); } } @@ -323,13 +455,15 @@ class Color { if (other.runtimeType != runtimeType) { return false; } - return other is Color - && other.value == value; + return other is Color && + other.value == value && + other.colorSpace == colorSpace; } @override - int get hashCode => value.hashCode; + int get hashCode => Object.hash(value, colorSpace); + // TODO(gaaclarke): Make toString() print out float values. @override String toString() => 'Color(0x${value.toRadixString(16).padLeft(8, '0')})'; } @@ -1126,22 +1260,31 @@ final class Paint { @pragma('vm:entry-point') final ByteData _data = ByteData(_kDataByteCount); + // Must match //lib/ui/painting/paint.cc. static const int _kIsAntiAliasIndex = 0; - static const int _kColorIndex = 1; - static const int _kBlendModeIndex = 2; - static const int _kStyleIndex = 3; - static const int _kStrokeWidthIndex = 4; - static const int _kStrokeCapIndex = 5; - static const int _kStrokeJoinIndex = 6; - static const int _kStrokeMiterLimitIndex = 7; - static const int _kFilterQualityIndex = 8; - static const int _kMaskFilterIndex = 9; - static const int _kMaskFilterBlurStyleIndex = 10; - static const int _kMaskFilterSigmaIndex = 11; - static const int _kInvertColorIndex = 12; + static const int _kColorRedIndex = 1; + static const int _kColorGreenIndex = 2; + static const int _kColorBlueIndex = 3; + static const int _kColorAlphaIndex = 4; + static const int _kColorSpaceIndex = 5; + static const int _kBlendModeIndex = 6; + static const int _kStyleIndex = 7; + static const int _kStrokeWidthIndex = 8; + static const int _kStrokeCapIndex = 9; + static const int _kStrokeJoinIndex = 10; + static const int _kStrokeMiterLimitIndex = 11; + static const int _kFilterQualityIndex = 12; + static const int _kMaskFilterIndex = 13; + static const int _kMaskFilterBlurStyleIndex = 14; + static const int _kMaskFilterSigmaIndex = 15; + static const int _kInvertColorIndex = 16; static const int _kIsAntiAliasOffset = _kIsAntiAliasIndex << 2; - static const int _kColorOffset = _kColorIndex << 2; + static const int _kColorRedOffset = _kColorRedIndex << 2; + static const int _kColorGreenOffset = _kColorGreenIndex << 2; + static const int _kColorBlueOffset = _kColorBlueIndex << 2; + static const int _kColorAlphaOffset = _kColorAlphaIndex << 2; + static const int _kColorSpaceOffset = _kColorSpaceIndex << 2; static const int _kBlendModeOffset = _kBlendModeIndex << 2; static const int _kStyleOffset = _kStyleIndex << 2; static const int _kStrokeWidthOffset = _kStrokeWidthIndex << 2; @@ -1155,7 +1298,7 @@ final class Paint { static const int _kInvertColorOffset = _kInvertColorIndex << 2; // If you add more fields, remember to update _kDataByteCount. - static const int _kDataByteCount = 52; // 4 * (last index + 1). + static const int _kDataByteCount = 68; // 4 * (last index + 1). // Binary format must match the deserialization code in paint.cc. // C++ unit tests access this. @@ -1201,12 +1344,28 @@ final class Paint { /// This color is not used when compositing. To colorize a layer, use /// [colorFilter]. Color get color { - final int encoded = _data.getInt32(_kColorOffset, _kFakeHostEndian); - return Color(encoded ^ _kColorDefault); + final double red = _data.getFloat32(_kColorRedOffset, _kFakeHostEndian); + final double green = _data.getFloat32(_kColorGreenOffset, _kFakeHostEndian); + final double blue = _data.getFloat32(_kColorBlueOffset, _kFakeHostEndian); + final double alpha = + 1.0 - _data.getFloat32(_kColorAlphaOffset, _kFakeHostEndian); + final ColorSpace colorSpace = _indexToColorSpace( + _data.getInt32(_kColorSpaceOffset, _kFakeHostEndian)); + return Color.from( + alpha: alpha, + red: red, + green: green, + blue: blue, + colorSpace: colorSpace); } + set color(Color value) { - final int encoded = value.value ^ _kColorDefault; - _data.setInt32(_kColorOffset, encoded, _kFakeHostEndian); + _data.setFloat32(_kColorRedOffset, value.r, _kFakeHostEndian); + _data.setFloat32(_kColorGreenOffset, value.g, _kFakeHostEndian); + _data.setFloat32(_kColorBlueOffset, value.b, _kFakeHostEndian); + _data.setFloat32(_kColorAlphaOffset, 1.0 - value.a, _kFakeHostEndian); + _data.setInt32(_kColorSpaceOffset, _colorSpaceToIndex(value.colorSpace), + _kFakeHostEndian); } // Must be kept in sync with the default in paint.cc. @@ -1580,6 +1739,38 @@ enum ColorSpace { /// see the extended values an [ImageByteFormat] like /// [ImageByteFormat.rawExtendedRgba128] must be used. extendedSRGB, + /// The Display P3 color space. + /// + /// This is a wide gamut color space that has broad hardware support. It's + /// supported in cases like using Impeller on iOS. When used on a platform + /// that doesn't support Display P3, the colors will be clamped to sRGB. + /// + /// See also: https://en.wikipedia.org/wiki/DCI-P3 + displayP3, +} + +int _colorSpaceToIndex(ColorSpace colorSpace) { + switch (colorSpace) { + case ColorSpace.sRGB: + return 0; + case ColorSpace.extendedSRGB: + return 1; + case ColorSpace.displayP3: + return 2; + } +} + +ColorSpace _indexToColorSpace(int index) { + switch(index) { + case 0: + return ColorSpace.sRGB; + case 1: + return ColorSpace.extendedSRGB; + case 2: + return ColorSpace.displayP3; + default: + throw ArgumentError('Unknown color space: $index'); + } } /// The format in which image bytes should be returned when using @@ -3464,6 +3655,121 @@ class MaskFilter { String toString() => 'MaskFilter.blur($_style, ${_sigma.toStringAsFixed(1)})'; } +abstract class _ColorTransform { + Color transform(Color color, ColorSpace resultColorSpace); +} + +class _IdentityColorTransform implements _ColorTransform { + const _IdentityColorTransform(); + @override + Color transform(Color color, ColorSpace resultColorSpace) => color; +} + +class _ClampTransform implements _ColorTransform { + const _ClampTransform(this.child); + final _ColorTransform child; + @override + Color transform(Color color, ColorSpace resultColorSpace) { + return Color.from( + alpha: clampDouble(color.a, 0, 1), + red: clampDouble(color.r, 0, 1), + green: clampDouble(color.g, 0, 1), + blue: clampDouble(color.b, 0, 1), + colorSpace: resultColorSpace); + } +} + +class _MatrixColorTransform implements _ColorTransform { + /// Row-major. + const _MatrixColorTransform(this.values); + + final List values; + + @override + Color transform(Color color, ColorSpace resultColorSpace) { + return Color.from( + alpha: color.a, + red: values[0] * color.r + + values[1] * color.g + + values[2] * color.b + + values[3], + green: values[4] * color.r + + values[5] * color.g + + values[6] * color.b + + values[7], + blue: values[8] * color.r + + values[9] * color.g + + values[10] * color.b + + values[11], + colorSpace: resultColorSpace); + } +} + +_ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) { + // The transforms were calculated with the following octave script from known + // conversions. These transforms have a white point that matches Apple's. + // + // p3Colors = [ + // 1, 0, 0, 0.25; + // 0, 1, 0, 0.5; + // 0, 0, 1, 0.75; + // 1, 1, 1, 1; + // ]; + // srgbColors = [ + // 1.0930908918380737, -0.5116420984268188, -0.0003518527664709836, 0.12397786229848862; + // -0.22684034705162048, 1.0182716846466064, 0.00027732315356843174, 0.5073589086532593; + // -0.15007957816123962, -0.31062406301498413, 1.0420056581497192, 0.771118700504303; + // 1, 1, 1, 1; + // ]; + // + // format long + // p3ToSrgb = srgbColors * inv(p3Colors) + // srgbToP3 = inv(p3ToSrgb) + const _MatrixColorTransform srgbToP3 = _MatrixColorTransform([ + 0.808052267214446, 0.220292047628890, -0.139648846160100, + 0.145738111193222, // + 0.096480880462996, 0.916386732581291, -0.086093928394828, + 0.089490172325882, // + -0.127099563510240, -0.068983484963878, 0.735426667591299, 0.233655661600230 + ]); + const _ColorTransform p3ToSrgb = _MatrixColorTransform([ + 1.306671048092539, -0.298061942172353, 0.213228303487995, + -0.213580156254466, // + -0.117390025596251, 1.127722006101976, 0.109727644608938, + -0.109450321455370, // + 0.214813187718391, 0.054268702864647, 1.406898424029350, -0.364892765879631 + ]); + switch (source) { + case ColorSpace.sRGB: + switch (destination) { + case ColorSpace.sRGB: + return const _IdentityColorTransform(); + case ColorSpace.extendedSRGB: + return const _IdentityColorTransform(); + case ColorSpace.displayP3: + return srgbToP3; + } + case ColorSpace.extendedSRGB: + switch (destination) { + case ColorSpace.sRGB: + return const _ClampTransform(_IdentityColorTransform()); + case ColorSpace.extendedSRGB: + return const _IdentityColorTransform(); + case ColorSpace.displayP3: + return const _ClampTransform(srgbToP3); + } + case ColorSpace.displayP3: + switch (destination) { + case ColorSpace.sRGB: + return const _ClampTransform(p3ToSrgb); + case ColorSpace.extendedSRGB: + return p3ToSrgb; + case ColorSpace.displayP3: + return const _IdentityColorTransform(); + } + } +} + /// A description of a color filter to apply when drawing a shape or compositing /// a layer with a particular [Paint]. A color filter is a function that takes /// two colors, and outputs one color. When applied during compositing, it is diff --git a/lib/ui/painting/paint.cc b/lib/ui/painting/paint.cc index b13f3bb6b1af7..ab746738bb96c 100644 --- a/lib/ui/painting/paint.cc +++ b/lib/ui/painting/paint.cc @@ -21,20 +21,25 @@ namespace flutter { // Indices for 32bit values. +// Must match //lib/ui/painting.dart. constexpr int kIsAntiAliasIndex = 0; -constexpr int kColorIndex = 1; -constexpr int kBlendModeIndex = 2; -constexpr int kStyleIndex = 3; -constexpr int kStrokeWidthIndex = 4; -constexpr int kStrokeCapIndex = 5; -constexpr int kStrokeJoinIndex = 6; -constexpr int kStrokeMiterLimitIndex = 7; -constexpr int kFilterQualityIndex = 8; -constexpr int kMaskFilterIndex = 9; -constexpr int kMaskFilterBlurStyleIndex = 10; -constexpr int kMaskFilterSigmaIndex = 11; -constexpr int kInvertColorIndex = 12; -constexpr size_t kDataByteCount = 52; // 4 * (last index + 1) +constexpr int kColorRedIndex = 1; +constexpr int kColorGreenIndex = 2; +constexpr int kColorBlueIndex = 3; +constexpr int kColorAlphaIndex = 4; +constexpr int kColorSpaceIndex = 5; +constexpr int kBlendModeIndex = 6; +constexpr int kStyleIndex = 7; +constexpr int kStrokeWidthIndex = 8; +constexpr int kStrokeCapIndex = 9; +constexpr int kStrokeJoinIndex = 10; +constexpr int kStrokeMiterLimitIndex = 11; +constexpr int kFilterQualityIndex = 12; +constexpr int kMaskFilterIndex = 13; +constexpr int kMaskFilterBlurStyleIndex = 14; +constexpr int kMaskFilterSigmaIndex = 15; +constexpr int kInvertColorIndex = 16; +constexpr size_t kDataByteCount = 68; // 4 * (last index + 1) static_assert(kDataByteCount == sizeof(uint32_t) * (kInvertColorIndex + 1), "kDataByteCount must match the size of the data array."); @@ -44,9 +49,6 @@ constexpr int kColorFilterIndex = 1; constexpr int kImageFilterIndex = 2; constexpr int kObjectCount = 3; // One larger than largest object index. -// Must be kept in sync with the default in painting.dart. -constexpr uint32_t kColorDefault = 0xFF000000; - // Must be kept in sync with the default in painting.dart. constexpr uint32_t kBlendModeDefault = static_cast(SkBlendMode::kSrcOver); @@ -58,6 +60,28 @@ constexpr float kStrokeMiterLimitDefault = 4.0f; // Must be kept in sync with the MaskFilter private constants in painting.dart. enum MaskFilterType { kNull, kBlur }; +namespace { +DlColor ReadColor(const tonic::DartByteData& byte_data) { + const uint32_t* uint_data = static_cast(byte_data.data()); + const float* float_data = static_cast(byte_data.data()); + + float red = float_data[kColorRedIndex]; + float green = float_data[kColorGreenIndex]; + float blue = float_data[kColorBlueIndex]; + // Invert alpha so 0 initialized buffer has default value; + float alpha = 1.f - float_data[kColorAlphaIndex]; + uint32_t colorspace = uint_data[kColorSpaceIndex]; + (void)colorspace; + uint32_t encoded_color = + static_cast(std::round(alpha * 255.f)) << 24 | // + static_cast(std::round(red * 255.f)) << 16 | // + static_cast(std::round(green * 255.f)) << 8 | // + static_cast(std::round(blue * 255.f)) << 0; + // TODO(gaaclarke): Pass down color info to DlColor. + return DlColor(encoded_color); +} +} // namespace + Paint::Paint(Dart_Handle paint_objects, Dart_Handle paint_data) : paint_objects_(paint_objects), paint_data_(paint_data) {} @@ -137,8 +161,7 @@ const DlPaint* Paint::paint(DlPaint& paint, } if (flags.applies_alpha_or_color()) { - uint32_t encoded_color = uint_data[kColorIndex]; - paint.setColor(DlColor(encoded_color ^ kColorDefault)); + paint.setColor(ReadColor(byte_data)); } if (flags.applies_blend()) { @@ -238,8 +261,7 @@ void Paint::toDlPaint(DlPaint& paint) const { paint.setAntiAlias(uint_data[kIsAntiAliasIndex] == 0); - uint32_t encoded_color = uint_data[kColorIndex]; - paint.setColor(DlColor(encoded_color ^ kColorDefault)); + paint.setColor(ReadColor(byte_data)); uint32_t encoded_blend_mode = uint_data[kBlendModeIndex]; uint32_t blend_mode = encoded_blend_mode ^ kBlendModeDefault; diff --git a/lib/web_ui/lib/painting.dart b/lib/web_ui/lib/painting.dart index 6369511173041..e224a1815422d 100644 --- a/lib/web_ui/lib/painting.dart +++ b/lib/web_ui/lib/painting.dart @@ -22,25 +22,126 @@ Color _scaleAlpha(Color a, double factor) { } class Color { - const Color(int value) : value = value & 0xFFFFFFFF; + const Color(int value) + : _value = value & 0xFFFFFFFF, + colorSpace = ColorSpace.sRGB, + _a = null, + _r = null, + _g = null, + _b = null; + + const Color.from( + {required double alpha, + required double red, + required double green, + required double blue, + this.colorSpace = ColorSpace.sRGB}) + : _value = 0, + _a = alpha, + _r = red, + _g = green, + _b = blue; + const Color.fromARGB(int a, int r, int g, int b) - : value = (((a & 0xff) << 24) | + : _value = (((a & 0xff) << 24) | ((r & 0xff) << 16) | ((g & 0xff) << 8) | ((b & 0xff) << 0)) & - 0xFFFFFFFF; + 0xFFFFFFFF, + colorSpace = ColorSpace.sRGB, + _a = null, + _r = null, + _g = null, + _b = null; + + const Color._fromARGBC( + int alpha, int red, int green, int blue, this.colorSpace) + : _value = (((alpha & 0xff) << 24) | + ((red & 0xff) << 16) | + ((green & 0xff) << 8) | + ((blue & 0xff) << 0)) & + 0xFFFFFFFF, + _a = null, + _r = null, + _g = null, + _b = null; + const Color.fromRGBO(int r, int g, int b, double opacity) - : value = ((((opacity * 0xff ~/ 1) & 0xff) << 24) | + : _value = ((((opacity * 0xff ~/ 1) & 0xff) << 24) | ((r & 0xff) << 16) | ((g & 0xff) << 8) | ((b & 0xff) << 0)) & - 0xFFFFFFFF; - final int value; + 0xFFFFFFFF, + colorSpace = ColorSpace.sRGB, + _a = null, + _r = null, + _g = null, + _b = null; + + double get a => _a ?? (alpha / 255); + final double? _a; + + double get r => _r ?? (red / 255); + final double? _r; + + double get g => _g ?? (green / 255); + final double? _g; + + double get b => _b ?? (blue / 255); + final double? _b; + + final ColorSpace colorSpace; + + static int _floatToInt8(double x) { + return ((x * 255.0).round()) & 0xff; + } + + int get value { + if (_a != null && _r != null && _g != null && _b != null) { + return _floatToInt8(_a) << 24 | + _floatToInt8(_r) << 16 | + _floatToInt8(_g) << 8 | + _floatToInt8(_b) << 0; + } else { + return _value; + } + } + final int _value; + int get alpha => (0xff000000 & value) >> 24; + double get opacity => alpha / 0xFF; + int get red => (0x00ff0000 & value) >> 16; + int get green => (0x0000ff00 & value) >> 8; + int get blue => (0x000000ff & value) >> 0; + + Color withValues( + {double? alpha, + double? red, + double? green, + double? blue, + ColorSpace? colorSpace}) { + Color? updatedComponents; + if (alpha != null || red != null || green != null || blue != null) { + updatedComponents = Color.from( + alpha: alpha ?? a, + red: red ?? r, + green: green ?? g, + blue: blue ?? b, + colorSpace: this.colorSpace); + } + if (colorSpace != null && colorSpace != this.colorSpace) { + final _ColorTransform transform = + _getColorTransform(this.colorSpace, colorSpace); + return transform.transform(updatedComponents ?? this, colorSpace); + } else { + return updatedComponents ?? this; + } + } + Color withAlpha(int a) { return Color.fromARGB(a, red, green, blue); } @@ -79,6 +180,8 @@ class Color { } static Color? lerp(Color? a, Color? b, double t) { + assert(a?.colorSpace != ColorSpace.extendedSRGB); + assert(b?.colorSpace != ColorSpace.extendedSRGB); if (b == null) { if (a == null) { return null; @@ -89,42 +192,45 @@ class Color { if (a == null) { return _scaleAlpha(b, t); } else { - return Color.fromARGB( + assert(a.colorSpace == b.colorSpace); + return Color._fromARGBC( engine.clampInt(_lerpInt(a.alpha, b.alpha, t).toInt(), 0, 255), engine.clampInt(_lerpInt(a.red, b.red, t).toInt(), 0, 255), engine.clampInt(_lerpInt(a.green, b.green, t).toInt(), 0, 255), engine.clampInt(_lerpInt(a.blue, b.blue, t).toInt(), 0, 255), + a.colorSpace, ); } } } static Color alphaBlend(Color foreground, Color background) { + assert(foreground.colorSpace == background.colorSpace); + assert(foreground.colorSpace != ColorSpace.extendedSRGB); final int alpha = foreground.alpha; if (alpha == 0x00) { - // Foreground completely transparent. return background; } final int invAlpha = 0xff - alpha; int backAlpha = background.alpha; if (backAlpha == 0xff) { - // Opaque background case - return Color.fromARGB( + return Color._fromARGBC( 0xff, (alpha * foreground.red + invAlpha * background.red) ~/ 0xff, (alpha * foreground.green + invAlpha * background.green) ~/ 0xff, (alpha * foreground.blue + invAlpha * background.blue) ~/ 0xff, + foreground.colorSpace, ); } else { - // General case backAlpha = (backAlpha * invAlpha) ~/ 0xff; final int outAlpha = alpha + backAlpha; assert(outAlpha != 0x00); - return Color.fromARGB( + return Color._fromARGBC( outAlpha, (foreground.red * alpha + background.red * backAlpha) ~/ outAlpha, (foreground.green * alpha + background.green * backAlpha) ~/ outAlpha, (foreground.blue * alpha + background.blue * backAlpha) ~/ outAlpha, + foreground.colorSpace, ); } } @@ -141,16 +247,16 @@ class Color { if (other.runtimeType != runtimeType) { return false; } - return other is Color && other.value == value; + return other is Color && + other.value == value && + other.colorSpace == colorSpace; } @override - int get hashCode => value.hashCode; + int get hashCode => Object.hash(value, colorSpace); @override - String toString() { - return 'Color(0x${value.toRadixString(16).padLeft(8, '0')})'; - } + String toString() => 'Color(0x${value.toRadixString(16).padLeft(8, '0')})'; } enum StrokeCap { @@ -414,6 +520,101 @@ class MaskFilter { String toString() => 'MaskFilter.blur($_style, ${_sigma.toStringAsFixed(1)})'; } +abstract class _ColorTransform { + Color transform(Color color, ColorSpace resultColorSpace); +} + +class _IdentityColorTransform implements _ColorTransform { + const _IdentityColorTransform(); + @override + Color transform(Color color, ColorSpace resultColorSpace) => color; +} + +class _ClampTransform implements _ColorTransform { + const _ClampTransform(this.child); + final _ColorTransform child; + @override + Color transform(Color color, ColorSpace resultColorSpace) { + return Color.from( + alpha: clampDouble(color.a, 0, 1), + red: clampDouble(color.r, 0, 1), + green: clampDouble(color.g, 0, 1), + blue: clampDouble(color.b, 0, 1), + colorSpace: resultColorSpace); + } +} + +class _MatrixColorTransform implements _ColorTransform { + const _MatrixColorTransform(this.values); + + final List values; + + @override + Color transform(Color color, ColorSpace resultColorSpace) { + return Color.from( + alpha: color.a, + red: values[0] * color.r + + values[1] * color.g + + values[2] * color.b + + values[3], + green: values[4] * color.r + + values[5] * color.g + + values[6] * color.b + + values[7], + blue: values[8] * color.r + + values[9] * color.g + + values[10] * color.b + + values[11], + colorSpace: resultColorSpace); + } +} + +_ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) { + const _MatrixColorTransform srgbToP3 = _MatrixColorTransform([ + 0.808052267214446, 0.220292047628890, -0.139648846160100, + 0.145738111193222, // + 0.096480880462996, 0.916386732581291, -0.086093928394828, + 0.089490172325882, // + -0.127099563510240, -0.068983484963878, 0.735426667591299, 0.233655661600230 + ]); + const _ColorTransform p3ToSrgb = _MatrixColorTransform([ + 1.306671048092539, -0.298061942172353, 0.213228303487995, + -0.213580156254466, // + -0.117390025596251, 1.127722006101976, 0.109727644608938, + -0.109450321455370, // + 0.214813187718391, 0.054268702864647, 1.406898424029350, -0.364892765879631 + ]); + switch (source) { + case ColorSpace.sRGB: + switch (destination) { + case ColorSpace.sRGB: + return const _IdentityColorTransform(); + case ColorSpace.extendedSRGB: + return const _IdentityColorTransform(); + case ColorSpace.displayP3: + return srgbToP3; + } + case ColorSpace.extendedSRGB: + switch (destination) { + case ColorSpace.sRGB: + return const _ClampTransform(_IdentityColorTransform()); + case ColorSpace.extendedSRGB: + return const _IdentityColorTransform(); + case ColorSpace.displayP3: + return const _ClampTransform(srgbToP3); + } + case ColorSpace.displayP3: + switch (destination) { + case ColorSpace.sRGB: + return const _ClampTransform(p3ToSrgb); + case ColorSpace.extendedSRGB: + return p3ToSrgb; + case ColorSpace.displayP3: + return const _IdentityColorTransform(); + } + } +} + // This needs to be kept in sync with the "_FilterQuality" enum in skwasm's canvas.cpp enum FilterQuality { none, @@ -453,6 +654,7 @@ class ImageFilter { enum ColorSpace { sRGB, extendedSRGB, + displayP3, } // This must be kept in sync with the `ImageByteFormat` enum in Skwasm's surface.cpp. diff --git a/testing/dart/color_test.dart b/testing/dart/color_test.dart index 5d8f680cb8062..56e8766f103b2 100644 --- a/testing/dart/color_test.dart +++ b/testing/dart/color_test.dart @@ -10,6 +10,10 @@ class NotAColor extends Color { const NotAColor(super.value); } +Matcher approxEquals(dynamic o) => (v) { + Expect.approxEquals(o as num, v as num); +}; + void main() { test('color accessors should work', () { const Color foo = Color(0x12345678); @@ -76,6 +80,51 @@ void main() { ); }); + test('Color.lerp different colorspaces', () { + bool didThrow = false; + try { + Color.lerp( + const Color.from( + alpha: 1, + red: 1, + green: 0, + blue: 0, + colorSpace: ColorSpace.displayP3), + const Color.from( + alpha: 1, red: 1, green: 0, blue: 0), + 0.0); + } catch (ex) { + didThrow = true; + } + expect(didThrow, isTrue); + }); + + test('Color.lerp same colorspaces', () { + expect( + Color.lerp( + const Color.from( + alpha: 1, + red: 0, + green: 0, + blue: 0, + colorSpace: ColorSpace.displayP3), + const Color.from( + alpha: 1, + red: 1, + green: 0, + blue: 0, + colorSpace: ColorSpace.displayP3), + 0.2), + equals( + const Color.from( + alpha: 1, + red: 0.2, + green: 0, + blue: 0, + colorSpace: ColorSpace.displayP3), + )); + }); + test('Color.alphaBlend', () { expect( Color.alphaBlend(const Color(0x00000000), const Color(0x00000000)), @@ -119,6 +168,29 @@ void main() { ); }); + test('Color.alphaBlend keeps colorspace', () { + expect( + Color.alphaBlend( + const Color.from( + alpha: 0.5, + red: 1, + green: 1, + blue: 1, + colorSpace: ColorSpace.displayP3), + const Color.from( + alpha: 1, + red: 0, + green: 0, + blue: 0, + colorSpace: ColorSpace.displayP3)), + const Color.from( + alpha: 1, + red: 0.5, + green: 0.5, + blue: 0.5, + colorSpace: ColorSpace.displayP3)); + }); + test('compute gray luminance', () { // Each color component is at 20%. const Color lightGray = Color(0xFF333333); @@ -134,4 +206,124 @@ void main() { // 0.0722 * ((0.18823529411 + 0.055) / 1.055) ^ 2.4 expect(brightRed.computeLuminance(), equals(0.24601329637099723)); }); + + test('from and accessors', () { + const Color color = Color.from(alpha: 0.1, red: 0.2, green: 0.3, blue: 0.4); + expect(color.a, equals(0.1)); + expect(color.r, equals(0.2)); + expect(color.g, equals(0.3)); + expect(color.b, equals(0.4)); + expect(color.colorSpace, equals(ColorSpace.sRGB)); + + expect(color.alpha, equals(26)); + expect(color.red, equals(51)); + expect(color.green, equals(77)); + expect(color.blue, equals(102)); + + expect(color.value, equals(0x1a334d66)); + }); + + test('fromARGB and accessors', () { + const Color color = Color.fromARGB(10, 20, 35, 47); + expect(color.alpha, equals(10)); + expect(color.red, equals(20)); + expect(color.green, equals(35)); + expect(color.blue, equals(47)); + }); + + test('constructor and accessors', () { + const Color color = Color(0xffeeddcc); + expect(color.alpha, equals(0xff)); + expect(color.red, equals(0xee)); + expect(color.green, equals(0xdd)); + expect(color.blue, equals(0xcc)); + }); + + test('p3 to extended srgb', () { + const Color p3 = Color.from( + alpha: 1, red: 1, green: 0, blue: 0, colorSpace: ColorSpace.displayP3); + final Color srgb = p3.withValues(colorSpace: ColorSpace.extendedSRGB); + expect(srgb.a, equals(1.0)); + expect(srgb.r, approxEquals(1.0931)); + expect(srgb.g, approxEquals(-0.22684034705162098)); + expect(srgb.b, approxEquals(-0.15007957816123998)); + expect(srgb.colorSpace, equals(ColorSpace.extendedSRGB)); + }); + + test('p3 to srgb', () { + const Color p3 = Color.from( + alpha: 1, red: 1, green: 0, blue: 0, colorSpace: ColorSpace.displayP3); + final Color srgb = p3.withValues(colorSpace: ColorSpace.sRGB); + expect(srgb.a, equals(1.0)); + expect(srgb.r, approxEquals(1)); + expect(srgb.g, approxEquals(0)); + expect(srgb.b, approxEquals(0)); + expect(srgb.colorSpace, equals(ColorSpace.sRGB)); + }); + + test('extended srgb to p3', () { + const Color srgb = Color.from( + alpha: 1, + red: 1.0931, + green: -0.2268, + blue: -0.1501, + colorSpace: ColorSpace.extendedSRGB); + final Color p3 = srgb.withValues(colorSpace: ColorSpace.displayP3); + expect(p3.a, equals(1.0)); + expect(p3.r, approxEquals(1)); + expect(p3.g, approxEquals(0)); + expect(p3.b, approxEquals(0)); + expect(p3.colorSpace, equals(ColorSpace.displayP3)); + }); + + test('extended srgb to p3 clamped', () { + const Color srgb = Color.from( + alpha: 1, + red: 2, + green: 0, + blue: 0, + colorSpace: ColorSpace.extendedSRGB); + final Color p3 = srgb.withValues(colorSpace: ColorSpace.displayP3); + expect(srgb.a, equals(1.0)); + expect(p3.r <= 1.0, isTrue); + expect(p3.g <= 1.0, isTrue); + expect(p3.b <= 1.0, isTrue); + expect(p3.r >= 0.0, isTrue); + expect(p3.g >= 0.0, isTrue); + expect(p3.b >= 0.0, isTrue); + }); + + test('hash considers colorspace', () { + const Color srgb = Color.from( + alpha: 1, red: 1, green: 0, blue: 0); + const Color p3 = Color.from( + alpha: 1, red: 1, green: 0, blue: 0, colorSpace: ColorSpace.displayP3); + expect(srgb.hashCode, notEquals(p3.hashCode)); + }); + + test('equality considers colorspace', () { + const Color srgb = Color.from( + alpha: 1, red: 1, green: 0, blue: 0); + const Color p3 = Color.from( + alpha: 1, red: 1, green: 0, blue: 0, colorSpace: ColorSpace.displayP3); + expect(srgb, notEquals(p3)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/41257 + // CupertinoDynamicColor was overriding base class and calling super(0). + test('subclass of Color can override value', () { + const DynamicColorClass color = DynamicColorClass(0xF0E0D0C0); + expect(color.value, 0xF0E0D0C0); + // Call base class member, make sure it uses overridden value. + expect(color.red, 0xE0); + }); +} + +class DynamicColorClass extends Color { + const DynamicColorClass(int newValue) : _newValue = newValue, super(0); + + final int _newValue; + + @override + int get value => _newValue; }