Merge terrain-first hydrology into master

Surface rivers v2: valley-first eroded channels, spring pools, standing
pools, tributaries, bed padding, ring-ordered feature search, schema v2
hydrology configuration and the transect probe.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-02 03:00:10 -04:00
commit 665b201e41
330 files changed
+60150 -606

No files matched your search

@@ -23,6 +23,8 @@ import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.render.IrisRenderer;
import art.arcane.iris.engine.framework.render.RenderType;
import art.arcane.iris.engine.hydrology.HydrologyCandidateKind;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
@@ -69,6 +71,7 @@ import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@@ -484,11 +487,52 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
}
private void renderLegend(Graphics2D canvas) {
if (currentType == RenderType.HEIGHT) {
if (currentType == RenderType.RIVER) {
renderRiverLegend(canvas);
} else if (currentType == RenderType.HEIGHT) {
renderHeightLegend(canvas);
}
}
private void renderRiverLegend(Graphics2D canvas) {
HydrologyFeatureType[] types = HydrologyFeatureType.values();
HydrologyCandidateKind[] candidateKinds = HydrologyCandidateKind.values();
int lineHeight = 18;
int width = 188;
int height = (types.length + candidateKinds.length + 1) * lineHeight + CARD_PADDING * 2;
int x = getWidth() - width - CARD_PADDING;
int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING;
drawCardBackground(canvas, x, y, width, height);
canvas.setFont(BODY_FONT);
int headwaterY = y + CARD_PADDING;
canvas.setColor(new Color(IrisRenderer.headwaterColor()));
canvas.fillRoundRect(x + CARD_PADDING, headwaterY + 2, 12, 12, 4, 4);
canvas.setColor(new Color(IrisRenderer.headwaterDirectionColor()));
canvas.drawLine(x + CARD_PADDING + 3, headwaterY + 8, x + CARD_PADDING + 9, headwaterY + 8);
canvas.drawLine(x + CARD_PADDING + 9, headwaterY + 8, x + CARD_PADDING + 6, headwaterY + 5);
canvas.drawLine(x + CARD_PADDING + 9, headwaterY + 8, x + CARD_PADDING + 6, headwaterY + 11);
canvas.setColor(TEXT_SECONDARY);
canvas.drawString("headwater / source flow", x + CARD_PADDING + 20, headwaterY + 13);
for (int index = 0; index < types.length; index++) {
HydrologyFeatureType type = types[index];
int rowY = y + CARD_PADDING + (index + 1) * lineHeight;
canvas.setColor(new Color(IrisRenderer.hydrologyFeatureColor(type)));
canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4);
canvas.setColor(TEXT_SECONDARY);
String label = type.name().toLowerCase(Locale.ROOT).replace('_', ' ');
canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13);
}
for (int index = 0; index < candidateKinds.length; index++) {
HydrologyCandidateKind kind = candidateKinds[index];
int rowY = y + CARD_PADDING + (types.length + index + 1) * lineHeight;
canvas.setColor(new Color(IrisRenderer.hydrologyDiagnosticColor(kind)));
canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4);
canvas.setColor(TEXT_SECONDARY);
String label = "projected " + kind.name().toLowerCase(Locale.ROOT).replace('_', ' ');
canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13);
}
}
private void renderHeightLegend(Graphics2D canvas) {
int width = 244;
int height = 54;
@@ -1037,6 +1081,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
case BIOME_SEA -> DesktopUiMessages.VISION_MODE_BIOME_SEA;
case REGION -> DesktopUiMessages.VISION_MODE_REGION;
case CAVE_LAND -> DesktopUiMessages.VISION_MODE_CAVE_LAND;
case RIVER -> DesktopUiMessages.VISION_MODE_RIVER;
case HEIGHT -> DesktopUiMessages.VISION_MODE_HEIGHT;
case OBJECT_LOAD -> DesktopUiMessages.VISION_MODE_OBJECT_LOAD;
case DECORATOR_LOAD -> DesktopUiMessages.VISION_MODE_DECORATOR_LOAD;
@@ -275,7 +275,8 @@ final class VisionRenderController implements AutoCloseable {
if (!isCurrent(work)) {
return;
}
while (work.inFlight() < renderWorkerCount) {
int admissionLimit = work.frame().spec().type() == RenderType.RIVER ? 1 : renderWorkerCount;
while (work.inFlight() < admissionLimit) {
VisibleTile tile = work.nextMissing();
if (tile == null) {
return;
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.engine.IrisEngineMantle;
import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
@@ -253,7 +254,9 @@ public final class BukkitWorldConfiguration {
if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) {
return true;
}
for (String directory : new String[]{"region", "entities", "poi", "mantle"}) {
for (String directory : new String[]{
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
}) {
if (hasEntries(worldRoot.resolve(directory))) {
return true;
}
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.engine.IrisEngineMantle;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
@@ -37,7 +38,9 @@ import java.util.stream.Stream;
public final class HuskWorldQuarantine {
private static final String IRIS_NAMESPACE = "iris";
private static final Pattern MANAGED_KEY = Pattern.compile("[a-z0-9_-]+");
private static final String[] CHUNK_DATA_DIRECTORIES = {"region", "entities", "poi", "mantle"};
private static final String[] CHUNK_DATA_DIRECTORIES = {
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
};
private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
private static final String APPLE_DOUBLE_PREFIX = "._";
private static final DateTimeFormatter STAMP = DateTimeFormatter
@@ -52,6 +52,7 @@ public final class DesktopUiMessages {
public static final TextKey VISION_MODE_BIOME_SEA = TextKey.of("iris.desktop.vision.mode.biome_sea", "Biome sea");
public static final TextKey VISION_MODE_REGION = TextKey.of("iris.desktop.vision.mode.region", "Region");
public static final TextKey VISION_MODE_CAVE_LAND = TextKey.of("iris.desktop.vision.mode.cave_land", "Cave land");
public static final TextKey VISION_MODE_RIVER = TextKey.of("iris.desktop.vision.mode.river", "River network");
public static final TextKey VISION_MODE_HEIGHT = TextKey.of("iris.desktop.vision.mode.height", "Height");
public static final TextKey VISION_MODE_OBJECT_LOAD = TextKey.of("iris.desktop.vision.mode.object_load", "Object load");
public static final TextKey VISION_MODE_DECORATOR_LOAD = TextKey.of("iris.desktop.vision.mode.decorator_load", "Decorator load");
@@ -171,7 +172,7 @@ public final class DesktopUiMessages {
VISION_HELP_RESET_ZOOM, VISION_HELP_CYCLE_MODE, VISION_HELP_FPS,
VISION_HELP_GRID, VISION_HELP_BIOME, VISION_HELP_TELEPORT, VISION_HELP_EDITOR, VISION_OPENED,
VISION_TELEPORTING, VISION_MODE_BIOME, VISION_MODE_BIOME_LAND, VISION_MODE_BIOME_SEA,
VISION_MODE_REGION, VISION_MODE_CAVE_LAND, VISION_MODE_HEIGHT, VISION_MODE_OBJECT_LOAD,
VISION_MODE_REGION, VISION_MODE_CAVE_LAND, VISION_MODE_RIVER, VISION_MODE_HEIGHT, VISION_MODE_OBJECT_LOAD,
VISION_MODE_DECORATOR_LOAD, VISION_MODE_CONTINENT, VISION_MODE_LAYER_LOAD, NOISE_TITLE,
NOISE_TITLE_GENERATOR, NOISE_SEARCH, NOISE_STATUS, NOISE_CATEGORY_CUSTOM,
NOISE_CATEGORY_PACK_GENERATORS, NOISE_CATEGORY_SIMPLEX, NOISE_CATEGORY_PERLIN,
File diff suppressed because it is too large. Load diff
@@ -84,6 +84,10 @@ public final class PackValidator {
packFolder, dimensionFiles, validateLiveRegistries);
addDistinct(blockingErrors, imageMaps.errors());
addDistinct(warnings, imageMaps.warnings());
PackHydrologyValidator.Validation hydrologyValidation =
PackHydrologyValidator.validate(packFolder, dimensionFiles);
addDistinct(blockingErrors, hydrologyValidation.errors());
addDistinct(warnings, hydrologyValidation.warnings());
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder);
addDistinct(blockingErrors, lootIssues.errors());
@@ -481,8 +481,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
listener.onChunkFailed(x, z);
} else {
listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z);
listener.onChunkCleaned(x, z);
cleanupMantleChunksCoveredBy(x, z, listener);
onChunkCompleted(x, z, chunk);
success = true;
}
@@ -692,11 +691,11 @@ public class AsyncPregenMethod implements PregeneratorMethod {
}
}
private void cleanupMantleChunk(int x, int z) {
private void cleanupMantleChunksCoveredBy(int x, int z, PregenListener listener) {
Engine engine = resolveMetricsEngine();
if (engine != null) {
try {
engine.getMantle().forceCleanupChunk(x, z);
engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
} catch (Throwable ignored) {
}
}
@@ -92,6 +92,7 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
@Accessors(fluent = true, chain = true)
public class IrisCreator {
private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
private static final long INITIAL_SPAWN_TIMEOUT_SECONDS = 600L;
private static final long WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS = 60L;
private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L;
@@ -619,7 +620,7 @@ public class IrisCreator {
generator.getInitialSpawnReady(),
"Initial spawn preparation future");
try {
initialSpawnReady.get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
initialSpawnReady.get(INITIAL_SPAWN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException failure) {
throw new TimeoutException("Initial spawn preparation timed out for world \""
+ worldName + "\".");
@@ -22,14 +22,27 @@ import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyNaturalSample;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntimeContext;
import art.arcane.iris.engine.image.IrisImageMapRuntime;
import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationPart;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisInterpolator;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisDeepFluidConfig;
import art.arcane.iris.engine.object.IrisSurfacePoolConfig;
import art.arcane.iris.engine.object.IrisHydrology;
import art.arcane.iris.engine.object.IrisRiverHydrology;
import art.arcane.iris.engine.object.IrisRiverProfile;
import art.arcane.iris.engine.object.IrisShapedGeneratorStyle;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisLogging;
@@ -58,6 +71,7 @@ import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -66,8 +80,8 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiFunction;
@Data
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "imageMapRuntime"})
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
public class IrisComplex implements DataProvider {
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L);
@@ -109,14 +123,20 @@ public class IrisComplex implements DataProvider {
private ProceduralStream<IrisBiome> shoreBiomeStream;
private ProceduralStream<IrisBiome> baseBiomeStream;
private ProceduralStream<UUID> baseBiomeIDStream;
private ProceduralStream<IrisBiome> naturalTrueBiomeStream;
private ProceduralStream<IrisBiome> trueBiomeStream;
private ProceduralStream<PlatformBiome> trueBiomeDerivativeStream;
private ProceduralStream<Double> naturalHeightStream;
private ProceduralStream<Double> heightStream;
private ProceduralStream<Integer> roundedHeighteightStream;
private ProceduralStream<Double> maxHeightStream;
private ProceduralStream<Double> overlayStream;
private ProceduralStream<Double> heightFluidStream;
private ProceduralStream<Double> slopeStream;
private ProceduralStream<Double> riverDistanceStream;
private ProceduralStream<Double> riverFlowStream;
private ProceduralStream<Double> riverCarveWeightStream;
private ProceduralStream<Double> riverWaterSurfaceStream;
private ProceduralStream<Integer> topSurfaceStream;
private ProceduralStream<IrisDecorator> terrainSurfaceDecoration;
private ProceduralStream<IrisDecorator> terrainCeilingDecoration;
@@ -127,10 +147,12 @@ public class IrisComplex implements DataProvider {
private ProceduralStream<IrisDecorator> shoreSurfaceDecoration;
private ProceduralStream<PlatformBlockState> rockStream;
private ProceduralStream<PlatformBlockState> fluidStream;
private Map<String, ProceduralStream<PlatformBlockState>> hydrologyFluidStreams;
private IrisBiome focusBiome;
private IrisRegion focusRegion;
private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds;
private Set<IrisBiome> generatorBiomes;
private IrisHydrologyRuntime hydrologyRuntime;
private transient IrisImageMapRuntime imageMapRuntime;
// Copy-on-write: reads happen per column on every burst thread; the synchronizedMap
// monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map
@@ -166,7 +188,7 @@ public class IrisComplex implements DataProvider {
//@builder
if (focusRegion != null) {
prepareInferredBiomes(focusRegion, preparedRegions);
focusRegion.getAllBiomes(this).forEach(this::registerGenerators);
focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators);
} else {
engine.getDimension().getRegions().forEach(regionKey -> {
IrisRegion region = data.getRegionLoader().load(regionKey);
@@ -174,11 +196,11 @@ public class IrisComplex implements DataProvider {
return;
}
prepareInferredBiomes(region, preparedRegions);
region.getAllBiomes(this).forEach(this::registerGenerators);
region.getNaturalBiomes(this).forEach(this::registerGenerators);
});
for (IrisRegion region : imageMapRuntime.getMappedRegions()) {
prepareInferredBiomes(region, preparedRegions);
region.getAllBiomes(this).forEach(this::registerGenerators);
region.getNaturalBiomes(this).forEach(this::registerGenerators);
}
imageMapRuntime.getMappedBiomes().forEach(this::registerGenerators);
}
@@ -211,6 +233,7 @@ public class IrisComplex implements DataProvider {
.select(engine.getDimension().getRockPalette().getBlockData(data));
fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream()
.select(engine.getDimension().getFluidPalette().getBlockData(data));
hydrologyFluidStreams = createHydrologyFluidStreams(engine.getDimension().getHydrology());
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
.zoom(engine.getDimension().getRegionZoom());
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE);
@@ -263,28 +286,59 @@ public class IrisComplex implements DataProvider {
return mapped == null ? biome : mapped;
})
.cache2D("imageMappedBaseBiomeStream", engine, cacheSize);
heightStream = ProceduralStream.of((x, z) -> {
double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight());
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
}, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize);
slopeStream = heightStream.slope(3)
.cache2DDouble("slopeStream", engine, cacheSize);
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
naturalHeightStream = ProceduralStream.of(
(x, z) -> sampleNaturalTerrainHeight(engine, x, z),
Interpolated.DOUBLE
).cache2DDouble("naturalHeightStream", engine, cacheSize);
naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
.cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream
.convertAware2D((h, x, z) -> {
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
return mapped == null
? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight)
: mapped;
})
.cache2D("trueBiomeStream", engine, cacheSize);
.cache2D("naturalTrueBiomeStream", engine, cacheSize);
IrisHydrology configuredHydrology = engine.getDimension().getHydrology();
if (hydrologyActive(configuredHydrology)) {
hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(
engine.getSeedManager().getBodies(),
engine.getHeight(),
engine.getDimension(),
data,
(x, z, naturalHeight) -> sampleHydrologyNatural(engine, x, z, naturalHeight),
(x, z) -> naturalHeightStream.getDouble(x, z),
this::sampleNaturalOcean,
footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint)
));
}
heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z))
.cache2DDouble("heightStream", engine, cacheSize);
roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.round();
slopeStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.slope(3).cache2DDouble("slopeStream", engine, cacheSize);
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
.convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z))
.cache2D("trueBiomeStream", engine, cacheSize);
trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize);
heightFluidStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize);
riverDistanceStream = ProceduralStream.ofDouble(this::resolveHydrologyDistance)
.cache2DDouble("riverDistanceStream", engine, cacheSize);
riverFlowStream = ProceduralStream.ofDouble(this::resolveHydrologyFlow)
.cache2DDouble("riverFlowStream", engine, cacheSize);
riverCarveWeightStream = ProceduralStream.ofDouble(this::resolveHydrologyCarveWeight)
.cache2DDouble("riverCarveWeightStream", engine, cacheSize);
riverWaterSurfaceStream = ProceduralStream.ofDouble(this::resolveHydrologyFluidSurface)
.cache2DDouble("riverWaterSurfaceStream", engine, cacheSize);
heightFluidStream = ProceduralStream.ofDouble((x, z) -> Math.max(
heightStream.get(x, z),
riverWaterSurfaceStream.get(x, z)
))
.cache2DDouble("heightFluidStream", engine, cacheSize);
maxHeightStream = ProceduralStream.ofDouble((x, z) -> height);
terrainSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize);
@@ -310,6 +364,320 @@ public class IrisComplex implements DataProvider {
//@done
}
static InferredType resolveNaturalInferredType(
ProceduralStream<InferredType> bridgeStream,
IrisBiome focusBiome,
double x,
double z
) {
if (focusBiome != null) {
return focusBiome.getInferredType();
}
return bridgeStream.get(x, z);
}
private IrisHydrologyNaturalSample sampleHydrologyNatural(
Engine engine,
int x,
int z,
double naturalHeight
) {
InferredType inferredType = resolveNaturalInferredType(bridgeStream, focusBiome, x, z);
IrisRegion region = regionStream.get(x, z);
IrisBiome biome = focusBiome == null
? sampleNaturalBiome(inferredType, region, naturalHeight, x, z)
: focusBiome;
return new IrisHydrologyNaturalSample(
naturalHeight,
inferredType == InferredType.SEA,
biome,
region
);
}
private boolean sampleNaturalOcean(int x, int z) {
return resolveNaturalInferredType(bridgeStream, focusBiome, x, z) == InferredType.SEA;
}
private double sampleNaturalTerrainHeight(Engine engine, double x, double z) {
double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight());
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
}
static double calculateNaturalSlope(double naturalHeight, double easternHeight, double southernHeight) {
double deltaX = easternHeight - naturalHeight;
double deltaZ = southernHeight - naturalHeight;
return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ);
}
private IrisBiome sampleNaturalBiome(
InferredType inferredType,
IrisRegion region,
double naturalHeight,
double x,
double z
) {
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
if (mapped != null) {
return mapped;
}
IrisBiome baseBiome = implode(sampleInferredBiome(region, inferredType, x, z), x, z);
IrisBiome resolved = resolveNaturalSurfaceBiome(naturalHeight, baseBiome, region, x, z);
return resolved == baseBiome ? baseBiome : implode(resolved, x, z);
}
private IrisBiome resolveNaturalSurfaceBiome(
double height,
IrisBiome biome,
IrisRegion region,
double x,
double z
) {
if (biome == null || region == null) {
return biome;
}
double shoreHeight = region.getShoreHeight(x, z);
if (height >= fluidHeight - 1 && height <= fluidHeight + shoreHeight && !biome.isShore()) {
return sampleInferredBiome(region, InferredType.SHORE, x, z);
}
if (height > fluidHeight + shoreHeight && !biome.isLand()) {
return sampleInferredBiome(region, InferredType.LAND, x, z);
}
if (height < fluidHeight && !biome.isAquatic()) {
return sampleInferredBiome(region, InferredType.SEA, x, z);
}
if (height == fluidHeight && !biome.isShore()) {
return sampleInferredBiome(region, InferredType.SHORE, x, z);
}
return biome;
}
private IrisBiome sampleInferredBiome(
IrisRegion region,
InferredType inferredType,
double x,
double z
) {
return createInferredBiomeStream(region, inferredType).get(x, z);
}
private ProceduralStream<IrisBiome> createInferredBiomeStream(
IrisRegion region,
InferredType inferredType
) {
return preparedInferredBiomeStream(inferredBiomeStreams, region, inferredType);
}
private ProceduralStream<IrisBiome> compileInferredBiomeStream(
Engine engine,
IrisRegion region,
InferredType inferredType,
IrisBiome emptyBiome
) {
return switch (inferredType) {
case CAVE -> engine.getDimension().getCaveBiomeStyle()
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome);
case LAND -> engine.getDimension().getLandBiomeStyle()
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getLandZoom())
.zoom(region.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(region.getLandBiomes(), InferredType.LAND));
case SEA -> engine.getDimension().getSeaBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getSeaZoom())
.zoom(region.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA));
case SHORE -> engine.getDimension().getShoreBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE));
};
}
private static boolean hydrologyActive(IrisHydrology hydrology) {
if (hydrology == null) {
return false;
}
IrisRiverHydrology rivers = hydrology.getRivers();
if (rivers != null && rivers.isEnabled()) {
return true;
}
for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) {
if (deepFluid != null
&& deepFluid.getDensity() > 0D
&& (deepFluid.isContainedPools() || deepFluid.isShortChannels())) {
return true;
}
}
return false;
}
private Map<String, ProceduralStream<PlatformBlockState>> createHydrologyFluidStreams(IrisHydrology hydrology) {
if (hydrology == null) {
return Map.of();
}
LinkedHashMap<String, ProceduralStream<PlatformBlockState>> streams = new LinkedHashMap<>();
IrisRiverHydrology rivers = Objects.requireNonNull(hydrology.getRivers(), "hydrology.rivers");
int streamIndex = 0;
for (IrisRiverProfile profile : rivers.getProfiles()) {
if (profile == null) {
throw new IllegalArgumentException("hydrology.rivers.profiles cannot contain null entries");
}
String profileKey = requireHydrologyKey(profile.getId(), "hydrology.rivers.profiles[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
profile.getFluidPalette(),
rng.nextParallelRNG(7900 + streamIndex++),
"hydrology river profile " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) {
if (deepFluid == null) {
throw new IllegalArgumentException("hydrology.deepFluids cannot contain null entries");
}
String profileKey = requireHydrologyKey(deepFluid.getId(), "hydrology.deepFluids[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
deepFluid.getFluidPalette(),
rng.nextParallelRNG(8900 + streamIndex++),
"hydrology deep-fluid profile " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
for (IrisSurfacePoolConfig pool : hydrology.getSurfacePools()) {
if (pool == null) {
throw new IllegalArgumentException("hydrology.surfacePools cannot contain null entries");
}
String profileKey = requireHydrologyKey(pool.getId(), "hydrology.surfacePools[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
pool.getFluidPalette(),
rng.nextParallelRNG(9900 + streamIndex++),
"hydrology surface pool " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
return Collections.unmodifiableMap(streams);
}
private static String requireHydrologyKey(String key, String path) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException(path + " must not be blank");
}
return key.trim();
}
private ProceduralStream<PlatformBlockState> configuredFluidStream(
IrisMaterialPalette palette,
RNG fluidRng,
String configurationName
) {
Objects.requireNonNull(palette, configurationName + " fluidPalette must be configured");
KList<PlatformBlockState> blocks = palette.getBlockData(data);
if (blocks.isEmpty()) {
throw new IllegalArgumentException(
configurationName + " fluidPalette must resolve at least one fluid block");
}
for (PlatformBlockState block : blocks) {
if (block == null || !block.isFluid()) {
throw new IllegalArgumentException(
configurationName + " fluidPalette may contain only fluid blocks");
}
}
return palette.getLayerGenerator(fluidRng, data).stream().select(blocks);
}
public PlatformBlockState resolveHydrologyFluid(String profileKey, double x, double z) {
String key = Objects.requireNonNull(profileKey, "profileKey").trim();
ProceduralStream<PlatformBlockState> stream = hydrologyFluidStreams.get(key);
if (stream == null) {
throw new IllegalArgumentException("Unknown hydrology fluid profile: " + key);
}
return stream.get(x, z);
}
public PlatformBlockState resolveSurfaceFluid(double x, double z) {
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
if (layer != null) {
return resolveHydrologyFluid(layer.profileKey(), x, z);
}
return fluidStream.get(x, z);
}
private double resolveHydrologyTerrainHeight(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? naturalHeightStream.get(x, z) : sample.terrainHeight();
}
private double resolveHydrologyDistance(double x, double z) {
HydrologyColumnLayer layer = surfaceLayer(x, z);
if (layer == null) {
return Double.MAX_VALUE;
}
if (layer.channel()) {
return 0D;
}
return layer.shore() ? 1D : 2D;
}
private double resolveHydrologyFlow(double x, double z) {
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
if (layer == null) {
return 0D;
}
return layer.feature().flowDeltaX() == 0 && layer.feature().flowDeltaZ() == 0 ? 0D : 1D;
}
private double resolveHydrologyCarveWeight(double x, double z) {
HydrologyColumnLayer layer = surfaceLayer(x, z);
if (layer == null) {
return 0D;
}
if (layer.channel()) {
return 1D;
}
return layer.shore() ? 0.75D : 0.5D;
}
private double resolveHydrologyFluidSurface(double x, double z) {
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
return layer == null ? fluidHeight : layer.fluidHeadY();
}
private HydrologyColumnSample hydrologySample(double x, double z) {
return hydrologyRuntime == null ? null : hydrologyRuntime.sample(x, z).orElse(null);
}
private HydrologyColumnLayer surfaceLayer(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? null : sample.primarySurfaceLayer().orElse(null);
}
private HydrologyColumnLayer surfaceFluidLayer(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? null : sample.primarySurfaceFluidLayer().orElse(null);
}
public boolean hasHydrologySurfaceFluid(int x, int z) {
return surfaceFluidLayer(x, z) != null;
}
public boolean hasHydrologyChannelOrShore(int x, int z) {
HydrologyColumnLayer layer = surfaceLayer(x, z);
return layer != null && (layer.channel() || layer.shore());
}
public ProceduralStream<IrisBiome> getBiomeStream(InferredType type) {
switch (type) {
case CAVE:
@@ -370,6 +738,47 @@ public class IrisComplex implements DataProvider {
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);
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
if (mapped != null && layer == null) {
return mapped;
}
if (layer != null) {
String biomeKey = hydrologySurfaceBiomeKey(sample);
IrisBiome biome = data.getBiomeLoader().load(biomeKey);
if (biome == null) {
throw new IllegalStateException("Hydrology biome does not exist: " + biomeKey);
}
if (layer.biomeKey() == null) {
return implode(biome.withInferredType(InferredType.LAND), x, z);
}
InferredType inferredType = layer.shore()
? InferredType.SHORE
: layer.connectedFluid() ? InferredType.SEA : InferredType.LAND;
return implode(biome.withInferredType(inferredType), x, z);
}
return fixBiomeType(
terrainHeight,
baseBiomeStream.get(x, z),
regionStream.get(x, z),
x,
z,
fluidHeight
);
}
static String hydrologySurfaceBiomeKey(HydrologyColumnSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null);
if (layer == null) {
return sample.parentBiomeKey();
}
String biomeKey = layer.biomeKey();
return biomeKey == null ? sample.parentBiomeKey() : biomeKey;
}
private IrisBiome fixBiomeType(Double height, IrisBiome biome, IrisRegion region, Double x, Double z, double fluidHeight) {
IrisBiome resolved = resolveSurfaceBiome(
height,
@@ -528,6 +937,38 @@ public class IrisComplex implements DataProvider {
return h;
}
private NoiseBounds naturalHeightBounds(
Engine engine,
KList<IrisShapedGeneratorStyle> overlayNoise,
double x,
double z
) {
double minimum = fluidHeight;
double maximum = fluidHeight;
for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) {
NoiseBounds bounds = gridSampleBounds(
engine,
frozenInterpolators[interpolatorIndex],
interpolatorIndex,
frozenGenerators[interpolatorIndex],
x,
z
);
minimum += Math.min(bounds.min(), bounds.max());
maximum += Math.max(bounds.min(), bounds.max());
}
for (IrisShapedGeneratorStyle style : overlayNoise) {
minimum += Math.min(style.getMin(), style.getMax());
maximum += Math.max(style.getMin(), style.getMax());
}
minimum = imageMapRuntime.sampleTerrainHeight(x, z, minimum);
maximum = imageMapRuntime.sampleTerrainHeight(x, z, maximum);
return new NoiseBounds(
Math.max(0D, Math.min(engine.getHeight(), Math.min(minimum, maximum))),
Math.max(0D, Math.min(engine.getHeight(), Math.max(minimum, maximum)))
);
}
private double getHeight(Engine engine, double x, double z, long seed) {
return Math.max(Math.min(getInterpolatedHeight(engine, x, z, seed) + fluidHeight + overlayStream.get(x, z), engine.getHeight()), 0);
}
@@ -540,46 +981,6 @@ public class IrisComplex implements DataProvider {
preparedRegions.add(region);
}
private ProceduralStream<IrisBiome> createInferredBiomeStream(
IrisRegion region,
InferredType inferredType
) {
return preparedInferredBiomeStream(inferredBiomeStreams, region, inferredType);
}
private ProceduralStream<IrisBiome> compileInferredBiomeStream(
Engine engine,
IrisRegion region,
InferredType inferredType,
IrisBiome emptyBiome
) {
return switch (inferredType) {
case CAVE -> engine.getDimension().getCaveBiomeStyle()
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome);
case LAND -> engine.getDimension().getLandBiomeStyle()
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getLandZoom())
.zoom(region.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(region.getLandBiomes(), InferredType.LAND));
case SEA -> engine.getDimension().getSeaBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getSeaZoom())
.zoom(region.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA));
case SHORE -> engine.getDimension().getShoreBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE));
};
}
static Map<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> compileInferredBiomeStreams(
Iterable<IrisRegion> regions,
BiFunction<IrisRegion, InferredType, ProceduralStream<IrisBiome>> compiler
@@ -1072,6 +1473,8 @@ public class IrisComplex implements DataProvider {
}
public void close() {
if (hydrologyRuntime != null) {
hydrologyRuntime.close();
}
}
}
@@ -29,7 +29,9 @@ import art.arcane.iris.engine.mantle.MantlePass;
import art.arcane.iris.engine.mantle.components.MantleCarvingComponent;
import art.arcane.iris.engine.mantle.components.MantleFloatingObjectComponent;
import art.arcane.iris.engine.mantle.components.MantleObjectComponent;
import art.arcane.iris.engine.mantle.components.MantleHydrologyComponent;
import art.arcane.iris.engine.mantle.components.IrisStructureComponent;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
@@ -72,6 +74,7 @@ import java.util.function.Supplier;
@EqualsAndHashCode(exclude = "engine")
@ToString(exclude = "engine")
public class IrisEngineMantle implements EngineMantle {
public static final String STORAGE_FOLDER_NAME = "mantle-hydrology";
private final Engine engine;
private final Mantle<Matter> mantle;
@Getter(AccessLevel.NONE)
@@ -86,6 +89,7 @@ public class IrisEngineMantle implements EngineMantle {
this.mantle = createMantle(engine);
components = new KMap<>();
registerComponent(new MantleCarvingComponent(this));
registerComponent(new MantleHydrologyComponent(this));
object = new MantleObjectComponent(this);
registerComponent(object);
registerComponent(new MantleFloatingObjectComponent(this));
@@ -121,7 +125,7 @@ public class IrisEngineMantle implements EngineMantle {
List<MantleComponent> pass = passes.get(i);
int passBlockRadius = pass.stream()
.filter(MantleComponent::isEnabled)
.mapToInt(MantleComponent::getRadius)
.mapToInt(MantleComponent::getOutputRadius)
.max()
.orElse(0);
int passInputRadius = pass.stream()
@@ -174,10 +178,18 @@ public class IrisEngineMantle implements EngineMantle {
if (!getDimension().isCarvingEnabled()) {
disabled.addIfMissing(ReservedFlag.CARVED);
}
if (disabled.contains(ReservedFlag.CARVED)
|| !isHydrologyEnabled(getDimension())) {
disabled.addIfMissing(ReservedFlag.RIVER_HYDROLOGY);
}
return Set.copyOf(disabled);
});
}
static boolean isHydrologyEnabled(IrisDimension dimension) {
return MantleHydrologyComponent.isEnabledFor(dimension);
}
@Override
public MantleObjectComponent getObjectComponent() {
return object;
@@ -185,7 +197,7 @@ public class IrisEngineMantle implements EngineMantle {
private static Mantle<Matter> createMantle(Engine engine) {
IrisMatterSupport.ensureRegistered();
File dataFolder = new File(engine.getWorld().worldFolder(), "mantle");
File dataFolder = new File(engine.getWorld().worldFolder(), STORAGE_FOLDER_NAME);
int worldHeight = engine.getTarget().getHeight();
MantleDataAdapter<Matter> adapter = createDataAdapter(engine::getData);
MantleHooks hooks = createHooks(EnginePanic.scoped("world " + engine.getWorld().name()));
@@ -66,8 +66,8 @@ public class UpperDimensionContext implements DataProvider {
engine.getDimension(),
engine.getData(),
chunkHeight,
complex.getHeightStream(),
complex.getTrueBiomeStream(),
complex.getNaturalHeightStream(),
complex.getNaturalTrueBiomeStream(),
complex.getRegionStream(),
complex.getRockStream(),
complex.getImageMapRuntime(),
@@ -99,12 +99,12 @@ public class UpperDimensionContext implements DataProvider {
upperDim.getRegions().forEach(regionKey -> {
IrisRegion region = upperData.getRegionLoader().load(regionKey);
if (region != null) {
region.getAllBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
region.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
biome, dataProvider, allBiomes, generators));
}
});
for (IrisRegion mappedRegion : imageMapRuntime.getMappedRegions()) {
mappedRegion.getAllBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
mappedRegion.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
biome, dataProvider, allBiomes, generators));
}
for (IrisBiome mappedBiome : imageMapRuntime.getMappedBiomes()) {
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.actuator;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.collection.KList;
final class HydrologyFluidLayerSelector {
private HydrologyFluidLayerSelector() {
}
static PlatformBlockState select(
KList<PlatformBlockState> seaLayers,
int depth,
PlatformBlockState fluid,
boolean hydrologyOwned
) {
if (!hydrologyOwned && seaLayers != null && seaLayers.hasIndex(depth)) {
return seaLayers.get(depth);
}
return fluid;
}
}
@@ -26,6 +26,9 @@ import art.arcane.iris.engine.decorator.IrisSurfaceDecorator;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineAssignedActuator;
import art.arcane.iris.engine.framework.EngineDecorator;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.project.context.ChunkContext;
@@ -50,7 +53,7 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
@Getter
private final EngineDecorator seaFloorDecorator;
@Getter
private final EngineDecorator shoreLineDecorator;
private final IrisShoreLineDecorator shoreLineDecorator;
private final boolean shouldRay;
public IrisDecorantActuator(Engine engine) {
@@ -64,6 +67,84 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
seaFloorDecorator = new IrisSeaFloorDecorator(getEngine());
}
static ShorelineDecorationMode shorelineDecorationMode(
HydrologyColumnSample sample,
int height,
int fluidHeight
) {
HydrologyColumnLayer surfaceLayer = sample == null
? null
: sample.primarySurfaceLayer().orElse(null);
if (surfaceLayer != null) {
return surfaceLayer.shore()
? ShorelineDecorationMode.ACCEPTED
: ShorelineDecorationMode.NONE;
}
return height == fluidHeight
? ShorelineDecorationMode.LEGACY
: ShorelineDecorationMode.NONE;
}
static boolean hasConnectedSurfaceWater(
Hunk<PlatformBlockState> output,
int x,
int z,
int height,
int fluidHeight
) {
return height < fluidHeight
&& height + 1 < output.getHeight()
&& IrisSurfaceDecorator.hasConnectedWater(output, x, height + 1, z);
}
static boolean hasConnectedWaterColumn(
Hunk<PlatformBlockState> output,
int x,
int z,
int lowerY,
int upperY
) {
int boundedLowerY = Math.max(0, lowerY);
int boundedUpperY = Math.min(output.getHeight() - 1, upperY);
if (boundedLowerY > boundedUpperY) {
return false;
}
for (int y = boundedLowerY; y <= boundedUpperY; y++) {
if (!IrisSurfaceDecorator.hasConnectedWater(output, x, y, z)) {
return false;
}
}
return true;
}
static void restoreUnsupportedAquaticPlacement(
Hunk<PlatformBlockState> output,
int x,
int y,
int z,
PlatformBlockState original
) {
PlatformBlockState placed = output.get(x, y, z);
if (placed != original && IrisSurfaceDecorator.isAquaticPlacement(placed)) {
output.set(x, y, z, original);
}
}
static boolean isFallingWaterfallThroat(HydrologyColumnSample sample) {
if (sample == null) {
return false;
}
for (HydrologyColumnLayer layer : sample.layers()) {
if ((layer.feature().type() == HydrologyFeatureType.WATERFALL
|| layer.feature().type() == HydrologyFeatureType.SINKHOLE)
&& layer.channel()
&& layer.fallingFluid()) {
return true;
}
}
return false;
}
@BlockCoordinates
@Override
public void onActuate(int x, int z, Hunk<PlatformBlockState> output, boolean multicore, ChunkContext context) {
@@ -72,7 +153,6 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
}
PrecisionStopwatch p = PrecisionStopwatch.start();
for (int i = 0; i < output.getWidth(); i++) {
int height;
int realX = Math.round(x + i);
@@ -86,23 +166,57 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
height = context.getRoundedHeight(i, j);
biome = context.getBiome().get(i, j);
cave = shouldRay ? context.getCave().get(i, j) : null;
HydrologyColumnSample hydrology = getComplex().getHydrologyRuntime() == null
? null
: getComplex().getHydrologyRuntime().sample(realX, realZ).orElse(null);
if (isFallingWaterfallThroat(hydrology)) {
continue;
}
int surfaceFluidHeight = (int) Math.round(
getComplex().getRiverWaterSurfaceStream().get(realX, realZ));
if (biome.getDecorators().isEmpty() && (cave == null || cave.getDecorators().isEmpty())) {
continue;
}
if (height < getDimension().getFluidHeight() && PREDICATE_SOLID.test(output.get(i, height, j))
&& height + 1 < output.getHeight() && B.isWater(output.get(i, height + 1, j))) {
if (PREDICATE_SOLID.test(output.get(i, height, j))
&& hasConnectedSurfaceWater(output, i, j, height, surfaceFluidHeight)) {
int seaSurfaceY = surfaceFluidHeight + 1;
PlatformBlockState seaSurfaceOriginal = seaSurfaceY < output.getHeight()
? output.get(i, seaSurfaceY, j)
: null;
getSeaSurfaceDecorator().decorate(i, j,
realX, Math.round(i + 1), Math.round(x + i - 1),
realZ, Math.round(z + j + 1), Math.round(z + j - 1),
output, biome, getDimension().getFluidHeight(), getEngine().getHeight());
getSeaFloorDecorator().decorate(i, j,
realX, realZ, output, biome, height + 1,
getDimension().getFluidHeight() + 1);
output, biome, surfaceFluidHeight, getEngine().getHeight());
if (seaSurfaceY < output.getHeight()) {
restoreUnsupportedAquaticPlacement(
output, i, seaSurfaceY, j, seaSurfaceOriginal);
}
if (hasConnectedWaterColumn(output, i, j, height + 1, surfaceFluidHeight)) {
getSeaFloorDecorator().decorate(i, j,
realX, realZ, output, biome, height + 1,
surfaceFluidHeight + 1);
}
}
if (height == getDimension().getFluidHeight()) {
ShorelineDecorationMode shorelineMode = shorelineDecorationMode(
hydrology,
height,
surfaceFluidHeight
);
if (shorelineMode == ShorelineDecorationMode.ACCEPTED) {
getShoreLineDecorator().decorateAcceptedShore(
i,
j,
realX,
realZ,
output,
biome,
height,
getEngine().getHeight()
);
} else if (shorelineMode == ShorelineDecorationMode.LEGACY) {
getShoreLineDecorator().decorate(i, j,
realX, Math.round(x + i + 1), Math.round(x + i - 1),
realZ, Math.round(z + j + 1), Math.round(z + j - 1),
@@ -140,4 +254,10 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
private boolean shouldRayDecorate() {
return false; // TODO CAVES
}
enum ShorelineDecorationMode {
NONE,
LEGACY,
ACCEPTED
}
}
@@ -23,7 +23,11 @@ import art.arcane.iris.engine.framework.EngineAssignedActuator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.UpperDimensionContext;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisProceduralBlocks;
import art.arcane.iris.engine.object.IrisSurfaceRiverBedConfig;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisOreGenerator;
import art.arcane.iris.engine.object.IrisOreGeneratorBounds;
@@ -88,13 +92,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
IrisData data = getData();
IrisComplex complex = getComplex();
RNG localRng = rng;
int fluidHeight = dimension.getFluidHeight();
int clampedFluidHeight = Math.min(chunkHeight, fluidHeight);
boolean bedrockEnabled = dimension.isBedrock();
boolean hideOres = dimension.isHideOresForHiddenOre();
ChunkedDataCache<IrisBiome> biomeCache = context.getBiome();
ChunkedDataCache<IrisRegion> regionCache = context.getRegion();
ChunkedDataCache<PlatformBlockState> fluidCache = context.getFluid();
ChunkedDataCache<PlatformBlockState> rockCache = context.getRock();
int realX = xf + x;
UpperDimensionContext upperContext = getEngine().getUpperContext();
@@ -104,19 +105,47 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
KList<IrisOreGenerator> dimensionUndergroundOres = hideOres ? null : dimension.getUndergroundOreGenerators();
IrisOreGeneratorBounds dimensionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getSurfaceOreGeneratorBounds();
IrisOreGeneratorBounds dimensionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getUndergroundOreGeneratorBounds();
boolean exposeCutStrata = dimension.getHydrology() != null
&& dimension.getHydrology().getRivers().getSurface().getBanks().isExposeCutStrata();
IrisSurfaceRiverBedConfig riverBed = dimension.getHydrology() == null
? null
: dimension.getHydrology().getRivers().getSurface().getBed();
boolean padRiverBed = riverBed != null && !riverBed.isAllowGravityBlocks();
for (int zf = 0; zf < chunkDepth; zf++) {
int realZ = zf + z;
IrisBiome biome = biomeCache.get(xf, zf);
IrisRegion region = regionCache.get(xf, zf);
int he = Math.min(chunkHeight, context.getRoundedHeight(xf, zf));
int hf = Math.max(clampedFluidHeight, he);
int surfaceFluidHeight = Math.min(
chunkHeight,
(int) Math.round(complex.getRiverWaterSurfaceStream().get(realX, realZ))
);
int hf = Math.max(surfaceFluidHeight, he);
if (hf < 0) {
continue;
}
int topY = Math.min(hf, chunkHeight - 1);
PlatformBlockState fluid = fluidCache.get(xf, zf);
HydrologyColumnSample hydrology = complex.getHydrologyRuntime() == null
? null
: complex.getHydrologyRuntime().sample(realX, realZ).orElse(null);
HydrologyColumnLayer hydrologyFluid = hydrology == null
? null
: hydrology.primarySurfaceFluidLayer().orElse(null);
HydrologyColumnLayer hydrologyTerrain = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
int cut = exposeCutStrata
&& hydrologyTerrain != null
&& hydrologyTerrain.terrainOwned()
&& !hydrologyTerrain.channel()
? Math.max(0, hydrology.naturalHeight() - he)
: 0;
boolean riverOwned = padRiverBed && hydrologyTerrain != null && hydrologyTerrain.terrainOwned();
PlatformBlockState fluid = hydrologyFluid == null
? complex.resolveSurfaceFluid(realX, realZ)
: complex.resolveHydrologyFluid(hydrologyFluid.profileKey(), realX, realZ);
PlatformBlockState rock = rockCache.get(xf, zf);
PlatformBlockState mappedSurfaceBlock = complex.getImageMapRuntime().sampleSurfaceBlock(realX, realZ);
KList<IrisOreGenerator> biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators();
@@ -158,15 +187,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
if (i > he && i <= hf) {
int fdepth = hf - i;
if (fblocks == null) {
if (hydrologyFluid == null && fblocks == null) {
fblocks = biome.generateSeaLayers(realX, realZ, localRng, hf - he, data);
}
if (fblocks.hasIndex(fdepth)) {
h.setRaw(xf, i, zf, fblocks.get(fdepth));
} else {
h.setRaw(xf, i, zf, fluid);
}
h.setRaw(xf, i, zf, HydrologyFluidLayerSelector.select(
fblocks,
fdepth,
fluid,
hydrologyFluid != null
));
continue;
}
@@ -177,11 +206,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
continue;
}
if (blocks == null) {
blocks = biome.generateLayers(dimension, realX, realZ, localRng, he, he, data, complex);
blocks = biome.generateLayers(dimension, realX, realZ, localRng, he + cut, he + cut, data, complex);
}
if (blocks.hasIndex(depth)) {
h.setRaw(xf, i, zf, blocks.get(depth));
if (blocks.hasIndex(depth + cut)) {
PlatformBlockState layerBlock = blocks.get(depth + cut);
if (riverOwned && depth <= riverBed.getPadding() && IrisProceduralBlocks.isGravityAffected(layerBlock)) {
layerBlock = riverBed.getPaddingPalette().get(localRng, realX, i, realZ, data);
}
h.setRaw(xf, i, zf, layerBlock);
continue;
}
@@ -40,19 +40,46 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
@Override
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<PlatformBlockState> data, IrisBiome biome, int height, int max) {
if (height != getDimension().getFluidHeight()) {
double localFluidHeight = getComplex().getRiverWaterSurfaceStream().get(realX, realZ);
if (height != Math.round(localFluidHeight)) {
return;
}
double complexFluidHeight = getComplex().getFluidHeight();
ProceduralStream<Double> heightStream = getComplex().getHeightStream();
if (Math.round(heightStream.get(realX1, realZ)) >= complexFluidHeight
&& Math.round(heightStream.get(realX_1, realZ)) >= complexFluidHeight
&& Math.round(heightStream.get(realX, realZ1)) >= complexFluidHeight
&& Math.round(heightStream.get(realX, realZ_1)) >= complexFluidHeight) {
ProceduralStream<Double> fluidStream = getComplex().getRiverWaterSurfaceStream();
if (Math.round(heightStream.get(realX1, realZ)) >= Math.round(fluidStream.get(realX1, realZ))
&& Math.round(heightStream.get(realX_1, realZ)) >= Math.round(fluidStream.get(realX_1, realZ))
&& Math.round(heightStream.get(realX, realZ1)) >= Math.round(fluidStream.get(realX, realZ1))
&& Math.round(heightStream.get(realX, realZ_1)) >= Math.round(fluidStream.get(realX, realZ_1))) {
return;
}
place(x, z, realX, realZ, data, biome, height, max);
}
public void decorateAcceptedShore(
int x,
int z,
int realX,
int realZ,
Hunk<PlatformBlockState> data,
IrisBiome biome,
int height,
int max
) {
place(x, z, realX, realZ, data, biome, height, max);
}
private void place(
int x,
int z,
int realX,
int realZ,
Hunk<PlatformBlockState> data,
IrisBiome biome,
int height,
int max
) {
RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -70,6 +97,8 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
return;
}
IrisSurfaceDecorator.AquaticPlacementSnapshot aquaticSnapshot = IrisSurfaceDecorator.captureAquaticPlacement(
decorator, getData(), data, x, z, height, max);
if (!decorator.isStacking()) {
int targetY = height + 1;
if (targetY >= data.getHeight()
@@ -79,6 +108,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData());
if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
data.set(x, targetY, z, block);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
return;
}
@@ -99,6 +129,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData());
if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
data.set(x, targetY, z, block);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
return;
}
@@ -122,5 +153,6 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
}
data.set(x, targetY, z, block);
}
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
}
@@ -18,11 +18,13 @@
package art.arcane.iris.engine.decorator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationPart;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.engine.object.IrisProceduralBlocks;
import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.volmlib.util.documentation.BlockCoordinates;
import art.arcane.volmlib.util.math.RNG;
@@ -58,12 +60,15 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
@BlockCoordinates
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<PlatformBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) {
int fluidHeight = getDimension().getFluidHeight();
int fluidHeight = (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(realX, realZ));
if (inferredType == InferredType.SHORE && height < fluidHeight) {
return;
}
boolean underwater = isUnderwater(inferredType, height, fluidHeight);
if (underwater && !hasFluidAbove(data, x, height, z)) {
return;
}
boolean caveSkipFluid = skipsFluid(inferredType);
RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -72,6 +77,8 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
return;
}
AquaticPlacementSnapshot aquaticSnapshot = captureAquaticPlacement(
decorator, getData(), data, x, z, height, max);
if (decorator.isStacking()) {
DecoratorCore.PlaceOpts opts = DecoratorCore.SCRATCH_OPTS.get();
opts.reset();
@@ -79,11 +86,13 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
opts.fluidHeight = fluidHeight;
opts.caveSkipFluid = caveSkipFluid;
DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, rng, getData(), opts);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
return;
}
DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ,
data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle());
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) {
@@ -93,4 +102,148 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
static boolean skipsFluid(InferredType inferredType) {
return inferredType == InferredType.CAVE;
}
static boolean hasFluidAbove(Hunk<PlatformBlockState> data, int x, int height, int z) {
return height + 1 < data.getHeight() && data.get(x, height + 1, z).isFluid();
}
public static boolean isAquaticPlacement(PlatformBlockState state) {
if (state == null) {
return false;
}
if (state.isWaterLogged()) {
return true;
}
String material = IrisProceduralBlocks.materialKey(state);
if (material.equals("minecraft:seagrass")
|| material.equals("minecraft:tall_seagrass")
|| material.equals("minecraft:kelp")
|| material.equals("minecraft:kelp_plant")
|| material.equals("minecraft:sea_pickle")) {
return true;
}
return material.startsWith("minecraft:")
&& material.contains("_coral")
&& !material.contains(":dead_");
}
public static boolean hasConnectedWater(Hunk<PlatformBlockState> data, int x, int y, int z) {
if (y < 0 || y >= data.getHeight() || !isWater(data.get(x, y, z))) {
return false;
}
return isWaterNeighbor(data, x - 1, y, z)
|| isWaterNeighbor(data, x + 1, y, z)
|| isWaterNeighbor(data, x, y, z - 1)
|| isWaterNeighbor(data, x, y, z + 1);
}
static AquaticPlacementSnapshot captureAquaticPlacement(
IrisDecorator decorator,
IrisData irisData,
Hunk<PlatformBlockState> data,
int x,
int z,
int height,
int max
) {
if (!mayPlaceAquatic(decorator, irisData)) {
return AquaticPlacementSnapshot.EMPTY;
}
int highestOffset = decorator.isStacking()
? maximumStackHeight(decorator, max)
: 2;
int lowerY = Math.max(0, height);
int upperY = Math.min(data.getHeight() - 1, height + highestOffset);
if (lowerY > upperY) {
return AquaticPlacementSnapshot.EMPTY;
}
PlatformBlockState[] originals = new PlatformBlockState[upperY - lowerY + 1];
boolean[] connectedWater = new boolean[originals.length];
for (int i = 0; i < originals.length; i++) {
int y = lowerY + i;
originals[i] = data.get(x, y, z);
connectedWater[i] = hasConnectedWater(data, x, y, z);
}
return new AquaticPlacementSnapshot(lowerY, originals, connectedWater);
}
private static boolean mayPlaceAquatic(IrisDecorator decorator, IrisData irisData) {
PlatformBlockState[] palette = decorator.getBlockDataArray(irisData);
if (palette != null) {
for (PlatformBlockState state : palette) {
if (isAquaticPlacement(state)) {
return true;
}
}
}
PlatformBlockState[] topPalette = decorator.getBlockDataTopsArray(irisData);
if (topPalette != null) {
for (PlatformBlockState state : topPalette) {
if (isAquaticPlacement(state)) {
return true;
}
}
}
return decorator.getForceBlock() != null
&& isAquaticPlacement(decorator.getForceBlock().getBlockData(irisData));
}
private static int maximumStackHeight(IrisDecorator decorator, int max) {
if (decorator.isScaleStack()) {
return Math.max(1, decorator.getAbsoluteMaxStack());
}
return Math.max(1, Math.min(Math.max(1, max), decorator.getStackMax()));
}
private static boolean isWaterNeighbor(Hunk<PlatformBlockState> data, int x, int y, int z) {
if (x < 0 || x >= data.getWidth() || z < 0 || z >= data.getDepth()) {
return false;
}
return isWater(data.get(x, y, z));
}
private static boolean isWater(PlatformBlockState state) {
return state != null && state.isWater();
}
static final class AquaticPlacementSnapshot {
private static final AquaticPlacementSnapshot EMPTY = new AquaticPlacementSnapshot(
0, new PlatformBlockState[0], new boolean[0]);
private final int lowerY;
private final PlatformBlockState[] originals;
private final boolean[] connectedWater;
private AquaticPlacementSnapshot(
int lowerY,
PlatformBlockState[] originals,
boolean[] connectedWater
) {
this.lowerY = lowerY;
this.originals = originals;
this.connectedWater = connectedWater;
}
void restoreIfUnsupported(Hunk<PlatformBlockState> data, int x, int z) {
if (!containsUnsupportedAquatic(data, x, z)) {
return;
}
for (int i = 0; i < originals.length; i++) {
data.set(x, lowerY + i, z, originals[i]);
}
}
private boolean containsUnsupportedAquatic(Hunk<PlatformBlockState> data, int x, int z) {
for (int i = 0; i < originals.length; i++) {
PlatformBlockState placed = data.get(x, lowerY + i, z);
if (placed != originals[i] && isAquaticPlacement(placed) && !connectedWater[i]) {
return true;
}
}
return false;
}
}
}
@@ -42,6 +42,8 @@ import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBlockState;
@@ -244,6 +246,14 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
@BlockCoordinates
default IrisBiome getCaveOrMantleBiome(int x, int y, int z) {
HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent(
getMantle().getMantle(), x, y, z);
if (hydrology != null && !hydrology.floodedBiomeKey().isEmpty()) {
IrisBiome biome = getData().getBiomeLoader().load(hydrology.floodedBiomeKey());
if (biome != null) {
return biome;
}
}
MatterCavern m = getMantle().getMantle().get(x, y, z, MatterCavern.class);
if (m != null && m.getCustomBiome() != null && !m.getCustomBiome().isEmpty()) {
@@ -445,6 +455,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
default PlacedObject getObjectPlacement(int x, int y, int z, MantleChunk<Matter> chunk) {
String objectAt = chunk.get(x & 15, y, z & 15, String.class);
return resolveObjectPlacementMarker(x, z, objectAt);
}
default PlacedObject resolveObjectPlacementMarker(int x, int z, @Nullable String objectAt) {
if (objectAt == null || objectAt.isEmpty()) {
return null;
}
@@ -552,7 +566,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
return;
}
if (IrisSettings.get().getPerformance().isTrimMantleInStudio() || !isStudio()) {
getMantle().cleanupChunk(x, z);
getMantle().cleanupChunksCoveredBy(
x,
z,
false,
EngineMantle.ChunkCleanupCallback.NONE
);
}
}
}
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.framework;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.mantle.MantleComponent;
import art.arcane.volmlib.util.collection.KList;
public interface EnginePlatformHooks {
@@ -70,4 +71,8 @@ public interface EnginePlatformHooks {
default boolean shouldBypassMantleStages(Engine engine) {
return false;
}
default boolean shouldGenerateMantleComponent(Engine engine, MantleComponent component) {
return true;
}
}
@@ -328,7 +328,11 @@ public final class HintedLocator<T> implements Locator<T> {
}
if (!surfaceRegions.isEmpty()) {
return SearchPlan.of(surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts), BIOME_STRIDE_CHUNKS, null);
return SearchPlan.of(
surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts),
BIOME_STRIDE_CHUNKS,
null
);
}
if (!caveRegions.isEmpty()) {
@@ -341,6 +345,11 @@ public final class HintedLocator<T> implements Locator<T> {
return SearchPlan.of(coarse, BIOME_STRIDE_CHUNKS, caveExact);
}
for (IrisBiome reachableBiome : engine.getDimension().getReachableBiomes(engine)) {
if (reachableBiome != null && biomeKey.equals(reachableBiome.getLoadKey())) {
return SearchPlan.unpruned();
}
}
return SearchPlan.impossible();
}
@@ -576,6 +585,36 @@ public final class HintedLocator<T> implements Locator<T> {
return false;
}
private KList<IrisBiome> closure(String rootKey) {
KList<IrisBiome> biomes = new KList<>();
if (rootKey == null) {
return biomes;
}
KSet<String> visited = new KSet<>();
ArrayDeque<String> queue = new ArrayDeque<>();
queue.add(rootKey);
while (!queue.isEmpty()) {
String key = queue.poll();
if (!visited.add(key)) {
continue;
}
IrisBiome biome = load(key);
if (biome == null) {
continue;
}
biomes.add(biome);
for (String child : biome.getChildren()) {
if (child != null && !visited.contains(child)) {
queue.add(child);
}
}
}
return biomes;
}
private boolean closurePlacesObject(String rootKey, String objectKey) {
if (rootKey == null || objectKey == null) {
return false;
@@ -21,6 +21,7 @@ package art.arcane.iris.engine.framework;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.nms.container.BlockPos;
import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisRegion;
@@ -53,10 +54,22 @@ public interface Locator<T> {
}
static Locator<IrisBiome> surfaceBiome(String loadKey) {
Locator<IrisBiome> exact = (e, c) -> e.getSurfaceBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey);
Locator<IrisBiome> exact = (e, c) -> chunkContainsSurfaceBiome(e, c, loadKey);
return new HintedLocator<>(exact, (engine) -> HintedLocator.biomePlan(engine, loadKey));
}
static boolean chunkContainsSurfaceBiome(Engine engine, Position2 chunk, String loadKey) {
int minimumX = chunk.getX() << 4;
int minimumZ = chunk.getZ() << 4;
IrisBiome center = engine.getSurfaceBiome(minimumX + 8, minimumZ + 8);
if (center != null && loadKey.equals(center.getLoadKey())) {
return true;
}
IrisHydrologyRuntime hydrology = engine.getComplex().getHydrologyRuntime();
return hydrology != null
&& hydrology.hasAcceptedSurfaceBiomeInChunk(loadKey, chunk.getX(), chunk.getZ());
}
static Locator<art.arcane.iris.engine.object.IrisStructure> structure(String key) {
return (e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ());
}
@@ -157,7 +157,10 @@ public final class NativeStructurePlacementPlanner {
}
static boolean isSubmerged(Engine engine, int blockX, int blockZ) {
return engine.getHeight(blockX, blockZ, true) < engine.getDimension().getFluidHeight();
int localFluidHeight = engine.getComplex() == null
? engine.getDimension().getFluidHeight()
: (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
return engine.getHeight(blockX, blockZ, true) < localFluidHeight;
}
private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) {
@@ -3,6 +3,8 @@ package art.arcane.iris.engine.framework;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisStructureAnchorMode;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.matter.MatterCavern;
@@ -286,10 +288,12 @@ public final class StructureCaveAnchorResolver {
int mantleY,
int blockZ
) {
HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ);
MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ);
return acceptsAnchorFluid(
placement.isUnderwater(),
cavern,
hydrology,
mantleY,
engine.getDimension().getCaveLavaHeight());
}
@@ -300,6 +304,19 @@ public final class StructureCaveAnchorResolver {
int mantleY,
int defaultLavaHeight
) {
return acceptsAnchorFluid(underwater, cavern, null, mantleY, defaultLavaHeight);
}
static boolean acceptsAnchorFluid(
boolean underwater,
MatterCavern cavern,
HydrologyCaveCell hydrology,
int mantleY,
int defaultLavaHeight
) {
if (hydrology != null && hydrology.protectsPlacement()) {
return false;
}
if (cavern == null || !cavern.isCavern()) {
return false;
}
@@ -313,7 +330,15 @@ public final class StructureCaveAnchorResolver {
}
private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) {
return engine.getMantle().getMantle().get(blockX, mantleY, blockZ, MatterCavern.class);
MatterCavern baseline = engine.getMantle().getMantle()
.get(blockX, mantleY, blockZ, MatterCavern.class);
HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ);
return hydrology == null ? baseline : hydrology.asCavern();
}
private static HydrologyCaveCell hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) {
return HydrologyCaveStorage.getIfPresent(
engine.getMantle().getMantle(), blockX, mantleY, blockZ);
}
static int toMantleY(int worldY, int worldMinHeight) {
@@ -21,7 +21,6 @@ import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.matter.MatterCavern;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.bukkit.Bukkit;
@@ -112,7 +111,7 @@ public class WorldObjectPlacer implements IObjectPlacer {
@Override
public boolean isCarved(int x, int y, int z) {
return mantle.getMantle().get(x, y, z, MatterCavern.class) != null;
return mantle.isCarved(x, y, z);
}
@Override
@@ -20,6 +20,13 @@ package art.arcane.iris.engine.framework.render;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyCandidateKind;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyRenderSample;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBiomeGeneratorLink;
import art.arcane.iris.engine.object.IrisDimension;
@@ -29,9 +36,13 @@ import art.arcane.iris.util.project.stream.ProceduralStream;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.BooleanSupplier;
@@ -40,6 +51,24 @@ public final class IrisRenderer {
private static final int BLUE = new Color(45, 91, 156).getRGB();
private static final int YELLOW = new Color(211, 164, 67).getRGB();
private static final int GREEN = new Color(78, 137, 83).getRGB();
private static final int HEADWATER = new Color(99, 184, 230).getRGB();
private static final int HEADWATER_DIRECTION = new Color(13, 38, 55).getRGB();
private static final int RIVER_CHANNEL = new Color(48, 112, 190).getRGB();
private static final int RIVER_RIFFLE = new Color(96, 166, 214).getRGB();
private static final int RIVER_CASCADE = new Color(135, 204, 232).getRGB();
private static final int SUBTERRANEAN_CHANNEL = new Color(102, 86, 196).getRGB();
private static final int RIDGE_TUNNEL = new Color(120, 99, 174).getRGB();
private static final int COASTAL_GROTTO = new Color(51, 174, 174).getRGB();
private static final int INLAND_GROTTO = new Color(116, 76, 150).getRGB();
private static final int RIVER_SINKHOLE = new Color(176, 146, 232).getRGB();
private static final int RIVER_MOUTH = new Color(54, 164, 205).getRGB();
private static final int RIVER_WATERFALL = new Color(190, 236, 255).getRGB();
private static final int CONFIRMED_DEEP_LAVA = new Color(194, 50, 22).getRGB();
private static final int STANDING_POOL = new Color(232, 120, 40).getRGB();
private static final int PROJECTED_SOURCE = new Color(225, 75, 178).getRGB();
private static final int PROJECTED_OUTLET = new Color(235, 145, 54).getRGB();
private static final int PROJECTED_DEEP_FLUID = new Color(175, 70, 118).getRGB();
private static final int NO_RIVER = new Color(28, 31, 38).getRGB();
private static final int DEEP_WATER = new Color(20, 48, 92).getRGB();
private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB();
private static final int LOWLAND = new Color(78, 128, 76).getRGB();
@@ -105,9 +134,17 @@ public final class IrisRenderer {
renderHeightAtlas(pixels, resolution, sx, sz, step, renderer, cancelled);
return image;
}
PixelShader shader = shader(currentType, studio);
PixelShader shader = shader(currentType, step, studio);
if (studio && currentType == RenderType.RIVER) {
Arrays.fill(pixels, NO_RIVER);
renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, false);
return image;
}
if (studio && adaptiveStudioType(currentType)) {
renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled);
if (currentType == RenderType.BIOME) {
renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, true);
}
return image;
}
int groupSize = sampleGroup(step, resolution);
@@ -133,6 +170,57 @@ public final class IrisRenderer {
return image;
}
public static int waterfallColor() {
return RIVER_WATERFALL;
}
public static int surfaceWaterColor() {
return RIVER_CHANNEL;
}
public static int subterraneanColor() {
return SUBTERRANEAN_CHANNEL;
}
public static int confirmedDeepLavaColor() {
return CONFIRMED_DEEP_LAVA;
}
public static int hydrologyFeatureColor(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
return switch (type) {
case SURFACE_POOL -> RIVER_CHANNEL;
case RIFFLE -> RIVER_RIFFLE;
case CASCADE -> RIVER_CASCADE;
case WATERFALL -> RIVER_WATERFALL;
case SINKHOLE -> RIVER_SINKHOLE;
case RIDGE_BORE -> RIDGE_TUNNEL;
case UNDERGROUND_POOL, UNDERGROUND_DROP -> SUBTERRANEAN_CHANNEL;
case COASTAL_GROTTO -> COASTAL_GROTTO;
case INLAND_GROTTO -> INLAND_GROTTO;
case MOUTH -> RIVER_MOUTH;
case DEEP_POOL, DEEP_CHANNEL -> CONFIRMED_DEEP_LAVA;
case STANDING_POOL -> STANDING_POOL;
};
}
public static int headwaterColor() {
return HEADWATER;
}
public static int headwaterDirectionColor() {
return HEADWATER_DIRECTION;
}
public static int hydrologyDiagnosticColor(HydrologyCandidateKind kind) {
Objects.requireNonNull(kind, "kind");
return switch (kind) {
case SOURCE -> PROJECTED_SOURCE;
case OUTLET -> PROJECTED_OUTLET;
case DEEP_FLUID, POOL -> PROJECTED_DEEP_FLUID;
};
}
public static int heightColor(double height, double maximumHeight, double fluidHeight) {
double boundedMaximum = Math.max(1D, maximumHeight);
double boundedHeight = clamp(height, 0D, boundedMaximum);
@@ -160,7 +248,7 @@ public final class IrisRenderer {
return Math.max(1, Math.min(resolution, (int) Math.floor(16D / absoluteStep)));
}
private PixelShader shader(RenderType currentType, boolean studio) {
private PixelShader shader(RenderType currentType, double step, boolean studio) {
IrisComplex complex = renderer.getComplex();
return switch (currentType) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> biomeShader(
@@ -169,7 +257,8 @@ public final class IrisRenderer {
case BIOME_SEA -> biomeShader(complex.getSeaBiomeStream(), currentType);
case REGION -> regionShader(complex, currentType);
case CAVE_LAND -> biomeShader(complex.getCaveBiomeStream(), currentType);
case HEIGHT -> heightShader(complex.getHeightStream());
case HEIGHT -> heightShader(studio ? complex.getNaturalHeightStream() : complex.getHeightStream());
case RIVER -> (double x, double z) -> riverColor(complex, x, z, step);
case CONTINENT -> studio
? continentShader(complex.getBaseBiomeStream())
: this::continentColor;
@@ -180,7 +269,7 @@ public final class IrisRenderer {
return switch (type) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND,
CONTINENT -> true;
case HEIGHT -> false;
case HEIGHT, RIVER -> false;
};
}
@@ -224,7 +313,7 @@ public final class IrisRenderer {
startX,
startZ,
step,
complex.getHeightStream(),
complex.getNaturalHeightStream(),
engine.getHeight(),
fluidHeight,
cancelled
@@ -237,6 +326,212 @@ public final class IrisRenderer {
}
}
private static void renderRiverAtlas(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
IrisComplex complex,
BooleanSupplier cancelled,
boolean composite
) {
IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
if (runtime == null) {
return;
}
Map<Long, HydrologyFeatureRef> headwaters = new LinkedHashMap<>();
int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
int blockPixels = Integer.highestOneBit(maximumPixels);
for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
int height = Math.min(blockPixels, resolution - pixelZ);
for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) {
renderRiverBlock(
pixels,
resolution,
startX,
startZ,
step,
runtime,
cancelled,
composite,
!composite,
headwaters,
pixelX,
pixelZ,
Math.min(blockPixels, resolution - pixelX),
height
);
}
}
renderHeadwaterDirections(pixels, resolution, startX, startZ, step, headwaters);
}
private static void renderRiverBlock(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
IrisHydrologyRuntime runtime,
BooleanSupplier cancelled,
boolean composite,
boolean diagnostics,
Map<Long, HydrologyFeatureRef> headwaters,
int pixelX,
int pixelZ,
int width,
int height
) {
checkCancelled(cancelled);
HydrologyRenderSample sample = runtime.sampleRenderFootprint(
startX + pixelX * step,
startZ + pixelZ * step,
startX + (pixelX + width) * step,
startZ + (pixelZ + height) * step
);
HydrologyDiagnosticRenderSample diagnosticSample = diagnostics
? runtime.sampleDiagnosticFootprint(
startX + pixelX * step,
startZ + pixelZ * step,
startX + (pixelX + width) * step,
startZ + (pixelZ + height) * step
)
: null;
collectHeadwaters(sample, headwaters);
if (!sample.present() && (diagnosticSample == null || !diagnosticSample.present())) {
return;
}
if (width == 1 && height == 1) {
int index = pixelZ * resolution + pixelX;
int color = sample.present()
? hydrologyColor(sample)
: hydrologyDiagnosticColor(diagnosticSample);
pixels[index] = composite
? blend(pixels[index], color, 0.72D)
: color;
return;
}
int leftWidth = Math.max(1, width / 2);
int rightWidth = width - leftWidth;
int topHeight = Math.max(1, height / 2);
int bottomHeight = height - topHeight;
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX, pixelZ, leftWidth, topHeight);
if (rightWidth > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX + leftWidth, pixelZ, rightWidth, topHeight);
}
if (bottomHeight > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX, pixelZ + topHeight, leftWidth, bottomHeight);
if (rightWidth > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight);
}
}
}
private static void collectHeadwaters(
HydrologyRenderSample sample,
Map<Long, HydrologyFeatureRef> headwaters
) {
for (HydrologyFeatureRef feature : sample.features()) {
if (!feature.source()) {
continue;
}
HydrologyFeatureRef existing = headwaters.putIfAbsent(feature.id(), feature);
if (existing != null && !existing.equals(feature)) {
throw new IllegalStateException("Hydrology headwater feature id collision in renderer footprint.");
}
}
}
private static void renderHeadwaterDirections(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
Map<Long, HydrologyFeatureRef> headwaters
) {
if (resolution < 5 || headwaters.isEmpty()) {
return;
}
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(headwaters.values());
ordered.sort(Comparator.comparingLong(HydrologyFeatureRef::id));
for (HydrologyFeatureRef feature : ordered) {
int pixelX = worldToPixel(feature.x(), startX, step, resolution);
int pixelZ = worldToPixel(feature.z(), startZ, step, resolution);
drawHeadwaterDirection(
pixels,
resolution,
pixelX,
pixelZ,
feature.flowDeltaX(),
feature.flowDeltaZ()
);
}
}
private static int worldToPixel(int coordinate, double start, double step, int resolution) {
int pixel = (int) StrictMath.floor((coordinate + 0.5D - start) / step);
int markerRadius = 2;
return Math.max(markerRadius, Math.min(resolution - markerRadius - 1, pixel));
}
private static void drawHeadwaterDirection(
int[] pixels,
int resolution,
int centerX,
int centerZ,
int flowX,
int flowZ
) {
int markerRadius = Math.min(2, (resolution - 1) / 2);
for (int deltaZ = -markerRadius; deltaZ <= markerRadius; deltaZ++) {
for (int deltaX = -markerRadius; deltaX <= markerRadius; deltaX++) {
setPixel(pixels, resolution, centerX + deltaX, centerZ + deltaZ, HEADWATER);
}
}
if (flowX == 0 && flowZ == 0) {
setPixel(pixels, resolution, centerX, centerZ, HEADWATER_DIRECTION);
return;
}
setPixel(pixels, resolution, centerX - flowX * 2, centerZ - flowZ * 2, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX - flowX, centerZ - flowZ, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX, centerZ, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX + flowX, centerZ + flowZ, HEADWATER_DIRECTION);
int tipX = centerX + flowX * 2;
int tipZ = centerZ + flowZ * 2;
setPixel(pixels, resolution, tipX, tipZ, HEADWATER_DIRECTION);
setPixel(
pixels,
resolution,
tipX - flowX - flowZ,
tipZ - flowZ + flowX,
HEADWATER_DIRECTION
);
setPixel(
pixels,
resolution,
tipX - flowX + flowZ,
tipZ - flowZ - flowX,
HEADWATER_DIRECTION
);
}
private static void setPixel(int[] pixels, int resolution, int x, int z, int color) {
if (x < 0 || x >= resolution || z < 0 || z >= resolution) {
return;
}
pixels[z * resolution + x] = color;
}
private PixelShader biomeShader(ProceduralStream<IrisBiome> stream, RenderType currentType) {
IdentityHashMap<IrisBiome, Integer> colors = new IdentityHashMap<>();
return (double x, double z) -> {
@@ -271,6 +566,50 @@ public final class IrisRenderer {
return (double x, double z) -> heightColor(stream.getDouble(x, z), maximumHeight, fluidHeight);
}
private int riverColor(IrisComplex complex, double x, double z, double step) {
IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
if (runtime == null) {
return NO_RIVER;
}
double endX = x + step;
double endZ = z + step;
HydrologyRenderSample sample = runtime.sampleRenderFootprint(
StrictMath.min(x, endX),
StrictMath.min(z, endZ),
StrictMath.max(x, endX),
StrictMath.max(z, endZ)
);
if (sample.present()) {
return hydrologyColor(sample);
}
return hydrologyDiagnosticColor(runtime.sampleDiagnosticFootprint(
StrictMath.min(x, endX),
StrictMath.min(z, endZ),
StrictMath.max(x, endX),
StrictMath.max(z, endZ)
));
}
static int hydrologyColor(HydrologyRenderSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyFeatureRef feature = sample.primaryFeature().orElse(null);
if (feature == null) {
return NO_RIVER;
}
if (feature.source()) {
return HEADWATER;
}
return hydrologyFeatureColor(feature.type());
}
static int hydrologyDiagnosticColor(HydrologyDiagnosticRenderSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyDiagnosticCandidate candidate = sample.candidates().isEmpty()
? null
: sample.candidates().getFirst();
return candidate == null ? NO_RIVER : hydrologyDiagnosticColor(candidate.kind());
}
private int continentColor(double x, double z) {
IrisBiome biome = renderer.getBiome(
(int) Math.round(x),
@@ -19,5 +19,5 @@
package art.arcane.iris.engine.framework.render;
public enum RenderType {
BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, HEIGHT, OBJECT_LOAD, DECORATOR_LOAD, CONTINENT, LAYER_LOAD
BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, RIVER, HEIGHT, OBJECT_LOAD, DECORATOR_LOAD, CONTINENT, LAYER_LOAD
}
@@ -0,0 +1,36 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
public record DrainageEdge(
long id,
long upstreamNodeId,
long downstreamNodeId,
long outletId,
double cost,
int contributingSurfaceSources,
int contributingUndergroundSources,
List<HydrologyPoint> centerline
) {
public DrainageEdge {
if (upstreamNodeId == downstreamNodeId) {
throw new IllegalArgumentException("A drainage edge cannot point to the same node.");
}
if (!Double.isFinite(cost) || cost <= 0D) {
throw new IllegalArgumentException("Drainage edge cost must be finite and positive.");
}
if (contributingSurfaceSources < 0 || contributingUndergroundSources < 0) {
throw new IllegalArgumentException("Contributing source counts cannot be negative.");
}
Objects.requireNonNull(centerline, "centerline");
centerline = List.copyOf(centerline);
if (centerline.size() < 2) {
throw new IllegalArgumentException("A drainage edge requires at least two centerline points.");
}
}
public int totalContributingSources() {
return contributingSurfaceSources + contributingUndergroundSources;
}
}
@@ -0,0 +1,26 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record DrainageNode(
long id,
int x,
int z,
HydrologyTerrainSample terrain,
double potential,
long outletId
) {
public DrainageNode {
Objects.requireNonNull(terrain, "terrain");
if (!Double.isFinite(potential) || potential < 0D) {
throw new IllegalArgumentException("Drainage potential must be finite and non-negative.");
}
if (terrain.ocean()) {
throw new IllegalArgumentException("Drainage nodes must remain landward of the first ocean column.");
}
}
public HydrologyPoint naturalPoint() {
return new HydrologyPoint(x, terrain.naturalHeight(), z);
}
}
@@ -0,0 +1,66 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
public record HydraulicSegment(
long id,
long courseId,
HydrologyFeatureType type,
int upstreamHeadY,
int downstreamHeadY,
int width,
int depth,
boolean fallingFluid,
boolean receivingPool,
List<HydrologyPoint> centerline
) {
public HydraulicSegment {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(centerline, "centerline");
centerline = List.copyOf(centerline);
if (centerline.isEmpty()) {
throw new IllegalArgumentException("A hydraulic segment requires a centerline.");
}
if (upstreamHeadY < downstreamHeadY) {
throw new IllegalArgumentException("Hydraulic heads cannot rise downstream.");
}
if (width <= 0 || depth <= 0) {
throw new IllegalArgumentException("Hydraulic segment width and depth must be positive.");
}
int drop = upstreamHeadY - downstreamHeadY;
if (fallingFluid && drop == 0) {
throw new IllegalArgumentException("Falling fluid requires a positive head transition.");
}
if (drop > 0 && !type.isDrop()) {
throw new IllegalArgumentException("A positive head transition requires a drop segment type.");
}
if (drop == 0 && type.isDrop()) {
throw new IllegalArgumentException("A drop segment type requires a positive head transition.");
}
if (drop > 0 && !fallingFluid) {
if (centerline.getFirst().y() != upstreamHeadY || centerline.getLast().y() != downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must span its declared heads.");
}
int previousHead = upstreamHeadY;
for (HydrologyPoint point : centerline) {
if (point.y() > previousHead || point.y() < downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must descend monotonically.");
}
previousHead = point.y();
}
}
}
public int drop() {
return upstreamHeadY - downstreamHeadY;
}
public HydrologyPoint start() {
return centerline.getFirst();
}
public HydrologyPoint end() {
return centerline.getLast();
}
}
@@ -0,0 +1,8 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyCandidateKind {
SOURCE,
OUTLET,
DEEP_FLUID,
POOL
}
@@ -0,0 +1,24 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyCandidateRejection {
NO_LEGAL_OUTLET,
NO_DRAINAGE_PATH,
ROUTE_LIMIT,
COURSE_TOO_SHORT,
SOURCE_QUOTA,
SOURCE_SPACING,
OUTLET_LIMIT,
OUTLET_LEVEL,
CAVE_CONTAINMENT,
VOLUME_LIMIT,
SURFACE_HEAD_RANGE,
SURFACE_HEAD_DISTRIBUTION,
SURFACE_EXPOSURE,
SURFACE_DROP_UNSUPPORTED,
SURFACE_CORRIDOR_UNSUPPORTED,
SURFACE_TRANSITION_UNSUPPORTED,
SURFACE_SEGMENT_UNSUPPORTED,
SURFACE_SINKHOLE_CLEARANCE,
SURFACE_SHAPE_UNSUPPORTED,
POLICY_EXCLUDED;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,13 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
@FunctionalInterface
public interface HydrologyCaveVoxelViewFactory {
CaveVoxelView create(PlannedSurface plannedSurface);
@FunctionalInterface
interface PlannedSurface {
int resolve(int x, int z, int naturalHeight);
}
}
@@ -0,0 +1,89 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyColumnLayer(
HydrologyFeatureRef feature,
int bedY,
int fluidHeadY,
int ceilingY,
boolean channel,
boolean shore,
boolean grading,
boolean connectedFluid,
boolean fallingFluid,
boolean receivingPool,
boolean terrainOwned,
boolean fluidOwned,
boolean oceanApron,
String profileKey,
String surfaceBiomeKey,
String mouthBiomeKey,
String shoreBiomeKey,
String bankBiomeKey,
String floodedCaveBiomeKey
) {
public HydrologyColumnLayer {
Objects.requireNonNull(feature, "feature");
profileKey = requireKey(profileKey, "profileKey");
surfaceBiomeKey = requireKey(surfaceBiomeKey, "surfaceBiomeKey");
mouthBiomeKey = requireKey(mouthBiomeKey, "mouthBiomeKey");
shoreBiomeKey = requireKey(shoreBiomeKey, "shoreBiomeKey");
bankBiomeKey = requireKey(bankBiomeKey, "bankBiomeKey");
floodedCaveBiomeKey = requireKey(floodedCaveBiomeKey, "floodedCaveBiomeKey");
if (channel && bedY > fluidHeadY) {
throw new IllegalArgumentException("A channel bed cannot be above its fluid head.");
}
if (ceilingY < fluidHeadY) {
throw new IllegalArgumentException("A hydrology carve ceiling cannot be below its fluid head.");
}
if (!feature.type().isUnderground() && !feature.type().isDeepFluid() && ceilingY != fluidHeadY) {
throw new IllegalArgumentException("Surface hydrology ceilings must equal the fluid head.");
}
if (fluidOwned && !channel) {
throw new IllegalArgumentException("Owned fluid requires a channel footprint.");
}
if (fallingFluid && !connectedFluid) {
throw new IllegalArgumentException("Falling fluid must be connected.");
}
if (receivingPool && !connectedFluid) {
throw new IllegalArgumentException("A receiving pool must be connected.");
}
if (oceanApron && (terrainOwned || fluidOwned || grading || shore)) {
throw new IllegalArgumentException("An ocean apron cannot own river writes or grading.");
}
}
public String biomeKey() {
if (grading && !channel && !shore) {
return bankBiomeKey;
}
if (shore) {
return shoreBiomeKey;
}
if (feature.type() == HydrologyFeatureType.MOUTH || feature.type() == HydrologyFeatureType.COASTAL_GROTTO) {
return mouthBiomeKey;
}
if (feature.type().isUnderground() || feature.type().isDeepFluid()) {
return floodedCaveBiomeKey;
}
return surfaceBiomeKey;
}
/** Every owned channel water column is published so steps between stations receive fluid updates. */
public boolean publishesSurfaceFluid() {
return feature.type().isSurface()
&& !oceanApron
&& channel
&& fluidOwned
&& (terrainOwned || fallingFluid)
&& fluidHeadY > bedY;
}
private static String requireKey(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank.");
}
return value.trim();
}
}
@@ -0,0 +1,193 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record HydrologyColumnSample(
int x,
int z,
int naturalHeight,
int seaLevel,
boolean ocean,
String parentBiomeKey,
List<HydrologyColumnLayer> layers
) {
private static final Comparator<HydrologyColumnLayer> LAYER_ORDER = Comparator
.comparingInt((HydrologyColumnLayer layer) -> layer.feature().type().renderPriority())
.thenComparing(Comparator.comparingInt(HydrologyColumnLayer::fluidHeadY).reversed())
.thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id());
private static final Comparator<HydrologyColumnLayer> SURFACE_LAYER_ORDER = Comparator
.comparingInt(HydrologyColumnSample::surfaceRolePriority)
.thenComparingInt((HydrologyColumnLayer layer) -> layer.feature().type().renderPriority())
.thenComparing(Comparator.comparingInt(HydrologyColumnLayer::fluidHeadY).reversed())
.thenComparingInt(HydrologyColumnLayer::bedY)
.thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id());
public HydrologyColumnSample {
if (parentBiomeKey == null || parentBiomeKey.isBlank()) {
throw new IllegalArgumentException("parentBiomeKey must not be blank.");
}
parentBiomeKey = parentBiomeKey.trim();
Objects.requireNonNull(layers, "layers");
if (layers.size() < 2) {
layers = List.copyOf(layers);
} else {
ArrayList<HydrologyColumnLayer> ordered = new ArrayList<>(layers);
ordered.sort(LAYER_ORDER);
layers = List.copyOf(ordered);
}
if (ocean) {
for (HydrologyColumnLayer layer : layers) {
if (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore()) {
throw new IllegalArgumentException("Ocean columns cannot contain river-owned writes or grading.");
}
if (layer.fluidHeadY() > seaLevel) {
throw new IllegalArgumentException("Ocean column fluid cannot be elevated above sea level.");
}
}
}
if (naturalHeight <= seaLevel) {
for (HydrologyColumnLayer layer : layers) {
if (layer.feature().type().isSurface()
&& !layer.oceanApron()
&& (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore())) {
throw new IllegalArgumentException(
"Naturally submerged columns cannot contain owned surface hydrology writes."
);
}
}
}
}
public boolean present() {
return !layers.isEmpty();
}
public boolean hasFeature(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
for (HydrologyColumnLayer layer : layers) {
if (layer.feature().type() == type) {
return true;
}
}
return false;
}
public boolean hasConnectedFluid() {
for (HydrologyColumnLayer layer : layers) {
if (layer.connectedFluid()) {
return true;
}
}
return false;
}
public Optional<HydrologyColumnLayer> primaryLayer() {
return layers.isEmpty() ? Optional.empty() : Optional.of(layers.getFirst());
}
public Optional<HydrologyColumnLayer> primarySurfaceLayer() {
return selectSurfaceLayer(false);
}
public Optional<HydrologyColumnLayer> primarySurfaceFluidLayer() {
return selectSurfaceLayer(true);
}
public Optional<SurfacePublicationCell> surfacePublicationCellAt(int y) {
if (y <= terrainHeight()) {
return Optional.empty();
}
SurfacePublicationCell selected = null;
for (HydrologyColumnLayer layer : layers) {
if (!layer.publishesSurfaceFluid() || y <= layer.bedY() || y > layer.fluidHeadY()) {
continue;
}
HydrologyCaveAction action = layer.fallingFluid() && y < layer.fluidHeadY()
? HydrologyCaveAction.FALLING_FLUID
: HydrologyCaveAction.WET_SOURCE;
SurfacePublicationCell candidate = new SurfacePublicationCell(layer, action);
if (selected == null || surfaceActionPriority(action) < surfaceActionPriority(selected.action())) {
selected = candidate;
}
}
return Optional.ofNullable(selected);
}
public int terrainHeight() {
Optional<HydrologyColumnLayer> layer = primarySurfaceLayer();
if (layer.isEmpty()) {
return naturalHeight;
}
HydrologyColumnLayer primary = layer.get();
if (primary.channel()) {
return primary.bedY();
}
int terrainHeight = primary.bedY();
for (HydrologyColumnLayer candidate : layers) {
if (candidate.feature().type().isSurface()
&& candidate.terrainOwned()
&& !candidate.channel()) {
terrainHeight = Math.max(terrainHeight, candidate.bedY());
}
}
return terrainHeight;
}
public HydrologyRenderSample renderSample() {
ArrayList<HydrologyFeatureRef> features = new ArrayList<>(layers.size());
for (HydrologyColumnLayer layer : layers) {
features.add(layer.feature());
}
return new HydrologyRenderSample(x, z, features);
}
private Optional<HydrologyColumnLayer> selectSurfaceLayer(boolean fluidOnly) {
HydrologyColumnLayer selected = null;
for (HydrologyColumnLayer layer : layers) {
if (layer.oceanApron() || !layer.feature().type().isSurface()) {
continue;
}
if (fluidOnly) {
if (!layer.channel() || !layer.connectedFluid() || !layer.fluidOwned()) {
continue;
}
} else if (!layer.terrainOwned()) {
continue;
}
if (selected == null || SURFACE_LAYER_ORDER.compare(layer, selected) < 0) {
selected = layer;
}
}
return Optional.ofNullable(selected);
}
private static int surfaceRolePriority(HydrologyColumnLayer layer) {
if (layer.channel()) {
return 0;
}
return layer.shore() ? 1 : 2;
}
private static int surfaceActionPriority(HydrologyCaveAction action) {
return action == HydrologyCaveAction.WET_SOURCE ? 0 : 1;
}
public record SurfacePublicationCell(
HydrologyColumnLayer layer,
HydrologyCaveAction action
) {
public SurfacePublicationCell {
Objects.requireNonNull(layer, "layer");
if (action != HydrologyCaveAction.WET_SOURCE
&& action != HydrologyCaveAction.FALLING_FLUID) {
throw new IllegalArgumentException("Surface publication cells must contain fluid.");
}
}
}
}
@@ -0,0 +1,137 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveConflictPolicy;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
final class HydrologyCrossTileCaveAdmission {
private static final Comparator<Claim> CLAIM_PRIORITY = Comparator
.comparing((Claim claim) -> claim.plan().source(), HydrologyCaveConflictPolicy.sourcePriority())
.thenComparing(Claim::profileKey);
private static final Comparator<RankedClaim> BLOCKER_PRIORITY = Comparator
.comparingInt(RankedClaim::ownerRank)
.thenComparing(RankedClaim::claim, CLAIM_PRIORITY)
.thenComparing(RankedClaim::ownerKey);
private HydrologyCrossTileCaveAdmission() {
}
static Result admit(
int currentOwnerRank,
Collection<Claim> currentClaims,
Collection<RankedClaim> blockers
) {
if (currentOwnerRank < 0) {
throw new IllegalArgumentException("currentOwnerRank cannot be negative");
}
ArrayList<Claim> orderedClaims = new ArrayList<>(Objects.requireNonNull(currentClaims, "currentClaims"));
orderedClaims.sort(CLAIM_PRIORITY);
ArrayList<RankedClaim> orderedBlockers = new ArrayList<>(Objects.requireNonNull(blockers, "blockers"));
orderedBlockers.sort(BLOCKER_PRIORITY);
for (RankedClaim blocker : orderedBlockers) {
if (blocker.ownerRank() >= currentOwnerRank) {
throw new IllegalArgumentException("Cross-tile blockers must have a lower owner color rank");
}
}
ArrayList<Rejection> rejections = new ArrayList<>();
for (Claim claim : orderedClaims) {
RankedClaim winner = null;
for (RankedClaim blocker : orderedBlockers) {
if (claim.courseId() == blocker.claim().courseId()) {
throw new IllegalArgumentException("A cave course cannot belong to multiple color-ranked owners");
}
if (!conflicts(claim, blocker.claim())) {
continue;
}
if (winner == null || BLOCKER_PRIORITY.compare(blocker, winner) < 0) {
winner = blocker;
}
}
if (winner != null) {
rejections.add(new Rejection(claim, winner));
}
}
return new Result(rejections);
}
private static boolean conflicts(Claim left, Claim right) {
return HydrologyCaveConflictPolicy.hasIncompatibleOverlap(
left.profileKey(),
left.plan().actions(),
right.profileKey(),
right.plan().actions()
);
}
record Claim(String profileKey, HydrologyCavePlan plan) {
Claim {
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank");
}
profileKey = profileKey.trim();
Objects.requireNonNull(plan, "plan");
if (!plan.accepted()) {
throw new IllegalArgumentException("Cross-tile admission requires accepted cave plans");
}
}
long courseId() {
return plan.source().sourceId();
}
}
record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) {
RankedClaim {
Objects.requireNonNull(ownerKey, "ownerKey");
if (ownerRank < 0) {
throw new IllegalArgumentException("ownerRank cannot be negative");
}
Objects.requireNonNull(claim, "claim");
}
}
record Result(List<Rejection> rejections) {
Result {
rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections"));
}
Map<Long, Long> loserCourseIdsToWinnerSourceIds() {
TreeMap<Long, Long> ordered = new TreeMap<>();
for (Rejection rejection : rejections) {
ordered.put(rejection.loser().courseId(), rejection.winnerSourceId());
}
return Collections.unmodifiableMap(new LinkedHashMap<>(ordered));
}
Rejection rejection(Claim claim) {
Objects.requireNonNull(claim, "claim");
for (Rejection rejection : rejections) {
if (rejection.loser().equals(claim)) {
return rejection;
}
}
return null;
}
}
record Rejection(Claim loser, RankedClaim winner) {
Rejection {
Objects.requireNonNull(loser, "loser");
Objects.requireNonNull(winner, "winner");
}
long winnerSourceId() {
return winner.claim().plan().source().sourceId();
}
}
}
@@ -0,0 +1,163 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
final class HydrologyCrossTileSurfaceAdmission {
private HydrologyCrossTileSurfaceAdmission() {
}
static Result admit(
List<Claim> currentClaims,
List<RankedClaim> blockers,
int minimumSeparation
) {
ArrayList<Rejection> rejections = new ArrayList<>();
for (Claim current : currentClaims) {
for (RankedClaim ranked : blockers) {
Claim blocker = ranked.claim();
if (!conflicts(current, blocker, minimumSeparation)) {
continue;
}
rejections.add(new Rejection(current, blocker.courseId()));
break;
}
}
return new Result(List.copyOf(rejections));
}
private static boolean conflicts(Claim first, Claim second, int minimumSeparation) {
if (first.outletId() == second.outletId()) {
return true;
}
long minimumSeparationSquared = (long) minimumSeparation * minimumSeparation;
if (first.centerline().getFirst().distanceSquared2D(second.centerline().getFirst())
< minimumSeparationSquared) {
return true;
}
if (first.reachesOutlet() && second.reachesOutlet()
&& first.terminal().distanceSquared2D(second.terminal())
< minimumSeparationSquared) {
return true;
}
double clearance = Math.max(4D, (first.maximumWidth() + second.maximumWidth()) / 2D + 4D);
return centerlinesWithin(first.centerline(), second.centerline(), clearance);
}
private static boolean centerlinesWithin(
List<HydrologyPoint> first,
List<HydrologyPoint> second,
double clearance
) {
double clearanceSquared = clearance * clearance;
for (int firstIndex = 1; firstIndex < first.size(); firstIndex++) {
HydrologyPoint firstStart = first.get(firstIndex - 1);
HydrologyPoint firstEnd = first.get(firstIndex);
for (int secondIndex = 1; secondIndex < second.size(); secondIndex++) {
HydrologyPoint secondStart = second.get(secondIndex - 1);
HydrologyPoint secondEnd = second.get(secondIndex);
if (segmentDistanceSquared(firstStart, firstEnd, secondStart, secondEnd) <= clearanceSquared) {
return true;
}
}
}
return false;
}
private static double segmentDistanceSquared(
HydrologyPoint firstStart,
HydrologyPoint firstEnd,
HydrologyPoint secondStart,
HydrologyPoint secondEnd
) {
if (segmentsIntersect(firstStart, firstEnd, secondStart, secondEnd)) {
return 0D;
}
return Math.min(
Math.min(pointSegmentDistanceSquared(firstStart, secondStart, secondEnd),
pointSegmentDistanceSquared(firstEnd, secondStart, secondEnd)),
Math.min(pointSegmentDistanceSquared(secondStart, firstStart, firstEnd),
pointSegmentDistanceSquared(secondEnd, firstStart, firstEnd))
);
}
private static boolean segmentsIntersect(
HydrologyPoint firstStart,
HydrologyPoint firstEnd,
HydrologyPoint secondStart,
HydrologyPoint secondEnd
) {
long firstA = orientation(firstStart, firstEnd, secondStart);
long firstB = orientation(firstStart, firstEnd, secondEnd);
long secondA = orientation(secondStart, secondEnd, firstStart);
long secondB = orientation(secondStart, secondEnd, firstEnd);
return Long.signum(firstA) != Long.signum(firstB)
&& Long.signum(secondA) != Long.signum(secondB);
}
private static long orientation(HydrologyPoint start, HydrologyPoint end, HydrologyPoint point) {
long firstX = end.x() - (long) start.x();
long firstZ = end.z() - (long) start.z();
long secondX = point.x() - (long) start.x();
long secondZ = point.z() - (long) start.z();
return firstX * secondZ - firstZ * secondX;
}
private static double pointSegmentDistanceSquared(
HydrologyPoint point,
HydrologyPoint start,
HydrologyPoint end
) {
double deltaX = end.x() - start.x();
double deltaZ = end.z() - start.z();
double lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
if (lengthSquared == 0D) {
return point.distanceSquared2D(start);
}
double progress = ((point.x() - start.x()) * deltaX + (point.z() - start.z()) * deltaZ)
/ lengthSquared;
progress = Math.max(0D, Math.min(1D, progress));
double closestX = start.x() + deltaX * progress;
double closestZ = start.z() + deltaZ * progress;
double pointX = point.x() - closestX;
double pointZ = point.z() - closestZ;
return pointX * pointX + pointZ * pointZ;
}
record Claim(
long courseId,
long outletId,
HydrologyPoint terminal,
boolean reachesOutlet,
int maximumWidth,
List<HydrologyPoint> centerline
) {
Claim {
Objects.requireNonNull(terminal, "terminal");
centerline = List.copyOf(Objects.requireNonNull(centerline, "centerline"));
if (maximumWidth < 1 || centerline.size() < 2) {
throw new IllegalArgumentException("Surface claims require a positive width and centerline.");
}
}
}
record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) {
RankedClaim {
Objects.requireNonNull(ownerKey, "ownerKey");
Objects.requireNonNull(claim, "claim");
}
}
record Rejection(Claim loser, long winnerCourseId) {
Rejection {
Objects.requireNonNull(loser, "loser");
}
}
record Result(List<Rejection> rejections) {
Result {
rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections"));
}
}
}
@@ -0,0 +1,19 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyDiagnosticCandidate(
long id,
HydrologyCandidateKind kind,
HydrologyFeatureType projectedType,
HydrologyPoint point,
HydrologyCandidateRejection rejection,
int detail
) {
public HydrologyDiagnosticCandidate {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(projectedType, "projectedType");
Objects.requireNonNull(point, "point");
Objects.requireNonNull(rejection, "rejection");
}
}
@@ -0,0 +1,28 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public record HydrologyDiagnosticRenderSample(
int x,
int z,
List<HydrologyDiagnosticCandidate> candidates
) {
private static final Comparator<HydrologyDiagnosticCandidate> CANDIDATE_ORDER = Comparator
.comparing(HydrologyDiagnosticCandidate::kind)
.thenComparing(HydrologyDiagnosticCandidate::rejection)
.thenComparingLong(HydrologyDiagnosticCandidate::id);
public HydrologyDiagnosticRenderSample {
Objects.requireNonNull(candidates, "candidates");
ArrayList<HydrologyDiagnosticCandidate> ordered = new ArrayList<>(candidates);
ordered.sort(CANDIDATE_ORDER);
candidates = List.copyOf(ordered);
}
public boolean present() {
return !candidates.isEmpty();
}
}
@@ -0,0 +1,100 @@
package art.arcane.iris.engine.hydrology;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public record HydrologyFeatureQuery(Set<HydrologyFeatureType> types, String profileKey) {
private static final Set<HydrologyFeatureType> SURFACE = Set.of(
HydrologyFeatureType.SURFACE_POOL,
HydrologyFeatureType.RIFFLE,
HydrologyFeatureType.CASCADE,
HydrologyFeatureType.WATERFALL,
HydrologyFeatureType.MOUTH
);
private static final Set<HydrologyFeatureType> UNDERGROUND = Set.of(
HydrologyFeatureType.UNDERGROUND_POOL,
HydrologyFeatureType.UNDERGROUND_DROP,
HydrologyFeatureType.SINKHOLE
);
private static final Set<HydrologyFeatureType> GROTTOS = Set.of(
HydrologyFeatureType.COASTAL_GROTTO,
HydrologyFeatureType.INLAND_GROTTO
);
private static final Set<HydrologyFeatureType> DEEP = Set.of(
HydrologyFeatureType.DEEP_POOL,
HydrologyFeatureType.DEEP_CHANNEL
);
private static final Set<HydrologyFeatureType> PROFILED = Set.of(
HydrologyFeatureType.DEEP_POOL,
HydrologyFeatureType.DEEP_CHANNEL,
HydrologyFeatureType.STANDING_POOL
);
private static final Map<String, Set<HydrologyFeatureType>> BUILT_INS = builtIns();
public HydrologyFeatureQuery {
types = Set.copyOf(types);
if (types.isEmpty()) {
throw new IllegalArgumentException("Hydrology feature query requires at least one feature type.");
}
profileKey = profileKey == null || profileKey.isBlank() ? null : profileKey.trim();
}
public static HydrologyFeatureQuery parse(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Hydrology feature type must not be blank.");
}
String normalized = value.trim();
if (normalized.regionMatches(true, 0, "type=", 0, 5)) {
normalized = normalized.substring(5).trim();
}
String key = normalizeKeyword(normalized);
Set<HydrologyFeatureType> builtInTypes = BUILT_INS.get(key);
return builtInTypes == null
? new HydrologyFeatureQuery(PROFILED, normalized)
: new HydrologyFeatureQuery(builtInTypes, null);
}
public static List<String> suggestions(Collection<String> deepFluidIds) {
LinkedHashSet<String> suggestions = new LinkedHashSet<>(BUILT_INS.keySet());
TreeSet<String> orderedIds = new TreeSet<>();
if (deepFluidIds != null) {
for (String id : deepFluidIds) {
if (id != null && !id.isBlank() && !isReservedKeyword(id)) {
orderedIds.add(id.trim());
}
}
}
suggestions.addAll(orderedIds);
return List.copyOf(suggestions);
}
public static boolean isReservedKeyword(String value) {
return value != null && !value.isBlank() && BUILT_INS.containsKey(normalizeKeyword(value.trim()));
}
private static String normalizeKeyword(String value) {
return value.toLowerCase(Locale.ROOT).replace('-', '_');
}
private static Map<String, Set<HydrologyFeatureType>> builtIns() {
LinkedHashMap<String, Set<HydrologyFeatureType>> builtIns = new LinkedHashMap<>();
builtIns.put("surface", SURFACE);
builtIns.put("waterfall", Set.of(HydrologyFeatureType.WATERFALL));
builtIns.put("sinkhole", Set.of(HydrologyFeatureType.SINKHOLE));
builtIns.put("underground", UNDERGROUND);
builtIns.put("grotto", GROTTOS);
builtIns.put("coastal_grotto", Set.of(HydrologyFeatureType.COASTAL_GROTTO));
builtIns.put("inland_grotto", Set.of(HydrologyFeatureType.INLAND_GROTTO));
builtIns.put("mouth", Set.of(HydrologyFeatureType.MOUTH));
builtIns.put("deep", DEEP);
builtIns.put("pool", Set.of(HydrologyFeatureType.STANDING_POOL));
return Collections.unmodifiableMap(builtIns);
}
}
@@ -0,0 +1,27 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyFeatureRef(
long id,
HydrologyFeatureType type,
long courseId,
long segmentId,
int x,
int y,
int z,
int flowDeltaX,
int flowDeltaZ,
boolean source
) {
public HydrologyFeatureRef {
Objects.requireNonNull(type, "type");
if (flowDeltaX < -1 || flowDeltaX > 1 || flowDeltaZ < -1 || flowDeltaZ > 1) {
throw new IllegalArgumentException("Flow direction components must be between -1 and 1.");
}
}
public HydrologyPoint point() {
return new HydrologyPoint(x, y, z);
}
}
@@ -0,0 +1,67 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyFeatureType {
SURFACE_POOL,
RIFFLE,
CASCADE,
WATERFALL,
// No planner produces a ridge bore any more. The constant stays because its ordinal seeds the
// segment, outlet and feature ids of every constant after it on the cave and deep-fluid paths.
RIDGE_BORE,
UNDERGROUND_POOL,
UNDERGROUND_DROP,
SINKHOLE,
COASTAL_GROTTO,
INLAND_GROTTO,
MOUTH,
DEEP_POOL,
DEEP_CHANNEL,
// A standing surface pool (lava or any fluid) with no course: a bowl cut into the ground with a lip.
STANDING_POOL;
public boolean isSurface() {
return switch (this) {
case SURFACE_POOL, RIFFLE, CASCADE, WATERFALL, MOUTH, STANDING_POOL -> true;
default -> false;
};
}
public boolean isUnderground() {
return switch (this) {
case RIDGE_BORE, UNDERGROUND_POOL, UNDERGROUND_DROP, SINKHOLE, COASTAL_GROTTO, INLAND_GROTTO -> true;
default -> false;
};
}
public boolean isGrotto() {
return this == COASTAL_GROTTO || this == INLAND_GROTTO;
}
public boolean isDeepFluid() {
return this == DEEP_POOL || this == DEEP_CHANNEL;
}
public boolean isDrop() {
return this == RIFFLE || this == CASCADE || this == WATERFALL
|| this == UNDERGROUND_DROP || this == SINKHOLE;
}
int renderPriority() {
return switch (this) {
case WATERFALL -> 0;
case SINKHOLE -> 1;
case CASCADE -> 2;
case RIFFLE -> 3;
case COASTAL_GROTTO -> 4;
case INLAND_GROTTO -> 5;
case MOUTH -> 6;
case RIDGE_BORE -> 7;
case UNDERGROUND_DROP -> 8;
case UNDERGROUND_POOL -> 9;
case DEEP_CHANNEL -> 10;
case DEEP_POOL -> 11;
case SURFACE_POOL -> 12;
case STANDING_POOL -> 13;
};
}
}
@@ -0,0 +1,104 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
@FunctionalInterface
public interface HydrologyGeometrySampler {
int sample(Request request);
default int sample(
Field field,
String profileKey,
int x,
int z,
int minimum,
int maximum
) {
return sample(field, profileKey, x, z, 0L, minimum, maximum);
}
default int sample(
Field field,
String profileKey,
int x,
int z,
long stableId,
int minimum,
int maximum
) {
Request request = new Request(field, profileKey, x, z, stableId, minimum, maximum);
int value = sample(request);
if (value < minimum || value > maximum) {
throw new IllegalStateException(
"Hydrology geometry sampler returned " + value + " outside " + minimum + ".." + maximum
+ " for " + field + " at " + x + "," + z + "."
);
}
return value;
}
static HydrologyGeometrySampler deterministic(HydrologyTerrainSampler terrainSampler) {
Objects.requireNonNull(terrainSampler, "terrainSampler");
return request -> {
if (request.field() == Field.UNDERGROUND_FLUID_LEVEL) {
HydrologyTerrainSample terrain = Objects.requireNonNull(
terrainSampler.sample(request.x(), request.z()),
"Hydrology terrain sampler returned null at " + request.x() + "," + request.z()
);
return Math.max(request.minimum(), Math.min(request.maximum(), terrain.caveFluidY()));
}
if (request.field() == Field.SURFACE_WIDTH
|| request.field() == Field.SURFACE_DEPTH
|| request.field() == Field.UNDERGROUND_WIDTH
|| request.field() == Field.UNDERGROUND_DEPTH) {
return request.minimum();
}
long stable = switch (request.field()) {
case SURFACE_BLEND_WIDTH -> HydrologyHash.mix(
request.stableId(),
request.x(),
request.z(),
0x424c454e44L
);
case UNDERGROUND_HEADROOM -> HydrologyHash.mix(
request.stableId(),
request.x(),
request.z(),
0x48454144L
);
case DEEP_FLUID_HEIGHT -> HydrologyHash.mix(request.stableId(), 0x484549474854L);
default -> throw new IllegalStateException("Missing deterministic geometry rule for " + request.field() + ".");
};
return HydrologyHash.between(stable, request.minimum(), request.maximum());
};
}
enum Field {
SURFACE_WIDTH,
SURFACE_DEPTH,
SURFACE_BLEND_WIDTH,
UNDERGROUND_FLUID_LEVEL,
UNDERGROUND_WIDTH,
UNDERGROUND_DEPTH,
UNDERGROUND_HEADROOM,
DEEP_FLUID_HEIGHT
}
record Request(
Field field,
String profileKey,
int x,
int z,
long stableId,
int minimum,
int maximum
) {
public Request {
field = Objects.requireNonNull(field, "field");
profileKey = profileKey == null ? "" : profileKey;
if (minimum > maximum) {
throw new IllegalArgumentException("Hydrology geometry sample bounds are reversed.");
}
}
}
}
@@ -0,0 +1,43 @@
package art.arcane.iris.engine.hydrology;
public final class HydrologyHash {
private HydrologyHash() {
}
public static long mix(long seed, long... values) {
long mixed = avalanche(seed ^ 0x9e3779b97f4a7c15L);
for (long value : values) {
mixed = avalanche(mixed ^ avalanche(value + 0x9e3779b97f4a7c15L));
}
return mixed;
}
public static long text(String value) {
long hash = 0xcbf29ce484222325L;
for (int index = 0; index < value.length(); index++) {
hash ^= value.charAt(index);
hash *= 0x100000001b3L;
}
return avalanche(hash);
}
public static double unit(long value) {
return (avalanche(value) >>> 11) * 0x1.0p-53;
}
public static int between(long value, int minimum, int maximum) {
if (minimum > maximum) {
throw new IllegalArgumentException("minimum cannot exceed maximum.");
}
long span = (long) maximum - minimum + 1L;
return minimum + (int) Long.remainderUnsigned(avalanche(value), span);
}
private static long avalanche(long value) {
value ^= value >>> 30;
value *= 0xbf58476d1ce4e5b9L;
value ^= value >>> 27;
value *= 0x94d049bb133111ebL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,5 @@
package art.arcane.iris.engine.hydrology;
public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainSampler {
HydrologyTerrainSample sampleBasis(int blockX, int blockZ);
}
@@ -0,0 +1,48 @@
package art.arcane.iris.engine.hydrology;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class HydrologyObservedPlannedSurface implements HydrologyCaveVoxelViewFactory.PlannedSurface {
private final HydrologyCaveVoxelViewFactory.PlannedSurface delegate;
private final Long2ObjectOpenHashMap<ArrayList<Observation>> observations;
public HydrologyObservedPlannedSurface(HydrologyCaveVoxelViewFactory.PlannedSurface delegate) {
this.delegate = Objects.requireNonNull(delegate);
this.observations = new Long2ObjectOpenHashMap<>();
}
@Override
public int resolve(int x, int z, int naturalHeight) {
int resolvedHeight = delegate.resolve(x, z, naturalHeight);
long key = RiverFootprint.pack(x, z);
ArrayList<Observation> column = observations.get(key);
if (column == null) {
column = new ArrayList<>(1);
observations.put(key, column);
}
for (Observation observation : column) {
if (observation.naturalHeight() == naturalHeight) {
return resolvedHeight;
}
}
column.add(new Observation(x, z, naturalHeight, resolvedHeight));
return resolvedHeight;
}
public List<Observation> observationsAt(int x, int z) {
ArrayList<Observation> column = observations.get(RiverFootprint.pack(x, z));
return column == null ? List.of() : List.copyOf(column);
}
public record Observation(
int x,
int z,
int naturalHeight,
int resolvedHeight
) {
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
final class HydrologyOceanBoundaryRefiner {
private HydrologyOceanBoundaryRefiner() {
}
static Result refine(
List<HydrologyPoint> crossing,
HydrologyTerrainSampler terrainSampler,
HydrologyRoutingTerrainSampler routingSampler
) {
Objects.requireNonNull(crossing, "crossing");
Objects.requireNonNull(terrainSampler, "terrainSampler");
Objects.requireNonNull(routingSampler, "routingSampler");
if (crossing.size() < 2) {
return null;
}
HydrologyPoint previous = crossing.getFirst();
for (int index = 1; index < crossing.size(); index++) {
HydrologyPoint point = crossing.get(index);
HydrologyRoutingTerrainSampler.NaturalClassification classification =
routingSampler.classifyNatural(point.x(), point.z());
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.UNAVAILABLE) {
return null;
}
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN) {
HydrologyTerrainSample landwardTerrain = terrainSampler.sample(previous.x(), previous.z());
if (landwardTerrain == null || landwardTerrain.ocean()) {
return null;
}
return new Result(previous, point, landwardTerrain);
}
previous = point;
}
return null;
}
record Result(
HydrologyPoint landwardPoint,
HydrologyPoint oceanPoint,
HydrologyTerrainSample landwardTerrain
) {
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,586 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
public record HydrologyPlannerSettings(
int seaLevel,
Routing routing,
Surface surface,
Hydraulics hydraulics,
Underground underground,
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids,
List<SurfacePool> surfacePools
) {
private static final long PLAN_FORMAT_REVISION = 6L;
private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4;
public HydrologyPlannerSettings {
if (seaLevel < -2048 || seaLevel > 2048) {
throw new IllegalArgumentException("seaLevel is outside the supported planning range.");
}
if (routing == null || surface == null || hydraulics == null || underground == null
|| outlets == null || geometry == null) {
throw new IllegalArgumentException("Hydrology planner settings cannot contain null sections.");
}
deepFluids = deepFluids == null ? List.of() : List.copyOf(deepFluids);
surfacePools = surfacePools == null ? List.of() : List.copyOf(surfacePools);
int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools);
if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) {
throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period.");
}
}
public static HydrologyPlannerSettings defaults() {
Source surfaceSources = new Source(true, 0.5D, 88, 0, 1, 384);
Source undergroundSources = new Source(true, 0.25D, Integer.MIN_VALUE, 0, 1, 512);
return new HydrologyPlannerSettings(
63,
new Routing(2048, 64, 8192, 8192, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0),
new Surface(true, surfaceSources, 4, 8, 2, 4, 10, 1.5D, Banks.defaults()),
new Hydraulics(8),
new Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true),
new Outlets(
true,
new Grotto(true, 18, 8, 8, 32768),
new Grotto(true, 18, 8, 8, 32768),
false,
12,
64,
8,
12
),
Geometry.defaults(),
List.of(),
List.of()
);
}
public long fingerprint() {
long hash = HydrologyHash.mix(
0x485944524f4c4f47L,
PLAN_FORMAT_REVISION,
routing.hashCode(),
surface.hashCode(),
hydraulics.hashCode(),
underground.hashCode(),
outlets.hashCode(),
geometry.hashCode()
);
for (DeepFluid deepFluid : deepFluids) {
hash = HydrologyHash.mix(hash, deepFluid.hashCode(), HydrologyHash.text(deepFluid.id()));
}
for (SurfacePool pool : surfacePools) {
hash = HydrologyHash.mix(hash, pool.hashCode(), HydrologyHash.text(pool.id()));
}
return hash;
}
public int publicationRadius() {
return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools);
}
int crossTileColorPeriod() {
return crossTileColorPeriod(publicationRadius(), routing.tileSize());
}
private static int publicationRadius(
Routing routing,
Surface surface,
Underground underground,
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids,
List<SurfacePool> surfacePools
) {
int alignedHalo = Math.floorDiv(
Math.min(
routing.maximumRouteLength(),
Math.multiplyExact(routing.sampleSpacing(), 2)
),
routing.sampleSpacing()
) * routing.sampleSpacing();
int radius = 0;
int routeDisplacement = Math.multiplyExact(routing.sampleSpacing(), 3);
if (surface.enabled() && surface.sources().enabled()) {
int blendWidth = surface.banks().maximumBlendWidth();
int surfaceRadius = (int) StrictMath.ceil(
surface.maximumWidth() / 2D + surface.shoreWidth() + blendWidth
);
surfaceRadius = Math.max(
surfaceRadius,
(int) StrictMath.ceil(geometry.drops().basinWidth(
geometry.drops().flowWidth(surface.maximumWidth())
) / 2D + surface.shoreWidth() + blendWidth)
);
surfaceRadius = Math.max(surfaceRadius, outlets.coastalGrotto().horizontalRadius());
surfaceRadius = Math.max(surfaceRadius, outlets.inlandGrotto().horizontalRadius());
radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(surfaceRadius, routeDisplacement)));
}
if (underground.enabled() && underground.sources().enabled()) {
int undergroundRadius = (int) StrictMath.ceil(underground.maximumWidth() / 2D);
undergroundRadius = Math.max(
undergroundRadius,
(int) StrictMath.ceil(geometry.drops().basinWidth(
geometry.drops().flowWidth(underground.maximumWidth())
) / 2D)
);
undergroundRadius = Math.max(undergroundRadius, outlets.coastalGrotto().horizontalRadius());
undergroundRadius = Math.max(undergroundRadius, outlets.inlandGrotto().horizontalRadius());
radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(undergroundRadius, routeDisplacement)));
}
for (DeepFluid deepFluid : deepFluids) {
if (!deepFluid.enabled() || deepFluid.maximumPerTile() == 0) {
continue;
}
int poolReach = deepFluid.containedPools() ? deepFluid.maximumHorizontalRadius() : 0;
int channelReach = deepFluid.shortChannels()
? Math.addExact(deepFluid.maximumChannelLength(), (int) StrictMath.ceil(deepFluid.channelWidth() / 2D))
: 0;
radius = Math.max(radius, Math.max(poolReach, channelReach));
}
for (SurfacePool pool : surfacePools) {
if (!pool.enabled()) {
continue;
}
radius = Math.max(radius, pool.maximumRadius() + (int) StrictMath.ceil(surface.shoreWidth())
+ surface.banks().maximumBlendWidth());
}
return radius;
}
private static int crossTileColorPeriod(int publicationRadius, int tileSize) {
long actionReach = Math.addExact((long) publicationRadius, 1L);
return Math.toIntExact(Math.addExact(Math.floorDiv(Math.multiplyExact(2L, actionReach), tileSize), 2L));
}
public record Routing(
int tileSize,
int sampleSpacing,
int maximumRouteNodes,
int maximumRouteLength,
int minimumSurfaceCourseLength,
int minimumUndergroundCourseLength,
double valleyPreference,
double uphillPenalty,
double slopePenalty,
double confluenceAttraction,
double lengthPreference,
int tributaries
) {
public Routing {
if (tileSize < 32 || sampleSpacing < 4 || tileSize % sampleSpacing != 0) {
throw new IllegalArgumentException("Routing sizes must form an exact bounded tile lattice.");
}
int latticeWidth = tileSize / sampleSpacing + 1;
int halo = Math.min(maximumRouteLength, Math.multiplyExact(sampleSpacing, 2));
int alignedHalo = Math.floorDiv(halo, sampleSpacing) * sampleSpacing;
int expandedWidth = latticeWidth + alignedHalo * 2 / sampleSpacing;
long latticeNodes = (long) expandedWidth * expandedWidth;
if (maximumRouteNodes < latticeNodes || maximumRouteNodes > 1_000_000 || maximumRouteLength < 1) {
throw new IllegalArgumentException("maximumRouteNodes must contain the complete lattice and remain bounded.");
}
if (minimumSurfaceCourseLength < 0 || minimumSurfaceCourseLength > 32_768
|| minimumUndergroundCourseLength < 0 || minimumUndergroundCourseLength > 32_768) {
throw new IllegalArgumentException("Hydrology course lengths are invalid.");
}
if (minimumSurfaceCourseLength > maximumRouteLength
|| minimumUndergroundCourseLength > maximumRouteLength) {
throw new IllegalArgumentException("Minimum course lengths cannot exceed maximum route length.");
}
requireFiniteNonNegative(valleyPreference, "valleyPreference");
requireFiniteNonNegative(uphillPenalty, "uphillPenalty");
requireFiniteNonNegative(slopePenalty, "slopePenalty");
requireFiniteNonNegative(confluenceAttraction, "confluenceAttraction");
requireFiniteNonNegative(lengthPreference, "lengthPreference");
if (tributaries < 0) {
throw new IllegalArgumentException("tributaries must not be negative.");
}
}
// Route refinement is derived from the lattice, never authored.
public static int refinementSpacing(int sampleSpacing) {
if (sampleSpacing % 4 == 0) {
return 4;
}
return sampleSpacing % 2 == 0 ? 2 : 1;
}
public int refinementSpacing() {
return refinementSpacing(sampleSpacing);
}
public int minimumCourseLength(boolean surface) {
return surface ? minimumSurfaceCourseLength : minimumUndergroundCourseLength;
}
}
public record Source(
boolean enabled,
double density,
int minimumElevation,
int minimumPerTile,
int maximumPerTile,
int minimumSpacing
) {
public Source {
if (!Double.isFinite(density) || density < 0D || density > 64D) {
throw new IllegalArgumentException("Source density must be between 0 and 64 expected sources per tile.");
}
if (minimumPerTile < 0 || maximumPerTile < minimumPerTile || maximumPerTile > 4096) {
throw new IllegalArgumentException("Source tile quotas are invalid.");
}
if (minimumSpacing < 0) {
throw new IllegalArgumentException("Source spacing cannot be negative.");
}
}
}
public record Surface(
boolean enabled,
Source sources,
int minimumWidth,
int maximumWidth,
int minimumDepth,
int maximumDepth,
int maximumIncision,
double shoreWidth,
Banks banks
) {
public Surface {
if (sources == null || banks == null) {
throw new IllegalArgumentException("Surface source and bank settings are required.");
}
requireRange(minimumWidth, maximumWidth, 1, 256, "surface width");
requireRange(minimumDepth, maximumDepth, 1, 64, "surface depth");
if (maximumIncision < 0 || !Double.isFinite(shoreWidth) || shoreWidth < 0D || shoreWidth > 32D) {
throw new IllegalArgumentException("Surface incision and shore width are invalid.");
}
}
}
public record Banks(
int inset,
int freeboard,
double blendSlope,
int minimumBlendWidth,
int maximumBlendWidth,
double roughness,
int roughnessWavelength,
int cascadeRun,
int waterfallMinimumDrop,
double mouthFlareRatio,
double springWidthRatio,
int springLength,
boolean exposeCutStrata
) {
// Structural invariants only; authoring bounds live in the pack validator.
public Banks {
if (inset < 0 || freeboard < 0
|| !Double.isFinite(blendSlope) || blendSlope <= 0D
|| minimumBlendWidth < 1 || maximumBlendWidth < minimumBlendWidth
|| !Double.isFinite(roughness) || roughness < 0D || roughness > 1D
|| roughnessWavelength < 1
|| cascadeRun < 1
|| waterfallMinimumDrop < 1
|| !Double.isFinite(mouthFlareRatio) || mouthFlareRatio < 1D
|| !Double.isFinite(springWidthRatio) || springWidthRatio < 1D
|| springLength < 1) {
throw new IllegalArgumentException("Surface bank settings are invalid.");
}
}
public static Banks defaults() {
return new Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true);
}
}
/** A standing surface pool profile: a bowl of the given radius and depth, filled with its own fluid. */
public record SurfacePool(
String id,
boolean enabled,
double density,
int spacing,
int minimumRadius,
int maximumRadius,
int depth,
int maximumPerTile,
String biomeKey
) {
public SurfacePool {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Surface pool id must not be blank.");
}
id = id.trim();
if (!Double.isFinite(density) || density < 0D || spacing < 1
|| minimumRadius < 1 || maximumRadius < minimumRadius || depth < 1 || maximumPerTile < 0) {
throw new IllegalArgumentException("Surface pool settings are invalid.");
}
biomeKey = biomeKey == null || biomeKey.isBlank() ? null : biomeKey.trim();
}
}
// The routing gates read one hydraulic threshold: the drop that makes a reach a waterfall.
public record Hydraulics(int waterfallMinimumDrop) {
public Hydraulics {
if (waterfallMinimumDrop < 1) {
throw new IllegalArgumentException("waterfallMinimumDrop must be at least one block.");
}
}
}
public record Underground(
boolean enabled,
Source sources,
int minimumFluidY,
int maximumFluidY,
int minimumWidth,
int maximumWidth,
int minimumDepth,
int maximumDepth,
int minimumHeadroom,
int maximumHeadroom,
boolean connectToExistingCaves
) {
public Underground {
if (sources == null || minimumFluidY > maximumFluidY) {
throw new IllegalArgumentException("Underground source and height settings are invalid.");
}
requireRange(minimumWidth, maximumWidth, 1, 256, "underground width");
requireRange(minimumDepth, maximumDepth, 1, 64, "underground depth");
requireRange(minimumHeadroom, maximumHeadroom, 1, 128, "underground headroom");
}
}
public record Outlets(
boolean oceanEnabled,
Grotto coastalGrotto,
Grotto inlandGrotto,
boolean surfaceSinkholesEnabled,
int coastalCliffMinimumHeight,
int mouthLevelingDistance,
int maximumOceanApron,
int maximumPerTile
) {
public Outlets {
if (coastalGrotto == null || inlandGrotto == null) {
throw new IllegalArgumentException("Coastal and inland grotto settings are required.");
}
if (surfaceSinkholesEnabled && !inlandGrotto.enabled()) {
throw new IllegalArgumentException("Surface sinkholes require inland grotto outlets.");
}
if (coastalCliffMinimumHeight < 0 || maximumOceanApron < 0 || maximumOceanApron > 64
|| mouthLevelingDistance < 0 || maximumPerTile < 1 || maximumPerTile > 256) {
throw new IllegalArgumentException("Outlet bounds are invalid.");
}
}
}
public record Grotto(
boolean enabled,
int horizontalRadius,
int verticalRadius,
int headroom,
int maximumVolume
) {
public Grotto {
if (horizontalRadius < 1 || horizontalRadius > 128 || verticalRadius < 1 || verticalRadius > 128
|| headroom < 1 || headroom > 128 || maximumVolume < 1 || maximumVolume > 1_048_576) {
throw new IllegalArgumentException("Grotto bounds are invalid.");
}
}
}
public record Geometry(
Meanders meanders,
ChannelShape surface,
ChannelShape underground,
ChannelShape grottos,
Drops drops
) {
public Geometry {
if (meanders == null || surface == null || underground == null || grottos == null || drops == null) {
throw new IllegalArgumentException("Hydrology geometry settings cannot contain null sections.");
}
}
public static Geometry defaults() {
ChannelShape channel = new ChannelShape(2.4D, 0.28D, 0.24D, 11);
return new Geometry(
new Meanders(64, 12, 0.34D, 0.42D, 0.48D, 1, 82D),
channel,
channel,
channel,
new Drops(2, 1.4D, 2, 0.45D, 2, 1.8D, 8)
);
}
}
public record Meanders(
int primaryWavelength,
int detailWavelength,
double primaryStrength,
double detailStrength,
double maximumOffsetRatio,
int smoothingPasses,
double maximumTurnDegrees
) {
public Meanders {
if (primaryWavelength < 8 || primaryWavelength > 512
|| detailWavelength < 4 || detailWavelength > 128
|| detailWavelength > primaryWavelength
|| smoothingPasses < 0 || smoothingPasses > 4
|| !Double.isFinite(primaryStrength) || primaryStrength < 0D || primaryStrength > 2D
|| !Double.isFinite(detailStrength) || detailStrength < 0D || detailStrength > 2D
|| !Double.isFinite(maximumOffsetRatio) || maximumOffsetRatio < 0D || maximumOffsetRatio > 1D
|| !Double.isFinite(maximumTurnDegrees)
|| maximumTurnDegrees < 10D || maximumTurnDegrees > 150D) {
throw new IllegalArgumentException("Hydrology meander geometry is invalid.");
}
}
}
public record ChannelShape(
double bedRoundness,
double bedRoughness,
double wallRoughness,
int roughnessWavelength
) {
public ChannelShape {
if (!Double.isFinite(bedRoundness) || bedRoundness < 1D || bedRoundness > 6D
|| !Double.isFinite(bedRoughness) || bedRoughness < 0D || bedRoughness > 1D
|| !Double.isFinite(wallRoughness) || wallRoughness < 0D || wallRoughness > 1D
|| roughnessWavelength < 3 || roughnessWavelength > 128) {
throw new IllegalArgumentException("Hydrology channel shape is invalid.");
}
}
}
public record Drops(
int cascadeRunPerBlock,
double cascadeExponent,
int maximumCascadeStep,
double flowWidthRatio,
int maximumFlowDepth,
double basinWidthRatio,
int maximumBasinDepth
) {
public Drops {
if (cascadeRunPerBlock < 1 || cascadeRunPerBlock > 16
|| !Double.isFinite(cascadeExponent) || cascadeExponent < 0.25D || cascadeExponent > 6D
|| maximumCascadeStep < 1 || maximumCascadeStep > 4
|| !Double.isFinite(flowWidthRatio) || flowWidthRatio < 0.25D || flowWidthRatio > 1D
|| maximumFlowDepth < 1 || maximumFlowDepth > 16
|| !Double.isFinite(basinWidthRatio) || basinWidthRatio < 1D || basinWidthRatio > 4D
|| maximumBasinDepth < maximumFlowDepth || maximumBasinDepth > 32) {
throw new IllegalArgumentException("Hydrology drop geometry is invalid.");
}
}
public int flowWidth(int channelWidth) {
return Math.max(1, Math.min(channelWidth, (int) StrictMath.ceil(channelWidth * flowWidthRatio)));
}
public int stepLimit(HydrologyFeatureType type) {
return type.isSurface() ? 1 : maximumCascadeStep;
}
public int flowDepth(int channelDepth) {
return Math.max(1, Math.min(channelDepth, maximumFlowDepth));
}
public int basinWidth(int flowWidth) {
return Math.max(flowWidth, (int) StrictMath.ceil(flowWidth * basinWidthRatio));
}
public int basinDepth(int flowDepth, int drop) {
int erodedDepth = Math.addExact(flowDepth, Math.max(1, (int) StrictMath.ceil(StrictMath.sqrt(drop))));
return Math.max(flowDepth, Math.min(maximumBasinDepth, erodedDepth));
}
}
public record DeepFluid(
String id,
boolean enabled,
double density,
int spacing,
int minimumY,
int maximumY,
int minimumHorizontalRadius,
int maximumHorizontalRadius,
int minimumVerticalRadius,
int maximumVerticalRadius,
int minimumChannelLength,
int maximumChannelLength,
int channelWidth,
int channelDepth,
int headroom,
int maximumVolume,
int maximumPerTile,
boolean containedPools,
boolean shortChannels
) {
public DeepFluid {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Deep-fluid id must not be blank.");
}
id = id.trim();
if (!Double.isFinite(density) || density < 0D || density > 64D || spacing < 16 || minimumY > maximumY) {
throw new IllegalArgumentException("Deep-fluid density or height range is invalid.");
}
requireRange(minimumHorizontalRadius, maximumHorizontalRadius, 1, 128, "deep-fluid horizontal radius");
requireRange(minimumVerticalRadius, maximumVerticalRadius, 1, 128, "deep-fluid vertical radius");
requireRange(minimumChannelLength, maximumChannelLength, 0, 32_768, "deep-fluid channel length");
if (channelWidth < 1 || channelWidth > 256 || channelDepth < 1 || channelDepth > 64 || headroom < 1
|| maximumVolume < 64 || maximumVolume > 1_048_576
|| maximumPerTile < 0 || maximumPerTile > 1024) {
throw new IllegalArgumentException("Deep-fluid channel bounds are invalid.");
}
long poolEnvelope = ellipsoidVolume(
maximumHorizontalRadius,
Math.max(channelDepth, maximumVerticalRadius),
headroom
);
if (enabled && containedPools && poolEnvelope > maximumVolume) {
throw new IllegalArgumentException("Deep-fluid maximumVolume cannot contain its configured pool ellipsoid.");
}
if (enabled && !containedPools && !shortChannels) {
throw new IllegalArgumentException("An enabled deep fluid requires a pool or channel.");
}
if (enabled && shortChannels && minimumChannelLength < 1) {
throw new IllegalArgumentException("Enabled deep-fluid channels require a positive minimum length.");
}
}
}
private static void requireRange(int minimum, int maximum, int lower, int upper, String name) {
if (minimum < lower || maximum < minimum || maximum > upper) {
throw new IllegalArgumentException(name + " range is invalid.");
}
}
private static void requireFiniteNonNegative(double value, String name) {
if (!Double.isFinite(value) || value < 0D) {
throw new IllegalArgumentException(name + " must be finite and non-negative.");
}
}
static long ellipsoidVolume(int radius, int lowerExtent, int upperExtent) {
long volume = 0L;
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
for (int deltaX = -radius; deltaX <= radius; deltaX++) {
double distance = StrictMath.hypot(deltaX, deltaZ);
if (distance > radius + 0.25D) {
continue;
}
double normalized = Math.min(1D, distance / Math.max(1D, radius));
double scale = StrictMath.sqrt(Math.max(0D, 1D - normalized * normalized));
int lower = (int) StrictMath.ceil(lowerExtent * scale);
int upper = (int) StrictMath.floor(upperExtent * scale);
volume += lower + upper + 1L;
}
}
return volume;
}
}
@@ -0,0 +1,13 @@
package art.arcane.iris.engine.hydrology;
public record HydrologyPoint(int x, int y, int z) {
public long packedColumn() {
return RiverFootprint.pack(x, z);
}
public long distanceSquared2D(HydrologyPoint other) {
long deltaX = (long) x - other.x;
long deltaZ = (long) z - other.z;
return deltaX * deltaX + deltaZ * deltaZ;
}
}
@@ -0,0 +1,44 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public record HydrologyRenderSample(int x, int z, List<HydrologyFeatureRef> features) {
private static final Comparator<HydrologyFeatureRef> FEATURE_ORDER = Comparator
.comparingInt((HydrologyFeatureRef feature) -> feature.type().renderPriority())
.thenComparingLong(HydrologyFeatureRef::id);
public HydrologyRenderSample {
Objects.requireNonNull(features, "features");
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(features);
ordered.sort(FEATURE_ORDER);
Map<Long, HydrologyFeatureRef> unique = new LinkedHashMap<>();
for (HydrologyFeatureRef feature : ordered) {
unique.putIfAbsent(feature.id(), feature);
}
features = List.copyOf(unique.values());
}
public boolean present() {
return !features.isEmpty();
}
public boolean hasFeature(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
for (HydrologyFeatureRef feature : features) {
if (feature.type() == type) {
return true;
}
}
return false;
}
public Optional<HydrologyFeatureRef> primaryFeature() {
return features.isEmpty() ? Optional.empty() : Optional.of(features.getFirst());
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.hydrology;
public interface HydrologyRoutingTerrainSampler {
HydrologyTerrainSample[] sampleGrid(GridRequest request);
NaturalClassification classifyNatural(int blockX, int blockZ);
enum NaturalClassification {
LAND,
OCEAN,
UNAVAILABLE
}
record GridRequest(int minimumX, int minimumZ, int width, int spacing) {
public GridRequest {
if (width < 1 || spacing < 1) {
throw new IllegalArgumentException("Hydrology routing grid dimensions must be positive");
}
}
}
}
@@ -0,0 +1,107 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
final class HydrologyTerrainCaveVoxelView implements CaveVoxelView {
private final HydrologyTerrainSampler sampler;
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> samples;
private final int seaLevel;
private final int minimumY;
private final int maximumY;
HydrologyTerrainCaveVoxelView(HydrologyTerrainSampler sampler, int seaLevel, int worldHeight) {
this(sampler, seaLevel, 0, worldHeight);
}
HydrologyTerrainCaveVoxelView(
HydrologyTerrainSampler sampler,
int seaLevel,
int minimumY,
int maximumY
) {
this(sampler, seaLevel, minimumY, maximumY, new Long2ObjectOpenHashMap<>());
}
private HydrologyTerrainCaveVoxelView(
HydrologyTerrainSampler sampler,
int seaLevel,
int minimumY,
int maximumY,
Long2ObjectOpenHashMap<HydrologyTerrainSample> samples
) {
this.sampler = Objects.requireNonNull(sampler);
this.samples = Objects.requireNonNull(samples);
this.seaLevel = seaLevel;
if (maximumY - minimumY < 3) {
throw new IllegalArgumentException("The cave view requires at least three vertical blocks");
}
this.minimumY = minimumY;
this.maximumY = maximumY;
}
HydrologyTerrainCaveVoxelView withSampleCache(
Long2ObjectOpenHashMap<HydrologyTerrainSample> sampleCache
) {
return new HydrologyTerrainCaveVoxelView(
sampler,
seaLevel,
minimumY,
maximumY,
sampleCache
);
}
@Override
public boolean isInWorld(CavePosition position) {
return position.y() > minimumY && position.y() < maximumY - 1;
}
@Override
public CaveVoxel voxelAt(CavePosition position) {
HydrologyTerrainSample terrain = sample(position.x(), position.z());
if (position.y() <= terrain.naturalHeight()) {
return CaveVoxel.SOLID;
}
if (terrain.ocean() && position.y() <= seaLevel) {
return CaveVoxel.COMPATIBLE_FLUID;
}
return CaveVoxel.CAVE_AIR;
}
@Override
public boolean isOpenToSurface(CavePosition position) {
return isAboveTerrainSurface(position);
}
@Override
public boolean isAboveTerrainSurface(CavePosition position) {
HydrologyTerrainSample terrain = sample(position.x(), position.z());
return position.y() > terrain.naturalHeight();
}
@Override
public boolean hasAboveTerrainSurface(int x, int z, int requestedMinimumY, int requestedMaximumY) {
int firstY = Math.max(requestedMinimumY, minimumY + 1);
int lastY = Math.min(requestedMaximumY, maximumY - 2);
return firstY <= lastY && lastY > sample(x, z).naturalHeight();
}
private HydrologyTerrainSample sample(int x, int z) {
long key = (long) x << 32 ^ z & 0xffffffffL;
HydrologyTerrainSample cached = samples.get(key);
if (cached != null) {
return cached;
}
HydrologyTerrainSample sampled = Objects.requireNonNull(
sampler.sample(x, z),
"Hydrology terrain sampler returned null at " + x + "," + z
);
samples.put(key, sampled);
return sampled;
}
}
@@ -0,0 +1,235 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
public record HydrologyTerrainSample(
int naturalHeight,
double slope,
boolean ocean,
boolean caveAvailable,
int caveFloorY,
int caveFluidY,
boolean transitAllowed,
boolean outletAllowed,
boolean surfaceSourceAllowed,
boolean surfaceSourceRequired,
boolean undergroundSourceAllowed,
boolean undergroundSourceRequired,
double routingCost,
double surfaceSourceWeight,
double undergroundSourceWeight,
double widthMultiplier,
double depthMultiplier,
double incisionMultiplier,
double routingMultiplier,
double bankMultiplier,
String parentBiomeKey,
String surfaceBiomeKey,
String mouthBiomeKey,
String shoreBiomeKey,
String bankBiomeKey,
String floodedCaveBiomeKey,
List<String> preferredProfileKeys,
List<String> surfacePoolKeys
) {
public HydrologyTerrainSample {
requireFiniteNonNegative(slope, "slope");
requireFiniteNonNegative(routingCost, "routingCost");
requireFiniteNonNegative(surfaceSourceWeight, "surfaceSourceWeight");
requireFiniteNonNegative(undergroundSourceWeight, "undergroundSourceWeight");
requireFinitePositive(widthMultiplier, "widthMultiplier");
requireFinitePositive(depthMultiplier, "depthMultiplier");
requireFiniteNonNegative(incisionMultiplier, "incisionMultiplier");
requireFiniteNonNegative(routingMultiplier, "routingMultiplier");
requireFinitePositive(bankMultiplier, "bankMultiplier");
if (caveAvailable && (caveFluidY <= caveFloorY || caveFluidY >= naturalHeight)) {
throw new IllegalArgumentException("Available cave fluid must be above its floor and below natural terrain.");
}
parentBiomeKey = normalizeKey(parentBiomeKey, "parent");
surfaceBiomeKey = normalizeKey(surfaceBiomeKey, parentBiomeKey);
mouthBiomeKey = normalizeKey(mouthBiomeKey, surfaceBiomeKey);
shoreBiomeKey = normalizeKey(shoreBiomeKey, parentBiomeKey);
bankBiomeKey = normalizeKey(bankBiomeKey, parentBiomeKey);
floodedCaveBiomeKey = normalizeKey(floodedCaveBiomeKey, surfaceBiomeKey);
preferredProfileKeys = normalizeProfiles(preferredProfileKeys);
surfacePoolKeys = surfacePoolKeys == null ? List.of() : List.copyOf(surfacePoolKeys);
}
public static HydrologyTerrainSample openLand(int naturalHeight, double slope, String parentBiomeKey) {
return new HydrologyTerrainSample(
naturalHeight,
slope,
false,
false,
naturalHeight - 32,
naturalHeight - 30,
true,
true,
true,
false,
false,
false,
0D,
1D,
1D,
1D,
1D,
1D,
1D,
1D,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
List.of("default"),
List.of()
);
}
public static HydrologyTerrainSample ocean(int naturalHeight, String parentBiomeKey) {
return new HydrologyTerrainSample(
naturalHeight,
0D,
true,
false,
naturalHeight - 32,
naturalHeight - 30,
false,
false,
false,
false,
false,
false,
0D,
0D,
0D,
1D,
1D,
1D,
1D,
1D,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
List.of("default"),
List.of()
);
}
public HydrologyTerrainSample withSurfacePoolKeys(List<String> replacementPoolKeys) {
return new HydrologyTerrainSample(
naturalHeight,
slope,
ocean,
caveAvailable,
caveFloorY,
caveFluidY,
transitAllowed,
outletAllowed,
surfaceSourceAllowed,
surfaceSourceRequired,
undergroundSourceAllowed,
undergroundSourceRequired,
routingCost,
surfaceSourceWeight,
undergroundSourceWeight,
widthMultiplier,
depthMultiplier,
incisionMultiplier,
routingMultiplier,
bankMultiplier,
parentBiomeKey,
surfaceBiomeKey,
mouthBiomeKey,
shoreBiomeKey,
bankBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys,
replacementPoolKeys
);
}
public HydrologyTerrainSample withSlope(double replacementSlope) {
return new HydrologyTerrainSample(
naturalHeight,
replacementSlope,
ocean,
caveAvailable,
caveFloorY,
caveFluidY,
transitAllowed,
outletAllowed,
surfaceSourceAllowed,
surfaceSourceRequired,
undergroundSourceAllowed,
undergroundSourceRequired,
routingCost,
surfaceSourceWeight,
undergroundSourceWeight,
widthMultiplier,
depthMultiplier,
incisionMultiplier,
routingMultiplier,
bankMultiplier,
parentBiomeKey,
surfaceBiomeKey,
mouthBiomeKey,
shoreBiomeKey,
bankBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys,
surfacePoolKeys
);
}
private static List<String> normalizeProfiles(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of("default");
}
if (profilesNormalized(values)) {
return List.copyOf(values);
}
List<String> normalized = values.stream()
.filter((String value) -> value != null && !value.isBlank())
.map(String::trim)
.distinct()
.sorted()
.toList();
return normalized.isEmpty() ? List.of("default") : normalized;
}
private static boolean profilesNormalized(List<String> values) {
String previous = null;
for (String value : values) {
if (value == null
|| value.isBlank()
|| !value.equals(value.trim())
|| previous != null && previous.compareTo(value) >= 0) {
return false;
}
previous = value;
}
return true;
}
private static String normalizeKey(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
private static void requireFiniteNonNegative(double value, String name) {
if (!Double.isFinite(value) || value < 0D) {
throw new IllegalArgumentException(name + " must be finite and non-negative.");
}
}
private static void requireFinitePositive(double value, String name) {
if (!Double.isFinite(value) || value <= 0D) {
throw new IllegalArgumentException(name + " must be finite and positive.");
}
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.engine.hydrology;
@FunctionalInterface
public interface HydrologyTerrainSampler {
HydrologyTerrainSample sample(int blockX, int blockZ);
}
@@ -0,0 +1,618 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
public final class HydrologyTile {
private final HydrologyTileKey key;
private final long worldSeed;
private final long settingsFingerprint;
private final int tileSize;
private final List<DrainageNode> nodes;
private final List<DrainageEdge> edges;
private final List<RiverOutlet> outlets;
private final List<RiverCourse> courses;
private final List<HydrologyCavePlan> cavePlans;
private final List<HydrologyDiagnosticCandidate> diagnosticCandidates;
private final RiverFootprint footprint;
private final Map<Long, DrainageNode> nodesById;
private final Map<Long, RiverOutlet> outletsById;
private final Map<Long, HydrologyCavePlan> cavePlansByCourseId;
private final List<HydrologyFeatureRef> features;
public HydrologyTile(
HydrologyTileKey key,
long worldSeed,
long settingsFingerprint,
int tileSize,
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets,
List<RiverCourse> courses,
List<HydrologyCavePlan> cavePlans,
List<HydrologyDiagnosticCandidate> diagnosticCandidates,
RiverFootprint footprint
) {
this.key = Objects.requireNonNull(key, "key");
if (tileSize <= 0) {
throw new IllegalArgumentException("tileSize must be positive.");
}
this.worldSeed = worldSeed;
this.settingsFingerprint = settingsFingerprint;
this.tileSize = tileSize;
this.nodes = sortedCopy(nodes, Comparator.comparingLong(DrainageNode::id));
this.edges = sortedCopy(edges, Comparator.comparingLong(DrainageEdge::id));
this.outlets = sortedCopy(outlets, Comparator.comparingLong(RiverOutlet::id));
this.courses = sortedCopy(courses, Comparator.comparingLong(RiverCourse::id));
this.cavePlans = sortedCopy(
cavePlans,
Comparator.comparingLong((HydrologyCavePlan plan) -> plan.source().sourceId())
);
this.diagnosticCandidates = sortedCopy(
diagnosticCandidates,
Comparator.comparingLong(HydrologyDiagnosticCandidate::id)
);
this.footprint = Objects.requireNonNull(footprint, "footprint");
this.nodesById = indexNodes(this.nodes);
this.outletsById = indexOutlets(this.outlets);
this.cavePlansByCourseId = indexCavePlans(this.cavePlans);
validateGraph();
validateCourses();
this.features = collectFeatures(footprint);
}
public HydrologyTileKey key() {
return key;
}
public long worldSeed() {
return worldSeed;
}
public long settingsFingerprint() {
return settingsFingerprint;
}
public int tileSize() {
return tileSize;
}
public List<DrainageNode> nodes() {
return nodes;
}
public List<DrainageEdge> edges() {
return edges;
}
public List<RiverOutlet> outlets() {
return outlets;
}
public List<RiverCourse> courses() {
return courses;
}
public List<HydrologyCavePlan> cavePlans() {
return cavePlans;
}
public List<HydrologyDiagnosticCandidate> diagnosticCandidates() {
return diagnosticCandidates;
}
public RiverFootprint footprint() {
return footprint;
}
public Optional<DrainageNode> node(long id) {
return Optional.ofNullable(nodesById.get(id));
}
public Optional<RiverOutlet> outlet(long id) {
return Optional.ofNullable(outletsById.get(id));
}
public Optional<HydrologyCavePlan> cavePlan(long courseId) {
return Optional.ofNullable(cavePlansByCourseId.get(courseId));
}
public Optional<HydrologyColumnSample> columnAt(int x, int z) {
return footprint.sample(x, z);
}
public HydrologyRenderSample renderAt(int x, int z) {
return footprint.renderSample(x, z);
}
public HydrologyDiagnosticRenderSample diagnosticRenderAt(int x, int z, int maximumDistance) {
if (maximumDistance < 0) {
throw new IllegalArgumentException("maximumDistance cannot be negative.");
}
long maximumDistanceSquared = (long) maximumDistance * maximumDistance;
ArrayList<HydrologyDiagnosticCandidate> selected = new ArrayList<>();
for (HydrologyDiagnosticCandidate candidate : diagnosticCandidates) {
long distance = candidate.point().distanceSquared2D(new HydrologyPoint(x, candidate.point().y(), z));
if (distance <= maximumDistanceSquared) {
selected.add(candidate);
}
}
return new HydrologyDiagnosticRenderSample(x, z, selected);
}
public List<HydrologyFeatureRef> features() {
return features;
}
public Optional<HydrologyFeatureRef> nearestFeature(
HydrologyFeatureType type,
int x,
int z,
int maximumDistance
) {
return nearestFeature(Set.of(Objects.requireNonNull(type, "type")), null, x, z, maximumDistance);
}
public Optional<HydrologyFeatureRef> nearestFeature(
Set<HydrologyFeatureType> types,
String profileKey,
int x,
int z,
int maximumDistance
) {
Objects.requireNonNull(types, "types");
if (maximumDistance < 0) {
throw new IllegalArgumentException("maximumDistance cannot be negative.");
}
long maximumDistanceSquared = (long) maximumDistance * maximumDistance;
HydrologyFeatureRef nearest = null;
long nearestDistance = Long.MAX_VALUE;
for (RiverCourse course : courses) {
if (profileKey != null && !profileKey.isBlank() && !profileKey.equals(course.profileKey())) {
continue;
}
for (HydraulicSegment segment : course.segments()) {
if (!types.contains(segment.type())) {
continue;
}
for (HydrologyPoint point : segment.centerline()) {
long deltaX = (long) point.x() - x;
long deltaZ = (long) point.z() - z;
long distance = deltaX * deltaX + deltaZ * deltaZ;
if (distance > maximumDistanceSquared || distance > nearestDistance) {
continue;
}
HydrologyFeatureRef feature = publishedCenterlineFeature(segment, point);
if (feature == null) {
continue;
}
if (distance < nearestDistance || nearest == null || feature.id() < nearest.id()) {
nearest = feature;
nearestDistance = distance;
}
}
}
}
return Optional.ofNullable(nearest);
}
public boolean acyclic() {
for (DrainageEdge edge : edges) {
DrainageNode upstream = nodesById.get(edge.upstreamNodeId());
DrainageNode downstream = nodesById.get(edge.downstreamNodeId());
if (upstream == null || downstream == null || downstream.potential() >= upstream.potential()) {
return false;
}
}
return true;
}
private void validateGraph() {
if (!acyclic()) {
throw new IllegalArgumentException("Drainage edges must strictly lower potential.");
}
for (DrainageNode node : nodes) {
if (!outletsById.containsKey(node.outletId())) {
throw new IllegalArgumentException("Every drainage node must resolve to an accepted outlet.");
}
}
}
private void validateCourses() {
LinkedHashMap<Long, RiverCourse> coursesById = new LinkedHashMap<>();
for (RiverCourse course : courses) {
if (coursesById.put(course.id(), course) != null) {
throw new IllegalArgumentException("Duplicate river course id: " + course.id());
}
if (!course.hydraulicallyNonRising()) {
throw new IllegalArgumentException("Course heads must be non-rising downstream.");
}
if (course.sourceNodeId().isPresent() && !nodesById.containsKey(course.sourceNodeId().getAsLong())) {
throw new IllegalArgumentException("Course source is not part of the accepted drainage graph.");
}
if (course.outletId().isPresent() && !outletsById.containsKey(course.outletId().getAsLong())) {
throw new IllegalArgumentException("Course outlet is not accepted by this tile.");
}
if (isCaveBearing(course) != cavePlansByCourseId.containsKey(course.id())) {
throw new IllegalArgumentException("Every cave-bearing course must have one accepted containment plan.");
}
}
validateFootprintCourses(coursesById);
}
private void validateFootprintCourses(Map<Long, RiverCourse> coursesById) {
LinkedHashMap<Long, Map<CavePosition, HydrologyCaveAction>> expectedActions = new LinkedHashMap<>();
for (HydrologyColumnSample sample : footprint.columns().values()) {
ArrayList<Long> caveCourseIds = new ArrayList<>();
for (HydrologyColumnLayer layer : sample.layers()) {
long courseId = layer.feature().courseId();
if (!coursesById.containsKey(courseId)) {
throw new IllegalArgumentException("Footprint references an unaccepted river course: " + courseId);
}
if (layer.oceanApron() || !layer.channel() || !layer.terrainOwned()
|| !isCaveLayer(layer)) {
continue;
}
Map<CavePosition, HydrologyCaveAction> courseActions = expectedActions.computeIfAbsent(
courseId,
(Long ignored) -> new HashMap<>()
);
if (!caveCourseIds.contains(courseId)) {
caveCourseIds.add(courseId);
}
for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) {
CavePosition position = new CavePosition(sample.x(), y, sample.z());
HydrologyCaveAction action = actionAt(layer, y);
HydrologyCaveAction existing = courseActions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
courseActions.put(position, action);
}
}
}
composeSurfaceActions(sample, caveCourseIds, coursesById, expectedActions);
}
HashSet<Long> validatedCourseIds = new HashSet<>();
for (RiverCourse course : courses) {
if (!isCaveBearing(course)) {
continue;
}
if (!validatedCourseIds.add(course.id())) {
continue;
}
List<RiverCourse> sharedCourses = sharedOutletCaveCourses(course);
HashMap<CavePosition, HydrologyCaveAction> expected = new HashMap<>();
HashMap<CavePosition, HydrologyCaveAction> actual = new HashMap<>();
for (RiverCourse sharedCourse : sharedCourses) {
validatedCourseIds.add(sharedCourse.id());
mergeCaveActions(
expected,
expectedActions.getOrDefault(sharedCourse.id(), Map.of())
);
HydrologyCavePlan plan = cavePlansByCourseId.get(sharedCourse.id());
plan.forEachAction((CavePosition position, HydrologyCaveAction action) -> {
if (action != HydrologyCaveAction.SEAL_GUARD) {
mergeCaveAction(actual, position, action);
}
});
}
if (!actual.equals(expected)) {
throw caveFootprintMismatch(course.id(), expected, actual);
}
}
}
private List<RiverCourse> sharedOutletCaveCourses(RiverCourse course) {
if (course.outletId().isEmpty()) {
return List.of(course);
}
long outletId = course.outletId().getAsLong();
ArrayList<RiverCourse> shared = new ArrayList<>();
for (RiverCourse candidate : courses) {
if (isCaveBearing(candidate)
&& candidate.outletId().isPresent()
&& candidate.outletId().getAsLong() == outletId
&& candidate.profileKey().equals(course.profileKey())) {
shared.add(candidate);
}
}
return List.copyOf(shared);
}
private void mergeCaveActions(
Map<CavePosition, HydrologyCaveAction> target,
Map<CavePosition, HydrologyCaveAction> source
) {
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : source.entrySet()) {
mergeCaveAction(target, entry.getKey(), entry.getValue());
}
}
private void mergeCaveAction(
Map<CavePosition, HydrologyCaveAction> actions,
CavePosition position,
HydrologyCaveAction action
) {
HydrologyCaveAction existing = actions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
actions.put(position, action);
}
}
private IllegalArgumentException caveFootprintMismatch(
long courseId,
Map<CavePosition, HydrologyCaveAction> expected,
Map<CavePosition, HydrologyCaveAction> actual
) {
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : expected.entrySet()) {
HydrologyCaveAction actualAction = actual.get(entry.getKey());
if (actualAction != entry.getValue()) {
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + " at " + entry.getKey() + ", footprint=" + entry.getValue()
+ ", plan=" + actualAction + ".");
}
}
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : actual.entrySet()) {
if (!expected.containsKey(entry.getKey())) {
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + " has extra plan action " + entry.getValue() + " at " + entry.getKey() + ".");
}
}
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + ".");
}
private void composeSurfaceActions(
HydrologyColumnSample sample,
List<Long> caveCourseIds,
Map<Long, RiverCourse> coursesById,
Map<Long, Map<CavePosition, HydrologyCaveAction>> expectedActions
) {
if (caveCourseIds.isEmpty()) {
return;
}
int minimumY = Integer.MAX_VALUE;
int maximumY = Integer.MIN_VALUE;
for (HydrologyColumnLayer layer : sample.layers()) {
if (!layer.publishesSurfaceFluid()) {
continue;
}
minimumY = Math.min(minimumY, layer.bedY() + 1);
maximumY = Math.max(maximumY, layer.fluidHeadY());
}
for (int y = minimumY; y <= maximumY; y++) {
HydrologyColumnSample.SurfacePublicationCell surfaceCell = sample
.surfacePublicationCellAt(y)
.orElse(null);
if (surfaceCell == null) {
continue;
}
CavePosition position = new CavePosition(sample.x(), y, sample.z());
for (long courseId : caveCourseIds) {
Map<CavePosition, HydrologyCaveAction> courseActions = expectedActions.get(courseId);
if (!courseActions.containsKey(position)) {
continue;
}
RiverCourse course = coursesById.get(courseId);
if (!course.profileKey().equals(surfaceCell.layer().profileKey())) {
throw new IllegalArgumentException("Surface and cave hydrology profiles conflict at "
+ position + ".");
}
courseActions.put(position, surfaceCell.action());
}
}
}
private boolean isCaveBearing(RiverCourse course) {
for (HydraulicSegment segment : course.segments()) {
if (segment.type().isUnderground() || segment.type().isDeepFluid()) {
return true;
}
}
return false;
}
private boolean isCaveLayer(HydrologyColumnLayer layer) {
return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid();
}
private HydrologyCaveAction actionAt(HydrologyColumnLayer layer, int y) {
if (y > layer.fluidHeadY()) {
return HydrologyCaveAction.DRY_AIR;
}
if (layer.fallingFluid() && y < layer.fluidHeadY()) {
return HydrologyCaveAction.FALLING_FLUID;
}
return HydrologyCaveAction.WET_SOURCE;
}
private int actionPriority(HydrologyCaveAction action) {
return switch (action) {
case WET_SOURCE -> 0;
case FALLING_FLUID -> 1;
case DRY_AIR -> 2;
case SEAL_GUARD -> 3;
};
}
private static Map<Long, DrainageNode> indexNodes(List<DrainageNode> values) {
LinkedHashMap<Long, DrainageNode> indexed = new LinkedHashMap<>();
for (DrainageNode value : values) {
if (indexed.put(value.id(), value) != null) {
throw new IllegalArgumentException("Duplicate drainage node id: " + value.id());
}
}
return Collections.unmodifiableMap(indexed);
}
private static Map<Long, RiverOutlet> indexOutlets(List<RiverOutlet> values) {
LinkedHashMap<Long, RiverOutlet> indexed = new LinkedHashMap<>();
for (RiverOutlet value : values) {
if (indexed.put(value.id(), value) != null) {
throw new IllegalArgumentException("Duplicate river outlet id: " + value.id());
}
}
return Collections.unmodifiableMap(indexed);
}
private static Map<Long, HydrologyCavePlan> indexCavePlans(List<HydrologyCavePlan> values) {
LinkedHashMap<Long, HydrologyCavePlan> indexed = new LinkedHashMap<>();
for (HydrologyCavePlan value : values) {
if (!value.accepted()) {
throw new IllegalArgumentException("Hydrology tiles cannot contain rejected cave plans.");
}
long courseId = value.source().sourceId();
if (indexed.put(courseId, value) != null) {
throw new IllegalArgumentException("Duplicate cave plan for course: " + courseId);
}
}
return Collections.unmodifiableMap(indexed);
}
private HydrologyFeatureRef publishedCenterlineFeature(
HydraulicSegment segment,
HydrologyPoint point
) {
HydrologyColumnSample column = footprint.columns().get(RiverFootprint.pack(point.x(), point.z()));
if (column == null) {
return null;
}
for (HydrologyColumnLayer layer : column.layers()) {
HydrologyFeatureRef feature = layer.feature();
if (layer.channel()
&& feature.segmentId() == segment.id()
&& feature.type() == segment.type()) {
if (feature.x() == point.x() && feature.z() == point.z()) {
return feature;
}
return new HydrologyFeatureRef(
feature.id(),
feature.type(),
feature.courseId(),
feature.segmentId(),
point.x(),
layer.fluidHeadY(),
point.z(),
feature.flowDeltaX(),
feature.flowDeltaZ(),
feature.source()
);
}
}
return null;
}
static List<HydrologyFeatureRef> collectFeatures(RiverFootprint footprint) {
HashMap<Long, HydrologyColumnLayer> representatives = new HashMap<>();
for (HydrologyColumnSample column : footprint.columns().values()) {
for (HydrologyColumnLayer layer : column.layers()) {
long segmentId = layer.feature().segmentId();
HydrologyColumnLayer existing = representatives.get(segmentId);
if (existing == null || featureOrder(layer, existing) < 0) {
representatives.put(segmentId, layer);
}
}
}
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(representatives.size());
for (HydrologyColumnLayer layer : representatives.values()) {
ordered.add(layer.feature());
}
ordered.sort(Comparator.comparingInt((HydrologyFeatureRef feature) -> feature.type().renderPriority())
.thenComparingLong(HydrologyFeatureRef::id));
return List.copyOf(ordered);
}
private static int featureOrder(HydrologyColumnLayer first, HydrologyColumnLayer second) {
int role = Integer.compare(featureRole(first), featureRole(second));
if (role != 0) {
return role;
}
HydrologyFeatureRef firstFeature = first.feature();
HydrologyFeatureRef secondFeature = second.feature();
int y = Integer.compare(firstFeature.y(), secondFeature.y());
if (y != 0) {
return y;
}
int x = Integer.compare(firstFeature.x(), secondFeature.x());
return x != 0 ? x : Integer.compare(firstFeature.z(), secondFeature.z());
}
private static int featureRole(HydrologyColumnLayer layer) {
if (layer.feature().source()) {
return 0;
}
if (layer.fallingFluid()) {
return 1;
}
if (layer.receivingPool()) {
return 2;
}
if (layer.channel() && !layer.oceanApron()) {
return 3;
}
if (layer.channel()) {
return 4;
}
return 5;
}
private static <T> List<T> sortedCopy(List<T> values, Comparator<T> comparator) {
Objects.requireNonNull(values, "values");
ArrayList<T> ordered = new ArrayList<>(values);
ordered.sort(comparator);
return List.copyOf(ordered);
}
@Override
public boolean equals(Object other) {
if (!(other instanceof HydrologyTile tile)) {
return false;
}
return worldSeed == tile.worldSeed
&& settingsFingerprint == tile.settingsFingerprint
&& tileSize == tile.tileSize
&& key.equals(tile.key)
&& nodes.equals(tile.nodes)
&& edges.equals(tile.edges)
&& outlets.equals(tile.outlets)
&& courses.equals(tile.courses)
&& cavePlans.equals(tile.cavePlans)
&& diagnosticCandidates.equals(tile.diagnosticCandidates)
&& footprint.equals(tile.footprint);
}
@Override
public int hashCode() {
return Objects.hash(
key,
worldSeed,
settingsFingerprint,
tileSize,
nodes,
edges,
outlets,
courses,
cavePlans,
diagnosticCandidates,
footprint
);
}
@Override
public String toString() {
return "HydrologyTile[key=" + key + ", courses=" + courses.size() + ", candidates="
+ diagnosticCandidates.size() + ", columns=" + footprint.size() + "]";
}
}
@@ -0,0 +1,267 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.parallel.MultiBurst;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicLong;
public final class HydrologyTileCache implements AutoCloseable {
private static final int DEFAULT_MAXIMUM_ENTRIES = 64;
private static final int MAXIMUM_COMPOSED_CHUNKS = 256;
private static final int CHUNK_SIZE = 16;
private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE;
private final HydrologyPlanner planner;
private final int maximumEntries;
private final Cache<HydrologyTileKey, HydrologyTile> tiles;
private final Cache<Long, ChunkColumns> composedChunks;
private final AtomicLong cacheEpoch;
private final ThreadLocal<LocalChunkColumns> localChunkColumns;
public HydrologyTileCache(HydrologyPlanner planner) {
this(planner, DEFAULT_MAXIMUM_ENTRIES);
}
public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries) {
this.planner = Objects.requireNonNull(planner, "planner");
if (maximumEntries < 1) {
throw new IllegalArgumentException("maximumEntries must be positive.");
}
this.maximumEntries = maximumEntries;
this.tiles = Caffeine.newBuilder()
.maximumSize(maximumEntries)
.build();
this.composedChunks = Caffeine.newBuilder()
.maximumSize(MAXIMUM_COMPOSED_CHUNKS)
.build();
this.cacheEpoch = new AtomicLong();
this.localChunkColumns = new ThreadLocal<>();
}
public HydrologyTile get(HydrologyTileKey key) {
Objects.requireNonNull(key, "key");
return tiles.get(key, planner::plan);
}
public Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ);
}
public void prepareChunkColumns(int blockX, int blockZ) {
chunkColumns(blockX, blockZ);
}
public HydrologyRenderSample renderAt(int blockX, int blockZ) {
return columnAt(blockX, blockZ)
.map(HydrologyColumnSample::renderSample)
.orElseGet(() -> new HydrologyRenderSample(blockX, blockZ, List.of()));
}
public void invalidate(HydrologyTileKey key) {
Objects.requireNonNull(key, "key");
clear();
}
public void clear() {
cacheEpoch.incrementAndGet();
planner.clearOwnerDrafts();
tiles.invalidateAll();
composedChunks.invalidateAll();
localChunkColumns.remove();
}
public int size() {
tiles.cleanUp();
return Math.toIntExact(tiles.estimatedSize());
}
public int maximumEntries() {
return maximumEntries;
}
@Override
public void close() {
clear();
}
private ChunkColumns chunkColumns(int blockX, int blockZ) {
int chunkX = Math.floorDiv(blockX, CHUNK_SIZE);
int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE);
long epoch = cacheEpoch.get();
LocalChunkColumns local = localChunkColumns.get();
if (local != null && local.epoch() == epoch
&& local.columns().chunkX() == chunkX
&& local.columns().chunkZ() == chunkZ) {
return local.columns();
}
long packedChunk = RiverFootprint.pack(chunkX, chunkZ);
ChunkColumns columns = composedChunks.get(
packedChunk,
ignored -> composeChunkColumns(chunkX, chunkZ)
);
localChunkColumns.set(new LocalChunkColumns(epoch, columns));
return columns;
}
private ChunkColumns composeChunkColumns(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);
int maximumTileZ = tileCoordinate((long) minimumBlockZ + CHUNK_SIZE - 1L + publicationRadius, tileSize);
ArrayList<HydrologyTileKey> relevantKeys = new ArrayList<>(
Math.multiplyExact(maximumTileX - minimumTileX + 1, maximumTileZ - minimumTileZ + 1)
);
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
relevantKeys.add(new HydrologyTileKey(tileX, tileZ));
}
}
List<HydrologyTile> relevantTiles = loadTiles(relevantKeys);
HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT];
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
for (int localX = 0; localX < CHUNK_SIZE; localX++) {
int blockX = minimumBlockX + localX;
int blockZ = minimumBlockZ + localZ;
columns[localZ * CHUNK_SIZE + localX] = composeColumn(
blockX,
blockZ,
relevantKeys,
relevantTiles
)
.orElse(null);
}
}
return new ChunkColumns(chunkX, chunkZ, columns);
}
private List<HydrologyTile> loadTiles(List<HydrologyTileKey> keys) {
if (keys.size() < 2
|| !IrisPlatforms.isBound()) {
ArrayList<HydrologyTile> loaded = new ArrayList<>(keys.size());
for (HydrologyTileKey key : keys) {
loaded.add(get(key));
}
return List.copyOf(loaded);
}
ArrayList<CompletableFuture<HydrologyTile>> futures = new ArrayList<>(keys.size());
for (HydrologyTileKey key : keys) {
futures.add(CompletableFuture.supplyAsync(() -> get(key), MultiBurst.burst));
}
ArrayList<HydrologyTile> loaded = new ArrayList<>(keys.size());
for (CompletableFuture<HydrologyTile> future : futures) {
loaded.add(awaitTile(future));
}
return List.copyOf(loaded);
}
private static HydrologyTile awaitTile(CompletableFuture<HydrologyTile> future) {
try {
return future.join();
} catch (CompletionException failure) {
Throwable cause = failure.getCause();
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (cause instanceof Error error) {
throw error;
}
throw new IllegalStateException("Hydrology tile planning failed.", cause);
}
}
private Optional<HydrologyColumnSample> composeColumn(
int blockX,
int blockZ,
List<HydrologyTileKey> relevantKeys,
List<HydrologyTile> relevantTiles
) {
ArrayList<HydrologyColumnLayer> layers = new ArrayList<>();
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);
int maximumTileZ = tileCoordinate((long) blockZ + publicationRadius, tileSize);
for (int tileIndex = 0; tileIndex < relevantTiles.size(); tileIndex++) {
HydrologyTileKey key = relevantKeys.get(tileIndex);
if (key.tileX() < minimumTileX || key.tileX() > maximumTileX
|| key.tileZ() < minimumTileZ || key.tileZ() > maximumTileZ) {
continue;
}
HydrologyTile tile = relevantTiles.get(tileIndex);
Optional<HydrologyColumnSample> candidate = tile.columnAt(blockX, blockZ);
if (candidate.isEmpty()) {
continue;
}
HydrologyColumnSample sample = candidate.get();
if (template == null) {
template = sample;
} else {
requireMatchingTerrain(template, sample);
}
layers.addAll(sample.layers());
}
if (template == null) {
return Optional.empty();
}
LinkedHashMap<Long, HydrologyColumnLayer> unique = new LinkedHashMap<>();
for (HydrologyColumnLayer layer : 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 + ".");
}
}
return Optional.of(new HydrologyColumnSample(
blockX,
blockZ,
template.naturalHeight(),
template.seaLevel(),
template.ocean(),
template.parentBiomeKey(),
new ArrayList<>(unique.values())
));
}
private static int tileCoordinate(long blockCoordinate, int tileSize) {
return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize));
}
private static void requireMatchingTerrain(
HydrologyColumnSample expected,
HydrologyColumnSample actual
) {
if (expected.naturalHeight() != actual.naturalHeight()
|| expected.seaLevel() != actual.seaLevel()
|| expected.ocean() != actual.ocean()
|| !expected.parentBiomeKey().equals(actual.parentBiomeKey())) {
throw new IllegalStateException(
"Hydrology plans disagree on terrain metadata at " + expected.x() + "," + expected.z() + "."
);
}
}
private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) {
private Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
int localX = Math.floorMod(blockX, CHUNK_SIZE);
int localZ = Math.floorMod(blockZ, CHUNK_SIZE);
return Optional.ofNullable(columns[localZ * CHUNK_SIZE + localX]);
}
}
private record LocalChunkColumns(long epoch, ChunkColumns columns) {
}
}
@@ -0,0 +1,31 @@
package art.arcane.iris.engine.hydrology;
public record HydrologyTileKey(int tileX, int tileZ) implements Comparable<HydrologyTileKey> {
public static HydrologyTileKey fromBlock(int blockX, int blockZ, int tileSize) {
if (tileSize <= 0) {
throw new IllegalArgumentException("Tile size must be positive.");
}
return new HydrologyTileKey(Math.floorDiv(blockX, tileSize), Math.floorDiv(blockZ, tileSize));
}
public int minimumBlockX(int tileSize) {
return Math.multiplyExact(tileX, tileSize);
}
public int minimumBlockZ(int tileSize) {
return Math.multiplyExact(tileZ, tileSize);
}
public boolean contains(int blockX, int blockZ, int tileSize) {
int minimumX = minimumBlockX(tileSize);
int minimumZ = minimumBlockZ(tileSize);
return blockX >= minimumX && blockX < minimumX + tileSize
&& blockZ >= minimumZ && blockZ < minimumZ + tileSize;
}
@Override
public int compareTo(HydrologyTileKey other) {
int xComparison = Integer.compare(tileX, other.tileX);
return xComparison != 0 ? xComparison : Integer.compare(tileZ, other.tileZ);
}
}
@@ -0,0 +1,108 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
import java.util.OptionalLong;
public record RiverCourse(
long id,
RiverCourseType type,
OptionalLong sourceNodeId,
OptionalLong outletId,
String profileKey,
int discharge,
List<DrainageEdge> drainageEdges,
List<HydraulicSegment> segments
) {
public RiverCourse {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(sourceNodeId, "sourceNodeId");
Objects.requireNonNull(outletId, "outletId");
Objects.requireNonNull(drainageEdges, "drainageEdges");
Objects.requireNonNull(segments, "segments");
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank.");
}
profileKey = profileKey.trim();
if (discharge <= 0) {
throw new IllegalArgumentException("Course discharge must be positive.");
}
drainageEdges = List.copyOf(drainageEdges);
segments = List.copyOf(segments);
if (segments.isEmpty()) {
throw new IllegalArgumentException("A river course requires at least one hydraulic segment.");
}
for (HydraulicSegment segment : segments) {
if (segment.courseId() != id) {
throw new IllegalArgumentException("Every segment must belong to its containing course.");
}
}
validateSinkholeContinuation(type, segments);
boolean independent = type == RiverCourseType.DEEP_FLUID || type == RiverCourseType.SURFACE_POOL;
if (independent && (sourceNodeId.isPresent() || outletId.isPresent())) {
throw new IllegalArgumentException("Independent deep-fluid and pool courses cannot reference drainage nodes or river outlets.");
}
if (!independent && (sourceNodeId.isEmpty() || outletId.isEmpty())) {
throw new IllegalArgumentException("Surface and underground courses require a source and outlet.");
}
}
public boolean hydraulicallyNonRising() {
int previousHead = Integer.MAX_VALUE;
for (HydraulicSegment segment : segments) {
if (segment.upstreamHeadY() > previousHead || segment.downstreamHeadY() > segment.upstreamHeadY()) {
return false;
}
previousHead = segment.downstreamHeadY();
}
return true;
}
public boolean surfaceSinkholeContinuation() {
return type == RiverCourseType.SURFACE
&& segments.size() >= 2
&& segments.get(segments.size() - 2).type() == HydrologyFeatureType.SINKHOLE
&& segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO;
}
private static void validateSinkholeContinuation(
RiverCourseType courseType,
List<HydraulicSegment> segments
) {
int sinkholeCount = 0;
for (HydraulicSegment segment : segments) {
if (segment.type() == HydrologyFeatureType.SINKHOLE) {
sinkholeCount++;
}
}
boolean terminalInlandGrotto = courseType == RiverCourseType.SURFACE
&& segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO;
if (!terminalInlandGrotto) {
if (sinkholeCount > 0) {
throw new IllegalArgumentException("A sinkhole must link a surface course to its terminal inland grotto.");
}
return;
}
if (sinkholeCount != 1 || segments.size() < 3) {
throw new IllegalArgumentException("A surface inland grotto requires one explicit sinkhole link and surface lip.");
}
HydraulicSegment lip = segments.get(segments.size() - 3);
HydraulicSegment sinkhole = segments.get(segments.size() - 2);
HydraulicSegment grotto = segments.getLast();
if (!lip.type().isSurface()
|| sinkhole.type() != HydrologyFeatureType.SINKHOLE
|| sinkhole.drop() <= 0
|| !sinkhole.receivingPool()
|| lip.downstreamHeadY() != sinkhole.upstreamHeadY()
|| sinkhole.downstreamHeadY() != grotto.upstreamHeadY()
|| grotto.upstreamHeadY() != grotto.downstreamHeadY()
|| lip.end().y() != sinkhole.upstreamHeadY()
|| sinkhole.start().y() != sinkhole.upstreamHeadY()
|| sinkhole.end().y() != sinkhole.downstreamHeadY()
|| grotto.start().y() != grotto.upstreamHeadY()
|| lip.end().distanceSquared2D(sinkhole.start()) != 0L
|| sinkhole.end().distanceSquared2D(grotto.start()) != 0L) {
throw new IllegalArgumentException("A surface sinkhole requires a connected lip, graded descent, and receiving grotto.");
}
}
}
@@ -0,0 +1,8 @@
package art.arcane.iris.engine.hydrology;
public enum RiverCourseType {
SURFACE,
UNDERGROUND,
DEEP_FLUID,
SURFACE_POOL
}
@@ -0,0 +1,115 @@
package art.arcane.iris.engine.hydrology;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
public final class RiverFootprint {
private final Map<Long, HydrologyColumnSample> columns;
public RiverFootprint(Map<Long, HydrologyColumnSample> columns) {
if (columns == null) {
throw new IllegalArgumentException("columns must not be null.");
}
TreeMap<Long, HydrologyColumnSample> ordered = new TreeMap<>();
for (Map.Entry<Long, HydrologyColumnSample> entry : columns.entrySet()) {
HydrologyColumnSample sample = entry.getValue();
if (sample == null || entry.getKey() != pack(sample.x(), sample.z())) {
throw new IllegalArgumentException("Footprint keys must match their column coordinates.");
}
ordered.put(entry.getKey(), sample);
}
Long2ObjectLinkedOpenHashMap<HydrologyColumnSample> indexed =
new Long2ObjectLinkedOpenHashMap<>(ordered.size());
for (Map.Entry<Long, HydrologyColumnSample> entry : ordered.entrySet()) {
indexed.put(entry.getKey().longValue(), entry.getValue());
}
columns = Collections.unmodifiableMap(indexed);
this.columns = columns;
}
public static RiverFootprint empty() {
return new RiverFootprint(Map.of());
}
public Optional<HydrologyColumnSample> sample(int x, int z) {
return Optional.ofNullable(columns.get(pack(x, z)));
}
public HydrologyRenderSample renderSample(int x, int z) {
HydrologyColumnSample sample = columns.get(pack(x, z));
return sample == null ? new HydrologyRenderSample(x, z, List.of()) : sample.renderSample();
}
public Map<Long, HydrologyColumnSample> columns() {
return columns;
}
public List<HydrologyColumnSample> columnsIn(int minimumX, int minimumZ, int maximumX, int maximumZ) {
ArrayList<HydrologyColumnSample> selected = new ArrayList<>();
long width = (long) maximumX - minimumX;
long height = (long) maximumZ - minimumZ;
if (width <= 0L || height <= 0L) {
return List.of();
}
long area = width > Long.MAX_VALUE / height ? Long.MAX_VALUE : width * height;
if (area < columns.size()) {
for (long z = minimumZ; z < maximumZ; z++) {
for (long x = minimumX; x < maximumX; x++) {
HydrologyColumnSample sample = columns.get(pack((int) x, (int) z));
if (sample != null) {
selected.add(sample);
}
}
}
return List.copyOf(selected);
}
for (HydrologyColumnSample sample : columns.values()) {
if (sample.x() >= minimumX && sample.x() < maximumX
&& sample.z() >= minimumZ && sample.z() < maximumZ) {
selected.add(sample);
}
}
return List.copyOf(selected);
}
public int size() {
return columns.size();
}
public boolean isEmpty() {
return columns.isEmpty();
}
public static long pack(int x, int z) {
return ((long) x << 32) ^ (z & 0xffffffffL);
}
public static int unpackX(long packed) {
return (int) (packed >> 32);
}
public static int unpackZ(long packed) {
return (int) packed;
}
@Override
public boolean equals(Object other) {
return other instanceof RiverFootprint footprint && columns.equals(footprint.columns);
}
@Override
public int hashCode() {
return columns.hashCode();
}
@Override
public String toString() {
return "RiverFootprint[columns=" + columns.size() + "]";
}
}
@@ -0,0 +1,30 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record RiverOutlet(
long id,
HydrologyFeatureType type,
long drainageNodeId,
HydrologyPoint landwardPoint,
HydrologyPoint connectionPoint,
int seaLevel,
boolean directOcean
) {
public RiverOutlet {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(landwardPoint, "landwardPoint");
Objects.requireNonNull(connectionPoint, "connectionPoint");
if (type != HydrologyFeatureType.MOUTH
&& type != HydrologyFeatureType.COASTAL_GROTTO
&& type != HydrologyFeatureType.INLAND_GROTTO) {
throw new IllegalArgumentException("Unsupported outlet feature type: " + type);
}
if (directOcean && type == HydrologyFeatureType.INLAND_GROTTO) {
throw new IllegalArgumentException("An inland grotto cannot be a direct ocean outlet.");
}
if (directOcean && connectionPoint.y() != seaLevel) {
throw new IllegalArgumentException("A direct ocean connection must be at sea level.");
}
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.engine.hydrology.cave;
public record CavePosition(int x, int y, int z) {
public CavePosition offset(int dx, int dy, int dz) {
return new CavePosition(x + dx, y + dy, z + dz);
}
@Override
public int hashCode() {
int hash = 0x811C9DC5;
hash = (hash ^ x) * 0x01000193;
hash = (hash ^ y) * 0x01000193;
hash = (hash ^ z) * 0x01000193;
hash ^= hash >>> 16;
hash *= 0x7FEB352D;
hash ^= hash >>> 15;
hash *= 0x846CA68B;
return hash ^ hash >>> 16;
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Collection;
public final class CavePositionIndex {
private static final int DEFAULT_CAPACITY = 16;
private static final int MAXIMUM_CAPACITY = 1 << 30;
private int[] xCoordinates;
private int[] yCoordinates;
private int[] zCoordinates;
private boolean[] occupied;
private int mask;
private int resizeThreshold;
private int size;
public CavePositionIndex() {
initialize(DEFAULT_CAPACITY);
}
public CavePositionIndex(int expectedSize) {
initialize(capacityFor(expectedSize));
}
public static CavePositionIndex copyOf(Collection<CavePosition> positions) {
CavePositionIndex index = new CavePositionIndex(positions.size());
for (CavePosition position : positions) {
index.add(position.x(), position.y(), position.z());
}
return index;
}
public boolean add(int x, int y, int z) {
int slot = findSlot(x, y, z);
if (occupied[slot]) {
return false;
}
occupied[slot] = true;
xCoordinates[slot] = x;
yCoordinates[slot] = y;
zCoordinates[slot] = z;
size++;
if (size > resizeThreshold) {
resize();
}
return true;
}
public boolean contains(int x, int y, int z) {
return occupied[findSlot(x, y, z)];
}
private void initialize(int capacity) {
xCoordinates = new int[capacity];
yCoordinates = new int[capacity];
zCoordinates = new int[capacity];
occupied = new boolean[capacity];
mask = capacity - 1;
resizeThreshold = capacity - (capacity >>> 2);
size = 0;
}
private int findSlot(int x, int y, int z) {
int slot = hash(x, y, z) & mask;
while (occupied[slot]
&& (xCoordinates[slot] != x || yCoordinates[slot] != y || zCoordinates[slot] != z)) {
slot = slot + 1 & mask;
}
return slot;
}
private void resize() {
if (occupied.length == MAXIMUM_CAPACITY) {
resizeThreshold = Integer.MAX_VALUE;
return;
}
int[] previousXCoordinates = xCoordinates;
int[] previousYCoordinates = yCoordinates;
int[] previousZCoordinates = zCoordinates;
boolean[] previousOccupied = occupied;
initialize(occupied.length << 1);
for (int index = 0; index < previousOccupied.length; index++) {
if (previousOccupied[index]) {
add(previousXCoordinates[index], previousYCoordinates[index], previousZCoordinates[index]);
}
}
}
private static int capacityFor(int expectedSize) {
if (expectedSize < 0) {
throw new IllegalArgumentException("Expected position count must not be negative.");
}
long requiredCapacity = Math.max(DEFAULT_CAPACITY, ((long) expectedSize * 4L + 2L) / 3L);
if (requiredCapacity >= MAXIMUM_CAPACITY) {
return MAXIMUM_CAPACITY;
}
return Integer.highestOneBit((int) requiredCapacity - 1) << 1;
}
private static int hash(int x, int y, int z) {
int hash = 0x811C9DC5;
hash = (hash ^ x) * 0x01000193;
hash = (hash ^ y) * 0x01000193;
hash = (hash ^ z) * 0x01000193;
hash ^= hash >>> 16;
hash *= 0x7FEB352D;
hash ^= hash >>> 15;
hash *= 0x846CA68B;
return hash ^ hash >>> 16;
}
}
@@ -0,0 +1,10 @@
package art.arcane.iris.engine.hydrology.cave;
public enum CaveVoxel {
SOLID,
CAVE_AIR,
COMPATIBLE_FLUID,
LAVA,
INCOMPATIBLE_FLUID,
UNCONDITIONAL
}
@@ -0,0 +1,9 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Objects;
public record CaveVoxelPrecondition(CaveVoxel voxel, boolean openToSurface) {
public CaveVoxelPrecondition {
Objects.requireNonNull(voxel);
}
}
@@ -0,0 +1,26 @@
package art.arcane.iris.engine.hydrology.cave;
public interface CaveVoxelView {
boolean isInWorld(CavePosition position);
CaveVoxel voxelAt(CavePosition position);
boolean isOpenToSurface(CavePosition position);
boolean isAboveTerrainSurface(CavePosition position);
default boolean hasAboveTerrainSurface(int x, int z, int minimumY, int maximumY) {
if (minimumY > maximumY) {
return false;
}
for (int y = minimumY; ; y++) {
CavePosition position = new CavePosition(x, y, z);
if (isInWorld(position) && isAboveTerrainSurface(position)) {
return true;
}
if (y == maximumY) {
return false;
}
}
}
}
@@ -0,0 +1,8 @@
package art.arcane.iris.engine.hydrology.cave;
public enum HydrologyCaveAction {
WET_SOURCE,
FALLING_FLUID,
DRY_AIR,
SEAL_GUARD
}
@@ -0,0 +1,33 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public record HydrologyCaveCandidate(
HydrologyCaveSource source,
String profileKey,
HydrologyCavePlannerSettings settings,
boolean allowDryCaveConnections,
Map<CavePosition, HydrologyCaveAction> actions,
Set<CavePosition> intentionalOpenings
) {
public HydrologyCaveCandidate {
Objects.requireNonNull(source);
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank");
}
profileKey = profileKey.trim();
Objects.requireNonNull(settings);
actions = Collections.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(actions)));
intentionalOpenings = Collections.unmodifiableSet(
new LinkedHashSet<>(Objects.requireNonNull(intentionalOpenings))
);
if (actions.isEmpty()) {
throw new IllegalArgumentException("A cave candidate requires at least one planned action");
}
}
}
@@ -0,0 +1,98 @@
package art.arcane.iris.engine.hydrology.cave;
import art.arcane.volmlib.util.matter.MatterCavern;
import java.util.Objects;
public final class HydrologyCaveCell {
private static final String DEFAULT_FLUID_PROFILE_KEY = "default";
private static final byte LIQUID_FLUID = 1;
private static final byte LIQUID_FORCED_AIR = 3;
private final HydrologyCaveAction action;
private final String fluidProfileKey;
private final String floodedBiomeKey;
private final MatterCavern cavern;
public HydrologyCaveCell(
HydrologyCaveAction action,
String fluidProfileKey,
String floodedBiomeKey
) {
this.action = Objects.requireNonNull(action);
if (fluidProfileKey == null || fluidProfileKey.isBlank()) {
throw new IllegalArgumentException("fluidProfileKey must not be blank");
}
this.fluidProfileKey = fluidProfileKey.trim();
this.floodedBiomeKey = floodedBiomeKey == null ? "" : floodedBiomeKey.trim();
this.cavern = switch (action) {
case WET_SOURCE, FALLING_FLUID -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FLUID);
case DRY_AIR -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FORCED_AIR);
case SEAL_GUARD -> null;
};
}
public static HydrologyCaveCell of(HydrologyCaveAction action) {
return new HydrologyCaveCell(action, DEFAULT_FLUID_PROFILE_KEY, "");
}
public static HydrologyCaveCell of(HydrologyCaveAction action, String fluidProfileKey) {
return new HydrologyCaveCell(action, fluidProfileKey, "");
}
public boolean carves() {
return action != HydrologyCaveAction.SEAL_GUARD;
}
public boolean isWet() {
return action == HydrologyCaveAction.WET_SOURCE || action == HydrologyCaveAction.FALLING_FLUID;
}
public boolean isFalling() {
return action == HydrologyCaveAction.FALLING_FLUID;
}
public boolean protectsPlacement() {
return true;
}
public MatterCavern asCavern() {
return cavern;
}
public HydrologyCaveAction action() {
return action;
}
public String floodedBiomeKey() {
return floodedBiomeKey;
}
public String fluidProfileKey() {
return fluidProfileKey;
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof HydrologyCaveCell hydrology)) {
return false;
}
return action == hydrology.action
&& fluidProfileKey.equals(hydrology.fluidProfileKey)
&& floodedBiomeKey.equals(hydrology.floodedBiomeKey);
}
@Override
public int hashCode() {
return (31 * ((31 * action.hashCode()) + fluidProfileKey.hashCode())) + floodedBiomeKey.hashCode();
}
@Override
public String toString() {
return "HydrologyCaveCell[action=" + action + ", fluidProfileKey=" + fluidProfileKey
+ ", floodedBiomeKey=" + floodedBiomeKey + "]";
}
}
@@ -0,0 +1,82 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
public final class HydrologyCaveConflictPolicy {
private static final Comparator<HydrologyCaveSource> SOURCE_PRIORITY = Comparator
.comparingInt(HydrologyCaveSource::waterHeadY)
.reversed()
.thenComparingLong(HydrologyCaveSource::sourceId)
.thenComparingInt(source -> source.entry().x())
.thenComparingInt(source -> source.entry().y())
.thenComparingInt(source -> source.entry().z())
.thenComparingInt(source -> source.target().x())
.thenComparingInt(source -> source.target().y())
.thenComparingInt(source -> source.target().z())
.thenComparing(HydrologyCaveSource::mode);
private HydrologyCaveConflictPolicy() {
}
public static Comparator<HydrologyCaveSource> sourcePriority() {
return SOURCE_PRIORITY;
}
public static int compareSources(HydrologyCaveSource left, HydrologyCaveSource right) {
return SOURCE_PRIORITY.compare(
Objects.requireNonNull(left, "left"),
Objects.requireNonNull(right, "right")
);
}
public static boolean hasIncompatibleOverlap(
String leftProfileKey,
Map<CavePosition, HydrologyCaveAction> leftActions,
String rightProfileKey,
Map<CavePosition, HydrologyCaveAction> rightActions
) {
String normalizedLeftProfileKey = requireProfileKey(leftProfileKey, "leftProfileKey");
String normalizedRightProfileKey = requireProfileKey(rightProfileKey, "rightProfileKey");
Map<CavePosition, HydrologyCaveAction> normalizedLeftActions = Objects.requireNonNull(
leftActions,
"leftActions"
);
Map<CavePosition, HydrologyCaveAction> normalizedRightActions = Objects.requireNonNull(
rightActions,
"rightActions"
);
if (normalizedLeftActions.size() > normalizedRightActions.size()) {
return hasIncompatibleOverlap(
normalizedRightProfileKey,
normalizedRightActions,
normalizedLeftProfileKey,
normalizedLeftActions
);
}
boolean sameProfile = normalizedLeftProfileKey.equals(normalizedRightProfileKey);
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : normalizedLeftActions.entrySet()) {
CavePosition position = Objects.requireNonNull(entry.getKey(), "left action position");
if (!normalizedRightActions.containsKey(position)) {
continue;
}
HydrologyCaveAction leftAction = Objects.requireNonNull(entry.getValue(), "left action");
HydrologyCaveAction rightAction = Objects.requireNonNull(
normalizedRightActions.get(position),
"right action"
);
if (!sameProfile || leftAction != rightAction) {
return true;
}
}
return false;
}
private static String requireProfileKey(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.engine.hydrology.cave;
public enum HydrologyCaveFluidPolicy {
REJECT_EXISTING,
ALLOW_COMPATIBLE,
REPLACE_CONTAINED
}
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.hydrology.cave;
@FunctionalInterface
public interface HydrologyCaveGrottoShape {
HydrologyCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> {
double horizontalRadius = settings.grottoHorizontalRadius();
double verticalRadius = settings.grottoVerticalRadius();
double normalized = ((double) dx * dx / (horizontalRadius * horizontalRadius))
+ ((double) dy * dy / (verticalRadius * verticalRadius))
+ ((double) dz * dz / (horizontalRadius * horizontalRadius));
return normalized <= 1D;
};
boolean contains(
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
int offsetX,
int offsetY,
int offsetZ
);
}
@@ -0,0 +1,9 @@
package art.arcane.iris.engine.hydrology.cave;
public enum HydrologyCaveMode {
CLOSED_COMPONENT,
GENERATED_GROTTO,
GROTTO_OR_CLOSED_COMPONENT,
WATERFALL_POOL,
DEEP_POOL
}
@@ -0,0 +1,692 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.AbstractCollection;
import java.util.AbstractMap;
import java.util.AbstractSet;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BiPredicate;
public record HydrologyCavePlan(
HydrologyCaveSource source,
HydrologyCaveRejection rejection,
Map<CavePosition, HydrologyCaveAction> actions,
Map<CavePosition, CaveVoxelPrecondition> baselinePreconditions,
OptionalLong arbitrationWinnerSourceId
) {
private static final int ACTION_MASK = 0x03;
private static final int ACTION_PRESENT = 0x04;
private static final int VOXEL_SHIFT = 3;
private static final int VOXEL_MASK = 0x38;
private static final int OPEN_TO_SURFACE = 0x40;
private static final HydrologyCaveAction[] ACTIONS = HydrologyCaveAction.values();
private static final CaveVoxel[] VOXELS = CaveVoxel.values();
private static final CaveVoxelPrecondition[][] PRECONDITIONS = createPreconditions();
public HydrologyCavePlan {
Objects.requireNonNull(source);
Objects.requireNonNull(rejection);
Map<CavePosition, HydrologyCaveAction> requestedActions = Objects.requireNonNull(actions);
Map<CavePosition, CaveVoxelPrecondition> requestedPreconditions =
Objects.requireNonNull(baselinePreconditions);
Objects.requireNonNull(arbitrationWinnerSourceId);
if (rejection != HydrologyCaveRejection.NONE
&& (!requestedActions.isEmpty() || !requestedPreconditions.isEmpty())) {
throw new IllegalArgumentException("Rejected cave plans cannot contain mutations or preconditions");
}
if (!requestedPreconditions.keySet().containsAll(requestedActions.keySet())) {
throw new IllegalArgumentException("Every cave action requires a baseline precondition");
}
if (rejection != HydrologyCaveRejection.OVERLAPPING_SOURCE && arbitrationWinnerSourceId.isPresent()) {
throw new IllegalArgumentException("Only overlap rejections can name an arbitration winner");
}
if (requestedPreconditions.isEmpty()) {
actions = Map.of();
baselinePreconditions = Map.of();
} else {
LinkedHashMap<CavePosition, Byte> packed = new LinkedHashMap<>(requestedPreconditions.size());
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : requestedActions.entrySet()) {
CaveVoxelPrecondition precondition = requestedPreconditions.get(entry.getKey());
packed.put(entry.getKey(), encode(entry.getValue(), precondition));
}
for (Map.Entry<CavePosition, CaveVoxelPrecondition> entry : requestedPreconditions.entrySet()) {
packed.putIfAbsent(entry.getKey(), encode(null, entry.getValue()));
}
Map<CavePosition, Byte> immutablePacked = Collections.unmodifiableMap(packed);
PositionSpatialIndex spatialIndex = new PositionSpatialIndex(immutablePacked);
actions = new ActionMap(immutablePacked, requestedActions.size(), spatialIndex);
baselinePreconditions = new PreconditionMap(
immutablePacked,
List.copyOf(requestedPreconditions.keySet()),
spatialIndex
);
}
}
public boolean accepted() {
return rejection == HydrologyCaveRejection.NONE;
}
public void forEachAction(BiConsumer<CavePosition, HydrologyCaveAction> consumer) {
actions.forEach(consumer);
}
public void forEachPrecondition(BiConsumer<CavePosition, CaveVoxelPrecondition> consumer) {
baselinePreconditions.forEach(consumer);
}
public boolean allPreconditions(BiPredicate<CavePosition, CaveVoxelPrecondition> predicate) {
Objects.requireNonNull(predicate);
for (CavePosition position : baselinePreconditions.keySet()) {
if (!predicate.test(position, baselinePreconditions.get(position))) {
return false;
}
}
return true;
}
public boolean intersectsActions(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ
) {
if (maximumX <= minimumX || maximumZ <= minimumZ || !(actions instanceof ActionMap actionMap)) {
return false;
}
return actionMap.intersects(minimumX, minimumZ, maximumX, maximumZ);
}
public void forEachActionIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
BiConsumer<CavePosition, HydrologyCaveAction> consumer
) {
Objects.requireNonNull(consumer);
if (maximumX <= minimumX || maximumZ <= minimumZ || !(actions instanceof ActionMap actionMap)) {
return;
}
actionMap.forEachIn(minimumX, minimumZ, maximumX, maximumZ, consumer);
}
public boolean allPreconditionsIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
BiPredicate<CavePosition, CaveVoxelPrecondition> predicate
) {
Objects.requireNonNull(predicate);
if (maximumX <= minimumX || maximumZ <= minimumZ
|| !(baselinePreconditions instanceof PreconditionMap preconditionMap)) {
return true;
}
return preconditionMap.allIn(minimumX, minimumZ, maximumX, maximumZ, predicate);
}
private static byte encode(
HydrologyCaveAction action,
CaveVoxelPrecondition precondition
) {
Objects.requireNonNull(precondition);
int packed = precondition.voxel().ordinal() << VOXEL_SHIFT;
if (precondition.openToSurface()) {
packed |= OPEN_TO_SURFACE;
}
if (action != null) {
packed |= ACTION_PRESENT | action.ordinal();
}
return (byte) packed;
}
private static boolean hasAction(byte packed) {
return (packed & ACTION_PRESENT) != 0;
}
private static HydrologyCaveAction decodeAction(byte packed) {
return ACTIONS[packed & ACTION_MASK];
}
private static CaveVoxelPrecondition decodePrecondition(byte packed) {
int unsigned = Byte.toUnsignedInt(packed);
int voxel = unsigned & VOXEL_MASK;
int open = unsigned & OPEN_TO_SURFACE;
return PRECONDITIONS[voxel >>> VOXEL_SHIFT][open == 0 ? 0 : 1];
}
private static CaveVoxelPrecondition[][] createPreconditions() {
if (ACTIONS.length > ACTION_MASK + 1) {
throw new IllegalStateException("Hydrology cave action packing capacity exceeded");
}
if (VOXELS.length > (VOXEL_MASK >>> VOXEL_SHIFT) + 1) {
throw new IllegalStateException("Hydrology cave voxel packing capacity exceeded");
}
CaveVoxelPrecondition[][] preconditions = new CaveVoxelPrecondition[VOXELS.length][2];
for (CaveVoxel voxel : VOXELS) {
preconditions[voxel.ordinal()][0] = new CaveVoxelPrecondition(voxel, false);
preconditions[voxel.ordinal()][1] = new CaveVoxelPrecondition(voxel, true);
}
return preconditions;
}
private static final class ActionMap extends AbstractMap<CavePosition, HydrologyCaveAction> {
private final Map<CavePosition, Byte> packed;
private final int size;
private final PositionSpatialIndex spatialIndex;
private ActionMap(Map<CavePosition, Byte> packed, int size, PositionSpatialIndex spatialIndex) {
this.packed = packed;
this.size = size;
this.spatialIndex = spatialIndex;
}
@Override
public HydrologyCaveAction get(Object key) {
Byte value = packed.get(key);
return value == null || !hasAction(value) ? null : decodeAction(value);
}
@Override
public boolean containsKey(Object key) {
Byte value = packed.get(key);
return value != null && hasAction(value);
}
@Override
public int size() {
return size;
}
@Override
public Set<CavePosition> keySet() {
return new ActionKeySet(packed, size);
}
@Override
public Set<Entry<CavePosition, HydrologyCaveAction>> entrySet() {
return new ActionEntrySet(packed, size);
}
@Override
public Collection<HydrologyCaveAction> values() {
return new ActionValues(packed, size);
}
@Override
public void forEach(BiConsumer<? super CavePosition, ? super HydrologyCaveAction> consumer) {
Objects.requireNonNull(consumer);
for (Map.Entry<CavePosition, Byte> entry : packed.entrySet()) {
if (hasAction(entry.getValue())) {
consumer.accept(entry.getKey(), decodeAction(entry.getValue()));
}
}
}
private boolean intersects(int minimumX, int minimumZ, int maximumX, int maximumZ) {
return spatialIndex.anyIn(minimumX, minimumZ, maximumX, maximumZ,
(CavePosition position) -> hasAction(packed.get(position)));
}
private void forEachIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
BiConsumer<CavePosition, HydrologyCaveAction> consumer
) {
spatialIndex.forEachIn(minimumX, minimumZ, maximumX, maximumZ, (CavePosition position) -> {
byte value = packed.get(position);
if (hasAction(value)) {
consumer.accept(position, decodeAction(value));
}
});
}
}
private static final class PreconditionMap extends AbstractMap<CavePosition, CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private final PositionSpatialIndex spatialIndex;
private PreconditionMap(
Map<CavePosition, Byte> packed,
List<CavePosition> order,
PositionSpatialIndex spatialIndex
) {
this.packed = packed;
this.order = order;
this.spatialIndex = spatialIndex;
}
@Override
public CaveVoxelPrecondition get(Object key) {
Byte value = packed.get(key);
return value == null ? null : decodePrecondition(value);
}
@Override
public boolean containsKey(Object key) {
return packed.containsKey(key);
}
@Override
public int size() {
return order.size();
}
@Override
public Set<CavePosition> keySet() {
return new PreconditionKeySet(packed, order);
}
@Override
public Set<Entry<CavePosition, CaveVoxelPrecondition>> entrySet() {
return new PreconditionEntrySet(packed, order);
}
@Override
public Collection<CaveVoxelPrecondition> values() {
return new PreconditionValues(packed, order);
}
@Override
public void forEach(BiConsumer<? super CavePosition, ? super CaveVoxelPrecondition> consumer) {
Objects.requireNonNull(consumer);
for (CavePosition position : order) {
consumer.accept(position, decodePrecondition(packed.get(position)));
}
}
private boolean allIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
BiPredicate<CavePosition, CaveVoxelPrecondition> predicate
) {
return spatialIndex.allIn(minimumX, minimumZ, maximumX, maximumZ,
(CavePosition position) -> predicate.test(position, decodePrecondition(packed.get(position))));
}
}
private static final class PositionSpatialIndex {
private static final int CHUNK_SHIFT = 4;
private final Map<Long, List<CavePosition>> positionsByChunk;
private PositionSpatialIndex(Map<CavePosition, Byte> packed) {
HashMap<Long, ArrayList<CavePosition>> mutable = new HashMap<>();
for (CavePosition position : packed.keySet()) {
long chunkKey = packChunk(position.x() >> CHUNK_SHIFT, position.z() >> CHUNK_SHIFT);
mutable.computeIfAbsent(chunkKey, ignored -> new ArrayList<>()).add(position);
}
HashMap<Long, List<CavePosition>> immutable = HashMap.newHashMap(mutable.size());
for (Map.Entry<Long, ArrayList<CavePosition>> entry : mutable.entrySet()) {
immutable.put(entry.getKey(), List.copyOf(entry.getValue()));
}
positionsByChunk = Map.copyOf(immutable);
}
private boolean anyIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
java.util.function.Predicate<CavePosition> predicate
) {
int minimumChunkX = minimumX >> CHUNK_SHIFT;
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
int minimumChunkZ = minimumZ >> CHUNK_SHIFT;
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
if (positions == null) {
continue;
}
for (CavePosition position : positions) {
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)
&& predicate.test(position)) {
return true;
}
}
}
}
return false;
}
private boolean allIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
java.util.function.Predicate<CavePosition> predicate
) {
int minimumChunkX = minimumX >> CHUNK_SHIFT;
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
int minimumChunkZ = minimumZ >> CHUNK_SHIFT;
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
if (positions == null) {
continue;
}
for (CavePosition position : positions) {
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)
&& !predicate.test(position)) {
return false;
}
}
}
}
return true;
}
private void forEachIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
java.util.function.Consumer<CavePosition> consumer
) {
int minimumChunkX = minimumX >> CHUNK_SHIFT;
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
int minimumChunkZ = minimumZ >> CHUNK_SHIFT;
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
if (positions == null) {
continue;
}
for (CavePosition position : positions) {
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)) {
consumer.accept(position);
}
}
}
}
}
private static boolean inside(
CavePosition position,
int minimumX,
int minimumZ,
int maximumX,
int maximumZ
) {
return position.x() >= minimumX && position.x() < maximumX
&& position.z() >= minimumZ && position.z() < maximumZ;
}
private static long packChunk(int chunkX, int chunkZ) {
return (long) chunkX << 32 ^ chunkZ & 0xffffffffL;
}
}
private static final class ActionKeySet extends AbstractSet<CavePosition> {
private final Map<CavePosition, Byte> packed;
private final int size;
private ActionKeySet(Map<CavePosition, Byte> packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<CavePosition> iterator() {
return new ActionKeyIterator(packed.entrySet().iterator());
}
@Override
public int size() {
return size;
}
@Override
public boolean contains(Object value) {
Byte packedValue = packed.get(value);
return packedValue != null && hasAction(packedValue);
}
}
private static final class ActionEntrySet extends AbstractSet<Entry<CavePosition, HydrologyCaveAction>> {
private final Map<CavePosition, Byte> packed;
private final int size;
private ActionEntrySet(Map<CavePosition, Byte> packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<Entry<CavePosition, HydrologyCaveAction>> iterator() {
return new ActionEntryIterator(packed.entrySet().iterator());
}
@Override
public int size() {
return size;
}
}
private static final class PreconditionEntrySet
extends AbstractSet<Entry<CavePosition, CaveVoxelPrecondition>> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private PreconditionEntrySet(Map<CavePosition, Byte> packed, List<CavePosition> order) {
this.packed = packed;
this.order = order;
}
@Override
public Iterator<Entry<CavePosition, CaveVoxelPrecondition>> iterator() {
return new PreconditionEntryIterator(packed, order.iterator());
}
@Override
public int size() {
return order.size();
}
}
private static final class PreconditionKeySet extends AbstractSet<CavePosition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private PreconditionKeySet(Map<CavePosition, Byte> packed, List<CavePosition> order) {
this.packed = packed;
this.order = order;
}
@Override
public Iterator<CavePosition> iterator() {
return order.iterator();
}
@Override
public int size() {
return order.size();
}
@Override
public boolean contains(Object value) {
return packed.containsKey(value);
}
}
private static final class ActionValues extends AbstractCollection<HydrologyCaveAction> {
private final Map<CavePosition, Byte> packed;
private final int size;
private ActionValues(Map<CavePosition, Byte> packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<HydrologyCaveAction> iterator() {
return new ActionValueIterator(packed.entrySet().iterator());
}
@Override
public int size() {
return size;
}
}
private static final class PreconditionValues extends AbstractCollection<CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private PreconditionValues(Map<CavePosition, Byte> packed, List<CavePosition> order) {
this.packed = packed;
this.order = order;
}
@Override
public Iterator<CaveVoxelPrecondition> iterator() {
return new PreconditionValueIterator(packed, order.iterator());
}
@Override
public int size() {
return order.size();
}
}
private abstract static class FilteredActionIterator<T> implements Iterator<T> {
private final Iterator<Map.Entry<CavePosition, Byte>> entries;
private Map.Entry<CavePosition, Byte> next;
private FilteredActionIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
this.entries = entries;
}
@Override
public boolean hasNext() {
advance();
return next != null;
}
@Override
public T next() {
advance();
if (next == null) {
throw new NoSuchElementException();
}
Map.Entry<CavePosition, Byte> selected = next;
next = null;
return map(selected);
}
protected abstract T map(Map.Entry<CavePosition, Byte> entry);
private void advance() {
while (next == null && entries.hasNext()) {
Map.Entry<CavePosition, Byte> candidate = entries.next();
if (hasAction(candidate.getValue())) {
next = candidate;
}
}
}
}
private static final class ActionKeyIterator extends FilteredActionIterator<CavePosition> {
private ActionKeyIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
}
@Override
protected CavePosition map(Map.Entry<CavePosition, Byte> entry) {
return entry.getKey();
}
}
private static final class ActionEntryIterator
extends FilteredActionIterator<Entry<CavePosition, HydrologyCaveAction>> {
private ActionEntryIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
}
@Override
protected Entry<CavePosition, HydrologyCaveAction> map(Map.Entry<CavePosition, Byte> entry) {
return Map.entry(entry.getKey(), decodeAction(entry.getValue()));
}
}
private static final class ActionValueIterator extends FilteredActionIterator<HydrologyCaveAction> {
private ActionValueIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
}
@Override
protected HydrologyCaveAction map(Map.Entry<CavePosition, Byte> entry) {
return decodeAction(entry.getValue());
}
}
private static final class PreconditionEntryIterator
implements Iterator<Entry<CavePosition, CaveVoxelPrecondition>> {
private final Map<CavePosition, Byte> packed;
private final Iterator<CavePosition> positions;
private PreconditionEntryIterator(
Map<CavePosition, Byte> packed,
Iterator<CavePosition> positions
) {
this.packed = packed;
this.positions = positions;
}
@Override
public boolean hasNext() {
return positions.hasNext();
}
@Override
public Entry<CavePosition, CaveVoxelPrecondition> next() {
CavePosition position = positions.next();
return Map.entry(position, decodePrecondition(packed.get(position)));
}
}
private static final class PreconditionValueIterator implements Iterator<CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final Iterator<CavePosition> positions;
private PreconditionValueIterator(
Map<CavePosition, Byte> packed,
Iterator<CavePosition> positions
) {
this.packed = packed;
this.positions = positions;
}
@Override
public boolean hasNext() {
return positions.hasNext();
}
@Override
public CaveVoxelPrecondition next() {
return decodePrecondition(packed.get(positions.next()));
}
}
}
@@ -0,0 +1,106 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Objects;
public record HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
int maxThroatLength,
int throatRadius,
int grottoHorizontalRadius,
int grottoVerticalRadius,
int dryHeadroom,
HydrologyCaveFluidPolicy existingFluidPolicy,
HydrologyCaveGrottoShape grottoShape,
int maxClosedComponentHorizontalRadius,
int maxClosedComponentDepth
) {
public static final int MAXIMUM_PLANNED_MUTATIONS = 262_144;
public HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
int maxThroatLength,
int throatRadius,
int grottoHorizontalRadius,
int grottoVerticalRadius,
int dryHeadroom,
HydrologyCaveFluidPolicy existingFluidPolicy,
HydrologyCaveGrottoShape grottoShape
) {
this(
maxHorizontalRadius,
maxDepth,
maxFloodVolume,
maxThroatLength,
throatRadius,
grottoHorizontalRadius,
grottoVerticalRadius,
dryHeadroom,
existingFluidPolicy,
grottoShape,
maxHorizontalRadius,
maxDepth
);
}
public HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
int maxThroatLength,
int grottoHorizontalRadius,
int grottoVerticalRadius,
HydrologyCaveFluidPolicy existingFluidPolicy
) {
this(
maxHorizontalRadius,
maxDepth,
maxFloodVolume,
maxThroatLength,
1,
grottoHorizontalRadius,
grottoVerticalRadius,
0,
existingFluidPolicy,
HydrologyCaveGrottoShape.ELLIPSOID
);
}
public HydrologyCavePlannerSettings {
Objects.requireNonNull(existingFluidPolicy);
Objects.requireNonNull(grottoShape);
if (maxHorizontalRadius < 1) {
throw new IllegalArgumentException("maxHorizontalRadius must be positive");
}
if (maxDepth < 1) {
throw new IllegalArgumentException("maxDepth must be positive");
}
if (maxFloodVolume < 1) {
throw new IllegalArgumentException("maxFloodVolume must be positive");
}
if (maxThroatLength < 1) {
throw new IllegalArgumentException("maxThroatLength must be positive");
}
if (throatRadius < 1) {
throw new IllegalArgumentException("throatRadius must be positive");
}
if (grottoHorizontalRadius < 1) {
throw new IllegalArgumentException("grottoHorizontalRadius must be positive");
}
if (grottoVerticalRadius < 1) {
throw new IllegalArgumentException("grottoVerticalRadius must be positive");
}
if (dryHeadroom < 0) {
throw new IllegalArgumentException("dryHeadroom cannot be negative");
}
if (maxClosedComponentHorizontalRadius < 1) {
throw new IllegalArgumentException("maxClosedComponentHorizontalRadius must be positive");
}
if (maxClosedComponentDepth < 1) {
throw new IllegalArgumentException("maxClosedComponentDepth must be positive");
}
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public record HydrologyCavePlanningResult(
List<HydrologyCavePlan> plans,
Map<CavePosition, HydrologyCaveAction> actions,
Map<CavePosition, CaveVoxelPrecondition> baselinePreconditions
) {
public HydrologyCavePlanningResult {
plans = List.copyOf(Objects.requireNonNull(plans));
actions = Collections.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(actions)));
baselinePreconditions = Collections.unmodifiableMap(
new LinkedHashMap<>(Objects.requireNonNull(baselinePreconditions))
);
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.hydrology.cave;
public enum HydrologyCaveRejection {
NONE,
INVALID_SOURCE,
NO_CAVE_TARGET,
THROAT_LIMIT,
RADIUS_LIMIT,
DEPTH_LIMIT,
VOLUME_LIMIT,
WORLD_BOUNDARY,
OPEN_SURFACE,
LAVA_CONTACT,
EXISTING_FLUID,
INCOMPATIBLE_FLUID,
GROTTO_INTERSECTION,
GROTTO_SHELL_OPEN,
DRY_HEADROOM_LIMIT,
WATERFALL_SHAFT_OPEN,
OVERLAPPING_SOURCE
}
@@ -0,0 +1,17 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Objects;
public record HydrologyCaveSource(
long sourceId,
CavePosition entry,
CavePosition target,
int waterHeadY,
HydrologyCaveMode mode
) {
public HydrologyCaveSource {
Objects.requireNonNull(entry);
Objects.requireNonNull(target);
Objects.requireNonNull(mode);
}
}
@@ -0,0 +1,41 @@
package art.arcane.iris.engine.hydrology.cave;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.TectonicPlate;
import art.arcane.volmlib.util.matter.Matter;
public final class HydrologyCaveStorage {
private HydrologyCaveStorage() {
}
public static HydrologyCaveCell getIfPresent(Mantle<Matter> mantle, int x, int y, int z) {
if (y < 0 || y >= mantle.getWorldHeight()) {
return null;
}
int chunkX = x >> 4;
int chunkZ = z >> 4;
TectonicPlate<Matter> plate = mantle.getLoadedRegions().get(Mantle.key(chunkX >> 5, chunkZ >> 5));
if (plate == null || plate.isClosed()) {
return null;
}
MantleChunk<Matter> chunk = plate.get(chunkX & 31, chunkZ & 31);
return getIfPresent(chunk, x, y, z);
}
public static HydrologyCaveCell getIfPresent(MantleChunk<Matter> chunk, int x, int y, int z) {
if (chunk == null || y < 0) {
return null;
}
int section = y >> 4;
if (!chunk.exists(section)) {
return null;
}
Matter matter = chunk.get(section);
if (matter == null || !matter.hasSlice(HydrologyCaveCell.class)) {
return null;
}
return matter.<HydrologyCaveCell>getSlice(HydrologyCaveCell.class)
.get(x & 15, y & 15, z & 15);
}
}
@@ -0,0 +1,61 @@
package art.arcane.iris.engine.hydrology.policy;
import art.arcane.iris.engine.object.IrisRiverPlacementMode;
import art.arcane.iris.engine.object.IrisRiverRoutingMode;
import java.util.List;
import java.util.Objects;
public record EffectiveRiverPolicy(
IrisRiverPlacementMode placement,
IrisRiverRoutingMode routing,
boolean outletAdmission,
List<String> profiles,
List<String> surfaceBiomes,
List<String> mouthBiomes,
List<String> shoreBiomes,
List<String> bankBiomes,
List<String> floodedCaveBiomes,
List<String> surfacePools,
double widthMultiplier,
double depthMultiplier,
double incisionMultiplier,
double routingMultiplier,
double bankMultiplier
) {
public EffectiveRiverPolicy {
placement = Objects.requireNonNull(placement);
routing = Objects.requireNonNull(routing);
profiles = List.copyOf(profiles);
surfaceBiomes = List.copyOf(surfaceBiomes);
mouthBiomes = List.copyOf(mouthBiomes);
shoreBiomes = List.copyOf(shoreBiomes);
bankBiomes = List.copyOf(bankBiomes);
floodedCaveBiomes = List.copyOf(floodedCaveBiomes);
surfacePools = List.copyOf(surfacePools);
}
public boolean allowsSources() {
return switch (placement) {
case DISABLED, TRANSIT_ONLY -> false;
case NATURAL, PREFERRED_HEADWATER, REQUIRED_HEADWATER -> true;
};
}
public boolean allowsTransit() {
return placement != IrisRiverPlacementMode.DISABLED;
}
public boolean prefersHeadwaters() {
return placement == IrisRiverPlacementMode.PREFERRED_HEADWATER
|| placement == IrisRiverPlacementMode.REQUIRED_HEADWATER;
}
public boolean requiresHeadwaters() {
return placement == IrisRiverPlacementMode.REQUIRED_HEADWATER;
}
public boolean allowsRouting() {
return routing != IrisRiverRoutingMode.BLOCK;
}
}
@@ -0,0 +1,124 @@
package art.arcane.iris.engine.hydrology.policy;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisRiverPlacementMode;
import art.arcane.iris.engine.object.IrisRiverPolicy;
import art.arcane.iris.engine.object.IrisRiverRoutingMode;
import java.util.List;
public final class RiverPolicyResolver {
private RiverPolicyResolver() {
}
public static EffectiveRiverPolicy resolve(IrisDimension dimension, IrisRegion region, IrisBiome biome) {
return resolve(
dimension == null ? null : dimension.getRiverPolicy(),
region == null ? null : region.getRiverPolicy(),
biome == null ? null : biome.getRiverPolicy()
);
}
public static EffectiveRiverPolicy resolve(
IrisRiverPolicy dimensionPolicy,
IrisRiverPolicy regionPolicy,
IrisRiverPolicy biomePolicy
) {
State state = new State();
state.apply(dimensionPolicy);
state.apply(regionPolicy);
state.apply(biomePolicy);
return state.build();
}
private static final class State {
private IrisRiverPlacementMode placement = IrisRiverPlacementMode.NATURAL;
private IrisRiverRoutingMode routing = IrisRiverRoutingMode.ALLOW;
private boolean outletAdmission = true;
private List<String> profiles = List.of();
private List<String> surfaceBiomes = List.of();
private List<String> mouthBiomes = List.of();
private List<String> shoreBiomes = List.of();
private List<String> bankBiomes = List.of();
private List<String> floodedCaveBiomes = List.of();
private List<String> surfacePools = List.of();
private double widthMultiplier = 1D;
private double depthMultiplier = 1D;
private double incisionMultiplier = 1D;
private double routingMultiplier = 1D;
private double bankMultiplier = 1D;
private void apply(IrisRiverPolicy policy) {
if (policy == null) {
return;
}
if (policy.getPlacement() != null) {
placement = policy.getPlacement();
}
if (policy.getRouting() != null) {
routing = policy.getRouting();
}
if (policy.getOutletAdmission() != null) {
outletAdmission = policy.getOutletAdmission();
}
if (policy.getProfiles() != null) {
profiles = List.copyOf(policy.getProfiles());
}
if (policy.getSurfaceBiomes() != null) {
surfaceBiomes = List.copyOf(policy.getSurfaceBiomes());
}
if (policy.getMouthBiomes() != null) {
mouthBiomes = List.copyOf(policy.getMouthBiomes());
}
if (policy.getShoreBiomes() != null) {
shoreBiomes = List.copyOf(policy.getShoreBiomes());
}
if (policy.getBankBiomes() != null) {
bankBiomes = List.copyOf(policy.getBankBiomes());
}
if (policy.getFloodedCaveBiomes() != null) {
floodedCaveBiomes = List.copyOf(policy.getFloodedCaveBiomes());
}
if (policy.getSurfacePools() != null) {
surfacePools = List.copyOf(policy.getSurfacePools());
}
if (policy.getWidthMultiplier() != null) {
widthMultiplier = policy.getWidthMultiplier();
}
if (policy.getDepthMultiplier() != null) {
depthMultiplier = policy.getDepthMultiplier();
}
if (policy.getIncisionMultiplier() != null) {
incisionMultiplier = policy.getIncisionMultiplier();
}
if (policy.getRoutingMultiplier() != null) {
routingMultiplier = policy.getRoutingMultiplier();
}
if (policy.getBankMultiplier() != null) {
bankMultiplier = policy.getBankMultiplier();
}
}
private EffectiveRiverPolicy build() {
return new EffectiveRiverPolicy(
placement,
routing,
outletAdmission,
profiles,
surfaceBiomes,
mouthBiomes,
shoreBiomes,
bankBiomes,
floodedCaveBiomes,
surfacePools,
widthMultiplier,
depthMultiplier,
incisionMultiplier,
routingMultiplier,
bankMultiplier
);
}
}
}
@@ -0,0 +1,57 @@
package art.arcane.iris.engine.hydrology.runtime;
import art.arcane.iris.engine.hydrology.HydrologyTileKey;
import java.util.ArrayList;
import java.util.List;
/**
* Ring order for feature searches: tiles are visited outward from the origin tile so a search can
* stop as soon as no unvisited tile could hold a nearer feature, instead of planning the whole box.
*/
final class HydrologyFeatureSearch {
private HydrologyFeatureSearch() {
}
/** Tiles at exactly Chebyshev distance {@code radius} from the origin tile, in row-major order. */
static List<HydrologyTileKey> ring(int originTileX, int originTileZ, int radius) {
if (radius < 0) {
throw new IllegalArgumentException("radius must not be negative.");
}
if (radius == 0) {
return List.of(new HydrologyTileKey(originTileX, originTileZ));
}
ArrayList<HydrologyTileKey> keys = new ArrayList<>(radius * 8);
for (int tileZ = originTileZ - radius; tileZ <= originTileZ + radius; tileZ++) {
boolean edgeRow = tileZ == originTileZ - radius || tileZ == originTileZ + radius;
for (int tileX = originTileX - radius; tileX <= originTileX + radius; tileX++) {
boolean edgeColumn = tileX == originTileX - radius || tileX == originTileX + radius;
if (edgeRow || edgeColumn) {
keys.add(new HydrologyTileKey(tileX, tileZ));
}
}
}
return List.copyOf(keys);
}
/** Number of rings needed so every tile that can publish within {@code maximumDistance} is visited. */
static int ringLimit(int maximumDistance, int tileSize, int publicationRadius) {
if (maximumDistance < 0 || tileSize <= 0 || publicationRadius < 0) {
throw new IllegalArgumentException("Hydrology feature search bounds are invalid.");
}
long reach = (long) maximumDistance + publicationRadius;
return Math.toIntExact((reach + tileSize - 1) / tileSize);
}
/**
* Smallest block distance a feature published by a ring-{@code radius} tile can have from the
* origin: the ring starts {@code radius - 1} tiles away and a tile's footprint reaches
* {@code publicationRadius} beyond its bounds.
*/
static long lowerBound(int radius, int tileSize, int publicationRadius) {
if (radius <= 0) {
return 0L;
}
return Math.max(0L, (long) (radius - 1) * tileSize - publicationRadius);
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.engine.hydrology.runtime;
final class HydrologyFeatureSearchBounds {
private HydrologyFeatureSearchBounds() {
}
static int maximumDistance(
int x,
int z,
int requestedDistance,
int tileSize,
int publicationRadius,
int maximumTiles
) {
if (requestedDistance < 0 || tileSize <= 0 || publicationRadius < 0 || maximumTiles < 1) {
throw new IllegalArgumentException("Hydrology feature search bounds are invalid.");
}
if (tileCount(x, z, 0, tileSize, publicationRadius) > maximumTiles) {
throw new IllegalArgumentException("Hydrology publication radius exceeds the bounded tile limit.");
}
int minimum = 0;
int maximum = requestedDistance;
int accepted = 0;
while (minimum <= maximum) {
int candidate = minimum + (maximum - minimum) / 2;
if (tileCount(x, z, candidate, tileSize, publicationRadius) <= maximumTiles) {
accepted = candidate;
minimum = candidate + 1;
} else {
maximum = candidate - 1;
}
}
return accepted;
}
static long tileCount(int x, int z, int distance, int tileSize, int publicationRadius) {
int minimumTileX = tileCoordinate((long) x - distance - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) x + distance + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) z - distance - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) z + distance + publicationRadius, tileSize);
return (long) (maximumTileX - minimumTileX + 1) * (maximumTileZ - minimumTileZ + 1);
}
private static int tileCoordinate(long blockCoordinate, int tileSize) {
return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize));
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.engine.hydrology.runtime;
@FunctionalInterface
public interface IrisHydrologyNaturalHeightProvider {
double sample(int blockX, int blockZ);
}
@@ -0,0 +1,6 @@
package art.arcane.iris.engine.hydrology.runtime;
@FunctionalInterface
public interface IrisHydrologyNaturalOceanClassifier {
boolean isOcean(int blockX, int blockZ);
}
@@ -0,0 +1,12 @@
package art.arcane.iris.engine.hydrology.runtime;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
public record IrisHydrologyNaturalSample(
double naturalHeight,
boolean ocean,
IrisBiome biome,
IrisRegion region
) {
}
@@ -0,0 +1,6 @@
package art.arcane.iris.engine.hydrology.runtime;
@FunctionalInterface
public interface IrisHydrologyNaturalSampleProvider {
IrisHydrologyNaturalSample sample(int blockX, int blockZ, double naturalHeight);
}
@@ -0,0 +1,381 @@
package art.arcane.iris.engine.hydrology.runtime;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.hydrology.HydrologyNaturalTerrainSampler;
import art.arcane.iris.engine.hydrology.HydrologyRoutingTerrainSampler;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.util.common.parallel.MultiBurst;
import it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrainSampler, AutoCloseable {
private static final int MINIMUM_PARALLEL_BASIS_SAMPLES = 64;
private static final int COORDINATE_MIX = 0x9E3779B9;
private final BasisProvider basisProvider;
private final IrisHydrologyNaturalHeightProvider heightProvider;
private final IrisHydrologyNaturalOceanClassifier oceanClassifier;
private final int maximumEntries;
private final SamplingOptions samplingOptions;
private final Object lock;
private final Long2ObjectLinkedOpenHashMap<TerrainBasis> bases;
private final Long2DoubleLinkedOpenHashMap naturalHeights;
private final Long2ObjectLinkedOpenHashMap<NaturalClassification> oceanClassifications;
IrisHydrologyRoutingTerrainSampler(Sources sources, SamplingOptions samplingOptions) {
Objects.requireNonNull(sources, "sources");
this.samplingOptions = Objects.requireNonNull(samplingOptions, "samplingOptions");
this.basisProvider = Objects.requireNonNull(sources.basisProvider(), "basisProvider");
this.heightProvider = Objects.requireNonNull(sources.heightProvider(), "heightProvider");
this.oceanClassifier = Objects.requireNonNull(sources.oceanClassifier(), "oceanClassifier");
int maximumEntries = samplingOptions.maximumEntries();
this.maximumEntries = maximumEntries;
this.lock = new Object();
this.bases = new Long2ObjectLinkedOpenHashMap<>(maximumEntries);
this.naturalHeights = new Long2DoubleLinkedOpenHashMap(maximumEntries);
this.naturalHeights.defaultReturnValue(Double.NaN);
this.oceanClassifications = new Long2ObjectLinkedOpenHashMap<>(maximumEntries);
}
@Override
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
int minimumX = request.minimumX();
int minimumZ = request.minimumZ();
int width = request.width();
int spacing = request.spacing();
int planeWidth = Math.addExact(width, 1);
TerrainBasis[] plane = new TerrainBasis[Math.multiplyExact(planeWidth, planeWidth)];
fillBasisPlane(plane, planeWidth, minimumX, minimumZ, spacing);
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[Math.multiplyExact(width, width)];
double slopeScale = 3D / spacing;
int sampleIndex = 0;
for (int gridZ = 0; gridZ < width; gridZ++) {
int planeIndex = gridZ * planeWidth;
for (int gridX = 0; gridX < width; gridX++) {
TerrainBasis center = plane[planeIndex];
double slope = scaledRoutingSlope(
center.naturalHeight(),
plane[planeIndex + 1].naturalHeight(),
plane[planeIndex + planeWidth].naturalHeight(),
slopeScale
);
samples[sampleIndex++] = center.terrain().withSlope(slope);
planeIndex++;
}
}
return samples;
}
@Override
public NaturalClassification classifyNatural(int blockX, int blockZ) {
long packed = pack(blockX, blockZ);
synchronized (lock) {
TerrainBasis basis = bases.getAndMoveToLast(packed);
if (basis != null) {
return basis.terrain().ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND;
}
NaturalClassification cached = oceanClassifications.getAndMoveToLast(packed);
if (cached != null) {
return cached;
}
}
NaturalClassification sampled = oceanClassifier.isOcean(blockX, blockZ)
? NaturalClassification.OCEAN
: NaturalClassification.LAND;
synchronized (lock) {
TerrainBasis basis = bases.getAndMoveToLast(packed);
if (basis != null) {
return basis.terrain().ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND;
}
NaturalClassification existing = oceanClassifications.getAndMoveToLast(packed);
if (existing != null) {
return existing;
}
oceanClassifications.putAndMoveToLast(packed, sampled);
evictOldest(oceanClassifications);
}
return sampled;
}
@Override
public HydrologyTerrainSample sampleBasis(int blockX, int blockZ) {
// Point samples carry the forward slope; the aligned grid derives its own from neighbouring bases.
TerrainBasis basis = basis(blockX, blockZ);
return basis.terrain().withSlope(localSlope(blockX, blockZ, basis.naturalHeight()));
}
TerrainBasis basis(int blockX, int blockZ) {
long packed = pack(blockX, blockZ);
synchronized (lock) {
TerrainBasis cached = bases.getAndMoveToLast(packed);
if (cached != null) {
return cached;
}
}
double naturalHeight = naturalHeight(packed, blockX, blockZ);
TerrainBasis sampled = basisProvider.sample(blockX, blockZ, naturalHeight);
if (sampled == null) {
throw new NullPointerException(
"Hydrology terrain basis provider returned null at " + blockX + "," + blockZ
);
}
synchronized (lock) {
TerrainBasis existing = bases.getAndMoveToLast(packed);
if (existing != null) {
return existing;
}
bases.putAndMoveToLast(packed, sampled);
oceanClassifications.remove(packed);
evictOldest(bases);
}
return sampled;
}
int basisCacheSize() {
synchronized (lock) {
return bases.size();
}
}
int naturalHeightCacheSize() {
synchronized (lock) {
return naturalHeights.size();
}
}
int oceanClassificationCacheSize() {
synchronized (lock) {
return oceanClassifications.size();
}
}
@Override
public void close() {
synchronized (lock) {
bases.clear();
naturalHeights.clear();
oceanClassifications.clear();
}
}
static double localSlope(double naturalHeight, double easternHeight, double southernHeight) {
double deltaX = easternHeight - naturalHeight;
double deltaZ = southernHeight - naturalHeight;
return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ);
}
double localSlope(int blockX, int blockZ, double naturalHeight) {
double easternHeight = naturalHeight(blockX + 3, blockZ);
double southernHeight = naturalHeight(blockX, blockZ + 3);
return localSlope(naturalHeight, easternHeight, southernHeight);
}
static double routingSlope(
double naturalHeight,
double easternHeight,
double southernHeight,
int spacing
) {
if (spacing < 1) {
throw new IllegalArgumentException("spacing must be positive");
}
double scale = 3D / spacing;
return scaledRoutingSlope(naturalHeight, easternHeight, southernHeight, scale);
}
private static double scaledRoutingSlope(
double naturalHeight,
double easternHeight,
double southernHeight,
double scale
) {
double deltaX = (easternHeight - naturalHeight) * scale;
double deltaZ = (southernHeight - naturalHeight) * scale;
return StrictMath.sqrt(deltaX * deltaX + deltaZ * deltaZ);
}
private void fillBasisPlane(
TerrainBasis[] plane,
int planeWidth,
int minimumX,
int minimumZ,
int spacing
) {
int workerCount = gridWorkerCount(planeWidth, plane.length);
if (workerCount == 1) {
fillBasisRows(plane, planeWidth, minimumX, minimumZ, spacing, 0, planeWidth);
return;
}
int rowsPerWorker = Math.ceilDiv(planeWidth, workerCount);
ArrayList<CompletableFuture<Void>> futures = new ArrayList<>(workerCount);
for (int workerIndex = 0; workerIndex < workerCount; workerIndex++) {
int minimumRow = workerIndex * rowsPerWorker;
if (minimumRow >= planeWidth) {
break;
}
int maximumRow = Math.min(planeWidth, minimumRow + rowsPerWorker);
futures.add(CompletableFuture.runAsync(
() -> fillBasisRows(
plane,
planeWidth,
minimumX,
minimumZ,
spacing,
minimumRow,
maximumRow
),
samplingOptions.executor()
));
}
await(futures);
}
private void fillBasisRows(
TerrainBasis[] plane,
int planeWidth,
int minimumX,
int minimumZ,
int spacing,
int minimumRow,
int maximumRow
) {
for (int gridZ = minimumRow; gridZ < maximumRow; gridZ++) {
int z = Math.toIntExact((long) minimumZ + (long) gridZ * spacing);
int planeIndex = gridZ * planeWidth;
for (int gridX = 0; gridX < planeWidth; gridX++) {
int x = Math.toIntExact((long) minimumX + (long) gridX * spacing);
plane[planeIndex++] = basis(x, z);
}
}
}
private int gridWorkerCount(int planeWidth, int planeSize) {
if (samplingOptions.maximumWorkers() == 1
|| planeSize < MINIMUM_PARALLEL_BASIS_SAMPLES) {
return 1;
}
return Math.min(planeWidth, samplingOptions.maximumWorkers());
}
private static void await(List<CompletableFuture<Void>> futures) {
CompletableFuture<?>[] completions = futures.toArray(new CompletableFuture<?>[futures.size()]);
try {
CompletableFuture.allOf(completions).join();
} catch (CompletionException failure) {
Throwable cause = failure.getCause();
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (cause instanceof Error error) {
throw error;
}
throw new IllegalStateException("Hydrology routing terrain sampling failed.", cause);
}
}
private void evictOldest(Long2ObjectLinkedOpenHashMap<?> cache) {
if (cache.size() <= maximumEntries) {
return;
}
cache.removeFirst();
}
private void evictOldest(Long2DoubleLinkedOpenHashMap cache) {
if (cache.size() <= maximumEntries) {
return;
}
cache.removeFirstDouble();
}
private double naturalHeight(int blockX, int blockZ) {
return naturalHeight(pack(blockX, blockZ), blockX, blockZ);
}
private double naturalHeight(long packed, int blockX, int blockZ) {
synchronized (lock) {
double cached = naturalHeights.getAndMoveToLast(packed);
if (!Double.isNaN(cached) || naturalHeights.containsKey(packed)) {
return cached;
}
}
double sampled = heightProvider.sample(blockX, blockZ);
synchronized (lock) {
double existing = naturalHeights.getAndMoveToLast(packed);
if (!Double.isNaN(existing) || naturalHeights.containsKey(packed)) {
return existing;
}
naturalHeights.putAndMoveToLast(packed, sampled);
evictOldest(naturalHeights);
}
return sampled;
}
private static long pack(int blockX, int blockZ) {
int mixedZ = blockZ ^ Integer.rotateLeft(blockX * COORDINATE_MIX, 16);
return ((long) blockX << 32) ^ (mixedZ & 0xffffffffL);
}
@FunctionalInterface
interface BasisProvider {
TerrainBasis sample(int blockX, int blockZ, double naturalHeight);
}
record Sources(
BasisProvider basisProvider,
IrisHydrologyNaturalHeightProvider heightProvider,
IrisHydrologyNaturalOceanClassifier oceanClassifier
) {
Sources {
Objects.requireNonNull(basisProvider, "basisProvider");
Objects.requireNonNull(heightProvider, "heightProvider");
Objects.requireNonNull(oceanClassifier, "oceanClassifier");
}
}
record SamplingOptions(
int maximumEntries,
int maximumWorkers,
Executor executor
) {
SamplingOptions {
if (maximumEntries < 1) {
throw new IllegalArgumentException("maximumEntries must be positive");
}
if (maximumWorkers < 1) {
throw new IllegalArgumentException("maximumWorkers must be positive");
}
Objects.requireNonNull(executor, "executor");
}
static SamplingOptions production(int maximumEntries) {
int configuredWorkers = IrisSettings.getThreadCount(
IrisSettings.get().getConcurrency().getParallelism()
);
return new SamplingOptions(
maximumEntries,
Math.max(1, configuredWorkers),
MultiBurst.burst
);
}
static SamplingOptions serial(int maximumEntries) {
return new SamplingOptions(maximumEntries, 1, Runnable::run);
}
}
record TerrainBasis(double naturalHeight, HydrologyTerrainSample terrain) {
TerrainBasis {
if (!Double.isFinite(naturalHeight)) {
throw new IllegalArgumentException("naturalHeight must be finite");
}
Objects.requireNonNull(terrain, "terrain");
}
}
}
@@ -0,0 +1,958 @@
package art.arcane.iris.engine.hydrology.runtime;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlanner;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyRenderSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTile;
import art.arcane.iris.engine.hydrology.HydrologyTileCache;
import art.arcane.iris.engine.hydrology.HydrologyTileKey;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import art.arcane.iris.engine.hydrology.policy.EffectiveRiverPolicy;
import art.arcane.iris.engine.hydrology.policy.RiverPolicyResolver;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisCoastalRiverGrottoConfig;
import art.arcane.iris.engine.object.IrisDeepFluidConfig;
import art.arcane.iris.engine.object.IrisSurfacePoolConfig;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisHydrology;
import art.arcane.iris.engine.object.IrisInlandRiverGrottoConfig;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisRiverGrottoConfig;
import art.arcane.iris.engine.object.IrisRiverChannelShapeConfig;
import art.arcane.iris.engine.object.IrisRiverDropShapeConfig;
import art.arcane.iris.engine.object.IrisRiverGeometryConfig;
import art.arcane.iris.engine.object.IrisRiverHydrology;
import art.arcane.iris.engine.object.IrisRiverInlandOutlet;
import art.arcane.iris.engine.object.IrisRiverMeanderConfig;
import art.arcane.iris.engine.object.IrisRiverProfile;
import art.arcane.iris.engine.object.IrisRiverRoutingConfig;
import art.arcane.iris.engine.object.IrisRiverRoutingMode;
import art.arcane.iris.engine.object.IrisStyledRange;
import art.arcane.iris.engine.object.IrisSurfaceRiverBankConfig;
import art.arcane.iris.engine.object.IrisSurfaceRiverChannelConfig;
import art.arcane.iris.engine.object.IrisSurfaceRiverFlowConfig;
import art.arcane.iris.engine.object.IrisSurfaceRiverConfig;
import art.arcane.iris.engine.object.IrisSurfaceRiverSourceConfig;
import art.arcane.iris.engine.object.IrisUndergroundRiverConfig;
import art.arcane.iris.engine.object.IrisUndergroundRiverSourceConfig;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.volmlib.util.math.RNG;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.IntConsumer;
public final class IrisHydrologyRuntime implements AutoCloseable {
private static final int MAXIMUM_CACHE_TILES = 64;
private static final int MAXIMUM_TERRAIN_SAMPLES = 65_536;
private static final int MAXIMUM_FEATURE_SEARCH_TILES = 1_089;
private static final int BIOME_PATCH_SCALE = 32;
private static final long SURFACE_WIDTH_SALT = 0x5355524657494454L;
private static final long SURFACE_DEPTH_SALT = 0x5355524644455054L;
private static final long SURFACE_BLEND_SALT = 0x53555246424c454eL;
private static final long UNDERGROUND_LEVEL_SALT = 0x554e4445524c564cL;
private static final long UNDERGROUND_WIDTH_SALT = 0x554e444552574944L;
private static final long UNDERGROUND_DEPTH_SALT = 0x554e444552444550L;
private static final long UNDERGROUND_HEADROOM_SALT = 0x554e444552484541L;
private static final long DEEP_HEIGHT_SALT = 0x4445455048454947L;
private final IrisHydrologyRuntimeContext context;
private final IrisRiverHydrology rivers;
private final HydrologyPlannerSettings settings;
private final HydrologyTileCache cache;
private final IrisHydrologyRoutingTerrainSampler routingTerrainSampler;
private final Set<String> profileKeys;
private final String defaultProfileKey;
private final Object terrainSampleLock;
private final LinkedHashMap<Long, HydrologyTerrainSample> terrainSamples;
public IrisHydrologyRuntime(IrisHydrologyRuntimeContext context) {
this.context = Objects.requireNonNull(context);
IrisHydrology hydrology = Objects.requireNonNull(context.dimension().getHydrology());
this.rivers = Objects.requireNonNull(hydrology.getRivers());
this.profileKeys = profileKeys(rivers.getProfiles());
this.defaultProfileKey = profileKeys.isEmpty() ? "default" : profileKeys.iterator().next();
this.terrainSampleLock = new Object();
this.terrainSamples = new LinkedHashMap<>(MAXIMUM_TERRAIN_SAMPLES, 0.75F, true);
this.settings = createSettings(context.dimension(), hydrology);
HydrologyGeometrySampler geometrySampler = geometrySampler(context, hydrology);
IrisHydrologyRoutingTerrainSampler.Sources terrainSources = new IrisHydrologyRoutingTerrainSampler.Sources(
this::createTerrainBasis,
(int x, int z) -> context.naturalHeightProvider().sample(x, z),
(int x, int z) -> context.naturalOceanClassifier().isOcean(x, z)
);
this.routingTerrainSampler = new IrisHydrologyRoutingTerrainSampler(
terrainSources,
IrisHydrologyRoutingTerrainSampler.SamplingOptions.production(MAXIMUM_TERRAIN_SAMPLES)
);
HydrologyPlanner planner = new HydrologyPlanner(
context.seed(),
settings,
this::sampleTerrain,
routingTerrainSampler,
geometrySampler,
0,
context.caveViewFactory()
);
this.cache = new HydrologyTileCache(planner, MAXIMUM_CACHE_TILES);
}
public HydrologyPlannerSettings settings() {
return settings;
}
public HydrologyTile tile(HydrologyTileKey key) {
return cache.get(key);
}
public Optional<HydrologyColumnSample> sample(double x, double z) {
int blockX = (int) StrictMath.floor(x);
int blockZ = (int) StrictMath.floor(z);
return cache.columnAt(blockX, blockZ);
}
public void prepareChunkColumns(int blockX, int blockZ) {
cache.prepareChunkColumns(blockX, blockZ);
}
public HydrologyRenderSample renderSample(double x, double z) {
int blockX = (int) StrictMath.floor(x);
int blockZ = (int) StrictMath.floor(z);
return cache.renderAt(blockX, blockZ);
}
public boolean hasAcceptedSurfaceBiomeInChunk(String biomeKey, int chunkX, int chunkZ) {
if (biomeKey == null || biomeKey.isBlank()) {
return false;
}
int minimumX = chunkX << 4;
int minimumZ = chunkZ << 4;
int maximumX = Math.addExact(minimumX, 16);
int maximumZ = Math.addExact(minimumZ, 16);
int tileSize = settings.routing().tileSize();
int publicationRadius = settings.publicationRadius();
int minimumTileX = tileCoordinate((long) minimumX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) maximumX - 1L + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) minimumZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) maximumZ - 1L + publicationRadius, tileSize);
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ));
for (HydrologyColumnSample column : tile.footprint().columnsIn(
minimumX,
minimumZ,
maximumX,
maximumZ
)) {
HydrologyColumnLayer layer = column.primarySurfaceLayer().orElse(null);
if (layer != null && biomeKey.equals(layer.biomeKey())) {
return true;
}
}
}
}
return false;
}
public List<HydrologyCavePlan> cavePlansIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ
) {
if (maximumX <= minimumX || maximumZ <= minimumZ) {
return List.of();
}
int tileSize = settings.routing().tileSize();
int publicationRadius = settings.publicationRadius();
int minimumTileX = tileCoordinate((long) minimumX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) maximumX - 1L + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) minimumZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) maximumZ - 1L + publicationRadius, tileSize);
LinkedHashMap<Long, HydrologyCavePlan> plans = new LinkedHashMap<>();
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ));
for (HydrologyCavePlan plan : tile.cavePlans()) {
HydrologyCavePlan existing = plans.putIfAbsent(plan.source().sourceId(), plan);
if (existing != null && !existing.equals(plan)) {
throw new IllegalStateException("Hydrology cave plan id collision.");
}
}
}
}
return List.copyOf(plans.values());
}
public HydrologyRenderSample sampleRenderFootprint(
double minimumX,
double minimumZ,
double maximumX,
double maximumZ
) {
int minX = (int) StrictMath.floor(Math.min(minimumX, maximumX));
int minZ = (int) StrictMath.floor(Math.min(minimumZ, maximumZ));
int maxX = (int) StrictMath.ceil(Math.max(minimumX, maximumX));
int maxZ = (int) StrictMath.ceil(Math.max(minimumZ, maximumZ));
if (maxX <= minX) {
maxX = minX + 1;
}
if (maxZ <= minZ) {
maxZ = minZ + 1;
}
LinkedHashMap<Long, HydrologyFeatureRef> features = new LinkedHashMap<>();
int tileSize = settings.routing().tileSize();
int publicationRadius = settings.publicationRadius();
int minimumTileX = tileCoordinate((long) minX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) maxX - 1L + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) minZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) maxZ - 1L + publicationRadius, tileSize);
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ));
for (HydrologyColumnSample column : tile.footprint().columnsIn(minX, minZ, maxX, maxZ)) {
for (HydrologyFeatureRef feature : column.renderSample().features()) {
HydrologyFeatureRef existing = features.putIfAbsent(feature.id(), feature);
if (existing != null && !existing.equals(feature)) {
throw new IllegalStateException("Hydrology feature id collision in renderer footprint.");
}
}
}
}
}
return new HydrologyRenderSample(minX, minZ, List.copyOf(features.values()));
}
public HydrologyDiagnosticRenderSample sampleDiagnosticFootprint(
double minimumX,
double minimumZ,
double maximumX,
double maximumZ
) {
int minX = (int) StrictMath.floor(Math.min(minimumX, maximumX));
int minZ = (int) StrictMath.floor(Math.min(minimumZ, maximumZ));
int maxX = (int) StrictMath.ceil(Math.max(minimumX, maximumX));
int maxZ = (int) StrictMath.ceil(Math.max(minimumZ, maximumZ));
if (maxX <= minX) {
maxX = minX + 1;
}
if (maxZ <= minZ) {
maxZ = minZ + 1;
}
int tileSize = settings.routing().tileSize();
int publicationRadius = settings.publicationRadius();
int minimumTileX = tileCoordinate((long) minX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) maxX - 1L + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) minZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) maxZ - 1L + publicationRadius, tileSize);
LinkedHashMap<Long, HydrologyDiagnosticCandidate> candidates = new LinkedHashMap<>();
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ));
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
if (candidate.point().x() < minX || candidate.point().x() >= maxX
|| candidate.point().z() < minZ || candidate.point().z() >= maxZ) {
continue;
}
HydrologyDiagnosticCandidate existing = candidates.putIfAbsent(candidate.id(), candidate);
if (existing != null && !existing.equals(candidate)) {
throw new IllegalStateException("Hydrology diagnostic candidate id collision.");
}
}
}
}
return new HydrologyDiagnosticRenderSample(minX, minZ, List.copyOf(candidates.values()));
}
public Optional<HydrologyFeatureRef> nearestFeature(
Set<HydrologyFeatureType> types,
String profileKey,
int x,
int z,
int maximumDistance
) {
return nearestFeature(types, profileKey, x, z, maximumDistance, (int visited) -> { });
}
/**
* Walks tiles outward from the origin, planning each on demand, and stops as soon as no unvisited
* ring can hold a nearer feature. {@code progress} receives the running tile count after every ring.
*/
public Optional<HydrologyFeatureRef> nearestFeature(
Set<HydrologyFeatureType> types,
String profileKey,
int x,
int z,
int maximumDistance,
IntConsumer progress
) {
Objects.requireNonNull(types);
Objects.requireNonNull(progress);
if (types.isEmpty() || maximumDistance < 0) {
return Optional.empty();
}
int tileSize = settings.routing().tileSize();
int publicationRadius = settings.publicationRadius();
long tileCount = HydrologyFeatureSearchBounds.tileCount(
x, z, maximumDistance, tileSize, publicationRadius);
if (tileCount > MAXIMUM_FEATURE_SEARCH_TILES) {
throw new IllegalArgumentException("Hydrology feature search exceeds the bounded tile limit.");
}
int originTileX = tileCoordinate(x, tileSize);
int originTileZ = tileCoordinate(z, tileSize);
int ringLimit = HydrologyFeatureSearch.ringLimit(maximumDistance, tileSize, publicationRadius);
HydrologyFeatureRef nearest = null;
long nearestDistanceSquared = Long.MAX_VALUE;
int visited = 0;
for (int ring = 0; ring <= ringLimit; ring++) {
long lowerBound = HydrologyFeatureSearch.lowerBound(ring, tileSize, publicationRadius);
if (nearest != null && lowerBound * lowerBound > nearestDistanceSquared) {
break;
}
for (HydrologyTileKey key : HydrologyFeatureSearch.ring(originTileX, originTileZ, ring)) {
HydrologyTile tile = cache.get(key);
visited++;
HydrologyFeatureRef feature = tile.nearestFeature(
types, profileKey, x, z, maximumDistance).orElse(null);
if (feature == null) {
continue;
}
long deltaX = (long) feature.x() - x;
long deltaZ = (long) feature.z() - z;
long distanceSquared = deltaX * deltaX + deltaZ * deltaZ;
if (nearest == null || distanceSquared < nearestDistanceSquared
|| distanceSquared == nearestDistanceSquared && feature.id() < nearest.id()) {
nearest = feature;
nearestDistanceSquared = distanceSquared;
}
}
progress.accept(visited);
}
return Optional.ofNullable(nearest);
}
public int maximumFeatureSearchDistance(int x, int z, int requestedDistance) {
return HydrologyFeatureSearchBounds.maximumDistance(
x,
z,
requestedDistance,
settings.routing().tileSize(),
settings.publicationRadius(),
MAXIMUM_FEATURE_SEARCH_TILES
);
}
public Set<String> profileKeys() {
return profileKeys;
}
public List<String> featureQueryKeys() {
ArrayList<String> profiledIds = new ArrayList<>(settings.deepFluids().size() + settings.surfacePools().size());
for (HydrologyPlannerSettings.DeepFluid deepFluid : settings.deepFluids()) {
profiledIds.add(deepFluid.id());
}
for (HydrologyPlannerSettings.SurfacePool pool : settings.surfacePools()) {
profiledIds.add(pool.id());
}
return HydrologyFeatureQuery.suggestions(profiledIds);
}
@Override
public void close() {
cache.close();
routingTerrainSampler.close();
synchronized (terrainSampleLock) {
terrainSamples.clear();
}
}
private HydrologyTerrainSample sampleTerrain(int x, int z) {
long packed = ((long) x << 32) ^ (z & 0xffffffffL);
synchronized (terrainSampleLock) {
HydrologyTerrainSample cached = terrainSamples.get(packed);
if (cached != null) {
return cached;
}
}
HydrologyTerrainSample sampled = createDetailedTerrainSample(x, z);
synchronized (terrainSampleLock) {
HydrologyTerrainSample existing = terrainSamples.get(packed);
if (existing != null) {
return existing;
}
terrainSamples.put(packed, sampled);
if (terrainSamples.size() > MAXIMUM_TERRAIN_SAMPLES) {
Iterator<Long> iterator = terrainSamples.keySet().iterator();
iterator.next();
iterator.remove();
}
}
return sampled;
}
private HydrologyTerrainSample createDetailedTerrainSample(int x, int z) {
return routingTerrainSampler.sampleBasis(x, z);
}
private IrisHydrologyRoutingTerrainSampler.TerrainBasis createTerrainBasis(
int x,
int z,
double rawNaturalHeight
) {
IrisHydrologyNaturalSample naturalSample = Objects.requireNonNull(
context.naturalSampleProvider().sample(x, z, rawNaturalHeight),
"Hydrology natural sample provider returned null at " + x + "," + z
);
double sampledNaturalHeight = naturalSample.naturalHeight();
double resolvedNaturalHeight = Double.isFinite(sampledNaturalHeight)
? sampledNaturalHeight
: rawNaturalHeight;
if (!Double.isFinite(resolvedNaturalHeight)) {
throw new IllegalStateException("Hydrology natural height was not finite at " + x + "," + z);
}
int naturalHeight = (int) StrictMath.round(resolvedNaturalHeight);
boolean ocean = naturalSample.ocean();
IrisBiome biome = naturalSample.biome();
IrisRegion region = naturalSample.region();
EffectiveRiverPolicy policy = RiverPolicyResolver.resolve(context.dimension(), region, biome);
String parentBiomeKey = requireBiomeKey(biome);
List<String> profiles = validProfiles(policy.profiles());
int configuredFluidY = settings.underground().minimumFluidY()
+ (settings.underground().maximumFluidY() - settings.underground().minimumFluidY()) / 2;
if (!rivers.getUnderground().getFluidLevel().isFlat()) {
int lowestFluidY = Math.max(
settings.underground().minimumFluidY(),
settings.underground().maximumDepth() + 1
);
int highestFluidY = Math.min(
settings.underground().maximumFluidY(),
naturalHeight - settings.underground().minimumHeadroom() - 1
);
if (lowestFluidY <= highestFluidY) {
configuredFluidY = lowestFluidY + (highestFluidY - lowestFluidY) / 2;
}
}
int caveFloorY = configuredFluidY - settings.underground().maximumDepth();
boolean caveAvailable = caveFloorY > 0
&& configuredFluidY + settings.underground().maximumHeadroom() < naturalHeight;
boolean routingAllowed = policy.allowsTransit() && policy.allowsRouting() && !ocean;
boolean sourceAllowed = routingAllowed && policy.allowsSources();
double sourceWeight = switch (policy.placement()) {
case DISABLED, TRANSIT_ONLY -> 0D;
case NATURAL -> 1D;
case PREFERRED_HEADWATER -> 4D;
case REQUIRED_HEADWATER -> 8D;
};
double routingCost = policy.routing() == IrisRiverRoutingMode.AVOID ? 1024D : 0D;
double routingPreference = policy.routing() == IrisRiverRoutingMode.PREFER ? 0.5D : 1D;
double biomePatchNoise = coherentPatchNoise(context.seed(), x, z);
HydrologyTerrainSample terrain = new HydrologyTerrainSample(
naturalHeight,
0D,
ocean,
caveAvailable,
caveFloorY,
configuredFluidY,
routingAllowed,
routingAllowed && policy.outletAdmission(),
sourceAllowed,
sourceAllowed && policy.requiresHeadwaters(),
sourceAllowed && caveAvailable,
sourceAllowed && caveAvailable && policy.requiresHeadwaters(),
routingCost,
sourceWeight,
sourceWeight,
policy.widthMultiplier(),
policy.depthMultiplier(),
policy.incisionMultiplier(),
policy.routingMultiplier() * routingPreference,
policy.bankMultiplier(),
parentBiomeKey,
selectKey(policy.surfaceBiomes(), parentBiomeKey, biomePatchNoise, 1),
selectKey(policy.mouthBiomes(), parentBiomeKey, biomePatchNoise, 2),
selectKey(policy.shoreBiomes(), parentBiomeKey, biomePatchNoise, 3),
selectKey(policy.bankBiomes(), parentBiomeKey, biomePatchNoise, 4),
selectKey(policy.floodedCaveBiomes(), parentBiomeKey, biomePatchNoise, 5),
profiles,
policy.surfacePools()
);
return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(resolvedNaturalHeight, terrain);
}
private List<String> validProfiles(List<String> requested) {
if (requested == null || requested.isEmpty()) {
return List.of(defaultProfileKey);
}
ArrayList<String> valid = new ArrayList<>();
for (String key : requested) {
if (key != null && profileKeys.contains(key) && !valid.contains(key)) {
valid.add(key);
}
}
return valid.isEmpty() ? List.of(defaultProfileKey) : List.copyOf(valid);
}
private String selectKey(List<String> keys, String fallback, double patchNoise, int salt) {
if (keys == null || keys.isEmpty()) {
return fallback;
}
return keys.get(keyIndex(patchNoise, salt, keys.size()));
}
static int coherentKeyIndex(long seed, int x, int z, int salt, int keyCount) {
if (keyCount <= 0) {
throw new IllegalArgumentException("keyCount must be positive");
}
if (keyCount == 1) {
return 0;
}
return keyIndex(coherentPatchNoise(seed, x, z), salt, keyCount);
}
private static int keyIndex(double patchNoise, int salt, int keyCount) {
double shifted = patchNoise + salt * 0.3819660112501051D;
shifted -= StrictMath.floor(shifted);
return Math.min(keyCount - 1, (int) StrictMath.floor(shifted * keyCount));
}
private static double coherentPatchNoise(long seed, int x, int z) {
int cellX = Math.floorDiv(x, BIOME_PATCH_SCALE);
int cellZ = Math.floorDiv(z, BIOME_PATCH_SCALE);
double localX = Math.floorMod(x, BIOME_PATCH_SCALE) / (double) BIOME_PATCH_SCALE;
double localZ = Math.floorMod(z, BIOME_PATCH_SCALE) / (double) BIOME_PATCH_SCALE;
double smoothX = localX * localX * (3D - 2D * localX);
double smoothZ = localZ * localZ * (3D - 2D * localZ);
double top = interpolate(
keyNoise(seed, cellX, cellZ),
keyNoise(seed, cellX + 1, cellZ),
smoothX
);
double bottom = interpolate(
keyNoise(seed, cellX, cellZ + 1),
keyNoise(seed, cellX + 1, cellZ + 1),
smoothX
);
return interpolate(top, bottom, smoothZ);
}
private static double keyNoise(long seed, int cellX, int cellZ) {
long mixed = avalanche(seed ^ 0x9e3779b97f4a7c15L);
mixed = avalanche(mixed ^ avalanche(cellX + 0x9e3779b97f4a7c15L));
mixed = avalanche(mixed ^ avalanche(cellZ + 0x9e3779b97f4a7c15L));
return (avalanche(mixed) >>> 11) * 0x1.0p-53;
}
private static long avalanche(long value) {
value ^= value >>> 30;
value *= 0xbf58476d1ce4e5b9L;
value ^= value >>> 27;
value *= 0x94d049bb133111ebL;
return value ^ value >>> 31;
}
private static double interpolate(double first, double second, double progress) {
return first + (second - first) * progress;
}
private static HydrologyGeometrySampler geometrySampler(
IrisHydrologyRuntimeContext context,
IrisHydrology hydrology
) {
IrisRiverHydrology rivers = hydrology.getRivers();
IrisSurfaceRiverConfig surface = rivers.getSurface();
IrisSurfaceRiverChannelConfig channel = surface.getChannel();
IrisSurfaceRiverBankConfig banks = surface.getBanks();
IrisUndergroundRiverConfig underground = rivers.getUnderground();
ProceduralStream<Double> surfaceWidth = styledStream(context, channel.getWidth(), SURFACE_WIDTH_SALT);
ProceduralStream<Double> surfaceDepth = styledStream(context, channel.getDepth(), SURFACE_DEPTH_SALT);
ProceduralStream<Double> surfaceBlend = ProceduralStream.ofDouble((x, z) -> (double) banks.getMaximumBlendWidth());
ProceduralStream<Double> undergroundLevel = styledStream(
context,
underground.getFluidLevel(),
UNDERGROUND_LEVEL_SALT
);
ProceduralStream<Double> undergroundWidth = styledStream(
context,
underground.getChannelWidth(),
UNDERGROUND_WIDTH_SALT
);
ProceduralStream<Double> undergroundDepth = styledStream(
context,
underground.getDepth(),
UNDERGROUND_DEPTH_SALT
);
ProceduralStream<Double> undergroundHeadroom = styledStream(
context,
underground.getHeadroom(),
UNDERGROUND_HEADROOM_SALT
);
LinkedHashMap<String, ProceduralStream<Double>> deepHeights = new LinkedHashMap<>();
for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) {
long profileSalt = DEEP_HEIGHT_SALT ^ deepFluid.getId().hashCode();
deepHeights.put(deepFluid.getId(), styledStream(context, deepFluid.getHeight(), profileSalt));
}
int minimumWorldY = context.dimension().getMinHeight();
return request -> {
ProceduralStream<Double> stream = switch (request.field()) {
case SURFACE_WIDTH -> surfaceWidth;
case SURFACE_DEPTH -> surfaceDepth;
case SURFACE_BLEND_WIDTH -> surfaceBlend;
case UNDERGROUND_FLUID_LEVEL -> undergroundLevel;
case UNDERGROUND_WIDTH -> undergroundWidth;
case UNDERGROUND_DEPTH -> undergroundDepth;
case UNDERGROUND_HEADROOM -> undergroundHeadroom;
case DEEP_FLUID_HEIGHT -> Objects.requireNonNull(
deepHeights.get(request.profileKey()),
"Missing deep-fluid height style for " + request.profileKey() + "."
);
};
double sampled = stream.get(request.x(), request.z());
if (!Double.isFinite(sampled)) {
throw new IllegalStateException(
"Hydrology geometry style returned a non-finite value for " + request.field() + "."
);
}
long rounded = StrictMath.round(sampled);
if (request.field() == HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL
|| request.field() == HydrologyGeometrySampler.Field.DEEP_FLUID_HEIGHT) {
rounded = Math.subtractExact(rounded, minimumWorldY);
}
int value = Math.toIntExact(rounded);
return Math.max(request.minimum(), Math.min(request.maximum(), value));
};
}
private static ProceduralStream<Double> styledStream(
IrisHydrologyRuntimeContext context,
IrisStyledRange range,
long salt
) {
return range.stream(new RNG(context.seed() ^ salt), context.data());
}
static HydrologyPlannerSettings createSettings(
IrisDimension dimension,
IrisHydrology hydrology
) {
IrisRiverHydrology rivers = hydrology.getRivers();
IrisRiverRoutingConfig routing = rivers.getRouting();
IrisSurfaceRiverConfig surface = rivers.getSurface();
IrisSurfaceRiverSourceConfig surfaceSources = surface.getSources();
IrisSurfaceRiverChannelConfig channel = surface.getChannel();
IrisSurfaceRiverBankConfig banks = surface.getBanks();
IrisSurfaceRiverFlowConfig flow = surface.getFlow();
IrisUndergroundRiverConfig underground = rivers.getUnderground();
IrisUndergroundRiverSourceConfig undergroundSources = underground.getSources();
int routeNodes = maximumRouteNodes(routing);
int minimumWorldY = dimension.getMinHeight();
boolean riversEnabled = rivers.isEnabled();
boolean surfaceEnabled = riversEnabled && surface.isEnabled();
boolean undergroundEnabled = riversEnabled && underground.isEnabled();
HydrologyPlannerSettings.Routing plannerRouting = new HydrologyPlannerSettings.Routing(
routing.getTileSize(),
routing.getSampleSpacing(),
routeNodes,
routing.getMaximumRouteLength(),
routing.getMinimumSurfaceCourseLength(),
routing.getMinimumUndergroundCourseLength(),
routing.getValleyPreference(),
routing.getUphillPenalty(),
routing.getSlopePenalty(),
routing.getConfluenceAttraction(),
routing.getLengthPreference(),
routing.getTributaries()
);
HydrologyPlannerSettings.Source plannerSurfaceSources = new HydrologyPlannerSettings.Source(
surfaceEnabled,
surfaceSources.getDensity(),
surfaceSources.getMinimumElevation() - minimumWorldY,
surfaceSources.getMinimumPerTile(),
maximumSources(surfaceSources.getDensity(), surfaceSources.getMinimumPerTile()),
surfaceSources.getMinimumSpacing()
);
HydrologyPlannerSettings.Surface plannerSurface = new HydrologyPlannerSettings.Surface(
surfaceEnabled,
plannerSurfaceSources,
minimumInt(channel.getWidth()),
maximumInt(channel.getWidth()),
minimumInt(channel.getDepth()),
maximumInt(channel.getDepth()),
channel.getMaximumIncision(),
banks.getShoreWidth(),
new HydrologyPlannerSettings.Banks(
channel.getInset(),
banks.getFreeboard(),
banks.getBlendSlope(),
banks.getMinimumBlendWidth(),
banks.getMaximumBlendWidth(),
channel.getRoughness(),
channel.getRoughnessWavelength(),
flow.getCascadeRun(),
flow.getWaterfallMinimumDrop(),
surface.getMouths().getFlareRatio(),
channel.getSpringWidthRatio(),
channel.getSpringLength(),
banks.isExposeCutStrata()
)
);
HydrologyPlannerSettings.Hydraulics plannerHydraulics =
new HydrologyPlannerSettings.Hydraulics(flow.getWaterfallMinimumDrop());
HydrologyPlannerSettings.Source plannerUndergroundSources = new HydrologyPlannerSettings.Source(
undergroundEnabled,
undergroundSources.getDensity(),
Integer.MIN_VALUE,
undergroundSources.getMinimumPerTile(),
maximumSources(undergroundSources.getDensity(), undergroundSources.getMinimumPerTile()),
undergroundSources.getMinimumSpacing()
);
HydrologyPlannerSettings.Underground plannerUnderground = new HydrologyPlannerSettings.Underground(
undergroundEnabled,
plannerUndergroundSources,
minimumInt(underground.getFluidLevel()) - minimumWorldY,
maximumInt(underground.getFluidLevel()) - minimumWorldY,
minimumInt(underground.getChannelWidth()),
maximumInt(underground.getChannelWidth()),
minimumInt(underground.getDepth()),
maximumInt(underground.getDepth()),
minimumInt(underground.getHeadroom()),
maximumInt(underground.getHeadroom()),
underground.isConnectToExistingCaves()
);
IrisRiverGrottoConfig grottos = rivers.getGrottos();
IrisCoastalRiverGrottoConfig coastal = grottos.getCoastal();
IrisInlandRiverGrottoConfig inland = grottos.getInland();
boolean inlandEnabled = inland.isEnabled()
&& routing.getInlandOutlets().contains(IrisRiverInlandOutlet.SINKHOLE_GROTTO);
HydrologyPlannerSettings.Outlets plannerOutlets = new HydrologyPlannerSettings.Outlets(
routing.isOceanOutlets(),
grotto(coastal.isEnabled(), coastal.getHorizontalRadius(), coastal.getVerticalRadius(),
coastal.getHeadroom(), coastal.getMaximumVolume()),
grotto(inlandEnabled, inland.getHorizontalRadius(), inland.getVerticalRadius(),
inland.getHeadroom(), inland.getMaximumVolume()),
inlandEnabled && inland.isConnectSurfaceRivers(),
Math.max(4, coastal.getVerticalRadius()),
underground.getMouthLevelingDistance(),
surface.getMouths().getMaximumOceanApron(),
routing.getMaximumOutletsPerTile()
);
HydrologyPlannerSettings.Geometry plannerGeometry = geometry(
rivers.getGeometry(),
new HydrologyPlannerSettings.ChannelShape(
2D,
channel.getRoughness(),
channel.getRoughness(),
channel.getRoughnessWavelength()
)
);
return new HydrologyPlannerSettings(
dimension.getFluidHeight(),
plannerRouting,
plannerSurface,
plannerHydraulics,
plannerUnderground,
plannerOutlets,
plannerGeometry,
deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()),
surfacePools(hydrology.getSurfacePools())
);
}
private static List<HydrologyPlannerSettings.SurfacePool> surfacePools(List<IrisSurfacePoolConfig> configurations) {
ArrayList<HydrologyPlannerSettings.SurfacePool> pools = new ArrayList<>();
for (IrisSurfacePoolConfig configuration : configurations) {
pools.add(new HydrologyPlannerSettings.SurfacePool(
configuration.getId(),
configuration.getDensity() > 0D,
configuration.getDensity(),
configuration.getSpacing(),
configuration.getMinimumRadius(),
configuration.getMaximumRadius(),
configuration.getDepth(),
Math.min(64, maximumSources(configuration.getDensity(), 0)),
configuration.getBiome()
));
}
return List.copyOf(pools);
}
private static HydrologyPlannerSettings.Geometry geometry(
IrisRiverGeometryConfig geometry,
HydrologyPlannerSettings.ChannelShape surfaceShape
) {
IrisRiverMeanderConfig meanders = geometry.getMeanders();
IrisRiverDropShapeConfig drops = geometry.getDrops();
return new HydrologyPlannerSettings.Geometry(
new HydrologyPlannerSettings.Meanders(
meanders.getPrimaryWavelength(),
meanders.getDetailWavelength(),
meanders.getPrimaryStrength(),
meanders.getDetailStrength(),
meanders.getMaximumOffsetRatio(),
meanders.getSmoothingPasses(),
meanders.getMaximumTurnDegrees()
),
surfaceShape,
channelShape(geometry.getUnderground()),
channelShape(geometry.getGrottos()),
new HydrologyPlannerSettings.Drops(
drops.getCascadeRunPerBlock(),
drops.getCascadeExponent(),
drops.getMaximumCascadeStep(),
drops.getFlowWidthRatio(),
drops.getMaximumFlowDepth(),
drops.getBasinWidthRatio(),
drops.getMaximumBasinDepth()
)
);
}
private static HydrologyPlannerSettings.ChannelShape channelShape(IrisRiverChannelShapeConfig shape) {
return new HydrologyPlannerSettings.ChannelShape(
shape.getBedRoundness(),
shape.getBedRoughness(),
shape.getWallRoughness(),
shape.getRoughnessWavelength()
);
}
private static HydrologyPlannerSettings.Grotto grotto(
boolean enabled,
int horizontalRadius,
int verticalRadius,
int headroom,
int maximumVolume
) {
return new HydrologyPlannerSettings.Grotto(
enabled,
horizontalRadius,
verticalRadius,
headroom,
maximumVolume
);
}
private static List<HydrologyPlannerSettings.DeepFluid> deepFluids(
List<IrisDeepFluidConfig> configurations,
int minimumWorldY,
IrisRiverRoutingConfig routing,
int refinementSpacing
) {
ArrayList<HydrologyPlannerSettings.DeepFluid> deepFluids = new ArrayList<>();
for (IrisDeepFluidConfig configuration : configurations) {
int radius = configuration.getHorizontalRadius();
int verticalRadius = configuration.getVerticalRadius();
int maximumVolume = boundedVolume(radius, verticalRadius, configuration.getHeadroom());
int maximumPerTile = Math.min(64, maximumSources(configuration.getDensity(), 0));
int maximumChannelLength = maximumDeepChannelLength(configuration, routing);
int minimumChannelLength = maximumChannelLength == 0
? 0
: Math.min(maximumChannelLength, Math.max(refinementSpacing, radius));
deepFluids.add(new HydrologyPlannerSettings.DeepFluid(
configuration.getId(),
configuration.getDensity() > 0D
&& (configuration.isContainedPools() || configuration.isShortChannels()),
configuration.getDensity(),
configuration.getSpacing(),
minimumInt(configuration.getHeight()) - minimumWorldY,
maximumInt(configuration.getHeight()) - minimumWorldY,
radius,
radius,
verticalRadius,
verticalRadius,
minimumChannelLength,
maximumChannelLength,
configuration.getChannelWidth(),
configuration.getDepth(),
configuration.getHeadroom(),
maximumVolume,
maximumPerTile,
configuration.isContainedPools(),
configuration.isShortChannels()
));
}
return List.copyOf(deepFluids);
}
static int maximumDeepChannelLength(
IrisDeepFluidConfig configuration,
IrisRiverRoutingConfig routing
) {
if (!configuration.isShortChannels()) {
return 0;
}
int configuredLength = Math.max(
HydrologyPlannerSettings.Routing.refinementSpacing(routing.getSampleSpacing()),
configuration.getSpacing() / 3);
return Math.min(configuredLength, routing.getTileSize() / 2);
}
private static int maximumRouteNodes(IrisRiverRoutingConfig routing) {
int halo = Math.min(routing.getMaximumRouteLength(), routing.getTileSize() / 2);
int alignedHalo = Math.floorDiv(halo, routing.getSampleSpacing()) * routing.getSampleSpacing();
int width = routing.getTileSize() / routing.getSampleSpacing() + 1
+ alignedHalo * 2 / routing.getSampleSpacing();
long nodes = (long) width * width;
if (nodes > 1_000_000L) {
throw new IllegalArgumentException("Hydrology routing lattice exceeds one million nodes.");
}
return (int) nodes;
}
private static int tileCoordinate(long blockCoordinate, int tileSize) {
return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize));
}
private static int maximumSources(double density, int minimumPerTile) {
int expected = (int) Math.ceil(Math.max(0D, density));
return Math.min(64, Math.max(minimumPerTile, expected));
}
private static int boundedVolume(int radius, int verticalRadius, int headroom) {
long diameter = radius * 2L + 1L;
long volume = diameter * diameter * (verticalRadius + headroom + 1L);
return (int) Math.min(1_048_576L, Math.max(64L, volume));
}
private static int minimumInt(IrisStyledRange range) {
return (int) StrictMath.floor(Math.min(range.getMin(), range.getMax()));
}
private static int maximumInt(IrisStyledRange range) {
return (int) StrictMath.ceil(Math.max(range.getMin(), range.getMax()));
}
private static Set<String> profileKeys(List<IrisRiverProfile> profiles) {
LinkedHashSet<String> keys = new LinkedHashSet<>();
for (IrisRiverProfile profile : profiles) {
if (profile != null && profile.getId() != null && !profile.getId().isBlank()) {
keys.add(profile.getId().trim());
}
}
return Collections.unmodifiableSet(new LinkedHashSet<>(keys));
}
private static String requireBiomeKey(IrisBiome biome) {
if (biome == null || biome.getLoadKey() == null || biome.getLoadKey().isBlank()) {
throw new IllegalStateException("Hydrology terrain sampling requires a loaded parent biome.");
}
return biome.getLoadKey();
}
}
@@ -0,0 +1,30 @@
package art.arcane.iris.engine.hydrology.runtime;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory;
import art.arcane.iris.engine.object.IrisDimension;
import java.util.Objects;
public record IrisHydrologyRuntimeContext(
long seed,
int worldHeight,
IrisDimension dimension,
IrisData data,
IrisHydrologyNaturalSampleProvider naturalSampleProvider,
IrisHydrologyNaturalHeightProvider naturalHeightProvider,
IrisHydrologyNaturalOceanClassifier naturalOceanClassifier,
HydrologyCaveVoxelViewFactory caveViewFactory
) {
public IrisHydrologyRuntimeContext {
if (worldHeight < 3) {
throw new IllegalArgumentException("worldHeight must be at least three");
}
Objects.requireNonNull(dimension);
Objects.requireNonNull(data);
Objects.requireNonNull(naturalSampleProvider);
Objects.requireNonNull(naturalHeightProvider);
Objects.requireNonNull(naturalOceanClassifier);
Objects.requireNonNull(caveViewFactory);
}
}
@@ -0,0 +1,11 @@
package art.arcane.iris.engine.hydrology.surface;
public record ChannelProfile(double[] halfWidth, double[] depth, double[] bankMultiplier) {
public int size() {
return halfWidth.length;
}
public double collar(int station, double roughness) {
return halfWidth[station] * (1D + roughness) + 2D;
}
}
@@ -0,0 +1,140 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.Objects;
public final class ChannelProfileBuilder {
private static final int SMOOTHING_RADIUS = 16;
private static final int MOUTH_FLARE = 32;
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final HydrologyGeometrySampler geometry;
public ChannelProfileBuilder(
HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler sampler,
HydrologyGeometrySampler geometry
) {
this.surface = Objects.requireNonNull(surface, "surface");
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.geometry = Objects.requireNonNull(geometry, "geometry");
}
public ChannelProfile build(
SurfaceCenterline centerline,
String profileKey,
boolean directOcean
) {
int count = centerline.size();
double[] width = new double[count];
double[] depth = new double[count];
double[] bank = new double[count];
for (int station = 0; station < count; station++) {
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
int sampledWidth = geometry.sample(
HydrologyGeometrySampler.Field.SURFACE_WIDTH,
profileKey,
stationX,
stationZ,
0L,
surface.minimumWidth(),
surface.maximumWidth()
);
int sampledDepth = geometry.sample(
HydrologyGeometrySampler.Field.SURFACE_DEPTH,
profileKey,
stationX,
stationZ,
0L,
surface.minimumDepth(),
surface.maximumDepth()
);
HydrologyTerrainSample terrain = sampler.sample(stationX, stationZ);
double widthMultiplier = terrain == null ? 1D : terrain.widthMultiplier();
double depthMultiplier = terrain == null ? 1D : terrain.depthMultiplier();
bank[station] = terrain == null ? 1D : terrain.bankMultiplier();
width[station] = clamp(sampledWidth * widthMultiplier, surface.minimumWidth(), surface.maximumWidth() * 2D);
depth[station] = clamp(sampledDepth * depthMultiplier, 1D, surface.maximumDepth() * 2D);
}
double[] smoothWidth = smooth(width);
double[] smoothDepth = smooth(depth);
// The headwater opens as a spring pool: wider and one block deeper, narrowing to the cruise width.
// Ground that falls away across the pool shrinks it so it never demands a cut the valley solver rejects.
int spring = Math.min(surface.banks().springLength(), count / 2);
double springRatio = surface.banks().springWidthRatio();
for (int station = 0; station < spring; station++) {
double remaining = 1D - SurfaceNoise.smoothStep(station / (double) spring);
double localRatio = 1D + (springRatio - 1D) * springRoom(centerline, station, smoothWidth[station] * springRatio / 2D);
smoothWidth[station] *= 1D + (localRatio - 1D) * remaining;
smoothDepth[station] += remaining;
}
if (directOcean) {
int flare = Math.min(MOUTH_FLARE, count / 3);
int flareStart = count - flare;
for (int station = Math.max(0, flareStart); station < count; station++) {
double progress = SurfaceNoise.smoothStep((station - flareStart) / (double) Math.max(1, flare - 1));
smoothWidth[station] *= 1D + (surface.banks().mouthFlareRatio() - 1D) * progress;
}
}
double[] halfWidth = new double[count];
for (int station = 0; station < count; station++) {
halfWidth[station] = Math.max(0.5D, smoothWidth[station] / 2D);
smoothDepth[station] = Math.max(1D, smoothDepth[station]);
}
return new ChannelProfile(halfWidth, smoothDepth, bank);
}
/** Fraction of the spring pool the ground allows: 1 on level ground, 0 where the pool ring would sit half the permitted cut lower. */
private double springRoom(SurfaceCenterline centerline, int station, double reach) {
int centerX = centerline.x()[station];
int centerZ = centerline.z()[station];
HydrologyTerrainSample center = sampler.sample(centerX, centerZ);
if (center == null) {
return 1D;
}
double normalX = centerline.normalX(station);
double normalZ = centerline.normalZ(station);
int lowest = center.naturalHeight();
for (double direction = -1D; direction <= 1D; direction += 2D) {
HydrologyTerrainSample side = sampler.sample(
(int) StrictMath.round(centerX + normalX * reach * direction),
(int) StrictMath.round(centerZ + normalZ * reach * direction));
if (side != null) {
lowest = Math.min(lowest, side.naturalHeight());
}
}
double drop = center.naturalHeight() - lowest;
double allowance = Math.max(1D, surface.maximumIncision() / 2D);
return Math.max(0D, Math.min(1D, 1D - drop / allowance));
}
static double[] smooth(double[] values) {
int count = values.length;
double[] smoothed = new double[count];
for (int station = 0; station < count; station++) {
double total = 0D;
double weight = 0D;
for (int offset = -SMOOTHING_RADIUS; offset <= SMOOTHING_RADIUS; offset++) {
int index = station + offset;
if (index < 0 || index >= count) {
continue;
}
double factor = SMOOTHING_RADIUS + 1 - Math.abs(offset);
total += values[index] * factor;
weight += factor;
}
smoothed[station] = total / weight;
}
return smoothed;
}
private static double clamp(double value, double minimum, double maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
}
@@ -0,0 +1,32 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
public final class ErosionField {
private final Long2ObjectOpenHashMap<SurfaceColumn> columns;
private final int uncontainedWetCells;
ErosionField(Long2ObjectOpenHashMap<SurfaceColumn> columns, int uncontainedWetCells) {
this.columns = Objects.requireNonNull(columns, "columns");
this.uncontainedWetCells = uncontainedWetCells;
}
public Long2ObjectOpenHashMap<SurfaceColumn> columns() {
return columns;
}
public SurfaceColumn column(int x, int z) {
return columns.get(RiverFootprint.pack(x, z));
}
public int uncontainedWetCells() {
return uncontainedWetCells;
}
public int size() {
return columns.size();
}
}
@@ -0,0 +1,309 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.engine.hydrology.RiverFootprint;
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 java.util.Objects;
public final class ErosionFieldCompiler {
private static final int BLEND_SMOOTHING_RADIUS = 12;
private static final double THALWEG_FRACTION = 0.45D;
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private static final int[][] DIAGONALS = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
private static final long OUTLINE_SALT = 0x4f55544c494e45L;
private static final long BED_SALT = 0x424544L;
private static final double EPSILON = 1.0E-9D;
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final int seaLevel;
public ErosionFieldCompiler(
HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler sampler,
int seaLevel
) {
this.surface = Objects.requireNonNull(surface, "surface");
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.seaLevel = seaLevel;
}
public ErosionField compile(
long courseSeed,
SurfaceCenterline centerline,
ChannelProfile channel,
ValleyProfile valley,
SurfaceTerminal terminal,
int apronLimit
) {
HydrologyPlannerSettings.Banks banks = surface.banks();
int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations();
double roughness = banks.roughness();
double shoreWidth = surface.shoreWidth();
int freeboard = banks.freeboard();
boolean[] basin = basins(valley, channel, count);
double[][] blendWidth = blendWidths(centerline, channel, valley, count);
Long2IntOpenHashMap nearest = new Long2IntOpenHashMap();
nearest.defaultReturnValue(-1);
Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap();
for (int station = 0; station < count; station++) {
double widest = Math.max(blendWidth[station][0], blendWidth[station][1]);
int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + shoreWidth + widest) + 1;
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
for (int deltaX = -radius; deltaX <= radius; 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);
}
}
}
}
int oceanStart = oceanStart(centerline, valley, terminal);
Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(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);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
int head = valley.head()[station];
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, 0.6D * halfWidth), Math.min(1.4D * halfWidth, outline));
boolean wet = cellDistance <= outline + 0.25D;
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
if (terrain != null && 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));
double depth = channel.depth()[station];
double local = 1D + (depth - 1D) * bowl(normalized)
+ 0.5D * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength())
+ (basin[station] ? 1D : 0D);
int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local)));
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
wetKeys.add(key);
continue;
}
int bankTop = head + freeboard;
if (cellDistance <= outline + shoreWidth + 0.25D) {
int height = Math.min(natural, bankTop);
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false));
continue;
}
int cut = natural - bankTop;
if (cut <= 0) {
continue;
}
double side = side(centerline, station, cellX, cellZ);
double width = blendWidth[station][side < 0D ? 0 : 1];
double progress = (cellDistance - outline - shoreWidth) / width;
if (progress >= 1D) {
continue;
}
int height = Math.min(natural, (int) StrictMath.round(bankTop + cut * SurfaceNoise.smoothStep(progress)));
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.BANK, height, height, false));
}
int uncontained = contain(columns, wetKeys, freeboard);
connectSteps(columns, wetKeys);
return new ErosionField(columns, uncontained);
}
/**
* Where the head steps down between neighbouring wet cells, the upper bed is lowered so its water
* column reaches the lower head; the water stays one connected body across every step.
*/
private static void connectSteps(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys) {
for (int index = 0; index < wetKeys.size(); index++) {
long key = wetKeys.getLong(index);
SurfaceColumn wet = columns.get(key);
int bed = wet.height();
for (int[] offset : CARDINALS) {
SurfaceColumn neighbour = columns.get(RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1]));
if (neighbour == null || neighbour.role() != SurfaceRole.CHANNEL || neighbour.apron()) {
continue;
}
if (neighbour.headY() < wet.headY()) {
bed = Math.min(bed, neighbour.headY() - 1);
}
}
if (bed < wet.height()) {
columns.put(key, wet.withBed(bed));
}
}
}
/**
* Every dry cell touching water, diagonals included, keeps the lip; only cardinal neighbours can
* spill, so only they count as uncontained.
*/
private int contain(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys, int freeboard) {
int uncontained = 0;
for (int index = 0; index < wetKeys.size(); index++) {
SurfaceColumn wet = columns.get(wetKeys.getLong(index));
uncontained += lip(columns, wet, CARDINALS, freeboard, true);
lip(columns, wet, DIAGONALS, freeboard, false);
}
return uncontained;
}
private int lip(
Long2ObjectOpenHashMap<SurfaceColumn> columns,
SurfaceColumn wet,
int[][] offsets,
int freeboard,
boolean countSpills
) {
int uncontained = 0;
for (int[] offset : offsets) {
long key = RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1]);
SurfaceColumn neighbour = columns.get(key);
if (neighbour == null) {
if (countSpills && wet.headY() != seaLevel) {
uncontained++;
}
continue;
}
if (neighbour.role() == SurfaceRole.CHANNEL) {
continue;
}
int required = wet.headY() + freeboard;
if (neighbour.height() < required) {
int raised = Math.min(neighbour.terrain().naturalHeight(), required);
neighbour = neighbour.withHeight(raised);
columns.put(key, neighbour);
}
if (countSpills && neighbour.height() < wet.headY()) {
uncontained++;
}
}
return uncontained;
}
/** First mouth station whose center is ocean; the apron is measured from there, not from the mouth segment start. */
private int oceanStart(SurfaceCenterline centerline, ValleyProfile valley, SurfaceTerminal terminal) {
int count = centerline.size();
if (terminal != SurfaceTerminal.OCEAN_MOUTH) {
return count;
}
for (int station = Math.max(0, valley.exposedStations() - 1); station < count; station++) {
HydrologyTerrainSample center = sampler.sample(centerline.x()[station], centerline.z()[station]);
if (!SurfaceCellAdmission.writable(center, seaLevel)) {
return station;
}
}
return count;
}
private boolean[] basins(ValleyProfile valley, ChannelProfile channel, int count) {
boolean[] basin = new boolean[count];
for (int station = 1; station < count; station++) {
int drop = valley.head()[station - 1] - valley.head()[station];
if (drop < 2) {
continue;
}
int length = Math.max(2, (int) StrictMath.round(2D * channel.halfWidth()[station]));
for (int following = station; following < Math.min(count, station + length); following++) {
basin[following] = true;
}
}
return basin;
}
private double[][] blendWidths(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, int count) {
HydrologyPlannerSettings.Banks banks = surface.banks();
double[][] widths = new double[count][2];
for (int station = 0; station < count; station++) {
double halfWidth = channel.halfWidth()[station];
double probe = halfWidth + surface.shoreWidth() + Math.max(2D, halfWidth);
int bankTop = valley.head()[station] + banks.freeboard();
for (int side = 0; side < 2; side++) {
double direction = side == 0 ? -1D : 1D;
int probeX = (int) StrictMath.round(centerline.x()[station] + centerline.normalX(station) * probe * direction);
int probeZ = (int) StrictMath.round(centerline.z()[station] + centerline.normalZ(station) * probe * direction);
HydrologyTerrainSample terrain = sampler.sample(probeX, probeZ);
double cut = terrain == null ? 0D : Math.max(0D, terrain.naturalHeight() - bankTop);
double width = cut * banks.blendSlope() * channel.bankMultiplier()[station];
widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width));
}
}
return smoothWidths(widths, BLEND_SMOOTHING_RADIUS);
}
/**
* Moving average along the course so the valley outline widens and narrows gradually instead of
* stepping between neighbouring stations.
*/
static double[][] smoothWidths(double[][] widths, int radius) {
int count = widths.length;
double[][] smoothed = new double[count][2];
for (int side = 0; side < 2; side++) {
double sum = 0D;
int window = 0;
int head = -1;
int tail = 0;
for (int station = 0; station < count; station++) {
while (head + 1 < count && head + 1 <= station + radius) {
head++;
sum += widths[head][side];
window++;
}
while (tail < station - radius) {
sum -= widths[tail][side];
tail++;
window--;
}
smoothed[station][side] = sum / window;
}
}
return smoothed;
}
/**
* Bed depth factor across the channel: a level thalweg over the inner part of the width that eases
* up to a one-block edge, so the channel reads as a broad bowl rather than a V-shaped trough.
*/
static double bowl(double normalized) {
if (normalized <= THALWEG_FRACTION) {
return 1D;
}
double t = Math.min(1D, (normalized - THALWEG_FRACTION) / (1D - THALWEG_FRACTION));
return 1D - t * t * (3D - 2D * t);
}
private static double side(SurfaceCenterline centerline, int station, int cellX, int cellZ) {
double deltaX = cellX - centerline.x()[station];
double deltaZ = cellZ - centerline.z()[station];
return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station);
}
}
@@ -0,0 +1,12 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
public final class SurfaceCellAdmission {
private SurfaceCellAdmission() {
}
public static boolean writable(HydrologyTerrainSample terrain, int seaLevel) {
return terrain != null && !terrain.ocean() && terrain.naturalHeight() > seaLevel;
}
}
@@ -0,0 +1,109 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.List;
import java.util.Objects;
public record SurfaceCenterline(int[] x, int[] z, double[] tangentX, double[] tangentZ, int[] pathIndex) {
private static final int TANGENT_WINDOW = 6;
public static SurfaceCenterline densify(List<HydrologyPoint> path) {
Objects.requireNonNull(path, "path");
if (path.isEmpty()) {
throw new IllegalArgumentException("A surface centerline requires at least one path point.");
}
IntArrayList xs = new IntArrayList();
IntArrayList zs = new IntArrayList();
IntArrayList indices = new IntArrayList();
HydrologyPoint first = path.getFirst();
xs.add(first.x());
zs.add(first.z());
indices.add(0);
for (int pair = 0; pair + 1 < path.size(); pair++) {
HydrologyPoint start = path.get(pair);
HydrologyPoint end = path.get(pair + 1);
int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z()));
for (int step = 1; step <= steps; step++) {
double progress = step / (double) steps;
int cellX = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int cellZ = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
int last = xs.size() - 1;
if (xs.getInt(last) == cellX && zs.getInt(last) == cellZ) {
continue;
}
xs.add(cellX);
zs.add(cellZ);
indices.add(pair);
}
}
return fromStations(xs.toIntArray(), zs.toIntArray(), indices.toIntArray());
}
private static SurfaceCenterline fromStations(int[] xs, int[] zs, int[] indices) {
int count = xs.length;
double[] tangentX = new double[count];
double[] tangentZ = new double[count];
for (int station = 0; station < count; station++) {
int from = Math.max(0, station - TANGENT_WINDOW);
int to = Math.min(count - 1, station + TANGENT_WINDOW);
double vectorX = xs[to] - xs[from];
double vectorZ = zs[to] - zs[from];
double length = StrictMath.hypot(vectorX, vectorZ);
if (length <= 0D) {
tangentX[station] = 1D;
tangentZ[station] = 0D;
} else {
tangentX[station] = vectorX / length;
tangentZ[station] = vectorZ / length;
}
}
return new SurfaceCenterline(xs, zs, tangentX, tangentZ, indices);
}
public int size() {
return x.length;
}
public double normalX(int station) {
return -tangentZ[station];
}
public double normalZ(int station) {
return tangentX[station];
}
public double distanceToSegment(int station, double pointX, double pointZ) {
double startX = x[station];
double startZ = z[station];
if (station + 1 >= x.length) {
return StrictMath.hypot(pointX - startX, pointZ - startZ);
}
double endX = x[station + 1];
double endZ = z[station + 1];
double segmentX = endX - startX;
double segmentZ = endZ - startZ;
double lengthSquared = segmentX * segmentX + segmentZ * segmentZ;
double progress = lengthSquared <= 0D
? 0D
: ((pointX - startX) * segmentX + (pointZ - startZ) * segmentZ) / lengthSquared;
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);
}
public SurfaceCenterline truncate(int stations) {
if (stations < 1 || stations > x.length) {
throw new IllegalArgumentException("Cannot truncate a centerline of " + x.length + " stations to " + stations + ".");
}
int[] xs = new int[stations];
int[] zs = new int[stations];
int[] indices = new int[stations];
System.arraycopy(x, 0, xs, 0, stations);
System.arraycopy(z, 0, zs, 0, stations);
System.arraycopy(pathIndex, 0, indices, 0, stations);
return fromStations(xs, zs, indices);
}
}
@@ -0,0 +1,31 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import java.util.Objects;
public record SurfaceColumn(
int x,
int z,
HydrologyTerrainSample terrain,
int station,
SurfaceRole role,
int height,
int headY,
boolean apron
) {
public SurfaceColumn {
Objects.requireNonNull(terrain, "terrain");
Objects.requireNonNull(role, "role");
}
/** Dry columns sit at their own height; the head follows it. */
public SurfaceColumn withHeight(int replacementHeight) {
return new SurfaceColumn(x, z, terrain, station, role, replacementHeight, replacementHeight, apron);
}
/** Wet columns keep their head; only the bed moves. */
public SurfaceColumn withBed(int replacementBed) {
return new SurfaceColumn(x, z, terrain, station, role, replacementBed, headY, apron);
}
}
Loaded 100 of 330 files, more files were not shown because too many files have changed in this diff. Show more