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

TypeScript
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 everywhere

Create a canvas, its size and sharpness: new Canvas

TypeScript
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.

Note

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.

TypeScript
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.

TypeScript
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(): this

Styles

TypeScript
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 first

Paths

TypeScript
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

TypeScript
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 px

measureText is the one drawing call that asks the platform; the others only record.

Note

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.

TypeScript
canvas.loadImage(source: SvgSource | FetchResponse | File): Promise<Bitmap>   // an SvgSource, or fetched / picked PNG / JPEG / SVG bytes

The 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.

Note

an SvgSource loads on every host. Encoded PNG / JPEG bytes do not on the web host (its decoder is not synchronous) — loadImage rejects there.

TypeScript
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×dh

All 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.

TypeScript
canvas.toBitmap(): Bitmap    // immutable copy of the current pixels, independent of the canvas
TypeScript
bmp.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 after

The flatten pattern — keep re-bakes O(1) on an ever-growing drawing:

TypeScript
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 top

The previous snapshot goes by itself: reset() dropped its op, and nothing else holds it.

Baking to textures

TypeScript
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 produced

You 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.

TypeScript
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 now

A 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.

Note

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()

TypeScript
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 bake
Note

reset() 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

TypeScript
// ✗ 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