feat(mod): parallel-particles plumbing (CORES>=4 auto + -D tri-state)

Stage 1 item 4 from optimize-layer-design.md, simplified to what the
reference does (auto|true|false) instead of the original 'measure first'
plan. Workers now holds the resolved flag:
- Workers.PARALLEL_MIN_CORES = 4 gate for AUTO mode
- Workers.configureParallelParticles(Boolean forced, boolean moduleEnabled)
- Workers.shouldParallelParticles() read by the future particle-extraction
  mixin (26.2 QuadParticleGroup/SpriteBillboardParticle wiring is separate)
- Workers.resetParallelParticles() called by Optimize.onDisable

Optimize gains a parallel_particles BooleanValue (default true = AUTO).
OptimizePropertyBridge parses -Dlovisual.opt.parallel_particles as tri-state:
null (unset or 'auto' -> defer to module + cores gate), TRUE, FALSE;
the key is now part of OVERRIDE_KEYS so hasOverrides() sees it and toString()
includes it.

Docs: 'Что делаем лучше' item 4 marked done — no measurement gate, matching
the new plan direction.

Tests: 5 new WorkersTest cases for resolveParallelParticles + configure/reset
round trip; 4 new OptimizePropertyBridgeTest cases for the tri-state parser
(unset / explicit 'auto' / forced true|false / hasOverrides pickup). gradle
compileJava compileTestJava test checkFolderLimit green.
This commit is contained in:
loki5512344 2026-10-12 21:49:12 +02:00
parent ecde09560d
commit e85c4fc10a
Signed by: boba
GPG key ID: 253067914055423B
6 changed files with 141 additions and 2 deletions

View file

@ -65,6 +65,9 @@ optimize/
2. **Профилировщик секций** (сущности, частицы, HUD, эффекты, текст) в debug-оверлее. 2. **Профилировщик секций** (сущности, частицы, HUD, эффекты, текст) в debug-оверлее.
3. **Один конфиг** в `Optimize`, без отдельного экрана референса. 3. **Один конфиг** в `Optimize`, без отдельного экрана референса.
4. **Авто-порог параллельных частиц** по замеру (в референсе порог 4+ ядра взят из одного прогона). 4. **Авто-порог параллельных частиц** по замеру (в референсе порог 4+ ядра взят из одного прогона).
→ **Упрощено 2026-10-12**: сделано как в референсе — `CORES >= 4` + `-Dlovisual.opt.parallel_particles=auto|true|false`.
Модуль `Optimize.parallel_particles` (default true = AUTO) и `Workers.shouldParallelParticles()` — plumbing готов,
mixin-точка на vanilla extraction приложится позже (26.2 `QuadParticleGroup`/`SpriteBillboardParticle`).
5. **Кэш статичных HUD-виджетов в текстуру** (перерисовка только при изменении). 5. **Кэш статичных HUD-виджетов в текстуру** (перерисовка только при изменении).
6. Флаги для бенчмарков: `-Dlovisual.opt.<key>`. 6. Флаги для бенчмарков: `-Dlovisual.opt.<key>`.

View file

