Tprrt's Blog

Embedded Linux, Zephyr RTOS, open-source hardware, Linux gaming, retro gaming, and competitive fitness

Oct 02, 2026

Wireframe Ran at 20 FPS, Solid at 60: Counting ARM9 Cycles to Find Out Why

Introduction

Earlier articles on this blog about the Game Boy Advance and the Nintendo DS, among them the cycle-level optimization one and the one on faking point lights on the DS GPU, shared a premise: these machines have a CPU with no floating-point unit, and a graphics pipeline that makes you pay for every vertex somewhere. The GBA pays in rasterizer cycles. The DS has a real hardware 3D engine, so it pays somewhere else, and that somewhere turned out to be the part of the frame nobody was looking at.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

The framer-engine spinning_shapes example puts a cube, a sphere and a cone on screen, each rotating, and a button toggles between drawing them solid and drawing them as wireframes. On the DS it ran at 60 FPS in solid and about 20 in wireframe. Wireframe should be the cheaper of the two: fewer pixels filled, no lighting. That a debug visualization would be three times slower than the real thing was the bug report, and there was a second, less obvious question behind it: how do you even measure a frame on a platform you can only run in an emulator?

How wireframe gets drawn on a GPU that has no lines

The DS 3D engine draws triangles and quads, and nothing else: framer-engine's source notes that libnds' glBegin() takes only triangles and quads (and their strips), with no line primitive. The hardware does have a wireframe polygon mode, selected by a polygon alpha of zero, but it outlines a polygon's own edges. The engine's meshes keep a separate list of the edges they actually want drawn, and for a sphere that list leaves out the diagonals that split each patch into two triangles; asking the hardware for wireframe would draw them. (That is the reasoning, not an experiment: the mode was never tried.) So wireframe here means what it does on most hardware without lines: a thin quad along every edge, drawn flat and unlit.

Building that quad takes some care. It has to be widened perpendicular to the edge, in a direction that makes sense for a convex mesh centered on its own origin, so the code takes the direction from the mesh's origin to the middle of the edge and removes the part of it along the edge:

/* per edge, every frame */
float edge_len = sqrtf(edge[0]*edge[0] + edge[1]*edge[1] + edge[2]*edge[2]);
float mid_len  = sqrtf(mid[0]*mid[0] + mid[1]*mid[1] + mid[2]*mid[2]);
dir[k]    = edge[k] / edge_len;          /* k = 0..2 */
radial[k] = mid[k]  / mid_len;
dot       = radial[0]*dir[0] + radial[1]*dir[1] + radial[2]*dir[2];
width[k]  = radial[k] - dot * dir[k];    /* perpendicular to the edge */
width_len = sqrtf(width[0]*width[0] + width[1]*width[1] + width[2]*width[2]);
width[k]  = width[k] / width_len * half_width;
/* then four vertices: a -/+ width, b +/- width, as 4.12 fixed point */

Correct, and unremarkable on a PC. On the DS's ARM9 it is three square roots, nine divides, roughly fifty multiplies and adds and twelve float-to-fixed conversions, per edge, every frame, and every one of those is a call into a software floating-point routine.

Why not just look at the frame rate

The obvious next step, running the ROM in an emulator and reading the frame counter, is the one that tells you least. melonDS' own speed varied a lot from run to run in earlier debugging sessions on this project, so anything derived from wall-clock time or its frame counter says more about the host machine than about the console. What does hold still is the emulated hardware's timers: the same ROM executes the same instructions and the DS timers count them the same way every run.

libnds exposes two of them chained into a 32-bit counter through cpuStartTiming() and cpuEndTiming(), which return ticks of the hardware timer clock (about 33.5 MHz, the bus clock; the ARM9 itself runs at twice that). A throwaway build wraps the 3D render system and stores the result in a global per render mode:

volatile uint32_t g_diag_ticks[2];   /* [0] solid, [1] wireframe */
volatile uint32_t g_diag_count[2];

static void nds_hw3d_render_system(framer_iter_t *it)
{
    int w = framer_render_mode_get_override() ==
            FRAMER_RENDER_MODE_FORCE_WIREFRAME;

    cpuStartTiming(0);
    nds_hw3d_render_system_impl(it);   /* the real system, unchanged */
    g_diag_ticks[w] = cpuEndTiming();
    g_diag_count[w]++;
}

A copy of the example toggles the render mode itself every 120 frames, so one emulator launch produces both numbers, and the globals are read over melonDS' GDB stub, with their addresses taken from nm on the ARM9 ELF:

ticks = struct.unpack('<2I', bytes(rsp.read_mem(sock, tick_addr, 8)))
count = struct.unpack('<2I', bytes(rsp.read_mem(sock, count_addr, 8)))

The first run settled the question. Run twice, the figures agreed to within a few ticks, which is the property that makes them usable:

