Files
mrgameeng/src/MrGameEng.Graphics/Renderer2D.cs
T
Leonid PershinandClaude Fable 5 501d81e19f
CI / build-test (push) Failing after 1m7s
Fix engine-wide code review findings
Collisions: init bucket heads to -1 (QueryAabb hung before the first
rebuild), reset query stamps on truncated QueryAabb (later queries
silently dropped entities), inside-origin raycasts hit at fraction 0 for
circles too, exactly-touching boxes now pair like touching circles.

Graphics: render into the letterbox viewport so the picture matches
ScreenToWorld/WorldToScreen instead of stretching; Y-sort by the
transform pivot rather than the quad center; lock-free snapshot
LayerRegistry (parallel submit read it unsynchronized); validate
InitialCapacity; warn when UseRenderer2D drops options of a later scene.

Core: scenes are explicitly single-use (re-loading threw silently
duplicated systems/entities before — now it throws), Scene.RegisterUnload
for per-scene resources, a switch requested during the reveal phase
covers again instead of hard-swapping, borderless fullscreen
(HardwareModeSwitch off), InputCapture service for input-suppressing
overlays, host disposes the transition renderer and IDisposable services
on shutdown.

Input: game input reads as released while InputCapture is held; mouse
position and wheel freeze so deltas stay zero.

DevConsole: holds InputCapture while open (typing no longer drives the
camera), Revision increments only under the lock, quoted command
arguments, history capped at 256.

UI: scene Desktop skips Myra input processing while the console is open
(clicks no longer fall through), is disposed on scene unload, and Myra
init no longer depends on a process-static flag.

Audio: validate channel count/sample rate before stopping the previous
track, empty looped oggs no longer hang FillBuffers, the instance stops
when a non-looping track drains (IsPlaying was stuck true).

Atlases: metadata v2 stores per-source size+mtime snapshots, so
timestamp-preserving copies and renames invalidate correctly; loader
checks the version and disposes pages on partial load failure; shared
pages never exceed a non-POT MaxPageSize; oversized items pack first
onto exact-size pages instead of splitting an open shared page; the CLI
validates numeric options.

Assets.Generator: file names are escaped in XML docs and string
literals, members no longer collide with the enclosing class (CS0542),
and the Assets root is resolved against build_property.projectdir so
nested "Assets" directories do not shift region paths.

Pathfinding: queries throw when the grid was resized after construction;
generation stamps survive int overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:18:08 +03:00

494 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Graphics;
/// <summary>
/// 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)
/// → <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>.
/// </summary>
public sealed class Renderer2D : IDisposable
{
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>
public LayerRegistry Layers { get; } = new();
/// <summary>Camera state of the current frame. Valid between BeginFrame and the next BeginFrame.</summary>
public CameraState Camera { get; private set; }
/// <summary>Draw calls issued by the last <see cref="EndFrame"/>.</summary>
public int DrawCalls { get; private set; }
/// <summary>Sprites accepted by <see cref="Submit"/> this frame.</summary>
public int SubmittedSprites { get; private set; }
/// <summary>Sprites rejected by culling this frame.</summary>
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 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;
/// <summary>Creates the renderer. One instance per game is enough.</summary>
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;
/// <summary>Begins a frame with the given camera. Called by <see cref="CameraSystem"/>.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
public void BeginFrameWithDefaultCamera()
{
var (virtualW, virtualH, _) = ResolveVirtualResolution();
var camera = new Camera(new Vector2(virtualW / 2f, virtualH / 2f));
BeginFrame(in camera);
}
/// <summary>Submits one sprite. Invisible sprites (outside the camera) are culled here.</summary>
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;
}
}
/// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary>
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);
}
/// <summary>Converts a physical screen point to world coordinates using the current camera.</summary>
public Vector2 ScreenToWorld(Vector2 screen) => Camera.ScreenToWorld(screen);
/// <summary>Converts a world point to physical screen coordinates using the current camera.</summary>
public Vector2 WorldToScreen(Vector2 world) => Camera.WorldToScreen(world);
/// <inheritdoc />
public void Dispose()
{
_effect.Dispose();
_vertexBuffer.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;
}
// 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;
}
}