// 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);
}
}