wip(spatialgui): isolate screen render state via second GuiRenderer
Some checks failed
CI / backend (cargo test + clippy) (push) Failing after 1m19s
CI / frontend (lint + test + build) (push) Failing after 27s
CI / mod (gradle test + checkFolderLimit) (push) Successful in 1m56s

Also repairs origin/main: commit 39b270b1 accidentally registered two
SpatialGui mixins in lovisual.mixins.json without committing their
sources; this commit ships them.

Refactors the SpatialGui capture path to follow the upstream
Spatial-GUI architecture. Instead of cancelling and re-targeting the
main GuiRenderer.draw() pass (which required shadowing private fields
and rebuilding the projection matrices), the open screen is now
extracted into an isolated GuiRenderState + GuiRenderer pair owned by
SpatialGuiCapture:

- GuiSpatialExtractMixin @Redirects Screen.extractRenderStateWithTooltipAndSubtitles
  inside Gui.extractRenderState: while SpatialGui.isCapturing(), the
  screen is extracted into SpatialGuiCapture.extractScreen() using a
  private GuiRenderState instead of the vanilla main state, so the HUD
  pass no longer sees the screen content.
- GameRendererSpatialGuiMixin injects twice around GameRenderer.render:
  just before GuiRenderer.render() it resizes and clears the capture
  texture to transparent from the frame's WindowRenderState, and at
  TAIL it runs the isolated renderer's render()+endFrame() so the
  screen's draw list executes into the capture texture once the main
  HUD pass is done.
- GuiRendererCaptureMixin is now a @ModifyArg on the target argument of
  executeDrawRange inside GuiRenderer.draw(): if the instance is the
  isolated one, the target is swapped for SpatialGuiCapture.target();
  the vanilla main renderer keeps its normal framebuffer untouched.

SpatialGuiCapture gains screenRenderState, screenGuiRenderer, and the
extractScreen/renderIsolatedFrame/clearTransparent/target helpers.
SpatialGui.onRenderWorldEngine gets DebugLog.renderThreadOnChange
traces at each early-return branch (noLevel, closed, captureNotReady,
noTextureView, alpha0, published, closing, mesh) so plane publishing
can be diagnosed from a log without a debugger.

Docstring in SpatialGui updated to describe the new pipeline. Message
kept wip: this pass compiles clean and is behaviour-plausible, but I
have not run it against a live Minecraft session this turn.
This commit is contained in:
loki5512344 2026-10-12 23:05:30 +02:00
parent 4fce425dcd
commit 0f1709f25c
Signed by: boba
GPG key ID: 253067914055423B
5 changed files with 227 additions and 84 deletions

View file

