From 1fe26e6288bacf43c640e3bd862edae6ee1c4f81 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 22 Oct 2026 10:02:53 -0400 Subject: [PATCH] Shared bounds biomes and paged erosion raster - Bounds starcasts share whole-column base biome samples across threads through a heap-sized lock-free memo - Erosion raster geometry tracks station owners in paged local arrays and builds its index map in first-visit order --- .../surface/ErosionFieldCompiler.java | 118 ++++++++---- .../iris/generation/runtime/IrisComplex.java | 39 +++- .../runtime/SharedBoundsBiomes.java | 86 +++++++++ .../surface/ErosionRasterGeometryTest.java | 128 +++++++++++++ .../IrisComplexBoundsBiomeCacheTest.java | 74 +++++++- .../runtime/SharedBoundsBiomesTest.java | 169 ++++++++++++++++++ 6 files changed, 568 insertions(+), 46 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/generation/runtime/SharedBoundsBiomes.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionRasterGeometryTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/SharedBoundsBiomesTest.java diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java index ff4483649..44e4dbbf3 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java @@ -18,6 +18,7 @@ import it.unimi.dsi.fastutil.longs.LongIterator; import it.unimi.dsi.fastutil.longs.LongArrayFIFOQueue; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import java.util.Arrays; import java.util.Objects; public final class ErosionFieldCompiler { @@ -159,44 +160,7 @@ public final class ErosionFieldCompiler { IntArrayList stations = geometry.stations; DoubleArrayList distance = geometry.distance; if (!geometry.prepared) { - for (int station = 0; station < count; station++) { - int radius = prepared.radii()[station]; - int stationX = centerline.x()[station]; - int stationZ = centerline.z()[station]; - if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) { - continue; - } - int minimumZ = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumZ() - stationZ); - int maximumZ = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumZ() - stationZ); - int minimumX = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumX() - stationX); - int maximumX = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumX() - stationX); - for (int deltaZ = minimumZ; deltaZ <= maximumZ; deltaZ++) { - for (int deltaX = minimumX; deltaX <= maximumX; deltaX++) { - int cellX = stationX + deltaX; - int cellZ = stationZ + deltaZ; - double cellDistance = centerline.distanceToSegment(station, cellX, cellZ, SurfaceDistanceTable.SHARED); - if (cellDistance > radius) { - continue; - } - long key = RiverFootprint.pack(cellX, cellZ); - int index = indices.putIfAbsent(key, stations.size()); - if (index < 0) { - stations.add(station); - distance.add(cellDistance); - continue; - } - int existing = stations.getInt(index); - double existingDistance = distance.getDouble(index); - // A cell on a station's cross-section row ties between the two segments meeting - // there; the later station owns it so the row uses the head its own ring bounded. - if (cellDistance < existingDistance - EPSILON - || Math.abs(cellDistance - existingDistance) <= EPSILON && station > existing) { - stations.set(index, station); - distance.set(index, cellDistance); - } - } - } - } + rasterize(centerline, prepared.radii(), count, samplingBounds, indices, stations, distance); geometry.prepared = true; } int oceanStart = oceanStart(centerline, valley, terminal); @@ -912,12 +876,90 @@ public final class ErosionFieldCompiler { return 1D - t * t * (3D - 2D * t); } + static void rasterize(SurfaceCenterline centerline, int[] radii, int count, SurfaceBounds samplingBounds, + Long2IntOpenHashMap indices, IntArrayList stations, DoubleArrayList distance) { + CellPages pages = new CellPages(); + LongArrayList order = new LongArrayList(); + for (int station = 0; station < count; station++) { + int radius = radii[station]; + int stationX = centerline.x()[station]; + int stationZ = centerline.z()[station]; + if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) { + continue; + } + int minimumZ = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumZ() - stationZ); + int maximumZ = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumZ() - stationZ); + int minimumX = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumX() - stationX); + int maximumX = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumX() - stationX); + for (int deltaZ = minimumZ; deltaZ <= maximumZ; deltaZ++) { + for (int deltaX = minimumX; deltaX <= maximumX; deltaX++) { + int cellX = stationX + deltaX; + int cellZ = stationZ + deltaZ; + double cellDistance = centerline.distanceToSegment(station, cellX, cellZ, SurfaceDistanceTable.SHARED); + if (cellDistance > radius) { + continue; + } + int[] page = pages.page(cellX, cellZ); + int offset = CellPages.offset(cellX, cellZ); + int index = page[offset]; + if (index < 0) { + page[offset] = stations.size(); + stations.add(station); + distance.add(cellDistance); + order.add(RiverFootprint.pack(cellX, cellZ)); + continue; + } + int existing = stations.getInt(index); + double existingDistance = distance.getDouble(index); + // A cell on a station's cross-section row ties between the two segments meeting + // there; the later station owns it so the row uses the head its own ring bounded. + if (cellDistance < existingDistance - EPSILON + || Math.abs(cellDistance - existingDistance) <= EPSILON && station > existing) { + stations.set(index, station); + distance.set(index, cellDistance); + } + } + } + } + for (int index = 0; index < order.size(); index++) { + indices.put(order.getLong(index), index); + } + } + private static double side(SurfaceCenterline centerline, int station, int cellX, int cellZ) { double deltaX = cellX - centerline.x()[station]; double deltaZ = cellZ - centerline.z()[station]; return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station); } + private static final class CellPages { + private static final int SHIFT = 6; + private static final int MASK = (1 << SHIFT) - 1; + private final Long2ObjectOpenHashMap pages = new Long2ObjectOpenHashMap<>(); + private long currentKey; + private int[] current; + + private int[] page(int x, int z) { + long key = RiverFootprint.pack(x >> SHIFT, z >> SHIFT); + if (current != null && key == currentKey) { + return current; + } + int[] page = pages.get(key); + if (page == null) { + page = new int[1 << (SHIFT * 2)]; + Arrays.fill(page, -1); + pages.put(key, page); + } + current = page; + currentKey = key; + return page; + } + + private static int offset(int x, int z) { + return ((z & MASK) << SHIFT) | (x & MASK); + } + } + private static final class RasterGeometry { private final Long2IntOpenHashMap indices = new Long2IntOpenHashMap(); private final IntArrayList stations = new IntArrayList(); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index baec4cf00..587b24daf 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -128,8 +128,8 @@ import java.nio.file.Path; import java.util.function.BiFunction; @Data -@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "terrainProgram", "bakeCache", "regionSource", "baseBiomeSource", "caveSource", "rockSource", "fluidSource", "childStyleSeed", "inferredByType", "naturalHeightSource", "naturalTrueBiomeSource", "trueBiomeSource", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D", "hydrologyBanks3D"}) -@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "terrainProgram", "bakeCache", "regionSource", "baseBiomeSource", "caveSource", "rockSource", "fluidSource", "childStyleSeed", "inferredByType", "naturalHeightSource", "naturalTrueBiomeSource", "trueBiomeSource", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D", "hydrologyBanks3D"}) +@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "sharedBoundsBiomes", "terrainProgram", "bakeCache", "regionSource", "baseBiomeSource", "caveSource", "rockSource", "fluidSource", "childStyleSeed", "inferredByType", "naturalHeightSource", "naturalTrueBiomeSource", "trueBiomeSource", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D", "hydrologyBanks3D"}) +@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "sharedBoundsBiomes", "terrainProgram", "bakeCache", "regionSource", "baseBiomeSource", "caveSource", "rockSource", "fluidSource", "childStyleSeed", "inferredByType", "naturalHeightSource", "naturalTrueBiomeSource", "trueBiomeSource", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D", "hydrologyBanks3D"}) public class IrisComplex implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); @@ -163,6 +163,7 @@ public class IrisComplex implements DataProvider { @Getter(AccessLevel.NONE) private final int biomeBoundsSamplingStep; private transient volatile SharedCornerBounds sharedCornerBounds; + private transient volatile SharedBoundsBiomes sharedBoundsBiomes; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private transient volatile TerrainProgram terrainProgram; @@ -2054,6 +2055,21 @@ public class IrisComplex implements DataProvider { return shared; } + private SharedBoundsBiomes sharedBoundsBiomes(ProceduralStream source) { + SharedBoundsBiomes shared = sharedBoundsBiomes; + if (shared != null && shared.source() == source) { + return shared; + } + synchronized (this) { + shared = sharedBoundsBiomes; + if (shared == null || shared.source() != source) { + shared = new SharedBoundsBiomes(source, SharedBoundsBiomes.capacityFor(Runtime.getRuntime().maxMemory())); + sharedBoundsBiomes = shared; + } + return shared; + } + } + private long computePackedBounds(GridBoundsCache cache, Engine engine, IrisInterpolator interpolator, IrisGenerator[] generators, int gx, int gz) { NoiseBounds bounds = sampleBoundsRaw(cache, engine, interpolator, generators, gx, gz); return (((long) Float.floatToRawIntBits((float) bounds.min())) << 32) | (Float.floatToRawIntBits((float) bounds.max()) & 0xFFFFFFFFL); @@ -2688,6 +2704,7 @@ public class IrisComplex implements DataProvider { private Engine engine; private IrisGenerator[] generators; private IdentityHashMap cachedBounds; + private SharedBoundsBiomes sharedBiomes; private boolean integerCoordinates; private boolean inUse; @@ -2702,6 +2719,7 @@ public class IrisComplex implements DataProvider { this.cachedBounds = cachedBounds; this.integerCoordinates = complex.baseBiomeStream instanceof BakedStream || complex.baseBiomeStream instanceof CachedStream2D; + this.sharedBiomes = integerCoordinates ? complex.sharedBoundsBiomes(complex.baseBiomeStream) : null; this.inUse = true; if (sampleSource.get() != complex.baseBiomeStream) { sampleCache.clear(); @@ -2718,6 +2736,7 @@ public class IrisComplex implements DataProvider { engine = null; generators = null; cachedBounds = null; + sharedBiomes = null; inUse = false; } @@ -2728,11 +2747,17 @@ public class IrisComplex implements DataProvider { double sampleZ = integerCoordinates ? (int) zz : zz; IrisBiome bx = sampleCache.get(sampleX, sampleZ); if (bx == null) { - ChunkBakeCache.enterSparse(); - try { - bx = complex.baseBiomeStream.get(xx, zz); - } finally { - ChunkBakeCache.exitSparse(); + bx = sharedBiomes == null ? null : sharedBiomes.get((int) sampleX, (int) sampleZ); + if (bx == null) { + ChunkBakeCache.enterSparse(); + try { + bx = complex.baseBiomeStream.get(xx, zz); + } finally { + ChunkBakeCache.exitSparse(); + } + if (sharedBiomes != null) { + sharedBiomes.put((int) sampleX, (int) sampleZ, bx); + } } sampleCache.put(sampleX, sampleZ, bx); } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/SharedBoundsBiomes.java b/core/src/main/java/art/arcane/iris/generation/runtime/SharedBoundsBiomes.java new file mode 100644 index 000000000..3c3fa5ee4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/runtime/SharedBoundsBiomes.java @@ -0,0 +1,86 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.Objects; + +final class SharedBoundsBiomes { + static final int MINIMUM_CAPACITY = 1 << 16; + static final int MAXIMUM_CAPACITY = 1 << 21; + static final long BYTES_PER_ENTRY = 16L; + static final long HEAP_SHARE = 256L; + private static final VarHandle INTS = MethodHandles.arrayElementVarHandle(int[].class); + private static final VarHandle LONGS = MethodHandles.arrayElementVarHandle(long[].class); + private static final VarHandle OBJECTS = MethodHandles.arrayElementVarHandle(Object[].class); + + private final Object source; + private final long[] keys; + private final Object[] values; + private final int[] stamps; + private final int mask; + + SharedBoundsBiomes(Object source, int capacity) { + if (capacity < 2 || Integer.bitCount(capacity) != 1) { + throw new IllegalArgumentException("capacity must be a power of two of at least 2"); + } + this.source = Objects.requireNonNull(source, "source"); + this.keys = new long[capacity]; + this.values = new Object[capacity]; + this.stamps = new int[capacity]; + this.mask = capacity - 1; + } + + static int capacityFor(long maxMemory) { + long entries = Math.max(0L, maxMemory) / HEAP_SHARE / BYTES_PER_ENTRY; + long bounded = Math.max(MINIMUM_CAPACITY, Math.min(MAXIMUM_CAPACITY, entries)); + return Integer.highestOneBit((int) bounded); + } + + Object source() { + return source; + } + + int slot(int x, int z) { + long mixed = key(x, z); + mixed = (mixed ^ (mixed >>> 30)) * 0xBF58476D1CE4E5B9L; + mixed = (mixed ^ (mixed >>> 27)) * 0x94D049BB133111EBL; + return (int) ((mixed ^ (mixed >>> 31)) & mask); + } + + IrisBiome get(int x, int z) { + long key = key(x, z); + int slot = slot(x, z); + int before = (int) INTS.getAcquire(stamps, slot); + if (before == 0 || (before & 1) != 0) { + return null; + } + long storedKey = (long) LONGS.getAcquire(keys, slot); + Object value = OBJECTS.getAcquire(values, slot); + VarHandle.loadLoadFence(); + int after = (int) INTS.getAcquire(stamps, slot); + if (before != after || storedKey != key) { + return null; + } + return (IrisBiome) value; + } + + void put(int x, int z, IrisBiome biome) { + if (biome == null) { + return; + } + int slot = slot(x, z); + int stamp = (int) INTS.getAcquire(stamps, slot); + if ((stamp & 1) != 0 || !INTS.compareAndSet(stamps, slot, stamp, stamp + 1)) { + return; + } + LONGS.setRelease(keys, slot, key(x, z)); + OBJECTS.setRelease(values, slot, biome); + INTS.setRelease(stamps, slot, stamp + 2); + } + + private static long key(int x, int z) { + return ((long) x << 32) | (z & 0xFFFFFFFFL); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionRasterGeometryTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionRasterGeometryTest.java new file mode 100644 index 000000000..3745b5221 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionRasterGeometryTest.java @@ -0,0 +1,128 @@ +package art.arcane.iris.generation.hydrology.surface; + +import art.arcane.iris.generation.hydrology.HydrologyPoint; +import art.arcane.iris.generation.hydrology.RiverFootprint; +import it.unimi.dsi.fastutil.doubles.DoubleArrayList; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongIterator; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.SplittableRandom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ErosionRasterGeometryTest { + private static final double EPSILON = 1.0E-9D; + + @Test + public void rasterMatchesTheReferenceOwnersDistancesAndIterationOrder() { + SplittableRandom random = new SplittableRandom(0x5EED_E05EL); + int compared = 0; + for (int trial = 0; trial < 160; trial++) { + SurfaceCenterline centerline = randomCenterline(random); + int count = Math.max(1, centerline.size() - random.nextInt(Math.min(4, centerline.size()))); + int[] radii = new int[count]; + for (int station = 0; station < count; station++) { + radii[station] = 1 + random.nextInt(trial % 3 == 0 ? 48 : 12); + } + SurfaceBounds bounds = trial % 4 == 0 ? null : randomBounds(random, centerline); + Long2IntOpenHashMap expectedIndices = new Long2IntOpenHashMap(); + expectedIndices.defaultReturnValue(-1); + IntArrayList expectedStations = new IntArrayList(); + DoubleArrayList expectedDistances = new DoubleArrayList(); + reference(centerline, radii, count, bounds, expectedIndices, expectedStations, expectedDistances); + Long2IntOpenHashMap actualIndices = new Long2IntOpenHashMap(); + actualIndices.defaultReturnValue(-1); + IntArrayList actualStations = new IntArrayList(); + DoubleArrayList actualDistances = new DoubleArrayList(); + ErosionFieldCompiler.rasterize(centerline, radii, count, bounds, actualIndices, actualStations, actualDistances); + + assertEquals(expectedIndices.size(), actualIndices.size()); + LongIterator expectedKeys = expectedIndices.keySet().iterator(); + LongIterator actualKeys = actualIndices.keySet().iterator(); + while (expectedKeys.hasNext()) { + long expectedKey = expectedKeys.nextLong(); + long actualKey = actualKeys.nextLong(); + assertEquals(expectedKey, actualKey); + int expectedIndex = expectedIndices.get(expectedKey); + int actualIndex = actualIndices.get(actualKey); + assertEquals(expectedIndex, actualIndex); + assertEquals(expectedStations.getInt(expectedIndex), actualStations.getInt(actualIndex)); + assertEquals(Double.doubleToLongBits(expectedDistances.getDouble(expectedIndex)), + Double.doubleToLongBits(actualDistances.getDouble(actualIndex))); + compared++; + } + assertEquals(expectedStations.size(), actualStations.size()); + } + assertTrue(compared > 100_000); + } + + private static SurfaceCenterline randomCenterline(SplittableRandom random) { + int originX = random.nextInt(-40_000, 40_000); + int originZ = random.nextInt(-40_000, 40_000); + int points = 2 + random.nextInt(6); + List path = new ArrayList<>(points); + int x = originX; + int z = originZ; + for (int point = 0; point < points; point++) { + path.add(new HydrologyPoint(x, 64, z)); + x += random.nextInt(-90, 91); + z += random.nextInt(-90, 91); + } + return SurfaceCenterline.densify(path); + } + + private static SurfaceBounds randomBounds(SplittableRandom random, SurfaceCenterline centerline) { + int station = random.nextInt(centerline.size()); + int centerX = centerline.x()[station]; + int centerZ = centerline.z()[station]; + int halfWidth = 4 + random.nextInt(60); + int halfDepth = 4 + random.nextInt(60); + return new SurfaceBounds(centerX - halfWidth, centerZ - halfDepth, centerX + halfWidth, centerZ + halfDepth) + .expand(2); + } + + private static void reference(SurfaceCenterline centerline, int[] radii, int count, SurfaceBounds samplingBounds, + Long2IntOpenHashMap indices, IntArrayList stations, DoubleArrayList distance) { + for (int station = 0; station < count; station++) { + int radius = radii[station]; + int stationX = centerline.x()[station]; + int stationZ = centerline.z()[station]; + if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) { + continue; + } + int minimumZ = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumZ() - stationZ); + int maximumZ = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumZ() - stationZ); + int minimumX = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumX() - stationX); + int maximumX = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumX() - stationX); + for (int deltaZ = minimumZ; deltaZ <= maximumZ; deltaZ++) { + for (int deltaX = minimumX; deltaX <= maximumX; deltaX++) { + int cellX = stationX + deltaX; + int cellZ = stationZ + deltaZ; + double cellDistance = centerline.distanceToSegment(station, cellX, cellZ, SurfaceDistanceTable.SHARED); + if (cellDistance > radius) { + continue; + } + long key = RiverFootprint.pack(cellX, cellZ); + int index = indices.putIfAbsent(key, stations.size()); + if (index < 0) { + stations.add(station); + distance.add(cellDistance); + continue; + } + int existing = stations.getInt(index); + double existingDistance = distance.getDouble(index); + if (cellDistance < existingDistance - EPSILON + || Math.abs(cellDistance - existingDistance) <= EPSILON && station > existing) { + stations.set(index, station); + distance.set(index, cellDistance); + } + } + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBoundsBiomeCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBoundsBiomeCacheTest.java index 19b15ff10..401c1b533 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBoundsBiomeCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBoundsBiomeCacheTest.java @@ -71,6 +71,66 @@ public class IrisComplexBoundsBiomeCacheTest { } } + @Test + public void wholeColumnSamplesAreSharedAcrossThreadMemos() throws Exception { + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + ProceduralStream source = ProceduralStream.of( + (x, z) -> x.intValue() < 0 || z.intValue() < 0 ? first : second, + Interpolated.of(value -> 0D, value -> first)); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.get(PreservationRegistry.class)) + .thenReturn(mock(PreservationRegistry.class)); + CachedStream2D stream = spy(new CachedStream2D<>("bounds", mock(Engine.class), source, 16)); + Fixture fixture = new Fixture(stream); + IrisInterpolator interpolator = new IrisInterpolator() + .setFunction(InterpolationMethod.STARCAST_12).setHorizontalScale(1.25D); + fixture.setBounds(interpolator, first, -15.25D, 63.5D); + fixture.setBounds(interpolator, second, 7.75D, 127.25D); + Object otherThread = fixture.newGridCache(); + for (int z = -2; z <= 2; z++) { + for (int x = -2; x <= 2; x++) { + fixture.sample(interpolator, x, z); + } + } + long firstReads = streamReads(stream); + for (int z = -2; z <= 2; z++) { + for (int x = -2; x <= 2; x++) { + NoiseBounds expected = interpolator.interpolateBounds(x, z, (xx, zz) -> + (int) xx < 0 || (int) zz < 0 + ? new NoiseBounds(-15.25D, 63.5D) : new NoiseBounds(7.75D, 127.25D)); + assertBits(expected, fixture.sample(otherThread, interpolator, x, z)); + } + } + assertTrue(firstReads > 0L); + assertEquals(firstReads, streamReads(stream)); + } + } + + @Test + public void replacingAWholeColumnStreamDropsSharedSamples() throws Exception { + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.get(PreservationRegistry.class)) + .thenReturn(mock(PreservationRegistry.class)); + Fixture fixture = new Fixture(new CachedStream2D<>("bounds", mock(Engine.class), constant(first), 16)); + IrisInterpolator interpolator = new IrisInterpolator(); + fixture.setBounds(interpolator, first, -10D, 20D); + fixture.setBounds(interpolator, second, 50D, 80D); + + assertBits(new NoiseBounds(-10D, 20D), fixture.sample(interpolator, 0, 0)); + fixture.complex.setBaseBiomeStream(new CachedStream2D<>("bounds", mock(Engine.class), constant(second), 16)); + assertBits(new NoiseBounds(50D, 80D), fixture.sample(fixture.newGridCache(), interpolator, 0, 0)); + } + } + + private static long streamReads(CachedStream2D stream) { + return mockingDetails(stream).getInvocations().stream() + .filter(invocation -> invocation.getMethod().getName().equals("get")) + .count(); + } + @Test public void uncachedBiomeStreamsPreserveFractionalSamplesWithinOneColumn() throws Exception { IrisBiome first = new IrisBiome(); @@ -248,7 +308,19 @@ public class IrisComplexBoundsBiomeCacheTest { } private NoiseBounds sample(IrisInterpolator interpolator, double x, double z) throws ReflectiveOperationException { - return (NoiseBounds) sample.invoke(complex, cache, null, interpolator, new IrisGenerator[0], x, z); + return sample(cache, interpolator, x, z); + } + + private NoiseBounds sample(Object gridCache, IrisInterpolator interpolator, double x, double z) + throws ReflectiveOperationException { + return (NoiseBounds) sample.invoke(complex, gridCache, null, interpolator, new IrisGenerator[0], x, z); + } + + private Object newGridCache() throws ReflectiveOperationException { + Class cacheClass = Class.forName("art.arcane.iris.generation.runtime.IrisComplex$GridBoundsCache"); + Constructor constructor = cacheClass.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); } } } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/SharedBoundsBiomesTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/SharedBoundsBiomesTest.java new file mode 100644 index 000000000..b1f152f5c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/SharedBoundsBiomesTest.java @@ -0,0 +1,169 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class SharedBoundsBiomesTest { + private static final Object SOURCE = new Object(); + + @Test + public void storedColumnsComeBackAndUnknownColumnsMiss() { + SharedBoundsBiomes cache = new SharedBoundsBiomes(SOURCE, 1 << 10); + IrisBiome biome = new IrisBiome(); + + assertNull(cache.get(12, -40)); + cache.put(12, -40, biome); + + assertSame(biome, cache.get(12, -40)); + assertNull(cache.get(-40, 12)); + assertSame(SOURCE, cache.source()); + } + + @Test + public void originMissesUntilItIsStored() { + SharedBoundsBiomes cache = new SharedBoundsBiomes(SOURCE, 2); + IrisBiome biome = new IrisBiome(); + + assertNull(cache.get(0, 0)); + cache.put(0, 0, biome); + assertSame(biome, cache.get(0, 0)); + } + + @Test + public void nullBiomesAreNeverStored() { + SharedBoundsBiomes cache = new SharedBoundsBiomes(SOURCE, 2); + IrisBiome biome = new IrisBiome(); + cache.put(3, 4, biome); + cache.put(3, 4, null); + + assertSame(biome, cache.get(3, 4)); + } + + @Test + public void fullCoordinateDomainKeepsDistinctColumns() { + int[] coordinates = {0, 1, -1, 1 << 21, -(1 << 21), 30_000_000, -30_000_000, + Integer.MIN_VALUE, Integer.MAX_VALUE}; + IrisBiome biome = new IrisBiome(); + for (int x : coordinates) { + for (int z : coordinates) { + SharedBoundsBiomes cache = new SharedBoundsBiomes(SOURCE, 2); + cache.put(x, z, biome); + for (int otherX : coordinates) { + for (int otherZ : coordinates) { + if (x == otherX && z == otherZ) { + assertSame(biome, cache.get(otherX, otherZ)); + } else { + assertNull(cache.get(otherX, otherZ)); + } + } + } + } + } + } + + @Test + public void aCollidingColumnReplacesTheOldOne() { + SharedBoundsBiomes cache = new SharedBoundsBiomes(SOURCE, 2); + int colliding = 0; + for (int x = 1; x < 100_000; x++) { + if (cache.slot(x, 0) == cache.slot(0, 0)) { + colliding = x; + break; + } + } + assertNotEquals(0, colliding); + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + + cache.put(0, 0, first); + cache.put(colliding, 0, second); + + assertNull(cache.get(0, 0)); + assertSame(second, cache.get(colliding, 0)); + } + + @Test + public void capacityFollowsTheHeapWithinItsBounds() { + long megabyte = 1024L * 1024L; + assertEquals(SharedBoundsBiomes.MINIMUM_CAPACITY, SharedBoundsBiomes.capacityFor(0L)); + assertEquals(SharedBoundsBiomes.MINIMUM_CAPACITY, SharedBoundsBiomes.capacityFor(256L * megabyte)); + assertEquals(1 << 20, SharedBoundsBiomes.capacityFor(6L * 1024L * megabyte)); + assertEquals(SharedBoundsBiomes.MAXIMUM_CAPACITY, SharedBoundsBiomes.capacityFor(64L * 1024L * megabyte)); + long previous = 0L; + for (long heap = 64L * megabyte; heap <= 128L * 1024L * megabyte; heap *= 2L) { + int capacity = SharedBoundsBiomes.capacityFor(heap); + assertEquals(1, Integer.bitCount(capacity)); + assertTrue(capacity >= previous); + assertTrue((long) capacity * SharedBoundsBiomes.BYTES_PER_ENTRY <= Math.max(heap / SharedBoundsBiomes.HEAP_SHARE, + (long) SharedBoundsBiomes.MINIMUM_CAPACITY * SharedBoundsBiomes.BYTES_PER_ENTRY)); + previous = capacity; + } + } + + @Test + public void concurrentReadersNeverSeeAnotherColumnsBiome() throws InterruptedException { + SharedBoundsBiomes cache = new SharedBoundsBiomes(SOURCE, 8); + int[] coordinates = {0, 1 << 21, -(1 << 21), 30_000_000, -30_000_000, + Integer.MIN_VALUE, Integer.MAX_VALUE}; + IrisBiome[][] biomes = new IrisBiome[coordinates.length][coordinates.length]; + for (int x = 0; x < coordinates.length; x++) { + for (int z = 0; z < coordinates.length; z++) { + biomes[x][z] = new IrisBiome(); + } + } + AtomicBoolean torn = new AtomicBoolean(); + AtomicInteger hits = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + List threads = new ArrayList<>(); + for (int worker = 0; worker < 4; worker++) { + int seed = worker; + threads.add(new Thread(() -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + torn.set(true); + return; + } + long state = 0x9E3779B97F4A7C15L * (seed + 1); + for (int step = 0; step < 200_000; step++) { + state ^= state << 13; + state ^= state >>> 7; + state ^= state << 17; + int xi = (int) ((state & Long.MAX_VALUE) % coordinates.length); + int zi = (int) ((state >>> 10) % coordinates.length); + cache.put(coordinates[xi], coordinates[zi], biomes[xi][zi]); + IrisBiome actual = cache.get(coordinates[xi], coordinates[zi]); + if (actual != null) { + hits.incrementAndGet(); + if (actual != biomes[xi][zi]) { + torn.set(true); + } + } + } + })); + } + for (Thread thread : threads) { + thread.start(); + } + start.countDown(); + for (Thread thread : threads) { + thread.join(); + } + assertTrue(hits.get() > 0); + assertFalse(torn.get()); + } +}