A CPU software rasterizer that has to match a GPU pixel-for-pixel
The service in question composes character sprites (body, hairstyle, equipment) from binary animation files and PNG atlases, server-side, to produce a PNG image on demand. These characters have to match the game's rendering exactly: the same zone colors, the same atlas crops, the same transparency behavior. One pixel off, and the image no longer matches what the user sees in-game.
Why not a GPU
There's no graphics card available server-side, which would have been enough to settle the question on its own. But even with a GPU, the real problem would remain: you have to reproduce the exact behavior of a reference rendering backend, not invent a visually equivalent pipeline. That changes the nature of the work. You're not designing a generic rendering engine with your own implementation choices, you're reverse-engineering a precise contract:
- Rasterization
- Textured quads, UV lookup via inverse affine.
- UV coordinates
- A particular
flip_yon the V. - Compositing
- Straight-alpha OVER.
The CPU gives total control over every arithmetic operation, which is exactly what you need when the goal is to match an existing calculation rather than produce "something that looks about right".
The core: inverting the affine, not transforming it
The central function, render_shape(), rasterizes a textured quad (a shape) transformed by a 2x3 affine matrix that comes from the sprite tree (position, rotation, scale accumulated level by level).
let inv_det = 1.0 / det;
// ...
let dx = sx - m31;
let dy = sy - m32;
// Inverse affine: local = T^-1 · screen
let lx = (dx * m22 - dy * m21) * inv_det;
let ly = (dy * m11 - dx * m12) * inv_det;This direction of calculation isn't an implementation detail, it's what gives parity. By reproducing exactly the same sampling method (nearest, no interpolation, with the same flip_y on the UV's V) as the reference GPU backend, you get the same aliasing artifacts in the same places, not just an image that "looks roughly the same".
The per-pixel loop is unsafe, unbounded indexing into the raw bytes of the atlas and the output buffer. This is justified by debug_assert! checks that verify the bounds upstream, in debug builds, without paying the cost in release. Each atlas pixel read by this loop is a block of 4 consecutive RGBA bytes:
debug_assert!(ai + 3 < atlas_buf.len());
let (br, bg, bb, ba) = unsafe {
(*atlas_buf.get_unchecked(ai), *atlas_buf.get_unchecked(ai + 1),
*atlas_buf.get_unchecked(ai + 2), *atlas_buf.get_unchecked(ai + 3))
};Le piège
This loop runs for every pixel of every shape, of every sprite, potentially for every frame of an animation. The clamping of the ax/ay indices to the atlas bounds is already done just above, so the indices are proven valid by construction: the assert acts as a safety net in dev, not as correction logic in prod.
OVER blending, on the other hand, stays in integers1.
- The divisions by 255 are lowered by the compiler to multiply+shift, with no hardware division, and the numerators stay bounded (at most 255·255·2) so there's never an overflow in
u32. ↩
Flattening the color transform instead of replaying it per pixel
Each sprite can carry a color transform (Multiply, Add, or a combination of the two, arbitrarily nested by the parent/child sprite tree). The direct approach would be to rebuild and replay this chain for every pixel of the shape. Except a Multiply/Add/Combine chain is affine per channel: out = byte * scale + offset. No matter how deep it goes, it always reduces to these two numbers. So instead of folding it at every pixel, it's probed twice, once with pure black (0,0,0,0) and once with pure white (1,1,1,1), to extract scale and offset:
let z = transform.color.clone().fold(Color::new(0.0, 0.0, 0.0, 0.0));
let o = transform.color.clone().fold(Color::new(1.0, 1.0, 1.0, 1.0));
let (scl_r, off_r) = (o.red - z.red, z.red * 255.0); The result is then applied as a single multiply-add directly on the atlas's raw bytes, once per shape rather than per pixel. It's the kind of optimization that changes nothing about the result (parity is preserved since it's mathematically identical) but that moves all the work of rebuilding the color tree out of the hot loop.
A time budget, not a promise of unlimited quality
Static renders (a fixed pose) aren't size-bounded and use the full resolution of the high-definition atlases. But an animated render, exported frame by frame, has to stay fast. If the canvas's natural size exceeds this budget, the scale is reduced proportionally before rasterizing, rather than after.
- Budget
MAX_ANIM_PIXELSsets a budget of 120,000 canvas pixels.- Typical resolution
- ~300x400 pixels.
- Per frame
- ~50 ms rasterization + ~5 ms encoding.
The comment in the code gives the order of magnitude that justifies this figure. Multiplied by a hundred or so frames for a long animation, the difference between "bounded" and "unbounded" translates directly into an HTTP response time that's acceptable or not.
Caches: avoiding paying for the same computation twice
Animation files and PNG atlases are loaded from disk through asynchronous Moka caches, 512 entries each. The part that really matters is the use of try_get_with rather than a simple get-then-insert: it guarantees an atomic compute-if-absent.
Loading itself goes through spawn_blocking, because reading a file from disk is a blocking operation you don't want to let freeze a tokio task. And at startup, a warm-up preloads the base animations for each character class, so that the very first /render request is never the one that pays for the cold disk load.
The rest of the Rust choices follow the same pragmatic rather than dogmatic logic: axum and tokio for the asynchronous HTTP server, rayon to parallelize the rendering of frames within the same animation (each frame is independent, so trivially parallelizable), and a release profile with thin LTO and a single codegen unit, tuned specifically because the per-pixel rasterization loop is the service's hot spot.
À retenir
What remains from this experience is that a software rasterizer, too often dismissed as "the fallback when you don't have a GPU," can actually be the right technical choice as soon as the main constraint isn't raw performance but fidelity to an external reference. The CPU lets you reproduce precise arithmetic, instruction by instruction, without the abstractions and opaque optimizations of a GPU pipeline that could, on some sampling or rounding detail, produce a slightly different result. And once that parity constraint is set, optimization stops being an exercise in style: it becomes a matter of finding, as with the color transform, the places where the computation can be factored out without ever changing the pixel-by-pixel result.