Polish surface rivers: estuaries, ponds, erosion, sea caves

Surface rivers gain a drowned inlet at the mouth, a spring pond and an
inland terminal pond, a configurable erosion profile (thalweg fraction,
blend curve, bed noise, smoothing) and a per-area shore biome width;
banks lose freeboard and inset for a single sink. Underground courses
accept tributaries, coastal grottos can be admitted as standalone sea
caves, and a region or biome can confine the rivers born in it. Tile
planning forks its ring work through HydrologyForkJoin. Pack validation,
the schema, the probes and the tests follow.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-03 05:03:00 -04:00
1 parent e92c0efe09
commit a4fe527d65
64 files changed
+5619 -622

No files matched your search

@@ -183,9 +183,10 @@ final class PackHydrologyValidator {
if (geometry != null) {
validateGeometry(path + ".geometry", geometry, errors);
}
int minimumSurfaceCourseLength = routing == null ? 384 : integerValue(routing, "minimumSurfaceCourseLength", 384);
SourceBudget surfaceBudget = surface == null
? SourceBudget.defaults()
: validateSurface(path + ".surface", surface, dimension, errors, warnings);
: validateSurface(path + ".surface", surface, dimension, minimumSurfaceCourseLength, errors, warnings);
SourceBudget undergroundBudget = underground == null
? SourceBudget.defaults()
: validateUnderground(path + ".underground", underground, dimension, errors, warnings);
@@ -276,6 +277,7 @@ final class PackHydrologyValidator {
errors
);
PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "maximumOutletsPerTile", 1, 256, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "maximumCoastalOutletsPerTile", 0, 64, errors);
PackJsonFieldChecks.validateOptionalBoolean(path, routing, "oceanOutlets", errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "valleyPreference", 0D, 8D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "uphillPenalty", 0D, 128D, errors);
@@ -320,6 +322,7 @@ final class PackHydrologyValidator {
String path,
JSONObject surface,
JSONObject dimension,
int minimumSurfaceCourseLength,
List<String> errors,
List<String> warnings
) {
@@ -330,6 +333,8 @@ final class PackHydrologyValidator {
JSONObject flow = nestedObject(surface, "flow", path, errors);
JSONObject mouths = nestedObject(surface, "mouths", path, errors);
JSONObject bed = nestedObject(surface, "bed", path, errors);
JSONObject erosion = nestedObject(surface, "erosion", path, errors);
JSONObject ponds = nestedObject(surface, "ponds", path, errors);
SourceBudget budget = sources == null
? SourceBudget.defaults()
@@ -344,11 +349,17 @@ final class PackHydrologyValidator {
validateSurfaceFlow(path + ".flow", flow, errors);
}
if (mouths != null) {
validateMouths(path + ".mouths", mouths, errors);
validateMouths(path + ".mouths", mouths, minimumSurfaceCourseLength, errors);
}
if (bed != null) {
validateSurfaceBed(path + ".bed", bed, errors);
}
if (erosion != null) {
validateSurfaceErosion(path + ".erosion", erosion, errors);
}
if (ponds != null) {
validateSurfacePonds(path + ".ponds", ponds, errors);
}
boolean surfaceEnabled = booleanValue(surface, "enabled", true);
if (surfaceEnabled && budget.maximumExpectedSources() == 0) {
@@ -368,7 +379,7 @@ final class PackHydrologyValidator {
private static void validateSurfaceChannel(String path, JSONObject channel, List<String> errors) {
validateRange(path, channel, "width", 1D, 128D, 4D, 8D, errors);
validateRange(path, channel, "depth", 1D, 64D, 2D, 4D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "inset", 0, 3, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "sink", 0, 3, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "maximumIncision", 1, 32, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, channel, "springWidthRatio", 1D, 4D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "springLength", 4, 96, errors);
@@ -376,8 +387,33 @@ final class PackHydrologyValidator {
PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "roughnessWavelength", 4, 64, errors);
}
private static void validateSurfaceErosion(String path, JSONObject erosion, List<String> errors) {
PackJsonFieldChecks.validateOptionalBoolean(path, erosion, "enabled", errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, erosion, "smoothingRadius", 0, 64, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, erosion, "thalwegFraction", 0D, 0.95D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, erosion, "blendCurve", 0.25D, 4D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, erosion, "bedNoise", 0D, 2D, errors);
}
private static void validateSurfacePonds(String path, JSONObject ponds, List<String> errors) {
for (String end : new String[] {"source", "terminal"}) {
JSONObject pond = nestedObject(ponds, end, path, errors);
if (pond == null) {
continue;
}
String pondPath = path + "." + end;
PackJsonFieldChecks.validateOptionalBoolean(pondPath, pond, "enabled", errors);
PackJsonFieldChecks.validateOptionalIntegerRange(pondPath, pond, "minimumRadius", 1, 32, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(pondPath, pond, "maximumRadius", 1, 32, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(pondPath, pond, "depth", 1, 8, errors);
if (pond.has("minimumRadius") && pond.has("maximumRadius")
&& pond.optInt("minimumRadius") > pond.optInt("maximumRadius")) {
errors.add(pondPath + ".minimumRadius must not exceed maximumRadius");
}
}
}
private static void validateSurfaceBanks(String path, JSONObject banks, List<String> errors) {
PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "freeboard", 0, 4, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "shoreWidth", 0.5D, 6D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "blendSlope", 0.5D, 12D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "minimumBlendWidth", 1, 64, errors);
@@ -426,9 +462,16 @@ final class PackHydrologyValidator {
}
}
private static void validateMouths(String path, JSONObject mouths, List<String> errors) {
private static void validateMouths(String path, JSONObject mouths, int minimumSurfaceCourseLength, List<String> errors) {
PackJsonFieldChecks.validateOptionalDoubleRange(path, mouths, "flareRatio", 1D, 4D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "maximumOceanApron", 0, 32, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "inletLength", 0, 256, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "inletDepth", 0, 16, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "maximumIncision", 0, 128, errors);
// The drowned reach is the end of a river, never the whole of one.
if (mouths.has("inletLength") && integerValue(mouths, "inletLength", 64) > minimumSurfaceCourseLength) {
errors.add(path + ".inletLength must not exceed routing.minimumSurfaceCourseLength.");
}
}
private static SourceBudget validateUnderground(
@@ -554,6 +597,22 @@ final class PackHydrologyValidator {
errors.add(path + ".poolLevel must be SEA_LEVEL.");
}
}
if (coastal) {
JSONObject seaCaves = nestedObject(grotto, "seaCaves", path, errors);
if (seaCaves != null) {
String seaCavePath = path + ".seaCaves";
PackJsonFieldChecks.validateOptionalBoolean(seaCavePath, seaCaves, "enabled", errors);
PackJsonFieldChecks.validateOptionalIntegerRange(seaCavePath, seaCaves, "maximumPerTile", 0, 64, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(seaCavePath, seaCaves, "minimumSpacing", 16, 8192, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(seaCavePath, seaCaves, "minimumCoastHeight", 1, 128, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(seaCavePath, seaCaves, "depth", 0, 128, errors);
int horizontalRadius = integerValue(grotto, "horizontalRadius", DEFAULT_COASTAL_GROTTO.getHorizontalRadius());
int minimumSpacing = integerValue(seaCaves, "minimumSpacing", DEFAULT_COASTAL_GROTTO.getSeaCaves().getMinimumSpacing());
if (minimumSpacing < horizontalRadius * 2) {
errors.add(seaCavePath + ".minimumSpacing must be at least twice " + path + ".horizontalRadius.");
}
}
}
int verticalRadius = integerValue(
grotto,
@@ -902,6 +961,8 @@ final class PackHydrologyValidator {
validateNullableDouble(path, policy, "incisionMultiplier", 0D, 16D, errors);
validateNullableDouble(path, policy, "routingMultiplier", 0D, 64D, errors);
validateNullableDouble(path, policy, "bankMultiplier", 0D, 4D, errors);
validateNullableDouble(path, policy, "shoreBiomeWidth", 0D, 32D, errors);
validateNullableBoolean(path, policy, "confined", errors);
return policy;
}
@@ -215,7 +215,7 @@ public final class IrisRenderer {
public static int hydrologyDiagnosticColor(HydrologyCandidateKind kind) {
Objects.requireNonNull(kind, "kind");
return switch (kind) {
case SOURCE -> PROJECTED_SOURCE;
case SOURCE, TRIBUTARY -> PROJECTED_SOURCE;
case OUTLET -> PROJECTED_OUTLET;
case DEEP_FLUID, POOL -> PROJECTED_DEEP_FLUID;
};
@@ -4,5 +4,7 @@ public enum HydrologyCandidateKind {
SOURCE,
OUTLET,
DEEP_FLUID,
POOL
POOL,
/** A later course to an outlet that could not be joined to the outlet's main course. */
TRIBUTARY
}
@@ -3,6 +3,7 @@ package art.arcane.iris.engine.hydrology;
public enum HydrologyCandidateRejection {
NO_LEGAL_OUTLET,
NO_DRAINAGE_PATH,
CONFINED_NO_OUTLET,
ROUTE_LIMIT,
COURSE_TOO_SHORT,
SOURCE_QUOTA,
@@ -998,6 +998,9 @@ final class HydrologyFootprintCompiler {
continue;
}
addLayer(columns, x, z, terrain, layer);
if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO) {
addAdjacentSeaApron(columns, course, segment, point, x, z, flowX, flowZ, pointFeatures);
}
}
}
@@ -1010,9 +1013,10 @@ final class HydrologyFootprintCompiler {
int z,
SurfaceRasterIndex plannedSurface
) {
if (!terrainRoofedSurfaceTransition(course, segment)) {
if (!terrainRoofedLayer(course, segment)) {
return layer;
}
boolean seaCave = course.type() == RiverCourseType.SEA_CAVE;
int plannedTerrainHeight = plannedSurface.resolve(
x,
z,
@@ -1031,6 +1035,10 @@ final class HydrologyFootprintCompiler {
if (neighborTerrain == null) {
continue;
}
if (seaCave && neighborTerrain.ocean()) {
// The sea face is the intended opening; the seabed never lowers the chamber roof.
continue;
}
if (plannedSurface.ownsSurfaceChannelAt(neighborX, neighborZ, course.id())) {
continue;
}
@@ -1041,6 +1049,10 @@ final class HydrologyFootprintCompiler {
);
maximumCeiling = Math.min(maximumCeiling, neighborSurface - 1);
}
if (seaCave && Math.min(layer.ceilingY(), maximumCeiling) <= layer.fluidHeadY()) {
// A sea-cave column needs air between the sea and its roof; a roof at or below sea level is not carved.
return null;
}
if (maximumCeiling == Integer.MAX_VALUE || layer.ceilingY() <= maximumCeiling) {
return layer;
}
@@ -1158,7 +1170,7 @@ final class HydrologyFootprintCompiler {
);
int fluidHead = channel ? shape.fluidHead() : bed;
int ceiling = channel ? localCeiling(shape, ellipsoidScale) : fluidHead;
if (channel && terrainRoofedSurfaceTransition(course, segment)) {
if (channel && terrainRoofedLayer(course, segment)) {
ceiling = Math.max(fluidHead, Math.min(ceiling, terrain.naturalHeight() - 1));
}
boolean terrainOwned = !falling;
@@ -1235,13 +1247,24 @@ final class HydrologyFootprintCompiler {
HydrologyTerrainSample terrain,
HydrologyFeatureRef[] pointFeatures
) {
if (segment.type() != HydrologyFeatureType.MOUTH
&& segment.type() != HydrologyFeatureType.COASTAL_GROTTO) {
return;
}
if (distance > settings.outlets().maximumOceanApron() + 0.25D) {
if (!oceanApronEligible(segment, distance)) {
return;
}
addOceanApronLayer(columns, course, segment, point, x, z, flowX, flowZ, terrain, pointFeatures);
}
private void addOceanApronLayer(
Long2ObjectLinkedOpenHashMap<MutableColumn> columns,
RiverCourse course,
HydraulicSegment segment,
HydrologyPoint point,
int x,
int z,
int flowX,
int flowZ,
HydrologyTerrainSample terrain,
HydrologyFeatureRef[] pointFeatures
) {
int fluidHead = Math.min(settings.seaLevel(), point.y());
HydrologyFeatureRef feature = feature(
course,
@@ -1396,7 +1419,14 @@ final class HydrologyFootprintCompiler {
return shape.fluidHead() + localExtent;
}
private boolean terrainRoofedSurfaceTransition(RiverCourse course, HydraulicSegment segment) {
/**
* Layers whose ceiling follows the terrain: the underground transition of a surface river, and the
* chamber of a standalone sea cave, which must never break the surface above it.
*/
private boolean terrainRoofedLayer(RiverCourse course, HydraulicSegment segment) {
if (course.type() == RiverCourseType.SEA_CAVE) {
return segment.type() == HydrologyFeatureType.COASTAL_GROTTO;
}
return course.type() == RiverCourseType.SURFACE
&& (segment.type() == HydrologyFeatureType.UNDERGROUND_POOL
|| segment.type() == HydrologyFeatureType.UNDERGROUND_DROP);
@@ -1680,7 +1710,61 @@ final class HydrologyFootprintCompiler {
private boolean oceanApronEligible(HydraulicSegment segment, double distance) {
return (segment.type() == HydrologyFeatureType.MOUTH
|| segment.type() == HydrologyFeatureType.COASTAL_GROTTO)
&& distance <= settings.outlets().maximumOceanApron() + 0.25D;
&& distance <= apronReach(segment) + 0.25D;
}
/**
* How far from the centerline the ocean apron reaches. A coastal grotto chamber is as wide as its
* horizontal radius, so its apron reaches at least that far: every sea column the chamber touches
* must carry the apron that declares the sea face an intentional opening.
*/
private int apronReach(HydraulicSegment segment) {
int reach = settings.outlets().maximumOceanApron();
if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO) {
return Math.max(reach, settings.outlets().coastalGrotto().horizontalRadius());
}
return reach;
}
/**
* The organic chamber boundary can put a sea column one block past the apron reach while the land
* column beside it is still carved; the apron follows the chamber there so the face stays open.
*/
private void addAdjacentSeaApron(
Long2ObjectLinkedOpenHashMap<MutableColumn> columns,
RiverCourse course,
HydraulicSegment segment,
HydrologyPoint point,
int x,
int z,
int flowX,
int flowZ,
HydrologyFeatureRef[] pointFeatures
) {
for (int[] offset : HORIZONTAL_NEIGHBORS) {
int neighborX = x + offset[0];
int neighborZ = z + offset[1];
HydrologyTerrainSample neighborTerrain = sampleTerrainBasis(neighborX, neighborZ);
if (neighborTerrain == null) {
continue;
}
if (classifyNatural(neighborX, neighborZ) != HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN
&& !neighborTerrain.ocean()) {
continue;
}
addOceanApronLayer(
columns,
course,
segment,
point,
neighborX,
neighborZ,
flowX,
flowZ,
neighborTerrain,
pointFeatures
);
}
}
private HydrologyFeatureRef feature(
@@ -0,0 +1,91 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.ForkJoinWorkerThread;
/**
* Runs a batch of independent planning tasks in parallel without ever parking a pool worker on work
* that lives in another pool. On a ForkJoin worker the tasks are forked into that worker's own pool
* and joined with work helping, so a saturated pool still finishes them on the joining thread; on any
* other thread they run on the fallback executor, or inline when there is none. Results keep the
* task order and a task's unchecked exception is rethrown as is.
*/
public final class HydrologyForkJoin {
private HydrologyForkJoin() {
}
public static <T> List<T> invokeAll(List<Callable<T>> tasks, Executor fallback) {
Objects.requireNonNull(tasks, "tasks");
if (tasks.size() < 2 || fallback == null && !(Thread.currentThread() instanceof ForkJoinWorkerThread)) {
return runInline(tasks);
}
if (Thread.currentThread() instanceof ForkJoinWorkerThread) {
return forkJoin(tasks);
}
return runOn(tasks, fallback);
}
private static <T> List<T> runInline(List<Callable<T>> tasks) {
ArrayList<T> results = new ArrayList<>(tasks.size());
for (Callable<T> task : tasks) {
results.add(call(task));
}
return results;
}
private static <T> List<T> forkJoin(List<Callable<T>> tasks) {
ArrayList<ForkJoinTask<T>> forked = new ArrayList<>(tasks.size());
for (Callable<T> task : tasks) {
forked.add(ForkJoinTask.adapt(task));
}
ForkJoinTask.invokeAll(forked);
ArrayList<T> results = new ArrayList<>(tasks.size());
for (ForkJoinTask<T> task : forked) {
results.add(task.join());
}
return results;
}
private static <T> List<T> runOn(List<Callable<T>> tasks, Executor executor) {
ArrayList<CompletableFuture<T>> futures = new ArrayList<>(tasks.size());
for (Callable<T> task : tasks) {
futures.add(CompletableFuture.supplyAsync(() -> call(task), executor));
}
ArrayList<T> results = new ArrayList<>(tasks.size());
for (CompletableFuture<T> future : futures) {
try {
results.add(future.join());
} catch (CompletionException failure) {
throw unwrap(failure.getCause());
}
}
return results;
}
private static <T> T call(Callable<T> task) {
try {
return task.call();
} catch (RuntimeException | Error failure) {
throw failure;
} catch (Exception failure) {
throw new IllegalStateException("Hydrology planning task failed.", failure);
}
}
private static RuntimeException unwrap(Throwable cause) {
if (cause instanceof RuntimeException runtimeException) {
return runtimeException;
}
if (cause instanceof Error error) {
throw error;
}
return new IllegalStateException("Hydrology planning task failed.", cause);
}
}
File diff suppressed because it is too large. Load diff
@@ -11,7 +11,9 @@ public record HydrologyPlannerSettings(
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids,
List<SurfacePool> surfacePools
List<SurfacePool> surfacePools,
double widestShoreBiomeWidth,
SeaCaves seaCaves
) {
private static final long PLAN_FORMAT_REVISION = 6L;
private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4;
@@ -26,7 +28,13 @@ public record HydrologyPlannerSettings(
}
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 (!Double.isFinite(widestShoreBiomeWidth) || widestShoreBiomeWidth < 0D) {
throw new IllegalArgumentException("widestShoreBiomeWidth must be finite and non-negative.");
}
if (seaCaves == null) {
throw new IllegalArgumentException("Sea cave settings are required.");
}
int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves);
if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) {
throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period.");
}
@@ -40,7 +48,7 @@ public record HydrologyPlannerSettings(
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 Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true, 1),
new Outlets(
true,
new Grotto(true, 18, 8, 8, 32768),
@@ -49,11 +57,14 @@ public record HydrologyPlannerSettings(
12,
64,
8,
12
12,
2
),
Geometry.defaults(),
List.of(),
List.of()
List.of(),
0D,
SeaCaves.disabled()
);
}
@@ -66,7 +77,9 @@ public record HydrologyPlannerSettings(
hydraulics.hashCode(),
underground.hashCode(),
outlets.hashCode(),
geometry.hashCode()
geometry.hashCode(),
Double.doubleToLongBits(widestShoreBiomeWidth),
seaCaves.hashCode()
);
for (DeepFluid deepFluid : deepFluids) {
hash = HydrologyHash.mix(hash, deepFluid.hashCode(), HydrologyHash.text(deepFluid.id()));
@@ -78,7 +91,7 @@ public record HydrologyPlannerSettings(
}
public int publicationRadius() {
return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools);
return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves);
}
int crossTileColorPeriod() {
@@ -92,8 +105,11 @@ public record HydrologyPlannerSettings(
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids,
List<SurfacePool> surfacePools
List<SurfacePool> surfacePools,
double widestShoreBiomeWidth,
SeaCaves seaCaves
) {
double shoreReach = Math.max(surface.shoreWidth(), widestShoreBiomeWidth);
int alignedHalo = Math.floorDiv(
Math.min(
routing.maximumRouteLength(),
@@ -106,13 +122,13 @@ public record HydrologyPlannerSettings(
if (surface.enabled() && surface.sources().enabled()) {
int blendWidth = surface.banks().maximumBlendWidth();
int surfaceRadius = (int) StrictMath.ceil(
surface.maximumWidth() / 2D + surface.shoreWidth() + blendWidth
surface.maximumWidth() * surface.banks().mouthFlareRatio() / 2D + shoreReach + blendWidth
);
surfaceRadius = Math.max(
surfaceRadius,
(int) StrictMath.ceil(geometry.drops().basinWidth(
geometry.drops().flowWidth(surface.maximumWidth())
) / 2D + surface.shoreWidth() + blendWidth)
) / 2D + shoreReach + blendWidth)
);
surfaceRadius = Math.max(surfaceRadius, outlets.coastalGrotto().horizontalRadius());
surfaceRadius = Math.max(surfaceRadius, outlets.inlandGrotto().horizontalRadius());
@@ -144,9 +160,16 @@ public record HydrologyPlannerSettings(
if (!pool.enabled()) {
continue;
}
radius = Math.max(radius, pool.maximumRadius() + (int) StrictMath.ceil(surface.shoreWidth())
radius = Math.max(radius, pool.maximumRadius() + (int) StrictMath.ceil(shoreReach)
+ surface.banks().maximumBlendWidth());
}
if (seaCaves.enabled() && seaCaves.maximumPerTile() > 0) {
// A chamber hangs off a shoreline that may lie a halo away, swept inland by its depth.
radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(
outlets.coastalGrotto().horizontalRadius(),
Math.addExact(seaCaves.depth(), 1)
)));
}
return radius;
}
@@ -260,9 +283,12 @@ public record HydrologyPlannerSettings(
}
}
/**
* {@code sink} is how many blocks the water surface sits below the lowest natural ground beside
* the channel; zero keeps the water flush with the bank and the bank top always meets the water.
*/
public record Banks(
int inset,
int freeboard,
int sink,
double blendSlope,
int minimumBlendWidth,
int maximumBlendWidth,
@@ -271,13 +297,19 @@ public record HydrologyPlannerSettings(
int cascadeRun,
int waterfallMinimumDrop,
double mouthFlareRatio,
Inlet inlet,
double springWidthRatio,
int springLength,
boolean exposeCutStrata
boolean exposeCutStrata,
Erosion erosion,
Ponds ponds
) {
// Structural invariants only; authoring bounds live in the pack validator.
public Banks {
if (inset < 0 || freeboard < 0
if (erosion == null || ponds == null || inlet == null) {
throw new IllegalArgumentException("Surface erosion, inlet and pond settings are required.");
}
if (sink < 0
|| !Double.isFinite(blendSlope) || blendSlope <= 0D
|| minimumBlendWidth < 1 || maximumBlendWidth < minimumBlendWidth
|| !Double.isFinite(roughness) || roughness < 0D || roughness > 1D
@@ -292,7 +324,98 @@ public record HydrologyPlannerSettings(
}
public static Banks defaults() {
return new Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true);
return new Banks(0, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, Inlet.defaults(), 2.5D, 24, true, Erosion.defaults(), Ponds.defaults());
}
public Banks withInlet(Inlet inlet) {
return new Banks(sink, blendSlope, minimumBlendWidth, maximumBlendWidth, roughness, roughnessWavelength,
cascadeRun, waterfallMinimumDrop, mouthFlareRatio, inlet, springWidthRatio, springLength,
exposeCutStrata, erosion, ponds);
}
}
/**
* The drowned reach where a surface river meets the sea. Over the last {@code length} blocks before
* the coast the water is held at sea level, the channel widens toward the mouth flare and its bed
* deepens by {@code depth}; the stations above it grade down one block per station into the inlet.
* The inlet and its approach may be cut up to {@code maximumIncision} deep instead of the channel
* cap, so a coastal rise no longer rejects the course; a rise the cap cannot pass ends the inlet
* there, and the inlet never takes more than half the exposed course. A zero length is the plain
* crossing: the head drops to sea level at the coast, the channel neither widens nor deepens.
*/
public record Inlet(
int length,
int depth,
int maximumIncision
) {
public Inlet {
if (length < 0 || length > 1024 || depth < 0 || depth > 64 || maximumIncision < 0 || maximumIncision > 512) {
throw new IllegalArgumentException("Surface inlet settings are invalid.");
}
}
public static Inlet none() {
return new Inlet(0, 0, 0);
}
public static Inlet defaults() {
return new Inlet(64, 3, 32);
}
}
/**
* How the ground around a channel is eroded into a valley. {@code smoothingRadius} is the run of
* stations the blend width is averaged over, {@code thalwegFraction} the share of the channel
* half-width that stays at full bed depth, {@code blendCurve} the exponent on the blend progress
* (below one hollows the valley sides, above one steepens them near the shore), and
* {@code bedNoise} the share of the channel roughness applied to the bed. Disabled erosion keeps
* only the wet channel, the shore band and the containing lip.
*/
public record Erosion(
boolean enabled,
int smoothingRadius,
double thalwegFraction,
double blendCurve,
double bedNoise
) {
public Erosion {
if (smoothingRadius < 0
|| !Double.isFinite(thalwegFraction) || thalwegFraction < 0D || thalwegFraction >= 1D
|| !Double.isFinite(blendCurve) || blendCurve <= 0D
|| !Double.isFinite(bedNoise) || bedNoise < 0D) {
throw new IllegalArgumentException("Surface erosion settings are invalid.");
}
}
public static Erosion defaults() {
return new Erosion(true, 12, 0.45D, 1D, 0.5D);
}
}
/** A pond at one end of a surface course: a round bowl holding the course's head at that end. */
public record Pond(boolean enabled, int minimumRadius, int maximumRadius, int depth) {
public Pond {
if (minimumRadius < 1 || maximumRadius < minimumRadius || depth < 1) {
throw new IllegalArgumentException("Surface pond settings are invalid.");
}
}
}
/** The ponds at a surface course's source and at an inland terminal. */
public record Ponds(Pond source, Pond terminal) {
public Ponds {
if (source == null || terminal == null) {
throw new IllegalArgumentException("Surface pond settings are required.");
}
}
public static Ponds defaults() {
return new Ponds(new Pond(true, 6, 12, 3), new Pond(true, 4, 7, 3));
}
/** No pond at either end: what a standing pool or a test channel asks for. */
public static Ponds none() {
return new Ponds(new Pond(false, 1, 1, 1), new Pond(false, 1, 1, 1));
}
}
@@ -321,6 +444,33 @@ public record HydrologyPlannerSettings(
}
}
/**
* Sea caves: coastal grottos that open from the ocean into the coast without a river. A tile keeps at
* most {@code maximumPerTile} of them, the steepest owned coast first, at least {@code minimumSpacing}
* apart, only where the coast stands {@code minimumCoastHeight} above the sea, each chamber swept
* {@code depth} blocks inland from the shoreline. Chamber size and volume come from the coastal grotto.
*/
public record SeaCaves(
boolean enabled,
int maximumPerTile,
int minimumSpacing,
int minimumCoastHeight,
int depth
) {
public SeaCaves {
if (maximumPerTile < 0 || maximumPerTile > 64
|| minimumSpacing < 16 || minimumSpacing > 8192
|| minimumCoastHeight < 1 || minimumCoastHeight > 128
|| depth < 0 || depth > 128) {
throw new IllegalArgumentException("Sea cave bounds are invalid.");
}
}
public static SeaCaves disabled() {
return new SeaCaves(false, 0, 16, 1, 0);
}
}
// The routing gates read one hydraulic threshold: the drop that makes a reach a waterfall.
public record Hydraulics(int waterfallMinimumDrop) {
public Hydraulics {
@@ -341,7 +491,8 @@ public record HydrologyPlannerSettings(
int maximumDepth,
int minimumHeadroom,
int maximumHeadroom,
boolean connectToExistingCaves
boolean connectToExistingCaves,
int tributaries
) {
public Underground {
if (sources == null || minimumFluidY > maximumFluidY) {
@@ -350,6 +501,9 @@ public record HydrologyPlannerSettings(
requireRange(minimumWidth, maximumWidth, 1, 256, "underground width");
requireRange(minimumDepth, maximumDepth, 1, 64, "underground depth");
requireRange(minimumHeadroom, maximumHeadroom, 1, 128, "underground headroom");
if (tributaries < 0 || tributaries > 4) {
throw new IllegalArgumentException("underground tributaries must be between 0 and 4.");
}
}
}
@@ -361,7 +515,8 @@ public record HydrologyPlannerSettings(
int coastalCliffMinimumHeight,
int mouthLevelingDistance,
int maximumOceanApron,
int maximumPerTile
int maximumPerTile,
int maximumCoastalPerTile
) {
public Outlets {
if (coastalGrotto == null || inlandGrotto == null) {
@@ -371,7 +526,8 @@ public record HydrologyPlannerSettings(
throw new IllegalArgumentException("Surface sinkholes require inland grotto outlets.");
}
if (coastalCliffMinimumHeight < 0 || maximumOceanApron < 0 || maximumOceanApron > 64
|| mouthLevelingDistance < 0 || maximumPerTile < 1 || maximumPerTile > 256) {
|| mouthLevelingDistance < 0 || maximumPerTile < 1 || maximumPerTile > 256
|| maximumCoastalPerTile < 0 || maximumCoastalPerTile > 256) {
throw new IllegalArgumentException("Outlet bounds are invalid.");
}
}
@@ -30,8 +30,16 @@ public record HydrologyTerrainSample(
String bankBiomeKey,
String floodedCaveBiomeKey,
List<String> preferredProfileKeys,
List<String> surfacePoolKeys
List<String> surfacePoolKeys,
double shoreBiomeWidth,
String confinesKey
) {
/**
* {@code shoreBiomeWidth} is the width in blocks of the shore biome band beside a surface river at this
* column; {@code NaN} means the column has no policy value and the geometric shore width applies.
* {@code confinesKey} names the region or biome a river passing this column must stay inside, or is
* null where rivers may flow anywhere.
*/
public HydrologyTerrainSample {
requireFiniteNonNegative(slope, "slope");
requireFiniteNonNegative(routingCost, "routingCost");
@@ -53,6 +61,20 @@ public record HydrologyTerrainSample(
floodedCaveBiomeKey = normalizeKey(floodedCaveBiomeKey, surfaceBiomeKey);
preferredProfileKeys = normalizeProfiles(preferredProfileKeys);
surfacePoolKeys = surfacePoolKeys == null ? List.of() : List.copyOf(surfacePoolKeys);
if (!Double.isNaN(shoreBiomeWidth) && (!Double.isFinite(shoreBiomeWidth) || shoreBiomeWidth < 0D)) {
throw new IllegalArgumentException("shoreBiomeWidth must be NaN, or finite and non-negative.");
}
confinesKey = confinesKey == null || confinesKey.isBlank() ? null : confinesKey;
}
/** The shore biome band width at this column, or {@code fallback} when no policy set one. */
public double shoreBiomeWidth(double fallback) {
return Double.isNaN(shoreBiomeWidth) ? fallback : shoreBiomeWidth;
}
/** Whether a river at {@code upstream} may drain into this column without leaving its confines. */
public boolean drainsInto(HydrologyTerrainSample downstream) {
return confinesKey == null || confinesKey.equals(downstream.confinesKey());
}
public static HydrologyTerrainSample openLand(int naturalHeight, double slope, String parentBiomeKey) {
@@ -84,7 +106,9 @@ public record HydrologyTerrainSample(
parentBiomeKey,
parentBiomeKey,
List.of("default"),
List.of()
List.of(),
Double.NaN,
null
);
}
@@ -117,7 +141,9 @@ public record HydrologyTerrainSample(
parentBiomeKey,
parentBiomeKey,
List.of("default"),
List.of()
List.of(),
Double.NaN,
null
);
}
@@ -150,7 +176,9 @@ public record HydrologyTerrainSample(
bankBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys,
replacementPoolKeys
replacementPoolKeys,
shoreBiomeWidth,
confinesKey
);
}
@@ -183,7 +211,9 @@ public record HydrologyTerrainSample(
bankBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys,
surfacePoolKeys
surfacePoolKeys,
shoreBiomeWidth,
confinesKey
);
}
@@ -1,8 +1,6 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.volmlib.util.cache.CacheKey;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
@@ -12,11 +10,11 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinWorkerThread;
import java.util.concurrent.atomic.AtomicLong;
public final class HydrologyTileCache implements AutoCloseable {
@@ -32,7 +30,7 @@ public final class HydrologyTileCache implements AutoCloseable {
private final AtomicLong cacheEpoch;
private final ThreadLocal<LocalChunkColumns> localChunkColumns;
private final Executor prefetchExecutor;
private final Set<HydrologyTileKey> prefetching;
private final ConcurrentHashMap<HydrologyTileKey, CompletableFuture<HydrologyTile>> planning;
public HydrologyTileCache(HydrologyPlanner planner) {
this(planner, DEFAULT_MAXIMUM_ENTRIES);
@@ -49,7 +47,7 @@ public final class HydrologyTileCache implements AutoCloseable {
public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries, Executor prefetchExecutor) {
this.planner = Objects.requireNonNull(planner, "planner");
this.prefetchExecutor = prefetchExecutor;
this.prefetching = ConcurrentHashMap.newKeySet();
this.planning = new ConcurrentHashMap<>();
if (maximumEntries < 1) {
throw new IllegalArgumentException("maximumEntries must be positive.");
}
@@ -64,8 +62,34 @@ public final class HydrologyTileCache implements AutoCloseable {
this.localChunkColumns = new ThreadLocal<>();
}
/**
* The tile, planned on the prefetch executor when there is one so that a caller that gets
* interrupted or cancelled (a map render, a command) never aborts a plan every other caller is
* waiting for; without an executor the tile is planned on the caller.
*/
public HydrologyTile get(HydrologyTileKey key) {
Objects.requireNonNull(key, "key");
if (prefetchExecutor == null || plansInline()) {
return planInline(key);
}
return awaitTile(planAsync(key));
}
/**
* A pool worker plans on its own thread. Parking the workers of one pool on tasks of another is how
* two saturated pools deadlock each other (generation workers waiting for planning that waits for
* generation workers), and a ForkJoin worker keeps the planner's nested fan-out alive by helping
* with its own forks instead of waiting for them.
*/
private static boolean plansInline() {
return Thread.currentThread() instanceof ForkJoinWorkerThread;
}
private HydrologyTile planInline(HydrologyTileKey key) {
if (tiles.getIfPresent(key) == null && planning.containsKey(key)) {
IrisLogging.debug("Hydrology tile %d,%d awaited by %s while the planning pool plans it",
key.tileX(), key.tileZ(), Thread.currentThread().getName());
}
return tiles.get(key, this::planOrEmpty);
}
@@ -73,12 +97,16 @@ public final class HydrologyTileCache implements AutoCloseable {
* A tile whose planning throws is published without rivers instead of failing the chunks that
* asked for it: one bad column must not take the chunk system down. The failure is logged in
* full so the cause can be traced, and the empty tile is cached like any other so the session
* stays consistent.
* stays consistent. A plan that was interrupted is not a bad tile: it is rethrown without being
* cached so the next request plans it again.
*/
private HydrologyTile planOrEmpty(HydrologyTileKey key) {
try {
return planner.plan(key);
} catch (RuntimeException failure) {
if (interrupted(failure)) {
throw failure;
}
IrisLogging.error("Hydrology tile " + key.tileX() + "," + key.tileZ()
+ " failed to plan and generates without rivers: " + failure.getMessage());
IrisLogging.reportError(failure);
@@ -86,6 +114,21 @@ public final class HydrologyTileCache implements AutoCloseable {
}
}
private static boolean interrupted(Throwable failure) {
if (Thread.currentThread().isInterrupted()) {
return true;
}
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (cause instanceof InterruptedException || cause instanceof java.io.InterruptedIOException) {
return true;
}
if (cause.getCause() == cause) {
break;
}
}
return false;
}
public Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ);
}
@@ -94,6 +137,31 @@ public final class HydrologyTileCache implements AutoCloseable {
chunkColumns(blockX, blockZ);
}
/**
* Whether every tile the column's chunk composes from is already planned. A caller that must not
* wait (the server thread answering a height or biome query) uses this before sampling: when the
* answer is false the missing tiles are handed to the prefetch executor so a later query finds
* them, and nothing is planned on the caller.
*/
public boolean isPlanned(int blockX, int blockZ) {
int chunkX = Math.floorDiv(blockX, CHUNK_SIZE);
int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE);
if (composedChunks.getIfPresent(CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ))) != null) {
return true;
}
boolean planned = true;
for (HydrologyTileKey key : relevantKeys(chunkX, chunkZ)) {
if (tiles.getIfPresent(key) != null) {
continue;
}
planned = false;
if (prefetchExecutor != null) {
prefetch(key);
}
}
return planned;
}
public HydrologyRenderSample renderAt(int blockX, int blockZ) {
return columnAt(blockX, blockZ)
.map(HydrologyColumnSample::renderSample)
@@ -146,7 +214,8 @@ public final class HydrologyTileCache implements AutoCloseable {
return columns;
}
private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) {
/** The tiles a chunk's columns compose from: every tile within the publication radius of the chunk. */
private ArrayList<HydrologyTileKey> relevantKeys(int chunkX, int chunkZ) {
int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE);
int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
int tileSize = planner.settings().routing().tileSize();
@@ -163,7 +232,18 @@ public final class HydrologyTileCache implements AutoCloseable {
relevantKeys.add(new HydrologyTileKey(tileX, tileZ));
}
}
List<HydrologyTile> relevantTiles = loadTiles(relevantKeys);
return relevantKeys;
}
private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) {
int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE);
int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
ArrayList<HydrologyTileKey> relevantKeys = relevantKeys(chunkX, chunkZ);
int minimumTileX = relevantKeys.getFirst().tileX();
int maximumTileX = relevantKeys.getLast().tileX();
int minimumTileZ = relevantKeys.getFirst().tileZ();
int maximumTileZ = relevantKeys.getLast().tileZ();
List<HydrologyTile> relevantTiles = tiles(relevantKeys);
prefetchNeighbours(minimumTileX, maximumTileX, minimumTileZ, maximumTileZ);
HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT];
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
@@ -234,60 +314,69 @@ public final class HydrologyTileCache implements AutoCloseable {
}
private void prefetch(HydrologyTileKey key) {
if (tiles.getIfPresent(key) != null || !prefetching.add(key)) {
return;
planAsync(key);
}
/**
* Plans every tile that is not cached yet on the prefetch executor and returns all of them in key
* order. At most half the cache bound is in flight at once so a wide request cannot evict tiles
* before the caller has read them; a tile another caller is already planning is joined rather than
* planned twice. Without a prefetch executor the tiles are planned inline on the caller.
*/
public List<HydrologyTile> tiles(List<HydrologyTileKey> keys) {
Objects.requireNonNull(keys, "keys");
HydrologyTile[] loaded = new HydrologyTile[keys.size()];
if (prefetchExecutor == null || plansInline()) {
for (int index = 0; index < loaded.length; index++) {
loaded[index] = planInline(keys.get(index));
}
return List.of(loaded);
}
int batch = Math.max(1, maximumEntries / 2);
ArrayList<CompletableFuture<HydrologyTile>> futures = new ArrayList<>(Math.min(batch, loaded.length));
for (int start = 0; start < loaded.length; start += batch) {
int end = Math.min(loaded.length, start + batch);
futures.clear();
for (int index = start; index < end; index++) {
futures.add(planAsync(keys.get(index)));
}
for (int index = start; index < end; index++) {
loaded[index] = awaitTile(futures.get(index - start));
}
}
return List.of(loaded);
}
/**
* The tile, planned on the prefetch executor unless it is cached or already being planned, in which
* case the caller shares that plan instead of occupying a second executor thread with it.
*/
private CompletableFuture<HydrologyTile> planAsync(HydrologyTileKey key) {
HydrologyTile present = tiles.getIfPresent(key);
if (present != null) {
return CompletableFuture.completedFuture(present);
}
CompletableFuture<HydrologyTile> future = new CompletableFuture<>();
CompletableFuture<HydrologyTile> existing = planning.putIfAbsent(key, future);
if (existing != null) {
return existing;
}
try {
prefetchExecutor.execute(() -> {
try {
get(key);
} catch (RuntimeException failure) {
future.complete(planInline(key));
} catch (Throwable failure) {
future.completeExceptionally(failure);
IrisLogging.reportError(failure);
} finally {
prefetching.remove(key);
planning.remove(key, future);
}
});
} catch (RuntimeException rejected) {
prefetching.remove(key);
planning.remove(key, future);
future.completeExceptionally(rejected);
}
}
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);
}
// Tiles already planned are answered in place; only a tile that still needs planning is
// worth a round trip through the pool, and most chunks find every tile they need cached.
HydrologyTile[] loaded = new HydrologyTile[keys.size()];
ArrayList<CompletableFuture<HydrologyTile>> futures = null;
for (int index = 0; index < loaded.length; index++) {
HydrologyTileKey key = keys.get(index);
HydrologyTile present = tiles.getIfPresent(key);
if (present != null) {
loaded[index] = present;
continue;
}
if (futures == null) {
futures = new ArrayList<>(loaded.length);
}
int slot = index;
CompletableFuture<HydrologyTile> future = CompletableFuture.supplyAsync(() -> get(key), MultiBurst.hydrology);
futures.add(future.thenApply(tile -> {
loaded[slot] = tile;
return tile;
}));
}
if (futures != null) {
for (CompletableFuture<HydrologyTile> future : futures) {
awaitTile(future);
}
}
return List.of(loaded);
return future;
}
private static HydrologyTile awaitTile(CompletableFuture<HydrologyTile> future) {
@@ -38,9 +38,11 @@ public record RiverCourse(
}
}
validateSinkholeContinuation(type, segments);
boolean independent = type == RiverCourseType.DEEP_FLUID || type == RiverCourseType.SURFACE_POOL;
boolean independent = type == RiverCourseType.DEEP_FLUID
|| type == RiverCourseType.SURFACE_POOL
|| type == RiverCourseType.SEA_CAVE;
if (independent && (sourceNodeId.isPresent() || outletId.isPresent())) {
throw new IllegalArgumentException("Independent deep-fluid and pool courses cannot reference drainage nodes or river outlets.");
throw new IllegalArgumentException("Independent deep-fluid, pool and sea-cave 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.");
@@ -4,5 +4,7 @@ public enum RiverCourseType {
SURFACE,
UNDERGROUND,
DEEP_FLUID,
SURFACE_POOL
SURFACE_POOL,
// A coastal grotto the sea opens into on its own: no drainage source, no river outlet.
SEA_CAVE
}
@@ -21,11 +21,17 @@ public record EffectiveRiverPolicy(
double depthMultiplier,
double incisionMultiplier,
double routingMultiplier,
double bankMultiplier
double bankMultiplier,
Double shoreBiomeWidth,
RiverConfinement confinement
) {
public EffectiveRiverPolicy {
placement = Objects.requireNonNull(placement);
routing = Objects.requireNonNull(routing);
confinement = Objects.requireNonNull(confinement);
if (shoreBiomeWidth != null && (!Double.isFinite(shoreBiomeWidth) || shoreBiomeWidth < 0D)) {
throw new IllegalArgumentException("shoreBiomeWidth must be finite and non-negative.");
}
profiles = List.copyOf(profiles);
surfaceBiomes = List.copyOf(surfaceBiomes);
mouthBiomes = List.copyOf(mouthBiomes);
@@ -0,0 +1,8 @@
package art.arcane.iris.engine.hydrology.policy;
/** Which area a confined river policy binds courses to: the region or the biome that set it, or none. */
public enum RiverConfinement {
NONE,
REGION,
BIOME
}
@@ -27,9 +27,9 @@ public final class RiverPolicyResolver {
IrisRiverPolicy biomePolicy
) {
State state = new State();
state.apply(dimensionPolicy);
state.apply(regionPolicy);
state.apply(biomePolicy);
state.apply(dimensionPolicy, RiverConfinement.REGION);
state.apply(regionPolicy, RiverConfinement.REGION);
state.apply(biomePolicy, RiverConfinement.BIOME);
return state.build();
}
@@ -49,11 +49,20 @@ public final class RiverPolicyResolver {
private double incisionMultiplier = 1D;
private double routingMultiplier = 1D;
private double bankMultiplier = 1D;
private Double shoreBiomeWidth = null;
private RiverConfinement confinement = RiverConfinement.NONE;
private void apply(IrisRiverPolicy policy) {
/** {@code scope} is the confinement a {@code confined: true} at this level binds courses to. */
private void apply(IrisRiverPolicy policy, RiverConfinement scope) {
if (policy == null) {
return;
}
if (policy.getShoreBiomeWidth() != null) {
shoreBiomeWidth = policy.getShoreBiomeWidth();
}
if (policy.getConfined() != null) {
confinement = policy.getConfined() ? scope : RiverConfinement.NONE;
}
if (policy.getPlacement() != null) {
placement = policy.getPlacement();
}
@@ -117,7 +126,9 @@ public final class RiverPolicyResolver {
depthMultiplier,
incisionMultiplier,
routingMultiplier,
bankMultiplier
bankMultiplier,
shoreBiomeWidth,
confinement
);
}
}
@@ -4,6 +4,7 @@ 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.engine.hydrology.HydrologyForkJoin;
import art.arcane.iris.util.common.parallel.MultiBurst;
import it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
@@ -11,8 +12,7 @@ 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.Callable;
import java.util.concurrent.Executor;
final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrainSampler, AutoCloseable {
@@ -214,27 +214,19 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
return;
}
int rowsPerWorker = Math.ceilDiv(planeWidth, workerCount);
ArrayList<CompletableFuture<Void>> futures = new ArrayList<>(workerCount);
ArrayList<Callable<Void>> rows = 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()
));
rows.add(() -> {
fillBasisRows(plane, planeWidth, minimumX, minimumZ, spacing, minimumRow, maximumRow);
return null;
});
}
await(futures);
HydrologyForkJoin.invokeAll(rows, samplingOptions.executor());
}
private void fillBasisRows(
@@ -264,21 +256,6 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
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) {
@@ -18,37 +18,43 @@ 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.RiverConfinement;
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.IrisRiverGrottoConfig;
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.IrisRiverPolicy;
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.IrisSeaCaveConfig;
import art.arcane.iris.engine.object.IrisStyledRange;
import art.arcane.iris.engine.object.IrisSurfacePoolConfig;
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.IrisSurfaceRiverFlowConfig;
import art.arcane.iris.engine.object.IrisSurfaceRiverPondConfig;
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.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.data.DataProvider;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.parallel.MultiBurst;
import java.util.ArrayList;
import java.util.Collections;
@@ -60,6 +66,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.IntConsumer;
public final class IrisHydrologyRuntime implements AutoCloseable {
@@ -84,6 +91,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
private final Set<String> profileKeys;
private final String defaultProfileKey;
private final Object terrainSampleLock;
private final Set<HydrologyTileKey> unplannedQueries = ConcurrentHashMap.newKeySet();
private final LinkedHashMap<Long, HydrologyTerrainSample> terrainSamples;
public IrisHydrologyRuntime(IrisHydrologyRuntimeContext context) {
@@ -94,7 +102,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
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);
this.settings = createSettings(context.dimension(), hydrology, context::data);
HydrologyGeometrySampler geometrySampler = geometrySampler(context, hydrology);
IrisHydrologyRoutingTerrainSampler.Sources terrainSources = new IrisHydrologyRoutingTerrainSampler.Sources(
this::createTerrainBasis,
@@ -115,6 +123,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
context.caveViewFactory()
);
this.cache = new HydrologyTileCache(planner, MAXIMUM_CACHE_TILES, IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
IrisLogging.debug("Hydrology runtime: tileSize=%d publicationRadius=%d planningThreads=%d",
settings.routing().tileSize(), settings.publicationRadius(), MultiBurst.hydrology.parallelism());
}
public HydrologyPlannerSettings settings() {
@@ -125,6 +135,25 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
return cache.get(key);
}
/**
* Whether the column can be sampled without planning: true when its tiles are cached. Otherwise the
* planning pool is asked for them and the caller should answer from natural terrain for now.
*/
public boolean isPlanned(double x, double z) {
int blockX = (int) StrictMath.floor(x);
int blockZ = (int) StrictMath.floor(z);
boolean planned = cache.isPlanned(blockX, blockZ);
if (!planned) {
int tileSize = settings.routing().tileSize();
HydrologyTileKey key = new HydrologyTileKey(Math.floorDiv(blockX, tileSize), Math.floorDiv(blockZ, tileSize));
if (unplannedQueries.add(key)) {
IrisLogging.debug("Hydrology tile %d,%d queried before it was planned; natural terrain answers until the plan lands",
key.tileX(), key.tileZ());
}
}
return planned;
}
public Optional<HydrologyColumnSample> sample(double x, double z) {
int blockX = (int) StrictMath.floor(x);
int blockZ = (int) StrictMath.floor(z);
@@ -300,8 +329,9 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
}
/**
* 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.
* Walks tiles outward from the origin, planning each ring's tiles together on the hydrology pool, 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,
@@ -334,8 +364,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
if (nearest != null && lowerBound * lowerBound > nearestDistanceSquared) {
break;
}
for (HydrologyTileKey key : HydrologyFeatureSearch.ring(originTileX, originTileZ, ring)) {
HydrologyTile tile = cache.get(key);
for (HydrologyTile tile : cache.tiles(HydrologyFeatureSearch.ring(originTileX, originTileZ, ring))) {
visited++;
HydrologyFeatureRef feature = tile.nearestFeature(
types, profileKey, x, z, maximumDistance).orElse(null);
@@ -455,6 +484,12 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
? sampledNaturalHeight
: rawNaturalHeight;
if (!Double.isFinite(resolvedNaturalHeight)) {
// A non-finite height is a transient fault in a shared sampling cache, not a fact about
// the terrain: sample the column once more before giving the tile up.
double resampledNaturalHeight = context.naturalHeightProvider().sample(x, z);
if (Double.isFinite(resampledNaturalHeight)) {
return createTerrainBasis(x, z, resampledNaturalHeight);
}
throw new IllegalStateException(nonFiniteHeightMessage(
x,
z,
@@ -527,11 +562,22 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
selectKey(policy.bankBiomes(), parentBiomeKey, biomePatchNoise, 4),
selectKey(policy.floodedCaveBiomes(), parentBiomeKey, biomePatchNoise, 5),
profiles,
policy.surfacePools()
policy.surfacePools(),
policy.shoreBiomeWidth() == null ? Double.NaN : policy.shoreBiomeWidth(),
confinesKey(policy.confinement(), region, biome)
);
return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(resolvedNaturalHeight, terrain);
}
/** The area a river at this column must stay inside, named so that neighbouring areas never match. */
static String confinesKey(RiverConfinement confinement, IrisRegion region, IrisBiome biome) {
return switch (confinement) {
case NONE -> null;
case REGION -> region == null ? null : "region:" + region.getLoadKey();
case BIOME -> biome == null ? null : "biome:" + biome.getLoadKey();
};
}
private List<String> validProfiles(List<String> requested) {
if (requested == null || requested.isEmpty()) {
return List.of(defaultProfileKey);
@@ -685,7 +731,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
static HydrologyPlannerSettings createSettings(
IrisDimension dimension,
IrisHydrology hydrology
IrisHydrology hydrology,
DataProvider data
) {
IrisRiverHydrology rivers = hydrology.getRivers();
IrisRiverRoutingConfig routing = rivers.getRouting();
@@ -733,8 +780,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
channel.getMaximumIncision(),
banks.getShoreWidth(),
new HydrologyPlannerSettings.Banks(
channel.getInset(),
banks.getFreeboard(),
channel.getSink(),
banks.getBlendSlope(),
banks.getMinimumBlendWidth(),
banks.getMaximumBlendWidth(),
@@ -743,9 +789,25 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
flow.getCascadeRun(),
flow.getWaterfallMinimumDrop(),
surface.getMouths().getFlareRatio(),
new HydrologyPlannerSettings.Inlet(
surface.getMouths().getInletLength(),
surface.getMouths().getInletDepth(),
surface.getMouths().getMaximumIncision()
),
channel.getSpringWidthRatio(),
channel.getSpringLength(),
banks.isExposeCutStrata()
banks.isExposeCutStrata(),
new HydrologyPlannerSettings.Erosion(
surface.getErosion().isEnabled(),
surface.getErosion().getSmoothingRadius(),
surface.getErosion().getThalwegFraction(),
surface.getErosion().getBlendCurve(),
surface.getErosion().getBedNoise()
),
new HydrologyPlannerSettings.Ponds(
pond(surface.getPonds().getSource()),
pond(surface.getPonds().getTerminal())
)
)
);
HydrologyPlannerSettings.Hydraulics plannerHydraulics =
@@ -769,7 +831,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
maximumInt(underground.getDepth()),
minimumInt(underground.getHeadroom()),
maximumInt(underground.getHeadroom()),
underground.isConnectToExistingCaves()
underground.isConnectToExistingCaves(),
underground.getTributaries()
);
IrisRiverGrottoConfig grottos = rivers.getGrottos();
IrisCoastalRiverGrottoConfig coastal = grottos.getCoastal();
@@ -786,7 +849,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
Math.max(4, coastal.getVerticalRadius()),
underground.getMouthLevelingDistance(),
surface.getMouths().getMaximumOceanApron(),
routing.getMaximumOutletsPerTile()
routing.getMaximumOutletsPerTile(),
routing.getMaximumCoastalOutletsPerTile()
);
HydrologyPlannerSettings.Geometry plannerGeometry = geometry(
rivers.getGeometry(),
@@ -806,10 +870,32 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
plannerOutlets,
plannerGeometry,
deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()),
surfacePools(hydrology.getSurfacePools())
surfacePools(hydrology.getSurfacePools()),
widestShoreBiomeWidth(dimension, data, banks.getShoreWidth()),
seaCaves(coastal)
);
}
/**
* The widest shore biome band any policy of the dimension asks for, never below the geometric shore
* width, so the publication envelope reaches every column a river can give shore content to.
*/
static double widestShoreBiomeWidth(IrisDimension dimension, DataProvider data, double shoreWidth) {
double widest = Math.max(0D, shoreWidth);
widest = Math.max(widest, policyShoreBiomeWidth(dimension.getRiverPolicy()));
for (IrisRegion region : dimension.getAllRegions(data)) {
widest = Math.max(widest, policyShoreBiomeWidth(region.getRiverPolicy()));
}
for (IrisBiome biome : dimension.getAllBiomes(data)) {
widest = Math.max(widest, policyShoreBiomeWidth(biome.getRiverPolicy()));
}
return widest;
}
private static double policyShoreBiomeWidth(IrisRiverPolicy policy) {
return policy == null || policy.getShoreBiomeWidth() == null ? 0D : policy.getShoreBiomeWidth();
}
private static List<HydrologyPlannerSettings.SurfacePool> surfacePools(List<IrisSurfacePoolConfig> configurations) {
ArrayList<HydrologyPlannerSettings.SurfacePool> pools = new ArrayList<>();
for (IrisSurfacePoolConfig configuration : configurations) {
@@ -884,6 +970,18 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
);
}
// A sea cave is a coastal grotto without a river, so it needs the coastal grotto itself enabled.
private static HydrologyPlannerSettings.SeaCaves seaCaves(IrisCoastalRiverGrottoConfig coastal) {
IrisSeaCaveConfig seaCaves = coastal.getSeaCaves();
return new HydrologyPlannerSettings.SeaCaves(
coastal.isEnabled() && seaCaves.isEnabled(),
seaCaves.getMaximumPerTile(),
seaCaves.getMinimumSpacing(),
seaCaves.getMinimumCoastHeight(),
seaCaves.getDepth()
);
}
private static List<HydrologyPlannerSettings.DeepFluid> deepFluids(
List<IrisDeepFluidConfig> configurations,
int minimumWorldY,
@@ -966,6 +1064,15 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
return (int) Math.min(1_048_576L, Math.max(64L, volume));
}
private static HydrologyPlannerSettings.Pond pond(IrisSurfaceRiverPondConfig config) {
return new HydrologyPlannerSettings.Pond(
config.isEnabled(),
config.getMinimumRadius(),
config.getMaximumRadius(),
config.getDepth()
);
}
private static int minimumInt(IrisStyledRange range) {
return (int) StrictMath.floor(Math.min(range.getMin(), range.getMax()));
}
@@ -9,7 +9,6 @@ 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;
@@ -34,6 +33,7 @@ public final class ChannelProfileBuilder {
double[] width = new double[count];
double[] depth = new double[count];
double[] bank = new double[count];
int coast = count;
for (int station = 0; station < count; station++) {
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
@@ -56,6 +56,9 @@ public final class ChannelProfileBuilder {
surface.maximumDepth()
);
HydrologyTerrainSample terrain = sampler.sample(stationX, stationZ);
if (coast == count && (terrain == null || terrain.ocean())) {
coast = station;
}
double widthMultiplier = terrain == null ? 1D : terrain.widthMultiplier();
double depthMultiplier = terrain == null ? 1D : terrain.depthMultiplier();
bank[station] = terrain == null ? 1D : terrain.bankMultiplier();
@@ -74,12 +77,17 @@ public final class ChannelProfileBuilder {
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));
// The inlet: over its length before the coast the channel widens toward the mouth flare and
// its bed deepens by the inlet depth, so the estuary is wider and deeper than the river. The
// flare completes at the shoreline, the first station in the sea, and the stations past it keep it.
HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet();
if (directOcean && inlet.length() > 0 && coast > 1) {
int flare = Math.min(inlet.length(), coast - 1);
int flareStart = coast - flare;
for (int station = flareStart; station < count; station++) {
double progress = SurfaceNoise.smoothStep(Math.min(1D, (station - flareStart) / (double) Math.max(1, flare - 1)));
smoothWidth[station] *= 1D + (surface.banks().mouthFlareRatio() - 1D) * progress;
smoothDepth[station] += inlet.depth() * progress;
}
}
double[] halfWidth = new double[count];
@@ -1,5 +1,6 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyHash;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
@@ -12,12 +13,13 @@ 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 long SOURCE_POND_SALT = 0x504f4e4453524345L;
private static final long TERMINAL_POND_SALT = 0x504f4e4454524dL;
private static final int POND_RIM_SAMPLES_PER_BLOCK = 8;
private static final double EPSILON = 1.0E-9D;
private final HydrologyPlannerSettings.Surface surface;
@@ -41,12 +43,29 @@ public final class ErosionFieldCompiler {
ValleyProfile valley,
SurfaceTerminal terminal,
int apronLimit
) {
return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, surface.banks().ponds());
}
/**
* @param ponds the ponds to dig at the course ends; a river passes the configured ones, a standing
* pool passes none
*/
public ErosionField compile(
long courseSeed,
SurfaceCenterline centerline,
ChannelProfile channel,
ValleyProfile valley,
SurfaceTerminal terminal,
int apronLimit,
HydrologyPlannerSettings.Ponds ponds
) {
HydrologyPlannerSettings.Banks banks = surface.banks();
HydrologyPlannerSettings.Erosion erosion = banks.erosion();
int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations();
double roughness = banks.roughness();
double shoreWidth = surface.shoreWidth();
int freeboard = banks.freeboard();
int sink = banks.sink();
boolean[] basin = basins(valley, channel, count);
double[][] blendWidth = blendWidths(centerline, channel, valley, count);
Long2IntOpenHashMap nearest = new Long2IntOpenHashMap();
@@ -54,7 +73,10 @@ public final class ErosionFieldCompiler {
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;
// The shore biome band a policy asks for can be wider than the geometric shore and the
// eroded valley; the search radius has to reach it so those columns get a shore role.
double stationBand = stationShoreBiomeBand(centerline, station, shoreWidth);
int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + Math.max(shoreWidth + widest, stationBand)) + 1;
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
@@ -107,42 +129,195 @@ public final class ErosionFieldCompiler {
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())
double local = 1D + (depth - 1D) * bowl(normalized, erosion.thalwegFraction())
+ erosion.bedNoise() * 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;
int bankTop = head + sink;
// The SHORE role is the shore biome band: the column's policy width, or the geometric shore
// width where no policy sets one. It is independent of the flattened geometric shore and of
// the eroded valley, so a wide band reaches over untouched ground and a zero band leaves the
// geometric shore with the bank biome.
boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D;
SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK;
if (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));
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
continue;
}
int cut = natural - bankTop;
if (cut <= 0) {
if (cut <= 0 || !erosion.enabled()) {
if (shoreBiome) {
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
}
continue;
}
double side = side(centerline, station, cellX, cellZ);
double width = blendWidth[station][side < 0D ? 0 : 1];
double progress = (cellDistance - outline - shoreWidth) / width;
if (progress >= 1D) {
if (shoreBiome) {
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
}
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 height = Math.min(natural, (int) StrictMath.round(bankTop + cut * blend(progress, erosion.blendCurve())));
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
}
int uncontained = contain(columns, wetKeys, freeboard);
if (count > 0) {
pond(columns, wetKeys, courseSeed ^ SOURCE_POND_SALT, ponds.source(), centerline, 0, valley.head()[0]);
if (terminal == SurfaceTerminal.SINKHOLE) {
pond(columns, wetKeys, courseSeed ^ TERMINAL_POND_SALT, ponds.terminal(), centerline, count - 1, valley.head()[count - 1]);
}
}
int uncontained = contain(columns, wetKeys, sink);
connectSteps(columns, wetKeys);
return new ErosionField(columns, uncontained);
}
/**
* A round bowl around an end station holding that station's head: wet inside its outline, a shore
* ring and an eroded rim outside it like the channel's own. Channel water inside the bowl joins
* the pond at the pond's level; dry channel columns give way to the bowl. The radius is chosen per
* course within the configured range and shrinks where the ground around the rim falls below the
* water, so the pond never sits above the bank that has to hold it; no radius fits, no pond.
*/
private void pond(
Long2ObjectOpenHashMap<SurfaceColumn> columns,
LongArrayList wetKeys,
long seed,
HydrologyPlannerSettings.Pond pond,
SurfaceCenterline centerline,
int station,
int head
) {
if (!pond.enabled()) {
return;
}
HydrologyPlannerSettings.Banks banks = surface.banks();
HydrologyPlannerSettings.Erosion erosion = banks.erosion();
double roughness = banks.roughness();
double shoreWidth = surface.shoreWidth();
int sink = banks.sink();
int centreX = centerline.x()[station];
int centreZ = centerline.z()[station];
int radius = pondRadius(seed, pond, centreX, centreZ, head + sink, roughness);
if (radius < 1) {
return;
}
int bankTop = head + sink;
int reach = (int) StrictMath.ceil(radius * (1D + roughness) + shoreWidth + banks.maximumBlendWidth()) + 1;
for (int deltaZ = -reach; deltaZ <= reach; deltaZ++) {
for (int deltaX = -reach; deltaX <= reach; deltaX++) {
double distance = Math.sqrt((double) deltaX * deltaX + (double) deltaZ * deltaZ);
if (distance > reach) {
continue;
}
int cellX = centreX + deltaX;
int cellZ = centreZ + deltaZ;
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
continue;
}
int natural = terrain.naturalHeight();
long key = RiverFootprint.pack(cellX, cellZ);
SurfaceColumn existing = columns.get(key);
if (existing != null && existing.apron()) {
continue;
}
double outline = radius * (1D + roughness * SurfaceNoise.signed(
seed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength()));
outline = Math.max(Math.max(0.5D, 0.6D * radius), Math.min(1.4D * radius, outline));
if (distance <= outline + 0.25D) {
double normalized = Math.min(1D, distance / Math.max(0.5D, outline));
double local = 1D + (pond.depth() - 1D) * bowl(normalized, erosion.thalwegFraction())
+ erosion.bedNoise() * roughness * SurfaceNoise.signed(seed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength());
int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local)));
boolean wasWet = existing != null && existing.role() == SurfaceRole.CHANNEL;
if (wasWet) {
bed = Math.min(bed, existing.height());
}
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
if (!wasWet) {
wetKeys.add(key);
}
continue;
}
if (existing != null && existing.role() == SurfaceRole.CHANNEL) {
continue;
}
if (distance <= 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 || !erosion.enabled()) {
continue;
}
double width = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), cut * banks.blendSlope()));
double progress = (distance - outline - shoreWidth) / width;
if (progress >= 1D) {
continue;
}
int height = Math.min(natural, (int) StrictMath.round(bankTop + cut * blend(progress, erosion.blendCurve())));
if (existing != null && existing.height() <= height) {
continue;
}
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.BANK, height, height, false));
}
}
}
/**
* The largest radius in the configured range, chosen per course, whose rim ground everywhere
* reaches the bank top; zero when even the smallest radius sits above the ground beside it.
*/
private int pondRadius(long seed, HydrologyPlannerSettings.Pond pond, int centreX, int centreZ, int bankTop, double roughness) {
int chosen = HydrologyHash.between(seed, pond.minimumRadius(), pond.maximumRadius());
for (int radius = chosen; radius >= pond.minimumRadius(); radius--) {
if (rimHolds(centreX, centreZ, radius * (1D + roughness) + 1D, bankTop)) {
return radius;
}
}
return 0;
}
private boolean rimHolds(int centreX, int centreZ, double rim, int bankTop) {
int samples = Math.max(8, (int) StrictMath.ceil(rim * POND_RIM_SAMPLES_PER_BLOCK));
for (int sample = 0; sample < samples; sample++) {
double angle = sample * (2D * Math.PI / samples);
for (double ring = rim; ring <= rim + 1D; ring += 0.5D) {
int cellX = (int) StrictMath.round(centreX + Math.cos(angle) * ring);
int cellZ = (int) StrictMath.round(centreZ + Math.sin(angle) * ring);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
if (!SurfaceCellAdmission.writable(terrain, seaLevel) || terrain.naturalHeight() < bankTop) {
return false;
}
}
}
return true;
}
/** Eased blend from the bank top (0) to natural terrain (1); the curve exponent reshapes the valley side. */
static double blend(double progress, double curve) {
return SurfaceNoise.smoothStep(Math.pow(progress, curve));
}
/**
* 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.
*/
/** The shore biome band the terrain at a station asks for, or the geometric shore width without a policy. */
private double stationShoreBiomeBand(SurfaceCenterline centerline, int station, double shoreWidth) {
HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]);
return terrain == null ? shoreWidth : terrain.shoreBiomeWidth(shoreWidth);
}
private static void connectSteps(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys) {
for (int index = 0; index < wetKeys.size(); index++) {
long key = wetKeys.getLong(index);
@@ -167,21 +342,29 @@ public final class ErosionFieldCompiler {
* 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) {
private int contain(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys, int sink) {
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);
uncontained += lip(columns, wet, CARDINALS, sink, true);
lip(columns, wet, DIAGONALS, sink, false);
}
return uncontained;
}
/**
* The bank beside water must reach the natural ground the head was taken from: the water's own
* block level plus the sink. A neighbour lower than that is raised to it. The solver takes the head
* from the banks it samples at each station, so a cell it never sampled (between stations, on the
* outside of a bend) can lie a block lower than the water beside it: such a cell may be raised one
* block above its natural height, a lip no taller than the old fixed one, rather than let the
* water spill. A deeper shortfall stays uncontained and is counted.
*/
private int lip(
Long2ObjectOpenHashMap<SurfaceColumn> columns,
SurfaceColumn wet,
int[][] offsets,
int freeboard,
int sink,
boolean countSpills
) {
int uncontained = 0;
@@ -189,7 +372,9 @@ public final class ErosionFieldCompiler {
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) {
// Water that meets the sea at the mouth is not spilling; anything else beyond the field is.
if (countSpills && wet.headY() != seaLevel
&& SurfaceCellAdmission.writable(sampler.sample(wet.x() + offset[0], wet.z() + offset[1]), seaLevel)) {
uncontained++;
}
continue;
@@ -197,9 +382,10 @@ public final class ErosionFieldCompiler {
if (neighbour.role() == SurfaceRole.CHANNEL) {
continue;
}
int required = wet.headY() + freeboard;
int required = wet.headY() + sink;
if (neighbour.height() < required) {
int raised = Math.min(neighbour.terrain().naturalHeight(), required);
int ceiling = neighbour.terrain().naturalHeight() + (countSpills ? 1 : 0);
int raised = Math.min(ceiling, required);
neighbour = neighbour.withHeight(raised);
columns.put(key, neighbour);
}
@@ -243,10 +429,13 @@ public final class ErosionFieldCompiler {
private double[][] blendWidths(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, int count) {
HydrologyPlannerSettings.Banks banks = surface.banks();
double[][] widths = new double[count][2];
if (!banks.erosion().enabled()) {
return widths;
}
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();
int bankTop = valley.head()[station] + banks.sink();
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);
@@ -257,7 +446,7 @@ public final class ErosionFieldCompiler {
widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width));
}
}
return smoothWidths(widths, BLEND_SMOOTHING_RADIUS);
return smoothWidths(widths, banks.erosion().smoothingRadius());
}
/**
@@ -293,11 +482,11 @@ public final class ErosionFieldCompiler {
* 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) {
static double bowl(double normalized, double thalwegFraction) {
if (normalized <= thalwegFraction) {
return 1D;
}
double t = Math.min(1D, (normalized - THALWEG_FRACTION) / (1D - THALWEG_FRACTION));
double t = Math.min(1D, (normalized - thalwegFraction) / (1D - thalwegFraction));
return 1D - t * t * (3D - 2D * t);
}
@@ -74,7 +74,8 @@ public final class SurfaceFootprintCompiler {
channel,
valley,
terminal,
settings.outlets().maximumOceanApron()
settings.outlets().maximumOceanApron(),
pool ? HydrologyPlannerSettings.Ponds.none() : settings.surface().banks().ponds()
);
ArrayList<SurfaceColumn> ordered = new ArrayList<>(field.columns().values());
ordered.sort(Comparator
@@ -3,5 +3,7 @@ package art.arcane.iris.engine.hydrology.surface;
public enum SurfaceTerminal {
OCEAN_MOUTH,
SINKHOLE,
COASTAL_GROTTO
COASTAL_GROTTO,
/** The course ends on another surface course; the planner levels its tail to the stem's water at the junction. */
TRIBUTARY
}
@@ -8,6 +8,7 @@ import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.Objects;
public final class ValleyProfileSolver {
private static final int BANK_LOOKAHEAD = 2;
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final int seaLevel;
@@ -84,11 +85,28 @@ public final class ValleyProfileSolver {
if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed);
}
int inset = surface.banks().inset();
// The channel's rounded end caps reach past the first and last stations; the ground under
// them bounds those stations' water like any other bank cell.
crossMin[0] = Math.min(crossMin[0], capMinimum(centerline, channel, 0, -1D, roughness));
if (exposed == count) {
crossMin[exposed - 1] = Math.min(crossMin[exposed - 1], capMinimum(centerline, channel, exposed - 1, 1D, roughness));
}
int sink = surface.banks().sink();
int[] head = new int[count];
for (int station = 0; station < exposed; station++) {
int ceiling = crossMin[station] - inset;
head[station] = station == 0 ? ceiling : Math.min(head[station - 1], ceiling);
// The cells beside a station's water include cells the next stations own, so on ground
// that falls gently along the course the head steps down a station early rather than
// onto a bank the lip cannot raise above its natural height. A larger drop is a step or
// a fall and keeps its edge where the ground breaks.
int ceiling = crossMin[station] - sink;
int lookahead = ceiling;
for (int ahead = 1; ahead <= BANK_LOOKAHEAD && station + ahead < exposed; ahead++) {
int next = crossMin[station + ahead] - sink;
if (next < lookahead && next >= ceiling - 1) {
lookahead = next;
}
}
head[station] = station == 0 ? lookahead : Math.min(head[station - 1], lookahead);
}
if (terminal == SurfaceTerminal.OCEAN_MOUTH || terminal == SurfaceTerminal.COASTAL_GROTTO) {
for (int station = 0; station < exposed; station++) {
@@ -100,25 +118,108 @@ public final class ValleyProfileSolver {
head[station] = seaLevel;
}
}
// The inlet: the drowned reach runs inland from the coast, holding the water at sea level, as
// far as the inlet length allows and the ground can be cut to sea level within the inlet cap;
// a rise the cap cannot pass ends it there. Above it the approach grades down one block per
// station into the estuary wherever that cut fits, instead of dropping in a wall at the coast.
// The drowned reach is the end of a river, never most of one: it takes at most half the exposed
// course and the approach half of that again, so the headwater keeps its natural head.
// Every head set here is at or below the terrain-supported head, so the course stays non-rising.
HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet();
int channelIncision = surface.maximumIncision();
int inletIncision = Math.max(channelIncision, inlet.maximumIncision());
int rampStart = exposed;
if (terminal == SurfaceTerminal.OCEAN_MOUTH && inlet.length() > 0) {
int reach = Math.min(inlet.length(), exposed / 2);
int inletStart = exposed;
while (inletStart > 0 && exposed - inletStart < reach
&& cutFits(inletStart - 1, seaLevel, channel, centerNatural, incisionMultiplier, inletIncision)) {
inletStart--;
}
for (int station = inletStart; station < exposed; station++) {
head[station] = seaLevel;
}
rampStart = Math.max(0, inletStart - reach / 2);
// The ramp is contiguous with the inlet: the first station the cap cannot lower ends it,
// otherwise a station lowered further upstream would sit below one left at its natural head.
for (int station = inletStart - 1; station >= rampStart; station--) {
int target = seaLevel + (inletStart - station);
if (head[station] <= target) {
continue;
}
if (!cutFits(station, target, channel, centerNatural, incisionMultiplier, inletIncision)) {
break;
}
head[station] = target;
}
}
if (terminal == SurfaceTerminal.SINKHOLE && head[exposed - 1] < terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE, terminalHead - head[exposed - 1]);
}
if (terminal == SurfaceTerminal.COASTAL_GROTTO && head[exposed - 1] < terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE, terminalHead - head[exposed - 1]);
}
int maximumIncision = surface.maximumIncision();
int deepestCut = 0;
boolean rejected = false;
for (int station = 0; station < exposed; station++) {
int permitted = Math.min(maximumIncision, (int) StrictMath.floor(maximumIncision * incisionMultiplier[station]));
int bed = head[station] - (int) StrictMath.round(channel.depth()[station]);
int cut = centerNatural[station] - bed;
int maximumIncision = station >= rampStart ? inletIncision : channelIncision;
int cut = cut(station, head[station], channel, centerNatural);
deepestCut = Math.max(deepestCut, cut);
rejected |= cut > permitted;
rejected |= cut > permitted(station, incisionMultiplier, maximumIncision);
}
if (rejected) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, deepestCut);
}
return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null, 0);
}
private static boolean cutFits(
int station,
int head,
ChannelProfile channel,
int[] centerNatural,
double[] incisionMultiplier,
int maximumIncision
) {
return cut(station, head, channel, centerNatural) <= permitted(station, incisionMultiplier, maximumIncision);
}
/** How far below the natural ground the bed under a head sits at a station. */
private static int cut(int station, int head, ChannelProfile channel, int[] centerNatural) {
return centerNatural[station] - (head - (int) StrictMath.round(channel.depth()[station]));
}
private static int permitted(int station, double[] incisionMultiplier, int maximumIncision) {
return Math.min(maximumIncision, (int) StrictMath.floor(maximumIncision * incisionMultiplier[station]));
}
/**
* Lowest natural ground under the rounded cap the channel outline draws past an end station, in
* the given direction along the tangent; cells the channel cannot write are ignored.
*/
private int capMinimum(SurfaceCenterline centerline, ChannelProfile channel, int station, double direction, double roughness) {
double reach = channel.halfWidth()[station] * (1D + roughness) + 2D;
double tangentX = centerline.tangentX()[station] * direction;
double tangentZ = centerline.tangentZ()[station] * direction;
double normalX = centerline.normalX(station);
double normalZ = centerline.normalZ(station);
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
int minimum = Integer.MAX_VALUE;
for (double along = 0.5D; along <= reach; along += 0.5D) {
for (double across = -reach; across <= reach; across += 0.5D) {
if (along * along + across * across > reach * reach) {
continue;
}
int cellX = (int) StrictMath.round(stationX + tangentX * along + normalX * across);
int cellZ = (int) StrictMath.round(stationZ + tangentZ * along + normalZ * across);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
continue;
}
minimum = Math.min(minimum, terrain.naturalHeight());
}
}
return minimum;
}
}
@@ -9,7 +9,7 @@ import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("Controls direct, contained river-to-ocean grotto outlets.")
@Desc("Controls coastal grottos: sea-level chambers whose only opening is their ocean face, admitted either as a river outlet or as a standalone sea cave.")
@Data
public class IrisCoastalRiverGrottoConfig {
@Desc("Allow a coastal grotto to serve as an explicit accepted outlet.")
@@ -37,4 +37,7 @@ public class IrisCoastalRiverGrottoConfig {
@MaxNumber(1048576)
@Desc("Maximum accepted coastal grotto volume in blocks.")
private int maximumVolume = 8192;
@Desc("Standalone sea caves: coastal grottos opening from the ocean into the coast without a river.")
private IrisSeaCaveConfig seaCaves = new IrisSeaCaveConfig();
}
@@ -21,4 +21,19 @@ public class IrisRiverMouthConfig {
@MaxNumber(32)
@Desc("Maximum number of blocks the recorded mouth footprint may enter the ocean before terminating.")
private int maximumOceanApron = 8;
@MinNumber(0)
@MaxNumber(256)
@Desc("Blocks of river before the coast held at sea level as a drowned inlet, widened toward flareRatio over that reach. Zero ends the river at the shoreline with no inlet.")
private int inletLength = 64;
@MinNumber(0)
@MaxNumber(16)
@Desc("Extra bed depth reached at the shoreline over the inlet, so the estuary is deeper than the river above it.")
private int inletDepth = 3;
@MinNumber(0)
@MaxNumber(128)
@Desc("Deepest cut allowed in the inlet and its approach ramp, replacing channel.maximumIncision there, so the coast may be cut down to sea level through a rise of this height.")
private int maximumIncision = 32;
}
@@ -83,6 +83,14 @@ public class IrisRiverPolicy {
@Desc("Multiplier applied to the eroded bank width outside the shore band.")
private Double bankMultiplier = null;
@MinNumber(0)
@MaxNumber(32)
@Desc("Width in blocks of the shore biome band beside a surface river in this area. Null inherits, and without any value the band is the surface banks shore width. Zero gives the area no shore biome band; the geometric shore and erosion are unchanged.")
private Double shoreBiomeWidth = null;
@Desc("Rivers that start in this area stay inside it: their whole course and their outlet lie in this region (when set on a region) or this biome (when set on a biome), and a river that flows into the area never leaves it. A source with no reachable outlet inside the area is rejected. Null inherits, false releases the area.")
private Boolean confined = null;
public KSet<String> getAllBiomeIds() {
KSet<String> biomeIds = new KSet<>();
addAll(biomeIds, surfaceBiomes);
@@ -41,9 +41,14 @@ public class IrisRiverRoutingConfig {
@MinNumber(1)
@MaxNumber(256)
@Desc("Maximum drainage roots selected per planning tile.")
@Desc("Maximum inland drainage roots (sinkhole grottos) selected per planning tile. Unused when no inland outlet kind is enabled.")
private int maximumOutletsPerTile = 4;
@MinNumber(0)
@MaxNumber(64)
@Desc("Maximum sea outlets (river mouths and coastal grottos) selected per planning tile, chosen by type in turn: the best mouth, the best coastal grotto, the next mouth, and so on, each at least two sample spacings from the others. Budgeted separately from maximumOutletsPerTile.")
private int maximumCoastalOutletsPerTile = 2;
@Desc("Allow rivers to terminate at an ocean reservoir.")
private boolean oceanOutlets = true;
@@ -0,0 +1,37 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("Sea caves: coastal grottos that open from the ocean into the coast without a river. The chamber size, headroom and volume cap are the coastal grotto's.")
@Data
public class IrisSeaCaveConfig {
@Desc("Plan sea caves along the coast. Requires the coastal grotto to be enabled.")
private boolean enabled = true;
@MinNumber(0)
@MaxNumber(64)
@Desc("Sea caves accepted per planning tile; the steepest owned coast is taken first.")
private int maximumPerTile = 3;
@MinNumber(16)
@MaxNumber(8192)
@Desc("Least distance in blocks between two sea caves. Must be at least twice the coastal grotto horizontalRadius.")
private int minimumSpacing = 160;
@MinNumber(1)
@MaxNumber(128)
@Desc("The coast must stand this many blocks above the sea both at the shoreline and at the back of the chamber.")
private int minimumCoastHeight = 8;
@MinNumber(0)
@MaxNumber(128)
@Desc("How far inland from the shoreline the chamber is swept. 0 leaves a single chamber at the shore.")
private int depth = 12;
}
@@ -9,14 +9,9 @@ import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("Controls the lip, shore band and eroded valley around a surface river.")
@Desc("Controls the shore band and the eroded valley around a surface river.")
@Data
public class IrisSurfaceRiverBankConfig {
@MinNumber(0)
@MaxNumber(4)
@Desc("Blocks the bank top sits above the water surface at the channel edge.")
private int freeboard = 1;
@MinNumber(0.5)
@MaxNumber(6)
@Desc("Width in blocks of the shore band that receives shore biome content.")
@@ -20,8 +20,8 @@ public class IrisSurfaceRiverChannelConfig {
@MinNumber(0)
@MaxNumber(3)
@Desc("Blocks the water surface sits below the lowest natural bank beside the channel.")
private int inset = 1;
@Desc("Blocks the water surface sinks below the lowest natural ground beside the channel; 0 keeps the water flush with the bank, and the bank always meets the water at its own height.")
private int sink = 0;
@MinNumber(1)
@MaxNumber(32)
@@ -16,10 +16,10 @@ public class IrisSurfaceRiverConfig {
@Desc("Surface-river source allocation.")
private IrisSurfaceRiverSourceConfig sources = new IrisSurfaceRiverSourceConfig();
@Desc("Wet channel width, depth, inset and roughness.")
@Desc("Wet channel width, depth, sink and roughness.")
private IrisSurfaceRiverChannelConfig channel = new IrisSurfaceRiverChannelConfig();
@Desc("Lip, shore band and eroded valley around the channel.")
@Desc("Shore band and eroded valley around the channel.")
private IrisSurfaceRiverBankConfig banks = new IrisSurfaceRiverBankConfig();
@Desc("Material under and beside the water: falling-block replacement and padding.")
private IrisSurfaceRiverBedConfig bed = new IrisSurfaceRiverBedConfig();
@@ -29,4 +29,8 @@ public class IrisSurfaceRiverConfig {
@Desc("Coastal mouth flare and ocean-apron limits.")
private IrisRiverMouthConfig mouths = new IrisRiverMouthConfig();
@Desc("How the ground around the channel is eroded into a valley.")
private IrisSurfaceRiverErosionConfig erosion = new IrisSurfaceRiverErosionConfig();
@Desc("Ponds at the source and at the inland end of every surface river.")
private IrisSurfaceRiverPondsConfig ponds = new IrisSurfaceRiverPondsConfig();
}
@@ -0,0 +1,37 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("Controls how the ground around a surface river is eroded into a valley. The valley's reach comes from banks.blendSlope and the blend widths; this section shapes it.")
@Data
public class IrisSurfaceRiverErosionConfig {
@Desc("Erode the ground beyond the shore band into a valley; false keeps only the wet channel, the shore band and the lip that holds the water.")
private boolean enabled = true;
@MinNumber(0)
@MaxNumber(64)
@Desc("Stations along the course the valley width is averaged over, so it widens and narrows gradually; 0 follows the local cut exactly.")
private int smoothingRadius = 12;
@MinNumber(0)
@MaxNumber(0.95)
@Desc("Share of the channel half-width that stays at full bed depth before the bed rises to the edge; higher is a flatter, broader bed.")
private double thalwegFraction = 0.45D;
@MinNumber(0.25)
@MaxNumber(4)
@Desc("Exponent on the blend from the bank top out to natural terrain; below 1 hollows the valley sides, above 1 keeps them steep near the shore.")
private double blendCurve = 1D;
@MinNumber(0)
@MaxNumber(2)
@Desc("Share of the channel roughness applied to the bed as depth variation; 0 leaves a smooth bed.")
private double bedNoise = 0.5D;
}
@@ -0,0 +1,38 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("A round pond at one end of a surface river, holding the river's water level there.")
@Data
public class IrisSurfaceRiverPondConfig {
@Desc("Dig the pond.")
private boolean enabled = true;
@MinNumber(1)
@MaxNumber(32)
@Desc("Smallest pond radius in blocks; the radius is chosen per river between the minimum and the maximum, and shrinks where the ground falls away around the rim.")
private int minimumRadius = 6;
@MinNumber(1)
@MaxNumber(32)
@Desc("Largest pond radius in blocks.")
private int maximumRadius = 12;
@MinNumber(1)
@MaxNumber(8)
@Desc("Depth of the pond bowl below the water surface at its center.")
private int depth = 3;
public IrisSurfaceRiverPondConfig(int minimumRadius, int maximumRadius, int depth) {
this.minimumRadius = minimumRadius;
this.maximumRadius = maximumRadius;
this.depth = depth;
}
}
@@ -0,0 +1,18 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("Ponds at the two ends of a surface river: the spring it rises from and, for a river that ends inland, the pool it drains into.")
@Data
public class IrisSurfaceRiverPondsConfig {
@Desc("The spring pond every surface river rises from.")
private IrisSurfaceRiverPondConfig source = new IrisSurfaceRiverPondConfig(6, 12, 3);
@Desc("The pond a river that ends inland drains into; rivers that reach the ocean get none.")
private IrisSurfaceRiverPondConfig terminal = new IrisSurfaceRiverPondConfig(4, 7, 3);
}
@@ -38,6 +38,11 @@ public class IrisUndergroundRiverConfig {
@Desc("Distance in blocks over which an ocean-bound underground river levels to sea level before its mouth.")
private int mouthLevelingDistance = 64;
@MinNumber(0)
@MaxNumber(4)
@Desc("Extra underground courses an outlet may accept as tributaries joining its main passage; they are budgeted on top of the source density, and 0 keeps one passage per outlet.")
private int tributaries = 1;
private static IrisStyledRange range(double min, double max, double zoom) {
return new IrisStyledRange(min, max, new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(zoom));
}