Figma's Blend Modes: Not Quite Photoshop

Set blend mode to "Plus Darker" in Figma. The layer darkens as expected.

Port the design to CanvasKit. Use Skia's blend modes. Nothing matches.

Try kDarken. Wrong. Try kMultiply. Also wrong. Try kColorBurn. Still wrong.

Figma's "Plus Darker" doesn't exist in Skia's blend mode enum.

The Problem

Skia implements the W3C Compositing and Blending standard blend modes:

enum class SkBlendMode {
  kClear,    kSrc,       kDst,
  kSrcOver,  kDstOver,   kSrcIn,
  kMultiply, kScreen,    kOverlay,
  kDarken,   kLighten,   kColorDodge,
  kColorBurn, // ... 29 total modes
};

Figma has modes not in this list. "Plus Darker" is one of them.

The W3C spec doesn't define it. Photoshop has "Linear Burn", which sounds similar but uses different math. Figma's documentation doesn't specify the formula.

We need to reverse-engineer the blend formula from visual output and then add it to Skia.

First Attempt: Approximation with Existing Modes

Try to fake it by combining multiple blend modes:

// Attempt 1: Multiply + Darken
canvas.saveLayer(nullptr, nullptr);
canvas.drawImage(baseImage, 0, 0);
canvas.saveLayer(nullptr, &multiplyPaint);
canvas.drawImage(blendImage, 0, 0);
canvas.restore();
canvas.saveLayer(nullptr, &darkenPaint);
canvas.drawImage(blendImage, 0, 0);
canvas.restore();

Result: Not even close. The stacked operations produce artifacts and the math doesn't match.

Attempt 2: Custom shader using SkRuntimeEffect:

