Home > Articles

  • Print
  • + Share This
This chapter is from the book

Building the Virtual Computer Interface

Building the virtual computer interface is rather easy if you have a functional library that can implement features (1) through (3) from the preceding section—which of course we do. That is, the library from the first Tricks. But before we get into really specific details, let's design a "model" of the functions and data structures that we would like to have in the virtual computer interface. Of course, this is just an example, and is going to be very high-level. Moreover, I'm just going to make the function names up as we go, so we have something to refer to. Alas, the final version of the virtual computer will be different, because when implementing it, we must take details into consideration. However, the following exercise will give you an idea of the big picture.

The Frame Buffer and Video System

Before we get started, let's assume that there is a way to initialize the video system and open a window with a specific resolution and bit depth. Let's call it Create_Window(), and here's what it might look like:

Create_Window(int width, int height, int bit_depth);

To create a 640x480 display with 8-bits per pixel, you would make the following call:

Create_Window(640, 480, 8);

And to create a 800x600 display with 16-bits per pixel, you would make the following call:

Create_Window(800, 600, 16);

Remember, this is all academic, and the real code to create a window and initialize the system might be a set of functions, but you get the idea. In reality, the "window" might actually be a Windows window, or it might be full screen. So we know that we aren't going to get away this easy. But let's move on now assuming that we have a window.

As I said, we are interested in designing a system that is based on a primary display buffer that is visible, and a secondary offscreen buffer that is not visible. Both buffers should be linearly addressable, where one word, be it a BYTE, WORD, or QUAD represents a single pixel, depending on the bit depth. Figure 3.2 represents our frame buffer system.

Figure 3.2Figure 3.2 The frame buffer system.

You will notice that there is a memory padding region in the frame buffer memory in each of the primary and secondary display buffers. This models the fact that some video systems might be linear per row, but jump addresses row to row. For example, in Figure 3.3, we see this up close. In the example, the video system is set to 640x480x8, therefore, there is 1 byte per pixel, and there should be a total of 640 bytes per video line. But there's not! In this example, there are 1024 bytes per video line. This is a manifestation of the video card memory addressing, and is very common for many video cards. Hence, we need to model this discrepancy with what is commonly referred to as memory pitch.

Memory pitch models the fact that a video card might have extra memory per line because of caches or hardware addressing. This isn't a problem as long as we don't assume that memory pitch equals the video pitch (in the case of our example, 640 bytes = 640x1 byte per pixel). To locate a pixel on an 8-bit per pixel video system, we use the following code:

UCHAR *video_buffer; // ptr to the video buffer
int x,y;    // coordinates to the pixel
int memory_pitch; // number of bytes per line

video_buffer[x + y*memory_pitch] = pixel;

On a 16-bit system, we would use slightly different code:

USHORT *video_buffer; // ptr to the video buffer
int x,y;    // coordinates to the pixel
int memory_pitch;  // number of bytes per line

video_buffer[x + y*(memory_pitch >> 1)] = pixel;

Figure 3.3Figure 3.3 Close-up view of the hardware frame buffer.

Of course, if memory_pitch were the number of USHORTs per line, the bit shift operator >> used to divide by 2 wouldn't be needed.

With that in mind, let's create a model of the primary and secondary buffers. Remember, this is just a model, and the details will change when we actually implement the model with the T3DLIB game engine. Anyway, it looks like we need a pointer to both the primary and secondary buffers, along with variables to hold the memory pitch. For example, here's some globals that do the job:

UCHAR *primary_buffer; // the primary buffer
int primary_pitch; // the memory pitch in bytes

UCHAR *secondary_buffer; // the secondary buffer
int seconday_pitch; // the memory pitch in bytes

Notice that both pointers are UCHAR*, and that the memory pitch is in terms of bytes? That's great if we are working with an 8-bit mode, but what about 16-bit modes, where there are 2 bytes per pixel, and each word is a USHORT? The answer is that I want to keep the data types simple and homogeneous. If you are working in a 16-bit mode, you can simply cast the pointers to USHORT*, or whatever. The point is that keeping the pointers UCHAR* is cleaner, along with always knowing memory pitch is in terms of bytes. This way, all the functions we write will have the same pointer/pitch parameter footprint.

Locking Memory

Alright, we are getting there, but there is one other feature that we should work in—locking the memory. Many video cards have special memory that might be multi-ported, cached, or whatever. What this means is that when you access the memory, and you're reading and writing to and from the frame buffer(s), you must let the system know that you are accessing the memory, so that the system does not alter it during this time. When you're done, you unlock and release the memory and the system can go about its business.

TIP

If you're an old Windows programming guru from the 80s and early 90s, you should feel right at home, because in the earlier versions of Windows (1.0, 2.0, and 3.0), you had to lock memory—yuck!

