canvaskit-wasm 0.39 build 2026-04-29

EmulatedCanvas2D

A shim that re-implements the HTML5 Canvas2D API on top of Skia. You write the same ctx.fillRect / ctx.beginPath / ctx.drawImage you'd write against a <canvas>, but Skia does the rendering. Returned by CK.MakeCanvas.

The main reason this exists: porting. If you have a Canvas2D-based renderer and want to migrate to Skia gradually, EmulatedCanvas2D lets you keep the existing call sites and change them one shape at a time. It also lets Node-style headless rendering use the familiar API.

Code on this page is reference: EmulatedCanvas2D is a headless shim — there's no <canvas> to render into. To actually display its output you toDataURL and route the bytes through an <img>, or compose into a Skia surface (next section).

const cnv = CK.MakeCanvas(800, 600);
const ctx = cnv.getContext('2d');

ctx.fillStyle = 'rebeccapurple';
ctx.fillRect(0, 0, 800, 600);

const dataUrl = cnv.toDataURL();   // 'data:image/png;base64,...'
cnv.dispose();

getContext('2d') returns the same CanvasRenderingContext2D shape every browser exposes — typed in the .d.ts as EmulatedCanvas2DContext = CanvasRenderingContext2D. Methods are real Skia under the hood, not a forward to a real <canvas>.

EmulatedCanvas2D is a WASM-backed object — call .dispose() when you're done.

Core mental model

  • An EmulatedCanvas2D is not a <canvas> element. It has no DOM presence. You either read its pixels with toDataURL/decodeImage or wrap it as an Image and draw it onto a real surface.
  • The 2d context is the only context type. getContext('webgl') and friends return null.
  • The full Canvas2D specification is implemented including paths, gradients, patterns, transforms, blend modes (globalCompositeOperation), shadows, image draws, and basic text.
  • Text shaping is approximate. This build strips Skia's font/paragraph subsystem, so fillText falls back to whatever subset of glyph rendering Skia has compiled in — expect missing glyphs and no advanced shaping. Use Paint + Path for production text.

When to reach for it

Use case Reach for EmulatedCanvas2D?
Existing Canvas2D codebase you want to render through Skia (e.g. for filters, blend modes, SVG path fidelity) Yes — that's the whole point.
Generating PNG bytes server-side or in a worker without a DOM canvas YestoDataURL works without a <canvas> element.
Drawing through Path2D-style strings YesmakePath2D(svgPath) builds an emulated Path2D.
Rendering a fresh interactive doc-page demo from scratch No — go straight to Canvas for the full Skia API.
Anything text-heavy No — out of scope in this build.

surface.getContext2d() helper

The runnable examples on this page use a small helper installed onto CK.Surface.prototype by this site's runtime. It wires up a GPU-backed

on the same GrContext as the visible WebGL surface, returns a Canvas2D context, and adds flush() / delete() so the demo body stays focused on Canvas2D calls. The full source — copy this into any project to get the same shape:

CK.Surface.prototype.getContext2d = function () {
  const parentSurface = this;
  const parentCanvas = this.getCanvas();
  const grContext = this.getGrContext();
  const gpuSurf = CK.MakeRenderTarget(grContext, this.width(), this.height());
  const cnv = CK.MakeCanvasFromSurface(gpuSurf);
  const ctx = cnv.getContext('2d');
  ctx.flush = function () {
    const snap = gpuSurf.makeImageSnapshot();
    parentCanvas.drawImage(snap, 0, 0, null);
    parentSurface.flush();
    snap.delete();
  };
  ctx.delete = function () {
    cnv.dispose();
  };
  return ctx;
};

ctx.flush() snapshots the GPU surface and draws it onto the visible Skia canvas (single drawImage, GPU→GPU, no readback). ctx.delete() disposes the wrapper plus its render target. Drop this on the page once and every demo collapses to "grab ctx, draw, flush".

A first drawing

