From e12e8f1c3688eea38e77b8a70bdb36e7fbad8043 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 3 Oct 2026 04:06:38 +0200 Subject: [PATCH] Improve Cube3D rendering and controls --- App/apps/cube3d/README.md | 25 +++++ App/apps/cube3d/build.sh | 2 +- App/apps/cube3d/cube3d_app.c | 182 ++++++++++++++++++++++++++-------- App/apps/cube3d/gen_assets.py | 15 ++- 4 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 App/apps/cube3d/README.md diff --git a/App/apps/cube3d/README.md b/App/apps/cube3d/README.md new file mode 100644 index 00000000..c0f7f722 --- /dev/null +++ b/App/apps/cube3d/README.md @@ -0,0 +1,25 @@ +# Cube3D + +Cube3D renders rotating 3D shapes on the 128x64 monochrome display. Geometry +and lookup tables live in the app assets; the overlay uses fixed +point arithmetic and performs no hardware floating-point or division. + +The `GLOBE` scene has a dedicated renderer with latitude/longitude lines and +a stable circular limb. In wire mode its far side is +dotted; in solid mode it is hidden. Shape 10, `DODECA`, is reached with `*` +after the globe because the numeric keypad directly selects shapes 1 through 9. + +## Keys + +| Key | Action | +|---|---| +| 1-9 | Select a shape directly (`9` selects the globe) | +| * | Next shape | +| UP / DOWN | Increase / decrease rotation speed | +| F then UP / DOWN | Zoom in / out (eight levels) | +| F then * | Toggle wire / solid hidden-line rendering (`W` / `S`) | +| 0 | Reset the orientation and zoom | +| MENU | Pause / resume | +| EXIT | Quit | + +The status bar shows the shape name, the armed `F`, and `PAUSE` when stopped. diff --git a/App/apps/cube3d/build.sh b/App/apps/cube3d/build.sh index 8918bcfc..b43aa738 100755 --- a/App/apps/cube3d/build.sh +++ b/App/apps/cube3d/build.sh @@ -8,7 +8,7 @@ set -euo pipefail APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ... APP_NAME="Cube3D" # <-- the only per-app line -APP_VER="1.1" +APP_VER="1.2" APP_API_MIN=2 APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld) OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM) diff --git a/App/apps/cube3d/cube3d_app.c b/App/apps/cube3d/cube3d_app.c index 0547ae63..74c9b72e 100644 --- a/App/apps/cube3d/cube3d_app.c +++ b/App/apps/cube3d/cube3d_app.c @@ -19,7 +19,7 @@ * Vertices are spun by three axis rotations in Q14 fixed point (Cortex-M0+ has * no FPU and no hardware divide), then perspective-projected with a multiply by * a reciprocal from the assets, exactly equal to the divide it replaces, so no - * libgcc division is linked. Two looks, toggled with F: + * libgcc division is linked. Two looks, toggled with F then STAR: * - SOLID: hidden-line removal by back-face culling (a face is drawn only when * the signed area of its projected polygon shows it facing us). * - WIRE : every edge, with the far hemisphere dotted for a depth cue. @@ -28,11 +28,11 @@ * speeds are read-only assets (gen_assets.py; faces with a uniform outward * winding from an offline convex-hull extractor). They are copied onto the stack * (the sine once, the displayed solid every frame), so none of them occupies the - * 4 KiB overlay and a solid can be added without touching this file. + * 4 KiB overlay. The globe is rendered procedurally as a latitude/longitude grid. * Pure compute, no radio. * - * Keys: UP/DOWN speed · 1-9 shape · STAR next shape · F solid/wire · - * MENU pause · EXIT quit. + * Keys: UP/DOWN speed · F+UP/DOWN zoom · 1-9 shape · STAR next shape · + * F+STAR solid/wire · 0 reset view · MENU pause · EXIT quit. */ #include @@ -50,6 +50,9 @@ static const app_api_t *A; static const int16_t *sinq; /* Q14 sine quadrant, on app_main's stack */ +static const uint16_t *recipq; +static int rcx, rsx, rcy, rsy, rcz, rsz; +static uint8_t zoomq; /* Q4 screen scale: 16 = 100% */ static int sin8(uint8_t angle) { @@ -111,6 +114,90 @@ static int proj(int n, uint32_t m) return n < 0 ? -q : q; } +/* Rotate, project and retain rotated depth for hidden-line decisions. */ +static void project_point(int x, int y, int z, int16_t *px, int16_t *py, int16_t *pz) +{ + int ny = (y * rcx - z * rsx) >> 14; + int nz = (y * rsx + z * rcx) >> 14; + y = ny; z = nz; + int nx = (x * rcy + z * rsy) >> 14; + nz = (z * rcy - x * rsy) >> 14; + x = nx; z = nz; + nx = (x * rcz - y * rsz) >> 14; + ny = (x * rsz + y * rcz) >> 14; + x = nx; y = ny; + + int zc = z + (int)DIST; + if (zc < (int)RECIP_ZMIN) zc = RECIP_ZMIN; + if (zc > (int)RECIP_ZMAX) zc = RECIP_ZMAX; + const uint32_t m = recipq[zc - (int)RECIP_ZMIN]; + int qx = proj(x * (int)FOCAL, m), qy = proj(y * (int)FOCAL, m); + qx = qx < 0 ? -(((-qx) * zoomq) >> 4) : (qx * zoomq) >> 4; + qy = qy < 0 ? -(((-qy) * zoomq) >> 4) : (qy * zoomq) >> 4; + *px = (int16_t)(CX + qx); + *py = (int16_t)(CY + qy); + *pz = (int16_t)z; +} + +#define GLOBE_R 36 +#define GLOBE_SCREEN_R 19 + +/* Latitude/longitude use the same 256-step turn as sin8(). Longitude zero is + * the front of the globe, and positive latitude is drawn upward. */ +static void globe_point(int8_t lat, int8_t lon, int16_t *x, int16_t *y, int16_t *z) +{ + const uint8_t la = (uint8_t)lat, lo = (uint8_t)lon; + const int r = (GLOBE_R * sin8((uint8_t)(la + 64u))) >> 14; + const int gx = (r * sin8(lo)) >> 14; + const int gy = -((GLOBE_R * sin8(la)) >> 14); + const int gz = -((r * sin8((uint8_t)(lo + 64u))) >> 14); + project_point(gx, gy, gz, x, y, z); +} + +static void globe_edge(int16_t x0, int16_t y0, int16_t z0, + int16_t x1, int16_t y1, int16_t z1, bool wire) +{ + if (z0 <= 0 && z1 <= 0) + draw_edge(x0, y0, x1, y1, false); + else if (wire && z0 >= 0 && z1 >= 0) + draw_edge(x0, y0, x1, y1, true); +} + +static void draw_globe(bool wire) +{ + int16_t x0, y0, z0, x1, y1, z1; + + /* Three parallels, each split into 16 short segments. */ + for (int8_t lat = -32; lat <= 32; lat += 32) { + globe_point(lat, 0, &x0, &y0, &z0); + for (uint16_t lon = 16; lon <= 256u; lon += 16u) { + globe_point(lat, (int8_t)lon, &x1, &y1, &z1); + globe_edge(x0, y0, z0, x1, y1, z1, wire); + x0 = x1; y0 = y1; z0 = z1; + } + } + + /* Eight pole-to-pole half-meridians make four complete great circles. */ + for (uint16_t lon = 0; lon < 256u; lon += 32u) { + globe_point(-64, (int8_t)lon, &x0, &y0, &z0); + for (int8_t lat = -48; lat <= 64; lat += 16) { + globe_point(lat, (int8_t)lon, &x1, &y1, &z1); + globe_edge(x0, y0, z0, x1, y1, z1, wire); + x0 = x1; y0 = y1; z0 = z1; + } + } + + /* A stable limb keeps the globe legible when most grid lines are edge-on. */ + const int limb = (GLOBE_SCREEN_R * zoomq) >> 4; + x0 = (int16_t)(CX + limb); y0 = CY; + for (uint16_t a = 8; a <= 256u; a += 8u) { + x1 = (int16_t)(CX + ((limb * sin8((uint8_t)(a + 64u))) >> 14)); + y1 = (int16_t)(CY + ((limb * sin8((uint8_t)a)) >> 14)); + draw_edge(x0, y0, x1, y1, false); + x0 = x1; y0 = y1; + } +} + /* Signed area of a packed face's projected polygon (<0 == facing us). */ static int face_area(const uint8_t *f, const int16_t *px, const int16_t *py) { @@ -132,24 +219,29 @@ void app_main(const app_api_t *api) A->backlight_on(); A->status_clear(); - /* Assets copied to the stack: the sine once, the displayed solid every + /* Assets copied to the stack: shared tables once, the displayed solid every * frame (nv, nf, name + NUL, vertices, packed faces; see gen_assets.py). */ int16_t sin_q[SIN_Q_LEN / 2u]; uint16_t recip[RECIP_LEN / 2u]; /* by zc - RECIP_ZMIN */ + char pause[T_PAUSE_LEN]; uint8_t rec[SHAPE_REC_MAX]; uint8_t nshape; int16_t px[MAXV], py[MAXV], pz[MAXV]; /* projected x, y and rotated depth */ A->asset_read(SIN_Q, sin_q, sizeof(sin_q)); A->asset_read(RECIP, recip, sizeof(recip)); + A->asset_read(T_PAUSE, pause, sizeof(pause)); sinq = sin_q; + recipq = recip; if (A->asset_read(SHAPES, &nshape, 1) != 1u || nshape == 0u) return; uint16_t ax = 0, ay = 0, az = 0; /* Q2 half-units: 2048 = full turn */ uint8_t shape = 0; uint8_t speed = 4; /* 1..16, shared by all three rotation axes */ + uint8_t zoom = 4; /* 1..8: 62.5%..150%, level 4 = 100% */ bool paused = false; bool wire = true; /* false = solid (hidden-line) */ + bool fArm = false; /* F: the next UP/DOWN or STAR is modified */ bool running = true; uint8_t prevKey = APP_KEY_INVALID; @@ -165,6 +257,13 @@ void app_main(const app_api_t *api) key = APP_KEY_INVALID; if (key != prevKey && key != APP_KEY_INVALID) { A->backlight_on(); + if (key == APP_KEY_F) { + fArm = !fArm; + prevKey = key; + continue; + } + const bool fn = fArm; + fArm = false; switch (key) { case APP_KEY_EXIT: running = false; @@ -172,19 +271,27 @@ void app_main(const app_api_t *api) case APP_KEY_UP: case APP_KEY_DOWN: { const int8_t dir = A->nav_dir(key); - if (dir > 0 && speed < 16u) speed++; - if (dir < 0 && speed > 1u) speed--; + if (fn) { + if (dir > 0 && zoom < 8u) zoom++; + if (dir < 0 && zoom > 1u) zoom--; + } else { + if (dir > 0 && speed < 16u) speed++; + if (dir < 0 && speed > 1u) speed--; + } break; } case APP_KEY_MENU: paused = !paused; break; case APP_KEY_STAR: /* next shape, wrapping without a modulo */ - if (++shape >= nshape) + if (fn) + wire = !wire; + else if (++shape >= nshape) shape = 0; break; - case APP_KEY_F: - wire = !wire; + case APP_KEY_0: + ax = ay = az = 0; + zoom = 4; break; case APP_KEY_1: case APP_KEY_2: case APP_KEY_3: case APP_KEY_4: case APP_KEY_5: case APP_KEY_6: case APP_KEY_7: case APP_KEY_8: @@ -211,44 +318,33 @@ void app_main(const app_api_t *api) namelen++; const int8_t (*verts)[3] = (const int8_t (*)[3])&rec[3u + namelen]; const uint8_t ia = (uint8_t)(ax >> 3), ib = (uint8_t)(ay >> 3), ic = (uint8_t)(az >> 3); - const int cx = sin8((uint8_t)(ia + 64u)), sxr = sin8(ia); - const int cy = sin8((uint8_t)(ib + 64u)), syr = sin8(ib); - const int cz = sin8((uint8_t)(ic + 64u)), szr = sin8(ic); + zoomq = (uint8_t)(8u + 2u * zoom); + rcx = sin8((uint8_t)(ia + 64u)); rsx = sin8(ia); + rcy = sin8((uint8_t)(ib + 64u)); rsy = sin8(ib); + rcz = sin8((uint8_t)(ic + 64u)); rsz = sin8(ic); for (uint8_t i = 0; i < nv; i++) { - int x = verts[i][0], y = verts[i][1], z = verts[i][2]; - int ny = (y * cx - z * sxr) >> 14; /* Rx */ - int nz = (y * sxr + z * cx) >> 14; - y = ny; z = nz; - int nx = (x * cy + z * syr) >> 14; /* Ry */ - nz = (z * cy - x * syr) >> 14; - x = nx; z = nz; - nx = (x * cz - y * szr) >> 14; /* Rz */ - ny = (x * szr + y * cz) >> 14; - x = nx; y = ny; - - int zc = z + (int)DIST; /* always > 0 */ - if (zc < (int)RECIP_ZMIN) zc = RECIP_ZMIN; /* inside the table: */ - if (zc > (int)RECIP_ZMAX) zc = RECIP_ZMAX; /* never hit in practice */ - const uint32_t m = recip[zc - (int)RECIP_ZMIN]; - px[i] = (int16_t)(CX + proj(x * (int)FOCAL, m)); - py[i] = (int16_t)(CY + proj(y * (int)FOCAL, m)); - pz[i] = (int16_t)z; + project_point(verts[i][0], verts[i][1], verts[i][2], + &px[i], &py[i], &pz[i]); } clear_screen(); - const uint8_t *f = &rec[3u + namelen + 3u * rec[0]]; /* packed faces */ - for (uint8_t i = 0; i < nf; i++) { - const uint8_t n = f[0]; - if (wire || face_area(f, px, py) < 0) { /* else hidden face culled */ - for (uint8_t k = 0; k < n; k++) { - const uint8_t a = f[1u + k]; - const uint8_t b = f[(k + 1u == n) ? 1u : 2u + k]; - const bool dotted = wire && (pz[a] + pz[b] > 0); /* far half */ - draw_edge(px[a], py[a], px[b], py[b], dotted); + if (nv == 0u && nf == 0u) { + draw_globe(wire); + } else { + const uint8_t *f = &rec[3u + namelen + 3u * rec[0]]; /* packed faces */ + for (uint8_t i = 0; i < nf; i++) { + const uint8_t n = f[0]; + if (wire || face_area(f, px, py) < 0) { /* else hidden face culled */ + for (uint8_t k = 0; k < n; k++) { + const uint8_t a = f[1u + k]; + const uint8_t b = f[(k + 1u == n) ? 1u : 2u + k]; + const bool dotted = wire && (pz[a] + pz[b] > 0); /* far half */ + draw_edge(px[a], py[a], px[b], py[b], dotted); + } } + f += 1u + n; } - f += 1u + n; } /* Shape name: inverse label, top-left of the status bar (scan-list look). */ @@ -256,6 +352,10 @@ void app_main(const app_api_t *api) for (uint8_t i = 0; i <= end; i++) A->status_line[i] = 0; A->print_inverse((const char *)&rec[2], 2, 0, true, true, end); + if (fArm) + A->asset_read(BMP_F, A->status_line + 70, BMP_F_LEN); + if (paused) + A->print_inverse(pause, 82, 0, true, true, 104); A->blit_status(); A->blit_full(); diff --git a/App/apps/cube3d/gen_assets.py b/App/apps/cube3d/gen_assets.py index c989e5fd..36185ce5 100755 --- a/App/apps/cube3d/gen_assets.py +++ b/App/apps/cube3d/gen_assets.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Cube3D read-only assets: the solids, the Q14 sine quadrant and the speeds. +# Cube3D read-only assets: the solids, Q14 sine quadrant and speeds. # # SHAPES layout (offsets relative to SHAPES): # 0 count N @@ -44,6 +44,17 @@ SHAPES = [ ("PENTAGEM", [(28,0,0),(8,26,0),(-22,16,0),(-22,-16,0),(8,-26,0),(0,0,42),(0,0,-42)], [(5,0,1),(1,0,6),(4,0,5),(6,0,4),(5,1,2),(2,1,6),(5,2,3),(3,2,6),(5,3,4),(4,3,6)]), + # Empty geometry selects the procedural globe renderer in cube3d_app.c. + ("GLOBE", [], []), + ("DODECA", + [(-20,-20,-20),(-20,-20,20),(-20,20,-20),(-20,20,20), + (20,-20,-20),(20,-20,20),(20,20,-20),(20,20,20), + (0,-12,-32),(0,-12,32),(0,12,-32),(0,12,32), + (-12,-32,0),(-12,32,0),(12,-32,0),(12,32,0), + (-32,0,-12),(-32,0,12),(32,0,-12),(32,0,12)], + [(17,16,0,12,1),(10,8,0,16,2),(14,12,0,8,4),(3,17,1,9,11), + (5,9,1,12,14),(3,13,2,16,17),(6,10,2,13,15),(15,13,3,11,7), + (5,14,4,18,19),(6,18,4,8,10),(11,9,5,19,7),(19,18,6,15,7)]), ] # One Q14 sine quadrant: symmetry recovers the full 256-step wave. @@ -96,10 +107,12 @@ for r in records: body += r a = Assets("CUBE3D") +a.text("T_PAUSE", "PAUSE") a.raw("SHAPES", shapes + body) a.i16("SIN_Q", SIN_Q) a.u8("ROT_RATE", ROT_RATE) a.u16("RECIP", RECIP) +a.u8("BMP_F", [0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e]) a.const("RECIP_ZMIN", RECIP_ZMIN) a.const("RECIP_ZMAX", RECIP_ZMAX) a.const("RECIP_SHIFT", RECIP_SHIFT)