Mode Ticks per frame Time
Solid 317,950 9.5 ms
Wireframe 1,414,850 42 ms

A 60 FPS frame is 16.7 ms. Solid fits with room to spare. Wireframe takes 42 ms in the 3D pass alone, which is more than two vertical blanks, so the frame lands on the third and the screen updates every 50 ms: 20 FPS, to the number. The frame rate wasn't a mystery, it was a quantization of a cost.

Where 1.4 million ticks go

The meshes are not small. The sphere has 20 sectors and 10 stacks, which is 430 edges and 360 triangles; the cone adds 40 edges and 40 triangles and the cube 12 and 12. A wireframe frame therefore builds 482 quads, and a solid frame submits 412 triangles. Dividing:

  • wireframe: 1,414,850 / 482, about 2,900 ticks per edge, roughly 5,900 ARM9 cycles for the arithmetic above;
  • solid: 317,950 / 412, about 770 ticks per triangle, which is a normal and three vertices converted to fixed point and handed to the GPU, with no square root and no divide anywhere in it.

So the GPU was not what set the cost in either mode. The CPU was, and in wireframe it spent 42 ms of every frame, two and a half frame budgets, on arithmetic whose answer is the same on every frame.

The data never changes

That last sentence is the whole fix. The cube is the same cube every frame: only the model matrix it is drawn under changes, and the hardware applies that matrix itself, on the GPU, from a matrix stack. Every square root and every divide in that loop is computing the same four vertices it computed 1/60th of a second earlier.

The DS has a mechanism for exactly this: a display list, a block of pre-packed geometry-engine commands that glCallList() sends to the GPU FIFO by DMA. Its layout is the hardware's own FIFO layout, documented in GBATEK: a first word with the number of words that follow, then groups made of one word holding up to four command ids, one per byte, followed by the parameter words of those commands in order. A quad with four vertices comes out as:

word 0   count
word 1   BEGIN_VTXS | VTX_16 | VTX_16 | VTX_16     (0x23232340)
word 2   1                                          (GL_QUADS)
word 3   x0 | y0 << 16                              vertex 0
word 4   z0
word 5   x1 | y1 << 16                              vertex 1
word 6   z1
word 7   x2 | y2 << 16                              vertex 2
word 8   z2
word 9   VTX_16 | NOP | NOP | NOP                   (0x00000023)
word 10  x3 | y3 << 16                              vertex 3
word 11  z3

Writing the packer is the small part: about eighty lines of plain C with the command ids defined next to it (BEGIN_VTXS is 0x40, NORMAL 0x21 and VTX_16 0x23), no libnds dependency, and so unit-tested on the desktop like any other pure logic in the engine, with the layout above as one of the tests. libnds documents one trap that the packer simply avoids: the END_VTXS command does nothing and is reported to cause trouble in a DMA'd list, so lists never contain it.

void gx_dl_cmd(struct gx_dl *dl, uint8_t id, const uint32_t *params, int n)
{
    if (dl->slot == 4) {                 /* open a new command word */
        dl->cmd_at = dl->len++;
        dl->words[dl->cmd_at] = 0;
        dl->slot = 0;
    }
    dl->words[dl->cmd_at] |= (uint32_t)id << (8 * dl->slot);
    dl->slot++;
    for (int i = 0; i < n; i++)
        dl->words[dl->len++] = params[i];
}

The renderer bakes a mesh the first time it is drawn in a given mode: the edge quads for wireframe, the triangles with their flat normals for solid. After that a draw is a colour, a glCallList() and nothing else. Only what is identical on every draw goes into a list; the colour, the material, the polygon format and the model matrix stay with the caller, so the same list serves every object that shares a mesh. Two kinds of object keep the old triangle-by-triangle path because they vary something a list would have to hold: objects with a colour per face, since the material changes between faces, and textured ones, since their texture coordinates are scaled by the texture's size. The old immediate wireframe code also stays, as the fallback for a mesh that cannot be baked: more than eight different meshes in a program, or no memory left for the list.

Results

Same scene, same instrumented build, measured the same way:

Mode Before After Change
Solid 317,950 18,500 17 times cheaper
Wireframe 1,414,850 24,450 58 times cheaper

The wireframe pass goes from 42 ms to 0.73 ms and solid from 9.5 ms to 0.55 ms. The two modes now cost about the same, which is what the original bug report had assumed all along.

Faster is only worth anything if it is the same picture. The figure below is the top screen before and after, captured from the emulator window in the same runs as the numbers; the shapes are at different points of their rotation because the demo keeps spinning, but the shading and the lines are the same.

Top screen of spinning_shapes on melonDS, a blue cube, a red sphere and a green cone, drawn solid (above) and as wireframe (below), before the change on the left and after it on the right, looking the same

Solid and wireframe, before (left) and after (right).

