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