Skip to content

Fix P3-to-sRGB color conversion to operate in linear light#181720

Merged
gaaclarke merged 6 commits intoflutter:masterfrom
westito:fix-p3-to-srgb-color-conversion
Jan 30, 2026
Merged

Fix P3-to-sRGB color conversion to operate in linear light#181720
gaaclarke merged 6 commits intoflutter:masterfrom
westito:fix-p3-to-srgb-color-conversion

Conversation

@westito
Copy link
Contributor

@westito westito commented Jan 30, 2026

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:

#181717

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • 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!

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. engine flutter/engine related. See also e: labels. labels Jan 30, 2026
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request correctly fixes a mathematical error in the P3-to-sRGB color conversion by ensuring the transformation operates in linear light, as it should. The changes replace the direct affine matrix multiplication on gamma-encoded values with a proper 3-step pipeline: gamma decoding, applying a 3x3 matrix in linear space, and then gamma encoding. This fix is consistently applied in both C++ (dl_color.cc) and Dart (painting.dart), and the reverse sRGB-to-P3 conversion is also corrected in the Dart implementation. The addition of new unit tests for both C++ and Dart is excellent, as they verify the new conversion logic, particularly for mid-range colors where the previous error was most significant. My review includes suggestions to replace magic numbers with named constants to improve code readability and maintainability.

@github-actions github-actions bot added the platform-web Web applications specifically label Jan 30, 2026
@jtmcdole
Copy link
Member

Re-running flutter-dashboard / Mac_arm64 build_tests_4_4 since it looks like a flake

Thanks for the PR!

Copy link
Member

@gaaclarke gaaclarke left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome, thanks @westito. I double checked all the tests with Apple's color utility and they are good. Apple gives slightly different values but that might be captured in the epsilon of the assertions. The test coverage is great, no need to add tests.

In fact there is nothing else you need to do. I've made a few notes about minor cleanup and deduplication which I'll look into. I'll commit them here or implement in a followup.

// gamma encode.
DlColor p3ToExtendedSrgb(const DlColor& color) {
// Linearize P3 values (P3 uses same transfer function as sRGB).
double r_lin = srgbEOTFExtended(static_cast<double>(color.getRedF()));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no action required: do we want to just keep everything in floats?

double b_lin = srgbEOTFExtended(static_cast<double>(color.getBlueF()));

// Apply 3x3 P3-to-sRGB matrix in linear space.
double r_srgb_lin = kP3ToSrgbLinear[0] * r_lin + kP3ToSrgbLinear[1] * g_lin +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no action required: we have matrix multiplication available in our api

matrix[11], //
color_space);

// sRGB standard constants for transfer functions.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no action required: comments should be in doxygen format

// 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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no action required: apple's color tool gives (-0.3778, 0.8084, 0.8358)


