PPU
GBA PPU notes from Sly. GBATEK is the actual reference, this is the stuff I needed to know plus gotchas
Timing
- 1'232 cycles per line, 228 lines per frame (160 visible + 68 VBLANK) = 280'896 cycles, ~59.73 FPS
- 240 visible pixels at 4 cycles each, but the HBLANK flag/IRQ only happens around cycle 1'006-1'008 (not 960!)
VCOUNT(0x04000006) current line,DISPSTAT(0x04000004) IRQs for HBLANK, VBLANK, VCOUNT match
Games change registers between lines (HBLANK IRQ or HBLANK DMA) for per-line scroll, windows, affine matrices etc. Rendering a whole frame with the final register values loses all of that. I render each visible line at HBLANK with the state at that point. Mid-line changes would need finer timing.
DISPCNT
0-2 Mode
3 CGB mode (BIOS only)
4 Displayed page (bitmap modes)
5 OAM access during HBLANK
6 Sprite tile mapping (0=2D, 1=1D)
7 Forced blank
8-12 BG0-BG3, OBJ enable
13-15 WIN0, WIN1, OBJ window enable
mode BG0 BG1 BG2 BG3
-----------------------------------------------
0 text text text text
1 text text affine -
2 - - affine affine
3 - - 240x160 16bpp, 1 page
4 - - 240x160 8bpp (palette), 2 pages
5 - - 160x128 16bpp, 2 pages
Bitmap modes always draw on BG2 (bit 10 must be set) and use BG2's affine parameters, so bitmaps can be rotated/scaled too.
Bitmap Modes
- Mode 3/5: pixel is the 15-bit color directly
- Mode 4: pixel is a palette index,
0x06000000 + 240y + x(page 1 at+0xA000) - Mode 4 index 0 is transparent (backdrop/sprites behind BG2 show through), same for area around a mode 5 image
- Bitmap modes eat the first half of OBJ VRAM, sprite tiles are limited to 512-1023
- Outside of a bitmap is always transparent, wraparound bit is ignored
8-bit VRAM Writes
VRAM can't be written 8 bits at a time. An strb to the BG area is duplicated into both bytes of the halfword, an strb to the OBJ area is ignored. Source
fn write_vram(&mut self, mut address: u32, width: Width, value: u32) {
if width == Width::Byte {
if self::vram_offset(address) >= self.obj_offset() {
return;
}
address &= !1;
self::write_memory(&mut self.vram, address + 1, width, value, self::vram_offset);
}
self::write_memory(&mut self.vram, address, width, value, self::vram_offset);
}
Note: OBJ area boundary depends on mode (0x10000 in tiled modes, 0x14000 in bitmap modes).
Colors
0-4 Red
5-9 Green
10-14 Blue
15 unused
BG palette at 0x05000000, OBJ palette at 0x05000200. Entry 0 of the BG palette is the backdrop.
5 to 8 bit: (c << 3) | (c >> 2), otherwise 31 becomes 248 instead of 255.
Text Backgrounds
BGxCNT
0-1 Priority
2-3 Tile block (16 KiB)
6 Mosaic
7 8bpp
8-12 Map block (2 KiB)
13 Wraparound (affine only)
14-15 Map size
- 4bpp: 32-byte tiles, low nibble is the left pixel, 16 banks of 16 colors
- 8bpp: 64-byte tiles, full 256-color palette
- Index 0 is transparent in every bank
map entry
0-9 Tile number
10 H flip
11 V flip
12-15 Palette bank
Maps are stored in 32x32 entry blocks (2 KiB), not as one big row-major array!
size pixels blocks
-------------------------------------------
0 256x256 SC0
1 512x256 SC0 SC1
2 256x512 SC0 / SC1
3 512x512 SC0 SC1 / SC2 SC3
block = (ty / 32) * (W / 256) + (tx / 32)
offset = 2 * (32 * (ty % 32) + tx % 32)
entry = 0x06000000 + 2048 * (map_block + block) + offset
Scroll (BGxHOFS/BGxVOFS, 9 bits) wraps at the map size, e.g. HOFS 337 on a 256 wide map is 81:
let bg_x = (x + bg.hofs as usize) & (width - 1);
Flipped tiles use 7 - px / 7 - py. Palette entry is 16 * bank + index for 4bpp.
Sprites (OBJ)
OAM at 0x07000000, 128 entries of 8 bytes, 4th halfword belongs to affine matrices.
attr0
0-7 Y
8 Affine
9 Hide (regular) / double size (affine)
10-11 Mode (normal, semi-transparent, OBJ window)
12 Mosaic
13 8bpp
14-15 Shape (square, wide, tall, -)
attr1
0-8 X
9-13 Affine matrix number (affine)
12 H flip (regular)
13 V flip (regular)
14-15 Size
attr2
0-9 Tile number
10-11 Priority
12-15 Palette bank
size square wide tall
------------------------------------
0 8x8 16x8 8x16
1 16x16 32x8 8x32
2 32x32 32x16 16x32
3 64x64 64x32 32x64
- OBJ tiles at
0x06010000 + 32n. Tile numbers count 32-byte units even for 8bpp, so 8bpp sprites should use even numbers (lowest bit ignored in 2D mapping) - 1D mapping:
n + ty * (w / 8) + tx - 2D mapping:
n + 32 * ty + tx - 8bpp: column offset doubles in both, row offset doubles in 1D only
Position Wrap
Y is 8 bits and wraps, X is 9 bits and wraps at 512. A sprite at y = 247 with height 32 shows up at the top of the screen.
let row = (line as u8).wrapping_sub(attr.y_coordinate()) as usize;
if row >= height {
continue;
}
Affine double size sprites have a Y wrapping exception.
Sprite vs Sprite
Lower priority number wins, on tie the lower OAM index wins. Iterating from entry 0 up, a later opaque pixel only replaces one with a higher priority number. mGBA issue
if dst.is_none_or(|pixel| attr.priority() < pixel.priority) {
Cycle Budget
1'210 cycles per line, 954 if DISPCNT bit 5 is set. Regular sprite costs 1 cycle per pixel of width, affine costs 10 + 2 * area width. Once the budget runs out the rest of the line has no sprites, and offscreen sprites still count. Source
Layer Priority
- Lower priority number in front, on tie lower BG number wins
- Sprite goes in front of BGs with the same or higher priority number
- Backdrop is behind everything (treat it as priority
u8::MAX)
bg_order.sort_by_key(|&(_, priority)| priority); // stable sort keeps BG order on ties
let (mut color, bg_priority) = bg_order
.iter()
.find_map(|&(index, priority)| bg_lines[index][x].map(|color| (color, priority)))
.unwrap_or((backdrop, u8::MAX));
if let Some(sprite) = sprite_line[x]
&& sprite.priority <= bg_priority
{
color = sprite.color;
}
Blending also needs the layer right behind the top one, so track the top 2.
Windows
WINxH 0-7 X2 (right + 1) 8-15 X1 (left)
WINxV 0-7 Y2 (bottom + 1) 8-15 Y1 (top)
WININ 0-5 WIN0 (BG0-3, OBJ, effects) 8-13 WIN1
WINOUT 0-5 outside 8-13 OBJ window
- Check order: WIN0 -> WIN1 -> OBJ window -> outside. First match wins
- OBJ window = opaque pixels of sprites with mode OBJ window (not drawn themselves). Needs
DISPCNTbit 12 and 15 - No window enabled at all -> everything visible, effects allowed everywhere
- A layer still has to be enabled in
DISPCNT
if let Some(index) = (0..2).find(|&index| windows.contains(index, x, y)) {
return Some(WindowRegion::Rectangle(index));
}
Some(if windows.obj_enabled && inside_obj_window {
WindowRegion::Obj
} else {
WindowRegion::Outside
})
Blending
BLDCNT
0-5 1st target (BG0-3, OBJ, BD)
6-7 Effect (0=none, 1=alpha, 2=brighten, 3=darken)
8-13 2nd target (BG0-3, OBJ, BD)
Only the topmost and the layer right behind it matter. Alpha needs top = 1st target and below = 2nd target.
alpha: I = min(31, (I1 * EVA + I2 * EVB) >> 4)
brighten: I = I + (((31 - I) * EVY) >> 4)
darken: I = I - ((I * EVY) >> 4)
- EVA/EVB (
BLDALPHAbits 0-4 / 8-12) and EVY (BLDY) treat 17-31 as 16 - EVA + EVB can exceed 16, hence the
min(31) - Semi-transparent sprites alpha blend with the layer behind if it's a 2nd target, regardless of BLDCNT's 1st targets and effect
- Sprites can't blend with each other (already reduced to one sprite pixel)
- Window effects bit must be set in the pixel's region
Mosaic
MOSAIC
0-3 BG width - 1
4-7 BG height - 1
8-11 OBJ width - 1
12-15 OBJ height - 1
Enabled per BG (BGxCNT bit 6) and per sprite (attr0 bit 12). BG blocks are aligned to the screen and take the top-left pixel's color, i.e. round the screen position down to the block start before lookup.
Affine Backgrounds
Differences to text BGs:
- Always 8bpp,
BGxCNTbit 7 ignored - Map entry is 1 byte (tile number only, no flips/banks), max 256 tiles
- Map is square and plain row-major, no 32x32 blocks
- Scroll registers are ignored
size pixels map bytes
---------------------------------
0 128x128 256
1 256x256 1'024
2 512x512 4'096
3 1024x1024 16'384
The PPU walks from the screen back into the background. Parameters are signed 8.8 fixed point (BGxPA-PD, write only), reference point BGxX/BGxY is signed 20.8 (28 bits):
px = X + PA*x + PB*y
py = Y + PC*x + PD*y
texel = (px >> 8, py >> 8)
Bigger parameter = smaller picture (it's texels per pixel, not a zoom factor). For clockwise rotation θ with magnification s:
PA = 256*cos(θ)/s PB = 256*sin(θ)/s
PC = -256*sin(θ)/s PD = 256*cos(θ)/s
Note: GBATEK gives PB and PC the same sign, for a normal rotation they should be negatives of each other.
To keep BG point (cx, cy) at screen point (sx, sy):
X = 256*cx - PA*sx - PB*sy
Y = 256*cy - PC*sx - PD*sy
Wraparound (BGxCNT bit 13) off -> outside of the map is transparent. Masking with all ones when off lets negative coordinates become huge usize and get caught by the same bounds check:
for dst in output.iter_mut() {
let tx = ((x >> 8) as usize) & wrap_mask;
let ty = ((y >> 8) as usize) & wrap_mask;
x += matrix.pa;
y += matrix.pc;
if tx >= size || ty >= size {
continue;
}
// ...
}
Internal Reference Points
The above reference points are automatically copied to internal registers during each [VBLANK], specifying the origin for the first scanline. The internal registers are then incremented by dmx and dmy after each scanline. Source
- Copy
BGxX/BGxYto internal registers at VBLANK - Add
PB/PDto the internal ones after every line - Writing
BGxX/BGxYmid-frame updates the internal register immediately
/// Advances the internal reference points by PB/PD to the start of the next scanline.
fn step_reference_points(&mut self) {
for i in 0..2 {
let [_, pb, _, pd] = self.bg_matrix[i];
self.bg_ref_internal[i][0] += pb.value();
self.bg_ref_internal[i][1] += pd.value();
}
}
fn reload_reference_points(&mut self) {
self.bg_ref_internal = self.bg_ref.map(|point| point.map(ReferencePoint::value));
}
Using Y + PD*line instead of the internal register breaks anything that writes the matrix or reference point per line (mode 7 style roads).
Affine Sprites
- Bit 9 of attr0 means double size, affine sprites can't be hidden with it
- No flip bits, negate
PA+PB(H) orPC+PD(V) instead - 32 matrices in OAM, matrix
m:PAat0x07000006 + 32m,PB/PC/PDeach 8 bytes after - Transform is around the center of the sprite and the center of its on-screen area
- Double size makes the area
2w x 2h, position is top left of that, center at(x + w, y + h)
tx = ((PA*dx + PB*dy) >> 8) + w/2
ty = ((PC*dx + PD*dy) >> 8) + h/2
dx/dy measured from the area center. Texels outside the sprite are transparent.
let (x, y) = if let Some(matrix) = matrix {
let dx = col as i32 - (box_width / 2) as i32;
let (tx, ty) = matrix.apply(dx, dy);
let x = tx + (width / 2) as i32;
let y = ty + (height / 2) as i32;
let (Ok(x), Ok(y)) = (usize::try_from(x), usize::try_from(y)) else {
continue;
};
if x >= width || y >= height {
continue;
}
(x, y)
} else {
attr.flip(col, row)
};
Test Scenes
Games that exercise one specific feature really well:
- OpenLara: mode 4 page flipping (
DISPCNTalternating0x0404/0x0414) - Game Boy Advance Video cartridges: mode 3, 1000+ colors in a frame
- Shaman King: Master of Spirits: 4 text BGs with priorities, scrolling clouds
- Action Man: Robot Atak: mode 3 + sprites (tiles >= 512) on the map, 1D mapping, Y-wrapped health bar sprite
- Tales of Phantasia intro: WIN0/WIN1 reveal, darken fade restricted to WIN1 via effects bit
- Fire Emblem title: OBJ window (
WINOUT = 0x313e), per-line BG0 scroll, alpha blend on lines 40-99 - Golden Sun opening: alpha blending with EVA + EVB > 16 (
BLDALPHA = 0x1006) - The Minish Cap intro: alpha fade with window effect bit masking the picture
- Super Mario World level entry: BG mosaic
0x1111to0xFFFF, sprites unaffected - Wario Land 4: Nintendo logo (affine scale), vortex (affine BG + double size affine sprite)
- F-Zero: Maximum Velocity: mode switch at line 32, HBLANK DMA writing BG2 matrix + reference point every line
Writeups: Part 1: Bitmaps, Tiles and Palettes, Part 2: Layers, Sprites and Blending, Part 3: Rotation, Scaling and Perspective
Honorary mention: Pdawg, Zayd