Three caveats belong next to those numbers:

  • The GPU still has to consume the list. glCallList() flushes the list from the data cache, starts the DMA and waits for it to finish, and the DMA only completes once the geometry FIFO has accepted the data. melonDS models the geometry engine's timing only roughly, so on a real console that wait will be longer than 0.7 ms. It is waiting on the GPU, not computing, and nothing was measured on real hardware.
  • The first draw pays the old price once. Baking runs the original math one time per mesh and mode, so the first frame in wireframe still does what every frame used to. It is a one-off hitch rather than a rate, and an unmeasured one.
  • Lists cost memory. About 15 KB for the sphere's wireframe and 12 KB for its solid list, allocated from the heap and held for the life of the program.

A footnote on quitting a debugger session

One practical thing came out of the measuring that is worth passing on to anyone scripting melonDS' GDB stub. A script that interrupts the target, reads what it needs and closes the socket leaves the emulated CPU halted at the interruption, and the stub then keeps a dead connection in CLOSE-WAIT: the emulator can no longer be quit from its window, a new connection times out, and SIGTERM has no effect (with the Flatpak build of melonDS at least; my guess is that, as the first process of its own sandbox, it does not get the default signal handling when the signal comes from outside, but I did not verify that). The fix has two halves: always send continue before disconnecting, so the target is running again when the connection goes, and stop a stuck instance with flatpak kill rather than reaching for kill -9.

What is still on the slow path

Objects with per-face colours and textured objects still submit triangle by triangle, so a scene dominated by them gets none of this. Both can be baked too: a per-texture-size list for the textured case, and one list per colour run for the face-coloured one. They are the next candidates, and the same method applies: measure the render system's ticks first, in the mode that looks slowest, and let the number say where the frame went.

posted at 21:30  · 10 min read  ·   ·  nds  gpu  performance  optimization  embedded  retro  gamedev  c  article

Sep 25, 2026

One Entity, Many Bullets: Fitting a Projectile Pool Into the ECS

Introduction

The previous article on this blog was about the static, no-heap Entity Component System framer-engine runs on GBA and NDS: a fixed table of component slots and a fixed table of entities, sized to fit in a machine with no malloc(). That fixed sizing has a consequence the article didn't need to dwell on at the time: with today's component set, the table has room for 64 real entities. A caravan-style vertical shoot-'em-up, the first playable milestone this engine is building toward, wants several hundred bullets on screen during a dense pattern. Neither number moves to meet the other — the entity table isn't growing to fit a bullet count, and a bullet pattern isn't capping itself at 64 shots to fit the table.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

The tension is old and the usual answer is old too: don't make every bullet an entity. What took two attempts wasn't the bullet simulation itself — a flat array of structs, advanced by one system, is not a hard problem — it was deciding how that array gets drawn without quietly opening a second, ECS-shaped hole in the renderer. The first answer shipped, worked, and was wrong in a way no test had a reason to catch. The second answer was smaller, matched a pattern the engine already had, and went on to surface three real bugs across three different rendering backends, none of which were about bullets at all.

The first answer: a queue that isn't an entity

The obvious shape for "hundreds of short-lived things, updated every frame, drawn every frame, never touched individually by name" is a per-frame queue: a flat array of draw commands (position, sprite, frame), reset at the start of each frame, appended to by whatever wants something drawn, and consumed once by the renderer at the end of it. A bullet pool fits that shape exactly — push one entry per live bullet, once, after the physics step — and it required nothing new from the ECS at all, since nothing about it touched an entity, a component, or a query.

That was also exactly the problem. Every other thing this engine draws — a sprite, a tilemap, a HUD label — is an entity carrying a component, and every renderer's draw phase is systems iterating queries. A queue fed directly by application code and drained directly by the renderer is a second path with none of that: it isn't visible to the entity-inspection API a previous milestone added, it isn't something a capability query knows exists, and it isn't something any of the debugging tooling built around "the ECS is the whole scene" has any way to see. It worked, in the narrow sense that bullets appeared on screen in the right place moving at the right speed. It didn't survive being looked at as architecture: elements without an entity, bypassing the engine's own core model, aren't a smaller version of the real design — they're a different design that happens to render the same pixels.

The second answer: an entity that stands for many

framer-engine already had a component that solves this exact class of problem, for a different feature: a Tilemap is one entity whose component points at a grid the owner keeps, and the renderer draws the whole grid from that one entity every frame. Nothing about "many drawn things, one piece of external memory, one component pointing at it" is specific to tiles. The rewrite generalizes that shape and gives it a name — SpriteBatch — and the bullet pool becomes one more caller of it instead of a bespoke path of its own:

struct framer_batch_sprite {
    int32_t x, y;       /* center, world units, fixed point */
    uint32_t texture;
    uint8_t frame;
    uint16_t size;       /* drawn size, world units, fixed point */
};

struct sprite_batch {
    const void *first;   /* owner's element array */
    const uint16_t *count; /* owner's live-element count */
    uint16_t stride;      /* bytes between elements */
};

A framer_bullet places its own struct framer_batch_sprite as its first field, so the bullet pool's own storage array is the SpriteBatch's element array — no copy, no second buffer kept in sync. framer_bullet_pool_entity_create() gives the pool's one entity both a BulletPool component, stepped every frame by a physics system, and a SpriteBatch component built from the same array, drawn every frame by the renderer. Every renderer's draw phase still only ever does one thing: iterate a query and draw what it finds. Hundreds of bullets stay out of the 64-slot entity table; nothing draws that isn't reachable from an entity.

That much is a straightforward generalization of an existing pattern, and it's also where the interesting bugs started, because a component that every renderer backend has to independently learn to draw is a component that gives every renderer backend its own chance to get it wrong.

Bug 1 (Vulkan): a guard that was checking the wrong component

Every renderer backend's sprite system is imported once per world, and every one of them has to answer the same small design question: how do you know "once" has already happened? The naive answer, comparing the stored world pointer against the one you were just called with, breaks the moment a different world gets allocated at the same address — which is exactly what every test in this codebase does, tearing a world down and creating a fresh one for the next case. The existing convention across this codebase's renderer imports is therefore to also check that some component the import function itself registers is actually present on the world in front of you, not just that the pointer matches.

The version of this check written for SpriteBatch picked the wrong component to check:

/* Wrong: Sprite is registered by the renderer's own import, before
 * this function ever runs -- it says nothing about whether THIS
 * function has run on this particular world. */
if (s_world == world && FRAMER_COMPONENT_IS_REGISTERED(world, Sprite))
    return;

On a fresh world reusing a previous world's address, Sprite is already registered — by the caller, earlier in the same framer_sprite_system_import() — before this guard is ever reached. The check reports "already set up" on the very first call, every time, and the function returns without ever registering SpriteBatch or its draw system. Sprites drew fine; batches never did.

This one didn't need staring at code to find — it failed exactly where a test already existed to catch it. The new pixel-readback test for SpriteBatch (draw an element, read the pixel back, expect it filled) passed against the software and OpenGL backends and failed against Vulkan specifically, because Vulkan's test suite happened to run a batch test on a world whose address a prior test had already used. Fixing the guard to check the component this function actually registers —

if (s_world == world && FRAMER_COMPONENT_IS_REGISTERED(world, SpriteBatch))
    return;

— was a one-line change once the failing test pointed at exactly which world-reuse case was wrong. The same wrong-component shape was checked for, by inspection, in the OpenGL and software backends before either one had actually failed a test; both had the identical guard, both got the identical fix, and both were then verified rather than assumed fixed.

Bug 2 (OpenGL): reading back a write that hasn't happened yet

The OpenGL backend's default shaders are compiled lazily, the first time anything asks for one, and cached in the world's own Shader singleton component so the next thousand callers don't recompile anything:

const struct shader *shaders = FRAMER_GET(world, FRAMER_ID(Shader), Shader);
if (!shaders) {
    unsigned int sprite = framer_shader_create(...);
    /* ... compile the other three default shaders ... */

    FRAMER_SET(world, FRAMER_ID(Shader), Shader, .sprite_shader = sprite, /* ... */);
    shaders = FRAMER_GET(world, FRAMER_ID(Shader), Shader);
    if (!shaders)
        return 0;
}

switch (type) {
case SHADER_TYPE_SPRITE: return shaders->sprite_shader;
/* ... */
}

This is correct exactly as long as a component write, from the caller's point of view, takes effect immediately. It doesn't, unconditionally: a write issued from inside a running system is staged and only merged once every system scheduled for that step has finished, so a second system in the same step can already see it, but the same call that just made the write cannot read it back synchronously. That's an ordinary property of this ECS, not a bug in it — every other caller of framer_shader_get_default() had always been either the very first mesh or sprite draw of an existing scene, where something upstream (loading a level, spawning a player) had already asked for a shader on some earlier frame, so the singleton already existed by the time this function's fast path ran.

The SpriteBatch draw system is the first thing in a brand-new scene that has nothing upstream of it — no mesh, no plain sprite entity, just a bullet pool's one entity. On that scene's very first frame, its render system is the very first caller of framer_shader_get_default() ever, from inside a system, in a world where nothing has asked before. The write lands; the read-back in the same call finds nothing; the function returns 0; that frame renders every batch element with no shader bound at all. The fix doesn't touch the ECS's timing at all, because there's nothing wrong with it — it stops asking the world to hand back something it was just told, seconds from now, to go store:

struct shader created = {.sprite_shader = sprite, /* ... */};

framer_component_set(world, FRAMER_ID(Shader), FRAMER_ID(Shader), &created);
return shader_of_type(&created, type);

Bug 3 (all three desktop backends): drawn correctly, in the wrong order

