Skip to content

Commit c4ecdfe

Browse files
committed
feat: use more accurate atlas sprite packing
This resolves most of round-trip discrepancies in animated textures. There are still outliers that have to be handled in some way (horizontal/vertical stripes, no-padding atlas textures)
1 parent 3355481 commit c4ecdfe

2 files changed

Lines changed: 304 additions & 57 deletions

File tree

Core/Conversion/Formats/AnimatedTextureConverter.cs

Lines changed: 11 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -83,45 +83,33 @@ private static Image<Rgba32> AnimationTextureToAtlasImage(AnimatedTexture txt)
8383

8484
private static AnimatedTexture AnimationImageToAnimatedTexture(Image<Rgba32> animation)
8585
{
86-
(var atlasWidth, var atlasHeight) = FindMinimumPowerOfTwoAtlasSize(animation);
87-
var frames = ExtractFrameDataFromGif(animation, atlasWidth);
88-
89-
using var atlasImage = new Image<Rgba32>(atlasWidth, atlasHeight, Color.Transparent);
90-
PopulateAtlasImageFromGif(atlasImage, animation, frames);
91-
var atlas = TexturesUtil.ImageToTexture2D(atlasImage);
92-
93-
return new AnimatedTexture()
86+
var frames = ExtractFrameDataFromGif(animation);
87+
var animatedTexture = new AnimatedTexture()
9488
{
95-
AtlasWidth = atlasWidth,
96-
AtlasHeight = atlasHeight,
9789
FrameWidth = animation.Width,
9890
FrameHeight = animation.Height,
99-
10091
Frames = frames,
101-
TextureData = atlas.TextureData,
10292
};
93+
animatedTexture.PackFrames(1);
94+
95+
using var atlasImage = new Image<Rgba32>(animatedTexture.AtlasWidth, animatedTexture.AtlasHeight, Color.Transparent);
96+
PopulateAtlasImageFromGif(atlasImage, animation, frames);
97+
animatedTexture.TextureData = TexturesUtil.ImageToTexture2D(atlasImage).TextureData;
98+
99+
return animatedTexture;
103100
}
104101

105-
private static List<FrameContent> ExtractFrameDataFromGif(Image<Rgba32> animation, int atlasWidth)
102+
private static List<FrameContent> ExtractFrameDataFromGif(Image<Rgba32> animation)
106103
{
107104
var frames = new List<FrameContent>();
108105

109-
int framePosX = 0;
110-
int framePosY = 0;
111106
foreach (ImageFrame<Rgba32> frameImg in animation.Frames)
112107
{
113108
frames.Add(new()
114109
{
115110
Duration = TimeSpan.FromMilliseconds(frameImg.Metadata.GetGifMetadata().FrameDelay * 10),
116-
Rectangle = new(framePosX, framePosY, frameImg.Width, frameImg.Height)
111+
Rectangle = new(0, 0, frameImg.Width, frameImg.Height)
117112
});
118-
119-
framePosX += animation.Width + FramePadding;
120-
if (framePosX > atlasWidth - animation.Width)
121-
{
122-
framePosX = 0;
123-
framePosY += animation.Height + FramePadding;
124-
}
125113
}
126114

127115
return frames;
@@ -142,39 +130,5 @@ private static void PopulateAtlasImageFromGif(Image<Rgba32> atlasImage, Image<Rg
142130
});
143131
}
144132
}
145-
146-
147-
// Calculating the minimum size of the atlas for the animation
148-
// with both width and height being powers of two
149-
private static (int Width, int Height) FindMinimumPowerOfTwoAtlasSize(Image animatedImage)
150-
{
151-
int atlasWidth = 0;
152-
int atlasHeight = 0;
153-
int atlasArea = int.MaxValue;
154-
155-
for (int i = 1; i <= animatedImage.Frames.Count; i++)
156-
{
157-
var framesInRow = i;
158-
var framesInColumn = (int)Math.Ceiling(animatedImage.Frames.Count / (float)i);
159-
160-
int newAtlasWidth = (animatedImage.Width + FramePadding) * framesInRow;
161-
int newAtlasHeight = (animatedImage.Height + FramePadding) * framesInColumn;
162-
163-
newAtlasWidth = (int)Math.Pow(2, Math.Ceiling(Math.Log(newAtlasWidth, 2)));
164-
newAtlasHeight = (int)Math.Pow(2, Math.Ceiling(Math.Log(newAtlasHeight, 2)));
165-
166-
if (newAtlasWidth > newAtlasHeight && atlasWidth > 0 && atlasHeight > 0) break;
167-
168-
int newArea = newAtlasWidth * newAtlasHeight;
169-
if (newArea <= atlasArea)
170-
{
171-
atlasArea = newArea;
172-
atlasWidth = newAtlasWidth;
173-
atlasHeight = newAtlasHeight;
174-
}
175-
}
176-
177-
return (atlasWidth, atlasHeight);
178-
}
179133
}
180134
}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
using FEZRepacker.Core.Definitions.Game.Graphics;
2+
using FEZRepacker.Core.Definitions.Game.XNA;
3+
4+
/*
5+
* Modified image packer from SpriteSheetPacker by Nick Gravelyn
6+
* including rectangle packer by Javier Arevalo
7+
*
8+
* The algorithm was modified for usage of regenerating sprite sheets
9+
* accurate to those included as AnimatedTextures in FEZ.
10+
* Following simplifications were applied:
11+
* - frames are processed sequentially, ordered by their index
12+
* - pack space is always a power of 2
13+
* - all frames are equal size
14+
*/
15+
16+
namespace FEZRepacker.Core.Helpers
17+
{
18+
public static class SpriteSheetPackerUtil
19+
{
20+
private const int InitialAtlasSize = 2048;
21+
22+
public static void PackFrames(this AnimatedTexture tex, int padding)
23+
{
24+
tex.AtlasWidth = InitialAtlasSize;
25+
tex.AtlasHeight = InitialAtlasSize;
26+
var lastAtlasWidth = InitialAtlasSize;
27+
var lastAtlasHeight = InitialAtlasSize;
28+
29+
var shrinkVertical = false;
30+
31+
while (true)
32+
{
33+
if (!tex.TryPackFramesInCurrentAtlasSize(padding))
34+
{
35+
if (shrinkVertical)
36+
{
37+
break;
38+
}
39+
40+
shrinkVertical = true;
41+
tex.AtlasWidth += tex.FrameWidth + padding + padding;
42+
tex.AtlasHeight += tex.FrameHeight + padding + padding;
43+
continue;
44+
}
45+
46+
tex.ResizeAtlasToSmallestFrameFit();
47+
48+
if (!shrinkVertical)
49+
{
50+
tex.AtlasWidth -= padding;
51+
}
52+
tex.AtlasHeight -= padding;
53+
54+
tex.ResizeAtlasToNextPowerOfTwo();
55+
56+
if (tex.AtlasWidth == lastAtlasWidth && tex.AtlasHeight == lastAtlasHeight)
57+
{
58+
if (shrinkVertical)
59+
{
60+
break;
61+
}
62+
shrinkVertical = true;
63+
}
64+
65+
lastAtlasWidth = tex.AtlasWidth;
66+
lastAtlasHeight = tex.AtlasHeight;
67+
68+
if (!shrinkVertical)
69+
{
70+
tex.AtlasWidth -= tex.FrameWidth;
71+
}
72+
tex.AtlasHeight -= tex.FrameHeight;
73+
}
74+
75+
tex.ResizeAtlasToSmallestFrameFit();
76+
tex.ResizeAtlasToNextPowerOfTwo();
77+
}
78+
79+
private static bool TryPackFramesInCurrentAtlasSize(this AnimatedTexture tex, int padding)
80+
{
81+
ArevaloRectanglePacker rectanglePacker = new ArevaloRectanglePacker(tex.AtlasWidth, tex.AtlasHeight);
82+
83+
for (var i = 0; i < tex.Frames.Count; i++)
84+
{
85+
var frameRect = tex.Frames[i].Rectangle;
86+
if (!rectanglePacker.TryPack(frameRect, padding))
87+
{
88+
return false;
89+
}
90+
tex.Frames[i].Rectangle = frameRect;
91+
}
92+
93+
return true;
94+
}
95+
96+
private static void ResizeAtlasToSmallestFrameFit(this AnimatedTexture tex)
97+
{
98+
tex.AtlasWidth = tex.AtlasHeight = 0;
99+
foreach (var frame in tex.Frames)
100+
{
101+
tex.AtlasWidth = Math.Max(tex.AtlasWidth, frame.Rectangle.X + frame.Rectangle.Width);
102+
tex.AtlasHeight = Math.Max(tex.AtlasHeight, frame.Rectangle.Y + frame.Rectangle.Height);
103+
}
104+
}
105+
106+
private static void ResizeAtlasToNextPowerOfTwo(this AnimatedTexture tex)
107+
{
108+
tex.AtlasWidth = NextPowerOfTwo(tex.AtlasWidth);
109+
tex.AtlasHeight = NextPowerOfTwo(tex.AtlasHeight);
110+
}
111+
112+
private static int NextPowerOfTwo(int num)
113+
{
114+
const int bitsInInt = sizeof(int) * 8;
115+
116+
num--;
117+
for (var i = 1; i < bitsInInt; i <<= 1)
118+
{
119+
num |= num >> i;
120+
}
121+
return num + 1;
122+
}
123+
124+
#region ArevaloRectanglePacker
125+
126+
private class ArevaloRectanglePacker(int packingAreaWidth, int packingAreaHeight)
127+
{
128+
private struct Point(int x, int y)
129+
{
130+
public int X = x;
131+
public int Y = y;
132+
}
133+
134+
private class AnchorRankComparer : IComparer<Point>
135+
{
136+
public static readonly AnchorRankComparer Default = new();
137+
public int Compare(Point left, Point right) => (left.X + left.Y) - (right.X + right.Y);
138+
}
139+
140+
private int _searchAreaHeight = 1;
141+
private int _searchAreaWidth = 1;
142+
143+
private readonly List<Point> _anchors = [new(0, 0)];
144+
private readonly List<Rectangle> _packedRectangles = new();
145+
146+
public bool TryPack(Rectangle rectangle, int padding)
147+
{
148+
var rectWidth = rectangle.Width + padding;
149+
var rectHeight = rectangle.Height + padding;
150+
var anchorIndex = SelectAnchorRecursive(rectWidth, rectHeight, _searchAreaWidth, _searchAreaHeight);
151+
152+
if (anchorIndex == -1)
153+
{
154+
rectangle.X = rectangle.Y = 0;
155+
return false;
156+
}
157+
158+
var placement = _anchors[anchorIndex];
159+
160+
OptimizePlacement(placement, rectWidth, rectHeight);
161+
162+
var blocksAnchor =
163+
((placement.X + rectWidth) > _anchors[anchorIndex].X) &&
164+
((placement.Y + rectHeight) > _anchors[anchorIndex].Y);
165+
166+
if (blocksAnchor)
167+
{
168+
_anchors.RemoveAt(anchorIndex);
169+
}
170+
171+
InsertAnchor(new Point(placement.X + rectWidth, placement.Y));
172+
InsertAnchor(new Point(placement.X, placement.Y + rectHeight));
173+
174+
_packedRectangles.Add(new Rectangle(placement.X, placement.Y, rectWidth, rectHeight));
175+
rectangle.X = placement.X;
176+
rectangle.Y = placement.Y;
177+
178+
return true;
179+
}
180+
181+
private void OptimizePlacement(Point placement, int rectangleWidth, int rectangleHeight)
182+
{
183+
var rectangle = new Rectangle(placement.X, placement.Y, rectangleWidth, rectangleHeight);
184+
185+
var leftMost = placement.X;
186+
while (IsFree(rectangle, packingAreaWidth, packingAreaHeight))
187+
{
188+
leftMost = rectangle.X;
189+
--rectangle.X;
190+
}
191+
192+
rectangle.X = placement.X;
193+
194+
var topMost = placement.Y;
195+
while (IsFree(rectangle, packingAreaWidth, packingAreaHeight))
196+
{
197+
topMost = rectangle.Y;
198+
--rectangle.Y;
199+
}
200+
201+
if ((placement.X - leftMost) > (placement.Y - topMost))
202+
{
203+
placement.X = leftMost;
204+
}
205+
else
206+
{
207+
placement.Y = topMost;
208+
}
209+
}
210+
211+
private int SelectAnchorRecursive(int rectWidth, int rectHeight, int testedAreaWidth, int testedAreaHeight)
212+
{
213+
while (true)
214+
{
215+
var freeAnchorIndex = FindFirstFreeAnchor(rectWidth, rectHeight, testedAreaWidth, testedAreaHeight);
216+
if (freeAnchorIndex >= 0)
217+
{
218+
_searchAreaWidth = testedAreaWidth;
219+
_searchAreaHeight = testedAreaHeight;
220+
221+
return freeAnchorIndex;
222+
}
223+
224+
var canEnlargeWidth = (testedAreaWidth < packingAreaWidth);
225+
var canEnlargeHeight = (testedAreaHeight < packingAreaHeight);
226+
var shouldEnlargeHeight = (!canEnlargeWidth) || (testedAreaHeight < testedAreaWidth);
227+
228+
if (canEnlargeHeight && shouldEnlargeHeight)
229+
{
230+
testedAreaHeight = Math.Min(testedAreaHeight * 2, packingAreaHeight);
231+
continue;
232+
}
233+
234+
if (canEnlargeWidth)
235+
{
236+
testedAreaWidth = Math.Min(testedAreaWidth * 2, packingAreaWidth);
237+
continue;
238+
}
239+
240+
return -1;
241+
}
242+
}
243+
244+
private int FindFirstFreeAnchor(int rectWidth, int rectHeight, int testedAreaWidth, int testedAreaHeight)
245+
{
246+
var potentialLocation = new Rectangle(0, 0, rectWidth, rectHeight);
247+
248+
for (var index = 0; index < _anchors.Count; ++index)
249+
{
250+
potentialLocation.X = _anchors[index].X;
251+
potentialLocation.Y = _anchors[index].Y;
252+
253+
if (IsFree(potentialLocation, testedAreaWidth, testedAreaHeight))
254+
{
255+
return index;
256+
}
257+
}
258+
259+
return -1;
260+
}
261+
262+
private bool IsFree(Rectangle rectangle, int testedPackingAreaWidth, int testedPackingAreaHeight)
263+
{
264+
var leavesPackingArea =
265+
(rectangle.X < 0) || (rectangle.Y < 0) ||
266+
(rectangle.X + rectangle.Width > testedPackingAreaWidth) ||
267+
(rectangle.Y + rectangle.Height > testedPackingAreaHeight);
268+
269+
return !leavesPackingArea && !IntersectsPackedRectangle(rectangle);
270+
}
271+
272+
private bool IntersectsPackedRectangle(Rectangle rectangle)
273+
{
274+
return _packedRectangles.Any(packed =>
275+
rectangle.X < packed.X + packed.Width &&
276+
rectangle.X + rectangle.Width > packed.X &&
277+
rectangle.Y < packed.Y + packed.Height &&
278+
rectangle.Y + rectangle.Height > packed.Y);
279+
}
280+
281+
private void InsertAnchor(Point anchor)
282+
{
283+
int insertIndex = _anchors.BinarySearch(anchor, AnchorRankComparer.Default);
284+
if (insertIndex < 0)
285+
insertIndex = ~insertIndex;
286+
287+
_anchors.Insert(insertIndex, anchor);
288+
}
289+
}
290+
291+
#endregion
292+
}
293+
}

0 commit comments

Comments
 (0)