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() {