The first two bugs were both "nothing drew." The third one was quieter: every bullet drew, at the right position, the right size, the right texture — and the entity that fired them disappeared behind its own bullets the moment enough of them piled up at its position.

SpriteBatch and the ordinary per-entity Sprite draw are two separate systems in the same ECS phase, and nothing had ever said which one goes first. On GBA and NDS this was never a question worth asking, because the hardware answers it structurally: OAM sprites carry an explicit priority value, and entity sprites are deliberately drawn at a higher priority than batch elements, so an entity is in front of a batch by construction, regardless of which system happened to run first that frame. Sampling the actual OAM contents through mGBA's debug stub, mid-run, confirms it — the emitter (the round blue sprite at the center) sits visibly in front of the spiral of bullets it just fired:

The bullets example running under mGBA, OAM contents captured over its debug stub -- a blue emitter sprite is clearly visible on top of a spiral of orange bullet sprites radiating from it

The bullets example's spiral pattern, OAM contents read directly off a running GBA — the emitter draws in front of every bullet it fires, by hardware priority, regardless of draw order.

The three desktop backends (OpenGL, Vulkan, software) have no equivalent hardware guarantee — draw order is the only ordering mechanism they have — and the two systems simply registered in whatever order their import code happened to call them, which put SpriteBatch after the entity sprite system. Sampling actual framebuffer pixels at the emitter's screen position, once every fifty frames of a real Vulkan run, shows exactly the moment it stops working: the emitter's own blue for hundreds of frames, then — once the bullet pool has enough live bullets to reach that exact pixel — the pixel starts reading back as whatever bullet is currently passing through it instead:

frame  350: 2850c8 5a96ff 5a96ff 5a96ff 2850c8   (emitter blue)
frame  750: 2850c8 5a96ff 5a96ff 5a96ff 2850c8   (emitter blue)
frame  800: c81e28 ff7828 ffdc50 ff7828 c81e28   (bullet red/orange)
frame 1200: c81e28 ffdc50 fffff0 ff7828 c81e28   (bullet, still)

The fix is the same one line of intent, spelled out explicitly instead of left to registration order, on every desktop backend:

static const struct framer_system_dep batch_deps[] = {
    {.before = "sprite_render_system", .after = NULL}};

framer_system_register(world, "sprite_batch_render_system",
                        FRAMER_PHASE_ON_UPDATE,
                        sprite_batch_render_system, bq, batch_deps, 1);

A new renderer test pins this down the same way the fix does — a red entity sprite and a white batch element occupying the same pixels, one assertion that the pixel reads back red — so the ordering is now a property the test suite checks rather than an accident of import order.

Verification: one bug per backend, so one check per backend

Nothing about the design change itself is backend-specific, so the actual proof that the redesign works had to be, backend by backend, rather than inferred from any one of them: the desktop pixel-readback tests (software, OpenGL, Vulkan) each got their own pass through the same three scenes, examples/bullets was cross-compiled for both GBA and NDS and the resulting ROM's OAM contents were sampled through mGBA's debug stub — the same technique the previous dithering article used to catch its own hardware-only bug — and every GBA example ROM in the tree was booted headlessly to confirm the pooled component-storage change underneath all of this (a separate, smaller fix the redesign also needed, since one more component pushed the fixed-stride storage layout past EWRAM) didn't regress startup on anything else.

One number came out the same on every target it was checked on, which wasn't the point of the exercise but is a reassuring sign the redesign is actually target-neutral: with the pool's rotating spiral pattern running continuously, the live bullet count settles in roughly the high 90s on GBA, OpenGL and Vulkan alike, all three converging on the same rough capacity for the same pattern despite three completely different drawing paths underneath.

Where this goes next

FRAMER_STATIC_MAX_COMPONENTS (40) and FRAMER_STATIC_MAX_ENTITIES (104, leaving 64 for actual game use) both grew slightly to make room for the new component without pushing the pooled storage past EWRAM on GBA — a two-line configuration change today, in a codebase where each of those numbers has moved before and will again. The bullet-pool roadmap item itself narrows to what's left once "draws correctly, everywhere" is no longer the open question: measuring and documenting the actual per-target bullet ceiling, rather than the two capacity constants (examples/bullets currently uses 128 on GBA/NDS, 1024 on desktop) picked ahead of having real numbers to size them against.

posted at 21:40  · 11 min read  ·   ·  ecs  rendering  vulkan  opengl  gba  nds  embedded  retro  gamedev  c  article

Jul 07, 2026

Only Parallel Light Sources Are Supported: Faking Point Lights on the DS GPU

Introduction

The previous two articles on this blog — one on cycle-level optimization, one on the no-heap ECS underneath it — were both about the Game Boy Advance: an FPU-less CPU with no hardware polygon fill at all, where every trick is about avoiding soft-float calls and touching as few VRAM bytes as possible. The Nintendo DS backend in framer-engine is the opposite kind of retro target — it has a real fixed-function 3D GPU, with hardware transform, lighting, and rasterization. That sounds like it should make lighting the easy part. It doesn't. It just moves the constraint from "the CPU can't do float math" to "the GPU can't do the thing you're asking it to do, and it won't tell you that — it'll just render something else instead."

This article is about chasing exactly that down: a real rendering bug, a real hardware surprise straight out of GBATEK, and a fix that gets meaningfully closer to real point-light falloff without ever abandoning the GPU's own lighting hardware for a software fallback.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

The hardware constraint that drives everything

Every light in framer-engine — directional, point, or spot — is the same struct light component, and every backend (GBA's software rasterizer, desktop OpenGL/Vulkan/software, and this one) is expected to shade point lights with real distance falloff: the closer an object is, the brighter it gets, fading to nothing at the light's configured range. The DS's own hardware lighting docs rule that out before any code gets written. libnds's glLight():

void glLight(int id, rgb color, v10 x, v10 y, v10 z)
Only parallel light sources are supported on the DS

Four hardware light slots, each one a color and a direction vector — no position, no range, no falloff term anywhere in the API. The DS's GPU computes real per-vertex Lambertian shading (N·L) against those four directions entirely in fixed-function hardware, at zero CPU cost per triangle — genuinely excellent, for a directional light. A point light, on this hardware, can only ever be an approximation: pick a direction, accept that "distance" isn't a thing the lighting unit knows about, and find whatever ways are still available to make that approximation better without leaving hardware lighting behind entirely for a per-vertex CPU-side shading pass (which the DS's ARM946E-S can do, but which throws away the entire reason to use the GPU's fixed-function path in the first place).

Bug 1: an ambient floor that was zero on purpose

Before any of the point-light work, the hardware 3D path had a simpler problem: GL_AMBIENT — the DS's material property for "the color when a face isn't catching any light at all" — was set to black, unconditionally, the instant any Light entity existed in the scene:

/* what shipped, briefly */
glMaterialf(GL_AMBIENT, RGB15(0, 0, 0));

glLight() only lights a face whose normal faces toward one of the four active directions — anything else gets zero contribution from diffuse, and with GL_AMBIENT pinned to black, zero contribution from ambient too. The result: any face pointing away from every active light rendered as pure, flat black, on hardware that was otherwise correctly shading everything facing the right way. The fix looked simple — accumulate every ambient/directional light's color into a running sum each frame, and feed that into GL_AMBIENT instead of a hardcoded zero:

s_ambient_r += gl->r;
s_ambient_g += gl->g;
s_ambient_b += gl->b;
/* ...clamped to 1.0 after the loop, then: */
glMaterialf(GL_AMBIENT,
    RGB15((int)(r * s_ambient_r * 31.0f), /* ...g, b... */));

It rendered correctly with exactly one active light. It did not render correctly with two.

The hardware surprise: ambient is summed once per active light

Adding a second point light to the scene — the natural next step, since the whole point of this work was to support more than one — turned every previously-correctly-shaded face white. Not brighter: blown-out, clipped-to-white white, on geometry that had looked exactly right a commit earlier.

The cause is in GBATEK's own description of the DS's polygon lighting equation, easy to miss because nothing in libnds's glMaterialf() doc comment mentions it: the hardware doesn't compute MaterialAmbient once and add it to the final result. It computes MaterialAmbient × LightColor, separately, for every currently active light, and sums those. Diffuse and specular are supposed to work that way — that's how multiple colored lights are meant to combine — but ambient, conceptually, is "the floor when nothing is lighting this face," and nothing about that concept should scale with how many lights happen to be turned on. The DS hardware doesn't make that distinction. Set GL_AMBIENT to the value you actually want as a floor, and with three active lights, the hardware hands you back three times that, added into every face's final color.

The first attempted fix was the obvious one: divide by the number of active lights before setting GL_AMBIENT, so the hardware's own summation would reconstruct roughly the intended value.

/* looked right, wasn't */
float ambient_scale = 1.0f / (float)s_num_lights;

This fixed the two-point-light overexposure. It also made a sphere lit by one dim ambient light and two much brighter colored point lights render far too dark — because a flat 1/N correction has no way to know that those three active lights aren't equally bright. It divides down a lone dim ambient contribution by exactly as much as it divides down two point lights that have nothing to do with the ambient floor at all. A cube's few flat faces happened to look fine anyway (whichever face caught a point light's direction was still bright from diffuse); a sphere's curved surface — mostly not facing any of the three light directions — was left with almost nothing but that over-divided ambient term, and rendered close to black again, just from a different arithmetic mistake than bug 1.

The actual fix reconstructs the target ambient exactly, not approximately: track the real per-channel sum of every active light's own color (not just the count of lights), and divide by that instead.

/* MaterialAmbient * sum(LightColor) == target once hardware sums it
 * back, regardless of how many lights are active or how bright any
 * one of them is. */
float ambient_scale_r = 1.0f / fmaxf(s_light_color_sum_r, 0.05f);
glMaterialf(GL_AMBIENT,
    RGB15((int)(r * amb_r * ambient_scale_r * 31.0f), /* ...g, b... */));

Since MaterialAmbient × sum(LightColor) is exactly what the hardware computes internally, setting MaterialAmbient = target / sum(LightColor) makes the hardware's own summation land back on target — no matter how many lights are active or how differently bright each one is. The fmaxf(..., 0.05f) floor matters for a real edge case this reasoning otherwise misses entirely: if every active light has an exact 0 in one color channel (a pure-hue light, easy to reach — a pure green point light has no red or blue component at all), the sum for that channel is genuinely zero, and no MaterialAmbient value can make hardware produce a nonzero result by multiplying against a color that has none. Flooring the divisor doesn't fully fix that case — the DS hardware mathematically cannot recover a floor for a channel no active light contributes to — but it keeps the reconstruction from returning inf and cascading into garbage the moment a scene reaches for a light whose color isn't already a happy accident of "has something in every channel."

RGB555 has an opinion about how bright "bright enough" is

With the arithmetic fixed, faces pointing away from every light were no longer wrong — just still visually indistinguishable from unlit black in a real screenshot. RGB555 gives each channel 5 bits, 32 steps; a first ambient floor of 0.18 on a 0.8-albedo material works out to roughly 0.8 * 0.18 * 31 ≈ 4/31 — technically nonzero, comfortably lost to ordinary display gamma and contrast. Raising the floor to 0.45 reaches roughly 11/31, about 35%: the difference between "the math is correct" and "a person looking at the screen can tell the math is correct." Getting the equation right and getting the number right turned out to be two separate bugs, not one.

Real per-object falloff, without leaving hardware lighting

With the ambient floor actually behaving, the remaining problem was the one this article opened with: point lights, as hardware directional lights, weren't just "no falloff" in the abstract — the direction itself was computed once per frame, not per object, from the world origin:

/* once per frame, not once per object */
float lx = gl->x, ly = gl->y, lz = gl->z; /* the light's position */
/* ...normalize (lx, ly, lz) as a direction from the origin... */
glLight(s_num_lights, hw_color, dx, dy, dz);

Every object in the scene, regardless of where it actually sat relative to that light, saw the identical direction and the identical brightness, with no falloff at all. A "point" light behaved exactly like a directional one from every single object's point of view — which is a stronger regression than the hardware's own real limitation demands. The DS genuinely cannot do per-pixel or per-vertex-across-one-object falloff for a point light; it can absolutely do a different, correct direction and brightness for each separate object, if something recomputes those per object instead of once for the whole frame.

That's the fix: move the glLight() upload from once-per-frame to once-per-object, called right before that object's own triangles submit, computing real direction and falloff from that object's actual world position:

static void nds_upload_lights_for_object(const float pos[3])
{
    for (int i = 0; i < s_num_lights; i++) {
        /* ...ambient/directional lights reuse the once-per-frame
         * direction/color computed earlier -- no position to react
         * to, so recomputing per object would be pure waste... */
        float lx = gl->x - pos[0], ly = gl->y - pos[1], lz = gl->z - pos[2];
        float d2 = lx * lx + ly * ly + lz * lz;
        float atten = 1.0f - d2 * gl->inv_range2; /* same falloff every backend uses */
        /* ...clamp atten to [0, 1], normalize (lx, ly, lz), scale color by atten... */
        glLight(i, hw_color, dx, dy, dz);
    }
}

An object near the light now renders bright; an object near the edge of its range renders dim; an object outside the range entirely gets nothing from it — a real approximation of point-light falloff, per object, running through the exact same hardware N·L diffuse calculation the GPU always did. Nothing about the triangle count, vertex count, or rasterization work changes even slightly — this only changes the values written into two hardware registers that were already being written once per object either way.

It isn't free in the sense of costing literally zero CPU cycles — a point light's direction still needs a square root to normalize, and that now happens once per (point light, object) pair instead of once per frame. But it's the same fixed-point sqrtf32/divf32 primitives this code already used for the once-per-frame version, on a scene with a handful of lights and a handful of objects — a few extra fixed-point operations, not a new soft-float cost, and not one more vertex for the GPU to transform or one more pixel for it to fill. Against the cost of submitting an object's actual geometry at all, it doesn't move the needle, which is the only sense of "free" that was ever on the table for a change like this.

Two smaller wins that came out of the same pass

Two more things fell out of looking this closely at the hardware lighting path, neither one changing a single triangle:

GL_ANTIALIAS was already enabled at startup, and doing about half of its job. The DS only blends an edge under antialiasing where the polygon ID changes across it — and every object in the scene defaulted to the same ID (0), so hardware edge smoothing only ever applied where an object's silhouette met the background. Two overlapping objects, sharing the same ID, got a hard edge between them regardless. Assigning each object its own ID (an index mod 63, since glClearPolyID(63) reserves 63 for the backdrop) makes that blending apply between objects too — a different bitmask, OR'd into a glPolyFmt() call that already happens once per object, at the same cost as before.

And the built-in sphere/cone mesh, generated at 12 sectors × 6 stacks (120 triangles), was sized for a target this GPU isn't. That segment count lives in a comment noting the DS's real per-frame budget is "on the order of a couple thousand polygons" — a 360-triangle sphere at 20×10 is still a rounding error against that, and visibly rounder on screen, for a target whose real constraint was never triangle count in the first place.

Update: the tessellation win had a hidden cost

That "visibly rounder on screen" sphere from the section above had a cost this article didn't account for at the time. examples/lighting quietly ran at exactly 30fps on real NDS timing (melonDS) rather than 60, and the cause traced straight back to this same pass.

nds_submit_tri() — the function issuing each triangle's vertices — was also where every triangle's GL_AMBIENT/GL_DIFFUSE material state got (re)computed, including the full ambient-reconstruction division this article spent three sections on above. For a single-color object that's pure waste: the material state is identical across every one of an object's triangles, so it only ever needs setting once per object, not once per triangle. A 12×6 (120-triangle) sphere issued 120 redundant glMaterialf() pairs into the GX FIFO every frame even before this article's own change; the same sphere at 20×10 (360 triangles, the tessellation bump described above) issued 360. Combined with the lighting example's four active lights — each one re-triggering that same reconstruction math on every single one of those calls — frame time crept just past one VBlank period.

swiWaitForVBlank() doesn't degrade gracefully when that happens. It waits for the next VBlank interrupt after the frame's work completes, full stop — there's no "a little late" state between VBlanks, only "made it" or "missed it and now wait for the one after." Missing a single VBlank by any margin, even a fraction of a scanline's worth of GX FIFO backlog, halves the observed frame rate outright: 60fps to exactly 30fps, not 59 or 58. The tessellation bump didn't cause a gradual slowdown that could be spotted incrementally — it silently sat under the VBlank deadline until it didn't, and the frame rate cut itself exactly in half the moment it crossed.

The fix has nothing to do with tessellation, lighting, or the ambient math itself — all of that stays correct, unchanged. It's purely a matter of when the material state gets written: once per object (or once per face, only for the rare object whose faces genuinely have different colors), immediately before that object's triangles submit, instead of redundantly with every single triangle. The exact same GL_AMBIENT/GL_DIFFUSE values land on the GPU either way — this changes nothing about what gets drawn, only how many times the identical FIFO command gets issued to say so.

This doesn't extend to the GBA backend: its software rasterizer computes each face's final color directly in gba_lit_color_face(), with no separate material/normal GPU state to redundantly reissue in the first place — there's no equivalent waste to hoist out over there.

Confirmed on real timing (melonDS): back to a solid 60fps. The underlying shape of the bug is the same one this whole article is about — a GPU that will happily do exactly what's asked of it, at whatever cost that actually carries, and won't warn you when two separately reasonable changes (a rounder sphere, correct per-object point-light falloff) compound into a cost neither one would have caused alone.

Verifying a change with nothing to count

The GBA articles on this blog leaned on cycle_probe.py for every claim — a deterministic emulator's own cycle counter, diffed between frames, turning "did this help" into a yes/no number. None of that tooling applies here, and that's not an oversight: this whole article is about work that happens on the GPU's fixed-function pipeline, not the ARM9's own cycles. There's no CPU-side cost to isolate with a breakpoint, because there almost isn't one — the actual verification for this kind of change is the build and test suite (native, GBA, and NDS cross builds, all passing unchanged throughout), plus looking at the actual rendered frame in melonDS to confirm the sphere in examples/lighting is now visibly shaded by two differently-colored, differently-positioned lights instead of one flat approximation. Different kind of hardware, different kind of proof.

Where this goes next

The DS's fixed-function GPU has real capabilities this backend still doesn't touch at all: hardware texture sampling from VRAM, a specular shininess table, per-vertex fog. None of those are CPU-cost questions — they're GPU features sitting unused, the same shape of opportunity this article's point-light work turned out to be. The natural next piece, given how this one went, is real diffuse texturing: the highest-value use of hardware this backend doesn't ask anything of yet, and — if the pattern from the ambient bug repeats — probably has its own GBATEK surprise waiting to be found the same way this one was, by trying the obvious thing first and actually looking at what came out the other end.

posted at 21:14  · 14 min read  ·   ·  nds  gpu  lighting  embedded  retro  gamedev  article