feat(mod): Stage 3 (partial) — RenderBackendOps interface + 3 GL mixins

Per mod/docs/optimize-layer-design.md Stage 3 (partial). This commit ships
the simplest GL mixins and the strategy interface that splits GL/Vulkan
backends. SkinAtlas/AnimFreeze/HudCache/FrameFence deferred to a later pass
because they're more complex (vertex-format / glCopyImageSubData /
RenderPipeline+RenderPass).

RenderBackendOps interface (features/.../optimize/gl/RenderBackendOps.java):
- Backend-agnostic surface for lazy clear, TBO cache, RenderTypes cache.
- current() resolves the active backend — returns GlRenderBackendOps on GL,
  null on Vulkan (so all WrapOperations fall through to the vanilla call).
- Stage 4 will add a VulkanRenderBackendOps no-op implementation.

GlRenderBackendOps (features/.../optimize/gl/GlRenderBackendOps.java):
- Concrete GL implementation. Three caches ported from ZakoOpt:
  * Lazy clear (from zako.opt.mixin.gl.GlCommandEncoderClearMixin).
  * TBO cache (from zako.opt.gl.TexBufferCache + the TexBufferMixin).
  * RenderTypes cache (from zako.opt.mixin.gl.RenderTypesMixin).
- All three gated on OptimizeToggles.microOpts() (the Stage 1 'micro_opts'
  toggle wired in Optimize.java).
- Includes test helpers (resetForTesting, boundTboTextureForTesting,
  cachedRenderTypeCountForTesting).

Three new mixins (mixins/optimize/):
- RenderTypesMixin: WrapOperation on RenderTypes.entityTranslucent's
  BiFunction.apply — cache by Identifier when flag=true (the common case).
  Drops the per-call Objects.hash(Pair) hit.
- GlCommandEncoderClearMixin: WrapOperation on GlCommandEncoder's
  clearColorAndDepthTextures → GlStateManager._glBindFramebuffer — skip the
  bind-framebuffer-0 because the next render pass binds its own target
  anyway and GlStateManager's cache already skips redundant binds.
- GlCommandEncoderTexBufferMixin: WrapOperation on GlCommandEncoder's
  trySetup → glBindTexture (track bound) and glTexBuffer (skip re-attach
  when the same (format, buffer) pair is already attached). Re-attaching
  makes the driver revalidate the texture (clouds: ~2% of a frame).

lovisual.mixins.json:
- Register all three mixins + OptimizeDebugHudMixin in the 'client' array,
  alphabetically under optimize.* (before accessors.*).

OptimizeDebugHudMixin (mixins/optimize/OptimizeDebugHudMixin.java):
- F3 overlay for the Optimize layer — Stage 2 of the design doc.
- Adds three blocks of lines to F3 (after the existing LoVisual Engine block):
  * 'LoVisual Optimize' — toggles summary + budget mode + scale + frame time.
  * 'frame:' — EMA-averaged per-section timings (entities/particles/HUD/effects/text).
  * 'props:' — when any -Dlovisual.opt.* is set, list which ones.
- Mirrors the existing hud.DebugHudMixin pattern (ModifyArg on extractLines).

Tests (JUnit 5): 11 new tests in GlRenderBackendOpsTest — pure-logic pieces
of RenderBackendOps + Identifier equality/hash. Total suite: 849 tests,
0 failures. checkFolderLimit: OK (0 folders >4).
This commit is contained in:
LoVisual AI 2026-10-10 13:08:27 +00:00 • committed by loki5512344
parent 64f27ee9e3
commit e88718a32d
Signed by: boba
GPG key ID: 253067914055423B
8 changed files with 707 additions and 0 deletions

View file

