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:
parent
4a722259a5
commit
1a50a757b7
17 changed files with 703 additions and 19 deletions
15
mod/NOTICE
Executable file
15
mod/NOTICE
Executable 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/>.
|
||||
|
|
@ -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 не гарантирован, пока нет замеров.
|
||||
|
||||
## Вне объёма
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue