LeCodesdocs

Canvas & Bitmap

A 2D drawing surface that bakes to a texture — the way to get text and arbitrary vector graphics into sprites, 3D materials, and UI images ("a canvas is just a texture"). The API mirrors the browser's 2D context, but it records commands instead of drawing immediately: the whole buffer is rasterized once per bake, on the platform's native 2D stack.

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

Creating

TypeScript
new Canvas(width, height, opts?: {
  pixelRatio?: number    // device-pixel multiplier; default 1
})

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 platform. Do all drawing after await registerFont(...) for any custom font — an unloaded font mis-measures.

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 + 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

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.rect(x, y, w, h): this
c.roundRect(x, y, w, h, r): this        // one radius for all corners
c.closePath(): this
c.fill(evenOdd?): this                  // evenOdd default false (nonzero winding)
c.stroke(): this

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, logical px

measureText is the one synchronous round-trip to the platform — everything else just records.

Images: drawImage and Bitmap

drawImage blits a Bitmap — a snapshot made with toBitmap(). It does not accept a Texture2D or another Canvas.

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, so keep the Bitmap alive until the next bake (update() / texture() / toBitmap()).

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                // frees the native surface; the bitmap is unusable after

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

TypeScript
const snap = canvas.toBitmap()
canvas.reset()
canvas.drawImage(snap, 0, 0)   // one op replaces the whole history; keep drawing on top

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                       // free the native surface + its cached 2D texture
Note

after destroy(), the canvas and any sprite/material still sampling its texture are invalid. 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 recorded state too — font, fillStyle, textAlign, every setting you made is gone from the next bake, which falls back to defaults. The property getters still report the old values (and measureText still measures with the old font), which makes this easy to miss. 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.

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