Profile and optimize retained rendering

This commit is contained in:
gamer147
2026-07-22 14:19:20 -04:00
parent 4c9719c5dc
commit 4ea352e45d
17 changed files with 1619 additions and 144 deletions

View File

@@ -29,6 +29,7 @@
<Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" />
<Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.cs" />
<Compile Include="..\..\godot\GodotTraceSink.cs" Link="GodotTraceSink.cs" />
<Compile Include="..\..\godot\PerformanceFrameLog.cs" Link="PerformanceFrameLog.cs" />
<Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" />
<Compile Include="..\..\godot\RiffWaveSanitizer.cs" Link="RiffWaveSanitizer.cs" />
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />

View File

@@ -25,6 +25,25 @@ public class GfxAnimationTests
Assert.True(o.TranslationEnabled);
}
[Fact]
public void CallerOwnedVisibleSnapshot_ReusesStorageAndPreservesHandleOrder()
{
var g = new GfxState();
for (int i = 999; i >= 0; i--)
g.BindDraw(0x1000 + i, 1, 0, 0, 1, 1, i, 0);
var snapshot = new List<RenderObject>();
g.SnapshotVisibleObjects(0, snapshot); // Grow and warm the buffer outside the measured interval.
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10; i++) g.SnapshotVisibleObjects(i, snapshot);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.Equal(1000, snapshot.Count);
Assert.Equal(0x1000, snapshot[0].Handle);
Assert.Equal(0x1000 + 999, snapshot[^1].Handle);
}
[Fact]
public void CurrentScaleSetter_ExpandsGlowAroundItsAnchor()
{
@@ -94,18 +113,22 @@ public class GfxAnimationTests
var unchanged = VisibleObject();
Assert.False(unchanged.HasActiveVisualPresentation(1000));
Assert.False(unchanged.SnapshotVisibleObjects(1000).Single().TimeVarying);
var spritesheet = VisibleObject();
spritesheet.SetSrcRect(7, 4, 1, 0, 800);
Assert.True(spritesheet.HasActiveVisualPresentation(1000));
Assert.True(spritesheet.SnapshotVisibleObjects(1000).Single().TimeVarying);
var color = VisibleObject();
color.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000));
Assert.True(color.HasActiveVisualPresentation(1000));
Assert.True(color.SnapshotVisibleObjects(1000).Single().TimeVarying);
var rotation = VisibleObject();
rotation.SetRotationCycle(7, 1000, (0, 0, 1));
Assert.True(rotation.HasActiveVisualPresentation(1000));
Assert.True(rotation.SnapshotVisibleObjects(1000).Single().TimeVarying);
}
[Fact]
@@ -129,6 +152,108 @@ public class GfxAnimationTests
Assert.Equal((0, 0, 200, 200), (wrapped.SrcX, wrapped.SrcY, wrapped.W, wrapped.H));
}
[Fact]
public void PresentationReasons_PublishMutationOnce_AndSpritesheetOnlyAtCellBoundaries()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(7, 4, 0, 0, 16, 16, 0, 0);
g.SetSrcRect(7, frameCount: 4, columns: 2, cell: 0, period: 200);
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1000));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1199));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1399));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1400));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1599));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1600));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1799));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1800));
}
[Fact]
public void PresentationReasons_CloneBeforeFirstSampleSharesPhase_AndCloneAfterKeepsIt()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(10, 4, 0, 0, 16, 16, 0, 0);
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 200);
Assert.True(g.CloneObject(10, 11));
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1000));
Assert.Equal(1000, g.TryGet(10)!.SrcStart);
Assert.Equal(1000, g.TryGet(11)!.SrcStart);
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
Assert.Equal(g.SnapshotVisibleObjects(1200).Single(x => x.Handle == 10).SrcX,
g.SnapshotVisibleObjects(1200).Single(x => x.Handle == 11).SrcX);
Assert.True(g.CloneObject(10, 12));
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1250));
Assert.Equal(1000, g.TryGet(12)!.SrcStart);
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1400));
var cells = g.SnapshotVisibleObjects(1400).Select(x => x.SrcX).Distinct().ToArray();
Assert.Single(cells);
}
[Fact]
public void PresentationReasons_UsesObjectLocalPeriods_AndReconfigurationRestartsAtSharedSample()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(10, 4, 0, 0, 16, 16, 0, 0);
g.BindDraw(11, 4, 0, 0, 16, 16, 0, 0);
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 100);
g.SetSrcRect(11, frameCount: 4, columns: 2, cell: 0, period: 250);
g.ConsumePresentationReasons(1000);
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1099));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1100));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1199));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1249));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1250));
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 400);
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1250));
Assert.Equal(1250, g.TryGet(10)!.SrcStart);
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1499));
// The second object's 250 ms boundary and the reconfigured object's 400 ms boundary coincide here.
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1650));
}
[Fact]
public void PresentationReasons_ContinuousChannelRemainsFrameDriven()
{
var g = new GfxState();
g.SetSurface(1, 5, -1);
g.BindDraw(7, 1, 0, 0, 64, 64, 0, 0);
g.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000));
var first = g.ConsumePresentationReasons(1000);
Assert.True((first & GfxPresentationReason.RetainedMutation) != 0);
Assert.True((first & GfxPresentationReason.ContinuousChannel) != 0);
Assert.Equal(GfxPresentationReason.ContinuousChannel, g.ConsumePresentationReasons(1016));
}
[Fact]
public void PresentationReasons_PhaseLockedUnitFamilyPublishesFiveCellChangesPerSecond()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(100, 4, 0, 0, 16, 16, 0, 0);
g.SetSrcRect(100, frameCount: 4, columns: 2, cell: 0, period: 200);
for (long handle = 101; handle < 151; handle++) Assert.True(g.CloneObject(100, handle));
g.ConsumePresentationReasons(1000);
int cellChanges = 0;
for (long now = 1001; now <= 2000; now++)
if ((g.ConsumePresentationReasons(now) & GfxPresentationReason.DiscreteSourceCell) != 0)
cellChanges++;
Assert.Equal(5, cellChanges);
Assert.Single(g.Objects.Select(pair => g.TryGet(pair.Handle)!.SrcStart).Distinct());
}
[Fact]
public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample()
{

View File

@@ -60,6 +60,33 @@ public class MovieSurfaceRegistryTests
Assert.False(registry.IsKnownMovieResource(0x2af5));
}
[Fact]
public void ResourceLookup_SelectsNewestPublishedPlaybackWithoutAllocating()
{
var registry = new MovieSurfaceRegistry();
MovieSurfaceBinding older = registry.Begin(7, 0x2b42, out _);
MovieSurfaceBinding unrelated = registry.Begin(8, 0x2bad, out _);
MovieSurfaceBinding newer = registry.Begin(9, 0x2b42, out _);
Assert.True(registry.PublishFrame(older.PlaybackId, Frame(1), "OLDER.AGF", 1));
Assert.True(registry.PublishFrame(unrelated.PlaybackId, Frame(2), "OTHER.AGF", 2));
Assert.True(registry.PublishFrame(newer.PlaybackId, Frame(3), "NEWER.AGF", 3));
Assert.True(registry.TryResolveResource(0x2b42, out MovieSurfaceFrame? warm));
Assert.Equal("NEWER.AGF", warm!.Name);
Assert.False(registry.TryResolveResource(0x2af5, out _));
long before = GC.GetAllocatedBytesForCurrentThread();
bool resultsCorrect = true;
for (int i = 0; i < 10_000; i++)
{
resultsCorrect &= registry.TryResolveResource(0x2b42, out _);
resultsCorrect &= !registry.TryResolveResource(0x2af5, out _);
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(resultsCorrect);
Assert.Equal(0, allocated);
}
private static RgbaImage Frame(byte value)
=> new(1, 1, new[] { value, value, value, (byte)255 });
}

View File

@@ -0,0 +1,97 @@
using Age.Engine.Model;
public class PerformanceFrameLogTests
{
[Fact]
public void WritesStableCsvSchemaAndFrameWorkload()
{
string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(),
$"age-perf-{Guid.NewGuid():N}.csv");
try
{
using (var log = new PerformanceFrameLog(path))
{
log.BeginFrame(17, 1234, 1.0 / 60.0, "SC0000.BIN", 0x2a91, 0x20c);
log.BeginRecomposite(false);
log.RecordPresentationReasons(1 | 4 | 16);
log.RecordRecomposeAllocation(4096);
log.RecordSnapshotAllocation(512);
log.RecordCompositeAllocation(2048);
log.RecordSourcePrepAllocation(128);
log.RecordSetDataAllocation(1024);
log.RecordUiAllocation(64);
log.RecordPresentationCoordinate("SC0000.BIN", 0x2aaa, 0x21c);
log.RecordObject(timeVarying: true);
log.RecordRaster(800, 600, new Affine2D(1, 0, 0, 1, 0, 0),
800, 600, dynamic: false, BlendKind.Alpha, ticks: 10);
log.RecordFillLayer();
log.RecordSkippedLayer();
log.EndRecomposite();
log.EndFrame();
Assert.Equal(1, log.FrameCount);
Assert.Equal(1, log.RecompositeCount);
}
string[] lines = File.ReadAllLines(path);
Assert.Equal(2, lines.Length);
string[] header = lines[0].Split(',');
string[] row = lines[1].Split(',');
Assert.Equal(header.Length, row.Length);
Assert.Equal("frame", header[0]);
Assert.Equal("17", row[0]);
Assert.Equal("1", row[Array.IndexOf(header, "recomposited")]);
Assert.Equal("1", row[Array.IndexOf(header, "present_host_request")]);
Assert.Equal("1", row[Array.IndexOf(header, "present_retained_mutation")]);
Assert.Equal("1", row[Array.IndexOf(header, "present_discrete_cell")]);
Assert.Equal("0", row[Array.IndexOf(header, "present_continuous_channel")]);
Assert.Equal("4096", row[Array.IndexOf(header, "recompose_allocated_bytes")]);
Assert.Equal("512", row[Array.IndexOf(header, "snapshot_allocated_bytes")]);
Assert.Equal("2048", row[Array.IndexOf(header, "composite_allocated_bytes")]);
Assert.Equal("128", row[Array.IndexOf(header, "source_prep_allocated_bytes")]);
Assert.Equal("1024", row[Array.IndexOf(header, "set_data_allocated_bytes")]);
Assert.Equal("64", row[Array.IndexOf(header, "ui_allocated_bytes")]);
Assert.Equal("1", row[Array.IndexOf(header, "time_varying_objects")]);
Assert.Equal("480000", row[Array.IndexOf(header, "candidate_pixels")]);
Assert.Equal("SC0000.BIN", row[Array.IndexOf(header, "script")].Trim('"'));
Assert.Equal("10897", row[Array.IndexOf(header, "offset")]);
Assert.Equal("524", row[Array.IndexOf(header, "opcode")]);
Assert.Equal("10922", row[Array.IndexOf(header, "present_offset")]);
Assert.Equal("540", row[Array.IndexOf(header, "present_opcode")]);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Theory]
[InlineData(0, 0, 10, 10, 100)]
[InlineData(-5, -5, 10, 10, 25)]
[InlineData(95, 95, 10, 10, 25)]
[InlineData(200, 200, 10, 10, 0)]
public void CandidatePixelsClipsIntegerTranslations(int x, int y, int width, int height, long expected)
{
var transform = new Affine2D(1, 0, 0, 1, x, y);
Assert.True(PerformanceFrameLog.IsIntegerTranslation(transform));
Assert.Equal(expected,
PerformanceFrameLog.EstimateCandidatePixels(transform, width, height, 100, 100));
}
[Fact]
public void CandidatePixelsUsesAffineBoundingBoxAndDetectsNonIntegerPath()
{
var scaled = new Affine2D(2, 0, 0, 3, 1.5, 2.5);
Assert.False(PerformanceFrameLog.IsIntegerTranslation(scaled));
Assert.Equal(651,
PerformanceFrameLog.EstimateCandidatePixels(scaled, 10, 10, 100, 100));
}
[Fact]
public void RasterClassificationSeparatesTranslationScaleAndGeneralAffine()
{
Assert.True(PerformanceFrameLog.IsTranslation(new Affine2D(1, 0, 0, 1, 0.25, -0.5)));
Assert.False(PerformanceFrameLog.IsIntegerTranslation(new Affine2D(1, 0, 0, 1, 0.25, -0.5)));
Assert.True(PerformanceFrameLog.IsAxisAligned(new Affine2D(2, 0, 0, 3, 0, 0)));
Assert.False(PerformanceFrameLog.IsAxisAligned(new Affine2D(1, 0.25, 0, 1, 0, 0)));
}
}

View File

@@ -115,10 +115,110 @@ public class SoftwareAffineRasterizerTests
Assert.Equal(new byte[] { 43, 57, 109, 255 }, background);
}
[Fact]
public void BlitRgba_AxisAlignedScaleFastPath_MatchesGeneralAffineAcrossRandomizedCases()
{
var random = new Random(0x231);
double[] scales = { -2.0, -1.25, -0.5, 0.25, 0.5, 0.75, 1.25, 2.0, 3.0 };
double[] translations = { -5.5, -2.25, -0.5, 0.0, 0.5, 1.25, 4.5, 8.0 };
BlendKind[] blends = { BlendKind.Opaque, BlendKind.Alpha, BlendKind.Additive };
for (int iteration = 0; iteration < 250; iteration++)
{
int srcW = random.Next(2, 12), srcH = random.Next(2, 10);
byte[] src = new byte[srcW * srcH * 4];
random.NextBytes(src);
int srcX = random.Next(0, srcW), srcY = random.Next(0, srcH);
int width = random.Next(1, srcW - srcX + 1), height = random.Next(1, srcH - srcY + 1);
int dstW = random.Next(3, 15), dstH = random.Next(3, 13);
byte[] expected = new byte[dstW * dstH * 4];
random.NextBytes(expected);
byte[] actual = (byte[])expected.Clone();
var transform = new Affine2D(
scales[random.Next(scales.Length)], 0, 0, scales[random.Next(scales.Length)],
translations[random.Next(translations.Length)], translations[random.Next(translations.Length)]);
long tint = random.Next(0x1000000);
float tintStrength = random.NextSingle();
float opacity = random.NextSingle();
bool multiplyTint = random.Next(2) != 0;
BlendKind blend = blends[random.Next(blends.Length)];
ReferenceBlit(expected, dstW, dstH, src, srcW, srcX, srcY, width, height,
transform, tint, tintStrength, opacity, multiplyTint, blend);
SoftwareAffineRasterizer.BlitRgba(actual, dstW, dstH, src, srcW, srcH,
srcX, srcY, width, height, transform,
tint, tintStrength, opacity, multiplyTint, blend);
Assert.Equal(expected, actual);
}
}
[Fact]
public void FillRgba_AxisAlignedScaleFastPath_MatchesGeneralAffineAcrossRandomizedCases()
{
var random = new Random(0x22d);
double[] scales = { -2.0, -1.0, -0.5, 0.25, 0.5, 0.75, 1.5, 2.0, 3.0 };
double[] translations = { -5.5, -1.25, -0.5, 0.0, 0.5, 2.25, 7.0 };
for (int iteration = 0; iteration < 250; iteration++)
{
int width = random.Next(1, 10), height = random.Next(1, 9);
int dstW = random.Next(3, 15), dstH = random.Next(3, 13);
byte[] expected = new byte[dstW * dstH * 4];
random.NextBytes(expected);
byte[] actual = (byte[])expected.Clone();
var transform = new Affine2D(
scales[random.Next(scales.Length)], 0, 0, scales[random.Next(scales.Length)],
translations[random.Next(translations.Length)], translations[random.Next(translations.Length)]);
long color = random.Next(0x1000000);
float opacity = random.NextSingle();
ReferenceFill(expected, dstW, dstH, width, height, transform, color, opacity);
SoftwareAffineRasterizer.FillRgba(actual, dstW, dstH, width, height,
transform, color, opacity);
Assert.Equal(expected, actual);
}
}
[Fact]
public void BlitRgba_UnmodulatedSourceOverFastPaths_MatchGeneralAffineAcrossAlphaEdges()
{
var random = new Random(0x2e0);
Affine2D[] transforms =
{
new(1, 0, 0, 1, -2, 1),
new(1, 0, 0, 1, 3, -1),
new(0.5, 0, 0, 1.5, -1.25, 0.5),
new(-1.25, 0, 0, 0.75, 8.5, -0.5),
};
foreach (var transform in transforms)
foreach (BlendKind blend in new[] { BlendKind.Opaque, BlendKind.Alpha })
for (int iteration = 0; iteration < 50; iteration++)
{
int srcW = random.Next(3, 12), srcH = random.Next(3, 10);
byte[] src = new byte[srcW * srcH * 4];
random.NextBytes(src);
for (int pixel = 0; pixel < srcW * srcH; pixel++)
src[pixel * 4 + 3] = (byte)(pixel % 4 switch { 0 => 0, 1 => 1, 2 => 254, _ => 255 });
int dstW = random.Next(5, 16), dstH = random.Next(5, 14);
byte[] expected = new byte[dstW * dstH * 4];
random.NextBytes(expected);
byte[] actual = (byte[])expected.Clone();
ReferenceBlit(expected, dstW, dstH, src, srcW, 1, 1, srcW - 1, srcH - 1,
transform, 0x6a4c2e, 0, 1, false, blend);
SoftwareAffineRasterizer.BlitRgba(actual, dstW, dstH, src, srcW, srcH,
1, 1, srcW - 1, srcH - 1, transform,
0x6a4c2e, 0, 1, false, blend);
Assert.Equal(expected, actual);
}
}
// Pre-fast-path affine algorithm retained here as an independent differential oracle.
private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
int srcX, int srcY, int width, int height, Affine2D transform,
long tint, float tintStrength, float opacity, bool multiplyTint)
long tint, float tintStrength, float opacity, bool multiplyTint,
BlendKind blend = BlendKind.Alpha)
{
if (!transform.TryInverse(out var inv)) return;
ReferenceBounds(transform, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
@@ -136,7 +236,7 @@ public class SoftwareAffineRasterizerTests
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
ReferenceBlend(dst, di, sr, sg, sb, sa);
ReferenceBlend(dst, di, sr, sg, sb, sa, blend);
}
}
@@ -165,8 +265,17 @@ public class SoftwareAffineRasterizerTests
y1 = System.Math.Min(dh, (int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y, b.Y), System.Math.Max(c.Y, d.Y))));
}
private static void ReferenceBlend(byte[] dst, int i, int r, int g, int b, int a)
private static void ReferenceBlend(byte[] dst, int i, int r, int g, int b, int a,
BlendKind blend = BlendKind.Alpha)
{
if (blend == BlendKind.Additive)
{
dst[i] = (byte)System.Math.Min(255, dst[i] + r * a / 255);
dst[i + 1] = (byte)System.Math.Min(255, dst[i + 1] + g * a / 255);
dst[i + 2] = (byte)System.Math.Min(255, dst[i + 2] + b * a / 255);
dst[i + 3] = (byte)System.Math.Min(255, dst[i + 3] + a);
return;
}
dst[i] = (byte)((r * a + dst[i] * (255 - a)) / 255);
dst[i + 1] = (byte)((g * a + dst[i + 1] * (255 - a)) / 255);
dst[i + 2] = (byte)((b * a + dst[i + 2] * (255 - a)) / 255);

View File

@@ -0,0 +1,95 @@
using Age.Engine.Model;
using Xunit;
public class Transform2DMathTests
{
[Fact]
public void ValueMatrixBuild_MatchesFormerArrayCompositionExactly()
{
var random = new Random(0x240);
for (int i = 0; i < 500; i++)
{
var transform = new TransformState(
Next(random, -3, 3), Next(random, -3, 3), Next(random, -3, 3),
Next(random, -500, 500), Next(random, -500, 500), Next(random, -500, 500),
Next(random, -1000, 1000), Next(random, -1000, 1000), Next(random, -1000, 1000),
Next(random, -1, 1), Next(random, -1, 1), Next(random, -1, 1),
Next(random, -360, 360));
var cycle = new RotationCycleState(
random.Next(2) != 0, random.Next(1, 20000),
Next(random, -1, 1), Next(random, -1, 1), Next(random, -1, 1),
Next(random, -360, 360));
var expected = ArrayBuild(transform, cycle);
var actual = Transform2DMath.Build(transform, cycle);
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.XX), BitConverter.DoubleToInt64Bits(actual.XX));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.XY), BitConverter.DoubleToInt64Bits(actual.XY));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.YX), BitConverter.DoubleToInt64Bits(actual.YX));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.YY), BitConverter.DoubleToInt64Bits(actual.YY));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.TX), BitConverter.DoubleToInt64Bits(actual.TX));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.TY), BitConverter.DoubleToInt64Bits(actual.TY));
}
}
[Fact]
public void ValueMatrixBuild_DoesNotAllocatePerObject()
{
var transform = new TransformState(1.25, 0.75, 1.1, 42, -17, 5, 400, 300, 9, 0, 0, 1, 37);
var cycle = new RotationCycleState(true, 9000, 0, 0, -1, 123);
_ = Transform2DMath.Build(transform, cycle); // JIT/warm-up outside the measured interval.
long before = GC.GetAllocatedBytesForCurrentThread();
Affine2D result = default;
for (int i = 0; i < 10_000; i++) result = Transform2DMath.Build(transform, cycle);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
GC.KeepAlive(result);
Assert.Equal(0, allocated);
}
private static double Next(Random random, double minimum, double maximum)
=> minimum + random.NextDouble() * (maximum - minimum);
// The former heap-array implementation, retained only as a differential oracle.
private static Affine2D ArrayBuild(TransformState t, RotationCycleState cycle)
{
double[] m = Identity();
m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ));
m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ));
m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees));
m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ));
if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees));
m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ));
return new(m[0], m[1], m[4], m[5], m[12], m[13]);
}
private static double[] Identity() => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 };
private static double[] Scale(double x, double y, double z)
=> new double[] { x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1 };
private static double[] Translation(double x, double y, double z)
=> new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 };
private static double[] AxisAngle(double x, double y, double z, double degrees)
{
double len = Math.Sqrt(x*x + y*y + z*z);
if (len < 1e-12 || Math.Abs(degrees) < 1e-12) return Identity();
x /= len; y /= len; z /= len;
double r = degrees * Math.PI / 180.0, c = Math.Cos(r), s = Math.Sin(r), q = 1-c;
return new double[] {
x*x*q+c, x*y*q+z*s, x*z*q-y*s, 0,
x*y*q-z*s, y*y*q+c, y*z*q+x*s, 0,
x*z*q+y*s, y*z*q-x*s, z*z*q+c, 0,
0,0,0,1
};
}
private static double[] Mul(double[] a, double[] b)
{
var output = new double[16];
for (int row = 0; row < 4; row++)
for (int column = 0; column < 4; column++)
for (int k = 0; k < 4; k++) output[row * 4 + column] += a[row * 4 + k] * b[k * 4 + column];
return output;
}
}