← Back to list

Draw animations on an itty bitty computer with C++ (Part 1)

Amirali Monjar · 2026-06-05 12:51 · 0 claps · 8.6 min read
#cpp #robotics #animation #threads #memory-management
Open on Medium ↗
Wiki topics: BIZ · Business Strategy 🎬 · Film & Television

Draw animations on an itty bitty computer with C++ (Part 1)

[embed]

Mochiro is my little robot companion, and I wanted it to have an expressive face: eyes that blink, squint, and go wide. The catch is that it runs on a small single-board computer, and eventually I want parts of it running on even tinier microcontrollers. Animating a face on a device with about as much memory as a 1990s desktop turned out to be a genuinely fun problem, and this is the first of two posts about how I got there.

This part is the systems story: how the rendering, threading, and streaming fit together to keep the frame rate up. The second part digs into the C++ language tricks that squeezed out the last of the overhead.

The fact everything flows from

A face running at 60 fps has to produce a fresh image sixty times every second. The obvious way to picture that is “draw a full frame, hand it to the screen, repeat,” and on a desktop that’s fine. Here it’s fatal. A single 480×320 frame in full color is around 460 KB, and the smallest chip I’m targeting has 264 KB of RAM in total. The frame literally does not fit. So the entire design is organized around one idea: never build that full frame in memory if you can possibly avoid it.

The driver itself is a C++ module that I expose to Python through pybind11, and it renders with OpenGL ES 2.0 on top of SDL2. That’s the comfortable end of things, the version that runs on a Raspberry-Pi-class board. The interesting questions are how to keep that pipeline cheap, and then what’s left of it once you take GL away entirely and drop down to a bare microcontroller.

The conventional stuff, quickly

There are the usual optimizations you’d expect on tight hardware, and Mochiro uses all of them. The face is a small animated mesh rather than a folder of bitmaps, so memory never grows with the number of expressions and the GPU does the rasterizing for free. The per-frame vertex interpolation writes into a buffer I allocate once at startup, so nothing ever touches the heap inside the render loop. Only the vertices that actually moved get streamed up to a pre-allocated VBO with glBufferSubData, and the whole face draws in a single glDrawElements call. The shaders stay flat-colored and mediump to spare the little GPU's fragment throughput. And the binary is built with -Os, link-time optimization, and a -mcpu tuned to the exact chip. All of that is table stakes, though. The decisions that actually shaped this driver were the less obvious ones, and those are what the rest of this post is about.

Keeping the threads out of each other’s way

Rendering happens on its own thread, separate from the code that decides what the face should be doing. The two have to talk, and the whole trick is doing it without either one ever blocking on the other, because a render thread that waits is a render thread that stutters.

For the small signals, like which expression to show next, an atomic integer is all it takes. One thread writes it, the other reads it, and nobody locks.

// The integer IS the whole message, so relaxed ordering is enough.
static std::atomic<int> g_target_expression{EXPR_NEUTRAL};

int want = g_target_expression.load(std::memory_order_relaxed);
if (want != g_current_expression) begin_transition_to(want);

The relaxed there is deliberate, and it's worth understanding why it's safe, because the next case is exactly where it stops being safe. Relaxed ordering gives you an atomic read with no tearing, but it makes no promises about any other memory. That's fine here, because the integer is the entire message. There's no second piece of data the reader has to see in step with it.

That guarantee falls apart the moment the thing you’re handing over is bigger than a single value. Every so often Mochiro loads a whole new face geometry from disk, and I build it on a background thread so the render loop never hitches. Publishing it looks like a one-liner, “just flip a pointer,” but that phrase quietly hides two genuinely hard questions.

The first is ordering. The render thread has to see a fully constructed object, never the new pointer arriving ahead of the writes that filled the object in. That is precisely what release and acquire are for: the builder writes the geometry, then stores the pointer with release, and the render thread loads it with acquire. The pairing guarantees that everything written before the release is visible after the acquire. A relaxed store here would be a real bug, the nasty kind that passes every test on x86 and then corrupts frames on ARM.

The second question is the one most “just flip a pointer” explanations skate right past: who frees the old geometry, and when is that actually safe? You can’t delete it the instant you swap, because the render thread might be halfway through a frame still reading it. The clean answer for a single-producer, single-consumer setup is to make ownership unambiguous. The render thread is the only code that ever dereferences the live geometry, so it is the only code allowed to free it, and it only does so after it has already moved on to the replacement.

static std::atomic<FacePose*> g_pending{nullptr};   // builder -> renderer
static FacePose* g_active = /* the initial face */;  // render thread owns this
// Builder thread, after constructing `fresh` off to the side:
FacePose* dropped = g_pending.exchange(fresh, std::memory_order_release);
delete dropped;   // a previous pending the renderer never took; safe to reclaim here
// Render thread, once per frame:
if (FacePose* incoming = g_pending.exchange(nullptr, std::memory_order_acquire)) {
    delete g_active;       // safe: this thread is the only reader, and it's done with it
    g_active = incoming;
}

The exchange on both sides is what keeps this honest. Whichever thread swaps a pointer out becomes responsible for whatever it got back, so the old object is freed exactly once, by exactly one thread, with no window where the other could still be reading it. No locks, no garbage collector, no reference counting, just ownership that's clear at every step. That, rather than the atomic-integer flag, is the part of threading actually worth knowing.

Streaming the face out without paying for it

Mochiro can stream its face over the network as MJPEG, which is handy for watching it from a laptop while I debug. The trap is that doing this on the render thread will quietly destroy your frame rate, because both of the steps it needs are expensive: reading the rendered pixels back off the GPU, and compressing them to JPEG.

The real offender is glReadPixels. It forces the GPU to finish everything it's doing before it will hand the pixels over, which stalls the pipeline you worked so hard to keep moving. So I throttle the capture down to whatever a connected client actually needs, and I run the JPEG compression on a separate encoder thread. The render thread's only job is to drop the fresh pixels somewhere and get straight back to drawing. The compression is cheaper than it sounds, for what it's worth: at 480×320, quality-70 JPEG frames land around 5 KB each, so a 60 fps stream is roughly 300 KB/s. Drop to quality 50 and it's nearer 180 KB/s, which is what I use over flaky WiFi.

The handoff between the grabber and the encoder is where it gets subtle, and it’s easy to get wrong. Three buffers feels like the obvious answer, but three buffers on their own do not make it safe. If the only thing you track is “which slot is newest,” the grabber has no idea which slot the encoder is currently reading, so it eventually cycles back around and overwrites that slot mid-read, and you get a torn frame. The fix is to have the two threads exchange slot ownership atomically, so that the writer’s slot, the reader’s slot, and the slot in transit are always three genuinely different buffers.

// One atomic holds the "ready" slot index plus a bit marking it fresh. Each
// side also privately owns one slot. Because the three indices stay a
// permutation of {0,1,2}, the grabber and encoder never touch the same slot.
static constexpr unsigned kFresh = 0b100;   // "new frame ready" flag
static constexpr unsigned kIndex = 0b011;   // slot index in the low bits
static uint8_t g_slots[3][kFrameBytes];
static std::atomic<unsigned> g_shared{2};   // starts holding slot 2, not fresh
static unsigned g_write = 0;                 // grabber's private slot
static unsigned g_read  = 1;                 // encoder's private slot
// Grabber, after filling g_slots[g_write]:
void publish_frame() {
    glReadPixels(0, 0, W, H, GL_RGB, GL_UNSIGNED_BYTE, g_slots[g_write]);
    unsigned prev = g_shared.exchange(g_write | kFresh, std::memory_order_release);
    g_write = prev & kIndex;                 // reuse the slot the encoder handed back
}
// Encoder thread, before encoding:
bool acquire_frame() {
    if (!(g_shared.load(std::memory_order_acquire) & kFresh)) return false;  // nothing new
    unsigned prev = g_shared.exchange(g_read, std::memory_order_acquire);
    g_read = prev & kIndex;                   // take the freshly published slot
    return true;                              // now safe to read g_slots[g_read]
}

