diff --git a/mod/docs/optimize-layer-design.md b/mod/docs/optimize-layer-design.md index 735715aa..fcd7d123 100644 --- a/mod/docs/optimize-layer-design.md +++ b/mod/docs/optimize-layer-design.md @@ -3,6 +3,8 @@ Статус: Этап 1 готов (2026-10-10), Этап 2 + начало Этапа 3 готовы (2026-10-10). Цель MC: 26.2. ## История изменений +- **2026-10-12**: референсная реализация обновлена с 1.0.1 до 1.0.3 (сверка исходников). Корректировки плана ниже, в разделе + «Что нового в референсе 1.0.2-1.0.3». - **2026-10-10**: Этап 1 завершён — 11 портированных файлов (`core/LruMap`, `entity/{EntityLod,CachedName,StateEntity,CachedStack}`, `particle/{ParticleLod,Workers}`, `hud/HudWorth`, `block/{TickCached,CachedLight,CachedValidity,SpawnerCache}`, `text/StableText`) + `OptimizeToggles` + `mod/NOTICE`. 53 теста. Патчи: `0001-feat-mod-...patch`, `0002-test-mod-...patch`. - **2026-10-10**: Этап 2 завершён — `FrameBudget` (mode + target FPS + per-subsystem caps), `FrameProfiler` (5 секций EMA-таймингов), `OptimizePropertyBridge` (`-Dlovisual.opt.*` overrides), `OptimizeDebugHudMixin` (F3 overlay). 22 новых теста. `Optimize.java` расширен тумблерами `budget_mode` / `target_fps` / `show_profiler`. - **2026-10-10**: Этап 3 (частично) — `RenderBackendOps` интерфейс + `GlRenderBackendOps` реализация (lazy clear, TBO cache, RenderTypes cache). Три mixin'а зарегистрированы в `lovisual.mixins.json`: `RenderTypesMixin`, `GlCommandEncoderClearMixin`, `GlCommandEncoderTexBufferMixin`. SkinAtlas/AnimFreeze/HudCache/FrameFence оставлены на поздний Этап 3. @@ -110,15 +112,68 @@ optimize/ Существующие Vulkan-миксины LoVisual (`VulkanDeviceMixin` и др.) не переписываем, новый код подключается рядом. +## Что нового в референсе 1.0.2-1.0.3 (сверено 2026-10-12) +Референс обновился с 1.0.1 до 1.0.3 (+166 файлов). Что важно для нашего плана: +- **FrameFence/RealFence для 26.2 не нужен.** Комментарий в `26.1.2/gl/GlCommandEncoderMixin`: «26.1 ещё делает GL-sync + на каждый fence; с 26.2 ваниль делает один fence на submit и ждёт его по индексу submit'а». Хук `createFence` переехал из + `26.x` в модуль `26.1.2`, в 26.2 его нет; в changelog прямо сказано, что опция «Один fence на кадр» оставлена только для + 26.1.2, с 26.2 это делает игра. Плюс история краша Xaero's World Map («Cannot wait on a fence for the current submit») + при общем fence — риск, который ваниль уже закрыла. => Пункт «FrameFence/RealFence» из Этапа 3 вычеркиваем; остаётся + только хук конца кадра (`MinecraftFrameMixin`: `SkinAtlas.endFrame`, `AnimFreeze.frame++`, `refresh()` конфига). +- **Счётчики draw-вызовов (`DrawStats`, `common/gl/DrawStats.java`, 75 строк)** — ровно наше заявленное предусловие + «перед Этапом 3 нужен профиль HUD: число draw-вызовов и смен состояния на кадр». Считает draws/flush'и слоёв/FBO-bind'ы, + средние за секунду, топ-5 слоёв, рисует только пока открыт F3. Дёшево, даёт данные для раздела «Дополнительные + GL-оптимизации». => Включить в Этап 3 первым пунктом. +- **Parallel Models (крупное новое, Impact HIGH).** `26.2/ModelFeatureRendererMixin` перехватывает `buildGroup` и собирает + группу submit'ов на нескольких ядрах (`ParallelModelHook` + `ParallelModels` + два invoker'а). Заявлено +20-50% FPS в + толпе бронированных мобов, отдельная опция. Отключается при <4 ядрах и при активном шейдерпаке Iris + (`!(IRIS && IrisApi…isShaderPackInUse())` — Iris собирает расширенные атрибуты из вершин, отданных по одной). + Зависит от `AnimFreeze.freezes()` (часть, которую нельзя реплеить) — ставить после AnimFreeze. +- **ItemLod получил точный таргет в 26.2**: `entity/ItemFeatureRendererMixin` — `@WrapOperation` на `Submit.quads()` в + `prepareMainSubmit`/`prepareOutlineSubmit`/`prepareFoilSubmit`, плоские квады дальше `ItemLod.distanceSq()`. +- **Выброшенные предметы (новое):** `no_item_sort` (пропуск сортировки `item_entity_translucent_cull`, по умолчанию вкл, + до +40% FPS на кучах), `item_single_copy` (стак одним предметом), `ItemBounds` (кэш AABB модели предмета, IdentityHashMap + с полной очисткой >4096 — render state пересоздаётся каждый кадр, его собственный кэш умирает вместе с ним). + `BufferSourceMixin`+`RenderTypeAccessor` живут в модуле `26.1.2`, в `26.x` их нет — перед переносом проверить, есть ли + в 26.2 `MultiBufferSource.BufferSource.endBatch(RenderType, BufferBuilder)` и `RenderType.sortOnUpload()`. +- **Фиксы HudCache, которые надо взять сразу** (иначе воспроизведём баги): главный target снимается в `beginFrame` + (`main = gameRenderer.mainRenderTarget()`) и сравнение идёт с ним, а не с «текущим» — иначе редиректим чужой target + (Xaero подменяет свой, пока рисует); двойной троттлинг `GuiItemRefresh`+`HudCache` приводил к пропавшим зачарованным + предметам хотбара (фикс: пропускать свой троттлинг, когда `HudCache.active()`); composite — premultiplied + write mask + 7 (RGB), depth clear 0 (reversed depth в 26.x); кэш выключается при чужих HUD-кэшах (`axolotlclient`, `exordium`, + `gnetum`) и под VulkanMod; интервал = рефреш-рейт монитора (`MonitorRate`, GLFW-опрос 1 раз в секунду). +- **Правило утечек:** кэши с нативной памятью/GL-объектами чистить и на перезагрузку ресурсов (F3+T), и на смену мира. + Фикс 1.0.3: `BubbleColumnUpParticleMixin` держал `ClientLevel` в статике через сильную ссылку — теперь `WeakReference` + + очистка карты при смене уровня; `MovingBlockCache`/`ParallelModels` чинили утечки native/model. +- **Порог параллельных частиц:** план п.4 («авто-порог по замеру») упрощаем до того, что сделано в референсе — + `CORES >= 4` + `-D…=auto|true|false`; 6+ ядер оказалось догадкой (замер на 4 ядрах дал +12%), вернули к 4. + `Workers` получил `stream()` — публикация задач по мере готовности, рабочие берут их сразу, пока готовится остальное. +- **MixinPlugin** — условная регистрация миксинов (Sodium/Vulkan/Lunar, `shouldApplyMixin` + `getMixins`), а тумблеры в UI + прячутся, если миксин не построен/не применяется (`available()` → `MixinPlugin::applies`). У нас уже есть + `mixins/LoVisualMixinPlugin` — расширить для Vulkan (Этап 4) и для «миксин есть не всегда». +- **Не берём:** Lunar multi-draw (лаунчер Lunar, не наш случай); `blockEntityEarlyCull` перенесён только в `1.21.9+`, + в `26.x` его нет — при случае проверить, применим ли к 26.2; `26.3` поддержка и `26.3/renames.txt` пригодятся при + апгрейде версии (пакет `com.mojang.blaze3d` → `com.mojang.renderpearl`). + ## Этапы 1. ✅ `core/` + helpers + adapter interfaces + `OptimizeToggles` (бэкенд-независимые части). Тесты, сборка. **Завершён 2026-10-10** — 11 портированных .java, 53 теста, 0 падений, `checkFolderLimit` OK. 2. ✅ Бюджет кадра, профилировщик, `-Dlovisual.opt.*`. **Завершён 2026-10-10** — `FrameBudget` (3 режима: OFF/SOFT/AGGRESSIVE, target FPS, per-subsystem caps для entities/particles/HUD/effects/text/trails), `FrameProfiler` (5 секций, EMA, decay через 2 с idle), `OptimizePropertyBridge` (10 системных свойств + `all=false` отключает всё), `OptimizeDebugHudMixin` (F3 overlay показывает тумблеры + бюджет + тайминги секций + override'ы). 22 новых теста. -3. 🔄 `gl/`: shared FBO, FrameFence, lazy clear, TBO cache, SkinAtlas через GL; затем новые GL-идеи из раздела ниже. **Частично готово 2026-10-10**: `RenderBackendOps` интерфейс (GL/Vulkan стратегия), `GlRenderBackendOps` реализация, 3 mixin'а (`RenderTypesMixin`, `GlCommandEncoderClearMixin`, `GlCommandEncoderTexBufferMixin`), зарегистрированы в `lovisual.mixins.json`. **Осталось**: FrameFence/RealFence (shared GPU fence на кадр), SharedDepthFbos (DSA depth clear через cached FBO), SkinAtlas (glCopyImageSubData), AnimFreeze, ItemLod, LivingEntityLodMixin, ParticleMixin, SpawnerRendererMixin, BubbleColumnUpParticleMixin, VertexConsumerUtilsMixin, SodiumEntityRendererMixin, BlockEntity* mixins, HUD cache mixins, LevelRendererOutlineMixin, MinecraftFrameMixin. +3. 🔄 `gl/` + остаток рендера. **Готово 2026-10-10**: `RenderBackendOps`, `GlRenderBackendOps`, `RenderTypesMixin`, `GlCommandEncoderClearMixin`, `GlCommandEncoderTexBufferMixin`. **Готово 2026-10-10…10-11**: миксины `LivingEntityLodMixin`, `ParticleMixin`, `BubbleColumnUpParticleMixin`, `SodiumEntityRendererMixin`, `BlockEntity*`, `ChestRendererMixin`, `LevelRendererOutlineMixin`, `BaseSpawnerMixin`, `EntityName/State/Renderer*`. **Осталось, в порядке приоритета** (порядок пересмотрен 2026-10-12 по референсу 1.0.3): + 1. ✅ `DrawStats` + строки в F3 — счётчики draw/flush/bind за кадр (предусловие для решений о GL-оптимизациях; дёшево). **Готово 2026-10-12** — `DrawStats`, миксины `mixins/optimize/stats/*` (executeDraw/Draws/Indirect/Multiple, setPipeline, glBindFramebuffer), строка F3 `draws/pipelines/fbo binds`, подключены `FrameProfiler.beginFrame/endFrame` (баг Стадии 2: не вызывались в проде), 7 тестов. + 2. ✅ `SharedDepthFbos` кластер — `SharedDepthFbos` (DSA `glClearNamedFramebufferfv`, fallback на ваниль без GL4.5/DSA), `FrameBufferCacheMixin` (remember/forget по `getFbo`/`destroyFbo`), `GlCommandEncoderDepthClearMixin` (`clearDepthTexture` HEAD, cancellable), `GlBufferCloseMixin` (`forgetTboCache()` при `close` GlBuffer.Direct — чинит протухание TBO-кэша, id буфера переиспользуется). **Готово 2026-10-12** — тумблер `fbo_share` (модуль + `-Dlovisual.opt.fbo_share`), методы `RenderBackendOps.fboShareSupported/clearDepthInPlace`, миксины в `mixins/optimize/gl/` и `mixins/optimize/gl/fbo/`, 8+3 тестов. + 3. ~~`SkinAtlas` кластер~~ **Выкинут 2026-10-12** — в референсе 1.0.3 фичи нет на 26.2 (в `26.2/` нет `SkinAtlas.java`/`LivingEntityRendererSkinMixin`, флаг `skinAtlas()` никем не вызывается; атлас скинов был только для 1.21.4, см. историю версий референса, раздел «1.21.4»). Как и FrameFence, не переносим. + 4. ✅ `AnimFreeze` кластер — `AnimFreeze` (capture раз в тик, реплей в вершины), `EntityAnimHolderMixin`, `AnimHolder`, метод `freezes(state, pose)` (часть, построенная каждый кадр в другом месте, не реплеим — это и чинит рябь брони), `RawVertices`/`SodiumVertices`. **Готово 2026-10-12** — `AnimFreeze.configure(enabled, distance)` (свой холдер, не OptimizeToggles), тумблеры `entity_anim_freeze` (по умолчанию выкл) + `entity_anim_distance` (16), `frame++` в `MinecraftMixin.renderFrame` HEAD, вызов из `ModelFeatureRendererMixin.prepareModel` (`@WrapOperation` на `Model.renderToBuffer`), инфраструктура сырых вершин из п.7 (`RawBuffer`/`BufferBuilderRawMixin`/`RawVertices`/`SodiumVertices`/`VertexConsumerUtilsMixin`), 3 теста. TypeBuilders не выделялся — свёрнут в приватные хелперы `AnimFreeze`. + 5. ✅ `ItemLod` — `ItemLod` + `ItemFeatureRendererMixin` (`@WrapOperation` на `Submit.quads()` в `prepareMain/Outline/FoilSubmit`) + `ItemBounds`. **Готово 2026-10-12** — `ItemLod`/`ItemBounds` в `render/engine/optimize/entity/` (в `entity/` теперь ровно 4 файла — лимит исчерпан), LOD-хук добавлен в существующий `ItemFeatureRendererMixin`, `ItemStackRenderStateMixin`+`ItemLayerAccessor` в `mixins/render/item/bounds/`, тумблеры `item_lod`/`item_lod_distance` (8)/`item_bounds`, 9 тестов. + 6. ✅ `HudCache` кластер — `HudCache`, `MonitorRate`, `HudWorth` (есть), миксины `GameRendererHudMixin`, `GuiHudMixin`, `GuiRendererTargetMixin`, `HudInvoker`, `HudLiveMixin`; все фиксы совместимости из раздела выше — сразу. **Готово 2026-10-12** — тумблер `hud_cache` **по умолчанию выкл** (взаимодействует с нашими deferred-HUD-маркерами в `GuiRendererMixin`); гейт на `axolotlclient`/`exordium`/`gnetum`/`vulkanmod` через FabricLoader; main target снимается в `beginFrame`; composite premultiplied + write mask 7, depth clear 0; `HudInvoker` в подпапке `hud/cache/invoker/` (лимит 4 файлов), 9 тестов (MonitorRate clamp+троттлинг, HudWorth-гистерезис). + 7. ✅ `VertexConsumerUtilsMixin` (кэш `VertexBufferWriter`, только при Sodium — в `OPTIONAL_SODIUM_MIXINS`) + `BufferBuilderRawMixin`/`RawBuffer` (запись без Sodium) + `RawVertices`/`SodiumVertices`. **Готово 2026-10-12** (упреждённо, как предусловие п.4) — гейт на `micro_opts`, 2 миксина в `mixins/optimize/gl/raw/`. + 8. ✅ `ParallelModels` — `ParallelModelHook`, `ModelFeatureRendererMixin` (`buildGroup` cancellable), invoker'ы; после `AnimFreeze` (зависит от `freezes()`). **Готово 2026-10-12** — `ParallelModels`/`ParallelModelHook`/`ModelGeometry`/`ModelBuilder` в `render/engine/optimize/parallel/` (оригинальный 348-строчный класс разбит на 4 файла, лимит 250), `Workers` получил `stream()` (публикация по мере готовности), 3 интефейса в `mixins/optimize/entity/parallel/`, `buildGroup`-inject добавлен в существующий `ModelFeatureRendererMixin`, тумблер `parallel_models` (on) с внутренними гейтами: ≥4 ядер, нет активного Iris-шейдерпака, взаимоисключение с `AnimFreeze`, формат `DefaultVertexFormat.ENTITY` (36 байт, оффсеты сверены javap), `parts[0]=null` против утечки weak-map, native-страницы освобождаются в `reset()`, 7 тестов (3 stream + 4 гейтинг). + 9. По желанию/по замерам: `no_item_sort` + `item_single_copy` (`BufferSourceMixin`, `ItemClusterRenderStateMixin`) — сначала проверить наличие `endBatch(RenderType, BufferBuilder)` в 26.2. **Проверено 2026-10-12**: двухаргументного `endBatch(RenderType, BufferBuilder)` в 26.2 `BufferSource` **нет**, `MeshData.DrawState` без sort-методов; пункт остаётся отложенным до замеров и дизайна под 26.2-API. + Хук конца кадра не нужен отдельным `MinecraftFrameMixin`: `renderFrame` у нас уже перехвачен (`mixins/render/main/MinecraftMixin`), туда же вешаем `SkinAtlas.endFrame`/`AnimFreeze.frame++`/конфиг-`refresh()`. **FrameFence/RealFence вычеркнут** — см. «Что нового», ваниль 26.2 делает это сама. 4. ❌ `vulkan/`: `vkCmdCopyImage`, затем `VkPipelineCache`, затем остальное по замерам. **Не начат** — дизайн готов, ждёт профилирования GL-части чтобы понять, какие из Vulkan-оптимизаций реально нужны. 5. ❌ Бенчмарки до и после, отчёт цифр в этот документ. **Не начат** — требует запуска Minecraft на Windows, задача владельца. ## Оценки для Этапов 3-4 (после Stage 1-2) -- **Этап 3 (остальное)**: ~25 mixin'ов + ~8 helper-классов, ~80-100 новых .java-файлов. Сложные: `AnimFreeze` (vertex format + MemoryUtil), `SkinAtlas` (glCopyImageSubData + LRU), `HudCache` (RenderPipeline + RenderPass + TextureTarget). Безопасные простые: `LevelRendererOutlineMixin`, `BubbleColumnUpParticleMixin`, `SodiumEntityRendererMixin`. ~3-4 рабочих сессии. +- **Этап 3 (остальное)**: порядка 20 mixin'ов + ~10 helper-классов, ~70-90 новых .java-файлов. Сложные: `AnimFreeze` (vertex format + MemoryUtil), `SkinAtlas` (GPU-to-GPU копия + LRU), `HudCache` (RenderPipeline + RenderPass + TextureTarget), `ParallelModels`. Простые: `DrawStats`, `SharedDepthFbos`, `ItemLod`. ~3 рабочие сессии (FrameFence вычеркнут). - **Этап 4**: 6 пунктов, каждый требует профилирования. Минимум 1 сессия на `vkCmdCopyImage` (чтобы SkinAtlas заработал на Vulkan), остальное по мере замеров. ## Риски diff --git a/mod/gradle.properties b/mod/gradle.properties index a8e59e87..be96f22b 100644 --- a/mod/gradle.properties +++ b/mod/gradle.properties @@ -20,3 +20,4 @@ viafabricplus_version=4.6.0 entityculling_version=1.10.5 voicechat_api_version=2.6.24 mixinextras_version=0.5.5 +lovisualDevEnabled=false diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java index 1892d673..64e2d791 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java @@ -7,10 +7,18 @@ import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.phase.Notifier; +import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze; +import dev.loki.lovisual.render.engine.optimize.entity.ItemBounds; +import dev.loki.lovisual.render.engine.optimize.entity.ItemLod; +import dev.loki.lovisual.render.engine.optimize.hud.HudCache; +import dev.loki.lovisual.render.engine.optimize.parallel.ParallelModels; +import dev.loki.lovisual.render.engine.optimize.profile.DrawStats; import dev.loki.lovisual.render.engine.optimize.profile.FrameBudget; +import dev.loki.lovisual.render.engine.optimize.profile.FrameProfiler; import dev.loki.lovisual.render.engine.optimize.profile.OptimizePropertyBridge; import dev.loki.lovisual.render.engine.optimize.OptimizeState; import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; +import net.minecraft.client.Minecraft; /** * Manual FPS rubilnik: one switch plus a set of knobs, each cutting a named cost @@ -24,13 +32,13 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; * — never this class; this module only pushes state into them on * enable/disable and once per frame. * - *

Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module now also + *

Stage 2 of {@code mod/docs/optimize-layer-design.md}: the module also * pushes a {@link FrameBudget} snapshot (mode + target FPS) and resolves * {@code -Dlovisual.opt.*} system-property overrides via - * {@link OptimizePropertyBridge}. The frame profiler ({@code FrameProfiler}) - * is fed by the {@code MinecraftFrameMixin} (Stage 3 — not yet present); the - * F3 overlay ({@code OptimizeDebugHudMixin}) reads the profiler's snapshot - * whenever the user opens F3. + * {@link OptimizePropertyBridge}. Stage 3 step 1 adds {@link DrawStats} — + * draw/pipeline/framebuffer counters fed by the {@code mixins/optimize/stats} + * hooks, opened and closed here so they only run while F3 is up. The F3 + * overlay ({@code OptimizeDebugHudMixin}) reads both snapshots. */ @ModuleInfo( id = "optimize", @@ -58,6 +66,23 @@ public final class Optimize extends Module { private final BooleanValue preparedTextCache = bool("prepared_text_cache", true); private final BooleanValue microOpts = bool("micro_opts", true); + // --- Stage 3: GL-level helpers --- + private final BooleanValue fboShare = bool("fbo_share", true); + private final BooleanValue entityAnimFreeze = bool("entity_anim_freeze", false); + private final NumberValue entityAnimDistance = + num("entity_anim_distance", 16, 4, 128); + + // --- Stage 3: items --- + private final BooleanValue itemLod = bool("item_lod", true); + private final NumberValue itemLodDistance = num("item_lod_distance", 8, 2, 64); + private final BooleanValue itemBounds = bool("item_bounds", true); + + // --- Stage 3: HUD cache (off by default — interacts with the deferred-HUD markers) --- + private final BooleanValue hudCacheToggle = bool("hud_cache", false); + + // --- Stage 3: parallel model build (own holder: cores/Iris gating inside) --- + private final BooleanValue parallelModels = bool("parallel_models", true); + // --- Stage 2: frame budget + profiling --- private final EnumValue budgetMode = enumMode("budget_mode", FrameBudget.Mode.OFF, FrameBudget.Mode.values()); @@ -84,12 +109,27 @@ public final class Optimize extends Module { OptimizeState.reset(); OptimizeToggles.reset(); FrameBudget.reset(); + AnimFreeze.reset(); + ItemLod.reset(); + ItemBounds.reset(); + HudCache.reset(); + ParallelModels.reset(); + // stop counting and drop the F3 numbers, onFrame will not run again + DrawStats.endFrame(false); } /** Picks up knob changes made while the module stays on. */ @Override public void onFrame(float d) { pushState(); + // close the window of the frame that just drew; the next publish lands once a second + DrawStats.endFrame(debugOverlayVisible()); + } + + /** F3 counts are only worth collecting while they are on screen. */ + private static boolean debugOverlayVisible() { + Minecraft mc = Minecraft.getInstance(); + return mc != null && mc.getDebugOverlay() != null && mc.getDebugOverlay().showDebugScreen(); } private void pushState() { @@ -114,11 +154,25 @@ public final class Optimize extends Module { resolveBool(props, props.spawnerTickCache, on && spawnerTickCache.get()), resolveBool(props, props.blockEntityCache, on && blockEntityCache.get()), resolveBool(props, props.preparedTextCache, on && preparedTextCache.get()), - resolveBool(props, props.microOpts, on && microOpts.get())); + resolveBool(props, props.microOpts, on && microOpts.get()), + resolveBool(props, props.fboShare, on && fboShare.get())); // Stage 2: frame budget. -Dlovisual.opt.budget_mode / target_fps override the module. FrameBudget.Mode mode = on ? budgetMode.get() : FrameBudget.Mode.OFF; FrameBudget.apply(props.overrides(mode, mode), props.resolveTargetFps(targetFps.get())); + + // Stage 3: far-entity animation freeze — own holder, pushed once per frame. + AnimFreeze.configure(on && entityAnimFreeze.get(), entityAnimDistance.get()); + + // Stage 3: far-item LOD + shared single-layer item bounds. + ItemLod.configure(on && itemLod.get(), itemLodDistance.get()); + ItemBounds.configure(on && itemBounds.get()); + + // Stage 3: HUD-at-refresh-rate cache; own holder (foreign-HUD/Vulkan gating inside). + HudCache.configure(on && hudCacheToggle.get()); + + // Stage 3: parallel model build; mutually exclusive with anim freeze (checked inside). + ParallelModels.configure(on && parallelModels.get()); } private static boolean resolveBool(OptimizePropertyBridge.Snapshot props, diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOps.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOps.java index 63f558ff..1e65f484 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOps.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOps.java @@ -1,6 +1,7 @@ package dev.loki.lovisual.features.module.modules.misc.optimize.gl; import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; +import dev.loki.lovisual.render.engine.optimize.gl.SharedDepthFbos; import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap; import java.util.Map; @@ -11,31 +12,22 @@ import java.util.concurrent.ConcurrentHashMap; * *

Resolves the current GL device via {@code GlBackendAccess.current()} — * returns {@code null} on Vulkan so all mixins short-circuit to the vanilla - * call. The cache state is static and render-thread-only (ZakoOpt does the - * same — the GL command encoder runs single-threaded). - * - *

Ported from ZakoOpt's three GL caches: - *

+ * call. The cache state is static and render-thread-only (the GL command + * encoder runs single-threaded). * *

