No auto-degradation by FPS — the owner's rule. Render code reads - * {@link OptimizeState}, never this class; this module only pushes state into it. + * {@link OptimizeState} (the original cuts) and {@link OptimizeToggles} (the + * new transparent helpers), never this class; this module only pushes + * state into them on enable/disable and once per frame. + * + *
Stage 1 of {@code mod/docs/optimize-layer-design.md}: the toggles below
+ * wire the engine-side state holders up; the actual hooks (mixins on
+ * {@code LivingEntityRenderer}, {@code ParticleGroup}, {@code SpawnerRenderer},
+ * {@code BlockEntity}, {@code ClientLanguage}, etc.) are added in Stage 3
+ * alongside the GL path. The helpers themselves (LRU, LOD math, workers, HUD
+ * hysteresis) are already in place under
+ * {@code features/module/modules/misc/optimize/{core,entity,particle,hud,block,text}/}.
*/
@ModuleInfo(
id = "optimize",
@@ -20,11 +35,25 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeState;
)
public final class Optimize extends Module {
+ // --- Original "rubilnik" cuts: visible FPS trade-offs ---
private final BooleanValue noGlass = bool("no_glass", true);
private final BooleanValue litePost = bool("lite_post", true);
private final BooleanValue liteSky = bool("lite_sky", true);
private final BooleanValue leanHud = bool("lean_hud", true);
+ // --- transparent optimizations (no visual change) ---
+ private final BooleanValue entityLod = bool("entity_lod", true);
+ private final NumberValue Takes the {@link TickCached} interface directly rather than the
+ * {@code BaseSpawner} class so the contract is testable without a
+ * Minecraft runtime — the Stage 3 mixin casts {@code (TickCached) spawner}
+ * before calling.
+ *
+ * Reads {@link OptimizeToggles#spawnerTickCache()} to decide whether caching is enabled.
+ */
+public enum SpawnerCache {
+ ;
+
+ /** True when the cache is engaged this frame. */
+ public static boolean enabled() {
+ return OptimizeToggles.spawnerTickCache();
+ }
+
+ /** Cached state for this tick, or {@code null} if not yet cached. */
+ public static EntityRenderState get(TickCached spawner, long tick) {
+ return spawner.lovisual$cachedTick() == tick
+ ? (EntityRenderState) spawner.lovisual$cached()
+ : null;
+ }
+
+ /** Write the cached state for the given tick. */
+ public static void put(TickCached spawner, long tick, EntityRenderState state) {
+ spawner.lovisual$setCached(state, tick);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/TickCached.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/TickCached.java
new file mode 100755
index 00000000..0b2a8fb7
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/TickCached.java
@@ -0,0 +1,17 @@
+package dev.loki.lovisual.features.module.modules.misc.optimize.block;
+
+/**
+ * Mixin-contract: a generic per-tick cache slot on a target object
+ * (block-entity, spawner, piston moving block). Stores one cached value plus
+ * the tick it was written; the helper (e.g. {@link SpawnerCache}) checks
+ * {@link #lovisual$cachedTick()} against the current tick and reads
+ * {@link #lovisual$cached()} if it matches, otherwise lets the caller rebuild
+ * and writes the fresh value via {@link #lovisual$setCached(Object, long)}.
+ */
+public interface TickCached {
+ Object lovisual$cached();
+
+ long lovisual$cachedTick();
+
+ void lovisual$setCached(Object value, long tick);
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/core/LruMap.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/core/LruMap.java
new file mode 100755
index 00000000..4e1aa051
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/core/LruMap.java
@@ -0,0 +1,32 @@
+package dev.loki.lovisual.features.module.modules.misc.optimize.core;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Access-ordered LRU map with a size cap. Generic, no Minecraft dependencies,
+ * unit-testable as-is.
+ *
+ * Intended for the {@code ClientLanguage.getVisualOrder} LRU cache.
+ *
+ * @param The vanilla {@code Entity.getDisplayName()} rebuilds a {@link Component}
+ * (team prefix + name + suffix) every time it's called. Renderers call it once
+ * per frame per entity in view, which is wasteful — the team/name/suffix tuple
+ * only changes on tick boundaries. Mixins on {@code Entity} implement this
+ * interface, expose the cache slot via {@link #lovisual$name()}, {@link #lovisual$nameTick()}
+ * and {@link #lovisual$setName(Component, long)}, and the
+ * {@code EntityRenderer.getNameTag} wrapper fills it once per tick and reuses
+ * the cached value on intermediate frames.
+ */
+public interface CachedName {
+ Component lovisual$name();
+
+ long lovisual$nameTick();
+
+ void lovisual$setName(Component name, long tick);
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedStack.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedStack.java
new file mode 100755
index 00000000..f14ca907
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedStack.java
@@ -0,0 +1,50 @@
+package dev.loki.lovisual.features.module.modules.misc.optimize.entity;
+
+import org.lwjgl.system.MemoryStack;
+
+/**
+ * Hot-path helper: skip the {@link MemoryStack#stackPush()} ThreadLocal lookup
+ * on the thread that called last (the render thread). The lookup is cheap on
+ * its own, but Sodium's entity renderer calls it once per cuboid per frame
+ * (thousands of times), and a single volatile read + identity compare is
+ * strictly cheaper than the ThreadLocal hash lookup.
+ *
+ * Stores the {@code (thread, stack)} pair in one record so another thread
+ * never sees a half-updated pair. The first call from a new thread rewrites
+ * the volatile; subsequent calls from the same thread take the fast path.
+ *
+ * Used by the (Stage 3) Sodium {@code EntityRenderer.renderCuboid} mixin.
+ */
+public enum CachedStack {
+ ;
+
+ private record ThreadStack(Thread thread, MemoryStack stack) {
+ }
+
+ // One record so another thread never sees a thread/stack pair half-updated.
+ private static volatile ThreadStack cached = new ThreadStack(null, null);
+
+ /**
+ * Push a frame on the render thread's cached stack, falling back to the
+ * normal ThreadLocal lookup on a thread switch.
+ */
+ public static MemoryStack push() {
+ ThreadStack current = cached;
+ Thread thread = Thread.currentThread();
+ if (current.thread() != thread) {
+ current = new ThreadStack(thread, MemoryStack.stackGet());
+ cached = current;
+ }
+ return current.stack().push();
+ }
+
+ /** Test-only: peek the cached thread (null before first call). */
+ static Thread cachedThread() {
+ return cached.thread();
+ }
+
+ /** Test-only: peek the cached stack (null before first call). */
+ static MemoryStack cachedStack() {
+ return cached.stack();
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLod.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLod.java
new file mode 100755
index 00000000..0075ae76
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLod.java
@@ -0,0 +1,37 @@
+package dev.loki.lovisual.features.module.modules.misc.optimize.entity;
+
+import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
+
+/**
+ * Entity LOD gate: is an entity far enough that the renderer should skip its
+ * layers (armour, held items, eyes, cape, elytra, stuck arrows) and freeze its
+ * animation? Compares squared distance to the configured threshold so the
+ * caller doesn't have to {@code Math.sqrt}.
+ *
+ * The companion {@code farPlayer} exists so a config can drop
+ * players separately from entities (the {@code playerLod} toggle); in LoVisual
+ * Stage 1 we collapse the two — the same toggle gates both. Stage 2 may split
+ * them again if profiling shows players are the bigger cost.
+ */
+public enum EntityLod {
+ ;
+
+ /** Squared threshold at and above which an entity is "far". */
+ public static double farThresholdSq() {
+ double d = OptimizeToggles.entityLodDistance();
+ return d * d;
+ }
+
+ /** True when LOD is on and the entity is beyond the configured distance. */
+ public static boolean far(double distanceSq) {
+ if (!OptimizeToggles.entityLod()) {
+ return false;
+ }
+ return distanceSq > farThresholdSq();
+ }
+
+ /** Same as {@link #far} — players and entities share one toggle in Stage 1. */
+ public static boolean farPlayer(double distanceSq) {
+ return far(distanceSq);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/StateEntity.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/StateEntity.java
new file mode 100755
index 00000000..592343d4
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/StateEntity.java
@@ -0,0 +1,19 @@
+package dev.loki.lovisual.features.module.modules.misc.optimize.entity;
+
+import net.minecraft.world.entity.Entity;
+
+/**
+ * Mixin-contract: back-reference the source {@link Entity} on its
+ * {@code EntityRenderState}. Render-states are rebuilt every frame, so they
+ * can't keep their own pointer to the entity — the
+ * {@code EntityRenderer.createRenderState} mixin captures it once per state
+ * construction via {@link #lovisual$entity(Entity)} and downstream renderers
+ * (model feature renderer, item feature renderer, future LOD mixins) read it
+ * back via {@link #lovisual$entity()} to reach the live entity for animation
+ * freeze, LOD distance checks, etc.
+ */
+public interface StateEntity {
+ Entity lovisual$entity();
+
+ void lovisual$entity(Entity entity);
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/hud/HudWorth.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/hud/HudWorth.java
new file mode 100755
index 00000000..30a7a9d6
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/hud/HudWorth.java
@@ -0,0 +1,67 @@
+package dev.loki.lovisual.features.module.modules.misc.optimize.hud;
+
+/**
+ * Hysteresis controller for the HUD cache. The HUD cache only saves work when
+ * several frames share one HUD refresh; near one frame per refresh (refresh
+ * interval ~= frame time) it redraws the HUD anyway and just adds a
+ * full-screen clear + blend, which costs real FPS on integrated GPUs and
+ * 60 Hz monitors. This controller tracks an EMA of frame time
+ * ({@code *0.95 + dt*0.05}) and flips the "worth it" state with hysteresis:
+ * turns OFF below {@code OFF_BELOW} frames-per-refresh, turns ON above
+ * {@code ON_ABOVE}.
+ *
+ * Pure logic, no Minecraft dependencies, fully testable. The (Stage 3) HUD cache mixin reads
+ * {@link #worth()} to decide whether to engage.
+ */
+public enum HudWorth {
+ ;
+
+ private static final double OFF_BELOW = 1.5;
+ private static final double ON_ABOVE = 2.0;
+
+ private static double frameNs;
+ private static long last;
+ private static volatile boolean worth = true;
+
+ /** True when the HUD cache is currently engaged. */
+ public static boolean worth() {
+ return worth;
+ }
+
+ /** True when the HUD cache should be available at all (independent of FPS). */
+ public static boolean hysteresisEnabled() {
+ return true;
+ }
+
+ /**
+ * Update the EMA frame time and the hysteresis state. Called once per
+ * frame; returns the new {@link #worth()} value.
+ *
+ * @param now the current {@code System.nanoTime()} reading
+ * @param refreshNs nanoseconds between HUD refreshes (1e9 / monitor Hz)
+ */
+ public static boolean update(long now, long refreshNs) {
+ long dt = now - last;
+ last = now;
+ if (dt > 0 && dt < 1_000_000_000L) {
+ frameNs = frameNs == 0 ? dt : frameNs * 0.95 + dt * 0.05;
+ }
+ double framesPerRefresh = frameNs == 0 ? ON_ABOVE : refreshNs / frameNs;
+ if (worth ? framesPerRefresh < OFF_BELOW : framesPerRefresh > ON_ABOVE) {
+ worth = !worth;
+ }
+ return worth;
+ }
+
+ /** Test-only: reset EMA + hysteresis to defaults. */
+ static void reset() {
+ frameNs = 0;
+ last = 0;
+ worth = true;
+ }
+
+ /** Test-only: read the EMA frame time in nanoseconds. */
+ static double frameNs() {
+ return frameNs;
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLod.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLod.java
new file mode 100755
index 00000000..253f1fa9
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLod.java
@@ -0,0 +1,50 @@
+package dev.loki.lovisual.features.module.modules.misc.optimize.particle;
+
+import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
+
+/**
+ * Per-frame thinning of far particles. Particles beyond 16 blocks are kept
+ * every other frame (32+ blocks: every fourth) using
+ * {@code System.identityHashCode(particle) >>> 4} as a stable per-particle
+ * salt so the choice doesn't flicker.
+ *
+ * The hot-path API takes the camera position as primitives (no
+ * {@code Vec3} allocation); the {@code keep(Object, double)} overload is the
+ * pure-logic piece, testable without Minecraft types.
+ *
+ * The {@code tick} field (sampled once per {@code ParticleEngine.render}) lives on the
+ * (Stage 3) particle mixin that owns the per-frame lifecycle; the pure-logic
+ * distance decision is here.
+ */
+public enum ParticleLod {
+ ;
+
+ private static final double NEAR_SQ = 16.0 * 16.0;
+ private static final double MID_SQ = 32.0 * 32.0;
+
+ /**
+ * True when the particle should be kept this frame. The caller passes the
+ * particle's identity (for the per-particle salt) and the squared distance
+ * from the camera — both already computed by the renderer.
+ */
+ public static boolean keep(Object particle, double distanceSq) {
+ if (!OptimizeToggles.particleLod()) {
+ return true;
+ }
+ if (distanceSq < NEAR_SQ) {
+ return true;
+ }
+ int h = System.identityHashCode(particle) >>> 4;
+ return distanceSq < MID_SQ ? (h & 1) == 0 : (h & 3) == 0;
+ }
+
+ /**
+ * Convenience overload: compute squared distance from primitives and
+ * dispatch to {@link #keep(Object, double)}.
+ */
+ public static boolean keep(Object particle, double x, double y, double z,
+ double camX, double camY, double camZ) {
+ double dx = x - camX, dy = y - camY, dz = z - camZ;
+ return keep(particle, dx * dx + dy * dy + dz * dz);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java
new file mode 100755
index 00000000..def3df15
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java
@@ -0,0 +1,94 @@
+package dev.loki.lovisual.features.module.modules.misc.optimize.particle;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.IntConsumer;
+
+/**
+ * Fixed-size daemon pool for parallel particle extraction. The number of
+ * helpers is {@code max(1, min(8, cores-2))} — leaving two cores for the
+ * render thread and the GC. {@link #run(int, IntConsumer)} splits the work
+ * into {@code blocks} units (caller decides the block size), dispatches them
+ * across helpers AND the calling thread (the caller works too — it only ever
+ * waits for blocks a helper has already claimed), and returns the first
+ * failure or {@code null} on success.
+ *
+ * Used by the (Stage 3) {@code QuadParticleGroup} mixin to parallelise
+ * particle render-state extraction when the particle count exceeds 1024
+ * (Stage 1 ports the pool; the wiring is a later stage).
+ *
+ * Pure java.util.concurrent, no Minecraft dependencies, fully testable.
+ */
+public final class Workers {
+
+ public static final int COUNT = Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors() - 2));
+
+ private static final AtomicInteger THREAD_ID = new AtomicInteger();
+ private static final ExecutorService POOL = Executors.newFixedThreadPool(COUNT, r -> {
+ Thread t = new Thread(r, "lovisual-opt-worker-" + THREAD_ID.incrementAndGet());
+ t.setDaemon(true);
+ return t;
+ });
+
+ private Workers() {
+ }
+
+ public static int poolSize() {
+ return COUNT;
+ }
+
+ /**
+ * Run {@code task(0..blocks-1)} across the pool plus the calling thread.
+ * Returns the first failure, or {@code null} if all blocks succeeded.
+ */
+ public static Throwable run(int blocks, IntConsumer task) {
+ if (blocks <= 0) {
+ return null;
+ }
+ Job job = new Job(blocks, task);
+ for (int h = Math.min(COUNT, blocks - 1); h > 0; h--) {
+ POOL.execute(job::run);
+ }
+ // The caller works too; it only ever waits for blocks a helper has already claimed.
+ job.run();
+ while (job.completed.get() < blocks) {
+ Thread.onSpinWait();
+ }
+ return job.error.get();
+ }
+
+ /** Test-only: shut the pool down so the JVM can exit cleanly. */
+ public static void shutdown() {
+ POOL.shutdownNow();
+ }
+
+ private static final class Job {
+ final int blocks;
+ final IntConsumer task;
+ final AtomicInteger next = new AtomicInteger();
+ final AtomicInteger completed = new AtomicInteger();
+ final AtomicReference The fast path is a plain {@code instanceof}
+ * check on the wrapper; the slow path is a synchronised set lookup. LoVisual
+ * keeps both — Stage 1 wires them both to {@link OptimizeToggles#microOpts()}.
+ */
+public final class StableText implements FormattedCharSequence {
+
+ private static final Set Stage 1 of {@code mod/docs/optimize-layer-design.md}: backend-independent
+ * logic only. GL/Vulkan-specific toggles (frame fence, lazy clear, FBO share,
+ * TBO cache, skin atlas, HUD cache) live in their own holders added in Stages
+ * 3-4 alongside the mixins that consume them.
+ *
+ * All module settings are resolved into volatile primitive fields once per frame.
+ * System-property overrides are not supported here (the {@code Optimize} module's
+ * {@code bool()}/{@code num()} settings are the single source of truth); they can be
+ * layered back via {@code -Dlovisual.opt.*} in Stage 2 per the design doc.
+ */
+public enum OptimizeToggles {
+ ;
+
+ private static volatile boolean entityLod;
+ private static volatile int entityLodDistance;
+ private static volatile boolean particleLod;
+ private static volatile boolean particleLight;
+ private static volatile boolean particlePhysics;
+ private static volatile boolean spawnerCull;
+ private static volatile int spawnerDistance;
+ private static volatile boolean spawnerTickCache;
+ private static volatile boolean blockEntityCache;
+ private static volatile boolean preparedTextCache;
+ private static volatile boolean microOpts;
+
+ public static boolean entityLod() { return entityLod; }
+ public static int entityLodDistance() { return entityLodDistance; }
+ public static boolean particleLod() { return particleLod; }
+ public static boolean particleLight() { return particleLight; }
+ public static boolean particlePhysics() { return particlePhysics; }
+ public static boolean spawnerCull() { return spawnerCull; }
+ public static int spawnerDistance() { return spawnerDistance; }
+ public static boolean spawnerTickCache() { return spawnerTickCache; }
+ public static boolean blockEntityCache() { return blockEntityCache; }
+ public static boolean preparedTextCache() { return preparedTextCache; }
+ public static boolean microOpts() { return microOpts; }
+
+ /**
+ * Push a full snapshot. Called by {@code Optimize.onEnable/onDisable/onFrame}
+ * with the resolved {@code (isEnabled() && knob.get())} for every toggle.
+ */
+ public static void apply(
+ boolean entityLod, int entityLodDistance,
+ boolean particleLod, boolean particleLight, boolean particlePhysics,
+ boolean spawnerCull, int spawnerDistance,
+ boolean spawnerTickCache,
+ boolean blockEntityCache,
+ boolean preparedTextCache,
+ boolean microOpts) {
+ OptimizeToggles.entityLod = entityLod;
+ OptimizeToggles.entityLodDistance = entityLodDistance;
+ OptimizeToggles.particleLod = particleLod;
+ OptimizeToggles.particleLight = particleLight;
+ OptimizeToggles.particlePhysics = particlePhysics;
+ OptimizeToggles.spawnerCull = spawnerCull;
+ OptimizeToggles.spawnerDistance = spawnerDistance;
+ OptimizeToggles.spawnerTickCache = spawnerTickCache;
+ OptimizeToggles.blockEntityCache = blockEntityCache;
+ OptimizeToggles.preparedTextCache = preparedTextCache;
+ OptimizeToggles.microOpts = microOpts;
+ }
+
+ /** Reset to "nothing on" — used by {@code Optimize.onDisable}. */
+ public static void reset() {
+ apply(false, 0, false, false, false, false, 0, false, false, false, false);
+ }
+}