shisaku ~/devlog
$ auth github

Author sign in

Sign in with GitHub to comment on devlog entries.

Continue with GitHub
← all posts

Devlog #16 — Use More Memory, Calculate Less

A one-day performance campaign: the supersample that secretly did nothing, baking a storm into a texture, one atlas to batch them all, and why every answer to 'make it faster' was 'make it remember'.

Date: July 3, 2026

Focus: A one-day performance campaign across the whole renderer — how we found out the game was throwing away three of every four pixels it rendered, why the answer to “make it faster” was almost always “make it remember,” and how to land all of it in a repo that was busy growing an Arena mode at the same time.


The brief I gave was simple and a little greedy: drastically improve performance, don’t touch the visuals, and don’t bend the architecture doing it. And one more constraint, the one that ended up naming the whole day: use more memory, calculate less. Precompute it, bake it, cache it — spend bytes so the frame doesn’t have to spend math.

Eight commits later the game runs the same storm, the same swarms, the same lantern-lit fog — and does dramatically less work per frame to draw them. The best part: the biggest single win came from discovering that our most expensive setting had been doing nothing.


1. The supersample that wasn’t

EchoWarrior ships with render_scale = 2.0: the whole world renders into a buffer four times the size of the window, which then gets downsampled to the screen. That’s supersampling — the oldest, most honest anti-aliasing there is. We pay 4× the fill rate; we get smooth edges. A fair trade.

Except we weren’t getting the smooth edges.

The scene buffer is nearest-filtered on purpose — that’s the pixel-clarity pass, the thing that keeps sprite art crisp. But the composite shader read that buffer with a single tap per screen pixel:

vec4 base = texture2D(Texture, uv);

Nearest filtering, one tap, a 2:1 ratio. Do the arithmetic and it’s grim: each screen pixel picks one of the four texels the GPU just rendered and discards the other three. Point decimation. We were paying the full 4× supersampling bill and receiving an image statistically identical to rendering at native resolution. The setting cost everything and bought nothing.

The fix is almost embarrassingly small — average the four texels each pixel actually covers:

if (u_scene_texel.x > 0.0) {
    base = 0.25 * (
        texture2D(Texture, uv + vec2(-u_scene_texel.x, -u_scene_texel.y)) +
        texture2D(Texture, uv + vec2( u_scene_texel.x, -u_scene_texel.y)) +
        texture2D(Texture, uv + vec2(-u_scene_texel.x,  u_scene_texel.y)) +
        texture2D(Texture, uv + vec2( u_scene_texel.x,  u_scene_texel.y))
    );
}

At an exact 2:1 integer ratio those four taps land precisely on the four covered texels. Interior pixel-art texels average four identical values — byte-identical output, crispness untouched — while sprite silhouettes and every vector effect finally get the anti-aliasing the setting always promised. Same GPU cost as before. Strictly better picture.

And while we were in there: the emissive buffer that feeds the bloom pass was also allocated at the supersampled size, even though bloom immediately downsamples and blurs everything into soft low-frequency glow. Supersampling a buffer whose entire destiny is to be blurred is pure waste. It now allocates at native window resolution — a 4× fill-rate saving on its per-frame clear and draw — while the bloom ping-pong buffers keep their old size so the glow’s footprint on screen is pixel-for-pixel what it was.

One bug did fall out of staring at this pipeline that hard: the bloom layer had been vertically mirrored since the day it shipped. The blit chain ran an odd number of Y-inverting passes, so every glow composited upside-down — which is why XP gems (which live entirely in the emissive layer) never quite scrolled right. Storm glow is blurry and roughly symmetric, so the bug hid for weeks. One flip_y: true and the gems track the world again.


2. The GPU was doing poetry recitals

The storm look — fog banks, cloud shadows, rain sheen, the lantern’s ragged cone — comes from one full-screen shader. A beautiful one. It computed eleven fbm noise evaluations per pixel, per frame. Each fbm is three octaves of value noise; each octave is four hashes; each hash is a sin and a fract of a dot product. Something like 130 transcendental operations, per pixel, sixty times a second, to describe fog that does not meaningfully change from one millisecond to the next.

This is where “use more memory, calculate less” stops being a slogan and becomes a texture:

At load, a worker thread bakes the exact same three-octave value-noise character into a single 1024×1024 tile — same smoothstep fade, same amplitude stack, same numeric range, so every threshold in the shader behaves identically. The shader’s eleven fbm calls became eleven texture fetches:

float fbm_hi(vec2 p) {
    return texture2D(u_noise_tex, noise_tile_uv(p, NOISE_HI_CELLS)).r;
}

Two details earn their keep here. First, the tile carries two scales — one channel tiles every 32 lattice cells for fine consumers (water ripple, embers), another every 8 cells for the broad cloud and fog domains. Heavily magnified low-frequency noise sampled from too-coarse a texture shows bilinear faceting — ugly diamond artifacts in slow gradients — and the second channel is what makes the bake visually lossless instead of merely cheap. Second, the tile is seamless under plain clamp-and-linear sampling: the last texel duplicates the first, and an inset mapping keeps every bilinear tap inside the texture. No wrap-mode API, no seams, no drama.

The bake is pure math on a worker thread while the loading screen streams textures, so it costs the player nothing. And if it ever fails, the shader gets a flat mid-gray texel — the storm loses its texture but keeps its brightness, and the game shrugs instead of crashing. Data may fail; the frame may not.


3. Batches, and the art of not interrupting the GPU

macroquad batches draw calls beautifully — right up until you change textures, at which point it seals the batch and starts a new one. Our entity loops were texture-switch machines: each enemy drew its sprite (sheet texture), then its HP bar (white texture), then the next enemy’s sprite (sheet again). One swarm, hundreds of draw calls, nearly all of them one quad long.

Two fixes, layered:

Draw in layers, not in entities. All sprites first, then all overlays. Two passes over the same culled list, a handful of batches instead of two per enemy. (Side effect we kept on purpose: HP bars now always render above overlapping neighbours’ sprites, which reads better in a crowd.)

Then remove the texture switches entirely. Even layered, a mixed swarm alternated textures between neighbouring enemies of different kinds. So at load, every character sheet — every enemy kind, every companion — now gets decoded CPU-side and shelf-packed into one shared atlas texture, with each sheet’s offset added to its source rects. Every character on screen shares one texture; the whole sprite population is a single draw call no matter how kinds interleave. Sheets that fail to decode or don’t fit just stay standalone — identical rendering, own batch — and modded replacement sheets join the atlas automatically because packing happens after the mod layers resolve.


4. The little lies we told the allocator

The CPU side had a quieter version of the same disease: rebuilding things every frame that never change between frames.

None of these was individually dramatic. Together they removed every known steady-state per-frame heap allocation from the simulation and draw paths.


5. If you can’t see it, you didn’t fix it

The uncomfortable truth about the whole campaign: it landed on code inspection. Convincing inspection — but the honest way to sustain performance work is to measure in-game, always. So the F1 debug overlay grew a frame profile: every timing scope in the engine feeds a per-frame accumulator, and the overlay shows exactly where the last frame’s milliseconds went, sorted, with call counts. Next to it, a draws counter — the exact number of GPU draw calls submitted last frame, captured only while the overlay is open so it costs nothing during play.

Now the atlas isn’t a claim, it’s a number on screen. Regressions can’t hide. Future optimizations get to quote receipts.

(Also in the receipts department: the dev build now compiles dependencies optimized while the game’s own crate stays fast-to-iterate, and release builds got thin LTO. The dependency change is the cheapest enormous win in all of Rust gamedev — vector math at -O0 is not a thing anyone should play against.)


6. Optimizing a moving target

None of this landed in a frozen repo. The same day, the same runtime module was busy growing an Arena mode — new commits arriving every few minutes, the tree shifting under the performance work while it was in flight. Optimizing code that’s actively being developed is its own discipline, and it deserves a paragraph.

What made it work: small self-contained units, gates green before every commit, staging strictly by explicit path, and reading git diff like a suspicious customs officer before every git add. The history came out as a clean braid of two workstreams — and, the part I’m quietly proud of, every single commit in it compiles and passes the full suite.


The scoreboard

Same visuals. Same architecture. And per frame: three of four rendered pixels no longer discarded (they’re resolved now — real SSAA), a 4× smaller emissive pass, ~130 transcendental ops per pixel replaced by three texture fetches, hundreds of swarm draw calls collapsed to a handful, and zero steady-state allocations in the hot loops. The memory bill for all of it: one 4 MB noise tile, one character atlas, a few kilobytes of resolved animation rects.

Use more memory. Calculate less. The frame says thank you.

$ comments

Reader notes

0 notes

No notes yet.

If you're reading this as a developer: this devlog is built in the open.

If you're reading this as a modder: the direction is source-visible, inspectable systems.