Files
libakgl/tests/perf_render.c
Andrew Kesterson 9924d74dcc Namespace every exported symbol, and bump to 0.5.0
Closes internal-consistency items 1 through 6, 12 and 13. Every include guard
is _AKGL_<FILE>_H_, every in-project header include is angled, and every
exported function, type and global carries the akgl_ prefix. This is an ABI
break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename
table.

The renames were driven by renaming each declaration and letting the compiler
find the uses, not by pattern substitution: renderer, physics and camera are
also parameter and struct-member names, and a sed would have rewritten
map->physics and every akgl_RenderBackend *renderer parameter without a word.

Item 4 turned out not to be cosmetic. The library exported a global called
renderer and tests/character.c defined an SDL_Renderer *renderer of its own;
the executable's definition preempted the library's, akgl_sprite_load_json
read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load
in that suite failed. The suite reported success anyway, because libakerror's
unhandled-error handler ends in exit(errctx->status), exit keeps only the low
byte, and AKGL_ERR_SDL is exactly 256. So character had been green while
running one of its four tests, and every suite in the tree was unable to fail
on the most common status in a library built on SDL.

Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which
collapses any status a byte cannot carry onto 1, and every suite installs it.
character binds a real backend with akgl_render_2d_bind. Its fourth test then
runs for the first time and fails on a defect it has asserted all along, so
akgl_heap_release_character now walks state_sprites with
AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot
-- TODO.md Defects item 21 and half of Carried over item 1.

AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so
akgl_game_state_lock waited roughly sixteen minutes rather than one second. It
is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and
tests/game.c holds the mutex from a second thread to assert the wait -- the
contended path had no coverage at all.

Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both
install() and a generated translation unit per header, so a header that ships
is a header that is checked. Writing that found registry.h, which used
SDL_PropertiesID in eight declarations and included no SDL header.

23/23 suites pass, memcheck is clean, reindent --check is clean.

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

839 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.
* akgl_game_update's update sweep is nested inside a loop over
* #AKGL_TILEMAP_MAX_LAYERS and does not filter by layer, so every live actor is
* updated sixteen times per frame rather than once. The gap between this
* benchmark and the scene benchmark plus the logic frame in tests/perf.c is that
* multiplier, and TODO.md carries it as a defect.
*/
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());
// And this one is not optional either. akgl_game_update_fps calls
// game.lowfpsfunc through the pointer, unguarded, on every frame under
// 30 fps -- which includes the first frame, before there is a frame rate
// to compare. A host that brings the library up the way renderer.h
// documents for an embedder, akgl_render_2d_bind over its own window,
// segfaults on its first akgl_game_update. Filed in TODO.md; installing
// the default by hand is the workaround.
akgl_game.lowfpsfunc = &akgl_game_lowfps;
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);
}