This is the 4th 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 properly at any location on the screen, and in any colour!
Recap
The Nintendo GameBoy was an incredibly successful 8-bit hand-held game console from the late 1980s. To maintain performance given significant memory and CPU-speed constraints, it used Tile-based graphics, where the screen was divided into a 20x18 TileMap grid; where each grid location was a byte that referenced an 8x8, 2 bits-per-pixel Tile. Each tile took up 16 bytes, where each row of the tile contained the least significant bit of 8 pixels, and the next byte contained the most significant bit.
The CPU was a sort-of Z80/Intel 8080 hybrid with some instructions missing from both; some Z80 instructions (including the Bit handling instructions) included and a few instructions of its own.
You can use RGBDS-live to both edit games in assembly and emulate the GameBoy, so development is fast. With 8-bits per Tile reference in the TileMap, only 256 tiles could be supported, but there's a raster trick to switch to another tile map (and then back) based on a scan-line interrupt. This means I should be able to support a full 160x144x2bpp Frame-Buffer. Part 3 started this process, beginning with RGBDS-live's "Hello World" demo and ending with a frame-buffer display of a 4x8 pixel font.
There are four major steps in being able to display characters the way we want.
Calculating Font and Frame-Buffer Addresses
Font Addresses
In the Firmware, the font is at address CharSet; each pair of characters occupies 8 bytes and begins at ' ' (ASCII code 32). So, the address is CharSet+(((chr-32)>>1)<<3), though in fact this gets simplified to: (CharSet-(32>>1)<<3)+((char&0x7e)<<2). This eliminates an extra subtraction, because the constants get combined, and also a shift. In addition, because the maximum character value is 0x7f, we can do 1 8-bit shift, before transferring to HL, doubling and adding (CharSet-(32>>1)<<3). We also need to retain chr&1.
Tile Addresses
These are 0x8000|( (y*20+(x>>1))<<4), because (x,y) are character, not pixel coordinates. Again, because y is in the range 0..17, we can compute y*10 entirely in 8-bits before finally doubling to *20. As before, we need to retain x&1.
Register Allocation
On input, A=char, and I use global "system" variables for the print position: gChX and gChY. For speed, the goal is to put everything in registers:
| Register | Use |
|---|---|
| A | Temp |
| B | bit 1 =1 if Chr is odd; bit 0=1 if X is odd. In the display loop: bits 7:4 are the char row (X is even) bits 3:0 are the char row (X is odd). |
| C | Bits<3:2>=Ink, Bits<1:0>=Paper |
| DE | Destination Addr (Tile) |
| HL | Source Addr (Char) |
Although C ends up containing the colour bits, and these get tested using bit instructions, it's still much faster than loading them from RAM.
Shifting And Masking Bitmaps
Because each 4x8 character occupies either bits <3:0> or bits <7:4> of each character row, and needs to be copied to either pixels <3:0> or pixels <7:4> of a destination tile, we need to mask (and possibly shift) the source pixels from the font and mask the destination pixels before combining them. There are 4 possible combinations:
| Tile Coord Bits=> Font Char Code (Bits) |
Even (<7:4>) | Odd (<3:0>) |
|---|---|---|
| Even (<7:4>) | (FontBits&240)|(TileBits&15) | ((FontBits&240)>>4)|(TileBits&240) |
| Odd (<3:0>) | ((FontBits&15)<<4)|(TileBits&240) | (FontBits&15)|(TileBits&240) |
Two of the cases are easy. When bits <7:4> of a character row (an even character code) are displayed to an even x coordinate (pixels <7:4> of a tile). Or vice-versa, when bits <3:0> of a character row are displayed an odd x coordinate (pixels <3:0>). In these cases, there's no shifting, the font bytes are masked by either 240 (even) or 15 (odd) and the tile pixels are masked by the complementary values, 15 (even) or 240 (odd).
The other two cases aren't much more complex: When bits <7:4> (even) are display to an odd x coordinate (pixels <3:0>), the font's nybbles should be swapped, and then it's treated like the odd case. Likewise, when bits <3:0> are displayed to an even x coordinate, the font's nybbles get swapped and it's treated like the even case.
In practice, because there aren't really enough registers to store the masks and determine if the source character row should be swapped, there's a main row-copying loop for each of the Font and Tile, Even and Odd combinations. In addition, the loop is unrolled for both bytes of a tile's row.
Displaying Characters In Different Ink And Paper Colours
We can consider GameBoy tile colours to be two bitmapped planes, where the first plane handles bit 0 of the colour and the second plane handles bit 1 of the colour. Thus, the same colour calculations apply for both planes.
Ink
Ink by itself is easy, you just OR the character row with a tile's plane, if that plane's colour bit is set, or skip the plane.
Paper
Is more complex as we need to consider all 4 combinations for ink and paper bits on a given plane. The diagram below shows how it works for a pair of pixels, an ink pixel followed by a paper pixel. Here the xor mask below is for X is odd case).
The upshot is that if ink<plane>==paper<plane> then we skip; for ink<plane>=1 we OR the character row's nybble and for paper<plane>==1, we xor with the character nybble's mask.
Code Fragment
Putting all these things together gives us a code fragment for a single plane (in this case, the first plane for when the character and x are odd) which looks like:
ldi a,[hl]
and 15 ;The Character code is odd,
ld b,a ; so low nybble.
;First plane byte c<2>=ink on, c<0>=paper on.
ld a,[de]
and 240; background in upper nybble.
bit 2,c ;ink bit 0
jr z,PutChL2L60 ;no ink, so just upper nybble.
bit 0,c ;paper bit 0
jr nz,PutChL2L65 ;paper<0> == ink<0>==1, so bottom nybble =15.
or b ;paper<0>==0, ink<0>==1, so or in bottom nybble.
jr PutChL2L70
PutChL2L60:
bit 0,c ;paper bit 0
jr z,PutChL2L70 ;paper<0>==ink<0>==0, so bottom nybble=0.
or b ;PaperNoInk paper<0>==1, ink<0>==0, so bit pattern,
PutChL2L65:
xor 15 ;but inverted.
PutChL2L70:
ld [de],a ;combine.
inc e
and 15 ;The Character code is odd,
ld b,a ; so low nybble.
;First plane byte c<2>=ink on, c<0>=paper on.
ld a,[de]
and 240; background in upper nybble.
bit 2,c ;ink bit 0
jr z,PutChL2L60 ;no ink, so just upper nybble.
bit 0,c ;paper bit 0
jr nz,PutChL2L65 ;paper<0> == ink<0>==1, so bottom nybble =15.
or b ;paper<0>==0, ink<0>==1, so or in bottom nybble.
jr PutChL2L70
PutChL2L60:
bit 0,c ;paper bit 0
jr z,PutChL2L70 ;paper<0>==ink<0>==0, so bottom nybble=0.
or b ;PaperNoInk paper<0>==1, ink<0>==0, so bit pattern,
PutChL2L65:
xor 15 ;but inverted.
PutChL2L70:
ld [de],a ;combine.
inc e
The whole section in bold is repeated for the second plane (but checks bits 1 and 3 for ink and paper). A swap a is inserted after the ld a,[hl] when (chr&1) isn't (x&1).
Testing Process
Again the testing process started with odd character codes on an odd x coordinate with black text only, then progressed to testing even characters on an even x coordinate; then the two remaining cases (which needed the swap a). Then ink colours were added and finally paper colours. That gave me:
So, again here, the initial image is just the font's bytes copied to the tiles, but the second set are all the characters in every ink and paper combination for every odd and even character and x combination.
Conclusion
Displaying 1bpp bitmapped characters on a 2bpp tiled screen is a fairly standard process, but if we want to maintain performance given the register limitations of an SM83 it can be helpful to consider a number of optimisations for calculating addresses and masking the data.
So far, all the display code has been static: the data was copied in its entirety to the frame buffer; then the LCD was turned on and the main code entered a loop. The only dynamic aspect was switching the tile set using the Line Counter compare interrupt.
Real code will need to update text or graphics in real-time, while the display generator is running.
We will then find out that performance optimisations are important, because the Nintendo Game Boy allows even less time per frame than a ZX81 for updating. the screen. That's the subject of the next post.
No comments:
Post a Comment