Improved riverbed generation

- Pond levels follow intersecting lower channels
- Pond sampling covers requested flood margins
- Water steps align with sea level
- River crests grade into surrounding beds
- Riverbeds include varied cross sections and finer noise
- Submerged soils use stable block types
- Hydrology probes inspect wet bed geometry
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-26 18:01:26 -04:00
1 parent 494cdb1a5c
commit 75837d757b
25 files changed
+1566 -144

No files matched your search

@@ -11,6 +11,7 @@ import art.arcane.iris.generation.hydrology.grid.FlowAccumulator;
import art.arcane.iris.generation.hydrology.grid.HeightLattice;
import art.arcane.iris.generation.hydrology.grid.HydrologySampleBuffer;
import art.arcane.iris.generation.hydrology.grid.PriorityFlood;
import art.arcane.iris.generation.hydrology.raster.PondChannelSettlement;
import art.arcane.iris.generation.hydrology.underground.SeaInlet;
import art.arcane.iris.generation.hydrology.underground.SeaInletPlanner;
import art.arcane.iris.generation.hydrology.water.CupSet;
@@ -108,16 +109,16 @@ public final class WindowPlanner {
}
List<Pond> water = new ArrayList<>(ponds.ponds(raw, drainage.filled(), drainage.basins(), lakes, fine, cups));
water.addAll(lakes);
ShapedNetwork network = shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), drainage.channels(), water,
fine, settings.seed());
int passes = water.size() + 1;
for (int pass = 1; lowered(water, network.ponds()); pass++) {
ShapedNetwork network = PondChannelSettlement.settle(shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(),
drainage.channels(), water, fine, settings.seed()), settings.seaLevel());
long passes = settlementPasses(water);
for (long pass = 1; lowered(water, network.ponds()); pass++) {
if (pass >= passes) {
throw new IllegalStateException("Window " + key + " ponds did not settle within " + passes + " shaping passes");
}
water = replanned(settled(raw, cups, water, network.ponds()));
network = shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), drainage.channels(), water, fine,
settings.seed());
network = PondChannelSettlement.settle(shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(),
drainage.channels(), water, fine, settings.seed()), settings.seaLevel());
}
List<Reach> reaches = network.reaches();
List<Pond> windowPonds = windowPonds(network.ponds());
@@ -263,6 +264,16 @@ public final class WindowPlanner {
return settled;
}
private long settlementPasses(List<Pond> water) {
long passes = 1L;
for (Pond pond : water) {
if (pond.kind() != PondKind.LAKE) {
passes += Math.max(0L, (long) pond.level() - settings.seaLevel()) + 1L;
}
}
return passes;
}
private static boolean lowered(List<Pond> planned, List<Pond> shaped) {
for (int index = 0; index < shaped.size(); index++) {
if (shaped.get(index).level() < planned.get(index).level()) {
@@ -0,0 +1,55 @@
package art.arcane.iris.generation.hydrology.raster;
import art.arcane.iris.generation.hydrology.grid.CellHash;
final class BedProfile {
private static final long SALT_BROAD = 0x42454442524f4144L;
private static final long SALT_DETAIL = 0x4245444445544149L;
private static final long SALT_TEXTURE = 0x4245445445585455L;
private BedProfile() {
}
static double variation(long seed, int x, int z) {
return 0.65D * noise(seed, SALT_BROAD, x, z, 32) + 0.35D * noise(seed, SALT_DETAIL, x, z, 8);
}
static double detail(long seed, int x, int z) {
return 0.35D * noise(seed, SALT_TEXTURE, x, z, 2);
}
static double position(double u, double variation) {
double bounded = Math.clamp(u, 0D, 1D);
return bounded + 0.8D * variation * bounded * (1D - bounded);
}
static int channelDepth(double depth, double ratio, double variation, double detail) {
double across = position(ratio, -variation);
double bowl = 1D - across * across;
double cut = bowl * (depth + variation * Math.min(2D, depth * 0.3D) + detail);
return (int) Math.clamp(Math.round(cut), 1L, Math.max(1L, Math.round(depth)));
}
static int pondDepth(int maximum, double u, double variation, double detail) {
double across = position(u, variation);
double bowl = across * across * (3D - 2D * across);
double cut = 1D + bowl * (maximum - 1D + variation * Math.min(2D, (maximum - 1D) * 0.3D) + detail);
return (int) Math.clamp(Math.round(cut), 1L, maximum);
}
private static double noise(long seed, long salt, int x, int z, int scale) {
int ix = Math.floorDiv(x, scale);
int iz = Math.floorDiv(z, scale);
double u = fade(Math.floorMod(x, scale) / (double) scale);
double v = fade(Math.floorMod(z, scale) / (double) scale);
double a = CellHash.unit(CellHash.mix(seed, salt, ix, iz));
double b = CellHash.unit(CellHash.mix(seed, salt, ix + 1, iz));
double c = CellHash.unit(CellHash.mix(seed, salt, ix, iz + 1));
double d = CellHash.unit(CellHash.mix(seed, salt, ix + 1, iz + 1));
return 2D * ((a + (b - a) * u) * (1D - v) + (c + (d - c) * u) * v) - 1D;
}
private static double fade(double value) {
return value * value * value * (value * (value * 6D - 15D) + 10D);
}
}
@@ -50,7 +50,10 @@ final class ChunkFeatures {
List<SeaInlet> inlets = new ArrayList<>();
for (IndexedWindow window : windows) {
for (SeaInlet inlet : window.plan().inlets()) {
if (SeaInletRasterizer.touches(inlet, chunkX, chunkZ)) {
int[] bounds = SeaInletRasterizer.bounds(inlet);
int padding = RiffleProfile.RADIUS + 1;
if (bounds[0] <= chunkX * CHUNK + CHUNK - 1 + padding && bounds[2] >= chunkX * CHUNK - padding
&& bounds[1] <= chunkZ * CHUNK + CHUNK - 1 + padding && bounds[3] >= chunkZ * CHUNK - padding) {
inlets.add(inlet);
}
}
@@ -83,6 +86,25 @@ final class ChunkFeatures {
return segments;
}
boolean hasOnlyLevel(int head, int seaLevel) {
for (Pond pond : ponds) {
if (pond.level() - Math.floorMod(pond.level() - seaLevel, 2) != head) {
return false;
}
}
for (Pond lake : lakes) {
if (lake.level() - Math.floorMod(lake.level() - seaLevel, 2) != head) {
return false;
}
}
for (SeaInlet inlet : inlets) {
if (inlet.waterLevel() - Math.floorMod(inlet.waterLevel() - seaLevel, 2) != head) {
return false;
}
}
return segments.hasOnlyLevel(head, seaLevel);
}
int ponds() {
return ponds.size();
}
@@ -28,7 +28,9 @@ public final class ChunkRasterizer {
}
public static List<RegionKey> lakeRegions(int chunkX, int chunkZ) {
return RegionKey.lakeOwners(chunkX * CHUNK, chunkZ * CHUNK, chunkX * CHUNK + CHUNK - 1, chunkZ * CHUNK + CHUNK - 1);
int halo = RiffleProfile.RADIUS + 1;
return RegionKey.lakeOwners(chunkX * CHUNK - halo, chunkZ * CHUNK - halo,
chunkX * CHUNK + CHUNK - 1 + halo, chunkZ * CHUNK + CHUNK - 1 + halo);
}
public ChunkWaterRaster rasterize(int chunkX, int chunkZ, IntBinaryOperator naturalHeight, List<IndexedWindow> windows,
@@ -0,0 +1,129 @@
package art.arcane.iris.generation.hydrology.raster;
import art.arcane.iris.generation.hydrology.water.Pond;
import art.arcane.iris.generation.hydrology.water.PondKind;
import art.arcane.iris.generation.hydrology.water.PondMask;
import art.arcane.iris.generation.hydrology.water.Reach;
import art.arcane.iris.generation.hydrology.water.ShapedNetwork;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.List;
public final class PondChannelSettlement {
private static final int[][] SIDES = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private PondChannelSettlement() {
}
public static ShapedNetwork settle(ShapedNetwork network, int seaLevel) {
int[] initial = new int[network.ponds().size()];
for (int index = 0; index < initial.length; index++) {
initial[index] = network.ponds().get(index).level();
}
int[] levels = levels(network.reaches(), network.ponds(), initial, seaLevel);
List<Pond> water = new ArrayList<>(initial.length);
for (int index = 0; index < initial.length; index++) {
Pond pond = network.ponds().get(index);
water.add(levels[index] == initial[index] ? pond : new Pond(pond.id(), pond.kind(), levels[index], pond.floor(), pond.spillX(),
pond.spillZ(), pond.spillHeight(), pond.mask(), pond.outletReachId(), pond.damRaise(), pond.fluid(), pond.floorX(), pond.floorZ()));
}
return new ShapedNetwork(network.reaches(), water);
}
static int[] levels(List<Reach> reaches, List<Pond> ponds, int[] initial, int seaLevel) {
int[] levels = initial.clone();
if (reaches.isEmpty() || ponds.isEmpty()) {
return levels;
}
boolean eligible = false;
for (int pond = 0; pond < ponds.size() && !eligible; pond++) {
eligible = ponds.get(pond).kind() != PondKind.LAKE && levels[pond] > seaLevel;
}
if (!eligible) {
return levels;
}
ReachIndex index = ReachIndex.build(reaches, List.of());
List<Reach> nearby = new ArrayList<>();
List<Pond> unused = new ArrayList<>();
for (int pond = 0; pond < ponds.size(); pond++) {
Pond water = ponds.get(pond);
if (water.kind() == PondKind.LAKE || levels[pond] <= seaLevel || water.mask().width() == 0 || water.mask().depth() == 0) {
continue;
}
Bounds bounds = Bounds.of(water.mask());
int chunkX = (bounds.minX() + (bounds.maxX() - bounds.minX()) / 2) >> 4;
int chunkZ = (bounds.minZ() + (bounds.maxZ() - bounds.minZ()) / 2) >> 4;
int halo = Math.max(0, Math.max(Math.max((chunkX << 4) - bounds.minX(), bounds.maxX() - (chunkX << 4) - 15),
Math.max((chunkZ << 4) - bounds.minZ(), bounds.maxZ() - (chunkZ << 4) - 15))) + 1;
nearby.clear();
index.candidates(chunkX, chunkZ, halo, nearby, unused);
if (!nearby.isEmpty()) {
levels[pond] = lower(nearby, water.mask(), bounds, levels[pond], seaLevel);
}
}
return levels;
}
private static int lower(List<Reach> reaches, PondMask mask, Bounds bounds, int level, int seaLevel) {
ReachSegments segments = new ReachSegments(64);
for (Reach reach : reaches) {
segments.addNear(reach, bounds.minX() - 1, bounds.minZ() - 1, bounds.maxX() + 1, bounds.maxZ() + 1, 0f);
}
ReachCursor cursor = new ReachCursor();
int width = bounds.maxX() - bounds.minX() + 1;
BitSet visited = new BitSet(width * (bounds.maxZ() - bounds.minZ() + 1));
for (Reach reach : reaches) {
for (int segment = 0; segment + 1 < reach.xs().length; segment++) {
if (Math.min(head(reach.grade()[segment], seaLevel), head(reach.grade()[segment + 1], seaLevel)) >= head(level, seaLevel)) {
continue;
}
float radius = Math.max(reach.halfWidth()[segment], reach.halfWidth()[segment + 1]) + 1f;
int minX = Math.max(bounds.minX(), (int) Math.ceil(Math.min(reach.xs()[segment], reach.xs()[segment + 1]) - radius));
int minZ = Math.max(bounds.minZ(), (int) Math.ceil(Math.min(reach.zs()[segment], reach.zs()[segment + 1]) - radius));
int maxX = Math.min(bounds.maxX(), (int) Math.floor(Math.max(reach.xs()[segment], reach.xs()[segment + 1]) + radius));
int maxZ = Math.min(bounds.maxZ(), (int) Math.floor(Math.max(reach.zs()[segment], reach.zs()[segment + 1]) + radius));
for (int z = minZ; z <= maxZ; z++) {
for (int x = minX; x <= maxX; x++) {
int cell = (z - bounds.minZ()) * width + x - bounds.minX();
if (visited.get(cell) || !mask.contains(x, z)) {
continue;
}
visited.set(cell);
level = Math.min(level, touchingHead(segments, cursor, x, z, seaLevel, level));
for (int[] side : SIDES) {
int nx = x + side[0];
int nz = z + side[1];
if (!mask.contains(nx, nz)) {
level = Math.min(level, touchingHead(segments, cursor, nx, nz, seaLevel, level));
}
}
}
}
}
}
return level;
}
private static int touchingHead(ReachSegments segments, ReachCursor cursor, int x, int z, int seaLevel, int level) {
segments.nearest(x, z, ReachSegments.Scope.CHANNEL, cursor);
ReachCursor.Choice channel = cursor.channel;
if (channel.reach == null) {
return level;
}
int head = head(channel.reach.grade()[channel.point()], seaLevel);
return head < head(level, seaLevel) ? head : level;
}
private static int head(int level, int seaLevel) {
return level - Math.floorMod(level - seaLevel, 2);
}
private record Bounds(int minX, int minZ, int maxX, int maxZ) {
private static Bounds of(PondMask mask) {
int minX = mask.originX() - (mask.step() >> 1);
int minZ = mask.originZ() - (mask.step() >> 1);
return new Bounds(minX, minZ, minX + mask.width() * mask.step() - 1, minZ + mask.depth() * mask.step() - 1);
}
}
}
@@ -67,6 +67,7 @@ final class RasterComposer {
column(cell);
}
}
gradeRiffles();
raiseDams();
bermEdges();
measureLakeShores();
@@ -80,8 +81,8 @@ final class RasterComposer {
int ground = natural[cell];
SeaInlet inlet = features.openInletAt(x, z, inletColumn);
if (inlet != null) {
head[cell] = inlet.waterLevel();
terrain[cell] = Math.min(ground, inletColumn[0]);
head[cell] = level(inlet.waterLevel());
terrain[cell] = Math.min(Math.min(ground, inletColumn[0]), head[cell] - 1);
role[cell] = WaterRole.INLET;
feature[cell] = inlet.id();
fluid[cell] = HydrologyCaveCell.SEA_FLUID;
@@ -91,72 +92,77 @@ final class RasterComposer {
int pondIndex = lake >= 0 ? -1 : features.pondAt(x, z);
boolean shore = interior(cell) && lake < 0 && pondIndex < 0;
features.segments().nearest(x, z, shore ? ReachSegments.Scope.SHORE : ReachSegments.Scope.CHANNEL, cursor);
double variation = BedProfile.variation(settings.seed(), x, z);
if (lake >= 0) {
Pond water = features.lake(lake);
standing(cell, water, trench(ground, water.level(), ground));
standing(cell, water, trench(ground, level(water.level()), ground, variation, BedProfile.detail(settings.seed(), x, z)));
return;
}
if (pondIndex >= 0) {
Pond water = features.pond(pondIndex);
pond[cell] = pondIndex;
int floor = interior(cell) ? pondFloor(pondIndex, water.level(), x, z, ground) : ground;
standing(cell, water, trench(floor, water.level(), ground));
double detail = BedProfile.detail(settings.seed(), x, z);
int floor = interior(cell) ? pondFloor(pondIndex, level(water.level()), x, z, ground, variation, detail) : ground;
standing(cell, water, trench(floor, level(water.level()), ground, variation, detail));
return;
}
if (cursor.channel.reach != null) {
channel(cell, ground);
channel(cell, ground, variation, BedProfile.detail(settings.seed(), x, z));
} else if (interior(cell)) {
dry(cell, x, z, ground);
dry(cell, x, z, ground, variation);
}
}
private void standing(int cell, Pond water, int floor) {
head[cell] = water.level();
terrain[cell] = floor;
head[cell] = level(water.level());
terrain[cell] = Math.min(floor, head[cell] - 1);
role[cell] = WaterRole.POND;
feature[cell] = water.id();
fluid[cell] = water.fluid();
}
private int trench(int floor, int level, int ground) {
private int trench(int floor, int level, int ground, double variation, double detail) {
ReachCursor.Choice hit = cursor.channel;
if (hit.reach == null) {
return floor;
}
return Math.min(floor, bed(hit, Math.min(level, hit.reach.grade()[hit.point()]), ground));
return Math.min(floor, bed(hit, Math.min(level, level(hit.reach.grade()[hit.point()])), ground, variation, detail));
}
private void channel(int cell, int ground) {
private void channel(int cell, int ground, double variation, double detail) {
ReachCursor.Choice hit = cursor.channel;
Reach reach = hit.reach;
int point = hit.point();
head[cell] = reach.grade()[point];
terrain[cell] = bed(hit, head[cell], ground);
head[cell] = level(reach.grade()[point]);
terrain[cell] = bed(hit, head[cell], ground, variation, detail);
role[cell] = role(reach.style()[point]);
feature[cell] = reach.id();
tier[cell] = reach.tier();
fluid[cell] = reach.tier().fluid();
}
private static int bed(ReachCursor.Choice hit, int surface, int ground) {
private static int bed(ReachCursor.Choice hit, int surface, int ground, double variation, double detail) {
double width = hit.halfWidth();
double ratio = width > 0D ? hit.distance / width : 0D;
long cut = Math.max(1L, Math.round(hit.depth() * (1D - ratio * ratio)));
long cut = BedProfile.channelDepth(hit.depth(), ratio, variation, detail);
return (int) Math.min(ground, surface - cut);
}
private void dry(int cell, int x, int z, int ground) {
private void dry(int cell, int x, int z, int ground, double variation) {
int cut = ground;
int raise = Integer.MIN_VALUE;
double bankEdge = Double.POSITIVE_INFINITY;
ReachCursor.Choice bank = cursor.bank;
if (bank.reach != null) {
HydrologyTier bankTier = bank.reach.tier();
int surface = bank.reach.grade()[bank.point()];
int surface = level(bank.reach.grade()[bank.point()]);
bankEdge = bank.distance - bank.halfWidth();
double u = bankEdge / bankTier.bankBand();
if (bankTier.bankStyle() != IrisBankStyle.CLIFF && bankTier.bankStyle() != IrisBankStyle.TERRACED) {
u = BedProfile.position(u, -variation);
}
if (ground >= surface) {
cut = BankProfile.cut(bankTier.bankStyle(), surface, ground, u, coneSlope(cell) * bankEdge, noise(x, z));
cut = BankProfile.cut(bankTier.bankStyle(), surface, ground, u, coneSlope(cell) * u * bankTier.bankBand(), noise(x, z));
} else {
raise = BankProfile.levee(bankTier.bankStyle(), surface, ground, u, noise(x, z), settings.deformation().maxFill());
}
@@ -172,8 +178,8 @@ final class RasterComposer {
}
int taper = settings.ponds().shore().taper();
boolean tapered = nearPond >= 0 && pondEdge <= taper;
if (tapered && ground > features.pond(nearPond).level()) {
cut = Math.min(cut, taperCut(features.pond(nearPond).level(), ground, pondEdge / taper));
if (tapered && ground > level(features.pond(nearPond).level())) {
cut = Math.min(cut, taperCut(level(features.pond(nearPond).level()), ground, BedProfile.position(pondEdge / taper, -variation)));
}
terrain[cell] = raise == Integer.MIN_VALUE ? cut : Math.max(cut, raise);
if (bank.reach != null && (!tapered || bankEdge <= pondEdge)) {
@@ -215,11 +221,58 @@ final class RasterComposer {
return (int) Math.clamp((long) Math.floor(level + rise), level, ground);
}
private int pondFloor(int index, int level, int x, int z, int ground) {
private int level(int planned) {
return planned - Math.floorMod(planned - settings.seaLevel(), 2);
}
private void gradeRiffles() {
int surface = Integer.MIN_VALUE;
for (int cell = 0; cell < CELLS && surface == Integer.MIN_VALUE; cell++) {
if (interior(cell)) {
surface = head[cell];
}
}
if (surface == Integer.MIN_VALUE || features.hasOnlyLevel(surface, settings.seaLevel())) {
return;
}
RiffleProfile riffles = new RiffleProfile((originX + 1) >> 4, (originZ + 1) >> 4, this::headAt);
for (int cell = 0; cell < CELLS; cell++) {
if (interior(cell) && head[cell] != Integer.MIN_VALUE) {
int x = originX + cell % GRID;
int z = originZ + cell / GRID;
terrain[cell] = riffles.bed(cell % GRID - 1, cell / GRID - 1, terrain[cell], head[cell],
BedProfile.variation(settings.seed(), x, z));
}
}
}
private int headAt(int x, int z) {
int gx = x - originX;
int gz = z - originZ;
if (gx >= 0 && gx < GRID && gz >= 0 && gz < GRID && !corner(gz * GRID + gx)) {
return head[gz * GRID + gx];
}
SeaInlet inlet = features.openInletAt(x, z, inletColumn);
if (inlet != null) {
return level(inlet.waterLevel());
}
int lake = features.lakeCovers(x, z) ? features.lakeAt(x, z, naturalAt(x, z)) : -1;
if (lake >= 0) {
return level(features.lake(lake).level());
}
int pond = features.pondAt(x, z);
if (pond >= 0) {
return level(features.pond(pond).level());
}
features.segments().nearest(x, z, ReachSegments.Scope.CHANNEL, cursor);
return cursor.channel.reach == null ? Integer.MIN_VALUE : level(cursor.channel.reach.grade()[cursor.channel.point()]);
}
private int pondFloor(int index, int level, int x, int z, int ground, double variation, double detail) {
HydrologySettings.PondShore shore = settings.ponds().shore();
double bankDistance = features.pondBankDistance(index, x, z);
double u = shore.shelf() <= 0 ? 1D : Math.clamp((bankDistance - 1D) / shore.shelf(), 0D, 1D);
long bowl = Math.max(1L, Math.round(1D + (shore.maxDepth() - 1D) * BankProfile.shape(IrisBankStyle.SMOOTH, u)));
long bowl = BedProfile.pondDepth(shore.maxDepth(), u, variation, detail);
return (int) Math.min(ground, level - bowl);
}
@@ -236,8 +289,9 @@ final class RasterComposer {
continue;
}
Pond held = features.pond(index);
if (held.damRaise() > 0 && terrain[cell] < held.level() && held.level() + 1 > crest) {
crest = held.level() + 1;
int level = level(held.level());
if (held.damRaise() > 0 && terrain[cell] < level && level + 1 > crest) {
crest = level + 1;
dam = held.id();
}
}
@@ -45,6 +45,18 @@ final class ReachSegments {
}
}
boolean hasOnlyLevel(int head, int seaLevel) {
for (int index = 0; index < size; index++) {
int[] grade = reaches[index].grade();
int start = grade[segments[index]];
int end = grade[segments[index] + 1];
if (start - Math.floorMod(start - seaLevel, 2) != head || end - Math.floorMod(end - seaLevel, 2) != head) {
return false;
}
}
return true;
}
void addNear(Reach reach, float minX, float minZ, float maxX, float maxZ, float outward) {
float[] xs = reach.xs();
float[] zs = reach.zs();
@@ -0,0 +1,177 @@
package art.arcane.iris.generation.hydrology.raster;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.TreeSet;
import java.util.function.IntBinaryOperator;
final class RiffleProfile {
static final int RADIUS = 16;
private static final int CHUNK = 16;
private static final int PADDING = RADIUS + 1;
private static final int GRID = CHUNK + 2 * PADDING;
private static final int OUTPUT = CHUNK + 2;
private static final int LIMIT = 10 * RADIUS;
private static final int DRY = Integer.MIN_VALUE;
private static final int[] SIDES = {1, -1, GRID, -GRID};
private final List<Field> fields = new ArrayList<>();
RiffleProfile(int chunkX, int chunkZ, IntBinaryOperator headAt) {
int[] heads = new int[GRID * GRID];
int[] crests = new int[GRID * GRID];
int originX = (chunkX << 4) - PADDING;
int originZ = (chunkZ << 4) - PADDING;
Arrays.fill(crests, DRY);
for (int cell = 0; cell < heads.length; cell++) {
heads[cell] = headAt.applyAsInt(originX + cell % GRID, originZ + cell / GRID);
}
TreeSet<Integer> levels = new TreeSet<>();
for (int z = 1; z < GRID - 1; z++) {
for (int x = 1; x < GRID - 1; x++) {
int cell = z * GRID + x;
if (heads[cell] == DRY) {
continue;
}
for (int side : SIDES) {
if (heads[cell + side] != DRY && heads[cell + side] <= heads[cell] - 2) {
crests[cell] = heads[cell] - 1;
levels.add(crests[cell]);
break;
}
}
}
}
if (levels.isEmpty()) {
return;
}
DistanceField distances = new DistanceField(heads);
for (int crest : levels) {
distances.reset();
for (int cell = 0; cell < crests.length; cell++) {
if (crests[cell] == crest) {
distances.offer(cell, 0);
}
}
distances.spread();
int[] output = distances.output();
if (output != null) {
fields.add(new Field(crest, output));
}
}
}
int bed(int localX, int localZ, int original, int head, double variation) {
if (head == DRY) {
return original;
}
int cell = (localZ + 1) * OUTPUT + localX + 1;
double raised = original;
for (Field field : fields) {
int crest = Math.min(head - 1, field.crest());
if (crest <= original) {
continue;
}
double reach = 10D * Math.min(RADIUS, Math.max(4D, 2D * (crest - (double) original)))
* (0.925D + 0.075D * variation);
if (field.distances()[cell] >= reach) {
continue;
}
double u = field.distances()[cell] / reach;
double blend = 1D - u * u * (3D - 2D * u);
raised = Math.max(raised, original + (crest - (double) original) * blend);
}
return (int) Math.max(original, Math.min(head - 1L, Math.round(raised)));
}
private record Field(int crest, int[] distances) {
}
private static final class DistanceField {
private final int[] heads;
private final int[] distances = new int[GRID * GRID];
private final int[] next = new int[GRID * GRID];
private final int[] previous = new int[GRID * GRID];
private final int[] first = new int[LIMIT];
private DistanceField(int[] heads) {
this.heads = heads;
}
private void reset() {
Arrays.fill(distances, LIMIT);
Arrays.fill(next, -1);
Arrays.fill(previous, -1);
Arrays.fill(first, -1);
}
private void offer(int cell, int distance) {
if (distances[cell] < LIMIT) {
remove(cell);
}
distances[cell] = distance;
next[cell] = first[distance];
previous[cell] = -1;
if (next[cell] >= 0) {
previous[next[cell]] = cell;
}
first[distance] = cell;
}
private void remove(int cell) {
if (previous[cell] < 0) {
first[distances[cell]] = next[cell];
} else {
next[previous[cell]] = next[cell];
}
if (next[cell] >= 0) {
previous[next[cell]] = previous[cell];
}
next[cell] = -1;
previous[cell] = -1;
}
private void spread() {
for (int distance = 0; distance < LIMIT; distance++) {
while (first[distance] >= 0) {
int cell = first[distance];
remove(cell);
int x = cell % GRID;
int z = cell / GRID;
for (int dz = -1; dz <= 1; dz++) {
for (int dx = -1; dx <= 1; dx++) {
int nx = x + dx;
int nz = z + dz;
if (dx == 0 && dz == 0 || nx < 0 || nx >= GRID || nz < 0 || nz >= GRID) {
continue;
}
int neighbour = nz * GRID + nx;
int candidate = distance + (dx != 0 && dz != 0 ? 14 : 10);
if (heads[neighbour] == DRY || candidate >= distances[neighbour]) {
continue;
}
if (dx != 0 && dz != 0 && (heads[z * GRID + nx] == DRY || heads[nz * GRID + x] == DRY)) {
continue;
}
offer(neighbour, candidate);
}
}
}
}
}
private int[] output() {
int[] output = new int[OUTPUT * OUTPUT];
boolean affected = false;
for (int z = 0; z < OUTPUT; z++) {
for (int x = 0; x < OUTPUT; x++) {
int distance = distances[(z + PADDING - 1) * GRID + x + PADDING - 1];
output[z * OUTPUT + x] = distance;
affected |= distance < LIMIT;
}
}
return affected ? output : null;
}
}
}
@@ -126,8 +126,10 @@ public final class PondPlanner {
public Pond reflood(DemGrid raw, HeightLattice fine, CupSet cups, Pond pond) {
PondMask mask = pond.mask();
int step = mask.step();
Box box = box(raw, fine, mask.originX() - step, mask.originZ() - step, mask.originX() + mask.width() * step,
mask.originZ() + mask.depth() * step, cups, Math.max(1, step / fine.cell()));
int margin = Math.max(step, fine.cell());
Box box = box(raw, fine, mask.originX() - margin, mask.originZ() - margin,
mask.originX() + (mask.width() - 1) * step + margin, mask.originZ() + (mask.depth() - 1) * step + margin,
cups, Math.max(1, step / fine.cell()));
if (box == null) {
return null;
}
@@ -669,6 +671,9 @@ public final class PondPlanner {
}
int i = node % box.width();
int j = node / box.width();
if (i == 0 || j == 0 || i == box.width() - 1 || j == box.depth() - 1) {
return false;
}
for (int axis = 0; axis < AXIS_X.length; axis++) {
int neighbour = (j + AXIS_Z[axis]) * box.width() + i + AXIS_X[axis];
if (!wet[neighbour] && ground[neighbour] < level) {
@@ -29,11 +29,7 @@ final class EffectiveWater {
if (head == DRY) {
return highest == DRY ? column : column(Math.max(ground, highest + 1), DRY);
}
int oneAbove = oneAbove(head, east) + oneAbove(head, west) + oneAbove(head, south) + oneAbove(head, north);
if (oneAbove >= 2) {
return column(Math.max(ground, highest), DRY);
}
return dropsBelow(head, east, west, south, north) ? column(Math.max(ground, head - 1), head) : column;
return column;
}
static void settleGrid(int originX, int originZ, int width, int depth, Columns columns, long[] settled) {
@@ -61,18 +57,6 @@ final class EffectiveWater {
}
}
private static int oneAbove(int head, long neighbour) {
return head(neighbour) != DRY && head(neighbour) == head + 1 ? 1 : 0;
}
private static boolean dropsBelow(int head, long east, long west, long south, long north) {
return dropsBelow(head, east) || dropsBelow(head, west) || dropsBelow(head, south) || dropsBelow(head, north);
}
private static boolean dropsBelow(int head, long neighbour) {
return head(neighbour) != DRY && head(neighbour) <= head - 2;
}
interface Columns {
long pre(int x, int z);
@@ -48,6 +48,8 @@ import lombok.Getter;
public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBlockState> {
private static final BoundBlockState BEDROCK = BoundBlockState.of("BEDROCK");
private static final BoundBlockState AIR = BoundBlockState.of("AIR");
private static final BoundBlockState DIRT = BoundBlockState.of("minecraft:dirt");
private static final BoundBlockState NETHERRACK = BoundBlockState.of("minecraft:netherrack");
private static final int SLOT_SEA_LAYERS = 1;
@Getter
private final RNG rng;
@@ -132,6 +134,17 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBloc
return painted == null ? layerBlock : painted;
}
private static NativeBlockState submergedSoil(NativeBlockState block) {
if (block == null) {
return null;
}
return switch (IrisProceduralBlocks.materialKey(block)) {
case "minecraft:grass_block", "minecraft:mycelium", "minecraft:dirt_path", "minecraft:farmland" -> DIRT.get();
case "minecraft:crimson_nylium", "minecraft:warped_nylium" -> NETHERRACK.get();
default -> block;
};
}
private final class ColumnPainter {
private final int x;
private final int z;
@@ -214,6 +227,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBloc
int topY = Math.min(hf, chunkHeight - 1);
WaterRole role = waterRoleStream.get(realX, realZ);
boolean wet = role.wet();
boolean submerged = wet && he < surfaceFluidHeight;
boolean seaFloor = !wet || role == WaterRole.INLET;
IrisHydrologyMaterials materials = role == WaterRole.NONE || role == WaterRole.INLET ? null
: complex.resolveWaterMaterials(realX, realZ);
@@ -292,7 +306,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBloc
if (i <= he) {
int depth = layerSurfaceY - i;
if (depth == 0 && mappedSurfaceBlock != null) {
put(i, mappedSurfaceBlock);
put(i, submerged && layerSurfaceY == he ? submergedSoil(mappedSurfaceBlock) : mappedSurfaceBlock);
continue;
}
if (blocks == null) {
@@ -327,6 +341,9 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBloc
NativeBlockState layerBlock = paintWaterMaterial(
blocks.hasIndex(strataIndex) ? blocks.get(strataIndex) : null,
rolePalette, paintDepth, depth, rng, realX, i, realZ, data);
if (submerged && layerSurfaceY == he) {
layerBlock = submergedSoil(layerBlock);
}
if (layerBlock != null) {
if (keepGravityAway && IrisProceduralBlocks.isGravityAffected(layerBlock)) {
layerBlock = rock;
@@ -4,6 +4,7 @@ import art.arcane.iris.generation.hydrology.config.IrisHydrology;
import art.arcane.iris.generation.hydrology.grid.HeightLattice;
import art.arcane.iris.generation.hydrology.underground.SeaInlet;
import art.arcane.iris.generation.hydrology.water.HydrologyTier;
import art.arcane.iris.generation.hydrology.water.CupSet;
import art.arcane.iris.generation.hydrology.water.Pond;
import art.arcane.iris.generation.hydrology.water.PondKind;
import art.arcane.iris.generation.hydrology.water.PondMask;
@@ -24,6 +25,7 @@ import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -32,6 +34,8 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
public class WindowPlannerTest {
@@ -49,6 +53,51 @@ public class WindowPlannerTest {
assertTrue(plan.ponds().isEmpty());
}
@Test
public void shrinkingPondReplansUntilItsChangedOutletStopsLowering() {
HydrologySettings settings = settings(new IrisHydrology());
SyntheticTerrain terrain = SyntheticTerrain.flat(100D).withPit(64D, 64D, 20D, 20D);
int width = 41;
long[] bits = new long[(width * width + 63) >>> 6];
for (int z = -20; z <= 20; z++) {
for (int x = -20; x <= 20; x++) {
if (x * x + z * z < 400) {
int cell = (z + 20) * width + x + 20;
bits[cell >>> 6] |= 1L << cell;
}
}
}
Pond initial = new Pond(19L, PondKind.NATURAL, 100, 80, 84, 64, 100f,
new PondMask(44, 44, 1, width, width, bits), 0L, 0, "water", 64, 64);
PondPlanner ponds = spy(new PondPlanner(settings));
doReturn(CupSet.EMPTY).when(ponds).subtractCups(any(), any(), any(), any());
doReturn(List.of(initial)).when(ponds).ponds(any(), any(), any(), any(), any(), any());
ReachShaper shaper = mock(ReachShaper.class);
AtomicInteger passes = new AtomicInteger();
doAnswer(call -> {
List<Pond> water = call.getArgument(4);
Pond pond = single(water);
passes.incrementAndGet();
int head = pond.level() > 96 ? 96 : 94;
Reach outlet = new Reach(20L, settings.tiers().get(0), 0L, new float[]{64f, 64f}, new float[]{32f, 96f},
new int[]{head, head}, new float[]{2f, 2f}, new float[]{2f, 2f}, new ReachStyle[]{ReachStyle.RUN, ReachStyle.RUN}, 1000f);
return new ShapedNetwork(List.of(outlet), List.of(pond.withOutletReachId(outlet.id())));
}).when(shaper).shape(any(), any(), any(), any(), any(), any(), anyLong());
WindowPlanner planner = new WindowPlanner(settings, new HeightLattice(terrain, WindowPlanner.CELL, false, 1 << 18),
new HeightLattice(terrain, WindowPlanner.FINE_CELL, false, 1 << 18), ponds, shaper);
WindowComputation settled = planner.compute(ORIGIN, noRegions(ORIGIN));
Pond pond = single(settled.allPonds());
assertEquals(3, passes.get());
assertEquals(94, pond.level());
assertEquals(20L, pond.outletReachId());
assertTrue(initial.mask().contains(83, 64));
assertFalse(pond.mask().contains(83, 64));
assertTrue(pond.mask().contains(64, 64));
assertEquals(94, settled.allReaches().get(0).grade()[0]);
}
@Test
public void burnedTrunkCapturesLocalDrainage() {
HydrologySettings settings = settings(new IrisHydrology());
@@ -0,0 +1,96 @@
package art.arcane.iris.generation.hydrology.raster;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class BedProfileTest {
@Test
public void fineDetailHasBoundedAmplitudeAndAdjacentSteps() {
double minimum = Double.POSITIVE_INFINITY;
double maximum = Double.NEGATIVE_INFINITY;
for (long seed : new long[]{0L, 1337L, Long.MIN_VALUE, Long.MAX_VALUE}) {
for (int x = -48; x <= 48; x++) {
for (int z = -48; z <= 48; z++) {
double detail = BedProfile.detail(seed, x, z);
minimum = Math.min(minimum, detail);
maximum = Math.max(maximum, detail);
assertTrue(Math.abs(detail) <= 0.35D);
assertTrue(Math.abs(detail - BedProfile.detail(seed, x + 1, z)) <= 0.35D);
assertTrue(Math.abs(detail - BedProfile.detail(seed, x, z + 1)) <= 0.35D);
}
}
}
assertTrue(minimum < -0.3D);
assertTrue(maximum > 0.3D);
}
@Test
public void fineDetailBreaksUpLevelContoursWithoutTallFaces() {
int changedChannels = 0;
int changedPonds = 0;
for (int x = -64; x <= 64; x++) {
for (int z = -32; z <= 32; z++) {
double detail = BedProfile.detail(1337L, x, z);
int channel = BedProfile.channelDepth(6D, 0.5D, 0D, detail);
int pond = BedProfile.pondDepth(8, 0.5D, 0D, detail);
changedChannels += channel != BedProfile.channelDepth(6D, 0.5D, 0D, 0D) ? 1 : 0;
changedPonds += pond != BedProfile.pondDepth(8, 0.5D, 0D, 0D) ? 1 : 0;
assertTrue(channel == 4 || channel == 5);
assertTrue(pond == 4 || pond == 5);
}
}
assertTrue(changedChannels > 1000);
assertTrue(changedPonds > 1000);
}
@Test
public void depthLimitsAndShallowEdgesSurviveFineDetail() {
for (int maximum : new int[]{1, 2, 6, 8, 16, 64}) {
for (double variation : new double[]{-1D, -0.5D, 0D, 0.5D, 1D}) {
for (double detail : new double[]{-0.35D, 0D, 0.35D}) {
assertEquals(1, BedProfile.channelDepth(maximum, 1D, variation, detail));
assertEquals(1, BedProfile.pondDepth(maximum, 0D, variation, detail));
for (int step = 0; step <= 100; step++) {
double position = step / 100D;
int channel = BedProfile.channelDepth(maximum, position, variation, detail);
int pond = BedProfile.pondDepth(maximum, position, variation, detail);
assertTrue(channel >= 1 && channel <= maximum);
assertTrue(pond >= 1 && pond <= maximum);
assertTrue(Math.abs(channel - BedProfile.channelDepth(maximum, position, variation, 0D)) <= 1);
assertTrue(Math.abs(pond - BedProfile.pondDepth(maximum, position, variation, 0D)) <= 1);
}
}
}
}
}
@Test
public void detailUsesWorldCoordinatesAcrossNegativeChunkBoundaries() {
double[][] expected = new double[66][66];
for (int x = -33; x <= 32; x++) {
for (int z = -33; z <= 32; z++) {
expected[x + 33][z + 33] = BedProfile.detail(1337L, x, z);
}
}
int differentSeeds = 0;
for (int chunkX = 2; chunkX >= -3; chunkX--) {
for (int chunkZ = 2; chunkZ >= -3; chunkZ--) {
for (int localX = 16; localX >= -1; localX--) {
for (int localZ = 16; localZ >= -1; localZ--) {
int x = (chunkX << 4) + localX;
int z = (chunkZ << 4) + localZ;
if (x < -33 || x > 32 || z < -33 || z > 32) {
continue;
}
double detail = BedProfile.detail(1337L, x, z);
assertEquals(expected[x + 33][z + 33], detail, 0D);
differentSeeds += detail != BedProfile.detail(1338L, x, z) ? 1 : 0;
}
}
}
}
assertTrue(differentSeeds > 4000);
}
}
@@ -47,6 +47,41 @@ public class ChunkWaterRasterTest {
private static final float SPACING = 2f;
private static final int GENTLE_STEP = 3;
@Test
public void flatLakesSkipExtraTerrainSamplingForRiffles() {
AtomicInteger samples = new AtomicInteger();
Scene scene = new Scene(settings(new IrisHydrology()), (x, z) -> {
samples.incrementAndGet();
return 70;
}).lake(pond(8L, PondKind.LAKE, 80, circle(8, 8, 100, 1), 8, 8, 0));
assertEquals(80, scene.raster(0, 0).waterHead(8, 8));
assertEquals(320, samples.get());
}
@Test
public void shallowStandingWaterRetainsItsFootprintWhenItsHeadLowersOneBlock() {
int desiredLevel = 75;
Pond water = pond(9L, PondKind.NATURAL, desiredLevel, circle(-16, -16, 12, 1), -16, -16, 0);
HydrologySettings settings = settings(new IrisHydrology());
for (Scene scene : List.of(new Scene(settings, (x, z) -> desiredLevel - 1).pond(water),
new Scene(settings, (x, z) -> desiredLevel - 1).lake(water))) {
int wet = 0;
for (int x = -32; x <= 0; x++) {
for (int z = -32; z <= 0; z++) {
if (!water.mask().contains(x, z)) {
continue;
}
wet++;
assertEquals("head at " + x + "," + z, desiredLevel - 1, scene.head(x, z));
assertTrue("bed at " + x + "," + z, scene.terrain(x, z) < scene.head(x, z));
}
}
assertTrue("wet footprint " + wet, wet > 400);
assertEquals(0, scene.leaks(-32, -32, 0, 0));
}
}
@Test
public void channelBedBelowWaterHead() {
IntBinaryOperator natural = (x, z) -> x >= 40 && x < 50 ? 82 : 90 + (int) Math.round(2D * Math.sin(x / 9D));
@@ -62,15 +97,15 @@ public class ChunkWaterRasterTest {
channel++;
int bed = scene.terrain(x, z);
int ground = natural.applyAsInt(x, z);
assertEquals(87, scene.head(x, z));
assertTrue("bed " + bed + " below the water head at " + x + "," + z, bed < 87);
assertEquals(86, scene.head(x, z));
assertTrue("bed " + bed + " below the water head at " + x + "," + z, bed < 86);
assertTrue("bed never above natural at " + x + "," + z, bed <= ground);
if (ground < 84) {
pooled++;
assertEquals("a depression under the channel keeps its natural floor at " + x + "," + z, ground, bed);
}
}
assertTrue("full depth on the centre line at x=" + x, 87 - scene.terrain(x, 8) >= 3);
assertTrue("the centre line keeps a submerged bed at x=" + x, 86 - scene.terrain(x, 8) >= 2);
}
assertTrue("channel columns " + channel, channel > 900);
assertTrue("pooled columns " + pooled, pooled > 50);
@@ -99,6 +134,69 @@ public class ChunkWaterRasterTest {
assertEquals("far columns sit at the cap", ChunkWaterRaster.MAX_SHORE_DISTANCE, scene.shore(0, -40));
}
@Test
public void channelBedsAndBanksVaryAlongConstantWidthReaches() {
IrisHydrology config = new IrisHydrology();
config.getDeformation().setBankNoise(0D);
HydrologySettings settings = settings(config);
HydrologyTier river = tier("river", 8f, 0.75f, 24, IrisBankStyle.SMOOTH);
Reach reach = line(15L, river, 0L, -112f, 0f, 112f, 0f, (x, z) -> 100);
Scene scene = new Scene(settings, (x, z) -> 120).reach(reach);
Set<Integer> bedHeights = new HashSet<>();
Set<Integer> bankHeights = new HashSet<>();
int previousBed = scene.terrain(-80, 4);
int previousBank = scene.terrain(-80, 20);
for (int x = -80; x <= 80; x++) {
int bed = scene.terrain(x, 4);
int bank = scene.terrain(x, 20);
bedHeights.add(bed);
bankHeights.add(bank);
assertEquals(100, scene.head(x, 4));
assertEquals(Integer.MIN_VALUE, scene.head(x, 20));
assertTrue("depth stays within the tier limit", bed >= 94 && bed < 100);
assertTrue("bank stays between the water and terrain", bank >= 100 && bank <= 120);
assertTrue("bed variation remains coherent across chunk boundaries", Math.abs(bed - previousBed) <= 1);
assertTrue("bank variation remains coherent across chunk boundaries", Math.abs(bank - previousBank) <= 1);
previousBed = bed;
previousBank = bank;
}
assertTrue("constant-width bed has varying contours", bedHeights.size() >= 3);
assertTrue("constant-distance bank has varying contours", bankHeights.size() >= 3);
}
@Test
public void pondShelvesAndTapersVaryWithoutChangingWaterCoverage() {
HydrologySettings settings = settings(new IrisHydrology());
long[] bits = new long[192 * 64 / 64];
Arrays.fill(bits, -1L);
Pond water = pond(16L, PondKind.NATURAL, 100, new PondMask(-96, -32, 1, 192, 64, bits), 0, 0, 0);
Scene scene = new Scene(settings, (x, z) -> 110).pond(water);
Set<Integer> shelfHeights = new HashSet<>();
Set<Integer> deepHeights = new HashSet<>();
Set<Integer> taperHeights = new HashSet<>();
for (int x = -64; x <= 64; x++) {
shelfHeights.add(scene.terrain(x, -28));
deepHeights.add(scene.terrain(x, 0));
taperHeights.add(scene.terrain(x, -40));
for (int z = -48; z <= 47; z++) {
assertEquals("the shoreline remains fixed", water.mask().contains(x, z), scene.water(x, z));
if (scene.water(x, z)) {
assertEquals(100, scene.head(x, z));
assertTrue("pond depth remains bounded", scene.terrain(x, z) >= 92 && scene.terrain(x, z) < 100);
} else {
assertTrue("dry taper stays above water", scene.terrain(x, z) >= 100);
}
}
}
assertTrue("shelf contours vary along a straight shore", shelfHeights.size() >= 3);
assertTrue("deep floor has relief", deepHeights.size() >= 2);
assertTrue("dry taper varies along a straight shore", taperHeights.size() >= 2);
Scene reverse = new Scene(settings, (x, z) -> 110).pond(water);
for (int x = 64; x >= -64; x--) {
assertEquals("generation order leaves the floor unchanged", scene.terrain(x, -28), reverse.terrain(x, -28));
}
}
@Test
public void lakeRimShoreDistanceMatchesNearestLakeWater() {
IntBinaryOperator natural = (x, z) -> 60 + (int) (Math.hypot(x - 24, z - 24) / 2D);
@@ -189,14 +287,14 @@ public class ChunkWaterRasterTest {
wet++;
chunks.add(pack(Math.floorDiv(x, 16), Math.floorDiv(z, 16)));
assertEquals(pond.id(), scene.feature(x, z));
assertEquals("pond level at " + x + "," + z, 75, scene.head(x, z));
assertTrue("pond floor below the level at " + x + "," + z, scene.terrain(x, z) < 75);
assertEquals("pond level at " + x + "," + z, 74, scene.head(x, z));
assertTrue("pond floor below the level at " + x + "," + z, scene.terrain(x, z) < 74);
assertEquals("water", scene.fluid(x, z));
}
}
assertTrue("pond columns " + wet, wet > 400);
assertEquals("the pond spans four chunks", 4, chunks.size());
assertEquals("open water reaches full depth", 75 - 8, scene.terrain(16, 16));
assertTrue("open water stays within the maximum depth", scene.terrain(16, 16) >= 74 - 8 && scene.terrain(16, 16) < 74);
assertEquals(WaterRole.POND_SHORE, scene.role(16, 30));
}
@@ -206,7 +304,7 @@ public class ChunkWaterRasterTest {
float startZ = -20f;
float endX = 80f;
float endZ = 60f;
IntBinaryOperator natural = (x, z) -> 80 + (int) Math.floor(0.35D * perpendicular(x, z, startX, startZ, endX, endZ));
IntBinaryOperator natural = (x, z) -> 79 + (int) Math.floor(0.35D * perpendicular(x, z, startX, startZ, endX, endZ));
HydrologyTier river = tier("river", 3f, 0.5f, 12, IrisBankStyle.SMOOTH);
Scene scene = new Scene(settings(new IrisHydrology()), natural).reach(line(31L, river, 0L, startX, startZ, endX, endZ, (x, z) -> 79));
int interior = 0;
@@ -253,12 +351,12 @@ public class ChunkWaterRasterTest {
.reach(line(44L, stream, 0L, 8f, 8f, 56f, 8f, (x, z) -> Math.clamp(eastSlope.applyAsInt((int) x, (int) z) - 1, 94, 99)));
assertContained("chained ponds", chained, -16, -8, 80, 24, true);
IntBinaryOperator cliff = (x, z) -> (x < 24 ? 100 : 80) - Math.max(0, z - 8);
IntBinaryOperator cliff = (x, z) -> (x < 24 ? 99 : 79) - Math.max(0, z - 8);
Scene waterfall = new Scene(settings, cliff).reach(line(45L, stream, 0L, -24f, 8f, 72f, 8f, (x, z) -> x < 24 ? 99 : 79));
assertContained("waterfall", waterfall, -16, -8, 64, 24, true);
assertEquals(WaterRole.WATERFALL, waterfall.role(24, 8));
assertEquals(99, waterfall.head(22, 8));
assertEquals(79, waterfall.head(24, 8));
assertEquals(98, waterfall.head(22, 8));
assertEquals(78, waterfall.head(24, 8));
}
@Test
@@ -455,7 +553,7 @@ public class ChunkWaterRasterTest {
IrisHydrology config = new IrisHydrology();
config.getDeformation().setBankSlope(0D).setBankSteepFactor(0D).setBankNoise(0D);
HydrologySettings settings = settings(config);
IntBinaryOperator natural = (x, z) -> z >= 8 ? 70 + (z - 8) : 68 - (8 - z) / 4;
IntBinaryOperator natural = (x, z) -> z >= 8 ? 69 + (z - 8) : 67 - (8 - z) / 4;
Map<IrisBankStyle, int[]> cuts = new EnumMap<>(IrisBankStyle.class);
for (IrisBankStyle style : IrisBankStyle.values()) {
Scene scene = new Scene(settings, natural).reach(line(90L + style.ordinal(), tier("river", 2f, 0.5f, 16, style), 0L, -48f, 8f, 64f, 8f,
@@ -466,7 +564,7 @@ public class ChunkWaterRasterTest {
int height = scene.terrain(8, z);
assertFalse(style + " bank is dry at z=" + z, scene.water(8, z));
assertTrue(style + " bank role at z=" + z, scene.role(8, z) == WaterRole.BANK || scene.role(8, z) == WaterRole.SHORE);
assertTrue(style + " bank between water and natural at z=" + z + ": " + height, height >= 69 && height <= natural.applyAsInt(8, z));
assertTrue(style + " bank between water and natural at z=" + z + ": " + height, height >= 68 && height <= natural.applyAsInt(8, z));
assertTrue(style + " bank never falls toward the ground at z=" + z, outward == 1 || height >= cut[outward - 2]);
cut[outward - 1] = height;
int levee = scene.terrain(8, 6 - outward);
@@ -475,7 +573,7 @@ public class ChunkWaterRasterTest {
levee >= ground && levee <= ground + settings.deformation().maxFill());
}
assertEquals(style + " meets natural at the band edge", natural.applyAsInt(8, 26), cut[15]);
assertTrue(style + " levee holds the water", scene.terrain(8, 5) >= 70);
assertTrue(style + " levee holds the water", scene.terrain(8, 5) >= 69);
assertEquals(style + " levee meets natural at the band edge", natural.applyAsInt(8, -10), scene.terrain(8, -10));
assertEquals(style + " leaks", 0, scene.leaks(-16, -16, 32, 32));
cuts.put(style, cut);
@@ -494,16 +592,16 @@ public class ChunkWaterRasterTest {
assertTrue(ordered);
int[] cliff = cuts.get(IrisBankStyle.CLIFF);
for (int index = 0; index < 8; index++) {
assertEquals("cliff bench at outward " + (index + 1), 69, cliff[index]);
assertEquals("cliff bench at outward " + (index + 1), 68, cliff[index]);
}
assertEquals("cliff wall reaches natural", natural.applyAsInt(8, 22), cliff[11]);
int[] terraced = cuts.get(IrisBankStyle.TERRACED);
boolean level = false;
for (int index = 0; index < 15; index++) {
if (terraced[index] < natural.applyAsInt(8, 11 + index)) {
assertEquals("terrace step height at outward " + (index + 1), 0, (terraced[index] - 69) % 3);
assertEquals("terrace step height at outward " + (index + 1), 0, (terraced[index] - 68) % 3);
}
level |= terraced[index] > 69 && terraced[index] == terraced[index + 1];
level |= terraced[index] > 68 && terraced[index] == terraced[index + 1];
}
assertTrue("terraces hold level steps", level);
List<IrisBankStyle> styles = List.of(IrisBankStyle.values());
@@ -521,13 +619,13 @@ public class ChunkWaterRasterTest {
int maxFill = settings.deformation().maxFill();
HydrologyTier stream = tier("stream", 1.5f, 0.5f, 0, IrisBankStyle.SMOOTH);
Reach perchedReach = line(101L, stream, 0L, -32f, 8f, 48f, 8f, (x, z) -> 79);
IntBinaryOperator slope = (x, z) -> 80 - Math.max(0, z - 8);
IntBinaryOperator slope = (x, z) -> 79 - Math.max(0, z - 8);
Scene scene = new Scene(settings, slope).reach(perchedReach);
for (int x = -24; x < 40; x++) {
assertTrue("channel water at x=" + x, scene.water(x, 9));
assertFalse("dry downhill column at x=" + x, scene.water(x, 10));
assertEquals("natural sits below the water at x=" + x, 78, slope.applyAsInt(x, 10));
assertEquals("berm one block above the water at x=" + x, 80, scene.terrain(x, 10));
assertEquals("natural sits below the water at x=" + x, 77, slope.applyAsInt(x, 10));
assertEquals("berm one block above the water at x=" + x, 79, scene.terrain(x, 10));
assertEquals(WaterRole.SHORE, scene.role(x, 10));
assertEquals(perchedReach.id(), scene.feature(x, 10));
assertEquals(stream, scene.tier(x, 10));
@@ -536,11 +634,11 @@ public class ChunkWaterRasterTest {
assertEquals(0, scene.leaks(-24, -8, 40, 24));
Scene steep = new Scene(settings, (x, z) -> z > 9 ? 60 : 80).reach(perchedReach);
assertEquals("past the fill limit the guard berms to the water itself", 79, steep.terrain(0, 10));
assertEquals("past the fill limit the guard berms to the water itself", 78, steep.terrain(0, 10));
assertTrue("the guard rises past the fill limit", steep.terrain(0, 10) > 60 + maxFill);
assertEquals(0, steep.leaks(-24, -8, 40, 24));
Scene below = new Scene(settings, (x, z) -> z > 9 ? 30 : 80).reach(perchedReach);
assertEquals("a column under the sea surface is bermed when the water stands above it", 79, below.terrain(0, 10));
assertEquals("a column under the sea surface is bermed when the water stands above it", 78, below.terrain(0, 10));
}
@Test
@@ -559,7 +657,7 @@ public class ChunkWaterRasterTest {
}
dams++;
assertEquals(dammed.id(), scene.feature(x, z));
assertEquals("dam crest one block above the level at " + x + "," + z, 96, scene.terrain(x, z));
assertEquals("dam crest one block above the level at " + x + "," + z, 95, scene.terrain(x, z));
if (scene.terrain(x, z) - slope.applyAsInt(x, z) > maxFill) {
tall++;
}
@@ -596,7 +694,7 @@ public class ChunkWaterRasterTest {
drawn.catchment());
Scene scene = new Scene(settings(new IrisHydrology()), coast).reach(coastal);
assertEquals("the lip carries the fall", WaterRole.WATERFALL, scene.role(38, 8));
assertEquals(59, scene.head(38, 8));
assertEquals(58, scene.head(38, 8));
int mouths = 0;
for (int x = -20; x <= 56; x++) {
for (int z = 0; z <= 16; z++) {
@@ -0,0 +1,128 @@
package art.arcane.iris.generation.hydrology.raster;
import art.arcane.iris.generation.hydrology.config.IrisBankStyle;
import art.arcane.iris.generation.hydrology.water.HydrologyTier;
import art.arcane.iris.generation.hydrology.water.Pond;
import art.arcane.iris.generation.hydrology.water.PondKind;
import art.arcane.iris.generation.hydrology.water.PondMask;
import art.arcane.iris.generation.hydrology.water.Reach;
import art.arcane.iris.generation.hydrology.water.ReachStyle;
import org.junit.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
public class PondChannelSettlementTest {
private static final int SEA_LEVEL = 40;
@Test
public void aLowerChannelDrainsThePondWhereOnlyItsWidthOverlaps() {
for (int offset : new int[]{-512, -19, 0, 513}) {
Pond pond = pond(1L, PondKind.CHAINED, 100, circle(offset, offset, 8));
Reach river = line(2L, offset + 12, offset - 20, offset + 12, offset + 20, 5f, 95, 95);
assertEquals(94, settle(List.of(river), pond));
}
}
@Test
public void cardinalWaterContactDrainsButDiagonalProximityDoesNot() {
Pond pond = pond(1L, PondKind.NATURAL, 100, circle(0, 0, 0));
Reach cardinal = line(2L, 1, 0, 1, 1, 0.25f, 94, 94);
Reach diagonal = line(2L, 1, 1, 1, 2, 0.25f, 94, 94);
assertEquals(94, settle(List.of(cardinal), pond));
assertEquals(100, settle(List.of(diagonal), pond));
}
@Test
public void drySeparationHigherChannelsAndFixedLakesKeepTheirLevels() {
PondMask mask = circle(0, 0, 8);
Pond pond = pond(1L, PondKind.NATURAL, 100, mask);
Pond lake = pond(1L, PondKind.LAKE, 100, mask);
Reach separate = line(2L, 15, -20, 15, 20, 4f, 94, 94);
Reach higher = line(2L, 0, -20, 0, 20, 4f, 104, 104);
Reach lower = line(2L, 0, -20, 0, 20, 4f, 94, 94);
assertEquals(100, settle(List.of(separate), pond));
assertEquals(100, settle(List.of(higher), pond));
assertEquals(100, settle(List.of(lower), lake));
}
@Test
public void equalCanonicalHeadsDoNotTriggerAnotherReflood() {
Pond pond = pond(1L, PondKind.CHAINED, 101, circle(0, 0, 8));
Reach river = line(2L, 0, -20, 0, 20, 4f, 100, 100);
assertEquals(101, settle(List.of(river), pond));
}
@Test
public void nearerChannelsAndIdentityTiesMatchTheRasterWinner() {
Pond pond = pond(1L, PondKind.CHAINED, 100, circle(0, 0, 4));
Reach lower = line(4L, 2, -20, 2, 20, 8f, 94, 94);
Reach nearer = line(3L, 0, -20, 0, 20, 8f, 100, 100);
Pond single = pond(2L, PondKind.NATURAL, 100, circle(0, 0, 0));
Reach tied = line(3L, 2, -20, 2, 20, 8f, 100, 100);
assertEquals(100, settle(List.of(lower, nearer), single));
assertEquals(100, settle(List.of(lower, tied), pond));
assertEquals(100, settle(List.of(tied, lower), pond));
}
@Test
public void projectedStationChoosesOnlyTheTouchingSideOfADrop() {
Reach river = line(2L, -20, 0, 20, 0, 2f, 100, 94);
assertEquals(100, settle(List.of(river), pond(1L, PondKind.NATURAL, 100, circle(-5, 0, 0))));
assertEquals(94, settle(List.of(river), pond(1L, PondKind.NATURAL, 100, circle(5, 0, 0))));
}
@Test
public void adjacentPondsSettleIndependentlyOfReachAndPondOrder() {
Pond first = pond(1L, PondKind.CHAINED, 104, circle(0, 0, 8));
Pond second = pond(2L, PondKind.CHAINED, 102, circle(17, 0, 8));
Reach across = line(3L, -20, 0, 40, 0, 4f, 96, 96);
Reach lower = line(4L, 17, -20, 17, 20, 4f, 94, 94);
assertArrayEquals(new int[]{96, 94}, PondChannelSettlement.levels(List.of(across, lower), List.of(first, second),
new int[]{104, 102}, SEA_LEVEL));
assertArrayEquals(new int[]{94, 96}, PondChannelSettlement.levels(List.of(lower, across), List.of(second, first),
new int[]{102, 104}, SEA_LEVEL));
}
private static int settle(List<Reach> reaches, Pond pond) {
return PondChannelSettlement.levels(reaches, List.of(pond), new int[]{pond.level()}, SEA_LEVEL)[0];
}
private static Pond pond(long id, PondKind kind, int level, PondMask mask) {
return new Pond(id, kind, level, 90, mask.originX(), mask.originZ(), level + 1f, mask, 0L, 0, "water",
mask.originX(), mask.originZ());
}
private static PondMask circle(int x, int z, int radius) {
int size = 2 * radius + 1;
long[] bits = new long[(size * size + 63) >>> 6];
for (int dz = -radius; dz <= radius; dz++) {
for (int dx = -radius; dx <= radius; dx++) {
if (dx * dx + dz * dz <= radius * radius) {
int cell = (dz + radius) * size + dx + radius;
bits[cell >>> 6] |= 1L << cell;
}
}
}
return new PondMask(x - radius, z - radius, 1, size, size, bits);
}
private static Reach line(long id, int x0, int z0, int x1, int z1, float width, int first, int last) {
HydrologyTier tier = new HydrologyTier("river", 0f, width, width, 0f, 0.5f, 2, true, 6, 0, IrisBankStyle.SMOOTH,
false, null, "water", List.of());
float[] widths = new float[]{width, width};
float[] depth = new float[]{2f, 2f};
ReachStyle[] styles = new ReachStyle[2];
Arrays.fill(styles, ReachStyle.RUN);
return new Reach(id, tier, 0L, new float[]{x0, x1}, new float[]{z0, z1}, new int[]{first, last}, widths, depth, styles, 1000f);
}
}
@@ -0,0 +1,135 @@
package art.arcane.iris.generation.hydrology.raster;
import org.junit.Test;
import java.util.HashMap;
import java.util.Map;
import java.util.function.IntBinaryOperator;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class RiffleProfileTest {
private static final int DRY = Integer.MIN_VALUE;
@Test
public void flatPoolsRetainEveryBedDepth() {
IntBinaryOperator heads = (x, z) -> 100;
Surface surface = new Surface(heads);
for (int x = -32; x <= 32; x++) {
for (int z = -16; z <= 16; z++) {
int bed = 64 + Math.floorMod(x + z, 30);
assertEquals(bed, surface.bed(x, z, bed));
}
}
}
@Test
public void twoBlockDropsHaveGradedApproachesAndNoTallSubmergedFace() {
Surface surface = new Surface((x, z) -> x < 0 ? 100 : 98);
for (int z = -24; z <= 24; z++) {
assertEquals(99, surface.bed(-1, z, 92));
assertEquals(97, surface.bed(0, z, 92));
for (int x = -24; x <= 24; x++) {
int bed = surface.bed(x, z, 92);
int next = surface.bed(x + 1, z, 92);
assertTrue(x + "," + z + " bed " + bed, bed < (x < 0 ? 100 : 98));
int headDrop = x == -1 ? 2 : 0;
assertTrue(x + "," + z + " face " + bed + " to " + next, Math.abs(bed - next) <= Math.max(1, headDrop));
}
assertEquals(92, surface.bed(-18, z, 92));
assertEquals(92, surface.bed(17, z, 92));
}
}
@Test
public void deeperBedsFadeWithoutACliffAtTheRadiusBoundary() {
Surface surface = new Surface((x, z) -> x < 0 ? 100 : 98);
for (int z = -8; z <= 8; z++) {
for (int x : new int[]{-18, -17, -16, 14, 15, 16, 17}) {
assertTrue(x + "," + z, Math.abs(surface.bed(x, z, 64) - surface.bed(x + 1, z, 64)) <= 1);
}
assertEquals(64, surface.bed(-18, z, 64));
assertEquals(64, surface.bed(17, z, 64));
assertEquals(99, surface.bed(-1, z, 64));
}
}
@Test
public void lowerPoolsRecoverTheirDepthWithoutAWideFlatShelf() {
Surface surface = new Surface((x, z) -> x < 0 ? 104 : 100);
for (int z = -8; z <= 8; z++) {
assertEquals(103, surface.bed(-1, z, 96));
assertEquals(99, surface.bed(0, z, 96));
assertTrue(surface.bed(3, z, 96) < 99);
assertEquals(96, surface.bed(6, z, 96));
assertTrue(surface.bed(-6, z, 96) > 96);
}
}
@Test
public void nearbyDisconnectedPoolsKeepTheirDepth() {
Surface surface = new Surface((x, z) -> z == 0 ? DRY : z > 0 ? 100 : x < 0 ? 100 : 98);
for (int x = -24; x <= 24; x++) {
assertEquals(92, surface.bed(x, 1, 92));
}
assertEquals(99, surface.bed(-1, -1, 92));
}
@Test
public void aNearerLowerDropNeverCutsOffAHigherRiffle() {
Surface mixed = new Surface((x, z) -> x < 0 ? 104 : z < 0 ? 102 : 100);
Surface high = new Surface((x, z) -> x < 0 ? 104 : 100);
Surface low = new Surface((x, z) -> z < 0 ? 102 : 100);
for (int x = 0; x <= 20; x++) {
for (int z = 0; z <= 20; z++) {
int bed = mixed.bed(x, z, 94);
assertTrue(x + "," + z + " lost the higher influence", bed >= high.bed(x, z, 94));
assertTrue(x + "," + z + " lost the lower influence", bed >= low.bed(x, z, 94));
assertTrue(x + "," + z + " east face", Math.abs(bed - mixed.bed(x + 1, z, 94)) <= 1);
assertTrue(x + "," + z + " south face", Math.abs(bed - mixed.bed(x, z + 1, 94)) <= 1);
}
}
}
@Test
public void diagonalJoinsHaveTheSameProfileAcrossChunkBoundaries() {
IntBinaryOperator heads = (x, z) -> x + z < -7 ? 100 : 98;
for (int chunkX = -2; chunkX <= 1; chunkX++) {
for (int chunkZ = -2; chunkZ <= 1; chunkZ++) {
RiffleProfile first = new RiffleProfile(chunkX, chunkZ, heads);
RiffleProfile east = new RiffleProfile(chunkX + 1, chunkZ, heads);
RiffleProfile south = new RiffleProfile(chunkX, chunkZ + 1, heads);
for (int edge = 0; edge < 16; edge++) {
int x = (chunkX + 1) << 4;
int z = (chunkZ << 4) + edge;
double variation = BedProfile.variation(1337L, x, z);
assertEquals(first.bed(16, edge, 92, heads.applyAsInt(x, z), variation),
east.bed(0, edge, 92, heads.applyAsInt(x, z), variation));
x = (chunkX << 4) + edge;
z = (chunkZ + 1) << 4;
variation = BedProfile.variation(1337L, x, z);
assertEquals(first.bed(edge, 16, 92, heads.applyAsInt(x, z), variation),
south.bed(edge, 0, 92, heads.applyAsInt(x, z), variation));
}
}
}
}
private static final class Surface {
private final IntBinaryOperator heads;
private final Map<Long, RiffleProfile> chunks = new HashMap<>();
private Surface(IntBinaryOperator heads) {
this.heads = heads;
}
private int bed(int x, int z, int original) {
int cx = x >> 4;
int cz = z >> 4;
long key = ((long) cx << 32) | (cz & 0xFFFFFFFFL);
RiffleProfile profile = chunks.computeIfAbsent(key, ignored -> new RiffleProfile(cx, cz, heads));
return profile.bed(x & 15, z & 15, original, heads.applyAsInt(x, z), BedProfile.variation(1337L, x, z));
}
}
}
@@ -35,6 +35,7 @@ import java.util.function.DoubleBinaryOperator;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -177,6 +178,51 @@ public class PondPlannerTest {
assertNull(planner.reflood(sampled.grid(), fine, cups, drained));
}
@Test
public void refloodingBlockMasksKeepsADryFineLatticeMargin() {
HydrologySettings settings = settings(config -> config.getPonds().getShore().setRoundness(0D), MIN_Y, MAX_Y);
int[][] origins = {{0, 0}, {-533, -303}, {529, -301}};
for (int[] origin : origins) {
int x = origin[0];
int z = origin[1];
DoubleBinaryOperator height = (px, pz) -> 100D + 2D * Math.max(Math.abs(px - x - 4), Math.abs(pz - z - 4));
Terrain terrain = new Terrain(height, 1f, ALLOW_ALL, null, MIN_Y, MAX_Y);
HeightLattice fine = new HeightLattice(terrain, 4, false, 4096);
BitSet wet = new BitSet(81);
wet.set(0, 81);
PondMask mask = new PondMask(x, z, 1, 9, 9, wet.toLongArray());
Pond pond = new Pond(1L, PondKind.NATURAL, 109, 100, x + 12, z + 4, 112f, mask, 7L, 0, "water", x + 4, z + 4);
DemGrid raw = new DemGrid((Math.floorDiv(x, 8) - 1) * 8, (Math.floorDiv(z, 8) - 1) * 8, 8, 6, 6);
Pond reflooded = new PondPlanner(settings).reflood(raw, fine, CupSet.EMPTY, pond);
assertNotNull("reflooded pond at " + x + "," + z, reflooded);
assertEquals(pond.level(), reflooded.level());
assertEquals(pond.outletReachId(), reflooded.outletReachId());
assertTrue(reflooded.mask().contains(x + 4, z + 4));
for (int[] node : wetNodes(reflooded.mask())) {
assertTrue("reflood remains within its previous mask", mask.contains(node[0], node[1]));
}
assertContained(reflooded, height);
}
}
@Test
public void refloodingRejectsWetRimsClippedByThePlanningGrid() {
HydrologySettings settings = settings(config -> config.getPonds().getShore().setRoundness(0D), MIN_Y, MAX_Y);
Terrain terrain = new Terrain((x, z) -> x >= 0 && x <= 8 && z >= 0 && z <= 8 ? 100D : 112D,
1f, ALLOW_ALL, null, MIN_Y, MAX_Y);
BitSet wet = new BitSet(81);
wet.set(0, 81);
Pond pond = new Pond(1L, PondKind.NATURAL, 109, 100, 12, 4, 112f, new PondMask(0, 0, 1, 9, 9, wet.toLongArray()),
0L, 0, "water", 4, 4);
Pond reflooded = new PondPlanner(settings).reflood(new DemGrid(0, 0, 8, 2, 2),
new HeightLattice(terrain, 4, false, 4096), CupSet.EMPTY, pond);
assertNull(reflooded);
}
@Test
public void seededCupOnSlopeIsPerchedAndContained() {
HydrologySettings settings = settings(config -> {
@@ -5,6 +5,7 @@ import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.water.Pond;
import art.arcane.iris.generation.hydrology.water.Reach;
import art.arcane.iris.generation.hydrology.water.ReachStyle;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.mantle.MantleHydrologyComponent;
import art.arcane.iris.generation.mantle.MantleWriter;
@@ -84,23 +85,18 @@ public class EffectiveWaterTest {
}
@Test
public void cornerColumnsBelowTwoOneBlockHigherNeighboursBecomeRimsAtTheUpperHead() {
long corner = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(80), wet(80), wet(79), NONE);
assertEquals(DRY, EffectiveWater.head(corner));
assertEquals(80, EffectiveWater.ground(corner));
public void lowerCornerColumnsRemainWetBetweenHigherNeighbours() {
long corner = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(81), wet(81), wet(79), NONE);
assertEquals(EffectiveWater.column(77, 79), corner);
long mixed = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(80), wet(80), wet(86), NONE);
assertEquals(DRY, EffectiveWater.head(mixed));
assertEquals(86, EffectiveWater.ground(mixed));
long edge = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(80), wet(79), NONE, NONE);
assertEquals(EffectiveWater.column(77, 79), edge);
long mixed = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(81), wet(81), wet(87), NONE);
assertEquals(EffectiveWater.column(77, 79), mixed);
}
@Test
public void columnsAboveADropOfTwoOrMoreHoldWaterOnlyInTheirTopBlock() {
assertEquals(EffectiveWater.column(98, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), wet(79), wet(99), NONE, NONE));
assertEquals(EffectiveWater.column(98, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), NONE, wet(97), NONE, NONE));
public void wetColumnsKeepTheirCanonicalBedAcrossDrops() {
assertEquals(EffectiveWater.column(94, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), wet(79), wet(99), NONE, NONE));
assertEquals(EffectiveWater.column(94, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), NONE, wet(97), NONE, NONE));
assertEquals(EffectiveWater.column(94, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), wet(98), NONE, NONE, NONE));
assertEquals(EffectiveWater.column(98, 99), EffectiveWater.settle(EffectiveWater.column(98, 99), wet(70), NONE, NONE, NONE));
}
@@ -197,33 +193,27 @@ public class EffectiveWaterTest {
}
@Test(timeout = 180_000L)
public void staircaseStepsTurnExactlyTheirCornerColumnsIntoRims() throws Exception {
public void diagonalStaircaseStepsPreserveEveryWetColumnWithoutSourceConversion() throws Exception {
Scene scene = staircase();
try (HydrologyFixture fixture = fixture(); SceneRun run = scene.install(fixture)) {
Drops drops = new Drops(run.complex(), 0, 7, 0, 7);
int rims = 0;
int wet = 0;
for (int x = 24; x <= 104; x++) {
for (int z = 24; z <= 104; z++) {
int head = rasterHead(run.hydrology(), x, z);
if (head == DRY) {
continue;
}
int above = 0;
int highest = DRY;
wet++;
assertEquals("water head at " + x + "," + z, head, drops.head(x, z));
assertTrue("submerged bed at " + x + "," + z, drops.ground(x, z) < head);
for (int[] side : SIDES) {
int neighbour = rasterHead(run.hydrology(), x + side[0], z + side[1]);
above += neighbour == head + 1 ? 1 : 0;
highest = Math.max(highest, neighbour);
}
boolean rim = above >= 2;
assertEquals("rim at " + x + "," + z, rim, drops.head(x, z) == DRY);
if (rim) {
rims++;
assertEquals("rim ground at " + x + "," + z, highest, drops.ground(x, z));
assertFalse("one-block drop at " + x + "," + z, neighbour != DRY && Math.abs(neighbour - head) == 1);
}
}
}
assertTrue("rims " + rims, rims > 10);
assertTrue("wet columns " + wet, wet > 200);
drops.assertSealed(24, 104, 24, 104);
}
}
@@ -234,7 +224,6 @@ public class EffectiveWaterTest {
try (HydrologyFixture fixture = fixture(); SceneRun run = scene.install(fixture)) {
Drops drops = new Drops(run.complex(), 0, 7, 0, 5);
int lips = 0;
int raised = 0;
int falls = 0;
for (int x = 16; x <= 112; x++) {
for (int z = 30; z <= 50; z++) {
@@ -250,17 +239,22 @@ public class EffectiveWaterTest {
if (lip) {
lips++;
assertEquals("lip ground at " + x + "," + z, head - 1, drops.ground(x, z));
raised += rasterGround(run.hydrology(), x, z) < head - 1 ? 1 : 0;
assertEquals("canonical lip at " + x + "," + z, rasterGround(run.hydrology(), x, z), drops.ground(x, z));
}
if (drops.falling(x, head + 1, z)) {
falls++;
assertTrue(drops.falling(x, head + 3, z));
assertFalse(drops.falling(x, head + 4, z));
int highest = head;
for (int[] side : SIDES) {
highest = Math.max(highest, drops.head(x + side[0], z + side[1]));
}
for (int y = head + 1; y <= highest; y++) {
assertTrue("fall column at " + x + "," + y + "," + z, drops.falling(x, y, z));
}
assertFalse(drops.falling(x, highest + 1, z));
}
}
}
assertTrue("lips " + lips, lips > 10);
assertTrue("lips raised from deeper beds " + raised, raised > 0);
assertTrue("falls " + falls, falls > 10);
drops.assertSealed(16, 112, 30, 50);
}
@@ -268,11 +262,24 @@ public class EffectiveWaterTest {
@Test(timeout = 180_000L)
public void noFallAnywhereMeetsTheSourceConversionCondition() throws Exception {
for (Scene scene : List.of(staircase(), cascade(), outlet())) {
for (Scene scene : List.of(staircase(), cascade(), outlet(), mouth())) {
try (HydrologyFixture fixture = fixture(); SceneRun run = scene.install(fixture)) {
Drops drops = new Drops(run.complex(), 0, 7, 0, 7);
int wet = 0;
for (int x = 8; x <= 120; x++) {
for (int z = 8; z <= 120; z++) {
int head = rasterHead(run.hydrology(), x, z);
if (head != DRY) {
wet++;
assertEquals(scene.name + " water stays connected at " + x + "," + z, head, drops.head(x, z));
assertTrue(scene.name + " bed stays submerged at " + x + "," + z, drops.ground(x, z) < head);
}
}
}
assertTrue(scene.name + " wet columns " + wet, wet > 100);
assertEquals(scene.name + " conversions", 0, drops.conversions(8, 120, 8, 120));
assertEquals(scene.name + " unsupported falls", 0, drops.unsupported(8, 120, 8, 120));
drops.assertSealed(8, 120, 8, 120);
}
}
}
@@ -324,6 +331,21 @@ public class EffectiveWaterTest {
List.of(WaterScene.pond(74L, 191, 48, 48, 10)));
}
private static Scene mouth() {
IntBinaryOperator natural = (x, z) -> x < 64 ? SEA + 12 : SEA - 8;
Reach reach = WaterScene.line(75L, WaterScene.stream(), 8, 40, 120, 40,
(x, z) -> x < 64 ? SEA + 11 : SEA);
ReachStyle[] styles = reach.style().clone();
for (int point = 0; point < styles.length; point++) {
if (reach.xs()[point] >= 64) {
styles[point] = ReachStyle.MOUTH;
}
}
Reach coastal = new Reach(reach.id(), reach.tier(), reach.downstreamId(), reach.xs(), reach.zs(), reach.grade(),
reach.halfWidth(), reach.depth(), styles, reach.catchment());
return new Scene("mouth", natural, List.of(coastal), List.of());
}
private HydrologyFixture fixture() throws Exception {
return new HydrologyFixture(HydrologyFixture.pack(temporaryFolder.newFolder().toPath(), null, null, true));
}
@@ -410,9 +432,11 @@ public class EffectiveWaterTest {
}
private int[] column(int x, int z) {
return columns.computeIfAbsent(((long) x << 32) | (z & 0xFFFFFFFFL), key -> new int[]{
(int) Math.round(complex.resolveHydrologyTerrainHeight(x, z)),
complex.resolveWaterRole(x, z).wet() ? (int) Math.round(complex.resolveWaterHead(x, z)) : DRY});
return columns.computeIfAbsent(((long) x << 32) | (z & 0xFFFFFFFFL), key -> {
int ground = (int) Math.round(complex.resolveHydrologyTerrainHeight(x, z));
int head = (int) Math.round(complex.resolveWaterHead(x, z));
return new int[]{ground, head > ground ? head : DRY};
});
}
private boolean still(int x, int y, int z) {
@@ -206,7 +206,7 @@ public class IrisComplexHeightStreamTest {
assertTrue(fixture.complex.isHydrologyPlanned(8, 8));
bulk.fillChunkDoubles(0, 0, values);
assertBits(landed.terrainHeight(8, 8), values[8 << 4 | 8]);
assertBits(HydrologyFixture.POND_LEVEL, fixture.complex.getHeightFluidStream().getDouble(8, 8));
assertBits(HydrologyFixture.POND_LEVEL - 1, fixture.complex.getHeightFluidStream().getDouble(8, 8));
fixture.mainThread.set(false);
bulk.fillChunkDoubles(0, 0, values);
assertBits(landed.terrainHeight(8, 8), values[8 << 4 | 8]);
@@ -247,14 +247,14 @@ public class IrisComplexHeightStreamTest {
public void waterStreamsFollowTheRaster() throws Exception {
try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) {
ChunkWaterRaster raster = fixture.hydrology.runtime.raster(0, 0);
assertBits(HydrologyFixture.POND_LEVEL, fixture.complex.getWaterHeadStream().getDouble(8, 8));
assertBits(HydrologyFixture.POND_LEVEL - 1, fixture.complex.getWaterHeadStream().getDouble(8, 8));
assertSame(WaterRole.POND, fixture.complex.getWaterRoleStream().get(8, 8));
assertSame(raster.role(15, 15), fixture.complex.getWaterRoleStream().get(15.5, 15.5));
assertBits(HydrologyFixture.SEA_LEVEL, fixture.complex.getWaterHeadStream().getDouble(640, 640));
assertSame(WaterRole.NONE, fixture.complex.getWaterRoleStream().get(640, 640));
EngineMantle mantle = mock(EngineMantle.class, CALLS_REAL_METHODS);
doReturn(fixture.complex).when(mantle).getComplex();
assertEquals(HydrologyFixture.POND_LEVEL, mantle.getFluidHeight(8, 8));
assertEquals(HydrologyFixture.POND_LEVEL - 1, mantle.getFluidHeight(8, 8));
assertEquals(HydrologyFixture.SEA_LEVEL, mantle.getFluidHeight(640, 640));
}
}
@@ -266,7 +266,7 @@ public class IrisComplexHeightStreamTest {
doReturn(fixture.engine).when(placer).getEngine();
doReturn(fixture.engine.getDimension().getFluidHeight()).when(placer).getFluidHeight();
assertEquals(HydrologyFixture.POND_LEVEL, placer.getFluidHeight(8, 8));
assertEquals(HydrologyFixture.POND_LEVEL - 1, placer.getFluidHeight(8, 8));
assertEquals(HydrologyFixture.SEA_LEVEL, placer.getFluidHeight(640, 640));
}
}
@@ -165,7 +165,7 @@ public class IrisComplexTransitionTest {
if (terrain > 150) {
berms++;
assertEquals(terrain, ground, 0D);
assertTrue(ground >= 156);
assertTrue(ground >= 155);
}
assertContained(complex, x, z, ground);
}
@@ -195,13 +195,13 @@ public class IrisComplexTransitionTest {
double ground = complex.resolveHydrologyTerrainHeight(x, z);
assertTrue(raster.terrainHeight(x, z) < 150);
assertContained(complex, x, z, ground);
if (ground >= 148) {
if (ground >= 147) {
dried++;
assertEquals(127D, complex.resolveWaterHead(x, z), 0D);
assertSame(WaterRole.NONE, complex.resolveWaterRole(x, z));
} else {
wet++;
assertEquals(148D, complex.resolveWaterHead(x, z), 0D);
assertEquals(147D, complex.resolveWaterHead(x, z), 0D);
assertSame(WaterRole.POND, complex.resolveWaterRole(x, z));
}
}
@@ -220,7 +220,7 @@ public class IrisComplexTransitionTest {
FakeHydrology hydrology = seam.hydrology;
IrisComplex complex = seam.natural((x, z) -> 150D).complex();
for (int z = 7; z <= 9; z++) {
assertEquals(148, hydrology.runtime.raster(1, 0).waterHead(0, z));
assertEquals(147, hydrology.runtime.raster(1, 0).waterHead(0, z));
assertSame(WaterRole.NONE, complex.resolveWaterRole(16, z));
assertEquals(150D, complex.resolveHydrologyTerrainHeight(16, z), 0D);
}
@@ -308,9 +308,9 @@ public class IrisComplexTransitionTest {
}
if (stage.raster(1).waterHead(lx, lz) != Integer.MIN_VALUE && oldChunkWeight(x, lz) > 0D && oldChunkWeight(x - 1, lz) == 0D) {
dams++;
assertTrue(x + "," + lz + " dam lost its crest", blended.getRaw(lx, 157, lz) == stage.stone);
assertTrue(x + "," + lz + " dam lost its crest", blended.getRaw(lx, 156, lz) == stage.stone);
}
if (blended.getRaw(lx, 156, lz) != stage.water) {
if (blended.getRaw(lx, 155, lz) != stage.water) {
continue;
}
wet++;
@@ -318,7 +318,7 @@ public class IrisComplexTransitionTest {
int nx = lx + side[0];
int nz = lz + side[1];
if (nx >= 0 && nx < 16 && nz >= 0 && nz < 16) {
assertFalse(x + "," + lz + " leaks toward " + (16 + nx) + "," + nz, stage.open(blended.getRaw(nx, 156, nz)));
assertFalse(x + "," + lz + " leaks toward " + (16 + nx) + "," + nz, stage.open(blended.getRaw(nx, 155, nz)));
}
}
}
@@ -339,13 +339,13 @@ public class IrisComplexTransitionTest {
int dams = 0;
int water = 0;
for (int lz = 0; lz < 16; lz++) {
assertTrue(stage.complex.naturalTrueHeight(16, lz) >= 156);
assertTrue(stage.complex.naturalTrueHeight(16, lz) >= 155);
if (raster.waterHead(1, lz) != Integer.MIN_VALUE) {
dams++;
assertSame(WaterRole.NONE, stage.complex.resolveWaterRole(17, lz));
assertTrue(17 + "," + lz + " dam at " + stage.complex.resolveHydrologyTerrainHeight(17, lz),
stage.complex.resolveHydrologyTerrainHeight(17, lz) >= 157D);
assertSame(stage.stone, blended.getRaw(1, 157, lz));
stage.complex.resolveHydrologyTerrainHeight(17, lz) >= 156D);
assertSame(stage.stone, blended.getRaw(1, 156, lz));
}
for (int lx = 0; lx < 16; lx++) {
for (int y = 0; y < TransitionStage.HEIGHT; y++) {
@@ -373,7 +373,7 @@ public class IrisComplexTransitionTest {
public void seamDamHoldsNewWaterBesideAnOpenOldRiverOrLakeEdge() throws Exception {
try (TransitionStage stage = new TransitionStage(IrisComplexTransitionTest::oldChunkWeight, true, (x, z) -> 150.3D,
TransitionStage.savedColumn(140, 160, -1))) {
assertTrue(stage.complex.resolveWaterHead(16, 4) < 156D);
assertTrue(stage.complex.resolveWaterHead(16, 4) < 155D);
Hunk<NativeBlockState> blended = stage.blend(1, stage.paint(1));
assertTrue(stage.dams(blended) > 0);
stage.assertNoWaterTouchesAir(blended);
@@ -421,9 +421,9 @@ public class IrisComplexTransitionTest {
if (stage.raster(1).waterHead(1, lz) != Integer.MIN_VALUE) {
wet++;
assertSame(WaterRole.POND, stage.complex.resolveWaterRole(17, lz));
assertEquals(156D, stage.complex.resolveWaterHead(17, lz), 0D);
assertSame(stage.water, blended.getRaw(1, 156, lz));
assertSame(stage.stone, blended.getRaw(0, 156, lz));
assertEquals(155D, stage.complex.resolveWaterHead(17, lz), 0D);
assertSame(stage.water, blended.getRaw(1, 155, lz));
assertSame(stage.stone, blended.getRaw(0, 155, lz));
}
}
assertTrue(wet > 0);
@@ -455,7 +455,7 @@ public class IrisComplexTransitionTest {
boolean beside = oldChunkWeight(x + 1, z) == 0D || oldChunkWeight(x - 1, z) == 0D
|| oldChunkWeight(x, z + 1) == 0D || oldChunkWeight(x, z - 1) == 0D;
if (raster.waterHead(index & 15, index >> 4) != Integer.MIN_VALUE && oldChunkWeight(x, z) > 0D && beside
&& stage.complex.resolveHydrologyTerrainHeight(x, z) < 156D + 1D) {
&& stage.complex.resolveHydrologyTerrainHeight(x, z) < 155D + 1D) {
fail(x + "," + z + " stays wet beside a saved column that opens below its head");
}
if (role == WaterRole.NONE && raster.waterHead(index & 15, index >> 4) != Integer.MIN_VALUE && beside
@@ -663,8 +663,8 @@ public class IrisComplexTransitionTest {
for (int lz = 0; lz < 16; lz++) {
if (raster(1).waterHead(1, lz) != Integer.MIN_VALUE && complex.resolveWaterRole(17, lz) == WaterRole.NONE) {
dams++;
assertTrue(17 + "," + lz + " dam below the crest", complex.resolveHydrologyTerrainHeight(17, lz) >= 157D);
assertSame(stone, blended.getRaw(1, 157, lz));
assertTrue(17 + "," + lz + " dam below the crest", complex.resolveHydrologyTerrainHeight(17, lz) >= 156D);
assertSame(stone, blended.getRaw(1, 156, lz));
}
}
return dams;
@@ -821,7 +821,7 @@ public class IrisComplexTransitionTest {
dams++;
assertEquals(127D, head, 0D);
assertSame(WaterRole.NONE, role);
assertTrue(x + "," + z + " dam at " + ground, ground >= 157D);
assertTrue(x + "," + z + " dam at " + ground, ground >= 156D);
}
}
}
@@ -24,6 +24,7 @@ import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Rule;
import org.junit.Test;
import org.junit.Before;
import java.util.ArrayList;
import java.util.List;
@@ -51,6 +52,14 @@ public class IrisTerrainNormalActuatorHydrologyTest {
@Rule
public final PlatformBinding platform = PlatformBinding.mockPlatform();
private final NativeBlockState submergedDirt = state("minecraft:dirt");
private final NativeBlockState submergedNetherrack = state("minecraft:netherrack");
@Before
public void bindSubmergedSoils() {
when(platform.registries().block("minecraft:dirt")).thenReturn(submergedDirt);
when(platform.registries().block("minecraft:netherrack")).thenReturn(submergedNetherrack);
}
@Test
public void hydrologyOwnedFluidBypassesBiomeSeaLayers() {
@@ -100,7 +109,7 @@ public class IrisTerrainNormalActuatorHydrologyTest {
Hunk<NativeBlockState> output = painter.paint();
assertSame(role.name(), painter.grass, output.get(0, GROUND, 0));
assertSame(role.name(), submergedDirt, output.get(0, GROUND, 0));
for (int y = GROUND + 1; y <= HEAD; y++) {
assertSame(role + " y " + y, painter.channelFluid, output.get(0, y, 0));
}
@@ -117,7 +126,7 @@ public class IrisTerrainNormalActuatorHydrologyTest {
Hunk<NativeBlockState> sea = ocean.paint();
assertSame("the sea floor keeps the biome's own top", ocean.grass, sea.get(0, GROUND, 0));
assertSame("the sea floor keeps a submerged soil top", submergedDirt, sea.get(0, GROUND, 0));
assertSame(ocean.dirt, sea.get(0, GROUND - 1, 0));
assertSame("the slot takes the sea layers", ocean.ice, sea.get(0, HEAD, 0));
for (int y = GROUND + 1; y < HEAD; y++) {
@@ -178,7 +187,7 @@ public class IrisTerrainNormalActuatorHydrologyTest {
NativeBlockState expectedTop = switch (role) {
case NONE -> painter.grass;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL -> painter.bed;
case INLET -> painter.grass;
case INLET -> submergedDirt;
case SHORE, POND_SHORE, DAM -> painter.shore;
case BANK -> painter.bank;
};
@@ -192,11 +201,68 @@ public class IrisTerrainNormalActuatorHydrologyTest {
}
materials.setBank(null).setShore(null).setBed(null);
output = painter.paint();
assertSame(role.name(), painter.grass, output.get(0, GROUND, 0));
assertSame(role.name(), role.wet() ? submergedDirt : painter.grass, output.get(0, GROUND, 0));
assertSame(role.name(), painter.dirt, output.get(0, GROUND - 1, 0));
}
}
@Test
public void submergedSoilLayersAndExplicitPalettesUseStableSubstrates() {
for (String key : List.of("grass_block[snowy=false]", "mycelium", "dirt_path", "farmland[moisture=7]",
"crimson_nylium", "warped_nylium")) {
NativeBlockState livingSoil = state("minecraft:" + key);
NativeBlockState expected = key.endsWith("nylium") ? submergedNetherrack : submergedDirt;
for (WaterRole role : WET) {
Painter painter = new Painter(role, HEAD);
painter.layers(livingSoil, livingSoil);
painter.materials(true).setBed(painter.palette(livingSoil));
Hunk<NativeBlockState> output = painter.paint();
assertSame(role + " " + key, expected, output.get(0, GROUND, 0));
assertSame(role + " submerged side " + key, expected, output.get(0, GROUND - 1, 0));
assertSame(painter.channelFluid, output.get(0, GROUND + 1, 0));
}
for (WaterRole role : List.of(WaterRole.NONE, WaterRole.BANK, WaterRole.SHORE, WaterRole.POND_SHORE)) {
Painter painter = new Painter(role, GROUND);
painter.layers(livingSoil, livingSoil);
assertSame(role + " " + key, livingSoil, painter.paint().get(0, GROUND, 0));
}
}
}
@Test
public void submergedImageMapSoilUsesStableSubstrate() {
Painter painter = new Painter(WaterRole.CHANNEL, HEAD);
when(painter.complex.getImageMapRuntime().sampleSurfaceBlock(11, -4)).thenReturn(painter.grass);
assertSame(submergedDirt, painter.paint().get(0, GROUND, 0));
}
@Test
public void submergedPodzolAndMossKeepTheirAuthoredMaterials() {
for (String key : List.of("podzol[snowy=false]", "moss_block", "mud", "clay")) {
Painter painter = new Painter(WaterRole.POND, HEAD);
NativeBlockState stable = state("minecraft:" + key);
painter.layers(stable, stable);
Hunk<NativeBlockState> output = painter.paint();
assertSame(key, stable, output.get(0, GROUND, 0));
assertSame(key, stable, output.get(0, GROUND - 1, 0));
}
}
@Test
public void dryLowerTerrainSpansKeepTheirLivingSoilsBelowAWetBed() {
Painter painter = new Painter(WaterRole.POND, HEAD);
painter.carved();
Hunk<NativeBlockState> output = painter.paint();
assertSame(submergedDirt, output.get(0, GROUND, 0));
assertSame(painter.grass, output.get(0, 40, 0));
assertSame(painter.dirt, output.get(0, 39, 0));
}
@Test
public void sandSurvivesUnlessTheMaterialsKeepFallingBlocksAwayFromTheWater() {
for (WaterRole role : List.of(WaterRole.CHANNEL, WaterRole.POND, WaterRole.SHORE, WaterRole.BANK)) {
@@ -484,7 +484,7 @@ public final class GenerationSemanticCaptureTest {
ChunkGenerationSemantics pond = GenerationSemanticCapture.capture(fixture.engine, 0, 0, 5L);
for (ChunkGenerationSemantics.RiverFeatureOccurrence occurrence : pond.riverFeatures()) {
assertEquals("water", occurrence.profileKey());
assertEquals(63 - 64, occurrence.position().y());
assertEquals(62 - 64, occurrence.position().y());
assertEquals(WaterRole.POND, hydrology.runtime.raster(0, 0).role(occurrence.position().x() & 15,
occurrence.position().z() & 15));
}
@@ -0,0 +1,92 @@
package art.arcane.iris.probe;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
final class HydrologyBlockMaps {
private HydrologyBlockMaps() {
}
static List<String> write(HydrologyBlockProbe.Configuration configuration,
Map<Long, HydrologyBlockProbe.PlannedColumn> columns, HydrologyVoxels.Volume volume,
int worldHeight) throws IOException {
int minimumX = configuration.minimumChunkX() << 4;
int minimumZ = configuration.minimumChunkZ() << 4;
int width = (configuration.maximumChunkX() - configuration.minimumChunkX() + 1) << 4;
int depth = (configuration.maximumChunkZ() - configuration.minimumChunkZ() + 1) << 4;
BufferedImage raw = new BufferedImage(width, depth, BufferedImage.TYPE_INT_RGB);
BufferedImage effective = new BufferedImage(width, depth, BufferedImage.TYPE_INT_RGB);
BufferedImage loss = new BufferedImage(width, depth, BufferedImage.TYPE_INT_RGB);
BufferedImage fill = new BufferedImage(width, depth, BufferedImage.TYPE_INT_RGB);
File csv = new File(configuration.output(), "columns.csv");
try (BufferedWriter writer = Files.newBufferedWriter(csv.toPath(), StandardCharsets.UTF_8)) {
writer.write("x,z,role,natural_height,raster_terrain,effective_terrain,raster_head,effective_head,water_lost,terrain_delta,wet_bed_raise,generated_fill_solids,lowest_neighbor_head,known_neighbors\n");
for (int z = 0; z < depth; z++) {
for (int x = 0; x < width; x++) {
int blockX = minimumX + x;
int blockZ = minimumZ + z;
HydrologyBlockProbe.PlannedColumn column = columns.get(HydrologyVoxels.columnKey(blockX, blockZ));
boolean lost = column.rawHead() != HydrologyVoxels.NO_WATER && column.head() == HydrologyVoxels.NO_WATER;
int raised = column.wetBedRaise();
HydrologyBlockProbe.BedNeighbours neighbours = HydrologyBlockProbe.bedNeighbours(columns, blockX, blockZ);
raw.setRGB(x, z, relief(columns, column, blockX, blockZ, false, worldHeight));
effective.setRGB(x, z, relief(columns, column, blockX, blockZ, true, worldHeight));
loss.setRGB(x, z, lost ? 0xD33B31 : column.head() != HydrologyVoxels.NO_WATER ? 0x247AC4 : 0xC4C4C4);
fill.setRGB(x, z, raised > 0 ? (0xFF0000 | (Math.max(0, 224 - 28 * raised) << 8) | 0x20)
: column.head() != HydrologyVoxels.NO_WATER ? 0x247AC4 : 0xC4C4C4);
writer.write(blockX + "," + blockZ + "," + column.role() + "," + column.natural() + "," + column.terrain() + ","
+ column.effectiveTerrain() + "," + waterHead(column.rawHead()) + "," + waterHead(column.head()) + "," + lost + ","
+ (column.effectiveTerrain() - column.terrain()) + "," + raised + ","
+ HydrologyBlockProbe.raisedBedSolids(column, volume, blockX, blockZ) + ","
+ waterHead(neighbours.lowestHead()) + "," + neighbours.known() + "\n");
}
}
}
List<String> artifacts = new ArrayList<>();
artifacts.add(csv.getAbsolutePath());
artifacts.add(writeImage(configuration.output(), "raster-bed.png", raw));
artifacts.add(writeImage(configuration.output(), "effective-bed.png", effective));
artifacts.add(writeImage(configuration.output(), "water-loss.png", loss));
artifacts.add(writeImage(configuration.output(), "bed-fill.png", fill));
return List.copyOf(artifacts);
}
private static String waterHead(int head) {
return head == HydrologyVoxels.NO_WATER ? "" : Integer.toString(head);
}
private static String writeImage(File output, String name, BufferedImage image) throws IOException {
File file = new File(output, name);
ImageIO.write(image, "png", file);
return file.getAbsolutePath();
}
private static int relief(Map<Long, HydrologyBlockProbe.PlannedColumn> columns, HydrologyBlockProbe.PlannedColumn column,
int x, int z, boolean effective, int worldHeight) {
int height = effective ? column.effectiveTerrain() : column.terrain();
int head = effective ? column.head() : column.rawHead();
double dx = height(columns, x + 1, z, effective, height) - height(columns, x - 1, z, effective, height);
double dz = height(columns, x, z + 1, effective, height) - height(columns, x, z - 1, effective, height);
double light = Math.clamp((dx + dz + 4D) / Math.sqrt(6D * (dx * dx + dz * dz + 4D)), 0D, 1D);
double shade = (0.5D + 0.5D * light) * (Math.floorMod(height, 4) == 0 ? 0.82D : 1D);
double altitude = Math.clamp(height / (double) Math.max(1, worldHeight - 1), 0D, 1D);
boolean wet = head != HydrologyVoxels.NO_WATER;
int red = (int) Math.round(shade * (wet ? 50D + 60D * altitude : 160D + 50D * altitude));
int green = (int) Math.round(shade * (wet ? 130D + 65D * altitude : 145D + 60D * altitude));
int blue = (int) Math.round(shade * (wet ? 190D + 50D * altitude : 110D + 80D * altitude));
return (red << 16) | (green << 8) | blue;
}
private static int height(Map<Long, HydrologyBlockProbe.PlannedColumn> columns, int x, int z, boolean effective, int fallback) {
HydrologyBlockProbe.PlannedColumn column = columns.get(HydrologyVoxels.columnKey(x, z));
return column == null ? fallback : effective ? column.effectiveTerrain() : column.terrain();
}
}
@@ -66,6 +66,14 @@ public final class HydrologyBlockProbe {
static final class Evidence {
long plannedWetColumns;
long effectiveWetColumns;
long lostWetColumns;
long raisedWetBedColumns;
long raisedWetBedBlocks;
int maximumWetBedRaise;
long solidRaisedBedBlocks;
long dropAdjacentRaisedWetBedColumns;
long censoredRaisedWetBedColumns;
long channelColumns;
long pondColumns;
long bankColumns;
@@ -88,7 +96,16 @@ public final class HydrologyBlockProbe {
List<String> artifacts) {
}
record PlannedColumn(WaterRole role, int terrain, int natural, int head) {
record PlannedColumn(WaterRole role, int terrain, int natural, int rawHead, int effectiveTerrain, int head) {
int wetBedRaise() {
return rawHead == HydrologyVoxels.NO_WATER || head == HydrologyVoxels.NO_WATER ? 0 : Math.max(0, effectiveTerrain - terrain);
}
}
record BedNeighbours(int lowestHead, int known) {
boolean dropBelow(int head) {
return lowestHead != HydrologyVoxels.NO_WATER && lowestHead <= head - 2;
}
}
public static void main(String[] args) {
@@ -96,8 +113,10 @@ public final class HydrologyBlockProbe {
try {
Result result = run(Configuration.parse(args));
Evidence evidence = result.evidence();
System.out.println("IRIS_HYDROLOGY_BLOCK_RESULT version=2 status=" + result.status() + " seed=" + result.configuration().seed()
System.out.println("IRIS_HYDROLOGY_BLOCK_RESULT version=4 status=" + result.status() + " seed=" + result.configuration().seed()
+ " plannedWetColumns=" + evidence.plannedWetColumns + " missingSurfaceColumns=" + evidence.scan.missingSurfaces
+ " effectiveWetColumns=" + evidence.effectiveWetColumns + " lostWetColumns=" + evidence.lostWetColumns
+ " raisedWetBedColumns=" + evidence.raisedWetBedColumns + " raisedWetBedBlocks=" + evidence.raisedWetBedBlocks
+ " containmentLeaks=" + evidence.scan.containmentLeaks + " tickConversionCells=" + evidence.scan.tickConversionCells
+ " cane=" + evidence.validCaneRoots + " failures=" + result.failures().size()
+ " output=" + result.configuration().output().getAbsolutePath());
@@ -135,7 +154,8 @@ public final class HydrologyBlockProbe {
failures.add("The engine reported " + reports.size() + " generation errors.");
}
Files.createDirectories(configuration.output().toPath());
List<String> artifacts = writeImages(configuration, volume, engine);
List<String> artifacts = new ArrayList<>(writeImages(configuration, volume, engine));
artifacts.addAll(HydrologyBlockMaps.write(configuration, planned, volume, engine.getHeight()));
Result result = new Result(failures.isEmpty() ? "PASS" : "FAIL", configuration, seaLevel, evidence, List.copyOf(failures), artifacts);
Files.writeString(new File(configuration.output(), "hydrology-blocks.json").toPath(), json(result), StandardCharsets.UTF_8);
return result;
@@ -155,7 +175,9 @@ public final class HydrologyBlockProbe {
int head = heads[(lz << 4) | lx];
int natural = complex.naturalTrueHeight(x, z);
int terrain = own.empty() ? natural : own.terrainHeight(lx, lz);
planned.put(HydrologyVoxels.columnKey(x, z), new PlannedColumn(own.role(lx, lz), terrain, natural, head));
int effectiveTerrain = (int) Math.round(complex.getRawHeightStream().getDouble(x, z));
planned.put(HydrologyVoxels.columnKey(x, z), new PlannedColumn(own.role(lx, lz), terrain, natural,
own.waterHead(lx, lz), effectiveTerrain, head));
}
}
}
@@ -203,8 +225,15 @@ public final class HydrologyBlockProbe {
default -> {
}
}
if (column.head() != HydrologyVoxels.NO_WATER) {
evidence.plannedWetColumns++;
inspectWater(column, x, z, evidence);
if (column.wetBedRaise() > 0) {
evidence.solidRaisedBedBlocks += raisedBedSolids(column, volume, x, z);
BedNeighbours neighbours = bedNeighbours(planned, x, z);
if (neighbours.dropBelow(column.head())) {
evidence.dropAdjacentRaisedWetBedColumns++;
} else if (neighbours.known() < CARDINALS.length) {
evidence.censoredRaisedWetBedColumns++;
}
}
if (HydrologyBlockMetrics.scanned(columns, x, z)) {
evidence.scan.column(volume, x, z, HydrologyBlockMetrics.scanFloor(columns, seaLevel, x, z), height - 1);
@@ -214,6 +243,58 @@ public final class HydrologyBlockProbe {
return evidence;
}
static void inspectWater(PlannedColumn column, int x, int z, Evidence evidence) {
if (column.head() != HydrologyVoxels.NO_WATER) {
evidence.effectiveWetColumns++;
}
if (column.rawHead() == HydrologyVoxels.NO_WATER) {
return;
}
evidence.plannedWetColumns++;
if (column.head() == HydrologyVoxels.NO_WATER) {
evidence.lostWetColumns++;
evidence.example("lost planned water at " + x + "," + column.rawHead() + "," + z + " role=" + column.role()
+ " bed=" + column.terrain() + " effectiveGround=" + column.effectiveTerrain());
} else if (column.wetBedRaise() > 0) {
int raised = column.wetBedRaise();
evidence.raisedWetBedColumns++;
evidence.raisedWetBedBlocks += raised;
evidence.maximumWetBedRaise = Math.max(evidence.maximumWetBedRaise, raised);
evidence.example("raised wet bed at " + x + "," + z + " role=" + column.role() + " rasterGround=" + column.terrain()
+ " effectiveGround=" + column.effectiveTerrain() + " fill=" + raised + " remainingDepth="
+ (column.head() - column.effectiveTerrain()));
}
}
static int raisedBedSolids(PlannedColumn column, HydrologyVoxels.Volume volume, int x, int z) {
if (column.wetBedRaise() == 0) {
return 0;
}
int solids = 0;
for (int y = column.terrain() + 1; y <= column.effectiveTerrain(); y++) {
if (volume.at(x, y, z) == HydrologyVoxels.SOLID) {
solids++;
}
}
return solids;
}
static BedNeighbours bedNeighbours(Map<Long, PlannedColumn> columns, int x, int z) {
int lowest = Integer.MAX_VALUE;
int known = 0;
for (int[] side : CARDINALS) {
PlannedColumn neighbour = columns.get(HydrologyVoxels.columnKey(x + side[0], z + side[1]));
if (neighbour == null) {
continue;
}
known++;
if (neighbour.head() != HydrologyVoxels.NO_WATER) {
lowest = Math.min(lowest, neighbour.head());
}
}
return new BedNeighbours(lowest == Integer.MAX_VALUE ? HydrologyVoxels.NO_WATER : lowest, known);
}
private static void inspectCane(HydrologyVoxels.Volume volume, int x, int z, int height, Evidence evidence) {
for (int y = 1; y < height; y++) {
if (volume.at(x, y, z) != HydrologyVoxels.CANE || volume.at(x, y - 1, z) == HydrologyVoxels.CANE) {
@@ -249,6 +330,13 @@ public final class HydrologyBlockProbe {
if (evidence.invalidCaneSubstrates > 0 || evidence.dryCaneRoots > 0) {
failures.add("Generated sugar cane has invalid substrate or no adjacent water.");
}
if (evidence.lostWetColumns > 0) {
failures.add("Water settling removes planned water from " + evidence.lostWetColumns + " columns in fresh generation.");
}
if (evidence.raisedWetBedColumns > 0) {
failures.add("Water settling fills " + evidence.raisedWetBedBlocks + " blocks above the raster bed in "
+ evidence.raisedWetBedColumns + " wet columns in fresh generation.");
}
if (evidence.scan.missingSurfaces > 0) {
failures.add("Generated blocks miss the planned water surface in " + evidence.scan.missingSurfaces + " columns.");
}
@@ -288,6 +376,14 @@ public final class HydrologyBlockProbe {
Evidence evidence = result.evidence();
Map<String, Object> counts = new LinkedHashMap<>();
counts.put("plannedWetColumns", evidence.plannedWetColumns);
counts.put("effectiveWetColumns", evidence.effectiveWetColumns);
counts.put("lostWetColumns", evidence.lostWetColumns);
counts.put("raisedWetBedColumns", evidence.raisedWetBedColumns);
counts.put("raisedWetBedBlocks", evidence.raisedWetBedBlocks);
counts.put("maximumWetBedRaise", evidence.maximumWetBedRaise);
counts.put("solidRaisedBedBlocks", evidence.solidRaisedBedBlocks);
counts.put("dropAdjacentRaisedWetBedColumns", evidence.dropAdjacentRaisedWetBedColumns);
counts.put("censoredRaisedWetBedColumns", evidence.censoredRaisedWetBedColumns);
counts.put("missingSurfaceColumns", evidence.scan.missingSurfaces);
counts.put("channelColumns", evidence.channelColumns);
counts.put("pondColumns", evidence.pondColumns);
@@ -313,6 +409,9 @@ public final class HydrologyBlockProbe {
report.put("artifacts", result.artifacts());
report.put("coordinateConvention", "Block X/Z are world coordinates; Y is relative to the world minimum height.");
report.put("imageLegend", "open=light blue,water=blue,lava=orange,cane=lime,solid=gray");
report.put("bedMapLegend", "raster-bed and effective-bed show elevation, slope shading and four-block contours on the same world-height scale; water-loss: red=planned wet made dry, blue=retained water, gray=dry");
report.put("bedFillLegend", "bed-fill: yellow to red=one to eight or more blocks of wet bed fill, blue=wet bed retained, gray=dry; dry bank and dam fill is excluded");
report.put("columnData", "columns.csv is ordered by Z then X; blank water heads mean dry; heights are relative to the world minimum height");
return new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n";
}
@@ -1,13 +1,28 @@
package art.arcane.iris.probe;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
public class HydrologyBlockProbeTest {
@Rule
public TemporaryFolder folder = new TemporaryFolder();
@Test
public void failuresNameMissingSurfaceLeaksTicksAndCoverage() {
HydrologyBlockProbe.Evidence evidence = new HydrologyBlockProbe.Evidence();
@@ -21,4 +36,110 @@ public class HydrologyBlockProbeTest {
"Fluid ticks would move generated water in 4 cells.",
"Required generated coverage is absent: pond"), HydrologyBlockProbe.failures(evidence, Set.of("channel", "pond")));
}
@Test
public void dryRidgesFailEvenWhenTheRemainingWaterIsSealed() {
HydrologyBlockProbe.Evidence evidence = new HydrologyBlockProbe.Evidence();
HydrologyBlockProbe.inspectWater(new HydrologyBlockProbe.PlannedColumn(WaterRole.CHANNEL, 77, 82, 79, 80,
HydrologyVoxels.NO_WATER), -103, -1391, evidence);
HydrologyBlockProbe.inspectWater(new HydrologyBlockProbe.PlannedColumn(WaterRole.WATERFALL, 70, 82, 79, 70, 79),
-102, -1391, evidence);
HydrologyBlockProbe.inspectWater(new HydrologyBlockProbe.PlannedColumn(WaterRole.BANK, 82, 82, HydrologyVoxels.NO_WATER,
82, HydrologyVoxels.NO_WATER), -104, -1391, evidence);
assertEquals(2L, evidence.plannedWetColumns);
assertEquals(1L, evidence.effectiveWetColumns);
assertEquals(1L, evidence.lostWetColumns);
assertEquals(List.of("Water settling removes planned water from 1 columns in fresh generation."),
HydrologyBlockProbe.failures(evidence, Set.of()));
assertEquals(List.of("lost planned water at -103,79,-1391 role=CHANNEL bed=77 effectiveGround=80"), evidence.examples);
}
@Test
public void submergedChannelAndPondFillFailsWithoutAnyLostSurfaceWater() {
HydrologyBlockProbe.PlannedColumn channel = new HydrologyBlockProbe.PlannedColumn(WaterRole.CHANNEL, 318, 330, 326, 324, 326);
HydrologyBlockProbe.PlannedColumn pond = new HydrologyBlockProbe.PlannedColumn(WaterRole.POND, 316, 330, 326, 324, 326);
HydrologyBlockProbe.Evidence evidence = new HydrologyBlockProbe.Evidence();
HydrologyBlockProbe.inspectWater(channel, -372, -441, evidence);
HydrologyBlockProbe.inspectWater(pond, -371, -441, evidence);
HydrologyBlockProbe.inspectWater(new HydrologyBlockProbe.PlannedColumn(WaterRole.DAM, 318, 318, HydrologyVoxels.NO_WATER,
330, HydrologyVoxels.NO_WATER), -373, -441, evidence);
assertEquals(0L, evidence.lostWetColumns);
assertEquals(2L, evidence.effectiveWetColumns);
assertEquals(2L, evidence.raisedWetBedColumns);
assertEquals(14L, evidence.raisedWetBedBlocks);
assertEquals(8, evidence.maximumWetBedRaise);
assertEquals(List.of("Water settling fills 14 blocks above the raster bed in 2 wet columns in fresh generation."),
HydrologyBlockProbe.failures(evidence, Set.of()));
assertEquals("raised wet bed at -372,-441 role=CHANNEL rasterGround=318 effectiveGround=324 fill=6 remainingDepth=2",
evidence.examples.getFirst());
HydrologyVoxels.ChunkBlocks chunk = HydrologyVoxels.ChunkBlocks.empty(-24, -28, 384);
for (int y = 319; y <= 324; y++) {
chunk.set(12, y, 7, HydrologyVoxels.SOLID);
}
chunk.set(12, 325, 7, HydrologyVoxels.WATER);
HydrologyVoxels.Volume volume = new HydrologyVoxels.Volume();
volume.put(chunk);
assertEquals(6, HydrologyBlockProbe.raisedBedSolids(channel, volume, -372, -441));
assertEquals(0, HydrologyBlockProbe.raisedBedSolids(pond, volume, -371, -441));
HydrologyBlockProbe.BedNeighbours neighbours = HydrologyBlockProbe.bedNeighbours(Map.of(
HydrologyVoxels.columnKey(-373, -441), new HydrologyBlockProbe.PlannedColumn(WaterRole.CHANNEL, 316, 330, 324, 316, 324)),
-372, -441);
assertTrue(neighbours.dropBelow(channel.head()));
assertEquals(1, neighbours.known());
assertEquals(324, neighbours.lowestHead());
}
@Test
public void columnDataAndBedImagesExposeHeightChangesBeneathOneWaterSurface() throws Exception {
File output = folder.newFolder("columns");
HydrologyBlockProbe.Configuration configuration = new HydrologyBlockProbe.Configuration(new File("/synthetic-pack"), "overworld",
1337L, -1, -1, -1, -1, Set.of(), output, false);
Map<Long, HydrologyBlockProbe.PlannedColumn> columns = new HashMap<>();
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
boolean lost = x == 4 && z == 5;
boolean dry = x == 0 && z == 0;
boolean raised = x == 6 && z == 5;
columns.put(HydrologyVoxels.columnKey(x - 16, z - 16), new HydrologyBlockProbe.PlannedColumn(
dry ? WaterRole.BANK : WaterRole.CHANNEL, 60 + x, 120, dry ? HydrologyVoxels.NO_WATER : 100,
lost ? 101 : raised ? 72 : 60 + x, lost || dry ? HydrologyVoxels.NO_WATER : 100));
}
}
HydrologyVoxels.ChunkBlocks chunk = HydrologyVoxels.ChunkBlocks.empty(-1, -1, 128);
for (int y = 67; y <= 72; y++) {
chunk.set(6, y, 5, HydrologyVoxels.SOLID);
}
HydrologyVoxels.Volume volume = new HydrologyVoxels.Volume();
volume.put(chunk);
List<String> artifacts = HydrologyBlockMaps.write(configuration, columns, volume, 320);
assertEquals(5, artifacts.size());
List<String> csv = Files.readAllLines(new File(output, "columns.csv").toPath());
assertEquals(257, csv.size());
assertEquals("x,z,role,natural_height,raster_terrain,effective_terrain,raster_head,effective_head,water_lost,terrain_delta,wet_bed_raise,generated_fill_solids,lowest_neighbor_head,known_neighbors", csv.getFirst());
assertEquals("-16,-16,BANK,120,60,60,,,false,0,0,0,100,2", csv.get(1));
assertTrue(csv.contains("-12,-11,CHANNEL,120,64,101,100,,true,37,0,0,100,4"));
assertTrue(csv.contains("-10,-11,CHANNEL,120,66,72,100,100,false,6,6,6,100,4"));
assertFalse(String.join("\n", csv).contains(Integer.toString(HydrologyVoxels.NO_WATER)));
BufferedImage raw = ImageIO.read(new File(output, "raster-bed.png"));
BufferedImage effective = ImageIO.read(new File(output, "effective-bed.png"));
BufferedImage loss = ImageIO.read(new File(output, "water-loss.png"));
BufferedImage fill = ImageIO.read(new File(output, "bed-fill.png"));
assertEquals(16, raw.getWidth());
assertEquals(16, raw.getHeight());
assertNotEquals(raw.getRGB(1, 10), raw.getRGB(13, 10));
assertNotEquals(raw.getRGB(4, 5), effective.getRGB(4, 5));
assertEquals(raw.getRGB(10, 10), effective.getRGB(10, 10));
assertEquals(0xD33B31, loss.getRGB(4, 5) & 0xFFFFFF);
assertEquals(0x247AC4, loss.getRGB(10, 10) & 0xFFFFFF);
assertEquals(0xC4C4C4, loss.getRGB(0, 0) & 0xFFFFFF);
assertEquals(0xFF3820, fill.getRGB(6, 5) & 0xFFFFFF);
assertEquals(0x247AC4, fill.getRGB(10, 10) & 0xFFFFFF);
assertEquals(0xC4C4C4, fill.getRGB(0, 0) & 0xFFFFFF);
}
}