From 86fc839dc99101549528d4e3e2ebe722616a0c64 Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 8 Sep 2026 19:03:36 +0100 Subject: [PATCH] fix(renderers): always sort the emissive layer's quads The layer writes no depth and disables back face culling, so nothing in the batch occludes anything else and both faces of every part are submitted. Submission order is then the only thing deciding what is on top, which put lights through panels and stopped the animated monitor glow on Renaissance and Toyota reading as animated. Binary alpha was the reason given for dropping the sorter. It makes the blend order independent, not the occlusion. Costs back the 0.6 ms the sort was measured to save on Copper. One layer per emission texture now, so the exterior no longer flushes twice when both accessors saw the same texture in a frame. --- .../ait/client/renderers/AITRenderLayers.java | 115 ++++-------------- .../ait/client/renderers/TardisStar.java | 4 +- .../renderers/exteriors/ExteriorRenderer.java | 5 +- 3 files changed, 27 insertions(+), 97 deletions(-) diff --git a/src/main/java/dev/amble/ait/client/renderers/AITRenderLayers.java b/src/main/java/dev/amble/ait/client/renderers/AITRenderLayers.java index 9b16f1ed0..8c59b8f2b 100644 --- a/src/main/java/dev/amble/ait/client/renderers/AITRenderLayers.java +++ b/src/main/java/dev/amble/ait/client/renderers/AITRenderLayers.java @@ -1,6 +1,5 @@ package dev.amble.ait.client.renderers; -import java.util.Set; import java.util.function.Function; import net.fabricmc.api.EnvType; @@ -14,13 +13,29 @@ import net.minecraft.util.Identifier; import net.minecraft.util.Util; -import dev.amble.ait.AITMod; - @Environment(EnvType.CLIENT) public class AITRenderLayers extends RenderLayer { - private static RenderLayer emissive(Identifier texture, boolean sorted) { + /** + * The emissive layer, always with the quad sorter. + * + *

The sorter is not an optimisation to be skipped when the alpha is opaque. This layer writes + * no depth ({@code COLOR_MASK}) and disables back face culling, so nothing in the batch occludes + * anything else in it and both faces of every part are submitted. Whichever quad is drawn last + * wins, which leaves submission order as the only thing deciding what ends up on top: a part's + * own back face, or a light sitting behind a panel, will paint over the front of it. + * + *

That is independent of the texture's alpha. Binary alpha makes the blend order + * independent, not the occlusion, and dropping the sorter on that reasoning put lights through + * panels and left the animated monitor glow on Renaissance and Toyota not reading as animated. + * + *

It does cost. {@code BufferBuilder.setSorter} allocates a primitive centre per quad and + * writes an explicit index buffer instead of reusing the shared sequential one, which on Copper's + * roughly 6800 quad emission pass measured about 0.6 ms a frame. That is the price of drawing it + * in the right order. + */ + private static RenderLayer emissive(Identifier texture) { RenderPhase.Texture texture2 = new RenderPhase.Texture(texture, false, false); MultiPhaseParameters multiPhaseParameters = RenderLayer.MultiPhaseParameters.builder() .program(RenderPhase.EYES_PROGRAM) @@ -33,98 +48,16 @@ private static RenderLayer emissive(Identifier texture, boolean sorted) { .depthTest(RenderPhase.LEQUAL_DEPTH_TEST) .build(false); - // The last flag is what makes RenderLayer.draw hand the buffer a sorter, and that is more than - // a sort: BufferBuilder.setSorter also builds a Vector3f per quad for the primitive centres, - // and build() then writes an explicit index buffer instead of reusing the shared sequential - // one. Copper's emission pass is around 6800 quads, so per flush that is 6800 allocations, a - // 6800-element sort and about 41000 index writes. - // - // It is separate from the blend mode set above. The layer never writes depth (COLOR_MASK), so - // ordering can only matter where emissive geometry overlaps emissive geometry inside one batch. - // - // Mostly the textures make that moot: 106 of the mod's 113 emission textures are strictly - // binary alpha, where SRC_ALPHA blending is order-independent. Seven are not, and one is not - // marginal: hourglass_default_emission.png carries 1372 partial texels including a flat run of - // 768 at alpha 100, which is deliberate semi-transparent glow. Crystalline (64 texels), - // steam_copper (24), steam_playpal (2) and bookshelf_default (34) are the rest. On those, a - // glow-over-glow overlap composites in submission order here where it used to composite in - // depth order. - // - // Vertex alpha is a separate exposure and is not covered by the texture argument at all, which - // is what the sorted layer below is for. - return RenderLayer.of(sorted ? "emissive_cull_z_offset_sorted" : "emissive_cull_z_offset_unsorted", + return RenderLayer.of("emissive_cull_z_offset", VertexFormats.POSITION_COLOR_TEXTURE_OVERLAY_LIGHT_NORMAL, VertexFormat.DrawMode.QUADS, 256, - false, sorted, multiPhaseParameters); + false, true, multiPhaseParameters); } - /** - * Emission textures whose partial alpha is large enough that draw order is visible, so they get the - * sorted layer whoever asks for them. Sortedness is a property of the texture here, not of the call - * site, which keeps one texture mapped to one layer object. - * - *

