Float precision and Time in Unity
Recently, I was tasked with addressing an interesting issue that occured in a Unity game. After the game run for about 2 days, it virtually stopped streaming in map assets during world locomotion. Assets were streamed in with a huge delay (up to x-teen seconds) and it simply wouldn’t do. After finding out what caused this strange bug, I thought it would make an interesting article to share with you. Both the problem, and the process of finding its origin were quite an interesting experience.Debugging
Since the problem only occurred after at least 2 days of ‘soaking’, I knew time is going to play a role here. I started investigating the issue by looking at the custom streaming code used in the game. It consisted of a bunch of asset loading and unloading functions, called every tick. At the start of each tick, before calling the functions, the code would cache the current Time.realtimeSinceStartup, which is a timer managed by the Unity engine that tracks for how long the application has run. There were also several constant floats defined in the same streaming manager. Those floats described the time allowed for loading and unloading data every tick.Here is a simplified example of the code:
class AssetStreamingManager
{
const float MAX_TIME_FOR_ASSET_LOADING = 0,005;
List<Asset> AssetsToUnload;
List<Asset> AssetsToLoad;
private void Tick(float deltaTime)
{
float operationStopTime = Time.realtimeSinceStartup + MAX_TIME_FOR_ASSET_LOADING;
LoadAndUnloadAssets(operationStopTime);
}
private void LoadAndUnloadAssets(float stopTime)
{
for (var asset in AssetsToLoad)
{
UnloadAsset(asset);
AssetsToLoad.Remove(asset);
if ( Time.realtimeSinceStartup >= stopTime)
break;
}
for (var asset in AssetsToUnload)
{
UnloadAsset(asset);
AssetsToUnload.Remove(asset);
if ( Time.realtimeSinceStartup >= stopTime)
break;
}
}
private void LoadAsset(Asset asset)
{
// loding logic
}
private void UnloadAsset(Asset asset)
{
// unloding logic
}
}
Finding the cause
From this point on, the debugging procedure was as straightforward as it can be. After placing breakpoints inside loading/unloading functions, I discovered that the functions returned immediately without processing any load or unload request, apparently because of the time allotted for the operations have already run out. On the loading/unloading function’s start, the value of Time.realtimeSinceStartup was equal to the time at the beginning of the tick + the time allotted for loading/unloading. This was clearly wrong.Don’t store that in a float
It’s important to note, that the constant float values representing the time allotted for loading and unloading assets was a value of 0,00X. A very small number that actually represented how many milliseconds are allowed for these operations. I had a hunch that adding these two floats (200.000 + 0,00X) might be asking too much considering how different the numbers are. And it all comes down to float precision. Since you don’t always deal with float precision issues, I had to look up some resources to see if I’m not completely bananas. I managed to find this gem of a blog post by Bruce Dawson. I also found this great IEEE calculator online. After switching to the 32 bit float and inserting my data, it became clear that adding the small number of 0,005 to the one that’s over 200.000 results in the small number being completely lost due to insufficient float precision. We have found the cause of the issue. Now it was time to fix it.Fixing it
First off, I have to mention that this was a legacy project that I only worked on as a part of a team that was porting it to another platform. We didn’t have a lot of time on our hands and any rewrites that would cause huge refactors were out of the question. The problems associated with working on legacy projects are quite numerous and an interesting topic to explore on its own (possible blog post). In this particular case, it was clear the original developers did not want to deal with more complicated solutions for tracking execution time or parallelize the functionality. I had to come up with a solution that fit this paradigm and was as safe as possible to introduce. The first thing I wanted to try is to store all time values in a double in a hope of increasing the precision enough. This had the downside of not really fixing the issue, but making it take longer to surface. It also didn’t work. I’ve read some articles suggesting I should add a certain number to a double to make it stick from the getgo to a precision range that would be consistent for months. This seemed quite dirty and I didn’t really want to deal with it. It was clear that in order to time the loading/unloading operations, I didn’t really need to know for how long the game has run. The only thing that mattered here was to be able to break a loop on a timer. Time.realtimeSinceStartup was probably only used because it was updated by the Unity engine, so it was convenient to access and use. Since the game was originally released on PC, they either didn’t expect anybody to have the game running for 2 days or more, or didn’t even consider the issue of float precision. I decided to move the timing to using a long (high precision integer) that would store milliseconds. This played ideally with the timer object I intended to use: System.Diagnostics.Stopwatch. Once a Stopwatch is started, checking how many milliseconds have passed is as easy as getting the value of its ElapsedMilliseconds property. With this in place, I moved the const float values that represented how long a load/unload operation can take to longs as well (could have been int, but I wanted to kill this issue with fire). With these changes in place, all that was left to do was to fire off a Stopwatch on every tick and pass it to the loading/unloading function.Here’s how the code looked like after the fix:
class AssetStreamingManager
{
const long MAX_MILISECONDS_FOR_ASSET_LOADING = 5;
List<Asset> AssetsToUnload;
List<Asset> AssetsToLoad;
private void Tick(float deltaTime)
{
System.Diagnostics.Stopwatch loadStopwatch = System.Diagnostics.Stopwatch.StartNew();
LoadAndUnloadAssets(loadStopwatch);
}
private void LoadAndUnloadAssets(System.Diagnostics.Stopwatch stopwatch)
{
for (var asset in AssetsToLoad)
{
UnloadAsset(asset);
AssetsToLoad.Remove(asset);
if (stopwatch.ElapsedMiliseconds >= MAX_MILISECONDS_FOR_ASSET_LOADING)
break;
}
for (var asset in AssetsToUnload)
{
UnloadAsset(asset);
AssetsToUnload.Remove(asset);
if (stopwatch.ElapsedMiliseconds >= MAX_MILISECONDS_FOR_ASSET_LOADING)
break;
}
}
private void LoadAsset(Asset asset)
{
// loding logic
}
private void UnloadAsset(Asset asset)
{
// unloding logic
}
}
Comments
Post a Comment