Skip to content

Commit 5879de2

Browse files
committed
Cull distant dropped item labels
1 parent 6f2a6e2 commit 5879de2

4 files changed

Lines changed: 270 additions & 18 deletions

File tree

common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@
5656
import org.joml.Quaternionf;
5757
import org.joml.Vector3f;
5858

59+
import java.util.ArrayList;
60+
import java.util.Collection;
5961
import java.util.HashMap;
6062
import java.util.HashSet;
63+
import java.util.Iterator;
6164
import java.util.List;
6265
import java.util.Map;
6366
import java.util.Set;
@@ -71,6 +74,8 @@
7174
public final class DroppedItemDisplay extends VisualizerRunnableDisplay implements Listener {
7275

7376
public static final EntryKey KEY = new EntryKey("item");
77+
private static final int DEFAULT_VIEW_DISTANCE = 64;
78+
private static final int VIEW_DISTANCE_HYSTERESIS = 16;
7479

7580
private final Map<UUID, Item> trackedItems = new HashMap<>();
7681
private final Map<UUID, TextDisplay> labels = new HashMap<>();
@@ -87,6 +92,7 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen
8792
private int updateRate = 20;
8893
private int ticksUntilUpdate;
8994
private int despawnTicks = 6000;
95+
private int viewDistance = DEFAULT_VIEW_DISTANCE;
9096
private boolean stripColorBlacklist;
9197
private DroppedItemBlacklist blacklist = DroppedItemBlacklist.compile(List.of(), DroppedItemDisplay::warn);
9298

@@ -107,6 +113,11 @@ public void onReload(InteractionVisualizerReloadEvent event) {
107113
.getDouble("Entities.Item.Options.LabelYOffset");
108114
labelYOffset = Double.isFinite(configuredLabelYOffset) ? configuredLabelYOffset : 0.8D;
109115
updateRate = Math.max(1, InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.UpdateRate"));
116+
int configuredViewDistance = InteractionVisualizer.plugin.getConfiguration()
117+
.getInt("Entities.Item.Options.ViewDistance");
118+
viewDistance = configuredViewDistance > 0
119+
? Math.max(8, Math.min(512, configuredViewDistance))
120+
: DEFAULT_VIEW_DISTANCE;
110121
int configuredDespawnTicks = InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.DespawnTicks");
111122
despawnTicks = configuredDespawnTicks > 0 ? configuredDespawnTicks : 6000;
112123
stripColorBlacklist = InteractionVisualizer.plugin.getConfiguration()
@@ -200,32 +211,76 @@ private void track(Item item) {
200211
}
201212

202213
private void tickAll() {
203-
reconcileEligibleViewers();
204-
for (Map.Entry<UUID, Item> entry : new HashMap<>(trackedItems).entrySet()) {
214+
Collection<Player> viewers = reconcileEligibleViewers();
215+
DroppedItemSpatialIndex.ViewerIndex viewerIndex = new DroppedItemSpatialIndex.ViewerIndex();
216+
for (Player viewer : viewers) {
217+
Location location = viewer.getLocation();
218+
viewerIndex.addViewer(viewer.getWorld().getUID(), location.getX(), location.getY(), location.getZ());
219+
}
220+
221+
List<TrackedItem> validItems = new ArrayList<>(trackedItems.size());
222+
Iterator<Map.Entry<UUID, Item>> iterator = trackedItems.entrySet().iterator();
223+
while (iterator.hasNext()) {
224+
Map.Entry<UUID, Item> entry = iterator.next();
205225
Item item = entry.getValue();
206226
if (!item.isValid() || item.isDead()) {
207-
remove(entry.getKey());
227+
iterator.remove();
228+
removeLabel(entry.getKey());
208229
continue;
209230
}
210-
update(item);
231+
validItems.add(new TrackedItem(entry.getKey(), item, item.getLocation()));
232+
}
233+
234+
if (viewerIndex.isEmpty()) {
235+
for (UUID itemId : new HashSet<>(labels.keySet())) {
236+
removeLabel(itemId);
237+
}
238+
return;
239+
}
240+
241+
DroppedItemSpatialIndex itemIndex = new DroppedItemSpatialIndex();
242+
for (TrackedItem tracked : validItems) {
243+
Location location = tracked.location();
244+
itemIndex.addItem(tracked.item().getWorld().getUID(),
245+
location.getX(), location.getY(), location.getZ());
246+
}
247+
for (TrackedItem tracked : validItems) {
248+
update(tracked, itemIndex, viewerIndex);
211249
}
212250
}
213251

214-
private void update(Item item) {
252+
private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex,
253+
DroppedItemSpatialIndex.ViewerIndex viewerIndex) {
254+
Item item = tracked.item();
255+
Location itemLocation = tracked.location();
256+
TextDisplay label = labels.get(tracked.itemId());
257+
int trackingDistance = InteractionVisualizer.playerTrackingRange
258+
.getOrDefault(item.getWorld(), DEFAULT_VIEW_DISTANCE);
259+
int effectiveViewDistance = Math.min(viewDistance, trackingDistance);
260+
int cullingDistance = label == null
261+
? effectiveViewDistance
262+
: effectiveViewDistance + VIEW_DISTANCE_HYSTERESIS;
263+
if (!viewerIndex.hasViewerWithin(item.getWorld().getUID(),
264+
itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), cullingDistance)) {
265+
removeLabel(tracked.itemId());
266+
return;
267+
}
268+
215269
ItemStack stack = item.getItemStack();
216270
String matchingName = matchingName(stack);
217271
NamespacedKey customItemId = blacklist.requiresCustomItemId()
218272
? CustomContentManager.customItemId(stack).orElse(null)
219273
: null;
220274
int ticksLeft = despawnTicks - item.getTicksLived();
221275
if (stack.isEmpty() || blacklist.matches(matchingName, stack.getType(), customItemId)
222-
|| item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 || isCramping(item)) {
276+
|| item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0
277+
|| (cramp > 0 && itemIndex.exceedsItemLimit(item.getWorld().getUID(),
278+
itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), cramp))) {
223279
removeLabel(item.getUniqueId());
224280
return;
225281
}
226282

227283
Component text = format(stack, ticksLeft);
228-
TextDisplay label = labels.get(item.getUniqueId());
229284
boolean created = false;
230285
if (label == null || !label.isValid() || !label.getWorld().equals(item.getWorld())) {
231286
removeLabel(item.getUniqueId());
@@ -236,6 +291,10 @@ private void update(Item item) {
236291
if (!text.equals(label.text())) {
237292
label.text(text);
238293
}
294+
float targetViewRange = labelViewRange();
295+
if (Math.abs(label.getViewRange() - targetViewRange) > 1.0E-4F) {
296+
label.setViewRange(targetViewRange);
297+
}
239298
boolean mounted = item.equals(label.getVehicle()) || item.addPassenger(label);
240299
if (mounted) {
241300
// A mounted display follows the item on every client render frame.
@@ -274,7 +333,7 @@ private TextDisplay spawnLabel(Item item) {
274333
display.setSilent(true);
275334
display.setNoPhysics(true);
276335
display.setBillboard(Display.Billboard.CENTER);
277-
display.setViewRange(1.0F);
336+
display.setViewRange(labelViewRange());
278337
display.setInterpolationDuration(0);
279338
display.setTeleportDuration(0);
280339
display.setShadowed(true);
@@ -287,6 +346,10 @@ private TextDisplay spawnLabel(Item item) {
287346
});
288347
}
289348

349+
private float labelViewRange() {
350+
return (float) Math.max(0.125D, Math.min(8.0D, viewDistance / 64.0D));
351+
}
352+
290353
private Location labelLocation(Item item) {
291354
return item.getLocation().add(0.0, labelYOffset, 0.0);
292355
}
@@ -308,7 +371,7 @@ private static void setLabelVerticalTranslation(TextDisplay label, float targetY
308371
new Quaternionf(current.getRightRotation())));
309372
}
310373

311-
private void reconcileEligibleViewers() {
374+
private Collection<Player> reconcileEligibleViewers() {
312375
Map<UUID, Player> desired = new HashMap<>();
313376
for (Player player : InteractionVisualizerAPI.getPlayerModuleList(Modules.HOLOGRAM, KEY)) {
314377
if (player.isOnline()) {
@@ -338,6 +401,7 @@ private void reconcileEligibleViewers() {
338401
}
339402
}
340403
}
404+
return desired.values();
341405
}
342406

343407
private void showToEligibleViewers(TextDisplay label) {
@@ -390,15 +454,6 @@ private String matchingName(ItemStack stack) {
390454
return stripColorBlacklist ? ChatColorUtils.stripColor(plain) : plain;
391455
}
392456

393-
private boolean isCramping(Item item) {
394-
return cramp > 0 && item.getWorld()
395-
.getNearbyEntitiesByType(Item.class, item.getLocation(), 0.5, 0.5, 0.5)
396-
.stream()
397-
.filter(nearby -> !isOwned(nearby))
398-
.limit(cramp + 1L)
399-
.count() > cramp;
400-
}
401-
402457
private void remove(UUID itemId) {
403458
trackedItems.remove(itemId);
404459
removeLabel(itemId);
@@ -427,4 +482,7 @@ private static boolean isOwned(Entity entity) {
427482
private static NamespacedKey ownerKey() {
428483
return new NamespacedKey(InteractionVisualizer.plugin, "visual_entity");
429484
}
485+
486+
private record TrackedItem(UUID itemId, Item item, Location location) {
487+
}
430488
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* This file is part of InteractionVisualizer.
3+
*
4+
* Copyright (C) 2026. Contributors
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*/
11+
12+
package com.loohp.interactionvisualizer.entities;
13+
14+
import java.util.ArrayList;
15+
import java.util.HashMap;
16+
import java.util.List;
17+
import java.util.Map;
18+
import java.util.UUID;
19+
20+
/** Per-update spatial indexes for dropped items and eligible viewers. */
21+
final class DroppedItemSpatialIndex {
22+
23+
private static final double ITEM_QUERY_RADIUS = 0.5D;
24+
private static final double ITEM_CELL_SIZE = ITEM_QUERY_RADIUS;
25+
private static final double VIEWER_CELL_SIZE = 16.0D;
26+
27+
private final Map<Cell, List<Point>> itemCells = new HashMap<>();
28+
29+
void addItem(UUID worldId, double x, double y, double z) {
30+
itemCells.computeIfAbsent(itemCell(worldId, x, y, z), ignored -> new ArrayList<>())
31+
.add(new Point(x, y, z));
32+
}
33+
34+
boolean exceedsItemLimit(UUID worldId, double x, double y, double z, int maximum) {
35+
if (maximum < 1) {
36+
return false;
37+
}
38+
39+
int minimumX = itemCoordinate(x - ITEM_QUERY_RADIUS);
40+
int maximumX = itemCoordinate(x + ITEM_QUERY_RADIUS);
41+
int minimumY = itemCoordinate(y - ITEM_QUERY_RADIUS);
42+
int maximumY = itemCoordinate(y + ITEM_QUERY_RADIUS);
43+
int minimumZ = itemCoordinate(z - ITEM_QUERY_RADIUS);
44+
int maximumZ = itemCoordinate(z + ITEM_QUERY_RADIUS);
45+
int matches = 0;
46+
47+
for (int cellX = minimumX; cellX <= maximumX; cellX++) {
48+
for (int cellY = minimumY; cellY <= maximumY; cellY++) {
49+
for (int cellZ = minimumZ; cellZ <= maximumZ; cellZ++) {
50+
List<Point> points = itemCells.get(new Cell(worldId, cellX, cellY, cellZ));
51+
if (points == null) {
52+
continue;
53+
}
54+
for (Point point : points) {
55+
if (Math.abs(point.x() - x) <= ITEM_QUERY_RADIUS
56+
&& Math.abs(point.y() - y) <= ITEM_QUERY_RADIUS
57+
&& Math.abs(point.z() - z) <= ITEM_QUERY_RADIUS
58+
&& ++matches > maximum) {
59+
return true;
60+
}
61+
}
62+
}
63+
}
64+
}
65+
return false;
66+
}
67+
68+
private static Cell itemCell(UUID worldId, double x, double y, double z) {
69+
return new Cell(worldId, itemCoordinate(x), itemCoordinate(y), itemCoordinate(z));
70+
}
71+
72+
private static int itemCoordinate(double coordinate) {
73+
return (int) Math.floor(coordinate / ITEM_CELL_SIZE);
74+
}
75+
76+
static final class ViewerIndex {
77+
78+
private final Map<ViewerCell, List<Point>> viewerCells = new HashMap<>();
79+
80+
void addViewer(UUID worldId, double x, double y, double z) {
81+
viewerCells.computeIfAbsent(viewerCell(worldId, x, z), ignored -> new ArrayList<>())
82+
.add(new Point(x, y, z));
83+
}
84+
85+
boolean isEmpty() {
86+
return viewerCells.isEmpty();
87+
}
88+
89+
boolean hasViewerWithin(UUID worldId, double x, double y, double z, double range) {
90+
if (range < 0.0D) {
91+
return false;
92+
}
93+
94+
int minimumX = viewerCoordinate(x - range);
95+
int maximumX = viewerCoordinate(x + range);
96+
int minimumZ = viewerCoordinate(z - range);
97+
int maximumZ = viewerCoordinate(z + range);
98+
double rangeSquared = range * range;
99+
100+
for (int cellX = minimumX; cellX <= maximumX; cellX++) {
101+
for (int cellZ = minimumZ; cellZ <= maximumZ; cellZ++) {
102+
List<Point> points = viewerCells.get(new ViewerCell(worldId, cellX, cellZ));
103+
if (points == null) {
104+
continue;
105+
}
106+
for (Point point : points) {
107+
double deltaX = point.x() - x;
108+
double deltaY = point.y() - y;
109+
double deltaZ = point.z() - z;
110+
if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) {
111+
return true;
112+
}
113+
}
114+
}
115+
}
116+
return false;
117+
}
118+
119+
private static ViewerCell viewerCell(UUID worldId, double x, double z) {
120+
return new ViewerCell(worldId, viewerCoordinate(x), viewerCoordinate(z));
121+
}
122+
123+
private static int viewerCoordinate(double coordinate) {
124+
return (int) Math.floor(coordinate / VIEWER_CELL_SIZE);
125+
}
126+
}
127+
128+
private record Cell(UUID worldId, int x, int y, int z) {
129+
}
130+
131+
private record ViewerCell(UUID worldId, int x, int z) {
132+
}
133+
134+
private record Point(double x, double y, double z) {
135+
}
136+
}

common/src/main/resources/config.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ Entities:
281281
#This is in ticks (20 ticks = 1 second)
282282
#Setting this too low might impact performance
283283
UpdateRate: 20
284+
#Maximum distance in blocks at which a dropped-item label is kept active
285+
#Labels outside every eligible player's range are not spawned server-side
286+
ViewDistance: 64
284287
#Paper does not expose its internal per-item despawn rate. This value
285288
#drives the public-API timer (6000 ticks = 5 minutes).
286289
DespawnTicks: 6000
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* This file is part of InteractionVisualizer.
3+
*
4+
* Copyright (C) 2026. Contributors
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*/
11+
12+
package com.loohp.interactionvisualizer.entities;
13+
14+
import org.junit.jupiter.api.Test;
15+
16+
import java.util.UUID;
17+
18+
import static org.junit.jupiter.api.Assertions.assertFalse;
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
class DroppedItemSpatialIndexTest {
22+
23+
@Test
24+
void preservesTheOriginalHalfBlockCrampingBoxAcrossCellBoundaries() {
25+
UUID world = UUID.randomUUID();
26+
DroppedItemSpatialIndex index = new DroppedItemSpatialIndex();
27+
index.addItem(world, 0.49D, 64.0D, 0.49D);
28+
index.addItem(world, 0.99D, 64.5D, -0.01D);
29+
30+
assertTrue(index.exceedsItemLimit(world, 0.49D, 64.0D, 0.49D, 1));
31+
assertFalse(index.exceedsItemLimit(world, 0.49D, 64.0D, 0.49D, 2));
32+
}
33+
34+
@Test
35+
void excludesItemsOutsideTheBoxAndItemsInOtherWorlds() {
36+
UUID world = UUID.randomUUID();
37+
DroppedItemSpatialIndex index = new DroppedItemSpatialIndex();
38+
index.addItem(world, 0.0D, 64.0D, 0.0D);
39+
index.addItem(world, 0.5001D, 64.0D, 0.0D);
40+
index.addItem(UUID.randomUUID(), 0.0D, 64.0D, 0.0D);
41+
42+
assertFalse(index.exceedsItemLimit(world, 0.0D, 64.0D, 0.0D, 1));
43+
}
44+
45+
@Test
46+
void findsViewersAcrossChunkBoundariesUsingExactDistance() {
47+
UUID world = UUID.randomUUID();
48+
DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex();
49+
viewers.addViewer(world, 16.1D, 64.0D, 0.0D);
50+
51+
assertTrue(viewers.hasViewerWithin(world, 15.9D, 64.0D, 0.0D, 0.3D));
52+
assertFalse(viewers.hasViewerWithin(world, 15.9D, 64.0D, 0.0D, 0.1D));
53+
assertFalse(viewers.hasViewerWithin(UUID.randomUUID(), 15.9D, 64.0D, 0.0D, 1.0D));
54+
}
55+
}

0 commit comments

Comments
 (0)