LinearBurn: The Blend Mode That Wasn't There
Figma has a "Plus Darker" blend mode. It's LinearBurn from Photoshop—a simple darkening formula: max(0, base + blend - 1).
Skia has 24 blend modes. LinearBurn isn't one of them.
We needed to add it.
The Problem
Blend modes control how layers composite. Multiply, Screen, Overlay—standard stuff. LinearBurn is less common but straightforward:
result = max(0, dst + src - 1);
For each RGB channel, add the values, subtract 1, clamp to zero. Simple darkening effect.
Skia doesn't have it. The enum SkBlendMode goes from 0 to 24. LinearBurn would be 25.
First Attempt: RuntimeEffect Shader
Maybe implement it as a custom shader?
const char* linearBurnShader = R"(
uniform shader src;
uniform shader dst;
half4 main(float2 coord) {
half4 s = src.eval(coord);
half4 d = dst.eval(coord);
return half4(max(half3(0), s.rgb + d.rgb - 1), ...);
}
)";
auto effect = SkRuntimeEffect::MakeForShader(linearBurnShader);
This worked for simple cases. Then we tried it with semi-transparent layers.
The alpha blending was wrong. We were blending RGB channels correctly, but alpha needed special handling for premultiplied colors. Getting that formula right in a custom shader meant reimplementing all of Skia's alpha composition math.
Also, custom shaders don't get GPU pipeline optimizations. Skia's built-in blend modes have fast paths. Custom shaders are slower.
Second Attempt: Modify Skia Core
Add LinearBurn as a native blend mode. Modify the enum, implement the formula, expose it through CanvasKit.
Sounds simple. Took three days of tracking down all the places blend modes are referenced.
Step 1: Add to the enum (include/core/SkBlendMode.h):
enum class SkBlendMode {
// ...existing modes...
kLinearDodge, // 24
kLinearBurn, // 25 ← New
kLastMode = kLinearBurn
};
Step 2: Add the formula (src/opts/SkBlend_opts.h for CPU, SkSL files for GPU):
half4 blend_linear_burn(half4 src, half4 dst) {
// Premultiplied alpha handling
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
);
}
That formula accounts for premultiplied alpha. The simple max(0, src + dst - 1) only works for opaque colors. For transparency, you need to unpremultiply, blend, repremultiply, and handle partial coverage.
Step 3: Register the XferProcessor factory (src/gpu/ganesh/GrCustomXfermode.cpp):
static constexpr const CustomXPFactory gLinearBurn(SkBlendMode::kLinearBurn);
const GrXPFactory* GrCustomXfermode::Get(SkBlendMode mode) {
switch (mode) {
// ...existing cases...
case SkBlendMode::kLinearBurn: return &gLinearBurn;
}
}
This connects the enum to the GPU rendering pipeline.
Step 4: Add to blend function mapping (src/sksl/codegen/SkSLGLSLCodeGenerator.cpp):
const char* BlendFuncName(SkBlendMode mode) {
switch (mode) {
// ...
case SkBlendMode::kLinearBurn: return "blend_linear_burn";
}
}
Step 5: Expose to CanvasKit (modules/canvaskit/canvaskit_bindings.cpp):
enum_("BlendMode")
// ...
.value("LinearBurn", SkBlendMode::kLinearBurn);
Five files modified. Seven different locations. One blend mode.
The Build System Gotcha
After implementing everything, it didn't work. The blend mode was recognized, but rendered as solid black.
Debugging showed the shader function wasn't being called. Traced through the GPU pipeline: enum registered, factory connected, function name mapped—everything looked right.
The problem: Skia generates minified SkSL files at build time. Our changes were in source SkSL files (src/sksl/sksl_public.sksl), but the build used generated files (include/sksl/generated/*.h).
Had to run the SkSL generator to rebuild the minified headers. Only then did our blend function actually compile into the GPU shaders.
Build systems hide your changes in generated code.
The Alpha Blending Formula: Why Premultiplication Matters
The tricky part: premultiplied alpha. Most blend mode formulas you find online (Wikipedia, Photoshop docs) assume straight alpha—colors in the range [0, 1] with separate alpha.
GPU pipelines use premultiplied alpha—RGB values are already multiplied by alpha. This optimization enables faster compositing, but it means the naive blend formula doesn't work.
Here's a concrete example showing why:
Example: Semi-Transparent Red + Blue
Straight alpha representation:
Red: (1.0, 0.0, 0.0, alpha=0.5)
Blue: (0.0, 0.0, 1.0, alpha=0.5)
Premultiplied representation (what GPUs actually use):
Red: (0.5, 0.0, 0.0, 0.5) // RGB multiplied by alpha
Blue: (0.0, 0.0, 0.5, 0.5)
Naive formula (wrong for premultiplied):
result.rgb = max(0, src.rgb + dst.rgb - 1)
= max(0, (0.5,0,0.5) + (0,0,0.5) - 1)
= max(0, (-0.5, 0, 0))
= (0, 0, 0) // BLACK - WRONG!
The issue: we're blending values that are already scaled by alpha (0.5 instead of 1.0), so the subtraction of 1.0 pushes everything negative.
Correct formula (unpremultiply, blend, repremultiply):
// Step 1: Unpremultiply to get original colors
srcRGB = src.rgb / src.a = (0.5,0,0) / 0.5 = (1,0,0) // Pure red
dstRGB = dst.rgb / dst.a = (0,0,0.5) / 0.5 = (0,0,1) // Pure blue
// Step 2: Blend in linear space
blendRGB = max(0, srcRGB + dstRGB - 1)
= max(0, (1,0,0) + (0,0,1) - 1)
= max(0, (0,0,0))
= (0,0,0) // Black, which IS correct for opaque LinearBurn(red+blue)
// Step 3: Repremultiply for the overlapping region
result.rgb = blendRGB * src.a * dst.a // The blended center
+ (1-src.a) * dst.rgb // Area only dst is visible
+ (1-dst.a) * src.rgb; // Area only src is visible
result.rgb = (0,0,0) * 0.5 * 0.5 // Black center at 25% opacity
+ (1-0.5) * (0,0,0.5) // Blue at 50% showing through
+ (1-0.5) * (0.5,0,0) // Red at 50% showing through
= (0,0,0) + (0,0,0.25) + (0.25,0,0)
= (0.25, 0, 0.25) // Dark purple - CORRECT!
result.a = src.a + (1-src.a) * dst.a
= 0.5 + 0.5 * 0.5
= 0.75 // Standard alpha compositing
Final result: (0.25, 0, 0.25, 0.75) - a dark purple where the semi-transparent layers overlap.
This is what the complex formula in the shader does:
half4(
((1-src.a)*dst.rgb + (1-dst.a)*src.rgb) + // Non-overlapping regions
max(half3(0), (src.rgb/src.a) + (dst.rgb/dst.a) - half3(1)) * src.a * dst.a, // Blended overlap
src.a + (1-src.a)*dst.a // Standard alpha composition
)
Why This Matters
Without correct premultiplied alpha handling:
- Semi-transparent layers render as solid black (formula underflows)
- Gradients have harsh edges instead of smooth fadeouts
- Transparency blending produces visible banding artifacts
We tested with overlapping semi-transparent gradients. The darkening effect was correct, alpha edges were smooth, no banding artifacts. The formula works because it handles the unpremultiply-blend-repremultiply cycle correctly while accounting for partial coverage from both layers.
Results
LinearBurn now works as a native blend mode in CanvasKit. Figma's "Plus Darker" renders correctly.
The implementation is ~30 lines of actual code spread across 5 files and 7 specific integration points:
- Enum value (1 line)
- Name mapping (1 line)
- CPU pipeline (5 lines)
- GPU shader (10 lines)
- XferProcessor factory (2 lines)
- Function name mapping (1 line)
- CanvasKit binding (1 line)
Plus rebuilding generated SkSL headers.
Adding a blend mode isn't hard. Finding all the places where blend modes are registered and integrated is hard.
Sometimes you need to modify the library. Just be prepared to chase the integration points through multiple subsystems.
Read next: Diamond Gradients: The Forgotten Primitive - Implementing box-shaped gradients with distance fields.