optimize large hologram rendering

This commit is contained in:
ABKQPO committed 2026-10-15 19:52:45 +08:00
1 parent f31de65a1d
commit 9f9fe63729
2 files changed
+216 -105

No files matched your search

@@ -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<HintParticleInfo, HintGroup> 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)
* <p>
* 10k should be more than enough, if not a bit wasteful
*/
private static final List<HintParticleInfo> 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<HintParticleInfo> 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<HintParticleInfo> 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<HintParticleInfo> 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<HintParticleInfo> hints = new LinkedList<>();
private final List<HintParticleInfo> hints = new ArrayList<>();
private int creationTime = -1;
public List<HintParticleInfo> 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<HintGroup> 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<HintGroup> 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();
@@ -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<T> {
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<T> {
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<T>() {
@Override
public boolean visit(IStructureElement<T> 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<T> 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(