Files
libakgl/tests/perf_render.c
Andrew Kesterson 913834a3af Draw actors at their sprite's height, and update each one once a frame
Closes Defects item 26 and Performance item 32.

akgl_actor_render set dest.h from curSprite->width, so every actor was drawn
square and a non-square sprite was stretched or squashed. Invisible in the
fixtures because they are all square, so tests/actor.c gets a 48x24 sprite and
a render backend whose draw_texture records the rectangle it is handed instead
of drawing it. That recording backend is the first coverage akgl_actor_render
has had at all -- every other test in the file stubs renderfunc out.

akgl_game_update looped over AKGL_TILEMAP_MAX_LAYERS with the actor sweep
nested inside it and never compared an actor's layer to the layer it was on, so
every live actor's updatefunc ran sixteen times a frame. The sweep is hoisted
out: updating an actor is not a per-layer operation, and
akgl_render_2d_draw_world already walks the layers for the half that is.
AKGL_ITERATOR_OP_LAYERMASK is honoured rather than ignored now, so a caller who
wants one layer can still ask, and gets each of those actors once.

Counting is the assertion, deliberately. The defect was invisible in the frame
total because the tilemap blits are three orders of magnitude larger, so a
timing test would have measured the rasterizer. tests/game.c counts calls into
a stub updatefunc and reports 16 against the old code.

PERFORMANCE.md records the timing side as what it honestly is: the gap between
the akgl_game_update and draw_world rows of the same run, 92 us before and
noise in both directions after. The absolute table is not re-taken -- a later
run on this machine read every row about 15% high, including rows nothing has
touched.

25/25 pass, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:27 -04:00

840 lines
31 KiB
C

