Saturday, September 19, 2026

Designing the graphics engines: Text, Tile, and Sprite graphics.


         

        At this point, I had a simple FPGA program to output a HDMI video signal consisting of a single static image. This was a start, but to be useful it needed a lot more functionality.


        I would eventually need to code a CPU interface to allow the contents of the frame memory to be written externally, but first, I wanted to code the actual GPU function. My goal was not to have a simple bitmap display, as that would require the external CPU to do a lot of work to draw simple text and tile graphics. Instead, I wanted to build graphics engines to make the CPU’s job easier. The system I was imagining needed an easy-to-use text mode, as well as multiple tile and sprite layers for implementing simple games. I quickly came up with a design that would have four independent graphics engines: text, tile A, tile B, and sprite, running simultaneously to generate different layers of the final screen image.


        The 75K frame buffer took up far too much of the FPGA’s limited memory, so it would have to go. Instead, the individual engines would each have a dedicated line buffer, and would generate the image to the screen line-by-line as needed, racing the beam as it moved down the screen. I actually created two line buffers for each graphics engine, with one of them being written to while the other one was being read out to generate the video output. After each line the buffers would be swapped, so the most recently generated one would be output while a new line was being generated.


        The block RAMs on the T20F256C3 are each 512 x 10 bits, which fit my needs for this well enough. I was only using 240 locations out of each block, but the 10 bit width came in handy. Of those I was using 8 bits for the color value, while the remaining 2 bits were used to encode a layer priority value. When reading data from the four line buffers, the priority values from each would be compared, and the color value with the highest priority would be used as the color value sent to the palette memory. With this each sprite in the sprite engine could have a different layer priority, allowing them to appear in front of or behind the tile layers as desired. Likewise different parts of the tiles generated by each tile engine could have different layer priorities relative to the sprites and each other.

Simplified block diagram of the graphics pipeline.


        The text engine stores a 64x32 page of text in memory, although only 40x30 characters are visible on the screen at once. While it would have been nice to be able to implement 80 column text mode, I haven’t come up with a clean way to fit it into this existing architecture yet. Each character is stored as 4 bytes in memory: one byte for letter ID, one byte for foreground color, one byte for background color, and one ‘attribute’ byte which encodes various bits for cursors, underline, blinking, and per-character layer priority. This came out to 8K for the text map, plus 2k for the 256 character font, which might seem a lot for a simple text display, but I wanted it to have a lot of built-in functionality. I also much later added a feature to allow this space to be repurposed for other uses by disabling the text engine.

         A single byte in a general-purpose control register space allows the text display to be shifted up and down on a per-line basis, allowing smooth scrolling if the external CPU wants to implement that.


        The two tile engines are identical, other than pointing to different line buffers and reading from different memory banks in memory. Similar to the text engine, each 8x8 tile on the screen is stored as 4 bytes in memory. One byte selects the tile image ID, one selects the transparency color (pixels on the tile matching this color will have their layer priority set to zero, allowing other layers to show through), and then there are two bytes encoding various special features. The tile map encodes 64x32 tiles, requiring a total of 8K of memory, and another 8K of memory is set aside for the tile graphics.


        There’s a lot of fancy functionality built into the tile engines. Each tile’s image can be encoded in 256 color, or 16 color with a per-tile palette selection. Tiles can be individually flipped horizontally, vertically, rotated, or any combination of those. There is also an automatic animation function where a tile’s image can be cycled at a per-tile adjustable speed. Each tile can have an individually adjustable layer priority, so you can have effects where some tiles are in front of the other layers and some are behind them. There’s also a collision bitmask for detecting sprite-to-tile collisions, although that feature hasn’t been well tested at this point.


        There are also scrolling registers in the general control register space which allow each tile layer to be scrolled in X and Y on a per-pixel level. It’s also possible to generate parallax scrolling effects by having the external CPU change the X scroll register between scanlines.


        Finally, there’s the sprite engine. This handles up to 128 sprites on the screen at once, and is one of the more complex bits of coding in the project. Each sprite requires 8 bytes of RAM, requiring 1K of RAM for the 128 sprites. Initially I set aside 16K of RAM for the sprite graphics, which turned out to be a fairly severe limitation. I later added provisions to increase the sprite graphics memory up to 64K, by allowing the sprite engine to use other areas of memory when the tile or text engines were disabled.


        Sprites have 10 bits of X position and 9 bits of Y position. These are signed integers, which allows a sprite to be partially off the top or left side of the screen with a negative location. Sprites must be square, but can be 8, 16, 32, or 64 pixels wide. Like tiles, sprite images can be flipped horizontally, vertically, and/or rotated, and their images can be stored as 16 or 256 colors. There is also a feature to automatically animate sprites, rotating their image through several with adjustable timing, and a sprite-to-sprite and sprite-to-tile collision detection system.


        The code of the sprite engine is more complicated than the other engines. First there is a scan process that iterates through the data for the 128 sprites, reading the next sprite's data from the sprite data registers and passing it to the sprite math block.

       The sprite math block determines if this sprite is potentially present on the current horizontal line being drawn to the line buffer. If the sprite is to potentially be drawn, the math block generates a command to be sent to the blitter. This command includes source address in memory, destination address on the line, and information about how much and in what direction to increment the source data pointer between each pixel - required for sprites which might be flipped or rotated. The math block also takes into account the animation cycle, moving the data pointer between animation frames as needed. This information is all packed into a 64 bit command which is sent to a command FIFO.

        The command FIFO holds up to 16 blit commands. If the FIFO happens to be full - possible if multiple large sprites are handled at once - the scan state engine gets paused until there is a free spot in the FIFO. When there is a command ready in the FIFO, the blitter engine takes the command and copies the indicated pixels from the sprite bitmap memory to the line buffer.

        There is no hard-coded limit to the number of sprites per line. In theory, if you draw too many large sprites to the same line at once, the blitter will at some point run out of time and be unable to finish drawing all of them before the line buffers get swapped and sent to the screen. I’m not entirely sure where that point is yet, I need to do more testing.

        I figured that these four engines should be enough for a machine that would be more than comparable to any early 80’s 8 bit boot-to-basic or home game machine. The next step would be to connect an external CPU and actually write code to run it.

No comments:

Post a Comment