Canvas & Bitmap
A picture drawn in code — shapes, lines, text, images — that becomes a texture or an image: a
chart, a signature, a badge, a name tag over a sprite. The API mirrors the browser's 2D context.
A UI made of elements (text, buttons, lists) is the UI, not a canvas;
toFile() exports what a canvas drew, and a screenshot of the UI does not exist
(Not available).
- draw lines, circles and shapes
- draw a chart, a signature or a badge and show it on screen
- put text or a name tag on a sprite or a 3D model's texture
- draw an image or an SVG into the picture
- export the drawing as a PNG file to share or save
- redraw the picture when the data changes, or every frame
Platforms: iOS, Android, desktop, the web, the web player and the le.codes viewer app; a canvas
as a sprite / material texture is not on the web (Web Lite), and the web player's loadImage takes
SVG only. Headless tests bake only with the @napi-rs/canvas package (without it the picture is
empty and measureText answers 0).
At a glance
const c = new Canvas(160, 40, { pixelRatio: 2 }) // logical 160×40, baked at 320×80
c.font = 'bold 24px Inter'
const w = c.measureText('Alice').width + 24
c.resize(w, 40)
c.fillStyle = '#000a'
c.roundRect(0, 0, w, 40, 12).fill()
c.fillStyle = '#fff'
c.textAlign = 'center'
c.fillText('Alice', w / 2, 27)
const tag = new Sprite({ texture: c }) // a Canvas is a valid texture source everywhereCreate a canvas, its size and sharpness: new Canvas
new Canvas(width, height, opts?: {
pixelRatio?: number // device-pixel multiplier; default 1
})A canvas records commands instead of drawing immediately: the whole recording is baked at once — the runtime's canvas core turns it into a draw list, and the host paints that with the platform's native 2D stack.
All drawing is authored in logical units. width/height are the logical size; the baked
texture is width × pixelRatio by height × pixelRatio device pixels. pixelRatio affects
crispness only, never on-screen size — a Canvas-backed Sprite defaults its world size to the
logical size. Use pixelRatio: 2 (or device.pixelRatio) for crisp text on retina screens.
colors and fonts are CSS strings, resolved by the runtime's canvas core: an invalid
color keeps the previous style, and of a font list ('18px Foo, Inter') only the first family
is used. Do all drawing after await registerFont(...) for any custom font — an unloaded
font mis-measures.
Colors, line width and font: the drawing state
Get/set properties, like the browser. Setting one records a state op and shadows the value so the getter reads it back.
c.fillStyle = '#ff8800' // any CSS color string; default '#000000'
c.strokeStyle = '#fff'
c.lineWidth = 2 // default 1
c.lineJoin = 'miter' // 'miter' | 'round' | 'bevel'
c.lineCap = 'butt' // 'butt' | 'round' | 'square'
c.globalAlpha = 0.5 // 0..1; default 1
c.font = '16px sans-serif' // CSS font string; default '10px sans-serif'
c.textAlign = 'start' // 'left' | 'center' | 'right' | 'start' | 'end'
c.textBaseline = 'alphabetic' // 'alphabetic' | 'top' | 'middle' | 'bottom' | 'hanging' | 'ideographic'Transforms & state stack
All drawing methods return this, so calls chain.
c.save(): this // push state (transform + clip + drawing state)
c.restore(): this // pop it
c.translate(x, y): this
c.scale(sx, sy): this // a transform — unrelated to pixelRatio
c.rotate(rad): this // radians
c.transform(a, b, c, d, e, f): this // multiply the current transform
c.setTransform(a, b, c, d, e, f): this // replace it
c.resetTransform(): thisStyles
c.fillStyle = '#f80' // a CSS color or a gradient (below); strokeStyle likewise
c.globalAlpha = 0.5
c.lineWidth = 2; c.lineJoin = 'round'; c.lineCap = 'butt'; c.miterLimit = 10
c.setLineDash([4, 2]); c.lineDashOffset = 0 // [] = solid; c.getLineDash() reads a copy back
c.letterSpacing = 1 // px between glyphs
const g = c.createLinearGradient(x0, y0, x1, y1) // or createRadialGradient(x0, y0, r0, x1, y1, r1)
g.addColorStop(0, '#fff').addColorStop(1, 'transparent') // offset 0..1, else a RangeError
c.fillStyle = g // a gradient is captured when ASSIGNED; add its stops firstPaths
c.beginPath(): this
c.moveTo(x, y): this
c.lineTo(x, y): this
c.quadraticCurveTo(cx, cy, x, y): this
c.bezierCurveTo(c1x, c1y, c2x, c2y, x, y): this
c.arc(x, y, r, a0, a1, ccw?): this // angles in radians; ccw default false
c.arcTo(x1, y1, x2, y2, r): this
c.ellipse(x, y, rx, ry, rotation, a0, a1, ccw?): this
c.rect(x, y, w, h): this
c.roundRect(x, y, w, h, r): this // one radius for all corners
c.closePath(): this
c.fill(rule?): this // 'nonzero' (default) | 'evenodd'
c.stroke(): this
c.clip(rule?): this // intersect the clip with the current path (until restore())Rect & text sugar
c.fillRect(x, y, w, h): this
c.strokeRect(x, y, w, h): this
c.clearRect(x, y, w, h): this
c.fillText(text, x, y, maxWidth?): this // maxWidth 0 = unconstrained (default)
c.strokeText(text, x, y, maxWidth?): this
c.measureText(text): { width, ascent, descent } // measures in the CURRENT font and letterSpacing, logical pxmeasureText is the one drawing call that asks the platform; the others only record.
a host without a painter (the windowless lecodes-server) still records and bakes,
but measureText answers 0 and toFile rejects there.
Images: drawImage and Bitmap
drawImage blits a Bitmap — a snapshot made with toBitmap() or an image decoded by
loadImage(). It does not accept a Texture2D or another Canvas.
canvas.loadImage(source: SvgSource | FetchResponse | File): Promise<Bitmap> // an SvgSource, or fetched / picked PNG / JPEG / SVG bytesThe Bitmap has the image's natural pixel size and pixelRatio 1. await it once (inside an
async function), then drawImage it synchronously as often as you like.
an SvgSource loads on every host. Encoded PNG / JPEG bytes do not on the web host
(its decoder is not synchronous) — loadImage rejects there.
c.drawImage(bmp, dx, dy): this // whole bitmap at natural (logical) size
c.drawImage(bmp, dx, dy, dw, dh): this // whole bitmap scaled into dw×dh
c.drawImage(bmp, sx, sy, sw, sh, dx, dy, dw, dh): this // sub-rect scaled into dw×dhAll coordinates are logical — including the source sub-rect in the 9-arg form. The blit is
recorded like everything else and every bake replays the recording; the canvas keeps the Bitmap
alive until reset() removes the drawImage op.
canvas.toBitmap(): Bitmap // immutable copy of the current pixels, independent of the canvasbmp.width, bmp.height // logical size
bmp.pixelWidth, bmp.pixelHeight // device pixels (logical × pixelRatio)
bmp.toFile(name?, type?): Promise<File> // encode: 'image/png' (default) or 'image/jpeg'
bmp.destroy(): void // optional early free of the native surface; unusable afterThe flatten pattern — keep re-bakes O(1) on an ever-growing drawing:
const next = canvas.toBitmap() // bakes with the previous snapshot still alive
canvas.reset()
canvas.drawImage(next, 0, 0) // one op replaces the whole history; keep drawing on topThe previous snapshot goes by itself: reset() dropped its op, and nothing else holds it.
Baking to textures
canvas.texture(): Texture2D // bake + return the 2D texture (created once, then cached)
canvas.update(): this // re-rasterize and re-upload to every texture this canvas producedYou rarely call texture() yourself — assigning the canvas where a texture is expected
(new Sprite({ texture: c }), UIImage, a Material map) bakes it. After redrawing a dynamic
canvas (reset() + new commands), call update() to push the new pixels to all its consumers.
canvas.toFile(name?, type?): Promise<File> // encode current pixels; png (default) or jpeg
canvas.destroy(): void // optional: free the native surface + its cached 2D texture nowA Canvas or Bitmap you drop is freed automatically once nothing shows or draws it: a UIImage
showing the canvas keeps it until the image shows something else, a canvas that drew a Bitmap
keeps it until reset(). Sprite and material textures are copies and don't keep it.
destroy() also frees the canvas' 2D texture — a sprite still using it samples a
freed texture — and an image showing the canvas goes blank. The canvas keeps its recording: a
later bake makes a new surface. A texture baked into the 3D engine is freed at that engine's
teardown, not here.
reset() and resize()
canvas.reset(): this // discard the recorded drawing to start a new one
canvas.resize(w, h): this // change the logical size; takes effect on the next bakereset() clears the drawing state too. font, fillStyle, textAlign and every
other setting go back to their defaults, both in the next bake and in the getters (so
measureText measures in the default 10px sans-serif again). Re-set font and friends
after every reset().
resize() does not clear recorded commands; combine with reset() when starting over at a new
size. The measure-then-resize flow (see At a glance) works because nothing rasterizes until the
first bake. A canvas whose recording is still growing past 20 000 values from one bake to the
next logs one [canvas] … keeps growing warning: a redraw loop without reset() gets slower
every frame.
Pitfalls
// ✗ redrawing after reset() without re-setting state — text bakes in the 10px default font
c.reset(); c.fillText('99', 10, 20)
// ✓ state is part of the recording; set it again
c.reset(); c.font = 'bold 24px Inter'; c.fillStyle = '#fff'; c.fillText('99', 10, 20)
// ✗ redrawing and expecting live consumers to change — the sprite keeps the old pixels
c.reset(); c.fillRect(0, 0, 40, 40)
// ✓ push the new pixels to every texture this canvas produced
c.reset(); c.fillRect(0, 0, 40, 40); c.update()
// ✗ sizing a sprite by pixelRatio — pixelRatio is crispness, not size
new Sprite({ texture: c, size: [c.width * c.pixelRatio, c.height * c.pixelRatio] })
// ✓ the default world size is already the logical size
new Sprite({ texture: c })See also
- Sprite — Canvas as a sprite texture
- Conventions — logical px, color strings