Image Filters: Exposure, Contrast, and the Missing Matrix

Skia has SkColorMatrix for image adjustments. A 5×4 matrix that transforms RGBA values.

Figma has exposure, temperature, tint, highlights, shadows, saturation, and contrast controls.

ColorMatrix can do some of these. Not all of them.

The Problem

A color matrix is a linear transformation:

R' = m[0]*R + m[1]*G + m[2]*B + m[3]*A + m[4]
G' = m[5]*R + m[6]*G + m[7]*B + m[8]*A + m[9]
B' = m[10]*R + m[11]*G + m[12]*B + m[13]*A + m[14]
A' = m[15]*R + m[16]*G + m[17]*B + m[18]*A + m[19]

Saturation: Linear transform ✅

Contrast: Linear transform ✅

Exposure: Requires pow(2, value) scaling ❌ (non-linear)

Temperature/Tint: Color space conversion ❌ (complex)

Highlights/Shadows: Conditional on luminance ❌ (non-linear)

We needed more than matrix math.

First Attempt: Chain ColorMatrices

Maybe combine multiple matrices?

auto saturation = SkColorFilters::Matrix(saturationMatrix);
auto contrast = SkColorFilters::Matrix(contrastMatrix);
auto combined = SkColorFilters::Compose(saturation, contrast);

This worked for linear adjustments. But for exposure:

// Exposure = scaling by 2^value, not a linear transform
// Can't express this as a matrix

ColorMatrix can only do linear combinations of input values. Exposure is exponential. Can't fake it.

The Solution: Custom RuntimeEffect Shader

SkSL shaders can do arbitrary math:

uniform half exposure;
uniform half contrast;
uniform half saturation;
uniform half temperature;
uniform half tint;
uniform half highlights;
uniform half shadows;

half4 main(float2 coord, half4 color) {
  // 1. Exposure (exponential)
  color.rgb *= pow(2.0, exposure);

  // 2. Contrast (around midpoint)
  color.rgb = ((color.rgb - 0.5) * contrast) + 0.5;

  // 3. Saturation
  half luma = dot(color.rgb, half3(0.299, 0.587, 0.114));
  color.rgb = mix(half3(luma), color.rgb, saturation);

  // 4. Temperature/Tint (color space shift)
  color.rgb = applyTemperature(color.rgb, temperature, tint);

  // 5. Highlights/Shadows (luminance-conditional)
  color.rgb = adjustTonemapping(color.rgb, highlights, shadows);

  return clamp(color, 0.0, 1.0);
}

Each adjustment is a separate calculation, not a matrix multiplication.

The Uniform Efficiency

Uniforms are per-draw constants—they don't change for each pixel. Changing exposure from 0.5 to 0.6 doesn't require recompiling the shader, just updating one float value.

This makes runtime adjustments efficient:

function setExposure(value) {
  uniforms.exposure = value;  // Just update the uniform
  // Shader recompiles automatically if needed
  canvas.redraw();
}

No need to rebuild color matrices or composite filter chains. Just update the uniform and render.

The Transform Uniformity Issue

Initially, we were treating the image transform as per-pixel data, recalculating it for every fragment.

That's wasteful. The transform matrix is constant per draw—it doesn't change across the image.

Made it a uniform:

uniform float4x4 imageTransform;

half4 main(float2 coord) {
  float2 transformedCoord = (imageTransform * float4(coord, 0, 1)).xy;
  half4 color = image.eval(transformedCoord);
  // ... apply filters ...
}

Now the matrix is sent once per draw, not recalculated for millions of pixels.

Results

Custom image filter shader with 7 adjustable parameters:

All controlled via uniforms, all applied in one shader pass.

The shader is ~80 lines of SkSL. Trying to express this as color matrices would require conditional composition of dozens of matrices, recalculated for every parameter change.

Sometimes you need custom shaders. Matrices are elegant, but limited to linear transforms.


Read next: The Transform Matrix Puzzle: Figma Space to Skia Space - Transform order and sampling modes.