mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Expanded terrain generation tooling
- Standalone Minecraft 26.3 generation uses native registries, biome planning and structure planning with bounded concurrency - Offline export publishes fresh TERRAIN checkpoints for native server completion - Checkpoints retain mantle data, immutable generation history and captured server configuration - Output publication uses staged directories, durability checks and atomic moves - Offline distribution tasks provide an installed launcher and reusable native fixtures - Dimension settings control terrain, biome-bound and cave-density sampling with unchanged defaults - Pack validation and schemas enforce the supported sampling intervals - Biome layer caches isolate data, engine and seed identities - Floating biome palettes keep stable thickness patterns across generation orders - Hydrology batches terrain samples within the owning generation runtime - Hydrology planners share bounded basis caches and reuse raster geometry and distance samples - Demand planning admits new tiles as active plans finish - Volumetric height queries avoid unnecessary density evaluation and bound cached anchors - Cave generation reuses chunk-scoped inputs and semantic capture - Object replay indexes mutations by destination and reuses resolved records - Source-plan caches coordinate concurrent builds and reduce overlay allocation - Prerequisite carving reads preserve the underlying hydrology before object placement - Tree material decoding preserves stored native block identities - Engine shutdown drains generation outside the lifecycle lock and coordinates runtime retirement - Pregeneration sizes temporary worker boosts from processor and heap capacity - Pregeneration restores native worker counts after drain and failure paths - Native structure queries retain per-origin coordination without broad window locks - Saved biome writes drain pending batches under their write stripe - Boundary column lookups avoid repeated voxel decoding - Modded registry consumers use explicit VolmLib providers and version-specific native source overlays - Regression tests cover generation parity, cache isolation, concurrency, shutdown, persistence and offline checkpoints
This commit is contained in:
@@ -115,9 +115,9 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
|
||||
private final transient AtomicCache<Integer> maxWithObjectHeight = new AtomicCache<>();
|
||||
private final transient AtomicCache<IrisBiome> realCarveBiome = new AtomicCache<>();
|
||||
private final transient AtomicCache<KList<IrisBiome>> realChildren = new AtomicCache<>();
|
||||
private final transient AtomicCache<KList<CNG>> layerHeightGenerators = new AtomicCache<>();
|
||||
private final transient AtomicCache<KList<CNG>> layerCeilingHeightGenerators = new AtomicCache<>();
|
||||
private final transient AtomicCache<KList<CNG>> layerSeaHeightGenerators = new AtomicCache<>();
|
||||
private final transient IrisBiomeLayerHeightCache layerHeightGenerators = new IrisBiomeLayerHeightCache();
|
||||
private final transient IrisBiomeLayerHeightCache layerCeilingHeightGenerators = new IrisBiomeLayerHeightCache();
|
||||
private final transient IrisBiomeLayerHeightCache layerSeaHeightGenerators = new IrisBiomeLayerHeightCache();
|
||||
private final transient AtomicCache<KList<IrisOreGenerator>> surfaceOreCache = new AtomicCache<>();
|
||||
private final transient AtomicCache<KList<IrisOreGenerator>> undergroundOreCache = new AtomicCache<>();
|
||||
private final transient AtomicCache<IrisOreGeneratorBounds> surfaceOreBoundsCache = new AtomicCache<>();
|
||||
|
||||
@@ -25,7 +25,6 @@ import art.arcane.iris.structure.object.IrisObjectPlacement;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.volmlib.util.cache.AtomicCache;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
@@ -316,27 +315,10 @@ final class IrisBiomeLayerGenerator {
|
||||
return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data);
|
||||
}
|
||||
|
||||
private static KList<CNG> getHeightGenerators(AtomicCache<KList<CNG>> cache,
|
||||
private static KList<CNG> getHeightGenerators(IrisBiomeLayerHeightCache cache,
|
||||
KList<IrisBiomePaletteLayer> layers,
|
||||
int seedOffset, RNG rng, IrisData data) {
|
||||
KList<CNG> cached = cache.getIfPresent();
|
||||
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return cache.aquire(() ->
|
||||
{
|
||||
KList<CNG> layerHeightGenerators = new KList<>();
|
||||
|
||||
int m = seedOffset;
|
||||
|
||||
for (IrisBiomePaletteLayer layer : layers) {
|
||||
layerHeightGenerators.add(layer.getHeightGenerator(rng.nextParallelRNG((m++) * m * m * m), data));
|
||||
}
|
||||
|
||||
return layerHeightGenerators;
|
||||
});
|
||||
return cache.get(layers, seedOffset, rng, data);
|
||||
}
|
||||
|
||||
private static void appendLayer(KList<NativeBlockState> blocks, IrisBiomePaletteLayer layer,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package art.arcane.iris.generation.biome;
|
||||
|
||||
import art.arcane.iris.generation.cache.LazyBoundedCache;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.noise.CNG;
|
||||
|
||||
final class IrisBiomeLayerHeightCache {
|
||||
private final LazyBoundedCache<GeneratorKey, KList<CNG>> generators = new LazyBoundedCache<>(32);
|
||||
private volatile CachedGenerators recent;
|
||||
|
||||
KList<CNG> get(KList<IrisBiomePaletteLayer> layers, int seedOffset, RNG rng, IrisData data) {
|
||||
Engine engine = data == null ? null : data.getEngine();
|
||||
long seed = rng.getSeed();
|
||||
CachedGenerators cached = recent;
|
||||
if (cached != null && cached.key.matches(data, engine, seed)) {
|
||||
return cached.generators;
|
||||
}
|
||||
|
||||
GeneratorKey key = new GeneratorKey(data, engine, seed);
|
||||
KList<CNG> result = generators.computeIfAbsent(key, ignored -> create(layers, seedOffset, rng, data));
|
||||
recent = new CachedGenerators(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static KList<CNG> create(KList<IrisBiomePaletteLayer> layers, int seedOffset, RNG rng, IrisData data) {
|
||||
KList<CNG> result = new KList<>();
|
||||
int offset = seedOffset;
|
||||
for (IrisBiomePaletteLayer layer : layers) {
|
||||
result.add(layer.getHeightGenerator(rng.nextParallelRNG((offset++) * offset * offset * offset), data));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private record CachedGenerators(GeneratorKey key, KList<CNG> generators) {
|
||||
}
|
||||
|
||||
private static final class GeneratorKey {
|
||||
private final IrisData data;
|
||||
private final Engine engine;
|
||||
private final long seed;
|
||||
|
||||
private GeneratorKey(IrisData data, Engine engine, long seed) {
|
||||
this.data = data;
|
||||
this.engine = engine;
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
private boolean matches(IrisData data, Engine engine, long seed) {
|
||||
return this.data == data && this.engine == engine && this.seed == seed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object object) {
|
||||
return this == object || object instanceof GeneratorKey other
|
||||
&& matches(other.data, other.engine, other.seed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = System.identityHashCode(data);
|
||||
result = 31 * result + System.identityHashCode(engine);
|
||||
return 31 * result + Long.hashCode(seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,13 @@ public class IrisBiomePaletteLayer {
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private final transient AtomicReference<CachedLayerGenerator> recentLayerGenerator = new AtomicReference<>();
|
||||
private final transient AtomicCache<CNG> heightGenerator = new AtomicCache<>();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private final transient LazyBoundedCache<LayerGeneratorKey, CNG> heightGenerators =
|
||||
new LazyBoundedCache<>(LAYER_GENERATOR_CACHE_SIZE);
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private final transient AtomicReference<CachedLayerGenerator> recentHeightGenerator = new AtomicReference<>();
|
||||
@Description("The style of noise")
|
||||
private IrisGeneratorStyle style = NoiseStyle.STATIC.style();
|
||||
@DependsOn({"minHeight", "maxHeight"})
|
||||
@@ -91,13 +97,18 @@ public class IrisBiomePaletteLayer {
|
||||
private KList<IrisBlockData> palette = new KList<IrisBlockData>().qadd(new IrisBlockData("GRASS_BLOCK"));
|
||||
|
||||
public CNG getHeightGenerator(RNG rng, IrisData data) {
|
||||
CNG cached = heightGenerator.getIfPresent();
|
||||
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
Engine engine = data == null ? null : data.getEngine();
|
||||
long generatorSeed = rng.getSeed() + minHeight * maxHeight + getBlockData(data).size();
|
||||
CachedLayerGenerator recent = recentHeightGenerator.get();
|
||||
if (recent != null && recent.key.matches(data, engine, generatorSeed)) {
|
||||
return recent.generator;
|
||||
}
|
||||
|
||||
return heightGenerator.aquire(() -> CNG.signature(rng.nextParallelRNG(minHeight * maxHeight + getBlockData(data).size())));
|
||||
LayerGeneratorKey key = new LayerGeneratorKey(data, engine, generatorSeed);
|
||||
CNG generator = heightGenerators.computeIfAbsent(key,
|
||||
ignored -> CNG.signature(new RNG(generatorSeed)));
|
||||
recentHeightGenerator.set(new CachedLayerGenerator(key, generator));
|
||||
return generator;
|
||||
}
|
||||
|
||||
public NativeBlockState get(RNG rng, double x, double y, double z, IrisData data) {
|
||||
|
||||
@@ -35,7 +35,8 @@ public class LinkedTerrainChunk implements TerrainChunk {
|
||||
private final int minHeight;
|
||||
private final int maxHeight;
|
||||
private final int biomeHeight;
|
||||
private final Biome[] biomes;
|
||||
private final NativeBiome[] biomes;
|
||||
private volatile NativeBiome defaultBiome;
|
||||
|
||||
public LinkedTerrainChunk(World world) {
|
||||
this(Bukkit.createChunkData(world));
|
||||
@@ -46,19 +47,27 @@ public class LinkedTerrainChunk implements TerrainChunk {
|
||||
minHeight = data.getMinHeight();
|
||||
maxHeight = data.getMaxHeight();
|
||||
biomeHeight = Math.max(1, maxHeight - minHeight);
|
||||
biomes = new Biome[CHUNK_SIZE * biomeHeight * CHUNK_SIZE];
|
||||
biomes = new NativeBiome[CHUNK_SIZE * biomeHeight * CHUNK_SIZE];
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBiome getBiome(int x, int y, int z) {
|
||||
int index = biomeIndex(x, y, z);
|
||||
Biome biome = biomes[index];
|
||||
return BukkitBiome.of(biome == null ? Biome.PLAINS : biome);
|
||||
NativeBiome biome = biomes[index];
|
||||
if (biome != null) {
|
||||
return biome;
|
||||
}
|
||||
NativeBiome fallback = defaultBiome;
|
||||
if (fallback == null) {
|
||||
fallback = BukkitBiome.of(Biome.PLAINS);
|
||||
defaultBiome = fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBiome(int x, int y, int z, NativeBiome bio) {
|
||||
biomes[biomeIndex(x, y, z)] = (Biome) bio.nativeHandle();
|
||||
biomes[biomeIndex(x, y, z)] = canonicalBiome(bio);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +75,7 @@ public class LinkedTerrainChunk implements TerrainChunk {
|
||||
* calling setBiome for every y in [minHeight, maxHeight).
|
||||
*/
|
||||
public void fillBiomeColumn(int x, int z, NativeBiome bio) {
|
||||
Biome handle = (Biome) bio.nativeHandle();
|
||||
NativeBiome handle = canonicalBiome(bio);
|
||||
int stride = CHUNK_SIZE * CHUNK_SIZE;
|
||||
int index = (z & (CHUNK_SIZE - 1)) * CHUNK_SIZE + (x & (CHUNK_SIZE - 1));
|
||||
|
||||
@@ -120,4 +129,12 @@ public class LinkedTerrainChunk implements TerrainChunk {
|
||||
int clampedY = Math.max(minHeight, Math.min(maxHeight - 1, y)) - minHeight;
|
||||
return (clampedY * CHUNK_SIZE + clampedZ) * CHUNK_SIZE + clampedX;
|
||||
}
|
||||
|
||||
private static NativeBiome canonicalBiome(NativeBiome biome) {
|
||||
if (biome instanceof BukkitBiome) {
|
||||
return biome;
|
||||
}
|
||||
Biome handle = (Biome) biome.nativeHandle();
|
||||
return handle == null ? null : BukkitBiome.of(handle);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@ public record TreeBlockMaterial(String materialKey) {
|
||||
}
|
||||
|
||||
public static TreeBlockMaterial of(NativeBlockState state) {
|
||||
return of(Objects.requireNonNull(state, "state").key());
|
||||
NativeBlockState resolved = Objects.requireNonNull(state, "state");
|
||||
String materialKey = resolved.materialKey();
|
||||
return materialKey == null ? of(resolved.key()) : new TreeBlockMaterial(materialKey);
|
||||
}
|
||||
|
||||
public static TreeBlockMaterial of(String blockStateKey) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.volmlib.util.cache.CacheKey;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
|
||||
final class HydrologyBasisCache {
|
||||
private static final int MAXIMUM_ENTRIES = 32_768;
|
||||
private static final int MAXIMUM_STRIPES = 64;
|
||||
private final Stripe[] full;
|
||||
private final Stripe[] withoutSlope;
|
||||
|
||||
HydrologyBasisCache() {
|
||||
this(MAXIMUM_ENTRIES);
|
||||
}
|
||||
|
||||
HydrologyBasisCache(int maximumEntries) {
|
||||
if (maximumEntries < 1 || Integer.bitCount(maximumEntries) != 1) {
|
||||
throw new IllegalArgumentException("Terrain basis cache size must be a positive power of two.");
|
||||
}
|
||||
int stripeCount = Math.min(MAXIMUM_STRIPES, maximumEntries);
|
||||
full = new Stripe[stripeCount];
|
||||
withoutSlope = new Stripe[stripeCount];
|
||||
for (int index = 0; index < stripeCount; index++) {
|
||||
full[index] = new Stripe(maximumEntries / stripeCount);
|
||||
withoutSlope[index] = new Stripe(maximumEntries / stripeCount);
|
||||
}
|
||||
}
|
||||
|
||||
HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler) {
|
||||
return sample(x, z, sampler, false);
|
||||
}
|
||||
|
||||
HydrologyTerrainSample sampleWithoutSlope(int x, int z, HydrologyNaturalTerrainSampler sampler) {
|
||||
return sample(x, z, sampler, true);
|
||||
}
|
||||
|
||||
void seed(Long2ObjectMap<HydrologyTerrainSample> samples) {
|
||||
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 rememberWithoutSlope(long packed, HydrologyTerrainSample sample) {
|
||||
Stripe stripe = stripe(withoutSlope, packed);
|
||||
synchronized (stripe) {
|
||||
if (stripe.samples.containsKey(packed)) {
|
||||
return stripe.samples.getAndMoveToFirst(packed);
|
||||
}
|
||||
stripe.put(packed, sample);
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
|
||||
void seedWithoutSlope(Long2ObjectMap<HydrologyTerrainSample> samples) {
|
||||
seed(samples, withoutSlope);
|
||||
}
|
||||
|
||||
private HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler, boolean omitSlope) {
|
||||
long packed = RiverFootprint.pack(x, z);
|
||||
Stripe stripe = stripe(omitSlope ? withoutSlope : full, packed);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private void seed(Long2ObjectMap<HydrologyTerrainSample> samples, Stripe[] stripes) {
|
||||
for (Long2ObjectMap.Entry<HydrologyTerrainSample> entry : samples.long2ObjectEntrySet()) {
|
||||
Stripe stripe = stripe(stripes, entry.getLongKey());
|
||||
synchronized (stripe) {
|
||||
stripe.put(entry.getLongKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Stripe stripe(Stripe[] stripes, long packed) {
|
||||
return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)];
|
||||
}
|
||||
|
||||
private static final class Stripe {
|
||||
private final int maximumEntries;
|
||||
private final Long2ObjectLinkedOpenHashMap<HydrologyTerrainSample> samples;
|
||||
|
||||
private Stripe(int maximumEntries) {
|
||||
this.maximumEntries = maximumEntries;
|
||||
samples = new Long2ObjectLinkedOpenHashMap<>(Math.min(16, maximumEntries));
|
||||
}
|
||||
|
||||
private void put(long packed, HydrologyTerrainSample value) {
|
||||
if (samples.size() >= maximumEntries && !samples.containsKey(packed)) {
|
||||
samples.removeLast();
|
||||
}
|
||||
samples.putAndMoveToFirst(packed, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -269,6 +269,7 @@ final class HydrologyCrossTileResolver {
|
||||
}
|
||||
HydrologyCaveCourseFilter.Result result = publication.result();
|
||||
List<HydrologyDiagnosticCandidate> uniqueDiagnostics = planner.sourcePlanner.uniqueDiagnostics(diagnostics);
|
||||
footprintCompiler.sharedBasis = null;
|
||||
HydrologyOwnerDraft draft = new HydrologyOwnerDraft(
|
||||
key,
|
||||
result,
|
||||
@@ -295,9 +296,15 @@ final class HydrologyCrossTileResolver {
|
||||
HydrologyPlanner.PlanningSamples ownerSamples = planner.planningSamples.get();
|
||||
HydrologyLandHeightCache sharedLandHeights = planner.naturalSampler == null ? null
|
||||
: ownerSamples == null ? new HydrologyLandHeightCache() : ownerSamples.fallbackLandHeights();
|
||||
HydrologyBasisCache sharedBasis = null;
|
||||
if (planner.naturalSampler != null) {
|
||||
sharedBasis = ownerSamples == null ? new HydrologyBasisCache()
|
||||
: ownerSamples.fallbackBasis(context.footprintCompiler().terrainBases);
|
||||
}
|
||||
FallbackSamples shared = new FallbackSamples(sharedLandHeights, sharedBasis);
|
||||
for (int index = start; index < end; index++) {
|
||||
OutletCandidate outlet = outlets.get(index);
|
||||
tasks.add(() -> compileFallbackTrial(context, outlet, sharedLandHeights));
|
||||
tasks.add(() -> compileFallbackTrial(context, outlet, shared));
|
||||
}
|
||||
List<FallbackTrial> trials = HydrologyForkJoin.invokeAll(tasks,
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
|
||||
@@ -318,7 +325,7 @@ final class HydrologyCrossTileResolver {
|
||||
private FallbackTrial compileFallbackTrial(
|
||||
FallbackContext context,
|
||||
OutletCandidate outlet,
|
||||
HydrologyLandHeightCache sharedLandHeights
|
||||
FallbackSamples shared
|
||||
) {
|
||||
HydrologyPlanner.PlanningSamples previousSamples = planner.planningSamples.get();
|
||||
DraftProfile previousProfile = planner.draftProfiles.get();
|
||||
@@ -328,12 +335,13 @@ final class HydrologyCrossTileResolver {
|
||||
HashMap<SourceCompilationKey, SourceCompilation> sourceCompilations = new HashMap<>(context.sourceCompilations());
|
||||
HydrologyFootprintCompiler footprints = null;
|
||||
try {
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(sharedLandHeights));
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared.landHeights(), shared.basis()));
|
||||
planner.draftProfiles.set(profile);
|
||||
footprints = new HydrologyFootprintCompiler(
|
||||
planner.settings,
|
||||
new HydrologyFootprintCompiler.Sampling(planner.sampler, planner.geometrySampler, planner.naturalSampler)
|
||||
);
|
||||
footprints.sharedBasis = shared.basis();
|
||||
footprints.regionalNetwork = context.footprintCompiler().regionalNetwork;
|
||||
footprints.regionalSurfaceFootprints.putAll(context.footprintCompiler().regionalSurfaceFootprints);
|
||||
footprints.regionalDropRasters.putAll(context.footprintCompiler().regionalDropRasters);
|
||||
@@ -368,6 +376,9 @@ final class HydrologyCrossTileResolver {
|
||||
}
|
||||
}
|
||||
|
||||
private record FallbackSamples(HydrologyLandHeightCache landHeights, HydrologyBasisCache basis) {
|
||||
}
|
||||
|
||||
private record FallbackContext(
|
||||
HydrologyTileKey key,
|
||||
HydrologySampledGrid grid,
|
||||
|
||||
+60
-3
@@ -46,6 +46,7 @@ final class HydrologyFootprintCompiler {
|
||||
final Map<Long, SurfaceFootprint> regionalSurfaceFootprints;
|
||||
final Map<Long, HydrologySurfaceDropRaster> regionalDropRasters;
|
||||
HydrologyRegionalNetwork regionalNetwork;
|
||||
HydrologyBasisCache sharedBasis;
|
||||
|
||||
int fullMaterializationCount;
|
||||
final HydrologyFootprintRasterizer rasterizer;
|
||||
@@ -86,7 +87,17 @@ final class HydrologyFootprintCompiler {
|
||||
this.courseFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true);
|
||||
this.validationCourseRasters = new LinkedHashMap<>(VALIDATION_RASTER_CACHE_SIZE, 1F, true);
|
||||
this.rasterStencils = new HashMap<>();
|
||||
this.surfaceCompiler = new SurfaceFootprintCompiler(settings, this::sampleTerrainBasis, this.geometrySampler);
|
||||
this.surfaceCompiler = new SurfaceFootprintCompiler(settings, new HydrologyTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample sample(int x, int z) {
|
||||
return sampleTerrainBasis(x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) {
|
||||
return sampleTerrainBases(coordinates, count);
|
||||
}
|
||||
}, this.geometrySampler);
|
||||
this.surfaceFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true);
|
||||
this.regionalSurfaceFootprints = new LinkedHashMap<>();
|
||||
this.regionalDropRasters = new LinkedHashMap<>();
|
||||
@@ -101,6 +112,14 @@ final class HydrologyFootprintCompiler {
|
||||
}
|
||||
|
||||
SurfaceFootprint surfaceFootprint(RiverCourse course) {
|
||||
return surfaceFootprint(course, false);
|
||||
}
|
||||
|
||||
SurfaceFootprint surfaceFootprintForPublication(RiverCourse course) {
|
||||
return surfaceFootprint(course, true);
|
||||
}
|
||||
|
||||
private SurfaceFootprint surfaceFootprint(RiverCourse course, boolean publication) {
|
||||
SurfaceFootprint regional = regionalSurfaceFootprints.get(course.id());
|
||||
if (regional != null) {
|
||||
return regional;
|
||||
@@ -115,7 +134,10 @@ final class HydrologyFootprintCompiler {
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
SurfaceFootprint compiled = surfaceCompiler.compile(course);
|
||||
SurfaceFootprint compiled = publication ? surfaceCompiler.compileForPublication(course) : surfaceCompiler.compile(course);
|
||||
if (publication && !compiled.accepted()) {
|
||||
return compiled;
|
||||
}
|
||||
surfaceFootprints.put(rasterKey, compiled);
|
||||
if (surfaceFootprints.size() > COURSE_FOOTPRINT_CACHE_SIZE) {
|
||||
surfaceFootprints.remove(surfaceFootprints.sequencedKeySet().getFirst());
|
||||
@@ -906,13 +928,48 @@ final class HydrologyFootprintCompiler {
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
HydrologyTerrainSample sampled = naturalSampler.sampleBasisWithoutSlope(x, z);
|
||||
HydrologyTerrainSample sampled = sharedBasis == null ? naturalSampler.sampleBasisWithoutSlope(x, z)
|
||||
: sharedBasis.sampleWithoutSlope(x, z, naturalSampler);
|
||||
if (sampled != null) {
|
||||
terrainBases.put(packed, sampled);
|
||||
}
|
||||
return sampled;
|
||||
}
|
||||
|
||||
private HydrologyTerrainSample[] sampleTerrainBases(long[] coordinates, int count) {
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count];
|
||||
long[] missing = new long[count];
|
||||
int[] positions = new int[count];
|
||||
int missingCount = 0;
|
||||
for (int index = 0; index < count; index++) {
|
||||
long key = coordinates[index];
|
||||
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 {
|
||||
missing[missingCount] = key;
|
||||
positions[missingCount++] = index;
|
||||
}
|
||||
}
|
||||
if (missingCount > 0) {
|
||||
HydrologyTerrainSample[] loaded = naturalSampler.sampleBasisWithoutSlopeBatch(missing, missingCount);
|
||||
for (int index = 0; index < missingCount; index++) {
|
||||
samples[positions[index]] = sharedBasis == null ? loaded[index]
|
||||
: sharedBasis.rememberWithoutSlope(missing[index], loaded[index]);
|
||||
}
|
||||
}
|
||||
for (int index = 0; index < count; index++) {
|
||||
if (samples[index] != null) {
|
||||
terrainBases.put(coordinates[index], samples[index]);
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
HydrologyRoutingTerrainSampler.NaturalClassification classifyNatural(int x, int z) {
|
||||
if (naturalSampler == null) {
|
||||
return HydrologyRoutingTerrainSampler.NaturalClassification.UNAVAILABLE;
|
||||
|
||||
+8
@@ -11,4 +11,12 @@ public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainS
|
||||
HydrologyTerrainSample terrain = sampleBasisWithoutSlope(blockX, blockZ);
|
||||
return terrain == null || terrain.ocean() ? Double.NaN : terrain.naturalHeight();
|
||||
}
|
||||
|
||||
default HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) {
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count];
|
||||
for (int index = 0; index < count; index++) {
|
||||
samples[index] = sampleBasisWithoutSlope(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index]));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,11 @@ public final class HydrologyOceanReceiver implements HydrologyTerrainSampler {
|
||||
return source.sample(blockX, blockZ);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) {
|
||||
return source.sampleBatch(coordinates, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean receivingWater(int blockX, int blockZ, int queriedSeaLevel) {
|
||||
return queriedSeaLevel == seaLevel && receiving.contains(RiverFootprint.pack(blockX, blockZ))
|
||||
|
||||
@@ -11,6 +11,7 @@ import art.arcane.volmlib.util.noise.SimplexNoise;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -549,7 +550,8 @@ public final class HydrologyPlanner {
|
||||
}
|
||||
HydrologyTerrainSample sampled = naturalSampler == null
|
||||
? sampler.sample(blockX, blockZ)
|
||||
: naturalSampler.sampleBasis(blockX, blockZ);
|
||||
: samples.sharedBasis == null ? naturalSampler.sampleBasis(blockX, blockZ)
|
||||
: samples.sharedBasis.sample(blockX, blockZ, naturalSampler);
|
||||
samples.basis.put(packed, sampled);
|
||||
return sampled;
|
||||
}
|
||||
@@ -576,7 +578,9 @@ public final class HydrologyPlanner {
|
||||
if (cached != null || samples.basisWithoutSlope.containsKey(packed)) {
|
||||
return cached;
|
||||
}
|
||||
HydrologyTerrainSample sampled = naturalSampler.sampleBasisWithoutSlope(blockX, blockZ);
|
||||
HydrologyTerrainSample sampled = samples.sharedBasis == null
|
||||
? naturalSampler.sampleBasisWithoutSlope(blockX, blockZ)
|
||||
: samples.sharedBasis.sampleWithoutSlope(blockX, blockZ, naturalSampler);
|
||||
samples.basisWithoutSlope.put(packed, sampled);
|
||||
return sampled;
|
||||
}
|
||||
@@ -692,6 +696,7 @@ public final class HydrologyPlanner {
|
||||
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> basisWithoutSlope;
|
||||
private final Long2DoubleOpenHashMap landHeights;
|
||||
private HydrologyLandHeightCache sharedLandHeights;
|
||||
private HydrologyBasisCache sharedBasis;
|
||||
final Long2ObjectOpenHashMap<HydrologyTerrainSample> detailed;
|
||||
private final Long2ObjectOpenHashMap<HydrologyRoutingTerrainSampler.NaturalClassification> classifications;
|
||||
private final HashMap<HydrologyGeometrySampler.Request, Integer> geometry;
|
||||
@@ -704,11 +709,12 @@ public final class HydrologyPlanner {
|
||||
final HydrologyCaveContainmentPlanner.ValidationCache caveValidations;
|
||||
|
||||
PlanningSamples() {
|
||||
this(null);
|
||||
this(null, null);
|
||||
}
|
||||
|
||||
PlanningSamples(HydrologyLandHeightCache sharedLandHeights) {
|
||||
PlanningSamples(HydrologyLandHeightCache sharedLandHeights, HydrologyBasisCache sharedBasis) {
|
||||
this.sharedLandHeights = sharedLandHeights;
|
||||
this.sharedBasis = sharedBasis;
|
||||
this.basis = new Long2ObjectOpenHashMap<>();
|
||||
this.basisWithoutSlope = new Long2ObjectOpenHashMap<>();
|
||||
this.landHeights = new Long2DoubleOpenHashMap();
|
||||
@@ -725,6 +731,16 @@ public final class HydrologyPlanner {
|
||||
this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache();
|
||||
}
|
||||
|
||||
HydrologyBasisCache fallbackBasis(Long2ObjectMap<HydrologyTerrainSample> footprintBasis) {
|
||||
if (sharedBasis == null) {
|
||||
sharedBasis = new HydrologyBasisCache();
|
||||
sharedBasis.seed(basis);
|
||||
sharedBasis.seedWithoutSlope(basisWithoutSlope);
|
||||
sharedBasis.seedWithoutSlope(footprintBasis);
|
||||
}
|
||||
return sharedBasis;
|
||||
}
|
||||
|
||||
HydrologyLandHeightCache fallbackLandHeights() {
|
||||
if (sharedLandHeights == null) {
|
||||
sharedLandHeights = new HydrologyLandHeightCache();
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ final class HydrologyRegionalTributaries {
|
||||
if (tributary == null || !confined(tributary)) {
|
||||
return null;
|
||||
}
|
||||
SurfaceFootprint footprint = footprints.surfaceFootprint(tributary);
|
||||
SurfaceFootprint footprint = footprints.surfaceFootprintForPublication(tributary);
|
||||
if (!footprint.accepted() || !joinsWater(tributary, footprint, receiver.course())) {
|
||||
if (!footprint.accepted()) {
|
||||
planner.tributaries.addTributaryDiagnostic(local.id(), local.segments().getFirst().start(),
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ final class HydrologySurfaceProfiles {
|
||||
if (course.type() != RiverCourseType.SURFACE) {
|
||||
continue;
|
||||
}
|
||||
SurfaceFootprint footprint = compiler.surfaceFootprint(course);
|
||||
SurfaceFootprint footprint = compiler.surfaceFootprintForPublication(course);
|
||||
if (!footprint.accepted()) {
|
||||
iterator.remove();
|
||||
rejectedOutlets.add(course.outletId().orElseThrow());
|
||||
|
||||
@@ -2,8 +2,18 @@ package art.arcane.iris.generation.hydrology;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface HydrologyTerrainSampler {
|
||||
int MAXIMUM_BATCH_SIZE = 4096;
|
||||
|
||||
HydrologyTerrainSample sample(int blockX, int blockZ);
|
||||
|
||||
default HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) {
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count];
|
||||
for (int index = 0; index < count; index++) {
|
||||
samples[index] = sample(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index]));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
default boolean receivingWater(int blockX, int blockZ, int seaLevel) {
|
||||
HydrologyTerrainSample terrain = sample(blockX, blockZ);
|
||||
return terrain != null && terrain.ocean() && terrain.naturalHeight() < seaLevel;
|
||||
|
||||
@@ -44,6 +44,8 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
.build();
|
||||
|
||||
private final HydrologyPlanner planner;
|
||||
private final int tileSize;
|
||||
private final int publicationRadius;
|
||||
private final int maximumEntries;
|
||||
private final Cache<HydrologyTileKey, HydrologyTile> tiles;
|
||||
private final Cache<Long, ChunkColumns> composedChunks;
|
||||
@@ -107,6 +109,8 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
SharedCacheScope sharedCacheScope
|
||||
) {
|
||||
this.planner = Objects.requireNonNull(planner, "planner");
|
||||
this.tileSize = planner.settings().routing().tileSize();
|
||||
this.publicationRadius = planner.settings().publicationRadius();
|
||||
this.prefetchExecutor = prefetchExecutor;
|
||||
this.waitingForbidden = waitingForbidden;
|
||||
this.sharedCacheScope = sharedCacheScope;
|
||||
@@ -560,8 +564,6 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
|
||||
public PregenerationScope preparePregeneration(PregenerationArea area) {
|
||||
Objects.requireNonNull(area, "area");
|
||||
int tileSize = planner.settings().routing().tileSize();
|
||||
int publicationRadius = planner.settings().publicationRadius();
|
||||
TileBounds bounds = new TileBounds(
|
||||
tileCoordinate(Math.subtractExact(area.minimumBlockX(), publicationRadius), tileSize),
|
||||
tileCoordinate(Math.subtractExact(area.minimumBlockZ(), publicationRadius), tileSize),
|
||||
@@ -590,7 +592,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
sharedCacheScope = Objects.requireNonNull(scope, "scope");
|
||||
persistentStore = persistentRoot == null
|
||||
? null
|
||||
: new PreparedHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize());
|
||||
: new PreparedHydrologyTileStore(persistentRoot, scope, tileSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -670,7 +672,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
return tile.key().equals(sharedKey.tileKey())
|
||||
&& tile.worldSeed() == scope.worldSeed()
|
||||
&& tile.settingsFingerprint() == scope.settingsFingerprint()
|
||||
&& tile.tileSize() == planner.settings().routing().tileSize();
|
||||
&& tile.tileSize() == tileSize;
|
||||
}
|
||||
|
||||
private ChunkColumns chunkColumns(int blockX, int blockZ) {
|
||||
@@ -765,8 +767,6 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
private ArrayList<HydrologyTileKey> relevantKeys(int chunkX, int chunkZ) {
|
||||
int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE);
|
||||
int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
|
||||
int tileSize = planner.settings().routing().tileSize();
|
||||
int publicationRadius = planner.settings().publicationRadius();
|
||||
int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize);
|
||||
int maximumTileX = tileCoordinate((long) minimumBlockX + CHUNK_SIZE - 1L + publicationRadius, tileSize);
|
||||
int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize);
|
||||
@@ -854,8 +854,6 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
|
||||
private List<HydrologyTileKey> prefetchAreaKeys(long minimumBlockX, long minimumBlockZ, long maximumBlockX,
|
||||
long maximumBlockZ, int centreBlockX, int centreBlockZ) {
|
||||
int tileSize = planner.settings().routing().tileSize();
|
||||
int publicationRadius = planner.settings().publicationRadius();
|
||||
int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize);
|
||||
int maximumTileX = tileCoordinate((long) maximumBlockX + publicationRadius, tileSize);
|
||||
int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize);
|
||||
@@ -1127,23 +1125,42 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
return List.of(loaded);
|
||||
}
|
||||
int batch = Math.max(1, Math.min(maximumEntries / 2, HydrologyPlanningAdmission.maximumRoots()));
|
||||
ArrayList<CompletableFuture<HydrologyTile>> futures = new ArrayList<>(Math.min(batch, loaded.length));
|
||||
for (int start = 0; start < loaded.length; start += batch) {
|
||||
int end = Math.min(loaded.length, start + batch);
|
||||
futures.clear();
|
||||
for (int index = start; index < end; index++) {
|
||||
futures.add(planDemandAsync(keys.get(index)));
|
||||
}
|
||||
for (int index = start; index < end; index++) {
|
||||
loaded[index] = awaitPlan(futures.get(index - start));
|
||||
}
|
||||
}
|
||||
loadDemandTiles(keys, loaded, batch);
|
||||
return List.of(loaded);
|
||||
} finally {
|
||||
finishDemandBatch(demandEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadDemandTiles(List<HydrologyTileKey> keys, HydrologyTile[] loaded, int maximumPending) {
|
||||
ArrayList<PendingTile> pending = new ArrayList<>(Math.min(maximumPending, loaded.length));
|
||||
int next = 0;
|
||||
while (next < loaded.length || !pending.isEmpty()) {
|
||||
while (next < loaded.length && pending.size() < maximumPending) {
|
||||
if (loaded[next] == null) {
|
||||
pending.add(new PendingTile(next, planDemandAsync(keys.get(next))));
|
||||
}
|
||||
next++;
|
||||
}
|
||||
boolean completed = false;
|
||||
for (int index = pending.size() - 1; index >= 0; index--) {
|
||||
PendingTile tile = pending.get(index);
|
||||
if (tile.future().isDone()) {
|
||||
loaded[tile.index()] = awaitPlan(tile.future());
|
||||
pending.remove(index);
|
||||
completed = true;
|
||||
}
|
||||
}
|
||||
if (!completed && !pending.isEmpty()) {
|
||||
CompletableFuture<?>[] futures = new CompletableFuture<?>[pending.size()];
|
||||
for (int index = 0; index < pending.size(); index++) {
|
||||
futures[index] = pending.get(index).future();
|
||||
}
|
||||
awaitPlan(CompletableFuture.anyOf(futures));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long beginDemandBatch() {
|
||||
synchronized (prefetchQueue) {
|
||||
demandBatches++;
|
||||
@@ -1283,10 +1300,8 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
List<HydrologyTileKey> relevantKeys,
|
||||
List<HydrologyTile> relevantTiles
|
||||
) {
|
||||
ArrayList<HydrologyColumnLayer> layers = new ArrayList<>();
|
||||
ArrayList<HydrologyColumnLayer> layers = null;
|
||||
HydrologyColumnSample template = null;
|
||||
int tileSize = planner.settings().routing().tileSize();
|
||||
int publicationRadius = planner.settings().publicationRadius();
|
||||
int minimumTileX = tileCoordinate((long) blockX - publicationRadius, tileSize);
|
||||
int maximumTileX = tileCoordinate((long) blockX + publicationRadius, tileSize);
|
||||
int minimumTileZ = tileCoordinate((long) blockZ - publicationRadius, tileSize);
|
||||
@@ -1307,19 +1322,28 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
template = sample;
|
||||
} else {
|
||||
requireMatchingTerrain(template, sample);
|
||||
if (layers == null) {
|
||||
layers = new ArrayList<>(template.layers());
|
||||
}
|
||||
layers.addAll(sample.layers());
|
||||
}
|
||||
layers.addAll(sample.layers());
|
||||
}
|
||||
if (template == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (layers == null && template.layers().size() < 2) {
|
||||
return Optional.of(template);
|
||||
}
|
||||
LinkedHashMap<Long, HydrologyColumnLayer> unique = new LinkedHashMap<>();
|
||||
for (HydrologyColumnLayer layer : layers) {
|
||||
for (HydrologyColumnLayer layer : layers == null ? template.layers() : layers) {
|
||||
HydrologyColumnLayer existing = unique.putIfAbsent(layer.feature().id(), layer);
|
||||
if (existing != null && !existing.equals(layer)) {
|
||||
throw new IllegalStateException("Hydrology feature id collision at " + blockX + "," + blockZ + ".");
|
||||
}
|
||||
}
|
||||
if (layers == null && unique.size() == template.layers().size()) {
|
||||
return Optional.of(template);
|
||||
}
|
||||
return Optional.of(new HydrologyColumnSample(
|
||||
blockX,
|
||||
blockZ,
|
||||
@@ -1384,6 +1408,9 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private record PendingTile(int index, CompletableFuture<HydrologyTile> future) {
|
||||
}
|
||||
|
||||
private record TileBounds(int minimumX, int minimumZ, int maximumX, int maximumZ) {
|
||||
private boolean contains(HydrologyTileKey key) {
|
||||
return key.tileX() >= minimumX && key.tileX() <= maximumX
|
||||
|
||||
+57
-3
@@ -5,6 +5,9 @@ import art.arcane.volmlib.util.cache.CacheKey;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyNaturalTerrainSampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
|
||||
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 it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap;
|
||||
@@ -15,6 +18,8 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrainSampler, AutoCloseable {
|
||||
private static final int MINIMUM_PARALLEL_BASIS_SAMPLES = 64;
|
||||
@@ -130,6 +135,44 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
return basis(blockX, blockZ).terrain();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) {
|
||||
Objects.checkFromIndexSize(0, count, coordinates.length);
|
||||
if (count > HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE) {
|
||||
throw new IllegalArgumentException("Terrain basis batch exceeds its sample limit");
|
||||
}
|
||||
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];
|
||||
int perWorker = Math.ceilDiv(count, workers);
|
||||
ArrayList<Callable<Void>> tasks = new ArrayList<>(workers);
|
||||
for (int worker = 0; worker < workers; worker++) {
|
||||
int start = worker * perWorker;
|
||||
int end = Math.min(count, start + perWorker);
|
||||
if (start >= end) {
|
||||
break;
|
||||
}
|
||||
tasks.add(() -> {
|
||||
try (NativeGenerationScope ignored = scope.get()) {
|
||||
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]));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
HydrologyForkJoin.invokeAll(tasks, samplingOptions.executor());
|
||||
return samples;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double sampleLandHeight(int blockX, int blockZ) {
|
||||
long packed = pack(blockX, blockZ);
|
||||
@@ -415,8 +458,13 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
record SamplingOptions(
|
||||
int maximumEntries,
|
||||
int maximumWorkers,
|
||||
Executor executor
|
||||
Executor executor,
|
||||
BatchScopes batchScopes
|
||||
) {
|
||||
SamplingOptions(int maximumEntries, int maximumWorkers, Executor executor) {
|
||||
this(maximumEntries, maximumWorkers, executor, () -> () -> () -> {});
|
||||
}
|
||||
|
||||
SamplingOptions {
|
||||
if (maximumEntries < 1) {
|
||||
throw new IllegalArgumentException("maximumEntries must be positive");
|
||||
@@ -425,16 +473,18 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
throw new IllegalArgumentException("maximumWorkers must be positive");
|
||||
}
|
||||
Objects.requireNonNull(executor, "executor");
|
||||
Objects.requireNonNull(batchScopes, "batchScopes");
|
||||
}
|
||||
|
||||
static SamplingOptions production(int maximumEntries) {
|
||||
static SamplingOptions production(int maximumEntries, BatchScopes batchScopes) {
|
||||
int configuredWorkers = IrisSettings.getThreadCount(
|
||||
IrisSettings.get().getConcurrency().getParallelism()
|
||||
);
|
||||
return new SamplingOptions(
|
||||
maximumEntries,
|
||||
Math.max(1, configuredWorkers),
|
||||
MultiBurst.burst
|
||||
MultiBurst.burst,
|
||||
batchScopes
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +493,10 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
}
|
||||
}
|
||||
|
||||
interface BatchScopes {
|
||||
Supplier<NativeGenerationScope> capture();
|
||||
}
|
||||
|
||||
record TerrainBasis(double naturalHeight, HydrologyTerrainSample terrain) {
|
||||
TerrainBasis {
|
||||
if (!Double.isFinite(naturalHeight)) {
|
||||
|
||||
+36
-1
@@ -1,6 +1,9 @@
|
||||
package art.arcane.iris.generation.hydrology.runtime;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.context.IrisContext;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope;
|
||||
import art.arcane.iris.pack.loading.ResourceLoader;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
|
||||
@@ -53,6 +56,7 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
private static final int MAXIMUM_CACHE_TILES = 64;
|
||||
@@ -102,7 +106,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
);
|
||||
this.routingTerrainSampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
terrainSources,
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.production(MAXIMUM_TERRAIN_SAMPLES)
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.production(MAXIMUM_TERRAIN_SAMPLES, this::captureBasisScope)
|
||||
);
|
||||
HydrologyPlanner planner = new HydrologyPlanner(
|
||||
context.seed(),
|
||||
@@ -532,6 +536,37 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
+ " (raw=" + rawNaturalHeight + ", sampled=" + sampledNaturalHeight + "; " + breakdown + ")";
|
||||
}
|
||||
|
||||
private Supplier<NativeGenerationScope> captureBasisScope() {
|
||||
if (!(context.data().getEngine() instanceof IrisEngine engine)) {
|
||||
return null;
|
||||
}
|
||||
IrisEngine.GenerationRuntimeBinding binding = engine.captureGenerationRuntimeBinding();
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) {
|
||||
if (engine.getComplex().getHydrologyRuntime() != this) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
IrisContext current = IrisContext.get();
|
||||
long session = current != null && current.getEngine() == engine ? current.getGenerationSessionId() : 0L;
|
||||
return () -> {
|
||||
IrisEngine.GenerationRuntimeScope runtime = engine.openGenerationRuntimeScope(binding);
|
||||
IrisContext.Scope execution;
|
||||
try {
|
||||
execution = IrisContext.open(engine, session, null);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
runtime.close();
|
||||
throw failure;
|
||||
}
|
||||
return () -> {
|
||||
try {
|
||||
execution.close();
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
private IrisHydrologyRoutingTerrainSampler.TerrainBasis createTerrainBasis(
|
||||
int x,
|
||||
int z,
|
||||
|
||||
+229
-140
@@ -13,6 +13,7 @@ import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.LongArrayList;
|
||||
import it.unimi.dsi.fastutil.longs.LongIterator;
|
||||
import it.unimi.dsi.fastutil.longs.LongArrayFIFOQueue;
|
||||
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
|
||||
|
||||
@@ -105,9 +106,33 @@ public final class ErosionFieldCompiler {
|
||||
return new PreparedField(count, basin, bendOffsets, blendWidth, radii, inletStart);
|
||||
}
|
||||
|
||||
ErosionField compileForPublication(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel,
|
||||
ValleyProfile valley, SurfaceTerminal terminal, int apronLimit,
|
||||
HydrologyPlannerSettings.Ponds ponds, SurfaceRasterContext context,
|
||||
PreparedField prepared) {
|
||||
RasterGeometry geometry = new RasterGeometry();
|
||||
if (context.bounds() == null) {
|
||||
ErosionField wet = compile(courseSeed, centerline, channel, valley, terminal, apronLimit,
|
||||
ponds, context, prepared, geometry, true);
|
||||
if (wet.rejection() != null) {
|
||||
return wet;
|
||||
}
|
||||
}
|
||||
return compile(courseSeed, centerline, channel, valley, terminal, apronLimit,
|
||||
ponds, context, prepared, geometry, false);
|
||||
}
|
||||
|
||||
ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley,
|
||||
SurfaceTerminal terminal, int apronLimit, HydrologyPlannerSettings.Ponds ponds,
|
||||
SurfaceRasterContext context, PreparedField prepared) {
|
||||
return compile(courseSeed, centerline, channel, valley, terminal, apronLimit,
|
||||
ponds, context, prepared, new RasterGeometry(), false);
|
||||
}
|
||||
|
||||
private ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel,
|
||||
ValleyProfile valley, SurfaceTerminal terminal, int apronLimit,
|
||||
HydrologyPlannerSettings.Ponds ponds, SurfaceRasterContext context,
|
||||
PreparedField prepared, RasterGeometry geometry, boolean wetOnly) {
|
||||
Objects.requireNonNull(context, "context");
|
||||
SurfaceBounds bounds = context.bounds();
|
||||
SurfaceBounds samplingBounds = bounds == null ? null : bounds.expand(2);
|
||||
@@ -124,148 +149,191 @@ public final class ErosionFieldCompiler {
|
||||
boolean[] basin = prepared.basin();
|
||||
double[] bendOffsets = prepared.bendOffsets();
|
||||
double[][] blendWidth = prepared.blendWidth();
|
||||
Long2IntOpenHashMap nearest = new Long2IntOpenHashMap();
|
||||
nearest.defaultReturnValue(-1);
|
||||
Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap();
|
||||
for (int station = 0; station < count; station++) {
|
||||
int radius = prepared.radii()[station];
|
||||
int stationX = centerline.x()[station];
|
||||
int stationZ = centerline.z()[station];
|
||||
if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) {
|
||||
continue;
|
||||
}
|
||||
int minimumZ = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumZ() - stationZ);
|
||||
int maximumZ = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumZ() - stationZ);
|
||||
int minimumX = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumX() - stationX);
|
||||
int maximumX = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumX() - stationX);
|
||||
for (int deltaZ = minimumZ; deltaZ <= maximumZ; deltaZ++) {
|
||||
for (int deltaX = minimumX; deltaX <= maximumX; deltaX++) {
|
||||
int cellX = stationX + deltaX;
|
||||
int cellZ = stationZ + deltaZ;
|
||||
double cellDistance = centerline.distanceToSegment(station, cellX, cellZ);
|
||||
if (cellDistance > radius) {
|
||||
continue;
|
||||
}
|
||||
long key = RiverFootprint.pack(cellX, cellZ);
|
||||
int existing = nearest.get(key);
|
||||
// A cell on a station's cross-section row ties between the two segments meeting
|
||||
// there; the later station owns it so the row uses the head its own ring bounded.
|
||||
if (existing < 0
|
||||
|| cellDistance < distance.get(key) - EPSILON
|
||||
|| Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) {
|
||||
nearest.put(key, station);
|
||||
distance.put(key, cellDistance);
|
||||
Long2IntOpenHashMap nearest = geometry.nearest;
|
||||
Long2DoubleOpenHashMap distance = geometry.distance;
|
||||
if (!geometry.prepared) {
|
||||
for (int station = 0; station < count; station++) {
|
||||
int radius = prepared.radii()[station];
|
||||
int stationX = centerline.x()[station];
|
||||
int stationZ = centerline.z()[station];
|
||||
if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) {
|
||||
continue;
|
||||
}
|
||||
int minimumZ = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumZ() - stationZ);
|
||||
int maximumZ = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumZ() - stationZ);
|
||||
int minimumX = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumX() - stationX);
|
||||
int maximumX = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumX() - stationX);
|
||||
for (int deltaZ = minimumZ; deltaZ <= maximumZ; deltaZ++) {
|
||||
for (int deltaX = minimumX; deltaX <= maximumX; deltaX++) {
|
||||
int cellX = stationX + deltaX;
|
||||
int cellZ = stationZ + deltaZ;
|
||||
double cellDistance = centerline.distanceToSegment(station, cellX, cellZ, SurfaceDistanceTable.SHARED);
|
||||
if (cellDistance > radius) {
|
||||
continue;
|
||||
}
|
||||
long key = RiverFootprint.pack(cellX, cellZ);
|
||||
int existing = nearest.get(key);
|
||||
// A cell on a station's cross-section row ties between the two segments meeting
|
||||
// there; the later station owns it so the row uses the head its own ring bounded.
|
||||
if (existing < 0
|
||||
|| cellDistance < distance.get(key) - EPSILON
|
||||
|| Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) {
|
||||
nearest.put(key, station);
|
||||
distance.put(key, cellDistance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
geometry.prepared = true;
|
||||
}
|
||||
int oceanStart = oceanStart(centerline, valley, terminal);
|
||||
int inletStart = prepared.inletStart();
|
||||
Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(nearest.size());
|
||||
Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(wetOnly
|
||||
? Math.min(nearest.size(), Math.max(16, count * 8)) : nearest.size());
|
||||
LongArrayList wetKeys = new LongArrayList();
|
||||
for (long key : nearest.keySet()) {
|
||||
int station = nearest.get(key);
|
||||
double cellDistance = distance.get(key);
|
||||
int cellX = RiverFootprint.unpackX(key);
|
||||
int cellZ = RiverFootprint.unpackZ(key);
|
||||
int head = valley.head()[station];
|
||||
if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) {
|
||||
continue;
|
||||
}
|
||||
double halfWidth = channel.halfWidth()[station];
|
||||
double outline = halfWidth * (1D + roughness * SurfaceNoise.signed(
|
||||
courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength()));
|
||||
outline = Math.max(
|
||||
Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth),
|
||||
Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline));
|
||||
boolean wet = cellDistance <= outline + 0.25D;
|
||||
double bankDistance = cellDistance - outline;
|
||||
if (!wet && cellDistance > outline + maximumShoreWidth + 0.25D
|
||||
&& (bankDistance > erosion.excavation().maximumWidth() || !erosion.enabled()
|
||||
|| bankDistance - maximumShoreWidth >= blendWidth[station][side(centerline, station, cellX, cellZ) < 0D ? 0 : 1])) {
|
||||
continue;
|
||||
}
|
||||
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
|
||||
boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel
|
||||
&& SurfaceCellAdmission.mouthLand(terrain, seaLevel);
|
||||
if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) {
|
||||
if (sampler.receivingWater(cellX, cellZ, seaLevel) && wet && terminal == SurfaceTerminal.OCEAN_MOUTH
|
||||
&& station >= valley.exposedStations() - 1
|
||||
&& station - oceanStart < apronLimit
|
||||
&& cellDistance <= apronLimit + 0.25D) {
|
||||
columns.put(key, new SurfaceColumn(
|
||||
cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, terrain.naturalHeight(), seaLevel, true));
|
||||
LongIterator candidates = nearest.keySet().iterator();
|
||||
long[] coordinates = new long[Math.min(nearest.size(), HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE)];
|
||||
double[] outlines = new double[coordinates.length];
|
||||
while (candidates.hasNext()) {
|
||||
int pageSize = 0;
|
||||
while (candidates.hasNext() && pageSize < coordinates.length) {
|
||||
long key = candidates.nextLong();
|
||||
int station = nearest.get(key);
|
||||
double cellDistance = distance.get(key);
|
||||
int cellX = RiverFootprint.unpackX(key);
|
||||
int cellZ = RiverFootprint.unpackZ(key);
|
||||
int head = valley.head()[station];
|
||||
if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int natural = terrain.naturalHeight();
|
||||
if (wet) {
|
||||
double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline));
|
||||
normalized = SurfaceBendProfile.normalizedDistance(normalized,
|
||||
side(centerline, station, cellX, cellZ), bendOffsets[station]);
|
||||
double depth = channel.depth()[station];
|
||||
double local = 1D + (depth - 1D) * bedFactor(normalized, erosion)
|
||||
+ erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength())
|
||||
+ (basin[station] ? flow.plungeBasinDepth() : 0D);
|
||||
int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local)));
|
||||
int minimumBed = natural - maximumIncision(terrain, station >= inletStart);
|
||||
if (minimumBed < head) {
|
||||
bed = Math.max(bed, minimumBed);
|
||||
double halfWidth = channel.halfWidth()[station];
|
||||
double outline = halfWidth * (1D + roughness * SurfaceNoise.signed(
|
||||
courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength()));
|
||||
outline = Math.max(
|
||||
Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth),
|
||||
Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline));
|
||||
boolean wet = cellDistance <= outline + 0.25D;
|
||||
double bankDistance = cellDistance - outline;
|
||||
if (wetOnly && !wet) {
|
||||
continue;
|
||||
}
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
|
||||
wetKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
int bankTop = head + sink;
|
||||
// The geometric shore is the column's own policy width where it sets one, so a beach can be
|
||||
// wide on one bank and narrow on the other.
|
||||
double shore = terrain.shoreWidth(shoreWidth);
|
||||
// The SHORE role is the shore biome band: the column's policy width, or the geometric shore
|
||||
// width where no policy sets one. It is independent of the flattened geometric shore and of
|
||||
// the eroded valley, so a wide band reaches over untouched ground and a zero band leaves the
|
||||
// geometric shore with the bank biome.
|
||||
boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D;
|
||||
SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK;
|
||||
if (bankDistance > erosion.excavation().maximumWidth()) {
|
||||
if (shoreBiome) {
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
|
||||
if (!wet && cellDistance > outline + maximumShoreWidth + 0.25D
|
||||
&& (bankDistance > erosion.excavation().maximumWidth() || !erosion.enabled()
|
||||
|| bankDistance - maximumShoreWidth >= blendWidth[station][side(centerline, station, cellX, cellZ) < 0D ? 0 : 1])) {
|
||||
continue;
|
||||
}
|
||||
coordinates[pageSize] = key;
|
||||
outlines[pageSize++] = outline;
|
||||
}
|
||||
if (pageSize == 0) {
|
||||
continue;
|
||||
}
|
||||
if (cellDistance <= outline + shore + 0.25D) {
|
||||
int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore));
|
||||
HydrologyTerrainSample[] samples = sampler.sampleBatch(coordinates, pageSize);
|
||||
for (int index = 0; index < pageSize; index++) {
|
||||
long key = coordinates[index];
|
||||
int station = nearest.get(key);
|
||||
double cellDistance = distance.get(key);
|
||||
int cellX = RiverFootprint.unpackX(key);
|
||||
int cellZ = RiverFootprint.unpackZ(key);
|
||||
int head = valley.head()[station];
|
||||
double outline = outlines[index];
|
||||
boolean wet = cellDistance <= outline + 0.25D;
|
||||
double bankDistance = cellDistance - outline;
|
||||
HydrologyTerrainSample terrain = samples[index];
|
||||
boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel
|
||||
&& SurfaceCellAdmission.mouthLand(terrain, seaLevel);
|
||||
if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) {
|
||||
if (sampler.receivingWater(cellX, cellZ, seaLevel) && wet && terminal == SurfaceTerminal.OCEAN_MOUTH
|
||||
&& station >= valley.exposedStations() - 1
|
||||
&& station - oceanStart < apronLimit
|
||||
&& cellDistance <= apronLimit + 0.25D) {
|
||||
columns.put(key, new SurfaceColumn(
|
||||
cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, terrain.naturalHeight(), seaLevel, true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int natural = terrain.naturalHeight();
|
||||
if (wet) {
|
||||
double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline));
|
||||
normalized = SurfaceBendProfile.normalizedDistance(normalized,
|
||||
side(centerline, station, cellX, cellZ), bendOffsets[station]);
|
||||
double depth = channel.depth()[station];
|
||||
double local = 1D + (depth - 1D) * bedFactor(normalized, erosion)
|
||||
+ erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength())
|
||||
+ (basin[station] ? flow.plungeBasinDepth() : 0D);
|
||||
int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local)));
|
||||
int minimumBed = natural - maximumIncision(terrain, station >= inletStart);
|
||||
if (minimumBed < head) {
|
||||
bed = Math.max(bed, minimumBed);
|
||||
}
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
|
||||
wetKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
int bankTop = head + sink;
|
||||
// The geometric shore is the column's own policy width where it sets one, so a beach can be
|
||||
// wide on one bank and narrow on the other.
|
||||
double shore = terrain.shoreWidth(shoreWidth);
|
||||
// The SHORE role is the shore biome band: the column's policy width, or the geometric shore
|
||||
// width where no policy sets one. It is independent of the flattened geometric shore and of
|
||||
// the eroded valley, so a wide band reaches over untouched ground and a zero band leaves the
|
||||
// geometric shore with the bank biome.
|
||||
boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D;
|
||||
SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK;
|
||||
if (bankDistance > erosion.excavation().maximumWidth()) {
|
||||
if (shoreBiome) {
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cellDistance <= outline + shore + 0.25D) {
|
||||
int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore));
|
||||
height = boundedBankHeight(terrain, height, erosion.excavation());
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
|
||||
continue;
|
||||
}
|
||||
int shoreTop = bankTop + (int) StrictMath.round(shoreRise);
|
||||
int cut = natural - shoreTop;
|
||||
if (cut == 0 || !erosion.enabled() || !terrain.erosion()) {
|
||||
if (shoreBiome) {
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
double side = side(centerline, station, cellX, cellZ);
|
||||
double width = blendWidth[station][side < 0D ? 0 : 1];
|
||||
width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore));
|
||||
double progress = (cellDistance - outline - shore) / width;
|
||||
if (progress >= 1D) {
|
||||
if (shoreBiome) {
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion));
|
||||
height = boundedBankHeight(terrain, height, erosion.excavation());
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
|
||||
continue;
|
||||
}
|
||||
int shoreTop = bankTop + (int) StrictMath.round(shoreRise);
|
||||
int cut = natural - shoreTop;
|
||||
if (cut == 0 || !erosion.enabled() || !terrain.erosion()) {
|
||||
if (shoreBiome) {
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
double side = side(centerline, station, cellX, cellZ);
|
||||
double width = blendWidth[station][side < 0D ? 0 : 1];
|
||||
width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore));
|
||||
double progress = (cellDistance - outline - shore) / width;
|
||||
if (progress >= 1D) {
|
||||
if (shoreBiome) {
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion));
|
||||
height = boundedBankHeight(terrain, height, erosion.excavation());
|
||||
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
|
||||
}
|
||||
if (count > 0) {
|
||||
pond(columns, wetKeys, courseSeed ^ SOURCE_POND_SALT, ponds.source(), centerline, 0, valley.head()[0], samplingContext);
|
||||
pond(columns, wetKeys, courseSeed ^ SOURCE_POND_SALT, ponds.source(), centerline, 0, valley.head()[0], samplingContext, wetOnly);
|
||||
if (terminal == SurfaceTerminal.SINKHOLE) {
|
||||
pond(columns, wetKeys, courseSeed ^ TERMINAL_POND_SALT, ponds.terminal(), centerline, count - 1, valley.head()[count - 1], samplingContext);
|
||||
pond(columns, wetKeys, courseSeed ^ TERMINAL_POND_SALT, ponds.terminal(), centerline, count - 1, valley.head()[count - 1], samplingContext, wetOnly);
|
||||
}
|
||||
}
|
||||
if (wetOnly) {
|
||||
connectSteps(columns, wetKeys);
|
||||
for (SurfaceColumn column : columns.values()) {
|
||||
if (!column.apron()) {
|
||||
int cut = unsupportedIncisionCut(column, inletStart);
|
||||
if (cut > 0) {
|
||||
return new ErosionField(columns, 0,
|
||||
HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, cut, 0L);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ErosionField(columns, 0, null, 0, 0L);
|
||||
}
|
||||
int uncontained = contain(columns, wetKeys, sink, bounds, context.drops());
|
||||
connectSteps(columns, wetKeys);
|
||||
return validate(columns, wetKeys, centerline, terminal, bounds, uncontained, inletStart);
|
||||
@@ -286,7 +354,8 @@ public final class ErosionFieldCompiler {
|
||||
SurfaceCenterline centerline,
|
||||
int station,
|
||||
int head,
|
||||
SurfaceRasterContext context
|
||||
SurfaceRasterContext context,
|
||||
boolean wetOnly
|
||||
) {
|
||||
SurfaceBounds bounds = context.bounds();
|
||||
if (!pond.enabled()) {
|
||||
@@ -315,6 +384,8 @@ public final class ErosionFieldCompiler {
|
||||
}
|
||||
int bankTop = head + sink;
|
||||
int shoreTop = bankTop + (int) StrictMath.round(shoreRise);
|
||||
double maximumBlendWidth = Math.min(banks.maximumBlendWidth(),
|
||||
Math.max(0.25D, erosion.excavation().maximumWidth() - shore));
|
||||
int reach = (int) StrictMath.ceil(radius * (1D + roughness) + shore + banks.maximumBlendWidth()) + 1;
|
||||
for (int deltaZ = -reach; deltaZ <= reach; deltaZ++) {
|
||||
for (int deltaX = -reach; deltaX <= reach; deltaX++) {
|
||||
@@ -329,11 +400,6 @@ public final class ErosionFieldCompiler {
|
||||
|| context.drops().connects(cellX, cellZ, head)) {
|
||||
continue;
|
||||
}
|
||||
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
|
||||
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
|
||||
continue;
|
||||
}
|
||||
int natural = terrain.naturalHeight();
|
||||
long key = RiverFootprint.pack(cellX, cellZ);
|
||||
SurfaceColumn existing = columns.get(key);
|
||||
if (existing != null && existing.apron()) {
|
||||
@@ -344,7 +410,22 @@ public final class ErosionFieldCompiler {
|
||||
outline = Math.max(
|
||||
Math.max(0.5D, channelSettings.outlineMinimumRatio() * radius),
|
||||
Math.min(channelSettings.outlineMaximumRatio() * radius, outline));
|
||||
if (distance <= outline + 0.25D) {
|
||||
boolean wet = distance <= outline + 0.25D;
|
||||
if (wetOnly && !wet) {
|
||||
continue;
|
||||
}
|
||||
if (!wet && (existing != null && existing.role() == SurfaceRole.CHANNEL
|
||||
|| distance - outline > erosion.excavation().maximumWidth()
|
||||
|| distance > outline + shore + 0.25D && (!erosion.enabled() || !erode
|
||||
|| (distance - outline - shore) / maximumBlendWidth >= 1D))) {
|
||||
continue;
|
||||
}
|
||||
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
|
||||
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
|
||||
continue;
|
||||
}
|
||||
int natural = terrain.naturalHeight();
|
||||
if (wet) {
|
||||
double normalized = Math.min(1D, distance / Math.max(0.5D, outline));
|
||||
double local = 1D + (pond.depth() - 1D) * bedFactor(normalized, erosion)
|
||||
+ erosion.bedNoise() * roughness * SurfaceNoise.signed(seed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength());
|
||||
@@ -363,12 +444,6 @@ public final class ErosionFieldCompiler {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (existing != null && existing.role() == SurfaceRole.CHANNEL) {
|
||||
continue;
|
||||
}
|
||||
if (distance - outline > erosion.excavation().maximumWidth()) {
|
||||
continue;
|
||||
}
|
||||
if (distance <= outline + shore + 0.25D) {
|
||||
int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore));
|
||||
height = boundedBankHeight(terrain, height, erosion.excavation());
|
||||
@@ -376,7 +451,7 @@ public final class ErosionFieldCompiler {
|
||||
continue;
|
||||
}
|
||||
int cut = natural - shoreTop;
|
||||
if (cut == 0 || !erosion.enabled() || !erode) {
|
||||
if (cut == 0) {
|
||||
continue;
|
||||
}
|
||||
double width = Math.max(banks.minimumBlendWidth(),
|
||||
@@ -624,6 +699,12 @@ public final class ErosionFieldCompiler {
|
||||
return Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
|
||||
}
|
||||
|
||||
private int unsupportedIncisionCut(SurfaceColumn column, int inletStart) {
|
||||
int incision = maximumIncision(column.terrain(), column.station() >= inletStart);
|
||||
int cut = column.terrain().naturalHeight() - column.height();
|
||||
return cut > incision ? cut : 0;
|
||||
}
|
||||
|
||||
private ErosionField validate(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys,
|
||||
SurfaceCenterline centerline, SurfaceTerminal terminal,
|
||||
SurfaceBounds bounds, int uncontained, int inletStart) {
|
||||
@@ -635,10 +716,8 @@ public final class ErosionFieldCompiler {
|
||||
continue;
|
||||
}
|
||||
if (column.role() == SurfaceRole.CHANNEL) {
|
||||
HydrologyTerrainSample terrain = column.terrain();
|
||||
int incision = maximumIncision(terrain, column.station() >= inletStart);
|
||||
int cut = terrain.naturalHeight() - column.height();
|
||||
if (cut > incision) {
|
||||
int cut = unsupportedIncisionCut(column, inletStart);
|
||||
if (cut > 0) {
|
||||
return new ErosionField(columns, uncontained,
|
||||
HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
|
||||
cut, excavation);
|
||||
@@ -818,6 +897,16 @@ public final class ErosionFieldCompiler {
|
||||
return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station);
|
||||
}
|
||||
|
||||
private static final class RasterGeometry {
|
||||
private final Long2IntOpenHashMap nearest = new Long2IntOpenHashMap();
|
||||
private final Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap();
|
||||
private boolean prepared;
|
||||
|
||||
private RasterGeometry() {
|
||||
nearest.defaultReturnValue(-1);
|
||||
}
|
||||
}
|
||||
|
||||
record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii,
|
||||
int inletStart) {
|
||||
}
|
||||
|
||||
+8
-2
@@ -75,10 +75,15 @@ public record SurfaceCenterline(int[] x, int[] z, double[] tangentX, double[] ta
|
||||
}
|
||||
|
||||
public double distanceToSegment(int station, double pointX, double pointZ) {
|
||||
return distanceToSegment(station, pointX, pointZ, null);
|
||||
}
|
||||
|
||||
double distanceToSegment(int station, double pointX, double pointZ, SurfaceDistanceTable distances) {
|
||||
double startX = x[station];
|
||||
double startZ = z[station];
|
||||
if (station + 1 >= x.length) {
|
||||
return StrictMath.hypot(pointX - startX, pointZ - startZ);
|
||||
return distances == null ? StrictMath.hypot(pointX - startX, pointZ - startZ)
|
||||
: distances.hypot(pointX - startX, pointZ - startZ);
|
||||
}
|
||||
double endX = x[station + 1];
|
||||
double endZ = z[station + 1];
|
||||
@@ -91,7 +96,8 @@ public record SurfaceCenterline(int[] x, int[] z, double[] tangentX, double[] ta
|
||||
progress = Math.max(0D, Math.min(1D, progress));
|
||||
double closestX = startX + segmentX * progress;
|
||||
double closestZ = startZ + segmentZ * progress;
|
||||
return StrictMath.hypot(pointX - closestX, pointZ - closestZ);
|
||||
return distances == null ? StrictMath.hypot(pointX - closestX, pointZ - closestZ)
|
||||
: distances.hypot(pointX - closestX, pointZ - closestZ);
|
||||
}
|
||||
|
||||
public SurfaceCenterline truncate(int stations) {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package art.arcane.iris.generation.hydrology.surface;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.DoubleBinaryOperator;
|
||||
|
||||
final class SurfaceDistanceTable {
|
||||
static final SurfaceDistanceTable SHARED = new SurfaceDistanceTable(256, StrictMath::hypot);
|
||||
|
||||
private final int maximumResidual;
|
||||
private final int width;
|
||||
private final double[] integers;
|
||||
private final double[] diagonals;
|
||||
private final DoubleBinaryOperator calculator;
|
||||
|
||||
SurfaceDistanceTable(int maximumResidual, DoubleBinaryOperator calculator) {
|
||||
if (maximumResidual < 0 || maximumResidual > 256) {
|
||||
throw new IllegalArgumentException("Surface distance table residual must be between zero and 256.");
|
||||
}
|
||||
this.maximumResidual = maximumResidual;
|
||||
this.width = maximumResidual + 1;
|
||||
this.calculator = Objects.requireNonNull(calculator, "calculator");
|
||||
this.integers = new double[width * width];
|
||||
this.diagonals = new double[maximumResidual];
|
||||
for (int x = 0; x <= maximumResidual; x++) {
|
||||
for (int z = 0; z <= maximumResidual; z++) {
|
||||
integers[x * width + z] = calculator.applyAsDouble(x, z);
|
||||
}
|
||||
}
|
||||
for (int offset = 0; offset < maximumResidual; offset++) {
|
||||
diagonals[offset] = calculator.applyAsDouble(offset + 0.5D, offset + 0.5D);
|
||||
}
|
||||
}
|
||||
|
||||
double hypot(double x, double z) {
|
||||
double positiveX = Math.abs(x);
|
||||
double positiveZ = Math.abs(z);
|
||||
int integerX = (int) positiveX;
|
||||
int integerZ = (int) positiveZ;
|
||||
if (integerX <= maximumResidual && integerZ <= maximumResidual
|
||||
&& positiveX == integerX && positiveZ == integerZ) {
|
||||
return integers[integerX * width + integerZ];
|
||||
}
|
||||
if (positiveX == positiveZ && positiveX < maximumResidual && positiveX == integerX + 0.5D) {
|
||||
return diagonals[integerX];
|
||||
}
|
||||
return calculator.applyAsDouble(x, z);
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -54,11 +54,19 @@ public final class SurfaceFootprintCompiler {
|
||||
return compile(prepare(course), bounds);
|
||||
}
|
||||
|
||||
public SurfaceFootprint compileForPublication(RiverCourse course) {
|
||||
return compile(prepare(course), null, true);
|
||||
}
|
||||
|
||||
public PreparedCourse prepare(RiverCourse course) {
|
||||
return new PreparedCourse(this, Objects.requireNonNull(course, "course"));
|
||||
}
|
||||
|
||||
public SurfaceFootprint compile(PreparedCourse prepared, SurfaceBounds bounds) {
|
||||
return compile(prepared, bounds, false);
|
||||
}
|
||||
|
||||
private SurfaceFootprint compile(PreparedCourse prepared, SurfaceBounds bounds, boolean publication) {
|
||||
if (prepared.compiler != this) {
|
||||
throw new IllegalArgumentException("Prepared course belongs to another surface compiler");
|
||||
}
|
||||
@@ -89,7 +97,7 @@ public final class SurfaceFootprintCompiler {
|
||||
while (after < course.segments().size() && exposedSegment(course.segments().get(after))) {
|
||||
after++;
|
||||
}
|
||||
SurfaceFootprint run = compileRun(prepared, first, after, prepared.inlet.offset(first), bounds, drops, pool, receivingSampler);
|
||||
SurfaceFootprint run = compileRun(prepared, first, after, prepared.inlet.offset(first), bounds, drops, pool, receivingSampler, publication);
|
||||
columns.addAll(run.columns());
|
||||
uncontained += run.uncontainedWetCells();
|
||||
excavation += run.bankExcavation();
|
||||
@@ -147,7 +155,7 @@ public final class SurfaceFootprintCompiler {
|
||||
|
||||
private SurfaceFootprint compileRun(PreparedCourse prepared, int first, int after, int stationOffset,
|
||||
SurfaceBounds bounds, HydrologySurfaceDropRaster drops, boolean pool,
|
||||
HydrologyTerrainSampler receivingSampler) {
|
||||
HydrologyTerrainSampler receivingSampler, boolean publication) {
|
||||
RiverCourse course = prepared.course;
|
||||
PreparedRun run = prepared.runs[first];
|
||||
if (run == null) {
|
||||
@@ -159,9 +167,13 @@ public final class SurfaceFootprintCompiler {
|
||||
SurfaceCenterline centerline = run.centerline();
|
||||
boolean coastalChannel = run.coastalChannel();
|
||||
String poolBiome = run.poolBiome();
|
||||
ErosionField field = run.erosion().compile(
|
||||
HydrologyHash.mix(course.id(), COURSE_SEED_SALT), centerline, run.channel(), run.valley(), run.terminal(),
|
||||
settings.outlets().maximumOceanApron(), run.ponds(), new SurfaceRasterContext(bounds, drops, run.boundary()),
|
||||
SurfaceRasterContext context = new SurfaceRasterContext(bounds, drops, run.boundary());
|
||||
long seed = HydrologyHash.mix(course.id(), COURSE_SEED_SALT);
|
||||
ErosionField field = publication ? run.erosion().compileForPublication(
|
||||
seed, centerline, run.channel(), run.valley(), run.terminal(),
|
||||
settings.outlets().maximumOceanApron(), run.ponds(), context, run.field()) : run.erosion().compile(
|
||||
seed, centerline, run.channel(), run.valley(), run.terminal(),
|
||||
settings.outlets().maximumOceanApron(), run.ponds(), context,
|
||||
run.field());
|
||||
ArrayList<SurfaceColumn> ordered = new ArrayList<>(field.columns().values());
|
||||
ordered.sort(Comparator.comparingInt(SurfaceColumn::station)
|
||||
|
||||
@@ -40,6 +40,12 @@ final class CaveCarveScratch {
|
||||
final double[] planeThresholdLimit = new double[256];
|
||||
final boolean[] planeCarve = new boolean[256];
|
||||
final double[] adaptivePlaneDensity = new double[81];
|
||||
double[] verticalDensityLower = new double[9];
|
||||
double[] verticalDensityUpper = new double[9];
|
||||
int verticalDensityLowerY = Integer.MIN_VALUE;
|
||||
int verticalDensityUpperY = Integer.MIN_VALUE;
|
||||
int verticalDensityX;
|
||||
int verticalDensityZ;
|
||||
final double[] adaptivePlanePrediction = new double[256];
|
||||
final double[] adaptivePlaneAmbiguity = new double[256];
|
||||
final int[] adaptivePlaneSampleBounds = new int[4];
|
||||
|
||||
@@ -91,6 +91,9 @@ public class IrisCaveCarver3D {
|
||||
private final int fluidHeight;
|
||||
private final int aquiferCeilingY;
|
||||
private final boolean parallelDensity;
|
||||
private final int verticalDensityStep;
|
||||
private final int worldMinimumY;
|
||||
private final int densityMaximumY;
|
||||
private final ThreadLocal<Boolean> carving = ThreadLocal.withInitial(() -> false);
|
||||
private final ThreadLocal<CaveCarveScratch> scratchCache = ThreadLocal.withInitial(CaveCarveScratch::new);
|
||||
|
||||
@@ -139,6 +142,9 @@ public class IrisCaveCarver3D {
|
||||
detailMinContribution = -detailWeight;
|
||||
detailMaxContribution = detailWeight;
|
||||
parallelDensity = fixedDensityStyles(profile);
|
||||
verticalDensityStep = parallelDensity ? Math.max(1, engine.getDimension().getCaveDensitySamplingStep()) : 1;
|
||||
worldMinimumY = engine.getWorld().minHeight();
|
||||
densityMaximumY = engine.getWorld().getHeight() - 1;
|
||||
}
|
||||
|
||||
public int carve(MantleWriter writer, int chunkX, int chunkZ) {
|
||||
@@ -1354,6 +1360,13 @@ public class IrisCaveCarver3D {
|
||||
return;
|
||||
}
|
||||
|
||||
if (verticalDensityStep > 1 && adaptiveSampleStep == ADAPTIVE_DEEP_SAMPLE_STEP) {
|
||||
prepareVerticalDensityPlane(scratch, x0, z0, y);
|
||||
classifyDeepAdaptivePlaneFromSamples(scratch, planeColumnIndices, planeThresholdLimit,
|
||||
planeCount, planeCarve, ADAPTIVE_DEEP_SAMPLE_STEP, scratch.adaptivePlaneDensity, 2, 3);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasWarp) {
|
||||
if (!hasModules) {
|
||||
classifyDensityPlaneAdaptiveNoWarpNoModules(scratch, x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin);
|
||||
@@ -1372,6 +1385,65 @@ public class IrisCaveCarver3D {
|
||||
classifyDensityPlaneAdaptiveWarpModules(scratch, x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin);
|
||||
}
|
||||
|
||||
private void prepareVerticalDensityPlane(CaveCarveScratch scratch, int x0, int z0, int y) {
|
||||
int lower = (int) (Math.floorDiv((long) y + worldMinimumY, verticalDensityStep)
|
||||
* verticalDensityStep - worldMinimumY);
|
||||
int upper = Math.min(densityMaximumY, lower + verticalDensityStep);
|
||||
lower = Math.max(0, lower);
|
||||
for (CaveFieldModuleState module : modules) {
|
||||
if (y < module.minY) {
|
||||
upper = Math.min(upper, module.minY - 1);
|
||||
} else if (y > module.maxY) {
|
||||
lower = Math.max(lower, module.maxY + 1);
|
||||
} else {
|
||||
lower = Math.max(lower, module.minY);
|
||||
upper = Math.min(upper, module.maxY);
|
||||
}
|
||||
}
|
||||
if (scratch.verticalDensityX != x0 || scratch.verticalDensityZ != z0) {
|
||||
scratch.verticalDensityX = x0;
|
||||
scratch.verticalDensityZ = z0;
|
||||
scratch.verticalDensityLowerY = Integer.MIN_VALUE;
|
||||
scratch.verticalDensityUpperY = Integer.MIN_VALUE;
|
||||
}
|
||||
if (scratch.verticalDensityUpperY == lower) {
|
||||
double[] previousLower = scratch.verticalDensityLower;
|
||||
scratch.verticalDensityLower = scratch.verticalDensityUpper;
|
||||
scratch.verticalDensityUpper = previousLower;
|
||||
scratch.verticalDensityUpperY = scratch.verticalDensityLowerY;
|
||||
scratch.verticalDensityLowerY = lower;
|
||||
}
|
||||
if (scratch.verticalDensityLowerY != lower) {
|
||||
scratch.verticalDensityLowerY = Integer.MIN_VALUE;
|
||||
sampleVerticalDensityPlane(scratch, x0, z0, lower, scratch.verticalDensityLower);
|
||||
scratch.verticalDensityLowerY = lower;
|
||||
}
|
||||
if (y == lower) {
|
||||
System.arraycopy(scratch.verticalDensityLower, 0, scratch.adaptivePlaneDensity, 0, 9);
|
||||
return;
|
||||
}
|
||||
if (scratch.verticalDensityUpperY != upper) {
|
||||
scratch.verticalDensityUpperY = Integer.MIN_VALUE;
|
||||
sampleVerticalDensityPlane(scratch, x0, z0, upper, scratch.verticalDensityUpper);
|
||||
scratch.verticalDensityUpperY = upper;
|
||||
}
|
||||
double fraction = (y - lower) / (double) (upper - lower);
|
||||
for (int index = 0; index < 9; index++) {
|
||||
double lowerDensity = scratch.verticalDensityLower[index];
|
||||
scratch.adaptivePlaneDensity[index] = lowerDensity
|
||||
+ (scratch.verticalDensityUpper[index] - lowerDensity) * fraction;
|
||||
}
|
||||
}
|
||||
|
||||
private void sampleVerticalDensityPlane(CaveCarveScratch scratch, int x0, int z0, int y, double[] samples) {
|
||||
for (int x = 0; x < 3; x++) {
|
||||
for (int z = 0; z < 3; z++) {
|
||||
samples[x * 3 + z] = sampleDensityOptimized(scratch,
|
||||
x0 + x * ADAPTIVE_DEEP_SAMPLE_STEP, y, z0 + z * ADAPTIVE_DEEP_SAMPLE_STEP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void classifyDensityPlaneNoWarpNoModules(int x0, int z0, int y, int[] planeColumnIndices, double[] planeThresholdLimit, int planeCount, boolean[] planeCarve) {
|
||||
for (int planeIndex = 0; planeIndex < planeCount; planeIndex++) {
|
||||
int columnIndex = planeColumnIndices[planeIndex];
|
||||
|
||||
@@ -590,29 +590,25 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
if (matter == null) {
|
||||
continue;
|
||||
}
|
||||
MatterSlice<PreObjectMatterCell> journalSlice = matter.hasSlice(PreObjectMatterCell.class)
|
||||
? matter.getSlice(PreObjectMatterCell.class)
|
||||
: null;
|
||||
MatterSlice<MatterCavern> cavernSlice = matter.hasSlice(MatterCavern.class)
|
||||
? matter.getSlice(MatterCavern.class)
|
||||
: null;
|
||||
MatterSlice<HydrologyCaveCell> hydrologySlice = matter.hasSlice(HydrologyCaveCell.class)
|
||||
? matter.getSlice(HydrologyCaveCell.class)
|
||||
: null;
|
||||
MatterSlice<PreObjectMatterCell> journalSlice = matter.getSlice(PreObjectMatterCell.class);
|
||||
MatterSlice<MatterCavern> cavernSlice = matter.getSlice(MatterCavern.class);
|
||||
MatterSlice<HydrologyCaveCell> hydrologySlice = matter.getSlice(HydrologyCaveCell.class);
|
||||
int sectionBaseY = section << 4;
|
||||
int sectionMaxY = Math.min(cappedHeight, sectionBaseY + 16);
|
||||
for (int y = sectionBaseY; y < sectionMaxY; y++) {
|
||||
int localY = y & 15;
|
||||
HydrologyCaveCell hydrology = hydrologySlice == null
|
||||
PreObjectMatterCell cell = journalSlice == null
|
||||
? null
|
||||
: journalSlice.get(x & 15, localY, z & 15);
|
||||
HydrologyCaveCell hydrology = cell != null && cell.hydrologyCaptured()
|
||||
? cell.hydrology()
|
||||
: hydrologySlice == null
|
||||
? null
|
||||
: hydrologySlice.get(x & 15, localY, z & 15);
|
||||
if (hydrology != null) {
|
||||
carvedColumn[y] = hydrology.carves() ? (byte) 1 : 0;
|
||||
continue;
|
||||
}
|
||||
PreObjectMatterCell cell = journalSlice == null
|
||||
? null
|
||||
: journalSlice.get(x & 15, localY, z & 15);
|
||||
MatterCavern cavern = cell != null && cell.cavernCaptured()
|
||||
? cell.cavern()
|
||||
: cavernSlice == null ? null : cavernSlice.get(x & 15, localY, z & 15);
|
||||
@@ -799,9 +795,11 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
private static <T> T prerequisiteValue(Matter matter, int x, int y, int z, Class<T> type) {
|
||||
PreObjectMatterCell cell = preObjectCell(matter, x, y, z);
|
||||
if (cell != null && cell.captures(type)) {
|
||||
return cell.original(type);
|
||||
if (isPreObjectType(type) || type == HydrologyCaveCell.class) {
|
||||
PreObjectMatterCell cell = preObjectCell(matter, x, y, z);
|
||||
if (cell != null && cell.captures(type)) {
|
||||
return cell.original(type);
|
||||
}
|
||||
}
|
||||
MatterSlice<T> slice = matter.getSlice(type);
|
||||
return slice == null ? null : slice.get(x & 15, y & 15, z & 15);
|
||||
|
||||
+131
-35
@@ -3,6 +3,7 @@ package art.arcane.iris.generation.mantle;
|
||||
import art.arcane.iris.integration.Identifier;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.generation.block.TileData;
|
||||
import art.arcane.iris.structure.object.IObjectPlacer;
|
||||
@@ -11,6 +12,9 @@ import art.arcane.iris.generation.block.B;
|
||||
import art.arcane.iris.world.storage.matter.TileWrapper;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@@ -26,7 +30,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
private final int destinationChunkZ;
|
||||
private final int worldHeight;
|
||||
private final List<Mutation> mutations;
|
||||
private final Map<DataKey, Object> overlay;
|
||||
private final Long2ObjectOpenHashMap<Int2ObjectOpenHashMap<OverlayCell>> overlay;
|
||||
|
||||
ObjectDestinationTransaction(MantleWriter writer, int destinationChunkX, int destinationChunkZ) {
|
||||
this.writer = writer;
|
||||
@@ -34,7 +38,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
this.destinationChunkZ = destinationChunkZ;
|
||||
this.worldHeight = writer.getMantle().getWorldHeight();
|
||||
this.mutations = new ArrayList<>();
|
||||
this.overlay = new HashMap<>();
|
||||
this.overlay = new Long2ObjectOpenHashMap<>();
|
||||
}
|
||||
|
||||
void commit() {
|
||||
@@ -53,9 +57,10 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
}
|
||||
|
||||
void apply(ObjectSourcePlan plan) {
|
||||
for (Mutation mutation : plan.mutations()) {
|
||||
if (isDestination(mutation.x(), mutation.z())) {
|
||||
mutation.apply(this);
|
||||
for (Mutation mutation : plan.mutationsFor(destinationChunkX, destinationChunkZ)) {
|
||||
switch (mutation) {
|
||||
case SetMutation set -> setData(set.key().x(), set.key().y(), set.key().z(), set.value(), set);
|
||||
case CustomBlockMutation custom -> set(custom.key().x(), custom.key().y(), custom.key().z(), custom.state(), custom);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +107,10 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
|
||||
@Override
|
||||
public void set(int x, int y, int z, NativeBlockState state) {
|
||||
set(x, y, z, state, null);
|
||||
}
|
||||
|
||||
private void set(int x, int y, int z, NativeBlockState state, CustomBlockMutation replayed) {
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
@@ -111,11 +120,12 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
if (!canSetBlock(x, y, z)) {
|
||||
return;
|
||||
}
|
||||
DataKey blockKey = new DataKey(x, y, z, NativeBlockState.class);
|
||||
Identifier identifier = Identifier.fromString(placementKey);
|
||||
overlay.put(blockKey, baseState);
|
||||
overlay.put(new DataKey(x, y, z, Identifier.class), identifier);
|
||||
mutations.add(new CustomBlockMutation(blockKey, state));
|
||||
OverlayCell cell = writableCell(x, y, z);
|
||||
cell.block = baseState;
|
||||
cell.identifier = identifier;
|
||||
mutations.add(replayed != null
|
||||
? replayed : new CustomBlockMutation(new DataKey(x, y, z, NativeBlockState.class), state));
|
||||
return;
|
||||
}
|
||||
setData(x, y, z, state);
|
||||
@@ -123,8 +133,8 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
|
||||
@Override
|
||||
public NativeBlockState get(int x, int y, int z) {
|
||||
DataKey key = new DataKey(x, y, z, NativeBlockState.class);
|
||||
Object value = overlay.get(key);
|
||||
OverlayCell cell = overlayCell(x, y, z);
|
||||
Object value = cell == null ? null : cell.block;
|
||||
if (value == CLEARED) {
|
||||
return EngineMantle.AIR.get();
|
||||
}
|
||||
@@ -140,15 +150,19 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
|
||||
@Override
|
||||
public boolean isCarved(int x, int y, int z) {
|
||||
Object block = overlay.get(new DataKey(x, y, z, NativeBlockState.class));
|
||||
OverlayCell cell = overlayCell(x, y, z);
|
||||
if (cell == null) {
|
||||
return writer.isPrerequisiteCarved(x, y, z);
|
||||
}
|
||||
Object block = cell.block;
|
||||
if (block instanceof NativeBlockState state && !state.isAir() && !state.isFluid()) {
|
||||
return false;
|
||||
}
|
||||
Object hydrologyValue = overlay.get(new DataKey(x, y, z, HydrologyCaveCell.class));
|
||||
Object hydrologyValue = cell.hydrology;
|
||||
if (hydrologyValue instanceof HydrologyCaveCell hydrology) {
|
||||
return hydrology.carves();
|
||||
}
|
||||
Object cavern = overlay.get(new DataKey(x, y, z, MatterCavern.class));
|
||||
Object cavern = cell.cavern;
|
||||
return cavern == null ? writer.isPrerequisiteCarved(x, y, z) : cavern != CLEARED;
|
||||
}
|
||||
|
||||
@@ -186,6 +200,10 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
|
||||
@Override
|
||||
public <T> void setData(int x, int y, int z, T data) {
|
||||
setData(x, y, z, data, null);
|
||||
}
|
||||
|
||||
private void setData(int x, int y, int z, Object data, SetMutation replayed) {
|
||||
if (data == null || y < 0 || y >= worldHeight) {
|
||||
return;
|
||||
}
|
||||
@@ -196,18 +214,21 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
return;
|
||||
}
|
||||
Class<?> type = data instanceof NativeBlockState ? NativeBlockState.class : data.getClass();
|
||||
OverlayCell cell = writableCell(x, y, z);
|
||||
if (data instanceof NativeBlockState) {
|
||||
overlay.put(new DataKey(x, y, z, Identifier.class), CLEARED);
|
||||
cell.block = data;
|
||||
cell.identifier = CLEARED;
|
||||
} else {
|
||||
cell.put(type, data);
|
||||
}
|
||||
DataKey key = new DataKey(x, y, z, type);
|
||||
overlay.put(key, data);
|
||||
mutations.add(new SetMutation(key, data));
|
||||
mutations.add(replayed != null
|
||||
? replayed : new SetMutation(new DataKey(x, y, z, type), data));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> @Nullable T getDataIfPresent(int x, int y, int z, Class<T> type) {
|
||||
DataKey key = new DataKey(x, y, z, type);
|
||||
Object value = overlay.get(key);
|
||||
OverlayCell cell = overlayCell(x, y, z);
|
||||
Object value = cell == null ? null : cell.get(type);
|
||||
if (value == CLEARED) {
|
||||
return null;
|
||||
}
|
||||
@@ -228,22 +249,22 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
} else {
|
||||
carved = carved.clone();
|
||||
}
|
||||
for (int y = 0; y < cappedHeight; y++) {
|
||||
Object block = overlay.get(new DataKey(x, y, z, NativeBlockState.class));
|
||||
if (block instanceof NativeBlockState state && !state.isAir() && !state.isFluid()) {
|
||||
Int2ObjectOpenHashMap<OverlayCell> column = overlay.get(columnKey(x, z));
|
||||
if (column == null) {
|
||||
return carved;
|
||||
}
|
||||
for (Int2ObjectMap.Entry<OverlayCell> entry : column.int2ObjectEntrySet()) {
|
||||
int y = entry.getIntKey();
|
||||
if (y >= cappedHeight) {
|
||||
continue;
|
||||
}
|
||||
OverlayCell cell = entry.getValue();
|
||||
if (cell.block instanceof NativeBlockState state && !state.isAir() && !state.isFluid()) {
|
||||
carved[y] = 0;
|
||||
continue;
|
||||
}
|
||||
DataKey hydrologyKey = new DataKey(x, y, z, HydrologyCaveCell.class);
|
||||
Object hydrologyValue = overlay.get(hydrologyKey);
|
||||
if (hydrologyValue instanceof HydrologyCaveCell hydrology) {
|
||||
} else if (cell.hydrology instanceof HydrologyCaveCell hydrology) {
|
||||
carved[y] = hydrology.carves() ? (byte) 1 : 0;
|
||||
continue;
|
||||
}
|
||||
DataKey cavernKey = new DataKey(x, y, z, MatterCavern.class);
|
||||
Object cavernValue = overlay.get(cavernKey);
|
||||
if (cavernValue != null) {
|
||||
carved[y] = cavernValue == CLEARED ? (byte) 0 : (byte) 1;
|
||||
} else if (cell.cavern != null) {
|
||||
carved[y] = cell.cavern == CLEARED ? (byte) 0 : (byte) 1;
|
||||
}
|
||||
}
|
||||
return carved;
|
||||
@@ -254,6 +275,30 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
return writer.getEngine();
|
||||
}
|
||||
|
||||
private static long columnKey(int x, int z) {
|
||||
return ((long) x << 32) | (z & 0xffffffffL);
|
||||
}
|
||||
|
||||
private OverlayCell overlayCell(int x, int y, int z) {
|
||||
Int2ObjectOpenHashMap<OverlayCell> column = overlay.get(columnKey(x, z));
|
||||
return column == null ? null : column.get(y);
|
||||
}
|
||||
|
||||
private OverlayCell writableCell(int x, int y, int z) {
|
||||
long coordinate = columnKey(x, z);
|
||||
Int2ObjectOpenHashMap<OverlayCell> column = overlay.get(coordinate);
|
||||
if (column == null) {
|
||||
column = new Int2ObjectOpenHashMap<>(4);
|
||||
overlay.put(coordinate, column);
|
||||
}
|
||||
OverlayCell cell = column.get(y);
|
||||
if (cell == null) {
|
||||
cell = new OverlayCell();
|
||||
column.put(y, cell);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
private Object prerequisiteOrCleared(int x, int y, int z, Class<?> type) {
|
||||
Object value = writer.getPrerequisiteDataIfPresent(x, y, z, type);
|
||||
return value == null ? CLEARED : value;
|
||||
@@ -317,10 +362,61 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class OverlayCell {
|
||||
private Object block;
|
||||
private Object hydrology;
|
||||
private Object cavern;
|
||||
private Object identifier;
|
||||
private Object string;
|
||||
private Object treeMaterial;
|
||||
private Map<Class<?>, Object> other;
|
||||
|
||||
private Object get(Class<?> type) {
|
||||
if (type == NativeBlockState.class) {
|
||||
return block;
|
||||
}
|
||||
if (type == HydrologyCaveCell.class) {
|
||||
return hydrology;
|
||||
}
|
||||
if (type == MatterCavern.class) {
|
||||
return cavern;
|
||||
}
|
||||
if (type == Identifier.class) {
|
||||
return identifier;
|
||||
}
|
||||
if (type == String.class) {
|
||||
return string;
|
||||
}
|
||||
if (type == TreeBlockMaterial.class) {
|
||||
return treeMaterial;
|
||||
}
|
||||
return other == null ? null : other.get(type);
|
||||
}
|
||||
|
||||
private void put(Class<?> type, Object value) {
|
||||
if (type == HydrologyCaveCell.class) {
|
||||
hydrology = value;
|
||||
} else if (type == MatterCavern.class) {
|
||||
cavern = value;
|
||||
} else if (type == Identifier.class) {
|
||||
identifier = value;
|
||||
} else if (type == String.class) {
|
||||
string = value;
|
||||
} else if (type == TreeBlockMaterial.class) {
|
||||
treeMaterial = value;
|
||||
} else {
|
||||
if (other == null) {
|
||||
other = new HashMap<>();
|
||||
}
|
||||
other.put(type, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record DataKey(int x, int y, int z, Class<?> type) {
|
||||
}
|
||||
|
||||
interface Mutation {
|
||||
sealed interface Mutation permits SetMutation, CustomBlockMutation {
|
||||
int x();
|
||||
|
||||
int z();
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
package art.arcane.iris.generation.mantle;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class ObjectSourcePlan {
|
||||
private final List<ObjectDestinationTransaction.Mutation> mutations;
|
||||
private static final int DESTINATION_INDEX_WEIGHT = 8;
|
||||
private final Long2ObjectOpenHashMap<List<ObjectDestinationTransaction.Mutation>> destinations;
|
||||
private final int mutationWeight;
|
||||
|
||||
ObjectSourcePlan(List<ObjectDestinationTransaction.Mutation> mutations) {
|
||||
this.mutations = List.copyOf(mutations);
|
||||
Long2ObjectOpenHashMap<ArrayList<ObjectDestinationTransaction.Mutation>> grouped = new Long2ObjectOpenHashMap<>(2);
|
||||
long weight = 1L;
|
||||
for (ObjectDestinationTransaction.Mutation mutation : this.mutations) {
|
||||
for (ObjectDestinationTransaction.Mutation mutation : mutations) {
|
||||
long key = destinationKey(mutation.x() >> 4, mutation.z() >> 4);
|
||||
ArrayList<ObjectDestinationTransaction.Mutation> local = grouped.get(key);
|
||||
if (local == null) {
|
||||
local = new ArrayList<>();
|
||||
grouped.put(key, local);
|
||||
weight += DESTINATION_INDEX_WEIGHT;
|
||||
}
|
||||
local.add(mutation);
|
||||
weight += mutation.weight();
|
||||
}
|
||||
destinations = new Long2ObjectOpenHashMap<>(grouped.size());
|
||||
for (Long2ObjectMap.Entry<ArrayList<ObjectDestinationTransaction.Mutation>> entry : grouped.long2ObjectEntrySet()) {
|
||||
destinations.put(entry.getLongKey(), List.copyOf(entry.getValue()));
|
||||
}
|
||||
this.mutationWeight = weight >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) weight;
|
||||
}
|
||||
|
||||
List<ObjectDestinationTransaction.Mutation> mutations() {
|
||||
return mutations;
|
||||
List<ObjectDestinationTransaction.Mutation> mutationsFor(int chunkX, int chunkZ) {
|
||||
List<ObjectDestinationTransaction.Mutation> mutations = destinations.get(destinationKey(chunkX, chunkZ));
|
||||
return mutations == null ? List.of() : mutations;
|
||||
}
|
||||
|
||||
int mutationWeight() {
|
||||
return mutationWeight;
|
||||
}
|
||||
|
||||
private static long destinationKey(int chunkX, int chunkZ) {
|
||||
return ((long) chunkX << 32) | (chunkZ & 0xffffffffL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,21 @@ package art.arcane.iris.generation.mantle;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import art.arcane.volmlib.util.cache.CacheKey;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static art.arcane.iris.generation.cache.Cache.key;
|
||||
|
||||
final class ObjectSourcePlanCache {
|
||||
static final long MAXIMUM_MUTATION_WEIGHT = 1_048_576L;
|
||||
static final long MAXIMUM_MUTATION_WEIGHT = 4_194_304L;
|
||||
|
||||
private final Cache<Long, ObjectSourcePlan> plans;
|
||||
private final long maximumMutationWeight;
|
||||
private volatile State state;
|
||||
|
||||
ObjectSourcePlanCache() {
|
||||
this(MAXIMUM_MUTATION_WEIGHT);
|
||||
@@ -21,23 +26,88 @@ final class ObjectSourcePlanCache {
|
||||
if (maximumMutationWeight <= 0L) {
|
||||
throw new IllegalArgumentException("Maximum mutation weight must be positive");
|
||||
}
|
||||
this.plans = Caffeine.newBuilder()
|
||||
.maximumWeight(maximumMutationWeight)
|
||||
.weigher((Long key, ObjectSourcePlan plan) -> plan.mutationWeight())
|
||||
.build();
|
||||
this.maximumMutationWeight = maximumMutationWeight;
|
||||
state = new State(maximumMutationWeight);
|
||||
}
|
||||
|
||||
ObjectSourcePlan get(int sourceChunkX, int sourceChunkZ, Supplier<ObjectSourcePlan> builder) {
|
||||
Objects.requireNonNull(builder, "Source plan builder");
|
||||
return plans.get(key(sourceChunkX, sourceChunkZ), ignored -> builder.get());
|
||||
State current = state;
|
||||
long source = CacheKey.mix(key(sourceChunkX, sourceChunkZ));
|
||||
ObjectSourcePlan cached = current.plans.getIfPresent(source);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Pending created = new Pending(Thread.currentThread());
|
||||
Pending pending = current.pending.putIfAbsent(source, created);
|
||||
if (pending != null) {
|
||||
return pending.await();
|
||||
}
|
||||
try {
|
||||
ObjectSourcePlan plan = current.plans.getIfPresent(source);
|
||||
if (plan == null) {
|
||||
plan = builder.get();
|
||||
if (plan != null) {
|
||||
current.plans.put(source, plan);
|
||||
}
|
||||
}
|
||||
created.result.complete(plan);
|
||||
return plan;
|
||||
} catch (RuntimeException | Error failure) {
|
||||
created.failure = failure;
|
||||
created.result.completeExceptionally(failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
current.pending.remove(source, created);
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
plans.invalidateAll();
|
||||
state = new State(maximumMutationWeight);
|
||||
}
|
||||
|
||||
long estimatedSize() {
|
||||
plans.cleanUp();
|
||||
return plans.estimatedSize();
|
||||
State current = state;
|
||||
current.plans.cleanUp();
|
||||
return current.plans.estimatedSize();
|
||||
}
|
||||
|
||||
private static final class State {
|
||||
private final Cache<Long, ObjectSourcePlan> plans;
|
||||
private final ConcurrentHashMap<Long, Pending> pending = new ConcurrentHashMap<>();
|
||||
|
||||
private State(long maximumMutationWeight) {
|
||||
plans = Caffeine.newBuilder()
|
||||
.maximumWeight(maximumMutationWeight)
|
||||
.weigher((Long key, ObjectSourcePlan plan) -> plan.mutationWeight())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Pending {
|
||||
private final Thread owner;
|
||||
private final CompletableFuture<ObjectSourcePlan> result = new CompletableFuture<>();
|
||||
private Throwable failure;
|
||||
|
||||
private Pending(Thread owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
private ObjectSourcePlan await() {
|
||||
if (owner == Thread.currentThread()) {
|
||||
throw new IllegalStateException("Recursive source plan construction");
|
||||
}
|
||||
try {
|
||||
return result.join();
|
||||
} catch (CompletionException completion) {
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
throw completion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import art.arcane.volmlib.util.matter.MatterSlice;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.ArrayList;
|
||||
@@ -117,6 +118,7 @@ public final class TerrainMatterView {
|
||||
}
|
||||
|
||||
private static <T> T raw(Matter matter, int x, int y, int z, Class<T> type) {
|
||||
return !matter.hasSlice(type) ? null : matter.<T>getSlice(type).get(x & 15, y & 15, z & 15);
|
||||
MatterSlice<T> slice = matter.getSlice(type);
|
||||
return slice == null ? null : slice.get(x & 15, y & 15, z & 15);
|
||||
}
|
||||
}
|
||||
|
||||
+132
-82
@@ -35,6 +35,8 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Iterator;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
@@ -52,6 +54,7 @@ final class EngineShutdownSequence {
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
private final Set<EngineTarget> incompleteTargets = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
private final Map<GenerationRuntime, CompletableFuture<Throwable>> detachedRuntimeCloses = new IdentityHashMap<>();
|
||||
private boolean runtimeReleased;
|
||||
private boolean worldManagerStopped;
|
||||
private boolean targetReleased;
|
||||
@@ -66,7 +69,7 @@ final class EngineShutdownSequence {
|
||||
generationAdmissions.add(history.retainRuntime());
|
||||
}
|
||||
|
||||
void close() {
|
||||
synchronized void close() {
|
||||
if (!engine.beginShutdown()) {
|
||||
return;
|
||||
}
|
||||
@@ -91,91 +94,93 @@ final class EngineShutdownSequence {
|
||||
}
|
||||
engine.lifecycleState = LifecycleState.CLOSING;
|
||||
engine.getClosing().set(true);
|
||||
engine.backgroundTasks.closeBackgroundTaskAdmission();
|
||||
EngineTickRegistry.unregisterTicking(engine);
|
||||
// Best-effort like every other step: a pregen join timeout must not escape the
|
||||
// synchronized block before anything is saved or released. On failure, save what
|
||||
// can be saved and leave the close incomplete-but-retryable (a live pregen writer
|
||||
// may still hold the mantle, so the releases are skipped).
|
||||
Throwable pregenFailure = runCleanup(null, () -> engine.getPlatformHooks().shutdownPregenerator(engine));
|
||||
if (pregenFailure != null) {
|
||||
engine.lifecycleState = LifecycleState.FAILED;
|
||||
failure = pregenFailure;
|
||||
failure = runCleanup(failure, engine::savePrefetchOnce);
|
||||
failure = runCleanup(failure, engine::saveEngineData);
|
||||
failure = runCleanup(failure, () -> engine.getMantle().saveAllNow());
|
||||
reportIncompleteClose(failure);
|
||||
return;
|
||||
}
|
||||
Throwable drainFailure = null;
|
||||
}
|
||||
engine.backgroundTasks.closeBackgroundTaskAdmission();
|
||||
EngineTickRegistry.unregisterTicking(engine);
|
||||
// Best-effort like every other step: a pregen join timeout must not escape the
|
||||
// shutdown before anything is saved or released. On failure, save what
|
||||
// can be saved and leave the close incomplete-but-retryable (a live pregen writer
|
||||
// may still hold the mantle, so the releases are skipped).
|
||||
Throwable pregenFailure = runCleanup(null, () -> engine.getPlatformHooks().shutdownPregenerator(engine));
|
||||
if (pregenFailure != null) {
|
||||
engine.lifecycleState = LifecycleState.FAILED;
|
||||
failure = pregenFailure;
|
||||
failure = runCleanup(failure, engine::savePrefetchOnce);
|
||||
failure = runCleanup(failure, engine::saveEngineData);
|
||||
failure = runCleanup(failure, () -> engine.getMantle().saveAllNow());
|
||||
reportIncompleteClose(failure);
|
||||
return;
|
||||
}
|
||||
Throwable drainFailure = null;
|
||||
try {
|
||||
engine.getGenerationSessions().sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true);
|
||||
} catch (GenerationSessionException e) {
|
||||
drainFailure = e;
|
||||
}
|
||||
if (drainFailure != null) {
|
||||
// A drain timeout must not abandon teardown: give remaining admitted work
|
||||
// one final bounded drain before deciding whether resources can be released.
|
||||
IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name()
|
||||
+ "; waiting briefly for remaining work.");
|
||||
try {
|
||||
engine.getGenerationSessions().sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true);
|
||||
engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true);
|
||||
drainFailure = null;
|
||||
} catch (GenerationSessionException e) {
|
||||
drainFailure = e;
|
||||
drainFailure = appendFailure(drainFailure, e);
|
||||
}
|
||||
if (drainFailure != null) {
|
||||
// A drain timeout must not abandon teardown: give remaining admitted work
|
||||
// one final bounded drain before deciding whether resources can be released.
|
||||
IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name()
|
||||
+ "; waiting briefly for remaining work.");
|
||||
try {
|
||||
engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true);
|
||||
drainFailure = null;
|
||||
} catch (GenerationSessionException e) {
|
||||
drainFailure = appendFailure(drainFailure, e);
|
||||
}
|
||||
if (drainFailure != null) {
|
||||
// A live lease may be mid-write, so the mantle must not be closed at it — but
|
||||
// dirty plates can still be flushed (saveAll is synchronized) so terrain since
|
||||
// the last periodic save is not lost. The close stays incomplete and retryable.
|
||||
engine.lifecycleState = LifecycleState.FAILED;
|
||||
failure = runCleanup(drainFailure, () -> engine.getMantle().saveAllNow());
|
||||
reportIncompleteClose(failure);
|
||||
return;
|
||||
}
|
||||
|
||||
BackgroundTaskDrain backgroundDrain = engine.backgroundTasks.drainBackgroundTasks("close");
|
||||
failure = backgroundDrain.failure();
|
||||
if (!backgroundDrain.allowsResourceRelease() && failure == null) {
|
||||
failure = new IllegalStateException("Iris background tasks remain active during close.");
|
||||
}
|
||||
|
||||
if (backgroundDrain.allowsResourceRelease()) {
|
||||
Throwable ownershipFailure = runCleanup(null,
|
||||
() -> NativeStructureOwnershipStore.close(engine));
|
||||
failure = appendFailure(failure, ownershipFailure);
|
||||
if (ownershipFailure == null) {
|
||||
Throwable prefetchFailure = runCleanup(null, engine::savePrefetchOnce);
|
||||
Throwable engineDataFailure = runCleanup(null, engine::saveEngineData);
|
||||
failure = appendFailure(failure, prefetchFailure);
|
||||
failure = appendFailure(failure, engineDataFailure);
|
||||
failure = releaseRuntime(failure);
|
||||
if (runtimeReleased) {
|
||||
failure = releaseTarget(failure);
|
||||
}
|
||||
if (prefetchFailure == null
|
||||
&& engineDataFailure == null
|
||||
&& runtimeReleased
|
||||
&& targetReleased) {
|
||||
failure = releaseEngineDataForShutdown(failure);
|
||||
}
|
||||
if (engineDataReleased) {
|
||||
failure = releasePreservation(failure);
|
||||
}
|
||||
}
|
||||
if (drainFailure != null) {
|
||||
// A live lease may be mid-write, so the mantle must not be closed at it — but
|
||||
// dirty plates can still be flushed (saveAll is synchronized) so terrain since
|
||||
// the last periodic save is not lost. The close stays incomplete and retryable.
|
||||
engine.lifecycleState = LifecycleState.FAILED;
|
||||
failure = runCleanup(drainFailure, () -> engine.getMantle().saveAllNow());
|
||||
reportIncompleteClose(failure);
|
||||
return;
|
||||
}
|
||||
|
||||
BackgroundTaskDrain backgroundDrain = engine.backgroundTasks.drainBackgroundTasks("close");
|
||||
failure = backgroundDrain.failure();
|
||||
if (!backgroundDrain.allowsResourceRelease() && failure == null) {
|
||||
failure = new IllegalStateException("Iris background tasks remain active during close.");
|
||||
}
|
||||
|
||||
if (backgroundDrain.allowsResourceRelease()) {
|
||||
Throwable ownershipFailure = runCleanup(null,
|
||||
() -> NativeStructureOwnershipStore.close(engine));
|
||||
failure = appendFailure(failure, ownershipFailure);
|
||||
if (ownershipFailure == null) {
|
||||
Throwable prefetchFailure = runCleanup(null, engine::savePrefetchOnce);
|
||||
Throwable engineDataFailure = runCleanup(null, engine::saveEngineData);
|
||||
failure = appendFailure(failure, prefetchFailure);
|
||||
failure = appendFailure(failure, engineDataFailure);
|
||||
failure = releaseRuntime(failure);
|
||||
if (runtimeReleased) {
|
||||
failure = releaseTarget(failure);
|
||||
}
|
||||
if (prefetchFailure == null
|
||||
&& engineDataFailure == null
|
||||
&& runtimeReleased
|
||||
&& targetReleased) {
|
||||
failure = releaseEngineDataForShutdown(failure);
|
||||
}
|
||||
if (engineDataReleased) {
|
||||
failure = releasePreservation(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure == null
|
||||
&& runtimeReleased
|
||||
&& targetReleased
|
||||
&& engineDataReleased
|
||||
&& preservationReleased) {
|
||||
failure = releaseGenerationAdmissions(failure);
|
||||
if (failure == null) {
|
||||
}
|
||||
if (failure == null
|
||||
&& runtimeReleased
|
||||
&& targetReleased
|
||||
&& engineDataReleased
|
||||
&& preservationReleased) {
|
||||
failure = releaseGenerationAdmissions(failure);
|
||||
if (failure == null) {
|
||||
synchronized (engine.lifecycleLock) {
|
||||
engine.closed = true;
|
||||
engine.lifecycleState = LifecycleState.CLOSED;
|
||||
IrisLogging.debug("Engine Fully Shutdown!");
|
||||
}
|
||||
IrisLogging.debug("Engine Fully Shutdown!");
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
@@ -359,6 +364,38 @@ final class EngineShutdownSequence {
|
||||
return failure;
|
||||
}
|
||||
|
||||
DetachedRuntimeClose claimDetachedRuntimeClose(GenerationRuntime runtime, boolean rejectConcurrent) {
|
||||
synchronized (engine.lifecycleLock) {
|
||||
if (!engine.detachedGenerationRuntimes.contains(runtime)) {
|
||||
return new DetachedRuntimeClose(CompletableFuture.completedFuture(null), false);
|
||||
}
|
||||
CompletableFuture<Throwable> existing = detachedRuntimeCloses.get(runtime);
|
||||
if (existing != null) {
|
||||
if (rejectConcurrent) {
|
||||
throw new IllegalStateException("Detached Iris generation runtime is already retiring.");
|
||||
}
|
||||
return new DetachedRuntimeClose(existing, false);
|
||||
}
|
||||
CompletableFuture<Throwable> completion = new CompletableFuture<>();
|
||||
detachedRuntimeCloses.put(runtime, completion);
|
||||
if (rejectConcurrent) {
|
||||
engine.retiringGenerationRuntimes.add(runtime);
|
||||
}
|
||||
return new DetachedRuntimeClose(completion, true);
|
||||
}
|
||||
}
|
||||
|
||||
void finishDetachedRuntimeClose(GenerationRuntime runtime, DetachedRuntimeClose close, Throwable failure) {
|
||||
synchronized (engine.lifecycleLock) {
|
||||
if (failure == null) {
|
||||
engine.detachedGenerationRuntimes.remove(runtime);
|
||||
}
|
||||
engine.retiringGenerationRuntimes.remove(runtime);
|
||||
close.completion().complete(failure);
|
||||
detachedRuntimeCloses.remove(runtime);
|
||||
}
|
||||
}
|
||||
|
||||
Throwable closeDetachedGenerationRuntime(GenerationRuntime generationRuntime, Throwable failure) {
|
||||
IrisEngine.GenerationRuntimeBinding binding = new IrisEngine.GenerationRuntimeBinding(
|
||||
engine,
|
||||
@@ -509,15 +546,28 @@ final class EngineShutdownSequence {
|
||||
private Throwable closeDetachedGenerationRuntimes(Throwable failure) {
|
||||
GenerationRuntime[] detachedRuntimes = engine.detachedGenerationRuntimes.toArray(new GenerationRuntime[0]);
|
||||
for (GenerationRuntime detached : detachedRuntimes) {
|
||||
Throwable detachedFailure = closeDetachedGenerationRuntime(detached, null);
|
||||
failure = appendFailure(failure, detachedFailure);
|
||||
if (detachedFailure == null) {
|
||||
engine.detachedGenerationRuntimes.remove(detached);
|
||||
DetachedRuntimeClose close = claimDetachedRuntimeClose(detached, false);
|
||||
Throwable detachedFailure;
|
||||
if (close.owner()) {
|
||||
detachedFailure = null;
|
||||
try {
|
||||
detachedFailure = closeDetachedGenerationRuntime(detached, null);
|
||||
} catch (Throwable closeFailure) {
|
||||
detachedFailure = closeFailure;
|
||||
} finally {
|
||||
finishDetachedRuntimeClose(detached, close, detachedFailure);
|
||||
}
|
||||
} else {
|
||||
detachedFailure = close.completion().join();
|
||||
}
|
||||
failure = appendFailure(failure, detachedFailure);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
record DetachedRuntimeClose(CompletableFuture<Throwable> completion, boolean owner) {
|
||||
}
|
||||
|
||||
private Throwable releaseTarget(Throwable failure) {
|
||||
if (targetReleased) {
|
||||
return failure;
|
||||
|
||||
@@ -63,6 +63,7 @@ import art.arcane.iris.generation.hydrology.IrisRiverProfile;
|
||||
import art.arcane.iris.generation.noise.IrisShapedGeneratorStyle;
|
||||
import art.arcane.iris.generation.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.generation.terrain.Terrain3DRuntime;
|
||||
import art.arcane.iris.generation.terrain.ProceduralTerrainHeightSampler;
|
||||
import art.arcane.iris.generation.terrain.HydrologyBankTerrainRuntime;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
@@ -113,9 +114,9 @@ public class IrisComplex implements DataProvider {
|
||||
private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L);
|
||||
private static final int GRID_BOUNDS_CACHE_SIZE = 8192;
|
||||
private static final int STUDIO_NOISE_CACHE_SIZE = 32_768;
|
||||
private static final int TERRAIN_COLUMN_CACHE_SIZE = 65_536;
|
||||
/** One million corners: about 16 MB, roughly a 4000 by 4000 block area at the 4-block grid. */
|
||||
private static final int SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20;
|
||||
private static final int HEIGHT_BOUNDS_GRID = 4;
|
||||
/** The slope streams measure the rise across a run of this many blocks, so gradient is slope over run. */
|
||||
private static final double SLOPE_RUN = 3D;
|
||||
/** A sheer coast would let shoreMinimumWidth buy unbounded height, so the shore band stops climbing here. */
|
||||
@@ -135,6 +136,8 @@ public class IrisComplex implements DataProvider {
|
||||
};
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient ThreadLocal<GridBoundsCache> gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new);
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final int biomeBoundsSamplingStep;
|
||||
private transient volatile SharedCornerBounds sharedCornerBounds = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY);
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient IrisInterpolator[] frozenInterpolators;
|
||||
@@ -178,6 +181,8 @@ public class IrisComplex implements DataProvider {
|
||||
private ProceduralStream<Double> naturalHeightStream;
|
||||
private ProceduralStream<Double> baseTerrainHeightStream;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final ProceduralTerrainHeightSampler proceduralTerrainHeight;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient Engine terrainEngine;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private transient Terrain3DRuntime terrain3D;
|
||||
@@ -240,6 +245,7 @@ public class IrisComplex implements DataProvider {
|
||||
|
||||
IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan, boolean detached) {
|
||||
terrainEngine = engine;
|
||||
biomeBoundsSamplingStep = engine.getDimension().getBiomeBoundsSamplingStep();
|
||||
this.detached = detached;
|
||||
this.transitionGenerationPlan = transitionGenerationPlan;
|
||||
this.resolvedTerrain = new ResolvedTerrainProvider(engine);
|
||||
@@ -379,8 +385,11 @@ public class IrisComplex implements DataProvider {
|
||||
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
|
||||
return mapped == null ? biome : mapped;
|
||||
}), "imageMappedBaseBiomeStream", engine, cacheSize);
|
||||
proceduralTerrainHeight = new ProceduralTerrainHeightSampler(
|
||||
(x, z) -> getHeight(engine, x, z, engine.getSeedManager().getHeight()),
|
||||
engine.getDimension().getTerrainSamplingStep());
|
||||
baseTerrainHeightStream = GenerationStreams.cache2DDouble(ProceduralStream.of(
|
||||
(x, z) -> sampleUnblendedNaturalTerrainHeight(engine, x, z),
|
||||
this::sampleUnblendedNaturalTerrainHeight,
|
||||
Interpolated.DOUBLE
|
||||
), "baseTerrainHeightStream", engine, cacheSize);
|
||||
boolean terrain3DEnabled = false;
|
||||
@@ -395,7 +404,7 @@ public class IrisComplex implements DataProvider {
|
||||
(x, z) -> baseTerrainHeightStream.getDouble(x, z),
|
||||
this::sampleTerrain3DBiome),
|
||||
new Terrain3DRuntime.Options(engine.getSeedManager().getTerrain(), engine.getHeight(),
|
||||
fluidHeight, data, terrain3DEnabled, Math.max(4_096, cacheSize)));
|
||||
fluidHeight, data, terrain3DEnabled, TERRAIN_COLUMN_CACHE_SIZE));
|
||||
unblendedNaturalHeightStream = terrain3DEnabled
|
||||
? GenerationStreams.cache2DDouble(ProceduralStream.ofDouble(this::sampleTerrain3DHeight), "unblendedNaturalHeightStream", engine, cacheSize)
|
||||
: baseTerrainHeightStream;
|
||||
@@ -634,12 +643,11 @@ public class IrisComplex implements DataProvider {
|
||||
private double sampleNaturalTerrainHeight(Engine engine, double x, double z) {
|
||||
return terrain3D != null && terrain3D.active()
|
||||
? sampleTerrain3DHeight(x, z)
|
||||
: sampleUnblendedNaturalTerrainHeight(engine, x, z);
|
||||
: sampleUnblendedNaturalTerrainHeight(x, z);
|
||||
}
|
||||
|
||||
private double sampleTerrain3DHeight(double x, double z) {
|
||||
Terrain3DColumn column = terrain3D.column(blockCoordinate(x), blockCoordinate(z));
|
||||
return column.shaped() ? column.topY() : column.baseHeight();
|
||||
return terrain3D.height(blockCoordinate(x), blockCoordinate(z));
|
||||
}
|
||||
|
||||
private IrisBiome focusedBiomeAt(double x, double z) {
|
||||
@@ -735,8 +743,8 @@ public class IrisComplex implements DataProvider {
|
||||
return column == null ? getPlacementHeightStream().getDouble(x, z) : column.nearestSurfaceY(surfaceY);
|
||||
}
|
||||
|
||||
private double sampleUnblendedNaturalTerrainHeight(Engine engine, double x, double z) {
|
||||
double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight());
|
||||
private double sampleUnblendedNaturalTerrainHeight(double x, double z) {
|
||||
double proceduralHeight = proceduralTerrainHeight.sample(x, z);
|
||||
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
|
||||
}
|
||||
|
||||
@@ -1279,11 +1287,8 @@ public class IrisComplex implements DataProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
public double sampleProceduralTerrainHeight(Engine engine, double worldX, double worldZ) {
|
||||
if (engine == null) {
|
||||
throw new IllegalArgumentException("Engine is required to sample procedural terrain height");
|
||||
}
|
||||
return getHeight(engine, worldX, worldZ, engine.getSeedManager().getHeight());
|
||||
public double sampleProceduralTerrainHeight(double worldX, double worldZ) {
|
||||
return proceduralTerrainHeight.sample(worldX, worldZ);
|
||||
}
|
||||
|
||||
private IrisDecorator decorateFor(IrisBiome b, double x, double z, IrisDecorationPart part) {
|
||||
@@ -1439,7 +1444,7 @@ public class IrisComplex implements DataProvider {
|
||||
if (biomeBuffet != null && !biomeBuffet.cells().isEmpty()) {
|
||||
return generatorBounds.get(interpolator).get(focusedBiomeAt(x, z)).noiseBounds;
|
||||
}
|
||||
int grid = HEIGHT_BOUNDS_GRID;
|
||||
int grid = biomeBoundsSamplingStep;
|
||||
GridBoundsCache cache = gridBoundsCache.get();
|
||||
if (grid <= 1) {
|
||||
return sampleBoundsRaw(cache, engine, interpolator, generators, x, z);
|
||||
|
||||
@@ -1166,6 +1166,7 @@ public class IrisEngine implements Engine {
|
||||
public void closeDetachedGenerationRuntime(GenerationRuntimeBinding binding) {
|
||||
GenerationRuntimeBinding required = Objects.requireNonNull(binding, "detached generation runtime binding");
|
||||
GenerationRuntime retired;
|
||||
EngineShutdownSequence.DetachedRuntimeClose retirement;
|
||||
synchronized (lifecycleLock) {
|
||||
if (required.engine != this) {
|
||||
throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine.");
|
||||
@@ -1177,19 +1178,17 @@ public class IrisEngine implements Engine {
|
||||
if (!detachedGenerationRuntimes.contains(required.runtime)) {
|
||||
return;
|
||||
}
|
||||
if (!retiringGenerationRuntimes.add(required.runtime)) {
|
||||
throw new IllegalStateException("Detached Iris generation runtime is already retiring.");
|
||||
}
|
||||
retired = required.runtime;
|
||||
retirement = shutdownSequence.claimDetachedRuntimeClose(retired, true);
|
||||
}
|
||||
Throwable failure = retireGenerationRuntimeCaches(retired.cacheId(), null);
|
||||
Throwable failure = null;
|
||||
try {
|
||||
failure = retireGenerationRuntimeCaches(retired.cacheId(), null);
|
||||
failure = shutdownSequence.closeDetachedGenerationRuntime(retired, failure);
|
||||
} catch (Throwable closeFailure) {
|
||||
failure = EngineShutdownSequence.appendFailure(failure, closeFailure);
|
||||
} finally {
|
||||
synchronized (lifecycleLock) {
|
||||
detachedGenerationRuntimes.remove(retired);
|
||||
retiringGenerationRuntimes.remove(retired);
|
||||
}
|
||||
shutdownSequence.finishDetachedRuntimeClose(retired, retirement, failure);
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new IllegalStateException("Failed to close a detached Iris generation runtime.", failure);
|
||||
|
||||
@@ -24,6 +24,10 @@ import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.iris.generation.runtime.UpperDimensionContext;
|
||||
import art.arcane.iris.generation.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.iris.generation.runtime.EngineAssignedModifier;
|
||||
import art.arcane.iris.generation.mantle.TerrainMatterView;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
@@ -37,6 +41,7 @@ import art.arcane.iris.generation.hydrology.IrisRiverMaterialConfig;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.generation.context.ChunkContext;
|
||||
import art.arcane.iris.generation.context.IrisContext;
|
||||
import art.arcane.iris.generation.block.B;
|
||||
import art.arcane.volmlib.util.documentation.ChunkCoordinates;
|
||||
import art.arcane.volmlib.util.hunk.Hunk;
|
||||
@@ -83,7 +88,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
public void onModify(int x, int z, Hunk<NativeBlockState> output, boolean multicore, ChunkContext context) {
|
||||
PrecisionStopwatch caveStopwatch = PrecisionStopwatch.start();
|
||||
Mantle<Matter> mantle = getEngine().getMantle().getMantle();
|
||||
IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State();
|
||||
CaveInputs resolverState = new CaveInputs(getEngine());
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache = new Long2ObjectOpenHashMap<>(2048);
|
||||
IrisCarveScratch scratch = SCRATCH.get();
|
||||
scratch.reset();
|
||||
@@ -97,7 +102,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
int[] upperSurfaceHeights = protectUpper ? scratch.getOrCreateUpperSurfaceHeights() : null;
|
||||
DimensionStackContext stackContext = getEngine().getDimensionStackContext();
|
||||
DimensionStackLayout[] stackLayouts = stackContext == null ? null : new DimensionStackLayout[256];
|
||||
Terrain3DColumn[] terrainColumns = getComplex().hasTerrain3D() ? new Terrain3DColumn[256] : null;
|
||||
Terrain3DColumn[] terrainColumns = resolverState.complex.hasTerrain3D() ? new Terrain3DColumn[256] : null;
|
||||
int chunkBlockX = PowerOfTwoCoordinates.chunkToBlock(x);
|
||||
int chunkBlockZ = PowerOfTwoCoordinates.chunkToBlock(z);
|
||||
for (int columnIndex = 0; columnIndex < 256; columnIndex++) {
|
||||
@@ -105,7 +110,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
int localZ = columnIndex & 15;
|
||||
surfaceHeights[columnIndex] = context.getRoundedHeight(localX, localZ);
|
||||
if (terrainColumns != null) {
|
||||
terrainColumns[columnIndex] = getComplex().terrainColumn(chunkBlockX + localX, chunkBlockZ + localZ);
|
||||
terrainColumns[columnIndex] = resolverState.complex.terrainColumn(chunkBlockX + localX, chunkBlockZ + localZ);
|
||||
}
|
||||
if (protectUpper) {
|
||||
int worldX = localX + chunkBlockX;
|
||||
@@ -594,7 +599,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
int columnIndex,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
IrisDimensionCarvingResolver.State resolverState,
|
||||
CaveInputs resolverState,
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache,
|
||||
Map<String, IrisBiome> customBiomeCache
|
||||
) {
|
||||
@@ -617,12 +622,12 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
int y = firstHeight;
|
||||
|
||||
while (y >= 0) {
|
||||
if (y <= getEngine().getHeight()) {
|
||||
if (y <= resolverState.height) {
|
||||
if (y == buf + 1) {
|
||||
buf = y;
|
||||
zone.ceiling = buf;
|
||||
} else {
|
||||
if (zone.isValid(getEngine())) {
|
||||
if (zone.isValid(resolverState.height)) {
|
||||
processZone(output, mc, mantle, zone, rx, rz, worldX, worldZ, resolverState,
|
||||
caveBiomeCache, customBiomeCache);
|
||||
}
|
||||
@@ -635,7 +640,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
y = columnMask.nextSetBit(y + 1);
|
||||
}
|
||||
|
||||
if (zone.isValid(getEngine())) {
|
||||
if (zone.isValid(resolverState.height)) {
|
||||
processZone(output, mc, mantle, zone, rx, rz, worldX, worldZ, resolverState,
|
||||
caveBiomeCache, customBiomeCache);
|
||||
}
|
||||
@@ -649,7 +654,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
int columnIndex,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
IrisDimensionCarvingResolver.State resolverState,
|
||||
CaveInputs resolverState,
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache,
|
||||
Map<String, IrisBiome> customBiomeCache
|
||||
) {
|
||||
@@ -692,7 +697,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
int worldZ,
|
||||
int zoneFloor,
|
||||
int zoneCeiling,
|
||||
IrisDimensionCarvingResolver.State resolverState,
|
||||
CaveInputs resolverState,
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache,
|
||||
Map<String, IrisBiome> customBiomeCache
|
||||
) {
|
||||
@@ -711,7 +716,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
mantleChunk, rx, zoneFloor, rz, HydrologyCaveCell.class);
|
||||
IrisRiverMaterialConfig bedMaterial = undergroundBedMaterial();
|
||||
KList<NativeBlockState> floorLayers = floorBiome.generateLayers(
|
||||
getDimension(), worldX, worldZ, rng, 3, zoneFloor, getData(), getComplex());
|
||||
resolverState.dimension, worldX, worldZ, rng, 3, zoneFloor, resolverState.data, resolverState.complex);
|
||||
for (int i = 0; i < zoneFloor - 1; i++) {
|
||||
if (!floorLayers.hasIndex(i)) {
|
||||
break;
|
||||
@@ -732,7 +737,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
output, rx, floorY, rz,
|
||||
paintUndergroundBedMaterial(
|
||||
floorLayers.get(i), bedMaterial, floorHydrology, i,
|
||||
rng, worldX, floorY, worldZ, getData()),
|
||||
rng, worldX, floorY, worldZ, resolverState.data),
|
||||
floorHydrology);
|
||||
if (!B.isSolid(existing)
|
||||
|| !canReplaceHydrologyGuard(hydrology, layer, false)
|
||||
@@ -750,7 +755,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
if (ceilingBiome != null) {
|
||||
int worldMaxY = getEngine().getWorld().maxHeight() - getEngine().getWorld().minHeight();
|
||||
KList<NativeBlockState> ceilingLayers = ceilingBiome.generateCeilingLayers(
|
||||
getDimension(), worldX, worldZ, rng, 3, zoneCeiling, getData(), getComplex());
|
||||
resolverState.dimension, worldX, worldZ, rng, 3, zoneCeiling, resolverState.data, resolverState.complex);
|
||||
for (int i = 0; i < ceilingLayers.size(); i++) {
|
||||
int ceilingY = zoneCeiling + i + 1;
|
||||
if (ceilingY >= worldMaxY) {
|
||||
@@ -794,7 +799,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
|
||||
private void processZone(Hunk<NativeBlockState> output, MantleChunk<Matter> mc, Mantle<Matter> mantle,
|
||||
CaveZone zone, int rx, int rz, int xx, int zz,
|
||||
IrisDimensionCarvingResolver.State resolverState,
|
||||
CaveInputs resolverState,
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache,
|
||||
Map<String, IrisBiome> customBiomeCache) {
|
||||
int maxY = output.getHeight();
|
||||
@@ -818,7 +823,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
HydrologyCaveCell floorHydrology = dataIfPresent(
|
||||
mc, rx, zone.floor, rz, HydrologyCaveCell.class);
|
||||
IrisRiverMaterialConfig bedMaterial = undergroundBedMaterial();
|
||||
KList<NativeBlockState> floorBlocks = floorBiome.generateLayers(getDimension(), xx, zz, rng, 3, zone.floor, getData(), getComplex());
|
||||
KList<NativeBlockState> floorBlocks = floorBiome.generateLayers(resolverState.dimension, xx, zz, rng, 3, zone.floor, resolverState.data, resolverState.complex);
|
||||
for (int i = 0; i < zone.floor - 1; i++) {
|
||||
if (!floorBlocks.hasIndex(i)) {
|
||||
break;
|
||||
@@ -834,7 +839,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
output, rx, y, rz,
|
||||
paintUndergroundBedMaterial(
|
||||
floorBlocks.get(i), bedMaterial, floorHydrology, i,
|
||||
rng, xx, y, zz, getData()),
|
||||
rng, xx, y, zz, resolverState.data),
|
||||
floorHydrology);
|
||||
NativeBlockState existing = output.getRaw(rx, y, rz);
|
||||
if (!B.isSolid(existing)
|
||||
@@ -851,7 +856,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
}
|
||||
|
||||
if (ceilingBiome != null) {
|
||||
KList<NativeBlockState> ceilingBlocks = ceilingBiome.generateCeilingLayers(getDimension(), xx, zz, rng, 3, zone.ceiling, getData(), getComplex());
|
||||
KList<NativeBlockState> ceilingBlocks = ceilingBiome.generateCeilingLayers(resolverState.dimension, xx, zz, rng, 3, zone.ceiling, resolverState.data, resolverState.complex);
|
||||
for (int i = 0; i < ceilingBlocks.size(); i++) {
|
||||
int cy = zone.ceiling + i + 1;
|
||||
if (cy >= maxY) {
|
||||
@@ -882,7 +887,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
public void decorateNaturalCaves(int blockX, int blockZ, Hunk<NativeBlockState> output) {
|
||||
Mantle<Matter> mantle = getEngine().getMantle().getMantle();
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(blockX >> 4, blockZ >> 4).use();
|
||||
IrisDimensionCarvingResolver.State resolver = new IrisDimensionCarvingResolver.State();
|
||||
CaveInputs resolver = new CaveInputs(getEngine());
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomes = new Long2ObjectOpenHashMap<>(256);
|
||||
Map<String, IrisBiome> customBiomes = new HashMap<>();
|
||||
try {
|
||||
@@ -901,7 +906,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
CaveZone zone = new CaveZone();
|
||||
zone.setFloor(floor);
|
||||
zone.setCeiling(y - 1);
|
||||
if (zone.isValid(getEngine())
|
||||
if (zone.isValid(resolver.height)
|
||||
&& !getComplex().isTerrain3DOpening(worldX, floor, worldZ)) {
|
||||
if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) {
|
||||
mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING);
|
||||
@@ -985,15 +990,15 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
}
|
||||
}
|
||||
|
||||
IrisBiome resolveCaveBoundaryBiome(MantleChunk<Matter> mantleChunk, int x, int y, int z, int worldX, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, Map<String, IrisBiome> customBiomeCache) {
|
||||
IrisBiome resolveCaveBoundaryBiome(MantleChunk<Matter> mantleChunk, int x, int y, int z, int worldX, int worldZ, CaveInputs resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, Map<String, IrisBiome> customBiomeCache) {
|
||||
MatterCavern cavern = composedCavernAt(mantleChunk, x, y, z);
|
||||
return resolveCaveBoundaryBiome(
|
||||
cavern, worldX, y, worldZ, resolverState, caveBiomeCache, customBiomeCache);
|
||||
}
|
||||
|
||||
IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, Map<String, IrisBiome> customBiomeCache) {
|
||||
if (getComplex().isTerrain3DOpening(worldX, y, worldZ)) {
|
||||
return getComplex().getTrueBiomeStream().get(worldX, worldZ);
|
||||
IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, CaveInputs resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, Map<String, IrisBiome> customBiomeCache) {
|
||||
if (resolverState.complex.isTerrain3DOpening(worldX, y, worldZ)) {
|
||||
return resolverState.complex.getTrueBiomeStream().get(worldX, worldZ);
|
||||
}
|
||||
if (cavern != null && !cavern.getCustomBiome().isEmpty()) {
|
||||
return resolveCustomBiome(customBiomeCache, cavern.getCustomBiome());
|
||||
@@ -1105,7 +1110,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
return IrisProceduralBlocks.isGravityAffected(state);
|
||||
}
|
||||
|
||||
private IrisBiome resolveCaveBiome(Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) {
|
||||
private IrisBiome resolveCaveBiome(Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, int x, int y, int z, CaveInputs resolverState) {
|
||||
IrisBiome center = sampleCaveBiome(caveBiomeCache, x, y, z, resolverState);
|
||||
if (center == null) {
|
||||
return null;
|
||||
@@ -1125,14 +1130,14 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
return neighbor != null ? neighbor : center;
|
||||
}
|
||||
|
||||
private IrisBiome sampleCaveBiome(Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) {
|
||||
private IrisBiome sampleCaveBiome(Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, int x, int y, int z, CaveInputs resolverState) {
|
||||
long key = BlockPosition.toLong(x, y, z);
|
||||
IrisBiome cachedBiome = caveBiomeCache.get(key);
|
||||
if (cachedBiome != null) {
|
||||
return cachedBiome;
|
||||
}
|
||||
|
||||
IrisBiome resolvedBiome = getEngine().getCaveBiome(x, y, z, resolverState);
|
||||
IrisBiome resolvedBiome = resolverState.resolve(x, y, z);
|
||||
if (resolvedBiome != null) {
|
||||
caveBiomeCache.put(key, resolvedBiome);
|
||||
}
|
||||
@@ -1149,6 +1154,79 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
return loaded;
|
||||
}
|
||||
|
||||
static final class CaveInputs {
|
||||
private final Engine engine;
|
||||
private final IrisComplex complex;
|
||||
private final IrisDimension dimension;
|
||||
private final IrisData data;
|
||||
private final int height;
|
||||
private final IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
private final IrisDimensionCarvingResolver.Snapshot snapshot;
|
||||
private final int minimumY;
|
||||
private final DimensionStackContext stack;
|
||||
private final ProceduralStream<IrisBiome> surfaceBiomes;
|
||||
private final ProceduralStream<IrisBiome> caveBiomes;
|
||||
private final ProceduralStream<Double> heights;
|
||||
private final CaveColumn[] columns;
|
||||
|
||||
CaveInputs(Engine engine) {
|
||||
this.engine = engine;
|
||||
complex = engine.getComplex();
|
||||
dimension = engine.getDimension();
|
||||
data = engine.getData();
|
||||
height = engine.getHeight();
|
||||
IrisContext context = IrisContext.get();
|
||||
boolean scoped = engine instanceof IrisEngine irisEngine
|
||||
&& irisEngine.hasGenerationRuntimeScope() && !engine.getPlatformHooks().isMainThread()
|
||||
&& context != null && context.getChunkContext() != null
|
||||
&& context.getChunkContext().getComplex() == complex
|
||||
&& context.getChunkContext().isNaturalTerrain();
|
||||
snapshot = scoped ? IrisDimensionCarvingResolver.snapshot(engine) : null;
|
||||
minimumY = scoped ? engine.getWorld().minHeight() : 0;
|
||||
stack = scoped ? engine.getDimensionStackContext() : null;
|
||||
surfaceBiomes = scoped ? complex.getTrueBiomeStream() : null;
|
||||
caveBiomes = scoped ? complex.getCaveBiomeStream() : null;
|
||||
heights = scoped ? complex.getHeightStream() : null;
|
||||
columns = scoped ? new CaveColumn[1024] : null;
|
||||
}
|
||||
|
||||
IrisBiome resolve(int x, int y, int z) {
|
||||
if (snapshot == null) {
|
||||
return engine.getCaveBiome(x, y, z, state);
|
||||
}
|
||||
IrisBiome configured = snapshot.resolveBiome(x, y + minimumY, z);
|
||||
if (configured != null) {
|
||||
return configured;
|
||||
}
|
||||
long key = ((long) x << 32) | (z & 0xffffffffL);
|
||||
int index = (x * 31 + z) & (columns.length - 1);
|
||||
CaveColumn column = columns[index];
|
||||
if (column == null || column.key != key) {
|
||||
IrisBiome surface = surfaceBiomes.get(x, z);
|
||||
IrisBiome cave = caveBiomes.get(x, z);
|
||||
int surfaceY = heights.get(x, z).intValue();
|
||||
if (cave == null || cave.getLoadKey() == null) {
|
||||
cave = surface;
|
||||
if (stack != null) {
|
||||
DimensionStackLayout.Layer layer = stack.getLayout(x, z).surfaceLayer();
|
||||
if (layer != null && layer.biome() != null) {
|
||||
cave = layer.biome();
|
||||
}
|
||||
}
|
||||
}
|
||||
column = new CaveColumn(key, surface, cave, surfaceY,
|
||||
cave == null ? 0 : Math.max(0, cave.getCaveMinDepthBelowSurface()));
|
||||
columns[index] = column;
|
||||
}
|
||||
int depth = column.surfaceY - y;
|
||||
return column.cave == null || depth <= 0 || depth < column.minimumDepth
|
||||
? column.surface : column.cave;
|
||||
}
|
||||
}
|
||||
|
||||
private record CaveColumn(long key, IrisBiome surface, IrisBiome cave, int surfaceY, int minimumDepth) {
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CaveZone {
|
||||
private int ceiling = -1;
|
||||
@@ -1158,8 +1236,8 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
||||
return (ceiling - floor) - 1;
|
||||
}
|
||||
|
||||
public boolean isValid(Engine engine) {
|
||||
return floor < ceiling && ceiling - floor >= 1 && floor >= 0 && ceiling <= engine.getHeight() && airThickness() > 0;
|
||||
public boolean isValid(int height) {
|
||||
return floor < ceiling && ceiling - floor >= 1 && floor >= 0 && ceiling <= height && airThickness() > 0;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
|
||||
+8
-5
@@ -176,10 +176,9 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
|
||||
return block == null ? fallbackSolid : block;
|
||||
}
|
||||
|
||||
private PaletteContext createPaletteContext(IrisBiome parent, IrisFloatingChildBiomes entry, IrisDimension dimension, int wx, int wz, long colSeed, int paletteDepth, IrisData data, IrisComplex complex) {
|
||||
private PaletteContext createPaletteContext(IrisBiome parent, IrisFloatingChildBiomes entry, IrisDimension dimension, int wx, int wz, int paletteDepth, IrisData data, IrisComplex complex) {
|
||||
IrisBiome target = entry == null ? parent : entry.getRealBiome(parent, data);
|
||||
int entrySeed = entry == null || entry.getBiome() == null ? 0 : entry.getBiome().hashCode();
|
||||
RNG layerRng = rng.nextParallelRNG((int) (colSeed ^ 0x7A4E ^ entrySeed));
|
||||
RNG layerRng = paletteRng(rng, entry);
|
||||
KList<NativeBlockState> topBlocks = target == null ? null : target.generateLayers(dimension, wx, wz, layerRng, paletteDepth, paletteDepth, data, complex);
|
||||
if (topBlocks == null || topBlocks.isEmpty()) {
|
||||
topBlocks = parent.generateLayers(dimension, wx, wz, layerRng, paletteDepth, paletteDepth, data, complex);
|
||||
@@ -188,6 +187,11 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
|
||||
return new PaletteContext(topBlocks, bottomBlocks, B.getState("minecraft:stone"));
|
||||
}
|
||||
|
||||
static RNG paletteRng(RNG worldRng, IrisFloatingChildBiomes entry) {
|
||||
int entrySeed = entry == null || entry.getBiome() == null ? 0 : entry.getBiome().hashCode();
|
||||
return worldRng.nextParallelRNG(0x7A4E ^ entrySeed);
|
||||
}
|
||||
|
||||
private static final class PaletteContext {
|
||||
private final KList<NativeBlockState> topBlocks;
|
||||
private final KList<NativeBlockState> bottomBlocks;
|
||||
@@ -230,7 +234,6 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
|
||||
continue;
|
||||
}
|
||||
|
||||
long colSeed = FloatingIslandSample.columnSeed(baseSeed, wx, wz);
|
||||
int paletteDepth = Math.max(4, sample.solidCount + 4);
|
||||
IdentityHashMap<IrisFloatingChildBiomes, PaletteContext> paletteContexts = new IdentityHashMap<>();
|
||||
IdentityHashMap<IrisFloatingChildBiomes, Integer> topDepthByEntry = new IdentityHashMap<>();
|
||||
@@ -246,7 +249,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
|
||||
IrisFloatingChildBiomes entry = sample.entryAt(k);
|
||||
PaletteContext paletteContext = paletteContexts.get(entry);
|
||||
if (paletteContext == null) {
|
||||
paletteContext = createPaletteContext(parent, entry, dimension, wx, wz, colSeed, paletteDepth, data, complex);
|
||||
paletteContext = createPaletteContext(parent, entry, dimension, wx, wz, paletteDepth, data, complex);
|
||||
paletteContexts.put(entry, paletteContext);
|
||||
}
|
||||
int depth = topDepthByEntry.getOrDefault(entry, 0);
|
||||
|
||||
@@ -71,6 +71,7 @@ import art.arcane.iris.pack.schema.annotation.ArrayType;
|
||||
import art.arcane.volmlib.util.documentation.Description;
|
||||
import art.arcane.iris.pack.schema.annotation.MaxNumber;
|
||||
import art.arcane.iris.pack.schema.annotation.MinNumber;
|
||||
import art.arcane.iris.pack.schema.annotation.IntegerOptions;
|
||||
import art.arcane.iris.pack.schema.annotation.RegistryListFunction;
|
||||
import art.arcane.iris.pack.schema.annotation.RegistryListResource;
|
||||
import art.arcane.iris.pack.schema.annotation.Required;
|
||||
@@ -278,6 +279,21 @@ public class IrisDimension extends IrisRegistrant {
|
||||
@MaxNumber(512)
|
||||
@Description("Zoom in or out the biome size. Higher = bigger biomes")
|
||||
private double biomeZoom = 1D;
|
||||
@MinNumber(1)
|
||||
@MaxNumber(8)
|
||||
@IntegerOptions({1, 2, 4, 8})
|
||||
@Description("Procedural terrain height sampling interval in blocks: 1, 2, 4, or 8. 1 retains full detail. Larger intervals interpolate between shared samples, reducing generation work and smoothing small height features. Image maps retain their configured sampling. Set before creating a world.")
|
||||
private int terrainSamplingStep = 1;
|
||||
@MinNumber(4)
|
||||
@MaxNumber(32)
|
||||
@IntegerOptions({4, 8, 16, 32})
|
||||
@Description("Biome height-bound sampling interval in blocks: 4, 8, 16, or 32. 4 retains the original biome blending. Larger intervals interpolate the configured generator bounds between shared anchors, smoothing small biome-height features. Generator interpolation styles and radii remain active at the anchors. Set before creating a world.")
|
||||
private int biomeBoundsSamplingStep = 4;
|
||||
@MinNumber(1)
|
||||
@MaxNumber(8)
|
||||
@IntegerOptions({1, 2, 4, 8})
|
||||
@Description("Vertical cave density sampling interval in blocks: 1, 2, 4, or 8. Larger intervals interpolate density while retaining cave module bounds, surface protection, and fluid rules. Changes cave geometry. Set before creating a world.")
|
||||
private int caveDensitySamplingStep = 1;
|
||||
@MinNumber(0)
|
||||
@MaxNumber(360)
|
||||
@Description("You can rotate the input coordinates by an angle. This can make terrain appear more natural (less sharp corners and lines). This literally rotates the entire dimension by an angle. Hint: Try 12 degrees or something not on a 90 or 45 degree angle.")
|
||||
@@ -513,6 +529,14 @@ public class IrisDimension extends IrisRegistrant {
|
||||
});
|
||||
}
|
||||
|
||||
public int getBiomeBoundsSamplingStep() {
|
||||
if (biomeBoundsSamplingStep != 4 && biomeBoundsSamplingStep != 8
|
||||
&& biomeBoundsSamplingStep != 16 && biomeBoundsSamplingStep != 32) {
|
||||
throw new IllegalArgumentException("Biome bounds sampling step must be 4, 8, 16, or 32");
|
||||
}
|
||||
return biomeBoundsSamplingStep;
|
||||
}
|
||||
|
||||
public int getFluidHeight() {
|
||||
return fluidHeight - (int) dimensionHeight.getMin();
|
||||
}
|
||||
|
||||
+69
-1
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.generation.terrain;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.noise.IrisGeneratorStyle;
|
||||
import art.arcane.iris.pack.value.IrisRange;
|
||||
import art.arcane.iris.pack.value.IrisRaritySelection;
|
||||
|
||||
@@ -28,6 +29,10 @@ public final class IrisDimensionCarvingResolver {
|
||||
|
||||
}
|
||||
|
||||
public static Snapshot snapshot(Engine engine) {
|
||||
return new Snapshot(engine);
|
||||
}
|
||||
|
||||
public static IrisBiome resolveBiome(Engine engine, int worldX, int worldY, int worldZ, State state) {
|
||||
State resolvedState = state == null ? threadState() : state;
|
||||
IrisData data = resolvedState.bind(engine);
|
||||
@@ -80,7 +85,10 @@ public final class IrisDimensionCarvingResolver {
|
||||
return resolvedState.rootEntriesByWorldY.get(worldY);
|
||||
}
|
||||
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
return selectBoundRootEntry(engine.getDimension(), worldY, data, resolvedState);
|
||||
}
|
||||
|
||||
private static IrisDimensionCarvingEntry selectBoundRootEntry(IrisDimension dimension, int worldY, IrisData data, State resolvedState) {
|
||||
List<IrisDimensionCarvingEntry> entries = dimension.getCarving();
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
resolvedState.rootEntriesByWorldY.put(worldY, null);
|
||||
@@ -115,10 +123,19 @@ public final class IrisDimensionCarvingResolver {
|
||||
return rootEntry;
|
||||
}
|
||||
|
||||
ColumnResolutions columns = resolvedState.columns;
|
||||
int slot = ((worldX & 31) << 5) | (worldZ & 31);
|
||||
long coordinate = ((long) worldX << 32) | (worldZ & 0xffffffffL);
|
||||
if (columns != null && columns.roots[slot] == rootEntry && columns.coordinates[slot] == coordinate) {
|
||||
return columns.entries[slot];
|
||||
}
|
||||
|
||||
Map<String, IrisDimensionCarvingEntry> entryIndex = resolveEntryIndex(engine, resolvedState);
|
||||
IrisDimensionCarvingEntry current = rootEntry;
|
||||
int depth = remainingDepth;
|
||||
boolean cacheable = true;
|
||||
while (depth > 0) {
|
||||
cacheable &= fixedChildStyle(current);
|
||||
IrisDimensionCarvingEntry selected = selectChild(engine, data, current, worldX, worldZ, entryIndex, resolvedState);
|
||||
if (selected == null || selected == current) {
|
||||
break;
|
||||
@@ -132,9 +149,27 @@ public final class IrisDimensionCarvingResolver {
|
||||
current = selected;
|
||||
}
|
||||
|
||||
if (cacheable) {
|
||||
if (columns == null) {
|
||||
columns = new ColumnResolutions();
|
||||
resolvedState.columns = columns;
|
||||
}
|
||||
columns.coordinates[slot] = coordinate;
|
||||
columns.entries[slot] = current;
|
||||
columns.roots[slot] = rootEntry;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static boolean fixedChildStyle(IrisDimensionCarvingEntry entry) {
|
||||
for (IrisGeneratorStyle style = entry.getChildStyle(); style != null; style = style.getFracture()) {
|
||||
if (style.getExpression() != null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IrisBiome resolveBoundEntryBiome(IrisData data, IrisDimensionCarvingEntry entry, State state) {
|
||||
if (state.biomeCache.containsKey(entry)) {
|
||||
return state.biomeCache.get(entry);
|
||||
@@ -275,6 +310,31 @@ public final class IrisDimensionCarvingResolver {
|
||||
return replacement;
|
||||
}
|
||||
|
||||
public static final class Snapshot {
|
||||
private final Engine engine;
|
||||
private final State state = new State();
|
||||
private final IrisDimension dimension;
|
||||
private final IrisData data;
|
||||
|
||||
private Snapshot(Engine engine) {
|
||||
this.engine = engine;
|
||||
this.dimension = engine.getDimension();
|
||||
this.data = engine.getData();
|
||||
state.entryIndex = dimension.getCarvingEntryIndex();
|
||||
}
|
||||
|
||||
public IrisBiome resolveBiome(int worldX, int worldY, int worldZ) {
|
||||
IrisDimensionCarvingEntry root = state.rootEntriesByWorldY.containsKey(worldY)
|
||||
? state.rootEntriesByWorldY.get(worldY)
|
||||
: selectBoundRootEntry(dimension, worldY, data, state);
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldZ, data, state);
|
||||
return resolved == null ? null : resolveBoundEntryBiome(data, resolved, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class State {
|
||||
private final Map<Integer, IrisDimensionCarvingEntry> rootEntriesByWorldY = new HashMap<>();
|
||||
private final Map<IrisDimensionCarvingEntry, IrisRaritySelection<CarvingChoice>> selectionPlans = new IdentityHashMap<>();
|
||||
@@ -284,6 +344,7 @@ public final class IrisDimensionCarvingResolver {
|
||||
private WeakReference<IrisData> dataIdentity;
|
||||
private Map<String, IrisDimensionCarvingEntry> entryIndex;
|
||||
private Long childSeed;
|
||||
private ColumnResolutions columns;
|
||||
|
||||
private IrisData bind(Engine engine) {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
@@ -301,6 +362,7 @@ public final class IrisDimensionCarvingResolver {
|
||||
biomeCache.clear();
|
||||
entryIndex = null;
|
||||
childSeed = null;
|
||||
columns = null;
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -309,6 +371,12 @@ public final class IrisDimensionCarvingResolver {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ColumnResolutions {
|
||||
private final long[] coordinates = new long[1024];
|
||||
private final IrisDimensionCarvingEntry[] roots = new IrisDimensionCarvingEntry[1024];
|
||||
private final IrisDimensionCarvingEntry[] entries = new IrisDimensionCarvingEntry[1024];
|
||||
}
|
||||
|
||||
private static final class CarvingChoice implements Rarity {
|
||||
private final IrisDimensionCarvingEntry entry;
|
||||
private final int rarity;
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package art.arcane.iris.generation.terrain;
|
||||
|
||||
import art.arcane.volmlib.util.cache.CacheKey;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReferenceArray;
|
||||
import java.util.function.DoubleBinaryOperator;
|
||||
|
||||
public final class ProceduralTerrainHeightSampler {
|
||||
private static final int MAXIMUM_NODES = 262_144;
|
||||
|
||||
private final DoubleBinaryOperator source;
|
||||
private final int step;
|
||||
private final AtomicReferenceArray<Node> nodes;
|
||||
|
||||
public ProceduralTerrainHeightSampler(DoubleBinaryOperator source, int step) {
|
||||
if (step != 1 && step != 2 && step != 4 && step != 8) {
|
||||
throw new IllegalArgumentException("Terrain sampling step must be 1, 2, 4, or 8");
|
||||
}
|
||||
this.source = Objects.requireNonNull(source, "Procedural terrain height source");
|
||||
this.step = step;
|
||||
nodes = step == 1 ? null : new AtomicReferenceArray<>(MAXIMUM_NODES);
|
||||
}
|
||||
|
||||
public double sample(double x, double z) {
|
||||
if (step == 1) {
|
||||
return source.applyAsDouble(x, z);
|
||||
}
|
||||
double lowerX = Math.floor(x / step) * step;
|
||||
double lowerZ = Math.floor(z / step) * step;
|
||||
double dx = (x - lowerX) / step;
|
||||
double dz = (z - lowerZ) / step;
|
||||
double northWest = node(lowerX, lowerZ);
|
||||
double north = dx == 0D ? northWest
|
||||
: northWest + (node(lowerX + step, lowerZ) - northWest) * dx;
|
||||
if (dz == 0D) {
|
||||
return north;
|
||||
}
|
||||
double southWest = node(lowerX, lowerZ + step);
|
||||
double south = dx == 0D ? southWest
|
||||
: southWest + (node(lowerX + step, lowerZ + step) - southWest) * dx;
|
||||
return north + (south - north) * dz;
|
||||
}
|
||||
|
||||
int cachedNodeCount() {
|
||||
if (nodes == null) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (int index = 0; index < nodes.length(); index++) {
|
||||
if (nodes.get(index) != null) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private double node(double x, double z) {
|
||||
long xBits = Double.doubleToLongBits(x);
|
||||
long zBits = Double.doubleToLongBits(z);
|
||||
int index = (int) CacheKey.mix(xBits ^ Long.rotateLeft(zBits, 32)) & (MAXIMUM_NODES - 1);
|
||||
Node cached = nodes.get(index);
|
||||
if (cached != null && cached.xBits() == xBits && cached.zBits() == zBits) {
|
||||
return cached.height();
|
||||
}
|
||||
double height = source.applyAsDouble(x, z);
|
||||
if (!Double.isFinite(height)) {
|
||||
throw new IllegalStateException("Nonfinite procedural terrain height at " + x + "," + z);
|
||||
}
|
||||
nodes.set(index, new Node(xBits, zBits, height));
|
||||
return height;
|
||||
}
|
||||
|
||||
private record Node(long xBits, long zBits, double height) {
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,27 @@ final class Terrain3DFragmentFilter {
|
||||
this.source = Objects.requireNonNull(source, "Raw density columns");
|
||||
}
|
||||
|
||||
double height(int x, int z) {
|
||||
DensityColumn density = source.column(x, z);
|
||||
Terrain3DColumn raw = density.raw;
|
||||
if (!raw.shaped()) {
|
||||
return raw.baseHeight();
|
||||
}
|
||||
Terrain3DColumn resolved = density.resolved;
|
||||
if (resolved != null) {
|
||||
return resolved.topY();
|
||||
}
|
||||
for (int span = raw.spanCount() - 1; span > 0; span--) {
|
||||
if (density.state(span) == UNKNOWN) {
|
||||
TRAVERSALS.get().classify(this, x, z, density, span);
|
||||
}
|
||||
if (density.state(span) == KEEP) {
|
||||
return raw.floor(span);
|
||||
}
|
||||
}
|
||||
return raw.floor(0);
|
||||
}
|
||||
|
||||
Terrain3DColumn column(int x, int z) {
|
||||
DensityColumn density = source.column(x, z);
|
||||
Terrain3DColumn resolved = density.resolved;
|
||||
|
||||
@@ -4,6 +4,7 @@ import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.noise.IrisGeneratorStyle;
|
||||
import art.arcane.volmlib.util.noise.CNG;
|
||||
import art.arcane.volmlib.util.cache.CacheKey;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||
|
||||
@@ -16,6 +17,8 @@ import java.util.concurrent.atomic.AtomicReferenceArray;
|
||||
public final class Terrain3DRuntime {
|
||||
private static final int STEP = 4;
|
||||
private static final int CACHE_STRIPES = 16;
|
||||
private static final int MAXIMUM_ANCHORS = 32_768;
|
||||
private static final long ANCHOR_CACHE_BYTES = 64L * 1024 * 1024;
|
||||
private static final long DENSITY_SALT = 0x536E4A11C924B3D7L;
|
||||
private static final long CRACK_SALT = 0x7839A16DC4052EFBL;
|
||||
private static final NodeSample EMPTY_SAMPLE = new NodeSample(0D, 0D, 0D);
|
||||
@@ -44,7 +47,7 @@ public final class Terrain3DRuntime {
|
||||
this.options = Objects.requireNonNull(options, "Terrain options");
|
||||
this.noiseFactory = Objects.requireNonNull(noiseFactory, "Terrain noise factory");
|
||||
columns = new BoundedCache<>(options.maximumColumns());
|
||||
anchors = new BoundedCache<>(Math.max(CACHE_STRIPES, options.maximumColumns() / 4));
|
||||
anchors = new BoundedCache<>(anchorCacheCapacity(options.height(), options.maximumColumns()));
|
||||
fragments = new Terrain3DFragmentFilter(this::rawColumn);
|
||||
}
|
||||
|
||||
@@ -52,6 +55,24 @@ public final class Terrain3DRuntime {
|
||||
return options.enabled();
|
||||
}
|
||||
|
||||
static int anchorCacheCapacity(int height, int maximumColumns) {
|
||||
long bytesPerAnchor = 160L + 48L * (Math.ceilDiv((long) height, STEP) + 1);
|
||||
long maximumEntries = Math.min(MAXIMUM_ANCHORS,
|
||||
Math.min((long) maximumColumns * 8, ANCHOR_CACHE_BYTES / bytesPerAnchor));
|
||||
return Math.max(CACHE_STRIPES, (int) (maximumEntries / CACHE_STRIPES) * CACHE_STRIPES);
|
||||
}
|
||||
|
||||
public double height(int x, int z) {
|
||||
if (!active()) {
|
||||
return baseHeight(x, z);
|
||||
}
|
||||
ColumnMemo memo = localColumn.get();
|
||||
if (memo.generation == cacheGeneration && memo.key == pack(x, z)) {
|
||||
return memo.column.shaped() ? memo.column.topY() : memo.column.baseHeight();
|
||||
}
|
||||
return fragments.height(x, z);
|
||||
}
|
||||
|
||||
public Terrain3DColumn column(int x, int z) {
|
||||
if (!active()) {
|
||||
return Terrain3DColumn.unshaped(baseHeight(x, z), options.height());
|
||||
@@ -80,6 +101,10 @@ public final class Terrain3DRuntime {
|
||||
localColumn.remove();
|
||||
}
|
||||
|
||||
int cachedColumnCount() {
|
||||
return columns.size();
|
||||
}
|
||||
|
||||
private Terrain3DFragmentFilter.DensityColumn rawColumn(int x, int z) {
|
||||
long key = pack(x, z);
|
||||
Terrain3DFragmentFilter.DensityColumn cached = columns.get(key);
|
||||
@@ -408,11 +433,16 @@ public final class Terrain3DRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private int size() {
|
||||
int size = 0;
|
||||
for (CacheStripe<T> stripe : stripes) {
|
||||
size += stripe.size();
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
private CacheStripe<T> stripe(long key) {
|
||||
long mixed = key ^ (key >>> 33);
|
||||
mixed *= 0xff51afd7ed558ccdL;
|
||||
mixed ^= mixed >>> 33;
|
||||
return stripes[(int) mixed & (CACHE_STRIPES - 1)];
|
||||
return stripes[(int) CacheKey.mix(key) & (CACHE_STRIPES - 1)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,5 +473,9 @@ public final class Terrain3DRuntime {
|
||||
private synchronized void clear() {
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
private synchronized int size() {
|
||||
return entries.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ final class PackDimensionValidator {
|
||||
|
||||
validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings);
|
||||
validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors);
|
||||
validateSamplingStep(dimensionKey, dimJson, "terrainSamplingStep", 1, blockingErrors);
|
||||
validateSamplingStep(dimensionKey, dimJson, "caveDensitySamplingStep", 1, blockingErrors);
|
||||
validateSamplingStep(dimensionKey, dimJson, "biomeBoundsSamplingStep", 4, blockingErrors);
|
||||
validateWorldBoundary(dimensionKey, dimJson, blockingErrors);
|
||||
validateStaticObjects(packFolder, dimensionKey, dimJson, blockingErrors);
|
||||
validateObjectScaleFactor(dimensionKey, dimJson, blockingErrors);
|
||||
@@ -109,6 +112,21 @@ final class PackDimensionValidator {
|
||||
IrisObjectScale.MAXIMUM_FACTOR, "Dimension '" + dimensionKey + "'", errors);
|
||||
}
|
||||
|
||||
private static void validateSamplingStep(String dimensionKey, JSONObject dimension, String field, int minimum, List<String> errors) {
|
||||
if (!dimension.has(field)) {
|
||||
return;
|
||||
}
|
||||
Object value = dimension.opt(field);
|
||||
if (value instanceof Number number) {
|
||||
double step = number.doubleValue();
|
||||
if (step == minimum || step == minimum * 2D || step == minimum * 4D || step == minimum * 8D) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
errors.add("Dimension '" + dimensionKey + "' " + field + " must be one of " + minimum + ", "
|
||||
+ minimum * 2 + ", " + minimum * 4 + ", or " + minimum * 8 + ".");
|
||||
}
|
||||
|
||||
static void validateDimensionStack(File packFolder, String dimensionKey, JSONObject dimension, Set<String> dimensionKeys,
|
||||
List<String> blockingErrors) {
|
||||
if (!dimension.has("dimensionStack")) {
|
||||
|
||||
@@ -22,11 +22,13 @@ import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.pack.schema.annotation.ArrayType;
|
||||
import art.arcane.iris.pack.schema.annotation.MaxNumber;
|
||||
import art.arcane.iris.pack.schema.annotation.MinNumber;
|
||||
import art.arcane.iris.pack.schema.annotation.IntegerOptions;
|
||||
import art.arcane.iris.pack.schema.annotation.RegistryMapBlockState;
|
||||
import art.arcane.iris.pack.schema.annotation.Required;
|
||||
import art.arcane.iris.pack.schema.annotation.Snippet;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
@@ -67,6 +69,13 @@ final class SchemaPropertyBuilder {
|
||||
}
|
||||
|
||||
private String integerProperty(Field k, JSONObject prop, KList<String> description) {
|
||||
if (k.isAnnotationPresent(IntegerOptions.class)) {
|
||||
JSONArray options = new JSONArray();
|
||||
for (int value : k.getDeclaredAnnotation(IntegerOptions.class).value()) {
|
||||
options.put(value);
|
||||
}
|
||||
prop.put("enum", options);
|
||||
}
|
||||
if (k.isAnnotationPresent(MinNumber.class)) {
|
||||
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
|
||||
prop.put("minimum", min);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package art.arcane.iris.pack.schema.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.FIELD)
|
||||
public @interface IntegerOptions {
|
||||
int[] value();
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import art.arcane.volmlib.nativelib.terrain.NativeBiomeRegistry;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourcePolicy;
|
||||
import java.util.function.IntUnaryOperator;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
@@ -22,7 +21,6 @@ import art.arcane.iris.platform.generation.BukkitChunkGenerator;
|
||||
import art.arcane.iris.generation.context.IrisContext;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -37,6 +35,8 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class BukkitBiomePolicy<H, V> implements NativeBiomeSourcePolicy<H> {
|
||||
private static final int NOISE_BIOME_CACHE_MAX = 262144;
|
||||
@@ -49,6 +49,7 @@ public final class BukkitBiomePolicy<H, V> implements NativeBiomeSourcePolicy<H>
|
||||
private final BukkitChunkGenerator platformGenerator;
|
||||
private final NativeBiomeRegistry<H, V> biomeCustomRegistry;
|
||||
private final NativeBiomeRegistry<H, V> biomeRegistry;
|
||||
private final Supplier<NativeBiomeRegistry<H, V>> possibleBiomeRegistry;
|
||||
private final H fallbackBiome;
|
||||
private final ConcurrentHashMap<RuntimeNoiseKey, H> noiseBiomeCache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<RuntimeNoiseKey, H> structureBiomeCache = new ConcurrentHashMap<>();
|
||||
@@ -56,13 +57,11 @@ public final class BukkitBiomePolicy<H, V> implements NativeBiomeSourcePolicy<H>
|
||||
private final ConcurrentHashMap<RuntimeColumnKey, H> naturalSurfaceStructureBiomeCache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Integer, RuntimeBiomeState<H, V>> runtimeBiomeStates = new ConcurrentHashMap<>();
|
||||
|
||||
public BukkitBiomePolicy(RuntimeOptions options, NativeBiomeRegistry<H, V> registry) {
|
||||
public BukkitBiomePolicy(RuntimeOptions<H, V> options, NativeBiomeRegistry<H, V> registry) {
|
||||
Engine engine = options.engine();
|
||||
World world = options.world();
|
||||
this.engine = engine;
|
||||
this.platformGenerator = world.getGenerator() instanceof BukkitChunkGenerator generator
|
||||
? generator
|
||||
: null;
|
||||
this.platformGenerator = options.platformGenerator();
|
||||
this.possibleBiomeRegistry = options.possibleBiomeRegistry();
|
||||
this.seed = options.seed();
|
||||
this.biomeCustomRegistry = registry;
|
||||
this.biomeRegistry = biomeCustomRegistry;
|
||||
@@ -179,12 +178,8 @@ public final class BukkitBiomePolicy<H, V> implements NativeBiomeSourcePolicy<H>
|
||||
throw new IllegalStateException("Iris possible biome lookup has no active engine runtime");
|
||||
}
|
||||
runtimeBiomeState();
|
||||
World world = BukkitWorldBinding.world(engine.getWorld());
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Iris biome source has no bound Bukkit world");
|
||||
}
|
||||
NativeBiomeRegistry<H, V> customRegistry = biomeCustomRegistry;
|
||||
NativeBiomeRegistry<H, V> worldRegistry = biomeRegistry.forWorld(world);
|
||||
NativeBiomeRegistry<H, V> worldRegistry = Objects.requireNonNull(possibleBiomeRegistry.get(), "possible biome registry");
|
||||
return Set.copyOf(getAllBiomes(
|
||||
customRegistry, worldRegistry, engine, includeDimensionStack));
|
||||
}
|
||||
@@ -1105,7 +1100,12 @@ public final class BukkitBiomePolicy<H, V> implements NativeBiomeSourcePolicy<H>
|
||||
Map<V, H> vanillaSpawnBiomes
|
||||
) {
|
||||
}
|
||||
public record RuntimeOptions(long seed, Engine engine, World world) {
|
||||
public record RuntimeOptions<H, V>(long seed, Engine engine, BukkitChunkGenerator platformGenerator,
|
||||
Supplier<NativeBiomeRegistry<H, V>> possibleBiomeRegistry) {
|
||||
public RuntimeOptions {
|
||||
Objects.requireNonNull(engine, "engine");
|
||||
Objects.requireNonNull(possibleBiomeRegistry, "possible biome registry");
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public VisibleResolver<H> visibleResolver() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.EngineLifecycleTasks;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.platform.generation.BukkitChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
|
||||
import art.arcane.iris.structure.nativegen.ImportedFeaturePolicy;
|
||||
import art.arcane.iris.structure.nativegen.IrisStructureVolumePolicy;
|
||||
@@ -50,7 +51,16 @@ public final class BukkitGeneratorContext extends BukkitGenerationPolicy impleme
|
||||
|
||||
@Override
|
||||
public <H, V> NativeBiomeSourcePolicy<H> biomes(NativeBiomeRegistry<H, V> registry) {
|
||||
return new BukkitBiomePolicy<>(new BukkitBiomePolicy.RuntimeOptions(options.seed(), current(), options.world()), registry);
|
||||
return new BukkitBiomePolicy<>(new BukkitBiomePolicy.RuntimeOptions<>(options.seed(), current(), generator,
|
||||
() -> worldBiomeRegistry(registry)), registry);
|
||||
}
|
||||
|
||||
private <H, V> NativeBiomeRegistry<H, V> worldBiomeRegistry(NativeBiomeRegistry<H, V> registry) {
|
||||
World world = BukkitWorldBinding.world(current().getWorld());
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Iris biome source has no bound Bukkit world");
|
||||
}
|
||||
return registry.forWorld(world);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
-112
@@ -18,10 +18,6 @@ import java.util.Objects;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.ForkJoinTask;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@@ -35,8 +31,6 @@ import java.util.function.Supplier;
|
||||
*/
|
||||
public final class NativeStructureVolumeIndex {
|
||||
private static final int ORIGIN_REACH_CHUNKS = StructureOwnershipRecordView.MAX_REFERENCE_DISTANCE_CHUNKS;
|
||||
private static final int ORIGIN_WINDOW_DIAMETER = ORIGIN_REACH_CHUNKS * 2 + 1;
|
||||
private static final int ORIGIN_WINDOW_STRIPE_COUNT = Long.SIZE;
|
||||
private static final int MAX_CACHED_ORIGIN_CHUNKS = 16_384;
|
||||
private static final int MAX_CACHED_QUERY_CHUNKS = 4_096;
|
||||
private static final Map<Engine, NativeStructureVolumeIndex> INDEXES =
|
||||
@@ -49,7 +43,6 @@ public final class NativeStructureVolumeIndex {
|
||||
new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<RuntimeChunkKey, CompletableFuture<KList<NativeStructureVolume>>> queryBuilds =
|
||||
new ConcurrentHashMap<>();
|
||||
private final ReentrantLock[] originWindowStripes = createOriginWindowStripes();
|
||||
private final IntConsumer retirementListener = this::evictRuntime;
|
||||
|
||||
private NativeStructureVolumeIndex(OriginResolver origins) {
|
||||
@@ -147,16 +140,6 @@ public final class NativeStructureVolumeIndex {
|
||||
}
|
||||
|
||||
private KList<NativeStructureVolume> buildChunkVolumes(Engine engine, int chunkX, int chunkZ) {
|
||||
long stripeMask = originWindowStripeMask(chunkX, chunkZ);
|
||||
lockOriginWindow(stripeMask);
|
||||
try {
|
||||
return buildChunkVolumesCoordinated(engine, chunkX, chunkZ);
|
||||
} finally {
|
||||
unlockOriginWindow(stripeMask);
|
||||
}
|
||||
}
|
||||
|
||||
private KList<NativeStructureVolume> buildChunkVolumesCoordinated(Engine engine, int chunkX, int chunkZ) {
|
||||
int minX = chunkX << 4;
|
||||
int minZ = chunkZ << 4;
|
||||
int maxX = minX + 15;
|
||||
@@ -207,20 +190,6 @@ public final class NativeStructureVolumeIndex {
|
||||
return originVolumes(engine, chunkX, chunkZ);
|
||||
}
|
||||
|
||||
static long originWindowStripeMask(int chunkX, int chunkZ) {
|
||||
int minCellX = Math.floorDiv(chunkX - ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER);
|
||||
int maxCellX = Math.floorDiv(chunkX + ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER);
|
||||
int minCellZ = Math.floorDiv(chunkZ - ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER);
|
||||
int maxCellZ = Math.floorDiv(chunkZ + ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER);
|
||||
long mask = 0L;
|
||||
for (int cellX = minCellX; cellX <= maxCellX; cellX++) {
|
||||
for (int cellZ = minCellZ; cellZ <= maxCellZ; cellZ++) {
|
||||
mask |= 1L << originWindowStripe(cellX, cellZ);
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
KList<NativeStructureVolume> originVolumes(Engine engine, int chunkX, int chunkZ) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openHistoryCoordinateScope(engine, chunkX, chunkZ)) {
|
||||
@@ -301,87 +270,6 @@ public final class NativeStructureVolumeIndex {
|
||||
return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL);
|
||||
}
|
||||
|
||||
private static int originWindowStripe(int cellX, int cellZ) {
|
||||
long mixed = chunkKey(cellX, cellZ);
|
||||
mixed ^= mixed >>> 33;
|
||||
mixed *= 0xff51afd7ed558ccdL;
|
||||
mixed ^= mixed >>> 33;
|
||||
mixed *= 0xc4ceb9fe1a85ec53L;
|
||||
mixed ^= mixed >>> 33;
|
||||
return (int) mixed & (ORIGIN_WINDOW_STRIPE_COUNT - 1);
|
||||
}
|
||||
|
||||
private static ReentrantLock[] createOriginWindowStripes() {
|
||||
ReentrantLock[] stripes = new ReentrantLock[ORIGIN_WINDOW_STRIPE_COUNT];
|
||||
for (int i = 0; i < stripes.length; i++) {
|
||||
stripes[i] = new ReentrantLock();
|
||||
}
|
||||
return stripes;
|
||||
}
|
||||
|
||||
private void lockOriginWindow(long stripeMask) {
|
||||
long pending = stripeMask;
|
||||
while (pending != 0L) {
|
||||
int stripe = Long.numberOfTrailingZeros(pending);
|
||||
lockOriginStripe(originWindowStripes[stripe]);
|
||||
pending &= pending - 1L;
|
||||
}
|
||||
}
|
||||
|
||||
private static void lockOriginStripe(ReentrantLock lock) {
|
||||
if (lock.tryLock()) {
|
||||
return;
|
||||
}
|
||||
if (!ForkJoinTask.inForkJoinPool()) {
|
||||
lock.lock();
|
||||
return;
|
||||
}
|
||||
OriginLockBlocker blocker = new OriginLockBlocker(lock);
|
||||
try {
|
||||
ForkJoinPool.managedBlock(blocker);
|
||||
} catch (InterruptedException interruption) {
|
||||
Thread.currentThread().interrupt();
|
||||
blocker.block();
|
||||
} catch (RejectedExecutionException exhaustedPool) {
|
||||
blocker.block();
|
||||
}
|
||||
}
|
||||
|
||||
private void unlockOriginWindow(long stripeMask) {
|
||||
long pending = stripeMask;
|
||||
while (pending != 0L) {
|
||||
int stripe = Long.SIZE - 1 - Long.numberOfLeadingZeros(pending);
|
||||
originWindowStripes[stripe].unlock();
|
||||
pending &= ~(1L << stripe);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class OriginLockBlocker implements ForkJoinPool.ManagedBlocker {
|
||||
private final ReentrantLock lock;
|
||||
private boolean acquired;
|
||||
|
||||
private OriginLockBlocker(ReentrantLock lock) {
|
||||
this.lock = lock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean block() {
|
||||
if (!acquired) {
|
||||
lock.lock();
|
||||
acquired = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReleasable() {
|
||||
if (!acquired) {
|
||||
acquired = lock.tryLock();
|
||||
}
|
||||
return acquired;
|
||||
}
|
||||
}
|
||||
|
||||
private static GenerationHistoryRuntimeRouter.CoordinateScope openHistoryCoordinateScope(
|
||||
Engine engine,
|
||||
int chunkX,
|
||||
|
||||
@@ -583,7 +583,7 @@ public final class ImageMapStudioGUI {
|
||||
&& !engine.isClosed()
|
||||
&& engine.getComplex() != null) {
|
||||
proceduralHeightSampler = (worldX, worldZ) -> engine.getComplex()
|
||||
.sampleProceduralTerrainHeight(engine, worldX, worldZ);
|
||||
.sampleProceduralTerrainHeight(worldX, worldZ);
|
||||
}
|
||||
DoubleBinaryOperator currentProceduralHeightSampler = proceduralHeightSampler;
|
||||
new SwingWorker<ImageMapStudioExporter.PreviewResult, Void>() {
|
||||
|
||||
@@ -163,13 +163,14 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn {
|
||||
}
|
||||
int nearest = -1;
|
||||
double nearestDistance = Double.POSITIVE_INFINITY;
|
||||
for (int offset = 0; offset + 1 < height(); offset++) {
|
||||
Voxel current = voxelAt(minimumY + offset);
|
||||
Voxel above = voxelAt(minimumY + offset + 1);
|
||||
for (int run = 0; run + 1 < runEnds.length; run++) {
|
||||
Voxel current = palette.get(paletteIndices[run]);
|
||||
Voxel above = palette.get(paletteIndices[run + 1]);
|
||||
if (current.phase() != Phase.SOLID || current.protectedContent()
|
||||
|| above.phase() == Phase.SOLID && !above.protectedContent()) {
|
||||
continue;
|
||||
}
|
||||
int offset = runEnds[run] - 1;
|
||||
double distance = Math.abs(offset - expectedOffset);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = offset;
|
||||
@@ -179,10 +180,10 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn {
|
||||
if (nearest >= 0) {
|
||||
return nearest;
|
||||
}
|
||||
for (int offset = height() - 1; offset >= 0; offset--) {
|
||||
Voxel current = voxelAt(minimumY + offset);
|
||||
for (int run = runEnds.length - 1; run >= 0; run--) {
|
||||
Voxel current = palette.get(paletteIndices[run]);
|
||||
if (current.phase() == Phase.SOLID && !current.protectedContent()) {
|
||||
return offset;
|
||||
return runEnds[run] - 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
|
||||
+1
-1
@@ -1415,7 +1415,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
|| router.history.resolveActivation(chunkX(), chunkZ()).activationId() != stage.activation().activationId()) {
|
||||
return false;
|
||||
}
|
||||
ChunkGenerationSemantics semantics = GenerationSemanticCapture.capture(
|
||||
ChunkGenerationSemantics semantics = GenerationSemanticCapture.captureScoped(
|
||||
router.engine,
|
||||
stage,
|
||||
caveSpace
|
||||
|
||||
@@ -3,6 +3,10 @@ package art.arcane.iris.world.history;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockPositionPredicate;
|
||||
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.structure.placement.IrisStructureLocator;
|
||||
import art.arcane.iris.structure.placement.StructurePlacementMarker;
|
||||
@@ -12,6 +16,7 @@ import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.structure.placement.IrisStructurePlacement;
|
||||
import art.arcane.volmlib.util.mantle.flag.ReservedFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
@@ -28,6 +33,14 @@ public final class GenerationSemanticCapture {
|
||||
private GenerationSemanticCapture() {
|
||||
}
|
||||
|
||||
static ChunkGenerationSemantics captureScoped(
|
||||
Engine engine,
|
||||
GenerationHistory.GenerationStage stage,
|
||||
NativeBlockPositionPredicate caveSpace
|
||||
) {
|
||||
return capture(engine, stage.chunkX(), stage.chunkZ(), stage.activation().activationId(), caveSpace, true);
|
||||
}
|
||||
|
||||
public static ChunkGenerationSemantics capture(
|
||||
Engine engine,
|
||||
GenerationHistory.GenerationStage stage
|
||||
@@ -47,7 +60,8 @@ public final class GenerationSemanticCapture {
|
||||
requiredStage.chunkX(),
|
||||
requiredStage.chunkZ(),
|
||||
requiredStage.activation().activationId(),
|
||||
caveSpace
|
||||
caveSpace,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +71,7 @@ public final class GenerationSemanticCapture {
|
||||
int chunkZ,
|
||||
long activationId
|
||||
) {
|
||||
return capture(engine, chunkX, chunkZ, activationId, (x, y, z) -> true);
|
||||
return capture(engine, chunkX, chunkZ, activationId, (x, y, z) -> true, false);
|
||||
}
|
||||
|
||||
private static ChunkGenerationSemantics capture(
|
||||
@@ -65,7 +79,8 @@ public final class GenerationSemanticCapture {
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
long activationId,
|
||||
NativeBlockPositionPredicate caveSpace
|
||||
NativeBlockPositionPredicate caveSpace,
|
||||
boolean scoped
|
||||
) {
|
||||
Objects.requireNonNull(caveSpace, "cave space");
|
||||
Engine requiredEngine = Objects.requireNonNull(engine, "engine");
|
||||
@@ -75,7 +90,7 @@ public final class GenerationSemanticCapture {
|
||||
activationId
|
||||
);
|
||||
captureColumns(requiredEngine, chunkX, chunkZ, semantics);
|
||||
captureMantleFacts(requiredEngine, chunkX, chunkZ, semantics, caveSpace);
|
||||
captureMantleFacts(requiredEngine, chunkX, chunkZ, semantics, caveSpace, scoped);
|
||||
captureStructures(requiredEngine, chunkX, chunkZ, semantics);
|
||||
return semantics.seal().build();
|
||||
}
|
||||
@@ -133,18 +148,20 @@ public final class GenerationSemanticCapture {
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
ChunkGenerationSemantics.Builder semantics,
|
||||
NativeBlockPositionPredicate caveSpace
|
||||
NativeBlockPositionPredicate caveSpace,
|
||||
boolean scoped
|
||||
) {
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE);
|
||||
int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
|
||||
BitSet resolvedCaves = new BitSet();
|
||||
CaveBiomes caveBiomes = new CaveBiomes(engine, scoped);
|
||||
mantle.iterateChunk(chunkX, chunkZ, MatterCavern.class, (localX, y, localZ, cavern) -> {
|
||||
if (!caveSpace.test(localX, y, localZ)) {
|
||||
return;
|
||||
}
|
||||
captureCave(
|
||||
engine,
|
||||
caveBiomes,
|
||||
semantics,
|
||||
resolvedCaves,
|
||||
minimumX + localX,
|
||||
@@ -163,7 +180,7 @@ public final class GenerationSemanticCapture {
|
||||
return;
|
||||
}
|
||||
captureCave(
|
||||
engine,
|
||||
caveBiomes,
|
||||
semantics,
|
||||
resolvedCaves,
|
||||
blockX,
|
||||
@@ -196,7 +213,7 @@ public final class GenerationSemanticCapture {
|
||||
}
|
||||
|
||||
private static void captureCave(
|
||||
Engine engine,
|
||||
CaveBiomes caveBiomes,
|
||||
ChunkGenerationSemantics.Builder semantics,
|
||||
BitSet resolvedCaves,
|
||||
int blockX,
|
||||
@@ -213,7 +230,7 @@ public final class GenerationSemanticCapture {
|
||||
semantics.addCaveBiome(explicitBiomeKey);
|
||||
return;
|
||||
}
|
||||
IrisBiome caveBiome = engine.getCaveBiome(blockX, y, blockZ);
|
||||
IrisBiome caveBiome = caveBiomes.resolve(blockX, y, blockZ);
|
||||
if (caveBiome != null && caveBiome.getLoadKey() != null) {
|
||||
semantics.addCaveBiome(caveBiome.getLoadKey());
|
||||
}
|
||||
@@ -256,4 +273,80 @@ public final class GenerationSemanticCapture {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CaveBiomes {
|
||||
private final Engine engine;
|
||||
private final boolean scoped;
|
||||
private final CaveFallback fallback;
|
||||
private final IrisDimensionCarvingResolver.State fallbackState = new IrisDimensionCarvingResolver.State();
|
||||
private IrisDimensionCarvingResolver.Snapshot snapshot;
|
||||
private int minimumY;
|
||||
|
||||
private CaveBiomes(Engine engine, boolean scoped) {
|
||||
this.engine = engine;
|
||||
this.scoped = scoped;
|
||||
this.fallback = scoped && engine instanceof IrisEngine irisEngine
|
||||
&& irisEngine.hasGenerationRuntimeScope() && !engine.getPlatformHooks().isMainThread()
|
||||
? new CaveFallback(engine.getComplex(), engine.getDimensionStackContext())
|
||||
: null;
|
||||
}
|
||||
|
||||
private IrisBiome resolve(int x, int y, int z) {
|
||||
if (!scoped) {
|
||||
return engine.getCaveBiome(x, y, z);
|
||||
}
|
||||
if (snapshot == null) {
|
||||
snapshot = IrisDimensionCarvingResolver.snapshot(engine);
|
||||
minimumY = engine.getWorld().minHeight();
|
||||
}
|
||||
IrisBiome configured = snapshot.resolveBiome(x, y + minimumY, z);
|
||||
if (configured != null) {
|
||||
return configured;
|
||||
}
|
||||
return fallback == null ? engine.getCaveBiome(x, y, z, fallbackState) : fallback.resolve(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CaveFallback {
|
||||
private final ProceduralStream<IrisBiome> surfaceBiomes;
|
||||
private final ProceduralStream<IrisBiome> caveBiomes;
|
||||
private final ProceduralStream<Double> heights;
|
||||
private final DimensionStackContext stack;
|
||||
private final CaveColumn[] columns = new CaveColumn[CHUNK_SIZE * CHUNK_SIZE];
|
||||
|
||||
private CaveFallback(IrisComplex complex, DimensionStackContext stack) {
|
||||
this.surfaceBiomes = complex.getTrueBiomeStream();
|
||||
this.caveBiomes = complex.getCaveBiomeStream();
|
||||
this.heights = complex.getHeightStream();
|
||||
this.stack = stack;
|
||||
}
|
||||
|
||||
private IrisBiome resolve(int x, int y, int z) {
|
||||
int index = ((x & 15) << 4) | (z & 15);
|
||||
CaveColumn column = columns[index];
|
||||
if (column == null) {
|
||||
IrisBiome surfaceBiome = surfaceBiomes.get(x, z);
|
||||
int surfaceY = heights.get(x, z).intValue();
|
||||
IrisBiome caveBiome = caveBiomes.get(x, z);
|
||||
if (caveBiome == null || caveBiome.getLoadKey() == null) {
|
||||
caveBiome = surfaceBiome;
|
||||
if (stack != null) {
|
||||
DimensionStackLayout.Layer layer = stack.getLayout(x, z).surfaceLayer();
|
||||
if (layer != null && layer.biome() != null) {
|
||||
caveBiome = layer.biome();
|
||||
}
|
||||
}
|
||||
}
|
||||
column = new CaveColumn(surfaceBiome, caveBiome, surfaceY,
|
||||
caveBiome == null ? 0 : Math.max(0, caveBiome.getCaveMinDepthBelowSurface()));
|
||||
columns[index] = column;
|
||||
}
|
||||
int depth = column.surfaceY() - y;
|
||||
return column.caveBiome() == null || depth <= 0 || depth < column.minimumDepth()
|
||||
? column.surfaceBiome() : column.caveBiome();
|
||||
}
|
||||
}
|
||||
|
||||
private record CaveColumn(IrisBiome surfaceBiome, IrisBiome caveBiome, int surfaceY, int minimumDepth) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ public final class SavedBiomeStore {
|
||||
public boolean claimAndPersist(SavedBiomeChunk chunk) throws IOException {
|
||||
SavedBiomeChunk required = Objects.requireNonNull(chunk, "chunk");
|
||||
WriteStripe lock = regionLock(required.chunkX(), required.chunkZ());
|
||||
if (!lock.writing) {
|
||||
requireWritable();
|
||||
if (cached(required.chunkX(), required.chunkZ()).isPresent()) {
|
||||
synchronized (lock) {
|
||||
if (alreadyClaimed(required)) {
|
||||
return false;
|
||||
@@ -119,15 +120,8 @@ public final class SavedBiomeStore {
|
||||
PendingWrite pending = new PendingWrite(required, record);
|
||||
lock.pending.add(pending);
|
||||
synchronized (lock) {
|
||||
if (!pending.completed) {
|
||||
lock.writing = true;
|
||||
try {
|
||||
while (!pending.completed) {
|
||||
persistPending(lock);
|
||||
}
|
||||
} finally {
|
||||
lock.writing = false;
|
||||
}
|
||||
while (!pending.completed) {
|
||||
persistPending(lock);
|
||||
}
|
||||
if (pending.failure != null) {
|
||||
throw pending.failure;
|
||||
@@ -788,7 +782,6 @@ public final class SavedBiomeStore {
|
||||
|
||||
private static final class WriteStripe {
|
||||
private final ConcurrentLinkedQueue<PendingWrite> pending = new ConcurrentLinkedQueue<>();
|
||||
private volatile boolean writing;
|
||||
}
|
||||
|
||||
private static final class PendingWrite {
|
||||
|
||||
@@ -79,6 +79,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
private final int workerPoolThreads;
|
||||
private final int runtimeCpuThreads;
|
||||
private final int effectiveWorkerThreads;
|
||||
private final int pregenWorkerThreads;
|
||||
private final int recommendedRuntimeConcurrencyCap;
|
||||
private final Method directChunkAtAsyncUrgentMethod;
|
||||
private final Method directChunkAtAsyncMethod;
|
||||
@@ -141,10 +142,11 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
this.chunkAccessMode = chunkAsyncMethodSelection.mode();
|
||||
int detectedWorkerPoolThreads = resolveWorkerPoolThreads();
|
||||
int detectedCpuThreads = Math.max(1, Runtime.getRuntime().availableProcessors());
|
||||
int configuredWorldGenThreads = Math.max(1, IrisSettings.get().getConcurrency().getWorldGenThreads());
|
||||
this.pregenWorkerThreads = pregenWorkerThreadTarget(detectedCpuThreads, Runtime.getRuntime().maxMemory(),
|
||||
!foliaRuntime && !strictSerial && detectedWorkerPoolThreads > 0);
|
||||
int workerThreadsForCap = foliaRuntime
|
||||
? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, configuredWorldGenThreads)
|
||||
: resolvePaperLikeConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, configuredWorldGenThreads);
|
||||
? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads)
|
||||
: resolvePaperLikeConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads);
|
||||
if (foliaRuntime) {
|
||||
this.paperLikeBackendMode = IrisPaperLikeBackendMode.AUTO;
|
||||
this.backendMode = "folia-region";
|
||||
@@ -650,14 +652,22 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
}
|
||||
|
||||
static int computeInitialInFlightLimit(int concurrencyCap, int workerThreads) {
|
||||
int initial = Math.max(4, Math.max(1, workerThreads));
|
||||
return Math.min(Math.max(1, concurrencyCap), initial);
|
||||
long initial = Math.max(1L, workerThreads) * 8L;
|
||||
return (int) Math.min(Math.max(1, concurrencyCap), initial);
|
||||
}
|
||||
|
||||
static int nextAdaptiveInFlightLimit(int current, int concurrencyCap) {
|
||||
return Math.min(Math.max(1, concurrencyCap), Math.max(1, current) + 1);
|
||||
}
|
||||
|
||||
static int pregenWorkerThreadTarget(int availableProcessors, long maximumHeapBytes, boolean expanded) {
|
||||
int baseline = Math.max(2, availableProcessors);
|
||||
if (!expanded || maximumHeapBytes < (16L << 30)) {
|
||||
return baseline;
|
||||
}
|
||||
return Math.max(baseline, (int) Math.min(32L, (long) baseline * 2L));
|
||||
}
|
||||
|
||||
static int resolvePaperLikeConcurrencyWorkerThreads(int detectedWorkerPoolThreads, int detectedCpuThreads, int configuredWorldGenThreads) {
|
||||
int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads);
|
||||
if (detectedWorkerPoolThreads > 0) {
|
||||
@@ -804,8 +814,10 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
+ ", recommendedCap=" + recommendedRuntimeConcurrencyCap
|
||||
+ ", urgent=" + urgent
|
||||
+ ", slowWarning=" + slowRequestWarningSeconds + "s");
|
||||
if (workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) {
|
||||
acquireWorkerThreadBoost();
|
||||
synchronized (this) {
|
||||
if (!closing.get() && workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) {
|
||||
acquireWorkerThreadBoost(pregenWorkerThreads);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,17 +849,25 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
flushAllRemainingChunks();
|
||||
chunkFlush.close(CHUNK_FLUSH_DRAIN_SECONDS, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
if (holdsWorkerBoost.compareAndSet(true, false)) {
|
||||
releaseWorkerThreadBoost();
|
||||
}
|
||||
} finally {
|
||||
HydrologyTileCache.PregenerationScope scope = hydrologyPrefetchScope;
|
||||
if (scope != null) {
|
||||
scope.close();
|
||||
hydrologyPrefetchScope = null;
|
||||
}
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
HydrologyTileCache.PregenerationScope scope = hydrologyPrefetchScope;
|
||||
if (scope != null) {
|
||||
scope.close();
|
||||
hydrologyPrefetchScope = null;
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
synchronized (this) {
|
||||
if (holdsWorkerBoost.compareAndSet(true, false)) {
|
||||
releaseWorkerThreadBoost();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1007,62 +1027,51 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void acquireWorkerThreadBoost() {
|
||||
if (BOOST_HOLDERS.getAndIncrement() > 0) {
|
||||
return;
|
||||
public static synchronized void acquireWorkerThreadBoost(int targetThreads) {
|
||||
if (targetThreads < 1) {
|
||||
throw new IllegalArgumentException("Worker thread target must be positive");
|
||||
}
|
||||
increaseWorkerThreads();
|
||||
BOOST_HOLDERS.incrementAndGet();
|
||||
increaseWorkerThreads(targetThreads);
|
||||
}
|
||||
|
||||
public static void releaseWorkerThreadBoost() {
|
||||
if (BOOST_HOLDERS.decrementAndGet() > 0) {
|
||||
public static synchronized void releaseWorkerThreadBoost() {
|
||||
if (BOOST_HOLDERS.get() == 0 || BOOST_HOLDERS.decrementAndGet() > 0) {
|
||||
return;
|
||||
}
|
||||
resetWorkerThreads();
|
||||
}
|
||||
|
||||
private static void increaseWorkerThreads() {
|
||||
THREAD_COUNT.updateAndGet(i -> {
|
||||
if (i > 0) {
|
||||
return i;
|
||||
private static void increaseWorkerThreads(int targetThreads) {
|
||||
try {
|
||||
NativeWorkerPool pool = workerPool();
|
||||
int threads = pool.threadCount();
|
||||
if (threads >= targetThreads) {
|
||||
return;
|
||||
}
|
||||
|
||||
int adjusted = IrisSettings.get().getConcurrency().getWorldGenThreads();
|
||||
try {
|
||||
NativeWorkerPool pool = workerPool();
|
||||
int threads = pool.threadCount();
|
||||
if (threads >= adjusted) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
pool.adjustThreadCount(adjusted);
|
||||
return threads;
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.warn("Failed to increase worker threads, if you are on paper or a fork of it please increase it manually to " + adjusted);
|
||||
IrisLogging.warn("For more information see https://docs.papermc.io/paper/reference/global-configuration#chunk_system_worker_threads");
|
||||
if (e instanceof InvocationTargetException) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
if (THREAD_COUNT.get() == 0) {
|
||||
THREAD_COUNT.set(threads);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
pool.adjustThreadCount(targetThreads);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
IrisLogging.warn("Failed to increase worker threads, if you are on paper or a fork of it please increase it manually to " + targetThreads);
|
||||
IrisLogging.warn("For more information see https://docs.papermc.io/paper/reference/global-configuration#chunk_system_worker_threads");
|
||||
}
|
||||
}
|
||||
|
||||
private static void resetWorkerThreads() {
|
||||
THREAD_COUNT.updateAndGet(i -> {
|
||||
if (i == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
workerPool().adjustThreadCount(i);
|
||||
return 0;
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
IrisLogging.error("Failed to reset worker threads");
|
||||
}
|
||||
return i;
|
||||
});
|
||||
int original = THREAD_COUNT.get();
|
||||
if (original == 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
workerPool().adjustThreadCount(original);
|
||||
THREAD_COUNT.set(0);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
IrisLogging.error("Failed to reset worker threads");
|
||||
}
|
||||
}
|
||||
|
||||
private static NativeWorkerPool workerPool() {
|
||||
|
||||
@@ -11,7 +11,6 @@ import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
@@ -28,7 +27,6 @@ public class IrisBiomeCeilingLayerTest {
|
||||
new IrisDimension(), 13, -27, new RNG(1), 8, 64, null, null);
|
||||
|
||||
assertEquals(List.of(first, first, second, third, third), result);
|
||||
assertNull(biome.getLayerHeightGenerators().getIfPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -45,8 +43,8 @@ public class IrisBiomeCeilingLayerTest {
|
||||
assertEquals(firstCeiling.size(), secondCeiling.size());
|
||||
}
|
||||
first.getLayerHeightGenerators(rng, null);
|
||||
assertNotSame(first.getLayerHeightGenerators().getIfPresent().get(0),
|
||||
first.getLayerCeilingHeightGenerators().getIfPresent().get(0));
|
||||
assertNotSame(first.getLayerHeightGenerators(rng, null).get(0),
|
||||
first.getLayerCeilingHeightGenerators().get(first.getCaveCeilingLayers(), 7235, rng, null).get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package art.arcane.iris.generation.biome;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.noise.CNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisBiomeLayerHeightCacheTest {
|
||||
private static final long TERRAIN_SEED = 7191L;
|
||||
private static final long CARVE_SEED = -872315L;
|
||||
private static final long FLOATING_SEED = (TERRAIN_SEED ^ 0x7EB0A73F1DCE514DL)
|
||||
+ (0x7A4E ^ "magnetics/glass-shard".hashCode());
|
||||
|
||||
@Test
|
||||
public void layerHeightUsesEachCallerSeedInEitherOrder() {
|
||||
IrisBiomePaletteLayer layer = layer();
|
||||
assertNoise(layer().getHeightGenerator(new RNG(CARVE_SEED), null),
|
||||
layer.getHeightGenerator(new RNG(CARVE_SEED), null));
|
||||
assertNoise(layer().getHeightGenerator(new RNG(FLOATING_SEED), null),
|
||||
layer.getHeightGenerator(new RNG(FLOATING_SEED), null));
|
||||
IrisBiomePaletteLayer reversed = layer();
|
||||
assertNoise(layer.getHeightGenerator(new RNG(FLOATING_SEED), null),
|
||||
reversed.getHeightGenerator(new RNG(FLOATING_SEED), null));
|
||||
assertNoise(layer.getHeightGenerator(new RNG(CARVE_SEED), null),
|
||||
reversed.getHeightGenerator(new RNG(CARVE_SEED), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedSurfaceCarveAndFloatingBiomeIgnoresWarmOrder() {
|
||||
IrisBiome biome = biome();
|
||||
long[] seeds = {FLOATING_SEED, CARVE_SEED, TERRAIN_SEED, FLOATING_SEED + 733L};
|
||||
for (long seed : seeds) {
|
||||
assertGenerators(biome().getLayerHeightGenerators(new RNG(seed), null),
|
||||
biome.getLayerHeightGenerators(new RNG(seed), null));
|
||||
}
|
||||
IrisBiome reversed = biome();
|
||||
for (int i = seeds.length - 1; i >= 0; i--) {
|
||||
assertGenerators(biome.getLayerHeightGenerators(new RNG(seeds[i]), null),
|
||||
reversed.getLayerHeightGenerators(new RNG(seeds[i]), null));
|
||||
}
|
||||
assertNotEquals(biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null).get(0).noise(13, -29),
|
||||
biome.getLayerHeightGenerators(new RNG(FLOATING_SEED), null).get(0).noise(13, -29), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentSurfaceCarveAndFloatingCallersMatchIsolatedBiomes() throws Exception {
|
||||
IrisBiome shared = biome();
|
||||
ExecutorService executor = Executors.newFixedThreadPool(8);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<?>> tasks = new ArrayList<>();
|
||||
long[] seeds = {TERRAIN_SEED, CARVE_SEED, FLOATING_SEED, FLOATING_SEED + 733L};
|
||||
try {
|
||||
for (int worker = 0; worker < 8; worker++) {
|
||||
long seed = seeds[worker % seeds.length];
|
||||
KList<CNG> expected = biome().getLayerHeightGenerators(new RNG(seed), null);
|
||||
tasks.add(executor.submit(() -> {
|
||||
assertTrue(start.await(5, TimeUnit.SECONDS));
|
||||
for (int sample = 0; sample < 30; sample++) {
|
||||
assertGenerators(expected, shared.getLayerHeightGenerators(new RNG(seed), null));
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for (Future<?> task : tasks) {
|
||||
task.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void runtimeAndDataIdentitiesDoNotReuseHeightGenerators() {
|
||||
IrisBiome biome = biome();
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisData otherData = mock(IrisData.class);
|
||||
Engine first = mock(Engine.class);
|
||||
Engine second = mock(Engine.class);
|
||||
when(data.getEngine()).thenReturn(first);
|
||||
when(otherData.getEngine()).thenReturn(first);
|
||||
RNG rng = new RNG(TERRAIN_SEED);
|
||||
KList<CNG> original = biome.getLayerHeightGenerators(rng, data);
|
||||
assertSame(original, biome.getLayerHeightGenerators(rng, data));
|
||||
KList<CNG> other = biome.getLayerHeightGenerators(rng, otherData);
|
||||
assertNotSame(original, other);
|
||||
assertNotSame(original.get(0), other.get(0));
|
||||
assertGenerators(original, other);
|
||||
when(data.getEngine()).thenReturn(second);
|
||||
KList<CNG> replacement = biome.getLayerHeightGenerators(rng, data);
|
||||
assertNotSame(original, replacement);
|
||||
assertNotSame(original.get(0), replacement.get(0));
|
||||
assertGenerators(original, replacement);
|
||||
when(data.getEngine()).thenReturn(first);
|
||||
assertSame(original, biome.getLayerHeightGenerators(rng, data));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void materialBandsMatchIsolatedGenerationAcrossSurfaceSeaAndCeiling() {
|
||||
NativeBlockState first = mock(NativeBlockState.class);
|
||||
NativeBlockState second = mock(NativeBlockState.class);
|
||||
IrisBiome shared = materialBiome(first, second);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
long[] seeds = {FLOATING_SEED, TERRAIN_SEED, CARVE_SEED, FLOATING_SEED + 733L};
|
||||
for (long seed : seeds) {
|
||||
IrisBiome isolated = materialBiome(first, second);
|
||||
RNG rng = new RNG(seed);
|
||||
for (int x = -65; x <= 65; x += 7) {
|
||||
assertEquals(isolated.generateLayers(dimension, x, -x, rng, 64, 120, null, null),
|
||||
shared.generateLayers(dimension, x, -x, rng, 64, 120, null, null));
|
||||
assertEquals(isolated.generateCeilingLayers(dimension, x, -x, rng, 64, 120, null, null),
|
||||
shared.generateCeilingLayers(dimension, x, -x, rng, 64, 120, null, null));
|
||||
assertEquals(isolated.generateSeaLayers(x, -x, rng, 64, null),
|
||||
shared.generateSeaLayers(x, -x, rng, 64, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void evictionRecomputesTheSameNoise() {
|
||||
IrisBiome biome = biome();
|
||||
KList<CNG> original = biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null);
|
||||
for (int seed = 0; seed < 96; seed++) {
|
||||
biome.getLayerHeightGenerators(new RNG(seed), null);
|
||||
}
|
||||
KList<CNG> recomputed = biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null);
|
||||
assertNotSame(original, recomputed);
|
||||
assertNotSame(original.get(0), recomputed.get(0));
|
||||
assertGenerators(original, recomputed);
|
||||
}
|
||||
|
||||
private static IrisBiomePaletteLayer layer() {
|
||||
return new IrisBiomePaletteLayer().zero().setMinHeight(1).setMaxHeight(12);
|
||||
}
|
||||
|
||||
private static IrisBiome biome() {
|
||||
return new IrisBiome().setLayers(new KList<>(layer(), layer().setMinHeight(3).setMaxHeight(5)));
|
||||
}
|
||||
|
||||
private static IrisBiome materialBiome(NativeBlockState first, NativeBlockState second) {
|
||||
IrisBiomePaletteLayer upper = IrisBiomeCeilingLayerTest.layer(first, 1).setMaxHeight(5);
|
||||
IrisBiomePaletteLayer lower = IrisBiomeCeilingLayerTest.layer(second, 3).setMaxHeight(12);
|
||||
KList<IrisBiomePaletteLayer> layers = new KList<>(upper, lower);
|
||||
return new IrisBiome().setLayers(layers).setCaveCeilingLayers(layers).setSeaLayers(layers);
|
||||
}
|
||||
|
||||
private static void assertGenerators(KList<CNG> expected, KList<CNG> actual) {
|
||||
assertEquals(expected.size(), actual.size());
|
||||
for (int index = 0; index < expected.size(); index++) {
|
||||
assertNoise(expected.get(index), actual.get(index));
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNoise(CNG expected, CNG actual) {
|
||||
for (int x = -97; x <= 97; x += 13) {
|
||||
assertEquals(expected.noise(x, x * -3.25), actual.noise(x, x * -3.25), 0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package art.arcane.iris.generation.chunk;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitBiome;
|
||||
import art.arcane.iris.testsupport.BukkitTestServer;
|
||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||
import art.arcane.iris.world.history.NativeTerrainReceipt;
|
||||
import art.arcane.iris.world.history.SavedTerrainChunk;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
|
||||
import io.papermc.paper.InternalAPIBridge;
|
||||
import io.papermc.paper.registry.RegistryAccess;
|
||||
import net.kyori.adventure.key.Key;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.generator.ChunkGenerator.ChunkData;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.RETURNS_DEFAULTS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class LinkedTerrainChunkTest {
|
||||
@BeforeClass
|
||||
public static void initializeBiomes() throws Exception {
|
||||
BukkitTestServer.install();
|
||||
AtomicReference<Object> registered = new AtomicReference<>();
|
||||
RegistryAccess access = mock(RegistryAccess.class, invocation -> {
|
||||
if (invocation.getMethod().getName().equals("getRegistry")) {
|
||||
if (registered.get() == null) {
|
||||
registered.set(Proxy.newProxyInstance(Registry.class.getClassLoader(), new Class<?>[]{Registry.class},
|
||||
(proxy, method, arguments) -> method.getName().equals("getOrThrow")
|
||||
? biome((Key) arguments[0]) : null));
|
||||
}
|
||||
return registered.get();
|
||||
}
|
||||
return RETURNS_DEFAULTS.answer(invocation);
|
||||
});
|
||||
InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation ->
|
||||
invocation.getMethod().getName().equals("constructLegacyCustomBiome")
|
||||
? biome(Key.key("minecraft:custom")) : RETURNS_DEFAULTS.answer(invocation));
|
||||
try (MockedStatic<RegistryAccess> registries = mockStatic(RegistryAccess.class);
|
||||
MockedStatic<InternalAPIBridge> internals = mockStatic(InternalAPIBridge.class)) {
|
||||
registries.when(RegistryAccess::registryAccess).thenReturn(access);
|
||||
internals.when(InternalAPIBridge::get).thenReturn(bridge);
|
||||
Class.forName(Registry.class.getName());
|
||||
Class.forName(Biome.class.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void biomeReadsReuseCanonicalHandlesAndPreserveReceiptBytes() throws Exception {
|
||||
LinkedTerrainChunk chunk = chunk();
|
||||
NativeBiome plains = BukkitBiome.of(Biome.PLAINS);
|
||||
NativeBiome desert = BukkitBiome.of(Biome.DESERT);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
chunk.fillBiomeColumn(x, z, plains);
|
||||
}
|
||||
}
|
||||
NativeBiome alternate = mock(NativeBiome.class);
|
||||
when(alternate.nativeHandle()).thenReturn(Biome.DESERT);
|
||||
when(alternate.key()).thenReturn("ignored:alias");
|
||||
chunk.setBiome(0, -12, 0, alternate);
|
||||
chunk.setBiome(17, 100, -1, desert);
|
||||
AtomicInteger conversions = new AtomicInteger();
|
||||
try (MockedStatic<BukkitBiome> wrappers = mockStatic(BukkitBiome.class, invocation -> {
|
||||
conversions.incrementAndGet();
|
||||
return CALLS_REAL_METHODS.answer(invocation);
|
||||
})) {
|
||||
assertSame(desert, chunk.getBiome(0, -12, 0));
|
||||
assertSame(desert, chunk.getBiome(1, -1, 15));
|
||||
assertSame(plains, chunk.getBiome(0, -100, 0));
|
||||
for (boolean boundary : new boolean[]{false, true}) {
|
||||
SavedTerrainChunk.VoxelSource actual = source((x, y, z) -> chunk.getBiome(x, y, z).key());
|
||||
SavedTerrainChunk.VoxelSource expected = source((x, y, z) ->
|
||||
x == 0 && y == -12 && z == 0 || x == 1 && y == -1 && z == 15
|
||||
? "minecraft:desert" : "minecraft:plains");
|
||||
SavedTerrainChunk captured = capture(actual, boundary);
|
||||
assertArrayEquals(NativeTerrainReceipt.encode(capture(expected, boundary), 7L, "epoch"),
|
||||
NativeTerrainReceipt.encode(captured, 7L, "epoch"));
|
||||
}
|
||||
assertEquals(0, conversions.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyColumnsResolveTheDefaultBiomeOnlyOnce() {
|
||||
LinkedTerrainChunk chunk = chunk();
|
||||
NativeBiome plains = BukkitBiome.of(Biome.PLAINS);
|
||||
AtomicInteger conversions = new AtomicInteger();
|
||||
try (MockedStatic<BukkitBiome> wrappers = mockStatic(BukkitBiome.class, invocation -> {
|
||||
conversions.incrementAndGet();
|
||||
return CALLS_REAL_METHODS.answer(invocation);
|
||||
})) {
|
||||
for (int y = -32; y < 16; y++) {
|
||||
assertSame(plains, chunk.getBiome(0, y, 0));
|
||||
}
|
||||
assertEquals(1, conversions.get());
|
||||
}
|
||||
}
|
||||
|
||||
private static LinkedTerrainChunk chunk() {
|
||||
ChunkData data = mock(ChunkData.class);
|
||||
when(data.getMinHeight()).thenReturn(-16);
|
||||
when(data.getMaxHeight()).thenReturn(0);
|
||||
return new LinkedTerrainChunk(data);
|
||||
}
|
||||
|
||||
private static SavedTerrainChunk capture(SavedTerrainChunk.VoxelSource source, boolean boundary) throws Exception {
|
||||
return boundary ? SavedTerrainChunk.captureBoundary(-1, 2, -16, 16, "minecraft:noise", source)
|
||||
: SavedTerrainChunk.capture(-1, 2, -16, 16, "minecraft:noise", source);
|
||||
}
|
||||
|
||||
private static SavedTerrainChunk.VoxelSource source(BiomeKey source) {
|
||||
BoundaryColumnGeometry.Voxel stone = new BoundaryColumnGeometry.Voxel(
|
||||
"minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false);
|
||||
return new SavedTerrainChunk.VoxelSource() {
|
||||
@Override
|
||||
public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) {
|
||||
return stone;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String biome(int x, int y, int z) {
|
||||
return source.get(x, y, z);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Biome biome(Key key) {
|
||||
NamespacedKey namespaced = new NamespacedKey(key.namespace(), key.value());
|
||||
return (Biome) Proxy.newProxyInstance(Biome.class.getClassLoader(), new Class<?>[]{Biome.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getKey" -> namespaced;
|
||||
case "hashCode" -> namespaced.hashCode();
|
||||
case "equals" -> proxy == arguments[0];
|
||||
case "toString", "name" -> key.value();
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
|
||||
private interface BiomeKey {
|
||||
String get(int x, int y, int z);
|
||||
}
|
||||
}
|
||||
+31
@@ -1,11 +1,42 @@
|
||||
package art.arcane.iris.generation.decoration.tree;
|
||||
|
||||
import org.junit.Test;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class TreeBlockMaterialTest {
|
||||
@Test
|
||||
public void nativeMaterialUsesTheMemoizedStringWithoutReadingTheStateKey() {
|
||||
NativeBlockState state = mock(NativeBlockState.class);
|
||||
String material = new String("minecraft:oak_log");
|
||||
when(state.materialKey()).thenReturn(material);
|
||||
|
||||
TreeBlockMaterial first = TreeBlockMaterial.of(state);
|
||||
TreeBlockMaterial second = TreeBlockMaterial.of(state);
|
||||
|
||||
assertSame(material, first.materialKey());
|
||||
assertSame(first.materialKey(), second.materialKey());
|
||||
verify(state, never()).key();
|
||||
assertEquals(TreeBlockMaterial.of("minecraft:oak_log[axis=x]"), first);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeMaterialFallsBackToTheStateKeyWhenNotMemoized() {
|
||||
NativeBlockState state = mock(NativeBlockState.class);
|
||||
when(state.key()).thenReturn("minecraft:oak_log[axis=z]");
|
||||
|
||||
assertEquals(TreeBlockMaterial.of("minecraft:oak_log"), TreeBlockMaterial.of(state));
|
||||
verify(state).key();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void materialComparisonIgnoresBlockStateProperties() {
|
||||
TreeBlockMaterial expected = TreeBlockMaterial.of("minecraft:oak_log[axis=y]");
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyBasisCacheTest {
|
||||
@Test
|
||||
public void batchLookupPreservesNullAndFirstPublishedSample() {
|
||||
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));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void channelsNullsFailuresAndEvictionPreserveSamples() {
|
||||
CountingSampler sampler = new CountingSampler();
|
||||
HydrologyBasisCache cache = new HydrologyBasisCache(1);
|
||||
assertEquals(3D, cache.sample(1, -2, sampler).slope(), 0D);
|
||||
assertEquals(0D, cache.sampleWithoutSlope(1, -2, sampler).slope(), 0D);
|
||||
assertEquals(3D, cache.sample(1, -2, sampler).slope(), 0D);
|
||||
assertEquals(0D, cache.sampleWithoutSlope(1, -2, sampler).slope(), 0D);
|
||||
assertEquals(1, sampler.fullCalls.get());
|
||||
assertEquals(1, sampler.slopeFreeCalls.get());
|
||||
assertNull(cache.sampleWithoutSlope(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler));
|
||||
assertNull(cache.sampleWithoutSlope(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler));
|
||||
assertEquals(2, sampler.slopeFreeCalls.get());
|
||||
assertThrows(IllegalStateException.class, () -> cache.sampleWithoutSlope(99, 0, sampler));
|
||||
assertEquals(87, cache.sampleWithoutSlope(99, 0, sampler).naturalHeight());
|
||||
assertEquals(87, cache.sampleWithoutSlope(99, 0, sampler).naturalHeight());
|
||||
assertEquals(4, sampler.slopeFreeCalls.get());
|
||||
assertEquals(87, cache.sampleWithoutSlope(1, -2, sampler).naturalHeight());
|
||||
assertEquals(5, sampler.slopeFreeCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plannerTrialsPreserveLocalSlopePrecedenceAndOwnerIsolation() {
|
||||
CountingSampler natural = new CountingSampler();
|
||||
HydrologyTerrainSampler detailed = (x, z) -> {
|
||||
throw new AssertionError("Basis caching must not sample caves");
|
||||
};
|
||||
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
|
||||
HydrologyPlanner planner = new HydrologyPlanner(23L, settings, detailed, natural,
|
||||
HydrologyGeometrySampler.deterministic(detailed), -4096,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(detailed, settings.seaLevel(), -4096, 4096));
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
try {
|
||||
assertEquals(3D, planner.sampleBasis(1, 1).slope(), 0D);
|
||||
assertEquals(0D, planner.sampleBasisWithoutSlope(2, 1).slope(), 0D);
|
||||
HydrologyBasisCache shared = planner.planningSamples.get()
|
||||
.fallbackBasis(new Long2ObjectOpenHashMap<>());
|
||||
for (int trial = 0; trial < 4; trial++) {
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(null, shared));
|
||||
assertEquals(0D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D);
|
||||
assertEquals(3D, planner.sampleBasis(1, 1).slope(), 0D);
|
||||
assertEquals(3D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D);
|
||||
assertEquals(0D, planner.sampleBasisWithoutSlope(2, 1).slope(), 0D);
|
||||
}
|
||||
assertEquals(1, natural.fullCalls.get());
|
||||
assertEquals(2, natural.slopeFreeCalls.get());
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
assertEquals(0D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D);
|
||||
assertEquals(3, natural.slopeFreeCalls.get());
|
||||
} finally {
|
||||
planner.planningSamples.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parallelTrialsComputeEachCoordinateOnce() throws Exception {
|
||||
CountingSampler sampler = new CountingSampler();
|
||||
HydrologyBasisCache cache = new HydrologyBasisCache();
|
||||
CountDownLatch ready = new CountDownLatch(4);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
ExecutorService workers = Executors.newFixedThreadPool(4);
|
||||
try {
|
||||
List<Future<List<HydrologyTerrainSample>>> results = new ArrayList<>();
|
||||
for (int trial = 0; trial < 4; trial++) {
|
||||
results.add(workers.submit(() -> {
|
||||
ready.countDown();
|
||||
assertTrue(start.await(5, TimeUnit.SECONDS));
|
||||
List<HydrologyTerrainSample> values = new ArrayList<>();
|
||||
for (int x = -128; x < 0; x++) {
|
||||
values.add(cache.sampleWithoutSlope(x, -17, sampler));
|
||||
}
|
||||
return values;
|
||||
}));
|
||||
}
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS));
|
||||
start.countDown();
|
||||
List<HydrologyTerrainSample> expected = results.getFirst().get(5, TimeUnit.SECONDS);
|
||||
for (Future<List<HydrologyTerrainSample>> result : results) {
|
||||
assertEquals(expected, result.get(5, TimeUnit.SECONDS));
|
||||
}
|
||||
assertEquals(128, sampler.slopeFreeCalls.get());
|
||||
} finally {
|
||||
start.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CountingSampler implements HydrologyNaturalTerrainSampler {
|
||||
private final AtomicInteger fullCalls = new AtomicInteger();
|
||||
private final AtomicInteger slopeFreeCalls = new AtomicInteger();
|
||||
private final AtomicInteger failures = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasis(int x, int z) {
|
||||
fullCalls.incrementAndGet();
|
||||
return HydrologyTerrainSample.openLand(87, 3D, "land");
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) {
|
||||
slopeFreeCalls.incrementAndGet();
|
||||
if (x == Integer.MIN_VALUE) {
|
||||
return null;
|
||||
}
|
||||
if (x == 99 && failures.getAndIncrement() == 0) {
|
||||
throw new IllegalStateException("retry");
|
||||
}
|
||||
return HydrologyTerrainSample.openLand(87, 0D, "land");
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
throw new AssertionError("Basis caching must not sample grids");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int x, int z) {
|
||||
throw new AssertionError("Basis caching must not classify separately");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -34,6 +34,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void terrainLoadsLeaveDiagnosticsLazyAndQueriesRetainImmutableResults() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
HydrologyDiagnosticCandidate candidate = mock(HydrologyDiagnosticCandidate.class);
|
||||
@@ -59,6 +60,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void forbiddenThreadsCanReadOnlyCompleteCachedDiagnostics() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
AtomicBoolean forbidden = new AtomicBoolean(true);
|
||||
@@ -77,6 +79,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void interruptedWaiterLeavesTheSharedDiagnosticLoadRunning() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
List<HydrologyDiagnosticCandidate> expected = List.of(mock(HydrologyDiagnosticCandidate.class));
|
||||
@@ -127,6 +130,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void clearDoesNotPublishAnOlderDiagnosticResultIntoTheNewEpoch() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
List<HydrologyDiagnosticCandidate> old = List.of(mock(HydrologyDiagnosticCandidate.class));
|
||||
@@ -166,6 +170,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void closeCancelsQueuedDiagnosticsAndDrainsActiveDiagnostics() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey firstKey = new HydrologyTileKey(0, 0);
|
||||
HydrologyTileKey secondKey = new HydrologyTileKey(1, 0);
|
||||
@@ -213,6 +218,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void aDiagnosticLoadCannotCloseItsOwnCache() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
when(planner.plan(key)).thenReturn(tile);
|
||||
@@ -229,6 +235,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void diagnosticFailureIsRetriedAndCompletedListsRemainBounded() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(planner.diagnosticCandidates(tile)).thenThrow(new IllegalStateException("expected")).thenReturn(List.of());
|
||||
|
||||
+33
-2
@@ -113,6 +113,34 @@ public class HydrologyFootprintCompilerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fallbackCompilersShareBasisWithoutChangingValidationOrFootprints() {
|
||||
HydraulicSegment segment = new HydraulicSegment(
|
||||
9001L, 9000L, HydrologyFeatureType.SURFACE_POOL, 70, 70, 6, 3, false, false,
|
||||
List.of(new HydrologyPoint(-12, 70, -3), new HydrologyPoint(32, 70, 5)),
|
||||
HydraulicChannelProfile.uniform(6, 3));
|
||||
RiverCourse course = course(9000L, RiverCourseType.SURFACE, segment);
|
||||
SlopeCountingSampler sampler = new SlopeCountingSampler(true);
|
||||
HydrologyFootprintCompiler owner = new HydrologyFootprintCompiler(
|
||||
HydrologyPlannerSettings.defaults(),
|
||||
new HydrologyFootprintCompiler.Sampling(sampler, request -> request.minimum(), sampler));
|
||||
HydrologyFootprintCompiler.ValidationRaster expectedValidation = owner.compileValidation(List.of(course));
|
||||
RiverFootprint expected = owner.compile(List.of(course));
|
||||
int ownerCalls = sampler.slopeFreeCalls;
|
||||
assertTrue(ownerCalls > 0);
|
||||
HydrologyBasisCache shared = new HydrologyBasisCache();
|
||||
shared.seedWithoutSlope(owner.terrainBases);
|
||||
for (int trial = 0; trial < 3; trial++) {
|
||||
HydrologyFootprintCompiler alternative = new HydrologyFootprintCompiler(
|
||||
HydrologyPlannerSettings.defaults(),
|
||||
new HydrologyFootprintCompiler.Sampling(sampler, request -> request.minimum(), sampler));
|
||||
alternative.sharedBasis = shared;
|
||||
assertEquals(expectedValidation.columns(), alternative.compileValidation(List.of(course)).columns());
|
||||
assertEquals(expected, alternative.compile(List.of(course)));
|
||||
}
|
||||
assertEquals(ownerCalls, sampler.slopeFreeCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void surfaceChannelNeverRaisesTerrainOrPublishesUnsupportedFluid() {
|
||||
HydraulicSegment segment = new HydraulicSegment(
|
||||
@@ -536,11 +564,14 @@ public class HydrologyFootprintCompilerTest {
|
||||
);
|
||||
RiverCourse course = course(42L, RiverCourseType.SURFACE, segment);
|
||||
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 1D, "parent");
|
||||
RiverFootprint footprint = new HydrologyFootprintCompiler(
|
||||
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(
|
||||
HydrologyPlannerSettings.defaults(),
|
||||
terrain,
|
||||
request -> request.minimum()
|
||||
).compile(List.of(course));
|
||||
);
|
||||
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
|
||||
compiler.surfaceFootprintForPublication(course).rejection());
|
||||
RiverFootprint footprint = compiler.compile(List.of(course));
|
||||
|
||||
int channelEdge = 0;
|
||||
while (layerAt(footprint, channelEdge + 1, 0).channel()) {
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ public class HydrologyPlannerRoutingSamplerTest {
|
||||
assertEquals(6, heightCalls.get());
|
||||
HydrologyLandHeightCache shared = planner.planningSamples.get().fallbackLandHeights();
|
||||
for (int trial = 0; trial < 3; trial++) {
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared));
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared, null));
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D);
|
||||
assertEquals(0D, planner.sampleLandHeight(1, -17), 0D);
|
||||
assertEquals(Integer.MIN_VALUE, planner.sampleLandHeight(2, -17), 0D);
|
||||
|
||||
@@ -2118,13 +2118,15 @@ public class HydrologyPlannerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fallbackAlternatesCoastalAndInlandTrials() {
|
||||
public void fallbackAlternatesCoastalAndInlandTrials() throws Exception {
|
||||
// An island whose coast is walled by a berm between the lattice columns: every mouth course
|
||||
// needs a cut deeper than the channel may make, so the surface routing falls back. The coast
|
||||
// offers more fallback mouths than the trial budget, so a sinkhole only gets its trial when
|
||||
// the fallback alternates coastal and inland candidates.
|
||||
HydrologyPlannerSettings settings = standardSettings(1D, 0D, true, true, List.of());
|
||||
AtomicInteger terrainSamples = new AtomicInteger();
|
||||
HydrologyTerrainSampler island = (int x, int z) -> {
|
||||
terrainSamples.incrementAndGet();
|
||||
if (x < -16 || x > 144 || z < -16 || z > 144) {
|
||||
return oceanTerrain();
|
||||
}
|
||||
@@ -2135,6 +2137,7 @@ public class HydrologyPlannerTest {
|
||||
};
|
||||
|
||||
HydrologyTile tile = new HydrologyPlanner(885L, settings, island, solidCaveView()).plan(TILE);
|
||||
assertTrue("Fallback terrain samples: " + terrainSamples.get(), terrainSamples.get() < 37_500);
|
||||
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty());
|
||||
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) ->
|
||||
@@ -2144,6 +2147,46 @@ public class HydrologyPlannerTest {
|
||||
assertFalse("outlet=" + outlet, outlet.directOcean());
|
||||
assertTrue(course.surfaceSinkholeContinuation());
|
||||
}
|
||||
|
||||
HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasis(int x, int z) {
|
||||
return island.sample(x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()];
|
||||
for (int z = 0; z < request.width(); z++) {
|
||||
for (int x = 0; x < request.width(); x++) {
|
||||
samples[z * request.width() + x] = island.sample(
|
||||
request.minimumX() + x * request.spacing(), request.minimumZ() + z * request.spacing());
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int x, int z) {
|
||||
return island.sample(x, z).ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSharedGridSamples() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
HydrologyPlanner sharedPlanner = new HydrologyPlanner(885L, settings, island, natural,
|
||||
HydrologyGeometrySampler.deterministic(island), Integer.MIN_VALUE, footprint -> solidCaveView());
|
||||
ForkJoinPool workers = new ForkJoinPool(4);
|
||||
try {
|
||||
HydrologyTile shared = workers.submit(() -> sharedPlanner.plan(TILE)).get(30, TimeUnit.SECONDS);
|
||||
assertTileContentsEqual(tile, shared);
|
||||
assertEquals(tile.courses(), shared.courses());
|
||||
assertEquals(tile.localDiagnosticCandidates(), shared.localDiagnosticCandidates());
|
||||
} finally {
|
||||
workers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyPlannerSettings mixedCoastSettings(int maximumCoastalPerTile) {
|
||||
|
||||
+4
@@ -40,7 +40,9 @@ public class HydrologyPlanningAdmissionIntegrationTest {
|
||||
AtomicInteger peak = new AtomicInteger();
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class);
|
||||
when(firstPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class);
|
||||
when(secondPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
when(firstPlanner.plan(key)).thenAnswer(invocation -> {
|
||||
peak.accumulateAndGet(active.incrementAndGet(), Math::max);
|
||||
@@ -86,6 +88,7 @@ public class HydrologyPlanningAdmissionIntegrationTest {
|
||||
ExecutorService workers = Executors.newSingleThreadExecutor();
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
try {
|
||||
@@ -101,6 +104,7 @@ public class HydrologyPlanningAdmissionIntegrationTest {
|
||||
releaseRoots(held);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyPlanner nextPlanner = mock(HydrologyPlanner.class);
|
||||
when(nextPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
when(nextPlanner.plan(key)).thenReturn(tile);
|
||||
try (HydrologyTileCache next = new HydrologyTileCache(nextPlanner, 4, workers)) {
|
||||
assertSame(tile, callers.submit(() -> next.get(key)).get(5L, TimeUnit.SECONDS));
|
||||
|
||||
@@ -280,6 +280,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void interruptedWaiterExitsWithoutCancellingTheSharedPlan() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
@@ -331,6 +332,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void closeDrainsBackgroundPlanningAndRejectsNewDemand() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
@@ -402,6 +404,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void closingFromAnOwnedLoadFailsWithoutDeadlockingOrClosingTheCache() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
@@ -707,6 +710,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void equivalentStudioRuntimesReuseCompletedTiles() {
|
||||
HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class);
|
||||
when(firstPlanner.settings()).thenReturn(emptySettings());
|
||||
HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(2, -3);
|
||||
@@ -739,7 +743,9 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void failedPlansDoNotEnterTheSharedStudioCache() {
|
||||
HydrologyPlanner failingPlanner = mock(HydrologyPlanner.class);
|
||||
when(failingPlanner.settings()).thenReturn(emptySettings());
|
||||
HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class);
|
||||
when(succeedingPlanner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile emptyTile = mock(HydrologyTile.class);
|
||||
HydrologyTile plannedTile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(-4, 5);
|
||||
@@ -759,6 +765,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void closeWaitsForActivePlansWithoutPublishingTheirSharedTiles() throws Exception {
|
||||
HydrologyPlanner closingPlanner = mock(HydrologyPlanner.class);
|
||||
when(closingPlanner.settings()).thenReturn(emptySettings());
|
||||
HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile lateTile = mock(HydrologyTile.class);
|
||||
HydrologyTile plannedTile = mock(HydrologyTile.class);
|
||||
@@ -956,6 +963,106 @@ public class HydrologyTileCacheTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleColumnContributionReusesItsImmutableSample() {
|
||||
HydrologyColumnSample sample = compositionSample(90, List.of(compositionLayer(11L, 80)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) {
|
||||
assertSame(sample, cache.columnAt(-1, 0).orElseThrow());
|
||||
assertTrue(cache.columnAt(-2, 0).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleColumnContributionPreservesCanonicalLayerOrder() {
|
||||
HydrologyColumnSample sample = compositionSample(90,
|
||||
List.of(compositionLayer(11L, 80), compositionLayer(12L, 78)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) {
|
||||
assertSame(sample, cache.columnAt(-1, 0).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleColumnContributionDeduplicatesIdenticalFeatureIds() {
|
||||
HydrologyColumnLayer layer = compositionLayer(11L, 80);
|
||||
HydrologyColumnSample sample = compositionSample(90, List.of(layer, layer));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) {
|
||||
assertEquals(compositionSample(90, List.of(layer)), cache.columnAt(-1, 0).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleColumnContributionRejectsConflictingFeatureIds() {
|
||||
HydrologyColumnSample sample = compositionSample(90,
|
||||
List.of(compositionLayer(11L, 80), compositionLayer(11L, 79)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0));
|
||||
assertEquals("Hydrology feature id collision at -1,0.", failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlappingColumnContributionsMergeAndDeduplicateLayers() {
|
||||
HydrologyColumnLayer shared = compositionLayer(11L, 80);
|
||||
HydrologyColumnLayer additional = compositionLayer(12L, 78);
|
||||
HydrologyColumnSample first = compositionSample(90, List.of(shared));
|
||||
HydrologyColumnSample second = compositionSample(90, List.of(additional, shared));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(
|
||||
new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) {
|
||||
assertEquals(compositionSample(90, List.of(shared, additional)), cache.columnAt(-1, 0).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlappingColumnContributionsRejectConflictingFeatureIds() {
|
||||
HydrologyColumnSample first = compositionSample(90, List.of(compositionLayer(11L, 80)));
|
||||
HydrologyColumnSample second = compositionSample(90, List.of(compositionLayer(11L, 79)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(
|
||||
new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0));
|
||||
assertEquals("Hydrology feature id collision at -1,0.", failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlappingColumnContributionsRejectDifferentNaturalTerrain() {
|
||||
HydrologyColumnSample first = compositionSample(90, List.of(compositionLayer(11L, 80)));
|
||||
HydrologyColumnSample second = compositionSample(91, List.of(compositionLayer(12L, 78)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(
|
||||
new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0));
|
||||
assertEquals("Hydrology plans disagree on terrain metadata at -1,0.", failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyTileCache compositionCache(Map<HydrologyTileKey, HydrologyColumnSample> samples) {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(featureSettings());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenAnswer(invocation -> {
|
||||
HydrologyTileKey key = invocation.getArgument(0);
|
||||
HydrologyColumnSample sample = samples.get(key);
|
||||
RiverFootprint footprint = sample == null ? RiverFootprint.empty()
|
||||
: new RiverFootprint(Map.of(RiverFootprint.pack(sample.x(), sample.z()), sample));
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(tile.footprint()).thenReturn(footprint);
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenAnswer(query ->
|
||||
footprint.sample(query.getArgument(0), query.getArgument(1)));
|
||||
return tile;
|
||||
});
|
||||
return new HydrologyTileCache(planner, 64);
|
||||
}
|
||||
|
||||
private static HydrologyColumnSample compositionSample(int naturalHeight, List<HydrologyColumnLayer> layers) {
|
||||
return new HydrologyColumnSample(-1, 0, naturalHeight, 63, false, "parent", layers);
|
||||
}
|
||||
|
||||
private static HydrologyColumnLayer compositionLayer(long featureId, int bedY) {
|
||||
HydrologyFeatureRef feature = new HydrologyFeatureRef(featureId, HydrologyFeatureType.SURFACE_POOL,
|
||||
1L, 1L, -1, 82, 0, 1, 0, false);
|
||||
return new HydrologyColumnLayer(feature, bedY, 82, 82,
|
||||
true, false, false, true, false, false, true, true, false,
|
||||
"default", "river", "mouth", "shore", "bank", "cave");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentDifferentTileAndCompositeQueriesRemainDeterministic() throws Exception {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(1211L, featureSettings(), this::featureTerrain);
|
||||
@@ -1338,6 +1445,7 @@ public class HydrologyTileCacheTest {
|
||||
|
||||
private void assertConcurrentTilePlanning(List<HydrologyTileKey> keys) throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
CountDownLatch started = new CountDownLatch(Math.min(keys.size(), HydrologyPlanningAdmission.maximumRoots()));
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
@@ -1449,6 +1557,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void clearDoesNotJoinOrPublishAnOldTilePlan() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile oldTile = mock(HydrologyTile.class);
|
||||
HydrologyTile newTile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assume.assumeTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyTileDemandWindowTest {
|
||||
@Test
|
||||
public void completedSlotsRefillBeforeTheSlowestTileFinishes() throws Exception {
|
||||
assumeTrue(HydrologyPlanningAdmission.maximumRoots() >= 2);
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologyTile first = mock(HydrologyTile.class);
|
||||
HydrologyTile second = mock(HydrologyTile.class);
|
||||
HydrologyTile third = mock(HydrologyTile.class);
|
||||
List<HydrologyTile> expected = List.of(first, second, third);
|
||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
CountDownLatch thirdStarted = new CountDownLatch(1);
|
||||
AtomicInteger active = new AtomicInteger();
|
||||
AtomicInteger peak = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
HydrologyTileKey key = invocation.getArgument(0);
|
||||
peak.accumulateAndGet(active.incrementAndGet(), Math::max);
|
||||
try {
|
||||
if (key.tileX() == 0) {
|
||||
firstStarted.countDown();
|
||||
assertTrue(releaseFirst.await(5, TimeUnit.SECONDS));
|
||||
} else {
|
||||
assertTrue(firstStarted.await(5, TimeUnit.SECONDS));
|
||||
if (key.tileX() == 2) {
|
||||
thirdStarted.countDown();
|
||||
}
|
||||
}
|
||||
return expected.get(key.tileX());
|
||||
} finally {
|
||||
active.decrementAndGet();
|
||||
}
|
||||
}).when(planner).plan(any(HydrologyTileKey.class));
|
||||
ExecutorService workers = Executors.newFixedThreadPool(3);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
|
||||
try {
|
||||
Future<List<HydrologyTile>> result = caller.submit(() -> cache.tiles(keys()));
|
||||
assertTrue("a completed slot must admit the third tile while the first is blocked",
|
||||
thirdStarted.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(result.isDone());
|
||||
releaseFirst.countDown();
|
||||
assertEquals(expected, result.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(2, peak.get());
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
caller.shutdownNow();
|
||||
workers.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancellationStopsRefillWithoutCancellingSharedSiblingWork() throws Exception {
|
||||
assumeTrue(HydrologyPlanningAdmission.maximumRoots() >= 2);
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
AtomicInteger planned = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
HydrologyTileKey key = invocation.getArgument(0);
|
||||
planned.incrementAndGet();
|
||||
if (key.tileX() == 0) {
|
||||
firstStarted.countDown();
|
||||
assertTrue(releaseFirst.await(5, TimeUnit.SECONDS));
|
||||
return tile;
|
||||
}
|
||||
assertTrue(firstStarted.await(5, TimeUnit.SECONDS));
|
||||
throw new CancellationException("Cancelled demand tile");
|
||||
}).when(planner).plan(any(HydrologyTileKey.class));
|
||||
ExecutorService workers = Executors.newFixedThreadPool(3);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
|
||||
try {
|
||||
Future<List<HydrologyTile>> result = callers.submit(() -> cache.tiles(keys()));
|
||||
ExecutionException failure = assertThrows(ExecutionException.class,
|
||||
() -> result.get(5, TimeUnit.SECONDS));
|
||||
assertTrue(failure.getCause() instanceof CancellationException);
|
||||
assertEquals(2, planned.get());
|
||||
Future<HydrologyTile> shared = callers.submit(() -> cache.get(keys().getFirst()));
|
||||
releaseFirst.countDown();
|
||||
assertSame(tile, shared.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(2, planned.get());
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
callers.shutdownNow();
|
||||
workers.shutdownNow();
|
||||
assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void capturedCachedTilesSurviveInvalidationDuringAnotherPlan() {
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologyTile cached = mock(HydrologyTile.class);
|
||||
HydrologyTile generated = mock(HydrologyTile.class);
|
||||
HydrologyTileKey cachedKey = new HydrologyTileKey(2, 0);
|
||||
when(planner.plan(cachedKey)).thenReturn(cached);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, Runnable::run);
|
||||
try {
|
||||
assertSame(cached, cache.get(cachedKey));
|
||||
doAnswer(invocation -> {
|
||||
cache.clear();
|
||||
return generated;
|
||||
}).when(planner).plan(keys().getFirst());
|
||||
when(planner.plan(cachedKey)).thenThrow(new AssertionError("Captured tile was replanned"));
|
||||
assertEquals(List.of(generated, cached), cache.tiles(List.of(keys().getFirst(), cachedKey)));
|
||||
} finally {
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static HydrologyPlanner planner() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
return planner;
|
||||
}
|
||||
|
||||
private static List<HydrologyTileKey> keys() {
|
||||
return List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0));
|
||||
}
|
||||
}
|
||||
+73
@@ -2,6 +2,7 @@ package art.arcane.iris.generation.hydrology.runtime;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
|
||||
import art.arcane.iris.generation.hydrology.RiverFootprint;
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
@@ -26,6 +27,9 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
@@ -36,6 +40,75 @@ import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
@Test
|
||||
public void boundedBasisBatchUsesScopedWorkersAndDrainsNestedPool() throws Exception {
|
||||
ThreadLocal<Integer> scope = new ThreadLocal<>();
|
||||
AtomicInteger opened = new AtomicInteger();
|
||||
AtomicInteger closed = new AtomicInteger();
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
CountDownLatch simultaneous = new CountDownLatch(2);
|
||||
ForkJoinPool pool = new ForkJoinPool(2);
|
||||
try {
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources((x, z, height) -> {
|
||||
assertEquals(Integer.valueOf(7), scope.get());
|
||||
if (x >= 10_000) {
|
||||
throw new IllegalStateException("terrain failure");
|
||||
}
|
||||
simultaneous.countDown();
|
||||
try {
|
||||
assertTrue(simultaneous.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
samples.incrementAndGet();
|
||||
return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height,
|
||||
HydrologyTerrainSample.openLand((int) height, 0D, "land"));
|
||||
}, (x, z) -> 100 + Math.floorMod(x + z, 17), (x, z) -> false, 60),
|
||||
new IrisHydrologyRoutingTerrainSampler.SamplingOptions(8192, 2, pool, () -> () -> {
|
||||
Integer previous = scope.get();
|
||||
scope.set(7);
|
||||
opened.incrementAndGet();
|
||||
return () -> {
|
||||
if (previous == null) {
|
||||
scope.remove();
|
||||
} else {
|
||||
scope.set(previous);
|
||||
}
|
||||
closed.incrementAndGet();
|
||||
};
|
||||
}));
|
||||
long[] coordinates = new long[4096];
|
||||
for (int index = 0; index < coordinates.length; index++) {
|
||||
coordinates[index] = RiverFootprint.pack(index - 2048, -index);
|
||||
}
|
||||
HydrologyTerrainSample[] result = pool.submit(() -> sampler.sampleBasisWithoutSlopeBatch(
|
||||
coordinates, coordinates.length)).get(10, TimeUnit.SECONDS);
|
||||
for (int index = 0; index < result.length; index++) {
|
||||
assertEquals(100 + Math.floorMod(-2048, 17), result[index].naturalHeight());
|
||||
}
|
||||
assertEquals(coordinates.length, samples.get());
|
||||
assertEquals(2, opened.get());
|
||||
assertEquals(opened.get(), closed.get());
|
||||
assertEquals(null, pool.submit(scope::get).get(5, TimeUnit.SECONDS));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> sampler.sampleBasisWithoutSlopeBatch(new long[4097], 4097));
|
||||
long[] failureCoordinates = new long[4096];
|
||||
for (int index = 0; index < failureCoordinates.length; index++) {
|
||||
failureCoordinates[index] = RiverFootprint.pack(10_000 + index, -index);
|
||||
}
|
||||
assertThrows(ExecutionException.class, () -> pool.submit(() -> sampler.sampleBasisWithoutSlopeBatch(
|
||||
failureCoordinates, failureCoordinates.length)).get(10, TimeUnit.SECONDS));
|
||||
assertEquals(4, opened.get());
|
||||
assertEquals(opened.get(), closed.get());
|
||||
assertEquals(null, pool.submit(scope::get).get(5, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bankHeightsAtSeaLevelSkipOceanIntentSampling() {
|
||||
AtomicInteger classifierCalls = new AtomicInteger();
|
||||
|
||||
+231
@@ -2,6 +2,12 @@ package art.arcane.iris.generation.hydrology.surface;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.HydraulicChannelProfile;
|
||||
import art.arcane.iris.generation.hydrology.HydraulicSegment;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
|
||||
import art.arcane.iris.generation.hydrology.HydrologySurfaceDropRaster;
|
||||
import art.arcane.iris.generation.hydrology.RiverCourse;
|
||||
import art.arcane.iris.generation.hydrology.RiverCourseType;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
|
||||
@@ -13,10 +19,18 @@ import art.arcane.iris.generation.hydrology.IrisRiverBedProfile;
|
||||
import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
|
||||
@@ -33,6 +47,223 @@ public class ErosionFieldCompilerTest {
|
||||
default -> request.minimum();
|
||||
};
|
||||
|
||||
@Test
|
||||
public void wetIncisionPreflightMatchesFullVerdictsAcrossPondsCrossingsStepsAndPolicies() {
|
||||
int accepted = 0;
|
||||
int unsupported = 0;
|
||||
for (int scenario = 0; scenario < 24; scenario++) {
|
||||
int fixture = scenario;
|
||||
boolean crossing = scenario % 3 == 1;
|
||||
boolean coastal = scenario % 6 == 5;
|
||||
HydrologyPlannerSettings.Ponds ponds = scenario % 4 == 0 ? noPonds()
|
||||
: new HydrologyPlannerSettings.Ponds(new HydrologyPlannerSettings.Pond(true, 3, 8, 3),
|
||||
new HydrologyPlannerSettings.Pond(true, 3, 7, 2));
|
||||
HydrologyPlannerSettings.Erosion erosion = shapedErosion(
|
||||
IrisRiverBlendStyle.values()[scenario % IrisRiverBlendStyle.values().length], 4, 0.5D,
|
||||
IrisRiverBedProfile.values()[scenario % IrisRiverBedProfile.values().length], 1D, 2D);
|
||||
HydrologyPlannerSettings.Surface surface = surfaceWith(scenario % 2 == 0 ? 0D : 0.6D,
|
||||
0, 1.5D, erosion, ponds, HydrologyPlannerSettings.Channel.defaults(),
|
||||
HydrologyPlannerSettings.Flow.defaults());
|
||||
HydrologyTerrainSampler terrain = (x, z) -> {
|
||||
if (coastal && x > 12) {
|
||||
return HydrologyTerrainSample.ocean(50, "ocean");
|
||||
}
|
||||
int natural = coastal ? SEA_LEVEL : 90;
|
||||
if (fixture >= 12 && x >= -2 && x <= 2) {
|
||||
natural += 30;
|
||||
}
|
||||
if (fixture % 4 == 2 && z > 4) {
|
||||
natural += 12;
|
||||
}
|
||||
HydrologyTerrainSample sample = HydrologyTerrainSample.openLand(natural, 0D, "land")
|
||||
.withErosion(fixture % 7 != 0).withShoreWidth(z < 0 ? 0.5D : 3D);
|
||||
return fixture % 3 == 2 ? sample.withSurfacePolicy(new SurfaceRiverPolicy(
|
||||
"local", null, null, null, null, null, null, x < 0 ? 3 : 16)) : sample;
|
||||
};
|
||||
List<HydrologyPoint> path = crossing
|
||||
? List.of(new HydrologyPoint(-18, 90, -8), new HydrologyPoint(18, 90, 8),
|
||||
new HydrologyPoint(-18, 90, 8), new HydrologyPoint(18, 90, -8))
|
||||
: List.of(new HydrologyPoint(-18, 90, 0), new HydrologyPoint(18, 90, 0));
|
||||
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
|
||||
double[] widths = new double[centerline.size()];
|
||||
double[] depths = new double[centerline.size()];
|
||||
double[] multipliers = new double[centerline.size()];
|
||||
int[] heads = new int[centerline.size()];
|
||||
Arrays.fill(widths, 3D);
|
||||
Arrays.fill(depths, 3D);
|
||||
Arrays.fill(multipliers, 1D);
|
||||
for (int station = 0; station < heads.length; station++) {
|
||||
heads[station] = coastal ? SEA_LEVEL : 89 - (scenario % 4 == 3 ? station / 8 : 0);
|
||||
}
|
||||
ChannelProfile channel = new ChannelProfile(widths, depths, multipliers);
|
||||
ValleyProfile valley = ValleyProfile.fromHeads(heads, heads.length);
|
||||
SurfaceTerminal terminal = coastal ? SurfaceTerminal.OCEAN_MOUTH : SurfaceTerminal.SINKHOLE;
|
||||
HydrologyPlannerSettings settings = rasterSettings(surface);
|
||||
HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.empty();
|
||||
if (scenario % 4 == 3 && !coastal) {
|
||||
HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
|
||||
89, 81, 6, 3, true, true,
|
||||
List.of(new HydrologyPoint(-5, 89, 0), new HydrologyPoint(-4, 81, 0)),
|
||||
HydraulicChannelProfile.uniform(6, 3));
|
||||
RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(9L),
|
||||
OptionalLong.of(8L), "water", 1, List.of(), List.of(fall));
|
||||
drops = HydrologySurfaceDropRaster.compile(settings, terrain, CONSTANT_GEOMETRY, course);
|
||||
assertTrue("Drop raster scenario " + scenario, !drops.columns().isEmpty());
|
||||
}
|
||||
SurfaceRasterContext context = new SurfaceRasterContext(null, drops,
|
||||
scenario % 4 == 1 ? new SurfaceRunBoundary(
|
||||
new SurfaceRunBoundary.Plane(-18, 0, 1, 0, 10D),
|
||||
new SurfaceRunBoundary.Plane(18, 0, 1, 0, 10D)) : SurfaceRunBoundary.unbounded());
|
||||
ErosionFieldCompiler compiler = new ErosionFieldCompiler(settings, terrain);
|
||||
ErosionFieldCompiler.PreparedField prepared = compiler.prepare(centerline, channel, valley,
|
||||
terminal, coastal ? Math.max(0, centerline.size() - 12) : centerline.size());
|
||||
for (long seed = 0; seed < 4; seed++) {
|
||||
ErosionField full = compiler.compile(seed, centerline, channel, valley, terminal, 8,
|
||||
ponds, context, prepared);
|
||||
ErosionField publication = compiler.compileForPublication(seed, centerline, channel, valley,
|
||||
terminal, 8, ponds, context, prepared);
|
||||
assertEquals("scenario=" + scenario + " seed=" + seed, full.rejection(), publication.rejection());
|
||||
if (full.rejection() == HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED) {
|
||||
unsupported++;
|
||||
for (SurfaceColumn wet : publication.columns().values()) {
|
||||
if (wet.role() == SurfaceRole.CHANNEL) {
|
||||
assertEquals(full.column(wet.x(), wet.z()), wet);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assertEqualFields(full, publication);
|
||||
if (full.rejection() == null) {
|
||||
accepted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue("Accepted oracle cases: " + accepted, accepted >= 8);
|
||||
assertTrue("Unsupported oracle cases: " + unsupported, unsupported >= 8);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rasterBatchesPreserveExactDemandAndColumnOrder() {
|
||||
Map<Long, Integer> serialCalls = new HashMap<>();
|
||||
Map<Long, Integer> batchCalls = new HashMap<>();
|
||||
AtomicInteger batches = new AtomicInteger();
|
||||
HydrologyTerrainSampler serial = (x, z) -> {
|
||||
serialCalls.merge(RiverFootprint.pack(x, z), 1, Integer::sum);
|
||||
return HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 7), 0D, "land");
|
||||
};
|
||||
HydrologyTerrainSampler batched = new HydrologyTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample sample(int x, int z) {
|
||||
batchCalls.merge(RiverFootprint.pack(x, z), 1, Integer::sum);
|
||||
return HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 7), 0D, "land");
|
||||
}
|
||||
|
||||
public HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) {
|
||||
assertTrue(count > 0 && count <= 4096);
|
||||
batches.incrementAndGet();
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count];
|
||||
for (int index = 0; index < count; index++) {
|
||||
samples[index] = sample(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index]));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
};
|
||||
ErosionField expected = compile(serial, 300, SurfaceTerminal.SINKHOLE, 40).field();
|
||||
ErosionField actual = compile(batched, 300, SurfaceTerminal.SINKHOLE, 40).field();
|
||||
assertEquals(new ArrayList<>(expected.columns().values()), new ArrayList<>(actual.columns().values()));
|
||||
assertEquals(expected.rejection(), actual.rejection());
|
||||
assertEquals(expected.rejectionDetail(), actual.rejectionDetail());
|
||||
assertEquals(expected.uncontainedWetCells(), actual.uncontainedWetCells());
|
||||
assertEquals(expected.bankExcavation(), actual.bankExcavation());
|
||||
assertEquals(serialCalls, batchCalls);
|
||||
assertTrue(batches.get() > 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pondExclusionsPreserveCompleteAndClippedFieldsWithFewerTerrainSamples() throws Exception {
|
||||
List<String> fingerprints = new ArrayList<>();
|
||||
List<Integer> sampleCounts = new ArrayList<>();
|
||||
for (int scenario = 0; scenario < 8; scenario++) {
|
||||
double shore = switch (scenario) {
|
||||
case 0 -> Math.nextDown(1.75D);
|
||||
case 1 -> 1.75D;
|
||||
case 2 -> Math.nextUp(1.75D);
|
||||
default -> 1.5D;
|
||||
};
|
||||
boolean enabled = scenario != 4;
|
||||
boolean erode = scenario != 5;
|
||||
boolean coastal = scenario == 7;
|
||||
HydrologyPlannerSettings.Erosion defaults = HydrologyPlannerSettings.Erosion.defaults();
|
||||
HydrologyPlannerSettings.Erosion erosion = new HydrologyPlannerSettings.Erosion(enabled,
|
||||
defaults.smoothingRadius(), defaults.thalwegFraction(), defaults.blendCurve(), defaults.bedNoise(),
|
||||
defaults.style(), defaults.terraceSteps(), defaults.cliffFraction(), defaults.bedProfile(),
|
||||
defaults.shoreRise(), defaults.blendBaseWidth(),
|
||||
new HydrologyPlannerSettings.Excavation(16, scenario == 6 ? 5 : 48, 256));
|
||||
HydrologyPlannerSettings.Ponds ponds = new HydrologyPlannerSettings.Ponds(
|
||||
new HydrologyPlannerSettings.Pond(true, 6, 6, 3),
|
||||
new HydrologyPlannerSettings.Pond(true, 5, 5, 2));
|
||||
HydrologyPlannerSettings.Surface surface = surfaceWith(scenario == 3 ? 0.65D : 0D, 0, shore,
|
||||
erosion, ponds, HydrologyPlannerSettings.Channel.defaults(), HydrologyPlannerSettings.Flow.defaults());
|
||||
HydrologyTerrainSampler terrain = (x, z) -> coastal && x >= 4
|
||||
? HydrologyTerrainSample.ocean(50, "ocean")
|
||||
: HydrologyTerrainSample.openLand(coastal ? SEA_LEVEL : 80 + Math.max(0, z) / 3, 0D, "land")
|
||||
.withErosion(erode);
|
||||
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(
|
||||
new HydrologyPoint(-12, 80, 0), new HydrologyPoint(12, 80, 0)));
|
||||
double[] widths = new double[centerline.size()];
|
||||
double[] depths = new double[centerline.size()];
|
||||
double[] multipliers = new double[centerline.size()];
|
||||
int[] heads = new int[centerline.size()];
|
||||
Arrays.fill(widths, 3D);
|
||||
Arrays.fill(depths, 2D);
|
||||
Arrays.fill(multipliers, 1D);
|
||||
Arrays.fill(heads, coastal ? SEA_LEVEL : 80);
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
ErosionField complete = null;
|
||||
for (SurfaceBounds bounds : new SurfaceBounds[]{null, new SurfaceBounds(-20, -12, -1, 12),
|
||||
new SurfaceBounds(0, -12, 20, 12)}) {
|
||||
ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), (x, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return terrain.sample(x, z);
|
||||
}).compile(1234L, centerline, new ChannelProfile(widths, depths, multipliers),
|
||||
ValleyProfile.fromHeads(heads, coastal ? 17 : heads.length),
|
||||
coastal ? SurfaceTerminal.OCEAN_MOUTH : SurfaceTerminal.SINKHOLE, 8, ponds,
|
||||
SurfaceRasterContext.bounded(bounds));
|
||||
List<SurfaceColumn> ordered = new ArrayList<>(field.columns().values());
|
||||
ordered.sort(Comparator.comparingLong(column -> RiverFootprint.pack(column.x(), column.z())));
|
||||
for (SurfaceColumn column : ordered) {
|
||||
digest.update(column.toString().getBytes(StandardCharsets.UTF_8));
|
||||
if (bounds != null && bounds.contains(column.x(), column.z())) {
|
||||
assertEquals(complete.column(column.x(), column.z()), column);
|
||||
}
|
||||
}
|
||||
digest.update((field.rejection() + ":" + field.rejectionDetail() + ":"
|
||||
+ field.uncontainedWetCells() + ":" + field.bankExcavation()).getBytes(StandardCharsets.UTF_8));
|
||||
if (bounds == null) {
|
||||
complete = field;
|
||||
}
|
||||
}
|
||||
fingerprints.add(HexFormat.of().formatHex(digest.digest()));
|
||||
sampleCounts.add(samples.get());
|
||||
}
|
||||
assertEquals(List.of(
|
||||
"66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a",
|
||||
"66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a",
|
||||
"66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a",
|
||||
"744b15e684ba996f9c730ab1409ada2643985c902a6da1aadb5cc2b4882d640b",
|
||||
"2e7e4af1e433ccd0a7392f0d2e93bb85ac122088cd2cf8d55107f7dcfb4ec283",
|
||||
"7beee0b78c37b1287510fa0719375e14e0f0013eb8af81df8e20a3b5628612b7",
|
||||
"ad35016194318efa42dc862df4e2bb3d9ec398d3bacec79588576d74bfd1723a",
|
||||
"a2fe6d20c87be6b6ebf1f4503da75ee767e65d5c8e8c43d5c344957f48f3085a"), fingerprints);
|
||||
int[] previousSamples = {16724, 16724, 16724, 19594, 15164, 15464, 16724, 7367};
|
||||
int[] maximumSamples = {15500, 15500, 15500, 17000, 3000, 3300, 5000, 7367};
|
||||
for (int scenario = 0; scenario < previousSamples.length; scenario++) {
|
||||
assertTrue("scenario " + scenario + " samples " + sampleCounts.get(scenario)
|
||||
+ "/" + previousSamples[scenario], sampleCounts.get(scenario) <= maximumSamples[scenario]);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void geometricExclusionAvoidsTerrainSamplesWithoutChangingAsymmetricBanks() {
|
||||
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package art.arcane.iris.generation.hydrology.surface;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class SurfaceDistanceTableTest {
|
||||
@Test
|
||||
public void latticeResidualsReuseExactDistancesIncludingSignedZero() {
|
||||
SurfaceDistanceTable table = new SurfaceDistanceTable(32, StrictMath::hypot);
|
||||
for (int x = -66; x <= 66; x++) {
|
||||
for (int z = -66; z <= 66; z++) {
|
||||
equalBits(StrictMath.hypot(x / 2D, z / 2D), table.hypot(x / 2D, z / 2D));
|
||||
}
|
||||
}
|
||||
double[] special = {-0D, 0D, Double.MIN_VALUE, Double.MAX_VALUE, Double.NaN,
|
||||
Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, 0.25D, 31.75D, 32.5D};
|
||||
for (double x : special) {
|
||||
for (double z : special) {
|
||||
equalBits(StrictMath.hypot(x, z), table.hypot(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scalarProjectionMatchesAcrossDirectionsAndExtremeCoordinates() {
|
||||
SurfaceDistanceTable table = new SurfaceDistanceTable(64, StrictMath::hypot);
|
||||
int[] origins = {Integer.MIN_VALUE, Integer.MIN_VALUE + 64, -30000000, -1, 0,
|
||||
30000000, Integer.MAX_VALUE - 64, Integer.MAX_VALUE};
|
||||
for (int origin : origins) {
|
||||
for (int directionX = -1; directionX <= 1; directionX++) {
|
||||
for (int directionZ = -1; directionZ <= 1; directionZ++) {
|
||||
SurfaceCenterline centerline = segment(origin, -origin, origin + directionX, -origin + directionZ);
|
||||
for (int station = 0; station < 2; station++) {
|
||||
for (int x = -34; x <= 34; x++) {
|
||||
for (int z = -34; z <= 34; z++) {
|
||||
double pointX = origin + x;
|
||||
double pointZ = -origin + z;
|
||||
equalBits(centerline.distanceToSegment(station, pointX, pointZ),
|
||||
centerline.distanceToSegment(station, pointX, pointZ, table));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SurfaceCenterline arbitrary = segment(-1700000000, 1500000000, 1900000000, -1600000000);
|
||||
for (int x = -80; x <= 80; x++) {
|
||||
equalBits(arbitrary.distanceToSegment(0, x + 0.125D, x - 0.75D),
|
||||
arbitrary.distanceToSegment(0, x + 0.125D, x - 0.75D, table));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedStationSquaresOnlyCalculateTheBoundedTable() {
|
||||
AtomicInteger calculations = new AtomicInteger();
|
||||
SurfaceDistanceTable table = new SurfaceDistanceTable(33, (x, z) -> {
|
||||
calculations.incrementAndGet();
|
||||
return StrictMath.hypot(x, z);
|
||||
});
|
||||
int initialization = calculations.get();
|
||||
assertEquals(34 * 34 + 33, initialization);
|
||||
long samples = 0;
|
||||
for (int station = 0; station < 1000; station++) {
|
||||
SurfaceCenterline centerline = segment(station, -station, station + 1, -station + 1);
|
||||
for (int x = -32; x <= 32; x++) {
|
||||
for (int z = -32; z <= 32; z++) {
|
||||
centerline.distanceToSegment(0, station + x, -station + z, table);
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(4_225_000L, samples);
|
||||
assertEquals(initialization, calculations.get());
|
||||
table.hypot(34D, 0D);
|
||||
assertEquals(initialization + 1, calculations.get());
|
||||
}
|
||||
|
||||
private static SurfaceCenterline segment(int x, int z, int endX, int endZ) {
|
||||
return new SurfaceCenterline(new int[]{x, endX}, new int[]{z, endZ},
|
||||
new double[2], new double[2], new int[2]);
|
||||
}
|
||||
|
||||
private static void equalBits(double expected, double actual) {
|
||||
assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual));
|
||||
}
|
||||
}
|
||||
+21
@@ -37,6 +37,27 @@ public class SurfaceFootprintCompilerTest {
|
||||
default -> request.minimum();
|
||||
};
|
||||
|
||||
@Test
|
||||
public void impossibleWetIncisionRejectsBeforeSamplingWideBanks() {
|
||||
AtomicInteger bankSamples = new AtomicInteger();
|
||||
HydrologyTerrainSampler sampler = (x, z) -> {
|
||||
if (Math.abs(z) > 8) {
|
||||
bankSamples.incrementAndGet();
|
||||
}
|
||||
return HydrologyTerrainSample.openLand(x >= 60 && x <= 68 ? 170 : 90, 0D, "land");
|
||||
};
|
||||
RiverCourse course = course(List.of(segment(1L, HydrologyFeatureType.SURFACE_POOL,
|
||||
89, 89, points(0, 200, 89))));
|
||||
|
||||
SurfaceFootprint footprint = compiler(sampler).compileForPublication(course);
|
||||
|
||||
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, footprint.rejection());
|
||||
assertTrue("Wide-bank terrain samples: " + bankSamples.get(), bankSamples.get() < 1_000);
|
||||
SurfaceFootprint complete = compiler(sampler).compile(course);
|
||||
assertEquals(complete.rejection(), footprint.rejection());
|
||||
assertTrue(complete.columns().size() > footprint.columns().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mouthUsesConnectedFloodedLandWithoutChangingTerrainMetadataOrOwningWater() {
|
||||
int seaLevel = HydrologyPlannerSettings.defaults().seaLevel();
|
||||
|
||||
@@ -2,6 +2,7 @@ package art.arcane.iris.generation.image;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.terrain.ProceduralTerrainHeightSampler;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import com.google.gson.Gson;
|
||||
import org.junit.Rule;
|
||||
@@ -48,6 +49,33 @@ public class IrisImageMapRuntimeTest {
|
||||
assertEquals(first.hash(), second.hash());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heightImagesRetainPixelDetailAfterProceduralLatticeSampling() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("sampled-height-pack");
|
||||
writeMap(pack, "terrain", "terrain", grayscale(0, 255, 0, 255), new IrisImageMap()
|
||||
.setSource("terrain")
|
||||
.setType(IrisImageMapType.GRAYSCALE_HEIGHT)
|
||||
.setMinimumHeight(-64D)
|
||||
.setMaximumHeight(320D)
|
||||
.setOutOfBounds(IrisImageMapOutOfBounds.CLAMP));
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.getImageMaps().add(new IrisImageMapBinding()
|
||||
.setKey("terrain")
|
||||
.setMap("terrain")
|
||||
.setApplication(IrisImageMapApplication.TERRAIN_HEIGHT));
|
||||
ProceduralTerrainHeightSampler terrain = new ProceduralTerrainHeightSampler((x, z) -> x + 100D, 4);
|
||||
IrisData data = IrisData.openDatapackCompiler(pack);
|
||||
try {
|
||||
IrisImageMapRuntime runtime = IrisImageMapRuntime.compile(data, dimension, -64);
|
||||
for (int x = 0; x < 4; x++) {
|
||||
assertEquals(x % 2 == 0 ? 0D : 384D,
|
||||
runtime.sampleTerrainHeight(x, 0D, terrain.sample(x, 0D)), EPSILON);
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composesNamedMasksInDeclarationOrder() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("mask-pack");
|
||||
|
||||
+170
@@ -126,6 +126,176 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
carveForcedAirField.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verticalDensityPlanesUseGlobalAnchorsAndReduceSamplesAcrossChunkEdges() throws Exception {
|
||||
Engine engine = createEngine(128, 110);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
doReturn(4).when(dimension).getCaveDensitySamplingStep();
|
||||
doReturn(IrisWorld.builder().minHeight(-63).maxHeight(65).build()).when(engine).getWorld();
|
||||
IrisCaveCarver3D carver = new IrisCaveCarver3D(engine,
|
||||
createProfile(false, false).setDetailWeight(0D).setAllowFluid(false));
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
Set<Integer> sampledY = new HashSet<>();
|
||||
CNG density = mock(CNG.class);
|
||||
doAnswer(invocation -> {
|
||||
calls.incrementAndGet();
|
||||
double x = invocation.getArgument(0);
|
||||
double y = invocation.getArgument(1);
|
||||
double z = invocation.getArgument(2);
|
||||
sampledY.add((int) y);
|
||||
return x * 0.0001D + y * 0.01D + z * 0.0002D;
|
||||
}).when(density).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble());
|
||||
baseDensityField.set(carver, density);
|
||||
Method prepare = IrisCaveCarver3D.class.getDeclaredMethod("prepareVerticalDensityPlane",
|
||||
CaveCarveScratch.class, int.class, int.class, int.class);
|
||||
prepare.setAccessible(true);
|
||||
CaveCarveScratch scratch = new CaveCarveScratch();
|
||||
for (int y = 0; y < 64; y++) {
|
||||
prepare.invoke(carver, scratch, -16, 32, y);
|
||||
for (int x = 0; x < 3; x++) {
|
||||
for (int z = 0; z < 3; z++) {
|
||||
assertEquals((-16 + x * 8) * 0.0001D + y * 0.01D + (32 + z * 8) * 0.0002D,
|
||||
scratch.adaptivePlaneDensity[x * 3 + z], 1.0E-14D);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(153, calls.get());
|
||||
assertFalse(sampledY.contains(1));
|
||||
int interpolatedCalls = calls.get();
|
||||
doReturn(1).when(dimension).getCaveDensitySamplingStep();
|
||||
IrisCaveCarver3D scalar = new IrisCaveCarver3D(engine,
|
||||
createProfile(false, false).setDetailWeight(0D).setAllowFluid(false));
|
||||
baseDensityField.set(scalar, density);
|
||||
CaveCarveScratch scalarScratch = new CaveCarveScratch();
|
||||
for (int index = 0; index < 256; index++) {
|
||||
scalarScratch.planeColumnIndices[index] = index;
|
||||
}
|
||||
Method classify = IrisCaveCarver3D.class.getDeclaredMethod("classifyDensityPlaneAdaptive",
|
||||
CaveCarveScratch.class, int.class, int.class, int.class, int[].class, double[].class,
|
||||
int.class, boolean[].class, int.class, double.class);
|
||||
classify.setAccessible(true);
|
||||
calls.set(0);
|
||||
for (int y = 0; y < 64; y++) {
|
||||
classify.invoke(scalar, scalarScratch, -16, 32, y, scalarScratch.planeColumnIndices,
|
||||
scalarScratch.planeThresholdLimit, 256, scalarScratch.planeCarve, 8, 0.04D);
|
||||
}
|
||||
assertEquals(576, calls.get());
|
||||
assertTrue(interpolatedCalls < calls.get() * 0.3D);
|
||||
assertTrue(sampledY.containsAll(List.of(0, 3, 7, 63)));
|
||||
CaveCarveScratch neighboring = new CaveCarveScratch();
|
||||
prepare.invoke(carver, neighboring, 0, 32, 63);
|
||||
for (int z = 0; z < 3; z++) {
|
||||
assertEquals(scratch.adaptivePlaneDensity[6 + z], neighboring.adaptivePlaneDensity[z], 0D);
|
||||
}
|
||||
prepare.invoke(carver, scratch, 48, -32, 9);
|
||||
assertEquals(48 * 0.0001D + 0.09D - 32 * 0.0002D, scratch.adaptivePlaneDensity[0], 1.0E-14D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expressionStylesKeepTheOriginalSamplingAndCarvingBehavior() throws Exception {
|
||||
CNG density = mock(CNG.class);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
calls.incrementAndGet();
|
||||
double y = invocation.getArgument(1);
|
||||
return Math.sin(y * 0.2D);
|
||||
}).when(density).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble());
|
||||
IrisGeneratorStyle style = mock(IrisGeneratorStyle.class);
|
||||
doReturn("density_expression").when(style).getExpression();
|
||||
doReturn(density).when(style).create(any(RNG.class), any(IrisData.class));
|
||||
IrisCaveProfile profile = createProfile(false, false).setBaseDensityStyle(style)
|
||||
.setDetailWeight(0D).setAllowFluid(false)
|
||||
.setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT)));
|
||||
WriterCapture expected = null;
|
||||
int expectedSamples = 0;
|
||||
for (int step : new int[]{1, 4}) {
|
||||
Engine engine = createEngine(128, 110);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
doReturn(step).when(dimension).getCaveDensitySamplingStep();
|
||||
WriterCapture capture = createWriterCapture(128);
|
||||
calls.set(0);
|
||||
new IrisCaveCarver3D(engine, profile).carve(capture.writer, 0, 0,
|
||||
fullWeights(), 0D, 0D, null, filledHeights(110));
|
||||
assertFalse(capture.carvedCells.isEmpty());
|
||||
if (expected == null) {
|
||||
expected = capture;
|
||||
expectedSamples = calls.get();
|
||||
} else {
|
||||
assertEquals(expectedSamples, calls.get());
|
||||
assertEquals(expected.carvedCells, capture.carvedCells);
|
||||
assertEquals(expected.carvedLiquids, capture.carvedLiquids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verticalDensityPreservesTwoBlockModuleTunnelsWithoutBleedingAcrossBands() throws Exception {
|
||||
for (int step : new int[]{2, 4, 8}) {
|
||||
Engine engine = createEngine(128, 110);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
doReturn(step).when(dimension).getCaveDensitySamplingStep();
|
||||
IrisCaveProfile profile = createProfile(false, false).setDetailWeight(0D).setAllowFluid(false)
|
||||
.setVerticalEdgeFade(0).setVerticalEdgeFadeStrength(0D).setAllowSurfaceBreak(false)
|
||||
.setDensityThreshold(new IrisStyledRange(0.25D, 0.25D, new IrisGeneratorStyle(NoiseStyle.FLAT)))
|
||||
.setModules(new KList<>(new IrisCaveFieldModule(new IrisGeneratorStyle(NoiseStyle.FLAT),
|
||||
1D, 0D, new IrisRange(5D, 6D), false)));
|
||||
IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile);
|
||||
CNG base = mock(CNG.class);
|
||||
doReturn(1D).when(base).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble());
|
||||
baseDensityField.set(carver, base);
|
||||
CNG module = mock(CNG.class);
|
||||
doReturn(-1D).when(module).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble());
|
||||
moduleDensityField.set(((CaveFieldModuleState[]) modulesField.get(carver))[0], module);
|
||||
WriterCapture capture = createWriterCapture(128);
|
||||
carver.carve(capture.writer, -1, 2, fullWeights(), 0D, 0D, null, filledHeights(110));
|
||||
assertEquals(512, capture.carvedCells.size());
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
assertTrue(capture.carvedCells.contains(cellKey(x, 5, z)));
|
||||
assertTrue(capture.carvedCells.contains(cellKey(x, 6, z)));
|
||||
assertFalse(capture.carvedCells.contains(cellKey(x, 4, z)));
|
||||
assertFalse(capture.carvedCells.contains(cellKey(x, 7, z)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verticalDensityMatchesParallelDecisionsAndKeepsSurfaceFluidBarriers() throws Exception {
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
for (boolean warp : new boolean[]{false, true}) {
|
||||
Engine engine = createEngine(128, 110);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
doReturn(4).when(dimension).getCaveDensitySamplingStep();
|
||||
doReturn(IrisWorld.builder().minHeight(-63).maxHeight(65).build()).when(engine).getWorld();
|
||||
IrisCaveProfile profile = createProfile(warp, true).setFluidRequiresFloor(false)
|
||||
.setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT)));
|
||||
IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile);
|
||||
long[] boundaries = new long[256];
|
||||
Arrays.fill(boundaries, SurfaceFluidBoundaryPlan.boundary(
|
||||
SurfaceFluidBoundaryPlan.NO_BOUNDARY, Integer.MIN_VALUE));
|
||||
boundaries[16] = SurfaceFluidBoundaryPlan.boundary(61, 64);
|
||||
WriterCapture serial = createWriterCapture(128);
|
||||
WriterCapture parallel = createWriterCapture(128);
|
||||
int[] heights = filledHeights(110);
|
||||
int expected = carver.carve(serial.writer, -17, 19, fullWeights(), 0D, 0D,
|
||||
null, heights, boundaries, null, new CaveFluidSupportPlan());
|
||||
int actual = pool.submit(() -> carver.carve(parallel.writer, -17, 19, fullWeights(), 0D, 0D,
|
||||
null, heights, boundaries, null, new CaveFluidSupportPlan())).get(20, TimeUnit.SECONDS);
|
||||
assertTrue(expected > 0);
|
||||
assertEquals(expected, actual);
|
||||
assertEquals(serial.carvedCells, parallel.carvedCells);
|
||||
assertEquals(serial.carvedLiquids, parallel.carvedLiquids);
|
||||
for (int y = 61; y <= 64; y++) {
|
||||
assertFalse(serial.carvedCells.contains(cellKey(1, y, 0)));
|
||||
}
|
||||
assertTrue(countLiquid(serial, (byte) 2) > 0);
|
||||
assertTrue(countLiquid(serial, (byte) 0) > 0);
|
||||
assertTrue(maxY(serial.carvedCells) <= 110);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carvingReleasesSectionReferencesAfterSuccessAndFailure() throws Exception {
|
||||
for (boolean fail : new boolean[]{false, true}) {
|
||||
|
||||
+104
-1
@@ -37,6 +37,10 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class MantleWriterPreObjectJournalTest {
|
||||
@@ -72,7 +76,7 @@ public class MantleWriterPreObjectJournalTest {
|
||||
when(engineMantle.getComplex()).thenReturn(mock(IrisComplex.class));
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
matter = new IrisMatter(16, 16, 16);
|
||||
matter = spy(new IrisMatter(16, 16, 16));
|
||||
when(mantle.getWorldHeight()).thenReturn(32);
|
||||
when(mantle.getChunk(0, 0)).thenReturn(chunk);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
@@ -90,6 +94,88 @@ public class MantleWriterPreObjectJournalTest {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentTransactionsKeepOverlaysPrivateDuringObjectPublication() throws Exception {
|
||||
NativeBlockState original = mock(NativeBlockState.class);
|
||||
NativeBlockState published = mock(NativeBlockState.class);
|
||||
NativeBlockState firstOverlay = mock(NativeBlockState.class);
|
||||
NativeBlockState secondOverlay = mock(NativeBlockState.class);
|
||||
matter.<NativeBlockState>slice(NativeBlockState.class).set(X, Y, Z, original);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
Future<?> first = executor.submit(() -> verifyTransactionReads(original, firstOverlay, ready, start));
|
||||
Future<?> second = executor.submit(() -> verifyTransactionReads(original, secondOverlay, ready, start));
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS));
|
||||
start.countDown();
|
||||
writer.withComponentPriority(2, () -> {
|
||||
for (int pass = 0; pass < 100; pass++) {
|
||||
writer.setData(X, Y, Z, published);
|
||||
}
|
||||
});
|
||||
first.get(5, TimeUnit.SECONDS);
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
assertSame(published, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class));
|
||||
assertSame(original, writer.getPrerequisiteBlock(X, Y, Z));
|
||||
} finally {
|
||||
start.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void integerPrerequisitesDoNotReadTheObjectJournal() {
|
||||
matter.<Integer>slice(Integer.class).set(X, Y, Z, 17);
|
||||
clearInvocations(matter);
|
||||
|
||||
assertEquals(Integer.valueOf(17), writer.getPrerequisiteDataIfPresent(X, Y, Z, Integer.class));
|
||||
|
||||
verify(matter, never()).getSlice(PreObjectMatterCell.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hydrologyPrerequisitesHonorCapturedOriginalsAndNulls() {
|
||||
HydrologyCaveCell original = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR);
|
||||
HydrologyCaveCell live = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD);
|
||||
matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class).set(X, Y, Z, live);
|
||||
matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class).set(X, Y + 1, Z, live);
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class)
|
||||
.set(X, Y, Z, PreObjectMatterCell.hydrology(original));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class)
|
||||
.set(X, Y + 1, Z, PreObjectMatterCell.hydrology(null));
|
||||
|
||||
assertSame(original, writer.getPrerequisiteDataIfPresent(X, Y, Z, HydrologyCaveCell.class));
|
||||
assertNull(writer.getPrerequisiteDataIfPresent(X, Y + 1, Z, HydrologyCaveCell.class));
|
||||
assertSame(live, writer.getDataIfPresent(X, Y, Z, HydrologyCaveCell.class));
|
||||
assertSame(live, writer.getDataIfPresent(X, Y + 1, Z, HydrologyCaveCell.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carvedColumnsHonorCapturedHydrologyBeforeLiveCells() {
|
||||
HydrologyCaveCell original = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR);
|
||||
HydrologyCaveCell live = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD);
|
||||
MatterSlice<HydrologyCaveCell> hydrology = matter.slice(HydrologyCaveCell.class);
|
||||
MatterSlice<PreObjectMatterCell> journal = matter.slice(PreObjectMatterCell.class);
|
||||
hydrology.set(X, Y, Z, live);
|
||||
hydrology.set(X, Y + 1, Z, live);
|
||||
hydrology.set(X, Y + 2, Z, original);
|
||||
journal.set(X, Y, Z, PreObjectMatterCell.hydrology(original));
|
||||
journal.set(X, Y + 1, Z, PreObjectMatterCell.hydrology(null));
|
||||
journal.set(X, Y + 2, Z, PreObjectMatterCell.hydrology(live));
|
||||
matter.<MatterCavern>slice(MatterCavern.class)
|
||||
.set(X, Y + 1, Z, new MatterCavern(true, "cave", (byte) 0));
|
||||
|
||||
byte[] carved = writer.getPrerequisiteCarvedColumn(X, Z, 32);
|
||||
|
||||
assertEquals(1, carved[Y]);
|
||||
assertEquals(1, carved[Y + 1]);
|
||||
assertEquals(0, carved[Y + 2]);
|
||||
for (int y = 0; y < carved.length; y++) {
|
||||
assertEquals(writer.isPrerequisiteCarved(X, y, Z), carved[y] != 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstMutationCapturesEachSupportedOriginalIndependently() {
|
||||
NativeBlockState originalBlock = mock(NativeBlockState.class);
|
||||
@@ -207,6 +293,23 @@ public class MantleWriterPreObjectJournalTest {
|
||||
assertFalse(writer.restorePrerequisiteCell(X + 1, Y, Z));
|
||||
}
|
||||
|
||||
private void verifyTransactionReads(
|
||||
NativeBlockState original,
|
||||
NativeBlockState overlay,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start
|
||||
) {
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
ready.countDown();
|
||||
await(start);
|
||||
for (int pass = 0; pass < 100; pass++) {
|
||||
assertSame(original, transaction.get(X, Y, Z));
|
||||
}
|
||||
transaction.setData(X, Y, Z, overlay);
|
||||
assertSame(overlay, transaction.get(X, Y, Z));
|
||||
assertSame(original, writer.getPrerequisiteBlock(X, Y, Z));
|
||||
}
|
||||
|
||||
private void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS));
|
||||
|
||||
+263
-1
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.generation.mantle;
|
||||
|
||||
import art.arcane.iris.integration.Identifier;
|
||||
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||
@@ -27,6 +28,9 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -47,6 +51,219 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ObjectDestinationTransactionTest {
|
||||
@Test
|
||||
public void commonAndGenericMetadataSurviveBlockReplacementAndSourceReplay() {
|
||||
MantleWriter writer = writer();
|
||||
NativeBlockState custom = mock(NativeBlockState.class);
|
||||
NativeBlockState base = mock(NativeBlockState.class);
|
||||
NativeBlockState replacement = mock(NativeBlockState.class);
|
||||
Identifier identifier = Identifier.fromString("iris:tree_block");
|
||||
when(custom.isCustom()).thenReturn(true);
|
||||
when(custom.deferredPlacementKey()).thenReturn(identifier.toString());
|
||||
when(custom.placementBaseState()).thenReturn(base);
|
||||
TreeBlockMaterial firstMaterial = new TreeBlockMaterial("minecraft:oak_log");
|
||||
TreeBlockMaterial secondMaterial = new TreeBlockMaterial("minecraft:birch_log");
|
||||
Marker generic = new Marker("generic");
|
||||
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
int[][] positions = {{-17, 31}, {Integer.MIN_VALUE, Integer.MAX_VALUE}};
|
||||
for (int[] position : positions) {
|
||||
int x = position[0];
|
||||
int z = position[1];
|
||||
source.setData(x, 7, z, "first");
|
||||
source.setData(x, 7, z, firstMaterial);
|
||||
source.setData(x, 7, z, generic);
|
||||
source.set(x, 7, z, custom);
|
||||
source.setData(x, 7, z, "second");
|
||||
source.setData(x, 7, z, secondMaterial);
|
||||
source.setData(x, 7, z, Integer.valueOf(42));
|
||||
source.setData(x, 7, z, replacement);
|
||||
source.setData(x, 7, z, null);
|
||||
assertNull(source.getDataIfPresent(x, 7, z, Identifier.class));
|
||||
}
|
||||
ObjectSourcePlan plan = source.sourcePlanSince(0);
|
||||
assertEquals(16, source.mutationCheckpoint());
|
||||
assertEquals(35, plan.mutationWeight());
|
||||
for (int[] position : positions) {
|
||||
int x = position[0];
|
||||
int z = position[1];
|
||||
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, x >> 4, z >> 4);
|
||||
destination.apply(plan);
|
||||
List<ObjectDestinationTransaction.Mutation> sourceMutations = plan.mutationsFor(x >> 4, z >> 4);
|
||||
List<ObjectDestinationTransaction.Mutation> replayed = destination.sourcePlanSince(0).mutationsFor(x >> 4, z >> 4);
|
||||
assertEquals(sourceMutations.size(), replayed.size());
|
||||
for (int index = 0; index < sourceMutations.size(); index++) {
|
||||
assertSame(sourceMutations.get(index), replayed.get(index));
|
||||
}
|
||||
assertEquals(8, destination.mutationCheckpoint());
|
||||
assertSame(replacement, destination.get(x, 7, z));
|
||||
assertNull(destination.getDataIfPresent(x, 7, z, Identifier.class));
|
||||
assertEquals("second", destination.getDataIfPresent(x, 7, z, String.class));
|
||||
assertSame(secondMaterial, destination.getDataIfPresent(x, 7, z, TreeBlockMaterial.class));
|
||||
assertSame(generic, destination.getDataIfPresent(x, 7, z, Marker.class));
|
||||
assertEquals(Integer.valueOf(42), destination.getDataIfPresent(x, 7, z, Integer.class));
|
||||
destination.commit();
|
||||
InOrder order = inOrder(writer);
|
||||
order.verify(writer).setData(x, 7, z, "first");
|
||||
order.verify(writer).setData(x, 7, z, firstMaterial);
|
||||
order.verify(writer).setData(x, 7, z, generic);
|
||||
order.verify(writer).set(x, 7, z, custom);
|
||||
order.verify(writer).setData(x, 7, z, "second");
|
||||
order.verify(writer).setData(x, 7, z, secondMaterial);
|
||||
order.verify(writer).setData(x, 7, z, Integer.valueOf(42));
|
||||
order.verify(writer).setData(x, 7, z, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replayRechecksDestinationProtectionAndHeightBeforeRetainingMutations() {
|
||||
MantleWriter sourceWriter = writer();
|
||||
when(sourceWriter.getMantle().getWorldHeight()).thenReturn(128);
|
||||
NativeBlockState block = mock(NativeBlockState.class);
|
||||
NativeBlockState custom = mock(NativeBlockState.class);
|
||||
when(custom.isCustom()).thenReturn(true);
|
||||
when(custom.deferredPlacementKey()).thenReturn("iris:tree_block");
|
||||
when(custom.placementBaseState()).thenReturn(block);
|
||||
MatterCavern cavern = new MatterCavern(true, "", (byte) 0);
|
||||
TreeBlockMaterial material = new TreeBlockMaterial("minecraft:oak_log");
|
||||
ObjectDestinationTransaction source = new ObjectDestinationTransaction(sourceWriter, 0, 0);
|
||||
source.setData(0, 4, 0, block);
|
||||
source.setData(1, 4, 0, cavern);
|
||||
source.set(2, 4, 0, custom);
|
||||
source.setData(3, 0, 0, block);
|
||||
source.setData(4, 80, 0, block);
|
||||
source.setData(0, 4, 0, "tree");
|
||||
source.setData(0, 4, 0, material);
|
||||
ObjectSourcePlan plan = source.sourcePlanSince(0);
|
||||
assertEquals(7, plan.mutationsFor(0, 0).size());
|
||||
MantleWriter destinationWriter = writer();
|
||||
when(destinationWriter.getEngine().getDimension().isBedrock()).thenReturn(true);
|
||||
for (int x = 0; x < 3; x++) {
|
||||
when(destinationWriter.getPrerequisiteDataIfPresent(x, 4, 0, HydrologyCaveCell.class))
|
||||
.thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD));
|
||||
}
|
||||
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(destinationWriter, 0, 0);
|
||||
|
||||
destination.apply(plan);
|
||||
|
||||
List<ObjectDestinationTransaction.Mutation> replayed = destination.sourcePlanSince(0).mutationsFor(0, 0);
|
||||
assertEquals(2, replayed.size());
|
||||
assertSame(plan.mutationsFor(0, 0).get(5), replayed.get(0));
|
||||
assertSame(plan.mutationsFor(0, 0).get(6), replayed.get(1));
|
||||
assertNull(destination.getDataIfPresent(2, 4, 0, Identifier.class));
|
||||
assertNull(destination.getDataIfPresent(1, 4, 0, MatterCavern.class));
|
||||
assertEquals("tree", destination.getDataIfPresent(0, 4, 0, String.class));
|
||||
assertSame(material, destination.getDataIfPresent(0, 4, 0, TreeBlockMaterial.class));
|
||||
destination.commit();
|
||||
verify(destinationWriter, never()).setData(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class));
|
||||
verify(destinationWriter, never()).set(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class));
|
||||
verify(destinationWriter, never()).setData(anyInt(), anyInt(), anyInt(), any(MatterCavern.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCommonMetadataPublicationRestoresOriginalValuesAndMissingTypes() {
|
||||
MantleWriter writer = writer();
|
||||
TreeBlockMaterial originalMaterial = new TreeBlockMaterial("minecraft:oak_log");
|
||||
TreeBlockMaterial replacementMaterial = new TreeBlockMaterial("minecraft:birch_log");
|
||||
Marker generic = new Marker("generic");
|
||||
Marker failure = new Marker("failure");
|
||||
when(writer.getPrerequisiteDataIfPresent(0, 4, 0, String.class)).thenReturn("original");
|
||||
when(writer.getPrerequisiteDataIfPresent(0, 4, 0, TreeBlockMaterial.class)).thenReturn(originalMaterial);
|
||||
doThrow(new IllegalStateException("publication failed"))
|
||||
.when(writer).setData(1, 4, 0, failure);
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
assertEquals("original", transaction.getDataIfPresent(0, 4, 0, String.class));
|
||||
assertSame(originalMaterial, transaction.getDataIfPresent(0, 4, 0, TreeBlockMaterial.class));
|
||||
transaction.setData(0, 4, 0, "replacement");
|
||||
transaction.setData(0, 4, 0, replacementMaterial);
|
||||
transaction.setData(0, 4, 0, generic);
|
||||
transaction.setData(1, 4, 0, failure);
|
||||
|
||||
assertThrows(IllegalStateException.class, transaction::commit);
|
||||
|
||||
verify(writer).clearData(0, 4, 0, String.class);
|
||||
verify(writer).setData(0, 4, 0, "original");
|
||||
verify(writer).clearData(0, 4, 0, TreeBlockMaterial.class);
|
||||
verify(writer).setData(0, 4, 0, originalMaterial);
|
||||
verify(writer).clearData(0, 4, 0, Marker.class);
|
||||
verify(writer).setData(0, 4, 0, generic);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sparseOverlayMatchesScalarAndColumnReadsAcrossSignedCoordinates() {
|
||||
MantleWriter writer = writer();
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
int[][] columns = {{0, 0}, {-1, -1}, {1, -1}, {-1, 1},
|
||||
{Integer.MIN_VALUE, Integer.MAX_VALUE}, {Integer.MAX_VALUE, Integer.MIN_VALUE},
|
||||
{0, 1 << 26}, {1 << 26, 0}};
|
||||
NativeBlockState solid = mock(NativeBlockState.class);
|
||||
NativeBlockState air = mock(NativeBlockState.class);
|
||||
NativeBlockState fluid = mock(NativeBlockState.class);
|
||||
when(air.isAir()).thenReturn(true);
|
||||
when(fluid.isFluid()).thenReturn(true);
|
||||
MatterCavern cavern = new MatterCavern(true, "", (byte) 0);
|
||||
Object[] values = {solid, air, fluid, cavern,
|
||||
HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD),
|
||||
HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)};
|
||||
Map<ObjectDestinationTransaction.DataKey, Object> expected = new HashMap<>();
|
||||
Random random = new Random(918241L);
|
||||
for (int pass = 0; pass < 12; pass++) {
|
||||
for (int write = 0; write < 80; write++) {
|
||||
int[] column = columns[random.nextInt(columns.length)];
|
||||
int y = random.nextInt(64);
|
||||
Object value = values[random.nextInt(values.length)];
|
||||
transaction.setData(column[0], y, column[1], value);
|
||||
HydrologyCaveCell hydrology = (HydrologyCaveCell) expected.get(
|
||||
new ObjectDestinationTransaction.DataKey(column[0], y, column[1], HydrologyCaveCell.class));
|
||||
if (!(value instanceof HydrologyCaveCell)
|
||||
&& hydrology != null && hydrology.protectsPlacement()) {
|
||||
continue;
|
||||
}
|
||||
Class<?> type = value instanceof NativeBlockState ? NativeBlockState.class : value.getClass();
|
||||
expected.put(new ObjectDestinationTransaction.DataKey(column[0], y, column[1], type), value);
|
||||
}
|
||||
for (int[] column : columns) {
|
||||
byte[] carved = new byte[64];
|
||||
for (int y = 0; y < 64; y++) {
|
||||
NativeBlockState block = (NativeBlockState) expected.get(
|
||||
new ObjectDestinationTransaction.DataKey(column[0], y, column[1], NativeBlockState.class));
|
||||
HydrologyCaveCell hydrology = (HydrologyCaveCell) expected.get(
|
||||
new ObjectDestinationTransaction.DataKey(column[0], y, column[1], HydrologyCaveCell.class));
|
||||
MatterCavern expectedCavern = (MatterCavern) expected.get(
|
||||
new ObjectDestinationTransaction.DataKey(column[0], y, column[1], MatterCavern.class));
|
||||
boolean isCarved = block != solid && (hydrology == null
|
||||
? expectedCavern != null : hydrology.carves());
|
||||
carved[y] = isCarved ? (byte) 1 : 0;
|
||||
assertSame(block, transaction.get(column[0], y, column[1]));
|
||||
assertSame(hydrology, transaction.getDataIfPresent(column[0], y, column[1], HydrologyCaveCell.class));
|
||||
assertSame(expectedCavern, transaction.getDataIfPresent(column[0], y, column[1], MatterCavern.class));
|
||||
assertEquals(isCarved, transaction.isCarved(column[0], y, column[1]));
|
||||
}
|
||||
assertArrayEquals(carved, transaction.getCarvedColumn(column[0], column[1], 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlayReadsLeavePrerequisiteUpdatesVisibleAndReturnedColumnsIndependent() {
|
||||
MantleWriter writer = writer();
|
||||
NativeBlockState first = mock(NativeBlockState.class);
|
||||
NativeBlockState second = mock(NativeBlockState.class);
|
||||
when(writer.getPrerequisiteBlock(-1, 5, -1)).thenReturn(first, second);
|
||||
byte[] prerequisite = new byte[64];
|
||||
prerequisite[5] = 1;
|
||||
when(writer.getPrerequisiteCarvedColumn(-1, -1, 64)).thenReturn(prerequisite);
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, -1, -1);
|
||||
|
||||
assertSame(first, transaction.get(-1, 5, -1));
|
||||
assertSame(second, transaction.get(-1, 5, -1));
|
||||
byte[] column = transaction.getCarvedColumn(-1, -1, 64);
|
||||
column[5] = 0;
|
||||
assertEquals(1, transaction.getCarvedColumn(-1, -1, 64)[5]);
|
||||
transaction.setData(-1, 5, -1, second);
|
||||
assertEquals(0, transaction.getCarvedColumn(-1, -1, 64)[5]);
|
||||
assertEquals(1, prerequisite[5]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlayReadsEarlierWritesAndCommitsOnlyDestinationChunk() {
|
||||
MantleWriter writer = writer();
|
||||
@@ -122,6 +339,46 @@ public class ObjectDestinationTransactionTest {
|
||||
verify(writer, never()).setData(-1, 4, 16, foreign);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void indexedSourceReplayPreservesCustomIdentityAndWriteOrderAtNegativeChunks() {
|
||||
MantleWriter writer = writer();
|
||||
NativeBlockState custom = mock(NativeBlockState.class);
|
||||
NativeBlockState base = mock(NativeBlockState.class);
|
||||
NativeBlockState replacement = mock(NativeBlockState.class);
|
||||
Identifier identifier = Identifier.fromString("iris:custom_block");
|
||||
when(custom.isCustom()).thenReturn(true);
|
||||
when(custom.deferredPlacementKey()).thenReturn(identifier.toString());
|
||||
when(custom.placementBaseState()).thenReturn(base);
|
||||
Marker first = new Marker("first");
|
||||
Marker second = new Marker("second");
|
||||
Marker foreign = new Marker("foreign");
|
||||
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
source.setData(-17, 4, 31, first);
|
||||
source.set(-17, 4, 31, custom);
|
||||
source.setData(-16, 4, 31, foreign);
|
||||
source.setData(-17, 4, 31, replacement);
|
||||
source.setData(-17, 4, 32, foreign);
|
||||
source.set(-17, 4, 31, custom);
|
||||
source.setData(-17, 4, 31, second);
|
||||
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, -2, 1);
|
||||
|
||||
destination.apply(source.sourcePlanSince(0));
|
||||
|
||||
assertEquals(5, destination.mutationCheckpoint());
|
||||
assertSame(base, destination.get(-17, 4, 31));
|
||||
assertEquals(identifier, destination.getDataIfPresent(-17, 4, 31, Identifier.class));
|
||||
assertSame(second, destination.getDataIfPresent(-17, 4, 31, Marker.class));
|
||||
destination.commit();
|
||||
InOrder order = inOrder(writer);
|
||||
order.verify(writer).setData(-17, 4, 31, first);
|
||||
order.verify(writer).set(-17, 4, 31, custom);
|
||||
order.verify(writer).setData(-17, 4, 31, replacement);
|
||||
order.verify(writer).set(-17, 4, 31, custom);
|
||||
order.verify(writer).setData(-17, 4, 31, second);
|
||||
verify(writer, never()).setData(-16, 4, 31, foreign);
|
||||
verify(writer, never()).setData(-17, 4, 32, foreign);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourcePlanRejectsAnInvalidCheckpoint() {
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer(), 0, 0);
|
||||
@@ -340,8 +597,13 @@ public class ObjectDestinationTransactionTest {
|
||||
transaction.setData(0, 4, 0, block);
|
||||
assertNull(transaction.getDataIfPresent(0, 4, 0, Identifier.class));
|
||||
transaction.setData(1, 4, 0, failure);
|
||||
ObjectSourcePlan plan = transaction.sourcePlanSince(0);
|
||||
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
destination.apply(plan);
|
||||
assertNull(destination.getDataIfPresent(0, 4, 0, Identifier.class));
|
||||
assertSame(plan.mutationsFor(0, 0).getFirst(), destination.sourcePlanSince(0).mutationsFor(0, 0).getFirst());
|
||||
|
||||
assertThrows(IllegalStateException.class, transaction::commit);
|
||||
assertThrows(IllegalStateException.class, destination::commit);
|
||||
verify(writer).clearData(0, 4, 0, Identifier.class);
|
||||
verify(writer).setData(0, 4, 0, originalIdentifier);
|
||||
}
|
||||
|
||||
+147
-1
@@ -1,22 +1,91 @@
|
||||
package art.arcane.iris.generation.mantle;
|
||||
|
||||
import art.arcane.volmlib.util.cache.CacheKey;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
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 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 ObjectSourcePlanCacheTest {
|
||||
@Test
|
||||
public void defaultCapacityRetainsNeighboringSourceWorkingSetWithoutRebuilding() {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache();
|
||||
AtomicInteger builds = new AtomicInteger();
|
||||
ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(
|
||||
0, 4, 0, String.class);
|
||||
ObjectDestinationTransaction.Mutation mutation = new ObjectDestinationTransaction.SetMutation(key, "marker");
|
||||
ObjectSourcePlan expected = new ObjectSourcePlan(Collections.nCopies(2048, mutation));
|
||||
assertTrue(expected.mutationWeight() * 1024L > 1_048_576L);
|
||||
for (int sweep = 0; sweep < 3; sweep++) {
|
||||
for (int x = -16; x < 16; x++) {
|
||||
for (int z = -16; z < 16; z++) {
|
||||
assertSame(expected, cache.get(x, z, () -> {
|
||||
builds.incrementAndGet();
|
||||
return expected;
|
||||
}));
|
||||
}
|
||||
}
|
||||
assertEquals(1024L, cache.estimatedSize());
|
||||
assertEquals(1024, builds.get());
|
||||
}
|
||||
cache.clear();
|
||||
assertEquals(0L, cache.estimatedSize());
|
||||
assertSame(expected, cache.get(-16, -16, () -> {
|
||||
builds.incrementAndGet();
|
||||
return expected;
|
||||
}));
|
||||
assertEquals(1025, builds.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedCoordinatesBuildWhileAnotherPlanIsPending() throws Exception {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
ObjectSourcePlan first = planAt(1);
|
||||
ObjectSourcePlan second = planAt(2);
|
||||
CountDownLatch firstEntered = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
assertEquals(Long.hashCode(CacheKey.mix(CacheKey.key(32113, 37))),
|
||||
Long.hashCode(CacheKey.mix(CacheKey.key(32667, 37))));
|
||||
Future<ObjectSourcePlan> firstResult = executor.submit(() -> cache.get(32113, 37, () -> {
|
||||
firstEntered.countDown();
|
||||
try {
|
||||
assertTrue(releaseFirst.await(10L, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
return first;
|
||||
}));
|
||||
assertTrue(firstEntered.await(5L, TimeUnit.SECONDS));
|
||||
Future<ObjectSourcePlan> secondResult = executor.submit(() -> cache.get(32667, 37, () -> second));
|
||||
|
||||
assertSame(second, secondResult.get(3L, TimeUnit.SECONDS));
|
||||
releaseFirst.countDown();
|
||||
assertSame(first, firstResult.get(5L, TimeUnit.SECONDS));
|
||||
assertSame(first, cache.get(32113, 37, () -> second));
|
||||
assertSame(second, cache.get(32667, 37, () -> first));
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentRequestsBuildOneSourcePlan() throws Exception {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
@@ -51,9 +120,9 @@ public class ObjectSourcePlanCacheTest {
|
||||
|
||||
@Test
|
||||
public void cacheEvictsByMutationWeightAndCanBeCleared() {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(3L);
|
||||
ObjectSourcePlan first = planAt(0);
|
||||
ObjectSourcePlan second = planAt(1);
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.mutationWeight());
|
||||
|
||||
cache.get(0, 0, () -> first);
|
||||
cache.get(1, 0, () -> second);
|
||||
@@ -68,6 +137,83 @@ public class ObjectSourcePlanCacheTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlanCache(0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearingPendingWorkKeepsNewGenerationIsolated() throws Exception {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
ObjectSourcePlan old = planAt(1);
|
||||
ObjectSourcePlan replacement = planAt(2);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
|
||||
Future<ObjectSourcePlan> first = executor.submit(() -> cache.get(7, 8, () -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
return old;
|
||||
}));
|
||||
try {
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
cache.clear();
|
||||
assertSame(replacement, cache.get(7, 8, () -> replacement));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
assertSame(old, first.get(5L, TimeUnit.SECONDS));
|
||||
assertSame(replacement, cache.get(7, 8, () -> old));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void waitingForkJoinWorkerAllowsQueuedDependencyToRun() throws Exception {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
ObjectSourcePlan expected = planAt(1);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch waiting = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try (ExecutorService owner = Executors.newSingleThreadExecutor();
|
||||
ForkJoinPool pool = new ForkJoinPool(1)) {
|
||||
Future<ObjectSourcePlan> first = owner.submit(() -> cache.get(7, 8, () -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
return expected;
|
||||
}));
|
||||
try {
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
Future<ObjectSourcePlan> second = pool.submit(() -> {
|
||||
waiting.countDown();
|
||||
return cache.get(7, 8, () -> { throw new AssertionError("Duplicate build"); });
|
||||
});
|
||||
assertTrue(waiting.await(5L, TimeUnit.SECONDS));
|
||||
pool.submit(release::countDown).get(5L, TimeUnit.SECONDS);
|
||||
assertSame(expected, first.get(5L, TimeUnit.SECONDS));
|
||||
assertSame(expected, second.get(5L, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedAndNullBuildsAreRetryableAndRecursionFailsImmediately() {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
IllegalArgumentException failure = new IllegalArgumentException("Build failed");
|
||||
assertSame(failure, assertThrows(IllegalArgumentException.class,
|
||||
() -> cache.get(7, 8, () -> { throw failure; })));
|
||||
assertNull(cache.get(7, 8, () -> null));
|
||||
assertThrows(IllegalStateException.class, () -> cache.get(7, 8,
|
||||
() -> cache.get(7, 8, () -> planAt(1))));
|
||||
ObjectSourcePlan expected = planAt(1);
|
||||
assertSame(expected, cache.get(7, 8, () -> expected));
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(10L, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectSourcePlan planAt(int x) {
|
||||
ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(
|
||||
x,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package art.arcane.iris.generation.mantle;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ObjectSourcePlanTest {
|
||||
@Test
|
||||
public void destinationLookupRetainsImmutableIndexedOrder() {
|
||||
List<ObjectDestinationTransaction.Mutation> mutations = new ArrayList<>();
|
||||
int[][] positions = {{-1, -1}, {0, 0}, {-16, -16}, {-17, -1},
|
||||
{Integer.MIN_VALUE, Integer.MAX_VALUE}, {Integer.MAX_VALUE, Integer.MIN_VALUE}};
|
||||
for (int pass = 0; pass < 3; pass++) {
|
||||
for (int[] position : positions) {
|
||||
mutations.add(mutationAt(position[0], position[1]));
|
||||
}
|
||||
}
|
||||
ObjectSourcePlan plan = new ObjectSourcePlan(mutations);
|
||||
List<ObjectDestinationTransaction.Mutation> expected = List.of(
|
||||
mutations.get(0), mutations.get(2), mutations.get(6), mutations.get(8), mutations.get(12), mutations.get(14));
|
||||
mutations.clear();
|
||||
|
||||
List<ObjectDestinationTransaction.Mutation> local = plan.mutationsFor(-1, -1);
|
||||
assertEquals(expected, local);
|
||||
assertSame(local, plan.mutationsFor(-1, -1));
|
||||
assertEquals(3, plan.mutationsFor(Integer.MIN_VALUE >> 4, Integer.MAX_VALUE >> 4).size());
|
||||
assertEquals(3, plan.mutationsFor(Integer.MAX_VALUE >> 4, Integer.MIN_VALUE >> 4).size());
|
||||
assertTrue(plan.mutationsFor(1, -1).isEmpty());
|
||||
assertTrue(plan.mutationsFor(-1, 1).isEmpty());
|
||||
assertThrows(UnsupportedOperationException.class, () -> local.clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cacheWeightIncludesEachDestinationIndex() {
|
||||
ObjectDestinationTransaction.Mutation first = mutationAt(0, 0);
|
||||
ObjectDestinationTransaction.Mutation nearby = mutationAt(1, 0);
|
||||
ObjectDestinationTransaction.Mutation remote = mutationAt(16, 0);
|
||||
ObjectSourcePlan compact = new ObjectSourcePlan(List.of(first, nearby));
|
||||
ObjectSourcePlan scattered = new ObjectSourcePlan(List.of(first, remote));
|
||||
|
||||
assertTrue(compact.mutationWeight() > 1 + first.weight() + nearby.weight());
|
||||
assertTrue(scattered.mutationWeight() > compact.mutationWeight());
|
||||
assertThrows(NullPointerException.class, () -> new ObjectSourcePlan(Arrays.asList(first, null)));
|
||||
}
|
||||
|
||||
private static ObjectDestinationTransaction.Mutation mutationAt(int x, int z) {
|
||||
return new ObjectDestinationTransaction.SetMutation(
|
||||
new ObjectDestinationTransaction.DataKey(x, 4, z, String.class), "marker");
|
||||
}
|
||||
}
|
||||
+136
-2
@@ -34,6 +34,11 @@ import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
@@ -97,6 +102,128 @@ public class EngineShutdownDrainTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closingRuntimeAllowsItsBackgroundWorkersToOpenCapturedScopes() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
doCallRealMethod().when(fixture.engine).openGenerationRuntimeScope(any());
|
||||
IrisEngine.GenerationRuntimeBinding binding = new IrisEngine.GenerationRuntimeBinding(
|
||||
fixture.engine, fixture.generation);
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
doAnswer(invocation -> {
|
||||
Future<?> continuation = worker.submit(() -> {
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = fixture.engine.openGenerationRuntimeScope(binding)) {
|
||||
assertSame(binding, fixture.engine.generationRuntimeScopes.current());
|
||||
assertSame(fixture.runtime, fixture.engine.runtime);
|
||||
assertTrue(fixture.engine.getClosing().get());
|
||||
assertThrows(IllegalStateException.class, () -> fixture.engine.requireRunning("start generation"));
|
||||
}
|
||||
});
|
||||
continuation.get(5, TimeUnit.SECONDS);
|
||||
return null;
|
||||
}).when(fixture.complex).close();
|
||||
|
||||
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
|
||||
fixture.shutdown.close();
|
||||
assertTrue(fixture.engine.closed);
|
||||
assertThrows(IllegalStateException.class, () -> fixture.engine.openGenerationRuntimeScope(binding));
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentCloseWaitsForTheRunningShutdown() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
ExecutorService closer = Executors.newSingleThreadExecutor();
|
||||
CompletableFuture<Future<?>> concurrentClose = new CompletableFuture<>();
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
doAnswer(invocation -> {
|
||||
concurrentClose.complete(closer.submit(() -> {
|
||||
entered.countDown();
|
||||
fixture.shutdown.close();
|
||||
}));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(fixture.complex).close();
|
||||
|
||||
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
|
||||
fixture.shutdown.close();
|
||||
concurrentClose.get(5, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
|
||||
verify(fixture.complex).close();
|
||||
verify(fixture.mantle).close();
|
||||
assertTrue(fixture.engine.closed);
|
||||
} finally {
|
||||
closer.shutdownNow();
|
||||
assertTrue(closer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shutdownWaitsForConcurrentDetachedRetirementWithoutClosingItTwice() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
GenerationRuntime detached = generation(target(), mock(EngineMantle.class));
|
||||
IrisComplex detachedComplex = detached.complex();
|
||||
fixture.engine.detachedGenerationRuntimes.add(detached);
|
||||
CountDownLatch retirementStarted = new CountDownLatch(1);
|
||||
CountDownLatch allowRetirement = new CountDownLatch(1);
|
||||
CountDownLatch backgroundDrained = new CountDownLatch(1);
|
||||
doAnswer(invocation -> {
|
||||
retirementStarted.countDown();
|
||||
assertTrue(allowRetirement.await(5, TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(detachedComplex).close();
|
||||
when(fixture.engine.backgroundTasks.drainBackgroundTasks("close")).thenAnswer(invocation -> {
|
||||
backgroundDrained.countDown();
|
||||
return new EngineBackgroundTasks.BackgroundTaskDrain(null, true);
|
||||
});
|
||||
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> retiring = workers.submit(() -> fixture.engine.closeDetachedGenerationRuntime(
|
||||
new IrisEngine.GenerationRuntimeBinding(fixture.engine, detached)));
|
||||
assertTrue(retirementStarted.await(5, TimeUnit.SECONDS));
|
||||
Future<?> shutdown = workers.submit(() -> {
|
||||
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
|
||||
fixture.shutdown.close();
|
||||
}
|
||||
});
|
||||
assertTrue(backgroundDrained.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(shutdown.isDone());
|
||||
verify(fixture.target, never()).close();
|
||||
allowRetirement.countDown();
|
||||
retiring.get(5, TimeUnit.SECONDS);
|
||||
shutdown.get(5, TimeUnit.SECONDS);
|
||||
verify(detached.complex()).close();
|
||||
verify(detached.target()).close();
|
||||
assertTrue(fixture.engine.closed);
|
||||
} finally {
|
||||
allowRetirement.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedDetachedRetirementRetainsOwnershipForShutdownRetry() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
GenerationRuntime detached = generation(target(), mock(EngineMantle.class));
|
||||
IrisComplex detachedComplex = detached.complex();
|
||||
fixture.engine.detachedGenerationRuntimes.add(detached);
|
||||
doThrow(new IllegalStateException("Background work is still active")).doNothing()
|
||||
.when(detachedComplex).close();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> fixture.engine.closeDetachedGenerationRuntime(
|
||||
new IrisEngine.GenerationRuntimeBinding(fixture.engine, detached)));
|
||||
assertTrue(fixture.engine.detachedGenerationRuntimes.contains(detached));
|
||||
verify(detached.target(), never()).close();
|
||||
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
|
||||
fixture.shutdown.close();
|
||||
}
|
||||
verify(detached.complex(), times(2)).close();
|
||||
verify(detached.target()).close();
|
||||
assertTrue(fixture.engine.closed);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void settledStructureBootstrapFailureDoesNotPreventEngineCleanup() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
@@ -726,14 +853,21 @@ public class EngineShutdownDrainTest {
|
||||
setField(engine, "lifecycleLock", new Object());
|
||||
setField(engine, "runtimeAssembly", new ThreadLocal<RuntimeAssembly>());
|
||||
setField(engine, "detachedGenerationRuntimes", ConcurrentHashMap.newKeySet());
|
||||
when(engine.getClosing()).thenReturn(new AtomicBoolean());
|
||||
setField(engine, "retiringGenerationRuntimes", ConcurrentHashMap.newKeySet());
|
||||
setField(engine, "generationRuntimeScopes", new GenerationRuntimeScopeState());
|
||||
AtomicBoolean closing = new AtomicBoolean();
|
||||
setField(engine, "closing", closing);
|
||||
when(engine.getClosing()).thenReturn(closing);
|
||||
when(engine.getGenerationSessions()).thenReturn(sessions);
|
||||
when(engine.getPlatformHooks()).thenReturn(mock(EnginePlatformHooks.class));
|
||||
when(engine.getMantle()).thenReturn(mantle);
|
||||
IrisWorld world = target.getWorld();
|
||||
when(engine.getWorld()).thenReturn(world);
|
||||
when(engine.beginShutdown()).thenReturn(true);
|
||||
doCallRealMethod().when(engine).beginShutdown();
|
||||
doCallRealMethod().when(engine).requireRunning(anyString());
|
||||
doCallRealMethod().when(engine).closeDetachedGenerationRuntime(any());
|
||||
engine.runtime = runtime;
|
||||
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
|
||||
engine.publishedTarget = target;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-7
@@ -19,6 +19,8 @@ import art.arcane.volmlib.util.function.Consumer4;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import org.junit.Test;
|
||||
@@ -208,6 +210,7 @@ public class IrisCarveWallPaletteScopeTest {
|
||||
engine.runtime = published;
|
||||
doReturn(IrisWorld.builder().minHeight(-64).maxHeight(0).build()).when(engine).getWorld();
|
||||
doReturn(64).when(engine).getHeight();
|
||||
doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks();
|
||||
doReturn(mock(EngineMetrics.class, RETURNS_DEEP_STUBS)).when(engine).getMetrics();
|
||||
doReturn(48).when(context).getRoundedHeight(anyInt(), anyInt());
|
||||
IrisComplex activeComplex = active.complex();
|
||||
@@ -230,13 +233,15 @@ public class IrisCarveWallPaletteScopeTest {
|
||||
paletteCalls.incrementAndGet();
|
||||
return wall;
|
||||
}).when(palette).get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class));
|
||||
doAnswer(call -> {
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(
|
||||
new IrisEngine.GenerationRuntimeBinding(engine, active))) {
|
||||
assertSame(engine.runtimeAssembly.get() == null ? active.data() : assemblyData, engine.getData());
|
||||
}
|
||||
return biome;
|
||||
}).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
for (IrisComplex complex : new IrisComplex[]{activeComplex, detachedComplex, assemblyComplex}) {
|
||||
doReturn(ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> null)))
|
||||
.when(complex).getTrueBiomeStream();
|
||||
doReturn(ProceduralStream.of((x, z) -> scopedBiome(biome), Interpolated.of(value -> 0D, value -> null)))
|
||||
.when(complex).getCaveBiomeStream();
|
||||
doReturn(ProceduralStream.of((x, z) -> 48D, Interpolated.DOUBLE))
|
||||
.when(complex).getHeightStream();
|
||||
}
|
||||
doAnswer(call -> scopedBiome(biome)).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doAnswer(call -> {
|
||||
dataLookups.incrementAndGet();
|
||||
@@ -246,5 +251,14 @@ public class IrisCarveWallPaletteScopeTest {
|
||||
field(modifier, IrisCarveModifier.class, "AIR", air);
|
||||
field(modifier, IrisCarveModifier.class, "LAVA", air);
|
||||
}
|
||||
|
||||
private IrisBiome scopedBiome(IrisBiome biome) {
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(
|
||||
new IrisEngine.GenerationRuntimeBinding(engine, active))) {
|
||||
assertSame(engine.runtimeAssembly.get() == null ? active.data() : assemblyData, engine.getData());
|
||||
}
|
||||
return biome;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+90
-3
@@ -84,6 +84,72 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
}
|
||||
}
|
||||
|
||||
public static class CoarseBounds {
|
||||
@Test
|
||||
public void coarseBoundsUseGlobalCornersAcrossNegativeChunkSeams() throws Exception {
|
||||
for (int step : new int[]{8, 16, 32}) {
|
||||
IrisComplex complex = createComplex();
|
||||
setStep(complex, step);
|
||||
CoordinateInterpolator interpolator = new CoordinateInterpolator();
|
||||
for (double x : new double[]{-33D, -32D, -17D, -16D, -1D, 0D, 15D, 16D, 31D, 32D}) {
|
||||
for (double z : new double[]{-17D, -0.25D, 0D, 15.5D, 16D}) {
|
||||
NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBoundsMethod(), interpolator, x, z);
|
||||
assertBoundsBitsEqual(expectedGridSampleBounds(x, z, step), actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coarseGridReducesDistinctEnvelopeEvaluationsForDenseTerrainNodes() throws Exception {
|
||||
for (int step : new int[]{4, 8, 16, 32}) {
|
||||
IrisComplex complex = createComplex();
|
||||
setStep(complex, step);
|
||||
CountingInterpolator interpolator = new CountingInterpolator(1D, 9D);
|
||||
for (int x = 0; x < 32; x += 4) {
|
||||
for (int z = 0; z < 32; z += 4) {
|
||||
NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBoundsMethod(), interpolator, x, z);
|
||||
assertEquals(1D, actual.min(), 0D);
|
||||
assertEquals(9D, actual.max(), 0D);
|
||||
}
|
||||
}
|
||||
int side = step == 4 ? 8 : 32 / step + 1;
|
||||
assertEquals(side * side, interpolator.getInvocations());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyInterpolatorGroupKeepsItsOwnEnvelopeAndGenerators() throws Exception {
|
||||
IrisComplex complex = createComplex();
|
||||
setStep(complex, 16);
|
||||
CountingInterpolator first = new CountingInterpolator(1D, 2D);
|
||||
CountingInterpolator second = new CountingInterpolator(10D, 20D);
|
||||
SampleGenerator firstGenerator = new SampleGenerator(0.25D);
|
||||
SampleGenerator secondGenerator = new SampleGenerator(0.75D);
|
||||
setField(complex, "frozenInterpolators", new IrisInterpolator[]{first, second});
|
||||
setField(complex, "frozenGenerators", new IrisGenerator[][]{{firstGenerator}, {secondGenerator}});
|
||||
setField(complex, "biomeBuffetGenerators", new HashMap<>());
|
||||
Method method = IrisComplex.class.getDeclaredMethod("getInterpolatedHeight", Engine.class,
|
||||
double.class, double.class, long.class);
|
||||
method.setAccessible(true);
|
||||
assertEquals(18.75D, (double) method.invoke(complex, null, -1D, 17D, 31L), 0D);
|
||||
assertEquals(4, first.getInvocations());
|
||||
assertEquals(4, second.getInvocations());
|
||||
assertEquals(1, firstGenerator.invocations);
|
||||
assertEquals(1, secondGenerator.invocations);
|
||||
}
|
||||
|
||||
private static void setStep(IrisComplex complex, int step) throws Exception {
|
||||
setField(complex, "biomeBoundsSamplingStep", step);
|
||||
}
|
||||
|
||||
private static void setField(IrisComplex complex, String name, Object value) throws Exception {
|
||||
Field field = IrisComplex.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(complex, value);
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(Parameterized.class)
|
||||
public static class LegacyBilerpParity {
|
||||
@Parameters(name = "x={0} z={1}")
|
||||
@@ -121,6 +187,9 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
|
||||
private static IrisComplex createComplex() throws Exception {
|
||||
IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
|
||||
Field biomeBoundsSamplingStep = IrisComplex.class.getDeclaredField("biomeBoundsSamplingStep");
|
||||
biomeBoundsSamplingStep.setAccessible(true);
|
||||
biomeBoundsSamplingStep.setInt(complex, 4);
|
||||
|
||||
Field generatorBounds = IrisComplex.class.getDeclaredField("generatorBounds");
|
||||
generatorBounds.setAccessible(true);
|
||||
@@ -192,7 +261,10 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
}
|
||||
|
||||
private static NoiseBounds legacyGridSampleBounds(double x, double z) {
|
||||
int grid = 4;
|
||||
return expectedGridSampleBounds(x, z, 4);
|
||||
}
|
||||
|
||||
private static NoiseBounds expectedGridSampleBounds(double x, double z, int grid) {
|
||||
int xi = (int) Math.floor(x);
|
||||
int zi = (int) Math.floor(z);
|
||||
int mask = grid - 1;
|
||||
@@ -262,6 +334,21 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SampleGenerator extends IrisGenerator {
|
||||
private final double value;
|
||||
private int invocations;
|
||||
|
||||
private SampleGenerator(double value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getHeight(double x, double z, long seed) {
|
||||
invocations++;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CoordinateInterpolator extends IrisInterpolator {
|
||||
private final AtomicInteger invocations = new AtomicInteger();
|
||||
|
||||
@@ -272,11 +359,11 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
}
|
||||
|
||||
private static double low(double x, double z) {
|
||||
return x * 0.125D - z * 0.0625D - 7.75D;
|
||||
return x * 0.125D - z * 0.0625D - 7.75D + x * x * 0.000125D;
|
||||
}
|
||||
|
||||
private static double high(double x, double z) {
|
||||
return x * -0.03125D + z * 0.1875D + 12.5D;
|
||||
return x * -0.03125D + z * 0.1875D + 12.5D + z * z * 0.00015625D;
|
||||
}
|
||||
|
||||
private int getInvocations() {
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.world.pregen.PregenPerformanceProfile;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyPlanner;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTile;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTileKey;
|
||||
@@ -335,6 +336,7 @@ public class IrisComplexHydrologyNoiseCacheTest {
|
||||
|
||||
private HydrologyTileCache planningCache(HydrologyNoiseCacheBudget budget) {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(mock(HydrologyTile.class));
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
cache.setTerrainPreparation(() -> complex.expandHydrologyNoiseCaches(budget));
|
||||
|
||||
+53
@@ -1,6 +1,9 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntimeContext;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope;
|
||||
import art.arcane.iris.generation.runtime.GenerationRuntime.BiomeMaxes;
|
||||
import art.arcane.iris.world.history.SavedBiomeRuntime;
|
||||
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
|
||||
@@ -32,6 +35,7 @@ import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
@@ -47,6 +51,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
@@ -70,6 +77,52 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
@ClassRule
|
||||
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void hydrologyBasisWorkersRetainHistoricalBindingAndRestoreAmbientContext() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture historical = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
setField(engine, "runtimeBuilder", new EngineRuntimeBuilder(engine));
|
||||
IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class, CALLS_REAL_METHODS);
|
||||
when(historical.data.getEngine()).thenReturn(engine);
|
||||
when(historical.complex.getHydrologyRuntime()).thenReturn(hydrology);
|
||||
IrisHydrologyRuntimeContext context = new IrisHydrologyRuntimeContext(17L, 384, historical.dimension,
|
||||
historical.data, (x, z, height) -> null, (x, z) -> 80D, (x, z) -> "constant",
|
||||
(x, z) -> false, footprint -> null, () -> false, () -> {});
|
||||
Field runtimeContext = IrisHydrologyRuntime.class.getDeclaredField("context");
|
||||
runtimeContext.setAccessible(true);
|
||||
runtimeContext.set(hydrology, context);
|
||||
Method capture = IrisHydrologyRuntime.class.getDeclaredMethod("captureBasisScope");
|
||||
capture.setAccessible(true);
|
||||
assertNull(capture.invoke(hydrology));
|
||||
Supplier<NativeGenerationScope> workerScope;
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(detachedBinding(engine, historical.runtime));
|
||||
IrisContext.Scope execution = IrisContext.open(engine, 73L, mock(ChunkContext.class))) {
|
||||
workerScope = (Supplier<NativeGenerationScope>) capture.invoke(hydrology);
|
||||
}
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
worker.submit(() -> {
|
||||
assertSame(active.complex, engine.getComplex());
|
||||
try (IrisContext.Scope ambient = IrisContext.open(engine, 99L, null)) {
|
||||
try (NativeGenerationScope selected = workerScope.get()) {
|
||||
assertSame(historical.complex, engine.getComplex());
|
||||
assertSame(historical.data, IrisContext.require().getData());
|
||||
assertEquals(73L, IrisContext.require().getGenerationSessionId());
|
||||
assertNull(IrisContext.require().getChunkContext());
|
||||
}
|
||||
assertSame(active.complex, engine.getComplex());
|
||||
assertEquals(99L, IrisContext.require().getGenerationSessionId());
|
||||
}
|
||||
assertNull(IrisContext.get());
|
||||
}).get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carvingResolverRestoresNestedRuntimeDefinitions() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
|
||||
+2
-2
@@ -128,7 +128,7 @@ public class IrisCarveModifierBiomeBlendTest {
|
||||
private static IrisCarveModifier modifier(Engine engine, RNG rng) throws ReflectiveOperationException {
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(mock(IrisComplex.class)).when(modifier).getComplex();
|
||||
doReturn(mock(IrisComplex.class)).when(engine).getComplex();
|
||||
Field field = IrisCarveModifier.class.getDeclaredField("rng");
|
||||
field.setAccessible(true);
|
||||
field.set(modifier, rng);
|
||||
@@ -138,7 +138,7 @@ public class IrisCarveModifierBiomeBlendTest {
|
||||
private static IrisBiome resolve(IrisCarveModifier modifier, int[] coordinate,
|
||||
Long2ObjectOpenHashMap<IrisBiome> cache) {
|
||||
return modifier.resolveCaveBoundaryBiome(null, coordinate[0], coordinate[1], coordinate[2],
|
||||
new IrisDimensionCarvingResolver.State(), cache, new HashMap<>());
|
||||
new IrisCarveModifier.CaveInputs(modifier.getEngine()), cache, new HashMap<>());
|
||||
}
|
||||
|
||||
private static int[] coordinate(int roll, int sign) {
|
||||
|
||||
+6
-4
@@ -50,7 +50,7 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(mock(IrisComplex.class)).when(modifier).getComplex();
|
||||
doReturn(mock(IrisComplex.class)).when(engine).getComplex();
|
||||
Field rng = IrisCarveModifier.class.getDeclaredField("rng");
|
||||
rng.setAccessible(true);
|
||||
rng.set(modifier, new RNG(71L));
|
||||
@@ -73,7 +73,7 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache = new Long2ObjectOpenHashMap<>();
|
||||
Map<String, IrisBiome> customBiomeCache = new HashMap<>();
|
||||
IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State();
|
||||
IrisCarveModifier.CaveInputs resolverState = new IrisCarveModifier.CaveInputs(engine);
|
||||
|
||||
IrisBiome floor = modifier.resolveCaveBoundaryBiome(
|
||||
mantleChunk, 1, 6, 2, 40, 44, resolverState, caveBiomeCache, customBiomeCache);
|
||||
@@ -94,11 +94,13 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
doReturn(biomes).when(complex).getTrueBiomeStream();
|
||||
doReturn(surface).when(biomes).get(40, 44);
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(complex).when(modifier).getComplex();
|
||||
Engine engine = mock(Engine.class);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(complex).when(engine).getComplex();
|
||||
|
||||
IrisBiome biome = modifier.resolveCaveBoundaryBiome(
|
||||
new MatterCavern(true, "carving/deep", (byte) 0), 40, 42, 44,
|
||||
new IrisDimensionCarvingResolver.State(), new Long2ObjectOpenHashMap<>(), new HashMap<>());
|
||||
new IrisCarveModifier.CaveInputs(engine), new Long2ObjectOpenHashMap<>(), new HashMap<>());
|
||||
|
||||
assertSame(surface, biome);
|
||||
}
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package art.arcane.iris.generation.stage;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.context.ChunkContext;
|
||||
import art.arcane.iris.generation.context.IrisContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.EnginePlatformHooks;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.pack.loading.ResourceLoader;
|
||||
import art.arcane.iris.pack.value.IrisRange;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisCarveModifierCaveInputsTest {
|
||||
@Test
|
||||
public void capturedColumnsMatchScalarAcrossDepthStackAndCoordinateCollisions() {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
DimensionStackContext stack = mock(DimensionStackContext.class);
|
||||
when(fixture.engine.getDimensionStackContext()).thenReturn(stack);
|
||||
when(fixture.scalar.getDimensionStackContext()).thenReturn(stack);
|
||||
when(stack.getLayout(anyInt(), anyInt())).thenAnswer(call -> {
|
||||
DimensionStackLayout layout = mock(DimensionStackLayout.class);
|
||||
DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class);
|
||||
when(layer.biome()).thenReturn((int) call.getArgument(0) % 2 == 0 ? fixture.stacked : null);
|
||||
when(layout.surfaceLayer()).thenReturn(layer);
|
||||
return layout;
|
||||
});
|
||||
IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine);
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
for (int x : new int[]{-35, -32, -17, -16, 0, 16, 1024, Integer.MIN_VALUE, Integer.MAX_VALUE}) {
|
||||
for (int z : new int[]{-35, -16, 0, 3, 1024, Integer.MIN_VALUE, Integer.MAX_VALUE}) {
|
||||
for (int y : new int[]{0, 30, 32, 33, 34, 39, 40, 41}) {
|
||||
assertSame(fixture.scalar.getCaveBiome(x, y, z, state), inputs.resolve(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedHeightsReuseCapturedColumnAndIgnoreAmbientRuntimeReplacement() {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine);
|
||||
IrisComplex replacement = mock(IrisComplex.class);
|
||||
when(fixture.engine.getComplex()).thenReturn(replacement);
|
||||
when(fixture.engine.getDimension()).thenReturn(new IrisDimension());
|
||||
when(fixture.engine.getData()).thenReturn(mock(IrisData.class));
|
||||
for (int y = 0; y < 40; y++) {
|
||||
assertSame(y <= 33 ? fixture.cave : fixture.surface, inputs.resolve(-31, y, 6));
|
||||
}
|
||||
assertEquals(1, fixture.samples.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void configuredWorldHeightRangeRetainsCapturedPackAndPrecedesFallback() {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
IrisBiome configured = biome("configured", 0);
|
||||
ResourceLoader<IrisBiome> loader = mock(ResourceLoader.class);
|
||||
when(fixture.data.getBiomeLoader()).thenReturn(loader);
|
||||
when(loader.load("configured")).thenReturn(configured);
|
||||
IrisDimensionCarvingEntry root = new IrisDimensionCarvingEntry();
|
||||
root.setBiome("configured");
|
||||
root.setChildRecursionDepth(0);
|
||||
root.setWorldYRange(new IrisRange(-64, -48));
|
||||
fixture.dimension.setCarving(new KList<>(List.of(root)));
|
||||
IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine);
|
||||
when(fixture.engine.getDimension()).thenReturn(new IrisDimension());
|
||||
when(fixture.engine.getData()).thenReturn(mock(IrisData.class));
|
||||
assertSame(configured, inputs.resolve(-31, 0, 6));
|
||||
assertSame(configured, inputs.resolve(-31, 16, 6));
|
||||
assertEquals(0, fixture.samples.get());
|
||||
assertSame(fixture.cave, inputs.resolve(-31, 17, 6));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void genericAndMainThreadEnginesRetainCustomBiomeDispatch() {
|
||||
IrisBiome custom = biome("custom", 0);
|
||||
Engine generic = mock(Engine.class);
|
||||
when(generic.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(custom);
|
||||
assertSame(custom, new IrisCarveModifier.CaveInputs(generic).resolve(1, 2, 3));
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
when(fixture.hooks.isMainThread()).thenReturn(true);
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(custom);
|
||||
assertSame(custom, new IrisCarveModifier.CaveInputs(fixture.engine).resolve(1, 2, 3));
|
||||
assertEquals(0, fixture.samples.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentStageKeepsCoordinateHistoryDispatch() {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
when(fixture.context.isNaturalTerrain()).thenReturn(false);
|
||||
IrisBiome historical = biome("historical", 0);
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(historical);
|
||||
assertSame(historical, new IrisCarveModifier.CaveInputs(fixture.engine).resolve(-17, 20, 16));
|
||||
assertEquals(0, fixture.samples.get());
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisBiome biome(String key, int depth) {
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoadKey(key);
|
||||
biome.setCaveMinDepthBelowSurface(depth);
|
||||
return biome;
|
||||
}
|
||||
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
private final IrisContext.Scope scope;
|
||||
private final ChunkContext context = mock(ChunkContext.class);
|
||||
private final IrisEngine engine = mock(IrisEngine.class);
|
||||
private final Engine scalar = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
private final IrisComplex complex = mock(IrisComplex.class);
|
||||
private final IrisDimension dimension = new IrisDimension();
|
||||
private final IrisData data = mock(IrisData.class);
|
||||
private final EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||
private final IrisBiome surface = biome("surface", 3);
|
||||
private final IrisBiome cave = biome("cave", 7);
|
||||
private final IrisBiome unkeyed = biome(null, 2);
|
||||
private final IrisBiome stacked = biome("stacked", 5);
|
||||
private final AtomicInteger samples = new AtomicInteger();
|
||||
|
||||
private Fixture() {
|
||||
when(context.getComplex()).thenReturn(complex);
|
||||
when(context.isNaturalTerrain()).thenReturn(true);
|
||||
scope = IrisContext.open(engine, 1L, context);
|
||||
when(engine.hasGenerationRuntimeScope()).thenReturn(true);
|
||||
for (Engine target : new Engine[]{engine, scalar}) {
|
||||
doReturn(complex).when(target).getComplex();
|
||||
doReturn(dimension).when(target).getDimension();
|
||||
doReturn(data).when(target).getData();
|
||||
doReturn(hooks).when(target).getPlatformHooks();
|
||||
doReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()).when(target).getWorld();
|
||||
}
|
||||
when(complex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> surface, Interpolated.of(value -> 0D, value -> null)));
|
||||
when(complex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> switch (Math.floorMod(x.intValue(), 3)) {
|
||||
case 0 -> null;
|
||||
case 1 -> unkeyed;
|
||||
default -> cave;
|
||||
}, Interpolated.of(value -> 0D, value -> null)));
|
||||
when(complex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return 40D;
|
||||
}, Interpolated.DOUBLE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
scope.close();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -64,7 +64,7 @@ public class IrisCarveModifierInferenceIsolationTest {
|
||||
int.class,
|
||||
int.class,
|
||||
int.class,
|
||||
IrisDimensionCarvingResolver.State.class,
|
||||
IrisCarveModifier.CaveInputs.class,
|
||||
Long2ObjectOpenHashMap.class,
|
||||
Map.class
|
||||
);
|
||||
@@ -80,7 +80,7 @@ public class IrisCarveModifierInferenceIsolationTest {
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
new IrisDimensionCarvingResolver.State(),
|
||||
new IrisCarveModifier.CaveInputs(engine),
|
||||
new Long2ObjectOpenHashMap<IrisBiome>(),
|
||||
customBiomes
|
||||
);
|
||||
|
||||
+19
@@ -1,14 +1,33 @@
|
||||
package art.arcane.iris.generation.stage;
|
||||
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.iris.generation.biome.IrisFloatingChildBiomes;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisFloatingChildBiomeModifierStackTest {
|
||||
@Test
|
||||
public void floatingPaletteSeedDependsOnWorldAndBiomeInsteadOfColumnVisitOrder() {
|
||||
IrisFloatingChildBiomes first = new IrisFloatingChildBiomes().setBiome("magnetics/glass-shard");
|
||||
IrisFloatingChildBiomes repeated = new IrisFloatingChildBiomes().setBiome("magnetics/glass-shard");
|
||||
IrisFloatingChildBiomes other = new IrisFloatingChildBiomes().setBiome("other/floating");
|
||||
RNG world = new RNG(7191L ^ 0x7EB0A73F1DCE514DL);
|
||||
long expected = IrisFloatingChildBiomeModifier.paletteRng(world, first).getSeed();
|
||||
for (int column = -256; column < 256; column++) {
|
||||
world.nextLong();
|
||||
assertEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(world, repeated).getSeed());
|
||||
}
|
||||
assertNotEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(world, other).getSeed());
|
||||
assertNotEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(new RNG(8191L), first).getSeed());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void floatingBiomeMarkersSkipStackProtectedCells() {
|
||||
DimensionStackLayout layout = mock(DimensionStackLayout.class);
|
||||
|
||||
+129
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.generation.terrain;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.noise.IrisGeneratorStyle;
|
||||
import art.arcane.iris.pack.value.IrisRange;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
|
||||
@@ -48,6 +49,7 @@ import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class IrisDimensionCarvingResolverParityTest {
|
||||
@@ -59,6 +61,67 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
BukkitTestServer.install();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedSnapshotMatchesScalarRootsAndReadsRuntimeIdentityOnce() {
|
||||
Fixture fixture = createFixture();
|
||||
Map<String, IrisBiome> expected = new HashMap<>();
|
||||
for (int y = -80; y <= 340; y += 13) {
|
||||
for (int x = -17; x <= 17; x += 17) {
|
||||
expected.put(x + ":" + y, IrisDimensionCarvingResolver.resolveBiome(
|
||||
fixture.engine, x, y, -23, new IrisDimensionCarvingResolver.State()));
|
||||
}
|
||||
}
|
||||
clearInvocations(fixture.engine);
|
||||
IrisDimensionCarvingResolver.Snapshot snapshot = IrisDimensionCarvingResolver.snapshot(fixture.engine);
|
||||
for (int y = -80; y <= 340; y += 13) {
|
||||
for (int x = -17; x <= 17; x += 17) {
|
||||
assertSame(expected.get(x + ":" + y), snapshot.resolveBiome(x, y, -23));
|
||||
}
|
||||
}
|
||||
verify(fixture.engine).getDimension();
|
||||
verify(fixture.engine).getData();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedSnapshotsBindReplacementRuntimeWithoutSharingPriorResults() {
|
||||
Fixture first = createFixture();
|
||||
Fixture replacement = createMixedDepthFixture();
|
||||
IrisBiome expectedFirst = IrisDimensionCarvingResolver.resolveBiome(first.engine, -17, 80, 23, null);
|
||||
IrisBiome expectedReplacement = IrisDimensionCarvingResolver.resolveBiome(replacement.engine, -17, 80, 23, null);
|
||||
assertSame(expectedFirst, IrisDimensionCarvingResolver.snapshot(first.engine).resolveBiome(-17, 80, 23));
|
||||
doReturn(replacement.engine.getData()).when(first.engine).getData();
|
||||
doReturn(replacement.engine.getDimension()).when(first.engine).getDimension();
|
||||
doReturn(replacement.engine.getSeedManager()).when(first.engine).getSeedManager();
|
||||
assertSame(expectedReplacement, IrisDimensionCarvingResolver.snapshot(first.engine).resolveBiome(-17, 80, 23));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedSnapshotsKeepExpressionSamplingPerImplicitCell() {
|
||||
for (boolean fracture : new boolean[]{false, true}) {
|
||||
Fixture fixture = createFixture();
|
||||
IrisDimension dimension = fixture.engine.getDimension();
|
||||
IrisDimensionCarvingEntry parent = spy(dimension.getCarvingEntryIndex().get("root-low"));
|
||||
parent.setChildRecursionDepth(1);
|
||||
if (fracture) {
|
||||
parent.getChildStyle().setFracture(new IrisGeneratorStyle().setExpression("live-height"));
|
||||
} else {
|
||||
parent.getChildStyle().setExpression("live-height");
|
||||
}
|
||||
KList<IrisDimensionCarvingEntry> entries = new KList<>(dimension.getCarving());
|
||||
entries.set(0, parent);
|
||||
doReturn(entries).when(dimension).getCarving();
|
||||
dimension.getCarvingEntryIndex().put("root-low", parent);
|
||||
CNG generator = mock(CNG.class);
|
||||
doReturn(0.5D, 0D).when(generator).noiseFast2D(-17D, 23D);
|
||||
doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class));
|
||||
IrisDimensionCarvingResolver.Snapshot snapshot = IrisDimensionCarvingResolver.snapshot(fixture.engine);
|
||||
|
||||
assertSame(fixture.engine.getData().getBiomeLoader().load("child-a"), snapshot.resolveBiome(-17, 40, 23));
|
||||
assertSame(fixture.engine.getData().getBiomeLoader().load("root-low"), snapshot.resolveBiome(-17, 41, 23));
|
||||
verify(generator, times(2)).noiseFast2D(-17D, 23D);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void extremeChildRarityRemainsReachableWithOneNoiseSample() {
|
||||
Fixture fixture = createFixture();
|
||||
@@ -84,6 +147,72 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
verify(generator, never()).fit2D(anyInt(), anyInt(), anyDouble(), anyDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedColumnResolutionSamplesChildrenOnceAcrossHeights() {
|
||||
Fixture fixture = createFixture();
|
||||
IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low"));
|
||||
parent.setChildRecursionDepth(1);
|
||||
CNG generator = mock(CNG.class);
|
||||
doReturn(0.5D).when(generator).noiseFast2D(anyDouble(), anyDouble());
|
||||
doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class));
|
||||
IrisDimensionCarvingEntry child = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a");
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
|
||||
for (int height = 0; height < 16; height++) {
|
||||
for (int x = -16; x < 0; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, x, z, state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verify(generator, times(256)).noiseFast2D(anyDouble(), anyDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expressionChildStylesKeepSamplingTheCurrentContext() {
|
||||
for (boolean fracture : new boolean[]{false, true}) {
|
||||
Fixture fixture = createFixture();
|
||||
IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low"));
|
||||
parent.setChildRecursionDepth(1);
|
||||
if (fracture) {
|
||||
parent.getChildStyle().setFracture(new IrisGeneratorStyle().setExpression("live-height"));
|
||||
} else {
|
||||
parent.getChildStyle().setExpression("live-height");
|
||||
}
|
||||
CNG generator = mock(CNG.class);
|
||||
doReturn(0.5D, 0D).when(generator).noiseFast2D(-17D, 23D);
|
||||
doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class));
|
||||
IrisDimensionCarvingEntry child = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a");
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
|
||||
assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state));
|
||||
assertSame(parent, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cachedColumnResolutionInvalidatesWithDataAndEngineChanges() {
|
||||
Fixture fixture = createFixture();
|
||||
Fixture replacement = createFixture();
|
||||
IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low"));
|
||||
parent.setChildRecursionDepth(1);
|
||||
CNG generator = mock(CNG.class);
|
||||
doReturn(0.5D).when(generator).noiseFast2D(anyDouble(), anyDouble());
|
||||
doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class));
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
IrisDimensionCarvingEntry firstChild = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a");
|
||||
IrisDimensionCarvingEntry replacementChild = replacement.engine.getDimension().getCarvingEntryIndex().get("child-a");
|
||||
|
||||
assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state));
|
||||
doReturn(replacement.engine.getData()).when(fixture.engine).getData();
|
||||
assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state));
|
||||
assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, 23, state));
|
||||
assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, 23, state));
|
||||
|
||||
verify(generator, times(3)).noiseFast2D(-17D, 23D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolverStatefulOverloadsMatchLegacyResolverAcrossSampleGrid() {
|
||||
Fixture fixture = createFixture();
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package art.arcane.iris.generation.terrain;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.DoubleBinaryOperator;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ProceduralTerrainHeightSamplerTest {
|
||||
@Test
|
||||
public void unitStepPreservesOriginalFractionalSamples() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> {
|
||||
calls.incrementAndGet();
|
||||
return terrain(x, z);
|
||||
}, 1);
|
||||
for (double x : new double[]{-17.25D, -4D, -0.5D, 0D, 1.75D, 32D}) {
|
||||
assertEquals(terrain(x, -2.25D), sampler.sample(x, -2.25D), 0D);
|
||||
}
|
||||
assertEquals(6, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void denseTerrainReusesLatticeNodesInsteadOfSamplingEveryColumn() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> {
|
||||
calls.incrementAndGet();
|
||||
return terrain(x, z);
|
||||
}, 4);
|
||||
for (int x = -32; x < 32; x++) {
|
||||
for (int z = -32; z < 32; z++) {
|
||||
assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, 4),
|
||||
sampler.sample(x, z), 0D);
|
||||
}
|
||||
}
|
||||
assertTrue("Procedural sample calls: " + calls.get(), calls.get() < 400);
|
||||
assertTrue(calls.get() >= 289);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void neighboringRegionsRetainHeightsAcrossRepeatedSweeps() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
DoubleBinaryOperator linear = (x, z) -> x * 0.25D - z * 0.125D + 96D;
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> {
|
||||
calls.incrementAndGet();
|
||||
return linear.applyAsDouble(x, z);
|
||||
}, 4);
|
||||
int uniqueNodes = 261 * 261;
|
||||
for (int sweep = 0; sweep < 2; sweep++) {
|
||||
int before = calls.get();
|
||||
for (int chunkX = -32; chunkX <= 32; chunkX++) {
|
||||
for (int chunkZ = -32; chunkZ <= 32; chunkZ++) {
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
int x = chunkX * 16 + localX;
|
||||
int z = chunkZ * 16 + localZ;
|
||||
assertEquals(linear.applyAsDouble(x, z), sampler.sample(x, z), 0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
int sourceCalls = calls.get() - before;
|
||||
assertTrue("Procedural sample calls in sweep " + sweep + ": " + sourceCalls,
|
||||
sourceCalls < (sweep == 0 ? uniqueNodes * 1.05D : uniqueNodes * 0.25D));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allStepsPreserveNodesLinearFieldsAndSignedWorldCoordinates() {
|
||||
DoubleBinaryOperator linear = (x, z) -> x * 0.25D - z * 0.125D + 96D;
|
||||
for (int step : new int[]{2, 4, 8}) {
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler(linear, step);
|
||||
ProceduralTerrainHeightSampler shaped = new ProceduralTerrainHeightSampler(
|
||||
ProceduralTerrainHeightSamplerTest::terrain, step);
|
||||
for (double x : new double[]{Integer.MIN_VALUE, -33.5D, -8D, -0.25D, 0D, 8D, 31.75D,
|
||||
Integer.MAX_VALUE}) {
|
||||
for (double z : new double[]{-19.5D, -8D, 0D, 8D, 19.5D}) {
|
||||
assertEquals(linear.applyAsDouble(x, z), sampler.sample(x, z), 0D);
|
||||
assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, step),
|
||||
shaped.sample(x, z), 0D);
|
||||
}
|
||||
}
|
||||
for (int x = -32; x <= 32; x += step) {
|
||||
assertEquals(terrain(x, -8), shaped.sample(x, -8), 0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentQueriesRemainDeterministicAcrossCacheReplacement() throws Exception {
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler(
|
||||
ProceduralTerrainHeightSamplerTest::terrain, 4);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(4);
|
||||
List<Future<?>> tasks = new ArrayList<>();
|
||||
try {
|
||||
for (int worker = 0; worker < 4; worker++) {
|
||||
int offset = worker * 65_537;
|
||||
tasks.add(executor.submit(() -> {
|
||||
for (int index = 0; index < 100_000; index++) {
|
||||
int x = index * 4 - offset;
|
||||
int z = -index * 8 + offset;
|
||||
assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, 4),
|
||||
sampler.sample(x, z), 0D);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (Future<?> task : tasks) {
|
||||
task.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(sampler.cachedNodeCount() > 200_000);
|
||||
assertTrue(sampler.cachedNodeCount() <= 262_144);
|
||||
ProceduralTerrainHeightSampler other = new ProceduralTerrainHeightSampler((x, z) -> 42D, 4);
|
||||
assertEquals(42D, other.sample(0, 0), 0D);
|
||||
assertEquals(terrain(0, 0), sampler.sample(0, 0), 0D);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedNodesCanBeRetriedAndInvalidStepsAreRejected() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> {
|
||||
if (calls.incrementAndGet() == 1) {
|
||||
throw new IllegalStateException("height failure");
|
||||
}
|
||||
return 72D;
|
||||
}, 4);
|
||||
assertThrows(IllegalStateException.class, () -> sampler.sample(-4D, 8D));
|
||||
assertEquals(72D, sampler.sample(-4D, 8D), 0D);
|
||||
assertEquals(2, calls.get());
|
||||
for (int step : new int[]{-1, 0, 3, 16}) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ProceduralTerrainHeightSampler((x, z) -> 0D, step));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sampledBaseHeightsRetainThreeDimensionalLedgesAndAirGaps() {
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler(
|
||||
(x, z) -> 96D + (Math.floorMod((int) x, 4) == 2 ? 2D : 0D), 4);
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(new IrisTerrain3D()
|
||||
.setAmplitude(32).setHorizontalScale(64).setVerticalScale(64)
|
||||
.setMinimumSlope(0).setFluidClearance(0).setFluidFade(1));
|
||||
Terrain3DRuntime.Options options = new Terrain3DRuntime.Options(12, 256, 0, null, true, 4096);
|
||||
Terrain3DRuntime.NoiseFactory noise = (style, seed) ->
|
||||
(x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D);
|
||||
Terrain3DRuntime sampled = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> sampler.sample(x, z), (x, z) -> biome), options, noise);
|
||||
Terrain3DRuntime reference = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome), options, noise);
|
||||
for (int x = -9; x <= 9; x++) {
|
||||
Terrain3DColumn column = sampled.column(x, -2);
|
||||
assertEquals(reference.column(x, -2), column);
|
||||
assertTrue(column.shaped());
|
||||
assertTrue(column.spanCount() > 1);
|
||||
assertTrue(column.isSolid(104));
|
||||
assertFalse(column.isSolid(84));
|
||||
assertTrue(column.isSolid(60));
|
||||
}
|
||||
}
|
||||
|
||||
private static double terrain(double x, double z) {
|
||||
return 96D + StrictMath.sin(x * 0.13D) * 18D + StrictMath.cos(z * 0.17D) * 12D;
|
||||
}
|
||||
|
||||
private static double interpolated(DoubleBinaryOperator source, double x, double z, int step) {
|
||||
double lowerX = Math.floor(x / step) * step;
|
||||
double lowerZ = Math.floor(z / step) * step;
|
||||
double dx = (x - lowerX) / step;
|
||||
double dz = (z - lowerZ) / step;
|
||||
double northWest = source.applyAsDouble(lowerX, lowerZ);
|
||||
double north = dx == 0D ? northWest
|
||||
: northWest + (source.applyAsDouble(lowerX + step, lowerZ) - northWest) * dx;
|
||||
if (dz == 0D) {
|
||||
return north;
|
||||
}
|
||||
double southWest = source.applyAsDouble(lowerX, lowerZ + step);
|
||||
double south = dx == 0D ? southWest
|
||||
: southWest + (source.applyAsDouble(lowerX + step, lowerZ + step) - southWest) * dx;
|
||||
return north + (south - north) * dz;
|
||||
}
|
||||
}
|
||||
+131
@@ -3,15 +3,146 @@ package art.arcane.iris.generation.terrain;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class Terrain3DFragmentFilterTest {
|
||||
@Test
|
||||
public void heightLeavesIrrelevantLowerFragmentsUnclassified() {
|
||||
Terrain3DColumn center = new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20, 100, 612});
|
||||
BiFunction<Integer, Integer, Terrain3DColumn> source = (x, z) -> x == 0 && z == 0
|
||||
? center : new Terrain3DColumn(10D, 11, true, new int[]{0, 10});
|
||||
Fixture reference = new Fixture(source);
|
||||
Fixture selective = new Fixture(source);
|
||||
Terrain3DColumn expected = reference.filter.column(0, 0);
|
||||
|
||||
assertEquals(expected.topY(), selective.filter.height(0, 0), 0D);
|
||||
assertEquals(1, selective.reads.get());
|
||||
assertTrue(reference.reads.get() > selective.reads.get());
|
||||
assertEquals(expected, selective.filter.column(0, 0));
|
||||
assertEquals(2, expected.spanCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heightRejectsSmallTopFragmentBeforeKeepingGroundConnectedLedge() {
|
||||
Fixture fixture = new Fixture((x, z) -> x == 0 && z == 0
|
||||
? new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 50, 70, 110, 112})
|
||||
: new Terrain3DColumn(100D, 101, true, new int[]{0, 100}));
|
||||
|
||||
assertEquals(70D, fixture.filter.height(0, 0), 0D);
|
||||
assertEquals(70, fixture.filter.column(0, 0).topY());
|
||||
assertEquals(2, fixture.filter.column(0, 0).spanCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heightUsesExactConnectedVolumeThresholdAndSignedCoordinateEdges() {
|
||||
for (int volume : new int[]{512, 513}) {
|
||||
BiFunction<Integer, Integer, Terrain3DColumn> source = (x, z) ->
|
||||
x >= -volume && x < 0 && z == -17
|
||||
? new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20})
|
||||
: new Terrain3DColumn(10D, 11, true, new int[]{0, 10});
|
||||
Fixture reference = new Fixture(source);
|
||||
Fixture selective = new Fixture(source);
|
||||
assertEquals(volume == 512 ? 10D : 20D, selective.filter.height(-volume, -17), 0D);
|
||||
assertEquals(reference.filter.column(-volume, -17), selective.filter.column(-volume, -17));
|
||||
}
|
||||
for (int x : new int[]{Integer.MIN_VALUE, Integer.MAX_VALUE}) {
|
||||
Fixture fixture = new Fixture((ignoredX, ignoredZ) ->
|
||||
new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20}));
|
||||
assertEquals(20D, fixture.filter.height(x, 7), 0D);
|
||||
assertEquals(20, fixture.filter.column(x, 7).topY());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentHeightAndFullColumnQueriesMatchIndependentFullClassification() throws Exception {
|
||||
Random random = new Random(883719L);
|
||||
Map<Long, Terrain3DColumn> raw = new HashMap<>();
|
||||
List<Long> positions = new ArrayList<>();
|
||||
for (int x = -8; x <= 8; x++) {
|
||||
for (int z = -8; z <= 8; z++) {
|
||||
int ground = random.nextInt(5) == 0 ? 36 : 5 + random.nextInt(6);
|
||||
int[] boundaries = new int[10];
|
||||
boundaries[0] = 0;
|
||||
boundaries[1] = ground;
|
||||
int count = 2;
|
||||
for (int y = 16; y <= 40; y += 8) {
|
||||
if (y > ground + 1 && random.nextBoolean()) {
|
||||
boundaries[count++] = y;
|
||||
boundaries[count++] = y + random.nextInt(3);
|
||||
}
|
||||
}
|
||||
long key = pack(x, z);
|
||||
raw.put(key, new Terrain3DColumn(ground + 0.25D, ground + 1, true, Arrays.copyOf(boundaries, count)));
|
||||
positions.add(key);
|
||||
}
|
||||
}
|
||||
Terrain3DColumn outside = new Terrain3DColumn(8D, 9, true, new int[]{0, 8});
|
||||
BiFunction<Integer, Integer, Terrain3DColumn> source = (x, z) -> raw.getOrDefault(pack(x, z), outside);
|
||||
Fixture reference = new Fixture(source);
|
||||
Map<Long, Terrain3DColumn> expected = new HashMap<>();
|
||||
for (long key : positions) {
|
||||
expected.put(key, reference.filter.column((int) (key >> 32), (int) key));
|
||||
}
|
||||
Fixture selective = new Fixture(source);
|
||||
try (ExecutorService workers = Executors.newFixedThreadPool(4)) {
|
||||
List<Future<?>> tasks = new ArrayList<>();
|
||||
for (int worker = 0; worker < 4; worker++) {
|
||||
int shift = worker * 59;
|
||||
tasks.add(workers.submit(() -> {
|
||||
for (int index = 0; index < positions.size() * 2; index++) {
|
||||
long key = positions.get((index + shift) % positions.size());
|
||||
int x = (int) (key >> 32);
|
||||
int z = (int) key;
|
||||
assertEquals(expected.get(key).topY(), selective.filter.height(x, z), 0D);
|
||||
if ((index & 1) == 0) {
|
||||
assertEquals(expected.get(key), selective.filter.column(x, z));
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (Future<?> task : tasks) {
|
||||
task.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
for (long key : positions) {
|
||||
assertEquals(expected.get(key), selective.filter.column((int) (key >> 32), (int) key));
|
||||
}
|
||||
}
|
||||
|
||||
private static long pack(int x, int z) {
|
||||
return (long) x << 32 | z & 0xffffffffL;
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final AtomicInteger reads = new AtomicInteger();
|
||||
private final ConcurrentHashMap<Long, Terrain3DFragmentFilter.DensityColumn> columns = new ConcurrentHashMap<>();
|
||||
private final Terrain3DFragmentFilter filter;
|
||||
|
||||
private Fixture(BiFunction<Integer, Integer, Terrain3DColumn> source) {
|
||||
filter = new Terrain3DFragmentFilter((x, z) -> {
|
||||
reads.incrementAndGet();
|
||||
return columns.computeIfAbsent(pack(x, z), ignored -> new Terrain3DFragmentFilter.DensityColumn(source.apply(x, z)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void perThreadTraversalScratchDoesNotPinTheFilterAfterItIsDropped() throws Exception {
|
||||
// A hotload retires the runtime that owns the filter; the worker threads that classified
|
||||
|
||||
@@ -21,6 +21,35 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class Terrain3DRuntimeTest {
|
||||
@Test
|
||||
public void heightQueriesPreserveCompleteDensityColumnsAndFractionalFallback() {
|
||||
Terrain3DRuntime.NoiseSource noise = (x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D)
|
||||
* StrictMath.cos(x * 0.08D) * StrictMath.cos(z * 0.06D);
|
||||
Terrain3DRuntime reference = runtime(profile(), noise);
|
||||
Terrain3DRuntime selective = runtime(profile(), noise);
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
for (int x = -12; x <= 12; x++) {
|
||||
for (int z = -6; z <= 6; z++) {
|
||||
Terrain3DColumn expected = reference.column(x, z);
|
||||
assertEquals(expected.topY(), selective.height(x, z), 0D);
|
||||
assertEquals(expected, selective.column(x, z));
|
||||
assertEquals(expected.topY(), selective.height(x, z), 0D);
|
||||
}
|
||||
}
|
||||
selective.clear();
|
||||
}
|
||||
Terrain3DRuntime unshaped = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 300.25D, (x, z) -> new IrisBiome()),
|
||||
options(true), (style, seed) -> noise);
|
||||
assertEquals(300.25D, unshaped.height(-17, 23), 0D);
|
||||
assertEquals(255, unshaped.column(-17, 23).topY());
|
||||
assertEquals(300.25D, unshaped.height(-17, 23), 0D);
|
||||
Terrain3DRuntime inactive = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> -12.75D, (x, z) -> null),
|
||||
options(false), (style, seed) -> noise);
|
||||
assertEquals(-12.75D, inactive.height(0, 0), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void alignedColumnsOnlySampleContributingAnchors() {
|
||||
for (int x = -8; x <= -7; x++) {
|
||||
@@ -260,6 +289,138 @@ public class Terrain3DRuntimeTest {
|
||||
assertEquals(40, water.column(0, 0).topY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anchorRetentionBoundsFullHeightNoiseStorage() {
|
||||
int shorter = Terrain3DRuntime.anchorCacheCapacity(256, 4096);
|
||||
int taller = Terrain3DRuntime.anchorCacheCapacity(1024, 4096);
|
||||
assertTrue(shorter > taller);
|
||||
assertTrue(taller > 4096 / 4);
|
||||
for (int height : new int[]{16, 256, 640, 1024, 4096, 16_384}) {
|
||||
for (int columns : new int[]{16, 4096, Integer.MAX_VALUE}) {
|
||||
int capacity = Terrain3DRuntime.anchorCacheCapacity(height, columns);
|
||||
long fullHeightStorage = 160L + 48L * (Math.ceilDiv((long) height, 4) + 1);
|
||||
assertEquals(0, capacity % 16);
|
||||
assertTrue(capacity >= 16);
|
||||
assertTrue(capacity <= 32_768);
|
||||
assertTrue(capacity * fullHeightStorage <= 64L * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedColumnWorkingSetReusesTerrainAcrossChunksAndClearsCompletely() {
|
||||
AtomicInteger sourceCalls = new AtomicInteger();
|
||||
AtomicInteger height = new AtomicInteger(96);
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> {
|
||||
sourceCalls.incrementAndGet();
|
||||
return height.get();
|
||||
}, (x, z) -> biome),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 65_536),
|
||||
(style, seed) -> (x, y, z) -> 0D);
|
||||
List<Terrain3DColumn> columns = new ArrayList<>(128 * 128);
|
||||
for (int x = -64; x < 64; x++) {
|
||||
for (int z = -64; z < 64; z++) {
|
||||
Terrain3DColumn column = runtime.column(x, z);
|
||||
assertTrue(column.shaped());
|
||||
assertEquals(96, column.topY());
|
||||
columns.add(column);
|
||||
}
|
||||
}
|
||||
int callsBeforeRevisit = sourceCalls.get();
|
||||
int index = 0;
|
||||
for (int x = -64; x < 64; x++) {
|
||||
for (int z = -64; z < 64; z++) {
|
||||
assertSame(columns.get(index++), runtime.column(x, z));
|
||||
}
|
||||
}
|
||||
assertEquals(callsBeforeRevisit, sourceCalls.get());
|
||||
assertEquals(columns.size(), runtime.cachedColumnCount());
|
||||
height.set(120);
|
||||
runtime.clear();
|
||||
assertEquals(0, runtime.cachedColumnCount());
|
||||
assertEquals(120, runtime.column(-64, -64).topY());
|
||||
assertTrue(sourceCalls.get() > callsBeforeRevisit);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedColumnCacheRemainsBoundedAcrossDistantWorkingSets() {
|
||||
IrisBiome biome = new IrisBiome();
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 65_536),
|
||||
(style, seed) -> (x, y, z) -> 0D);
|
||||
for (int x = -65_536; x < 65_536; x++) {
|
||||
assertEquals(96, runtime.column(x, -1).topY());
|
||||
}
|
||||
assertEquals(65_536, runtime.cachedColumnCount());
|
||||
assertEquals(96, runtime.column(-65_536, -1).topY());
|
||||
assertEquals(65_536, runtime.cachedColumnCount());
|
||||
runtime.clear();
|
||||
assertEquals(0, runtime.cachedColumnCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void evictedAnchorsRecomputeIdenticalColumns() {
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 16),
|
||||
(style, seed) -> (x, y, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return 0.2D * StrictMath.sin(x + z);
|
||||
});
|
||||
Terrain3DColumn original = runtime.column(-4, -4);
|
||||
for (int index = 0; index < 512; index++) {
|
||||
runtime.column(index * 4, -16);
|
||||
}
|
||||
int samplesBeforeRevisit = samples.get();
|
||||
|
||||
assertEquals(original, runtime.column(-4, -4));
|
||||
assertTrue(samples.get() > samplesBeforeRevisit);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anchorNoiseSurvivesColumnCacheTurnover() {
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
AtomicInteger biomes = new AtomicInteger();
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> {
|
||||
biomes.incrementAndGet();
|
||||
return biome;
|
||||
}),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 64),
|
||||
(style, seed) -> (x, y, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return 0.2D * StrictMath.sin(x + z);
|
||||
});
|
||||
List<Terrain3DColumn> columns = new ArrayList<>();
|
||||
for (int x = -32; x < 32; x += 4) {
|
||||
for (int z = -32; z < 32; z += 4) {
|
||||
columns.add(runtime.column(x, z));
|
||||
}
|
||||
}
|
||||
int initialSamples = samples.get();
|
||||
int initialBiomes = biomes.get();
|
||||
int index = 0;
|
||||
for (int x = -32; x < 32; x += 4) {
|
||||
for (int z = -32; z < 32; z += 4) {
|
||||
assertEquals(columns.get(index++), runtime.column(x, z));
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(initialSamples > 0);
|
||||
assertEquals(initialSamples, samples.get());
|
||||
assertEquals(initialBiomes, biomes.get());
|
||||
runtime.clear();
|
||||
assertEquals(columns.getFirst(), runtime.column(-32, -32));
|
||||
assertTrue(samples.get() > initialSamples);
|
||||
assertTrue(biomes.get() > initialBiomes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void columnCacheReusesGeometryAndAnchorNoise() {
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package art.arcane.iris.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackValidatorTerrainSamplingTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void acceptsOmittedAndSupportedSamplingSteps() throws Exception {
|
||||
assertTrue(validate("terrainSamplingStep", null).isLoadable());
|
||||
for (String field : new String[]{"terrainSamplingStep", "caveDensitySamplingStep"}) {
|
||||
for (String value : new String[]{"1", "2", "4", "8"}) {
|
||||
PackValidationResult result = validate(field, value);
|
||||
assertTrue(result.getBlockingErrors().toString(), result.isLoadable());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidSamplingStepsBeforeWorldCreation() throws Exception {
|
||||
for (String field : new String[]{"terrainSamplingStep", "caveDensitySamplingStep"}) {
|
||||
for (String value : new String[]{"0", "-1", "3", "16", "4.5", "\"4\"", "null", "true"}) {
|
||||
PackValidationResult result = validate(field, value);
|
||||
assertFalse("Accepted " + field + "=" + value, result.isLoadable());
|
||||
assertTrue(result.getBlockingErrors().contains(
|
||||
"Dimension 'main' " + field + " must be one of 1, 2, 4, or 8."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsOnlySupportedBiomeBoundsSamplingSteps() throws Exception {
|
||||
assertTrue(validate("biomeBoundsSamplingStep", null).isLoadable());
|
||||
for (String value : new String[]{"4", "8", "16", "32"}) {
|
||||
PackValidationResult result = validate("biomeBoundsSamplingStep", value);
|
||||
assertTrue(result.getBlockingErrors().toString(), result.isLoadable());
|
||||
}
|
||||
for (String value : new String[]{"0", "-1", "1", "2", "3", "5", "64", "8.5", "\"8\"", "null", "true"}) {
|
||||
PackValidationResult result = validate("biomeBoundsSamplingStep", value);
|
||||
assertFalse("Accepted biomeBoundsSamplingStep=" + value, result.isLoadable());
|
||||
assertTrue(result.getBlockingErrors().contains(
|
||||
"Dimension 'main' biomeBoundsSamplingStep must be one of 4, 8, 16, or 32."));
|
||||
}
|
||||
}
|
||||
|
||||
private PackValidationResult validate(String field, String value) throws Exception {
|
||||
Path pack = temporary.newFolder().toPath();
|
||||
Files.createDirectories(pack.resolve("dimensions"));
|
||||
Files.createDirectories(pack.resolve("regions"));
|
||||
Files.createDirectories(pack.resolve("biomes"));
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), "{\"regions\":[\"main\"]"
|
||||
+ (value == null ? "" : ",\"" + field + "\":" + value) + "}");
|
||||
Files.writeString(pack.resolve("regions/main.json"), "{\"landBiomes\":[\"main\"]}");
|
||||
Files.writeString(pack.resolve("biomes/main.json"), "{\"name\":\"Main\"}");
|
||||
return PackValidator.validate(pack.toFile());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package art.arcane.iris.pack.schema;
|
||||
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import com.google.gson.Gson;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class IrisBiomeBoundsSamplingSchemaTest {
|
||||
@Test
|
||||
public void omittedSettingKeepsFourAndExplicitValuesRoundTrip() {
|
||||
Gson gson = new Gson();
|
||||
assertEquals(4, gson.fromJson("{}", IrisDimension.class).getBiomeBoundsSamplingStep());
|
||||
for (int step : new int[]{4, 8, 16, 32}) {
|
||||
IrisDimension dimension = new IrisDimension().setBiomeBoundsSamplingStep(step);
|
||||
assertEquals(step, gson.fromJson(gson.toJson(dimension), IrisDimension.class).getBiomeBoundsSamplingStep());
|
||||
}
|
||||
for (int step : new int[]{0, 1, 2, 3, 5, 64}) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new IrisDimension().setBiomeBoundsSamplingStep(step).getBiomeBoundsSamplingStep());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void schemaListsOnlySupportedIntegerSteps() throws Exception {
|
||||
SchemaBuilder builder = new SchemaBuilder(IrisDimension.class, null);
|
||||
for (String name : new String[]{"biomeBoundsSamplingStep", "terrainSamplingStep", "caveDensitySamplingStep"}) {
|
||||
int minimum = name.equals("biomeBoundsSamplingStep") ? 4 : 1;
|
||||
Field field = IrisDimension.class.getDeclaredField(name);
|
||||
JSONObject property = new SchemaPropertyBuilder(builder).buildProperty(field, IrisDimension.class);
|
||||
assertEquals("integer", property.getString("type"));
|
||||
assertEquals(minimum, property.getInt("minimum"));
|
||||
assertEquals(minimum * 8, property.getInt("maximum"));
|
||||
JSONArray options = property.getJSONArray("enum");
|
||||
assertEquals(4, options.length());
|
||||
for (int index = 0; index < options.length(); index++) {
|
||||
assertEquals(minimum << index, options.getInt(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest {
|
||||
@ClassRule
|
||||
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
|
||||
|
||||
private static final String EXPECTED_SCHEMA_DIGEST = "415345947f2c96fc468848aefa064854a085ae5631ea0f780c77c99973bff594";
|
||||
private static final String EXPECTED_SCHEMA_DIGEST = "9c118c18c2f757f08ffb71d52d8549c53e4dae4939e0e030d36e4e9115794497";
|
||||
|
||||
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
|
||||
IrisDimension.class,
|
||||
|
||||
@@ -52,6 +52,59 @@ public class BoundaryColumnGeometryTest {
|
||||
assertEquals(50, geometry.surfaceOffsetNear(70D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nearestSurfaceMatchesDenseColumnsAcrossRunsProtectionAndTies() {
|
||||
List<BoundaryColumnGeometry.Voxel> states = List.of(STONE, AIR,
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:oak_log[axis=y]",
|
||||
BoundaryColumnGeometry.Phase.SOLID, "", true),
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:water[level=0]",
|
||||
BoundaryColumnGeometry.Phase.FLUID, "minecraft:water[level=0]", false));
|
||||
for (int combination = 0; combination < 4096; combination++) {
|
||||
int encoded = combination;
|
||||
ArrayList<BoundaryColumnGeometry.Voxel> voxels = new ArrayList<>(18);
|
||||
for (int run = 0; run < 6; run++) {
|
||||
BoundaryColumnGeometry.Voxel voxel = states.get(encoded & 3);
|
||||
encoded >>= 2;
|
||||
for (int offset = 0; offset <= run % 3; offset++) {
|
||||
voxels.add(voxel);
|
||||
}
|
||||
}
|
||||
BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-64, voxels);
|
||||
for (double expected : new double[]{-10D, 0D, 1.5D, 3D, 5.5D, 8D, 12D, 100D}) {
|
||||
assertEquals(denseSurface(voxels, expected), geometry.surfaceOffsetNear(expected));
|
||||
}
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> BoundaryColumnGeometry.empty().surfaceOffsetNear(0D));
|
||||
BoundaryColumnGeometry solid = BoundaryColumnGeometry.fromVoxels(-64, List.of(STONE));
|
||||
assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.NaN));
|
||||
assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.POSITIVE_INFINITY));
|
||||
}
|
||||
|
||||
private static int denseSurface(List<BoundaryColumnGeometry.Voxel> voxels, double expected) {
|
||||
int selected = -1;
|
||||
double distance = Double.POSITIVE_INFINITY;
|
||||
for (int index = 0; index + 1 < voxels.size(); index++) {
|
||||
BoundaryColumnGeometry.Voxel current = voxels.get(index);
|
||||
BoundaryColumnGeometry.Voxel above = voxels.get(index + 1);
|
||||
if (current.phase() == BoundaryColumnGeometry.Phase.SOLID && !current.protectedContent()
|
||||
&& (above.phase() != BoundaryColumnGeometry.Phase.SOLID || above.protectedContent())
|
||||
&& Math.abs(index - expected) < distance) {
|
||||
selected = index;
|
||||
distance = Math.abs(index - expected);
|
||||
}
|
||||
}
|
||||
if (selected >= 0) {
|
||||
return selected;
|
||||
}
|
||||
for (int index = voxels.size() - 1; index >= 0; index--) {
|
||||
BoundaryColumnGeometry.Voxel voxel = voxels.get(index);
|
||||
if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enclosedOpenQueriesIncludeUpperCavesAndExcludeOpenSky() {
|
||||
BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-4,
|
||||
|
||||
+4
@@ -369,6 +369,10 @@ public final class GenerationHistoryRuntimeRouterRoutingTest extends GenerationH
|
||||
GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(4, -3);
|
||||
|
||||
assertThrows(IllegalStateException.class, route::claimGeneratedSemantics);
|
||||
try (GenerationHistoryRuntimeRouter.RuntimeRoute other = router.openRoute(5, -3);
|
||||
GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = other.openRuntimeScope()) {
|
||||
assertThrows(IllegalStateException.class, route::claimGeneratedSemantics);
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = route.openRuntimeScope()) {
|
||||
assertTrue(route.claimGeneratedSemantics());
|
||||
assertFalse(route.claimGeneratedSemantics());
|
||||
|
||||
+234
-3
@@ -3,6 +3,19 @@ package art.arcane.iris.world.history;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.EnginePlatformHooks;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.pack.loading.ResourceLoader;
|
||||
import art.arcane.iris.pack.value.IrisRange;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.structure.placement.StructurePlacementMarker;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.generation.mantle.EngineMantle;
|
||||
@@ -23,13 +36,18 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
@@ -47,6 +65,211 @@ public final class GenerationSemanticCaptureTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void scopedCaptureKeepsExplicitPrecedenceAndScalarFallback() {
|
||||
Fixture fixture = new Fixture();
|
||||
IrisBiome configured = new IrisBiome();
|
||||
configured.setLoadKey("iris:configured");
|
||||
IrisBiome fallback = new IrisBiome();
|
||||
fallback.setLoadKey("iris:fallback");
|
||||
IrisData data = mock(IrisData.class);
|
||||
ResourceLoader<IrisBiome> loader = mock(ResourceLoader.class);
|
||||
when(data.getBiomeLoader()).thenReturn(loader);
|
||||
when(loader.load("iris:configured")).thenReturn(configured);
|
||||
IrisDimensionCarvingEntry root = new IrisDimensionCarvingEntry();
|
||||
root.setBiome("iris:configured");
|
||||
root.setWorldYRange(new IrisRange(-64, -48));
|
||||
root.setChildRecursionDepth(0);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setCarving(new KList<>(List.of(root)));
|
||||
when(fixture.engine.getDimension()).thenReturn(dimension);
|
||||
when(fixture.engine.getData()).thenReturn(data);
|
||||
when(fixture.engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build());
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)))
|
||||
.thenAnswer(invocation -> (int) invocation.getArgument(1) <= 16 ? configured : fallback);
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
for (int y = 0; y < 64; y++) {
|
||||
consumer.accept(1, y, 2, new MatterCavern(true, y == 3 ? "iris:explicit" : null, (byte) 0));
|
||||
}
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
HydrologyCaveCell duplicate = mock(HydrologyCaveCell.class);
|
||||
when(duplicate.floodedBiomeKey()).thenReturn("iris:duplicate");
|
||||
when(duplicate.fluidProfileKey()).thenReturn("iris:river");
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, HydrologyCaveCell> consumer = invocation.getArgument(3);
|
||||
consumer.accept(1, 4, 2, duplicate);
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(HydrologyCaveCell.class), any());
|
||||
GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class);
|
||||
GenerationActivation activation = mock(GenerationActivation.class);
|
||||
when(activation.activationId()).thenReturn(5L);
|
||||
when(stage.activation()).thenReturn(activation);
|
||||
when(stage.chunkX()).thenReturn(-2);
|
||||
when(stage.chunkZ()).thenReturn(3);
|
||||
|
||||
ChunkGenerationSemantics expected = GenerationSemanticCapture.capture(fixture.engine, stage, (x, y, z) -> y != 8);
|
||||
verify(fixture.engine, times(62)).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
clearInvocations(fixture.engine);
|
||||
ChunkGenerationSemantics actual = GenerationSemanticCapture.captureScoped(fixture.engine, stage, (x, y, z) -> y != 8);
|
||||
|
||||
assertEquals(expected, actual);
|
||||
assertEquals(Set.of("iris:configured", "iris:fallback", "iris:explicit"), actual.caveBiomeKeys());
|
||||
verify(fixture.engine, times(47)).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedFallbackMatchesScalarDepthAndStackBiomesWithOneSamplePerColumn() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
Fixture fixture = new Fixture(engine);
|
||||
IrisComplex complex = engine.getComplex();
|
||||
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||
when(engine.hasGenerationRuntimeScope()).thenReturn(true);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisWorld world = IrisWorld.builder().minHeight(-64).maxHeight(320).build();
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getData()).thenReturn(data);
|
||||
when(engine.getWorld()).thenReturn(world);
|
||||
DimensionStackContext stack = mock(DimensionStackContext.class);
|
||||
when(engine.getDimensionStackContext()).thenReturn(stack);
|
||||
IrisBiome[] surfaces = new IrisBiome[256];
|
||||
IrisBiome[] caves = new IrisBiome[256];
|
||||
for (int index = 0; index < 256; index++) {
|
||||
surfaces[index] = biome("surface:" + index, 7);
|
||||
caves[index] = index % 3 == 0 ? null : biome(index % 3 == 1 ? null : "cave:" + index, 5);
|
||||
}
|
||||
when(complex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> surfaces[column(x, z)], Interpolated.of(value -> 0D, value -> null)));
|
||||
when(complex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> caves[column(x, z)], Interpolated.of(value -> 0D, value -> null)));
|
||||
AtomicInteger heightSamples = new AtomicInteger();
|
||||
when(complex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> {
|
||||
heightSamples.incrementAndGet();
|
||||
return 40D + (column(x, z) % 8);
|
||||
}, Interpolated.DOUBLE));
|
||||
when(stack.getLayout(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int index = column((int) invocation.getArgument(0), (int) invocation.getArgument(1));
|
||||
DimensionStackLayout layout = mock(DimensionStackLayout.class);
|
||||
DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class);
|
||||
when(layer.biome()).thenReturn(index % 2 == 0 ? biome("stack:" + index, 3) : null);
|
||||
when(layout.surfaceLayer()).thenReturn(layer);
|
||||
return layout;
|
||||
});
|
||||
Engine scalar = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
doReturn(complex).when(scalar).getComplex();
|
||||
doReturn(dimension).when(scalar).getDimension();
|
||||
doReturn(stack).when(scalar).getDimensionStackContext();
|
||||
doReturn(data).when(scalar).getData();
|
||||
doReturn(world).when(scalar).getWorld();
|
||||
doReturn(hooks).when(scalar).getPlatformHooks();
|
||||
Set<String> expected = new HashSet<>();
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
int index = (x << 4) | z;
|
||||
int surfaceY = 40 + index % 8;
|
||||
int y = surfaceY - index % 10;
|
||||
expected.add(scalar.getCaveBiome(x - 32, y, z + 48, state).getLoadKey());
|
||||
expected.add(scalar.getCaveBiome(x - 32, y + 1, z + 48, state).getLoadKey());
|
||||
}
|
||||
}
|
||||
heightSamples.set(0);
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
int index = (x << 4) | z;
|
||||
int y = 40 + index % 8 - index % 10;
|
||||
consumer.accept(x, y, z, new MatterCavern(true, null, (byte) 0));
|
||||
consumer.accept(x, y + 1, z, new MatterCavern(true, null, (byte) 0));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
|
||||
ChunkGenerationSemantics captured = GenerationSemanticCapture.captureScoped(engine, stage(), (x, y, z) -> true);
|
||||
|
||||
assertEquals(expected, captured.caveBiomeKeys());
|
||||
assertEquals(256, heightSamples.get());
|
||||
verify(engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedFallbackRetainsCapturedRuntimeWhenAmbientInputsChange() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
Fixture fixture = new Fixture(engine);
|
||||
IrisComplex capturedComplex = engine.getComplex();
|
||||
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||
when(engine.hasGenerationRuntimeScope()).thenReturn(true);
|
||||
when(engine.getDimension()).thenReturn(new IrisDimension());
|
||||
when(engine.getData()).thenReturn(mock(IrisData.class));
|
||||
when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build());
|
||||
when(capturedComplex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> biome("captured:surface", 0), Interpolated.of(value -> 0D, value -> null)));
|
||||
when(capturedComplex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> biome("captured:cave", 5), Interpolated.of(value -> 0D, value -> null)));
|
||||
when(capturedComplex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> 40D, Interpolated.DOUBLE));
|
||||
IrisComplex changedComplex = mock(IrisComplex.class);
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
consumer.accept(0, 40, 0, new MatterCavern(true, null, (byte) 0));
|
||||
when(engine.getComplex()).thenReturn(changedComplex);
|
||||
when(engine.getDimension()).thenReturn(new IrisDimension());
|
||||
when(engine.getData()).thenReturn(mock(IrisData.class));
|
||||
consumer.accept(0, 35, 0, new MatterCavern(true, null, (byte) 0));
|
||||
consumer.accept(15, 20, 15, new MatterCavern(true, null, (byte) 0));
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
|
||||
ChunkGenerationSemantics captured = GenerationSemanticCapture.captureScoped(engine, stage(), (x, y, z) -> true);
|
||||
|
||||
assertEquals(Set.of("captured:surface", "captured:cave"), captured.caveBiomeKeys());
|
||||
verify(changedComplex, never()).getCaveBiomeStream();
|
||||
verify(changedComplex, never()).getHeightStream();
|
||||
verify(engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
private static IrisBiome biome(String key, int minimumDepth) {
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoadKey(key);
|
||||
biome.setCaveMinDepthBelowSurface(minimumDepth);
|
||||
return biome;
|
||||
}
|
||||
|
||||
private static int column(double x, double z) {
|
||||
return (((int) x & 15) << 4) | ((int) z & 15);
|
||||
}
|
||||
|
||||
private static GenerationHistory.GenerationStage stage() {
|
||||
GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class);
|
||||
GenerationActivation activation = mock(GenerationActivation.class);
|
||||
when(activation.activationId()).thenReturn(5L);
|
||||
when(stage.activation()).thenReturn(activation);
|
||||
when(stage.chunkX()).thenReturn(-2);
|
||||
when(stage.chunkZ()).thenReturn(3);
|
||||
return stage;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unscopedCapturePreservesCustomThreeArgumentCaveBiomeDispatch() {
|
||||
Fixture fixture = new Fixture();
|
||||
IrisBiome custom = biome("custom:cave", 0);
|
||||
doReturn(custom).when(fixture.engine).getCaveBiome(-31, 20, 50);
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
consumer.accept(1, 20, 2, new MatterCavern(true, null, (byte) 0));
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
clearInvocations(fixture.engine);
|
||||
|
||||
ChunkGenerationSemantics captured = GenerationSemanticCapture.capture(fixture.engine, -2, 3, 5L);
|
||||
|
||||
assertEquals(Set.of("custom:cave"), captured.caveBiomeKeys());
|
||||
verify(fixture.engine).getCaveBiome(-31, 20, 50);
|
||||
verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void captureKeepsWorldPoiCoordinatesAndInternalHeight() {
|
||||
Fixture fixture = new Fixture();
|
||||
@@ -103,7 +326,7 @@ public final class GenerationSemanticCaptureTest {
|
||||
new ChunkGenerationSemantics.BlockPosition(3, 11, 4),
|
||||
new ChunkGenerationSemantics.BlockPosition(5, 12, 6)), checked);
|
||||
verify(filled, never()).fluidProfileKey();
|
||||
verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt());
|
||||
verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -112,7 +335,7 @@ public final class GenerationSemanticCaptureTest {
|
||||
List<Integer> heights = List.of(0, 15, 16, 255, 256, 767, 4063);
|
||||
Set<String> expected = new HashSet<>();
|
||||
List<ChunkGenerationSemantics.BlockPosition> resolved = new ArrayList<>();
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenAnswer(invocation -> {
|
||||
int x = invocation.getArgument(0);
|
||||
int y = invocation.getArgument(1);
|
||||
int z = invocation.getArgument(2);
|
||||
@@ -181,11 +404,19 @@ public final class GenerationSemanticCaptureTest {
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final Engine engine = mock(Engine.class);
|
||||
private final Engine engine;
|
||||
@SuppressWarnings("unchecked")
|
||||
private final Mantle<Matter> mantle = mock(Mantle.class);
|
||||
|
||||
private Fixture() {
|
||||
this(mock(Engine.class));
|
||||
}
|
||||
|
||||
private Fixture(Engine engine) {
|
||||
this.engine = engine;
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
when(engine.getCaveBiome(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> engine.getCaveBiome(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2), state));
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
|
||||
@@ -250,25 +250,25 @@ public class SavedBiomeStoreBatchTest {
|
||||
private static List<Future<ClaimResult>> enqueue(SavedBiomeStore store, Path root, BatchControl control,
|
||||
ExecutorService workers, List<SavedBiomeChunk> chunks) throws Exception {
|
||||
Object stripe = regionLock(store, 0, 0);
|
||||
Field writingField = stripe.getClass().getDeclaredField("writing");
|
||||
Field pendingField = stripe.getClass().getDeclaredField("pending");
|
||||
writingField.setAccessible(true);
|
||||
pendingField.setAccessible(true);
|
||||
Queue<?> pending = (Queue<?>) pendingField.get(stripe);
|
||||
ArrayList<Future<ClaimResult>> results = new ArrayList<>(chunks.size());
|
||||
synchronized (stripe) {
|
||||
writingField.setBoolean(stripe, true);
|
||||
try {
|
||||
for (SavedBiomeChunk chunk : chunks) {
|
||||
results.add(workers.submit(() -> claim(store, root, control, chunk)));
|
||||
}
|
||||
Await.reached("every claim to queue behind the region write", Duration.ofSeconds(5L),
|
||||
() -> pending.size() == chunks.size());
|
||||
assertEquals(chunks.size(), pending.size());
|
||||
} finally {
|
||||
writingField.setBoolean(stripe, false);
|
||||
int uncachedClaims = 0;
|
||||
for (SavedBiomeChunk chunk : chunks) {
|
||||
if (store.cached(chunk.chunkX(), chunk.chunkZ()).isEmpty()) {
|
||||
uncachedClaims++;
|
||||
}
|
||||
}
|
||||
int expectedQueuedClaims = uncachedClaims;
|
||||
synchronized (stripe) {
|
||||
for (SavedBiomeChunk chunk : chunks) {
|
||||
results.add(workers.submit(() -> claim(store, root, control, chunk)));
|
||||
}
|
||||
Await.reached("uncached claims to queue before acquiring the region write lock", Duration.ofSeconds(5L),
|
||||
() -> pending.size() == expectedQueuedClaims);
|
||||
assertEquals(expectedQueuedClaims, pending.size());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
+26
-3
@@ -16,6 +16,24 @@ import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class AsyncPregenMethodConcurrencyCapTest {
|
||||
@Test
|
||||
public void pregenWorkerTargetExpandsOnlyOptedInHighHeapJobs() {
|
||||
assertEquals(16, AsyncPregenMethod.pregenWorkerThreadTarget(16, (16L << 30) - 1L, true));
|
||||
assertEquals(32, AsyncPregenMethod.pregenWorkerThreadTarget(16, 16L << 30, true));
|
||||
assertEquals(32, AsyncPregenMethod.pregenWorkerThreadTarget(24, 16L << 30, true));
|
||||
assertEquals(64, AsyncPregenMethod.pregenWorkerThreadTarget(64, 16L << 30, true));
|
||||
assertEquals(16, AsyncPregenMethod.pregenWorkerThreadTarget(16, 32L << 30, false));
|
||||
assertEquals(2, AsyncPregenMethod.pregenWorkerThreadTarget(1, 8L << 30, true));
|
||||
assertEquals(Integer.MAX_VALUE, AsyncPregenMethod.pregenWorkerThreadTarget(Integer.MAX_VALUE, Long.MAX_VALUE, true));
|
||||
int target = AsyncPregenMethod.pregenWorkerThreadTarget(16, 16L << 30, true);
|
||||
int workers = AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, target);
|
||||
assertEquals(32, workers);
|
||||
assertEquals(256, AsyncPregenMethod.computePaperLikeRecommendedCap(workers));
|
||||
assertEquals(256, AsyncPregenMethod.computeInitialInFlightLimit(256, workers));
|
||||
assertEquals(48, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(48, 16, target));
|
||||
assertEquals(1, AsyncPregenMethod.selectConcurrencyCap(256, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void paperLikeRecommendedCapTracksWorkerThreads() {
|
||||
assertEquals(16, AsyncPregenMethod.computePaperLikeRecommendedCap(1));
|
||||
@@ -57,11 +75,16 @@ public class AsyncPregenMethodConcurrencyCapTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coldStartMatchesAvailableWorkersBeforeAdaptiveGrowth() {
|
||||
public void coldStartFillsChunkStatusPipelineWithinHardCap() {
|
||||
assertEquals(1, AsyncPregenMethod.computeInitialInFlightLimit(1, 16));
|
||||
assertEquals(4, AsyncPregenMethod.computeInitialInFlightLimit(128, 1));
|
||||
assertEquals(16, AsyncPregenMethod.computeInitialInFlightLimit(128, 16));
|
||||
assertEquals(8, AsyncPregenMethod.computeInitialInFlightLimit(128, 1));
|
||||
assertEquals(128, AsyncPregenMethod.computeInitialInFlightLimit(128, 16));
|
||||
assertEquals(6, AsyncPregenMethod.computeInitialInFlightLimit(6, 16));
|
||||
assertEquals(64, AsyncPregenMethod.computeInitialInFlightLimit(64, 8));
|
||||
assertEquals(192, AsyncPregenMethod.computeInitialInFlightLimit(192, 64));
|
||||
assertEquals(256, AsyncPregenMethod.computeInitialInFlightLimit(256, Integer.MAX_VALUE));
|
||||
assertEquals(8, AsyncPregenMethod.computeInitialInFlightLimit(128, Integer.MIN_VALUE));
|
||||
assertEquals(1, AsyncPregenMethod.computeInitialInFlightLimit(0, 16));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package art.arcane.iris.world.pregen;
|
||||
|
||||
import art.arcane.iris.world.lifecycle.CapabilitySnapshot;
|
||||
import art.arcane.iris.world.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorkerPool;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class AsyncPregenWorkerBoostTest {
|
||||
@Test
|
||||
public void nestedTargetsEscalateAndRestoreOnlyAfterLastRelease() throws Exception {
|
||||
try (Fixture fixture = new Fixture(4)) {
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(16);
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(16);
|
||||
assertEquals(32, fixture.threads.get());
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(32, fixture.threads.get());
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(4, fixture.threads.get());
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(List.of(16, 32, 4), fixture.adjustments);
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(List.of(16, 32, 4, 32, 4), fixture.adjustments);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitHigherNativeCountIsNeverReduced() throws Exception {
|
||||
try (Fixture fixture = new Fixture(48)) {
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(16);
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(48, fixture.threads.get());
|
||||
assertTrue(fixture.adjustments.isEmpty());
|
||||
assertThrows(IllegalArgumentException.class, () -> AsyncPregenMethod.acquireWorkerThreadBoost(0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reacquisitionWaitsForPreviousRestorationAndKeepsItsOwnHold() throws Exception {
|
||||
try (Fixture fixture = new Fixture(4); ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(16);
|
||||
CountDownLatch restoring = new CountDownLatch(1);
|
||||
CountDownLatch finishRestore = new CountDownLatch(1);
|
||||
CountDownLatch acquiring = new CountDownLatch(1);
|
||||
AtomicBoolean waitOnce = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
int count = invocation.getArgument(0);
|
||||
if (count == 4 && waitOnce.compareAndSet(true, false)) {
|
||||
restoring.countDown();
|
||||
assertTrue(finishRestore.await(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
fixture.adjustments.add(count);
|
||||
fixture.threads.set(count);
|
||||
return null;
|
||||
}).when(fixture.pool).adjustThreadCount(anyInt());
|
||||
Future<?> released = executor.submit(() -> fixture.withBinding(AsyncPregenMethod::releaseWorkerThreadBoost));
|
||||
try {
|
||||
assertTrue(restoring.await(5L, TimeUnit.SECONDS));
|
||||
Future<?> acquired = executor.submit(() -> fixture.withBinding(() -> {
|
||||
acquiring.countDown();
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
}));
|
||||
assertTrue(acquiring.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(acquired.isDone());
|
||||
finishRestore.countDown();
|
||||
released.get(5L, TimeUnit.SECONDS);
|
||||
acquired.get(5L, TimeUnit.SECONDS);
|
||||
assertEquals(32, fixture.threads.get());
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(4, fixture.threads.get());
|
||||
} finally {
|
||||
finishRestore.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeRestoresWorkersWhenDrainFailsAndPreservesCancellationInterrupt() throws Exception {
|
||||
try (Fixture fixture = new Fixture(4)) {
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
AsyncPregenMethod method = mock(AsyncPregenMethod.class, CALLS_REAL_METHODS);
|
||||
PregenAdmissionGate admission = mock(PregenAdmissionGate.class);
|
||||
IllegalStateException failure = new IllegalStateException("drain failed");
|
||||
when(admission.awaitDrain(anyLong(), any(TimeUnit.class), any(Runnable.class))).thenThrow(failure);
|
||||
set(method, "closing", new AtomicBoolean());
|
||||
set(method, "holdsWorkerBoost", new AtomicBoolean(true));
|
||||
set(method, "admission", admission);
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, method::close));
|
||||
assertEquals(4, fixture.threads.get());
|
||||
assertTrue(Thread.currentThread().isInterrupted());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, method::close));
|
||||
assertEquals(List.of(32, 4), fixture.adjustments);
|
||||
}
|
||||
}
|
||||
|
||||
private static void set(AsyncPregenMethod method, String name, Object value) throws Exception {
|
||||
Field field = AsyncPregenMethod.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(method, value);
|
||||
}
|
||||
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
private final WorldLifecycleService service = mock(WorldLifecycleService.class);
|
||||
private final NativeWorkerPool pool = mock(NativeWorkerPool.class);
|
||||
private final AtomicInteger threads;
|
||||
private final List<Integer> adjustments = Collections.synchronizedList(new ArrayList<>());
|
||||
private final MockedStatic<WorldLifecycleService> lifecycle = mockStatic(WorldLifecycleService.class);
|
||||
|
||||
private Fixture(int initialThreads) throws Exception {
|
||||
threads = new AtomicInteger(initialThreads);
|
||||
CapabilitySnapshot capabilities = mock(CapabilitySnapshot.class);
|
||||
NativeWorldRuntime runtime = mock(NativeWorldRuntime.class);
|
||||
when(service.capabilities()).thenReturn(capabilities);
|
||||
when(capabilities.nativeRuntime()).thenReturn(runtime);
|
||||
when(runtime.workers()).thenReturn(pool);
|
||||
when(pool.threadCount()).thenAnswer(invocation -> threads.get());
|
||||
doAnswer(invocation -> {
|
||||
int count = invocation.getArgument(0);
|
||||
adjustments.add(count);
|
||||
threads.set(count);
|
||||
return null;
|
||||
}).when(pool).adjustThreadCount(anyInt());
|
||||
lifecycle.when(WorldLifecycleService::get).thenReturn(service);
|
||||
}
|
||||
|
||||
private void withBinding(Runnable action) {
|
||||
try (MockedStatic<WorldLifecycleService> binding = mockStatic(WorldLifecycleService.class)) {
|
||||
binding.when(WorldLifecycleService::get).thenReturn(service);
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (int index = 0; index < 4; index++) {
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
}
|
||||
lifecycle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user