All this means to us as programmers is that the pointers primary_buffer and secondary_buffer are only valid during a locked period. Furthermore, you can't assume that the addresses will be the same in the next lock. That is, maybe during one lock-unlock period the primary buffer was at

0x0FFFEFFC00000000

and during the next lock it's at

0x0FFFFFFD00000000

This is a manifestation of the hardware. It might move the frame buffer(s), so watch out! In any case, the locking procedure is as follows:

  1. Lock the buffer of interest (either primary or secondary) and retrieve the starting memory address, along with the memory pitch.

  2. Manipulate the video memory.

  3. Unlock the buffer.

And of course, in 99% of the cases you will only lock-Read/Write-unlock the secondary buffer rather than the primary buffer, because you wouldn't want the user to "see" you altering the primary display buffer.

Based on this new feature, let's create a few functions that perform the locking and unlocking operations:

Lock_Primary(UCHAR **primary_buffer, int *primary_pitch);
Unlock_Primary(UCHAR *primary_buffer);

Lock_Secondary(UCHAR **secondary_buffer, int *secondary_pitch);
Unlock_Secondary(UCHAR *secondary buffer);

Basically, to lock a buffer you call the function with the addresses of the storage for the frame buffer and memory pitch. The function then locks the surface and alters the sent variables (writes to them). Then you are free to use them. When you're done, you unlock the surface with a call to Unlock_*() with a pointer to the locked surface. Simple, huh?

As an example, let's see how we would write a single pixel in 800x600 mode to the screen of the secondary buffer in both 8-bit mode and 16-bit mode. First, here's the 8-bit example:

UCHAR *primary_buffer; // the primary buffer
int primary_pitch; // the memory pitch in bytes

UCHAR *secondary_buffer; // the secondary buffer
int seconday_pitch; // the memory pitch in bytes

UCHAR pixel; // the pixel to write
int x,y;  // coordinates of pixel

// step 0:create the window
Create_Window(800, 600, 8);

// step 1: lock the secondary surface
Lock_Secondary(&secondary_buffer, &secondary_pitch);

// write the pixel to the center of the screen
secondary_buffer[x + y*secondary_pitch] = pixel;

// we are done, so unlock the secondary buffer
Unlock_Secondary(secondary_buffer);

That was easy! Now here's the same code, assuming that the system is in 16-bit mode or 2 bytes per pixel.

UCHAR *primary_buffer; // the primary buffer
int primary_pitch; // the memory pitch in bytes

UCHAR *secondary_buffer; // the secondary buffer
int seconday_pitch; // the memory pitch in bytes

USHORT pixel; // the pixel to write, 16-bits
int x,y;  // coordinates of pixel

// step 0:create the window
Create_Window(800, 600, 16);

// step 1: lock the secondary surface
Lock_Secondary(&secondary_buffer, &secondary_pitch);

// at this point, we have to be careful since the
// locked pointer is a UCHAR * and not a USHORT *
// hence we need to cast it

USHORT *video_buffer = (USHORT *)secondary_buffer;

// write the pixel to the center of the screen
video_buffer[x + y*(secondary_pitch >> 1)] = pixel;

// we are done, so unlock the secondary buffer
Unlock_Secondary(secondary_buffer);

Note that we also had to divide the memory pitch by 2 (using the >> operator) because it's in terms of bytes, and if we want to use the built-in pointer arithmetic properly, we need to convert the memory pitch to the number of USHORTs per line. Of course, there are a hundred ways you can write this code, but you get the point. Get it—point? You could say I have a one-dimensional sense of humor <GRIN>.

Finally, there is one detail that we have left out—the 8- or 16-bit pixel data format. Oops!

Working with Color

We are primarily going to work with 8-bit palettized video modes and 16-bit RGB modes in our 3D game programming for speed reasons. In 8-bit mode, there is 1 byte per pixel, and in 16-bit mode, there are 2 bytes per pixel. However, the encoding is completely different.

8-Bit Color Mode

8-bit mode uses a standard 256-entry color lookup table (CLUT), as shown in Figure 3.4. 16-bit mode uses a standard RGB encoding. When working with the 8-bit mode, we must fill the color lookup table with RGB values for each color entry in the table for 0...255. We assume that there is a function(s) to access this table and read/alter each entry, so let's not worry about it for now.

I just want you to know that there aren't any surprises about 8-bit mode—it's the standard 1 byte per pixel system, where each pixel value is an index into the color lookup table, and each table entry is composed of a color triad with 8-bits for each channel, (red, green, blue), for a total of 24-bits per entry.

TIP

Some cards only use 6 bits of each 8-bit channel entry, or in other words, there are only 26 = 64 shades of each primary—red, green, blue—rather than the standard 28 = 256 shades, with a full 8 bits per channel color lookup.

Figure 3.4Figure 3.4 Comparisons of various color depths and their implementations.

16-Bit Color Mode

