Publishing game titles for multiple platforms is no new idea, but it is usually the case that the title is developed for a single operating system and the source code is subsequently ported to additional platforms. Ports can be costly, time-consuming, and downright ugly, however, if the original code is heavily dependent on interfaces or data types which are not present on the platform targeted by the port. The only alternative is to develop for multiple platforms simultaneously from the very beginning of a project. Simultaneous development requires that a system architect have some expertise in each of the platforms for which a title is targeted for release. It is the focus of this article to demonstrate how major subsystems of a game engine can be written to function on both Windows and Macintosh operating systems.
General Design Philosophy
When programming for multiple platforms, it is usually desirable to hide code that is dependent on a particular operating system by using a layered design. Platform-specific code should be safely tucked away at the lowest level possible, and code inhabiting the higher levels should require no knowledge of how the lower levels are implemented. This black box approach should be used to encapsulate major subsystems of a game engine as well as any miscellaneous functions and data types which may be depend on the underlying operating system. Once these lowest layers have been written, an applications programmer should be able to write code that interacts only with the interface that the game engine itself exposes to higher layers. The game engine is then responsible for communicating the right information to and from the operating system.
There appear to be two popular ways of organizing platform-dependent code. The first is to have separate Windows and Macintosh versions of a file that implements a particular subsystem. Ports invariably lead to this arrangement. This approach, however, usually leads to duplication of a lot of code which is actually common to both platforms. It also carries the drawback of having to touch multiple files to make a small change which requires that each implementation be altered. The examples in this article shall use a second approach: conditional compilation. We can define two flags, named WINDOWS and MACOS, which when used with #if … #elif … #endif blocks tell the preprocessor to either keep a block of code, if the corresponding flag is defined to be 1, or remove a block of code, if the flag is defined to be 0.
Consider the following simple data type abstraction example which uses the above scheme.
typedef HWND WindowReference;
typedef WindowPtr WindowReference;
This creates a small layer between the operating system and higher levels of the game engine which hides the platform-dependence of the WindowReference type. Any functions or derivative data types that need to refer to a window should use the WindowReference name instead of the name defined by the operating system’s API. This way, anything that refers to a window does not require separate definitions for each platform since it does not directly depend on a platform-specific data type.
This practice of isolating platform dependencies at the lowest possible level will be apparent throughout the code accompanying this article. The remainder of this article will be spent building abstraction layers for 3D graphics, sound, and networking subsystems upon which higher levels can rest without knowledge of what operating system they are running on. The differences between the two platforms will be pointed out, and methods for avoiding common pitfalls will be presented.
When developing a 3D engine for both Windows and Macintosh, one has little choice but to use OpenGL, primarily because it is the only 3D API supported on the Macintosh platform. OpenGL carries the tremendous advantage that it is a cross-platform standard. Almost all of a game’s 3D code can be written once and be guaranteed to work on both platforms. The only piece that is dependent on the operating system is the initial context setup. Both the Windows and Macintosh operating systems supply a small API to link OpenGL to their internal windowing systems. On Windows, it is called WGL, and on the Macintosh, it is known as AGL. These libraries enable an application to query the pixel formats that the 3D hardware supports and to bind an OpenGL context to a window.
Listing 1 shows the Windows and Macintosh versions of a class named GraphicsContext which encapsulates an OpenGL context. The constructor of this class creates an OpenGL context and associates it with a window. The first step towards accomplishing this is to tell the operating system what pixel format and context attributes are desired. Both WGL and AGL supply a function which takes this information and returns the most appropriate pixel format identifier. With this identifier in hand, an OpenGL context can be created and bound to a window. It is important to note that on both platforms, each thread of execution may have only one active OpenGL context at any time, as specified with the wglMakeCurrent and aglSetCurrentContext functions.
The SetPixelFormat function on the Windows platform carries with it the limitation that it may be called only once for any given device context. This becomes problematic if you would like to change resolutions or pixel depths and thus need to create a new OpenGL context for a window which previously had one bound to it. Destroying and recreating the main window can just make the problem worse since other game systems which are also associated with that window would have to be restarted as well. A nice workaround is to create a full-size child window of the main window and use it as the window to which the OpenGL context is attached. This way, only the child window needs to be destroyed and recreated allowing OpenGL context replacement without disturbing other systems.
Once a context has been created and rendering has occurred, the image is displayed by calling a single WGL or AGL function which swaps the OpenGL drawing buffers. This is demonstrated in the UpdateContext function shown in Listing 1.
Accessing OpenGL extensions is the only remaining area in which Windows and Macintosh methods differ. Once you have determined that an extension is available (by calling glGetString with the GL_EXTENSIONS selector), Windows implementations must call wglGetProcAddress to retrieve a pointer to any function defined by the extension. Extensions currently supported on the Macintosh already have entry points for their functions defined in the OpenGL library, so there is no need to locate the function and call it through a pointer. As an example, suppose that the GL_ARB_multitexture extension is available and you want to call the function glActiveTextureARB. On the Macintosh, this function is already defined and may be called directly. On Windows, a pointer to this function can be retrieved by using the following code.
typedef void (*ATProc)(GLenum target);
ATProc activeTextureProc =
Once this function pointer has been acquired, a small inline function can be defined to emulate a direct function call as follows.
inline void glActiveTextureARB(Glenum target)
This method provides a consistent interface to the glActiveTextureARB function on both platforms. The predefined extension functions will exist only on Macintosh operating systems predating MacOS X. On MacOS X, the OpenGl library will provide a counterpart to the wglGetProcAddress function, and it will be necessary to retrieve function pointers in the same way that it is currrently done on Windows.
Sound programming differs significantly between the Windows and Macintosh operating systems. On Windows, DirectSound provides the functionality for playing basic sounds and for implementing more advanced effects such as 3D positional audio. On the Macintosh, one uses the Sound Manager to handle their audio needs. The Macintosh Sound Manager by itself does not provide any 3D support, however. Instead, 3D sound is provided by the supplemental use of the SoundSprocket library. Listing 2 shows how to play a buffer of 16-bit, 22.050 kHz, stereo sound on both Windows and Macintosh. Adding 3D effects to this code is beyond the scope of this article, but is not difficult once the level of functionality presented here has been implemented.
The SoundMgr class shown in Listing 2 demonstrates the initialization of DirectSound. The Macintosh Sound Manager requires no initialization, and thus there is no Macintosh counterpart to this class. DirectSound requires that you create a primary play buffer and specify what sound format the hardware should be prepared to play. Sounds are actually played by allocating secondary play buffers which are then mixed into the primary buffer by DirectSound. This is demonstrated in the Sound class shown in Listing 2. The constructor for the Sound class creates a secondary sound buffer and copies the sound information into this buffer (which may actually be on the sound hardware). The sound is then played by calling the Play function. Sounds may be looped by specifying the DSBPLAY_LOOPING flag as the last parameter to the IDirectSoundBuffer::Play function.
Sound is played on the Macintosh by allocating sound channels and sending commands to them. The creation of a sound channel is demonstrated in the Sound constructor shown in Listing 2. Once a sound channel exists, a sound buffer can be played through it by issuing a bufferCmd command. This command carries with it a pointer to an extended sound header, which was filled out by the Sound constructor. This header contains all of the format information necessary for the Sound Manager to correctly play the sound.
Playing looping sounds on the Macintosh is not as simple as on Windows. If you want a sound to play a finite number of times, you can simply issue several bufferCmd’s to the sound channel. (Sound commands are queued and executed only after any previous command has completed.) If you want the sound to loop indefinitely, however, you will have to issue a callBackCmd command which will notify you when a sound has finished playing. When a sound channel is created, an optional callback function may be specified, and this function is invoked whenever the sound channel encounters a callBackCmd. A callback function on the Macintosh has to be specified as a universal procedure pointer, as done in the Sound constructor. (Universal procedure pointers are function pointer abstraction mechanisms left over from the 680x0 to PowerPC transition.) The callback function itself simply issues another bufferCmd and callBackCmd which continue the looping process.
An important issue to keep in mind when playing 16-bit sound is byte order. If you are playing sound from a *.WAV file for instance, you will have to reverse the two bytes in each audio sample before playing them on the Macintosh since these samples are stored in little endian byte order. A single audio sample can have its byte order reversed with the following code.
unsigned short *samp; // Pointer to sample
samp = __lhbrx(0, samp);
This code uses the handy PowerPC instruction lhbrx (Load Halfword Byte Reversed Indexed), which is accessible from C through the __lhbrx intrinsic function. This function loads a 16-bit value and swaps the low and high order bytes.
Cross-platform network communication is a must for any multiplayer game. Both the Windows and Macintosh operating systems provide libraries for multiplayer gaming (DirectPlay on Windows and NetSprocket on Macintosh), but both are platform-specific and do not allow for cross-platform networking. These libraries are thus useless to the engineer wishing to implement cross-platform multiplayer games. The only alternative is to code directly to the low-level networking libraries. This section describes how to implement UDP (User Datagram Protocol) communications, a part of the standard TCP/IP stack, using the WinSock library on Windows and the OpenTransport library on the Macintosh.
The NetworkMgr class shown in Listing 3 demonstrates the initialization of WinSock and OpenTransport. After starting up the networking library, the NetworkMgr constructor determines the machine’s local IP address. The WinSock library does not provide a direct way of retrieving this address, so we are forced to use the workaround of retrieving the local machine’s name and then resolving that name’s IP address.
Actual communications occur through the NetworkEndpoint class shown in Listing 3. The code given here implements fully asynchronous operation, meaning that data can be sent and received at any time without dependency on a repetitively called servicing function. This is accomplished through the use of a dedicated thread on Windows and an endpoint notification function on the Macintosh.
The Windows version of the NetworkEndpoint constructor creates a UDP socket and binds it to the machine’s local address. A new thread is also created which listens on two event objects. The first event object is configured by the WSAEventSelect function to be set whenever new data arrives (the FD_READ event) and whenever it becomes possible to send data (the FD_WRITE event). The second event is just used to signal that the thread should exit because the NetworkEndpoint class is being shut down. The dedicated thread sleeps until an event occurs, so it has minimal impact on overall game performance.
The Macintosh version of the NetworkEndpoint constructor creates an asynchronous UDP endpoint and assigns it a notification function. The notification function is then called asynchronously to handle the remainder of the initialization and any subsequent network communications. The notification function first receives the T_OPENCOMPLETE event indicating that the endpoint has been created (even this happens asynchronously). At this point, the endpoint is bound to the machine’s local address. The notification function receives the T_DATA event when new data arrives and it receives the T_GODATA event when it becomes possible to send data which may not have been previously sent because the connection was full.
The Send and Receive functions of the NetworkEndpoint class demonstrate how data packets may be sent and received by a WinSock socket or an OpenTransport endpoint. These functions each send or receive a single data packet. It is import to realize that any data that you expect to send across the wire on a heterogeneous network needs to use a consistent byte order. The official byte order of the internet is big endian, and in fact even WinSock requires that you specify IP addresses in big endian byte order. WinSock provides functions such as htonl (Host TO Network Long) which convert between the host byte order and the network byte order (big endian).
Both WinSock and OpenTransport allow you to receive data packets which are larger than the size of your receipt buffer by breaking the packet into multiple pieces. If you know what the maximum size of your packets can be and set your receipt buffer to this size, then any larger packet may be interpreted as invalid data, perhaps a hacker attempt to flood the connection. The Receive function protects against this by implementing a simple oversized packet mechanism which causes it to ignore all of the pieces of any packets that it receives that are larger than the receipt buffer.
It is likely that you will want to preallocate space for a large number of data packets which can later be used to send and receive data. Since these packets are going to be used asynchronously by more than one thread of execution, they need to be protected by some kind of synchronization mechanism such as a mutex. In the Windows environment, where every thread can be preempted by any other thread, it is safe to wait indefinitely to acquire a mutex inside the dedicated send/receive thread. This is not the case on the Macintosh, however, since the notification function is actually part of an interrupt service routine and will never be preempted by the main thread. On the Macintosh, you have no choice but to only attempt to acquire a mutex from within the notification function. If that attempt fails, you must set a flag indicating that a send or receive operating needs to take place at a later time.
Remember that UDP is an unreliable protocol. UDP datagrams are not guaranteed to reach there destination, and when they do get through, are not guaranteed to arrive in the same order in which they were sent. It is up to you to sort out the details of acknowledging the receipt of packets, resending lost packets, and processing packets in the correct order.
For Further Information
Documentation for the WGL library, DirectX, and WinSock can all be found inside the Microsoft Platform SDK, which can be downloaded at
Documentation for the AGL library can be found inside the Apple OpenGL SDK, which is available at
Documentation for the Macintosh Sound Manager is available online at
Documentation for OpenTransport is available online at
The Apple Game Sprockets SDK can be found at
Lengyel is currently a software engineer on the OpenGL team at
Apple Computer. Before joining Apple in 1999, he spent a few years
in the game development industry and continues to sacrifice sleep in
order to code games in his spare time. Eric can be reached at [email protected].