diff --git a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java index bcb02f8dc..f9718154c 100644 --- a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java @@ -17,8 +17,6 @@ import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.iris.generation.concurrent.MultiBurst; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.CompletableFuture; public class ChunkContext { @@ -95,38 +93,11 @@ public class ChunkContext { if (hydrologyRuntime != null && (resolvedPlan.height || resolvedPlan.biome)) { hydrologyRuntime.prepareChunkColumns(x, z); } - List fillTasks = new ArrayList<>(6); - if (resolvedPlan.height) { - fillTasks.add(() -> height.fillRounded(roundedHeight)); - } - if (resolvedPlan.biome) { - fillTasks.add(new PrefillFillTask(biome)); - } - if (resolvedPlan.rock) { - fillTasks.add(new PrefillFillTask(rock)); - } - if (resolvedPlan.fluid) { - fillTasks.add(new PrefillFillTask(fluid)); - } - if (resolvedPlan.region) { - fillTasks.add(new PrefillFillTask(region)); - } - if (resolvedPlan.cave) { - fillTasks.add(new PrefillFillTask(cave)); - } - - if (!shouldPrefillAsync(resolvedPlan, fillTasks.size())) { - for (Runnable fillTask : fillTasks) { - fillTask.run(); - } + if (shouldPrefillAsync(resolvedPlan, complex.isPlatformMainThread())) { + prefillAsync(resolvedPlan); } else { - List> futures = new ArrayList<>(fillTasks.size()); - for (Runnable fillTask : fillTasks) { - futures.add(CompletableFuture.runAsync(fillTask, MultiBurst.burst)); - } - for (CompletableFuture future : futures) { - future.join(); - } + prefillSurface(resolvedPlan); + prefillPalettes(resolvedPlan); } if (capturePrefillMetric) { metrics.getContextPrefill().put((System.nanoTime() - totalStartNanos) / 1_000_000D); @@ -134,14 +105,46 @@ public class ChunkContext { } } - static boolean shouldPrefillAsync(PrefillPlan prefillPlan, int fillTaskCount) { - if (prefillPlan == PrefillPlan.NATURAL_TERRAIN || fillTaskCount <= 1 || !IrisPlatforms.isBound()) { + static boolean shouldPrefillAsync(PrefillPlan prefillPlan, boolean platformMainThread) { + if (!platformMainThread || prefillPlan == PrefillPlan.NATURAL_TERRAIN || prefillPlan == PrefillPlan.NONE + || !IrisPlatforms.isBound()) { return false; } return !MultiBurst.burst.ownsCurrentThread(); } + private void prefillSurface(PrefillPlan prefillPlan) { + if (prefillPlan.height) { + height.fillRounded(roundedHeight); + } + if (prefillPlan.biome) { + biome.fill(); + } + } + + private void prefillPalettes(PrefillPlan prefillPlan) { + if (prefillPlan.rock) { + rock.fill(); + } + if (prefillPlan.fluid) { + fluid.fill(); + } + if (prefillPlan.region) { + region.fill(); + } + if (prefillPlan.cave) { + cave.fill(); + } + } + + private void prefillAsync(PrefillPlan prefillPlan) { + CompletableFuture surface = CompletableFuture.runAsync(() -> prefillSurface(prefillPlan), MultiBurst.burst); + CompletableFuture palettes = CompletableFuture.runAsync(() -> prefillPalettes(prefillPlan), MultiBurst.burst); + surface.join(); + palettes.join(); + } + public void setTerrainBiomeOutput(Hunk terrainBiomes) { this.terrainBiomes = terrainBiomes; } @@ -295,17 +298,4 @@ public class ChunkContext { public record TransitionTerrainColumn(Terrain3DColumn solids, int fluidHeight) { } - - private static final class PrefillFillTask implements Runnable { - private final ChunkedDataCache dataCache; - - private PrefillFillTask(ChunkedDataCache dataCache) { - this.dataCache = dataCache; - } - - @Override - public void run() { - dataCache.fill(); - } - } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java index fa4187e59..568c92088 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java @@ -77,7 +77,6 @@ import java.awt.Color; import java.nio.file.Path; import java.util.Locale; import java.util.Set; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -224,10 +223,6 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, EngineWorldManager getWorldManager(); - default UUID getBiomeID(int x, int z) { - return getComplex().getBaseBiomeIDStream().get(x, z); - } - int getParallelism(); void setParallelism(int parallelism); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index 64dcf075c..0b8255c20 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -29,7 +29,6 @@ import art.arcane.iris.generation.subterrain.SubterrainRasterizer; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisRegistrant; -import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.studio.generation.BiomeBuffetLayout; import art.arcane.iris.world.history.GenerationBlend; import art.arcane.iris.world.history.TransitionGenerationPlan; @@ -52,8 +51,6 @@ import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink; import art.arcane.iris.pack.value.IrisRaritySelection; -import art.arcane.iris.generation.decoration.IrisDecorationPart; -import art.arcane.iris.generation.decoration.IrisDecorator; import art.arcane.iris.generation.noise.IrisGenerator; import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.generation.terrain.ContinentGenerator; @@ -80,12 +77,12 @@ 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; -import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.generation.block.DataProvider; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.interpolation.IrisInterpolation; import art.arcane.volmlib.util.interpolation.NoiseBounds; import art.arcane.volmlib.util.interpolation.NoiseBoundsProvider; import art.arcane.volmlib.util.noise.CNG; @@ -99,8 +96,10 @@ import lombok.Setter; import lombok.ToString; import java.lang.ref.WeakReference; +import java.util.ArrayDeque; import java.util.Arrays; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.EnumMap; @@ -133,6 +132,8 @@ public class IrisComplex implements DataProvider { private static final double SLOPE_RUN = 3D; /** A sheer coast would let shoreMinimumWidth buy unbounded height, so the shore band stops climbing here. */ private static final double MAX_SHORE_BAND_GRADIENT = 3D; + static final double SHORE_NOISE_LOWEST = -0.5D; + static final double SHORE_NOISE_HIGHEST = 1.5D; private static final Comparator INTERPOLATOR_ORDER = Comparator .comparing((IrisInterpolator interpolator) -> interpolator.getFunction().name()) .thenComparingDouble(IrisInterpolator::getHorizontalScale); @@ -176,7 +177,6 @@ public class IrisComplex implements DataProvider { private ProceduralStream regionStream; private ProceduralStream regionStyleStream; private ProceduralStream regionIdentityStream; - private ProceduralStream regionIDStream; private ProceduralStream bridgeStream; private ProceduralStream landBiomeStream; private ProceduralStream caveBiomeStream; @@ -185,10 +185,8 @@ public class IrisComplex implements DataProvider { private ProceduralStream baseBiomeStream; @Getter(AccessLevel.NONE) private final transient boolean reuseNaturalBaseBiome; - private ProceduralStream baseBiomeIDStream; private ProceduralStream naturalTrueBiomeStream; private ProceduralStream trueBiomeStream; - private ProceduralStream trueBiomeDerivativeStream; private ProceduralStream naturalHeightStream; private ProceduralStream baseTerrainHeightStream; @Getter(AccessLevel.NONE) @@ -230,13 +228,6 @@ public class IrisComplex implements DataProvider { private ProceduralStream riverCarveWeightStream; private ProceduralStream riverWaterSurfaceStream; private ProceduralStream topSurfaceStream; - private ProceduralStream terrainSurfaceDecoration; - private ProceduralStream terrainCeilingDecoration; - private ProceduralStream terrainCaveSurfaceDecoration; - private ProceduralStream terrainCaveCeilingDecoration; - private ProceduralStream seaSurfaceDecoration; - private ProceduralStream seaFloorDecoration; - private ProceduralStream shoreSurfaceDecoration; private ProceduralStream rockStream; private ProceduralStream fluidStream; private Map> hydrologyFluidStreams; @@ -350,7 +341,9 @@ public class IrisComplex implements DataProvider { preparedRegions, (region, inferredType) -> compileInferredBiomeStream(engine, region, inferredType, emptyBiome) ); - GeneratorGroup[] generatorGroups = freezeGeneratorGroups(generators); + GeneratorGroup[] generatorGroups = focusBiome == null && !buffetActive + ? contributingGroups(freezeGeneratorGroups(generators), baseCapableBiomes(preparedRegions)) + : freezeGeneratorGroups(generators); int interpolatorCount = generatorGroups.length; frozenInterpolators = new IrisInterpolator[interpolatorCount]; frozenGenerators = new IrisGenerator[interpolatorCount][]; @@ -384,8 +377,6 @@ public class IrisComplex implements DataProvider { : GenerationStreams.cache2D(RegionSelection.select(new RegionSelection.Options( engine.getDimension(), data, regionStyleStream, imageMapRuntime, null)), "regionStream", engine, cacheSize); - regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i), - String.valueOf(i * 38445).hashCode() * 3245556666L)); caveBiomeStream = GenerationStreams.cache2D(GenerationStreams.contextInjecting(regionStream, engine, (c, x, z) -> c.getRegion().get(x, z)) .convert((r) -> createInferredBiomeStream(r, InferredType.CAVE)) .convertAware2D(ProceduralStream::get), "caveBiomeStream", engine, cacheSize); @@ -487,8 +478,6 @@ public class IrisComplex implements DataProvider { shoreSlopeStream = terrain3DEnabled ? baseTerrainHeightStream.slope(3) : naturalSlopeStream; trueBiomeStream = focusedBiomes != null ? GenerationStreams.cache2D(focusedBiomes, "trueBiomeStream-focus", engine, cacheSize) : GenerationStreams.cache2D(heightStream .convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z)), "trueBiomeStream", engine, cacheSize); - trueBiomeDerivativeStream = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z)) - .convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())), "trueBiomeDerivativeStream", engine, cacheSize); riverDistanceStream = GenerationStreams.cache2DDouble(ProceduralStream.ofDouble(this::resolveHydrologyDistance), "riverDistanceStream", engine, cacheSize); riverFlowStream = GenerationStreams.cache2DDouble(ProceduralStream.ofDouble(this::resolveHydrologyFlow), "riverFlowStream", engine, cacheSize); riverCarveWeightStream = GenerationStreams.cache2DDouble(ProceduralStream.ofDouble(this::resolveHydrologyCarveWeight), "riverCarveWeightStream", engine, cacheSize); @@ -498,26 +487,6 @@ public class IrisComplex implements DataProvider { riverWaterSurfaceStream.get(x, z) )), "heightFluidStream", engine, cacheSize); maxHeightStream = ProceduralStream.ofDouble((x, z) -> height); - terrainSurfaceDecoration = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)), "terrainSurfaceDecoration", engine, cacheSize); - terrainCeilingDecoration = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)), "terrainCeilingDecoration", engine, cacheSize); - terrainCaveSurfaceDecoration = GenerationStreams.cache2D(GenerationStreams.contextInjecting(caveBiomeStream, engine, (c, x, z) -> c.getCave().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)), "terrainCaveSurfaceDecoration", engine, cacheSize); - terrainCaveCeilingDecoration = GenerationStreams.cache2D(GenerationStreams.contextInjecting(caveBiomeStream, engine, (c, x, z) -> c.getCave().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)), "terrainCaveCeilingDecoration", engine, cacheSize); - shoreSurfaceDecoration = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SHORE_LINE)), "shoreSurfaceDecoration", engine, cacheSize); - seaSurfaceDecoration = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_SURFACE)), "seaSurfaceDecoration", engine, cacheSize); - seaFloorDecoration = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_FLOOR)), "seaFloorDecoration", engine, cacheSize); - baseBiomeIDStream = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, x, z) -> { - UUID d = regionIDStream.get(x, z); - return new UUID(b.getLoadKey().hashCode() * 818223L, - d.hashCode()); - }), "", engine, cacheSize); //@done TerrainTransformer transformer = engine.getDimension().getTerrainTransform() == null ? null : TerrainTransformRegistry.discover().resolve(engine.getDimension().getTerrainTransform()); @@ -1415,6 +1384,10 @@ public class IrisComplex implements DataProvider { return false; } + public boolean isPlatformMainThread() { + return terrainEngine.getPlatformHooks().isMainThread(); + } + public ProceduralStream getRawHeightStream() { return heightStream; } @@ -1629,24 +1602,6 @@ public class IrisComplex implements DataProvider { return proceduralTerrainHeight.sample(worldX, worldZ); } - private IrisDecorator decorateFor(IrisBiome b, double x, double z, IrisDecorationPart part) { - RNG rngc = new RNG(Cache.key(((int) x), ((int) z))); - - for (IrisDecorator i : b.getDecorators()) { - if (!i.getPartOf().equals(part)) { - continue; - } - - NativeBlockState block = i.getBlockData(b, rngc, x, z, data); - - if (block != null) { - return i; - } - } - - return null; - } - private IrisBiome resolveHydrologySurfaceBiome(double terrainHeight, double x, double z) { HydrologyColumnSample sample = hydrologySample(x, z); HydrologyColumnLayer layer = sample == null ? null : sample.primarySurfaceLayer().orElse(null); @@ -1730,6 +1685,33 @@ public class IrisComplex implements DataProvider { return bandTop + shoreMinimumWidth * Math.min(shoreSlopes.getDouble(x, z) / SLOPE_RUN, MAX_SHORE_BAND_GRADIENT); } + static boolean aboveShoreBand( + double height, + IrisRegion region, + double x, + double z, + double fluidHeight, + ProceduralStream shoreSlopes + ) { + if (region.getShoreMinimumWidth() <= 0) { + double minimum = region.getShoreHeightMin(); + double maximum = region.getShoreHeightMax(); + double lowestTop = fluidHeight + shoreHeightAt(minimum, maximum, SHORE_NOISE_LOWEST); + double highestTop = fluidHeight + shoreHeightAt(minimum, maximum, SHORE_NOISE_HIGHEST); + if (height > Math.max(lowestTop, highestTop)) { + return true; + } + if (height <= Math.min(lowestTop, highestTop)) { + return false; + } + } + return height > shoreBandTop(height, region, x, z, fluidHeight, shoreSlopes); + } + + private static double shoreHeightAt(double minimum, double maximum, double noise) { + return minimum == maximum ? minimum : IrisInterpolation.lerp(minimum, maximum, noise); + } + static IrisBiome resolveSurfaceBiome( double height, IrisBiome biome, @@ -1745,13 +1727,15 @@ public class IrisComplex implements DataProvider { if (biome == null || region == null) { return biome; } - double shoreTop = shoreBandTop(height, region, x, z, fluidHeight, shoreSlopes); - if (height >= fluidHeight - 1 && height <= shoreTop && !biome.isShore()) { - return shoreBiomes.get(x, z); - } - - if (height > shoreTop && !biome.isLand()) { + if (height >= fluidHeight - 1 && !biome.isShore()) { + if (!aboveShoreBand(height, region, x, z, fluidHeight, shoreSlopes)) { + return shoreBiomes.get(x, z); + } + if (!biome.isLand()) { + return landBiomes.get(x, z); + } + } else if (!biome.isLand() && aboveShoreBand(height, region, x, z, fluidHeight, shoreSlopes)) { return landBiomes.get(x, z); } @@ -1911,13 +1895,92 @@ public class IrisComplex implements DataProvider { return h; } + int grid = biomeBoundsSamplingStep; + GridBoundsCache cache = gridBoundsCache.get(); + if (!ChunkCorners.supports(grid) || cache.cornersInUse) { + for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) { + h += interpolateGenerators(engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, frozenGenerators[interpolatorIndex], x, z, seed); + } + return h; + } + + cache.cornersInUse = true; + try { + return cornerBlockHeight(cache, engine, grid, x, z, seed); + } finally { + cache.cornersInUse = false; + } + } + + private double cornerBlockHeight(GridBoundsCache cache, Engine engine, int grid, double x, double z, long seed) { + double h = 0; + int xi = (int) Math.floor(x); + int zi = (int) Math.floor(z); + int gx = xi & ~(grid - 1); + int gz = zi & ~(grid - 1); + double fx = (x - gx) / grid; + double fz = (z - gz) / grid; + ChunkCorners corners = cache.chunkCorners(xi >> 4, zi >> 4, frozenInterpolators.length, grid); + int cornerX = (gx - corners.originX) / grid; + int cornerZ = (gz - corners.originZ) / grid; + int cell = cornerZ * corners.cellsPerAxis + cornerX; + long heightSeed = seed + 239945; for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) { - h += interpolateGenerators(engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, frozenGenerators[interpolatorIndex], x, z, seed); + IrisGenerator[] generators = frozenGenerators[interpolatorIndex]; + if (generators.length == 0) { + continue; + } + int state = corners.cellState(interpolatorIndex, cell); + if (state == ChunkCorners.SILENT) { + continue; + } + long b00 = cornerAt(cache, engine, corners, interpolatorIndex, cornerX, cornerZ); + long b10 = fx == 0D ? 0L : cornerAt(cache, engine, corners, interpolatorIndex, cornerX + 1, cornerZ); + long b01 = fz == 0D ? 0L : cornerAt(cache, engine, corners, interpolatorIndex, cornerX, cornerZ + 1); + long b11 = fx == 0D || fz == 0D ? 0L : cornerAt(cache, engine, corners, interpolatorIndex, cornerX + 1, cornerZ + 1); + if (state == ChunkCorners.UNKNOWN) { + corners.classify(interpolatorIndex, cornerX, cornerZ, cell); + if (silentBounds(b00) && silentBounds(b10) && silentBounds(b01) && silentBounds(b11)) { + continue; + } + } + double lo; + double hi; + if (fx == 0D && fz == 0D) { + lo = boundsLow(b00); + hi = boundsHigh(b00); + } else if (fz == 0D) { + lo = biLerp(boundsLow(b00), boundsLow(b10), boundsLow(b00), boundsLow(b10), fx, fz); + hi = biLerp(boundsHigh(b00), boundsHigh(b10), boundsHigh(b00), boundsHigh(b10), fx, fz); + } else if (fx == 0D) { + lo = biLerp(boundsLow(b00), boundsLow(b00), boundsLow(b01), boundsLow(b01), fx, fz); + hi = biLerp(boundsHigh(b00), boundsHigh(b00), boundsHigh(b01), boundsHigh(b01), fx, fz); + } else { + lo = biLerp(boundsLow(b00), boundsLow(b10), boundsLow(b01), boundsLow(b11), fx, fz); + hi = biLerp(boundsHigh(b00), boundsHigh(b10), boundsHigh(b01), boundsHigh(b11), fx, fz); + } + h += averageGeneratorHeights(generators, lo, hi, x, z, heightSeed); } return h; } + private long cornerAt(GridBoundsCache cache, Engine engine, ChunkCorners corners, int interpolatorIndex, int column, int row) { + if (corners.known(interpolatorIndex, column, row)) { + return corners.corner(interpolatorIndex, column, row); + } + long packed = cornerBounds(cache, engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, + frozenGenerators[interpolatorIndex], corners.originX + column * corners.grid, corners.originZ + row * corners.grid); + if (Double.isFinite(boundsLow(packed)) && Double.isFinite(boundsHigh(packed))) { + corners.remember(interpolatorIndex, column, row, packed); + } + return packed; + } + + private static boolean silentBounds(long packed) { + return (packed & 0x7FFFFFFF7FFFFFFFL) == 0L; + } + private NoiseBounds naturalHeightBounds( Engine engine, KList overlayNoise, @@ -2058,6 +2121,48 @@ public class IrisComplex implements DataProvider { return groups; } + static GeneratorGroup[] contributingGroups(GeneratorGroup[] groups, Collection baseBiomes) { + List contributing = new ArrayList<>(groups.length); + for (GeneratorGroup group : groups) { + if (contributesHeight(group.generators(), baseBiomes)) { + contributing.add(group); + } + } + return contributing.toArray(new GeneratorGroup[0]); + } + + private static boolean contributesHeight(IrisGenerator[] generators, Collection baseBiomes) { + for (IrisBiome biome : baseBiomes) { + GeneratorBounds bounds = computeGeneratorBounds(generators, biome); + if (bounds.min != 0D || bounds.max != 0D) { + return true; + } + } + return false; + } + + private Collection baseCapableBiomes(KList regions) { + Set reached = Collections.newSetFromMap(new IdentityHashMap<>()); + ArrayDeque pending = new ArrayDeque<>(); + for (IrisRegion region : regions) { + pending.addAll(loadInferredBiomes(region.getLandBiomes(), InferredType.LAND)); + pending.addAll(loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA)); + } + pending.addAll(imageMapRuntime.getMappedBiomes()); + while (!pending.isEmpty()) { + IrisBiome biome = pending.poll(); + if (!reached.add(biome)) { + continue; + } + for (IrisBiome child : biome.getRealChildren(this)) { + if (child != null) { + pending.add(child); + } + } + } + return reached; + } + static double averageGeneratorHeights( IrisGenerator[] generators, double low, @@ -2103,7 +2208,7 @@ public class IrisComplex implements DataProvider { for (int i = 0; i < frozenInterpolators.length; i++) { IdentityHashMap interpolatorBounds = new IdentityHashMap<>(Math.max(allBiomes.size(), 16)); for (IrisBiome biome : allBiomes) { - interpolatorBounds.put(biome, computeGeneratorBounds(engine, frozenGenerators[i], biome)); + interpolatorBounds.put(biome, computeGeneratorBounds(frozenGenerators[i], biome)); } bounds.put(frozenInterpolators[i], interpolatorBounds); } @@ -2111,7 +2216,7 @@ public class IrisComplex implements DataProvider { return bounds; } - private GeneratorBounds computeGeneratorBounds(Engine engine, IrisGenerator[] generators, IrisBiome biome) { + private static GeneratorBounds computeGeneratorBounds(IrisGenerator[] generators, IrisBiome biome) { double min = 0D; double max = 0D; @@ -2145,7 +2250,7 @@ public class IrisComplex implements DataProvider { return local; } - GeneratorBounds computed = computeGeneratorBounds(engine, generators, biome); + GeneratorBounds computed = computeGeneratorBounds(generators, biome); localBounds.put(biome, computed); return computed; } @@ -2246,6 +2351,27 @@ public class IrisComplex implements DataProvider { private final long[] packed = new long[GRID_BOUNDS_CACHE_SIZE]; private final boolean[] valid = new boolean[GRID_BOUNDS_CACHE_SIZE]; private final BoundsSampler sampler = new BoundsSampler(); + private ChunkCorners recentCorners; + private ChunkCorners previousCorners; + private boolean cornersInUse; + + private ChunkCorners chunkCorners(int chunkX, int chunkZ, int groups, int grid) { + ChunkCorners recent = recentCorners; + if (recent != null && recent.matches(chunkX, chunkZ, groups, grid)) { + return recent; + } + ChunkCorners previous = previousCorners; + if (previous != null && previous.matches(chunkX, chunkZ, groups, grid)) { + previousCorners = recent; + recentCorners = previous; + return previous; + } + ChunkCorners reused = previous != null && previous.fits(groups, grid) ? previous : new ChunkCorners(groups, grid); + reused.reset(chunkX, chunkZ); + previousCorners = recent; + recentCorners = reused; + return reused; + } private int slot(int cornerX, int cornerZ, int interpolatorIndex) { long h = (cornerX * 0x9E3779B97F4A7C15L) ^ (cornerZ * 0xC2B2AE3D27D4EB4FL) ^ (interpolatorIndex * 0x165667B19E3779F9L); @@ -2254,6 +2380,92 @@ public class IrisComplex implements DataProvider { } } + private static final class ChunkCorners { + private static final int UNKNOWN = 0; + private static final int SILENT = 1; + private static final int ACTIVE = 2; + + private final int groups; + private final int grid; + private final int cornersPerAxis; + private final int cellsPerAxis; + private final long[] packed; + private final boolean[] known; + private final byte[] cellStates; + private int chunkX; + private int chunkZ; + private int originX; + private int originZ; + + private ChunkCorners(int groups, int grid) { + this.groups = groups; + this.grid = grid; + cellsPerAxis = 16 / grid; + cornersPerAxis = cellsPerAxis + 1; + packed = new long[groups * cornersPerAxis * cornersPerAxis]; + known = new boolean[packed.length]; + cellStates = new byte[groups * cellsPerAxis * cellsPerAxis]; + } + + private static boolean supports(int grid) { + return grid >= 2 && grid <= 16 && Integer.bitCount(grid) == 1; + } + + private boolean fits(int groups, int grid) { + return this.groups == groups && this.grid == grid; + } + + private boolean matches(int chunkX, int chunkZ, int groups, int grid) { + return this.chunkX == chunkX && this.chunkZ == chunkZ && fits(groups, grid); + } + + private void reset(int chunkX, int chunkZ) { + this.chunkX = chunkX; + this.chunkZ = chunkZ; + originX = chunkX << 4; + originZ = chunkZ << 4; + Arrays.fill(known, false); + Arrays.fill(cellStates, (byte) UNKNOWN); + } + + private int cellState(int group, int cell) { + return cellStates[group * cellsPerAxis * cellsPerAxis + cell]; + } + + private void setCellState(int group, int cell, int state) { + cellStates[group * cellsPerAxis * cellsPerAxis + cell] = (byte) state; + } + + private boolean known(int group, int column, int row) { + return known[index(group, column, row)]; + } + + private long corner(int group, int column, int row) { + return packed[index(group, column, row)]; + } + + private void remember(int group, int column, int row, long value) { + int index = index(group, column, row); + packed[index] = value; + known[index] = true; + } + + private void classify(int group, int column, int row, int cell) { + int first = index(group, column, row); + int second = first + cornersPerAxis; + if (!known[first] || !known[first + 1] || !known[second] || !known[second + 1]) { + return; + } + boolean silent = silentBounds(packed[first]) && silentBounds(packed[first + 1]) + && silentBounds(packed[second]) && silentBounds(packed[second + 1]); + setCellState(group, cell, silent ? SILENT : ACTIVE); + } + + private int index(int group, int column, int row) { + return (group * cornersPerAxis + row) * cornersPerAxis + column; + } + } + /** * Reusable per-thread bounds provider. Holds the biome memo and the lazily computed generator * bounds for one sampleBoundsRaw pass so the pass allocates nothing; {@link #bind} diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java index 6a61d308f..7c39f3338 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java @@ -160,6 +160,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator biomeCache; private final ChunkedDataCache regionCache; private final ChunkedDataCache rockCache; + private final ChunkedDataCache fluidCache; private final UpperDimensionContext upperContext; private final IrisOreBands dimensionSurfaceOres; private final IrisOreBands dimensionUndergroundOres; @@ -192,6 +193,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator platforms = mockStatic(IrisPlatforms.class)) { + platforms.when(IrisPlatforms::isBound).thenReturn(true); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NO_CAVE, false)); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.ALL, false)); + platforms.verifyNoInteractions(); + } + } + + @Test + public void platformMainThreadKeepsBlockingPrefillOffTheTickThread() { + try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { + platforms.when(IrisPlatforms::isBound).thenReturn(true); + assertTrue(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NO_CAVE, true)); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NONE, true)); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NATURAL_TERRAIN, true)); + } + } + + @Test + public void inlinePrefillFinishesHeightsBeforeTheBiomePassStarts() { + AtomicInteger caveCalls = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + AtomicInteger biomeCalls = new AtomicInteger(); + AtomicInteger rockCalls = new AtomicInteger(); + AtomicInteger fluidCalls = new AtomicInteger(); + AtomicInteger regionCalls = new AtomicInteger(); + AtomicInteger heightsSeenByFirstBiome = new AtomicInteger(-1); + try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { + platforms.when(IrisPlatforms::isBound).thenReturn(true); + createContext(ChunkContext.PrefillPlan.NO_CAVE, caveCalls, heightCalls, biomeCalls, rockCalls, + fluidCalls, regionCalls, null, () -> heightsSeenByFirstBiome.compareAndSet(-1, heightCalls.get())); + } + + assertEquals(256, heightsSeenByFirstBiome.get()); + assertEquals(256, biomeCalls.get()); } @Test @@ -110,7 +143,7 @@ public class ChunkContextPrefillPlanTest { AtomicInteger regionCalls = new AtomicInteger(); try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { platforms.when(IrisPlatforms::isBound).thenReturn(true); - assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NATURAL_TERRAIN, 5)); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NATURAL_TERRAIN, true)); ChunkContext context = createContext(ChunkContext.PrefillPlan.NATURAL_TERRAIN, caveCalls, heightCalls, biomeCalls, rockCalls, fluidCalls, regionCalls); @@ -228,6 +261,22 @@ public class ChunkContextPrefillPlanTest { AtomicInteger fluidCalls, AtomicInteger regionCalls, IrisHydrologyRuntime runtime + ) { + return createContext(prefillPlan, caveCalls, heightCalls, biomeCalls, rockCalls, fluidCalls, regionCalls, + runtime, () -> { + }); + } + + private ChunkContext createContext( + ChunkContext.PrefillPlan prefillPlan, + AtomicInteger caveCalls, + AtomicInteger heightCalls, + AtomicInteger biomeCalls, + AtomicInteger rockCalls, + AtomicInteger fluidCalls, + AtomicInteger regionCalls, + IrisHydrologyRuntime runtime, + Runnable biomeObserver ) { IrisComplex complex = mock(IrisComplex.class); @@ -244,6 +293,7 @@ public class ChunkContextPrefillPlanTest { ProceduralStream biomeStream = mock(ProceduralStream.class); IrisBiome biome = mock(IrisBiome.class); doAnswer(invocation -> { + biomeObserver.run(); biomeCalls.incrementAndGet(); return biome; }).when(biomeStream).get(anyDouble(), anyDouble()); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java index 8a77db249..5a9f60e57 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java @@ -351,6 +351,7 @@ public class DimensionTerrain3DCompositionTest { when(context.getBiome()).thenReturn(biomes); when(context.getRegion()).thenReturn(regions); when(context.getRock()).thenReturn(rocks); + when(context.getFluid()).thenReturn(mock(ChunkedDataCache.class)); when(context.getRoundedHeight(0, 0)).thenReturn(rootHeight); Hunk blocks = Hunk.newArrayHunk(1, 64, 1); new IrisTerrainNormalActuator(engine).paint(0, 0, blocks, context); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexChunkCornersTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexChunkCornersTest.java new file mode 100644 index 000000000..090046995 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexChunkCornersTest.java @@ -0,0 +1,289 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisInterpolator; +import art.arcane.volmlib.util.interpolation.NoiseBounds; +import art.arcane.volmlib.util.interpolation.NoiseBoundsProvider; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.SplittableRandom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +public class IrisComplexChunkCornersTest { + @Test + public void chunkCornerHeightsMatchPerGroupGridSamplingBitForBit() throws Exception { + for (int step : new int[]{2, 4, 8, 16}) { + IrisInterpolator[] interpolators = { + new PatchInterpolator(0, 1D), + new PatchInterpolator(1, -3D), + new ZeroInterpolator(true), + new PatchInterpolator(2, 0.5D), + new ZeroInterpolator(false) + }; + IrisGenerator[][] generators = { + {new WaveGenerator(1D), new WaveGenerator(0.5D)}, + {new WaveGenerator(-0.25D)}, + {new WaveGenerator(2D)}, + {new WaveGenerator(0.75D), new WaveGenerator(-1D), new WaveGenerator(0.125D)}, + {new WaveGenerator(4D)} + }; + IrisComplex candidate = createComplex(step, interpolators, generators); + IrisComplex reference = createComplex(step, interpolators, generators); + SplittableRandom random = new SplittableRandom(step * 31L); + for (int sample = 0; sample < 60_000; sample++) { + double x = coordinate(random); + double z = coordinate(random); + assertEquals("step " + step + " at " + x + "," + z, + Double.doubleToRawLongBits(expectedHeight(reference, interpolators, generators, x, z)), + Double.doubleToRawLongBits(interpolatedHeight(candidate, x, z))); + } + } + } + + @Test + public void silentCellsNeverEvaluateTheirGenerators() throws Exception { + WaveGenerator quiet = new WaveGenerator(1D); + WaveGenerator loud = new WaveGenerator(1D); + IrisInterpolator[] interpolators = {new ZeroInterpolator(true), new PatchInterpolator(0, 2D)}; + IrisGenerator[][] generators = {{quiet}, {loud}}; + IrisComplex complex = createComplex(4, interpolators, generators); + + for (int x = 0; x < 64; x++) { + for (int z = 0; z < 64; z++) { + interpolatedHeight(complex, x, z); + } + } + + assertEquals(0, quiet.invocations); + assertTrue(loud.invocations > 0); + } + + @Test + public void sparseSamplesFetchOnlyTheCornersTheirColumnsUse() throws Exception { + CountingInterpolator counting = new CountingInterpolator(); + IrisInterpolator[] interpolators = {counting}; + IrisGenerator[][] generators = {{new WaveGenerator(1D)}}; + IrisComplex complex = createComplex(4, interpolators, generators); + + for (int sample = 0; sample < 32; sample++) { + interpolatedHeight(complex, sample * 64D, sample * -64D); + } + assertEquals(32, counting.calls); + + interpolatedHeight(complex, 2048D + 1D, 0D); + assertEquals(34, counting.calls); + interpolatedHeight(complex, 4096D, 1D); + assertEquals(36, counting.calls); + interpolatedHeight(complex, 8192D + 1D, 1D); + assertEquals(40, counting.calls); + } + + @Test + public void nestedHeightSamplesLeaveTheOuterChunkBlockIntact() throws Exception { + ReentrantGenerator reentrant = new ReentrantGenerator(); + IrisInterpolator[] interpolators = {new ZeroFreeInterpolator(), new PatchInterpolator(4, -2D)}; + IrisGenerator[][] generators = {{reentrant}, {new WaveGenerator(0.5D)}}; + IrisComplex candidate = createComplex(4, interpolators, generators); + IrisComplex reference = createComplex(4, interpolators, generators); + SplittableRandom random = new SplittableRandom(99L); + for (int sample = 0; sample < 4_000; sample++) { + double x = coordinate(random); + double z = coordinate(random); + reentrant.target = reference; + double expected = expectedHeight(reference, interpolators, generators, x, z); + reentrant.target = candidate; + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(interpolatedHeight(candidate, x, z))); + } + } + + @Test + public void nonFiniteCornersFailTheSameWayAndAreRetried() throws Exception { + NaNCornerInterpolator interpolator = new NaNCornerInterpolator(); + IrisInterpolator[] interpolators = {interpolator}; + IrisGenerator[][] generators = {{new WaveGenerator(1D)}}; + IrisComplex complex = createComplex(4, interpolators, generators); + + for (int attempt = 0; attempt < 2; attempt++) { + try { + interpolatedHeight(complex, 2D, 2D); + fail("A non-finite corner must fail the column"); + } catch (InvocationTargetException expected) { + assertTrue(expected.getCause() instanceof IllegalStateException); + } + } + assertEquals(2, interpolator.nanCalls); + IrisComplex reference = createComplex(4, interpolators, generators); + assertEquals(Double.doubleToRawLongBits(expectedHeight(reference, interpolators, generators, 0D, 0D)), + Double.doubleToRawLongBits(interpolatedHeight(complex, 0D, 0D))); + } + + private static double coordinate(SplittableRandom random) { + int base = random.nextInt(-80, 80); + return switch (random.nextInt(4)) { + case 0 -> base; + case 1 -> base + random.nextInt(4) * 0.25D; + case 2 -> base + random.nextDouble(); + default -> base * 1031 + random.nextInt(-3, 3); + }; + } + + private static double expectedHeight( + IrisComplex complex, + IrisInterpolator[] interpolators, + IrisGenerator[][] generators, + double x, + double z + ) throws Exception { + Method interpolate = IrisComplex.class.getDeclaredMethod("interpolateGenerators", Engine.class, + IrisInterpolator.class, int.class, IrisGenerator[].class, double.class, double.class, long.class); + interpolate.setAccessible(true); + double height = 0; + for (int group = 0; group < interpolators.length; group++) { + height += (double) interpolate.invoke(complex, null, interpolators[group], group, generators[group], x, z, 31L); + } + return height; + } + + private static double interpolatedHeight(IrisComplex complex, double x, double z) throws Exception { + Method method = IrisComplex.class.getDeclaredMethod("getInterpolatedHeight", Engine.class, + double.class, double.class, long.class); + method.setAccessible(true); + return (double) method.invoke(complex, null, x, z, 31L); + } + + private static IrisComplex createComplex(int step, IrisInterpolator[] interpolators, IrisGenerator[][] generators) throws Exception { + IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + setField(complex, "biomeBoundsSamplingStep", step); + setField(complex, "generatorBounds", new HashMap<>()); + setField(complex, "biomeBuffetGenerators", new HashMap<>()); + setField(complex, "frozenInterpolators", interpolators); + setField(complex, "frozenGenerators", generators); + Class cacheClass = Class.forName("art.arcane.iris.generation.runtime.IrisComplex$GridBoundsCache"); + Constructor cacheConstructor = cacheClass.getDeclaredConstructor(); + cacheConstructor.setAccessible(true); + setField(complex, "gridBoundsCache", ThreadLocal.withInitial(() -> newCache(cacheConstructor))); + return complex; + } + + private static Object newCache(Constructor constructor) { + try { + return constructor.newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + 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); + } + + private static final class PatchInterpolator extends IrisInterpolator { + private final int salt; + private final double scale; + + private PatchInterpolator(int salt, double scale) { + this.salt = salt; + this.scale = scale; + } + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + long cell = Math.floorDiv((long) x, 24L) * 73_856_093L ^ Math.floorDiv((long) z, 24L) * 19_349_663L ^ salt * 83_492_791L; + if (Math.floorMod(cell, 3L) != 0L) { + return new NoiseBounds(0D, 0D); + } + double low = (x * 0.0625D - z * 0.03125D) * scale; + return new NoiseBounds(low, low + 3.75D * scale + Math.floorMod(cell, 5L)); + } + } + + private static final class ZeroFreeInterpolator extends IrisInterpolator { + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + return new NoiseBounds(x * 0.01D - 1D, z * 0.01D + 3D); + } + } + + private static final class ZeroInterpolator extends IrisInterpolator { + private final boolean negative; + + private ZeroInterpolator(boolean negative) { + this.negative = negative; + } + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + return negative ? new NoiseBounds(-0D, 0D) : new NoiseBounds(0D, -0D); + } + } + + private static final class CountingInterpolator extends IrisInterpolator { + private int calls; + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + calls++; + return new NoiseBounds(x * 0.001D, x * 0.001D + 2D); + } + } + + private static final class NaNCornerInterpolator extends IrisInterpolator { + private int nanCalls; + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + if (x == 4D && z == 4D) { + nanCalls++; + return new NoiseBounds(Double.NaN, 1D); + } + return new NoiseBounds(0.25D, 0.5D); + } + } + + private static final class ReentrantGenerator extends IrisGenerator { + private IrisComplex target; + private boolean nested; + + @Override + public double getHeight(double x, double z, long seed) { + if (nested) { + return Math.cos(x * 0.13D) * 0.5D; + } + nested = true; + try { + return (interpolatedHeight(target, x + 4099D, z - 2053D) + interpolatedHeight(target, x - 8191D, z + 1031D)) * 0.001D; + } catch (Exception e) { + throw new IllegalStateException(e); + } finally { + nested = false; + } + } + } + + private static final class WaveGenerator extends IrisGenerator { + private final double amplitude; + private int invocations; + + private WaveGenerator(double amplitude) { + this.amplitude = amplitude; + } + + @Override + public double getHeight(double x, double z, long seed) { + invocations++; + return Math.sin(x * 0.37D + seed) * Math.cos(z * 0.21D) * amplitude; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexContributingGroupsTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexContributingGroupsTest.java new file mode 100644 index 000000000..c94268a1f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexContributingGroupsTest.java @@ -0,0 +1,85 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink; +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisInterpolator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.interpolation.InterpolationMethod; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +public class IrisComplexContributingGroupsTest { + @Test + public void groupsWithZeroBoundsInEveryBaseBiomeAreDropped() { + IrisComplex.GeneratorGroup flat = group(InterpolationMethod.BILINEAR_STARCAST_6, 7D, "default"); + IrisComplex.GeneratorGroup hills = group(InterpolationMethod.BILINEAR_STARCAST_9, 12D, "hills", "cliffs"); + IrisComplex.GeneratorGroup shoreOnly = group(InterpolationMethod.BILINEAR_STARCAST_3, 30D, "ocean"); + IrisBiome plains = biome(link("default", 0, 0), link("hills", -4, 12)); + IrisBiome desert = biome(link("default", 0, 0), link("cliffs", 0, 3)); + + IrisComplex.GeneratorGroup[] contributing = IrisComplex.contributingGroups( + new IrisComplex.GeneratorGroup[]{flat, hills, shoreOnly}, List.of(plains, desert)); + + assertEquals(1, contributing.length); + assertSame(hills, contributing[0]); + } + + @Test + public void aNegativeOnlyOrMaximumOnlyLinkKeepsItsGroup() { + IrisComplex.GeneratorGroup sinks = group(InterpolationMethod.BILINEAR, 16D, "sinks"); + IrisComplex.GeneratorGroup peaks = group(InterpolationMethod.STARCAST_12, 42D, "peaks"); + + IrisComplex.GeneratorGroup[] contributing = IrisComplex.contributingGroups( + new IrisComplex.GeneratorGroup[]{sinks, peaks}, + List.of(biome(link("sinks", -6, 0)), biome(link("peaks", 0, 9)))); + + assertEquals(2, contributing.length); + assertSame(sinks, contributing[0]); + assertSame(peaks, contributing[1]); + } + + @Test + public void linksThatCancelAcrossGeneratorsSumToZeroBoundsAndAreDropped() { + IrisComplex.GeneratorGroup pair = group(InterpolationMethod.BILINEAR_STARCAST_9, 52D, "rise", "fall"); + + IrisComplex.GeneratorGroup[] contributing = IrisComplex.contributingGroups( + new IrisComplex.GeneratorGroup[]{pair}, + List.of(biome(link("rise", -5, 5), link("fall", 5, -5)))); + + assertEquals(0, contributing.length); + } + + @Test + public void noBaseBiomesDropsEveryGroup() { + IrisComplex.GeneratorGroup hills = group(InterpolationMethod.BILINEAR_STARCAST_9, 12D, "hills"); + + assertEquals(0, IrisComplex.contributingGroups(new IrisComplex.GeneratorGroup[]{hills}, List.of()).length); + } + + private static IrisComplex.GeneratorGroup group(InterpolationMethod function, double scale, String... keys) { + IrisInterpolator interpolator = new IrisInterpolator().setFunction(function).setHorizontalScale(scale); + IrisGenerator[] generators = new IrisGenerator[keys.length]; + for (int index = 0; index < keys.length; index++) { + IrisGenerator generator = new IrisGenerator(); + generator.setLoadKey(keys[index]); + generator.setInterpolator(interpolator); + generators[index] = generator; + } + return new IrisComplex.GeneratorGroup(interpolator, generators); + } + + private static IrisBiome biome(IrisBiomeGeneratorLink... links) { + IrisBiome biome = new IrisBiome(); + biome.setGenerators(new KList<>(List.of(links))); + return biome; + } + + private static IrisBiomeGeneratorLink link(String generator, int min, int max) { + return new IrisBiomeGeneratorLink().setGenerator(generator).setMin(min).setMax(max); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexShoreBandTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexShoreBandTest.java new file mode 100644 index 000000000..fb8c9db89 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexShoreBandTest.java @@ -0,0 +1,207 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.OffsetNoiseGenerator; +import art.arcane.volmlib.util.noise.SimplexNoise; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.Test; + +import java.util.SplittableRandom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.withSettings; + +public class IrisComplexShoreBandTest { + private static final double[] SHORE_EXTREMES = {0D, 1.2D, 3.2D, 2.5D, 7.75D, -1.5D, 4D, 0.0001D, 63.9D}; + + @Test + public void shoreHeightGeneratorIsAnIdentityPostSimplexSignature() { + for (int sample = 0; sample < 8; sample++) { + CNG generator = region(sample).getShoreHeightGenerator(); + assertTrue(generator.getGenerator() instanceof OffsetNoiseGenerator); + assertTrue(((OffsetNoiseGenerator) generator.getGenerator()).getBase() instanceof SimplexNoise); + assertEquals(1D, generator.getOpacity(), 0D); + assertTrue(generator.getChildren() == null || generator.getChildren().isEmpty()); + assertEquals(1D, generator.getPower(), 0D); + assertEquals(0D, generator.getUp(), 0D); + assertEquals(0D, generator.getDown(), 0D); + assertEquals(1D, generator.getPatch(), 0D); + assertNull(generator.getCache()); + assertNull(generator.getCustomGenerator()); + } + } + + @Test + public void simplexAmplitudeBoundFitsInsideTheShoreNoiseRange() { + double kernelPeak = 0D; + for (int step = 0; step <= 2_000_000; step++) { + double radius = Math.sqrt(0.5D) * step / 2_000_000D; + double falloff = 0.5D - radius * radius; + kernelPeak = Math.max(kernelPeak, falloff * falloff * falloff * falloff * Math.sqrt(2D) * radius); + } + assertEquals(256D / 19683D, kernelPeak, 1e-9D); + double amplitude = 50D * 3D * 256D / 19683D; + assertTrue(0.5D + amplitude / 2D < IrisComplex.SHORE_NOISE_HIGHEST); + assertTrue(0.5D - amplitude / 2D > IrisComplex.SHORE_NOISE_LOWEST); + } + + @Test + public void shoreHeightNoiseStaysInsideTheProvenRange() { + SplittableRandom random = new SplittableRandom(1337L); + double lowest = Double.POSITIVE_INFINITY; + double highest = Double.NEGATIVE_INFINITY; + for (int sample = 0; sample < 4; sample++) { + CNG generator = region(sample).getShoreHeightGenerator(); + for (int point = 0; point < 250_000; point++) { + double scale = point % 2 == 0 ? 1D : 64D; + double noise = generator.noise(random.nextDouble(-1e6D, 1e6D) / scale, random.nextDouble(-1e6D, 1e6D) / scale); + lowest = Math.min(lowest, noise); + highest = Math.max(highest, noise); + } + } + assertTrue("lowest " + lowest, lowest >= IrisComplex.SHORE_NOISE_LOWEST); + assertTrue("highest " + highest, highest <= IrisComplex.SHORE_NOISE_HIGHEST); + } + + @Test + public void lazyShoreBandMatchesTheEagerBandTop() { + SplittableRandom random = new SplittableRandom(42L); + ProceduralStream slopes = ProceduralStream.ofDouble((x, z) -> 2D); + for (int sample = 0; sample < 400_000; sample++) { + IrisRegion region = region(random.nextInt(64)) + .setShoreHeightMin(SHORE_EXTREMES[random.nextInt(SHORE_EXTREMES.length)]) + .setShoreHeightMax(SHORE_EXTREMES[random.nextInt(SHORE_EXTREMES.length)]) + .setShoreHeightZoom(random.nextBoolean() ? 3.14D : 0.15D) + .setShoreMinimumWidth(random.nextInt(8) == 0 ? 2D : 0D); + double fluid = random.nextInt(4) == 0 ? 63D : random.nextInt(-64, 200); + double height = random.nextInt(3) == 0 + ? fluid + random.nextDouble(-3D, 9D) + : random.nextInt(-64, 320) + (random.nextBoolean() ? 0D : random.nextDouble()); + double x = random.nextInt(-30_000_000, 30_000_000); + double z = random.nextInt(-30_000_000, 30_000_000); + boolean eager = height > IrisComplex.shoreBandTop(height, region, x, z, fluid, slopes); + assertEquals(eager, IrisComplex.aboveShoreBand(height, region, x, z, fluid, slopes)); + } + } + + @Test + public void lazySurfaceBiomeMatchesTheEagerSelection() { + SplittableRandom random = new SplittableRandom(7L); + IrisBiome land = mock(IrisBiome.class, withSettings().stubOnly()); + IrisBiome sea = mock(IrisBiome.class, withSettings().stubOnly()); + IrisBiome shore = mock(IrisBiome.class, withSettings().stubOnly()); + IrisBiome[] bases = new IrisBiome[8]; + for (int flags = 0; flags < bases.length; flags++) { + IrisBiome base = mock(IrisBiome.class, withSettings().stubOnly()); + doReturn((flags & 1) != 0).when(base).isShore(); + doReturn((flags & 2) != 0).when(base).isLand(); + doReturn((flags & 4) != 0).when(base).isAquatic(); + bases[flags] = base; + } + ProceduralStream lands = constant(land); + ProceduralStream seas = constant(sea); + ProceduralStream shores = constant(shore); + ProceduralStream slopes = ProceduralStream.ofDouble((x, z) -> 4D); + for (int sample = 0; sample < 200_000; sample++) { + IrisRegion region = region(random.nextInt(16)) + .setShoreHeightMin(SHORE_EXTREMES[random.nextInt(SHORE_EXTREMES.length)]) + .setShoreHeightMax(SHORE_EXTREMES[random.nextInt(SHORE_EXTREMES.length)]) + .setShoreMinimumWidth(random.nextInt(8) == 0 ? 3D : 0D); + double fluid = 63D; + double height = random.nextBoolean() ? fluid + random.nextInt(-3, 8) : random.nextInt(-64, 320); + double x = random.nextInt(-100_000, 100_000); + double z = random.nextInt(-100_000, 100_000); + IrisBiome base = bases[random.nextInt(bases.length)]; + IrisBiome expected = eagerSurfaceBiome(height, base, region, x, z, fluid, lands, seas, shores, slopes); + assertSame(expected, IrisComplex.resolveSurfaceBiome(height, base, region, x, z, fluid, lands, seas, shores, slopes)); + } + } + + @Test + public void columnsOutsideTheShoreWindowNeverSampleTheShoreNoise() { + IrisRegion region = mock(IrisRegion.class); + doReturn(1.2D).when(region).getShoreHeightMin(); + doReturn(3.2D).when(region).getShoreHeightMax(); + doReturn(0D).when(region).getShoreMinimumWidth(); + doThrow(new AssertionError("The shore noise was sampled for a column outside the shore window")) + .when(region).getShoreHeight(anyDouble(), anyDouble()); + ProceduralStream slopes = ProceduralStream.ofDouble((x, z) -> 0D); + + assertTrue(IrisComplex.aboveShoreBand(68D, region, 3D, 4D, 63D, slopes)); + assertTrue(IrisComplex.aboveShoreBand(250D, region, 3D, 4D, 63D, slopes)); + assertFalse(IrisComplex.aboveShoreBand(63.2D, region, 3D, 4D, 63D, slopes)); + assertFalse(IrisComplex.aboveShoreBand(-20D, region, 3D, 4D, 63D, slopes)); + } + + @Test + public void equalShoreExtremesNeverSampleTheShoreNoise() { + IrisRegion region = mock(IrisRegion.class); + doReturn(2D).when(region).getShoreHeightMin(); + doReturn(2D).when(region).getShoreHeightMax(); + doReturn(0D).when(region).getShoreMinimumWidth(); + doThrow(new AssertionError("Equal shore extremes need no shore noise")) + .when(region).getShoreHeight(anyDouble(), anyDouble()); + ProceduralStream slopes = ProceduralStream.ofDouble((x, z) -> 0D); + + assertFalse(IrisComplex.aboveShoreBand(65D, region, 3D, 4D, 63D, slopes)); + assertTrue(IrisComplex.aboveShoreBand(65.0001D, region, 3D, 4D, 63D, slopes)); + } + + private static IrisRegion region(int variant) { + IrisRegion region = new IrisRegion(); + region.setName("Shore Region " + variant); + region.setLandBiomeZoom(1D + variant * 0.25D); + KList lands = new KList<>(); + for (int index = 0; index < variant % 5; index++) { + lands.add("land-" + index); + } + region.setLandBiomes(lands); + return region; + } + + private static IrisBiome eagerSurfaceBiome( + double height, + IrisBiome biome, + IrisRegion region, + double x, + double z, + double fluidHeight, + ProceduralStream landBiomes, + ProceduralStream seaBiomes, + ProceduralStream shoreBiomes, + ProceduralStream shoreSlopes + ) { + double shoreTop = IrisComplex.shoreBandTop(height, region, x, z, fluidHeight, shoreSlopes); + if (height >= fluidHeight - 1 && height <= shoreTop && !biome.isShore()) { + return shoreBiomes.get(x, z); + } + if (height > shoreTop && !biome.isLand()) { + return landBiomes.get(x, z); + } + if (height < fluidHeight && !biome.isAquatic()) { + return seaBiomes.get(x, z); + } + if (height == fluidHeight && !biome.isShore()) { + return shoreBiomes.get(x, z); + } + return biome; + } + + private static ProceduralStream constant(IrisBiome biome) { + @SuppressWarnings("unchecked") + ProceduralStream stream = mock(ProceduralStream.class, withSettings().stubOnly()); + doReturn(biome).when(stream).get(anyDouble(), anyDouble()); + return stream; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexSurfaceBiomeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexSurfaceBiomeTest.java index 67aeaa2e9..db4e716e8 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexSurfaceBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexSurfaceBiomeTest.java @@ -119,6 +119,8 @@ public class IrisComplexSurfaceBiomeTest { doReturn(false).when(base).isShore(); doReturn(false).when(base).isAquatic(); doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(1D).when(region).getShoreHeightMin(); + doReturn(5D).when(region).getShoreHeightMax(); IrisBiome resolved = IrisComplex.resolveSurfaceBiome( 68D, @@ -143,6 +145,8 @@ public class IrisComplexSurfaceBiomeTest { IrisRegion region = mock(IrisRegion.class); doReturn(false).when(base).isShore(); doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(1D).when(region).getShoreHeightMin(); + doReturn(5D).when(region).getShoreHeightMax(); IrisBiome resolved = IrisComplex.resolveSurfaceBiome( 63D, @@ -168,6 +172,8 @@ public class IrisComplexSurfaceBiomeTest { doReturn(false).when(aquatic).isShore(); doReturn(false).when(aquatic).isLand(); doReturn(1D).when(region).getShoreHeight(4D, 7D); + doReturn(0.5D).when(region).getShoreHeightMin(); + doReturn(1.5D).when(region).getShoreHeightMax(); IrisBiome resolved = IrisComplex.resolveSurfaceBiome( 66D, @@ -193,6 +199,8 @@ public class IrisComplexSurfaceBiomeTest { doReturn(false).when(base).isShore(); doReturn(false).when(base).isLand(); doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(1D).when(region).getShoreHeightMin(); + doReturn(5D).when(region).getShoreHeightMax(); doReturn(0D).when(region).getShoreMinimumWidth(); IrisBiome resolved = IrisComplex.resolveSurfaceBiome( @@ -218,6 +226,8 @@ public class IrisComplexSurfaceBiomeTest { IrisRegion region = mock(IrisRegion.class); doReturn(false).when(base).isShore(); doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(1D).when(region).getShoreHeightMin(); + doReturn(5D).when(region).getShoreHeightMax(); doReturn(3D).when(region).getShoreMinimumWidth(); IrisBiome resolved = IrisComplex.resolveSurfaceBiome( @@ -244,6 +254,8 @@ public class IrisComplexSurfaceBiomeTest { doReturn(false).when(base).isShore(); doReturn(false).when(base).isLand(); doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(1D).when(region).getShoreHeightMin(); + doReturn(5D).when(region).getShoreHeightMax(); doReturn(3D).when(region).getShoreMinimumWidth(); IrisBiome resolved = IrisComplex.resolveSurfaceBiome( @@ -272,6 +284,8 @@ public class IrisComplexSurfaceBiomeTest { doReturn(false).when(base).isLand(); doReturn(false).when(base).isAquatic(); doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(1D).when(region).getShoreHeightMin(); + doReturn(5D).when(region).getShoreHeightMax(); doReturn(3D).when(region).getShoreMinimumWidth(); assertSame(sea, IrisComplex.resolveSurfaceBiome( @@ -304,6 +318,8 @@ public class IrisComplexSurfaceBiomeTest { public void shoreBandTopTradesHorizontalWidthForHeightOnSlopes() { IrisRegion region = mock(IrisRegion.class); doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(1D).when(region).getShoreHeightMin(); + doReturn(5D).when(region).getShoreHeightMax(); doReturn(3D).when(region).getShoreMinimumWidth(); assertEquals(66D, IrisComplex.shoreBandTop(67D, region, 12D, 18D, 63D, constantSlope(0D)), 0D); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorColumnParityTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorColumnParityTest.java index 0534903c4..846b43c13 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorColumnParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorColumnParityTest.java @@ -175,7 +175,6 @@ public class IrisTerrainNormalActuatorColumnParityTest { when(complex.terrainColumn(eq(X), eq(Z), hydrology == null ? isNull() : any(HydrologyColumnSample.class))) .thenReturn(terrainColumn); when(complex.naturalTerrainColumn(X, Z)).thenReturn(terrainColumn); - when(complex.resolveSurfaceFluid(anyDouble(), anyDouble())).thenReturn(fluid); when(context.getRoundedHeight(0, 0)).thenReturn(he); when(context.getBiome()).thenReturn(new ChunkedDataCache<>( ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> biome)), X, Z, false)); @@ -183,6 +182,8 @@ public class IrisTerrainNormalActuatorColumnParityTest { ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), X, Z, false)); when(context.getRock()).thenReturn(new ChunkedDataCache<>( ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), X, Z, false)); + when(context.getFluid()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> fluid, Interpolated.of(value -> 0D, value -> fluid)), X, Z, false)); when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), anyInt(), anyInt(), eq(data), eq(complex))).thenAnswer(invocation -> layers("layer", layerCount)); when(biome.generateSeaLayers(anyDouble(), anyDouble(), any(RNG.class), anyInt(), eq(data))) diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java index 3b9577f2b..d9e5cc60c 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java @@ -259,6 +259,8 @@ public class IrisTerrainNormalActuatorHydrologyTest { ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), 11, -4, false)); when(context.getRock()).thenReturn(new ChunkedDataCache<>( ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), 11, -4, false)); + when(context.getFluid()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> null, Interpolated.of(value -> 0D, value -> null)), 11, -4, false)); when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>(List.of(grass))); Hunk output = Hunk.newArrayHunk(1, 64, 1); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java index e1a350da8..6b1cdb959 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java @@ -155,6 +155,8 @@ public class IrisTerrainNormalActuatorStrataCutTest { ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), 11, -4, false)); when(context.getRock()).thenReturn(new ChunkedDataCache<>( ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), 11, -4, false)); + when(context.getFluid()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> null, Interpolated.of(value -> 0D, value -> null)), 11, -4, false)); when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>(List.of(grass, dirt))); when(biome.generateCeilingLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class),