diff --git a/mod/docs/optimize-layer-design.md b/mod/docs/optimize-layer-design.md
index 735715aa..fcd7d123 100644
--- a/mod/docs/optimize-layer-design.md
+++ b/mod/docs/optimize-layer-design.md
@@ -3,6 +3,8 @@
Статус: Этап 1 готов (2026-10-10), Этап 2 + начало Этапа 3 готовы (2026-10-10). Цель MC: 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`.
- **2026-10-10**: Этап 2 завершён — `FrameBudget` (mode + target FPS + per-subsystem caps), `FrameProfiler` (5 секций EMA-таймингов), `OptimizePropertyBridge` (`-Dlovisual.opt.*` overrides), `OptimizeDebugHudMixin` (F3 overlay). 22 новых теста. `Optimize.java` расширен тумблерами `budget_mode` / `target_fps` / `show_profiler`.
- **2026-10-10**: Этап 3 (частично) — `RenderBackendOps` интерфейс + `GlRenderBackendOps` реализация (lazy clear, TBO cache, RenderTypes cache). Три mixin'а зарегистрированы в `lovisual.mixins.json`: `RenderTypesMixin`, `GlCommandEncoderClearMixin`, `GlCommandEncoderTexBufferMixin`. SkinAtlas/AnimFreeze/HudCache/FrameFence оставлены на поздний Этап 3.
@@ -110,15 +112,68 @@ optimize/
Существующие Vulkan-миксины LoVisual (`VulkanDeviceMixin` и др.) не переписываем, новый код подключается рядом.
+## Что нового в референсе 1.0.2-1.0.3 (сверено 2026-10-12)
+Референс обновился с 1.0.1 до 1.0.3 (+166 файлов). Что важно для нашего плана:
+- **FrameFence/RealFence для 26.2 не нужен.** Комментарий в `26.1.2/gl/GlCommandEncoderMixin`: «26.1 ещё делает GL-sync
+ на каждый fence; с 26.2 ваниль делает один fence на submit и ждёт его по индексу submit'а». Хук `createFence` переехал из
+ `26.x` в модуль `26.1.2`, в 26.2 его нет; в changelog прямо сказано, что опция «Один fence на кадр» оставлена только для
+ 26.1.2, с 26.2 это делает игра. Плюс история краша Xaero's World Map («Cannot wait on a fence for the current submit»)
+ при общем fence — риск, который ваниль уже закрыла. => Пункт «FrameFence/RealFence» из Этапа 3 вычеркиваем; остаётся
+ только хук конца кадра (`MinecraftFrameMixin`: `SkinAtlas.endFrame`, `AnimFreeze.frame++`, `refresh()` конфига).
+- **Счётчики draw-вызовов (`DrawStats`, `common/gl/DrawStats.java`, 75 строк)** — ровно наше заявленное предусловие
+ «перед Этапом 3 нужен профиль HUD: число draw-вызовов и смен состояния на кадр». Считает draws/flush'и слоёв/FBO-bind'ы,
+ средние за секунду, топ-5 слоёв, рисует только пока открыт F3. Дёшево, даёт данные для раздела «Дополнительные
+ GL-оптимизации». => Включить в Этап 3 первым пунктом.
+- **Parallel Models (крупное новое, Impact HIGH).** `26.2/ModelFeatureRendererMixin` перехватывает `buildGroup` и собирает
+ группу submit'ов на нескольких ядрах (`ParallelModelHook` + `ParallelModels` + два invoker'а). Заявлено +20-50% FPS в
+ толпе бронированных мобов, отдельная опция. Отключается при <4 ядрах и при активном шейдерпаке Iris
+ (`!(IRIS && IrisApi…isShaderPackInUse())` — Iris собирает расширенные атрибуты из вершин, отданных по одной).
+ Зависит от `AnimFreeze.freezes()` (часть, которую нельзя реплеить) — ставить после AnimFreeze.
+- **ItemLod получил точный таргет в 26.2**: `entity/ItemFeatureRendererMixin` — `@WrapOperation` на `Submit.quads()` в
+ `prepareMainSubmit`/`prepareOutlineSubmit`/`prepareFoilSubmit`, плоские квады дальше `ItemLod.distanceSq()`.
+- **Выброшенные предметы (новое):** `no_item_sort` (пропуск сортировки `item_entity_translucent_cull`, по умолчанию вкл,
+ до +40% FPS на кучах), `item_single_copy` (стак одним предметом), `ItemBounds` (кэш AABB модели предмета, IdentityHashMap
+ с полной очисткой >4096 — render state пересоздаётся каждый кадр, его собственный кэш умирает вместе с ним).
+ `BufferSourceMixin`+`RenderTypeAccessor` живут в модуле `26.1.2`, в `26.x` их нет — перед переносом проверить, есть ли
+ в 26.2 `MultiBufferSource.BufferSource.endBatch(RenderType, BufferBuilder)` и `RenderType.sortOnUpload()`.
+- **Фиксы HudCache, которые надо взять сразу** (иначе воспроизведём баги): главный target снимается в `beginFrame`
+ (`main = gameRenderer.mainRenderTarget()`) и сравнение идёт с ним, а не с «текущим» — иначе редиректим чужой target
+ (Xaero подменяет свой, пока рисует); двойной троттлинг `GuiItemRefresh`+`HudCache` приводил к пропавшим зачарованным
+ предметам хотбара (фикс: пропускать свой троттлинг, когда `HudCache.active()`); composite — premultiplied + write mask
+ 7 (RGB), depth clear 0 (reversed depth в 26.x); кэш выключается при чужих HUD-кэшах (`axolotlclient`, `exordium`,
+ `gnetum`) и под VulkanMod; интервал = рефреш-рейт монитора (`MonitorRate`, GLFW-опрос 1 раз в секунду).
+- **Правило утечек:** кэши с нативной памятью/GL-объектами чистить и на перезагрузку ресурсов (F3+T), и на смену мира.
+ Фикс 1.0.3: `BubbleColumnUpParticleMixin` держал `ClientLevel` в статике через сильную ссылку — теперь `WeakReference`
+ + очистка карты при смене уровня; `MovingBlockCache`/`ParallelModels` чинили утечки native/model.
+- **Порог параллельных частиц:** план п.4 («авто-порог по замеру») упрощаем до того, что сделано в референсе —
+ `CORES >= 4` + `-D…=auto|true|false`; 6+ ядер оказалось догадкой (замер на 4 ядрах дал +12%), вернули к 4.
+ `Workers` получил `stream()` — публикация задач по мере готовности, рабочие берут их сразу, пока готовится остальное.
+- **MixinPlugin** — условная регистрация миксинов (Sodium/Vulkan/Lunar, `shouldApplyMixin` + `getMixins`), а тумблеры в UI
+ прячутся, если миксин не построен/не применяется (`available()` → `MixinPlugin::applies`). У нас уже есть
+ `mixins/LoVisualMixinPlugin` — расширить для Vulkan (Этап 4) и для «миксин есть не всегда».
+- **Не берём:** Lunar multi-draw (лаунчер Lunar, не наш случай); `blockEntityEarlyCull` перенесён только в `1.21.9+`,
+ в `26.x` его нет — при случае проверить, применим ли к 26.2; `26.3` поддержка и `26.3/renames.txt` пригодятся при
+ апгрейде версии (пакет `com.mojang.blaze3d` → `com.mojang.renderpearl`).
+
## Этапы
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/`: shared FBO, FrameFence, lazy clear, TBO cache, SkinAtlas через GL; затем новые GL-идеи из раздела ниже. **Частично готово 2026-10-10**: `RenderBackendOps` интерфейс (GL/Vulkan стратегия), `GlRenderBackendOps` реализация, 3 mixin'а (`RenderTypesMixin`, `GlCommandEncoderClearMixin`, `GlCommandEncoderTexBufferMixin`), зарегистрированы в `lovisual.mixins.json`. **Осталось**: FrameFence/RealFence (shared GPU fence на кадр), SharedDepthFbos (DSA depth clear через cached FBO), SkinAtlas (glCopyImageSubData), AnimFreeze, ItemLod, LivingEntityLodMixin, ParticleMixin, SpawnerRendererMixin, BubbleColumnUpParticleMixin, VertexConsumerUtilsMixin, SodiumEntityRendererMixin, BlockEntity* mixins, HUD cache mixins, LevelRendererOutlineMixin, MinecraftFrameMixin.
+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):
+ 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, не переносим.
+ 4. ✅ `AnimFreeze` кластер — `AnimFreeze` (capture раз в тик, реплей в вершины), `EntityAnimHolderMixin`, `AnimHolder`, метод `freezes(state, pose)` (часть, построенная каждый кадр в другом месте, не реплеим — это и чинит рябь брони), `RawVertices`/`SodiumVertices`. **Готово 2026-10-12** — `AnimFreeze.configure(enabled, distance)` (свой холдер, не OptimizeToggles), тумблеры `entity_anim_freeze` (по умолчанию выкл) + `entity_anim_distance` (16), `frame++` в `MinecraftMixin.renderFrame` HEAD, вызов из `ModelFeatureRendererMixin.prepareModel` (`@WrapOperation` на `Model.renderToBuffer`), инфраструктура сырых вершин из п.7 (`RawBuffer`/`BufferBuilderRawMixin`/`RawVertices`/`SodiumVertices`/`VertexConsumerUtilsMixin`), 3 теста. TypeBuilders не выделялся — свёрнут в приватные хелперы `AnimFreeze`.
+ 5. ✅ `ItemLod` — `ItemLod` + `ItemFeatureRendererMixin` (`@WrapOperation` на `Submit.quads()` в `prepareMain/Outline/FoilSubmit`) + `ItemBounds`. **Готово 2026-10-12** — `ItemLod`/`ItemBounds` в `render/engine/optimize/entity/` (в `entity/` теперь ровно 4 файла — лимит исчерпан), LOD-хук добавлен в существующий `ItemFeatureRendererMixin`, `ItemStackRenderStateMixin`+`ItemLayerAccessor` в `mixins/render/item/bounds/`, тумблеры `item_lod`/`item_lod_distance` (8)/`item_bounds`, 9 тестов.
+ 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.
+ Хук конца кадра не нужен отдельным `MinecraftFrameMixin`: `renderFrame` у нас уже перехвачен (`mixins/render/main/MinecraftMixin`), туда же вешаем `SkinAtlas.endFrame`/`AnimFreeze.frame++`/конфиг-`refresh()`. **FrameFence/RealFence вычеркнут** — см. «Что нового», ваниль 26.2 делает это сама.
4. ❌ `vulkan/`: `vkCmdCopyImage`, затем `VkPipelineCache`, затем остальное по замерам. **Не начат** — дизайн готов, ждёт профилирования GL-части чтобы понять, какие из Vulkan-оптимизаций реально нужны.
5. ❌ Бенчмарки до и после, отчёт цифр в этот документ. **Не начат** — требует запуска Minecraft на Windows, задача владельца.
## Оценки для Этапов 3-4 (после Stage 1-2)
-- **Этап 3 (остальное)**: ~25 mixin'ов + ~8 helper-классов, ~80-100 новых .java-файлов. Сложные: `AnimFreeze` (vertex format + MemoryUtil), `SkinAtlas` (glCopyImageSubData + LRU), `HudCache` (RenderPipeline + RenderPass + TextureTarget). Безопасные простые: `LevelRendererOutlineMixin`, `BubbleColumnUpParticleMixin`, `SodiumEntityRendererMixin`. ~3-4 рабочих сессии.
+- **Этап 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), остальное по мере замеров.
## Риски
diff --git a/mod/gradle.properties b/mod/gradle.properties
index a8e59e87..be96f22b 100644
--- a/mod/gradle.properties
+++ b/mod/gradle.properties
@@ -20,3 +20,4 @@ viafabricplus_version=4.6.0
entityculling_version=1.10.5
voicechat_api_version=2.6.24
mixinextras_version=0.5.5
+lovisualDevEnabled=false
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java
index 1892d673..64e2d791 100644
--- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java
@@ -7,10 +7,18 @@ 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.entity.AnimFreeze;
+import dev.loki.lovisual.render.engine.optimize.entity.ItemBounds;
+import dev.loki.lovisual.render.engine.optimize.entity.ItemLod;
+import dev.loki.lovisual.render.engine.optimize.hud.HudCache;
+import dev.loki.lovisual.render.engine.optimize.parallel.ParallelModels;
+import dev.loki.lovisual.render.engine.optimize.profile.DrawStats;
import dev.loki.lovisual.render.engine.optimize.profile.FrameBudget;
+import dev.loki.lovisual.render.engine.optimize.profile.FrameProfiler;
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;
/**
* Manual FPS rubilnik: one switch plus a set of knobs, each cutting a named cost
@@ -24,13 +32,13 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
* — never this class; this module only pushes state into them on
* enable/disable and once per frame.
*
- *
Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module now also
+ *
Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module also
* pushes a {@link FrameBudget} snapshot (mode + target FPS) and resolves
* {@code -Dlovisual.opt.*} system-property overrides via
- * {@link OptimizePropertyBridge}. The frame profiler ({@code FrameProfiler})
- * is fed by the {@code MinecraftFrameMixin} (Stage 3 — not yet present); the
- * F3 overlay ({@code OptimizeDebugHudMixin}) reads the profiler's snapshot
- * whenever the user opens F3.
+ * {@link OptimizePropertyBridge}. Stage 3 step 1 adds {@link DrawStats} —
+ * draw/pipeline/framebuffer counters fed by the {@code mixins/optimize/stats}
+ * hooks, opened and closed here so they only run while F3 is up. The F3
+ * overlay ({@code OptimizeDebugHudMixin}) reads both snapshots.
*/
@ModuleInfo(
id = "optimize",
@@ -58,6 +66,23 @@ public final class Optimize extends Module {
private final BooleanValue preparedTextCache = bool("prepared_text_cache", true);
private final BooleanValue microOpts = bool("micro_opts", true);
+ // --- Stage 3: GL-level helpers ---
+ private final BooleanValue fboShare = bool("fbo_share", true);
+ private final BooleanValue entityAnimFreeze = bool("entity_anim_freeze", false);
+ private final NumberValue entityAnimDistance =
+ num("entity_anim_distance", 16, 4, 128);
+
+ // --- Stage 3: items ---
+ private final BooleanValue itemLod = bool("item_lod", true);
+ private final NumberValue itemLodDistance = num("item_lod_distance", 8, 2, 64);
+ private final BooleanValue itemBounds = bool("item_bounds", true);
+
+ // --- Stage 3: HUD cache (off by default — interacts with the deferred-HUD markers) ---
+ private final BooleanValue hudCacheToggle = bool("hud_cache", false);
+
+ // --- Stage 3: parallel model build (own holder: cores/Iris gating inside) ---
+ private final BooleanValue parallelModels = bool("parallel_models", true);
+
// --- Stage 2: frame budget + profiling ---
private final EnumValue budgetMode =
enumMode("budget_mode", FrameBudget.Mode.OFF, FrameBudget.Mode.values());
@@ -84,12 +109,27 @@ public final class Optimize extends Module {
OptimizeState.reset();
OptimizeToggles.reset();
FrameBudget.reset();
+ AnimFreeze.reset();
+ ItemLod.reset();
+ ItemBounds.reset();
+ HudCache.reset();
+ ParallelModels.reset();
+ // stop counting and drop the F3 numbers, onFrame will not run again
+ DrawStats.endFrame(false);
}
/** Picks up knob changes made while the module stays on. */
@Override
public void onFrame(float d) {
pushState();
+ // close the window of the frame that just drew; the next publish lands once a second
+ DrawStats.endFrame(debugOverlayVisible());
+ }
+
+ /** F3 counts are only worth collecting while they are on screen. */
+ private static boolean debugOverlayVisible() {
+ Minecraft mc = Minecraft.getInstance();
+ return mc != null && mc.getDebugOverlay() != null && mc.getDebugOverlay().showDebugScreen();
}
private void pushState() {
@@ -114,11 +154,25 @@ public final class Optimize extends Module {
resolveBool(props, props.spawnerTickCache, on && spawnerTickCache.get()),
resolveBool(props, props.blockEntityCache, on && blockEntityCache.get()),
resolveBool(props, props.preparedTextCache, on && preparedTextCache.get()),
- resolveBool(props, props.microOpts, on && microOpts.get()));
+ resolveBool(props, props.microOpts, on && microOpts.get()),
+ resolveBool(props, props.fboShare, on && fboShare.get()));
// Stage 2: frame budget. -Dlovisual.opt.budget_mode / target_fps override the module.
FrameBudget.Mode mode = on ? budgetMode.get() : FrameBudget.Mode.OFF;
FrameBudget.apply(props.overrides(mode, mode), props.resolveTargetFps(targetFps.get()));
+
+ // Stage 3: far-entity animation freeze — own holder, pushed once per frame.
+ AnimFreeze.configure(on && entityAnimFreeze.get(), entityAnimDistance.get());
+
+ // Stage 3: far-item LOD + shared single-layer item bounds.
+ ItemLod.configure(on && itemLod.get(), itemLodDistance.get());
+ ItemBounds.configure(on && itemBounds.get());
+
+ // Stage 3: HUD-at-refresh-rate cache; own holder (foreign-HUD/Vulkan gating inside).
+ HudCache.configure(on && hudCacheToggle.get());
+
+ // Stage 3: parallel model build; mutually exclusive with anim freeze (checked inside).
+ ParallelModels.configure(on && parallelModels.get());
}
private static boolean resolveBool(OptimizePropertyBridge.Snapshot props,
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOps.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOps.java
index 63f558ff..1e65f484 100644
--- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOps.java
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOps.java
@@ -1,6 +1,7 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.gl;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
+import dev.loki.lovisual.render.engine.optimize.gl.SharedDepthFbos;
import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap;
import java.util.Map;
@@ -11,31 +12,22 @@ import java.util.concurrent.ConcurrentHashMap;
*
* Resolves the current GL device via {@code GlBackendAccess.current()} —
* returns {@code null} on Vulkan so all mixins short-circuit to the vanilla
- * call. The cache state is static and render-thread-only (ZakoOpt does the
- * same — the GL command encoder runs single-threaded).
- *
- *
Ported from ZakoOpt's three GL caches:
- *
- * - {@code zako.opt.mixin.gl.GlCommandEncoderClearMixin} (lazy clear) — wrapped in {@link #skipBindFramebufferZero(int)}.
- * - {@code zako.opt.gl.TexBufferCache} + {@code zako.opt.mixin.gl.GlCommandEncoderTexBufferMixin} — wrapped in the TBO-cache methods.
- * - {@code zako.opt.mixin.gl.RenderTypesMixin} — wrapped in the RenderTypes-cache methods.
- *
+ * call. The cache state is static and render-thread-only (the GL command
+ * encoder runs single-threaded).
*
* Each toggle is gated by {@link OptimizeToggles#microOpts()} (the Stage 1
- * "micro_opts" toggle wired into {@code Optimize.java}); ZakoOpt splits this
- * into {@code lookupCaches}, {@code microOpts}, {@code tboCache} — Stage 1
- * collapses them, Stage 2 may split if profiling warrants.
+ * "micro_opts" toggle wired into {@code Optimize.java}).
*/
public final class GlRenderBackendOps implements RenderBackendOps {
private static final GlRenderBackendOps INSTANCE = new GlRenderBackendOps();
- // --- TBO cache (ported from zako.opt.gl.TexBufferCache) ---
+ // --- TBO cache ---
/** Maps bound-texture-id -> packed (format << 32) | (buffer & 0xFFFFFFFFL). -1 = nothing attached. */
private final Int2LongOpenHashMap tboAttached = new Int2LongOpenHashMap();
private int boundTboTexture;
- // --- RenderTypes cache (ported from zako.opt.mixin.gl.RenderTypesMixin) ---
+ // --- RenderTypes cache ---
private final Map translucentCache = new ConcurrentHashMap<>();
private GlRenderBackendOps() {
@@ -100,6 +92,18 @@ public final class GlRenderBackendOps implements RenderBackendOps {
return ((long) format << 32) | (buffer & 0xFFFFFFFFL);
}
+ // --- FBO share (Stage 3 step 2) ---
+
+ @Override
+ public boolean fboShareSupported() {
+ return OptimizeToggles.fboShare();
+ }
+
+ @Override
+ public boolean clearDepthInPlace(int depthId, double value) {
+ return fboShareSupported() && SharedDepthFbos.clear(depthId, value);
+ }
+
// --- RenderTypes cache ---
@Override
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/RenderBackendOps.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/RenderBackendOps.java
index b0a2c59b..00605c8f 100644
--- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/RenderBackendOps.java
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/RenderBackendOps.java
@@ -17,6 +17,8 @@ package dev.loki.lovisual.features.module.modules.misc.optimize.gl;
* {@link #tboCacheSupported()} / {@link #tboAlreadyAttached(int, int)} /
* {@link #markTboAttached(int, int)} / {@link #trackBoundTboTexture(int)} /
* {@link #forgetTboCache()} — TBO cache
+ * {@link #fboShareSupported()} / {@link #clearDepthInPlace(int, double)} —
+ * shared depth FBOs (Stage 3 step 2)
* {@link #renderTypesCacheSupported()} / {@link #renderTypeFor(Identifier)} /
* {@link #cacheRenderType(Identifier, Object)} — RenderTypes cache
*
@@ -64,6 +66,22 @@ public interface RenderBackendOps {
/** Forget all TBO attachments — called when a {@code GlBuffer.Direct} is closed. */
void forgetTboCache();
+ // --- FBO share (Stage 3 step 2) ---
+
+ /** True when this backend can clear a depth texture in place via a cached FBO. */
+ boolean fboShareSupported();
+
+ /**
+ * Called from {@code GlCommandEncoder.clearDepthTexture} on the
+ * {@code GlTexture} depth argument.
+ *
+ * @param depthId the GL id of the depth texture
+ * @param value the clear value (0..1)
+ * @return {@code true} when the clear already ran in place (mixin cancels
+ * the vanilla path); {@code false} to fall through to vanilla
+ */
+ boolean clearDepthInPlace(int depthId, double value);
+
// --- RenderTypes cache ---
/** True when this backend can cache {@code RenderTypes.entityTranslucent(id, flag=true)}. */
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java
index 25a33993..3ba1ceca 100644
--- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java
@@ -16,8 +16,6 @@ import net.minecraft.world.level.material.FluidState;
* and destroyed constantly — per-particle state would be re-initialized too
* often to be useful.
*
- * Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* The cache logic is in {@link TickCache} (a generic per-tick keyed cache),
* so it's unit-testable without Minecraft types.
*/
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java
index 3280a355..0cfa58b2 100644
--- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java
@@ -15,8 +15,6 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
* created and destroyed constantly — per-particle state would be re-initialized
* too often to be useful.
*
- *
Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
*/
public final class TickCache {
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java
index def3df15..5aeb49e9 100755
--- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java
@@ -59,11 +59,78 @@ public final class Workers {
return job.error.get();
}
+ /**
+ * Open a task stream: helpers start right away and take items as the
+ * caller publishes them, so work overlaps with preparing the rest.
+ * Used by the parallel model builder (Stage 3).
+ */
+ public static Stream stream(IntConsumer task) {
+ Stream stream = new Stream(task);
+ for (int h = COUNT; h > 0; h--) {
+ POOL.execute(stream::work);
+ }
+ return stream;
+ }
+
/** Test-only: shut the pool down so the JVM can exit cleanly. */
public static void shutdown() {
POOL.shutdownNow();
}
+ /** Items {@code 0..count-1} are fully set up and may be taken by helpers. */
+ public static final class Stream {
+ final IntConsumer task;
+ final AtomicInteger next = new AtomicInteger();
+ final AtomicInteger completed = new AtomicInteger();
+ final AtomicReference error = new AtomicReference<>();
+ volatile int ready;
+ volatile int total = -1;
+
+ Stream(IntConsumer task) {
+ this.task = task;
+ }
+
+ public void publish(int count) {
+ ready = count;
+ }
+
+ /** The caller helps with what is left and waits for the rest; first failure or null. */
+ public Throwable finish(int count) {
+ ready = count;
+ total = count;
+ work();
+ while (completed.get() < count) {
+ Thread.onSpinWait();
+ }
+ return error.get();
+ }
+
+ void work() {
+ while (true) {
+ int n = next.get();
+ if (n < ready) {
+ if (next.compareAndSet(n, n + 1)) {
+ try {
+ if (error.get() == null) {
+ task.accept(n);
+ }
+ } catch (Throwable t) {
+ error.compareAndSet(null, t);
+ } finally {
+ completed.incrementAndGet();
+ }
+ }
+ continue;
+ }
+ int t = total;
+ if (t >= 0 && n >= t) {
+ return;
+ }
+ Thread.onSpinWait();
+ }
+ }
+ }
+
private static final class Job {
final int blocks;
final IntConsumer task;
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/camera/CameraPosition.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/camera/CameraPosition.java
index f50de799..5d100f4a 100644
--- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/camera/CameraPosition.java
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/camera/CameraPosition.java
@@ -12,8 +12,6 @@ import net.minecraft.world.phys.Vec3;
* {@link ParticleMixin} reads it without re-walking the gameRenderer chain
* per particle.
*
- * Ported from {@code zako.opt.particle.CameraPosition} (ZakoOpt, LGPL-3.0).
- * Original author: Zako — https://t.me/StarikZako. Render-thread only —
* particle ticking runs on the render thread.
*/
public final class CameraPosition {
diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java
index 22c36a0d..f99fc32b 100644
--- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java
+++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java
@@ -15,8 +15,6 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
* outline-target-clear {@code WrapOperation} when the renderer actually
* touches the outline target.
*
- *
Ported from {@code zako.opt.mixin.entity.LevelRendererOutlineMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* The state is lifted out of the mixin so the mixin stays a thin wrapper.
*/
public enum OutlineTracker {
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java b/mod/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java
index 664e5c34..1442aa00 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java
@@ -22,7 +22,8 @@ public final class LoVisualMixinPlugin implements IMixinConfigPlugin {
"dev.loki.lovisual.mixins.sodium.vertex.SodiumVertexConsumerTrackerMixin",
"dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceCullMixin",
"dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceHideMixin",
- "dev.loki.lovisual.mixins.optimize.entity.SodiumEntityRendererMixin"
+ "dev.loki.lovisual.mixins.optimize.entity.SodiumEntityRendererMixin",
+ "dev.loki.lovisual.mixins.optimize.gl.raw.VertexConsumerUtilsMixin"
);
private static final Set OPTIONAL_IRIS_MIXINS = Set.of(
"dev.loki.lovisual.mixins.iris.render.IrisCommonUniformsMixin",
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BaseSpawnerMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BaseSpawnerMixin.java
index 52be9e45..cb75df84 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BaseSpawnerMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BaseSpawnerMixin.java
@@ -16,9 +16,6 @@ import org.spongepowered.asm.mixin.Unique;
* the display entity only changes on tick boundaries (spin speed, mob
* rotation, etc.), so caching by tick removes that rebuild from the hot path.
*
- * Ported from {@code zako.opt.mixin.block.BaseSpawnerMixin} (ZakoOpt,
- * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. Field/method
- * prefix changed from {@code zakoopt$} to {@code lovisual$}.
*/
@Mixin(BaseSpawner.class)
public abstract class BaseSpawnerMixin implements TickCached {
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityMixin.java
index 985f8c6b..ff4a96b2 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityMixin.java
@@ -21,9 +21,6 @@ import org.spongepowered.asm.mixin.Unique;
* distance check that only changes when the entity moves across the cull
* boundary. All three are cached via the interface fields implemented here.
*
- *
Ported from {@code zako.opt.mixin.block.BlockEntityMixin} (ZakoOpt,
- * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. Field/method
- * prefix changed from {@code zakoopt$} to {@code lovisual$}.
*/
@Mixin(BlockEntity.class)
public abstract class BlockEntityMixin implements TickCached, CachedLight, CachedValidity {
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityRenderStateMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityRenderStateMixin.java
index 30f1e14e..98afe97f 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityRenderStateMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityRenderStateMixin.java
@@ -25,8 +25,6 @@ import org.spongepowered.asm.mixin.injection.At;
* {@link CachedLight} mixin from Stage 3-continued-2) before falling through
* to the original.
*
- *
Ported from {@code zako.opt.mixin.block.BlockEntityRenderStateMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* Gated on the {@code blockEntityCache} toggle via
* {@link OptimizeToggles#blockEntityCache()}. When the toggle is off, the
* original runs unchanged.
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/ChestRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/ChestRendererMixin.java
index 05bdd51b..b01621ee 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/ChestRendererMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/ChestRendererMixin.java
@@ -35,12 +35,8 @@ import org.spongepowered.asm.mixin.injection.At;
* {@link CachedLight#lovisual$pairLight()} slot.
*
*
- *
Ported from {@code zako.opt.mixin.block.ChestRendererMixin} (ZakoOpt,
- * LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* Combine cache gated on {@code blockEntityCache} toggle; pair-light cache
- * gated on {@code microOpts} toggle (matches ZakoOpt's microOpts2 split —
- * Stage 1 collapsed microOpts2 into microOpts, so both paths read the same
- * toggle).
+ * gated on {@code microOpts} toggle.
*/
@Mixin(ChestRenderer.class)
public abstract class ChestRendererMixin {
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityNameMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityNameMixin.java
index 08420803..1474b4d2 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityNameMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityNameMixin.java
@@ -13,9 +13,6 @@ import org.spongepowered.asm.mixin.Unique;
* prefix + name + suffix on every call; renderers call it once per frame per
* entity in view, so caching by tick cuts the per-frame cost.
*
- *
Ported from {@code zako.opt.mixin.entity.EntityNameMixin} (ZakoOpt,
- * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. Field/method
- * prefix changed from {@code zakoopt$} to {@code lovisual$}.
*/
@Mixin(Entity.class)
public abstract class EntityNameMixin implements CachedName {
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityRenderStateEntityMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityRenderStateEntityMixin.java
index e2330d2a..3f907a6c 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityRenderStateEntityMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityRenderStateEntityMixin.java
@@ -13,9 +13,6 @@ import org.spongepowered.asm.mixin.Unique;
* {@link EntityRendererStateMixin} captures it once per state construction
* via {@code createRenderState} and downstream renderers read it back.
*
- *
Ported from {@code zako.opt.mixin.entity.EntityRenderStateEntityMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
- * Prefix changed from {@code zakoopt$} to {@code lovisual$}.
*/
@Mixin(EntityRenderState.class)
public abstract class EntityRenderStateEntityMixin implements StateEntity {
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/LivingEntityLodMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/LivingEntityLodMixin.java
index 9c2de03d..3ee6bc30 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/LivingEntityLodMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/LivingEntityLodMixin.java
@@ -18,8 +18,6 @@ import org.spongepowered.asm.mixin.injection.At;
* entity; we AND it with "not far" so far entities skip the layers but
* close entities render them as usual.
*
- *
Ported from {@code zako.opt.mixin.entity.LivingEntityLodMixin} (ZakoOpt,
- * LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
*
*
Gated on the {@code entity_lod} toggle via {@link EntityLod#far(double)}.
* When the toggle is off, {@code far} returns {@code false} so the modify
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/SodiumEntityRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/SodiumEntityRendererMixin.java
index 69de7148..444fdad6 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/SodiumEntityRendererMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/SodiumEntityRendererMixin.java
@@ -21,8 +21,6 @@ import org.spongepowered.asm.mixin.injection.At;
* but the convention is kept for safety. The mixin is registered with
* {@code remap = false} because the target is a Sodium class, not a Mojang one.
*
- *
Ported from {@code zako.opt.mixin.entity.SodiumEntityRendererMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* Gated on the {@code microOpts} toggle via {@link OptimizeToggles#microOpts()}.
* When the toggle is off, the original runs unchanged.
*
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/EntityAnimHolderMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/EntityAnimHolderMixin.java
new file mode 100644
index 00000000..b8935ea0
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/EntityAnimHolderMixin.java
@@ -0,0 +1,30 @@
+package dev.loki.lovisual.mixins.optimize.entity.anim;
+
+import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze;
+import dev.loki.lovisual.render.engine.optimize.entity.AnimHolder;
+import net.minecraft.world.entity.Entity;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Unique;
+
+/**
+ * Stage 3 step 4: implements {@link AnimHolder} on {@code Entity} — adds the
+ * per-entity bag of frozen animation captures. The field lives as long as
+ * the entity; captures hold direct buffers, so long-lived entities keep at
+ * most one capture per (model, renderType, sprite) submission slot.
+ */
+@Mixin(Entity.class)
+public abstract class EntityAnimHolderMixin implements AnimHolder {
+
+ @Unique
+ private AnimFreeze.Holder lovisual$anim;
+
+ @Override
+ public AnimFreeze.Holder lovisual$anim() {
+ return lovisual$anim;
+ }
+
+ @Override
+ public void lovisual$anim(AnimFreeze.Holder holder) {
+ lovisual$anim = holder;
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/ModelFeatureRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/ModelFeatureRendererMixin.java
new file mode 100644
index 00000000..9a3e6427
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/ModelFeatureRendererMixin.java
@@ -0,0 +1,61 @@
+package dev.loki.lovisual.mixins.optimize.entity.anim;
+
+import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
+import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import dev.loki.lovisual.features.module.modules.misc.optimize.entity.StateEntity;
+import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze;
+import dev.loki.lovisual.render.engine.optimize.parallel.ParallelModelHook;
+import java.util.List;
+import net.minecraft.client.model.Model;
+import net.minecraft.client.renderer.entity.state.EntityRenderState;
+import net.minecraft.client.renderer.feature.FeatureFrameContext;
+import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
+import net.minecraft.world.entity.Entity;
+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 step 4: the AnimFreeze call site. When a far entity's model part is
+ * about to render, hand the submission to {@link AnimFreeze#render} — it
+ * replays the once-per-tick raw vertex capture shifted to the current
+ * position; on success the vanilla {@code renderToBuffer} is skipped.
+ *
+ *
The buffer may already be sprite-wrapped; the replayed vertices go
+ * through it, so Sodium's push remaps their UVs. Sheeted decals (block
+ * cracks) never freeze — their pose is built elsewhere.
+ */
+@Mixin(ModelFeatureRenderer.class)
+public abstract class ModelFeatureRendererMixin {
+
+ @Inject(method = "buildGroup", at = @At("HEAD"), cancellable = true)
+ private void lovisual$parallel(FeatureFrameContext context, List> submits,
+ CallbackInfo ci) {
+ if (ParallelModelHook.buildGroup((ModelFeatureRenderer) (Object) this, submits)) {
+ ci.cancel();
+ }
+ }
+
+ @WrapOperation(
+ method = "prepareModel",
+ at = @At(value = "INVOKE",
+ target = "Lnet/minecraft/client/model/Model;renderToBuffer(Lcom/mojang/blaze3d/vertex/PoseStack;Lcom/mojang/blaze3d/vertex/VertexConsumer;III)V")
+ )
+ private void lovisual$frozenAnimation(Model> model, PoseStack poseStack, VertexConsumer consumer,
+ int light, int overlay, int color, Operation original,
+ ModelFeatureRenderer.Submit> submit) {
+ if (AnimFreeze.enabled() && submit.sheetedDecalPose() == null
+ && submit.state() instanceof EntityRenderState state) {
+ Entity entity = ((StateEntity) state).lovisual$entity();
+ if (entity != null && AnimFreeze.render(entity, model, submit.renderType(), null,
+ poseStack.last().pose(), consumer,
+ target -> original.call(model, poseStack, target, light, overlay, color))) {
+ return;
+ }
+ }
+ original.call(model, poseStack, consumer, light, overlay, color);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelFeatureRendererInvoker.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelFeatureRendererInvoker.java
new file mode 100644
index 00000000..d7d8e6dd
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelFeatureRendererInvoker.java
@@ -0,0 +1,17 @@
+package dev.loki.lovisual.mixins.optimize.entity.parallel;
+
+import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Invoker;
+
+/**
+ * Stage 3 step 8: the vanilla per-model build, invoked as the fallback for
+ * submits the parallel path could not take (wrong format, decals, frozen
+ * animations, raw-vertex consumer unavailable).
+ */
+@Mixin(ModelFeatureRenderer.class)
+public interface ModelFeatureRendererInvoker {
+
+ @Invoker("prepareModel")
+ void lovisual$prepareModel(ModelFeatureRenderer.Submit submit);
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelPartAccessor.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelPartAccessor.java
new file mode 100644
index 00000000..5b13b016
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelPartAccessor.java
@@ -0,0 +1,21 @@
+package dev.loki.lovisual.mixins.optimize.entity.parallel;
+
+import java.util.List;
+import java.util.Map;
+import net.minecraft.client.model.geom.ModelPart;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+/**
+ * Stage 3 step 8: exposes the part-tree internals the parallel model build
+ * flattens once per model — the baked cubes and the ordered children map.
+ */
+@Mixin(ModelPart.class)
+public interface ModelPartAccessor {
+
+ @Accessor("cubes")
+ List lovisual$cubes();
+
+ @Accessor("children")
+ Map lovisual$children();
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/RenderTypeFeatureRendererInvoker.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/RenderTypeFeatureRendererInvoker.java
new file mode 100644
index 00000000..1b26f29d
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/RenderTypeFeatureRendererInvoker.java
@@ -0,0 +1,19 @@
+package dev.loki.lovisual.mixins.optimize.entity.parallel;
+
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import net.minecraft.client.renderer.feature.RenderTypeFeatureRenderer;
+import net.minecraft.client.renderer.rendertype.RenderType;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Invoker;
+
+/**
+ * Stage 3 step 8: the per-render-type vertex builder the parallel emit hands
+ * its raw vertices to — one builder call per model, exactly as vanilla's
+ * prepareModel would make.
+ */
+@Mixin(RenderTypeFeatureRenderer.class)
+public interface RenderTypeFeatureRendererInvoker {
+
+ @Invoker("getVertexBuilder")
+ VertexConsumer lovisual$vertexBuilder(RenderType renderType);
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererNameMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererNameMixin.java
index 7eeea3db..dcb7f1aa 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererNameMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererNameMixin.java
@@ -22,8 +22,6 @@ import org.spongepowered.asm.mixin.injection.At;
* original runs and the result is stored; subsequent calls in the same tick
* return the cached instance.
*
- * Ported from {@code zako.opt.mixin.entity.EntityRendererNameMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* Gated on the {@code microOpts} toggle via {@link OptimizeToggles#microOpts()}.
* When the toggle is off, the original runs unchanged.
*/
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererStateMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererStateMixin.java
index 2085f8a1..97b571c8 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererStateMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererStateMixin.java
@@ -18,8 +18,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
* renderers (model feature renderer, item feature renderer, future LOD mixins)
* can reach the live entity.
*
- *
Ported from {@code zako.opt.mixin.entity.EntityRendererStateMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* The original used {@code @ModifyReturnValue}; LoVisual uses {@code @Inject}
* at RETURN with {@code CallbackInfoReturnable} so the mixin stays simple and
* doesn't rely on the (more fragile across versions) MixinExtras return-value
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlBufferCloseMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlBufferCloseMixin.java
new file mode 100644
index 00000000..ab51ca1f
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlBufferCloseMixin.java
@@ -0,0 +1,29 @@
+package dev.loki.lovisual.mixins.optimize.gl;
+
+import dev.loki.lovisual.features.module.modules.misc.optimize.gl.RenderBackendOps;
+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 step 2 (completes the TBO cache): only {@code GlBuffer.Direct}
+ * deletes its GL handle; transient buffers are slices of a shared one. A
+ * deleted buffer's id can be reused by a new buffer, so the attachment cache
+ * must not outlive it — this hook clears the cache on every Direct close.
+ *
+ *
+ *
Unconditional (clearing a tiny map on buffer close is free); correctness
+ * beats the micro-saving of keeping stale attachments around.
+ */
+@Mixin(targets = "com.mojang.blaze3d.opengl.GlBuffer$Direct")
+public abstract class GlBufferCloseMixin {
+
+ @Inject(method = "close", at = @At("HEAD"))
+ private void lovisual$forgetTboCache(CallbackInfo ci) {
+ RenderBackendOps backend = RenderBackendOps.current();
+ if (backend != null) {
+ backend.forgetTboCache();
+ }
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderClearMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderClearMixin.java
index 22ed2415..67cbde1f 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderClearMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderClearMixin.java
@@ -15,8 +15,6 @@ import org.spongepowered.asm.mixin.injection.At;
* we don't pay for a {@code glBindFramebuffer(GL_FRAMEBUFFER, 0)} that's
* immediately overwritten.
*
- *
Ported from {@code zako.opt.mixin.gl.GlCommandEncoderClearMixin} (ZakoOpt,
- * LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
*
*
Gated on the {@code microOpts} toggle via {@link RenderBackendOps#skipBindFramebufferZero(int)}.
* When the toggle is off, the original call runs unchanged.
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderTexBufferMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderTexBufferMixin.java
index a6dfe7ee..9d09f12a 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderTexBufferMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderTexBufferMixin.java
@@ -15,8 +15,6 @@ import org.spongepowered.asm.mixin.injection.At;
* re-attaching makes the driver revalidate the texture (clouds: ~2% of a
* frame in {@code glDrawElements}).
*
- *
Ported from {@code zako.opt.mixin.gl.GlCommandEncoderTexBufferMixin} (ZakoOpt,
- * LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
*
*
Gated on the {@code microOpts} toggle via
* {@link RenderBackendOps#tboCacheSupported()}. When the toggle is off, both
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/RenderTypesMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/RenderTypesMixin.java
index 7bbc974d..997a0acb 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/RenderTypesMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/RenderTypesMixin.java
@@ -19,8 +19,6 @@ import java.util.function.BiFunction;
* items, etc.), the result depends only on the identifier, so we cache it
* directly in a {@code ConcurrentHashMap}.
*
- *
Ported from {@code zako.opt.mixin.gl.RenderTypesMixin} (ZakoOpt, LGPL-3.0).
- * Original author: Zako — https://t.me/StarikZako. The original cache logic
* is wrapped in {@link RenderBackendOps}; LoVisual's GL implementation is
* {@code GlRenderBackendOps}, which gates on {@code microOpts} (Stage 1 toggle).
*
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/FrameBufferCacheMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/FrameBufferCacheMixin.java
new file mode 100644
index 00000000..40611df9
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/FrameBufferCacheMixin.java
@@ -0,0 +1,53 @@
+package dev.loki.lovisual.mixins.optimize.gl.fbo;
+
+import com.mojang.blaze3d.opengl.DirectStateAccess;
+import com.mojang.blaze3d.opengl.FrameBufferAttachment;
+import com.mojang.blaze3d.opengl.FrameBufferCache;
+import dev.loki.lovisual.render.engine.optimize.gl.SharedDepthFbos;
+import it.unimi.dsi.fastutil.objects.Object2IntMap;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+import java.util.List;
+
+/**
+ * Stage 3 step 2: feeds {@link SharedDepthFbos} from the vanilla FBO cache.
+ * {@code getFbo} records "this depth texture lives in that FBO" (mip 0 only —
+ * higher mips are never the depth attachment of the cached FBO);
+ * {@code destroyFbo} drops the mapping before the FBO is deleted, because its
+ * GL id may be reused by the next one.
+ *
+ *
+ *
Bookkeeping only — runs regardless of the {@code fbo_share} toggle (a
+ * map put/remove per FBO create/destroy is free); the toggle gates the clear
+ * path in {@code GlCommandEncoderDepthClearMixin}.
+ */
+@Mixin(FrameBufferCache.class)
+public abstract class FrameBufferCacheMixin {
+
+ @Shadow
+ @Final
+ private Object2IntMap cache;
+
+ @Inject(method = "getFbo", at = @At("RETURN"))
+ private void lovisual$rememberDepth(DirectStateAccess dsa,
+ List colors,
+ FrameBufferAttachment depth,
+ CallbackInfoReturnable cir) {
+ if (depth != null && depth.fboMipLevel() == 0) {
+ SharedDepthFbos.remember(depth.glId(), cir.getReturnValueI());
+ }
+ }
+
+ @Inject(method = "destroyFbo", at = @At("HEAD"))
+ private void lovisual$forget(FrameBufferCache.CacheKey key, CallbackInfo ci) {
+ if (cache.containsKey(key)) {
+ SharedDepthFbos.forget(cache.getInt(key));
+ }
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/GlCommandEncoderDepthClearMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/GlCommandEncoderDepthClearMixin.java
new file mode 100644
index 00000000..90dfd1e7
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/GlCommandEncoderDepthClearMixin.java
@@ -0,0 +1,36 @@
+package dev.loki.lovisual.mixins.optimize.gl.fbo;
+
+import com.mojang.blaze3d.opengl.GlTexture;
+import com.mojang.blaze3d.textures.GpuTexture;
+import dev.loki.lovisual.features.module.modules.misc.optimize.gl.RenderBackendOps;
+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 step 2: vanilla attaches the depth texture to a scratch FBO,
+ * clears, detaches and unbinds; a cached FBO (see {@code FrameBufferCacheMixin})
+ * already holds the attachment, so the clear becomes one
+ * {@code glClearNamedFramebufferfv}. Cancels the vanilla path when the
+ * in-place clear ran.
+ *
+ *
+ * Gated on the {@code fbo_share} toggle via
+ * {@link RenderBackendOps#clearDepthInPlace(int, double)}; a closed texture
+ * or a missing DSA capability falls through to vanilla unchanged.
+ */
+@Mixin(targets = "com.mojang.blaze3d.opengl.GlCommandEncoder")
+public abstract class GlCommandEncoderDepthClearMixin {
+
+ @Inject(method = "clearDepthTexture", at = @At("HEAD"), cancellable = true)
+ private void lovisual$clearInPlace(GpuTexture depth, double value, CallbackInfo ci) {
+ if (!(depth instanceof GlTexture gl) || gl.isClosed()) {
+ return;
+ }
+ RenderBackendOps backend = RenderBackendOps.current();
+ if (backend != null && backend.clearDepthInPlace(gl.glId(), value)) {
+ ci.cancel();
+ }
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/BufferBuilderRawMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/BufferBuilderRawMixin.java
new file mode 100644
index 00000000..83b357d9
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/BufferBuilderRawMixin.java
@@ -0,0 +1,53 @@
+package dev.loki.lovisual.mixins.optimize.gl.raw;
+
+import com.mojang.blaze3d.vertex.BufferBuilder;
+import com.mojang.blaze3d.vertex.ByteBufferBuilder;
+import com.mojang.blaze3d.vertex.VertexFormat;
+import dev.loki.lovisual.render.engine.optimize.gl.RawBuffer;
+import org.lwjgl.system.MemoryUtil;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+/**
+ * Stage 3 step 7: what Sodium's {@code VertexBufferWriter} does for a
+ * {@code BufferBuilder} — push ready-made vertex bytes straight into the
+ * buffer — reimplemented on the vanilla builder so raw-vertex consumers
+ * (AnimFreeze, ParallelModels) work without Sodium.
+ *
+ */
+@Mixin(BufferBuilder.class)
+public abstract class BufferBuilderRawMixin implements RawBuffer {
+
+ @Shadow
+ private int vertices;
+ @Shadow
+ @Final
+ private int vertexSize;
+ @Shadow
+ private long vertexPointer;
+ @Shadow
+ @Final
+ private ByteBufferBuilder buffer;
+ @Shadow
+ private int elementsToFill;
+ @Shadow
+ @Final
+ private VertexFormat format;
+
+ @Override
+ public boolean lovisual$accepts(VertexFormat format) {
+ return format == this.format;
+ }
+
+ @Override
+ public void lovisual$push(long src, int count) {
+ int length = count * vertexSize;
+ long dst = buffer.reserve(length);
+ MemoryUtil.memCopy(src, dst, length);
+ vertices += count;
+ // the pushed vertices are complete: the next addVertex must not see a half-filled one
+ vertexPointer = dst + length - vertexSize;
+ elementsToFill = 0;
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/VertexConsumerUtilsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/VertexConsumerUtilsMixin.java
new file mode 100644
index 00000000..1fc9cb97
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/VertexConsumerUtilsMixin.java
@@ -0,0 +1,42 @@
+package dev.loki.lovisual.mixins.optimize.gl.raw;
+
+import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
+import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
+import net.caffeinemc.mods.sodium.api.vertex.buffer.VertexBufferWriter;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Unique;
+
+/**
+ * Stage 3 step 7: caches Sodium's {@code VertexConsumerUtils.convertOrLog}
+ * result. The vanilla method is an interface instanceof plus an interface
+ * call on a handful of alternating consumer classes, which keeps evicting
+ * HotSpot's per-class type-check cache; an identity compare against the last
+ * successful consumer skips both.
+ *
+ * Sodium-only: registered in {@code OPTIONAL_SODIUM_MIXINS}, never applied
+ * without Sodium.
+ *
+ *
Gated on the {@code micro_opts} toggle. When the toggle is off, the
+ * original method runs unchanged.
+ */
+@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.vertex.VertexConsumerUtils", remap = false)
+public abstract class VertexConsumerUtilsMixin {
+
+ // last consumer that passed the check; a whole model goes to one consumer, so this hits for nearly every cuboid
+ @Unique
+ private static volatile Object lovisual$lastWriter;
+
+ @WrapMethod(method = "convertOrLog")
+ private static VertexBufferWriter lovisual$cachedConvert(VertexConsumer consumer, Operation original) {
+ if (consumer == lovisual$lastWriter && OptimizeToggles.microOpts()) {
+ return (VertexBufferWriter) consumer;
+ }
+ VertexBufferWriter writer = original.call(consumer);
+ if (writer == consumer) {
+ lovisual$lastWriter = consumer;
+ }
+ return writer;
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/hud/OptimizeDebugHudMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/hud/OptimizeDebugHudMixin.java
index 1750a422..72010152 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/hud/OptimizeDebugHudMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/hud/OptimizeDebugHudMixin.java
@@ -2,6 +2,7 @@ package dev.loki.lovisual.mixins.optimize.hud;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize;
+import dev.loki.lovisual.render.engine.optimize.profile.DrawStats;
import dev.loki.lovisual.render.engine.optimize.profile.FrameBudget;
import dev.loki.lovisual.render.engine.optimize.profile.FrameProfiler;
import dev.loki.lovisual.render.engine.optimize.profile.OptimizePropertyBridge;
@@ -65,12 +66,25 @@ public abstract class OptimizeDebugHudMixin {
out.add(String.format("frame: %.2f ms (%.0f fps)",
snap.frameMs(),
1000.0 / Math.max(0.01, snap.frameMs())));
- out.add(String.format(" entities=%.2fms particles=%.2fms hud=%.2fms effects=%.2fms text=%.2fms",
- snap.entityMs(),
- snap.particleMs(),
- snap.hudMs(),
- snap.effectMs(),
- snap.textMs()));
+ if (hasSectionTimings(snap)) {
+ out.add(String.format(" entities=%.2fms particles=%.2fms hud=%.2fms effects=%.2fms text=%.2fms",
+ snap.entityMs(),
+ snap.particleMs(),
+ snap.hudMs(),
+ snap.effectMs(),
+ snap.textMs()));
+ }
+ }
+
+ DrawStats.Snapshot counters = DrawStats.snapshot();
+ if (counters.frames() > 0) {
+ out.add(String.format("draws: %d, pipelines: %d, fbo binds: %d (avg/frame)",
+ counters.draws(),
+ counters.pipelines(),
+ counters.binds()));
+ if (!counters.top().isEmpty()) {
+ out.add(" top: " + String.join(", ", counters.top()));
+ }
}
if (OptimizePropertyBridge.hasOverrides()) {
@@ -82,6 +96,15 @@ public abstract class OptimizeDebugHudMixin {
return out;
}
+ /** Sections are only reported once something opens them — a row of zeros is noise. */
+ private static boolean hasSectionTimings(FrameProfiler.Snapshot snap) {
+ return snap.entityMs() > 0
+ || snap.particleMs() > 0
+ || snap.hudMs() > 0
+ || snap.effectMs() > 0
+ || snap.textMs() > 0;
+ }
+
private static String togglesSummary() {
StringBuilder sb = new StringBuilder();
if (OptimizeToggles.entityLod()) append(sb, "entity_lod@" + OptimizeToggles.entityLodDistance());
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/BubbleColumnUpParticleMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/BubbleColumnUpParticleMixin.java
index 94d802ee..7b8265be 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/BubbleColumnUpParticleMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/BubbleColumnUpParticleMixin.java
@@ -18,8 +18,6 @@ import org.spongepowered.asm.mixin.injection.At;
* {@link BubbleColumnCache} first; on a miss, the original runs and the
* result is stored.
*
- * Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* The cache logic is in {@link BubbleColumnCache} so the mixin stays a thin
* wrapper and the cache is unit-testable without Minecraft types.
*
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/ParticleMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/ParticleMixin.java
index 446317c0..0d983eb3 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/ParticleMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/ParticleMixin.java
@@ -20,7 +20,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Stage 3: per-particle light cache + far-particle physics skip.
*
- *
Two optimizations in one mixin (matches ZakoOpt):
+ *
Two optimizations in one mixin:
*
* - Light cache — vanilla {@code getLightCoords(partialTick)} walks
* the chunk's light section data per call, called per particle per frame.
@@ -34,8 +34,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
* duration of {@code move}.
*
*
- * Ported from {@code zako.opt.mixin.particle.ParticleMixin} (ZakoOpt,
- * LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* Light-cache gated on the {@code microOpts} toggle (Stage 1 wiring);
* physics-skip also gated on {@code microOpts} since both are micro-opts.
* When the toggle is off, the original code runs unchanged.
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlCommandEncoderStatsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlCommandEncoderStatsMixin.java
new file mode 100644
index 00000000..a27df7d6
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlCommandEncoderStatsMixin.java
@@ -0,0 +1,41 @@
+package dev.loki.lovisual.mixins.optimize.stats;
+
+import dev.loki.lovisual.render.engine.optimize.profile.DrawStats;
+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;
+
+/**
+ * Counts the four places {@code GlCommandEncoder} actually issues a draw —
+ * {@code executeDraw}, {@code executeDraws}, {@code executeDrawIndirect} and
+ * {@code executeDrawMultiple} do not call each other, so exactly one of them
+ * sees every submission.
+ *
+ *
Part of Stage 3 step 1 in {@code mod/docs/optimize-layer-design.md};
+ * the counter is a boolean test unless F3 is open with Optimize on.
+ *
+ */
+@Mixin(targets = "com.mojang.blaze3d.opengl.GlCommandEncoder")
+public abstract class GlCommandEncoderStatsMixin {
+
+ @Inject(method = "executeDraw", at = @At("HEAD"))
+ private void lovisual$countDraw(CallbackInfo ci) {
+ DrawStats.draw();
+ }
+
+ @Inject(method = "executeDraws", at = @At("HEAD"))
+ private void lovisual$countDraws(CallbackInfo ci) {
+ DrawStats.draw();
+ }
+
+ @Inject(method = "executeDrawIndirect", at = @At("HEAD"))
+ private void lovisual$countDrawIndirect(CallbackInfo ci) {
+ DrawStats.draw();
+ }
+
+ @Inject(method = "executeDrawMultiple", at = @At("HEAD"))
+ private void lovisual$countDrawMultiple(CallbackInfo ci) {
+ DrawStats.draw();
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlRenderPassStatsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlRenderPassStatsMixin.java
new file mode 100644
index 00000000..c948fd6f
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlRenderPassStatsMixin.java
@@ -0,0 +1,30 @@
+package dev.loki.lovisual.mixins.optimize.stats;
+
+import com.mojang.blaze3d.pipeline.RenderPipeline;
+import dev.loki.lovisual.render.engine.optimize.profile.DrawStats;
+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;
+
+/**
+ * Counts {@code RenderPass.setPipeline} — one per state selection, repeats
+ * included — and feeds the pipeline name to the per-frame top list, so F3
+ * shows which pipelines a frame spends its draws on. That is the input for
+ * the "sort draws by pipeline" idea in
+ * {@code mod/docs/optimize-layer-design.md}.
+ *
+ *
Part of Stage 3 step 1 in {@code mod/docs/optimize-layer-design.md}.
+ *
+ */
+@Mixin(targets = "com.mojang.blaze3d.opengl.GlRenderPass")
+public abstract class GlRenderPassStatsMixin {
+
+ @Inject(method = "setPipeline", at = @At("HEAD"))
+ private void lovisual$countPipeline(RenderPipeline pipeline, CallbackInfo ci) {
+ if (pipeline == null || pipeline.getLocation() == null) {
+ return;
+ }
+ DrawStats.pipeline(pipeline.getLocation().getPath());
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlStateManagerStatsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlStateManagerStatsMixin.java
new file mode 100644
index 00000000..d8101e82
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlStateManagerStatsMixin.java
@@ -0,0 +1,33 @@
+package dev.loki.lovisual.mixins.optimize.stats;
+
+import com.mojang.blaze3d.opengl.GlStateManager;
+import dev.loki.lovisual.render.engine.optimize.profile.DrawStats;
+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;
+
+/**
+ * Counts framebuffer binds that actually reach the driver: the injection sits
+ * on the {@code glBindFramebuffer} calls inside {@code _glBindFramebuffer}, so
+ * binds GlStateManager's read/write cache absorbs are not counted. A target of
+ * {@code GL_FRAMEBUFFER} runs both branches and is counted twice — two GL calls
+ * really happen.
+ *
+ *
Part of Stage 3 step 1 in {@code mod/docs/optimize-layer-design.md}.
+ *
+ */
+@Mixin(GlStateManager.class)
+public abstract class GlStateManagerStatsMixin {
+
+ @Inject(
+ method = "_glBindFramebuffer",
+ at = @At(
+ value = "INVOKE",
+ target = "Lorg/lwjgl/opengl/GL33C;glBindFramebuffer(II)V"
+ )
+ )
+ private static void lovisual$countBind(int target, int framebuffer, CallbackInfo ci) {
+ DrawStats.bind();
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/world/LevelRendererOutlineMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/world/LevelRendererOutlineMixin.java
index 54a25f77..4a53d532 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/world/LevelRendererOutlineMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/world/LevelRendererOutlineMixin.java
@@ -27,8 +27,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
* entity was seen this frame.
*
*
- *
Ported from {@code zako.opt.mixin.entity.LevelRendererOutlineMixin}
- * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
*
*
Gated on the {@code microOpts} toggle via {@link OutlineTracker#enabled()}.
* When the toggle is off, all hooks fall through to the vanilla call.
@@ -50,9 +48,9 @@ public abstract class LevelRendererOutlineMixin {
// 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; ZakoOpt uses @Local to grab
- // the actual target reference, but that requires MixinExtras @Local which
- // is brittle across MC version changes. The cost of the conservative path
+ // 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);
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemFeatureRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemFeatureRendererMixin.java
index a9461e47..596999e4 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemFeatureRendererMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemFeatureRendererMixin.java
@@ -2,10 +2,16 @@ package dev.loki.lovisual.mixins.render.item;
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
import com.llamalad7.mixinextras.injector.ModifyReturnValue;
+import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
+import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.llamalad7.mixinextras.sugar.Local;
+import dev.loki.lovisual.render.engine.optimize.entity.ItemLod;
+import java.util.List;
import net.minecraft.client.renderer.feature.ItemFeatureRenderer;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.resources.model.geometry.BakedQuad;
+import net.minecraft.world.item.ItemDisplayContext;
+import org.joml.Matrix4f;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
@@ -56,4 +62,20 @@ public abstract class ItemFeatureRendererMixin {
// corrupt the following world translucent composite, so item layers keep their own type.
return original;
}
+
+ @WrapOperation(
+ method = {"prepareMainSubmit", "prepareOutlineSubmit", "prepareFoilSubmit"},
+ at = @At(value = "INVOKE",
+ target = "Lnet/minecraft/client/renderer/feature/ItemFeatureRenderer$Submit;quads()Ljava/util/List;")
+ )
+ private List lovisual$itemLod(ItemFeatureRenderer.Submit submit, Operation> original) {
+ List quads = original.call(submit);
+ ItemDisplayContext context = submit.displayContext();
+ if (context.firstPerson() || context == ItemDisplayContext.GUI || !ItemLod.enabled()) {
+ return quads;
+ }
+ Matrix4f m = submit.pose().pose();
+ float dSq = m.m30() * m.m30() + m.m31() * m.m31() + m.m32() * m.m32();
+ return dSq > ItemLod.distanceSq() ? ItemLod.flat(quads) : quads;
+ }
}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemLayerAccessor.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemLayerAccessor.java
new file mode 100644
index 00000000..fcfec516
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemLayerAccessor.java
@@ -0,0 +1,28 @@
+package dev.loki.lovisual.mixins.render.item.bounds;
+
+import java.util.function.Supplier;
+import net.minecraft.client.renderer.item.ItemStackRenderState;
+import net.minecraft.client.resources.model.cuboid.ItemTransform;
+import org.joml.Matrix4f;
+import org.joml.Vector3fc;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+/**
+ * Stage 3 step 5: exposes the layer internals {@link ItemStackRenderStateMixin}
+ * keys the shared {@code ItemBounds} cache on — the extents supplier identity,
+ * the item transform and the local matrix whose identity bit short-circuits
+ * the cache.
+ */
+@Mixin(ItemStackRenderState.LayerRenderState.class)
+public interface ItemLayerAccessor {
+
+ @Accessor("extents")
+ Supplier lovisual$extents();
+
+ @Accessor("itemTransform")
+ ItemTransform lovisual$itemTransform();
+
+ @Accessor("localTransform")
+ Matrix4f lovisual$localTransform();
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemStackRenderStateMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemStackRenderStateMixin.java
new file mode 100644
index 00000000..e2f1663a
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemStackRenderStateMixin.java
@@ -0,0 +1,39 @@
+package dev.loki.lovisual.mixins.render.item.bounds;
+
+import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
+import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
+import dev.loki.lovisual.render.engine.optimize.entity.ItemBounds;
+import net.minecraft.client.renderer.item.ItemStackRenderState;
+import net.minecraft.world.phys.AABB;
+import org.joml.Matrix4fc;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+
+/**
+ * Stage 3 step 5: single-layer item model bounds served from
+ * {@link ItemBounds} instead of recomputed every frame. The vanilla path
+ * walks the layer's extents supplier; the cache key is that supplier's
+ * identity, which is stable per baked model. Multi-layer items and items
+ * with a non-identity local transform keep the vanilla computation.
+ */
+@Mixin(ItemStackRenderState.class)
+public abstract class ItemStackRenderStateMixin {
+
+ @Shadow
+ private int activeLayerCount;
+
+ @Shadow
+ private ItemStackRenderState.LayerRenderState[] layers;
+
+ @WrapMethod(method = "getModelBoundingBox")
+ private AABB lovisual$sharedBounds(Operation original) {
+ if (!ItemBounds.enabled() || activeLayerCount != 1) {
+ return original.call();
+ }
+ ItemLayerAccessor layer = (ItemLayerAccessor) layers[0];
+ if ((layer.lovisual$localTransform().properties() & Matrix4fc.PROPERTY_IDENTITY) == 0) {
+ return original.call();
+ }
+ return ItemBounds.get(layer.lovisual$extents(), layer.lovisual$itemTransform(), original::call);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/MinecraftMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/main/MinecraftMixin.java
index 951617e2..a2ed3278 100644
--- a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/MinecraftMixin.java
+++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/main/MinecraftMixin.java
@@ -35,6 +35,7 @@ import dev.loki.lovisual.features.module.modules.player.interact.NoDelay;
import dev.loki.lovisual.features.module.modules.player.interact.NoInteract;
import dev.loki.lovisual.features.module.modules.visuals.camera.Freecam;
import dev.loki.lovisual.features.relations.StaffTracker;
+import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze;
import dev.loki.lovisual.render.helpers.util.TickDelta;
import dev.loki.lovisual.util.combat.tracking.AntiBotTracker;
import dev.loki.lovisual.util.session.SessionChanger;
@@ -257,6 +258,11 @@ public class MinecraftMixin implements MinecraftGameConfigHolder {
AntiBotTracker.INSTANCE.reset();
}
+ @Inject(method = "renderFrame", at = @At("HEAD"))
+ private void lovisual$animFreezeFrame(boolean tick, CallbackInfo ci) {
+ AnimFreeze.frame++;
+ }
+
@Inject(
method = "renderFrame",
at = @At(
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/core/system/LoVisualRenderSystem.java b/mod/src/main/java/dev/loki/lovisual/render/engine/core/system/LoVisualRenderSystem.java
index cf847c12..eeffb842 100644
--- a/mod/src/main/java/dev/loki/lovisual/render/engine/core/system/LoVisualRenderSystem.java
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/core/system/LoVisualRenderSystem.java
@@ -14,6 +14,7 @@ import dev.loki.lovisual.render.engine.core.policy.LightPolicy;
import dev.loki.lovisual.render.engine.core.policy.fog.VanillaWorldFogProvider;
import dev.loki.lovisual.render.engine.depth.WorldSceneDepth;
import dev.loki.lovisual.render.engine.framegraph.LoVisualFrameGraph;
+import dev.loki.lovisual.render.engine.optimize.profile.FrameProfiler;
import dev.loki.lovisual.render.engine.profiler.frame.FrameStutterProfiler;
import dev.loki.lovisual.render.engine.profiler.frame.RenderFrameProfiler;
import dev.loki.lovisual.render.engine.rhi.resource.LoVisualRhi;
@@ -220,6 +221,7 @@ public enum LoVisualRenderSystem {
TextRenderSystem.beginFrame();
sodiumFrame = SODIUM.beginFrame();
RenderFrameProfiler.beginFrame(frameId);
+ FrameProfiler.beginFrame();
frameOpen = true;
submissionEnded = false;
lifecycle = FrameLifecycle.RECORDING;
@@ -319,6 +321,7 @@ public enum LoVisualRenderSystem {
FrameStutterProfiler.onFramePresented(rhiStatsSnapshot(), uniformStatsSnapshot(), resourceStatsSnapshot());
}
RenderFrameProfiler.endFrame(null, null);
+ FrameProfiler.endFrame();
lifecycle = FrameLifecycle.PRESENTED;
} finally {
currentContext = null;
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java
index a1c9d55d..f818d9bf 100755
--- a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java
@@ -8,9 +8,11 @@ package dev.loki.lovisual.render.engine.optimize;
* future mixins) — never by the module class itself.
*
* 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.
+ * logic plus the toggle snapshot. Lazy clear and the TBO cache ride
+ * {@link #microOpts()}; FBO share (Stage 3 step 2) has its own
+ * {@link #fboShare()} flag. GL/Vulkan-specific helpers that need their own
+ * holder (skin atlas, HUD cache) arrive in later Stage 3 steps alongside the
+ * mixins that consume them.
*
*
All module settings are resolved into volatile primitive fields once per frame.
* System-property overrides are not supported here (the {@code Optimize} module's
@@ -31,6 +33,7 @@ public enum OptimizeToggles {
private static volatile boolean blockEntityCache;
private static volatile boolean preparedTextCache;
private static volatile boolean microOpts;
+ private static volatile boolean fboShare;
public static boolean entityLod() { return entityLod; }
public static int entityLodDistance() { return entityLodDistance; }
@@ -43,6 +46,7 @@ public enum OptimizeToggles {
public static boolean blockEntityCache() { return blockEntityCache; }
public static boolean preparedTextCache() { return preparedTextCache; }
public static boolean microOpts() { return microOpts; }
+ public static boolean fboShare() { return fboShare; }
/**
* Push a full snapshot. Called by {@code Optimize.onEnable/onDisable/onFrame}
@@ -55,7 +59,8 @@ public enum OptimizeToggles {
boolean spawnerTickCache,
boolean blockEntityCache,
boolean preparedTextCache,
- boolean microOpts) {
+ boolean microOpts,
+ boolean fboShare) {
OptimizeToggles.entityLod = entityLod;
OptimizeToggles.entityLodDistance = entityLodDistance;
OptimizeToggles.particleLod = particleLod;
@@ -67,10 +72,11 @@ public enum OptimizeToggles {
OptimizeToggles.blockEntityCache = blockEntityCache;
OptimizeToggles.preparedTextCache = preparedTextCache;
OptimizeToggles.microOpts = microOpts;
+ OptimizeToggles.fboShare = fboShare;
}
/** 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);
+ apply(false, 0, false, false, false, false, 0, false, false, false, false, false);
}
}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreeze.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreeze.java
new file mode 100644
index 00000000..e16dc7b7
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreeze.java
@@ -0,0 +1,208 @@
+package dev.loki.lovisual.render.engine.optimize.entity;
+
+import com.mojang.blaze3d.vertex.BufferBuilder;
+import com.mojang.blaze3d.vertex.ByteBufferBuilder;
+import com.mojang.blaze3d.vertex.DefaultVertexFormat;
+import com.mojang.blaze3d.vertex.MeshData;
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import com.mojang.blaze3d.vertex.VertexFormat;
+import dev.loki.lovisual.features.module.modules.misc.optimize.entity.StateEntity;
+import dev.loki.lovisual.render.engine.optimize.gl.RawVertices;
+import net.minecraft.client.model.Model;
+import net.minecraft.client.renderer.entity.state.EntityRenderState;
+import net.minecraft.client.renderer.rendertype.RenderType;
+import net.minecraft.client.renderer.texture.TextureAtlasSprite;
+import net.minecraft.world.entity.Entity;
+import org.joml.Matrix4f;
+import org.lwjgl.system.MemoryStack;
+import org.lwjgl.system.MemoryUtil;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Stage 3 step 4: far-entity animation freeze. A distant entity's model
+ * vertices are captured once per game tick and, on the frames in between,
+ * replayed as raw vertex copies shifted to the entity's current position —
+ * the model is no longer rebuilt (and re-animated) every frame, only moved.
+ *
+ *
Distance gate: farther than {@link #distance()} blocks from the camera
+ * (squared comparison against the pose translation). Close entities render
+ * through the vanilla path untouched.
+ *
+ *
The capture is keyed per {@code (model, renderType, sprite)} triplet per
+ * entity — one entity may submit the same model several times per frame
+ * (body + armor layers); the n-th submission reuses the n-th capture. Parts
+ * that are rebuilt every frame elsewhere (e.g. armor trim shimmer) are NOT
+ * replayed — callers decide via {@link #freezes}, so those parts stay in sync
+ * with the frozen rest instead of drifting.
+ *
+ *
Requires the raw-vertex push path ({@code RawVertices} + Sodium or the
+ * vanilla {@code RawBuffer} mixin); when neither consumer accepts raw bytes,
+ * {@link #render} returns {@code false} and vanilla runs.
+ *
+ *
Toggle: {@link #configure(boolean, int)}, pushed by the Optimize module
+ * once per frame. The frame counter advances from the {@code renderFrame}
+ * hook in {@code MinecraftMixin}.
+ */
+public enum AnimFreeze {
+ ;
+
+ /** Advances once per rendered frame; captures are reused within one frame. */
+ public static volatile long frame;
+
+ private static final ByteBufferBuilder SCRATCH = new ByteBufferBuilder(16384);
+ private static ByteBuffer emitBuffer = MemoryUtil.memAlloc(16384);
+
+ private static volatile boolean enabled;
+ private static volatile int distance = 16;
+
+ /** Push a full snapshot — called by the Optimize module once per frame. */
+ public static void configure(boolean enable, int distanceBlocks) {
+ enabled = enable;
+ distance = distanceBlocks;
+ }
+
+ /** Reset to "off" — used by {@code Optimize.onDisable}. */
+ public static void reset() {
+ configure(false, 0);
+ }
+
+ public static boolean enabled() {
+ return enabled;
+ }
+
+ public static int distance() {
+ return distance;
+ }
+
+ /**
+ * True when this (state, pose) pair belongs to a far entity whose parts
+ * must all replay the frozen capture — parts built every frame elsewhere
+ * consult this so they don't drift off the frozen rest.
+ */
+ public static boolean freezes(Object state, Matrix4f pose) {
+ if (!enabled || !(state instanceof EntityRenderState s)
+ || ((StateEntity) s).lovisual$entity() == null) {
+ return false;
+ }
+ float d = distance;
+ return beyondDistance(pose.m30(), pose.m31(), pose.m32(), d);
+ }
+
+ /**
+ * Capture-once-per-tick / replay-in-between for one model submission.
+ *
+ * @return {@code false} when not frozen (caller renders as usual)
+ */
+ public static boolean render(Entity entity, Model> model, RenderType type, TextureAtlasSprite sprite,
+ Matrix4f pose, VertexConsumer target, Consumer draw) {
+ float x = pose.m30(), y = pose.m31(), z = pose.m32();
+ float d = distance;
+ if (!enabled || !beyondDistance(x, y, z, d) || !RawVertices.accepts(target, type.format())) {
+ return false;
+ }
+ AnimHolder owner = (AnimHolder) entity;
+ Holder holder = owner.lovisual$anim();
+ if (holder == null) {
+ holder = new Holder();
+ owner.lovisual$anim(holder);
+ }
+ Capture capture = find(holder, model, type, sprite);
+ long tick = entity.level().getGameTime();
+ if (capture.tick != tick) {
+ BufferBuilder builder = new BufferBuilder(SCRATCH, type.primitiveTopology(), type.format());
+ draw.accept(sprite == null ? builder : sprite.wrap(builder));
+ capture.count = 0;
+ try (MeshData mesh = builder.build()) {
+ if (mesh != null) {
+ ByteBuffer src = mesh.vertexBuffer();
+ int bytes = src.remaining();
+ if (capture.data == null || capture.data.capacity() < bytes) {
+ capture.data = ByteBuffer.allocateDirect(bytes * 2);
+ }
+ MemoryUtil.memCopy(MemoryUtil.memAddress(src), MemoryUtil.memAddress0(capture.data), bytes);
+ capture.count = mesh.drawState().vertexCount();
+ }
+ }
+ capture.tick = tick;
+ capture.x = x;
+ capture.y = y;
+ capture.z = z;
+ }
+ if (capture.count > 0) {
+ emit(capture, type.format(), x - capture.x, y - capture.y, z - capture.z, target);
+ }
+ return true;
+ }
+
+ /** Squared distance from the origin — shared by {@link #freezes} and {@link #render}. */
+ static boolean beyondDistance(float x, float y, float z, float dist) {
+ return x * x + y * y + z * z > dist * dist;
+ }
+
+ /** Per-entity bag of captures. */
+ public static final class Holder {
+ final List captures = new ArrayList<>(4);
+
+ Holder() {
+ }
+ }
+
+ static final class Capture {
+ Object model;
+ RenderType type;
+ TextureAtlasSprite sprite;
+ int ordinal;
+ long usedFrame = -1;
+ long tick = Long.MIN_VALUE;
+ ByteBuffer data;
+ int count;
+ float x, y, z;
+ }
+
+ /** The same model may be submitted several times per entity; the n-th call this frame reuses the n-th capture. */
+ private static Capture find(Holder holder, Object model, RenderType type, TextureAtlasSprite sprite) {
+ int ordinal = 0;
+ for (Capture c : holder.captures) {
+ if (c.model == model && c.type == type && c.sprite == sprite && c.usedFrame == frame) {
+ ordinal++;
+ }
+ }
+ for (Capture c : holder.captures) {
+ if (c.model == model && c.type == type && c.sprite == sprite && c.ordinal == ordinal) {
+ c.usedFrame = frame;
+ return c;
+ }
+ }
+ Capture c = new Capture();
+ c.model = model;
+ c.type = type;
+ c.sprite = sprite;
+ c.ordinal = ordinal;
+ c.usedFrame = frame;
+ holder.captures.add(c);
+ return c;
+ }
+
+ private static void emit(Capture capture, VertexFormat format, float dx, float dy, float dz, VertexConsumer target) {
+ int stride = format.getVertexSize();
+ int pos = format.getElement(DefaultVertexFormat.POSITION_SEMANTIC_NAME).offset();
+ int size = capture.count * stride;
+ if (emitBuffer.capacity() < size) {
+ emitBuffer = MemoryUtil.memRealloc(emitBuffer, size * 2);
+ }
+ long dst = MemoryUtil.memAddress0(emitBuffer);
+ MemoryUtil.memCopy(MemoryUtil.memAddress0(capture.data), dst, size);
+ for (long v = dst + pos, end = dst + size; v < end; v += stride) {
+ MemoryUtil.memPutFloat(v, MemoryUtil.memGetFloat(v) + dx);
+ MemoryUtil.memPutFloat(v + 4, MemoryUtil.memGetFloat(v + 4) + dy);
+ MemoryUtil.memPutFloat(v + 8, MemoryUtil.memGetFloat(v + 8) + dz);
+ }
+ try (MemoryStack stack = MemoryStack.stackPush()) {
+ RawVertices.push(target, stack, dst, capture.count, format);
+ }
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimHolder.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimHolder.java
new file mode 100644
index 00000000..b502ce25
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimHolder.java
@@ -0,0 +1,14 @@
+package dev.loki.lovisual.render.engine.optimize.entity;
+
+/**
+ * Mixin-contract: per-entity storage for frozen animation captures
+ * ({@link AnimFreeze.Holder}). Implemented on {@code Entity} by
+ * {@code EntityAnimHolderMixin}; render code reaches it through the
+ * {@code EntityRenderState} back-reference ({@code StateEntity}).
+ */
+public interface AnimHolder {
+
+ AnimFreeze.Holder lovisual$anim();
+
+ void lovisual$anim(AnimFreeze.Holder holder);
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBounds.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBounds.java
new file mode 100644
index 00000000..648151ed
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBounds.java
@@ -0,0 +1,52 @@
+package dev.loki.lovisual.render.engine.optimize.entity;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+import net.minecraft.client.resources.model.cuboid.ItemTransform;
+import net.minecraft.world.phys.AABB;
+import org.joml.Vector3fc;
+
+/**
+ * Stage 3 step 5: shared model bounds for single-layer items. Render states
+ * are rebuilt every frame, so a cache stored on the state itself dies with
+ * it; the extents supplier identity is stable per baked model and works as
+ * a long-lived key here.
+ */
+public enum ItemBounds {
+ ;
+
+ private static final Map, Entry> BOUNDS = new IdentityHashMap<>();
+
+ private static volatile boolean enabled;
+
+ public static void configure(boolean on) {
+ enabled = on;
+ }
+
+ public static void reset() {
+ enabled = false;
+ BOUNDS.clear();
+ }
+
+ public static boolean enabled() {
+ return enabled;
+ }
+
+ public static AABB get(Supplier extents, ItemTransform transform, Supplier compute) {
+ Entry e = BOUNDS.get(extents);
+ if (e != null && e.transform() == transform) {
+ return e.bounds();
+ }
+ AABB bounds = compute.get();
+ // wholesale clear instead of LRU; models only change on resource reload
+ if (BOUNDS.size() > 4096) {
+ BOUNDS.clear();
+ }
+ BOUNDS.put(extents, new Entry(transform, bounds));
+ return bounds;
+ }
+
+ private record Entry(ItemTransform transform, AABB bounds) {
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLod.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLod.java
new file mode 100644
index 00000000..b1615e7c
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLod.java
@@ -0,0 +1,91 @@
+package dev.loki.lovisual.render.engine.optimize.entity;
+
+import com.google.common.collect.MapMaker;
+import java.util.List;
+import java.util.Map;
+import net.minecraft.client.resources.model.geometry.BakedQuad;
+import net.minecraft.core.Direction;
+import org.joml.Vector3fc;
+
+/**
+ * Stage 3 step 5: far-item level of detail. Distant flat items (generated
+ * item models with a few large Z faces plus thin edge strips) drop their
+ * side geometry — at 8+ blocks the two big faces read the same on screen.
+ *
+ * The quad list handed to the item renderer is a reused per-render-state
+ * buffer, so the flat-list cache is keyed by the model's first quad with
+ * weak keys; a model rebuild invalidates the entry for free.
+ */
+public enum ItemLod {
+ ;
+
+ private static final float EDGE = 1f / 16f + 1e-4f;
+ private static final Map FLAT = new MapMaker().weakKeys().makeMap();
+
+ private static volatile boolean enabled;
+ private static volatile float distanceSq;
+
+ public static void configure(boolean on, int distance) {
+ enabled = on;
+ distanceSq = (float) distance * (float) distance;
+ }
+
+ public static void reset() {
+ enabled = false;
+ distanceSq = 0f;
+ FLAT.clear();
+ }
+
+ public static boolean enabled() {
+ return enabled;
+ }
+
+ public static float distanceSq() {
+ return distanceSq;
+ }
+
+ /**
+ * Returns the model's Z faces only when the whole model is flat (every
+ * quad is an Z-axis face or thinner than 1/16 block) and has at least
+ * eight quads; anything else is returned unchanged.
+ */
+ public static List flat(List quads) {
+ if (quads.isEmpty()) {
+ return quads;
+ }
+ Flat cached = FLAT.get(quads.getFirst());
+ if (cached == null || cached.sourceSize() != quads.size()) {
+ cached = new Flat(quads.size(), compute(quads));
+ FLAT.put(quads.getFirst(), cached);
+ }
+ return cached.quads() == null ? quads : cached.quads();
+ }
+
+ private record Flat(int sourceSize, List quads) {
+ }
+
+ private static List compute(List quads) {
+ if (quads.size() < 8) {
+ return null;
+ }
+ for (BakedQuad q : quads) {
+ if (q.direction().getAxis() != Direction.Axis.Z && !thin(q)) {
+ return null;
+ }
+ }
+ List faces = List.copyOf(
+ quads.stream().filter(q -> q.direction().getAxis() == Direction.Axis.Z).toList());
+ return faces.isEmpty() ? null : faces;
+ }
+
+ private static boolean thin(BakedQuad q) {
+ float minZ = Float.MAX_VALUE;
+ float maxZ = -Float.MAX_VALUE;
+ for (int i = 0; i < 4; i++) {
+ Vector3fc p = q.position(i);
+ minZ = Math.min(minZ, p.z());
+ maxZ = Math.max(maxZ, p.z());
+ }
+ return maxZ - minZ <= EDGE;
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawBuffer.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawBuffer.java
new file mode 100644
index 00000000..efbea398
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawBuffer.java
@@ -0,0 +1,23 @@
+package dev.loki.lovisual.render.engine.optimize.gl;
+
+import com.mojang.blaze3d.vertex.VertexFormat;
+
+/**
+ * Stage 3 step 7: a {@code BufferBuilder} that accepts ready-made vertex
+ * bytes pushed straight from memory — what Sodium's {@code VertexBufferWriter}
+ * does, reimplemented for the vanilla builder so optimizations work without
+ * Sodium.
+ *
+ * Implemented on {@code BufferBuilder} by {@code BufferBuilderRawMixin}.
+ */
+public interface RawBuffer {
+
+ /** True when this buffer's vertex format is exactly {@code format}. */
+ boolean lovisual$accepts(VertexFormat format);
+
+ /**
+ * Copy {@code count} whole vertices from {@code src} (a native address)
+ * into the buffer.
+ */
+ void lovisual$push(long src, int count);
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawVertices.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawVertices.java
new file mode 100644
index 00000000..824a97d7
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawVertices.java
@@ -0,0 +1,38 @@
+package dev.loki.lovisual.render.engine.optimize.gl;
+
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import com.mojang.blaze3d.vertex.VertexFormat;
+import net.fabricmc.loader.api.FabricLoader;
+import org.lwjgl.system.MemoryStack;
+
+/**
+ * Stage 3 step 7: dispatches ready vertex bytes into a consumer — through
+ * Sodium's {@code VertexBufferWriter} when Sodium is installed, else into a
+ * vanilla {@code BufferBuilder} via {@link RawBuffer}.
+ *
+ *
+ * Optional-dependency pattern: when Sodium is absent the {@code SODIUM}
+ * flag short-circuits before {@link SodiumVertices} is ever touched, so the
+ * Sodium API classes are never loaded.
+ */
+public enum RawVertices {
+ ;
+
+ private static final boolean SODIUM = FabricLoader.getInstance().isModLoaded("sodium");
+
+ /** True when this consumer can receive raw vertices in {@code format}. */
+ public static boolean accepts(VertexConsumer consumer, VertexFormat format) {
+ return SODIUM
+ ? SodiumVertices.accepts(consumer)
+ : consumer instanceof RawBuffer raw && raw.lovisual$accepts(format);
+ }
+
+ /** Push {@code count} whole vertices from native address {@code src}. */
+ public static void push(VertexConsumer consumer, MemoryStack stack, long src, int count, VertexFormat format) {
+ if (SODIUM) {
+ SodiumVertices.push(consumer, stack, src, count, format);
+ } else {
+ ((RawBuffer) consumer).lovisual$push(src, count);
+ }
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbos.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbos.java
new file mode 100644
index 00000000..db658c26
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbos.java
@@ -0,0 +1,88 @@
+package dev.loki.lovisual.render.engine.optimize.gl;
+
+import com.mojang.blaze3d.opengl.GlStateManager;
+import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap;
+import org.lwjgl.opengl.ARBDirectStateAccess;
+import org.lwjgl.opengl.GL;
+import org.lwjgl.opengl.GL11C;
+
+/**
+ * Stage 3 step 2: depth-texture id → FBO that already has that texture
+ * attached at mip 0. Vanilla's {@code clearDepthTexture} attaches the depth
+ * texture to a scratch FBO, clears, detaches and unbinds every time; with a
+ * cached FBO the clear is a single {@code glClearNamedFramebufferfv}.
+ *
+ *
+ *
{@link #remember}/{@link #forget} are driven by
+ * {@code mixins/optimize/gl/fbo/FrameBufferCacheMixin} on every
+ * {@code getFbo}/{@code destroyFbo}; {@link #clear} is called (gated on the
+ * {@code fbo_share} toggle) from {@code GlCommandEncoderDepthClearMixin}.
+ * Without GL 4.5 / {@code ARB_direct_state_access} the map still tracks, but
+ * {@code clear} returns {@code false} and the vanilla path runs.
+ *
+ *
GL-only: on Vulkan this class is never reached (the mixins that feed it
+ * target {@code com.mojang.blaze3d.opengl.*}).
+ */
+public enum SharedDepthFbos {
+ ;
+
+ private static final Int2IntOpenHashMap FBO_BY_DEPTH = new Int2IntOpenHashMap();
+ private static final float[] VALUE = new float[1];
+ private static Boolean dsa;
+
+ /** Remember that {@code fbo} has {@code depthId} attached at mip 0. */
+ public static void remember(int depthId, int fbo) {
+ FBO_BY_DEPTH.put(depthId, fbo);
+ }
+
+ /** Drop every mapping pointing at {@code fbo} — its id may be reused by the next FBO. */
+ public static void forget(int fbo) {
+ FBO_BY_DEPTH.values().removeIf(f -> f == fbo);
+ }
+
+ /**
+ * Clear the depth texture in place through its cached FBO.
+ *
+ * @param depthId the GL id of the depth texture
+ * @param depth the clear value (0..1)
+ * @return {@code true} when the clear ran (caller should skip vanilla);
+ * {@code false} to fall through to the vanilla attach/clear/detach
+ */
+ public static boolean clear(int depthId, double depth) {
+ int fbo = FBO_BY_DEPTH.getOrDefault(depthId, 0);
+ if (fbo == 0) {
+ return false;
+ }
+ if (dsa == null) {
+ dsa = GL.getCapabilities().OpenGL45 || GL.getCapabilities().GL_ARB_direct_state_access;
+ }
+ if (!dsa) {
+ return false;
+ }
+ // glClearNamedFramebuffer obeys the depth write mask and scissor test,
+ // like glClear in the vanilla path — but make both states explicit.
+ GlStateManager._depthMask(true);
+ GlStateManager._disableScissorTest();
+ VALUE[0] = (float) depth;
+ ARBDirectStateAccess.glClearNamedFramebufferfv(fbo, GL11C.GL_DEPTH, 0, VALUE);
+ return true;
+ }
+
+ // --- Test helpers (headless-safe: never touch GL) ---
+
+ /** Test-only: drop all mappings and the cached DSA capability. */
+ static void resetForTesting() {
+ FBO_BY_DEPTH.clear();
+ dsa = null;
+ }
+
+ /** Test-only: number of tracked depth textures. */
+ static int sizeForTesting() {
+ return FBO_BY_DEPTH.size();
+ }
+
+ /** Test-only: the FBO currently mapped for {@code depthId}, or 0. */
+ static int fboForTesting(int depthId) {
+ return FBO_BY_DEPTH.getOrDefault(depthId, 0);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SodiumVertices.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SodiumVertices.java
new file mode 100644
index 00000000..2d37393f
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SodiumVertices.java
@@ -0,0 +1,40 @@
+package dev.loki.lovisual.render.engine.optimize.gl;
+
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import com.mojang.blaze3d.vertex.VertexFormat;
+import net.caffeinemc.mods.sodium.api.vertex.buffer.VertexBufferWriter;
+import org.lwjgl.system.MemoryStack;
+
+/**
+ * Stage 3 step 7: the only class touching Sodium's {@code VertexBufferWriter}
+ * API — kept separate so it is loaded only when Sodium is installed
+ * ({@link RawVertices} never references it on the vanilla path).
+ *
+ */
+final class SodiumVertices {
+
+ // tryOf is an interface instanceof plus an interface call, which thrashes HotSpot's
+ // per-class type check cache when a handful of consumer classes alternate; most calls
+ // ask about the same buffer as the last one
+ private static Object lastWriter;
+
+ private SodiumVertices() {
+ }
+
+ static boolean accepts(VertexConsumer consumer) {
+ if (consumer == lastWriter) {
+ return true;
+ }
+ if (VertexBufferWriter.tryOf(consumer) == null) {
+ return false;
+ }
+ lastWriter = consumer;
+ return true;
+ }
+
+ static void push(VertexConsumer consumer, MemoryStack stack, long src, int count, VertexFormat format) {
+ (consumer == lastWriter
+ ? (VertexBufferWriter) consumer
+ : VertexBufferWriter.of(consumer)).push(stack, src, count, format);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudCache.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudCache.java
new file mode 100644
index 00000000..2e4bfa36
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudCache.java
@@ -0,0 +1,160 @@
+package dev.loki.lovisual.render.engine.optimize.hud;
+
+import com.mojang.blaze3d.GpuFormat;
+import com.mojang.blaze3d.PrimitiveTopology;
+import com.mojang.blaze3d.pipeline.BlendFunction;
+import com.mojang.blaze3d.pipeline.ColorTargetState;
+import com.mojang.blaze3d.pipeline.RenderPipeline;
+import com.mojang.blaze3d.pipeline.RenderTarget;
+import com.mojang.blaze3d.pipeline.TextureTarget;
+import com.mojang.blaze3d.systems.RenderPass;
+import com.mojang.blaze3d.systems.RenderSystem;
+import com.mojang.blaze3d.textures.FilterMode;
+import java.util.Optional;
+import java.util.OptionalDouble;
+import net.fabricmc.loader.api.FabricLoader;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.renderer.BindGroupLayouts;
+import net.minecraft.resources.Identifier;
+import org.joml.Vector4f;
+
+/**
+ * Stage 3 step 6: the HUD is drawn into its own texture at the monitor
+ * refresh rate; frames in between only blend that texture in. Crosshair and
+ * camera overlays (vignette, pumpkin...) blend with the world, so they stay
+ * live every frame.
+ *
+ *
Disabled outright when a foreign HUD cache (axolotlclient, exordium,
+ * gnetum) or VulkanMod is loaded — two caches stacked on one HUD break each
+ * other. Off by default: the LoVisual deferred-HUD marker path draws its own
+ * ranges straight into the main target and does not go through the cache.
+ */
+public enum HudCache {
+ ;
+
+ // the HUD texture holds premultiplied colour: GUI blending onto transparent black
+ // leaves colour * alpha; write mask 7 = RGB only, alpha is not written
+ private static final RenderPipeline COMPOSITE = RenderPipeline.builder()
+ .withBindGroupLayout(BindGroupLayouts.GLOBALS)
+ .withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/hud_composite"))
+ .withVertexShader("core/screenquad")
+ .withFragmentShader("core/blit_screen")
+ .withBindGroupLayout(BindGroupLayouts.IN_SAMPLER)
+ .withColorTargetState(new ColorTargetState(
+ Optional.of(BlendFunction.TRANSLUCENT_PREMULTIPLIED_ALPHA), GpuFormat.RGBA8_UNORM, 7))
+ .withPrimitiveTopology(PrimitiveTopology.TRIANGLES)
+ .build();
+
+ private static final Vector4f CLEAR = new Vector4f(0, 0, 0, 0);
+ private static final boolean FOREIGN_HUD = isModLoaded("axolotlclient")
+ || isModLoaded("exordium")
+ || isModLoaded("gnetum");
+ private static final boolean VULKAN = isModLoaded("vulkanmod");
+
+ private static TextureTarget target;
+ // the game's own main target, taken before the HUD: mods (Xaero's minimap) swap
+ // their own one in while they draw
+ private static RenderTarget main;
+ private static volatile boolean enabled;
+ private static boolean active;
+ private static boolean refresh;
+ private static boolean drawing;
+ private static long lastRefresh;
+ private static volatile boolean suppressLiveParts;
+
+ public static boolean suppressLiveParts() {
+ return suppressLiveParts;
+ }
+
+ public static void suppressLiveParts(boolean value) {
+ suppressLiveParts = value;
+ }
+
+ public static void configure(boolean on) {
+ enabled = on && !FOREIGN_HUD && !VULKAN;
+ }
+
+ public static void reset() {
+ enabled = false;
+ active = false;
+ refresh = false;
+ drawing = false;
+ suppressLiveParts = false;
+ lastRefresh = 0;
+ main = null;
+ if (target != null) {
+ target.destroyBuffers();
+ target = null;
+ }
+ HudWorth.reset();
+ MonitorRate.reset();
+ }
+
+ // once per frame, before any GUI extraction
+ public static void beginFrame(boolean renderLevel) {
+ Minecraft mc = Minecraft.getInstance();
+ boolean was = active;
+ long now = System.nanoTime();
+ active = enabled
+ && HudWorth.update(now, MonitorRate.intervalNs(now))
+ && renderLevel && mc.level != null
+ && mc.gui.screen() == null && mc.gui.overlay() == null;
+ if (!active) {
+ return;
+ }
+ main = mc.gameRenderer.mainRenderTarget();
+ int w = mc.getWindow().getWidth();
+ int h = mc.getWindow().getHeight();
+ refresh = !was || target == null || target.width != w || target.height != h
+ || now - lastRefresh >= MonitorRate.intervalNs(now);
+ if (refresh) {
+ lastRefresh = now;
+ if (target == null) {
+ target = new TextureTarget("lovisual hud", w, h, true, GpuFormat.RGBA8_UNORM);
+ } else if (target.width != w || target.height != h) {
+ target.resize(w, h);
+ }
+ }
+ }
+
+ public static boolean active() {
+ return active;
+ }
+
+ public static boolean skipExtraction() {
+ return active && !refresh;
+ }
+
+ public static RenderTarget drawTarget(RenderTarget requested) {
+ return drawing && requested == main ? target : null;
+ }
+
+ // depth is cleared to 0: 26.x renders with reversed depth
+ public static void drawIntoTexture(Runnable draw) {
+ RenderSystem.getDevice().createCommandEncoder()
+ .clearColorAndDepthTextures(target.getColorTexture(), CLEAR, target.getDepthTexture(), 0.0);
+ drawing = true;
+ try {
+ draw.run();
+ } finally {
+ drawing = false;
+ }
+ }
+
+ public static void composite() {
+ RenderTarget main = Minecraft.getInstance().gameRenderer.mainRenderTarget();
+ try (RenderPass pass = RenderSystem.getDevice().createCommandEncoder()
+ .createRenderPass(() -> "lovisual hud composite", main.getColorTextureView(), Optional.empty(),
+ main.useDepth ? main.getDepthTextureView() : null, OptionalDouble.empty())) {
+ pass.setPipeline(COMPOSITE);
+ RenderSystem.bindDefaultUniforms(pass);
+ pass.bindTexture("InSampler", target.getColorTextureView(),
+ RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST));
+ pass.draw(3, 1, 0, 0);
+ }
+ }
+
+ private static boolean isModLoaded(String id) {
+ return FabricLoader.getInstance().isModLoaded(id);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorth.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorth.java
new file mode 100644
index 00000000..c573f63a
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorth.java
@@ -0,0 +1,42 @@
+package dev.loki.lovisual.render.engine.optimize.hud;
+
+/**
+ * Stage 3 step 6: the HUD cache only saves work when several frames share one
+ * HUD refresh; near one frame per refresh it redraws the HUD anyway and just
+ * adds a full-screen clear and blend, which costs real FPS on integrated GPUs
+ * and 60 Hz monitors. A hysteresis band (off below 1.5, on above 2.0 frames
+ * per refresh) keeps it from flipping every frame.
+ */
+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;
+
+ 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;
+ }
+
+ public static boolean worth() {
+ return worth;
+ }
+
+ public static void reset() {
+ frameNs = 0;
+ last = 0;
+ worth = true;
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRate.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRate.java
new file mode 100644
index 00000000..3af0457d
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRate.java
@@ -0,0 +1,53 @@
+package dev.loki.lovisual.render.engine.optimize.hud;
+
+import com.mojang.blaze3d.platform.Monitor;
+import dev.loki.lovisual.util.logging.DebugLog;
+import java.util.function.IntSupplier;
+import net.minecraft.client.Minecraft;
+
+/**
+ * Stage 3 step 6: nanoseconds per refresh of the monitor the window is on.
+ * The GLFW query runs at most once a second — the window may have moved to
+ * another monitor, so the value is re-probed on that interval.
+ */
+public enum MonitorRate {
+ ;
+
+ private static final long DEFAULT_INTERVAL = 1_000_000_000L / 144;
+
+ private static volatile long intervalNs = DEFAULT_INTERVAL;
+ private static long nextCheck;
+ private static volatile IntSupplier probe = MonitorRate::glfwRefreshRate;
+
+ public static long intervalNs(long now) {
+ if (now >= nextCheck) {
+ int hz = probe.getAsInt();
+ long interval = 1_000_000_000L / Math.max(30, hz);
+ if (interval != intervalNs) {
+ DebugLog.info("Monitor refresh rate {} Hz", hz);
+ }
+ intervalNs = interval;
+ nextCheck = now + 1_000_000_000L;
+ }
+ return intervalNs;
+ }
+
+ public static void reset() {
+ intervalNs = DEFAULT_INTERVAL;
+ nextCheck = 0;
+ probe = MonitorRate::glfwRefreshRate;
+ }
+
+ static void setProbeForTesting(IntSupplier testProbe) {
+ probe = testProbe;
+ }
+
+ private static int glfwRefreshRate() {
+ Minecraft mc = Minecraft.getInstance();
+ if (mc == null || mc.getWindow() == null) {
+ return 144;
+ }
+ Monitor monitor = mc.getWindow().findBestMonitor();
+ return monitor != null ? monitor.currentMode().getRefreshRate() : 144;
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelBuilder.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelBuilder.java
new file mode 100644
index 00000000..ba3f6290
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelBuilder.java
@@ -0,0 +1,148 @@
+package dev.loki.lovisual.render.engine.optimize.parallel;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.VertexFormat;
+import dev.loki.lovisual.render.engine.optimize.parallel.ModelGeometry.Cube;
+import dev.loki.lovisual.render.engine.optimize.parallel.ParallelModels.Job;
+import net.minecraft.client.model.geom.ModelPart;
+import net.minecraft.client.renderer.texture.TextureAtlasSprite;
+import net.minecraft.util.Mth;
+import org.joml.Matrix4f;
+import org.joml.Quaternionf;
+import org.joml.Vector3f;
+import org.lwjgl.system.MemoryUtil;
+
+/**
+ * Writes one posed model's vertices into native memory, mirroring
+ * {@code ModelPart.render}/{@code translateAndRotate}/{@code Cube.compile}
+ * but reading the per-model pose snapshot instead of the shared part instance
+ * — that is what makes the build safe off the render thread.
+ */
+final class ModelBuilder {
+
+ static final int STRIDE;
+ static final int FIELDS = 9;
+
+ static {
+ VertexFormat format = ParallelModels.FORMAT;
+ STRIDE = format.getVertexSize();
+ }
+
+ private ModelBuilder() {
+ }
+
+ static void build(Job job) {
+ PoseStack poses = ParallelModels.stacks();
+ poses.last().set(job.matrix);
+ long[] ptr = {job.address};
+ for (int i = 0; i < job.geometry.parts.length; ) {
+ i = part(job, poses, i, ptr);
+ }
+ }
+
+ private static int normal(float c) {
+ return (int) (Mth.clamp(c, -1.0F, 1.0F) * 127.0F) & 0xFF;
+ }
+
+ private static int part(Job job, PoseStack poses, int i, long[] ptr) {
+ ModelGeometry geometry = job.geometry;
+ int end = geometry.end[i];
+ if ((job.flags[i] & 1) == 0 || geometry.empty[i]) {
+ return end;
+ }
+ float[] p = job.pose;
+ int o = i * FIELDS;
+ poses.pushPose();
+ poses.translate(p[o] / 16.0F, p[o + 1] / 16.0F, p[o + 2] / 16.0F);
+ if (p[o + 3] != 0.0F || p[o + 4] != 0.0F || p[o + 5] != 0.0F) {
+ // what PoseStack.mulPose does; spelled out for the worker thread
+ Quaternionf rotation = new Quaternionf().rotationZYX(p[o + 5], p[o + 4], p[o + 3]);
+ poses.last().pose().rotate(rotation);
+ poses.last().normal().rotate(rotation);
+ }
+ if (p[o + 6] != 1.0F || p[o + 7] != 1.0F || p[o + 8] != 1.0F) {
+ poses.scale(p[o + 6], p[o + 7], p[o + 8]);
+ }
+ if ((job.flags[i] & 2) == 0) {
+ PoseStack.Pose pose = poses.last();
+ Matrix4f matrix = pose.pose();
+ Vector3f v = new Vector3f();
+ // what SpriteCoordinateExpander does to every vertex; the sprite's
+ // bounds are final, safe to read here
+ TextureAtlasSprite sprite = job.sprite;
+ for (Cube cube : geometry.cubes[i]) {
+ // a cube's 24 vertices share 8 corners: transform the corners once
+ float[] at = ParallelModels.corners();
+ if (at.length < cube.corners.length) {
+ at = new float[cube.corners.length];
+ ParallelModels.setCorners(at);
+ }
+ for (int c = 0; c < cube.corners.length; c += 3) {
+ matrix.transformPosition(cube.corners[c], cube.corners[c + 1], cube.corners[c + 2], v);
+ at[c] = v.x();
+ at[c + 1] = v.y();
+ at[c + 2] = v.z();
+ }
+ int k = 0;
+ for (int q = 0; q < cube.sizes.length; q++) {
+ Vector3f n = pose.transformNormal(cube.normals[q * 3], cube.normals[q * 3 + 1], cube.normals[q * 3 + 2], v);
+ int packedNormal = normal(n.x()) | normal(n.y()) << 8 | normal(n.z()) << 16;
+ for (int e = k + cube.sizes[q]; k < e; k++) {
+ int c = cube.index[k] * 3;
+ float u = cube.uv[k * 2];
+ float w = cube.uv[k * 2 + 1];
+ if (sprite != null) {
+ u = sprite.getU(u);
+ w = sprite.getV(w);
+ }
+ long v0 = ptr[0];
+ MemoryUtil.memPutFloat(v0, at[c]);
+ MemoryUtil.memPutFloat(v0 + 4, at[c + 1]);
+ MemoryUtil.memPutFloat(v0 + 8, at[c + 2]);
+ MemoryUtil.memPutInt(v0 + 12, job.color);
+ MemoryUtil.memPutFloat(v0 + 16, u);
+ MemoryUtil.memPutFloat(v0 + 20, w);
+ MemoryUtil.memPutInt(v0 + 24, job.overlay);
+ MemoryUtil.memPutInt(v0 + 28, job.light);
+ MemoryUtil.memPutInt(v0 + 32, packedNormal);
+ ptr[0] += STRIDE;
+ }
+ }
+ }
+ }
+ for (int c = i + 1; c < end; ) {
+ c = part(job, poses, c, ptr);
+ }
+ poses.popPose();
+ return end;
+ }
+
+ static int countVertices(ModelGeometry geometry, Job job) {
+ int vertices = 0;
+ for (int i = 0; i < geometry.parts.length; ) {
+ if ((job.flags[i] & 1) == 0) {
+ i = geometry.end[i];
+ continue;
+ }
+ if ((job.flags[i] & 2) == 0) {
+ vertices += geometry.vertices[i];
+ }
+ i++;
+ }
+ return vertices;
+ }
+
+ static void snapshotPose(ModelPart part, Job job, int i) {
+ int o = i * FIELDS;
+ job.pose[o] = part.x;
+ job.pose[o + 1] = part.y;
+ job.pose[o + 2] = part.z;
+ job.pose[o + 3] = part.xRot;
+ job.pose[o + 4] = part.yRot;
+ job.pose[o + 5] = part.zRot;
+ job.pose[o + 6] = part.xScale;
+ job.pose[o + 7] = part.yScale;
+ job.pose[o + 8] = part.zScale;
+ job.flags[i] = (byte) ((part.visible ? 1 : 0) | (part.skipDraw ? 2 : 0));
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelGeometry.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelGeometry.java
new file mode 100644
index 00000000..6233a49e
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelGeometry.java
@@ -0,0 +1,116 @@
+package dev.loki.lovisual.render.engine.optimize.parallel;
+
+import dev.loki.lovisual.mixins.optimize.entity.parallel.ModelPartAccessor;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import net.minecraft.client.model.geom.ModelPart;
+
+/**
+ * The baked part tree of one model in render order — the static half of the
+ * parallel model build: which parts exist, where each subtree ends and the
+ * cube geometry folded onto distinct corners. Cached per model root in
+ * {@link ParallelModels}' weak map, rebuilt only on resource reload.
+ */
+final class ModelGeometry {
+
+ final ModelPart[] parts;
+ /** end[i] is where the subtree of part i stops in the flat order. */
+ final int[] end;
+ final int[] vertices;
+ final boolean[] empty;
+ final Cube[][] cubes;
+
+ ModelGeometry(ModelPart root) {
+ List list = new ArrayList<>();
+ List ends = new ArrayList<>();
+ add(root, list, ends);
+ int n = list.size();
+ parts = list.toArray(new ModelPart[0]);
+ end = new int[n];
+ vertices = new int[n];
+ empty = new boolean[n];
+ cubes = new Cube[n][];
+ for (int i = 0; i < n; i++) {
+ ModelPartAccessor accessor = (ModelPartAccessor) (Object) parts[i];
+ end[i] = ends.get(i);
+ List source = accessor.lovisual$cubes();
+ cubes[i] = new Cube[source.size()];
+ for (int c = 0; c < cubes[i].length; c++) {
+ cubes[i][c] = new Cube(source.get(c));
+ vertices[i] += cubes[i][c].index.length;
+ }
+ empty[i] = cubes[i].length == 0 && accessor.lovisual$children().isEmpty();
+ }
+ // drop the strong ref from the value back to the weak-map key, or the
+ // root model part would never be collected (leak fix)
+ parts[0] = null;
+ }
+
+ Map children(ModelPart part) {
+ return ((ModelPartAccessor) (Object) part).lovisual$children();
+ }
+
+ private void add(ModelPart part, List list, List ends) {
+ int at = list.size();
+ list.add(part);
+ ends.add(0);
+ for (ModelPart child : children(part).values()) {
+ add(child, list, ends);
+ }
+ ends.set(at, list.size());
+ }
+
+ /** One cube in vanilla's polygon and vertex order, folded onto distinct corners. */
+ static final class Cube {
+ final float[] corners;
+ final int[] index;
+ final float[] uv;
+ final float[] normals;
+ final int[] sizes;
+
+ Cube(ModelPart.Cube cube) {
+ List corner = new ArrayList<>();
+ List idx = new ArrayList<>();
+ List uvs = new ArrayList<>();
+ normals = new float[cube.polygons.length * 3];
+ sizes = new int[cube.polygons.length];
+ for (int q = 0; q < cube.polygons.length; q++) {
+ ModelPart.Polygon polygon = cube.polygons[q];
+ normals[q * 3] = polygon.normal().x();
+ normals[q * 3 + 1] = polygon.normal().y();
+ normals[q * 3 + 2] = polygon.normal().z();
+ sizes[q] = polygon.vertices().length;
+ for (ModelPart.Vertex vertex : polygon.vertices()) {
+ int found = -1;
+ for (int c = 0; c < corner.size(); c += 3) {
+ if (corner.get(c) == vertex.worldX()
+ && corner.get(c + 1) == vertex.worldY()
+ && corner.get(c + 2) == vertex.worldZ()) {
+ found = c / 3;
+ break;
+ }
+ }
+ if (found < 0) {
+ found = corner.size() / 3;
+ corner.add(vertex.worldX());
+ corner.add(vertex.worldY());
+ corner.add(vertex.worldZ());
+ }
+ idx.add(found);
+ uvs.add(vertex.u());
+ uvs.add(vertex.v());
+ }
+ }
+ corners = new float[corner.size()];
+ for (int c = 0; c < corners.length; c++) {
+ corners[c] = corner.get(c);
+ }
+ index = idx.stream().mapToInt(Integer::intValue).toArray();
+ uv = new float[uvs.size()];
+ for (int c = 0; c < uv.length; c++) {
+ uv[c] = uvs.get(c);
+ }
+ }
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelHook.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelHook.java
new file mode 100644
index 00000000..2db9b9b9
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelHook.java
@@ -0,0 +1,58 @@
+package dev.loki.lovisual.render.engine.optimize.parallel;
+
+import dev.loki.lovisual.mixins.optimize.entity.parallel.ModelFeatureRendererInvoker;
+import dev.loki.lovisual.mixins.optimize.entity.parallel.RenderTypeFeatureRendererInvoker;
+import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze;
+import java.util.ArrayList;
+import java.util.List;
+import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
+import org.lwjgl.system.MemoryStack;
+
+/**
+ * Stage 3 step 8: drives one parallel batch per model group. 26.2 hands
+ * models over in groups that are already ordered and batched, so a whole
+ * group is one parallel run; groups below {@link ParallelModels#MIN_SUBMITS}
+ * and submits that cannot go raw (block cracks, non-entity formats, frozen
+ * animations) fall back to the vanilla path per model.
+ */
+public final class ParallelModelHook {
+
+ private static final List JOBS = new ArrayList<>();
+
+ private ParallelModelHook() {
+ }
+
+ /** false: too few models in this group, vanilla builds it. */
+ public static boolean buildGroup(ModelFeatureRenderer renderer, List> submits) {
+ if (!ParallelModels.enabled() || submits.size() < ParallelModels.MIN_SUBMITS) {
+ return false;
+ }
+ JOBS.clear();
+ ParallelModels.begin();
+ try {
+ for (ModelFeatureRenderer.Submit> s : submits) {
+ // block cracks go through a decal generator: those few go the vanilla way
+ JOBS.add(s.sheetedDecalPose() != null || s.renderType().format() != ParallelModels.FORMAT
+ || AnimFreeze.freezes(s.state(), s.pose().pose())
+ ? null
+ : ParallelModels.add(s.model(), s.state(), s.pose(), s.lightCoords(), s.overlayCoords(),
+ s.tintedColor(), s.sprite()));
+ }
+ } finally {
+ ParallelModels.end();
+ }
+ RenderTypeFeatureRendererInvoker builders = (RenderTypeFeatureRendererInvoker) renderer;
+ ModelFeatureRendererInvoker vanilla = (ModelFeatureRendererInvoker) renderer;
+ try (MemoryStack stack = MemoryStack.stackPush()) {
+ for (int k = 0; k < submits.size(); k++) {
+ ModelFeatureRenderer.Submit> s = submits.get(k);
+ ParallelModels.Job job = JOBS.get(k);
+ // one builder call per model, as vanilla does: it decides where a new draw starts
+ if (job == null || !ParallelModels.emit(job, builders.lovisual$vertexBuilder(s.renderType()), stack)) {
+ vanilla.lovisual$prepareModel(s);
+ }
+ }
+ }
+ return true;
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModels.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModels.java
new file mode 100644
index 00000000..3a4e01d3
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModels.java
@@ -0,0 +1,199 @@
+package dev.loki.lovisual.render.engine.optimize.parallel;
+
+import com.mojang.blaze3d.vertex.DefaultVertexFormat;
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import com.mojang.blaze3d.vertex.VertexFormat;
+import dev.loki.lovisual.features.module.modules.misc.optimize.particle.Workers;
+import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze;
+import dev.loki.lovisual.render.engine.optimize.gl.RawVertices;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+import net.fabricmc.loader.api.FabricLoader;
+import net.minecraft.client.model.Model;
+import net.minecraft.client.model.geom.ModelPart;
+import net.minecraft.client.renderer.texture.TextureAtlasSprite;
+import org.lwjgl.system.MemoryStack;
+import org.lwjgl.system.MemoryUtil;
+
+/**
+ * Stage 3 step 8: model vertices built on several threads. setupAnim writes
+ * the pose into a model instance shared by every entity of that kind, so the
+ * render thread poses each model and snapshots its parts, workers build the
+ * vertices from the snapshots while the next models are posed, and the render
+ * thread then hands them to the buffers in vanilla's order. Driven per group
+ * by {@code ModelFeatureRendererMixin.buildGroup} through
+ * {@link ParallelModelHook}.
+ *
+ * Mutually exclusive with {@link AnimFreeze} (freeze replays captured
+ * vertices, this builds fresh ones). Off with fewer than four cores or an
+ * active Iris shader pack.
+ */
+public enum ParallelModels {
+ ;
+
+ public static final VertexFormat FORMAT = DefaultVertexFormat.ENTITY;
+ public static final int MIN_SUBMITS = 24;
+ private static final int PAGE = 1 << 20;
+ private static final boolean CORES_OK = Runtime.getRuntime().availableProcessors() >= 4;
+
+ private static final Map GEOMETRY = new WeakHashMap<>();
+ private static final List BUILT = new ArrayList<>();
+ private static final ThreadLocal STACKS = ThreadLocal.withInitial(PoseStack::new);
+ private static final ThreadLocal CORNERS = ThreadLocal.withInitial(() -> new float[24]);
+ // vertex memory handed out while models are still being posed, so the total
+ // is not known up front; reused every frame
+ private static final List PAGES = new ArrayList<>();
+ private static final List PAGE_SIZES = new ArrayList<>();
+ private static int page;
+ private static int pageUsed;
+ private static Workers.Stream stream;
+ private static volatile boolean enabled;
+
+ public static void configure(boolean on) {
+ enabled = on;
+ }
+
+ public static void reset() {
+ enabled = false;
+ stream = null;
+ BUILT.clear();
+ page = 0;
+ pageUsed = 0;
+ for (int i = 0; i < PAGES.size(); i++) {
+ MemoryUtil.nmemFree(PAGES.get(i));
+ }
+ PAGES.clear();
+ PAGE_SIZES.clear();
+ GEOMETRY.clear();
+ }
+
+ public static boolean enabled() {
+ return enabled && CORES_OK && !AnimFreeze.enabled() && !irisShaderPack();
+ }
+
+ public static void begin() {
+ BUILT.clear();
+ page = 0;
+ pageUsed = 0;
+ stream = Workers.stream(i -> ModelBuilder.build(BUILT.get(i)));
+ }
+
+ // render thread: poses the model and queues its vertices; the caller has
+ // already checked the vertex format and that nothing else (outline, block
+ // cracks) has to see the same model
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public static Job add(Model model, Object state, PoseStack.Pose pose, int light, int overlay,
+ int color, TextureAtlasSprite sprite) {
+ model.setupAnim(state);
+ ModelPart root = model.root();
+ ModelGeometry geometry = GEOMETRY.computeIfAbsent(root, ModelGeometry::new);
+ int n = geometry.parts.length;
+ Job job = new Job(geometry, pose, light, overlay,
+ Integer.reverseBytes(Integer.rotateLeft(color, 8)), sprite, new float[n * ModelBuilder.FIELDS], new byte[n]);
+ for (int i = 0; i < n; i++) {
+ ModelPart p = i == 0 ? root : geometry.parts[i];
+ ModelBuilder.snapshotPose(p, job, i);
+ }
+ job.vertices = ModelBuilder.countVertices(geometry, job);
+ job.address = reserve(job.vertices * ModelBuilder.STRIDE);
+ BUILT.add(job);
+ stream.publish(BUILT.size());
+ return job;
+ }
+
+ // must run even when posing failed: helpers spin until the stream is finished
+ public static void end() {
+ Throwable error = stream.finish(BUILT.size());
+ stream = null;
+ if (error != null) {
+ throw new RuntimeException("lovisual parallel models", error);
+ }
+ }
+
+ // false: the consumer cannot take raw vertices, the caller renders this model the vanilla way
+ public static boolean emit(Job job, VertexConsumer consumer, MemoryStack stack) {
+ if (job.vertices == 0) {
+ return true;
+ }
+ if (!RawVertices.accepts(consumer, FORMAT)) {
+ return false;
+ }
+ stack.push();
+ try {
+ RawVertices.push(consumer, stack, job.address, job.vertices, FORMAT);
+ } finally {
+ stack.pop();
+ }
+ return true;
+ }
+
+ static PoseStack stacks() {
+ return STACKS.get();
+ }
+
+ static float[] corners() {
+ return CORNERS.get();
+ }
+
+ static void setCorners(float[] at) {
+ CORNERS.set(at);
+ }
+
+ private static long reserve(int bytes) {
+ if (page < PAGES.size() && pageUsed + bytes > PAGE_SIZES.get(page)) {
+ page++;
+ pageUsed = 0;
+ }
+ if (page == PAGES.size()) {
+ int size = Math.max(PAGE, bytes);
+ PAGES.add(MemoryUtil.nmemAlloc(size));
+ PAGE_SIZES.add(size);
+ } else if (bytes > PAGE_SIZES.get(page)) {
+ MemoryUtil.nmemFree(PAGES.get(page));
+ PAGES.set(page, MemoryUtil.nmemAlloc(bytes));
+ PAGE_SIZES.set(page, bytes);
+ }
+ long address = PAGES.get(page) + pageUsed;
+ pageUsed += bytes;
+ return address;
+ }
+
+ private static boolean irisShaderPack() {
+ try {
+ if (!FabricLoader.getInstance().isModLoaded("iris")) {
+ return false;
+ }
+ return net.irisshaders.iris.api.v0.IrisApi.getInstance().isShaderPackInUse();
+ } catch (LinkageError | RuntimeException e) {
+ return false;
+ }
+ }
+
+ public static final class Job {
+ final ModelGeometry geometry;
+ final PoseStack.Pose matrix;
+ final int light;
+ final int overlay;
+ final int color;
+ final TextureAtlasSprite sprite;
+ final float[] pose;
+ final byte[] flags;
+ int vertices;
+ long address;
+
+ Job(ModelGeometry geometry, PoseStack.Pose matrix, int light, int overlay, int color,
+ TextureAtlasSprite sprite, float[] pose, byte[] flags) {
+ this.geometry = geometry;
+ this.matrix = matrix;
+ this.light = light;
+ this.overlay = overlay;
+ this.color = color;
+ this.sprite = sprite;
+ this.pose = pose;
+ this.flags = flags;
+ }
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStats.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStats.java
new file mode 100644
index 00000000..ddb71497
--- /dev/null
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStats.java
@@ -0,0 +1,149 @@
+package dev.loki.lovisual.render.engine.optimize.profile;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Per-frame draw counters for the F3 overlay — step 1 of Stage 3 in
+ * {@code mod/docs/optimize-layer-design.md}.
+ *
+ * Answers the question the "additional GL optimisations" section asks
+ * before anything gets turned on: how many draw calls, pipeline selections
+ * and framebuffer binds a frame really costs, and which pipelines dominate
+ * them. Sorting draws, batching HUD and dropping redundant FBO switches are
+ * all decisions that need these three numbers first.
+ *
+ *
Counting stays off unless the Optimize module is on and F3 is
+ * open, so on a normal frame each hook is a single boolean test.
+ *
+ *
+ *
Render-thread only: counters are touched by the thread that draws the
+ * frame, the published snapshot is one volatile field so the F3 read may
+ * happen anywhere.
+ */
+public final class DrawStats {
+
+ /** Length of one measurement window — averages are published once a second. */
+ static final long WINDOW_NS = 1_000_000_000L;
+
+ /** How many pipelines are listed in {@link Snapshot#top()}. */
+ static final int TOP = 5;
+
+ /**
+ * One second of counting, already averaged per frame.
+ *
+ * @param draws GL draw submissions
+ * @param pipelines {@code RenderPass.setPipeline} calls — state changes
+ * @param binds {@code glBindFramebuffer} calls that reached the driver
+ * @param frames frames in the window; {@code 0} means "nothing measured yet"
+ * @param top busiest pipelines, busiest first, as {@code " "}
+ */
+ public record Snapshot(int draws, int pipelines, int binds, int frames, List top) {
+ public Snapshot {
+ top = List.copyOf(top);
+ }
+ }
+
+ private static final Snapshot EMPTY = new Snapshot(0, 0, 0, 0, List.of());
+
+ private static volatile Snapshot snapshot = EMPTY;
+
+ private static boolean active;
+ private static int draws;
+ private static int pipelines;
+ private static int binds;
+ private static int frames;
+ private static long windowStartNs;
+ private static final Map BY_PIPELINE = new HashMap<>();
+
+ private DrawStats() {
+ }
+
+ public static void draw() {
+ if (active) draws++;
+ }
+
+ /** @param location pipeline location path, already cut down to a short name */
+ public static void pipeline(String location) {
+ if (active) {
+ pipelines++;
+ BY_PIPELINE.merge(location, 1, Integer::sum);
+ }
+ }
+
+ public static void bind() {
+ if (active) binds++;
+ }
+
+ /**
+ * Closes the frame that just finished. {@code shown} is false while F3 is
+ * closed or the module is off — the first false call stops counting and
+ * empties the published snapshot, the next true call starts a fresh window.
+ */
+ public static void endFrame(boolean shown) {
+ endFrame(shown, System.nanoTime());
+ }
+
+ static void endFrame(boolean shown, long nowNs) {
+ if (!shown) {
+ if (active) {
+ active = false;
+ snapshot = EMPTY;
+ BY_PIPELINE.clear();
+ }
+ return;
+ }
+ if (!active) {
+ active = true;
+ startWindow(nowNs);
+ return;
+ }
+ frames++;
+ if (nowNs - windowStartNs >= WINDOW_NS) {
+ snapshot = publish(draws, pipelines, binds, frames, BY_PIPELINE, TOP);
+ startWindow(nowNs);
+ }
+ }
+
+ /**
+ * Averages the window's counters over its frames and picks the busiest
+ * pipelines. Pure — the part of {@link #endFrame(boolean, long)} worth a test.
+ *
+ * @param byPipeline pipeline location → calls in the window, unordered
+ * @param topN how many entries {@link Snapshot#top()} keeps
+ */
+ static Snapshot publish(int draws, int pipelines, int binds, int frames,
+ Map byPipeline, int topN) {
+ int perFrame = Math.max(frames, 1);
+ List top = new ArrayList<>(Math.min(topN, byPipeline.size()));
+ byPipeline.entrySet().stream()
+ .sorted((a, b) -> Integer.compare(b.getValue(), a.getValue()))
+ .limit(topN)
+ .forEach(e -> top.add(e.getKey() + " " + avg(e.getValue(), perFrame)));
+ return new Snapshot(avg(draws, perFrame), avg(pipelines, perFrame), avg(binds, perFrame), frames, top);
+ }
+
+ private static int avg(int total, int frames) {
+ return (int) Math.round(total / (double) frames);
+ }
+
+ /** Latest published window. */
+ public static Snapshot snapshot() {
+ return snapshot;
+ }
+
+ private static void startWindow(long nowNs) {
+ draws = pipelines = binds = frames = 0;
+ BY_PIPELINE.clear();
+ windowStartNs = nowNs;
+ }
+
+ /** Test-only: stop counting, drop counters and the published snapshot. */
+ static void reset() {
+ active = false;
+ snapshot = EMPTY;
+ startWindow(0);
+ }
+}
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java
index 2058e308..13d635e0 100644
--- a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java
@@ -40,7 +40,7 @@ public enum FrameBudget {
/** Multiplier applied to baseline caps when AGGRESSIVE mode is on. */
static final double AGGRESSIVE_SCALE = 0.6;
- // Defaults match ZakoOpt's stage-1 caps so the behavior is familiar.
+ // Stage-1 baseline caps.
private static final int BASE_ENTITY_LOD_DISTANCE = 35;
private static final int BASE_PARTICLE_NEAR_BLOCKS = 16; // NEAR_SQ = 16²
private static final int BASE_PARTICLE_MID_BLOCKS = 32; // MID_SQ = 32²
diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java
index 28c9616d..0b943e9d 100644
--- a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java
+++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java
@@ -21,6 +21,7 @@ import java.util.Map;
* {@code -Dlovisual.opt.block_entity_cache=true|false}
* {@code -Dlovisual.opt.prepared_text_cache=true|false}
* {@code -Dlovisual.opt.micro_opts=true|false}
+ * {@code -Dlovisual.opt.fbo_share=true|false}
* {@code -Dlovisual.opt.entity_lod_distance=35} — overrides the distance (4..256).
* {@code -Dlovisual.opt.spawner_distance=16} — overrides the distance (4..64).
* {@code -Dlovisual.opt.budget_mode=off|soft|aggressive}
@@ -47,8 +48,7 @@ public enum OptimizePropertyBridge {
/**
* Read the current {@code -Dlovisual.opt.*} properties into a fresh snapshot.
- * The Optimize module calls this once per frame (mirrors ZakoOpt's
- * {@code ZakoOptConfig.refresh()}).
+ * The Optimize module calls this once per frame.
*/
public static Snapshot resolve() {
Snapshot s = new Snapshot();
@@ -61,6 +61,7 @@ public enum OptimizePropertyBridge {
s.blockEntityCache = boolOverride("block_entity_cache");
s.preparedTextCache = boolOverride("prepared_text_cache");
s.microOpts = boolOverride("micro_opts");
+ s.fboShare = boolOverride("fbo_share");
s.entityLodDistance = intOverride("entity_lod_distance", -1);
s.spawnerDistance = intOverride("spawner_distance", -1);
@@ -93,7 +94,7 @@ public enum OptimizePropertyBridge {
private static final String[] OVERRIDE_KEYS = {
"entity_lod", "particle_lod", "spawner_cull", "spawner_tick_cache",
- "block_entity_cache", "prepared_text_cache", "micro_opts",
+ "block_entity_cache", "prepared_text_cache", "micro_opts", "fbo_share",
"entity_lod_distance", "spawner_distance", "budget_mode", "target_fps"
};
@@ -144,6 +145,7 @@ public enum OptimizePropertyBridge {
public Boolean blockEntityCache;
public Boolean preparedTextCache;
public Boolean microOpts;
+ public Boolean fboShare;
public int entityLodDistance = -1;
public int spawnerDistance = -1;
public FrameBudget.Mode budgetMode; // null = use module value
@@ -183,6 +185,7 @@ public enum OptimizePropertyBridge {
m.put("block_entity_cache", blockEntityCache);
m.put("prepared_text_cache", preparedTextCache);
m.put("micro_opts", microOpts);
+ m.put("fbo_share", fboShare);
m.put("entity_lod_distance", entityLodDistance);
m.put("spawner_distance", spawnerDistance);
m.put("budget_mode", budgetMode);
diff --git a/mod/src/main/resources/lovisual.mixins.json b/mod/src/main/resources/lovisual.mixins.json
index 000e84c4..d9408755 100644
--- a/mod/src/main/resources/lovisual.mixins.json
+++ b/mod/src/main/resources/lovisual.mixins.json
@@ -37,18 +37,31 @@
"optimize.block.BlockEntityMixin",
"optimize.block.BlockEntityRenderStateMixin",
"optimize.block.ChestRendererMixin",
- "optimize.entity.EntityNameMixin",
- "optimize.entity.EntityRenderStateEntityMixin",
- "optimize.entity.LivingEntityLodMixin",
- "optimize.entity.SodiumEntityRendererMixin",
+ "optimize.entity.EntityNameMixin",
+ "optimize.entity.EntityRenderStateEntityMixin",
+ "optimize.entity.LivingEntityLodMixin",
+ "optimize.entity.SodiumEntityRendererMixin",
+ "optimize.entity.anim.EntityAnimHolderMixin",
+ "optimize.entity.anim.ModelFeatureRendererMixin",
+ "optimize.entity.parallel.ModelFeatureRendererInvoker",
+ "optimize.entity.parallel.ModelPartAccessor",
+ "optimize.entity.parallel.RenderTypeFeatureRendererInvoker",
"optimize.entity.renderer.EntityRendererNameMixin",
"optimize.entity.renderer.EntityRendererStateMixin",
- "optimize.gl.GlCommandEncoderClearMixin",
- "optimize.gl.GlCommandEncoderTexBufferMixin",
- "optimize.gl.RenderTypesMixin",
+ "optimize.gl.GlBufferCloseMixin",
+ "optimize.gl.GlCommandEncoderClearMixin",
+ "optimize.gl.GlCommandEncoderTexBufferMixin",
+ "optimize.gl.RenderTypesMixin",
+ "optimize.gl.fbo.FrameBufferCacheMixin",
+ "optimize.gl.fbo.GlCommandEncoderDepthClearMixin",
+ "optimize.gl.raw.BufferBuilderRawMixin",
+ "optimize.gl.raw.VertexConsumerUtilsMixin",
"optimize.hud.OptimizeDebugHudMixin",
"optimize.particle.BubbleColumnUpParticleMixin",
"optimize.particle.ParticleMixin",
+ "optimize.stats.GlCommandEncoderStatsMixin",
+ "optimize.stats.GlRenderPassStatsMixin",
+ "optimize.stats.GlStateManagerStatsMixin",
"optimize.world.LevelRendererOutlineMixin",
"accessors.blaze3d.GlTextureInvoker",
"accessors.blaze3d.GpuDeviceAccessor",
@@ -129,6 +142,11 @@
"hud.GuiGraphicsMixin",
"hud.GuiMixin",
"hud.GuiRendererMixin",
+ "hud.cache.GameRendererHudMixin",
+ "hud.cache.GuiHudMixin",
+ "hud.cache.GuiRendererTargetMixin",
+ "hud.cache.HudLiveMixin",
+ "hud.cache.invoker.HudInvoker",
"hud.accessibility.NarratorManagerMixin",
"hud.accessibility.OptionsNarratorMixin",
"hud.bars.BossHealthOverlayMixin",
@@ -172,6 +190,8 @@
"render.fog.WaterFogEnvironmentMixin",
"render.fogmod.PowderSnowFogModifierMixin",
"render.item.EquipmentLayerRendererMixin",
+ "render.item.bounds.ItemLayerAccessor",
+ "render.item.bounds.ItemStackRenderStateMixin",
"render.models.AvatarCustomModelMixin",
"render.models.LivingEntityCustomModelMixin",
"render.placement.submit.BlockEntityPlaceMixin",
diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCacheTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCacheTest.java
index d3a94792..b593d692 100755
--- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCacheTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCacheTest.java
@@ -52,21 +52,21 @@ public final class SpawnerCacheTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
assertFalse(SpawnerCache.enabled());
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
- true, false, false, false);
+ true, false, false, false, false);
assertTrue(SpawnerCache.enabled());
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
assertFalse(SpawnerCache.enabled());
}
@@ -76,7 +76,7 @@ public final class SpawnerCacheTest {
false, 0,
false, false, false,
false, 0,
- true, false, false, false);
+ true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner();
assertNull(SpawnerCache.get(spawner, 100L));
}
@@ -87,7 +87,7 @@ public final class SpawnerCacheTest {
false, 0,
false, false, false,
false, 0,
- true, false, false, false);
+ true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner();
EntityRenderState state = new EntityRenderState();
SpawnerCache.put(spawner, 42L, state);
@@ -100,7 +100,7 @@ public final class SpawnerCacheTest {
false, 0,
false, false, false,
false, 0,
- true, false, false, false);
+ true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner();
EntityRenderState state = new EntityRenderState();
SpawnerCache.put(spawner, 42L, state);
@@ -114,7 +114,7 @@ public final class SpawnerCacheTest {
false, 0,
false, false, false,
false, 0,
- true, false, false, false);
+ true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner();
EntityRenderState first = new EntityRenderState();
EntityRenderState second = new EntityRenderState();
@@ -130,7 +130,7 @@ public final class SpawnerCacheTest {
false, 0,
false, false, false,
false, 0,
- true, false, false, false);
+ true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner();
EntityRenderState state = new EntityRenderState();
SpawnerCache.put(spawner, 100L, state);
diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLodTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLodTest.java
index 7fba85db..5bf4d0a3 100755
--- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLodTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLodTest.java
@@ -21,7 +21,7 @@ public final class EntityLodTest {
false, 35,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
assertFalse(EntityLod.far(10_000_000));
assertFalse(EntityLod.farPlayer(10_000_000));
}
@@ -32,7 +32,7 @@ public final class EntityLodTest {
true, 35,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
// d=35 -> d²=1225: 1225 is NOT greater than 1225, so not far.
assertFalse(EntityLod.far(35 * 35));
// d² just above threshold: should be far.
@@ -48,7 +48,7 @@ public final class EntityLodTest {
true, 16,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
assertEquals(16 * 16, EntityLod.farThresholdSq(), 0.0001);
assertFalse(EntityLod.far(16 * 16));
assertTrue(EntityLod.far(16 * 16 + 1));
@@ -60,7 +60,7 @@ public final class EntityLodTest {
true, 35,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
// Stage 1 collapses player LOD into entity LOD — same toggle, same threshold.
for (double dSq : new double[]{0, 100, 1224, 1226, 10_000}) {
assertEquals(EntityLod.far(dSq), EntityLod.farPlayer(dSq),
@@ -74,14 +74,14 @@ public final class EntityLodTest {
true, 35,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
assertTrue(EntityLod.far(10_000));
// Toggle off mid-test — same distance should now be "not far".
OptimizeToggles.apply(
false, 35,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
assertFalse(EntityLod.far(10_000));
}
}
diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOpsTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOpsTest.java
index de67332d..a8639d53 100644
--- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOpsTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOpsTest.java
@@ -35,7 +35,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
RenderBackendOps ops = RenderBackendOps.current();
assertFalse(ops.lazyClearSupported());
assertFalse(ops.skipBindFramebufferZero(0));
@@ -48,7 +48,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current();
assertTrue(ops.lazyClearSupported());
assertTrue(ops.skipBindFramebufferZero(0));
@@ -62,7 +62,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
RenderBackendOps ops = RenderBackendOps.current();
assertFalse(ops.tboCacheSupported());
// Even after marking attached, lookup should be a no-op (returns false because not supported).
@@ -76,7 +76,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42);
assertFalse(ops.tboAlreadyAttached(0x8229, 100),
@@ -92,7 +92,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100);
@@ -108,7 +108,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100);
@@ -126,7 +126,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100);
@@ -145,7 +145,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current();
RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "textures/entity/skin.png");
assertNull(ops.renderTypeFor(id), "Fresh cache should miss");
@@ -161,7 +161,7 @@ public final class GlRenderBackendOpsTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
RenderBackendOps ops = RenderBackendOps.current();
assertFalse(ops.renderTypesCacheSupported());
RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "test");
@@ -182,4 +182,32 @@ public final class GlRenderBackendOpsTest {
assertFalse(a.equals(d));
assertEquals("mc:path", a.toString());
}
+
+ // --- FBO share (Stage 3 step 2) ---
+
+ @Test
+ void fboShareDisabledWhenToggleOff() {
+ OptimizeToggles.apply(
+ false, 0,
+ false, false, false,
+ false, 0,
+ false, false, false, false, false);
+ RenderBackendOps ops = RenderBackendOps.current();
+ assertFalse(ops.fboShareSupported());
+ // Toggle off short-circuits before SharedDepthFbos — safe without GL.
+ assertFalse(ops.clearDepthInPlace(1, 1.0));
+ }
+
+ @Test
+ void fboShareSupportedWhenToggleOn() {
+ OptimizeToggles.apply(
+ false, 0,
+ false, false, false,
+ false, 0,
+ false, false, false, false, true);
+ RenderBackendOps ops = RenderBackendOps.current();
+ assertTrue(ops.fboShareSupported());
+ // No mapping for this depth id — clear() falls through to vanilla.
+ assertFalse(ops.clearDepthInPlace(1, 1.0));
+ }
}
diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLodTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLodTest.java
index 5748353c..fd5c3bc0 100755
--- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLodTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLodTest.java
@@ -23,7 +23,7 @@ public final class ParticleLodTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Object p = new Object();
assertTrue(ParticleLod.keep(p, 0));
assertTrue(ParticleLod.keep(p, 100));
@@ -36,7 +36,7 @@ public final class ParticleLodTest {
false, 0,
true, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Object p = new Object();
// Strictly under 16 blocks: always keep (NEAR_SQ = 16² = 256).
assertTrue(ParticleLod.keep(p, 0));
@@ -51,7 +51,7 @@ public final class ParticleLodTest {
false, 0,
true, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
// At exactly 16² (the boundary), the particle is no longer "near" — its
// keep decision is delegated to the mid-range thinning. Over many
// particles, about half should survive.
@@ -70,7 +70,7 @@ public final class ParticleLodTest {
false, 0,
true, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
double midSq = 24 * 24; // Between 16² and 32².
Set seen = new HashSet<>();
for (int i = 0; i < 256; i++) {
@@ -88,7 +88,7 @@ public final class ParticleLodTest {
false, 0,
true, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
double farSq = 48 * 48; // Beyond 32².
int kept = 0;
for (int i = 0; i < 1024; i++) {
@@ -106,7 +106,7 @@ public final class ParticleLodTest {
false, 0,
true, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Object p = new Object();
double farSq = 48 * 48;
boolean first = ParticleLod.keep(p, farSq);
@@ -122,7 +122,7 @@ public final class ParticleLodTest {
false, 0,
true, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Object p = new Object();
// particle at (50, 0, 0), camera at (0, 0, 0): distance² = 2500.
boolean objectApi = ParticleLod.keep(p, 50 * 50);
@@ -137,7 +137,7 @@ public final class ParticleLodTest {
false, 0,
true, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Object p = new Object();
assertTrue(ParticleLod.keep(p, 15 * 15));
assertTrue(ParticleLod.keep(p, 0));
@@ -149,7 +149,7 @@ public final class ParticleLodTest {
false, 0,
true, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Object p = new Object();
boolean first = ParticleLod.keep(p, 48 * 48);
// Toggle off — every distance should now keep.
@@ -157,7 +157,7 @@ public final class ParticleLodTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
assertTrue(ParticleLod.keep(p, 48 * 48));
assertTrue(ParticleLod.keep(p, 0));
// first is just whatever it was — we only care that the new state is "keep everything".
diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/WorkersTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/WorkersTest.java
index b9678421..c741f5d7 100755
--- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/WorkersTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/WorkersTest.java
@@ -82,4 +82,37 @@ public final class WorkersTest {
assertNull(err);
assertEquals(blocks, counter.get());
}
+
+ @Test
+ void streamPublishesAndFinishes() {
+ int blocks = 48;
+ AtomicInteger counter = new AtomicInteger();
+ Workers.Stream stream = Workers.stream(b -> counter.incrementAndGet());
+ for (int i = 1; i <= blocks; i++) {
+ stream.publish(i);
+ }
+ Throwable err = stream.finish(blocks);
+ assertNull(err);
+ assertEquals(blocks, counter.get());
+ }
+
+ @Test
+ void streamFinishesWithNoItems() {
+ Workers.Stream stream = Workers.stream(b -> { });
+ Throwable err = stream.finish(0);
+ assertNull(err);
+ }
+
+ @Test
+ void streamReturnsFirstFailure() {
+ int blocks = 12;
+ Workers.Stream stream = Workers.stream(b -> {
+ if (b == 2) {
+ throw new IllegalStateException("stream item 2 failed");
+ }
+ });
+ Throwable err = stream.finish(blocks);
+ assertNotNull(err);
+ assertTrue(err instanceof IllegalStateException);
+ }
}
diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableTextTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableTextTest.java
index b97f686a..448142a0 100755
--- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableTextTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableTextTest.java
@@ -35,7 +35,7 @@ public final class StableTextTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner);
assertTrue(marked instanceof StableText,
@@ -49,7 +49,7 @@ public final class StableTextTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner);
assertTrue(StableText.isStable(marked));
@@ -61,7 +61,7 @@ public final class StableTextTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Recorder plain = new Recorder();
assertFalse(StableText.isStable(plain));
}
@@ -74,7 +74,7 @@ public final class StableTextTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner);
StableText.clearTracked();
@@ -88,7 +88,7 @@ public final class StableTextTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner);
assertTrue(StableText.isStable(marked));
@@ -103,7 +103,7 @@ public final class StableTextTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner);
// Pass a sink that just returns true; the marked wrapper should delegate to inner.
@@ -117,7 +117,7 @@ public final class StableTextTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ false, false, false, false, false);
assertEquals(0, StableText.trackedCount());
StableText.mark(new Recorder());
StableText.mark(new Recorder());
diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java
index 5896d7da..f0f02546 100644
--- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java
@@ -25,7 +25,7 @@ public final class OutlineTrackerTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, false);
+ 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.
@@ -39,7 +39,7 @@ public final class OutlineTrackerTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
OutlineTracker.beginFrame();
assertTrue(OutlineTracker.shouldSkipOutlineRender(),
"Without glowing entities, doEntityOutline should be skipped");
@@ -51,7 +51,7 @@ public final class OutlineTrackerTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
OutlineTracker.beginFrame();
OutlineTracker.markGlowing();
assertFalse(OutlineTracker.shouldSkipOutlineRender(),
@@ -65,7 +65,7 @@ public final class OutlineTrackerTest {
false, 0,
false, false, false,
false, 0,
- false, false, false, true);
+ false, false, false, true, false);
OutlineTracker.markGlowing();
assertTrue(OutlineTracker.glowingThisFrame());
OutlineTracker.beginFrame();
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/OptimizeTogglesTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/OptimizeTogglesTest.java
index ee6541f8..c5bc15da 100755
--- a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/OptimizeTogglesTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/OptimizeTogglesTest.java
@@ -23,6 +23,7 @@ public final class OptimizeTogglesTest {
assertFalse(OptimizeToggles.blockEntityCache());
assertFalse(OptimizeToggles.preparedTextCache());
assertFalse(OptimizeToggles.microOpts());
+ assertFalse(OptimizeToggles.fboShare());
assertEquals(0, OptimizeToggles.entityLodDistance());
assertEquals(0, OptimizeToggles.spawnerDistance());
}
@@ -36,7 +37,7 @@ public final class OptimizeTogglesTest {
true,
false,
true,
- true);
+ true, true);
assertTrue(OptimizeToggles.entityLod());
assertEquals(35, OptimizeToggles.entityLodDistance());
assertFalse(OptimizeToggles.particleLod());
@@ -48,12 +49,14 @@ public final class OptimizeTogglesTest {
assertFalse(OptimizeToggles.blockEntityCache());
assertTrue(OptimizeToggles.preparedTextCache());
assertTrue(OptimizeToggles.microOpts());
+ assertTrue(OptimizeToggles.fboShare());
OptimizeToggles.reset();
assertFalse(OptimizeToggles.entityLod());
assertEquals(0, OptimizeToggles.entityLodDistance());
assertFalse(OptimizeToggles.spawnerCull());
assertFalse(OptimizeToggles.microOpts());
+ assertFalse(OptimizeToggles.fboShare());
}
@Test
@@ -65,7 +68,7 @@ public final class OptimizeTogglesTest {
false,
false,
false,
- false);
+ false, false);
assertFalse(OptimizeToggles.entityLod());
assertFalse(OptimizeToggles.particleLod());
assertFalse(OptimizeToggles.spawnerCull());
@@ -73,6 +76,7 @@ public final class OptimizeTogglesTest {
assertFalse(OptimizeToggles.blockEntityCache());
assertFalse(OptimizeToggles.preparedTextCache());
assertFalse(OptimizeToggles.microOpts());
+ assertFalse(OptimizeToggles.fboShare());
assertEquals(0, OptimizeToggles.entityLodDistance());
assertEquals(0, OptimizeToggles.spawnerDistance());
}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreezeTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreezeTest.java
new file mode 100644
index 00000000..188422d3
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreezeTest.java
@@ -0,0 +1,55 @@
+package dev.loki.lovisual.render.engine.optimize.entity;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link AnimFreeze} pure logic — the toggle snapshot and the
+ * squared-distance gate. The capture/replay path needs a live render
+ * pipeline (vertex buffers, Sodium or the raw-buffer mixin) and is
+ * covered by an in-game check.
+ */
+public final class AnimFreezeTest {
+
+ @AfterEach
+ void reset() {
+ AnimFreeze.reset();
+ }
+
+ @Test
+ void offUntilConfigured() {
+ assertFalse(AnimFreeze.enabled());
+ AnimFreeze.configure(true, 24);
+ assertTrue(AnimFreeze.enabled());
+ assertEquals(24, AnimFreeze.distance());
+ AnimFreeze.reset();
+ assertFalse(AnimFreeze.enabled());
+ }
+
+ @Test
+ void freezesRejectsNonRenderState() {
+ // Not an EntityRenderState (and even a far pose) — never freezes.
+ AnimFreeze.configure(true, 16);
+ assertFalse(AnimFreeze.freezes(new Object(), farPose()));
+ }
+
+ @Test
+ void beyondDistanceIsSquared() {
+ assertTrue(AnimFreeze.beyondDistance(17f, 0f, 0f, 16f), "just past the gate");
+ assertFalse(AnimFreeze.beyondDistance(16f, 0f, 0f, 16f), "exactly at the gate");
+ assertFalse(AnimFreeze.beyondDistance(3f, 4f, 0f, 16f), "5 blocks away, well inside");
+ // diagonal: 12²+12² = 288 < 16² = 256 is false… 12²+12²=288 > 256 is true
+ assertTrue(AnimFreeze.beyondDistance(12f, 12f, 0f, 16f), "diagonal past the gate");
+ assertFalse(AnimFreeze.beyondDistance(0f, 0f, 0f, 0f), "at the camera");
+ }
+
+ private static org.joml.Matrix4f farPose() {
+ org.joml.Matrix4f pose = new org.joml.Matrix4f();
+ pose.translate(100f, 64f, 100f);
+ return pose;
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBoundsTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBoundsTest.java
new file mode 100644
index 00000000..0119308f
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBoundsTest.java
@@ -0,0 +1,85 @@
+package dev.loki.lovisual.render.engine.optimize.entity;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import net.minecraft.client.resources.model.cuboid.ItemTransform;
+import net.minecraft.world.phys.AABB;
+import org.joml.Vector3f;
+import org.joml.Vector3fc;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link ItemBounds} caching — same extents supplier + same transform
+ * hits the cache, a different transform or a reset recomputes.
+ */
+public final class ItemBoundsTest {
+
+ @AfterEach
+ void reset() {
+ ItemBounds.reset();
+ }
+
+ @Test
+ void configureAndReset() {
+ assertFalse(ItemBounds.enabled());
+ ItemBounds.configure(true);
+ assertTrue(ItemBounds.enabled());
+ ItemBounds.reset();
+ assertFalse(ItemBounds.enabled());
+ }
+
+ @Test
+ void cachesPerExtentsAndTransform() {
+ ItemBounds.configure(true);
+ AtomicInteger computes = new AtomicInteger();
+ Supplier extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)};
+ Supplier compute = () -> {
+ computes.incrementAndGet();
+ return new AABB(0, 0, 0, 1, 1, 1);
+ };
+
+ AABB first = ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute);
+ AABB second = ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute);
+ assertSame(first, second);
+ assertEquals(1, computes.get(), "second lookup must not recompute");
+ }
+
+ @Test
+ void differentTransformRecomputes() {
+ ItemBounds.configure(true);
+ AtomicInteger computes = new AtomicInteger();
+ Supplier extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)};
+ Supplier compute = () -> {
+ computes.incrementAndGet();
+ return new AABB(0, 0, 0, 1, 1, 1);
+ };
+
+ ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute);
+ ItemTransform other = new ItemTransform(new Vector3f(1, 0, 0), new Vector3f(), new Vector3f(1, 1, 1));
+ ItemBounds.get(extents, other, compute);
+ assertEquals(2, computes.get(), "a new transform must invalidate the entry");
+ }
+
+ @Test
+ void resetClearsCache() {
+ ItemBounds.configure(true);
+ AtomicInteger computes = new AtomicInteger();
+ Supplier extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)};
+ Supplier compute = () -> {
+ computes.incrementAndGet();
+ return new AABB(0, 0, 0, 1, 1, 1);
+ };
+
+ ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute);
+ ItemBounds.reset();
+ ItemBounds.configure(true);
+ ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute);
+ assertEquals(2, computes.get(), "reset must drop the cached entry");
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLodTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLodTest.java
new file mode 100644
index 00000000..318ea041
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLodTest.java
@@ -0,0 +1,100 @@
+package dev.loki.lovisual.render.engine.optimize.entity;
+
+import java.util.ArrayList;
+import java.util.List;
+import net.minecraft.client.resources.model.geometry.BakedQuad;
+import net.minecraft.core.Direction;
+import org.joml.Vector3f;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link ItemLod} pure logic — the toggle snapshot and the flat-model
+ * flattening rules — with hand-built quads, no render pipeline needed.
+ */
+public final class ItemLodTest {
+
+ @AfterEach
+ void reset() {
+ ItemLod.reset();
+ }
+
+ @Test
+ void configureAndReset() {
+ assertFalse(ItemLod.enabled());
+ ItemLod.configure(true, 8);
+ assertTrue(ItemLod.enabled());
+ assertEquals(64f, ItemLod.distanceSq(), 0.001f);
+ ItemLod.reset();
+ assertFalse(ItemLod.enabled());
+ assertEquals(0f, ItemLod.distanceSq(), 0.001f);
+ }
+
+ @Test
+ void flatKeepsOnlyZFacesOfFlatModel() {
+ List flatModel = new ArrayList<>();
+ flatModel.add(quad(Direction.NORTH, 0f, 0f)); // big front face
+ flatModel.add(quad(Direction.SOUTH, 1f, 1f)); // big back face
+ for (int i = 0; i < 8; i++) {
+ flatModel.add(quad(Direction.UP, 0.94f, 1f)); // thin edge strips
+ }
+
+ List result = ItemLod.flat(flatModel);
+ assertEquals(2, result.size(), "only the two Z faces survive");
+ assertSame(flatModel.get(0), result.get(0));
+ assertSame(flatModel.get(1), result.get(1));
+ }
+
+ @Test
+ void flatLeavesThickModelUnchanged() {
+ List model = new ArrayList<>();
+ model.add(quad(Direction.NORTH, 0f, 0f));
+ model.add(quad(Direction.SOUTH, 1f, 1f));
+ for (int i = 0; i < 6; i++) {
+ model.add(quad(Direction.UP, 0.94f, 1f));
+ }
+ model.add(quad(Direction.UP, 0f, 1f)); // thick non-Z quad — full cube side
+
+ assertSame(model, ItemLod.flat(model), "thick model is returned as-is");
+ }
+
+ @Test
+ void flatLeavesSmallModelsUnchanged() {
+ // A plain baked box is six quads — below the flattening threshold.
+ List box = new ArrayList<>();
+ box.add(quad(Direction.NORTH, 0f, 0f));
+ box.add(quad(Direction.SOUTH, 1f, 1f));
+ box.add(quad(Direction.UP, 0f, 1f));
+ box.add(quad(Direction.DOWN, 0f, 1f));
+ box.add(quad(Direction.EAST, 0f, 1f));
+ box.add(quad(Direction.WEST, 0f, 1f));
+
+ assertSame(box, ItemLod.flat(box));
+ }
+
+ @Test
+ void flatCachesPerFirstQuad() {
+ List model = new ArrayList<>();
+ model.add(quad(Direction.NORTH, 0f, 0f));
+ model.add(quad(Direction.SOUTH, 1f, 1f));
+ for (int i = 0; i < 8; i++) {
+ model.add(quad(Direction.UP, 0.94f, 1f));
+ }
+
+ List first = ItemLod.flat(model);
+ List second = ItemLod.flat(model);
+ assertSame(first, second, "same list instance comes from the cache");
+ }
+
+ private static BakedQuad quad(Direction dir, float z0, float z1) {
+ return new BakedQuad(
+ new Vector3f(0f, 0f, z0), new Vector3f(1f, 0f, z0),
+ new Vector3f(1f, 1f, z1), new Vector3f(0f, 1f, z1),
+ 0L, 0L, 0L, 0L, dir, null);
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbosTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbosTest.java
new file mode 100644
index 00000000..c3d4d758
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbosTest.java
@@ -0,0 +1,76 @@
+package dev.loki.lovisual.render.engine.optimize.gl;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * Verifies {@link SharedDepthFbos} bookkeeping — the headless-safe paths
+ * only. The DSA clear path needs a live GL context and is covered by an
+ * in-game check (owner runs {@code runClient}); without a cached FBO or a
+ * known depth id, {@code clear} returns {@code false} before touching GL.
+ */
+public final class SharedDepthFbosTest {
+
+ @AfterEach
+ void reset() {
+ SharedDepthFbos.resetForTesting();
+ }
+
+ @Test
+ void rememberTracksDepthToFboMapping() {
+ SharedDepthFbos.remember(10, 100);
+ SharedDepthFbos.remember(11, 101);
+ assertEquals(2, SharedDepthFbos.sizeForTesting());
+ assertEquals(100, SharedDepthFbos.fboForTesting(10));
+ assertEquals(101, SharedDepthFbos.fboForTesting(11));
+ }
+
+ @Test
+ void rememberSameDepthTwiceKeepsLastFbo() {
+ SharedDepthFbos.remember(10, 100);
+ SharedDepthFbos.remember(10, 102);
+ assertEquals(1, SharedDepthFbos.sizeForTesting());
+ assertEquals(102, SharedDepthFbos.fboForTesting(10));
+ }
+
+ @Test
+ void forgetRemovesOnlyMappingsPointingAtThatFbo() {
+ SharedDepthFbos.remember(10, 100);
+ SharedDepthFbos.remember(11, 100);
+ SharedDepthFbos.remember(12, 101);
+ SharedDepthFbos.forget(100);
+ assertEquals(1, SharedDepthFbos.sizeForTesting());
+ assertEquals(0, SharedDepthFbos.fboForTesting(10));
+ assertEquals(0, SharedDepthFbos.fboForTesting(11));
+ assertEquals(101, SharedDepthFbos.fboForTesting(12));
+ }
+
+ @Test
+ void forgetUnknownFboIsNoop() {
+ SharedDepthFbos.remember(10, 100);
+ SharedDepthFbos.forget(999);
+ assertEquals(1, SharedDepthFbos.sizeForTesting());
+ assertEquals(100, SharedDepthFbos.fboForTesting(10));
+ }
+
+ @Test
+ void clearWithoutMappingFallsThroughToVanilla() {
+ SharedDepthFbos.remember(10, 100);
+ // Unknown depth id: no GL touched, caller keeps the vanilla path.
+ assertFalse(SharedDepthFbos.clear(42, 1.0));
+ // Empty map after forget: same.
+ SharedDepthFbos.forget(100);
+ assertFalse(SharedDepthFbos.clear(10, 1.0));
+ }
+
+ @Test
+ void resetDropsEverything() {
+ SharedDepthFbos.remember(10, 100);
+ SharedDepthFbos.resetForTesting();
+ assertEquals(0, SharedDepthFbos.sizeForTesting());
+ assertFalse(SharedDepthFbos.clear(10, 1.0));
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorthTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorthTest.java
new file mode 100644
index 00000000..6750f609
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorthTest.java
@@ -0,0 +1,80 @@
+package dev.loki.lovisual.render.engine.optimize.hud;
+
+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 HudWorth} hysteresis — off below 1.5 frames per refresh,
+ * on above 2.0, stable inside the band.
+ */
+public final class HudWorthTest {
+
+ private static final long REFRESH_60HZ = 1_000_000_000L / 60; // ~16.67 ms
+
+ @AfterEach
+ void reset() {
+ HudWorth.reset();
+ }
+
+ @Test
+ void startsWorth() {
+ assertTrue(HudWorth.worth());
+ }
+
+ @Test
+ void fastFramesTurnItOff() {
+ // ~1 ms frames at 60 Hz → framesPerRefresh ≈ 16.7, far above the band: stays on
+ long t = 0;
+ for (int i = 0; i < 100; i++) {
+ t += 1_000_000L;
+ assertTrue(HudWorth.update(t, REFRESH_60HZ));
+ }
+
+ // now ~16 ms frames → framesPerRefresh ≈ 1.04, below the off threshold
+ for (int i = 0; i < 200; i++) {
+ t += 16_000_000L;
+ HudWorth.update(t, REFRESH_60HZ);
+ }
+ assertFalse(HudWorth.worth(), "near one frame per refresh the cache must turn off");
+ }
+
+ @Test
+ void hysteresisBandKeepsState() {
+ // drive it off with ~16 ms frames
+ long t = 0;
+ for (int i = 0; i < 200; i++) {
+ t += 16_000_000L;
+ HudWorth.update(t, REFRESH_60HZ);
+ }
+ assertFalse(HudWorth.worth());
+
+ // ~10 ms frames → framesPerRefresh ≈ 1.67, inside the band: stays off
+ for (int i = 0; i < 200; i++) {
+ t += 10_000_000L;
+ HudWorth.update(t, REFRESH_60HZ);
+ }
+ assertFalse(HudWorth.worth(), "inside the hysteresis band the state must not flip");
+
+ // ~5 ms frames → framesPerRefresh ≈ 3.3, above the on threshold: back on
+ for (int i = 0; i < 200; i++) {
+ t += 5_000_000L;
+ HudWorth.update(t, REFRESH_60HZ);
+ }
+ assertTrue(HudWorth.worth(), "well above the band the cache must turn back on");
+ }
+
+ @Test
+ void resetRestoresWorth() {
+ long t = 0;
+ for (int i = 0; i < 200; i++) {
+ t += 16_000_000L;
+ HudWorth.update(t, REFRESH_60HZ);
+ }
+ assertFalse(HudWorth.worth());
+ HudWorth.reset();
+ assertTrue(HudWorth.worth());
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRateTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRateTest.java
new file mode 100644
index 00000000..856f2cc3
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRateTest.java
@@ -0,0 +1,61 @@
+package dev.loki.lovisual.render.engine.optimize.hud;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link MonitorRate} pure logic — the refresh-rate clamp, the
+ * once-per-second probe caching and the reset — through the test probe hook;
+ * the GLFW path needs a live window.
+ */
+public final class MonitorRateTest {
+
+ @AfterEach
+ void reset() {
+ MonitorRate.reset();
+ }
+
+ @Test
+ void clampsLowRefreshRatesToThirty() {
+ MonitorRate.setProbeForTesting(() -> 10);
+ assertEquals(1_000_000_000L / 30, MonitorRate.intervalNs(0L));
+ }
+
+ @Test
+ void usesProbedRate() {
+ MonitorRate.setProbeForTesting(() -> 60);
+ assertEquals(1_000_000_000L / 60, MonitorRate.intervalNs(0L));
+ }
+
+ @Test
+ void probesAtMostOncePerSecond() {
+ AtomicInteger probes = new AtomicInteger();
+ MonitorRate.setProbeForTesting(() -> {
+ probes.incrementAndGet();
+ return 60;
+ });
+
+ MonitorRate.intervalNs(0L);
+ MonitorRate.intervalNs(500_000_000L); // still inside the 1 s window
+ assertEquals(1, probes.get(), "second call inside the window must not re-probe");
+
+ MonitorRate.intervalNs(1_000_000_000L); // window elapsed
+ assertEquals(2, probes.get(), "a new second must re-probe");
+ }
+
+ @Test
+ void resetRestoresDefaultInterval() {
+ MonitorRate.setProbeForTesting(() -> 30);
+ MonitorRate.intervalNs(0L);
+ MonitorRate.reset();
+ assertTrue(MonitorRate.intervalNs(0L) > 0);
+
+ // after reset the production probe is back — in headless tests it
+ // falls back to the 144 Hz default instead of touching GLFW
+ assertEquals(1_000_000_000L / 144, MonitorRate.intervalNs(0L));
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelsTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelsTest.java
new file mode 100644
index 00000000..ad53f1b9
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelsTest.java
@@ -0,0 +1,54 @@
+package dev.loki.lovisual.render.engine.optimize.parallel;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link ParallelModels} gating — the toggle snapshot, the cores
+ * floor and the AnimFreeze interlock. The build itself needs a live render
+ * pipeline (posed models, raw vertex consumers) and is covered by an
+ * in-game check.
+ */
+public final class ParallelModelsTest {
+
+ @AfterEach
+ void reset() {
+ ParallelModels.reset();
+ }
+
+ @Test
+ void offUntilConfigured() {
+ assertFalse(ParallelModels.enabled());
+ ParallelModels.configure(true);
+ assertTrue(ParallelModels.enabled(), "4+ cores, no Iris, no anim freeze");
+ ParallelModels.reset();
+ assertFalse(ParallelModels.enabled());
+ }
+
+ @Test
+ void animFreezeDisablesParallelBuild() {
+ ParallelModels.configure(true);
+ dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze.configure(true, 16);
+ assertFalse(ParallelModels.enabled(), "freeze replays captures, parallel builds fresh — exclusive");
+ dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze.reset();
+ assertTrue(ParallelModels.enabled());
+ }
+
+ @Test
+ void minSubmitsMatchesReferenceThreshold() {
+ assertEquals(24, ParallelModels.MIN_SUBMITS);
+ assertTrue(ParallelModels.FORMAT.getVertexSize() > 0);
+ }
+
+ @Test
+ void resetFreesWithoutErrorWhenUnused() {
+ ParallelModels.configure(true);
+ // begin/add/end need real models; reset alone must stay safe on any machine
+ ParallelModels.reset();
+ assertFalse(ParallelModels.enabled());
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStatsTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStatsTest.java
new file mode 100644
index 00000000..23347767
--- /dev/null
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStatsTest.java
@@ -0,0 +1,127 @@
+package dev.loki.lovisual.render.engine.optimize.profile;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link DrawStats} — the F3 draw/pipeline/framebuffer counters.
+ *
+ * Stage 3 step 1 of {@code mod/docs/optimize-layer-design.md}. Everything
+ * runs on the JUnit thread, which stands in for the render thread; time is
+ * passed into {@link DrawStats#endFrame(boolean, long)} so the one-second
+ * window can be driven without sleeping.
+ */
+public final class DrawStatsTest {
+
+ @AfterEach
+ void reset() {
+ DrawStats.reset();
+ }
+
+ @Test
+ void nothingCountsBeforeTheFirstShownFrame() {
+ DrawStats.draw();
+ DrawStats.pipeline("pipeline/a");
+ DrawStats.bind();
+
+ DrawStats.Snapshot s = DrawStats.snapshot();
+ assertEquals(0, s.frames());
+ assertEquals(0, s.draws());
+ assertTrue(s.top().isEmpty());
+ }
+
+ @Test
+ void nothingIsPublishedInsideTheWindow() {
+ DrawStats.endFrame(true, 0L);
+ DrawStats.draw();
+
+ DrawStats.endFrame(true, DrawStats.WINDOW_NS - 1);
+
+ assertEquals(0, DrawStats.snapshot().frames());
+ assertEquals(0, DrawStats.snapshot().draws());
+ }
+
+ @Test
+ void publishesOnceTheWindowElapses() {
+ DrawStats.endFrame(true, 0L);
+ DrawStats.draw();
+ DrawStats.draw();
+ DrawStats.pipeline("pipeline/a");
+ DrawStats.bind();
+
+ DrawStats.endFrame(true, DrawStats.WINDOW_NS);
+
+ DrawStats.Snapshot s = DrawStats.snapshot();
+ assertEquals(1, s.frames());
+ assertEquals(2, s.draws());
+ assertEquals(1, s.pipelines());
+ assertEquals(1, s.binds());
+ assertEquals(List.of("pipeline/a 1"), s.top());
+ }
+
+ @Test
+ void closingF3StopsCountingAndClearsTheSnapshot() {
+ DrawStats.endFrame(true, 0L);
+ DrawStats.draw();
+ DrawStats.endFrame(true, DrawStats.WINDOW_NS);
+ assertEquals(1, DrawStats.snapshot().draws());
+
+ DrawStats.endFrame(false, DrawStats.WINDOW_NS + 1);
+ assertEquals(0, DrawStats.snapshot().frames());
+ assertTrue(DrawStats.snapshot().top().isEmpty());
+
+ // a frame drawn while F3 is closed must not land anywhere
+ DrawStats.draw();
+ DrawStats.bind();
+ assertEquals(0, DrawStats.snapshot().draws());
+ assertEquals(0, DrawStats.snapshot().binds());
+ }
+
+ @Test
+ void publishAveragesOverFramesAndSortsTopBusiestFirst() {
+ Map byPipeline = new LinkedHashMap<>();
+ byPipeline.put("pipeline/rare", 2);
+ byPipeline.put("pipeline/busy", 40);
+ byPipeline.put("pipeline/middle", 10);
+
+ DrawStats.Snapshot s = DrawStats.publish(100, 50, 10, 4, byPipeline, 2);
+
+ assertEquals(25, s.draws()); // 100 / 4
+ assertEquals(13, s.pipelines()); // round(50 / 4)
+ assertEquals(3, s.binds()); // round(10 / 4)
+ assertEquals(4, s.frames());
+ assertEquals(List.of("pipeline/busy 10", "pipeline/middle 3"), s.top());
+ }
+
+ @Test
+ void publishWithNoFramesDoesNotDivideByZero() {
+ DrawStats.Snapshot s = DrawStats.publish(7, 3, 1, 0, Map.of(), DrawStats.TOP);
+
+ assertEquals(0, s.frames());
+ assertEquals(7, s.draws());
+ assertEquals(3, s.pipelines());
+ assertEquals(1, s.binds());
+ assertTrue(s.top().isEmpty());
+ }
+
+ @Test
+ void topIsLimitedToTheConfiguredCount() {
+ Map byPipeline = new LinkedHashMap<>();
+ for (int i = 0; i < 20; i++) {
+ byPipeline.put("pipeline/" + i, i + 1);
+ }
+
+ DrawStats.Snapshot s = DrawStats.publish(210, 210, 0, 1, byPipeline, DrawStats.TOP);
+
+ assertEquals(DrawStats.TOP, s.top().size());
+ assertEquals("pipeline/19 20", s.top().get(0));
+ assertEquals("pipeline/15 16", s.top().get(DrawStats.TOP - 1));
+ }
+}
diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java
index bf4a03be..3135fc44 100644
--- a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java
+++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java
@@ -23,7 +23,7 @@ public final class OptimizePropertyBridgeTest {
"lovisual.opt.entity_lod", "lovisual.opt.particle_lod",
"lovisual.opt.spawner_cull", "lovisual.opt.spawner_tick_cache",
"lovisual.opt.block_entity_cache", "lovisual.opt.prepared_text_cache",
- "lovisual.opt.micro_opts",
+ "lovisual.opt.micro_opts", "lovisual.opt.fbo_share",
"lovisual.opt.entity_lod_distance", "lovisual.opt.spawner_distance",
"lovisual.opt.budget_mode", "lovisual.opt.target_fps"
}) {
@@ -68,6 +68,19 @@ public final class OptimizePropertyBridgeTest {
assertFalse(s.overrides(s.entityLod, true));
}
+ @Test
+ void fboShareBooleanOverride() {
+ System.setProperty("lovisual.opt.fbo_share", "false");
+ OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve();
+ assertEquals(Boolean.FALSE, s.fboShare);
+ assertFalse(s.overrides(s.fboShare, true));
+
+ System.setProperty("lovisual.opt.fbo_share", "true");
+ s = OptimizePropertyBridge.resolve();
+ assertEquals(Boolean.TRUE, s.fboShare);
+ assertTrue(s.overrides(s.fboShare, false));
+ }
+
@Test
void distanceOverride() {
System.setProperty("lovisual.opt.entity_lod_distance", "100");