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)
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.
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
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.
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.
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.
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.
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.)
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.
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:
| Feature | line_style | color2 | Renders as |
|---|---|---|---|
| Line | solid | — | a flat stroke — the unchanged path |
| Line | solid | set | road casing — a wider color2 base under the fill (finest LOD only) |
| Line | dashed | — | dashes in color, gaps transparent (admin borders) |
| Line | dashed | set | railway stripe — a solid color2 base with color dashes on top |
| Polygon | (ignored) | set | fill 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.
(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.
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:
- 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.
- Breadcrumb — the recorded trail behind you (navy), a two-tier coarse-spine-plus-recent-tail path.
- 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.
- 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.
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.
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:
| Buffer | Holds | Capacity |
|---|---|---|
frame_points | every visible feature's vertices, concatenated | 12 288 |
frame_ring_lens | per-feature ring lengths | 3 072 |
spans | per-feature 14-byte draw records | 3 072 |
dec_points | one feature's vertices during decode | 2 048 |
screen | projected points for the feature being drawn | 4 096 |
xs | scanline crossings for one row | 256 |
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
- The renderer and all its rasterisers:
obc-render/src/— the frame loop and buffers inlib.rs; projection, collection, stroking, polygon fill and the overlays inviewport.rs/collect.rs/stroke.rs/fill.rs/overlay.rs - The streamed scene contract:
obc-map-scene/src/lib.rs; the production OBCM adapter:obc-reader/src/scene.rs - The map parsing, quadtree walk, and skip-don't-decode:
obc-reader/src/reader.rs
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.