Files
akbasic/src/graphics_akgl.c
Tachikoma d3b69ff867 Keep what a program draws, instead of making it a sprite
A drawing lasted exactly one frame. The verbs are immediate, they went to the
back buffer, SDL double-buffers and the frontend never clears -- so the only way
to keep a picture was to capture it with `SSHAPE` and install it as a sprite,
which is what `examples/breakout/sprites/breakout.bas` spends two of its eight
sprites doing. That was TODO.md section 9 item 9.

The drawing verbs now render into a layer texture the frame composites under the
text and the sprites. Draw once; it is there on every frame after.

**Bracketed around the step phase, not around each verb.** One pair of
`SDL_SetRenderTarget` calls a frame instead of one per `DRAW`, and it is also
what makes `SSHAPE` read back what the program has just drawn rather than
whatever the last frame left.

**The layer is transparent where nothing was drawn.** It covers the whole window
and composites underneath, so an opaque one would black out the frame the moment
a program issued a single `DRAW`. And a fresh SDL target texture's contents are
undefined, so it is cleared on creation -- skipping that puts uninitialised
memory under the first frame's text and looks like a driver bug rather than a
missing memset.

**The line editor forced a wrinkle worth naming.** `akbasic_frontend_akgl_pump()`
is called from two places with different answers to "is a render target current":
the frame loop calls it between steps, and the sink's editor calls it from
*inside* a step, borrowing a frame while it waits for a typed line. SDL refuses
to present while a target is current, so the pump ends the layer, presents, and
puts it back only if it was the one that ended it. `akgl_frontend` caught this --
it drives a REPL session, and it failed with "You can't present on a render
target" the first time the brackets went in.

This does not make a drawing *visible* on its own. The text layer still repaints
every row it owns, opaque, every frame, and by default it owns the whole window;
`WINDOW` shrinks it and that half was already fixed. The two together are what a
picture needed, and the tests assert both -- a pixel still there a frame later
with nothing redrawn, and a pixel below a shrunk text area surviving the text
repaint. The second assertion wipes to a non-black colour first, because against
black it could not tell a transparent layer from an opaque one.

The tests found two of their own bugs on the way: `stop_runtime()` was not
tearing the graphics backend down, so re-initialising it dropped a live texture
on the floor; and a first draft called `begin()` before `start_runtime()`, which
re-inits the backend, so the assertion read back off an orphaned render target
and passed while proving nothing.

Chapters 6 and 13 stop saying a drawing has to be redrawn every frame, because it
does not. The batch-boundary tear stays documented -- it bites an `SSHAPE`
capture, which matters much less now that capturing is not the only way to keep a
picture.

Both games still run clean. 111 with akgl, 110 without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 10:46:10 -04:00

405 lines
14 KiB
C

/**
* @file graphics_akgl.c
* @brief Wires the graphics backend record to libakgl's immediate-mode drawing.
*
* A thin adaptor and deliberately so: the interesting decisions about what a
* BASIC graphics verb means live in src/runtime_graphics.c, and everything here
* is coordinate and colour conversion plus the shape pool that SSHAPE hands out
* handles into.
*
* The akgl_draw_* family is new in libakgl 42b60f7. Every one of its entry
* points takes the akgl_RenderBackend the host already initialized rather than
* reaching for a global, which is exactly what goal 3 needs.
*/
#include <string.h>
#include <akerror.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akbasic/akgl.h>
#include <akbasic/error.h>
/** @brief The colour conversion, which is the whole impedance mismatch. */
static SDL_Color to_sdl(akbasic_Color color)
{
SDL_Color out;
out.r = color.r;
out.g = color.g;
out.b = color.b;
out.a = color.a;
return out;
}
/** @brief Recover the backend's own state, or say that it has none. */
static akerr_ErrorContext *state_of(akbasic_GraphicsBackend *self, akbasic_AkglGraphics **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in akgl graphics backend");
*dest = (akbasic_AkglGraphics *)self->self;
FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKERR_NULLPOINTER,
"akgl graphics backend has no state");
SUCCEED_RETURN(errctx);
}
/**
* @brief How big the renderer's output is, which is the space BASIC draws into.
*
* SDL_GetCurrentRenderOutputSize rather than the window size: the two differ on
* a high-DPI display and on a renderer with a logical presentation set, and it
* is the output that a coordinate handed to akgl_draw_point actually indexes.
*/
static akerr_ErrorContext *gfx_size(akbasic_GraphicsBackend *self, int *width, int *height)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (width != NULL && height != NULL), AKERR_NULLPOINTER,
"NULL destination in gfx_size");
FAIL_ZERO_RETURN(errctx,
SDL_GetCurrentRenderOutputSize(state->renderer->sdl_renderer, width, height),
AKGL_ERR_SDL, "%s", SDL_GetError());
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_point(akbasic_GraphicsBackend *self, double x, double y, akbasic_Color color)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
PASS(errctx, state_of(self, &state));
PASS(errctx, akgl_draw_point(state->renderer, (float32_t)x, (float32_t)y, to_sdl(color)));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_line(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
PASS(errctx, state_of(self, &state));
PASS(errctx, akgl_draw_line(state->renderer, (float32_t)x1, (float32_t)y1,
(float32_t)x2, (float32_t)y2, to_sdl(color)));
SUCCEED_RETURN(errctx);
}
/**
* @brief Turn two opposite corners into the rect SDL wants.
*
* BASIC gives two corners in whatever order the program felt like; SDL_FRect is
* an origin and a size and does nothing useful with a negative one.
*/
static SDL_FRect corners_to_rect(double x1, double y1, double x2, double y2)
{
SDL_FRect rect;
rect.x = (float32_t)((x1 < x2) ? x1 : x2);
rect.y = (float32_t)((y1 < y2) ? y1 : y2);
rect.w = (float32_t)((x1 < x2) ? (x2 - x1) : (x1 - x2));
rect.h = (float32_t)((y1 < y2) ? (y2 - y1) : (y1 - y2));
return rect;
}
static akerr_ErrorContext *gfx_rect(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
SDL_FRect rect;
PASS(errctx, state_of(self, &state));
rect = corners_to_rect(x1, y1, x2, y2);
PASS(errctx, akgl_draw_rect(state->renderer, &rect, to_sdl(color)));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_filled_rect(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
SDL_FRect rect;
PASS(errctx, state_of(self, &state));
rect = corners_to_rect(x1, y1, x2, y2);
PASS(errctx, akgl_draw_filled_rect(state->renderer, &rect, to_sdl(color)));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_paint(akbasic_GraphicsBackend *self, int x, int y, akbasic_Color color)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
PASS(errctx, state_of(self, &state));
/*
* AKERR_OUTOFBOUNDS out of here means the fill exhausted its span stack and
* left the region partly filled. It is passed straight through: PAINT is
* where that is turned into something a BASIC program can see, because PAINT
* is the only thing that knows a program is watching.
*/
PASS(errctx, akgl_draw_flood_fill(state->renderer, x, y, to_sdl(color)));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_clear(akbasic_GraphicsBackend *self, akbasic_Color color)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
SDL_FRect rect;
int w = 0;
int h = 0;
PASS(errctx, state_of(self, &state));
PASS(errctx, gfx_size(self, &w, &h));
/*
* A filled rectangle over the output rather than SDL_RenderClear, because
* clearing is the host's prerogative: a game that draws a background and
* then hands the renderer to a script does not expect GRAPHIC to wipe it to
* a colour of the script's choosing outside the area the script owns. This
* covers exactly the render output and nothing beyond it.
*/
rect.x = 0.0f;
rect.y = 0.0f;
rect.w = (float32_t)w;
rect.h = (float32_t)h;
PASS(errctx, akgl_draw_filled_rect(state->renderer, &rect, to_sdl(color)));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_save_shape(akbasic_GraphicsBackend *self, int x1, int y1, int x2, int y2, int *handle)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
SDL_Rect region;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (handle != NULL), AKERR_NULLPOINTER,
"NULL handle in save_shape");
FAIL_ZERO_RETURN(errctx, (state->shapecount < AKBASIC_AKGL_MAX_SHAPES),
AKBASIC_ERR_DEVICE,
"SSHAPE pool is full at %d saved regions; GRAPHIC CLR releases them",
AKBASIC_AKGL_MAX_SHAPES);
region.x = (x1 < x2) ? x1 : x2;
region.y = (y1 < y2) ? y1 : y2;
region.w = (x1 < x2) ? (x2 - x1) : (x1 - x2);
region.h = (y1 < y2) ? (y2 - y1) : (y1 - y2);
/*
* NULL through dest asks akgl_draw_copy_region to allocate the surface. That
* is the one allocation in this file and it belongs to the pool below, which
* is bounded -- so it is a fixed ceiling on memory, not an open one.
*/
state->shapes[state->shapecount] = NULL;
PASS(errctx, akgl_draw_copy_region(state->renderer, &region,
&state->shapes[state->shapecount]));
*handle = state->shapecount;
state->shapecount += 1;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_paste_shape(akbasic_GraphicsBackend *self, int handle, double x, double y)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (handle >= 0 && handle < state->shapecount),
AKBASIC_ERR_BOUNDS, "No saved shape %d", handle);
FAIL_ZERO_RETURN(errctx, (state->shapes[handle] != NULL), AKBASIC_ERR_STATE,
"Saved shape %d has been released", handle);
PASS(errctx, akgl_draw_paste_region(state->renderer, state->shapes[handle],
(float32_t)x, (float32_t)y));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_free_shapes(akbasic_GraphicsBackend *self)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
int i = 0;
PASS(errctx, state_of(self, &state));
for ( i = 0; i < state->shapecount; i++ ) {
if ( state->shapes[i] != NULL ) {
SDL_DestroySurface(state->shapes[i]);
state->shapes[i] = NULL;
}
}
state->shapecount = 0;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_graphics_init_akgl(akbasic_GraphicsBackend *obj, akbasic_AkglGraphics *state, akgl_RenderBackend *renderer)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL), AKERR_NULLPOINTER,
"NULL argument in graphics_init_akgl");
FAIL_ZERO_RETURN(errctx, (renderer != NULL), AKERR_NULLPOINTER,
"NULL renderer in graphics_init_akgl: the host creates it, not this");
PASS(errctx, akgl_error_init());
memset(state, 0, sizeof(*state));
state->renderer = renderer;
obj->self = state;
obj->size = gfx_size;
obj->point = gfx_point;
obj->line = gfx_line;
obj->rect = gfx_rect;
obj->filled_rect = gfx_filled_rect;
obj->paint = gfx_paint;
obj->clear = gfx_clear;
obj->save_shape = gfx_save_shape;
obj->paste_shape = gfx_paste_shape;
obj->free_shapes = gfx_free_shapes;
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------- the layer -- */
/**
* @brief Make sure the layer texture exists and is the size of the output.
*
* Created on demand rather than at init, because it is the size of the window
* and a program that never draws should not pay for one. Recreated if the
* output has since changed size, which is what a resized window looks like from
* here.
*/
static akerr_ErrorContext AKERR_NOIGNORE *ensure_layer(akbasic_AkglGraphics *state)
{
PREPARE_ERROR(errctx);
SDL_Texture *previous = NULL;
int w = 0;
int h = 0;
float texw = 0.0f;
float texh = 0.0f;
FAIL_ZERO_RETURN(errctx, SDL_GetCurrentRenderOutputSize(state->renderer->sdl_renderer, &w, &h),
AKGL_ERR_SDL, "Couldn't measure the render output: %s", SDL_GetError());
if ( state->layer != NULL ) {
if ( SDL_GetTextureSize(state->layer, &texw, &texh)
&& (int)texw == w && (int)texh == h ) {
SUCCEED_RETURN(errctx);
}
SDL_DestroyTexture(state->layer);
state->layer = NULL;
}
state->layer = SDL_CreateTexture(state->renderer->sdl_renderer,
SDL_PIXELFORMAT_RGBA8888,
SDL_TEXTUREACCESS_TARGET, w, h);
FAIL_ZERO_RETURN(errctx, (state->layer != NULL), AKGL_ERR_SDL,
"Couldn't create the drawing layer: %s", SDL_GetError());
/*
* Blended, and cleared to fully transparent. The layer covers the whole
* window and is composited *under* the text and the sprites, so anywhere the
* program has not drawn has to let what is behind it through -- otherwise a
* single DRAW would black out the entire frame.
*/
FAIL_ZERO_RETURN(errctx, SDL_SetTextureBlendMode(state->layer, SDL_BLENDMODE_BLEND),
AKGL_ERR_SDL, "%s", SDL_GetError());
/*
* **And clear it, because a fresh target texture's contents are undefined.**
* Skipping this puts whatever was in that memory on the screen under the
* first frame's text, which is the kind of defect that looks like a driver
* bug and is not.
*/
previous = SDL_GetRenderTarget(state->renderer->sdl_renderer);
FAIL_ZERO_RETURN(errctx, SDL_SetRenderTarget(state->renderer->sdl_renderer, state->layer),
AKGL_ERR_SDL, "%s", SDL_GetError());
if ( !SDL_SetRenderDrawColor(state->renderer->sdl_renderer, 0, 0, 0, 0)
|| !SDL_RenderClear(state->renderer->sdl_renderer) ) {
/* Best effort: we are already failing, and leaving the target on the
layer would make the next unrelated draw land in the wrong place. */
(void)SDL_SetRenderTarget(state->renderer->sdl_renderer, previous);
FAIL_RETURN(errctx, AKGL_ERR_SDL, "Couldn't clear the drawing layer: %s", SDL_GetError());
}
FAIL_ZERO_RETURN(errctx, SDL_SetRenderTarget(state->renderer->sdl_renderer, previous),
AKGL_ERR_SDL, "%s", SDL_GetError());
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_graphics_akgl_begin(akbasic_GraphicsBackend *obj)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
if ( obj == NULL || obj->self == NULL ) {
SUCCEED_RETURN(errctx);
}
state = (akbasic_AkglGraphics *)obj->self;
PASS(errctx, ensure_layer(state));
state->savedtarget = SDL_GetRenderTarget(state->renderer->sdl_renderer);
FAIL_ZERO_RETURN(errctx, SDL_SetRenderTarget(state->renderer->sdl_renderer, state->layer),
AKGL_ERR_SDL, "Couldn't make the drawing layer current: %s", SDL_GetError());
state->layeractive = true;
SUCCEED_RETURN(errctx);
}
bool akbasic_graphics_akgl_layer_active(akbasic_GraphicsBackend *obj)
{
if ( obj == NULL || obj->self == NULL ) {
return false;
}
return ((akbasic_AkglGraphics *)obj->self)->layeractive;
}
akerr_ErrorContext *akbasic_graphics_akgl_end(akbasic_GraphicsBackend *obj)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
if ( obj == NULL || obj->self == NULL ) {
SUCCEED_RETURN(errctx);
}
state = (akbasic_AkglGraphics *)obj->self;
if ( !state->layeractive ) {
SUCCEED_RETURN(errctx);
}
state->layeractive = false;
FAIL_ZERO_RETURN(errctx,
SDL_SetRenderTarget(state->renderer->sdl_renderer, state->savedtarget),
AKGL_ERR_SDL, "Couldn't restore the render target: %s", SDL_GetError());
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_graphics_akgl_render(akbasic_GraphicsBackend *obj)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
if ( obj == NULL || obj->self == NULL ) {
SUCCEED_RETURN(errctx);
}
state = (akbasic_AkglGraphics *)obj->self;
/* Nothing drawn, nothing to composite: a text-only program pays nothing. */
if ( state->layer == NULL ) {
SUCCEED_RETURN(errctx);
}
FAIL_ZERO_RETURN(errctx,
SDL_RenderTexture(state->renderer->sdl_renderer, state->layer, NULL, NULL),
AKGL_ERR_SDL, "Couldn't draw the drawing layer: %s", SDL_GetError());
SUCCEED_RETURN(errctx);
}
void akbasic_graphics_akgl_shutdown(akbasic_GraphicsBackend *obj)
{
akbasic_AkglGraphics *state = NULL;
if ( obj == NULL || obj->self == NULL ) {
return;
}
state = (akbasic_AkglGraphics *)obj->self;
if ( state->layer != NULL ) {
SDL_DestroyTexture(state->layer);
state->layer = NULL;
}
}