const ctx = surface.getContext2d(); // Background ctx.fillStyle = '#fafafa'; ctx.fillRect(0, 0, w, h); // Stroked rectangle ctx.strokeStyle = '#285ab4'; ctx.lineWidth = 4; ctx.strokeRect(40, 30, w - 80, h - 60); // Filled circle ctx.beginPath(); ctx.arc(w / 2, h / 2, 60, 0, Math.PI * 2); ctx.fillStyle = '#dc3c3c'; ctx.fill(); ctx.flush(); ctx.delete();

The headless equivalent against the stock EmulatedCanvas2D is a one-line change at the top — same Canvas2D body, different source for the context:

const cnv = CK.MakeCanvas(400, 300);
const ctx = cnv.getContext('2d');
// …same body as above…
const url = cnv.toDataURL();   // 'data:image/png;base64,...'
cnv.dispose();

Canvas2D feature samples

These demos use the surface.getContext2d() helper above. Each one ends with ctx.flush() to composite the GPU render target onto the visible Skia surface, and ctx.delete() to free it.

Arcs

arc(x, y, r, start, end, counterclockwise?) and arcTo(x1, y1, x2, y2, r) — the same API browsers expose. Angles in radians; 0 starts at 3 o'clock.

const ctx = surface.getContext2d(); ctx.fillStyle = '#fafafa'; ctx.fillRect(0, 0, w, h); ctx.fillStyle = '#285ab4'; // Full circle. ctx.beginPath(); ctx.arc(80, 130, 50, 0, Math.PI * 2); ctx.fill(); // Pac-man wedge. ctx.beginPath(); ctx.moveTo(220, 130); ctx.arc(220, 130, 50, Math.PI * 0.2, Math.PI * 1.8); ctx.closePath(); ctx.fill(); // Rounded corner via arcTo. ctx.strokeStyle = '#dc3c3c'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(310, 80); ctx.arcTo(390, 80, 390, 180, 30); ctx.lineTo(390, 180); ctx.stroke(); ctx.flush(); ctx.delete();

Paths

beginPath / moveTo / lineTo / quadraticCurveTo / bezierCurveTo / closePath plus fill() / stroke(). Pass 'evenodd' to fill() for shapes with holes.

const ctx = surface.getContext2d(); ctx.fillStyle = '#fafafa'; ctx.fillRect(0, 0, w, h); // Star with even-odd fill. ctx.fillStyle = '#dc3c3c'; ctx.beginPath(); const cx = 110, cy = 130, R = 70, r = 28; for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5; const rad = i % 2 === 0 ? R : r; const x = cx + Math.cos(a) * rad; const y = cy + Math.sin(a) * rad; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.closePath(); ctx.fill('evenodd'); // Quadratic + cubic curves. ctx.strokeStyle = '#285ab4'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(220, 200); ctx.quadraticCurveTo(260, 60, 310, 200); ctx.stroke(); ctx.beginPath(); ctx.moveTo(330, 200); ctx.bezierCurveTo(340, 60, 400, 60, 400, 200); ctx.stroke(); ctx.flush(); ctx.delete();

Strokes

lineWidth, lineCap, lineJoin, miterLimit, setLineDash([…]), lineDashOffset — every stroke knob a browser exposes.

const ctx = surface.getContext2d(); ctx.fillStyle = '#fafafa'; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = '#285ab4'; ctx.lineWidth = 12; // Three caps stacked: butt / round / square. ['butt', 'round', 'square'].forEach((cap, i) => { ctx.lineCap = cap; ctx.beginPath(); ctx.moveTo(40, 40 + i * 30); ctx.lineTo(180, 40 + i * 30); ctx.stroke(); }); // Three joins on a corner: bevel / round / miter. ['bevel', 'round', 'miter'].forEach((join, i) => { ctx.lineJoin = join; ctx.beginPath(); ctx.moveTo(230, 30 + i * 50); ctx.lineTo(300, 70 + i * 50); ctx.lineTo(370, 30 + i * 50); ctx.stroke(); }); // Dashed stroke. ctx.lineCap = 'butt'; ctx.lineWidth = 4; ctx.setLineDash([14, 6, 2, 6]); ctx.beginPath(); ctx.moveTo(20, 220); ctx.lineTo(400, 220); ctx.stroke(); ctx.setLineDash([]); ctx.flush(); ctx.delete();

