Skip to content

Commit 0e960ef

Browse files
committed
picogame: add a RAM-framebuffer render backend (Fruit Jam / scanout targets)
Add picogame_render_framebuffer() + a picogame.Framebuffer render-target type, and let Scene and the module-level render() target a framebuffer instead of a BusDisplay — compositing dirty rects / regions straight into a caller-owned wire-order RGB565 buffer. For RP2350 DVI/HSTX scanout boards (Adafruit Fruit Jam) and other framebuffer platforms; the SPI/DMA bus path is unchanged. Gated behind CIRCUITPY_PICOGAME_FRAMEBUFFER (default off): 0 flash on SPI-only boards.
1 parent 385259e commit 0e960ef

7 files changed

Lines changed: 338 additions & 67 deletions

File tree

‎py/circuitpy_mpconfig.mk‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,12 @@ CFLAGS += -DCIRCUITPY_PICOGAME_FAST_DISPLAY=$(CIRCUITPY_PICOGAME_FAST_DISPLAY)
589589
# game can auto-enable Display(rgb444=...) only where it works. Default 0 (safe RGB565).
590590
CIRCUITPY_PICOGAME_RGB444 ?= 0
591591
CFLAGS += -DCIRCUITPY_PICOGAME_RGB444=$(CIRCUITPY_PICOGAME_RGB444)
592+
# Full-frame RAM-framebuffer render backend (picogame_render_framebuffer): for scanout-buffer
593+
# platforms that composite into a RAM framebuffer instead of an SPI strip bus (RP2350 DVI/HSTX
594+
# like the Adafruit Fruit Jam, the desktop sim, the WASM playground). Off by default so
595+
# flash-tight SPI-only boards (PicoPad) don't carry the unused function.
596+
CIRCUITPY_PICOGAME_FRAMEBUFFER ?= 0
597+
CFLAGS += -DCIRCUITPY_PICOGAME_FRAMEBUFFER=$(CIRCUITPY_PICOGAME_FRAMEBUFFER)
592598

593599
CIRCUITPY_STATUS_BAR ?= 1
594600
CFLAGS += -DCIRCUITPY_STATUS_BAR=$(CIRCUITPY_STATUS_BAR)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// This file is part of the CircuitPython project: https://circuitpython.org
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
#pragma once
6+
7+
#include "shared-module/picogame/__init__.h" // picogame_framebuffer_obj_t
8+
9+
#if CIRCUITPY_PICOGAME_FRAMEBUFFER
10+
extern const mp_obj_type_t picogame_framebuffer_type;
11+
#endif

‎shared-bindings/picogame/Scene.c‎

Lines changed: 145 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "shared-bindings/picogame/Tilemap.h"
1313
#include "shared-bindings/picogame/Particles.h"
1414
#include "shared-bindings/picogame/Canvas.h"
15+
#include "shared-bindings/picogame/Framebuffer.h"
1516
#include "shared-bindings/busdisplay/BusDisplay.h"
1617
#if CIRCUITPY_PICOGAME_FAST_DISPLAY
1718
#include "shared-bindings/picogame/Display.h"
@@ -28,6 +29,36 @@
2829
#define SCENE_INIT_CAP 8
2930
#define PICOGAME_MAX_DIRTY_RECTS 6 // separate regions repainted per refresh
3031

32+
// Classify the `display` arg into a render backend and return the object Scene should
33+
// store: a fast picogame.Display (DMA), a picogame.Framebuffer (RAM scanout buffer, when
34+
// built in), or a plain busdisplay (portable bus.send). Sets *fast / *fb_target
35+
// accordingly; for a busdisplay it accepts a SUBCLASS (e.g. adafruit_st7789.ST7789) by
36+
// casting to its native base and returns that. Raises TypeError otherwise. This is where
37+
// the flag-gated type checks live, so the constructor body stays clean.
38+
static mp_obj_t scene_resolve_target(mp_obj_t disp, bool *fast, bool *fb_target) {
39+
*fast = false;
40+
*fb_target = false;
41+
#if CIRCUITPY_PICOGAME_FAST_DISPLAY
42+
if (mp_obj_is_type(disp, &picogame_display_type)) {
43+
*fast = true;
44+
return disp;
45+
}
46+
#endif
47+
#if CIRCUITPY_PICOGAME_FRAMEBUFFER
48+
if (mp_obj_is_type(disp, &picogame_framebuffer_type)) {
49+
*fb_target = true; // refresh() composites dirty rects straight into its RAM buffer
50+
return disp;
51+
}
52+
#endif
53+
// Plain busdisplay: accept a subclass by casting to its native base; the portable
54+
// renderer treats self->display as a busdisplay_busdisplay_obj_t directly.
55+
mp_obj_t native = mp_obj_cast_to_native_base(disp, &busdisplay_busdisplay_type);
56+
if (!mp_obj_is_type(native, &busdisplay_busdisplay_type)) {
57+
mp_raise_TypeError(MP_ERROR_TEXT("expected a BusDisplay"));
58+
}
59+
return native;
60+
}
61+
3162
//| class Scene:
3263
//| """Retained-mode scene with dirty-rectangle rendering. Add sprites and
3364
//| tilemaps once (tilemaps first = bottom layer), mutate them each frame,
@@ -59,33 +90,27 @@ static mp_obj_t picogame_scene_make_new(const mp_obj_type_t *type, size_t n_args
5990
mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)];
6091
mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args);
6192

62-
// Accept either the fast picogame.Display (DMA, where available) or a plain
63-
// busdisplay (rendered via the portable bus.send fallback -> cross-port).
64-
mp_obj_t disp = args[ARG_display].u_obj;
65-
bool fast = false;
66-
#if CIRCUITPY_PICOGAME_FAST_DISPLAY
67-
if (mp_obj_is_type(disp, &picogame_display_type)) {
68-
fast = true;
69-
} else
70-
#endif
71-
{
72-
// Plain busdisplay path (no fast DMA backend, or one passed directly): accept a SUBCLASS
73-
// too (e.g. adafruit_st7789.ST7789, a Python subclass of busdisplay.BusDisplay) by casting
74-
// to its native base. Store the native object, since the portable renderer below treats
75-
// self->display as a busdisplay_busdisplay_obj_t directly.
76-
mp_obj_t native = mp_obj_cast_to_native_base(disp, &busdisplay_busdisplay_type);
77-
if (!mp_obj_is_type(native, &busdisplay_busdisplay_type)) {
78-
mp_raise_TypeError(MP_ERROR_TEXT("expected a BusDisplay"));
79-
}
80-
disp = native;
93+
// Resolve the render backend (fast Display / Framebuffer / busdisplay) - all the
94+
// flag-gated type checks are concentrated in scene_resolve_target().
95+
bool fast, fb_target;
96+
mp_obj_t disp = scene_resolve_target(args[ARG_display].u_obj, &fast, &fb_target);
97+
98+
// The framebuffer path composites directly into the target, so it needs no strip
99+
// buffers; the SPI/DMA paths do. Validate them only when they'll be used. (When
100+
// CIRCUITPY_PICOGAME_FRAMEBUFFER is off, fb_target is always false -> always validated,
101+
// as before.)
102+
if (!fb_target) {
103+
mp_buffer_info_t tmp;
104+
mp_get_buffer_raise(args[ARG_buffer_a].u_obj, &tmp, MP_BUFFER_WRITE);
105+
mp_get_buffer_raise(args[ARG_buffer_b].u_obj, &tmp, MP_BUFFER_WRITE);
81106
}
82-
mp_buffer_info_t tmp;
83-
mp_get_buffer_raise(args[ARG_buffer_a].u_obj, &tmp, MP_BUFFER_WRITE);
84-
mp_get_buffer_raise(args[ARG_buffer_b].u_obj, &tmp, MP_BUFFER_WRITE);
85107

86108
picogame_scene_obj_t *self = mp_obj_malloc(picogame_scene_obj_t, type);
87109
self->display = disp;
88110
self->fast = fast;
111+
#if CIRCUITPY_PICOGAME_FRAMEBUFFER
112+
self->fb_target = fb_target;
113+
#endif
89114
self->buf_a = args[ARG_buffer_a].u_obj;
90115
self->buf_b = args[ARG_buffer_b].u_obj;
91116
self->background = args[ARG_background].u_int;
@@ -271,36 +296,18 @@ static mp_obj_t picogame_scene_get_display(mp_obj_t self_in) {
271296
static MP_DEFINE_CONST_FUN_OBJ_1(picogame_scene_get_display_obj, picogame_scene_get_display);
272297
MP_PROPERTY_GETTER(picogame_scene_display_obj, (mp_obj_t)&picogame_scene_get_display_obj);
273298

274-
//| def refresh(self) -> Optional[list]:
275-
//| """Diff against the previous frame and repaint only the changed region(s).
276-
//| Returns the bounding dirty rect as a REUSED list [x1, y1, x2, y2] (read it
277-
//| immediately; it's overwritten next call), or None if nothing changed."""
278-
//| ...
279-
static mp_obj_t picogame_scene_refresh(mp_obj_t self_in) {
280-
picogame_scene_obj_t *self = MP_OBJ_TO_PTR(self_in);
281-
282-
// Resolve the underlying busdisplay from either backend.
283-
busdisplay_busdisplay_obj_t *bd;
284-
#if CIRCUITPY_PICOGAME_FAST_DISPLAY
285-
picogame_display_obj_t *disp = NULL;
286-
if (self->fast) {
287-
disp = MP_OBJ_TO_PTR(self->display);
288-
bd = disp->display;
289-
} else
290-
#endif
291-
{
292-
bd = MP_OBJ_TO_PTR(self->display);
293-
}
294-
int w = bd->core.width;
295-
int h = bd->core.height;
296-
297-
picogame_rect_t rects[PICOGAME_MAX_DIRTY_RECTS];
299+
// Compute the frame's dirty rectangles for a WxH target, clipped to the play rect
300+
// [left, w-right) x [top, h-bottom). On the first refresh (or after invalidate) this is
301+
// a single full-screen rect + a snapshot sync; afterwards it diffs against the previous
302+
// frame. Returns the rect count written to `rects` (0 = nothing to repaint).
303+
// Backend-agnostic, so the SPI/fast and framebuffer paths share the exact dirty logic.
304+
static int scene_collect_dirty_rects(picogame_scene_obj_t *self, int w, int h, picogame_rect_t *rects) {
298305
int nr;
299306
if (self->cleared) {
300307
nr = picogame_scene_compute_dirty_rects(self->items, self->kinds, self->snap,
301308
self->count, w, h, self->ox, self->oy, rects, PICOGAME_MAX_DIRTY_RECTS);
302309
if (nr == 0) {
303-
return mp_const_none;
310+
return 0;
304311
}
305312
} else {
306313
rects[0].x1 = 0;
@@ -333,7 +340,96 @@ static mp_obj_t picogame_scene_refresh(mp_obj_t self_in) {
333340
rects[kept].y2 = ry2;
334341
kept++;
335342
}
336-
nr = kept;
343+
return kept;
344+
}
345+
346+
// Store the bounding union of `nr` rects into the reusable dirty_rect list (no per-frame
347+
// tuple allocation) and return it. Shared by both refresh backends.
348+
static mp_obj_t scene_store_dirty(picogame_scene_obj_t *self, const picogame_rect_t *rects, int nr, int w, int h) {
349+
int ux1 = w, uy1 = h, ux2 = 0, uy2 = 0;
350+
for (int i = 0; i < nr; i++) {
351+
if (rects[i].x1 < ux1) {
352+
ux1 = rects[i].x1;
353+
}
354+
if (rects[i].y1 < uy1) {
355+
uy1 = rects[i].y1;
356+
}
357+
if (rects[i].x2 > ux2) {
358+
ux2 = rects[i].x2;
359+
}
360+
if (rects[i].y2 > uy2) {
361+
uy2 = rects[i].y2;
362+
}
363+
}
364+
mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(0), MP_OBJ_NEW_SMALL_INT(ux1));
365+
mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(1), MP_OBJ_NEW_SMALL_INT(uy1));
366+
mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(2), MP_OBJ_NEW_SMALL_INT(ux2));
367+
mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(3), MP_OBJ_NEW_SMALL_INT(uy2));
368+
return self->dirty_rect;
369+
}
370+
371+
#if CIRCUITPY_PICOGAME_FRAMEBUFFER
372+
// Retained-mode refresh against a RAM framebuffer target (scanout-buffer platforms:
373+
// WASM playground, desktop sim, FruitJam DVI/HSTX). Same dirty-rect logic as the SPI
374+
// path (via the shared helpers), but each rect is composited straight into the target
375+
// with picogame_render_framebuffer - no strip buffers, no bus transaction. A latched
376+
// StripDraw BaseException is re-raised (no bus to close). Self-contained here so
377+
// picogame_scene_refresh() keeps its original SPI/fast shape.
378+
static mp_obj_t scene_refresh_fb(picogame_scene_obj_t *self) {
379+
picogame_framebuffer_obj_t *fbt = MP_OBJ_TO_PTR(self->display);
380+
int w = fbt->width;
381+
int h = fbt->height;
382+
383+
picogame_rect_t rects[PICOGAME_MAX_DIRTY_RECTS];
384+
int nr = scene_collect_dirty_rects(self, w, h, rects);
385+
if (nr == 0) {
386+
return mp_const_none;
387+
}
388+
389+
for (int i = 0; i < nr; i++) {
390+
mp_obj_t exc = picogame_render_framebuffer(fbt->fb, fbt->width, fbt->height,
391+
self->items, self->kinds, self->count,
392+
rects[i].x1, rects[i].y1, rects[i].x2, rects[i].y2,
393+
self->background, self->ox, self->oy);
394+
if (exc != MP_OBJ_NULL) {
395+
nlr_raise(MP_OBJ_TO_PTR(exc));
396+
}
397+
}
398+
return scene_store_dirty(self, rects, nr, w, h);
399+
}
400+
#endif // CIRCUITPY_PICOGAME_FRAMEBUFFER
401+
402+
//| def refresh(self) -> Optional[list]:
403+
//| """Diff against the previous frame and repaint only the changed region(s).
404+
//| Returns the bounding dirty rect as a REUSED list [x1, y1, x2, y2] (read it
405+
//| immediately; it's overwritten next call), or None if nothing changed."""
406+
//| ...
407+
static mp_obj_t picogame_scene_refresh(mp_obj_t self_in) {
408+
picogame_scene_obj_t *self = MP_OBJ_TO_PTR(self_in);
409+
410+
#if CIRCUITPY_PICOGAME_FRAMEBUFFER
411+
if (self->fb_target) {
412+
return scene_refresh_fb(self);
413+
}
414+
#endif
415+
416+
// Resolve the underlying busdisplay from either backend.
417+
busdisplay_busdisplay_obj_t *bd;
418+
#if CIRCUITPY_PICOGAME_FAST_DISPLAY
419+
picogame_display_obj_t *disp = NULL;
420+
if (self->fast) {
421+
disp = MP_OBJ_TO_PTR(self->display);
422+
bd = disp->display;
423+
} else
424+
#endif
425+
{
426+
bd = MP_OBJ_TO_PTR(self->display);
427+
}
428+
int w = bd->core.width;
429+
int h = bd->core.height;
430+
431+
picogame_rect_t rects[PICOGAME_MAX_DIRTY_RECTS];
432+
int nr = scene_collect_dirty_rects(self, w, h, rects);
337433
if (nr == 0) {
338434
return mp_const_none;
339435
}
@@ -347,7 +443,6 @@ static mp_obj_t picogame_scene_refresh(mp_obj_t self_in) {
347443

348444
// Render each dirty rect independently; return their bounding union (kept for
349445
// the existing "dirty WxH" debug prints).
350-
int ux1 = w, uy1 = h, ux2 = 0, uy2 = 0;
351446
for (int i = 0; i < nr; i++) {
352447
#if CIRCUITPY_PICOGAME_FAST_DISPLAY
353448
if (self->fast) {
@@ -364,26 +459,9 @@ static mp_obj_t picogame_scene_refresh(mp_obj_t self_in) {
364459
rects[i].x1, rects[i].y1, rects[i].x2, rects[i].y2,
365460
self->background, self->ox, self->oy);
366461
}
367-
if (rects[i].x1 < ux1) {
368-
ux1 = rects[i].x1;
369-
}
370-
if (rects[i].y1 < uy1) {
371-
uy1 = rects[i].y1;
372-
}
373-
if (rects[i].x2 > ux2) {
374-
ux2 = rects[i].x2;
375-
}
376-
if (rects[i].y2 > uy2) {
377-
uy2 = rects[i].y2;
378-
}
379462
}
380463

381-
// Update the reusable list in place - no per-frame tuple allocation.
382-
mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(0), MP_OBJ_NEW_SMALL_INT(ux1));
383-
mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(1), MP_OBJ_NEW_SMALL_INT(uy1));
384-
mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(2), MP_OBJ_NEW_SMALL_INT(ux2));
385-
mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(3), MP_OBJ_NEW_SMALL_INT(uy2));
386-
return self->dirty_rect;
464+
return scene_store_dirty(self, rects, nr, w, h);
387465
}
388466
static MP_DEFINE_CONST_FUN_OBJ_1(picogame_scene_refresh_obj, picogame_scene_refresh);
389467

0 commit comments

Comments
 (0)