CI / build-test (push) Failing after 1m7s
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>
494 lines
18 KiB
C#
494 lines
18 KiB
C#
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;
|
||
}
|
||
}
|