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
EmulatedCanvas2Dis not a<canvas>element. It has no DOM presence. You either read its pixels withtoDataURL/decodeImageor wrap it as anImageand draw it onto a real surface. - The
2dcontext is the only context type.getContext('webgl')and friends returnnull. - 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
fillTextfalls back to whatever subset of glyph rendering Skia has compiled in — expect missing glyphs and no advanced shaping. UsePaint+Pathfor 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 | Yes — toDataURL works without a <canvas> element. |
Drawing through Path2D-style strings |
Yes — makePath2D(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
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)infillStyle/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
| Member | Args / value | Notes |
|---|---|---|
save | — | Push state (transform, clip, all style props) onto an internal stack. |
restore | — | Pop the previous state. |
canvas | property → EmulatedCanvas2D | Read-only back-pointer to the parent canvas. |
width / height | property → number | Pixel dimensions of the underlying surface. |
Transforms
| Member | Args / value | Notes |
|---|---|---|
translate | tx, ty: number | Add a translation to the CTM. |
rotate | radians: number | Rotate around (0, 0) — combine with translate to rotate around an arbitrary pivot. |
scale | sx, sy: number | Scale axes. |
transform | a, b, c, d, e, f: number | Multiply the current transform by an arbitrary affine matrix. |
setTransform | a, b, c, d, e, f: number or m: DOMMatrix | Replace the CTM. |
resetTransform | — | Set the CTM back to identity. |
currentTransform | property → DOMMatrix | Read or replace the CTM as a DOMMatrix. |
Compositing & filters
| Member | Args / value | Notes |
|---|---|---|
globalAlpha | property → number 0..1 | Multiplier on every subsequent draw's alpha. |
globalCompositeOperation | property → string | Blend mode keyword: 'source-over', 'multiply', 'screen', 'overlay', etc. |
imageSmoothingEnabled | property → boolean | Toggle bilinear filtering on drawImage. |
imageSmoothingQuality | property → 'low'|'medium'|'high' | Filter strength when smoothing is on. |
Shadows
| Member | Args / value | Notes |
|---|---|---|
shadowOffsetX / shadowOffsetY | property → number | Pixel offset of the drop shadow. |
shadowBlur | property → number | Blur radius (pixels). |
shadowColor | property → string | CSS color (use rgba() for translucent shadows). |
Fill & stroke styles
| Member | Args / value | Notes |
|---|---|---|
fillStyle | property → string | CanvasGradient | CanvasPattern | Set color (CSS string), gradient (from create*Gradient), or pattern. |
strokeStyle | property → string | CanvasGradient | CanvasPattern | Same shape, used by stroke*. |
createLinearGradient | x0, y0, x1, y1: number | Linear gradient between two points. Add stops with g.addColorStop(t, css). |
createRadialGradient | x0, y0, r0, x1, y1, r1: number | Radial gradient between two circles. |
createPattern | image, repetition: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | Tile an image as a fill/stroke style. |
Stroke styling
| Member | Args / value | Notes |
|---|---|---|
lineWidth | property → number | Stroke thickness in canvas units. |
lineCap | property → 'butt' | 'round' | 'square' | Endcap shape. |
lineJoin | property → 'bevel' | 'round' | 'miter' | Corner shape between segments. |
miterLimit | property → number | Cutoff angle past which miter falls back to bevel. |
setLineDash | segments: number[] | Alternating on/off dash lengths. Empty array = solid. |
getLineDash | — | Returns the current dash array. |
lineDashOffset | property → number | Phase offset into the dash pattern. |
Rectangles
| Member | Args / value | Notes |
|---|---|---|
clearRect | x, y, w, h: number | Erase a rectangular region to transparent black. |
fillRect | x, y, w, h: number | Fill with current fillStyle. |
strokeRect | x, y, w, h: number | Stroke with current strokeStyle. |
Path building
All these accumulate into the current path. Drawing happens only on fill / stroke / clip.
| Member | Args / value | Notes |
|---|---|---|
beginPath | — | Reset the current path. |
closePath | — | Add a line back to the start of the current sub-path. |
moveTo | x, y: number | Start a new sub-path. |
lineTo | x, y: number | Straight segment. |
quadraticCurveTo | cx, cy, x, y: number | Quadratic Bezier (one control point). |
bezierCurveTo | c1x, c1y, c2x, c2y, x, y: number | Cubic Bezier (two control points). |
arc | x, y, r, start, end, ccw?: boolean | Circular arc — angles in radians, 0 at 3 o'clock. |
arcTo | x1, y1, x2, y2, r: number | Tangent-line arc; rounds the corner of an L-shape. |
ellipse | x, y, rx, ry, rotation, start, end, ccw?: boolean | Ellipse arc with separate radii and an axis rotation. |
rect | x, y, w, h: number | Add a four-segment rectangle to the current path. |
Drawing paths
| Member | Args / value | Notes |
|---|---|---|
fill | fillRule?: '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. |
clip | fillRule? or (path: Path2D, fillRule?) | Intersect the clip region with the current path. |
isPointInPath | x, y, fillRule? or (path: Path2D, x, y, fillRule?) | Hit-test the fill region. |
isPointInStroke | x, y or (path: Path2D, x, y) | Hit-test the stroke region (uses current lineWidth). |
Text
| Member | Args / value | Notes |
|---|---|---|
font | property → string | CSS font shorthand: '24px sans-serif', 'bold 16px Inter'. |
fillText | text, x, y, maxWidth?: number | Fill text. Subject to font availability — see "Text shaping" note above. |
strokeText | text, x, y, maxWidth?: number | Stroke text outline. |
measureText | text: string | Returns a TextMetrics with width + various baseline offsets. |
Images
| Member | Args / value | Notes |
|---|---|---|
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. |
createImageData | w, h: number or imgData: ImageData | Allocate or clone a transparent ImageData. |
getImageData | x, y, w, h: number | Read pixels into a fresh ImageData. Goes through a WASM-memory copy. |
putImageData | imgData, dx, dy[, sx, sy, sw, sh] | Blit pixels back into the canvas — bypasses globalAlpha/comp-op. |
Misc
| Member | Args / value | Notes |
|---|---|---|
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
| Member | Args | Returns | Notes |
|---|---|---|---|
dispose | — | void | Free the WASM resources backing this canvas. Required. |
getContext | type: string | EmulatedCanvas2DContext | null | Only '2d' returns a context; everything else returns null. |
decodeImage | bytes: ArrayBuffer | Uint8Array | Image | Decode encoded bytes (PNG/JPEG/etc.) into a Skia Image. |
loadFont | bytes, descriptors: Record<string, string> | void | Register a font with the emulator. Mirrors new FontFace(). |
makePath2D | str?: string | Path2D | Build a Path2D; SVG d= string optional. Pass into ctx.fill(p) / ctx.stroke(p) / ctx.clip(p). |
toDataURL | codec?: string, quality?: number | string | Encode current canvas as a data URL. image/png or image/jpeg. |
Caveats vs. browser Canvas2D
- No event model: there's no
requestAnimationFramedriving a real DOM canvas. You drive your own loop and calltoDataURL(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. drawFocusIfNeededand accessibility-related methods are no-ops.getImageData/putImageDatawork but go through WASM-memory copies — not zero-cost.- Filters (the CSS-style
ctx.filter = 'blur(8px)'API) are not supported here. UseImageFilteron a paint when drawing the result back to a SkiaCanvas.
See also
CanvasKit.MakeCanvas— factory.Canvas— Skia-native drawing surface; preferred for new code.Image.encodeToBytes— alternative totoDataURLfor raw bytes.Path.MakeFromSVGString— Skia parser behindmakePath2D.