From f8aaee56949afc14a49b3a30ad613e735dff08bf Mon Sep 17 00:00:00 2001 From: nmarci89 Date: Fri, 30 Jan 2026 21:53:45 +0100 Subject: [PATCH] Fix P3-to-sRGB color conversion to operate in linear light (#181720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem The P3-to-sRGB color conversion applies an affine matrix directly to gamma-encoded values in both the C++ engine (`dl_color.cc`) and Dart (`painting.dart`). This is mathematically incorrect — color space conversion matrices must operate in linear light. The result is P3 colors that appear nearly identical to sRGB instead of showing the full gamut difference. For example, P3 `#1ECAD3` (30/255, 202/255, 211/255) converts to: - **Old (wrong):** extendedSRGB (-0.12, 0.86, 0.87) - **New (correct):** extendedSRGB (-0.38, 0.81, 0.84) The red channel error of 0.26 is clearly visible — colors appear washed out instead of vivid. ### Fix Replace the single affine matrix multiply with the correct 3-step pipeline: 1. **Linearize** — decode gamma via sRGB EOTF 2. **Transform** — apply 3x3 P3-to-sRGB matrix in linear space 3. **Encode** — re-apply gamma via sRGB OETF Extended range (negative values from out-of-gamut colors) is handled by mirroring the transfer function. **C++ (`dl_color.cc`):** Replace affine matrix with `p3ToExtendedSrgb()` using `double` precision. **Dart (`painting.dart`):** Replace `_MatrixColorTransform` with `_P3ToSrgbTransform` and `_SrgbToP3Transform` classes. Also fixes the sRGB-to-P3 direction. ### Performance Negligible. The C++ conversion runs once per paint setup in `ReadColor`, not per frame or per pixel. The Dart conversion runs once per `Color.withValues()` call. ### Tests - **C++:** Added `ColorSpaceP3ToExtendedSRGBLinearLight` test in `dl_color_unittests.cc` - **Dart:** Added 3 tests in `colors_test.dart`: mid-range P3→extendedSRGB, P3 green→extendedSRGB, sRGB→P3 round-trip All new tests fail with the old code and pass with the fix. Existing tests continue to pass. ## Issue: https://github.com/flutter/flutter/issues/181717 ## 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], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. **New tests pass, can't run golden tests!** --------- Co-authored-by: gaaclarke <30870216+gaaclarke@users.noreply.github.com> --- engine/src/flutter/display_list/dl_color.cc | 104 +++++++++--- .../display_list/dl_color_unittests.cc | 29 ++++ engine/src/flutter/lib/ui/painting.dart | 154 +++++++++++++----- .../src/flutter/lib/web_ui/lib/painting.dart | 134 ++++++++++++--- .../flutter/test/painting/colors_test.dart | 53 ++++++ 5 files changed, 379 insertions(+), 95 deletions(-) diff --git a/engine/src/flutter/display_list/dl_color.cc b/engine/src/flutter/display_list/dl_color.cc index a78339cc50f..258b6a6b4c3 100644 --- a/engine/src/flutter/display_list/dl_color.cc +++ b/engine/src/flutter/display_list/dl_color.cc @@ -5,36 +5,87 @@ #include "flutter/display_list/dl_color.h" #include +#include namespace flutter { namespace { -const std::array kP3ToSrgb = { - 1.306671048092539, -0.298061942172353, - 0.213228303487995, -0.213580156254466, // - -0.117390025596251, 1.127722006101976, - 0.109727644608938, -0.109450321455370, // - 0.214813187718391, 0.054268702864647, - 1.406898424029350, -0.364892765879631}; -DlColor transform(const DlColor& color, - const std::array& matrix, - DlColorSpace color_space) { - return DlColor(color.getAlphaF(), - matrix[0] * color.getRedF() + // - matrix[1] * color.getGreenF() + // - matrix[2] * color.getBlueF() + // - matrix[3], // - matrix[4] * color.getRedF() + // - matrix[5] * color.getGreenF() + // - matrix[6] * color.getBlueF() + // - matrix[7], // - matrix[8] * color.getRedF() + // - matrix[9] * color.getGreenF() + // - matrix[10] * color.getBlueF() + // - matrix[11], // - color_space); +/// sRGB standard constants for transfer functions. +/// See https://en.wikipedia.org/wiki/SRGB. +constexpr double kSrgbGamma = 2.4; +constexpr double kSrgbLinearThreshold = 0.04045; +constexpr double kSrgbLinearSlope = 12.92; +constexpr double kSrgbEncodedOffset = 0.055; +constexpr double kSrgbEncodedDivisor = 1.055; +constexpr double kSrgbLinearToEncodedThreshold = 0.0031308; + +/// sRGB electro-optical transfer function (gamma decode, gamma ~2.2 to linear). +double srgbEOTF(double v) { + if (v <= kSrgbLinearThreshold) { + return v / kSrgbLinearSlope; + } + return std::pow((v + kSrgbEncodedOffset) / kSrgbEncodedDivisor, kSrgbGamma); } + +/// sRGB opto-electronic transfer function (linear to gamma encode). +double srgbOETF(double v) { + if (v <= kSrgbLinearToEncodedThreshold) { + return v * kSrgbLinearSlope; + } + return kSrgbEncodedDivisor * std::pow(v, 1.0 / kSrgbGamma) - + kSrgbEncodedOffset; +} + +/// sRGB EOTF extended to handle negative values (for extended sRGB). +double srgbEOTFExtended(double v) { + return v < 0.0 ? -srgbEOTF(-v) : srgbEOTF(v); +} + +/// sRGB OETF extended to handle negative values (for extended sRGB). +double srgbOETFExtended(double v) { + return v < 0.0 ? -srgbOETF(-v) : srgbOETF(v); +} + +/// Display P3 to sRGB linear 3x3 matrix. +/// Both P3 and sRGB use the same D65 white point. +/// P3 has wider primaries than sRGB, so converting P3 colors to sRGB +/// can produce values outside [0,1] (extended sRGB). +/// +/// Matrix derived from: +/// M = sRGB_XYZ_to_RGB * P3_RGB_to_XYZ +static constexpr double kP3ToSrgbLinear[9] = { + 1.2249401, -0.2249402, 0.0, -0.0420569, 1.0420571, + 0.0, -0.0196376, -0.0786507, 1.0982884, +}; + +/// Converts a Display P3 color (gamma-encoded) to extended sRGB +/// (gamma-encoded). Steps: P3 gamma decode -> linear P3 -> linear sRGB (via 3x3 +/// matrix) -> sRGB gamma encode. +DlColor p3ToExtendedSrgb(const DlColor& color) { + // Linearize P3 values (P3 uses same transfer function as sRGB). + double r_lin = srgbEOTFExtended(static_cast(color.getRedF())); + double g_lin = srgbEOTFExtended(static_cast(color.getGreenF())); + double b_lin = srgbEOTFExtended(static_cast(color.getBlueF())); + + // Apply 3x3 P3-to-sRGB matrix in linear space. + double r_srgb_lin = kP3ToSrgbLinear[0] * r_lin + kP3ToSrgbLinear[1] * g_lin + + kP3ToSrgbLinear[2] * b_lin; + double g_srgb_lin = kP3ToSrgbLinear[3] * r_lin + kP3ToSrgbLinear[4] * g_lin + + kP3ToSrgbLinear[5] * b_lin; + double b_srgb_lin = kP3ToSrgbLinear[6] * r_lin + kP3ToSrgbLinear[7] * g_lin + + kP3ToSrgbLinear[8] * b_lin; + + // Gamma encode back to sRGB. + double r_out = srgbOETFExtended(r_srgb_lin); + double g_out = srgbOETFExtended(g_srgb_lin); + double b_out = srgbOETFExtended(b_srgb_lin); + + return DlColor(color.getAlphaF(), static_cast(r_out), + static_cast(g_out), static_cast(b_out), + DlColorSpace::kExtendedSRGB); +} + } // namespace DlColor DlColor::withColorSpace(DlColorSpace color_space) const { @@ -65,10 +116,9 @@ DlColor DlColor::withColorSpace(DlColorSpace color_space) const { case DlColorSpace::kDisplayP3: switch (color_space) { case DlColorSpace::kSRGB: - return transform(*this, kP3ToSrgb, DlColorSpace::kExtendedSRGB) - .withColorSpace(DlColorSpace::kSRGB); + return p3ToExtendedSrgb(*this).withColorSpace(DlColorSpace::kSRGB); case DlColorSpace::kExtendedSRGB: - return transform(*this, kP3ToSrgb, DlColorSpace::kExtendedSRGB); + return p3ToExtendedSrgb(*this); case DlColorSpace::kDisplayP3: return *this; } diff --git a/engine/src/flutter/display_list/dl_color_unittests.cc b/engine/src/flutter/display_list/dl_color_unittests.cc index 9164f9fb867..98ecbbbc914 100644 --- a/engine/src/flutter/display_list/dl_color_unittests.cc +++ b/engine/src/flutter/display_list/dl_color_unittests.cc @@ -299,6 +299,35 @@ TEST(DisplayListColor, ColorSpaceP3ToExtendedSRGB) { << blue.withColorSpace(DlColorSpace::kExtendedSRGB); } +// Verifies that P3-to-sRGB conversion operates in linear light. +// Mid-range values (not 0 or 1) expose the gamma nonlinearity: +// the correct pipeline is EOTF(decode) -> 3x3 matrix -> OETF(encode), +// which produces significantly different results than applying a matrix +// directly to gamma-encoded values. +TEST(DisplayListColor, ColorSpaceP3ToExtendedSRGBLinearLight) { + // P3 #1ECAD3 (30/255, 202/255, 211/255) + // Correct extended sRGB: (-0.3764, 0.8065, 0.8372) + // Wrong (gamma-encoded matrix): (-0.1195, 0.8609, 0.8675) + // Red channel diff: 0.2569 — well above isClose tolerance of 1/256. + DlColor p3_teal(1.0, 30.0f / 255.0f, 202.0f / 255.0f, 211.0f / 255.0f, + DlColorSpace::kDisplayP3); + DlColor expected_teal(1.0, -0.3764f, 0.8065f, 0.8372f, + DlColorSpace::kExtendedSRGB); + EXPECT_TRUE(expected_teal.isClose( + p3_teal.withColorSpace(DlColorSpace::kExtendedSRGB))) + << p3_teal.withColorSpace(DlColorSpace::kExtendedSRGB); + + // P3 (0, 1, 1) — max teal + // Correct extended sRGB: (-0.5116, 1.0183, 1.0086) + // Wrong (gamma-encoded matrix): (-0.2984, 1.1280, 1.0963) + DlColor p3_max_teal(1.0, 0.0, 1.0, 1.0, DlColorSpace::kDisplayP3); + DlColor expected_max_teal(1.0, -0.5116f, 1.0183f, 1.0086f, + DlColorSpace::kExtendedSRGB); + EXPECT_TRUE(expected_max_teal.isClose( + p3_max_teal.withColorSpace(DlColorSpace::kExtendedSRGB))) + << p3_max_teal.withColorSpace(DlColorSpace::kExtendedSRGB); +} + TEST(DisplayListColor, ColorSpaceP3ToSRGB) { DlColor red(0.9, 1.0, 0.0, 0.0, DlColorSpace::kDisplayP3); EXPECT_TRUE(DlColor(0.9, 1.0, 0.0, 0.0, DlColorSpace::kSRGB) diff --git a/engine/src/flutter/lib/ui/painting.dart b/engine/src/flutter/lib/ui/painting.dart index a7286cca371..d3ca916fe41 100644 --- a/engine/src/flutter/lib/ui/painting.dart +++ b/engine/src/flutter/lib/ui/painting.dart @@ -3904,58 +3904,124 @@ class _ClampTransform implements _ColorTransform { } } -class _MatrixColorTransform implements _ColorTransform { - /// Row-major. - const _MatrixColorTransform(this.values); +// sRGB standard constants for transfer functions. +// See https://en.wikipedia.org/wiki/SRGB. +const double _kSrgbGamma = 2.4; +const double _kSrgbLinearThreshold = 0.04045; +const double _kSrgbLinearSlope = 12.92; +const double _kSrgbEncodedOffset = 0.055; +const double _kSrgbEncodedDivisor = 1.055; +const double _kSrgbLinearToEncodedThreshold = 0.0031308; - final List values; +/// sRGB electro-optical transfer function (gamma decode to linear). +double _srgbEOTF(double v) { + if (v <= _kSrgbLinearThreshold) { + return v / _kSrgbLinearSlope; + } + return math.pow((v + _kSrgbEncodedOffset) / _kSrgbEncodedDivisor, _kSrgbGamma).toDouble(); +} + +/// sRGB opto-electronic transfer function (linear to gamma encode). +double _srgbOETF(double v) { + if (v <= _kSrgbLinearToEncodedThreshold) { + return v * _kSrgbLinearSlope; + } + return _kSrgbEncodedDivisor * math.pow(v, 1.0 / _kSrgbGamma).toDouble() - _kSrgbEncodedOffset; +} + +/// Extended versions that handle negative values by mirroring. +double _srgbEOTFExtended(double v) { + return v < 0.0 ? -_srgbEOTF(-v) : _srgbEOTF(v); +} + +double _srgbOETFExtended(double v) { + return v < 0.0 ? -_srgbOETF(-v) : _srgbOETF(v); +} + +/// Display P3 to sRGB 3x3 matrix in linear space. +/// M = sRGB_XYZ_to_RGB * P3_RGB_to_XYZ +const List _kP3ToSrgbLinear = [ + 1.2249401, + -0.2249402, + 0.0, + -0.0420569, + 1.0420571, + 0.0, + -0.0196376, + -0.0786507, + 1.0982884, +]; + +/// sRGB to Display P3 3x3 matrix in linear space (inverse of [_kP3ToSrgbLinear]). +const List _kSrgbToP3Linear = [ + 0.8224622, + 0.1775380, + 0.0, + 0.0331942, + 0.9668058, + 0.0, + 0.0170806, + 0.0723974, + 0.9105220, +]; + +/// Converts Display P3 (gamma-encoded) to extended sRGB (gamma-encoded). +/// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode). +class _P3ToSrgbTransform implements _ColorTransform { + const _P3ToSrgbTransform(); @override Color transform(Color color, ColorSpace resultColorSpace) { + final double rLin = _srgbEOTFExtended(color.r); + final double gLin = _srgbEOTFExtended(color.g); + final double bLin = _srgbEOTFExtended(color.b); + + final double rOut = + _kP3ToSrgbLinear[0] * rLin + _kP3ToSrgbLinear[1] * gLin + _kP3ToSrgbLinear[2] * bLin; + final double gOut = + _kP3ToSrgbLinear[3] * rLin + _kP3ToSrgbLinear[4] * gLin + _kP3ToSrgbLinear[5] * bLin; + final double bOut = + _kP3ToSrgbLinear[6] * rLin + _kP3ToSrgbLinear[7] * gLin + _kP3ToSrgbLinear[8] * bLin; + 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], + red: _srgbOETFExtended(rOut), + green: _srgbOETFExtended(gOut), + blue: _srgbOETFExtended(bOut), + colorSpace: resultColorSpace, + ); + } +} + +/// Converts sRGB (gamma-encoded) to Display P3 (gamma-encoded). +/// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode). +class _SrgbToP3Transform implements _ColorTransform { + const _SrgbToP3Transform(); + + @override + Color transform(Color color, ColorSpace resultColorSpace) { + final double rLin = _srgbEOTFExtended(color.r); + final double gLin = _srgbEOTFExtended(color.g); + final double bLin = _srgbEOTFExtended(color.b); + + final double rOut = + _kSrgbToP3Linear[0] * rLin + _kSrgbToP3Linear[1] * gLin + _kSrgbToP3Linear[2] * bLin; + final double gOut = + _kSrgbToP3Linear[3] * rLin + _kSrgbToP3Linear[4] * gLin + _kSrgbToP3Linear[5] * bLin; + final double bOut = + _kSrgbToP3Linear[6] * rLin + _kSrgbToP3Linear[7] * gLin + _kSrgbToP3Linear[8] * bLin; + + return Color.from( + alpha: color.a, + red: _srgbOETFExtended(rOut), + green: _srgbOETFExtended(gOut), + blue: _srgbOETFExtended(bOut), 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 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) { @@ -3964,7 +4030,7 @@ _ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) { case ColorSpace.extendedSRGB: return const _IdentityColorTransform(); case ColorSpace.displayP3: - return srgbToP3; + return const _SrgbToP3Transform(); } case ColorSpace.extendedSRGB: switch (destination) { @@ -3973,14 +4039,14 @@ _ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) { case ColorSpace.extendedSRGB: return const _IdentityColorTransform(); case ColorSpace.displayP3: - return const _ClampTransform(srgbToP3); + return const _ClampTransform(_SrgbToP3Transform()); } case ColorSpace.displayP3: switch (destination) { case ColorSpace.sRGB: - return const _ClampTransform(p3ToSrgb); + return const _ClampTransform(_P3ToSrgbTransform()); case ColorSpace.extendedSRGB: - return p3ToSrgb; + return const _P3ToSrgbTransform(); case ColorSpace.displayP3: return const _IdentityColorTransform(); } diff --git a/engine/src/flutter/lib/web_ui/lib/painting.dart b/engine/src/flutter/lib/web_ui/lib/painting.dart index 67260526671..6f1572ebf8e 100644 --- a/engine/src/flutter/lib/web_ui/lib/painting.dart +++ b/engine/src/flutter/lib/web_ui/lib/painting.dart @@ -503,52 +503,138 @@ class _ClampTransform implements _ColorTransform { } } -class _MatrixColorTransform implements _ColorTransform { - const _MatrixColorTransform(this.values); +// sRGB standard constants for transfer functions. +// See https://en.wikipedia.org/wiki/SRGB. +const double _kSrgbGamma = 2.4; +const double _kSrgbLinearThreshold = 0.04045; +const double _kSrgbLinearSlope = 12.92; +const double _kSrgbEncodedOffset = 0.055; +const double _kSrgbEncodedDivisor = 1.055; +const double _kSrgbLinearToEncodedThreshold = 0.0031308; - final List values; +/// sRGB electro-optical transfer function (gamma decode to linear). +double _srgbEOTF(double v) { + if (v <= _kSrgbLinearThreshold) { + return v / _kSrgbLinearSlope; + } + return math.pow((v + _kSrgbEncodedOffset) / _kSrgbEncodedDivisor, _kSrgbGamma).toDouble(); +} + +/// sRGB opto-electronic transfer function (linear to gamma encode). +double _srgbOETF(double v) { + if (v <= _kSrgbLinearToEncodedThreshold) { + return v * _kSrgbLinearSlope; + } + return _kSrgbEncodedDivisor * math.pow(v, 1.0 / _kSrgbGamma).toDouble() - _kSrgbEncodedOffset; +} + +/// Extended versions that handle negative values by mirroring. +double _srgbEOTFExtended(double v) { + return v < 0.0 ? -_srgbEOTF(-v) : _srgbEOTF(v); +} + +double _srgbOETFExtended(double v) { + return v < 0.0 ? -_srgbOETF(-v) : _srgbOETF(v); +} + +/// Display P3 to sRGB 3x3 matrix in linear space. +/// M = sRGB_XYZ_to_RGB * P3_RGB_to_XYZ +const List _kP3ToSrgbLinear = [ + 1.2249401, + -0.2249402, + 0.0, + -0.0420569, + 1.0420571, + 0.0, + -0.0196376, + -0.0786507, + 1.0982884, +]; + +/// sRGB to Display P3 3x3 matrix in linear space (inverse of [_kP3ToSrgbLinear]). +const List _kSrgbToP3Linear = [ + 0.8224622, + 0.1775380, + 0.0, + 0.0331942, + 0.9668058, + 0.0, + 0.0170806, + 0.0723974, + 0.9105220, +]; + +/// Converts Display P3 (gamma-encoded) to extended sRGB (gamma-encoded). +/// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode). +class _P3ToSrgbTransform implements _ColorTransform { + const _P3ToSrgbTransform(); @override Color transform(Color color, ColorSpace resultColorSpace) { + final double rLin = _srgbEOTFExtended(color.r); + final double gLin = _srgbEOTFExtended(color.g); + final double bLin = _srgbEOTFExtended(color.b); + + final double rOut = + _kP3ToSrgbLinear[0] * rLin + _kP3ToSrgbLinear[1] * gLin + _kP3ToSrgbLinear[2] * bLin; + final double gOut = + _kP3ToSrgbLinear[3] * rLin + _kP3ToSrgbLinear[4] * gLin + _kP3ToSrgbLinear[5] * bLin; + final double bOut = + _kP3ToSrgbLinear[6] * rLin + _kP3ToSrgbLinear[7] * gLin + _kP3ToSrgbLinear[8] * bLin; + 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], + red: _srgbOETFExtended(rOut), + green: _srgbOETFExtended(gOut), + blue: _srgbOETFExtended(bOut), + colorSpace: resultColorSpace, + ); + } +} + +// Converts sRGB (gamma-encoded) to Display P3 (gamma-encoded). +// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode). +class _SrgbToP3Transform implements _ColorTransform { + const _SrgbToP3Transform(); + + @override + Color transform(Color color, ColorSpace resultColorSpace) { + final double rLin = _srgbEOTFExtended(color.r); + final double gLin = _srgbEOTFExtended(color.g); + final double bLin = _srgbEOTFExtended(color.b); + + final double rOut = + _kSrgbToP3Linear[0] * rLin + _kSrgbToP3Linear[1] * gLin + _kSrgbToP3Linear[2] * bLin; + final double gOut = + _kSrgbToP3Linear[3] * rLin + _kSrgbToP3Linear[4] * gLin + _kSrgbToP3Linear[5] * bLin; + final double bOut = + _kSrgbToP3Linear[6] * rLin + _kSrgbToP3Linear[7] * gLin + _kSrgbToP3Linear[8] * bLin; + + return Color.from( + alpha: color.a, + red: _srgbOETFExtended(rOut), + green: _srgbOETFExtended(gOut), + blue: _srgbOETFExtended(bOut), colorSpace: resultColorSpace, ); } } _ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) { - const 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, - ]); return switch (source) { ColorSpace.sRGB => switch (destination) { ColorSpace.sRGB => const _IdentityColorTransform(), ColorSpace.extendedSRGB => const _IdentityColorTransform(), - ColorSpace.displayP3 => srgbToP3, + ColorSpace.displayP3 => const _SrgbToP3Transform(), }, ColorSpace.extendedSRGB => switch (destination) { ColorSpace.sRGB => const _ClampTransform(_IdentityColorTransform()), ColorSpace.extendedSRGB => const _IdentityColorTransform(), - ColorSpace.displayP3 => const _ClampTransform(srgbToP3), + ColorSpace.displayP3 => const _ClampTransform(_SrgbToP3Transform()), }, ColorSpace.displayP3 => switch (destination) { - ColorSpace.sRGB => const _ClampTransform(p3ToSrgb), - ColorSpace.extendedSRGB => p3ToSrgb, + ColorSpace.sRGB => const _ClampTransform(_P3ToSrgbTransform()), + ColorSpace.extendedSRGB => const _P3ToSrgbTransform(), ColorSpace.displayP3 => const _IdentityColorTransform(), }, }; diff --git a/packages/flutter/test/painting/colors_test.dart b/packages/flutter/test/painting/colors_test.dart index 478644fb0bc..0d8d392aa9d 100644 --- a/packages/flutter/test/painting/colors_test.dart +++ b/packages/flutter/test/painting/colors_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' show ColorSpace; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -540,6 +542,57 @@ void main() { } }); + // Verifies that P3-to-sRGB color conversion operates in linear light. + // Mid-range values expose the gamma nonlinearity: the correct pipeline + // is EOTF(decode) -> 3x3 matrix -> OETF(encode). + test('Display P3 to extended sRGB color conversion', () { + // P3 #1ECAD3 (30/255, 202/255, 211/255) + const p3Color = Color.from( + alpha: 1.0, + red: 30 / 255.0, + green: 202 / 255.0, + blue: 211 / 255.0, + colorSpace: ColorSpace.displayP3, + ); + + final Color esrgb = p3Color.withValues(colorSpace: ColorSpace.extendedSRGB); + expect(esrgb.colorSpace, ColorSpace.extendedSRGB); + + // Correct values from linearize -> matrix -> encode pipeline. + // The red channel must be strongly negative for a P3 teal in extended sRGB. + // Correct: ~-0.376. Wrong (gamma-encoded matrix): ~-0.120. + expect(esrgb.r, within(distance: 0.01, from: -0.376)); + expect(esrgb.g, within(distance: 0.01, from: 0.807)); + expect(esrgb.b, within(distance: 0.01, from: 0.837)); + }); + + test('Display P3 pure green to extended sRGB', () { + const p3Green = Color.from( + alpha: 1.0, + red: 0.0, + green: 1.0, + blue: 0.0, + colorSpace: ColorSpace.displayP3, + ); + final Color esrgb = p3Green.withValues(colorSpace: ColorSpace.extendedSRGB); + + // P3 pure green -> extended sRGB should have negative red and blue. + // Correct: R ~-0.512, G ~1.018, B ~-0.311 + expect(esrgb.r, within(distance: 0.01, from: -0.512)); + expect(esrgb.g, within(distance: 0.01, from: 1.018)); + expect(esrgb.b, within(distance: 0.01, from: -0.311)); + }); + + test('sRGB to Display P3 round-trip', () { + const srgbColor = Color.from(alpha: 1.0, red: 0.5, green: 0.3, blue: 0.8); + // sRGB -> P3 -> sRGB should round-trip. + final Color p3 = srgbColor.withValues(colorSpace: ColorSpace.displayP3); + final Color backToSrgb = p3.withValues(colorSpace: ColorSpace.extendedSRGB); + expect(backToSrgb.r, within(distance: 0.01, from: srgbColor.r)); + expect(backToSrgb.g, within(distance: 0.01, from: srgbColor.g)); + expect(backToSrgb.b, within(distance: 0.01, from: srgbColor.b)); + }); + test('ColorSwatch test', () { final int color = nonconst(0xFF027223); final greens1 = ColorSwatch(color, const {