@ -24,6 +24,7 @@ import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.spatialgui.SpatialGuiCapture;
import dev.loki.lovisual.render.spatialgui.SpatialGuiPlane;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.Camera;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
@ -38,10 +39,12 @@ import org.joml.Vector3fc;
* porting the core idea of Spatial GUI (https://github.com/tastytrash/Spatial-GUI, MIT) to LoVisual's own
* module/config system and its deferred GUI-render pipeline.
* <p>
* The actual off-screen capture happens in {@code GuiRendererCaptureMixin} (which redirects the vanilla
* {@code GuiRenderer.draw()} pass into {@link SpatialGuiCapture} instead of the main render target while this
* module is active), and mouse routing happens in {@code MouseHandlerSpatialGuiMixin} (which reprojects the
* real cursor position onto the plane published every frame via {@link SpatialGuiPlane}).
* The off-screen capture follows the reference architecture: {@code GuiSpatialExtractMixin} diverts the open
* screen's extraction from the vanilla main GUI state into {@link SpatialGuiCapture}'s isolated state,
* {@code GameRendererSpatialGuiMixin} clears the capture texture and runs the isolated renderer at the end of
* the frame, and {@code GuiRendererCaptureMixin} swaps that renderer's draw target for the capture texture —
* mouse routing happens in {@code MouseHandlerSpatialGuiMixin} (which reprojects the real cursor position onto
* the plane published every frame via {@link SpatialGuiPlane}).
*/
@ModuleInfo(
id = "spatialgui",
@ -168,23 +171,29 @@ public class SpatialGui extends Module {
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null || mc.level == null) return;
if (mc.player == null || mc.level == null) {
DebugLog.renderThreadOnChange("sg.plane", "noLevel", "[SpatialGui] plane skipped: no level");
return;
}
boolean liveScreen = trackedScreen != null;
float progress = animationProgress();
if (progress <= 0.0F && !liveScreen) {
SpatialGuiPlane.clear();
DebugLog.renderThreadOnChange("sg.plane", "closed", "[SpatialGui] plane skipped: screen closed");
return;
}
SpatialGuiCapture capture = SpatialGuiCapture.get();
if (!capture.isReady()) {
SpatialGuiPlane.clear();
DebugLog.renderThreadOnChange("sg.plane", "captureNotReady", "[SpatialGui] plane skipped: capture not ready");
return;
}
GpuTextureView textureView = capture.colorTextureView();
if (textureView == null) {
SpatialGuiPlane.clear();
DebugLog.renderThreadOnChange("sg.plane", "noTextureView", "[SpatialGui] plane skipped: no texture view");
return;
}
@ -245,6 +254,7 @@ public class SpatialGui extends Module {
float drawAlpha = "Fade".equals(style) ? baseAlpha * ease(progress) : baseAlpha;
if (drawAlpha <= 0.001F) {
SpatialGuiPlane.clear();
DebugLog.renderThreadOnChange("sg.plane", "alpha0", "[SpatialGui] plane skipped: drawAlpha<=0");
return;
}
@ -261,6 +271,7 @@ public class SpatialGui extends Module {
}
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_PREMULTIPLIED, textureView, cachedSampler, Renderer3D.DepthMode.NONE);
DebugLog.renderThreadOnChange("sg.plane.mesh", mesh != null, "[SpatialGui] plane mesh=%b alpha=%.2f", mesh != null, drawAlpha);
if (mesh != null) {
// The capture holds premultiplied colour (GUI draws blended onto a transparent target), so the fade
// factor must scale RGB together with alpha.
@ -275,8 +286,11 @@ public class SpatialGui extends Module {
if (liveScreen) {
SpatialGuiPlane.publish(new SpatialGuiPlane(center, planeRight, planeUp, halfWidth, halfHeight, guiWidth, guiHeight));
DebugLog.renderThreadOnChange("sg.plane", "published",
"[SpatialGui] plane published center=(%.2f, %.2f, %.2f) size=%.2fx%.2f", center.x, center.y, center.z, halfWidth * 2.0F, halfHeight * 2.0F);
} else {
SpatialGuiPlane.clear();
DebugLog.renderThreadOnChange("sg.plane", "closing", "[SpatialGui] plane cleared: closing animation");
}
}

View file

@ -0,0 +1,74 @@
/*
* Ported/adapted from Spatial GUI (https://github.com/tastytrash/Spatial-GUI), MIT License.
* Copyright (c) tastytrash and Spatial GUI contributors.
*
* This file is part of LoVisual and is distributed under LoVisual's own GPLv3 license terms
* for the modifications made here (see mod/LICENSE and mod/THIRD_PARTY_NOTICES.md); the MIT-licensed
* portions ported from Spatial GUI remain available under the MIT terms above.
*/
package dev.loki.lovisual.mixins.render.spatialgui;
import dev.loki.lovisual.features.module.modules.visuals.spatialgui.SpatialGui;
import dev.loki.lovisual.render.spatialgui.SpatialGuiCapture;
import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.gui.render.GuiRenderer;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.client.renderer.state.WindowRenderState;
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;
/**
* Adapted from Spatial-GUI's {@code GameRendererMixin}: right before the main GUI pass the capture texture is
* (re)sized to the frame's window state and cleared to transparent, and at the end of the frame the isolated
* screen renderer built by {@link SpatialGuiCapture} runs its own prepare/draw cycle so the open screen lands
* in the capture texture while the flat overlay stays absent.
*/
@Mixin(GameRenderer.class)
public abstract class GameRendererSpatialGuiMixin {
@Inject(method = "render", at = @At(value = "INVOKE",
target = "Lnet/minecraft/client/gui/render/GuiRenderer;render()V"))
private void lovisual$prepareSpatialCapture(DeltaTracker deltaTracker, boolean renderLevel, CallbackInfo ci) {
SpatialGui module = SpatialGui.getInstance();
if (module == null || !module.isCapturing()) {
DebugLog.renderThreadOnChange("sg.prepare", "notCapturing",
"[SpatialGui] prepare skipped: not capturing");
return;
}
WindowRenderState windowState = ((GameRenderer) (Object) this).gameRenderState().windowRenderState;
if (windowState == null || windowState.width <= 0 || windowState.height <= 0) {
DebugLog.renderThreadOnChange("sg.prepare", "badWindowState",
"[SpatialGui] prepare skipped: windowState=%s", String.valueOf(windowState));
return;
}
SpatialGuiCapture capture = SpatialGuiCapture.get();
capture.ensure(windowState.width, windowState.height);
capture.clearTransparent();
DebugLog.renderThreadOnChange("sg.prepare", windowState.width + "x" + windowState.height,
"[SpatialGui] capture prepared and cleared %dx%d", windowState.width, windowState.height);
}
@Inject(method = "render", at = @At("TAIL"))
private void lovisual$renderIsolatedSpatialScreen(DeltaTracker deltaTracker, boolean renderLevel, CallbackInfo ci) {
SpatialGui module = SpatialGui.getInstance();
if (module == null || !module.isCapturing()) {
DebugLog.renderThreadOnChange("sg.tail", "notCapturing",
"[SpatialGui] isolated TAIL skipped: not capturing");
return;
}
if (!SpatialGuiCapture.get().isReady()) {
DebugLog.renderThreadOnChange("sg.tail", "notReady",
"[SpatialGui] isolated TAIL skipped: capture not ready");
return;
}
DebugLog.renderThreadOnChange("sg.tail", "run",
"[SpatialGui] isolated render frame");
SpatialGuiCapture.get().renderIsolatedFrame();
}
}

View file

@ -8,92 +8,38 @@
*/
package dev.loki.lovisual.mixins.render.spatialgui;
import com.mojang.blaze3d.ProjectionType;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.pipeline.RenderTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import dev.loki.lovisual.features.module.modules.visuals.spatialgui.SpatialGui;
import dev.loki.lovisual.render.spatialgui.SpatialGuiCapture;
import net.minecraft.client.Minecraft;
import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.gui.render.GuiRenderer;
import net.minecraft.client.renderer.Projection;
import net.minecraft.client.renderer.ProjectionMatrixBuffer;
import net.minecraft.client.renderer.state.WindowRenderState;
import org.joml.Matrix4f;
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 java.util.List;
import java.util.function.Supplier;
import org.spongepowered.asm.mixin.injection.ModifyArg;
/**
* Adapted from Spatial-GUI's {@code ScreenExtractor}/{@code TextureTargetManager}: on this Minecraft version the
* whole 2D GUI pass is a deferred draw list executed once per frame by {@link GuiRenderer#draw()} against an
* explicit {@link RenderTarget}. Redirecting the currently open screen into a 3D plane therefore only requires
* pointing that one draw call at {@link SpatialGuiCapture}'s off-screen target instead of the main render
* target, and cancelling the vanilla draw so nothing shows up as a flat overlay.
* <p>
* The vertex data itself is prepared and uploaded earlier in {@code GuiRenderer.render()} (which this mixin does
* not touch), so this only ever affects where the already-built draw list is executed to.
* Adapted from Spatial-GUI's {@code GuiRendererMixin}: every {@code draw()} executes its prepared draw list
* through {@code executeDrawRange(label, target, ...)} — when the instance is the isolated screen renderer
* built by {@link SpatialGuiCapture} while SpatialGui is capturing, the target argument is swapped for the
* off-screen capture texture. The vanilla main GuiRenderer (HUD) keeps its normal framebuffer, and the flat
* screen overlay is naturally absent because the screen was extracted into the isolated state instead.
*/
@Mixin(value = GuiRenderer.class, priority = 900)
@Mixin(GuiRenderer.class)
public abstract class GuiRendererCaptureMixin {
@Shadow
@Final
private List<?> draws;
@Shadow
private int firstDrawIndexAfterBlur;
@Shadow
@Final
private Projection guiProjection;
@Shadow
@Final
private ProjectionMatrixBuffer guiProjectionMatrixBuffer;
@Shadow
private void executeDrawRange(Supplier<String> label, RenderTarget target, GpuBufferSlice transform, int startIndex, int endIndex) {
throw new AssertionError();
}
@Inject(method = "draw", at = @At("HEAD"), cancellable = true)
private void lovisual$captureToSpatialPlane(CallbackInfo ci) {
if (draws == null || draws.isEmpty()) return;
@ModifyArg(
method = "draw",
at = @At(value = "INVOKE",
target = "Lnet/minecraft/client/gui/render/GuiRenderer;executeDrawRange(Ljava/util/function/Supplier;Lcom/mojang/blaze3d/pipeline/RenderTarget;Lcom/mojang/blaze3d/buffers/GpuBufferSlice;II)V"),
index = 1)
private RenderTarget lovisual$redirectSpatialCapture(RenderTarget original) {
SpatialGui module = SpatialGui.getInstance();
if (module == null || !module.isCapturing()) return;
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.gameRenderer == null) return;
WindowRenderState windowState = mc.gameRenderer.gameRenderState().windowRenderState;
if (windowState == null || windowState.guiScale <= 0) return;
if (module == null || !module.isCapturing()) return original;
SpatialGuiCapture capture = SpatialGuiCapture.get();
RenderTarget target = capture.ensure(windowState.width, windowState.height);
capture.clearTransparent();
guiProjection.setupOrtho(1000.0F, 11000.0F,
(float) windowState.width / windowState.guiScale, (float) windowState.height / windowState.guiScale, true);
RenderSystem.setProjectionMatrix(guiProjectionMatrixBuffer.getBuffer(guiProjection), ProjectionType.ORTHOGRAPHIC);
GpuBufferSlice transform = RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f().setTranslation(0.0F, 0.0F, -11000.0F));
int splitIndex = Math.min(firstDrawIndexAfterBlur, draws.size());
if (splitIndex > 0) {
executeDrawRange(() -> "spatial gui before blur", target, transform, 0, splitIndex);
}
if (draws.size() > firstDrawIndexAfterBlur) {
RenderSystem.getDevice().createCommandEncoder().clearDepthTexture(target.getDepthTexture(), 0.0D);
mc.gameRenderer.processBlurEffect();
executeDrawRange(() -> "spatial gui after blur", target, transform, firstDrawIndexAfterBlur, draws.size());
}
ci.cancel();
if ((Object) this != capture.screenGuiRenderer()) return original;
RenderTarget redirect = capture.target();
if (redirect == null) return original;
DebugLog.renderThreadOnce("sg.swap", "[SpatialGui] draw target swapped to capture texture");
return redirect;
}
}

View file

@ -0,0 +1,43 @@
/*
* Ported/adapted from Spatial GUI (https://github.com/tastytrash/Spatial-GUI), MIT License.
* Copyright (c) tastytrash and Spatial GUI contributors.
*
* This file is part of LoVisual and is distributed under LoVisual's own GPLv3 license terms
* for the modifications made here (see mod/LICENSE and mod/THIRD_PARTY_NOTICES.md); the MIT-licensed
* portions ported from Spatial GUI remain available under the MIT terms above.
*/
package dev.loki.lovisual.mixins.render.spatialgui;
import dev.loki.lovisual.features.module.modules.visuals.spatialgui.SpatialGui;
import dev.loki.lovisual.render.spatialgui.SpatialGuiCapture;
import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.screens.Screen;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
/**
* Adapted from Spatial-GUI's {@code GuiMixin}: while SpatialGui is capturing, the open screen's extraction
* is diverted from the vanilla main GUI render state into {@link SpatialGuiCapture}'s isolated state, so the
* main pass draws only the HUD and the screen content lands exclusively in the capture texture for the 3D
* plane. Mouse coordinates arrive already reprojected onto the plane via the MouseHandler mixin.
*/
@Mixin(Gui.class)
public abstract class GuiSpatialExtractMixin {
@Redirect(method = "extractRenderState", at = @At(value = "INVOKE",
target = "Lnet/minecraft/client/gui/screens/Screen;extractRenderStateWithTooltipAndSubtitles(Lnet/minecraft/client/gui/GuiGraphicsExtractor;IIF)V"))
private void lovisual$isolateScreenExtraction(Screen screen, GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
SpatialGui module = SpatialGui.getInstance();
boolean capturing = module != null && module.isCapturing();
DebugLog.renderThreadOnChange("sg.extract", capturing,
"[SpatialGui] screen extraction divert=%b screen=%s", capturing, screen.getClass().getSimpleName());
if (capturing) {
SpatialGuiCapture.get().extractScreen(screen, mouseX, mouseY, partialTick);
} else {
screen.extractRenderStateWithTooltipAndSubtitles(graphics, mouseX, mouseY, partialTick);
}
}
}

View file

@ -14,16 +14,30 @@ import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.textures.GpuTexture;
import com.mojang.blaze3d.textures.GpuTextureView;
import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.render.GuiRenderer;
import net.minecraft.client.gui.render.pip.GuiBannerResultRenderer;
import net.minecraft.client.gui.render.pip.GuiBookModelRenderer;
import net.minecraft.client.gui.render.pip.GuiEntityRenderer;
import net.minecraft.client.gui.render.pip.GuiProfilerChartRenderer;
import net.minecraft.client.gui.render.pip.GuiSkinRenderer;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.renderer.state.gui.GuiRenderState;
import org.joml.Vector4f;
import java.util.List;
/**
* Owns the off-screen {@link RenderTarget} that the currently open {@link net.minecraft.client.gui.screens.Screen}
* is redirected into when the SpatialGui module is displaying it as a 3D plane instead of a flat overlay.
* is rendered into while the SpatialGui module displays it as a 3D plane instead of a flat overlay.
* <p>
* This replaces Spatial-GUI's {@code TextureTargetManager}/{@code ScreenExtractor} pair: on this Minecraft
* version the GUI is drawn through a deferred {@code GuiRenderer} that executes its draw list against an
* explicit {@link RenderTarget}, so capturing the screen only requires swapping that target for the duration
* of one draw call (see {@code GuiRendererCaptureMixin}) instead of hooking a legacy immediate-mode GL pass.
* Follows the reference Spatial-GUI architecture: the screen is extracted into a private
* {@link GuiRenderState} served by a second {@link GuiRenderer} instance (see the spatialgui mixins), and that
* instance's draw pass is redirected into {@link #target()} while the vanilla main GUI pass keeps rendering
* the HUD untouched. Texture size matches the window framebuffer; the target is cleared to transparent once
* per frame before the main GUI pass.
*/
public final class SpatialGuiCapture {
private static final SpatialGuiCapture INSTANCE = new SpatialGuiCapture();
@ -32,6 +46,9 @@ public final class SpatialGuiCapture {
private int width;
private int height;
private GuiRenderState screenRenderState;
private GuiRenderer screenGuiRenderer;
private SpatialGuiCapture() {
}
@ -57,6 +74,17 @@ public final class SpatialGuiCapture {
return target;
}
/** Current capture target, created lazily against the real window size if nothing prepared it yet. */
public TextureTarget target() {
if (target == null) {
Minecraft mc = Minecraft.getInstance();
if (mc != null && mc.getWindow() != null) {
ensure(mc.getWindow().getWidth(), mc.getWindow().getHeight());
}
}
return target;
}
public void clearTransparent() {
if (target == null) return;
GpuTexture colorTexture = target.getColorTexture();
@ -69,6 +97,44 @@ public final class SpatialGuiCapture {
}
}
/** Second GuiRenderer whose draw pass is redirected into the capture target; built on first use. */
public GuiRenderer screenGuiRenderer() {
if (screenGuiRenderer == null) {
Minecraft mc = Minecraft.getInstance();
screenRenderState = new GuiRenderState();
screenGuiRenderer = new GuiRenderer(
screenRenderState,
mc.gameRenderer.featureRenderDispatcher(),
List.of(
new GuiEntityRenderer(mc.getEntityRenderDispatcher()),
new GuiSkinRenderer(),
new GuiBookModelRenderer(),
new GuiBannerResultRenderer(mc.getAtlasManager()),
new GuiProfilerChartRenderer()));
}
return screenGuiRenderer;
}
/** Extracts the screen into the isolated render state instead of the vanilla main GUI state. */
public void extractScreen(Screen screen, int mouseX, int mouseY, float partialTick) {
Minecraft mc = Minecraft.getInstance();
screenGuiRenderer();
GuiGraphicsExtractor graphics = new GuiGraphicsExtractor(mc, screenRenderState, mouseX, mouseY);
screen.extractRenderStateWithTooltipAndSubtitles(graphics, mouseX, mouseY, partialTick);
int[] count = {0};
screenRenderState.forEachElement(e -> count[0]++, GuiRenderState.TraverseRange.ALL);
DebugLog.renderThreadOnChange("sg.extract.elements", count[0],
"[SpatialGui] isolated render state elements=%d screen=%s", count[0], screen.getClass().getSimpleName());
}
/** Builds and draws the isolated frame: prepare, execute into the capture target, reset the state. */
public void renderIsolatedFrame() {
GuiRenderer renderer = screenGuiRenderer();
renderer.render();
renderer.endFrame();
DebugLog.renderThreadOnce("sg.isolated.render", "[SpatialGui] isolated render pass executed");
}
public GpuTextureView colorTextureView() {
return target == null ? null : target.getColorTextureView();
}