Split akgl_render_bind2d out of akgl_render_init2d
A host that owns its own window -- an embedded interpreter, which must not create one -- wants the six 2D function pointers without the window, the camera and the property registry that came with them. Until now the only options were to let libakgl own the window or to assign the vtable by hand. akgl_render_bind2d() installs the methods and nothing else, deliberately leaving self->sdl_renderer alone so a caller's own renderer survives it and a caller without one gets a backend that reports AKERR_NULLPOINTER rather than crashing. akgl_render_init2d() calls it once its window exists. tests/renderer.c is new and needs no display: the binding, frame start and end, both draw_texture paths and the draw_mesh stub against a software renderer under the dummy video driver. src/renderer.c goes from 10% line coverage to 56%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,7 @@ set(AKGL_TEST_SUITES
|
|||||||
json_helpers
|
json_helpers
|
||||||
physics
|
physics
|
||||||
registry
|
registry
|
||||||
|
renderer
|
||||||
sprite
|
sprite
|
||||||
staticstring
|
staticstring
|
||||||
text
|
text
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
* the six `akgl_render_2d_*` entry points. A different renderer is a different
|
* the six `akgl_render_2d_*` entry points. A different renderer is a different
|
||||||
* initializer populating the same struct, not a branch inside these functions.
|
* initializer populating the same struct, not a branch inside these functions.
|
||||||
*
|
*
|
||||||
|
* Those are two separable jobs, and a host that already owns an `SDL_Renderer`
|
||||||
|
* -- an embedded interpreter, which must not create the window -- wants only the
|
||||||
|
* second. akgl_render_bind2d() is that half on its own; akgl_render_init2d()
|
||||||
|
* makes the window and then calls it.
|
||||||
|
*
|
||||||
* Callers do not normally name the `akgl_render_2d_*` functions directly; they
|
* Callers do not normally name the `akgl_render_2d_*` functions directly; they
|
||||||
* go through the pointers on the backend (`renderer->frame_start(renderer)`),
|
* go through the pointers on the backend (`renderer->frame_start(renderer)`),
|
||||||
* which is what makes the swap possible. The global `renderer` in game.h is the
|
* which is what makes the swap possible. The global `renderer` in game.h is the
|
||||||
@@ -139,14 +144,36 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_mesh(akgl_RenderBackend *
|
|||||||
*/
|
*/
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_world(akgl_RenderBackend *self, akgl_Iterator *opflags);
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_world(akgl_RenderBackend *self, akgl_Iterator *opflags);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Install the 2D backend's methods on a backend the caller owns.
|
||||||
|
*
|
||||||
|
* The vtable half of akgl_render_init2d(), with no window, no renderer, and no
|
||||||
|
* property registry involved: it points @p self's six function pointers at the
|
||||||
|
* `akgl_render_2d_*` entry points and returns. `sdl_renderer` is not touched,
|
||||||
|
* so a caller who has already put its own `SDL_Renderer` there keeps it, and a
|
||||||
|
* caller who has not gets a backend whose entry points all report
|
||||||
|
* `AKERR_NULLPOINTER` rather than crash.
|
||||||
|
*
|
||||||
|
* This is the entry point for a host that owns its own window -- it can drive
|
||||||
|
* akgl_actor_render(), akgl_tilemap_draw() and the rest through a backend
|
||||||
|
* libakgl never created. akgl_render_init2d() is the same thing with a window
|
||||||
|
* in front of it.
|
||||||
|
*
|
||||||
|
* @param self The backend to bind. Required. Only the method pointers are
|
||||||
|
* written.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_bind2d(akgl_RenderBackend *self);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Create the window and SDL renderer, and bind the 2D backend's methods.
|
* @brief Create the window and SDL renderer, and bind the 2D backend's methods.
|
||||||
*
|
*
|
||||||
* Reads `game.screenwidth` and `game.screenheight` from the property registry
|
* Reads `game.screenwidth` and `game.screenheight` from the property registry
|
||||||
* (both defaulting to the string "0", which asks SDL for a zero-sized window),
|
* (both defaulting to the string "0", which asks SDL for a zero-sized window),
|
||||||
* creates the window and renderer with `game.uri` as the title, points `camera`
|
* creates the window and renderer with `game.uri` as the title, points `camera`
|
||||||
* at the full screen rectangle, and installs the six `akgl_render_2d_*` function
|
* at the full screen rectangle, and then calls akgl_render_bind2d() to install
|
||||||
* pointers on @p self.
|
* the six `akgl_render_2d_*` function pointers on @p self.
|
||||||
*
|
*
|
||||||
* Because the dimensions come from the registry, akgl_registry_init_properties
|
* Because the dimensions come from the registry, akgl_registry_init_properties
|
||||||
* and the property writes have to happen first -- see the note on
|
* and the property writes have to happen first -- see the note on
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_render_init2d(akgl_RenderBackend *self)
|
|||||||
camera->w = screenwidth;
|
camera->w = screenwidth;
|
||||||
camera->h = screenheight;
|
camera->h = screenheight;
|
||||||
|
|
||||||
|
PASS(e, akgl_render_bind2d(self));
|
||||||
|
SUCCEED_RETURN(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_bind2d(akgl_RenderBackend *self)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(e);
|
||||||
|
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||||
|
|
||||||
|
// Deliberately does not touch self->sdl_renderer: a host that owns its own
|
||||||
|
// window has already put one there, and this is the only way it gets a
|
||||||
|
// usable backend without libakgl creating a second window.
|
||||||
self->shutdown = &akgl_render_2d_shutdown;
|
self->shutdown = &akgl_render_2d_shutdown;
|
||||||
self->frame_start = &akgl_render_2d_frame_start;
|
self->frame_start = &akgl_render_2d_frame_start;
|
||||||
self->frame_end = &akgl_render_2d_frame_end;
|
self->frame_end = &akgl_render_2d_frame_end;
|
||||||
|
|||||||
222
tests/renderer.c
Normal file
222
tests/renderer.c
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* @file renderer.c
|
||||||
|
* @brief Unit tests for the 2D render backend's vtable and its entry points.
|
||||||
|
*
|
||||||
|
* None of this needs a display. akgl_render_bind2d() installs the backend's
|
||||||
|
* methods and nothing else, so it can be checked against a backend that has no
|
||||||
|
* SDL_Renderer at all; the entry points that do draw are checked against a
|
||||||
|
* software renderer under the dummy video driver, the same way tests/draw.c
|
||||||
|
* does it.
|
||||||
|
*
|
||||||
|
* akgl_render_init2d() is not covered here: it creates a window from the
|
||||||
|
* property registry and writes the `camera` global, which is what the offscreen
|
||||||
|
* harness described in TODO.md exists to make testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <akerror.h>
|
||||||
|
|
||||||
|
#include <akgl/error.h>
|
||||||
|
#include <akgl/game.h>
|
||||||
|
#include <akgl/renderer.h>
|
||||||
|
|
||||||
|
#include "testutil.h"
|
||||||
|
|
||||||
|
/** @brief Width and height of the offscreen target the drawing tests use. */
|
||||||
|
#define TEST_TARGET_SIZE 32
|
||||||
|
|
||||||
|
/** @brief A backend bound to a live software renderer, built by main(). */
|
||||||
|
static akgl_RenderBackend bound;
|
||||||
|
|
||||||
|
akerr_ErrorContext *test_render_bind2d(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
akgl_RenderBackend backend;
|
||||||
|
|
||||||
|
ATTEMPT {
|
||||||
|
// The state a host is in when it owns its own window: a zeroed backend
|
||||||
|
// with somebody else's SDL_Renderer in it. Binding must fill in the six
|
||||||
|
// methods and leave that renderer alone -- creating a second window is
|
||||||
|
// precisely what an embedded interpreter must not do.
|
||||||
|
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
|
||||||
|
backend.sdl_renderer = (SDL_Renderer *)&bound;
|
||||||
|
|
||||||
|
TEST_EXPECT_OK(errctx, akgl_render_bind2d(&backend), "binding the 2D backend");
|
||||||
|
|
||||||
|
TEST_ASSERT(errctx, backend.sdl_renderer == (SDL_Renderer *)&bound,
|
||||||
|
"binding replaced the caller's SDL_Renderer");
|
||||||
|
TEST_ASSERT(errctx, backend.shutdown == &akgl_render_2d_shutdown,
|
||||||
|
"binding did not install shutdown");
|
||||||
|
TEST_ASSERT(errctx, backend.frame_start == &akgl_render_2d_frame_start,
|
||||||
|
"binding did not install frame_start");
|
||||||
|
TEST_ASSERT(errctx, backend.frame_end == &akgl_render_2d_frame_end,
|
||||||
|
"binding did not install frame_end");
|
||||||
|
TEST_ASSERT(errctx, backend.draw_texture == &akgl_render_2d_draw_texture,
|
||||||
|
"binding did not install draw_texture");
|
||||||
|
TEST_ASSERT(errctx, backend.draw_mesh == &akgl_render_2d_draw_mesh,
|
||||||
|
"binding did not install draw_mesh");
|
||||||
|
TEST_ASSERT(errctx, backend.draw_world == &akgl_render_2d_draw_world,
|
||||||
|
"binding did not install draw_world");
|
||||||
|
|
||||||
|
// Binding a backend that has no renderer yet is legal -- the two halves
|
||||||
|
// are separable in both directions -- and leaves it NULL rather than
|
||||||
|
// inventing one.
|
||||||
|
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
|
||||||
|
TEST_EXPECT_OK(errctx, akgl_render_bind2d(&backend),
|
||||||
|
"binding a backend with no SDL renderer");
|
||||||
|
TEST_ASSERT(errctx, backend.sdl_renderer == NULL,
|
||||||
|
"binding invented an SDL_Renderer");
|
||||||
|
|
||||||
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_bind2d(NULL),
|
||||||
|
"binding a NULL backend");
|
||||||
|
} CLEANUP {
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} FINISH(errctx, true);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *test_render_backend_without_a_renderer(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
akgl_RenderBackend empty;
|
||||||
|
|
||||||
|
ATTEMPT {
|
||||||
|
// Bound but never given an SDL_Renderer. Every entry point that draws
|
||||||
|
// has to report that rather than dereference it.
|
||||||
|
memset(&empty, 0x00, sizeof(akgl_RenderBackend));
|
||||||
|
CATCH(errctx, akgl_render_bind2d(&empty));
|
||||||
|
|
||||||
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, empty.frame_start(&empty),
|
||||||
|
"starting a frame on a backend with no renderer");
|
||||||
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, empty.frame_end(&empty),
|
||||||
|
"ending a frame on a backend with no renderer");
|
||||||
|
|
||||||
|
// Shutdown is the one that has nothing to release yet, so it succeeds.
|
||||||
|
TEST_EXPECT_OK(errctx, empty.shutdown(&empty), "shutting down an unused backend");
|
||||||
|
} CLEANUP {
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} FINISH(errctx, true);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *test_render_frames_and_textures(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
SDL_Surface *surface = NULL;
|
||||||
|
SDL_Texture *texture = NULL;
|
||||||
|
SDL_FRect dest;
|
||||||
|
SDL_FPoint center;
|
||||||
|
|
||||||
|
ATTEMPT {
|
||||||
|
dest.x = 0.0f;
|
||||||
|
dest.y = 0.0f;
|
||||||
|
dest.w = 8.0f;
|
||||||
|
dest.h = 8.0f;
|
||||||
|
center.x = 4.0f;
|
||||||
|
center.y = 4.0f;
|
||||||
|
|
||||||
|
surface = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32);
|
||||||
|
FAIL_ZERO_BREAK(errctx, surface, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||||
|
texture = SDL_CreateTextureFromSurface(bound.sdl_renderer, surface);
|
||||||
|
FAIL_ZERO_BREAK(errctx, texture, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||||
|
|
||||||
|
TEST_EXPECT_OK(errctx, bound.frame_start(&bound), "starting a frame");
|
||||||
|
TEST_EXPECT_OK(errctx,
|
||||||
|
bound.draw_texture(&bound, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
|
||||||
|
"blitting a texture unrotated");
|
||||||
|
// A non-zero angle takes the rotated path, which is the one that
|
||||||
|
// consults the pivot and the flip mode.
|
||||||
|
TEST_EXPECT_OK(errctx,
|
||||||
|
bound.draw_texture(&bound, texture, NULL, &dest, 90.0, ¢er, SDL_FLIP_HORIZONTAL),
|
||||||
|
"blitting a texture rotated");
|
||||||
|
TEST_EXPECT_OK(errctx, bound.frame_end(&bound), "ending a frame");
|
||||||
|
|
||||||
|
// A rotation with no pivot is refused: SDL's "NULL means the centre of
|
||||||
|
// dest" convention is not offered here, so a NULL is a caller mistake
|
||||||
|
// rather than a default.
|
||||||
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||||
|
bound.draw_texture(&bound, texture, NULL, &dest, 90.0, NULL, SDL_FLIP_NONE),
|
||||||
|
"blitting rotated with no pivot");
|
||||||
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||||
|
akgl_render_2d_draw_texture(NULL, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
|
||||||
|
"blitting through a NULL backend");
|
||||||
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||||
|
bound.draw_texture(&bound, NULL, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
|
||||||
|
"blitting a NULL texture");
|
||||||
|
} CLEANUP {
|
||||||
|
if ( texture != NULL ) {
|
||||||
|
SDL_DestroyTexture(texture);
|
||||||
|
}
|
||||||
|
if ( surface != NULL ) {
|
||||||
|
SDL_DestroySurface(surface);
|
||||||
|
}
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} FINISH(errctx, true);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *test_render_unimplemented_and_guards(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
ATTEMPT {
|
||||||
|
// The 3D hook is not a stub that quietly does nothing. It refuses, so a
|
||||||
|
// caller that reaches it finds out on the first frame.
|
||||||
|
TEST_EXPECT_STATUS(errctx, AKERR_API, bound.draw_mesh(&bound),
|
||||||
|
"drawing a mesh through the 2D backend");
|
||||||
|
|
||||||
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_draw_world(NULL, NULL),
|
||||||
|
"drawing the world through a NULL backend");
|
||||||
|
|
||||||
|
// Shutting the 2D backend down releases nothing today, and says so.
|
||||||
|
TEST_EXPECT_OK(errctx, bound.shutdown(&bound), "shutting the backend down");
|
||||||
|
} CLEANUP {
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} FINISH(errctx, true);
|
||||||
|
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");
|
||||||
|
|
||||||
|
ATTEMPT {
|
||||||
|
CATCH(errctx, akgl_error_init());
|
||||||
|
memset(&bound, 0x00, sizeof(akgl_RenderBackend));
|
||||||
|
|
||||||
|
FAIL_ZERO_BREAK(
|
||||||
|
errctx,
|
||||||
|
SDL_Init(SDL_INIT_VIDEO),
|
||||||
|
AKGL_ERR_SDL,
|
||||||
|
"Couldn't initialize SDL: %s",
|
||||||
|
SDL_GetError());
|
||||||
|
FAIL_ZERO_BREAK(
|
||||||
|
errctx,
|
||||||
|
SDL_CreateWindowAndRenderer(
|
||||||
|
"net/aklabs/libakgl/test_renderer",
|
||||||
|
TEST_TARGET_SIZE,
|
||||||
|
TEST_TARGET_SIZE,
|
||||||
|
0,
|
||||||
|
&window,
|
||||||
|
&bound.sdl_renderer),
|
||||||
|
AKGL_ERR_SDL,
|
||||||
|
"Couldn't create window/renderer: %s",
|
||||||
|
SDL_GetError());
|
||||||
|
// The host owns the window; libakgl only ever binds to it. This is the
|
||||||
|
// arrangement akgl_render_bind2d exists for.
|
||||||
|
CATCH(errctx, akgl_render_bind2d(&bound));
|
||||||
|
|
||||||
|
CATCH(errctx, test_render_bind2d());
|
||||||
|
CATCH(errctx, test_render_backend_without_a_renderer());
|
||||||
|
CATCH(errctx, test_render_frames_and_textures());
|
||||||
|
CATCH(errctx, test_render_unimplemented_and_guards());
|
||||||
|
} CLEANUP {
|
||||||
|
SDL_Quit();
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} FINISH_NORETURN(errctx);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user