/**
* @file perf_render.c
* @brief Stress and timing benchmarks for the paths that need a renderer.
*
* The companion to `tests/perf.c`, which covers everything that does not draw.
* Everything here runs against a software renderer under the dummy video driver
* at #BENCH_SCREEN_W x #BENCH_SCREEN_H -- no display, no window shown, no GPU.
*
* A software renderer is the right instrument for this even though no shipped
* game uses one. What these benchmarks are measuring is the work libakgl does
* *per drawn thing* -- how many blits a screenful of tiles turns into, how many
* lookups an actor costs before its texture moves, what a line of HUD text
* allocates and throws away every frame. A software renderer keeps that work in
* the same process where it can be timed, and its per-blit cost is at least
* honest about how much pixel traffic was asked for. Read these numbers as
* *counts of work*, not as frame rates a real game would see.
*
* The tilemap benchmarks build their map by hand rather than loading the 2x2
* fixture. A 2x2 map measures nothing: the point of the exercise is a full
* screen of tiles -- #BENCH_MAP_W x #BENCH_MAP_H cells at #BENCH_TILE_SIZE
* pixels, of which the camera covers 40 x 30 -- and the second variant repeats
* it with eight tilesets, because akgl_tilemap_draw scans every tileset for
* every tile and never stops at the one that matched.
*/
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/sprite.h>
#include <akgl/physics.h>
#include <akgl/renderer.h>
#include <akgl/draw.h>
#include <akgl/text.h>
#include <akgl/tilemap.h>
#include <akgl/staticstring.h>
#include "benchutil.h"
#include "testutil.h"
/** @brief Width of the offscreen render target every benchmark draws into. */
#define BENCH_SCREEN_W 640
/** @brief Height of that target. */
#define BENCH_SCREEN_H 480
/** @brief Edge of one cell in the synthetic map, in pixels. */
#define BENCH_TILE_SIZE 16
/** @brief Width of the synthetic map, in tiles. Bigger than the camera on purpose. */
#define BENCH_MAP_W 128
/** @brief Height of the synthetic map, in tiles. */
#define BENCH_MAP_H 128
/** @brief Tilesets in the many-tileset variant of the draw benchmark. */
#define BENCH_MAP_TILESETS 8
/** @brief Actors the scene-wide benchmarks put on the heap. */
#define BENCH_ACTOR_COUNT AKGL_MAX_HEAP_ACTOR
/** @brief The tileset image the synthetic map cuts its tiles from: 768x576, 48x36 tiles. */
#define BENCH_TILESET_IMAGE "assets/World_A1.png"
/** @brief The font the text benchmarks rasterize with. */
#define BENCH_FONT_PATH "assets/akgl_test_mono.ttf"
/** @brief Point size for that font. */
#define BENCH_FONT_SIZE 16
/** @brief A representative line of HUD text: a score readout. */
#define BENCH_TEXT "SCORE 000123456"
/** @brief The font opened once in main and used by the text benchmarks. */
static TTF_Font *benchfont = NULL;
/** @brief The tileset texture the synthetic map borrows. Destroyed in main. */
static SDL_Texture *benchtiles = NULL;
/**
* @brief Stop the clock, but only after the renderer has done what it was asked.
*
* SDL batches. A `draw_*` call queues a command and returns, and the queue is
* not executed until something forces it: a present, a readback, or the
* destruction of a texture it refers to. Without a flush inside the timed
* region, every drawing benchmark here measures the cost of *queueing* work and
* none of the cost of doing it -- and the bill arrives at teardown, where
* SDL_DestroyTexture on the tileset spent nearly two minutes executing blits
* that six earlier benchmarks had queued and been given credit for not doing.
*
* So the flush goes inside the measurement: what these benchmarks report is N
* calls plus the drawing those N calls asked for, which is the number a caller
* actually pays over a frame.
*/
#define BENCH_FLUSH_STOP(e, ops) \
{ \
bool __bench_flushed = SDL_FlushRenderer(akgl_renderer->sdl_renderer); \
bench_stop(ops); \
FAIL_ZERO_RETURN(e, __bench_flushed, AKGL_ERR_SDL, "SDL_FlushRenderer: %s", SDL_GetError()); \
}
/**
* @brief Swallow SDL's log output. See the same function in tests/perf.c.
*/
static void bench_discard_log(void *userdata, int category, SDL_LogPriority priority, const char *message)
{
return;
}
/**
* @brief Build a map by hand: one tile layer, @p tilesets tilesets, every cell filled.
*
* The cells all draw from the *last* tileset, which is the worst case for
* akgl_tilemap_draw's inner scan and the reason the many-tileset variant exists.
* Global ids are laid out so that tileset k owns [1 + k*tilecount, ...], which
* keeps each cell matching exactly one tileset -- the draw loop has no `break`,
* so overlapping ranges would blit the same cell more than once and measure
* something that cannot happen with a map Tiled produced.
*
* @param map The tilemap to fill in. Required. Zeroed first.
* @param tilesets How many tilesets to declare, all sharing one texture.
*/
static akerr_ErrorContext *bench_build_map(akgl_Tilemap *map, int tilesets)
{
PREPARE_ERROR(errctx);
int columns = 0;
int rows = 0;
int tilecount = 0;
int lastgid = 0;
int i = 0;
int cell = 0;
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
FAIL_ZERO_RETURN(errctx, benchtiles, AKERR_NULLPOINTER, "benchtiles");
FAIL_NONZERO_RETURN(
errctx,
((tilesets < 1) || (tilesets > AKGL_TILEMAP_MAX_TILESETS)),
AKERR_OUTOFBOUNDS,
"%d tilesets is outside 1..%d",
tilesets,
AKGL_TILEMAP_MAX_TILESETS);
memset(map, 0x00, sizeof(akgl_Tilemap));
columns = benchtiles->w / BENCH_TILE_SIZE;
rows = benchtiles->h / BENCH_TILE_SIZE;
tilecount = columns * rows;
map->tilewidth = BENCH_TILE_SIZE;
map->tileheight = BENCH_TILE_SIZE;
map->width = BENCH_MAP_W;
map->height = BENCH_MAP_H;
map->numlayers = 1;
map->numtilesets = tilesets;
for ( i = 0; i < tilesets; i++ ) {
map->tilesets[i].firstgid = 1 + (i * tilecount);
map->tilesets[i].tilecount = tilecount;
map->tilesets[i].columns = columns;
map->tilesets[i].tilewidth = BENCH_TILE_SIZE;
map->tilesets[i].tileheight = BENCH_TILE_SIZE;
map->tilesets[i].imagewidth = benchtiles->w;
map->tilesets[i].imageheight = benchtiles->h;
map->tilesets[i].texture = benchtiles;
snprintf((char *)&map->tilesets[i].name, AKGL_TILEMAP_MAX_TILESET_NAME_SIZE, "benchtileset%d", i);
PASS(errctx, akgl_tilemap_compute_tileset_offsets(map, i));
}
lastgid = map->tilesets[tilesets - 1].firstgid;
map->layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
map->layers[0].width = BENCH_MAP_W;
map->layers[0].height = BENCH_MAP_H;
map->layers[0].visible = true;
map->layers[0].opacity = 1.0;
for ( i = 0; i < (BENCH_MAP_W * BENCH_MAP_H); i++ ) {
cell = i % tilecount;
map->layers[0].data[i] = lastgid + cell;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Load the test sprite and character, then fill the actor pool with actors wearing it.
*
* These are the real assets, loaded through the real loaders, so the actors have
* a texture-backed sprite and akgl_actor_render takes its full path rather than
* bailing out at the missing-sprite branch. Facing is pinned by turning
* `movement_controls_face` off: the automatic face logic clears every facing bit
* for an actor that is not moving, and the fixture character maps its sprite to
* `ALIVE | FACE_LEFT`.
*/
static akerr_ErrorContext *bench_populate_scene(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
char name[AKGL_ACTOR_MAX_NAME_LENGTH];
int i = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
PASS(errctx, akgl_sprite_load_json("assets/testsprite.json"));
PASS(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
PASS(errctx, akgl_character_load_json("assets/testcharacter.json"));
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
memset(&name, 0x00, sizeof(name));
snprintf((char *)&name, AKGL_ACTOR_MAX_NAME_LENGTH, "benchactor%d", i);
PASS(errctx, akgl_heap_next_actor(&actor));
PASS(errctx, akgl_actor_initialize(actor, (char *)&name));
PASS(errctx, akgl_actor_set_character(actor, "testcharacter"));
actor->visible = true;
actor->layer = 0;
actor->movement_controls_face = false;
actor->x = (float32_t)((i * 37) % BENCH_SCREEN_W);
actor->y = (float32_t)((i * 23) % BENCH_SCREEN_H);
actor->curSpriteFrameId = 0;
AKGL_BITMASK_ADD(actor->state, (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT));
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the empty frame: clear the target and present it.
*
* Everything else a frame does is on top of this. It is also the only benchmark
* here whose cost is entirely SDL's rather than libakgl's, which makes it the
* baseline the rest are worth reading against.
*/
static akerr_ErrorContext *bench_frame(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
int count = bench_iterations(5000);
int i = 0;
int rep = 0;
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("frame_start + frame_end, 640x480", "frame", 230000.0);
for ( i = 0; i < count; i++ ) {
inner = akgl_renderer->frame_start(akgl_renderer);
if ( inner != NULL ) {
break;
}
inner = akgl_renderer->frame_end(akgl_renderer);
if ( inner != NULL ) {
break;
}
}
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the immediate-mode primitives.
*
* Each of these saves the renderer's draw colour, sets its own, draws, and puts
* the colour back, so the fixed cost per call is three SDL round trips whatever
* the shape. The shapes are sized like something a game would actually draw: a
* full-diagonal line, a dialogue-box-sized rectangle, a 64 pixel radius circle.
*/
static akerr_ErrorContext *bench_primitives(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
SDL_Color red = { 0xff, 0x00, 0x00, 0xff };
SDL_FRect rect = { .x = 32.0, .y = 32.0, .w = 200.0, .h = 150.0 };
int count = bench_iterations(100000);
int filled = bench_iterations(2000);
int circles = bench_iterations(20000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("draw_point", "call", 1400.0);
BENCH_LOOP(inner, i, count, akgl_draw_point(akgl_renderer, 320.0, 240.0, red));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
bench_start("draw_line, screen diagonal", "call", 6200.0);
BENCH_LOOP(inner, i, count, akgl_draw_line(akgl_renderer, 0.0, 0.0, 639.0, 479.0, red));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
bench_start("draw_rect, 200x150 outline", "call", 5000.0);
BENCH_LOOP(inner, i, count, akgl_draw_rect(akgl_renderer, &rect, red));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
bench_start("draw_filled_rect, 200x150", "call", 440000.0);
BENCH_LOOP(inner, i, filled, akgl_draw_filled_rect(akgl_renderer, &rect, red));
BENCH_FLUSH_STOP(errctx, filled);
PASS(errctx, inner);
bench_start("draw_circle, radius 64", "call", 25000.0);
BENCH_LOOP(inner, i, circles, akgl_draw_circle(akgl_renderer, 320.0, 240.0, 64.0, red));
BENCH_FLUSH_STOP(errctx, circles);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the framebuffer round trips: save a region, put it back, flood fill.
*
* These are the expensive ones by construction. A copy reads pixels back off the
* render target; a paste uploads a texture, draws it, and destroys it again; and
* a flood fill reads back the *entire* target, converts the whole surface to
* RGBA32, walks it, uploads a patch, and destroys everything. That is a
* megabyte and a half of traffic for one fill at 640x480, whatever the size of
* the region that actually changed.
*
* The fill alternates colours because a fill whose seed pixel already carries
* the requested colour returns immediately, and would otherwise measure the
* early exit rather than the fill.
*/
static akerr_ErrorContext *bench_framebuffer(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
SDL_Surface *saved = NULL;
SDL_Rect region = { .x = 64, .y = 64, .w = 64, .h = 64 };
SDL_Color red = { 0xff, 0x00, 0x00, 0xff };
SDL_Color blue = { 0x00, 0x00, 0xff, 0xff };
int count = bench_iterations(5000);
int fills = bench_iterations(200);
int i = 0;
int rep = 0;
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
PASS(errctx, akgl_draw_copy_region(akgl_renderer, &region, &saved));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("draw_copy_region, 64x64 readback", "call", 10000.0);
for ( i = 0; i < count; i++ ) {
SDL_Surface *scratch = NULL;
inner = akgl_draw_copy_region(akgl_renderer, &region, &scratch);
if ( inner != NULL ) {
break;
}
SDL_DestroySurface(scratch);
}
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
bench_start("draw_paste_region, 64x64 upload", "call", 55000.0);
BENCH_LOOP(inner, i, count, akgl_draw_paste_region(akgl_renderer, saved, 64.0, 64.0));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
bench_start("draw_flood_fill, full 640x480 target", "call", 20000000.0);
for ( i = 0; i < fills; i++ ) {
inner = akgl_draw_flood_fill(akgl_renderer, 320, 240, ( (i % 2) == 0 ) ? red : blue);
if ( inner != NULL ) {
break;
}
}
BENCH_FLUSH_STOP(errctx, fills);
PASS(errctx, inner);
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
}
SDL_DestroySurface(saved);
SUCCEED_RETURN(errctx);
}
/**
* @brief Time measuring and drawing one line of text.
*
* akgl_text_rendertextat rasterizes the string, uploads it as a texture, blits
* it, and destroys the texture again -- every call, with no cache anywhere. A
* HUD with six readouts pays this six times a frame for text that changes once a
* second.
*/
static akerr_ErrorContext *bench_text(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
SDL_Color white = { 0xff, 0xff, 0xff, 0xff };
int w = 0;
int h = 0;
int count = bench_iterations(20000);
int lines = bench_iterations(5000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("text_measure, 15 characters", "call", 400.0);
BENCH_LOOP(inner, i, count, akgl_text_measure(benchfont, BENCH_TEXT, &w, &h));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
bench_start("text_rendertextat, 15 characters", "call", 130000.0);
BENCH_LOOP(inner, i, lines, akgl_text_rendertextat(benchfont, BENCH_TEXT, white, 0, 8, 8));
BENCH_FLUSH_STOP(errctx, lines);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time loading assets off disk: a sprite, a character, and a whole map.
*
* This is startup and level-transition cost, not frame cost, and it is measured
* because it is the part of a game the player waits through. The sprite load
* after the first reuses the already registered spritesheet, so what is measured
* is the JSON path rather than the image decode.
*
* @note Each character load leaks the SDL property set holding its
* state-to-sprite map -- akgl_heap_release_character does not walk it.
* That is a known defect rather than something this benchmark introduces;
* it is repeated here often enough to be worth saying out loud.
*/
static akerr_ErrorContext *bench_asset_load(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Sprite *sprite = NULL;
akgl_Character *basechar = NULL;
int count = bench_iterations(2000);
int maps = bench_iterations(50);
int i = 0;
int j = 0;
int rep = 0;
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
PASS(errctx, bench_populate_scene());
bench_start("sprite_load_json, sheet already loaded", "load", 180000.0);
for ( i = 0; i < count; i++ ) {
inner = akgl_sprite_load_json("assets/testsprite.json");
if ( inner != NULL ) {
break;
}
sprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
if ( sprite == NULL ) {
break;
}
inner = akgl_heap_release_sprite(sprite);
if ( inner != NULL ) {
break;
}
}
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
PASS(errctx, bench_populate_scene());
bench_start("character_load_json, two sprite mappings", "load", 150000.0);
for ( i = 0; i < count; i++ ) {
inner = akgl_character_load_json("assets/testcharacter.json");
if ( inner != NULL ) {
break;
}
basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "testcharacter", NULL);
if ( basechar == NULL ) {
break;
}
inner = akgl_heap_release_character(basechar);
if ( inner != NULL ) {
break;
}
}
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
PASS(errctx, bench_populate_scene());
bench_start("tilemap_load + release, fixture map", "load", 120000000.0);
for ( i = 0; i < maps; i++ ) {
// The map's object layer spawns actors into the pool, and neither
// akgl_tilemap_release nor anything else gives them back, so a loop
// that only loaded and released would exhaust the actor pool on its
// 32nd iteration. Clearing the pool is part of what a host does
// between levels anyway, and it is a pair of memsets against a map
// load that decodes a 768x576 PNG.
inner = akgl_heap_init_actor();
if ( inner != NULL ) {
break;
}
inner = akgl_registry_init_actor();
if ( inner != NULL ) {
break;
}
// And the string pool has to be reclaimed by hand, because a map
// load leaks five pooled strings that release does not give back.
// The 52nd load would find the pool empty, and what happens then is
// not an AKGL_ERR_HEAP: akgl_get_json_string_value finishes with
// pass-up false, swallows the failed claim, and dereferences the
// pointer it never set. Both defects are filed in TODO.md; this loop
// works around the first so it never reaches the second.
for ( j = 0; j < AKGL_MAX_HEAP_STRING; j++ ) {
akgl_heap_strings[j].refcount = 0;
}
inner = akgl_tilemap_load("assets/testmap.tmj", akgl_gamemap);
if ( inner != NULL ) {
break;
}
inner = akgl_tilemap_release(akgl_gamemap);
if ( inner != NULL ) {
break;
}
}
BENCH_FLUSH_STOP(errctx, maps);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time zeroing an akgl_Tilemap, which is the first thing a map load does.
*
* akgl_tilemap_load begins with `memset(dest, 0, sizeof(akgl_Tilemap))`, and an
* akgl_Tilemap is 16 layers of 512x512 cells plus 16 tilesets of 65536 offset
* pairs. The struct is that size whether the map is 2x2 or 512x512, so this is a
* fixed toll on every level transition, paid before a single byte of the map
* file has been read. Measuring it separately is the only way to tell how much
* of the load benchmark above is the map and how much is the container.
*/
static akerr_ErrorContext *bench_map_zero(void)
{
PREPARE_ERROR(errctx);
int count = bench_iterations(200);
int i = 0;
int rep = 0;
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("zeroing one akgl_Tilemap", "call", 16000000.0);
for ( i = 0; i < count; i++ ) {
memset(akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
}
BENCH_FLUSH_STOP(errctx, count);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time building a tileset's offset table.
*
* akgl_tilemap_compute_tileset_offsets fills one entry per tile in the image --
* 1728 of them for the fixture tileset -- and a map pays it once per tileset at
* load time. It is also the function whose table costs 512 KB per tileset,
* which is the reason an akgl_Tilemap is as large as it is.
*/
static akerr_ErrorContext *bench_tileset_offsets(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
int count = bench_iterations(20000);
int i = 0;
int rep = 0;
PASS(errctx, bench_build_map(akgl_gamemap, 1));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("tilemap_compute_tileset_offsets, 1728 tiles", "call", 23000.0);
BENCH_LOOP(inner, i, count, akgl_tilemap_compute_tileset_offsets(akgl_gamemap, 0));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time drawing a screenful of tiles, with one tileset and with eight.
*
* The camera covers 40 x 30 cells, so each of these is 1200 blits plus whatever
* the tileset scan costs on top. The gap between the two numbers is that scan:
* akgl_tilemap_draw walks every tileset for every tile and does not stop at the
* one that matched, so the cost is linear in tileset count even though at most
* one can ever answer.
*/
static akerr_ErrorContext *bench_tilemap_draw(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
int count = bench_iterations(30);
int i = 0;
int rep = 0;
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
PASS(errctx, bench_build_map(akgl_gamemap, 1));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("tilemap_draw, 40x30 tiles, 1 tileset", "frame", 170000000.0);
BENCH_LOOP(inner, i, count, akgl_tilemap_draw(akgl_gamemap, akgl_camera, 0));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
}
PASS(errctx, bench_build_map(akgl_gamemap, BENCH_MAP_TILESETS));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("tilemap_draw, 40x30 tiles, 8 tilesets", "frame", 170000000.0);
BENCH_LOOP(inner, i, count, akgl_tilemap_draw(akgl_gamemap, akgl_camera, 0));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
}
printf("tilemap_draw covers %d x %d = %d tiles per frame\n",
(BENCH_SCREEN_W / BENCH_TILE_SIZE),
(BENCH_SCREEN_H / BENCH_TILE_SIZE),
((BENCH_SCREEN_W / BENCH_TILE_SIZE) * (BENCH_SCREEN_H / BENCH_TILE_SIZE)));
SUCCEED_RETURN(errctx);
}
/**
* @brief The control: the same 1200 blits, issued straight to SDL.
*
* Without this, every drawing number in this file is unreadable -- there is no
* way to tell how much of a 16 ms tilemap frame is libakgl deciding what to draw
* and how much is the software rasterizer moving pixels. This does the same
* amount of pixel work with none of the library in the way: same texture, same
* source rectangles, same destinations, no bounds logic, no tileset scan, no
* error contexts. Subtract it from the tilemap number and what is left is what
* libakgl costs.
*/
static akerr_ErrorContext *bench_raw_blit_control(void)
{
PREPARE_ERROR(errctx);
SDL_FRect src = { .x = 0.0, .y = 0.0, .w = BENCH_TILE_SIZE, .h = BENCH_TILE_SIZE };
SDL_FRect dest = { .x = 0.0, .y = 0.0, .w = BENCH_TILE_SIZE, .h = BENCH_TILE_SIZE };
int across = (BENCH_SCREEN_W / BENCH_TILE_SIZE);
int down = (BENCH_SCREEN_H / BENCH_TILE_SIZE);
int columns = 0;
int tilecount = 0;
int tile = 0;
int count = bench_iterations(30);
int i = 0;
int x = 0;
int y = 0;
int rep = 0;
FAIL_ZERO_RETURN(errctx, benchtiles, AKERR_NULLPOINTER, "benchtiles");
columns = benchtiles->w / BENCH_TILE_SIZE;
tilecount = columns * (benchtiles->h / BENCH_TILE_SIZE);
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
// One source tile, blitted 1200 times. The floor: 1200 SDL calls and
// 1200 x 256 pixels of copying, with the source rectangle staying in
// cache the whole way.
bench_start("raw SDL blits, one source tile (control)", "frame", 5000000.0);
for ( i = 0; i < count; i++ ) {
src.x = 0.0;
src.y = 0.0;
for ( y = 0; y < down; y++ ) {
for ( x = 0; x < across; x++ ) {
dest.x = (float32_t)(x * BENCH_TILE_SIZE);
dest.y = (float32_t)(y * BENCH_TILE_SIZE);
SDL_RenderTexture(akgl_renderer->sdl_renderer, benchtiles, &src, &dest);
}
}
}
BENCH_FLUSH_STOP(errctx, count);
// The same 1200 blits reading the same scattered source tiles the map
// asks for: cell (x, y) of a BENCH_MAP_W-wide map, so consecutive screen
// rows read parts of the sheet BENCH_MAP_W tiles apart rather than
// walking it in order. This is the honest control for
// akgl_tilemap_draw -- same call count, same pixels, same access
// pattern, none of libakgl in the path -- and the gap between the two is
// what the library's own per-tile work costs.
bench_start("raw SDL blits, map order (control)", "frame", 170000000.0);
for ( i = 0; i < count; i++ ) {
for ( y = 0; y < down; y++ ) {
for ( x = 0; x < across; x++ ) {
tile = (x + (y * BENCH_MAP_W)) % tilecount;
src.x = (float32_t)((tile % columns) * BENCH_TILE_SIZE);
src.y = (float32_t)((tile / columns) * BENCH_TILE_SIZE);
dest.x = (float32_t)(x * BENCH_TILE_SIZE);
dest.y = (float32_t)(y * BENCH_TILE_SIZE);
SDL_RenderTexture(akgl_renderer->sdl_renderer, benchtiles, &src, &dest);
}
}
}
BENCH_FLUSH_STOP(errctx, count);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time one actor's render, and then the whole scene through the backend.
*
* A render is two state-to-sprite lookups -- one for the sprite, one inside the
* visibility test -- some frame arithmetic, and one blit. The scene benchmark
* puts that together with the tilemap: one layer of tiles plus 64 actors, which
* is what akgl_render_2d_draw_world does for a modest 2D game.
*/
static akerr_ErrorContext *bench_scene(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Actor *actor = NULL;
int count = bench_iterations(20000);
int frames = bench_iterations(30);
int i = 0;
int rep = 0;
PASS(errctx, bench_populate_scene());
PASS(errctx, bench_build_map(akgl_gamemap, 1));
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
actor = &akgl_heap_actors[0];
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("actor_render, on camera", "actor", 31000.0);
BENCH_LOOP(inner, i, count, actor->renderfunc(actor));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
bench_start("draw_world, 1200 tiles + 64 actors", "frame", 170000000.0);
BENCH_LOOP(inner, i, frames, akgl_renderer->draw_world(akgl_renderer, NULL));
BENCH_FLUSH_STOP(errctx, frames);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time a whole frame the way akgl_game_update runs one.
*
* This is the number a host actually gets, and it is not the sum of the parts.
*
* It used to be measurably worse than that: the update sweep was nested inside
* a loop over #AKGL_TILEMAP_MAX_LAYERS that never filtered by layer, so every
* live actor updated sixteen times a frame, and this benchmark sat about 92 us
* above the scene benchmark below it. That gap *was* the multiplier. Since
* 0.5.0 the two are equal within run-to-run noise, which is the shape of the
* fix rather than a number worth quoting on its own.
*/
static akerr_ErrorContext *bench_game_update(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
int count = bench_iterations(30);
int i = 0;
int rep = 0;
PASS(errctx, bench_populate_scene());
PASS(errctx, bench_build_map(akgl_gamemap, 1));
PASS(errctx, akgl_physics_init_null(akgl_physics));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("game_update, full frame", "frame", 170000000.0);
BENCH_LOOP(inner, i, count, akgl_game_update(NULL));
BENCH_FLUSH_STOP(errctx, count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
SDL_SetLogOutputFunction(bench_discard_log, NULL);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
akgl_physics = &akgl_default_physics;
akgl_camera = &akgl_default_camera;
akgl_gamemap = &akgl_default_gamemap;
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_VIDEO),
AKGL_ERR_SDL,
"Couldn't initialize SDL: %s",
SDL_GetError());
FAIL_ZERO_BREAK(
errctx,
TTF_Init(),
AKGL_ERR_SDL,
"Couldn't initialize the font engine: %s",
SDL_GetError());
FAIL_ZERO_BREAK(
errctx,
SDL_CreateWindowAndRenderer(
"net/aklabs/libakgl/test_perf_render",
BENCH_SCREEN_W,
BENCH_SCREEN_H,
0,
&akgl_window,
&akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"Couldn't create window/renderer: %s",
SDL_GetError());
CATCH(errctx, akgl_render_2d_bind(akgl_renderer));
akgl_camera->x = 0.0;
akgl_camera->y = 0.0;
akgl_camera->w = BENCH_SCREEN_W;
akgl_camera->h = BENCH_SCREEN_H;
// akgl_game_update takes this before it does anything else. Normally
// akgl_game_init creates it; this suite brings the library up piece by
// piece so it can stay headless, so it creates the mutex itself.
akgl_game.statelock = SDL_CreateMutex();
FAIL_ZERO_BREAK(errctx, akgl_game.statelock, AKGL_ERR_SDL, "%s", SDL_GetError());
// akgl_game.lowfpsfunc is deliberately left NULL. It used to have to be
// installed by hand here, because akgl_game_update_fps called it
// unguarded on every frame under 30 fps -- which includes the first,
// before there is a frame rate to compare against -- and a host that
// binds its own renderer the way renderer.h documents never went through
// akgl_game_init to get one. It installs the default itself now, and
// leaving it NULL here is what keeps that true.
benchfont = TTF_OpenFont(BENCH_FONT_PATH, BENCH_FONT_SIZE);
FAIL_ZERO_BREAK(errctx, benchfont, AKGL_ERR_SDL, "Couldn't open %s: %s", BENCH_FONT_PATH, SDL_GetError());
benchtiles = IMG_LoadTexture(akgl_renderer->sdl_renderer, BENCH_TILESET_IMAGE);
FAIL_ZERO_BREAK(errctx, benchtiles, AKGL_ERR_SDL, "Couldn't load %s: %s", BENCH_TILESET_IMAGE, SDL_GetError());
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, akgl_registry_init_properties());
CATCH(errctx, bench_frame());
CATCH(errctx, bench_primitives());
CATCH(errctx, bench_framebuffer());
CATCH(errctx, bench_text());
CATCH(errctx, bench_asset_load());
CATCH(errctx, bench_map_zero());
CATCH(errctx, bench_tileset_offsets());
CATCH(errctx, bench_tilemap_draw());
CATCH(errctx, bench_raw_blit_control());
CATCH(errctx, bench_scene());
CATCH(errctx, bench_game_update());
BENCH_REPORT_BREAK(errctx);
} CLEANUP {
if ( benchtiles != NULL ) {
SDL_DestroyTexture(benchtiles);
}
if ( benchfont != NULL ) {
TTF_CloseFont(benchfont);
}
TTF_Quit();
SDL_Quit();
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}