Compare the measured frame to Construct and Phaser

New PERFORMANCE.md section putting the baseline next to what Construct and
Phaser publish about their own frames: their numbers assume GPU command
submission, so what transfers here is the culling and caching (text dirty
flags, collision cells), not the batching -- the control rows already showed
per-tile submission overhead at 0.2%. All claims cite primary sources.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 17:21:02 -04:00
parent e4aa6a5084
commit 00b68f5873

View File

@@ -325,6 +325,154 @@ reads**, and that is with output going to a sink that throws it away; write it t
a terminal and it is far worse. `akgl_actor_initialize` and
`akgl_character_sprite_add` both log unconditionally at `INFO`.
## How other engines spend the same frame
The natural question after a baseline like this is how it compares to the
engines people actually ship 2D games on. The comparison below is against
Construct (2 and 3) and Phaser (3 and 4), because both publish real
engineering detail: Ashley Gullen's Construct tech blogs and the Phaser docs
and source say what they built, what they rejected, and — most usefully — what
they measured. Godot and GameMaker publish similar batching guidance and add
nothing the other two don't. Sources are at the end of the section; the
construct.net posts are Cloudflare-walled to non-browsers and were verified
through Wayback captures.
One caveat governs the whole comparison, so it goes first. **Every number
these engines publish assumes the pixels are on a GPU.** Construct's ~300 CPU
cycles per sprite and Phaser's 16,384-quad batch ceiling are budgets for
*building command lists*, not for touching pixels. Two things follow:
- Ashley's argument that a frame costs max(logic, render) — because the CPU
builds commands while the GPU rasterizes the previous batch — inverts under
a software renderer. Here the frame really is logic *plus* render, which is
why 97.6% of ours is blits.
- What transfers from their playbooks is the **culling and the caching**:
camera culls, dirty-flagged text textures, collision cells. What does not
transfer is the **batching**, and the control rows above already proved it:
libakgl's per-tile submission overhead is 0.2% of the frame. Batching is a
solution to a problem this library has measured itself not to have — and
SDL's renderer batches internally anyway.
### Tilemaps: they optimize draw calls; ours cost 0.2%
Construct 2 merged runs of identical tiles into rectangles and drew each
rectangle in one call — 120 draw calls down to 22 in their worked example —
and the Construct 3 runtime renders an entire tilemap layer with zero texture
switches, which they measured at 40x over C2 when every visible tile differs.
Phaser culls tiles to the camera every frame (one-tile padding by default),
and Phaser 4 added an opt-in `TilemapGPULayer` that draws a whole layer as a
single shader quad at a fixed cost per screen pixel, capped at 4096x4096
tiles.
All of that machinery exists to cut draw-call submission cost. libakgl's
equivalent cost is the 0.03 ms gap between `akgl_tilemap_draw` and the raw-SDL
control row. The 16.26 ms is rasterization, which none of those techniques
touch — a merged rectangle still fills the same pixels in software. The one
tilemap idea worth keeping in the back pocket is the one Construct *rejected*
in 2013 on simplicity grounds and Phaser shipped in 2026: push the whole layer
to the GPU. That is a backend decision, not a loop optimization, and the
per-tile bookkeeping measured above (~25 ns) is what would remain of the frame
when it happens.
### Sprites: their per-sprite budget and ours are the same order
Construct measured its C3 runtime at roughly 300 CPU cycles to render one
sprite — about 65 ns on this machine's clock — and got there by replacing
polymorphic dispatch with monomorphic code, which they measured as 70-88%
faster on their quad benchmark. Phaser batches 16,384 quads at a time and
multi-texture batching turned 210 sprites across 15 textures from 212 draw
operations into 2. libakgl's actor render bookkeeping is ~250 ns by
subtraction (target 3), through one `draw_texture` function pointer per actor
with an error scope per call. Same order of magnitude, no batch layer, and at
64 actors the whole sweep is 1.1% of the frame. Their lesson that dispatch
indirection is where per-sprite CPU goes is worth remembering if the actor
ceiling ever rises by an order of magnitude; at 64 it is noise.
### Text: everyone else has the cache
Phaser's `Text` object re-rasterizes its canvas and re-uploads the texture
*only when the content or style changes*; its `BitmapText` never pays even
that — their docs state "you don't incur any speed penalty when updating their
content because the underlying texture doesn't change." Construct's Text
plugin documents the same failure mode this suite measured: automatic-
resolution mode "can cause the text to constantly re-render when being
smoothly scaled," and fixed-resolution mode is the documented escape hatch.
Both engines treat re-rasterizing an unchanged string as a defect. libakgl
does it unconditionally, every call, every frame: 12.6 µs a string. The
dirty-flag cache in targets 8 and 9 is not novel engineering; it is the
industry floor.
### Collision: Construct already ran this experiment
Construct's brute-force collision at 1000-vs-1000 objects was about a million
checks per tick and ran at 10 fps on a desktop; their viewport-sized uniform
collision cells cut that to ~40,000 checks — a 96% reduction, six times
faster — with incremental insert/remove as objects move and zero cost for
static objects. They evaluated quadtrees and rejected them as "more
complicated and can be more expensive to update as objects move around."
Phaser went the other way: Arcade Physics keeps dynamic bodies in an RTree
that is *cleared and rebuilt every frame*, and their own docs say a
"conservative estimate of around 5,000 bodies should be considered the max"
before turning the tree off beats keeping it.
libakgl has no broad phase at all, and at the current 64-actor ceiling it
does not need one: the all-pairs sweep is 115 µs, 0.7% of the frame. The
comparison settles *which* broad phase fits when the ceiling rises — the
incremental uniform grid, which is allocation-free at steady state and simple
enough that its authors chose it over the fancier structure, not the
rebuild-every-frame tree. The design is on record in `TODO.md` under
**Performance -> The plan**.
### Memory: stricter than either, and paying for it wrong
Construct budgets memory layout-by-layout — only the current layout's images
are resident, every image is width x height x 4 bytes regardless of source
format, and peak memory is the worst single layout. Their create/destroy
churn is a first-class engine metric (5x faster in the C3 runtime), and their
anti-jank advice — place one instance in the layout so its texture is loaded,
then destroy it at start — is a warm-the-pool idiom. Phaser's pooling is
`Group` objects with `maxSize` as a hard cap, recycling members by toggling
`active`. Both engines converge on what libakgl starts from: fixed budgets
and recycled objects.
The difference is what the budget buys. Their costs scale with what a scene
*uses*; libakgl's scale with what the build *allows* — 28 MB of BSS with 94%
in one worst-case-sized tilemap, 4 KiB string wipes for eleven-byte strings,
pool scans that walk the ceiling rather than the population. The static-pool
discipline is right, and stricter than either engine. Targets 6, 7, 14 and 15
are all the same correction: keep the fixed ceiling, stop paying for it per
operation.
### The frame split: the same shape as 2012
Ashley wrote in 2012 that "it's not unusual for even a fairly complex game to
spend 10% of the time on the logic, and 90% of the time on rendering — even
when hardware accelerated!" That is a stated profiling experience from the
canvas era, with no published methodology, and it is cited here as exactly
that and nothing more; Phaser publishes no frame-composition profile at all,
and no official Phaser bunnymark number exists to borrow (third-party figures
float around; none are cited here). libakgl's measured 97.6% blits against
under 1% bookkeeping is the same shape, more extreme, because software
rasterization is slower than a GPU and the logic is C rather than JavaScript.
Both lead to the conclusion already in this document: the pixels are the
whole frame, and the per-operation bookkeeping numbers are what matter the
day a GPU backend makes the pixels cheap.
### Sources
- Ashley Gullen, [How the Construct 2 WebGL renderer works](https://www.construct.net/en/blogs/ashleys-blog-2/construct-webgl-renderer-works-917) (2014)
- Ashley Gullen, [Tech blog: Tilemap tidbits](https://www.construct.net/en/blogs/ashleys-blog-2/tech-blog-tilemap-tidbits-913) (2013)
- Ashley Gullen, [Collision cell optimisation in r155](https://www.construct.net/en/blogs/ashleys-blog-2/collision-cell-optimisation-914) (2013)
- Ashley Gullen, [How render cells work](https://www.construct.net/en/blogs/ashleys-blog-2/render-cells-work-921) (2014)
- Ashley Gullen, [Optimisation: don't waste your time](https://www.construct.net/en/blogs/construct-official-blog-1/optimisation-dont-waste-time-768) (2012)
- Scirra, [Announcing the Construct 3 runtime](https://www.construct.net/en/blogs/construct-official-blog-1/announcing-construct-runtime-904) (2018)
- Construct 3 manual: [Performance tips](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/performance-tips), [Memory usage](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/memory-usage), [Text](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/text), [Sprite Font](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/sprite-font)
- Phaser, [Phaser 4 rendering concepts](https://phaser.io/tutorials/phaser-4-rendering-concepts)
- Phaser, [How to render thousands of sprites in Phaser 4](https://phaser.io/news/2026/05/phaser4-spritegpulayer-performance)
- Phaser, [Advanced rendering tutorial, part 2: multi-texture batching](https://phaser.io/tutorials/advanced-rendering-tutorial/part2)
- Phaser docs: [Text](https://docs.phaser.io/phaser/concepts/gameobjects/text), [BitmapText](https://docs.phaser.io/phaser/concepts/gameobjects/bitmap-text), [Group](https://docs.phaser.io/phaser/concepts/gameobjects/group)
- Phaser source: [physics/arcade/World.js](https://github.com/phaserjs/phaser/blob/master/src/physics/arcade/World.js), [tilemaps/components/CullTiles.js](https://github.com/phaserjs/phaser/blob/master/src/tilemaps/components/CullTiles.js), [core/Config.js](https://github.com/phaserjs/phaser/blob/master/src/core/Config.js)
## Defects these tests found
Six, and they are filed where defects live: `TODO.md`, under **Performance ->