feat(mod): Stage 3 steps 1-8 — DrawStats, SharedDepthFbos, AnimFreeze, ItemLod/Bounds, HudCache, raw vertices, ParallelModels

This commit is contained in:
loki5512344 2026-10-12 14:51:41 +02:00
parent 8ef46e4560
commit 645896ca4b
Signed by: boba
GPG key ID: 253067914055423B
83 changed files with 3107 additions and 143 deletions

View file

@ -3,6 +3,8 @@
Статус: Этап 1 готов (2026-10-10), Этап 2 + начало Этапа 3 готовы (2026-10-10). Цель MC: 26.2. Статус: Этап 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**: Этап 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**: Этап 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. - **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` и др.) не переписываем, новый код подключается рядом. Существующие 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. 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 новых теста. 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-оптимизаций реально нужны. 4. ❌ `vulkan/`: `vkCmdCopyImage`, затем `VkPipelineCache`, затем остальное по замерам. **Не начат** — дизайн готов, ждёт профилирования GL-части чтобы понять, какие из Vulkan-оптимизаций реально нужны.
5. ❌ Бенчмарки до и после, отчёт цифр в этот документ. **Не начат** — требует запуска Minecraft на Windows, задача владельца. 5. ❌ Бенчмарки до и после, отчёт цифр в этот документ. **Не начат** — требует запуска Minecraft на Windows, задача владельца.
## Оценки для Этапов 3-4 (после Stage 1-2) ## Оценки для Этапов 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), остальное по мере замеров. - **Этап 4**: 6 пунктов, каждый требует профилирования. Минимум 1 сессия на `vkCmdCopyImage` (чтобы SkinAtlas заработал на Vulkan), остальное по мере замеров.
## Риски ## Риски

View file

@ -20,3 +20,4 @@ viafabricplus_version=4.6.0
entityculling_version=1.10.5 entityculling_version=1.10.5
voicechat_api_version=2.6.24 voicechat_api_version=2.6.24
mixinextras_version=0.5.5 mixinextras_version=0.5.5
lovisualDevEnabled=false

View file

@ -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.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.Notifier; 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.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.profile.OptimizePropertyBridge;
import dev.loki.lovisual.render.engine.optimize.OptimizeState; import dev.loki.lovisual.render.engine.optimize.OptimizeState;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; 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 * 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 * — never this class; this module only pushes state into them on
* enable/disable and once per frame. * enable/disable and once per frame.
* *
* <p>Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module now also * <p>Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module also
* pushes a {@link FrameBudget} snapshot (mode + target FPS) and resolves * pushes a {@link FrameBudget} snapshot (mode + target FPS) and resolves
* {@code -Dlovisual.opt.*} system-property overrides via * {@code -Dlovisual.opt.*} system-property overrides via
* {@link OptimizePropertyBridge}. The frame profiler ({@code FrameProfiler}) * {@link OptimizePropertyBridge}. Stage 3 step 1 adds {@link DrawStats} —
* is fed by the {@code MinecraftFrameMixin} (Stage 3 — not yet present); the * draw/pipeline/framebuffer counters fed by the {@code mixins/optimize/stats}
* F3 overlay ({@code OptimizeDebugHudMixin}) reads the profiler's snapshot * hooks, opened and closed here so they only run while F3 is up. The F3
* whenever the user opens F3. * overlay ({@code OptimizeDebugHudMixin}) reads both snapshots.
*/ */
@ModuleInfo( @ModuleInfo(
id = "optimize", id = "optimize",
@ -58,6 +66,23 @@ public final class Optimize extends Module {
private final BooleanValue preparedTextCache = bool("prepared_text_cache", true); private final BooleanValue preparedTextCache = bool("prepared_text_cache", true);
private final BooleanValue microOpts = bool("micro_opts", 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<Integer> entityAnimDistance =
num("entity_anim_distance", 16, 4, 128);
// --- Stage 3: items ---
private final BooleanValue itemLod = bool("item_lod", true);
private final NumberValue<Integer> 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 --- // --- Stage 2: frame budget + profiling ---
private final EnumValue<FrameBudget.Mode> budgetMode = private final EnumValue<FrameBudget.Mode> budgetMode =
enumMode("budget_mode", FrameBudget.Mode.OFF, FrameBudget.Mode.values()); enumMode("budget_mode", FrameBudget.Mode.OFF, FrameBudget.Mode.values());
@ -84,12 +109,27 @@ public final class Optimize extends Module {
OptimizeState.reset(); OptimizeState.reset();
OptimizeToggles.reset(); OptimizeToggles.reset();
FrameBudget.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. */ /** Picks up knob changes made while the module stays on. */
@Override @Override
public void onFrame(float d) { public void onFrame(float d) {
pushState(); 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() { private void pushState() {
@ -114,11 +154,25 @@ public final class Optimize extends Module {
resolveBool(props, props.spawnerTickCache, on && spawnerTickCache.get()), resolveBool(props, props.spawnerTickCache, on && spawnerTickCache.get()),
resolveBool(props, props.blockEntityCache, on && blockEntityCache.get()), resolveBool(props, props.blockEntityCache, on && blockEntityCache.get()),
resolveBool(props, props.preparedTextCache, on && preparedTextCache.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. // Stage 2: frame budget. -Dlovisual.opt.budget_mode / target_fps override the module.
FrameBudget.Mode mode = on ? budgetMode.get() : FrameBudget.Mode.OFF; FrameBudget.Mode mode = on ? budgetMode.get() : FrameBudget.Mode.OFF;
FrameBudget.apply(props.overrides(mode, mode), props.resolveTargetFps(targetFps.get())); 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, private static boolean resolveBool(OptimizePropertyBridge.Snapshot props,

View file

@ -1,6 +1,7 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.gl; 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.OptimizeToggles;
import dev.loki.lovisual.render.engine.optimize.gl.SharedDepthFbos;
import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap; import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap;
import java.util.Map; import java.util.Map;
@ -11,31 +12,22 @@ import java.util.concurrent.ConcurrentHashMap;
* *
* <p>Resolves the current GL device via {@code GlBackendAccess.current()} — * <p>Resolves the current GL device via {@code GlBackendAccess.current()} —
* returns {@code null} on Vulkan so all mixins short-circuit to the vanilla * 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 * call. The cache state is static and render-thread-only (the GL command
* same — the GL command encoder runs single-threaded). * encoder runs single-threaded).
*
* <p>Ported from ZakoOpt's three GL caches:
* <ul>
* <li>{@code zako.opt.mixin.gl.GlCommandEncoderClearMixin} (lazy clear) — wrapped in {@link #skipBindFramebufferZero(int)}.</li>
* <li>{@code zako.opt.gl.TexBufferCache} + {@code zako.opt.mixin.gl.GlCommandEncoderTexBufferMixin} — wrapped in the TBO-cache methods.</li>
* <li>{@code zako.opt.mixin.gl.RenderTypesMixin} — wrapped in the RenderTypes-cache methods.</li>
* </ul>
* *
* <p>Each toggle is gated by {@link OptimizeToggles#microOpts()} (the Stage 1 * <p>Each toggle is gated by {@link OptimizeToggles#microOpts()} (the Stage 1
* "micro_opts" toggle wired into {@code Optimize.java}); ZakoOpt splits this * "micro_opts" toggle wired into {@code Optimize.java}).
* into {@code lookupCaches}, {@code microOpts}, {@code tboCache} — Stage 1
* collapses them, Stage 2 may split if profiling warrants.
*/ */
public final class GlRenderBackendOps implements RenderBackendOps { public final class GlRenderBackendOps implements RenderBackendOps {
private static final GlRenderBackendOps INSTANCE = new GlRenderBackendOps(); 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. */ /** Maps bound-texture-id -> packed (format << 32) | (buffer & 0xFFFFFFFFL). -1 = nothing attached. */
private final Int2LongOpenHashMap tboAttached = new Int2LongOpenHashMap(); private final Int2LongOpenHashMap tboAttached = new Int2LongOpenHashMap();
private int boundTboTexture; private int boundTboTexture;
// --- RenderTypes cache (ported from zako.opt.mixin.gl.RenderTypesMixin) --- // --- RenderTypes cache ---
private final Map<Identifier, Object> translucentCache = new ConcurrentHashMap<>(); private final Map<Identifier, Object> translucentCache = new ConcurrentHashMap<>();
private GlRenderBackendOps() { private GlRenderBackendOps() {
@ -100,6 +92,18 @@ public final class GlRenderBackendOps implements RenderBackendOps {
return ((long) format << 32) | (buffer & 0xFFFFFFFFL); 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 --- // --- RenderTypes cache ---
@Override @Override

View file

@ -17,6 +17,8 @@ package dev.loki.lovisual.features.module.modules.misc.optimize.gl;
* <li>{@link #tboCacheSupported()} / {@link #tboAlreadyAttached(int, int)} / * <li>{@link #tboCacheSupported()} / {@link #tboAlreadyAttached(int, int)} /
* {@link #markTboAttached(int, int)} / {@link #trackBoundTboTexture(int)} / * {@link #markTboAttached(int, int)} / {@link #trackBoundTboTexture(int)} /
* {@link #forgetTboCache()} — TBO cache</li> * {@link #forgetTboCache()} — TBO cache</li>
* <li>{@link #fboShareSupported()} / {@link #clearDepthInPlace(int, double)} —
* shared depth FBOs (Stage 3 step 2)</li>
* <li>{@link #renderTypesCacheSupported()} / {@link #renderTypeFor(Identifier)} / * <li>{@link #renderTypesCacheSupported()} / {@link #renderTypeFor(Identifier)} /
* {@link #cacheRenderType(Identifier, Object)} — RenderTypes cache</li> * {@link #cacheRenderType(Identifier, Object)} — RenderTypes cache</li>
* </ul> * </ul>
@ -64,6 +66,22 @@ public interface RenderBackendOps {
/** Forget all TBO attachments — called when a {@code GlBuffer.Direct} is closed. */ /** Forget all TBO attachments — called when a {@code GlBuffer.Direct} is closed. */
void forgetTboCache(); 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 --- // --- RenderTypes cache ---
/** True when this backend can cache {@code RenderTypes.entityTranslucent(id, flag=true)}. */ /** True when this backend can cache {@code RenderTypes.entityTranslucent(id, flag=true)}. */

View file

@ -16,8 +16,6 @@ import net.minecraft.world.level.material.FluidState;
* and destroyed constantly — per-particle state would be re-initialized too * and destroyed constantly — per-particle state would be re-initialized too
* often to be useful. * often to be useful.
* *
* <p>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), * The cache logic is in {@link TickCache} (a generic per-tick keyed cache),
* so it's unit-testable without Minecraft types. * so it's unit-testable without Minecraft types.
*/ */

View file

@ -15,8 +15,6 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
* created and destroyed constantly — per-particle state would be re-initialized * created and destroyed constantly — per-particle state would be re-initialized
* too often to be useful. * too often to be useful.
* *
* <p>Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin}
* (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
*/ */
public final class TickCache<V> { public final class TickCache<V> {

View file

@ -59,11 +59,78 @@ public final class Workers {
return job.error.get(); 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. */ /** Test-only: shut the pool down so the JVM can exit cleanly. */
public static void shutdown() { public static void shutdown() {
POOL.shutdownNow(); 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<Throwable> 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 { private static final class Job {
final int blocks; final int blocks;
final IntConsumer task; final IntConsumer task;

View file

@ -12,8 +12,6 @@ import net.minecraft.world.phys.Vec3;
* {@link ParticleMixin} reads it without re-walking the gameRenderer chain * {@link ParticleMixin} reads it without re-walking the gameRenderer chain
* per particle. * per particle.
* *
* <p>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. * particle ticking runs on the render thread.
*/ */
public final class CameraPosition { public final class CameraPosition {

View file

@ -15,8 +15,6 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
* outline-target-clear {@code WrapOperation} when the renderer actually * outline-target-clear {@code WrapOperation} when the renderer actually
* touches the outline target. * touches the outline target.
* *
* <p>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. * The state is lifted out of the mixin so the mixin stays a thin wrapper.
*/ */
public enum OutlineTracker { public enum OutlineTracker {

View file

@ -22,7 +22,8 @@ public final class LoVisualMixinPlugin implements IMixinConfigPlugin {
"dev.loki.lovisual.mixins.sodium.vertex.SodiumVertexConsumerTrackerMixin", "dev.loki.lovisual.mixins.sodium.vertex.SodiumVertexConsumerTrackerMixin",
"dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceCullMixin", "dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceCullMixin",
"dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceHideMixin", "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<String> OPTIONAL_IRIS_MIXINS = Set.of( private static final Set<String> OPTIONAL_IRIS_MIXINS = Set.of(
"dev.loki.lovisual.mixins.iris.render.IrisCommonUniformsMixin", "dev.loki.lovisual.mixins.iris.render.IrisCommonUniformsMixin",

View file

@ -16,9 +16,6 @@ import org.spongepowered.asm.mixin.Unique;
* the display entity only changes on tick boundaries (spin speed, mob * the display entity only changes on tick boundaries (spin speed, mob
* rotation, etc.), so caching by tick removes that rebuild from the hot path. * rotation, etc.), so caching by tick removes that rebuild from the hot path.
* *
* <p>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) @Mixin(BaseSpawner.class)
public abstract class BaseSpawnerMixin implements TickCached { public abstract class BaseSpawnerMixin implements TickCached {

View file

@ -21,9 +21,6 @@ import org.spongepowered.asm.mixin.Unique;
* distance check that only changes when the entity moves across the cull * distance check that only changes when the entity moves across the cull
* boundary. All three are cached via the interface fields implemented here. * boundary. All three are cached via the interface fields implemented here.
* *
* <p>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) @Mixin(BlockEntity.class)
public abstract class BlockEntityMixin implements TickCached, CachedLight, CachedValidity { public abstract class BlockEntityMixin implements TickCached, CachedLight, CachedValidity {

View file

@ -25,8 +25,6 @@ import org.spongepowered.asm.mixin.injection.At;
* {@link CachedLight} mixin from Stage 3-continued-2) before falling through * {@link CachedLight} mixin from Stage 3-continued-2) before falling through
* to the original. * to the original.
* *
* <p>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 * Gated on the {@code blockEntityCache} toggle via
* {@link OptimizeToggles#blockEntityCache()}. When the toggle is off, the * {@link OptimizeToggles#blockEntityCache()}. When the toggle is off, the
* original runs unchanged. * original runs unchanged.

View file

@ -35,12 +35,8 @@ import org.spongepowered.asm.mixin.injection.At;
* {@link CachedLight#lovisual$pairLight()} slot.</li> * {@link CachedLight#lovisual$pairLight()} slot.</li>
* </ul> * </ul>
* *
* <p>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 * Combine cache gated on {@code blockEntityCache} toggle; pair-light cache
* gated on {@code microOpts} toggle (matches ZakoOpt's microOpts2 split — * gated on {@code microOpts} toggle.
* Stage 1 collapsed microOpts2 into microOpts, so both paths read the same
* toggle).
*/ */
@Mixin(ChestRenderer.class) @Mixin(ChestRenderer.class)
public abstract class ChestRendererMixin { public abstract class ChestRendererMixin {

View file

@ -13,9 +13,6 @@ import org.spongepowered.asm.mixin.Unique;
* prefix + name + suffix on every call; renderers call it once per frame per * 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. * entity in view, so caching by tick cuts the per-frame cost.
* *
* <p>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) @Mixin(Entity.class)
public abstract class EntityNameMixin implements CachedName { public abstract class EntityNameMixin implements CachedName {

View file

@ -13,9 +13,6 @@ import org.spongepowered.asm.mixin.Unique;
* {@link EntityRendererStateMixin} captures it once per state construction * {@link EntityRendererStateMixin} captures it once per state construction
* via {@code createRenderState} and downstream renderers read it back. * via {@code createRenderState} and downstream renderers read it back.
* *
* <p>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) @Mixin(EntityRenderState.class)
public abstract class EntityRenderStateEntityMixin implements StateEntity { public abstract class EntityRenderStateEntityMixin implements StateEntity {

View file

@ -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 * entity; we AND it with "not far" so far entities skip the layers but
* close entities render them as usual. * close entities render them as usual.
* *
* <p>Ported from {@code zako.opt.mixin.entity.LivingEntityLodMixin} (ZakoOpt,
* LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* *
* <p>Gated on the {@code entity_lod} toggle via {@link EntityLod#far(double)}. * <p>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 * When the toggle is off, {@code far} returns {@code false} so the modify

View file

@ -21,8 +21,6 @@ import org.spongepowered.asm.mixin.injection.At;
* but the convention is kept for safety. The mixin is registered with * 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. * {@code remap = false} because the target is a Sodium class, not a Mojang one.
* *
* <p>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()}. * Gated on the {@code microOpts} toggle via {@link OptimizeToggles#microOpts()}.
* When the toggle is off, the original runs unchanged. * When the toggle is off, the original runs unchanged.
* *

View file

@ -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;
}
}

View file

@ -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.
*
* <p>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<ModelFeatureRenderer.Submit<?>> 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<Void> 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);
}
}

View file

@ -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")
<S> void lovisual$prepareModel(ModelFeatureRenderer.Submit<S> submit);
}

View file

@ -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<ModelPart.Cube> lovisual$cubes();
@Accessor("children")
Map<String, ModelPart> lovisual$children();
}

View file

@ -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);
}

View file

@ -22,8 +22,6 @@ import org.spongepowered.asm.mixin.injection.At;
* original runs and the result is stored; subsequent calls in the same tick * original runs and the result is stored; subsequent calls in the same tick
* return the cached instance. * return the cached instance.
* *
* <p>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()}. * Gated on the {@code microOpts} toggle via {@link OptimizeToggles#microOpts()}.
* When the toggle is off, the original runs unchanged. * When the toggle is off, the original runs unchanged.
*/ */

View file

@ -18,8 +18,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
* renderers (model feature renderer, item feature renderer, future LOD mixins) * renderers (model feature renderer, item feature renderer, future LOD mixins)
* can reach the live entity. * can reach the live entity.
* *
* <p>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} * The original used {@code @ModifyReturnValue}; LoVisual uses {@code @Inject}
* at RETURN with {@code CallbackInfoReturnable} so the mixin stays simple and * at RETURN with {@code CallbackInfoReturnable} so the mixin stays simple and
* doesn't rely on the (more fragile across versions) MixinExtras return-value * doesn't rely on the (more fragile across versions) MixinExtras return-value

View file

@ -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.
*
*
* <p>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();
}
}
}

View file

@ -15,8 +15,6 @@ import org.spongepowered.asm.mixin.injection.At;
* we don't pay for a {@code glBindFramebuffer(GL_FRAMEBUFFER, 0)} that's * we don't pay for a {@code glBindFramebuffer(GL_FRAMEBUFFER, 0)} that's
* immediately overwritten. * immediately overwritten.
* *
* <p>Ported from {@code zako.opt.mixin.gl.GlCommandEncoderClearMixin} (ZakoOpt,
* LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* *
* <p>Gated on the {@code microOpts} toggle via {@link RenderBackendOps#skipBindFramebufferZero(int)}. * <p>Gated on the {@code microOpts} toggle via {@link RenderBackendOps#skipBindFramebufferZero(int)}.
* When the toggle is off, the original call runs unchanged. * When the toggle is off, the original call runs unchanged.

View file

@ -15,8 +15,6 @@ import org.spongepowered.asm.mixin.injection.At;
* re-attaching makes the driver revalidate the texture (clouds: ~2% of a * re-attaching makes the driver revalidate the texture (clouds: ~2% of a
* frame in {@code glDrawElements}). * frame in {@code glDrawElements}).
* *
* <p>Ported from {@code zako.opt.mixin.gl.GlCommandEncoderTexBufferMixin} (ZakoOpt,
* LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* *
* <p>Gated on the {@code microOpts} toggle via * <p>Gated on the {@code microOpts} toggle via
* {@link RenderBackendOps#tboCacheSupported()}. When the toggle is off, both * {@link RenderBackendOps#tboCacheSupported()}. When the toggle is off, both

View file

@ -19,8 +19,6 @@ import java.util.function.BiFunction;
* items, etc.), the result depends only on the identifier, so we cache it * items, etc.), the result depends only on the identifier, so we cache it
* directly in a {@code ConcurrentHashMap}. * directly in a {@code ConcurrentHashMap}.
* *
* <p>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 * is wrapped in {@link RenderBackendOps}; LoVisual's GL implementation is
* {@code GlRenderBackendOps}, which gates on {@code microOpts} (Stage 1 toggle). * {@code GlRenderBackendOps}, which gates on {@code microOpts} (Stage 1 toggle).
* *

View file

@ -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.
*
*
* <p>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<FrameBufferCache.CacheKey> cache;
@Inject(method = "getFbo", at = @At("RETURN"))
private void lovisual$rememberDepth(DirectStateAccess dsa,
List<FrameBufferAttachment> colors,
FrameBufferAttachment depth,
CallbackInfoReturnable<Integer> 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));
}
}
}

View file

@ -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.
*
*
* <p>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();
}
}
}

View file

@ -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;
}
}

View file

@ -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.
*
* <p>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<VertexBufferWriter> original) {
if (consumer == lovisual$lastWriter && OptimizeToggles.microOpts()) {
return (VertexBufferWriter) consumer;
}
VertexBufferWriter writer = original.call(consumer);
if (writer == consumer) {
lovisual$lastWriter = consumer;
}
return writer;
}
}

View file

@ -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.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize; 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.FrameBudget;
import dev.loki.lovisual.render.engine.optimize.profile.FrameProfiler; 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.profile.OptimizePropertyBridge;
@ -65,12 +66,25 @@ public abstract class OptimizeDebugHudMixin {
out.add(String.format("frame: %.2f ms (%.0f fps)", out.add(String.format("frame: %.2f ms (%.0f fps)",
snap.frameMs(), snap.frameMs(),
1000.0 / Math.max(0.01, snap.frameMs()))); 1000.0 / Math.max(0.01, snap.frameMs())));
out.add(String.format(" entities=%.2fms particles=%.2fms hud=%.2fms effects=%.2fms text=%.2fms", if (hasSectionTimings(snap)) {
snap.entityMs(), out.add(String.format(" entities=%.2fms particles=%.2fms hud=%.2fms effects=%.2fms text=%.2fms",
snap.particleMs(), snap.entityMs(),
snap.hudMs(), snap.particleMs(),
snap.effectMs(), snap.hudMs(),
snap.textMs())); 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()) { if (OptimizePropertyBridge.hasOverrides()) {
@ -82,6 +96,15 @@ public abstract class OptimizeDebugHudMixin {
return out; 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() { private static String togglesSummary() {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
if (OptimizeToggles.entityLod()) append(sb, "entity_lod@" + OptimizeToggles.entityLodDistance()); if (OptimizeToggles.entityLod()) append(sb, "entity_lod@" + OptimizeToggles.entityLodDistance());

View file

@ -18,8 +18,6 @@ import org.spongepowered.asm.mixin.injection.At;
* {@link BubbleColumnCache} first; on a miss, the original runs and the * {@link BubbleColumnCache} first; on a miss, the original runs and the
* result is stored. * result is stored.
* *
* <p>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 * The cache logic is in {@link BubbleColumnCache} so the mixin stays a thin
* wrapper and the cache is unit-testable without Minecraft types. * wrapper and the cache is unit-testable without Minecraft types.
* *

View file

@ -20,7 +20,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** /**
* Stage 3: per-particle light cache + far-particle physics skip. * Stage 3: per-particle light cache + far-particle physics skip.
* *
* <p>Two optimizations in one mixin (matches ZakoOpt): * <p>Two optimizations in one mixin:
* <ul> * <ul>
* <li><b>Light cache</b> — vanilla {@code getLightCoords(partialTick)} walks * <li><b>Light cache</b> — vanilla {@code getLightCoords(partialTick)} walks
* the chunk's light section data per call, called per particle per frame. * 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}.</li> * duration of {@code move}.</li>
* </ul> * </ul>
* *
* <p>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); * Light-cache gated on the {@code microOpts} toggle (Stage 1 wiring);
* physics-skip also gated on {@code microOpts} since both are micro-opts. * physics-skip also gated on {@code microOpts} since both are micro-opts.
* When the toggle is off, the original code runs unchanged. * When the toggle is off, the original code runs unchanged.

View file

@ -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.
*
* <p>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();
}
}

View file

@ -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}.
*
* <p>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());
}
}

View file

@ -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.
*
* <p>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();
}
}

View file

@ -27,8 +27,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
* entity was seen this frame.</li> * entity was seen this frame.</li>
* </ul> * </ul>
* *
* <p>Ported from {@code zako.opt.mixin.entity.LevelRendererOutlineMixin}
* (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
* *
* <p>Gated on the {@code microOpts} toggle via {@link OutlineTracker#enabled()}. * <p>Gated on the {@code microOpts} toggle via {@link OutlineTracker#enabled()}.
* When the toggle is off, all hooks fall through to the vanilla call. * 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 // 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 // (it's one of several cleared textures in addMainPass), so we mark the
// frame as glowing unconditionally — the doEntityOutline inject will then // frame as glowing unconditionally — the doEntityOutline inject will then
// run. This is a conservative approximation; ZakoOpt uses @Local to grab // run. This is a conservative approximation (grabbing the actual target
// the actual target reference, but that requires MixinExtras @Local which // reference would need MixinExtras @Local, which is brittle across MC
// is brittle across MC version changes. The cost of the conservative path // version changes). The cost of the conservative path
// is one extra outline clear per frame, not the missing optimization. // is one extra outline clear per frame, not the missing optimization.
OutlineTracker.markGlowing(); OutlineTracker.markGlowing();
original.call(encoder, texture, color); original.call(encoder, texture, color);

View file

@ -2,10 +2,16 @@ package dev.loki.lovisual.mixins.render.item;
import com.llamalad7.mixinextras.injector.ModifyExpressionValue; import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
import com.llamalad7.mixinextras.injector.ModifyReturnValue; 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 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.feature.ItemFeatureRenderer;
import net.minecraft.client.renderer.rendertype.RenderType; import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.resources.model.geometry.BakedQuad; 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.Mixin;
import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At; 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. // corrupt the following world translucent composite, so item layers keep their own type.
return original; 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<BakedQuad> lovisual$itemLod(ItemFeatureRenderer.Submit submit, Operation<List<BakedQuad>> original) {
List<BakedQuad> 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;
}
} }

View file

@ -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<Vector3fc[]> lovisual$extents();
@Accessor("itemTransform")
ItemTransform lovisual$itemTransform();
@Accessor("localTransform")
Matrix4f lovisual$localTransform();
}

View file

@ -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<AABB> 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);
}
}

View file

@ -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.player.interact.NoInteract;
import dev.loki.lovisual.features.module.modules.visuals.camera.Freecam; import dev.loki.lovisual.features.module.modules.visuals.camera.Freecam;
import dev.loki.lovisual.features.relations.StaffTracker; 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.render.helpers.util.TickDelta;
import dev.loki.lovisual.util.combat.tracking.AntiBotTracker; import dev.loki.lovisual.util.combat.tracking.AntiBotTracker;
import dev.loki.lovisual.util.session.SessionChanger; import dev.loki.lovisual.util.session.SessionChanger;
@ -257,6 +258,11 @@ public class MinecraftMixin implements MinecraftGameConfigHolder {
AntiBotTracker.INSTANCE.reset(); AntiBotTracker.INSTANCE.reset();
} }
@Inject(method = "renderFrame", at = @At("HEAD"))
private void lovisual$animFreezeFrame(boolean tick, CallbackInfo ci) {
AnimFreeze.frame++;
}
@Inject( @Inject(
method = "renderFrame", method = "renderFrame",
at = @At( at = @At(

View file

@ -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.core.policy.fog.VanillaWorldFogProvider;
import dev.loki.lovisual.render.engine.depth.WorldSceneDepth; import dev.loki.lovisual.render.engine.depth.WorldSceneDepth;
import dev.loki.lovisual.render.engine.framegraph.LoVisualFrameGraph; 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.FrameStutterProfiler;
import dev.loki.lovisual.render.engine.profiler.frame.RenderFrameProfiler; import dev.loki.lovisual.render.engine.profiler.frame.RenderFrameProfiler;
import dev.loki.lovisual.render.engine.rhi.resource.LoVisualRhi; import dev.loki.lovisual.render.engine.rhi.resource.LoVisualRhi;
@ -220,6 +221,7 @@ public enum LoVisualRenderSystem {
TextRenderSystem.beginFrame(); TextRenderSystem.beginFrame();
sodiumFrame = SODIUM.beginFrame(); sodiumFrame = SODIUM.beginFrame();
RenderFrameProfiler.beginFrame(frameId); RenderFrameProfiler.beginFrame(frameId);
FrameProfiler.beginFrame();
frameOpen = true; frameOpen = true;
submissionEnded = false; submissionEnded = false;
lifecycle = FrameLifecycle.RECORDING; lifecycle = FrameLifecycle.RECORDING;
@ -319,6 +321,7 @@ public enum LoVisualRenderSystem {
FrameStutterProfiler.onFramePresented(rhiStatsSnapshot(), uniformStatsSnapshot(), resourceStatsSnapshot()); FrameStutterProfiler.onFramePresented(rhiStatsSnapshot(), uniformStatsSnapshot(), resourceStatsSnapshot());
} }
RenderFrameProfiler.endFrame(null, null); RenderFrameProfiler.endFrame(null, null);
FrameProfiler.endFrame();
lifecycle = FrameLifecycle.PRESENTED; lifecycle = FrameLifecycle.PRESENTED;
} finally { } finally {
currentContext = null; currentContext = null;

View file

@ -8,9 +8,11 @@ package dev.loki.lovisual.render.engine.optimize;
* future mixins) — never by the module class itself. * future mixins) — never by the module class itself.
* *
* <p>Stage 1 of {@code mod/docs/optimize-layer-design.md}: backend-independent * <p>Stage 1 of {@code mod/docs/optimize-layer-design.md}: backend-independent
* logic only. GL/Vulkan-specific toggles (frame fence, lazy clear, FBO share, * logic plus the toggle snapshot. Lazy clear and the TBO cache ride
* TBO cache, skin atlas, HUD cache) live in their own holders added in Stages * {@link #microOpts()}; FBO share (Stage 3 step 2) has its own
* 3-4 alongside the mixins that consume them. * {@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.
* *
* <p>All module settings are resolved into volatile primitive fields once per frame. * <p>All module settings are resolved into volatile primitive fields once per frame.
* System-property overrides are not supported here (the {@code Optimize} module's * 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 blockEntityCache;
private static volatile boolean preparedTextCache; private static volatile boolean preparedTextCache;
private static volatile boolean microOpts; private static volatile boolean microOpts;
private static volatile boolean fboShare;
public static boolean entityLod() { return entityLod; } public static boolean entityLod() { return entityLod; }
public static int entityLodDistance() { return entityLodDistance; } public static int entityLodDistance() { return entityLodDistance; }
@ -43,6 +46,7 @@ public enum OptimizeToggles {
public static boolean blockEntityCache() { return blockEntityCache; } public static boolean blockEntityCache() { return blockEntityCache; }
public static boolean preparedTextCache() { return preparedTextCache; } public static boolean preparedTextCache() { return preparedTextCache; }
public static boolean microOpts() { return microOpts; } public static boolean microOpts() { return microOpts; }
public static boolean fboShare() { return fboShare; }
/** /**
* Push a full snapshot. Called by {@code Optimize.onEnable/onDisable/onFrame} * Push a full snapshot. Called by {@code Optimize.onEnable/onDisable/onFrame}
@ -55,7 +59,8 @@ public enum OptimizeToggles {
boolean spawnerTickCache, boolean spawnerTickCache,
boolean blockEntityCache, boolean blockEntityCache,
boolean preparedTextCache, boolean preparedTextCache,
boolean microOpts) { boolean microOpts,
boolean fboShare) {
OptimizeToggles.entityLod = entityLod; OptimizeToggles.entityLod = entityLod;
OptimizeToggles.entityLodDistance = entityLodDistance; OptimizeToggles.entityLodDistance = entityLodDistance;
OptimizeToggles.particleLod = particleLod; OptimizeToggles.particleLod = particleLod;
@ -67,10 +72,11 @@ public enum OptimizeToggles {
OptimizeToggles.blockEntityCache = blockEntityCache; OptimizeToggles.blockEntityCache = blockEntityCache;
OptimizeToggles.preparedTextCache = preparedTextCache; OptimizeToggles.preparedTextCache = preparedTextCache;
OptimizeToggles.microOpts = microOpts; OptimizeToggles.microOpts = microOpts;
OptimizeToggles.fboShare = fboShare;
} }
/** Reset to "nothing on" — used by {@code Optimize.onDisable}. */ /** Reset to "nothing on" — used by {@code Optimize.onDisable}. */
public static void reset() { 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);
} }
} }

View file

@ -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.
*
* <p>Distance gate: farther than {@link #distance()} blocks from the camera
* (squared comparison against the pose translation). Close entities render
* through the vanilla path untouched.
*
* <p>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.
*
* <p>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.
*
* <p>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<VertexConsumer> 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<Capture> 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);
}
}
}

View file

@ -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);
}

View file

@ -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<Supplier<Vector3fc[]>, 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<Vector3fc[]> extents, ItemTransform transform, Supplier<AABB> 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) {
}
}

View file

@ -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.
*
* <p>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<BakedQuad, Flat> 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<BakedQuad> flat(List<BakedQuad> 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<BakedQuad> quads) {
}
private static List<BakedQuad> compute(List<BakedQuad> quads) {
if (quads.size() < 8) {
return null;
}
for (BakedQuad q : quads) {
if (q.direction().getAxis() != Direction.Axis.Z && !thin(q)) {
return null;
}
}
List<BakedQuad> 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;
}
}

View file

@ -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);
}

View file

@ -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}.
*
*
* <p>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);
}
}
}

View file

@ -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}.
*
*
* <p>{@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.
*
* <p>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);
}
}

View file

@ -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);
}
}

View file

@ -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.
*
* <p>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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -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<ModelPart> list = new ArrayList<>();
List<Integer> 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<ModelPart.Cube> 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<String, ModelPart> children(ModelPart part) {
return ((ModelPartAccessor) (Object) part).lovisual$children();
}
private void add(ModelPart part, List<ModelPart> list, List<Integer> 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<Float> corner = new ArrayList<>();
List<Integer> idx = new ArrayList<>();
List<Float> 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);
}
}
}
}

View file

@ -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<ParallelModels.Job> JOBS = new ArrayList<>();
private ParallelModelHook() {
}
/** false: too few models in this group, vanilla builds it. */
public static boolean buildGroup(ModelFeatureRenderer renderer, List<ModelFeatureRenderer.Submit<?>> 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;
}
}

View file

@ -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}.
*
* <p>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<ModelPart, ModelGeometry> GEOMETRY = new WeakHashMap<>();
private static final List<Job> BUILT = new ArrayList<>();
private static final ThreadLocal<PoseStack> STACKS = ThreadLocal.withInitial(PoseStack::new);
private static final ThreadLocal<float[]> 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<Long> PAGES = new ArrayList<>();
private static final List<Integer> 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;
}
}
}

View file

@ -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}.
*
* <p>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.
*
* <p>Counting stays off unless the Optimize module is on <em>and</em> F3 is
* open, so on a normal frame each hook is a single boolean test.
*
*
* <p>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 "<path> <per-frame avg>"}
*/
public record Snapshot(int draws, int pipelines, int binds, int frames, List<String> 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<String, Integer> 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<String, Integer> byPipeline, int topN) {
int perFrame = Math.max(frames, 1);
List<String> 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);
}
}

View file

@ -40,7 +40,7 @@ public enum FrameBudget {
/** Multiplier applied to baseline caps when AGGRESSIVE mode is on. */ /** Multiplier applied to baseline caps when AGGRESSIVE mode is on. */
static final double AGGRESSIVE_SCALE = 0.6; 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_ENTITY_LOD_DISTANCE = 35;
private static final int BASE_PARTICLE_NEAR_BLOCKS = 16; // NEAR_SQ = 16² private static final int BASE_PARTICLE_NEAR_BLOCKS = 16; // NEAR_SQ = 16²
private static final int BASE_PARTICLE_MID_BLOCKS = 32; // MID_SQ = 32² private static final int BASE_PARTICLE_MID_BLOCKS = 32; // MID_SQ = 32²

View file

@ -21,6 +21,7 @@ import java.util.Map;
* <li>{@code -Dlovisual.opt.block_entity_cache=true|false}</li> * <li>{@code -Dlovisual.opt.block_entity_cache=true|false}</li>
* <li>{@code -Dlovisual.opt.prepared_text_cache=true|false}</li> * <li>{@code -Dlovisual.opt.prepared_text_cache=true|false}</li>
* <li>{@code -Dlovisual.opt.micro_opts=true|false}</li> * <li>{@code -Dlovisual.opt.micro_opts=true|false}</li>
* <li>{@code -Dlovisual.opt.fbo_share=true|false}</li>
* <li>{@code -Dlovisual.opt.entity_lod_distance=35} — overrides the distance (4..256).</li> * <li>{@code -Dlovisual.opt.entity_lod_distance=35} — overrides the distance (4..256).</li>
* <li>{@code -Dlovisual.opt.spawner_distance=16} — overrides the distance (4..64).</li> * <li>{@code -Dlovisual.opt.spawner_distance=16} — overrides the distance (4..64).</li>
* <li>{@code -Dlovisual.opt.budget_mode=off|soft|aggressive}</li> * <li>{@code -Dlovisual.opt.budget_mode=off|soft|aggressive}</li>
@ -47,8 +48,7 @@ public enum OptimizePropertyBridge {
/** /**
* Read the current {@code -Dlovisual.opt.*} properties into a fresh snapshot. * Read the current {@code -Dlovisual.opt.*} properties into a fresh snapshot.
* The Optimize module calls this once per frame (mirrors ZakoOpt's * The Optimize module calls this once per frame.
* {@code ZakoOptConfig.refresh()}).
*/ */
public static Snapshot resolve() { public static Snapshot resolve() {
Snapshot s = new Snapshot(); Snapshot s = new Snapshot();
@ -61,6 +61,7 @@ public enum OptimizePropertyBridge {
s.blockEntityCache = boolOverride("block_entity_cache"); s.blockEntityCache = boolOverride("block_entity_cache");
s.preparedTextCache = boolOverride("prepared_text_cache"); s.preparedTextCache = boolOverride("prepared_text_cache");
s.microOpts = boolOverride("micro_opts"); s.microOpts = boolOverride("micro_opts");
s.fboShare = boolOverride("fbo_share");
s.entityLodDistance = intOverride("entity_lod_distance", -1); s.entityLodDistance = intOverride("entity_lod_distance", -1);
s.spawnerDistance = intOverride("spawner_distance", -1); s.spawnerDistance = intOverride("spawner_distance", -1);
@ -93,7 +94,7 @@ public enum OptimizePropertyBridge {
private static final String[] OVERRIDE_KEYS = { private static final String[] OVERRIDE_KEYS = {
"entity_lod", "particle_lod", "spawner_cull", "spawner_tick_cache", "entity_lod", "particle_lod", "spawner_cull", "spawner_tick_cache",
"block_entity_cache", "prepared_text_cache", "micro_opts", "block_entity_cache", "prepared_text_cache", "micro_opts", "fbo_share",
"entity_lod_distance", "spawner_distance", "budget_mode", "target_fps" "entity_lod_distance", "spawner_distance", "budget_mode", "target_fps"
}; };
@ -144,6 +145,7 @@ public enum OptimizePropertyBridge {
public Boolean blockEntityCache; public Boolean blockEntityCache;
public Boolean preparedTextCache; public Boolean preparedTextCache;
public Boolean microOpts; public Boolean microOpts;
public Boolean fboShare;
public int entityLodDistance = -1; public int entityLodDistance = -1;
public int spawnerDistance = -1; public int spawnerDistance = -1;
public FrameBudget.Mode budgetMode; // null = use module value public FrameBudget.Mode budgetMode; // null = use module value
@ -183,6 +185,7 @@ public enum OptimizePropertyBridge {
m.put("block_entity_cache", blockEntityCache); m.put("block_entity_cache", blockEntityCache);
m.put("prepared_text_cache", preparedTextCache); m.put("prepared_text_cache", preparedTextCache);
m.put("micro_opts", microOpts); m.put("micro_opts", microOpts);
m.put("fbo_share", fboShare);
m.put("entity_lod_distance", entityLodDistance); m.put("entity_lod_distance", entityLodDistance);
m.put("spawner_distance", spawnerDistance); m.put("spawner_distance", spawnerDistance);
m.put("budget_mode", budgetMode); m.put("budget_mode", budgetMode);

View file

@ -37,18 +37,31 @@
"optimize.block.BlockEntityMixin", "optimize.block.BlockEntityMixin",
"optimize.block.BlockEntityRenderStateMixin", "optimize.block.BlockEntityRenderStateMixin",
"optimize.block.ChestRendererMixin", "optimize.block.ChestRendererMixin",
"optimize.entity.EntityNameMixin", "optimize.entity.EntityNameMixin",
"optimize.entity.EntityRenderStateEntityMixin", "optimize.entity.EntityRenderStateEntityMixin",
"optimize.entity.LivingEntityLodMixin", "optimize.entity.LivingEntityLodMixin",
"optimize.entity.SodiumEntityRendererMixin", "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.EntityRendererNameMixin",
"optimize.entity.renderer.EntityRendererStateMixin", "optimize.entity.renderer.EntityRendererStateMixin",
"optimize.gl.GlCommandEncoderClearMixin", "optimize.gl.GlBufferCloseMixin",
"optimize.gl.GlCommandEncoderTexBufferMixin", "optimize.gl.GlCommandEncoderClearMixin",
"optimize.gl.RenderTypesMixin", "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.hud.OptimizeDebugHudMixin",
"optimize.particle.BubbleColumnUpParticleMixin", "optimize.particle.BubbleColumnUpParticleMixin",
"optimize.particle.ParticleMixin", "optimize.particle.ParticleMixin",
"optimize.stats.GlCommandEncoderStatsMixin",
"optimize.stats.GlRenderPassStatsMixin",
"optimize.stats.GlStateManagerStatsMixin",
"optimize.world.LevelRendererOutlineMixin", "optimize.world.LevelRendererOutlineMixin",
"accessors.blaze3d.GlTextureInvoker", "accessors.blaze3d.GlTextureInvoker",
"accessors.blaze3d.GpuDeviceAccessor", "accessors.blaze3d.GpuDeviceAccessor",
@ -129,6 +142,11 @@
"hud.GuiGraphicsMixin", "hud.GuiGraphicsMixin",
"hud.GuiMixin", "hud.GuiMixin",
"hud.GuiRendererMixin", "hud.GuiRendererMixin",
"hud.cache.GameRendererHudMixin",
"hud.cache.GuiHudMixin",
"hud.cache.GuiRendererTargetMixin",
"hud.cache.HudLiveMixin",
"hud.cache.invoker.HudInvoker",
"hud.accessibility.NarratorManagerMixin", "hud.accessibility.NarratorManagerMixin",
"hud.accessibility.OptionsNarratorMixin", "hud.accessibility.OptionsNarratorMixin",
"hud.bars.BossHealthOverlayMixin", "hud.bars.BossHealthOverlayMixin",
@ -172,6 +190,8 @@
"render.fog.WaterFogEnvironmentMixin", "render.fog.WaterFogEnvironmentMixin",
"render.fogmod.PowderSnowFogModifierMixin", "render.fogmod.PowderSnowFogModifierMixin",
"render.item.EquipmentLayerRendererMixin", "render.item.EquipmentLayerRendererMixin",
"render.item.bounds.ItemLayerAccessor",
"render.item.bounds.ItemStackRenderStateMixin",
"render.models.AvatarCustomModelMixin", "render.models.AvatarCustomModelMixin",
"render.models.LivingEntityCustomModelMixin", "render.models.LivingEntityCustomModelMixin",
"render.placement.submit.BlockEntityPlaceMixin", "render.placement.submit.BlockEntityPlaceMixin",

View file

@ -52,21 +52,21 @@ public final class SpawnerCacheTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertFalse(SpawnerCache.enabled()); assertFalse(SpawnerCache.enabled());
OptimizeToggles.apply( OptimizeToggles.apply(
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
true, false, false, false); true, false, false, false, false);
assertTrue(SpawnerCache.enabled()); assertTrue(SpawnerCache.enabled());
OptimizeToggles.apply( OptimizeToggles.apply(
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertFalse(SpawnerCache.enabled()); assertFalse(SpawnerCache.enabled());
} }
@ -76,7 +76,7 @@ public final class SpawnerCacheTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
true, false, false, false); true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner(); FakeSpawner spawner = new FakeSpawner();
assertNull(SpawnerCache.get(spawner, 100L)); assertNull(SpawnerCache.get(spawner, 100L));
} }
@ -87,7 +87,7 @@ public final class SpawnerCacheTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
true, false, false, false); true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner(); FakeSpawner spawner = new FakeSpawner();
EntityRenderState state = new EntityRenderState(); EntityRenderState state = new EntityRenderState();
SpawnerCache.put(spawner, 42L, state); SpawnerCache.put(spawner, 42L, state);
@ -100,7 +100,7 @@ public final class SpawnerCacheTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
true, false, false, false); true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner(); FakeSpawner spawner = new FakeSpawner();
EntityRenderState state = new EntityRenderState(); EntityRenderState state = new EntityRenderState();
SpawnerCache.put(spawner, 42L, state); SpawnerCache.put(spawner, 42L, state);
@ -114,7 +114,7 @@ public final class SpawnerCacheTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
true, false, false, false); true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner(); FakeSpawner spawner = new FakeSpawner();
EntityRenderState first = new EntityRenderState(); EntityRenderState first = new EntityRenderState();
EntityRenderState second = new EntityRenderState(); EntityRenderState second = new EntityRenderState();
@ -130,7 +130,7 @@ public final class SpawnerCacheTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
true, false, false, false); true, false, false, false, false);
FakeSpawner spawner = new FakeSpawner(); FakeSpawner spawner = new FakeSpawner();
EntityRenderState state = new EntityRenderState(); EntityRenderState state = new EntityRenderState();
SpawnerCache.put(spawner, 100L, state); SpawnerCache.put(spawner, 100L, state);

View file

@ -21,7 +21,7 @@ public final class EntityLodTest {
false, 35, false, 35,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertFalse(EntityLod.far(10_000_000)); assertFalse(EntityLod.far(10_000_000));
assertFalse(EntityLod.farPlayer(10_000_000)); assertFalse(EntityLod.farPlayer(10_000_000));
} }
@ -32,7 +32,7 @@ public final class EntityLodTest {
true, 35, true, 35,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
// d=35 -> d²=1225: 1225 is NOT greater than 1225, so not far. // d=35 -> d²=1225: 1225 is NOT greater than 1225, so not far.
assertFalse(EntityLod.far(35 * 35)); assertFalse(EntityLod.far(35 * 35));
// d² just above threshold: should be far. // d² just above threshold: should be far.
@ -48,7 +48,7 @@ public final class EntityLodTest {
true, 16, true, 16,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertEquals(16 * 16, EntityLod.farThresholdSq(), 0.0001); assertEquals(16 * 16, EntityLod.farThresholdSq(), 0.0001);
assertFalse(EntityLod.far(16 * 16)); assertFalse(EntityLod.far(16 * 16));
assertTrue(EntityLod.far(16 * 16 + 1)); assertTrue(EntityLod.far(16 * 16 + 1));
@ -60,7 +60,7 @@ public final class EntityLodTest {
true, 35, true, 35,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
// Stage 1 collapses player LOD into entity LOD — same toggle, same threshold. // Stage 1 collapses player LOD into entity LOD — same toggle, same threshold.
for (double dSq : new double[]{0, 100, 1224, 1226, 10_000}) { for (double dSq : new double[]{0, 100, 1224, 1226, 10_000}) {
assertEquals(EntityLod.far(dSq), EntityLod.farPlayer(dSq), assertEquals(EntityLod.far(dSq), EntityLod.farPlayer(dSq),
@ -74,14 +74,14 @@ public final class EntityLodTest {
true, 35, true, 35,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertTrue(EntityLod.far(10_000)); assertTrue(EntityLod.far(10_000));
// Toggle off mid-test — same distance should now be "not far". // Toggle off mid-test — same distance should now be "not far".
OptimizeToggles.apply( OptimizeToggles.apply(
false, 35, false, 35,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertFalse(EntityLod.far(10_000)); assertFalse(EntityLod.far(10_000));
} }
} }

View file

@ -35,7 +35,7 @@ public final class GlRenderBackendOpsTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
assertFalse(ops.lazyClearSupported()); assertFalse(ops.lazyClearSupported());
assertFalse(ops.skipBindFramebufferZero(0)); assertFalse(ops.skipBindFramebufferZero(0));
@ -48,7 +48,7 @@ public final class GlRenderBackendOpsTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
assertTrue(ops.lazyClearSupported()); assertTrue(ops.lazyClearSupported());
assertTrue(ops.skipBindFramebufferZero(0)); assertTrue(ops.skipBindFramebufferZero(0));
@ -62,7 +62,7 @@ public final class GlRenderBackendOpsTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
assertFalse(ops.tboCacheSupported()); assertFalse(ops.tboCacheSupported());
// Even after marking attached, lookup should be a no-op (returns false because not supported). // 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, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42); ops.trackBoundTboTexture(42);
assertFalse(ops.tboAlreadyAttached(0x8229, 100), assertFalse(ops.tboAlreadyAttached(0x8229, 100),
@ -92,7 +92,7 @@ public final class GlRenderBackendOpsTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42); ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100); ops.markTboAttached(0x8229, 100);
@ -108,7 +108,7 @@ public final class GlRenderBackendOpsTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42); ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100); ops.markTboAttached(0x8229, 100);
@ -126,7 +126,7 @@ public final class GlRenderBackendOpsTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42); ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100); ops.markTboAttached(0x8229, 100);
@ -145,7 +145,7 @@ public final class GlRenderBackendOpsTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "textures/entity/skin.png"); RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "textures/entity/skin.png");
assertNull(ops.renderTypeFor(id), "Fresh cache should miss"); assertNull(ops.renderTypeFor(id), "Fresh cache should miss");
@ -161,7 +161,7 @@ public final class GlRenderBackendOpsTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps ops = RenderBackendOps.current();
assertFalse(ops.renderTypesCacheSupported()); assertFalse(ops.renderTypesCacheSupported());
RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "test"); RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "test");
@ -182,4 +182,32 @@ public final class GlRenderBackendOpsTest {
assertFalse(a.equals(d)); assertFalse(a.equals(d));
assertEquals("mc:path", a.toString()); 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));
}
} }

View file

@ -23,7 +23,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Object p = new Object(); Object p = new Object();
assertTrue(ParticleLod.keep(p, 0)); assertTrue(ParticleLod.keep(p, 0));
assertTrue(ParticleLod.keep(p, 100)); assertTrue(ParticleLod.keep(p, 100));
@ -36,7 +36,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
true, false, false, true, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Object p = new Object(); Object p = new Object();
// Strictly under 16 blocks: always keep (NEAR_SQ = 16² = 256). // Strictly under 16 blocks: always keep (NEAR_SQ = 16² = 256).
assertTrue(ParticleLod.keep(p, 0)); assertTrue(ParticleLod.keep(p, 0));
@ -51,7 +51,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
true, false, false, true, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
// At exactly 16² (the boundary), the particle is no longer "near" — its // At exactly 16² (the boundary), the particle is no longer "near" — its
// keep decision is delegated to the mid-range thinning. Over many // keep decision is delegated to the mid-range thinning. Over many
// particles, about half should survive. // particles, about half should survive.
@ -70,7 +70,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
true, false, false, true, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
double midSq = 24 * 24; // Between 16² and 32². double midSq = 24 * 24; // Between 16² and 32².
Set<Boolean> seen = new HashSet<>(); Set<Boolean> seen = new HashSet<>();
for (int i = 0; i < 256; i++) { for (int i = 0; i < 256; i++) {
@ -88,7 +88,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
true, false, false, true, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
double farSq = 48 * 48; // Beyond 32². double farSq = 48 * 48; // Beyond 32².
int kept = 0; int kept = 0;
for (int i = 0; i < 1024; i++) { for (int i = 0; i < 1024; i++) {
@ -106,7 +106,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
true, false, false, true, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Object p = new Object(); Object p = new Object();
double farSq = 48 * 48; double farSq = 48 * 48;
boolean first = ParticleLod.keep(p, farSq); boolean first = ParticleLod.keep(p, farSq);
@ -122,7 +122,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
true, false, false, true, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Object p = new Object(); Object p = new Object();
// particle at (50, 0, 0), camera at (0, 0, 0): distance² = 2500. // particle at (50, 0, 0), camera at (0, 0, 0): distance² = 2500.
boolean objectApi = ParticleLod.keep(p, 50 * 50); boolean objectApi = ParticleLod.keep(p, 50 * 50);
@ -137,7 +137,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
true, false, false, true, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Object p = new Object(); Object p = new Object();
assertTrue(ParticleLod.keep(p, 15 * 15)); assertTrue(ParticleLod.keep(p, 15 * 15));
assertTrue(ParticleLod.keep(p, 0)); assertTrue(ParticleLod.keep(p, 0));
@ -149,7 +149,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
true, false, false, true, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Object p = new Object(); Object p = new Object();
boolean first = ParticleLod.keep(p, 48 * 48); boolean first = ParticleLod.keep(p, 48 * 48);
// Toggle off — every distance should now keep. // Toggle off — every distance should now keep.
@ -157,7 +157,7 @@ public final class ParticleLodTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertTrue(ParticleLod.keep(p, 48 * 48)); assertTrue(ParticleLod.keep(p, 48 * 48));
assertTrue(ParticleLod.keep(p, 0)); assertTrue(ParticleLod.keep(p, 0));
// first is just whatever it was — we only care that the new state is "keep everything". // first is just whatever it was — we only care that the new state is "keep everything".

View file

@ -82,4 +82,37 @@ public final class WorkersTest {
assertNull(err); assertNull(err);
assertEquals(blocks, counter.get()); 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);
}
} }

View file

@ -35,7 +35,7 @@ public final class StableTextTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Recorder inner = new Recorder(); Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner); FormattedCharSequence marked = StableText.mark(inner);
assertTrue(marked instanceof StableText, assertTrue(marked instanceof StableText,
@ -49,7 +49,7 @@ public final class StableTextTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Recorder inner = new Recorder(); Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner); FormattedCharSequence marked = StableText.mark(inner);
assertTrue(StableText.isStable(marked)); assertTrue(StableText.isStable(marked));
@ -61,7 +61,7 @@ public final class StableTextTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Recorder plain = new Recorder(); Recorder plain = new Recorder();
assertFalse(StableText.isStable(plain)); assertFalse(StableText.isStable(plain));
} }
@ -74,7 +74,7 @@ public final class StableTextTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
Recorder inner = new Recorder(); Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner); FormattedCharSequence marked = StableText.mark(inner);
StableText.clearTracked(); StableText.clearTracked();
@ -88,7 +88,7 @@ public final class StableTextTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Recorder inner = new Recorder(); Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner); FormattedCharSequence marked = StableText.mark(inner);
assertTrue(StableText.isStable(marked)); assertTrue(StableText.isStable(marked));
@ -103,7 +103,7 @@ public final class StableTextTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
Recorder inner = new Recorder(); Recorder inner = new Recorder();
FormattedCharSequence marked = StableText.mark(inner); FormattedCharSequence marked = StableText.mark(inner);
// Pass a sink that just returns true; the marked wrapper should delegate to 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, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertEquals(0, StableText.trackedCount()); assertEquals(0, StableText.trackedCount());
StableText.mark(new Recorder()); StableText.mark(new Recorder());
StableText.mark(new Recorder()); StableText.mark(new Recorder());

View file

@ -25,7 +25,7 @@ public final class OutlineTrackerTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, false); false, false, false, false, false);
assertFalse(OutlineTracker.enabled()); assertFalse(OutlineTracker.enabled());
// Even when glowing was set this frame, shouldSkipOutlineRender returns false // Even when glowing was set this frame, shouldSkipOutlineRender returns false
// when the toggle is off — the vanilla path runs unchanged. // when the toggle is off — the vanilla path runs unchanged.
@ -39,7 +39,7 @@ public final class OutlineTrackerTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
OutlineTracker.beginFrame(); OutlineTracker.beginFrame();
assertTrue(OutlineTracker.shouldSkipOutlineRender(), assertTrue(OutlineTracker.shouldSkipOutlineRender(),
"Without glowing entities, doEntityOutline should be skipped"); "Without glowing entities, doEntityOutline should be skipped");
@ -51,7 +51,7 @@ public final class OutlineTrackerTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
OutlineTracker.beginFrame(); OutlineTracker.beginFrame();
OutlineTracker.markGlowing(); OutlineTracker.markGlowing();
assertFalse(OutlineTracker.shouldSkipOutlineRender(), assertFalse(OutlineTracker.shouldSkipOutlineRender(),
@ -65,7 +65,7 @@ public final class OutlineTrackerTest {
false, 0, false, 0,
false, false, false, false, false, false,
false, 0, false, 0,
false, false, false, true); false, false, false, true, false);
OutlineTracker.markGlowing(); OutlineTracker.markGlowing();
assertTrue(OutlineTracker.glowingThisFrame()); assertTrue(OutlineTracker.glowingThisFrame());
OutlineTracker.beginFrame(); OutlineTracker.beginFrame();

View file

@ -23,6 +23,7 @@ public final class OptimizeTogglesTest {
assertFalse(OptimizeToggles.blockEntityCache()); assertFalse(OptimizeToggles.blockEntityCache());
assertFalse(OptimizeToggles.preparedTextCache()); assertFalse(OptimizeToggles.preparedTextCache());
assertFalse(OptimizeToggles.microOpts()); assertFalse(OptimizeToggles.microOpts());
assertFalse(OptimizeToggles.fboShare());
assertEquals(0, OptimizeToggles.entityLodDistance()); assertEquals(0, OptimizeToggles.entityLodDistance());
assertEquals(0, OptimizeToggles.spawnerDistance()); assertEquals(0, OptimizeToggles.spawnerDistance());
} }
@ -36,7 +37,7 @@ public final class OptimizeTogglesTest {
true, true,
false, false,
true, true,
true); true, true);
assertTrue(OptimizeToggles.entityLod()); assertTrue(OptimizeToggles.entityLod());
assertEquals(35, OptimizeToggles.entityLodDistance()); assertEquals(35, OptimizeToggles.entityLodDistance());
assertFalse(OptimizeToggles.particleLod()); assertFalse(OptimizeToggles.particleLod());
@ -48,12 +49,14 @@ public final class OptimizeTogglesTest {
assertFalse(OptimizeToggles.blockEntityCache()); assertFalse(OptimizeToggles.blockEntityCache());
assertTrue(OptimizeToggles.preparedTextCache()); assertTrue(OptimizeToggles.preparedTextCache());
assertTrue(OptimizeToggles.microOpts()); assertTrue(OptimizeToggles.microOpts());
assertTrue(OptimizeToggles.fboShare());
OptimizeToggles.reset(); OptimizeToggles.reset();
assertFalse(OptimizeToggles.entityLod()); assertFalse(OptimizeToggles.entityLod());
assertEquals(0, OptimizeToggles.entityLodDistance()); assertEquals(0, OptimizeToggles.entityLodDistance());
assertFalse(OptimizeToggles.spawnerCull()); assertFalse(OptimizeToggles.spawnerCull());
assertFalse(OptimizeToggles.microOpts()); assertFalse(OptimizeToggles.microOpts());
assertFalse(OptimizeToggles.fboShare());
} }
@Test @Test
@ -65,7 +68,7 @@ public final class OptimizeTogglesTest {
false, false,
false, false,
false, false,
false); false, false);
assertFalse(OptimizeToggles.entityLod()); assertFalse(OptimizeToggles.entityLod());
assertFalse(OptimizeToggles.particleLod()); assertFalse(OptimizeToggles.particleLod());
assertFalse(OptimizeToggles.spawnerCull()); assertFalse(OptimizeToggles.spawnerCull());
@ -73,6 +76,7 @@ public final class OptimizeTogglesTest {
assertFalse(OptimizeToggles.blockEntityCache()); assertFalse(OptimizeToggles.blockEntityCache());
assertFalse(OptimizeToggles.preparedTextCache()); assertFalse(OptimizeToggles.preparedTextCache());
assertFalse(OptimizeToggles.microOpts()); assertFalse(OptimizeToggles.microOpts());
assertFalse(OptimizeToggles.fboShare());
assertEquals(0, OptimizeToggles.entityLodDistance()); assertEquals(0, OptimizeToggles.entityLodDistance());
assertEquals(0, OptimizeToggles.spawnerDistance()); assertEquals(0, OptimizeToggles.spawnerDistance());
} }

View file

@ -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;
}
}

View file

@ -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<Vector3fc[]> extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)};
Supplier<AABB> 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<Vector3fc[]> extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)};
Supplier<AABB> 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<Vector3fc[]> extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)};
Supplier<AABB> 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");
}
}

View file

@ -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<BakedQuad> 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<BakedQuad> 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<BakedQuad> 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<BakedQuad> 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<BakedQuad> 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<BakedQuad> first = ItemLod.flat(model);
List<BakedQuad> 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);
}
}

View file

@ -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));
}
}

View file

@ -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());
}
}

View file

@ -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));
}
}

View file

@ -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());
}
}

View file

@ -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.
*
* <p>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<String, Integer> 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<String, Integer> 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));
}
}

View file

@ -23,7 +23,7 @@ public final class OptimizePropertyBridgeTest {
"lovisual.opt.entity_lod", "lovisual.opt.particle_lod", "lovisual.opt.entity_lod", "lovisual.opt.particle_lod",
"lovisual.opt.spawner_cull", "lovisual.opt.spawner_tick_cache", "lovisual.opt.spawner_cull", "lovisual.opt.spawner_tick_cache",
"lovisual.opt.block_entity_cache", "lovisual.opt.prepared_text_cache", "lovisual.opt.block_entity_cache", "lovisual.opt.prepared_text_cache",
"lovisual.opt.micro_opts", "lovisual.opt.micro_opts", "lovisual.opt.fbo_share",
"lovisual.opt.entity_lod_distance", "lovisual.opt.spawner_distance", "lovisual.opt.entity_lod_distance", "lovisual.opt.spawner_distance",
"lovisual.opt.budget_mode", "lovisual.opt.target_fps" "lovisual.opt.budget_mode", "lovisual.opt.target_fps"
}) { }) {
@ -68,6 +68,19 @@ public final class OptimizePropertyBridgeTest {
assertFalse(s.overrides(s.entityLod, true)); 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 @Test
void distanceOverride() { void distanceOverride() {
System.setProperty("lovisual.opt.entity_lod_distance", "100"); System.setProperty("lovisual.opt.entity_lod_distance", "100");