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
+131
View File
@@ -2,6 +2,44 @@ using Microsoft.Xna.Framework;
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>
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>
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>
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()
{
var capacity = _instances.Length * 2;