diff options
author | isXander <xander@isxander.dev> | 2023-08-17 14:19:32 +0100 |
---|---|---|
committer | isXander <xander@isxander.dev> | 2023-08-17 14:19:32 +0100 |
commit | 39bc5b5d8b8e6d4369ea71a7787907521e11ad34 (patch) | |
tree | d56be6ac071094646829061e2d835e6e952df47c /common | |
parent | 9ffd159faa215256161c3af9c57bd0b742b6d818 (diff) | |
download | YetAnotherConfigLib-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')
15 files changed, 544 insertions, 435 deletions
diff --git a/common/src/main/java/dev/isxander/yacl3/api/OptionDescription.java b/common/src/main/java/dev/isxander/yacl3/api/OptionDescription.java index 40f1d68..7336379 100644 --- a/common/src/main/java/dev/isxander/yacl3/api/OptionDescription.java +++ b/common/src/main/java/dev/isxander/yacl3/api/OptionDescription.java @@ -1,6 +1,6 @@ package dev.isxander.yacl3.api; -import dev.isxander.yacl3.gui.ImageRenderer; +import dev.isxander.yacl3.gui.image.ImageRenderer; import dev.isxander.yacl3.impl.OptionDescriptionImpl; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; diff --git a/common/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java b/common/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java index 0d7b289..acbf338 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java +++ b/common/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java @@ -22,6 +22,20 @@ import java.util.function.UnaryOperator; * * @param <T> config data type * @deprecated upgrade to config v2 {@link dev.isxander.yacl3.config.v2.api.ConfigClassHandler} with {@link dev.isxander.yacl3.config.v2.api.serializer.GsonConfigSerializerBuilder} + * <pre> + * {@code + * public class MyConfig { + * public static ConfigClassHandler<MyConfig> HANDLER = ConfigClassHandler.createBuilder(MyConfig.class) + * .id(new ResourceLocation("modid", "config")) + * .serializer(config -> GsonConfigSerializerBuilder.create(config) + * .setPath(FabricLoader.getInstance().getConfigDir().resolve("my_mod.json") + * .build()) + * .build(); + * + * @SerialEntry public boolean myBoolean = true; + * } + * } + * </pre> */ @Deprecated public class GsonConfigInstance<T> extends ConfigInstance<T> { diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OverrideImage.java b/common/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OverrideImage.java index 649121a..5a33884 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OverrideImage.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OverrideImage.java @@ -2,7 +2,7 @@ package dev.isxander.yacl3.config.v2.api.autogen; import dev.isxander.yacl3.config.v2.api.ConfigField; import dev.isxander.yacl3.config.v2.impl.autogen.EmptyCustomImageFactory; -import dev.isxander.yacl3.gui.ImageRenderer; +import dev.isxander.yacl3.gui.image.ImageRenderer; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EmptyCustomImageFactory.java b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EmptyCustomImageFactory.java index f6949e7..421de82 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EmptyCustomImageFactory.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EmptyCustomImageFactory.java @@ -3,7 +3,7 @@ package dev.isxander.yacl3.config.v2.impl.autogen; import dev.isxander.yacl3.config.v2.api.ConfigField; import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess; import dev.isxander.yacl3.config.v2.api.autogen.OverrideImage; -import dev.isxander.yacl3.gui.ImageRenderer; +import dev.isxander.yacl3.gui.image.ImageRenderer; import java.util.Optional; import java.util.concurrent.CompletableFuture; diff --git a/common/src/main/java/dev/isxander/yacl3/gui/ImageRenderer.java b/common/src/main/java/dev/isxander/yacl3/gui/ImageRenderer.java deleted file mode 100644 index 9617c58..0000000 --- a/common/src/main/java/dev/isxander/yacl3/gui/ImageRenderer.java +++ /dev/null @@ -1,388 +0,0 @@ -package dev.isxander.yacl3.gui; - -import com.mojang.blaze3d.Blaze3D; -import com.mojang.blaze3d.platform.NativeImage; -import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi; -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.client.renderer.texture.DynamicTexture; -import net.minecraft.client.renderer.texture.TextureManager; -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.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; -import java.util.stream.IntStream; - -public interface ImageRenderer { - int render(GuiGraphics graphics, int x, int y, int renderWidth); - - void close(); - - default void tick() {} - - Map<ResourceLocation, CompletableFuture<Optional<ImageRenderer>>> CACHE = new ConcurrentHashMap<>(); - - static CompletableFuture<Optional<ImageRenderer>> getOrMakeAsync(ResourceLocation id, Supplier<Optional<ImageRenderer>> factory) { - return CACHE.computeIfAbsent(id, key -> CompletableFuture.supplyAsync(factory, YACLConstants.SINGLE_THREAD_EXECUTOR)); - } - - static CompletableFuture<Optional<ImageRenderer>> getOrMakeSync(ResourceLocation id, Supplier<Optional<ImageRenderer>> factory) { - return CACHE.computeIfAbsent(id, key -> CompletableFuture.completedFuture(factory.get())); - } - - static void closeAll() { - CACHE.values().forEach(future -> future.thenAccept(opt -> opt.ifPresent(ImageRenderer::close))); - CACHE.clear(); - } - - class TextureBacked implements ImageRenderer { - private final ResourceLocation location; - private final int width, height; - private final int textureWidth, textureHeight; - private final float u, v; - - public TextureBacked(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 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() { - - } - } - - class NativeImageBacked 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 NativeImageBacked(NativeImage image, ResourceLocation uniqueLocation) { - this.image = image; - this.texture = new DynamicTexture(image); - this.uniqueLocation = uniqueLocation; - textureManager.register(this.uniqueLocation, this.texture); - this.width = image.getWidth(); - this.height = image.getHeight(); - } - - private NativeImageBacked(Path imagePath, ResourceLocation uniqueLocation) throws IOException { - this.uniqueLocation = uniqueLocation; - this.image = NativeImage.read(new FileInputStream(imagePath.toFile())); - this.width = image.getWidth(); - this.height = image.getHeight(); - this.texture = new DynamicTexture(image); - textureManager.register(this.uniqueLocation, this.texture); - } - - public static Optional<ImageRenderer> createFromPath(Path path, ResourceLocation uniqueLocation) { - try { - return Optional.of(new NativeImageBacked(path, uniqueLocation)); - } catch (IOException e) { - e.printStackTrace(); - return Optional.empty(); - } - } - - @Override - public int render(GuiGraphics graphics, int x, int y, int renderWidth) { - 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); - } - } - - class AnimatedNativeImageBacked extends NativeImageBacked { - 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 AnimatedNativeImageBacked(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; - } - - public static AnimatedNativeImageBacked createGIFFromTexture(ResourceLocation textureLocation) throws IOException { - ResourceManager resourceManager = Minecraft.getInstance().getResourceManager(); - Resource resource = resourceManager.getResource(textureLocation).orElseThrow(); - - return createGIF(resource.open(), textureLocation); - } - - public static AnimatedNativeImageBacked createWEBPFromTexture(ResourceLocation textureLocation) throws IOException { - ResourceManager resourceManager = Minecraft.getInstance().getResourceManager(); - Resource resource = resourceManager.getResource(textureLocation).orElseThrow(); - - return createWEBP(resource.open(), textureLocation); - } - - public static AnimatedNativeImageBacked createGIF(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); - } - } - - public static AnimatedNativeImageBacked createWEBP(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); - 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 AnimatedNativeImageBacked createFromImageReader(ImageReader reader, AnimFrameProvider animationProvider, ResourceLocation uniqueLocation) throws Exception { - 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 new AnimatedNativeImageBacked(image, frameWidth, frameHeight, frameCount, frameDelays, cols, rows, uniqueLocation); - } - - @Override - public int render(GuiGraphics graphics, int x, int y, int renderWidth) { - 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; - } - - @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/OptionDescriptionWidget.java b/common/src/main/java/dev/isxander/yacl3/gui/OptionDescriptionWidget.java index d0db6e4..0732c5f 100644 --- a/common/src/main/java/dev/isxander/yacl3/gui/OptionDescriptionWidget.java +++ b/common/src/main/java/dev/isxander/yacl3/gui/OptionDescriptionWidget.java @@ -2,6 +2,7 @@ package dev.isxander.yacl3.gui; import com.mojang.blaze3d.Blaze3D; import com.mojang.blaze3d.platform.InputConstants; +import dev.isxander.yacl3.gui.image.ImageRenderer; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.ComponentPath; import net.minecraft.client.gui.Font; @@ -76,7 +77,7 @@ public class OptionDescriptionWidget extends AbstractWidget { if (description.description().image().isDone()) { var image = description.description().image().join(); if (image.isPresent()) { - y += image.get().render(graphics, getX(), y, getWidth()) + 5; + y += image.get().render(graphics, getX(), y, getWidth(), delta) + 5; } } 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); + } +} diff --git a/common/src/main/java/dev/isxander/yacl3/impl/OptionDescriptionImpl.java b/common/src/main/java/dev/isxander/yacl3/impl/OptionDescriptionImpl.java index 9ea9456..3a3008c 100644 --- a/common/src/main/java/dev/isxander/yacl3/impl/OptionDescriptionImpl.java +++ b/common/src/main/java/dev/isxander/yacl3/impl/OptionDescriptionImpl.java @@ -1,7 +1,11 @@ package dev.isxander.yacl3.impl; import dev.isxander.yacl3.api.OptionDescription; -import dev.isxander.yacl3.gui.ImageRenderer; +import dev.isxander.yacl3.gui.image.ImageRenderer; +import dev.isxander.yacl3.gui.image.ImageRendererManager; +import dev.isxander.yacl3.gui.image.impl.AnimatedDynamicTextureImage; +import dev.isxander.yacl3.gui.image.impl.DynamicTextureImage; +import dev.isxander.yacl3.gui.image.impl.ResourceTextureImage; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; import net.minecraft.resources.ResourceLocation; @@ -37,7 +41,7 @@ public record OptionDescriptionImpl(Component text, CompletableFuture<Optional<I Validate.isTrue(width > 0, "Width must be greater than 0!"); Validate.isTrue(height > 0, "Height must be greater than 0!"); - this.image = ImageRenderer.getOrMakeSync(image, () -> Optional.of(new ImageRenderer.TextureBacked(image, 0, 0, width, height, width, height))); + this.image = ImageRendererManager.registerImage(image, ResourceTextureImage.createFactory(image, 0, 0, width, height, width, height)).thenApply(Optional::of); imageUnset = false; return this; } @@ -48,7 +52,7 @@ public record OptionDescriptionImpl(Component text, CompletableFuture<Optional<I Validate.isTrue(width > 0, "Width must be greater than 0!"); Validate.isTrue(height > 0, "Height must be greater than 0!"); - this.image = ImageRenderer.getOrMakeSync(image, () -> Optional.of(new ImageRenderer.TextureBacked(image, u, v, width, height, textureWidth, textureHeight))); + this.image = ImageRendererManager.registerImage(image, ResourceTextureImage.createFactory(image, u, v, width, height, textureWidth, textureHeight)).thenApply(Optional::of); imageUnset = false; return this; } @@ -56,7 +60,8 @@ public record OptionDescriptionImpl(Component text, CompletableFuture<Optional<I @Override public Builder image(Path path, ResourceLocation uniqueLocation) { Validate.isTrue(imageUnset, "Image already set!"); - this.image = ImageRenderer.getOrMakeAsync(uniqueLocation, () -> ImageRenderer.NativeImageBacked.createFromPath(path, uniqueLocation)); + + this.image = ImageRendererManager.registerImage(uniqueLocation, DynamicTextureImage.fromPath(path, uniqueLocation)).thenApply(Optional::of); imageUnset = false; return this; } @@ -64,14 +69,8 @@ public record OptionDescriptionImpl(Component text, CompletableFuture<Optional<I @Override public Builder gifImage(ResourceLocation image) { Validate.isTrue(imageUnset, "Image already set!"); - this.image = ImageRenderer.getOrMakeAsync(image, () -> { - try { - return Optional.of(ImageRenderer.AnimatedNativeImageBacked.createGIFFromTexture(image)); - } catch (IOException e) { - e.printStackTrace(); - return Optional.empty(); - } - }); + + this.image = ImageRendererManager.registerImage(image, AnimatedDynamicTextureImage.createGIFFromTexture(image)).thenApply(Optional::of); imageUnset = false; return this; } @@ -79,14 +78,8 @@ public record OptionDescriptionImpl(Component text, CompletableFuture<Optional<I @Override public Builder gifImage(Path path, ResourceLocation uniqueLocation) { Validate.isTrue(imageUnset, "Image already set!"); - this.image = ImageRenderer.getOrMakeAsync(uniqueLocation, () -> { - try { - return Optional.of(ImageRenderer.AnimatedNativeImageBacked.createGIF(new FileInputStream(path.toFile()), uniqueLocation)); - } catch (IOException e) { - e.printStackTrace(); - return Optional.empty(); - } - }); + + this.image = ImageRendererManager.registerImage(uniqueLocation, AnimatedDynamicTextureImage.createGIFFromPath(path, uniqueLocation)).thenApply(Optional::of); imageUnset = false; return this; } @@ -94,14 +87,8 @@ public record OptionDescriptionImpl(Component text, CompletableFuture<Optional<I @Override public Builder webpImage(ResourceLocation image) { Validate.isTrue(imageUnset, "Image already set!"); - this.image = ImageRenderer.getOrMakeAsync(image, () -> { - try { - return Optional.of(ImageRenderer.AnimatedNativeImageBacked.createWEBPFromTexture(image)); - } catch (IOException e) { - e.printStackTrace(); - return Optional.empty(); - } - }); + + this.image = ImageRendererManager.registerImage(image, AnimatedDynamicTextureImage.createWEBPFromTexture(image)).thenApply(Optional::of); imageUnset = false; return this; } @@ -109,14 +96,8 @@ public record OptionDescriptionImpl(Component text, CompletableFuture<Optional<I @Override public Builder webpImage(Path path, ResourceLocation uniqueLocation) { Validate.isTrue(imageUnset, "Image already set!"); - this.image = ImageRenderer.getOrMakeAsync(uniqueLocation, () -> { - try { - return Optional.of(ImageRenderer.AnimatedNativeImageBacked.createWEBP(new FileInputStream(path.toFile()), uniqueLocation)); - } catch (IOException e) { - e.printStackTrace(); - return Optional.empty(); - } - }); + + this.image = ImageRendererManager.registerImage(uniqueLocation, AnimatedDynamicTextureImage.createWEBPFromPath(path, uniqueLocation)).thenApply(Optional::of); imageUnset = false; return this; } diff --git a/common/src/main/java/dev/isxander/yacl3/impl/utils/YACLConstants.java b/common/src/main/java/dev/isxander/yacl3/impl/utils/YACLConstants.java index 9570b02..5ff1b79 100644 --- a/common/src/main/java/dev/isxander/yacl3/impl/utils/YACLConstants.java +++ b/common/src/main/java/dev/isxander/yacl3/impl/utils/YACLConstants.java @@ -3,11 +3,6 @@ package dev.isxander.yacl3.impl.utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - public class YACLConstants { public static final Logger LOGGER = LoggerFactory.getLogger("YetAnotherConfigLib"); - - public static final ExecutorService SINGLE_THREAD_EXECUTOR = Executors.newSingleThreadExecutor(); } diff --git a/common/src/main/java/dev/isxander/yacl3/mixin/MinecraftMixin.java b/common/src/main/java/dev/isxander/yacl3/mixin/MinecraftMixin.java index 0681213..0b228a1 100644 --- a/common/src/main/java/dev/isxander/yacl3/mixin/MinecraftMixin.java +++ b/common/src/main/java/dev/isxander/yacl3/mixin/MinecraftMixin.java @@ -1,6 +1,6 @@ package dev.isxander.yacl3.mixin; -import dev.isxander.yacl3.gui.ImageRenderer; +import dev.isxander.yacl3.gui.image.ImageRendererManager; import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -11,6 +11,11 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; public class MinecraftMixin { @Inject(method = "destroy", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;close()V", shift = At.Shift.BEFORE)) private void closeImages(CallbackInfo ci) { - ImageRenderer.closeAll(); + ImageRendererManager.closeAll(); + } + + @Inject(method = "runTick", at = @At(value = "HEAD")) + private void finaliseImages(boolean tick, CallbackInfo ci) { + ImageRendererManager.pollImageFactories(); } } |