diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java index 172478b39..40e050675 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java @@ -53,6 +53,7 @@ import java.util.Set; @ComponentFlag(ReservedFlag.FLOATING_OBJECT) public class MantleFloatingObjectComponent extends IrisMantleComponent { + private static final int MINIMUM_PLACEMENT_RADIUS = 16; private static final int INVERTED_PICK_ATTEMPTS = 8; private static final IrisObjectRotation ROTATION_NONE = IrisObjectRotation.of(0, 0, 0); @@ -62,6 +63,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + if (getRadius() == 0) { + return; + } IrisComplex complex = context.getComplex(); if (!complex.allowsNewGenerationChunk(x, z)) { return; @@ -593,7 +597,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { @Override protected int computeRadius() { - int maxObjectExtent = 16; + int maxObjectExtent = 0; Map sizeCache = new HashMap<>(); Set warnedLargeObjects = new HashSet<>(); try { @@ -613,16 +617,20 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (target != null) { maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.resolveTopObjects(target), data, sizeCache, warnedLargeObjects)); maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.resolveBottomObjects(target), data, sizeCache, warnedLargeObjects)); + } else { + maxObjectExtent = Math.max(maxObjectExtent, MINIMUM_PLACEMENT_RADIUS); } } catch (Throwable e) { GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); + maxObjectExtent = Math.max(maxObjectExtent, MINIMUM_PLACEMENT_RADIUS); } } } } catch (Throwable e) { GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); + maxObjectExtent = Math.max(maxObjectExtent, MINIMUM_PLACEMENT_RADIUS); } return maxObjectExtent; } @@ -630,10 +638,13 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { private int computePlacementRadius(KList placements, IrisData data, Map sizeCache, Set warnedLargeObjects) { - int radius = 0; if (placements == null) { - return radius; + return MINIMUM_PLACEMENT_RADIUS; } + if (placements.isEmpty()) { + return 0; + } + int radius = MINIMUM_PLACEMENT_RADIUS; for (IrisObjectPlacement placement : placements) { if (placement == null || placement.getPlace() == null) { continue; diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java index 50dcc3a51..b10fbe171 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java @@ -31,6 +31,14 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.interpolation.IrisInterpolation; import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.FlatNoise; +import art.arcane.volmlib.util.noise.FractalBillowPerlinNoise; +import art.arcane.volmlib.util.noise.FractalBillowSimplexNoise; +import art.arcane.volmlib.util.noise.FractalFBMSimplexNoise; +import art.arcane.volmlib.util.noise.NoiseGenerator; +import art.arcane.volmlib.util.noise.OffsetNoiseGenerator; +import art.arcane.volmlib.util.noise.PerlinNoise; +import art.arcane.volmlib.util.noise.SimplexNoise; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; @@ -50,6 +58,9 @@ import java.util.concurrent.atomic.AtomicReference; public class IrisNoiseGenerator { private static final int GENERATOR_CACHE_SIZE = 32; private static final long GENERATOR_SEED_SALT = 33_955_677L; + private static final int MINIMUM_FRACTURE_DEPTH = 4; + private static final int MAXIMUM_FRACTURE_NODES = 32; + private static final ThreadLocal FRACTURE_SAMPLES = ThreadLocal.withInitial(FractureSamples::new); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @@ -135,6 +146,20 @@ public class IrisNoiseGenerator { return offsetY; } + if (hasDeepFracture()) { + FractureSamples samples = FRACTURE_SAMPLES.get(); + if (!samples.active) { + samples.active = true; + try { + if (samples.prepare(this, superSeed, data, engine)) { + return samples.sample(xv, zv); + } + } finally { + samples.clear(); + } + } + } + double x = xv; double z = zv; int g = 33; @@ -148,17 +173,7 @@ public class IrisNoiseGenerator { g += 819; } - CNG cng = getGenerator(superSeed, data, engine); - double sampleX = (x / zoom) + offsetX; - double sampleZ = (z / zoom) + offsetZ; - double n = cng.noiseFast2D(sampleX, sampleZ) * opacity; - n = negative ? (-n + opacity) : n; - n = (exponent != 1 ? n < 0 ? -Math.pow(-n, exponent) : Math.pow(n, exponent) : n) + offsetY; - n = parametric ? IrisInterpolation.parametric(n, 2D) : n; - n = bezier ? IrisInterpolation.bezier(n) : n; - n = sinCentered ? IrisInterpolation.sinCenter(n) : n; - - return n; + return sample(getGenerator(superSeed, data, engine), x, z); } public KList getAllComposites() { @@ -173,6 +188,65 @@ public class IrisNoiseGenerator { return g; } + private double sample(CNG cng, double x, double z) { + double sampleX = (x / zoom) + offsetX; + double sampleZ = (z / zoom) + offsetZ; + double n = cng.noiseFast2D(sampleX, sampleZ) * opacity; + n = negative ? (-n + opacity) : n; + n = (exponent != 1 ? n < 0 ? -Math.pow(-n, exponent) : Math.pow(n, exponent) : n) + offsetY; + n = parametric ? IrisInterpolation.parametric(n, 2D) : n; + n = bezier ? IrisInterpolation.bezier(n) : n; + n = sinCentered ? IrisInterpolation.sinCenter(n) : n; + + return n; + } + + private boolean hasDeepFracture() { + IrisNoiseGenerator node = this; + for (int depth = 0; depth < MINIMUM_FRACTURE_DEPTH; depth++) { + if (node.getClass() != IrisNoiseGenerator.class || !node.enabled || node.fracture.size() != 1) { + return false; + } + node = node.fracture.get(0); + } + return true; + } + + private boolean hasPureStyle() { + return style != null && style.getClass() == IrisGeneratorStyle.class + && style.getExpression() == null && style.getImageMap() == null + && style.getFracture() == null && style.getCellularFrequency() == 0D + && style.getCacheSize() == 0; + } + + private static boolean isPureGenerator(CNG cng) { + for (int depth = 0; depth < MAXIMUM_FRACTURE_NODES; depth++) { + if (cng.getClass() != CNG.class || cng.getChildren() != null + || cng.getCustomGenerator() != null || cng.getInjector() != CNG.ADD + || !isPureBase(cng.getGenerator())) { + return false; + } + cng = cng.getFracture(); + if (cng == null) { + return true; + } + } + return false; + } + + private static boolean isPureBase(NoiseGenerator generator) { + for (int depth = 0; depth < MAXIMUM_FRACTURE_NODES && generator != null; depth++) { + Class type = generator.getClass(); + if (type != OffsetNoiseGenerator.class) { + return type == SimplexNoise.class || type == PerlinNoise.class || type == FlatNoise.class + || type == FractalBillowPerlinNoise.class || type == FractalBillowSimplexNoise.class + || type == FractalFBMSimplexNoise.class; + } + generator = ((OffsetNoiseGenerator) generator).getBase(); + } + return false; + } + private static final class GeneratorKey { private final IrisData data; private final Engine engine; @@ -208,6 +282,59 @@ public class IrisNoiseGenerator { } } + private static final class FractureSamples { + private final IrisNoiseGenerator[] nodes = new IrisNoiseGenerator[MAXIMUM_FRACTURE_NODES]; + private final CNG[] generators = new CNG[MAXIMUM_FRACTURE_NODES]; + private int size; + private boolean active; + + private boolean prepare(IrisNoiseGenerator node, long superSeed, IrisData data, Engine engine) { + while (size < MAXIMUM_FRACTURE_NODES) { + if (node.getClass() != IrisNoiseGenerator.class || !node.enabled || !node.hasPureStyle() + || node.fracture.size() > 1) { + return false; + } + CNG generator = node.getGenerator(superSeed, data, engine); + if (!isPureGenerator(generator)) { + return false; + } + nodes[size] = node; + generators[size++] = generator; + if (node.fracture.isEmpty()) { + return true; + } + superSeed += node.seed + 33; + node = node.fracture.get(0); + } + return false; + } + + private double sample(double x, double z) { + int leaf = size - 1; + double forward = nodes[leaf].sample(generators[leaf], x, z); + double reverse = nodes[leaf].sample(generators[leaf], z, x); + for (int index = leaf - 1; index >= 0; index--) { + double offset = nodes[index + 1].opacity / 2D; + double forwardX = x + (forward - offset); + double forwardZ = z + (reverse - offset); + forward = nodes[index].sample(generators[index], forwardX, forwardZ); + if (index > 0) { + reverse = nodes[index].sample(generators[index], forwardZ, forwardX); + } + } + return forward; + } + + private void clear() { + for (int index = 0; index < size; index++) { + nodes[index] = null; + generators[index] = null; + } + size = 0; + active = false; + } + } + private static final class CachedGenerator { private final GeneratorKey key; private final CNG generator; 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 74deeedc8..a57ca67b3 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 @@ -125,7 +125,7 @@ public class IrisComplex implements DataProvider { private static final int GRID_BOUNDS_CACHE_SIZE = 8192; private static final int STUDIO_NOISE_CACHE_SIZE = 32_768; private static final int TERRAIN_COLUMN_CACHE_SIZE = 524_288; - /** One million corners: about 16 MB, roughly a 4000 by 4000 block area at the 4-block grid. */ + /** One million corners: about 20 MB, roughly a 4000 by 4000 block area at the 4-block grid. */ private static final int SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20; /** The slope streams measure the rise across a run of this many blocks, so gradient is slope over run. */ private static final double SLOPE_RUN = 3D; @@ -148,7 +148,7 @@ public class IrisComplex implements DataProvider { private final transient ThreadLocal gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new); @Getter(AccessLevel.NONE) private final int biomeBoundsSamplingStep; - private transient volatile SharedCornerBounds sharedCornerBounds = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY); + private transient volatile SharedCornerBounds sharedCornerBounds; @Getter(AccessLevel.NONE) private final transient IrisInterpolator[] frozenInterpolators; @Getter(AccessLevel.NONE) @@ -407,11 +407,13 @@ public class IrisComplex implements DataProvider { GenerationStreams.cache2D(bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z)) .convertAware2D(this::implode), "baseBiomeStream", engine, cacheSize); baseBiomeStream = focusedBiomes != null || !imageMapRuntime.has(IrisImageMapApplication.BIOME) - ? proceduralBaseBiomeStream : GenerationStreams.cache2D(proceduralBaseBiomeStream - .convertAware2D((biome, x, z) -> { + ? proceduralBaseBiomeStream : GenerationStreams.cache2D(ProceduralStream.of((x, z) -> { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); - return mapped == null ? biome : mapped; - }), "imageMappedBaseBiomeStream", engine, cacheSize); + return mapped == null ? proceduralBaseBiomeStream.get(x, z) : mapped; + }, (x, y, z) -> { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null ? proceduralBaseBiomeStream.get(x, y, z) : mapped; + }, proceduralBaseBiomeStream), "imageMappedBaseBiomeStream", engine, cacheSize); proceduralTerrainHeight = new ProceduralTerrainHeightSampler( (x, z) -> getHeight(engine, x, z, engine.getSeedManager().getHeight()), engine.getDimension().getTerrainSamplingStep()); @@ -441,14 +443,9 @@ public class IrisComplex implements DataProvider { : GenerationStreams.cache2DDouble(ProceduralStream.ofDouble((x, z) -> transitionDisplacement.height( blockCoordinate(x), blockCoordinate(z), unblendedNaturalHeightStream.getDouble(x, z))), "naturalHeightStream", engine, cacheSize); - naturalTrueBiomeStream = focusedBiomes != null ? GenerationStreams.cache2D(focusedBiomes, "naturalTrueBiomeStream-focus", engine, cacheSize) : GenerationStreams.cache2D(naturalHeightStream - .convertAware2D((h, x, z) -> { - IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); - return mapped == null - ? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, - transitionFluidHeight(x, z, fluidHeight)) - : mapped; - }), "naturalTrueBiomeStream", engine, cacheSize); + naturalTrueBiomeStream = focusedBiomes != null ? GenerationStreams.cache2D(focusedBiomes, "naturalTrueBiomeStream-focus", engine, cacheSize) + : GenerationStreams.cache2D(ProceduralStream.of(this::sampleNaturalTrueBiome, this::sampleNaturalTrueBiome, + Interpolated.of(value -> 0D, value -> null)), "naturalTrueBiomeStream", engine, cacheSize); IrisHydrology configuredHydrology = engine.getDimension().getHydrology(); if (hydrologyActive(configuredHydrology)) { hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext( @@ -477,10 +474,8 @@ public class IrisComplex implements DataProvider { Math.max(4096, cacheSize), data, banks3D)); } } - cachedHeightStream = new CachedDoubleStream2D("heightStream", engine, - ProceduralStream.ofDouble(this::resolveHydrologyTerrainHeight), cacheSize); - heightStream = ProceduralStream.ofDouble((x, z) -> terrainEngine.getPlatformHooks().isMainThread() - ? nonblockingTerrainHeight(x, z) : cachedHeightStream.getDouble(x, z)); + cachedHeightStream = new TerrainHeightStream(engine, cacheSize); + heightStream = cachedHeightStream; roundedHeighteightStream = GenerationStreams.contextInjecting(heightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .round(); slopeStream = GenerationStreams.contextInjecting(heightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) @@ -1038,6 +1033,22 @@ public class IrisComplex implements DataProvider { return sample.terrainHeight(); } + private IrisBiome sampleNaturalTrueBiome(double x, double z) { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null + ? fixBiomeType(naturalHeightStream.getDouble(x, z), baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, + transitionFluidHeight(x, z, fluidHeight)) + : mapped; + } + + private IrisBiome sampleNaturalTrueBiome(double x, double y, double z) { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null + ? fixBiomeType(naturalHeightStream.getDouble(x, y, z), baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, + transitionFluidHeight(x, z, fluidHeight)) + : mapped; + } + private double nonblockingTerrainHeight(double x, double z) { HydrologyColumnSnapshot snapshot = sampleHydrologySnapshot(blockCoordinate(x), blockCoordinate(z)); if (!snapshot.available() || snapshot.column() == null) { @@ -1693,12 +1704,17 @@ public class IrisComplex implements DataProvider { return packed; } - /** The field initializer covers every real complex; an instance built without it gets one here. */ + /** Coarse bounds allocate their shared table on the first corner sample. */ private SharedCornerBounds sharedCornerBounds() { SharedCornerBounds shared = sharedCornerBounds; if (shared == null) { - shared = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY); - sharedCornerBounds = shared; + synchronized (this) { + shared = sharedCornerBounds; + if (shared == null) { + shared = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY); + sharedCornerBounds = shared; + } + } } return shared; } @@ -2214,6 +2230,62 @@ public class IrisComplex implements DataProvider { } } + private final class TerrainHeightStream extends CachedDoubleStream2D { + private TerrainHeightStream(Engine engine, int cacheSize) { + super("heightStream", engine, ProceduralStream.ofDouble(IrisComplex.this::resolveHydrologyTerrainHeight), cacheSize); + } + + @Override + public double getDouble(double x, double z) { + return terrainEngine.getPlatformHooks().isMainThread() + ? nonblockingTerrainHeight(x, z) : super.getDouble(x, z); + } + + @Override + public Double get(double x, double y, double z) { + return getDouble(x, z); + } + + @Override + public double getDouble(double x, double y, double z) { + return getDouble(x, z); + } + + @Override + public void fillChunkDoubles(int worldX, int worldZ, double[] target) { + boolean mainThread = terrainEngine.getPlatformHooks().isMainThread(); + if (!mainThread && (worldX & 15) == 0 && (worldZ & 15) == 0) { + super.fillChunkDoubles(worldX, worldZ, target); + return; + } + if (target == null || target.length != 256) { + throw new IllegalArgumentException("Expected a 16x16 target array."); + } + for (int index = 0; index < 256; index++) { + int x = worldX + (index & 15); + int z = worldZ + (index >> 4); + target[index] = mainThread ? nonblockingTerrainHeight(x, z) : super.getDouble(x, z); + } + } + + @Override + public void fillChunkRaw(int worldX, int worldZ, Object[] target) { + boolean mainThread = terrainEngine.getPlatformHooks().isMainThread(); + if (!mainThread && (worldX & 15) == 0 && (worldZ & 15) == 0) { + super.fillChunkRaw(worldX, worldZ, target); + return; + } + if (target == null || target.length != 256) { + throw new IllegalArgumentException("Expected a 16x16 target array."); + } + for (int index = 0; index < 256; index++) { + int x = worldX + (index & 15); + int z = worldZ + (index >> 4); + target[index] = mainThread ? nonblockingTerrainHeight(x, z) : super.getDouble(x, z); + } + } + } + static final class ChildSelectionPlan { private final IrisRaritySelection selection; diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisPostModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisPostModifier.java index f83b6ff91..ff2f986bc 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisPostModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisPostModifier.java @@ -54,43 +54,44 @@ public class IrisPostModifier extends EngineAssignedModifier { int depth = output.getDepth(); int planeWidth = width + 2; int[] heights = heightPlane(x, z, width, depth, planeWidth); + boolean[] rivers = riverPlane(getComplex().getRiverCarveWeightStream(), x, z, width, depth); IrisDimension dimension = getDimension(); boolean walls = dimension.isPostProcessingWalls(); boolean slabs = dimension.isPostProcessingSlabs(); for (int i = 0; i < width; i++) { for (int j = 0; j < depth; j++) { - post(i, j, sync, i + x, j + z, context, heights, planeWidth, walls, slabs); + post(i, j, sync, i + x, j + z, context, heights, rivers, planeWidth, walls, slabs); } } getEngine().getMetrics().getPost().put(p.getMilliseconds()); } + static boolean[] riverPlane(ProceduralStream carveWeights, int x, int z, int width, int depth) { + int planeWidth = width + 2; + boolean[] rivers = new boolean[planeWidth * (depth + 2)]; + for (int j = -1; j <= depth; j++) { + boolean edge = j == -1 || j == depth; + int from = edge ? 0 : -1; + int to = edge ? width - 1 : width; + int row = (j + 1) * planeWidth; + for (int i = from; i <= to; i++) { + rivers[row + i + 1] = carveWeights.get(x + i, z + j) > 0D; + } + } + return rivers; + } + + static boolean skipsHydrologyColumn(boolean[] rivers, int center, int planeWidth) { + return rivers[center] || rivers[center + 1] || rivers[center - 1] + || rivers[center + planeWidth] || rivers[center - planeWidth]; + } + /** * Every column reads its own height plus its four neighbours, so adjacent columns would otherwise * resolve the same height stream entry up to five times. Resolve the padded plane once instead. The * four diagonal corners are never read, so they are left unresolved. */ - private boolean riverColumn(int x, int z) { - ProceduralStream carveWeights = getComplex().getRiverCarveWeightStream(); - return skipsHydrologyColumn(new double[] { - carveWeights.get(x, z), - carveWeights.get(x + 1, z), - carveWeights.get(x - 1, z), - carveWeights.get(x, z + 1), - carveWeights.get(x, z - 1) - }); - } - - static boolean skipsHydrologyColumn(double[] carveWeights) { - for (double weight : carveWeights) { - if (weight > 0D) { - return true; - } - } - return false; - } - private int[] heightPlane(int x, int z, int width, int depth, int planeWidth) { int[] heights = new int[planeWidth * (depth + 2)]; EngineMantle mantle = getEngine().getMantle(); @@ -109,7 +110,7 @@ public class IrisPostModifier extends EngineAssignedModifier { return heights; } - private void post(int currentPostX, int currentPostZ, Hunk currentData, int x, int z, ChunkContext context, int[] heights, int planeWidth, boolean walls, boolean slabs) { + private void post(int currentPostX, int currentPostZ, Hunk currentData, int x, int z, ChunkContext context, int[] heights, boolean[] rivers, int planeWidth, boolean walls, boolean slabs) { // x/z are world coordinates, the hunk is indexed relative to this chunk origin. int originX = x - currentPostX; int originZ = z - currentPostZ; @@ -120,7 +121,7 @@ public class IrisPostModifier extends EngineAssignedModifier { int hc = heights[center - 1]; int hd = heights[center - planeWidth]; int fluidHeight = (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(x, z)); - boolean river = riverColumn(x, z); + boolean river = skipsHydrologyColumn(rivers, center, planeWidth); // Floating Nibs int g = 0; diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponentEmptyTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponentEmptyTest.java new file mode 100644 index 000000000..9d743a033 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponentEmptyTest.java @@ -0,0 +1,275 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.biome.FloatingIslandBoundarySampler; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisFloatingChildBiomes; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; +import art.arcane.volmlib.util.mantle.runtime.MantleHooks; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class MantleFloatingObjectComponentEmptyTest { + @ClassRule + public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform(); + + @Test + public void absentFloatingObjectsDoNotReadColumnsOrWriteMatter() { + Fixture fixture = new Fixture(new IrisBiome()); + MantleWriter writer = mock(MantleWriter.class); + ChunkContext context = mock(ChunkContext.class); + + assertEquals(0, fixture.component.getRadius()); + fixture.component.generateLayer(writer, 4, -6, context); + + verifyNoInteractions(writer, context); + } + + @Test + public void floatingTerrainWithoutObjectsHasNoObjectReach() { + Fixture fixture = new Fixture(floatingBiome(new IrisFloatingChildBiomes())); + + assertEquals(0, fixture.component.getRadius()); + } + + @Test + public void explicitPlacementListsKeepTheirMinimumReachWhenObjectsAreMissing() { + IrisObjectPlacement placement = new IrisObjectPlacement().setPlace(new KList<>("test/missing")); + List entries = List.of( + new IrisFloatingChildBiomes().setFloatingObjects(new KList<>(placement)), + new IrisFloatingChildBiomes().setExtraObjects(new KList<>(placement)), + new IrisFloatingChildBiomes().setTopObjectOverrides(new KList<>(placement)), + new IrisFloatingChildBiomes().setBottomObjectOverrides(new KList<>(placement)) + ); + + for (IrisFloatingChildBiomes entry : entries) { + Fixture fixture = new Fixture(floatingBiome(entry)); + assertEquals(16, fixture.component.getRadius()); + } + } + + @Test + public void inheritedParentObjectsKeepTheirMinimumReach() { + IrisBiome parent = floatingBiome(new IrisFloatingChildBiomes()) + .setObjects(new KList<>(new IrisObjectPlacement())); + Fixture fixture = new Fixture(parent); + + assertEquals(16, fixture.component.getRadius()); + } + + @Test + public void inheritedTargetObjectsKeepTheirMinimumReach() { + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes().setBiome("test/target"); + Fixture fixture = new Fixture(floatingBiome(entry)); + IrisBiome target = new IrisBiome().setObjects(new KList<>(new IrisObjectPlacement())); + when(fixture.biomes.load("test/target")).thenReturn(target); + + assertEquals(16, fixture.component.getRadius()); + } + + @Test + public void disabledInheritanceLeavesObjectlessFloatingTerrainEmpty() { + IrisBiome parent = floatingBiome(new IrisFloatingChildBiomes().setInheritObjects(false)) + .setObjects(new KList<>(new IrisObjectPlacement())); + Fixture fixture = new Fixture(parent); + + assertEquals(0, fixture.component.getRadius()); + } + + @Test + public void hotloadRechecksObjectsAcrossPackChanges() { + Fixture fixture = new Fixture(new IrisBiome()); + assertEquals(0, fixture.component.getRadius()); + + IrisBiome active = floatingBiome(new IrisFloatingChildBiomes() + .setExtraObjects(new KList<>(new IrisObjectPlacement()))); + when(fixture.dimension.getReachableBiomes(any())).thenReturn(new KList<>(active)); + fixture.component.hotload(); + assertEquals(16, fixture.component.getRadius()); + + when(fixture.dimension.getReachableBiomes(any())).thenReturn(new KList<>(new IrisBiome())); + fixture.component.hotload(); + assertEquals(0, fixture.component.getRadius()); + assertEquals(0, fixture.component.getRadius()); + verify(fixture.dimension, times(3)).getReachableBiomes(any()); + } + + @Test + public void failedReachabilityScanKeepsTheComponentActive() { + Fixture fixture = new Fixture(new IrisBiome()); + when(fixture.dimension.getReachableBiomes(any())).thenThrow(new IllegalStateException("Unavailable biome")); + + assertEquals(16, fixture.component.getRadius()); + } + + @Test + public void failedTargetResolutionKeepsTheComponentActive() { + IrisFloatingChildBiomes entry = spy(new IrisFloatingChildBiomes()); + Fixture fixture = new Fixture(floatingBiome(entry)); + doThrow(new IllegalStateException("Unavailable floating biome")).when(entry).getRealBiome(any(), any()); + + assertEquals(16, fixture.component.getRadius()); + } + + @Test + public void unresolvedTargetKeepsTheComponentActive() { + IrisFloatingChildBiomes entry = spy(new IrisFloatingChildBiomes()); + Fixture fixture = new Fixture(floatingBiome(entry)); + doReturn(null).when(entry).getRealBiome(any(), any()); + + assertEquals(16, fixture.component.getRadius()); + } + + @Test + public void unresolvedPlacementListKeepsTheComponentActive() { + Fixture fixture = new Fixture(floatingBiome(new IrisFloatingChildBiomes().setExtraObjects(null))); + + assertEquals(16, fixture.component.getRadius()); + } + + @Test + public void emptyComponentShrinksTheWindowAndPreservesCompletionFlags() { + Fixture compact = new Fixture(new IrisBiome()); + Fixture conservative = new Fixture(new IrisBiome()); + MantleFloatingObjectComponent conservativeComponent = new ConservativeFloatingObjects(conservative.engineMantle); + + GenerationResult compactResult = compact.generate(compact.component); + GenerationResult conservativeResult = conservative.generate(conservativeComponent); + + assertEquals(9, compactResult.carveCalls()); + assertEquals(25, conservativeResult.carveCalls()); + assertEquals(0, compact.columnReads.get()); + assertEquals(2304, conservative.columnReads.get()); + assertEquals(9, compactResult.loadedChunks()); + assertEquals(25, conservativeResult.loadedChunks()); + for (MantleFlag flag : List.of(ReservedFlag.CARVED, ReservedFlag.FLOATING_OBJECT, MantleFlag.PLANNED)) { + assertTrue(compactResult.center().isFlagged(flag)); + assertEquals(conservativeResult.center().isFlagged(flag), compactResult.center().isFlagged(flag)); + } + } + + private static IrisBiome floatingBiome(IrisFloatingChildBiomes entry) { + return new IrisBiome().setFloatingChildBiomes(new KList<>(entry)); + } + + private static final class Fixture { + private final IrisDimension dimension = mock(IrisDimension.class); + private final Engine engine = mock(Engine.class); + private final EngineMantle engineMantle = mock(EngineMantle.class); + private final IrisComplex complex = mock(IrisComplex.class); + private final ChunkContext context = mock(ChunkContext.class); + private final ResourceLoader biomes; + private final AtomicInteger columnReads = new AtomicInteger(); + private final MantleFloatingObjectComponent component; + + @SuppressWarnings("unchecked") + private Fixture(IrisBiome biome) { + biomes = mock(ResourceLoader.class); + ResourceLoader objects = mock(ResourceLoader.class); + IrisData data = mock(IrisData.class); + when(data.getBiomeLoader()).thenReturn(biomes); + when(data.getObjectLoader()).thenReturn(objects); + when(dimension.isUseMantle()).thenReturn(true); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>(biome)); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getHeight()).thenReturn(256); + when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getData()).thenReturn(data); + when(engineMantle.getComplex()).thenReturn(complex); + when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true); + when(complex.allowsMantleChunkWrite(anyInt(), anyInt())).thenReturn(true); + when(context.getComplex()).thenReturn(complex); + when(context.getFloatingIslandBoundarySampler()).thenReturn(new FloatingIslandBoundarySampler((x, z) -> { + columnReads.incrementAndGet(); + return biome; + })); + component = new MantleFloatingObjectComponent(engineMantle); + } + + @SuppressWarnings("unchecked") + private GenerationResult generate(MantleFloatingObjectComponent floating) { + Map> chunks = new HashMap<>(); + MantleDataAdapter adapter = mock(MantleDataAdapter.class); + Mantle mantle = mock(Mantle.class); + when(mantle.useChunk(anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int z = invocation.getArgument(1); + long key = ((long) x << 32) ^ (z & 0xffffffffL); + return chunks.computeIfAbsent(key, ignored -> new MantleChunk<>(16, x, z, adapter, MantleHooks.NONE)).use(); + }); + MantleComponent carving = mock(MantleComponent.class); + when(carving.isEnabled()).thenReturn(true); + when(carving.getFlag()).thenReturn(ReservedFlag.CARVED); + when(carving.getOutputRadius()).thenReturn(1); + when(carving.getGenerationPhase()).thenReturn(MatterGenerationPhase.TERRAIN); + when(carving.getPrerequisiteFlags()).thenReturn(MantleComponent.EMPTY_PREREQUISITES); + AtomicInteger carveCalls = new AtomicInteger(); + doAnswer(invocation -> { + carveCalls.incrementAndGet(); + return null; + }).when(carving).generateLayer(any(), anyInt(), anyInt(), any()); + MatterGenerator generator = mock(MatterGenerator.class, CALLS_REAL_METHODS); + when(generator.getEngine()).thenReturn(engine); + when(generator.getMantle()).thenReturn(mantle); + when(generator.getComponents()).thenReturn(List.of( + new MantlePass(List.of(carving), 0, 0), + new MantlePass(List.of(floating), 0, 0) + )); + + generator.generateMatter(0, 0, false, context); + + return new GenerationResult(chunks.get(0L), carveCalls.get(), chunks.size()); + } + } + + private static final class ConservativeFloatingObjects extends MantleFloatingObjectComponent { + private ConservativeFloatingObjects(EngineMantle engineMantle) { + super(engineMantle); + } + + @Override + protected int computeRadius() { + return Math.max(16, super.computeRadius()); + } + } + + private record GenerationResult(MantleChunk center, int carveCalls, int loadedChunks) { + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorFractureTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorFractureTest.java new file mode 100644 index 000000000..774fb7568 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorFractureTest.java @@ -0,0 +1,271 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.interpolation.IrisInterpolation; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.NoiseGenerator; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public final class IrisNoiseGeneratorFractureTest { + @Test + public void deepChainsMatchRecursiveSamplingBits() { + Random coordinates = new Random(718311L); + for (NoiseStyle style : List.of(NoiseStyle.SIMPLEX, NoiseStyle.PERLIN, NoiseStyle.IRIS, + NoiseStyle.FRACTAL_BILLOW_SIMPLEX, NoiseStyle.FRACTAL_FBM_SIMPLEX)) { + for (int depth : new int[]{0, 1, 3, 4, 5, 8}) { + IrisNoiseGenerator root = chain(depth, style).getFirst(); + for (long seed : new long[]{0L, -4193L, Long.MIN_VALUE, Long.MAX_VALUE}) { + assertParity(root, seed, 0D, -0D, null, null); + assertParity(root, seed, -0D, 0D, null, null); + assertParity(root, seed, -30_000_000D, 29_999_999.75D, null, null); + assertParity(root, seed, Math.nextDown(-64D), Math.nextUp(64D), null, null); + for (int index = 0; index < 8; index++) { + assertParity(root, seed, coordinates.nextDouble() * 4096D - 2048D, + coordinates.nextDouble() * 4096D - 2048D, null, null); + } + } + } + } + } + + @Test + public void fourFracturesReducePrimitiveSamplesFromThirtyOneToNine() { + List chain = chain(4, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + reference(chain.getFirst(), 71L, 13.25D, -21.75D, null, null); + for (int index = 0; index < bases.size(); index++) { + verify(bases.get(index), times(1 << index)).noise(anyDouble(), anyDouble()); + clearInvocations(bases.get(index)); + } + + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + for (int index = 0; index < bases.size(); index++) { + verify(bases.get(index), times(index == 0 ? 1 : 2)).noise(anyDouble(), anyDouble()); + } + } + + @Test + public void shallowChainsKeepTheirSamplingCounts() { + List chain = chain(3, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + for (int index = 0; index < bases.size(); index++) { + verify(bases.get(index), times(1 << index)).noise(anyDouble(), anyDouble()); + } + } + + @Test + public void changedParametersAndFracturesRemainVisible() { + List chain = chain(5, NoiseStyle.IRIS); + IrisNoiseGenerator root = chain.getFirst(); + assertParity(root, 19L, 31D, -79D, null, null); + chain.get(3).setOffsetX(-319D).setOffsetZ(17D).setOffsetY(-0.7D) + .setSeed(Long.MAX_VALUE).setOpacity(0.35D).setZoom(17D); + assertParity(root, 19L, 31D, -79D, null, null); + chain.get(4).setEnabled(false); + assertParity(root, 19L, 31D, -79D, null, null); + chain.get(4).setEnabled(true); + chain.get(2).getFracture().add(new IrisNoiseGenerator().setStyle(NoiseStyle.PERLIN.style())); + assertParity(root, 19L, 31D, -79D, null, null); + chain.get(2).getFracture().removeLast(); + chain.getLast().getFracture().add(new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style())); + assertParity(root, 19L, 31D, -79D, null, null); + } + + @Test + public void statefulRuntimeGeneratorsKeepAllEvaluations() { + List chain = chain(4, NoiseStyle.SIMPLEX); + long leafSeed = childSeed(chain, 23L, chain.size() - 1); + StatefulNoise stateful = new StatefulNoise(); + chain.getLast().getGenerator(leafSeed, null, null).setGenerator(stateful); + double expected = reference(chain.getFirst(), 23L, 17D, -31D, null, null); + assertEquals(16, stateful.samples); + stateful.samples = 0; + double actual = chain.getFirst().getNoise(23L, 17D, -31D, null); + assertBits(expected, actual); + assertEquals(16, stateful.samples); + } + + @Test + public void expressionConfigurationDoesNotReuseEvenPreviouslyCachedPureNoise() { + List chain = chain(4, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + chain.getLast().getStyle().setExpression("custom"); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + verify(bases.getLast(), times(16)).noise(anyDouble(), anyDouble()); + chain.getLast().getStyle().setExpression(null).setImageMap("map"); + clearInvocations(bases.getLast()); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + verify(bases.getLast(), times(16)).noise(anyDouble(), anyDouble()); + } + + @Test + public void customStylesKeepTheScalarPath() { + List chain = chain(4, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + chain.getLast().setStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX) { + }); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + verify(bases.getLast(), times(16)).noise(anyDouble(), anyDouble()); + } + + @Test + public void failedSamplingReleasesTheThreadScratch() { + List chain = chain(4, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + doThrow(new IllegalStateException("sample failed")).doCallRealMethod() + .when(bases.getLast()).noise(anyDouble(), anyDouble()); + assertThrows(IllegalStateException.class, + () -> chain.getFirst().getNoise(71L, 13.25D, -21.75D, null)); + clearInvocations(bases.getLast()); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + verify(bases.getLast(), times(2)).noise(anyDouble(), anyDouble()); + } + + @Test + public void threadsAndEngineIdentitiesRemainIndependent() throws Exception { + IrisNoiseGenerator root = chain(5, NoiseStyle.IRIS).getFirst(); + IrisData data = mock(IrisData.class); + Engine firstEngine = mock(Engine.class); + Engine secondEngine = mock(Engine.class); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> sampleConcurrently(root, data, firstEngine, 47L, start)); + Future second = executor.submit(() -> sampleConcurrently(root, data, secondEngine, -131L, start)); + start.countDown(); + first.get(15L, TimeUnit.SECONDS); + second.get(15L, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + assertParity(root, 47L, 17D, 19D, data, secondEngine); + assertParity(root, 47L, 17D, 19D, data, firstEngine); + } + + private static void sampleConcurrently(IrisNoiseGenerator root, IrisData data, Engine engine, + long seed, CountDownLatch start) { + try { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + for (int index = 0; index < 64; index++) { + assertParity(root, seed, index * 13.75D, -index * 2.25D, data, engine); + } + } + + private static List chain(int depth, NoiseStyle style) { + List nodes = new ArrayList<>(depth + 1); + for (int index = 0; index <= depth; index++) { + IrisNoiseGenerator node = new IrisNoiseGenerator().setStyle(style.style()) + .setSeed(index * 713L).setZoom(0.75D + index).setOpacity(0.63D + index * 0.01D) + .setOffsetX(index * -0.2D).setOffsetY(index * -0.03D).setOffsetZ(index * 0.4D) + .setNegative(index % 2 == 0).setExponent(index % 3 == 0 ? 1.25D : 1D) + .setParametric(index % 3 == 1).setBezier(index % 3 == 2) + .setSinCentered(index % 4 == 1); + if (!nodes.isEmpty()) { + nodes.getLast().setFracture(new KList<>(node)); + } + nodes.add(node); + } + return nodes; + } + + private static List spyBases(List nodes, long superSeed) { + List bases = new ArrayList<>(nodes.size()); + for (IrisNoiseGenerator node : nodes) { + CNG generator = node.getGenerator(superSeed, null, null); + NoiseGenerator base = spy(generator.getGenerator()); + generator.setGenerator(base); + bases.add(base); + superSeed += node.getSeed() + 33; + } + return bases; + } + + private static long childSeed(List nodes, long seed, int index) { + for (int child = 0; child < index; child++) { + seed += nodes.get(child).getSeed() + 33; + } + return seed; + } + + private static void assertParity(IrisNoiseGenerator node, long seed, double x, double z, + IrisData data, Engine engine) { + assertBits(reference(node, seed, x, z, data, engine), node.getNoise(seed, x, z, data, engine)); + } + + private static void assertBits(double expected, double actual) { + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + } + + private static double reference(IrisNoiseGenerator node, long superSeed, double xv, double zv, + IrisData data, Engine engine) { + if (!node.isEnabled()) { + return node.getOffsetY(); + } + double x = xv; + double z = zv; + int salt = 33; + for (IrisNoiseGenerator child : node.getFracture()) { + if (child.isEnabled()) { + double offset = child.getOpacity() / 2D; + x += reference(child, superSeed + node.getSeed() + salt, xv, zv, data, engine) - offset; + z += reference(child, superSeed + node.getSeed() + salt, zv, xv, data, engine) - offset; + } + salt += 819; + } + double sampleX = (x / node.getZoom()) + node.getOffsetX(); + double sampleZ = (z / node.getZoom()) + node.getOffsetZ(); + double result = node.getGenerator(superSeed, data, engine).noiseFast2D(sampleX, sampleZ) * node.getOpacity(); + result = node.isNegative() ? -result + node.getOpacity() : result; + result = (node.getExponent() != 1D ? result < 0D ? -Math.pow(-result, node.getExponent()) + : Math.pow(result, node.getExponent()) : result) + node.getOffsetY(); + result = node.isParametric() ? IrisInterpolation.parametric(result, 2D) : result; + result = node.isBezier() ? IrisInterpolation.bezier(result) : result; + return node.isSinCentered() ? IrisInterpolation.sinCenter(result) : result; + } + + private static final class StatefulNoise implements NoiseGenerator { + private int samples; + + @Override + public double noise(double x) { + return ++samples * 0.01D; + } + + @Override + public double noise(double x, double z) { + return ++samples * 0.01D; + } + + @Override + public double noise(double x, double y, double z) { + return ++samples * 0.01D; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBiomeBuffetTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBiomeBuffetTest.java index 1888580dc..944635e63 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBiomeBuffetTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBiomeBuffetTest.java @@ -84,12 +84,14 @@ public class IrisComplexBiomeBuffetTest { IrisComplex complex = new IrisComplex(engine); doReturn(complex).when(engine).getComplex(); try { + assertNull(complex.getSharedCornerBounds()); assertEquals("alpha", complex.getTrueBiomeStream().get(15, 0).getLoadKey()); assertEquals("beta", complex.getTrueBiomeStream().get(16, 0).getLoadKey()); assertEquals("gamma", complex.getTrueBiomeStream().get(0, 16).getLoadKey()); assertEquals(137D, complex.getBaseTerrainHeightStream().getDouble(15, 0), 0D); assertEquals(147D, complex.getBaseTerrainHeightStream().getDouble(16, 0), 0D); assertEquals(157D, complex.getBaseTerrainHeightStream().getDouble(0, 16), 0D); + assertNull(complex.getSharedCornerBounds()); assertEquals("owned", complex.getRegionStream().get(16, 0).getLoadKey()); assertEquals("iris-focus/", complex.getRegionStream().get(0, 16).getLoadKey().substring(0, 11)); assertEquals("alpha", dimension.getFocus()); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java index d061bf79e..7efa384c0 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java @@ -13,20 +13,52 @@ import org.junit.runners.Parameterized.Parameters; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; 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.junit.Assert.assertSame; import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; @RunWith(Enclosed.class) public class IrisComplexGridBoundsCacheTest { public static class CornerCache { + @Test(timeout = 10_000L) + public void concurrentFirstSamplesShareOneCornerTable() throws Exception { + IrisComplex complex = createComplex(); + Method sharedCache = IrisComplex.class.getDeclaredMethod("sharedCornerBounds"); + sharedCache.setAccessible(true); + CountDownLatch entered = new CountDownLatch(4); + CountDownLatch release = new CountDownLatch(1); + try (ExecutorService readers = Executors.newFixedThreadPool(4)) { + List> results = new ArrayList<>(); + for (int index = 0; index < 4; index++) { + results.add(readers.submit(() -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return (SharedCornerBounds) sharedCache.invoke(complex); + })); + } + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + release.countDown(); + SharedCornerBounds expected = results.getFirst().get(5L, TimeUnit.SECONDS); + for (Future result : results) { + assertSame(expected, result.get(5L, TimeUnit.SECONDS)); + } + } + } + @Test public void gridBoundsCacheIsIsolatedPerComplex() throws Exception { IrisComplex first = createComplex(); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java new file mode 100644 index 000000000..1e0dbbac0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java @@ -0,0 +1,304 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.context.ChunkedDoubleDataCache; +import art.arcane.iris.generation.hydrology.HydrologyColumnSample; +import art.arcane.iris.generation.hydrology.HydrologyColumnSnapshot; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.generation.image.IrisImageMapApplication; +import art.arcane.iris.generation.image.IrisImageMapRuntime; +import art.arcane.iris.generation.stream.ChunkFillableDoubleStream2D; +import art.arcane.iris.generation.stream.ChunkFillableStream2D; +import art.arcane.iris.generation.stream.ProvisionalSampling; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisComplexHeightStreamTest { + @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisSettings previousSettings; + + @Before + public void prepareRuntime() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class)); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.platformName()).thenReturn("bukkit"); + IrisPlatforms.bind(platform); + } + + @After + public void releaseRuntime() { + IrisPlatforms.unbind(); + IrisServices.remove(PreservationRegistry.class); + IrisSettings.settings = previousSettings; + } + + @Test + public void sharedCornersRemainUnallocatedUntilTerrainSamplesThem() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + assertNull(fixture.complex.getSharedCornerBounds()); + fixture.complex.getBaseTerrainHeightStream().getDouble(0, 0); + assertNotNull(fixture.complex.getSharedCornerBounds()); + } + } + + @Test + public void mappedBiomesSkipProceduralSelectionAndNaturalHeight() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + IrisBiome mapped = new IrisBiome(); + when(fixture.images.sampleBiome(anyDouble(), anyDouble())).thenReturn(mapped); + MeteredCache bridge = (MeteredCache) fixture.complex.getBridgeStream(); + MeteredCache regions = (MeteredCache) fixture.complex.getRegionStream(); + MeteredCache heights = (MeteredCache) fixture.complex.getNaturalHeightStream(); + assertSame(mapped, fixture.complex.getBaseBiomeStream().get(-16, 32)); + assertSame(mapped, fixture.complex.getBaseBiomeStream().get(-16.75, 10, 32.5)); + assertSame(mapped, fixture.complex.getNaturalTrueBiomeStream().get(-16, 32)); + assertSame(mapped, fixture.complex.getNaturalTrueBiomeStream().get(-16.75, 10, 32.5)); + assertEquals(0L, bridge.getSize()); + assertEquals(0L, regions.getSize()); + assertEquals(0L, heights.getSize()); + assertNull(fixture.complex.getSharedCornerBounds()); + + when(fixture.images.sampleBiome(anyDouble(), anyDouble())).thenReturn(null); + assertEquals("plain", fixture.complex.getBaseBiomeStream().get(16, 32).getLoadKey()); + assertEquals("plain", fixture.complex.getNaturalTrueBiomeStream().get(16, 32).getLoadKey()); + assertNotNull(fixture.complex.getSharedCornerBounds()); + } + } + + @Test + public void workerPrefillUsesOneThreadCheckAndRetainsRoundedValues() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + AtomicInteger samples = fixture.installNaturalHeight(); + ChunkedDoubleDataCache cache = new ChunkedDoubleDataCache(fixture.complex.getRawHeightStream(), -16, 32); + int[] rounded = new int[256]; + clearInvocations(fixture.hooks); + cache.fillRounded(rounded); + verify(fixture.hooks, times(1)).isMainThread(); + assertEquals(256, samples.get()); + for (int index = 0; index < 256; index++) { + int x = index & 15; + int z = index >> 4; + double expected = height(-16 + x, 32 + z); + assertBits(expected, cache.getDouble(x, z)); + assertEquals((int) Math.round(expected), rounded[index]); + } + clearInvocations(fixture.hooks); + cache.fill(); + verify(fixture.hooks, times(1)).isMainThread(); + assertEquals(256, samples.get()); + } + } + + @Test + public void mainThreadPrefillNeverWaitsOrConsumesCachedHydrology() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + fixture.installNaturalHeight(); + IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + HydrologyColumnSample planned = mock(HydrologyColumnSample.class); + when(planned.terrainHeight()).thenReturn(100); + when(hydrology.sample(anyDouble(), anyDouble())).thenAnswer(invocation -> { + if (fixture.mainThread.get()) { + throw new AssertionError("Main-thread sampling entered blocking hydrology"); + } + return Optional.of(planned); + }); + when(hydrology.sampleSnapshot(anyInt(), anyInt())).thenAnswer(invocation -> { + ProvisionalSampling.mark(); + return HydrologyColumnSnapshot.unavailable(); + }); + fixture.complex.setHydrologyRuntime(hydrology); + ProceduralStream stream = fixture.complex.getRawHeightStream(); + ChunkFillableDoubleStream2D bulk = (ChunkFillableDoubleStream2D) stream; + double[] values = new double[256]; + fixture.mainThread.set(true); + assertThrows(ProvisionalSampling.Unmemoizable.class, () -> ProvisionalSampling.memoizable(() -> { + bulk.fillChunkDoubles(-16, 32, values); + return values[0]; + })); + assertBits(height(-16, 32), values[0]); + fixture.mainThread.set(false); + bulk.fillChunkDoubles(-16, 32, values); + assertBits(100D, values[0]); + assertBits(100D, values[255]); + verify(hydrology, times(256)).sample(anyDouble(), anyDouble()); + + fixture.mainThread.set(true); + bulk.fillChunkDoubles(-16, 32, values); + assertBits(height(-16, 32), values[0]); + assertBits(height(-16.75, 32.5), stream.getDouble(-16.75, 10, 32.5)); + assertBits(height(-16.75, 32.5), stream.get(-16.75, 10, 32.5)); + Object[] boxed = new Object[256]; + ((ChunkFillableStream2D) stream).fillChunkRaw(-16, 32, boxed); + assertBits(height(-1, 47), (Double) boxed[255]); + verify(hydrology, times(256)).sample(anyDouble(), anyDouble()); + } + } + + @Test + public void unalignedPrefillAndThreeDimensionalReadsRetainColumnCoordinates() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + fixture.installNaturalHeight(); + ProceduralStream stream = fixture.complex.getRawHeightStream(); + double[] values = new double[256]; + ((ChunkFillableDoubleStream2D) stream).fillChunkDoubles(-17, 31, values); + for (int index = 0; index < 256; index++) { + assertBits(height(-17 + (index & 15), 31 + (index >> 4)), values[index]); + } + assertBits(height(-16, 32), stream.getDouble(-16.75, 10, 32.5)); + assertBits(height(-16, 32), stream.get(-16.75, 10, 32.5)); + } + } + + @Test + public void workerPrefillDoesNotMemoizeProvisionalNaturalValues() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + AtomicBoolean ready = new AtomicBoolean(); + AtomicInteger samples = new AtomicInteger(); + fixture.complex.setNaturalHeightStream(ProceduralStream.ofDouble((x, z) -> { + samples.incrementAndGet(); + if (!ready.get()) { + ProvisionalSampling.mark(); + return -1D; + } + return height(x, z); + })); + ChunkFillableDoubleStream2D stream = (ChunkFillableDoubleStream2D) fixture.complex.getRawHeightStream(); + double[] values = new double[256]; + stream.fillChunkDoubles(-16, 32, values); + for (double value : values) { + assertBits(-1D, value); + } + ready.set(true); + int before = samples.get(); + stream.fillChunkDoubles(-16, 32, values); + assertEquals(before + 256, samples.get()); + for (int index = 0; index < 256; index++) { + assertBits(height(-16 + (index & 15), 32 + (index >> 4)), values[index]); + } + stream.fillChunkDoubles(-16, 32, values); + assertEquals(before + 256, samples.get()); + } + } + + private Path pack() throws Exception { + Path pack = temporaryFolder.newFolder().toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.createDirectories(pack.resolve("regions")); + Files.createDirectories(pack.resolve("biomes")); + Files.createDirectories(pack.resolve("generators")); + Files.writeString(pack.resolve("dimensions/main.json"), """ + {"regions":["plain"],"landChance":1, + "carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}} + """); + Files.writeString(pack.resolve("regions/plain.json"), "{\"landBiomes\":[\"plain\"]}"); + Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); + Files.writeString(pack.resolve("biomes/plain.json"), + "{\"generators\":[{\"generator\":\"flat\",\"min\":10,\"max\":10}]}"); + return pack; + } + + private static double height(double x, double z) { + return x * 0.125D - z * 0.03125D; + } + + private static void assertBits(double expected, double actual) { + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + } + + private static final class Fixture implements AutoCloseable { + private final Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + private final EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + private final AtomicBoolean mainThread = new AtomicBoolean(); + private final MockedStatic blocks = mockStatic(B.class); + private final MockedStatic imageRuntimes = mockStatic(IrisImageMapRuntime.class); + private final IrisImageMapRuntime images = mock(IrisImageMapRuntime.class); + private final IrisData data; + private final IrisComplex complex; + + private Fixture(Path pack) { + NativeBlockState block = mock(NativeBlockState.class); + when(block.isFluid()).thenReturn(true); + blocks.when(() -> B.getState(anyString())).thenReturn(block); + blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block); + imageRuntimes.when(() -> IrisImageMapRuntime.compile(engine)).thenReturn(images); + when(images.has(IrisImageMapApplication.BIOME)).thenReturn(true); + when(images.sampleTerrainHeight(anyDouble(), anyDouble(), anyDouble())) + .thenAnswer(invocation -> invocation.getArgument(2)); + data = IrisData.openRuntime(pack.toFile()); + IrisDimension dimension = data.getDimensionLoader().load("main"); + doReturn(data).when(engine).getData(); + doReturn(dimension).when(engine).getDimension(); + doReturn(384).when(engine).getHeight(); + doReturn(-64).when(engine).getMinHeight(); + doReturn(320).when(engine).getMaxHeight(); + when(hooks.isMainThread()).thenAnswer(ignored -> mainThread.get()); + doReturn(hooks).when(engine).getPlatformHooks(); + doReturn(new SeedManager(1337L)).when(engine).getSeedManager(); + complex = new IrisComplex(engine); + doReturn(complex).when(engine).getComplex(); + } + + private AtomicInteger installNaturalHeight() { + AtomicInteger samples = new AtomicInteger(); + complex.setNaturalHeightStream(ProceduralStream.ofDouble((x, z) -> { + samples.incrementAndGet(); + return height(x, z); + })); + return samples; + } + + @Override + public void close() { + try { + complex.close(); + data.close(); + } finally { + imageRuntimes.close(); + blocks.close(); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierHydrologyTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierHydrologyTest.java index c41a878d8..ed572c868 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierHydrologyTest.java @@ -1,20 +1,87 @@ package art.arcane.iris.generation.stage; +import art.arcane.volmlib.util.stream.ProceduralStream; import org.junit.Test; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class IrisPostModifierHydrologyTest { @Test public void columnsTouchingARiverFootprintSkipThePostPasses() { - assertTrue(IrisPostModifier.skipsHydrologyColumn(new double[] {1D, 0D, 0D, 0D, 0D})); - assertTrue(IrisPostModifier.skipsHydrologyColumn(new double[] {0D, 0D, 0.5D, 0D, 0D})); - assertTrue(IrisPostModifier.skipsHydrologyColumn(new double[] {0D, 0D, 0D, 0D, 0.75D})); + for (int index : new int[] {4, 3, 5, 1, 7}) { + boolean[] rivers = new boolean[9]; + rivers[index] = true; + assertTrue(IrisPostModifier.skipsHydrologyColumn(rivers, 4, 3)); + } } @Test public void columnsAwayFromRiversKeepThePostPasses() { - assertFalse(IrisPostModifier.skipsHydrologyColumn(new double[] {0D, 0D, 0D, 0D, 0D})); + boolean[] rivers = new boolean[9]; + rivers[0] = true; + rivers[2] = true; + rivers[6] = true; + rivers[8] = true; + assertFalse(IrisPostModifier.skipsHydrologyColumn(rivers, 4, 3)); + } + + @Test + public void paddedPlaneSamplesEachRequiredCoordinateOnce() { + Map samples = new HashMap<>(); + ProceduralStream stream = ProceduralStream.ofDouble((x, z) -> { + long key = ((long) (int) x << 32) | ((int) z & 0xffffffffL); + samples.merge(key, 1, Integer::sum); + return weight((int) x, (int) z); + }); + boolean[] rivers = IrisPostModifier.riverPlane(stream, -32, 48, 16, 16); + assertEquals(320, samples.size()); + for (int count : samples.values()) { + assertEquals(1, count); + } + assertPlaneMatchesPointSampling(rivers, -32, 48, 16, 16); + } + + @Test + public void rectangularPlanesPreservePositiveWeightRules() { + ProceduralStream stream = ProceduralStream.ofDouble((x, z) -> weight((int) x, (int) z)); + for (int width : new int[] {1, 3, 16}) { + for (int depth : new int[] {1, 5, 16}) { + boolean[] rivers = IrisPostModifier.riverPlane(stream, -19, -37, width, depth); + assertPlaneMatchesPointSampling(rivers, -19, -37, width, depth); + } + } + } + + private static void assertPlaneMatchesPointSampling(boolean[] rivers, int x, int z, int width, int depth) { + int planeWidth = width + 2; + for (int localX = 0; localX < width; localX++) { + for (int localZ = 0; localZ < depth; localZ++) { + int worldX = x + localX; + int worldZ = z + localZ; + boolean expected = weight(worldX, worldZ) > 0D || weight(worldX + 1, worldZ) > 0D + || weight(worldX - 1, worldZ) > 0D || weight(worldX, worldZ + 1) > 0D + || weight(worldX, worldZ - 1) > 0D; + assertEquals(expected, IrisPostModifier.skipsHydrologyColumn(rivers, + (localZ + 1) * planeWidth + localX + 1, planeWidth)); + } + } + } + + private static double weight(int x, int z) { + return switch (Math.floorMod(x * 31 + z * 17, 8)) { + case 0 -> Double.NaN; + case 1 -> -1D; + case 2 -> 0D; + case 3 -> -0D; + case 4 -> Double.MIN_VALUE; + case 5 -> 1D; + case 6 -> Double.POSITIVE_INFINITY; + default -> Double.NEGATIVE_INFINITY; + }; } } diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierSlabSupportTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierSlabSupportTest.java index 7a635f226..3e6f2cffd 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierSlabSupportTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierSlabSupportTest.java @@ -91,9 +91,9 @@ public class IrisPostModifierSlabSupportTest { heights[2 * PLANE_WIDTH + 3] = SURFACE + 1; Method post = IrisPostModifier.class.getDeclaredMethod("post", int.class, int.class, Hunk.class, - int.class, int.class, ChunkContext.class, int[].class, int.class, boolean.class, boolean.class); + int.class, int.class, ChunkContext.class, int[].class, boolean[].class, int.class, boolean.class, boolean.class); post.setAccessible(true); - post.invoke(modifier, 1, 1, output, 1, 1, context, heights, PLANE_WIDTH, false, true); + post.invoke(modifier, 1, 1, output, 1, 1, context, heights, new boolean[heights.length], PLANE_WIDTH, false, true); return output.get(1, SURFACE + 1, 1); } diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierWallShoreTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierWallShoreTest.java index 88555daee..43ff0d941 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierWallShoreTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierWallShoreTest.java @@ -123,9 +123,10 @@ public class IrisPostModifierWallShoreTest { heights[5] = SURFACE - 4; Method post = IrisPostModifier.class.getDeclaredMethod("post", int.class, int.class, Hunk.class, - int.class, int.class, ChunkContext.class, int[].class, int.class, boolean.class, boolean.class); + int.class, int.class, ChunkContext.class, int[].class, boolean[].class, int.class, boolean.class, boolean.class); post.setAccessible(true); - post.invoke(modifier, 0, 0, data, 0, 0, context, heights, PLANE_WIDTH, walls, false); + boolean[] rivers = IrisPostModifier.riverPlane(carveWeights, 0, 0, PLANE_WIDTH - 2, PLANE_WIDTH - 2); + post.invoke(modifier, 0, 0, data, 0, 0, context, heights, rivers, PLANE_WIDTH, walls, false); return data.get(0, SURFACE, 0); }