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:

  1. Draw content to offscreen buffer
  2. Apply mask (clip to mask shape)
  3. Apply effects (blur, shadows)
  4. 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:

  1. Start effect layer (blur will apply on restore)
  2. Start mask layer
  3. Draw content
  4. Restore mask layer → content is now a fixed image
  5. Draw mask shape with DstIn blend → clips the fixed image
  6. Restore effect layer → blur applies to the masked image
  7. 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:

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.