Add fast texture atlas stitcher

This commit is contained in:
Algent committed 2026-07-09 00:19:50 +02:00
1 parent c22935660a
commit 267378a381
4 files changed
+383 -1

No files matched your search

@@ -463,7 +463,9 @@ public enum Mixins implements IMixins {
OPTIMIZE_TEXTURE_LOADING(new MixinBuilder()
.setPhase(Phase.EARLY)
.addClientMixins("angelica.textures.MixinTextureUtil_OptimizeMipmap")
.addClientMixins(
"angelica.textures.MixinTextureUtil_OptimizeMipmap",
"angelica.textures.MixinStitcher")
.setApplyIf(() -> AngelicaConfig.optimizeTextureLoading)),
REPLACE_FFP(new MixinBuilder()
@@ -0,0 +1,269 @@
package com.gtnewhorizons.angelica.textures;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import net.minecraft.client.renderer.StitcherException;
import net.minecraft.client.renderer.texture.Stitcher;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.util.MathHelper;
public final class FastTextureStitcher {
private FastTextureStitcher() {}
public static Result stitch(Stitcher.Holder[] holders, int maxWidth, int maxHeight, boolean forcePowerOf2) {
if (holders.length == 0) {
return new Result(0, 0, Collections.<TextureAtlasSprite>emptyList());
}
long area = 0L;
int minWidth = 1;
int minHeight = 1;
boolean[] initialRotations = new boolean[holders.length];
for (int i = 0; i < holders.length; i++) {
Stitcher.Holder holder = holders[i];
initialRotations[i] = holder.isRotated();
int width = holder.getWidth();
int height = holder.getHeight();
if (width > maxWidth || height > maxHeight) {
throw unableToFit(holder, maxWidth, maxHeight);
}
area += (long) width * height;
minWidth = Math.max(minWidth, width);
minHeight = Math.max(minHeight, height);
}
int width = roundDimension(Math.max(minWidth, (int) Math.ceil(Math.sqrt(area))), forcePowerOf2);
width = Math.min(width, maxWidth);
int height = roundDimension(Math.max(minHeight, (int) ((area + width - 1L) / width)), forcePowerOf2);
height = Math.min(height, maxHeight);
while (width <= maxWidth && height <= maxHeight) {
restoreRotations(holders, initialRotations);
Result result = tryStitch(holders, width, height, forcePowerOf2);
if (result != null) {
return result;
}
if ((width <= height && width < maxWidth) || height >= maxHeight) {
width = grow(width, maxWidth, forcePowerOf2);
} else {
height = grow(height, maxHeight, forcePowerOf2);
}
}
throw unableToFit(holders[0], maxWidth, maxHeight);
}
private static Result tryStitch(Stitcher.Holder[] holders, int atlasWidth, int atlasHeight, boolean forcePowerOf2) {
List<Node> skyline = new ArrayList<>();
List<TextureAtlasSprite> sprites = new ArrayList<>(holders.length);
int[] xs = new int[holders.length];
int[] ys = new int[holders.length];
skyline.add(new Node(0, 0, atlasWidth));
int usedWidth = 0;
int usedHeight = 0;
for (int i = 0; i < holders.length; i++) {
Stitcher.Holder holder = holders[i];
Placement placement = findPlacement(skyline, atlasWidth, atlasHeight, holder);
if (placement == null) {
return null;
}
if (holder.isRotated() != placement.rotated) {
holder.rotate();
}
place(skyline, placement);
int width = holder.getWidth();
int height = holder.getHeight();
usedWidth = Math.max(usedWidth, placement.x + width);
usedHeight = Math.max(usedHeight, placement.y + height);
sprites.add(holder.getAtlasSprite());
xs[i] = placement.x;
ys[i] = placement.y;
}
int resultWidth = forcePowerOf2 ? atlasWidth : usedWidth;
int resultHeight = forcePowerOf2 ? atlasHeight : usedHeight;
for (int i = 0; i < holders.length; i++) {
Stitcher.Holder holder = holders[i];
TextureAtlasSprite sprite = holder.getAtlasSprite();
sprite.initSprite(resultWidth, resultHeight, xs[i], ys[i], holder.isRotated());
}
return new Result(resultWidth, resultHeight, sprites);
}
private static Placement findPlacement(List<Node> skyline, int atlasWidth, int atlasHeight, Stitcher.Holder holder) {
Placement best = findPlacement(skyline, atlasWidth, atlasHeight, holder.getWidth(), holder.getHeight(), holder.isRotated());
holder.rotate();
Placement rotated = findPlacement(skyline, atlasWidth, atlasHeight, holder.getWidth(), holder.getHeight(), holder.isRotated());
holder.rotate();
if (best == null) {
return rotated;
}
if (rotated == null) {
return best;
}
return rotated.bottom < best.bottom || rotated.bottom == best.bottom && rotated.x < best.x ? rotated : best;
}
private static Placement findPlacement(List<Node> skyline, int atlasWidth, int atlasHeight, int width, int height, boolean rotated) {
Placement best = null;
for (int i = 0; i < skyline.size(); i++) {
Node node = skyline.get(i);
int y = fit(skyline, i, width, height, atlasWidth, atlasHeight);
if (y >= 0) {
int bottom = y + height;
if (best == null || bottom < best.bottom || bottom == best.bottom && node.x < best.x) {
best = new Placement(i, node.x, y, width, bottom, rotated);
}
}
}
return best;
}
private static int fit(List<Node> skyline, int index, int width, int height, int atlasWidth, int atlasHeight) {
Node node = skyline.get(index);
if (node.x + width > atlasWidth) {
return -1;
}
int y = node.y;
int widthLeft = width;
for (int i = index; widthLeft > 0; i++) {
if (i >= skyline.size()) {
return -1;
}
Node current = skyline.get(i);
y = Math.max(y, current.y);
if (y + height > atlasHeight) {
return -1;
}
widthLeft -= current.width;
}
return y;
}
private static void place(List<Node> skyline, Placement placement) {
skyline.add(placement.index, new Node(placement.x, placement.bottom, placement.width));
for (int i = placement.index + 1; i < skyline.size(); i++) {
Node previous = skyline.get(i - 1);
Node current = skyline.get(i);
int shrink = previous.x + previous.width - current.x;
if (shrink <= 0) {
break;
}
current.x += shrink;
current.width -= shrink;
if (current.width <= 0) {
skyline.remove(i--);
} else {
break;
}
}
for (int i = 0; i < skyline.size() - 1; i++) {
Node current = skyline.get(i);
Node next = skyline.get(i + 1);
if (current.y == next.y) {
current.width += next.width;
skyline.remove(i-- + 1);
}
}
}
private static void restoreRotations(Stitcher.Holder[] holders, boolean[] rotations) {
for (int i = 0; i < holders.length; i++) {
if (holders[i].isRotated() != rotations[i]) {
holders[i].rotate();
}
}
}
private static int roundDimension(int value, boolean forcePowerOf2) {
return forcePowerOf2 ? MathHelper.roundUpToPowerOfTwo(value) : value;
}
private static int grow(int value, int max, boolean forcePowerOf2) {
if (value >= max) {
return max + 1;
}
int next = forcePowerOf2 ? MathHelper.roundUpToPowerOfTwo(value + 1) : value * 2;
return Math.min(Math.max(next, value + 1), max);
}
private static StitcherException unableToFit(Stitcher.Holder holder, int maxWidth, int maxHeight) {
return new StitcherException(holder, "Unable to fit: " + holder.getAtlasSprite().getIconName()
+ " - size: " + holder.getAtlasSprite().getIconWidth() + "x" + holder.getAtlasSprite().getIconHeight()
+ " - max atlas: " + maxWidth + "x" + maxHeight);
}
public static final class Result {
public final int width;
public final int height;
public final List<TextureAtlasSprite> sprites;
private Result(int width, int height, List<TextureAtlasSprite> sprites) {
this.width = width;
this.height = height;
this.sprites = sprites;
}
}
private static final class Node {
int x;
int y;
int width;
private Node(int x, int y, int width) {
this.x = x;
this.y = y;
this.width = width;
}
}
private static final class Placement {
final int index;
final int x;
final int y;
final int width;
final int bottom;
final boolean rotated;
private Placement(int index, int x, int y, int width, int bottom, boolean rotated) {
this.index = index;
this.x = x;
this.y = y;
this.width = width;
this.bottom = bottom;
this.rotated = rotated;
}
}
}
@@ -0,0 +1,53 @@
package com.gtnewhorizons.angelica.mixins.early.angelica.textures;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import com.gtnewhorizons.angelica.textures.FastTextureStitcher;
import net.minecraft.client.renderer.texture.Stitcher;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Overwrite;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
@Mixin(Stitcher.class)
public abstract class MixinStitcher {
@Shadow @Final private Set setStitchHolders;
@Shadow @Final private int maxWidth;
@Shadow @Final private int maxHeight;
@Shadow @Final private boolean forcePowerOf2;
@Shadow private int currentWidth;
@Shadow private int currentHeight;
@Unique private List<TextureAtlasSprite> angelica$stitchedSprites = Collections.emptyList();
/**
* @author Angelica
* @reason Use a skyline packer instead of vanilla recursive slots.
*/
@Overwrite
public void doStitch() {
Stitcher.Holder[] holders = (Stitcher.Holder[]) this.setStitchHolders.toArray(new Stitcher.Holder[this.setStitchHolders.size()]);
Arrays.sort(holders);
FastTextureStitcher.Result result = FastTextureStitcher.stitch(holders, this.maxWidth, this.maxHeight, this.forcePowerOf2);
this.currentWidth = result.width;
this.currentHeight = result.height;
this.angelica$stitchedSprites = result.sprites;
}
/**
* @author Angelica
* @reason FastTextureStitcher initializes sprites directly.
*/
@Overwrite
public List getStichSlots() {
return new ArrayList(this.angelica$stitchedSprites);
}
}
@@ -0,0 +1,58 @@
package com.gtnewhorizons.angelica.textures;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.awt.Rectangle;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import net.minecraft.client.renderer.texture.Stitcher;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import org.junit.jupiter.api.Test;
class FastTextureStitcherTest {
@Test
void packsSpritesWithoutOverlap() {
Stitcher.Holder[] holders = {
holder("a", 32, 16),
holder("b", 16, 32),
holder("c", 16, 16),
holder("d", 8, 40),
holder("e", 24, 8)
};
Arrays.sort(holders);
FastTextureStitcher.Result result = FastTextureStitcher.stitch(holders, 128, 128, true);
assertTrue(result.width <= 128);
assertTrue(result.height <= 128);
assertEquals(holders.length, result.sprites.size());
List<Rectangle> rectangles = new ArrayList<>();
for (Stitcher.Holder holder : holders) {
TextureAtlasSprite sprite = holder.getAtlasSprite();
Rectangle rectangle = new Rectangle(sprite.getOriginX(), sprite.getOriginY(), holder.getWidth(), holder.getHeight());
assertTrue(rectangle.x >= 0);
assertTrue(rectangle.y >= 0);
assertTrue(rectangle.x + rectangle.width <= result.width);
assertTrue(rectangle.y + rectangle.height <= result.height);
for (Rectangle other : rectangles) {
assertFalse(rectangle.intersects(other), rectangle + " overlaps " + other);
}
rectangles.add(rectangle);
}
}
private static Stitcher.Holder holder(String name, int width, int height) {
TextureAtlasSprite sprite = new TextureAtlasSprite(name);
sprite.setIconWidth(width);
sprite.setIconHeight(height);
return new Stitcher.Holder(sprite, 0);
}
}