Each toggle is gated by {@link OptimizeToggles#microOpts()} (the Stage 1 - * "micro_opts" toggle wired into {@code Optimize.java}); ZakoOpt splits this - * into {@code lookupCaches}, {@code microOpts}, {@code tboCache} — Stage 1 - * collapses them, Stage 2 may split if profiling warrants. + * "micro_opts" toggle wired into {@code Optimize.java}). */ public final class GlRenderBackendOps implements RenderBackendOps { private static final GlRenderBackendOps INSTANCE = new GlRenderBackendOps(); - // --- TBO cache (ported from zako.opt.gl.TexBufferCache) --- + // --- TBO cache --- /** Maps bound-texture-id -> packed (format << 32) | (buffer & 0xFFFFFFFFL). -1 = nothing attached. */ private final Int2LongOpenHashMap tboAttached = new Int2LongOpenHashMap(); private int boundTboTexture; - // --- RenderTypes cache (ported from zako.opt.mixin.gl.RenderTypesMixin) --- + // --- RenderTypes cache --- private final Map translucentCache = new ConcurrentHashMap<>(); private GlRenderBackendOps() { @@ -100,6 +92,18 @@ public final class GlRenderBackendOps implements RenderBackendOps { return ((long) format << 32) | (buffer & 0xFFFFFFFFL); } + // --- FBO share (Stage 3 step 2) --- + + @Override + public boolean fboShareSupported() { + return OptimizeToggles.fboShare(); + } + + @Override + public boolean clearDepthInPlace(int depthId, double value) { + return fboShareSupported() && SharedDepthFbos.clear(depthId, value); + } + // --- RenderTypes cache --- @Override diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/RenderBackendOps.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/RenderBackendOps.java index b0a2c59b..00605c8f 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/RenderBackendOps.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/RenderBackendOps.java @@ -17,6 +17,8 @@ package dev.loki.lovisual.features.module.modules.misc.optimize.gl; *

  • {@link #tboCacheSupported()} / {@link #tboAlreadyAttached(int, int)} / * {@link #markTboAttached(int, int)} / {@link #trackBoundTboTexture(int)} / * {@link #forgetTboCache()} — TBO cache
  • + *
  • {@link #fboShareSupported()} / {@link #clearDepthInPlace(int, double)} — + * shared depth FBOs (Stage 3 step 2)
  • *
  • {@link #renderTypesCacheSupported()} / {@link #renderTypeFor(Identifier)} / * {@link #cacheRenderType(Identifier, Object)} — RenderTypes cache
  • * @@ -64,6 +66,22 @@ public interface RenderBackendOps { /** Forget all TBO attachments — called when a {@code GlBuffer.Direct} is closed. */ void forgetTboCache(); + // --- FBO share (Stage 3 step 2) --- + + /** True when this backend can clear a depth texture in place via a cached FBO. */ + boolean fboShareSupported(); + + /** + * Called from {@code GlCommandEncoder.clearDepthTexture} on the + * {@code GlTexture} depth argument. + * + * @param depthId the GL id of the depth texture + * @param value the clear value (0..1) + * @return {@code true} when the clear already ran in place (mixin cancels + * the vanilla path); {@code false} to fall through to vanilla + */ + boolean clearDepthInPlace(int depthId, double value); + // --- RenderTypes cache --- /** True when this backend can cache {@code RenderTypes.entityTranslucent(id, flag=true)}. */ diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java index 25a33993..3ba1ceca 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/BubbleColumnCache.java @@ -16,8 +16,6 @@ import net.minecraft.world.level.material.FluidState; * and destroyed constantly — per-particle state would be re-initialized too * often to be useful. * - *

    Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * The cache logic is in {@link TickCache} (a generic per-tick keyed cache), * so it's unit-testable without Minecraft types. */ diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java index 3280a355..0cfa58b2 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/TickCache.java @@ -15,8 +15,6 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; * created and destroyed constantly — per-particle state would be re-initialized * too often to be useful. * - *

    Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. */ public final class TickCache { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java index def3df15..5aeb49e9 100755 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java @@ -59,11 +59,78 @@ public final class Workers { return job.error.get(); } + /** + * Open a task stream: helpers start right away and take items as the + * caller publishes them, so work overlaps with preparing the rest. + * Used by the parallel model builder (Stage 3). + */ + public static Stream stream(IntConsumer task) { + Stream stream = new Stream(task); + for (int h = COUNT; h > 0; h--) { + POOL.execute(stream::work); + } + return stream; + } + /** Test-only: shut the pool down so the JVM can exit cleanly. */ public static void shutdown() { POOL.shutdownNow(); } + /** Items {@code 0..count-1} are fully set up and may be taken by helpers. */ + public static final class Stream { + final IntConsumer task; + final AtomicInteger next = new AtomicInteger(); + final AtomicInteger completed = new AtomicInteger(); + final AtomicReference error = new AtomicReference<>(); + volatile int ready; + volatile int total = -1; + + Stream(IntConsumer task) { + this.task = task; + } + + public void publish(int count) { + ready = count; + } + + /** The caller helps with what is left and waits for the rest; first failure or null. */ + public Throwable finish(int count) { + ready = count; + total = count; + work(); + while (completed.get() < count) { + Thread.onSpinWait(); + } + return error.get(); + } + + void work() { + while (true) { + int n = next.get(); + if (n < ready) { + if (next.compareAndSet(n, n + 1)) { + try { + if (error.get() == null) { + task.accept(n); + } + } catch (Throwable t) { + error.compareAndSet(null, t); + } finally { + completed.incrementAndGet(); + } + } + continue; + } + int t = total; + if (t >= 0 && n >= t) { + return; + } + Thread.onSpinWait(); + } + } + } + private static final class Job { final int blocks; final IntConsumer task; diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/camera/CameraPosition.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/camera/CameraPosition.java index f50de799..5d100f4a 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/camera/CameraPosition.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/camera/CameraPosition.java @@ -12,8 +12,6 @@ import net.minecraft.world.phys.Vec3; * {@link ParticleMixin} reads it without re-walking the gameRenderer chain * per particle. * - *

    Ported from {@code zako.opt.particle.CameraPosition} (ZakoOpt, LGPL-3.0). - * Original author: Zako — https://t.me/StarikZako. Render-thread only — * particle ticking runs on the render thread. */ public final class CameraPosition { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java index 22c36a0d..f99fc32b 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTracker.java @@ -15,8 +15,6 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; * outline-target-clear {@code WrapOperation} when the renderer actually * touches the outline target. * - *

    Ported from {@code zako.opt.mixin.entity.LevelRendererOutlineMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * The state is lifted out of the mixin so the mixin stays a thin wrapper. */ public enum OutlineTracker { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java b/mod/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java index 664e5c34..1442aa00 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java @@ -22,7 +22,8 @@ public final class LoVisualMixinPlugin implements IMixinConfigPlugin { "dev.loki.lovisual.mixins.sodium.vertex.SodiumVertexConsumerTrackerMixin", "dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceCullMixin", "dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceHideMixin", - "dev.loki.lovisual.mixins.optimize.entity.SodiumEntityRendererMixin" + "dev.loki.lovisual.mixins.optimize.entity.SodiumEntityRendererMixin", + "dev.loki.lovisual.mixins.optimize.gl.raw.VertexConsumerUtilsMixin" ); private static final Set OPTIONAL_IRIS_MIXINS = Set.of( "dev.loki.lovisual.mixins.iris.render.IrisCommonUniformsMixin", diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BaseSpawnerMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BaseSpawnerMixin.java index 52be9e45..cb75df84 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BaseSpawnerMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BaseSpawnerMixin.java @@ -16,9 +16,6 @@ import org.spongepowered.asm.mixin.Unique; * the display entity only changes on tick boundaries (spin speed, mob * rotation, etc.), so caching by tick removes that rebuild from the hot path. * - *

    Ported from {@code zako.opt.mixin.block.BaseSpawnerMixin} (ZakoOpt, - * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. Field/method - * prefix changed from {@code zakoopt$} to {@code lovisual$}. */ @Mixin(BaseSpawner.class) public abstract class BaseSpawnerMixin implements TickCached { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityMixin.java index 985f8c6b..ff4a96b2 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityMixin.java @@ -21,9 +21,6 @@ import org.spongepowered.asm.mixin.Unique; * distance check that only changes when the entity moves across the cull * boundary. All three are cached via the interface fields implemented here. * - *

    Ported from {@code zako.opt.mixin.block.BlockEntityMixin} (ZakoOpt, - * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. Field/method - * prefix changed from {@code zakoopt$} to {@code lovisual$}. */ @Mixin(BlockEntity.class) public abstract class BlockEntityMixin implements TickCached, CachedLight, CachedValidity { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityRenderStateMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityRenderStateMixin.java index 30f1e14e..98afe97f 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityRenderStateMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/BlockEntityRenderStateMixin.java @@ -25,8 +25,6 @@ import org.spongepowered.asm.mixin.injection.At; * {@link CachedLight} mixin from Stage 3-continued-2) before falling through * to the original. * - *

    Ported from {@code zako.opt.mixin.block.BlockEntityRenderStateMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * Gated on the {@code blockEntityCache} toggle via * {@link OptimizeToggles#blockEntityCache()}. When the toggle is off, the * original runs unchanged. diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/ChestRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/ChestRendererMixin.java index 05bdd51b..b01621ee 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/ChestRendererMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/block/ChestRendererMixin.java @@ -35,12 +35,8 @@ import org.spongepowered.asm.mixin.injection.At; * {@link CachedLight#lovisual$pairLight()} slot. * * - *

    Ported from {@code zako.opt.mixin.block.ChestRendererMixin} (ZakoOpt, - * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * Combine cache gated on {@code blockEntityCache} toggle; pair-light cache - * gated on {@code microOpts} toggle (matches ZakoOpt's microOpts2 split — - * Stage 1 collapsed microOpts2 into microOpts, so both paths read the same - * toggle). + * gated on {@code microOpts} toggle. */ @Mixin(ChestRenderer.class) public abstract class ChestRendererMixin { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityNameMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityNameMixin.java index 08420803..1474b4d2 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityNameMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityNameMixin.java @@ -13,9 +13,6 @@ import org.spongepowered.asm.mixin.Unique; * prefix + name + suffix on every call; renderers call it once per frame per * entity in view, so caching by tick cuts the per-frame cost. * - *

    Ported from {@code zako.opt.mixin.entity.EntityNameMixin} (ZakoOpt, - * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. Field/method - * prefix changed from {@code zakoopt$} to {@code lovisual$}. */ @Mixin(Entity.class) public abstract class EntityNameMixin implements CachedName { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityRenderStateEntityMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityRenderStateEntityMixin.java index e2330d2a..3f907a6c 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityRenderStateEntityMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/EntityRenderStateEntityMixin.java @@ -13,9 +13,6 @@ import org.spongepowered.asm.mixin.Unique; * {@link EntityRendererStateMixin} captures it once per state construction * via {@code createRenderState} and downstream renderers read it back. * - *

    Ported from {@code zako.opt.mixin.entity.EntityRenderStateEntityMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. - * Prefix changed from {@code zakoopt$} to {@code lovisual$}. */ @Mixin(EntityRenderState.class) public abstract class EntityRenderStateEntityMixin implements StateEntity { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/LivingEntityLodMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/LivingEntityLodMixin.java index 9c2de03d..3ee6bc30 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/LivingEntityLodMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/LivingEntityLodMixin.java @@ -18,8 +18,6 @@ import org.spongepowered.asm.mixin.injection.At; * entity; we AND it with "not far" so far entities skip the layers but * close entities render them as usual. * - *

    Ported from {@code zako.opt.mixin.entity.LivingEntityLodMixin} (ZakoOpt, - * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * *

    Gated on the {@code entity_lod} toggle via {@link EntityLod#far(double)}. * When the toggle is off, {@code far} returns {@code false} so the modify diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/SodiumEntityRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/SodiumEntityRendererMixin.java index 69de7148..444fdad6 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/SodiumEntityRendererMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/SodiumEntityRendererMixin.java @@ -21,8 +21,6 @@ import org.spongepowered.asm.mixin.injection.At; * but the convention is kept for safety. The mixin is registered with * {@code remap = false} because the target is a Sodium class, not a Mojang one. * - *

    Ported from {@code zako.opt.mixin.entity.SodiumEntityRendererMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * Gated on the {@code microOpts} toggle via {@link OptimizeToggles#microOpts()}. * When the toggle is off, the original runs unchanged. * diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/EntityAnimHolderMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/EntityAnimHolderMixin.java new file mode 100644 index 00000000..b8935ea0 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/EntityAnimHolderMixin.java @@ -0,0 +1,30 @@ +package dev.loki.lovisual.mixins.optimize.entity.anim; + +import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze; +import dev.loki.lovisual.render.engine.optimize.entity.AnimHolder; +import net.minecraft.world.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +/** + * Stage 3 step 4: implements {@link AnimHolder} on {@code Entity} — adds the + * per-entity bag of frozen animation captures. The field lives as long as + * the entity; captures hold direct buffers, so long-lived entities keep at + * most one capture per (model, renderType, sprite) submission slot. + */ +@Mixin(Entity.class) +public abstract class EntityAnimHolderMixin implements AnimHolder { + + @Unique + private AnimFreeze.Holder lovisual$anim; + + @Override + public AnimFreeze.Holder lovisual$anim() { + return lovisual$anim; + } + + @Override + public void lovisual$anim(AnimFreeze.Holder holder) { + lovisual$anim = holder; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/ModelFeatureRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/ModelFeatureRendererMixin.java new file mode 100644 index 00000000..9a3e6427 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/anim/ModelFeatureRendererMixin.java @@ -0,0 +1,61 @@ +package dev.loki.lovisual.mixins.optimize.entity.anim; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import dev.loki.lovisual.features.module.modules.misc.optimize.entity.StateEntity; +import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze; +import dev.loki.lovisual.render.engine.optimize.parallel.ParallelModelHook; +import java.util.List; +import net.minecraft.client.model.Model; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.feature.FeatureFrameContext; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.world.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Stage 3 step 4: the AnimFreeze call site. When a far entity's model part is + * about to render, hand the submission to {@link AnimFreeze#render} — it + * replays the once-per-tick raw vertex capture shifted to the current + * position; on success the vanilla {@code renderToBuffer} is skipped. + * + *

    The buffer may already be sprite-wrapped; the replayed vertices go + * through it, so Sodium's push remaps their UVs. Sheeted decals (block + * cracks) never freeze — their pose is built elsewhere. + */ +@Mixin(ModelFeatureRenderer.class) +public abstract class ModelFeatureRendererMixin { + + @Inject(method = "buildGroup", at = @At("HEAD"), cancellable = true) + private void lovisual$parallel(FeatureFrameContext context, List> submits, + CallbackInfo ci) { + if (ParallelModelHook.buildGroup((ModelFeatureRenderer) (Object) this, submits)) { + ci.cancel(); + } + } + + @WrapOperation( + method = "prepareModel", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/model/Model;renderToBuffer(Lcom/mojang/blaze3d/vertex/PoseStack;Lcom/mojang/blaze3d/vertex/VertexConsumer;III)V") + ) + private void lovisual$frozenAnimation(Model model, PoseStack poseStack, VertexConsumer consumer, + int light, int overlay, int color, Operation original, + ModelFeatureRenderer.Submit submit) { + if (AnimFreeze.enabled() && submit.sheetedDecalPose() == null + && submit.state() instanceof EntityRenderState state) { + Entity entity = ((StateEntity) state).lovisual$entity(); + if (entity != null && AnimFreeze.render(entity, model, submit.renderType(), null, + poseStack.last().pose(), consumer, + target -> original.call(model, poseStack, target, light, overlay, color))) { + return; + } + } + original.call(model, poseStack, consumer, light, overlay, color); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelFeatureRendererInvoker.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelFeatureRendererInvoker.java new file mode 100644 index 00000000..d7d8e6dd --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelFeatureRendererInvoker.java @@ -0,0 +1,17 @@ +package dev.loki.lovisual.mixins.optimize.entity.parallel; + +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** + * Stage 3 step 8: the vanilla per-model build, invoked as the fallback for + * submits the parallel path could not take (wrong format, decals, frozen + * animations, raw-vertex consumer unavailable). + */ +@Mixin(ModelFeatureRenderer.class) +public interface ModelFeatureRendererInvoker { + + @Invoker("prepareModel") + void lovisual$prepareModel(ModelFeatureRenderer.Submit submit); +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelPartAccessor.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelPartAccessor.java new file mode 100644 index 00000000..5b13b016 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/ModelPartAccessor.java @@ -0,0 +1,21 @@ +package dev.loki.lovisual.mixins.optimize.entity.parallel; + +import java.util.List; +import java.util.Map; +import net.minecraft.client.model.geom.ModelPart; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +/** + * Stage 3 step 8: exposes the part-tree internals the parallel model build + * flattens once per model — the baked cubes and the ordered children map. + */ +@Mixin(ModelPart.class) +public interface ModelPartAccessor { + + @Accessor("cubes") + List lovisual$cubes(); + + @Accessor("children") + Map lovisual$children(); +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/RenderTypeFeatureRendererInvoker.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/RenderTypeFeatureRendererInvoker.java new file mode 100644 index 00000000..1b26f29d --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/parallel/RenderTypeFeatureRendererInvoker.java @@ -0,0 +1,19 @@ +package dev.loki.lovisual.mixins.optimize.entity.parallel; + +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.renderer.feature.RenderTypeFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderType; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** + * Stage 3 step 8: the per-render-type vertex builder the parallel emit hands + * its raw vertices to — one builder call per model, exactly as vanilla's + * prepareModel would make. + */ +@Mixin(RenderTypeFeatureRenderer.class) +public interface RenderTypeFeatureRendererInvoker { + + @Invoker("getVertexBuilder") + VertexConsumer lovisual$vertexBuilder(RenderType renderType); +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererNameMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererNameMixin.java index 7eeea3db..dcb7f1aa 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererNameMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererNameMixin.java @@ -22,8 +22,6 @@ import org.spongepowered.asm.mixin.injection.At; * original runs and the result is stored; subsequent calls in the same tick * return the cached instance. * - *

    Ported from {@code zako.opt.mixin.entity.EntityRendererNameMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * Gated on the {@code microOpts} toggle via {@link OptimizeToggles#microOpts()}. * When the toggle is off, the original runs unchanged. */ diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererStateMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererStateMixin.java index 2085f8a1..97b571c8 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererStateMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/entity/renderer/EntityRendererStateMixin.java @@ -18,8 +18,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; * renderers (model feature renderer, item feature renderer, future LOD mixins) * can reach the live entity. * - *

    Ported from {@code zako.opt.mixin.entity.EntityRendererStateMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * The original used {@code @ModifyReturnValue}; LoVisual uses {@code @Inject} * at RETURN with {@code CallbackInfoReturnable} so the mixin stays simple and * doesn't rely on the (more fragile across versions) MixinExtras return-value diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlBufferCloseMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlBufferCloseMixin.java new file mode 100644 index 00000000..ab51ca1f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlBufferCloseMixin.java @@ -0,0 +1,29 @@ +package dev.loki.lovisual.mixins.optimize.gl; + +import dev.loki.lovisual.features.module.modules.misc.optimize.gl.RenderBackendOps; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Stage 3 step 2 (completes the TBO cache): only {@code GlBuffer.Direct} + * deletes its GL handle; transient buffers are slices of a shared one. A + * deleted buffer's id can be reused by a new buffer, so the attachment cache + * must not outlive it — this hook clears the cache on every Direct close. + * + * + *

    Unconditional (clearing a tiny map on buffer close is free); correctness + * beats the micro-saving of keeping stale attachments around. + */ +@Mixin(targets = "com.mojang.blaze3d.opengl.GlBuffer$Direct") +public abstract class GlBufferCloseMixin { + + @Inject(method = "close", at = @At("HEAD")) + private void lovisual$forgetTboCache(CallbackInfo ci) { + RenderBackendOps backend = RenderBackendOps.current(); + if (backend != null) { + backend.forgetTboCache(); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderClearMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderClearMixin.java index 22ed2415..67cbde1f 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderClearMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderClearMixin.java @@ -15,8 +15,6 @@ import org.spongepowered.asm.mixin.injection.At; * we don't pay for a {@code glBindFramebuffer(GL_FRAMEBUFFER, 0)} that's * immediately overwritten. * - *

    Ported from {@code zako.opt.mixin.gl.GlCommandEncoderClearMixin} (ZakoOpt, - * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * *

    Gated on the {@code microOpts} toggle via {@link RenderBackendOps#skipBindFramebufferZero(int)}. * When the toggle is off, the original call runs unchanged. diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderTexBufferMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderTexBufferMixin.java index a6dfe7ee..9d09f12a 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderTexBufferMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/GlCommandEncoderTexBufferMixin.java @@ -15,8 +15,6 @@ import org.spongepowered.asm.mixin.injection.At; * re-attaching makes the driver revalidate the texture (clouds: ~2% of a * frame in {@code glDrawElements}). * - *

    Ported from {@code zako.opt.mixin.gl.GlCommandEncoderTexBufferMixin} (ZakoOpt, - * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * *

    Gated on the {@code microOpts} toggle via * {@link RenderBackendOps#tboCacheSupported()}. When the toggle is off, both diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/RenderTypesMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/RenderTypesMixin.java index 7bbc974d..997a0acb 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/RenderTypesMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/RenderTypesMixin.java @@ -19,8 +19,6 @@ import java.util.function.BiFunction; * items, etc.), the result depends only on the identifier, so we cache it * directly in a {@code ConcurrentHashMap}. * - *

    Ported from {@code zako.opt.mixin.gl.RenderTypesMixin} (ZakoOpt, LGPL-3.0). - * Original author: Zako — https://t.me/StarikZako. The original cache logic * is wrapped in {@link RenderBackendOps}; LoVisual's GL implementation is * {@code GlRenderBackendOps}, which gates on {@code microOpts} (Stage 1 toggle). * diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/FrameBufferCacheMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/FrameBufferCacheMixin.java new file mode 100644 index 00000000..40611df9 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/FrameBufferCacheMixin.java @@ -0,0 +1,53 @@ +package dev.loki.lovisual.mixins.optimize.gl.fbo; + +import com.mojang.blaze3d.opengl.DirectStateAccess; +import com.mojang.blaze3d.opengl.FrameBufferAttachment; +import com.mojang.blaze3d.opengl.FrameBufferCache; +import dev.loki.lovisual.render.engine.optimize.gl.SharedDepthFbos; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.List; + +/** + * Stage 3 step 2: feeds {@link SharedDepthFbos} from the vanilla FBO cache. + * {@code getFbo} records "this depth texture lives in that FBO" (mip 0 only — + * higher mips are never the depth attachment of the cached FBO); + * {@code destroyFbo} drops the mapping before the FBO is deleted, because its + * GL id may be reused by the next one. + * + * + *

    Bookkeeping only — runs regardless of the {@code fbo_share} toggle (a + * map put/remove per FBO create/destroy is free); the toggle gates the clear + * path in {@code GlCommandEncoderDepthClearMixin}. + */ +@Mixin(FrameBufferCache.class) +public abstract class FrameBufferCacheMixin { + + @Shadow + @Final + private Object2IntMap cache; + + @Inject(method = "getFbo", at = @At("RETURN")) + private void lovisual$rememberDepth(DirectStateAccess dsa, + List colors, + FrameBufferAttachment depth, + CallbackInfoReturnable cir) { + if (depth != null && depth.fboMipLevel() == 0) { + SharedDepthFbos.remember(depth.glId(), cir.getReturnValueI()); + } + } + + @Inject(method = "destroyFbo", at = @At("HEAD")) + private void lovisual$forget(FrameBufferCache.CacheKey key, CallbackInfo ci) { + if (cache.containsKey(key)) { + SharedDepthFbos.forget(cache.getInt(key)); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/GlCommandEncoderDepthClearMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/GlCommandEncoderDepthClearMixin.java new file mode 100644 index 00000000..90dfd1e7 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/fbo/GlCommandEncoderDepthClearMixin.java @@ -0,0 +1,36 @@ +package dev.loki.lovisual.mixins.optimize.gl.fbo; + +import com.mojang.blaze3d.opengl.GlTexture; +import com.mojang.blaze3d.textures.GpuTexture; +import dev.loki.lovisual.features.module.modules.misc.optimize.gl.RenderBackendOps; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Stage 3 step 2: vanilla attaches the depth texture to a scratch FBO, + * clears, detaches and unbinds; a cached FBO (see {@code FrameBufferCacheMixin}) + * already holds the attachment, so the clear becomes one + * {@code glClearNamedFramebufferfv}. Cancels the vanilla path when the + * in-place clear ran. + * + * + *

    Gated on the {@code fbo_share} toggle via + * {@link RenderBackendOps#clearDepthInPlace(int, double)}; a closed texture + * or a missing DSA capability falls through to vanilla unchanged. + */ +@Mixin(targets = "com.mojang.blaze3d.opengl.GlCommandEncoder") +public abstract class GlCommandEncoderDepthClearMixin { + + @Inject(method = "clearDepthTexture", at = @At("HEAD"), cancellable = true) + private void lovisual$clearInPlace(GpuTexture depth, double value, CallbackInfo ci) { + if (!(depth instanceof GlTexture gl) || gl.isClosed()) { + return; + } + RenderBackendOps backend = RenderBackendOps.current(); + if (backend != null && backend.clearDepthInPlace(gl.glId(), value)) { + ci.cancel(); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/BufferBuilderRawMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/BufferBuilderRawMixin.java new file mode 100644 index 00000000..83b357d9 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/BufferBuilderRawMixin.java @@ -0,0 +1,53 @@ +package dev.loki.lovisual.mixins.optimize.gl.raw; + +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.loki.lovisual.render.engine.optimize.gl.RawBuffer; +import org.lwjgl.system.MemoryUtil; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +/** + * Stage 3 step 7: what Sodium's {@code VertexBufferWriter} does for a + * {@code BufferBuilder} — push ready-made vertex bytes straight into the + * buffer — reimplemented on the vanilla builder so raw-vertex consumers + * (AnimFreeze, ParallelModels) work without Sodium. + * + */ +@Mixin(BufferBuilder.class) +public abstract class BufferBuilderRawMixin implements RawBuffer { + + @Shadow + private int vertices; + @Shadow + @Final + private int vertexSize; + @Shadow + private long vertexPointer; + @Shadow + @Final + private ByteBufferBuilder buffer; + @Shadow + private int elementsToFill; + @Shadow + @Final + private VertexFormat format; + + @Override + public boolean lovisual$accepts(VertexFormat format) { + return format == this.format; + } + + @Override + public void lovisual$push(long src, int count) { + int length = count * vertexSize; + long dst = buffer.reserve(length); + MemoryUtil.memCopy(src, dst, length); + vertices += count; + // the pushed vertices are complete: the next addVertex must not see a half-filled one + vertexPointer = dst + length - vertexSize; + elementsToFill = 0; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/VertexConsumerUtilsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/VertexConsumerUtilsMixin.java new file mode 100644 index 00000000..1fc9cb97 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/raw/VertexConsumerUtilsMixin.java @@ -0,0 +1,42 @@ +package dev.loki.lovisual.mixins.optimize.gl.raw; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.mojang.blaze3d.vertex.VertexConsumer; +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; +import net.caffeinemc.mods.sodium.api.vertex.buffer.VertexBufferWriter; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +/** + * Stage 3 step 7: caches Sodium's {@code VertexConsumerUtils.convertOrLog} + * result. The vanilla method is an interface instanceof plus an interface + * call on a handful of alternating consumer classes, which keeps evicting + * HotSpot's per-class type-check cache; an identity compare against the last + * successful consumer skips both. + * + * Sodium-only: registered in {@code OPTIONAL_SODIUM_MIXINS}, never applied + * without Sodium. + * + *

    Gated on the {@code micro_opts} toggle. When the toggle is off, the + * original method runs unchanged. + */ +@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.vertex.VertexConsumerUtils", remap = false) +public abstract class VertexConsumerUtilsMixin { + + // last consumer that passed the check; a whole model goes to one consumer, so this hits for nearly every cuboid + @Unique + private static volatile Object lovisual$lastWriter; + + @WrapMethod(method = "convertOrLog") + private static VertexBufferWriter lovisual$cachedConvert(VertexConsumer consumer, Operation original) { + if (consumer == lovisual$lastWriter && OptimizeToggles.microOpts()) { + return (VertexBufferWriter) consumer; + } + VertexBufferWriter writer = original.call(consumer); + if (writer == consumer) { + lovisual$lastWriter = consumer; + } + return writer; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/hud/OptimizeDebugHudMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/hud/OptimizeDebugHudMixin.java index 1750a422..72010152 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/hud/OptimizeDebugHudMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/hud/OptimizeDebugHudMixin.java @@ -2,6 +2,7 @@ package dev.loki.lovisual.mixins.optimize.hud; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.misc.optimize.Optimize; +import dev.loki.lovisual.render.engine.optimize.profile.DrawStats; import dev.loki.lovisual.render.engine.optimize.profile.FrameBudget; import dev.loki.lovisual.render.engine.optimize.profile.FrameProfiler; import dev.loki.lovisual.render.engine.optimize.profile.OptimizePropertyBridge; @@ -65,12 +66,25 @@ public abstract class OptimizeDebugHudMixin { out.add(String.format("frame: %.2f ms (%.0f fps)", snap.frameMs(), 1000.0 / Math.max(0.01, snap.frameMs()))); - out.add(String.format(" entities=%.2fms particles=%.2fms hud=%.2fms effects=%.2fms text=%.2fms", - snap.entityMs(), - snap.particleMs(), - snap.hudMs(), - snap.effectMs(), - snap.textMs())); + if (hasSectionTimings(snap)) { + out.add(String.format(" entities=%.2fms particles=%.2fms hud=%.2fms effects=%.2fms text=%.2fms", + snap.entityMs(), + snap.particleMs(), + snap.hudMs(), + snap.effectMs(), + snap.textMs())); + } + } + + DrawStats.Snapshot counters = DrawStats.snapshot(); + if (counters.frames() > 0) { + out.add(String.format("draws: %d, pipelines: %d, fbo binds: %d (avg/frame)", + counters.draws(), + counters.pipelines(), + counters.binds())); + if (!counters.top().isEmpty()) { + out.add(" top: " + String.join(", ", counters.top())); + } } if (OptimizePropertyBridge.hasOverrides()) { @@ -82,6 +96,15 @@ public abstract class OptimizeDebugHudMixin { return out; } + /** Sections are only reported once something opens them — a row of zeros is noise. */ + private static boolean hasSectionTimings(FrameProfiler.Snapshot snap) { + return snap.entityMs() > 0 + || snap.particleMs() > 0 + || snap.hudMs() > 0 + || snap.effectMs() > 0 + || snap.textMs() > 0; + } + private static String togglesSummary() { StringBuilder sb = new StringBuilder(); if (OptimizeToggles.entityLod()) append(sb, "entity_lod@" + OptimizeToggles.entityLodDistance()); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/BubbleColumnUpParticleMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/BubbleColumnUpParticleMixin.java index 94d802ee..7b8265be 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/BubbleColumnUpParticleMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/BubbleColumnUpParticleMixin.java @@ -18,8 +18,6 @@ import org.spongepowered.asm.mixin.injection.At; * {@link BubbleColumnCache} first; on a miss, the original runs and the * result is stored. * - *

    Ported from {@code zako.opt.mixin.particle.BubbleColumnUpParticleMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * The cache logic is in {@link BubbleColumnCache} so the mixin stays a thin * wrapper and the cache is unit-testable without Minecraft types. * diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/ParticleMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/ParticleMixin.java index 446317c0..0d983eb3 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/ParticleMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/particle/ParticleMixin.java @@ -20,7 +20,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; /** * Stage 3: per-particle light cache + far-particle physics skip. * - *

    Two optimizations in one mixin (matches ZakoOpt): + *

    Two optimizations in one mixin: *

    * - *

    Ported from {@code zako.opt.mixin.particle.ParticleMixin} (ZakoOpt, - * LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * Light-cache gated on the {@code microOpts} toggle (Stage 1 wiring); * physics-skip also gated on {@code microOpts} since both are micro-opts. * When the toggle is off, the original code runs unchanged. diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlCommandEncoderStatsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlCommandEncoderStatsMixin.java new file mode 100644 index 00000000..a27df7d6 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlCommandEncoderStatsMixin.java @@ -0,0 +1,41 @@ +package dev.loki.lovisual.mixins.optimize.stats; + +import dev.loki.lovisual.render.engine.optimize.profile.DrawStats; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Counts the four places {@code GlCommandEncoder} actually issues a draw — + * {@code executeDraw}, {@code executeDraws}, {@code executeDrawIndirect} and + * {@code executeDrawMultiple} do not call each other, so exactly one of them + * sees every submission. + * + *

    Part of Stage 3 step 1 in {@code mod/docs/optimize-layer-design.md}; + * the counter is a boolean test unless F3 is open with Optimize on. + * + */ +@Mixin(targets = "com.mojang.blaze3d.opengl.GlCommandEncoder") +public abstract class GlCommandEncoderStatsMixin { + + @Inject(method = "executeDraw", at = @At("HEAD")) + private void lovisual$countDraw(CallbackInfo ci) { + DrawStats.draw(); + } + + @Inject(method = "executeDraws", at = @At("HEAD")) + private void lovisual$countDraws(CallbackInfo ci) { + DrawStats.draw(); + } + + @Inject(method = "executeDrawIndirect", at = @At("HEAD")) + private void lovisual$countDrawIndirect(CallbackInfo ci) { + DrawStats.draw(); + } + + @Inject(method = "executeDrawMultiple", at = @At("HEAD")) + private void lovisual$countDrawMultiple(CallbackInfo ci) { + DrawStats.draw(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlRenderPassStatsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlRenderPassStatsMixin.java new file mode 100644 index 00000000..c948fd6f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlRenderPassStatsMixin.java @@ -0,0 +1,30 @@ +package dev.loki.lovisual.mixins.optimize.stats; + +import com.mojang.blaze3d.pipeline.RenderPipeline; +import dev.loki.lovisual.render.engine.optimize.profile.DrawStats; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Counts {@code RenderPass.setPipeline} — one per state selection, repeats + * included — and feeds the pipeline name to the per-frame top list, so F3 + * shows which pipelines a frame spends its draws on. That is the input for + * the "sort draws by pipeline" idea in + * {@code mod/docs/optimize-layer-design.md}. + * + *

    Part of Stage 3 step 1 in {@code mod/docs/optimize-layer-design.md}. + * + */ +@Mixin(targets = "com.mojang.blaze3d.opengl.GlRenderPass") +public abstract class GlRenderPassStatsMixin { + + @Inject(method = "setPipeline", at = @At("HEAD")) + private void lovisual$countPipeline(RenderPipeline pipeline, CallbackInfo ci) { + if (pipeline == null || pipeline.getLocation() == null) { + return; + } + DrawStats.pipeline(pipeline.getLocation().getPath()); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlStateManagerStatsMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlStateManagerStatsMixin.java new file mode 100644 index 00000000..d8101e82 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/stats/GlStateManagerStatsMixin.java @@ -0,0 +1,33 @@ +package dev.loki.lovisual.mixins.optimize.stats; + +import com.mojang.blaze3d.opengl.GlStateManager; +import dev.loki.lovisual.render.engine.optimize.profile.DrawStats; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Counts framebuffer binds that actually reach the driver: the injection sits + * on the {@code glBindFramebuffer} calls inside {@code _glBindFramebuffer}, so + * binds GlStateManager's read/write cache absorbs are not counted. A target of + * {@code GL_FRAMEBUFFER} runs both branches and is counted twice — two GL calls + * really happen. + * + *

    Part of Stage 3 step 1 in {@code mod/docs/optimize-layer-design.md}. + * + */ +@Mixin(GlStateManager.class) +public abstract class GlStateManagerStatsMixin { + + @Inject( + method = "_glBindFramebuffer", + at = @At( + value = "INVOKE", + target = "Lorg/lwjgl/opengl/GL33C;glBindFramebuffer(II)V" + ) + ) + private static void lovisual$countBind(int target, int framebuffer, CallbackInfo ci) { + DrawStats.bind(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/world/LevelRendererOutlineMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/world/LevelRendererOutlineMixin.java index 54a25f77..4a53d532 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/world/LevelRendererOutlineMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/world/LevelRendererOutlineMixin.java @@ -27,8 +27,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; * entity was seen this frame. * * - *

    Ported from {@code zako.opt.mixin.entity.LevelRendererOutlineMixin} - * (ZakoOpt, LGPL-3.0). Original author: Zako — https://t.me/StarikZako. * *

    Gated on the {@code microOpts} toggle via {@link OutlineTracker#enabled()}. * When the toggle is off, all hooks fall through to the vanilla call. @@ -50,9 +48,9 @@ public abstract class LevelRendererOutlineMixin { // We can't tell from here whether 'texture' is the entity-outline target // (it's one of several cleared textures in addMainPass), so we mark the // frame as glowing unconditionally — the doEntityOutline inject will then - // run. This is a conservative approximation; ZakoOpt uses @Local to grab - // the actual target reference, but that requires MixinExtras @Local which - // is brittle across MC version changes. The cost of the conservative path + // run. This is a conservative approximation (grabbing the actual target + // reference would need MixinExtras @Local, which is brittle across MC + // version changes). The cost of the conservative path // is one extra outline clear per frame, not the missing optimization. OutlineTracker.markGlowing(); original.call(encoder, texture, color); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemFeatureRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemFeatureRendererMixin.java index a9461e47..596999e4 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemFeatureRendererMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemFeatureRendererMixin.java @@ -2,10 +2,16 @@ package dev.loki.lovisual.mixins.render.item; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import com.llamalad7.mixinextras.sugar.Local; +import dev.loki.lovisual.render.engine.optimize.entity.ItemLod; +import java.util.List; import net.minecraft.client.renderer.feature.ItemFeatureRenderer; import net.minecraft.client.renderer.rendertype.RenderType; import net.minecraft.client.resources.model.geometry.BakedQuad; +import net.minecraft.world.item.ItemDisplayContext; +import org.joml.Matrix4f; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; @@ -56,4 +62,20 @@ public abstract class ItemFeatureRendererMixin { // corrupt the following world translucent composite, so item layers keep their own type. return original; } + + @WrapOperation( + method = {"prepareMainSubmit", "prepareOutlineSubmit", "prepareFoilSubmit"}, + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/feature/ItemFeatureRenderer$Submit;quads()Ljava/util/List;") + ) + private List lovisual$itemLod(ItemFeatureRenderer.Submit submit, Operation> original) { + List quads = original.call(submit); + ItemDisplayContext context = submit.displayContext(); + if (context.firstPerson() || context == ItemDisplayContext.GUI || !ItemLod.enabled()) { + return quads; + } + Matrix4f m = submit.pose().pose(); + float dSq = m.m30() * m.m30() + m.m31() * m.m31() + m.m32() * m.m32(); + return dSq > ItemLod.distanceSq() ? ItemLod.flat(quads) : quads; + } } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemLayerAccessor.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemLayerAccessor.java new file mode 100644 index 00000000..fcfec516 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemLayerAccessor.java @@ -0,0 +1,28 @@ +package dev.loki.lovisual.mixins.render.item.bounds; + +import java.util.function.Supplier; +import net.minecraft.client.renderer.item.ItemStackRenderState; +import net.minecraft.client.resources.model.cuboid.ItemTransform; +import org.joml.Matrix4f; +import org.joml.Vector3fc; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +/** + * Stage 3 step 5: exposes the layer internals {@link ItemStackRenderStateMixin} + * keys the shared {@code ItemBounds} cache on — the extents supplier identity, + * the item transform and the local matrix whose identity bit short-circuits + * the cache. + */ +@Mixin(ItemStackRenderState.LayerRenderState.class) +public interface ItemLayerAccessor { + + @Accessor("extents") + Supplier lovisual$extents(); + + @Accessor("itemTransform") + ItemTransform lovisual$itemTransform(); + + @Accessor("localTransform") + Matrix4f lovisual$localTransform(); +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemStackRenderStateMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemStackRenderStateMixin.java new file mode 100644 index 00000000..e2f1663a --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/bounds/ItemStackRenderStateMixin.java @@ -0,0 +1,39 @@ +package dev.loki.lovisual.mixins.render.item.bounds; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import dev.loki.lovisual.render.engine.optimize.entity.ItemBounds; +import net.minecraft.client.renderer.item.ItemStackRenderState; +import net.minecraft.world.phys.AABB; +import org.joml.Matrix4fc; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +/** + * Stage 3 step 5: single-layer item model bounds served from + * {@link ItemBounds} instead of recomputed every frame. The vanilla path + * walks the layer's extents supplier; the cache key is that supplier's + * identity, which is stable per baked model. Multi-layer items and items + * with a non-identity local transform keep the vanilla computation. + */ +@Mixin(ItemStackRenderState.class) +public abstract class ItemStackRenderStateMixin { + + @Shadow + private int activeLayerCount; + + @Shadow + private ItemStackRenderState.LayerRenderState[] layers; + + @WrapMethod(method = "getModelBoundingBox") + private AABB lovisual$sharedBounds(Operation original) { + if (!ItemBounds.enabled() || activeLayerCount != 1) { + return original.call(); + } + ItemLayerAccessor layer = (ItemLayerAccessor) layers[0]; + if ((layer.lovisual$localTransform().properties() & Matrix4fc.PROPERTY_IDENTITY) == 0) { + return original.call(); + } + return ItemBounds.get(layer.lovisual$extents(), layer.lovisual$itemTransform(), original::call); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/MinecraftMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/main/MinecraftMixin.java index 951617e2..a2ed3278 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/main/MinecraftMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/main/MinecraftMixin.java @@ -35,6 +35,7 @@ import dev.loki.lovisual.features.module.modules.player.interact.NoDelay; import dev.loki.lovisual.features.module.modules.player.interact.NoInteract; import dev.loki.lovisual.features.module.modules.visuals.camera.Freecam; import dev.loki.lovisual.features.relations.StaffTracker; +import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze; import dev.loki.lovisual.render.helpers.util.TickDelta; import dev.loki.lovisual.util.combat.tracking.AntiBotTracker; import dev.loki.lovisual.util.session.SessionChanger; @@ -257,6 +258,11 @@ public class MinecraftMixin implements MinecraftGameConfigHolder { AntiBotTracker.INSTANCE.reset(); } + @Inject(method = "renderFrame", at = @At("HEAD")) + private void lovisual$animFreezeFrame(boolean tick, CallbackInfo ci) { + AnimFreeze.frame++; + } + @Inject( method = "renderFrame", at = @At( diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/core/system/LoVisualRenderSystem.java b/mod/src/main/java/dev/loki/lovisual/render/engine/core/system/LoVisualRenderSystem.java index cf847c12..eeffb842 100644 --- a/mod/src/main/java/dev/loki/lovisual/render/engine/core/system/LoVisualRenderSystem.java +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/core/system/LoVisualRenderSystem.java @@ -14,6 +14,7 @@ import dev.loki.lovisual.render.engine.core.policy.LightPolicy; import dev.loki.lovisual.render.engine.core.policy.fog.VanillaWorldFogProvider; import dev.loki.lovisual.render.engine.depth.WorldSceneDepth; import dev.loki.lovisual.render.engine.framegraph.LoVisualFrameGraph; +import dev.loki.lovisual.render.engine.optimize.profile.FrameProfiler; import dev.loki.lovisual.render.engine.profiler.frame.FrameStutterProfiler; import dev.loki.lovisual.render.engine.profiler.frame.RenderFrameProfiler; import dev.loki.lovisual.render.engine.rhi.resource.LoVisualRhi; @@ -220,6 +221,7 @@ public enum LoVisualRenderSystem { TextRenderSystem.beginFrame(); sodiumFrame = SODIUM.beginFrame(); RenderFrameProfiler.beginFrame(frameId); + FrameProfiler.beginFrame(); frameOpen = true; submissionEnded = false; lifecycle = FrameLifecycle.RECORDING; @@ -319,6 +321,7 @@ public enum LoVisualRenderSystem { FrameStutterProfiler.onFramePresented(rhiStatsSnapshot(), uniformStatsSnapshot(), resourceStatsSnapshot()); } RenderFrameProfiler.endFrame(null, null); + FrameProfiler.endFrame(); lifecycle = FrameLifecycle.PRESENTED; } finally { currentContext = null; diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java index a1c9d55d..f818d9bf 100755 --- a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java @@ -8,9 +8,11 @@ package dev.loki.lovisual.render.engine.optimize; * future mixins) — never by the module class itself. * *

    Stage 1 of {@code mod/docs/optimize-layer-design.md}: backend-independent - * logic only. GL/Vulkan-specific toggles (frame fence, lazy clear, FBO share, - * TBO cache, skin atlas, HUD cache) live in their own holders added in Stages - * 3-4 alongside the mixins that consume them. + * logic plus the toggle snapshot. Lazy clear and the TBO cache ride + * {@link #microOpts()}; FBO share (Stage 3 step 2) has its own + * {@link #fboShare()} flag. GL/Vulkan-specific helpers that need their own + * holder (skin atlas, HUD cache) arrive in later Stage 3 steps alongside the + * mixins that consume them. * *

    All module settings are resolved into volatile primitive fields once per frame. * System-property overrides are not supported here (the {@code Optimize} module's @@ -31,6 +33,7 @@ public enum OptimizeToggles { private static volatile boolean blockEntityCache; private static volatile boolean preparedTextCache; private static volatile boolean microOpts; + private static volatile boolean fboShare; public static boolean entityLod() { return entityLod; } public static int entityLodDistance() { return entityLodDistance; } @@ -43,6 +46,7 @@ public enum OptimizeToggles { public static boolean blockEntityCache() { return blockEntityCache; } public static boolean preparedTextCache() { return preparedTextCache; } public static boolean microOpts() { return microOpts; } + public static boolean fboShare() { return fboShare; } /** * Push a full snapshot. Called by {@code Optimize.onEnable/onDisable/onFrame} @@ -55,7 +59,8 @@ public enum OptimizeToggles { boolean spawnerTickCache, boolean blockEntityCache, boolean preparedTextCache, - boolean microOpts) { + boolean microOpts, + boolean fboShare) { OptimizeToggles.entityLod = entityLod; OptimizeToggles.entityLodDistance = entityLodDistance; OptimizeToggles.particleLod = particleLod; @@ -67,10 +72,11 @@ public enum OptimizeToggles { OptimizeToggles.blockEntityCache = blockEntityCache; OptimizeToggles.preparedTextCache = preparedTextCache; OptimizeToggles.microOpts = microOpts; + OptimizeToggles.fboShare = fboShare; } /** Reset to "nothing on" — used by {@code Optimize.onDisable}. */ public static void reset() { - apply(false, 0, false, false, false, false, 0, false, false, false, false); + apply(false, 0, false, false, false, false, 0, false, false, false, false, false); } } diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreeze.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreeze.java new file mode 100644 index 00000000..e16dc7b7 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreeze.java @@ -0,0 +1,208 @@ +package dev.loki.lovisual.render.engine.optimize.entity; + +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.loki.lovisual.features.module.modules.misc.optimize.entity.StateEntity; +import dev.loki.lovisual.render.engine.optimize.gl.RawVertices; +import net.minecraft.client.model.Model; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.world.entity.Entity; +import org.joml.Matrix4f; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Stage 3 step 4: far-entity animation freeze. A distant entity's model + * vertices are captured once per game tick and, on the frames in between, + * replayed as raw vertex copies shifted to the entity's current position — + * the model is no longer rebuilt (and re-animated) every frame, only moved. + * + *

    Distance gate: farther than {@link #distance()} blocks from the camera + * (squared comparison against the pose translation). Close entities render + * through the vanilla path untouched. + * + *

    The capture is keyed per {@code (model, renderType, sprite)} triplet per + * entity — one entity may submit the same model several times per frame + * (body + armor layers); the n-th submission reuses the n-th capture. Parts + * that are rebuilt every frame elsewhere (e.g. armor trim shimmer) are NOT + * replayed — callers decide via {@link #freezes}, so those parts stay in sync + * with the frozen rest instead of drifting. + * + *

    Requires the raw-vertex push path ({@code RawVertices} + Sodium or the + * vanilla {@code RawBuffer} mixin); when neither consumer accepts raw bytes, + * {@link #render} returns {@code false} and vanilla runs. + * + *

    Toggle: {@link #configure(boolean, int)}, pushed by the Optimize module + * once per frame. The frame counter advances from the {@code renderFrame} + * hook in {@code MinecraftMixin}. + */ +public enum AnimFreeze { + ; + + /** Advances once per rendered frame; captures are reused within one frame. */ + public static volatile long frame; + + private static final ByteBufferBuilder SCRATCH = new ByteBufferBuilder(16384); + private static ByteBuffer emitBuffer = MemoryUtil.memAlloc(16384); + + private static volatile boolean enabled; + private static volatile int distance = 16; + + /** Push a full snapshot — called by the Optimize module once per frame. */ + public static void configure(boolean enable, int distanceBlocks) { + enabled = enable; + distance = distanceBlocks; + } + + /** Reset to "off" — used by {@code Optimize.onDisable}. */ + public static void reset() { + configure(false, 0); + } + + public static boolean enabled() { + return enabled; + } + + public static int distance() { + return distance; + } + + /** + * True when this (state, pose) pair belongs to a far entity whose parts + * must all replay the frozen capture — parts built every frame elsewhere + * consult this so they don't drift off the frozen rest. + */ + public static boolean freezes(Object state, Matrix4f pose) { + if (!enabled || !(state instanceof EntityRenderState s) + || ((StateEntity) s).lovisual$entity() == null) { + return false; + } + float d = distance; + return beyondDistance(pose.m30(), pose.m31(), pose.m32(), d); + } + + /** + * Capture-once-per-tick / replay-in-between for one model submission. + * + * @return {@code false} when not frozen (caller renders as usual) + */ + public static boolean render(Entity entity, Model model, RenderType type, TextureAtlasSprite sprite, + Matrix4f pose, VertexConsumer target, Consumer draw) { + float x = pose.m30(), y = pose.m31(), z = pose.m32(); + float d = distance; + if (!enabled || !beyondDistance(x, y, z, d) || !RawVertices.accepts(target, type.format())) { + return false; + } + AnimHolder owner = (AnimHolder) entity; + Holder holder = owner.lovisual$anim(); + if (holder == null) { + holder = new Holder(); + owner.lovisual$anim(holder); + } + Capture capture = find(holder, model, type, sprite); + long tick = entity.level().getGameTime(); + if (capture.tick != tick) { + BufferBuilder builder = new BufferBuilder(SCRATCH, type.primitiveTopology(), type.format()); + draw.accept(sprite == null ? builder : sprite.wrap(builder)); + capture.count = 0; + try (MeshData mesh = builder.build()) { + if (mesh != null) { + ByteBuffer src = mesh.vertexBuffer(); + int bytes = src.remaining(); + if (capture.data == null || capture.data.capacity() < bytes) { + capture.data = ByteBuffer.allocateDirect(bytes * 2); + } + MemoryUtil.memCopy(MemoryUtil.memAddress(src), MemoryUtil.memAddress0(capture.data), bytes); + capture.count = mesh.drawState().vertexCount(); + } + } + capture.tick = tick; + capture.x = x; + capture.y = y; + capture.z = z; + } + if (capture.count > 0) { + emit(capture, type.format(), x - capture.x, y - capture.y, z - capture.z, target); + } + return true; + } + + /** Squared distance from the origin — shared by {@link #freezes} and {@link #render}. */ + static boolean beyondDistance(float x, float y, float z, float dist) { + return x * x + y * y + z * z > dist * dist; + } + + /** Per-entity bag of captures. */ + public static final class Holder { + final List captures = new ArrayList<>(4); + + Holder() { + } + } + + static final class Capture { + Object model; + RenderType type; + TextureAtlasSprite sprite; + int ordinal; + long usedFrame = -1; + long tick = Long.MIN_VALUE; + ByteBuffer data; + int count; + float x, y, z; + } + + /** The same model may be submitted several times per entity; the n-th call this frame reuses the n-th capture. */ + private static Capture find(Holder holder, Object model, RenderType type, TextureAtlasSprite sprite) { + int ordinal = 0; + for (Capture c : holder.captures) { + if (c.model == model && c.type == type && c.sprite == sprite && c.usedFrame == frame) { + ordinal++; + } + } + for (Capture c : holder.captures) { + if (c.model == model && c.type == type && c.sprite == sprite && c.ordinal == ordinal) { + c.usedFrame = frame; + return c; + } + } + Capture c = new Capture(); + c.model = model; + c.type = type; + c.sprite = sprite; + c.ordinal = ordinal; + c.usedFrame = frame; + holder.captures.add(c); + return c; + } + + private static void emit(Capture capture, VertexFormat format, float dx, float dy, float dz, VertexConsumer target) { + int stride = format.getVertexSize(); + int pos = format.getElement(DefaultVertexFormat.POSITION_SEMANTIC_NAME).offset(); + int size = capture.count * stride; + if (emitBuffer.capacity() < size) { + emitBuffer = MemoryUtil.memRealloc(emitBuffer, size * 2); + } + long dst = MemoryUtil.memAddress0(emitBuffer); + MemoryUtil.memCopy(MemoryUtil.memAddress0(capture.data), dst, size); + for (long v = dst + pos, end = dst + size; v < end; v += stride) { + MemoryUtil.memPutFloat(v, MemoryUtil.memGetFloat(v) + dx); + MemoryUtil.memPutFloat(v + 4, MemoryUtil.memGetFloat(v + 4) + dy); + MemoryUtil.memPutFloat(v + 8, MemoryUtil.memGetFloat(v + 8) + dz); + } + try (MemoryStack stack = MemoryStack.stackPush()) { + RawVertices.push(target, stack, dst, capture.count, format); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimHolder.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimHolder.java new file mode 100644 index 00000000..b502ce25 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/AnimHolder.java @@ -0,0 +1,14 @@ +package dev.loki.lovisual.render.engine.optimize.entity; + +/** + * Mixin-contract: per-entity storage for frozen animation captures + * ({@link AnimFreeze.Holder}). Implemented on {@code Entity} by + * {@code EntityAnimHolderMixin}; render code reaches it through the + * {@code EntityRenderState} back-reference ({@code StateEntity}). + */ +public interface AnimHolder { + + AnimFreeze.Holder lovisual$anim(); + + void lovisual$anim(AnimFreeze.Holder holder); +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBounds.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBounds.java new file mode 100644 index 00000000..648151ed --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBounds.java @@ -0,0 +1,52 @@ +package dev.loki.lovisual.render.engine.optimize.entity; + +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.function.Supplier; +import net.minecraft.client.resources.model.cuboid.ItemTransform; +import net.minecraft.world.phys.AABB; +import org.joml.Vector3fc; + +/** + * Stage 3 step 5: shared model bounds for single-layer items. Render states + * are rebuilt every frame, so a cache stored on the state itself dies with + * it; the extents supplier identity is stable per baked model and works as + * a long-lived key here. + */ +public enum ItemBounds { + ; + + private static final Map, Entry> BOUNDS = new IdentityHashMap<>(); + + private static volatile boolean enabled; + + public static void configure(boolean on) { + enabled = on; + } + + public static void reset() { + enabled = false; + BOUNDS.clear(); + } + + public static boolean enabled() { + return enabled; + } + + public static AABB get(Supplier extents, ItemTransform transform, Supplier compute) { + Entry e = BOUNDS.get(extents); + if (e != null && e.transform() == transform) { + return e.bounds(); + } + AABB bounds = compute.get(); + // wholesale clear instead of LRU; models only change on resource reload + if (BOUNDS.size() > 4096) { + BOUNDS.clear(); + } + BOUNDS.put(extents, new Entry(transform, bounds)); + return bounds; + } + + private record Entry(ItemTransform transform, AABB bounds) { + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLod.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLod.java new file mode 100644 index 00000000..b1615e7c --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLod.java @@ -0,0 +1,91 @@ +package dev.loki.lovisual.render.engine.optimize.entity; + +import com.google.common.collect.MapMaker; +import java.util.List; +import java.util.Map; +import net.minecraft.client.resources.model.geometry.BakedQuad; +import net.minecraft.core.Direction; +import org.joml.Vector3fc; + +/** + * Stage 3 step 5: far-item level of detail. Distant flat items (generated + * item models with a few large Z faces plus thin edge strips) drop their + * side geometry — at 8+ blocks the two big faces read the same on screen. + * + *

    The quad list handed to the item renderer is a reused per-render-state + * buffer, so the flat-list cache is keyed by the model's first quad with + * weak keys; a model rebuild invalidates the entry for free. + */ +public enum ItemLod { + ; + + private static final float EDGE = 1f / 16f + 1e-4f; + private static final Map FLAT = new MapMaker().weakKeys().makeMap(); + + private static volatile boolean enabled; + private static volatile float distanceSq; + + public static void configure(boolean on, int distance) { + enabled = on; + distanceSq = (float) distance * (float) distance; + } + + public static void reset() { + enabled = false; + distanceSq = 0f; + FLAT.clear(); + } + + public static boolean enabled() { + return enabled; + } + + public static float distanceSq() { + return distanceSq; + } + + /** + * Returns the model's Z faces only when the whole model is flat (every + * quad is an Z-axis face or thinner than 1/16 block) and has at least + * eight quads; anything else is returned unchanged. + */ + public static List flat(List quads) { + if (quads.isEmpty()) { + return quads; + } + Flat cached = FLAT.get(quads.getFirst()); + if (cached == null || cached.sourceSize() != quads.size()) { + cached = new Flat(quads.size(), compute(quads)); + FLAT.put(quads.getFirst(), cached); + } + return cached.quads() == null ? quads : cached.quads(); + } + + private record Flat(int sourceSize, List quads) { + } + + private static List compute(List quads) { + if (quads.size() < 8) { + return null; + } + for (BakedQuad q : quads) { + if (q.direction().getAxis() != Direction.Axis.Z && !thin(q)) { + return null; + } + } + List faces = List.copyOf( + quads.stream().filter(q -> q.direction().getAxis() == Direction.Axis.Z).toList()); + return faces.isEmpty() ? null : faces; + } + + private static boolean thin(BakedQuad q) { + float minZ = Float.MAX_VALUE; + float maxZ = -Float.MAX_VALUE; + for (int i = 0; i < 4; i++) { + Vector3fc p = q.position(i); + minZ = Math.min(minZ, p.z()); + maxZ = Math.max(maxZ, p.z()); + } + return maxZ - minZ <= EDGE; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawBuffer.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawBuffer.java new file mode 100644 index 00000000..efbea398 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawBuffer.java @@ -0,0 +1,23 @@ +package dev.loki.lovisual.render.engine.optimize.gl; + +import com.mojang.blaze3d.vertex.VertexFormat; + +/** + * Stage 3 step 7: a {@code BufferBuilder} that accepts ready-made vertex + * bytes pushed straight from memory — what Sodium's {@code VertexBufferWriter} + * does, reimplemented for the vanilla builder so optimizations work without + * Sodium. + * + * Implemented on {@code BufferBuilder} by {@code BufferBuilderRawMixin}. + */ +public interface RawBuffer { + + /** True when this buffer's vertex format is exactly {@code format}. */ + boolean lovisual$accepts(VertexFormat format); + + /** + * Copy {@code count} whole vertices from {@code src} (a native address) + * into the buffer. + */ + void lovisual$push(long src, int count); +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawVertices.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawVertices.java new file mode 100644 index 00000000..824a97d7 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/RawVertices.java @@ -0,0 +1,38 @@ +package dev.loki.lovisual.render.engine.optimize.gl; + +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import net.fabricmc.loader.api.FabricLoader; +import org.lwjgl.system.MemoryStack; + +/** + * Stage 3 step 7: dispatches ready vertex bytes into a consumer — through + * Sodium's {@code VertexBufferWriter} when Sodium is installed, else into a + * vanilla {@code BufferBuilder} via {@link RawBuffer}. + * + * + *

    Optional-dependency pattern: when Sodium is absent the {@code SODIUM} + * flag short-circuits before {@link SodiumVertices} is ever touched, so the + * Sodium API classes are never loaded. + */ +public enum RawVertices { + ; + + private static final boolean SODIUM = FabricLoader.getInstance().isModLoaded("sodium"); + + /** True when this consumer can receive raw vertices in {@code format}. */ + public static boolean accepts(VertexConsumer consumer, VertexFormat format) { + return SODIUM + ? SodiumVertices.accepts(consumer) + : consumer instanceof RawBuffer raw && raw.lovisual$accepts(format); + } + + /** Push {@code count} whole vertices from native address {@code src}. */ + public static void push(VertexConsumer consumer, MemoryStack stack, long src, int count, VertexFormat format) { + if (SODIUM) { + SodiumVertices.push(consumer, stack, src, count, format); + } else { + ((RawBuffer) consumer).lovisual$push(src, count); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbos.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbos.java new file mode 100644 index 00000000..db658c26 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbos.java @@ -0,0 +1,88 @@ +package dev.loki.lovisual.render.engine.optimize.gl; + +import com.mojang.blaze3d.opengl.GlStateManager; +import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap; +import org.lwjgl.opengl.ARBDirectStateAccess; +import org.lwjgl.opengl.GL; +import org.lwjgl.opengl.GL11C; + +/** + * Stage 3 step 2: depth-texture id → FBO that already has that texture + * attached at mip 0. Vanilla's {@code clearDepthTexture} attaches the depth + * texture to a scratch FBO, clears, detaches and unbinds every time; with a + * cached FBO the clear is a single {@code glClearNamedFramebufferfv}. + * + * + *

    {@link #remember}/{@link #forget} are driven by + * {@code mixins/optimize/gl/fbo/FrameBufferCacheMixin} on every + * {@code getFbo}/{@code destroyFbo}; {@link #clear} is called (gated on the + * {@code fbo_share} toggle) from {@code GlCommandEncoderDepthClearMixin}. + * Without GL 4.5 / {@code ARB_direct_state_access} the map still tracks, but + * {@code clear} returns {@code false} and the vanilla path runs. + * + *

    GL-only: on Vulkan this class is never reached (the mixins that feed it + * target {@code com.mojang.blaze3d.opengl.*}). + */ +public enum SharedDepthFbos { + ; + + private static final Int2IntOpenHashMap FBO_BY_DEPTH = new Int2IntOpenHashMap(); + private static final float[] VALUE = new float[1]; + private static Boolean dsa; + + /** Remember that {@code fbo} has {@code depthId} attached at mip 0. */ + public static void remember(int depthId, int fbo) { + FBO_BY_DEPTH.put(depthId, fbo); + } + + /** Drop every mapping pointing at {@code fbo} — its id may be reused by the next FBO. */ + public static void forget(int fbo) { + FBO_BY_DEPTH.values().removeIf(f -> f == fbo); + } + + /** + * Clear the depth texture in place through its cached FBO. + * + * @param depthId the GL id of the depth texture + * @param depth the clear value (0..1) + * @return {@code true} when the clear ran (caller should skip vanilla); + * {@code false} to fall through to the vanilla attach/clear/detach + */ + public static boolean clear(int depthId, double depth) { + int fbo = FBO_BY_DEPTH.getOrDefault(depthId, 0); + if (fbo == 0) { + return false; + } + if (dsa == null) { + dsa = GL.getCapabilities().OpenGL45 || GL.getCapabilities().GL_ARB_direct_state_access; + } + if (!dsa) { + return false; + } + // glClearNamedFramebuffer obeys the depth write mask and scissor test, + // like glClear in the vanilla path — but make both states explicit. + GlStateManager._depthMask(true); + GlStateManager._disableScissorTest(); + VALUE[0] = (float) depth; + ARBDirectStateAccess.glClearNamedFramebufferfv(fbo, GL11C.GL_DEPTH, 0, VALUE); + return true; + } + + // --- Test helpers (headless-safe: never touch GL) --- + + /** Test-only: drop all mappings and the cached DSA capability. */ + static void resetForTesting() { + FBO_BY_DEPTH.clear(); + dsa = null; + } + + /** Test-only: number of tracked depth textures. */ + static int sizeForTesting() { + return FBO_BY_DEPTH.size(); + } + + /** Test-only: the FBO currently mapped for {@code depthId}, or 0. */ + static int fboForTesting(int depthId) { + return FBO_BY_DEPTH.getOrDefault(depthId, 0); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SodiumVertices.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SodiumVertices.java new file mode 100644 index 00000000..2d37393f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/SodiumVertices.java @@ -0,0 +1,40 @@ +package dev.loki.lovisual.render.engine.optimize.gl; + +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import net.caffeinemc.mods.sodium.api.vertex.buffer.VertexBufferWriter; +import org.lwjgl.system.MemoryStack; + +/** + * Stage 3 step 7: the only class touching Sodium's {@code VertexBufferWriter} + * API — kept separate so it is loaded only when Sodium is installed + * ({@link RawVertices} never references it on the vanilla path). + * + */ +final class SodiumVertices { + + // tryOf is an interface instanceof plus an interface call, which thrashes HotSpot's + // per-class type check cache when a handful of consumer classes alternate; most calls + // ask about the same buffer as the last one + private static Object lastWriter; + + private SodiumVertices() { + } + + static boolean accepts(VertexConsumer consumer) { + if (consumer == lastWriter) { + return true; + } + if (VertexBufferWriter.tryOf(consumer) == null) { + return false; + } + lastWriter = consumer; + return true; + } + + static void push(VertexConsumer consumer, MemoryStack stack, long src, int count, VertexFormat format) { + (consumer == lastWriter + ? (VertexBufferWriter) consumer + : VertexBufferWriter.of(consumer)).push(stack, src, count, format); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudCache.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudCache.java new file mode 100644 index 00000000..2e4bfa36 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudCache.java @@ -0,0 +1,160 @@ +package dev.loki.lovisual.render.engine.optimize.hud; + +import com.mojang.blaze3d.GpuFormat; +import com.mojang.blaze3d.PrimitiveTopology; +import com.mojang.blaze3d.pipeline.BlendFunction; +import com.mojang.blaze3d.pipeline.ColorTargetState; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.systems.RenderPass; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.textures.FilterMode; +import java.util.Optional; +import java.util.OptionalDouble; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.BindGroupLayouts; +import net.minecraft.resources.Identifier; +import org.joml.Vector4f; + +/** + * Stage 3 step 6: the HUD is drawn into its own texture at the monitor + * refresh rate; frames in between only blend that texture in. Crosshair and + * camera overlays (vignette, pumpkin...) blend with the world, so they stay + * live every frame. + * + *

    Disabled outright when a foreign HUD cache (axolotlclient, exordium, + * gnetum) or VulkanMod is loaded — two caches stacked on one HUD break each + * other. Off by default: the LoVisual deferred-HUD marker path draws its own + * ranges straight into the main target and does not go through the cache. + */ +public enum HudCache { + ; + + // the HUD texture holds premultiplied colour: GUI blending onto transparent black + // leaves colour * alpha; write mask 7 = RGB only, alpha is not written + private static final RenderPipeline COMPOSITE = RenderPipeline.builder() + .withBindGroupLayout(BindGroupLayouts.GLOBALS) + .withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/hud_composite")) + .withVertexShader("core/screenquad") + .withFragmentShader("core/blit_screen") + .withBindGroupLayout(BindGroupLayouts.IN_SAMPLER) + .withColorTargetState(new ColorTargetState( + Optional.of(BlendFunction.TRANSLUCENT_PREMULTIPLIED_ALPHA), GpuFormat.RGBA8_UNORM, 7)) + .withPrimitiveTopology(PrimitiveTopology.TRIANGLES) + .build(); + + private static final Vector4f CLEAR = new Vector4f(0, 0, 0, 0); + private static final boolean FOREIGN_HUD = isModLoaded("axolotlclient") + || isModLoaded("exordium") + || isModLoaded("gnetum"); + private static final boolean VULKAN = isModLoaded("vulkanmod"); + + private static TextureTarget target; + // the game's own main target, taken before the HUD: mods (Xaero's minimap) swap + // their own one in while they draw + private static RenderTarget main; + private static volatile boolean enabled; + private static boolean active; + private static boolean refresh; + private static boolean drawing; + private static long lastRefresh; + private static volatile boolean suppressLiveParts; + + public static boolean suppressLiveParts() { + return suppressLiveParts; + } + + public static void suppressLiveParts(boolean value) { + suppressLiveParts = value; + } + + public static void configure(boolean on) { + enabled = on && !FOREIGN_HUD && !VULKAN; + } + + public static void reset() { + enabled = false; + active = false; + refresh = false; + drawing = false; + suppressLiveParts = false; + lastRefresh = 0; + main = null; + if (target != null) { + target.destroyBuffers(); + target = null; + } + HudWorth.reset(); + MonitorRate.reset(); + } + + // once per frame, before any GUI extraction + public static void beginFrame(boolean renderLevel) { + Minecraft mc = Minecraft.getInstance(); + boolean was = active; + long now = System.nanoTime(); + active = enabled + && HudWorth.update(now, MonitorRate.intervalNs(now)) + && renderLevel && mc.level != null + && mc.gui.screen() == null && mc.gui.overlay() == null; + if (!active) { + return; + } + main = mc.gameRenderer.mainRenderTarget(); + int w = mc.getWindow().getWidth(); + int h = mc.getWindow().getHeight(); + refresh = !was || target == null || target.width != w || target.height != h + || now - lastRefresh >= MonitorRate.intervalNs(now); + if (refresh) { + lastRefresh = now; + if (target == null) { + target = new TextureTarget("lovisual hud", w, h, true, GpuFormat.RGBA8_UNORM); + } else if (target.width != w || target.height != h) { + target.resize(w, h); + } + } + } + + public static boolean active() { + return active; + } + + public static boolean skipExtraction() { + return active && !refresh; + } + + public static RenderTarget drawTarget(RenderTarget requested) { + return drawing && requested == main ? target : null; + } + + // depth is cleared to 0: 26.x renders with reversed depth + public static void drawIntoTexture(Runnable draw) { + RenderSystem.getDevice().createCommandEncoder() + .clearColorAndDepthTextures(target.getColorTexture(), CLEAR, target.getDepthTexture(), 0.0); + drawing = true; + try { + draw.run(); + } finally { + drawing = false; + } + } + + public static void composite() { + RenderTarget main = Minecraft.getInstance().gameRenderer.mainRenderTarget(); + try (RenderPass pass = RenderSystem.getDevice().createCommandEncoder() + .createRenderPass(() -> "lovisual hud composite", main.getColorTextureView(), Optional.empty(), + main.useDepth ? main.getDepthTextureView() : null, OptionalDouble.empty())) { + pass.setPipeline(COMPOSITE); + RenderSystem.bindDefaultUniforms(pass); + pass.bindTexture("InSampler", target.getColorTextureView(), + RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST)); + pass.draw(3, 1, 0, 0); + } + } + + private static boolean isModLoaded(String id) { + return FabricLoader.getInstance().isModLoaded(id); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorth.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorth.java new file mode 100644 index 00000000..c573f63a --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorth.java @@ -0,0 +1,42 @@ +package dev.loki.lovisual.render.engine.optimize.hud; + +/** + * Stage 3 step 6: the HUD cache only saves work when several frames share one + * HUD refresh; near one frame per refresh it redraws the HUD anyway and just + * adds a full-screen clear and blend, which costs real FPS on integrated GPUs + * and 60 Hz monitors. A hysteresis band (off below 1.5, on above 2.0 frames + * per refresh) keeps it from flipping every frame. + */ +public enum HudWorth { + ; + + private static final double OFF_BELOW = 1.5; + private static final double ON_ABOVE = 2.0; + + private static double frameNs; + private static long last; + private static volatile boolean worth = true; + + public static boolean update(long now, long refreshNs) { + long dt = now - last; + last = now; + if (dt > 0 && dt < 1_000_000_000L) { + frameNs = frameNs == 0 ? dt : frameNs * 0.95 + dt * 0.05; + } + double framesPerRefresh = frameNs == 0 ? ON_ABOVE : refreshNs / frameNs; + if (worth ? framesPerRefresh < OFF_BELOW : framesPerRefresh > ON_ABOVE) { + worth = !worth; + } + return worth; + } + + public static boolean worth() { + return worth; + } + + public static void reset() { + frameNs = 0; + last = 0; + worth = true; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRate.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRate.java new file mode 100644 index 00000000..3af0457d --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRate.java @@ -0,0 +1,53 @@ +package dev.loki.lovisual.render.engine.optimize.hud; + +import com.mojang.blaze3d.platform.Monitor; +import dev.loki.lovisual.util.logging.DebugLog; +import java.util.function.IntSupplier; +import net.minecraft.client.Minecraft; + +/** + * Stage 3 step 6: nanoseconds per refresh of the monitor the window is on. + * The GLFW query runs at most once a second — the window may have moved to + * another monitor, so the value is re-probed on that interval. + */ +public enum MonitorRate { + ; + + private static final long DEFAULT_INTERVAL = 1_000_000_000L / 144; + + private static volatile long intervalNs = DEFAULT_INTERVAL; + private static long nextCheck; + private static volatile IntSupplier probe = MonitorRate::glfwRefreshRate; + + public static long intervalNs(long now) { + if (now >= nextCheck) { + int hz = probe.getAsInt(); + long interval = 1_000_000_000L / Math.max(30, hz); + if (interval != intervalNs) { + DebugLog.info("Monitor refresh rate {} Hz", hz); + } + intervalNs = interval; + nextCheck = now + 1_000_000_000L; + } + return intervalNs; + } + + public static void reset() { + intervalNs = DEFAULT_INTERVAL; + nextCheck = 0; + probe = MonitorRate::glfwRefreshRate; + } + + static void setProbeForTesting(IntSupplier testProbe) { + probe = testProbe; + } + + private static int glfwRefreshRate() { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getWindow() == null) { + return 144; + } + Monitor monitor = mc.getWindow().findBestMonitor(); + return monitor != null ? monitor.currentMode().getRefreshRate() : 144; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelBuilder.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelBuilder.java new file mode 100644 index 00000000..ba3f6290 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelBuilder.java @@ -0,0 +1,148 @@ +package dev.loki.lovisual.render.engine.optimize.parallel; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.loki.lovisual.render.engine.optimize.parallel.ModelGeometry.Cube; +import dev.loki.lovisual.render.engine.optimize.parallel.ParallelModels.Job; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.util.Mth; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.lwjgl.system.MemoryUtil; + +/** + * Writes one posed model's vertices into native memory, mirroring + * {@code ModelPart.render}/{@code translateAndRotate}/{@code Cube.compile} + * but reading the per-model pose snapshot instead of the shared part instance + * — that is what makes the build safe off the render thread. + */ +final class ModelBuilder { + + static final int STRIDE; + static final int FIELDS = 9; + + static { + VertexFormat format = ParallelModels.FORMAT; + STRIDE = format.getVertexSize(); + } + + private ModelBuilder() { + } + + static void build(Job job) { + PoseStack poses = ParallelModels.stacks(); + poses.last().set(job.matrix); + long[] ptr = {job.address}; + for (int i = 0; i < job.geometry.parts.length; ) { + i = part(job, poses, i, ptr); + } + } + + private static int normal(float c) { + return (int) (Mth.clamp(c, -1.0F, 1.0F) * 127.0F) & 0xFF; + } + + private static int part(Job job, PoseStack poses, int i, long[] ptr) { + ModelGeometry geometry = job.geometry; + int end = geometry.end[i]; + if ((job.flags[i] & 1) == 0 || geometry.empty[i]) { + return end; + } + float[] p = job.pose; + int o = i * FIELDS; + poses.pushPose(); + poses.translate(p[o] / 16.0F, p[o + 1] / 16.0F, p[o + 2] / 16.0F); + if (p[o + 3] != 0.0F || p[o + 4] != 0.0F || p[o + 5] != 0.0F) { + // what PoseStack.mulPose does; spelled out for the worker thread + Quaternionf rotation = new Quaternionf().rotationZYX(p[o + 5], p[o + 4], p[o + 3]); + poses.last().pose().rotate(rotation); + poses.last().normal().rotate(rotation); + } + if (p[o + 6] != 1.0F || p[o + 7] != 1.0F || p[o + 8] != 1.0F) { + poses.scale(p[o + 6], p[o + 7], p[o + 8]); + } + if ((job.flags[i] & 2) == 0) { + PoseStack.Pose pose = poses.last(); + Matrix4f matrix = pose.pose(); + Vector3f v = new Vector3f(); + // what SpriteCoordinateExpander does to every vertex; the sprite's + // bounds are final, safe to read here + TextureAtlasSprite sprite = job.sprite; + for (Cube cube : geometry.cubes[i]) { + // a cube's 24 vertices share 8 corners: transform the corners once + float[] at = ParallelModels.corners(); + if (at.length < cube.corners.length) { + at = new float[cube.corners.length]; + ParallelModels.setCorners(at); + } + for (int c = 0; c < cube.corners.length; c += 3) { + matrix.transformPosition(cube.corners[c], cube.corners[c + 1], cube.corners[c + 2], v); + at[c] = v.x(); + at[c + 1] = v.y(); + at[c + 2] = v.z(); + } + int k = 0; + for (int q = 0; q < cube.sizes.length; q++) { + Vector3f n = pose.transformNormal(cube.normals[q * 3], cube.normals[q * 3 + 1], cube.normals[q * 3 + 2], v); + int packedNormal = normal(n.x()) | normal(n.y()) << 8 | normal(n.z()) << 16; + for (int e = k + cube.sizes[q]; k < e; k++) { + int c = cube.index[k] * 3; + float u = cube.uv[k * 2]; + float w = cube.uv[k * 2 + 1]; + if (sprite != null) { + u = sprite.getU(u); + w = sprite.getV(w); + } + long v0 = ptr[0]; + MemoryUtil.memPutFloat(v0, at[c]); + MemoryUtil.memPutFloat(v0 + 4, at[c + 1]); + MemoryUtil.memPutFloat(v0 + 8, at[c + 2]); + MemoryUtil.memPutInt(v0 + 12, job.color); + MemoryUtil.memPutFloat(v0 + 16, u); + MemoryUtil.memPutFloat(v0 + 20, w); + MemoryUtil.memPutInt(v0 + 24, job.overlay); + MemoryUtil.memPutInt(v0 + 28, job.light); + MemoryUtil.memPutInt(v0 + 32, packedNormal); + ptr[0] += STRIDE; + } + } + } + } + for (int c = i + 1; c < end; ) { + c = part(job, poses, c, ptr); + } + poses.popPose(); + return end; + } + + static int countVertices(ModelGeometry geometry, Job job) { + int vertices = 0; + for (int i = 0; i < geometry.parts.length; ) { + if ((job.flags[i] & 1) == 0) { + i = geometry.end[i]; + continue; + } + if ((job.flags[i] & 2) == 0) { + vertices += geometry.vertices[i]; + } + i++; + } + return vertices; + } + + static void snapshotPose(ModelPart part, Job job, int i) { + int o = i * FIELDS; + job.pose[o] = part.x; + job.pose[o + 1] = part.y; + job.pose[o + 2] = part.z; + job.pose[o + 3] = part.xRot; + job.pose[o + 4] = part.yRot; + job.pose[o + 5] = part.zRot; + job.pose[o + 6] = part.xScale; + job.pose[o + 7] = part.yScale; + job.pose[o + 8] = part.zScale; + job.flags[i] = (byte) ((part.visible ? 1 : 0) | (part.skipDraw ? 2 : 0)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelGeometry.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelGeometry.java new file mode 100644 index 00000000..6233a49e --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ModelGeometry.java @@ -0,0 +1,116 @@ +package dev.loki.lovisual.render.engine.optimize.parallel; + +import dev.loki.lovisual.mixins.optimize.entity.parallel.ModelPartAccessor; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.minecraft.client.model.geom.ModelPart; + +/** + * The baked part tree of one model in render order — the static half of the + * parallel model build: which parts exist, where each subtree ends and the + * cube geometry folded onto distinct corners. Cached per model root in + * {@link ParallelModels}' weak map, rebuilt only on resource reload. + */ +final class ModelGeometry { + + final ModelPart[] parts; + /** end[i] is where the subtree of part i stops in the flat order. */ + final int[] end; + final int[] vertices; + final boolean[] empty; + final Cube[][] cubes; + + ModelGeometry(ModelPart root) { + List list = new ArrayList<>(); + List ends = new ArrayList<>(); + add(root, list, ends); + int n = list.size(); + parts = list.toArray(new ModelPart[0]); + end = new int[n]; + vertices = new int[n]; + empty = new boolean[n]; + cubes = new Cube[n][]; + for (int i = 0; i < n; i++) { + ModelPartAccessor accessor = (ModelPartAccessor) (Object) parts[i]; + end[i] = ends.get(i); + List source = accessor.lovisual$cubes(); + cubes[i] = new Cube[source.size()]; + for (int c = 0; c < cubes[i].length; c++) { + cubes[i][c] = new Cube(source.get(c)); + vertices[i] += cubes[i][c].index.length; + } + empty[i] = cubes[i].length == 0 && accessor.lovisual$children().isEmpty(); + } + // drop the strong ref from the value back to the weak-map key, or the + // root model part would never be collected (leak fix) + parts[0] = null; + } + + Map children(ModelPart part) { + return ((ModelPartAccessor) (Object) part).lovisual$children(); + } + + private void add(ModelPart part, List list, List ends) { + int at = list.size(); + list.add(part); + ends.add(0); + for (ModelPart child : children(part).values()) { + add(child, list, ends); + } + ends.set(at, list.size()); + } + + /** One cube in vanilla's polygon and vertex order, folded onto distinct corners. */ + static final class Cube { + final float[] corners; + final int[] index; + final float[] uv; + final float[] normals; + final int[] sizes; + + Cube(ModelPart.Cube cube) { + List corner = new ArrayList<>(); + List idx = new ArrayList<>(); + List uvs = new ArrayList<>(); + normals = new float[cube.polygons.length * 3]; + sizes = new int[cube.polygons.length]; + for (int q = 0; q < cube.polygons.length; q++) { + ModelPart.Polygon polygon = cube.polygons[q]; + normals[q * 3] = polygon.normal().x(); + normals[q * 3 + 1] = polygon.normal().y(); + normals[q * 3 + 2] = polygon.normal().z(); + sizes[q] = polygon.vertices().length; + for (ModelPart.Vertex vertex : polygon.vertices()) { + int found = -1; + for (int c = 0; c < corner.size(); c += 3) { + if (corner.get(c) == vertex.worldX() + && corner.get(c + 1) == vertex.worldY() + && corner.get(c + 2) == vertex.worldZ()) { + found = c / 3; + break; + } + } + if (found < 0) { + found = corner.size() / 3; + corner.add(vertex.worldX()); + corner.add(vertex.worldY()); + corner.add(vertex.worldZ()); + } + idx.add(found); + uvs.add(vertex.u()); + uvs.add(vertex.v()); + } + } + corners = new float[corner.size()]; + for (int c = 0; c < corners.length; c++) { + corners[c] = corner.get(c); + } + index = idx.stream().mapToInt(Integer::intValue).toArray(); + uv = new float[uvs.size()]; + for (int c = 0; c < uv.length; c++) { + uv[c] = uvs.get(c); + } + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelHook.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelHook.java new file mode 100644 index 00000000..2db9b9b9 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelHook.java @@ -0,0 +1,58 @@ +package dev.loki.lovisual.render.engine.optimize.parallel; + +import dev.loki.lovisual.mixins.optimize.entity.parallel.ModelFeatureRendererInvoker; +import dev.loki.lovisual.mixins.optimize.entity.parallel.RenderTypeFeatureRendererInvoker; +import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import org.lwjgl.system.MemoryStack; + +/** + * Stage 3 step 8: drives one parallel batch per model group. 26.2 hands + * models over in groups that are already ordered and batched, so a whole + * group is one parallel run; groups below {@link ParallelModels#MIN_SUBMITS} + * and submits that cannot go raw (block cracks, non-entity formats, frozen + * animations) fall back to the vanilla path per model. + */ +public final class ParallelModelHook { + + private static final List JOBS = new ArrayList<>(); + + private ParallelModelHook() { + } + + /** false: too few models in this group, vanilla builds it. */ + public static boolean buildGroup(ModelFeatureRenderer renderer, List> submits) { + if (!ParallelModels.enabled() || submits.size() < ParallelModels.MIN_SUBMITS) { + return false; + } + JOBS.clear(); + ParallelModels.begin(); + try { + for (ModelFeatureRenderer.Submit s : submits) { + // block cracks go through a decal generator: those few go the vanilla way + JOBS.add(s.sheetedDecalPose() != null || s.renderType().format() != ParallelModels.FORMAT + || AnimFreeze.freezes(s.state(), s.pose().pose()) + ? null + : ParallelModels.add(s.model(), s.state(), s.pose(), s.lightCoords(), s.overlayCoords(), + s.tintedColor(), s.sprite())); + } + } finally { + ParallelModels.end(); + } + RenderTypeFeatureRendererInvoker builders = (RenderTypeFeatureRendererInvoker) renderer; + ModelFeatureRendererInvoker vanilla = (ModelFeatureRendererInvoker) renderer; + try (MemoryStack stack = MemoryStack.stackPush()) { + for (int k = 0; k < submits.size(); k++) { + ModelFeatureRenderer.Submit s = submits.get(k); + ParallelModels.Job job = JOBS.get(k); + // one builder call per model, as vanilla does: it decides where a new draw starts + if (job == null || !ParallelModels.emit(job, builders.lovisual$vertexBuilder(s.renderType()), stack)) { + vanilla.lovisual$prepareModel(s); + } + } + } + return true; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModels.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModels.java new file mode 100644 index 00000000..3a4e01d3 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModels.java @@ -0,0 +1,199 @@ +package dev.loki.lovisual.render.engine.optimize.parallel; + +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.loki.lovisual.features.module.modules.misc.optimize.particle.Workers; +import dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze; +import dev.loki.lovisual.render.engine.optimize.gl.RawVertices; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.model.Model; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; + +/** + * Stage 3 step 8: model vertices built on several threads. setupAnim writes + * the pose into a model instance shared by every entity of that kind, so the + * render thread poses each model and snapshots its parts, workers build the + * vertices from the snapshots while the next models are posed, and the render + * thread then hands them to the buffers in vanilla's order. Driven per group + * by {@code ModelFeatureRendererMixin.buildGroup} through + * {@link ParallelModelHook}. + * + *

    Mutually exclusive with {@link AnimFreeze} (freeze replays captured + * vertices, this builds fresh ones). Off with fewer than four cores or an + * active Iris shader pack. + */ +public enum ParallelModels { + ; + + public static final VertexFormat FORMAT = DefaultVertexFormat.ENTITY; + public static final int MIN_SUBMITS = 24; + private static final int PAGE = 1 << 20; + private static final boolean CORES_OK = Runtime.getRuntime().availableProcessors() >= 4; + + private static final Map GEOMETRY = new WeakHashMap<>(); + private static final List BUILT = new ArrayList<>(); + private static final ThreadLocal STACKS = ThreadLocal.withInitial(PoseStack::new); + private static final ThreadLocal CORNERS = ThreadLocal.withInitial(() -> new float[24]); + // vertex memory handed out while models are still being posed, so the total + // is not known up front; reused every frame + private static final List PAGES = new ArrayList<>(); + private static final List PAGE_SIZES = new ArrayList<>(); + private static int page; + private static int pageUsed; + private static Workers.Stream stream; + private static volatile boolean enabled; + + public static void configure(boolean on) { + enabled = on; + } + + public static void reset() { + enabled = false; + stream = null; + BUILT.clear(); + page = 0; + pageUsed = 0; + for (int i = 0; i < PAGES.size(); i++) { + MemoryUtil.nmemFree(PAGES.get(i)); + } + PAGES.clear(); + PAGE_SIZES.clear(); + GEOMETRY.clear(); + } + + public static boolean enabled() { + return enabled && CORES_OK && !AnimFreeze.enabled() && !irisShaderPack(); + } + + public static void begin() { + BUILT.clear(); + page = 0; + pageUsed = 0; + stream = Workers.stream(i -> ModelBuilder.build(BUILT.get(i))); + } + + // render thread: poses the model and queues its vertices; the caller has + // already checked the vertex format and that nothing else (outline, block + // cracks) has to see the same model + @SuppressWarnings({"unchecked", "rawtypes"}) + public static Job add(Model model, Object state, PoseStack.Pose pose, int light, int overlay, + int color, TextureAtlasSprite sprite) { + model.setupAnim(state); + ModelPart root = model.root(); + ModelGeometry geometry = GEOMETRY.computeIfAbsent(root, ModelGeometry::new); + int n = geometry.parts.length; + Job job = new Job(geometry, pose, light, overlay, + Integer.reverseBytes(Integer.rotateLeft(color, 8)), sprite, new float[n * ModelBuilder.FIELDS], new byte[n]); + for (int i = 0; i < n; i++) { + ModelPart p = i == 0 ? root : geometry.parts[i]; + ModelBuilder.snapshotPose(p, job, i); + } + job.vertices = ModelBuilder.countVertices(geometry, job); + job.address = reserve(job.vertices * ModelBuilder.STRIDE); + BUILT.add(job); + stream.publish(BUILT.size()); + return job; + } + + // must run even when posing failed: helpers spin until the stream is finished + public static void end() { + Throwable error = stream.finish(BUILT.size()); + stream = null; + if (error != null) { + throw new RuntimeException("lovisual parallel models", error); + } + } + + // false: the consumer cannot take raw vertices, the caller renders this model the vanilla way + public static boolean emit(Job job, VertexConsumer consumer, MemoryStack stack) { + if (job.vertices == 0) { + return true; + } + if (!RawVertices.accepts(consumer, FORMAT)) { + return false; + } + stack.push(); + try { + RawVertices.push(consumer, stack, job.address, job.vertices, FORMAT); + } finally { + stack.pop(); + } + return true; + } + + static PoseStack stacks() { + return STACKS.get(); + } + + static float[] corners() { + return CORNERS.get(); + } + + static void setCorners(float[] at) { + CORNERS.set(at); + } + + private static long reserve(int bytes) { + if (page < PAGES.size() && pageUsed + bytes > PAGE_SIZES.get(page)) { + page++; + pageUsed = 0; + } + if (page == PAGES.size()) { + int size = Math.max(PAGE, bytes); + PAGES.add(MemoryUtil.nmemAlloc(size)); + PAGE_SIZES.add(size); + } else if (bytes > PAGE_SIZES.get(page)) { + MemoryUtil.nmemFree(PAGES.get(page)); + PAGES.set(page, MemoryUtil.nmemAlloc(bytes)); + PAGE_SIZES.set(page, bytes); + } + long address = PAGES.get(page) + pageUsed; + pageUsed += bytes; + return address; + } + + private static boolean irisShaderPack() { + try { + if (!FabricLoader.getInstance().isModLoaded("iris")) { + return false; + } + return net.irisshaders.iris.api.v0.IrisApi.getInstance().isShaderPackInUse(); + } catch (LinkageError | RuntimeException e) { + return false; + } + } + + public static final class Job { + final ModelGeometry geometry; + final PoseStack.Pose matrix; + final int light; + final int overlay; + final int color; + final TextureAtlasSprite sprite; + final float[] pose; + final byte[] flags; + int vertices; + long address; + + Job(ModelGeometry geometry, PoseStack.Pose matrix, int light, int overlay, int color, + TextureAtlasSprite sprite, float[] pose, byte[] flags) { + this.geometry = geometry; + this.matrix = matrix; + this.light = light; + this.overlay = overlay; + this.color = color; + this.sprite = sprite; + this.pose = pose; + this.flags = flags; + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStats.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStats.java new file mode 100644 index 00000000..ddb71497 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStats.java @@ -0,0 +1,149 @@ +package dev.loki.lovisual.render.engine.optimize.profile; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Per-frame draw counters for the F3 overlay — step 1 of Stage 3 in + * {@code mod/docs/optimize-layer-design.md}. + * + *

    Answers the question the "additional GL optimisations" section asks + * before anything gets turned on: how many draw calls, pipeline selections + * and framebuffer binds a frame really costs, and which pipelines dominate + * them. Sorting draws, batching HUD and dropping redundant FBO switches are + * all decisions that need these three numbers first. + * + *

    Counting stays off unless the Optimize module is on and F3 is + * open, so on a normal frame each hook is a single boolean test. + * + * + *

    Render-thread only: counters are touched by the thread that draws the + * frame, the published snapshot is one volatile field so the F3 read may + * happen anywhere. + */ +public final class DrawStats { + + /** Length of one measurement window — averages are published once a second. */ + static final long WINDOW_NS = 1_000_000_000L; + + /** How many pipelines are listed in {@link Snapshot#top()}. */ + static final int TOP = 5; + + /** + * One second of counting, already averaged per frame. + * + * @param draws GL draw submissions + * @param pipelines {@code RenderPass.setPipeline} calls — state changes + * @param binds {@code glBindFramebuffer} calls that reached the driver + * @param frames frames in the window; {@code 0} means "nothing measured yet" + * @param top busiest pipelines, busiest first, as {@code " "} + */ + public record Snapshot(int draws, int pipelines, int binds, int frames, List top) { + public Snapshot { + top = List.copyOf(top); + } + } + + private static final Snapshot EMPTY = new Snapshot(0, 0, 0, 0, List.of()); + + private static volatile Snapshot snapshot = EMPTY; + + private static boolean active; + private static int draws; + private static int pipelines; + private static int binds; + private static int frames; + private static long windowStartNs; + private static final Map BY_PIPELINE = new HashMap<>(); + + private DrawStats() { + } + + public static void draw() { + if (active) draws++; + } + + /** @param location pipeline location path, already cut down to a short name */ + public static void pipeline(String location) { + if (active) { + pipelines++; + BY_PIPELINE.merge(location, 1, Integer::sum); + } + } + + public static void bind() { + if (active) binds++; + } + + /** + * Closes the frame that just finished. {@code shown} is false while F3 is + * closed or the module is off — the first false call stops counting and + * empties the published snapshot, the next true call starts a fresh window. + */ + public static void endFrame(boolean shown) { + endFrame(shown, System.nanoTime()); + } + + static void endFrame(boolean shown, long nowNs) { + if (!shown) { + if (active) { + active = false; + snapshot = EMPTY; + BY_PIPELINE.clear(); + } + return; + } + if (!active) { + active = true; + startWindow(nowNs); + return; + } + frames++; + if (nowNs - windowStartNs >= WINDOW_NS) { + snapshot = publish(draws, pipelines, binds, frames, BY_PIPELINE, TOP); + startWindow(nowNs); + } + } + + /** + * Averages the window's counters over its frames and picks the busiest + * pipelines. Pure — the part of {@link #endFrame(boolean, long)} worth a test. + * + * @param byPipeline pipeline location → calls in the window, unordered + * @param topN how many entries {@link Snapshot#top()} keeps + */ + static Snapshot publish(int draws, int pipelines, int binds, int frames, + Map byPipeline, int topN) { + int perFrame = Math.max(frames, 1); + List top = new ArrayList<>(Math.min(topN, byPipeline.size())); + byPipeline.entrySet().stream() + .sorted((a, b) -> Integer.compare(b.getValue(), a.getValue())) + .limit(topN) + .forEach(e -> top.add(e.getKey() + " " + avg(e.getValue(), perFrame))); + return new Snapshot(avg(draws, perFrame), avg(pipelines, perFrame), avg(binds, perFrame), frames, top); + } + + private static int avg(int total, int frames) { + return (int) Math.round(total / (double) frames); + } + + /** Latest published window. */ + public static Snapshot snapshot() { + return snapshot; + } + + private static void startWindow(long nowNs) { + draws = pipelines = binds = frames = 0; + BY_PIPELINE.clear(); + windowStartNs = nowNs; + } + + /** Test-only: stop counting, drop counters and the published snapshot. */ + static void reset() { + active = false; + snapshot = EMPTY; + startWindow(0); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java index 2058e308..13d635e0 100644 --- a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/FrameBudget.java @@ -40,7 +40,7 @@ public enum FrameBudget { /** Multiplier applied to baseline caps when AGGRESSIVE mode is on. */ static final double AGGRESSIVE_SCALE = 0.6; - // Defaults match ZakoOpt's stage-1 caps so the behavior is familiar. + // Stage-1 baseline caps. private static final int BASE_ENTITY_LOD_DISTANCE = 35; private static final int BASE_PARTICLE_NEAR_BLOCKS = 16; // NEAR_SQ = 16² private static final int BASE_PARTICLE_MID_BLOCKS = 32; // MID_SQ = 32² diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java index 28c9616d..0b943e9d 100644 --- a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridge.java @@ -21,6 +21,7 @@ import java.util.Map; *

  • {@code -Dlovisual.opt.block_entity_cache=true|false}
  • *
  • {@code -Dlovisual.opt.prepared_text_cache=true|false}
  • *
  • {@code -Dlovisual.opt.micro_opts=true|false}
  • + *
  • {@code -Dlovisual.opt.fbo_share=true|false}
  • *
  • {@code -Dlovisual.opt.entity_lod_distance=35} — overrides the distance (4..256).
  • *
  • {@code -Dlovisual.opt.spawner_distance=16} — overrides the distance (4..64).
  • *
  • {@code -Dlovisual.opt.budget_mode=off|soft|aggressive}
  • @@ -47,8 +48,7 @@ public enum OptimizePropertyBridge { /** * Read the current {@code -Dlovisual.opt.*} properties into a fresh snapshot. - * The Optimize module calls this once per frame (mirrors ZakoOpt's - * {@code ZakoOptConfig.refresh()}). + * The Optimize module calls this once per frame. */ public static Snapshot resolve() { Snapshot s = new Snapshot(); @@ -61,6 +61,7 @@ public enum OptimizePropertyBridge { s.blockEntityCache = boolOverride("block_entity_cache"); s.preparedTextCache = boolOverride("prepared_text_cache"); s.microOpts = boolOverride("micro_opts"); + s.fboShare = boolOverride("fbo_share"); s.entityLodDistance = intOverride("entity_lod_distance", -1); s.spawnerDistance = intOverride("spawner_distance", -1); @@ -93,7 +94,7 @@ public enum OptimizePropertyBridge { private static final String[] OVERRIDE_KEYS = { "entity_lod", "particle_lod", "spawner_cull", "spawner_tick_cache", - "block_entity_cache", "prepared_text_cache", "micro_opts", + "block_entity_cache", "prepared_text_cache", "micro_opts", "fbo_share", "entity_lod_distance", "spawner_distance", "budget_mode", "target_fps" }; @@ -144,6 +145,7 @@ public enum OptimizePropertyBridge { public Boolean blockEntityCache; public Boolean preparedTextCache; public Boolean microOpts; + public Boolean fboShare; public int entityLodDistance = -1; public int spawnerDistance = -1; public FrameBudget.Mode budgetMode; // null = use module value @@ -183,6 +185,7 @@ public enum OptimizePropertyBridge { m.put("block_entity_cache", blockEntityCache); m.put("prepared_text_cache", preparedTextCache); m.put("micro_opts", microOpts); + m.put("fbo_share", fboShare); m.put("entity_lod_distance", entityLodDistance); m.put("spawner_distance", spawnerDistance); m.put("budget_mode", budgetMode); diff --git a/mod/src/main/resources/lovisual.mixins.json b/mod/src/main/resources/lovisual.mixins.json index 000e84c4..d9408755 100644 --- a/mod/src/main/resources/lovisual.mixins.json +++ b/mod/src/main/resources/lovisual.mixins.json @@ -37,18 +37,31 @@ "optimize.block.BlockEntityMixin", "optimize.block.BlockEntityRenderStateMixin", "optimize.block.ChestRendererMixin", - "optimize.entity.EntityNameMixin", - "optimize.entity.EntityRenderStateEntityMixin", - "optimize.entity.LivingEntityLodMixin", - "optimize.entity.SodiumEntityRendererMixin", + "optimize.entity.EntityNameMixin", + "optimize.entity.EntityRenderStateEntityMixin", + "optimize.entity.LivingEntityLodMixin", + "optimize.entity.SodiumEntityRendererMixin", + "optimize.entity.anim.EntityAnimHolderMixin", + "optimize.entity.anim.ModelFeatureRendererMixin", + "optimize.entity.parallel.ModelFeatureRendererInvoker", + "optimize.entity.parallel.ModelPartAccessor", + "optimize.entity.parallel.RenderTypeFeatureRendererInvoker", "optimize.entity.renderer.EntityRendererNameMixin", "optimize.entity.renderer.EntityRendererStateMixin", - "optimize.gl.GlCommandEncoderClearMixin", - "optimize.gl.GlCommandEncoderTexBufferMixin", - "optimize.gl.RenderTypesMixin", + "optimize.gl.GlBufferCloseMixin", + "optimize.gl.GlCommandEncoderClearMixin", + "optimize.gl.GlCommandEncoderTexBufferMixin", + "optimize.gl.RenderTypesMixin", + "optimize.gl.fbo.FrameBufferCacheMixin", + "optimize.gl.fbo.GlCommandEncoderDepthClearMixin", + "optimize.gl.raw.BufferBuilderRawMixin", + "optimize.gl.raw.VertexConsumerUtilsMixin", "optimize.hud.OptimizeDebugHudMixin", "optimize.particle.BubbleColumnUpParticleMixin", "optimize.particle.ParticleMixin", + "optimize.stats.GlCommandEncoderStatsMixin", + "optimize.stats.GlRenderPassStatsMixin", + "optimize.stats.GlStateManagerStatsMixin", "optimize.world.LevelRendererOutlineMixin", "accessors.blaze3d.GlTextureInvoker", "accessors.blaze3d.GpuDeviceAccessor", @@ -129,6 +142,11 @@ "hud.GuiGraphicsMixin", "hud.GuiMixin", "hud.GuiRendererMixin", + "hud.cache.GameRendererHudMixin", + "hud.cache.GuiHudMixin", + "hud.cache.GuiRendererTargetMixin", + "hud.cache.HudLiveMixin", + "hud.cache.invoker.HudInvoker", "hud.accessibility.NarratorManagerMixin", "hud.accessibility.OptionsNarratorMixin", "hud.bars.BossHealthOverlayMixin", @@ -172,6 +190,8 @@ "render.fog.WaterFogEnvironmentMixin", "render.fogmod.PowderSnowFogModifierMixin", "render.item.EquipmentLayerRendererMixin", + "render.item.bounds.ItemLayerAccessor", + "render.item.bounds.ItemStackRenderStateMixin", "render.models.AvatarCustomModelMixin", "render.models.LivingEntityCustomModelMixin", "render.placement.submit.BlockEntityPlaceMixin", diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCacheTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCacheTest.java index d3a94792..b593d692 100755 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCacheTest.java +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCacheTest.java @@ -52,21 +52,21 @@ public final class SpawnerCacheTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertFalse(SpawnerCache.enabled()); OptimizeToggles.apply( false, 0, false, false, false, false, 0, - true, false, false, false); + true, false, false, false, false); assertTrue(SpawnerCache.enabled()); OptimizeToggles.apply( false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertFalse(SpawnerCache.enabled()); } @@ -76,7 +76,7 @@ public final class SpawnerCacheTest { false, 0, false, false, false, false, 0, - true, false, false, false); + true, false, false, false, false); FakeSpawner spawner = new FakeSpawner(); assertNull(SpawnerCache.get(spawner, 100L)); } @@ -87,7 +87,7 @@ public final class SpawnerCacheTest { false, 0, false, false, false, false, 0, - true, false, false, false); + true, false, false, false, false); FakeSpawner spawner = new FakeSpawner(); EntityRenderState state = new EntityRenderState(); SpawnerCache.put(spawner, 42L, state); @@ -100,7 +100,7 @@ public final class SpawnerCacheTest { false, 0, false, false, false, false, 0, - true, false, false, false); + true, false, false, false, false); FakeSpawner spawner = new FakeSpawner(); EntityRenderState state = new EntityRenderState(); SpawnerCache.put(spawner, 42L, state); @@ -114,7 +114,7 @@ public final class SpawnerCacheTest { false, 0, false, false, false, false, 0, - true, false, false, false); + true, false, false, false, false); FakeSpawner spawner = new FakeSpawner(); EntityRenderState first = new EntityRenderState(); EntityRenderState second = new EntityRenderState(); @@ -130,7 +130,7 @@ public final class SpawnerCacheTest { false, 0, false, false, false, false, 0, - true, false, false, false); + true, false, false, false, false); FakeSpawner spawner = new FakeSpawner(); EntityRenderState state = new EntityRenderState(); SpawnerCache.put(spawner, 100L, state); diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLodTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLodTest.java index 7fba85db..5bf4d0a3 100755 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLodTest.java +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLodTest.java @@ -21,7 +21,7 @@ public final class EntityLodTest { false, 35, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertFalse(EntityLod.far(10_000_000)); assertFalse(EntityLod.farPlayer(10_000_000)); } @@ -32,7 +32,7 @@ public final class EntityLodTest { true, 35, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); // d=35 -> d²=1225: 1225 is NOT greater than 1225, so not far. assertFalse(EntityLod.far(35 * 35)); // d² just above threshold: should be far. @@ -48,7 +48,7 @@ public final class EntityLodTest { true, 16, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertEquals(16 * 16, EntityLod.farThresholdSq(), 0.0001); assertFalse(EntityLod.far(16 * 16)); assertTrue(EntityLod.far(16 * 16 + 1)); @@ -60,7 +60,7 @@ public final class EntityLodTest { true, 35, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); // Stage 1 collapses player LOD into entity LOD — same toggle, same threshold. for (double dSq : new double[]{0, 100, 1224, 1226, 10_000}) { assertEquals(EntityLod.far(dSq), EntityLod.farPlayer(dSq), @@ -74,14 +74,14 @@ public final class EntityLodTest { true, 35, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertTrue(EntityLod.far(10_000)); // Toggle off mid-test — same distance should now be "not far". OptimizeToggles.apply( false, 35, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertFalse(EntityLod.far(10_000)); } } diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOpsTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOpsTest.java index de67332d..a8639d53 100644 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOpsTest.java +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/gl/GlRenderBackendOpsTest.java @@ -35,7 +35,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); RenderBackendOps ops = RenderBackendOps.current(); assertFalse(ops.lazyClearSupported()); assertFalse(ops.skipBindFramebufferZero(0)); @@ -48,7 +48,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); RenderBackendOps ops = RenderBackendOps.current(); assertTrue(ops.lazyClearSupported()); assertTrue(ops.skipBindFramebufferZero(0)); @@ -62,7 +62,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); RenderBackendOps ops = RenderBackendOps.current(); assertFalse(ops.tboCacheSupported()); // Even after marking attached, lookup should be a no-op (returns false because not supported). @@ -76,7 +76,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); RenderBackendOps ops = RenderBackendOps.current(); ops.trackBoundTboTexture(42); assertFalse(ops.tboAlreadyAttached(0x8229, 100), @@ -92,7 +92,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); RenderBackendOps ops = RenderBackendOps.current(); ops.trackBoundTboTexture(42); ops.markTboAttached(0x8229, 100); @@ -108,7 +108,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); RenderBackendOps ops = RenderBackendOps.current(); ops.trackBoundTboTexture(42); ops.markTboAttached(0x8229, 100); @@ -126,7 +126,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); RenderBackendOps ops = RenderBackendOps.current(); ops.trackBoundTboTexture(42); ops.markTboAttached(0x8229, 100); @@ -145,7 +145,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); RenderBackendOps ops = RenderBackendOps.current(); RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "textures/entity/skin.png"); assertNull(ops.renderTypeFor(id), "Fresh cache should miss"); @@ -161,7 +161,7 @@ public final class GlRenderBackendOpsTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); RenderBackendOps ops = RenderBackendOps.current(); assertFalse(ops.renderTypesCacheSupported()); RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "test"); @@ -182,4 +182,32 @@ public final class GlRenderBackendOpsTest { assertFalse(a.equals(d)); assertEquals("mc:path", a.toString()); } + + // --- FBO share (Stage 3 step 2) --- + + @Test + void fboShareDisabledWhenToggleOff() { + OptimizeToggles.apply( + false, 0, + false, false, false, + false, 0, + false, false, false, false, false); + RenderBackendOps ops = RenderBackendOps.current(); + assertFalse(ops.fboShareSupported()); + // Toggle off short-circuits before SharedDepthFbos — safe without GL. + assertFalse(ops.clearDepthInPlace(1, 1.0)); + } + + @Test + void fboShareSupportedWhenToggleOn() { + OptimizeToggles.apply( + false, 0, + false, false, false, + false, 0, + false, false, false, false, true); + RenderBackendOps ops = RenderBackendOps.current(); + assertTrue(ops.fboShareSupported()); + // No mapping for this depth id — clear() falls through to vanilla. + assertFalse(ops.clearDepthInPlace(1, 1.0)); + } } diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLodTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLodTest.java index 5748353c..fd5c3bc0 100755 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLodTest.java +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLodTest.java @@ -23,7 +23,7 @@ public final class ParticleLodTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Object p = new Object(); assertTrue(ParticleLod.keep(p, 0)); assertTrue(ParticleLod.keep(p, 100)); @@ -36,7 +36,7 @@ public final class ParticleLodTest { false, 0, true, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Object p = new Object(); // Strictly under 16 blocks: always keep (NEAR_SQ = 16² = 256). assertTrue(ParticleLod.keep(p, 0)); @@ -51,7 +51,7 @@ public final class ParticleLodTest { false, 0, true, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); // At exactly 16² (the boundary), the particle is no longer "near" — its // keep decision is delegated to the mid-range thinning. Over many // particles, about half should survive. @@ -70,7 +70,7 @@ public final class ParticleLodTest { false, 0, true, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); double midSq = 24 * 24; // Between 16² and 32². Set seen = new HashSet<>(); for (int i = 0; i < 256; i++) { @@ -88,7 +88,7 @@ public final class ParticleLodTest { false, 0, true, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); double farSq = 48 * 48; // Beyond 32². int kept = 0; for (int i = 0; i < 1024; i++) { @@ -106,7 +106,7 @@ public final class ParticleLodTest { false, 0, true, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Object p = new Object(); double farSq = 48 * 48; boolean first = ParticleLod.keep(p, farSq); @@ -122,7 +122,7 @@ public final class ParticleLodTest { false, 0, true, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Object p = new Object(); // particle at (50, 0, 0), camera at (0, 0, 0): distance² = 2500. boolean objectApi = ParticleLod.keep(p, 50 * 50); @@ -137,7 +137,7 @@ public final class ParticleLodTest { false, 0, true, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Object p = new Object(); assertTrue(ParticleLod.keep(p, 15 * 15)); assertTrue(ParticleLod.keep(p, 0)); @@ -149,7 +149,7 @@ public final class ParticleLodTest { false, 0, true, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Object p = new Object(); boolean first = ParticleLod.keep(p, 48 * 48); // Toggle off — every distance should now keep. @@ -157,7 +157,7 @@ public final class ParticleLodTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertTrue(ParticleLod.keep(p, 48 * 48)); assertTrue(ParticleLod.keep(p, 0)); // first is just whatever it was — we only care that the new state is "keep everything". diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/WorkersTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/WorkersTest.java index b9678421..c741f5d7 100755 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/WorkersTest.java +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/WorkersTest.java @@ -82,4 +82,37 @@ public final class WorkersTest { assertNull(err); assertEquals(blocks, counter.get()); } + + @Test + void streamPublishesAndFinishes() { + int blocks = 48; + AtomicInteger counter = new AtomicInteger(); + Workers.Stream stream = Workers.stream(b -> counter.incrementAndGet()); + for (int i = 1; i <= blocks; i++) { + stream.publish(i); + } + Throwable err = stream.finish(blocks); + assertNull(err); + assertEquals(blocks, counter.get()); + } + + @Test + void streamFinishesWithNoItems() { + Workers.Stream stream = Workers.stream(b -> { }); + Throwable err = stream.finish(0); + assertNull(err); + } + + @Test + void streamReturnsFirstFailure() { + int blocks = 12; + Workers.Stream stream = Workers.stream(b -> { + if (b == 2) { + throw new IllegalStateException("stream item 2 failed"); + } + }); + Throwable err = stream.finish(blocks); + assertNotNull(err); + assertTrue(err instanceof IllegalStateException); + } } diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableTextTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableTextTest.java index b97f686a..448142a0 100755 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableTextTest.java +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableTextTest.java @@ -35,7 +35,7 @@ public final class StableTextTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Recorder inner = new Recorder(); FormattedCharSequence marked = StableText.mark(inner); assertTrue(marked instanceof StableText, @@ -49,7 +49,7 @@ public final class StableTextTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Recorder inner = new Recorder(); FormattedCharSequence marked = StableText.mark(inner); assertTrue(StableText.isStable(marked)); @@ -61,7 +61,7 @@ public final class StableTextTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Recorder plain = new Recorder(); assertFalse(StableText.isStable(plain)); } @@ -74,7 +74,7 @@ public final class StableTextTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); Recorder inner = new Recorder(); FormattedCharSequence marked = StableText.mark(inner); StableText.clearTracked(); @@ -88,7 +88,7 @@ public final class StableTextTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Recorder inner = new Recorder(); FormattedCharSequence marked = StableText.mark(inner); assertTrue(StableText.isStable(marked)); @@ -103,7 +103,7 @@ public final class StableTextTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); Recorder inner = new Recorder(); FormattedCharSequence marked = StableText.mark(inner); // Pass a sink that just returns true; the marked wrapper should delegate to inner. @@ -117,7 +117,7 @@ public final class StableTextTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertEquals(0, StableText.trackedCount()); StableText.mark(new Recorder()); StableText.mark(new Recorder()); diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java index 5896d7da..f0f02546 100644 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/world/OutlineTrackerTest.java @@ -25,7 +25,7 @@ public final class OutlineTrackerTest { false, 0, false, false, false, false, 0, - false, false, false, false); + false, false, false, false, false); assertFalse(OutlineTracker.enabled()); // Even when glowing was set this frame, shouldSkipOutlineRender returns false // when the toggle is off — the vanilla path runs unchanged. @@ -39,7 +39,7 @@ public final class OutlineTrackerTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); OutlineTracker.beginFrame(); assertTrue(OutlineTracker.shouldSkipOutlineRender(), "Without glowing entities, doEntityOutline should be skipped"); @@ -51,7 +51,7 @@ public final class OutlineTrackerTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); OutlineTracker.beginFrame(); OutlineTracker.markGlowing(); assertFalse(OutlineTracker.shouldSkipOutlineRender(), @@ -65,7 +65,7 @@ public final class OutlineTrackerTest { false, 0, false, false, false, false, 0, - false, false, false, true); + false, false, false, true, false); OutlineTracker.markGlowing(); assertTrue(OutlineTracker.glowingThisFrame()); OutlineTracker.beginFrame(); diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/OptimizeTogglesTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/OptimizeTogglesTest.java index ee6541f8..c5bc15da 100755 --- a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/OptimizeTogglesTest.java +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/OptimizeTogglesTest.java @@ -23,6 +23,7 @@ public final class OptimizeTogglesTest { assertFalse(OptimizeToggles.blockEntityCache()); assertFalse(OptimizeToggles.preparedTextCache()); assertFalse(OptimizeToggles.microOpts()); + assertFalse(OptimizeToggles.fboShare()); assertEquals(0, OptimizeToggles.entityLodDistance()); assertEquals(0, OptimizeToggles.spawnerDistance()); } @@ -36,7 +37,7 @@ public final class OptimizeTogglesTest { true, false, true, - true); + true, true); assertTrue(OptimizeToggles.entityLod()); assertEquals(35, OptimizeToggles.entityLodDistance()); assertFalse(OptimizeToggles.particleLod()); @@ -48,12 +49,14 @@ public final class OptimizeTogglesTest { assertFalse(OptimizeToggles.blockEntityCache()); assertTrue(OptimizeToggles.preparedTextCache()); assertTrue(OptimizeToggles.microOpts()); + assertTrue(OptimizeToggles.fboShare()); OptimizeToggles.reset(); assertFalse(OptimizeToggles.entityLod()); assertEquals(0, OptimizeToggles.entityLodDistance()); assertFalse(OptimizeToggles.spawnerCull()); assertFalse(OptimizeToggles.microOpts()); + assertFalse(OptimizeToggles.fboShare()); } @Test @@ -65,7 +68,7 @@ public final class OptimizeTogglesTest { false, false, false, - false); + false, false); assertFalse(OptimizeToggles.entityLod()); assertFalse(OptimizeToggles.particleLod()); assertFalse(OptimizeToggles.spawnerCull()); @@ -73,6 +76,7 @@ public final class OptimizeTogglesTest { assertFalse(OptimizeToggles.blockEntityCache()); assertFalse(OptimizeToggles.preparedTextCache()); assertFalse(OptimizeToggles.microOpts()); + assertFalse(OptimizeToggles.fboShare()); assertEquals(0, OptimizeToggles.entityLodDistance()); assertEquals(0, OptimizeToggles.spawnerDistance()); } diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreezeTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreezeTest.java new file mode 100644 index 00000000..188422d3 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/AnimFreezeTest.java @@ -0,0 +1,55 @@ +package dev.loki.lovisual.render.engine.optimize.entity; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link AnimFreeze} pure logic — the toggle snapshot and the + * squared-distance gate. The capture/replay path needs a live render + * pipeline (vertex buffers, Sodium or the raw-buffer mixin) and is + * covered by an in-game check. + */ +public final class AnimFreezeTest { + + @AfterEach + void reset() { + AnimFreeze.reset(); + } + + @Test + void offUntilConfigured() { + assertFalse(AnimFreeze.enabled()); + AnimFreeze.configure(true, 24); + assertTrue(AnimFreeze.enabled()); + assertEquals(24, AnimFreeze.distance()); + AnimFreeze.reset(); + assertFalse(AnimFreeze.enabled()); + } + + @Test + void freezesRejectsNonRenderState() { + // Not an EntityRenderState (and even a far pose) — never freezes. + AnimFreeze.configure(true, 16); + assertFalse(AnimFreeze.freezes(new Object(), farPose())); + } + + @Test + void beyondDistanceIsSquared() { + assertTrue(AnimFreeze.beyondDistance(17f, 0f, 0f, 16f), "just past the gate"); + assertFalse(AnimFreeze.beyondDistance(16f, 0f, 0f, 16f), "exactly at the gate"); + assertFalse(AnimFreeze.beyondDistance(3f, 4f, 0f, 16f), "5 blocks away, well inside"); + // diagonal: 12²+12² = 288 < 16² = 256 is false… 12²+12²=288 > 256 is true + assertTrue(AnimFreeze.beyondDistance(12f, 12f, 0f, 16f), "diagonal past the gate"); + assertFalse(AnimFreeze.beyondDistance(0f, 0f, 0f, 0f), "at the camera"); + } + + private static org.joml.Matrix4f farPose() { + org.joml.Matrix4f pose = new org.joml.Matrix4f(); + pose.translate(100f, 64f, 100f); + return pose; + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBoundsTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBoundsTest.java new file mode 100644 index 00000000..0119308f --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemBoundsTest.java @@ -0,0 +1,85 @@ +package dev.loki.lovisual.render.engine.optimize.entity; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import net.minecraft.client.resources.model.cuboid.ItemTransform; +import net.minecraft.world.phys.AABB; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link ItemBounds} caching — same extents supplier + same transform + * hits the cache, a different transform or a reset recomputes. + */ +public final class ItemBoundsTest { + + @AfterEach + void reset() { + ItemBounds.reset(); + } + + @Test + void configureAndReset() { + assertFalse(ItemBounds.enabled()); + ItemBounds.configure(true); + assertTrue(ItemBounds.enabled()); + ItemBounds.reset(); + assertFalse(ItemBounds.enabled()); + } + + @Test + void cachesPerExtentsAndTransform() { + ItemBounds.configure(true); + AtomicInteger computes = new AtomicInteger(); + Supplier extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)}; + Supplier compute = () -> { + computes.incrementAndGet(); + return new AABB(0, 0, 0, 1, 1, 1); + }; + + AABB first = ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute); + AABB second = ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute); + assertSame(first, second); + assertEquals(1, computes.get(), "second lookup must not recompute"); + } + + @Test + void differentTransformRecomputes() { + ItemBounds.configure(true); + AtomicInteger computes = new AtomicInteger(); + Supplier extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)}; + Supplier compute = () -> { + computes.incrementAndGet(); + return new AABB(0, 0, 0, 1, 1, 1); + }; + + ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute); + ItemTransform other = new ItemTransform(new Vector3f(1, 0, 0), new Vector3f(), new Vector3f(1, 1, 1)); + ItemBounds.get(extents, other, compute); + assertEquals(2, computes.get(), "a new transform must invalidate the entry"); + } + + @Test + void resetClearsCache() { + ItemBounds.configure(true); + AtomicInteger computes = new AtomicInteger(); + Supplier extents = () -> new Vector3fc[]{new Vector3f(), new Vector3f(1, 1, 1)}; + Supplier compute = () -> { + computes.incrementAndGet(); + return new AABB(0, 0, 0, 1, 1, 1); + }; + + ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute); + ItemBounds.reset(); + ItemBounds.configure(true); + ItemBounds.get(extents, ItemTransform.NO_TRANSFORM, compute); + assertEquals(2, computes.get(), "reset must drop the cached entry"); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLodTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLodTest.java new file mode 100644 index 00000000..318ea041 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/entity/ItemLodTest.java @@ -0,0 +1,100 @@ +package dev.loki.lovisual.render.engine.optimize.entity; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.client.resources.model.geometry.BakedQuad; +import net.minecraft.core.Direction; +import org.joml.Vector3f; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link ItemLod} pure logic — the toggle snapshot and the flat-model + * flattening rules — with hand-built quads, no render pipeline needed. + */ +public final class ItemLodTest { + + @AfterEach + void reset() { + ItemLod.reset(); + } + + @Test + void configureAndReset() { + assertFalse(ItemLod.enabled()); + ItemLod.configure(true, 8); + assertTrue(ItemLod.enabled()); + assertEquals(64f, ItemLod.distanceSq(), 0.001f); + ItemLod.reset(); + assertFalse(ItemLod.enabled()); + assertEquals(0f, ItemLod.distanceSq(), 0.001f); + } + + @Test + void flatKeepsOnlyZFacesOfFlatModel() { + List flatModel = new ArrayList<>(); + flatModel.add(quad(Direction.NORTH, 0f, 0f)); // big front face + flatModel.add(quad(Direction.SOUTH, 1f, 1f)); // big back face + for (int i = 0; i < 8; i++) { + flatModel.add(quad(Direction.UP, 0.94f, 1f)); // thin edge strips + } + + List result = ItemLod.flat(flatModel); + assertEquals(2, result.size(), "only the two Z faces survive"); + assertSame(flatModel.get(0), result.get(0)); + assertSame(flatModel.get(1), result.get(1)); + } + + @Test + void flatLeavesThickModelUnchanged() { + List model = new ArrayList<>(); + model.add(quad(Direction.NORTH, 0f, 0f)); + model.add(quad(Direction.SOUTH, 1f, 1f)); + for (int i = 0; i < 6; i++) { + model.add(quad(Direction.UP, 0.94f, 1f)); + } + model.add(quad(Direction.UP, 0f, 1f)); // thick non-Z quad — full cube side + + assertSame(model, ItemLod.flat(model), "thick model is returned as-is"); + } + + @Test + void flatLeavesSmallModelsUnchanged() { + // A plain baked box is six quads — below the flattening threshold. + List box = new ArrayList<>(); + box.add(quad(Direction.NORTH, 0f, 0f)); + box.add(quad(Direction.SOUTH, 1f, 1f)); + box.add(quad(Direction.UP, 0f, 1f)); + box.add(quad(Direction.DOWN, 0f, 1f)); + box.add(quad(Direction.EAST, 0f, 1f)); + box.add(quad(Direction.WEST, 0f, 1f)); + + assertSame(box, ItemLod.flat(box)); + } + + @Test + void flatCachesPerFirstQuad() { + List model = new ArrayList<>(); + model.add(quad(Direction.NORTH, 0f, 0f)); + model.add(quad(Direction.SOUTH, 1f, 1f)); + for (int i = 0; i < 8; i++) { + model.add(quad(Direction.UP, 0.94f, 1f)); + } + + List first = ItemLod.flat(model); + List second = ItemLod.flat(model); + assertSame(first, second, "same list instance comes from the cache"); + } + + private static BakedQuad quad(Direction dir, float z0, float z1) { + return new BakedQuad( + new Vector3f(0f, 0f, z0), new Vector3f(1f, 0f, z0), + new Vector3f(1f, 1f, z1), new Vector3f(0f, 1f, z1), + 0L, 0L, 0L, 0L, dir, null); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbosTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbosTest.java new file mode 100644 index 00000000..c3d4d758 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/SharedDepthFbosTest.java @@ -0,0 +1,76 @@ +package dev.loki.lovisual.render.engine.optimize.gl; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Verifies {@link SharedDepthFbos} bookkeeping — the headless-safe paths + * only. The DSA clear path needs a live GL context and is covered by an + * in-game check (owner runs {@code runClient}); without a cached FBO or a + * known depth id, {@code clear} returns {@code false} before touching GL. + */ +public final class SharedDepthFbosTest { + + @AfterEach + void reset() { + SharedDepthFbos.resetForTesting(); + } + + @Test + void rememberTracksDepthToFboMapping() { + SharedDepthFbos.remember(10, 100); + SharedDepthFbos.remember(11, 101); + assertEquals(2, SharedDepthFbos.sizeForTesting()); + assertEquals(100, SharedDepthFbos.fboForTesting(10)); + assertEquals(101, SharedDepthFbos.fboForTesting(11)); + } + + @Test + void rememberSameDepthTwiceKeepsLastFbo() { + SharedDepthFbos.remember(10, 100); + SharedDepthFbos.remember(10, 102); + assertEquals(1, SharedDepthFbos.sizeForTesting()); + assertEquals(102, SharedDepthFbos.fboForTesting(10)); + } + + @Test + void forgetRemovesOnlyMappingsPointingAtThatFbo() { + SharedDepthFbos.remember(10, 100); + SharedDepthFbos.remember(11, 100); + SharedDepthFbos.remember(12, 101); + SharedDepthFbos.forget(100); + assertEquals(1, SharedDepthFbos.sizeForTesting()); + assertEquals(0, SharedDepthFbos.fboForTesting(10)); + assertEquals(0, SharedDepthFbos.fboForTesting(11)); + assertEquals(101, SharedDepthFbos.fboForTesting(12)); + } + + @Test + void forgetUnknownFboIsNoop() { + SharedDepthFbos.remember(10, 100); + SharedDepthFbos.forget(999); + assertEquals(1, SharedDepthFbos.sizeForTesting()); + assertEquals(100, SharedDepthFbos.fboForTesting(10)); + } + + @Test + void clearWithoutMappingFallsThroughToVanilla() { + SharedDepthFbos.remember(10, 100); + // Unknown depth id: no GL touched, caller keeps the vanilla path. + assertFalse(SharedDepthFbos.clear(42, 1.0)); + // Empty map after forget: same. + SharedDepthFbos.forget(100); + assertFalse(SharedDepthFbos.clear(10, 1.0)); + } + + @Test + void resetDropsEverything() { + SharedDepthFbos.remember(10, 100); + SharedDepthFbos.resetForTesting(); + assertEquals(0, SharedDepthFbos.sizeForTesting()); + assertFalse(SharedDepthFbos.clear(10, 1.0)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorthTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorthTest.java new file mode 100644 index 00000000..6750f609 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/HudWorthTest.java @@ -0,0 +1,80 @@ +package dev.loki.lovisual.render.engine.optimize.hud; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link HudWorth} hysteresis — off below 1.5 frames per refresh, + * on above 2.0, stable inside the band. + */ +public final class HudWorthTest { + + private static final long REFRESH_60HZ = 1_000_000_000L / 60; // ~16.67 ms + + @AfterEach + void reset() { + HudWorth.reset(); + } + + @Test + void startsWorth() { + assertTrue(HudWorth.worth()); + } + + @Test + void fastFramesTurnItOff() { + // ~1 ms frames at 60 Hz → framesPerRefresh ≈ 16.7, far above the band: stays on + long t = 0; + for (int i = 0; i < 100; i++) { + t += 1_000_000L; + assertTrue(HudWorth.update(t, REFRESH_60HZ)); + } + + // now ~16 ms frames → framesPerRefresh ≈ 1.04, below the off threshold + for (int i = 0; i < 200; i++) { + t += 16_000_000L; + HudWorth.update(t, REFRESH_60HZ); + } + assertFalse(HudWorth.worth(), "near one frame per refresh the cache must turn off"); + } + + @Test + void hysteresisBandKeepsState() { + // drive it off with ~16 ms frames + long t = 0; + for (int i = 0; i < 200; i++) { + t += 16_000_000L; + HudWorth.update(t, REFRESH_60HZ); + } + assertFalse(HudWorth.worth()); + + // ~10 ms frames → framesPerRefresh ≈ 1.67, inside the band: stays off + for (int i = 0; i < 200; i++) { + t += 10_000_000L; + HudWorth.update(t, REFRESH_60HZ); + } + assertFalse(HudWorth.worth(), "inside the hysteresis band the state must not flip"); + + // ~5 ms frames → framesPerRefresh ≈ 3.3, above the on threshold: back on + for (int i = 0; i < 200; i++) { + t += 5_000_000L; + HudWorth.update(t, REFRESH_60HZ); + } + assertTrue(HudWorth.worth(), "well above the band the cache must turn back on"); + } + + @Test + void resetRestoresWorth() { + long t = 0; + for (int i = 0; i < 200; i++) { + t += 16_000_000L; + HudWorth.update(t, REFRESH_60HZ); + } + assertFalse(HudWorth.worth()); + HudWorth.reset(); + assertTrue(HudWorth.worth()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRateTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRateTest.java new file mode 100644 index 00000000..856f2cc3 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/hud/MonitorRateTest.java @@ -0,0 +1,61 @@ +package dev.loki.lovisual.render.engine.optimize.hud; + +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link MonitorRate} pure logic — the refresh-rate clamp, the + * once-per-second probe caching and the reset — through the test probe hook; + * the GLFW path needs a live window. + */ +public final class MonitorRateTest { + + @AfterEach + void reset() { + MonitorRate.reset(); + } + + @Test + void clampsLowRefreshRatesToThirty() { + MonitorRate.setProbeForTesting(() -> 10); + assertEquals(1_000_000_000L / 30, MonitorRate.intervalNs(0L)); + } + + @Test + void usesProbedRate() { + MonitorRate.setProbeForTesting(() -> 60); + assertEquals(1_000_000_000L / 60, MonitorRate.intervalNs(0L)); + } + + @Test + void probesAtMostOncePerSecond() { + AtomicInteger probes = new AtomicInteger(); + MonitorRate.setProbeForTesting(() -> { + probes.incrementAndGet(); + return 60; + }); + + MonitorRate.intervalNs(0L); + MonitorRate.intervalNs(500_000_000L); // still inside the 1 s window + assertEquals(1, probes.get(), "second call inside the window must not re-probe"); + + MonitorRate.intervalNs(1_000_000_000L); // window elapsed + assertEquals(2, probes.get(), "a new second must re-probe"); + } + + @Test + void resetRestoresDefaultInterval() { + MonitorRate.setProbeForTesting(() -> 30); + MonitorRate.intervalNs(0L); + MonitorRate.reset(); + assertTrue(MonitorRate.intervalNs(0L) > 0); + + // after reset the production probe is back — in headless tests it + // falls back to the 144 Hz default instead of touching GLFW + assertEquals(1_000_000_000L / 144, MonitorRate.intervalNs(0L)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelsTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelsTest.java new file mode 100644 index 00000000..ad53f1b9 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/parallel/ParallelModelsTest.java @@ -0,0 +1,54 @@ +package dev.loki.lovisual.render.engine.optimize.parallel; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link ParallelModels} gating — the toggle snapshot, the cores + * floor and the AnimFreeze interlock. The build itself needs a live render + * pipeline (posed models, raw vertex consumers) and is covered by an + * in-game check. + */ +public final class ParallelModelsTest { + + @AfterEach + void reset() { + ParallelModels.reset(); + } + + @Test + void offUntilConfigured() { + assertFalse(ParallelModels.enabled()); + ParallelModels.configure(true); + assertTrue(ParallelModels.enabled(), "4+ cores, no Iris, no anim freeze"); + ParallelModels.reset(); + assertFalse(ParallelModels.enabled()); + } + + @Test + void animFreezeDisablesParallelBuild() { + ParallelModels.configure(true); + dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze.configure(true, 16); + assertFalse(ParallelModels.enabled(), "freeze replays captures, parallel builds fresh — exclusive"); + dev.loki.lovisual.render.engine.optimize.entity.AnimFreeze.reset(); + assertTrue(ParallelModels.enabled()); + } + + @Test + void minSubmitsMatchesReferenceThreshold() { + assertEquals(24, ParallelModels.MIN_SUBMITS); + assertTrue(ParallelModels.FORMAT.getVertexSize() > 0); + } + + @Test + void resetFreesWithoutErrorWhenUnused() { + ParallelModels.configure(true); + // begin/add/end need real models; reset alone must stay safe on any machine + ParallelModels.reset(); + assertFalse(ParallelModels.enabled()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStatsTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStatsTest.java new file mode 100644 index 00000000..23347767 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/DrawStatsTest.java @@ -0,0 +1,127 @@ +package dev.loki.lovisual.render.engine.optimize.profile; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link DrawStats} — the F3 draw/pipeline/framebuffer counters. + * + *

    Stage 3 step 1 of {@code mod/docs/optimize-layer-design.md}. Everything + * runs on the JUnit thread, which stands in for the render thread; time is + * passed into {@link DrawStats#endFrame(boolean, long)} so the one-second + * window can be driven without sleeping. + */ +public final class DrawStatsTest { + + @AfterEach + void reset() { + DrawStats.reset(); + } + + @Test + void nothingCountsBeforeTheFirstShownFrame() { + DrawStats.draw(); + DrawStats.pipeline("pipeline/a"); + DrawStats.bind(); + + DrawStats.Snapshot s = DrawStats.snapshot(); + assertEquals(0, s.frames()); + assertEquals(0, s.draws()); + assertTrue(s.top().isEmpty()); + } + + @Test + void nothingIsPublishedInsideTheWindow() { + DrawStats.endFrame(true, 0L); + DrawStats.draw(); + + DrawStats.endFrame(true, DrawStats.WINDOW_NS - 1); + + assertEquals(0, DrawStats.snapshot().frames()); + assertEquals(0, DrawStats.snapshot().draws()); + } + + @Test + void publishesOnceTheWindowElapses() { + DrawStats.endFrame(true, 0L); + DrawStats.draw(); + DrawStats.draw(); + DrawStats.pipeline("pipeline/a"); + DrawStats.bind(); + + DrawStats.endFrame(true, DrawStats.WINDOW_NS); + + DrawStats.Snapshot s = DrawStats.snapshot(); + assertEquals(1, s.frames()); + assertEquals(2, s.draws()); + assertEquals(1, s.pipelines()); + assertEquals(1, s.binds()); + assertEquals(List.of("pipeline/a 1"), s.top()); + } + + @Test + void closingF3StopsCountingAndClearsTheSnapshot() { + DrawStats.endFrame(true, 0L); + DrawStats.draw(); + DrawStats.endFrame(true, DrawStats.WINDOW_NS); + assertEquals(1, DrawStats.snapshot().draws()); + + DrawStats.endFrame(false, DrawStats.WINDOW_NS + 1); + assertEquals(0, DrawStats.snapshot().frames()); + assertTrue(DrawStats.snapshot().top().isEmpty()); + + // a frame drawn while F3 is closed must not land anywhere + DrawStats.draw(); + DrawStats.bind(); + assertEquals(0, DrawStats.snapshot().draws()); + assertEquals(0, DrawStats.snapshot().binds()); + } + + @Test + void publishAveragesOverFramesAndSortsTopBusiestFirst() { + Map byPipeline = new LinkedHashMap<>(); + byPipeline.put("pipeline/rare", 2); + byPipeline.put("pipeline/busy", 40); + byPipeline.put("pipeline/middle", 10); + + DrawStats.Snapshot s = DrawStats.publish(100, 50, 10, 4, byPipeline, 2); + + assertEquals(25, s.draws()); // 100 / 4 + assertEquals(13, s.pipelines()); // round(50 / 4) + assertEquals(3, s.binds()); // round(10 / 4) + assertEquals(4, s.frames()); + assertEquals(List.of("pipeline/busy 10", "pipeline/middle 3"), s.top()); + } + + @Test + void publishWithNoFramesDoesNotDivideByZero() { + DrawStats.Snapshot s = DrawStats.publish(7, 3, 1, 0, Map.of(), DrawStats.TOP); + + assertEquals(0, s.frames()); + assertEquals(7, s.draws()); + assertEquals(3, s.pipelines()); + assertEquals(1, s.binds()); + assertTrue(s.top().isEmpty()); + } + + @Test + void topIsLimitedToTheConfiguredCount() { + Map byPipeline = new LinkedHashMap<>(); + for (int i = 0; i < 20; i++) { + byPipeline.put("pipeline/" + i, i + 1); + } + + DrawStats.Snapshot s = DrawStats.publish(210, 210, 0, 1, byPipeline, DrawStats.TOP); + + assertEquals(DrawStats.TOP, s.top().size()); + assertEquals("pipeline/19 20", s.top().get(0)); + assertEquals("pipeline/15 16", s.top().get(DrawStats.TOP - 1)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java index bf4a03be..3135fc44 100644 --- a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/profile/OptimizePropertyBridgeTest.java @@ -23,7 +23,7 @@ public final class OptimizePropertyBridgeTest { "lovisual.opt.entity_lod", "lovisual.opt.particle_lod", "lovisual.opt.spawner_cull", "lovisual.opt.spawner_tick_cache", "lovisual.opt.block_entity_cache", "lovisual.opt.prepared_text_cache", - "lovisual.opt.micro_opts", + "lovisual.opt.micro_opts", "lovisual.opt.fbo_share", "lovisual.opt.entity_lod_distance", "lovisual.opt.spawner_distance", "lovisual.opt.budget_mode", "lovisual.opt.target_fps" }) { @@ -68,6 +68,19 @@ public final class OptimizePropertyBridgeTest { assertFalse(s.overrides(s.entityLod, true)); } + @Test + void fboShareBooleanOverride() { + System.setProperty("lovisual.opt.fbo_share", "false"); + OptimizePropertyBridge.Snapshot s = OptimizePropertyBridge.resolve(); + assertEquals(Boolean.FALSE, s.fboShare); + assertFalse(s.overrides(s.fboShare, true)); + + System.setProperty("lovisual.opt.fbo_share", "true"); + s = OptimizePropertyBridge.resolve(); + assertEquals(Boolean.TRUE, s.fboShare); + assertTrue(s.overrides(s.fboShare, false)); + } + @Test void distanceOverride() { System.setProperty("lovisual.opt.entity_lod_distance", "100");