From 53652c7043d3026b6c521445d617f52a2b872631 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 29 Oct 2026 10:41:04 -0500 Subject: [PATCH] feat: add the job-safe transform math and the transform handle allocator First of five PRs that replace #4123. Adds Burst-compatible math for NetworkTransform (position deltas, half-float vectors, quaternion compression) and the allocator that gives each NetworkTransform a handle into native storage. Nothing calls either yet. --- .../Runtime/Components/HalfVector3.cs | 48 ++- .../Components/NetworkTransformMath.cs | 258 +++++++++++++ .../Components/NetworkTransformMath.cs.meta | 2 + .../Components/QuaternionCompressor.cs | 114 +++++- .../Components/TransformHandleAllocator.cs | 139 +++++++ .../TransformHandleAllocator.cs.meta | 2 + .../NetworkTransformMathTests.cs | 365 ++++++++++++++++++ .../NetworkTransformMathTests.cs.meta | 2 + .../TransformHandleAllocatorTests.cs | 140 +++++++ .../TransformHandleAllocatorTests.cs.meta | 2 + 10 files changed, 1035 insertions(+), 37 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/HalfVector3.cs b/com.unity.netcode.gameobjects/Runtime/Components/HalfVector3.cs index fa7d1c9fb3..3ae97ed5e4 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/HalfVector3.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/HalfVector3.cs @@ -96,16 +96,19 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade [MethodImpl(MethodImplOptions.AggressiveInlining)] public Vector3 ToVector3() { - Vector3 fullPrecision = Vector3.zero; - Vector3 fullConversion = math.float3(Axis); - for (int i = 0; i < Length; i++) - { - if (AxisToSynchronize[i]) - { - fullPrecision[i] = fullConversion[i]; - } - } - return fullPrecision; + return ToFloat3(Axis, AxisToSynchronize); + } + + /// + /// The based implementation of . + /// + /// + /// This is a job safe method to be used in place of . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static float3 ToFloat3(half3 axis, bool3 axisToSynchronize) + { + return math.select(float3.zero, math.float3(axis), axisToSynchronize); } /// @@ -115,14 +118,23 @@ public Vector3 ToVector3() [MethodImpl(MethodImplOptions.AggressiveInlining)] public void UpdateFrom(ref Vector3 vector3) { - var half3Full = math.half3(vector3); - for (int i = 0; i < Length; i++) - { - if (AxisToSynchronize[i]) - { - Axis[i] = half3Full[i]; - } - } + Axis = UpdatedAxis(Axis, math.float3(vector3), AxisToSynchronize); + } + + /// + /// The based implementation of . + /// + /// + /// This is a job safe method to be used in place of . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static half3 UpdatedAxis(half3 axis, float3 value, bool3 axisToSynchronize) + { + var updated = math.half3(value); + axis.x = axisToSynchronize.x ? updated.x : axis.x; + axis.y = axisToSynchronize.y ? updated.y : axis.y; + axis.z = axisToSynchronize.z ? updated.z : axis.z; + return axis; } /// diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs new file mode 100644 index 0000000000..0f9dd748d8 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs @@ -0,0 +1,258 @@ +using System.Runtime.CompilerServices; +using Unity.Mathematics; +using UnityEngine; + +namespace Unity.Netcode.Components +{ + /// + /// Burst compatible replacement methods for non-burst compatible math methods that uses. + /// + /// + /// NetworkTransformMathTests measures each method against the non-burst compatible version that it replaces. + /// + internal static class NetworkTransformMath + { + internal const float Rad2Deg = 360f / (math.PI * 2f); + internal const float Deg2Rad = (math.PI * 2f) / 360f; + + /// + /// . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static float Repeat(float t, float length) + { + return math.clamp(t - math.floor(t / length) * length, 0.0f, length); + } + + /// + /// . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static float DeltaAngle(float current, float target) + { + var delta = Repeat(target - current, 360.0f); + if (delta > 180.0f) + { + delta -= 360.0f; + } + return delta; + } + + /// + /// The burst compatible version of . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static float3 Lerp(float3 start, float3 end, float time) + { + // Written per component in the same form the engine uses so the rounding matches. + time = math.clamp(time, 0.0f, 1.0f); + return new float3( + start.x + (end.x - start.x) * time, + start.y + (end.y - start.y) * time, + start.z + (end.z - start.z) * time); + } + + /// + /// Brings each euler angle into the 0 to 360 range, matching what the engine returns. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float NormalizeAngle(float angle) + { + // Written as a loop free expression so it stays branch predictable and Burst friendly. + var normalized = Repeat(angle, 360.0f); + return normalized; + } + + /// + /// The burst compatible version of . + /// + /// + /// Extracted in ZXY order to match , which applies Z, then X, + /// then Y. For a rotation matrix R = Ry * Rx * Rz that gives sin(x) = -m12, y = atan2(m02, m22) and + /// z = atan2(m10, m11), written below directly in terms of the quaternion components. + /// + internal static float3 EulerAngles(quaternion rotation) + { + var q = rotation.value; + var sqx = q.x * q.x; + var sqy = q.y * q.y; + var sqz = q.z * q.z; + + var m10 = 2.0f * (q.x * q.y + q.w * q.z); + var m11 = 1.0f - 2.0f * (sqx + sqz); + var sinX = 2.0f * (q.x * q.w - q.y * q.z); + + float3 result; + result.x = math.atan2(sinX, math.sqrt(m10 * m10 + m11 * m11)); + result.y = math.atan2(2.0f * (q.x * q.z + q.w * q.y), 1.0f - 2.0f * (sqx + sqy)); + result.z = math.atan2(m10, m11); + + result *= Rad2Deg; + result.x = NormalizeAngle(result.x); + result.y = NormalizeAngle(result.y); + result.z = NormalizeAngle(result.z); + return result; + } + + /// + /// The burst compatible version of with the + /// exception that it takes a for all axis. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static quaternion Euler(float3 eulerDegrees) + { + // The engine applies the rotations in Z, X, then Y order. + return quaternion.EulerZXY(eulerDegrees * Deg2Rad); + } + + /// + /// The burst compatible version of . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static quaternion Slerp(quaternion start, quaternion end, float time) + { + return math.slerp(start, end, math.clamp(time, 0.0f, 1.0f)); + } + + /// + /// The burst compatible version of , which normalizes its result. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static quaternion Nlerp(quaternion start, quaternion end, float time) + { + return math.nlerp(start, end, math.clamp(time, 0.0f, 1.0f)); + } + + /// + /// The burst compatible version of , which interpolates both direction and + /// magnitude. + /// + internal static float3 Slerp(float3 start, float3 end, float time) + { + time = math.clamp(time, 0.0f, 1.0f); + + var startMagnitude = math.length(start); + var endMagnitude = math.length(end); + + // With a zero length input there is no direction to rotate through, so this degenerates to a lerp. + if (startMagnitude < math.EPSILON || endMagnitude < math.EPSILON) + { + return math.lerp(start, end, time); + } + + var startDirection = start / startMagnitude; + var endDirection = end / endMagnitude; + var magnitude = math.lerp(startMagnitude, endMagnitude, time); + + var dot = math.clamp(math.dot(startDirection, endDirection), -1.0f, 1.0f); + var angle = math.acos(dot); + var sinAngle = math.sin(angle); + + // Both ends of the range make sinAngle approach zero, which the division below cannot survive. + // Nearly parallel is safe to lerp through. Nearly antiparallel has no defined rotation plane at + // all, so any implementation has to pick one; that case is expected to differ from the engine. + if (sinAngle < 0.001f) + { + return math.normalizesafe(math.lerp(startDirection, endDirection, time), startDirection) * magnitude; + } + + var direction = (math.sin((1.0f - time) * angle) * startDirection + math.sin(time * angle) * endDirection) / sinAngle; + return direction * magnitude; + } + + /// + /// The burst compatible version of . + /// + /// + /// A direct port of the engine's managed implementation. + /// + internal static float3 SmoothDamp(float3 current, float3 target, ref float3 currentVelocity, float smoothTime, float maxSpeed, float deltaTime) + { + smoothTime = math.max(0.0001f, smoothTime); + var omega = 2.0f / smoothTime; + + var x = omega * deltaTime; + var exp = 1.0f / (1.0f + x + 0.48f * x * x + 0.235f * x * x * x); + + var changeX = current.x - target.x; + var changeY = current.y - target.y; + var changeZ = current.z - target.z; + var originalTo = target; + + // Clamp the maximum speed. The engine takes this square root in double precision, which is + // observable in the result, so it is taken the same way here. + var maxChange = maxSpeed * smoothTime; + var maxChangeSq = maxChange * maxChange; + var sqrMagnitude = changeX * changeX + changeY * changeY + changeZ * changeZ; + if (sqrMagnitude > maxChangeSq) + { + var magnitude = (float)math.sqrt((double)sqrMagnitude); + changeX = changeX / magnitude * maxChange; + changeY = changeY / magnitude * maxChange; + changeZ = changeZ / magnitude * maxChange; + } + + var targetX = current.x - changeX; + var targetY = current.y - changeY; + var targetZ = current.z - changeZ; + + var tempX = (currentVelocity.x + omega * changeX) * deltaTime; + var tempY = (currentVelocity.y + omega * changeY) * deltaTime; + var tempZ = (currentVelocity.z + omega * changeZ) * deltaTime; + + currentVelocity.x = (currentVelocity.x - omega * tempX) * exp; + currentVelocity.y = (currentVelocity.y - omega * tempY) * exp; + currentVelocity.z = (currentVelocity.z - omega * tempZ) * exp; + + var output = new float3( + targetX + (changeX + tempX) * exp, + targetY + (changeY + tempY) * exp, + targetZ + (changeZ + tempZ) * exp); + + // Prevent overshooting. + var originalMinusCurrent = originalTo - current; + var outputMinusOriginal = output - originalTo; + if (math.dot(originalMinusCurrent, outputMinusOriginal) > 0.0f) + { + output = originalTo; + currentVelocity = (output - originalTo) / deltaTime; + } + return output; + } + + /// + /// The burst compatible version of . + /// + /// + /// A direct port of the engine's managed implementation. + /// + internal static float SmoothDampAngle(float current, float target, ref float currentVelocity, float smoothTime, float maxSpeed, float deltaTime) + { + target = current + DeltaAngle(current, target); + + smoothTime = math.max(0.0001f, smoothTime); + var omega = 2.0f / smoothTime; + + var x = omega * deltaTime; + var exp = 1.0f / (1.0f + x + 0.48f * x * x + 0.235f * x * x * x); + + var change = current - target; + var originalTo = target; + + var maxChange = maxSpeed * smoothTime; + change = math.clamp(change, -maxChange, maxChange); + target = current - change; + + var temp = (currentVelocity + omega * change) * deltaTime; + currentVelocity = (currentVelocity - omega * temp) * exp; + var output = target + (change + temp) * exp; + + if (originalTo - current > 0.0f == output > originalTo) + { + output = originalTo; + currentVelocity = (output - originalTo) / deltaTime; + } + return output; + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs.meta new file mode 100644 index 0000000000..10534c0472 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d5966883c252634997cc156985cf0ed \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Components/QuaternionCompressor.cs b/com.unity.netcode.gameobjects/Runtime/Components/QuaternionCompressor.cs index 3c338a2a84..e87bed6f47 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/QuaternionCompressor.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/QuaternionCompressor.cs @@ -1,10 +1,11 @@ using System.Runtime.CompilerServices; +using Unity.Mathematics; using UnityEngine; namespace Unity.Netcode { /// - /// The Smallest Three Quaternion Compressor Implementation + /// The Smallest Three Quaternion Compressor implementation, safe to use in jobs. /// /// /// Explanation of why "The smallest three": @@ -49,21 +50,48 @@ public static class QuaternionCompressor /// the compressed as an unsigned integer [MethodImpl(MethodImplOptions.AggressiveInlining)] public static uint CompressQuaternion(ref Quaternion quaternion) + { + return Compress(new float4(quaternion.x, quaternion.y, quaternion.z, quaternion.w)); + } + + /// + /// Decompress an unsigned integer into a . + /// + /// quaternion to store the decompressed values within + /// the compressed quaternion + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void DecompressQuaternion(ref Quaternion quaternion, uint compressed) + { + Decompress(out var decompressed, compressed); + quaternion.x = decompressed.x; + quaternion.y = decompressed.y; + quaternion.z = decompressed.z; + quaternion.w = decompressed.w; + } + + /// + /// The based implementation of . + /// + /// + /// This is a job safe method to be used in place of . + /// + /// the quaternion, as a , to be compressed + /// the quaternion compressed as an unsigned integer + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static uint Compress(in float4 quaternion) { // Store off the absolute value for each Quaternion element - var quatAbsValue0 = Mathf.Abs(quaternion[0]); - var quatAbsValue1 = Mathf.Abs(quaternion[1]); - var quatAbsValue2 = Mathf.Abs(quaternion[2]); - var quatAbsValue3 = Mathf.Abs(quaternion[3]); + var quatAbsValues = math.abs(quaternion); // Get the largest element value of the quaternion to know what the remaining "Smallest Three" values are - var quatMax = Mathf.Max(quatAbsValue0, quatAbsValue1, quatAbsValue2, quatAbsValue3); + var quatMax = math.cmax(quatAbsValues); // Find the index of the largest element, so we can skip that element while compressing and decompressing - var indexToSkip = (ushort)(quatAbsValue0 == quatMax ? 0 : quatAbsValue1 == quatMax ? 1 : quatAbsValue2 == quatMax ? 2 : 3); + var indexToSkip = (ushort)(quatAbsValues.x == quatMax ? 0 : quatAbsValues.y == quatMax ? 1 : quatAbsValues.z == quatMax ? 2 : 3); // Get the sign of the largest element which is all that is needed when calculating the sum of squares of a normalized quaternion. - var quatMaxSign = (quaternion[indexToSkip] < 0 ? k_True : k_False); + var maxValue = indexToSkip == 0 ? quaternion.x : indexToSkip == 1 ? quaternion.y : indexToSkip == 2 ? quaternion.z : quaternion.w; + var quatMaxSign = maxValue < 0 ? k_True : k_False; // Start with the index to skip which will be shifted to the highest two bits var compressed = (uint)indexToSkip; @@ -71,24 +99,45 @@ public static uint CompressQuaternion(ref Quaternion quaternion) // Step 1: If we are on the index to skip, preserve the current compressed value, otherwise proceed to step 2 and 3 // Step 2: Get the sign of the element we are processing. If it is not the same as the largest value's sign bit then we set the bit // Step 3: Get the compressed and encoded value by multiplying the absolute value of the current element by k_CompressionEncodingMask and round that result up - compressed = 0 != indexToSkip ? (compressed << 10) | (uint)((quaternion[0] < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit | (ushort)Mathf.Round(k_CompressionEncodingMask * quatAbsValue0) : compressed; + compressed = 0 != indexToSkip ? EncodeElement(compressed, quaternion.x, quatAbsValues.x, quatMaxSign) : compressed; // Repeat the 3 steps for the remaining elements - compressed = 1 != indexToSkip ? (compressed << 10) | (uint)((quaternion[1] < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit | (ushort)Mathf.Round(k_CompressionEncodingMask * quatAbsValue1) : compressed; - compressed = 2 != indexToSkip ? (compressed << 10) | (uint)((quaternion[2] < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit | (ushort)Mathf.Round(k_CompressionEncodingMask * quatAbsValue2) : compressed; - compressed = 3 != indexToSkip ? (compressed << 10) | (uint)((quaternion[3] < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit | (ushort)Mathf.Round(k_CompressionEncodingMask * quatAbsValue3) : compressed; + compressed = 1 != indexToSkip ? EncodeElement(compressed, quaternion.y, quatAbsValues.y, quatMaxSign) : compressed; + compressed = 2 != indexToSkip ? EncodeElement(compressed, quaternion.z, quatAbsValues.z, quatMaxSign) : compressed; + compressed = 3 != indexToSkip ? EncodeElement(compressed, quaternion.w, quatAbsValues.w, quatMaxSign) : compressed; // Return the compress quaternion return compressed; } /// - /// Decompress a compressed quaternion + /// The encoding algorithm, broken into its elements. /// - /// quaternion to store the decompressed values within + /// The current compressed value. + /// The value to be compressed into the compressed value. + /// The absolute value of the value to be compressed. + /// The sign of the largest value that is calculated upon decompression. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint EncodeElement(uint compressed, float value, float absValue, ushort quatMaxSign) + { + return (compressed << 10) + | (uint)((value < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit + | (ushort)math.round(k_CompressionEncodingMask * absValue); + } + + /// + /// The based implementation of . + /// + /// + /// This is a job safe method to be used in place of . + /// + /// the decompressed quaternion as a /// the compressed quaternion [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void DecompressQuaternion(ref Quaternion quaternion, uint compressed) + internal static void Decompress(out float4 quaternion, uint compressed) { + quaternion = float4.zero; + // Get the last two bits for the index to skip (0-3) var indexToSkip = (int)(compressed >> 30); @@ -101,13 +150,40 @@ public static void DecompressQuaternion(ref Quaternion quaternion, uint compress continue; } // Check the negative bit and multiply that result with the decompressed and decoded value - quaternion[i] = ((compressed & k_NegShortBit) > 0 ? -1.0f : 1.0f) * ((compressed & k_PrecisionMask) * k_DecompressionDecodingMask); - sumOfSquaredMagnitudes += quaternion[i] * quaternion[i]; + var value = ((compressed & k_NegShortBit) > 0 ? -1.0f : 1.0f) * ((compressed & k_PrecisionMask) * k_DecompressionDecodingMask); + SetAxis(ref quaternion, i, value); + sumOfSquaredMagnitudes += value * value; compressed = compressed >> 10; } // Since a normalized quaternion's magnitude is 1.0f, we subtract the sum of the squared smallest three from the unit value and take - // the square root of the difference to find the final largest value - quaternion[indexToSkip] = Mathf.Sqrt(1.0f - sumOfSquaredMagnitudes); + // the square root of the difference to find the final largest value. + SetAxis(ref quaternion, indexToSkip, math.sqrt(1.0f - sumOfSquaredMagnitudes)); + } + + /// + /// Sets each component directly rather than indexing into the array to avoid bounds checking cost. + /// + /// The current decompressed quaternion. + /// The index of the decompressed quaternion to be set. + /// The axis value to apply to the decompressed quaternion. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetAxis(ref float4 decompressed, int index, float value) + { + switch (index) + { + case 0: + decompressed.x = value; + break; + case 1: + decompressed.y = value; + break; + case 2: + decompressed.z = value; + break; + default: + decompressed.w = value; + break; + } } } } diff --git a/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs new file mode 100644 index 0000000000..10ec15b362 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; + +namespace Unity.Netcode.Components +{ + /// + /// Allocates the ushort handles that identify instances in mode. + /// + /// + /// A batched state update names its instance by a handle. The alternative is a + /// and pair, + /// which costs more once bit packed and grows as object ids climb.
+ /// Only the instance writing synchronization data allocates: the server, or the session owner in a + /// distributed authority topology. Everyone else is told the handle at spawn.
+ /// The owner does not allocate. A handle has to outlive ownership changes.
+ /// Freed handles are held for before being reissued. An unreliable + /// state update naming a handle can still be in flight when its instance despawns. + ///
+ internal class TransformHandleAllocator + { + /// + /// Reserved to mean "no handle assigned". + /// + internal const ushort InvalidHandle = 0; + + /// + /// How long a freed handle is held before it can be reissued. Comfortably longer than any state + /// update can remain in flight. + /// + private const double k_RecycleDelaySeconds = 5.0; + + private struct PendingHandle + { + internal ushort Handle; + internal double ReusableAtTime; + } + + private ushort m_NextHandle = 1; + + /// + /// Freed handles in the order they were released, which is also the order they become reusable. + /// + private readonly Queue m_PendingRecycle = new Queue(); + + /// + /// Resolves a handle back to its instance when a batched state update is applied. + /// + private readonly Dictionary m_ByHandle = new Dictionary(); + + /// + /// Issues a handle, reusing a previously freed one once it has been held long enough. + /// + /// The current network time, used to age freed handles. + internal ushort Allocate(double currentTime) + { + if (m_PendingRecycle.Count > 0 && m_PendingRecycle.Peek().ReusableAtTime <= currentTime) + { + return m_PendingRecycle.Dequeue().Handle; + } + + if (m_NextHandle != ushort.MaxValue) + { + return m_NextHandle++; + } + + // Every handle is in use or still cooling down. Reusing the oldest one is the only way to keep + // going, and it is the least likely to still be named by anything in flight. + if (m_PendingRecycle.Count > 0) + { + NetworkLog.LogWarning($"[{nameof(NetworkTransform)}] Ran out of transform handles and had to reuse one before its hold expired. " + + "A state update still in flight for the previous instance could be applied to the new one."); + return m_PendingRecycle.Dequeue().Handle; + } + + NetworkLog.LogError($"[{nameof(NetworkTransform)}] Exhausted all {ushort.MaxValue - 1} transform handles. " + + "Any further instances cannot be synchronized."); + return InvalidHandle; + } + + /// + /// Releases a handle that only becomes reusable when the k_RecycleDelaySeconds + /// delay period has expired. + /// + internal void Release(ushort handle, double currentTime) + { + if (handle == InvalidHandle) + { + return; + } + m_ByHandle.Remove(handle); + m_PendingRecycle.Enqueue(new PendingHandle() + { + Handle = handle, + ReusableAtTime = currentTime + k_RecycleDelaySeconds, + }); + } + + /// + /// Associates a handle with the instance it addresses, on both the sending and receiving sides. + /// + internal void Register(ushort handle, NetworkTransform networkTransform) + { + if (handle == InvalidHandle) + { + return; + } + m_ByHandle[handle] = networkTransform; + } + + /// + /// Removes the association without making the handle reusable for the non-authoritative + /// instances. + /// + internal void Unregister(ushort handle) + { + if (handle == InvalidHandle) + { + return; + } + m_ByHandle.Remove(handle); + } + + internal bool TryGet(ushort handle, out NetworkTransform networkTransform) + { + return m_ByHandle.TryGetValue(handle, out networkTransform); + } + + internal int GetRegisteredCount() + { + return m_ByHandle.Count; + } + + internal void Clear() + { + m_ByHandle.Clear(); + m_PendingRecycle.Clear(); + m_NextHandle = 1; + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta new file mode 100644 index 0000000000..0d476551b3 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc11efbe776870d419c04b48f17bb61c \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs new file mode 100644 index 0000000000..77b4312dd0 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs @@ -0,0 +1,365 @@ +using System; +using System.Text; +using NUnit.Framework; +using Unity.Mathematics; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Measures how closely agrees with the engine math it replaces. + /// + /// + /// The replacements exist because the engine equivalents are native bindings Burst cannot compile.
+ /// Some are ports of engine code that is managed C#, and those are expected to match exactly.
+ /// The rest are mathematically equivalent, and cannot be verified as bit identical because the engine's + /// operation order is not observable. + ///
+ // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + internal class NetworkTransformMathTests + { + private const int k_Iterations = 20000; + + /// + /// Ports of engine implementations that are managed C#, so they are expected to match exactly. + /// + private const float k_ExactTolerance = 0.0f; + + /// + /// Replacements for native implementations. Tight enough that a real divergence fails while ordinary + /// floating point reassociation does not. + /// + private const float k_EquivalentTolerance = 0.001f; + + /// + /// agrees with the engine to within a single float ulp + /// over the value ranges used here, but not bit for bit. + /// + /// + /// Not worth closing: it would mean guessing at how the engine's build contracts multiply and add, and + /// a difference this size is far below anything interpolation can express. + /// + private const float k_SingleUlpTolerance = 1E-5f; + + private static System.Random s_Random; + + [SetUp] + public void SetUp() + { + // Fixed seed so a failure is reproducible. + s_Random = new System.Random(20260814); + } + + private static float RandomFloat(float min, float max) + { + return (float)(s_Random.NextDouble() * (max - min) + min); + } + + private static Vector3 RandomVector(float range) + { + return new Vector3(RandomFloat(-range, range), RandomFloat(-range, range), RandomFloat(-range, range)); + } + + private static Quaternion RandomRotation() + { + // Uniformly distributed rotations, which reaches the pole cases the euler conversion special cases. + var u1 = (float)s_Random.NextDouble(); + var u2 = (float)s_Random.NextDouble(); + var u3 = (float)s_Random.NextDouble(); + var sqrt1MinusU1 = Mathf.Sqrt(1.0f - u1); + var sqrtU1 = Mathf.Sqrt(u1); + return new Quaternion( + sqrt1MinusU1 * Mathf.Sin(2.0f * Mathf.PI * u2), + sqrt1MinusU1 * Mathf.Cos(2.0f * Mathf.PI * u2), + sqrtU1 * Mathf.Sin(2.0f * Mathf.PI * u3), + sqrtU1 * Mathf.Cos(2.0f * Mathf.PI * u3)); + } + + /// + /// Tracks the largest disagreement seen so a failure can report it. + /// + private struct Worst + { + public float Error; + public string Detail; + + public void Record(float error, Func detail) + { + if (error > Error) + { + Error = error; + Detail = detail(); + } + } + + public void Assert(string name, float tolerance) + { + NUnit.Framework.Assert.LessOrEqual(Error, tolerance, + $"{name} deviates from the engine implementation by {Error} (tolerance {tolerance}).\n{Detail}"); + } + } + + [Test] + public void DeltaAngleMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var current = RandomFloat(-1080.0f, 1080.0f); + var target = RandomFloat(-1080.0f, 1080.0f); + var expected = Mathf.DeltaAngle(current, target); + var actual = NetworkTransformMath.DeltaAngle(current, target); + worst.Record(Mathf.Abs(expected - actual), () => $"current={current} target={target} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.DeltaAngle), k_ExactTolerance); + } + + [Test] + public void RepeatMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var t = RandomFloat(-1080.0f, 1080.0f); + var expected = Mathf.Repeat(t, 360.0f); + var actual = NetworkTransformMath.Repeat(t, 360.0f); + worst.Record(Mathf.Abs(expected - actual), () => $"t={t} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.Repeat), k_ExactTolerance); + } + + [Test] + public void LerpVector3MatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var start = RandomVector(100.0f); + var end = RandomVector(100.0f); + var t = RandomFloat(-0.5f, 1.5f); + var expected = Vector3.Lerp(start, end, t); + var actual = (Vector3)NetworkTransformMath.Lerp(start, end, t); + worst.Record(Vector3.Distance(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}"); + } + worst.Assert("Lerp(Vector3)", k_ExactTolerance); + } + + [Test] + public void SmoothDampVector3MatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var current = RandomVector(50.0f); + var target = RandomVector(50.0f); + var velocity = RandomVector(10.0f); + var smoothTime = RandomFloat(0.001f, 1.0f); + var maxSpeed = RandomFloat(0.1f, 100.0f); + var deltaTime = RandomFloat(0.001f, 0.1f); + + var engineVelocity = velocity; + var expected = Vector3.SmoothDamp(current, target, ref engineVelocity, smoothTime, maxSpeed, deltaTime); + + float3 portedVelocity = velocity; + var actual = (Vector3)NetworkTransformMath.SmoothDamp(current, target, ref portedVelocity, smoothTime, maxSpeed, deltaTime); + + var error = Mathf.Max(Vector3.Distance(expected, actual), Vector3.Distance(engineVelocity, (Vector3)portedVelocity)); + worst.Record(error, () => $"current={current} target={target} smoothTime={smoothTime} maxSpeed={maxSpeed} dt={deltaTime}\n" + + $" expected={expected} vel={engineVelocity}\n actual ={actual} vel={(Vector3)portedVelocity}"); + } + worst.Assert("SmoothDamp(Vector3)", k_SingleUlpTolerance); + } + + [Test] + public void SmoothDampAngleMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var current = RandomFloat(-720.0f, 720.0f); + var target = RandomFloat(-720.0f, 720.0f); + var velocity = RandomFloat(-50.0f, 50.0f); + var smoothTime = RandomFloat(0.001f, 1.0f); + var maxSpeed = RandomFloat(0.1f, 500.0f); + var deltaTime = RandomFloat(0.001f, 0.1f); + + var engineVelocity = velocity; + var expected = Mathf.SmoothDampAngle(current, target, ref engineVelocity, smoothTime, maxSpeed, deltaTime); + + var portedVelocity = velocity; + var actual = NetworkTransformMath.SmoothDampAngle(current, target, ref portedVelocity, smoothTime, maxSpeed, deltaTime); + + var error = Mathf.Max(Mathf.Abs(expected - actual), Mathf.Abs(engineVelocity - portedVelocity)); + worst.Record(error, () => $"current={current} target={target} smoothTime={smoothTime} dt={deltaTime} expected={expected} actual={actual}"); + } + worst.Assert("SmoothDampAngle", k_ExactTolerance); + } + + [Test] + public void EulerAnglesMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var rotation = RandomRotation(); + var expected = rotation.eulerAngles; + var actual = (Vector3)NetworkTransformMath.EulerAngles(rotation); + + // Compared as angles so that 359.999 and 0.001 are not treated as a large disagreement. + var error = Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(expected.x, actual.x)), + Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(expected.y, actual.y)), Mathf.Abs(Mathf.DeltaAngle(expected.z, actual.z)))); + worst.Record(error, () => $"rotation={rotation} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.EulerAngles), k_EquivalentTolerance); + } + + [Test] + public void EulerMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var euler = new Vector3(RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f)); + var expected = Quaternion.Euler(euler); + var actual = (Quaternion)NetworkTransformMath.Euler(euler); + + // q and -q are the same rotation, so compare the angle between them. + var error = Quaternion.Angle(expected, actual); + worst.Record(error, () => $"euler={euler} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.Euler), k_EquivalentTolerance); + } + + [Test] + public void SlerpQuaternionMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var start = RandomRotation(); + var end = RandomRotation(); + var t = RandomFloat(0.0f, 1.0f); + var expected = Quaternion.Slerp(start, end, t); + var actual = (Quaternion)NetworkTransformMath.Slerp(start, end, t); + worst.Record(Quaternion.Angle(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}"); + } + worst.Assert("Slerp(Quaternion)", k_EquivalentTolerance); + } + + [Test] + public void LerpQuaternionMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var start = RandomRotation(); + var end = RandomRotation(); + var t = RandomFloat(0.0f, 1.0f); + var expected = Quaternion.Lerp(start, end, t); + var actual = (Quaternion)NetworkTransformMath.Nlerp(start, end, t); + worst.Record(Quaternion.Angle(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.Nlerp), k_EquivalentTolerance); + } + + [Test] + public void SlerpVector3MatchesEngine() + { + var worst = new Worst(); + var worstAntiparallel = new Worst(); + var antiparallelCount = 0; + + for (int i = 0; i < k_Iterations; i++) + { + var start = RandomVector(50.0f); + var end = RandomVector(50.0f); + var t = RandomFloat(0.0f, 1.0f); + var expected = Vector3.Slerp(start, end, t); + var actual = (Vector3)NetworkTransformMath.Slerp((float3)start, (float3)end, t); + var error = Vector3.Distance(expected, actual); + + // Nearly antiparallel inputs have no defined rotation plane, so both implementations have to + // pick one arbitrarily. Measured and reported, but not asserted on. + var cosAngle = Vector3.Dot(start.normalized, end.normalized); + if (cosAngle < -0.999f) + { + antiparallelCount++; + worstAntiparallel.Record(error, () => $"start={start} end={end} t={t}"); + } + else + { + worst.Record(error, () => $"start={start} end={end} t={t} expected={expected} actual={actual}"); + } + } + + Debug.Log($"Slerp(Vector3): nearly antiparallel max deviation {worstAntiparallel.Error:E3} over {antiparallelCount} samples (not asserted)."); + worst.Assert("Slerp(Vector3)", k_EquivalentTolerance); + } + + /// + /// Reports every measurement in one place so the numbers can be reviewed together rather than one + /// assertion at a time. + /// + [Test] + public void ReportAllDeviations() + { + var report = new StringBuilder(); + report.AppendLine($"{nameof(NetworkTransformMath)} agreement with the engine ({k_Iterations} samples each):"); + + void Measure(string name, Func sample) + { + var worst = 0.0f; + for (int i = 0; i < k_Iterations; i++) + { + worst = Mathf.Max(worst, sample()); + } + report.AppendLine($" {name,-24} max deviation {worst:E3}"); + } + + Measure("DeltaAngle", () => + { + var a = RandomFloat(-1080.0f, 1080.0f); + var b = RandomFloat(-1080.0f, 1080.0f); + return Mathf.Abs(Mathf.DeltaAngle(a, b) - NetworkTransformMath.DeltaAngle(a, b)); + }); + Measure("EulerAngles", () => + { + var r = RandomRotation(); + var e = r.eulerAngles; + var a = (Vector3)NetworkTransformMath.EulerAngles(r); + return Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(e.x, a.x)), Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(e.y, a.y)), Mathf.Abs(Mathf.DeltaAngle(e.z, a.z)))); + }); + Measure("Euler", () => + { + var e = new Vector3(RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f)); + return Quaternion.Angle(Quaternion.Euler(e), (Quaternion)NetworkTransformMath.Euler(e)); + }); + Measure("Slerp(Quaternion)", () => + { + var s = RandomRotation(); + var e = RandomRotation(); + var t = RandomFloat(0.0f, 1.0f); + return Quaternion.Angle(Quaternion.Slerp(s, e, t), (Quaternion)NetworkTransformMath.Slerp(s, e, t)); + }); + Measure("Lerp(Quaternion)", () => + { + var s = RandomRotation(); + var e = RandomRotation(); + var t = RandomFloat(0.0f, 1.0f); + return Quaternion.Angle(Quaternion.Lerp(s, e, t), (Quaternion)NetworkTransformMath.Nlerp(s, e, t)); + }); + Measure("Slerp(Vector3)", () => + { + var s = RandomVector(50.0f); + var e = RandomVector(50.0f); + var t = RandomFloat(0.0f, 1.0f); + return Vector3.Distance(Vector3.Slerp(s, e, t), (Vector3)NetworkTransformMath.Slerp((float3)s, (float3)e, t)); + }); + + Debug.Log(report.ToString()); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta new file mode 100644 index 0000000000..6aa632ae1a --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 78923d5c3a7b9814096112ab77c999a7 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs new file mode 100644 index 0000000000..bdc59a32da --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs @@ -0,0 +1,140 @@ +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Covers , in particular that a freed handle is held long enough + /// that a state update still in flight for the instance that owned it cannot be applied to whichever + /// instance picks it up next. + /// + // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + internal class TransformHandleAllocatorTests + { + /// + /// Has to match the allocator's hold duration. + /// + private const double k_RecycleDelaySeconds = 5.0; + + [Test] + public void AllocatesDenselyAndSkipsTheInvalidHandle() + { + var allocator = new TransformHandleAllocator(); + + for (ushort expected = 1; expected <= 100; expected++) + { + var handle = allocator.Allocate(0.0); + Assert.AreNotEqual(TransformHandleAllocator.InvalidHandle, handle, "Allocated the reserved invalid handle!"); + Assert.AreEqual(expected, handle, "Handles were not allocated densely!"); + } + } + + [Test] + public void ReleasedHandleIsNotReissuedBeforeItsHoldExpires() + { + var allocator = new TransformHandleAllocator(); + + var first = allocator.Allocate(0.0); + var second = allocator.Allocate(0.0); + allocator.Release(first, 0.0); + + // Anything allocated before the hold expires has to be a fresh handle, not the released one. + for (double time = 0.0; time < k_RecycleDelaySeconds; time += 1.0) + { + var handle = allocator.Allocate(time); + Assert.AreNotEqual(first, handle, + $"Released handle {first} was reissued at time {time}, before its hold expired!"); + Assert.AreNotEqual(second, handle, "Reissued a handle that was never released!"); + } + } + + [Test] + public void ReleasedHandleIsReissuedOnceItsHoldExpires() + { + var allocator = new TransformHandleAllocator(); + + var first = allocator.Allocate(0.0); + allocator.Release(first, 0.0); + + var reissued = allocator.Allocate(k_RecycleDelaySeconds); + Assert.AreEqual(first, reissued, "A handle held past its delay was not reused, which would leak the handle space!"); + } + + [Test] + public void ReleasedHandlesAreReissuedInReleaseOrder() + { + var allocator = new TransformHandleAllocator(); + + var first = allocator.Allocate(0.0); + var second = allocator.Allocate(0.0); + var third = allocator.Allocate(0.0); + + // Released at increasing times, so they become reusable in the same order. + allocator.Release(first, 0.0); + allocator.Release(second, 1.0); + allocator.Release(third, 2.0); + + Assert.AreEqual(first, allocator.Allocate(k_RecycleDelaySeconds), "Oldest released handle was not reissued first!"); + Assert.AreEqual(second, allocator.Allocate(k_RecycleDelaySeconds + 1.0), "Handles were not reissued in release order!"); + Assert.AreEqual(third, allocator.Allocate(k_RecycleDelaySeconds + 2.0), "Handles were not reissued in release order!"); + } + + [Test] + public void ReleasingTheInvalidHandleIsIgnored() + { + var allocator = new TransformHandleAllocator(); + + allocator.Release(TransformHandleAllocator.InvalidHandle, 0.0); + + // If the invalid handle had been queued it would come back out here. + Assert.AreEqual(1, allocator.Allocate(k_RecycleDelaySeconds * 2.0), "The reserved invalid handle entered the recycle queue!"); + } + + [Test] + public void RegisteredHandleResolvesBackToItsInstance() + { + var allocator = new TransformHandleAllocator(); + var handle = allocator.Allocate(0.0); + + // The association is what the receiving side uses to route a batched state update. A null instance + // is enough to prove the table behavior without needing a spawned NetworkObject. + allocator.Register(handle, null); + Assert.IsTrue(allocator.TryGet(handle, out _), "A registered handle did not resolve!"); + Assert.AreEqual(1, allocator.GetRegisteredCount()); + + allocator.Unregister(handle); + Assert.IsFalse(allocator.TryGet(handle, out _), "An unregistered handle still resolved!"); + Assert.AreEqual(0, allocator.GetRegisteredCount()); + } + + [Test] + public void ReleaseAlsoDropsTheRegistration() + { + var allocator = new TransformHandleAllocator(); + var handle = allocator.Allocate(0.0); + allocator.Register(handle, null); + + allocator.Release(handle, 0.0); + + Assert.IsFalse(allocator.TryGet(handle, out _), + "A released handle still resolved, which would route state updates to a despawned instance!"); + } + + [Test] + public void ClearResetsTheAllocator() + { + var allocator = new TransformHandleAllocator(); + allocator.Allocate(0.0); + allocator.Allocate(0.0); + var third = allocator.Allocate(0.0); + allocator.Register(third, null); + + allocator.Clear(); + + Assert.AreEqual(0, allocator.GetRegisteredCount(), "Clear left registrations behind!"); + Assert.AreEqual(1, allocator.Allocate(0.0), "Clear did not reset the handle sequence, so a new session would not start from the beginning!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs.meta new file mode 100644 index 0000000000..07671fec58 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e0cf23068d2423479f2ffe62ae6ed51 \ No newline at end of file