Only hourglass qualifies. Its 1372 partial texels include a flat run of 768 at alpha 100, - * which is deliberate semi-transparent glow rather than an edge ramp. The other six partial-alpha - * emission textures are accepted unsorted: crystalline and its two recolours have 64 texels each at - * alpha 199, bookshelf_default 34, steam_copper 24 at alpha 4, steam_playpal 2. Crystalline was - * checked against a sorted arm in game and showed nothing above its own animation noise. - */ - private static final Set SORT_SENSITIVE_EMISSION = Set.of( - AITMod.id("textures/blockentities/consoles/hourglass_default_emission.png")); + private static final Function EMISSIVE = Util.memoize(AITRenderLayers::emissive); - private static final Function EMISSIVE_SORTED = Util - .memoize(texture -> emissive(texture, true)); - - private static final Function EMISSIVE_UNSORTED = Util - .memoize(texture -> emissive(texture, false)); - - /** - * The emissive layer for geometry drawn at full vertex alpha, which is every caller but two. - * - *

Returns the sorted layer anyway for the textures in {@link #SORT_SENSITIVE_EMISSION}. - * - *

Unsorted. At alpha 1 the blend leaves nothing for the ordering to change except where opaque - * glow overlaps opaque glow, and the sort is the single most expensive thing the console's - * emission pass does. Measured on Copper, interior, landed, interleaved arms inside one client - * session: the {@code monitor} zone went from 1.371-1.404 ms to 0.743-0.830 ms, disjoint ranges, - * while the two emission zones, which are negative controls this cannot affect, moved by under - * 0.06 ms. Frame time moved too but its per-rep ranges overlap at n=3, so it is not claimed. - * - *

The cost lands in {@code monitor} only when monitor text is enabled and the variant overrides - * {@code renderMonitorText}: that call is what next asks {@code Immediate} for a buffer, and the - * flush of this layer is billed to whichever zone is open at the time. With the text off it moves - * to {@code sonic_port} or later. It is the same work either way. - */ + /** One layer per emission texture. See {@link #emissive} for why the sort is not optional. */ public static RenderLayer tardisEmissiveCullZOffset(Identifier texture) { - return SORT_SENSITIVE_EMISSION.contains(texture) - ? EMISSIVE_SORTED.apply(texture) - : EMISSIVE_UNSORTED.apply(texture); - } - - /** - * The emissive layer for geometry drawn at partial vertex alpha, where draw order is visible. - * - *

Two callers. {@code TardisStar} draws two nested star models into one batch, the outer at - * alpha 0.5 and the inner at alpha 1, with culling disabled so both faces of both shells are - * submitted; unsorted, the opaque core would land last and paint over the shell that is supposed - * to veil it. {@code ExteriorRenderer} draws the emission at the demat and remat fade alpha, which - * sweeps continuously through the partial range on every takeoff and landing. - * - *

The star is 24 quads, so sorting it costs nothing worth measuring. The exterior is around 342, - * and it pays the sort on every landed TARDIS even though only demat and remat need it. That is - * deliberate: picking per frame on the current alpha would hand out two different layer objects for - * one texture, and the moment one TARDIS is fading while another sits landed that alternates every - * frame, which costs more than the sort it saves. - * - *

Exterior emission identifiers already reach both layers, because {@code ExteriorRenderer} is - * here while {@code DoorRenderer}, {@code FlightTardisRenderer}, {@code FallingTardisRenderer}, - * {@code SnowGlobeRenderer} and the BOTI paths draw the same textures unsorted. {@code Immediate} - * keys buffers on layer identity, so that costs one extra flush when both appear in a frame. It is - * accepted: no {@code AITRenderLayers} layer has a dedicated buffer in {@code BufferBuilderStorage}, - * so every switch between them already flushes. Console emissions never collide, being a separate - * texture directory and registry. - */ - public static RenderLayer tardisEmissiveCullZOffsetSorted(Identifier texture) { - return EMISSIVE_SORTED.apply(texture); + return EMISSIVE.apply(texture); } private AITRenderLayers(String name, VertexFormat vertexFormat, VertexFormat.DrawMode drawMode, diff --git a/src/main/java/dev/amble/ait/client/renderers/TardisStar.java b/src/main/java/dev/amble/ait/client/renderers/TardisStar.java index e8e6eba45..ef22d531e 100644 --- a/src/main/java/dev/amble/ait/client/renderers/TardisStar.java +++ b/src/main/java/dev/amble/ait/client/renderers/TardisStar.java @@ -64,13 +64,13 @@ public static void renderStar(WorldRenderContext context, Tardis tardis) { .rotationDegrees(delta)); TardisStarModel.getTexturedModelData().createModel().render(matrixStack, - provider.getBuffer(AITRenderLayers.tardisEmissiveCullZOffsetSorted(TARDIS_STAR_TEXTURE)), + provider.getBuffer(AITRenderLayers.tardisEmissiveCullZOffset(TARDIS_STAR_TEXTURE)), LightmapTextureManager.MAX_LIGHT_COORDINATE, OverlayTexture.DEFAULT_UV, tardis.isGrowth() ? 0.1f : 1, tardis.isGrowth() ? 0.1f : 1, tardis.isGrowth() ? 0.1f : 1, 0.5f); matrixStack.scale(0.9f, 0.9f, 0.9f); TardisStarModel.getTexturedModelData().createModel().render(matrixStack, - provider.getBuffer(AITRenderLayers.tardisEmissiveCullZOffsetSorted(TARDIS_STAR_TEXTURE)), + provider.getBuffer(AITRenderLayers.tardisEmissiveCullZOffset(TARDIS_STAR_TEXTURE)), LightmapTextureManager.MAX_LIGHT_COORDINATE, OverlayTexture.DEFAULT_UV, 1, tardis.isGrowth() ? 0.2f : 1, tardis.isGrowth() ? 0f : 1, 1f); diff --git a/src/main/java/dev/amble/ait/client/renderers/exteriors/ExteriorRenderer.java b/src/main/java/dev/amble/ait/client/renderers/exteriors/ExteriorRenderer.java index 1516cebdc..39125adf2 100644 --- a/src/main/java/dev/amble/ait/client/renderers/exteriors/ExteriorRenderer.java +++ b/src/main/java/dev/amble/ait/client/renderers/exteriors/ExteriorRenderer.java @@ -310,13 +310,10 @@ private void renderExterior(Profiler profiler, ClientTardis tardis, T entity, fl ? !power ? 0.01f : 0.3f : u - colorAlpha; - // Sorted, not unsorted: alpha here is the demat and remat fade, which sweeps through the - // partial range where draw order is visible. - // // TODO the guard above tests `emission`, which DOOM reassigns per rotation, but the layer // below binds `variant.emission()`, the un-adjusted base. For DOOM those disagree. Left // alone here because changing which texture DOOM binds is not part of this change. - model.renderWithAnimations(tardis, entity, this.model.getPart(), matrices, vertexConsumers.getBuffer(AITRenderLayers.tardisEmissiveCullZOffsetSorted(variant.emission())), + model.renderWithAnimations(tardis, entity, this.model.getPart(), matrices, vertexConsumers.getBuffer(AITRenderLayers.tardisEmissiveCullZOffset(variant.emission())), 0xF000F0, OverlayTexture.DEFAULT_UV, red, green, blue, alpha, tickDelta); } if (DependencyChecker.hasIris()) {