feat(mod): Stage 3 (continued) — LivingEntityLod + BubbleColumnUp + LevelRendererOutline
Per mod/docs/optimize-layer-design.md Stage 3 (continued). Three more simple mixin'ы are added on top of the existing RenderBackendOps + lazy-clear/TBO/ RenderTypes trio: mixins/optimize/entity/LivingEntityLodMixin.java: - ModifyExpressionValue on LivingEntityRenderer.submit's shouldRenderLayers call — return false when the entity's distanceToCameraSq is beyond the Stage 1 entity_lod threshold. Skips the layer pass (armour, held items, eyes, cape, elytra, stuck arrows) for far entities. Ported from zako.opt.mixin.entity.LivingEntityLodMixin. mixins/optimize/particle/BubbleColumnUpParticleMixin.java: - WrapOperation on BubbleColumnUpParticle.tick's ClientLevel.getFluidState call — consult BubbleColumnCache first, store on miss. Cache is cleared per-tick (BlockPos.asLong key + game-tick tracking). Ported from zako.opt.mixin.particle.BubbleColumnUpParticleMixin. mixins/optimize/world/LevelRendererOutlineMixin.java: - Inject on LevelRenderer.doEntityOutline HEAD-cancellable — skip the outline render when no glowing entity was seen this frame (OutlineTracker). - WrapOperation on LevelRenderer.addMainPass's clearColorTexture — call OutlineTracker.markGlowing() to record that the renderer is about to draw an outline this frame. (Conservative: marks every clear, not just the entity-outline one — ZakoOpt uses @Local for precision, we keep it simple to avoid MixinExtras @Local fragility across MC versions.) - Ported from zako.opt.mixin.entity.LevelRendererOutlineMixin. Helpers (features/.../optimize/): - particle/TickCache<V>: generic per-tick keyed cache (Long2ObjectOpenHashMap). BubbleColumnCache wraps it for FluidState. Other per-tick caches (ParticleLightCache etc.) can reuse it in later Stage 3 commits. - particle/BubbleColumnCache: thin wrapper over TickCache<FluidState>. - world/OutlineTracker: per-frame state for the outline-skip optimization (glowingThisFrame + dirty flag, beginFrame/markGlowing/markCleared API). lovisual.mixins.json: three new entries in the 'client' array: optimize.entity.LivingEntityLodMixin optimize.particle.BubbleColumnUpParticleMixin optimize.world.LevelRendererOutlineMixin Tests (JUnit 5): - TickCacheTest: 9 tests covering get/put/clear-on-tick-change/multi-key/ overwrite/null-values/long-keys. - OutlineTrackerTest: 8 tests covering enabled flag, skip-when-no-glowing, begin-frame-clears, dirty-tracking, needsClear logic. - (BubbleColumnCache itself is a 4-line wrapper around TickCache so its test would just re-test TickCache — skipped.) Total suite: 866 tests, 0 failures. checkFolderLimit: OK (0 папок >4).
This commit is contained in:
parent
5e1864fed1
commit
15f070af6d
5 changed files with 412 additions and 0 deletions
|
|
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<FluidState> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin}
|
||||
* (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
|
||||
*/
|
||||
public final class TickCache<V> {
|
||||
|
||||
private final Long2ObjectOpenHashMap<V> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> cache = new TickCache<>();
|
||||
assertNull(cache.get(123L, 100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putThenGetReturnsSameValue() {
|
||||
TickCache<String> cache = new TickCache<>();
|
||||
cache.get(42L, 100L); // prime the tick
|
||||
cache.put(42L, "fluid");
|
||||
assertSame("fluid", cache.get(42L, 100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClearsCacheWhenTickChanges() {
|
||||
TickCache<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue