A Real-Time Procedural Universe, Part Three: Matters of Scale

I

nThe main problem with trying to model and render a really large game world is precision. A 32-bit float has a maximum of 6 significant digits of accuracy, and a 64-bit double has a maximum of 15. To put this into the correct frame of reference, if the smallest unit you care about keeping track of is a millimeter, you start to lose accuracy around 1,000 km with floats and around 1 trillion km with doubles.

Given the fact that the Earth's radius is close to 6,378 km, a 32-bit float isn't even enough to model and render one Earth-sized planet accurately. But losing precision at the millimeter, and possibly centimeter level, with the vertices in a planet's model is not a significant concern. You will run into a number of much bigger problems trying to model and render such a large game world. One possible solution is to use 64-bit doubles everywhere, but this is a slow and rather clumsy way to solve these problems.

When I first rendered my planet centered at the origin of my 3D map, I noticed two problems right away. The first was that placing the far clipping plane out at a decent distance made my Z-buffer useless. The second problem was that at a certain distance, the planet would disappear regardless of what I set the far clipping plane to. The second problem seemed to be driver or card-specific because each video card I tested it on ran into the problem at different distances. Both problems had something to do with very large numbers being used in the transformation matrices.

I solved both of these problems by scaling down the size and the distance of planetary bodies by modifying the model matrix. Using a defined constant for the desired far clipping plane, which I'll call FCP for now, I exponentially scale down the distance so that everything past FCP/2 (out to infinity) is scaled down to fall between FCP/2 and FCP. To make the size of the planetary body appear accurate, all you have to do is scale the size by the same factor you scale the distance. Once the routine was written, I just brought the far clipping plane in until the Z-buffer precision seemed to be sufficient. Because distances are scaled exponentially, the proper Z order is maintained in the Z-buffer.

Problems of Scale: A Star System

Next I tried placing a star at the center of the 3D map and placing the planet and camera out to Earth's orbital distance in the X direction. I immediately ran into rendering problems and positioning problems, though it was hard to tell that it was multiple problems until I fixed the rendering problems. The rendering problems caused all objects in the scene to shake and occasionally disappear whenever the camera moved or turned. Again the rendering problems showed up differently on each video card I tested it on, and again they had something to do with very large numbers being used in the transformation matrices.

Perhaps the most common way to use OpenGL's model/view matrix is to push the camera's view matrix onto the stack and multiply it by each object's model matrix during rendering. The problem with the traditional model and view matrices in the test case outlined above is that both have a very large translation in the X direction. A 32-bit float starts to lose precision around 1000 km, and Earth's orbit is around 149,600,000 km. Even though the camera is close to the planet and the numbers should cancel each other out, too much precision is lost during the calculations to make the resulting matrix accurate.

Is it time to resort to doubles yet? Not yet. This problem can be fixed very easily without using doubles by changing how the model and view matrices are calculated. Start out by pretending the camera is at the origin when you calculate your view matrix. If you use an existing function like gluLookAt() to generate your view matrix, just pass it (0, 0, 0) for the eye position and adjust your center position. Then calculate each model matrix relative to the camera's actual position by subtracting the camera's position from the model's position. The result is two matrices with very small numbers when the camera is close to the model, which makes the problem go away completely. A precision problem still exists with objects at a great distance from the camera, but at that distance the precision loss isn't noticeable.

After all rendering problems have been fixed, you run into precision problems with object positions. Using floats, you can't model positions accurately once you get out past 1,000 km from the origin. The most obvious symptom appears when you try to move the camera (or any other object) when it's far away from the origin. When a position contains really large numbers, a relatively small velocity will be completely dropped as a rounding error. Sometimes it will be dropped in 1 axis, sometimes in 2, and sometimes in all 3. When the velocity gets high enough along a specific axis, the position will start to "jump" in noticeably discrete amounts along that axis. The end result is that both the direction and magnitude of your velocity vector end up being ignored to a certain extent.

Is it time to resort to doubles yet? Yes. I don't think there's any way around it with object position. There's no number magic you can work that will give you extra digits of precision without cost. TANSTAAFL. Luckily, you only need doubles for object positions. Everything else can still be represented with floats, and almost every math operation you perform will still be a single-precision operation. The only time you need double-precision operations is when you're updating an object's position or comparing the positions of two objects. And with 15 digits of precision, you get better precision way out at 1000 times Pluto's orbit than you get with floats dealing with one planet at the origin.

Problems of Scale: An Entire Galaxy

This is a tough one. A double may get you safely out to 1000 times Pluto's orbit, which is just under 2/3 of a light-year, but you really can't take it much farther. Since we don't currently have any built-in data types larger than a double, you have to resort to something custom. I've seen a number of implementations that will work here, but something fast is needed. I've seen custom 128-bit fixed-point numbers created using 2 __int64 values. I've seen 4-bit BCD (Binary Coded Decimal) routines used to achieve unlimited precision. I'm sure if you looked you could even find 128-bit floating-point emulation routines out there.

A common problem with all the schemes I've mentioned so far is performance. Generally speaking, software is much slower than hardware. This means that if you're not using a native data type, all of these custom routines will run much more slowly than double-precision operations. I prefer to solve this problem by using different frames of reference at different scales. The top level would be the galaxy level, with the galaxy centered at the origin and with 1 unit being equal to 1 light-year. The next level would be the star system level, with the star centered at the origin and 1 unit being equal to 1 kilometer.

Because the distance between stars is so vast, you really don't need to mix the two frames of reference. If you consider the fact that anything traveling between stars at sub-light speeds would never get there during the player's lifetime, then you can choose your frame of reference based on whether an object is traveling above or below the speed of light. When an object jumps to FTL (Faster Than Light) travel, you can immediately switch to the galaxy frame of reference. When an object drops back to sub-light speed, you can immediately switch to the star system frame of reference. If a star is nearby, you can choose that to be the new origin. Otherwise, you can make the object's initial position the origin. It is also possible to keep the player from stopping between star systems by forcing them to select a destination star system, then leading the camera there any way you want.