Texture2DRegion: support trimmed and rotated sprites optionally load origin from atlas data file (#1004)

* Added the following features to Texture2DRegion to improve sprite sheet packing efficiency:
- Regions on the texture can now be rotated.
- Transparent borders around sprites can be trimmed.

The SpriteBatch.Draw method will automatically rotate the region back to its original orientation and restore the transparent borders during rendering.

Additionally, since sprite sheet data files can define sprite origins, TextureRegion2D can now optionally store this origin. Sprites created from a Texture2DRegion will use this stored origin as their default.

* Adapted nine-patch sprite implementation for support rotated / trimmed sprites, too.

* Extended the TextureAtlas content pipeline to support trimmed and rotated sprites, as well as reading anchor points (origins) from the data file.
The JSON format has been cleaned up by removing unnecessary keys. The legacy format remains supported for backward compatibility.

* added build-time check to ensure that texture image file referenced by atlas data file exists
This commit is contained in:
Andreas Loew
2025-08-04 18:04:25 +02:00
committed by GitHub
parent 59803aa1fe
commit de157c44e8
14 changed files with 549 additions and 74 deletions
@@ -13,7 +13,20 @@ namespace MonoGame.Extended.Content.Pipeline.TextureAtlases
public override TexturePackerFileContent Import(string filename, ContentImporterContext context)
{
var tpFile = TexturePackerFileReader.Read(filename);
context.AddDependency(tpFile.Meta.Image);
if (tpFile.Meta.Image != null)
{
context.AddDependency(tpFile.Meta.Image);
}
else if (tpFile.Meta.DataFormat == "monogame-extended")
{
// new format: textures are in the textures array
foreach (var texture in tpFile.Textures)
{
context.AddDependency(texture.FileName);
}
}
return tpFile;
}
}
@@ -3,6 +3,8 @@
// See LICENSE file in the project root for full license information.
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline.Processors;
using MonoGame.Extended.Content.TexturePacker;
namespace MonoGame.Extended.Content.Pipeline.TextureAtlases;
@@ -12,6 +14,21 @@ public class TexturePackerProcessor : ContentProcessor<TexturePackerFileContent,
{
public override TexturePackerProcessorResult Process(TexturePackerFileContent input, ContentProcessorContext context)
{
if (input.Meta.Image != null)
{
// Validates the texture exists and can be processed (fails build if missing)
var externalRef = new ExternalReference<Texture2DContent>(input.Meta.Image);
context.BuildAndLoadAsset<Texture2DContent, Texture2DContent>(externalRef, nameof(TextureProcessor));
}
else if (input.Meta.DataFormat == "monogame-extended")
{
foreach (var texture in input.Textures)
{
var externalRef = new ExternalReference<Texture2DContent>(texture.FileName);
context.BuildAndLoadAsset<Texture2DContent, Texture2DContent>(externalRef, nameof(TextureProcessor));
}
}
return new TexturePackerProcessorResult(input);
}
}
@@ -2,6 +2,7 @@
// Licensed under the MIT license.
// See LICENSE file in the project root for full license information.
using System;
using System.IO;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;
@@ -15,20 +16,66 @@ public class TexturePackerWriter : ContentTypeWriter<TexturePackerProcessorResul
protected override void Write(ContentWriter writer, TexturePackerProcessorResult result)
{
var tpFile = result.Data;
var imageAssetName = Path.ChangeExtension(tpFile.Meta.Image, null);
writer.Write(imageAssetName);
writer.Write(tpFile.Regions.Count);
foreach (var region in tpFile.Regions)
if (tpFile.Meta.DataFormat == "monogame-extended") // new "MonoGame.Extended" format, recommended
{
var regionName = Path.ChangeExtension(region.FileName, null);
var texture = tpFile.Textures[0];
var imageAssetName = Path.ChangeExtension(texture.FileName, null);
writer.Write(imageAssetName);
writer.Write(texture.Frames.Count);
writer.Write(region.Frame.X);
writer.Write(region.Frame.Y);
writer.Write(region.Frame.Width);
writer.Write(region.Frame.Height);
writer.Write(regionName);
foreach (var kv in texture.Frames)
{
var frameKey = kv.Key;
var frame = kv.Value;
writer.Write(frame.Frame.X);
writer.Write(frame.Frame.Y);
writer.Write(frame.Frame.Width);
writer.Write(frame.Frame.Height);
writer.Write(frameKey);
writer.Write(frame.Rotated); // angle (0, 90)
writer.Write(frame.Size != null); // trimmed sprite: true/false
if (frame.Size != null)
{
writer.Write(frame.Size.Width); // sprite size before trimming
writer.Write(frame.Size.Height);
writer.Write(frame.Offset.X); // trim offset
writer.Write(frame.Offset.Y);
}
writer.Write(frame.Pivot != null); // has pivot point: true/false
if (frame.Pivot != null)
{
writer.Write(frame.Pivot.X);
writer.Write(frame.Pivot.Y);
}
}
}
else if (tpFile.Regions.Count != 0) // generic "JSON (Array)" format
{
var imageAssetName = Path.ChangeExtension(tpFile.Meta.Image, null);
writer.Write(imageAssetName);
writer.Write(tpFile.Regions.Count);
foreach (var region in tpFile.Regions)
{
var regionName = Path.ChangeExtension(region.FileName, null);
writer.Write(region.Frame.X);
writer.Write(region.Frame.Y);
writer.Write(region.Frame.Width);
writer.Write(region.Frame.Height);
writer.Write(regionName);
writer.Write(0); // rotation
writer.Write(false); // no trimming
writer.Write(false); // no pivot point
}
}
else
{
throw new InvalidOperationException("No frames or textures found in TexturePackerFileContent.");
}
}
@@ -2,6 +2,7 @@
// Licensed under the MIT license.
// See LICENSE file in the project root for full license information.
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using MonoGame.Extended.Graphics;
@@ -25,7 +26,38 @@ namespace MonoGame.Extended.Content.ContentReaders
int width = reader.ReadInt32();
int height = reader.ReadInt32();
string regionName = reader.ReadString();
atlas.CreateRegion(x, y, width, height, regionName);
int rotated = reader.ReadInt32();
int origWidth, origHeight, offsetX, offsetY;
if (reader.ReadBoolean()) // trimmed sprite?
{
origWidth = reader.ReadInt32();
origHeight = reader.ReadInt32();
offsetX = reader.ReadInt32();
offsetY = reader.ReadInt32();
}
else
{
origWidth = (rotated == 0) ? width : height;
origHeight = (rotated == 0) ? height : width;
offsetX = offsetY = 0;
}
Vector2? pivotPoint = null;
if (reader.ReadBoolean()) // pivot point available?
{
float ppX = (float)reader.ReadDouble();
float ppY = (float)reader.ReadDouble();
pivotPoint = new Vector2(ppX, ppY);
}
atlas.CreateRegion(new Rectangle(x, y, width, height),
rotated != 0,
new Size(origWidth, origHeight),
new Vector2(offsetX, offsetY),
pivotPoint,
regionName);
}
return atlas;
@@ -5,9 +5,28 @@ using MonoGame.Extended.Serialization.Json;
namespace MonoGame.Extended.Content.TexturePacker;
public record TexturePackerFileContent([property: JsonPropertyName("frames")] List<TexturePackerFrame> Regions,
[property: JsonPropertyName("textures")] List<TexturePackerTexture> Textures,
[property: JsonPropertyName("meta")] TexturePackerMeta Meta);
public record TexturePackerPoint([property: JsonPropertyName("x")] double X,
[property: JsonPropertyName("y")] double Y);
public record TexturePackerTexture([property: JsonPropertyName("filename")] string FileName,
[property: JsonPropertyName("format")] string? Format = null,
[property: JsonPropertyName("size")] TexturePackerSize Size = default,
[property: JsonPropertyName("scale")] string? Scale = null,
[property: JsonPropertyName("premultiplied")] bool Premultiplied = false,
[property: JsonPropertyName("frames")] Dictionary<string, TexturePackerTextureFrame>? Frames = null
);
public record TexturePackerTextureFrame([property: JsonPropertyName("frame")] TexturePackerRectangle Frame,
[property: JsonPropertyName("rotated")] int Rotated = 0,
[property: JsonPropertyName("size")] TexturePackerSize? Size = null,
[property: JsonPropertyName("offset")] TexturePackerPoint? Offset = null,
[property: JsonPropertyName("pivot")] TexturePackerPointF? Pivot = null,
[property: JsonPropertyName("scale9")] TexturePackerRectangle? Scale9 = null
);
public record TexturePackerPoint([property: JsonPropertyName("x")] int X,
[property: JsonPropertyName("y")] int Y);
public record TexturePackerPointF([property: JsonPropertyName("x")] double X,
[property: JsonPropertyName("y")] double Y);
public record TexturePackerSize([property: JsonPropertyName("w")] int Width,
[property: JsonPropertyName("h")] int Height);
@@ -22,12 +41,10 @@ public record TexturePackerFrame([property: JsonPropertyName("filename")] string
[property: JsonPropertyName("trimmed")] bool Trimmed,
[property: JsonPropertyName("spriteSourceSize")] TexturePackerRectangle SpriteSourceSize,
[property: JsonPropertyName("sourceSize")] TexturePackerSize SourceSize,
[property: JsonPropertyName("pivot")] TexturePackerPoint PivotPoint);
[property: JsonPropertyName("pivot")] TexturePackerPointF PivotPoint);
public record TexturePackerMeta([property: JsonPropertyName("app")] string App,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("image")] string Image,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("size")] TexturePackerSize Size,
[property: JsonPropertyName("scale"), JsonConverter(typeof(FloatStringConverter))] float Scale,
[property: JsonPropertyName("dataformat")] string DataFormat,
[property: JsonPropertyName("smartupdate")] string SmartUpdate);
@@ -121,8 +121,8 @@ public class NinePatch
_patches = patches;
Size topLeft = patches[NinePatch.TopLeft].Size;
Size bottomRight = patches[NinePatch.BottomRight].Size;
Size topLeft = patches[NinePatch.TopLeft].OriginalSize;
Size bottomRight = patches[NinePatch.BottomRight].OriginalSize;
Padding = new Thickness(topLeft.Width, topLeft.Height, bottomRight.Width, bottomRight.Height);
Name = name;
+14 -4
View File
@@ -61,6 +61,11 @@ public class Sprite : IColorable
/// </summary>
public object Tag { get; set; }
/// <summary>
/// Gets the size of the sprite.
/// </summary>
public Point Size => TextureRegion.OriginalSize;
/// <summary>
/// Gets or sets the origin of this sprite.
/// </summary>
@@ -79,8 +84,8 @@ public class Sprite : IColorable
/// </remarks>
public Vector2 OriginNormalized
{
get { return new Vector2(Origin.X / TextureRegion.Width, Origin.Y / TextureRegion.Height); }
set { Origin = new Vector2(value.X * TextureRegion.Width, value.Y * TextureRegion.Height); }
get { return new Vector2(Origin.X / TextureRegion.OriginalSize.Width, Origin.Y / TextureRegion.OriginalSize.Height); }
set { Origin = new Vector2(value.X * TextureRegion.OriginalSize.Width, value.Y * TextureRegion.OriginalSize.Height); }
}
/// <summary>
@@ -101,6 +106,11 @@ public class Sprite : IColorable
throw new ObjectDisposedException(nameof(value), $"The source {nameof(Texture2D)} of the {nameof(TextureRegion)} was disposed prior to setting this property.");
}
_textureRegion = value;
if (value.OriginNormalized.HasValue)
{
OriginNormalized = value.OriginNormalized.Value;
}
}
}
@@ -145,7 +155,7 @@ public class Sprite : IColorable
Color = Color.White;
IsVisible = true;
Effect = SpriteEffects.None;
OriginNormalized = Vector2.Zero;
OriginNormalized = textureRegion.OriginNormalized ?? Vector2.Zero;
Depth = 0.0f;
}
@@ -184,7 +194,7 @@ public class Sprite : IColorable
public Vector2[] GetCorners(Vector2 position, float rotation, Vector2 scale)
{
var min = -Origin;
var max = min + new Vector2(TextureRegion.Width, TextureRegion.Height);
var max = min + new Vector2(TextureRegion.OriginalSize.Width, TextureRegion.OriginalSize.Height);
var offset = position;
if (scale != Vector2.One)
@@ -32,21 +32,17 @@ public static class SpriteBatchExtensions
for (int i = 0; i < sourcePatches.Length; i++)
{
Rectangle source = sourcePatches[i].Bounds;
Rectangle destination = _patchCache[i];
Texture2DRegion sourceRegion = sourcePatches[i];
Rectangle destinationRect = _patchCache[i];
if (clippingRectangle.HasValue)
{
source = ClipSourceRectangle(source, destination, clippingRectangle.Value);
destination = ClipDestinationRectangle(destination, clippingRectangle.Value);
Draw(spriteBatch, sourcePatches[i].Texture, source, destination, color, clippingRectangle);
sourceRegion = ClipSourceRegion(sourceRegion, destinationRect, clippingRectangle.Value);
destinationRect = ClipDestinationRectangle(destinationRect, clippingRectangle.Value);
}
else
if (sourceRegion != null && !destinationRect.IsEmpty)
{
if (destination.Width > 0 && destination.Height > 0)
{
spriteBatch.Draw(sourcePatches[i].Texture, destination, source, color);
}
Draw(spriteBatch, sourceRegion, destinationRect, color);
}
}
}
@@ -104,9 +100,17 @@ public static class SpriteBatchExtensions
if (sprite.IsVisible)
{
var texture = sprite.TextureRegion.Texture;
var sourceRectangle = sprite.TextureRegion.Bounds;
spriteBatch.Draw(texture, position, sourceRectangle, sprite.Color * sprite.Alpha, rotation, sprite.Origin, scale, sprite.Effect, sprite.Depth);
Draw(
spriteBatch,
sprite.TextureRegion,
position,
sprite.Color * sprite.Alpha,
rotation,
sprite.Origin,
scale,
sprite.Effect,
sprite.Depth
);
}
}
#endregion -------------------------Sprite-----------------------------
@@ -164,29 +168,49 @@ public static class SpriteBatchExtensions
/// <param name="layerDepth">The layer depth.</param>
/// <param name="clippingRectangle">An optional clipping rectangle.</param>
public static void Draw(this SpriteBatch spriteBatch, Texture2DRegion textureRegion, Vector2 position, Color color,
float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth, Rectangle? clippingRectangle = null)
float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth, Rectangle? clippingRectangle = null)
{
var sourceRectangle = textureRegion.Bounds;
var offset = (textureRegion.Offset - origin) * scale;
Vector2 drawScale = scale;
float drawRotation = rotation;
// Handle rotated texture regions
if (textureRegion.IsRotated)
{
offset.Y += textureRegion.Width * scale.Y;
// Swap scale axes and adjust rotation for rotated regions
drawScale = new Vector2(scale.Y, scale.X);
drawRotation = rotation - (float)Math.PI / 2;
}
var drawPosition = position + RotateVector(offset, rotation);
if (clippingRectangle.HasValue)
{
var x = (int)(position.X - origin.X);
var y = (int)(position.Y - origin.Y);
var width = (int)(textureRegion.Width * scale.X);
var height = (int)(textureRegion.Height * scale.Y);
var x = (int)drawPosition.X;
var y = (int)drawPosition.Y;
var width = (int)(textureRegion.Width * drawScale.X);
var height = (int)(textureRegion.Height * drawScale.Y);
if (textureRegion.IsRotated)
{
(width, height) = (height, width);
y -= height;
}
var destinationRectangle = new Rectangle(x, y, width, height);
if (!ClipRectangles(ref sourceRectangle, ref destinationRectangle, clippingRectangle))
if (!ClipRectangles(ref sourceRectangle, ref destinationRectangle, clippingRectangle, textureRegion.IsRotated))
{
// Clipped rectangle is empty, nothing to draw
return;
}
position.X = destinationRectangle.X + origin.X;
position.Y = destinationRectangle.Y + origin.Y;
drawPosition.X = destinationRectangle.X;
drawPosition.Y = textureRegion.IsRotated ? destinationRectangle.Y + destinationRectangle.Height
: destinationRectangle.Y;
}
spriteBatch.Draw(textureRegion.Texture, position, sourceRectangle, color, rotation, origin, scale, effects, layerDepth);
spriteBatch.Draw(textureRegion.Texture, drawPosition, sourceRectangle, color, drawRotation, Vector2.Zero, drawScale, effects, layerDepth);
}
/// <summary>
@@ -199,7 +223,9 @@ public static class SpriteBatchExtensions
/// <param name="clippingRectangle">An optional clipping rectangle.</param>
public static void Draw(this SpriteBatch spriteBatch, Texture2DRegion textureRegion, Rectangle destinationRectangle, Color color, Rectangle? clippingRectangle = null)
{
Draw(spriteBatch, textureRegion.Texture, textureRegion.Bounds, destinationRectangle, color, clippingRectangle);
float scaleX = (float)destinationRectangle.Width / textureRegion.OriginalSize.Width;
float scaleY = (float)destinationRectangle.Height / textureRegion.OriginalSize.Height;
Draw(spriteBatch, textureRegion, new Vector2(destinationRectangle.X, destinationRectangle.Y), color, 0, Vector2.Zero, new Vector2(scaleX, scaleY), SpriteEffects.None, 0, clippingRectangle);
}
#endregion -------------------------TextureRegion-----------------------------
@@ -227,7 +253,7 @@ public static class SpriteBatchExtensions
_patchCache[NinePatch.BottomMiddle] = new Rectangle(left, bottom, midWidth, bottomPadding);
_patchCache[NinePatch.BottomRight] = new Rectangle(right, bottom, rightPadding, bottomPadding);
}
private static bool ClipRectangles(ref Rectangle sourceRectangle, ref Rectangle destinationRectangle, Rectangle? clippingRectangle)
private static bool ClipRectangles(ref Rectangle sourceRectangle, ref Rectangle destinationRectangle, Rectangle? clippingRectangle, bool rotatedSource = false)
{
if (!clippingRectangle.HasValue)
return true;
@@ -238,21 +264,35 @@ public static class SpriteBatchExtensions
if (destinationRectangle == Rectangle.Empty)
return false; // Clipped rectangle is empty, nothing to draw
var scaleX = (float)sourceRectangle.Width / originalDestination.Width;
var scaleY = (float)sourceRectangle.Height / originalDestination.Height;
int leftDiff = destinationRectangle.Left - originalDestination.Left;
int topDiff = destinationRectangle.Top - originalDestination.Top;
int bottomDiff = originalDestination.Bottom - destinationRectangle.Bottom;
sourceRectangle.X += (int)(leftDiff * scaleX);
sourceRectangle.Y += (int)(topDiff * scaleY);
sourceRectangle.Width = (int)(destinationRectangle.Width * scaleX);
sourceRectangle.Height = (int)(destinationRectangle.Height * scaleY);
if (rotatedSource)
{
var scaleX = (float)sourceRectangle.Height / originalDestination.Width;
var scaleY = (float)sourceRectangle.Width / originalDestination.Height;
sourceRectangle.X += (int)(bottomDiff * scaleY);
sourceRectangle.Y += (int)(leftDiff * scaleX);
sourceRectangle.Width = (int)(destinationRectangle.Height * scaleY);
sourceRectangle.Height = (int)(destinationRectangle.Width * scaleX);
}
else
{
var scaleX = (float)sourceRectangle.Width / originalDestination.Width;
var scaleY = (float)sourceRectangle.Height / originalDestination.Height;
sourceRectangle.X += (int)(leftDiff * scaleX);
sourceRectangle.Y += (int)(topDiff * scaleY);
sourceRectangle.Width = (int)(destinationRectangle.Width * scaleX);
sourceRectangle.Height = (int)(destinationRectangle.Height * scaleY);
}
return true;
}
private static Rectangle ClipSourceRectangle(Rectangle sourceRectangle, Rectangle destinationRectangle, Rectangle clippingRectangle)
private static Texture2DRegion ClipSourceRegion(Texture2DRegion sourceRegion, Rectangle destinationRectangle, Rectangle clippingRectangle)
{
var left = (float)(clippingRectangle.Left - destinationRectangle.Left);
var right = (float)(destinationRectangle.Right - clippingRectangle.Right);
@@ -263,23 +303,31 @@ public static class SpriteBatchExtensions
var w = (right > 0 ? right : 0) + x;
var h = (bottom > 0 ? bottom : 0) + y;
var scaleX = (float)destinationRectangle.Width / sourceRectangle.Width;
var scaleY = (float)destinationRectangle.Height / sourceRectangle.Height;
var scaleX = (float)destinationRectangle.Width / sourceRegion.OriginalSize.Width;
var scaleY = (float)destinationRectangle.Height / sourceRegion.OriginalSize.Height;
x /= scaleX;
y /= scaleY;
w /= scaleX;
h /= scaleY;
return new Rectangle((int)(sourceRectangle.X + x), (int)(sourceRectangle.Y + y), (int)(sourceRectangle.Width - w), (int)(sourceRectangle.Height - h));
return sourceRegion.GetSubregion((int)x, (int)y, (int)(sourceRegion.OriginalSize.Width - w), (int)(sourceRegion.OriginalSize.Height - h));
}
private static Rectangle ClipDestinationRectangle(Rectangle destinationRectangle, Rectangle clippingRectangle)
{
var left = clippingRectangle.Left < destinationRectangle.Left ? destinationRectangle.Left : clippingRectangle.Left;
var top = clippingRectangle.Top < destinationRectangle.Top ? destinationRectangle.Top : clippingRectangle.Top;
var bottom = clippingRectangle.Bottom < destinationRectangle.Bottom ? clippingRectangle.Bottom : destinationRectangle.Bottom;
var right = clippingRectangle.Right < destinationRectangle.Right ? clippingRectangle.Right : destinationRectangle.Right;
return new Rectangle(left, top, right - left, bottom - top);
return destinationRectangle.Clip(clippingRectangle);
}
// Rotates a 2D vector by the specified angle in radians.
private static Vector2 RotateVector(Vector2 original, double rotation)
{
double cosTheta = Math.Cos(rotation);
double sinTheta = Math.Sin(rotation);
double rotatedX = original.X * cosTheta - original.Y * sinTheta;
double rotatedY = original.X * sinTheta + original.Y * cosTheta;
return new Vector2((float)rotatedX, (float)rotatedY);
}
#endregion -------------------------Utilities-----------------------------
@@ -174,6 +174,23 @@ public class Texture2DAtlas : IEnumerable<Texture2DRegion>
return region;
}
/// <summary>
/// Creates a new texture region with the specified name and adds it to this atlas.
/// </summary>
/// <param name="bounds">The bounds of the region.</param>
/// <param name="name">The name of the texture region.</param>
/// <param name="isRotated">A value indicating whether this texture region is rotated 90 degrees clockwise in the atlas.</param>
/// <returns>The created texture region.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown if a region with the same name as the <paramref name="name"/> parameter already exists in this atlas.
/// </exception>
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;
}
/// <summary>
/// Determines whether the atlas contains a region with the specified name.
/// </summary>
@@ -65,8 +65,8 @@ public static class Texture2DRegionExtensions
/// Gets a subregion of the specified texture region using the provided name, coordinates, and dimensions.
/// </summary>
/// <param name="textureRegion">The texture region to get the subregion from.</param>
/// <param name="x">The top-left x-coordinate of the subregion within the texture region.</param>
/// <param name="y">The top-left y-coordinate of the subregion within the texture region.</param>
/// <param name="x">The top-left x-coordinate of the subregion within the original sprite.</param>
/// <param name="y">The top-left y-coordinate of the subregion within the original sprite.</param>
/// <param name="width">The width, in pixels, of the subregion.</param>
/// <param name="height">The height, in pixels, of the subregion.</param>
/// <param name="name">The name of the new subregion.</param>
@@ -86,8 +86,47 @@ public static class Texture2DRegionExtensions
name = $"{textureRegion.Texture.Name}({x}, {y}, {width}, {height})";
}
Rectangle region = textureRegion.Bounds.GetRelativeRectangle(x, y, width, height);
return new Texture2DRegion(textureRegion.Texture, region, name);
// The requested subregion is in original sprite coordinates
Rectangle requestedRegion = new Rectangle(x, y, width, height);
// Calculate the bounds of the trimmed region in original sprite coordinates
Rectangle trimmedBounds = new Rectangle(
(int)textureRegion.Offset.X,
(int)textureRegion.Offset.Y,
textureRegion.IsRotated ? textureRegion.Height : textureRegion.Width,
textureRegion.IsRotated ? textureRegion.Width : textureRegion.Height);
// Find intersection between requested subregion and the actual trimmed region
Rectangle intersection = Rectangle.Intersect(requestedRegion, trimmedBounds);
if (intersection.IsEmpty)
{
return null;
}
// The subregion offset must include the existing offset of the input region
Vector2 subregionOffset = new Vector2(Math.Max(0, textureRegion.Offset.X - x),
Math.Max(0, textureRegion.Offset.Y - y));
if (textureRegion.IsRotated)
{
// Calculate the actual texture coordinates
int textureX = textureRegion.X + (textureRegion.Width + (int)textureRegion.Offset.Y - intersection.Bottom);
int textureY = textureRegion.Y + (intersection.X - (int)textureRegion.Offset.X);
return new Texture2DRegion(textureRegion.Texture, textureX, textureY, intersection.Height, intersection.Width,
textureRegion.IsRotated, new Size(width, height), subregionOffset, textureRegion.OriginNormalized, name);
}
else
{
// Calculate the actual texture coordinates
int textureX = textureRegion.X + (intersection.X - (int)textureRegion.Offset.X);
int textureY = textureRegion.Y + (intersection.Y - (int)textureRegion.Offset.Y);
return new Texture2DRegion(textureRegion.Texture, textureX, textureY, intersection.Width, intersection.Height,
textureRegion.IsRotated, new Size(width, height), subregionOffset, textureRegion.OriginNormalized, name);
}
}
/// <summary>
@@ -139,10 +178,11 @@ public static class Texture2DRegionExtensions
{
Texture2DRegion[] patches = new Texture2DRegion[9];
int middleWidth = textureRegion.Width - leftPadding - rightPadding;
int middleHeight = textureRegion.Height - topPadding - bottomPadding;
int rightX = textureRegion.Width - rightPadding;
int bottomY = textureRegion.Height - bottomPadding;
// Use original sprite dimensions for calculations
int middleWidth = textureRegion.OriginalSize.Width - leftPadding - rightPadding;
int middleHeight = textureRegion.OriginalSize.Height - topPadding - bottomPadding;
int rightX = textureRegion.OriginalSize.Width - rightPadding;
int bottomY = textureRegion.OriginalSize.Height - bottomPadding;
patches[NinePatch.TopLeft] = textureRegion.GetSubregion(0, 0, leftPadding, topPadding);
patches[NinePatch.TopMiddle] = textureRegion.GetSubregion(leftPadding, 0, middleWidth, topPadding);
@@ -78,6 +78,26 @@ public class Texture2DRegion
/// </summary>
public float LeftUV { get; }
/// <summary>
/// Gets a value indicating whether this texture region is rotated 90 degrees clockwise in the atlas.
/// </summary>
public bool IsRotated { get; }
/// <summary>
/// Gets the original size of the texture region before trimming.
/// </summary>
public Size OriginalSize { get; }
/// <summary>
/// Gets the offset between the top-left corner of the original sprite and the top-left corner of the trimmed sprite.
/// </summary>
public Vector2 Offset { get; }
/// <summary>
/// Gets the normalized origin point of the texture region, or null if no origin is specified.
/// </summary>
public Vector2? OriginNormalized { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Texture2DRegion"/> class representing the entire texture.
/// </summary>
@@ -170,6 +190,29 @@ public class Texture2DRegion
/// Thrown if <paramref name="texture"/> has been disposed prior.
/// </exception>
public Texture2DRegion(Texture2D texture,int x, int y, int width, int height, string name)
: this(texture, x, y, width, height, false, new Size(width, height), Vector2.Zero, null, name) { }
/// <summary>
/// Initializes a new instance of the <see cref="Texture2DRegion"/> class with the specified region of the texture,
/// original size, offset, origin, and name.
/// </summary>
/// <param name="texture">The texture to create the region from.</param>
/// <param name="x">The top-left x-coordinate of the region within the texture.</param>
/// <param name="y">The top-left y-coordinate of the region within the texture.</param>
/// <param name="width">The width, in pixels, of the region.</param>
/// <param name="height">The height, in pixels, of the region.</param>
/// <param name="isRotated">A value indicating whether this texture region is rotated 90 degrees clockwise in the atlas.</param>
/// <param name="originalSize">The original size of the texture region before trimming.</param>
/// <param name="offset">The offset between the top-left corner of the original sprite and the top-left corner of the trimmed sprite.</param>
/// <param name="originNormalized">The origin point of the texture region, or null if no origin is specified.</param>
/// <param name="name">The name of the texture region.</param>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="texture"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ObjectDisposedException">
/// Thrown if <paramref name="texture"/> has been disposed prior.
/// </exception>
public Texture2DRegion(Texture2D texture, int x, int y, int width, int height, bool isRotated, Size originalSize, Vector2 offset, Vector2? originNormalized, string name)
{
ArgumentNullException.ThrowIfNull(texture);
if (texture.IsDisposed)
@@ -188,6 +231,10 @@ public class Texture2DRegion
Y = y;
Width = width;
Height = height;
IsRotated = isRotated;
OriginalSize = originalSize;
Offset = offset;
OriginNormalized = originNormalized;
Bounds = new Rectangle(x, y, width, height);
Size = new Size(width, height);
TopUV = Bounds.Top / (float)texture.Height;
@@ -210,6 +257,9 @@ public class Texture2DRegion
Height = height;
Bounds = new Rectangle(x, y, width, height);
Size = new Size(width, height);
OriginalSize = Size;
Offset = Vector2.Zero;
OriginNormalized = null;
TopUV = Bounds.Top / 1.0f;
RightUV = Bounds.Right / 1.0f;
BottomUV = Bounds.Bottom / 1.0f;
@@ -12,6 +12,7 @@
<Content Include="TestData\astrid-animator-atlas.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="TestData\astrid-animator.aa" CopyToOutputDirectory="PreserveNewest" />
<Content Include="TestData\test-tileset.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="TestData\test-atlas.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="TestData\isometric.tmx" CopyToOutputDirectory="PreserveNewest" />
<Content Include="TestData\isometric_tileset.png" CopyToOutputDirectory="PreserveNewest" />
<Content Include="TestData\level01.tmx" CopyToOutputDirectory="PreserveNewest" />
@@ -0,0 +1,120 @@
{
"textures": [
{
"filename": "test-atlas-texture.png",
"format": "RGBA8888",
"size": {"w":917,"h":462},
"frames": {
"0001.png":{
"frame": {"x":164,"y":1,"w":158,"h":316},
"size": {"w":187,"h":324},
"offset": {"x":15,"y":3},
"pivot": {"x":0.5,"y":1}
},
"0002.png":{
"frame": {"x":164,"y":1,"w":158,"h":316},
"size": {"w":187,"h":324},
"offset": {"x":15,"y":3},
"pivot": {"x":0.5,"y":1}
},
"0003.png":{
"frame": {"x":622,"y":155,"w":168,"h":303},
"size": {"w":187,"h":324},
"offset": {"x":0,"y":8},
"pivot": {"x":0.5,"y":1}
},
"0004.png":{
"frame": {"x":622,"y":155,"w":168,"h":303},
"size": {"w":187,"h":324},
"offset": {"x":0,"y":8},
"pivot": {"x":0.5,"y":1}
},
"0005.png":{
"frame": {"x":609,"y":1,"w":307,"h":152},
"rotated": 90,
"size": {"w":187,"h":324},
"offset": {"x":26,"y":2},
"pivot": {"x":0.5,"y":1}
},
"0006.png":{
"frame": {"x":609,"y":1,"w":307,"h":152},
"rotated": 90,
"size": {"w":187,"h":324},
"offset": {"x":26,"y":2},
"pivot": {"x":0.5,"y":1}
},
"0007.png":{
"frame": {"x":314,"y":319,"w":306,"h":141},
"rotated": 90,
"size": {"w":187,"h":324},
"offset": {"x":42,"y":1},
"pivot": {"x":0.5,"y":1}
},
"0008.png":{
"frame": {"x":314,"y":319,"w":306,"h":141},
"rotated": 90,
"size": {"w":187,"h":324},
"offset": {"x":42,"y":1},
"pivot": {"x":0.5,"y":1}
},
"0009.png":{
"frame": {"x":1,"y":322,"w":311,"h":139},
"rotated": 90,
"size": {"w":187,"h":324},
"offset": {"x":33,"y":3},
"pivot": {"x":0.5,"y":1}
},
"0010.png":{
"frame": {"x":1,"y":322,"w":311,"h":139},
"rotated": 90,
"size": {"w":187,"h":324},
"offset": {"x":33,"y":3},
"pivot": {"x":0.5,"y":1}
},
"0011.png":{
"frame": {"x":472,"y":1,"w":135,"h":311},
"size": {"w":187,"h":324},
"offset": {"x":29,"y":8},
"pivot": {"x":0.5,"y":1}
},
"0012.png":{
"frame": {"x":472,"y":1,"w":135,"h":311},
"size": {"w":187,"h":324},
"offset": {"x":29,"y":8},
"pivot": {"x":0.5,"y":1}
},
"0013.png":{
"frame": {"x":324,"y":1,"w":146,"h":314},
"size": {"w":187,"h":324},
"offset": {"x":32,"y":2},
"pivot": {"x":0.5,"y":1}
},
"0014.png":{
"frame": {"x":324,"y":1,"w":146,"h":314},
"size": {"w":187,"h":324},
"offset": {"x":32,"y":2},
"pivot": {"x":0.5,"y":1}
},
"0015.png":{
"frame": {"x":1,"y":1,"w":161,"h":319},
"size": {"w":187,"h":324},
"offset": {"x":22,"y":1},
"pivot": {"x":0.5,"y":1}
},
"0016.png":{
"frame": {"x":1,"y":1,"w":161,"h":319},
"size": {"w":187,"h":324},
"offset": {"x":22,"y":1},
"pivot": {"x":0.5,"y":1}
}
}
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"dataformat": "monogame-extended",
"version": "1.2",
"smartupdate": "$TexturePacker:SmartUpdate:682bde45425e0dc70518ec1f7428e5ba:7979479a5ba52a9c36728fb50f2150eb:a2acd4b7aba6ca32e8c17414ae3e522b$"
}
}
@@ -15,6 +15,26 @@ namespace MonoGame.Extended.Content.Pipeline.Tests
var data = importer.Import(filePath, Substitute.For<ContentImporterContext>());
Assert.NotNull(data);
// Check meta.image contains image name
Assert.Equal("test-tileset.png", data.Meta.Image);
// Check regions count and textures
Assert.Equal(9, data.Regions.Count);
Assert.Null(data.Textures); // only used by new MonoGame.Extended JSON format
// Check first region
var firstRegion = data.Regions[0];
Assert.Equal("1.png", firstRegion.FileName);
Assert.Equal(2, firstRegion.Frame.X);
Assert.Equal(2, firstRegion.Frame.Y);
Assert.Equal(32, firstRegion.Frame.Width);
Assert.Equal(32, firstRegion.Frame.Height);
Assert.Equal(0.5, firstRegion.PivotPoint.X);
Assert.Equal(0.5, firstRegion.PivotPoint.Y);
Assert.False(firstRegion.Rotated);
Assert.Equal(32, firstRegion.SourceSize.Width);
Assert.Equal(32, firstRegion.SourceSize.Height);
}
[Fact]
@@ -28,5 +48,48 @@ namespace MonoGame.Extended.Content.Pipeline.Tests
Assert.NotNull(output);
}
[Fact]
public void TexturePackerJsonImporter_Import_NewFormat_Test()
{
var filePath = PathExtensions.GetApplicationFullPath(@"TestData/test-atlas.json");
var importer = new TexturePackerJsonImporter();
var data = importer.Import(filePath, Substitute.For<ContentImporterContext>());
// Regions must be null (only used by old generic json format)
Assert.Null(data.Regions);
// Textures contains 1 texture
Assert.NotNull(data.Textures);
Assert.Single(data.Textures);
var texture = data.Textures[0];
Assert.Equal("test-atlas-texture.png", texture.FileName);
// The first texture contains frames
Assert.NotNull(texture.Frames);
Assert.Contains("0001.png", texture.Frames.Keys);
// Check properties of the first frame ("0001.png")
var frame = texture.Frames["0001.png"];
Assert.Equal(164, frame.Frame.X);
Assert.Equal(1, frame.Frame.Y);
Assert.Equal(158, frame.Frame.Width);
Assert.Equal(316, frame.Frame.Height);
Assert.NotNull(frame.Size);
Assert.Equal(187, frame.Size.Width);
Assert.Equal(324, frame.Size.Height);
Assert.NotNull(frame.Offset);
Assert.Equal(15, frame.Offset.X);
Assert.Equal(3, frame.Offset.Y);
Assert.Equal(0, frame.Rotated);
Assert.NotNull(frame.Pivot);
Assert.Equal(0.5, frame.Pivot.X);
Assert.Equal(1.0, frame.Pivot.Y);
}
}
}