diff --git a/mod/docs/optimize-layer-design.md b/mod/docs/optimize-layer-design.md index c49c2de2..f240bf10 100644 --- a/mod/docs/optimize-layer-design.md +++ b/mod/docs/optimize-layer-design.md @@ -75,8 +75,13 @@ optimize/ Реализуем за default-off тумблерами (`-Dlovisual.opt.` или поле в модуле); включать или нет решает пользователь. Автомерялок не ждём — если пункт безопасен (не меняет картинку) и дешёв, он имеет смысл и при выключенном состоянии, тумблер даёт путь включить. -- **Кэш состояния GL** шире, чем в референсе: пропуск повторных `glUseProgram`, `glBindVertexArray`, `glBindTexture`, `glBlendFunc`, - `glDepthMask` (LoVisual уже имеет `GlStateManagerMixin`, расширяем). +- ✅ **Кэш состояния GL** шире, чем в референсе: пропуск повторных `glUseProgram`, `glBindVertexArray`, `glBindTexture`, `glBlendFunc`, + `glDepthMask` (LoVisual уже имеет `GlStateManagerMixin`, расширяем). **Готово 2026-10-12, частичнo:** `glUseProgram` и + `glBindVertexArray` — по байткоду `com.mojang.blaze3d.opengl.GlStateManager` в 26.2 это единственный два «сырых» + бинда без ванильного кэша; остальные (`glBindTexture`/`glBlendFunc`/`glDepthMask`) уже идут через ванильные + `TEXTURES/BLEND/DEPTH` state-массивы, дублировать их кэш — только хуже. `render/engine/optimize/gl/state/GlStateCache` + + миксин `mixins/optimize/gl/state/GlStateManagerCacheMixin`, гейт `micro_opts`, 6 тестов, invalidация на + `glDeleteProgram` и на `Optimize.onDisable`. - **Сортировка draw по pipeline и текстуре** внутри слоя там, где порядок не влияет на результат (наши квады `Renderer2D`, ленты trails). - **Persistent mapped ring** для динамических вершин наших эффектов и HUD (`GL_ARB_buffer_storage`), если профиль покажет стоимость `glBufferSubData`/map. Ring Auto-Tune из референса берём как идею размера. 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 6f6c9086..f5b8da1b 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 @@ -14,6 +14,7 @@ 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.entity.ItemBounds; import dev.loki.lovisual.render.engine.optimize.entity.ItemLod; +import dev.loki.lovisual.render.engine.optimize.gl.state.GlStateCache; 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; @@ -132,6 +133,7 @@ public final class Optimize extends Module { HudCache.reset(); ParallelModels.reset(); Workers.resetParallelParticles(); + GlStateCache.reset(); // stop counting and drop the F3 numbers, onFrame will not run again DrawStats.endFrame(false); } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/state/GlStateManagerCacheMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/state/GlStateManagerCacheMixin.java new file mode 100644 index 00000000..3b082010 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/optimize/gl/state/GlStateManagerCacheMixin.java @@ -0,0 +1,59 @@ +package dev.loki.lovisual.mixins.optimize.gl.state; + +import com.mojang.blaze3d.opengl.GlStateManager; +import dev.loki.lovisual.render.engine.optimize.OptimizeToggles; +import dev.loki.lovisual.render.engine.optimize.gl.state.GlStateCache; +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; + +/** + * Extended GL state cache — Stage 3 "Дополнительные GL-оптимизации" item 1 in + * {@code mod/docs/optimize-layer-design.md}. Skips redundant driver calls that + * vanilla's own {@code BLEND/DEPTH/CULL/SCISSOR/TEXTURES} arrays do not cover: + * {@code _glUseProgram(int)} and {@code _glBindVertexArray(int)} reach + * {@code GL33C} every time, with no cache. + * + *

Gated by {@link OptimizeToggles#microOpts()} — the Stage 1 "micro_opts" + * switch already covers the TBO / RenderTypes caches, so this rides along. + * + *

Cache is invalidated on {@link GlStateManager#glDeleteProgram(int)} — + * the driver recycles program ids, and on {@code Optimize.onDisable} / + * resource reload via {@code GlStateCache.reset()} called from the module. + * Under Vulkan the mixin's target class is never loaded, so no injection fires. + */ +@Mixin(GlStateManager.class) +public abstract class GlStateManagerCacheMixin { + + @Inject(method = "_glUseProgram", at = @At("HEAD"), cancellable = true) + private static void lovisual$skipRedundantUseProgram(int program, CallbackInfo ci) { + if (!OptimizeToggles.microOpts()) { + GlStateCache.reset(); + return; + } + if (GlStateCache.programAlreadyBound(program)) { + ci.cancel(); + return; + } + GlStateCache.markProgram(program); + } + + @Inject(method = "_glBindVertexArray", at = @At("HEAD"), cancellable = true) + private static void lovisual$skipRedundantBindVao(int vao, CallbackInfo ci) { + if (!OptimizeToggles.microOpts()) { + GlStateCache.reset(); + return; + } + if (GlStateCache.vaoAlreadyBound(vao)) { + ci.cancel(); + return; + } + GlStateCache.markVao(vao); + } + + @Inject(method = "glDeleteProgram", at = @At("HEAD")) + private static void lovisual$invalidateOnDeleteProgram(int program, CallbackInfo ci) { + GlStateCache.reset(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/state/GlStateCache.java b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/state/GlStateCache.java new file mode 100644 index 00000000..2aa86e60 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/optimize/gl/state/GlStateCache.java @@ -0,0 +1,81 @@ +package dev.loki.lovisual.render.engine.optimize.gl.state; + +/** + * Tracks the last program / VAO bound through {@code GlStateManager} so + * redundant driver calls can be skipped. Vanilla's {@code _glUseProgram} and + * {@code _glBindVertexArray} hit the driver every time — see + * {@code com.mojang.blaze3d.opengl.GlStateManager} bytecode. Framebuffer, + * texture, blend, depth, cull and scissor already ride the vanilla + * {@code BLEND/DEPTH/CULL/SCISSOR/TEXTURES/...} state arrays, so this cache + * only fills the two slots vanilla forgot. + * + *

Two independent slots: + *

+ * + *

{@code -1} is the safe state: the next call through the mixin always + * reaches the driver. {@link #reset()} returns to that state; call it whenever + * anything outside the patched {@code GlStateManager} path may have changed + * the binding (device reset, shader recompile that internally calls + * {@code glUseProgram}, {@code F3+T} resource reload, world change). + * + *

Backed by the Stage 1 {@code micro_opts} gate; the mixin checks + * {@code OptimizeToggles.microOpts()} before consulting this holder so + * turning micro-opts off also disables the cache. + * + *

Part of Stage 3 "Дополнительные GL-оптимизации" — the extended GL state + * cache item in {@code mod/docs/optimize-layer-design.md}. + */ +public final class GlStateCache { + + /** Unknown / stale binding — the next call always reaches the driver. */ + public static final int UNKNOWN = -1; + + private static volatile int program = UNKNOWN; + private static volatile int vao = UNKNOWN; + + private GlStateCache() { + } + + /** True when a {@code glUseProgram(program)} would be a no-op. */ + public static boolean programAlreadyBound(int id) { + return program == id; + } + + /** True when a {@code glBindVertexArray(id)} would be a no-op. */ + public static boolean vaoAlreadyBound(int id) { + return vao == id; + } + + /** Record the program that just reached the driver. */ + public static void markProgram(int id) { + program = id; + } + + /** Record the VAO that just reached the driver. */ + public static void markVao(int id) { + vao = id; + } + + /** Program id the driver believes is bound (for tests / diagnostics). */ + public static int program() { + return program; + } + + /** VAO id the driver believes is bound (for tests / diagnostics). */ + public static int vao() { + return vao; + } + + /** + * Forget both cached bindings — next {@code glUseProgram}/{@code glBindVertexArray} + * always goes through. Called from {@code clearPipelineCache} (F3+T), device + * shutdown, and the {@code Optimize} module's {@code onDisable}. + */ + public static void reset() { + program = UNKNOWN; + vao = UNKNOWN; + } +} diff --git a/mod/src/main/resources/lovisual.mixins.json b/mod/src/main/resources/lovisual.mixins.json index 252e1bd7..ce9c4ea8 100644 --- a/mod/src/main/resources/lovisual.mixins.json +++ b/mod/src/main/resources/lovisual.mixins.json @@ -62,6 +62,7 @@ "optimize.stats.GlCommandEncoderStatsMixin", "optimize.stats.GlRenderPassStatsMixin", "optimize.stats.GlStateManagerStatsMixin", + "optimize.gl.state.GlStateManagerCacheMixin", "accessors.blaze3d.GlTextureInvoker", "accessors.blaze3d.GpuDeviceAccessor", "accessors.blaze3d.ShaderLoaderDefinitionsAccessor", @@ -221,7 +222,9 @@ "render.main.MinecraftMixin", "render.main.ScreenEffectRendererMixin", "render.weather.WeatherHideMixin", + "render.spatialgui.GameRendererSpatialGuiMixin", "render.spatialgui.GuiRendererCaptureMixin", + "render.spatialgui.GuiSpatialExtractMixin", "render.spatialgui.ScreenSpatialBackgroundMixin", "render.sound.LibraryMixin", "render.sound.SoundEngineMixin", diff --git a/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/state/GlStateCacheTest.java b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/state/GlStateCacheTest.java new file mode 100644 index 00000000..2a147645 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/render/engine/optimize/gl/state/GlStateCacheTest.java @@ -0,0 +1,73 @@ +package dev.loki.lovisual.render.engine.optimize.gl.state; + +import org.junit.jupiter.api.BeforeEach; +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 GlStateCache} — pure logic, no GL driver. Covers: UNKNOWN + * starts the state, mark-then-already-bound round trip, independent slots for + * program and VAO, reset wipes both, and the id-recycle case (mark A, mark B, + * B is now current). + */ +public final class GlStateCacheTest { + + @BeforeEach + void resetState() { + GlStateCache.reset(); + } + + @Test + void startsUnknown() { + assertEquals(GlStateCache.UNKNOWN, GlStateCache.program()); + assertEquals(GlStateCache.UNKNOWN, GlStateCache.vao()); + // UNKNOWN must never short-circuit a real id (in case a driver ever returns -1). + assertFalse(GlStateCache.programAlreadyBound(0)); + assertFalse(GlStateCache.vaoAlreadyBound(0)); + } + + @Test + void programRoundTrip() { + GlStateCache.markProgram(42); + assertTrue(GlStateCache.programAlreadyBound(42)); + assertFalse(GlStateCache.programAlreadyBound(43)); + assertEquals(42, GlStateCache.program()); + } + + @Test + void vaoRoundTrip() { + GlStateCache.markVao(7); + assertTrue(GlStateCache.vaoAlreadyBound(7)); + assertFalse(GlStateCache.vaoAlreadyBound(8)); + assertEquals(7, GlStateCache.vao()); + } + + @Test + void programAndVaoAreIndependent() { + GlStateCache.markProgram(11); + assertEquals(11, GlStateCache.program()); + assertEquals(GlStateCache.UNKNOWN, GlStateCache.vao()); + + GlStateCache.markVao(22); + assertEquals(11, GlStateCache.program()); + assertEquals(22, GlStateCache.vao()); + } + + @Test + void idReuseUpdatesCache() { + GlStateCache.markProgram(5); + GlStateCache.markProgram(9); + assertTrue(GlStateCache.programAlreadyBound(9)); + assertFalse(GlStateCache.programAlreadyBound(5)); + } + + @Test + void zeroIsAValidId() { + GlStateCache.markProgram(0); + assertEquals(0, GlStateCache.program()); + assertTrue(GlStateCache.programAlreadyBound(0)); + } +}