What is GEA · Engineering

The Canvas 2D API, without the browser

When layout isn't the point and you just want the pixels, you reach for <canvas> and getContext('2d'). GEA gives you that API — fillRect, drawImage, fillText — and compiles each call to a native draw. No browser, no Skia on the device. Here is how it works, and what it costs.

The canvas API · part of the GEA stack

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

It is the web's API. canvas.getContext('2d'), then fillRect, drawImage, fillText and path fills — the same calls you'd write in a browser tab. GEA keeps the calls and drops everything beneath them. Each ctx.* lowers to 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, pinch-zoom, tiles over Wi-Fi — drawn with plain drawImage, fillRect and fillText. We took the whole demo apart in a deep-dive.

The same calls, none of the browser

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

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

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

It is a subset, and worth being plain about. The calls you reach for, unchanged — fillRect, clearRect, paths, drawImage, fillText, globalAlpha — are here, and behave as on the web. The heavier machinery is not: no gradients, no transform stack, no clip() or 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 same frame, and when the canvas owns the whole screen the batch is pushed straight to the panel, bypassing the UI tree entirely. That is the right model for scenes with thousands of moving things, where the tree-walk would be the bottleneck. Five hundred circles are one batched call:

const ctx = Display.ctx

ctx.beginBatch()
ctx.clear()
ctx.fillCirclesRgb565(ballX, ballY, radius, colors) // 500 circles, one call
ctx.fillText(fps, 6, 23)
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 it to the panel's pixel format at build time — a fill becomes a single 16-bit constant written straight to memory, with no per-draw #rrggbb → native conversion. A runtime colour pays that conversion once.

ctx.fillStyle = '#5260e0'
ctx.fillRect(x, y, w, h)
↓ geatsc
// '#5260e0' folded to a panel-native RGB565 constant at build time
ctx.setFillStyleRgb565(0x531c);
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 the device. Images decode into native-format slots and blit, scaled with a nearest or bilinear path. And it is one C++ rasteriser everywhere: the same code paints an RGB565 framebuffer on an ESP32, compiles to WASM for the browser simulator, and renders through CoreGraphics on iOS — no Skia, no Metal under the canvas. The same draw calls land on an RGB565 framebuffer, a WASM canvas or 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 circles in immediate mode hold the panel's full 60 fps — the same frame rate a retained tree reaches managing sixty-four. That's roughly eight times the count, at the same frame rate. The cost is per-circle CPU rasterisation, not the full-screen DMA both pay every frame. The engine 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.
1
call draws 500 circles, batched
402 KiB
RGB565 framebuffer, reused every frame
0
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, globalAlpha, lineWidth, font
  • Batched RGB565 fast-paths — thousands of circles in a single call
  • beginBatch/endBatch to push a whole frame straight to the panel

And what it deliberately leaves out:

  • Gradients (createLinearGradient/createRadialGradient) and patterns
  • The transform stack — save/restore, translate/rotate/scale — and clip() on the context
  • getImageData/putImageData, measureText, shadows and filters on the context

Canvas, compiled to native

Describe a screen in CSS, or draw it yourself one command at a time — both compile to the same native binary, for a microcontroller, embedded Linux, macOS and iOS. The code is the best place to see it for yourself.