Files
mrgameeng/src/MrGameEng.Graphics/CameraMath.cs
T
Leonid Pershin 08381703f7
CI / build-test (push) Successful in 1m19s
Add WorldCenter property to CameraState for effective camera positioning
Enhanced the CameraState struct with a new WorldCenter property that calculates the effective position of the camera after bounds-clamping. This property is intended to be used for zoom-to-cursor functionality, ensuring that the repositioning aligns with what is rendered.

Added unit tests to verify that WorldCenter reflects the unclamped camera position and correctly accounts for bounds clamping, distinguishing it from the raw camera position.

Tests: WorldCenter_EqualsUnclampedCameraPosition, WorldCenter_ReflectsBoundsClamp_UnlikeRawPosition.
2026-06-13 05:21:24 +03:00

158 lines
5.8 KiB
C#

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>
/// World point at the centre of the virtual screen — the camera's <em>effective</em> position
/// after bounds-clamping, i.e. what the view is actually built around. Prefer this over the raw
/// <see cref="Camera.Position"/> when anchoring zoom-to-cursor, so the reposition matches what is
/// rendered even while the camera is clamped against <see cref="Camera.Bounds"/>.
/// </summary>
public Vector2 WorldCenter =>
Vector2.Transform(new Vector2(VirtualWidth / 2f, VirtualHeight / 2f), InverseView);
/// <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);
}
}