feat: Quaternion and float Compression (#2368)

Adding compression methods for Quaternion and floats. These methods can be used to decrease size of Quaternions before sending the value over the network.

ScaleToUInt method can be used to compress float from 32 bits to the range given to the method. This can be used to compress Vector3 if the bounds of the world are known and fixed before runtime.
This commit is contained in:
James Frowen 2020-11-01 02:33:24 +00:00 committed by GitHub
parent 9182b32946
commit bbb61848be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 544 additions and 0 deletions

View File

@ -0,0 +1,225 @@
using System;
using UnityEngine;
namespace Mirror
{
/// <summary>
/// Functions to Compress Quaternions and Floats
/// </summary>
/// <remarks>
/// Uncompressed Quaternion = 32 * 4 = 128 bits => send 16 bytes
///
/// <para>
/// Quaternion is always normalized so we drop largest value and re-calculate it.
/// We can encode which one is the largest using 2 bits
/// <code>
/// x^2 + y^2 + z^2 + w^2 = 1
/// </code>
/// </para>
///
/// <para>
/// 2nd largest value has max size of 1/sqrt(2)
/// We can encode the smallest three components in [-1/sqrt(2),+1/sqrt(2)] instead of [-1,+1]
/// <code>
/// c^2 + c^2 + 0 + 0 = 1
/// </code>
/// </para>
///
/// <para>
/// Sign of largest value doesnt matter
/// <code>
/// Q * vec3 == (-Q) * vec3
/// </code>
/// </para>
///
/// <list type="bullet">
/// <listheader><description>
/// RotationPrecision <br/>
/// <code>
/// 2/sqrt(2) / (2^bitCount - 1)
/// </code>
/// </description></listheader>
///
/// <item><description>
/// rotation precision +-0.00138 in range [-1,+1]
/// <code>
/// 10 bits per value
/// 2 + 10 * 3 = 32 bits => send 4 bytes
/// </code>
/// </description></item>
/// </list>
///
/// <para>
/// Links for more info:
/// <br/><see href="https://youtu.be/Z9X4lysFr64">GDC Talk</see>
/// <br/><see href="https://gafferongames.com/post/snapshot_compression/">Post on Snapshot Compression</see>
/// </para>
/// </remarks>
public static class Compression
{
const float QuaternionMinValue = -1f / 1.414214f; // 1/ sqrt(2)
const float QuaternionMaxValue = 1f / 1.414214f;
const int QuaternionBitLength = 10;
// same as Mathf.Pow(2, targetBitLength) - 1
const uint QuaternionUintRange = (1 << QuaternionBitLength) - 1;
/// <summary>
/// Used to Compress Quaternion into 4 bytes
/// </summary>
public static uint CompressQuaternion(Quaternion value)
{
value = value.normalized;
int largestIndex = FindLargestIndex(value);
Vector3 small = GetSmallerDimensions(largestIndex, value);
// largest needs to be positive to be calculated by reader
// if largest is negative flip sign of others because Q = -Q
if (value[largestIndex] < 0)
{
small *= -1;
}
uint a = ScaleToUInt(small.x, QuaternionMinValue, QuaternionMaxValue, 0, QuaternionUintRange);
uint b = ScaleToUInt(small.y, QuaternionMinValue, QuaternionMaxValue, 0, QuaternionUintRange);
uint c = ScaleToUInt(small.z, QuaternionMinValue, QuaternionMaxValue, 0, QuaternionUintRange);
// pack each 10 bits and extra 2 bits into uint32
uint packed = a | b << 10 | c << 20 | (uint)largestIndex << 30;
return packed;
}
internal static int FindLargestIndex(Quaternion q)
{
int index = default;
float current = default;
// check each value to see which one is largest (ignoring +-)
for (int i = 0; i < 4; i++)
{
float next = Mathf.Abs(q[i]);
if (next > current)
{
index = i;
current = next;
}
}
return index;
}
static Vector3 GetSmallerDimensions(int largestIndex, Quaternion value)
{
float x = value.x;
float y = value.y;
float z = value.z;
float w = value.w;
switch (largestIndex)
{
case 0:
return new Vector3(y, z, w);
case 1:
return new Vector3(x, z, w);
case 2:
return new Vector3(x, y, w);
case 3:
return new Vector3(x, y, z);
default:
throw new IndexOutOfRangeException("Invalid Quaternion index!");
}
}
/// <summary>
/// Used to read a Compressed Quaternion from 4 bytes
/// <para>Quaternion is normalized</para>
/// </summary>
public static Quaternion DecompressQuaternion(uint packed)
{
// 10 bits
const uint mask = 0b11_1111_1111;
Quaternion result;
uint a = packed & mask;
uint b = (packed >> 10) & mask;
uint c = (packed >> 20) & mask;
uint largestIndex = (packed >> 30) & mask;
float x = ScaleFromUInt(a, QuaternionMinValue, QuaternionMaxValue, 0, QuaternionUintRange);
float y = ScaleFromUInt(b, QuaternionMinValue, QuaternionMaxValue, 0, QuaternionUintRange);
float z = ScaleFromUInt(c, QuaternionMinValue, QuaternionMaxValue, 0, QuaternionUintRange);
Vector3 small = new Vector3(x, y, z);
result = FromSmallerDimensions(largestIndex, small);
return result;
}
static Quaternion FromSmallerDimensions(uint largestIndex, Vector3 smallest)
{
float a = smallest.x;
float b = smallest.y;
float c = smallest.z;
float largest = Mathf.Sqrt(1 - a * a - b * b - c * c);
switch (largestIndex)
{
case 0:
return new Quaternion(largest, a, b, c).normalized;
case 1:
return new Quaternion(a, largest, b, c).normalized;
case 2:
return new Quaternion(a, b, largest, c).normalized;
case 3:
return new Quaternion(a, b, c, largest).normalized;
default:
throw new IndexOutOfRangeException("Invalid Quaternion index!");
}
}
/// <summary>
/// Scales float from minFloat->maxFloat to minUint->maxUint
/// <para>values out side of minFloat/maxFloat will return either 0 or maxUint</para>
/// </summary>
public static uint ScaleToUInt(float value, float minFloat, float maxFloat, uint minUint, uint maxUint)
{
// if out of range return min/max
if (value > maxFloat) { return maxUint; }
if (value < minFloat) { return minUint; }
float rangeFloat = maxFloat - minFloat;
uint rangeUint = maxUint - minUint;
// scale value to 0->1 (as float)
float valueRelative = (value - minFloat) / rangeFloat;
// scale value to uMin->uMax
float outValue = valueRelative * rangeUint + minUint;
return (uint)outValue;
}
/// <summary>
/// Scales uint from minUint->maxUint to minFloat->maxFloat
/// </summary>
public static float ScaleFromUInt(uint value, float minFloat, float maxFloat, uint minUint, uint maxUint)
{
// if out of range return min/max
if (value > maxUint) { return maxFloat; }
if (value < minUint) { return minFloat; }
float rangeFloat = maxFloat - minFloat;
uint rangeUint = maxUint - minUint;
// scale value to 0->1 (as float)
// make sure divide is float
float valueRelative = (value - minUint) / (float)rangeUint;
// scale value to fMin->fMax
float outValue = valueRelative * rangeFloat + minFloat;
return outValue;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5c28963f9c4b97e418252a55500fb91e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,141 @@
using System.Collections;
using NUnit.Framework;
using UnityEngine;
namespace Mirror.Tests
{
public class CompressionFloatTest
{
[Test]
[TestCaseSource(nameof(FloatTestCases))]
public void CanScaleToUintAndBack(float value, float minFloat, float maxFloat, uint minUint, uint maxUint, float allowedPrecision)
{
uint packed = Compression.ScaleToUInt(value, minFloat, maxFloat, minUint, maxUint);
float unpacked = Compression.ScaleFromUInt(packed, minFloat, maxFloat, minUint, maxUint);
Assert.That(unpacked, Is.Not.NaN);
Assert.That(unpacked, Is.EqualTo(value).Within(allowedPrecision), $"Value not in Allowed Precision\n value : {value}\n unpacked: {unpacked}");
}
static IEnumerable FloatTestCases
{
get
{
// test values in various ranges with different min/max
foreach (object value in range(-2.2f, 2.5f, 0.1f, 0, byte.MaxValue))
{
yield return value;
}
foreach (object value in range(-2.2f, 2.5f, 0.1f, byte.MaxValue, ushort.MaxValue))
{
yield return value;
}
foreach (object value in range(-200f, 200f, Mathf.PI, 0, (1 << 10) - 1))
{
yield return value;
}
foreach (object value in range(-0.7f, 0.7f, 0.03f, 0, (1 << 19) - 1))
{
yield return value;
}
foreach (object value in range(-0.7f, 0.7f, 0.03f, (1 << 10) - 1, (1 << 20) - 1))
{
yield return value;
}
foreach (object value in range(10f, 200f, 2f, 0, (1 << 19) - 1))
{
yield return value;
}
IEnumerable range(float min, float max, float step, uint uMin, uint uMax)
{
float precision = (max - min) / (uMax - uMin);
for (float f = min; f <= max; f += step)
{
yield return new TestCaseData(f, min, max, uMin, uMax, precision);
}
}
}
}
[Test]
[TestCaseSource(nameof(InvalidRangeTestCases))]
public void ShouldNotThrowWhenGivenInvalidValues(float value, float minFloat, float maxFloat, uint minUint, uint maxUint)
{
uint packed = Compression.ScaleToUInt(value, minFloat, maxFloat, minUint, maxUint);
float unpacked = Compression.ScaleFromUInt(packed, minFloat, maxFloat, minUint, maxUint);
// should not throw
}
static IEnumerable InvalidRangeTestCases
{
get
{
yield return new TestCaseData(0, 0, 0, 0u, 0u);
yield return new TestCaseData(0, 0, 0, 1u, 0u);
yield return new TestCaseData(0, 0, 0, 1u, 1u);
yield return new TestCaseData(0, 1, -1, 0u, byte.MaxValue);
yield return new TestCaseData(0, 1.5f, 1.5f, 0u, byte.MaxValue);
}
}
[Test]
[TestCaseSource(nameof(OutOfRangeFloatTestCases))]
public uint ValuesOutOfRangeArePackedInRange(float value, float minFloat, float maxFloat, uint minUint, uint maxUint)
{
uint packed = Compression.ScaleToUInt(value, minFloat, maxFloat, minUint, maxUint);
Assert.That(packed, Is.Not.NaN);
return packed;
}
static IEnumerable OutOfRangeFloatTestCases
{
get
{
float min = -1;
float max = 1;
uint uMin = 0;
uint uMax = 10;
for (float f = -2; f <= min; f += 0.1f)
{
yield return new TestCaseData(f, min, max, uMin, uMax).Returns(0);
}
for (float f = max; f <= 2; f += 0.1f)
{
yield return new TestCaseData(f, min, max, uMin, uMax).Returns(10);
}
}
}
[Test]
[TestCaseSource(nameof(OutOfRangeUintTestCases))]
public float ValuesOutOfRangeAreUnPackedInRange(uint value, float minFloat, float maxFloat, uint minUint, uint maxUint)
{
float packed = Compression.ScaleFromUInt(value, minFloat, maxFloat, minUint, maxUint);
Assert.That(packed, Is.Not.NaN);
return packed;
}
static IEnumerable OutOfRangeUintTestCases
{
get
{
float min = -1;
float max = 1;
uint uMin = 5;
uint uMax = 25;
for (uint u = 0; u <= uMin; u++)
{
yield return new TestCaseData(u, min, max, uMin, uMax).Returns(-1f);
}
for (uint u = uMax; u <= (uMax + 10u); u++)
{
yield return new TestCaseData(u, min, max, uMin, uMax).Returns(1f);
}
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: af232bfd6f14b824ba8d1782b5ee181a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,145 @@
using System.Collections;
using NUnit.Framework;
using UnityEngine;
namespace Mirror.Tests
{
public class CompressionQuaternionTest
{
// worse case where xyzw all equal error in largest is ~1.732 times greater than error in smallest 3
// High/Low Precision fails when xyzw all equal,
internal const float AllowedPrecision = 0.00138f;
[Test]
[TestCaseSource(nameof(QuaternionTestCases))]
public void QuaternionCompressIsWithinPrecision(Quaternion inRot)
{
uint packed = Compression.CompressQuaternion(inRot);
Quaternion outRot = Compression.DecompressQuaternion(packed);
Assert.That(outRot.x, Is.Not.NaN, "x was NaN");
Assert.That(outRot.y, Is.Not.NaN, "y was NaN");
Assert.That(outRot.z, Is.Not.NaN, "z was NaN");
Assert.That(outRot.w, Is.Not.NaN, "w was NaN");
int largest = Compression.FindLargestIndex(inRot);
float sign = Mathf.Sign(inRot[largest]);
// flip sign of A if largest is is negative
// Q == (-Q)
Assert.AreEqual(sign * inRot.x, outRot.x, AllowedPrecision, $"x off by {Mathf.Abs(sign * inRot.x - outRot.x)}");
Assert.AreEqual(sign * inRot.y, outRot.y, AllowedPrecision, $"y off by {Mathf.Abs(sign * inRot.y - outRot.y)}");
Assert.AreEqual(sign * inRot.z, outRot.z, AllowedPrecision, $"z off by {Mathf.Abs(sign * inRot.z - outRot.z)}");
Assert.AreEqual(sign * inRot.w, outRot.w, AllowedPrecision, $"w off by {Mathf.Abs(sign * inRot.w - outRot.w)}");
}
static IEnumerable QuaternionTestCases
{
get
{
yield return new TestCaseData(Quaternion.identity);
yield return new TestCaseData(new Quaternion(1, 0, 0, 0));
yield return new TestCaseData(new Quaternion(0, 1, 0, 0));
yield return new TestCaseData(new Quaternion(0, 0, 1, 0));
yield return new TestCaseData(new Quaternion(1, 1, 0, 0).normalized);
yield return new TestCaseData(new Quaternion(0, 1, 1, 0).normalized);
yield return new TestCaseData(new Quaternion(0, 1, 1, 0).normalized);
yield return new TestCaseData(new Quaternion(0, 0, 1, 1).normalized);
yield return new TestCaseData(new Quaternion(1, 1, 1, 0).normalized);
yield return new TestCaseData(new Quaternion(1, 1, 0, 1).normalized);
yield return new TestCaseData(new Quaternion(1, 0, 1, 1).normalized);
yield return new TestCaseData(new Quaternion(0, 1, 1, 1).normalized);
yield return new TestCaseData(new Quaternion(1, 1, 1, 1).normalized);
yield return new TestCaseData(new Quaternion(-1, 0, 0, 0));
yield return new TestCaseData(new Quaternion(0, -1, 0, 0));
yield return new TestCaseData(new Quaternion(0, 0, -1, 0));
yield return new TestCaseData(new Quaternion(0, 0, 0, -1));
yield return new TestCaseData(new Quaternion(-1, -1, 0, 0).normalized);
yield return new TestCaseData(new Quaternion(0, -1, -1, 0).normalized);
yield return new TestCaseData(new Quaternion(0, -1, -1, 0).normalized);
yield return new TestCaseData(new Quaternion(0, 0, -1, -1).normalized);
yield return new TestCaseData(new Quaternion(-1, -1, -1, 0).normalized);
yield return new TestCaseData(new Quaternion(-1, -1, 0, -1).normalized);
yield return new TestCaseData(new Quaternion(-1, 0, -1, -1).normalized);
yield return new TestCaseData(new Quaternion(0, -1, -1, -1).normalized);
yield return new TestCaseData(new Quaternion(-1, -1, -1, -1).normalized);
yield return new TestCaseData(Quaternion.Euler(200, 100, 10));
yield return new TestCaseData(Quaternion.LookRotation(new Vector3(0.3f, 0.4f, 0.5f)));
yield return new TestCaseData(Quaternion.Euler(45f, 56f, Mathf.PI));
yield return new TestCaseData(Quaternion.AngleAxis(30, new Vector3(1, 2, 5)));
yield return new TestCaseData(Quaternion.AngleAxis(5, new Vector3(-1, .01f, 0.44f)));
yield return new TestCaseData(Quaternion.AngleAxis(358, new Vector3(0.5f, 2, 5)));
yield return new TestCaseData(Quaternion.AngleAxis(-54, new Vector3(1, 2, 5)));
}
}
[Test]
[TestCaseSource(nameof(QuaternionTestCases))]
public void RotationIsWithinPrecision(Quaternion rotationIn)
{
uint packed = Compression.CompressQuaternion(rotationIn);
Quaternion rotationOut = Compression.DecompressQuaternion(packed);
Assert.That(rotationOut.x, Is.Not.NaN, "x was NaN");
Assert.That(rotationOut.y, Is.Not.NaN, "y was NaN");
Assert.That(rotationOut.z, Is.Not.NaN, "z was NaN");
Assert.That(rotationOut.w, Is.Not.NaN, "w was NaN");
Vector3 inVec = rotationIn * Vector3.forward;
Vector3 outVec = rotationOut * Vector3.forward;
// allow for extra precision when rotating vector
float precision = AllowedPrecision * 2;
Assert.AreEqual(inVec.x, outVec.x, precision, $"x off by {Mathf.Abs(inVec.x - outVec.x)}");
Assert.AreEqual(inVec.y, outVec.y, precision, $"y off by {Mathf.Abs(inVec.y - outVec.y)}");
Assert.AreEqual(inVec.z, outVec.z, precision, $"z off by {Mathf.Abs(inVec.z - outVec.z)}");
}
[Test]
[TestCaseSource(nameof(LargestIndexTestCases))]
public void FindLargestIndexWork(Quaternion quaternion, int expected)
{
int largest = Compression.FindLargestIndex(quaternion);
Assert.That(largest, Is.EqualTo(expected));
}
static IEnumerable LargestIndexTestCases
{
get
{
// args = Quaternion quaternion, int expected
yield return new TestCaseData(new Quaternion(1, 0, 0, 0), 0);
yield return new TestCaseData(new Quaternion(0, 1, 0, 0), 1);
yield return new TestCaseData(new Quaternion(0, 0, 1, 0), 2);
yield return new TestCaseData(new Quaternion(0, 0, 0, 1), 3);
yield return new TestCaseData(new Quaternion(-1, 0, 0, 0), 0);
yield return new TestCaseData(new Quaternion(0, -1, 0, 0), 1);
yield return new TestCaseData(new Quaternion(0, 0, -1, 0), 2);
yield return new TestCaseData(new Quaternion(0, 0, 0, -1), 3);
yield return new TestCaseData(new Quaternion(1, 0, 0.5f, 0).normalized, 0);
yield return new TestCaseData(new Quaternion(0, 1, 0.5f, 0).normalized, 1);
yield return new TestCaseData(new Quaternion(0, 0.5f, 1, 0).normalized, 2);
yield return new TestCaseData(new Quaternion(0, 0.5f, 0, 1).normalized, 3);
yield return new TestCaseData(new Quaternion(-1, 0.9f, 0.5f, 0).normalized, 0);
yield return new TestCaseData(new Quaternion(0.9f, -1, 0.5f, 0).normalized, 1);
yield return new TestCaseData(new Quaternion(0, 0.5f, -1, 0.9f).normalized, 2);
yield return new TestCaseData(new Quaternion(0, 0.5f, 0.9f, -1).normalized, 3);
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a9df82db27e550f49888f01b737e9cc0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: