Answer server-thread terrain queries from natural terrain

A height or biome query from the tick thread no longer blocks on a cold
hydrology plan: it answers from the natural streams and asks the
planning pool for the tiles meanwhile. A new world prefetches the tiles
around spawn when the generator is injected, and the hydrology pool gets
full parallelism instead of a third. Also stops the shared corner-bounds
cache from pinning a non-finite sample and fences its two stamp reads.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-03 05:03:07 -04:00
parent a4fe527d65
commit fd66b41bad
8 changed files with 115 additions and 9 deletions
@@ -42,6 +42,7 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KList;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.WorldBorder;
@@ -112,6 +113,11 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
}
}
@Override
public boolean isMainThread() {
return Bukkit.isPrimaryThread();
}
@Override
public boolean isPregeneratorActive(Engine engine) {
IrisWorld world = engine.getWorld();
@@ -692,6 +692,26 @@ public class IrisComplex implements DataProvider {
return hydrologyRuntime == null ? null : hydrologyRuntime.sample(x, z).orElse(null);
}
/** Whether the column's hydrology can be sampled without waiting for a plan (see Engine.answersFromNaturalTerrain). */
public boolean isHydrologyPlanned(int x, int z) {
return hydrologyRuntime == null || hydrologyRuntime.isPlanned(x, z);
}
/** The natural terrain height of a column, from the natural height stream only: never touches hydrology or its caches. */
public int naturalTrueHeight(int x, int z) {
return (int) Math.round(naturalHeightStream.getDouble(x, z));
}
/** The surface biome a column would have without any river content, from natural streams only. */
public IrisBiome naturalSurfaceBiome(int x, int z) {
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
if (mapped != null) {
return mapped;
}
double terrainHeight = naturalHeightStream.getDouble(x, z);
return fixBiomeType(terrainHeight, baseBiomeStream.get(x, z), regionStream.get(x, z), (double) x, (double) z, fluidHeight);
}
private HydrologyColumnLayer surfaceLayer(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? null : sample.primarySurfaceLayer().orElse(null);
@@ -928,15 +948,14 @@ public class IrisComplex implements DataProvider {
// catches the same corner reached by another thread or by hydrology planning earlier.
SharedCornerBounds shared = sharedCornerBounds();
long sharedKey = SharedCornerBounds.key(gx, gz, interpolatorIndex);
long packed;
if (shared.contains(sharedKey)) {
packed = shared.get(sharedKey);
if (packed == Long.MIN_VALUE) {
packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz);
shared.put(sharedKey, packed);
}
} else {
long packed = shared.contains(sharedKey) ? shared.get(sharedKey) : Long.MIN_VALUE;
if (packed == Long.MIN_VALUE) {
packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz);
if (!Double.isFinite(boundsLow(packed)) || !Double.isFinite(boundsHigh(packed))) {
// A non-finite corner is a transient sampling fault, not a fact about the terrain:
// hand it back uncached so the next sample recomputes it instead of pinning it.
return packed;
}
shared.put(sharedKey, packed);
}
cache.gx[slot] = gx;
@@ -52,6 +52,7 @@ final class SharedCornerBounds {
return false;
}
long storedKey = (long) LONGS.getAcquire(keys, slot);
VarHandle.loadLoadFence();
int after = (int) STAMPS.getAcquire(stamps, slot);
return before == after && storedKey == key;
}
@@ -69,6 +70,8 @@ final class SharedCornerBounds {
}
long storedKey = (long) LONGS.getAcquire(keys, slot);
long value = (long) LONGS.getAcquire(values, slot);
// The data loads above must not drift past the second stamp read, or a torn slot could pass.
VarHandle.loadLoadFence();
int after = (int) STAMPS.getAcquire(stamps, slot);
if (before != after || storedKey != key) {
return Long.MIN_VALUE;
@@ -333,9 +333,21 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
@BlockCoordinates
default IrisBiome getSurfaceBiome(int x, int z) {
if (answersFromNaturalTerrain(x, z)) {
return getComplex().naturalSurfaceBiome(x, z);
}
return getComplex().getTrueBiomeStream().get(x, z);
}
/**
* The server thread must never wait for a cold hydrology plan, so a height or biome query from it
* on a column whose tiles are not planned yet is answered from natural terrain (the tiles are
* requested from the planning pool meanwhile). Generation threads always wait for the real plan.
*/
default boolean answersFromNaturalTerrain(int x, int z) {
return getPlatformHooks().isMainThread() && !getComplex().isHydrologyPlanned(x, z);
}
@BlockCoordinates
default int getHeight(int x, int z) {
return getHeight(x, z, true);
@@ -343,6 +355,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
@BlockCoordinates
default int getHeight(int x, int z, boolean ignoreFluid) {
if (answersFromNaturalTerrain(x, z)) {
int natural = getComplex().naturalTrueHeight(x, z);
return ignoreFluid ? natural : Math.max(natural, getMantle().getFluidHeight());
}
return getMantle().getHighest(x, z, getData(), ignoreFluid);
}
@@ -53,6 +53,14 @@ public interface EnginePlatformHooks {
return false;
}
/**
* Whether the calling thread is the server's tick thread. Queries from there must never wait
* for hydrology planning, so they answer from natural terrain until the tiles are planned.
*/
default boolean isMainThread() {
return false;
}
default void shutdownPregenerator(Engine engine) {
}
@@ -194,6 +194,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
INMS.get().inject(world.getSeed(), engine, world);
engine.getPlatformHooks().applyWorldBoundary(engine);
IrisLogging.debug("Injected Iris Biome Source into " + world.getName());
prefetchSpawnHydrology(engine, world);
if (!studio) {
J.sfut(() -> updateSpawnLocation(world), 1)
.whenComplete((ignored, failure) -> {
@@ -239,6 +240,44 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
return new Location(world, 0.5D, y, 0.5D);
}
private int hydrologyTileSize() {
Engine activeEngine = engine;
if (activeEngine == null || activeEngine.getComplex() == null || activeEngine.getComplex().getHydrologyRuntime() == null) {
return 0;
}
return activeEngine.getComplex().getHydrologyRuntime().settings().routing().tileSize();
}
/**
* Starts planning the hydrology tiles around the initial spawn as soon as the generator is injected
* into a world whose spawn chunk does not exist yet. The first teleport there makes the chunk system
* generate chunks a few hundred blocks around the spawn, and each of those chunks' mantle windows
* reaches further still, so the area planned ahead is half a tile in every direction: every tile a
* cold entry can touch starts planning at once instead of one generation thread at a time finding
* it cold. A world that already generated its spawn (every existing world at boot, a hotloaded live
* world) skips it.
*/
private void prefetchSpawnHydrology(Engine engine, World world) {
if (engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) {
return;
}
Location spawn = getInitialSpawnLocation(world);
int spawnX = spawn.getBlockX();
int spawnZ = spawn.getBlockZ();
if (world.isChunkGenerated(spawnX >> 4, spawnZ >> 4)) {
return;
}
int reach = Math.max(16, hydrologyTileSize() / 2);
engine.getComplex().getHydrologyRuntime().prefetchArea(
spawnX - reach,
spawnZ - reach,
spawnX + reach - 1,
spawnZ + reach - 1,
spawnX,
spawnZ
);
}
private void updateSpawnLocation(World world) {
try {
Location initialSpawn = getInitialSpawnLocation(world);
@@ -20,7 +20,7 @@ public class MultiBurst extends MultiBurstSupport {
* Long-running hydrology tile planning. Kept off {@link #burst} so a handful of twenty-second
* plans never pin the workers that chunk generation's short stage and mantle tasks need.
*/
public static final MultiBurst hydrology = new MultiBurst("Iris Hydrology", () -> Math.max(2, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) / 3));
public static final MultiBurst hydrology = new MultiBurst("Iris Hydrology", () -> Math.max(2, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism())));
public MultiBurst() {
this("Iris");
@@ -14,6 +14,7 @@ import java.util.HashMap;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNotEquals;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
@@ -48,6 +49,20 @@ public class IrisComplexGridBoundsCacheTest {
assertEquals(1, interpolator.getInvocations());
}
@Test
public void nonFiniteCornerBoundsAreNeverCached() throws Exception {
IrisComplex complex = createComplex();
Method cornerBounds = cornerBoundsMethod();
CountingInterpolator interpolator = new CountingInterpolator(Double.NaN, 7.25D);
long firstPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96);
long secondPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96);
assertTrue(Double.isNaN(unpackLow(firstPacked)));
assertTrue(Double.isNaN(unpackLow(secondPacked)));
assertEquals(2, interpolator.getInvocations());
}
@Test
public void alignedGridSampleUsesOnlyTheContributingCorner() throws Exception {
IrisComplex complex = createComplex();