From 267378a3817ccb17d890b839b25a82542e2e677c Mon Sep 17 00:00:00 2001 From: Algent Date: Thu, 9 Jul 2026 00:19:50 +0200 Subject: [PATCH] Add fast texture atlas stitcher --- .../gtnewhorizons/angelica/mixins/Mixins.java | 4 +- .../textures/FastTextureStitcher.java | 269 ++++++++++++++++++ .../angelica/textures/MixinStitcher.java | 53 ++++ .../textures/FastTextureStitcherTest.java | 58 ++++ 4 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gtnewhorizons/angelica/textures/FastTextureStitcher.java create mode 100644 src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/textures/MixinStitcher.java create mode 100644 src/test/java/com/gtnewhorizons/angelica/textures/FastTextureStitcherTest.java diff --git a/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java b/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java index a51edf2c4..78b045f30 100644 --- a/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java +++ b/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java @@ -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() diff --git a/src/main/java/com/gtnewhorizons/angelica/textures/FastTextureStitcher.java b/src/main/java/com/gtnewhorizons/angelica/textures/FastTextureStitcher.java new file mode 100644 index 000000000..6d6c56f78 --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/textures/FastTextureStitcher.java @@ -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.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 skyline = new ArrayList<>(); + List 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 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 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 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 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 sprites; + + private Result(int width, int height, List 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; + } + } +} diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/textures/MixinStitcher.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/textures/MixinStitcher.java new file mode 100644 index 000000000..3fdc9170c --- /dev/null +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/textures/MixinStitcher.java @@ -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 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); + } +} diff --git a/src/test/java/com/gtnewhorizons/angelica/textures/FastTextureStitcherTest.java b/src/test/java/com/gtnewhorizons/angelica/textures/FastTextureStitcherTest.java new file mode 100644 index 000000000..a043d0131 --- /dev/null +++ b/src/test/java/com/gtnewhorizons/angelica/textures/FastTextureStitcherTest.java @@ -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 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); + } +}