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:
co-authored by
Claude Fable 5
parent
2b7d4c4fef
commit
a3e6d3bb0a
@@ -132,11 +132,18 @@ public static partial class GameAssets
|
|||||||
- Порядок сортировки: **слой → depth (или Y) → текстура**; спрайты с одной
|
- Порядок сортировки: **слой → depth (или Y) → текстура**; спрайты с одной
|
||||||
текстурой сливаются в один draw call (динамический vertex buffer + общий
|
текстурой сливаются в один draw call (динамический vertex buffer + общий
|
||||||
quad index buffer).
|
quad index buffer).
|
||||||
|
- Сортировка — **стабильный LSD radix sort**: спрайты с равным ключом сохраняют
|
||||||
|
порядок сабмита между кадрами (нет мерцания), сложность O(n); проходы по
|
||||||
|
одинаковым у всех ключей разрядам пропускаются.
|
||||||
|
- Горячий путь без тригонометрии и корней: для спрайтов без поворота SinCos
|
||||||
|
не вычисляется, радиус culling-окружности берётся из предрассчитанной
|
||||||
|
диагонали региона.
|
||||||
- Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа,
|
- Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа,
|
||||||
спрайты одного атласа батчатся автоматически.
|
спрайты одного атласа батчатся автоматически.
|
||||||
- Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе,
|
- Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе,
|
||||||
0 аллокаций на кадр. Контролируется бенчмарками (BenchmarkDotNet) и
|
0 аллокаций на кадр. Контролируется стресс-сценой в Sample: 100k сущностей
|
||||||
стресс-сценой в Sample.
|
(~61k в кадре) ≈ 124 FPS в Release. **Производительность измеряется только
|
||||||
|
в Release** — Debug-сборка медленнее в 5–6 раз (нет инлайнинга JIT).
|
||||||
|
|
||||||
## Сцены и переходы
|
## Сцены и переходы
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,5 +20,5 @@
|
|||||||
- Particles
|
- Particles
|
||||||
- UI
|
- UI
|
||||||
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
|
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
|
||||||
- Стабильная сортировка спрайтов с равным ключом (сейчас порядок не гарантирован между кадрами)
|
|
||||||
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
|
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
|
||||||
|
- Параллельная запись вершин (Parallel.For по чанкам), если упрёмся в CPU на ещё больших сценах
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using var host = new GameHost(
|
|||||||
Width = 1280,
|
Width = 1280,
|
||||||
Height = 720,
|
Height = 720,
|
||||||
ClearColor = new Color(24, 26, 32),
|
ClearColor = new Color(24, 26, 32),
|
||||||
|
VSync = false, // техдемо: показываем реальный FPS, не ограниченный частотой монитора
|
||||||
},
|
},
|
||||||
new MainScene());
|
new MainScene());
|
||||||
|
|
||||||
|
|||||||
@@ -14,19 +14,40 @@ public static class CullingMath
|
|||||||
{
|
{
|
||||||
var scaledW = regionWidth * transform.Scale.X;
|
var scaledW = regionWidth * transform.Scale.X;
|
||||||
var scaledH = regionHeight * transform.Scale.Y;
|
var scaledH = regionHeight * transform.Scale.Y;
|
||||||
|
var center = SpriteCenter(in transform, scaledW, scaledH, origin);
|
||||||
|
var radius = 0.5f * MathF.Sqrt(scaledW * scaledW + scaledH * scaledH);
|
||||||
|
return (center, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hot-path variant used by the renderer: the radius comes from the region's precomputed
|
||||||
|
/// diagonal (no square root per sprite; conservative for non-uniform scale, exact for uniform).
|
||||||
|
/// </summary>
|
||||||
|
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
|
||||||
|
in Transform2D transform, Texture2DRegion region, Vector2 origin)
|
||||||
|
{
|
||||||
|
var center = SpriteCenter(
|
||||||
|
in transform, region.Width * transform.Scale.X, region.Height * transform.Scale.Y, origin);
|
||||||
|
var maxScale = MathF.Max(MathF.Abs(transform.Scale.X), MathF.Abs(transform.Scale.Y));
|
||||||
|
return (center, 0.5f * region.Diagonal * maxScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2 SpriteCenter(in Transform2D transform, float scaledW, float scaledH, Vector2 origin)
|
||||||
|
{
|
||||||
// Offset from the pivot (= transform.Position) to the sprite's geometric center.
|
// Offset from the pivot (= transform.Position) to the sprite's geometric center.
|
||||||
var toCenter = new Vector2(
|
var toCenter = new Vector2(
|
||||||
scaledW / 2f - origin.X * transform.Scale.X,
|
scaledW / 2f - origin.X * transform.Scale.X,
|
||||||
scaledH / 2f - origin.Y * transform.Scale.Y);
|
scaledH / 2f - origin.Y * transform.Scale.Y);
|
||||||
|
|
||||||
|
if (transform.Rotation == 0f)
|
||||||
|
{
|
||||||
|
return transform.Position + toCenter;
|
||||||
|
}
|
||||||
|
|
||||||
var (sin, cos) = MathF.SinCos(transform.Rotation);
|
var (sin, cos) = MathF.SinCos(transform.Rotation);
|
||||||
var center = transform.Position + new Vector2(
|
return transform.Position + new Vector2(
|
||||||
toCenter.X * cos - toCenter.Y * sin,
|
toCenter.X * cos - toCenter.Y * sin,
|
||||||
toCenter.X * sin + toCenter.Y * cos);
|
toCenter.X * sin + toCenter.Y * cos);
|
||||||
|
|
||||||
var radius = 0.5f * MathF.Sqrt(scaledW * scaledW + scaledH * scaledH);
|
|
||||||
return (center, radius);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True when the circle overlaps the rectangle.</summary>
|
/// <summary>True when the circle overlaps the rectangle.</summary>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public sealed class Renderer2D : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var layer = Layers[sprite.Layer];
|
var layer = Layers[sprite.Layer];
|
||||||
var (center, radius) = CullingMath.SpriteBoundingCircle(transform, region.Width, region.Height, sprite.Origin);
|
var (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin);
|
||||||
|
|
||||||
if (layer.Space == LayerSpace.World &&
|
if (layer.Space == LayerSpace.World &&
|
||||||
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
|
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
|
||||||
@@ -203,9 +203,19 @@ public sealed class Renderer2D : IDisposable
|
|||||||
(v0, v1) = (v1, v0);
|
(v0, v1) = (v1, v0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Vector2 rx, ry;
|
||||||
|
if (instance.Rotation == 0f)
|
||||||
|
{
|
||||||
|
rx = new Vector2(instance.HalfSize.X, 0f);
|
||||||
|
ry = new Vector2(0f, instance.HalfSize.Y);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
var (sin, cos) = MathF.SinCos(instance.Rotation);
|
var (sin, cos) = MathF.SinCos(instance.Rotation);
|
||||||
var rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin);
|
rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin);
|
||||||
var ry = new Vector2(-instance.HalfSize.Y * sin, instance.HalfSize.Y * cos);
|
ry = new Vector2(-instance.HalfSize.Y * sin, instance.HalfSize.Y * cos);
|
||||||
|
}
|
||||||
|
|
||||||
var center = instance.Center;
|
var center = instance.Center;
|
||||||
|
|
||||||
var vertex = i * 4;
|
var vertex = i * 4;
|
||||||
|
|||||||
@@ -29,14 +29,22 @@ public struct SpriteInstance
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CPU side of the renderer: collects <see cref="SpriteInstance"/>s with their sort keys
|
/// CPU side of the renderer: collects <see cref="SpriteInstance"/>s with their sort keys
|
||||||
/// and orders them layer → depth → texture. Allocation-free after warm-up
|
/// and orders them layer → depth → texture using a stable LSD radix sort — sprites with
|
||||||
/// (arrays grow geometrically and are reused across frames).
|
/// equal keys keep their submission order across frames (no flicker), and sorting stays
|
||||||
|
/// O(n) on large counts. Allocation-free after warm-up (arrays grow geometrically and are
|
||||||
|
/// reused across frames).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SpriteBatcher
|
public sealed class SpriteBatcher
|
||||||
{
|
{
|
||||||
|
private const int RadixBits = 16;
|
||||||
|
private const int RadixSize = 1 << RadixBits;
|
||||||
|
|
||||||
private SpriteInstance[] _instances;
|
private SpriteInstance[] _instances;
|
||||||
private ulong[] _keys;
|
private ulong[] _keys;
|
||||||
|
private ulong[] _keysTemp;
|
||||||
private int[] _order;
|
private int[] _order;
|
||||||
|
private int[] _orderTemp;
|
||||||
|
private readonly int[] _histogram = new int[RadixSize];
|
||||||
private int _count;
|
private int _count;
|
||||||
|
|
||||||
/// <summary>Creates a batcher with the given initial capacity.</summary>
|
/// <summary>Creates a batcher with the given initial capacity.</summary>
|
||||||
@@ -44,7 +52,9 @@ public sealed class SpriteBatcher
|
|||||||
{
|
{
|
||||||
_instances = new SpriteInstance[initialCapacity];
|
_instances = new SpriteInstance[initialCapacity];
|
||||||
_keys = new ulong[initialCapacity];
|
_keys = new ulong[initialCapacity];
|
||||||
|
_keysTemp = new ulong[initialCapacity];
|
||||||
_order = new int[initialCapacity];
|
_order = new int[initialCapacity];
|
||||||
|
_orderTemp = new int[initialCapacity];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Number of sprites submitted this frame.</summary>
|
/// <summary>Number of sprites submitted this frame.</summary>
|
||||||
@@ -64,18 +74,74 @@ public sealed class SpriteBatcher
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sorts all submitted sprites and returns their indices in draw order.
|
/// Sorts all submitted sprites (stable: equal keys keep submission order) and returns
|
||||||
/// Valid until the next <see cref="Clear"/>.
|
/// their indices in draw order. Valid until the next <see cref="Clear"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ReadOnlySpan<int> Sort()
|
public ReadOnlySpan<int> Sort()
|
||||||
{
|
{
|
||||||
for (var i = 0; i < _count; i++)
|
var n = _count;
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
_order[i] = i;
|
_order[i] = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
Array.Sort(_keys, _order, 0, _count);
|
if (n < 2)
|
||||||
return _order.AsSpan(0, _count);
|
{
|
||||||
|
return _order.AsSpan(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Биты, различающиеся хотя бы у одной пары ключей: проходы по одинаковым
|
||||||
|
// разрядам (один слой, одна глубина) пропускаются целиком.
|
||||||
|
ulong orBits = 0, andBits = ~0UL;
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
orBits |= _keys[i];
|
||||||
|
andBits &= _keys[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
var differing = orBits ^ andBits;
|
||||||
|
var keys = _keys;
|
||||||
|
var order = _order;
|
||||||
|
var keysOut = _keysTemp;
|
||||||
|
var orderOut = _orderTemp;
|
||||||
|
|
||||||
|
for (var shift = 0; shift < 64; shift += RadixBits)
|
||||||
|
{
|
||||||
|
if ((differing >> shift & (RadixSize - 1)) == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Clear(_histogram, 0, RadixSize);
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
_histogram[(int)(keys[i] >> shift & (RadixSize - 1))]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var running = 0;
|
||||||
|
for (var digit = 0; digit < RadixSize; digit++)
|
||||||
|
{
|
||||||
|
var bucket = _histogram[digit];
|
||||||
|
_histogram[digit] = running;
|
||||||
|
running += bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var position = _histogram[(int)(keys[i] >> shift & (RadixSize - 1))]++;
|
||||||
|
keysOut[position] = keys[i];
|
||||||
|
orderOut[position] = order[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
(keys, keysOut) = (keysOut, keys);
|
||||||
|
(order, orderOut) = (orderOut, order);
|
||||||
|
}
|
||||||
|
|
||||||
|
_keys = keys;
|
||||||
|
_keysTemp = keysOut;
|
||||||
|
_order = order;
|
||||||
|
_orderTemp = orderOut;
|
||||||
|
return _order.AsSpan(0, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Returns the instance at <paramref name="index"/> (an index from <see cref="Sort"/>).</summary>
|
/// <summary>Returns the instance at <paramref name="index"/> (an index from <see cref="Sort"/>).</summary>
|
||||||
@@ -89,6 +155,8 @@ public sealed class SpriteBatcher
|
|||||||
var capacity = _instances.Length * 2;
|
var capacity = _instances.Length * 2;
|
||||||
Array.Resize(ref _instances, capacity);
|
Array.Resize(ref _instances, capacity);
|
||||||
Array.Resize(ref _keys, capacity);
|
Array.Resize(ref _keys, capacity);
|
||||||
|
Array.Resize(ref _keysTemp, capacity);
|
||||||
Array.Resize(ref _order, capacity);
|
Array.Resize(ref _order, capacity);
|
||||||
|
Array.Resize(ref _orderTemp, capacity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class Texture2DRegion
|
|||||||
public int Height => Bounds.Height;
|
public int Height => Bounds.Height;
|
||||||
|
|
||||||
internal readonly int TextureSortKey;
|
internal readonly int TextureSortKey;
|
||||||
|
internal readonly float Diagonal;
|
||||||
|
|
||||||
/// <summary>Creates a region covering part of <paramref name="texture"/>.</summary>
|
/// <summary>Creates a region covering part of <paramref name="texture"/>.</summary>
|
||||||
public Texture2DRegion(Texture2D texture, Rectangle bounds)
|
public Texture2DRegion(Texture2D texture, Rectangle bounds)
|
||||||
@@ -30,6 +31,7 @@ public sealed class Texture2DRegion
|
|||||||
Texture = texture;
|
Texture = texture;
|
||||||
Bounds = bounds;
|
Bounds = bounds;
|
||||||
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
|
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
|
||||||
|
Diagonal = MathF.Sqrt((float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
|
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
|
||||||
|
|||||||
@@ -37,6 +37,33 @@ public class CullingTests
|
|||||||
Assert.Equal(0.5f * MathF.Sqrt(800f), radius, 3);
|
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]
|
[Theory]
|
||||||
[InlineData(50f, 50f, true)] // inside
|
[InlineData(50f, 50f, true)] // inside
|
||||||
[InlineData(-4f, 50f, true)] // touching from the left (radius 5)
|
[InlineData(-4f, 50f, true)] // touching from the left (radius 5)
|
||||||
|
|||||||
@@ -39,6 +39,66 @@ public class SpriteBatcherTests
|
|||||||
Assert.Equal(99, order[0]); // последний сабмит имеет наименьший ключ
|
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]
|
[Fact]
|
||||||
public void Clear_ResetsCount_KeepsWorking()
|
public void Clear_ResetsCount_KeepsWorking()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user