Skip to content

Commit

Permalink
Compression.ScaleToLong/Float to prepare for next NetworkTransform fl…
Browse files Browse the repository at this point in the history
…oat<->long compression
  • Loading branch information
vis2k committed Oct 17, 2022
1 parent 9d487b3 commit d9aa7b1
Show file tree
Hide file tree
Showing 2 changed files with 238 additions and 23 deletions.
136 changes: 113 additions & 23 deletions Assets/Mirror/Core/Compression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,119 @@ namespace Mirror
/// <summary>Functions to Compress Quaternions and Floats</summary>
public static class Compression
{
// divide by precision (functions backported from Mirror II)
// for example, 0.1 cm precision converts '5.0f' float to '50' long.
//
// 'long' instead of 'int' to allow for large enough worlds.
// value / precision exceeds int.max range too easily.
// Convert.ToInt32/64 would throw.
// https://github.com/vis2k/DOTSNET/issues/59
//
// 'long' and 'int' will result in the same bandwidth though.
// for example, ScaleToLong(10.5, 0.1) = 105.
// int: 0x00000069
// long: 0x0000000000000069
// delta compression will reduce both to 1 byte.
//
// returns
// 'true' if scaling was possible within 'long' bounds.
// 'false' if clamping was necessary.
// never throws. checking result is optional.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool ScaleToLong(float value, float precision, out long result)
{
// user might try to pass precision = 0 to disable rounding.
// this is not supported.
// throw to make the user fix this immediately.
// otherwise we would have to reinterpret-cast if ==0 etc.
// this function should be kept simple.
// if rounding isn't wanted, this function shouldn't be called.
if (precision == 0) throw new DivideByZeroException($"ScaleToLong: precision=0 would cause null division. If rounding isn't wanted, don't call this function.");

// catch OverflowException if value/precision > long.max.
// attackers should never be able to throw exceptions.
try
{
result = Convert.ToInt64(value / precision);
return true;
}
// clamp to .max/.min.
// returning '0' would make far away entities reset to origin.
// returning 'max' would keep them stuck at the end of the world.
// the latter is much easier to debug.
catch (OverflowException)
{
result = value > 0 ? long.MaxValue : long.MinValue;
return false;
}
}

// returns
// 'true' if scaling was possible within 'long' bounds.
// 'false' if clamping was necessary.
// never throws. checking result is optional.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool ScaleToLong(Vector3 value, float precision, out long x, out long y, out long z)
{
// attempt to convert every component.
// do not return early if one conversion returned 'false'.
// the return value is optional. always attempt to convert all.
bool result = true;
result &= ScaleToLong(value.x, precision, out x);
result &= ScaleToLong(value.y, precision, out y);
result &= ScaleToLong(value.z, precision, out z);
return result;
}

// multiple by precision.
// for example, 0.1 cm precision converts '50' long to '5.0f' float.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float ScaleToFloat(long value, float precision)
{
// user might try to pass precision = 0 to disable rounding.
// this is not supported.
// throw to make the user fix this immediately.
// otherwise we would have to reinterpret-cast if ==0 etc.
// this function should be kept simple.
// if rounding isn't wanted, this function shouldn't be called.
if (precision == 0) throw new DivideByZeroException($"ScaleToLong: precision=0 would cause null division. If rounding isn't wanted, don't call this function.");

return value * precision;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector3 ScaleToFloat(long x, long y, long z, float precision)
{
Vector3 v;
v.x = ScaleToFloat(x, precision);
v.y = ScaleToFloat(y, precision);
v.z = ScaleToFloat(z, precision);
return v;
}

// scale a float within min/max range to an ushort between min/max range
// note: can also use this for byte range from byte.MinValue to byte.MaxValue
public static ushort ScaleFloatToUShort(float value, float minValue, float maxValue, ushort minTarget, ushort maxTarget)
{
// note: C# ushort - ushort => int, hence so many casts
// max ushort - min ushort only fits into something bigger
int targetRange = maxTarget - minTarget;
float valueRange = maxValue - minValue;
float valueRelative = value - minValue;
return (ushort)(minTarget + (ushort)(valueRelative / valueRange * targetRange));
}

// scale an ushort within min/max range to a float between min/max range
// note: can also use this for byte range from byte.MinValue to byte.MaxValue
public static float ScaleUShortToFloat(ushort value, ushort minValue, ushort maxValue, float minTarget, float maxTarget)
{
// note: C# ushort - ushort => int, hence so many casts
float targetRange = maxTarget - minTarget;
ushort valueRange = (ushort)(maxValue - minValue);
ushort valueRelative = (ushort)(value - minValue);
return minTarget + (valueRelative / (float)valueRange * targetRange);
}

// quaternion compression //////////////////////////////////////////////
// smallest three: https://gafferongames.com/post/snapshot_compression/
// compresses 16 bytes quaternion into 4 bytes
Expand Down Expand Up @@ -50,29 +163,6 @@ public static int LargestAbsoluteComponentIndex(Vector4 value, out float largest
return largestIndex;
}

// scale a float within min/max range to an ushort between min/max range
// note: can also use this for byte range from byte.MinValue to byte.MaxValue
public static ushort ScaleFloatToUShort(float value, float minValue, float maxValue, ushort minTarget, ushort maxTarget)
{
// note: C# ushort - ushort => int, hence so many casts
// max ushort - min ushort only fits into something bigger
int targetRange = maxTarget - minTarget;
float valueRange = maxValue - minValue;
float valueRelative = value - minValue;
return (ushort)(minTarget + (ushort)(valueRelative / valueRange * targetRange));
}

// scale an ushort within min/max range to a float between min/max range
// note: can also use this for byte range from byte.MinValue to byte.MaxValue
public static float ScaleUShortToFloat(ushort value, ushort minValue, ushort maxValue, float minTarget, float maxTarget)
{
// note: C# ushort - ushort => int, hence so many casts
float targetRange = maxTarget - minTarget;
ushort valueRange = (ushort)(maxValue - minValue);
ushort valueRelative = (ushort)(value - minValue);
return minTarget + (valueRelative / (float)valueRange * targetRange);
}

const float QuaternionMinRange = -0.707107f;
const float QuaternionMaxRange = 0.707107f;
const ushort TenBitsMax = 0x3FF;
Expand Down
125 changes: 125 additions & 0 deletions Assets/Mirror/Tests/Editor/CompressionTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,135 @@
using System;
using NUnit.Framework;
using UnityEngine;

namespace Mirror.Tests
{
public class CompressionTests
{
[Test]
public void ScaleToLong_Scalar()
{
// origin
Assert.True(Compression.ScaleToLong(0, 0.1f, out long value));
Assert.That(value, Is.EqualTo(0));

// 10m far
Assert.True(Compression.ScaleToLong(10.5f, 0.1f, out value));
Assert.That(value, Is.EqualTo(105));

// 100m far
Assert.True(Compression.ScaleToLong(100.5f, 0.1f, out value));
Assert.That(value, Is.EqualTo(1005));

// 10km
Assert.True(Compression.ScaleToLong(10_000.5f, 0.1f, out value));
Assert.That(value, Is.EqualTo(100005));

// 1000 km
Assert.True(Compression.ScaleToLong(1_000_000.5f, 0.1f, out value));
Assert.That(value, Is.EqualTo(10000005));

// negative
Assert.True(Compression.ScaleToLong(-1_000_000.5f, 0.1f, out value));
Assert.That(value, Is.EqualTo(-10000005));
}

// users may try to 'disable' the scaling by setting precision = 0.
// this would cause null division. need to detect and throw so the user
// knows it needs immediate fixing.
[Test]
public void ScaleToLong_Precision_0()
{
Assert.Throws<DivideByZeroException>(() =>
{
Compression.ScaleToLong(10.5f, 0, out _);
});
}

[Test]
public void ScaleToLong_Scalar_OutOfRange()
{
float precision = 0.1f;
float largest = long.MaxValue / 0.1f;
float smallest = long.MinValue / 0.1f;

// larger than long.max should clamp to max and return false
Assert.False(Compression.ScaleToLong(largest + 1, precision, out long value));
Assert.That(value, Is.EqualTo(long.MaxValue));

// smaller than long.min should clamp to min and return false
Assert.False(Compression.ScaleToLong(smallest - 1, precision, out value));
Assert.That(value, Is.EqualTo(long.MinValue));
}

[Test]
public void ScaleToLong_Vector3()
{
// 0, positive, negative
Assert.True(Compression.ScaleToLong(new Vector3(0, 10.5f, -100.5f), 0.1f, out long x, out long y, out long z));
Assert.That(x, Is.EqualTo(0));
Assert.That(y, Is.EqualTo(105));
Assert.That(z, Is.EqualTo(-1005));
}

[Test]
public void ScaleToLong_Vector3_OutOfRange()
{
float precision = 0.1f;
float largest = long.MaxValue / 0.1f;
float smallest = long.MinValue / 0.1f;

// 0, largest, smallest
Assert.False(Compression.ScaleToLong(new Vector3(0, largest, smallest), precision, out long x, out long y, out long z));
Assert.That(x, Is.EqualTo(0));
Assert.That(y, Is.EqualTo(long.MaxValue));
Assert.That(z, Is.EqualTo(long.MinValue));
}

[Test]
public void ScaleToFloat()
{
// origin
Assert.That(Compression.ScaleToFloat(0, 0.1f), Is.EqualTo(0));

// 10m far
Assert.That(Compression.ScaleToFloat(105, 0.1f), Is.EqualTo(10.5f));

// 100m far
Assert.That(Compression.ScaleToFloat(1005, 0.1f), Is.EqualTo(100.5f));

// 10km
Assert.That(Compression.ScaleToFloat(100005, 0.1f), Is.EqualTo(10_000.5f));

// 1000 km
Assert.That(Compression.ScaleToFloat(10000005, 0.1f), Is.EqualTo(1_000_000.5f));

// negative
Assert.That(Compression.ScaleToFloat(-10000005, 0.1f), Is.EqualTo(-1_000_000.5f));
}

// users may try to 'disable' the scaling by setting precision = 0.
// this would cause null division. need to detect and throw so the user
// knows it needs immediate fixing.
[Test]
public void ScaleToFloat_Precision_0()
{
Assert.Throws<DivideByZeroException>(() =>
{
Compression.ScaleToFloat(105, 0);
});
}

[Test]
public void ScaleToFloat_Vector3()
{
// 0, positive, negative
Vector3 v = Compression.ScaleToFloat(0, 105, -1005, 0.1f);
Assert.That(v.x, Is.EqualTo(0));
Assert.That(v.y, Is.EqualTo(10.5f));
Assert.That(v.z, Is.EqualTo(-100.5f));
}

[Test]
public void LargestAbsoluteComponentIndex()
{
Expand Down

0 comments on commit d9aa7b1

Please sign in to comment.