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
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 everywhereCreating
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.
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.
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 + 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 // radiansPaths
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(): thisRect & 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, logical pxmeasureText 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.
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, so keep the Bitmap alive until the next bake
(update() / texture() / toBitmap()).
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 // frees the native surface; the bitmap is unusable afterThe flatten pattern — keep re-bakes O(1) on an ever-growing drawing:
const snap = canvas.toBitmap()
canvas.reset()
canvas.drawImage(snap, 0, 0) // one op replaces the whole history; keep drawing on topBaking 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 // free the native surface + its cached 2D textureafter 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()
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 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
// ✗ 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