From 0d34fae316f3a784497e2808dfa19baab985c8d6 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 13 Oct 2026 14:57:12 -0400 Subject: [PATCH] Batched terrain noise - Hydrology grids carry uncached heights through bounded noise batches - Pure generator layers resolve seed contexts once per batch - Surface smoothing and volumetric terrain retain scalar output - Provisional terrain samples stay out of routing caches - Tests cover exact parity, scalar fallbacks, batch limits and cached misses --- .../IrisHydrologyNaturalHeightProvider.java | 12 ++ .../IrisHydrologyRoutingTerrainSampler.java | 170 +++++++++++++++--- .../runtime/IrisHydrologyRuntime.java | 2 +- .../iris/generation/noise/IrisGenerator.java | 99 +++++++++- .../generation/noise/IrisNoiseGenerator.java | 62 +++++++ .../iris/generation/runtime/IrisComplex.java | 152 +++++++++++++++- .../stream/CachedDoubleStream2D.java | 14 ++ .../stream/ProvisionalSampling.java | 4 + .../generation/terrain/Terrain3DRuntime.java | 4 + ...risHydrologyRoutingTerrainSamplerTest.java | 127 +++++++++++++ .../noise/IrisGeneratorBatchTest.java | 129 +++++++++++++ .../runtime/IrisComplexHeightBatchTest.java | 78 ++++++++ 12 files changed, 820 insertions(+), 33 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorBatchTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightBatchTest.java diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyNaturalHeightProvider.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyNaturalHeightProvider.java index 8b2e462f0..611cc454f 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyNaturalHeightProvider.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyNaturalHeightProvider.java @@ -1,6 +1,18 @@ package art.arcane.iris.generation.hydrology.runtime; +import art.arcane.iris.generation.hydrology.RiverFootprint; + @FunctionalInterface public interface IrisHydrologyNaturalHeightProvider { double sample(int blockX, int blockZ); + + default boolean supportsBatch() { + return false; + } + + default void sampleBatch(long[] coordinates, int count, double[] heights) { + for (int index = 0; index < count; index++) { + heights[index] = sample(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index])); + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java index 1ff641808..c86d8295e 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -10,6 +10,8 @@ import art.arcane.iris.generation.hydrology.RiverFootprint; import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope; import art.arcane.iris.generation.hydrology.HydrologyForkJoin; import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.stream.ProvisionalSampling; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; @@ -98,15 +100,10 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return cached; } } - NaturalClassification sampled = NaturalClassification.LAND; - if (oceanClassifier.isOcean(blockX, blockZ)) { - double height = naturalHeight(stripe, packed, blockX, blockZ); - if (!Double.isFinite(height)) { - return NaturalClassification.UNAVAILABLE; - } - if (physicalOcean(true, height, seaLevel)) { - sampled = NaturalClassification.OCEAN; - } + long revision = ProvisionalSampling.revision(); + NaturalClassification sampled = classifyNatural(stripe, packed, blockX, blockZ); + if (sampled == NaturalClassification.UNAVAILABLE || revision != ProvisionalSampling.revision()) { + return sampled; } synchronized (stripe) { TerrainBasis basis = stripe.bases.getAndMoveToLast(packed); @@ -123,6 +120,17 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return sampled; } + private NaturalClassification classifyNatural(CacheStripe stripe, long packed, int blockX, int blockZ) { + if (!oceanClassifier.isOcean(blockX, blockZ)) { + return NaturalClassification.LAND; + } + double height = naturalHeight(stripe, packed, blockX, blockZ); + if (!Double.isFinite(height)) { + return NaturalClassification.UNAVAILABLE; + } + return physicalOcean(true, height, seaLevel) ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + @Override public HydrologyTerrainSample sampleBasis(int blockX, int blockZ) { // Point samples carry the forward slope; the aligned grid derives its own from neighbouring bases. @@ -144,10 +152,15 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai int workers = Math.min(Math.min(samplingOptions.maximumWorkers(), Runtime.getRuntime().availableProcessors()), Math.ceilDiv(count, MINIMUM_PARALLEL_BASIS_SAMPLES)); Supplier scope = workers > 1 ? samplingOptions.batchScopes().capture() : null; - if (scope == null) { - return HydrologyNaturalTerrainSampler.super.sampleBasisWithoutSlopeBatch(coordinates, count); - } HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count]; + if (scope == null) { + TerrainBasis[] bases = new TerrainBasis[count]; + fillBasisBatch(coordinates, count, bases, 0); + for (int index = 0; index < count; index++) { + samples[index] = bases[index].terrain(); + } + return samples; + } int perWorker = Math.ceilDiv(count, workers); ArrayList> tasks = new ArrayList<>(workers); for (int worker = 0; worker < workers; worker++) { @@ -158,12 +171,12 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai } tasks.add(() -> { try (NativeGenerationScope ignored = scope.get()) { + long[] slice = new long[end - start]; + System.arraycopy(coordinates, start, slice, 0, slice.length); + TerrainBasis[] bases = new TerrainBasis[slice.length]; + fillBasisBatch(slice, slice.length, bases, 0); for (int index = start; index < end; index++) { - if (Thread.currentThread().isInterrupted()) { - throw new CancellationException("Terrain basis sampling interrupted"); - } - samples[index] = sampleBasisWithoutSlope(RiverFootprint.unpackX(coordinates[index]), - RiverFootprint.unpackZ(coordinates[index])); + samples[index] = bases[index - start].terrain(); } } return null; @@ -183,9 +196,12 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return cached.terrain().ocean() ? Double.NaN : cached.terrain().naturalHeight(); } } + long revision = ProvisionalSampling.revision(); double naturalHeight = naturalHeight(stripe, packed, blockX, blockZ); + boolean memoizable = revision == ProvisionalSampling.revision(); if (!Double.isFinite(naturalHeight)) { - TerrainBasis basis = loadBasis(blockX, blockZ, stripe, packed, naturalHeight); + TerrainBasis basis = memoizable ? loadBasis(blockX, blockZ, stripe, packed, naturalHeight) + : sampleBasis(blockX, blockZ, naturalHeight); return basis.terrain().ocean() ? Double.NaN : basis.terrain().naturalHeight(); } long roundedHeight = StrictMath.round(naturalHeight); @@ -194,7 +210,8 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai } NaturalClassification classification = classifyNatural(blockX, blockZ); if (classification == NaturalClassification.UNAVAILABLE) { - TerrainBasis basis = loadBasis(blockX, blockZ, stripe, packed, naturalHeight); + TerrainBasis basis = memoizable ? loadBasis(blockX, blockZ, stripe, packed, naturalHeight) + : sampleBasis(blockX, blockZ, naturalHeight); return basis.terrain().ocean() ? Double.NaN : basis.terrain().naturalHeight(); } return classification == NaturalClassification.OCEAN ? Double.NaN : (int) roundedHeight; @@ -209,16 +226,28 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return cached; } } - return loadBasis(blockX, blockZ, stripe, packed, naturalHeight(stripe, packed, blockX, blockZ)); + long revision = ProvisionalSampling.revision(); + TerrainBasis sampled = sampleBasis(blockX, blockZ, naturalHeight(stripe, packed, blockX, blockZ)); + return revision == ProvisionalSampling.revision() ? publishBasis(stripe, packed, sampled) : sampled; } private TerrainBasis loadBasis(int blockX, int blockZ, CacheStripe stripe, long packed, double naturalHeight) { + long revision = ProvisionalSampling.revision(); + TerrainBasis sampled = sampleBasis(blockX, blockZ, naturalHeight); + return revision == ProvisionalSampling.revision() ? publishBasis(stripe, packed, sampled) : sampled; + } + + private TerrainBasis sampleBasis(int blockX, int blockZ, double naturalHeight) { TerrainBasis sampled = basisProvider.sample(blockX, blockZ, naturalHeight); if (sampled == null) { throw new NullPointerException( "Hydrology terrain basis provider returned null at " + blockX + "," + blockZ ); } + return sampled; + } + + private TerrainBasis publishBasis(CacheStripe stripe, long packed, TerrainBasis sampled) { synchronized (stripe) { TerrainBasis existing = stripe.bases.getAndMoveToLast(packed); if (existing != null) { @@ -349,14 +378,108 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai int minimumRow, int maximumRow ) { + int capacity = Math.min(HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE, + Math.multiplyExact(maximumRow - minimumRow, planeWidth)); + long[] coordinates = new long[capacity]; + int count = 0; + int destination = minimumRow * planeWidth; for (int gridZ = minimumRow; gridZ < maximumRow; gridZ++) { int z = Math.toIntExact((long) minimumZ + (long) gridZ * spacing); - int planeIndex = gridZ * planeWidth; for (int gridX = 0; gridX < planeWidth; gridX++) { int x = Math.toIntExact((long) minimumX + (long) gridX * spacing); - plane[planeIndex++] = basis(x, z); + coordinates[count++] = RiverFootprint.pack(x, z); + if (count == coordinates.length) { + fillBasisBatch(coordinates, count, plane, destination); + destination += count; + count = 0; + } } } + if (count > 0) { + fillBasisBatch(coordinates, count, plane, destination); + } + } + + private void fillBasisBatch(long[] coordinates, int count, TerrainBasis[] target, int offset) { + if (!heightProvider.supportsBatch()) { + for (int index = 0; index < count; index++) { + if (Thread.currentThread().isInterrupted()) { + throw new CancellationException("Terrain basis sampling interrupted"); + } + target[offset + index] = basis(RiverFootprint.unpackX(coordinates[index]), + RiverFootprint.unpackZ(coordinates[index])); + } + return; + } + long[] missing = new long[count]; + int[] slots = new int[count]; + double[] heights = new double[count]; + Long2IntOpenHashMap indices = new Long2IntOpenHashMap(count); + indices.defaultReturnValue(-1); + int missingCount = 0; + for (int index = 0; index < count; index++) { + int x = RiverFootprint.unpackX(coordinates[index]); + int z = RiverFootprint.unpackZ(coordinates[index]); + long packed = pack(x, z); + CacheStripe stripe = stripe(packed); + synchronized (stripe) { + TerrainBasis basis = stripe.bases.getAndMoveToLast(packed); + if (basis != null) { + target[offset + index] = basis; + slots[index] = -1; + continue; + } + heights[index] = stripe.naturalHeights.getAndMoveToLast(packed); + } + if (!Double.isNaN(heights[index])) { + slots[index] = -1; + continue; + } + int slot = indices.get(coordinates[index]); + if (slot < 0) { + slot = missingCount++; + indices.put(coordinates[index], slot); + missing[slot] = coordinates[index]; + } + slots[index] = slot; + } + double[] sampled = new double[missingCount]; + boolean memoizable = true; + if (missingCount > 0) { + if (Thread.currentThread().isInterrupted()) { + throw new CancellationException("Terrain basis sampling interrupted"); + } + long revision = ProvisionalSampling.revision(); + heightProvider.sampleBatch(missing, missingCount, sampled); + memoizable = revision == ProvisionalSampling.revision(); + for (int index = 0; memoizable && index < missingCount; index++) { + if (!Double.isFinite(sampled[index])) { + continue; + } + long packed = pack(RiverFootprint.unpackX(missing[index]), RiverFootprint.unpackZ(missing[index])); + CacheStripe stripe = stripe(packed); + synchronized (stripe) { + double existing = stripe.naturalHeights.getAndMoveToLast(packed); + if (Double.isNaN(existing)) { + stripe.naturalHeights.putAndMoveToLast(packed, sampled[index]); + stripe.evictOldest(stripe.naturalHeights); + } else { + sampled[index] = existing; + } + } + } + } + for (int index = 0; index < count; index++) { + if (target[offset + index] != null) { + continue; + } + int x = RiverFootprint.unpackX(coordinates[index]); + int z = RiverFootprint.unpackZ(coordinates[index]); + long packed = pack(x, z); + double height = slots[index] < 0 ? heights[index] : sampled[slots[index]]; + target[offset + index] = memoizable ? loadBasis(x, z, stripe(packed), packed, height) + : sampleBasis(x, z, height); + } } private int gridWorkerCount(int planeWidth, int planeSize) { @@ -384,8 +507,9 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return cachedBasis.naturalHeight(); } } + long revision = ProvisionalSampling.revision(); double sampled = heightProvider.sample(blockX, blockZ); - if (!Double.isFinite(sampled)) { + if (!Double.isFinite(sampled) || revision != ProvisionalSampling.revision()) { // Never memoize a broken sample: the basis provider rejects it with a diagnostic and a // later sample of the same column gets a fresh chance instead of the cached failure. return sampled; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java index 2cff804f9..1703625a7 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java @@ -100,7 +100,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { HydrologyGeometrySampler geometrySampler = geometrySampler(context, hydrology); IrisHydrologyRoutingTerrainSampler.Sources terrainSources = new IrisHydrologyRoutingTerrainSampler.Sources( this::createTerrainBasis, - (int x, int z) -> context.naturalHeightProvider().sample(x, z), + context.naturalHeightProvider(), (int x, int z) -> context.naturalOceanClassifier().isOcean(x, z), settings.seaLevel() ); diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java index 7d0a33444..6ff6032fb 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java @@ -41,7 +41,10 @@ import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; +import java.util.Arrays; import java.util.List; +import java.util.Objects; +import java.util.function.DoubleBinaryOperator; @Accessors(chain = true) @NoArgsConstructor @@ -208,26 +211,106 @@ public class IrisGenerator extends IrisRegistrant { if (surfaceDetail == 1D) { return sampleHeight(rx, rz, superSeed, data, engine); } + return surfaceHeight(rx, rz, superSeed, data, engine, null); + } + + public boolean supportsBatch() { + if (getClass() != IrisGenerator.class || !Double.isFinite(surfaceDetail) + || surfaceDetail < 0D || surfaceDetail > 1D) { + return false; + } + for (IrisNoiseGenerator layer : composite) { + if (!layer.supportsBatch()) { + return false; + } + } + return !hasCliffs() || cliffHeightGenerator.supportsBatch(); + } + + public void getHeights(double[] xs, double[] zs, long superSeed, double[] target, int count) { + Objects.checkFromIndexSize(0, count, xs.length); + Objects.checkFromIndexSize(0, count, zs.length); + Objects.checkFromIndexSize(0, count, target.length); + if (count > 4096) { + throw new IllegalArgumentException("Generator batch exceeds 4096 samples"); + } + if (count < 8 || !supportsBatch()) { + for (int index = 0; index < count; index++) { + target[index] = getHeight(xs[index], zs[index], superSeed); + } + return; + } + if (composite.isEmpty()) { + Arrays.fill(target, 0, count, 0D); + return; + } + IrisData data = getLoader(); + Engine engine = data == null ? null : data.getEngine(); + DoubleBinaryOperator prepared = prepareHeight(superSeed, data, engine); + for (int index = 0; index < count; index++) { + target[index] = surfaceDetail == 1D ? prepared.applyAsDouble(xs[index], zs[index]) + : surfaceHeight(xs[index], zs[index], superSeed, data, engine, prepared); + } + } + + private DoubleBinaryOperator prepareHeight(long superSeed, IrisData data, Engine engine) { + int hc = (int) ((cliffHeightMin * 10) + 10 + cliffHeightMax * getSeed() + offsetX + offsetZ); + DoubleBinaryOperator[] layers = new DoubleBinaryOperator[composite.size()]; + double totalOpacity = 0D; + for (int index = 0; index < layers.length; index++) { + IrisNoiseGenerator layer = composite.get(index); + layers[index] = layer.prepareBatch(getSeed() + superSeed + hc, data, engine); + if (!multiplicitive) { + totalOpacity += layer.getOpacity(); + } + } + double sumOpacity = totalOpacity; + double cliffSeed = (double) (superSeed + 294596 + hc) - 34857; + DoubleBinaryOperator cliffs = hasCliffs() ? cliffHeightGenerator.prepareBatch( + (long) (getSeed() + cliffSeed + hc), data, engine) : null; + return (rx, rz) -> { + double sampleX = (rx + offsetX) / zoom; + double sampleZ = (rz + offsetZ) / zoom; + double h = multiplicitive ? 1D : 0D; + for (DoubleBinaryOperator layer : layers) { + double sampled = layer.applyAsDouble(sampleX, sampleZ); + h = multiplicitive ? h * sampled : h + sampled; + } + double value = multiplicitive ? h * opacity : (h / sumOpacity) * opacity; + if (Double.isNaN(value)) { + value = 0D; + } + if (cliffs != null) { + double height = IrisInterpolation.lerp(cliffHeightMin, cliffHeightMax, + cliffs.applyAsDouble(sampleX, sampleZ)); + value = (Math.round((value * 255D) / height) * height) / 255D; + } + return hasCellCracks() ? cell(rx, rz, value, superSeed + 48622 + hc) : value; + }; + } + + private double surfaceHeight(double rx, double rz, long superSeed, IrisData data, Engine engine, + DoubleBinaryOperator prepared) { SurfaceCacheStripe[] stripes = surfaceCache(data, engine).stripes(); double lowerX = Math.floor(rx / SURFACE_GRID) * SURFACE_GRID; double lowerZ = Math.floor(rz / SURFACE_GRID) * SURFACE_GRID; double dx = (rx - lowerX) / SURFACE_GRID; double dz = (rz - lowerZ) / SURFACE_GRID; - double northWest = cachedSurfaceHeight(lowerX, lowerZ, superSeed, stripes, data, engine); + double northWest = cachedSurfaceHeight(lowerX, lowerZ, superSeed, stripes, data, engine, prepared); if (dx == 0D && dz == 0D) { return northWest; } double north = dx == 0D ? northWest : IrisInterpolation.lerp(northWest, - cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ, superSeed, stripes, data, engine), dx); + cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ, superSeed, stripes, data, engine, prepared), dx); double smooth = north; if (dz != 0D) { - double southWest = cachedSurfaceHeight(lowerX, lowerZ + SURFACE_GRID, superSeed, stripes, data, engine); + double southWest = cachedSurfaceHeight(lowerX, lowerZ + SURFACE_GRID, superSeed, stripes, data, engine, prepared); double south = dx == 0D ? southWest : IrisInterpolation.lerp(southWest, - cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ + SURFACE_GRID, superSeed, stripes, data, engine), dx); + cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ + SURFACE_GRID, superSeed, stripes, data, engine, prepared), dx); smooth = IrisInterpolation.lerp(north, south, dz); } return surfaceDetail == 0D ? smooth - : smooth + (sampleHeight(rx, rz, superSeed, data, engine) - smooth) * surfaceDetail; + : smooth + ((prepared == null ? sampleHeight(rx, rz, superSeed, data, engine) : prepared.applyAsDouble(rx, rz)) - smooth) * surfaceDetail; } private double sampleHeight(double rx, double rz, long superSeed, IrisData data, Engine engine) { @@ -267,9 +350,9 @@ public class IrisGenerator extends IrisRegistrant { return v; } - private double cachedSurfaceHeight(double x, double z, long superSeed, SurfaceCacheStripe[] stripes, IrisData data, Engine engine) { + private double cachedSurfaceHeight(double x, double z, long superSeed, SurfaceCacheStripe[] stripes, IrisData data, Engine engine, DoubleBinaryOperator prepared) { if (x != (int) x || z != (int) z) { - return sampleHeight(x, z, superSeed, data, engine); + return prepared == null ? sampleHeight(x, z, superSeed, data, engine) : prepared.applyAsDouble(x, z); } long key = ((long) (int) x << 32) | ((int) z & 0xffffffffL); long mixed = key ^ (key >>> 33); @@ -281,7 +364,7 @@ public class IrisGenerator extends IrisRegistrant { if (cached != null) { return cached.height(); } - double height = sampleHeight(x, z, superSeed, data, engine); + double height = prepared == null ? sampleHeight(x, z, superSeed, data, engine) : prepared.applyAsDouble(x, z); stripe.put(slot, new SurfaceSample(key, superSeed, height)); return height; } 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 b10fbe171..2793c4c23 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 @@ -48,6 +48,7 @@ import lombok.Setter; import lombok.experimental.Accessors; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.DoubleBinaryOperator; @Snippet("generator") @Accessors(chain = true) @@ -176,6 +177,67 @@ public class IrisNoiseGenerator { return sample(getGenerator(superSeed, data, engine), x, z); } + boolean supportsBatch() { + if (getClass() != IrisNoiseGenerator.class) { + return false; + } + if (!enabled) { + return true; + } + for (IrisGeneratorStyle current = style; current != null; current = current.getFracture()) { + if (current.getClass() != IrisGeneratorStyle.class || current.getExpression() != null + || current.getImageMap() != null) { + return false; + } + } + for (IrisNoiseGenerator child : fracture) { + if (!child.supportsBatch()) { + return false; + } + } + return true; + } + + DoubleBinaryOperator prepareBatch(long superSeed, IrisData data, Engine engine) { + if (!enabled) { + double value = offsetY; + return (x, z) -> value; + } + if (hasDeepFracture()) { + FractureSamples samples = new FractureSamples(); + if (samples.prepare(this, superSeed, data, engine)) { + return samples::sample; + } + } + DoubleBinaryOperator[] children = new DoubleBinaryOperator[fracture.size()]; + double[] offsets = new double[fracture.size()]; + int salt = 33; + for (int index = 0; index < children.length; index++) { + IrisNoiseGenerator child = fracture.get(index); + if (child.isEnabled()) { + children[index] = child.prepareBatch(superSeed + seed + salt, data, engine); + offsets[index] = child.getOpacity() / 2D; + } + salt += 819; + } + CNG generator = getGenerator(superSeed, data, engine); + return (x, z) -> samplePrepared(generator, children, offsets, x, z); + } + + private double samplePrepared(CNG generator, DoubleBinaryOperator[] children, double[] offsets, + double xv, double zv) { + double x = xv; + double z = zv; + for (int index = 0; index < children.length; index++) { + DoubleBinaryOperator child = children[index]; + if (child != null) { + x += child.applyAsDouble(xv, zv) - offsets[index]; + z += child.applyAsDouble(zv, xv) - offsets[index]; + } + } + return sample(generator, x, z); + } + public KList getAllComposites() { KList g = new KList<>(); 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 0204af6fe..64dcf075c 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 @@ -39,6 +39,8 @@ import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.hydrology.HydrologyColumnSnapshot; import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyNaturalSample; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyNaturalHeightProvider; +import art.arcane.iris.generation.hydrology.RiverFootprint; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntimeContext; import art.arcane.iris.generation.image.IrisImageMapApplication; @@ -454,7 +456,7 @@ public class IrisComplex implements DataProvider { engine.getDimension(), data, (x, z, naturalHeight) -> sampleHydrologyNatural(engine, x, z, naturalHeight), - (x, z) -> naturalHeightStream.getDouble(x, z), + new HydrologyHeightProvider(), (x, z) -> describeNaturalHeight(engine, x, z), this::sampleNaturalOcean, footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint), @@ -791,6 +793,154 @@ public class IrisComplex implements DataProvider { return column == null ? getHeightStream().getDouble(x, z) : column.nearestSurfaceY(surfaceY); } + private boolean supportsHeightBatch() { + if (terrainEngine.getDimension().getTerrainSamplingStep() != 1 || !biomeBuffetGenerators.isEmpty() + || !reuseNaturalBaseBiome) { + return false; + } + for (IrisGenerator[] group : frozenGenerators) { + for (IrisGenerator generator : group) { + if (!generator.supportsBatch()) { + return false; + } + } + } + for (IrisShapedGeneratorStyle overlay : terrainEngine.getDimension().getOverlayNoise()) { + if (overlay.getClass() != IrisShapedGeneratorStyle.class) { + return false; + } + for (IrisGeneratorStyle style = overlay.getGenerator(); style != null; style = style.getFracture()) { + if (style.getClass() != IrisGeneratorStyle.class || style.getExpression() != null) { + return false; + } + } + } + return true; + } + + private void sampleBaseHeightBatch(long[] coordinates, int count, double[] target) { + ((CachedDoubleStream2D) baseTerrainHeightStream).getBatch(coordinates, count, target, this::resolveBaseHeightBatch); + } + + private void resolveBaseHeightBatch(long[] missing, int size, double[] values) { + double[] xs = new double[size]; + double[] zs = new double[size]; + double[] lows = new double[size]; + double[] highs = new double[size]; + double[] activeXs = new double[size]; + double[] activeZs = new double[size]; + double[] noise = new double[size]; + double[] groupHeights = new double[size]; + int[] active = new int[size]; + for (int index = 0; index < size; index++) { + xs[index] = RiverFootprint.unpackX(missing[index]); + zs[index] = RiverFootprint.unpackZ(missing[index]); + } + long seed = terrainEngine.getSeedManager().getHeight() + 239945; + for (int group = 0; group < frozenInterpolators.length; group++) { + IrisGenerator[] generators = frozenGenerators[group]; + if (generators.length == 0) { + continue; + } + int activeCount = 0; + Arrays.fill(groupHeights, 0D); + for (int index = 0; index < size; index++) { + NoiseBounds bounds = gridSampleBounds(terrainEngine, frozenInterpolators[group], group, + generators, xs[index], zs[index]); + lows[index] = bounds.min(); + highs[index] = bounds.max(); + if (!Double.isFinite(lows[index]) || !Double.isFinite(highs[index])) { + averageGeneratorHeights(generators, lows[index], highs[index], xs[index], zs[index], seed); + } + if (lows[index] != highs[index]) { + active[activeCount] = index; + activeXs[activeCount] = xs[index]; + activeZs[activeCount++] = zs[index]; + } else { + for (int generator = 0; generator < generators.length; generator++) { + groupHeights[index] += lows[index]; + } + } + } + for (IrisGenerator generator : generators) { + if (activeCount == 0) { + break; + } + generator.getHeights(activeXs, activeZs, seed, noise, activeCount); + for (int sample = 0; sample < activeCount; sample++) { + int index = active[sample]; + if (!Double.isFinite(noise[sample])) { + throw new IllegalStateException("Terrain generator noise was not finite at " + xs[index] + "," + zs[index] + + " (generator=" + generator.getLoadKey() + ", noise=" + noise[sample] + + ", bounds=" + lows[index] + ".." + highs[index] + ")."); + } + groupHeights[index] += M.lerp(lows[index], highs[index], noise[sample]); + } + } + for (int index = 0; index < size; index++) { + values[index] += groupHeights[index] / generators.length; + } + } + for (int index = 0; index < size; index++) { + double height = Math.max(Math.min(values[index] + fluidHeight + overlayStream.get(xs[index], zs[index]), + terrainEngine.getHeight()), 0); + values[index] = imageMapRuntime.sampleTerrainHeight(xs[index], zs[index], height); + } + } + + private void sampleUnblendedHeightBatch(long[] coordinates, int count, double[] target) { + if (terrain3D == null) { + sampleBaseHeightBatch(coordinates, count, target); + return; + } + ((CachedDoubleStream2D) unblendedNaturalHeightStream).getBatch(coordinates, count, target, (missing, size, values) -> { + long[] required = new long[size]; + int requiredCount = 0; + for (int index = 0; index < size; index++) { + if (terrain3D.requiresBaseHeight(RiverFootprint.unpackX(missing[index]), RiverFootprint.unpackZ(missing[index]))) { + required[requiredCount++] = missing[index]; + } + } + if (requiredCount > 0) { + sampleBaseHeightBatch(required, requiredCount, new double[requiredCount]); + } + for (int index = 0; index < size; index++) { + values[index] = sampleTerrain3DHeight(RiverFootprint.unpackX(missing[index]), RiverFootprint.unpackZ(missing[index])); + } + }); + } + + private final class HydrologyHeightProvider implements IrisHydrologyNaturalHeightProvider { + private final boolean batch = supportsHeightBatch(); + + @Override + public double sample(int blockX, int blockZ) { + return naturalHeightStream.getDouble(blockX, blockZ); + } + + @Override + public boolean supportsBatch() { + return batch; + } + + @Override + public void sampleBatch(long[] coordinates, int count, double[] heights) { + if (!batch) { + IrisHydrologyNaturalHeightProvider.super.sampleBatch(coordinates, count, heights); + } else if (transitionDisplacement == null) { + sampleUnblendedHeightBatch(coordinates, count, heights); + } else { + ((CachedDoubleStream2D) naturalHeightStream).getBatch(coordinates, count, heights, (missing, size, values) -> { + sampleUnblendedHeightBatch(missing, size, values); + for (int index = 0; index < size; index++) { + values[index] = transitionDisplacement.height(RiverFootprint.unpackX(missing[index]), + RiverFootprint.unpackZ(missing[index]), values[index]); + } + }); + } + } + } + private double sampleUnblendedNaturalTerrainHeight(double x, double z) { double proceduralHeight = proceduralTerrainHeight.sample(x, z); return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight); diff --git a/core/src/main/java/art/arcane/iris/generation/stream/CachedDoubleStream2D.java b/core/src/main/java/art/arcane/iris/generation/stream/CachedDoubleStream2D.java index 6947a6afb..2c36c94aa 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/CachedDoubleStream2D.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/CachedDoubleStream2D.java @@ -50,6 +50,20 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu } } + public void getBatch(long[] coordinates, int count, double[] target, WorldCache2DDouble.BatchResolver resolver) { + try { + cache.getBatch(coordinates, count, target, (missing, size, values) -> + ProvisionalSampling.memoizable(() -> { + resolver.resolve(missing, size, values); + return values; + })); + } catch (ProvisionalSampling.Unmemoizable provisional) { + for (int index = 0; index < count; index++) { + target[index] = getDouble((int) (coordinates[index] >> 32), (int) coordinates[index]); + } + } + } + @Override public long getSize() { return cache.getSize(); diff --git a/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java b/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java index 0d912b67f..4b6c82d53 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java @@ -16,6 +16,10 @@ public final class ProvisionalSampling { private ProvisionalSampling() { } + public static long revision() { + return TRACKERS.get().revision; + } + /** Records that the calling thread just answered from inputs that are not final. */ public static void mark() { TRACKERS.get().revision++; diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java index c419b7bf1..9f35327db 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java @@ -63,6 +63,10 @@ public final class Terrain3DRuntime { return Math.max(ANCHOR_BLOCK_SIZE, (int) (maximumEntries / ANCHOR_BLOCK_SIZE) * ANCHOR_BLOCK_SIZE); } + public boolean requiresBaseHeight(int x, int z) { + return !active() || columns.get(x, z) == null; + } + public double height(int x, int z) { if (!active()) { return baseHeight(x, z); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java index e8db7a54f..3bad05e42 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.hydrology.runtime; import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler; +import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; import art.arcane.iris.generation.hydrology.RiverFootprint; import art.arcane.iris.generation.concurrent.MultiBurst; @@ -40,6 +41,132 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; public class IrisHydrologyRoutingTerrainSamplerTest { + @Test + public void provisionalOceanClassificationIsReevaluated() { + AtomicInteger calls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources((x, z, height) -> { + throw new AssertionError("Classification must not load a basis"); + }, (x, z) -> { + if (calls.incrementAndGet() == 1) { + ProvisionalSampling.mark(); + return 100D; + } + return 40D; + }, (x, z) -> true, 60), IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16)); + assertEquals(IrisHydrologyRoutingTerrainSampler.NaturalClassification.LAND, sampler.classifyNatural(2, 3)); + assertEquals(0, sampler.oceanClassificationCacheSize()); + assertEquals(IrisHydrologyRoutingTerrainSampler.NaturalClassification.OCEAN, sampler.classifyNatural(2, 3)); + assertEquals(1, sampler.oceanClassificationCacheSize()); + } + + @Test + public void bulkHeightProviderReceivesOnlyUniqueUncachedCoordinates() { + ArrayList requested = new ArrayList<>(); + IrisHydrologyNaturalHeightProvider heights = new IrisHydrologyNaturalHeightProvider() { + @Override + public double sample(int x, int z) { + return 100D + Math.floorMod(x + z, 17); + } + + @Override + public boolean supportsBatch() { + return true; + } + + @Override + public void sampleBatch(long[] coordinates, int count, double[] target) { + for (int index = 0; index < count; index++) { + requested.add(coordinates[index]); + target[index] = sample(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index])); + } + } + }; + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources((x, z, height) -> + new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height, + HydrologyTerrainSample.openLand((int) height, 0D, "land")), heights, (x, z) -> false, 60), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(8192)); + sampler.sampleBasisWithoutSlope(-1, 5); + sampler.sampleLandHeight(6, -7); + long[] coordinates = {RiverFootprint.pack(-1, 5), RiverFootprint.pack(6, -7), + RiverFootprint.pack(-9, 19), RiverFootprint.pack(-9, 19), + RiverFootprint.pack(Integer.MAX_VALUE, Integer.MIN_VALUE)}; + for (int repeat = 0; repeat < 2; repeat++) { + HydrologyTerrainSample[] samples = sampler.sampleBasisWithoutSlopeBatch(coordinates, coordinates.length); + for (int index = 0; index < coordinates.length; index++) { + assertEquals((int) heights.sample(RiverFootprint.unpackX(coordinates[index]), + RiverFootprint.unpackZ(coordinates[index])), samples[index].naturalHeight()); + } + } + assertEquals(List.of(coordinates[2], coordinates[4]), requested); + } + + @Test + public void gridUsesBoundedHeightSlices() { + ArrayList sizes = new ArrayList<>(); + IrisHydrologyNaturalHeightProvider heights = new IrisHydrologyNaturalHeightProvider() { + @Override + public double sample(int x, int z) { + throw new AssertionError("Expected bulk height sampling"); + } + + @Override + public boolean supportsBatch() { + return true; + } + + @Override + public void sampleBatch(long[] coordinates, int count, double[] target) { + sizes.add(count); + for (int index = 0; index < count; index++) { + target[index] = 100D; + } + } + }; + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources((x, z, height) -> + new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height, + HydrologyTerrainSample.openLand((int) height, 0D, "land")), heights, (x, z) -> false, 60), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(8192)); + HydrologyTerrainSample[] samples = sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest(-1024, -4096, 65, 8)); + assertEquals(65 * 65, samples.length); + assertEquals(List.of(4096, 260), sizes); + for (HydrologyTerrainSample sample : samples) { + assertEquals(100, sample.naturalHeight()); + assertEquals(0D, sample.slope(), 0D); + } + } + + @Test + public void provisionalBulkAndScalarHeightsNeverBecomeRoutingCacheEntries() { + AtomicInteger calls = new AtomicInteger(); + IrisHydrologyNaturalHeightProvider heights = new IrisHydrologyNaturalHeightProvider() { + @Override + public double sample(int x, int z) { + ProvisionalSampling.mark(); + return 100D + calls.incrementAndGet(); + } + + @Override + public boolean supportsBatch() { + return true; + } + }; + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources((x, z, height) -> + new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height, + HydrologyTerrainSample.openLand((int) height, 0D, "land")), heights, (x, z) -> false, 60), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16)); + long[] coordinates = {RiverFootprint.pack(4, -3)}; + assertEquals(101, sampler.sampleBasisWithoutSlopeBatch(coordinates, 1)[0].naturalHeight()); + assertEquals(102, sampler.sampleBasisWithoutSlopeBatch(coordinates, 1)[0].naturalHeight()); + assertEquals(103, sampler.sampleBasisWithoutSlope(4, -3).naturalHeight()); + assertEquals(104, sampler.sampleBasisWithoutSlope(4, -3).naturalHeight()); + assertEquals(0, sampler.basisCacheSize()); + assertEquals(0, sampler.naturalHeightCacheSize()); + } + @Test public void boundedBasisBatchUsesScopedWorkersAndDrainsNestedPool() throws Exception { ThreadLocal scope = new ThreadLocal<>(); diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorBatchTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorBatchTest.java new file mode 100644 index 000000000..7d984d9da --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorBatchTest.java @@ -0,0 +1,129 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class IrisGeneratorBatchTest { + private static final double[] XS = {-0D, 0D, -12D, -5.75D, 18D, 18D, 8191.25D, -29999999.5D, 2147483647D}; + private static final double[] ZS = {0D, -0D, 6D, 2.5D, -6D, -6D, -4096.75D, 29999998D, -2147483648D}; + + @Test + public void batchPreservesRawBitsAcrossStylesSeedsAndSurfaceDetails() { + NoiseStyle[] styles = {NoiseStyle.SIMPLEX, NoiseStyle.IRIS, NoiseStyle.FRACTAL_SMOKE, + NoiseStyle.CLOVER, NoiseStyle.CELLULAR, NoiseStyle.FLAT}; + long[] seeds = {0L, -1337L, Long.MAX_VALUE - 37L, Long.MIN_VALUE + 23L}; + for (NoiseStyle style : styles) { + for (double detail : new double[]{0D, 0.5D, 1D}) { + for (boolean multiply : new boolean[]{false, true}) { + IrisGenerator scalar = generator(style, detail, multiply); + IrisGenerator batch = generator(style, detail, multiply); + assertTrue(batch.supportsBatch()); + double[] sampled = new double[XS.length]; + for (long seed : seeds) { + batch.getHeights(XS, ZS, seed, sampled, sampled.length); + for (int index = 0; index < sampled.length; index++) { + assertEquals(style + " detail=" + detail + " seed=" + seed + " index=" + index, + Double.doubleToRawLongBits(scalar.getHeight(XS[index], ZS[index], seed)), + Double.doubleToRawLongBits(sampled[index])); + } + } + } + } + } + } + + @Test + public void unroundedCompositeBatchRetainsEveryNoiseBit() { + for (NoiseStyle style : new NoiseStyle[]{NoiseStyle.SIMPLEX, NoiseStyle.IRIS, NoiseStyle.CLOVER}) { + for (double detail : new double[]{0D, 0.5D, 1D}) { + IrisGenerator scalar = generator(style, detail, false).setCliffHeightMax(0D).setCellFractureHeight(0D); + IrisGenerator batch = generator(style, detail, false).setCliffHeightMax(0D).setCellFractureHeight(0D); + double[] values = new double[XS.length]; + for (long seed : new long[]{-1337L, Long.MAX_VALUE - 37L}) { + batch.getHeights(XS, ZS, seed, values, XS.length); + for (int index = 0; index < XS.length; index++) { + assertEquals(Double.doubleToRawLongBits(scalar.getHeight(XS[index], ZS[index], seed)), + Double.doubleToRawLongBits(values[index])); + } + } + } + } + } + + @Test + public void resolvesLayerContextOncePerBatch() { + IrisNoiseGenerator layer = spy(new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style())); + IrisGenerator generator = new IrisGenerator().setComposite(new KList<>(layer)); + double[] sampled = new double[XS.length]; + generator.getHeights(XS, ZS, 99L, sampled, XS.length); + verify(layer, times(1)).getGenerator(anyLong(), isNull(), isNull()); + clearInvocations(layer); + for (int index = 0; index < XS.length; index++) { + generator.getHeight(XS[index], ZS[index], 99L); + } + verify(layer, times(XS.length)).getGenerator(anyLong(), isNull(), isNull()); + } + + @Test + public void customGeneratorFallbackKeepsCoordinateOrder() { + List sampled = new ArrayList<>(); + IrisGenerator generator = new IrisGenerator() { + @Override + public double getHeight(double x, double z, long seed) { + sampled.add(x); + return sampled.size(); + } + }; + assertFalse(generator.supportsBatch()); + double[] values = new double[XS.length]; + generator.getHeights(XS, ZS, 0L, values, values.length); + for (int index = 0; index < values.length; index++) { + assertEquals(Double.valueOf(XS[index]), sampled.get(index)); + assertEquals(index + 1D, values[index], 0D); + } + assertThrows(IllegalArgumentException.class, + () -> generator.getHeights(new double[4097], new double[4097], 0L, new double[4097], 4097)); + } + + @Test + public void expressionsAndCustomStylesKeepScalarSampling() { + assertFalse(new IrisGenerator().setComposite(new KList<>(new IrisNoiseGenerator() + .setStyle(new IrisGeneratorStyle().setExpression("height")))).supportsBatch()); + assertFalse(new IrisGenerator().setComposite(new KList<>(new IrisNoiseGenerator() + .setStyle(new IrisGeneratorStyle() {}))).supportsBatch()); + } + + private static IrisGenerator generator(NoiseStyle style, double detail, boolean multiply) { + IrisNoiseGenerator layer = new IrisNoiseGenerator().setStyle(style.style()).setSeed(-831L) + .setZoom(0.73D).setOffsetX(3.25D).setOffsetZ(-1.75D).setOpacity(0.7D) + .setNegative(true).setExponent(1.37D).setOffsetY(0.03D).setParametric(true); + IrisNoiseGenerator node = layer; + for (int depth = 0; depth < 5; depth++) { + IrisNoiseGenerator child = new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style()) + .setSeed(depth * 7L).setOpacity(3.1D).setZoom(1.7D); + node.setFracture(new KList<>(child)); + node = child; + } + return new IrisGenerator().setComposite(new KList<>(layer, + new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style()).setOpacity(0.2D))) + .setSurfaceDetail(detail).setMultiplicitive(multiply).setSeed(-93L).setZoom(1.3D) + .setOffsetX(7.25D).setOffsetZ(-9.125D).setOpacity(0.93D) + .setCliffHeightMin(3.5D).setCliffHeightMax(12.25D) + .setCliffHeightGenerator(new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style())) + .setCellFractureHeight(0.25D); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightBatchTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightBatchTest.java new file mode 100644 index 000000000..21be1be28 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightBatchTest.java @@ -0,0 +1,78 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.IrisNoiseGenerator; +import art.arcane.iris.generation.noise.IrisShapedGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisComplexHeightBatchTest { + @Test + public void standardSurfaceGraphsCanBatchAndCoarseSamplingKeepsScalarPath() throws Exception { + IrisDimension dimension = new IrisDimension(); + IrisGenerator generator = new IrisGenerator().setSurfaceDetail(0.5D) + .setComposite(new KList<>(new IrisNoiseGenerator().setStyle(NoiseStyle.FRACTAL_SMOKE.style()) + .setFracture(new KList<>(new IrisNoiseGenerator().setStyle(NoiseStyle.IRIS.style()))))); + IrisComplex complex = complex(dimension, generator); + assertTrue(eligible(complex)); + for (int step : new int[]{2, 4, 8}) { + dimension.setTerrainSamplingStep(step); + assertFalse(eligible(complex)); + } + dimension.setTerrainSamplingStep(1); + assertTrue(eligible(complex)); + generator.getComposite().getFirst().setStyle(new IrisGeneratorStyle().setExpression("height")); + assertFalse(eligible(complex)); + } + + @Test + public void statefulBiomeAndOverlaySourcesKeepScalarPath() throws Exception { + IrisDimension dimension = new IrisDimension(); + IrisComplex complex = complex(dimension, new IrisGenerator() + .setComposite(new KList<>(new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style())))); + set(complex, "reuseNaturalBaseBiome", false); + assertFalse(eligible(complex)); + set(complex, "reuseNaturalBaseBiome", true); + dimension.setOverlayNoise(new KList<>(new IrisShapedGeneratorStyle() + .setGenerator(new IrisGeneratorStyle() {}))); + assertFalse(eligible(complex)); + dimension.getOverlayNoise().getFirst().setGenerator(NoiseStyle.SIMPLEX.style()); + assertTrue(eligible(complex)); + } + + private static IrisComplex complex(IrisDimension dimension, IrisGenerator generator) throws Exception { + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + set(complex, "terrainEngine", engine); + set(complex, "biomeBuffetGenerators", Map.of()); + set(complex, "reuseNaturalBaseBiome", true); + set(complex, "frozenGenerators", new IrisGenerator[][]{{generator}}); + return complex; + } + + private static boolean eligible(IrisComplex complex) throws Exception { + Method method = IrisComplex.class.getDeclaredMethod("supportsHeightBatch"); + method.setAccessible(true); + return (boolean) method.invoke(complex); + } + + private static void set(IrisComplex complex, String name, Object value) throws Exception { + Field field = IrisComplex.class.getDeclaredField(name); + field.setAccessible(true); + field.set(complex, value); + } +}