The release on the grabber and the acquire on the encoder are doing the exact same job they did in the threading section: making sure the encoder sees a fully written frame and never a half-copied one. It’s the same lesson, wearing a different hat.

The HTTP server in front of all this is hand-rolled on raw sockets. A real HTTP framework would be hundreds of kilobytes to do something MJPEG barely asks for: write a header, then keep emitting a boundary, a length, and a JPEG blob. Writing those few lines myself keeps the binary small and the dependency list empty, which counts as its own kind of performance on a device this tight.

Going smaller, when the GPU disappears

Everything so far assumes a board big enough to run real OpenGL ES. The far end of “small” is a microcontroller like the RP2040 in a Raspberry Pi Pico, which has no GPU, no Linux, no SDL, and only that 264 KB of RAM. The full-color framebuffer doesn’t even fit. So the useful question is which of these tricks were real principles, and which were just things the GPU happened to make easy.

The geometry-instead-of-pixels idea carries over completely, and it matters even more here. The catch is that without a GPU to rasterize for you, you now have to fill those shapes yourself, in software, which is real work rather than a freebie. The saving grace is that a cartoon face is mostly big flat regions, so a simple integer scanline fill, walking each shape’s outline and writing spans of solid color, is enough. It’s fiddly enough to deserve its own post, but it stays bounded and predictable in a way a general rasterizer isn’t, and that predictability is the whole point down here.

The framebuffer itself gets smaller the moment you switch from 24-bit color to 16-bit RGB565, which halves both the memory it takes and the bytes you have to push to the panel. On the smallest devices you don’t keep a whole framebuffer at all. You drive a small SPI display like an ST7789 or ILI9341 and only re-send the rectangles that changed. If just the eyes blinked, only the two little rectangles around the eyes go out, and they go out over SPI by DMA, so the single CPU core can get back to thinking while the hardware clocks the pixels.

The principle that becomes essential down here is getting rid of floating-point. The main board has an FPU, so all that float interpolation runs in hardware. A Cortex-M0+ like the Pico's doesn't, so every float multiply turns into a slow software routine. The fix is to rewrite the interpolation in fixed-point, where a value is just an integer scaled by a fixed factor and a "multiply" is an integer multiply followed by a shift.

// Fixed-point: 16.16 format. A "1.0" is (1 << 16). Integer math only,
// no FPU required, so this runs fast on an M0+ where floats would crawl.
typedef int32_t fix16_t;
#define FIX_ONE (1 << 16)

static inline fix16_t fix_lerp(fix16_t a, fix16_t b, fix16_t t) {
    // a + (b - a) * t, keeping everything in integer land
    return a + (fix16_t)(((int64_t)(b - a) * t) >> 16);
}

It’s the same blend as before, only wearing integer clothes. The shape of the solution didn’t change at all; only the arithmetic did.

Where this is going

Everything here is about the shape of the system. Keep the heavy frame out of memory, hand work between threads without anyone waiting or anyone reading a half-written buffer, touch only the pixels that actually changed, and be ready to throw the whole GPU away when the hardware gets small enough. That’s what got Mochiro’s face running smoothly on the hardware I had.

But a surprising amount of the remaining speed didn’t come from the architecture at all. It came from leaning on C++ itself, getting the compiler to do real work before the program even runs and shedding the hidden costs that ordinary abstractions quietly smuggle in. That’s Part 2.


메타데이터
post_id
907c8c9d524a
slug
draw-animations-on-an-itty-bitty-computer-with-c-part-1-907c8c9d524a
url
https://medium.com/@amirali.mnj/draw-animations-on-an-itty-bitty-computer-with-c-part-1-907c8c9d524a
canonical_url
https://medium.com/@amirali.mnj/draw-animations-on-an-itty-bitty-computer-with-c-part-1-907c8c9d524a
author_url
https://medium.com/@amirali.mnj
status
ok
fetched_at
2026-06-09 15:37:30