using Microsoft.Xna.Framework; namespace MrGameEng.Graphics; /// Maps physical screen pixels to virtual-resolution pixels (letterbox scaling). public readonly record struct ViewportMapping(Vector2 Offset, float Scale) { /// Identity mapping (no letterbox). public static readonly ViewportMapping Identity = new(Vector2.Zero, 1f); } /// Per-frame camera matrices and derived data, computed by . public readonly struct CameraState { /// World → virtual-screen transform of the active camera. public required Matrix View { get; init; } /// Virtual-screen → NDC orthographic projection. public required Matrix Projection { get; init; } /// Inverse of . public required Matrix InverseView { get; init; } /// World-space rectangle visible through the camera; used for culling. public required RectF CullRect { get; init; } /// Virtual resolution width in pixels. public required int VirtualWidth { get; init; } /// Virtual resolution height in pixels. public required int VirtualHeight { get; init; } /// Physical-screen to virtual-pixel mapping. public required ViewportMapping Mapping { get; init; } /// /// World point at the centre of the virtual screen — the camera's effective position /// after bounds-clamping, i.e. what the view is actually built around. Prefer this over the raw /// when anchoring zoom-to-cursor, so the reposition matches what is /// rendered even while the camera is clamped against . /// public Vector2 WorldCenter => Vector2.Transform(new Vector2(VirtualWidth / 2f, VirtualHeight / 2f), InverseView); /// Converts a physical screen point to world coordinates. public Vector2 ScreenToWorld(Vector2 screen) { var virtualPoint = (screen - Mapping.Offset) / Mapping.Scale; return Vector2.Transform(virtualPoint, InverseView); } /// Converts a world point to physical screen coordinates. public Vector2 WorldToScreen(Vector2 world) { var virtualPoint = Vector2.Transform(world, View); return virtualPoint * Mapping.Scale + Mapping.Offset; } } /// Pure math for the orthographic 2D camera. Y axis points down, rotation is clockwise. public static class CameraMath { /// Computes the full camera state for a frame. 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, }; } /// /// Computes the letterbox mapping that fits the virtual resolution into a physical /// viewport, preserving aspect ratio and centering. /// 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); } }