Alpha

Three ways to get translucency, increasing in scope:

  • rgba(r, g, b, a) in fillStyle / strokeStyle — per-color, one shape.
  • ctx.globalAlpha — multiplies into every subsequent draw until reset.
  • ctx.globalCompositeOperation = 'multiply' | 'screen' | … — controls how alpha blends against what's already there.
const ctx = surface.getContext2d(); ctx.fillStyle = '#fafafa'; ctx.fillRect(0, 0, w, h); // Per-color alpha — three overlapping circles. ctx.fillStyle = 'rgba(40, 90, 180, 0.5)'; ctx.beginPath(); ctx.arc(150, 120, 60, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(220, 60, 60, 0.5)'; ctx.beginPath(); ctx.arc(210, 120, 60, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(60, 160, 90, 0.5)'; ctx.beginPath(); ctx.arc(180, 170, 60, 0, Math.PI * 2); ctx.fill(); // globalAlpha further dims a 50% red. ctx.globalAlpha = 0.5; ctx.fillStyle = '#dc3c3c'; ctx.beginPath(); ctx.arc(340, 120, 45, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; ctx.flush(); ctx.delete();

globalCompositeOperation accepts every Canvas2D blend keyword that maps onto a BlendMode Skia knows: 'source-over' (default), 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity', plus the Porter-Duff modes ('source-in', 'destination-over', …).

2D context reference

Members exposed by cnv.getContext('2d') in this build, grouped by family. Same shape as the HTML5 CanvasRenderingContext2D — for prose explanations, MDN's pages and skia-canvas's context docs line up cleanly.

State

MemberArgs / valueNotes
savePush state (transform, clip, all style props) onto an internal stack.
restorePop the previous state.
canvasproperty → EmulatedCanvas2DRead-only back-pointer to the parent canvas.
width / heightproperty → numberPixel dimensions of the underlying surface.

Transforms

MemberArgs / valueNotes
translatetx, ty: numberAdd a translation to the CTM.
rotateradians: numberRotate around (0, 0) — combine with translate to rotate around an arbitrary pivot.
scalesx, sy: numberScale axes.
transforma, b, c, d, e, f: numberMultiply the current transform by an arbitrary affine matrix.
setTransforma, b, c, d, e, f: number or m: DOMMatrixReplace the CTM.
resetTransformSet the CTM back to identity.
currentTransformproperty → DOMMatrixRead or replace the CTM as a DOMMatrix.

Compositing & filters

MemberArgs / valueNotes
globalAlphaproperty → number 0..1Multiplier on every subsequent draw's alpha.
globalCompositeOperationproperty → stringBlend mode keyword: 'source-over', 'multiply', 'screen', 'overlay', etc.
imageSmoothingEnabledproperty → booleanToggle bilinear filtering on drawImage.
imageSmoothingQualityproperty → 'low'|'medium'|'high'Filter strength when smoothing is on.

Shadows

MemberArgs / valueNotes
shadowOffsetX / shadowOffsetYproperty → numberPixel offset of the drop shadow.
shadowBlurproperty → numberBlur radius (pixels).
shadowColorproperty → stringCSS color (use rgba() for translucent shadows).

Fill & stroke styles

MemberArgs / valueNotes
fillStyleproperty → string | CanvasGradient | CanvasPatternSet color (CSS string), gradient (from create*Gradient), or pattern.
strokeStyleproperty → string | CanvasGradient | CanvasPatternSame shape, used by stroke*.
createLinearGradientx0, y0, x1, y1: numberLinear gradient between two points. Add stops with g.addColorStop(t, css).
createRadialGradientx0, y0, r0, x1, y1, r1: numberRadial gradient between two circles.
createPatternimage, repetition: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat'Tile an image as a fill/stroke style.

Stroke styling

MemberArgs / valueNotes
lineWidthproperty → numberStroke thickness in canvas units.
lineCapproperty → 'butt' | 'round' | 'square'Endcap shape.
lineJoinproperty → 'bevel' | 'round' | 'miter'Corner shape between segments.
miterLimitproperty → numberCutoff angle past which miter falls back to bevel.
setLineDashsegments: number[]Alternating on/off dash lengths. Empty array = solid.
getLineDashReturns the current dash array.
lineDashOffsetproperty → numberPhase offset into the dash pattern.

Rectangles

MemberArgs / valueNotes
clearRectx, y, w, h: numberErase a rectangular region to transparent black.
fillRectx, y, w, h: numberFill with current fillStyle.
strokeRectx, y, w, h: numberStroke with current strokeStyle.

Path building

All these accumulate into the current path. Drawing happens only on fill / stroke / clip.

MemberArgs / valueNotes
beginPathReset the current path.
closePathAdd a line back to the start of the current sub-path.
moveTox, y: numberStart a new sub-path.
lineTox, y: numberStraight segment.
quadraticCurveTocx, cy, x, y: numberQuadratic Bezier (one control point).
bezierCurveToc1x, c1y, c2x, c2y, x, y: numberCubic Bezier (two control points).
arcx, y, r, start, end, ccw?: booleanCircular arc — angles in radians, 0 at 3 o'clock.
arcTox1, y1, x2, y2, r: numberTangent-line arc; rounds the corner of an L-shape.
ellipsex, y, rx, ry, rotation, start, end, ccw?: booleanEllipse arc with separate radii and an axis rotation.
rectx, y, w, h: numberAdd a four-segment rectangle to the current path.

Drawing paths

MemberArgs / valueNotes
fillfillRule?: 'nonzero' | 'evenodd' or (path: Path2D, fillRule?)Fill the current path or a passed Path2D.
stroke(path?: Path2D)Stroke the current path or a passed Path2D.
clipfillRule? or (path: Path2D, fillRule?)Intersect the clip region with the current path.
isPointInPathx, y, fillRule? or (path: Path2D, x, y, fillRule?)Hit-test the fill region.
isPointInStrokex, y or (path: Path2D, x, y)Hit-test the stroke region (uses current lineWidth).

Text

MemberArgs / valueNotes
fontproperty → stringCSS font shorthand: '24px sans-serif', 'bold 16px Inter'.
fillTexttext, x, y, maxWidth?: numberFill text. Subject to font availability — see "Text shaping" note above.
strokeTexttext, x, y, maxWidth?: numberStroke text outline.
measureTexttext: stringReturns a TextMetrics with width + various baseline offsets.

Images

MemberArgs / valueNotes
drawImage(img, dx, dy) · (img, dx, dy, dw, dh) · (img, sx, sy, sw, sh, dx, dy, dw, dh)3 / 5 / 9-arg forms — img is a Skia Image, an HTMLImageElement, ImageBitmap, etc.
createImageDataw, h: number or imgData: ImageDataAllocate or clone a transparent ImageData.
getImageDatax, y, w, h: numberRead pixels into a fresh ImageData. Goes through a WASM-memory copy.
putImageDataimgData, dx, dy[, sx, sy, sw, sh]Blit pixels back into the canvas — bypasses globalAlpha/comp-op.

Misc

MemberArgs / valueNotes
scrollPathIntoView(path?: Path2D)A11y stub — no-op outside a real DOM.

Not exposed in this build

