// Copyright (c) Craftwork Games. All rights reserved. // Licensed under the MIT license. // See LICENSE file in the project root for full license information. using System; using System.Collections; using System.Collections.Generic; using System.Runtime.InteropServices; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using MonoGame.Extended.Animations; namespace MonoGame.Extended.Graphics; /// /// Represents a 2D texture atlas that contains a collection of texture regions. /// /// /// /// A texture atlas, also known as a tile map, tile engine, or sprite sheet, is a large image that contains a /// collection of sub-images, or "textures", each representing a texture map for a specific part of a 2D or 3D model. /// /// /// These sub-textures can be rendered by adjusting the texture coordinates (UV map) to reference the appropriate /// part of the atlas. This technique allows efficient rendering in applications where many small textures are /// frequently used. /// /// /// By storing textures in a single atlas, the graphics hardware treats them as a single unit, which can save memory /// and improve performance by reducing the number of rendering state changes. Binding one large texture once is /// typically faster than binding multiple smaller textures individually. /// /// /// However, careful alignment is necessary to avoid texture bleeding when using mipmapping, and to prevent artifacts /// between tiles when using texture compression. /// /// public class Texture2DAtlas : IEnumerable { private readonly List _regionsByIndex = new List(); private readonly Dictionary _regionsByName = new Dictionary(); /// /// Gets the name of the texture atlas. /// public string Name { get; } /// /// Gets the underlying 2D texture. /// public Texture2D Texture { get; } /// /// Gets the number of regions in the atlas. /// public int RegionCount => _regionsByIndex.Count; /// /// Gets the at the specified index. /// /// The index of the texture region. /// The texture region at the specified index. /// /// Thrown if the value of the parameter is less than zero or greater than or equal to /// the total number of regions in this atlas. /// public Texture2DRegion this[int index] => GetRegion(index); /// /// Gets the with the specified name. /// /// The name of the texture region. /// The texture region with the specified name. /// /// Thrown if this atlas does not contain a region with a name that matches the parameter. /// public Texture2DRegion this[string name] => GetRegion(name); /// /// Initializes a new instance of the class with the specified texture. /// /// The texture to create the atlas from. /// Thrown if is null. /// Thrown if is disposed. public Texture2DAtlas(Texture2D texture) : this(null, texture) { } /// /// Initializes a new instance of the class with the specified name and texture. /// /// The name of the texture atlas. /// The texture to create the atlas from. /// Thrown if is null. /// Thrown if is disposed. public Texture2DAtlas(string name, Texture2D texture) { ArgumentNullException.ThrowIfNull(texture); if (texture.IsDisposed) { throw new ObjectDisposedException(nameof(texture), $"{nameof(texture)} was disposed prior"); } if (string.IsNullOrEmpty(name)) { name = $"{texture.Name}Atlas"; } Name = name; Texture = texture; } /// /// Creates a new texture region and adds it to this atlas. /// /// The x-coordinate of the region. /// The y-coordinate of the region. /// The width, in pixels, of the region. /// The height, in pixels, of the region. /// The created texture region. public Texture2DRegion CreateRegion(int x, int y, int width, int height) => CreateRegion(new Rectangle(x, y, width, height), null); /// /// Creates a new texture region with the specified name and adds it to this atlas. /// /// The x-coordinate of the region. /// The y-coordinate of the region. /// The width, in pixels, of the region. /// The height, in pixels, of the region. /// The name of the texture region. /// The created texture region. /// /// Thrown if a region with the same name as the parameter already exists in this atlas. /// public Texture2DRegion CreateRegion(int x, int y, int width, int height, string name) => CreateRegion(new Rectangle(x, y, width, height), name); /// /// Creates a new texture region and adds it to this atlas. /// /// The location of the region. /// The size, in pixels, of the region. /// The created texture region. public Texture2DRegion CreateRegion(Point location, Size size) => CreateRegion(new Rectangle(location.X, location.Y, size.Width, size.Height), null); /// /// Creates a new texture region with the specified name and adds it to this atlas. /// /// The location of the region. /// The size, in pixels, of the region. /// The name of the texture region. /// The created texture region. /// /// Thrown if a region with the same name as the parameter already exists in this atlas. /// public Texture2DRegion CreateRegion(string name, Point location, Size size) => CreateRegion(new Rectangle(location.X, location.Y, size.Width, size.Height), name); /// /// Creates a new texture region and adds it to this atlas. /// /// The bounds of the region. /// The created texture region. public Texture2DRegion CreateRegion(Rectangle bounds) => CreateRegion(bounds, null); /// /// Creates a new texture region with the specified name and adds it to this atlas. /// /// The bounds of the region. /// The name of the texture region. /// The created texture region. /// /// Thrown if a region with the same name as the parameter already exists in this atlas. /// public Texture2DRegion CreateRegion(Rectangle bounds, string name) { Texture2DRegion region = new Texture2DRegion(Texture, bounds, name); AddRegion(region); return region; } /// /// Creates a new texture region with the specified name and adds it to this atlas. /// /// The bounds of the region. /// The name of the texture region. /// A value indicating whether this texture region is rotated 90 degrees clockwise in the atlas. /// The created texture region. /// /// Thrown if a region with the same name as the parameter already exists in this atlas. /// public Texture2DRegion CreateRegion(Rectangle bounds, bool isRotated, Size originalSize, Vector2 trimOffset, Vector2? originNormalized, string name) { Texture2DRegion region = new Texture2DRegion(Texture, bounds.X, bounds.Y, bounds.Width, bounds.Height, isRotated, originalSize, trimOffset, originNormalized, name); AddRegion(region); return region; } /// /// Determines whether the atlas contains a region with the specified name. /// /// The name of the region. /// /// if the atlas contains a region with the specified name; otherwise, /// . /// public bool ContainsRegion(string name) => _regionsByName.ContainsKey(name); /// /// Gets the index of the region with the specified name. /// /// The name of the region. /// The index of the region if found; otherwise, -1. public int GetIndexOfRegion(string name) { for (int i = 0; i < _regionsByIndex.Count; i++) { if (_regionsByIndex[i].Name == name) { return i; } } return -1; } /// /// Gets the region at the specified index. /// /// The index of the region. /// The region at the specified index. /// /// Throw if the value of the is less than zero or is greater than or equal to the total /// number of regions in this atlas. /// public Texture2DRegion GetRegion(int index) => _regionsByIndex[index]; /// /// Gets the region with the specified name. /// /// The name of the region. /// The region with the specified name. /// /// Thrown if this atlas does not contain a region with a name that matches the parameter. /// public Texture2DRegion GetRegion(string name) => _regionsByName[name]; /// /// Tries to get the region at the specified index. /// /// The index of the region. /// /// When this method returns, contains the region at the specified index, if the index is found; otherwise, /// . /// /// /// if the region is found at the specified index; otherwise, . /// public bool TryGetRegion(int index, out Texture2DRegion region) { region = default; if (index < 0 || index >= _regionsByIndex.Count) { return false; } region = _regionsByIndex[index]; return true; } /// /// Tries to get the region with the specified name. /// /// The name of the region. /// /// When this method returns, contains the region with the specified name, if the name is found; otherwise, /// . /// /// /// if the region is found with the specified name; otherwise, . /// public bool TryGetRegion(string name, out Texture2DRegion region) => _regionsByName.TryGetValue(name, out region); /// /// Gets the regions at the specified indexes. /// /// The indexes of the regions to get. /// An array of the regions at the specified indexes. /// /// Thrown if the value of any index in the parameter is less than zero or is greater /// than or equal to the total number of regions in this atlas. /// public Texture2DRegion[] GetRegions(params int[] indexes) { Texture2DRegion[] regions = new Texture2DRegion[indexes.Length]; for (int i = 0; i < indexes.Length; i++) { regions[i] = GetRegion(indexes[i]); } return regions; } internal Texture2DRegion[] GetRegions(ReadOnlySpan frames) { Texture2DRegion[] regions = new Texture2DRegion[frames.Length]; for (int i = 0; i < frames.Length; i++) { regions[i] = GetRegion(frames[i].FrameIndex); } return regions; } /// /// Gets the regions with the specified names. /// /// The names of the regions to get. /// An array of the regions with the specified names. /// /// Thrown if a region is not found in this atlas with a name that matches any of the names in the /// parameter. /// public Texture2DRegion[] GetRegions(params string[] names) { Texture2DRegion[] regions = new Texture2DRegion[names.Length]; for (int i = 0; i < names.Length; i++) { regions[i] = GetRegion(names[i]); } return regions; } /// /// Removes the region at the specified index. /// /// The index of the region to remove. /// /// if the region is successfully removed; otherwise, . /// /// /// Throw if the value of the is less than zero or is greater than or equal to the total /// number of regions in this atlas. /// public bool RemoveRegion(int index) { if (TryGetRegion(index, out Texture2DRegion region)) { return RemoveRegion(region); } return false; } /// /// Removes the region with the specified name. /// /// The name of the region to remove. /// /// if the region is successfully removed; otherwise, . /// public bool RemoveRegion(string name) { if (TryGetRegion(name, out Texture2DRegion region)) { return RemoveRegion(region); } return false; } /// /// Removes all regions from the atlas. /// public void ClearRegions() { _regionsByIndex.Clear(); _regionsByName.Clear(); } private void AddRegion(Texture2DRegion region) { if (_regionsByName.ContainsKey(region.Name)) { throw new InvalidOperationException($"This {nameof(Texture2DAtlas)} already contains a {nameof(Texture2DRegion)} with the name '{region.Name}'"); } _regionsByIndex.Add(region); _regionsByName.Add(region.Name, region); } /// /// Creates a new using the region from this atlas at the specified index. /// /// The index of the region to use. /// The created using the region at the specified index. /// /// Throw if the value of the is less than zero or is greater than or equal to the total /// number of regions in this atlas. /// public Sprite CreateSprite(int regionIndex) { Texture2DRegion region = GetRegion(regionIndex); return new Sprite(region); } /// /// Creates a new using the region from this atlas with the specified name. /// /// The name of the region to use. /// The created using the region with the specified name. /// /// Thrown if this atlas does not contain a region with a name that matches the parameter. /// public Sprite CreateSprite(string regionName) { Texture2DRegion region = GetRegion(regionName); return new Sprite(region); } private bool RemoveRegion(Texture2DRegion region) => _regionsByIndex.Remove(region) && _regionsByName.Remove(region.Name); /// /// Returns an enumerator that iterates through the collection of texture regions. /// /// An enumerator that can be used to iterate through the collection. public IEnumerator GetEnumerator() => _regionsByIndex.GetEnumerator(); /// /// Returns an enumerator that iterates through the collection of texture regions. /// /// An enumerator that can be used to iterate through the collection. IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// /// Creates a new from the specified texture by dividing it into regions. /// /// The name of the texture atlas. /// The source texture to create the atlas from. /// The width, in pixels, of each region. /// The height, in pixels, of each region. /// /// The maximum number of regions to create. Defaults to . /// /// /// The margin, in pixels, to leave around the edges of the texture. Defaults to 0. /// /// The spacing, in pixels, between regions. Defaults to 0. /// A containing the created regions. /// /// Region names are automatically generated using the pattern {name}_{index}, where /// is the atlas name and index is the sequential region number starting from 0. For example, if the atlas /// is named "spritesheet", the regions will be named "spritesheet_0", "spritesheet_1", "spritesheet_2", etc. /// Regions are created in row-major order (left-to-right, top-to-bottom). /// /// Thrown if is null. /// Thrown if is disposed. public static Texture2DAtlas Create(string name, Texture2D texture, int regionWidth, int regionHeight, int maxRegionCount = int.MaxValue, int margin = 0, int spacing = 0) { ReadOnlySpan regions = CalculateRegions(name, texture.Width, texture.Height, regionWidth, regionHeight, maxRegionCount, margin, spacing); Texture2DAtlas textureAtlas = new(name, texture); for (int i = 0; i < regions.Length; i++) { CalculatedRegion region = regions[i]; textureAtlas.CreateRegion(region.bounds, region.Name); } return textureAtlas; } internal readonly record struct CalculatedRegion(Rectangle bounds, string Name); internal static ReadOnlySpan CalculateRegions(string atlasName, int textureWidth, int textureHeight, int regionWidth, int regionHeight, int maxRegionCount, int margin, int spacing) { int width = textureWidth - margin; int height = textureHeight - margin; int xIncrement = regionWidth + spacing; int yIncrement = regionHeight + spacing; int columns = (width - margin + spacing) / xIncrement; int rows = (height - margin + spacing) / yIncrement; int totalRegions = columns * rows; // We know what the final size of the collection will be so calculate it // and use it to prevent reallocations as items are added to the list int capacity = Math.Min(totalRegions, maxRegionCount); List regions = new List(capacity); for (int i = 0; i < totalRegions; i++) { int x = margin + (i % columns) * xIncrement; int y = margin + (i / columns) * yIncrement; if (x >= width || y >= height) { break; } Rectangle bounds = new Rectangle(x, y, regionWidth, regionHeight); string name = $"{atlasName}_{i}"; CalculatedRegion region = new(bounds, name); regions.Add(region); if (regions.Count >= maxRegionCount) { break; } } return CollectionsMarshal.AsSpan(regions); } }