Files
libakgl/tests/perf_render.c
Andrew Kesterson 8a90cbf275 Drop the perf suite's lowfpsfunc workaround, and record item 31
akgl_game_update_fps installs the default itself now, so the benchmark no
longer has to. Leaving the hook NULL there is what keeps that true.

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

838 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());
// 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);
}