Merged terrain sampling

- Concurrent fallback queries share pending terrain samples
- Pure height generators reuse noise contexts across batches
- Storage allocation tests account for the configured heap budget
This commit is contained in:
Brian Neumann-Fopiano
2026-10-13 15:20:40 -04:00
18 changed files with 1506 additions and 75 deletions
@@ -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<HydrologyTerrainSample> 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<HydrologyTerrainSample> samples;
private final Long2ObjectOpenHashMap<Pending> 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;
}
}
}
@@ -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++) {
@@ -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<T> 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 {
@@ -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]));
}
}
}
@@ -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<NativeGenerationScope> 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<Callable<Void>> 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;
@@ -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()
);
@@ -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;
}
@@ -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<IrisNoiseGenerator> getAllComposites() {
KList<IrisNoiseGenerator> g = new KList<>();
@@ -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);
@@ -50,6 +50,20 @@ public class CachedDoubleStream2D extends BasicStream<Double> 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();
@@ -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++;
@@ -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);
@@ -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<HydrologyTerrainSample[]> first = workers.submit(() -> cache.sampleWithoutSlopeBatch(
new long[]{RiverFootprint.pack(1, 0), RiverFootprint.pack(2, 0)}, 2, sampler));
assertTrue(firstStarted.await(5, TimeUnit.SECONDS));
Future<HydrologyTerrainSample[]> 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<HydrologyTerrainSample> point = workers.submit(() -> cache.sampleWithoutSlope(1, 0, sampler));
assertTrue(pointStarted.await(5, TimeUnit.SECONDS));
Future<HydrologyTerrainSample[]> 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<HydrologyTerrainSample> 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<Thread> 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<HydrologyTerrainSample[]> batch = workers.submit(() -> cache.sampleWithoutSlopeBatch(
new long[]{RiverFootprint.pack(1, 0)}, 1, sampler));
assertTrue(started.await(5, TimeUnit.SECONDS));
Future<HydrologyTerrainSample> 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<Thread> 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<IllegalArgumentException> batch = workers.submit(() -> assertThrows(IllegalArgumentException.class,
() -> cache.sampleWithoutSlopeBatch(coordinates, 2, sampler)));
assertTrue(started.await(5, TimeUnit.SECONDS));
Future<IllegalArgumentException> 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<Thread> waiting = new AtomicReference<>();
ExecutorService providerWorkers = Executors.newFixedThreadPool(2);
CountingSampler sampler = new CountingSampler() {
@Override
public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) {
List<HydrologyTerrainSample> 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<HydrologyTerrainSample[]> batch = callers.submit(() -> cache.sampleWithoutSlopeBatch(coordinates, 2, sampler));
assertTrue(started.await(5, TimeUnit.SECONDS));
Future<ProvisionalSampling.Unmemoizable> 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<HydrologyTerrainSample[]> 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<HydrologyTerrainSample> 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> 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();
@@ -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<Integer> 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,
@@ -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<Path> files = Files.list(store.file(original.key()).getParent())) {
assertEquals(List.of(store.file(original.key())), files.toList());
@@ -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<Long> 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<Integer> 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<Integer> scope = new ThreadLocal<>();
@@ -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<Double> 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);
}
}
@@ -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);
}
}