From 2ec4943253ff5f488a47b349e7951a3a803d1160 Mon Sep 17 00:00:00 2001 From: Raila23 Date: Thu, 30 Apr 2026 01:29:18 +0800 Subject: [PATCH 1/2] perf: add per-tick occupied-chunk cache for fast entity queries Adds a LongSet-based cache of every chunk position occupied by at least one sub-level. The cache is rebuilt from scratch each tick in SubLevelContainer.tick() and provides O(1) lookups via the new isChunkOccupied(chunkX, chunkZ) method. This cache is consumed by SubLevelInclusiveLevelEntityGetter to skip the full sub-level traversal when the queried AABB demonstrably does not intersect any sub-level, dramatically reducing per-call overhead. --- .../sable/api/sublevel/SubLevelContainer.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java index 0e17ffdf..19d67ef4 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java @@ -11,6 +11,8 @@ import dev.ryanhcode.sable.sublevel.storage.SubLevelOccupancySavedData; import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason; import dev.ryanhcode.sable.util.iterator.ListBackedFilterIterator; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.longs.LongSet; import it.unimi.dsi.fastutil.objects.ObjectArrayList; import it.unimi.dsi.fastutil.objects.ObjectList; import net.minecraft.client.multiplayer.ClientLevel; @@ -61,6 +63,16 @@ public abstract class SubLevelContainer { */ private final List observers = new ObjectArrayList<>(); + /** + * A per-tick cache of every chunk position that is occupied by at least one sub-level. + * Rebuilt from scratch each tick in {@link #tick()} so that moving sub-levels + * are tracked correctly. Used by {@link #isChunkOccupied(int, int)} for O(1) lookups. + * + * @see #rebuildOccupiedChunksCache() + * @see #isChunkOccupied(int, int) + */ + private final LongSet occupiedChunks = new LongOpenHashSet(); + /** * The level of the plotgrid */ @@ -143,6 +155,10 @@ public void tick() { this.processSubLevelRemovals(); this.observers.forEach(observer -> observer.tick(this)); + + // Rebuild the occupied-chunk cache so that entity-getter fast-paths + // always see up-to-date positions (sub-levels can move every tick). + this.rebuildOccupiedChunksCache(); } /** @@ -528,4 +544,42 @@ public void removeSubLevel(final SubLevel subLevel, final SubLevelRemovalReason public BitSet getOccupancy() { return this.occupancy; } + + // ───────────────────────────────────────────────────── + // Occupied-chunk cache (fast entity-getter path) + // ───────────────────────────────────────────────────── + + /** + * Rebuilds the occupied-chunk cache from scratch. + * Called every tick so that moving/rotating sub-levels are tracked correctly. + * + *

The cache is a simple {@link LongSet} of chunk-position-encoded longs + * ({@link ChunkPos#asLong(int, int)}). It is consumed by + * {@link #isChunkOccupied(int, int)} which runs in O(1) time.

+ */ + private void rebuildOccupiedChunksCache() { + this.occupiedChunks.clear(); + for (final SubLevel sub : this.allSubLevels) { + final LevelPlot plot = sub.getPlot(); + final ChunkPos min = plot.getChunkMin(); + final ChunkPos max = plot.getChunkMax(); + for (int cx = min.x; cx <= max.x; cx++) { + for (int cz = min.z; cz <= max.z; cz++) { + this.occupiedChunks.add(ChunkPos.asLong(cx, cz)); + } + } + } + } + + /** + * Checks whether any loaded sub-level occupies the given chunk position. + *

This is backed by the per-tick cache and completes in O(1) time.

+ * + * @param chunkX the global chunk X coordinate + * @param chunkZ the global chunk Z coordinate + * @return {@code true} if at least one sub-level occupies this chunk + */ + public boolean isChunkOccupied(final int chunkX, final int chunkZ) { + return this.occupiedChunks.contains(ChunkPos.asLong(chunkX, chunkZ)); + } } From 89f8cbc15069e7411dbfef3c1f7d60ebabe8ba40 Mon Sep 17 00:00:00 2001 From: Raila23 Date: Thu, 30 Apr 2026 01:29:20 +0800 Subject: [PATCH 2/2] perf: short-circuit entity getters when no sub-level is present Before entering the full sub-level-aware traversal, check whether the queried AABB actually overlaps any sub-level by consulting the per-tick occupied-chunk cache. If the region is free of sub-levels the call is forwarded directly to the vanilla delegate, bypassing all Sable wrapper overhead. --- .../SubLevelInclusiveLevelEntityGetter.java | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/dev/ryanhcode/sable/util/SubLevelInclusiveLevelEntityGetter.java b/common/src/main/java/dev/ryanhcode/sable/util/SubLevelInclusiveLevelEntityGetter.java index c02e6a3c..235adc21 100644 --- a/common/src/main/java/dev/ryanhcode/sable/util/SubLevelInclusiveLevelEntityGetter.java +++ b/common/src/main/java/dev/ryanhcode/sable/util/SubLevelInclusiveLevelEntityGetter.java @@ -2,6 +2,7 @@ import dev.ryanhcode.sable.Sable; import dev.ryanhcode.sable.api.SubLevelHelper; +import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; import dev.ryanhcode.sable.companion.math.BoundingBox3d; import dev.ryanhcode.sable.sublevel.SubLevel; import net.minecraft.util.AbortableIterationConsumer; @@ -37,6 +38,43 @@ private static void logError(final AABB aabb) { Sable.LOGGER.error("Aborting entity get for abnormally large AABB: {}", aabb, new Throwable("Stack Trace")); } + /** + * Quick test: does any sub-level exist within (or overlap) this AABB? + * + *

Uses a two-level short-circuit:

+ *
    + *
  1. Dimension-level — if this world has zero loaded sub-levels, skip entirely.
  2. + *
  3. Chunk-level — consult the per-tick occupied-chunk cache + * ({@link SubLevelContainer#isChunkOccupied(int, int)}) for every chunk the AABB + * touches. The cache is rebuilt once per tick and provides O(1) lookups.
  4. + *
+ * + * @return {@code true} if at least one sub-level intersects the given AABB + */ + private boolean hasSubLevelInAABB(final AABB aabb) { + final SubLevelContainer container = SubLevelContainer.getContainer(this.level); + // ① No sub-levels in this dimension at all → fast skip + if (container == null || container.getLoadedCount() == 0) { + return false; + } + + // ② Convert the AABB to chunk-range and probe the cache + final int minCX = (int) Math.floor(aabb.minX) >> 4; + final int maxCX = (int) Math.floor(aabb.maxX) >> 4; + final int minCZ = (int) Math.floor(aabb.minZ) >> 4; + final int maxCZ = (int) Math.floor(aabb.maxZ) >> 4; + + for (int cx = minCX; cx <= maxCX; cx++) { + for (int cz = minCZ; cz <= maxCZ; cz++) { + if (container.isChunkOccupied(cx, cz)) { + return true; + } + } + } + + return false; + } + @Override public @Nullable T get(final int i) { return this.delegate.get(i); @@ -64,6 +102,13 @@ public void get(AABB aABB, final Consumer consumer) { return; } + // ── fast path: no sub-level in this region → vanilla behaviour ── + if (!this.hasSubLevelInAABB(aABB)) { + this.delegate.get(aABB, consumer); + return; + } + + // ── slow path: sub-level-aware traversal ── final SubLevel subLevel = Sable.HELPER.getContaining(this.level, aABB.getCenter()); this.delegate.get(aABB, consumer); @@ -96,6 +141,13 @@ public void get(final EntityTypeTest entityTypeTest, AABB aA return; } + // ── fast path: no sub-level in this region → vanilla behaviour ── + if (!this.hasSubLevelInAABB(aABB)) { + this.delegate.get(entityTypeTest, aABB, abortableIterationConsumer); + return; + } + + // ── slow path: sub-level-aware traversal ── final SubLevel subLevel = Sable.HELPER.getContaining(this.level, aABB.getCenter()); this.delegate.get(entityTypeTest, aABB, abortableIterationConsumer); @@ -113,7 +165,7 @@ public void get(final EntityTypeTest entityTypeTest, AABB aA continue; } - final AABB localBounds = bb.set(aABB).transformInverse(otherSubLevel.logicalPose(), bb).toMojang(); + final AABB localBounds = bb.set(aABB).transformInverse(otherSubLevel.logicalPose(), bakedMatrix, bb).toMojang(); this.delegate.get(entityTypeTest, localBounds, abortableIterationConsumer); }