From a3e6d3bb0a010cae094da7f045c41eb4c49ca9ba Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 11 Jun 2026 04:39:17 +0300 Subject: [PATCH] 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 --- docs/architecture.md | 11 ++- docs/roadmap.md | 2 +- samples/MrGameEng.Sample/Program.cs | 1 + src/MrGameEng.Graphics/CullingMath.cs | 29 ++++++- src/MrGameEng.Graphics/Renderer2D.cs | 18 +++- src/MrGameEng.Graphics/SpriteBatcher.cs | 82 +++++++++++++++++-- src/MrGameEng.Graphics/Texture2DRegion.cs | 2 + .../MrGameEng.Graphics.Tests/CullingTests.cs | 27 ++++++ .../SpriteBatcherTests.cs | 60 ++++++++++++++ 9 files changed, 214 insertions(+), 18 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 73905cc..276ba19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -132,11 +132,18 @@ public static partial class GameAssets - Порядок сортировки: **слой → depth (или Y) → текстура**; спрайты с одной текстурой сливаются в один draw call (динамический vertex buffer + общий quad index buffer). +- Сортировка — **стабильный LSD radix sort**: спрайты с равным ключом сохраняют + порядок сабмита между кадрами (нет мерцания), сложность O(n); проходы по + одинаковым у всех ключей разрядам пропускаются. +- Горячий путь без тригонометрии и корней: для спрайтов без поворота SinCos + не вычисляется, радиус culling-окружности берётся из предрассчитанной + диагонали региона. - Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа, спрайты одного атласа батчатся автоматически. - Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе, - 0 аллокаций на кадр. Контролируется бенчмарками (BenchmarkDotNet) и - стресс-сценой в Sample. + 0 аллокаций на кадр. Контролируется стресс-сценой в Sample: 100k сущностей + (~61k в кадре) ≈ 124 FPS в Release. **Производительность измеряется только + в Release** — Debug-сборка медленнее в 5–6 раз (нет инлайнинга JIT). ## Сцены и переходы diff --git a/docs/roadmap.md b/docs/roadmap.md index 942cd28..c8a05c6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -20,5 +20,5 @@ - Particles - UI - Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой) -- Стабильная сортировка спрайтов с равным ключом (сейчас порядок не гарантирован между кадрами) - Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость) +- Параллельная запись вершин (Parallel.For по чанкам), если упрёмся в CPU на ещё больших сценах diff --git a/samples/MrGameEng.Sample/Program.cs b/samples/MrGameEng.Sample/Program.cs index d4009a1..15145c3 100644 --- a/samples/MrGameEng.Sample/Program.cs +++ b/samples/MrGameEng.Sample/Program.cs @@ -9,6 +9,7 @@ using var host = new GameHost( Width = 1280, Height = 720, ClearColor = new Color(24, 26, 32), + VSync = false, // техдемо: показываем реальный FPS, не ограниченный частотой монитора }, new MainScene()); diff --git a/src/MrGameEng.Graphics/CullingMath.cs b/src/MrGameEng.Graphics/CullingMath.cs index e1dee80..b75c334 100644 --- a/src/MrGameEng.Graphics/CullingMath.cs +++ b/src/MrGameEng.Graphics/CullingMath.cs @@ -14,19 +14,40 @@ public static class CullingMath { var scaledW = regionWidth * transform.Scale.X; 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); + } + /// + /// 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). + /// + 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. var toCenter = new Vector2( scaledW / 2f - origin.X * transform.Scale.X, scaledH / 2f - origin.Y * transform.Scale.Y); + if (transform.Rotation == 0f) + { + return transform.Position + toCenter; + } + 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 * sin + toCenter.Y * cos); - - var radius = 0.5f * MathF.Sqrt(scaledW * scaledW + scaledH * scaledH); - return (center, radius); } /// True when the circle overlaps the rectangle. diff --git a/src/MrGameEng.Graphics/Renderer2D.cs b/src/MrGameEng.Graphics/Renderer2D.cs index ec8f5d7..5ee91e3 100644 --- a/src/MrGameEng.Graphics/Renderer2D.cs +++ b/src/MrGameEng.Graphics/Renderer2D.cs @@ -97,7 +97,7 @@ public sealed class Renderer2D : IDisposable } 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 && !CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect)) @@ -203,9 +203,19 @@ public sealed class Renderer2D : IDisposable (v0, v1) = (v1, v0); } - var (sin, cos) = MathF.SinCos(instance.Rotation); - var rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin); - var ry = new Vector2(-instance.HalfSize.Y * sin, instance.HalfSize.Y * cos); + 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); + rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin); + ry = new Vector2(-instance.HalfSize.Y * sin, instance.HalfSize.Y * cos); + } + var center = instance.Center; var vertex = i * 4; diff --git a/src/MrGameEng.Graphics/SpriteBatcher.cs b/src/MrGameEng.Graphics/SpriteBatcher.cs index da85c53..d69747f 100644 --- a/src/MrGameEng.Graphics/SpriteBatcher.cs +++ b/src/MrGameEng.Graphics/SpriteBatcher.cs @@ -29,14 +29,22 @@ public struct SpriteInstance /// /// CPU side of the renderer: collects s with their sort keys -/// and orders them layer → depth → texture. Allocation-free after warm-up -/// (arrays grow geometrically and are reused across frames). +/// and orders them layer → depth → texture using a stable LSD radix sort — sprites with +/// 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). /// public sealed class SpriteBatcher { + private const int RadixBits = 16; + private const int RadixSize = 1 << RadixBits; + private SpriteInstance[] _instances; private ulong[] _keys; + private ulong[] _keysTemp; private int[] _order; + private int[] _orderTemp; + private readonly int[] _histogram = new int[RadixSize]; private int _count; /// Creates a batcher with the given initial capacity. @@ -44,7 +52,9 @@ public sealed class SpriteBatcher { _instances = new SpriteInstance[initialCapacity]; _keys = new ulong[initialCapacity]; + _keysTemp = new ulong[initialCapacity]; _order = new int[initialCapacity]; + _orderTemp = new int[initialCapacity]; } /// Number of sprites submitted this frame. @@ -64,18 +74,74 @@ public sealed class SpriteBatcher } /// - /// Sorts all submitted sprites and returns their indices in draw order. - /// Valid until the next . + /// Sorts all submitted sprites (stable: equal keys keep submission order) and returns + /// their indices in draw order. Valid until the next . /// public ReadOnlySpan Sort() { - for (var i = 0; i < _count; i++) + var n = _count; + for (var i = 0; i < n; i++) { _order[i] = i; } - Array.Sort(_keys, _order, 0, _count); - return _order.AsSpan(0, _count); + if (n < 2) + { + 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); } /// Returns the instance at (an index from ). @@ -89,6 +155,8 @@ public sealed class SpriteBatcher var capacity = _instances.Length * 2; Array.Resize(ref _instances, capacity); Array.Resize(ref _keys, capacity); + Array.Resize(ref _keysTemp, capacity); Array.Resize(ref _order, capacity); + Array.Resize(ref _orderTemp, capacity); } } diff --git a/src/MrGameEng.Graphics/Texture2DRegion.cs b/src/MrGameEng.Graphics/Texture2DRegion.cs index 6483b86..5ba23d7 100644 --- a/src/MrGameEng.Graphics/Texture2DRegion.cs +++ b/src/MrGameEng.Graphics/Texture2DRegion.cs @@ -23,6 +23,7 @@ public sealed class Texture2DRegion public int Height => Bounds.Height; internal readonly int TextureSortKey; + internal readonly float Diagonal; /// Creates a region covering part of . public Texture2DRegion(Texture2D texture, Rectangle bounds) @@ -30,6 +31,7 @@ public sealed class Texture2DRegion Texture = texture; Bounds = bounds; TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture); + Diagonal = MathF.Sqrt((float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height); } /// Creates a region covering the whole . diff --git a/tests/MrGameEng.Graphics.Tests/CullingTests.cs b/tests/MrGameEng.Graphics.Tests/CullingTests.cs index f68231b..bcf5e0c 100644 --- a/tests/MrGameEng.Graphics.Tests/CullingTests.cs +++ b/tests/MrGameEng.Graphics.Tests/CullingTests.cs @@ -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) diff --git a/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs b/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs index ae89194..8fcc727 100644 --- a/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs +++ b/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs @@ -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() {