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:
148
PERFORMANCE.md
148
PERFORMANCE.md
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user