Update README.md to include project description, developer documentation links, and license information.
CI / build-test (push) Successful in 1m6s
CI / build-test (push) Successful in 1m6s
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Orthographic 2D camera component. The renderer uses the first entity that has this
|
||||
/// component as the active camera. Create via the constructor — the struct default has zero zoom.
|
||||
/// </summary>
|
||||
public struct Camera : IComponent
|
||||
{
|
||||
/// <summary>World position the camera looks at (center of the view).</summary>
|
||||
public Vector2 Position;
|
||||
|
||||
/// <summary>Zoom factor. 1 = one world unit per virtual pixel; 2 = twice as close.</summary>
|
||||
public float Zoom;
|
||||
|
||||
/// <summary>Camera roll in radians, clockwise.</summary>
|
||||
public float Rotation;
|
||||
|
||||
/// <summary>Optional world-bounds clamp: the view never leaves this rectangle (when it fits).</summary>
|
||||
public RectF? Bounds;
|
||||
|
||||
/// <summary>Creates a camera centered at <paramref name="position"/>.</summary>
|
||||
public Camera(Vector2 position, float zoom = 1f, float rotation = 0f, RectF? bounds = null)
|
||||
{
|
||||
Position = position;
|
||||
Zoom = zoom;
|
||||
Rotation = rotation;
|
||||
Bounds = bounds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>Maps physical screen pixels to virtual-resolution pixels (letterbox scaling).</summary>
|
||||
public readonly record struct ViewportMapping(Vector2 Offset, float Scale)
|
||||
{
|
||||
/// <summary>Identity mapping (no letterbox).</summary>
|
||||
public static readonly ViewportMapping Identity = new(Vector2.Zero, 1f);
|
||||
}
|
||||
|
||||
/// <summary>Per-frame camera matrices and derived data, computed by <see cref="CameraMath"/>.</summary>
|
||||
public readonly struct CameraState
|
||||
{
|
||||
/// <summary>World → virtual-screen transform of the active camera.</summary>
|
||||
public required Matrix View { get; init; }
|
||||
|
||||
/// <summary>Virtual-screen → NDC orthographic projection.</summary>
|
||||
public required Matrix Projection { get; init; }
|
||||
|
||||
/// <summary>Inverse of <see cref="View"/>.</summary>
|
||||
public required Matrix InverseView { get; init; }
|
||||
|
||||
/// <summary>World-space rectangle visible through the camera; used for culling.</summary>
|
||||
public required RectF CullRect { get; init; }
|
||||
|
||||
/// <summary>Virtual resolution width in pixels.</summary>
|
||||
public required int VirtualWidth { get; init; }
|
||||
|
||||
/// <summary>Virtual resolution height in pixels.</summary>
|
||||
public required int VirtualHeight { get; init; }
|
||||
|
||||
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
|
||||
public required ViewportMapping Mapping { get; init; }
|
||||
|
||||
/// <summary>Converts a physical screen point to world coordinates.</summary>
|
||||
public Vector2 ScreenToWorld(Vector2 screen)
|
||||
{
|
||||
var virtualPoint = (screen - Mapping.Offset) / Mapping.Scale;
|
||||
return Vector2.Transform(virtualPoint, InverseView);
|
||||
}
|
||||
|
||||
/// <summary>Converts a world point to physical screen coordinates.</summary>
|
||||
public Vector2 WorldToScreen(Vector2 world)
|
||||
{
|
||||
var virtualPoint = Vector2.Transform(world, View);
|
||||
return virtualPoint * Mapping.Scale + Mapping.Offset;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure math for the orthographic 2D camera. Y axis points down, rotation is clockwise.</summary>
|
||||
public static class CameraMath
|
||||
{
|
||||
/// <summary>Computes the full camera state for a frame.</summary>
|
||||
public static CameraState Compute(in Camera camera, int virtualWidth, int virtualHeight, ViewportMapping mapping)
|
||||
{
|
||||
var zoom = camera.Zoom <= 0f ? 1f : camera.Zoom;
|
||||
var position = ClampToBounds(camera, virtualWidth, virtualHeight, zoom);
|
||||
|
||||
var view =
|
||||
Matrix.CreateTranslation(-position.X, -position.Y, 0f) *
|
||||
Matrix.CreateRotationZ(-camera.Rotation) *
|
||||
Matrix.CreateScale(zoom, zoom, 1f) *
|
||||
Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f);
|
||||
|
||||
var inverseView = Matrix.Invert(view);
|
||||
|
||||
return new CameraState
|
||||
{
|
||||
View = view,
|
||||
Projection = Matrix.CreateOrthographicOffCenter(0f, virtualWidth, virtualHeight, 0f, 0f, 1f),
|
||||
InverseView = inverseView,
|
||||
CullRect = ComputeCullRect(inverseView, virtualWidth, virtualHeight),
|
||||
VirtualWidth = virtualWidth,
|
||||
VirtualHeight = virtualHeight,
|
||||
Mapping = mapping,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the letterbox mapping that fits the virtual resolution into a physical
|
||||
/// viewport, preserving aspect ratio and centering.
|
||||
/// </summary>
|
||||
public static ViewportMapping ComputeMapping(int screenWidth, int screenHeight, int virtualWidth, int virtualHeight)
|
||||
{
|
||||
var scale = MathF.Min((float)screenWidth / virtualWidth, (float)screenHeight / virtualHeight);
|
||||
var offset = new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale) / 2f;
|
||||
return new ViewportMapping(offset, scale);
|
||||
}
|
||||
|
||||
private static Vector2 ClampToBounds(in Camera camera, int virtualWidth, int virtualHeight, float zoom)
|
||||
{
|
||||
if (camera.Bounds is not { } bounds)
|
||||
{
|
||||
return camera.Position;
|
||||
}
|
||||
|
||||
// Clamp uses unrotated view extents; with camera roll the clamp is approximate.
|
||||
var halfW = virtualWidth / (2f * zoom);
|
||||
var halfH = virtualHeight / (2f * zoom);
|
||||
return new Vector2(
|
||||
ClampAxis(camera.Position.X, bounds.Left + halfW, bounds.Right - halfW),
|
||||
ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH));
|
||||
}
|
||||
|
||||
private static float ClampAxis(float value, float min, float max) =>
|
||||
min > max ? (min + max) / 2f : Math.Clamp(value, min, max);
|
||||
|
||||
private static RectF ComputeCullRect(in Matrix inverseView, int virtualWidth, int virtualHeight)
|
||||
{
|
||||
var c0 = Vector2.Transform(Vector2.Zero, inverseView);
|
||||
var c1 = Vector2.Transform(new Vector2(virtualWidth, 0f), inverseView);
|
||||
var c2 = Vector2.Transform(new Vector2(0f, virtualHeight), inverseView);
|
||||
var c3 = Vector2.Transform(new Vector2(virtualWidth, virtualHeight), inverseView);
|
||||
|
||||
var min = Vector2.Min(Vector2.Min(c0, c1), Vector2.Min(c2, c3));
|
||||
var max = Vector2.Max(Vector2.Max(c0, c1), Vector2.Max(c2, c3));
|
||||
return RectF.FromCorners(min, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>Conservative visibility tests used before sprites are written to the batcher.</summary>
|
||||
public static class CullingMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the world-space center and a conservative bounding-circle radius of a sprite
|
||||
/// (valid for any rotation), given its transform, region size in pixels and origin.
|
||||
/// </summary>
|
||||
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
|
||||
in Transform2D transform, float regionWidth, float regionHeight, Vector2 origin)
|
||||
{
|
||||
var scaledW = regionWidth * transform.Scale.X;
|
||||
var scaledH = regionHeight * transform.Scale.Y;
|
||||
|
||||
// Offset from the pivot (= transform.Position) to the sprite's geometric center.
|
||||
var toCenter = new Vector2(
|
||||
scaledW / 2f - origin.X * transform.Scale.X,
|
||||
scaledH / 2f - origin.Y * transform.Scale.Y);
|
||||
|
||||
var (sin, cos) = MathF.SinCos(transform.Rotation);
|
||||
var center = transform.Position + new Vector2(
|
||||
toCenter.X * cos - toCenter.Y * sin,
|
||||
toCenter.X * sin + toCenter.Y * cos);
|
||||
|
||||
var radius = 0.5f * MathF.Sqrt(scaledW * scaledW + scaledH * scaledH);
|
||||
return (center, radius);
|
||||
}
|
||||
|
||||
/// <summary>True when the circle overlaps the rectangle.</summary>
|
||||
public static bool CircleIntersectsRect(Vector2 center, float radius, in RectF rect)
|
||||
{
|
||||
var nearestX = Math.Clamp(center.X, rect.Left, rect.Right);
|
||||
var nearestY = Math.Clamp(center.Y, rect.Top, rect.Bottom);
|
||||
var dx = center.X - nearestX;
|
||||
var dy = center.Y - nearestY;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>Compact identifier of a render layer. Obtained from <see cref="LayerRegistry.Register"/>.</summary>
|
||||
public readonly record struct LayerId(byte Value)
|
||||
{
|
||||
/// <summary>The default layer (the first one registered).</summary>
|
||||
public static readonly LayerId Default = new(0);
|
||||
}
|
||||
|
||||
/// <summary>Coordinate space a layer is drawn in.</summary>
|
||||
public enum LayerSpace
|
||||
{
|
||||
/// <summary>Drawn through the active camera's transform.</summary>
|
||||
World,
|
||||
|
||||
/// <summary>Drawn in screen coordinates, ignoring the camera (HUD, UI). Never culled.</summary>
|
||||
Screen,
|
||||
}
|
||||
|
||||
/// <summary>How sprites are ordered within a layer.</summary>
|
||||
public enum LayerSortMode
|
||||
{
|
||||
/// <summary>Order by the sprite's <see cref="Sprite.Depth"/> value (smaller = drawn first).</summary>
|
||||
Depth,
|
||||
|
||||
/// <summary>Order by world Y position (top-down games: lower on screen = drawn in front).</summary>
|
||||
YSort,
|
||||
}
|
||||
|
||||
/// <summary>A registered render layer.</summary>
|
||||
public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, LayerSortMode SortMode);
|
||||
|
||||
/// <summary>
|
||||
/// Registry of render layers. Layers are registered up front (typically when the renderer is
|
||||
/// created) and drawn in registration order. Maximum 256 layers.
|
||||
/// </summary>
|
||||
public sealed class LayerRegistry
|
||||
{
|
||||
private readonly List<RenderLayer> _layers = [];
|
||||
|
||||
/// <summary>Creates a registry containing the built-in "Default" world layer.</summary>
|
||||
public LayerRegistry() => Register("Default");
|
||||
|
||||
/// <summary>Number of registered layers.</summary>
|
||||
public int Count => _layers.Count;
|
||||
|
||||
/// <summary>Registers a layer drawn after all previously registered ones.</summary>
|
||||
public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth)
|
||||
{
|
||||
if (_layers.Count == 256)
|
||||
{
|
||||
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
|
||||
}
|
||||
|
||||
var id = new LayerId((byte)_layers.Count);
|
||||
_layers.Add(new RenderLayer(id, name, space, sortMode));
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the layer with the given id.</summary>
|
||||
public RenderLayer this[LayerId id] => _layers[id.Value];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>Axis-aligned rectangle with float coordinates (MonoGame's <see cref="Rectangle"/> is int-only).</summary>
|
||||
public readonly record struct RectF(float X, float Y, float Width, float Height)
|
||||
{
|
||||
/// <summary>Left edge.</summary>
|
||||
public float Left => X;
|
||||
|
||||
/// <summary>Top edge.</summary>
|
||||
public float Top => Y;
|
||||
|
||||
/// <summary>Right edge.</summary>
|
||||
public float Right => X + Width;
|
||||
|
||||
/// <summary>Bottom edge.</summary>
|
||||
public float Bottom => Y + Height;
|
||||
|
||||
/// <summary>Center point.</summary>
|
||||
public Vector2 Center => new(X + Width / 2f, Y + Height / 2f);
|
||||
|
||||
/// <summary>Creates the smallest rectangle containing both corner points.</summary>
|
||||
public static RectF FromCorners(Vector2 min, Vector2 max) =>
|
||||
new(min.X, min.Y, max.X - min.X, max.Y - min.Y);
|
||||
|
||||
/// <summary>True when this rectangle and <paramref name="other"/> overlap.</summary>
|
||||
public bool Intersects(in RectF other) =>
|
||||
other.Left < Right && Left < other.Right && other.Top < Bottom && Top < other.Bottom;
|
||||
|
||||
/// <summary>True when the point lies inside the rectangle.</summary>
|
||||
public bool Contains(Vector2 point) =>
|
||||
point.X >= Left && point.X < Right && point.Y >= Top && point.Y < Bottom;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// First draw system: finds the active camera entity (the first one with a <see cref="Camera"/>
|
||||
/// component) and begins the renderer frame. Without a camera entity a default camera showing
|
||||
/// world origin at the top-left corner is used.
|
||||
/// </summary>
|
||||
public sealed class CameraSystem : QuerySystem<Camera>
|
||||
{
|
||||
private readonly Renderer2D _renderer;
|
||||
|
||||
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
|
||||
public CameraSystem(Renderer2D renderer) => _renderer = renderer;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
foreach (var (cameras, _) in Query.Chunks)
|
||||
{
|
||||
if (cameras.Length > 0)
|
||||
{
|
||||
_renderer.BeginFrame(in cameras.Span[0]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_renderer.BeginFrameWithDefaultCamera();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Submits every entity that has both <see cref="Sprite"/> and <see cref="Transform2D"/>.</summary>
|
||||
public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
|
||||
{
|
||||
private readonly Renderer2D _renderer;
|
||||
|
||||
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
|
||||
public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
foreach (var (sprites, transforms, _) in Query.Chunks)
|
||||
{
|
||||
var s = sprites.Span;
|
||||
var t = transforms.Span;
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
_renderer.Submit(in t[i], in s[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Last draw system: sorts the frame and issues the draw calls.</summary>
|
||||
public sealed class RenderFlushSystem : BaseSystem
|
||||
{
|
||||
private readonly Renderer2D _renderer;
|
||||
|
||||
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
|
||||
public RenderFlushSystem(Renderer2D renderer) => _renderer = renderer;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdateGroup() => _renderer.EndFrame();
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
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"/> (sort layer → depth → texture, build vertices, issue draw calls).
|
||||
/// Registered as a service; scenes attach it via <c>scene.UseRenderer2D()</c>.
|
||||
/// </summary>
|
||||
public sealed class Renderer2D : IDisposable
|
||||
{
|
||||
private const int MaxQuadsPerDraw = 8192;
|
||||
|
||||
/// <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; }
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>Creates the renderer. One instance per game is enough.</summary>
|
||||
public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null)
|
||||
{
|
||||
_device = device;
|
||||
_options = options ?? new Renderer2DOptions();
|
||||
_batcher = new SpriteBatcher(_options.InitialCapacity);
|
||||
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
|
||||
_vertexBuffer = new DynamicVertexBuffer(
|
||||
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length, BufferUsage.WriteOnly);
|
||||
|
||||
_effect = new BasicEffect(device)
|
||||
{
|
||||
TextureEnabled = true,
|
||||
VertexColorEnabled = true,
|
||||
World = Matrix.Identity,
|
||||
};
|
||||
|
||||
_indexBuffer = CreateQuadIndexBuffer(device);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (!_begun)
|
||||
{
|
||||
throw new InvalidOperationException("Submit called outside BeginFrame/EndFrame (is CameraSystem registered first?).");
|
||||
}
|
||||
|
||||
if (sprite.Region is not { } region)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var layer = Layers[sprite.Layer];
|
||||
var (center, radius) = CullingMath.SpriteBoundingCircle(transform, region.Width, region.Height, 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++;
|
||||
}
|
||||
|
||||
/// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary>
|
||||
public void EndFrame()
|
||||
{
|
||||
if (!_begun)
|
||||
{
|
||||
throw new InvalidOperationException("EndFrame called without BeginFrame.");
|
||||
}
|
||||
|
||||
_begun = false;
|
||||
DrawCalls = 0;
|
||||
|
||||
var order = _batcher.Sort();
|
||||
if (order.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureVertexCapacity(order.Length * 4);
|
||||
BuildVertices(order);
|
||||
_vertexBuffer.SetData(_vertices, 0, order.Length * 4, SetDataOptions.Discard);
|
||||
|
||||
_device.BlendState = BlendState.AlphaBlend;
|
||||
_device.SamplerStates[0] = _options.Sampler;
|
||||
_device.DepthStencilState = DepthStencilState.None;
|
||||
_device.RasterizerState = RasterizerState.CullNone;
|
||||
_device.SetVertexBuffer(_vertexBuffer);
|
||||
_device.Indices = _indexBuffer;
|
||||
|
||||
DrawBatches(order);
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
|
||||
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 BuildVertices(ReadOnlySpan<int> order)
|
||||
{
|
||||
for (var i = 0; i < order.Length; i++)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
var (sin, cos) = MathF.SinCos(instance.Rotation);
|
||||
var rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin);
|
||||
var 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<int> order)
|
||||
{
|
||||
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 <= order.Length; i++)
|
||||
{
|
||||
Texture2D? texture = null;
|
||||
byte layer = 0;
|
||||
if (i < order.Length)
|
||||
{
|
||||
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 < order.Length)
|
||||
{
|
||||
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, firstQuad * 4, 0, quads * 2);
|
||||
DrawCalls++;
|
||||
}
|
||||
|
||||
firstQuad += quads;
|
||||
quadCount -= quads;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureVertexCapacity(int vertexCount)
|
||||
{
|
||||
if (_vertices.Length >= vertexCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var capacity = _vertices.Length;
|
||||
while (capacity < vertexCount)
|
||||
{
|
||||
capacity *= 2;
|
||||
}
|
||||
|
||||
_vertices = new VertexPositionColorTexture[capacity];
|
||||
_vertexBuffer.Dispose();
|
||||
_vertexBuffer = new DynamicVertexBuffer(
|
||||
_device, VertexPositionColorTexture.VertexDeclaration, capacity, BufferUsage.WriteOnly);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>Configuration of <see cref="Renderer2D"/>.</summary>
|
||||
public sealed class Renderer2DOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed virtual resolution. When set, the world is rendered at this resolution and
|
||||
/// letterbox-scaled to the window. When null, the backbuffer size is used directly.
|
||||
/// </summary>
|
||||
public Point? VirtualResolution { get; set; }
|
||||
|
||||
/// <summary>Texture sampling. Defaults to <see cref="SamplerState.PointClamp"/> (crisp pixel art).</summary>
|
||||
public SamplerState Sampler { get; set; } = SamplerState.PointClamp;
|
||||
|
||||
/// <summary>Initial sprite capacity of the batcher; grows automatically.</summary>
|
||||
public int InitialCapacity { get; set; } = 2048;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>Wires the graphics module into a <see cref="Scene"/>.</summary>
|
||||
public static class SceneGraphicsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches the 2D renderer to the scene: registers <see cref="CameraSystem"/>,
|
||||
/// <see cref="SpriteRenderSystem"/>, any <paramref name="extraDrawSystems"/> and finally
|
||||
/// <see cref="RenderFlushSystem"/> in the draw phase. The <see cref="Renderer2D"/> service
|
||||
/// is created on first use and shared between scenes. Call from <c>OnLoad</c>.
|
||||
/// </summary>
|
||||
public static Renderer2D UseRenderer2D(
|
||||
this Scene scene, Renderer2DOptions? options = null, params BaseSystem[] extraDrawSystems)
|
||||
{
|
||||
var services = scene.Context.Services;
|
||||
var renderer = services.GetOrDefault<Renderer2D>();
|
||||
if (renderer is null)
|
||||
{
|
||||
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
|
||||
services.Add(renderer);
|
||||
}
|
||||
|
||||
scene.DrawSystems.Add(new CameraSystem(renderer));
|
||||
scene.DrawSystems.Add(new SpriteRenderSystem(renderer));
|
||||
foreach (var system in extraDrawSystems)
|
||||
{
|
||||
scene.DrawSystems.Add(system);
|
||||
}
|
||||
|
||||
scene.DrawSystems.Add(new RenderFlushSystem(renderer));
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/// <summary>Adds <see cref="SpriteAnimationSystem"/> to the scene's update phase. Call from <c>OnLoad</c>.</summary>
|
||||
public static void UseSpriteAnimation(this Scene scene) =>
|
||||
scene.UpdateSystems.Add(new SpriteAnimationSystem());
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>Horizontal / vertical mirroring of a sprite.</summary>
|
||||
[Flags]
|
||||
public enum SpriteFlip : byte
|
||||
{
|
||||
/// <summary>No mirroring.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Mirror horizontally.</summary>
|
||||
X = 1,
|
||||
|
||||
/// <summary>Mirror vertically.</summary>
|
||||
Y = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprite component: a texture region plus tint, origin, layer and depth.
|
||||
/// Create via the constructor — the struct default has no region and a transparent tint.
|
||||
/// </summary>
|
||||
public struct Sprite : IComponent
|
||||
{
|
||||
/// <summary>The texture region to draw.</summary>
|
||||
public Texture2DRegion? Region;
|
||||
|
||||
/// <summary>Tint color, multiplied with the texture. White = unmodified.</summary>
|
||||
public Color Color;
|
||||
|
||||
/// <summary>
|
||||
/// Pivot in region pixels, measured from the region's top-left corner. The sprite is
|
||||
/// positioned, rotated and scaled around this point.
|
||||
/// </summary>
|
||||
public Vector2 Origin;
|
||||
|
||||
/// <summary>The render layer this sprite belongs to.</summary>
|
||||
public LayerId Layer;
|
||||
|
||||
/// <summary>Draw order within the layer (smaller = drawn first / behind). Ignored on Y-sort layers.</summary>
|
||||
public float Depth;
|
||||
|
||||
/// <summary>Mirroring flags.</summary>
|
||||
public SpriteFlip Flip;
|
||||
|
||||
/// <summary>Creates a sprite on the given layer with a white tint and top-left origin.</summary>
|
||||
public Sprite(Texture2DRegion region, LayerId layer = default)
|
||||
{
|
||||
Region = region;
|
||||
Color = Color.White;
|
||||
Origin = Vector2.Zero;
|
||||
Layer = layer;
|
||||
Depth = 0f;
|
||||
Flip = SpriteFlip.None;
|
||||
}
|
||||
|
||||
/// <summary>Sets <see cref="Origin"/> to the center of the region.</summary>
|
||||
public void CenterOrigin()
|
||||
{
|
||||
if (Region is not null)
|
||||
{
|
||||
Origin = new Vector2(Region.Width / 2f, Region.Height / 2f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>A frame-by-frame sprite animation: an ordered list of texture regions played at a fixed rate.</summary>
|
||||
public sealed class SpriteAnimationClip
|
||||
{
|
||||
/// <summary>Animation frames in play order. Never empty.</summary>
|
||||
public IReadOnlyList<Texture2DRegion> Frames { get; }
|
||||
|
||||
/// <summary>Playback rate in frames per second.</summary>
|
||||
public float FramesPerSecond { get; }
|
||||
|
||||
/// <summary>Restart from the first frame after the last one.</summary>
|
||||
public bool Loop { get; }
|
||||
|
||||
/// <summary>Total clip duration in seconds.</summary>
|
||||
public float Duration => Frames.Count / FramesPerSecond;
|
||||
|
||||
/// <summary>Creates a clip.</summary>
|
||||
public SpriteAnimationClip(IReadOnlyList<Texture2DRegion> frames, float framesPerSecond = 12f, bool loop = true)
|
||||
{
|
||||
if (frames.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("An animation clip needs at least one frame.", nameof(frames));
|
||||
}
|
||||
|
||||
Frames = frames;
|
||||
FramesPerSecond = framesPerSecond;
|
||||
Loop = loop;
|
||||
}
|
||||
|
||||
/// <summary>Returns the frame shown at <paramref name="time"/> seconds into the clip.</summary>
|
||||
public Texture2DRegion FrameAt(float time)
|
||||
{
|
||||
var frame = (int)(time * FramesPerSecond);
|
||||
if (Loop)
|
||||
{
|
||||
frame = ((frame % Frames.Count) + Frames.Count) % Frames.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
frame = Math.Clamp(frame, 0, Frames.Count - 1);
|
||||
}
|
||||
|
||||
return Frames[frame];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays a <see cref="SpriteAnimationClip"/> on the entity's <see cref="Sprite"/>.
|
||||
/// Create via the constructor — the struct default has no clip and zero speed.
|
||||
/// </summary>
|
||||
public struct SpriteAnimator : IComponent
|
||||
{
|
||||
/// <summary>The clip being played; null = nothing to play.</summary>
|
||||
public SpriteAnimationClip? Clip;
|
||||
|
||||
/// <summary>Playback position in seconds.</summary>
|
||||
public float Time;
|
||||
|
||||
/// <summary>Playback speed multiplier. 1 = normal.</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>False pauses playback.</summary>
|
||||
public bool Playing;
|
||||
|
||||
/// <summary>Starts playing <paramref name="clip"/> from the beginning.</summary>
|
||||
public SpriteAnimator(SpriteAnimationClip clip)
|
||||
{
|
||||
Clip = clip;
|
||||
Time = 0f;
|
||||
Speed = 1f;
|
||||
Playing = true;
|
||||
}
|
||||
|
||||
/// <summary>Switches to <paramref name="clip"/> and restarts unless it is already playing.</summary>
|
||||
public void Play(SpriteAnimationClip clip)
|
||||
{
|
||||
if (ReferenceEquals(Clip, clip) && Playing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Clip = clip;
|
||||
Time = 0f;
|
||||
Playing = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update-phase system advancing all <see cref="SpriteAnimator"/>s and writing the current
|
||||
/// frame into the entity's <see cref="Sprite.Region"/>.
|
||||
/// </summary>
|
||||
public sealed class SpriteAnimationSystem : QuerySystem<Sprite, SpriteAnimator>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var delta = Tick.deltaTime;
|
||||
foreach (var (sprites, animators, _) in Query.Chunks)
|
||||
{
|
||||
var s = sprites.Span;
|
||||
var a = animators.Span;
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
ref var animator = ref a[i];
|
||||
if (!animator.Playing || animator.Clip is not { } clip)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
animator.Time += delta * animator.Speed;
|
||||
if (!clip.Loop && animator.Time >= clip.Duration)
|
||||
{
|
||||
animator.Time = clip.Duration;
|
||||
animator.Playing = false;
|
||||
}
|
||||
|
||||
s[i].Region = clip.FrameAt(animator.Time);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>One sprite queued for rendering this frame.</summary>
|
||||
public struct SpriteInstance
|
||||
{
|
||||
/// <summary>Texture region to draw. Never null for submitted instances.</summary>
|
||||
public Texture2DRegion Region;
|
||||
|
||||
/// <summary>World-space (or screen-space) center of the quad.</summary>
|
||||
public Vector2 Center;
|
||||
|
||||
/// <summary>Half extents after scaling, in pixels. May be negative for negative scale.</summary>
|
||||
public Vector2 HalfSize;
|
||||
|
||||
/// <summary>Rotation in radians, clockwise.</summary>
|
||||
public float Rotation;
|
||||
|
||||
/// <summary>Tint color.</summary>
|
||||
public Color Color;
|
||||
|
||||
/// <summary>Mirroring flags.</summary>
|
||||
public SpriteFlip Flip;
|
||||
|
||||
/// <summary>Render layer the instance belongs to.</summary>
|
||||
public byte Layer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CPU side of the renderer: collects <see cref="SpriteInstance"/>s with their sort keys
|
||||
/// and orders them layer → depth → texture. Allocation-free after warm-up
|
||||
/// (arrays grow geometrically and are reused across frames).
|
||||
/// </summary>
|
||||
public sealed class SpriteBatcher
|
||||
{
|
||||
private SpriteInstance[] _instances;
|
||||
private ulong[] _keys;
|
||||
private int[] _order;
|
||||
private int _count;
|
||||
|
||||
/// <summary>Creates a batcher with the given initial capacity.</summary>
|
||||
public SpriteBatcher(int initialCapacity = 2048)
|
||||
{
|
||||
_instances = new SpriteInstance[initialCapacity];
|
||||
_keys = new ulong[initialCapacity];
|
||||
_order = new int[initialCapacity];
|
||||
}
|
||||
|
||||
/// <summary>Number of sprites submitted this frame.</summary>
|
||||
public int Count => _count;
|
||||
|
||||
/// <summary>Queues one sprite.</summary>
|
||||
public void Submit(in SpriteInstance instance, ulong sortKey)
|
||||
{
|
||||
if (_count == _instances.Length)
|
||||
{
|
||||
Grow();
|
||||
}
|
||||
|
||||
_instances[_count] = instance;
|
||||
_keys[_count] = sortKey;
|
||||
_count++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts all submitted sprites and returns their indices in draw order.
|
||||
/// Valid until the next <see cref="Clear"/>.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<int> Sort()
|
||||
{
|
||||
for (var i = 0; i < _count; i++)
|
||||
{
|
||||
_order[i] = i;
|
||||
}
|
||||
|
||||
Array.Sort(_keys, _order, 0, _count);
|
||||
return _order.AsSpan(0, _count);
|
||||
}
|
||||
|
||||
/// <summary>Returns the instance at <paramref name="index"/> (an index from <see cref="Sort"/>).</summary>
|
||||
public ref readonly SpriteInstance this[int index] => ref _instances[index];
|
||||
|
||||
/// <summary>Resets the batcher for the next frame. Keeps allocated capacity.</summary>
|
||||
public void Clear() => _count = 0;
|
||||
|
||||
private void Grow()
|
||||
{
|
||||
var capacity = _instances.Length * 2;
|
||||
Array.Resize(ref _instances, capacity);
|
||||
Array.Resize(ref _keys, capacity);
|
||||
Array.Resize(ref _order, capacity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 64-bit sort key the batcher orders sprites by:
|
||||
/// layer (8 bits) → depth (32 bits) → texture (24 bits).
|
||||
/// Texture bits only group equal textures for batching; collisions are harmless.
|
||||
/// </summary>
|
||||
public static class SpriteSortKey
|
||||
{
|
||||
/// <summary>Composes a sort key from layer, depth and texture grouping key.</summary>
|
||||
public static ulong Make(byte layer, float depth, int textureKey) =>
|
||||
((ulong)layer << 56) | ((ulong)DepthToSortableBits(depth) << 24) | ((uint)textureKey & 0xFF_FFFF);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a float to bits whose unsigned order matches the float order
|
||||
/// (negative depths sort before positive ones).
|
||||
/// </summary>
|
||||
public static uint DepthToSortableBits(float depth)
|
||||
{
|
||||
var bits = BitConverter.SingleToUInt32Bits(depth);
|
||||
return (bits & 0x8000_0000) != 0 ? ~bits : bits | 0x8000_0000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// A rectangular region of a texture — the unit sprites are drawn from. A standalone texture
|
||||
/// is a region covering the whole texture; texture atlases hand out one region per sprite,
|
||||
/// and sprites sharing an atlas batch into a single draw call automatically.
|
||||
/// </summary>
|
||||
public sealed class Texture2DRegion
|
||||
{
|
||||
/// <summary>The texture this region belongs to.</summary>
|
||||
public Texture2D Texture { get; }
|
||||
|
||||
/// <summary>Region bounds in texture pixels.</summary>
|
||||
public Rectangle Bounds { get; }
|
||||
|
||||
/// <summary>Region width in pixels.</summary>
|
||||
public int Width => Bounds.Width;
|
||||
|
||||
/// <summary>Region height in pixels.</summary>
|
||||
public int Height => Bounds.Height;
|
||||
|
||||
internal readonly int TextureSortKey;
|
||||
|
||||
/// <summary>Creates a region covering part of <paramref name="texture"/>.</summary>
|
||||
public Texture2DRegion(Texture2D texture, Rectangle bounds)
|
||||
{
|
||||
Texture = texture;
|
||||
Bounds = bounds;
|
||||
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
|
||||
}
|
||||
|
||||
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
|
||||
public Texture2DRegion(Texture2D texture)
|
||||
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height))
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// 2D transform component: position (world units = pixels), rotation (radians, clockwise
|
||||
/// in the engine's y-down coordinate system) and per-axis scale.
|
||||
/// Create via <see cref="At"/> or the constructor — the struct default has zero scale.
|
||||
/// </summary>
|
||||
public struct Transform2D : IComponent
|
||||
{
|
||||
/// <summary>World position in pixels.</summary>
|
||||
public Vector2 Position;
|
||||
|
||||
/// <summary>Rotation in radians, clockwise (y-down).</summary>
|
||||
public float Rotation;
|
||||
|
||||
/// <summary>Per-axis scale. 1 is unscaled.</summary>
|
||||
public Vector2 Scale;
|
||||
|
||||
/// <summary>Creates a transform with the given position, rotation and scale.</summary>
|
||||
public Transform2D(Vector2 position, float rotation = 0f, Vector2? scale = null)
|
||||
{
|
||||
Position = position;
|
||||
Rotation = rotation;
|
||||
Scale = scale ?? Vector2.One;
|
||||
}
|
||||
|
||||
/// <summary>Creates an unrotated, unscaled transform at <paramref name="position"/>.</summary>
|
||||
public static Transform2D At(Vector2 position) => new(position);
|
||||
}
|
||||
Reference in New Issue
Block a user