diff --git a/readme.md b/readme.md index da7ce57..264677c 100644 --- a/readme.md +++ b/readme.md @@ -23,25 +23,26 @@ It targets .NET 10+ desktop and web apps, providing a clean and flexible API tha ```csharp using KappaDuck.Quack.Core; using KappaDuck.Quack.Events; +using KappaDuck.Quack.Graphics.Rendering; using KappaDuck.Quack.Input; +using KappaDuck.Quack.Video.Pixels; using KappaDuck.Quack.Windows; using EngineScope _ = QuackEngine.Init(Subsystem.Video); -using Window window = new("Quack!", 1920, 1080) -{ - Resizable = true -}; +using Window window = new("Quack!", 1920, 1080) { Resizable = true }; +using Renderer renderer = new(window); while (window.IsOpen) { while (window.Poll(out Event e)) { if (e is QuitRequestedEvent or KeyPressedEvent { Key: Key.Escape }) - { return; - } } + + renderer.Clear(Colors.Black); + renderer.Present(); } ``` @@ -102,7 +103,7 @@ During active development, `KappaDuck.Quack` references the **pre-release** vers | Quack! | Runtimes | SDL3 | SDL_image | SDL_ttf | SDL_mixer | | :------: | :------------: | :------: | :-------: | :-----: | :-------: | -| `source` | `0.1.0-beta.2` | `3.4.10` | `3.4.4` | `3.2.2` | `3.2.2` | +| `source` | `0.1.0-beta.4` | `3.4.12` | `3.4.4` | `3.2.2` | `3.2.4` | ## Development & Sandbox @@ -129,20 +130,19 @@ The sandbox project requires at least one `.cs` file to compile. All `.cs` files ```csharp using KappaDuck.Quack.Core; using KappaDuck.Quack.Events; +using KappaDuck.Quack.Input; using KappaDuck.Quack.Windows; using EngineScope _ = QuackEngine.Init(Subsystem.Video); -using Window window = new("Quack! Sandbox", 1280, 720); + +using Window window = new("Quack!", 1920, 1080) { Resizable = true }; while (window.IsOpen) { while (window.Poll(out Event e)) { - if (e is QuitEvent || e is KeyEvent { Key: Key.Escape }) - { - window.Close(); + if (e is QuitRequestedEvent or KeyPressedEvent { Key: Key.Escape }) return; - } } } ``` diff --git a/runtimes/utils/sdl.env b/runtimes/utils/sdl.env index 1a4a23b..25bdd61 100644 --- a/runtimes/utils/sdl.env +++ b/runtimes/utils/sdl.env @@ -1,4 +1,4 @@ -SDL3=3.4.10 +SDL3=3.4.12 SDL3_IMAGE=3.4.4 SDL3_TTF=3.2.2 -SDL3_MIXER=3.2.2 +SDL3_MIXER=3.2.4 diff --git a/src/KappaDuck.Quack/Graphics/Drawing/IDrawable.cs b/src/KappaDuck.Quack/Graphics/Drawing/IDrawable.cs new file mode 100644 index 0000000..7ad976e --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Drawing/IDrawable.cs @@ -0,0 +1,25 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Graphics.Rendering; + +namespace KappaDuck.Quack.Graphics.Drawing; + +/// +/// Represents an object that can draw itself onto an , such as a sprite, shape or anything. +/// +public interface IDrawable +{ + /// + /// Draws this object onto . + /// + /// + /// Implementations fold their own transform into (for example with + /// state = state with { Transform = state.Transform * localTransform }), set the texture they need, then + /// lower themselves to the target's vertex draws. Call rather than + /// this method directly. + /// + /// The target to draw onto. + /// The render state to draw with, carrying the accumulated transform, blend mode and texture. + void Draw(IRenderTarget target, RenderState state); +} diff --git a/src/KappaDuck.Quack/Graphics/Drawing/IRenderTarget.cs b/src/KappaDuck.Quack/Graphics/Drawing/IRenderTarget.cs new file mode 100644 index 0000000..dea07da --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Drawing/IRenderTarget.cs @@ -0,0 +1,47 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Graphics.Primitives; +using KappaDuck.Quack.Graphics.Rendering; + +namespace KappaDuck.Quack.Graphics.Drawing; + +/// +/// Represents a surface that can be drawn onto, such as a . +/// +/// +/// Drawables are rendered with ; they in turn lower themselves to the vertex overloads, +/// which apply the render state's transform, blend mode and texture. +/// +public interface IRenderTarget +{ + /// + /// Draws onto this target using the state. + /// + /// The object to draw. + void Draw(IDrawable drawable); + + /// + /// Draws onto this target using the given render state. + /// + /// The object to draw. + /// The render state to draw with. + void Draw(IDrawable drawable, RenderState state); + + /// + /// Draws a sequence of vertices as triangles onto this target. + /// + /// The render state's transform is applied to each vertex, and its texture, when set, textures the triangles. + /// The vertices to draw, taken three at a time as triangles. + /// The render state to draw with. + void Draw(ReadOnlySpan vertices, RenderState state); + + /// + /// Draws indexed vertices as triangles onto this target. + /// + /// The render state's transform is applied to each vertex, and its texture, when set, textures the triangles. + /// The vertices to draw. + /// Indices into , taken three at a time as triangles. + /// The render state to draw with. + void Draw(ReadOnlySpan vertices, ReadOnlySpan indices, RenderState state); +} diff --git a/src/KappaDuck.Quack/Graphics/Drawing/Sprite.cs b/src/KappaDuck.Quack/Graphics/Drawing/Sprite.cs new file mode 100644 index 0000000..40c25a8 --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Drawing/Sprite.cs @@ -0,0 +1,145 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Graphics.Primitives; +using KappaDuck.Quack.Graphics.Rendering; +using KappaDuck.Quack.Video.Pixels; + +namespace KappaDuck.Quack.Graphics.Drawing; + +/// +/// A drawable image, or a region of one, that can be positioned, rotated, scaled and tinted. +/// +/// +/// A sprite is a lightweight view onto a : many sprites can share one texture. Draw it with +/// . +/// +public class Sprite : Transformable, IDrawable +{ + private static readonly int[] _indices = [0, 1, 2, 0, 2, 3]; + private readonly Vertex[] _vertices = new Vertex[4]; + + private Texture _texture; + private RectI _region; + private Color _color = Colors.White; + + /// + /// Creates a sprite that displays the whole of . + /// + /// The texture to display. + public Sprite(Texture texture) : this(texture, new RectI(0, 0, texture.Width, texture.Height)) + { + } + + /// + /// Creates a sprite that displays a region of . + /// + /// The texture to display. + /// The region of the texture to display, in texture pixels. + public Sprite(Texture texture, RectI region) + { + _texture = texture; + _region = region; + + UpdateVertices(); + } + + /// + /// Gets or sets the texture the sprite displays. + /// + /// The current is kept; assign it again if the new texture has different dimensions. + public Texture Texture + { + get => _texture; + set + { + _texture = value; + UpdateVertices(); + } + } + + /// + /// Gets or sets the region of the the sprite displays, in texture pixels. + /// + public RectI Region + { + get => _region; + set + { + _region = value; + UpdateVertices(); + } + } + + /// + /// Gets or sets a color multiplied into the sprite, tinting it. + /// + /// Defaults to , which leaves the texture unchanged; the color's alpha sets the sprite's opacity. + public Color Color + { + get => _color; + set + { + _color = value; + UpdateColors(); + } + } + + /// + /// Gets the sprite's bounding rectangle in its own local space, before its transform is applied. + /// + /// Its size matches , with the top-left corner at (0, 0). + public Rect LocalBounds => new(0f, 0f, _region.Width, _region.Height); + + /// + /// Gets the sprite's axis-aligned bounding rectangle in its parent's space, after its transform is applied. + /// + /// When the sprite is rotated, this is the smallest upright rectangle that contains it. + public Rect GlobalBounds => Transform.TransformRect(LocalBounds); + + /// + public void Draw(IRenderTarget target, RenderState state) + { + state = state with + { + Transform = state.Transform * Transform, + Texture = _texture + }; + + target.Draw(_vertices, _indices, state); + } + + private void UpdateVertices() + { + float width = _region.Width; + float height = _region.Height; + + _vertices[0].Position = new PointF(0f, 0f); + _vertices[1].Position = new PointF(width, 0f); + _vertices[2].Position = new PointF(width, height); + _vertices[3].Position = new PointF(0f, height); + + float left = (float)_region.X / _texture.Width; + float top = (float)_region.Y / _texture.Height; + float right = (float)(_region.X + _region.Width) / _texture.Width; + float bottom = (float)(_region.Y + _region.Height) / _texture.Height; + + _vertices[0].TextureCoordinate = new PointF(left, top); + _vertices[1].TextureCoordinate = new PointF(right, top); + _vertices[2].TextureCoordinate = new PointF(right, bottom); + _vertices[3].TextureCoordinate = new PointF(left, bottom); + + UpdateColors(); + } + + private void UpdateColors() + { + ColorF color = _color.ToColorF(); + + _vertices[0].Color = color; + _vertices[1].Color = color; + _vertices[2].Color = color; + _vertices[3].Color = color; + } +} diff --git a/src/KappaDuck.Quack/Graphics/Rendering/RenderState.cs b/src/KappaDuck.Quack/Graphics/Rendering/RenderState.cs new file mode 100644 index 0000000..e2281cd --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Rendering/RenderState.cs @@ -0,0 +1,50 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Graphics.Drawing; + +namespace KappaDuck.Quack.Graphics.Rendering; + +/// +/// The set of parameters that control how a drawable is rendered onto an : the transform +/// applied to its vertices, the blend mode, and the texture it is drawn with. +/// +/// +/// Create a render state with an object initializer, for example new RenderState { Texture = texture }, or from +/// , then layer on additional transforms with a with expression. Avoid +/// default(RenderState): it leaves the unset rather than at +/// . +/// +public readonly record struct RenderState +{ + /// + /// Creates a render state with an identity , alpha blending and no texture. + /// + public RenderState() + { + Transform = Transform.Identity; + BlendMode = BlendMode.Blend; + Texture = null; + } + + /// + /// Gets the default render state: an identity transform, alpha blending and no texture. + /// + public static RenderState Default { get; } = new(); + + /// + /// Gets the transform applied to the vertices before they are drawn. Defaults to . + /// + public Transform Transform { get; init; } + + /// + /// Gets the blend mode used to combine the drawing with the target. Defaults to . + /// + public BlendMode BlendMode { get; init; } + + /// + /// Gets the texture the vertices are drawn with, or to draw untextured geometry. + /// + public Texture? Texture { get; init; } +} diff --git a/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs b/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs index 4a1d369..71f9491 100644 --- a/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs +++ b/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs @@ -9,6 +9,7 @@ using KappaDuck.Quack.Video; using KappaDuck.Quack.Video.Pixels; using KappaDuck.Quack.Windows; +using System.Buffers; using System.ComponentModel; namespace KappaDuck.Quack.Graphics.Rendering; @@ -20,8 +21,10 @@ namespace KappaDuck.Quack.Graphics.Rendering; /// A window can host only one renderer or surface at a time; creating a renderer binds the /// window until the renderer is disposed. /// -public sealed class Renderer : IDisposable +public sealed class Renderer : IRenderTarget, IDisposable { + private const int MaxStackVertices = 64; + private SDL_Renderer* _handle; private Window? _window; @@ -377,7 +380,7 @@ public Vector2 Scale } /// - /// Gets or sets the texture addressing mode used in or . + /// Gets or sets the texture addressing mode used in or . /// /// The renderer is disposed. public (TextureAddressMode Horizontal, TextureAddressMode Vertical) TextureAddressMode @@ -568,27 +571,22 @@ public void Dispose() _handle = null; } - /// - /// Draws the vertices to the render target. - /// - /// The vertices to draw to the render target. - public void Draw(ReadOnlySpan vertices) - { - ThrowIfDisposed(); - SDLThrowHelper.ThrowIfFailed(SDL3.UnsafeRenderGeometry(_handle, null, vertices, vertices.Length, null, 0)); - } + /// + public void Draw(IDrawable drawable) => Draw(drawable, RenderState.Default); - /// - /// Draws the vertices to the render target. - /// - /// The vertices to draw to the render target. - /// The indices to draw the vertices in the correct order. - public void Draw(ReadOnlySpan vertices, ReadOnlySpan indices) + /// + public void Draw(IDrawable drawable, RenderState state) { ThrowIfDisposed(); - SDLThrowHelper.ThrowIfFailed(SDL3.RenderGeometry(_handle, null, vertices, vertices.Length, indices, indices.Length)); + drawable.Draw(this, state); } + /// + public void Draw(ReadOnlySpan vertices, RenderState state) => DrawGeometry(vertices, [], state); + + /// + public void Draw(ReadOnlySpan vertices, ReadOnlySpan indices, RenderState state) => DrawGeometry(vertices, indices, state); + /// /// Draws a single point. /// @@ -993,5 +991,48 @@ public void SetDrawingColor(ColorF color) SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColorFloat(_handle, color.R, color.G, color.B, color.A)); } + private void DrawGeometry(ReadOnlySpan vertices, ReadOnlySpan indices, RenderState state) + { + ThrowIfDisposed(); + + BlendMode = state.BlendMode; + SDL_Texture* texture = state.Texture?.Handle; + + if (state.Transform == Transform.Identity || state.Transform == default) + { + Submit(texture, vertices, indices); + return; + } + + int count = vertices.Length; + + Vertex[]? rented = count > MaxStackVertices ? ArrayPool.Shared.Rent(count) : null; + Span transformed = rented ?? stackalloc Vertex[count]; + + for (int i = 0; i < count; i++) + { + Vertex vertex = vertices[i]; + vertex.Position = state.Transform.TransformPoint(vertex.Position); + + transformed[i] = vertex; + } + + Submit(texture, transformed, indices); + + if (rented is not null) + ArrayPool.Shared.Return(rented); + } + + private void Submit(SDL_Texture* texture, ReadOnlySpan vertices, ReadOnlySpan indices) + { + if (indices.IsEmpty) + { + SDLThrowHelper.ThrowIfFailed(SDL3.UnsafeRenderGeometry(_handle, texture, vertices, vertices.Length, null, 0)); + return; + } + + SDLThrowHelper.ThrowIfFailed(SDL3.RenderGeometry(_handle, texture, vertices, vertices.Length, indices, indices.Length)); + } + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_handle is null, typeof(Renderer)); } diff --git a/src/KappaDuck.Quack/Graphics/Rendering/Transformable.cs b/src/KappaDuck.Quack/Graphics/Rendering/Transformable.cs new file mode 100644 index 0000000..69ceae3 --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Rendering/Transformable.cs @@ -0,0 +1,132 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Geometry; + +namespace KappaDuck.Quack.Graphics.Rendering; + +/// +/// A base class for objects that can be positioned, rotated, scaled and pivoted in 2D space. +/// +/// +/// The is derived from , , and +/// , and is recomputed only when one of them changes. +/// +public abstract class Transformable +{ + private bool _dirty; + + /// + /// Gets or sets the position of the object's in its parent's space. Defaults to (0, 0). + /// + public PointF Position + { + get; + set + { + field = value; + _dirty = true; + } + } + + /// + /// Gets or sets the local point that refers to and that rotation and scaling pivot around. + /// + /// Defaults to (0, 0), the top-left corner. Set it to the object's center to rotate and scale about the middle. + public PointF Origin + { + get; + set + { + field = value; + _dirty = true; + } + } + + /// + /// Gets or sets the rotation applied to the object, clockwise on screen. Defaults to no rotation. + /// + public Angle Rotation + { + get; + set + { + field = value; + _dirty = true; + } + } + + /// + /// Gets or sets the horizontal and vertical scale factors. Defaults to (1, 1). + /// + public Vector2 Scale + { + get; + set + { + field = value; + _dirty = true; + } + } = Vector2.One; + + /// + /// Gets the combined transform that places the object in its parent's space, built from + /// , , and . + /// + public Transform Transform + { + get + { + if (_dirty) + { + field = Transform.Create(Position, Rotation, Scale, Origin); + _dirty = false; + } + + return field; + } + } = Transform.Identity; + + /// + /// Moves the object relative to its current . + /// + /// The displacement to add to the position. + public void Move(Vector2 offset) => Position += offset; + + /// + /// Moves the object towards a target position by at most , stopping exactly at it once within reach. + /// + /// The position to move towards. + /// The maximum distance to move this step. Should be non-negative. + public void MoveTowards(PointF target, float maxDistance) + { + Vector2 displacement = target - Position; + float distance = displacement.Magnitude; + + if (distance <= maxDistance || MathF.ApproximatelyZero(distance)) + { + Position = target; + return; + } + + Position += displacement / distance * maxDistance; + } + + /// + /// Rotates the object relative to its current . + /// + /// The angle to add to the rotation, clockwise on screen. + public void Rotate(Angle angle) => Rotation += angle; + + /// + /// Scales the object relative to its current . + /// + /// The horizontal and vertical factors to multiply the scale by. + public void ScaleBy(Vector2 factor) => Scale *= factor; + + /// + /// Scales the object uniformly relative to its current . + /// + /// The factor to multiply both scale components by. + public void ScaleBy(float factor) => Scale *= factor; +} diff --git a/tests/Unit.Tests/UI/Progress/Reporters/AsyncIndeterminateProgressReporterTests.cs b/tests/Unit.Tests/UI/Progress/Reporters/AsyncIndeterminateProgressReporterTests.cs index c9b3e7c..d53f4a4 100644 --- a/tests/Unit.Tests/UI/Progress/Reporters/AsyncIndeterminateProgressReporterTests.cs +++ b/tests/Unit.Tests/UI/Progress/Reporters/AsyncIndeterminateProgressReporterTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using KappaDuck.Quack.UI.Progress.Reporters; +using System.Diagnostics.CodeAnalysis; namespace Unit.Tests.UI.Progress.Reporters; @@ -12,6 +13,7 @@ internal sealed class AsyncIndeterminateProgressReporterTests : IDisposable public void Dispose() => _reporter.Dispose(); [Test] + [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "The test requires to test the method Cancel")] public async Task CancelShouldRequestToCancelTheToken() { _reporter.Cancel(); diff --git a/tests/Unit.Tests/UI/Progress/Reporters/AsyncProgressReporterTests.cs b/tests/Unit.Tests/UI/Progress/Reporters/AsyncProgressReporterTests.cs index fc6a31b..60a1771 100644 --- a/tests/Unit.Tests/UI/Progress/Reporters/AsyncProgressReporterTests.cs +++ b/tests/Unit.Tests/UI/Progress/Reporters/AsyncProgressReporterTests.cs @@ -3,6 +3,7 @@ using KappaDuck.Quack.UI.Progress; using KappaDuck.Quack.UI.Progress.Reporters; +using System.Diagnostics.CodeAnalysis; namespace Unit.Tests.UI.Progress.Reporters; @@ -19,6 +20,7 @@ public AsyncProgressReporterTests() public void Dispose() => _reporter.Dispose(); [Test] + [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "The test requires to test the method Cancel")] public async Task CancelShouldRequestToCancelTheToken() { _reporter.Cancel();