diff --git a/mod/NOTICE b/mod/NOTICE new file mode 100755 index 00000000..71cf1935 --- /dev/null +++ b/mod/NOTICE @@ -0,0 +1,15 @@ +LoVisual +Copyright (C) 2026 loki5512344 + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/mod/docs/optimize-layer-design.md b/mod/docs/optimize-layer-design.md index 9fcb8ca8..dea968df 100644 --- a/mod/docs/optimize-layer-design.md +++ b/mod/docs/optimize-layer-design.md @@ -1,14 +1,11 @@ -# Слой оптимизации (Optimize): перенос ZakoOpt, улучшения и Vulkan +# Слой оптимизации (Optimize): перенос референсных оптимизаций, улучшения и Vulkan Статус: черновик на согласование (2026-10-09). Цель MC: 26.2. ## Решение -Все оптимизации из ZakoOpt (слои `common`, `26.x`, `26.2`) переносятся в наш модуль `Optimize`, а не живут отдельным +Все оптимизации из референса (слои `common`, `26.x`, `26.2`) переносятся в наш модуль `Optimize`, а не живут отдельным модом. Sodium остаётся необязательным (`suggests`), выбор GL/Vulkan делается по бэкенду Blaze3D, а не по наличию Sodium. -Лицензия: ZakoOpt под LGPL-3.0, LoVisual под GPLv3, совместимо. Сохраняем указание автора (Zako, канал -https://t.me/StarikZako) в заголовках перенесённых файлов и в `NOTICE`. - ## Две независимые оси | Ось | Чем определяется | Что меняет | |---|---|---| @@ -25,7 +22,7 @@ JUnit на чистую логику, helper-классы вне `mixins.*`, м optimize/ Optimize.java модуль и настройки (ключи совместимы с .lvcfg) core/ бэкенд-независимая логика: LOD-пороги, LRU, кэши, бюджет кадра - entity/ particle/ hud/ block/ text/ перенос из ZakoOpt по категориям + entity/ particle/ hud/ block/ text/ перенос из референса по категориям gl/ GL-реализации: shared FBO, GpuFence, lazy clear, glCopyImageSubData vulkan/ Vulkan-реализации: vkCmdCopyImage, VkPipelineCache, кэш состояния profile/ профилировщик секций кадра @@ -33,7 +30,7 @@ optimize/ Выбор реализации один раз при старте через интерфейс `RenderBackendOps` (копирование текстур, фенсы, кольцо буфера). -## Что переносим (всё из ZakoOpt) +## Что переносим (всё из референса) 1. **GL/рендер-пайплайн (то, что реально есть в слоях 26.x/26.2):** Shared Frame Fence (`FrameFence`, `RealFence`), Lazy Clear, FBO Sharing (`SharedDepthFbos`, `FrameBufferCacheMixin`, `GlBufferCloseMixin`), TBO Cache, RenderType Cache, VertexConsumer Cache, Entity Sort Skip. Immediate Ring Buffer и Ring Auto-Tune существуют только в слоях 1.21.x, в 26.x их нет @@ -47,26 +44,26 @@ optimize/ 6. **Текст:** Bidi Cache, Prepared Text Cache, StableText. 7. **Микро:** MemoryStack Cache, Sign Lookup Cache, Bubble Fluid Cache. -Поток кадра как у ZakoOpt: `FrameFence.endFrame` -> `SkinAtlas.endFrame` -> `AnimFreeze.frame++` +Поток кадра как в референсе: `FrameFence.endFrame` -> `SkinAtlas.endFrame` -> `AnimFreeze.frame++` -> `OptConfig.refresh()` (флаги резолвятся один раз за кадр в примитивные поля). -## Что делаем лучше, чем в ZakoOpt +## Что делаем лучше, чем в референсе 1. **Единый бюджет кадра.** Один объект раздаёт лимиты LOD, частицам и нашим эффектам (ProjectileTrails, ElytraTrails, DeathEffects, Blizzard, GodRays): дистанция, число точек и вершин на кадр. Режим «Выкл / Мягко / Агрессивно», по умолчанию «Выкл», целевой FPS задаёт пользователь. Это не авто-деградация, прежнее решение (Фаза 9) не нарушается. 2. **Профилировщик секций** (сущности, частицы, HUD, эффекты, текст) в debug-оверлее. -3. **Один конфиг** в `Optimize`, без отдельного экрана ZakoOpt. -4. **Авто-порог параллельных частиц** по замеру (у ZakoOpt порог 4+ ядра взят из одного прогона). +3. **Один конфиг** в `Optimize`, без отдельного экрана референса. +4. **Авто-порог параллельных частиц** по замеру (в референсе порог 4+ ядра взят из одного прогона). 5. **Кэш статичных HUD-виджетов в текстуру** (перерисовка только при изменении). -6. Флаги для бенчмарков: `-Dlovisual.opt.` (аналог `-Dzakoopt.`). +6. Флаги для бенчмарков: `-Dlovisual.opt.`. ## Дополнительные GL-оптимизации (наши, гипотезы) Проверяем замером на 26.2 до включения по умолчанию. -- **Кэш состояния GL** шире, чем у ZakoOpt: пропуск повторных `glUseProgram`, `glBindVertexArray`, `glBindTexture`, `glBlendFunc`, +- **Кэш состояния GL** шире, чем в референсе: пропуск повторных `glUseProgram`, `glBindVertexArray`, `glBindTexture`, `glBlendFunc`, `glDepthMask` (LoVisual уже имеет `GlStateManagerMixin`, расширяем). - **Сортировка draw по pipeline и текстуре** внутри слоя там, где порядок не влияет на результат (наши квады `Renderer2D`, ленты trails). - **Persistent mapped ring** для динамических вершин наших эффектов и HUD (`GL_ARB_buffer_storage`), если профиль покажет стоимость - `glBufferSubData`/map. Ring Auto-Tune из ZakoOpt берём как идею размера. + `glBufferSubData`/map. Ring Auto-Tune из референса берём как идею размера. - **Батчинг HUD:** меньше смен пайплайна между виджетами (общий batch для `Renderer2D.COLOR`/`TEXTURE`). - **Инвалидация глубины/stencil** (`glInvalidateFramebuffer`) после проходов, чьё содержимое не читается: выгодно на тайловых и мобильных драйверах, на десктопе эффект мал. @@ -74,8 +71,8 @@ optimize/ - **Меньше FBO-переключений** в post-process цепочках (объединение проходов, общий ping-pong). - **Uniform-блоки:** один обновляемый UBO на кадр вместо множества мелких обновлений (в LoVisual есть `DynamicUniformStorageMixin`). -## Разбор GL-части ZakoOpt для 26.x/26.2 (что реально делает код) -Файлы в `/storage/project/jvm/ZakoOpt/zakoopt/`: +## Разбор GL-части референса для 26.x/26.2 (что реально делает код) +Файлы референса: - `26.x/.../gl/FrameFence.java` + `26.2/.../gl/RealFence.java`: один общий `GpuFence` на кадр вместо фенса на каждый запрос; если его ждут до конца кадра, реальный фенс создаётся сразу, как в ваниле. Подмена через `GlCommandEncoderMixin.createFence`. - `26.x/.../mixin/gl/GlCommandEncoderClearMixin.java` (Lazy Clear): после `clearColorAndDepthTextures` не делается лишний @@ -118,7 +115,7 @@ optimize/ ## Риски - Миксины 26.2 проверяются только компиляцией и запуском владельцем (`./gradlew runClient`), вживую я не смотрю. - Совместимость с ImmediatelyFast и Iris в 26.2 не проверена. -- Лимиты размера файлов проекта потребуют дробить крупные классы ZakoOpt (фасад + package-private хелперы). +- Лимиты размера файлов проекта потребуют дробить крупные классы референса (фасад + package-private хелперы). - Выигрыш по Vulkan не гарантирован, пока нет замеров. ## Вне объёма 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 5712d1db..05203522 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 @@ -1,17 +1,32 @@ package dev.loki.lovisual.features.module.modules.misc.optimize; import dev.loki.lovisual.config.values.primitive.BooleanValue; +import dev.loki.lovisual.config.values.primitive.NumberValue; import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.phase.Notifier; import dev.loki.lovisual.render.engine.optimize.OptimizeState; +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; /** - * Manual FPS rubilnik: one switch plus four knobs, each cutting a named cost. + * Manual FPS rubilnik: one switch plus a set of knobs, each cutting a named cost + * (the four original "rubilnik" cuts: no_glass, lite_post, lite_sky, lean_hud) + * OR toggling a transparent optimization (entity LOD, + * particle LOD, spawner tick-cache, etc.). * *

