Why path.transform() Killed Performance

Animate a rotated rectangle. 60fps. Smooth.

Animate a vector network with 50 segments. 15fps. Choppy.

Same animation. Different data structure. 4× performance difference.

The bottleneck: path.transform() happens on the CPU.

The Problem

Every frame, for every animated element:

SkPath originalPath = getPath();
SkMatrix transform = getAnimationMatrix();  // Rotation, scale, etc.

SkPath transformedPath;
originalPath.transform(transform, &transformedPath);

canvas.drawPath(transformedPath, paint);

The transform() call recalculates every vertex of the path on the CPU. For a 50-segment vector network with bezier curves, that's ~200 points recalculated every frame.

At 60fps, that's 12,000 point transformations per second. On the CPU. Single-threaded.

For static geometry, this is fine—transform once, cache the result. But for animations, you're recalculating the same transform 60 times per second.

First Attempt: Cache Transformed Paths

Maybe cache the transformed paths?

std::map, SkPath> transformCache;

SkPath getTransformedPath(PathID id, const SkMatrix& matrix) {
  auto key = {id, matrix};
  if (!transformCache.contains(key)) {
    transformCache[key] = originalPath.transform(matrix);
  }
  return transformCache[key];
}

This worked for static transforms. For animations, every frame has a different matrix—the cache never hits. Cache miss rate: 100%.

We're still doing CPU path transformation every frame.

The Fundamental Issue

GPUs are really good at matrix transformations. That's what they're designed for—transforming millions of vertices per frame.

But SkPath is a CPU data structure. To render it, Skia:

  1. Transforms the path on CPU (via path.transform())
  2. Tesselates the curved path into triangles (CPU)
  3. Uploads vertices to GPU
  4. GPU renders the triangles

Steps 1-2 happen every frame for animated paths. That's the bottleneck.

The Solution: Expose the Mesh Interface

Skia has SkMesh—a way to pass vertices directly to the GPU with a transform applied by vertex shaders.

We exposed it to CanvasKit:

// Build mesh once (CPU cost paid once)
const meshData = {
  vertices: new Float32Array([...v]),  // All vertex positions
  indices: new Uint16Array([...c]),    // Triangle connectivity
  uvs: new Float32Array([...coord])        // Texture coordinates
};

const mesh = CanvasKit.MakeMesh(meshData, ...m);

// Render with transform (GPU cost, very cheap)
function render(canvas, transform) {
  canvas.save();
  canvas.concat(transform);  // GPU applies this
  canvas.drawMesh(mesh, paint);
  canvas.restore();
}

Now the workflow is:

  1. Build mesh once on CPU
  2. Upload to GPU once
  3. Every frame: Send a transform matrix (16 floats = 64 bytes)
  4. GPU transforms vertices in parallel
  5. GPU renders

The expensive CPU work (path tessellation, vertex generation) happens once. Animation just sends new matrices to the GPU.

The Performance Difference

Before (path.transform):

After (mesh + GPU transform):

30× faster for animated vector graphics.

This is why Figma uses raw WebGL instead of higher-level Canvas APIs or CanvasKit for their main rendering. GPU vertex transforms are fundamentally faster than CPU path operations for animated content.

The Trade-off

Meshes are less flexible than paths:

But for rendering animated vector graphics, the performance win is worth it.

We use:

Generate the mesh from the path once, then animate the mesh.

Results

Animated vector networks now run at 60fps even with complex geometry. The mesh interface exposed ~200 lines of CanvasKit bindings, but the performance improvement is essential for smooth animation.

The CPU-GPU boundary matters. Path transforms are CPU-bound. Vertex transforms are GPU-parallel. For animation, you want the latter.


Read next: Image Filters: Exposure, Contrast, and the Missing Matrix - Why ColorMatrix isn't enough.