Skip to content

Display P3 wide gamut color conversion is incorrect #181717

Description

@westito

Steps to reproduce

Reproduce: Compare a solid P3 PNG image with equivalent color painted with Color.from(..., colorSpace: ColorSpace.displayP3)

Problem: The P3-to-sRGB conversion applies a 3x4 affine matrix directly to gamma-encoded color values. This is mathematically wrong because the color space conversion matrix must operate in linear light. The result is colors that are barely distinguishable from sRGB instead of showing the full P3 gamut difference.

Fix: Replace the single matrix multiply with the correct 3-step pipeline: decode gamma to linear (sRGB EOTF), apply the 3x3 P3-to-sRGB matrix in linear space, encode back to gamma (sRGB OETF). Both P3 and sRGB use the same sRGB transfer function. Extended range (negative values from out-of-gamut colors) is handled by mirroring the transfer function.

Performance impact of the fix: negligible. The conversion runs once per paint setup when a Dart Color with P3 colorspace is read into the engine (ReadColor in paint.cc), not per frame or per pixel. It adds six pow() calls (three for linearize, three for gamma encode) per P3 color, which is on the order of nanoseconds compared to the milliseconds spent rendering a frame.

Expected results

You see the same colors (screenshots attached). macOS has no P3 support in Flutter right now, but I also implement that to see differences more clearly. With camera it is barely visible. Can't take proper screenshot on device because it is converted to sRGB.

Actual results

Painted color has different shades

Code sample

Code sample
Column(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [
    Expanded(child: Image.asset('assets/p3.png', fit: BoxFit.cover)),
    Expanded(
      child: ColoredBox(
        color: const Color.from(
          alpha: 1,
          red: 30 / 255.0,
          green: 202 / 255.0,
          blue: 211 / 255.0,
          colorSpace: ColorSpace.displayP3,
        ),
      ),
    ),
  ],
),

Here is the fix (engine/src/flutter/display_list/dl_color.cc)

// 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_color.h"

#include <algorithm>
#include <cmath>

namespace flutter {

namespace {

// sRGB electro-optical transfer function (gamma decode, gamma ~2.2 to linear).
double srgbEOTF(double v) {
  if (v <= 0.04045) {
    return v / 12.92;
  }
  return std::pow((v + 0.055) / 1.055, 2.4);
}

// sRGB opto-electronic transfer function (linear to gamma encode).
double srgbOETF(double v) {
  if (v <= 0.0031308) {
    return v * 12.92;
  }
  return 1.055 * std::pow(v, 1.0 / 2.4) - 0.055;
}

// 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<double>(color.getRedF()));
  double g_lin = srgbEOTFExtended(static_cast<double>(color.getGreenF()));
  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 +
                      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<float>(r_out),
                 static_cast<float>(g_out),
                 static_cast<float>(b_out),
                 DlColorSpace::kExtendedSRGB);
}

}  // namespace

DlColor DlColor::withColorSpace(DlColorSpace color_space) const {
  switch (color_space_) {
    case DlColorSpace::kSRGB:
      switch (color_space) {
        case DlColorSpace::kSRGB:
          return *this;
        case DlColorSpace::kExtendedSRGB:
          return DlColor(alpha_, red_, green_, blue_,
                         DlColorSpace::kExtendedSRGB);
        case DlColorSpace::kDisplayP3:
          FML_CHECK(false) << "not implemented";
          return *this;
      }
    case DlColorSpace::kExtendedSRGB:
      switch (color_space) {
        case DlColorSpace::kSRGB:
          return DlColor(alpha_, std::clamp(red_, 0.0f, 1.0f),
                         std::clamp(green_, 0.0f, 1.0f),
                         std::clamp(blue_, 0.0f, 1.0f), DlColorSpace::kSRGB);
        case DlColorSpace::kExtendedSRGB:
          return *this;
        case DlColorSpace::kDisplayP3:
          FML_CHECK(false) << "not implemented";
          return *this;
      }
    case DlColorSpace::kDisplayP3:
      switch (color_space) {
        case DlColorSpace::kSRGB:
          return p3ToExtendedSrgb(*this)
              .withColorSpace(DlColorSpace::kSRGB);
        case DlColorSpace::kExtendedSRGB:
          return p3ToExtendedSrgb(*this);
        case DlColorSpace::kDisplayP3:
          return *this;
      }
  }
}

}  // namespace flutter

Screenshots or Video

Screenshots / Video demonstration

Incorrect colors:

Image Image

Correct colors:

Image Image

Logs

Logs

Flutter Doctor output

Doctor output I wrote the fix on main branch ```console Doctor summary (to see all details, run flutter doctor -v): [!] Flutter (Channel main, 3.41.0-1.0.pre-358, on macOS 26.2 25C56 darwin-arm64, locale hu-HU) ! Warning: `flutter` on your path resolves to /Users/westito/flutter/bin/flutter, which is not inside your current Flutter SDK checkout at /Users/westito/Projects/flutter. Consider adding /Users/westito/Projects/flutter/bin to the front of your path. ! Warning: `dart` on your path resolves to /Users/westito/flutter/bin/dart, which is not inside your current Flutter SDK checkout at /Users/westito/Projects/flutter. Consider adding /Users/westito/Projects/flutter/bin to the front of your path. [✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [✓] Xcode - develop for iOS and macOS (Xcode 26.2) [✓] Chrome - develop for the web [✓] Connected device (3 available) ! Error: Browsing on the local area network for Marcell Apple Watcha. Ensure the device is unlocked and discoverable via Bluetooth. (code -27) [✓] Network resources

! Doctor found issues in 1 category.


</details>

Metadata

Metadata

Assignees

No one assigned

    Labels

    engineflutter/engine related. See also e: labels.frameworkflutter/packages/flutter repository. See also f: labels.team-engineOwned by Engine team

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions