This is the 5th in the series on Framebuffer-based graphics on the Nintendo GameBoy. As before I'll start with a recap. Then we'll continue with more development, displaying characters while the PPU is active.
Recap
The incredibly successful 8-bit Nintendo GameBoy used tile-based graphics for performance reasons and memory constraints. The 160x144 pixel screen is divided into 20x18 (=360) TileMap locations and each one can point to one of 256, 8x8 2-bpp Tiles. However, a raster trick enables another 128 tiles to be selected part-way through each refresh and I want to use that feature to provide a full frame buffer.
In previous posts I described the tile system, then the CPU, then the basic mechanism for generating a full frame buffer and finally displaying characters in all ink/paper combinations.
Dynamic Displays
gbdev.io provides a lot of extensive documentation. The PPU, which renders a screen, blocks access to the TileMap and tiles while drawing pixels. It scans the video in raster order, row 0 to 143 and implements three main phases of each scan: Sprite scanning (OAM), Drawing Pixels and The Horizontal blank phase. For the first 144 scans it looks like:
| Mode 2 OAM scan |
Mode 3 Drawing Pixels |
Mode 0 Horizontal blank |
One frame 70224 dots/2²² Hz |
|---|---|---|---|
| 80 dots | 172-289 dots | 87-204 dots | @ 59.7fps |
The PPU then mimics a conventional CRT scan by adding more scanned lines, called the VBlank scans:
| Mode 1 VBlank |
10 Scan lines.. |
|---|---|
| 456 dots | 4560 dots in total |
This is basically copied from the PanDocs' rendering page. If drawn to scale, it'd look like this:
There's 204 clock cycles available in HBlank and another 80 cycles in OAM scan, 284 in total. That should be enough for about 35 x 8 cycle instructions (which is about the average). So, it appears like there's lots of time available in HBlank and OAM scan, but little time available in VBlank.
The real question is how to update the screen. There's a great article on gbdev.io about updating the screen during screen redraw and discusses a number of methods. All of the methods involve generating interrupts either at the beginning of Mode 2 (HBlank) or on an LYC match, which is at the beginning of Mode 1 (OAM).
One method is to prepare a whole area of RAM where the target data goes and then the interrupt routine can just copy the data to the right area of RAM. The problem here is that when displaying characters we need to read video RAM before modifying it and then writing back to it. Also, it's complicated. In a future blog post you'll discover why that isn't likely to be very effective even if I managed the complexity.
The next alternative is for the foreground task (the main program) to wait for VBlank. That kind of wait routine can be done by waiting for IF bit 0 to be set and is the most common update technique in GameBoy games. It would look like:
ld hl,rIF
Wait:
bit 0,[hl]
jr z,Wait ;24cycles per loop
res 0,[hl] ;+16/20 cycles when done.
Wait:
bit 0,[hl]
jr z,Wait ;24cycles per loop
res 0,[hl] ;+16/20 cycles when done.
However, a little calculation shows this would lead to a terribly slow display routine. Each tile row takes about 144 to 160 cycles and with 4560 cycles available for VBlank, that results in only about 4 characters being displayed. Also, it precludes using a VBlank interrupt. I tried it at an early stage just to prove it could work at all, but it was slow.
The gbdev.io article mentioned earlier covers a number of other techniques that mostly revolve around waiting for the rSTAT (LCD) register to say that it's not in a busy mode. The problem with that is that if you wait for the PPU to not be busy, it might be just about to start drawing pixels (Mode 3). So, then you have to wait for the PPU to be busy, then not busy, because in theory you're at the beginning of a HBlank. And this means a scan can be wasted.
What we really want to know is if there's enough time before HBlank finishes, for us to do some screen updates.
Timer-Based Waiting
My technique is to use the timer. By running it at 262144Hz (the maximum), the timer updates with a tick every 16 CPU cycles. This is quite close to the wait loop period above, so it's a good enough resolution. Then, we set rSTAT to generate HBlank interrupts and reset the timer during the interrupt.
Since the timer will count to about 456/16=28.5 in a complete scan, then providing our routine sees rTIMA as being less than the deadline, we'll be able to complete an update and won't have to wait for the PPU to become busy before writing more data. In fact if our routine takes longer than a whole scan between writes, that's not a problem and if our routine is so quick we can perform two updates before the timeout, then that's a bonus.
And we can improve things further. During VBlank we can set rTIMA to 0 and stop the timer. Then screen updates during VBLank will always see we have time to update, so that will proceed at the maximum rate. The only remaining difficulty is that on the very last VBlank scan line, we need to enable the timer again. We can do this using the LYC compare technique used to switch tile sets.
This method is closer to how it might be done on a bare-metal, real-time, microcontroller application, by assessing if there's enough time to complete a task before a timeout.
Code Snippets
The timer-based approach requires a few interrupt handler changes. The Timer needs initialising before the STAT interrupt is:
ld a,255 ;Init to 255 to stop any display updates before
ld [rTIMA],a ;handlers are ready.
In the main init code, we need to support both the STAT interrupt, and VBlank interrupt:
xor a
ldh [rIF],a ;clear pending interrupts.
ld a,IE_STAT | IE_VBLANK ;need both..
ldh [rIE],a ;interrupts enabled.
ei
The STAT interrupt will need to handle an interrupt every scan; a line matching interrupt for switching the tile set and a line matching interrupt for the end of VBlank to restart the timer. We could also use STAT to handle stopping the timer at the beginning of VBlank, but by using VBlank itself we can eliminate a conditional test and jump for that case:
DoVBlank:
push af
ld a, LCDC_ON | LCDC_BG_ON | 16 ;back to tiles at $8000.
ldh [rLCDC], a
xor a; stop the timer.
ldh [rTAC],a
ldh [rTIMA],a ;and reset it.
ld a,STAT_LYC ;select only scan match interrupt
ldh [rSTAT],a ;and enable it
ld a,152
ldh [rLYC],a ;interrupt at line 152.
ldh a,[rIF]
and 0xfd ;clear bit 2
ldh [rIF],a ;clear LCD interrupt.
pop af
reti
push af
ld a, LCDC_ON | LCDC_BG_ON | 16 ;back to tiles at $8000.
ldh [rLCDC], a
xor a; stop the timer.
ldh [rTAC],a
ldh [rTIMA],a ;and reset it.
ld a,STAT_LYC ;select only scan match interrupt
ldh [rSTAT],a ;and enable it
ld a,152
ldh [rLYC],a ;interrupt at line 152.
ldh a,[rIF]
and 0xfd ;clear bit 2
ldh [rIF],a ;clear LCD interrupt.
pop af
reti
The STAT interrupt is now fairly involved, but at any one stage it only executes one of 3 paths:
LcdStat: ;
push af
ld a,240 ;set up timer so it's 0 by
ldh [rTIMA],a ;HBlank.
ldh a,[rSTAT]
bit B_STAT_LYCF,a ;LYCF match?
jr nz,LcdStat10 ;so the common OAM int case..
pop af ;can just return now.
reti ;normally 8 instructions.
LcdStat10: ;only reached on scan 96 & 153.
bit B_STAT_LYC,a ;
jr nz,LcdStatELine ;it was the scan 153 case.
ld a, LCDC_ON | LCDC_BG_ON ;switch to tiles at $9000
ldh [rLCDC], a
pop af
reti ;12 instructions on scan 96.
LcdStatELine: ;we're at the end of vblank.
ld a,(256/20)*8
ldh [rLYC],a ;back to the proper line
ld a,STAT_MODE_2 ;STAT_LYC | STAT_MODE_0
ldh [rSTAT],a
ldh a,[rIF]
and 0xfd ;clear bit 2
ldh [rIF],a ;clear LCD interrupt.
ld a,TAC_START|TAC_262KHZ ;back to normal mode for timer.
ldh [rTAC],a
pop af
reti ;19 instructions on scan 153.
push af
ld a,240 ;set up timer so it's 0 by
ldh [rTIMA],a ;HBlank.
ldh a,[rSTAT]
bit B_STAT_LYCF,a ;LYCF match?
jr nz,LcdStat10 ;so the common OAM int case..
pop af ;can just return now.
reti ;normally 8 instructions.
LcdStat10: ;only reached on scan 96 & 153.
bit B_STAT_LYC,a ;
jr nz,LcdStatELine ;it was the scan 153 case.
ld a, LCDC_ON | LCDC_BG_ON ;switch to tiles at $9000
ldh [rLCDC], a
pop af
reti ;12 instructions on scan 96.
LcdStatELine: ;we're at the end of vblank.
ld a,(256/20)*8
ldh [rLYC],a ;back to the proper line
ld a,STAT_MODE_2 ;STAT_LYC | STAT_MODE_0
ldh [rSTAT],a
ldh a,[rIF]
and 0xfd ;clear bit 2
ldh [rIF],a ;clear LCD interrupt.
ld a,TAC_START|TAC_262KHZ ;back to normal mode for timer.
ldh [rTAC],a
pop af
reti ;19 instructions on scan 153.
Finally, the wait routine is used multiple times as a Macro, once before each plane byte is written:
MACRO waitScan
.waitScan\@
ldh a,[rTIMA]
cp \1 ;\1 is the wait period.
jr nc,.waitScan\@
ENDM
.waitScan\@
ldh a,[rTIMA]
cp \1 ;\1 is the wait period.
jr nc,.waitScan\@
ENDM
In the end I found that waitScan 6 was the optimum. Anything more than that and I'd see artefacts appearing in the text output, like pixels of text being the wrong colour. And this is because there must be some kind of clash between the PPU and the main code accessing VRAM.
Performance
My original estimates were that I should be able to update an entire screen with text in much less than a second. It takes about 16.7s to display 50*95=3150 characters, so that's 3150/16.7=188.6 characters per second. This is certainly not great and certainly much slower than doing the same thing on a ZX Spectrum even though it lacks a swap instruction.
Conclusion
I started doodling the display code on a train while going to a fun 27K cycle ride. It's fairly easy to write down the concepts behind the code, and it really is fun to write in assembly, because it's far more like a logic puzzle than a more expressive high-level language.
Then again, it's taken me 5 reasonable-length blog posts to get to the primary objective, a real-time display of text in multiple colours. It takes a lot of tricks to do this on the Nintendo GameBoy, because it is really designed as a low-power handheld for playing platform games. I had to switch tiles and add synchronisation mechanisms to stop the PPU and my code from clashing with VRAM accesses. This isn't something that 8-bit programmers had to deal with in the early 1980s.
One interesting puzzle remains, My estimates were that I'd get far more time during HBlank and OAM to update the screen contents. I thought I'd be able to update an entire tile row. However, I'm only updating 284*8/50.97=44.575 tile rows (or 89 bytes) per frame, which seems really poor: less than 1 byte per HBlank, because VBlank is 100% available for updates. It's like it's not updating in HBlank at all, though in fact placing breakpoints after the Waits show that it does (because I can see the scan line is <144 much of the time).
Improving upon this will be for a future blog post, but in the meantime, the next post will be about some simple additional routines for clearing the screen and scrolling!
No comments:
Post a Comment