Working with 16-bit color is a little simpler than 8-bit—sorta. In the case of 16-bit color, there isn't a lookup table anymore. In fact, each pixel is in the form of a concatenated bit string word of RGB. In this form, the pixel format is either 5.5.5 (RGB) or 5.6.5 (RGB) format; that is, 5 bits of red, 5 bits of green, 5 bits of blue, or 5 bits of red, 6 bits of green, and 5 bits of blue, respectively. This is shown in Figure 3.5. This RGB pixel format concept sometimes gives people a lot of trouble (I have thousands of emails asking about it). The common question is how to build a pixel up? I think that the problem is that everyone thinks it's more complex than it really is. There is nothing to it really, just some bit manipulations.

In 5.5.5 RGB mode, there are 5 bits for every pixel, or 25 = 32 intensities for each color channel. Therefore, a single pixel's RGB values each range from 0–31 (5 bits each), and there are a total of 32*32*32 = 32,768 different colors possible. Hence, all you need to do is write some code that takes a triad of three values. Suppose you have r,g, and b, each ranging from 0–31 (31 being the highest intensity) and you use bit manipulation operators to build the pixel up. Here's one such macro for 5.5.5 mode:

// this builds a 16 bit color value in 5.5.5 format (1-bit alpha mode)
#define _RGB16BIT555(r,g,b) ((b & 31) + ((g & 31) << 5) + ((r & 31) << 10))

Figure 3.5Figure 3.5 16-bit color encodings for both the 5.5.5 and 5.6.5 formats.

Similarly, in 5.6.5 mode, there is an extra bit for green, so the range of RGB pixel values is 0–31 for red, 0–63 for green, and 0–31 for blue. This gives us a total of 32*64*32 = 65,536 possible colors. Once again, here's a macro to build a pixel from a triad of values r,g, and b:

// this builds a 16 bit color value in 5.6.5 format (green dominate mode)
#define _RGB16BIT565(r,g,b) ((b & 31) + ((g & 63) << 5) + ((r & 31) << 11))

This problem has given people so much trouble in the past that we are going to work some logic into the system to take this into account. The graphics system, when selected into 16-bit mode, will determine the pixel format (either 5.5.5 or 5.6.5) and set flags and function pointers, so you can always use the same macro to build up a 16-bit color pixel. However, this feature will be in the "real" library we make later. For now, just make note of this detail, because I don't want to hear about how everything "looks green" when someone assumes a pixel format. Now, let's talk about animating the display.

Animating the Display

The final bit of functionality we need to implement is a way to flip the display, or copy the secondary buffer to the primary buffer, as shown in Figure 3.6. Of course, we could simply lock both surfaces and do a line-by-line copy of memory. However, many cards have special hardware to flip the display and copy them faster than we can with the processor. Hence, we will allow for this option and use a black box approach to flipping the display via an API call. Let's call the function Flip_Display(). It takes no parameters, and simply copies the secondary buffer to the primary buffer (with software or hardware, but we don't know which).

Figure 3.6Figure 3.6 A page-flipped animation system.

CAUTION

You might wonder why I said to copy the secondary buffer line by line, rather than use a memcpy() function. This is because the secondary buffer may or may not be contiguous from line to line, and memcpy() has no way of knowing this. Alas, you must copy the buffers line by line, and then address each row by the appropriate memory pitch if you want to do it yourself.

However, there is a rule we are going to follow—when you make a call to Flip_Display(), you must make sure that both the primary and secondary buffers are unlocked. If they aren't, the flip won't occur, and an error will be thrown—or you will get some exercise with your index finger pressing the reset button on your computer. Therefore, before your call to Flip_Display(), you should always make sure that both buffers are unlocked and that the system has access to them.

Additionally, there are two more helper functions that come in handy when performing animation: functions to clear the buffers. You could once again just lock the buffers and use memset() or something to clear the buffer(s) line by line, but many cards have hardware-fill capabilities to clear memory buffers. Let's assume that where hardware filling is available it will be used; otherwise, software will be used to fill the memory. In either case, we don't care, because we will make up API functions to do this for both the secondary and primary buffers:

Fill_Primary(int color);
Fill_Secondary(int color);

The functions assume that the buffers are unlocked and the color parameter would be either 8- or 16-bit, depending on the mode. In most cases, you want to clear the buffer with black, so you would use 0. This is because most palettes use index 0 as black, and the USHORT value of 0 is equivalent to RGB (0,0,0), which is black, also.

Using the clear function is simple. At the top of your animation loop, you would make a call to clear the screen out. However, because you are going to later copy the secondary buffer to the primary buffer at the end of the animation cycle, there is no need to clear the primary buffer, so all you need to do is clear the secondary buffer.

As an example, here's how you would draw an animated display of randomly positioned pixels in 800x600x8 mode, at 30 fps:

