diff --git a/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs b/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs index 5747241..c71435a 100644 --- a/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs +++ b/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs @@ -9,7 +9,6 @@ using KappaDuck.Quack.Video; using KappaDuck.Quack.Video.Pixels; using KappaDuck.Quack.Windows; -using System.Buffers; using System.ComponentModel; namespace KappaDuck.Quack.Graphics.Rendering; @@ -23,7 +22,7 @@ namespace KappaDuck.Quack.Graphics.Rendering; /// public sealed class Renderer : IRenderTarget, IDisposable { - private const int MaxStackVertices = 64; + private const int MaxStackVertices = 256; private Window? _window; @@ -890,6 +889,26 @@ public Surface ReadPixels(RectI? area = null) return new Surface(surface); } + /// + /// Set the color used for drawing operations. + /// + /// The color to use for drawing operations. + public void SetDrawingColor(Color color) + { + ThrowIfDisposed(); + SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColor(Handle, color.R, color.G, color.B, color.A)); + } + + /// + /// Set the color used for drawing operations. + /// + /// The color to use for drawing operations. + public void SetDrawingColor(ColorF color) + { + ThrowIfDisposed(); + SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColorFloat(Handle, color.R, color.G, color.B, color.A)); + } + /// /// Redirects drawing into until the returned scope is disposed. /// @@ -908,23 +927,31 @@ public RenderTargetScope Target(Texture texture) } /// - /// Set the color used for drawing operations. + /// Redirects drawing to the given view: sets from and returns a + /// scope carrying the view's transform, until the scope is disposed. /// - /// The color to use for drawing operations. - public void SetDrawingColor(Color color) + /// + /// + /// Pass to for everything drawn through this view. + /// + /// + /// Uses , which accounts for logical , so the view's + /// normalized stays correct whether or not logical presentation is in effect. + /// + /// + /// The view to draw through. + /// A scope carrying the view's render state that restores the full-target viewport when disposed. + /// Failed to set the viewport. + /// The renderer is disposed. + public ViewScope View(View view) { ThrowIfDisposed(); - SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColor(Handle, color.R, color.G, color.B, color.A)); - } - /// - /// Set the color used for drawing operations. - /// - /// The color to use for drawing operations. - public void SetDrawingColor(ColorF color) - { - ThrowIfDisposed(); - SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColorFloat(Handle, color.R, color.G, color.B, color.A)); + RectI pixelViewport = view.ComputeViewport(CurrentOutputSize); + Viewport = pixelViewport; + + RenderState state = RenderState.Default with { Transform = view.GetTransform(pixelViewport.Size) }; + return new ViewScope(this, state); } private void DrawGeometry(ReadOnlySpan vertices, ReadOnlySpan indices, RenderState state) @@ -942,8 +969,7 @@ private void DrawGeometry(ReadOnlySpan vertices, ReadOnlySpan indic int count = vertices.Length; - Vertex[]? rented = count > MaxStackVertices ? ArrayPool.Shared.Rent(count) : null; - Span transformed = rented ?? stackalloc Vertex[count]; + Span transformed = count > MaxStackVertices ? new Vertex[count] : stackalloc Vertex[count]; for (int i = 0; i < count; i++) { @@ -954,9 +980,6 @@ private void DrawGeometry(ReadOnlySpan vertices, ReadOnlySpan indic } Submit(texture, transformed, indices); - - if (rented is not null) - ArrayPool.Shared.Return(rented); } private void Submit(SDL_Texture* texture, ReadOnlySpan vertices, ReadOnlySpan indices) diff --git a/src/KappaDuck.Quack/Graphics/Rendering/View.cs b/src/KappaDuck.Quack/Graphics/Rendering/View.cs new file mode 100644 index 0000000..cb1abcc --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Rendering/View.cs @@ -0,0 +1,261 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Geometry; + +namespace KappaDuck.Quack.Graphics.Rendering; + +/// +/// A 2D camera that defines what part of the scene is visible and where it is displayed on the render target. +/// +/// +/// +/// A view is described by two independent things: , and +/// control what part of the scene is visible, while controls where on the render target it is +/// displayed, as a rectangle normalized to the target's size. The default viewport, (0, 0, 1, 1), covers the whole +/// target. +/// +/// +/// Combine multiple views to build split-screens, minimaps, or picture-in-picture panels: draw the scene once per +/// view, each with its own . Obtain a from +/// to draw through a view. +/// +/// +public sealed class View +{ + /// + /// Creates a view centered at the origin with the given size and a viewport covering the whole target. + /// + /// The width and height of the visible scene, in world units. + /// has a negative or zero width or height. + public View(SizeF size) : this(PointF.Origin, size) + { + } + + /// + /// Creates a view with the given center and size and a viewport covering the whole target. + /// + /// The point of the scene that appears at the center of the viewport. + /// The width and height of the visible scene, in world units. + /// has a negative or zero width or height. + public View(PointF center, SizeF size) + { + Center = center; + Size = size; + } + + /// + /// Creates a view that shows the given rectangle of the scene, with a viewport covering the whole target. + /// + /// The rectangle of the scene to show, in world units. + /// has a negative or zero width or height. + public View(Rect rect) : this(rect.Center, rect.Size) + { + } + + /// + /// Gets or sets the rectangle, in world units, that the rendered content is confined to, or + /// to let the view move freely. + /// + /// + /// + /// Use this to stop a camera from showing past the edges of a level: set it to the level's world rectangle, and + /// move however you like for example following a player. The visible content stays + /// confined to without itself being altered. + /// + /// + /// Along an axis where is larger than . For example while zoomed out, + /// or on a minimap. The axis is centered on instead of clamped, since no position keeps + /// the view entirely inside it. + /// + /// + /// Ignores : the confinement treats as an axis-aligned extent, so a + /// rotated view can still show a sliver past the edge at its corners. + /// + /// + public Rect? Bounds { get; set; } + + /// + /// Gets or sets the point of the scene that appears at the center of the viewport. Defaults to (0, 0). + /// + /// + /// Kept exactly as set, even outside . Only the rendered content is confined. Reading it + /// back (for example to check how far a followed target has wandered) never reflects clamping. + /// + public PointF Center { get; set; } + + /// + /// Gets or sets the rotation of the view, clockwise on screen. Defaults to no rotation. + /// + public Angle Rotation { get; set; } + + /// + /// Gets or sets the width and height of the visible scene, in world units. + /// + /// + /// Shrinking the size zooms in; growing it zooms out. The aspect ratio is independent from the viewport's, so a + /// mismatch stretches the scene. Match them (or letterbox) if you need it undistorted. + /// + /// The width or height is negative or zero. + public SizeF Size + { + get; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value.Width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value.Height); + + field = value; + } + } + + /// + /// Gets or sets the rectangle, normalized to the render target's size, where this view is displayed. + /// + /// + /// Expressed in the 0–1 range so it stays correct across different target sizes. (0, 0, 1, 1), the default, + /// covers the whole target. Use a quarter of the target for split-screen (e.g. (0, 0, 0.5, 0.5) for the + /// top-left player) or a small corner rectangle for a minimap (e.g. (0.75, 0.75, 0.2, 0.2)). + /// + public Rect Viewport { get; set; } = new(0f, 0f, 1f, 1f); + + /// + /// Computes the pixel rectangle this view occupies on a render target of the given size, from . + /// + /// Assign the result to before drawing through this view. + /// + /// The size of the render target, accounting for logical presentation. Typically + /// , not . + /// + /// The viewport, in pixels, clamped to at least 1x1. + public RectI ComputeViewport(Size targetSize) + { + int x = (int)MathF.Round(Viewport.X * targetSize.Width); + int y = (int)MathF.Round(Viewport.Y * targetSize.Height); + int width = Math.Max(1, (int)MathF.Round(Viewport.Width * targetSize.Width)); + int height = Math.Max(1, (int)MathF.Round(Viewport.Height * targetSize.Height)); + + return new RectI(x, y, width, height); + } + + /// + /// Computes the transform that maps the scene into this view, ready to combine into a . + /// + /// + /// must be the pixel size this view is displayed at the size from + /// and not the full render target, so the scene fills the viewport correctly. + /// + /// The pixel size of this view's viewport. + /// A transform mapping scene coordinates to viewport-local render coordinates. + public Transform GetTransform(Size viewportSize) + { + Vector2 scale = new(viewportSize.Width / Size.Width, viewportSize.Height / Size.Height); + PointF center = new(viewportSize.Width / 2f, viewportSize.Height / 2f); + + // Rotation is negated: rotating the camera clockwise makes the scene appear to turn counter-clockwise. + return Transform.Create(center, -Rotation, scale, GetConfinedCenter()); + } + + /// + /// Moves the view relative to its current . + /// + /// The displacement to add to the center. + public void Move(Vector2 offset) => Center += offset; + + /// + /// Moves towards a target by at most , stopping exactly at + /// it once within reach. + /// + /// Call every frame with the point you want to follow (e.g. a player's position) for a smooth chase camera. + /// The point to move towards. + /// The maximum distance to move this step. Should be non-negative. + public void MoveTowards(PointF target, float maxDistance) + { + Vector2 displacement = target - Center; + float distance = displacement.Magnitude; + + if (distance <= maxDistance || MathF.ApproximatelyZero(distance)) + { + Center = target; + return; + } + + Center += displacement / distance * maxDistance; + } + + /// + /// Rotates the view relative to its current . + /// + /// The rotation to add. + public void Rotate(Angle angle) => Rotation += angle; + + /// + /// Resets the view to show the given rectangle of the scene, clearing any rotation. + /// + /// The rectangle of the scene to show, in world units. + /// has a negative or zero width or height. + public void Reset(Rect rect) + { + Center = rect.Center; + Size = rect.Size; + Rotation = Angle.Zero; + } + + /// + /// Zooms the view by scaling around its current value. + /// + /// Values greater than 1 zoom out (show more of the scene); values between 0 and 1 zoom in. + /// is negative or zero. + public void Zoom(float factor) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(factor); + Size = new SizeF(Size.Width * factor, Size.Height * factor); + } + + private PointF GetConfinedCenter() + { + if (Bounds is not { } bounds) + return Center; + + float halfWidth = Size.Width / 2f; + float halfHeight = Size.Height / 2f; + + float x = Size.Width >= bounds.Width + ? bounds.Center.X + : Math.Clamp(Center.X, bounds.Left + halfWidth, bounds.Right - halfWidth); + + float y = Size.Height >= bounds.Height + ? bounds.Center.Y + : Math.Clamp(Center.Y, bounds.Top + halfHeight, bounds.Bottom - halfHeight); + + return new PointF(x, y); + } +} + +/// +/// A scope obtained from that carries the render state to draw through a view, +/// and restores the renderer's full-target viewport when disposed. +/// +/// +/// Pass to for everything drawn through the view. +/// +public readonly ref struct ViewScope : IDisposable +{ + private readonly Renderer _renderer; + + internal ViewScope(Renderer renderer, RenderState state) + { + _renderer = renderer; + State = state; + } + + /// + /// Gets the render state carrying the view's transform. Pass it to every draw call made through this view. + /// + public RenderState State { get; } + + /// + /// Restores the renderer's viewport to cover the whole target. + /// + public void Dispose() => _renderer.Viewport = null; +} diff --git a/tests/Unit.Tests/Graphics/Rendering/ViewTests.cs b/tests/Unit.Tests/Graphics/Rendering/ViewTests.cs new file mode 100644 index 0000000..a4d16fc --- /dev/null +++ b/tests/Unit.Tests/Graphics/Rendering/ViewTests.cs @@ -0,0 +1,339 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Graphics.Rendering; + +namespace Unit.Tests.Graphics.Rendering; + +internal sealed class ViewTests +{ + [Test] + public async Task ConstructorWithSizeShouldDefaultCenterToOrigin() + { + View view = new(new SizeF(200f, 100f)); + + await view.Center.X.Should().BeEqualTo(0f); + await view.Center.Y.Should().BeEqualTo(0f); + } + + [Test] + public async Task ConstructorWithSizeShouldSetSize() + { + View view = new(new SizeF(200f, 100f)); + + await view.Size.Width.Should().BeEqualTo(200f); + await view.Size.Height.Should().BeEqualTo(100f); + } + + [Test] + public async Task ConstructorWithCenterAndSizeShouldSetBoth() + { + View view = new(new PointF(50f, 75f), new SizeF(200f, 100f)); + + await view.Center.X.Should().BeEqualTo(50f); + await view.Center.Y.Should().BeEqualTo(75f); + await view.Size.Width.Should().BeEqualTo(200f); + await view.Size.Height.Should().BeEqualTo(100f); + } + + [Test] + public async Task ConstructorWithRectShouldMatchTheRectsCenterAndSize() + { + Rect rect = new(100f, 200f, 400f, 300f); + View view = new(rect); + + await view.Center.X.Should().BeEqualTo(300f); + await view.Center.Y.Should().BeEqualTo(350f); + await view.Size.Width.Should().BeEqualTo(400f); + await view.Size.Height.Should().BeEqualTo(300f); + } + + [Test] + public async Task ConstructorShouldDefaultViewportToTheFullTarget() + { + View view = new(new SizeF(200f, 100f)); + + await view.Viewport.X.Should().BeEqualTo(0f); + await view.Viewport.Y.Should().BeEqualTo(0f); + await view.Viewport.Width.Should().BeEqualTo(1f); + await view.Viewport.Height.Should().BeEqualTo(1f); + } + + [Test] + public async Task ConstructorShouldDefaultRotationToZero() + { + View view = new(new SizeF(200f, 100f)); + + await view.Rotation.Degrees.Should().BeEqualTo(0f); + } + + [Test] + public async Task ConstructorShouldDefaultBoundsToNull() + { + View view = new(new SizeF(200f, 100f)); + + await view.Bounds.HasValue.Should().BeFalse(); + } + + [Test] + [Arguments(0f, 100f)] + [Arguments(-10f, 100f)] + [Arguments(100f, 0f)] + [Arguments(100f, -10f)] + public async Task ConstructorWithNonPositiveSizeShouldThrow(float width, float height) + { + await Assert.That(() => new View(new SizeF(width, height))) + .ThrowsExactly(); + } + + [Test] + public async Task SizeSetterShouldUpdateTheSize() + { + View view = new(new SizeF(200f, 100f)) + { + Size = new SizeF(400f, 300f) + }; + + await view.Size.Width.Should().BeEqualTo(400f); + await view.Size.Height.Should().BeEqualTo(300f); + } + + [Test] + [Arguments(0f, 100f)] + [Arguments(100f, 0f)] + public async Task SizeSetterWithNonPositiveValueShouldThrow(float width, float height) + { + View view = new(new SizeF(200f, 100f)); + + await Assert.That(() => view.Size = new SizeF(width, height)) + .ThrowsExactly(); + } + + [Test] + public async Task MoveShouldOffsetCenterByTheGivenVector() + { + View view = new(new PointF(10f, 20f), new SizeF(200f, 100f)); + view.Move(new Vector2(5f, -5f)); + + await view.Center.X.Should().BeEqualTo(15f); + await view.Center.Y.Should().BeEqualTo(15f); + } + + [Test] + public async Task RotateShouldAddToCurrentRotation() + { + View view = new(new SizeF(200f, 100f)) + { + Rotation = Angle.FromDegrees(30f) + }; + + view.Rotate(Angle.FromDegrees(15f)); + + await view.Rotation.Degrees.Should().BeCloseTo(45f, 1e-3f); + } + + [Test] + public async Task ZoomShouldScaleSizeByFactor() + { + View view = new(new SizeF(200f, 100f)); + view.Zoom(2f); + + await view.Size.Width.Should().BeEqualTo(400f); + await view.Size.Height.Should().BeEqualTo(200f); + } + + [Test] + [Arguments(0f)] + [Arguments(-1f)] + public async Task ZoomWithNonPositiveFactorShouldThrow(float factor) + { + View view = new(new SizeF(200f, 100f)); + + await Assert.That(() => view.Zoom(factor)) + .ThrowsExactly(); + } + + [Test] + public async Task ResetShouldSetCenterAndSizeFromTheRectAndClearRotation() + { + View view = new(new SizeF(200f, 100f)) + { + Rotation = Angle.FromDegrees(90f) + }; + + view.Reset(new Rect(0f, 0f, 800f, 600f)); + + await view.Center.X.Should().BeEqualTo(400f); + await view.Center.Y.Should().BeEqualTo(300f); + await view.Size.Width.Should().BeEqualTo(800f); + await view.Size.Height.Should().BeEqualTo(600f); + await view.Rotation.Degrees.Should().BeEqualTo(0f); + } + + [Test] + public async Task MoveTowardsShouldMoveExactlyMaxDistanceWhenTargetIsFarther() + { + View view = new(new PointF(0f, 0f), new SizeF(200f, 100f)); + view.MoveTowards(new PointF(100f, 0f), maxDistance: 30f); + + await view.Center.X.Should().BeEqualTo(30f); + await view.Center.Y.Should().BeEqualTo(0f); + } + + [Test] + public async Task MoveTowardsShouldSnapToTargetWhenWithinMaxDistance() + { + View view = new(new PointF(0f, 0f), new SizeF(200f, 100f)); + view.MoveTowards(new PointF(10f, 0f), maxDistance: 30f); + + await view.Center.X.Should().BeEqualTo(10f); + await view.Center.Y.Should().BeEqualTo(0f); + } + + [Test] + public async Task MoveTowardsShouldSnapToTargetWhenExactlyAtMaxDistance() + { + View view = new(new PointF(0f, 0f), new SizeF(200f, 100f)); + view.MoveTowards(new PointF(30f, 0f), maxDistance: 30f); + + await view.Center.X.Should().BeEqualTo(30f); + await view.Center.Y.Should().BeEqualTo(0f); + } + + [Test] + public async Task MoveTowardsShouldLeaveCenterUnchangedWhenAlreadyAtTarget() + { + View view = new(new PointF(50f, 50f), new SizeF(200f, 100f)); + view.MoveTowards(new PointF(50f, 50f), maxDistance: 30f); + + await view.Center.X.Should().BeEqualTo(50f); + await view.Center.Y.Should().BeEqualTo(50f); + } + + [Test] + public async Task ComputeViewportShouldReturnTheFullTargetByDefault() + { + View view = new(new SizeF(200f, 100f)); + RectI pixels = view.ComputeViewport(new Size(1920, 1080)); + + await pixels.X.Should().BeEqualTo(0); + await pixels.Y.Should().BeEqualTo(0); + await pixels.Width.Should().BeEqualTo(1920); + await pixels.Height.Should().BeEqualTo(1080); + } + + [Test] + public async Task ComputeViewportShouldScaleByTheNormalizedRect() + { + View view = new(new SizeF(200f, 100f)) + { + Viewport = new Rect(0.5f, 0f, 0.5f, 1f) + }; + + RectI pixels = view.ComputeViewport(new Size(1920, 1080)); + + await pixels.X.Should().BeEqualTo(960); + await pixels.Y.Should().BeEqualTo(0); + await pixels.Width.Should().BeEqualTo(960); + await pixels.Height.Should().BeEqualTo(1080); + } + + [Test] + public async Task ComputeViewportShouldClampToAtLeastOnePixelWhenTheNormalizedRectRoundsToZero() + { + View view = new(new SizeF(200f, 100f)) + { + Viewport = new Rect(0f, 0f, 0.0001f, 0.0001f) + }; + + RectI pixels = view.ComputeViewport(new Size(100, 100)); + + await pixels.Width.Should().BeEqualTo(1); + await pixels.Height.Should().BeEqualTo(1); + } + + [Test] + public async Task GetTransformShouldMapCenterToTheMiddleOfTheViewport() + { + View view = new(new PointF(500f, 300f), new SizeF(200f, 100f)); + Transform transform = view.GetTransform(new Size(200, 100)); + + PointF result = transform.TransformPoint(new PointF(500f, 300f)); + + await result.X.Should().BeEqualTo(100f); + await result.Y.Should().BeEqualTo(50f); + } + + [Test] + public async Task GetTransformShouldMapTheSceneEdgeToTheViewportEdgeWhenSizeMatchesTheViewport() + { + View view = new(new PointF(0f, 0f), new SizeF(200f, 100f)); + Transform transform = view.GetTransform(new Size(200, 100)); + + PointF result = transform.TransformPoint(new PointF(100f, 50f)); + + await result.X.Should().BeEqualTo(200f); + await result.Y.Should().BeEqualTo(100f); + } + + [Test] + public async Task GetTransformShouldScaleWhenSizeDiffersFromTheViewport() + { + View view = new(new PointF(0f, 0f), new SizeF(400f, 400f)); + Transform transform = view.GetTransform(new Size(200, 200)); + + PointF result = transform.TransformPoint(new PointF(200f, 0f)); + + await result.X.Should().BeEqualTo(200f); + await result.Y.Should().BeEqualTo(100f); + } + + [Test] + public async Task GetTransformShouldRotateTheSceneOppositeToTheViewsRotation() + { + View view = new(new PointF(0f, 0f), new SizeF(200f, 200f)) + { + Rotation = Angle.FromDegrees(90f) + }; + + Transform transform = view.GetTransform(new Size(200, 200)); + PointF result = transform.TransformPoint(new PointF(100f, 0f)); + + await result.X.Should().BeCloseTo(100f, 1e-3f); + await result.Y.Should().BeCloseTo(0f, 1e-3f); + } + + [Test] + public async Task GetTransformShouldClampTheEffectiveCenterToBoundsWithoutMutatingCenter() + { + View view = new(new PointF(5000f, 5000f), new SizeF(200f, 100f)) + { + Bounds = new Rect(0f, 0f, 1000f, 600f) + }; + + Transform transform = view.GetTransform(new Size(200, 100)); + PointF viewportCenter = transform.TransformPoint(new PointF(900f, 550f)); + + await viewportCenter.X.Should().BeEqualTo(100f); + await viewportCenter.Y.Should().BeEqualTo(50f); + + await view.Center.X.Should().BeEqualTo(5000f); + await view.Center.Y.Should().BeEqualTo(5000f); + } + + [Test] + public async Task GetTransformShouldCenterOnBoundsWhenTheViewIsLargerThanBoundsOnBothAxes() + { + View view = new(new PointF(9999f, 9999f), new SizeF(500f, 500f)) + { + Bounds = new Rect(0f, 0f, 100f, 100f) + }; + + Transform transform = view.GetTransform(new Size(500, 500)); + PointF viewportCenter = transform.TransformPoint(new PointF(50f, 50f)); + + await viewportCenter.X.Should().BeEqualTo(250f); + await viewportCenter.Y.Should().BeEqualTo(250f); + } +}