This commit is contained in:
Brian Neumann-Fopiano
2026-08-24 16:50:57 -04:00
parent 37b01b73c4
commit 2797a5bf22
24 changed files with 2208 additions and 809 deletions
@@ -255,19 +255,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public static void msg(String string) {
try {
getSender().sendMessage(string);
} catch (Throwable e) {
try {
Iris plugin = instance;
String tag = plugin == null ? "" : plugin.getTag();
ComponentLog.logMarkup(
plugin,
Logger.getLogger("Iris"),
"[Iris] ",
logPrefix(plugin),
Level.INFO,
tag + string,
string,
null);
} catch (Throwable e) {
try {
Iris plugin = instance;
String plainPrefix = ComponentText.legacy(logPrefix(plugin)).plain();
Logger.getLogger("Iris").log(Level.INFO, plainPrefix + IrisLogging.clean(string));
} catch (Throwable inner) {
System.err.println("[Iris] Failed to emit log message: " + inner.getMessage());
inner.printStackTrace(System.err);
@@ -561,15 +562,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private static void diagnostic(Level level, String message) {
String line = IrisLogging.clean(message);
Iris plugin = instance;
ComponentLog.log(
instance,
plugin,
Logger.getLogger("Iris"),
"[Iris] ",
logPrefix(plugin),
level,
ComponentText.literal(line),
null);
}
private static String logPrefix(Iris plugin) {
return plugin == null ? "[Iris] " : plugin.getTag();
}
/**
* @return false when the bootstrap was aborted (unsupported server version); the caller
* must bail out of onEnable without touching any further setup.
@@ -13,10 +13,8 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
/**
* Core states a severity on every message it logs and the modded adapters honour it. On Bukkit the same
* message used to become a coloured console line, which the server logs at INFO, so no core warning ever
* appeared in a WARN-level scan of logs/latest.log - including the orphaned-world-storage reports the
* bootstrap replays there specifically for operators to find.
* Core states a severity on every message it logs and the modded adapters honour it. Bukkit must preserve
* that severity through the shared component logger so diagnostics remain discoverable in logs/latest.log.
*/
public class IrisDiagnosticLogLevelTest {
@Test
@@ -26,25 +24,16 @@ public class IrisDiagnosticLogLevelTest {
}
@Test
public void informationalAndDebugMessagesStayOnTheConsolePath() {
public void informationalAndDebugMessagesStayOnTheInformationalPath() {
assertNull(Iris.diagnosticLevel(LogLevel.INFO));
assertNull(Iris.diagnosticLevel(LogLevel.DEBUG));
}
/**
* Console sender output reaches the terminal but not the instance's logs/latest.log, which is the only
* log most operators read after the fact. A handful of lifecycle lines go to the plugin logger instead.
*/
@Test
public void lifecycleNoticesReachThePluginLoggerAtInfo() {
assertEquals(Level.INFO, Iris.diagnosticLevel(LogLevel.NOTICE));
}
/**
* A warning raised by the adapter is the same kind of thing as a warning raised by core. Routing one
* through the plugin logger and the other through the console sender makes the level depend on which
* side of the SPI the call happened to be written on.
*/
@Test
public void adapterSideWarningsCarryTheSameSeverityAsCoreWarnings() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
@@ -70,6 +59,16 @@ public class IrisDiagnosticLogLevelTest {
assertTrue(diagnostic, diagnostic.contains("ComponentText.literal(line)"));
}
@Test
public void informationalMessagesUseTheSharedComponentLogger() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
String message = method(source, "public static void msg(String string)");
assertTrue(message, message.contains("ComponentLog.logMarkup("));
assertTrue(message, message.contains("logPrefix(plugin)"));
assertFalse(message, message.contains("getSender().sendMessage"));
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue("method not found: " + signature, start >= 0);
@@ -111,10 +111,10 @@ final class PackRiverValidator {
}
if (topology != null && terrain != null) {
double meanderStrength = doubleValue(terrain, "meanderStrength", 72D);
WormEnvelope wormEnvelope = wormEnvelope(terrain);
int cellSize = integerValue(topology, "cellSize", 512);
if (Double.isFinite(meanderStrength) && meanderStrength > cellSize) {
warnings.add(path + ".terrain.meanderStrength exceeds topology.cellSize; reaches may require large cache halos.");
if (wormEnvelope.maximumOffset() > cellSize) {
warnings.add(path + ".terrain.worms maxOffset exceeds topology.cellSize; reaches may require large cache halos.");
}
validateTopologyComplexity(packFolder, path, topology, terrain, errors);
}
@@ -144,8 +144,6 @@ final class PackRiverValidator {
PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "routingNoiseWeight", 0D, 1024D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "flowAlignmentWeight", 0D, 1024D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "confluenceWeight", 0D, 1024D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "branchSoftCap", 1, 8, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "branchChildShrinkFactor", 0D, 1D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "terrainHeightWeight", 0D, 16D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "terrainSlopeWeight", 0D, 16D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "oceanAttraction", 0D, 16D, errors);
@@ -180,8 +178,6 @@ final class PackRiverValidator {
PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelMouthBlend", 0D, 16D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelFloorVariation", 0D, 8D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelRoofVariation", 0D, 16D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "meanderStrength", 0D, 1024D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "meanderSubdivisions", 1, 64, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "bedRoughness", 0D, 8D, errors);
PackJsonFieldChecks.validateOptionalEnum(path, terrain, "terminalMode", TERMINAL_MODES, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "terminalTaper", 8, 1024, errors);
@@ -189,8 +185,8 @@ final class PackRiverValidator {
validateNoiseChance(packFolder, terrain, "incision", path, errors);
validateStyle(packFolder, terrain, "tunnelFloorStyle", path, errors);
validateStyle(packFolder, terrain, "tunnelRoofStyle", path, errors);
validateStyle(packFolder, terrain, "meanderStyle", path, errors);
validateStyle(packFolder, terrain, "bedRoughnessStyle", path, errors);
validateWorms(path, terrain, errors);
double maximumChannelWidth = doubleValue(terrain, "maxChannelWidth", 10D);
double maximumTunnelWidthMultiplier = styledRangeMaximum(
packFolder,
@@ -240,8 +236,7 @@ final class PackRiverValidator {
int tileCells = integerValue(topology, "tileCells", 4);
double siteJitter = doubleValue(topology, "siteJitter", 0.35D);
int maxRouteReaches = integerValue(topology, "maxRouteReaches", 16);
double meanderStrength = doubleValue(terrain, "meanderStrength", 72D);
int meanderSubdivisions = integerValue(terrain, "meanderSubdivisions", 8);
WormEnvelope wormEnvelope = wormEnvelope(terrain);
double maximumChannelWidth = doubleValue(terrain, "maxChannelWidth", 10D);
double maximumBankWidth = doubleValue(terrain, "maxBankWidth", 4D);
double maximumTunnelWidthMultiplier = styledRangeMaximum(
@@ -256,8 +251,8 @@ final class PackRiverValidator {
|| tileCells < 1 || tileCells > 64
|| !Double.isFinite(siteJitter) || siteJitter < 0D || siteJitter > 0.49D
|| maxRouteReaches < 1 || maxRouteReaches > 256
|| !Double.isFinite(meanderStrength) || meanderStrength < 0D || meanderStrength > 1024D
|| meanderSubdivisions < 1 || meanderSubdivisions > 64
|| wormEnvelope.maximumOffset() < 0D || wormEnvelope.maximumOffset() > 1024D
|| wormEnvelope.maximumSegments() < 1 || wormEnvelope.maximumSegments() > 64
|| !Double.isFinite(maximumChannelWidth) || maximumChannelWidth < 1D || maximumChannelWidth > 2048D
|| !Double.isFinite(maximumBankWidth) || maximumBankWidth < 0D || maximumBankWidth > 2048D
|| !Double.isFinite(maximumTunnelWidthMultiplier)
@@ -275,14 +270,187 @@ final class PackRiverValidator {
siteJitter,
maxRouteReaches,
maximumReachRadius,
meanderStrength,
meanderSubdivisions
wormEnvelope.maximumOffset(),
wormEnvelope.maximumSegments()
);
for (String violation : estimate.violations()) {
errors.add(path + " exceeds the safe derived complexity budget. " + violation);
}
}
private static void validateWorms(String path, JSONObject terrain, List<String> errors) {
Object rawWorms = terrain.opt("worms");
if (!(rawWorms instanceof JSONArray worms)) {
errors.add(path + ".worms must be an array with at least one Perlin-worm profile.");
return;
}
if (worms.length() < 1) {
errors.add(path + ".worms must contain at least one Perlin-worm profile.");
return;
}
if (worms.length() > 16) {
errors.add(path + ".worms must contain at most 16 root profiles.");
}
Set<String> ids = new HashSet<String>();
Set<Long> seeds = new HashSet<Long>();
int profileCount = validateWormArray(path + ".worms", worms, 1, ids, seeds, errors);
if (profileCount > 128) {
errors.add(path + ".worms hierarchy must contain at most 128 profiles.");
}
}
private static int validateWormArray(
String path,
JSONArray worms,
int depth,
Set<String> ids,
Set<Long> seeds,
List<String> errors
) {
if (worms.length() > 16) {
errors.add(path + " must contain at most 16 profiles.");
}
double totalWeight = 0D;
int profileCount = 0;
for (int index = 0; index < worms.length(); index++) {
JSONObject worm = worms.optJSONObject(index);
String wormPath = path + "[" + index + "]";
if (worm == null) {
errors.add(wormPath + " must be an object.");
continue;
}
profileCount++;
validateWormId(wormPath, worm, ids, errors);
long seed = validateWormSeed(wormPath, worm, errors);
if (!seeds.add(seed)) {
errors.add(wormPath + ".seed must be unique inside the worm hierarchy.");
}
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "weight", 0.000001D, 1000000D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "wavelength", 8D, 16384D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "detailWavelength", 8D, 16384D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "tortuosity", 0D, 1D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "detailTortuosity", 0D, 1D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "maxOffset", 0D, 1024D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(wormPath, worm, "segments", 1, 64, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "widthMultiplier", 0.125D, 8D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "bankMultiplier", 0.125D, 8D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "depthMultiplier", 0.125D, 8D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "bodyWavelength", 32D, 16384D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "bodyDetailWavelength", 32D, 16384D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "widthVariation", 0D, 0.875D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "bankVariation", 0D, 0.875D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "depthVariation", 0D, 0.875D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "roofVariation", 0D, 0.875D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(wormPath, worm, "branchCap", 1, 8, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "branchDecay", 0D, 1D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "confluenceMultiplier", 0D, 8D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "childChance", 0D, 1D, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(
wormPath, worm, "branchChildChance", 0D, 1D, errors);
totalWeight += doubleValue(worm, "weight", 1D);
Object rawChildren = worm.opt("children");
if (rawChildren == null || rawChildren == JSONObject.NULL) {
continue;
}
if (!(rawChildren instanceof JSONArray children)) {
errors.add(wormPath + ".children must be an array.");
continue;
}
if (children.length() > 0 && depth >= 4) {
errors.add(wormPath + ".children exceeds the maximum hierarchy depth of 4.");
continue;
}
profileCount += validateWormArray(
wormPath + ".children",
children,
depth + 1,
ids,
seeds,
errors
);
}
if (worms.length() > 0 && (!Double.isFinite(totalWeight) || totalWeight <= 0D)) {
errors.add(path + " total weight must be finite and positive.");
}
return profileCount;
}
private static void validateWormId(String path, JSONObject worm, Set<String> ids, List<String> errors) {
Object rawId = worm.opt("id");
if (!(rawId instanceof String id) || !id.matches("[a-z0-9][a-z0-9_-]{0,63}")) {
errors.add(path + ".id must use 1 to 64 lowercase letters, digits, underscores, or hyphens.");
return;
}
if (!ids.add(id)) {
errors.add(path + ".id must be unique inside the worm hierarchy.");
}
}
private static long validateWormSeed(String path, JSONObject worm, List<String> errors) {
Object rawSeed = worm.opt("seed");
if (rawSeed == null || rawSeed == JSONObject.NULL) {
return 1L;
}
if (!(rawSeed instanceof Number number)
|| !Double.isFinite(number.doubleValue())
|| number.doubleValue() != StrictMath.rint(number.doubleValue())) {
errors.add(path + ".seed must be an integer.");
return 1L;
}
return number.longValue();
}
private static WormEnvelope wormEnvelope(JSONObject terrain) {
JSONArray worms = terrain.optJSONArray("worms");
if (worms == null || worms.length() == 0) {
return new WormEnvelope(320D, 48);
}
double maximumOffset = 0D;
int maximumSegments = 1;
WormEnvelope envelope = wormEnvelope(worms, maximumOffset, maximumSegments);
maximumOffset = envelope.maximumOffset();
maximumSegments = envelope.maximumSegments();
return new WormEnvelope(maximumOffset, maximumSegments);
}
private static WormEnvelope wormEnvelope(JSONArray worms, double maximumOffset, int maximumSegments) {
double resolvedOffset = maximumOffset;
int resolvedSegments = maximumSegments;
for (int index = 0; index < worms.length(); index++) {
JSONObject worm = worms.optJSONObject(index);
if (worm == null) {
continue;
}
resolvedOffset = Math.max(resolvedOffset, doubleValue(worm, "maxOffset", 320D));
resolvedSegments = Math.max(resolvedSegments, integerValue(worm, "segments", 48));
JSONArray children = worm.optJSONArray("children");
if (children != null) {
WormEnvelope childEnvelope = wormEnvelope(children, resolvedOffset, resolvedSegments);
resolvedOffset = Math.max(resolvedOffset, childEnvelope.maximumOffset());
resolvedSegments = Math.max(resolvedSegments, childEnvelope.maximumSegments());
}
}
return new WormEnvelope(resolvedOffset, resolvedSegments);
}
private static void validateCaves(File packFolder, String path, JSONObject caves,
boolean forceGeneratedGrotto,
List<String> errors, List<String> warnings) {
@@ -961,6 +1129,9 @@ final class PackRiverValidator {
return chance == null ? defaultValue : doubleValue(chance, "chance", defaultValue);
}
private record WormEnvelope(double maximumOffset, int maximumSegments) {
}
record Validation(List<String> errors, List<String> warnings) {
Validation {
errors = List.copyOf(errors);
@@ -1,8 +1,11 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.ArrayType;
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 art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@@ -83,18 +86,10 @@ public class IrisRiverTerrain {
@Desc("The maximum vertical roof variation in river tunnels.")
private double tunnelRoofVariation = 3D;
@Desc("Warps the spacing and amplitude of varied local-normal sweeps, hooks, curls, and wandering bends while preserving graph endpoints.")
private IrisGeneratorStyle meanderStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(512D);
@MinNumber(0)
@MaxNumber(1024)
@Desc("The total endpoint-spline and shape-personality displacement envelope in blocks.")
private double meanderStrength = 72D;
@MinNumber(1)
@MaxNumber(64)
@Desc("The number of straight segments used to flatten and resolve each meandering graph reach.")
private int meanderSubdivisions = 8;
@Required
@ArrayType(min = 1, type = IrisRiverWorm.class)
@Desc("Weighted root Perlin-worm families with inherited child styles for trunks and tributaries.")
private KList<IrisRiverWorm> worms = new KList<IrisRiverWorm>();
@Desc("Modulates small river-bed height variation after the connected channel shape is solved.")
private IrisGeneratorStyle bedRoughnessStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(96D);
@@ -92,16 +92,6 @@ public class IrisRiverTopology {
@Desc("The deterministic attraction toward shared downstream nodes. Larger values form stronger tributary trees and confluences.")
private double confluenceWeight = 0D;
@MinNumber(1)
@MaxNumber(8)
@Desc("The number of upstream children a graph node accepts before additional branches begin shrinking probabilistically.")
private int branchSoftCap = 4;
@MinNumber(0)
@MaxNumber(1)
@Desc("The multiplicative survival factor for each child beyond branchSoftCap. Recursive generations remain unbounded by depth.")
private double branchChildShrinkFactor = 0.35D;
@MinNumber(0)
@MaxNumber(16)
@Desc("The contribution of natural terrain height to downstream routing cost.")
@@ -0,0 +1,133 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.ArrayType;
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 art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("One weighted Perlin-worm river shape and its channel proportions.")
@Data
public class IrisRiverWorm {
@Required
@Desc("Unique lowercase identifier for this root or child style.")
private String id = "river";
@Desc("Stable salt for this Perlin field pair.")
private long seed = 1L;
@MinNumber(0.000001)
@MaxNumber(1000000)
@Desc("Relative probability when selecting this root family or one child transition.")
private double weight = 1D;
@MinNumber(8)
@MaxNumber(16384)
@Desc("Primary gradient-Perlin wavelength in blocks.")
private double wavelength = 1024D;
@MinNumber(8)
@MaxNumber(16384)
@Desc("Secondary gradient-Perlin wavelength in blocks.")
private double detailWavelength = 256D;
@MinNumber(0)
@MaxNumber(1)
@Desc("Primary heading deviation as a fraction of 180 degrees.")
private double tortuosity = 0.5D;
@MinNumber(0)
@MaxNumber(1)
@Desc("Secondary heading deviation as a fraction of 180 degrees.")
private double detailTortuosity = 0.15D;
@MinNumber(0)
@MaxNumber(1024)
@Desc("Maximum endpoint-bridged displacement from the reach chord in blocks.")
private double maxOffset = 320D;
@MinNumber(1)
@MaxNumber(64)
@Desc("Number of deterministic Perlin-worm steps used to resolve the reach.")
private int segments = 48;
@MinNumber(0.125)
@MaxNumber(8)
@Desc("Channel-width multiplier for reaches selecting this worm.")
private double widthMultiplier = 1D;
@MinNumber(0.125)
@MaxNumber(8)
@Desc("Bank-width multiplier for reaches selecting this worm.")
private double bankMultiplier = 1D;
@MinNumber(0.125)
@MaxNumber(8)
@Desc("Depth multiplier for reaches selecting this worm.")
private double depthMultiplier = 1D;
@MinNumber(32)
@MaxNumber(16384)
@Desc("Primary world-space wavelength controlling longitudinal body swelling and pinching.")
private double bodyWavelength = 512D;
@MinNumber(32)
@MaxNumber(16384)
@Desc("Detail wavelength adding smaller changes to the longitudinal body profile.")
private double bodyDetailWavelength = 128D;
@MinNumber(0)
@MaxNumber(0.875)
@Desc("Maximum proportional channel-width variation along this style's body.")
private double widthVariation = 0D;
@MinNumber(0)
@MaxNumber(0.875)
@Desc("Maximum proportional bank or basin-width variation along this style's body.")
private double bankVariation = 0D;
@MinNumber(0)
@MaxNumber(0.875)
@Desc("Maximum proportional bed-depth variation along this style's body.")
private double depthVariation = 0D;
@MinNumber(0)
@MaxNumber(0.875)
@Desc("Maximum downward variation of tunnel roof clearance without exceeding the authored cave headroom.")
private double roofVariation = 0D;
@MinNumber(1)
@MaxNumber(8)
@Desc("Number of upstream children admitted before additional siblings decay probabilistically.")
private int branchCap = 4;
@MinNumber(0)
@MaxNumber(1)
@Desc("Multiplicative survival probability for every sibling beyond branchCap.")
private double branchDecay = 0.35D;
@MinNumber(0)
@MaxNumber(8)
@Desc("Multiplier applied to the dimension confluence attraction for this style.")
private double confluenceMultiplier = 1D;
@MinNumber(0)
@MaxNumber(1)
@Desc("Chance that an upstream continuation mutates from this style into one weighted child.")
private double childChance = 0D;
@MinNumber(0)
@MaxNumber(1)
@Desc("Additional child-mutation chance for each sibling slot beyond the primary branch.")
private double branchChildChance = 0D;
@ArrayType(type = IrisRiverWorm.class)
@Desc("Weighted descendant styles inherited by the complete upstream lineage after mutation.")
private KList<IrisRiverWorm> children = new KList<IrisRiverWorm>();
}
@@ -0,0 +1,182 @@
package art.arcane.iris.engine.river;
import java.util.Arrays;
public final class RiverBodyProfile {
private final double[] positions;
private final double[] widths;
private final double[] bankWidths;
private final double[] depths;
private final double[] roofScales;
private final double maximumWidth;
private final double maximumBankWidth;
private final double maximumDepth;
public RiverBodyProfile(
double[] positions,
double[] widths,
double[] bankWidths,
double[] depths,
double[] roofScales
) {
if (positions == null || widths == null || bankWidths == null || depths == null || roofScales == null
|| positions.length < 2
|| positions.length != widths.length
|| positions.length != bankWidths.length
|| positions.length != depths.length
|| positions.length != roofScales.length) {
throw new IllegalArgumentException("River body profiles require matching dimension samples");
}
this.positions = positions.clone();
this.widths = widths.clone();
this.bankWidths = bankWidths.clone();
this.depths = depths.clone();
this.roofScales = roofScales.clone();
double resolvedMaximumWidth = 0D;
double resolvedMaximumBankWidth = 0D;
double resolvedMaximumDepth = 0D;
for (int index = 0; index < this.positions.length; index++) {
double position = this.positions[index];
if (!Double.isFinite(position) || position < 0D || position > 1D
|| index > 0 && position <= this.positions[index - 1]) {
throw new IllegalArgumentException("River body profile positions must increase from zero to one");
}
requirePositive(this.widths[index], "width");
requireNonNegative(this.bankWidths[index], "bank width");
requirePositive(this.depths[index], "depth");
requireUnitScale(this.roofScales[index], "roof scale");
resolvedMaximumWidth = StrictMath.max(resolvedMaximumWidth, this.widths[index]);
resolvedMaximumBankWidth = StrictMath.max(resolvedMaximumBankWidth, this.bankWidths[index]);
resolvedMaximumDepth = StrictMath.max(resolvedMaximumDepth, this.depths[index]);
}
if (this.positions[0] != 0D || this.positions[this.positions.length - 1] != 1D) {
throw new IllegalArgumentException("River body profile positions must include zero and one");
}
maximumWidth = resolvedMaximumWidth;
maximumBankWidth = resolvedMaximumBankWidth;
maximumDepth = resolvedMaximumDepth;
}
public static RiverBodyProfile constant(double width, double bankWidth, double depth) {
return new RiverBodyProfile(
new double[]{0D, 1D},
new double[]{width, width},
new double[]{bankWidth, bankWidth},
new double[]{depth, depth},
new double[]{1D, 1D}
);
}
public double width(double alongReach) {
return sample(widths, alongReach);
}
public double bankWidth(double alongReach) {
return sample(bankWidths, alongReach);
}
public double depth(double alongReach) {
return sample(depths, alongReach);
}
public double roofScale(double alongReach) {
return sample(roofScales, alongReach);
}
public double maximumWidth() {
return maximumWidth;
}
public double maximumBankWidth() {
return maximumBankWidth;
}
public double maximumDepth() {
return maximumDepth;
}
public int size() {
return positions.length;
}
public double position(int index) {
return positions[index];
}
public double widthAtIndex(int index) {
return widths[index];
}
public double bankWidthAtIndex(int index) {
return bankWidths[index];
}
public double depthAtIndex(int index) {
return depths[index];
}
public double roofScaleAtIndex(int index) {
return roofScales[index];
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof RiverBodyProfile profile)) {
return false;
}
return Arrays.equals(positions, profile.positions)
&& Arrays.equals(widths, profile.widths)
&& Arrays.equals(bankWidths, profile.bankWidths)
&& Arrays.equals(depths, profile.depths)
&& Arrays.equals(roofScales, profile.roofScales);
}
@Override
public int hashCode() {
int hash = Arrays.hashCode(positions);
hash = 31 * hash + Arrays.hashCode(widths);
hash = 31 * hash + Arrays.hashCode(bankWidths);
hash = 31 * hash + Arrays.hashCode(depths);
return 31 * hash + Arrays.hashCode(roofScales);
}
private double sample(double[] values, double alongReach) {
double position = StrictMath.max(0D, StrictMath.min(1D, alongReach));
int index = Arrays.binarySearch(positions, position);
if (index >= 0) {
return values[index];
}
int upper = -index - 1;
if (upper <= 0) {
return values[0];
}
if (upper >= positions.length) {
return values[values.length - 1];
}
int lower = upper - 1;
double range = positions[upper] - positions[lower];
double interpolation = range <= 0D ? 0D : (position - positions[lower]) / range;
return values[lower] + (values[upper] - values[lower]) * interpolation;
}
private static void requirePositive(double value, String name) {
if (!Double.isFinite(value) || value <= 0D) {
throw new IllegalArgumentException("River body profile " + name + " must be finite and positive");
}
}
private static void requireNonNegative(double value, String name) {
if (!Double.isFinite(value) || value < 0D) {
throw new IllegalArgumentException("River body profile " + name + " must be finite and non-negative");
}
}
private static void requireUnitScale(double value, String name) {
if (!Double.isFinite(value) || value <= 0D || value > 1D) {
throw new IllegalArgumentException("River body profile " + name + " must be greater than zero and at most one");
}
}
}
@@ -1,18 +0,0 @@
package art.arcane.iris.engine.river;
import java.util.Objects;
public record RiverMeanderContext(
RiverEdgeId reachId,
double normalizedPosition,
double x,
double z
) {
public RiverMeanderContext {
Objects.requireNonNull(reachId);
if (!Double.isFinite(normalizedPosition) || normalizedPosition < 0.0 || normalizedPosition > 1.0
|| !Double.isFinite(x) || !Double.isFinite(z)) {
throw new IllegalArgumentException("River meander context must be finite and normalized");
}
}
}
@@ -21,21 +21,25 @@ public final class RiverNetwork {
private static final long SOURCE_FLOOR_SALT = 0xD6E8FEB86659FD93L;
private static final long REACH_SALT = 0x9B05688C2B3E6C1FL;
private static final long DRY_SALT = 0x1F83D9ABFB41BD6BL;
private static final long MEANDER_SALT = 0x5BE0CD19137E2179L;
private static final long MEANDER_PERSONALITY_SALT = 0x243F6A8885A308D3L;
private static final long MEANDER_AMPLITUDE_SALT = 0x13198A2E03707344L;
private static final long MEANDER_PHASE_SALT = 0xD1310BA698DFB5ACL;
private static final long MEANDER_SKEW_SALT = 0x2FFD72DBD01ADFB7L;
private static final long MEANDER_CYCLE_SALT = 0x452821E638D01377L;
private static final long MEANDER_FEATURE_A_SALT = 0xBE5466CF34E90C6CL;
private static final long MEANDER_FEATURE_B_SALT = 0xC0AC29B7C97C50DDL;
private static final long WORM_FAMILY_SALT = 0x5BE0CD19137E2179L;
private static final long WORM_CHILD_GATE_SALT = 0x452821E638D01377L;
private static final long WORM_CHILD_SELECTION_SALT = 0xBE5466CF34E90C6CL;
private static final long WORM_PRIMARY_SALT = 0x243F6A8885A308D3L;
private static final long WORM_DETAIL_SALT = 0x13198A2E03707344L;
private static final long BODY_WIDTH_PRIMARY_SALT = 0xA4093822299F31D0L;
private static final long BODY_WIDTH_DETAIL_SALT = 0x082EFA98EC4E6C89L;
private static final long BODY_BANK_PRIMARY_SALT = 0x452821E638D01377L;
private static final long BODY_BANK_DETAIL_SALT = 0xBE5466CF34E90C6CL;
private static final long BODY_DEPTH_PRIMARY_SALT = 0xC0AC29B7C97C50DDL;
private static final long BODY_DEPTH_DETAIL_SALT = 0x3F84D5B5B5470917L;
private static final long BODY_ROOF_PRIMARY_SALT = 0xD1310BA698DFB5ACL;
private static final long BODY_ROOF_DETAIL_SALT = 0x2FFD72DBD01ADFB7L;
private static final long CONFLUENCE_SALT = 0x9E3779B97F4A7C15L;
private static final long BRANCH_SLOT_SALT = 0x94D049BB133111EBL;
private static final long BRANCH_GATE_SALT = 0x2545F4914F6CDD1DL;
private static final int MEANDER_NOISE_SAMPLES = 12;
private static final int WIDTH_PROFILE_SAMPLES = 12;
private static final double TWO_PI = StrictMath.PI * 2D;
private static final MeanderPersonality[] MEANDER_PERSONALITIES = MeanderPersonality.values();
private static final int MINIMUM_BODY_PROFILE_SAMPLES = 12;
private static final int MAXIMUM_BODY_PROFILE_SAMPLES = 32;
private static final double PERLIN_NORMALIZATION = 1.4142135623730951D;
private final RiverNetworkOptions options;
@@ -208,7 +212,7 @@ public final class RiverNetwork {
double routingScore = naturalHeight * options.terrainHeightWeight()
+ routingNoise * options.routingNoiseWeight()
+ finiteOrZero(terrainSample.routingCost());
double drainageDistance = drainageDistance(id);
double drainageDistance = drainageBasin(id).distance();
double hydraulicHeight = ocean
? options.hydraulicBaseHeight()
: options.hydraulicBaseHeight()
@@ -228,6 +232,10 @@ public final class RiverNetwork {
}
private double drainageDistance(RiverNodeId id) {
return drainageBasin(id).distance();
}
private DrainageBasin drainageBasin(RiverNodeId id) {
int basinCells = options.routingBasinCells();
double nodeX = id.cellX() + 0.5D;
double nodeZ = id.cellZ() + 0.5D;
@@ -245,19 +253,21 @@ public final class RiverNetwork {
long basinZ = (long) StrictMath.floor(nodeZ / basinCells);
double jitterRadius = basinCells * 0.45D;
double nearestDistance = Double.MAX_VALUE;
DrainageBasinId nearestId = null;
for (long candidateX = basinX - 1L; candidateX <= basinX + 1L; candidateX++) {
for (long candidateZ = basinZ - 1L; candidateZ <= basinZ + 1L; candidateZ++) {
double siteX = (candidateX + 0.5D) * basinCells
+ centered(hash(candidateX, candidateZ, BASIN_X_SALT)) * jitterRadius;
double siteZ = (candidateZ + 0.5D) * basinCells
+ centered(hash(candidateX, candidateZ, BASIN_Z_SALT)) * jitterRadius;
nearestDistance = StrictMath.min(
nearestDistance,
StrictMath.hypot(nodeX - siteX, nodeZ - siteZ)
);
double distance = StrictMath.hypot(nodeX - siteX, nodeZ - siteZ);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestId = new DrainageBasinId(candidateX, candidateZ);
}
}
return nearestDistance;
}
return new DrainageBasin(nearestId, nearestDistance);
}
private double smoothCellNoise(double x, double z, int scale, long salt) {
@@ -305,7 +315,8 @@ public final class RiverNetwork {
if (compareRank(neighbor, node) >= 0) {
continue;
}
if (!branchPermitted(node, neighbor, resolver)) {
RiverWorm worm = resolver.worm(node, neighbor);
if (!branchPermitted(node, neighbor, resolver, worm)) {
continue;
}
RiverRoutingContext context = resolver.routingContext(node, neighbor);
@@ -313,7 +324,8 @@ public final class RiverNetwork {
double oceanAttraction = neighbor.ocean() ? options.oceanAttraction() : 0.0;
double flowAlignmentCost = flowAlignmentCost(node, neighbor, resolver);
double confluenceAttraction = unit(hash(neighbor.id(), CONFLUENCE_SALT))
* options.confluenceWeight();
* options.confluenceWeight()
* worm.confluenceMultiplier();
ranked.add(new RankedCandidate(
neighbor,
neighbor.routingScore() + routingCost + flowAlignmentCost
@@ -331,15 +343,20 @@ public final class RiverNetwork {
return List.copyOf(candidates);
}
private boolean branchPermitted(RiverNode child, RiverNode parent, NodeResolver resolver) {
private boolean branchPermitted(
RiverNode child,
RiverNode parent,
NodeResolver resolver,
RiverWorm worm
) {
RiverEdgeId childEdge = RiverEdgeId.of(child.id(), parent.id());
int childSlot = resolver.branchSlot(parent, child);
if (childSlot < options.branchSoftCap()) {
if (childSlot < worm.branchCap()) {
return true;
}
double survivalChance = 1D;
for (int overflow = options.branchSoftCap(); overflow <= childSlot; overflow++) {
survivalChance *= options.branchChildShrinkFactor();
for (int overflow = worm.branchCap(); overflow <= childSlot; overflow++) {
survivalChance *= worm.branchDecay();
}
return gate(hash(childEdge, BRANCH_GATE_SALT), survivalChance);
}
@@ -427,6 +444,7 @@ public final class RiverNetwork {
from,
to,
resolver.routingContext(from, to),
resolver.worm(from, to),
resolver.terrain
);
accumulators.put(edgeId, accumulator);
@@ -451,61 +469,65 @@ public final class RiverNetwork {
}
private RiverPolyline createPolyline(
RiverEdgeId id,
RiverNode from,
RiverNode to,
NodeResolver resolver
RiverWorm worm
) {
int pointCount = options.meanderSubdivisions() + 1;
double[] baseX = new double[pointCount];
double[] baseZ = new double[pointCount];
int pointCount = worm.segments() + 1;
double[] rawX = new double[pointCount];
double[] rawZ = new double[pointCount];
double deltaX = to.x() - from.x();
double deltaZ = to.z() - from.z();
double length = StrictMath.hypot(deltaX, deltaZ);
double maximumOffset = StrictMath.min(options.meanderStrength(), length * 0.35);
double directionX = length == 0D ? 1D : deltaX / length;
double directionZ = length == 0D ? 0D : deltaZ / length;
FlowTangent fromTangent = flowTangent(from, directionX, directionZ, resolver);
FlowTangent toTangent = flowTangent(to, directionX, directionZ, resolver);
for (int point = 0; point < pointCount; point++) {
double t = (double) point / (pointCount - 1);
double tSquared = t * t;
double tCubed = tSquared * t;
double fromWeight = 2D * tCubed - 3D * tSquared + 1D;
double fromTangentWeight = tCubed - 2D * tSquared + t;
double toWeight = -2D * tCubed + 3D * tSquared;
double toTangentWeight = tCubed - tSquared;
double curvedX = fromWeight * from.x()
+ fromTangentWeight * fromTangent.x() * maximumOffset
+ toWeight * to.x()
+ toTangentWeight * toTangent.x() * maximumOffset;
double curvedZ = fromWeight * from.z()
+ fromTangentWeight * fromTangent.z() * maximumOffset
+ toWeight * to.z()
+ toTangentWeight * toTangent.z() * maximumOffset;
baseX[point] = curvedX;
baseZ[point] = curvedZ;
rawX[0] = from.x();
rawZ[0] = from.z();
if (length <= 0D) {
return new RiverPolyline(rawX, rawZ);
}
if (maximumOffset <= 0D) {
return new RiverPolyline(baseX, baseZ);
double baseHeading = StrictMath.atan2(deltaZ, deltaX);
double stepLength = length / worm.segments();
for (int point = 1; point < pointCount; point++) {
double x = rawX[point - 1];
double z = rawZ[point - 1];
double primary = perlin(x, z, worm.wavelength(), worm.seed() ^ WORM_PRIMARY_SALT);
double detail = perlin(x, z, worm.detailWavelength(), worm.seed() ^ WORM_DETAIL_SALT);
double heading = baseHeading + StrictMath.PI * (
primary * worm.tortuosity()
+ detail * worm.detailTortuosity()
);
rawX[point] = x + StrictMath.cos(heading) * stepLength;
rawZ[point] = z + StrictMath.sin(heading) * stepLength;
}
MeanderProfile profile = meanderProfile(id);
double[] meanderNoise = meanderNoise(id, baseX, baseZ, resolver);
double[] meanderSignal = meanderSignal(id, profile, meanderNoise);
double[] x = new double[pointCount];
double[] z = new double[pointCount];
double rawDeltaX = rawX[pointCount - 1] - from.x();
double rawDeltaZ = rawZ[pointCount - 1] - from.z();
double maximumDisplacement = 0D;
for (int point = 0; point < pointCount; point++) {
double t = (double) point / (pointCount - 1);
double tSquared = t * t;
double envelope = 16D * tSquared * (1D - t) * (1D - t);
double straightX = from.x() + deltaX * t;
double straightZ = from.z() + deltaZ * t;
double baseDisplacement = StrictMath.hypot(baseX[point] - straightX, baseZ[point] - straightZ);
double availableOffset = StrictMath.max(0D, maximumOffset - baseDisplacement);
double offset = availableOffset * 0.92D * envelope * meanderSignal[point];
FlowTangent normal = localNormal(baseX, baseZ, point);
x[point] = baseX[point] + normal.x() * offset;
z[point] = baseZ[point] + normal.z() * offset;
double rawBridgeX = rawX[point] - (from.x() + rawDeltaX * t);
double rawBridgeZ = rawZ[point] - (from.z() + rawDeltaZ * t);
x[point] = straightX + rawBridgeX * envelope;
z[point] = straightZ + rawBridgeZ * envelope;
maximumDisplacement = StrictMath.max(
maximumDisplacement,
StrictMath.hypot(x[point] - straightX, z[point] - straightZ)
);
}
double maximumOffset = StrictMath.min(worm.maxOffset(), length * 0.35D);
if (maximumDisplacement > maximumOffset && maximumDisplacement > 0D) {
double scale = maximumOffset / maximumDisplacement;
for (int point = 1; point < pointCount - 1; point++) {
double t = (double) point / (pointCount - 1);
double straightX = from.x() + deltaX * t;
double straightZ = from.z() + deltaZ * t;
x[point] = straightX + (x[point] - straightX) * scale;
z[point] = straightZ + (z[point] - straightZ) * scale;
}
}
x[0] = from.x();
z[0] = from.z();
@@ -514,222 +536,141 @@ public final class RiverNetwork {
return new RiverPolyline(x, z);
}
private double[] meanderNoise(
RiverEdgeId id,
double[] baseX,
double[] baseZ,
NodeResolver resolver
private RiverWorm rootWormFor(RiverNodeId rootId) {
return selectWeighted(options.worms(), hash(rootId, WORM_FAMILY_SALT));
}
private RiverWorm childWormFor(
RiverWorm parent,
RiverNodeId parentId,
RiverNodeId childId,
int branchSlot
) {
int sampleCount = StrictMath.min(MEANDER_NOISE_SAMPLES, baseX.length);
double[] knots = new double[sampleCount];
for (int sample = 0; sample < sampleCount; sample++) {
double t = (double) sample / (sampleCount - 1);
double position = t * (baseX.length - 1);
int lower = (int) StrictMath.floor(position);
int upper = StrictMath.min(baseX.length - 1, lower + 1);
double interpolation = position - lower;
double curvedX = baseX[lower] + (baseX[upper] - baseX[lower]) * interpolation;
double curvedZ = baseZ[lower] + (baseZ[upper] - baseZ[lower]) * interpolation;
RiverMeanderContext context = new RiverMeanderContext(id, t, curvedX, curvedZ);
double configuredNoise = resolver.terrain.meanderNoise(context);
knots[sample] = Double.isFinite(configuredNoise)
? StrictMath.max(-1D, StrictMath.min(1D, configuredNoise))
: smoothEdgeNoise(id, t * 3D);
if (parent.children().isEmpty()) {
return parent;
}
double[] noise = new double[baseX.length];
for (int point = 0; point < noise.length; point++) {
double t = (double) point / (noise.length - 1);
double position = t * (sampleCount - 1);
int lower = (int) StrictMath.floor(position);
int upper = StrictMath.min(sampleCount - 1, lower + 1);
double interpolation = smoothStep(position - lower);
noise[point] = knots[lower] + (knots[upper] - knots[lower]) * interpolation;
double chance = StrictMath.min(
1D,
parent.childChance()
+ StrictMath.min(7, branchSlot) * parent.branchChildChance()
);
RiverEdgeId edgeId = RiverEdgeId.of(childId, parentId);
if (!gate(hash(edgeId, WORM_CHILD_GATE_SALT), chance)) {
return parent;
}
return noise;
return selectWeighted(parent.children(), hash(edgeId, WORM_CHILD_SELECTION_SALT));
}
private MeanderProfile meanderProfile(RiverEdgeId id) {
int personalityIndex = (int) StrictMath.floor(
unit(hash(id, MEANDER_PERSONALITY_SALT)) * MEANDER_PERSONALITIES.length
private RiverWorm selectWeighted(List<RiverWorm> worms, long selectionHash) {
double totalWeight = 0D;
for (RiverWorm worm : worms) {
totalWeight += worm.weight();
}
double selection = unit(selectionHash) * totalWeight;
double cumulative = 0D;
for (RiverWorm worm : worms) {
cumulative += worm.weight();
if (selection < cumulative) {
return worm;
}
}
return worms.get(worms.size() - 1);
}
private double perlin(double x, double z, double wavelength, long salt) {
double scaledX = x / wavelength;
double scaledZ = z / wavelength;
long minimumX = (long) StrictMath.floor(scaledX);
long minimumZ = (long) StrictMath.floor(scaledZ);
double fractionX = scaledX - minimumX;
double fractionZ = scaledZ - minimumZ;
double fadeX = perlinFade(fractionX);
double fadeZ = perlinFade(fractionZ);
double northwest = perlinGradient(minimumX, minimumZ, fractionX, fractionZ, salt);
double northeast = perlinGradient(minimumX + 1L, minimumZ, fractionX - 1D, fractionZ, salt);
double southwest = perlinGradient(minimumX, minimumZ + 1L, fractionX, fractionZ - 1D, salt);
double southeast = perlinGradient(
minimumX + 1L,
minimumZ + 1L,
fractionX - 1D,
fractionZ - 1D,
salt
);
MeanderPersonality personality = MEANDER_PERSONALITIES[
StrictMath.min(MEANDER_PERSONALITIES.length - 1, personalityIndex)
];
double amplitudeRoll = unit(hash(id, MEANDER_AMPLITUDE_SALT));
double amplitude = personality == MeanderPersonality.QUIET
? 0.16D + amplitudeRoll * 0.24D
: 0.48D + amplitudeRoll * 0.52D;
double maximumCycles = StrictMath.max(1D, StrictMath.min(8D, options.meanderSubdivisions() / 5D));
double cycleRoll = unit(hash(id, MEANDER_CYCLE_SALT));
double cycles = switch (personality) {
case SWEEP, HOOK, QUIET -> 0.45D + cycleRoll * 0.65D;
case S_CURVE -> 0.75D + cycleRoll * StrictMath.min(1.25D, maximumCycles);
case OXBOW -> 1D + cycleRoll * StrictMath.min(1.5D, maximumCycles);
case COIL -> StrictMath.min(maximumCycles, 2.25D + cycleRoll * 4.75D);
case WANDER -> 0.65D + cycleRoll * StrictMath.min(2.35D, maximumCycles);
case CHIRP -> StrictMath.min(maximumCycles, 1.5D + cycleRoll * 5.5D);
};
double handedness = (hash(id, MEANDER_SALT) & 1L) == 0L ? -1D : 1D;
return new MeanderProfile(
personality,
amplitude,
handedness,
unit(hash(id, MEANDER_PHASE_SALT)) * TWO_PI,
centered(hash(id, MEANDER_SKEW_SALT)) * 0.72D,
cycles,
unit(hash(id, MEANDER_FEATURE_A_SALT)),
unit(hash(id, MEANDER_FEATURE_B_SALT))
double north = northwest + (northeast - northwest) * fadeX;
double south = southwest + (southeast - southwest) * fadeX;
return StrictMath.max(
-1D,
StrictMath.min(1D, (north + (south - north) * fadeZ) * PERLIN_NORMALIZATION)
);
}
private double[] meanderSignal(RiverEdgeId id, MeanderProfile profile, double[] noise) {
int subdivisions = noise.length - 1;
for (int point = 0; point < noise.length; point++) {
double t = (double) point / subdivisions;
noise[point] = noise[point] * 0.78D + smoothEdgeNoise(id, t * 3.5D) * 0.22D;
}
smoothSignal(noise);
double detailCycles = StrictMath.max(0.5D, profile.cycles() + profile.featureB() * 1.5D);
double baseStep = TWO_PI * detailCycles / subdivisions;
double phase = profile.phase() + profile.featureA() * StrictMath.PI;
double[] signal = new double[noise.length];
for (int point = 0; point < noise.length; point++) {
double t = (double) point / subdivisions;
if (point > 0) {
double frequencyNoise = (noise[point - 1] + noise[point]) * 0.25D + 0.5D;
phase += baseStep * (0.45D + frequencyNoise * 1.1D);
}
double warpedT = warpMeanderPosition(t, profile.skew());
double macro = personalitySignal(profile, warpedT, noise[point]);
double detailWeight = detailWeight(profile.personality());
double detail = detailWeight <= 0D
? 0D
: StrictMath.sin(phase) * (0.65D + StrictMath.abs(noise[point]) * 0.35D);
double noiseWeight = noiseWeight(profile.personality());
double macroWeight = 1D - detailWeight - noiseWeight;
signal[point] = clampSigned(profile.amplitude()
* (macro * macroWeight + detail * detailWeight + noise[point] * noiseWeight));
}
smoothSignal(signal);
return signal;
}
private double personalitySignal(MeanderProfile profile, double t, double noise) {
double handedness = profile.handedness();
return switch (profile.personality()) {
case SWEEP -> handedness * (0.68D + StrictMath.sin(StrictMath.PI * t) * 0.32D);
case HOOK -> handedness * (0.12D + smoothStep(
profile.featureA() < 0.5D ? t : 1D - t
) * 0.88D);
case S_CURVE -> handedness * StrictMath.sin(
TWO_PI * profile.cycles() * t + profile.phase()
);
case COIL -> StrictMath.sin(TWO_PI * profile.cycles() * t + profile.phase())
* (0.62D + StrictMath.sin(StrictMath.PI * t) * 0.38D);
case WANDER -> clampSigned(
noise * 0.82D
+ StrictMath.sin(TWO_PI * profile.cycles() * t + profile.phase()) * 0.38D
+ handedness * 0.12D
);
case OXBOW -> handedness * clampSigned(
compactLobe(t, 0.18D + profile.featureA() * 0.2D, 0.24D + profile.featureB() * 0.12D)
- compactLobe(t, 0.62D + profile.featureB() * 0.2D, 0.22D + profile.featureA() * 0.14D)
* (0.55D + profile.featureA() * 0.45D)
);
case CHIRP -> StrictMath.sin(
profile.phase() + TWO_PI * (0.45D * t + profile.cycles() * t * t)
);
case QUIET -> handedness * (0.72D + noise * 0.28D);
private double perlinGradient(long latticeX, long latticeZ, double x, double z, long salt) {
return switch ((int) (hash(latticeX, latticeZ, salt) & 7L)) {
case 0 -> x;
case 1 -> -x;
case 2 -> z;
case 3 -> -z;
case 4 -> (x + z) / PERLIN_NORMALIZATION;
case 5 -> (-x + z) / PERLIN_NORMALIZATION;
case 6 -> (x - z) / PERLIN_NORMALIZATION;
default -> (-x - z) / PERLIN_NORMALIZATION;
};
}
private double detailWeight(MeanderPersonality personality) {
return switch (personality) {
case SWEEP, HOOK, OXBOW, QUIET -> 0D;
case S_CURVE -> 0.12D;
case COIL, WANDER -> 0.08D;
case CHIRP -> 0.1D;
};
private double perlinFade(double value) {
double squared = value * value;
double cubed = squared * value;
return cubed * (value * (value * 6D - 15D) + 10D);
}
private double noiseWeight(MeanderPersonality personality) {
return switch (personality) {
case SWEEP, S_CURVE, COIL, OXBOW, CHIRP -> 0.08D;
case HOOK -> 0.1D;
case WANDER -> 0.34D;
case QUIET -> 0.14D;
};
}
private double warpMeanderPosition(double t, double skew) {
return t + skew * t * (1D - t);
}
private double compactLobe(double t, double center, double radius) {
double distance = StrictMath.abs(t - center) / radius;
if (distance >= 1D) {
return 0D;
}
return smoothStep(1D - distance);
}
private double smoothStep(double value) {
double clamped = StrictMath.max(0D, StrictMath.min(1D, value));
return clamped * clamped * (3D - 2D * clamped);
}
private void smoothSignal(double[] signal) {
if (signal.length < 3) {
return;
}
double previousRaw = signal[0];
double currentRaw = signal[1];
for (int point = 1; point < signal.length - 1; point++) {
double nextRaw = signal[point + 1];
signal[point] = (previousRaw + currentRaw * 2D + nextRaw) * 0.25D;
previousRaw = currentRaw;
currentRaw = nextRaw;
}
}
private double clampSigned(double value) {
return StrictMath.max(-1D, StrictMath.min(1D, value));
}
private FlowTangent localNormal(double[] x, double[] z, int point) {
int previous = StrictMath.max(0, point - 1);
int next = StrictMath.min(x.length - 1, point + 1);
double tangentX = x[next] - x[previous];
double tangentZ = z[next] - z[previous];
double tangentLength = StrictMath.hypot(tangentX, tangentZ);
if (tangentLength <= 0.0000001D) {
return new FlowTangent(0D, 0D);
}
return new FlowTangent(-tangentZ / tangentLength, tangentX / tangentLength);
}
private FlowTangent flowTangent(
RiverNode node,
double fallbackX,
double fallbackZ,
NodeResolver resolver
private double bodyMultiplier(
ReachPosition position,
RiverWorm worm,
long primarySalt,
long detailSalt,
double variation
) {
FlowTangent preferred = resolver.flowTangent(node);
double tangentX = preferred.x();
double tangentZ = preferred.z();
if (tangentX == 0D && tangentZ == 0D) {
return new FlowTangent(fallbackX, fallbackZ);
if (variation <= 0D) {
return 1D;
}
double alignment = tangentX * fallbackX + tangentZ * fallbackZ;
if (alignment < 0D) {
tangentX = -tangentX;
tangentZ = -tangentZ;
alignment = -alignment;
return StrictMath.max(
0.125D,
1D + bodyField(position, worm, primarySalt, detailSalt) * variation
);
}
if (alignment < 0.15D) {
return new FlowTangent(fallbackX, fallbackZ);
private double roofScale(ReachPosition position, RiverWorm worm) {
if (worm.roofVariation() <= 0D) {
return 1D;
}
return new FlowTangent(tangentX, tangentZ);
double normalized = bodyField(
position,
worm,
BODY_ROOF_PRIMARY_SALT,
BODY_ROOF_DETAIL_SALT
) * 0.5D + 0.5D;
return StrictMath.max(0.125D, 1D - normalized * worm.roofVariation());
}
private double bodyField(
ReachPosition position,
RiverWorm worm,
long primarySalt,
long detailSalt
) {
double primary = perlin(
position.x(),
position.z(),
worm.bodyWavelength(),
worm.seed() ^ primarySalt
);
double detail = perlin(
position.x(),
position.z(),
worm.bodyDetailWavelength(),
worm.seed() ^ detailSalt
);
return primary * 0.7D + detail * 0.3D;
}
private FlowTangent resolveFlowTangent(RiverNode node, RiverTerrainSampler terrain) {
@@ -771,16 +712,6 @@ public final class RiverNetwork {
return options.flowAlignmentWeight() * (1D - StrictMath.min(1D, alignment));
}
private double smoothEdgeNoise(RiverEdgeId id, double position) {
int lower = (int) StrictMath.floor(position);
int upper = lower + 1;
double fraction = position - lower;
double fade = fraction * fraction * (3.0 - 2.0 * fraction);
double a = centered(mix(options.seed() ^ id.stableId() ^ MEANDER_SALT ^ lower * 0x9E3779B97F4A7C15L));
double b = centered(mix(options.seed() ^ id.stableId() ^ MEANDER_SALT ^ upper * 0x9E3779B97F4A7C15L));
return a + (b - a) * fade;
}
private boolean intersects(
RiverReach reach,
long minimumX,
@@ -812,8 +743,8 @@ public final class RiverNetwork {
long maximumZ
) {
double length = StrictMath.hypot(to.x() - from.x(), to.z() - from.z());
double maximumMeander = StrictMath.min(options.meanderStrength(), length * 0.35D);
double padding = options.maximumReachRadius() + maximumMeander;
double maximumWormOffset = StrictMath.min(options.maximumWormOffset(), length * 0.35D);
double padding = options.maximumReachRadius() + maximumWormOffset;
double reachMinimumX = StrictMath.min(from.x(), to.x()) - padding;
double reachMaximumX = StrictMath.max(from.x(), to.x()) + padding;
double reachMinimumZ = StrictMath.min(from.z(), to.z()) - padding;
@@ -825,8 +756,8 @@ public final class RiverNetwork {
private int geometryPaddingCells() {
double maximumEdgeAxisDelta = options.cellSize() * (1D + options.siteJitter());
double maximumEdgeLength = StrictMath.sqrt(2D) * maximumEdgeAxisDelta;
double maximumMeander = StrictMath.min(options.meanderStrength(), maximumEdgeLength * 0.35D);
double displacement = options.maximumReachRadius() + maximumMeander;
double maximumWormOffset = StrictMath.min(options.maximumWormOffset(), maximumEdgeLength * 0.35D);
double displacement = options.maximumReachRadius() + maximumWormOffset;
return 1 + (int) StrictMath.ceil(displacement / options.cellSize());
}
@@ -917,7 +848,12 @@ public final class RiverNetwork {
private final Map<RiverEdgeId, RiverRoutingContext> routingContexts;
private final Map<RiverNodeId, FlowTangent> flowTangents;
private final Map<RiverEdgeId, Integer> branchSlots;
private final Map<RiverNodeId, Double> styleDistances;
private final Map<RiverNodeId, StyleParent> styleParents;
private final Map<RiverNodeId, RiverWorm> styleWorms;
private final Map<RiverEdgeId, Integer> styleBranchSlots;
private final Map<RiverNodeId, Boolean> resolvedBranchParents;
private final Map<RiverNodeId, Boolean> resolvedStyleBranchParents;
private NodeResolver(RiverTerrainSampler terrain) {
this.terrain = terrain;
@@ -932,7 +868,12 @@ public final class RiverNetwork {
routingContexts = new HashMap<>();
flowTangents = new HashMap<>();
branchSlots = new HashMap<>();
styleDistances = new HashMap<>();
styleParents = new HashMap<>();
styleWorms = new HashMap<>();
styleBranchSlots = new HashMap<>();
resolvedBranchParents = new HashMap<>();
resolvedStyleBranchParents = new HashMap<>();
}
private RiverNode resolve(RiverNodeId id) {
@@ -1099,11 +1040,88 @@ public final class RiverNetwork {
private RiverRoutingContext routingContext(RiverNode from, RiverNode to) {
RiverEdgeId edgeId = RiverEdgeId.of(from.id(), to.id());
RiverWorm worm = worm(from, to);
return routingContexts.computeIfAbsent(edgeId, ignored -> RiverRoutingContext.lazy(
edgeId,
from,
to,
() -> createPolyline(edgeId, from, to, this)));
() -> createPolyline(from, to, worm)));
}
private RiverWorm worm(RiverNode first, RiverNode second) {
RiverNode child = compareRank(first, second) > 0 ? first : second;
return styleWorm(child.id());
}
private RiverWorm styleWorm(RiverNodeId id) {
RiverWorm cached = styleWorms.get(id);
if (cached != null) {
return cached;
}
StyleParent parent = styleParent(id);
RiverWorm selected;
if (parent.id() == null) {
selected = rootWormFor(id);
} else {
RiverWorm parentWorm = styleWorm(parent.id());
selected = childWormFor(
parentWorm,
parent.id(),
id,
styleBranchSlot(parent.id(), id)
);
}
styleWorms.put(id, selected);
return selected;
}
private StyleParent styleParent(RiverNodeId id) {
return styleParents.computeIfAbsent(id, nodeId -> {
double nodeDistance = styleDistance(nodeId);
RiverNodeId selected = null;
double selectedDistance = Double.POSITIVE_INFINITY;
for (RiverNodeId candidate : neighbors(nodeId)) {
double candidateDistance = styleDistance(candidate);
if (candidateDistance >= nodeDistance - 0.000000001D) {
continue;
}
if (candidateDistance < selectedDistance
|| candidateDistance == selectedDistance
&& (selected == null || candidate.compareTo(selected) < 0)) {
selected = candidate;
selectedDistance = candidateDistance;
}
}
return new StyleParent(selected);
});
}
private double styleDistance(RiverNodeId id) {
return styleDistances.computeIfAbsent(id, RiverNetwork.this::drainageDistance);
}
private int styleBranchSlot(RiverNodeId parentId, RiverNodeId childId) {
if (!resolvedStyleBranchParents.containsKey(parentId)) {
ArrayList<RiverNodeId> children = new ArrayList<>(8);
for (RiverNodeId candidate : neighbors(parentId)) {
StyleParent candidateParent = styleParent(candidate);
if (parentId.equals(candidateParent.id())) {
children.add(candidate);
}
}
children.sort((first, second) -> {
long firstPriority = hash(RiverEdgeId.of(first, parentId), BRANCH_SLOT_SALT);
long secondPriority = hash(RiverEdgeId.of(second, parentId), BRANCH_SLOT_SALT);
int priorityComparison = Long.compareUnsigned(firstPriority, secondPriority);
return priorityComparison != 0 ? priorityComparison : first.compareTo(second);
});
for (int slot = 0; slot < children.size(); slot++) {
RiverNodeId child = children.get(slot);
styleBranchSlots.put(RiverEdgeId.of(child, parentId), slot);
}
resolvedStyleBranchParents.put(parentId, true);
}
return styleBranchSlots.getOrDefault(RiverEdgeId.of(childId, parentId), Integer.MAX_VALUE);
}
private FlowTangent flowTangent(RiverNode node) {
@@ -1122,27 +1140,13 @@ public final class RiverNetwork {
private record FlowTangent(double x, double z) {
}
private record MeanderProfile(
MeanderPersonality personality,
double amplitude,
double handedness,
double phase,
double skew,
double cycles,
double featureA,
double featureB
) {
private record DrainageBasinId(long x, long z) {
}
private enum MeanderPersonality {
QUIET,
SWEEP,
HOOK,
S_CURVE,
COIL,
WANDER,
OXBOW,
CHIRP
private record DrainageBasin(DrainageBasinId id, double distance) {
}
private record StyleParent(RiverNodeId id) {
}
private record SourceTileId(long tileX, long tileZ) {
@@ -1156,6 +1160,7 @@ public final class RiverNetwork {
private final RiverNode from;
private final RiverNode to;
private final RiverRoutingContext context;
private final RiverWorm worm;
private final RiverTerrainSampler terrain;
private int wetFlow;
private int dryFlow;
@@ -1167,12 +1172,14 @@ public final class RiverNetwork {
RiverNode from,
RiverNode to,
RiverRoutingContext context,
RiverWorm worm,
RiverTerrainSampler terrain
) {
this.id = id;
this.from = from;
this.to = to;
this.context = context;
this.worm = worm;
this.terrain = terrain;
}
@@ -1193,21 +1200,7 @@ public final class RiverNetwork {
private RiverReach build() {
int flow = wetFlow + dryFlow;
int order = 1 + (31 - Integer.numberOfLeadingZeros(flow));
double bankWidth = nonNegativeOrFallback(
terrain.bankWidth(context, options.bankWidth()),
options.bankWidth()
);
double baseDepth = positiveOrFallback(
terrain.depth(context, options.depth()),
options.depth()
);
RiverWidthProfile widthProfile = widthProfile(order);
double width = widthProfile.maximum();
bankWidth = StrictMath.min(options.maxBankWidth(), bankWidth);
double depth = StrictMath.min(
options.maxDepth(),
baseDepth * (1.0 + options.orderDepthFactor() * (order - 1))
);
RiverBodyProfile bodyProfile = bodyProfile(order, worm);
RiverRouteState state = wetFlow > 0 ? RiverRouteState.WET : RiverRouteState.DRY;
return new RiverReach(
id,
@@ -1216,10 +1209,10 @@ public final class RiverNetwork {
state,
flow,
order,
width,
widthProfile,
bankWidth,
depth,
bodyProfile.maximumWidth(),
bodyProfile.maximumBankWidth(),
bodyProfile.maximumDepth(),
bodyProfile,
state == RiverRouteState.WET && to.ocean(),
state == RiverRouteState.WET
? terminalWetFlow == wetFlow
@@ -1228,12 +1221,21 @@ public final class RiverNetwork {
);
}
private RiverWidthProfile widthProfile(int order) {
private RiverBodyProfile bodyProfile(int order, RiverWorm worm) {
RiverPolyline polyline = context.polyline();
int sampleCount = Math.min(WIDTH_PROFILE_SAMPLES, Math.max(2, polyline.size()));
double minimumWavelength = StrictMath.min(worm.bodyWavelength(), worm.bodyDetailWavelength());
int resolvedSamples = 1 + (int) StrictMath.ceil(polyline.length() * 2D / minimumWavelength);
int sampleCount = StrictMath.max(
MINIMUM_BODY_PROFILE_SAMPLES,
StrictMath.min(MAXIMUM_BODY_PROFILE_SAMPLES, resolvedSamples)
);
double[] positions = new double[sampleCount];
double[] widths = new double[sampleCount];
double orderScale = 1D + options.orderWidthFactor() * (order - 1);
double[] bankWidths = new double[sampleCount];
double[] depths = new double[sampleCount];
double[] roofScales = new double[sampleCount];
double widthOrderScale = 1D + options.orderWidthFactor() * (order - 1);
double depthOrderScale = 1D + options.orderDepthFactor() * (order - 1);
for (int index = 0; index < sampleCount; index++) {
double alongReach = (double) index / (sampleCount - 1);
ReachPosition position = positionAt(polyline, alongReach);
@@ -1249,10 +1251,59 @@ public final class RiverNetwork {
positions[index] = alongReach;
widths[index] = StrictMath.min(
options.maxChannelWidth(),
StrictMath.max(1D, baseWidth * orderScale)
StrictMath.max(
1D,
baseWidth
* widthOrderScale
* worm.widthMultiplier()
* bodyMultiplier(
position,
worm,
BODY_WIDTH_PRIMARY_SALT,
BODY_WIDTH_DETAIL_SALT,
worm.widthVariation()
)
)
);
double baseBankWidth = nonNegativeOrFallback(
terrain.bankWidth(context, position.x(), position.z(), options.bankWidth()),
options.bankWidth()
);
bankWidths[index] = StrictMath.min(
options.maxBankWidth(),
baseBankWidth
* worm.bankMultiplier()
* bodyMultiplier(
position,
worm,
BODY_BANK_PRIMARY_SALT,
BODY_BANK_DETAIL_SALT,
worm.bankVariation()
)
);
double baseDepth = positiveOrFallback(
terrain.depth(context, position.x(), position.z(), options.depth()),
options.depth()
);
depths[index] = StrictMath.min(
options.maxDepth(),
StrictMath.max(
1D,
baseDepth
* depthOrderScale
* worm.depthMultiplier()
* bodyMultiplier(
position,
worm,
BODY_DEPTH_PRIMARY_SALT,
BODY_DEPTH_DETAIL_SALT,
worm.depthVariation()
)
)
);
roofScales[index] = roofScale(position, worm);
}
return new RiverWidthProfile(positions, widths);
return new RiverBodyProfile(positions, widths, bankWidths, depths, roofScales);
}
}
@@ -1,5 +1,9 @@
package art.arcane.iris.engine.river;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public record RiverNetworkOptions(
long seed,
int cellSize,
@@ -21,8 +25,6 @@ public record RiverNetworkOptions(
double routingNoiseWeight,
double flowAlignmentWeight,
double confluenceWeight,
int branchSoftCap,
double branchChildShrinkFactor,
double oceanAttraction,
double channelWidth,
double bankWidth,
@@ -33,8 +35,7 @@ public record RiverNetworkOptions(
double orderWidthFactor,
double orderDepthFactor,
double maximumReachRadius,
double meanderStrength,
int meanderSubdivisions
List<RiverWorm> worms
) {
public RiverNetworkOptions {
requireRange(cellSize, 8, 4096, "cellSize");
@@ -47,7 +48,6 @@ public record RiverNetworkOptions(
requireRange(routingDeviationStrengthCells, 0D, 32D, "routingDeviationStrengthCells");
requirePositive(routingPlateauHeight, "routingPlateauHeight");
requireFinite(hydraulicBaseHeight, "hydraulicBaseHeight");
requireRange(meanderSubdivisions, 1, 64, "meanderSubdivisions");
requireProbability(siteJitter, "siteJitter");
requireProbability(sourceChance, "sourceChance");
requireProbability(reachChance, "reachChance");
@@ -56,8 +56,6 @@ public record RiverNetworkOptions(
requireFiniteNonNegative(routingNoiseWeight, "routingNoiseWeight");
requireFiniteNonNegative(flowAlignmentWeight, "flowAlignmentWeight");
requireFiniteNonNegative(confluenceWeight, "confluenceWeight");
requireRange(branchSoftCap, 1, 8, "branchSoftCap");
requireProbability(branchChildShrinkFactor, "branchChildShrinkFactor");
requireFiniteNonNegative(oceanAttraction, "oceanAttraction");
requirePositive(channelWidth, "channelWidth");
requireFiniteNonNegative(bankWidth, "bankWidth");
@@ -68,15 +66,27 @@ public record RiverNetworkOptions(
requireFiniteNonNegative(orderWidthFactor, "orderWidthFactor");
requireFiniteNonNegative(orderDepthFactor, "orderDepthFactor");
requireFiniteNonNegative(maximumReachRadius, "maximumReachRadius");
requireFiniteNonNegative(meanderStrength, "meanderStrength");
if (worms == null || worms.isEmpty()) {
throw new IllegalArgumentException("worms must contain at least one profile");
}
if (worms.size() > 16) {
throw new IllegalArgumentException("worms must contain at most 16 root profiles");
}
Set<String> ids = new HashSet<String>();
Set<Long> seeds = new HashSet<Long>();
int wormCount = validateWormTree(worms, 1, ids, seeds);
if (wormCount > 128) {
throw new IllegalArgumentException("worm hierarchy must contain at most 128 profiles");
}
worms = List.copyOf(worms);
RiverTopologyComplexity.requireSafe(
cellSize,
tileCells,
siteJitter,
maxRouteReaches,
maximumReachRadius,
meanderStrength,
meanderSubdivisions
maximumWormOffset(worms),
maximumWormSegments(worms)
);
}
@@ -84,6 +94,66 @@ public record RiverNetworkOptions(
return new Builder(seed);
}
public double maximumWormOffset() {
return maximumWormOffset(worms);
}
public int maximumWormSegments() {
return maximumWormSegments(worms);
}
private static double maximumWormOffset(List<RiverWorm> worms) {
double maximum = 0D;
for (RiverWorm worm : worms) {
if (worm == null) {
throw new IllegalArgumentException("worms must not contain null profiles");
}
maximum = StrictMath.max(maximum, worm.maxOffset());
maximum = StrictMath.max(maximum, maximumWormOffset(worm.children()));
}
return maximum;
}
private static int maximumWormSegments(List<RiverWorm> worms) {
int maximum = 1;
for (RiverWorm worm : worms) {
if (worm == null) {
throw new IllegalArgumentException("worms must not contain null profiles");
}
maximum = StrictMath.max(maximum, worm.segments());
maximum = StrictMath.max(maximum, maximumWormSegments(worm.children()));
}
return maximum;
}
private static int validateWormTree(
List<RiverWorm> worms,
int depth,
Set<String> ids,
Set<Long> seeds
) {
if (depth > 4) {
throw new IllegalArgumentException("worm hierarchy must be at most 4 profiles deep");
}
int count = 0;
for (RiverWorm worm : worms) {
if (worm == null) {
throw new IllegalArgumentException("worm hierarchy must not contain null profiles");
}
if (!ids.add(worm.id())) {
throw new IllegalArgumentException("worm ids must be unique: " + worm.id());
}
if (!seeds.add(worm.seed())) {
throw new IllegalArgumentException("worm seeds must be unique: " + worm.seed());
}
count++;
if (!worm.children().isEmpty()) {
count += validateWormTree(worm.children(), depth + 1, ids, seeds);
}
}
return count;
}
private static void requireRange(int value, int minimum, int maximum, String name) {
if (value < minimum || value > maximum) {
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum);
@@ -141,8 +211,6 @@ public record RiverNetworkOptions(
private double routingNoiseWeight;
private double flowAlignmentWeight;
private double confluenceWeight;
private int branchSoftCap;
private double branchChildShrinkFactor;
private double oceanAttraction;
private double channelWidth;
private double bankWidth;
@@ -153,8 +221,7 @@ public record RiverNetworkOptions(
private double orderWidthFactor;
private double orderDepthFactor;
private double maximumReachRadius;
private double meanderStrength;
private int meanderSubdivisions;
private List<RiverWorm> worms;
private Builder(long seed) {
this.seed = seed;
@@ -177,8 +244,6 @@ public record RiverNetworkOptions(
routingNoiseWeight = 24.0;
flowAlignmentWeight = 0D;
confluenceWeight = 0D;
branchSoftCap = 4;
branchChildShrinkFactor = 0.35D;
oceanAttraction = 64.0;
channelWidth = 10.0;
bankWidth = 8.0;
@@ -189,8 +254,32 @@ public record RiverNetworkOptions(
orderWidthFactor = 0.35;
orderDepthFactor = 0.2;
maximumReachRadius = Double.NaN;
meanderStrength = 40.0;
meanderSubdivisions = 8;
worms = List.of(new RiverWorm(
"default",
1L,
1D,
1024D,
256D,
0.5D,
0.15D,
40D,
8,
1D,
1D,
1D,
512D,
128D,
0D,
0D,
0D,
0D,
4,
0.35D,
1D,
0D,
0D,
List.of()
));
}
public Builder cellSize(int value) {
@@ -288,16 +377,6 @@ public record RiverNetworkOptions(
return this;
}
public Builder branchSoftCap(int value) {
branchSoftCap = value;
return this;
}
public Builder branchChildShrinkFactor(double value) {
branchChildShrinkFactor = value;
return this;
}
public Builder oceanAttraction(double value) {
oceanAttraction = value;
return this;
@@ -348,13 +427,8 @@ public record RiverNetworkOptions(
return this;
}
public Builder meanderStrength(double value) {
meanderStrength = value;
return this;
}
public Builder meanderSubdivisions(int value) {
meanderSubdivisions = value;
public Builder worms(List<RiverWorm> value) {
worms = value;
return this;
}
@@ -383,8 +457,6 @@ public record RiverNetworkOptions(
routingNoiseWeight,
flowAlignmentWeight,
confluenceWeight,
branchSoftCap,
branchChildShrinkFactor,
oceanAttraction,
channelWidth,
bankWidth,
@@ -395,8 +467,7 @@ public record RiverNetworkOptions(
orderWidthFactor,
orderDepthFactor,
resolvedMaximumReachRadius,
meanderStrength,
meanderSubdivisions
worms
);
}
@@ -10,9 +10,9 @@ public record RiverReach(
int flow,
int order,
double width,
RiverWidthProfile widthProfile,
double bankWidth,
double depth,
RiverBodyProfile bodyProfile,
boolean mouth,
boolean terminal,
RiverPolyline polyline
@@ -22,7 +22,7 @@ public record RiverReach(
Objects.requireNonNull(from);
Objects.requireNonNull(to);
Objects.requireNonNull(state);
Objects.requireNonNull(widthProfile);
Objects.requireNonNull(bodyProfile);
Objects.requireNonNull(polyline);
if (state == RiverRouteState.SUPPRESSED) {
throw new IllegalArgumentException("Suppressed routes cannot produce reaches");
@@ -34,12 +34,26 @@ public record RiverReach(
|| !Double.isFinite(depth) || depth <= 0.0) {
throw new IllegalArgumentException("River reach dimensions must be finite and valid");
}
if (Double.compare(width, widthProfile.maximum()) != 0) {
throw new IllegalArgumentException("River reach width must equal its profile maximum");
if (Double.compare(width, bodyProfile.maximumWidth()) != 0
|| Double.compare(bankWidth, bodyProfile.maximumBankWidth()) != 0
|| Double.compare(depth, bodyProfile.maximumDepth()) != 0) {
throw new IllegalArgumentException("River reach dimensions must equal their body-profile maxima");
}
}
public double widthAt(double alongReach) {
return widthProfile.sample(alongReach);
return bodyProfile.width(alongReach);
}
public double bankWidthAt(double alongReach) {
return bodyProfile.bankWidth(alongReach);
}
public double depthAt(double alongReach) {
return bodyProfile.depth(alongReach);
}
public double roofScaleAt(double alongReach) {
return bodyProfile.roofScale(alongReach);
}
}
@@ -50,10 +50,6 @@ public interface RiverTerrainSampler {
return 0.0;
}
default double meanderNoise(RiverMeanderContext context) {
return Double.NaN;
}
default double flowNoise(double x, double z) {
return Double.NaN;
}
@@ -75,10 +71,28 @@ public interface RiverTerrainSampler {
return fallback;
}
default double bankWidth(
RiverRoutingContext context,
double x,
double z,
double fallback
) {
return bankWidth(context, fallback);
}
default double depth(RiverRoutingContext context, double fallback) {
return fallback;
}
default double depth(
RiverRoutingContext context,
double x,
double z,
double fallback
) {
return depth(context, fallback);
}
default RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) {
return RiverTerminalPolicy.INHERIT;
}
@@ -210,9 +210,11 @@ public final class RiverTile {
double distance = StrictMath.sqrt(nearestDistanceSquared);
double localWidth = nearestReach.widthAt(nearestAlongReach);
double localBankWidth = nearestReach.bankWidthAt(nearestAlongReach);
double localDepth = nearestReach.depthAt(nearestAlongReach);
double channelRadius = localWidth * 0.5;
RiverSection section = section(nearestReach, distance, channelRadius);
double carveWeight = carveWeight(distance, channelRadius, nearestReach.bankWidth());
double carveWeight = carveWeight(distance, channelRadius, localBankWidth);
return new RiverSample(
true,
nearestReach.state(),
@@ -223,8 +225,8 @@ public final class RiverTile {
nearestReach.flow(),
nearestReach.order(),
localWidth,
nearestReach.bankWidth(),
nearestReach.depth(),
localBankWidth,
localDepth,
nearestReach.terminal(),
nearestReach.id()
);
@@ -325,7 +327,7 @@ public final class RiverTile {
RiverPolyline polyline = reach.polyline();
if (polyline.length() == 0D) {
double distanceSquared = squared(x - polyline.x(0)) + squared(z - polyline.z(0));
double radius = reach.widthAt(0D) * 0.5D + reach.bankWidth() + additionalRadius;
double radius = reach.widthAt(0D) * 0.5D + reach.bankWidthAt(0D) + additionalRadius;
return distanceSquared <= radius * radius ? new ClosestPoint(distanceSquared, 0D) : null;
}
double nearest = Double.POSITIVE_INFINITY;
@@ -339,9 +341,9 @@ public final class RiverTile {
}
double deltaX = polyline.x(point + 1) - polyline.x(point);
double deltaZ = polyline.z(point + 1) - polyline.z(point);
for (int profileIndex = 0; profileIndex < reach.widthProfile().size() - 1; profileIndex++) {
double profileStart = reach.widthProfile().position(profileIndex);
double profileEnd = reach.widthProfile().position(profileIndex + 1);
for (int profileIndex = 0; profileIndex < reach.bodyProfile().size() - 1; profileIndex++) {
double profileStart = reach.bodyProfile().position(profileIndex);
double profileEnd = reach.bodyProfile().position(profileIndex + 1);
double overlapStart = StrictMath.max(segmentStartAlong, profileStart);
double overlapEnd = StrictMath.min(segmentEndAlong, profileEnd);
if (overlapStart > overlapEnd) {
@@ -349,13 +351,16 @@ public final class RiverTile {
}
double intervalStart = (overlapStart - segmentStartAlong) / segmentAlongSpan;
double intervalEnd = (overlapEnd - segmentStartAlong) / segmentAlongSpan;
double widthSlope = (reach.widthProfile().width(profileIndex + 1)
- reach.widthProfile().width(profileIndex)) / (profileEnd - profileStart);
double radiusBase = (reach.widthProfile().width(profileIndex)
double widthSlope = (reach.bodyProfile().widthAtIndex(profileIndex + 1)
- reach.bodyProfile().widthAtIndex(profileIndex)) / (profileEnd - profileStart);
double bankSlope = (reach.bodyProfile().bankWidthAtIndex(profileIndex + 1)
- reach.bodyProfile().bankWidthAtIndex(profileIndex)) / (profileEnd - profileStart);
double radiusBase = (reach.bodyProfile().widthAtIndex(profileIndex)
+ widthSlope * (segmentStartAlong - profileStart)) * 0.5D
+ reach.bankWidth()
+ reach.bodyProfile().bankWidthAtIndex(profileIndex)
+ bankSlope * (segmentStartAlong - profileStart)
+ additionalRadius;
double radiusSlope = widthSlope * segmentAlongSpan * 0.5D;
double radiusSlope = (widthSlope * 0.5D + bankSlope) * segmentAlongSpan;
ClosestPoint candidate = coveringPoint(
intervalStart,
intervalEnd,
@@ -394,7 +399,7 @@ public final class RiverTile {
maximumX,
maximumZ
);
double radius = reach.widthAt(0D) * 0.5D + reach.bankWidth();
double radius = reach.widthAt(0D) * 0.5D + reach.bankWidthAt(0D);
return distanceSquared <= radius * radius ? new ClosestPoint(distanceSquared, 0D) : null;
}
double nearest = Double.POSITIVE_INFINITY;
@@ -410,9 +415,9 @@ public final class RiverTile {
double startZ = polyline.z(point);
double deltaX = polyline.x(point + 1) - startX;
double deltaZ = polyline.z(point + 1) - startZ;
for (int profileIndex = 0; profileIndex < reach.widthProfile().size() - 1; profileIndex++) {
double profileStart = reach.widthProfile().position(profileIndex);
double profileEnd = reach.widthProfile().position(profileIndex + 1);
for (int profileIndex = 0; profileIndex < reach.bodyProfile().size() - 1; profileIndex++) {
double profileStart = reach.bodyProfile().position(profileIndex);
double profileEnd = reach.bodyProfile().position(profileIndex + 1);
double overlapStart = StrictMath.max(segmentStartAlong, profileStart);
double overlapEnd = StrictMath.min(segmentEndAlong, profileEnd);
if (overlapStart > overlapEnd) {
@@ -420,11 +425,15 @@ public final class RiverTile {
}
double intervalStart = (overlapStart - segmentStartAlong) / segmentAlongSpan;
double intervalEnd = (overlapEnd - segmentStartAlong) / segmentAlongSpan;
double widthSlope = (reach.widthProfile().width(profileIndex + 1)
- reach.widthProfile().width(profileIndex)) / (profileEnd - profileStart);
double radiusBase = (reach.widthProfile().width(profileIndex)
+ widthSlope * (segmentStartAlong - profileStart)) * 0.5D + reach.bankWidth();
double radiusSlope = widthSlope * segmentAlongSpan * 0.5D;
double widthSlope = (reach.bodyProfile().widthAtIndex(profileIndex + 1)
- reach.bodyProfile().widthAtIndex(profileIndex)) / (profileEnd - profileStart);
double bankSlope = (reach.bodyProfile().bankWidthAtIndex(profileIndex + 1)
- reach.bodyProfile().bankWidthAtIndex(profileIndex)) / (profileEnd - profileStart);
double radiusBase = (reach.bodyProfile().widthAtIndex(profileIndex)
+ widthSlope * (segmentStartAlong - profileStart)) * 0.5D
+ reach.bodyProfile().bankWidthAtIndex(profileIndex)
+ bankSlope * (segmentStartAlong - profileStart);
double radiusSlope = (widthSlope * 0.5D + bankSlope) * segmentAlongSpan;
double cursor = intervalStart;
do {
double next = intervalEnd;
@@ -19,14 +19,12 @@ public final class RiverTopologyComplexity {
double siteJitter,
int maxRouteReaches,
double maximumReachRadius,
double meanderStrength,
int meanderSubdivisions
double maximumWormOffset,
int maximumWormSegments
) {
double maximumEdgeAxisDelta = cellSize * (1D + siteJitter);
double maximumEdgeLength = StrictMath.sqrt(2D) * maximumEdgeAxisDelta;
double maximumMeander = StrictMath.min(meanderStrength, maximumEdgeLength * 0.35D);
long geometryPaddingCells = 1L + ceilToLong(
(maximumReachRadius + maximumMeander) / cellSize
(maximumReachRadius + maximumWormOffset) / cellSize
);
long targetWindowAxis = saturatedAdd(tileCells, saturatedMultiply(2L, geometryPaddingCells));
long sourceWindowAxis = saturatedAdd(
@@ -36,7 +34,7 @@ public final class RiverTopologyComplexity {
long sourceWindowCells = saturatedMultiply(sourceWindowAxis, sourceWindowAxis);
long maximumRouteScanSteps = saturatedMultiply(sourceWindowCells, maxRouteReaches);
double maximumSegmentSpan = maximumEdgeAxisDelta
+ maximumMeander * 2D
+ maximumWormOffset * 2D
+ maximumReachRadius * 2D;
long maximumSegmentBucketAxis = saturatedAdd(
ceilToLong(maximumSegmentSpan / SPATIAL_BUCKET_SIZE),
@@ -48,7 +46,7 @@ public final class RiverTopologyComplexity {
);
long maximumBucketWritesPerReach = saturatedMultiply(
maximumSegmentBucketCount,
meanderSubdivisions
maximumWormSegments
);
return new Estimate(
geometryPaddingCells,
@@ -66,8 +64,8 @@ public final class RiverTopologyComplexity {
double siteJitter,
int maxRouteReaches,
double maximumReachRadius,
double meanderStrength,
int meanderSubdivisions
double maximumWormOffset,
int maximumWormSegments
) {
Estimate estimate = estimate(
cellSize,
@@ -75,8 +73,8 @@ public final class RiverTopologyComplexity {
siteJitter,
maxRouteReaches,
maximumReachRadius,
meanderStrength,
meanderSubdivisions
maximumWormOffset,
maximumWormSegments
);
List<String> violations = estimate.violations();
if (!violations.isEmpty()) {
@@ -205,7 +203,7 @@ public final class RiverTopologyComplexity {
+ " bucket writes for one reach (" + maximumSegmentBucketAxis
+ " buckets per segment axis), above the safe limit of "
+ MAXIMUM_BUCKET_WRITES_PER_REACH
+ "; reduce channel width, bank width, orderWidthFactor, meanderStrength, or meanderSubdivisions.");
+ "; reduce channel width, bank width, orderWidthFactor, worm maxOffset, or worm segments.");
}
return List.copyOf(violations);
}
@@ -1,89 +0,0 @@
package art.arcane.iris.engine.river;
import java.util.Arrays;
public final class RiverWidthProfile {
private final double[] positions;
private final double[] widths;
private final double maximum;
public RiverWidthProfile(double[] positions, double[] widths) {
if (positions == null || widths == null || positions.length < 2 || positions.length != widths.length) {
throw new IllegalArgumentException("River width profiles require matching position and width samples");
}
this.positions = positions.clone();
this.widths = widths.clone();
double resolvedMaximum = 0D;
for (int index = 0; index < this.positions.length; index++) {
double position = this.positions[index];
double width = this.widths[index];
if (!Double.isFinite(position) || position < 0D || position > 1D
|| (index > 0 && position <= this.positions[index - 1])) {
throw new IllegalArgumentException("River width profile positions must increase from zero to one");
}
if (!Double.isFinite(width) || width <= 0D) {
throw new IllegalArgumentException("River width profile widths must be finite and positive");
}
resolvedMaximum = Math.max(resolvedMaximum, width);
}
if (this.positions[0] != 0D || this.positions[this.positions.length - 1] != 1D) {
throw new IllegalArgumentException("River width profile positions must include zero and one");
}
maximum = resolvedMaximum;
}
public static RiverWidthProfile constant(double width) {
return new RiverWidthProfile(new double[]{0D, 1D}, new double[]{width, width});
}
public double sample(double alongReach) {
double position = Math.max(0D, Math.min(1D, alongReach));
int index = Arrays.binarySearch(positions, position);
if (index >= 0) {
return widths[index];
}
int upper = -index - 1;
if (upper <= 0) {
return widths[0];
}
if (upper >= positions.length) {
return widths[widths.length - 1];
}
int lower = upper - 1;
double range = positions[upper] - positions[lower];
double interpolation = range <= 0D ? 0D : (position - positions[lower]) / range;
return widths[lower] + (widths[upper] - widths[lower]) * interpolation;
}
public double maximum() {
return maximum;
}
public int size() {
return widths.length;
}
public double position(int index) {
return positions[index];
}
public double width(int index) {
return widths[index];
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof RiverWidthProfile profile)) {
return false;
}
return Arrays.equals(positions, profile.positions) && Arrays.equals(widths, profile.widths);
}
@Override
public int hashCode() {
return 31 * Arrays.hashCode(positions) + Arrays.hashCode(widths);
}
}
@@ -0,0 +1,79 @@
package art.arcane.iris.engine.river;
import java.util.List;
public record RiverWorm(
String id,
long seed,
double weight,
double wavelength,
double detailWavelength,
double tortuosity,
double detailTortuosity,
double maxOffset,
int segments,
double widthMultiplier,
double bankMultiplier,
double depthMultiplier,
double bodyWavelength,
double bodyDetailWavelength,
double widthVariation,
double bankVariation,
double depthVariation,
double roofVariation,
int branchCap,
double branchDecay,
double confluenceMultiplier,
double childChance,
double branchChildChance,
List<RiverWorm> children
) {
public RiverWorm {
if (id == null || !id.matches("[a-z0-9][a-z0-9_-]{0,63}")) {
throw new IllegalArgumentException("id must use 1 to 64 lowercase letters, digits, underscores, or hyphens");
}
requireRange(weight, 0.000001D, 1000000D, "weight");
requireRange(wavelength, 8D, 16384D, "wavelength");
requireRange(detailWavelength, 8D, 16384D, "detailWavelength");
requireRange(tortuosity, 0D, 1D, "tortuosity");
requireRange(detailTortuosity, 0D, 1D, "detailTortuosity");
requireRange(maxOffset, 0D, 1024D, "maxOffset");
if (segments < 1 || segments > 64) {
throw new IllegalArgumentException("segments must be between 1 and 64");
}
requireRange(widthMultiplier, 0.125D, 8D, "widthMultiplier");
requireRange(bankMultiplier, 0.125D, 8D, "bankMultiplier");
requireRange(depthMultiplier, 0.125D, 8D, "depthMultiplier");
requireRange(bodyWavelength, 32D, 16384D, "bodyWavelength");
requireRange(bodyDetailWavelength, 32D, 16384D, "bodyDetailWavelength");
requireRange(widthVariation, 0D, 0.875D, "widthVariation");
requireRange(bankVariation, 0D, 0.875D, "bankVariation");
requireRange(depthVariation, 0D, 0.875D, "depthVariation");
requireRange(roofVariation, 0D, 0.875D, "roofVariation");
if (branchCap < 1 || branchCap > 8) {
throw new IllegalArgumentException("branchCap must be between 1 and 8");
}
requireRange(branchDecay, 0D, 1D, "branchDecay");
requireRange(confluenceMultiplier, 0D, 8D, "confluenceMultiplier");
requireRange(childChance, 0D, 1D, "childChance");
requireRange(branchChildChance, 0D, 1D, "branchChildChance");
if (children == null) {
throw new IllegalArgumentException("children must not be null");
}
if (children.size() > 16) {
throw new IllegalArgumentException("children must contain at most 16 profiles");
}
for (RiverWorm child : children) {
if (child == null) {
throw new IllegalArgumentException("children must not contain null profiles");
}
}
children = List.copyOf(children);
}
private static void requireRange(double value, double minimum, double maximum, String name) {
if (!Double.isFinite(value) || value < minimum || value > maximum) {
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum);
}
}
}
@@ -16,11 +16,11 @@ import art.arcane.iris.engine.object.IrisRiverTerrain;
import art.arcane.iris.engine.object.IrisRiverTopology;
import art.arcane.iris.engine.object.IrisRiverWater;
import art.arcane.iris.engine.object.IrisRiverWaterMode;
import art.arcane.iris.engine.object.IrisRiverWorm;
import art.arcane.iris.engine.object.IrisStyledRange;
import art.arcane.iris.engine.object.NoiseStyle;
import art.arcane.iris.engine.river.RiverAnchor;
import art.arcane.iris.engine.river.RiverEdgeId;
import art.arcane.iris.engine.river.RiverMeanderContext;
import art.arcane.iris.engine.river.RiverNetworkOptions;
import art.arcane.iris.engine.river.RiverPolyline;
import art.arcane.iris.engine.river.RiverReach;
@@ -35,6 +35,7 @@ import art.arcane.iris.engine.river.RiverTerminalPolicy;
import art.arcane.iris.engine.river.RiverTile;
import art.arcane.iris.engine.river.RiverTileCache;
import art.arcane.iris.engine.river.RiverTopologyComplexity;
import art.arcane.iris.engine.river.RiverWorm;
import art.arcane.iris.util.project.interpolation.NoiseBounds;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.project.stream.ProceduralStream;
@@ -61,7 +62,6 @@ public final class IrisRiverRuntime implements AutoCloseable {
private static final long WIDTH_NOISE_SALT = 0xBE5466CF34E90C6CL;
private static final long BANK_NOISE_SALT = 0xC0AC29B7C97C50DDL;
private static final long DEPTH_NOISE_SALT = 0x3F84D5B5B5470917L;
private static final long MEANDER_NOISE_SALT = 0x9216D5D98979FB1BL;
private static final long BED_NOISE_SALT = 0xD1310BA698DFB5ACL;
private static final long BIOME_NOISE_SALT = 0x2FFD72DBD01ADFB7L;
private static final long CAVE_ENTRY_NOISE_SALT = 0xB8E1AFED6A267E96L;
@@ -98,7 +98,6 @@ public final class IrisRiverRuntime implements AutoCloseable {
private final CNG widthNoise;
private final CNG bankNoise;
private final CNG depthNoise;
private final CNG meanderNoise;
private final CNG bedNoise;
private final CNG biomeNoise;
private final CNG caveEntryNoise;
@@ -144,7 +143,6 @@ public final class IrisRiverRuntime implements AutoCloseable {
widthNoise = noise(terrain.getChannelWidth(), WIDTH_NOISE_SALT);
bankNoise = noise(terrain.getBankWidth(), BANK_NOISE_SALT);
depthNoise = noise(terrain.getDepth(), DEPTH_NOISE_SALT);
meanderNoise = noise(terrain.getMeanderStyle(), MEANDER_NOISE_SALT);
bedNoise = noise(terrain.getBedRoughnessStyle(), BED_NOISE_SALT);
biomeNoise = noise(configuration.getBiomes().getSelectionStyle(), BIOME_NOISE_SALT);
caveEntryNoise = noise(caves.getEntry(), CAVE_ENTRY_NOISE_SALT);
@@ -256,7 +254,11 @@ public final class IrisRiverRuntime implements AutoCloseable {
z
);
int ceilingY = shapedTunnelCeilingY(
waterHeadY, caves.getDryHeadroom(), profile, roofOffset);
waterHeadY,
caves.getDryHeadroom() * column.reach().roofScaleAt(column.river().alongReach()),
profile,
roofOffset
);
return new IrisRiverTunnelSample(column.river(), bedY, waterHeadY, ceilingY);
}
@@ -349,7 +351,7 @@ public final class IrisRiverRuntime implements AutoCloseable {
IrisBiome sampledBiome = naturalBiome.get(center.x(), center.z());
double head = waterSurface(reach, alongReach, isNaturalOcean(sampledBiome));
double centerNaturalHeight = naturalHeight.get(center.x(), center.z());
double centerBedHeight = head - reach.depth() + bedRoughness(center.x(), center.z());
double centerBedHeight = head - reach.depthAt(alongReach) + bedRoughness(center.x(), center.z());
EffectiveRiverSettings settings = settingsFor(sampledRegion, sampledBiome);
double maximumIncision = Math.max(0D, terrain.getMaxIncision() * settings.maxIncisionMultiplier());
double cappedSurface = incisedHeight(centerNaturalHeight, centerBedHeight, 1D, maximumIncision);
@@ -627,8 +629,6 @@ public final class IrisRiverRuntime implements AutoCloseable {
.routingNoiseWeight(0D)
.flowAlignmentWeight(topology.getFlowAlignmentWeight())
.confluenceWeight(topology.getConfluenceWeight())
.branchSoftCap(topology.getBranchSoftCap())
.branchChildShrinkFactor(topology.getBranchChildShrinkFactor())
.oceanAttraction(topology.getOceanAttraction())
.channelWidth(mid(riverTerrain.getChannelWidth(), 12D))
.bankWidth(mid(riverTerrain.getBankWidth(), 8D))
@@ -639,11 +639,63 @@ public final class IrisRiverRuntime implements AutoCloseable {
.orderWidthFactor(riverTerrain.getOrderWidthFactor())
.orderDepthFactor(riverTerrain.getOrderDepthFactor())
.maximumReachRadius(maximumReachRadius(topology, riverTerrain))
.meanderStrength(riverTerrain.getMeanderStrength())
.meanderSubdivisions(riverTerrain.getMeanderSubdivisions())
.worms(worms(riverTerrain))
.build();
}
private List<RiverWorm> worms(IrisRiverTerrain riverTerrain) {
if (riverTerrain.getWorms() == null || riverTerrain.getWorms().isEmpty()) {
throw new IllegalArgumentException("River terrain must configure at least one Perlin worm");
}
ArrayList<RiverWorm> worms = new ArrayList<>(riverTerrain.getWorms().size());
for (IrisRiverWorm configured : riverTerrain.getWorms()) {
if (configured == null) {
throw new IllegalArgumentException("River terrain worms must not contain null entries");
}
worms.add(worm(configured));
}
return List.copyOf(worms);
}
private RiverWorm worm(IrisRiverWorm configured) {
if (configured.getChildren() == null) {
throw new IllegalArgumentException("River worm children must be an array");
}
ArrayList<RiverWorm> children = new ArrayList<>(configured.getChildren().size());
for (IrisRiverWorm child : configured.getChildren()) {
if (child == null) {
throw new IllegalArgumentException("River worm children must not contain null entries");
}
children.add(worm(child));
}
return new RiverWorm(
configured.getId(),
configured.getSeed(),
configured.getWeight(),
configured.getWavelength(),
configured.getDetailWavelength(),
configured.getTortuosity(),
configured.getDetailTortuosity(),
configured.getMaxOffset(),
configured.getSegments(),
configured.getWidthMultiplier(),
configured.getBankMultiplier(),
configured.getDepthMultiplier(),
configured.getBodyWavelength(),
configured.getBodyDetailWavelength(),
configured.getWidthVariation(),
configured.getBankVariation(),
configured.getDepthVariation(),
configured.getRoofVariation(),
configured.getBranchCap(),
configured.getBranchDecay(),
configured.getConfluenceMultiplier(),
configured.getChildChance(),
configured.getBranchChildChance(),
List.copyOf(children)
);
}
private void addTerminalCaveAnchors(
RiverTile tile,
int minimumX,
@@ -1141,11 +1193,6 @@ public final class IrisRiverRuntime implements AutoCloseable {
return routingCost(context.midpointX(), context.midpointZ());
}
@Override
public double meanderNoise(RiverMeanderContext context) {
return meanderNoise.fitDouble(-1D, 1D, context.x(), context.z());
}
@Override
public double flowNoise(double x, double z) {
return routingNoise.fitDouble(-1D, 1D, x, z);
@@ -1183,6 +1230,18 @@ public final class IrisRiverRuntime implements AutoCloseable {
* settings.bankWidthMultiplier();
}
@Override
public double bankWidth(RiverRoutingContext context, double x, double z, double fallback) {
EffectiveRiverSettings settings = settingsAt(x, z);
return styled(
terrain.getBankWidth(),
bankNoise,
(int) StrictMath.round(x),
(int) StrictMath.round(z),
fallback
) * settings.bankWidthMultiplier();
}
@Override
public double depth(RiverRoutingContext context, double fallback) {
EffectiveRiverSettings settings = settingsAt(context.midpointX(), context.midpointZ());
@@ -1199,6 +1258,22 @@ public final class IrisRiverRuntime implements AutoCloseable {
);
}
@Override
public double depth(RiverRoutingContext context, double x, double z, double fallback) {
EffectiveRiverSettings settings = settingsAt(x, z);
double configuredDepth = styled(
terrain.getDepth(),
depthNoise,
(int) StrictMath.round(x),
(int) StrictMath.round(z),
fallback
) * settings.depthMultiplier();
return Math.min(
terrain.getMaxDepth(),
Math.max(1D + terrain.getBedRoughness(), configuredDepth)
);
}
@Override
public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) {
EffectiveRiverSettings settings = settingsAt(blockX, blockZ);
@@ -50,19 +50,19 @@ public abstract class VolmitPlugin extends JavaPlugin implements Listener {
}
public void l(Object l) {
IrisLogging.info("[" + getName() + "]: " + l);
IrisLogging.info(String.valueOf(l));
}
public void w(Object l) {
IrisLogging.warn("[" + getName() + "]: " + l);
IrisLogging.warn(String.valueOf(l));
}
public void f(Object l) {
IrisLogging.error("[" + getName() + "]: " + l);
IrisLogging.error(String.valueOf(l));
}
public void v(Object l) {
IrisLogging.debug("[" + getName() + "]: " + l);
IrisLogging.debug(String.valueOf(l));
}
public void onEnable() {
@@ -25,6 +25,7 @@ public class PackRiverValidatorTest {
"logicalHeight": 256,
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"biomes": {
"channel": [],
"bank": [],
@@ -41,6 +42,212 @@ public class PackRiverValidatorTest {
assertTrue(result.getBlockingErrors().toString(), result.isLoadable());
}
@Test
public void acceptsRequiredValidWormProfiles() throws Exception {
File pack = pack("""
{
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {
"worms": [
{
"id": "floodplain_trunk",
"seed": 17,
"weight": 2.5,
"wavelength": 1536,
"detailWavelength": 192,
"tortuosity": 0.65,
"detailTortuosity": 0.2,
"maxOffset": 420,
"segments": 56,
"widthMultiplier": 1.4,
"bankMultiplier": 1.2,
"depthMultiplier": 0.8,
"bodyWavelength": 1400,
"bodyDetailWavelength": 320,
"widthVariation": 0.65,
"bankVariation": 0.75,
"depthVariation": 0.45,
"roofVariation": 0.55,
"branchCap": 3,
"branchDecay": 0.25,
"confluenceMultiplier": 1.5,
"childChance": 0.2,
"branchChildChance": 0.6,
"children": [
{
"id": "floodplain_tributary",
"seed": 29,
"weight": 1,
"wavelength": 640,
"detailWavelength": 128,
"tortuosity": 0.8,
"detailTortuosity": 0.3,
"maxOffset": 300,
"segments": 48,
"widthMultiplier": 0.7,
"bankMultiplier": 0.9,
"depthMultiplier": 1.3,
"bodyWavelength": 180,
"bodyDetailWavelength": 48,
"widthVariation": 0.8,
"bankVariation": 0.8,
"depthVariation": 0.65,
"roofVariation": 0.75,
"branchCap": 2,
"branchDecay": 0.1,
"confluenceMultiplier": 0.75,
"childChance": 0,
"branchChildChance": 0,
"children": []
}
]
}
]
}
}
}
""");
PackRiverValidator.Validation result = validate(pack);
assertTrue(result.errors().toString(), result.errors().isEmpty());
}
@Test
public void rejectsInvalidWormHierarchyIdentifiersRangesAndChildren() throws Exception {
File pack = pack("""
{
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {
"worms": [
{
"id": "trunk",
"seed": 17,
"bodyWavelength": 31,
"bodyDetailWavelength": 16385,
"widthVariation": -0.1,
"bankVariation": 0.876,
"depthVariation": -0.1,
"roofVariation": 0.876,
"branchCap": 0,
"branchDecay": 2,
"confluenceMultiplier": 9,
"childChance": -0.1,
"branchChildChance": 1.1,
"children": "tributary"
},
{"id": "trunk", "seed": 29},
{"id": "Bad ID", "seed": 31}
]
}
}
}
""");
PackRiverValidator.Validation result = validate(pack);
assertContains(result.errors(), "rivers.terrain.worms[0].bodyWavelength must be at least 32");
assertContains(result.errors(), "rivers.terrain.worms[0].bodyDetailWavelength must be at most 16384");
assertContains(result.errors(), "rivers.terrain.worms[0].widthVariation must be at least 0");
assertContains(result.errors(), "rivers.terrain.worms[0].bankVariation must be at most 0.875");
assertContains(result.errors(), "rivers.terrain.worms[0].depthVariation must be at least 0");
assertContains(result.errors(), "rivers.terrain.worms[0].roofVariation must be at most 0.875");
assertContains(result.errors(), "rivers.terrain.worms[0].branchCap must be at least 1");
assertContains(result.errors(), "rivers.terrain.worms[0].branchDecay must be at most 1");
assertContains(result.errors(), "rivers.terrain.worms[0].confluenceMultiplier must be at most 8");
assertContains(result.errors(), "rivers.terrain.worms[0].childChance must be at least 0");
assertContains(result.errors(), "rivers.terrain.worms[0].branchChildChance must be at most 1");
assertContains(result.errors(), "rivers.terrain.worms[0].children must be an array");
assertContains(result.errors(), "rivers.terrain.worms[1].id must be unique inside the worm hierarchy");
assertContains(result.errors(),
"rivers.terrain.worms[2].id must use 1 to 64 lowercase letters, digits, underscores, or hyphens");
}
@Test
public void rejectsWormHierarchyDepthAndProfileLimits() throws Exception {
File excessiveDepth = pack("""
{
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {
"worms": [
{
"id": "level_1",
"seed": 1,
"children": [
{
"id": "level_2",
"seed": 2,
"children": [
{
"id": "level_3",
"seed": 3,
"children": [
{
"id": "level_4",
"seed": 4,
"children": [
{"id": "level_5", "seed": 5}
]
}
]
}
]
}
]
}
]
}
}
}
""");
File excessiveRoots = packWithWorms(wormHierarchy(17, 0));
File excessiveProfiles = packWithWorms(wormHierarchy(16, 8));
PackRiverValidator.Validation depthResult = validate(excessiveDepth);
PackRiverValidator.Validation rootResult = validate(excessiveRoots);
PackRiverValidator.Validation profileResult = validate(excessiveProfiles);
assertContains(depthResult.errors(), "children exceeds the maximum hierarchy depth of 4");
assertContains(rootResult.errors(), "rivers.terrain.worms must contain at most 16 root profiles");
assertContains(profileResult.errors(), "rivers.terrain.worms hierarchy must contain at most 128 profiles");
}
@Test
public void rejectsMissingAndEmptyWormLists() throws Exception {
File missing = pack("""
{
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {}
}
}
""");
File empty = pack("""
{
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": []}
}
}
""");
PackRiverValidator.Validation missingResult = validate(missing);
PackRiverValidator.Validation emptyResult = validate(empty);
assertContains(missingResult.errors(),
"rivers.terrain.worms must be an array with at least one Perlin-worm profile");
assertContains(emptyResult.errors(),
"rivers.terrain.worms must contain at least one Perlin-worm profile");
}
@Test
public void rejectsNullEnabledNetworkSections() throws Exception {
File pack = pack("""
@@ -84,11 +291,10 @@ public class PackRiverValidatorTest {
"routingPlateauHeight": 0,
"flowAlignmentWeight": 1025,
"confluenceWeight": -1,
"branchSoftCap": 9,
"branchChildShrinkFactor": 2,
"routingStyle": {"zoom": 0}
},
"terrain": {
"worms": [{"id": "river"}],
"channelWidth": {"min": 40, "max": 12},
"depth": {},
"tunnelWidthMultiplier": {"min": 0.5, "max": 9},
@@ -116,8 +322,6 @@ public class PackRiverValidatorTest {
assertContains(result.errors(), "rivers.topology.routingPlateauHeight must be at least 1");
assertContains(result.errors(), "rivers.topology.flowAlignmentWeight must be at most 1024");
assertContains(result.errors(), "rivers.topology.confluenceWeight must be at least 0");
assertContains(result.errors(), "rivers.topology.branchSoftCap must be at most 8");
assertContains(result.errors(), "rivers.topology.branchChildShrinkFactor must be at most 1");
assertContains(result.errors(), "rivers.topology.routingStyle.zoom must be at least");
assertContains(result.errors(), "rivers.terrain.channelWidth.min must not exceed");
assertContains(result.errors(), "rivers.terrain.depth must set min and max explicitly.");
@@ -138,6 +342,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"water": {
"mode": "TERRACED",
"maximumPoolRise": 2,
@@ -169,8 +374,7 @@ public class PackRiverValidatorTest {
"channelWidth": {"min": 2048, "max": 2048},
"bankWidth": {"min": 2048, "max": 2048},
"orderWidthFactor": 8,
"meanderStrength": 1024,
"meanderSubdivisions": 64
"worms": [{"id": "river", "maxOffset": 1024, "segments": 64}]
}
}
}
@@ -191,6 +395,7 @@ public class PackRiverValidatorTest {
"rivers": {
"enabled": true,
"terrain": {
"worms": [{"id": "river"}],
"maxChannelWidth": 0,
"maxBankWidth": -1,
"maxDepth": 513
@@ -214,6 +419,7 @@ public class PackRiverValidatorTest {
"rivers": {
"enabled": true,
"terrain": {
"worms": [{"id": "river"}],
"maxChannelWidth": 2048,
"tunnelMouthBlend": 16
}
@@ -234,6 +440,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"biomes": {
"channel": ["biome", "missing"],
"bank": ["biome"]
@@ -257,6 +464,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"biomes": {
"channel": ["biome"],
"bank": ["biome"]
@@ -292,7 +500,10 @@ public class PackRiverValidatorTest {
File pack = pack("""
{
"regions": ["region"],
"rivers": {"enabled": true}
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]}
}
}
""");
write(pack, "regions/region.json", """
@@ -326,7 +537,10 @@ public class PackRiverValidatorTest {
File pack = pack("""
{
"regions": ["region"],
"rivers": {"enabled": true}
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]}
}
}
""");
write(pack, "regions/region.json", """
@@ -352,6 +566,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"caves": {
"mode": "GENERATE_GROTTO",
"throatRadius": 4,
@@ -383,6 +598,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"caves": {
"mode": "FLOOD_CLOSED_COMPONENT",
"maximumPerReach": 0,
@@ -406,7 +622,10 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"terminalMode": "SINKHOLE_GROTTO"},
"terrain": {
"worms": [{"id": "river"}],
"terminalMode": "SINKHOLE_GROTTO"
},
"caves": {"mode": "SEALED"}
}
}
@@ -424,7 +643,10 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"terminalMode": "SINKHOLE_GROTTO"},
"terrain": {
"worms": [{"id": "river"}],
"terminalMode": "SINKHOLE_GROTTO"
},
"caves": {
"mode": "GENERATE_GROTTO",
"maximumPerReach": 0
@@ -446,7 +668,10 @@ public class PackRiverValidatorTest {
"carvingEnabled": false,
"rivers": {
"enabled": true,
"terrain": {"terminalMode": "SINKHOLE_GROTTO"},
"terrain": {
"worms": [{"id": "river"}],
"terminalMode": "SINKHOLE_GROTTO"
},
"caves": {"mode": "GENERATE_GROTTO"}
}
}
@@ -464,7 +689,10 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"terminalMode": "SINKHOLE_GROTTO"},
"terrain": {
"worms": [{"id": "river"}],
"terminalMode": "SINKHOLE_GROTTO"
},
"caves": {
"mode": "FLOOD_CLOSED_COMPONENT",
"fallback": "SEALED",
@@ -490,6 +718,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"caves": {"mode": "SEALED"}
}
}
@@ -514,6 +743,7 @@ public class PackRiverValidatorTest {
"carvingEnabled": false,
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"caves": {"mode": "GENERATE_GROTTO"}
}
}
@@ -545,6 +775,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"topology": {
"routingStyle": {"expression": "route"}
}
@@ -587,6 +818,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"topology": {
"routingStyle": {"expression": "route"}
}
@@ -618,6 +850,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"topology": {
"routingStyle": "snippet/style/first"
}
@@ -639,6 +872,7 @@ public class PackRiverValidatorTest {
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": [{"id": "river"}]},
"topology": {
"routingStyle": {"expression": "route"}
}
@@ -677,6 +911,50 @@ public class PackRiverValidatorTest {
return pack;
}
private File packWithWorms(String worms) throws Exception {
return pack("""
{
"regions": ["region"],
"rivers": {
"enabled": true,
"terrain": {"worms": %s}
}
}
""".formatted(worms));
}
private String wormHierarchy(int rootCount, int childrenPerRoot) {
StringBuilder hierarchy = new StringBuilder("[");
int seed = 1;
for (int rootIndex = 0; rootIndex < rootCount; rootIndex++) {
if (rootIndex > 0) {
hierarchy.append(',');
}
hierarchy.append("{\"id\":\"root_")
.append(rootIndex)
.append("\",\"seed\":")
.append(seed++);
if (childrenPerRoot > 0) {
hierarchy.append(",\"children\":[");
for (int childIndex = 0; childIndex < childrenPerRoot; childIndex++) {
if (childIndex > 0) {
hierarchy.append(',');
}
hierarchy.append("{\"id\":\"child_")
.append(rootIndex)
.append('_')
.append(childIndex)
.append("\",\"seed\":")
.append(seed++)
.append('}');
}
hierarchy.append(']');
}
hierarchy.append('}');
}
return hierarchy.append(']').toString();
}
private void assertContains(List<String> messages, String fragment) {
assertTrue(messages.toString(), contains(messages, fragment));
}
@@ -30,7 +30,10 @@ public class IrisRiverSchemaTest {
JSONObject properties = schema.getJSONObject("properties");
JSONObject topology = referencedProperties(definitions, properties.getJSONObject("topology"));
JSONObject source = referencedProperties(definitions, topology.getJSONObject("source"));
JSONObject terrain = referencedProperties(definitions, properties.getJSONObject("terrain"));
JSONObject terrainDefinition = referencedDefinition(definitions, properties.getJSONObject("terrain"));
JSONObject terrain = terrainDefinition.getJSONObject("properties");
JSONObject worms = terrain.getJSONObject("worms");
JSONObject worm = referencedProperties(definitions, worms.getJSONObject("items"));
JSONObject water = referencedProperties(definitions, properties.getJSONObject("water"));
JSONObject biomes = referencedProperties(definitions, properties.getJSONObject("biomes"));
@@ -46,6 +49,29 @@ public class IrisRiverSchemaTest {
assertEquals(2048D, terrain.getJSONObject("maxChannelWidth").getDouble("maximum"), 0D);
assertEquals(0D, terrain.getJSONObject("maxBankWidth").getDouble("minimum"), 0D);
assertEquals(512D, terrain.getJSONObject("maxDepth").getDouble("maximum"), 0D);
assertTrue(arrayContains(terrainDefinition.getJSONArray("required"), "worms"));
assertEquals("array", worms.getString("type"));
assertEquals(1, worms.getInt("minItems"));
assertEquals(0.000001D, worm.getJSONObject("weight").getDouble("minimum"), 0D);
assertEquals(16384D, worm.getJSONObject("wavelength").getDouble("maximum"), 0D);
assertEquals(1D, worm.getJSONObject("tortuosity").getDouble("maximum"), 0D);
assertEquals(1024D, worm.getJSONObject("maxOffset").getDouble("maximum"), 0D);
assertEquals(64, worm.getJSONObject("segments").getInt("maximum"));
assertEquals(0.125D, worm.getJSONObject("widthMultiplier").getDouble("minimum"), 0D);
assertEquals(8D, worm.getJSONObject("bankMultiplier").getDouble("maximum"), 0D);
assertEquals(8D, worm.getJSONObject("depthMultiplier").getDouble("maximum"), 0D);
assertEquals(32D, worm.getJSONObject("bodyWavelength").getDouble("minimum"), 0D);
assertEquals(16384D, worm.getJSONObject("bodyDetailWavelength").getDouble("maximum"), 0D);
assertEquals(0.875D, worm.getJSONObject("widthVariation").getDouble("maximum"), 0D);
assertEquals(0.875D, worm.getJSONObject("bankVariation").getDouble("maximum"), 0D);
assertEquals(0.875D, worm.getJSONObject("depthVariation").getDouble("maximum"), 0D);
assertEquals(0.875D, worm.getJSONObject("roofVariation").getDouble("maximum"), 0D);
assertEquals(8, worm.getJSONObject("branchCap").getInt("maximum"));
assertEquals(1D, worm.getJSONObject("branchDecay").getDouble("maximum"), 0D);
assertEquals(8D, worm.getJSONObject("confluenceMultiplier").getDouble("maximum"), 0D);
assertEquals(1D, worm.getJSONObject("childChance").getDouble("maximum"), 0D);
assertEquals(1D, worm.getJSONObject("branchChildChance").getDouble("maximum"), 0D);
assertEquals("array", worm.getJSONObject("children").getString("type"));
assertEquals(List.of("SEA_LEVEL", "TERRACED"), enumValues(definitions, water.getJSONObject("mode")));
assertEquals("array", biomes.getJSONObject("channel").getString("type"));
assertEquals("#/definitions/erzbiomes",
@@ -87,8 +113,21 @@ public class IrisRiverSchemaTest {
}
private static JSONObject referencedProperties(JSONObject definitions, JSONObject reference) {
return referencedDefinition(definitions, reference).getJSONObject("properties");
}
private static JSONObject referencedDefinition(JSONObject definitions, JSONObject reference) {
String key = reference.getString("$ref").substring("#/definitions/".length());
return definitions.getJSONObject(key).getJSONObject("properties");
return definitions.getJSONObject(key);
}
private static boolean arrayContains(JSONArray values, String expected) {
for (int index = 0; index < values.length(); index++) {
if (expected.equals(values.getString(index))) {
return true;
}
}
return false;
}
private static List<String> enumValues(JSONObject definitions, JSONObject reference) {
@@ -36,6 +36,7 @@ public class IrisRiverConfigurationTest {
assertEquals(10D, dimension.getRivers().getTerrain().getMaxChannelWidth(), 0D);
assertEquals(4D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D);
assertEquals(10D, dimension.getRivers().getTerrain().getMaxDepth(), 0D);
assertTrue(dimension.getRivers().getTerrain().getWorms().isEmpty());
assertEquals(IrisRiverCaveMode.SEALED, dimension.getRivers().getCaves().getMode());
assertEquals(IrisRiverCaveFallback.SEALED, dimension.getRivers().getCaves().getFallback());
assertEquals(IrisRiverExistingFluidPolicy.REJECT,
@@ -65,6 +66,27 @@ public class IrisRiverConfigurationTest {
"maxBankWidth": 2.5,
"maxDepth": 8,
"maxIncision": 36,
"worms": [
{
"seed": 73,
"weight": 2.5,
"wavelength": 1536,
"detailWavelength": 192,
"tortuosity": 0.65,
"detailTortuosity": 0.2,
"maxOffset": 420,
"segments": 56,
"widthMultiplier": 1.4,
"bankMultiplier": 1.2,
"depthMultiplier": 0.8,
"bodyWavelength": 704,
"bodyDetailWavelength": 88,
"widthVariation": 0.75,
"bankVariation": 0.65,
"depthVariation": 0.55,
"roofVariation": 0.45
}
],
"terminalMode": "SUPPRESS"
},
"water": {"mode": "TERRACED", "poolLength": 80},
@@ -111,6 +133,24 @@ public class IrisRiverConfigurationTest {
assertEquals(2.5D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D);
assertEquals(8D, dimension.getRivers().getTerrain().getMaxDepth(), 0D);
assertEquals(36, dimension.getRivers().getTerrain().getMaxIncision());
IrisRiverWorm worm = dimension.getRivers().getTerrain().getWorms().get(0);
assertEquals(73L, worm.getSeed());
assertEquals(2.5D, worm.getWeight(), 0D);
assertEquals(1536D, worm.getWavelength(), 0D);
assertEquals(192D, worm.getDetailWavelength(), 0D);
assertEquals(0.65D, worm.getTortuosity(), 0D);
assertEquals(0.2D, worm.getDetailTortuosity(), 0D);
assertEquals(420D, worm.getMaxOffset(), 0D);
assertEquals(56, worm.getSegments());
assertEquals(1.4D, worm.getWidthMultiplier(), 0D);
assertEquals(1.2D, worm.getBankMultiplier(), 0D);
assertEquals(0.8D, worm.getDepthMultiplier(), 0D);
assertEquals(704D, worm.getBodyWavelength(), 0D);
assertEquals(88D, worm.getBodyDetailWavelength(), 0D);
assertEquals(0.75D, worm.getWidthVariation(), 0D);
assertEquals(0.65D, worm.getBankVariation(), 0D);
assertEquals(0.55D, worm.getDepthVariation(), 0D);
assertEquals(0.45D, worm.getRoofVariation(), 0D);
assertEquals(IrisRiverTerminalMode.SUPPRESS,
dimension.getRivers().getTerrain().getTerminalMode());
assertEquals(IrisRiverWaterMode.TERRACED, dimension.getRivers().getWater().getMode());
@@ -145,7 +185,11 @@ public class IrisRiverConfigurationTest {
.setFloodedCaveBiomes(new KList<>("biome-grotto"));
IrisDimension dimension = new IrisDimension()
.setRegions(new KList<>("region"))
.setRivers(new IrisRiverNetwork().setEnabled(true).setBiomes(dimensionBiomes));
.setRivers(new IrisRiverNetwork()
.setEnabled(true)
.setTerrain(new IrisRiverTerrain()
.setWorms(new KList<>(new IrisRiverWorm())))
.setBiomes(dimensionBiomes));
IrisRegion region = new IrisRegion()
.setLandBiomes(new KList<>("natural"))
.setRiverOverride(regionOverride);
@@ -21,8 +21,6 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class RiverNetworkTest {
private static final double TWO_PI_FOR_TESTS = StrictMath.PI * 2D;
@Test
public void mapsNegativeWorldCoordinatesWithFloorDivision() {
RiverNetwork network = new RiverNetwork(options(1L).build());
@@ -164,12 +162,25 @@ public class RiverNetworkTest {
}
@Test
public void recursiveBranchesApplyTheChildSoftCapAtEveryNode() {
public void recursiveBranchesApplyTheProfileChildSoftCapAtEveryNode() {
RiverWorm constrained = wormProfile(
"constrained",
720L,
6,
1D,
1D,
1D,
4,
0D,
1D,
0D,
0D,
List.of()
);
RiverNetwork network = new RiverNetwork(options(720L)
.requireOcean(false)
.routingBasinCells(16)
.branchSoftCap(4)
.branchChildShrinkFactor(0D)
.worms(List.of(constrained))
.build());
RiverTerrainSampler terrain = flatTerrain(false);
HashMap<RiverNodeId, Integer> incoming = new HashMap<>();
@@ -188,36 +199,29 @@ public class RiverNetworkTest {
}
@Test
public void candidateRankingAndTracingDoNotMaterializeMeanderGeometry() {
RiverNetwork network = new RiverNetwork(options(75L)
public void candidateRankingAndTracingAreIndependentOfWormGeometry() {
RiverNetwork straightNetwork = new RiverNetwork(options(75L)
.requireOcean(false)
.meanderSubdivisions(32)
.worms(List.of(worm(1L, 0D, 0D, 0D, 1)))
.build());
RiverNetwork windingNetwork = new RiverNetwork(options(75L)
.requireOcean(false)
.worms(List.of(worm(2L, 0.8D, 0.2D, 32D, 32)))
.build());
int[] meanderSamples = new int[1];
RiverTerrainSampler terrain = new TestTerrain(false) {
@Override
public double reachRoutingCost(RiverRoutingContext context) {
return context.midpointX() + context.midpointZ();
}
@Override
public double meanderNoise(RiverMeanderContext context) {
meanderSamples[0]++;
return 0D;
}
};
RiverNodeId source = new RiverNodeId(0L, 0L);
assertFalse(network.downstreamCandidates(new RiverNodeId(0L, 0L), terrain).isEmpty());
assertEquals(0, meanderSamples[0]);
assertFalse(network.trace(new RiverNodeId(0L, 0L), terrain).edges().isEmpty());
assertEquals(0, meanderSamples[0]);
RiverTile tile = network.buildTile(0, 0, terrain);
assertFalse(tile.reaches().isEmpty());
assertTrue(meanderSamples[0] > 0);
assertEquals(0, meanderSamples[0] % 12);
assertTrue(meanderSamples[0] / 12 <= 256);
assertFalse(straightNetwork.downstreamCandidates(source, terrain).isEmpty());
assertEquals(
straightNetwork.downstreamCandidates(source, terrain),
windingNetwork.downstreamCandidates(source, terrain)
);
assertEquals(straightNetwork.trace(source, terrain), windingNetwork.trace(source, terrain));
}
@Test
@@ -616,10 +620,10 @@ public class RiverNetworkTest {
);
assertEquals(List.of(
3050685048971830506L,
-3861364772874819248L,
-8770785170128501517L,
6698607697170340377L
973031325888677478L,
8932177974453767311L,
-5189009084208004632L,
6374264141259432071L
), actual);
}
@@ -659,9 +663,20 @@ public class RiverNetworkTest {
.channelWidth(512D)
.maxChannelWidth(512D)
.bankWidth(0D)
.branchChildShrinkFactor(1D)
.meanderStrength(0D)
.meanderSubdivisions(1)
.worms(List.of(wormProfile(
"straight",
1L,
1,
1D,
1D,
1D,
8,
1D,
1D,
0D,
0D,
List.of()
)))
.build());
RiverTerrainSampler terrain = new TestTerrain(false) {
@Override
@@ -787,9 +802,9 @@ public class RiverNetworkTest {
1,
1,
10.0,
RiverWidthProfile.constant(10.0),
5.0,
3.0,
RiverBodyProfile.constant(10.0, 5.0, 3.0),
false,
true,
new RiverPolyline(new double[]{0.0, 100.0}, new double[]{0.0, 0.0})
@@ -867,14 +882,9 @@ public class RiverNetworkTest {
.maxChannelWidth(10D)
.maxBankWidth(3D)
.maxDepth(6D)
.meanderStrength(0D)
.worms(List.of(worm(1L, 0D, 0D, 0D, 1)))
.build());
RiverTerrainSampler styled = new TestTerrain(false) {
@Override
public double meanderNoise(RiverMeanderContext context) {
return 0.0;
}
@Override
public double channelWidth(RiverRoutingContext context, double fallback) {
return 20.0;
@@ -906,12 +916,15 @@ public class RiverNetworkTest {
}
@Test
public void channelWidthVariesInsideOneReachAndRemainsSpatiallyQueryable() {
public void bodyAnatomyVariesInsideOneReachAndRemainsSpatiallyQueryable() {
RiverNode from = node(0L, 0L, 0D, 0D);
RiverNode to = node(1L, 0L, 100D, 0D);
RiverWidthProfile profile = new RiverWidthProfile(
RiverBodyProfile profile = new RiverBodyProfile(
new double[]{0D, 0.5D, 1D},
new double[]{1D, 7D, 1D}
new double[]{1D, 7D, 1D},
new double[]{1D, 9D, 1D},
new double[]{2D, 8D, 2D},
new double[]{1D, 0.35D, 1D}
);
RiverReach reach = new RiverReach(
RiverEdgeId.of(from.id(), to.id()),
@@ -921,31 +934,100 @@ public class RiverNetworkTest {
1,
1,
7D,
9D,
8D,
profile,
0D,
3D,
false,
false,
new RiverPolyline(new double[]{0D, 100D}, new double[]{0D, 0D})
);
RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach));
RiverSample narrow = tile.sample(10D, 2D);
RiverSample wide = tile.sample(50D, 2D);
RiverSample narrow = tile.sample(10D, 7D);
RiverSample swollen = tile.sample(50D, 7D);
assertFalse(narrow.present());
assertTrue(wide.present());
assertEquals(7D, wide.width(), 0D);
assertEquals(RiverSection.CHANNEL, wide.section());
assertTrue(swollen.present());
assertEquals(7D, swollen.width(), 0D);
assertEquals(9D, swollen.bankWidth(), 0D);
assertEquals(8D, swollen.depth(), 0D);
assertEquals(0.35D, reach.roofScaleAt(swollen.alongReach()), 0.0000001D);
assertEquals(RiverSection.BANK, swollen.section());
}
@Test
public void perlinWormBodyAnatomyVariesAllDimensionsDeterministically() {
RiverWorm anatomy = wormProfile(
"anatomy",
917L,
24,
1D,
1D,
1D,
64D,
32D,
0.875D,
0.875D,
0.875D,
0.875D,
4,
0.35D,
1D,
0D,
0D,
List.of()
);
RiverNetwork network = new RiverNetwork(options(917L)
.requireOcean(false)
.maxChannelWidth(64D)
.maxBankWidth(64D)
.maxDepth(64D)
.maximumReachRadius(96D)
.worms(List.of(anatomy))
.build());
RiverTerrainSampler terrain = flatTerrain(false);
RiverTile first = network.buildTile(0, 0, terrain);
RiverTile second = network.buildTile(0, 0, terrain);
assertFalse(first.reaches().isEmpty());
assertEquals(digest(first), digest(second));
double minimumWidth = Double.POSITIVE_INFINITY;
double maximumWidth = 0D;
double minimumBank = Double.POSITIVE_INFINITY;
double maximumBank = 0D;
double minimumDepth = Double.POSITIVE_INFINITY;
double maximumDepth = 0D;
double minimumRoof = Double.POSITIVE_INFINITY;
double maximumRoof = 0D;
for (RiverReach reach : first.reaches()) {
for (int index = 0; index < reach.bodyProfile().size(); index++) {
minimumWidth = StrictMath.min(minimumWidth, reach.bodyProfile().widthAtIndex(index));
maximumWidth = StrictMath.max(maximumWidth, reach.bodyProfile().widthAtIndex(index));
minimumBank = StrictMath.min(minimumBank, reach.bodyProfile().bankWidthAtIndex(index));
maximumBank = StrictMath.max(maximumBank, reach.bodyProfile().bankWidthAtIndex(index));
minimumDepth = StrictMath.min(minimumDepth, reach.bodyProfile().depthAtIndex(index));
maximumDepth = StrictMath.max(maximumDepth, reach.bodyProfile().depthAtIndex(index));
minimumRoof = StrictMath.min(minimumRoof, reach.bodyProfile().roofScaleAtIndex(index));
maximumRoof = StrictMath.max(maximumRoof, reach.bodyProfile().roofScaleAtIndex(index));
}
}
assertTrue(maximumWidth - minimumWidth > 2D);
assertTrue(maximumBank - minimumBank > 1.5D);
assertTrue(maximumDepth - minimumDepth > 0.75D);
assertTrue(maximumRoof - minimumRoof > 0.1D);
}
@Test
public void foldedReachSamplingFindsFartherCoveringWidthEnvelope() {
RiverNode from = node(0L, 0L, 0D, 0D);
RiverNode to = node(1L, 0L, 0D, 10D);
RiverWidthProfile profile = new RiverWidthProfile(
RiverBodyProfile profile = new RiverBodyProfile(
new double[]{0D, 0.48D, 0.53D, 1D},
new double[]{1D, 1D, 18D, 18D}
new double[]{1D, 1D, 18D, 18D},
new double[]{0D, 0D, 0D, 0D},
new double[]{3D, 3D, 3D, 3D},
new double[]{1D, 1D, 1D, 1D}
);
RiverReach reach = new RiverReach(
RiverEdgeId.of(from.id(), to.id()),
@@ -955,9 +1037,9 @@ public class RiverNetworkTest {
1,
1,
18D,
profile,
0D,
3D,
profile,
false,
false,
new RiverPolyline(
@@ -978,15 +1060,12 @@ public class RiverNetworkTest {
}
@Test
public void reachFeasibilityReceivesTheFinalPinnedMeanderPolyline() {
RiverNetwork network = new RiverNetwork(options(62L).meanderStrength(20D).build());
boolean[] observedMeander = new boolean[1];
public void reachFeasibilityReceivesTheFinalPinnedWormPolyline() {
RiverNetwork network = new RiverNetwork(options(62L)
.worms(List.of(worm(62L, 0.8D, 0.2D, 20D, 32)))
.build());
boolean[] observedWorm = new boolean[1];
RiverTerrainSampler terrain = new TestTerrain(false) {
@Override
public double meanderNoise(RiverMeanderContext context) {
return 1D;
}
@Override
public boolean allowsReach(RiverRoutingContext context) {
RiverPolyline polyline = context.polyline();
@@ -996,7 +1075,7 @@ public class RiverNetworkTest {
double pointX = polyline.x(point) - context.from().x();
double pointZ = polyline.z(point) - context.from().z();
if (StrictMath.abs(pointX * deltaZ - pointZ * deltaX) > 0.000001D) {
observedMeander[0] = true;
observedWorm[0] = true;
return false;
}
}
@@ -1006,76 +1085,66 @@ public class RiverNetworkTest {
RiverNode downstream = network.downstream(new RiverNodeId(0L, 0L), terrain);
assertTrue(observedMeander[0]);
assertTrue(observedWorm[0]);
assertEquals(null, downstream);
}
@Test
public void irisNoiseChangesTheSelectedPersonalityWithoutBreakingItsEnvelope() {
double meanderStrength = 20D;
RiverNetwork network = new RiverNetwork(options(63L)
public void wormSeedChangesDeterministicGeometryWithoutBreakingItsEnvelope() {
double maximumOffset = 20D;
RiverNetwork firstNetwork = new RiverNetwork(options(63L)
.siteJitter(0D)
.requireOcean(false)
.meanderStrength(meanderStrength)
.meanderSubdivisions(32)
.worms(List.of(worm(101L, 0.8D, 0.2D, maximumOffset, 32)))
.build());
RiverTerrainSampler quietTerrain = new TestTerrain(false) {
@Override
public double meanderNoise(RiverMeanderContext context) {
return 0D;
}
};
RiverTerrainSampler noisyTerrain = new TestTerrain(false) {
@Override
public double meanderNoise(RiverMeanderContext context) {
return StrictMath.sin(context.normalizedPosition() * StrictMath.PI * 7D);
}
};
RiverNetwork secondNetwork = new RiverNetwork(options(63L)
.siteJitter(0D)
.requireOcean(false)
.worms(List.of(worm(202L, 0.8D, 0.2D, maximumOffset, 32)))
.build());
RiverTerrainSampler terrain = new TestTerrain(false);
RiverReach quietReach = network.buildTile(0, 0, quietTerrain).reaches().getFirst();
RiverReach noisyReach = network.buildTile(0, 0, noisyTerrain).reaches().getFirst();
assertEquals(quietReach.id(), noisyReach.id());
RiverPolyline quietPolyline = quietReach.polyline();
RiverPolyline noisyPolyline = noisyReach.polyline();
double deltaX = noisyReach.to().x() - noisyReach.from().x();
double deltaZ = noisyReach.to().z() - noisyReach.from().z();
double chordLength = StrictMath.hypot(deltaX, deltaZ);
boolean changed = false;
for (int point = 1; point < noisyPolyline.size() - 1; point++) {
double pointX = noisyPolyline.x(point) - noisyReach.from().x();
double pointZ = noisyPolyline.z(point) - noisyReach.from().z();
double signedOffset = (pointX * deltaZ - pointZ * deltaX) / chordLength;
if (Double.doubleToLongBits(noisyPolyline.x(point)) != Double.doubleToLongBits(quietPolyline.x(point))
|| Double.doubleToLongBits(noisyPolyline.z(point))
!= Double.doubleToLongBits(quietPolyline.z(point))) {
changed = true;
}
assertTrue(StrictMath.abs(signedOffset) <= meanderStrength + 0.0000001D);
}
RiverReach firstReach = firstNetwork.buildTile(0, 0, terrain).reaches().getFirst();
RiverReach repeatedReach = firstNetwork.buildTile(0, 0, terrain).reaches().getFirst();
RiverReach secondReach = secondNetwork.buildTile(0, 0, terrain).reaches().getFirst();
assertTrue(changed);
assertEquals(noisyReach.from().x(), noisyPolyline.x(0), 0D);
assertEquals(noisyReach.from().z(), noisyPolyline.z(0), 0D);
int last = noisyPolyline.size() - 1;
assertEquals(noisyReach.to().x(), noisyPolyline.x(last), 0D);
assertEquals(noisyReach.to().z(), noisyPolyline.z(last), 0D);
assertReachEquals(firstReach, repeatedReach);
assertEquals(firstReach.id(), secondReach.id());
assertTrue(polylinesDiffer(firstReach.polyline(), secondReach.polyline()));
assertWormBounds(firstReach, maximumOffset);
assertWormBounds(secondReach, maximumOffset);
}
@Test
public void reachesSpanBroadAsymmetricCompoundAndRestlessShapeFamilies() {
double meanderStrength = 20D;
public void weightedWormProfilesSelectDistinctConfigurableVariants() {
RiverWorm gentle = new RiverWorm(
"gentle", 301L, 1D, 1024D, 256D, 0.2D, 0.05D, 10D, 8, 0.5D, 0.5D, 0.5D,
512D, 128D, 0D, 0D, 0D, 0D,
4, 0.35D, 1D, 0D, 0D, List.of()
);
RiverWorm winding = new RiverWorm(
"winding", 302L, 1D, 512D, 128D, 0.55D, 0.15D, 20D, 16, 1D, 1D, 1D,
512D, 128D, 0D, 0D, 0D, 0D,
4, 0.35D, 1D, 0D, 0D, List.of()
);
RiverWorm restless = new RiverWorm(
"restless", 303L, 1D, 192D, 48D, 0.9D, 0.35D, 30D, 32, 2D, 2D, 2D,
512D, 128D, 0D, 0D, 0D, 0D,
4, 0.35D, 1D, 0D, 0D, List.of()
);
RiverNetwork network = new RiverNetwork(options(64L)
.siteJitter(0D)
.routingBasinCells(8)
.requireOcean(false)
.meanderStrength(meanderStrength)
.meanderSubdivisions(48)
.maxChannelWidth(32D)
.maxBankWidth(32D)
.maxDepth(32D)
.orderWidthFactor(0D)
.orderDepthFactor(0D)
.maximumReachRadius(32D)
.worms(List.of(gentle, winding, restless))
.build());
RiverTerrainSampler terrain = new TestTerrain(false) {
@Override
public double meanderNoise(RiverMeanderContext context) {
return 0D;
}
};
RiverTerrainSampler terrain = new TestTerrain(false);
HashMap<RiverEdgeId, RiverReach> uniqueReaches = new HashMap<>();
for (int tileX = -2; tileX <= 2; tileX++) {
@@ -1085,39 +1154,217 @@ public class RiverNetworkTest {
}
}
}
ArrayList<ReachShape> shapes = new ArrayList<>(uniqueReaches.size());
Set<Integer> selectedPointCounts = new HashSet<>();
HashMap<Integer, Double> maximumDisplacementRatios = new HashMap<>();
for (RiverReach reach : uniqueReaches.values()) {
shapes.add(shapeOf(reach, meanderStrength));
}
double minimumAmplitude = shapes.stream()
.mapToDouble(ReachShape::amplitudeRatio)
.min()
.orElseThrow();
double maximumAmplitude = shapes.stream()
.mapToDouble(ReachShape::amplitudeRatio)
.max()
.orElseThrow();
HashSet<Integer> dominantBands = new HashSet<>();
HashSet<String> signatures = new HashSet<>();
for (ReachShape shape : shapes) {
dominantBands.add(shape.dominantBand());
signatures.add(
StrictMath.min(6, shape.crossings()) + ":"
+ (int) StrictMath.floor(shape.centroid() * 5D) + ":"
+ (int) StrictMath.floor(shape.amplitudeRatio() * 5D) + ":"
+ shape.dominantBand()
int pointCount = reach.polyline().size();
selectedPointCounts.add(pointCount);
RiverWorm selected = switch (pointCount) {
case 9 -> gentle;
case 17 -> winding;
case 33 -> restless;
default -> throw new AssertionError("Unexpected worm point count " + pointCount);
};
assertEquals(8D * selected.widthMultiplier(), reach.width(), 0.0000001D);
assertEquals(6D * selected.bankMultiplier(), reach.bankWidth(), 0.0000001D);
assertEquals(3D * selected.depthMultiplier(), reach.depth(), 0.0000001D);
assertWormBounds(reach, selected.maxOffset());
maximumDisplacementRatios.merge(
pointCount,
maximumWormDisplacement(reach) / selected.maxOffset(),
StrictMath::max
);
}
String diagnostics = shapes.toString();
assertTrue(diagnostics, shapes.size() >= 32);
assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.crossings() <= 1));
assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.crossings() >= 2 && shape.crossings() <= 4));
assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.crossings() >= 5));
assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.centroid() < 0.43D));
assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.centroid() > 0.57D));
assertTrue(diagnostics, maximumAmplitude - minimumAmplitude >= 0.25D);
assertTrue(diagnostics, dominantBands.size() >= 3);
assertTrue(diagnostics, signatures.size() >= 8);
assertEquals(Set.of(9, 17, 33), selectedPointCounts);
assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(9) > 0.03D);
assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(17) > 0.18D);
assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(33) > 0.4D);
}
@Test
public void forcedChildHierarchyControlsReachGeometryAndDimensionsDeterministically() {
RiverWorm child = wormProfile(
"child",
402L,
24,
2D,
1.5D,
0.5D,
8,
1D,
1D,
0D,
0D,
List.of()
);
RiverWorm root = wormProfile(
"root",
401L,
8,
0.5D,
0.5D,
0.5D,
8,
1D,
1D,
1D,
0D,
List.of(child)
);
RiverNetwork network = new RiverNetwork(options(68L)
.siteJitter(0D)
.requireOcean(false)
.maxChannelWidth(32D)
.maxBankWidth(32D)
.maxDepth(32D)
.orderWidthFactor(0D)
.orderDepthFactor(0D)
.maximumReachRadius(32D)
.worms(List.of(root))
.build());
RiverTerrainSampler terrain = new TestTerrain(false);
RiverTile first = network.buildTile(0, 0, terrain);
RiverTile second = network.buildTile(0, 0, terrain);
assertFalse(first.reaches().isEmpty());
assertEquals(digest(first), digest(second));
for (RiverReach reach : first.reaches()) {
assertEquals(child.segments() + 1, reach.polyline().size());
assertEquals(8D * child.widthMultiplier(), reach.width(), 0.0000001D);
assertEquals(6D * child.bankMultiplier(), reach.bankWidth(), 0.0000001D);
assertEquals(3D * child.depthMultiplier(), reach.depth(), 0.0000001D);
assertWormBounds(reach, child.maxOffset());
}
}
@Test
public void wormHierarchyRejectsInvalidIdsDepthAndCounts() {
assertThrows(IllegalArgumentException.class, () -> wormProfile(
"Invalid",
1L,
8,
1D,
1D,
1D,
512D,
128D,
0D,
0D,
0D,
0D,
4,
0.35D,
1D,
0D,
0D,
List.of()
));
RiverWorm duplicateChild = wormProfile(
"duplicate", 2L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of()
);
RiverWorm duplicateRoot = wormProfile(
"duplicate", 3L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1D, 0D, List.of(duplicateChild)
);
assertThrows(
IllegalArgumentException.class,
() -> options(10L).worms(List.of(duplicateRoot)).build()
);
RiverWorm tooDeep = wormProfile(
"depth-5", 5L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of()
);
for (int depth = 4; depth >= 1; depth--) {
tooDeep = wormProfile(
"depth-" + depth,
depth,
8,
1D,
1D,
1D,
4,
0.35D,
1D,
1D,
0D,
List.of(tooDeep)
);
}
RiverWorm depthRoot = tooDeep;
assertThrows(
IllegalArgumentException.class,
() -> options(10L).worms(List.of(depthRoot)).build()
);
ArrayList<RiverWorm> roots = new ArrayList<>();
for (int root = 0; root < 17; root++) {
roots.add(wormProfile(
"root-" + root, root, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of()
));
}
assertThrows(IllegalArgumentException.class, () -> options(10L).worms(roots).build());
ArrayList<RiverWorm> branches = new ArrayList<>();
for (int branch = 0; branch < 8; branch++) {
ArrayList<RiverWorm> leaves = new ArrayList<>();
for (int leaf = 0; leaf < 16; leaf++) {
leaves.add(wormProfile(
"leaf-" + branch + "-" + leaf,
branch * 16L + leaf,
8,
1D,
1D,
1D,
4,
0.35D,
1D,
0D,
0D,
List.of()
));
}
branches.add(wormProfile(
"branch-" + branch,
branch,
8,
1D,
1D,
1D,
4,
0.35D,
1D,
1D,
0D,
leaves
));
}
RiverWorm oversized = wormProfile(
"oversized", 900L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1D, 0D, branches
);
assertThrows(
IllegalArgumentException.class,
() -> options(10L).worms(List.of(oversized)).build()
);
}
@Test
public void wormProfileRejectsInvalidBranchControls() {
assertThrows(IllegalArgumentException.class, () -> wormProfile(
"cap", 1L, 8, 1D, 1D, 1D, 0, 0.35D, 1D, 0D, 0D, List.of()
));
assertThrows(IllegalArgumentException.class, () -> wormProfile(
"decay", 1L, 8, 1D, 1D, 1D, 4, 1.1D, 1D, 0D, 0D, List.of()
));
assertThrows(IllegalArgumentException.class, () -> wormProfile(
"confluence", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 8.1D, 0D, 0D, List.of()
));
assertThrows(IllegalArgumentException.class, () -> wormProfile(
"child-chance", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1.1D, 0D, List.of()
));
assertThrows(IllegalArgumentException.class, () -> wormProfile(
"sibling-chance", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 1.1D, List.of()
));
}
@Test
@@ -1194,8 +1441,6 @@ public class RiverNetworkTest {
assertThrows(IllegalArgumentException.class, () -> options(10L).routingPlateauHeight(0D).build());
assertThrows(IllegalArgumentException.class, () -> options(10L).hydraulicBaseHeight(Double.NaN).build());
assertThrows(IllegalArgumentException.class, () -> options(10L).confluenceWeight(-1D).build());
assertThrows(IllegalArgumentException.class, () -> options(10L).branchSoftCap(0).build());
assertThrows(IllegalArgumentException.class, () -> options(10L).branchChildShrinkFactor(1.1D).build());
assertThrows(IllegalArgumentException.class, () -> options(10L).channelWidth(-1.0).build());
assertThrows(IllegalArgumentException.class, () -> options(10L)
.tileCells(1)
@@ -1255,8 +1500,7 @@ public class RiverNetworkTest {
.siteJitter(0.49D)
.maxRouteReaches(256)
.maximumReachRadius(pathologicalRadius)
.meanderStrength(1024D)
.meanderSubdivisions(64)
.worms(List.of(worm(1L, 1D, 1D, 1024D, 64)))
.build()
);
assertTrue(failure.getMessage(), failure.getMessage().contains("source window"));
@@ -1278,8 +1522,126 @@ public class RiverNetworkTest {
.channelWidth(8.0)
.bankWidth(6.0)
.depth(3.0)
.meanderStrength(12.0)
.meanderSubdivisions(6);
.worms(List.of(worm(1L, 0.5D, 0.15D, 12D, 6)));
}
private static RiverWorm worm(
long seed,
double tortuosity,
double detailTortuosity,
double maximumOffset,
int segments
) {
return new RiverWorm(
"worm-" + Long.toUnsignedString(seed),
seed,
1D,
1024D,
256D,
tortuosity,
detailTortuosity,
maximumOffset,
segments,
1D,
1D,
1D,
512D,
128D,
0D,
0D,
0D,
0D,
4,
0.35D,
1D,
0D,
0D,
List.of()
);
}
private static RiverWorm wormProfile(
String id,
long seed,
int segments,
double widthMultiplier,
double bankMultiplier,
double depthMultiplier,
int branchCap,
double branchDecay,
double confluenceMultiplier,
double childChance,
double branchChildChance,
List<RiverWorm> children
) {
return wormProfile(
id,
seed,
segments,
widthMultiplier,
bankMultiplier,
depthMultiplier,
512D,
128D,
0D,
0D,
0D,
0D,
branchCap,
branchDecay,
confluenceMultiplier,
childChance,
branchChildChance,
children
);
}
private static RiverWorm wormProfile(
String id,
long seed,
int segments,
double widthMultiplier,
double bankMultiplier,
double depthMultiplier,
double bodyWavelength,
double bodyDetailWavelength,
double widthVariation,
double bankVariation,
double depthVariation,
double roofVariation,
int branchCap,
double branchDecay,
double confluenceMultiplier,
double childChance,
double branchChildChance,
List<RiverWorm> children
) {
return new RiverWorm(
id,
seed,
1D,
256D,
64D,
0.7D,
0.2D,
32D,
segments,
widthMultiplier,
bankMultiplier,
depthMultiplier,
bodyWavelength,
bodyDetailWavelength,
widthVariation,
bankVariation,
depthVariation,
roofVariation,
branchCap,
branchDecay,
confluenceMultiplier,
childChance,
branchChildChance,
children
);
}
private static RiverNode node(long cellX, long cellZ, double x, double z) {
@@ -1305,9 +1667,9 @@ public class RiverNetworkTest {
1,
1,
width,
RiverWidthProfile.constant(width),
bankWidth,
3D,
RiverBodyProfile.constant(width, bankWidth, 3D),
false,
false,
new RiverPolyline(
@@ -1347,55 +1709,45 @@ public class RiverNetworkTest {
return hydraulicComparison != 0 ? hydraulicComparison : first.id().compareTo(second.id());
}
private static ReachShape shapeOf(RiverReach reach, double meanderStrength) {
private static void assertWormBounds(RiverReach reach, double maximumOffset) {
assertTrue(maximumWormDisplacement(reach) <= maximumOffset + 0.0000001D);
RiverPolyline polyline = reach.polyline();
assertEquals(reach.from().x(), polyline.x(0), 0D);
assertEquals(reach.from().z(), polyline.z(0), 0D);
int last = polyline.size() - 1;
assertEquals(reach.to().x(), polyline.x(last), 0D);
assertEquals(reach.to().z(), polyline.z(last), 0D);
}
private static double maximumWormDisplacement(RiverReach reach) {
RiverPolyline polyline = reach.polyline();
double deltaX = reach.to().x() - reach.from().x();
double deltaZ = reach.to().z() - reach.from().z();
double chordLength = StrictMath.hypot(deltaX, deltaZ);
double[] offsets = new double[polyline.size()];
double maximumOffset = 0D;
double weightedPosition = 0D;
double totalWeight = 0D;
int crossings = 0;
int previousSign = 0;
double maximumDisplacement = 0D;
for (int point = 0; point < polyline.size(); point++) {
double pointX = polyline.x(point) - reach.from().x();
double pointZ = polyline.z(point) - reach.from().z();
double offset = (pointX * deltaZ - pointZ * deltaX) / chordLength;
offsets[point] = offset;
double absoluteOffset = StrictMath.abs(offset);
maximumOffset = StrictMath.max(maximumOffset, absoluteOffset);
double t = (double) point / (polyline.size() - 1);
weightedPosition += t * absoluteOffset;
totalWeight += absoluteOffset;
int sign = absoluteOffset < meanderStrength * 0.05D ? 0 : offset < 0D ? -1 : 1;
if (sign != 0 && previousSign != 0 && sign != previousSign) {
crossings++;
double straightX = reach.from().x() + deltaX * t;
double straightZ = reach.from().z() + deltaZ * t;
double displacement = StrictMath.hypot(
polyline.x(point) - straightX,
polyline.z(point) - straightZ
);
maximumDisplacement = StrictMath.max(maximumDisplacement, displacement);
}
if (sign != 0) {
previousSign = sign;
return maximumDisplacement;
}
assertTrue(absoluteOffset <= meanderStrength + 0.0000001D);
private static boolean polylinesDiffer(RiverPolyline first, RiverPolyline second) {
if (first.size() != second.size()) {
return true;
}
int dominantBand = 1;
double dominantEnergy = -1D;
int samples = offsets.length - 1;
for (int frequency = 1; frequency <= 8; frequency++) {
double cosine = 0D;
double sine = 0D;
for (int point = 0; point < samples; point++) {
double phase = TWO_PI_FOR_TESTS * frequency * point / samples;
cosine += offsets[point] * StrictMath.cos(phase);
sine += offsets[point] * StrictMath.sin(phase);
}
double energy = cosine * cosine + sine * sine;
if (energy > dominantEnergy) {
dominantEnergy = energy;
dominantBand = frequency;
for (int point = 0; point < first.size(); point++) {
if (Double.doubleToLongBits(first.x(point)) != Double.doubleToLongBits(second.x(point))
|| Double.doubleToLongBits(first.z(point)) != Double.doubleToLongBits(second.z(point))) {
return true;
}
}
double centroid = totalWeight <= 0.0000001D ? 0.5D : weightedPosition / totalWeight;
return new ReachShape(crossings, maximumOffset / meanderStrength, centroid, dominantBand);
return false;
}
private static long digest(RiverTile tile) {
@@ -1408,9 +1760,12 @@ public class RiverNetworkTest {
hash = RiverNetwork.mix(hash ^ reach.order());
hash = RiverNetwork.mix(hash ^ reach.state().ordinal());
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.width()));
for (int index = 0; index < reach.widthProfile().size(); index++) {
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.widthProfile().position(index)));
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.widthProfile().width(index)));
for (int index = 0; index < reach.bodyProfile().size(); index++) {
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().position(index)));
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().widthAtIndex(index)));
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().bankWidthAtIndex(index)));
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().depthAtIndex(index)));
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().roofScaleAtIndex(index)));
}
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bankWidth()));
hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.depth()));
@@ -1442,7 +1797,7 @@ public class RiverNetworkTest {
assertEquals(first.flow(), second.flow());
assertEquals(first.order(), second.order());
assertEquals(first.width(), second.width(), 0.0);
assertEquals(first.widthProfile(), second.widthProfile());
assertEquals(first.bodyProfile(), second.bodyProfile());
assertEquals(first.bankWidth(), second.bankWidth(), 0.0);
assertEquals(first.depth(), second.depth(), 0.0);
assertEquals(first.mouth(), second.mouth());
@@ -1473,7 +1828,4 @@ public class RiverNetworkTest {
return ocean && blockX >= 224;
}
}
private record ReachShape(int crossings, double amplitudeRatio, double centroid, int dominantBand) {
}
}
@@ -295,9 +295,9 @@ public class RiverTileCacheTest {
1,
1,
8.0,
RiverWidthProfile.constant(8.0),
4.0,
3.0,
RiverBodyProfile.constant(8.0, 4.0, 3.0),
false,
false,
new RiverPolyline(new double[]{8.0, 56.0}, new double[]{8.0, 8.0})
@@ -11,6 +11,7 @@ import art.arcane.iris.engine.object.IrisRiverOverride;
import art.arcane.iris.engine.object.IrisRiverRoutingPolicy;
import art.arcane.iris.engine.object.IrisRiverTerminalMode;
import art.arcane.iris.engine.object.IrisRiverWaterMode;
import art.arcane.iris.engine.object.IrisRiverWorm;
import art.arcane.iris.engine.object.IrisStyledRange;
import art.arcane.iris.engine.object.NoiseStyle;
import art.arcane.iris.engine.river.RiverEdgeId;
@@ -20,14 +21,15 @@ import art.arcane.iris.engine.river.RiverPolyline;
import art.arcane.iris.engine.river.RiverReach;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.RiverAnchor;
import art.arcane.iris.engine.river.RiverBodyProfile;
import art.arcane.iris.engine.river.RiverRoutingContext;
import art.arcane.iris.engine.river.RiverTerrainNodeSample;
import art.arcane.iris.engine.river.RiverTopologyComplexity;
import art.arcane.iris.engine.river.RiverTerrainSourceSample;
import art.arcane.iris.engine.river.RiverWidthProfile;
import art.arcane.iris.util.project.interpolation.NoiseBounds;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.interpolation.Interpolated;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import java.util.HashMap;
@@ -793,9 +795,9 @@ public class IrisRiverRuntimeTest {
1,
1,
4D,
RiverWidthProfile.constant(4D),
2D,
4D,
RiverBodyProfile.constant(4D, 2D, 4D),
true,
false,
new RiverPolyline(new double[]{-600D, 0D}, new double[]{0D, 0D})
@@ -1004,8 +1006,12 @@ public class IrisRiverRuntimeTest {
.setBankWidth(range(12D))
.setDepth(range(5D))
.setMaxIncision(512)
.setMeanderStrength(0D)
.setMeanderStyle(flat())
.setWorms(new KList<>(new IrisRiverWorm()
.setSeed(1L)
.setTortuosity(0D)
.setDetailTortuosity(0D)
.setMaxOffset(0D)
.setSegments(1)))
.setBedRoughness(0D)
.setBedRoughnessStyle(flat())
.setDryContinuationChance(1D);