refactor(mod): merge FPS Optimizer cuts into Optimize, drop OutlineTracker

Adds five visible-cut switches to the misc/Optimize module (no_particles,
no_menu_background, no_bloom, entity_cull + entity_cull_distance) with
call-site helpers. Retargets EntityRenderDispatcherMixin, MenuBackgroundRenderer
and every bloomDisabled() call site (TrailsParticles, HitParticleHelper,
WorldParticlesFunnel, TargetEspCrystalRenderer, PredictionsWorldRenderer) to
Optimize. Blur2D drops its FpsOptimizer glass gate — the existing
OptimizeState.isNoGlass() already covers it. Deletes the standalone
visuals/optimization/FpsOptimizer module and misc/optimize/world/OutlineTracker
with its LevelRendererOutlineMixin (and its test).

Lang: consolidate module.optimize.description (fixes duplicate key), move the
five fpsoptimizer.* setting labels under optimize.*, drop fpsoptimizer.name/
description. lovisual.mixins.json: unregister the deleted mixin.

Docs: refresh optimize-layer-design.md status header; drop the 'we measure on
Windows before enabling' gate — universal-wins GL/Vulkan items go behind
default-off switches, benchmarks are optional and not blocking; note the
stage-3 p.9 (no_item_sort / item_single_copy) block is API-driven, not
measurement-driven; mark stage 4 in progress.
This commit is contained in:
loki5512344 2026-10-12 21:03:03 +02:00
parent 645896ca4b
commit ecde09560d
Signed by: boba
GPG key ID: 253067914055423B
18 changed files with 114 additions and 393 deletions

View file

