diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyBasisCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyBasisCache.java index 5cbc7e2aa..5a283a0be 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyBasisCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyBasisCache.java @@ -1,8 +1,15 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.volmlib.util.cache.CacheKey; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.RejectedExecutionException; final class HydrologyBasisCache { private static final int MAXIMUM_ENTRIES = 32_768; @@ -39,23 +46,74 @@ final class HydrologyBasisCache { seed(samples, full); } - boolean findWithoutSlope(long packed, HydrologyTerrainSample[] samples, int index) { - Stripe stripe = stripe(withoutSlope, packed); - synchronized (stripe) { - samples[index] = stripe.samples.getAndMoveToFirst(packed); - return samples[index] != null || stripe.samples.containsKey(packed); + HydrologyTerrainSample[] sampleWithoutSlopeBatch(long[] coordinates, int count, + HydrologyNaturalTerrainSampler sampler) { + Objects.checkFromIndexSize(0, count, coordinates.length); + if (count > HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE) { + throw new IllegalArgumentException("Terrain basis batch exceeds its sample limit"); } - } - - HydrologyTerrainSample rememberWithoutSlope(long packed, HydrologyTerrainSample sample) { - Stripe stripe = stripe(withoutSlope, packed); - synchronized (stripe) { - if (stripe.samples.containsKey(packed)) { - return stripe.samples.getAndMoveToFirst(packed); + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count]; + Pending[] pending = new Pending[count]; + long[] ownedCoordinates = new long[count]; + Pending[] owned = new Pending[count]; + int ownedCount = count; + Completion completion = new Completion(); + int[] next = new int[count]; + int[] heads = groupByStripe(coordinates, count, next); + try { + for (int stripeIndex = 0; stripeIndex < withoutSlope.length; stripeIndex++) { + if (heads[stripeIndex] == 0) { + continue; + } + Stripe stripe = withoutSlope[stripeIndex]; + synchronized (stripe) { + for (int position = heads[stripeIndex]; position != 0; position = next[position - 1]) { + int index = position - 1; + long packed = coordinates[index]; + HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed); + if (cached != null || stripe.samples.containsKey(packed)) { + samples[index] = cached; + continue; + } + Pending loading = stripe.pending.get(packed); + if (loading == null) { + loading = new Pending(completion); + stripe.pending.put(packed, loading); + ownedCoordinates[index] = packed; + owned[index] = loading; + } + pending[index] = loading; + } + } } - stripe.put(packed, sample); - return sample; + int claimed = 0; + for (int index = 0; index < count; index++) { + if (owned[index] != null) { + ownedCoordinates[claimed] = ownedCoordinates[index]; + owned[claimed++] = owned[index]; + } + } + ownedCount = claimed; + if (ownedCount > 0) { + loadBatch(ownedCoordinates, owned, ownedCount, sampler); + } + } catch (RuntimeException | Error failure) { + for (int index = 0; index < ownedCount; index++) { + if (owned[index] != null) { + fail(withoutSlope, ownedCoordinates[index], owned[index], failure); + } + } + completion.failure = failure; + throw failure; + } finally { + completion.complete.countDown(); } + for (int index = 0; index < count; index++) { + if (pending[index] != null) { + samples[index] = pending[index].await(); + } + } + return samples; } void seedWithoutSlope(Long2ObjectMap samples) { @@ -64,15 +122,104 @@ final class HydrologyBasisCache { private HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler, boolean omitSlope) { long packed = RiverFootprint.pack(x, z); - Stripe stripe = stripe(omitSlope ? withoutSlope : full, packed); + Stripe[] stripes = omitSlope ? withoutSlope : full; + Stripe stripe = stripe(stripes, packed); + Pending loading; + boolean owner = false; synchronized (stripe) { HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed); if (cached != null || stripe.samples.containsKey(packed)) { return cached; } - HydrologyTerrainSample sampled = omitSlope ? sampler.sampleBasisWithoutSlope(x, z) : sampler.sampleBasis(x, z); - stripe.put(packed, sampled); - return sampled; + loading = stripe.pending.get(packed); + if (loading == null) { + loading = new Pending(new Completion()); + stripe.pending.put(packed, loading); + owner = true; + } + } + if (owner) { + try { + HydrologyTerrainSample sampled = ProvisionalSampling.memoizable(() -> omitSlope + ? sampler.sampleBasisWithoutSlope(x, z) : sampler.sampleBasis(x, z)); + complete(stripes, packed, loading, sampled, false); + } catch (ProvisionalSampling.Unmemoizable provisional) { + complete(stripes, packed, loading, provisional.value(), true); + } catch (RuntimeException | Error failure) { + fail(stripes, packed, loading, failure); + throw failure; + } finally { + loading.completion.complete.countDown(); + } + } + return loading.await(); + } + + private void loadBatch(long[] coordinates, Pending[] pending, int count, HydrologyNaturalTerrainSampler sampler) { + HydrologyTerrainSample[] samples; + boolean provisional = false; + try { + samples = ProvisionalSampling.memoizable(() -> sampler.sampleBasisWithoutSlopeBatch(coordinates, count)); + } catch (ProvisionalSampling.Unmemoizable value) { + samples = value.value(); + provisional = true; + } + if (samples.length < count) { + throw new IllegalStateException("Terrain basis batch returned too few samples"); + } + int[] next = new int[count]; + int[] heads = groupByStripe(coordinates, count, next); + for (int stripeIndex = 0; stripeIndex < withoutSlope.length; stripeIndex++) { + if (heads[stripeIndex] == 0) { + continue; + } + Stripe stripe = withoutSlope[stripeIndex]; + synchronized (stripe) { + for (int position = heads[stripeIndex]; position != 0; position = next[position - 1]) { + int index = position - 1; + complete(stripe, coordinates[index], pending[index], samples[index], provisional); + } + } + } + } + + private int[] groupByStripe(long[] coordinates, int count, int[] next) { + int[] heads = new int[withoutSlope.length]; + for (int index = count - 1; index >= 0; index--) { + int stripeIndex = (int) CacheKey.mix(coordinates[index]) & (withoutSlope.length - 1); + next[index] = heads[stripeIndex]; + heads[stripeIndex] = index + 1; + } + return heads; + } + + private void complete(Stripe[] stripes, long packed, Pending pending, HydrologyTerrainSample sample, + boolean provisional) { + Stripe stripe = stripe(stripes, packed); + synchronized (stripe) { + complete(stripe, packed, pending, sample, provisional); + } + } + + private void complete(Stripe stripe, long packed, Pending pending, HydrologyTerrainSample sample, + boolean provisional) { + if (stripe.samples.containsKey(packed)) { + sample = stripe.samples.getAndMoveToFirst(packed); + provisional = false; + } else if (!provisional) { + stripe.put(packed, sample); + } + stripe.pending.remove(packed); + pending.sample = sample; + pending.provisional = provisional; + } + + private void fail(Stripe[] stripes, long packed, Pending pending, Throwable failure) { + Stripe stripe = stripe(stripes, packed); + synchronized (stripe) { + if (stripe.pending.remove(packed, pending)) { + pending.completion.failure = failure; + } } } @@ -92,6 +239,7 @@ final class HydrologyBasisCache { private static final class Stripe { private final int maximumEntries; private final Long2ObjectLinkedOpenHashMap samples; + private final Long2ObjectOpenHashMap pending = new Long2ObjectOpenHashMap<>(); private Stripe(int maximumEntries) { this.maximumEntries = maximumEntries; @@ -105,4 +253,73 @@ final class HydrologyBasisCache { samples.putAndMoveToFirst(packed, value); } } + + private static final class Pending { + private final Completion completion; + private HydrologyTerrainSample sample; + private boolean provisional; + + private Pending(Completion completion) { + this.completion = completion; + } + + private HydrologyTerrainSample await() { + completion.await(); + if (provisional) { + ProvisionalSampling.mark(); + } + return sample; + } + } + + private static final class Completion implements ForkJoinPool.ManagedBlocker { + private final Thread owner = Thread.currentThread(); + private final CountDownLatch complete = new CountDownLatch(1); + private Throwable failure; + + private void await() { + if (!isReleasable() && owner == Thread.currentThread()) { + throw new IllegalStateException("Recursive terrain basis sampling"); + } + boolean interrupted = false; + boolean managed = true; + try { + while (!isReleasable()) { + try { + if (managed) { + ForkJoinPool.managedBlock(this); + } else { + complete.await(); + } + } catch (InterruptedException interruption) { + interrupted = true; + managed = false; + } catch (RejectedExecutionException unavailable) { + managed = false; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof RuntimeException runtime) { + throw runtime; + } + } + + @Override + public boolean block() throws InterruptedException { + complete.await(); + return true; + } + + @Override + public boolean isReleasable() { + return complete.getCount() == 0; + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java index e3ef3caec..ba9047cfa 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java @@ -946,8 +946,6 @@ final class HydrologyFootprintCompiler { HydrologyTerrainSample cached = terrainBases.get(key); if (cached != null) { samples[index] = cached; - } else if (sharedBasis != null && sharedBasis.findWithoutSlope(key, samples, index)) { - continue; } else if (naturalSampler == null) { samples[index] = sampleTerrainBasis(RiverFootprint.unpackX(key), RiverFootprint.unpackZ(key)); } else { @@ -956,10 +954,11 @@ final class HydrologyFootprintCompiler { } } if (missingCount > 0) { - HydrologyTerrainSample[] loaded = naturalSampler.sampleBasisWithoutSlopeBatch(missing, missingCount); + HydrologyTerrainSample[] loaded = sharedBasis == null + ? naturalSampler.sampleBasisWithoutSlopeBatch(missing, missingCount) + : sharedBasis.sampleWithoutSlopeBatch(missing, missingCount, naturalSampler); for (int index = 0; index < missingCount; index++) { - samples[positions[index]] = sharedBasis == null ? loaded[index] - : sharedBasis.rememberWithoutSlope(missing[index], loaded[index]); + samples[positions[index]] = loaded[index]; } } for (int index = 0; index < count; index++) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyForkJoin.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyForkJoin.java index 533fb7845..b26c1148b 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyForkJoin.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyForkJoin.java @@ -1,5 +1,7 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.stream.ProvisionalSampling; + import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -91,6 +93,7 @@ public final class HydrologyForkJoin { private final CountDownLatch complete = new CountDownLatch(1); private T result; private Throwable failure; + private boolean provisional; Task(Callable work) { this.work = Objects.requireNonNull(work); @@ -136,6 +139,9 @@ public final class HydrologyForkJoin { if (failure != null) { throw unwrap(failure); } + if (provisional) { + ProvisionalSampling.mark(); + } return result; } @@ -156,7 +162,10 @@ public final class HydrologyForkJoin { return; } try { - result = call(work); + result = ProvisionalSampling.memoizable(() -> call(work)); + } catch (ProvisionalSampling.Unmemoizable value) { + result = value.value(); + provisional = true; } catch (RuntimeException | Error failed) { failure = failed; } finally { 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/HydrologyBasisCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyBasisCacheTest.java index 1d0c01696..892e36f8b 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyBasisCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyBasisCacheTest.java @@ -1,37 +1,385 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.stream.ProvisionalSampling; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import org.junit.Test; import java.util.ArrayList; +import java.util.Arrays; 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.ForkJoinPool; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class HydrologyBasisCacheTest { @Test - public void batchLookupPreservesNullAndFirstPublishedSample() { + public void batchLookupPreservesNullDuplicatesAndEviction() { + CountingSampler sampler = new CountingSampler(); HydrologyBasisCache cache = new HydrologyBasisCache(1); - HydrologyTerrainSample[] samples = new HydrologyTerrainSample[1]; - long key = RiverFootprint.pack(-17, Integer.MAX_VALUE); - assertTrue(!cache.findWithoutSlope(key, samples, 0)); - assertNull(cache.rememberWithoutSlope(key, null)); - assertTrue(cache.findWithoutSlope(key, samples, 0)); - assertNull(samples[0]); - HydrologyTerrainSample later = HydrologyTerrainSample.openLand(73, 0D, "land"); - assertNull(cache.rememberWithoutSlope(key, later)); - cache.rememberWithoutSlope(key + 1, later); - assertTrue(!cache.findWithoutSlope(key, samples, 0)); - assertEquals(later, cache.rememberWithoutSlope(key + 1, null)); + long key = RiverFootprint.pack(Integer.MIN_VALUE, Integer.MAX_VALUE); + assertArrayEquals(new HydrologyTerrainSample[2], + cache.sampleWithoutSlopeBatch(new long[]{key, key}, 2, sampler)); + assertNull(cache.sampleWithoutSlope(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler)); + assertEquals(1, sampler.slopeFreeCalls.get()); + long later = RiverFootprint.pack(73, -17); + assertEquals(87, cache.sampleWithoutSlopeBatch(new long[]{later}, 1, sampler)[0].naturalHeight()); + assertNull(cache.sampleWithoutSlopeBatch(new long[]{key}, 1, sampler)[0]); + assertEquals(3, sampler.slopeFreeCalls.get()); + } + + @Test + public void batchOwnershipKeepsProviderOrderAcrossStripes() { + HydrologyBasisCache cache = new HydrologyBasisCache(); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + assertArrayEquals(new long[]{RiverFootprint.pack(7, 0), RiverFootprint.pack(-9, 0), + RiverFootprint.pack(-17, 0)}, Arrays.copyOf(coordinates, count)); + return super.sampleBasisWithoutSlopeBatch(coordinates, count); + } + + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) { + return HydrologyTerrainSample.openLand(63 + x, 0D, "land"); + } + }; + HydrologyTerrainSample seeded = cache.sampleWithoutSlope(4, 0, sampler); + HydrologyTerrainSample[] samples = cache.sampleWithoutSlopeBatch(new long[]{ + RiverFootprint.pack(7, 0), RiverFootprint.pack(-9, 0), RiverFootprint.pack(7, 0), + RiverFootprint.pack(4, 0), RiverFootprint.pack(-17, 0), RiverFootprint.pack(4, 0)}, 6, sampler); + assertEquals(70, samples[0].naturalHeight()); + assertEquals(54, samples[1].naturalHeight()); + assertSame(samples[0], samples[2]); + assertSame(seeded, samples[3]); + assertEquals(46, samples[4].naturalHeight()); + assertSame(seeded, samples[5]); + } + + @Test(timeout = 10_000) + public void overlappingBatchesDispatchOnlyTheirOwnedCoordinates() throws Exception { + HydrologyBasisCache cache = new HydrologyBasisCache(); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger batchCalls = new AtomicInteger(); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + if (batchCalls.getAndIncrement() == 0) { + assertEquals(2, count); + firstStarted.countDown(); + } else { + assertEquals(1, count); + assertEquals(RiverFootprint.pack(3, 0), coordinates[0]); + secondStarted.countDown(); + } + await(release); + return super.sampleBasisWithoutSlopeBatch(coordinates, count); + } + }; + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future first = workers.submit(() -> cache.sampleWithoutSlopeBatch( + new long[]{RiverFootprint.pack(1, 0), RiverFootprint.pack(2, 0)}, 2, sampler)); + assertTrue(firstStarted.await(5, TimeUnit.SECONDS)); + Future second = workers.submit(() -> cache.sampleWithoutSlopeBatch( + new long[]{RiverFootprint.pack(2, 0), RiverFootprint.pack(3, 0)}, 2, sampler)); + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + release.countDown(); + assertSame(first.get(5, TimeUnit.SECONDS)[1], second.get(5, TimeUnit.SECONDS)[0]); + assertEquals(3, sampler.slopeFreeCalls.get()); + assertEquals(2, batchCalls.get()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 10_000) + public void pointAndBatchSharePendingSamplesWithoutHoldingStripeLocks() throws Exception { + HydrologyBasisCache cache = new HydrologyBasisCache(1); + CountDownLatch pointStarted = new CountDownLatch(1); + CountDownLatch batchStarted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) { + if (x == 1) { + pointStarted.countDown(); + await(release); + } + return super.sampleBasisWithoutSlope(x, z); + } + + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + assertEquals(1, count); + assertEquals(RiverFootprint.pack(2, 0), coordinates[0]); + batchStarted.countDown(); + return super.sampleBasisWithoutSlopeBatch(coordinates, count); + } + }; + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future point = workers.submit(() -> cache.sampleWithoutSlope(1, 0, sampler)); + assertTrue(pointStarted.await(5, TimeUnit.SECONDS)); + Future batch = workers.submit(() -> cache.sampleWithoutSlopeBatch( + new long[]{RiverFootprint.pack(1, 0), RiverFootprint.pack(2, 0)}, 2, sampler)); + assertTrue(batchStarted.await(5, TimeUnit.SECONDS)); + release.countDown(); + assertSame(point.get(5, TimeUnit.SECONDS), batch.get(5, TimeUnit.SECONDS)[0]); + assertEquals(2, sampler.slopeFreeCalls.get()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 10_000) + public void nestedBatchesFinishOnSingleWorkerPool() throws Exception { + HydrologyBasisCache cache = new HydrologyBasisCache(); + ForkJoinPool workers = new ForkJoinPool(1); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + List values = HydrologyForkJoin.invokeAll(List.of( + () -> sampleBasisWithoutSlope(RiverFootprint.unpackX(coordinates[0]), 0), + () -> sampleBasisWithoutSlope(RiverFootprint.unpackX(coordinates[1]), 0)), workers); + return values.toArray(HydrologyTerrainSample[]::new); + } + }; + try { + workers.submit(() -> HydrologyForkJoin.invokeAll(List.of( + () -> cache.sampleWithoutSlopeBatch(new long[]{RiverFootprint.pack(1, 0), RiverFootprint.pack(2, 0)}, 2, sampler), + () -> cache.sampleWithoutSlopeBatch(new long[]{RiverFootprint.pack(2, 0), RiverFootprint.pack(1, 0)}, 2, sampler)), workers)) + .get(5, TimeUnit.SECONDS); + assertEquals(2, sampler.slopeFreeCalls.get()); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void failedMalformedAndRecursiveBatchesReleaseEveryReservation() { + HydrologyBasisCache cache = new HydrologyBasisCache(); + long[] coordinates = {RiverFootprint.pack(1, 0), RiverFootprint.pack(2, 0)}; + CountingSampler failed = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] keys, int count) { + throw new IllegalArgumentException("failure"); + } + }; + assertThrows(IllegalArgumentException.class, () -> cache.sampleWithoutSlopeBatch(coordinates, 2, failed)); + CountingSampler malformed = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] keys, int count) { + return new HydrologyTerrainSample[1]; + } + }; + assertThrows(IllegalStateException.class, () -> cache.sampleWithoutSlopeBatch(coordinates, 2, malformed)); + CountingSampler absent = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] keys, int count) { + return null; + } + }; + assertThrows(NullPointerException.class, () -> cache.sampleWithoutSlopeBatch(coordinates, 2, absent)); + CountingSampler recursive = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] keys, int count) { + cache.sampleWithoutSlope(2, 0, this); + throw new AssertionError("Recursive sampling must not wait on itself"); + } + }; + assertThrows(IllegalStateException.class, () -> cache.sampleWithoutSlopeBatch(coordinates, 2, recursive)); + CountingSampler successful = new CountingSampler(); + assertEquals(2, cache.sampleWithoutSlopeBatch(coordinates, 2, successful).length); + assertEquals(2, successful.slopeFreeCalls.get()); + } + + @Test(timeout = 10_000) + public void interruptedPointWaiterReceivesBatchResultAndRestoresInterrupt() throws Exception { + HydrologyBasisCache cache = new HydrologyBasisCache(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference waiting = new AtomicReference<>(); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + started.countDown(); + await(release); + return super.sampleBasisWithoutSlopeBatch(coordinates, count); + } + }; + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future batch = workers.submit(() -> cache.sampleWithoutSlopeBatch( + new long[]{RiverFootprint.pack(1, 0)}, 1, sampler)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Future point = workers.submit(() -> { + waiting.set(Thread.currentThread()); + HydrologyTerrainSample sample = cache.sampleWithoutSlope(1, 0, sampler); + assertTrue(Thread.interrupted()); + return sample; + }); + awaitWaiting(waiting); + waiting.get().interrupt(); + release.countDown(); + assertSame(batch.get(5, TimeUnit.SECONDS)[0], point.get(5, TimeUnit.SECONDS)); + assertEquals(1, sampler.slopeFreeCalls.get()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 10_000) + public void batchFailureReachesPointWaiterAndAllowsAllCoordinatesToRetry() throws Exception { + HydrologyBasisCache cache = new HydrologyBasisCache(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference waiting = new AtomicReference<>(); + IllegalArgumentException failure = new IllegalArgumentException("retry"); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + started.countDown(); + await(release); + throw failure; + } + }; + long[] coordinates = {RiverFootprint.pack(1, 0), RiverFootprint.pack(2, 0)}; + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future batch = workers.submit(() -> assertThrows(IllegalArgumentException.class, + () -> cache.sampleWithoutSlopeBatch(coordinates, 2, sampler))); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Future point = workers.submit(() -> { + waiting.set(Thread.currentThread()); + return assertThrows(IllegalArgumentException.class, () -> cache.sampleWithoutSlope(2, 0, sampler)); + }); + awaitWaiting(waiting); + release.countDown(); + assertSame(failure, batch.get(5, TimeUnit.SECONDS)); + assertSame(failure, point.get(5, TimeUnit.SECONDS)); + CountingSampler successful = new CountingSampler(); + assertEquals(2, cache.sampleWithoutSlopeBatch(coordinates, 2, successful).length); + assertEquals(2, successful.slopeFreeCalls.get()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void provisionalResultsAreNotRetained() { + HydrologyBasisCache cache = new HydrologyBasisCache(); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) { + ProvisionalSampling.mark(); + return super.sampleBasisWithoutSlope(x, z); + } + }; + long[] coordinates = {RiverFootprint.pack(1, 0), RiverFootprint.pack(2, 0)}; + cache.sampleWithoutSlopeBatch(coordinates, 2, sampler); + cache.sampleWithoutSlopeBatch(coordinates, 2, sampler); + cache.sampleWithoutSlope(1, 0, sampler); + cache.sampleWithoutSlope(1, 0, sampler); + assertEquals(6, sampler.slopeFreeCalls.get()); + } + + @Test(timeout = 10_000) + public void provisionalWorkerResultMarksSharedCacheWaitersAndIsNotRetained() throws Exception { + HydrologyBasisCache cache = new HydrologyBasisCache(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference waiting = new AtomicReference<>(); + ExecutorService providerWorkers = Executors.newFixedThreadPool(2); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + List values = HydrologyForkJoin.invokeAll(List.of(() -> { + started.countDown(); + await(release); + ProvisionalSampling.mark(); + return sampleBasisWithoutSlope(RiverFootprint.unpackX(coordinates[0]), 0); + }, () -> sampleBasisWithoutSlope(RiverFootprint.unpackX(coordinates[1]), 0)), providerWorkers); + return values.toArray(HydrologyTerrainSample[]::new); + } + }; + ExecutorService callers = Executors.newFixedThreadPool(2); + long[] coordinates = {RiverFootprint.pack(1, 0), RiverFootprint.pack(2, 0)}; + try { + Future batch = callers.submit(() -> cache.sampleWithoutSlopeBatch(coordinates, 2, sampler)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Future point = callers.submit(() -> { + waiting.set(Thread.currentThread()); + return assertThrows(ProvisionalSampling.Unmemoizable.class, + () -> ProvisionalSampling.memoizable(() -> cache.sampleWithoutSlope(1, 0, sampler))); + }); + awaitWaiting(waiting); + release.countDown(); + assertSame(batch.get(5, TimeUnit.SECONDS)[0], point.get(5, TimeUnit.SECONDS).value()); + cache.sampleWithoutSlopeBatch(coordinates, 2, sampler); + assertEquals(4, sampler.slopeFreeCalls.get()); + } finally { + release.countDown(); + callers.shutdownNow(); + providerWorkers.shutdownNow(); + assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(providerWorkers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 10_000) + public void seedWinsPendingSample() throws Exception { + HydrologyBasisCache cache = new HydrologyBasisCache(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountingSampler sampler = new CountingSampler() { + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) { + started.countDown(); + await(release); + return super.sampleBasisWithoutSlope(x, z); + } + }; + ExecutorService workers = Executors.newSingleThreadExecutor(); + try { + Future result = workers.submit(() -> cache.sampleWithoutSlopeBatch( + new long[]{RiverFootprint.pack(1, 0)}, 1, sampler)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + HydrologyTerrainSample seeded = HydrologyTerrainSample.openLand(73, 0D, "seed"); + Long2ObjectOpenHashMap seeds = new Long2ObjectOpenHashMap<>(); + seeds.put(RiverFootprint.pack(1, 0), seeded); + cache.seedWithoutSlope(seeds); + release.countDown(); + assertSame(seeded, result.get(5, TimeUnit.SECONDS)[0]); + assertSame(seeded, cache.sampleWithoutSlope(1, 0, sampler)); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } } @Test @@ -122,7 +470,28 @@ public class HydrologyBasisCacheTest { } } - private static final class CountingSampler implements HydrologyNaturalTerrainSampler { + private static void awaitWaiting(AtomicReference thread) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + Thread current = thread.get(); + if (current != null && current.getState() == Thread.State.WAITING) { + return; + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + throw new AssertionError("Cache waiter did not wait for its owner"); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new AssertionError(interruption); + } + } + + private static class CountingSampler implements HydrologyNaturalTerrainSampler { private final AtomicInteger fullCalls = new AtomicInteger(); private final AtomicInteger slopeFreeCalls = new AtomicInteger(); private final AtomicInteger failures = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyForkJoinTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyForkJoinTest.java index 77a6e3c19..610889807 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyForkJoinTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyForkJoinTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.stream.ProvisionalSampling; import org.junit.Test; import java.util.ArrayList; @@ -23,6 +24,44 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class HydrologyForkJoinTest { + @Test(timeout = 10_000) + public void workerProvisionalAnswersReachEveryAwaitingCaller() throws Exception { + HydrologyForkJoin.Task task = new HydrologyForkJoin.Task<>(() -> { + ProvisionalSampling.mark(); + return 73; + }); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + workers.submit(task).get(5, TimeUnit.SECONDS); + for (int attempt = 0; attempt < 2; attempt++) { + ProvisionalSampling.Unmemoizable result = workers.submit(() -> assertThrows( + ProvisionalSampling.Unmemoizable.class, + () -> ProvisionalSampling.memoizable(task::await))).get(5, TimeUnit.SECONDS); + assertEquals(Integer.valueOf(73), result.value()); + } + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 10_000) + public void provisionalWorkerMarksTheBatchCaller() { + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + ProvisionalSampling.Unmemoizable result = assertThrows(ProvisionalSampling.Unmemoizable.class, + () -> ProvisionalSampling.memoizable(() -> HydrologyForkJoin.invokeAll(List.of( + () -> 1, + () -> { + ProvisionalSampling.mark(); + return 2; + }), workers))); + assertEquals(List.of(1, 2), result.value()); + } finally { + workers.shutdownNow(); + } + } + @Test public void blockedGridBatchNeverHelpsUnrelatedOwnerWork() throws Exception { ForkJoinPool pool = new ForkJoinPool(2, ForkJoinPool.defaultForkJoinWorkerThreadFactory, diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStoreTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStoreTest.java index 611393ee6..135f8acd7 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStoreTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStoreTest.java @@ -279,11 +279,22 @@ public class PreparedHydrologyTileStoreTest { HydrologyTile original = tile(); PreparedHydrologyTileStore store = store(); store.save(original); - HydrologyTile oversized = new HydrologyTile(original.key(), original.worldSeed(), - original.settingsFingerprint(), original.tileSize(), original.nodes(), original.edges(), - original.outlets(), original.courses(), original.regionalCourseIds(), original.cavePlans(), - Collections.nCopies(600_000, original.localDiagnosticCandidates().getFirst()), original.footprint()); - assertThrows(IOException.class, () -> store.save(oversized)); + long allocationLimit = Math.max(16L * 1024L * 1024L, + Math.min(512L * 1024L * 1024L, Runtime.getRuntime().maxMemory() / 8L)); + int pointCount = 4096; + int courseCount = Math.toIntExact(allocationLimit / (256L * pointCount) + 1L); + HydraulicSegment segment = new HydraulicSegment(45L, 44L, HydrologyFeatureType.SURFACE_POOL, + 72, 72, 4, 2, false, false, Collections.nCopies(pointCount, new HydrologyPoint(0, 72, 0)), + HydraulicChannelProfile.uniform(4D, 2D)); + RiverCourse course = new RiverCourse(44L, RiverCourseType.SURFACE, OptionalLong.of(1L), + OptionalLong.of(2L), "default", 1, List.of(), List.of(segment)); + HydrologyOwnerDraft draft = new HydrologyOwnerDraft(original.key(), + new HydrologyCaveCourseFilter.Result(original.nodes(), original.edges(), original.outlets(), + original.courses(), original.cavePlans()), original.localDiagnosticCandidates(), null); + HydrologyTile oversized = original.withResolvedOwner(new CrossTileResolvedOwner(draft, + Collections.nCopies(courseCount, new CrossTileRejectedCourse(course, 4L)))); + IOException failure = assertThrows(IOException.class, () -> store.save(oversized)); + assertEquals("Prepared hydrology tile exceeds its allocation budget.", failure.getMessage()); assertEquals(original, store.load(original.key()).orElseThrow()); try (Stream files = Files.list(store.file(original.key()).getParent())) { assertEquals(List.of(store.file(original.key())), files.toList()); 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); + } +}