Masking: Clips, Effects, and Coordinate Chaos
Apply a blur effect to a masked layer. The blur extends beyond the mask boundary. Wrong.
The mask should clip first, then blur, then composite with the correct blend mode.
Getting the order right took three attempts.
The Problem
Figma's rendering order for masked layers with effects:
- Draw content to offscreen buffer
- Apply mask (clip to mask shape)
- Apply effects (blur, shadows)
- Composite with blend mode
Skia's natural rendering flow doesn't match this. Clips apply immediately, effects apply before clipping, blend modes composite at save/restore boundaries.
We needed explicit control over the sequencing.
First Attempt: Nested saveLayer
Use multiple saveLayer() calls to create the sequence:
canvas.saveLayer(nullptr, nullptr); // For blend mode
canvas.saveLayer(nullptr, blurPaint); // For blur effect
canvas.clipPath(maskPath); // Apply mask
canvas.drawContent(...); // Draw content
canvas.restore(); // Apply blur
canvas.restore(); // Apply blend mode
The clip applied to the content, but the blur also got clipped—it couldn't extend beyond the mask boundary because the clip was active when the blur layer restored.
Clips persist through saveLayer boundaries. That's the problem.
Second Attempt: Separate Render Passes
Render each stage to its own offscreen buffer, then composite them:
// Pass 1: Render content to buffer A
SkBitmap contentBuffer;
drawContentTo(contentBuffer);
// Pass 2: Apply mask to buffer A → buffer B
SkBitmap maskedBuffer;
applyMask(contentBuffer, maskPath, &maskedBuffer);
// Pass 3: Apply blur to buffer B → buffer C
SkBitmap blurredBuffer;
applyBlur(maskedBuffer, blurRadius, &blurredBuffer);
// Pass 4: Composite buffer C with blend mode
canvas.drawImage(blurredBuffer, 0, 0, blendPaint);
This worked. The blur extended beyond the original mask boundary as expected.
But: four separate buffers, four separate render passes, lots of memory allocation and copying. Performance tanked.
The Solution: Controlled Layer Sequencing
Use saveLayer() strategically with explicit restoration points:
// saveLayer for effects (blur)
SkPaint effectPaint;
effectPaint.setImageFilter(SkImageFilters::Blur(radius, radius, nullptr));
canvas.saveLayer(bounds, &effectPaint);
// saveLayer for mask
canvas.saveLayer(bounds, nullptr);
canvas.drawContent(...);
canvas.restore(); // Content is now in a layer
// Apply mask using SkBlendMode::kDstIn
SkPaint maskPaint;
maskPaint.setBlendMode(SkBlendMode::kDstIn);
canvas.drawPath(maskPath, maskPaint);
canvas.restore(); // Effect layer restores, blur applies to masked content
// Now composite with blend mode
SkPaint blendPaint;
blendPaint.setBlendMode(blendMode);
canvas.drawImageAtCurrentLayer(..., blendPaint);
The key: mask using a blend mode (DstIn), not a clip. Blend modes don't persist through layer boundaries—they apply once, then the layer contents are fixed.
Order:
- Start effect layer (blur will apply on restore)
- Start mask layer
- Draw content
- Restore mask layer → content is now a fixed image
- Draw mask shape with
DstInblend → clips the fixed image - Restore effect layer → blur applies to the masked image
- Composite with final blend mode
No separate buffers. No multiple render passes. Just careful sequencing of layer operations.
The Coordinate Space Issue
Initially, the mask was being applied in the wrong coordinate space. Content was transformed (rotated, scaled), but the mask was drawn in canvas space.
Fixed by storing the transform state at effectsStart():
function effectsStart(node, matrix) {
// matrix = parent × node transform
node._composedMatrix = matrix; // Store for later
}
Then when applying the mask:
// Apply the same transform that was active during content rendering
canvas.setMatrix(node._composedMatrix);
canvas.drawPath(maskPath, maskPaint);
The mask now renders in the same coordinate space as the content it's masking.
Results
Masked layers with effects now render correctly:
- Blur extends beyond mask boundaries
- Shadows apply to masked shapes
- Blend modes composite the final result properly
- Transforms apply consistently to content and mask
The implementation uses ~3 saveLayer() calls per masked effect layer, with explicit blend mode application for masking instead of clips.
Key insight: clips are persistent, blend modes are one-shot. For sequencing control, prefer blend modes over clips.
Read next: Why path.transform() Killed Performance - CPU path transformation and why Figma uses raw WebGL.