UnitTests/FlatpackMathTests.cs

This is a unit test file for the Flatpack math library. It verifies geometric calculations used for generating impostor frame layouts across various layouts like hemisphere, sphere, and cylinder.

using Flatpack;
using Sandbox;

/// <summary>
/// The frame layout maths. The shader has an HLSL copy of these functions, so a change that breaks a test here
/// almost certainly breaks the impostor on screen too.
/// </summary>
[TestClass]
public class FlatpackMathTests
{
	const float Epsilon = 1e-3f;

	static void AssertNear( Vector3 expected, Vector3 actual )
	{
		Assert.IsTrue( expected.Distance( actual ) < Epsilon, $"expected {expected}, got {actual}" );
	}

	[TestMethod]
	[DataRow( FlatpackLayout.Hemisphere )]
	[DataRow( FlatpackLayout.Sphere )]
	public void OctahedralRoundTrip( FlatpackLayout layout )
	{
		// Every frame direction must encode back to itself (and, for the hemisphere, to its own grid vertex), or the
		// shader picks the wrong frame.
		const int n = 8;
		for ( var y = 0; y < n; y++ )
		{
			for ( var x = 0; x < n; x++ )
			{
				var d = FlatpackMath.FrameDirection( x, y, n, layout );
				var o = FlatpackMath.EncodeOct( d, layout );

				// The full octahedron folds its border: mirrored edge texels (and all four corners) are the same
				// direction, so compare directions rather than grid positions.
				AssertNear( d, FlatpackMath.DecodeOct( o, layout ) );

				if ( layout == FlatpackLayout.Hemisphere )
				{
					var g = (o * 0.5f + 0.5f) * (n - 1);
					Assert.AreEqual( x, g.x, 1e-2f, $"frame ({x},{y}) x" );
					Assert.AreEqual( y, g.y, 1e-2f, $"frame ({x},{y}) y" );
				}
			}
		}
	}

	[TestMethod]
	public void HemisphereCornersAreOnTheHorizon()
	{
		// Frame (0,0) looks from -X, (n-1,n-1) from +X, and the centre straight down from +Z.
		AssertNear( new Vector3( -1, 0, 0 ), FlatpackMath.FrameDirection( 0, 0, 8, FlatpackLayout.Hemisphere ) );
		AssertNear( new Vector3( 1, 0, 0 ), FlatpackMath.FrameDirection( 7, 7, 8, FlatpackLayout.Hemisphere ) );
		AssertNear( new Vector3( 0, 0, 1 ), FlatpackMath.FrameDirection( 4, 4, 9, FlatpackLayout.Hemisphere ) );
	}

	[TestMethod]
	public void HemisphereClampsViewsFromBelow()
	{
		var below = new Vector3( 0.3f, 0.2f, -0.9f ).Normal;
		var o = FlatpackMath.EncodeOct( below, FlatpackLayout.Hemisphere );
		var back = FlatpackMath.DecodeOct( o, FlatpackLayout.Hemisphere );

		Assert.IsTrue( back.z >= -Epsilon, "a view from below should land on the horizon" );
	}

	[TestMethod]
	public void CylinderFramesCircleTheHorizon()
	{
		const int n = 4;
		var first = FlatpackMath.FrameDirection( 0, 0, n, FlatpackLayout.Cylinder );
		var quarter = FlatpackMath.FrameDirection( 0, 1, n, FlatpackLayout.Cylinder );

		AssertNear( new Vector3( 1, 0, 0 ), first );
		AssertNear( new Vector3( 0, 1, 0 ), quarter );
	}

	[TestMethod]
	public void FrameBasisIsOrthonormal()
	{
		foreach ( var d in new[] { new Vector3( 1, 0, 0 ), new Vector3( 0.3f, 0.5f, 0.8f ).Normal, new Vector3( 0, 0, 1 ) } )
		{
			FlatpackMath.FrameBasis( d, out var right, out var up );

			Assert.AreEqual( 1.0f, right.Length, Epsilon );
			Assert.AreEqual( 1.0f, up.Length, Epsilon );
			Assert.AreEqual( 0.0f, right.Dot( up ), Epsilon );
			Assert.AreEqual( 0.0f, right.Dot( d ), Epsilon );
			Assert.AreEqual( 0.0f, up.Dot( d ), Epsilon );
		}
	}

	[TestMethod]
	public void BoundingSphereContainsEveryPoint()
	{
		// The baker frames each view on this sphere; a point outside it would be cut off at the frame edge.
		var random = new System.Random( 1234 );
		var points = new Vector3[500];
		for ( var i = 0; i < points.Length; i++ )
			points[i] = new Vector3( random.Next( -100, 300 ), random.Next( -50, 50 ), random.Next( 0, 400 ) );

		var (center, radius) = FlatpackMath.BoundingSphere( points );

		foreach ( var p in points )
			Assert.IsTrue( p.Distance( center ) <= radius + Epsilon, $"{p} is outside the sphere" );

		// Ritter's sphere is close to minimal; the box half-diagonal it replaced was much looser.
		var box = BBox.FromPoints( points );
		Assert.IsTrue( radius < box.Size.Length * 0.5f );
	}

	[TestMethod]
	public void FrameFitContainsEveryPoint()
	{
		// The baker frames each view with these bounds and the shader reads them back from bytes, so every vertex has to
		// land inside the stored rectangle and depth range, or the card clips the object.
		var random = new System.Random( 99 );
		var points = new Vector3[400];
		for ( var i = 0; i < points.Length; i++ )
			points[i] = new Vector3( random.Next( -20, 20 ), random.Next( -5, 5 ), random.Next( 0, 120 ) );

		var (center, radius) = FlatpackMath.BoundingSphere( points );
		for ( var y = 0; y < 12; y++ )
		{
			for ( var x = 0; x < 12; x++ )
			{
				var dir = FlatpackMath.FrameDirection( x, y, 12, FlatpackLayout.Hemisphere );
				var fit = FlatpackMath.FitFrame( points, center, radius, dir, 170, 2 );
				FlatpackMath.FrameBasis( dir, out var right, out var up );

				foreach ( var p in points )
				{
					var rel = p - center;
					var u = 0.5f + Vector3.Dot( rel, right ) / (2.0f * radius);
					var v = 0.5f - Vector3.Dot( rel, up ) / (2.0f * radius);
					var h = Vector3.Dot( rel, dir ) / radius;
					Assert.IsTrue( u >= fit.Min.x - Epsilon && u <= fit.Max.x + Epsilon, $"frame ({x},{y}): u {u} outside {fit.Min.x}..{fit.Max.x}" );
					Assert.IsTrue( v >= fit.Min.y - Epsilon && v <= fit.Max.y + Epsilon, $"frame ({x},{y}): v {v} outside {fit.Min.y}..{fit.Max.y}" );
					Assert.IsTrue( h <= fit.Near + Epsilon && h >= fit.Far - Epsilon, $"frame ({x},{y}): height {h} outside {fit.Far}..{fit.Near}" );
				}

				// A tall, thin object should fill far more of its frames than the sphere does.
				Assert.IsTrue( (fit.Max.x - fit.Min.x) * (fit.Max.y - fit.Min.y) < 0.9f, $"frame ({x},{y}) is barely tighter than the sphere" );
			}
		}
	}

	[TestMethod]
	public void FrameFitBytesRoundTrip()
	{
		// What the baker renders with is what it decodes from the bytes it stores, so encoding a decoded fit again must
		// give the same bytes.
		var fit = new FlatpackMath.FrameFit { Min = new Vector2( 0.21f, 0.03f ), Max = new Vector2( 0.77f, 0.98f ), Near = 0.62f, Far = -0.41f };
		var bytes = FlatpackMath.EncodeFrameFit( fit );
		var again = FlatpackMath.EncodeFrameFit( FlatpackMath.DecodeFrameFit( bytes ) );
		Assert.AreEqual( bytes.Rect, again.Rect );
		Assert.AreEqual( bytes.Depth, again.Depth );
	}

	[TestMethod]
	public void MetresMatchTheIteration6Sheet()
	{
		// "6 radii (21 m)" for the hero tree, 140.08 units in radius.
		Assert.AreEqual( "21 m", FlatpackMath.FormatMetres( 6.0f * 140.08f ) );
		Assert.AreEqual( "4.1 m", FlatpackMath.FormatMetres( 6.0f * 27.05f ) );
	}
}