The upstream CanvasRenderingContext2D has a few members the Skia emulator skips. Watch for missing: roundRect, createConicGradient, getTransform() (use currentTransform instead), direction, letterSpacing, wordSpacing, fontKerning, filter (CSS-style filter chain), drawFocusIfNeeded, reset() (the newer Canvas2D reset). Use Path / Paint / ImageFilter on a Skia Canvas for the missing pieces.

Composing into a Skia surface

The intended pattern in builds where the emulator works: render with the Canvas2D API into the emulated canvas, then round-trip the result back into Skia.

const cnv = CK.MakeCanvas(280, 200);
const ctx = cnv.getContext('2d');

// Canvas2D linear gradient — backed by a real Skia shader underneath.
const grad = ctx.createLinearGradient(0, 0, 280, 200);
grad.addColorStop(0, '#285ab4');
grad.addColorStop(1, '#dc3c3c');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 280, 200);

// Encode and re-decode as a Skia Image for compositing.
const dataUrl = cnv.toDataURL();
const bytes = Uint8Array.from(atob(dataUrl.split(',')[1]), c => c.charCodeAt(0));
const img = CK.MakeImageFromEncoded(bytes);

canvas.drawImage(img, 70, 28, null);

img.delete();
cnv.dispose();

toDataURL is the simplest interop; for tighter loops, decodeImage skips one base64 round-trip when you already have encoded bytes.

GPU-backed Canvas2D

CK.MakeCanvas(w, h) is hardcoded to a software (raster) surface — pixels live in WASM linear memory, every snapshot is a CPU→GPU upload. Looking at the upstream wrapper makes the limitation obvious:

// modules/canvaskit/htmlcanvas/htmlcanvas.js
CanvasKit.MakeCanvas = function (width, height) {
  var surf = CanvasKit.MakeSurface(width, height);   // ← only thing that's CPU
  if (surf) return new HTMLCanvas(surf);
  return null;
};

function HTMLCanvas(skSurface) {
  this._surface = skSurface;
  this._context = new CanvasRenderingContext2D(skSurface.getCanvas());
  ...
}

HTMLCanvas and CanvasRenderingContext2D are surface-agnostic — they just call Skia paint/path methods on whatever Canvas you pass them. To get GPU-backed Canvas2D, build a GPU surface with MakeRenderTarget and wrap it with HTMLCanvas directly.

This build exposes CK.MakeCanvasFromSurface(surf) for that purpose:

const grContext = surface.getGrContext(); const gpuSurf = CK.MakeRenderTarget(grContext, w, h); const cnv = CK.MakeCanvasFromSurface(gpuSurf); const ctx = cnv.getContext('2d'); // All Canvas2D drawing now lands on a GPU surface — no CPU rasterizer. ctx.fillStyle = '#fafafa'; ctx.fillRect(0, 0, w, h); const grad = ctx.createLinearGradient(0, 0, w, h); grad.addColorStop(0, '#285ab4'); grad.addColorStop(1, '#dc3c3c'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(w / 2, h / 2, 80, 0, Math.PI * 2); ctx.fill(); // GPU→GPU snapshot + drawImage onto the visible WebGL surface — same context, // no readback, single texture handle. const snap = gpuSurf.makeImageSnapshot(); canvas.drawImage(snap, 0, 0, null); surface.flush(); snap.delete(); cnv.dispose();

Stock canvaskit-wasm pattern

MakeCanvasFromSurface isn't (yet) part of upstream canvaskit-wasm — it's a 4-line addition we ship in this build and will land upstream as a PR. To reproduce the experience on stock, fish the constructor out:

// Run once after CanvasKitInit resolves.
const _probe = CK.MakeCanvas(1, 1);
const HTMLCanvas = _probe.constructor;
_probe.dispose();

// Now equivalent to MakeCanvasFromSurface:
const cnv = new HTMLCanvas(gpuSurf);

_probe.constructor reaches the file-scope HTMLCanvas class through the standard JS constructor prototype property — no private API. The technique works on every release of canvaskit-wasm.

When the readback is fine

