From 9f9fe6372957cb0d902ef7d63f9ac06c52aa14b5 Mon Sep 17 00:00:00 2001 From: ABKQPO <93412322+ABKQPO@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:52:45 +0800 Subject: [PATCH] optimize large hologram rendering --- .../structurelib/ClientProxy.java | 291 ++++++++++++------ .../structure/IStructureDefinition.java | 30 +- 2 files changed, 216 insertions(+), 105 deletions(-) diff --git a/src/main/java/com/gtnewhorizon/structurelib/ClientProxy.java b/src/main/java/com/gtnewhorizon/structurelib/ClientProxy.java index 7237d301..17062910 100644 --- a/src/main/java/com/gtnewhorizon/structurelib/ClientProxy.java +++ b/src/main/java/com/gtnewhorizon/structurelib/ClientProxy.java @@ -6,7 +6,6 @@ import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -15,6 +14,7 @@ import net.minecraft.block.Block; import net.minecraft.client.Minecraft; import net.minecraft.client.entity.EntityClientPlayerMP; import net.minecraft.client.gui.GuiNewChat; +import net.minecraft.client.particle.EffectRenderer; import net.minecraft.client.particle.EntityFX; import net.minecraft.client.renderer.Tessellator; import net.minecraft.client.renderer.culling.Frustrum; @@ -62,7 +62,19 @@ public class ClientProxy extends CommonProxy { private static final short[] RGBA_NO_TINT = { 255, 255, 255, 255 }; private static final short[] RGBA_RED_TINT = { 255, 128, 128, 0 }; + /** + * Vanilla {@link EffectRenderer} keeps at most 4000 particles per layer and evicts the surplus with an + * {@code ArrayList.remove(0)}, so spawning more than that in one projection costs a lot of copying for particles + * that are thrown away before they are ever drawn. + */ + private static final int MAX_SPAWNED_PARTICLES_PER_GROUP = 4000; private static final Map allHints = new HashMap<>(); + /** + * Total number of hints held across {@link #allGroups} and {@link #currentHints}, i.e. what + * {@link ConfigurationHandler#getMaxRenderCount()} is compared against. Not the same as {@code allHints.size()}, + * which is keyed by position and therefore collapses hints that several holograms put at the same spot. + */ + private static int storedHintCount; /** * All batches of hints. */ @@ -72,12 +84,36 @@ public class ClientProxy extends CommonProxy { * Current batch of hints. Belongs to the same logical multiblock. */ private static HintGroup currentHints; + /** + * Player position and squared radius captured when {@link #currentHints} was opened, so the per-hint radius test is + * plain arithmetic instead of a player lookup and a config lookup. + */ + private static double hintOriginX, hintOriginY, hintOriginZ; + private static double hintRadiusSq; + /** + * Decorative particles spawned for {@link #currentHints} so far, capped at + * {@link #MAX_SPAWNED_PARTICLES_PER_GROUP}. + */ + private static int spawnedParticles; /** * Collection of all hints. Sorted from farthest (index 0) to nearest (index size - 1) *

* 10k should be more than enough, if not a bit wasteful */ private static final List allHintsForRender = new ArrayList<>(10000); + /** + * Hints with {@link HintParticleInfo#renderThrough} set, collected during the main render pass and drawn afterwards + * with the depth test off. Keeping them out of the main pass means the depth test is toggled once per frame rather + * than once per renderThrough boundary in the distance ordering. + */ + private static final List deferredRenderThrough = new ArrayList<>(); + /** + * Sorts farthest first, so transparent hints blend back to front and the nearest + * {@link ConfigurationHandler#getMaxRenderCount()} hints are the tail of the list. Reads the distance cached by + * {@link HintParticleInfo#cacheDistance} rather than recomputing it for every comparison. + */ + private static final Comparator FARTHEST_FIRST = (a, b) -> Double + .compare(b.distSqToPlayer, a.distSqToPlayer); /** * If the diff to current player position is too great sort the allHints. Initial value is very far off below y=0 * but not huge enough to make a NaN. @@ -89,10 +125,6 @@ public class ClientProxy extends CommonProxy { * if true rebuild the allHints list from all hintOwners (and sort it) */ private static boolean allHintsDirty = false; - /** - * counter keeping track of how many renderThrough hint particle we have. enable optimization if no renderThrough - */ - private static int renderThrough; /** * A throttle map for local player. Integrated server will use the throttle map in super class instead. */ @@ -101,20 +133,25 @@ public class ClientProxy extends CommonProxy { @Override public void hintParticleTinted(World w, int x, int y, int z, IIcon[] icons, short[] RGBa) { ensureHinting(); + if (!isWithinHintRadius(x, y, z)) return; + if (storedHintCount >= ConfigurationHandler.INSTANCE.getMaxRenderCount()) return; HintParticleInfo info = new HintParticleInfo(w, x, y, z, icons, RGBa); + HintGroup existing = allHints.get(info); + // this very projection already covered this position + if (existing == currentHints) return; // check and remove colliding holograms - if (ConfigurationHandler.INSTANCE.isRemoveCollidingHologram()) { - HintGroup dupe = allHints.get(info); - if (dupe != null && dupe != currentHints) { - allGroups.remove(dupe); - removeGroup(dupe); - } + if (existing != null && ConfigurationHandler.INSTANCE.isRemoveCollidingHologram()) { + allGroups.remove(existing); + removeGroup(existing); } allHints.put(info, currentHints); currentHints.getHints().add(info); + storedHintCount++; allHintsDirty = true; + if (spawnedParticles >= MAX_SPAWNED_PARTICLES_PER_GROUP) return; + spawnedParticles++; EntityFX particle = new WeightlessParticleFX( w, x + RANDOM.nextFloat() * 0.5F, @@ -190,10 +227,13 @@ public class ClientProxy extends CommonProxy { } static void removeGroup(HintGroup group) { - for (HintParticleInfo hintParticleInfo : group.getHints()) { - allHints.remove(hintParticleInfo); - if (hintParticleInfo.renderThrough) renderThrough--; + List hints = group.getHints(); + for (int i = 0, size = hints.size(); i < size; i++) { + HintParticleInfo hint = hints.get(i); + // a later hologram may have claimed this position, in which case the mapping is not ours to drop + if (allHints.get(hint) == group) allHints.remove(hint); } + storedHintCount -= hints.size(); allHintsDirty = true; } @@ -227,25 +267,49 @@ public class ClientProxy extends CommonProxy { public void startHinting(World w) { if (!w.isRemote) return; if (currentHints != null) endHinting(w); - currentHints = new HintGroup(); + beginGroup(); } private void ensureHinting() { - if (currentHints == null) currentHints = new HintGroup(); + if (currentHints == null) beginGroup(); + } + + private static void beginGroup() { + int maxCoexisting = ConfigurationHandler.INSTANCE.getMaxCoexistingHologram(); + // >= because we are about to add one more + while (!allGroups.isEmpty() && allGroups.size() >= maxCoexisting) { + removeGroup(allGroups.remove(0)); + } + EntityPlayer player = Minecraft.getMinecraft().thePlayer; + if (player == null) { + hintRadiusSq = Double.POSITIVE_INFINITY; + } else { + hintOriginX = player.posX; + hintOriginY = player.posY; + hintOriginZ = player.posZ; + double radius = ConfigurationHandler.INSTANCE.getMaxRenderRadius(); + hintRadiusSq = radius * radius; + } + spawnedParticles = 0; + currentHints = new HintGroup(); } @Override public void endHinting(World w) { if (!w.isRemote || currentHints == null) return; - while (!allGroups.isEmpty() && allGroups.size() >= ConfigurationHandler.INSTANCE.getMaxCoexistingHologram()) { - allGroups.remove(0); - } if (!currentHints.getHints().isEmpty()) allGroups.add(currentHints); // we use the player existence time here as some worlds don't really advance the time ticker currentHints.setCreationTime(getCurrentPlayer().ticksExisted); currentHints = null; } + private static boolean isWithinHintRadius(int x, int y, int z) { + double dx = x + 0.5 - hintOriginX; + double dy = y + 0.5 - hintOriginY; + double dz = z + 0.5 - hintOriginZ; + return dx * dx + dy * dy + dz * dz <= hintRadiusSq; + } + @Override public long getOverworldTime() { // there is no overworld, better just hope current world time is ok... @@ -311,7 +375,7 @@ public class ClientProxy extends CommonProxy { private static class HintGroup { - private final List hints = new LinkedList<>(); + private final List hints = new ArrayList<>(); private int creationTime = -1; public List getHints() { @@ -335,6 +399,7 @@ public class ClientProxy extends CommonProxy { private final IIcon[] icons; private short[] tint; private boolean renderThrough; + private double distSqToPlayer; private final long creationTime = System.currentTimeMillis(); // use tick time instead maybe @@ -360,10 +425,7 @@ public class ClientProxy extends CommonProxy { } public void setRenderThrough() { - if (!this.renderThrough) { - ClientProxy.renderThrough += 1; - allHintsDirty = true; - } + // no need to invalidate allHintsForRender: the render pass partitions on this flag per frame this.renderThrough = true; } @@ -379,18 +441,14 @@ public class ClientProxy extends CommonProxy { return frustrum.isBoxInFrustum(x + 0.25, y + 0.25, z + 0.25, x + 0.75, y + 0.75, z + 0.75); } - public void draw(Tessellator tes, double eyeX, double eyeY, double eyeZ, int eyeXint, int eyeYint, - int eyeZint) { + public void draw(Tessellator tes, int transparency, double eyeX, double eyeY, double eyeZ, int eyeXint, + int eyeYint, int eyeZint) { double size = 0.5; int brightness = w.blockExists(x, 0, z) ? w.getLightBrightnessForSkyBlocks(x, y, z, 0) : 0; tes.setBrightness(brightness); - tes.setColorRGBA( - (int) (tint[0] * .9F), - (int) (tint[1] * .95F), - (int) (tint[2] * 1F), - ConfigurationHandler.INSTANCE.getHintTransparency()); + tes.setColorRGBA((int) (tint[0] * .9F), (int) (tint[1] * .95F), (int) (tint[2] * 1F), transparency); double X = (x - eyeXint) + 0.25; double Y = (y - eyeYint) + 0.25; @@ -486,8 +544,18 @@ public class ClientProxy extends CommonProxy { return creationTime; } - public double getSquareDistanceTo(Vec3 point) { - return point.squareDistanceTo(x + 0.5, y + 0.5, z + 0.5); + private void cacheDistance(double playerX, double playerY, double playerZ) { + double dx = x + 0.5 - playerX; + double dy = y + 0.5 - playerY; + double dz = z + 0.5 - playerZ; + distSqToPlayer = dx * dx + dy * dy + dz * dz; + } + + private boolean isWithinRadius(double eyeX, double eyeY, double eyeZ, double radiusSq) { + double dx = x + 0.5 - eyeX; + double dy = y + 0.5 - eyeY; + double dz = z + 0.5 - eyeZ; + return dx * dx + dy * dy + dz * dz <= radiusSq; } } @@ -496,12 +564,6 @@ public class ClientProxy extends CommonProxy { static boolean connected = false; static boolean configSent = false; - private void resetPlayerLocation() { - lastPlayerPos.xCoord = Minecraft.getMinecraft().thePlayer.posX; - lastPlayerPos.yCoord = Minecraft.getMinecraft().thePlayer.posY; - lastPlayerPos.zCoord = Minecraft.getMinecraft().thePlayer.posZ; - } - @SubscribeEvent public void onPlayerLogIn(FMLNetworkEvent.ClientConnectedToServerEvent e) { connected = true; @@ -523,42 +585,52 @@ public class ClientProxy extends CommonProxy { @SubscribeEvent public void onClientTick(ClientTickEvent e) { - if (e.phase == Phase.END && Minecraft.getMinecraft().thePlayer != null) { - Vec3 playerPos = Minecraft.getMinecraft().thePlayer.getPosition(1); - boolean sortRequired = false; - // The allGroups is implicitly sorted by creationTime - // here we exploit this ordering to reduce iteration size - int deadline = Minecraft.getMinecraft().thePlayer.ticksExisted - - ConfigurationHandler.INSTANCE.getHintLifespan(); - int i; - for (i = 0; i < allGroups.size(); i++) { - if (allGroups.get(i).getCreationTime() > deadline) { - break; - } - } - if (i != 0) { - sortRequired = true; - // clear this block and go no further - List toRemove = allGroups.subList(0, i); - toRemove.forEach(ClientProxy::removeGroup); - toRemove.clear(); - } - if (allHintsDirty) { - allHintsForRender.clear(); - // we need greatly efficient sequential access and mildly efficient sorting - // rebuild is sort of rare, and it's expected to lag anyway - // so no min-heap here - for (HintGroup c : allGroups) allHintsForRender.addAll(c.getHints()); - sortRequired = true; - } - if (sortRequired || playerPos.squareDistanceTo(lastPlayerPos) > 1e-2) { - // only redo sort if player moved some distance - // default is 0.1 block - // if there was a full rebuild, go sort it as well - allHintsForRender.sort(Comparator.comparingDouble(info -> -info.getSquareDistanceTo(playerPos))); - resetPlayerLocation(); + EntityPlayer player = Minecraft.getMinecraft().thePlayer; + if (e.phase != Phase.END || player == null) return; + boolean sortRequired = false; + // The allGroups is implicitly sorted by creationTime + // here we exploit this ordering to reduce iteration size + int deadline = player.ticksExisted - ConfigurationHandler.INSTANCE.getHintLifespan(); + int i; + for (i = 0; i < allGroups.size(); i++) { + if (allGroups.get(i).getCreationTime() > deadline) { + break; } } + if (i != 0) { + sortRequired = true; + // clear this block and go no further + List toRemove = allGroups.subList(0, i); + toRemove.forEach(ClientProxy::removeGroup); + toRemove.clear(); + } + if (allHintsDirty) { + allHintsForRender.clear(); + // we need greatly efficient sequential access and mildly efficient sorting + // rebuild is sort of rare, and it's expected to lag anyway + // so no min-heap here + for (HintGroup c : allGroups) allHintsForRender.addAll(c.getHints()); + allHintsDirty = false; + sortRequired = true; + } + final int size = allHintsForRender.size(); + if (size == 0) return; + double px = player.posX, py = player.posY, pz = player.posZ; + if (sortRequired || lastPlayerPos.squareDistanceTo(px, py, pz) > resortThreshold(size)) { + // only redo sort if player moved some distance + // if there was a full rebuild, go sort it as well + for (int j = 0; j < size; j++) allHintsForRender.get(j).cacheDistance(px, py, pz); + allHintsForRender.sort(FARTHEST_FIRST); + lastPlayerPos.xCoord = px; + lastPlayerPos.yCoord = py; + lastPlayerPos.zCoord = pz; + } + } + + private static double resortThreshold(int size) { + if (size > 100_000) return 64; // 8 blocks + if (size > 10_000) return 4; // 2 blocks + return 1e-2; // 0.1 blocks } } @@ -605,17 +677,29 @@ public class ClientProxy extends CommonProxy { if (e.world.isRemote) { // flush hints. we are in a different world now. allHintsForRender.clear(); + deferredRenderThrough.clear(); + allHints.clear(); allGroups.clear(); + currentHints = null; + storedHintCount = 0; + allHintsDirty = false; lastPlayerPos.yCoord = -1e30; - renderThrough = 0; // clear throttles. hopefully a world switch is enough long as a cool down. localThrottleMap.clear(); } } + private static int flushIfFull(Tessellator tes, int cubeDrawn) { + if (cubeDrawn + 1 < 4096 / (6 * 2 * 3)) return cubeDrawn; + tes.draw(); + tes.startDrawing(GL11.GL_TRIANGLES); + return 0; + } + @SubscribeEvent public void onRenderWorldLast(RenderWorldLastEvent e) { - if (allHintsForRender.isEmpty()) return; + final int size = allHintsForRender.size(); + if (size == 0) return; // seriously, I'm not a OpenGL expert, so I'm probably doing a lot of very stupid stuff here. // Please consider contributing patches if you find something to optimize. @@ -635,6 +719,15 @@ public class ClientProxy extends CommonProxy { int i0 = (int) d0, i1 = (int) d1, i2 = (int) d2; frustrum.setPosition(d0, d1, d2); + // hoist every config lookup out of the hot loop + final int transparency = ConfigurationHandler.INSTANCE.getHintTransparency(); + final double radius = ConfigurationHandler.INSTANCE.getMaxRenderRadius(); + final double radiusSq = radius * radius; + // allHintsForRender is sorted farthest first, so the nearest hints the budget should keep are the tail of + // the list. Skipping a prefix is O(1) and, unlike counting as we draw, does not make hints pop in and out + // as the player turns around. + final int from = Math.max(0, size - ConfigurationHandler.INSTANCE.getMaxRenderCount()); + GL11.glPushMatrix(); GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_COLOR_BUFFER_BIT); // TODO figure out original states // we need the back facing rendered because the thing is transparent @@ -643,39 +736,41 @@ public class ClientProxy extends CommonProxy { GL11.glEnable(GL11.GL_BLEND); // enable blend so it is transparent GL11.glBlendFunc(GL11.GL_ONE_MINUS_SRC_ALPHA, GL11.GL_SRC_ALPHA); // depth test begin as enabled - boolean renderThrough = false; Minecraft.getMinecraft().renderEngine.bindTexture(TextureMap.locationBlocksTexture); GL11.glTranslated(-d0 + i0, -d1 + i1, -d2 + i2); Tessellator tes = Tessellator.instance; tes.startDrawing(GL11.GL_TRIANGLES); - for (int i = 0, allHintsForRenderSize = allHintsForRender.size(), cubeDrawn = 0; i - < allHintsForRenderSize; i++, cubeDrawn++) { + int cubeDrawn = 0; + for (int i = from; i < size; i++) { HintParticleInfo hint = allHintsForRender.get(i); + if (!hint.isWithinRadius(d0, d1, d2, radiusSq)) continue; if (!hint.isInFrustrum(frustrum)) continue; - if (renderThrough != hint.renderThrough) { - if (i > 0) { - p.endStartSection("Draw"); - tes.draw(); - tes.startDrawing(GL11.GL_TRIANGLES); - cubeDrawn = 0; - p.endStartSection("Prepare"); - } - if (hint.renderThrough) GL11.glDisable(GL11.GL_DEPTH_TEST); - else GL11.glEnable(GL11.GL_DEPTH_TEST); - renderThrough = hint.renderThrough; + if (hint.renderThrough) { + // drawn in a second pass, so the depth test is toggled once rather than at every boundary + deferredRenderThrough.add(hint); + continue; } - // Minecraft Tessellator draw in batch of 4096. This plays nicely with quads, but for trigs - // it might occasionally end up with one half in first batch and the other half in later batch. - // so we manually flush the buffer here if that would happen. - // 6 faces per particle, 2 trig per face, 3 vertex per trig - if ((cubeDrawn + 1) >= 4096 / (6 * 2 * 3)) { - tes.draw(); - tes.startDrawing(GL11.GL_TRIANGLES); - } - hint.draw(tes, d0, d1, d2, i0, i1, i2); + cubeDrawn = flushIfFull(tes, cubeDrawn); + hint.draw(tes, transparency, d0, d1, d2, i0, i1, i2); + cubeDrawn++; } p.endStartSection("Draw"); tes.draw(); + + if (!deferredRenderThrough.isEmpty()) { + p.endStartSection("Prepare"); + GL11.glDisable(GL11.GL_DEPTH_TEST); + tes.startDrawing(GL11.GL_TRIANGLES); + cubeDrawn = 0; + for (int i = 0, deferred = deferredRenderThrough.size(); i < deferred; i++) { + cubeDrawn = flushIfFull(tes, cubeDrawn); + deferredRenderThrough.get(i).draw(tes, transparency, d0, d1, d2, i0, i1, i2); + cubeDrawn++; + } + p.endStartSection("Draw"); + tes.draw(); + deferredRenderThrough.clear(); + } p.endSection(); GL11.glPopAttrib(); diff --git a/src/main/java/com/gtnewhorizon/structurelib/structure/IStructureDefinition.java b/src/main/java/com/gtnewhorizon/structurelib/structure/IStructureDefinition.java index 51d9d4bd..01e12063 100644 --- a/src/main/java/com/gtnewhorizon/structurelib/structure/IStructureDefinition.java +++ b/src/main/java/com/gtnewhorizon/structurelib/structure/IStructureDefinition.java @@ -12,6 +12,7 @@ import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.item.ItemStack; import net.minecraft.world.World; +import com.gtnewhorizon.structurelib.ConfigurationHandler; import com.gtnewhorizon.structurelib.StructureLib; import com.gtnewhorizon.structurelib.StructureLibAPI; import com.gtnewhorizon.structurelib.alignment.constructable.ChannelDataAccessor; @@ -443,6 +444,8 @@ public interface IStructureDefinition { return success; } else { if (hintsOnly) { + // elements are not strictly required to have a side effect free check, hence the opt out + final boolean skipCorrectlyPlaced = ConfigurationHandler.INSTANCE.isSkipCorrectlyPlacedHints(); StructureUtility.iterateV2( elements, world, @@ -453,14 +456,27 @@ public interface IStructureDefinition { basePositionA, basePositionB, basePositionC, - ignoreBlockUnloaded((e, w, x, y, z, a, b, c) -> { - e.spawnHint(object, world, x, y, z, trigger); - if (ChannelDataAccessor.hasSubChannel(trigger, StructureLibAPI.CHANNEL_SHOW_ERROR) - && !e.couldBeValid(object, world, x, y, z, trigger)) { - StructureLibAPI.markHintParticleError(StructureLib.getCurrentPlayer(), world, x, y, z); + new IStructureWalker() { + + @Override + public boolean visit(IStructureElement e, World w, int x, int y, int z, int a, int b, + int c) { + if (skipCorrectlyPlaced && e.check(object, w, x, y, z)) return true; + e.spawnHint(object, w, x, y, z, trigger); + if (ChannelDataAccessor.hasSubChannel(trigger, StructureLibAPI.CHANNEL_SHOW_ERROR) + && !e.couldBeValid(object, w, x, y, z, trigger)) { + StructureLibAPI.markHintParticleError(StructureLib.getCurrentPlayer(), w, x, y, z); + } + return true; } - return true; - }), + + @Override + public boolean blockNotLoaded(IStructureElement e, World w, int x, int y, int z, int a, + int b, int c) { + e.spawnHint(object, w, x, y, z, trigger); + return true; + } + }, "spawnHint"); } else { StructureUtility.iterateV2(