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:
co-authored by
Claude Fable 5
parent
af319d1276
commit
e06f24a319
@@ -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
@@ -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).
|
||||||
|
|
||||||
## Сцены и переходы
|
## Сцены и переходы
|
||||||
|
|||||||
@@ -23,4 +23,3 @@
|
|||||||
- DevTools-модуль на ImGui.NET: инспектор сущностей, дебаг-панели
|
- DevTools-модуль на ImGui.NET: инспектор сущностей, дебаг-панели
|
||||||
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
|
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
|
||||||
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
|
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
|
||||||
- Параллельная запись вершин (Parallel.For по чанкам), если упрёмся в CPU на ещё больших сценах
|
|
||||||
|
|||||||
@@ -50,33 +50,76 @@ 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;
|
||||||
for (var i = 0; i < t.Length; i++)
|
}
|
||||||
|
|
||||||
|
if (total < ParallelThreshold)
|
||||||
|
{
|
||||||
|
foreach (var (transforms, velocities) in _chunks)
|
||||||
{
|
{
|
||||||
ref var position = ref t[i].Position;
|
Move(transforms, velocities, 0, transforms.Length, delta);
|
||||||
ref var velocity = ref v[i].Value;
|
}
|
||||||
position += velocity * delta;
|
|
||||||
|
|
||||||
if (position.X < bounds.Left || position.X > bounds.Right)
|
return;
|
||||||
{
|
}
|
||||||
velocity.X = -velocity.X;
|
|
||||||
position.X = Math.Clamp(position.X, bounds.Left, bounds.Right);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (position.Y < bounds.Top || position.Y > bounds.Bottom)
|
_segments.Clear();
|
||||||
{
|
for (var c = 0; c < _chunks.Count; c++)
|
||||||
velocity.Y = -velocity.Y;
|
{
|
||||||
position.Y = Math.Clamp(position.Y, bounds.Top, bounds.Bottom);
|
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++)
|
||||||
|
{
|
||||||
|
ref var position = ref t[i].Position;
|
||||||
|
ref var velocity = ref v[i].Value;
|
||||||
|
position += velocity * delta;
|
||||||
|
|
||||||
|
if (position.X < bounds.Left || position.X > bounds.Right)
|
||||||
|
{
|
||||||
|
velocity.X = -velocity.X;
|
||||||
|
position.X = Math.Clamp(position.X, bounds.Left, bounds.Right);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.Y < bounds.Top || position.Y > bounds.Bottom)
|
||||||
|
{
|
||||||
|
velocity.Y = -velocity.Y;
|
||||||
|
position.Y = Math.Clamp(position.Y, bounds.Top, bounds.Bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +52,65 @@ 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)
|
||||||
{
|
{
|
||||||
var s = sprites.Span;
|
_chunks.Add((sprites, transforms));
|
||||||
var t = transforms.Span;
|
total += sprites.Length;
|
||||||
for (var i = 0; i < s.Length; i++)
|
}
|
||||||
|
|
||||||
|
if (total < _renderer.ParallelThreshold)
|
||||||
|
{
|
||||||
|
foreach (var (sprites, transforms) in _chunks)
|
||||||
{
|
{
|
||||||
_renderer.Submit(in t[i], in s[i]);
|
var s = sprites.Span;
|
||||||
|
var t = transforms.Span;
|
||||||
|
for (var i = 0; i < s.Length; 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
SubmittedSprites++;
|
||||||
|
break;
|
||||||
|
case SubmitResult.Culled:
|
||||||
|
CulledSprites++;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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,53 +313,48 @@ 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]];
|
||||||
|
var region = instance.Region;
|
||||||
|
|
||||||
|
var u0 = region.U0;
|
||||||
|
var v0 = region.V0;
|
||||||
|
var u1 = region.U1;
|
||||||
|
var v1 = region.V1;
|
||||||
|
|
||||||
|
if ((instance.Flip & SpriteFlip.X) != 0)
|
||||||
{
|
{
|
||||||
ref readonly var instance = ref _batcher[order[i]];
|
(u0, u1) = (u1, u0);
|
||||||
var bounds = instance.Region.Bounds;
|
|
||||||
var texture = instance.Region.Texture;
|
|
||||||
|
|
||||||
var u0 = bounds.X / (float)texture.Width;
|
|
||||||
var v0 = bounds.Y / (float)texture.Height;
|
|
||||||
var u1 = (bounds.X + bounds.Width) / (float)texture.Width;
|
|
||||||
var v1 = (bounds.Y + bounds.Height) / (float)texture.Height;
|
|
||||||
|
|
||||||
if ((instance.Flip & SpriteFlip.X) != 0)
|
|
||||||
{
|
|
||||||
(u0, u1) = (u1, u0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((instance.Flip & SpriteFlip.Y) != 0)
|
|
||||||
{
|
|
||||||
(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);
|
|
||||||
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;
|
|
||||||
_vertices[vertex + 0] = Vertex(center - rx - ry, instance.Color, u0, 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 + 3] = Vertex(center + rx + ry, instance.Color, u1, v1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((instance.Flip & SpriteFlip.Y) != 0)
|
||||||
|
{
|
||||||
|
(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);
|
||||||
|
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;
|
||||||
|
_vertices[vertex + 0] = Vertex(center - rx - ry, instance.Color, u0, 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 + 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,23 +421,31 @@ 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;
|
||||||
|
while (capacity < vertexCount)
|
||||||
|
{
|
||||||
|
capacity *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
_vertices = new VertexPositionColorTexture[capacity];
|
||||||
}
|
}
|
||||||
|
|
||||||
var capacity = _vertices.Length;
|
// GPU-буфер держим вдвое больше CPU-массива — кольцу нужен запас,
|
||||||
while (capacity < vertexCount)
|
// чтобы NoOverwrite срабатывал чаще, чем Discard.
|
||||||
|
var wantedBuffer = _vertices.Length * 2;
|
||||||
|
if (_vertexBuffer.VertexCount < wantedBuffer)
|
||||||
{
|
{
|
||||||
capacity *= 2;
|
_vertexBuffer.Dispose();
|
||||||
|
_vertexBuffer = new DynamicVertexBuffer(
|
||||||
|
_device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly);
|
||||||
|
_ringCursor = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_vertices = new VertexPositionColorTexture[capacity];
|
|
||||||
_vertexBuffer.Dispose();
|
|
||||||
_vertexBuffer = new DynamicVertexBuffer(
|
|
||||||
_device, VertexPositionColorTexture.VertexDeclaration, capacity, BufferUsage.WriteOnly);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user