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