aboutsummaryrefslogtreecommitdiff
path: root/common/src/main/java/dev/isxander/yacl3/gui/image
diff options
context:
space:
mode:
authorisXander <xander@isxander.dev>2023-08-17 14:19:32 +0100
committerisXander <xander@isxander.dev>2023-08-17 14:19:32 +0100
commit39bc5b5d8b8e6d4369ea71a7787907521e11ad34 (patch)
treed56be6ac071094646829061e2d835e6e952df47c /common/src/main/java/dev/isxander/yacl3/gui/image
parent9ffd159faa215256161c3af9c57bd0b742b6d818 (diff)
downloadYetAnotherConfigLib-39bc5b5d8b8e6d4369ea71a7787907521e11ad34.tar.gz
YetAnotherConfigLib-39bc5b5d8b8e6d4369ea71a7787907521e11ad34.tar.bz2
YetAnotherConfigLib-39bc5b5d8b8e6d4369ea71a7787907521e11ad34.zip
Re-write image renderer handling to be threadsafe (relates to #101)
Diffstat (limited to 'common/src/main/java/dev/isxander/yacl3/gui/image')
-rw-r--r--common/src/main/java/dev/isxander/yacl3/gui/image/ImageRenderer.java11
-rw-r--r--common/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererFactory.java24
-rw-r--r--common/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererManager.java76
-rw-r--r--common/src/main/java/dev/isxander/yacl3/gui/image/impl/AnimatedDynamicTextureImage.java282
-rw-r--r--common/src/main/java/dev/isxander/yacl3/gui/image/impl/DynamicTextureImage.java62
-rw-r--r--common/src/main/java/dev/isxander/yacl3/gui/image/impl/ResourceTextureImage.java46
6 files changed, 501 insertions, 0 deletions
diff --git a/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRenderer.java b/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRenderer.java
new file mode 100644
index 0000000..d3fb4bf
--- /dev/null
+++ b/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRenderer.java
@@ -0,0 +1,11 @@
+package dev.isxander.yacl3.gui.image;
+
+import net.minecraft.client.gui.GuiGraphics;
+
+public interface ImageRenderer {
+ int render(GuiGraphics graphics, int x, int y, int renderWidth, float tickDelta);
+
+ void close();
+
+ default void tick() {}
+}
diff --git a/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererFactory.java b/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererFactory.java
new file mode 100644
index 0000000..480d8b1
--- /dev/null
+++ b/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererFactory.java
@@ -0,0 +1,24 @@
+package dev.isxander.yacl3.gui.image;
+
+public interface ImageRendererFactory<T extends ImageRenderer> {
+ /**
+ * Prepares the image. This can be run off-thread,
+ * and should NOT contain any GL calls whatsoever.
+ */
+ ImageSupplier<T> prepareImage() throws Exception;
+
+ default boolean requiresOffThreadPreparation() {
+ return true;
+ }
+
+ interface ImageSupplier<T extends ImageRenderer> {
+ T completeImage() throws Exception;
+ }
+
+ interface OnThread<T extends ImageRenderer> extends ImageRendererFactory<T> {
+ @Override
+ default boolean requiresOffThreadPreparation() {
+ return false;
+ }
+ }
+}
diff --git a/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererManager.java b/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererManager.java
new file mode 100644
index 0000000..b1f133a
--- /dev/null
+++ b/common/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererManager.java
@@ -0,0 +1,76 @@
+package dev.isxander.yacl3.gui.image;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.resources.ResourceLocation;
+
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.*;
+
+public class ImageRendererManager {
+ private static final ExecutorService SINGLE_THREAD_EXECUTOR = Executors.newSingleThreadExecutor(task -> new Thread(task, "YACL Image Prep"));
+
+ private static final Map<ResourceLocation, CompletableFuture<ImageRenderer>> IMAGE_CACHE = new ConcurrentHashMap<>();
+ private static final Queue<FactoryIDPair<?>> CREATION_QUEUE = new ConcurrentLinkedQueue<>();
+
+ public static <T extends ImageRenderer> CompletableFuture<T> registerImage(ResourceLocation id, ImageRendererFactory<T> factory) {
+ SINGLE_THREAD_EXECUTOR.submit(() -> {
+ try {
+ ImageRendererFactory.ImageSupplier<T> supplier = factory.prepareImage();
+ CREATION_QUEUE.add(new FactoryIDPair<>(id, supplier));
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to prepare image '{}'", id, e);
+ IMAGE_CACHE.remove(id);
+ }
+ });
+
+ var future = new CompletableFuture<ImageRenderer>();
+ IMAGE_CACHE.put(id, future);
+ return (CompletableFuture<T>) future;
+ }
+
+ public static void pollImageFactories() {
+ RenderSystem.assertOnRenderThread();
+
+ while (!CREATION_QUEUE.isEmpty()) {
+ FactoryIDPair<?> pair = CREATION_QUEUE.poll();
+
+ // sanity check - this should never happen
+ if (!IMAGE_CACHE.containsKey(pair.id())) {
+ YACLConstants.LOGGER.error("Tried to finalise image '{}' but it was not found in cache.", pair.id());
+ continue;
+ }
+
+ ImageRenderer image;
+ try {
+ image = pair.supplier().completeImage();
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to create image '{}'", pair.id(), e);
+ continue;
+ }
+
+ CompletableFuture<ImageRenderer> future = IMAGE_CACHE.get(pair.id());
+ // another sanity check - this should never happen
+ if (future.isDone()) {
+ YACLConstants.LOGGER.error("Image '{}' was already completed", pair.id());
+ continue;
+ }
+
+ future.complete(image);
+ }
+ }
+
+ public static void closeAll() {
+ SINGLE_THREAD_EXECUTOR.shutdownNow();
+ CREATION_QUEUE.clear();
+ IMAGE_CACHE.values().forEach(future -> {
+ if (future.isDone()) {
+ future.join().close();
+ }
+ });
+ IMAGE_CACHE.clear();
+ }
+
+ private record FactoryIDPair<T extends ImageRenderer>(ResourceLocation id, ImageRendererFactory.ImageSupplier<T> supplier) {}
+}
diff --git a/common/src/main/java/dev/isxander/yacl3/gui/image/impl/AnimatedDynamicTextureImage.java b/common/src/main/java/dev/isxander/yacl3/gui/image/impl/AnimatedDynamicTextureImage.java
new file mode 100644
index 0000000..edf92a5
--- /dev/null
+++ b/common/src/main/java/dev/isxander/yacl3/gui/image/impl/AnimatedDynamicTextureImage.java
@@ -0,0 +1,282 @@
+package dev.isxander.yacl3.gui.image.impl;
+
+import com.mojang.blaze3d.Blaze3D;
+import com.mojang.blaze3d.platform.NativeImage;
+import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
+import dev.isxander.yacl3.gui.image.ImageRendererFactory;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.CrashReport;
+import net.minecraft.CrashReportCategory;
+import net.minecraft.ReportedException;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.packs.resources.Resource;
+import net.minecraft.server.packs.resources.ResourceManager;
+import net.minecraft.util.FastColor;
+
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.metadata.IIOMetadata;
+import javax.imageio.metadata.IIOMetadataNode;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.IntStream;
+
+public class AnimatedDynamicTextureImage extends DynamicTextureImage {
+ private int currentFrame;
+ private double lastFrameTime;
+
+ private final double[] frameDelays;
+ private final int frameCount;
+
+ private final int packCols, packRows;
+ private final int frameWidth, frameHeight;
+
+ public AnimatedDynamicTextureImage(NativeImage image, int frameWidth, int frameHeight, int frameCount, double[] frameDelayMS, int packCols, int packRows, ResourceLocation uniqueLocation) {
+ super(image, uniqueLocation);
+ this.frameWidth = frameWidth;
+ this.frameHeight = frameHeight;
+ this.frameCount = frameCount;
+ this.frameDelays = frameDelayMS;
+ this.packCols = packCols;
+ this.packRows = packRows;
+ }
+
+ @Override
+ public int render(GuiGraphics graphics, int x, int y, int renderWidth, float tickDelta) {
+ if (image == null) return 0;
+
+ float ratio = renderWidth / (float)frameWidth;
+ int targetHeight = (int) (frameHeight * ratio);
+
+ int currentCol = currentFrame % packCols;
+ int currentRow = (int) Math.floor(currentFrame / (double)packCols);
+
+ graphics.pose().pushPose();
+ graphics.pose().translate(x, y, 0);
+ graphics.pose().scale(ratio, ratio, 1);
+ graphics.blit(
+ uniqueLocation,
+ 0, 0,
+ frameWidth * currentCol, frameHeight * currentRow,
+ frameWidth, frameHeight,
+ this.width, this.height
+ );
+ graphics.pose().popPose();
+
+ if (frameCount > 1) {
+ double timeMS = Blaze3D.getTime() * 1000;
+ if (lastFrameTime == 0) lastFrameTime = timeMS;
+ if (timeMS - lastFrameTime >= frameDelays[currentFrame]) {
+ currentFrame++;
+ lastFrameTime = timeMS;
+ }
+ if (currentFrame >= frameCount - 1)
+ currentFrame = 0;
+ }
+
+ return targetHeight;
+ }
+
+ public static ImageRendererFactory<AnimatedDynamicTextureImage> createGIFFromTexture(ResourceLocation textureLocation) {
+ return () -> {
+ ResourceManager resourceManager = Minecraft.getInstance().getResourceManager();
+ Resource resource = resourceManager.getResource(textureLocation).orElseThrow();
+
+ return createGIFSupplier(resource.open(), textureLocation);
+ };
+ }
+
+ public static ImageRendererFactory<AnimatedDynamicTextureImage> createGIFFromPath(Path path, ResourceLocation uniqueLocation) {
+ return () -> createGIFSupplier(new FileInputStream(path.toFile()), uniqueLocation);
+ }
+
+ public static ImageRendererFactory<AnimatedDynamicTextureImage> createWEBPFromTexture(ResourceLocation textureLocation) {
+ return () -> {
+ ResourceManager resourceManager = Minecraft.getInstance().getResourceManager();
+ Resource resource = resourceManager.getResource(textureLocation).orElseThrow();
+
+ return createWEBPSupplier(resource.open(), textureLocation);
+ };
+ }
+
+ public static ImageRendererFactory<AnimatedDynamicTextureImage> createWEBPFromPath(Path path, ResourceLocation uniqueLocation) {
+ return () -> createWEBPSupplier(new FileInputStream(path.toFile()), uniqueLocation);
+ }
+
+ private static ImageRendererFactory.ImageSupplier<AnimatedDynamicTextureImage> createGIFSupplier(InputStream is, ResourceLocation uniqueLocation) {
+ try (is) {
+ ImageReader reader = ImageIO.getImageReadersBySuffix("gif").next();
+ reader.setInput(ImageIO.createImageInputStream(is));
+
+ AnimFrameProvider animFrameFunction = i -> {
+ IIOMetadata metadata = reader.getImageMetadata(i);
+ String metaFormatName = metadata.getNativeMetadataFormatName();
+ IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree(metaFormatName);
+ IIOMetadataNode graphicsControlExtensionNode = (IIOMetadataNode) root.getElementsByTagName("GraphicControlExtension").item(0);
+ int delay = Integer.parseInt(graphicsControlExtensionNode.getAttribute("delayTime")) * 10;
+
+ return new AnimFrame(delay, 0, 0);
+ };
+
+ return createFromImageReader(reader, animFrameFunction, uniqueLocation);
+ } catch (Exception e) {
+ CrashReport crashReport = CrashReport.forThrowable(e, "Failed to load GIF image");
+ CrashReportCategory category = crashReport.addCategory("YACL Gui");
+ category.setDetail("Image identifier", uniqueLocation.toString());
+ throw new ReportedException(crashReport);
+ }
+ }
+
+ private static ImageRendererFactory.ImageSupplier<AnimatedDynamicTextureImage> createWEBPSupplier(InputStream is, ResourceLocation uniqueLocation) {
+ try (is) {
+ ImageReader reader = new WebPImageReaderSpi().createReaderInstance();
+ reader.setInput(ImageIO.createImageInputStream(is));
+
+ int numImages = reader.getNumImages(true); // Force reading of all frames
+ AnimFrameProvider animFrameFunction = i -> null;
+ if (numImages > 1) {
+ // WebP reader does not expose frame delay, prepare for reflection hell
+ Class<?> webpReaderClass = Class.forName("com.twelvemonkeys.imageio.plugins.webp.WebPImageReader");
+ Field framesField = webpReaderClass.getDeclaredField("frames");
+ framesField.setAccessible(true);
+ java.util.List<?> frames = (List<?>) framesField.get(reader);
+
+ Class<?> animationFrameClass = Class.forName("com.twelvemonkeys.imageio.plugins.webp.AnimationFrame");
+ Field durationField = animationFrameClass.getDeclaredField("duration");
+ durationField.setAccessible(true);
+ Field boundsField = animationFrameClass.getDeclaredField("bounds");
+ boundsField.setAccessible(true);
+
+ animFrameFunction = i -> {
+ Rectangle bounds = (Rectangle) boundsField.get(frames.get(i));
+ return new AnimFrame((int) durationField.get(frames.get(i)), bounds.x, bounds.y);
+ };
+ // that was fun
+ }
+
+ return createFromImageReader(reader, animFrameFunction, uniqueLocation);
+ } catch (Throwable e) {
+ CrashReport crashReport = CrashReport.forThrowable(e, "Failed to load WEBP image");
+ CrashReportCategory category = crashReport.addCategory("YACL Gui");
+ category.setDetail("Image identifier", uniqueLocation.toString());
+ throw new ReportedException(crashReport);
+ }
+ }
+
+ private static ImageRendererFactory.ImageSupplier<AnimatedDynamicTextureImage> createFromImageReader(ImageReader reader, AnimFrameProvider animationProvider, ResourceLocation uniqueLocation) throws Exception {
+ YACLConstants.LOGGER.info("Thread 1: {}", Thread.currentThread().getName());
+
+ if (reader.isSeekForwardOnly()) {
+ throw new RuntimeException("Image reader is not seekable");
+ }
+
+ int frameCount = reader.getNumImages(true);
+
+ // Because this is being backed into a texture atlas, we need a maximum dimension
+ // so you can get the texture atlas size.
+ // Smaller frames are given black borders
+ int frameWidth = IntStream.range(0, frameCount).map(i -> {
+ try {
+ return reader.getWidth(i);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }).max().orElseThrow();
+ int frameHeight = IntStream.range(0, frameCount).map(i -> {
+ try {
+ return reader.getHeight(i);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }).max().orElseThrow();
+
+ // Packs the frames into an optimal 1:1 texture.
+ // OpenGL can only have texture axis with a max of 32768 pixels,
+ // and packing them to that length is not efficient, apparently.
+ double ratio = frameWidth / (double)frameHeight;
+ int cols = (int)Math.ceil(Math.sqrt(frameCount) / Math.sqrt(ratio));
+ int rows = (int)Math.ceil(frameCount / (double)cols);
+
+ NativeImage image = new NativeImage(NativeImage.Format.RGBA, frameWidth * cols, frameHeight * rows, false);
+
+// // Fill whole atlas with black, as each frame may have different dimensions
+// // that would cause borders of transparent pixels to appear around the frames
+// for (int x = 0; x < frameWidth * cols; x++) {
+// for (int y = 0; y < frameHeight * rows; y++) {
+// image.setPixelRGBA(x, y, 0xFF000000);
+// }
+// }
+
+ BufferedImage bi = null;
+ Graphics2D graphics = null;
+
+ // each frame may have a different delay
+ double[] frameDelays = new double[frameCount];
+
+ for (int i = 0; i < frameCount; i++) {
+ AnimFrame frame = animationProvider.get(i);
+ if (frameCount > 1) // frame will be null if not animation
+ frameDelays[i] = frame.durationMS;
+
+ if (bi == null) {
+ // first frame...
+ bi = reader.read(i);
+ graphics = bi.createGraphics();
+ } else {
+ // WebP reader sometimes provides delta frames, (only the pixels that changed since the last frame)
+ // so instead of overwriting the image every frame, we draw delta frames on top of the previous frame
+ // to keep a complete image.
+ BufferedImage deltaFrame = reader.read(i);
+ graphics.drawImage(deltaFrame, frame.xOffset, frame.yOffset, null);
+ }
+
+ // Each frame may have different dimensions, so we need to center them.
+ int xOffset = (frameWidth - bi.getWidth()) / 2;
+ int yOffset = (frameHeight - bi.getHeight()) / 2;
+
+ for (int w = 0; w < bi.getWidth(); w++) {
+ for (int h = 0; h < bi.getHeight(); h++) {
+ int rgb = bi.getRGB(w, h);
+ int r = FastColor.ARGB32.red(rgb);
+ int g = FastColor.ARGB32.green(rgb);
+ int b = FastColor.ARGB32.blue(rgb);
+ int a = FastColor.ARGB32.alpha(rgb);
+
+ int col = i % cols;
+ int row = (int) Math.floor(i / (double)cols);
+
+ image.setPixelRGBA(
+ frameWidth * col + w + xOffset,
+ frameHeight * row + h + yOffset,
+ FastColor.ABGR32.color(a, b, g, r) // NativeImage uses ABGR for some reason
+ );
+ }
+ }
+ }
+
+ if (graphics != null)
+ graphics.dispose();
+ reader.dispose();
+
+ return () -> {
+ YACLConstants.LOGGER.info("Thread 2: {}", Thread.currentThread().getName());
+ return new AnimatedDynamicTextureImage(image, frameWidth, frameHeight, frameCount, frameDelays, cols, rows, uniqueLocation);
+ };
+ }
+
+ @FunctionalInterface
+ private interface AnimFrameProvider {
+ AnimFrame get(int frame) throws Exception;
+ }
+
+ private record AnimFrame(int durationMS, int xOffset, int yOffset) {}
+
+}
diff --git a/common/src/main/java/dev/isxander/yacl3/gui/image/impl/DynamicTextureImage.java b/common/src/main/java/dev/isxander/yacl3/gui/image/impl/DynamicTextureImage.java
new file mode 100644
index 0000000..e0449e9
--- /dev/null
+++ b/common/src/main/java/dev/isxander/yacl3/gui/image/impl/DynamicTextureImage.java
@@ -0,0 +1,62 @@
+package dev.isxander.yacl3.gui.image.impl;
+
+import com.mojang.blaze3d.platform.NativeImage;
+import com.mojang.blaze3d.systems.RenderSystem;
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+import dev.isxander.yacl3.gui.image.ImageRendererFactory;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.renderer.texture.DynamicTexture;
+import net.minecraft.client.renderer.texture.TextureManager;
+import net.minecraft.resources.ResourceLocation;
+
+import java.io.FileInputStream;
+import java.nio.file.Path;
+
+public class DynamicTextureImage implements ImageRenderer {
+ protected static final TextureManager textureManager = Minecraft.getInstance().getTextureManager();
+
+ protected NativeImage image;
+ protected DynamicTexture texture;
+ protected final ResourceLocation uniqueLocation;
+ protected final int width, height;
+
+ public DynamicTextureImage(NativeImage image, ResourceLocation location) {
+ RenderSystem.assertOnRenderThread();
+
+ this.image = image;
+ this.texture = new DynamicTexture(image);
+ this.uniqueLocation = location;
+ textureManager.register(this.uniqueLocation, this.texture);
+ this.width = image.getWidth();
+ this.height = image.getHeight();
+ }
+
+ @Override
+ public int render(GuiGraphics graphics, int x, int y, int renderWidth, float tickDelta) {
+ if (image == null) return 0;
+
+ float ratio = renderWidth / (float)this.width;
+ int targetHeight = (int) (this.height * ratio);
+
+ graphics.pose().pushPose();
+ graphics.pose().translate(x, y, 0);
+ graphics.pose().scale(ratio, ratio, 1);
+ graphics.blit(uniqueLocation, 0, 0, 0, 0, this.width, this.height, this.width, this.height);
+ graphics.pose().popPose();
+
+ return targetHeight;
+ }
+
+ @Override
+ public void close() {
+ image.close();
+ image = null;
+ texture = null;
+ textureManager.release(uniqueLocation);
+ }
+
+ public static ImageRendererFactory<DynamicTextureImage> fromPath(Path imagePath, ResourceLocation location) {
+ return (ImageRendererFactory.OnThread<DynamicTextureImage>) () -> () -> new DynamicTextureImage(NativeImage.read(new FileInputStream(imagePath.toFile())), location);
+ }
+}
diff --git a/common/src/main/java/dev/isxander/yacl3/gui/image/impl/ResourceTextureImage.java b/common/src/main/java/dev/isxander/yacl3/gui/image/impl/ResourceTextureImage.java
new file mode 100644
index 0000000..8c2b3b5
--- /dev/null
+++ b/common/src/main/java/dev/isxander/yacl3/gui/image/impl/ResourceTextureImage.java
@@ -0,0 +1,46 @@
+package dev.isxander.yacl3.gui.image.impl;
+
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+import dev.isxander.yacl3.gui.image.ImageRendererFactory;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.resources.ResourceLocation;
+
+public class ResourceTextureImage implements ImageRenderer {
+ private final ResourceLocation location;
+ private final int width, height;
+ private final int textureWidth, textureHeight;
+ private final float u, v;
+
+ public ResourceTextureImage(ResourceLocation location, float u, float v, int width, int height, int textureWidth, int textureHeight) {
+ this.location = location;
+ this.width = width;
+ this.height = height;
+ this.textureWidth = textureWidth;
+ this.textureHeight = textureHeight;
+ this.u = u;
+ this.v = v;
+ }
+
+ @Override
+ public int render(GuiGraphics graphics, int x, int y, int renderWidth, float tickDelta) {
+ float ratio = renderWidth / (float)this.width;
+ int targetHeight = (int) (this.height * ratio);
+
+ graphics.pose().pushPose();
+ graphics.pose().translate(x, y, 0);
+ graphics.pose().scale(ratio, ratio, 1);
+ graphics.blit(location, 0, 0, this.u, this.v, this.width, this.height, this.textureWidth, this.textureHeight);
+ graphics.pose().popPose();
+
+ return targetHeight;
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ public static ImageRendererFactory<ResourceTextureImage> createFactory(ResourceLocation location, float u, float v, int width, int height, int textureWidth, int textureHeight) {
+ return (ImageRendererFactory.OnThread<ResourceTextureImage>) () -> () -> new ResourceTextureImage(location, u, v, width, height, textureWidth, textureHeight);
+ }
+}