No auto-degradation by FPS — the owner's rule. Render code reads - * {@link OptimizeState}, never this class; this module only pushes state into it. + * {@link OptimizeState} (the original cuts) and {@link OptimizeToggles} (the + * new transparent helpers), never this class; this module only pushes + * state into them on enable/disable and once per frame. + * + *

Stage 1 of {@code mod/docs/optimize-layer-design.md}: the toggles below + * wire the engine-side state holders up; the actual hooks (mixins on + * {@code LivingEntityRenderer}, {@code ParticleGroup}, {@code SpawnerRenderer}, + * {@code BlockEntity}, {@code ClientLanguage}, etc.) are added in Stage 3 + * alongside the GL path. The helpers themselves (LRU, LOD math, workers, HUD + * hysteresis) are already in place under + * {@code features/module/modules/misc/optimize/{core,entity,particle,hud,block,text}/}. */ @ModuleInfo( id = "optimize", @@ -20,11 +35,25 @@ import dev.loki.lovisual.render.engine.optimize.OptimizeState; ) public final class Optimize extends Module { + // --- Original "rubilnik" cuts: visible FPS trade-offs --- private final BooleanValue noGlass = bool("no_glass", true); private final BooleanValue litePost = bool("lite_post", true); private final BooleanValue liteSky = bool("lite_sky", true); private final BooleanValue leanHud = bool("lean_hud", true); + // --- transparent optimizations (no visual change) --- + private final BooleanValue entityLod = bool("entity_lod", true); + private final NumberValue entityLodDistance = + num("entity_lod_distance", 35, 4, 256); + private final BooleanValue particleLod = bool("particle_lod", true); + private final BooleanValue spawnerCull = bool("spawner_cull", true); + private final NumberValue spawnerDistance = + num("spawner_distance", 16, 4, 64); + private final BooleanValue spawnerTickCache = bool("spawner_tick_cache", true); + private final BooleanValue blockEntityCache = bool("block_entity_cache", true); + private final BooleanValue preparedTextCache = bool("prepared_text_cache", true); + private final BooleanValue microOpts = bool("micro_opts", true); + private boolean notified; @Override @@ -41,6 +70,7 @@ public final class Optimize extends Module { @Override public void onDisable() { OptimizeState.reset(); + OptimizeToggles.reset(); } /** Picks up knob changes made while the module stays on. */ @@ -56,6 +86,16 @@ public final class Optimize extends Module { on && litePost.get(), on && liteSky.get(), on && leanHud.get()); + OptimizeToggles.apply( + on && entityLod.get(), entityLodDistance.get(), + on && particleLod.get(), + on && particleLod.get(), // particleLight: same gate in Stage 1 + on && particleLod.get(), // particlePhysics: same gate in Stage 1 + on && spawnerCull.get(), spawnerDistance.get(), + on && spawnerTickCache.get(), + on && blockEntityCache.get(), + on && preparedTextCache.get(), + on && microOpts.get()); } /** Compact list of what is being cut, for the first-enable notice. */ diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/CachedLight.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/CachedLight.java new file mode 100755 index 00000000..d6c48084 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/CachedLight.java @@ -0,0 +1,23 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.block; + +/** + * Mixin-contract: per-tick cached block-light lookups on a {@code BlockEntity}. + * The vanilla {@code LightCoordsUtil.getLightCoords(level, pos)} call walks + * the chunk's light section data every frame per block-entity in view; + * caching it once per tick (block-light only changes on tick boundaries) + * removes that walk from the hot path. The companion {@code pairLight} + * methods cache the second lookup done for double-chests. + */ +public interface CachedLight { + int lovisual$light(); + + long lovisual$lightTick(); + + void lovisual$setLight(int light, long tick); + + int lovisual$pairLight(); + + long lovisual$pairLightTick(); + + void lovisual$setPairLight(int light, long tick); +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/CachedValidity.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/CachedValidity.java new file mode 100755 index 00000000..a461a821 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/CachedValidity.java @@ -0,0 +1,25 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.block; + +import net.minecraft.world.level.block.state.BlockState; + +/** + * Mixin-contract: cached validity and "should-render" distance check on a + * {@code BlockEntity}. {@code BlockEntityType.isValid(state)} is called every + * frame per block-entity in view but only changes on a state transition — + * cached by identity on {@link #lovisual$validState()} / {@link #lovisual$valid()}. + * The companion {@code inDistance} methods cache + * {@code BlockEntityRenderer.shouldRender}'s distance check per tick. + */ +public interface CachedValidity { + BlockState lovisual$validState(); + + boolean lovisual$valid(); + + void lovisual$setValid(BlockState state, boolean valid); + + long lovisual$distanceTick(); + + boolean lovisual$inDistance(); + + void lovisual$setInDistance(boolean inDistance, long tick); +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCache.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCache.java new file mode 100755 index 00000000..9d440961 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/SpawnerCache.java @@ -0,0 +1,40 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.block; + +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; +import net.minecraft.client.renderer.entity.state.EntityRenderState; + +/** + * Utility layer over {@link TickCached} on a spawner ({@code BaseSpawner} via + * mixin): get/put the cached {@link EntityRenderState} for the spawner's + * display mob, keyed by tick, skipping the per-frame {@code extractEntity} + * rebuild. The (Stage 3) spawner renderer mixin populates this on the first + * frame of a tick and the subsequent frames in the same tick read back from + * the cache. + * + *

Takes the {@link TickCached} interface directly rather than the + * {@code BaseSpawner} class so the contract is testable without a + * Minecraft runtime — the Stage 3 mixin casts {@code (TickCached) spawner} + * before calling. + * + *

Reads {@link OptimizeToggles#spawnerTickCache()} to decide whether caching is enabled. + */ +public enum SpawnerCache { + ; + + /** True when the cache is engaged this frame. */ + public static boolean enabled() { + return OptimizeToggles.spawnerTickCache(); + } + + /** Cached state for this tick, or {@code null} if not yet cached. */ + public static EntityRenderState get(TickCached spawner, long tick) { + return spawner.lovisual$cachedTick() == tick + ? (EntityRenderState) spawner.lovisual$cached() + : null; + } + + /** Write the cached state for the given tick. */ + public static void put(TickCached spawner, long tick, EntityRenderState state) { + spawner.lovisual$setCached(state, tick); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/TickCached.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/TickCached.java new file mode 100755 index 00000000..0b2a8fb7 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/block/TickCached.java @@ -0,0 +1,17 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.block; + +/** + * Mixin-contract: a generic per-tick cache slot on a target object + * (block-entity, spawner, piston moving block). Stores one cached value plus + * the tick it was written; the helper (e.g. {@link SpawnerCache}) checks + * {@link #lovisual$cachedTick()} against the current tick and reads + * {@link #lovisual$cached()} if it matches, otherwise lets the caller rebuild + * and writes the fresh value via {@link #lovisual$setCached(Object, long)}. + */ +public interface TickCached { + Object lovisual$cached(); + + long lovisual$cachedTick(); + + void lovisual$setCached(Object value, long tick); +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/core/LruMap.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/core/LruMap.java new file mode 100755 index 00000000..4e1aa051 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/core/LruMap.java @@ -0,0 +1,32 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.core; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Access-ordered LRU map with a size cap. Generic, no Minecraft dependencies, + * unit-testable as-is. + * + *

Intended for the {@code ClientLanguage.getVisualOrder} LRU cache. + * + * @param key type + * @param value type + */ +public final class LruMap extends LinkedHashMap { + + private final int maxSize; + + public LruMap(int maxSize) { + super(256, 0.75f, true); + this.maxSize = maxSize; + } + + public int maxSize() { + return maxSize; + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedName.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedName.java new file mode 100755 index 00000000..4c9e842f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedName.java @@ -0,0 +1,23 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.entity; + +import net.minecraft.network.chat.Component; + +/** + * Mixin-contract: cached rebuilt name tag for an entity. + * + *

The vanilla {@code Entity.getDisplayName()} rebuilds a {@link Component} + * (team prefix + name + suffix) every time it's called. Renderers call it once + * per frame per entity in view, which is wasteful — the team/name/suffix tuple + * only changes on tick boundaries. Mixins on {@code Entity} implement this + * interface, expose the cache slot via {@link #lovisual$name()}, {@link #lovisual$nameTick()} + * and {@link #lovisual$setName(Component, long)}, and the + * {@code EntityRenderer.getNameTag} wrapper fills it once per tick and reuses + * the cached value on intermediate frames. + */ +public interface CachedName { + Component lovisual$name(); + + long lovisual$nameTick(); + + void lovisual$setName(Component name, long tick); +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedStack.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedStack.java new file mode 100755 index 00000000..f14ca907 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/CachedStack.java @@ -0,0 +1,50 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.entity; + +import org.lwjgl.system.MemoryStack; + +/** + * Hot-path helper: skip the {@link MemoryStack#stackPush()} ThreadLocal lookup + * on the thread that called last (the render thread). The lookup is cheap on + * its own, but Sodium's entity renderer calls it once per cuboid per frame + * (thousands of times), and a single volatile read + identity compare is + * strictly cheaper than the ThreadLocal hash lookup. + * + *

Stores the {@code (thread, stack)} pair in one record so another thread + * never sees a half-updated pair. The first call from a new thread rewrites + * the volatile; subsequent calls from the same thread take the fast path. + * + *

Used by the (Stage 3) Sodium {@code EntityRenderer.renderCuboid} mixin. + */ +public enum CachedStack { + ; + + private record ThreadStack(Thread thread, MemoryStack stack) { + } + + // One record so another thread never sees a thread/stack pair half-updated. + private static volatile ThreadStack cached = new ThreadStack(null, null); + + /** + * Push a frame on the render thread's cached stack, falling back to the + * normal ThreadLocal lookup on a thread switch. + */ + public static MemoryStack push() { + ThreadStack current = cached; + Thread thread = Thread.currentThread(); + if (current.thread() != thread) { + current = new ThreadStack(thread, MemoryStack.stackGet()); + cached = current; + } + return current.stack().push(); + } + + /** Test-only: peek the cached thread (null before first call). */ + static Thread cachedThread() { + return cached.thread(); + } + + /** Test-only: peek the cached stack (null before first call). */ + static MemoryStack cachedStack() { + return cached.stack(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLod.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLod.java new file mode 100755 index 00000000..0075ae76 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/EntityLod.java @@ -0,0 +1,37 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.entity; + +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; + +/** + * Entity LOD gate: is an entity far enough that the renderer should skip its + * layers (armour, held items, eyes, cape, elytra, stuck arrows) and freeze its + * animation? Compares squared distance to the configured threshold so the + * caller doesn't have to {@code Math.sqrt}. + * + *

The companion {@code farPlayer} exists so a config can drop + * players separately from entities (the {@code playerLod} toggle); in LoVisual + * Stage 1 we collapse the two — the same toggle gates both. Stage 2 may split + * them again if profiling shows players are the bigger cost. + */ +public enum EntityLod { + ; + + /** Squared threshold at and above which an entity is "far". */ + public static double farThresholdSq() { + double d = OptimizeToggles.entityLodDistance(); + return d * d; + } + + /** True when LOD is on and the entity is beyond the configured distance. */ + public static boolean far(double distanceSq) { + if (!OptimizeToggles.entityLod()) { + return false; + } + return distanceSq > farThresholdSq(); + } + + /** Same as {@link #far} — players and entities share one toggle in Stage 1. */ + public static boolean farPlayer(double distanceSq) { + return far(distanceSq); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/StateEntity.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/StateEntity.java new file mode 100755 index 00000000..592343d4 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/entity/StateEntity.java @@ -0,0 +1,19 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.entity; + +import net.minecraft.world.entity.Entity; + +/** + * Mixin-contract: back-reference the source {@link Entity} on its + * {@code EntityRenderState}. Render-states are rebuilt every frame, so they + * can't keep their own pointer to the entity — the + * {@code EntityRenderer.createRenderState} mixin captures it once per state + * construction via {@link #lovisual$entity(Entity)} and downstream renderers + * (model feature renderer, item feature renderer, future LOD mixins) read it + * back via {@link #lovisual$entity()} to reach the live entity for animation + * freeze, LOD distance checks, etc. + */ +public interface StateEntity { + Entity lovisual$entity(); + + void lovisual$entity(Entity entity); +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/hud/HudWorth.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/hud/HudWorth.java new file mode 100755 index 00000000..30a7a9d6 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/hud/HudWorth.java @@ -0,0 +1,67 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.hud; + +/** + * Hysteresis controller for the HUD cache. The HUD cache only saves work when + * several frames share one HUD refresh; near one frame per refresh (refresh + * interval ~= frame time) it redraws the HUD anyway and just adds a + * full-screen clear + blend, which costs real FPS on integrated GPUs and + * 60 Hz monitors. This controller tracks an EMA of frame time + * ({@code *0.95 + dt*0.05}) and flips the "worth it" state with hysteresis: + * turns OFF below {@code OFF_BELOW} frames-per-refresh, turns ON above + * {@code ON_ABOVE}. + * + *

Pure logic, no Minecraft dependencies, fully testable. The (Stage 3) HUD cache mixin reads + * {@link #worth()} to decide whether to engage. + */ +public enum HudWorth { + ; + + private static final double OFF_BELOW = 1.5; + private static final double ON_ABOVE = 2.0; + + private static double frameNs; + private static long last; + private static volatile boolean worth = true; + + /** True when the HUD cache is currently engaged. */ + public static boolean worth() { + return worth; + } + + /** True when the HUD cache should be available at all (independent of FPS). */ + public static boolean hysteresisEnabled() { + return true; + } + + /** + * Update the EMA frame time and the hysteresis state. Called once per + * frame; returns the new {@link #worth()} value. + * + * @param now the current {@code System.nanoTime()} reading + * @param refreshNs nanoseconds between HUD refreshes (1e9 / monitor Hz) + */ + public static boolean update(long now, long refreshNs) { + long dt = now - last; + last = now; + if (dt > 0 && dt < 1_000_000_000L) { + frameNs = frameNs == 0 ? dt : frameNs * 0.95 + dt * 0.05; + } + double framesPerRefresh = frameNs == 0 ? ON_ABOVE : refreshNs / frameNs; + if (worth ? framesPerRefresh < OFF_BELOW : framesPerRefresh > ON_ABOVE) { + worth = !worth; + } + return worth; + } + + /** Test-only: reset EMA + hysteresis to defaults. */ + static void reset() { + frameNs = 0; + last = 0; + worth = true; + } + + /** Test-only: read the EMA frame time in nanoseconds. */ + static double frameNs() { + return frameNs; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLod.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLod.java new file mode 100755 index 00000000..253f1fa9 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/ParticleLod.java @@ -0,0 +1,50 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.particle; + +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; + +/** + * Per-frame thinning of far particles. Particles beyond 16 blocks are kept + * every other frame (32+ blocks: every fourth) using + * {@code System.identityHashCode(particle) >>> 4} as a stable per-particle + * salt so the choice doesn't flicker. + * + *

The hot-path API takes the camera position as primitives (no + * {@code Vec3} allocation); the {@code keep(Object, double)} overload is the + * pure-logic piece, testable without Minecraft types. + * + *

The {@code tick} field (sampled once per {@code ParticleEngine.render}) lives on the + * (Stage 3) particle mixin that owns the per-frame lifecycle; the pure-logic + * distance decision is here. + */ +public enum ParticleLod { + ; + + private static final double NEAR_SQ = 16.0 * 16.0; + private static final double MID_SQ = 32.0 * 32.0; + + /** + * True when the particle should be kept this frame. The caller passes the + * particle's identity (for the per-particle salt) and the squared distance + * from the camera — both already computed by the renderer. + */ + public static boolean keep(Object particle, double distanceSq) { + if (!OptimizeToggles.particleLod()) { + return true; + } + if (distanceSq < NEAR_SQ) { + return true; + } + int h = System.identityHashCode(particle) >>> 4; + return distanceSq < MID_SQ ? (h & 1) == 0 : (h & 3) == 0; + } + + /** + * Convenience overload: compute squared distance from primitives and + * dispatch to {@link #keep(Object, double)}. + */ + public static boolean keep(Object particle, double x, double y, double z, + double camX, double camY, double camZ) { + double dx = x - camX, dy = y - camY, dz = z - camZ; + return keep(particle, dx * dx + dy * dy + dz * dz); + } +} 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 new file mode 100755 index 00000000..def3df15 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/particle/Workers.java @@ -0,0 +1,94 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.particle; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.IntConsumer; + +/** + * Fixed-size daemon pool for parallel particle extraction. The number of + * helpers is {@code max(1, min(8, cores-2))} — leaving two cores for the + * render thread and the GC. {@link #run(int, IntConsumer)} splits the work + * into {@code blocks} units (caller decides the block size), dispatches them + * across helpers AND the calling thread (the caller works too — it only ever + * waits for blocks a helper has already claimed), and returns the first + * failure or {@code null} on success. + * + *

Used by the (Stage 3) {@code QuadParticleGroup} mixin to parallelise + * particle render-state extraction when the particle count exceeds 1024 + * (Stage 1 ports the pool; the wiring is a later stage). + * + *

Pure java.util.concurrent, no Minecraft dependencies, fully testable. + */ +public final class Workers { + + public static final int COUNT = Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors() - 2)); + + private static final AtomicInteger THREAD_ID = new AtomicInteger(); + private static final ExecutorService POOL = Executors.newFixedThreadPool(COUNT, r -> { + Thread t = new Thread(r, "lovisual-opt-worker-" + THREAD_ID.incrementAndGet()); + t.setDaemon(true); + return t; + }); + + private Workers() { + } + + public static int poolSize() { + return COUNT; + } + + /** + * Run {@code task(0..blocks-1)} across the pool plus the calling thread. + * Returns the first failure, or {@code null} if all blocks succeeded. + */ + public static Throwable run(int blocks, IntConsumer task) { + if (blocks <= 0) { + return null; + } + Job job = new Job(blocks, task); + for (int h = Math.min(COUNT, blocks - 1); h > 0; h--) { + POOL.execute(job::run); + } + // The caller works too; it only ever waits for blocks a helper has already claimed. + job.run(); + while (job.completed.get() < blocks) { + Thread.onSpinWait(); + } + return job.error.get(); + } + + /** Test-only: shut the pool down so the JVM can exit cleanly. */ + public static void shutdown() { + POOL.shutdownNow(); + } + + private static final class Job { + final int blocks; + final IntConsumer task; + final AtomicInteger next = new AtomicInteger(); + final AtomicInteger completed = new AtomicInteger(); + final AtomicReference error = new AtomicReference<>(); + + Job(int blocks, IntConsumer task) { + this.blocks = blocks; + this.task = task; + } + + void run() { + int b; + while ((b = next.getAndIncrement()) < blocks) { + try { + if (error.get() == null) { + task.accept(b); + } + } catch (Throwable t) { + error.compareAndSet(null, t); + } finally { + completed.incrementAndGet(); + } + } + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableText.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableText.java new file mode 100755 index 00000000..a7ed8801 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/text/StableText.java @@ -0,0 +1,79 @@ +package dev.loki.lovisual.features.module.modules.misc.optimize.text; + +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.FormattedCharSink; + +import java.util.Collections; +import java.util.Set; +import java.util.WeakHashMap; + +/** + * Marker {@link FormattedCharSequence} wrapper that lets later code recognise + * "this text won't change" by identity and skip BIDI re-shaping. The + * {@link #mark(FormattedCharSequence)} call wraps and registers the sequence + * in a {@link WeakHashMap}-backed set; {@link #isStable(FormattedCharSequence)} + * checks membership. + * + *

The fast path is a plain {@code instanceof} + * check on the wrapper; the slow path is a synchronised set lookup. LoVisual + * keeps both — Stage 1 wires them both to {@link OptimizeToggles#microOpts()}. + */ +public final class StableText implements FormattedCharSequence { + + private static final Set STABLE = + Collections.newSetFromMap(new WeakHashMap<>()); + + private final FormattedCharSequence text; + + /** Package-private — use {@link #mark(FormattedCharSequence)} to construct. */ + StableText(FormattedCharSequence text) { + this.text = text; + } + + /** + * Wrap a {@link FormattedCharSequence} so {@link #isStable(FormattedCharSequence)} + * returns {@code true} for it. The wrapper is registered in a weak set so + * it doesn't leak when the caller drops its reference. + */ + public static FormattedCharSequence mark(FormattedCharSequence text) { + StableText stable = new StableText(text); + synchronized (STABLE) { + STABLE.add(stable); + } + return stable; + } + + /** + * True when {@code text} is known not to change (so callers can skip BIDI + * re-shape and other per-frame work). Fast path: {@code instanceof StableText} + * when {@code microOpts} is on; slow path: synchronised set lookup otherwise. + */ + public static boolean isStable(FormattedCharSequence text) { + if (OptimizeToggles.microOpts()) { + return text instanceof StableText; + } + synchronized (STABLE) { + return STABLE.contains(text); + } + } + + /** Test-only: number of currently-tracked stable sequences. */ + static int trackedCount() { + synchronized (STABLE) { + return STABLE.size(); + } + } + + /** Test-only: clear the tracked set. */ + static void clearTracked() { + synchronized (STABLE) { + STABLE.clear(); + } + } + + @Override + public boolean accept(FormattedCharSink sink) { + return text.accept(sink); + } +} 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 new file mode 100755 index 00000000..a1c9d55d --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeToggles.java @@ -0,0 +1,76 @@ +package dev.loki.lovisual.render.engine.optimize; + +/** + * Resolved per-frame toggle snapshot for the Stage 1 optimization helpers + * (LOD distances, caches, micro-opts). Mirrors {@link OptimizeState}'s shape: + * an enum-utility holder with volatile fields, written by the {@code Optimize} + * module on enable/disable and once per frame, read by render code (helpers, + * future mixins) — never by the module class itself. + * + *

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. + * + *

All module settings are resolved into volatile primitive fields once per frame. + * System-property overrides are not supported here (the {@code Optimize} module's + * {@code bool()}/{@code num()} settings are the single source of truth); they can be + * layered back via {@code -Dlovisual.opt.*} in Stage 2 per the design doc. + */ +public enum OptimizeToggles { + ; + + private static volatile boolean entityLod; + private static volatile int entityLodDistance; + private static volatile boolean particleLod; + private static volatile boolean particleLight; + private static volatile boolean particlePhysics; + private static volatile boolean spawnerCull; + private static volatile int spawnerDistance; + private static volatile boolean spawnerTickCache; + private static volatile boolean blockEntityCache; + private static volatile boolean preparedTextCache; + private static volatile boolean microOpts; + + public static boolean entityLod() { return entityLod; } + public static int entityLodDistance() { return entityLodDistance; } + public static boolean particleLod() { return particleLod; } + public static boolean particleLight() { return particleLight; } + public static boolean particlePhysics() { return particlePhysics; } + public static boolean spawnerCull() { return spawnerCull; } + public static int spawnerDistance() { return spawnerDistance; } + public static boolean spawnerTickCache() { return spawnerTickCache; } + public static boolean blockEntityCache() { return blockEntityCache; } + public static boolean preparedTextCache() { return preparedTextCache; } + public static boolean microOpts() { return microOpts; } + + /** + * Push a full snapshot. Called by {@code Optimize.onEnable/onDisable/onFrame} + * with the resolved {@code (isEnabled() && knob.get())} for every toggle. + */ + public static void apply( + boolean entityLod, int entityLodDistance, + boolean particleLod, boolean particleLight, boolean particlePhysics, + boolean spawnerCull, int spawnerDistance, + boolean spawnerTickCache, + boolean blockEntityCache, + boolean preparedTextCache, + boolean microOpts) { + OptimizeToggles.entityLod = entityLod; + OptimizeToggles.entityLodDistance = entityLodDistance; + OptimizeToggles.particleLod = particleLod; + OptimizeToggles.particleLight = particleLight; + OptimizeToggles.particlePhysics = particlePhysics; + OptimizeToggles.spawnerCull = spawnerCull; + OptimizeToggles.spawnerDistance = spawnerDistance; + OptimizeToggles.spawnerTickCache = spawnerTickCache; + OptimizeToggles.blockEntityCache = blockEntityCache; + OptimizeToggles.preparedTextCache = preparedTextCache; + OptimizeToggles.microOpts = microOpts; + } + + /** Reset to "nothing on" — used by {@code Optimize.onDisable}. */ + public static void reset() { + apply(false, 0, false, false, false, false, 0, false, false, false, false); + } +}