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
+25 -4
View File
@@ -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);
}
/// <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.
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);
}
/// <summary>True when the circle overlaps the rectangle.</summary>
+14 -4
View File
@@ -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;
+75 -7
View File
@@ -29,14 +29,22 @@ public struct SpriteInstance
/// <summary>
/// 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
/// (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).
/// </summary>
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;
/// <summary>Creates a batcher with the given initial capacity.</summary>
@@ -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];
}
/// <summary>Number of sprites submitted this frame.</summary>
@@ -64,18 +74,74 @@ public sealed class SpriteBatcher
}
/// <summary>
/// Sorts all submitted sprites and returns their indices in draw order.
/// Valid until the next <see cref="Clear"/>.
/// Sorts all submitted sprites (stable: equal keys keep submission order) and returns
/// their indices in draw order. Valid until the next <see cref="Clear"/>.
/// </summary>
public ReadOnlySpan<int> 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);
}
/// <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;
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);
}
}
@@ -23,6 +23,7 @@ public sealed class Texture2DRegion
public int Height => Bounds.Height;
internal readonly int TextureSortKey;
internal readonly float Diagonal;
/// <summary>Creates a region covering part of <paramref name="texture"/>.</summary>
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);
}
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>