Community Issues Reference Playground

Skia Color Management

What we mean by color management

All the color spaces Skia works with describe themselves by how to transform colors from that color space to a common "connection" color space called XYZ D50. And we can infer from that same description how to transform from that XYZ D50 space back to the original color space. XYZ D50 is a color space represented in three dimensions like RGB, but the XYZ parts are not RGB-like at all, rather a linear remix of those channels. Y is closest to what you'd think of as brightness, but X and Z are a little more abstract. It's kind of like YUV if you're familiar with that. The "D50" part refers to the whitepoint of this space, around 5000 Kelvin.

All color managed drawing is divided into six parts, three steps connecting the source colors to that XYZ D50 space, then three symmetric steps connecting back from XYZ D50 to the destination color space. Some of these steps can annihilate with each other into no-ops, sometimes all the way to the entire process amounting to a no-op when the source space and destination space are the same. Here are the steps:

Color management steps

  1. unpremultiply if the source color is premultiplied -- alpha is not involved in color management, and we need to divide it out if it's multiplied in
  2. linearize the source color using the source color space's transfer function
  3. convert those unpremultiplied, linear source colors to XYZ D50 gamut by multiplying by a 3x3 matrix
  4. convert those XYZ D50 colors to the destination gamut by multiplying by a 3x3 matrix
  5. encode that color using the inverse of the destination color space's transfer function
  6. premultiply by alpha if the destination is premultiplied

If you poke around in our code the clearest place to see this logic is in a type called SkColorSpaceXformSteps. You'll see it as 5 steps there: we always merge the innermost two operations into a single 3x3 matrix multiply.

Optimizations

Whenever we're about to do some drawing we look at which of those steps we really need to do. Any step that's a fundamental no-op we skip:

We can reason from those basic skips into some more advanced optimizations:

All this comes together to an impressive "nothing to do" most of the time. If you're drawing opaque colors in a given color space to a destination tagged with that same color space, we'll notice we can skip all six steps. Sometimes fewer steps are needed, sometimes more. In general if you need to do a gamut conversion, you should generally expect all the middle steps to be active. Steps 2 and 5 are by far the most expensive to compute.

nullptr SkColorSpace defaults

Now how do nullptr SkColorSpace defaults work into all of this? We preface all that logic I've just mentioned above with this little snippet:

 if (srcCS == nullptr) { srcCS = sRGB; }
 if (dstCS == nullptr) { dstCS = srcCS; }

(Order matters there.) The gist is, we assume any untagged sources are sRGB. And if you leave your surface untagged, we act as if your destination fluidly matches whatever source you're trying to draw into it, which skips at least steps 2-5 as listed above, maintaining an unmanaged color mode of drawing compatible with how retro Skia used to work before we introduce color management. It's not very principled, but it's handy in practice to keep around.