@ -10,6 +10,7 @@ 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.lifecycle.Modules; import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.phase.Notifier; import dev.loki.lovisual.features.module.phase.Notifier;
import dev.loki.lovisual.features.module.modules.misc.optimize.particle.Workers;
import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze; import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze;
import dev.loki.lovisual.render.engine.optimize.entity.ItemBounds; import dev.loki.lovisual.render.engine.optimize.entity.ItemBounds;
import dev.loki.lovisual.render.engine.optimize.entity.ItemLod; import dev.loki.lovisual.render.engine.optimize.entity.ItemLod;
@ -87,6 +88,10 @@ public final class Optimize extends Module {
// --- Stage 3: parallel model build (own holder: cores/Iris gating inside) --- // --- Stage 3: parallel model build (own holder: cores/Iris gating inside) ---
private final BooleanValue parallelModels = bool("parallel_models", true); private final BooleanValue parallelModels = bool("parallel_models", true);
// --- Stage 1 item 4: parallel particles AUTO gate (cores >= 4) ---
// -Dlovisual.opt.parallel_particles=auto|true|false overrides this from OptimizePropertyBridge.
private final BooleanValue parallelParticles = bool("parallel_particles", true);
// --- Visible cuts merged from the old FPS Optimizer module --- // --- Visible cuts merged from the old FPS Optimizer module ---
private final BooleanValue noParticles = bool("no_particles", false); private final BooleanValue noParticles = bool("no_particles", false);
private final BooleanValue noMenuBackground = bool("no_menu_background", false); private final BooleanValue noMenuBackground = bool("no_menu_background", false);
@ -126,6 +131,7 @@ public final class Optimize extends Module {
ItemBounds.reset(); ItemBounds.reset();
HudCache.reset(); HudCache.reset();
ParallelModels.reset(); ParallelModels.reset();
Workers.resetParallelParticles();
// stop counting and drop the F3 numbers, onFrame will not run again // stop counting and drop the F3 numbers, onFrame will not run again
DrawStats.endFrame(false); DrawStats.endFrame(false);
} }
@ -220,6 +226,11 @@ public final class Optimize extends Module {
// Stage 3: parallel model build; mutually exclusive with anim freeze (checked inside). // Stage 3: parallel model build; mutually exclusive with anim freeze (checked inside).
ParallelModels.configure(on && parallelModels.get()); ParallelModels.configure(on && parallelModels.get());
// Stage 1 item 4: parallel-particles gate; -Dlovisual.opt.parallel_particles resolves
// to null (AUTO -> cores >= 4), Boolean.TRUE/FALSE (forced). Workers keeps the resolved
// flag so a future particle-extraction mixin can read it without touching this module.
Workers.configureParallelParticles(props.parallelParticles, on && parallelParticles.get());
} }
private static boolean resolveBool(OptimizePropertyBridge.Snapshot props, private static boolean resolveBool(OptimizePropertyBridge.Snapshot props,

View file

@ -25,6 +25,48 @@ public final class Workers {
public static final int COUNT = Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors() - 2)); public static final int COUNT = Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors() - 2));
/** Minimum logical CPU count required for AUTO mode to enable parallel particles. */
public static final int PARALLEL_MIN_CORES = 4;
/**
* Effective parallel-particles flag resolved from module + property overrides.
* Written once per frame by {@code Optimize.pushState()}, read by the future
* particle-extraction mixin to decide whether to fan out across {@link #stream(IntConsumer)}.
*/
private static volatile boolean parallelParticles;
/**
* Resolve the parallel-particles flag. {@code forced} is the tri-state from
* {@code -Dlovisual.opt.parallel_particles} (TRUE/FALSE) or {@code null} for AUTO; {@code moduleEnabled}
* is the {@code Optimize.parallel_particles} switch (default {@code true}); AUTO engages when the
* machine exposes {@link #PARALLEL_MIN_CORES} or more logical CPUs — mirrors the reference's
* {@code CORES >= 4} rule from {@code mod/docs/optimize-layer-design.md}, no benchmark gate.
*/
public static void configureParallelParticles(Boolean forced, boolean moduleEnabled) {
parallelParticles = resolveParallelParticles(forced, moduleEnabled, Runtime.getRuntime().availableProcessors());
}
/** Test-friendly variant that lets the caller stub the CPU count. */
public static boolean resolveParallelParticles(Boolean forced, boolean moduleEnabled, int cores) {
if (forced != null) {
return forced;
}
if (!moduleEnabled) {
return false;
}
return cores >= PARALLEL_MIN_CORES;
}
/** True when the particle-extraction mixin should split across the pool. */
public static boolean shouldParallelParticles() {
return parallelParticles;
}
/** Reset to a safe default — used by {@code Optimize.onDisable} and by tests. */
public static void resetParallelParticles() {
parallelParticles = false;
}
private static final AtomicInteger THREAD_ID = new AtomicInteger(); private static final AtomicInteger THREAD_ID = new AtomicInteger();
private static final ExecutorService POOL = Executors.newFixedThreadPool(COUNT, r -> { private static final ExecutorService POOL = Executors.newFixedThreadPool(COUNT, r -> {
Thread t = new Thread(r, "lovisual-opt-worker-" + THREAD_ID.incrementAndGet()); Thread t = new Thread(r, "lovisual-opt-worker-" + THREAD_ID.incrementAndGet());

View file

@ -22,6 +22,8 @@ import java.util.Map;
* <li>{@code -Dlovisual.opt.prepared_text_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.micro_opts=true|false}</li>
* <li>{@code -Dlovisual.opt.fbo_share=true|false}</li> * <li>{@code -Dlovisual.opt.fbo_share=true|false}</li>
* <li>{@code -Dlovisual.opt.parallel_particles=auto|true|false} — tri-state; {@code auto} defers
* to the module value + the {@code cores >= 4} gate inside {@code Workers}.</li>
* <li>{@code -Dlovisual.opt.entity_lod_distance=35} — overrides the distance (4..256).</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.spawner_distance=16} — overrides the distance (4..64).</li>
* <li>{@code -Dlovisual.opt.budget_mode=off|soft|aggressive}</li> * <li>{@code -Dlovisual.opt.budget_mode=off|soft|aggressive}</li>
@ -66,6 +68,8 @@ public enum OptimizePropertyBridge {
s.entityLodDistance = intOverride("entity_lod_distance", -1); s.entityLodDistance = intOverride("entity_lod_distance", -1);
s.spawnerDistance = intOverride("spawner_distance", -1); s.spawnerDistance = intOverride("spawner_distance", -1);
s.parallelParticles = triStateOverride("parallel_particles");
s.budgetMode = budgetModeOverride(); s.budgetMode = budgetModeOverride();
s.targetFps = intOverride("target_fps", -1); s.targetFps = intOverride("target_fps", -1);
cached = s; cached = s;
@ -95,7 +99,8 @@ public enum OptimizePropertyBridge {
private static final String[] OVERRIDE_KEYS = { private static final String[] OVERRIDE_KEYS = {
"entity_lod", "particle_lod", "spawner_cull", "spawner_tick_cache", "entity_lod", "particle_lod", "spawner_cull", "spawner_tick_cache",
"block_entity_cache", "prepared_text_cache", "micro_opts", "fbo_share", "block_entity_cache", "prepared_text_cache", "micro_opts", "fbo_share",
"entity_lod_distance", "spawner_distance", "budget_mode", "target_fps" "entity_lod_distance", "spawner_distance", "budget_mode", "target_fps",
"parallel_particles"
}; };
/** /**
@ -117,6 +122,18 @@ public enum OptimizePropertyBridge {
} }
} }
/**
* Tri-state override: {@code null} when the property is unset or set to {@code auto}
* (defer to module + cores gate), {@code Boolean.TRUE}/{@code FALSE} when the user forced it.
*/
private static Boolean triStateOverride(String key) {
String p = System.getProperty("lovisual.opt." + key);
if (p == null) return null;
String v = p.trim().toLowerCase();
if (v.isEmpty() || v.equals("auto")) return null;
return Boolean.parseBoolean(v);
}
private static FrameBudget.Mode budgetModeOverride() { private static FrameBudget.Mode budgetModeOverride() {
String p = System.getProperty("lovisual.opt.budget_mode"); String p = System.getProperty("lovisual.opt.budget_mode");
if (p == null) return null; if (p == null) return null;
@ -148,6 +165,8 @@ public enum OptimizePropertyBridge {
public Boolean fboShare; public Boolean fboShare;
public int entityLodDistance = -1; public int entityLodDistance = -1;
public int spawnerDistance = -1; public int spawnerDistance = -1;
/** Tri-state override: null = AUTO (use module + cores gate), TRUE/FALSE = forced. */
public Boolean parallelParticles;
public FrameBudget.Mode budgetMode; // null = use module value public FrameBudget.Mode budgetMode; // null = use module value
public int targetFps = -1; public int targetFps = -1;
@ -188,6 +207,7 @@ public enum OptimizePropertyBridge {
m.put("fbo_share", fboShare); m.put("fbo_share", fboShare);
m.put("entity_lod_distance", entityLodDistance); m.put("entity_lod_distance", entityLodDistance);
m.put("spawner_distance", spawnerDistance); m.put("spawner_distance", spawnerDistance);
m.put("parallel_particles", parallelParticles);
m.put("budget_mode", budgetMode); m.put("budget_mode", budgetMode);
m.put("target_fps", targetFps); m.put("target_fps", targetFps);
return m.toString(); return m.toString();

View file

@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@ -103,6 +104,39 @@ public final class WorkersTest {
assertNull(err); assertNull(err);
} }
@Test
void autoParallelParticlesRequiresFourCores() {
assertFalse(Workers.resolveParallelParticles(null, true, 2));
assertFalse(Workers.resolveParallelParticles(null, true, 3));
assertTrue(Workers.resolveParallelParticles(null, true, 4));
assertTrue(Workers.resolveParallelParticles(null, true, 8));
}
@Test
void forcedTrueOverridesModuleAndCores() {
assertTrue(Workers.resolveParallelParticles(Boolean.TRUE, false, 1));
}
@Test
void forcedFalseOverridesAutoGate() {
assertFalse(Workers.resolveParallelParticles(Boolean.FALSE, true, 16));
}
@Test
void moduleOffDisablesAutoEvenOnManyCores() {
assertFalse(Workers.resolveParallelParticles(null, false, 32));
}
@Test
void configureWritesFlagReadedByShould() {
Workers.configureParallelParticles(Boolean.TRUE, false);
assertTrue(Workers.shouldParallelParticles());
Workers.configureParallelParticles(Boolean.FALSE, true);
assertFalse(Workers.shouldParallelParticles());
Workers.resetParallelParticles();
assertFalse(Workers.shouldParallelParticles());
}
@Test @Test
void streamReturnsFirstFailure() { void streamReturnsFirstFailure() {
int blocks = 12; int blocks = 12;

View file

@ -25,7 +25,8 @@ public final class OptimizePropertyBridgeTest {
"lovisual.opt.block_entity_cache", "lovisual.opt.prepared_text_cache", "lovisual.opt.block_entity_cache", "lovisual.opt.prepared_text_cache",
"lovisual.opt.micro_opts", "lovisual.opt.fbo_share", "lovisual.opt.micro_opts", "lovisual.opt.fbo_share",
"lovisual.opt.entity_lod_distance", "lovisual.opt.spawner_distance", "lovisual.opt.entity_lod_distance", "lovisual.opt.spawner_distance",
"lovisual.opt.budget_mode", "lovisual.opt.target_fps" "lovisual.opt.budget_mode", "lovisual.opt.target_fps",
"lovisual.opt.parallel_particles"
}) { }) {
System.clearProperty(k); System.clearProperty(k);
} }
@ -178,4 +179,32 @@ public final class OptimizePropertyBridgeTest {
assertTrue(str.contains("entity_lod")); assertTrue(str.contains("entity_lod"));
assertTrue(str.contains("budget_mode")); assertTrue(str.contains("budget_mode"));
} }
@Test
void parallelParticlesUnsetMeansAuto() {
OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
assertNull(s.parallelParticles);
}
@Test
void parallelParticlesExplicitAutoAlsoMeansAuto() {
System.setProperty("lovisual.opt.parallel_particles", "auto");
OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
assertNull(s.parallelParticles);
}
@Test
void parallelParticlesTrueFalseForced() {
System.setProperty("lovisual.opt.parallel_particles", "true");
assertEquals(Boolean.TRUE, OptimizePropertyBridge.resolve().parallelParticles);
System.setProperty("lovisual.opt.parallel_particles", "false");
assertEquals(Boolean.FALSE, OptimizePropertyBridge.resolve().parallelParticles);
}
@Test
void parallelParticlesAppearsInHasOverrides() {
assertFalse(OptimizePropertyBridge.hasOverrides());
System.setProperty("lovisual.opt.parallel_particles", "auto");
assertTrue(OptimizePropertyBridge.hasOverrides());
}
} }