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);
}
}