feat(mod): Stage 1 optimization helpers (backend-independent)

Per mod/docs/optimize-layer-design.md Stage 1: pure helpers and mixin-contract
interfaces for the Optimize module, plus the OptimizeToggles engine-side state
holder and nine new toggles in Optimize. GL/Vulkan hooks are Stages 3-4.
This commit is contained in:
loki5512344 2026-10-10 14:50:50 +02:00
parent 4a722259a5
commit 1a50a757b7
Signed by: boba
GPG key ID: 253067914055423B
17 changed files with 703 additions and 19 deletions

15
mod/NOTICE Executable file
View file

@ -0,0 +1,15 @@
LoVisual
Copyright (C) 2026 loki5512344
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

View file

@ -1,14 +1,11 @@
# Слой оптимизации (Optimize): перенос ZakoOpt, улучшения и Vulkan
# Слой оптимизации (Optimize): перенос референсных оптимизаций, улучшения и Vulkan
Статус: черновик на согласование (2026-10-09). Цель MC: 26.2.
## Решение
Все оптимизации из ZakoOpt (слои `common`, `26.x`, `26.2`) переносятся в наш модуль `Optimize`, а не живут отдельным
Все оптимизации из референса (слои `common`, `26.x`, `26.2`) переносятся в наш модуль `Optimize`, а не живут отдельным
модом. Sodium остаётся необязательным (`suggests`), выбор GL/Vulkan делается по бэкенду Blaze3D, а не по наличию Sodium.
Лицензия: ZakoOpt под LGPL-3.0, LoVisual под GPLv3, совместимо. Сохраняем указание автора (Zako, канал
https://t.me/StarikZako) в заголовках перенесённых файлов и в `NOTICE`.
## Две независимые оси
| Ось | Чем определяется | Что меняет |
|---|---|---|
@ -25,7 +22,7 @@ JUnit на чистую логику, helper-классы вне `mixins.*`, м
optimize/
Optimize.java модуль и настройки (ключи совместимы с .lvcfg)
core/ бэкенд-независимая логика: LOD-пороги, LRU, кэши, бюджет кадра
entity/ particle/ hud/ block/ text/ перенос из ZakoOpt по категориям
entity/ particle/ hud/ block/ text/ перенос из референса по категориям
gl/ GL-реализации: shared FBO, GpuFence, lazy clear, glCopyImageSubData
vulkan/ Vulkan-реализации: vkCmdCopyImage, VkPipelineCache, кэш состояния
profile/ профилировщик секций кадра
@ -33,7 +30,7 @@ optimize/
Выбор реализации один раз при старте через интерфейс `RenderBackendOps` (копирование текстур, фенсы, кольцо буфера).
## Что переносим (всё из ZakoOpt)
## Что переносим (всё из референса)
1. **GL/рендер-пайплайн (то, что реально есть в слоях 26.x/26.2):** Shared Frame Fence (`FrameFence`, `RealFence`), Lazy Clear,
FBO Sharing (`SharedDepthFbos`, `FrameBufferCacheMixin`, `GlBufferCloseMixin`), TBO Cache, RenderType Cache,
VertexConsumer Cache, Entity Sort Skip. Immediate Ring Buffer и Ring Auto-Tune существуют только в слоях 1.21.x, в 26.x их нет
@ -47,26 +44,26 @@ optimize/
6. **Текст:** Bidi Cache, Prepared Text Cache, StableText.
7. **Микро:** MemoryStack Cache, Sign Lookup Cache, Bubble Fluid Cache.
Поток кадра как у ZakoOpt: `FrameFence.endFrame` -> `SkinAtlas.endFrame` -> `AnimFreeze.frame++`
Поток кадра как в референсе: `FrameFence.endFrame` -> `SkinAtlas.endFrame` -> `AnimFreeze.frame++`
-> `OptConfig.refresh()` (флаги резолвятся один раз за кадр в примитивные поля).
## Что делаем лучше, чем в ZakoOpt
## Что делаем лучше, чем в референсе
1. **Единый бюджет кадра.** Один объект раздаёт лимиты LOD, частицам и нашим эффектам (ProjectileTrails, ElytraTrails,
DeathEffects, Blizzard, GodRays): дистанция, число точек и вершин на кадр. Режим «Выкл / Мягко / Агрессивно», по умолчанию
«Выкл», целевой FPS задаёт пользователь. Это не авто-деградация, прежнее решение (Фаза 9) не нарушается.
2. **Профилировщик секций** (сущности, частицы, HUD, эффекты, текст) в debug-оверлее.
3. **Один конфиг** в `Optimize`, без отдельного экрана ZakoOpt.
4. **Авто-порог параллельных частиц** по замеру (у ZakoOpt порог 4+ ядра взят из одного прогона).
3. **Один конфиг** в `Optimize`, без отдельного экрана референса.
4. **Авто-порог параллельных частиц** по замеру (в референсе порог 4+ ядра взят из одного прогона).
5. **Кэш статичных HUD-виджетов в текстуру** (перерисовка только при изменении).
6. Флаги для бенчмарков: `-Dlovisual.opt.<key>` (аналог `-Dzakoopt.<key>`).
6. Флаги для бенчмарков: `-Dlovisual.opt.<key>`.
## Дополнительные GL-оптимизации (наши, гипотезы)
Проверяем замером на 26.2 до включения по умолчанию.
- **Кэш состояния GL** шире, чем у ZakoOpt: пропуск повторных `glUseProgram`, `glBindVertexArray`, `glBindTexture`, `glBlendFunc`,
- **Кэш состояния GL** шире, чем в референсе: пропуск повторных `glUseProgram`, `glBindVertexArray`, `glBindTexture`, `glBlendFunc`,
`glDepthMask` (LoVisual уже имеет `GlStateManagerMixin`, расширяем).
- **Сортировка draw по pipeline и текстуре** внутри слоя там, где порядок не влияет на результат (наши квады `Renderer2D`, ленты trails).
- **Persistent mapped ring** для динамических вершин наших эффектов и HUD (`GL_ARB_buffer_storage`), если профиль покажет стоимость
`glBufferSubData`/map. Ring Auto-Tune из ZakoOpt берём как идею размера.
`glBufferSubData`/map. Ring Auto-Tune из референса берём как идею размера.
- **Батчинг HUD:** меньше смен пайплайна между виджетами (общий batch для `Renderer2D.COLOR`/`TEXTURE`).
- **Инвалидация глубины/stencil** (`glInvalidateFramebuffer`) после проходов, чьё содержимое не читается: выгодно на тайловых и мобильных
драйверах, на десктопе эффект мал.
@ -74,8 +71,8 @@ optimize/
- **Меньше FBO-переключений** в post-process цепочках (объединение проходов, общий ping-pong).
- **Uniform-блоки:** один обновляемый UBO на кадр вместо множества мелких обновлений (в LoVisual есть `DynamicUniformStorageMixin`).
## Разбор GL-части ZakoOpt для 26.x/26.2 (что реально делает код)
Файлы в `/storage/project/jvm/ZakoOpt/zakoopt/`:
## Разбор GL-части референса для 26.x/26.2 (что реально делает код)
Файлы референса:
- `26.x/.../gl/FrameFence.java` + `26.2/.../gl/RealFence.java`: один общий `GpuFence` на кадр вместо фенса на каждый запрос;
если его ждут до конца кадра, реальный фенс создаётся сразу, как в ваниле. Подмена через `GlCommandEncoderMixin.createFence`.
- `26.x/.../mixin/gl/GlCommandEncoderClearMixin.java` (Lazy Clear): после `clearColorAndDepthTextures` не делается лишний
@ -118,7 +115,7 @@ optimize/
## Риски
- Миксины 26.2 проверяются только компиляцией и запуском владельцем (`./gradlew runClient`), вживую я не смотрю.
- Совместимость с ImmediatelyFast и Iris в 26.2 не проверена.
- Лимиты размера файлов проекта потребуют дробить крупные классы ZakoOpt (фасад + package-private хелперы).
- Лимиты размера файлов проекта потребуют дробить крупные классы референса (фасад + package-private хелперы).
- Выигрыш по Vulkan не гарантирован, пока нет замеров.
## Вне объёма

View file

@ -1,17 +1,32 @@
package dev.loki.lovisual.features.module.modules.misc.optimize;
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.OptimizeState;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
/**
* Manual FPS rubilnik: one switch plus four knobs, each cutting a named cost.
* Manual FPS rubilnik: one switch plus a set of knobs, each cutting a named cost
* (the four original "rubilnik" cuts: no_glass, lite_post, lite_sky, lean_hud)
* OR toggling a transparent optimization (entity LOD,
* particle LOD, spawner tick-cache, etc.).
*
* <p>No auto-degradation by FPS — the owner's rule. Render code reads
* {@link OptimizeState}, never this class; this module only pushes state into it.
* {@link OptimizeState} (the original cuts) and {@link OptimizeToggles} (the
* new transparent helpers), never this class; this module only pushes
* state into them on enable/disable and once per frame.
*
* <p>Stage 1 of {@code mod/docs/optimize-layer-design.md}: the toggles below
* wire the engine-side state holders up; the actual hooks (mixins on
* {@code LivingEntityRenderer}, {@code ParticleGroup}, {@code SpawnerRenderer},
* {@code BlockEntity}, {@code ClientLanguage}, etc.) are added in Stage 3
* alongside the GL path. The helpers themselves (LRU, LOD math, workers, HUD
* hysteresis) are already in place under
* {@code features/module/modules/misc/optimize/{core,entity,particle,hud,block,text}/}.
*/
@ModuleInfo(
id = "optimize",
@ -20,11 +35,25 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeState;
)
public final class Optimize extends Module {
// --- Original "rubilnik" cuts: visible FPS trade-offs ---
private final BooleanValue noGlass = bool("no_glass", true);
private final BooleanValue litePost = bool("lite_post", true);
private final BooleanValue liteSky = bool("lite_sky", true);
private final BooleanValue leanHud = bool("lean_hud", true);
// --- transparent optimizations (no visual change) ---
private final BooleanValue entityLod = bool("entity_lod", true);
private final NumberValue<Integer> entityLodDistance =
num("entity_lod_distance", 35, 4, 256);
private final BooleanValue particleLod = bool("particle_lod", true);
private final BooleanValue spawnerCull = bool("spawner_cull", true);
private final NumberValue<Integer> spawnerDistance =
num("spawner_distance", 16, 4, 64);
private final BooleanValue spawnerTickCache = bool("spawner_tick_cache", true);
private final BooleanValue blockEntityCache = bool("block_entity_cache", true);
private final BooleanValue preparedTextCache = bool("prepared_text_cache", true);
private final BooleanValue microOpts = bool("micro_opts", true);
private boolean notified;
@Override
@ -41,6 +70,7 @@ public final class Optimize extends Module {
@Override
public void onDisable() {
OptimizeState.reset();
OptimizeToggles.reset();
}
/** Picks up knob changes made while the module stays on. */
@ -56,6 +86,16 @@ public final class Optimize extends Module {
on && litePost.get(),
on && liteSky.get(),
on && leanHud.get());
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());
}
/** Compact list of what is being cut, for the first-enable notice. */

View file

@ -0,0 +1,23 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.block;
/**
* Mixin-contract: per-tick cached block-light lookups on a {@code BlockEntity}.
* The vanilla {@code LightCoordsUtil.getLightCoords(level, pos)} call walks
* the chunk's light section data every frame per block-entity in view;
* caching it once per tick (block-light only changes on tick boundaries)
* removes that walk from the hot path. The companion {@code pairLight}
* methods cache the second lookup done for double-chests.
*/
public interface CachedLight {
int lovisual$light();
long lovisual$lightTick();
void lovisual$setLight(int light, long tick);
int lovisual$pairLight();
long lovisual$pairLightTick();
void lovisual$setPairLight(int light, long tick);
}

View file

@ -0,0 +1,25 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.block;
import net.minecraft.world.level.block.state.BlockState;
/**
* Mixin-contract: cached validity and "should-render" distance check on a
* {@code BlockEntity}. {@code BlockEntityType.isValid(state)} is called every
* frame per block-entity in view but only changes on a state transition —
* cached by identity on {@link #lovisual$validState()} / {@link #lovisual$valid()}.
* The companion {@code inDistance} methods cache
* {@code BlockEntityRenderer.shouldRender}'s distance check per tick.
*/
public interface CachedValidity {
BlockState lovisual$validState();
boolean lovisual$valid();
void lovisual$setValid(BlockState state, boolean valid);
long lovisual$distanceTick();
boolean lovisual$inDistance();
void lovisual$setInDistance(boolean inDistance, long tick);
}

View file

@ -0,0 +1,40 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.block;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
import net.minecraft.client.renderer.entity.state.EntityRenderState;
/**
* Utility layer over {@link TickCached} on a spawner ({@code BaseSpawner} via
* mixin): get/put the cached {@link EntityRenderState} for the spawner's
* display mob, keyed by tick, skipping the per-frame {@code extractEntity}
* rebuild. The (Stage 3) spawner renderer mixin populates this on the first
* frame of a tick and the subsequent frames in the same tick read back from
* the cache.
*
* <p>Takes the {@link TickCached} interface directly rather than the
* {@code BaseSpawner} class so the contract is testable without a
* Minecraft runtime — the Stage 3 mixin casts {@code (TickCached) spawner}
* before calling.
*
* <p>Reads {@link OptimizeToggles#spawnerTickCache()} to decide whether caching is enabled.
*/
public enum SpawnerCache {
;
/** True when the cache is engaged this frame. */
public static boolean enabled() {
return OptimizeToggles.spawnerTickCache();
}
/** Cached state for this tick, or {@code null} if not yet cached. */
public static EntityRenderState get(TickCached spawner, long tick) {
return spawner.lovisual$cachedTick() == tick
? (EntityRenderState) spawner.lovisual$cached()
: null;
}
/** Write the cached state for the given tick. */
public static void put(TickCached spawner, long tick, EntityRenderState state) {
spawner.lovisual$setCached(state, tick);
}
}

View file

@ -0,0 +1,17 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.block;
/**
* Mixin-contract: a generic per-tick cache slot on a target object
* (block-entity, spawner, piston moving block). Stores one cached value plus
* the tick it was written; the helper (e.g. {@link SpawnerCache}) checks
* {@link #lovisual$cachedTick()} against the current tick and reads
* {@link #lovisual$cached()} if it matches, otherwise lets the caller rebuild
* and writes the fresh value via {@link #lovisual$setCached(Object, long)}.
*/
public interface TickCached {
Object lovisual$cached();
long lovisual$cachedTick();
void lovisual$setCached(Object value, long tick);
}

View file

@ -0,0 +1,32 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.core;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Access-ordered LRU map with a size cap. Generic, no Minecraft dependencies,
* unit-testable as-is.
*
* <p>Intended for the {@code ClientLanguage.getVisualOrder} LRU cache.
*
* @param <K> key type
* @param <V> value type
*/
public final class LruMap<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LruMap(int maxSize) {
super(256, 0.75f, true);
this.maxSize = maxSize;
}
public int maxSize() {
return maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
}

View file

@ -0,0 +1,23 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.entity;
import net.minecraft.network.chat.Component;
/**
* Mixin-contract: cached rebuilt name tag for an entity.
*
* <p>The vanilla {@code Entity.getDisplayName()} rebuilds a {@link Component}
* (team prefix + name + suffix) every time it's called. Renderers call it once
* per frame per entity in view, which is wasteful — the team/name/suffix tuple
* only changes on tick boundaries. Mixins on {@code Entity} implement this
* interface, expose the cache slot via {@link #lovisual$name()}, {@link #lovisual$nameTick()}
* and {@link #lovisual$setName(Component, long)}, and the
* {@code EntityRenderer.getNameTag} wrapper fills it once per tick and reuses
* the cached value on intermediate frames.
*/
public interface CachedName {
Component lovisual$name();
long lovisual$nameTick();
void lovisual$setName(Component name, long tick);
}

View file

@ -0,0 +1,50 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.entity;
import org.lwjgl.system.MemoryStack;
/**
* Hot-path helper: skip the {@link MemoryStack#stackPush()} ThreadLocal lookup
* on the thread that called last (the render thread). The lookup is cheap on
* its own, but Sodium's entity renderer calls it once per cuboid per frame
* (thousands of times), and a single volatile read + identity compare is
* strictly cheaper than the ThreadLocal hash lookup.
*
* <p>Stores the {@code (thread, stack)} pair in one record so another thread
* never sees a half-updated pair. The first call from a new thread rewrites
* the volatile; subsequent calls from the same thread take the fast path.
*
* <p>Used by the (Stage 3) Sodium {@code EntityRenderer.renderCuboid} mixin.
*/
public enum CachedStack {
;
private record ThreadStack(Thread thread, MemoryStack stack) {
}
// One record so another thread never sees a thread/stack pair half-updated.
private static volatile ThreadStack cached = new ThreadStack(null, null);
/**
* Push a frame on the render thread's cached stack, falling back to the
* normal ThreadLocal lookup on a thread switch.
*/
public static MemoryStack push() {
ThreadStack current = cached;
Thread thread = Thread.currentThread();
if (current.thread() != thread) {
current = new ThreadStack(thread, MemoryStack.stackGet());
cached = current;
}
return current.stack().push();
}
/** Test-only: peek the cached thread (null before first call). */
static Thread cachedThread() {
return cached.thread();
}
/** Test-only: peek the cached stack (null before first call). */
static MemoryStack cachedStack() {
return cached.stack();
}
}

View file

@ -0,0 +1,37 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.entity;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
/**
* Entity LOD gate: is an entity far enough that the renderer should skip its
* layers (armour, held items, eyes, cape, elytra, stuck arrows) and freeze its
* animation? Compares squared distance to the configured threshold so the
* caller doesn't have to {@code Math.sqrt}.
*
* <p>The companion {@code farPlayer} exists so a config can drop
* players separately from entities (the {@code playerLod} toggle); in LoVisual
* Stage 1 we collapse the two — the same toggle gates both. Stage 2 may split
* them again if profiling shows players are the bigger cost.
*/
public enum EntityLod {
;
/** Squared threshold at and above which an entity is "far". */
public static double farThresholdSq() {
double d = OptimizeToggles.entityLodDistance();
return d * d;
}
/** True when LOD is on and the entity is beyond the configured distance. */
public static boolean far(double distanceSq) {
if (!OptimizeToggles.entityLod()) {
return false;
}
return distanceSq > farThresholdSq();
}
/** Same as {@link #far} — players and entities share one toggle in Stage 1. */
public static boolean farPlayer(double distanceSq) {
return far(distanceSq);
}
}

View file

@ -0,0 +1,19 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.entity;
import net.minecraft.world.entity.Entity;
/**
* Mixin-contract: back-reference the source {@link Entity} on its
* {@code EntityRenderState}. Render-states are rebuilt every frame, so they
* can't keep their own pointer to the entity — the
* {@code EntityRenderer.createRenderState} mixin captures it once per state
* construction via {@link #lovisual$entity(Entity)} and downstream renderers
* (model feature renderer, item feature renderer, future LOD mixins) read it
* back via {@link #lovisual$entity()} to reach the live entity for animation
* freeze, LOD distance checks, etc.
*/
public interface StateEntity {
Entity lovisual$entity();
void lovisual$entity(Entity entity);
}

View file

@ -0,0 +1,67 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.hud;
/**
* Hysteresis controller for the HUD cache. The HUD cache only saves work when
* several frames share one HUD refresh; near one frame per refresh (refresh
* interval ~= frame time) it redraws the HUD anyway and just adds a
* full-screen clear + blend, which costs real FPS on integrated GPUs and
* 60 Hz monitors. This controller tracks an EMA of frame time
* ({@code *0.95 + dt*0.05}) and flips the "worth it" state with hysteresis:
* turns OFF below {@code OFF_BELOW} frames-per-refresh, turns ON above
* {@code ON_ABOVE}.
*
* <p>Pure logic, no Minecraft dependencies, fully testable. The (Stage 3) HUD cache mixin reads
* {@link #worth()} to decide whether to engage.
*/
public enum HudWorth {
;
private static final double OFF_BELOW = 1.5;
private static final double ON_ABOVE = 2.0;
private static double frameNs;
private static long last;
private static volatile boolean worth = true;
/** True when the HUD cache is currently engaged. */
public static boolean worth() {
return worth;
}
/** True when the HUD cache should be available at all (independent of FPS). */
public static boolean hysteresisEnabled() {
return true;
}
/**
* Update the EMA frame time and the hysteresis state. Called once per
* frame; returns the new {@link #worth()} value.
*
* @param now the current {@code System.nanoTime()} reading
* @param refreshNs nanoseconds between HUD refreshes (1e9 / monitor Hz)
*/
public static boolean update(long now, long refreshNs) {
long dt = now - last;
last = now;
if (dt > 0 && dt < 1_000_000_000L) {
frameNs = frameNs == 0 ? dt : frameNs * 0.95 + dt * 0.05;
}
double framesPerRefresh = frameNs == 0 ? ON_ABOVE : refreshNs / frameNs;
if (worth ? framesPerRefresh < OFF_BELOW : framesPerRefresh > ON_ABOVE) {
worth = !worth;
}
return worth;
}
/** Test-only: reset EMA + hysteresis to defaults. */
static void reset() {
frameNs = 0;
last = 0;
worth = true;
}
/** Test-only: read the EMA frame time in nanoseconds. */
static double frameNs() {
return frameNs;
}
}

View file

@ -0,0 +1,50 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.particle;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
/**
* Per-frame thinning of far particles. Particles beyond 16 blocks are kept
* every other frame (32+ blocks: every fourth) using
* {@code System.identityHashCode(particle) >>> 4} as a stable per-particle
* salt so the choice doesn't flicker.
*
* <p>The hot-path API takes the camera position as primitives (no
* {@code Vec3} allocation); the {@code keep(Object, double)} overload is the
* pure-logic piece, testable without Minecraft types.
*
* <p>The {@code tick} field (sampled once per {@code ParticleEngine.render}) lives on the
* (Stage 3) particle mixin that owns the per-frame lifecycle; the pure-logic
* distance decision is here.
*/
public enum ParticleLod {
;
private static final double NEAR_SQ = 16.0 * 16.0;
private static final double MID_SQ = 32.0 * 32.0;
/**
* True when the particle should be kept this frame. The caller passes the
* particle's identity (for the per-particle salt) and the squared distance
* from the camera — both already computed by the renderer.
*/
public static boolean keep(Object particle, double distanceSq) {
if (!OptimizeToggles.particleLod()) {
return true;
}
if (distanceSq < NEAR_SQ) {
return true;
}
int h = System.identityHashCode(particle) >>> 4;
return distanceSq < MID_SQ ? (h & 1) == 0 : (h & 3) == 0;
}
/**
* Convenience overload: compute squared distance from primitives and
* dispatch to {@link #keep(Object, double)}.
*/
public static boolean keep(Object particle, double x, double y, double z,
double camX, double camY, double camZ) {
double dx = x - camX, dy = y - camY, dz = z - camZ;
return keep(particle, dx * dx + dy * dy + dz * dz);
}
}

View file

@ -0,0 +1,94 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.particle;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.IntConsumer;
/**
* Fixed-size daemon pool for parallel particle extraction. The number of
* helpers is {@code max(1, min(8, cores-2))} — leaving two cores for the
* render thread and the GC. {@link #run(int, IntConsumer)} splits the work
* into {@code blocks} units (caller decides the block size), dispatches them
* across helpers AND the calling thread (the caller works too — it only ever
* waits for blocks a helper has already claimed), and returns the first
* failure or {@code null} on success.
*
* <p>Used by the (Stage 3) {@code QuadParticleGroup} mixin to parallelise
* particle render-state extraction when the particle count exceeds 1024
* (Stage 1 ports the pool; the wiring is a later stage).
*
* <p>Pure java.util.concurrent, no Minecraft dependencies, fully testable.
*/
public final class Workers {
public static final int COUNT = Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors() - 2));
private static final AtomicInteger THREAD_ID = new AtomicInteger();
private static final ExecutorService POOL = Executors.newFixedThreadPool(COUNT, r -> {
Thread t = new Thread(r, "lovisual-opt-worker-" + THREAD_ID.incrementAndGet());
t.setDaemon(true);
return t;
});
private Workers() {
}
public static int poolSize() {
return COUNT;
}
/**
* Run {@code task(0..blocks-1)} across the pool plus the calling thread.
* Returns the first failure, or {@code null} if all blocks succeeded.
*/
public static Throwable run(int blocks, IntConsumer task) {
if (blocks <= 0) {
return null;
}
Job job = new Job(blocks, task);
for (int h = Math.min(COUNT, blocks - 1); h > 0; h--) {
POOL.execute(job::run);
}
// The caller works too; it only ever waits for blocks a helper has already claimed.
job.run();
while (job.completed.get() < blocks) {
Thread.onSpinWait();
}
return job.error.get();
}
/** Test-only: shut the pool down so the JVM can exit cleanly. */
public static void shutdown() {
POOL.shutdownNow();
}
private static final class Job {
final int blocks;
final IntConsumer task;
final AtomicInteger next = new AtomicInteger();
final AtomicInteger completed = new AtomicInteger();
final AtomicReference<Throwable> error = new AtomicReference<>();
Job(int blocks, IntConsumer task) {
this.blocks = blocks;
this.task = task;
}
void run() {
int b;
while ((b = next.getAndIncrement()) < blocks) {
try {
if (error.get() == null) {
task.accept(b);
}
} catch (Throwable t) {
error.compareAndSet(null, t);
} finally {
completed.incrementAndGet();
}
}
}
}
}

