diff --git a/PERFORMANCE.md b/PERFORMANCE.md index b281d91..a6cf340 100644 --- a/PERFORMANCE.md +++ b/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 ->