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
This commit is contained in:
2026-08-02 10:46:10 -04:00
parent 802bbcc17a
commit 5709dc160c
7 changed files with 451 additions and 24 deletions

View File

@@ -251,6 +251,7 @@ akerr_ErrorContext *akbasic_frontend_akgl_pump(void *self, bool *running)
{
PREPARE_ERROR(errctx);
akbasic_AkglFrontend *obj = (akbasic_AkglFrontend *)self;
bool resume = false;
FAIL_ZERO_RETURN(errctx, (obj != NULL && running != NULL), AKERR_NULLPOINTER,
"NULL argument in frontend_akgl_pump");
@@ -262,12 +263,37 @@ akerr_ErrorContext *akbasic_frontend_akgl_pump(void *self, bool *running)
}
/*
* No clear. The graphics verbs draw straight to the renderer rather than
* into a display list, so clearing here would wipe every DRAW the moment the
* frame it was issued in ended. The text layer is drawn over whatever is
* already there, which is also how a C128 stacks its text plane on its
* bitmap plane.
* **Take the drawing layer off the target before presenting.**
*
* SDL refuses to present while a render target is current, and this function
* is called from two places with different answers to "is one current". The
* frame loop calls it between steps, with the layer off. The sink's *line
* editor* calls it from inside a step -- borrowing a frame while it waits
* for a typed line, which is how a REPL redraws without owning a loop -- and
* there the layer is very much on.
*
* So end it here and put it back afterwards. `resume` remembers which case
* this was, because unconditionally beginning would leave the layer current
* after a frame-loop pump and every subsequent draw would land in it at the
* wrong time.
*/
resume = akbasic_graphics_akgl_layer_active(&obj->graphics);
PASS(errctx, akbasic_graphics_akgl_end(&obj->graphics));
/*
* No clear. The text layer is drawn over whatever is already there, which is
* also how a C128 stacks its text plane on its bitmap plane -- and the
* drawing layer beneath it is composited rather than accumulated, so there
* is nothing here that a clear would fix.
*/
/*
* The drawing layer first, under everything. A drawing verb rendered into a
* texture rather than into the back buffer -- see
* akbasic_graphics_akgl_begin() -- so this is where what the program drew
* reaches the frame, and it is why a picture no longer has to be redrawn
* every frame or captured into a sprite to be kept.
*/
PASS(errctx, akbasic_graphics_akgl_render(&obj->graphics));
PASS(errctx, akbasic_sink_akgl_render(&obj->akglsink));
/*
* Sprites last, so they are over the text as well as over the drawing. That
@@ -278,6 +304,9 @@ akerr_ErrorContext *akbasic_frontend_akgl_pump(void *self, bool *running)
PASS(errctx, akbasic_sprite_akgl_render(&obj->sprites));
FAIL_ZERO_RETURN(errctx, SDL_RenderPresent(obj->renderer->sdl_renderer),
AKGL_ERR_SDL, "Couldn't present the frame: %s", SDL_GetError());
if ( resume ) {
PASS(errctx, akbasic_graphics_akgl_begin(&obj->graphics));
}
SUCCEED_RETURN(errctx);
}
@@ -297,7 +326,16 @@ static akerr_ErrorContext AKERR_NOIGNORE *drive_loop(akbasic_AkglFrontend *obj,
* clock_gettime and a _POSIX_C_SOURCE.
*/
PASS(errctx, akbasic_runtime_settime(rt, (int64_t)SDL_GetTicks()));
/*
* The steps run with the drawing layer current, so a DRAW lands in a
* texture that outlives the frame. Bracketed here rather than inside
* each verb: one pair of SDL_SetRenderTarget calls a frame instead of
* one per statement, and it is also what makes SSHAPE read back what the
* program has just drawn.
*/
PASS(errctx, akbasic_graphics_akgl_begin(&obj->graphics));
PASS(errctx, akbasic_runtime_run(rt, AKBASIC_FRONTEND_STEPS_PER_FRAME));
PASS(errctx, akbasic_graphics_akgl_end(&obj->graphics));
PASS(errctx, akbasic_frontend_akgl_pump(obj, &running));
}
SUCCEED_RETURN(errctx);
@@ -320,6 +358,7 @@ void akbasic_frontend_akgl_shutdown(akbasic_AkglFrontend *obj)
}
/* Before the renderer goes: these are textures it created. */
akbasic_sprite_akgl_shutdown(&obj->sprites);
akbasic_graphics_akgl_shutdown(&obj->graphics);
if ( obj->font != NULL ) {
TTF_CloseFont(obj->font);
obj->font = NULL;

View File

@@ -262,3 +262,143 @@ akerr_ErrorContext *akbasic_graphics_init_akgl(akbasic_GraphicsBackend *obj, akb
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;
}
}