diff --git a/src/main/java/org/polyfrost/polydium/client/perf/BlockEntityCullBox.java b/src/main/java/org/polyfrost/polydium/client/perf/BlockEntityCullBox.java index 97910c2..48c88a3 100644 --- a/src/main/java/org/polyfrost/polydium/client/perf/BlockEntityCullBox.java +++ b/src/main/java/org/polyfrost/polydium/client/perf/BlockEntityCullBox.java @@ -3,7 +3,11 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.phys.AABB; +import java.util.concurrent.atomic.AtomicInteger; + public interface BlockEntityCullBox { + AtomicInteger RENDERER_EPOCH = new AtomicInteger(1); + ClassValue FIXED_BOUNDS = new ClassValue<>() { @Override protected Boolean computeValue(Class renderer) { diff --git a/src/main/java/org/polyfrost/polydium/client/perf/ChunkPrefetchCache.java b/src/main/java/org/polyfrost/polydium/client/perf/ChunkPrefetchCache.java new file mode 100644 index 0000000..0193f76 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/client/perf/ChunkPrefetchCache.java @@ -0,0 +1,21 @@ +package org.polyfrost.polydium.client.perf; + +/** + * Entity Culling's filtered block entity list for one chunk, valid while the chunk's block entity map and the + * generation (renderer reloads, whitelist edits) are unchanged. + */ +public interface ChunkPrefetchCache { + Object[] polydium$storedPrefetch(); + + int polydium$prefetchGeneration(); + + void polydium$setPrefetch(Object[] cullables, int generation); + + default Object[] polydium$prefetch(int generation) { + return generation == this.polydium$prefetchGeneration() ? this.polydium$storedPrefetch() : null; + } + + default void polydium$invalidatePrefetch() { + this.polydium$setPrefetch(null, 0); + } +} diff --git a/src/main/java/org/polyfrost/polydium/client/perf/UpscaledSprite.java b/src/main/java/org/polyfrost/polydium/client/perf/UpscaledSprite.java new file mode 100644 index 0000000..14fa63a --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/client/perf/UpscaledSprite.java @@ -0,0 +1,7 @@ +package org.polyfrost.polydium.client.perf; + +public interface UpscaledSprite { + int polydium$upscale(); + + void polydium$setUpscale(int factor); +} diff --git a/src/main/java/org/polyfrost/polydium/early/EarlyVulkanPreLaunch.java b/src/main/java/org/polyfrost/polydium/early/EarlyVulkanPreLaunch.java index 051b446..955fba9 100644 --- a/src/main/java/org/polyfrost/polydium/early/EarlyVulkanPreLaunch.java +++ b/src/main/java/org/polyfrost/polydium/early/EarlyVulkanPreLaunch.java @@ -4,12 +4,24 @@ //? if >= 1.21.11 { import oshi.SystemInfo; import oshi.hardware.GraphicsCard; +import org.lwjgl.system.APIUtil; +import org.lwjgl.system.JNI; +import org.lwjgl.system.SharedLibrary; + +import java.io.IOException; +import java.nio.ByteBuffer; + +import static org.lwjgl.system.MemoryUtil.memAddress; +import static org.lwjgl.system.MemoryUtil.memFree; +import static org.lwjgl.system.MemoryUtil.memUTF16; //?} // Kept apart from EarlyVulkan: the JVM remembers a failed class lookup per calling class, and EarlyVulkan loads before oshi can public final class EarlyVulkanPreLaunch implements PreLaunchEntrypoint { @Override public void onPreLaunch() { + //? if >= 1.21.11 + showSwitchableGpus(); detect(/*? if >= 26.2 {*/true/*?} else {*//*false*//*?}*/); } @@ -33,4 +45,36 @@ private void detect(boolean decide) { } //?} } + + //? if >= 1.21.11 { + // NVIDIA's and AMD's implicit switchable-graphics Vulkan layers hide the dGPU from vkEnumeratePhysicalDevices + // when their driver profile picks the iGPU, even with the Windows GPU preference set to high performance. + // The loader reads these with GetEnvironmentVariable at vkCreateInstance, so setting them here is early enough. + // An explicit Windows "Power saving" choice for this executable is kept, since unhiding the dGPU would override it. + private static void showSwitchableGpus() { + if (!System.getProperty("os.name", "").startsWith("Windows")) + return; + try (SharedLibrary kernel32 = APIUtil.apiCreateLibrary("kernel32")) { + if (windowsPrefersPowerSaving()) + return; + long setEnv = kernel32.getFunctionAddress("SetEnvironmentVariableW"); + for (String name : new String[]{"DISABLE_LAYER_NV_OPTIMUS_1", "DISABLE_LAYER_AMD_SWITCHABLE_GRAPHICS_1"}) { + ByteBuffer key = memUTF16(name); + ByteBuffer value = memUTF16("1"); + JNI.callPPI(memAddress(key), memAddress(value), setEnv); + memFree(key); + memFree(value); + } + } catch (Throwable ignored) { + } + } + + private static boolean windowsPrefersPowerSaving() throws IOException { + String exe = ProcessHandle.current().info().command().orElse(null); + if (exe == null) + return false; + Process reg = new ProcessBuilder("reg", "query", "HKCU\\Software\\Microsoft\\DirectX\\UserGpuPreferences", "/v", exe).redirectErrorStream(true).start(); + return new String(reg.getInputStream().readAllBytes()).contains("GpuPreference=1;"); + } + //?} } diff --git a/src/main/java/org/polyfrost/polydium/mixin/PolydiumMixinPlugin.java b/src/main/java/org/polyfrost/polydium/mixin/PolydiumMixinPlugin.java index a781b51..b19e0c2 100644 --- a/src/main/java/org/polyfrost/polydium/mixin/PolydiumMixinPlugin.java +++ b/src/main/java/org/polyfrost/polydium/mixin/PolydiumMixinPlugin.java @@ -250,10 +250,13 @@ private static boolean standsDown(String mixinClassName) { Map.entry("OcclusionCull", new String[]{"OcclusionRegion", "OcclusionListAccessor"}), Map.entry("ImmediateDrawRing", new String[]{"ImmediateDrawRingSeparate", "ImmediateDrawRingEmulated", "ImmediateDrawRingFrame", "ImmediateDrawRingClose"}), Map.entry("EntityCullingBlockEntityBox", new String[]{"MixinBlockEntityCullBox"}), + Map.entry("EntityCullingPrefetch", new String[]{"EntityCullingBlockEntityBox", "MixinBlockEntityCullBox", "EntityCullingPrefetchChunk"}), Map.entry("ParticleLightBatch", new String[]{"ParticleLightDrain", "ParticleLightSourceInvoker", "ParticleLevelAccessor"}), Map.entry("ParticleCoreAsyncThreshold", new String[]{"ParticleSoundClientThread"}), Map.entry("PreparedFrameRecover", new String[]{"PreparedFrameAccessor", "StagedVertexBufferAccessor"}), Map.entry("IrisShadowOutlineDrop", new String[]{"BufferSourceAccessor", "OutlineBufferSourceAccessor"}), + Map.entry("SpriteMipUpscale", new String[]{"SpriteContentsAccessor", "SpriteContentsUpscale"}), + Map.entry("ItemModelUpscale", new String[]{"SpriteContentsUpscale"}), //? if < 1.21.10 { /*Map.entry("OpaqueSpriteParticle", new String[]{"SpriteContentsTransparency", "OpaqueParticleRenderOrder"}), *///?} else { @@ -435,6 +438,12 @@ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { if (mixinClassName.endsWith(".MixinLeafCullScratchPos") && !Mods.loaded("cull-fewer-leaves")) return false; + if (mixinClassName.endsWith(".MixinExpandedCylindricalGate") && !Mods.loaded("sodium-extra")) + return false; + + if (mixinClassName.endsWith(".MixinTierTaggerModeCache") && !Mods.loaded("tiertagger") && !Mods.loaded("tier-tagger")) + return false; + if ((mixinClassName.endsWith(".MixinClientLightBudget") || mixinClassName.endsWith(".MixinClientLightBudgetTick") || mixinClassName.endsWith(".MixinChunkLoadDirtyOnce")) && !Mods.loaded("scalablelux")) return false; @@ -463,7 +472,7 @@ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { if (mixinClassName.endsWith(".MixinReplayEntityTickets") && !Mods.loaded("flashback")) return false; - if ((mixinClassName.endsWith(".MixinEntityCullingBlockEntityBox") || mixinClassName.endsWith(".MixinBlockEntityCullBox")) && !Mods.loaded("entityculling")) + if ((mixinClassName.endsWith(".MixinEntityCullingBlockEntityBox") || mixinClassName.endsWith(".MixinBlockEntityCullBox") || mixinClassName.contains(".MixinEntityCullingPrefetch")) && !Mods.loaded("entityculling")) return false; if (mixinClassName.endsWith(".MixinBlockModelHookPrecheck") && !Mods.loaded("obe") && !Mods.loaded("overflowanimations")) @@ -644,6 +653,13 @@ public void postApply(String targetClassName, ClassNode targetClass, String mixi return; } + if (mixinClassName.endsWith(".MixinExpandedCylindricalGate")) { + for (MethodNode method : targetClass.methods) + if (method.name.equals("processQueue")) + gateExpandedCylindricalTraversal(targetClass, method); + return; + } + if (mixinClassName.endsWith(".MixinClientLightBudget")) { for (MethodNode method : targetClass.methods) if (method.name.equals("propagateChanges") && method.desc.equals("()V")) @@ -1488,6 +1504,56 @@ private static void chunkLoadDirtyOnce(ClassNode owner, MethodNode method) { } } + // Sodium Extra's @ModifyArgs on visitNeighbors boxes every argument into an Args object for each section the occlusion + // search visits, even though its handler returns untouched unless expanded cylindrical fog culling is active. Mixin + // emits `Args$N.of(args)`, the handler call, then Args unpacking right before the original call, with the same stack + // before `of` as at the call, so the handler's own early-out can jump straight to the plain call. + public static void gateExpandedCylindricalTraversal(ClassNode owner, MethodNode method) { + String fog = "me/flashyreese/mods/sodiumextra/client/fog/FogDistanceHelper"; + for (AbstractInsnNode insn : method.instructions.toArray()) { + if (!(insn instanceof MethodInsnNode handler) || !handler.owner.equals(owner.name) || !handler.name.endsWith("allowExpandedCylindricalVerticalTraversal") + || !handler.desc.equals("(Lorg/spongepowered/asm/mixin/injection/invoke/arg/Args;)V")) + continue; + AbstractInsnNode pack = handler.getPrevious(); + while (pack instanceof InsnNode || pack instanceof VarInsnNode) + pack = pack.getPrevious(); + if (!(pack instanceof MethodInsnNode of) || of.getOpcode() != Opcodes.INVOKESTATIC || !of.name.equals("of")) + continue; + AbstractInsnNode next = handler.getNext(); + while (next instanceof InsnNode || next instanceof MethodInsnNode call && call.owner.equals(of.owner)) + next = next.getNext(); + if (!(next instanceof MethodInsnNode neighbors) || !neighbors.name.equals("visitNeighbors") + || !Arrays.equals(Type.getArgumentTypes(neighbors.desc), Type.getArgumentTypes(of.desc))) + continue; + + LabelNode plain = new LabelNode(); + InsnList gate = new InsnList(); + if (handler.getOpcode() == Opcodes.INVOKESTATIC) { + Type[] params = Type.getArgumentTypes(method.desc); + if ((method.access & Opcodes.ACC_STATIC) == 0 || params.length < 3 || !params[2].equals(Type.FLOAT_TYPE)) + continue; + gate.add(new VarInsnNode(Opcodes.FLOAD, 2)); + gate.add(new MethodInsnNode(Opcodes.INVOKESTATIC, fog, "isExpandedCylindricalCullDistance", "(F)Z", false)); + gate.add(new JumpInsnNode(Opcodes.IFEQ, plain)); + } else { + String[] distances = {"searchDistanceRegular", "searchDistanceLocal"}; + if ((method.access & Opcodes.ACC_STATIC) != 0 || !Arrays.stream(distances).allMatch(name -> owner.fields.stream().anyMatch(field -> field.name.equals(name) && field.desc.equals("F")))) + continue; + LabelNode run = new LabelNode(); + for (String name : distances) { + gate.add(new VarInsnNode(Opcodes.ALOAD, 0)); + gate.add(new FieldInsnNode(Opcodes.GETFIELD, owner.name, name, "F")); + gate.add(new MethodInsnNode(Opcodes.INVOKESTATIC, fog, "isExpandedCylindricalCullDistance", "(F)Z", false)); + gate.add(new JumpInsnNode(Opcodes.IFNE, run)); + } + gate.add(new JumpInsnNode(Opcodes.GOTO, plain)); + gate.add(run); + } + method.instructions.insertBefore(of, gate); + method.instructions.insertBefore(neighbors, plain); + } + } + private static void budgetLightTasks(ClassNode owner, MethodNode method) { if (owner.fields.stream().noneMatch(field -> field.name.equals("isClientSide") && field.desc.equals("Z"))) return; diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingBlockEntityBox.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingBlockEntityBox.java index 8b7faf5..8124083 100644 --- a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingBlockEntityBox.java +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingBlockEntityBox.java @@ -29,20 +29,18 @@ public abstract class MixinEntityCullingBlockEntityBox { @Shadow public abstract BlockEntityRenderer getRenderer(E blockEntity); - @Unique - private int polydium$rendererEpoch = 1; @Unique private BlockEntity polydium$cullTarget; @Inject(method = "onResourceManagerReload", at = @At("RETURN")) private void polydium$invalidateCullBoxes(CallbackInfo ci) { - this.polydium$rendererEpoch++; + BlockEntityCullBox.RENDERER_EPOCH.incrementAndGet(); } @TargetHandler(mixin = POLYDIUM$MIXIN, name = "tryExtractRenderState") @WrapOperation(method = "@MixinSquared:Handler", at = @At(value = "INVOKE", target = "Ldev/tr7zw/entityculling/EntityCullingModBase;setupAABB(Lnet/minecraft/world/level/block/entity/BlockEntity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/phys/AABB;"), require = 0, expect = 0) private AABB polydium$inline$cachedBox(@Coerce Object mod, BlockEntity blockEntity, BlockPos pos, Operation original) { - AABB cached = ((BlockEntityCullBox) blockEntity).polydium$cullBox(this.polydium$rendererEpoch); + AABB cached = ((BlockEntityCullBox) blockEntity).polydium$cullBox(BlockEntityCullBox.RENDERER_EPOCH.get()); this.polydium$cullTarget = cached == null ? blockEntity : null; return cached != null ? cached : original.call(mod, blockEntity, pos); } @@ -60,9 +58,10 @@ public abstract class MixinEntityCullingBlockEntityBox { if (blockEntity != null) { this.polydium$cullTarget = null; BlockEntityCullBox holder = (BlockEntityCullBox) blockEntity; - if (holder.polydium$cullBoxEpoch() != this.polydium$rendererEpoch) { + int epoch = BlockEntityCullBox.RENDERER_EPOCH.get(); + if (holder.polydium$cullBoxEpoch() != epoch) { boolean fixed = BlockEntityCullBox.fixedBounds(this.getRenderer(blockEntity).getClass()); - holder.polydium$setCullBox(fixed ? box : null, this.polydium$rendererEpoch); + holder.polydium$setCullBox(fixed ? box : null, epoch); } } return frustum.isVisible(box); diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetch.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetch.java new file mode 100644 index 0000000..b9f4d9e --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetch.java @@ -0,0 +1,81 @@ +package org.polyfrost.polydium.mixin.client.perf; + +//? if >= 1.21.10 { +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.chunk.LevelChunk; +import org.polyfrost.polydium.client.perf.BlockEntityCullBox; +import org.polyfrost.polydium.client.perf.ChunkPrefetchCache; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Coerce; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Entity Culling rescans every block entity in 17x17 chunks each tick; reuse a chunk's filtered list until it changes +@Pseudo +@Mixin(targets = "dev.tr7zw.entityculling.EntityCullingModBase", remap = false) +public abstract class MixinEntityCullingPrefetch { + @Shadow + public Set> blockEntityWhitelist; + + @Unique + private int polydium$generation; + @Unique + private int polydium$rendererEpoch; + @Unique + private Set> polydium$whitelist = Set.of(); + + @Inject(method = "extractEntityData", at = @At("HEAD"), require = 0, expect = 0) + private void polydium$checkPrefetchInputs(CallbackInfo ci) { + if (!Minecraft.getInstance().isSameThread()) + return; + int epoch = BlockEntityCullBox.RENDERER_EPOCH.get(); + // The config screen edits the whitelist in place + if (epoch != this.polydium$rendererEpoch || !this.polydium$whitelist.equals(this.blockEntityWhitelist)) { + this.polydium$rendererEpoch = epoch; + this.polydium$whitelist = Set.copyOf(this.blockEntityWhitelist); + this.polydium$generation++; + } + } + + @WrapOperation(method = "extractEntityData", at = @At(value = "INVOKE", target = "Ldev/tr7zw/entityculling/EntityCullingModBase;prefetchBlockEntityData(Ljava/util/List;Ljava/util/Map;)V"), require = 0, expect = 0) + private void polydium$cachedPrefetch(@Coerce Object mod, List out, Map blockEntities, Operation original, @Local LevelChunk chunk) { + Minecraft client = Minecraft.getInstance(); + // With safe mode off this runs on the cull thread, racing chunk updates + if (!client.isSameThread()) { + original.call(mod, out, blockEntities); + return; + } + ChunkPrefetchCache cache = (ChunkPrefetchCache) chunk; + Object[] cached = cache.polydium$prefetch(this.polydium$generation); + if (cached != null) { + for (Object cullable : cached) + out.add(cullable); + return; + } + int start = out.size(); + original.call(mod, out, blockEntities); + List added = out.subList(start, out.size()); + BlockEntityRenderDispatcher dispatcher = client.getBlockEntityRenderDispatcher(); + // A renderer with its own getRenderBoundingBox may move its box every tick, so that chunk is rebuilt each time + for (Object cullable : added) { + if (!BlockEntityCullBox.fixedBounds(dispatcher.getRenderer((BlockEntity) cullable).getClass())) + return; + } + cache.polydium$setPrefetch(added.toArray(), this.polydium$generation); + } +} +//?} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetchChunk.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetchChunk.java new file mode 100644 index 0000000..3d71087 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetchChunk.java @@ -0,0 +1,47 @@ +package org.polyfrost.polydium.mixin.client.perf; + +//? if >= 1.21.10 { +import net.minecraft.world.level.chunk.LevelChunk; +import org.polyfrost.polydium.client.perf.ChunkPrefetchCache; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +// These are the only LevelChunk methods that change the block entity map's contents on the client +@Mixin(LevelChunk.class) +public abstract class MixinEntityCullingPrefetchChunk implements ChunkPrefetchCache { + @Unique + private Object[] polydium$prefetch; + @Unique + private int polydium$prefetchGeneration; + + @Override + public Object[] polydium$storedPrefetch() { + return this.polydium$prefetch; + } + + @Override + public int polydium$prefetchGeneration() { + return this.polydium$prefetchGeneration; + } + + @Override + public void polydium$setPrefetch(Object[] cullables, int generation) { + this.polydium$prefetch = cullables; + this.polydium$prefetchGeneration = generation; + } + + @Inject(method = {"setBlockEntity", "removeBlockEntity", "clearAllBlockEntities"}, at = @At("HEAD")) + private void polydium$invalidateOnChange(CallbackInfo ci) { + this.polydium$invalidatePrefetch(); + } + + @Inject(method = "getBlockEntity(Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/chunk/LevelChunk$EntityCreationType;)Lnet/minecraft/world/level/block/entity/BlockEntity;", at = @At(value = "INVOKE", target = "Ljava/util/Map;remove(Ljava/lang/Object;)Ljava/lang/Object;", ordinal = 1)) + private void polydium$invalidateOnDrop(CallbackInfoReturnable cir) { + this.polydium$invalidatePrefetch(); + } +} +//?} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinItemModelUpscale.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinItemModelUpscale.java new file mode 100644 index 0000000..40d09a4 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinItemModelUpscale.java @@ -0,0 +1,34 @@ +package org.polyfrost.polydium.mixin.client.perf; + +//? if >= 1.21.11 { +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import net.minecraft.client./*? if >= 26.1 {*/resources.model.cuboid/*?} else {*//*renderer.block.model*//*?}*/.ItemModelGenerator; +import net.minecraft.client.renderer.texture.SpriteContents; +import org.polyfrost.polydium.client.perf.UpscaledSprite; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +/** + * Generated item models get one side quad per edge pixel, so a sprite scaled up for mipmapping would multiply them + * by its scale factor. Side faces are built on the sprite's unscaled pixel grid instead, which gives the same shape. + */ +@Mixin(ItemModelGenerator.class) +public abstract class MixinItemModelUpscale { + @WrapOperation(method = {"getSideFaces", /*? if >= 26.1 {*/"bakeSideFaces"/*?} else {*//*"createSideElements"*//*?}*/}, at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/texture/SpriteContents;width()I")) + private static int polydium$unscaledWidth(SpriteContents sprite, Operation original) { + return original.call(sprite) / ((UpscaledSprite) sprite).polydium$upscale(); + } + + @WrapOperation(method = {"getSideFaces", /*? if >= 26.1 {*/"bakeSideFaces"/*?} else {*//*"createSideElements"*//*?}*/}, at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/texture/SpriteContents;height()I")) + private static int polydium$unscaledHeight(SpriteContents sprite, Operation original) { + return original.call(sprite) / ((UpscaledSprite) sprite).polydium$upscale(); + } + + @WrapOperation(method = "isTransparent", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/texture/SpriteContents;isTransparent(III)Z")) + private static boolean polydium$unscaledTransparent(SpriteContents sprite, int frame, int x, int y, Operation original) { + int factor = ((UpscaledSprite) sprite).polydium$upscale(); + return original.call(sprite, frame, x * factor, y * factor); + } +} +//?} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSectionAirCount.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSectionAirCount.java new file mode 100644 index 0000000..0ed2d2e --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSectionAirCount.java @@ -0,0 +1,37 @@ +package org.polyfrost.polydium.mixin.client.perf; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.PalettedContainer; +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; + +/** + * The client trusts the block count a server writes in front of each chunk section, and both vanilla and Sodium skip + * meshing any section whose count is zero. Servers that build chunk packets themselves (arena instancing, spectator + * cameras, protocol translators) sometimes send zero for sections that hold blocks, which leaves sky-coloured holes + * until the chunk is resent. A zero count with a palette that still names a non-air state is recounted from the data. + */ +@Mixin(LevelChunkSection.class) +public abstract class MixinSectionAirCount { + @Shadow + private short nonEmptyBlockCount; + + @Shadow + @Final + private PalettedContainer states; + + @Shadow + public abstract void recalcBlockCounts(); + + @Inject(method = "read", at = @At("TAIL")) + private void polydium$recountEmptyClaim(FriendlyByteBuf buffer, CallbackInfo ci) { + if (this.nonEmptyBlockCount == 0 && this.states.maybeHas(state -> !state.isAir())) + this.recalcBlockCounts(); + } +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteContentsUpscale.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteContentsUpscale.java new file mode 100644 index 0000000..685d381 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteContentsUpscale.java @@ -0,0 +1,22 @@ +package org.polyfrost.polydium.mixin.client.perf; + +import net.minecraft.client.renderer.texture.SpriteContents; +import org.polyfrost.polydium.client.perf.UpscaledSprite; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(SpriteContents.class) +public abstract class MixinSpriteContentsUpscale implements UpscaledSprite { + @Unique + private int polydium$upscale = 1; + + @Override + public int polydium$upscale() { + return this.polydium$upscale; + } + + @Override + public void polydium$setUpscale(int factor) { + this.polydium$upscale = factor; + } +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteMipUpscale.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteMipUpscale.java new file mode 100644 index 0000000..a3c0861 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteMipUpscale.java @@ -0,0 +1,139 @@ +package org.polyfrost.polydium.mixin.client.perf; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.mojang.blaze3d.platform.NativeImage; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import net.minecraft.ReportedException; +import net.minecraft.client.renderer.texture.SpriteContents; +import net.minecraft.client.renderer.texture.SpriteLoader; +import net.minecraft.client.renderer.texture.StitcherException; +import org.polyfrost.polydium.client.perf.UpscaledSprite; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +/** + * Vanilla lowers the mip level of a whole atlas to fit its least power-of-two sprite, so one odd-sized texture from a + * resource pack (a 3x4 vine leaf, say) turns off mipmaps for every block and distant terrain aliases. Static sprites + * that would cap the level are scaled up by an integer factor with nearest sampling instead, which looks the same at + * mip 0 because sprite UVs are normalised to the sprite's atlas rectangle. + */ +@Mixin(SpriteLoader.class) +public abstract class MixinSpriteMipUpscale { + @Unique + private static final int MAX_SIZE = 1024; + // 256 MiB of RGBA across all scaled copies, which stay alive with the atlas + @Unique + private static final long MAX_SCALED_PIXELS = 1L << 26; + + // Upscaling raises the atlas mip level, and with it the per-sprite padding, so an atlas that fit before can overflow. + // Then the original images go back and vanilla stitches them as it would have without this. + @WrapMethod(method = "stitch") + private SpriteLoader.Preparations polydium$upscaleCappingSprites(List sprites, int maxMipmapLevels, Executor executor, Operation original) { + int target = maxMipmapLevels; + int lowestUnscaled = 1 << maxMipmapLevels; + + for (SpriteContents sprite : sprites) { + int lowest = polydium$lowestOneBit(sprite); + lowestUnscaled = Math.min(lowestUnscaled, lowest); + if (lowest < 1 << maxMipmapLevels) + target = Math.min(target, Integer.numberOfTrailingZeros(lowest * polydium$maxFactor(sprite))); + } + + if (target <= Integer.numberOfTrailingZeros(lowestUnscaled)) + return original.call(sprites, maxMipmapLevels, executor); + + int needed = 1 << target; + long pixels = 0; + for (SpriteContents sprite : sprites) { + int factor = needed / polydium$lowestOneBit(sprite); + if (factor > 1) + pixels += (long) sprite.width() * sprite.height() * factor * factor; + } + + if (pixels > MAX_SCALED_PIXELS) + return original.call(sprites, maxMipmapLevels, executor); + + List scaledSprites = new ArrayList<>(); + List sources = new ArrayList<>(); + + try { + for (SpriteContents sprite : sprites) { + int lowest = polydium$lowestOneBit(sprite); + if (lowest >= needed) + continue; + + int factor = needed / lowest; + SpriteContentsAccessor access = (SpriteContentsAccessor) sprite; + NativeImage source = access.polydium$originalImage(); + NativeImage scaled = new NativeImage(sprite.width() * factor, sprite.height() * factor, false); + polydium$setImage(access, scaled); + ((UpscaledSprite) sprite).polydium$setUpscale(factor); + scaledSprites.add(sprite); + sources.add(source); + + for (int y = 0; y < scaled.getHeight(); y++) { + for (int x = 0; x < scaled.getWidth(); x++) { + //? if >= 1.21.4 { + scaled.setPixel(x, y, source.getPixel(x / factor, y / factor)); + //?} else { + /*scaled.setPixelRGBA(x, y, source.getPixelRGBA(x / factor, y / factor)); + *///?} + } + } + } + } catch (RuntimeException e) { + polydium$restore(scaledSprites, sources); + return original.call(sprites, maxMipmapLevels, executor); + } + + SpriteLoader.Preparations result; + try { + result = original.call(sprites, maxMipmapLevels, executor); + } catch (ReportedException e) { + if (!(e.getCause() instanceof StitcherException)) + throw e; + polydium$restore(scaledSprites, sources); + return original.call(sprites, maxMipmapLevels, executor); + } + + sources.forEach(NativeImage::close); + return result; + } + + @Unique + private static void polydium$restore(List scaledSprites, List sources) { + for (int i = 0; i < scaledSprites.size(); i++) { + SpriteContentsAccessor access = (SpriteContentsAccessor) scaledSprites.get(i); + NativeImage scaled = access.polydium$originalImage(); + polydium$setImage(access, sources.get(i)); + ((UpscaledSprite) scaledSprites.get(i)).polydium$setUpscale(1); + scaled.close(); + } + } + + @Unique + private static int polydium$lowestOneBit(SpriteContents sprite) { + return Math.min(Integer.lowestOneBit(sprite.width()), Integer.lowestOneBit(sprite.height())); + } + + @Unique + private static int polydium$maxFactor(SpriteContents sprite) { + SpriteContentsAccessor access = (SpriteContentsAccessor) sprite; + NativeImage source = access.polydium$originalImage(); + int size = Math.max(sprite.width(), sprite.height()); + if (access.polydium$frameCount() > 1 || size > MAX_SIZE || source.getWidth() != sprite.width() || source.getHeight() != sprite.height() || source.format() != NativeImage.Format.RGBA) + return 1; + return Integer.highestOneBit(MAX_SIZE / size); + } + + @Unique + private static void polydium$setImage(SpriteContentsAccessor access, NativeImage image) { + access.polydium$setWidth(image.getWidth()); + access.polydium$setHeight(image.getHeight()); + access.polydium$setOriginalImage(image); + access.polydium$setByMipLevel(new NativeImage[]{image}); + } +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/SpriteContentsAccessor.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/SpriteContentsAccessor.java new file mode 100644 index 0000000..c0d2e04 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/SpriteContentsAccessor.java @@ -0,0 +1,32 @@ +package org.polyfrost.polydium.mixin.client.perf; + +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.client.renderer.texture.SpriteContents; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(SpriteContents.class) +public interface SpriteContentsAccessor { + @Accessor("originalImage") + NativeImage polydium$originalImage(); + + @Mutable + @Accessor("originalImage") + void polydium$setOriginalImage(NativeImage image); + + @Accessor("byMipLevel") + void polydium$setByMipLevel(NativeImage[] images); + + @Mutable + @Accessor("width") + void polydium$setWidth(int width); + + @Mutable + @Accessor("height") + void polydium$setHeight(int height); + + @Invoker("getFrameCount") + int polydium$frameCount(); +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/sodiumextra/MixinExpandedCylindricalGate.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/sodiumextra/MixinExpandedCylindricalGate.java new file mode 100644 index 0000000..fa2a7a3 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/sodiumextra/MixinExpandedCylindricalGate.java @@ -0,0 +1,9 @@ +package org.polyfrost.polydium.mixin.client.perf.sodiumextra; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; + +@Pseudo +@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.chunk.occlusion.OcclusionCuller", remap = false, priority = 1100) +public abstract class MixinExpandedCylindricalGate { +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/tiertagger/MixinTierTaggerModeCache.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/tiertagger/MixinTierTaggerModeCache.java new file mode 100644 index 0000000..b76a1c0 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/tiertagger/MixinTierTaggerModeCache.java @@ -0,0 +1,41 @@ +package org.polyfrost.polydium.mixin.client.perf.tiertagger; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Unique; +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.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +// TierTagger streams its gamemode list on every Player.getDisplayName, several times per player per frame. +// The list only changes in init(), which swaps in a fresh cache so lookups that raced it can't stick. +@Pseudo +@Mixin(targets = {"net.uku3lig.tiertagger.TierCache", "com.kevin.tiertagger.TierCache"}, remap = false) +public class MixinTierTaggerModeCache { + @Unique + private static volatile Map> polydium$modes = new ConcurrentHashMap<>(); + + @SuppressWarnings({"rawtypes", "unchecked"}) + @WrapMethod(method = "findMode", remap = false, require = 0) + private static Optional polydium$cachedMode(String id, Operation original) { + if (id == null) return original.call(id); + Map> modes = polydium$modes; + Optional mode = modes.get(id); + if (mode == null) { + mode = original.call(id); + modes.put(id, mode); + } + return mode; + } + + @Inject(method = "init", at = @At("RETURN"), remap = false, require = 0) + private static void polydium$resetModes(CallbackInfo ci) { + polydium$modes = new ConcurrentHashMap<>(); + } +} diff --git a/src/main/resources/mixins.polydium.json b/src/main/resources/mixins.polydium.json index 25a785a..1f241f8 100644 --- a/src/main/resources/mixins.polydium.json +++ b/src/main/resources/mixins.polydium.json @@ -37,6 +37,12 @@ //? if >= 1.21.1 "client.perf.MixinPaneCapCull", "client.perf.CameraNearPlaneAccessor", + "client.perf.MixinSectionAirCount", + "client.perf.MixinSpriteMipUpscale", + "client.perf.SpriteContentsAccessor", + "client.perf.MixinSpriteContentsUpscale", + //? if >= 1.21.11 + "client.perf.MixinItemModelUpscale", //? if >= 1.21.8 "client.perf.ItemBoundsLayerAccessor", //? if >= 1.21.10 @@ -798,6 +804,10 @@ "client.perf.MixinBlockEntityLightCache", //? if >= 1.21.10 "client.perf.MixinEntityCullingBlockEntityBox", + //? if >= 1.21.10 + "client.perf.MixinEntityCullingPrefetch", + //? if >= 1.21.10 + "client.perf.MixinEntityCullingPrefetchChunk", "client.perf.MixinBlockEntityLightCacheChunks", "client.perf.MixinBlockEntityLightCacheCombiner", "client.perf.MixinBlockEntityLightCacheHolder", @@ -858,6 +868,7 @@ "client.perf.wwaypoints.MixinClientDimensionUtils", "client.perf.wwaypoints.MixinHideWaypointCycle", "client.perf.punchy.MixinPunchyBlacklistCache", + "client.perf.tiertagger.MixinTierTaggerModeCache", "client.perf.gammautils.MixinGammaOptionGate", "client.perf.lambdynlights.MixinDynamicLightOptimisticRead", "client.perf.lambdynlights.MixinLightSourceSetRemove", @@ -870,6 +881,7 @@ "client.perf.scalablelux.MixinClientLightBudget", "client.perf.scalablelux.MixinClientLightBudgetTick", "client.perf.scalablelux.MixinChunkLoadDirtyOnce", + "client.perf.sodiumextra.MixinExpandedCylindricalGate", "client.perf.scalablelux.MixinLightNibbleView", "client.perf.sodium.MixinClonedLightView", "client.perf.particle_core.MixinParticleCoreFinalizeCycle", diff --git a/src/test/kotlin/org/polyfrost/polydium/test/ChunkPrefetchCacheTest.kt b/src/test/kotlin/org/polyfrost/polydium/test/ChunkPrefetchCacheTest.kt new file mode 100644 index 0000000..12d58b7 --- /dev/null +++ b/src/test/kotlin/org/polyfrost/polydium/test/ChunkPrefetchCacheTest.kt @@ -0,0 +1,31 @@ +package org.polyfrost.polydium.test + +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import org.polyfrost.polydium.client.perf.ChunkPrefetchCache + +class ChunkPrefetchCacheTest { + private class Chunk : ChunkPrefetchCache { + var list: Array? = null + var generation = 0 + override fun `polydium$storedPrefetch`() = list + override fun `polydium$prefetchGeneration`() = generation + override fun `polydium$setPrefetch`(cullables: Array?, generation: Int) { + list = cullables + this.generation = generation + } + } + + @Test + fun `a list only survives while its chunk and generation are unchanged`() { + val chunk = Chunk() + val list = arrayOf("chest") + assertNull(chunk.`polydium$prefetch`(1)) + chunk.`polydium$setPrefetch`(list, 1) + assertSame(list, chunk.`polydium$prefetch`(1)) + assertNull(chunk.`polydium$prefetch`(2)) + chunk.`polydium$invalidatePrefetch`() + assertNull(chunk.`polydium$prefetch`(1)) + } +} diff --git a/src/test/kotlin/org/polyfrost/polydium/test/ExpandedCylindricalGateTest.kt b/src/test/kotlin/org/polyfrost/polydium/test/ExpandedCylindricalGateTest.kt new file mode 100644 index 0000000..4c82ff1 --- /dev/null +++ b/src/test/kotlin/org/polyfrost/polydium/test/ExpandedCylindricalGateTest.kt @@ -0,0 +1,112 @@ +package org.polyfrost.polydium.test + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import org.objectweb.asm.tree.AbstractInsnNode +import org.objectweb.asm.tree.ClassNode +import org.objectweb.asm.tree.FieldNode +import org.objectweb.asm.tree.InsnList +import org.objectweb.asm.tree.InsnNode +import org.objectweb.asm.tree.JumpInsnNode +import org.objectweb.asm.tree.LabelNode +import org.objectweb.asm.tree.MethodInsnNode +import org.objectweb.asm.tree.MethodNode +import org.objectweb.asm.tree.VarInsnNode +import org.objectweb.asm.tree.analysis.Analyzer +import org.objectweb.asm.tree.analysis.BasicVerifier +import org.polyfrost.polydium.mixin.PolydiumMixinPlugin + +// Models the bytecode Mixin 0.8.7's ModifyArgsInjector leaves in processQueue for Sodium Extra 0.9.4's +// sodiumExtra$allowExpandedCylindricalVerticalTraversal(Args) on both the Sodium 0.8 and 0.9 OcclusionCuller. +class ExpandedCylindricalGateTest { + private val owner = "net/caffeinemc/mods/sodium/client/render/chunk/occlusion/OcclusionCuller" + private val argsClass = "org/spongepowered/asm/synthetic/args/Args\$1" + private val handlerName = "handler\$zzz000\$sodium-extra\$sodiumExtra\$allowExpandedCylindricalVerticalTraversal" + private val handlerDesc = "(Lorg/spongepowered/asm/mixin/injection/invoke/arg/Args;)V" + + @Test + fun `static sodium 08 culler skips args packing unless the search distance is expanded`() { + val neighbors = "(Ljava/lang/Object;Ljava/lang/Object;II)V" + val method = processQueue(Opcodes.ACC_PRIVATE or Opcodes.ACC_STATIC, "(Ljava/lang/Object;Ljava/lang/Object;FLjava/lang/Object;)V", neighbors, static = true) { + add(VarInsnNode(Opcodes.ALOAD, 0)) + add(VarInsnNode(Opcodes.ALOAD, 3)) + add(InsnNode(Opcodes.ICONST_1)) + add(InsnNode(Opcodes.ICONST_2)) + } + val target = culler(method) + + PolydiumMixinPlugin.gateExpandedCylindricalTraversal(target, method) + + assertGated(target, method, listOf("isExpandedCylindricalCullDistance", "of", handlerName)) + } + + @Test + fun `instance sodium 09 culler skips args packing unless either search distance is expanded`() { + val neighbors = "(Ljava/lang/Object;Ljava/lang/Object;IIILjava/lang/Object;)V" + val method = processQueue(Opcodes.ACC_PRIVATE, "(Ljava/lang/Object;Ljava/lang/Object;)V", neighbors, static = false) { + add(VarInsnNode(Opcodes.ALOAD, 0)) + add(VarInsnNode(Opcodes.ALOAD, 1)) + add(VarInsnNode(Opcodes.ALOAD, 2)) + add(InsnNode(Opcodes.ICONST_1)) + add(InsnNode(Opcodes.ICONST_2)) + add(InsnNode(Opcodes.ICONST_3)) + add(VarInsnNode(Opcodes.ALOAD, 1)) + } + val target = culler(method).apply { + fields.add(FieldNode(Opcodes.ACC_PRIVATE, "searchDistanceRegular", "F", null, null)) + fields.add(FieldNode(Opcodes.ACC_PRIVATE, "searchDistanceLocal", "F", null, null)) + } + + PolydiumMixinPlugin.gateExpandedCylindricalTraversal(target, method) + + assertGated(target, method, listOf("isExpandedCylindricalCullDistance", "isExpandedCylindricalCullDistance", "of", handlerName)) + } + + private fun culler(method: MethodNode) = ClassNode().apply { + version = Opcodes.V21 + name = owner + superName = "java/lang/Object" + methods.add(method) + } + + private fun processQueue(access: Int, desc: String, neighbors: String, static: Boolean, pushArgs: InsnList.() -> Unit) = + MethodNode(access, "processQueue", desc, null, null).apply { + val args = Type.getArgumentTypes(neighbors) + instructions.add(InsnList().apply(pushArgs)) + instructions.add(MethodInsnNode(Opcodes.INVOKESTATIC, argsClass, "of", Type.getMethodDescriptor(Type.getObjectType(argsClass), *args), false)) + instructions.add(InsnNode(Opcodes.DUP)) + if (static) { + instructions.add(MethodInsnNode(Opcodes.INVOKESTATIC, owner, handlerName, handlerDesc, false)) + } else { + instructions.add(VarInsnNode(Opcodes.ALOAD, 0)) + instructions.add(InsnNode(Opcodes.SWAP)) + instructions.add(MethodInsnNode(Opcodes.INVOKESPECIAL, owner, handlerName, handlerDesc, false)) + } + args.forEachIndexed { i, type -> + val last = i == args.size - 1 + if (!last) instructions.add(InsnNode(Opcodes.DUP)) + instructions.add(MethodInsnNode(Opcodes.INVOKEVIRTUAL, argsClass, "\$$i", "()" + type.descriptor, false)) + if (!last) instructions.add(InsnNode(Opcodes.SWAP)) + } + instructions.add(MethodInsnNode(if (static) Opcodes.INVOKESTATIC else Opcodes.INVOKEVIRTUAL, owner, "visitNeighbors", neighbors, false)) + instructions.add(InsnNode(Opcodes.RETURN)) + maxStack = 16 + maxLocals = 8 + } + + private fun assertGated(target: ClassNode, method: MethodNode, callsBeforeHandler: List) { + val calls = method.instructions.filterIsInstance().map { it.name } + assertEquals(callsBeforeHandler, calls.take(callsBeforeHandler.size)) + + val neighbors = method.instructions.filterIsInstance().single { it.name == "visitNeighbors" } + val skip = method.instructions.filterIsInstance().last() + assertEquals(previousLabel(neighbors), skip.label) + assert(skip.opcode == Opcodes.IFEQ || skip.opcode == Opcodes.GOTO) + + Analyzer(BasicVerifier()).analyze(target.name, method) + } + + private fun previousLabel(insn: AbstractInsnNode): LabelNode? = insn.previous as? LabelNode +}