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:
- Base: red (1, 0, 0, 0.5) → premultiplied (0.5, 0, 0, 0.5)
- Blend: blue (0, 0, 1, 0.5) → premultiplied (0, 0, 0.5, 0.5)
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:
- 1 public header (enum declaration)
- 3 core files (name mapping, raster pipeline)
- 4 GPU backend files (shader, fragment processor, xfer factory, OpenGL mapping)
- 1 CanvasKit binding file
- ~15 test files (static assertions)
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.