UCHAR *primary_buffer; // the primary buffer
int primary_pitch; // the memory pitch in bytes

UCHAR *secondary_buffer; // the secondary buffer
int seconday_pitch; // the memory pitch in bytes

UCHAR pixel; // the pixel to write, 8-bits
int x,y;  // coordinates of pixel

// step 0:create the window
Create_Window(800, 600, 8);

// enter infinite loop
while(1)
{
// clear the secondary buffer
Fill_Secondary(0);

// step 1: lock the secondary surface
Lock_Secondary(&secondary_buffer, &secondary_pitch);

for (int num_dots=0; num_dots < 1000; num_dots++)
 {
 // get a random x,y,pixel
 x = rand()%800;
 y = rand()%600;
 pixel = rand()%256;

 // write the pixel to the center of the screen
 video_buffer[x + y*secondary_pitch)] = pixel;
 } // end for num_dots

// we are done, so unlock the secondary buffer
Unlock_Secondary(secondary_buffer);

// flip the display
Flip_Display();

// wait a sec
Sleep(33);

} // end while

Of course, the loop is infinite, and in a real Windows application, we can't be this greedy and not release each frame back to the main event loop—but you get the idea.

The Complete Virtual Graphics System

At this point, we have enough functionality to implement the virtual graphics system, so let's briefly summarize what we have thus far to make sure we all have the same quantum numbers:

  • We assume that we can call functions that will set the graphics system up and open a window (it could be full-screen) with any size and bit depth that we desire. However, most of the time, we are only going to work with 8- and 16-bit modes for speed.

  • The system is composed of both a primary and secondary offscreen display buffer. The buffers are linearly addressable, but have a memory pitch associated with them. Moreover, to access the actual memory of either buffer, the buffer must be locked. When the operation is complete, the buffer must be unlocked.

  • To animate the display and "flip" one page to another, we assume there is a function that takes advantage of hardware blitting (blocking image transfers) acceleration to accomplish this. However, before making the call, we must make sure that both the primary and secondary buffers are unlocked. Also, there are buffer-clearing functions available that use hardware acceleration (if it's available).

Hopefully, you can see that if we can implement this virtual software system, the 3D graphics aspect of our programming is the only problem left. All we have to interface with to get our image on the screen is the frame buffers and a few API calls here and there.

I/O, Sound, and Music

Because the idea of this book is to make 3D games that are playable, we need to be able to obtain input from the player, as well as play sounds and music. Otherwise, people are going to think you are weird when you use mind control to play the game along with your own sound effects <GRIN>.

Of course, all of this functionality is part of DirectX, and I have written wrappers around all of it to make it as painless as possible. But the point I want to make here is that even if you port the code from the book to another system that doesn't support DirectX, you have very little work. The engine from the first Tricks really only has the following fundamental capabilities (in order):

  1. Initialize and detect all input devices: keyboard, mouse, and joystick.

  2. Read the data or state of all input devices, and put the data in simple data structures.

  3. Initialize the sound and music system.

  4. Load a sound off disk in .WAV format and play it once or loop it. Load a MIDI song off disk and play it once or loop it. Also be capable of testing the status of sounds and music when playing.

  5. Shut everything down.

The preceding list is the exact functionality that we will implement. However, if you need to, you can simply "rip" all the I/O and sound code from any of the demos and port them with just the graphics interface. Of course, you need some kind of input, even if it is getch()!

As a sample API that looks something like what we will end up with, you might create something like this:

// this initializes all the input devices
Init_Input_Devs();

// this shuts down the input system
Shutdown_Input_Devs();

// this reads the keyboard, where data is some structure that
// holds the keyboard state, maybe an array
Read_Keyboard(&data);

// this reads the mouse, where data is some structure that
// holds the mouse state, maybe the position and buttons
Read_Mouse(&data);

// this reads the joystick, where data is some structure that
// holds the joystick state, maybe the position and buttons
Read_Joystick(&data);

// this initializes the sound and music system
Init_Sound_Music();

// this shuts down the sound and music system
Shutdown_Sound_Music();

// this loads a .WAV file and returns an id
int id = Load_WAV(char *filename);

// this plays a .WAV file based on an id
Play_WAV(int id);

// this loads a .MID file and returns an id
int id = Load_MID(char *filename);

// plays a .MID file based on an id
Play_MID(int id);

If we (you) can implement these functions, that's all you need for the I/O and sound interfaces.

Well, that's about it for the abstract design of the virtual computer interface. Remember, this was just an exercise in software design. Now we need to bang out the details—that is, the actual implementation of all the functions—I can't wait!

TIP

What we just did is actually a very useful exercise in design. We basically designed a portable graphics system. This is exactly how you would go about designing a game engine that you can port to other platforms just by implementing the "insides" of a very small set of functions.

  • + Share This
  • 🔖 Save To Your Account