Timer Control for Games
Understanding the High-Resolution Timer
All games today require at least some semblance of a timer. Back in the "early days" of game development, using a timer wasn't considered important. Computer systems were still all relatively the same speed (slow), and any calculations that needed to be performed could be based on the number of frames that had passed. If the code was only ever going to run on a single computer system, or if every system it was running on was identical, this might be a valid way to perform these calculationsbut even in that case, it's not normally the best-case scenario.
Imagine the situation where you are designing a physical limit on a car. Does it make more sense to think "The maximum speed of the car is 250 units per 6 frames," or instead, "The maximum speed of the car is 250 miles per hour (0.7 miles per second)?" Most calculations for things like physics are based on values over time, so it makes more sense to actually use time.
Another reason to avoid using frame-based calculations concerns the vast differences in computing systems nowadays. Imagine developing your game on a 2GHz processor. Through some trial and error, you've got your car physics perfect. Now, you give your game to your two buddies, one of whom has a 1GHz machine, the other a 3GHz machine. The one with the slower machine complains that everything goes too slow; the other complains about how things move too fast to control. It's never a good idea to rely on processing speed for any important calculations. Even on identical systems, other running applications could affect the running speed of your application.
Construction Cue
Caution
What you need here is a timer that has a much higher resolution. A resolution of 1ms would be perfect. The sample framework has such a timer built in, located in the dxmutmisc.cs code file. Because it is an important topic, I briefly discuss this timer now.
Because there is no high-resolution timer built into the .NET runtime, and you need it for your game, you need to use the DllImport attribute to call two particular Win32 APIs, QueryPerformanceFrequency and QueryPerformanceCounter. You see the declarations for these two external methods in the NativeMethods class, such as what appears in Listing 5.1.
Listing 5.1. Declaring External Functions
[System.Security.SuppressUnmanagedCodeSecurity] // We won't use this maliciously [DllImport("kernel32")] private static extern bool QueryPerformanceFrequency( ref long PerformanceFrequency); [System.Security.SuppressUnmanagedCodeSecurity] // We won't use this maliciously [DllImport("kernel32")] private static extern bool QueryPerformanceCounter(ref long PerformanceCount);
You'll notice that not only are you using the DllImport attribute, but you are also using the SuppressUnmanagedCodeSecurity attribute. Because you are calling a method that isn't controlled by the .NET runtime, in the default case it does a stack walk and ensures that your process has enough privileges to run unmanaged code (which is what the Win32 API calls are)and it performs this check every time you call this method. This security check is expensive and time consuming, and this attribute ensures that the check happens only once. Aside from that fix, this is a simple case where you declare a call into a Win32 API. Also notice a few instance variables in the FrameworkTimer class so that it can calculate the total time or the time elapsed since the last update:
private static bool isUsingQPF; private static bool isTimerStopped; private static long ticksPerSecond; private static long stopTime; private static long lastElapsedTime; private static long baseTime;
Here you can see that you want to store the time the timer has started, the amount of time that has elapsed, and the time that was stored at the last update. Because the high-resolution timer can have a varying number of ticks per second, you also want to store that as well. Finally, you store the actual state of the timer. Notice that all the variables are marked static. This move ensures that there is only one high-resolution timer per application domain. (You can read more about application domains in the .NET documentation.) With the state variables declared, you now need to initialize your data in the constructor for this class, as shown in Listing 5.2.
Listing 5.2. Initializing the High-Resolution Timer
private FrameworkTimer() { } // No creation ////// Static creation routine /// static FrameworkTimer() { isTimerStopped = true; ticksPerSecond = 0; stopTime = 0; lastElapsedTime = 0; baseTime = 0; // Use QueryPerformanceFrequency to get frequency of the timer isUsingQPF = NativeMethods.QueryPerformanceFrequency(ref ticksPerSecond); }
There are two things to see in the initialization here. First, there is a static constructor where the real initialization takes place, and second, the normal constructor is private. Because this class is going to contain only static methods, you don't want anyone to be able to create an instance of the class. The only initialization you need here is to determine the amount of ticks per second. If this function returns false, the system does not support a high-resolution timer. You could spend the time writing code to fall back to the less reliable TickCount property, but that work goes beyond the scope of this book. Now you add the code in Listing 5.3 to the timer code, which finishes up the timer class.
Listing 5.3. Implementation of the High-Resolution Timer
public static void Reset() { if (!isUsingQPF) return; // Nothing to do // Get either the current time or the stop time long time = 0; if (stopTime != 0) time = stopTime; else NativeMethods.QueryPerformanceCounter(ref time); baseTime = time; lastElapsedTime = time; stopTime = 0; isTimerStopped = false; } public static void Start() { if (!isUsingQPF) return; // Nothing to do // Get either the current time or the stop time long time = 0; if (stopTime != 0) time = stopTime; else NativeMethods.QueryPerformanceCounter(ref time); if (isTimerStopped) baseTime += (time - stopTime); stopTime = 0; lastElapsedTime = time; isTimerStopped = false; } public static void Stop() { if (!isUsingQPF) return; // Nothing to do if (!isTimerStopped) { // Get either the current time or the stop time long time = 0; if (stopTime != 0) time = stopTime; else NativeMethods.QueryPerformanceCounter(ref time); stopTime = time; lastElapsedTime = time; isTimerStopped = true; } } public static void Advance() { if (!isUsingQPF) return; // Nothing to do stopTime += ticksPerSecond / 10; } public static double GetAbsoluteTime() { if (!isUsingQPF) return -1.0; // Nothing to do // Get either the current time or the stop time long time = 0; if (stopTime != 0) time = stopTime; else NativeMethods.QueryPerformanceCounter(ref time); double absoluteTime = time / (double)ticksPerSecond; return absoluteTime; } public static double GetTime() { if (!isUsingQPF) return -1.0; // Nothing to do // Get either the current time or the stop time long time = 0; if (stopTime != 0) time = stopTime; else NativeMethods.QueryPerformanceCounter(ref time); double appTime = (double)(time - baseTime) / (double)ticksPerSecond; return appTime; } public static double GetElapsedTime() { if (!isUsingQPF) return -1.0; // Nothing to do // Get either the current time or the stop time long time = 0; if (stopTime != 0) time = stopTime; else NativeMethods.QueryPerformanceCounter(ref time); double elapsedTime = (double)(time - lastElapsedTime) / (double)ticksPerSecond; lastElapsedTime = time; return elapsedTime; } public static bool IsStopped { get { return isTimerStopped; } }
This is a relatively simple implementation. Everything you need to know about the state of the timer you have here, including starting, stopping, and getting the elapsed time or the total time. Each of these properties returns a float value based in seconds; for example, 1.0f is exactly 1 second, and 1.5f is exactly a second and a half. With that, you have a generic high-resolution timer available for your games.
Handling Lost DevicesBefore you start using that timer, you need to take care of something more pressing. To see the problem in action, change your project to run in release mode. You can do so by selecting Build, Configuration Manager. Running the application now renders the fledgling game in full-screen mode. On the surface, everything should appear the same; however, you might notice quickly that with the window border no longer around, you can't close the application. Luckily, you are using the sample framework; otherwise, if you decided to minimize the window (for example, by using the Alt+Tab key combination to switch to the next application) or close the window, the game would throw an exception as soon as it lost focus. Even though you don't have this problem because you're using the sample framework, it's important to know what causes this. When you are running the application in full-screen mode, Direct3D takes exclusive mode over the monitor that is being used to render the full screen image. When you switch to a different application, that exclusive mode must be released to allow the other application to run, so the current application is minimized and the device is lost. Once a device is lost, you cannot render to it any longer, and trying to do so throws an exception (specifically the DeviceLostException). Because this is a common problem that every game has to solve, you wouldn't want to clutter your rendering method with code to handle the situation, which is why the sample framework handles it for you. Look at the code snippet in Listing 5.4 for more information. Listing 5.4. Handling Lost Devicesint result; // Check the cooperative level to see if it's ok to render if (!device.CheckCooperativeLevel(out result)) { if (result == (int)ResultCode.DeviceLost) { // The device has been lost but cannot be reset at this time. // So wait until it can be reset. System.Threading.Thread.Sleep(50); return; } // Other code snippets } // Other code snippets // Show the frame on the primary surface try { device.Present(); } catch (DeviceLostException) { // Whoops, device is lost now State.IsDeviceLost = true; } catch (DriverInternalErrorException) { // When DriverInternalErrorException is thrown from Present(), // the application can do one of the following: // // - End, with the pop-up window saying that the application cannot continue // because of problems in the display adapter and that the user should // contact the adapter manufacturer. // // - Attempt to restart by calling Device.Reset, which is essentially the // same path as recovering from a lost device. If Device.Reset throws the // DriverInternalErrorException, the application should end immediately // with the message that the user should contact the adapter manufacturer. // // The framework attempts the path of resetting the device // State.IsDeviceLost = true; } You'll notice immediately that this method updates the variable IsDeviceLost on the State class. In this method, you check to see whether the device is currently lost, and if it is, you call the CheckCooperativeLevel method on the device. This call returns a Boolean value, true if the device is available and ready to use and false otherwise. Notice that an integer value is returned as an out parameter, which is the reason why the device is still not available. After this call (assuming it returned false), you want to check the reason why the device is not available to render. The two likely reasons are that the device is still lost (the application still doesn't have focus) or that the application has regained focus, but the device hasn't been reset. If the device is still lost, there's nothing you can do right now, so you simply return from the method. However, if the device needs to be reset (which it always does after regaining focus), you simply call the Reset method, which should return the device back to the exclusive mode and be ready for rendering (the framework handles this for you). Assuming you made it past that section, either the device was never lost to begin with or it is reset and ready to use now. Next, you are ready to render your scene. The framework calls the render method you have written and then makes the Present call. This call is the one that throws the exception when the device is lost, so after the render method is called, Present is called, wrapped in a try/catch block. Notice that the only exceptions caught are DeviceLostException and DriverInternalErrorException and the only action taken is to set the Boolean variable to TRue to start the check of the current cooperative level.
|
Comments