diff --git a/CLAUDE.md b/CLAUDE.md
index 2735945..c065b94 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,7 +37,10 @@ dotnet run --project samples/MrGameEng.Sample
- ECS-first: components are plain data (`struct` implementing `IComponent`),
behavior goes into Friflo systems (`QuerySystem`), wired through `SystemRoot`.
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,
atlas support); `SpriteBatch` is not used in engine code. Draw systems write vertices
directly from Friflo chunk iteration. Orthographic camera (one active per scene),
diff --git a/docs/architecture.md b/docs/architecture.md
index eacf85a..42dc1e8 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -151,14 +151,25 @@ public static partial class GameAssets
- Сортировка — **стабильный LSD radix sort**: спрайты с равным ключом сохраняют
порядок сабмита между кадрами (нет мерцания), сложность 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` хранит регион атласа,
спрайты одного атласа батчатся автоматически.
- Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе,
- 0 аллокаций на кадр. Контролируется стресс-сценой в Sample: 100k сущностей
- (~61k в кадре) ≈ 124 FPS в Release. **Производительность измеряется только
+ 0 аллокаций на кадр ниже порога параллелизма. Стресс-сцена Sample: 100k сущностей
+ (~61k в кадре) ≈ 297 FPS в Release. **Производительность измеряется только
в Release** — Debug-сборка медленнее в 5–6 раз (нет инлайнинга JIT).
## Сцены и переходы
diff --git a/docs/roadmap.md b/docs/roadmap.md
index b3ed178..1123e81 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -23,4 +23,3 @@
- DevTools-модуль на ImGui.NET: инспектор сущностей, дебаг-панели
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
-- Параллельная запись вершин (Parallel.For по чанкам), если упрёмся в CPU на ещё больших сценах
diff --git a/samples/MrGameEng.Sample/SampleSystems.cs b/samples/MrGameEng.Sample/SampleSystems.cs
index c104f8c..045d914 100644
--- a/samples/MrGameEng.Sample/SampleSystems.cs
+++ b/samples/MrGameEng.Sample/SampleSystems.cs
@@ -50,33 +50,76 @@ public sealed class CameraControlSystem(Entity cameraEntity, Entity player, Inpu
}
}
-/// Отскок сущностей со скоростью от границ мира.
+///
+/// Отскок сущностей со скоростью от границ мира. На больших количествах работа режется
+/// на сегменты по всем ядрам (чанк Friflo держит весь архетип — сам по себе он слишком крупный).
+///
public sealed class BounceSystem(RectF bounds) : QuerySystem
{
+ private const int ParallelThreshold = 8192;
+ private const int SegmentSize = 8192;
+
+ private readonly List<(Chunk Transforms, Chunk Velocities)> _chunks = [];
+ private readonly List<(int Chunk, int Start, int Length)> _segments = [];
+
protected override void OnUpdate()
{
var delta = Tick.deltaTime;
+ _chunks.Clear();
+ var total = 0;
foreach (var (transforms, velocities, _) in Query.Chunks)
{
- var t = transforms.Span;
- var v = velocities.Span;
- for (var i = 0; i < t.Length; i++)
+ _chunks.Add((transforms, velocities));
+ total += transforms.Length;
+ }
+
+ if (total < ParallelThreshold)
+ {
+ foreach (var (transforms, velocities) in _chunks)
{
- ref var position = ref t[i].Position;
- ref var velocity = ref v[i].Value;
- position += velocity * delta;
+ Move(transforms, velocities, 0, transforms.Length, delta);
+ }
- if (position.X < bounds.Left || position.X > bounds.Right)
- {
- velocity.X = -velocity.X;
- position.X = Math.Clamp(position.X, bounds.Left, bounds.Right);
- }
+ return;
+ }
- if (position.Y < bounds.Top || position.Y > bounds.Bottom)
- {
- velocity.Y = -velocity.Y;
- position.Y = Math.Clamp(position.Y, bounds.Top, bounds.Bottom);
- }
+ _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 transforms, Chunk 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().Title = $"MrGameEng Sample — {sceneName} | {stats}";
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)";
}
}
}
diff --git a/src/MrGameEng.Graphics/RenderSystems.cs b/src/MrGameEng.Graphics/RenderSystems.cs
index 20cf180..cbf4957 100644
--- a/src/MrGameEng.Graphics/RenderSystems.cs
+++ b/src/MrGameEng.Graphics/RenderSystems.cs
@@ -1,3 +1,4 @@
+using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
namespace MrGameEng.Graphics;
@@ -30,10 +31,20 @@ public sealed class CameraSystem : QuerySystem
}
}
-/// Submits every entity that has both and .
+///
+/// Submits every entity that has both and .
+/// Above 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.
+///
public sealed class SpriteRenderSystem : QuerySystem
{
+ private const int SegmentSize = 4096;
+
private readonly Renderer2D _renderer;
+ private readonly List<(Chunk Sprites, Chunk Transforms)> _chunks = [];
+ private readonly List<(int Chunk, int Start, int Length)> _segments = [];
+ private int[] _segmentLengths = [];
/// Creates the system for .
public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer;
@@ -41,15 +52,65 @@ public sealed class SpriteRenderSystem : QuerySystem
///
protected override void OnUpdate()
{
+ _chunks.Clear();
+ var total = 0;
foreach (var (sprites, transforms, _) in Query.Chunks)
{
- var s = sprites.Span;
- var t = transforms.Span;
- for (var i = 0; i < s.Length; i++)
+ _chunks.Add((sprites, transforms));
+ total += sprites.Length;
+ }
+
+ 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();
}
}
diff --git a/src/MrGameEng.Graphics/Renderer2D.cs b/src/MrGameEng.Graphics/Renderer2D.cs
index 5ee91e3..be8076f 100644
--- a/src/MrGameEng.Graphics/Renderer2D.cs
+++ b/src/MrGameEng.Graphics/Renderer2D.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
@@ -6,12 +7,17 @@ namespace MrGameEng.Graphics;
///
/// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers.
/// Per frame: (camera) → per sprite (with culling)
-/// → (sort layer → depth → texture, build vertices, issue draw calls).
+/// → (stable sort layer → depth → texture, build vertices, issue draw calls).
+/// Above 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 scene.UseRenderer2D().
///
public sealed class Renderer2D : IDisposable
{
private const int MaxQuadsPerDraw = 8192;
+ private const int ParallelBlock = 4096;
+
+ private static readonly int VertexStride = VertexPositionColorTexture.VertexDeclaration.VertexStride;
/// Render layer registry. Register layers before the first frame.
public LayerRegistry Layers { get; } = new();
@@ -28,6 +34,21 @@ public sealed class Renderer2D : IDisposable
/// Sprites rejected by culling this frame.
public int CulledSprites { get; private set; }
+ /// Milliseconds spent submitting sprites (BeginFrame → EndFrame) last frame.
+ public float SubmitMs { get; private set; }
+
+ /// Milliseconds spent sorting last frame.
+ public float SortMs { get; private set; }
+
+ /// Milliseconds spent building vertices last frame.
+ public float BuildMs { get; private set; }
+
+ /// Milliseconds spent uploading vertices to the GPU last frame.
+ public float UploadMs { get; private set; }
+
+ /// Milliseconds spent issuing draw calls last frame.
+ public float DrawMs { get; private set; }
+
private readonly GraphicsDevice _device;
private readonly Renderer2DOptions _options;
private readonly SpriteBatcher _batcher;
@@ -37,6 +58,9 @@ public sealed class Renderer2D : IDisposable
private VertexPositionColorTexture[] _vertices;
private CameraState _screenCamera;
private bool _begun;
+ private long _submitStartTimestamp;
+ private int _ringCursor;
+ private int _ringBaseVertex;
/// Creates the renderer. One instance per game is enough.
public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null)
@@ -46,7 +70,7 @@ public sealed class Renderer2D : IDisposable
_batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer(
- device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length, BufferUsage.WriteOnly);
+ device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly);
_effect = new BasicEffect(device)
{
@@ -58,6 +82,8 @@ public sealed class Renderer2D : IDisposable
_indexBuffer = CreateQuadIndexBuffer(device);
}
+ internal int ParallelThreshold => _options.ParallelThreshold;
+
/// Begins a frame with the given camera. Called by .
public void BeginFrame(in Camera camera)
{
@@ -70,6 +96,7 @@ public sealed class Renderer2D : IDisposable
SubmittedSprites = 0;
CulledSprites = 0;
_begun = true;
+ _submitStartTimestamp = Stopwatch.GetTimestamp();
}
///
@@ -86,63 +113,86 @@ public sealed class Renderer2D : IDisposable
/// Submits one sprite. Invisible sprites (outside the camera) are culled here.
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++;
}
/// Sorts, builds vertices and issues draw calls. Called by .
public void EndFrame()
{
- if (!_begun)
- {
- throw new InvalidOperationException("EndFrame called without BeginFrame.");
- }
-
+ EnsureBegun();
_begun = false;
DrawCalls = 0;
- var order = _batcher.Sort();
- if (order.Length == 0)
+ var submitEnd = Stopwatch.GetTimestamp();
+ 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;
}
- EnsureVertexCapacity(order.Length * 4);
- BuildVertices(order);
- _vertexBuffer.SetData(_vertices, 0, order.Length * 4, SetDataOptions.Discard);
+ var order = _batcher.SortedOrder;
+ EnsureVertexCapacity(count * 4);
+ 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.SamplerStates[0] = _options.Sampler;
@@ -151,7 +201,8 @@ public sealed class Renderer2D : IDisposable
_device.SetVertexBuffer(_vertexBuffer);
_device.Indices = _indexBuffer;
- DrawBatches(order);
+ DrawBatches(order, count);
+ DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
}
/// Converts a physical screen point to world coordinates using the current camera.
@@ -168,6 +219,88 @@ public sealed class Renderer2D : IDisposable
_indexBuffer.Dispose();
}
+ // --- Параллельная по-чанковая подача (используется SpriteRenderSystem выше порога) ----
+
+ internal void BeginChunkedSubmit(ReadOnlySpan 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()
{
var viewport = _device.Viewport;
@@ -180,53 +313,48 @@ public sealed class Renderer2D : IDisposable
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y));
}
- private void BuildVertices(ReadOnlySpan 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]];
- 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);
+ (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);
}
- private void DrawBatches(ReadOnlySpan order)
+ private void DrawBatches(int[] order, int count)
{
var batchStart = 0;
ref readonly var first = ref _batcher[order[0]];
@@ -234,11 +362,11 @@ public sealed class Renderer2D : IDisposable
var currentLayer = first.Layer;
ApplyLayerMatrices(currentLayer);
- for (var i = 1; i <= order.Length; i++)
+ for (var i = 1; i <= count; i++)
{
Texture2D? texture = null;
byte layer = 0;
- if (i < order.Length)
+ if (i < count)
{
ref readonly var instance = ref _batcher[order[i]];
texture = instance.Region.Texture;
@@ -252,7 +380,7 @@ public sealed class Renderer2D : IDisposable
DrawRange(currentTexture, batchStart, i - batchStart);
batchStart = i;
- if (i < order.Length)
+ if (i < count)
{
currentTexture = texture!;
if (layer != currentLayer)
@@ -281,7 +409,8 @@ public sealed class Renderer2D : IDisposable
foreach (var pass in _effect.CurrentTechnique.Passes)
{
pass.Apply();
- _device.DrawIndexedPrimitives(PrimitiveType.TriangleList, firstQuad * 4, 0, quads * 2);
+ _device.DrawIndexedPrimitives(
+ PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2);
DrawCalls++;
}
@@ -292,23 +421,31 @@ public sealed class Renderer2D : IDisposable
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;
- while (capacity < vertexCount)
+ // GPU-буфер держим вдвое больше CPU-массива — кольцу нужен запас,
+ // чтобы 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) =>
new(new Vector3(position, 0f), color, new Vector2(u, v));
diff --git a/src/MrGameEng.Graphics/Renderer2DOptions.cs b/src/MrGameEng.Graphics/Renderer2DOptions.cs
index c86627a..0abbaf7 100644
--- a/src/MrGameEng.Graphics/Renderer2DOptions.cs
+++ b/src/MrGameEng.Graphics/Renderer2DOptions.cs
@@ -17,4 +17,12 @@ public sealed class Renderer2DOptions
/// Initial sprite capacity of the batcher; grows automatically.
public int InitialCapacity { get; set; } = 2048;
+
+ ///
+ /// 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, Parallel.For adds a few small scheduler allocations per frame.
+ /// Set to to disable parallelism.
+ ///
+ public int ParallelThreshold { get; set; } = 8192;
}
diff --git a/src/MrGameEng.Graphics/SpriteBatcher.cs b/src/MrGameEng.Graphics/SpriteBatcher.cs
index d69747f..0b156a6 100644
--- a/src/MrGameEng.Graphics/SpriteBatcher.cs
+++ b/src/MrGameEng.Graphics/SpriteBatcher.cs
@@ -2,6 +2,44 @@ using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics;
+///
+/// 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.
+///
+public struct SpriteChunkWriter
+{
+ private readonly SpriteInstance[] _instances;
+ private readonly ulong[] _keys;
+ private readonly int _offset;
+
+ /// Sprites accepted into this chunk's range.
+ public int Count { get; private set; }
+
+ /// Sprites rejected by culling in this chunk.
+ public int Culled { get; private set; }
+
+ internal SpriteChunkWriter(SpriteInstance[] instances, ulong[] keys, int offset)
+ {
+ _instances = instances;
+ _keys = keys;
+ _offset = offset;
+ Count = 0;
+ Culled = 0;
+ }
+
+ /// Appends one accepted sprite.
+ public void Add(in SpriteInstance instance, ulong sortKey)
+ {
+ var index = _offset + Count;
+ _instances[index] = instance;
+ _keys[index] = sortKey;
+ Count++;
+ }
+
+ /// Counts one culled sprite.
+ public void AddCulled() => Culled++;
+}
+
/// One sprite queued for rendering this frame.
public struct SpriteInstance
{
@@ -147,9 +185,102 @@ public sealed class SpriteBatcher
/// Returns the instance at (an index from ).
public ref readonly SpriteInstance this[int index] => ref _instances[index];
+ /// The sorted index array after (first entries are valid).
+ internal int[] SortedOrder => _order;
+
/// Resets the batcher for the next frame. Keeps allocated capacity.
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;
+
+ /// Total culled count reported by chunk writers in the last .
+ public int LastChunkCulled { get; private set; }
+
+ ///
+ /// Prepares the scratch arena for chunked submission. are
+ /// the entity counts of each ECS chunk, in iteration order.
+ ///
+ public void BeginChunks(ReadOnlySpan 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];
+ }
+ }
+
+ /// Returns the writer for chunk . Each chunk is written by one thread.
+ public SpriteChunkWriter GetChunkWriter(int chunkIndex) =>
+ new(_scratchInstances, _scratchKeys, _chunkOffsets[chunkIndex]);
+
+ /// Records the writer's results. Called by the same thread that filled the writer.
+ public void EndChunk(int chunkIndex, in SpriteChunkWriter writer)
+ {
+ _chunkVisible[chunkIndex] = writer.Count;
+ _chunkCulled[chunkIndex] = writer.Culled;
+ }
+
+ ///
+ /// Merges all chunk ranges into the main arrays in chunk order (deterministic, keeps
+ /// sort stability) and returns the number of accepted sprites.
+ ///
+ 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;
diff --git a/src/MrGameEng.Graphics/Texture2DRegion.cs b/src/MrGameEng.Graphics/Texture2DRegion.cs
index 5ba23d7..a553337 100644
--- a/src/MrGameEng.Graphics/Texture2DRegion.cs
+++ b/src/MrGameEng.Graphics/Texture2DRegion.cs
@@ -24,6 +24,10 @@ public sealed class Texture2DRegion
internal readonly int TextureSortKey;
internal readonly float Diagonal;
+ internal readonly float U0;
+ internal readonly float V0;
+ internal readonly float U1;
+ internal readonly float V1;
/// Creates a region covering part of .
public Texture2DRegion(Texture2D texture, Rectangle bounds)
@@ -32,6 +36,16 @@ public sealed class Texture2DRegion
Bounds = bounds;
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
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;
+ }
}
/// Creates a region covering the whole .
diff --git a/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs b/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs
index 8fcc727..5526ad9 100644
--- a/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs
+++ b/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs
@@ -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]
public void Clear_ResetsCount_KeepsWorking()
{