The Canvas 2D API, without the browser

arbayi1 pts0 comments

The imperative Canvas 2D API, native — GEA

Skip to content

There are two ways to put something on a screen with GeaStack. Describe it — write CSS and let the<br>engine lay it out — or draw it yourself, one command at a time. The first is GEA's declarative layout<br>engine. This is the other half: an imperative Canvas 2D API.

It is the web's API. canvas.getContext('2d'), then fillRect,<br>drawImage, fillText and path fills — the same calls you'd write in a<br>browser tab. GEA keeps the calls and drops everything beneath them. Each ctx.* lowers to<br>a native draw straight into the framebuffer. The call is identical; the layers under it are not.

Canvas 2D on a microcontroller. An interactive OpenStreetMap on an ESP32 — pan,<br>pinch-zoom, tiles over Wi-Fi — drawn with plain drawImage, fillRect and<br>fillText.<br>We took the whole demo apart in a<br>deep-dive.

The same calls, none of the browser

getContext('2d') returns a real, typed native context — not a boxed dynamic value. The<br>methods are the ones you already know, and code that reads like a browser canvas behaves like one.<br>Here is the shipped maps app drawing a tile and a label, unchanged from how you'd write it for the<br>web:

const ctx = canvas.getContext('2d')

ctx.fillStyle = 'rgb(228, 230, 221)'<br>ctx.fillRect(0, 0, width, height)<br>ctx.drawImage(tile, x, y, w, h) // a raster map tile, scaled<br>ctx.font = '16px Inter'<br>ctx.fillText(label, lx, ly)

It is a subset, and worth being plain about. The calls you reach for, unchanged —<br>fillRect, clearRect, paths, drawImage,<br>fillText, globalAlpha — are here, and behave as on the web. The heavier<br>machinery is not: no gradients, no transform stack, no clip() or<br>getImageData on the context. It is the drawing API, not all of Canvas 2D.

Immediate mode: you own the frame

Canvas is immediate-mode. There is no retained node tree to diff — every call rasterises pixels that<br>same frame, and when the canvas owns the whole screen the batch is pushed straight to the panel,<br>bypassing the UI tree entirely. That is the right model for scenes with thousands of moving things,<br>where the tree-walk would be the bottleneck. Five hundred circles are one batched call:

const ctx = Display.ctx

ctx.beginBatch()<br>ctx.clear()<br>ctx.fillCirclesRgb565(ballX, ballY, radius, colors) // 500 circles, one call<br>ctx.fillText(fps, 6, 23)<br>ctx.endBatch() // recorded, pushed to the panel

Colours fold at build time

A colour never travels as a string into a hot loop. When the value is a literal, the compiler folds<br>it to the panel's pixel format at build time — a fill becomes a single 16-bit constant written<br>straight to memory, with no per-draw #rrggbb → native conversion. A runtime colour pays<br>that conversion once.

ctx.fillStyle = '#5260e0'<br>ctx.fillRect(x, y, w, h)<br>↓ geatsc

// '#5260e0' folded to a panel-native RGB565 constant at build time<br>ctx.setFillStyleRgb565(0x531c);<br>ctx.fillRect(x, y, w, h);

One rasteriser, every target

Text is drawn from coverage atlases rasterised at build time — anti-aliased glyphs, no TTF parser on<br>the device. Images decode into native-format slots and blit, scaled with a nearest or bilinear path.<br>And it is one C++ rasteriser everywhere: the same code paints an RGB565 framebuffer on an ESP32,<br>compiles to WASM for the browser simulator, and renders through CoreGraphics on iOS — no Skia, no<br>Metal under the canvas. The same draw calls land on an RGB565 framebuffer, a WASM canvas or<br>CoreGraphics — only the target underneath changes.

How fast is it?

The honest argument for the imperative path is a comparison. On the same panel, five hundred<br>circles in immediate mode hold the panel's full 60 fps — the same frame rate a retained tree<br>reaches managing sixty-four. That's roughly eight times the count, at the same frame rate. The<br>cost is per-circle CPU rasterisation, not the full-screen DMA both pay every frame. The engine<br>is compute-bound, not bus-bound.

Immediate vs retained, same ESP32-S3 panel — fps

Immediate · 500 circles~60

Retained · 64 circles~60

Per-circle CPU is the cost, not DMA. Both hold the panel's 60 fps; the immediate path skips the node-walk, so it carries about eight times the count at that frame rate.

Canvas apps on device — higher is smoother, fps

Software 3D (canvas-3d)~90

500 circles~60

Map panning30–48

Measured on device. Software 3D and the circles run on a few-dollar ESP32-S3 AMOLED; map panning is a 1280×720 ESP32-P4. Frame rate scales with panel size, so the board matters.

call draws 500 circles, batched

402 KiB<br>RGB565 framebuffer, reused every frame

runtime colour conversions for literals

GEA makes embedded development feel like the web — a declarative CSS engine for layout, this Canvas API for the rest — and compiles both to native code for the device.

What works, and what doesn't

The boundary is deliberate, and worth stating plainly. What the context gives you today:

fillRect, strokeRect, clearRect; fillCircle and even-odd path fills with moveTo/lineTo/fill/stroke

drawImage (placed and scaled), fillText,...

canvas panel frame circles native fillrect

Related Articles