diff --git a/src/KappaDuck.Quack/Graphics/Primitives/PrimitiveType.cs b/src/KappaDuck.Quack/Graphics/Primitives/PrimitiveType.cs new file mode 100644 index 0000000..58c6eee --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Primitives/PrimitiveType.cs @@ -0,0 +1,25 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Graphics.Primitives; + +/// +/// Describes how a sequence of vertices is assembled into triangles. +/// +public enum PrimitiveType +{ + /// + /// Every three vertices form one independent triangle. + /// + Triangles = 0, + + /// + /// Each vertex after the first two forms a triangle with the previous two vertices, sharing an edge with the last triangle. + /// + TriangleStrip = 1, + + /// + /// Each vertex after the first two forms a triangle with the first vertex and the previous one, like the slices of a pie. + /// + TriangleFan = 2 +} diff --git a/src/KappaDuck.Quack/Graphics/Primitives/VertexArray.cs b/src/KappaDuck.Quack/Graphics/Primitives/VertexArray.cs new file mode 100644 index 0000000..955eaa2 --- /dev/null +++ b/src/KappaDuck.Quack/Graphics/Primitives/VertexArray.cs @@ -0,0 +1,288 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Graphics.Drawing; +using KappaDuck.Quack.Graphics.Rendering; +using KappaDuck.Quack.Video.Pixels; +using System.Collections; +using System.Diagnostics; + +namespace KappaDuck.Quack.Graphics.Primitives; + +/// +/// A growable, drawable set of vertices assembled into triangles and drawn in a single call. +/// +/// +/// Use it to batch many primitives — tile maps, particle systems, custom meshes — into one draw for performance. +/// Choose how the vertices are assembled with , and set an optional +/// to texture the whole batch. Unlike a shape, a vertex array has no transform of its own; its vertices are in the +/// coordinate space you give them, and any transform comes from the it is drawn with. +/// +[DebuggerDisplay("Count = {Count}")] +[CollectionBuilder(typeof(VertexArray), nameof(Create))] +public sealed class VertexArray : IDrawable, IEnumerable +{ + private Vertex[] _vertices; + private int[] _indices = []; + private int _indexCount; + private bool _indicesDirty = true; + private PrimitiveType _primitiveType; + + /// + /// Creates an empty vertex array. + /// + /// How the vertices are assembled into triangles. + /// The number of vertices to reserve space for up front. + /// is negative. + public VertexArray(PrimitiveType primitiveType = PrimitiveType.Triangles, int capacity = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(capacity); + + _primitiveType = primitiveType; + _vertices = capacity > 0 ? new Vertex[capacity] : []; + } + + internal VertexArray(PrimitiveType primitiveType, int capacity, ReadOnlySpan vertices) : this(primitiveType, capacity) + { + Count = vertices.Length; + vertices.CopyTo(_vertices); + } + + /// + /// Gets or sets how the vertices are assembled into triangles. + /// + public PrimitiveType PrimitiveType + { + get => _primitiveType; + set + { + _primitiveType = value; + _indicesDirty = true; + } + } + + /// + /// Gets or sets the texture applied to the whole batch, or to draw untextured, colored geometry. + /// + public Texture? Texture { get; set; } + + /// + /// Gets the number of vertices in the array. + /// + public int Count { get; private set; } + + /// + /// Gets a reference to the vertex at the given index, which can be read or modified in place. + /// + /// The index of the vertex, from 0 to minus one. + /// A reference to the vertex. + /// is out of range. + public ref Vertex this[int index] + { + get + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Count); + + return ref _vertices[index]; + } + } + + /// + /// Returns the vertices as a span, for reading or bulk in-place modification. + /// + /// + /// The span is only valid until the next operation that changes the number of vertices. + /// + /// A span over the current vertices. + public Span AsSpan() => _vertices.AsSpan(0, Count); + + /// + /// Appends a vertex to the end of the array. + /// + /// The vertex to append. + public void Add(Vertex vertex) + { + if (Count == _vertices.Length) + Array.Resize(ref _vertices, _vertices.Length == 0 ? 4 : _vertices.Length * 2); + + _vertices[Count++] = vertex; + _indicesDirty = true; + } + + /// + /// Appends a quad, as two triangles, covering a destination rectangle. + /// + /// + /// Intended for a array; each call adds six vertices. + /// + /// The rectangle the quad covers, in render coordinates. + /// The color of the quad. + public void AddQuad(Rect destination, Color color) => AddQuad(destination, new Rect(0f, 0f, 1f, 1f), color); + + /// + /// Appends a textured quad, as two triangles, covering a destination rectangle. + /// + /// + /// Intended for a array; each call adds six vertices. + /// + /// The rectangle the quad covers, in render coordinates. + /// The region of the texture mapped onto the quad, in normalized coordinates where (0, 0) is the top-left and (1, 1) is the bottom-right. + /// The color multiplied into the quad. + public void AddQuad(Rect destination, Rect textureRegion, Color color) + { + ColorF tint = color.ToColorF(); + + Vertex topLeft = new(destination.TopLeft, tint, textureRegion.TopLeft); + Vertex topRight = new(destination.TopRight, tint, textureRegion.TopRight); + Vertex bottomRight = new(destination.BottomRight, tint, textureRegion.BottomRight); + Vertex bottomLeft = new(destination.BottomLeft, tint, textureRegion.BottomLeft); + + AddRange(topLeft, topRight, bottomRight, topLeft, bottomRight, bottomLeft); + } + + /// + /// Appends several vertices to the end of the array. + /// + /// The vertices to append. + public void AddRange(params ReadOnlySpan vertices) + { + int required = Count + vertices.Length; + + if (required > _vertices.Length) + Array.Resize(ref _vertices, int.Max(required, _vertices.Length * 2)); + + vertices.CopyTo(_vertices.AsSpan(Count)); + + Count = required; + _indicesDirty = true; + } + + /// + /// Creates a vertex array assembled as from the given vertices. + /// + /// The initial vertices. + /// A new vertex array containing the vertices. + public static VertexArray Create(ReadOnlySpan vertices) => Create(PrimitiveType.Triangles, vertices); + + /// + /// Creates a vertex array with the given primitive type from the given vertices. + /// + /// How the vertices are assembled into triangles. + /// The initial vertices. + /// A new vertex array containing the vertices. + public static VertexArray Create(PrimitiveType primitiveType, ReadOnlySpan vertices) + => Create(primitiveType, vertices.Length, vertices); + + /// + /// Creates a vertex array with the given primitive type and reserved capacity from the given vertices. + /// + /// How the vertices are assembled into triangles. + /// The number of vertices to reserve space for up front. + /// The initial vertices. + /// A new vertex array containing the vertices. + /// is negative. + public static VertexArray Create(PrimitiveType primitiveType, int capacity, ReadOnlySpan vertices) + { + ArgumentOutOfRangeException.ThrowIfNegative(capacity); + return new(primitiveType, int.Max(capacity, vertices.Length), vertices); + } + + /// + /// Removes all vertices from the array while keeping its capacity. + /// + public void Clear() + { + Count = 0; + _indicesDirty = true; + } + + /// + public void Draw(IRenderTarget target, RenderState state) + { + if (Count < 3) + return; + + ReadOnlySpan vertices = _vertices.AsSpan(0, Count); + + if (Texture is not null) + state = state with { Texture = Texture }; + + if (_primitiveType == PrimitiveType.Triangles) + { + target.Draw(vertices, state); + return; + } + + EnsureIndices(); + target.Draw(vertices, _indices.AsSpan(0, _indexCount), state); + } + + /// + /// Returns an enumerator that iterates over the vertices. + /// + /// An enumerator over the vertices. + public IEnumerator GetEnumerator() + { + for (int i = 0; i < Count; i++) + yield return _vertices[i]; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + /// Resizes the array, appending zeroed vertices or dropping trailing ones as needed. + /// + /// The new number of vertices. + /// is negative. + public void Resize(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + + if (count > _vertices.Length) + Array.Resize(ref _vertices, count); + + if (count > Count) + Array.Clear(_vertices, Count, count - Count); + else if (count < Count) + Array.Clear(_vertices, count, Count - count); + + Count = count; + _indicesDirty = true; + } + + private void EnsureIndices() + { + if (!_indicesDirty) + return; + + int triangles = Count - 2; + int required = triangles * 3; + + if (_indices.Length < required) + _indices = new int[required]; + + if (_primitiveType == PrimitiveType.TriangleStrip) + { + for (int i = 0; i < triangles; i++) + { + _indices[(i * 3) + 0] = i; + _indices[(i * 3) + 1] = i + 1; + _indices[(i * 3) + 2] = i + 2; + } + } + else + { + for (int i = 0; i < triangles; i++) + { + _indices[(i * 3) + 0] = 0; + _indices[(i * 3) + 1] = i + 1; + _indices[(i * 3) + 2] = i + 2; + } + } + + _indexCount = required; + _indicesDirty = false; + } +} diff --git a/tests/Unit.Tests/Graphics/Primitives/VertexArrayTests.cs b/tests/Unit.Tests/Graphics/Primitives/VertexArrayTests.cs new file mode 100644 index 0000000..46fdf99 --- /dev/null +++ b/tests/Unit.Tests/Graphics/Primitives/VertexArrayTests.cs @@ -0,0 +1,340 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Graphics.Drawing; +using KappaDuck.Quack.Graphics.Primitives; +using KappaDuck.Quack.Graphics.Rendering; +using KappaDuck.Quack.Video.Pixels; + +namespace Unit.Tests.Graphics.Primitives; + +internal sealed class VertexArrayTests +{ + private readonly CapturingTarget _target = new(); + + [Test] + public async Task ConstructorShouldStartEmpty() + { + VertexArray array = []; + await array.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task ConstructorWithNegativeCapacityShouldThrow() + { + await Assert.That(() => new VertexArray(PrimitiveType.Triangles, -1)) + .ThrowsExactly(); + } + + [Test] + public async Task PrimitiveTypeShouldDefaultToTriangles() + { + VertexArray array = []; + await array.PrimitiveType.Should().BeEqualTo(PrimitiveType.Triangles); + } + + [Test] + public async Task TextureShouldBeNullByDefault() + { + VertexArray array = []; + await array.Texture.Should().BeNull(); + } + + [Test] + public async Task AddShouldIncreaseCount() + { + VertexArray array = []; + array.Add(Vertex(0f, 0f)); + + await array.Count.Should().BeEqualTo(1); + } + + [Test] + public async Task AddShouldStoreTheVertex() + { + VertexArray array = []; + array.Add(Vertex(3f, 4f)); + + await array[0].Position.Should().BeEqualTo(new PointF(3f, 4f)); + } + + [Test] + public async Task AddRangeShouldAppendEveryVertex() + { + VertexArray array = []; + array.AddRange(Vertex(0f, 0f), Vertex(1f, 1f)); + + await array.Count.Should().BeEqualTo(2); + } + + [Test] + public async Task AddShouldGrowBeyondInitialCapacity() + { + VertexArray array = new(capacity: 1) + { + Vertex(0f, 0f), + Vertex(1f, 1f), + Vertex(2f, 2f) + }; + + await array.Count.Should().BeEqualTo(3); + await array[2].Position.Should().BeEqualTo(new PointF(2f, 2f)); + } + + [Test] + public async Task AddQuadShouldAddSixVertices() + { + VertexArray array = []; + array.AddQuad(new Rect(5f, 10f, 20f, 30f), Colors.White); + + await array.Count.Should().BeEqualTo(6); + } + + [Test] + public async Task AddQuadShouldFormTwoTrianglesCoveringTheRectangle() + { + VertexArray array = []; + array.AddQuad(new Rect(5f, 10f, 20f, 30f), Colors.White); + + await array[0].Position.Should().BeEqualTo(new PointF(5f, 10f)); + await array[1].Position.Should().BeEqualTo(new PointF(25f, 10f)); + await array[2].Position.Should().BeEqualTo(new PointF(25f, 40f)); + await array[3].Position.Should().BeEqualTo(new PointF(5f, 10f)); + await array[4].Position.Should().BeEqualTo(new PointF(25f, 40f)); + await array[5].Position.Should().BeEqualTo(new PointF(5f, 40f)); + } + + [Test] + public async Task AddQuadShouldUseTheFullTextureByDefault() + { + VertexArray array = []; + array.AddQuad(new Rect(0f, 0f, 10f, 10f), Colors.White); + + await array[0].TextureCoordinate.Should().BeEqualTo(new PointF(0f, 0f)); + await array[2].TextureCoordinate.Should().BeEqualTo(new PointF(1f, 1f)); + } + + [Test] + public async Task AddQuadWithRegionShouldMapTextureCoordinates() + { + VertexArray array = []; + array.AddQuad(new Rect(0f, 0f, 10f, 10f), new Rect(0.25f, 0.5f, 0.25f, 0.25f), Colors.White); + + await array[0].TextureCoordinate.Should().BeEqualTo(new PointF(0.25f, 0.5f)); + await array[2].TextureCoordinate.Should().BeEqualTo(new PointF(0.5f, 0.75f)); + } + + [Test] + public async Task AddQuadShouldTintEveryVertex() + { + Color color = new(10, 20, 30, 40); + + VertexArray array = []; + array.AddQuad(new Rect(0f, 0f, 10f, 10f), color); + + Vertex[] vertices = [.. array]; + ColorF expected = color.ToColorF(); + + await vertices.Should().All(v => v.Color.Equals(expected)); + } + + [Test] + public async Task IndexerShouldModifyVertexInPlace() + { + VertexArray array = []; + array.Add(Vertex(0f, 0f)); + + array[0].Position = new PointF(42f, 24f); + + await array[0].Position.Should().BeEqualTo(new PointF(42f, 24f)); + } + + [Test] + [Arguments(-1)] + [Arguments(3)] + public async Task IndexerWithInvalidIndexShouldThrow(int index) + { + VertexArray array = [Vertex(0f, 0f), Vertex(1f, 1f), Vertex(2f, 2f)]; + + await Assert.That(() => _ = array[index]) + .ThrowsExactly(); + } + + [Test] + public async Task ResizeShouldGrowWithDefaultVertices() + { + VertexArray array = []; + array.Add(Vertex(1f, 1f)); + array.Resize(3); + + await array.Count.Should().BeEqualTo(3); + await array[2].Position.Should().BeEqualTo(new PointF(0f, 0f)); + } + + [Test] + public async Task ResizeAfterClearShouldExposeDefaultVertices() + { + VertexArray array = [Vertex(5f, 5f), Vertex(6f, 6f)]; + + array.Clear(); + array.Resize(2); + + await array[0].Position.Should().BeEqualTo(new PointF(0f, 0f)); + await array[1].Position.Should().BeEqualTo(new PointF(0f, 0f)); + } + + [Test] + public async Task ResizeShouldDropTrailingVertices() + { + VertexArray array = [Vertex(0f, 0f), Vertex(1f, 1f), Vertex(2f, 2f)]; + array.Resize(1); + + await array.Count.Should().BeEqualTo(1); + } + + [Test] + public async Task ResizeWithNegativeCountShouldThrow() + { + VertexArray array = []; + + await Assert.That(() => array.Resize(-1)) + .ThrowsExactly(); + } + + [Test] + public async Task ClearShouldResetCount() + { + VertexArray array = [Vertex(0f, 0f), Vertex(1f, 1f), Vertex(2f, 2f)]; + array.Clear(); + + await array.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task AsSpanShouldReturnTheCurrentVertices() + { + VertexArray array = [Vertex(0f, 0f), Vertex(1f, 1f)]; + + await array.AsSpan().Length.Should().BeEqualTo(2); + } + + [Test] + public async Task CollectionExpressionShouldCreateTrianglesVertexArray() + { + VertexArray array = [Vertex(0f, 0f), Vertex(10f, 0f), Vertex(10f, 10f)]; + + await array.Count.Should().BeEqualTo(3); + await array.PrimitiveType.Should().BeEqualTo(PrimitiveType.Triangles); + } + + [Test] + public async Task CollectionExpressionWithPrimitiveTypeShouldSetIt() + { + VertexArray array = [with(PrimitiveType.TriangleFan), Vertex(0f, 0f), Vertex(10f, 0f), Vertex(10f, 10f)]; + + await array.Count.Should().BeEqualTo(3); + await array.PrimitiveType.Should().BeEqualTo(PrimitiveType.TriangleFan); + } + + [Test] + public async Task CollectionExpressionWithCapacityShouldCreateVertexArray() + { + VertexArray array = [with(PrimitiveType.Triangles, capacity: 10), Vertex(0f, 0f), Vertex(10f, 0f), Vertex(10f, 10f)]; + + await array.Count.Should().BeEqualTo(3); + await array.PrimitiveType.Should().BeEqualTo(PrimitiveType.Triangles); + } + + [Test] + public async Task CreateWithNegativeCapacityShouldThrow() + { + await Assert.That(() => VertexArray.Create(PrimitiveType.Triangles, -1, [])) + .ThrowsExactly(); + } + + [Test] + public async Task DrawWithTrianglesShouldSubmitWithoutIndices() + { + VertexArray array = [Vertex(0f, 0f), Vertex(10f, 0f), Vertex(10f, 10f)]; + + array.Draw(_target, RenderState.Default); + + await _target.Drawn.Should().BeTrue(); + await _target.Indices.Should().BeNull(); + await _target.Vertices.Length.Should().BeEqualTo(3); + } + + [Test] + public async Task DrawWithFewerThanThreeVerticesShouldNotDraw() + { + VertexArray array = [Vertex(0f, 0f), Vertex(10f, 0f)]; + + array.Draw(_target, RenderState.Default); + + await _target.Drawn.Should().BeFalse(); + } + + [Test] + public async Task DrawWithTriangleStripShouldGenerateStripIndices() + { + VertexArray array = + [ + with(PrimitiveType.TriangleStrip), + Vertex(0f, 0f), Vertex(10f, 0f), Vertex(10f, 10f), Vertex(0f, 10f) + ]; + + array.Draw(_target, RenderState.Default); + + await _target.Indices!.Length.Should().BeEqualTo(6); + await _target.Indices[3].Should().BeEqualTo(1); + } + + [Test] + public async Task DrawWithTriangleFanShouldGenerateFanIndices() + { + VertexArray array = + [ + with(PrimitiveType.TriangleFan), + Vertex(0f, 0f), Vertex(10f, 0f), Vertex(10f, 10f), Vertex(0f, 10f) + ]; + + array.Draw(_target, RenderState.Default); + + await _target.Indices!.Length.Should().BeEqualTo(6); + await _target.Indices[3].Should().BeEqualTo(0); + } + + private static Vertex Vertex(float x, float y) => new(new PointF(x, y), Colors.White); + + private sealed class CapturingTarget : IRenderTarget + { + public bool Drawn { get; private set; } + + public Vertex[] Vertices { get; private set; } = []; + + public int[]? Indices { get; private set; } + + public RenderState State { get; private set; } + + public void Draw(IDrawable drawable) => drawable.Draw(this, RenderState.Default); + + public void Draw(IDrawable drawable, RenderState state) => drawable.Draw(this, state); + + public void Draw(ReadOnlySpan vertices, RenderState state) + { + Drawn = true; + Vertices = vertices.ToArray(); + Indices = null; + State = state; + } + + public void Draw(ReadOnlySpan vertices, ReadOnlySpan indices, RenderState state) + { + Drawn = true; + Vertices = vertices.ToArray(); + Indices = indices.ToArray(); + State = state; + } + } +}