Making a GB Game, Part 4: Parallax Backgrounds
Published 2018-09-19
As a reminder, at this point we have a sprite animating on a solid black background. (Well, let's pretend I do, anyways. This is all a touch out of order.) Unless I plan on making a 70's arcade game, that's not really going to work out to well. I need some sort of backdrop.
Back when I was drawing the graphics I also drew some background tiles. It's time to put them to use. We have 3 "main" tile types (sky, grass, and dirt) and 2 transition tiles. They're all loaded into VRAM and ready to go. Now we just need to write to the background.
A background
Backgrounds on the Game Boy are stored in memory in a 32x32 array of 8x8 tiles. Every 32 bytes corresponds to one row of tiles.
For now, I can plan on repeating the same column of tiles across the entire 32x32 space. This is nice, but it also presents a bit of an issue: I'd have to define every tile 32 times in a row. That would be a pain to type out.
My gut instinct was to use the REPT command to add the 32 bytes/row and then use memcpy to copy the background into VRAM.
REPT 32
db BG_SKY
ENDR
REPT 32
db BG_GRASS
ENDR
...
However, that would mean that I need to dedicate 256 bytes to just the background, which is kind of a lot. This problem is made considerably worse when considering that memcpy'ing a preset background map won't allow for the ability to add any other column types (ex: goal posts, obstacles) without considerable difficulty and a lot of wasted cart ROM.
Instead, what I opted to do was to define one single column as
db BG_SKY, BG_SKY, BG_SKY, ..., BG_GRASS
and then use a simple loop to copy each element of that list 32 times. (See function LoadGFX in file main.z80 in commit 739986a. for how I did that.)
The neat thing about that approach is that, later in development, I can add a queue so I can say something like
BGCOL_Field:
db BG_SKY, ...
BGCOL_LeftGoal:
db BG_SKY, ...
BGCOL_RightGoal:
db BG_SKY, ...
...
BGMAP_overview:
db 1
dw BGCOL_LeftGoal
db 30
dw BGCOL_Field
db 1
dw BGCOL_RightGoal
db $FF
If I choose to draw BGMAP_overview, it would draw 1 column of LeftGoal would be drawn, followed by 30 columns of Field and 1 column of RightGoal. If BGMAP_overview is in RAM, then I can adjust that on the fly depending on the camera's X position.
Camera and Position
Oh, yes. The camera. That's an important concept I haven't mentioned yet. We're dealing with a lot of coordinates here, so before we talk about the camera let's break all that down.
There are two coordinate systems we need to work with. The first one is the screen coordinates. This is the 256x256 area that the Game Boy's VRAM can hold. We can scroll the visible section of the screen anywhere within that 256x256, but going past that will cause it to wrap around.
Seeing as I need a field more than 256 pixels wide, I need to introduce world coordinates, which for this game will be 65536x256. (I do not need the additional Y height, as the game takes place on a flat field.) This system is completely distinct from the screen coordinate system. All physics and collision must be done in world coordinates, as otherwise objects would collide with other objects on different screens.
As all object positions are in world coordinates, they must be converted to screen coordinates before being rendered. At the very left of the world, world coordinates are the same as screen coordinates. If we want to have things on the screen that are to the right, we must take everything in the world coordinates and shift it left to be within the screen coordinates.
To do this, we define a "camera X" variable that is defined as the left boundary of the screen within the world. For instance, if the camera X is 1000, we can view the world coordinates 1000-1192, because the visible screen is 192 pixels wide.
To handle objects, we simply take their X position (ex: 1002), subtract the camera X position of 1000, and draw the object at the position given by the difference, in this case 2. For the background, which isn't in world coordinates but instead is already in screen coordinates, we set the position of that to be the low byte of the camera X variable. This way the background will scroll left and right as the camera scrolls left and right.
Parallax
Our system, as we have it set up so far, is a bit flat. Every layer of the background moves at the exact same speed. It doesn't feel three dimensional. We need to fix that.
The easy way to add a fake feeling of 3D is with what is known as "parallax scrolling". To explain this, imagine you're on a road trip and overwhelmingly bored. Your Game Boy has ran out of batteries, so you're stuck staring out the window of the car. If you look at the ground directly in front of you, you'll see that it's speeding by at a good 70 miles an hour or whatever. However, if you look towards the fields of wheat in the distance, they appear to be moving much slower. And if you look at the mountains in the far, far distance, they barely appear to be moving at all.
We can simulate this effect with a couple of sheets of paper. If we draw a mountain range on one sheet, wheat on the second sheet, and the road on the third sheet, and stack them on top of each other so that you can see every layer, it simulates what we see out the window of the car at any given moment in time. If we want to move our "car" to the left, we move the topmost sheet (with the road) a lot to the right, the next sheet a little to the right, and the last sheet barely at all to the right.
(I'm not really trying with the art right now, but I hope that gets the point across.)
There is a bit of a caveat when it comes to implementing this on the Game Boy, though. It only has one single background layer. In our analogy, this is like having only one sheet of paper to move. You can't really pull off the parallax effect with only one sheet of paper. Or can you?
H-Blank
The Game Boy's screen is drawn row by row. As a result of emulating the behavior of old-timey CRT televisions, there's a bit of a delay between each row. What if we could take advantage of that somehow? As it turns out, the Game Boy has a special hardware interrupt specifically for that purpose.
Much like the VBlank interrupt we've been using this whole time to wait for the end of the frame when we can scribble in VRAM, there also exists an HBlank interrupt. By setting bit 6 of the register at $FF41, enabling the LCD STAT interrupt, and writing a row number to $FF45, we can tell the Game Boy to fire an LCD STAT interrupt whenever it's about to draw (and is in the HBlank of) the given row.
During this time we can alter any VRAM variable. It's not a lot of time, so we can't alter more than a couple of registers, but there's some possibilities. What we'll want to do to alter the horizontal scroll register at $FF43. What that will do is shift everything on the screen below the given row by some amount, giving us our parallax effect.
Going back to our mountain example up above, you'll notice a potential issue. The mountains and the clouds and the flowers aren't flat lines! We can't move our chosen row up and down as we draw it; once we picked it that's it until at least the next HBlank. We're limited to making our cuts with straight lines.
To solve this, we have to be a little clever about things. Simply put, we have to just declare some line in the background as a line that nothing can cross, so we can mode things above and below it without the player ever noticing. For instance, here's about where those lines were in the mountain scene.
Here, I've made my cuts directly above and below the mountain. Everything from the top to the first line moves slow, everything to the second line moves medium, and everything below that moves fast. It's simple, but it's a clever trick. And once you know about it, you can spot it in a lot of retro games, mostly for the Genesis/Mega Drive but some others as well. One of the most obvious examples I can think of is this cave segment from Mickey Mania. If you notice, the stalagmites and stalactites in the background are all exactly split on a horizontal line, with a really obvious black border between the layers.
So, long story short, I did that on my background. One catch, though. Assume the foreground speed moves at 1 pixel per pixel of camera movement, and the background speed is one-third of a pixel per pixel of camera movement, so that the background moves at one-third the visible distance as the foreground. There is, of course, no such thing as one-third of a pixel. We would instead have to move the thing one pixel every third pixel of movement.
If you're used to working with computers actually capable of doing math, you'd probably take whatever the camera position is, divide that by 3, and set that as the background's offset. Unfortunately, the Game Boy doesn't do division, aside from software division which is very slow and very painful. Adding division hardware (or multiplication hardware, for that matter) to a puny CPU for a hand-held entertainment device from the 80's wasn't seen as a cost-effective move at the time, so we should probably come up with a better method.
What I did in the code was, instead of reading the camera position from a variable, require it to be incremented or decremented. This way on every third increment I could increment the background's position and on every increment I could increment the foreground's position. This makes scrolling to some position on the other side of the field a bit difficult (the easiest solution would be to just reset the layer's positions during some transition) but solves the problem of having to divide things.
(Or at least that how I would do it right now. Looking at the code right now there's a call to read from the global timer and a Modulo3 because instead of scrolling every X increments it scrolls every X frames, probably a holdover from when I had it set to autoscroll forever. I should fix that.)
The Result
After all of that, I got this:
For a Game Boy game, that's actually pretty neat. Not a whole lot of them did parallax scrolling like that, as far as I'm aware.
Anyways, I'd normally write something about what I'm talking about next time but I've hit a yak-shaving roadblock with macro stuff and I'm back into Sonic R stuff again and university has just started up again so long story short I can't promise a timely update. I'll try, though. I'm a lot better than I used to be on pushing out regular updates, and I'd like to keep it that way.
Update: Next one's out!