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>
838 lines
31 KiB
C
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, ®ion, &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, ®ion, &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);
|
|
}
|