The Transform Matrix Puzzle: Figma Space to Skia Space
Import an image from Figma. Position: (100, 50). Scale: 2×. Rotation: 45°.
Render it in CanvasKit. The image is in the wrong place, wrong size, or both.
Same transform matrix. Different interpretation.
The Problem
Figma's image transform encodes: translation, rotation, scale, and skew in a 3×3 matrix.
Skia's SkMatrix also uses a 3×3 matrix.
But they don't mean the same thing. The order of operations differs.
Figma: scale → rotate → translate Skia: translate → rotate → scale
For the same final position, you need different matrix values.
First Attempt: Direct Matrix Copy
Just copy the matrix values:
// Figma matrix
float figmaMatrix[9] = {a, b, c, d, e, f, 0, 0, 1};
// Copy to Skia matrix
SkMatrix skiaMatrix;
skiaMatrix.set9(figmaMatrix);
// Draw image
canvas.concat(skiaMatrix);
canvas.drawImage(image, 0, 0);
The image rendered at the wrong scale. Rotation was correct, but size was off.
The matrix values were numerically correct, but the semantic meaning of those values differed between systems.
The Decomposition Approach
Decompose the Figma matrix into components, then rebuild for Skia:
// Extract translation
float tx = figmaMatrix[2];
float ty = figmaMatrix[5];
// Extract rotation (from the a,b components)
float angle = atan2(figmaMatrix[1], figmaMatrix[0]);
// Extract scale
float sx = sqrt(figmaMatrix[0]*figmaMatrix[0] + figmaMatrix[1]*figmaMatrix[1]);
float sy = sqrt(figmaMatrix[3]*figmaMatrix[3] + figmaMatrix[4]*figmaMatrix[4]);
// Rebuild in Skia's order
SkMatrix skiaMatrix;
skiaMatrix.setTranslate(tx, ty);
skiaMatrix.postRotate(angle * 180 / M_PI);
skiaMatrix.postScale(sx, sy);
This worked for simple cases. Then we tried it with image fills that tile.
The Tile Mode Complication
For tiled images (repeating patterns), the transform order does matter:
Scale → translate: Tiles are spaced based on scaled size Translate → scale: Tiles are positioned, then the whole pattern scales
Figma uses scale→translate for tiles. We were doing translate→scale.
The solution: different transform orders for different use cases:
if (tileMode != SkTileMode::kClamp) {
// For tiling: scale first, then translate
matrix.setScale(sx, sy);
matrix.postTranslate(tx, ty);
} else {
// For clamp: translate first (standard Skia order)
matrix.setTranslate(tx, ty);
matrix.postScale(sx, sy);
}
The Sampling Mode Issue
Even with the right transform order, image sampling was wrong for rotated images.
The problem: filterMode needs to match the sampling context.
For scaled-up images (magnification): use nearest or linear For scaled-down images (minification): use mipmaps
We were using a fixed filter mode. Should be dynamic based on the scale factor:
float scaleX = matrix.getScaleX();
float scaleY = matrix.getScaleY();
float scale = sqrt(scaleX * scaleX + scaleY * scaleY);
SkSamplingOptions sampling;
if (scale < 1.0) {
// Minification - use mipmaps
sampling = SkSamplingOptions(SkFilterMode::kLinear, SkMipmapMode::kLinear);
} else {
// Magnification - use simple filtering
sampling = SkSamplingOptions(SkFilterMode::kLinear, SkMipmapMode::kNone);
}
canvas.drawImage(image, 0, 0, sampling, &paint);
Now rotated, scaled images sample correctly without aliasing.
Results
Image transforms now match Figma's rendering:
- Correct transform order for tiled vs non-tiled
- Proper sampling based on scale factor
- Decompose→rebuild approach handles complex transforms
The fix is ~30 lines of matrix decomposition and conditional rebuild logic. The complexity is in understanding what the matrix means in each system's context, not in the math itself.
Same matrix, different semantics. Always check the transform order.
Read next: 65,535 Vertices: When Text Export Breaks Your Renderer - Flattened text and the Uint32 migration.