diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java new file mode 100644 index 00000000..25a33993 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java @@ -0,0 +1,43 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.particle; + +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; +import net.minecraft.world.level.material.FluidState; + +/** + * Per-tick cache of {@code ClientLevel.getFluidState(pos)} results for + * {@code BubbleColumnUpParticle.tick}. Thousands of bubble particles share a + * few columns; each one re-checks its block's fluid every tick, which is a + * chunk-section lookup per call. This cache is keyed by {@code BlockPos.asLong()} + * and cleared once per tick (game-time change), so it never serves stale data + * across ticks. + * + *

Render-thread only (vanilla particle ticking is single-threaded). The + * cache is static because {@code BubbleColumnUpParticle} instances are created + * and destroyed constantly — per-particle state would be re-initialized too + * often to be useful. + * + *

Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin} + * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. + * The cache logic is in {@link TickCache} (a generic per-tick keyed cache), + * so it's unit-testable without Minecraft types. + */ +public enum BubbleColumnCache { + ; + + private static final TickCache CACHE = new TickCache<>(); + + /** True when the cache is engaged this frame. */ + public static boolean enabled() { + return OptimizeToggles.microOpts(); + } + + /** Look up a cached fluid state for the given block-pos key + tick. */ + public static FluidState get(long posLong, long tick) { + return CACHE.get(posLong, tick); + } + + /** Store a fluid state for the given block-pos key. */ + public static void put(long posLong, FluidState fluid) { + CACHE.put(posLong, fluid); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java new file mode 100644 index 00000000..3280a355 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java @@ -0,0 +1,66 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.particle; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +/** + * Generic per-tick cache keyed by {@code long}. The cache is cleared once per + * tick change (game-time change), so it never serves stale data across ticks. + * + *

Used by {@link BubbleColumnCache} for {@code ClientLevel.getFluidState} + * results — thousands of bubble particles share a few columns; each one + * re-checks its block's fluid every tick. Generic on the value type so the + * logic is unit-testable without Minecraft types. + * + *

Render-thread only. The cache is static because particle instances are + * created and destroyed constantly — per-particle state would be re-initialized + * too often to be useful. + * + *

Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin} + * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. + */ +public final class TickCache { + + private final Long2ObjectOpenHashMap map = new Long2ObjectOpenHashMap<>(); + private long cachedTick = Long.MIN_VALUE; + + /** + * Look up a cached value for the given key, on the given tick. If the tick + * differs from the cached tick, the cache is cleared first. + * + * @param key the packed position key (e.g. {@code BlockPos.asLong()}) + * @param tick the current game tick + * @return the cached value, or {@code null} if not cached + */ + public V get(long key, long tick) { + if (tick != cachedTick) { + map.clear(); + cachedTick = tick; + } + return map.get(key); + } + + /** + * Store a value for the given key, on the current tick. The cache must + * have been primed by a {@link #get} call first — that's where the tick + * check happens; this method assumes the cache is fresh. + */ + public void put(long key, V value) { + map.put(key, value); + } + + /** Number of cached entries on the current tick. */ + public int size() { + return map.size(); + } + + /** The tick the cache currently holds data for. */ + public long cachedTick() { + return cachedTick; + } + + /** Reset the cache to empty (used by tests and on disable). */ + public void reset() { + map.clear(); + cachedTick = Long.MIN_VALUE; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java new file mode 100644 index 00000000..22c36a0d --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java @@ -0,0 +1,81 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.world; + +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; + +/** + * Per-frame state for the entity-outline skip optimization. The vanilla + * pipeline clears the entity-outline target and runs {@code doEntityOutline} + * every frame even when no glowing entity is on screen. This tracker flips + * to {@code false} when the most recent frame had at least one glowing + * entity, and the {@code LevelRendererOutlineMixin} reads it to skip the + * clear + outline render on frames with no glowing entity. + * + *

Render-thread only. The state is reset at the start of every frame + * ({@link #beginFrame()}), and {@link #markGlowing()} is called from the + * outline-target-clear {@code WrapOperation} when the renderer actually + * touches the outline target. + * + *

Ported from {@code zako.opt.mixin.entity.LevelRendererOutlineMixin} + * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. + * The state is lifted out of the mixin so the mixin stays a thin wrapper. + */ +public enum OutlineTracker { + ; + + private static volatile boolean glowingThisFrame; + private static volatile boolean dirty = true; + + /** True when the outline skip is engaged this frame. */ + public static boolean enabled() { + return OptimizeToggles.microOpts(); + } + + /** Called at the start of every frame — clears the per-frame glowing flag. */ + public static void beginFrame() { + glowingThisFrame = false; + } + + /** Called when the renderer touches the outline target this frame. */ + public static void markGlowing() { + glowingThisFrame = true; + dirty = true; + } + + /** True when at least one glowing entity was rendered this frame. */ + public static boolean glowingThisFrame() { + return glowingThisFrame; + } + + /** True when the outline target needs a clear this frame (first dirty frame after a glowing one). */ + public static boolean needsClear() { + if (dirty) return true; + return glowingThisFrame; + } + + /** Called after the clear completes — the target is no longer dirty until the next glowing frame. */ + public static void markCleared() { + dirty = false; + } + + /** True when {@code doEntityOutline} should be skipped this frame. */ + public static boolean shouldSkipOutlineRender() { + if (!enabled()) return false; + return !glowingThisFrame; + } + + /** Test-only: reset all state. */ + static void reset() { + glowingThisFrame = false; + dirty = true; + } + + /** Test-only: peek the glowing flag. */ + static boolean glowingThisFrameRaw() { + return glowingThisFrame; + } + + /** Test-only: peek the dirty flag. */ + static boolean dirtyRaw() { + return dirty; + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCacheTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCacheTest.java new file mode 100644 index 00000000..a15e98b6 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCacheTest.java @@ -0,0 +1,114 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.particle; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Verifies {@link TickCache} — the generic per-tick keyed cache used by + * {@link BubbleColumnCache} and (later) other per-tick caches like + * {@code ParticleLightCache}. Pure logic, no Minecraft dependencies. + */ +public final class TickCacheTest { + + @Test + void getReturnsNullOnEmptyCache() { + TickCache cache = new TickCache<>(); + assertNull(cache.get(123L, 100L)); + } + + @Test + void putThenGetReturnsSameValue() { + TickCache cache = new TickCache<>(); + cache.get(42L, 100L); // prime the tick + cache.put(42L, "fluid"); + assertSame("fluid", cache.get(42L, 100L)); + } + + @Test + void getClearsCacheWhenTickChanges() { + TickCache cache = new TickCache<>(); + cache.get(42L, 100L); + cache.put(42L, "fluid"); + assertSame("fluid", cache.get(42L, 100L)); + assertEquals(1, cache.size()); + // Different tick — cache cleared. + assertNull(cache.get(42L, 101L)); + assertEquals(0, cache.size()); + } + + @Test + void multipleKeysSameTick() { + TickCache cache = new TickCache<>(); + cache.get(1L, 100L); + cache.put(1L, "a"); + cache.get(2L, 100L); + cache.put(2L, "b"); + cache.get(3L, 100L); + cache.put(3L, "c"); + assertEquals(3, cache.size()); + assertSame("a", cache.get(1L, 100L)); + assertSame("b", cache.get(2L, 100L)); + assertSame("c", cache.get(3L, 100L)); + } + + @Test + void resetClearsEverything() { + TickCache cache = new TickCache<>(); + cache.get(1L, 100L); + cache.put(1L, "a"); + cache.get(2L, 100L); + cache.put(2L, "b"); + assertEquals(2, cache.size()); + cache.reset(); + assertEquals(0, cache.size()); + assertEquals(Long.MIN_VALUE, cache.cachedTick()); + } + + @Test + void overwriteSameKey() { + TickCache cache = new TickCache<>(); + cache.get(42L, 100L); + cache.put(42L, "first"); + cache.put(42L, "second"); + assertEquals(1, cache.size()); + assertSame("second", cache.get(42L, 100L)); + } + + @Test + void cachedTickTracksLastGetTick() { + TickCache cache = new TickCache<>(); + assertEquals(Long.MIN_VALUE, cache.cachedTick()); + cache.get(1L, 100L); + assertEquals(100L, cache.cachedTick()); + cache.get(2L, 200L); + assertEquals(200L, cache.cachedTick()); + } + + @Test + void nullValuesAreStored() { + TickCache cache = new TickCache<>(); + cache.get(42L, 100L); + cache.put(42L, null); + // get returns null — same as "not cached". This is a known limitation: + // callers that want to cache null should use a sentinel. + assertNull(cache.get(42L, 100L)); + // But the size is still 1, confirming the put went through. + assertEquals(1, cache.size()); + } + + @Test + void longKeyCollisionsHandled() { + TickCache cache = new TickCache<>(); + long k1 = 1L << 40; // Large keys that don't collide in a hashmap. + long k2 = 2L << 40; + cache.get(k1, 100L); + cache.put(k1, "a"); + cache.get(k2, 100L); + cache.put(k2, "b"); + assertSame("a", cache.get(k1, 100L)); + assertSame("b", cache.get(k2, 100L)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java new file mode 100644 index 00000000..5896d7da --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java @@ -0,0 +1,108 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.world; + +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link OutlineTracker} — per-frame state for the outline-skip + * optimization. Pure logic, no Minecraft dependencies. + */ +public final class OutlineTrackerTest { + + @AfterEach + void reset() { + OptimizeToggles.reset(); + OutlineTracker.reset(); + } + + @Test + void disabledWhenMicroOptsOff() { + OptimizeToggles.apply( + false, 0, + false, false, false, + false, 0, + false, false, false, false); + assertFalse(OutlineTracker.enabled()); + // Even when glowing was set this frame, shouldSkipOutlineRender returns false + // when the toggle is off — the vanilla path runs unchanged. + OutlineTracker.markGlowing(); + assertFalse(OutlineTracker.shouldSkipOutlineRender()); + } + + @Test + void skipWhenEnabledAndNoGlowing() { + OptimizeToggles.apply( + false, 0, + false, false, false, + false, 0, + false, false, false, true); + OutlineTracker.beginFrame(); + assertTrue(OutlineTracker.shouldSkipOutlineRender(), + "Without glowing entities, doEntityOutline should be skipped"); + } + + @Test + void doNotSkipWhenGlowingThisFrame() { + OptimizeToggles.apply( + false, 0, + false, false, false, + false, 0, + false, false, false, true); + OutlineTracker.beginFrame(); + OutlineTracker.markGlowing(); + assertFalse(OutlineTracker.shouldSkipOutlineRender(), + "Glowing entity this frame — should not skip doEntityOutline"); + assertTrue(OutlineTracker.glowingThisFrame()); + } + + @Test + void beginFrameClearsGlowingFlag() { + OptimizeToggles.apply( + false, 0, + false, false, false, + false, 0, + false, false, false, true); + OutlineTracker.markGlowing(); + assertTrue(OutlineTracker.glowingThisFrame()); + OutlineTracker.beginFrame(); + assertFalse(OutlineTracker.glowingThisFrame()); + } + + @Test + void dirtyStartsTrue() { + OptimizeToggles.reset(); + OutlineTracker.reset(); + assertTrue(OutlineTracker.dirtyRaw()); + assertTrue(OutlineTracker.needsClear()); + } + + @Test + void markClearedClearsDirty() { + OutlineTracker.markCleared(); + assertFalse(OutlineTracker.dirtyRaw()); + } + + @Test + void resetRestoresDefaults() { + OutlineTracker.markGlowing(); + OutlineTracker.markCleared(); + OutlineTracker.reset(); + assertFalse(OutlineTracker.glowingThisFrameRaw()); + assertTrue(OutlineTracker.dirtyRaw()); + } + + @Test + void needsClearFollowsDirtyAndGlowing() { + // dirty=true (default) → needsClear true + OutlineTracker.markCleared(); + // dirty=false, glowing=false → needsClear false (we'd need a glow to trigger a clear) + assertFalse(OutlineTracker.needsClear()); + OutlineTracker.markGlowing(); + // dirty=true again, glowing=true → needsClear true + assertTrue(OutlineTracker.needsClear()); + } +}