NFnexframe_
logs/027 · graphics

Building a Custom 2D Renderer in C: From Pixels to Sprites

Green phosphor text on an old computer terminal screen

Most engines hand you a draw_sprite() and hope you never ask what's underneath. This log opens the box. We'll build a small software renderer in C that starts from raw memory and ends with batched, alpha-blended sprites — the exact path the engine track walks, just condensed into one sitting. By the end you'll have drawn a sprite with code you fully understand, and you'll know why each line is where it is.

A framebuffer is just memory

Strip away the vocabulary and a 2D renderer draws into an array of pixels. One 32-bit integer per pixel, packed as ARGB, laid out row by row. Everything else — sprites, text, particles — is a fancy way of writing into that array before it hits the screen.

framebuffer.h
typedef struct {
  uint32_t *pixels;  // w * h, ARGB8888
  int w, h;
} Framebuffer;

Allocate w * h integers, keep the dimensions next to the pointer, and you have a canvas. To show it, you hand the pointer to whatever presents pixels — SDL's texture upload, a platform blit, or your OS window. The renderer itself never cares; it just fills memory.

Clip first, then write

The single most common bug in a hand-written renderer is writing outside the buffer. It doesn't crash politely — it corrupts whatever memory sits after your pixels, and you spend an evening chasing a "random" glitch. So the rule is boring and absolute: clip before every write.

draw.c — a safe pixel
void put_pixel(Framebuffer *fb, int x, int y, uint32_t c) {
  if ((unsigned)x >= (unsigned)fb->w) return;
  if ((unsigned)y >= (unsigned)fb->h) return;
  fb->pixels[y * fb->w + x] = c;
}

The cast to unsigned folds the two bounds checks into one each: a negative coordinate wraps to a huge positive number and fails the same comparison. Small trick, measurable win when you're calling it per pixel.

Blitting a rectangle of pixels

A sprite is a small framebuffer you copy into the big one. The naive version loops every source pixel and calls put_pixel. It works, but per-pixel clipping inside the inner loop is wasted work — the whole rectangle is either on-screen or partly off, so you can clip once and then copy without checks.

blit.c
// clip the rectangle once, then copy rows without checks
int x0 = dx < 0 ? -dx : 0, x1 = min(src->w, dst->w - dx);
int y0 = dy < 0 ? -dy : 0, y1 = min(src->h, dst->h - dy);

for (int y = y0; y < y1; ++y)
  for (int x = x0; x < x1; ++x)
    dst->pixels[(dy + y) * dst->w + dx + x] = src->pixels[y * src->w + x];

Clipping the loop bounds up front turns "check every pixel" into "check four times per sprite." On a scene with a few thousand sprites that's the difference between a smooth frame and a stutter — the kind of thing the profiler flags long before your eyes do.

Alpha: the difference between a rectangle and a sprite

A copy gives you an opaque square. Real sprites have transparent edges, so we blend the source over the destination using the source alpha. The math is the classic out = src * a + dst * (1 - a), done per channel. Skip the pixels where alpha is zero — most sprites are mostly empty, and that early-out is free speed.

blend.c
static inline uint32_t over(uint32_t s, uint32_t d) {
  uint32_t a = s >> 24, inv = 255 - a;
  if (a == 0) return d;   // most sprite pixels are empty
  uint32_t rb = ((s & 0xFF00FF) * a + (d & 0xFF00FF) * inv) & 0xFF00FF00;
  uint32_t g  = ((s & 0xFF00)   * a + (d & 0xFF00)   * inv) & 0xFF0000;
  return 0xFF000000 | ((rb | g) >> 8);
}

Blending two channels at once by masking red and blue into one word is a standard packed-pixel trick. It halves the multiplies compared with unpacking each channel, and it's the sort of micro-optimization that only earns its keep once the profiler says blending is your hot path — not before.

Rule of thumb from the track: never optimize a renderer by intuition. Draw the naive version, measure the frame, and only then reach for packed math or SIMD. The workshop spends a full session doing exactly this with a live flame graph.

Batching: fewer, bigger operations

Once blending works, the bottleneck moves from math to bookkeeping — the per-call overhead of setting up each sprite. Batching answers this by collecting draw requests into one array and processing them in a tight pass, sorted by texture so the CPU cache stays warm. It's the software-renderer version of the "reduce your draw calls" advice you hear about the GPU, and the reasoning is the same: setup is expensive, so amortize it.

That's the arc — pixels, clipping, blitting, alpha, batching. Nothing here is magic; it's just memory and arithmetic arranged carefully. The full engine track takes this renderer, moves it onto the GPU, and keeps the software path as a reference you can always fall back to when a shader misbehaves.

Want the complete, runnable project plus the SIMD-blit session? That's the Ship It bundle — the ebook and a live workshop where we profile this renderer together.