Refine organic cave generation

- Add seeded cave surface variation and rounded basin undersides.
- Keep dry cave intersections open while preserving fluid seals.
- Apply room biome decorators and preserve their floor substrates.
- Allow ceiling growth above reserved walking clearance.
- Restrict feature placement by region across live and saved generation.
- Cover cave geometry, placement, decoration, and regional filtering with regression tests.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-05 16:22:55 -04:00
1 parent c479e94c49
commit 7226fc4400
25 files changed
+785 -86

No files matched your search

@@ -82,7 +82,8 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
return CommitResult.REJECTED_BOUNDS;
}
SubterrainCell feature = engine.getSubterrainCell(mutation.x(), mutation.y(), mutation.z());
if (SubterrainRasterizer.protectsPlacement(feature)) {
if (SubterrainRasterizer.protectsPlacement(feature)
|| feature != null && feature.solid() && delegate.isSolid(mutation.x(), mutation.y(), mutation.z())) {
discard();
return CommitResult.REJECTED_SUBTERRAIN;
}
@@ -178,6 +178,9 @@ public class MantleCarvingComponent extends IrisMantleComponent {
if (y <= 0 || y >= writer.getMantle().getWorldHeight()) {
return;
}
if (cell.solid() && !cell.room().reservedSolid()) {
return;
}
if (cell.solid()) {
writer.clearData(x, y, z, MatterCavern.class);
} else {
@@ -64,6 +64,7 @@ import art.arcane.iris.generation.terrain.transform.TerrainTransformer;
import art.arcane.iris.generation.noise.IrisInterpolator;
import art.arcane.iris.generation.terrain.IrisMaterialPalette;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.generation.terrain.RegionSelection;
import art.arcane.iris.generation.hydrology.IrisDeepFluidConfig;
import art.arcane.iris.generation.hydrology.IrisSurfacePoolConfig;
import art.arcane.iris.generation.hydrology.IrisHydrology;
@@ -274,7 +275,8 @@ public class IrisComplex implements DataProvider {
subterrainPlanner = new SubterrainPlanner(new SubterrainPlanner.Options(
engine.getDimension().allowsSubterrainFeatures() ? engine.getDimension().getSubterrainFeatures() : List.of(),
engine.getSeedManager().getBodies(),
engine.getDimension().getMinHeight(), engine.getDimension().getMaxHeight()),
engine.getDimension().getMinHeight(), engine.getDimension().getMaxHeight(),
(x, z) -> regionStream.get(x, z).getLoadKey()),
bounds -> transitionGenerationPlan == null || transitionGenerationPlan.allowsNewFootprint(
bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ()));
biomeBoundsSamplingStep = engine.getDimension().getBiomeBoundsSamplingStep();
@@ -376,15 +378,10 @@ public class IrisComplex implements DataProvider {
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
.zoom(engine.getDimension().getRegionZoom());
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE);
ProceduralStream<IrisRegion> proceduralRegionStream = focusedRegions != null ? focusedRegions
: GenerationStreams.cache2D(regionStyleStream
.selectRarity(compatRegionPool(engine)), "regionStream", engine, cacheSize);
regionStream = focusedRegions != null || !imageMapRuntime.has(IrisImageMapApplication.REGION)
? proceduralRegionStream : GenerationStreams.cache2D(proceduralRegionStream
.convertAware2D((region, x, z) -> {
IrisRegion mapped = imageMapRuntime.sampleRegion(x, z);
return mapped == null ? region : mapped;
}), "imageMappedRegionStream", engine, cacheSize);
regionStream = focusedRegions != null ? focusedRegions
: GenerationStreams.cache2D(RegionSelection.select(new RegionSelection.Options(
engine.getDimension(), data, regionStyleStream, imageMapRuntime, null)),
"regionStream", engine, cacheSize);
regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i),
String.valueOf(i * 38445).hashCode() * 3245556666L));
caveBiomeStream = GenerationStreams.cache2D(GenerationStreams.contextInjecting(regionStream, engine, (c, x, z) -> c.getRegion().get(x, z))
@@ -1849,17 +1846,6 @@ public class IrisComplex implements DataProvider {
return inferred;
}
/** The dimension's region pool with regions the version-content gate excluded removed. */
private KList<IrisRegion> compatRegionPool(Engine engine) {
KList<IrisRegion> pool = new KList<>();
for (IrisRegion region : data.getRegionLoader().loadAll(engine.getDimension().getRegions())) {
if (!region.isCompatExcluded()) {
pool.add(region);
}
}
return pool;
}
private static <T extends IrisRegistrant> T compatUsable(T registrant) {
return registrant == null || registrant.isCompatExcluded() ? null : registrant;
}
@@ -242,7 +242,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
output.setRaw(localX, surfaceY, localZ, AIR);
}
}
restoreSubterrainSolids(output, x, z);
restoreSubterrainSolids(output, x, z, false);
} finally {
getEngine().getMetrics().getCarveApply().put(applyStopwatch.getMilliseconds());
}
@@ -272,6 +272,12 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
int x = worldX & 15;
int z = worldZ & 15;
NativeBlockState existing = output.getRaw(x, y, z);
if (cell.solid() && !cell.room().reservedSolid() && (existing == null || !existing.isSolid())) {
return;
}
if (materials.retainsFloorSupport(worldX, worldY, worldZ, cell, existing)) {
return;
}
NativeBlockState planned = materials.state(worldX, worldY, worldZ, cell);
if (cell.solid() || cell.room().reservedPassage() || cell.fluid() && (existing == null || B.isAir(existing) || B.isFluid(existing))) {
output.setRaw(x, y, z, planned);
@@ -281,7 +287,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
});
}
private void restoreSubterrainSolids(Hunk<NativeBlockState> output, int chunkX, int chunkZ) {
private void restoreSubterrainSolids(Hunk<NativeBlockState> output, int chunkX, int chunkZ, boolean preserveFloorSupports) {
SubterrainPlanner planner = getComplex().getSubterrainPlanner();
if (planner == null || planner.isEmpty()) {
return;
@@ -290,7 +296,14 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
SubterrainSurfaceMaterials materials = subterrainMaterials(planner, chunkX, chunkZ);
SubterrainRasterizer.rasterize(planner, chunkX, chunkZ, (worldX, worldY, worldZ, cell) -> {
int y = worldY - minimumY;
if (cell.solid() && y > 0 && y < output.getHeight()) {
if (!cell.solid() || y <= 0 || y >= output.getHeight()) {
return;
}
NativeBlockState existing = output.getRaw(worldX & 15, y, worldZ & 15);
if (preserveFloorSupports && materials.retainsFloorSupport(worldX, worldY, worldZ, cell, existing)) {
return;
}
if (cell.room().reservedSolid() || existing != null && existing.isSolid()) {
output.setRaw(worldX & 15, y, worldZ & 15, materials.state(worldX, worldY, worldZ, cell));
}
});
@@ -421,7 +434,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ);
SubterrainCell feature = context.subterrainPlanner() == null ? SubterrainCell.OUTSIDE : context.subterrainPlanner().sample(context.subterrainPlans(),
context.chunkBlockX() + localX, y + getEngine().getWorld().minHeight(), context.chunkBlockZ() + localZ);
if (feature.owned()) {
if (feature.owned() && (!feature.solid() || feature.room().reservedSolid())) {
context.output().setRaw(localX, y, localZ, SubterrainRasterizer.state(feature));
if (feature.carve()) {
context.columnMasks()[columnIndex].add(y);
@@ -1007,28 +1020,27 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
}
}
}
restoreSubterrainSolids(output, blockX >> 4, blockZ >> 4);
restoreSubterrainSolids(output, blockX >> 4, blockZ >> 4, true);
}
private void decorateZone(Hunk<NativeBlockState> output, CaveZone zone,
int rx, int rz, int xx, int zz, IrisBiome floorBiome, IrisBiome ceilingBiome) {
SubterrainCell feature = subterrainCell(xx, zone.getFloor(), zz);
if (feature.owned() && feature.room().reservedPassage()) {
return;
}
boolean reservedPassage = feature.owned() && feature.room().reservedPassage();
int maxY = output.getHeight();
IrisDecorator[] surfaceDecorators = floorBiome == null
? new IrisDecorator[0]
: floorBiome.getDecoratorBucket(IrisDecorationPart.NONE);
if (surfaceDecorators.length > 0 && hasStableCaveFloorSupport(output, rx, zone.getFloor(), rz)) {
if (!reservedPassage && surfaceDecorators.length > 0 && hasStableCaveFloorSupport(output, rx, zone.getFloor(), rz)) {
decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, floorBiome, InferredType.CAVE, zone.getFloor() - 1, zone.airThickness());
}
IrisDecorator[] ceilingDecorators = ceilingBiome == null
? new IrisDecorator[0]
: ceilingBiome.getDecoratorBucket(IrisDecorationPart.CEILING);
if (ceilingDecorators.length > 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) {
decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, ceilingBiome, InferredType.CAVE, zone.getCeiling(), zone.airThickness());
int ceilingSpace = zone.airThickness() - (reservedPassage ? 3 : 0);
if (ceilingSpace > 0 && ceilingDecorators.length > 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) {
decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, ceilingBiome, InferredType.CAVE, zone.getCeiling(), ceilingSpace);
}
}
@@ -1075,6 +1087,10 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
}
IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, CaveInputs resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, Map<String, IrisBiome> customBiomeCache) {
IrisBiome featureBiome = getEngine().getSubterrainBiome(worldX, y, worldZ);
if (featureBiome != null) {
return featureBiome;
}
if (isNaturalTerrainOpening(resolverState.complex, worldX, y, worldZ)) {
return resolverState.complex.getTrueBiomeStream().get(worldX, worldZ);
}
@@ -1,6 +1,8 @@
package art.arcane.iris.generation.subterrain;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.pack.schema.annotation.MaxNumber;
import art.arcane.iris.pack.schema.annotation.MinNumber;
import art.arcane.iris.pack.schema.annotation.RegistryListResource;
@@ -26,6 +28,9 @@ public class IrisSubterrainFeature {
@RegistryListResource(IrisBiome.class)
@Description("Biome that owns occupied cells in this feature. Solid rims and surrounding terrain retain their biome.")
private String biome = "";
@RegistryListResource(IrisRegion.class)
@Description("Allowed region keys at the feature placement center. Empty allows all regions.")
private KList<String> allowedRegions = new KList<>();
@Description("Inclusive absolute world Y bounds. The entire feature including seals must fit inside this band.")
private IrisRange worldYRange = new IrisRange(-48, 48);
@MinNumber(32)
@@ -48,6 +53,10 @@ public class IrisSubterrainFeature {
@MaxNumber(192)
@Description("Main room height in blocks.")
private int height = 32;
@MinNumber(0)
@MaxNumber(1)
@Description("Seeded erosion and shape distortion strength for lobed basins, winding passages, uneven banks and irregular vaults.")
private double shapeWarp = 0;
@Description("Retained fluid. When omitted, lava tubes use LAVA and cenotes and terraces use WATER. Faults remain dry.")
private IrisSubterrainFluid fluid;
@MinNumber(0)
@@ -11,6 +11,7 @@ public final class SubterrainPlan {
private final String id;
private final SubterrainBounds bounds;
private final double shapePhase;
private final long shapeSeed;
private final ThreadLocal<ColumnCache> columnCache = ThreadLocal.withInitial(ColumnCache::new);
SubterrainPlan(Options options) {
@@ -21,11 +22,12 @@ public final class SubterrainPlan {
rotated = options.rotated();
id = options.id();
shapePhase = (SubterrainPlanner.mix(id.hashCode() ^ definition.seedSalt()) >>> 11) * 0x1.0p-53 * Math.PI * 2;
shapeSeed = SubterrainPlanner.mix(id.hashCode() ^ definition.seedSalt());
int along = family() == IrisSubterrainFamily.CENOTE ? definition.radius() : definition.length() / 2;
int across = definition.radius();
int extentX = (rotated ? across : along) + 2;
int extentZ = (rotated ? along : across) + 2;
bounds = new SubterrainBounds(centerX - extentX, baseY() - 2, centerZ - extentZ,
bounds = new SubterrainBounds(centerX - extentX, baseY() - 2 - definition.baseDepth(), centerZ - extentZ,
centerX + extentX, baseY() + definition.height() + 2
+ (family() == IrisSubterrainFamily.LAVA_TUBE ? definition.chimneyHeight() : 0), centerZ + extentZ);
}
@@ -125,18 +127,20 @@ public final class SubterrainPlan {
if (column == null) {
return SubterrainCell.OUTSIDE;
}
int sealFloor = family() == IrisSubterrainFamily.CENOTE ? baseY() : column.floor();
if (y < sealFloor - 2 || y > column.ceiling() + 2) {
double horizontalOutside = Math.max(0, -column.boundary());
double verticalOutside = Math.max(0, Math.max(column.baseFloor() - y, y - column.ceiling()));
if (horizontalOutside * horizontalOutside + verticalOutside * verticalOutside > 4) {
return SubterrainCell.OUTSIDE;
}
SubterrainCell.Kind kind = classify(column, u, v, y);
int pathX = rotated ? centerX + column.pathOffset() : x;
int pathZ = rotated ? z : centerZ + column.pathOffset();
boolean passage = kind == SubterrainCell.Kind.AIR && column.pathDistance() <= 2.0;
boolean passage = kind == SubterrainCell.Kind.AIR && column.pathDistance() <= 2.0
&& y <= Math.max(column.floor(), column.head()) + 3;
SubterrainRoom room = new SubterrainRoom(id, biome(), family(), centerX, centerY, centerZ,
pathX, Math.max(column.floor() + 2, column.head() + 2), pathZ,
column.floor(), column.ceiling(), column.boundary(), column.head(), passage,
kind == SubterrainCell.Kind.SOLID, kind);
kind == SubterrainCell.Kind.SOLID && retainedSolid(column, u, v, y), kind);
String material = switch (kind) {
case SOLID -> definition.solid();
case AIR -> "minecraft:cave_air";
@@ -147,6 +151,12 @@ public final class SubterrainPlan {
return new SubterrainCell(kind, material, room);
}
private boolean retainedSolid(Column column, int u, int v, int y) {
return family() != IrisSubterrainFamily.TECTONIC_FAULT && definition.fluidDepth() > 0 && y <= column.head() + 1
|| column.interior() && y > column.floor() && y < column.ceiling()
&& formation(column, u, v, y);
}
private Column cachedColumn(int u, int v) {
ColumnCache cache = columnCache.get();
long key = ((long) u << 32) ^ (v & 0xffffffffL);
@@ -159,7 +169,7 @@ public final class SubterrainPlan {
case CENOTE -> cenote(u, v);
case TECTONIC_FAULT -> fault(u, v);
case TRAVERTINE_TERRACES -> terraces(u, v);
case LAVA_TUBE -> throw new IllegalStateException("Tube columns depend on height");
case LAVA_TUBE -> erodedTube(u, v);
};
int slot = cache.next;
cache.keys[slot] = key;
@@ -173,6 +183,9 @@ public final class SubterrainPlan {
if (!column.interior() || y <= column.floor() || y >= column.ceiling() || column.rim()) {
return SubterrainCell.Kind.SOLID;
}
if (erodedWall(column, u, v, y)) {
return SubterrainCell.Kind.SOLID;
}
if (formation(column, u, v, y)) {
return SubterrainCell.Kind.SOLID;
}
@@ -185,6 +198,18 @@ public final class SubterrainPlan {
return SubterrainCell.Kind.AIR;
}
private boolean erodedWall(Column column, int u, int v, int y) {
double strength = definition.shapeWarp();
double reach = Math.min(7, definition.radius() * 0.28) * strength;
if (reach == 0 || column.boundary() >= reach || column.pathDistance() <= 2) {
return false;
}
double height = y - baseY();
double inset = reach * (0.48 + 0.34 * noise(u / 13.0 + height / 17.0, v / 13.0 - height / 19.0, 149)
+ 0.18 * noise(u / 4.0 - height / 5.0, v / 4.0 + height / 7.0, 151));
return column.boundary() < inset;
}
private boolean formation(Column column, int u, int v, int y) {
if (column.pathDistance() <= 3 || column.boundary() < 4) {
return false;
@@ -225,6 +250,9 @@ public final class SubterrainPlan {
}
private Column cenote(int u, int v) {
if (definition.shapeWarp() > 0) {
return erodedCenote(u, v);
}
double distance = Math.hypot(u, v);
int radius = definition.radius();
if (distance > radius + 2) {
@@ -237,7 +265,7 @@ public final class SubterrainPlan {
double slope = bank * bank * (3 - 2 * bank);
int floor = baseY() + (int) Math.round((definition.fluidDepth() + 2) * slope);
boolean rim = definition.fluidDepth() > 0 && ceiling <= head + 2;
return new Column(floor, ceiling, head, distance < radius, rim,
return new Column(floor, floor, ceiling, head, distance < radius, rim,
radius - distance, Math.abs(v), 0);
}
@@ -249,12 +277,36 @@ public final class SubterrainPlan {
int step = Math.max(2, definition.radius() / 4);
int shelf = Math.max(0, (int) (Math.abs(footprint.offset()) - footprint.width() * 0.5) / step)
* Math.max(2, definition.height() / 8);
int ceiling = vaultedCeiling(u, footprint, baseY() + shelf, baseY() - 1);
return new Column(baseY() + shelf, ceiling, baseY() - 1,
int floor = baseY() + shelf;
if (definition.shapeWarp() > 0) {
floor = baseY() + (int) Math.round((1 - definition.shapeWarp()) * shelf
+ definition.shapeWarp() * definition.height() * (0.10 + 0.08 * erosion(u, v, 43)));
}
int ceiling = vaultedCeiling(u, footprint, floor, baseY() - 1);
return new Column(floor, floor, ceiling, baseY() - 1,
footprint.boundary() > 0, false, footprint.boundary(), Math.abs(footprint.offset()), footprint.path());
}
private Column tube(int u, int v, int y) {
if (definition.shapeWarp() > 0) {
Column column = cachedColumn(u, v);
if (column == null || definition.chimneyHeight() == 0) {
return column;
}
int halfLength = definition.length() / 2;
double chimneyRadius = Math.hypot(Math.min(Math.abs(u - halfLength / 2), Math.abs(u + halfLength / 2)),
v - column.pathOffset());
int top = baseY() + definition.height() + definition.chimneyHeight();
if (chimneyRadius < 3) {
return new Column(column.floor(), column.baseFloor(), top, column.head(), column.interior(), column.rim(),
column.boundary(), column.pathDistance(), column.pathOffset());
}
if (chimneyRadius <= 5 && y >= column.ceiling() - 1) {
return new Column(column.ceiling() - 1, column.ceiling() - 1, top, column.head(), false, true,
5 - chimneyRadius, column.pathDistance(), column.pathOffset());
}
return column;
}
int halfLength = definition.length() / 2;
int path = (int) Math.round(Math.sin(u * Math.PI / Math.max(1, halfLength)) * definition.radius() * 0.15);
int width = Math.max(6, (int) Math.floor(definition.radius() * 0.8));
@@ -282,12 +334,12 @@ public final class SubterrainPlan {
if (chimneyRadius < 3) {
ceiling = baseY() + definition.height() + definition.chimneyHeight();
} else if (y >= tubeCeiling - 1) {
return new Column(tubeCeiling - 1, baseY() + definition.height() + definition.chimneyHeight(),
return new Column(tubeCeiling - 1, tubeCeiling - 1, baseY() + definition.height() + definition.chimneyHeight(),
head, false, true, 5 - chimneyRadius, Math.abs(offset), path);
}
}
boolean rim = definition.fluidDepth() > 0 && floor >= head && floor < head + 2;
return new Column(floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < taperedWidth,
return new Column(floor, floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < taperedWidth,
rim, Math.min(halfLength - Math.abs(u), taperedWidth - Math.abs(offset)), Math.abs(offset), path);
}
@@ -298,12 +350,16 @@ public final class SubterrainPlan {
}
double warped = u + definition.radius() * 0.2 * Math.sin(v / (double) Math.max(6, definition.radius()) * 2.4 + shapePhase)
+ definition.radius() * 0.08 * Math.sin(u / 29.0 + v / 19.0 + shapePhase);
warped += definition.shapeWarp() * definition.radius() * (0.7 * noise(u / 43.0, v / 27.0, 71)
+ 0.23 * noise(u / 13.0, v / 11.0, 73));
double segment = Math.max(4.0, definition.length() / (double) definition.terraceCount());
double terrace = (warped + definition.length() / 2.0) / segment;
int index = Math.max(0, Math.min(definition.terraceCount() - 1, (int) Math.floor(terrace)));
int rise = Math.max(1, (definition.height() / 3) / definition.terraceCount());
int floor = baseY() + index * rise;
int head = floor + definition.fluidDepth();
floor += (int) Math.round(definition.shapeWarp() * Math.max(0, definition.fluidDepth() - 1)
* (0.5 + 0.5 * erosion(u, v, 79)));
double local = (terrace - Math.floor(terrace)) * segment;
boolean rim = definition.fluidDepth() > 0 && (local <= 1.5 || local >= segment - 1.5 || footprint.boundary() <= 2);
double pathOffset = Math.copySign(Math.max(0, footprint.width() - 4), footprint.offset());
@@ -312,7 +368,7 @@ public final class SubterrainPlan {
floor = Math.max(floor, head + 1);
}
int ceiling = vaultedCeiling(u, footprint, floor, head);
return new Column(floor, ceiling, head, footprint.boundary() > 0,
return new Column(floor, baseFloor(floor, footprint.boundary(), u, v), ceiling, head, footprint.boundary() > 0,
false, footprint.boundary(), pathDistance, footprint.path() + (int) Math.round(pathOffset));
}
@@ -330,6 +386,18 @@ public final class SubterrainPlan {
double bend = family() == IrisSubterrainFamily.TECTONIC_FAULT ? 0.08 : 0.12;
int path = (int) Math.round(definition.radius() * bend * Math.sin(u / 47.0 + shapePhase) * taper);
double vaultWave = 0.87 + 0.07 * Math.sin(u / 37.0 + shapePhase) + 0.04 * Math.sin(u / 17.0 - shapePhase);
if (definition.shapeWarp() > 0) {
double strength = definition.shapeWarp();
double scale = Math.max(12, definition.radius());
double erodedWidth = definition.radius() * taper * (0.45
+ 0.22 * noise(u / (scale * 1.8), 0.37, 11)
+ 0.15 * noise(u / (scale * 0.48), 0.71, 13));
double erodedPath = definition.radius() * taper * (0.72 * noise(u / (scale * 2.2), 0.19, 17)
+ 0.25 * noise(u / (scale * 0.73), 0.53, 19));
width += strength * (erodedWidth - width);
path = (int) Math.round(path + strength * (erodedPath - path));
vaultWave += strength * (0.76 + 0.18 * noise(u / (scale * 1.1), 0.31, 23) - vaultWave);
}
Profile profile = new Profile(Math.min(width, definition.radius() - Math.abs(path)), path, vaultWave);
int slot = cache.profileNext;
cache.profileKeys[slot] = u;
@@ -348,6 +416,12 @@ public final class SubterrainPlan {
double width = profile.width();
int path = profile.path();
double offset = v - path;
if (definition.shapeWarp() > 0) {
double strength = definition.shapeWarp();
width += strength * (definition.radius() * 0.20 * noise(u / 17.0, v / 19.0, 29)
+ Math.min(2.5, definition.radius() * 0.08) * noise(u / 5.0, v / 5.0, 31));
width = Math.max(0, Math.min(width, definition.radius() - Math.abs(path)));
}
double boundary = Math.min(halfLength - Math.abs(u), width - Math.abs(offset));
if (boundary < -2) {
return null;
@@ -362,9 +436,112 @@ public final class SubterrainPlan {
double roofVariation = 0.05 * Math.sin(u / 37.0 + footprint.offset() / 17.0 + shapePhase) * arch
+ 0.06 * footprint.offset() / Math.max(1, footprint.width()) * Math.sin(u / 41.0 - shapePhase);
int ceiling = baseY() + (int) Math.floor(definition.height() * wave * (0.4 + 0.6 * arch + roofVariation));
if (definition.shapeWarp() > 0) {
double shoulder = 1 - smooth(Math.max(0, Math.min(1, footprint.boundary() / Math.min(8, footprint.width() * 0.6))));
double erodedRoof = definition.height() * (wave - 0.08 - shoulder * 0.24
+ 0.32 * noise(u / 23.0, footprint.offset() / 17.0, 35)
+ 0.20 * erosion(u, footprint.offset(), 37));
ceiling = baseY() + (int) Math.floor((1 - definition.shapeWarp()) * (ceiling - baseY())
+ definition.shapeWarp() * erodedRoof);
}
return Math.min(baseY() + definition.height(), Math.max(Math.max(floor, head) + 3, ceiling));
}
private Column erodedCenote(int u, int v) {
double radius = definition.radius();
if (Math.hypot(u, v) > radius + 2) {
return null;
}
double strength = definition.shapeWarp();
double scale = Math.max(8, radius * 0.65);
double shiftedU = u + strength * radius * 0.18 * noise(u / scale, v / scale, 101);
double shiftedV = v + strength * radius * 0.18 * noise(u / scale, v / scale, 103);
double lobes = 0.69 + 0.24 * noise(u / scale, v / scale, 105)
+ 0.13 * noise(u / (scale * 0.46), v / (scale * 0.46), 106);
double outline = radius * (1 - strength + strength * lobes)
+ strength * (radius * 0.07 * noise(u / 13.0, v / 13.0, 107)
+ Math.min(2, radius * 0.05) * noise(u / 4.0, v / 4.0, 109));
double distance = Math.hypot(shiftedU, shiftedV * (1 + 0.12 * strength));
double boundary = Math.min(radius - Math.hypot(u, v), outline - distance);
if (boundary < -2) {
return null;
}
double normalized = Math.min(1, distance / Math.max(1, outline));
double bankStart = 0.65 - strength * 0.11;
double bankWidth = 0.25 + strength * 0.15;
double bank = smooth(Math.max(0, Math.min(1, (normalized - bankStart) / bankWidth)));
double bed = Math.max(0, 0.42 + 0.58 * erosion(u, v, 113));
double bedRelief = definition.fluidDepth() == 0 ? definition.height() * 0.30
: Math.max(1, definition.fluidDepth() - 1);
int floor = baseY() + (int) Math.round(bank * (definition.fluidDepth() + 2 + strength)
+ strength * bed * bedRelief);
double arch = Math.sqrt(Math.max(0, 1 - normalized * normalized));
double shoulder = 1 - smooth(Math.max(0, Math.min(1, boundary / Math.max(4, radius * 0.2))));
double roof = definition.height() * ((1 - strength) * arch
+ strength * (0.64 - 0.24 * shoulder
+ 0.40 * noise(u / (scale * 0.6), v / (scale * 0.6), 123)
+ 0.23 * erosion(u, v, 127)));
int head = baseY() + definition.fluidDepth();
int ceiling = Math.min(baseY() + definition.height(), Math.max(floor + 3, baseY() + (int) Math.floor(roof)));
boolean rim = definition.fluidDepth() > 0 && ceiling <= head + 2;
return new Column(floor, baseFloor(floor, boundary, u, v), ceiling, head, boundary > 0, rim, boundary, Math.abs(v), 0);
}
private Column erodedTube(int u, int v) {
Footprint footprint = footprint(u, v);
if (footprint == null) {
return null;
}
double cross = Math.min(1, Math.abs(footprint.offset()) / Math.max(1, footprint.width()));
double bank = smooth(Math.max(0, Math.min(1, (cross - 0.38) / 0.55)));
int head = baseY() + definition.fluidDepth();
int floor = baseY() + (int) Math.round(bank * (definition.fluidDepth() + 3)
+ definition.shapeWarp() * definition.height() * 0.11
* Math.max(0, 0.35 + 0.65 * erosion(u, v, 137)));
int ceiling = vaultedCeiling(u, footprint, floor, head);
return new Column(floor, baseFloor(floor, footprint.boundary(), u, v), ceiling, head, footprint.boundary() > 0, false,
footprint.boundary(), Math.abs(footprint.offset()), footprint.path());
}
private int baseFloor(int floor, double boundary, int u, int v) {
int depth = definition.baseDepth();
if (depth == 0 || boundary <= 0) {
return floor;
}
double taper = Math.sqrt(Math.min(1, boundary / Math.max(4, definition.radius() * 0.55)));
double scale = Math.max(8, definition.radius() * 0.7);
double relief = Math.max(0, Math.min(1, 0.73 + 0.25 * noise(u / scale, v / scale, 157)
+ 0.14 * noise(u / 7.0, v / 7.0, 163)));
return floor - (int) Math.round(depth * taper * relief);
}
private double erosion(double u, double v, long salt) {
return 0.58 * noise(u / 31.0, v / 29.0, salt)
+ 0.29 * noise(u / 11.0, v / 13.0, salt + 1)
+ 0.13 * noise(u / 4.0, v / 4.0, salt + 2);
}
private double noise(double u, double v, long salt) {
int x = (int) Math.floor(u);
int z = (int) Math.floor(v);
double dx = smooth(u - x);
double dz = smooth(v - z);
double near = lattice(x, z, salt);
near += dx * (lattice(x + 1, z, salt) - near);
double far = lattice(x, z + 1, salt);
far += dx * (lattice(x + 1, z + 1, salt) - far);
return near + dz * (far - near);
}
private double lattice(int x, int z, long salt) {
return (SubterrainPlanner.mix(shapeSeed ^ x * 0x9e3779b97f4a7c15L
^ z * 0xc2b2ae3d27d4eb4fL ^ salt * 0x165667b19e3779f9L) >>> 11) * 0x1.0p-52 - 1;
}
private double smooth(double value) {
return value * value * (3 - 2 * value);
}
private SubterrainPosition occupiedAnchor(int x, int preferredY, int z) {
if (anchorCell(sample(x, preferredY, z))) {
return new SubterrainPosition(x, preferredY, z);
@@ -406,7 +583,7 @@ public final class SubterrainPlan {
private record Footprint(double width, double offset, double boundary, int path) {
}
private record Column(int floor, int ceiling, int head, boolean interior, boolean rim,
private record Column(int floor, int baseFloor, int ceiling, int head, boolean interior, boolean rim,
double boundary, double pathDistance, int pathOffset) {
}
}
@@ -15,6 +15,7 @@ public final class SubterrainPlanner {
private final int maxYExclusive;
private final ThreadLocal<CandidateTile> sampleTile = new ThreadLocal<>();
private final Predicate<SubterrainBounds> allowedBounds;
private final RegionResolver regionResolver;
public SubterrainPlanner(Options options) {
this(options, bounds -> true);
@@ -23,6 +24,7 @@ public final class SubterrainPlanner {
public SubterrainPlanner(Options options, Predicate<SubterrainBounds> allowedBounds) {
Objects.requireNonNull(options);
this.allowedBounds = Objects.requireNonNull(allowedBounds);
regionResolver = Objects.requireNonNull(options.regionResolver());
seed = options.seed();
minY = options.minY();
maxYExclusive = options.maxYExclusive();
@@ -49,6 +51,7 @@ public final class SubterrainPlanner {
private SubterrainPlanner(SubterrainPlanner source, Predicate<SubterrainBounds> allowedBounds) {
definitions = source.definitions;
regionResolver = source.regionResolver;
seed = source.seed;
minY = source.minY;
maxYExclusive = source.maxYExclusive;
@@ -128,10 +131,10 @@ public final class SubterrainPlanner {
SubterrainCell selected = SubterrainCell.OUTSIDE;
for (SubterrainPlan plan : candidates) {
SubterrainCell cell = plan.sample(x, y, z);
if (cell.solid()) {
if (cell.solid() && cell.room().reservedSolid()) {
return cell;
}
if (!selected.owned() && cell.owned()) {
if (!selected.owned() && cell.owned() || selected.solid() && cell.occupied()) {
selected = cell;
}
}
@@ -164,7 +167,7 @@ public final class SubterrainPlanner {
int spacing = definition.spacing();
long x = gridX * spacing + spacing / 4L + Math.floorMod(mix(value + 1), Math.max(1, spacing / 2));
long z = gridZ * spacing + spacing / 4L + Math.floorMod(mix(value + 2), Math.max(1, spacing / 2));
int bottomMargin = definition.height() / 2 + 3;
int bottomMargin = definition.height() / 2 + 3 + definition.baseDepth();
int topMargin = definition.height() - definition.height() / 2 + 3
+ (definition.family() == IrisSubterrainFamily.LAVA_TUBE ? definition.chimneyHeight() : 0);
long low = (long) Math.max(minY, definition.minY()) + bottomMargin;
@@ -173,6 +176,10 @@ public final class SubterrainPlanner {
|| z < Integer.MIN_VALUE + spacing || z > Integer.MAX_VALUE - spacing) {
return null;
}
if (!definition.allowedRegions().isEmpty()
&& !definition.allowedRegions().contains(regionResolver.regionAt((int) x, (int) z))) {
return null;
}
int y = (int) (low + Math.floorMod(mix(value + 3), high - low + 1));
SubterrainPlan plan = new SubterrainPlan(new SubterrainPlan.Options(definition, (int) x, y, (int) z,
(value & 1L) != 0, definition.id() + ":" + Long.toUnsignedString(value, 16)));
@@ -189,9 +196,16 @@ public final class SubterrainPlanner {
return (value >>> 11) * 0x1.0p-53;
}
public record Options(List<IrisSubterrainFeature> features, long seed, int minY, int maxYExclusive) {
@FunctionalInterface
public interface RegionResolver {
String regionAt(int x, int z);
}
public record Options(List<IrisSubterrainFeature> features, long seed, int minY, int maxYExclusive,
RegionResolver regionResolver) {
public Options {
features = List.copyOf(Objects.requireNonNull(features));
Objects.requireNonNull(regionResolver);
}
}
@@ -199,9 +213,14 @@ public final class SubterrainPlanner {
}
record Definition(String id, IrisSubterrainFamily family, IrisSubterrainFluid fluid, String biome, int spacing, double probability,
int length, int radius, int height, int fluidDepth, int chimneyHeight, int terraceCount,
int length, int radius, int height, double shapeWarp, int fluidDepth, int chimneyHeight, int terraceCount,
int pillarSpacing, double formationFraction, String solid, int priority,
int minY, int maxY, long seedSalt) {
int minY, int maxY, long seedSalt, Set<String> allowedRegions) {
int baseDepth() {
return family == IrisSubterrainFamily.TECTONIC_FAULT || fluidDepth == 0 ? 0
: (int) Math.ceil(Math.min(24, radius * 0.45) * shapeWarp);
}
static Definition from(IrisSubterrainFeature feature) {
String id = Objects.requireNonNull(feature.getId(), "Subterrain id").trim();
IrisSubterrainFamily family = Objects.requireNonNull(feature.getFamily(), "Subterrain family");
@@ -214,6 +233,7 @@ public final class SubterrainPlanner {
int horizontalReach = family == IrisSubterrainFamily.CENOTE ? radius : Math.max(length / 2, radius);
if (!id.matches("[a-zA-Z0-9_/-]+") || length < 16 || length > 2048 || radius < 6 || radius > 256
|| height < 8 || height > 192 || chimney < 0 || chimney > 96
|| !Double.isFinite(feature.getShapeWarp()) || feature.getShapeWarp() < 0 || feature.getShapeWarp() > 1
|| feature.getSpacing() < Math.max(32, 2 * horizontalReach + 8) || feature.getSpacing() > 8192
|| !Double.isFinite(feature.getProbability()) || feature.getProbability() < 0 || feature.getProbability() > 1
|| feature.getFluidDepth() < 0 || feature.getFluidDepth() > Math.min(32, height / 3)
@@ -239,15 +259,21 @@ public final class SubterrainPlanner {
if (!biome.isEmpty() && !biome.matches("[a-zA-Z0-9_/-]+")) {
throw new IllegalArgumentException("Invalid subterrain biome key: " + id);
}
Set<String> allowedRegions = Set.copyOf(Objects.requireNonNull(feature.getAllowedRegions()));
for (String region : allowedRegions) {
if (!region.matches("[a-zA-Z0-9_/-]+")) {
throw new IllegalArgumentException("Invalid subterrain region key: " + id);
}
}
long salt = 0xcbf29ce484222325L;
for (int i = 0; i < id.length(); i++) {
salt = (salt ^ id.charAt(i)) * 0x100000001b3L;
}
return new Definition(id, family, fluid, biome,
feature.getSpacing(), feature.getProbability(), length, radius, height, feature.getFluidDepth(),
feature.getSpacing(), feature.getProbability(), length, radius, height, feature.getShapeWarp(), feature.getFluidDepth(),
chimney, feature.getTerraceCount(), feature.getPillarSpacing(), feature.getFormationFraction(),
solid, feature.getPriority(), (int) Math.ceil(feature.getWorldYRange().getMin()),
(int) Math.floor(feature.getWorldYRange().getMax()), salt);
(int) Math.floor(feature.getWorldYRange().getMax()), salt, allowedRegions);
}
}
}
@@ -74,7 +74,7 @@ public final class SubterrainRasterizer {
}
public static boolean protectsPlacement(SubterrainCell cell) {
return cell != null && cell.owned() && (cell.solid() || cell.room().reservedPassage());
return cell != null && cell.owned() && (cell.room().reservedSolid() || cell.room().reservedPassage());
}
@FunctionalInterface
@@ -1,6 +1,8 @@
package art.arcane.iris.generation.subterrain;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.decoration.IrisDecorationPart;
import art.arcane.iris.generation.decoration.IrisDecorator;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.pack.loading.IrisData;
@@ -9,8 +11,10 @@ import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class SubterrainSurfaceMaterials {
private final SubterrainPlanner planner;
@@ -21,6 +25,7 @@ public final class SubterrainSurfaceMaterials {
private final RNG random;
private final int minimumY;
private final Map<String, IrisBiome> biomes = new HashMap<>();
private final Map<String, Set<String>> floorSupports = new HashMap<>();
public SubterrainSurfaceMaterials(SubterrainPlanner planner, List<SubterrainPlan> plans,
IrisDimension dimension, IrisComplex complex, IrisData data,
@@ -43,11 +48,7 @@ public final class SubterrainSurfaceMaterials {
if (surface == Surface.INTERIOR) {
return fallback;
}
String key = cell.room().biome();
if (!biomes.containsKey(key)) {
biomes.put(key, data.getBiomeLoader().load(key));
}
IrisBiome biome = biomes.get(key);
IrisBiome biome = biome(cell.room().biome());
if (biome == null) {
return fallback;
}
@@ -63,6 +64,31 @@ public final class SubterrainSurfaceMaterials {
return SubterrainRasterizer.isRetainedSolid(candidate) ? candidate : fallback;
}
public boolean retainsFloorSupport(int x, int worldY, int z, SubterrainCell cell, NativeBlockState existing) {
if (!cell.solid() || cell.room().biome().isEmpty() || !SubterrainRasterizer.isRetainedSolid(existing)) {
return false;
}
String key = cell.room().biome();
Set<String> supports = floorSupports.get(key);
if (supports == null) {
supports = new HashSet<>();
IrisBiome biome = biome(key);
if (biome != null) {
for (IrisDecorator decorator : biome.getDecoratorBucket(IrisDecorationPart.NONE)) {
if (decorator.getForceBlock() == null) {
continue;
}
NativeBlockState support = decorator.getForceBlock().getBlockData(data);
if (SubterrainRasterizer.isRetainedSolid(support)) {
supports.add(support.key());
}
}
}
floorSupports.put(key, supports);
}
return supports.contains(existing.key()) && surface(x, worldY, z, cell) == Surface.FLOOR;
}
public Surface surface(int x, int worldY, int z, SubterrainCell cell) {
if (!cell.solid()) {
return Surface.INTERIOR;
@@ -86,6 +112,13 @@ public final class SubterrainSurfaceMaterials {
return neighbor.occupied() && neighbor.room().featureId().equals(cell.room().featureId());
}
private IrisBiome biome(String key) {
if (!biomes.containsKey(key)) {
biomes.put(key, data.getBiomeLoader().load(key));
}
return biomes.get(key);
}
public enum Surface {
FLOOR,
CEILING,
@@ -0,0 +1,80 @@
package art.arcane.iris.generation.terrain;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.image.IrisImageMapRuntime;
import art.arcane.iris.generation.image.IrisImageMapApplication;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.studio.generation.BiomeBuffetLayout;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
public final class RegionSelection {
private RegionSelection() {
}
public static ProceduralStream<IrisRegion> select(Options options) {
if (options.focused() != null) {
return options.focused();
}
KList<IrisRegion> regions = new KList<>();
for (IrisRegion region : options.data().getRegionLoader().loadAll(options.dimension().getRegions())) {
if (!region.isCompatExcluded()) {
regions.add(region);
}
}
ProceduralStream<IrisRegion> selected = options.style().selectRarity(regions);
if (!options.imageMaps().has(IrisImageMapApplication.REGION)) {
return selected;
}
return selected.convertAware2D((region, x, z) -> {
IrisRegion mapped = options.imageMaps().sampleRegion(x, z);
return mapped == null ? region : mapped;
});
}
public static ProceduralStream<IrisRegion> restore(Snapshot snapshot) {
IrisData data = snapshot.data();
IrisDimension dimension = snapshot.dimension();
ProceduralStream<IrisRegion> focused = null;
if (snapshot.buffet() && dimension.getStudioMode().biomeSizeChunks() > 0) {
BiomeBuffetLayout layout = new BiomeBuffetLayout(dimension, () -> data);
if (!layout.cells().isEmpty()) {
focused = ProceduralStream.of((x, z) -> layout.terrain(x, z).region(),
Interpolated.of(value -> 0D, value -> layout.cells().getFirst().region()));
}
}
if (focused == null) {
IrisRegion focus = focusRegion(data, dimension);
if (focus != null) {
focused = ProceduralStream.of((x, z) -> focus, Interpolated.of(value -> 0D, value -> focus));
}
}
return select(new Options(dimension, data,
dimension.getRegionStyle().create(new RNG(snapshot.complexSeed()).nextParallelRNG(883), data)
.stream().zoom(dimension.getRegionZoom()),
IrisImageMapRuntime.compile(data, dimension, dimension.getMinHeight()), focused));
}
private static IrisRegion focusRegion(IrisData data, IrisDimension dimension) {
if (dimension.getFocus() != null && !dimension.getFocus().isBlank()) {
IrisBiome focus = data.getBiomeLoader().load(dimension.getFocus());
if (focus != null && !focus.isCompatExcluded()) {
return dimension.resolveFocusRegion(focus.withInferredType(InferredType.LAND), () -> data);
}
}
if (dimension.getFocusRegion() == null || dimension.getFocusRegion().isBlank()) {
return null;
}
IrisRegion focus = data.getRegionLoader().load(dimension.getFocusRegion());
return focus == null || focus.isCompatExcluded() ? null : focus;
}
public record Options(IrisDimension dimension, IrisData data, ProceduralStream<Double> style,
IrisImageMapRuntime imageMaps, ProceduralStream<IrisRegion> focused) {
}
public record Snapshot(IrisDimension dimension, IrisData data, long complexSeed, boolean buffet) {
}
}
@@ -11,6 +11,10 @@ import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.generation.terrain.RegionSelection;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.cache.AtomicCache;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.iris.spi.IrisLogging;
import java.io.IOException;
@@ -521,11 +525,16 @@ public final class SavedBiomeRuntime implements AutoCloseable {
if (dimension.allowsSubterrainFeatures()) {
SubterrainRasterizer.validateMaterials(dimension.getSubterrainFeatures());
}
AtomicCache<ProceduralStream<IrisRegion>> regionSelection = new AtomicCache<>();
Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data),
resolveFocusRegions(data, dimension), new SubterrainPlanner(new SubterrainPlanner.Options(
dimension.allowsSubterrainFeatures() ? dimension.getSubterrainFeatures() : List.of(),
new SeedManager(epoch.worldSeed()).getBodies(),
dimension.getMinHeight(), dimension.getMaxHeight())));
dimension.getMinHeight(), dimension.getMaxHeight(),
(x, z) -> regionSelection.aquire(() -> RegionSelection.restore(new RegionSelection.Snapshot(
dimension, data, new SeedManager(epoch.worldSeed()).getComplex(),
engine.isStudio() && "bukkit".equals(IrisPlatforms.get().platformName()))))
.get(x, z).getLoadKey())));
data.prepareBlockDropRules();
definitions.put(epochId, loaded);
return loaded;