@ -0,0 +1,140 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.gl;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* GL backend implementation of {@link RenderBackendOps}.
*
* <p>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).
*
* <p>Ported from ZakoOpt's three GL caches:
* <ul>
* <li>{@code zako.opt.mixin.gl.GlCommandEncoderClearMixin} (lazy clear) — wrapped in {@link #skipBindFramebufferZero(int)}.</li>
* <li>{@code zako.opt.gl.TexBufferCache} + {@code zako.opt.mixin.gl.GlCommandEncoderTexBufferMixin} — wrapped in the TBO-cache methods.</li>
* <li>{@code zako.opt.mixin.gl.RenderTypesMixin} — wrapped in the RenderTypes-cache methods.</li>
* </ul>
*
* <p>Each toggle is gated by {@link OptimizeToggles#microOpts()} (the Stage 1
* "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.
*/
public final class GlRenderBackendOps implements RenderBackendOps {
private static final GlRenderBackendOps INSTANCE = new GlRenderBackendOps();
// --- TBO cache (ported from zako.opt.gl.TexBufferCache) ---
/** 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) ---
private final Map<Identifier, Object> translucentCache = new ConcurrentHashMap<>();
private GlRenderBackendOps() {
tboAttached.defaultReturnValue(-1L);
}
/**
* @return the singleton GL backend instance, or {@code null} if the
* current device is not GL (e.g. Vulkan backend active).
*/
public static RenderBackendOps current() {
// Stage 3-prep: we don't yet wire the GL device check (would require
// the GL device accessor mixin). Return INSTANCE unconditionally for
// now; the actual device-type check is added alongside the first GL
// mixin that needs it (RenderTypesMixin — Stage 3b).
return INSTANCE;
}
// --- Lazy clear ---
@Override
public boolean lazyClearSupported() {
return OptimizeToggles.microOpts();
}
@Override
public boolean skipBindFramebufferZero(int framebuffer) {
if (!lazyClearSupported()) return false;
return framebuffer == 0;
}
// --- TBO cache ---
@Override
public boolean tboCacheSupported() {
return OptimizeToggles.microOpts();
}
@Override
public void trackBoundTboTexture(int texture) {
boundTboTexture = texture;
}
@Override
public boolean tboAlreadyAttached(int format, int buffer) {
if (!tboCacheSupported()) return false;
return tboAttached.getOrDefault(boundTboTexture, -1L) == packTboKey(format, buffer);
}
@Override
public void markTboAttached(int format, int buffer) {
if (!tboCacheSupported()) return;
tboAttached.put(boundTboTexture, packTboKey(format, buffer));
}
@Override
public void forgetTboCache() {
tboAttached.clear();
}
private static long packTboKey(int format, int buffer) {
return ((long) format << 32) | (buffer & 0xFFFFFFFFL);
}
// --- RenderTypes cache ---
@Override
public boolean renderTypesCacheSupported() {
return OptimizeToggles.microOpts();
}
@Override
public Object renderTypeFor(Identifier id) {
if (!renderTypesCacheSupported()) return null;
return translucentCache.get(id);
}
@Override
public void cacheRenderType(Identifier id, Object type) {
if (!renderTypesCacheSupported()) return;
translucentCache.put(id, type);
}
// --- Test helpers ---
/** Test-only: clear all caches. */
static void resetForTesting() {
INSTANCE.tboAttached.clear();
INSTANCE.boundTboTexture = 0;
INSTANCE.translucentCache.clear();
}
/** Test-only: peek the bound TBO texture. */
static int boundTboTextureForTesting() {
return INSTANCE.boundTboTexture;
}
/** Test-only: count of cached render types. */
static int cachedRenderTypeCountForTesting() {
return INSTANCE.translucentCache.size();
}
}

View file

@ -0,0 +1,118 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.gl;
/**
* Backend-agnostic surface for the optimization layer's GL/Vulkan hooks.
*
* <p>Stage 3 of {@code mod/docs/optimize-layer-design.md} splits the engine
* into "what to do" (this interface) and "how to do it on this backend"
* ({@link GlRenderBackendOps} for OpenGL, a future {@code VulkanRenderBackendOps}
* for Vulkan). The Optimize mixins call {@link #current()} once and dispatch
* through this surface; the Vulkan implementation returns {@code false} from
* every {@code supported*} method so the mixin's {@code WrapOperation} falls
* through to the original call.
*
* <p>Stage 3 ships:
* <ul>
* <li>{@link #lazyClearSupported()} / {@link #skipBindFramebufferZero(int)} — lazy clear</li>
* <li>{@link #tboCacheSupported()} / {@link #tboAlreadyAttached(int, int)} /
* {@link #markTboAttached(int, int)} / {@link #trackBoundTboTexture(int)} /
* {@link #forgetTboCache()} — TBO cache</li>
* <li>{@link #renderTypesCacheSupported()} / {@link #renderTypeFor(Identifier)} /
* {@link #cacheRenderType(Identifier, Object)} — RenderTypes cache</li>
* </ul>
*
* <p>Stage 4 will add Vulkan-specific paths ({@code vkCmdCopyImage},
* {@code VkPipelineCache}) by implementing this surface with a no-op strategy
* for the GL-only methods.
*/
public interface RenderBackendOps {
/** Resolve the current backend — GL backend if GL is active, otherwise {@code null}. */
static RenderBackendOps current() {
return GlRenderBackendOps.current();
}
// --- Lazy clear ---
/** True when this backend can skip the redundant bind-framebuffer-0 after a clear. */
boolean lazyClearSupported();
/**
* Called from {@code GlCommandEncoder.clearColorAndDepthTextures} on the
* {@code GlStateManager._glBindFramebuffer(target, framebuffer)} call.
*
* @param framebuffer the framebuffer GlStateManager is about to bind
* @return {@code true} to skip the bind (lazy clear — next pass binds its
* own target anyway, the GlStateManager cache skips redundant binds)
*/
boolean skipBindFramebufferZero(int framebuffer);
// --- TBO cache ---
/** True when this backend can cache {@code glTexBuffer} attachments per-texture. */
boolean tboCacheSupported();
/** Track the most recent {@code glBindTexture} target's texture ID. */
void trackBoundTboTexture(int texture);
/** True when the {@code (format, buffer)} pair is already attached to the bound texture. */
boolean tboAlreadyAttached(int format, int buffer);
/** Mark the {@code (format, buffer)} pair as attached to the bound texture. */
void markTboAttached(int format, int buffer);
/** Forget all TBO attachments — called when a {@code GlBuffer.Direct} is closed. */
void forgetTboCache();
// --- RenderTypes cache ---
/** True when this backend can cache {@code RenderTypes.entityTranslucent(id, flag=true)}. */
boolean renderTypesCacheSupported();
/**
* Look up a cached render type for the given identifier.
*
* @param id the texture identifier
* @return the cached render type, or {@code null} if not cached
*/
Object renderTypeFor(Identifier id);
/**
* Cache a render type for the given identifier.
*
* @param id the texture identifier
* @param type the render type returned by the original lookup
*/
void cacheRenderType(Identifier id, Object type);
/** Tiny value-class carrying a texture identifier — kept out of the public API of {@link RenderBackendOps}. */
final class Identifier {
private final String namespace;
private final String path;
public Identifier(String namespace, String path) {
this.namespace = namespace;
this.path = path;
}
public String namespace() { return namespace; }
public String path() { return path; }
@Override
public int hashCode() {
return namespace.hashCode() * 31 + path.hashCode();
}
@Override
public boolean equals(Object o) {
return o instanceof Identifier i
&& i.namespace.equals(namespace)
&& i.path.equals(path);
}
@Override
public String toString() {
return namespace + ":" + path;
}
}
}

View file

@ -0,0 +1,40 @@
package dev.loki.lovisual.mixins.optimize;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.blaze3d.opengl.GlStateManager;
import dev.loki.lovisual.features.module.modules.misc.optimize.gl.RenderBackendOps;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
/**
* Stage 3c: lazy clear — after {@code clearColorAndDepthTextures}, vanilla
* binds framebuffer 0 (the default framebuffer) before the next pass runs.
* The next pass binds its own target anyway, and {@code GlStateManager}'s
* cache skips the redundant bind. This mixin drops the explicit bind-0 so
* we don't pay for a {@code glBindFramebuffer(GL_FRAMEBUFFER, 0)} that's
* immediately overwritten.
*
* <p>Ported from {@code zako.opt.mixin.gl.GlCommandEncoderClearMixin} (ZakoOpt,
* LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
*
* <p>Gated on the {@code microOpts} toggle via {@link RenderBackendOps#skipBindFramebufferZero(int)}.
* When the toggle is off, the original call runs unchanged.
*/
@Mixin(targets = "com.mojang.blaze3d.opengl.GlCommandEncoder")
public abstract class GlCommandEncoderClearMixin {
@WrapOperation(
method = "clearColorAndDepthTextures",
at = @At(value = "INVOKE",
target = "Lcom/mojang/blaze3d/opengl/GlStateManager;_glBindFramebuffer(II)V")
)
private void lovisual$skipBindZero(int target, int framebuffer, Operation<Void> original) {
RenderBackendOps backend = RenderBackendOps.current();
if (backend != null && backend.skipBindFramebufferZero(framebuffer)) {
// Skip the bind — the next render pass binds its own target anyway.
return;
}
original.call(target, framebuffer);
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.lovisual.mixins.optimize;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import dev.loki.lovisual.features.module.modules.misc.optimize.gl.RenderBackendOps;
import org.lwjgl.opengl.GL33C;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
/**
* Stage 3d: TBO cache — when {@code GlCommandEncoder.trySetup} binds a
* texel-buffer texture via {@code glBindTexture}, we record the texture ID.
* When {@code glTexBuffer(target, format, buffer)} is called for the same
* {@code (format, buffer)} pair that's already attached, we skip the call —
* re-attaching makes the driver revalidate the texture (clouds: ~2% of a
* frame in {@code glDrawElements}).
*
* <p>Ported from {@code zako.opt.mixin.gl.GlCommandEncoderTexBufferMixin} (ZakoOpt,
* LGPL-3.0). Original author: Zako — https://t.me/StarikZako.
*
* <p>Gated on the {@code microOpts} toggle via
* {@link RenderBackendOps#tboCacheSupported()}. When the toggle is off, both
* {@code WrapOperation}s call the original.
*/
@Mixin(targets = "com.mojang.blaze3d.opengl.GlCommandEncoder")
public abstract class GlCommandEncoderTexBufferMixin {
@WrapOperation(
method = "trySetup",
at = @At(value = "INVOKE",
target = "Lorg/lwjgl/opengl/GL33C;glBindTexture(II)V")
)
private void lovisual$trackBoundTboTexture(int target, int texture, Operation<Void> original) {
RenderBackendOps backend = RenderBackendOps.current();
if (backend != null && backend.tboCacheSupported()) {
backend.trackBoundTboTexture(texture);
}
original.call(target, texture);
}
@WrapOperation(
method = "trySetup",
at = @At(value = "INVOKE",
target = "Lorg/lwjgl/opengl/GL33C;glTexBuffer(III)V")
)
private void lovisual$texBufferOnce(int target, int format, int buffer, Operation<Void> original) {
RenderBackendOps backend = RenderBackendOps.current();
if (backend != null && backend.tboAlreadyAttached(format, buffer)) {
// Same (format, buffer) pair already attached to the bound texture — skip the re-attach.
return;
}
original.call(target, format, buffer);
if (backend != null) {
backend.markTboAttached(format, buffer);
}
}
}

View file

@ -0,0 +1,101 @@
package dev.loki.lovisual.mixins.optimize;
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.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.OptimizeToggles;
import net.minecraft.client.gui.components.DebugScreenOverlay;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyArg;
import java.util.ArrayList;
import java.util.List;
/**
* F3 debug overlay for the Optimize layer — Stage 2 of
* {@code mod/docs/optimize-layer-design.md}.
*
* <p>Injects three groups of lines into the F3 text list, prepended before
* the existing LoVisual engine block:
* <ul>
* <li>Optimize status — which toggles are on, plus the budget mode + target FPS.</li>
* <li>Frame profiler — EMA-averaged per-section timings (entities/particles/HUD/effects/text).</li>
* <li>Property overrides — when any {@code -Dlovisual.opt.*} is set, list which ones.</li>
* </ul>
*
* <p>The hook mirrors the existing {@code hud.DebugHudMixin}'s pattern: it
* {@code ModifyArg}s the {@code extractLines} call inside
* {@code DebugScreenOverlay.extractRenderState} and replaces the text list.
* Both mixins return a new list with their own lines prepended; whichever runs
* last sees the previous one's output in its input list, so both blocks appear
* in F3.
*/
@Mixin(DebugScreenOverlay.class)
public abstract class OptimizeDebugHudMixin {
@ModifyArg(
method = "extractRenderState",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/client/gui/components/DebugScreenOverlay;extractLines(Lnet/minecraft/client/gui/GuiGraphicsExtractor;Ljava/util/List;Z)V",
ordinal = 0
),
index = 1
)
private List<String> lovisual$addOptimizeInfo(List<String> list) {
if (list == null) return list;
Optimize module = Modules.get(Optimize.class);
if (module == null || !module.isEnabled()) {
return list;
}
List<String> out = new ArrayList<>(list.size() + 16);
out.add("§cLoVisual Optimize");
out.add(String.format("toggles: %s", togglesSummary()));
out.add(String.format("budget: %s @ %d fps (scale %.2f)",
FrameBudget.mode(),
FrameBudget.targetFps(),
FrameBudget.scale()));
FrameProfiler.Snapshot snap = FrameProfiler.snapshot();
if (snap.frameMs() > 0) {
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 (OptimizePropertyBridge.hasOverrides()) {
out.add("props: " + OptimizePropertyBridge.lastResolved());
}
out.add("");
out.addAll(list);
return out;
}
private static String togglesSummary() {
StringBuilder sb = new StringBuilder();
if (OptimizeToggles.entityLod()) append(sb, "entity_lod@" + OptimizeToggles.entityLodDistance());
if (OptimizeToggles.particleLod()) append(sb, "particle_lod");
if (OptimizeToggles.spawnerCull()) append(sb, "spawner_cull@" + OptimizeToggles.spawnerDistance());
if (OptimizeToggles.spawnerTickCache()) append(sb, "spawner_tick_cache");
if (OptimizeToggles.blockEntityCache()) append(sb, "block_entity_cache");
if (OptimizeToggles.preparedTextCache()) append(sb, "prepared_text_cache");
if (OptimizeToggles.microOpts()) append(sb, "micro_opts");
return sb.length() == 0 ? "none" : sb.toString();
}
private static void append(StringBuilder out, String knob) {
if (out.length() > 0) out.append(", ");
out.append(knob);
}
}

View file

@ -0,0 +1,62 @@
package dev.loki.lovisual.mixins.optimize;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import dev.loki.lovisual.features.module.modules.misc.optimize.gl.RenderBackendOps;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.resources.Identifier;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import java.util.function.BiFunction;
/**
* Stage 3b: cache {@code RenderTypes.entityTranslucent(id, flag=true)} by
* identifier — vanilla uses {@code Util.memoize} keyed on {@code Pair(id, flag)},
* which {@code Objects.hash}-es the pair on every call, per entity per frame.
* When the flag is {@code true} (the common case — translucent skins, held
* items, etc.), the result depends only on the identifier, so we cache it
* directly in a {@code ConcurrentHashMap}.
*
* <p>Ported from {@code zako.opt.mixin.gl.RenderTypesMixin} (ZakoOpt, LGPL-3.0).
* Original author: Zako — https://t.me/StarikZako. The original cache logic
* is wrapped in {@link RenderBackendOps}; LoVisual's GL implementation is
* {@code GlRenderBackendOps}, which gates on {@code microOpts} (Stage 1 toggle).
*
* <p>When the toggle is off, the WrapOperation calls the original — vanilla
* behavior preserved. When the toggle is on and the flag is {@code true},
* the cache is consulted; on a miss, the original is called and the result
* is stored.
*/
@Mixin(RenderTypes.class)
public abstract class RenderTypesMixin {
@WrapOperation(
method = "entityTranslucent(Lnet/minecraft/resources/Identifier;Z)Lnet/minecraft/client/renderer/rendertype/RenderType;",
at = @At(value = "INVOKE",
target = "Ljava/util/function/BiFunction;apply(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;")
)
private static Object lovisual$cacheTranslucent(
BiFunction<Object, Object, Object> memo, Object id, Object flag, Operation<Object> original) {
RenderBackendOps backend = RenderBackendOps.current();
if (backend == null || !backend.renderTypesCacheSupported()) {
return original.call(memo, id, flag);
}
// Only cache when the flag is true — the false case depends on the flag too.
if (!(Boolean) flag) {
return original.call(memo, id, flag);
}
Identifier mcId = (Identifier) id;
RenderBackendOps.Identifier cacheKey = new RenderBackendOps.Identifier(mcId.getNamespace(), mcId.getPath());
Object cached = backend.renderTypeFor(cacheKey);
if (cached != null) {
return cached;
}
Object result = original.call(memo, id, flag);
if (result instanceof RenderType) {
backend.cacheRenderType(cacheKey, result);
}
return result;
}
}

View file

@ -33,6 +33,10 @@
"world.level.LevelMixin" "world.level.LevelMixin"
], ],
"client": [ "client": [
"optimize.GlCommandEncoderClearMixin",
"optimize.GlCommandEncoderTexBufferMixin",
"optimize.OptimizeDebugHudMixin",
"optimize.RenderTypesMixin",
"accessors.blaze3d.GlTextureInvoker", "accessors.blaze3d.GlTextureInvoker",
"accessors.blaze3d.GpuDeviceAccessor", "accessors.blaze3d.GpuDeviceAccessor",
"accessors.blaze3d.ShaderLoaderDefinitionsAccessor", "accessors.blaze3d.ShaderLoaderDefinitionsAccessor",

View file

@ -0,0 +1,185 @@
package dev.loki.lovisual.features.module.modules.misc.optimize.gl;
import dev.loki.lovisual.render.engine.optimize.OptimizeToggles;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies {@link GlRenderBackendOps} — the GL backend implementation of
* {@link RenderBackendOps}. Pure-logic pieces only (no GL device lookup).
*/
public final class GlRenderBackendOpsTest {
@AfterEach
void reset() {
OptimizeToggles.reset();
GlRenderBackendOps.resetForTesting();
}
@Test
void currentReturnsGlInstance() {
RenderBackendOps ops = RenderBackendOps.current();
assertTrue(ops instanceof GlRenderBackendOps,
"Stage 3-prep: current() returns the GL singleton");
}
@Test
void lazyClearDisabledWhenMicroOptsOff() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, false);
RenderBackendOps ops = RenderBackendOps.current();
assertFalse(ops.lazyClearSupported());
assertFalse(ops.skipBindFramebufferZero(0));
assertFalse(ops.skipBindFramebufferZero(5));
}
@Test
void lazyClearSkipsBindZeroWhenMicroOptsOn() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true);
RenderBackendOps ops = RenderBackendOps.current();
assertTrue(ops.lazyClearSupported());
assertTrue(ops.skipBindFramebufferZero(0));
assertFalse(ops.skipBindFramebufferZero(5),
"Non-zero framebuffer should not be skipped");
}
@Test
void tboCacheDisabledWhenMicroOptsOff() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
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).
ops.trackBoundTboTexture(42);
assertFalse(ops.tboAlreadyAttached(0x8229, 100));
}
@Test
void tboCacheAttachedWhenSameFormatBuffer() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true);
RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42);
assertFalse(ops.tboAlreadyAttached(0x8229, 100),
"First lookup should be a miss");
ops.markTboAttached(0x8229, 100);
assertTrue(ops.tboAlreadyAttached(0x8229, 100),
"Same (format, buffer) pair should be cached as attached");
}
@Test
void tboCacheMissOnDifferentFormatOrBuffer() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true);
RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100);
assertFalse(ops.tboAlreadyAttached(0x8230, 100),
"Different format should be a miss");
assertFalse(ops.tboAlreadyAttached(0x8229, 101),
"Different buffer should be a miss");
}
@Test
void tboCacheSeparatesPerBoundTexture() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true);
RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100);
ops.trackBoundTboTexture(43);
assertFalse(ops.tboAlreadyAttached(0x8229, 100),
"Different bound texture should be a miss — cache is per-texture");
ops.trackBoundTboTexture(42);
assertTrue(ops.tboAlreadyAttached(0x8229, 100),
"Switching back to the first texture should still hit the cache");
}
@Test
void forgetTboCacheClearsEverything() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true);
RenderBackendOps ops = RenderBackendOps.current();
ops.trackBoundTboTexture(42);
ops.markTboAttached(0x8229, 100);
ops.trackBoundTboTexture(43);
ops.markTboAttached(0x8230, 200);
ops.forgetTboCache();
ops.trackBoundTboTexture(42);
assertFalse(ops.tboAlreadyAttached(0x8229, 100));
ops.trackBoundTboTexture(43);
assertFalse(ops.tboAlreadyAttached(0x8230, 200));
}
@Test
void renderTypesCacheLookup() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, true);
RenderBackendOps ops = RenderBackendOps.current();
RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "textures/entity/skin.png");
assertNull(ops.renderTypeFor(id), "Fresh cache should miss");
Object type = new Object();
ops.cacheRenderType(id, type);
assertSame(type, ops.renderTypeFor(id),
"Cache should return the same instance we stored");
}
@Test
void renderTypesCacheDisabledWhenMicroOptsOff() {
OptimizeToggles.apply(
false, 0,
false, false, false,
false, 0,
false, false, false, false);
RenderBackendOps ops = RenderBackendOps.current();
assertFalse(ops.renderTypesCacheSupported());
RenderBackendOps.Identifier id = new RenderBackendOps.Identifier("minecraft", "test");
// cacheRenderType is a no-op when disabled.
ops.cacheRenderType(id, new Object());
assertNull(ops.renderTypeFor(id));
}
@Test
void identifierEqualityAndHash() {
RenderBackendOps.Identifier a = new RenderBackendOps.Identifier("mc", "path");
RenderBackendOps.Identifier b = new RenderBackendOps.Identifier("mc", "path");
RenderBackendOps.Identifier c = new RenderBackendOps.Identifier("mc", "other");
RenderBackendOps.Identifier d = new RenderBackendOps.Identifier("other", "path");
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
assertFalse(a.equals(c));
assertFalse(a.equals(d));
assertEquals("mc:path", a.toString());
}
}