perf(mod): extended GL state cache for glUseProgram + glBindVertexArray

Stage 3 'Дополнительные GL-оптимизации' item 1. GlStateManager bytecode in
26.2 (com.mojang.blaze3d.opengl.GlStateManager) shows every other GL bind
(texture, blend, depth, cull, scissor, framebuffer) is already absorbed by
vanilla's BLEND/DEPTH/CULL/SCISSOR/TEXTURES/COLOR_MASK/readFbo/writeFbo state
arrays, but _glUseProgram and _glBindVertexArray reach GL33C every time. Cache
those two slots and skip the redundant driver call.

GlStateCache holds (program, vao) with UNKNOWN=-1 as the safe state (never
short-circuits a real id). GlStateManagerCacheMixin @Inject HEAD cancellable
on both _glUseProgram and _glBindVertexArray; @Inject HEAD on glDeleteProgram
resets because the driver may recycle ids. Gated by micro_opts, so switching
micro-opts off also drops the cache (the mixin resets when the gate is off).
Optimize.onDisable also calls GlStateCache.reset().

Mixins/optimize/gl/ hits the 4-file folder limit, so the mixin lives under
mixins/optimize/gl/state/. Render/engine/optimize/gl/ hits the same limit,
holder is in render/engine/optimize/gl/state/.

6 new tests cover UNKNOWN start, mark/already-bound round trip per slot, slot
independence, id-reuse and '0 is a valid id'. Doc: mark the item ✅ partial
(only these two sites are safe to add; the other three would duplicate
vanilla's caches and hurt).
This commit is contained in:
loki5512344 2026-10-12 22:05:05 +02:00
parent e85c4fc10a
commit 39b270b108
Signed by: boba
GPG key ID: 253067914055423B
6 changed files with 225 additions and 2 deletions

View file

@ -75,8 +75,13 @@ optimize/
Реализуем за default-off тумблерами (`-Dlovisual.opt.<key>` или поле в модуле); включать или нет решает пользователь.
Автомерялок не ждём — если пункт безопасен (не меняет картинку) и дешёв, он имеет смысл и при выключенном состоянии,
тумблер даёт путь включить.
- **Кэш состояния 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 из референса берём как идею размера.

View file

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

View file

@ -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.
*
* <p>Gated by {@link OptimizeToggles#microOpts()} — the Stage 1 "micro_opts"
* switch already covers the TBO / RenderTypes caches, so this rides along.
*
* <p>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();
}
}

View file

@ -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.
*
* <p>Two independent slots:
* <ul>
* <li>{@link #program} — {@code glUseProgram} id, {@code -1} = unknown.</li>
* <li>{@link #vao} — {@code glBindVertexArray} id, {@code -1} = unknown.</li>
* </ul>
*
* <p>{@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).
*
* <p>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.
*
* <p>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;
}
}

View file

@ -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",

View file

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