/** * @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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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. * * It used to be measurably worse than that: the update sweep was nested inside * a loop over #AKGL_TILEMAP_MAX_LAYERS that never filtered by layer, so every * live actor updated sixteen times a frame, and this benchmark sat about 92 us * above the scene benchmark below it. That gap *was* the multiplier. Since * 0.5.0 the two are equal within run-to-run noise, which is the shape of the * fix rather than a number worth quoting on its own. */ static akerr_ErrorContext *bench_game_update(void) { PREPARE_ERROR(errctx); akerr_ErrorContext *inner = NULL; int count = bench_iterations(30); int i = 0; int rep = 0; PASS(errctx, bench_populate_scene()); PASS(errctx, bench_build_map(akgl_gamemap, 1)); PASS(errctx, akgl_physics_init_null(akgl_physics)); for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { bench_start("game_update, full frame", "frame", 170000000.0); BENCH_LOOP(inner, i, count, akgl_game_update(NULL)); BENCH_FLUSH_STOP(errctx, count); PASS(errctx, inner); } SUCCEED_RETURN(errctx); } int main(void) { PREPARE_ERROR(errctx); SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software"); SDL_SetLogOutputFunction(bench_discard_log, NULL); ATTEMPT { CATCH(errctx, akgl_error_init()); TEST_TRAP_UNHANDLED_ERRORS(); akgl_renderer = &akgl_default_renderer; akgl_physics = &akgl_default_physics; akgl_camera = &akgl_default_camera; akgl_gamemap = &akgl_default_gamemap; FAIL_ZERO_BREAK( errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError()); FAIL_ZERO_BREAK( errctx, TTF_Init(), AKGL_ERR_SDL, "Couldn't initialize the font engine: %s", SDL_GetError()); FAIL_ZERO_BREAK( errctx, SDL_CreateWindowAndRenderer( "net/aklabs/libakgl/test_perf_render", BENCH_SCREEN_W, BENCH_SCREEN_H, 0, &akgl_window, &akgl_renderer->sdl_renderer), AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError()); CATCH(errctx, akgl_render_2d_bind(akgl_renderer)); akgl_camera->x = 0.0; akgl_camera->y = 0.0; akgl_camera->w = BENCH_SCREEN_W; akgl_camera->h = BENCH_SCREEN_H; // akgl_game_update takes this before it does anything else. Normally // akgl_game_init creates it; this suite brings the library up piece by // piece so it can stay headless, so it creates the mutex itself. akgl_game.statelock = SDL_CreateMutex(); FAIL_ZERO_BREAK(errctx, akgl_game.statelock, AKGL_ERR_SDL, "%s", SDL_GetError()); // akgl_game.lowfpsfunc is deliberately left NULL. It used to have to be // installed by hand here, because akgl_game_update_fps called it // unguarded on every frame under 30 fps -- which includes the first, // before there is a frame rate to compare against -- and a host that // binds its own renderer the way renderer.h documents never went through // akgl_game_init to get one. It installs the default itself now, and // leaving it NULL here is what keeps that true. benchfont = TTF_OpenFont(BENCH_FONT_PATH, BENCH_FONT_SIZE); FAIL_ZERO_BREAK(errctx, benchfont, AKGL_ERR_SDL, "Couldn't open %s: %s", BENCH_FONT_PATH, SDL_GetError()); benchtiles = IMG_LoadTexture(akgl_renderer->sdl_renderer, BENCH_TILESET_IMAGE); FAIL_ZERO_BREAK(errctx, benchtiles, AKGL_ERR_SDL, "Couldn't load %s: %s", BENCH_TILESET_IMAGE, SDL_GetError()); CATCH(errctx, akgl_heap_init()); CATCH(errctx, akgl_registry_init()); CATCH(errctx, akgl_registry_init_properties()); CATCH(errctx, bench_frame()); CATCH(errctx, bench_primitives()); CATCH(errctx, bench_framebuffer()); CATCH(errctx, bench_text()); CATCH(errctx, bench_asset_load()); CATCH(errctx, bench_map_zero()); CATCH(errctx, bench_tileset_offsets()); CATCH(errctx, bench_tilemap_draw()); CATCH(errctx, bench_raw_blit_control()); CATCH(errctx, bench_scene()); CATCH(errctx, bench_game_update()); BENCH_REPORT_BREAK(errctx); } CLEANUP { if ( benchtiles != NULL ) { SDL_DestroyTexture(benchtiles); } if ( benchfont != NULL ) { TTF_CloseFont(benchfont); } TTF_Quit(); SDL_Quit(); } PROCESS(errctx) { } FINISH_NORETURN(errctx); }