#19925 - abilyk - Wed Apr 28, 2004 2:33 am
I wanted to show off my scrolling engine design. Somebody may find it useful, and I needed a good excuse to write this all out. If anything seems inefficient, I'm open to suggestions.
Most commercial and homebrew games I've seen seem to use the standard suggested method for scrolling:
* Store the level tiles (or metatiles) as a big 2D array
* When you need to scroll outside the current section of the map in VRAM, copy in a new strip of tiles OR completely redraw VRAM with the new current section of the map
I didn't want to do this for a couple reasons. First, it forces your level boundaries to be rectangular. You could get around this by piecing together the level with multiple arrays and carefully determining which section of which array to pull tiles from when scrolling at a boundary of arrays, but this seemed like too much of a hassle.
Secondly, the GBA gives us this nice hardware scrolling. I wanted to take advantage of it and create a clean, logical solution that would help the level designers instead of getting in the way.
The game we're working on is similar to a Castlevania or a Metroid in that it will have some narrow tunnels and columns, as well as large, open areas. I wanted my level designers to be able to create maps using screen-sized chunks (30x20 tiles) as building blocks. Some commercial games I've seen use VRAM-block sized chunks (32x32 tiles), which is especially apparent in tight vertical scrolling areas; the camera will try to center on the character and shift back and forth horizontally because of the extra 2 horizontal tiles in the map. This is something I wanted to avoid.
My scrolling engine uses a 512x256 pixel (64x32 tile) map for each layer. The level map for each layer is made up of contiguous "halfscreens," which are literally half the size of the GBA screen (30x10 tiles). I'm using halfscreens instead of "screens" because the 512x256 size hardware map is too "short" to store multiple rows of screens. The six currently viewable halfscreens are stored in the VRAM map as shown here: http://www.dungeonmonkey.com/gbadev/vram_hs.gif
The white boxes are the halfscreens, the greyed-out part of the map is unused. The engine stores the indices of the current halfscreens. When the user tries to scroll into the greyed-out area, the engine updates the current indices array and overwrites the VRAM map with the halfscreens specified by the current indices. Here's a logical representation of a halfscreen's map data (blue are used tiles, grey is unused garbage data, black is no data at all -- there's no reason to store or copy the last 2 tiles worth of garbage data): http://www.dungeonmonkey.com/gbadev/hs_l.gif
And here are the first two rows of a halfscreen's map data as stored in memory: http://www.dungeonmonkey.com/gbadev/hs_m.gif
Halfscreens are DMA'd to VRAM at the starting addresses specified by the red Xs on the VRAM map above. Doing it this way, we can use the same halfscreen map structure and keep the unused garbage data on the outside edges of the VRAM map.
* A halfscreen's map data is 636 Bytes (318 tile entries * 2 bytes per entry).
* To update a layer, 6 halfscreens must be copied to VRAM, which is 3816 Bytes (636 * 6).
* Though unlikely, if all 4 layers are used and must be updated at the same time, 15264 Bytes must be transferred (3816 * 4).
* The DMA transfer rating for EWRAM -> VRAM transfers is equal to 2 cycles per Byte transferred (source: CowBite Virtual Hardware Spec), making the above max transfer (15264 Bytes) 30528 cycles long.
* There are 83776 cycles during VBlank (source: GBATEK). The transfers used by this engine during any given frame would take at maximum 36% of VBlank's cycles, and would leave at minimum 53248 VBlank cycles for other operations.
* These calculations ignore the cycles used to set up each transfer. There are only 24 transfers maximum, so this overhead should be negligible.
Here's my halfscreen struct:
I'll handwave over the event variable, since it's specific to my game's needs, and the comments there explain most of it, but let me explain the lock variable. Each of the first 4 bits specifies whether a given edge of the screen (left, right, up, down) is locked or unlocked. If an edge is locked, the engine will not allow you to scroll in that direction.
This feature serves two purposes. First, it stops the camera from showing secret areas to the player. In the Metroid games, for example, you can walk through certain walls. The camera will not scroll far enough to give away the secret, however, until Samus begins to walk through the wall. The lock variable allows me to easily do the same thing, keeping the camera from scrolling in a certain direction until an event is triggered that unlocks it.
Secondly, because of this, I don't have to test for a NULL neighbor when attempting to scroll the map. Normally, I would want to test each neighbor index for a NULL value, which would specify that the map boundary has been reached, so it wouldn't try to compute and display further. Instead, the map editor will automatically lock each edge on a map boundary. I don't have to test to see if a neighbor exists or not; I can't scroll that direction anyway.
You'll notice mention of metatiles in the comments of the struct. Levels will initially be stored in ROM, and halfscreen map data will not be stored as an array of tile entries but as an array of metatile entries (15 x 5 = 75 entries per metatile map):
Each layer will have its own metatile bank. If there are less than 256 unique metatiles in a bank, that layer's metatile maps will use 1-Byte entries. If there are more than 256, the maps must use 2-Byte entries. When the player enters a new level, the level data and halfscreen structs will be copied to EWRAM. The system will then generate the level's tilemaps in EWRAM using the information stored in the metatile maps and metatile banks.
I've covered a lot, but I may have left out some important details. I'm curious to hear any questions or comments regarding this. Thanks!
Most commercial and homebrew games I've seen seem to use the standard suggested method for scrolling:
* Store the level tiles (or metatiles) as a big 2D array
* When you need to scroll outside the current section of the map in VRAM, copy in a new strip of tiles OR completely redraw VRAM with the new current section of the map
I didn't want to do this for a couple reasons. First, it forces your level boundaries to be rectangular. You could get around this by piecing together the level with multiple arrays and carefully determining which section of which array to pull tiles from when scrolling at a boundary of arrays, but this seemed like too much of a hassle.
Secondly, the GBA gives us this nice hardware scrolling. I wanted to take advantage of it and create a clean, logical solution that would help the level designers instead of getting in the way.
The game we're working on is similar to a Castlevania or a Metroid in that it will have some narrow tunnels and columns, as well as large, open areas. I wanted my level designers to be able to create maps using screen-sized chunks (30x20 tiles) as building blocks. Some commercial games I've seen use VRAM-block sized chunks (32x32 tiles), which is especially apparent in tight vertical scrolling areas; the camera will try to center on the character and shift back and forth horizontally because of the extra 2 horizontal tiles in the map. This is something I wanted to avoid.
My scrolling engine uses a 512x256 pixel (64x32 tile) map for each layer. The level map for each layer is made up of contiguous "halfscreens," which are literally half the size of the GBA screen (30x10 tiles). I'm using halfscreens instead of "screens" because the 512x256 size hardware map is too "short" to store multiple rows of screens. The six currently viewable halfscreens are stored in the VRAM map as shown here: http://www.dungeonmonkey.com/gbadev/vram_hs.gif
The white boxes are the halfscreens, the greyed-out part of the map is unused. The engine stores the indices of the current halfscreens. When the user tries to scroll into the greyed-out area, the engine updates the current indices array and overwrites the VRAM map with the halfscreens specified by the current indices. Here's a logical representation of a halfscreen's map data (blue are used tiles, grey is unused garbage data, black is no data at all -- there's no reason to store or copy the last 2 tiles worth of garbage data): http://www.dungeonmonkey.com/gbadev/hs_l.gif
And here are the first two rows of a halfscreen's map data as stored in memory: http://www.dungeonmonkey.com/gbadev/hs_m.gif
Halfscreens are DMA'd to VRAM at the starting addresses specified by the red Xs on the VRAM map above. Doing it this way, we can use the same halfscreen map structure and keep the unused garbage data on the outside edges of the VRAM map.
* A halfscreen's map data is 636 Bytes (318 tile entries * 2 bytes per entry).
* To update a layer, 6 halfscreens must be copied to VRAM, which is 3816 Bytes (636 * 6).
* Though unlikely, if all 4 layers are used and must be updated at the same time, 15264 Bytes must be transferred (3816 * 4).
* The DMA transfer rating for EWRAM -> VRAM transfers is equal to 2 cycles per Byte transferred (source: CowBite Virtual Hardware Spec), making the above max transfer (15264 Bytes) 30528 cycles long.
* There are 83776 cycles during VBlank (source: GBATEK). The transfers used by this engine during any given frame would take at maximum 36% of VBlank's cycles, and would leave at minimum 53248 VBlank cycles for other operations.
* These calculations ignore the cycles used to set up each transfer. There are only 24 transfers maximum, so this overhead should be negligible.
Here's my halfscreen struct:
Code: |
typedef struct Halfscreen // 16 Bytes
{ u16* map; // pointer to tile map or metatile map data u16 left; // index of left neighbor u16 right; // index of right neighbor u16 up; // index of upper neighbor u16 down; // index of lower neighbor u16 event; // index of level event triggered by attempting to scroll from this halfscreen u8 lock; // bits 0-3 represent the state of each halfscreen edge: unlocked (0) or locked (1) u8 type; // tile map (0), 8-bit entry metatile map (1), 16-bit entry metatile map (2) }Halfscreen; |
I'll handwave over the event variable, since it's specific to my game's needs, and the comments there explain most of it, but let me explain the lock variable. Each of the first 4 bits specifies whether a given edge of the screen (left, right, up, down) is locked or unlocked. If an edge is locked, the engine will not allow you to scroll in that direction.
This feature serves two purposes. First, it stops the camera from showing secret areas to the player. In the Metroid games, for example, you can walk through certain walls. The camera will not scroll far enough to give away the secret, however, until Samus begins to walk through the wall. The lock variable allows me to easily do the same thing, keeping the camera from scrolling in a certain direction until an event is triggered that unlocks it.
Secondly, because of this, I don't have to test for a NULL neighbor when attempting to scroll the map. Normally, I would want to test each neighbor index for a NULL value, which would specify that the map boundary has been reached, so it wouldn't try to compute and display further. Instead, the map editor will automatically lock each edge on a map boundary. I don't have to test to see if a neighbor exists or not; I can't scroll that direction anyway.
You'll notice mention of metatiles in the comments of the struct. Levels will initially be stored in ROM, and halfscreen map data will not be stored as an array of tile entries but as an array of metatile entries (15 x 5 = 75 entries per metatile map):
Code: |
typedef struct Metatile // 8 Bytes
{ u16 tile_map_entry[4]; // array of 4 16-bit tile map entries }Metatile; |
Each layer will have its own metatile bank. If there are less than 256 unique metatiles in a bank, that layer's metatile maps will use 1-Byte entries. If there are more than 256, the maps must use 2-Byte entries. When the player enters a new level, the level data and halfscreen structs will be copied to EWRAM. The system will then generate the level's tilemaps in EWRAM using the information stored in the metatile maps and metatile banks.
I've covered a lot, but I may have left out some important details. I'm curious to hear any questions or comments regarding this. Thanks!