Spring pools at headwaters and ring-ordered feature search

- channel.springWidthRatio / springLength open every surface river in a pool that shrinks where the ground falls away
- Banks settings keep structural invariants only; authoring bounds stay in the validator
- Feature searches walk tiles outward and stop early; find and goto report progress per ring
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-01 23:13:47 -04:00
1 parent 896e58e212
commit d1c73025a0
12 files changed
+152 -56

No files matched your search

@@ -365,6 +365,8 @@ final class PackHydrologyValidator {
validateRange(path, channel, "depth", 1D, 64D, 2D, 4D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "inset", 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);
PackJsonFieldChecks.validateOptionalDoubleRange(path, channel, "roughness", 0D, 1D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "roughnessWavelength", 4, 64, errors);
}
@@ -251,23 +251,28 @@ public record HydrologyPlannerSettings(
int cascadeRun,
int waterfallMinimumDrop,
double mouthFlareRatio,
double springWidthRatio,
int springLength,
boolean exposeCutStrata
) {
// Structural invariants only; authoring bounds live in the pack validator.
public Banks {
if (inset < 1 || inset > 16 || freeboard < 1 || freeboard > 8
|| !Double.isFinite(blendSlope) || blendSlope < 1D || blendSlope > 16D
|| minimumBlendWidth < 1 || maximumBlendWidth < minimumBlendWidth || maximumBlendWidth > 128
if (inset < 0 || freeboard < 0
|| !Double.isFinite(blendSlope) || blendSlope <= 0D
|| minimumBlendWidth < 1 || maximumBlendWidth < minimumBlendWidth
|| !Double.isFinite(roughness) || roughness < 0D || roughness > 1D
|| roughnessWavelength < 3 || roughnessWavelength > 128
|| cascadeRun < 1 || cascadeRun > 16
|| waterfallMinimumDrop < 2 || waterfallMinimumDrop > 24
|| !Double.isFinite(mouthFlareRatio) || mouthFlareRatio < 1D || mouthFlareRatio > 4D) {
|| roughnessWavelength < 1
|| cascadeRun < 1
|| waterfallMinimumDrop < 1
|| !Double.isFinite(mouthFlareRatio) || mouthFlareRatio < 1D
|| !Double.isFinite(springWidthRatio) || springWidthRatio < 1D
|| springLength < 1) {
throw new IllegalArgumentException("Surface bank settings are invalid.");
}
}
public static Banks defaults() {
return new Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, true);
return new Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true);
}
}
@@ -57,6 +57,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.IntConsumer;
public final class IrisHydrologyRuntime implements AutoCloseable {
private static final int MAXIMUM_CACHE_TILES = 64;
@@ -286,40 +287,63 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
int x,
int z,
int maximumDistance
) {
return nearestFeature(types, profileKey, x, z, maximumDistance, (int visited) -> { });
}
/**
* Walks tiles outward from the origin, planning each on demand, and stops as soon as no unvisited
* ring can hold a nearer feature. {@code progress} receives the running tile count after every ring.
*/
public Optional<HydrologyFeatureRef> nearestFeature(
Set<HydrologyFeatureType> types,
String profileKey,
int x,
int z,
int maximumDistance,
IntConsumer progress
) {
Objects.requireNonNull(types);
Objects.requireNonNull(progress);
if (types.isEmpty() || maximumDistance < 0) {
return Optional.empty();
}
int tileSize = settings.routing().tileSize();
int publicationRadius = settings.publicationRadius();
int minimumTileX = tileCoordinate((long) x - maximumDistance - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) x + maximumDistance + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) z - maximumDistance - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) z + maximumDistance + publicationRadius, tileSize);
long tileCount = HydrologyFeatureSearchBounds.tileCount(
x, z, maximumDistance, tileSize, publicationRadius);
if (tileCount > MAXIMUM_FEATURE_SEARCH_TILES) {
throw new IllegalArgumentException("Hydrology feature search exceeds the bounded tile limit.");
}
int originTileX = tileCoordinate(x, tileSize);
int originTileZ = tileCoordinate(z, tileSize);
int ringLimit = HydrologyFeatureSearch.ringLimit(maximumDistance, tileSize, publicationRadius);
HydrologyFeatureRef nearest = null;
long nearestDistanceSquared = Long.MAX_VALUE;
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ));
int visited = 0;
for (int ring = 0; ring <= ringLimit; ring++) {
long lowerBound = HydrologyFeatureSearch.lowerBound(ring, tileSize, publicationRadius);
if (nearest != null && lowerBound * lowerBound > nearestDistanceSquared) {
break;
}
for (HydrologyTileKey key : HydrologyFeatureSearch.ring(originTileX, originTileZ, ring)) {
HydrologyTile tile = cache.get(key);
visited++;
HydrologyFeatureRef feature = tile.nearestFeature(
types, profileKey, x, z, maximumDistance).orElse(null);
if (feature != null) {
long deltaX = (long) feature.x() - x;
long deltaZ = (long) feature.z() - z;
long distanceSquared = deltaX * deltaX + deltaZ * deltaZ;
if (nearest == null || distanceSquared < nearestDistanceSquared
|| distanceSquared == nearestDistanceSquared && feature.id() < nearest.id()) {
nearest = feature;
nearestDistanceSquared = distanceSquared;
}
if (feature == null) {
continue;
}
long deltaX = (long) feature.x() - x;
long deltaZ = (long) feature.z() - z;
long distanceSquared = deltaX * deltaX + deltaZ * deltaZ;
if (nearest == null || distanceSquared < nearestDistanceSquared
|| distanceSquared == nearestDistanceSquared && feature.id() < nearest.id()) {
nearest = feature;
nearestDistanceSquared = distanceSquared;
}
}
progress.accept(visited);
}
return Optional.ofNullable(nearest);
}
@@ -677,6 +701,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
flow.getCascadeRun(),
flow.getWaterfallMinimumDrop(),
surface.getMouths().getFlareRatio(),
channel.getSpringWidthRatio(),
channel.getSpringLength(),
banks.isExposeCutStrata()
)
);
@@ -9,7 +9,6 @@ import java.util.Objects;
public final class ChannelProfileBuilder {
private static final int SMOOTHING_RADIUS = 16;
private static final int HEADWATER_TAPER = 48;
private static final int MOUTH_FLARE = 32;
private final HydrologyPlannerSettings.Surface surface;
@@ -65,11 +64,15 @@ public final class ChannelProfileBuilder {
}
double[] smoothWidth = smooth(width);
double[] smoothDepth = smooth(depth);
int taper = Math.min(HEADWATER_TAPER, count / 2);
for (int station = 0; station < taper; station++) {
double progress = SurfaceNoise.smoothStep(station / (double) taper);
smoothWidth[station] = 1D + (smoothWidth[station] - 1D) * progress;
smoothDepth[station] = 1D + (smoothDepth[station] - 1D) * progress;
// The headwater opens as a spring pool: wider and one block deeper, narrowing to the cruise width.
// Ground that falls away across the pool shrinks it so it never demands a cut the valley solver rejects.
int spring = Math.min(surface.banks().springLength(), count / 2);
double springRatio = surface.banks().springWidthRatio();
for (int station = 0; station < spring; station++) {
double remaining = 1D - SurfaceNoise.smoothStep(station / (double) spring);
double localRatio = 1D + (springRatio - 1D) * springRoom(centerline, station, smoothWidth[station] * springRatio / 2D);
smoothWidth[station] *= 1D + (localRatio - 1D) * remaining;
smoothDepth[station] += remaining;
}
if (directOcean) {
int flare = Math.min(MOUTH_FLARE, count / 3);
@@ -87,6 +90,30 @@ public final class ChannelProfileBuilder {
return new ChannelProfile(halfWidth, smoothDepth, bank);
}
/** Fraction of the spring pool the ground allows: 1 on level ground, 0 where the pool ring would sit half the permitted cut lower. */
private double springRoom(SurfaceCenterline centerline, int station, double reach) {
int centerX = centerline.x()[station];
int centerZ = centerline.z()[station];
HydrologyTerrainSample center = sampler.sample(centerX, centerZ);
if (center == null) {
return 1D;
}
double normalX = centerline.normalX(station);
double normalZ = centerline.normalZ(station);
int lowest = center.naturalHeight();
for (double direction = -1D; direction <= 1D; direction += 2D) {
HydrologyTerrainSample side = sampler.sample(
(int) StrictMath.round(centerX + normalX * reach * direction),
(int) StrictMath.round(centerZ + normalZ * reach * direction));
if (side != null) {
lowest = Math.min(lowest, side.naturalHeight());
}
}
double drop = center.naturalHeight() - lowest;
double allowance = Math.max(1D, surface.maximumIncision() / 2D);
return Math.max(0D, Math.min(1D, 1D - drop / allowance));
}
static double[] smooth(double[] values) {
int count = values.length;
double[] smoothed = new double[count];
@@ -38,6 +38,16 @@ public class IrisSurfaceRiverChannelConfig {
@Desc("Wavelength in blocks of the outline and bed variation.")
private int roughnessWavelength = 16;
@MinNumber(1)
@MaxNumber(4)
@Desc("Width of the spring pool at the headwater relative to the channel width; 1 starts the river at its normal width.")
private double springWidthRatio = 2.5D;
@MinNumber(4)
@MaxNumber(96)
@Desc("Blocks over which the spring pool narrows back to the channel width.")
private int springLength = 24;
private static IrisStyledRange range(double min, double max, double zoom) {
IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(zoom);
return new IrisStyledRange(min, max, style);
@@ -288,7 +288,9 @@ public class PackHydrologyValidatorTest {
"inset": 4,
"maximumIncision": 33,
"roughness": 1.5,
"roughnessWavelength": 65
"roughnessWavelength": 65,
"springWidthRatio": 5,
"springLength": 97
}
}
}
@@ -303,7 +305,9 @@ public class PackHydrologyValidatorTest {
"inset": -1,
"maximumIncision": 0,
"roughness": -0.1,
"roughnessWavelength": 3
"roughnessWavelength": 3,
"springWidthRatio": 0.5,
"springLength": 3
}
}
}
@@ -318,6 +322,10 @@ public class PackHydrologyValidatorTest {
assertContains(aboveResult.errors(), "maximumIncision must be at most 32");
assertContains(aboveResult.errors(), "roughness must be at most 1.0");
assertContains(aboveResult.errors(), "roughnessWavelength must be at most 64");
assertContains(aboveResult.errors(), "springWidthRatio must be at most 4.0");
assertContains(aboveResult.errors(), "springLength must be at most 96");
assertContains(belowResult.errors(), "springWidthRatio must be at least 1.0");
assertContains(belowResult.errors(), "springLength must be at least 4");
assertContains(belowResult.errors(), "width.min must be at least 1.0");
assertContains(belowResult.errors(), "depth.min must be at least 1.0");
assertContains(belowResult.errors(), "inset must be at least 0");
@@ -144,20 +144,13 @@ public class HydrologyPlannerSettingsTest {
assertEquals(32, banks.maximumBlendWidth());
assertEquals(6, banks.waterfallMinimumDrop());
assertTrue(banks.exposeCutStrata());
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(
0, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(
1, 0, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(
1, 1, 0.5D, 4, 32, 0.25D, 16, 2, 6, 1.6D, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(
1, 1, 3D, 40, 32, 0.25D, 16, 2, 6, 1.6D, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(
1, 1, 3D, 4, 32, 0.25D, 16, 2, 1, 1.6D, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(
1, 1, 3D, 4, 32, 0.25D, 16, 0, 6, 1.6D, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(
1, 1, 3D, 4, 32, 1.5D, 16, 2, 6, 1.6D, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(-1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, -1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 0D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 40, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 0, 1.6D, 2.5D, 24, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0.25D, 16, 0, 6, 1.6D, 2.5D, 24, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 1.5D, 16, 2, 6, 1.6D, 2.5D, 24, true));
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Surface(
true,
new HydrologyPlannerSettings.Source(true, 1D, 0, 0, 1, 0),
@@ -303,7 +303,7 @@ public class HydrologyTileCacheTest {
// Small-tile settings need a blend envelope that fits inside the bounded cross-tile admission period.
private static HydrologyPlannerSettings.Banks tileBoundedBanks() {
return new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 4, 0.25D, 16, 2, 6, 1.6D, true);
return new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 4, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true);
}
private HydrologyTerrainSample featureTerrain(int x, int z) {
@@ -8,6 +8,7 @@ import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import org.junit.Test;
import java.util.List;
import java.util.function.IntBinaryOperator;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@@ -20,7 +21,7 @@ public class ChannelProfileBuilderTest {
};
@Test
public void cruiseWidthIsConstantAndTapersFromTheHeadwater() {
public void headwaterOpensAsASpringPoolThatNarrowsToTheCruiseWidth() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(1D, 1D).build(centerline, "water", false);
@@ -28,11 +29,14 @@ public class ChannelProfileBuilderTest {
assertEquals(3D, profile.halfWidth()[200], 1.0E-9D);
assertEquals(3D, profile.halfWidth()[399], 1.0E-9D);
assertEquals(3D, profile.depth()[200], 1.0E-9D);
assertTrue(profile.halfWidth()[0] < 1D);
for (int station = 1; station < 48; station++) {
assertTrue(profile.halfWidth()[station] >= profile.halfWidth()[station - 1]);
assertEquals(3D * 2.5D, profile.halfWidth()[0], 1.0E-9D);
assertEquals(4D, profile.depth()[0], 1.0E-9D);
for (int station = 1; station <= 24; station++) {
assertTrue(profile.halfWidth()[station] <= profile.halfWidth()[station - 1]);
assertTrue(profile.depth()[station] <= profile.depth()[station - 1]);
}
assertTrue(profile.depth()[0] >= 1D);
assertEquals(3D, profile.halfWidth()[24], 1.0E-9D);
assertEquals(3D, profile.depth()[24], 1.0E-9D);
}
@Test
@@ -55,9 +59,26 @@ public class ChannelProfileBuilderTest {
assertEquals(6D * 1.25D + 2D, profile.collar(200, 0.25D), 1.0E-9D);
}
@Test
public void groundFallingAwayAcrossThePoolShrinksTheSpringPool() {
SurfaceCenterline centerline = straight(400);
ChannelProfile sloped = builder(1D, 1D, (int x, int z) -> 80 - Math.abs(z) / 2).build(centerline, "water", false);
ChannelProfile cliff = builder(1D, 1D, (int x, int z) -> 80 - Math.abs(z) * 8).build(centerline, "water", false);
assertTrue(sloped.halfWidth()[0] > 3D);
assertTrue(sloped.halfWidth()[0] < 3D * 2.5D);
assertEquals(3D, cliff.halfWidth()[0], 1.0E-9D);
assertEquals(4D, cliff.depth()[0], 1.0E-9D);
assertEquals(3D, sloped.halfWidth()[24], 1.0E-9D);
}
private static ChannelProfileBuilder builder(double widthMultiplier, double depthMultiplier) {
return builder(widthMultiplier, depthMultiplier, (int x, int z) -> 80);
}
private static ChannelProfileBuilder builder(double widthMultiplier, double depthMultiplier, IntBinaryOperator height) {
HydrologyTerrainSampler sampler = (int x, int z) -> new HydrologyTerrainSample(
80,
height.applyAsInt(x, z),
0D,
false,
false,
@@ -173,7 +173,7 @@ public class ErosionFieldCompilerTest {
true,
defaults.sources(),
4, 8, 2, 4, 10, 1.5D,
new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0D, 16, 2, 6, 1.6D, true)
new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0D, 16, 2, 6, 1.6D, 2.5D, 24, true)
);
}