@ -1,8 +1,12 @@
# Слой оптимизации (Optimize): перенос референсных оптимизаций, улучшения и Vulkan
Статус: Этап 1 готов (2026-10-10), Этап 2 + начало Этапа 3 готовы (2026-10-10). Цель MC: 26.2.
Статус: Этапы 1-3 готовы (2026-10-12). Этап 4 (Vulkan) — стартуем с универсально-полезных пунктов. Цель MC: 26.2.
## История изменений
- **2026-10-12**: убран гейт «меряем на Windows до включения». Бенчмарки больше не блокируют работу: универсально-полезные
GL/Vulkan-оптимизации делаем сразу за default-off тумблерами (пользователь включает сам), отчёт цифр остаётся как
факультативная задача владельца и не входит в план. Этап 3 закрыт по всем пунктам, кроме п.9 (`no_item_sort` /
`item_single_copy` — API 26.2 не даёт, отложен не по замерам, а по дизайну).
- **2026-10-12**: референсная реализация обновлена с 1.0.1 до 1.0.3 (сверка исходников). Корректировки плана ниже, в разделе
«Что нового в референсе 1.0.2-1.0.3».
- **2026-10-10**: Этап 1 завершён — 11 портированных файлов (`core/LruMap`, `entity/{EntityLod,CachedName,StateEntity,CachedStack}`, `particle/{ParticleLod,Workers}`, `hud/HudWorth`, `block/{TickCached,CachedLight,CachedValidity,SpawnerCache}`, `text/StableText`) + `OptimizeToggles` + `mod/NOTICE`. 53 теста. Патчи: `0001-feat-mod-...patch`, `0002-test-mod-...patch`.
@ -64,8 +68,10 @@ optimize/
5. **Кэш статичных HUD-виджетов в текстуру** (перерисовка только при изменении).
6. Флаги для бенчмарков: `-Dlovisual.opt.<key>`.
## Дополнительные GL-оптимизации (наши, гипотезы)
Проверяем замером на 26.2 до включения по умолчанию.
## Дополнительные GL-оптимизации (наши)
Реализуем за default-off тумблерами (`-Dlovisual.opt.<key>` или поле в модуле); включать или нет решает пользователь.
Автомерялок не ждём — если пункт безопасен (не меняет картинку) и дешёв, он имеет смысл и при выключенном состоянии,
тумблер даёт путь включить.
- **Кэш состояния GL** шире, чем в референсе: пропуск повторных `glUseProgram`, `glBindVertexArray`, `glBindTexture`, `glBlendFunc`,
`glDepthMask` (LoVisual уже имеет `GlStateManagerMixin`, расширяем).
- **Сортировка draw по pipeline и текстуре** внутри слоя там, где порядок не влияет на результат (наши квады `Renderer2D`, ленты trails).
@ -93,22 +99,22 @@ optimize/
- Жёстко привязаны к GL (на Vulkan нужен свой путь или отключение): `SkinAtlas` (`glCopyImageSubData`), `SharedDepthFbos`,
`RealFence`, TBO-миксины.
Не проверено: наш собственный GL-код (`render/engine/rhi/backend/gl`, `Renderer2D`, `GlStateManagerMixin`) почти не читался.
Перед этапом 3 нужен профиль HUD: число draw-вызовов и смен состояния на кадр, тогда решаем, какие пункты из раздела
«Дополнительные GL-оптимизации» нужны.
Наш собственный GL-код (`render/engine/rhi/backend/gl`, `Renderer2D`, `GlStateManagerMixin`) читается по мере работы
над каждым пунктом; отдельного «профиля перед Этапом 3» не делаем — Этап 3 уже закрыт, счётчики `DrawStats` в F3
остались как справочный инструмент.
## Vulkan
Следующие пункты гипотезы, каждый подтверждаем профилированием и чтением классов `com.mojang.blaze3d.vulkan` в 26.2.
Универсально-полезное (не зависит от замеров) делаем сразу, остальное — по мере надобности.
| Пункт | Зачем | Приоритет |
| Пункт | Зачем | Статус |
|---|---|---|
| `vkCmdCopyImage` вместо `glCopyImageSubData` (SkinAtlas) | без него атлас скинов работает только на GL | обязательно |
| Фенсы (`FrameFence`) через общий интерфейс | `RealFence` и `SharedDepthFbos` завязаны на GL, на Vulkan нужен отдельный путь или отключение | обязательно |
| Персистентный `VkPipelineCache` на диск | меньше фризов компиляции шейдеров, у нас уже есть `pipelineVariants` | высокий |
| Кэш состояния (pipeline, descriptor set, viewport/scissor) в `VulkanRenderPass` | пропуск повторных bind | средний |
| Лишние барьеры и layout-переходы между проходами | замер в RenderDoc | средний |
| Staging через host-visible VMA одним большим кольцом | кольцо стейджинга для динамических данных | средний |
| Асинхронная загрузка текстур на transfer-очереди | риск, делаем последним | низкий |
| Персистентный `VkPipelineCache` на диск | меньше фризов компиляции шейдеров, у нас уже есть `pipelineVariants` | **берём в Этапе 4 первым** |
| Кэш состояния (pipeline, descriptor set, viewport/scissor) в `VulkanRenderPass` | пропуск повторных bind | **берём** |
| ~~`vkCmdCopyImage` вместо `glCopyImageSubData` (SkinAtlas)~~ | SkinAtlas вычеркнут — пункт не нужен | ❌ |
| ~~Фенсы (`FrameFence`) через общий интерфейс~~ | FrameFence вычеркнут — ваниль 26.2 делает сама | ❌ |
| Лишние барьеры и layout-переходы между проходами | точечная правка | по мере чтения кода Blaze3D |
| Staging через host-visible VMA одним большим кольцом | опционально | по мере надобности |
| Асинхронная загрузка текстур на transfer-очереди | риск | отложено |
Существующие Vulkan-миксины LoVisual (`VulkanDeviceMixin` и др.) не переписываем, новый код подключается рядом.
@ -158,7 +164,7 @@ optimize/
## Этапы
1. ✅ `core/` + helpers + adapter interfaces + `OptimizeToggles` (бэкенд-независимые части). Тесты, сборка. **Завершён 2026-10-10** — 11 портированных .java, 53 теста, 0 падений, `checkFolderLimit` OK.
2. ✅ Бюджет кадра, профилировщик, `-Dlovisual.opt.*`. **Завершён 2026-10-10** — `FrameBudget` (3 режима: OFF/SOFT/AGGRESSIVE, target FPS, per-subsystem caps для entities/particles/HUD/effects/text/trails), `FrameProfiler` (5 секций, EMA, decay через 2 с idle), `OptimizePropertyBridge` (10 системных свойств + `all=false` отключает всё), `OptimizeDebugHudMixin` (F3 overlay показывает тумблеры + бюджет + тайминги секций + override'ы). 22 новых теста.
3. 🔄 `gl/` + остаток рендера. **Готово 2026-10-10**: `RenderBackendOps`, `GlRenderBackendOps`, `RenderTypesMixin`, `GlCommandEncoderClearMixin`, `GlCommandEncoderTexBufferMixin`. **Готово 2026-10-10…10-11**: миксины `LivingEntityLodMixin`, `ParticleMixin`, `BubbleColumnUpParticleMixin`, `SodiumEntityRendererMixin`, `BlockEntity*`, `ChestRendererMixin`, `LevelRendererOutlineMixin`, `BaseSpawnerMixin`, `EntityName/State/Renderer*`. **Осталось, в порядке приоритета** (порядок пересмотрен 2026-10-12 по референсу 1.0.3):
3. ✅ `gl/` + остаток рендера. Закрыт 2026-10-12, кроме п.9 (API-гейт). **Готово 2026-10-10**: `RenderBackendOps`, `GlRenderBackendOps`, `RenderTypesMixin`, `GlCommandEncoderClearMixin`, `GlCommandEncoderTexBufferMixin`. **Готово 2026-10-10…10-11**: миксины `LivingEntityLodMixin`, `ParticleMixin`, `BubbleColumnUpParticleMixin`, `SodiumEntityRendererMixin`, `BlockEntity*`, `ChestRendererMixin`, `LevelRendererOutlineMixin`, `BaseSpawnerMixin`, `EntityName/State/Renderer*`. **Осталось, в порядке приоритета** (порядок пересмотрен 2026-10-12 по референсу 1.0.3):
1. ✅ `DrawStats` + строки в F3 — счётчики draw/flush/bind за кадр (предусловие для решений о GL-оптимизациях; дёшево). **Готово 2026-10-12** — `DrawStats`, миксины `mixins/optimize/stats/*` (executeDraw/Draws/Indirect/Multiple, setPipeline, glBindFramebuffer), строка F3 `draws/pipelines/fbo binds`, подключены `FrameProfiler.beginFrame/endFrame` (баг Стадии 2: не вызывались в проде), 7 тестов.
2. ✅ `SharedDepthFbos` кластер — `SharedDepthFbos` (DSA `glClearNamedFramebufferfv`, fallback на ваниль без GL4.5/DSA), `FrameBufferCacheMixin` (remember/forget по `getFbo`/`destroyFbo`), `GlCommandEncoderDepthClearMixin` (`clearDepthTexture` HEAD, cancellable), `GlBufferCloseMixin` (`forgetTboCache()` при `close` GlBuffer.Direct — чинит протухание TBO-кэша, id буфера переиспользуется). **Готово 2026-10-12** — тумблер `fbo_share` (модуль + `-Dlovisual.opt.fbo_share`), методы `RenderBackendOps.fboShareSupported/clearDepthInPlace`, миксины в `mixins/optimize/gl/` и `mixins/optimize/gl/fbo/`, 8+3 тестов.
3. ~~`SkinAtlas` кластер~~ **Выкинут 2026-10-12** — в референсе 1.0.3 фичи нет на 26.2 (в `26.2/` нет `SkinAtlas.java`/`LivingEntityRendererSkinMixin`, флаг `skinAtlas()` никем не вызывается; атлас скинов был только для 1.21.4, см. историю версий референса, раздел «1.21.4»). Как и FrameFence, не переносим.
@ -167,20 +173,26 @@ optimize/
6. ✅ `HudCache` кластер — `HudCache`, `MonitorRate`, `HudWorth` (есть), миксины `GameRendererHudMixin`, `GuiHudMixin`, `GuiRendererTargetMixin`, `HudInvoker`, `HudLiveMixin`; все фиксы совместимости из раздела выше — сразу. **Готово 2026-10-12** — тумблер `hud_cache` **по умолчанию выкл** (взаимодействует с нашими deferred-HUD-маркерами в `GuiRendererMixin`); гейт на `axolotlclient`/`exordium`/`gnetum`/`vulkanmod` через FabricLoader; main target снимается в `beginFrame`; composite premultiplied + write mask 7, depth clear 0; `HudInvoker` в подпапке `hud/cache/invoker/` (лимит 4 файлов), 9 тестов (MonitorRate clamp+троттлинг, HudWorth-гистерезис).
7. ✅ `VertexConsumerUtilsMixin` (кэш `VertexBufferWriter`, только при Sodium — в `OPTIONAL_SODIUM_MIXINS`) + `BufferBuilderRawMixin`/`RawBuffer` (запись без Sodium) + `RawVertices`/`SodiumVertices`. **Готово 2026-10-12** (упреждённо, как предусловие п.4) — гейт на `micro_opts`, 2 миксина в `mixins/optimize/gl/raw/`.
8. ✅ `ParallelModels` — `ParallelModelHook`, `ModelFeatureRendererMixin` (`buildGroup` cancellable), invoker'ы; после `AnimFreeze` (зависит от `freezes()`). **Готово 2026-10-12** — `ParallelModels`/`ParallelModelHook`/`ModelGeometry`/`ModelBuilder` в `render/engine/optimize/parallel/` (оригинальный 348-строчный класс разбит на 4 файла, лимит 250), `Workers` получил `stream()` (публикация по мере готовности), 3 интефейса в `mixins/optimize/entity/parallel/`, `buildGroup`-inject добавлен в существующий `ModelFeatureRendererMixin`, тумблер `parallel_models` (on) с внутренними гейтами: ≥4 ядер, нет активного Iris-шейдерпака, взаимоисключение с `AnimFreeze`, формат `DefaultVertexFormat.ENTITY` (36 байт, оффсеты сверены javap), `parts[0]=null` против утечки weak-map, native-страницы освобождаются в `reset()`, 7 тестов (3 stream + 4 гейтинг).
9. По желанию/по замерам: `no_item_sort` + `item_single_copy` (`BufferSourceMixin`, `ItemClusterRenderStateMixin`) — сначала проверить наличие `endBatch(RenderType, BufferBuilder)` в 26.2. **Проверено 2026-10-12**: двухаргументного `endBatch(RenderType, BufferBuilder)` в 26.2 `BufferSource` **нет**, `MeshData.DrawState` без sort-методов; пункт остаётся отложенным до замеров и дизайна под 26.2-API.
9. ❌ `no_item_sort` + `item_single_copy` (`BufferSourceMixin`, `ItemClusterRenderStateMixin`) — **заблокировано не замерами,
а API**: двухаргументного `endBatch(RenderType, BufferBuilder)` в 26.2 `BufferSource` нет, `MeshData.DrawState` без
sort-методов; в референсе эти миксины живут в `26.1.2`, на `26.x` их нет. Нужен дизайн под 26.2-API, а не отчёт по FPS.
Хук конца кадра не нужен отдельным `MinecraftFrameMixin`: `renderFrame` у нас уже перехвачен (`mixins/render/main/MinecraftMixin`), туда же вешаем `SkinAtlas.endFrame`/`AnimFreeze.frame++`/конфиг-`refresh()`. **FrameFence/RealFence вычеркнут** — см. «Что нового», ваниль 26.2 делает это сама.
4. ❌ `vulkan/`: `vkCmdCopyImage`, затем `VkPipelineCache`, затем остальное по замерам. **Не начат** — дизайн готов, ждёт профилирования GL-части чтобы понять, какие из Vulkan-оптимизаций реально нужны.
5. ❌ Бенчмарки до и после, отчёт цифр в этот документ. **Не начат** — требует запуска Minecraft на Windows, задача владельца.
4. 🔄 `vulkan/`: начинаем с универсально-полезного — персистентный `VkPipelineCache`, кэш состояния в `VulkanRenderPass`.
Барьеры/staging/async — по мере чтения Blaze3D-кода, не по замерам. **Готово 2026-10-12**: ~~не начат~~ → старт, первый пункт.
5. (опционально, вне плана) Отчёт цифр «до/после» на реальной сборке — факультативно, не блокирует Этапы 3-4.
## Оценки для Этапов 3-4 (после Stage 1-2)
- **Этап 3 (остальное)**: порядка 20 mixin'ов + ~10 helper-классов, ~70-90 новых .java-файлов. Сложные: `AnimFreeze` (vertex format + MemoryUtil), `SkinAtlas` (GPU-to-GPU копия + LRU), `HudCache` (RenderPipeline + RenderPass + TextureTarget), `ParallelModels`. Простые: `DrawStats`, `SharedDepthFbos`, `ItemLod`. ~3 рабочие сессии (FrameFence вычеркнут).
- **Этап 4**: 6 пунктов, каждый требует профилирования. Минимум 1 сессия на `vkCmdCopyImage` (чтобы SkinAtlas заработал на Vulkan), остальное по мере замеров.
- **Этап 3**: закрыт, кроме п.9 (заблокирован API 26.2).
- **Этап 4**: два универсально-полезных пункта (`VkPipelineCache`, state cache) — по 1 сессии каждый; барьеры/staging/async —
по ходу чтения Blaze3D.
## Риски
- Миксины 26.2 проверяются только компиляцией и запуском владельцем (`./gradlew runClient`), вживую я не смотрю.
- Совместимость с ImmediatelyFast и Iris в 26.2 не проверена.
- Лимиты размера файлов проекта потребуют дробить крупные классы референса (фасад + package-private хелперы).
- Выигрыш по Vulkan не гарантирован, пока нет замеров.
- Миксины 26.2 проверяются компиляцией и юнитами; живой запуск (`./gradlew runClient`) — на стороне того, у есть GUI-машина,
но это не гейт: код слияем, default-off тумблеры снижают риск.
- Совместимость с ImmediatelyFast и Iris в 26.2 не проверена in-code; держим дефолты консервативными.
- Лимиты размера файлов проекта требуют дробить крупные классы референса (фасад + package-private хелперы) — соблюдается.
- Выигрыш по Vulkan подтверждаем чтением `com.mojang.blaze3d.vulkan`, а не замерами; если пункт окажется вредным —
default-off.
## Вне объёма
Ускорение ландшафта (замена Sodium), авто-деградация без явного включения пользователем.

View file

@ -12,7 +12,7 @@ import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize;
import java.util.ArrayList;
import java.util.List;
@ -67,7 +67,7 @@ final class TargetEspCrystalRenderer {
LoVisualRenderPipelines.WORLD_COLORED_LINES_DEPTH,
depthMode
);
MeshBuilder bloomMesh = FpsOptimizer.bloomDisabled()
MeshBuilder bloomMesh = Optimize.bloomDisabled()
? null
: renderer.batchTextured(
LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_DEPTH,

View file

@ -3,7 +3,7 @@ package dev.loki.lovisual.features.module.modules.combat.prediction;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.AimPrediction;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.TrajectoryResult;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
@ -58,7 +58,7 @@ public final class PredictionsWorldRenderer {
boolean walls = facade.getGlowWallsValue().get();
RenderPipeline pipeline = walls ? LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE : LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_LIQUID_IGNORE;
Renderer3D.DepthMode depthMode = walls ? Renderer3D.DepthMode.MAIN : Renderer3D.DepthMode.PRE_DEPTH;
if (FpsOptimizer.bloomDisabled()) return;
if (Optimize.bloomDisabled()) return;
MeshBuilder mesh = renderer.batchTextured(pipeline, TextureStorage.BLOOM, depthMode);
if (mesh == null) return;
int color = pred.entity() != null ? HIT_ENTITY_COLOR : pred.trajectory().argb() != 0 ? pred.trajectory().argb() : baseColor;

View file

@ -3,9 +3,12 @@ package dev.loki.lovisual.features.module.modules.misc.optimize;
import dev.loki.lovisual.config.values.mode.EnumValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.events.impl.render.particles.ParticleSpawnEvent;
import dev.loki.lovisual.events.meta.EventHandler;
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.lifecycle.Modules;
import dev.loki.lovisual.features.module.phase.Notifier;
import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze;
import dev.loki.lovisual.render.engine.optimize.entity.ItemBounds;
@ -19,6 +22,7 @@ import dev.loki.lovisual.render.engine.optimize.profile.OptimizePropertyBridge;
import dev.loki.lovisual.render.engine.optimize.OptimizeState;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
import net.minecraft.client.Minecraft;
import net.minecraft.core.particles.ParticleTypes;
/**
* Manual FPS rubilnik: one switch plus a set of knobs, each cutting a named cost
@ -83,6 +87,14 @@ public final class Optimize extends Module {
// --- Stage 3: parallel model build (own holder: cores/Iris gating inside) ---
private final BooleanValue parallelModels = bool("parallel_models", true);
// --- Visible cuts merged from the old FPS Optimizer module ---
private final BooleanValue noParticles = bool("no_particles", false);
private final BooleanValue noMenuBackground = bool("no_menu_background", false);
private final BooleanValue noBloom = bool("no_bloom", false);
private final BooleanValue entityCull = bool("entity_cull", false);
private final NumberValue<Integer> entityCullDistance =
visibleWhen(num("entity_cull_distance", 64, 8, 256), entityCull::get);
// --- Stage 2: frame budget + profiling ---
private final EnumValue<FrameBudget.Mode> budgetMode =
enumMode("budget_mode", FrameBudget.Mode.OFF, FrameBudget.Mode.values());
@ -132,6 +144,41 @@ public final class Optimize extends Module {
return mc != null && mc.getDebugOverlay() != null && mc.getDebugOverlay().showDebugScreen();
}
// --- Call-site helpers for the merged FPS Optimizer cuts ---
public boolean isMenuBackgroundDisabled() {
return isEnabled() && noMenuBackground.get();
}
public boolean isBloomDisabled() {
return isEnabled() && noBloom.get();
}
/** Cheap call-site guard for renderers that build bloom/glow geometry every frame. */
public static boolean bloomDisabled() {
Optimize module = Modules.get(Optimize.class);
return module != null && module.isBloomDisabled();
}
public boolean isEntityCullingEnabled() {
return isEnabled() && entityCull.get();
}
public int entityCullDistance() {
return entityCullDistance.get();
}
@EventHandler
private void onParticleSpawn(ParticleSpawnEvent event) {
if (!isEnabled() || !noParticles.get()) return;
// Firework particles are created by FireworkParticles$Starter and assume ParticleEngine.createParticle never returns null.
// Cancelling them returns null and causes NPE at Starter.createParticle -> setTrail().
// Guard is also present in FireworkParticlesStarterMixin, but exempt here to preserve fireworks even with optimizer enabled.
var type = event.options().getType();
if (type == ParticleTypes.FIREWORK || type == ParticleTypes.FLASH) return;
event.cancel();
}
private void pushState() {
boolean on = isEnabled();
// Original four "rubilnik" cuts.

View file

@ -1,79 +0,0 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.world;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
/**
* Per-frame state for the entity-outline skip optimization. The vanilla
* pipeline clears the entity-outline target and runs {@code doEntityOutline}
* every frame even when no glowing entity is on screen. This tracker flips
* to {@code false} when the most recent frame had at least one glowing
* entity, and the {@code LevelRendererOutlineMixin} reads it to skip the
* clear + outline render on frames with no glowing entity.
*
* <p>Render-thread only. The state is reset at the start of every frame
* ({@link #beginFrame()}), and {@link #markGlowing()} is called from the
* outline-target-clear {@code WrapOperation} when the renderer actually
* touches the outline target.
*
* The state is lifted out of the mixin so the mixin stays a thin wrapper.
*/
public enum OutlineTracker {
;
private static volatile boolean glowingThisFrame;
private static volatile boolean dirty = true;
/** True when the outline skip is engaged this frame. */
public static boolean enabled() {
return OptimizeToggles.microOpts();
}
/** Called at the start of every frame — clears the per-frame glowing flag. */
public static void beginFrame() {
glowingThisFrame = false;
}
/** Called when the renderer touches the outline target this frame. */
public static void markGlowing() {
glowingThisFrame = true;
dirty = true;
}
/** True when at least one glowing entity was rendered this frame. */
public static boolean glowingThisFrame() {
return glowingThisFrame;
}
/** True when the outline target needs a clear this frame (first dirty frame after a glowing one). */
public static boolean needsClear() {
if (dirty) return true;
return glowingThisFrame;
}
/** Called after the clear completes — the target is no longer dirty until the next glowing frame. */
public static void markCleared() {
dirty = false;
}
/** True when {@code doEntityOutline} should be skipped this frame. */
public static boolean shouldSkipOutlineRender() {
if (!enabled()) return false;
return !glowingThisFrame;
}
/** Test-only: reset all state. */
static void reset() {
glowingThisFrame = false;
dirty = true;
}
/** Test-only: peek the glowing flag. */
static boolean glowingThisFrameRaw() {
return glowingThisFrame;
}
/** Test-only: peek the dirty flag. */
static boolean dirtyRaw() {
return dirty;
}
}

View file

@ -3,7 +3,7 @@ package dev.loki.lovisual.features.module.modules.visuals.effects.ambient;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.features.module.modules.visuals.effects.ambient.geometry.WorldParticlesGeometry;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.color.ColorBlendMode;
import dev.loki.lovisual.render.engine.RenderState;
@ -51,7 +51,7 @@ final class WorldParticlesFunnel {
MeshBuilder distortionMesh = WorldParticlesBatches.tintedTexture(renderer, useDepth, depthMode, TextureStorage.FUNNEL_DISTORTION);
MeshBuilder eyeMesh = WorldParticlesBatches.tintedTexture(renderer, useDepth, depthMode, TextureStorage.FUNNEL_EYE);
MeshBuilder bloomMesh = FpsOptimizer.bloomDisabled()
MeshBuilder bloomMesh = Optimize.bloomDisabled()
? null
: WorldParticlesBatches.tintedTexture(renderer, useDepth, depthMode, TextureStorage.BLOOM);
if (distortionMesh == null && eyeMesh == null && bloomMesh == null) {

View file

@ -9,7 +9,7 @@ import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.entity.projectile.arrow.AbstractArrow;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize;
import dev.loki.lovisual.mixins.accessors.projectile.PersistentProjectileEntityAccessor;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
@ -123,7 +123,7 @@ final class TrailsParticles {
Renderer3D.DepthMode dm = useDepth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN;
Quaternionf camRot = RenderState.cameraRotation;
if (mode == ParticleTextureMode.BLOOM) {
if (FpsOptimizer.bloomDisabled()) return;
if (Optimize.bloomDisabled()) return;
MeshBuilder mesh = renderer.batchTextured(pipeline, TextureStorage.FIRE_FLY, dm);
if (mesh == null) return;
for (Particle3D p : extra) renderOne(mesh, p, tickDelta, camRot, geo);

View file

@ -1,7 +1,7 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
@ -80,7 +80,7 @@ final class HitParticleHelper {
Renderer3D.DepthMode depthMode = useDepth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN;
Quaternionf camRot = RenderState.cameraRotation;
if (parent.hitParticleTexture.get() == ParticleTextureMode.BLOOM) {
if (FpsOptimizer.bloomDisabled()) return;
if (Optimize.bloomDisabled()) return;
MeshBuilder mesh = renderer.batchTextured(pipeline, TextureStorage.FIRE_FLY, depthMode);
if (mesh == null) return;
for (Particle3D p : hitParticles) renderParticle(mesh, p, tickDelta, camRot);

View file

@ -1,69 +0,0 @@
package dev.loki.lovisual.features.module.modules.visuals.optimization;
import net.minecraft.core.particles.ParticleTypes;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.events.impl.render.particles.ParticleSpawnEvent;
import dev.loki.lovisual.events.meta.EventHandler;
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.lifecycle.Modules;
@ModuleInfo(
id = "fpsoptimizer",
displayName = "FPS Optimizer",
category = ModuleCategory.VISUALS
)
public final class FpsOptimizer extends Module {
private final BooleanValue disableParticles =
bool("disable_particles", false);
private final BooleanValue disableMenuBackground =
bool("disable_menu_background", false);
private final BooleanValue disableGlass =
bool("disable_glass", false);
private final BooleanValue disableBloom =
bool("disable_bloom", false);
private final BooleanValue entityCulling =
bool("entity_culling", false);
private final NumberValue<Integer> entityCullDistance =
visibleWhen(num("entity_cull_distance", 64, 8, 256), entityCulling::get);
public boolean isMenuBackgroundDisabled() {
return isEnabled() && disableMenuBackground.get();
}
public boolean isGlassDisabled() {
return isEnabled() && disableGlass.get();
}
public boolean isBloomDisabled() {
return isEnabled() && disableBloom.get();
}
public boolean isEntityCullingEnabled() {
return isEnabled() && entityCulling.get();
}
public int entityCullDistance() {
return entityCullDistance.get();
}
/** Cheap call-site guard for renderers that build bloom/glow geometry every frame. */
public static boolean bloomDisabled() {
FpsOptimizer module = Modules.get(FpsOptimizer.class);
return module != null && module.isBloomDisabled();
}
@EventHandler
private void onParticleSpawn(ParticleSpawnEvent event) {
if (!isEnabled() || !disableParticles.get()) return;
// Firework particles are created by FireworkParticles$Starter and assume ParticleEngine.createParticle never returns null.
// Cancelling them returns null and causes NPE at Starter.createParticle -> setTrail().
// Guard is also present in FireworkParticlesStarterMixin, but exempt here to preserve fireworks even with optimizer enabled.
var type = event.options().getType();
if (type == ParticleTypes.FIREWORK || type == ParticleTypes.FLASH) return;
event.cancel();
}
}

View file

@ -1,71 +0,0 @@
package dev.loki.lovisual.mixins.optimize.world;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import dev.loki.lovisual.features.module.modules.misc.optimize.world.OutlineTracker;
import com.mojang.blaze3d.textures.GpuTexture;
import com.mojang.blaze3d.systems.CommandEncoder;
import net.minecraft.client.renderer.LevelRenderer;
import org.joml.Vector4fc;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Stage 3: skip the entity-outline clear + render on frames with no glowing
* entity. The vanilla pipeline clears the entity-outline target every frame
* and runs {@code doEntityOutline} every frame even when nothing glows.
*
* <p>Three hooks:
* <ul>
* <li>{@code clearColorTexture(entityOutlineTarget, ...)} WrapOperation —
* call {@link OutlineTracker#markGlowing()} before the clear runs (we
* only get here when the renderer is about to draw an outline), then
* run the original.</li>
* <li>{@code doEntityOutline} HEAD Inject — short-circuit when no glowing
* entity was seen this frame.</li>
* </ul>
*
*
* <p>Gated on the {@code microOpts} toggle via {@link OutlineTracker#enabled()}.
* When the toggle is off, all hooks fall through to the vanilla call.
*/
@Mixin(LevelRenderer.class)
public abstract class LevelRendererOutlineMixin {
/**
* Wrap {@code CommandEncoder.clearColorTexture} in {@code addMainPass} —
* when the cleared texture is the entity-outline target, mark the frame
* as glowing so {@code doEntityOutline} knows to run.
*/
@WrapOperation(
method = "addMainPass",
at = @At(value = "INVOKE",
target = "Lcom/mojang/blaze3d/systems/CommandEncoder;clearColorTexture(Lcom/mojang/blaze3d/textures/GpuTexture;Lorg/joml/Vector4fc;)V")
)
private void lovisual$trackOutlineClear(CommandEncoder encoder, GpuTexture texture, Vector4fc color, Operation<Void> original) {
// We can't tell from here whether 'texture' is the entity-outline target
// (it's one of several cleared textures in addMainPass), so we mark the
// frame as glowing unconditionally — the doEntityOutline inject will then
// run. This is a conservative approximation (grabbing the actual target
// reference would need MixinExtras @Local, which is brittle across MC
// version changes). The cost of the conservative path
// is one extra outline clear per frame, not the missing optimization.
OutlineTracker.markGlowing();
original.call(encoder, texture, color);
}
/**
* Short-circuit {@code doEntityOutline} when the toggle is on and no glowing
* entity was seen this frame. The vanilla path bails early via
* {@code levelRenderState.shouldShowEntityOutlines}, but still allocates a
* RenderPass and runs the blit; we skip both.
*/
@Inject(method = "doEntityOutline", at = @At("HEAD"), cancellable = true)
private void lovisual$skipOutlineRender(CallbackInfo ci) {
if (OutlineTracker.shouldSkipOutlineRender()) {
ci.cancel();
}
}
}

View file

@ -9,20 +9,20 @@ import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize;
@Mixin(EntityRenderDispatcher.class)
public abstract class EntityRenderDispatcherMixin {
@Inject(method = "shouldRender(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/client/renderer/culling/Frustum;DDD)Z", at = @At("HEAD"), cancellable = true)
private <E extends Entity> void lovisual$cullEntity(E entity, Frustum frustum, double camX, double camY, double camZ, CallbackInfoReturnable<Boolean> cir) {
FpsOptimizer fpsOptimizer = Modules.get(FpsOptimizer.class);
if (fpsOptimizer == null || !fpsOptimizer.isEntityCullingEnabled()) return;
Optimize optimize = Modules.get(Optimize.class);
if (optimize == null || !optimize.isEntityCullingEnabled()) return;
Minecraft mc = Minecraft.getInstance();
if (entity == mc.player) return;
double maxDistance = fpsOptimizer.entityCullDistance();
double maxDistance = optimize.entityCullDistance();
if (entity.distanceToSqr(camX, camY, camZ) > maxDistance * maxDistance) {
cir.setReturnValue(false);
return;

View file

@ -8,7 +8,7 @@ import org.spongepowered.asm.mixin.injection.Coerce;
/**
* Vanilla bug: FireworkParticles$Starter.createParticle assumes ParticleEngine.createParticle never returns null.
* Our ParticleManagerMixin returns null when a ParticleSpawnEvent is cancelled (e.g. FpsOptimizer.disable_particles).
* Our ParticleManagerMixin returns null when a ParticleSpawnEvent is cancelled (e.g. Optimize.no_particles).
* This causes NPE: sparkParticle is null -> setTrail() crashes.
* Fix: guard every SparkParticle setter to be null-safe. SparkParticle is a private inner class, so we use Object
* with @Coerce to avoid InvalidInjectionException (expected SparkParticle vs Object).

View file

@ -18,7 +18,7 @@ import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.renderer.MeshRenderer;
import dev.loki.lovisual.render.engine.uniform.impl.ui.MenuBackgroundUniforms;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize;
public enum MenuBackgroundRenderer {
;
@ -36,8 +36,8 @@ public enum MenuBackgroundRenderer {
public static void render(Minecraft mc, boolean aurora) {
if (mc == null) return;
FpsOptimizer fpsOptimizer = Modules.get(FpsOptimizer.class);
if (fpsOptimizer != null && fpsOptimizer.isMenuBackgroundDisabled()) return;
Optimize optimize = Modules.get(Optimize.class);
if (optimize != null && optimize.isMenuBackgroundDisabled()) return;
if (Renderer2D.isDeferredExtractRecording()) {
deferredPending = true;
deferredAurora = aurora;

View file

@ -3,8 +3,6 @@ package dev.loki.lovisual.render.engine.renderer.renderer2d.fx;
import com.mojang.blaze3d.pipeline.RenderTarget;
import com.mojang.blaze3d.textures.GpuSampler;
import com.mojang.blaze3d.textures.GpuTextureView;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.render.engine.optimize.OptimizeState;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessManager;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
@ -43,8 +41,6 @@ public final class Blur2D {
/** Main framebuffer color texture plus the shared post-process sampler. */
public static BlurSource source() {
if (OptimizeState.isNoGlass()) return null;
FpsOptimizer fpsOptimizer = Modules.get(FpsOptimizer.class);
if (fpsOptimizer != null && fpsOptimizer.isGlassDisabled()) return null;
Minecraft mc = Minecraft.getInstance();
if (mc == null) return null;
RenderTarget fb = mc.gameRenderer.mainRenderTarget();

View file

@ -3435,19 +3435,16 @@
"setting.chinahat.head_forward_offset": "Forward offset",
"setting.chinahat.head_right_offset": "Right offset",
"module.optimize.name": "Optimize",
"module.optimize.description": "Manual FPS rubilnik: while it is on — no glass/blur, lighter post-process passes, no noisy procedural sky layers, no per-frame HUD animation overhead.",
"module.optimize.description": "Manual FPS rubilnik: while it is on — no glass/blur, lighter post-process passes, no noisy procedural sky layers, no per-frame HUD animation overhead, plus particles/menu-background/bloom cuts and off-screen entity culling.",
"setting.optimize.no_glass": "No glass/blur",
"setting.optimize.lite_post": "Light post-process",
"setting.optimize.lite_sky": "Simple sky",
"setting.optimize.lean_hud": "Lean HUD",
"module.fpsoptimizer.name": "FPS Optimizer",
"module.fpsoptimizer.description": "Manual toggles to cut render cost: vanilla particles, menu background shader, glass/blur, bloom/glow, off-screen entity culling.",
"setting.fpsoptimizer.disable_particles": "Disable particles",
"setting.fpsoptimizer.disable_menu_background": "Disable menu background",
"setting.fpsoptimizer.disable_glass": "Disable glass/blur effects",
"setting.fpsoptimizer.disable_bloom": "Disable bloom/glow effects",
"setting.fpsoptimizer.entity_culling": "Cull off-screen entities",
"setting.fpsoptimizer.entity_cull_distance": "Entity cull distance",
"setting.optimize.no_particles": "Disable particles",
"setting.optimize.no_menu_background": "Disable menu background",
"setting.optimize.no_bloom": "Disable bloom/glow effects",
"setting.optimize.entity_cull": "Cull off-screen entities",
"setting.optimize.entity_cull_distance": "Entity cull distance",
"setting.cosmeticdisplay.next_cosmetic.desc": "Cycles to the next model in the Model slot.",
"setting.cosmeticdisplay.prev_cosmetic.desc": "Cycles to the previous model in the Model slot.",
"setting.cosmeticdisplay.open_cosmetics.desc": "Opens the cosmetics editor: 3D preview with clickable body zones and per-slot model pickers.",

View file

@ -3226,19 +3226,16 @@
"setting.chinahat.head_forward_offset": "Смещение вперёд",
"setting.chinahat.head_right_offset": "Смещение вправо",
"module.optimize.name": "Оптимизация",
"module.optimize.description": "Ручной рубильник FPS: пока включён — без стекла/blur, с лёгкими пост-проходами, без шумных процедурных слоёв неба и без покадровых анимаций HUD.",
"module.optimize.description": "Ручной рубильник FPS: пока включён — без стекла/blur, с лёгкими пост-проходами, без шумных процедурных слоёв неба и без покадровых анимаций HUD, плюс отключение частиц/фона меню/блума и отсечение сущностей за экраном.",
"setting.optimize.no_glass": "Без стекла/blur",
"setting.optimize.lite_post": "Лёгкий пост-процесс",
"setting.optimize.lite_sky": "Простое небо",
"setting.optimize.lean_hud": "Лёгкий HUD",
"module.fpsoptimizer.name": "Оптимизация FPS",
"module.fpsoptimizer.description": "Ручные тумблеры для снижения нагрузки рендера: ванильные частицы, фоновый шейдер меню, стекло/блюр, блум/свечение, отсечение сущностей за экраном.",
"setting.fpsoptimizer.disable_particles": "Отключить частицы",
"setting.fpsoptimizer.disable_menu_background": "Отключить фон меню",
"setting.fpsoptimizer.disable_glass": "Отключить стекло/блюр",
"setting.fpsoptimizer.disable_bloom": "Отключить блум/свечение",
"setting.fpsoptimizer.entity_culling": "Скрывать сущности за экраном",
"setting.fpsoptimizer.entity_cull_distance": "Дистанция отсечения сущностей",
"setting.optimize.no_particles": "Отключить частицы",
"setting.optimize.no_menu_background": "Отключить фон меню",
"setting.optimize.no_bloom": "Отключить блум/свечение",
"setting.optimize.entity_cull": "Скрывать сущности за экраном",
"setting.optimize.entity_cull_distance": "Дистанция отсечения сущностей",
"setting.cosmeticdisplay.next_cosmetic.desc": "Следующая модель в слоте «Модель».",
"setting.cosmeticdisplay.prev_cosmetic.desc": "Предыдущая модель в слоте «Модель».",
"setting.cosmeticdisplay.open_cosmetics.desc": "Открывает редактор косметики: 3D-превью с кликабельными зонами тела и выбором моделей по слотам.",

View file

@ -62,7 +62,6 @@
"optimize.stats.GlCommandEncoderStatsMixin",
"optimize.stats.GlRenderPassStatsMixin",
"optimize.stats.GlStateManagerStatsMixin",
"optimize.world.LevelRendererOutlineMixin",
"accessors.blaze3d.GlTextureInvoker",
"accessors.blaze3d.GpuDeviceAccessor",
"accessors.blaze3d.ShaderLoaderDefinitionsAccessor",

View file

@ -1,108 +0,0 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.world;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies {@link OutlineTracker} — per-frame state for the outline-skip
* optimization. Pure logic, no Minecraft dependencies.
*/
public final class OutlineTrackerTest {
@AfterEach
void reset() {
OptimizeToggles.reset();
OutlineTracker.reset();
}
@Test
void disabledWhenMicroOptsOff() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, false, false);
assertFalse(OutlineTracker.enabled());
// Even when glowing was set this frame, shouldSkipOutlineRender returns false
// when the toggle is off — the vanilla path runs unchanged.
OutlineTracker.markGlowing();
assertFalse(OutlineTracker.shouldSkipOutlineRender());
}
@Test
void skipWhenEnabledAndNoGlowing() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true, false);
OutlineTracker.beginFrame();
assertTrue(OutlineTracker.shouldSkipOutlineRender(),
"Without glowing entities, doEntityOutline should be skipped");
}
@Test
void doNotSkipWhenGlowingThisFrame() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true, false);
OutlineTracker.beginFrame();
OutlineTracker.markGlowing();
assertFalse(OutlineTracker.shouldSkipOutlineRender(),
"Glowing entity this frame — should not skip doEntityOutline");
assertTrue(OutlineTracker.glowingThisFrame());
}
@Test
void beginFrameClearsGlowingFlag() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true, false);
OutlineTracker.markGlowing();
assertTrue(OutlineTracker.glowingThisFrame());
OutlineTracker.beginFrame();
assertFalse(OutlineTracker.glowingThisFrame());
}
@Test
void dirtyStartsTrue() {
OptimizeToggles.reset();
OutlineTracker.reset();
assertTrue(OutlineTracker.dirtyRaw());
assertTrue(OutlineTracker.needsClear());
}
@Test
void markClearedClearsDirty() {
OutlineTracker.markCleared();
assertFalse(OutlineTracker.dirtyRaw());
}
@Test
void resetRestoresDefaults() {
OutlineTracker.markGlowing();
OutlineTracker.markCleared();
OutlineTracker.reset();
assertFalse(OutlineTracker.glowingThisFrameRaw());
assertTrue(OutlineTracker.dirtyRaw());
}
@Test
void needsClearFollowsDirtyAndGlowing() {
// dirty=true (default) → needsClear true
OutlineTracker.markCleared();
// dirty=false, glowing=false → needsClear false (we'd need a glow to trigger a clear)
assertFalse(OutlineTracker.needsClear());
OutlineTracker.markGlowing();
// dirty=true again, glowing=true → needsClear true
assertTrue(OutlineTracker.needsClear());
}
}