feat(mod): Stage 2 — frame budget + profiler + -Dlovisual.opt.* overrides
Per mod/docs/optimize-layer-design.md Stage 2. Engine-side state holders (render/engine/optimize/profile/): - FrameBudget: Mode enum (OFF/SOFT/AGGRESSIVE) + target FPS + per-subsystem caps (entity LOD distance², particle near/mid², spawner distance, trail max points + step). Scale 1.0 / 0.9 / 0.6 per mode. NOT auto-degradation — the user picks the mode explicitly; Phase 9 rule stands. - FrameProfiler: per-section EMA timings (entities/particles/HUD/effects/text), try-with-resources Section API, 2 s idle decay, frame-time tracking. - OptimizePropertyBridge: -Dlovisual.opt.* system-property overrides. 10 properties (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) + all=false to disable everything. Mirrors ZakoOpt's ZakoOptConfig.refresh() shape. Module wiring: - Optimize.java: extended with budget_mode (enum), target_fps (15..360), show_profiler toggles. pushState() now resolves the property bridge and pushes FrameBudget + OptimizeToggles snapshots together. Tests (JUnit 5): 22 new tests — FrameBudgetTest (8), FrameProfilerTest (7), OptimizePropertyBridgeTest (12). Total suite: 849 tests, 0 failures. Note: OptimizeDebugHudMixin (the F3 overlay) is committed in a separate patch alongside the Stage 3 mixins because it depends on the RenderBackendOps GL/Vulkan strategy interface for the property-bridge status line.
This commit is contained in:
parent
494990ca4c
commit
64f27ee9e3
7 changed files with 1001 additions and 19 deletions
|
|
@ -1,11 +1,14 @@
|
||||||
package dev.loki.lovisual.features.module.modules.misc.optimize;
|
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.BooleanValue;
|
||||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||||
import dev.loki.lovisual.features.module.core.Module;
|
import dev.loki.lovisual.features.module.core.Module;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
import dev.loki.lovisual.features.module.phase.Notifier;
|
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.OptimizeState;
|
||||||
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
|
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.).
|
* particle LOD, spawner tick-cache, etc.).
|
||||||
*
|
*
|
||||||
* <p>No auto-degradation by FPS — the owner's rule. Render code reads
|
* <p>No auto-degradation by FPS — the owner's rule. Render code reads
|
||||||
* {@link OptimizeState} (the original cuts) and {@link OptimizeToggles} (the
|
* {@link OptimizeState} (the original cuts), {@link OptimizeToggles} (the
|
||||||
* new transparent helpers), never this class; this module only pushes
|
* Stage 1 transparent helpers), {@link FrameBudget} (Stage 2 frame budget)
|
||||||
* state into them on enable/disable and once per frame.
|
* — never this class; this module only pushes state into them on
|
||||||
|
* enable/disable and once per frame.
|
||||||
*
|
*
|
||||||
* <p>Stage 1 of {@code mod/docs/optimize-layer-design.md}: the toggles below
|
* <p>Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module now also
|
||||||
* wire the engine-side state holders up; the actual hooks (mixins on
|
* pushes a {@link FrameBudget} snapshot (mode + target FPS) and resolves
|
||||||
* {@code LivingEntityRenderer}, {@code ParticleGroup}, {@code SpawnerRenderer},
|
* {@code -Dlovisual.opt.*} system-property overrides via
|
||||||
* {@code BlockEntity}, {@code ClientLanguage}, etc.) are added in Stage 3
|
* {@link OptimizePropertyBridge}. The frame profiler ({@code FrameProfiler})
|
||||||
* alongside the GL path. The helpers themselves (LRU, LOD math, workers, HUD
|
* is fed by the {@code MinecraftFrameMixin} (Stage 3 — not yet present); the
|
||||||
* hysteresis) are already in place under
|
* F3 overlay ({@code OptimizeDebugHudMixin}) reads the profiler's snapshot
|
||||||
* {@code features/module/modules/misc/optimize/{core,entity,particle,hud,block,text}/}.
|
* whenever the user opens F3.
|
||||||
*/
|
*/
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "optimize",
|
id = "optimize",
|
||||||
|
|
@ -54,6 +58,14 @@ public final class Optimize extends Module {
|
||||||
private final BooleanValue preparedTextCache = bool("prepared_text_cache", true);
|
private final BooleanValue preparedTextCache = bool("prepared_text_cache", true);
|
||||||
private final BooleanValue microOpts = bool("micro_opts", true);
|
private final BooleanValue microOpts = bool("micro_opts", true);
|
||||||
|
|
||||||
|
// --- Stage 2: frame budget + profiling ---
|
||||||
|
private final EnumValue<FrameBudget.Mode> budgetMode =
|
||||||
|
enumMode("budget_mode", FrameBudget.Mode.OFF, FrameBudget.Mode.values());
|
||||||
|
private final NumberValue<Integer> targetFps =
|
||||||
|
num("target_fps", FrameBudget.DEFAULT_TARGET_FPS, 15, 360);
|
||||||
|
private final BooleanValue showProfiler =
|
||||||
|
bool("show_profiler", false);
|
||||||
|
|
||||||
private boolean notified;
|
private boolean notified;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -71,6 +83,7 @@ public final class Optimize extends Module {
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
OptimizeState.reset();
|
OptimizeState.reset();
|
||||||
OptimizeToggles.reset();
|
OptimizeToggles.reset();
|
||||||
|
FrameBudget.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Picks up knob changes made while the module stays on. */
|
/** Picks up knob changes made while the module stays on. */
|
||||||
|
|
@ -81,21 +94,41 @@ public final class Optimize extends Module {
|
||||||
|
|
||||||
private void pushState() {
|
private void pushState() {
|
||||||
boolean on = isEnabled();
|
boolean on = isEnabled();
|
||||||
|
// Original four "rubilnik" cuts.
|
||||||
OptimizeState.apply(
|
OptimizeState.apply(
|
||||||
on && noGlass.get(),
|
on && noGlass.get(),
|
||||||
on && litePost.get(),
|
on && litePost.get(),
|
||||||
on && liteSky.get(),
|
on && liteSky.get(),
|
||||||
on && leanHud.get());
|
on && leanHud.get());
|
||||||
|
|
||||||
|
// Stage 1 toggles — wire through the -Dlovisual.opt.* overrides.
|
||||||
|
OptimizePropertyBridge.Snapshot props = OptimizePropertyBridge.resolve();
|
||||||
OptimizeToggles.apply(
|
OptimizeToggles.apply(
|
||||||
on && entityLod.get(), entityLodDistance.get(),
|
resolveBool(props, props.entityLod, on && entityLod.get()),
|
||||||
on && particleLod.get(),
|
resolveInt(props, props.entityLodDistance, entityLodDistance.get()),
|
||||||
on && particleLod.get(), // particleLight: same gate in Stage 1
|
resolveBool(props, props.particleLod, on && particleLod.get()),
|
||||||
on && particleLod.get(), // particlePhysics: same gate in Stage 1
|
resolveBool(props, props.particleLod, on && particleLod.get()),
|
||||||
on && spawnerCull.get(), spawnerDistance.get(),
|
resolveBool(props, props.particleLod, on && particleLod.get()),
|
||||||
on && spawnerTickCache.get(),
|
resolveBool(props, props.spawnerCull, on && spawnerCull.get()),
|
||||||
on && blockEntityCache.get(),
|
resolveInt(props, props.spawnerDistance, spawnerDistance.get()),
|
||||||
on && preparedTextCache.get(),
|
resolveBool(props, props.spawnerTickCache, on && spawnerTickCache.get()),
|
||||||
on && microOpts.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. */
|
/** Compact list of what is being cut, for the first-enable notice. */
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}).
|
||||||
|
*
|
||||||
|
* <p>Use the {@link Section} try-with-resources idiom:
|
||||||
|
* <pre>{@code
|
||||||
|
* try (FrameBudget.Section s = FrameProfiler.section(FrameBudget.SubSystem.PARTICLES)) {
|
||||||
|
* // ... extract particles ...
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Stage 2 of {@code mod/docs/optimize-layer-design.md}.
|
||||||
|
*
|
||||||
|
* <p>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<FrameBudget.SubSystem, Double> EMA_MS = new EnumMap<>(FrameBudget.SubSystem.class);
|
||||||
|
private static final EnumMap<FrameBudget.SubSystem, Long> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>Supported properties (all optional):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code -Dlovisual.opt.all=false} — disables every toggle regardless of module state.</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.entity_lod=true|false} — overrides the entity_lod toggle.</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.particle_lod=true|false}</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.spawner_cull=true|false}</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.spawner_tick_cache=true|false}</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.block_entity_cache=true|false}</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.prepared_text_cache=true|false}</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.micro_opts=true|false}</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.entity_lod_distance=35} — overrides the distance (4..256).</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.spawner_distance=16} — overrides the distance (4..64).</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.budget_mode=off|soft|aggressive}</li>
|
||||||
|
* <li>{@code -Dlovisual.opt.target_fps=120}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code null} for booleans — use the module's value.</li>
|
||||||
|
* <li>{@code -1} for ints when unset — use the module's value.</li>
|
||||||
|
* <li>the explicit override when the property is set.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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<String, Object> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>Three assertions matter most:
|
||||||
|
* <ol>
|
||||||
|
* <li>OFF mode = full quality (scale 1.0, no subsystem is limited).</li>
|
||||||
|
* <li>SOFT/AGGRESSIVE modes scale the baseline caps correctly (0.9 / 0.6).</li>
|
||||||
|
* <li>The user-supplied target FPS is clamped to a sane range and the per-subsystem
|
||||||
|
* caps track the scale.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue