Skip to content

Commit

Permalink
Fix various issues with the atlas texture cache (#7)
Browse files Browse the repository at this point in the history
* refactor: Invalidate the atlas texture cache when reloading resources

* fix: Fix cache recreation when reloading resources

* fix: Fix mipmapping issues and crashes

Also moved some Mixin classes into packages and removed unused code.

* fix: Fix cache directory not being deleted recursively when it's out of date

* perf: Write cache data to the cache file asynchronously

* fix: Fix native crash when writing files

Also improved logging and rolled back the `NativeImageAdapter`, because saving the images in JSON is faster than creating hundreds of PNG files.

* fix: Fix crash when a JSON element is an instance of `JsonNull` or when an exception occurs

* feat: Use enabled resource packs for the atlas texture cache hash

* refactor: Change hash name prefix filter to a substring filter

* fix: Add missing Mod Menu `modCompilyOnly`

* Revert "fix: Add missing Mod Menu `modCompilyOnly`"

This reverts commit b748bb7.

* fix: Fix crash when getting enabled resource pack names

* fix: Fix `ConcurrentModificationException` crash when getting enabled resource pack names
  • Loading branch information
Steveplays28 authored Sep 8, 2024
1 parent 7460f26 commit f54ca1a
Show file tree
Hide file tree
Showing 14 changed files with 212 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import io.github.steveplays28.blinkload.util.resource.json.StitchResult;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand All @@ -29,15 +30,18 @@ public class BlinkLoadCache {
private static final Logger LOGGER = LoggerFactory.getLogger(String.format("%s/cache", MOD_ID));
private static final @NotNull File CACHED_DATA_FILE = new File(
String.format("%s/atlas_textures_cache.json", CacheUtil.getCachePath()));
private static final @NotNull String MOD_LIST_HASH = HashUtil.calculateHash(HashUtil.getModList());
private static final @NotNull String MOD_LIST_HASH = HashUtil.calculateHash(HashUtil.getModAndEnabledResourcePackListCommaSeparated());

private static @Nullable CompletableFuture<Map<AtlasTextureIdentifier, StitchResult>> cachedDataCompletableFuture = null;
private static @Nullable Map<AtlasTextureIdentifier, StitchResult> cachedData = null;
private static boolean hasClientStarted = false;
private static @Nullable Boolean isUpToDate = null;

public static void initialize() {
ClientLifecycleEvent.CLIENT_MAIN_STARTING.register(BlinkLoadCache::loadCachedDataAsync);
ClientLifecycleEvent.CLIENT_RESOURCE_RELOAD_FINISHED.register(BlinkLoadCache::writeCacheDataToFile);
dev.architectury.event.events.client.ClientLifecycleEvent.CLIENT_STARTED.register(instance -> hasClientStarted = true);
ClientLifecycleEvent.CLIENT_RESOURCE_RELOAD_STARTING.register(BlinkLoadCache::invalidateCache);
ClientLifecycleEvent.CLIENT_RESOURCE_RELOAD_FINISHED.register(BlinkLoadCache::writeCacheDataToFileAsync);
}

public static boolean isUpToDate() {
Expand All @@ -48,6 +52,14 @@ public static boolean isUpToDate() {
return isUpToDate;
}

public static void invalidateCache() {
if (!hasClientStarted) {
return;
}

isUpToDate = false;
}

public static @NotNull Map<AtlasTextureIdentifier, StitchResult> getCachedData() {
if (cachedData == null) {
loadCachedDataAsync().join();
Expand All @@ -63,7 +75,7 @@ public static void cacheData(@NotNull StitchResult stitchResult) {
private static @NotNull CompletableFuture<Map<AtlasTextureIdentifier, StitchResult>> loadCachedDataAsync() {
if (cachedDataCompletableFuture == null) {
cachedDataCompletableFuture = CompletableFuture.supplyAsync(
BlinkLoadCache::loadCachedData, ThreadUtil.getAtlasTextureLoaderThreadPoolExecutor()
BlinkLoadCache::loadCachedData, ThreadUtil.getAtlasTextureIOThreadPoolExecutor()
).whenCompleteAsync(
(cachedData, throwable) -> {
if (throwable != null) {
Expand All @@ -88,7 +100,6 @@ public static void cacheData(@NotNull StitchResult stitchResult) {
}

var startTime = System.nanoTime();

@NotNull Map<AtlasTextureIdentifier, StitchResult> cachedData = new ConcurrentHashMap<>();
// Read JSON from the cached data file
try (@NotNull Reader reader = new FileReader(CACHED_DATA_FILE)) {
Expand All @@ -110,22 +121,37 @@ public static void cacheData(@NotNull StitchResult stitchResult) {
return cachedData;
}

private static void writeCacheDataToFileAsync() {
CompletableFuture.runAsync(BlinkLoadCache::writeCacheDataToFile, ThreadUtil.getAtlasTextureIOThreadPoolExecutor());
}

@SuppressWarnings("ResultOfMethodCallIgnored")
private static void writeCacheDataToFile() {
if (isUpToDate()) {
return;
}

var startTime = System.nanoTime();
try {
LOGGER.info("Atlas creation finished, writing cache data to file ({}).", CACHED_DATA_FILE);
CACHED_DATA_FILE.getParentFile().mkdirs();
@NotNull var cacheDirectory = CACHED_DATA_FILE.getParentFile();
FileUtils.deleteDirectory(cacheDirectory);
cacheDirectory.mkdirs();
HashUtil.saveHash(MOD_LIST_HASH);
} catch (IOException e) {
LOGGER.error("Exception thrown while re-creating directories and/or saving hash data: ", e);
}

@NotNull var file = new FileWriter(CACHED_DATA_FILE);
file.write(JsonUtil.getGson().toJson(getCachedData().values()));
file.close();
try (@NotNull var fileWriter = new FileWriter(CACHED_DATA_FILE)) {
fileWriter.write(JsonUtil.getGson().toJson(getCachedData().values()));
} catch (IOException e) {
LOGGER.error("Exception thrown while writing cache data to file ({}): {}", e, CACHED_DATA_FILE);
LOGGER.error("Exception thrown while writing cache data to file ({}): {}", CACHED_DATA_FILE, e);
}

isUpToDate = true;
LOGGER.info(
"Saved atlas textures to cache ({}; took {}ms).",
CACHED_DATA_FILE,
TimeUnit.MILLISECONDS.convert(System.nanoTime() - startTime, TimeUnit.NANOSECONDS)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public interface ClientLifecycleEvent {
* @see ClientMainStarting
*/
Event<ClientMainStarting> CLIENT_MAIN_STARTING = EventFactory.createLoop();
/**
* @see ClientResourceReloadStarting
*/
Event<ClientResourceReloadStarting> CLIENT_RESOURCE_RELOAD_STARTING = EventFactory.createLoop();
/**
* @see ClientResourceReloadFinished
*/
Expand All @@ -31,4 +35,12 @@ interface ClientResourceReloadFinished {
*/
void onClientResourceReloadFinished();
}

@FunctionalInterface
interface ClientResourceReloadStarting {
/**
* Invoked when the client is starting resource reloading.
*/
void onClientResourceReloadStarting();
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@
public interface SpriteContentsAccessor {
@Accessor
NativeImage getImage();

@Accessor
NativeImage[] getMipmapLevelsImages();

@Accessor
void setMipmapLevelsImages(NativeImage[] mipmapLevelsImages);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.github.steveplays28.blinkload.mixin.client.resource;

import io.github.steveplays28.blinkload.client.event.ClientLifecycleEvent;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.minecraft.resource.ReloadableResourceManagerImpl;
import net.minecraft.resource.ResourcePack;
import net.minecraft.resource.ResourceReload;
import net.minecraft.util.Unit;
import org.jetbrains.annotations.NotNull;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;

@Environment(EnvType.CLIENT)
@Mixin(ReloadableResourceManagerImpl.class)
public class ReloadableResourceManagerImplMixin {
@Inject(method = "reload", at = @At(value = "HEAD"))
private void blinkload$invokeBeforeResourceReloadEvent(Executor prepareExecutor, Executor applyExecutor, CompletableFuture<Unit> initialStage, List<ResourcePack> packs, CallbackInfoReturnable<ResourceReload> cir) {
ClientLifecycleEvent.CLIENT_RESOURCE_RELOAD_STARTING.invoker().onClientResourceReloadStarting();
}

@Inject(method = "reload", at = @At(value = "RETURN"))
private void blinkload$invokeAfterResourceReloadEvent(Executor prepareExecutor, Executor applyExecutor, CompletableFuture<Unit> initialStage, List<ResourcePack> packs, @NotNull CallbackInfoReturnable<ResourceReload> cir) {
cir.getReturnValue().whenComplete().whenComplete(
(unused, throwable) -> ClientLifecycleEvent.CLIENT_RESOURCE_RELOAD_FINISHED.invoker().onClientResourceReloadFinished());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.github.steveplays28.blinkload.mixin.client.texture;

import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.minecraft.client.texture.NativeImage;
import org.jetbrains.annotations.NotNull;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;

import java.nio.channels.WritableByteChannel;

@Environment(EnvType.CLIENT)
@Mixin(NativeImage.class)
public abstract class NativeImageMixin {
@Shadow
private long pointer;

@Inject(method = "write", at = @At(value = "HEAD"), cancellable = true)
private void blinkload$checkIfPointerIsAllocated(WritableByteChannel channel, @NotNull CallbackInfoReturnable<Boolean> cir) {
if (this.pointer == 0L) {
cir.setReturnValue(true);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.github.steveplays28.blinkload.mixin.client;
package io.github.steveplays28.blinkload.mixin.client.texture;

import io.github.steveplays28.blinkload.client.cache.BlinkLoadCache;
import io.github.steveplays28.blinkload.mixin.client.accessor.SpriteContentsAccessor;
Expand Down Expand Up @@ -58,20 +58,21 @@ public class SpriteLoaderMixin {
}

@Nullable var spriteId = sprite.getIdentifier();
@Nullable var spriteNativeImage = sprite.getNativeImage();
if (spriteId == null || spriteNativeImage == null) {
@Nullable var spriteMipmapLevel0Image = sprite.getMipmapLevel0Image();
@Nullable var spriteMipmapLevelsImages = sprite.getMipmapLevelsImages();
if (spriteId == null || spriteMipmapLevel0Image == null || spriteMipmapLevelsImages == null) {
continue;
}

atlasTextureRegions.put(
spriteId, new Sprite(
atlasTextureRegionId, new SpriteContents(spriteId,
new SpriteDimensions(atlasTextureRegion.getWidth(), atlasTextureRegion.getHeight()), spriteNativeImage,
AnimationResourceMetadata.EMPTY
), stitchResult.getWidth(), stitchResult.getHeight(), atlasTextureRegion.getX(),
atlasTextureRegion.getY()
)
@NotNull var spriteContents = new SpriteContents(spriteId,
new SpriteDimensions(atlasTextureRegion.getWidth(), atlasTextureRegion.getHeight()), spriteMipmapLevel0Image,
AnimationResourceMetadata.EMPTY
);
((SpriteContentsAccessor) spriteContents).setMipmapLevelsImages(spriteMipmapLevelsImages);
atlasTextureRegions.put(spriteId, new Sprite(
atlasTextureRegionId, spriteContents, stitchResult.getWidth(), stitchResult.getHeight(), atlasTextureRegion.getX(),
atlasTextureRegion.getY()
));
}

return new SpriteLoader.StitchResult(stitchResult.getWidth(), stitchResult.getHeight(), stitchResult.getMipLevel(),
Expand All @@ -86,7 +87,7 @@ atlasTextureRegionId, new SpriteContents(spriteId,
*/
@Inject(method = "load(Lnet/minecraft/resource/ResourceManager;Lnet/minecraft/util/Identifier;ILjava/util/concurrent/Executor;)Ljava/util/concurrent/CompletableFuture;", at = @At(value = "RETURN"))
private void blinkload$saveAtlasTextures(@NotNull ResourceManager resourceManager, @NotNull Identifier atlasTextureId, int mipLevel, @NotNull Executor executor, @NotNull CallbackInfoReturnable<CompletableFuture<SpriteLoader.StitchResult>> cir) {
cir.getReturnValue().thenAccept(stitchResult -> {
cir.getReturnValue().thenAccept(incompleteStitchResult -> incompleteStitchResult.whenComplete().thenAccept(stitchResult -> {
if (BlinkLoadCache.isUpToDate()) {
return;
}
Expand All @@ -102,21 +103,23 @@ atlasTextureRegionId, new SpriteContents(spriteId,
var atlasIdentifier = stitchResultAtlasTextureRegion.getValue().getAtlasId();
// Actual sprite identifier
var spriteIdentifier = stitchResultAtlasTextureRegion.getKey();
var spriteContents = ((SpriteContentsAccessor) stitchResultAtlasTextureRegion.getValue().getContents());
var x = stitchResultAtlasTextureRegion.getValue().getX();
var y = stitchResultAtlasTextureRegion.getValue().getY();
var width = stitchResultAtlasTextureRegion.getValue().getContents().getWidth();
var height = stitchResultAtlasTextureRegion.getValue().getContents().getHeight();
atlasTextureRegions.add(new StitchResult.AtlasTextureRegion(atlasIdentifier,
new StitchResult.AtlasTextureRegion.Sprite(
spriteIdentifier,
((SpriteContentsAccessor) stitchResultAtlasTextureRegion.getValue().getContents()).getImage()
spriteIdentifier, spriteContents.getImage(),
spriteContents.getMipmapLevelsImages()
), width, height, x, y
));
}

BlinkLoadCache.cacheData(new StitchResult(atlasTextureId, stitchResult.width(), stitchResult.height(), stitchResult.mipLevel(),
atlasTextureRegions.toArray(StitchResult.AtlasTextureRegion[]::new)
));
});
BlinkLoadCache.cacheData(
new StitchResult(atlasTextureId, stitchResult.width(), stitchResult.height(), stitchResult.mipLevel(),
atlasTextureRegions.toArray(StitchResult.AtlasTextureRegion[]::new)
));
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,42 @@

import com.google.common.hash.Hashing;
import io.github.steveplays28.blinkload.BlinkLoad;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class HashUtil {
private static final @NotNull File CACHED_HASH_FILE = new File(String.format("%s/mod_list_hash", CacheUtil.getCachePath()));
private static final @NotNull File CACHED_HASH_FILE = new File(
String.format("%s/mod_and_enabled_resource_pack_list_hash", CacheUtil.getCachePath()));
// TODO: Move into a config file
private static final @NotNull String[] filterArray = {"generated_"};
private static final @NotNull String[] nameFilterSubstrings = {"generated"};

public static @NotNull String getModList() {
@NotNull List<String> modListNames = ModUtil.getModListNames();
// Alphabetically sort the mod list
modListNames.sort(String::compareToIgnoreCase);
public static @NotNull String getModAndEnabledResourcePackListCommaSeparated() {
@NotNull var modAndResourcePackNames = ModUtil.getModListNames();
modAndResourcePackNames.addAll(ModUtil.getEnabledResourcePackNames());
// Alphabetically sort the mod/resource pack list
modAndResourcePackNames.sort(String::compareToIgnoreCase);

@NotNull StringBuilder modNames = new StringBuilder();
for (@NotNull String modName : modListNames) {
if (Arrays.stream(filterArray).anyMatch(modName::startsWith)) {
BlinkLoad.LOGGER.info("Mod: {} Contains a filtered prefix.", modName);
@NotNull List<String> filteredNames = new ArrayList<>();
for (@NotNull String name : modAndResourcePackNames) {
if (Arrays.stream(nameFilterSubstrings).noneMatch(name::contains)) {
continue;
}

if (!modNames.isEmpty()) {
modNames.append(", ");
}
modNames.append(modName);
filteredNames.add(name);
}
modAndResourcePackNames.removeAll(filteredNames);

return modNames.toString();
BlinkLoad.LOGGER.info("Mods/resource packs containing a filtered substring: {}", StringUtils.join(filteredNames, ", "));
return StringUtils.join(modAndResourcePackNames, ", ");
}

/**
Expand Down
Loading

0 comments on commit f54ca1a

Please sign in to comment.