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.
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.
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.
// 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.
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.
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.
