OBC OpenBikeComputer / docs

The rendering pipeline

Drawing a map on a microcontroller is a budgeting problem. A dense city view holds far more geometry than a 512 KB-RAM device can hold at once, the panel is only 240×320, and every millisecond and every byte of RAM is spoken for. The renderer (obc-render) is the machinery that turns a map far larger than memory into one frame, without allocating a single byte on the heap, and it runs byte-for-byte identically on the desktop simulator and on the device.

This page walks one frame from map bytes to lit pixels.

One render path, two surfaces

The whole renderer is a single no_std, zero-allocation crate. The only things that differ between the desktop and the device are where the pixels go and what colour they end up — and both are injected as parameters, so the drawing code never knows which machine it's on.

MapRenderer::render(target, scene, vp, bg, color_fn)
//                  │       │       │   │   └ RGB565 → this panel's pixel
//                  │       │       │   └ the backdrop colour
//                  │       │       └ the camera (Viewport)
//                  │       └ a streamed map scene
//                  └ where pixels land (a DrawTarget)
Everything device-specific lives at two seams obc-render one no_std code path · zero-alloc scene · viewport · bg → DrawTarget where pixels go color_fn RGB565 → pixel obc-sim RGB888 framebuffer true colour device LS021B7DD02 panel 64 colours (RGB222)
The renderer is generic over a streamed map scene as well as a DrawTarget (the pixel sink), and takes a colour function (RGB565 → the panel's native pixel). The normal scene is the OBCM reader's thin adapter; tests can supply static geometry. The concrete source and pixel target are monomorphised, so identical geometry code runs between hosts without per-feature dispatch.

The base-map input is the allocation-free obc-map-scene seam: LOD and style metadata, a visible-candidate visit, complete selected-feature decode into caller-owned scratch, and optional source counters. It exposes no OBCM offsets, quadtree records, cache slots, or retained scene graph. The production Reader adapter keeps streaming the same chunks through the same cache; its opaque six-byte candidate token replaces the same six bytes the renderer's stub formerly devoted to chunk and offset, so neither the 14-byte slot nor resident RAM grows.

Styles in the map store device-independent RGB565; the host's color_fn resolves each to a concrete pixel — true colour in the simulator, 64-colour RGB222 quantisation on the device. Because of these seams, the simulator you can run in your browser is not a mock-up: it is the device's exact rendering code, so the two can never drift apart.

The frame, end to end

Here's the whole journey before we slow down for each step: seven stages, most of them cheap, two where the real work happens.

One frame, start to finish map + route bytes 1 Project camera → px 2 Pick LOD for this zoom 3 Quadtree cull visible chunks 4 Priority decode fill the buffers 5 Painter sort by z-index 6 Rasterise fill + stroke 7 Overlays route · you panel
Stages 1–3 and 5 only touch lightweight index data — they're cheap. The two coral waypoints, priority decode and rasterise, are where the time goes, so the rest of this page spends most of its words there.

The orange waypoints matter for a reason worth stating up front: the cheap stages (walking the tree, scanning headers) are allowed to run more than once per frame, because re-running them is nearly free. The expensive work — decoding a feature's coordinates, and rasterising pixels — happens at most once per feature per frame. Keep that asymmetry in mind; it explains several design choices below.

1 · Projection: ground to screen

The camera is a Viewport — a centre point in microdegrees, a zoom, an aspect correction for the latitude, and a rotation. Its hot path is to_screen, called once per vertex, so it's written to keep full precision while staying fast:

let delta_lon = lon.wrapping_sub(self.cam_lon);   // i32 µdeg, relative to camera
let delta_lat = lat.wrapping_sub(self.cam_lat);
let ex = (delta_lon as f32) * self.aspect;        // squash longitude by cos(lat)
let ny =  delta_lat as f32;
let rx = self.cos_c * ex - self.sin_c * ny;       // rotate to heading-up
let ry = -self.sin_c * ex - self.cos_c * ny;
let x = rx * self.zoom + self.w / 2.0;            // scale, centre
let y = ry * self.zoom + self.h / 2.0;
(round_coord(x), round_coord(y))                  // round to nearest — a branch + add,
                                                  // no soft-float roundf on the hot path
Ground · µdeg camera P Δ (lon,lat) Screen · px N P ① Δ vs camera ② × cos(lat) ③ rotate ④ × zoom ⑤ round
Only the small delta from the camera is cast to f32, so absolute microdegree precision is preserved no matter where on Earth you are. Longitude is squashed by cos(latitude) so things stay the right shape, then the whole view is rotated for heading-up navigation.

Two small decisions there carry weight. Keeping the delta in integer microdegrees until the last moment means precision doesn't degrade far from the origin. And rounding to nearest (rather than truncating) is symmetric about the camera — truncation biases toward the screen centre, which, combined with how chunks are clipped, used to crack open hairline gaps at chunk seams under rotation. The inverse, to_map, reuses the very same coefficients because the screen↔ground rotation is its own inverse, so panning and the visible-area computation need no extra trigonometry.

2 · Level of detail: pick the right layer

A map at riding zoom and the same map zoomed out to the whole region are not the same data drawn smaller — they're different, pre-simplified layers. An OBCM map is a pyramid of level-of-detail (LOD) tiers, each with its own simplified geometry and a maximum meters-per-pixel it's good for. Zooming out reads a small coarse layer instead of decimating fine geometry on the fly.

LOD pyramid — coarse to fine coarse fine LOD 0 · coarsest covers any zoom LOD 1 good to ≤ 16 m/px LOD 2 good to ≤ 4 m/px LOD 3 · finest good to ≤ 1 m/px this view 0.5 m/px
The renderer turns zoom into meters-per-pixel — independent of screen size, so a desktop window and the 240 px panel showing the same ground span pick the same tier — then scans for the finest LOD whose range still covers it. Here 0.5 m/px lands on LOD 3; the coarsest tier covers any zoom, so a choice always exists.

The selection itself is a short scan — keep the finest tier whose range still covers the current resolution:

pub fn select_lod_for_mpp(&self, mpp: f32) -> usize {
    let mut chosen = 0;
    for (i, lod) in self.lods.iter().enumerate() {
        if lod.max_mpp >= mpp {
            chosen = i; // a finer tier still covers this zoom — prefer it
        }
    }
    chosen
}

Because meters_per_pixel depends only on zoom and latitude, not on the display, the device and the simulator agree on the LOD for any given view.

3 · The quadtree cull: only the chunks you can see

Within an OBCM source's chosen LOD, geometry is bucketed into fixed-size chunks, indexed by a quadtree over the map's bounding box. The reader adapter walks that tree from the root, descending only into children whose box intersects the view, and streams every non-empty leaf's candidates through the scene seam. The renderer sees candidates, not the tree or chunks that produced them.

Descend only where the view reaches NW skipped SW skipped NE SE view root NW NE SW SE visited leaf → a chunk to draw skipped — bbox misses the view a high bit marks a branch; a sentinel marks an empty leaf
Children split NW · NE · SW · SE at floor-division midpoints — identical math at every level. Here the view straddles the NE/SE boundary, so the walk descends into both — and within each it visits only the sub-cells the view actually touches, pruning the other two along with the whole NW and SW quadrants. The walk reads only u32 index nodes (no geometry), so it's cheap to re-run, and it's uncapped — a wide view can overlap hundreds of leaves and every one is visited.

The walk is a recursive descent that prunes whole subtrees by bounding box and reads the index as raw bits — a high bit flags a branch, a sentinel marks an empty leaf (walk_leaves):

if idx >= lod.node_count || depth > MAX_DEPTH || !node.intersects(view) {
    return Ok(());                   // prune: out of range, too deep, or off-screen
}
let val = self.read_node(lod, idx)?; // medium/cache failures stay distinct
if val & BRANCH_BIT == 0 {
    if val != EMPTY_LEAF {
        visit(val, node);            // a non-empty leaf = a chunk to decode
    }
    return Ok(());
}
// branch: child must advance (child > idx) — reject a corrupt back-reference —
// then split `node`'s bbox into NW/NE/SW/SE and recurse into each child

The depth > MAX_DEPTH bound and the child > idx check are pure robustness. A well-formed tree is only ~30 levels deep and always stores a branch's children after it, so neither ever fires on a real map — but a truncated or hostile .obcm off the SD card could otherwise point a branch back at itself and drive the walk into unbounded recursion. On the MCU there's no MMU guard page, so that's a stack overflow straight into a HardFault; bounding the depth makes the walk safe on any bytes. (This caps recursion depth, not the number of chunks visited — a different axis from the next paragraph.)

That "uncapped" property is load-bearing. An earlier version capped the visited chunks at a fixed number; a wide zoomed-out view overlaps far more leaves than the cap, so it silently dropped half the map before any importance logic could weigh in. Streaming the leaves through a callback instead means the decision about what to drop belongs entirely to the next stage — where it can be made by priority, not by accident.

4 · Decode by priority

Here is the central problem. A dense view holds far more geometry than the fixed frame buffers can hold. When they fill up, something must be dropped — and the dropped things must be the least important features, globally, no matter which chunk they live in. You never want to drop the coastline or a motorway because an unimportant forest patch in an early chunk got there first.

Two mechanisms work together to solve this within the memory and time budget.

Priority, and cheap skipping. Each feature's style carries a 2-bit priority (1 = keep first … 4 = drop first) — the axis the drop decision turns on. And a feature is cheap to step over: its header is a fixed 12 bytes, so the reader can advance past a feature it doesn't want with pure offset arithmetic — no coordinate math, no buffer writes. That skip primitive is what lets the collector touch a chunk's bytes selectively: past features whose style isn't drawn at all, and — the payoff below — straight to the handful of winners it must re-decode.

Pass B — the source resolves each opaque winner token win skip skip win skip skip win end read head jumps offset → offset → re-decoded winners (coral) cost coordinate math; the features between them cost only a pointer add.
A feature header is 12 bytes, so skipping is pure offset arithmetic inside the OBCM adapter. Pass A saves an opaque source token in each winner's stub; pass B gives it back to the source, which seeks straight to the feature — re-decoding only survivors without exposing byte offsets to the renderer.

Stub-select. The global-priority drop is easy to state and hard to do cheaply, because the device streams chunks off the SD card through a cache that holds one at a time. An earlier design made four passes over the visible chunks — one per priority level — filling the buffers level by level. That kept the guarantee, but it re-read every visible chunk four times; the one-slot cache absorbed none of it, so a wide view cost 4 × N chunk reads off SPI SD and the frame crawled. The fix (issue #564) splits selection from geometry:

let candidates = self.collect_stubs(scene, lod, view, &vis_mask, stats); // pass A
let winners    = self.select(scene);                                     // RAM only
self.decode_winners(scene, lod, view, winners, stats);                   // pass B
  • Pass A asks the scene source to visit visible candidates once, decoding every drawn feature just far enough to get its bounding box, and records a fixed-size stub — style, opaque token, vertex/ring counts — but keeps no geometry. When the stub buffer fills, the lowest-priority stub is evicted, so it always holds the best candidates.
  • Select is pure RAM: sort the stubs by priority and admit them greedily against the exact point/span budget. Drops are strictly lowest-priority-first, globally — the same guarantee as before, now with the exact vertex cost of every candidate known before a single coordinate is copied.
  • Pass B returns the winning tokens to the scene source, which preserves its natural chunk-major walk and re-decodes only those winners directly into caller-owned scratch. In the OBCM adapter, only chunks that own a winner are re-read.

There are two deliberately separate kinds of degradation here. A frame-budget drop happens only after a complete feature produced a stub; it follows the deterministic priority policy above and increments features_dropped. A decode failure never produces partial geometry: an over-capacity feature is consumed and dropped whole, malformed feature bytes are rejected, structural map/index corruption is distinguished from them, and medium/cache failures remain typed. The renderer counts these separately as capacity drops, malformed features, structural-map failures, read failures, and cache contentions. Pass A simply omits a failed feature's stub and continues when its byte extent is known. If a winner refetch or the second index walk fails in pass B, the collector rolls back any point/ring prefix and compacts only successfully decoded spans; failed placeholders and untouched stubs never reach the painter.

A feature that survives is decoded twice (cheap, in RAM); a chunk is fetched at most twice instead of four times, so the SD traffic that dominates a wide frame roughly halves. The stubs live in the same buffer the spans end up in — a stub is sized to fit a span slot — so the split costs no extra RAM.

Lowest priority is dropped — globally, by construction P1sea·land·motorway P2major roads P3minor roads P4buildings·detail frame buffer (fixed) P1 P2 P3 FULL P4 — dropped the buffer filled before priority 4 fit — exactly the right things to lose
Select admits stubs priority-1 first, so when the budget saturates the undrawn remainder is strictly the lowest priority, across all chunks. Only the survivors' geometry is ever built — a dropped feature cost only its stub, never a copied vertex.

Every kept feature becomes a 14-byte span — a compact draw record that says what and where without copying the geometry again (Span):

struct Span {
    kind: Kind,      // polygon or line
    z: i8,           // paint order
    weight: u8,      // nominal line width — ramped by zoom at draw (see §6)
    style_id: u8,    // → the full Style (dashed / color2) at draw time (a spare byte)
    color: u16,      // RGB565
    pt_start: u16,   // where its points sit in the frame buffer
    ring_start: u16,
    ring_count: u16,
    seq: u16,        // collection order — the stable-sort tiebreak
}

Thousands of spans can be buffered at coarse zoom, so they're kept small (u16 offsets, not usize). The two chunk walks cost little on their own — the leaf walk reads only the cheap index — and the expensive part, copying vertices into the frame buffers, happens once, for the winners alone.

5 · Painter's order

With the visible features collected, the renderer sorts the spans — not the geometry, just the little records — into back-to-front draw order:

self.frame.spans_mut().sort_unstable_by_key(|s| (s.z, s.seq));

Each style carries a z_index; sea draws under land draws under forest draws under roads. Ties break on seq, the order the feature was collected in — a stable, allocation-free tiebreak so the result is deterministic. Note that priority and z-index are different axes: priority decides whether a feature survives the memory budget; z-index decides where in the stack the survivors are painted.

6 · Rasterising: fills and strokes

The draw loop walks the sorted spans and dispatches on kind — polygons fill, lines stroke.

Polygons: even-odd scanline fill

Each polygon is filled with a classic scanline algorithm. For every screen row the polygon covers, the renderer collects where the row crosses each edge, sorts those crossings, and fills between consecutive pairs. This is the general filler — it makes no assumption about the shape, so it handles arbitrary polygons, concave outlines, and holes. (Thick line strokes reuse the same span contract but skip most of this machinery through a convex fast path — see Lines below.)

Even-odd rule — holes fall out for free scanline y + 0.5 x0x1x2x3 fill (x0→x1) skip (x1→x2) the hole fill (x2→x3) A hole is just another ring in the same crossing list — no special case needed.
Holes cost nothing extra: they're additional rings whose crossings join the same sorted list, so the even-odd pairing leaves them empty automatically. Each span is rounded outward (left floored, right ceiled) so adjacent fills overlap by at most a pixel — cheap insurance that closes hairline cracks at chunk seams.

The fill leans on one DrawTarget primitive: a fast clipped horizontal-rectangle blit, one per span. There's no per-pixel plotting in the hot loop. A row whose crossing list would overflow its small fixed buffer is skipped entirely rather than filled from a truncated list — keeping the even-odd parity correct is more important than one stray row.

Lines: clip first, then stroke

Lines are where a naïve approach gets expensive. A loaded route or a long road is mostly off-screen at riding zoom, and a thick-line rasteriser that walks the whole polyline pays for every off-screen pixel. The renderer's line path is built to never pay for what it can't see.

Clip to the view, then smooth the joints the whole route view only the clipped part is stroked butt-jointed rects (notch) + round-join / cap discs a disc (⌀ = width) at corners + run ends → smooth arc, no gaps
Each segment is clipped to the view (grown by the stroke's half-width so edge-hugging lines keep full thickness) before it's drawn, so the stroker only ever touches on-screen pixels. The points are also deduplicated in screen space at a subpixel tolerance — folding away the integer-projection staircase — so a dense line hands the stroker far fewer segments without ever shifting a visible pixel.

A 1 px line is stroked by embedded-graphics directly — a thin Bresenham line it draws cheaply, and the one width the span fill below can't (a zero-width rectangle has no scanline crossings). Everything else — width-2 roads on up, plus the route and breadcrumb — goes through the same span contract as the polygons, because a per-pixel thick-line rasteriser generates every stroke pixel one at a time, and that generation was measured to dominate the overlay (~10× a span stroke even at 2 px). Each segment becomes a rectangle — the segment swept ±half-width along its perpendicular — filled row-by-row as one fill_solid span apiece. A swept rectangle is always convex, crossing any row exactly twice, so a specialized quad filler keeps the row's leftmost and rightmost crossing directly instead of running the general filler's crossing buffer and per-row sort. The pixels are byte-for-byte identical to the general even-odd filler on the same quad — same half-open edge rule, same crossing math, same outward rounding — and if rounding ever collapses a degenerate quad into something that crosses a row more than twice, that row falls back to the exact sort-and-pair, so the fast path can never disagree with the general one.

Two filled rectangles butt-joined at a vertex leave a small notch on the outside of a bend and don't round the line's ends, so a disc the width of the stroke is filled (also as spans) to smooth each joint and cap. The disc is the overlay's biggest remaining cost, so it's spent only where it shows: at the two run ends (which round the cap and close the gap to the next feature at a chunk seam) and at interior vertices where the line bends sharply. The uncovered notch at a turn of θ off straight is only about r·sin(θ/2) deep (r = half the stroke width), so a gentle bend leaves a sub-pixel notch and needs no disc at all.

The half-width r above is not the style's stored weight directly — a line's on-screen thickness ramps with zoom. A style's weight is its width at a reference ground scale (mid-riding zoom); each frame the renderer multiplies it by (ref_mpp / mpp)^0.6 — one factor for the whole frame — so a road thickens as you zoom in and thins as you zoom out, the way a physical thing looks closer or farther. The exponent is deliberately sub-linear: true physical scaling (^1) would make every road sub-pixel at continent zoom and let one motorway swallow the panel up close, so the ramp is clamped to 1…12 px and rounded to whole pixels (the map zooms in fixed ×1.2 steps, so the width steps cleanly with no shimmer). This also rights the cost curve — a wide overview, where hundreds of roads are on screen and the frame budget is tightest, now strokes each at 1–2 px instead of its full authored weight; the fat strokes happen only zoomed in, where a handful of features are visible. Dashed lines (admin borders, railway stripes) ride the same ramped width, so their dash rhythm tracks the line's thickness rather than fighting it.

A second colour and a dash: line styles

Everything above strokes a line in one flat colour. But a railway isn't flat, an admin border is dashed, a road at riding zoom wants a darker casing down each side so it reads as a road and not a scratch, and a building at the finest zoom wants a crisp wall instead of a grey slab. Version 10 of the map format gives every style two extra knobs — a line_style (solid or dashed) and an optional secondary colour color2 — and the renderer fans those two bits, crossed with whether the feature is a line or a polygon, into five distinct looks:

Featureline_stylecolor2Renders as
Linesolida flat stroke — the unchanged path
Linesolidsetroad casing — a wider color2 base under the fill (finest LOD only)
Linedasheddashes in color, gaps transparent (admin borders)
Linedashedsetrailway stripe — a solid color2 base with color dashes on top
Polygon(ignored)setfill in color, plus a color2 ring outline (finest LOD only)

Each span already carries its one-byte style_id, so the draw loop re-resolves the full style from the scene source's O(1) style table at draw time — no extra per-span RAM. Two of the five (casing, outline) are extra passes that cost real time, so they're gated to the finest LOD; the other three ride the existing stroke path. A config that uses none of them renders byte-for-byte what it did before — the whole feature is built to be free when unused, and each renderer sub-issue proved it with a before/after PNG md5 diff on a fixed camera.

Dashes, for free. A dashed line reuses the entire clip-then-stroke pipeline unchanged and diverges only at the very end: instead of filling the whole visible run, it walks that run in screen-space arc length and emits only the "on" intervals. Because the clip happens first, the walker only ever sees on-screen geometry — so a dashed line is actually cheaper than the solid one, not dearer: it clips away the same off-screen majority, then paints only half of what survives. The phase resets at each clipped run (each time the line re-enters the view), which lets a dash straddle a bend seamlessly but also means the pattern can "crawl" a pixel or two as you pan — every slippy map does this, and it isn't worth carrying feature-space arc length to avoid. A railway stripe is just this composed with a base: stroke the whole line once in color2, then stroke color dashes on top, so the gaps between the dark dashes show the light base through — alternating stripes, no perpendicular crossties.

Road casing, and the z-boundary that makes junctions work

A casing is a weight + 2 px stroke in color2 painted under a road's fill, giving the road a darker edge down each side — the OSM-carto / Komoot "roads have borders" look. The subtlety isn't the stroke; it's when to paint it. Paint all the casings first, before the map, and the low-z fills that blanket a town (landuse, forest, water) paint straight over them — the casings vanish. Paint each casing right before its own road, and where two roads cross, one road's casing slices across the other's fill and the junction reads as cut.

The spans are already sorted by (z, seq), so the cased road lines form one contiguous z-band. The casing pass is inserted at exactly the z boundary where that band begins — a split index into the sorted array, not a re-sort — so the casings land above the low-z fills that would erase them yet under every road fill.

The casing pass goes at the z boundary — not before the frame spans · sorted (z, seq) · drawn bottom-up z ↑ · paint order ↑ water landuse buildings casings road fills ③ spans[split..) — road band, on top ② casing pass — wide color2, finest LOD split — first cased road line ① spans[0..split) — the base pass at a crossing fills continuous through the junction casing hugs the outside of each road only
Because the spans are already (z, seq)-sorted, the cased road lines are a contiguous band; the draw phase draws everything below that band, then runs the casing pass, then draws the road band on top. So a casing survives the landuse/water fills below it, yet sits under all the road fills above it — at a junction (right) the fills stay continuous and no casing slices across the crossing. With no cased style the split is the whole array, the casing pass is empty, and ①+③ collapse to today's single pass; coarser LODs skip the pass outright.

Building outlines: all the fills, then all the walls

A polygon whose style carries a color2 gets every ring — its exterior and any courtyard holes — stroked closed in that colour at the finest LOD, so a building stops reading as a flat grey slab when you zoom in. Here too the interesting part is ordering. Touching row-house buildings share a wall, so if you outline each building right after its own fill, the next building's fill paints over the wall the previous one just drew — the terrace merges into one blob.

The fix reuses the same (z, seq) sort. Within each contiguous equal-z group the draw loop runs two passes: all the fills first, then all the outlines. Nothing paints a fill after an outline within the group, so a neighbour can no longer erase a shared wall — both buildings stroke that edge, and it survives. The group closes before the next z begins, so a road at a higher z still paints over a building outline where it crosses.

Outline after every fill in the z-group, so shared walls survive per feature outline after each fill ① fill + outline A B's fill erased A's shared wall ② B's fill lands ③ one merged blob ✗ per z-group all fills, then outlines ① all fills first ② all outlines the shared wall is drawn after every fill → two crisp buildings ✓
Both shared-wall neighbours sit in the same z-group (buildings share a z), so drawing every fill before any outline is what keeps the wall. The outline is a fixed 1-px hairline, not the zoom-ramped stroke width: ramped, a closed ring hits 3–4 px at the sub-metre finest-LOD scale and its round joins flood a small footprint until the fill drowns and the building reads as a dark slab — the opposite of the goal. With no outlined polygon the group takes the single-loop path, byte-identical to before.

What the finest-LOD passes cost. Both casing and outlines run only at the finest LOD, so every coarser zoom — where the frame budget is already tightest — pays exactly nothing. Where they do run, the numbers (measured draw_us, dense street scenes) are modest: casing adds ~20–25% to the draw (at 4 m/px on a dense grid, ~170–179 µs climbs to ~206–250 µs for 152 cased roads; ~+55–80 µs at a busier zoom), because a casing is one extra wide stroke — wider than the fill it underlies, so its cost is the raster, not the re-projection. Building outlines are far cheaper still — ~7–9% of the casing pass (about +10 µs at 2 m/px, +25 µs at 4 m/px) — because a hairline ring is the thin Bresenham line path, a fraction of a filled stroke.

7 · The overlays

The map underneath is the base; everything that moves with you is drawn on top, after the map, in a fixed order:

  1. Route — the planned line (magenta), stroked through the same clip-then-stroke path, with direction chevrons laid down in a second sweep so they sit on top even where the route doubles back. Chevrons are spaced by ground distance derived from a fixed pixel cadence, so they stay evenly spread as you zoom and stay pinned to the ground as you pan. The Detour chooser suppresses the chevrons and streams only its skipped interval again as a narrower warning-orange stroke — and on the Detour preview the planned detour's decimated polyline is stroked in blue on top; the magenta edges remain visible and no second route copy is retained.
  2. Breadcrumb — the recorded trail behind you (navy), a two-tier coarse-spine-plus-recent-tail path.
  3. Waypoints and markers — route waypoint diamonds, then the rider's course-pointing chevron (or stationary diamond), followed by the Detour flow's fixed-size rejoin ring when its chooser or preview is open.
  4. HUD chrome — the off-route readout and pan-mode indicators, or the Detour flow's floating distance/cost panel.

Each is just another polyline through the stroker or a triangle through the polygon fill — and since a thick stroke is itself rectangles and discs filled as spans, nearly everything on screen comes down to the one scanline span fill, reused.

To the panel: the banded push

Everything above is shared — byte-for-byte identical on the simulator and the device. This last step is where the transport parts, because the device has neither the memory nor the display hardware a desktop takes for granted. The device has 512 KB of RAM and no external memory, and no scan-out engine that would stream a framebuffer to the panel on its own. So it does two things a PC never has to: it draws into a device-native RGB222 framebuffer, and then it ships that framebuffer to the panel itself, a strip at a time. The simulator's interactive window is the second backend of the very same display contracts: it renders into the identical RGB222 framebuffer and runs the identical self-diffing present, differing only in the final hop — it uploads the changed rows to an egui texture instead of scanning them to a panel. (The headless --png dump is the one path that still writes a full true-colour framebuffer in one blit — the un-quantized reference.)

The RGB222 framebuffer

The renderer draws into a single resident RGB222 plane: one byte per pixel over the 240×320 panel — 75 KB, the whole frame held in .bss. Each byte is 0b00_RR_GG_BB, the top two bits of each channel: the 64-colour gamut the style table is tuned to. The renderer's color_fn is the identity (styles are already RGB565) and the framebuffer quantises to those 64 colours on store — so the expensive geometry code from sections 1–7 is exactly the simulator's, and only the pixel sink differs. This is the DrawTarget seam from the top of the page, realised for the device.

A band at a time (the bring-up path)

A finished frame now sits in RAM, but nothing is putting it on glass. During bring-up the firmware did it in software over SPI: walk the framebuffer top to bottom and DMA it to a stand-in panel a band of a few rows at a time. That band push was the visible refresh — a top-to-bottom wipe you could watch sweep down the panel. The shipping device hands that same job to the FLPR coprocessor (below), which scans the framebuffer itself; the banding picture still frames the shape of the problem — get a resident frame onto a panel with no host-side scan-out engine, a strip at a time — so it's worth seeing first.

One finished frame → the panel, a band at a time RGB222 framebuffer 240×320 · 75 KB · .bss pack → RGB444 2 px → 3 bytes band scratch ≈ 7 KB · reused ×23 SPI · DMA CASET/RASET ST7789 · bring-up addressed window top → bottom
The seam that hides the panel's wire format is the presenter half of the display contracts (in obc-display): the renderer draws each band through a frame-absolute Band view, and the backend reformats + transports it. The band scratch is only a few rows, reused for every band, so the frame lives once in the RGB222 plane and never as a second full RGB565 copy.

The wire format lives behind the board-agnostic display contracts (in obc-display), so the render stack never couples to it — and the seam has two live implementations keeping it honest: the LS021/FLPR panel on-device and the simulator on the host, the latter compiled and tested in the workspace on every CI run. The original bring-up stand-in was an ST7789 over SPI: each band was packed to the panel's 12-bit RGB444 format — two pixels into three bytes, ~25% fewer bytes than RGB565, and the RGB222 gamut survives 4-bit channels losslessly — then a CASET/RASET window addressed and the bytes streamed by DMA. Because the scratch was just a few rows (~7 KB), the 320-row frame tiled through it in ~23 pushes; the frame itself never got a second full-frame buffer. The shipping panel drops the band scratch entirely — the FLPR packs each line straight from the resident frame.

That seam is two separable contracts, spelled out by the generic display contracts: a native-frame format — geometry, the device's own pixel storage, and a DrawTarget writing straight into the backing — and presenter capabilities — presenting the clean resident frame, and compositing a bounded transient overlay over it (next section). Rust's borrows encode when render and present may touch the bytes: rendering borrows the frame mutably, a base present shares it for the whole scan, and the overlay present borrows it mutably for its composite-and-restore. What counts as a changed region deliberately belongs to the presenter, not the contract: the per-row hashing and span masking below are the LS021 pairing's own damage strategy, and a different panel could diff tiles or lean on a controller's dirty window without the render stack noticing.

The panel the device ships on is a reflective memory-LCD (LS021B7DD02-class MIP), driven by the nRF's FLPR coprocessor — the only display path. The FLPR scans the frame top-to-bottom in one pass: the M33 renders into the RGB222 plane, publishes the dirty-row list, rings a doorbell, and awaits the coprocessor's end-of-frame interrupt — the FLPR reads the framebuffer directly out of shared SRAM and packs each line to the panel wire itself, so the present costs the M33 almost nothing and it is free to run storage or sensor work for the whole scan. A full MIP frame is still ~44 ms, so the present doesn't rewrite the whole frame when it needn't: it keeps a per-row hash of the last-pushed frame, re-hashes on each present, and drives a span-masked scan over only the rows whose hash changed — the FLPR fast-forwards its gate over the unchanged rows and stops early after the last, so frame cost scales with changed rows, not a flat 320.

The screens never say where they changed — they stay immediate-mode, clearing and redrawing the whole frame — so the present detects the changed region automatically: a Home clock ticking a minute re-hashes to find just its clock band and repaints that (~44 ms → a few ms), with zero per-screen code. A collision — a changed row hashing equal, so skipped — is ~2⁻³² per row-change and self-heals the next time the row changes; the simulator runs an exact full-frame diff as a CI oracle. (That oracle earned its keep once: a word-folded FNV variant had a structural blind spot for changes confined to every fourth pixel column, caught the moment a demo parked on a static screen — each word is now avalanche-mixed before it meets the accumulator, issue #626.) That's render-on-demand carried onto the glass; the overlay below rides the very same masked scan.

Redraw the whole frame — push only the rows that changed ① the frame clock framebuffer screen redrew all 320 rows hash each ② the diff hash = stored → skip hash ≠ stored → span (y₀, 3) 32-bit hash per row 320×u32 = 1.28 KB store span list (start, count) ③ the push to glass 3 rows pushed, rest retained fast-forward the gate write the span stop early — rest not scanned one-minute clock tick: ~44 ms full frame → a few ms
Screens stay immediate-mode — they clear and redraw the whole frame, never declaring where they changed. The present works it out: one 32-bit hash per row (320×u32 = 1.28 KB) compared against last frame, the changed rows coalesced into a span, and one masked FLPR scan that fast-forwards over the unchanged rows and stops early. A minute's clock tick repaints a few rows, not a full ~44 ms frame — the rest of the picture is retained on the glass.

The overlay composites on the push

The hold-progress bulge — the little arc that swells as you hold a button — is never baked into the framebuffer. If it were, dismissing it would force a full map re-render just to paint it back out. Instead it composites on the push: over a static map the input plane re-presents only the region the bulge occupies, reading the clean framebuffer back as the backdrop and drawing the bulge over it — the resident frame stays the untouched map. The MIP/FLPR addresses that region in its native grain: the bulge's rows, through the span-masked scan above (fast-forward the gate to them, write just those, early-stop). And when the map does redraw mid-animation, the present goes around the bulge's rows so it never flashes off — the display seam's present takes the live bulge span as an exclude and clips its push with the same shared span logic (issues #163, #345). Either way the map underneath is never touched and the expensive render_map stays asleep — render-on-demand taken to its limit: repaint the region that changed, and nothing else.

Zero allocation, by budget

Everything above happens in fixed-size buffers owned by the renderer and cleared, not freed, each frame — so steady-state rendering does no heap work at all, which is what makes it safe on a microcontroller. The capacities are tuned for a 512 KB-RAM device and checked at compile time:

BufferHoldsCapacity
frame_pointsevery visible feature's vertices, concatenated12 288
frame_ring_lensper-feature ring lengths3 072
spansper-feature 14-byte draw records3 072
dec_pointsone feature's vertices during decode2 048
screenprojected points for the feature being drawn4 096
xsscanline crossings for one row256

A compile-time assertion fails the build if the renderer's total buffer footprint grows past its RAM budget — so you can't accidentally blow the memory ceiling by bumping a constant. The frame buffers are the reason the collector drops by priority: they're deliberately too small for the densest views, and the global priority order is what makes "too small" degrade gracefully instead of catastrophically.


Where this lives

For how this renderer gets driven — the camera, the screen stack, and the per-frame loop — see system architecture. For the map format it reads, see data formats.