For static or low-frequency drawing (one frame per user input, occasional re-render), the CPU upload from MakeCanvas is a fraction of a millisecond on the demo sizes you'll see in the wild. The GPU path matters when you're doing per-frame Canvas2D work at 60 fps with non-trivial draw counts.

Path2D emulation

The Canvas2D Path2D class is also emulated. makePath2D(svgString) parses an SVG-style d= string and returns a Path2D you can pass into ctx.fill(path) / ctx.stroke(path) / ctx.clip(path):

const cnv = CK.MakeCanvas(400, 300);
const ctx = cnv.getContext('2d');

const heart = cnv.makePath2D('M 200 240 C 60 140 60 60 200 100 C 340 60 340 140 200 240 Z');
ctx.fillStyle = '#dc3c3c';
ctx.fill(heart);

The string flows through the same SVG parser Skia uses for Path.MakeFromSVGString — so anything legal there is legal here. Those paths share Skia's full curve fidelity, including arcs that the browser-native Canvas2D approximates.

Loading custom fonts

loadFont(bytes, descriptors) registers a font for use by ctx.fillText/ctx.strokeText:

const fontBytes = await fetch('/fonts/Inter-Regular.ttf').then(r => r.arrayBuffer());
cnv.loadFont(fontBytes, {
  family: 'Inter',
  weight: '400',
  style: 'normal',
});

const ctx = cnv.getContext('2d');
ctx.font = '32px Inter';
ctx.fillText('Hello', 40, 80);

The descriptors mirror FontFace's constructor descriptors — family, weight, style, stretch. As mentioned in the model section, text shaping in this build is limited; complex scripts and ligatures may render incorrectly.

decodeImage helper

Outside getContext, the emulated canvas exposes decodeImage(bytes) — a thin wrapper around MakeImageFromEncoded so that DOM-style code (HTMLImageElement.src = dataUrl; await onload) doesn't need to be invented for Node-style code paths. Returns a Skia Image.

const png = await fetch('/img/chess.png').then(r => r.arrayBuffer());
const img = cnv.decodeImage(png);
ctx.drawImage(img, 0, 0);
img.delete();

Exporting

cnv.toDataURL();              // PNG, default quality
cnv.toDataURL('image/jpeg');  // JPEG (defaults to quality 0.92)
cnv.toDataURL('image/jpeg', 0.7);

Only image/png and image/jpeg are supported in this build. For other formats, use Image.encodeToBytes on a snapshot and write bytes directly.

The full member table

MemberArgsReturnsNotes
disposevoidFree the WASM resources backing this canvas. Required.
getContexttype: stringEmulatedCanvas2DContext | nullOnly '2d' returns a context; everything else returns null.
decodeImagebytes: ArrayBuffer | Uint8ArrayImageDecode encoded bytes (PNG/JPEG/etc.) into a Skia Image.
loadFontbytes, descriptors: Record<string, string>voidRegister a font with the emulator. Mirrors new FontFace().
makePath2Dstr?: stringPath2DBuild a Path2D; SVG d= string optional. Pass into ctx.fill(p) / ctx.stroke(p) / ctx.clip(p).
toDataURLcodec?: string, quality?: numberstringEncode current canvas as a data URL. image/png or image/jpeg.

Caveats vs. browser Canvas2D

  • No event model: there's no requestAnimationFrame driving a real DOM canvas. You drive your own loop and call toDataURL (or compose into a Skia surface) when you want to display.
  • Different premultiplication defaults than some browsers in edge cases; if you're getting subtle alpha differences, draw the same content side-by-side with a real <canvas> and inspect the pixels.
  • drawFocusIfNeeded and accessibility-related methods are no-ops.
  • getImageData/putImageData work but go through WASM-memory copies — not zero-cost.
  • Filters (the CSS-style ctx.filter = 'blur(8px)' API) are not supported here. Use ImageFilter on a paint when drawing the result back to a Skia Canvas.

See also