Parallel rendering pipeline, ring vertex buffer, phase timings

Five optimizations measured on the 100k-entity stress scene (Release,
vsync off): 103 FPS baseline -> 297 FPS.

- Sprite submission and vertex building run on all cores above
  Renderer2DOptions.ParallelThreshold (default 8192). Work is sliced
  into 4096-entity segments: a Friflo chunk holds a whole archetype,
  so per-chunk parallelism degenerates to one thread. Segments merge
  in deterministic order, preserving radix sort stability.
- Vertex buffer is ring-written with SetDataOptions.NoOverwrite
  (GPU buffer 2x frame size); Discard only on wrap-around.
- Texture2DRegion precomputes UVs - four float divisions per sprite
  per frame removed.
- Renderer2D exposes per-phase timings (submit/sort/build/upload/draw),
  shown in the sample HUD - all further optimization is data-driven.
- Sample BounceSystem parallelized the same segmented way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 05:06:55 +03:00
co-authored by Claude Fable 5
parent af319d1276
commit e06f24a319
10 changed files with 589 additions and 132 deletions
+4 -1
View File
@@ -37,7 +37,10 @@ dotnet run --project samples/MrGameEng.Sample
- ECS-first: components are plain data (`struct` implementing `IComponent`), - ECS-first: components are plain data (`struct` implementing `IComponent`),
behavior goes into Friflo systems (`QuerySystem`), wired through `SystemRoot`. behavior goes into Friflo systems (`QuerySystem`), wired through `SystemRoot`.
No `Update()` methods on game objects, no inheritance-based entities. No `Update()` methods on game objects, no inheritance-based entities.
- Hot paths (per-frame systems) must be allocation-free. - Hot paths (per-frame systems) must be allocation-free below the renderer's parallel
threshold; above it Parallel.For scheduler overhead is the accepted trade.
A Friflo chunk holds a whole archetype — parallelize by slicing chunks into segments,
never by chunk alone. Measure in Release only, using the Renderer2D phase timings.
- Rendering: custom batcher in `Graphics` (vertex buffers, layer→depth→texture sort, - Rendering: custom batcher in `Graphics` (vertex buffers, layer→depth→texture sort,
atlas support); `SpriteBatch` is not used in engine code. Draw systems write vertices atlas support); `SpriteBatch` is not used in engine code. Draw systems write vertices
directly from Friflo chunk iteration. Orthographic camera (one active per scene), directly from Friflo chunk iteration. Orthographic camera (one active per scene),
+16 -5
View File
@@ -151,14 +151,25 @@ public static partial class GameAssets
- Сортировка — **стабильный LSD radix sort**: спрайты с равным ключом сохраняют - Сортировка — **стабильный LSD radix sort**: спрайты с равным ключом сохраняют
порядок сабмита между кадрами (нет мерцания), сложность O(n); проходы по порядок сабмита между кадрами (нет мерцания), сложность O(n); проходы по
одинаковым у всех ключей разрядам пропускаются. одинаковым у всех ключей разрядам пропускаются.
- Горячий путь без тригонометрии и корней: для спрайтов без поворота SinCos - Горячий путь без тригонометрии, корней и делений: для спрайтов без поворота
не вычисляется, радиус culling-окружности берётся из предрассчитанной SinCos не вычисляется; радиус culling-окружности и UV-координаты
диагонали региона. предрассчитаны в `Texture2DRegion`.
- **Параллелизм**: выше `Renderer2DOptions.ParallelThreshold` (по умолчанию 8192)
подача спрайтов и построение вершин идут на всех ядрах. Работа режется на
сегменты по 4096 сущностей (чанк Friflo держит весь архетип — сам по себе он
слишком крупный для распределения); сегменты сливаются в детерминированном
порядке, поэтому стабильность сортировки сохраняется. Ниже порога — прежний
однопоточный путь без аллокаций.
- Vertex buffer пишется **кольцом** (`SetDataOptions.NoOverwrite`, GPU-буфер
вдвое больше кадра): загрузка не ждёт, пока GPU дорисует предыдущий кадр;
`Discard` — только на перемотке кольца.
- **Тайминги фаз** кадра (submit/sort/build/upload/draw, мс) доступны как свойства
`Renderer2D` — выводятся в HUD стресс-сцены; оптимизации делаются только по ним.
- Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа, - Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа,
спрайты одного атласа батчатся автоматически. спрайты одного атласа батчатся автоматически.
- Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе, - Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе,
0 аллокаций на кадр. Контролируется стресс-сценой в Sample: 100k сущностей 0 аллокаций на кадр ниже порога параллелизма. Стресс-сцена Sample: 100k сущностей
(~61k в кадре) ≈ 124 FPS в Release. **Производительность измеряется только (~61k в кадре) ≈ 297 FPS в Release. **Производительность измеряется только
в Release** — Debug-сборка медленнее в 5–6 раз (нет инлайнинга JIT). в Release** — Debug-сборка медленнее в 5–6 раз (нет инлайнинга JIT).
## Сцены и переходы ## Сцены и переходы
-1
View File
@@ -23,4 +23,3 @@
- DevTools-модуль на ImGui.NET: инспектор сущностей, дебаг-панели - DevTools-модуль на ImGui.NET: инспектор сущностей, дебаг-панели
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой) - Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость) - Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
- Параллельная запись вершин (Parallel.For по чанкам), если упрёмся в CPU на ещё больших сценах
+51 -5
View File
@@ -50,16 +50,60 @@ public sealed class CameraControlSystem(Entity cameraEntity, Entity player, Inpu
} }
} }
/// <summary>Отскок сущностей со скоростью от границ мира.</summary> /// <summary>
/// Отскок сущностей со скоростью от границ мира. На больших количествах работа режется
/// на сегменты по всем ядрам (чанк Friflo держит весь архетип — сам по себе он слишком крупный).
/// </summary>
public sealed class BounceSystem(RectF bounds) : QuerySystem<Transform2D, Velocity> public sealed class BounceSystem(RectF bounds) : QuerySystem<Transform2D, Velocity>
{ {
private const int ParallelThreshold = 8192;
private const int SegmentSize = 8192;
private readonly List<(Chunk<Transform2D> Transforms, Chunk<Velocity> Velocities)> _chunks = [];
private readonly List<(int Chunk, int Start, int Length)> _segments = [];
protected override void OnUpdate() protected override void OnUpdate()
{ {
var delta = Tick.deltaTime; var delta = Tick.deltaTime;
_chunks.Clear();
var total = 0;
foreach (var (transforms, velocities, _) in Query.Chunks) foreach (var (transforms, velocities, _) in Query.Chunks)
{ {
var t = transforms.Span; _chunks.Add((transforms, velocities));
var v = velocities.Span; total += transforms.Length;
}
if (total < ParallelThreshold)
{
foreach (var (transforms, velocities) in _chunks)
{
Move(transforms, velocities, 0, transforms.Length, delta);
}
return;
}
_segments.Clear();
for (var c = 0; c < _chunks.Count; c++)
{
var length = _chunks[c].Transforms.Length;
for (var start = 0; start < length; start += SegmentSize)
{
_segments.Add((c, start, Math.Min(SegmentSize, length - start)));
}
}
Parallel.For(0, _segments.Count, i =>
{
var (chunk, start, length) = _segments[i];
Move(_chunks[chunk].Transforms, _chunks[chunk].Velocities, start, length, delta);
});
}
private void Move(Chunk<Transform2D> transforms, Chunk<Velocity> velocities, int start, int length, float delta)
{
var t = transforms.Span.Slice(start, length);
var v = velocities.Span.Slice(start, length);
for (var i = 0; i < t.Length; i++) for (var i = 0; i < t.Length; i++)
{ {
ref var position = ref t[i].Position; ref var position = ref t[i].Position;
@@ -79,7 +123,6 @@ public sealed class BounceSystem(RectF bounds) : QuerySystem<Transform2D, Veloci
} }
} }
} }
}
} }
/// <summary>Пауза (P), музыка (M), переключение сцены с переходом (Tab).</summary> /// <summary>Пауза (P), музыка (M), переключение сцены с переходом (Tab).</summary>
@@ -137,7 +180,10 @@ public sealed class StatsSystem(
context.Services.Get<GameWindow>().Title = $"MrGameEng Sample — {sceneName} | {stats}"; context.Services.Get<GameWindow>().Title = $"MrGameEng Sample — {sceneName} | {stats}";
if (hudLabel is not null) if (hudLabel is not null)
{ {
hudLabel.Text = $"{sceneName}\n{stats}"; hudLabel.Text =
$"{sceneName}\n{stats}\n" +
$"submit {renderer.SubmitMs:F2} | sort {renderer.SortMs:F2} | build {renderer.BuildMs:F2} | " +
$"upload {renderer.UploadMs:F2} | draw {renderer.DrawMs:F2} (ms)";
} }
} }
} }
+62 -1
View File
@@ -1,3 +1,4 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems; using Friflo.Engine.ECS.Systems;
namespace MrGameEng.Graphics; namespace MrGameEng.Graphics;
@@ -30,10 +31,20 @@ public sealed class CameraSystem : QuerySystem<Camera>
} }
} }
/// <summary>Submits every entity that has both <see cref="Sprite"/> and <see cref="Transform2D"/>.</summary> /// <summary>
/// Submits every entity that has both <see cref="Sprite"/> and <see cref="Transform2D"/>.
/// Above <see cref="Renderer2DOptions.ParallelThreshold"/> entities, work is split into
/// fixed-size segments processed on all cores (a Friflo chunk holds a whole archetype, so
/// chunks themselves are too coarse); results merge in segment order, preserving sort stability.
/// </summary>
public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D> public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
{ {
private const int SegmentSize = 4096;
private readonly Renderer2D _renderer; private readonly Renderer2D _renderer;
private readonly List<(Chunk<Sprite> Sprites, Chunk<Transform2D> Transforms)> _chunks = [];
private readonly List<(int Chunk, int Start, int Length)> _segments = [];
private int[] _segmentLengths = [];
/// <summary>Creates the system for <paramref name="renderer"/>.</summary> /// <summary>Creates the system for <paramref name="renderer"/>.</summary>
public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer; public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer;
@@ -41,7 +52,17 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
/// <inheritdoc /> /// <inheritdoc />
protected override void OnUpdate() protected override void OnUpdate()
{ {
_chunks.Clear();
var total = 0;
foreach (var (sprites, transforms, _) in Query.Chunks) foreach (var (sprites, transforms, _) in Query.Chunks)
{
_chunks.Add((sprites, transforms));
total += sprites.Length;
}
if (total < _renderer.ParallelThreshold)
{
foreach (var (sprites, transforms) in _chunks)
{ {
var s = sprites.Span; var s = sprites.Span;
var t = transforms.Span; var t = transforms.Span;
@@ -50,6 +71,46 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
_renderer.Submit(in t[i], in s[i]); _renderer.Submit(in t[i], in s[i]);
} }
} }
return;
}
_segments.Clear();
for (var c = 0; c < _chunks.Count; c++)
{
var length = _chunks[c].Sprites.Length;
for (var start = 0; start < length; start += SegmentSize)
{
_segments.Add((c, start, Math.Min(SegmentSize, length - start)));
}
}
if (_segmentLengths.Length < _segments.Count)
{
Array.Resize(ref _segmentLengths, _segments.Count);
}
for (var i = 0; i < _segments.Count; i++)
{
_segmentLengths[i] = _segments[i].Length;
}
_renderer.BeginChunkedSubmit(_segmentLengths.AsSpan(0, _segments.Count));
Parallel.For(0, _segments.Count, segmentIndex =>
{
var (chunk, start, length) = _segments[segmentIndex];
var (sprites, transforms) = _chunks[chunk];
var writer = _renderer.GetChunkWriter(segmentIndex);
var s = sprites.Span.Slice(start, length);
var t = transforms.Span.Slice(start, length);
for (var i = 0; i < s.Length; i++)
{
_renderer.SubmitInto(ref writer, in t[i], in s[i]);
}
_renderer.EndChunk(segmentIndex, in writer);
});
_renderer.CommitChunkedSubmit();
} }
} }
+204 -67
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
@@ -6,12 +7,17 @@ namespace MrGameEng.Graphics;
/// <summary> /// <summary>
/// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers. /// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers.
/// Per frame: <see cref="BeginFrame"/> (camera) → <see cref="Submit"/> per sprite (with culling) /// Per frame: <see cref="BeginFrame"/> (camera) → <see cref="Submit"/> per sprite (with culling)
/// → <see cref="EndFrame"/> (sort layer → depth → texture, build vertices, issue draw calls). /// → <see cref="EndFrame"/> (stable sort layer → depth → texture, build vertices, issue draw calls).
/// Above <see cref="Renderer2DOptions.ParallelThreshold"/> sprites, submission and vertex
/// building run on all cores; the vertex buffer is ring-written (NoOverwrite) to avoid GPU stalls.
/// Registered as a service; scenes attach it via <c>scene.UseRenderer2D()</c>. /// Registered as a service; scenes attach it via <c>scene.UseRenderer2D()</c>.
/// </summary> /// </summary>
public sealed class Renderer2D : IDisposable public sealed class Renderer2D : IDisposable
{ {
private const int MaxQuadsPerDraw = 8192; private const int MaxQuadsPerDraw = 8192;
private const int ParallelBlock = 4096;
private static readonly int VertexStride = VertexPositionColorTexture.VertexDeclaration.VertexStride;
/// <summary>Render layer registry. Register layers before the first frame.</summary> /// <summary>Render layer registry. Register layers before the first frame.</summary>
public LayerRegistry Layers { get; } = new(); public LayerRegistry Layers { get; } = new();
@@ -28,6 +34,21 @@ public sealed class Renderer2D : IDisposable
/// <summary>Sprites rejected by culling this frame.</summary> /// <summary>Sprites rejected by culling this frame.</summary>
public int CulledSprites { get; private set; } public int CulledSprites { get; private set; }
/// <summary>Milliseconds spent submitting sprites (BeginFrame → EndFrame) last frame.</summary>
public float SubmitMs { get; private set; }
/// <summary>Milliseconds spent sorting last frame.</summary>
public float SortMs { get; private set; }
/// <summary>Milliseconds spent building vertices last frame.</summary>
public float BuildMs { get; private set; }
/// <summary>Milliseconds spent uploading vertices to the GPU last frame.</summary>
public float UploadMs { get; private set; }
/// <summary>Milliseconds spent issuing draw calls last frame.</summary>
public float DrawMs { get; private set; }
private readonly GraphicsDevice _device; private readonly GraphicsDevice _device;
private readonly Renderer2DOptions _options; private readonly Renderer2DOptions _options;
private readonly SpriteBatcher _batcher; private readonly SpriteBatcher _batcher;
@@ -37,6 +58,9 @@ public sealed class Renderer2D : IDisposable
private VertexPositionColorTexture[] _vertices; private VertexPositionColorTexture[] _vertices;
private CameraState _screenCamera; private CameraState _screenCamera;
private bool _begun; private bool _begun;
private long _submitStartTimestamp;
private int _ringCursor;
private int _ringBaseVertex;
/// <summary>Creates the renderer. One instance per game is enough.</summary> /// <summary>Creates the renderer. One instance per game is enough.</summary>
public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null) public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null)
@@ -46,7 +70,7 @@ public sealed class Renderer2D : IDisposable
_batcher = new SpriteBatcher(_options.InitialCapacity); _batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4]; _vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer( _vertexBuffer = new DynamicVertexBuffer(
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length, BufferUsage.WriteOnly); device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly);
_effect = new BasicEffect(device) _effect = new BasicEffect(device)
{ {
@@ -58,6 +82,8 @@ public sealed class Renderer2D : IDisposable
_indexBuffer = CreateQuadIndexBuffer(device); _indexBuffer = CreateQuadIndexBuffer(device);
} }
internal int ParallelThreshold => _options.ParallelThreshold;
/// <summary>Begins a frame with the given camera. Called by <see cref="CameraSystem"/>.</summary> /// <summary>Begins a frame with the given camera. Called by <see cref="CameraSystem"/>.</summary>
public void BeginFrame(in Camera camera) public void BeginFrame(in Camera camera)
{ {
@@ -70,6 +96,7 @@ public sealed class Renderer2D : IDisposable
SubmittedSprites = 0; SubmittedSprites = 0;
CulledSprites = 0; CulledSprites = 0;
_begun = true; _begun = true;
_submitStartTimestamp = Stopwatch.GetTimestamp();
} }
/// <summary> /// <summary>
@@ -86,63 +113,86 @@ public sealed class Renderer2D : IDisposable
/// <summary>Submits one sprite. Invisible sprites (outside the camera) are culled here.</summary> /// <summary>Submits one sprite. Invisible sprites (outside the camera) are culled here.</summary>
public void Submit(in Transform2D transform, in Sprite sprite) public void Submit(in Transform2D transform, in Sprite sprite)
{ {
if (!_begun) EnsureBegun();
switch (TryBuildInstance(in transform, in sprite, out var instance, out var key))
{ {
throw new InvalidOperationException("Submit called outside BeginFrame/EndFrame (is CameraSystem registered first?)."); case SubmitResult.Visible:
} _batcher.Submit(in instance, key);
if (sprite.Region is not { } region)
{
return;
}
var layer = Layers[sprite.Layer];
var (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin);
if (layer.Space == LayerSpace.World &&
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
{
CulledSprites++;
return;
}
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
_batcher.Submit(
new SpriteInstance
{
Region = region,
Center = center,
HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f,
Rotation = transform.Rotation,
Color = sprite.Color,
Flip = sprite.Flip,
Layer = sprite.Layer.Value,
},
SpriteSortKey.Make(sprite.Layer.Value, depth, region.TextureSortKey));
SubmittedSprites++; SubmittedSprites++;
break;
case SubmitResult.Culled:
CulledSprites++;
break;
}
} }
/// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary> /// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary>
public void EndFrame() public void EndFrame()
{ {
if (!_begun) EnsureBegun();
{
throw new InvalidOperationException("EndFrame called without BeginFrame.");
}
_begun = false; _begun = false;
DrawCalls = 0; DrawCalls = 0;
var order = _batcher.Sort(); var submitEnd = Stopwatch.GetTimestamp();
if (order.Length == 0) SubmitMs = ToMs(submitEnd - _submitStartTimestamp);
SortMs = 0f;
BuildMs = 0f;
UploadMs = 0f;
DrawMs = 0f;
_batcher.Sort();
var count = _batcher.Count;
var sortEnd = Stopwatch.GetTimestamp();
SortMs = ToMs(sortEnd - submitEnd);
if (count == 0)
{ {
return; return;
} }
EnsureVertexCapacity(order.Length * 4); var order = _batcher.SortedOrder;
BuildVertices(order); EnsureVertexCapacity(count * 4);
_vertexBuffer.SetData(_vertices, 0, order.Length * 4, SetDataOptions.Discard); if (count >= _options.ParallelThreshold)
{
var blocks = (count + ParallelBlock - 1) / ParallelBlock;
Parallel.For(0, blocks, block =>
{
var end = Math.Min((block + 1) * ParallelBlock, count);
for (var i = block * ParallelBlock; i < end; i++)
{
BuildVertex(order, i);
}
});
}
else
{
for (var i = 0; i < count; i++)
{
BuildVertex(order, i);
}
}
var buildEnd = Stopwatch.GetTimestamp();
BuildMs = ToMs(buildEnd - sortEnd);
// Кольцевая запись: NoOverwrite не заставляет GPU ждать предыдущий кадр;
// Discard только на перемотке кольца.
var vertexCount = count * 4;
SetDataOptions hint;
if (_ringCursor + vertexCount <= _vertexBuffer.VertexCount)
{
_ringBaseVertex = _ringCursor;
hint = SetDataOptions.NoOverwrite;
}
else
{
_ringBaseVertex = 0;
hint = SetDataOptions.Discard;
}
_vertexBuffer.SetData(_ringBaseVertex * VertexStride, _vertices, 0, vertexCount, VertexStride, hint);
_ringCursor = _ringBaseVertex + vertexCount;
var uploadEnd = Stopwatch.GetTimestamp();
UploadMs = ToMs(uploadEnd - buildEnd);
_device.BlendState = BlendState.AlphaBlend; _device.BlendState = BlendState.AlphaBlend;
_device.SamplerStates[0] = _options.Sampler; _device.SamplerStates[0] = _options.Sampler;
@@ -151,7 +201,8 @@ public sealed class Renderer2D : IDisposable
_device.SetVertexBuffer(_vertexBuffer); _device.SetVertexBuffer(_vertexBuffer);
_device.Indices = _indexBuffer; _device.Indices = _indexBuffer;
DrawBatches(order); DrawBatches(order, count);
DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
} }
/// <summary>Converts a physical screen point to world coordinates using the current camera.</summary> /// <summary>Converts a physical screen point to world coordinates using the current camera.</summary>
@@ -168,6 +219,88 @@ public sealed class Renderer2D : IDisposable
_indexBuffer.Dispose(); _indexBuffer.Dispose();
} }
// --- Параллельная по-чанковая подача (используется SpriteRenderSystem выше порога) ----
internal void BeginChunkedSubmit(ReadOnlySpan<int> chunkLengths)
{
EnsureBegun();
_batcher.BeginChunks(chunkLengths);
}
internal SpriteChunkWriter GetChunkWriter(int chunkIndex) => _batcher.GetChunkWriter(chunkIndex);
internal void SubmitInto(ref SpriteChunkWriter writer, in Transform2D transform, in Sprite sprite)
{
switch (TryBuildInstance(in transform, in sprite, out var instance, out var key))
{
case SubmitResult.Visible:
writer.Add(in instance, key);
break;
case SubmitResult.Culled:
writer.AddCulled();
break;
}
}
internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) => _batcher.EndChunk(chunkIndex, in writer);
internal void CommitChunkedSubmit()
{
SubmittedSprites += _batcher.CommitChunks();
CulledSprites += _batcher.LastChunkCulled;
}
private enum SubmitResult
{
Skipped,
Culled,
Visible,
}
private SubmitResult TryBuildInstance(
in Transform2D transform, in Sprite sprite, out SpriteInstance instance, out ulong key)
{
instance = default;
key = 0;
if (sprite.Region is not { } region)
{
return SubmitResult.Skipped;
}
var layer = Layers[sprite.Layer];
var (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin);
if (layer.Space == LayerSpace.World &&
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
{
return SubmitResult.Culled;
}
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
instance = new SpriteInstance
{
Region = region,
Center = center,
HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f,
Rotation = transform.Rotation,
Color = sprite.Color,
Flip = sprite.Flip,
Layer = sprite.Layer.Value,
};
key = SpriteSortKey.Make(sprite.Layer.Value, depth, region.TextureSortKey);
return SubmitResult.Visible;
}
private void EnsureBegun()
{
if (!_begun)
{
throw new InvalidOperationException(
"Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?).");
}
}
private (int Width, int Height, ViewportMapping Mapping) ResolveVirtualResolution() private (int Width, int Height, ViewportMapping Mapping) ResolveVirtualResolution()
{ {
var viewport = _device.Viewport; var viewport = _device.Viewport;
@@ -180,18 +313,15 @@ public sealed class Renderer2D : IDisposable
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y)); CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y));
} }
private void BuildVertices(ReadOnlySpan<int> order) private void BuildVertex(int[] order, int i)
{
for (var i = 0; i < order.Length; i++)
{ {
ref readonly var instance = ref _batcher[order[i]]; ref readonly var instance = ref _batcher[order[i]];
var bounds = instance.Region.Bounds; var region = instance.Region;
var texture = instance.Region.Texture;
var u0 = bounds.X / (float)texture.Width; var u0 = region.U0;
var v0 = bounds.Y / (float)texture.Height; var v0 = region.V0;
var u1 = (bounds.X + bounds.Width) / (float)texture.Width; var u1 = region.U1;
var v1 = (bounds.Y + bounds.Height) / (float)texture.Height; var v1 = region.V1;
if ((instance.Flip & SpriteFlip.X) != 0) if ((instance.Flip & SpriteFlip.X) != 0)
{ {
@@ -217,16 +347,14 @@ public sealed class Renderer2D : IDisposable
} }
var center = instance.Center; var center = instance.Center;
var vertex = i * 4; var vertex = i * 4;
_vertices[vertex + 0] = Vertex(center - rx - ry, instance.Color, u0, v0); _vertices[vertex + 0] = Vertex(center - rx - ry, instance.Color, u0, v0);
_vertices[vertex + 1] = Vertex(center + rx - ry, instance.Color, u1, v0); _vertices[vertex + 1] = Vertex(center + rx - ry, instance.Color, u1, v0);
_vertices[vertex + 2] = Vertex(center - rx + ry, instance.Color, u0, v1); _vertices[vertex + 2] = Vertex(center - rx + ry, instance.Color, u0, v1);
_vertices[vertex + 3] = Vertex(center + rx + ry, instance.Color, u1, v1); _vertices[vertex + 3] = Vertex(center + rx + ry, instance.Color, u1, v1);
} }
}
private void DrawBatches(ReadOnlySpan<int> order) private void DrawBatches(int[] order, int count)
{ {
var batchStart = 0; var batchStart = 0;
ref readonly var first = ref _batcher[order[0]]; ref readonly var first = ref _batcher[order[0]];
@@ -234,11 +362,11 @@ public sealed class Renderer2D : IDisposable
var currentLayer = first.Layer; var currentLayer = first.Layer;
ApplyLayerMatrices(currentLayer); ApplyLayerMatrices(currentLayer);
for (var i = 1; i <= order.Length; i++) for (var i = 1; i <= count; i++)
{ {
Texture2D? texture = null; Texture2D? texture = null;
byte layer = 0; byte layer = 0;
if (i < order.Length) if (i < count)
{ {
ref readonly var instance = ref _batcher[order[i]]; ref readonly var instance = ref _batcher[order[i]];
texture = instance.Region.Texture; texture = instance.Region.Texture;
@@ -252,7 +380,7 @@ public sealed class Renderer2D : IDisposable
DrawRange(currentTexture, batchStart, i - batchStart); DrawRange(currentTexture, batchStart, i - batchStart);
batchStart = i; batchStart = i;
if (i < order.Length) if (i < count)
{ {
currentTexture = texture!; currentTexture = texture!;
if (layer != currentLayer) if (layer != currentLayer)
@@ -281,7 +409,8 @@ public sealed class Renderer2D : IDisposable
foreach (var pass in _effect.CurrentTechnique.Passes) foreach (var pass in _effect.CurrentTechnique.Passes)
{ {
pass.Apply(); pass.Apply();
_device.DrawIndexedPrimitives(PrimitiveType.TriangleList, firstQuad * 4, 0, quads * 2); _device.DrawIndexedPrimitives(
PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2);
DrawCalls++; DrawCalls++;
} }
@@ -292,11 +421,8 @@ public sealed class Renderer2D : IDisposable
private void EnsureVertexCapacity(int vertexCount) private void EnsureVertexCapacity(int vertexCount)
{ {
if (_vertices.Length >= vertexCount) if (_vertices.Length < vertexCount)
{ {
return;
}
var capacity = _vertices.Length; var capacity = _vertices.Length;
while (capacity < vertexCount) while (capacity < vertexCount)
{ {
@@ -304,10 +430,21 @@ public sealed class Renderer2D : IDisposable
} }
_vertices = new VertexPositionColorTexture[capacity]; _vertices = new VertexPositionColorTexture[capacity];
}
// GPU-буфер держим вдвое больше CPU-массива — кольцу нужен запас,
// чтобы NoOverwrite срабатывал чаще, чем Discard.
var wantedBuffer = _vertices.Length * 2;
if (_vertexBuffer.VertexCount < wantedBuffer)
{
_vertexBuffer.Dispose(); _vertexBuffer.Dispose();
_vertexBuffer = new DynamicVertexBuffer( _vertexBuffer = new DynamicVertexBuffer(
_device, VertexPositionColorTexture.VertexDeclaration, capacity, BufferUsage.WriteOnly); _device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly);
_ringCursor = 0;
} }
}
private static float ToMs(long timestampDelta) => (float)timestampDelta * 1000f / Stopwatch.Frequency;
private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) => private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) =>
new(new Vector3(position, 0f), color, new Vector2(u, v)); new(new Vector3(position, 0f), color, new Vector2(u, v));
@@ -17,4 +17,12 @@ public sealed class Renderer2DOptions
/// <summary>Initial sprite capacity of the batcher; grows automatically.</summary> /// <summary>Initial sprite capacity of the batcher; grows automatically.</summary>
public int InitialCapacity { get; set; } = 2048; public int InitialCapacity { get; set; } = 2048;
/// <summary>
/// Sprite count from which submission and vertex building run on all cores.
/// Below the threshold the renderer stays single-threaded and allocation-free;
/// above it, <c>Parallel.For</c> adds a few small scheduler allocations per frame.
/// Set to <see cref="int.MaxValue"/> to disable parallelism.
/// </summary>
public int ParallelThreshold { get; set; } = 8192;
} }
+131
View File
@@ -2,6 +2,44 @@ using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics; namespace MrGameEng.Graphics;
/// <summary>
/// Writer for one ECS chunk during parallel submission: appends accepted sprites compactly
/// into the chunk's scratch range and counts culled ones. Used by exactly one thread.
/// </summary>
public struct SpriteChunkWriter
{
private readonly SpriteInstance[] _instances;
private readonly ulong[] _keys;
private readonly int _offset;
/// <summary>Sprites accepted into this chunk's range.</summary>
public int Count { get; private set; }
/// <summary>Sprites rejected by culling in this chunk.</summary>
public int Culled { get; private set; }
internal SpriteChunkWriter(SpriteInstance[] instances, ulong[] keys, int offset)
{
_instances = instances;
_keys = keys;
_offset = offset;
Count = 0;
Culled = 0;
}
/// <summary>Appends one accepted sprite.</summary>
public void Add(in SpriteInstance instance, ulong sortKey)
{
var index = _offset + Count;
_instances[index] = instance;
_keys[index] = sortKey;
Count++;
}
/// <summary>Counts one culled sprite.</summary>
public void AddCulled() => Culled++;
}
/// <summary>One sprite queued for rendering this frame.</summary> /// <summary>One sprite queued for rendering this frame.</summary>
public struct SpriteInstance public struct SpriteInstance
{ {
@@ -147,9 +185,102 @@ public sealed class SpriteBatcher
/// <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>
public ref readonly SpriteInstance this[int index] => ref _instances[index]; public ref readonly SpriteInstance this[int index] => ref _instances[index];
/// <summary>The sorted index array after <see cref="Sort"/> (first <see cref="Count"/> entries are valid).</summary>
internal int[] SortedOrder => _order;
/// <summary>Resets the batcher for the next frame. Keeps allocated capacity.</summary> /// <summary>Resets the batcher for the next frame. Keeps allocated capacity.</summary>
public void Clear() => _count = 0; public void Clear() => _count = 0;
// --- Параллельная по-чанковая подача -------------------------------------------------
// Каждый чанк ECS пишет в свой непересекающийся диапазон scratch-арены, затем диапазоны
// сливаются в порядке чанков — порядок детерминирован, стабильность сортировки сохраняется.
private SpriteInstance[] _scratchInstances = [];
private ulong[] _scratchKeys = [];
private int[] _chunkOffsets = [];
private int[] _chunkVisible = [];
private int[] _chunkCulled = [];
private int _chunkCount;
/// <summary>Total culled count reported by chunk writers in the last <see cref="CommitChunks"/>.</summary>
public int LastChunkCulled { get; private set; }
/// <summary>
/// Prepares the scratch arena for chunked submission. <paramref name="chunkLengths"/> are
/// the entity counts of each ECS chunk, in iteration order.
/// </summary>
public void BeginChunks(ReadOnlySpan<int> chunkLengths)
{
_chunkCount = chunkLengths.Length;
if (_chunkOffsets.Length < _chunkCount)
{
Array.Resize(ref _chunkOffsets, _chunkCount);
Array.Resize(ref _chunkVisible, _chunkCount);
Array.Resize(ref _chunkCulled, _chunkCount);
}
var total = 0;
for (var i = 0; i < _chunkCount; i++)
{
_chunkOffsets[i] = total;
total += chunkLengths[i];
}
if (_scratchInstances.Length < total)
{
_scratchInstances = new SpriteInstance[total];
_scratchKeys = new ulong[total];
}
}
/// <summary>Returns the writer for chunk <paramref name="chunkIndex"/>. Each chunk is written by one thread.</summary>
public SpriteChunkWriter GetChunkWriter(int chunkIndex) =>
new(_scratchInstances, _scratchKeys, _chunkOffsets[chunkIndex]);
/// <summary>Records the writer's results. Called by the same thread that filled the writer.</summary>
public void EndChunk(int chunkIndex, in SpriteChunkWriter writer)
{
_chunkVisible[chunkIndex] = writer.Count;
_chunkCulled[chunkIndex] = writer.Culled;
}
/// <summary>
/// Merges all chunk ranges into the main arrays in chunk order (deterministic, keeps
/// sort stability) and returns the number of accepted sprites.
/// </summary>
public int CommitChunks()
{
var visible = 0;
var culled = 0;
for (var i = 0; i < _chunkCount; i++)
{
visible += _chunkVisible[i];
culled += _chunkCulled[i];
}
while (_count + visible > _instances.Length)
{
Grow();
}
for (var i = 0; i < _chunkCount; i++)
{
var length = _chunkVisible[i];
if (length == 0)
{
continue;
}
Array.Copy(_scratchInstances, _chunkOffsets[i], _instances, _count, length);
Array.Copy(_scratchKeys, _chunkOffsets[i], _keys, _count, length);
_count += length;
}
LastChunkCulled = culled;
_chunkCount = 0;
return visible;
}
private void Grow() private void Grow()
{ {
var capacity = _instances.Length * 2; var capacity = _instances.Length * 2;
+14
View File
@@ -24,6 +24,10 @@ public sealed class Texture2DRegion
internal readonly int TextureSortKey; internal readonly int TextureSortKey;
internal readonly float Diagonal; internal readonly float Diagonal;
internal readonly float U0;
internal readonly float V0;
internal readonly float U1;
internal readonly float V1;
/// <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)
@@ -32,6 +36,16 @@ public sealed class Texture2DRegion
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); Diagonal = MathF.Sqrt((float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height);
// UV предрассчитаны один раз — в кадре на каждый спрайт экономятся 4 деления.
// texture может быть null только в headless-тестах.
if (texture is not null)
{
U0 = bounds.X / (float)texture.Width;
V0 = bounds.Y / (float)texture.Height;
U1 = (bounds.X + bounds.Width) / (float)texture.Width;
V1 = (bounds.Y + bounds.Height) / (float)texture.Height;
}
} }
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary> /// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
@@ -99,6 +99,53 @@ public class SpriteBatcherTests
} }
} }
[Fact]
public void ChunkedSubmit_MergesInChunkOrder_AndCountsCulled()
{
var batcher = new SpriteBatcher();
var key = SpriteSortKey.Make(0, 0f, 0); // одинаковый ключ — порядок задаётся слиянием чанков
batcher.BeginChunks([3, 2]);
var writer1 = batcher.GetChunkWriter(1); // чанки могут заполняться в любом порядке (параллельно)
writer1.Add(Instance(10), key);
writer1.AddCulled();
batcher.EndChunk(1, in writer1);
var writer0 = batcher.GetChunkWriter(0);
writer0.Add(Instance(1), key);
writer0.Add(Instance(2), key);
batcher.EndChunk(0, in writer0);
var accepted = batcher.CommitChunks();
Assert.Equal(3, accepted);
Assert.Equal(1, batcher.LastChunkCulled);
var order = batcher.Sort();
Assert.Equal(1, batcher[order[0]].Layer); // сначала чанк 0...
Assert.Equal(2, batcher[order[1]].Layer);
Assert.Equal(10, batcher[order[2]].Layer); // ...затем чанк 1 — стабильно
}
[Fact]
public void ChunkedSubmit_GrowsMainArrays_WhenNeeded()
{
var batcher = new SpriteBatcher(initialCapacity: 2);
batcher.BeginChunks([10]);
var writer = batcher.GetChunkWriter(0);
for (byte i = 0; i < 10; i++)
{
writer.Add(Instance(i), i);
}
batcher.EndChunk(0, in writer);
Assert.Equal(10, batcher.CommitChunks());
Assert.Equal(10, batcher.Count);
Assert.Equal(0, batcher[batcher.Sort()[0]].Layer);
}
[Fact] [Fact]
public void Clear_ResetsCount_KeepsWorking() public void Clear_ResetsCount_KeepsWorking()
{ {