using System.Diagnostics; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; namespace MrGameEng.Graphics; /// /// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers. /// Per frame: (camera) → per sprite (with culling) /// → (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(); /// Camera state of the current frame. Valid between BeginFrame and the next BeginFrame. public CameraState Camera { get; private set; } /// Draw calls issued by the last . public int DrawCalls { get; private set; } /// Sprites accepted by this frame. public int SubmittedSprites { get; private set; } /// 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; private readonly BasicEffect _effect; private readonly IndexBuffer _indexBuffer; private DynamicVertexBuffer _vertexBuffer; 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) { _device = device; _options = options ?? new Renderer2DOptions(); ArgumentOutOfRangeException.ThrowIfLessThan(_options.InitialCapacity, 1, nameof(options)); _batcher = new SpriteBatcher(_options.InitialCapacity); _vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4]; _vertexBuffer = new DynamicVertexBuffer( device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly); _effect = new BasicEffect(device) { TextureEnabled = true, VertexColorEnabled = true, World = Matrix.Identity, }; _indexBuffer = CreateQuadIndexBuffer(device); } internal int ParallelThreshold => _options.ParallelThreshold; /// Begins a frame with the given camera. Called by . public void BeginFrame(in Camera camera) { var (virtualW, virtualH, mapping) = ResolveVirtualResolution(); Camera = CameraMath.Compute(camera, virtualW, virtualH, mapping); _screenCamera = CameraMath.Compute( new Camera(new Vector2(virtualW / 2f, virtualH / 2f)), virtualW, virtualH, mapping); _batcher.Clear(); SubmittedSprites = 0; CulledSprites = 0; _begun = true; _submitStartTimestamp = Stopwatch.GetTimestamp(); } /// /// Begins a frame with a default camera that shows the world origin at the top-left /// corner of the screen. Used when the scene has no camera entity. /// public void BeginFrameWithDefaultCamera() { var (virtualW, virtualH, _) = ResolveVirtualResolution(); var camera = new Camera(new Vector2(virtualW / 2f, virtualH / 2f)); BeginFrame(in camera); } /// Submits one sprite. Invisible sprites (outside the camera) are culled here. public void Submit(in Transform2D transform, in Sprite sprite) { EnsureBegun(); switch (TryBuildInstance(in transform, in sprite, out var instance, out var key)) { case SubmitResult.Visible: _batcher.Submit(in instance, key); SubmittedSprites++; break; case SubmitResult.Culled: CulledSprites++; break; } } /// Sorts, builds vertices and issues draw calls. Called by . public void EndFrame() { EnsureBegun(); _begun = false; DrawCalls = 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; } 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; _device.DepthStencilState = DepthStencilState.None; _device.RasterizerState = RasterizerState.CullNone; _device.SetVertexBuffer(_vertexBuffer); _device.Indices = _indexBuffer; // Виртуальное разрешение рисуется в letterbox-прямоугольник — тот же, по которому // считают ScreenToWorld/WorldToScreen; иначе картинка растягивается мимо маппинга. var previousViewport = _device.Viewport; var letterboxed = _options.VirtualResolution is not null; if (letterboxed) { var mapping = Camera.Mapping; _device.Viewport = new Viewport( (int)MathF.Round(mapping.Offset.X), (int)MathF.Round(mapping.Offset.Y), (int)MathF.Round(Camera.VirtualWidth * mapping.Scale), (int)MathF.Round(Camera.VirtualHeight * mapping.Scale)); } DrawBatches(order, count); if (letterboxed) { _device.Viewport = previousViewport; } DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd); } /// Converts a physical screen point to world coordinates using the current camera. public Vector2 ScreenToWorld(Vector2 screen) => Camera.ScreenToWorld(screen); /// Converts a world point to physical screen coordinates using the current camera. public Vector2 WorldToScreen(Vector2 world) => Camera.WorldToScreen(world); /// public void Dispose() { _effect.Dispose(); _vertexBuffer.Dispose(); _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; } // Y-sort по пивоту (Transform2D.Position), а не по центру квада: спрайты разной // высоты с origin «в ногах» сортируются по ногам, как принято в top-down. var depth = layer.SortMode == LayerSortMode.YSort ? transform.Position.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; if (_options.VirtualResolution is not { } virtualSize) { return (viewport.Width, viewport.Height, ViewportMapping.Identity); } return (virtualSize.X, virtualSize.Y, CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y)); } private void BuildVertex(int[] order, int 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) { (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(int[] order, int count) { var batchStart = 0; ref readonly var first = ref _batcher[order[0]]; var currentTexture = first.Region.Texture; var currentLayer = first.Layer; ApplyLayerMatrices(currentLayer); for (var i = 1; i <= count; i++) { Texture2D? texture = null; byte layer = 0; if (i < count) { ref readonly var instance = ref _batcher[order[i]]; texture = instance.Region.Texture; layer = instance.Layer; if (ReferenceEquals(texture, currentTexture) && layer == currentLayer) { continue; } } DrawRange(currentTexture, batchStart, i - batchStart); batchStart = i; if (i < count) { currentTexture = texture!; if (layer != currentLayer) { currentLayer = layer; ApplyLayerMatrices(currentLayer); } } } } private void ApplyLayerMatrices(byte layer) { var state = Layers[new LayerId(layer)].Space == LayerSpace.Screen ? _screenCamera : Camera; _effect.View = state.View; _effect.Projection = state.Projection; } private void DrawRange(Texture2D texture, int firstQuad, int quadCount) { _effect.Texture = texture; while (quadCount > 0) { var quads = Math.Min(quadCount, MaxQuadsPerDraw); foreach (var pass in _effect.CurrentTechnique.Passes) { pass.Apply(); _device.DrawIndexedPrimitives( PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2); DrawCalls++; } firstQuad += quads; quadCount -= quads; } } private void EnsureVertexCapacity(int vertexCount) { if (_vertices.Length < vertexCount) { var capacity = _vertices.Length; while (capacity < vertexCount) { capacity *= 2; } _vertices = new VertexPositionColorTexture[capacity]; } // GPU-буфер держим вдвое больше CPU-массива — кольцу нужен запас, // чтобы NoOverwrite срабатывал чаще, чем Discard. var wantedBuffer = _vertices.Length * 2; if (_vertexBuffer.VertexCount < wantedBuffer) { _vertexBuffer.Dispose(); _vertexBuffer = new DynamicVertexBuffer( _device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly); _ringCursor = 0; } } private static float ToMs(long timestampDelta) => (float)timestampDelta * 1000f / Stopwatch.Frequency; private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) => new(new Vector3(position, 0f), color, new Vector2(u, v)); private static IndexBuffer CreateQuadIndexBuffer(GraphicsDevice device) { var indices = new ushort[MaxQuadsPerDraw * 6]; for (var quad = 0; quad < MaxQuadsPerDraw; quad++) { var vertex = quad * 4; var index = quad * 6; indices[index + 0] = (ushort)(vertex + 0); indices[index + 1] = (ushort)(vertex + 1); indices[index + 2] = (ushort)(vertex + 2); indices[index + 3] = (ushort)(vertex + 2); indices[index + 4] = (ushort)(vertex + 1); indices[index + 5] = (ushort)(vertex + 3); } var buffer = new IndexBuffer(device, IndexElementSize.SixteenBits, indices.Length, BufferUsage.WriteOnly); buffer.SetData(indices); return buffer; } }