Skip to content
Eliott65wiki
Contents

Tiles and sprites: the graphics

Tiled backgrounds, moving sprites, up to 256 colours: without taking any memory from the 6502, and drawing each line in under 63.5 microseconds. The project’s longest piece of work.

On this page

Why tiles

A 320 × 240 image in 256 colours weighs 75 KiB: more than the 6502’s entire memory. Games consoles solved this problem long ago with tiles: a small set of patterns (8 × 8 or 16 × 16 pixels) is drawn once, and the background is just a map saying which pattern goes where. On top, sprites move freely.

With two layers, 256 tiles and 128 sprites, about 22 KiB is needed at 320 × 240. Memory is no longer the problem; the problem becomes drawing time.

The chosen model

The model is inspired by two well-known machines, without aiming for compatibility:

  • from the VERA (the Commander X16’s video chip), flexible layers: tiles or bitmap, from 1 to 8 bits per pixel, 8 × 8 or 16 × 16 tiles;
  • from the SNES, per-line effects and priorities.
ElementCapacity
Resolution320 × 240
Layers2, tiled or bitmap, independent scrolling
Sprites128, with depth and mirroring
Palette256 colours chosen from 4,096
Per-line effectsscrolling and palette bank, line by line
Video memory32 KiB, expandable to 64 KiB

Eliott65’s own idea: reading without copying

As with the text screen, graphics data can stay in the 6502 program’s memory: maps, bitmaps, sprite images, sprite table. The engine reads them there, without copying. The program moves a sprite by changing two bytes in its own memory, without a system call.

Alongside this, there is a 32 KiB video memory in the RP2040, loaded by kernel services (including directly from the USB stick, without going through the 6502). It can expand to 64 KiB by borrowing free frames from the multitasking pool, which are given back at the end.

GRAPHE demo: a chequerboard of coloured tiles scrolling diagonally and eight coloured diamonds
GRAPHE.E65 (994 bytes): a 16-colour tile layer scrolling diagonally, its map in the program’s memory, and 8 bouncing 16 × 16 sprites. Screenshot from the emulator.
VAGUE demo: vertical gradient bands in every colour, rippling like a wave
VAGUE.E65: a 16-colour image, but a different palette bank on each line gives 241 colours on screen; per-line scrolling makes the image ripple. Screenshot from the emulator.

A reference to avoid mistakes

Before optimising anything, a reference was written in Python, from the specification, pixel by pixel. The firmware engine must produce exactly the same image. Three deliberately introduced errors (priority order, vertical mirroring, map wrap-around) are all detected.

Twice, the engine and the reference agreed against the hand-written test case: it was the expectation that was wrong.

The 63.5 µs wall

Each logical line must be ready within 63.5 µs. First run on the board:

ProgramWorst lineBudget
text (shell)61 µs63.5 µs
VAGUE140 µs63.5 µs
GRAPHE161 µs63.5 µs

Two and a half times too slow. What the user saw: a garbled screen. The rendering was correct, but late, and the screen was putting out half-written lines.

“Shouldn’t we raise the core frequency?” No: the 252 MHz clock is the HDMI bit clock. Raising it would mean sending the pixels correspondingly faster: the number of cycles per line would stay the same. (The 720 × 480 signal adopted later, at 270 MHz, does give 7% more cycles per line: its line is longer, not faster.)

The way down, measurement by measurement

StepGRAPHEVAGUE
start161 µs140 µs
engine reorganised (pages, loops per format, sprite list)12386
encoding tables in C12386
encoding in assembler10063
map read once per line91—
4-bit tiles in dedicated C85—
4-bit tiles in assembler6163

The main culprit: the Cortex-M0+ has few registers, and the compiler was keeping variables on the stack, about 28 instructions per pixel for the encoding. Hand-written assembler does it in 7 to 12.

“I can see the diamonds bouncing, it’s fine”: GRAPHE on the board, in v0.36.

The limits, measured

Fourteen scenarios were then measured on the board, with a randomly filled video memory and every frame checked against the reference. The rendering is correct everywhere; only a few scenarios fitted within the budget: one 8 × 8 tile layer at 4 bits, a bitmap on its own, a few sprites per line. Two layers, 16 × 16 tiles, many sprites on the same line went over.

Two cores working together (v0.39 – v0.40)

Until then, core 1 did everything: draw the line, then encode it as an HDMI signal. The idea adopted: core 0 draws, core 1 encodes.

The journey of one picture lineThe picture starts in the 6502's memory, read without copying. Core 0 draws the line up to eight lines ahead; core 1 encodes it into TMDS signals; DMA and PIO send it to the HDMI socket, one bit per cycle at 252 MHz. Each line must be ready within 63.5 microseconds, and no full picture is ever kept in memory.6502 memorytext, tiles,spritesCore 0draws the line(between calls)Buffer8 linesaheadCore 1encodes to TMDS(HDMI signal)DMA + PIO→ HDMI socket1 bit / cycleread without copyingBudget: 63.5 µs per logical line (2 lines of the 640 × 480, 60 Hz screen)No full picture in memory: only line buffers.Since v0.40.0 both cores work together; before that, core 1 did everything.
Core 0, between two system calls, draws the lines up to eight lines ahead; core 1 now only encodes them.
ScenarioCore 1 aloneWorking togetherMissed lines
8 × 8 tiles, 4 bits52 µs30 µs0
two layers71 µs31 µs0 (201 before)
16 × 16 tiles78 µs31 µs0
bitmap + tiles82 µs31 µs0
tiles + 16 sprites of 16 × 1693 µs31 µs0
tiles + 32 sprites of 32 × 32192 µs30 µs30

(In the “working together” column, core 1’s time; core 0 has the eight-line buffer to smooth out its longer steps.)

Ten scenarios out of fourteen now fit, compared with four before.

Sprites in assembler (v0.41)

The RP2040’s two small banks of fast memory were full. Since only core 0 now runs the engine, and it already runs the kernel from flash, an experiment: leave the engine in flash. Measured on the board, it is as fast as from RAM in all fourteen scenarios (0 to 2 µs apart): the flash cache is enough. The space freed holds the sprite drawing in assembler.

ScenarioLongest core 0 stepLines missed per frame
32 sprites 16 × 16103 → 85 µs7 → 0
32 sprites 32 × 32157 → 118 µs30 → 26
32 sprites 64 × 64172 → 132 µs63 → 61
two layers + 32 sprites122 → 104 µs13 → 10

Eleven scenarios out of fourteen fit. What remains: 32 large sprites on the same lines, and two layers with 32 sprites. The trade-off: graphics need a free page for the line buffer; without one, the kernel refuses them.

Sprite loops rewritten (v0.49)

A cycle-accurate measurement showed that the sprite pixel loop still cost 17 to 18 cycles per pixel: the byte was read again, and the column parity tested, for every pixel. Rewritten (advancing pointers, two pixels per byte in 4-bit, four pixels at a time in 8-bit), it drops to about 11 cycles per pixel. Two scenarios were added for mirroring, which the first fourteen did not cover.

ScenarioEngineLate lines per frame
32 sprites 16 × 1685 → 73 µs0
32 sprites 32 × 32118 → 93 µs26 → 19
32 sprites 64 × 64 in 8-bit132 → 116 µs61 → 59
two layers + 32 sprites 16 × 16104 → 92 µs10 → 5

Thirteen scenarios out of sixteen fit, all correct. The work item is closed with these limits, in agreement with the user.

The price: system calls

Core 0 also serves system calls. A drawing step can therefore delay a call. Measured from the 6502’s side while displaying two layers: +0.7 µs on average, +34 µs at worst. A price judged acceptable: the cores have worked together by default since v0.40.

One long step remained: 104 µs with GRAPHE, at the start of each frame. The hypothesis: re-reading the table of 128 sprites (1 KiB) in one go. Since v0.42 it is done in slices of 16 sprites, one per pass of core 0, with the line buffer covering those passes. Longest step: 104 → 30 µs, hypothesis confirmed. It is also the worst delay a system call suffers because of drawing.

This measurement also revealed an old kernel flaw, unrelated to graphics: a program that made calls back to back could no longer be pre-empted. Fixed.

What is specific to Eliott65

Asked about the state of the art, the project looked at PicoDVI and PicoVGA, two well-known video libraries for the RP2040: HDMI encoding converges on the same solutions, as physics leaves little choice. What sets Eliott65 apart lies elsewhere: the kernel outside the 6502, graphics read in place from program memory, and video memory borrowed from processes when needed.

What next

  • an avenue for the three scenarios that still overrun: core 1, free for about 29 µs per line, could draw the front sprites;
  • the Hercules mode (720 × 350, 80 × 25 text): the 720 × 480 signal at 270 MHz has been the default since v0.52, the graphics part has existed since v0.53 and the text since v0.54, with its own 9 × 14 font since v0.58 (see Video);
  • GRAPHE stopping after a few minutes at 270 MHz, with a candidate fix to be checked on the board (see The bus).

Sound, meanwhile, has made its way: see Sound.

Sources in the repository : docs/journal/13-graphisme.md, docs/adr/ADR-0025-graphisme-tuiles-lutins.md, docs/adr/ADR-0026-collaboration-des-coeurs.md.