// Converts Display P3 (gamma-encoded) to extended sRGB (gamma-encoded).
// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode).
class _P3ToSrgbTransform implements _ColorTransform {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no action required: We should call into c++ for this instead of implementing it twice (now that it is a bit more involved)


class _MatrixColorTransform implements _ColorTransform {
const _MatrixColorTransform(this.values);
// sRGB standard constants for transfer functions.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no action required: let's find a way to share this with web.

Copy link
Member

@gaaclarke gaaclarke left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked briefly into deduplicating the logic and it is a bit involved. If dart:ui calls into c++ then the code can't be shared with web. There is also no existing good way to share logic between the web and standard dart:ui so I don't want to try to do it on top of this PR. Everything else is minor nits and LGTM. Thanks again.

@westito
Copy link
Contributor Author

westito commented Jan 30, 2026

Thanks! Deduplication may be possible: Color conversion could be done at Dart level when Color is constructed. The problem is, current constructor is constant, any change would cause API break. That's why I chose to implement it at the native level.

Copy link
Member

@jtmcdole jtmcdole left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTW (looks good to wikipedia specification)

@gaaclarke gaaclarke added this pull request to the merge queue Jan 30, 2026
Merged via the queue into flutter:master with commit f8aaee5 Jan 30, 2026
179 of 180 checks passed
@westito
Copy link
Contributor Author

westito commented Jan 31, 2026

gaaclarke: This is awesome, thanks westito. I double checked all the tests with Apple's color utility and they are good. Apple gives slightly different values but that might be captured in the epsilon of the assertions. The test coverage is great, no need to add tests.

In fact there is nothing else you need to do. I've made a few notes about minor cleanup and deduplication which I'll look into. I'll commit them here or implement in a followup.

I found the issue for the slight difference: iOS engine uses MTLPixelFormatBGRA10_XR pixel format that causes precision lost. Should use MTLPixelFormatRGBA16Float for full wide gamut support. It would be acceptable to use 16bit pixel format when developer enables wide gamut? I think it wouldn't have a high performance impact, considering that all native iOS apps use 16bit and supports full-precision wide gamut. If user need performance or experience performance issues, should disable that. I think wide gamut should fully supported. 10bit vs 16bit have no memory usage difference, only affect GPU processing speed. I'm working on macOS wide gamut support where I found this precision lost, then I would include iOS fix also.

@gaaclarke
Copy link
Member

I found the issue for the slight difference: iOS engine uses MTLPixelFormatBGRA10_XR pixel format that causes precision lost. Should use MTLPixelFormatRGBA16Float for full wide gamut support. It would be acceptable to use 16bit pixel format when developer enables wide gamut? I think it wouldn't have a high performance impact, considering that all native iOS apps use 16bit and supports full-precision wide gamut. If user need performance or experience performance issues, should disable that. I think wide gamut should fully supported. 10bit vs 16bit have no memory usage difference, only affect GPU processing speed. I'm working on macOS wide gamut support where I found this precision lost, then I would include iOS fix also.

When the feature was originally implemented we looked into this and there was a noticeable difference in performance from what I recall. The team was reticent to give up any performance for wide gamut colors. We can reevaluate that in the future as things change. There isn't a lot of bandwidth to allocate to that now, especially considering wide gamut for android would be a higher priority in my mind.

auto-submit bot pushed a commit to flutter/packages that referenced this pull request Feb 2, 2026
flutter/flutter@1d9d6a9...9eafba4

2026-02-01 [email protected] Roll Skia from a8ceddc2bca0 to 4ee9eb659ae0 (1 revision) (flutter/flutter#181773)
2026-02-01 [email protected] Roll Skia from a5a535bc21a3 to a8ceddc2bca0 (1 revision) (flutter/flutter#181772)
2026-02-01 [email protected] Roll Dart SDK from cde322c518b9 to 84abc8d58ec8 (1 revision) (flutter/flutter#181767)
2026-02-01 [email protected] Roll Dart SDK from c72b3f527a07 to cde322c518b9 (1 revision) (flutter/flutter#181763)
2026-01-31 [email protected] Roll Dart SDK from 43de79f08887 to c72b3f527a07 (1 revision) (flutter/flutter#181762)
2026-01-31 [email protected] Roll Fuchsia Linux SDK from 27OsrsrKcrr2pOaqY... to nI52U4LJMrBv8G1M9... (flutter/flutter#181760)
2026-01-31 [email protected] Roll Dart SDK from a0aac59705e5 to 43de79f08887 (1 revision) (flutter/flutter#181758)
2026-01-31 [email protected] Roll Skia from b704afbcd8e7 to a5a535bc21a3 (1 revision) (flutter/flutter#181757)
2026-01-31 [email protected] Roll Dart SDK from d6f77f9098e1 to a0aac59705e5 (1 revision) (flutter/flutter#181756)
2026-01-31 [email protected] Roll Skia from 6f9511ce11ba to b704afbcd8e7 (4 revisions) (flutter/flutter#181753)
2026-01-31 [email protected] Roll Dart SDK from 0cf997832948 to d6f77f9098e1 (2 revisions) (flutter/flutter#181749)
2026-01-31 [email protected] Roll Skia from 33c00c0f3b8b to 6f9511ce11ba (1 revision) (flutter/flutter#181744)
2026-01-31 [email protected] Roll Dart SDK from be3ffc51d407 to 0cf997832948 (1 revision) (flutter/flutter#181740)
2026-01-30 [email protected] Roll Skia from b62d43b59dd6 to 33c00c0f3b8b (1 revision) (flutter/flutter#181735)
2026-01-30 [email protected] [Impeller] Fix type conversion warnings seen on Windows when Impeller GL API wrappers log arguments with function types (flutter/flutter#181734)
2026-01-30 [email protected] Update New Android API Docs (flutter/flutter#180604)
2026-01-30 [email protected] Roll Fuchsia Linux SDK from isy1ARvK-3bsvtfc-... to 27OsrsrKcrr2pOaqY... (flutter/flutter#181733)
2026-01-30 [email protected] Fix P3-to-sRGB color conversion to operate in linear light (flutter/flutter#181720)
2026-01-30 [email protected] chore: deflake Linux_mokey flutter_engine_group_performance (flutter/flutter#181624)
2026-01-30 [email protected] Roll Dart SDK from 2703fd9733ce to be3ffc51d407 (1 revision) (flutter/flutter#181729)
2026-01-30 [email protected] Ensure content-sizing resize is run on UI thread (flutter/flutter#181686)
2026-01-30 [email protected] [ Tool ] Cleanup `ResidentCompiler` initialization logic (flutter/flutter#181421)
2026-01-30 [email protected] [native assets] Split debug info into `.dsym` files (flutter/flutter#181533)
2026-01-30 [email protected] Roll Skia from 4745eb2fe837 to b62d43b59dd6 (1 revision) (flutter/flutter#181727)
2026-01-30 [email protected] add ccache support for custom toolchain (flutter/flutter#180737)
2026-01-30 [email protected] update GLFW to latest and use EGL context creation on linux (flutter/flutter#181259)
2026-01-30 [email protected] add stacktrace support when requested for host builds (flutter/flutter#181264)

If this roll has caused a breakage, revert this CL and stop the roller
using the controls here:
https://autoroll.skia.org/r/flutter-packages
Please CC [email protected],[email protected] on the revert to ensure that a human
is aware of the problem.

To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose

To report a problem with the AutoRoller itself, please file a bug:
https://issues.skia.org/issues/new?component=1389291&template=1850622

Documentation for the AutoRoller is here:
https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
LongCatIsLooong pushed a commit to LongCatIsLooong/flutter that referenced this pull request Feb 6, 2026
…81720)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

### 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:
flutter#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 <[email protected]>
flutter-zl pushed a commit to flutter-zl/flutter that referenced this pull request Feb 10, 2026
…81720)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

### 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:
flutter#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 <[email protected]>
rickhohler pushed a commit to rickhohler/flutter that referenced this pull request Feb 19, 2026
…81720)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

### 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:
flutter#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 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

engine flutter/engine related. See also e: labels. framework flutter/packages/flutter repository. See also f: labels. platform-web Web applications specifically

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants