diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java index 05203522..1892d673 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java @@ -1,11 +1,14 @@ package dev.loki.lovisual.features.module.modules.misc.optimize; +import dev.loki.lovisual.config.values.mode.EnumValue; import dev.loki.lovisual.config.values.primitive.BooleanValue; import dev.loki.lovisual.config.values.primitive.NumberValue; import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.phase.Notifier; +import dev.loki.lovisual.render.engine.optimize.profile.FrameBudget; +import dev.loki.lovisual.render.engine.optimize.profile.OptimizePropertyBridge; import dev.loki.lovisual.render.engine.optimize.OptimizeState; import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; @@ -16,17 +19,18 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; * particle LOD, spawner tick-cache, etc.). * *

No auto-degradation by FPS — the owner's rule. Render code reads - * {@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. + * {@link OptimizeState} (the original cuts), {@link OptimizeToggles} (the + * Stage 1 transparent helpers), {@link FrameBudget} (Stage 2 frame budget) + * — 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}/}. + *

Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module now also + * pushes a {@link FrameBudget} snapshot (mode + target FPS) and resolves + * {@code -Dlovisual.opt.*} system-property overrides via + * {@link OptimizePropertyBridge}. The frame profiler ({@code FrameProfiler}) + * is fed by the {@code MinecraftFrameMixin} (Stage 3 — not yet present); the + * F3 overlay ({@code OptimizeDebugHudMixin}) reads the profiler's snapshot + * whenever the user opens F3. */ @ModuleInfo( id = "optimize", @@ -54,6 +58,14 @@ public final class Optimize extends Module { private final BooleanValue preparedTextCache = bool("prepared_text_cache", true); private final BooleanValue microOpts = bool("micro_opts", true); + // --- Stage 2: frame budget + profiling --- + private final EnumValue budgetMode = + enumMode("budget_mode", FrameBudget.Mode.OFF, FrameBudget.Mode.values()); + private final NumberValue targetFps = + num("target_fps", FrameBudget.DEFAULT_TARGET_FPS, 15, 360); + private final BooleanValue showProfiler = + bool("show_profiler", false); + private boolean notified; @Override @@ -71,6 +83,7 @@ public final class Optimize extends Module { public void onDisable() { OptimizeState.reset(); OptimizeToggles.reset(); + FrameBudget.reset(); } /** Picks up knob changes made while the module stays on. */ @@ -81,21 +94,41 @@ public final class Optimize extends Module { private void pushState() { boolean on = isEnabled(); + // Original four "rubilnik" cuts. OptimizeState.apply( on && noGlass.get(), on && litePost.get(), on && liteSky.get(), on && leanHud.get()); + + // Stage 1 toggles — wire through the -Dlovisual.opt.* overrides. + OptimizePropertyBridge.Snapshot props = OptimizePropertyBridge.resolve(); OptimizeToggles.apply( - on && entityLod.get(), entityLodDistance.get(), - on && particleLod.get(), - on && particleLod.get(), // particleLight: same gate in Stage 1 - on && particleLod.get(), // particlePhysics: same gate in Stage 1 - on && spawnerCull.get(), spawnerDistance.get(), - on && spawnerTickCache.get(), - on && blockEntityCache.get(), - on && preparedTextCache.get(), - on && microOpts.get()); + resolveBool(props, props.entityLod, on && entityLod.get()), + resolveInt(props, props.entityLodDistance, entityLodDistance.get()), + resolveBool(props, props.particleLod, on && particleLod.get()), + resolveBool(props, props.particleLod, on && particleLod.get()), + resolveBool(props, props.particleLod, on && particleLod.get()), + resolveBool(props, props.spawnerCull, on && spawnerCull.get()), + resolveInt(props, props.spawnerDistance, spawnerDistance.get()), + resolveBool(props, props.spawnerTickCache, on && spawnerTickCache.get()), + resolveBool(props, props.blockEntityCache, on && blockEntityCache.get()), + resolveBool(props, props.preparedTextCache, on && preparedTextCache.get()), + resolveBool(props, props.microOpts, on && microOpts.get())); + + // Stage 2: frame budget. -Dlovisual.opt.budget_mode / target_fps override the module. + FrameBudget.Mode mode = on ? budgetMode.get() : FrameBudget.Mode.OFF; + FrameBudget.apply(props.overrides(mode, mode), props.resolveTargetFps(targetFps.get())); + } + + private static boolean resolveBool(OptimizePropertyBridge.Snapshot props, + Boolean override, boolean moduleValue) { + return props.overrides(override, moduleValue); + } + + private static int resolveInt(OptimizePropertyBridge.Snapshot props, + int override, int moduleValue) { + return props.overrides(override, moduleValue); } /** Compact list of what is being cut, for the first-enable notice. */ diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java new file mode 100644 index 00000000..2058e308 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java @@ -0,0 +1,139 @@ +package dev.loki.lovisual.render.engine.optimize.profile; + +/** + * Frame budget for the optimization layer — one place that decides how much + * work each subsystem is allowed to do per frame, scaled by the user's chosen + * {@link Mode} and a target FPS. Render code (LOD distance caps, particle + * caps, our own effects — ProjectileTrails, ElytraTrails, DeathEffects, + * Blizzard, GodRays) reads these getters every frame instead of hardcoding + * their own limits. + * + *

This is NOT auto-degradation by FPS — the user explicitly chooses a mode + * (default {@link Mode#OFF}) and a target FPS; we never silently lower + * quality based on measured frame time. The owner's Phase 9 rule stands. + * + *

Stage 2 of {@code mod/docs/optimize-layer-design.md}. + */ +public enum FrameBudget { + ; + + /** Aggressiveness level — user-facing setting in the {@code Optimize} module. */ + public enum Mode { + /** No budget enforcement — every subsystem runs at full quality. */ + OFF, + /** Tighten the most expensive caps by a small margin (~10%) below the baseline. */ + SOFT, + /** Aggressive caps tuned for the target FPS — may visually reduce far detail. */ + AGGRESSIVE; + + public boolean active() { + return this != OFF; + } + } + + /** Default target FPS when the user hasn't picked one. 60 is the safe baseline. */ + public static final int DEFAULT_TARGET_FPS = 60; + + /** Multiplier applied to baseline caps when SOFT mode is on (slightly tighter). */ + static final double SOFT_SCALE = 0.9; + + /** Multiplier applied to baseline caps when AGGRESSIVE mode is on. */ + static final double AGGRESSIVE_SCALE = 0.6; + + // Defaults match ZakoOpt's stage-1 caps so the behavior is familiar. + private static final int BASE_ENTITY_LOD_DISTANCE = 35; + private static final int BASE_PARTICLE_NEAR_BLOCKS = 16; // NEAR_SQ = 16² + private static final int BASE_PARTICLE_MID_BLOCKS = 32; // MID_SQ = 32² + private static final int BASE_SPAWNER_DISTANCE = 16; + private static final int BASE_TRAIL_MAX_POINTS = 256; + private static final int BASE_TRAIL_STEP_NS = 350_000_000; + + private static volatile Mode mode = Mode.OFF; + private static volatile int targetFps = DEFAULT_TARGET_FPS; + + public static Mode mode() { + return mode; + } + + public static int targetFps() { + return targetFps; + } + + /** The scale factor applied to baseline caps — 1.0 in OFF, 0.9 in SOFT, 0.6 in AGGRESSIVE. */ + public static double scale() { + return switch (mode) { + case OFF -> 1.0; + case SOFT -> SOFT_SCALE; + case AGGRESSIVE -> AGGRESSIVE_SCALE; + }; + } + + /** Apply a new budget snapshot — called by {@code Optimize} on enable/disable/frame. */ + public static void apply(Mode mode, int targetFps) { + FrameBudget.mode = mode; + FrameBudget.targetFps = Math.max(15, Math.min(360, targetFps)); + } + + /** Reset to defaults — used by {@code Optimize.onDisable}. */ + public static void reset() { + apply(Mode.OFF, DEFAULT_TARGET_FPS); + } + + // --- Per-subsystem caps (read by render code every frame) --- + + /** Squared LOD distance threshold for entities — beyond this, layers/anim get skipped. */ + public static double entityLodDistanceSq() { + double d = BASE_ENTITY_LOD_DISTANCE * scale(); + return d * d; + } + + /** Squared distance under which particles are always kept (never thinned). */ + public static double particleNearSq() { + double d = BASE_PARTICLE_NEAR_BLOCKS * scale(); + return d * d; + } + + /** Squared distance at which mid-range particle thinning kicks in. */ + public static double particleMidSq() { + double d = BASE_PARTICLE_MID_BLOCKS * scale(); + return d * d; + } + + /** Distance beyond which a spawner's display entity is fully culled. */ + public static int spawnerDistance() { + return Math.max(4, (int) Math.round(BASE_SPAWNER_DISTANCE * scale())); + } + + /** Max points recorded per trail (ProjectileTrails, ElytraTrails). */ + public static int trailMaxPoints() { + return Math.max(8, (int) Math.round(BASE_TRAIL_MAX_POINTS * scale())); + } + + /** Min step between two recorded trail samples, in nanoseconds. */ + public static long trailStepNs() { + // Step grows (less frequent sampling) as the budget tightens. + long base = BASE_TRAIL_STEP_NS; + return Math.round(base / scale()); + } + + /** + * True when the budget actively limits this subsystem. Renderers can use + * this to skip work entirely (e.g. parallel particles disabled below + * a threshold when AGGRESSIVE). + */ + public static boolean limits(SubSystem subsystem) { + if (!mode.active()) return false; + return switch (subsystem) { + case ENTITIES, PARTICLES, HUD, EFFECTS, TEXT -> true; + }; + } + + /** Subsystems tracked by the frame profiler — used by {@link #limits(SubSystem)}. */ + public enum SubSystem { + ENTITIES, + PARTICLES, + HUD, + EFFECTS, + TEXT + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfiler.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfiler.java new file mode 100644 index 00000000..9334bc08 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfiler.java @@ -0,0 +1,195 @@ +package dev.loki.lovisual.render.engine.optimize.profile; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Section-level frame profiler: times five render subsystems + * ({@link FrameBudget.SubSystem#ENTITIES ENTITIES}, {@link FrameBudget.SubSystem#PARTICLES PARTICLES}, + * {@link FrameBudget.SubSystem#HUD HUD}, {@link FrameBudget.SubSystem#EFFECTS EFFECTS}, + * {@link FrameBudget.SubSystem#TEXT TEXT}) per frame and exposes a stable + * EMA-averaged snapshot for the F3 debug overlay (see {@code OptimizeDebugHudMixin}). + * + *

Use the {@link Section} try-with-resources idiom: + *

{@code
+ * try (FrameBudget.Section s = FrameProfiler.section(FrameBudget.SubSystem.PARTICLES)) {
+ *     // ... extract particles ...
+ * }
+ * }
+ * + *

Stage 2 of {@code mod/docs/optimize-layer-design.md}. + * + *

Thread-safety: render-thread only — the same thread that calls + * {@link #beginFrame()} / {@link #endFrame()} is the one that opens/closes + * sections. The snapshot is published via a single volatile field so a + * concurrent F3-thread read always sees a consistent map. + */ +public enum FrameProfiler { + ; + + /** EMA weight for the per-section timing — same as {@code HudWorth}'s 0.95/0.05. */ + private static final double EMA_ALPHA = 0.05; + + private static final int SAMPLE_DECAY_MS = 2000; // forget a section if idle for 2 s + + /** Read by the F3 overlay via {@link #snapshot()}. */ + public static final class Snapshot { + private final double entityMs; + private final double particleMs; + private final double hudMs; + private final double effectMs; + private final double textMs; + private final double frameMs; + + Snapshot(double e, double p, double h, double f, double t, double frame) { + this.entityMs = e; + this.particleMs = p; + this.hudMs = h; + this.effectMs = f; + this.textMs = t; + this.frameMs = frame; + } + + public double entityMs() { return entityMs; } + public double particleMs(){ return particleMs; } + public double hudMs() { return hudMs; } + public double effectMs() { return effectMs; } + public double textMs() { return textMs; } + public double frameMs() { return frameMs; } + } + + private static final EnumMap EMA_MS = new EnumMap<>(FrameBudget.SubSystem.class); + private static final EnumMap LAST_SEEN_MS = new EnumMap<>(FrameBudget.SubSystem.class); + + private static volatile Snapshot snapshot = new Snapshot(0, 0, 0, 0, 0, 0); + + private static long frameStartNs; + private static long frameEndNs; + private static double frameMs; + + // Section state — render-thread only, so plain non-volatile fields are enough. + private static FrameBudget.SubSystem currentSection; + private static long sectionStartNs; + private static long sectionAccumulatorNs; + + /** Begin a new frame — clears accumulators and records the start time. */ + public static void beginFrame() { + frameStartNs = System.nanoTime(); + for (FrameBudget.SubSystem s : FrameBudget.SubSystem.values()) { + // We don't clear EMA_MS here — sections that aren't seen this frame decay + // based on LAST_SEEN_MS, see endFrame(). + } + } + + /** End the frame — decays idle sections, publishes the new snapshot. */ + public static void endFrame() { + frameEndNs = System.nanoTime(); + long frameNs = frameEndNs - frameStartNs; + frameMs = frameNs / 1_000_000.0; + + long nowMs = frameEndNs / 1_000_000L; + double e = decayed(FrameBudget.SubSystem.ENTITIES, nowMs); + double p = decayed(FrameBudget.SubSystem.PARTICLES, nowMs); + double h = decayed(FrameBudget.SubSystem.HUD, nowMs); + double f = decayed(FrameBudget.SubSystem.EFFECTS, nowMs); + double t = decayed(FrameBudget.SubSystem.TEXT, nowMs); + snapshot = new Snapshot(e, p, h, f, t, frameMs); + } + + private static double decayed(FrameBudget.SubSystem s, long nowMs) { + Long last = LAST_SEEN_MS.get(s); + if (last != null && nowMs - last > SAMPLE_DECAY_MS) { + EMA_MS.remove(s); + return 0; + } + Double v = EMA_MS.get(s); + return v == null ? 0 : v; + } + + /** + * Open a section. The previous section (if any) is closed first so + * nested {@code section(...)} calls on different subsystems accumulate + * correctly without leaking time across sections. + */ + public static Section section(FrameBudget.SubSystem subsystem) { + if (currentSection != null) { + closeSection(); + } + currentSection = subsystem; + sectionStartNs = System.nanoTime(); + sectionAccumulatorNs = 0; + return new Section(subsystem); + } + + /** Close the currently-open section — pushed into the EMA. */ + static void closeSection() { + if (currentSection == null) return; + long elapsedNs = System.nanoTime() - sectionStartNs + sectionAccumulatorNs; + double elapsedMs = elapsedNs / 1_000_000.0; + Double prev = EMA_MS.get(currentSection); + double next = prev == null ? elapsedMs : prev * (1 - EMA_ALPHA) + elapsedMs * EMA_ALPHA; + EMA_MS.put(currentSection, next); + LAST_SEEN_MS.put(currentSection, System.nanoTime() / 1_000_000L); + currentSection = null; + sectionStartNs = 0; + sectionAccumulatorNs = 0; + } + + /** Suspend the current section (e.g. when calling into another subsystem). */ + static void suspend() { + if (currentSection == null) return; + sectionAccumulatorNs += System.nanoTime() - sectionStartNs; + sectionStartNs = 0; + } + + /** Resume the current section after a {@link #suspend()}. */ + static void resume() { + if (currentSection == null) return; + sectionStartNs = System.nanoTime(); + } + + /** Read-only snapshot for the F3 overlay. */ + public static Snapshot snapshot() { + return snapshot; + } + + /** Test-only: reset EMA + section state. */ + static void reset() { + EMA_MS.clear(); + LAST_SEEN_MS.clear(); + snapshot = new Snapshot(0, 0, 0, 0, 0, 0); + currentSection = null; + sectionStartNs = 0; + sectionAccumulatorNs = 0; + frameStartNs = 0; + frameEndNs = 0; + frameMs = 0; + } + + /** + * Try-with-resources section. {@code close()} closes the current section + * in {@link FrameProfiler}; nested usage of the same subsystem is fine + * because each {@code close()} only ends the most recent open section. + */ + public static final class Section implements AutoCloseable { + private final FrameBudget.SubSystem subsystem; + private boolean closed; + + Section(FrameBudget.SubSystem subsystem) { + this.subsystem = subsystem; + } + + public FrameBudget.SubSystem subsystem() { + return subsystem; + } + + @Override + public void close() { + if (closed) return; + closed = true; + if (currentSection == subsystem) { + closeSection(); + } + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java new file mode 100644 index 00000000..28c9616d --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java @@ -0,0 +1,193 @@ +package dev.loki.lovisual.render.engine.optimize.profile; + +import java.util.HashMap; +import java.util.Map; + +/** + * Bridge between {@code -Dlovisual.opt.*} system properties and the + * engine-side {@link OptimizeToggles}/{@link FrameBudget} state holders. + * + *

System-property overrides are the benchmark escape hatch — they let you + * flip a toggle or a budget cap without touching the in-game {@code Optimize} + * module UI. Useful for A/B testing in Stage 5. + * + *

Supported properties (all optional): + *

+ * + *

Properties are resolved once per frame via {@link #resolve(Snapshot)} and + * the resulting {@link Snapshot} is applied to {@link OptimizeToggles} and + * {@link FrameBudget}. {@code Optimize} module reads the snapshot in its + * {@code onFrame(float)} lifecycle. + * + *

Stage 2 of {@code mod/docs/optimize-layer-design.md}. + */ +public enum OptimizePropertyBridge { + ; + + /** Cached parsed values — {@link System#getProperty} is too slow for the hot path. */ + private static volatile Snapshot cached = new Snapshot(); + + /** True when the user wants every override disabled (forced off via {@code all=false}). */ + public static boolean allOff() { + return "false".equals(System.getProperty("lovisual.opt.all")); + } + + /** + * Read the current {@code -Dlovisual.opt.*} properties into a fresh snapshot. + * The Optimize module calls this once per frame (mirrors ZakoOpt's + * {@code ZakoOptConfig.refresh()}). + */ + public static Snapshot resolve() { + Snapshot s = new Snapshot(); + s.allOff = allOff(); + + s.entityLod = boolOverride("entity_lod"); + s.particleLod = boolOverride("particle_lod"); + s.spawnerCull = boolOverride("spawner_cull"); + s.spawnerTickCache = boolOverride("spawner_tick_cache"); + s.blockEntityCache = boolOverride("block_entity_cache"); + s.preparedTextCache = boolOverride("prepared_text_cache"); + s.microOpts = boolOverride("micro_opts"); + + s.entityLodDistance = intOverride("entity_lod_distance", -1); + s.spawnerDistance = intOverride("spawner_distance", -1); + + s.budgetMode = budgetModeOverride(); + s.targetFps = intOverride("target_fps", -1); + cached = s; + return s; + } + + /** Last resolved snapshot — render code can read this without re-parsing. */ + public static Snapshot lastResolved() { + return cached; + } + + /** Reset cache — used by tests. */ + static void resetCache() { + cached = new Snapshot(); + } + + /** True if any system property is currently set, false otherwise. */ + public static boolean hasOverrides() { + for (String key : OVERRIDE_KEYS) { + if (System.getProperty("lovisual.opt." + key) != null) return true; + } + return System.getProperty("lovisual.opt.all") != null; + } + + // ---- helpers ---- + + private static final String[] OVERRIDE_KEYS = { + "entity_lod", "particle_lod", "spawner_cull", "spawner_tick_cache", + "block_entity_cache", "prepared_text_cache", "micro_opts", + "entity_lod_distance", "spawner_distance", "budget_mode", "target_fps" + }; + + /** + * Returns {@code null} when the property isn't set (use the module-supplied + * value); {@code Boolean.TRUE}/{@code FALSE} when it's set. + */ + private static Boolean boolOverride(String key) { + String p = System.getProperty("lovisual.opt." + key); + return p == null ? null : Boolean.parseBoolean(p); + } + + private static int intOverride(String key, int fallback) { + String p = System.getProperty("lovisual.opt." + key); + if (p == null) return fallback; + try { + return (int) Double.parseDouble(p); + } catch (NumberFormatException e) { + return fallback; + } + } + + private static FrameBudget.Mode budgetModeOverride() { + String p = System.getProperty("lovisual.opt.budget_mode"); + if (p == null) return null; + return switch (p.toLowerCase()) { + case "off" -> FrameBudget.Mode.OFF; + case "soft" -> FrameBudget.Mode.SOFT; + case "aggressive" -> FrameBudget.Mode.AGGRESSIVE; + default -> null; + }; + } + + /** + * Resolved snapshot. Each field is one of: + *

+ */ + public static final class Snapshot { + public boolean allOff; + public Boolean entityLod; + public Boolean particleLod; + public Boolean spawnerCull; + public Boolean spawnerTickCache; + public Boolean blockEntityCache; + public Boolean preparedTextCache; + public Boolean microOpts; + public int entityLodDistance = -1; + public int spawnerDistance = -1; + public FrameBudget.Mode budgetMode; // null = use module value + public int targetFps = -1; + + /** True when this snapshot should override the given module-side boolean. */ + public boolean overrides(Boolean override, boolean moduleValue) { + if (allOff) return false; + if (override != null) return override; + return moduleValue; + } + + public int overrides(int override, int moduleValue) { + if (allOff) return moduleValue; + return override > 0 ? override : moduleValue; + } + + public FrameBudget.Mode overrides(FrameBudget.Mode override, FrameBudget.Mode moduleValue) { + if (allOff) return FrameBudget.Mode.OFF; + return override != null ? override : moduleValue; + } + + public int resolveTargetFps(int moduleValue) { + if (allOff) return moduleValue; + return targetFps > 0 ? targetFps : moduleValue; + } + + /** Test-only: pretty-print. */ + @Override + public String toString() { + Map m = new HashMap<>(); + m.put("allOff", allOff); + m.put("entity_lod", entityLod); + m.put("particle_lod", particleLod); + m.put("spawner_cull", spawnerCull); + m.put("spawner_tick_cache", spawnerTickCache); + m.put("block_entity_cache", blockEntityCache); + m.put("prepared_text_cache", preparedTextCache); + m.put("micro_opts", microOpts); + m.put("entity_lod_distance", entityLodDistance); + m.put("spawner_distance", spawnerDistance); + m.put("budget_mode", budgetMode); + m.put("target_fps", targetFps); + return m.toString(); + } + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudgetTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudgetTest.java new file mode 100644 index 00000000..86b00883 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudgetTest.java @@ -0,0 +1,117 @@ +package dev.loki.lovisual.render.engine.optimize.profile; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link FrameBudget} — the user-facing budget envelope. + * + *

Three assertions matter most: + *

    + *
  1. OFF mode = full quality (scale 1.0, no subsystem is limited).
  2. + *
  3. SOFT/AGGRESSIVE modes scale the baseline caps correctly (0.9 / 0.6).
  4. + *
  5. The user-supplied target FPS is clamped to a sane range and the per-subsystem + * caps track the scale.
  6. + *
+ */ +public final class FrameBudgetTest { + + @AfterEach + void reset() { + FrameBudget.reset(); + } + + @Test + void offIsFullQualityByDefault() { + FrameBudget.reset(); + assertEquals(FrameBudget.Mode.OFF, FrameBudget.mode()); + assertEquals(FrameBudget.DEFAULT_TARGET_FPS, FrameBudget.targetFps()); + assertEquals(1.0, FrameBudget.scale(), 1e-9); + assertFalse(FrameBudget.limits(FrameBudget.SubSystem.ENTITIES)); + assertFalse(FrameBudget.limits(FrameBudget.SubSystem.PARTICLES)); + assertFalse(FrameBudget.limits(FrameBudget.SubSystem.HUD)); + } + + @Test + void softScalesBy09() { + FrameBudget.apply(FrameBudget.Mode.SOFT, 60); + assertEquals(0.9, FrameBudget.scale(), 1e-9); + assertTrue(FrameBudget.limits(FrameBudget.SubSystem.ENTITIES)); + assertTrue(FrameBudget.limits(FrameBudget.SubSystem.PARTICLES)); + // Distance caps shrink with the scale. + double d = 35 * 0.9; + assertEquals(d * d, FrameBudget.entityLodDistanceSq(), 0.5); + } + + @Test + void aggressiveScalesBy06() { + FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 120); + assertEquals(0.6, FrameBudget.scale(), 1e-9); + assertTrue(FrameBudget.limits(FrameBudget.SubSystem.EFFECTS)); + assertTrue(FrameBudget.limits(FrameBudget.SubSystem.TEXT)); + // Particle near/mid thresholds shrink with the scale. + assertEquals((16 * 0.6) * (16 * 0.6), FrameBudget.particleNearSq(), 0.5); + assertEquals((32 * 0.6) * (32 * 0.6), FrameBudget.particleMidSq(), 0.5); + } + + @Test + void targetFpsIsClamped() { + FrameBudget.apply(FrameBudget.Mode.SOFT, 5); + assertEquals(15, FrameBudget.targetFps()); + FrameBudget.apply(FrameBudget.Mode.SOFT, 1000); + assertEquals(360, FrameBudget.targetFps()); + FrameBudget.apply(FrameBudget.Mode.SOFT, -10); + assertEquals(15, FrameBudget.targetFps()); + } + + @Test + void trailMaxPointsShrinkWithBudget() { + FrameBudget.apply(FrameBudget.Mode.OFF, 60); + assertEquals(256, FrameBudget.trailMaxPoints()); + FrameBudget.apply(FrameBudget.Mode.SOFT, 60); + // 256 * 0.9 = 230.4 -> rounded + assertTrue(FrameBudget.trailMaxPoints() >= 200 && FrameBudget.trailMaxPoints() <= 240, + "SOFT trail max should be ~230, got " + FrameBudget.trailMaxPoints()); + FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 60); + // 256 * 0.6 = 153.6 -> rounded + assertTrue(FrameBudget.trailMaxPoints() >= 140 && FrameBudget.trailMaxPoints() <= 170, + "AGGRESSIVE trail max should be ~154, got " + FrameBudget.trailMaxPoints()); + } + + @Test + void trailStepGrowsWhenBudgetTightens() { + // Step is divided by the scale — AGGRESSIVE (0.6) gives a longer step (less frequent sampling). + FrameBudget.apply(FrameBudget.Mode.OFF, 60); + long baseStep = FrameBudget.trailStepNs(); + FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 60); + assertTrue(FrameBudget.trailStepNs() > baseStep, + "Step should grow with AGGRESSIVE: base=" + baseStep + ", aggressive=" + FrameBudget.trailStepNs()); + } + + @Test + void resetReturnsToOffAndDefaultFps() { + FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 240); + FrameBudget.reset(); + assertEquals(FrameBudget.Mode.OFF, FrameBudget.mode()); + assertEquals(FrameBudget.DEFAULT_TARGET_FPS, FrameBudget.targetFps()); + assertEquals(1.0, FrameBudget.scale(), 1e-9); + } + + @Test + void modeActiveFlag() { + assertFalse(FrameBudget.Mode.OFF.active()); + assertTrue(FrameBudget.Mode.SOFT.active()); + assertTrue(FrameBudget.Mode.AGGRESSIVE.active()); + } + + @Test + void spawnerDistanceNeverGoesBelow4() { + FrameBudget.apply(FrameBudget.Mode.AGGRESSIVE, 30); + assertTrue(FrameBudget.spawnerDistance() >= 4, + "Spawner distance should be clamped at 4, got " + FrameBudget.spawnerDistance()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfilerTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfilerTest.java new file mode 100644 index 00000000..1ac5b35a --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/FrameProfilerTest.java @@ -0,0 +1,137 @@ +package dev.loki.lovisual.render.engine.optimize.profile; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link FrameProfiler} — the per-section frame profiler. + * + *

Stage 2 of {@code mod/docs/optimize-layer-design.md}. The profiler is + * render-thread only; tests run on the JUnit main thread so the same thread + * owns both {@code beginFrame}/{@code endFrame} and the section lifecycle. + */ +public final class FrameProfilerTest { + + @AfterEach + void reset() { + FrameProfiler.reset(); + } + + @Test + void snapshotStartsAtZero() { + FrameProfiler.Snapshot s = FrameProfiler.snapshot(); + assertEquals(0, s.entityMs(), 1e-9); + assertEquals(0, s.particleMs(), 1e-9); + assertEquals(0, s.hudMs(), 1e-9); + assertEquals(0, s.effectMs(), 1e-9); + assertEquals(0, s.textMs(), 1e-9); + assertEquals(0, s.frameMs(), 1e-9); + } + + @Test + void sectionTimeIsRecordedAfterClose() throws InterruptedException { + FrameProfiler.beginFrame(); + try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.PARTICLES)) { + Thread.sleep(5); + } + FrameProfiler.endFrame(); + + FrameProfiler.Snapshot snap = FrameProfiler.snapshot(); + assertTrue(snap.particleMs() > 0, + "Particle section should record >0 ms, got " + snap.particleMs()); + assertTrue(snap.particleMs() < 50, + "5ms sleep should not produce huge EMA, got " + snap.particleMs()); + } + + @Test + void frameMsReflectsBeginEndGap() throws InterruptedException { + FrameProfiler.beginFrame(); + Thread.sleep(10); + FrameProfiler.endFrame(); + double frameMs = FrameProfiler.snapshot().frameMs(); + assertTrue(frameMs >= 10, + "Frame time should be >= 10ms, got " + frameMs); + assertTrue(frameMs < 200, + "Frame time should be reasonable (<200ms), got " + frameMs); + } + + @Test + void nestedSectionsAreClosed() throws InterruptedException { + FrameProfiler.beginFrame(); + try (FrameProfiler.Section outer = FrameProfiler.section(FrameBudget.SubSystem.ENTITIES)) { + Thread.sleep(2); + // Opening a new section implicitly closes the previous one. + try (FrameProfiler.Section inner = FrameProfiler.section(FrameBudget.SubSystem.HUD)) { + Thread.sleep(2); + } + } + FrameProfiler.endFrame(); + FrameProfiler.Snapshot s = FrameProfiler.snapshot(); + assertTrue(s.entityMs() > 0, "Entity section should be recorded"); + assertTrue(s.hudMs() > 0, "HUD section should be recorded"); + } + + @Test + void idleSectionDecaysAfter2Seconds() throws InterruptedException { + FrameProfiler.beginFrame(); + try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.TEXT)) { + Thread.sleep(2); + } + FrameProfiler.endFrame(); + assertTrue(FrameProfiler.snapshot().textMs() > 0, "Text section should be recorded first"); + + // Wait long enough for the decay threshold (2000 ms) to trigger. + Thread.sleep(2200); + FrameProfiler.beginFrame(); + FrameProfiler.endFrame(); + assertEquals(0, FrameProfiler.snapshot().textMs(), 1e-9, + "Text section should decay to 0 after 2s idle"); + } + + @Test + void emaSmoothsSingleOutlier() throws InterruptedException { + FrameProfiler.beginFrame(); + try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.HUD)) { + Thread.sleep(1); + } + FrameProfiler.endFrame(); + // Repeat several short frames; the EMA should converge towards the short value. + for (int i = 0; i < 10; i++) { + FrameProfiler.beginFrame(); + try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.HUD)) { + Thread.sleep(1); + } + FrameProfiler.endFrame(); + } + double hudMs = FrameProfiler.snapshot().hudMs(); + assertTrue(hudMs > 0 && hudMs < 10, + "EMA of HUD section should be small after 10 short frames, got " + hudMs); + } + + @Test + void resetClearsAllState() throws InterruptedException { + FrameProfiler.beginFrame(); + try (FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.EFFECTS)) { + Thread.sleep(3); + } + FrameProfiler.endFrame(); + FrameProfiler.reset(); + FrameProfiler.Snapshot snap = FrameProfiler.snapshot(); + assertEquals(0, snap.effectMs(), 1e-9); + assertEquals(0, snap.frameMs(), 1e-9); + } + + @Test + void sectionCloseIsIdempotent() throws InterruptedException { + FrameProfiler.beginFrame(); + FrameProfiler.Section s = FrameProfiler.section(FrameBudget.SubSystem.PARTICLES); + Thread.sleep(2); + s.close(); + s.close(); // Should not throw or double-count. + FrameProfiler.endFrame(); + assertTrue(FrameProfiler.snapshot().particleMs() > 0); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java new file mode 100644 index 00000000..bf4a03be --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java @@ -0,0 +1,168 @@ +package dev.loki.lovisual.render.engine.optimize.profile; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link OptimizePropertyBridge} — the {@code -Dlovisual.opt.*} + * override resolver. Each test sets the system property, calls {@link + * OptimizePropertyBridge#resolve()}, then clears the property. + */ +public final class OptimizePropertyBridgeTest { + + @AfterEach + void cleanup() { + for (String k : new String[]{ + "lovisual.opt.all", + "lovisual.opt.entity_lod", "lovisual.opt.particle_lod", + "lovisual.opt.spawner_cull", "lovisual.opt.spawner_tick_cache", + "lovisual.opt.block_entity_cache", "lovisual.opt.prepared_text_cache", + "lovisual.opt.micro_opts", + "lovisual.opt.entity_lod_distance", "lovisual.opt.spawner_distance", + "lovisual.opt.budget_mode", "lovisual.opt.target_fps" + }) { + System.clearProperty(k); + } + OptimizePropertyBridge.resetCache(); + } + + @Test + void noPropertiesMeansNoOverrides() { + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + assertFalse(s.allOff); + assertFalse(OptimizePropertyBridge.hasOverrides()); + assertNull(s.entityLod); + assertNull(s.particleLod); + assertNull(s.budgetMode); + assertEquals(-1, s.entityLodDistance); + } + + @Test + void allFalseDisablesEverything() { + System.setProperty("lovisual.opt.all", "false"); + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + assertTrue(s.allOff); + // overrides() should return false for every boolean regardless of the per-key override. + System.setProperty("lovisual.opt.entity_lod", "true"); + s = OptimizePropertyBridge.resolve(); + assertFalse(s.overrides(s.entityLod, true), + "all=false should disable even an explicit true override"); + } + + @Test + void entityLodBooleanOverride() { + System.setProperty("lovisual.opt.entity_lod", "true"); + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + assertEquals(Boolean.TRUE, s.entityLod); + assertTrue(s.overrides(s.entityLod, false)); + + System.setProperty("lovisual.opt.entity_lod", "false"); + s = OptimizePropertyBridge.resolve(); + assertEquals(Boolean.FALSE, s.entityLod); + assertFalse(s.overrides(s.entityLod, true)); + } + + @Test + void distanceOverride() { + System.setProperty("lovisual.opt.entity_lod_distance", "100"); + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + assertEquals(100, s.entityLodDistance); + assertEquals(100, s.overrides(s.entityLodDistance, 35)); + } + + @Test + void distanceOverrideFallsBackWhenUnset() { + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + // -1 means "use the module value". + assertEquals(35, s.overrides(s.entityLodDistance, 35)); + } + + @Test + void invalidDistanceFallsBack() { + System.setProperty("lovisual.opt.entity_lod_distance", "not-a-number"); + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + // The bridge returns -1 (the fallback) when parsing fails, and overrides() + // uses the module value when override <= 0. + assertEquals(-1, s.entityLodDistance); + assertEquals(35, s.overrides(s.entityLodDistance, 35)); + } + + @Test + void budgetModeOverride() { + System.setProperty("lovisual.opt.budget_mode", "soft"); + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + assertEquals(FrameBudget.Mode.SOFT, s.budgetMode); + + System.setProperty("lovisual.opt.budget_mode", "aggressive"); + s = OptimizePropertyBridge.resolve(); + assertEquals(FrameBudget.Mode.AGGRESSIVE, s.budgetMode); + + System.setProperty("lovisual.opt.budget_mode", "off"); + s = OptimizePropertyBridge.resolve(); + assertEquals(FrameBudget.Mode.OFF, s.budgetMode); + } + + @Test + void budgetModeUnknownStringIsNull() { + System.setProperty("lovisual.opt.budget_mode", "turbo"); + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + assertNull(s.budgetMode); + // The bridge falls back to the module value when budgetMode is null. + assertEquals(FrameBudget.Mode.SOFT, s.overrides(s.budgetMode, FrameBudget.Mode.SOFT)); + } + + @Test + void targetFpsOverride() { + System.setProperty("lovisual.opt.target_fps", "144"); + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + assertEquals(144, s.targetFps); + assertEquals(144, s.resolveTargetFps(60)); + } + + @Test + void targetFpsFallsBackWhenUnset() { + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + // Unset = -1 sentinel (the bridge never silently substitutes DEFAULT_TARGET_FPS, + // so the module value always wins when the property is unset). + assertEquals(-1, s.targetFps); + assertEquals(120, s.resolveTargetFps(120)); + assertEquals(60, s.resolveTargetFps(60)); + } + + @Test + void hasOverridesDetectsAnyProperty() { + assertFalse(OptimizePropertyBridge.hasOverrides()); + System.setProperty("lovisual.opt.micro_opts", "false"); + assertTrue(OptimizePropertyBridge.hasOverrides()); + } + + @Test + void allFalseSetsHasOverrides() { + System.setProperty("lovisual.opt.all", "false"); + assertTrue(OptimizePropertyBridge.hasOverrides()); + } + + @Test + void lastResolvedMatchesLatest() { + System.setProperty("lovisual.opt.spawner_cull", "true"); + OptimizePropertyBridge.resolve(); + assertEquals(Boolean.TRUE, OptimizePropertyBridge.lastResolved().spawnerCull); + System.setProperty("lovisual.opt.spawner_cull", "false"); + OptimizePropertyBridge.resolve(); + assertEquals(Boolean.FALSE, OptimizePropertyBridge.lastResolved().spawnerCull); + } + + @Test + void snapshotToStringDoesNotCrash() { + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + String str = s.toString(); + assertTrue(str.contains("entity_lod")); + assertTrue(str.contains("budget_mode")); + } +}