sk_sp effect = SkRuntimeEffect::MakeForShader(SkString(R"(
  uniform shader base;
  uniform shader blend;

  half4 main(float2 coord) {
    half4 b = base.eval(coord);
    half4 s = blend.eval(coord);
    return half4(max(half3(0), b.rgb + s.rgb - half3(1)), ...);
  }
)")).effect;

This works! But it's slow—every layer with this blend mode runs a custom shader. Standard blend modes use GPU-accelerated hardware blending. Custom shaders don't.

Performance: 15fps for a complex scene with multiple Plus Darker layers (vs 60fps with native blend modes).

The Solution: Add LinearBurn to Skia Core

Skia is open source. We can add the blend mode ourselves.

The formula for Linear Burn (which matches Figma's Plus Darker):

Result = max(0, Base + Blend - 1)

For premultiplied alpha (Skia's internal representation):

Result.rgb = (1 - src.a) * dst.rgb + (1 - dst.a) * src.rgb
             + max(0, src.rgb/src.a + dst.rgb/dst.a - 1) * src.a * dst.a

Result.a = src.a + (1 - src.a) * dst.a

The implementation requires changes across 7 subsystems:

Step 1: Add to Blend Mode Enum

File: include/core/SkBlendMode.h

enum class SkBlendMode {
  // ... existing modes ...
  kMultiply,      //!< r = s*(1-da) + d*(1-sa) + s*d
  kLinearBurn,    //!< r = max(0, s + d - 1)  // NEW

  kHue,           // ... non-separable modes ...
};

Also update:

kLastSeparableMode = kLinearBurn,  // was kMultiply

Step 2: Add Name Mapping

File: src/core/SkBlendMode.cpp

const char* SkBlendMode_Name(SkBlendMode mode) {
  switch (mode) {
    case SkBlendMode::kMultiply:   return "Multiply";
    case SkBlendMode::kLinearBurn: return "LinearBurn";  // NEW
    case SkBlendMode::kHue:        return "Hue";
    // ...
  }
}

Step 3: GPU Shader Implementation

File: src/sksl/sksl_gpu.sksl

$pure half4 blend_linear_burn(half4 src, half4 dst) {
  // Premultiplied alpha formula
  return half4(
    ((1 - src.a) * dst.rgb + (1 - dst.a) * src.rgb) +
    max(half3(0), (src.rgb / src.a) + (dst.rgb / dst.a) - half3(1)) * src.a * dst.a,
    src.a + (1 - src.a) * dst.a
  );
}

The shader must handle premultiplied alpha correctly—the naive formula max(0, src + dst - 1) produces wrong colors when layers are semi-transparent.

Step 4: GPU Backend Integration

File: src/gpu/ganesh/effects/GrBlendFragmentProcessor.cpp

static const char* BlendFuncName(SkBlendMode mode) {
  switch (mode) {
    case SkBlendMode::kMultiply:   return "blend_multiply";
    case SkBlendMode::kLinearBurn: return "blend_linear_burn";  // NEW
    case SkBlendMode::kHue:        return "blend_hsl_hue";
    // ...
  }
}

Step 5: XferProcessor Factory Registration

File: src/gpu/ganesh/GrCustomXfermode.cpp

This is the critical piece that makes the GPU use the custom shader:

// Factory declarations
static constexpr const CustomXPFactory gMultiply(SkBlendMode::kMultiply);
static constexpr const CustomXPFactory gLinearBurn(SkBlendMode::kLinearBurn);  // NEW
static constexpr const CustomXPFactory gHue(SkBlendMode::kHue);

const GrXPFactory* GrCustomXfermode::Get(SkBlendMode mode) {
  switch (mode) {
    case SkBlendMode::kMultiply:   return &gMultiply;
    case SkBlendMode::kLinearBurn: return &gLinearBurn;  // NEW
    case SkBlendMode::kHue:        return &gHue;
    // ...
  }
}

Without this, the GPU falls back to software rendering.

Step 6: CanvasKit JavaScript Binding

File: modules/canvaskit/canvaskit_bindings.cpp

enum_("BlendMode")
  .value("Multiply",   SkBlendMode::kMultiply)
  .value("LinearBurn", SkBlendMode::kLinearBurn)  // NEW
  .value("Hue",        SkBlendMode::kHue);

Now JavaScript can use it:

const paint = new CanvasKit.Paint();
paint.setBlendMode(CanvasKit.BlendMode.LinearBurn);
canvas.drawRect(rect, paint);

Step 7: Update Static Assertions

Multiple files have static assertions verifying enum values. All indices after the new mode shift by +1:

File: src/gpu/ganesh/gl/GrGLGpu.cpp

static_assert(13 == (int)skgpu::BlendEquation::kMultiply);
static_assert(14 == (int)skgpu::BlendEquation::kLinearBurn);  // NEW
static_assert(15 == (int)skgpu::BlendEquation::kHSLHue);      // was 14

The Premultiplied Alpha Problem

The simple Linear Burn formula max(0, src + dst - 1) doesn't work for semi-transparent pixels.

Example:

Naive formula: max(0, (0.5, 0, 0.5) + (0, 0, 0.5) - 1) = max(0, (−0.5, 0, 0)) = (0, 0, 0)

Result: black. Wrong.

Correct formula: unpremultiply, blend, repremultiply:

// Unpremultiply
vec3 baseColor = dst.rgb / dst.a;
vec3 blendColor = src.rgb / src.a;

// Blend
vec3 result = max(vec3(0), baseColor + blendColor - vec3(1));

// Repremultiply and composite
vec3 final = ((1 - src.a) * dst.rgb + (1 - dst.a) * src.rgb) +
             result * src.a * dst.a;

This produces the correct darkening effect while preserving transparency.

Results

Linear Burn blend mode now works in CanvasKit:

Before: Custom shader fallback, 15fps After: Native GPU blend mode, 60fps

The implementation touched:

Total code changes: ~50 lines spread across the rendering pipeline.

The lesson: sometimes the "right" blend mode doesn't exist. When building production graphics software, you may need to modify your graphics library, not just use it.

Standards aren't universal. Figma, Photoshop, and W3C all define blend modes slightly differently. Verify the math—don't assume.


Series complete. Thanks for reading! For more on graphics rendering and low-level implementation details, check out the full article index.