View file

@ -0,0 +1,79 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.text;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.util.FormattedCharSink;
import java.util.Collections;
import java.util.Set;
import java.util.WeakHashMap;
/**
* Marker {@link FormattedCharSequence} wrapper that lets later code recognise
* "this text won't change" by identity and skip BIDI re-shaping. The
* {@link #mark(FormattedCharSequence)} call wraps and registers the sequence
* in a {@link WeakHashMap}-backed set; {@link #isStable(FormattedCharSequence)}
* checks membership.
*
* <p>The fast path is a plain {@code instanceof}
* check on the wrapper; the slow path is a synchronised set lookup. LoVisual
* keeps both — Stage 1 wires them both to {@link OptimizeToggles#microOpts()}.
*/
public final class StableText implements FormattedCharSequence {
private static final Set<FormattedCharSequence> STABLE =
Collections.newSetFromMap(new WeakHashMap<>());
private final FormattedCharSequence text;
/** Package-private — use {@link #mark(FormattedCharSequence)} to construct. */
StableText(FormattedCharSequence text) {
this.text = text;
}
/**
* Wrap a {@link FormattedCharSequence} so {@link #isStable(FormattedCharSequence)}
* returns {@code true} for it. The wrapper is registered in a weak set so
* it doesn't leak when the caller drops its reference.
*/
public static FormattedCharSequence mark(FormattedCharSequence text) {
StableText stable = new StableText(text);
synchronized (STABLE) {
STABLE.add(stable);
}
return stable;
}
/**
* True when {@code text} is known not to change (so callers can skip BIDI
* re-shape and other per-frame work). Fast path: {@code instanceof StableText}
* when {@code microOpts} is on; slow path: synchronised set lookup otherwise.
*/
public static boolean isStable(FormattedCharSequence text) {
if (OptimizeToggles.microOpts()) {
return text instanceof StableText;
}
synchronized (STABLE) {
return STABLE.contains(text);
}
}
/** Test-only: number of currently-tracked stable sequences. */
static int trackedCount() {
synchronized (STABLE) {
return STABLE.size();
}
}
/** Test-only: clear the tracked set. */
static void clearTracked() {
synchronized (STABLE) {
STABLE.clear();
}
}
@Override
public boolean accept(FormattedCharSink sink) {
return text.accept(sink);
}
}

View file

@ -0,0 +1,76 @@
package dev.loki.lovisual.render.engine.optimize;
/**
* Resolved per-frame toggle snapshot for the Stage 1 optimization helpers
* (LOD distances, caches, micro-opts). Mirrors {@link OptimizeState}'s shape:
* an enum-utility holder with volatile fields, written by the {@code Optimize}
* module on enable/disable and once per frame, read by render code (helpers,
* future mixins) — never by the module class itself.
*
* <p>Stage 1 of {@code mod/docs/optimize-layer-design.md}: backend-independent
* logic only. GL/Vulkan-specific toggles (frame fence, lazy clear, FBO share,
* TBO cache, skin atlas, HUD cache) live in their own holders added in Stages
* 3-4 alongside the mixins that consume them.
*
* <p>All module settings are resolved into volatile primitive fields once per frame.
* System-property overrides are not supported here (the {@code Optimize} module's
* {@code bool()}/{@code num()} settings are the single source of truth); they can be
* layered back via {@code -Dlovisual.opt.*} in Stage 2 per the design doc.
*/
public enum OptimizeToggles {
;
private static volatile boolean entityLod;
private static volatile int entityLodDistance;
private static volatile boolean particleLod;
private static volatile boolean particleLight;
private static volatile boolean particlePhysics;
private static volatile boolean spawnerCull;
private static volatile int spawnerDistance;
private static volatile boolean spawnerTickCache;
private static volatile boolean blockEntityCache;
private static volatile boolean preparedTextCache;
private static volatile boolean microOpts;
public static boolean entityLod() { return entityLod; }
public static int entityLodDistance() { return entityLodDistance; }
public static boolean particleLod() { return particleLod; }
public static boolean particleLight() { return particleLight; }
public static boolean particlePhysics() { return particlePhysics; }
public static boolean spawnerCull() { return spawnerCull; }
public static int spawnerDistance() { return spawnerDistance; }
public static boolean spawnerTickCache() { return spawnerTickCache; }
public static boolean blockEntityCache() { return blockEntityCache; }
public static boolean preparedTextCache() { return preparedTextCache; }
public static boolean microOpts() { return microOpts; }
/**
* Push a full snapshot. Called by {@code Optimize.onEnable/onDisable/onFrame}
* with the resolved {@code (isEnabled() && knob.get())} for every toggle.
*/
public static void apply(
boolean entityLod, int entityLodDistance,
boolean particleLod, boolean particleLight, boolean particlePhysics,
boolean spawnerCull, int spawnerDistance,
boolean spawnerTickCache,
boolean blockEntityCache,
boolean preparedTextCache,
boolean microOpts) {
OptimizeToggles.entityLod = entityLod;
OptimizeToggles.entityLodDistance = entityLodDistance;
OptimizeToggles.particleLod = particleLod;
OptimizeToggles.particleLight = particleLight;
OptimizeToggles.particlePhysics = particlePhysics;
OptimizeToggles.spawnerCull = spawnerCull;
OptimizeToggles.spawnerDistance = spawnerDistance;
OptimizeToggles.spawnerTickCache = spawnerTickCache;
OptimizeToggles.blockEntityCache = blockEntityCache;
OptimizeToggles.preparedTextCache = preparedTextCache;
OptimizeToggles.microOpts = microOpts;
}
/** Reset to "nothing on" — used by {@code Optimize.onDisable}. */
public static void reset() {
apply(false, 0, false, false, false, false, 0, false, false, false, false);
}
}