Stable radix sprite sort and render hot-path optimizations

SpriteBatcher now sorts with a stable LSD radix sort: equal-key sprites
keep submission order across frames (no flicker) and passes over digits
identical in all keys are skipped, making the common single-layer case
nearly free. Hot paths avoid per-sprite trig and square roots: SinCos is
skipped for unrotated sprites and the culling radius comes from the
region's precomputed diagonal.

Stress scene (100k entities, ~61k on screen, Release): 103 -> 124 FPS.
Sample now runs with VSync off to show real frame rates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 04:39:17 +03:00
co-authored by Claude Fable 5
parent 2b7d4c4fef
commit a3e6d3bb0a
9 changed files with 214 additions and 18 deletions
@@ -37,6 +37,33 @@ public class CullingTests
Assert.Equal(0.5f * MathF.Sqrt(800f), radius, 3);
}
[Fact]
public void BoundingCircle_RegionOverload_MatchesSizeOverload_ForUniformScale()
{
var region = new Texture2DRegion(null!, new Rectangle(0, 0, 48, 24));
var transform = new Transform2D(new Vector2(10f, 20f), rotation: 0.6f, scale: new Vector2(1.5f, 1.5f));
var origin = new Vector2(5f, 7f);
var (centerA, radiusA) = CullingMath.SpriteBoundingCircle(transform, 48f, 24f, origin);
var (centerB, radiusB) = CullingMath.SpriteBoundingCircle(in transform, region, origin);
Assert.Equal(centerA.X, centerB.X, 3);
Assert.Equal(centerA.Y, centerB.Y, 3);
Assert.Equal(radiusA, radiusB, 3);
}
[Fact]
public void BoundingCircle_RegionOverload_IsConservative_ForNonUniformScale()
{
var region = new Texture2DRegion(null!, new Rectangle(0, 0, 100, 10));
var transform = new Transform2D(Vector2.Zero, scale: new Vector2(1f, 3f));
var (_, exact) = CullingMath.SpriteBoundingCircle(transform, 100f, 10f, Vector2.Zero);
var (_, conservative) = CullingMath.SpriteBoundingCircle(in transform, region, Vector2.Zero);
Assert.True(conservative >= exact);
}
[Theory]
[InlineData(50f, 50f, true)] // inside
[InlineData(-4f, 50f, true)] // touching from the left (radius 5)
@@ -39,6 +39,66 @@ public class SpriteBatcherTests
Assert.Equal(99, order[0]); // последний сабмит имеет наименьший ключ
}
[Fact]
public void Sort_IsStable_EqualKeysKeepSubmissionOrder()
{
var batcher = new SpriteBatcher();
var key = SpriteSortKey.Make(1, 5f, 7);
for (byte i = 0; i < 50; i++)
{
batcher.Submit(Instance(i), key); // одинаковый ключ у всех
}
var order = batcher.Sort();
for (var i = 0; i < 50; i++)
{
Assert.Equal(i, order[i]);
}
}
[Fact]
public void Sort_IsStable_WithinMixedKeys()
{
var batcher = new SpriteBatcher();
var keyA = SpriteSortKey.Make(0, 0f, 1);
var keyB = SpriteSortKey.Make(2, 3f, 9);
// Чередуем два ключа: внутри каждой группы порядок сабмита должен сохраниться.
for (byte i = 0; i < 20; i++)
{
batcher.Submit(Instance(i), i % 2 == 0 ? keyA : keyB);
}
var order = batcher.Sort();
var expectedA = new[] { 0, 2, 4, 6, 8, 10, 12, 14, 16, 18 };
var expectedB = new[] { 1, 3, 5, 7, 9, 11, 13, 15, 17, 19 };
Assert.Equal(expectedA, order[..10].ToArray());
Assert.Equal(expectedB, order[10..].ToArray());
}
[Fact]
public void Sort_LargeRandomSet_FullyOrdered()
{
var batcher = new SpriteBatcher(initialCapacity: 16);
var random = new Random(123);
var keys = new ulong[5000];
for (var i = 0; i < keys.Length; i++)
{
keys[i] = (ulong)random.NextInt64();
batcher.Submit(Instance(0), keys[i]);
}
var order = batcher.Sort();
for (var i = 1; i < order.Length; i++)
{
Assert.True(keys[order[i - 1]] <= keys[order[i]]);
}
}
[Fact]
public void Clear_ResetsCount_KeepsWorking()
{