mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Volumetric surface terrain
- Add an optional per-biome `terrain3D` profile with density and fissure noise styles, amplitude, horizontal and vertical feature scales, crack depth, width and scale, slope and fluid-clearance gates, and a seed offset, each range-checked before use - Add a terrain runtime that samples the signed density and fissure fields on a four-block lattice around the biome's generated height and resolves each column into solid spans, so surfaces gain projecting rock, undercuts, ledges and narrow cracks that fade out on flat ground and near the fluid line - Remove unsupported rock by flood-filling span components across neighbouring columns and dropping any fragment of 512 blocks or fewer, while larger connected masses are kept - Report the shaped column top as the natural terrain height, keep the unshaped generator height available as a separate stream, and measure surface slope between neighbouring ledge floors instead of the flat heightmap - Write air through the gaps in the normal terrain actuator, generate a fresh surface palette for every ledge floor, and paint ceiling layers beneath undercuts - Carry each layer's own column through the dimension stack and upper dimension so stack top heights, solidity and surface lookups follow the spans, and each exposed face draws its palette from its own source height and slope - Sample a layer's slope condition once per column at the surface being generated, and gate surface decorators on the slope of the ledge they sit on rather than the column heightmap - Decorate every exposed ledge floor and the ceiling above it during the decorant pass - Treat shaped openings as surface rather than cave when resolving biomes, skip them during cave carving, suppress cave zone markers inside them, and resolve their boundary biome from the surface biome stream - Include shaped openings in mantle carve queries and carved columns, fall back to terrain solidity for surface-solid checks across stack, upper and normal terrain, and answer both queries from the island mask when placing objects on floating islands - Let the hydrology planned surface report whether it owns a column, so the cave voxel view consults natural terrain solidity where hydrology does not own the terrain, and split replayed plan observations into height and ownership records - Validate `terrain3D` on biomes and `snippet/terrain-3d` snippets as a blocking pack error, rejecting unknown fields, wrong types, out-of-range numbers, unknown noise styles, style nesting past 32 levels and snippet references that escape the pack - Copy the whole snippet tree during modded studio pack export through one shared routine instead of only `snippet/style`, so terrain and nested style snippets survive export - Add a terrain probe and Gradle task that generates real pack chunks, compares predicted column geometry against the actual blocks, requires retained overhang coverage for named biomes, reports detached-component topology, and writes cross-section images with a JSON summary - Enumerate image-mapped regions and skip compat-excluded ones when collecting biomes for stacked and upper dimension terrain
This commit is contained in:
+1
-22
@@ -73,8 +73,6 @@ import org.zeroturnaround.zip.ZipUtil;
|
||||
import java.awt.Desktop;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -997,7 +995,7 @@ public final class ModdedStudioCommands {
|
||||
for (String key : Stream.of(dm.getExpressionLoader().getPossibleKeys()).sorted().toList()) {
|
||||
hashes.append(copyJson(folder, "expressions", key, dm.getExpressionLoader().findFile(key)));
|
||||
}
|
||||
hashes.append(copyStyleSnippets(packFolder, folder));
|
||||
hashes.append(PackExportClosure.copySnippets(packFolder, folder));
|
||||
for (String key : regionKeys) {
|
||||
hashes.append(copyJson(folder, "regions", key, dm.getRegionLoader().findFile(key)));
|
||||
}
|
||||
@@ -1060,25 +1058,6 @@ public final class ModdedStudioCommands {
|
||||
}
|
||||
}
|
||||
|
||||
private static String copyStyleSnippets(File packFolder, File folder) throws IOException {
|
||||
Path sourceRoot = new File(packFolder, "snippet/style").toPath();
|
||||
if (!Files.isDirectory(sourceRoot)) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder hashes = new StringBuilder();
|
||||
try (Stream<Path> files = Files.walk(sourceRoot)) {
|
||||
for (Path source : files.filter(Files::isRegularFile)
|
||||
.filter(path -> path.getFileName().toString().endsWith(".json"))
|
||||
.sorted()
|
||||
.toList()) {
|
||||
String relative = sourceRoot.relativize(source).toString().replace(File.separatorChar, '/');
|
||||
String key = relative.substring(0, relative.length() - ".json".length());
|
||||
hashes.append(copyJson(folder, "snippet/style", key, source.toFile()));
|
||||
}
|
||||
}
|
||||
return hashes.toString();
|
||||
}
|
||||
|
||||
private static int regions(CommandSourceStack source, int radius) {
|
||||
ServerPlayer player = source.getPlayer();
|
||||
if (player == null) {
|
||||
|
||||
@@ -24,9 +24,16 @@ import art.arcane.iris.engine.object.IrisObjectMarker;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.engine.object.IrisStaticObject;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Shared key collection for the pack packagers. Both the Bukkit re-serializing compiler and the
|
||||
@@ -38,6 +45,26 @@ public final class PackExportClosure {
|
||||
private PackExportClosure() {
|
||||
}
|
||||
|
||||
public static String copySnippets(File packFolder, File targetFolder) throws IOException {
|
||||
Path sourceRoot = new File(packFolder, "snippet").toPath();
|
||||
if (!Files.isDirectory(sourceRoot)) {
|
||||
return "";
|
||||
}
|
||||
Path targetRoot = new File(targetFolder, "snippet").toPath();
|
||||
StringBuilder hashes = new StringBuilder();
|
||||
try (Stream<Path> files = Files.walk(sourceRoot)) {
|
||||
for (Path source : files.filter(Files::isRegularFile)
|
||||
.filter(path -> path.getFileName().toString().endsWith(".json"))
|
||||
.sorted()
|
||||
.toList()) {
|
||||
String json = new JSONObject(IO.readAll(source.toFile())).toString(0);
|
||||
IO.writeAll(targetRoot.resolve(sourceRoot.relativize(source)).toFile(), json);
|
||||
hashes.append(IO.hash(json));
|
||||
}
|
||||
}
|
||||
return hashes.toString();
|
||||
}
|
||||
|
||||
public static KSet<String> collectDimensionKeys(IrisDimension dimension) {
|
||||
return collectDimensionKeys(dimension, key -> dimension == null || dimension.getLoader() == null
|
||||
? null
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisTerrain3D;
|
||||
import art.arcane.iris.engine.object.NoiseStyle;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
final class PackTerrain3DValidator {
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final Set<String> PROFILE_FIELDS = Set.of("$schema", "enabled", "seed", "amplitude",
|
||||
"horizontalScale", "verticalScale", "densityStyle", "crackDepth", "crackWidth", "crackScale",
|
||||
"crackStyle", "minimumSlope", "slopeFade", "fluidClearance", "fluidFade");
|
||||
private static final Set<String> STYLE_FIELDS = Set.of("$schema", "style", "cellularFrequency",
|
||||
"cellularZoom", "zoom", "expression", "imageMap", "multiplier", "fracture", "exponent", "cacheSize");
|
||||
|
||||
private PackTerrain3DValidator() {
|
||||
}
|
||||
|
||||
static List<String> validate(File packFolder) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
if (packFolder == null || !packFolder.isDirectory()) {
|
||||
return errors;
|
||||
}
|
||||
File biomeFolder = new File(packFolder, "biomes");
|
||||
for (File biomeFile : files(biomeFolder)) {
|
||||
JsonObject biome = readObject(biomeFile, "Biome '" + PackValidationIo.deriveKey(biomeFolder, biomeFile)
|
||||
+ "'", errors);
|
||||
if (biome != null && biome.has("terrain3D") && !biome.get("terrain3D").isJsonNull()) {
|
||||
validateProfile(packFolder, biome.get("terrain3D"),
|
||||
"Biome '" + PackValidationIo.deriveKey(biomeFolder, biomeFile) + "' terrain3D", errors);
|
||||
}
|
||||
}
|
||||
File snippetFolder = new File(packFolder, "snippet/terrain-3d");
|
||||
for (File snippetFile : files(snippetFolder)) {
|
||||
String location = "Terrain3D snippet '" + PackValidationIo.deriveKey(snippetFolder, snippetFile) + "'";
|
||||
JsonObject profile = readObject(snippetFile, location, errors);
|
||||
if (profile != null) {
|
||||
validateProfile(packFolder, profile, location, errors);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static List<File> files(File folder) {
|
||||
if (!folder.isDirectory()) {
|
||||
return List.of();
|
||||
}
|
||||
List<File> files = PackValidationIo.listJsonRecursive(folder);
|
||||
files.sort(Comparator.comparing(File::getPath));
|
||||
return files;
|
||||
}
|
||||
|
||||
private static void validateProfile(File packFolder, JsonElement raw, String location, List<String> errors) {
|
||||
JsonObject object = resolveObject(packFolder, raw, "terrain-3d", location, errors);
|
||||
if (object == null) {
|
||||
return;
|
||||
}
|
||||
JsonObject normalized = object.deepCopy();
|
||||
int initialErrors = errors.size();
|
||||
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
|
||||
String field = entry.getKey();
|
||||
JsonElement value = entry.getValue();
|
||||
String path = location + "." + field;
|
||||
if (!PROFILE_FIELDS.contains(field)) {
|
||||
errors.add(path + " is not a terrain3D field.");
|
||||
continue;
|
||||
}
|
||||
switch (field) {
|
||||
case "$schema" -> requireString(value, path, false, errors);
|
||||
case "enabled" -> {
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) {
|
||||
errors.add(path + " must be a JSON boolean.");
|
||||
}
|
||||
}
|
||||
case "seed" -> requireInteger(value, path, errors);
|
||||
case "densityStyle", "crackStyle" -> {
|
||||
JsonObject style = validateStyle(packFolder, value, path, 0, errors);
|
||||
if (style != null) {
|
||||
normalized.add(field, style);
|
||||
}
|
||||
}
|
||||
default -> requireNumber(value, path, errors);
|
||||
}
|
||||
}
|
||||
if (initialErrors != errors.size()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
GSON.fromJson(normalized, IrisTerrain3D.class).validate();
|
||||
} catch (JsonParseException | IllegalArgumentException invalid) {
|
||||
errors.add(location + ": " + invalid.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject validateStyle(File packFolder, JsonElement raw, String location,
|
||||
int depth, List<String> errors) {
|
||||
if (depth > 32) {
|
||||
errors.add(location + " exceeds the maximum style nesting depth of 32.");
|
||||
return null;
|
||||
}
|
||||
JsonObject object = resolveObject(packFolder, raw, "style", location, errors);
|
||||
if (object == null) {
|
||||
return null;
|
||||
}
|
||||
JsonObject normalized = object.deepCopy();
|
||||
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
|
||||
String field = entry.getKey();
|
||||
JsonElement value = entry.getValue();
|
||||
String path = location + "." + field;
|
||||
if (!STYLE_FIELDS.contains(field)) {
|
||||
errors.add(path + " is not a generator style field.");
|
||||
continue;
|
||||
}
|
||||
switch (field) {
|
||||
case "$schema" -> requireString(value, path, false, errors);
|
||||
case "expression", "imageMap" -> requireString(value, path, true, errors);
|
||||
case "style" -> {
|
||||
if (requireString(value, path, false, errors)) {
|
||||
try {
|
||||
NoiseStyle.valueOf(value.getAsString());
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
errors.add(path + " is not a known noise style.");
|
||||
}
|
||||
}
|
||||
}
|
||||
case "fracture" -> {
|
||||
if (!value.isJsonNull()) {
|
||||
JsonObject fracture = validateStyle(packFolder, value, path, depth + 1, errors);
|
||||
if (fracture != null) {
|
||||
normalized.add(field, fracture);
|
||||
}
|
||||
}
|
||||
}
|
||||
case "cacheSize" -> {
|
||||
if (requireInteger(value, path, errors)) {
|
||||
requireRange(value, path, 0D, 8192D, errors);
|
||||
}
|
||||
}
|
||||
case "exponent" -> requireRange(value, path, 0.01562D, 64D, errors);
|
||||
case "cellularFrequency" -> requireRange(value, path, 0D, Double.MAX_VALUE, errors);
|
||||
default -> requireRange(value, path, 0.00001D, Double.MAX_VALUE, errors);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static JsonObject resolveObject(File packFolder, JsonElement raw, String snippetType,
|
||||
String location, List<String> errors) {
|
||||
if (raw.isJsonObject()) {
|
||||
return raw.getAsJsonObject();
|
||||
}
|
||||
if (!raw.isJsonPrimitive() || !raw.getAsJsonPrimitive().isString()
|
||||
|| !raw.getAsString().startsWith("snippet/")) {
|
||||
errors.add(location + " must be an object or snippet reference.");
|
||||
return null;
|
||||
}
|
||||
String reference = raw.getAsString();
|
||||
String prefix = "snippet/" + snippetType + "/";
|
||||
String resolved = reference.startsWith(prefix) ? reference : prefix + reference.substring(8);
|
||||
Path root = packFolder.toPath().toAbsolutePath().normalize();
|
||||
Path path = root.resolve(resolved + ".json").normalize();
|
||||
if (!path.startsWith(root.resolve(prefix)) || !Files.isRegularFile(path)) {
|
||||
errors.add(location + " references an unavailable " + snippetType + " snippet '" + reference + "'.");
|
||||
return null;
|
||||
}
|
||||
return readObject(path.toFile(), location + " (" + reference + ")", errors);
|
||||
}
|
||||
|
||||
private static JsonObject readObject(File file, String location, List<String> errors) {
|
||||
try {
|
||||
JsonElement parsed = JsonParser.parseString(Files.readString(file.toPath(), StandardCharsets.UTF_8));
|
||||
if (parsed.isJsonObject()) {
|
||||
return parsed.getAsJsonObject();
|
||||
}
|
||||
errors.add(location + " must contain a JSON object.");
|
||||
} catch (IOException | JsonParseException invalid) {
|
||||
errors.add(location + " contains unreadable JSON: " + invalid.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean requireString(JsonElement value, String path, boolean nullable, List<String> errors) {
|
||||
if (nullable && value.isJsonNull()) {
|
||||
return true;
|
||||
}
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
|
||||
errors.add(path + " must be a JSON string" + (nullable ? " or null." : "."));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean requireNumber(JsonElement value, String path, List<String> errors) {
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()
|
||||
|| !Double.isFinite(value.getAsDouble())) {
|
||||
errors.add(path + " must be a finite JSON number.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean requireInteger(JsonElement value, String path, List<String> errors) {
|
||||
if (!requireNumber(value, path, errors)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
value.getAsBigDecimal().longValueExact();
|
||||
return true;
|
||||
} catch (ArithmeticException invalid) {
|
||||
errors.add(path + " must be an integer in the signed 64-bit range.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireRange(JsonElement value, String path, double minimum,
|
||||
double maximum, List<String> errors) {
|
||||
if (requireNumber(value, path, errors)
|
||||
&& (value.getAsDouble() < minimum || value.getAsDouble() > maximum)) {
|
||||
errors.add(path + " must be between " + minimum + " and " + maximum + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ public final class PackValidator {
|
||||
addDistinct(blockingErrors, hydrologyValidation.errors());
|
||||
addDistinct(warnings, hydrologyValidation.warnings());
|
||||
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
|
||||
addDistinct(blockingErrors, PackTerrain3DValidator.validate(packFolder));
|
||||
PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder);
|
||||
addDistinct(blockingErrors, lootIssues.errors());
|
||||
addDistinct(warnings, lootIssues.warnings());
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class DimensionStackContext {
|
||||
private final List<DimensionTerrainContext> layersBottomToTop;
|
||||
private final List<SeamOffsetSampler> seamOffsetSamplersBottomToTop;
|
||||
private final Cache<Long, DimensionStackLayout> layoutCache;
|
||||
private final boolean volumetric;
|
||||
|
||||
private DimensionStackContext(ContextState state) {
|
||||
engine = state.engine();
|
||||
@@ -41,6 +42,7 @@ public final class DimensionStackContext {
|
||||
layersTopToBottom = List.copyOf(reversed);
|
||||
seamOffsetSamplersBottomToTop = List.copyOf(state.seamOffsetSamplersBottomToTop());
|
||||
layoutCache = Caffeine.newBuilder().maximumSize(LAYOUT_CACHE_SIZE).build();
|
||||
volumetric = layersBottomToTop.stream().anyMatch(DimensionTerrainContext::hasTerrain3D);
|
||||
}
|
||||
|
||||
public static DimensionStackContext create(Engine engine, IrisDimensionStack stack) {
|
||||
@@ -191,7 +193,8 @@ public final class DimensionStackContext {
|
||||
column.fluidBlock(),
|
||||
column.surfaceBlock(),
|
||||
normalTerrainHeight,
|
||||
fluidHeight
|
||||
fluidHeight,
|
||||
column.terrainColumn()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -244,6 +247,11 @@ public final class DimensionStackContext {
|
||||
}
|
||||
|
||||
private long sampleTopHeights(int x, int z) {
|
||||
if (volumetric) {
|
||||
DimensionStackLayout layout = getLayout(x, z);
|
||||
return ((long) layout.clippedStackTerrainTopY() << 32)
|
||||
| (layout.clippedStackTopY() & 0xFFFFFFFFL);
|
||||
}
|
||||
boolean naturalFallback = usesTemporaryNaturalFallback(x, z);
|
||||
int baseY = 0;
|
||||
int renderedTerrainTopY = Integer.MIN_VALUE;
|
||||
|
||||
@@ -2,6 +2,7 @@ package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -15,6 +16,7 @@ public final class DimensionStackLayout {
|
||||
private final int stackTopY;
|
||||
private final int clippedStackTerrainTopY;
|
||||
private final int clippedStackTopY;
|
||||
private final boolean volumetric;
|
||||
|
||||
private DimensionStackLayout(LayoutState state) {
|
||||
layersBottomToTop = List.copyOf(state.layersBottomToTop());
|
||||
@@ -25,6 +27,7 @@ public final class DimensionStackLayout {
|
||||
stackTopY = state.stackTopY();
|
||||
clippedStackTerrainTopY = state.clippedStackTerrainTopY();
|
||||
clippedStackTopY = state.clippedStackTopY();
|
||||
volumetric = layersBottomToTop.stream().anyMatch(layer -> layer.terrainColumn() != null);
|
||||
}
|
||||
|
||||
static DimensionStackLayout create(
|
||||
@@ -80,6 +83,15 @@ public final class DimensionStackLayout {
|
||||
int renderMinY = Math.max(0, baseY);
|
||||
int renderMaxY = Math.min(outputMaxY, contentTopY);
|
||||
boolean visible = renderMinY <= renderMaxY;
|
||||
int clippedSurfaceY = clip(surfaceY, outputMaxY);
|
||||
if (input.terrainColumn() != null) {
|
||||
int maximumSourceY = Math.clamp((long) renderMaxY - baseY,
|
||||
Integer.MIN_VALUE, Integer.MAX_VALUE);
|
||||
int sourceY = input.terrainColumn().highestSolidY(maximumSourceY);
|
||||
int visibleY = saturatedAdd(baseY, sourceY);
|
||||
clippedSurfaceY = sourceY >= 0 && visibleY >= renderMinY && visible
|
||||
? visibleY : -1;
|
||||
}
|
||||
Layer layer = new Layer(
|
||||
input.terrainContext(),
|
||||
input.biome(),
|
||||
@@ -97,9 +109,10 @@ public final class DimensionStackLayout {
|
||||
seamOffsetBelow,
|
||||
renderMinY,
|
||||
renderMaxY,
|
||||
clip(surfaceY, outputMaxY),
|
||||
clippedSurfaceY,
|
||||
clip(fluidY, outputMaxY),
|
||||
visible
|
||||
visible,
|
||||
input.terrainColumn()
|
||||
);
|
||||
layers.add(layer);
|
||||
stackTerrainTopY = Math.max(stackTerrainTopY, surfaceY);
|
||||
@@ -122,6 +135,14 @@ public final class DimensionStackLayout {
|
||||
}
|
||||
}
|
||||
|
||||
boolean volumetric = false;
|
||||
for (Layer layer : layers) {
|
||||
volumetric |= layer.terrainColumn() != null;
|
||||
}
|
||||
if (volumetric) {
|
||||
renderedTerrainTopY = highestVisibleY(layers, outputMaxY, false);
|
||||
renderedContentTopY = highestVisibleY(layers, outputMaxY, true);
|
||||
}
|
||||
return new DimensionStackLayout(new LayoutState(
|
||||
layers,
|
||||
stackTerrainTopY,
|
||||
@@ -182,6 +203,33 @@ public final class DimensionStackLayout {
|
||||
return layer.clippedSurfaceY();
|
||||
}
|
||||
|
||||
private static int highestVisibleY(List<Layer> layers, int maximumY, boolean includeFluid) {
|
||||
int highest = -1;
|
||||
for (int sourceIndex = 0; sourceIndex < layers.size(); sourceIndex++) {
|
||||
Layer source = layers.get(sourceIndex);
|
||||
int candidate = includeFluid ? source.highestContentY(maximumY) : source.highestSolidY(maximumY);
|
||||
int index = sourceIndex + 1;
|
||||
while (candidate > highest && index < layers.size()) {
|
||||
Layer overlay = layers.get(index);
|
||||
Layer lower = layers.get(index - 1);
|
||||
int nextMaximum = candidate;
|
||||
if (overlay.containsRenderedY(candidate)) {
|
||||
nextMaximum = overlay.renderMinY() - 1;
|
||||
} else if ((long) candidate > lower.contentTopY() && (long) candidate < overlay.localBaseY()) {
|
||||
nextMaximum = lower.contentTopY();
|
||||
}
|
||||
if (nextMaximum < candidate) {
|
||||
candidate = includeFluid ? source.highestContentY(nextMaximum) : source.highestSolidY(nextMaximum);
|
||||
index = sourceIndex + 1;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
highest = Math.max(highest, candidate);
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
public List<Layer> layersBottomToTop() {
|
||||
return layersBottomToTop;
|
||||
}
|
||||
@@ -207,6 +255,10 @@ public final class DimensionStackLayout {
|
||||
}
|
||||
|
||||
public Layer topTerrainLayer() {
|
||||
if (volumetric) {
|
||||
Layer owner = layerAt(clippedStackTerrainTopY);
|
||||
return isSolid(clippedStackTerrainTopY) ? owner : null;
|
||||
}
|
||||
Layer highest = null;
|
||||
int highestSurfaceY = Integer.MIN_VALUE;
|
||||
for (int layerIndex = 0; layerIndex < layersBottomToTop.size(); layerIndex++) {
|
||||
@@ -278,6 +330,20 @@ public final class DimensionStackLayout {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public boolean isSolid(int y) {
|
||||
for (int index = layersBottomToTop.size() - 1; index > 0; index--) {
|
||||
Layer layer = layersBottomToTop.get(index);
|
||||
if (layer.containsRenderedY(y)) {
|
||||
return layer.isSolid(y);
|
||||
}
|
||||
Layer lower = layersBottomToTop.get(index - 1);
|
||||
if ((long) y > lower.contentTopY() && (long) y < layer.localBaseY()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return layersBottomToTop.get(0).isSolid(y);
|
||||
}
|
||||
|
||||
public boolean containsUpperLayerY(int y) {
|
||||
for (int layerIndex = 1; layerIndex < layersBottomToTop.size(); layerIndex++) {
|
||||
if (layersBottomToTop.get(layerIndex).containsRenderedY(y)) {
|
||||
@@ -307,7 +373,8 @@ public final class DimensionStackLayout {
|
||||
PlatformBlockState fluidBlock,
|
||||
PlatformBlockState surfaceBlock,
|
||||
int normalTerrainHeight,
|
||||
int fluidHeight
|
||||
int fluidHeight,
|
||||
Terrain3DColumn terrainColumn
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -330,12 +397,51 @@ public final class DimensionStackLayout {
|
||||
int renderMaxY,
|
||||
int clippedSurfaceY,
|
||||
int clippedFluidY,
|
||||
boolean visible
|
||||
boolean visible,
|
||||
Terrain3DColumn terrainColumn
|
||||
) {
|
||||
public boolean containsRenderedY(int y) {
|
||||
return visible && y >= renderMinY && y <= renderMaxY;
|
||||
}
|
||||
|
||||
public boolean isSolid(int y) {
|
||||
if (!containsRenderedY(y)) {
|
||||
return false;
|
||||
}
|
||||
int sourceY = y - localBaseY;
|
||||
return terrainColumn == null ? sourceY <= normalTerrainHeight : terrainColumn.isSolid(sourceY);
|
||||
}
|
||||
|
||||
public int surfaceAt(int y) {
|
||||
if (!isSolid(y)) {
|
||||
return -1;
|
||||
}
|
||||
return terrainColumn == null ? surfaceY
|
||||
: saturatedAdd(localBaseY, terrainColumn.surfaceY(y - localBaseY));
|
||||
}
|
||||
|
||||
public int highestSolidY(int maximumY) {
|
||||
if (!visible || maximumY < renderMinY) {
|
||||
return -1;
|
||||
}
|
||||
int maximumSourceY = Math.clamp((long) Math.min(maximumY, renderMaxY) - localBaseY,
|
||||
Integer.MIN_VALUE, Integer.MAX_VALUE);
|
||||
int sourceY = terrainColumn == null ? Math.min(normalTerrainHeight, maximumSourceY)
|
||||
: terrainColumn.highestSolidY(maximumSourceY);
|
||||
int worldY = saturatedAdd(localBaseY, sourceY);
|
||||
return sourceY >= 0 && worldY >= renderMinY ? worldY : -1;
|
||||
}
|
||||
|
||||
public int highestContentY(int maximumY) {
|
||||
if (visible && fluidHeight > normalTerrainHeight) {
|
||||
int fluidTop = Math.min(maximumY, Math.min(renderMaxY, fluidY));
|
||||
if (fluidTop >= renderMinY && fluidTop > surfaceY) {
|
||||
return fluidTop;
|
||||
}
|
||||
}
|
||||
return highestSolidY(maximumY);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private record LayoutState(
|
||||
|
||||
@@ -11,6 +11,8 @@ import art.arcane.iris.engine.object.IrisGenerator;
|
||||
import art.arcane.iris.engine.object.IrisInterpolator;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisShapedGeneratorStyle;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DRuntime;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.DataProvider;
|
||||
@@ -48,6 +50,8 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
private final IrisImageMapRuntime imageMapRuntime;
|
||||
private final boolean selfReferencing;
|
||||
private final SelfFallback selfFallback;
|
||||
private final Terrain3DRuntime terrain3D;
|
||||
private final boolean naturalSelf;
|
||||
|
||||
private DimensionTerrainContext(ContextState state) {
|
||||
engine = state.engine();
|
||||
@@ -64,6 +68,8 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
imageMapRuntime = state.imageMapRuntime();
|
||||
selfReferencing = state.selfReferencing();
|
||||
selfFallback = state.selfFallback();
|
||||
terrain3D = state.terrain3D();
|
||||
naturalSelf = state.naturalSelf();
|
||||
}
|
||||
|
||||
public static DimensionTerrainContext forStack(Engine engine, IrisDimension dimension) {
|
||||
@@ -130,7 +136,9 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
fluidBlockSampler,
|
||||
complex.getImageMapRuntime(),
|
||||
true,
|
||||
selfFallback
|
||||
selfFallback,
|
||||
null,
|
||||
natural
|
||||
));
|
||||
}
|
||||
|
||||
@@ -175,7 +183,7 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
focusRegion.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
|
||||
biome, dataProvider, allBiomes, generators));
|
||||
} else {
|
||||
for (IrisRegion region : dimension.getAllRegions(dataProvider)) {
|
||||
for (IrisRegion region : terrainRegions(dimension.getAllRegions(dataProvider), imageMapRuntime)) {
|
||||
preparedRegions.add(region);
|
||||
region.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
|
||||
biome, dataProvider, allBiomes, generators));
|
||||
@@ -351,9 +359,6 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
return mappedTerrainHeight(imageMapRuntime, proceduralHeight, x, z);
|
||||
}, Interpolated.DOUBLE).cache2DDouble(
|
||||
cachePrefix + "ImageMappedHeightStream", engine, cacheSize);
|
||||
ProceduralStream<Double> slopeStream = heightStream.slope(3)
|
||||
.cache2DDouble(cachePrefix + "SlopeStream", engine, cacheSize);
|
||||
|
||||
ProceduralStream<IrisBiome> finalBiomeStream = focusBiome == null
|
||||
? heightStream.convertAware2D((height, x, z) -> {
|
||||
IrisBiome mappedBiome = imageMapRuntime.sampleBiome(x, z);
|
||||
@@ -381,6 +386,24 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
Interpolated.of(value -> 0D, value -> focusBiome)
|
||||
).cache2D(cachePrefix + "FinalBiomeStreamFocus", engine, cacheSize);
|
||||
|
||||
boolean terrain3DEnabled = false;
|
||||
for (IrisBiome biome : allBiomes) {
|
||||
if (biome.getTerrain3D() != null) {
|
||||
biome.getTerrain3D().validate();
|
||||
terrain3DEnabled |= biome.getTerrain3D().isEnabled();
|
||||
}
|
||||
}
|
||||
Terrain3DRuntime terrain3D = !terrain3DEnabled ? null : new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources(heightStream::getDouble, finalBiomeStream::get),
|
||||
new Terrain3DRuntime.Options(engine.getSeedManager().getTerrain() ^ seedOffset,
|
||||
localHeight, fluidHeight, dimensionData, true, Math.max(4096, cacheSize))
|
||||
);
|
||||
ProceduralStream<Double> shapedHeightStream = terrain3D == null ? heightStream
|
||||
: ProceduralStream.ofDouble((x, z) -> shapedHeight(terrain3D, x, z))
|
||||
.cache2DDouble(cachePrefix + "Terrain3DHeightStream", engine, cacheSize);
|
||||
ProceduralStream<Double> slopeStream = shapedHeightStream.slope(3)
|
||||
.cache2DDouble(cachePrefix + "SlopeStream", engine, cacheSize);
|
||||
|
||||
ProceduralStream<PlatformBlockState> rockStream = dimension.getRockPalette()
|
||||
.getLayerGenerator(rng.nextParallelRNG(45), dimensionData).stream()
|
||||
.select(dimension.getRockPalette().getBlockData(dimensionData));
|
||||
@@ -399,7 +422,7 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
dimension,
|
||||
dimensionData,
|
||||
localHeight,
|
||||
heightStream,
|
||||
shapedHeightStream,
|
||||
slopeStream,
|
||||
ProceduralStream.ofDouble((x, z) -> fluidHeight),
|
||||
finalBiomeStream,
|
||||
@@ -408,7 +431,9 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
fluidBlockSampler,
|
||||
imageMapRuntime,
|
||||
false,
|
||||
null
|
||||
null,
|
||||
terrain3D,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
@@ -427,6 +452,22 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
});
|
||||
}
|
||||
|
||||
static KList<IrisRegion> terrainRegions(Iterable<IrisRegion> declared, IrisImageMapRuntime imageMaps) {
|
||||
KList<IrisRegion> regions = new KList<>();
|
||||
Set<IrisRegion> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (IrisRegion region : declared) {
|
||||
if (!region.isCompatExcluded() && seen.add(region)) {
|
||||
regions.add(region);
|
||||
}
|
||||
}
|
||||
for (IrisRegion region : imageMaps.getMappedRegions()) {
|
||||
if (!region.isCompatExcluded() && seen.add(region)) {
|
||||
regions.add(region);
|
||||
}
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
static IrisRegion mappedRegion(
|
||||
IrisImageMapRuntime imageMapRuntime,
|
||||
IrisRegion proceduralRegion,
|
||||
@@ -629,6 +670,32 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
return getNormalTerrainHeight(x, z, usesNaturalFallback(x, z));
|
||||
}
|
||||
|
||||
public boolean hasTerrain3D() {
|
||||
return selfReferencing ? engine.getComplex().hasTerrain3D() : terrain3D != null;
|
||||
}
|
||||
|
||||
public Terrain3DColumn terrainColumn(int x, int z) {
|
||||
return terrainColumn(x, z, usesNaturalFallback(x, z));
|
||||
}
|
||||
|
||||
Terrain3DColumn terrainColumn(int x, int z, boolean naturalFallback) {
|
||||
if (selfReferencing) {
|
||||
return naturalSelf || naturalFallback
|
||||
? engine.getComplex().naturalTerrainColumn(x, z)
|
||||
: engine.getComplex().terrainColumn(x, z);
|
||||
}
|
||||
if (terrain3D == null) {
|
||||
return null;
|
||||
}
|
||||
Terrain3DColumn column = terrain3D.column(x, z);
|
||||
return column.shaped() ? column : null;
|
||||
}
|
||||
|
||||
private static double shapedHeight(Terrain3DRuntime runtime, double x, double z) {
|
||||
Terrain3DColumn column = runtime.column((int) Math.floor(x), (int) Math.floor(z));
|
||||
return column.shaped() ? column.topY() : column.baseHeight();
|
||||
}
|
||||
|
||||
double getNormalTerrainHeight(double x, double z, boolean naturalFallback) {
|
||||
if (naturalFallback && selfFallback != null) {
|
||||
return selfFallback.heightStream().getDouble(x, z);
|
||||
@@ -651,6 +718,25 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
return slopeStream;
|
||||
}
|
||||
|
||||
public ProceduralStream<Double> getSurfaceSlopeStream(int sourceY) {
|
||||
return !hasTerrain3D() ? slopeStream : ProceduralStream.ofDouble((x, z) ->
|
||||
surfaceSlope((int) Math.floor(x), sourceY, (int) Math.floor(z)));
|
||||
}
|
||||
|
||||
double surfaceSlope(int x, int sourceY, int z) {
|
||||
Terrain3DColumn column = terrainColumn(x, z);
|
||||
if (column == null || sourceY >= column.topY() || column.surfaceY(sourceY) != sourceY) {
|
||||
return slopeStream.getDouble(x, z);
|
||||
}
|
||||
return IrisComplex.calculateNaturalSlope(sourceY,
|
||||
nearbySurfaceHeight(x + 3, sourceY, z), nearbySurfaceHeight(x, sourceY, z + 3));
|
||||
}
|
||||
|
||||
private double nearbySurfaceHeight(int x, int sourceY, int z) {
|
||||
Terrain3DColumn column = terrainColumn(x, z);
|
||||
return column == null ? getNormalTerrainHeight(x, z) : column.nearestSurfaceY(sourceY);
|
||||
}
|
||||
|
||||
public IrisBiome getBiome(double x, double z) {
|
||||
return getBiome(x, z, usesNaturalFallback(x, z));
|
||||
}
|
||||
@@ -710,7 +796,8 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
getRegion(x, z),
|
||||
getRockBlock(x, z),
|
||||
getFluidBlock(x, z, naturalFallback),
|
||||
getSurfaceBlock(x, z)
|
||||
getSurfaceBlock(x, z),
|
||||
terrainColumn((int) Math.floor(x), (int) Math.floor(z), naturalFallback)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -739,7 +826,9 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
FluidBlockSampler fluidBlockSampler,
|
||||
IrisImageMapRuntime imageMapRuntime,
|
||||
boolean selfReferencing,
|
||||
SelfFallback selfFallback
|
||||
SelfFallback selfFallback,
|
||||
Terrain3DRuntime terrain3D,
|
||||
boolean naturalSelf
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -758,7 +847,8 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
IrisRegion region,
|
||||
PlatformBlockState rockBlock,
|
||||
PlatformBlockState fluidBlock,
|
||||
PlatformBlockState surfaceBlock
|
||||
PlatformBlockState surfaceBlock,
|
||||
Terrain3DColumn terrainColumn
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ import art.arcane.iris.engine.object.IrisHydrology;
|
||||
import art.arcane.iris.engine.object.IrisRiverHydrology;
|
||||
import art.arcane.iris.engine.object.IrisRiverProfile;
|
||||
import art.arcane.iris.engine.object.IrisShapedGeneratorStyle;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DRuntime;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
@@ -94,8 +96,8 @@ import java.nio.file.Path;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
|
||||
@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
|
||||
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D"})
|
||||
@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D"})
|
||||
public class IrisComplex implements DataProvider {
|
||||
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
|
||||
private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L);
|
||||
@@ -149,6 +151,11 @@ public class IrisComplex implements DataProvider {
|
||||
private ProceduralStream<IrisBiome> trueBiomeStream;
|
||||
private ProceduralStream<PlatformBiome> trueBiomeDerivativeStream;
|
||||
private ProceduralStream<Double> naturalHeightStream;
|
||||
private ProceduralStream<Double> baseTerrainHeightStream;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient Engine terrainEngine;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private transient Terrain3DRuntime terrain3D;
|
||||
private ProceduralStream<Double> unblendedNaturalHeightStream;
|
||||
private final ResolvedTerrainProvider resolvedTerrain;
|
||||
private ProceduralStream<Double> placementHeightStream;
|
||||
@@ -199,6 +206,7 @@ public class IrisComplex implements DataProvider {
|
||||
}
|
||||
|
||||
IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan) {
|
||||
terrainEngine = engine;
|
||||
this.transitionGenerationPlan = transitionGenerationPlan;
|
||||
this.resolvedTerrain = new ResolvedTerrainProvider(engine);
|
||||
int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize());
|
||||
@@ -324,10 +332,27 @@ public class IrisComplex implements DataProvider {
|
||||
return mapped == null ? biome : mapped;
|
||||
})
|
||||
.cache2D("imageMappedBaseBiomeStream", engine, cacheSize);
|
||||
unblendedNaturalHeightStream = ProceduralStream.of(
|
||||
baseTerrainHeightStream = ProceduralStream.of(
|
||||
(x, z) -> sampleUnblendedNaturalTerrainHeight(engine, x, z),
|
||||
Interpolated.DOUBLE
|
||||
).cache2DDouble("unblendedNaturalHeightStream", engine, cacheSize);
|
||||
).cache2DDouble("baseTerrainHeightStream", engine, cacheSize);
|
||||
boolean terrain3DEnabled = false;
|
||||
for (IrisBiome biome : generatorBiomes) {
|
||||
if (biome.getTerrain3D() != null) {
|
||||
biome.getTerrain3D().validate();
|
||||
terrain3DEnabled |= biome.getTerrain3D().isEnabled();
|
||||
}
|
||||
}
|
||||
terrain3D = !terrain3DEnabled ? null : new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources(
|
||||
(x, z) -> baseTerrainHeightStream.getDouble(x, z),
|
||||
this::sampleTerrain3DBiome),
|
||||
new Terrain3DRuntime.Options(engine.getSeedManager().getTerrain(), engine.getHeight(),
|
||||
fluidHeight, data, terrain3DEnabled, Math.max(4_096, cacheSize)));
|
||||
unblendedNaturalHeightStream = terrain3DEnabled
|
||||
? ProceduralStream.ofDouble(this::sampleTerrain3DHeight)
|
||||
.cache2DDouble("unblendedNaturalHeightStream", engine, cacheSize)
|
||||
: baseTerrainHeightStream;
|
||||
naturalHeightStream = unblendedNaturalHeightStream;
|
||||
naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
|
||||
b -> focusBiome))
|
||||
@@ -491,7 +516,95 @@ public class IrisComplex implements DataProvider {
|
||||
}
|
||||
|
||||
private double sampleNaturalTerrainHeight(Engine engine, double x, double z) {
|
||||
return sampleUnblendedNaturalTerrainHeight(engine, x, z);
|
||||
return terrain3D != null && terrain3D.active()
|
||||
? sampleTerrain3DHeight(x, z)
|
||||
: sampleUnblendedNaturalTerrainHeight(engine, x, z);
|
||||
}
|
||||
|
||||
private double sampleTerrain3DHeight(double x, double z) {
|
||||
Terrain3DColumn column = terrain3D.column(blockCoordinate(x), blockCoordinate(z));
|
||||
return column.shaped() ? column.topY() : column.baseHeight();
|
||||
}
|
||||
|
||||
private IrisBiome sampleTerrain3DBiome(int x, int z) {
|
||||
if (focusBiome != null) {
|
||||
return focusBiome;
|
||||
}
|
||||
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
|
||||
return mapped == null
|
||||
? fixBiomeType(baseTerrainHeightStream.getDouble(x, z), baseBiomeStream.get(x, z),
|
||||
regionStream.get(x, z), (double) x, (double) z, fluidHeight)
|
||||
: mapped;
|
||||
}
|
||||
|
||||
public Terrain3DColumn naturalTerrainColumn(int x, int z) {
|
||||
if (terrain3D == null || !terrain3D.active()) {
|
||||
return null;
|
||||
}
|
||||
Terrain3DColumn column = terrain3D.column(x, z);
|
||||
return column.shaped() ? column : null;
|
||||
}
|
||||
|
||||
public boolean hasTerrain3D() {
|
||||
return terrain3D != null && terrain3D.active();
|
||||
}
|
||||
|
||||
public Terrain3DColumn terrainColumn(int x, int z, HydrologyColumnSample hydrology) {
|
||||
if (hydrology != null) {
|
||||
HydrologyColumnLayer layer = hydrology.primarySurfaceLayer().orElse(null);
|
||||
if (layer != null && layer.terrainOwned()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return naturalTerrainColumn(x, z);
|
||||
}
|
||||
|
||||
public Terrain3DColumn terrainColumn(int x, int z) {
|
||||
if (terrain3D == null || !terrain3D.active()) {
|
||||
return null;
|
||||
}
|
||||
if (terrainEngine != null && terrainEngine.answersFromNaturalTerrain(x, z)) {
|
||||
return naturalTerrainColumn(x, z);
|
||||
}
|
||||
return terrainColumn(x, z, sampleHydrologyColumn(x, z));
|
||||
}
|
||||
|
||||
public boolean isTerrain3DOpening(int x, int y, int z) {
|
||||
Terrain3DColumn column = terrainColumn(x, z);
|
||||
return column != null && y >= column.minY() && y < column.topY() && !column.isSolid(y)
|
||||
&& (terrainEngine == null || !terrainEngine.isAdditionalTerrainOwned(x, y, z));
|
||||
}
|
||||
|
||||
public boolean isNaturalTerrainSolid(int x, int y, int z) {
|
||||
Terrain3DColumn column = naturalTerrainColumn(x, z);
|
||||
return column == null ? y <= naturalTrueHeight(x, z) : column.isSolid(y);
|
||||
}
|
||||
|
||||
public boolean isTerrain3DSurface(int x, int y, int z) {
|
||||
Terrain3DColumn column = terrainColumn(x, z);
|
||||
if (column == null || y < column.minY()) {
|
||||
return false;
|
||||
}
|
||||
if (!column.isSolid(y)) {
|
||||
return true;
|
||||
}
|
||||
int surface = column.surfaceY(y);
|
||||
return surface >= y && surface - y < 2;
|
||||
}
|
||||
|
||||
public double terrainSurfaceSlope(int x, int surfaceY, int z) {
|
||||
Terrain3DColumn column = terrainColumn(x, z);
|
||||
if (column == null || surfaceY >= column.topY() || column.surfaceY(surfaceY) != surfaceY) {
|
||||
return getSlopeStream().getDouble(x, z);
|
||||
}
|
||||
return calculateNaturalSlope(surfaceY,
|
||||
nearbyTerrainSurfaceHeight(x + 3, surfaceY, z),
|
||||
nearbyTerrainSurfaceHeight(x, surfaceY, z + 3));
|
||||
}
|
||||
|
||||
private double nearbyTerrainSurfaceHeight(int x, int surfaceY, int z) {
|
||||
Terrain3DColumn column = terrainColumn(x, z);
|
||||
return column == null ? getPlacementHeightStream().getDouble(x, z) : column.nearestSurfaceY(surfaceY);
|
||||
}
|
||||
|
||||
private double sampleUnblendedNaturalTerrainHeight(Engine engine, double x, double z) {
|
||||
@@ -1735,6 +1848,9 @@ public class IrisComplex implements DataProvider {
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (terrain3D != null) {
|
||||
terrain3D.clear();
|
||||
}
|
||||
if (hydrologyRuntime != null) {
|
||||
hydrologyRuntime.close();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import art.arcane.iris.engine.image.IrisImageMapRuntime;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.DataProvider;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
@@ -93,6 +94,26 @@ public class UpperDimensionContext implements DataProvider {
|
||||
}
|
||||
|
||||
public int getEffectiveSurfaceY(int x, int z) {
|
||||
int surfaceY = effectiveEnvelopeY(x, z);
|
||||
Terrain3DColumn column = terrainContext.terrainColumn(x, z);
|
||||
return clipSurfaceY(column, ceilingLayout.height(), surfaceY);
|
||||
}
|
||||
|
||||
public Column sampleColumn(int x, int z) {
|
||||
Terrain3DColumn column = terrainContext.terrainColumn(x, z);
|
||||
return new Column(ceilingLayout.height(),
|
||||
clipSurfaceY(column, ceilingLayout.height(), effectiveEnvelopeY(x, z)), column);
|
||||
}
|
||||
|
||||
private static int clipSurfaceY(Terrain3DColumn column, int height, int surfaceY) {
|
||||
if (column == null || surfaceY >= height) {
|
||||
return surfaceY;
|
||||
}
|
||||
int top = column.highestSolidY(height - 1 - surfaceY);
|
||||
return top <= 0 ? height : height - 1 - top;
|
||||
}
|
||||
|
||||
private int effectiveEnvelopeY(int x, int z) {
|
||||
double depth = Math.max(
|
||||
0D,
|
||||
Math.min(ceilingLayout.height() - 1D, terrainContext.getNormalTerrainHeight(x, z))
|
||||
@@ -127,6 +148,10 @@ public class UpperDimensionContext implements DataProvider {
|
||||
return terrainContext.getSurfaceBlock(x, z);
|
||||
}
|
||||
|
||||
public ProceduralStream<Double> getSurfaceSlopeStream(int sourceY) {
|
||||
return terrainContext.getSurfaceSlopeStream(sourceY);
|
||||
}
|
||||
|
||||
public IrisDimension getDimension() {
|
||||
return terrainContext.getDimension();
|
||||
}
|
||||
@@ -140,6 +165,24 @@ public class UpperDimensionContext implements DataProvider {
|
||||
return terrainContext.isSelfReferencing();
|
||||
}
|
||||
|
||||
public record Column(int height, int surfaceY, Terrain3DColumn terrain) {
|
||||
public boolean ownsY(int y) {
|
||||
return y >= surfaceY && y < height;
|
||||
}
|
||||
|
||||
public boolean isSolid(int y) {
|
||||
return ownsY(y) && (terrain == null || terrain.isSolid(height - 1 - y));
|
||||
}
|
||||
|
||||
public int faceY(int y) {
|
||||
if (!isSolid(y)) {
|
||||
return -1;
|
||||
}
|
||||
return terrain == null ? surfaceY
|
||||
: Math.max(surfaceY, height - 1 - terrain.surfaceY(height - 1 - y));
|
||||
}
|
||||
}
|
||||
|
||||
record CeilingLayout(int height, int minimumGap) {
|
||||
CeilingLayout {
|
||||
if (height <= 0 || minimumGap < 0) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.volmlib.util.documentation.BlockCoordinates;
|
||||
@@ -228,6 +229,11 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
|
||||
getSurfaceDecorator().decorate(i, j, realX, realZ, output, biome, height, getEngine().getHeight() - height);
|
||||
}
|
||||
|
||||
Terrain3DColumn terrainColumn = getComplex().terrainColumn(realX, realZ, hydrology);
|
||||
if (terrainColumn != null) {
|
||||
decorateTerrainLedges(output, terrainColumn, biome, i, j, realX, realZ);
|
||||
}
|
||||
|
||||
|
||||
if (cave != null && cave.getDecorators().isNotEmpty()) {
|
||||
for (int k = Math.min(height, output.getHeight() - 1); k > 0; k--) {
|
||||
@@ -256,6 +262,24 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
|
||||
return false; // TODO CAVES
|
||||
}
|
||||
|
||||
private void decorateTerrainLedges(Hunk<PlatformBlockState> output, Terrain3DColumn column,
|
||||
IrisBiome biome, int localX, int localZ, int worldX, int worldZ) {
|
||||
for (int span = 0; span + 1 < column.spanCount(); span++) {
|
||||
int floorY = column.floor(span);
|
||||
int ceilingY = column.ceiling(span + 1);
|
||||
int headroom = ceilingY - floorY - 1;
|
||||
if (headroom < 1 || !PREDICATE_SOLID.test(output.getRaw(localX, floorY, localZ))) {
|
||||
continue;
|
||||
}
|
||||
getSurfaceDecorator().decorate(localX, localZ, worldX, worldZ,
|
||||
output, biome, floorY, headroom);
|
||||
if (PREDICATE_SOLID.test(output.getRaw(localX, ceilingY, localZ))) {
|
||||
getCeilingDecorator().decorate(localX, localZ, worldX, worldZ,
|
||||
output, biome, ceilingY - 1, headroom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ShorelineDecorationMode {
|
||||
NONE,
|
||||
LEGACY,
|
||||
|
||||
@@ -180,7 +180,7 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Pla
|
||||
IrisBiome biome = layer.biome();
|
||||
int surfaceDepth = Math.max(0, layer.normalTerrainHeight());
|
||||
int fluidDepth = Math.max(0, layer.fluidHeight() - layer.normalTerrainHeight());
|
||||
KList<PlatformBlockState> surfaceBlocks = biome == null
|
||||
KList<PlatformBlockState> surfaceBlocks = biome == null || layer.terrainColumn() != null
|
||||
? null
|
||||
: biome.generateLayersWithSlope(
|
||||
dimension,
|
||||
@@ -192,6 +192,7 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Pla
|
||||
data,
|
||||
terrainContext.getSlopeStream()
|
||||
);
|
||||
int paletteSurfaceY = layer.normalTerrainHeight();
|
||||
KList<PlatformBlockState> seaBlocks = biome == null || fluidDepth == 0
|
||||
? null
|
||||
: biome.generateSeaLayers(worldX, worldZ, rng, fluidDepth, data);
|
||||
@@ -215,13 +216,26 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Pla
|
||||
if (sourceY > layer.normalTerrainHeight()) {
|
||||
continue;
|
||||
}
|
||||
int depth = layer.surfaceY() - y;
|
||||
if (!layer.isSolid(y)) {
|
||||
writeBlock(output, metadata, localX, y, localZ, AIR.get());
|
||||
continue;
|
||||
}
|
||||
int depth = layer.surfaceAt(y) - y;
|
||||
int sourceSurfaceY = sourceY + depth;
|
||||
if (sourceSurfaceY != paletteSurfaceY) {
|
||||
paletteSurfaceY = sourceSurfaceY;
|
||||
surfaceBlocks = null;
|
||||
}
|
||||
if (depth == 0 && layer.surfaceBlock() != null) {
|
||||
writeBlock(output, metadata, localX, y, localZ, layer.surfaceBlock());
|
||||
} else if (surfaceBlocks != null && surfaceBlocks.hasIndex(depth)) {
|
||||
writeBlock(output, metadata, localX, y, localZ, surfaceBlocks.get(depth));
|
||||
} else {
|
||||
writeBlock(output, metadata, localX, y, localZ, layer.rockBlock());
|
||||
if (surfaceBlocks == null && biome != null) {
|
||||
surfaceBlocks = biome.generateLayersWithSlope(dimension, worldX, worldZ, rng,
|
||||
sourceSurfaceY, sourceSurfaceY, data, terrainContext.getSurfaceSlopeStream(sourceSurfaceY));
|
||||
}
|
||||
writeBlock(output, metadata, localX, y, localZ,
|
||||
surfaceBlocks != null && surfaceBlocks.hasIndex(depth)
|
||||
? surfaceBlocks.get(depth) : layer.rockBlock());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import art.arcane.iris.engine.object.IrisOreGeneratorBounds;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisRiverMaterialConfig;
|
||||
import art.arcane.iris.engine.object.IrisSurfaceRiverBankConfig;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.util.common.data.BoundBlockState;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.util.project.context.ChunkedDataCache;
|
||||
@@ -47,6 +48,7 @@ import lombok.Getter;
|
||||
|
||||
public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBlockState> {
|
||||
private static final BoundBlockState BEDROCK = BoundBlockState.of("BEDROCK");
|
||||
private static final BoundBlockState AIR = BoundBlockState.of("AIR");
|
||||
@Getter
|
||||
private final RNG rng;
|
||||
@Getter
|
||||
@@ -132,6 +134,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
HydrologyColumnLayer hydrologyTerrain = hydrology == null
|
||||
? null
|
||||
: hydrology.primarySurfaceLayer().orElse(null);
|
||||
Terrain3DColumn terrainColumn = complex.terrainColumn(realX, realZ, hydrology);
|
||||
int terrainSpan = terrainColumn == null ? -1 : terrainColumn.spanCount() - 1;
|
||||
int layerSurfaceY = he;
|
||||
int layerCeilingY = 0;
|
||||
int cut = exposeCutStrata
|
||||
&& hydrologyTerrain != null
|
||||
&& hydrologyTerrain.terrainOwned()
|
||||
@@ -157,6 +163,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
boolean hasSurfaceOres = biomeSurfaceOreBounds.hasOres() || regionSurfaceOreBounds.hasOres() || dimensionSurfaceOreBounds.hasOres();
|
||||
boolean hasUndergroundOres = biomeUndergroundOreBounds.hasOres() || regionUndergroundOreBounds.hasOres() || dimensionUndergroundOreBounds.hasOres();
|
||||
KList<PlatformBlockState> blocks = null;
|
||||
KList<PlatformBlockState> ceilingBlocks = null;
|
||||
KList<PlatformBlockState> fblocks = null;
|
||||
|
||||
for (int i = topY; i >= 0; i--) {
|
||||
@@ -166,6 +173,20 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
continue;
|
||||
}
|
||||
|
||||
if (terrainColumn != null && i <= he) {
|
||||
while (terrainSpan >= 0 && i < terrainColumn.ceiling(terrainSpan)) {
|
||||
terrainSpan--;
|
||||
blocks = null;
|
||||
ceilingBlocks = null;
|
||||
}
|
||||
if (terrainSpan < 0 || i > terrainColumn.floor(terrainSpan)) {
|
||||
h.setRaw(xf, i, zf, AIR.get());
|
||||
continue;
|
||||
}
|
||||
layerSurfaceY = terrainColumn.floor(terrainSpan);
|
||||
layerCeilingY = terrainColumn.ceiling(terrainSpan);
|
||||
}
|
||||
|
||||
PlatformBlockState ore = null;
|
||||
if (hasSurfaceOres) {
|
||||
if (biomeSurfaceOreBounds.contains(i)) {
|
||||
@@ -198,13 +219,25 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
}
|
||||
|
||||
if (i <= he) {
|
||||
int depth = he - i;
|
||||
int depth = layerSurfaceY - i;
|
||||
if (depth == 0 && mappedSurfaceBlock != null) {
|
||||
h.setRaw(xf, i, zf, mappedSurfaceBlock);
|
||||
continue;
|
||||
}
|
||||
if (blocks == null) {
|
||||
blocks = biome.generateLayers(dimension, realX, realZ, localRng, he + cut, he + cut, data, complex);
|
||||
blocks = biome.generateLayers(dimension, realX, realZ, localRng,
|
||||
layerSurfaceY + cut, layerSurfaceY + cut, data, complex);
|
||||
}
|
||||
|
||||
if (layerCeilingY > 0 && depth >= 2 && i - layerCeilingY < 2) {
|
||||
if (ceilingBlocks == null) {
|
||||
ceilingBlocks = biome.generateCeilingLayers(dimension, realX, realZ, localRng,
|
||||
2, layerCeilingY, data, complex);
|
||||
}
|
||||
int ceilingDepth = i - layerCeilingY;
|
||||
h.setRaw(xf, i, zf, ceilingBlocks.hasIndex(ceilingDepth)
|
||||
? ceilingBlocks.get(ceilingDepth) : rock);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blocks.hasIndex(depth + cut)) {
|
||||
@@ -241,29 +274,41 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
}
|
||||
|
||||
if (upperContext != null) {
|
||||
int upperSurfaceY = upperContext.getEffectiveSurfaceY(realX, realZ);
|
||||
UpperDimensionContext.Column upperColumn = upperContext.sampleColumn(realX, realZ);
|
||||
int upperSurfaceY = upperColumn.surfaceY();
|
||||
|
||||
if (upperSurfaceY < chunkHeight - 1) {
|
||||
IrisBiome upperBiome = upperContext.getUpperBiome(realX, realZ);
|
||||
PlatformBlockState upperRock = upperContext.getRockBlock(realX, realZ);
|
||||
PlatformBlockState upperMappedSurface = upperContext.getSurfaceBlock(realX, realZ);
|
||||
int upperThickness = chunkHeight - 1 - upperSurfaceY;
|
||||
KList<PlatformBlockState> upperBlocks = upperBiome != null
|
||||
? upperBiome.generateLayers(upperContext.getDimension(),
|
||||
realX, realZ, localRng, upperThickness, upperThickness,
|
||||
upperContext.getData(), complex)
|
||||
: null;
|
||||
KList<PlatformBlockState> upperBlocks = null;
|
||||
int paletteSourceY = -1;
|
||||
|
||||
for (int y = chunkHeight - 1; y >= upperSurfaceY; y--) {
|
||||
if (y == chunkHeight - 1 && bedrockEnabled) {
|
||||
h.setRaw(xf, y, zf, BEDROCK.get());
|
||||
continue;
|
||||
}
|
||||
if (y == upperSurfaceY && upperMappedSurface != null) {
|
||||
if (!upperColumn.isSolid(y)) {
|
||||
h.setRaw(xf, y, zf, AIR.get());
|
||||
continue;
|
||||
}
|
||||
int faceY = upperColumn.faceY(y);
|
||||
int sourceSurfaceY = upperColumn.height() - 1 - faceY;
|
||||
if (sourceSurfaceY != paletteSourceY) {
|
||||
paletteSourceY = sourceSurfaceY;
|
||||
upperBlocks = null;
|
||||
}
|
||||
if (y == faceY && upperMappedSurface != null) {
|
||||
h.setRaw(xf, y, zf, upperMappedSurface);
|
||||
continue;
|
||||
}
|
||||
int depthFromFace = y - upperSurfaceY;
|
||||
int depthFromFace = y - faceY;
|
||||
if (upperBlocks == null && upperBiome != null) {
|
||||
upperBlocks = upperBiome.generateLayersWithSlope(upperContext.getDimension(),
|
||||
realX, realZ, localRng, sourceSurfaceY, sourceSurfaceY,
|
||||
upperContext.getData(), upperContext.getSurfaceSlopeStream(sourceSurfaceY));
|
||||
}
|
||||
if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) {
|
||||
h.setRaw(xf, y, zf, upperBlocks.get(depthFromFace));
|
||||
} else {
|
||||
|
||||
@@ -43,11 +43,14 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
|
||||
this.partRNG = new RNG(DecoratorCore.partSeed(getSeed(), IrisDecorationPart.NONE));
|
||||
}
|
||||
|
||||
protected boolean isSlopeValid(IrisDecorator decorator, int realX, int realZ) {
|
||||
protected boolean isSlopeValid(IrisDecorator decorator, int realX, int height, int realZ) {
|
||||
if (decorator.isForcePlace() || decorator.getSlopeCondition().isDefault()) {
|
||||
return true;
|
||||
}
|
||||
return decorator.getSlopeCondition().isValid(getComplex().getSlopeStream().get(realX, realZ));
|
||||
double slope = getComplex().hasTerrain3D()
|
||||
? getComplex().terrainSurfaceSlope(realX, height, realZ)
|
||||
: getComplex().getSlopeStream().getDouble(realX, realZ);
|
||||
return decorator.getSlopeCondition().isValid(slope);
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@@ -73,7 +76,7 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
|
||||
RNG rng = getRNG(realX, realZ);
|
||||
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
|
||||
|
||||
if (decorator == null || !isSlopeValid(decorator, realX, realZ)) {
|
||||
if (decorator == null || !isSlopeValid(decorator, realX, height, realZ)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
|
||||
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
@@ -118,6 +120,34 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
|
||||
return null;
|
||||
}
|
||||
|
||||
default boolean isAdditionalTerrainOwned(int x, int y, int z) {
|
||||
DimensionStackContext stack = getDimensionStackContext();
|
||||
if (stack != null && stack.getLayout(x, z).isHostFeatureProtectedY(y)) {
|
||||
return true;
|
||||
}
|
||||
UpperDimensionContext upper = getUpperContext();
|
||||
return upper != null && y >= upper.getEffectiveSurfaceY(x, z) && y < getHeight();
|
||||
}
|
||||
|
||||
default boolean isTerrainSurfaceSolid(int x, int y, int z) {
|
||||
DimensionStackContext stack = getDimensionStackContext();
|
||||
if (stack != null) {
|
||||
DimensionStackLayout layout = stack.getLayout(x, z);
|
||||
if (layout.isHostFeatureProtectedY(y)) {
|
||||
return layout.isSolid(y);
|
||||
}
|
||||
}
|
||||
UpperDimensionContext upper = getUpperContext();
|
||||
if (upper != null) {
|
||||
UpperDimensionContext.Column column = upper.sampleColumn(x, z);
|
||||
if (column.ownsY(y)) {
|
||||
return column.isSolid(y);
|
||||
}
|
||||
}
|
||||
Terrain3DColumn column = getComplex().terrainColumn(x, z);
|
||||
return column == null || column.isSolid(y);
|
||||
}
|
||||
|
||||
EngineMode getMode();
|
||||
|
||||
EnginePlatformHooks getPlatformHooks();
|
||||
@@ -615,12 +645,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
|
||||
if (y > bottomLayer.renderMaxY()) {
|
||||
return bottomLayer.biome();
|
||||
}
|
||||
if (y <= bottomLayer.clippedSurfaceY() - 2) {
|
||||
if (y <= bottomLayer.clippedSurfaceY() - 2 && !getComplex().isTerrain3DSurface(x, y, z)) {
|
||||
return getCaveBiome(x, y, z);
|
||||
}
|
||||
return bottomLayer.biome();
|
||||
}
|
||||
if (y <= getHeight(x, z) - 2) {
|
||||
if (y <= getHeight(x, z) - 2 && !getComplex().isTerrain3DSurface(x, y, z)) {
|
||||
return getCaveBiome(x, y, z);
|
||||
}
|
||||
|
||||
@@ -639,12 +669,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
|
||||
if (y > bottomLayer.renderMaxY()) {
|
||||
return bottomLayer.biome();
|
||||
}
|
||||
if (y <= bottomLayer.clippedSurfaceY() - 2) {
|
||||
if (y <= bottomLayer.clippedSurfaceY() - 2 && !getComplex().isTerrain3DSurface(x, y, z)) {
|
||||
return getCaveOrMantleBiome(x, y, z);
|
||||
}
|
||||
return bottomLayer.biome();
|
||||
}
|
||||
if (y <= getHeight(x, z) - 2) {
|
||||
if (y <= getHeight(x, z) - 2 && !getComplex().isTerrain3DSurface(x, y, z)) {
|
||||
return getCaveOrMantleBiome(x, y, z);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -6,8 +6,9 @@ import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
|
||||
public interface HydrologyCaveVoxelViewFactory {
|
||||
CaveVoxelView create(PlannedSurface plannedSurface);
|
||||
|
||||
@FunctionalInterface
|
||||
interface PlannedSurface {
|
||||
int resolve(int x, int z, int naturalHeight);
|
||||
|
||||
boolean ownsTerrain(int x, int z);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-3
@@ -2039,9 +2039,7 @@ final class HydrologyFootprintCompiler {
|
||||
if (materializedSurface == null) {
|
||||
return surfaceRaster;
|
||||
}
|
||||
return (int x, int z, int naturalHeight) -> materializedSurface.sample(x, z)
|
||||
.map(HydrologyColumnSample::terrainHeight)
|
||||
.orElse(naturalHeight);
|
||||
return new MaterializedSurface(materializedSurface);
|
||||
}
|
||||
|
||||
HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) {
|
||||
@@ -2100,6 +2098,19 @@ final class HydrologyFootprintCompiler {
|
||||
}
|
||||
}
|
||||
|
||||
private record MaterializedSurface(RiverFootprint footprint) implements HydrologyCaveVoxelViewFactory.PlannedSurface {
|
||||
@Override
|
||||
public int resolve(int x, int z, int naturalHeight) {
|
||||
return footprint.sample(x, z).map(HydrologyColumnSample::terrainHeight).orElse(naturalHeight);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ownsTerrain(int x, int z) {
|
||||
HydrologyColumnSample sample = footprint.sample(x, z).orElse(null);
|
||||
return sample != null && sample.primarySurfaceLayer().isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
private final class SurfaceRasterIndex implements HydrologyCaveVoxelViewFactory.PlannedSurface {
|
||||
private final Long2ObjectOpenHashMap<HydrologyColumnSample> surfaceColumns;
|
||||
private final Long2IntOpenHashMap validationNaturalHeights;
|
||||
@@ -2140,6 +2151,12 @@ final class HydrologyFootprintCompiler {
|
||||
return naturalHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ownsTerrain(int x, int z) {
|
||||
HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z));
|
||||
return sample != null && sample.primarySurfaceLayer().isPresent();
|
||||
}
|
||||
|
||||
private HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) {
|
||||
HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z));
|
||||
if (sample != null && sample.naturalHeight() != naturalHeight) {
|
||||
|
||||
+37
-4
@@ -25,24 +25,57 @@ public final class HydrologyObservedPlannedSurface implements HydrologyCaveVoxel
|
||||
observations.put(key, column);
|
||||
}
|
||||
for (Observation observation : column) {
|
||||
if (observation.naturalHeight() == naturalHeight) {
|
||||
if (observation instanceof HeightObservation height && height.naturalHeight() == naturalHeight) {
|
||||
return resolvedHeight;
|
||||
}
|
||||
}
|
||||
column.add(new Observation(x, z, naturalHeight, resolvedHeight));
|
||||
column.add(new HeightObservation(x, z, naturalHeight, resolvedHeight));
|
||||
return resolvedHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ownsTerrain(int x, int z) {
|
||||
boolean owned = delegate.ownsTerrain(x, z);
|
||||
long key = RiverFootprint.pack(x, z);
|
||||
ArrayList<Observation> column = observations.get(key);
|
||||
if (column == null) {
|
||||
column = new ArrayList<>(1);
|
||||
observations.put(key, column);
|
||||
}
|
||||
for (Observation observation : column) {
|
||||
if (observation instanceof OwnershipObservation) {
|
||||
return owned;
|
||||
}
|
||||
}
|
||||
column.add(new OwnershipObservation(x, z, owned));
|
||||
return owned;
|
||||
}
|
||||
|
||||
public List<Observation> observationsAt(int x, int z) {
|
||||
ArrayList<Observation> column = observations.get(RiverFootprint.pack(x, z));
|
||||
return column == null ? List.of() : List.copyOf(column);
|
||||
}
|
||||
|
||||
public record Observation(
|
||||
public sealed interface Observation permits HeightObservation, OwnershipObservation {
|
||||
boolean matches(HydrologyCaveVoxelViewFactory.PlannedSurface surface);
|
||||
}
|
||||
|
||||
public record HeightObservation(
|
||||
int x,
|
||||
int z,
|
||||
int naturalHeight,
|
||||
int resolvedHeight
|
||||
) {
|
||||
) implements Observation {
|
||||
@Override
|
||||
public boolean matches(HydrologyCaveVoxelViewFactory.PlannedSurface surface) {
|
||||
return surface.resolve(x, z, naturalHeight) == resolvedHeight;
|
||||
}
|
||||
}
|
||||
|
||||
public record OwnershipObservation(int x, int z, boolean terrainOwned) implements Observation {
|
||||
@Override
|
||||
public boolean matches(HydrologyCaveVoxelViewFactory.PlannedSurface surface) {
|
||||
return surface.ownsTerrain(x, z) == terrainOwned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-6
@@ -508,12 +508,7 @@ public final class HydrologyCaveContainmentPlanner {
|
||||
|
||||
private boolean matches(HydrologyObservedPlannedSurface plannedSurface) {
|
||||
for (HydrologyObservedPlannedSurface.Observation observation : observations) {
|
||||
int resolvedHeight = plannedSurface.resolve(
|
||||
observation.x(),
|
||||
observation.z(),
|
||||
observation.naturalHeight()
|
||||
);
|
||||
if (resolvedHeight != observation.resolvedHeight()) {
|
||||
if (!observation.matches(plannedSurface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,8 @@ public interface EngineMantle extends MatterGenerator {
|
||||
if (hydrology != null) {
|
||||
return hydrology.carves();
|
||||
}
|
||||
return getMantle().get(x, h, z, MatterCavern.class) != null;
|
||||
return getMantle().get(x, h, z, MatterCavern.class) != null
|
||||
|| getComplex().isTerrain3DOpening(x, h, z);
|
||||
}
|
||||
|
||||
default PlatformBlockState get(int x, int y, int z) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import art.arcane.iris.core.tools.WorldMaintenance;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.engine.history.BoundaryColumnGeometry;
|
||||
@@ -499,7 +500,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
if (hydrology != null) {
|
||||
return hydrology.carves();
|
||||
}
|
||||
return getPrerequisiteDataIfPresent(x, y, z, MatterCavern.class) != null;
|
||||
return getPrerequisiteDataIfPresent(x, y, z, MatterCavern.class) != null
|
||||
|| engineMantle.getComplex().isTerrain3DOpening(x, y, z);
|
||||
}
|
||||
|
||||
public byte[] getPrerequisiteCarvedColumn(int x, int z, int height) {
|
||||
@@ -513,6 +515,17 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
return carvedColumn;
|
||||
}
|
||||
|
||||
Terrain3DColumn terrainColumn = engineMantle.getComplex().terrainColumn(x, z);
|
||||
if (terrainColumn != null) {
|
||||
int maximumY = Math.min(cappedHeight, terrainColumn.topY());
|
||||
for (int y = Math.max(0, terrainColumn.minY()); y < maximumY; y++) {
|
||||
if (!terrainColumn.isSolid(y)
|
||||
&& !engineMantle.getEngine().isAdditionalTerrainOwned(x, y, z)) {
|
||||
carvedColumn[y] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MantleChunk<Matter> chunk = acquireChunk(x >> 4, z >> 4);
|
||||
if (chunk == null) {
|
||||
return carvedColumn;
|
||||
@@ -885,7 +898,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
if (hydrology != null) {
|
||||
return hydrology.carves();
|
||||
}
|
||||
return getDataIfPresent(x, y, z, MatterCavern.class) != null;
|
||||
return getDataIfPresent(x, y, z, MatterCavern.class) != null
|
||||
|| engineMantle.getComplex().isTerrain3DOpening(x, y, z);
|
||||
}
|
||||
|
||||
private byte[] resolvedCarvedColumn(TerrainBoundarySignature column, int height) {
|
||||
@@ -933,6 +947,16 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
return carvedColumn;
|
||||
}
|
||||
|
||||
Terrain3DColumn terrainColumn = engineMantle.getComplex().terrainColumn(x, z);
|
||||
if (terrainColumn != null) {
|
||||
int maximumY = Math.min(cappedHeight, terrainColumn.topY());
|
||||
for (int y = terrainColumn.minY(); y < maximumY; y++) {
|
||||
if (!terrainColumn.isSolid(y) && !engineMantle.getEngine().isAdditionalTerrainOwned(x, y, z)) {
|
||||
carvedColumn[y] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MantleChunk<Matter> chunk = acquireChunk(x >> 4, z >> 4);
|
||||
if (chunk == null) {
|
||||
return carvedColumn;
|
||||
@@ -980,9 +1004,12 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
@Override
|
||||
public boolean isSurfaceSolid(int x, int y, int z) {
|
||||
Optional<TerrainBoundarySignature> resolved = resolvedColumn(x, z);
|
||||
return resolved.isEmpty() || resolved.get().geometry()
|
||||
if (resolved.isPresent()) {
|
||||
return resolved.get().geometry()
|
||||
.voxelAt(y + engineMantle.getEngine().getMinHeight()).phase() == BoundaryColumnGeometry.Phase.SOLID;
|
||||
}
|
||||
return engineMantle.getEngine().isTerrainSurfaceSolid(x, y, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSolid(int x, int y, int z) {
|
||||
|
||||
@@ -98,12 +98,16 @@ public final class IslandObjectPlacer implements IObjectPlacer {
|
||||
|
||||
@Override
|
||||
public boolean isCarved(int x, int y, int z) {
|
||||
FloatingIslandSample sample = samples.sample(x, z);
|
||||
if (sample != null && y >= sample.islandBaseY && y < sample.islandBaseY + sample.solidMask.length) {
|
||||
return !sample.solidMask[y - sample.islandBaseY];
|
||||
}
|
||||
return wrapped.isCarved(x, y, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSurfaceSolid(int x, int y, int z) {
|
||||
return wrapped.isSurfaceSolid(x, y, z);
|
||||
return samples.sample(x, z) == null ? wrapped.isSurfaceSolid(x, y, z) : isSolid(x, y, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+52
-24
@@ -15,13 +15,13 @@ import art.arcane.iris.engine.mantle.TerrainMatterView;
|
||||
import art.arcane.iris.engine.object.IrisProceduralBlocks;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.volmlib.util.function.Function2;
|
||||
import art.arcane.volmlib.util.mantle.flag.ReservedFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
|
||||
|
||||
import java.util.Objects;
|
||||
@@ -33,11 +33,12 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView {
|
||||
|
||||
private final Mantle<Matter> mantle;
|
||||
private final int worldHeight;
|
||||
private final Function2<Integer, Integer, Integer> surfaceHeight;
|
||||
private final ColumnSource terrainColumns;
|
||||
private final SolidSource naturalSolid;
|
||||
private final BiConsumer<Integer, Integer> chunkLoader;
|
||||
private final LongOpenHashSet loadedChunks;
|
||||
private final Long2IntOpenHashMap openFloorCache;
|
||||
private final Long2IntOpenHashMap surfaceHeightCache;
|
||||
private final Long2ObjectOpenHashMap<TerrainColumn> terrainCache;
|
||||
|
||||
public MantleHydrologyCaveVoxelView(
|
||||
Engine engine,
|
||||
@@ -71,8 +72,10 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView {
|
||||
this(
|
||||
engineMantle.getMantle(),
|
||||
engineMantle.getMantle().getWorldHeight(),
|
||||
(x, z) -> plannedSurfaceHeight(complex, footprint, x, z),
|
||||
(chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ)
|
||||
new TerrainSources(
|
||||
(x, z) -> terrainColumn(complex, footprint.sample(x, z).orElse(null), x, z),
|
||||
complex::isNaturalTerrainSolid,
|
||||
(chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,33 +87,32 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView {
|
||||
this(
|
||||
engineMantle.getMantle(),
|
||||
engineMantle.getMantle().getWorldHeight(),
|
||||
(x, z) -> plannedSurface.resolve(
|
||||
new TerrainSources((x, z) -> new TerrainColumn(plannedSurface.resolve(
|
||||
x,
|
||||
z,
|
||||
(int) Math.round(complex.getNaturalHeightStream().getDouble(x, z))
|
||||
),
|
||||
(chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ)
|
||||
), plannedSurface.ownsTerrain(x, z)), complex::isNaturalTerrainSolid,
|
||||
(chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ))
|
||||
);
|
||||
}
|
||||
|
||||
MantleHydrologyCaveVoxelView(
|
||||
Mantle<Matter> mantle,
|
||||
int worldHeight,
|
||||
Function2<Integer, Integer, Integer> surfaceHeight,
|
||||
BiConsumer<Integer, Integer> chunkLoader
|
||||
TerrainSources sources
|
||||
) {
|
||||
this.mantle = Objects.requireNonNull(mantle);
|
||||
if (worldHeight < 3) {
|
||||
throw new IllegalArgumentException("worldHeight must be at least three");
|
||||
}
|
||||
this.worldHeight = worldHeight;
|
||||
this.surfaceHeight = Objects.requireNonNull(surfaceHeight);
|
||||
this.chunkLoader = Objects.requireNonNull(chunkLoader);
|
||||
this.terrainColumns = Objects.requireNonNull(sources.columns());
|
||||
this.naturalSolid = Objects.requireNonNull(sources.naturalSolid());
|
||||
this.chunkLoader = Objects.requireNonNull(sources.chunkLoader());
|
||||
this.loadedChunks = new LongOpenHashSet();
|
||||
this.openFloorCache = new Long2IntOpenHashMap();
|
||||
this.openFloorCache.defaultReturnValue(CACHE_MISS);
|
||||
this.surfaceHeightCache = new Long2IntOpenHashMap();
|
||||
this.surfaceHeightCache.defaultReturnValue(CACHE_MISS);
|
||||
this.terrainCache = new Long2ObjectOpenHashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -140,7 +142,9 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView {
|
||||
? CaveVoxel.LAVA
|
||||
: CaveVoxel.INCOMPATIBLE_FLUID;
|
||||
}
|
||||
return position.y() > surfaceY(position.x(), position.z())
|
||||
TerrainColumn terrain = column(position.x(), position.z());
|
||||
return position.y() > terrain.surfaceHeight()
|
||||
|| !terrain.terrainOwned() && !naturalSolid.test(position.x(), position.y(), position.z())
|
||||
? CaveVoxel.CAVE_AIR
|
||||
: CaveVoxel.SOLID;
|
||||
}
|
||||
@@ -187,14 +191,21 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView {
|
||||
}
|
||||
|
||||
private int surfaceY(int x, int z) {
|
||||
return column(x, z).surfaceHeight();
|
||||
}
|
||||
|
||||
private TerrainColumn column(int x, int z) {
|
||||
long key = RiverFootprint.pack(x, z);
|
||||
int cached = surfaceHeightCache.get(key);
|
||||
if (cached != CACHE_MISS) {
|
||||
TerrainColumn cached = terrainCache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
int resolved = Math.max(1, Math.min(worldHeight - 2, surfaceHeight.apply(x, z)));
|
||||
surfaceHeightCache.put(key, resolved);
|
||||
return resolved;
|
||||
TerrainColumn sampled = Objects.requireNonNull(terrainColumns.sample(x, z));
|
||||
int height = Math.clamp(sampled.surfaceHeight(), 1, worldHeight - 2);
|
||||
TerrainColumn bounded = height == sampled.surfaceHeight() ? sampled
|
||||
: new TerrainColumn(height, sampled.terrainOwned());
|
||||
terrainCache.put(key, bounded);
|
||||
return bounded;
|
||||
}
|
||||
|
||||
private <T> T dataIfPresent(CavePosition position, Class<T> type) {
|
||||
@@ -207,16 +218,16 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView {
|
||||
return TerrainMatterView.get(mantle, position.x(), position.y(), position.z(), type);
|
||||
}
|
||||
|
||||
private static int plannedSurfaceHeight(
|
||||
static TerrainColumn terrainColumn(
|
||||
IrisComplex complex,
|
||||
RiverFootprint footprint,
|
||||
HydrologyColumnSample sample,
|
||||
int x,
|
||||
int z
|
||||
) {
|
||||
HydrologyColumnSample sample = footprint.sample(x, z).orElse(null);
|
||||
return sample == null
|
||||
int surfaceHeight = sample == null
|
||||
? (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z))
|
||||
: sample.terrainHeight();
|
||||
return new TerrainColumn(surfaceHeight, sample != null && sample.primarySurfaceLayer().isPresent());
|
||||
}
|
||||
|
||||
static void generateCarvingInput(
|
||||
@@ -263,4 +274,21 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView {
|
||||
static boolean requiresCarvingInput(Mantle<Matter> mantle, int chunkX, int chunkZ) {
|
||||
return !mantle.hasFlag(chunkX, chunkZ, ReservedFlag.CARVED);
|
||||
}
|
||||
|
||||
record TerrainSources(ColumnSource columns,
|
||||
SolidSource naturalSolid, BiConsumer<Integer, Integer> chunkLoader) {
|
||||
}
|
||||
|
||||
record TerrainColumn(int surfaceHeight, boolean terrainOwned) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface ColumnSource {
|
||||
TerrainColumn sample(int x, int z);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface SolidSource {
|
||||
boolean test(int x, int y, int z);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-7
@@ -96,18 +96,14 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
|
||||
CaveVoxelView caveView = new MantleHydrologyCaveVoxelView(
|
||||
writer.getMantle(),
|
||||
writer.getMantle().getWorldHeight(),
|
||||
(x, z) -> {
|
||||
HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z);
|
||||
return sample == null
|
||||
? (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z))
|
||||
: sample.terrainHeight();
|
||||
},
|
||||
new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> MantleHydrologyCaveVoxelView.terrainColumn(
|
||||
complex, complex.sampleHydrologyColumn(x, z), x, z), complex::isNaturalTerrainSolid,
|
||||
(inputChunkX, inputChunkZ) -> MantleHydrologyCaveVoxelView.generateCarvingInput(
|
||||
getEngineMantle(),
|
||||
complex,
|
||||
inputChunkX,
|
||||
inputChunkZ
|
||||
)
|
||||
))
|
||||
);
|
||||
Publication publication = compilePublication(
|
||||
chunkX,
|
||||
|
||||
@@ -22,6 +22,7 @@ import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.DimensionStackContext;
|
||||
import art.arcane.iris.engine.DimensionStackLayout;
|
||||
import art.arcane.iris.engine.UpperDimensionContext;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.engine.actuator.IrisDecorantActuator;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineAssignedModifier;
|
||||
@@ -97,12 +98,16 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
int[] upperSurfaceHeights = protectUpper ? scratch.getOrCreateUpperSurfaceHeights() : null;
|
||||
DimensionStackContext stackContext = getEngine().getDimensionStackContext();
|
||||
DimensionStackLayout[] stackLayouts = stackContext == null ? null : new DimensionStackLayout[256];
|
||||
Terrain3DColumn[] terrainColumns = getComplex().hasTerrain3D() ? new Terrain3DColumn[256] : null;
|
||||
int chunkBlockX = PowerOfTwoCoordinates.chunkToBlock(x);
|
||||
int chunkBlockZ = PowerOfTwoCoordinates.chunkToBlock(z);
|
||||
for (int columnIndex = 0; columnIndex < 256; columnIndex++) {
|
||||
int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex);
|
||||
int localZ = columnIndex & 15;
|
||||
surfaceHeights[columnIndex] = context.getRoundedHeight(localX, localZ);
|
||||
if (terrainColumns != null) {
|
||||
terrainColumns[columnIndex] = getComplex().terrainColumn(chunkBlockX + localX, chunkBlockZ + localZ);
|
||||
}
|
||||
if (protectUpper) {
|
||||
int worldX = localX + chunkBlockX;
|
||||
int worldZ = localZ + chunkBlockZ;
|
||||
@@ -125,6 +130,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
columnMasks,
|
||||
upperSurfaceHeights,
|
||||
stackLayouts,
|
||||
terrainColumns,
|
||||
worldHeightSpan,
|
||||
caveLavaHeight,
|
||||
chunkBlockX,
|
||||
@@ -363,6 +369,12 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
}
|
||||
|
||||
PlatformBlockState current = context.output().getRaw(localX, y, localZ);
|
||||
if (hydrology == null && context.terrainColumns() != null) {
|
||||
Terrain3DColumn column = context.terrainColumns()[columnIndex];
|
||||
if (column != null && y >= column.minY() && y < column.topY() && !column.isSolid(y)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (hydrology != null && hydrology.action() == HydrologyCaveAction.SEAL_GUARD) {
|
||||
PlatformBlockState normalized = resolveHydrologyState(hydrology, current, null, AIR);
|
||||
if (normalized != current) {
|
||||
@@ -419,6 +431,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
CarveColumnMask[] columnMasks,
|
||||
int[] upperSurfaceHeights,
|
||||
DimensionStackLayout[] stackLayouts,
|
||||
Terrain3DColumn[] terrainColumns,
|
||||
int worldHeightSpan,
|
||||
int caveLavaHeight,
|
||||
int chunkBlockX,
|
||||
@@ -889,7 +902,8 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
CaveZone zone = new CaveZone();
|
||||
zone.setFloor(floor);
|
||||
zone.setCeiling(y - 1);
|
||||
if (zone.isValid(getEngine())) {
|
||||
if (zone.isValid(getEngine())
|
||||
&& !getComplex().isTerrain3DOpening(worldX, floor, worldZ)) {
|
||||
if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) {
|
||||
mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING);
|
||||
}
|
||||
@@ -979,6 +993,9 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
}
|
||||
|
||||
IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, Map<String, IrisBiome> customBiomeCache) {
|
||||
if (getComplex().isTerrain3DOpening(worldX, y, worldZ)) {
|
||||
return getComplex().getTrueBiomeStream().get(worldX, worldZ);
|
||||
}
|
||||
if (cavern != null && !cavern.getCustomBiome().isEmpty()) {
|
||||
return resolveCustomBiome(customBiomeCache, cavern.getCustomBiome());
|
||||
}
|
||||
|
||||
@@ -207,6 +207,9 @@ public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
@ArrayType(min = 1, type = IrisBiomeGeneratorLink.class)
|
||||
@Desc("Generators for this biome. Multiple generators with different interpolation sizes will mix with other biomes how you would expect. This defines your biome height relative to the fluid height. Use negative for oceans.")
|
||||
private KList<IrisBiomeGeneratorLink> generators = new KList<IrisBiomeGeneratorLink>().qadd(new IrisBiomeGeneratorLink());
|
||||
|
||||
@Desc("Optional volumetric surface terrain profile. Shapes projecting ledges, undercuts, arches, and fissures around this biome's height generators.")
|
||||
private IrisTerrain3D terrain3D = null;
|
||||
@ArrayType(min = 1, type = IrisDepositGenerator.class)
|
||||
@Desc("Define biome deposit generators that add onto the existing regional and global deposit generators")
|
||||
private KList<IrisDepositGenerator> deposits = new KList<>();
|
||||
|
||||
@@ -68,6 +68,8 @@ final class IrisBiomeLayerGenerator {
|
||||
}
|
||||
|
||||
KList<CNG> heightGenerators = getLayerHeightGenerators(biome, random, rdata);
|
||||
double surfaceSlope = 0D;
|
||||
boolean sampledSlope = false;
|
||||
|
||||
for (int i = 0; i < layerCount; i++) {
|
||||
IrisBiomePaletteLayer layer = layers.get(i);
|
||||
@@ -78,7 +80,11 @@ final class IrisBiomeLayerGenerator {
|
||||
IrisSlopeClip sc = layer.getSlopeCondition();
|
||||
|
||||
if (!sc.isDefault()) {
|
||||
if (!sc.isValid(resolveSlopeStream(complex, slopeStream).getDouble(wx, wz))) {
|
||||
if (!sampledSlope) {
|
||||
surfaceSlope = resolveSurfaceSlope(complex, slopeStream, wx, height, wz);
|
||||
sampledSlope = true;
|
||||
}
|
||||
if (!sc.isValid(surfaceSlope)) {
|
||||
d = 0;
|
||||
}
|
||||
}
|
||||
@@ -171,6 +177,8 @@ final class IrisBiomeLayerGenerator {
|
||||
|
||||
if (layerCount > 0) {
|
||||
KList<CNG> heightGenerators = getLayerHeightGenerators(biome, random, rdata);
|
||||
double surfaceSlope = 0D;
|
||||
boolean sampledSlope = false;
|
||||
|
||||
for (int i = 0; i < layerCount; i++) {
|
||||
IrisBiomePaletteLayer layer = layers.get(i);
|
||||
@@ -181,7 +189,11 @@ final class IrisBiomeLayerGenerator {
|
||||
IrisSlopeClip sc = layer.getSlopeCondition();
|
||||
|
||||
if (!sc.isDefault()) {
|
||||
if (!sc.isValid(resolveSlopeStream(complex, slopeStream).getDouble(wx, wz))) {
|
||||
if (!sampledSlope) {
|
||||
surfaceSlope = resolveSurfaceSlope(complex, slopeStream, wx, height, wz);
|
||||
sampledSlope = true;
|
||||
}
|
||||
if (!sc.isValid(surfaceSlope)) {
|
||||
d = 0;
|
||||
}
|
||||
}
|
||||
@@ -206,11 +218,19 @@ final class IrisBiomeLayerGenerator {
|
||||
return real;
|
||||
}
|
||||
|
||||
private static ProceduralStream<Double> resolveSlopeStream(
|
||||
private static double resolveSurfaceSlope(
|
||||
IrisComplex complex,
|
||||
ProceduralStream<Double> slopeStream
|
||||
ProceduralStream<Double> slopeStream,
|
||||
double x,
|
||||
int height,
|
||||
double z
|
||||
) {
|
||||
return slopeStream == null ? complex.getSlopeStream() : slopeStream;
|
||||
if (slopeStream != null) {
|
||||
return slopeStream.getDouble(x, z);
|
||||
}
|
||||
return complex.hasTerrain3D()
|
||||
? complex.terrainSurfaceSlope((int) Math.floor(x), height, (int) Math.floor(z))
|
||||
: complex.getSlopeStream().getDouble(x, z);
|
||||
}
|
||||
|
||||
static KList<PlatformBlockState> generateSeaLayers(IrisBiome biome, double wx, double wz, RNG random, int maxDepth, IrisData rdata) {
|
||||
|
||||
@@ -219,7 +219,7 @@ public class IrisDimension extends IrisRegistrant {
|
||||
@MaxNumber(256)
|
||||
@Desc("Minimum air gap in blocks between the lower terrain surface and the upper terrain surface.")
|
||||
private int upperDimensionGap = 32;
|
||||
@Desc("When true, cave carving will cut through the upper dimension terrain. When false, the upper terrain is a solid untouched mass.")
|
||||
@Desc("When true, cave carving cuts through the upper dimension terrain. When false, cave carving leaves the upper terrain unchanged, including its biome terrain3D openings.")
|
||||
private boolean upperDimensionCarving = false;
|
||||
@Desc("When true, objects from the mantle (structures, trees, etc.) can be placed in the upper dimension terrain zone. When false, the upper terrain is protected from object placement.")
|
||||
private boolean upperDimensionObjects = false;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.iris.engine.object.annotations.MaxNumber;
|
||||
import art.arcane.iris.engine.object.annotations.MinNumber;
|
||||
import art.arcane.iris.engine.object.annotations.Snippet;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
@Snippet("terrain-3d")
|
||||
@Accessors(chain = true)
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Desc("Volumetric surface terrain that adds projecting rock and removes undercuts and fissures around the biome's height generators.")
|
||||
public class IrisTerrain3D {
|
||||
@Desc("Enable this biome's volumetric terrain profile.")
|
||||
private boolean enabled = true;
|
||||
|
||||
@Desc("Seed offset for the surface density and fissure fields.")
|
||||
private long seed = 0;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(128)
|
||||
@Desc("Maximum positive or negative surface density displacement in blocks.")
|
||||
private double amplitude = 32;
|
||||
|
||||
@MinNumber(8)
|
||||
@MaxNumber(4096)
|
||||
@Desc("Horizontal feature size in blocks for a density style with its default zoom.")
|
||||
private double horizontalScale = 96;
|
||||
|
||||
@MinNumber(8)
|
||||
@MaxNumber(4096)
|
||||
@Desc("Vertical feature size in blocks. Shorter scales relative to amplitude permit overlapping ledges and overhangs.")
|
||||
private double verticalScale = 24;
|
||||
|
||||
@Desc("Signed three-dimensional noise that displaces terrain density. Style zoom multiplies the configured feature sizes.")
|
||||
private IrisGeneratorStyle densityStyle = new IrisGeneratorStyle(NoiseStyle.SIMPLEX);
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(128)
|
||||
@Desc("Maximum additional fissure depth in blocks. Zero disables fissures.")
|
||||
private double crackDepth = 0;
|
||||
|
||||
@MinNumber(0.25)
|
||||
@MaxNumber(64)
|
||||
@Desc("Approximate fissure half-width in blocks around the fissure field's zero crossings.")
|
||||
private double crackWidth = 4;
|
||||
|
||||
@MinNumber(8)
|
||||
@MaxNumber(4096)
|
||||
@Desc("Horizontal fissure feature size in blocks. Fissures use four times this vertical scale.")
|
||||
private double crackScale = 96;
|
||||
|
||||
@Desc("Noise whose zero crossings define tall narrow fissures.")
|
||||
private IrisGeneratorStyle crackStyle = new IrisGeneratorStyle(NoiseStyle.SIMPLEX);
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(16)
|
||||
@Desc("Minimum original terrain slope, measured as rise divided by horizontal distance, before shaping starts. Zero disables slope gating.")
|
||||
private double minimumSlope = 0.15;
|
||||
|
||||
@MinNumber(0.001)
|
||||
@MaxNumber(16)
|
||||
@Desc("Slope interval above minimumSlope over which shaping reaches full strength.")
|
||||
private double slopeFade = 0.35;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(128)
|
||||
@Desc("Height above the dimension fluid level below which terrain remains solid and unchanged.")
|
||||
private double fluidClearance = 8;
|
||||
|
||||
@MinNumber(1)
|
||||
@MaxNumber(128)
|
||||
@Desc("Vertical distance over which shaping grows from zero above the fluid clearance.")
|
||||
private double fluidFade = 24;
|
||||
|
||||
public void validate() {
|
||||
requireRange("amplitude", amplitude, 0D, 128D);
|
||||
requireRange("horizontalScale", horizontalScale, 8D, 4096D);
|
||||
requireRange("verticalScale", verticalScale, 8D, 4096D);
|
||||
requireRange("crackDepth", crackDepth, 0D, 128D);
|
||||
requireRange("crackWidth", crackWidth, 0.25D, 64D);
|
||||
requireRange("crackScale", crackScale, 8D, 4096D);
|
||||
requireRange("minimumSlope", minimumSlope, 0D, 16D);
|
||||
requireRange("slopeFade", slopeFade, 0.001D, 16D);
|
||||
requireRange("fluidClearance", fluidClearance, 0D, 128D);
|
||||
requireRange("fluidFade", fluidFade, 1D, 128D);
|
||||
if (densityStyle == null || crackStyle == null) {
|
||||
throw new IllegalArgumentException("terrain3D densityStyle and crackStyle must be style objects or snippets");
|
||||
}
|
||||
validateStyle(densityStyle, "densityStyle", 0);
|
||||
validateStyle(crackStyle, "crackStyle", 0);
|
||||
}
|
||||
|
||||
private static void validateStyle(IrisGeneratorStyle style, String path, int depth) {
|
||||
if (depth > 32 || style.getStyle() == null) {
|
||||
throw new IllegalArgumentException("terrain3D." + path + " must use a known style with at most 32 nested fractures");
|
||||
}
|
||||
requireRange(path + ".zoom", style.getZoom(), 0.00001D, Double.MAX_VALUE);
|
||||
requireRange(path + ".cellularFrequency", style.getCellularFrequency(), 0D, Double.MAX_VALUE);
|
||||
requireRange(path + ".cellularZoom", style.getCellularZoom(), 0.00001D, Double.MAX_VALUE);
|
||||
requireRange(path + ".multiplier", style.getMultiplier(), 0.00001D, Double.MAX_VALUE);
|
||||
requireRange(path + ".exponent", style.getExponent(), 0.01562D, 64D);
|
||||
requireRange(path + ".cacheSize", style.getCacheSize(), 0D, 8192D);
|
||||
if (style.getFracture() != null) {
|
||||
validateStyle(style.getFracture(), path + ".fracture", depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireRange(String name, double value, double minimum, double maximum) {
|
||||
if (!Double.isFinite(value) || value < minimum || value > maximum) {
|
||||
throw new IllegalArgumentException("terrain3D." + name + " must be finite and between "
|
||||
+ minimum + " and " + maximum);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package art.arcane.iris.engine.terrain;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class Terrain3DColumn {
|
||||
private final double baseHeight;
|
||||
private final int minY;
|
||||
private final boolean shaped;
|
||||
private final int[] boundaries;
|
||||
|
||||
Terrain3DColumn(double baseHeight, int minY, boolean shaped, int[] boundaries) {
|
||||
this.baseHeight = baseHeight;
|
||||
this.minY = minY;
|
||||
this.shaped = shaped;
|
||||
this.boundaries = boundaries.clone();
|
||||
}
|
||||
|
||||
public static Terrain3DColumn unshaped(double baseHeight, int height) {
|
||||
int top = Math.clamp(Math.round(baseHeight), 0, height - 1);
|
||||
return new Terrain3DColumn(baseHeight, top + 1, false, new int[]{0, top});
|
||||
}
|
||||
|
||||
public double baseHeight() {
|
||||
return baseHeight;
|
||||
}
|
||||
|
||||
public int topY() {
|
||||
return boundaries[boundaries.length - 1];
|
||||
}
|
||||
|
||||
public int minY() {
|
||||
return minY;
|
||||
}
|
||||
|
||||
public boolean shaped() {
|
||||
return shaped;
|
||||
}
|
||||
|
||||
public int spanCount() {
|
||||
return boundaries.length / 2;
|
||||
}
|
||||
|
||||
public int floor(int span) {
|
||||
return boundaries[span * 2 + 1];
|
||||
}
|
||||
|
||||
public int ceiling(int span) {
|
||||
return boundaries[span * 2];
|
||||
}
|
||||
|
||||
public boolean isSolid(int y) {
|
||||
return surfaceY(y) >= 0;
|
||||
}
|
||||
|
||||
public int highestSolidY(int maximumY) {
|
||||
for (int index = boundaries.length - 2; index >= 0; index -= 2) {
|
||||
if (maximumY >= boundaries[index]) {
|
||||
return Math.min(maximumY, boundaries[index + 1]);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public int surfaceY(int y) {
|
||||
for (int index = 0; index < boundaries.length; index += 2) {
|
||||
if (y < boundaries[index]) {
|
||||
return -1;
|
||||
}
|
||||
if (y <= boundaries[index + 1]) {
|
||||
return boundaries[index + 1];
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public int nearestSurfaceY(int y) {
|
||||
int nearest = boundaries[1];
|
||||
long distance = Math.abs((long) nearest - y);
|
||||
for (int index = 3; index < boundaries.length; index += 2) {
|
||||
long candidateDistance = Math.abs((long) boundaries[index] - y);
|
||||
if (candidateDistance < distance) {
|
||||
nearest = boundaries[index];
|
||||
distance = candidateDistance;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object value) {
|
||||
return this == value || value instanceof Terrain3DColumn other
|
||||
&& Double.compare(baseHeight, other.baseHeight) == 0
|
||||
&& minY == other.minY && shaped == other.shaped
|
||||
&& Arrays.equals(boundaries, other.boundaries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Double.hashCode(baseHeight);
|
||||
result = 31 * result + minY;
|
||||
result = 31 * result + Boolean.hashCode(shaped);
|
||||
return 31 * result + Arrays.hashCode(boundaries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package art.arcane.iris.engine.terrain;
|
||||
|
||||
import it.unimi.dsi.fastutil.HashCommon;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
|
||||
final class Terrain3DFragmentFilter {
|
||||
private static final int MAXIMUM_FRAGMENT_VOLUME = 512;
|
||||
private static final int VISITED_CAPACITY = 2048;
|
||||
private static final int UNKNOWN = 0;
|
||||
private static final int KEEP = 1;
|
||||
private static final int REMOVE = 2;
|
||||
|
||||
private final ColumnSource source;
|
||||
private final ThreadLocal<Traversal> traversals = ThreadLocal.withInitial(Traversal::new);
|
||||
|
||||
Terrain3DFragmentFilter(ColumnSource source) {
|
||||
this.source = Objects.requireNonNull(source, "Raw density columns");
|
||||
}
|
||||
|
||||
Terrain3DColumn column(int x, int z) {
|
||||
DensityColumn density = source.column(x, z);
|
||||
Terrain3DColumn resolved = density.resolved;
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
}
|
||||
Terrain3DColumn raw = density.raw;
|
||||
int removed = 0;
|
||||
for (int span = 1; span < raw.spanCount(); span++) {
|
||||
if (density.state(span) == UNKNOWN) {
|
||||
traversals.get().classify(x, z, density, span);
|
||||
}
|
||||
if (density.state(span) == REMOVE) {
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
resolved = removed == 0 ? raw : retainSpans(density, removed);
|
||||
density.resolved = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
traversals.remove();
|
||||
}
|
||||
|
||||
private Terrain3DColumn retainSpans(DensityColumn density, int removed) {
|
||||
Terrain3DColumn raw = density.raw;
|
||||
int[] boundaries = new int[(raw.spanCount() - removed) * 2];
|
||||
int index = 0;
|
||||
for (int span = 0; span < raw.spanCount(); span++) {
|
||||
if (density.state(span) != REMOVE) {
|
||||
boundaries[index++] = raw.ceiling(span);
|
||||
boundaries[index++] = raw.floor(span);
|
||||
}
|
||||
}
|
||||
return new Terrain3DColumn(raw.baseHeight(), raw.minY(), raw.shaped(), boundaries);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface ColumnSource {
|
||||
DensityColumn column(int x, int z);
|
||||
}
|
||||
|
||||
static final class DensityColumn {
|
||||
private final Terrain3DColumn raw;
|
||||
private final AtomicIntegerArray states;
|
||||
private volatile Terrain3DColumn resolved;
|
||||
|
||||
DensityColumn(Terrain3DColumn raw) {
|
||||
this.raw = Objects.requireNonNull(raw, "Raw density column");
|
||||
states = raw.spanCount() == 1 ? null : new AtomicIntegerArray(raw.spanCount() - 1);
|
||||
resolved = states == null ? raw : null;
|
||||
}
|
||||
|
||||
private int state(int span) {
|
||||
return span == 0 ? KEEP : states.get(span - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Traversal {
|
||||
private final int[] xs = new int[MAXIMUM_FRAGMENT_VOLUME + 1];
|
||||
private final int[] zs = new int[MAXIMUM_FRAGMENT_VOLUME + 1];
|
||||
private final int[] spans = new int[MAXIMUM_FRAGMENT_VOLUME + 1];
|
||||
private final DensityColumn[] columns = new DensityColumn[MAXIMUM_FRAGMENT_VOLUME + 1];
|
||||
private final int[] visited = new int[VISITED_CAPACITY];
|
||||
private final int[] visitedSlots = new int[MAXIMUM_FRAGMENT_VOLUME + 1];
|
||||
private int count;
|
||||
private int volume;
|
||||
private int decision;
|
||||
|
||||
private void classify(int x, int z, DensityColumn density, int span) {
|
||||
try {
|
||||
visit(x, z, density, span);
|
||||
for (int cursor = 0; cursor < count && decision == UNKNOWN; cursor++) {
|
||||
Terrain3DColumn raw = columns[cursor].raw;
|
||||
int bottom = raw.ceiling(spans[cursor]);
|
||||
int top = raw.floor(spans[cursor]);
|
||||
inspect((long) xs[cursor] - 1, zs[cursor], bottom, top);
|
||||
inspect((long) xs[cursor] + 1, zs[cursor], bottom, top);
|
||||
inspect(xs[cursor], (long) zs[cursor] - 1, bottom, top);
|
||||
inspect(xs[cursor], (long) zs[cursor] + 1, bottom, top);
|
||||
}
|
||||
int result = decision == UNKNOWN ? REMOVE : decision;
|
||||
for (int index = 0; index < count; index++) {
|
||||
columns[index].states.set(spans[index] - 1, result);
|
||||
}
|
||||
} finally {
|
||||
for (int index = 0; index < count; index++) {
|
||||
visited[visitedSlots[index]] = 0;
|
||||
}
|
||||
Arrays.fill(columns, 0, count, null);
|
||||
count = 0;
|
||||
volume = 0;
|
||||
decision = UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private void inspect(long x, long z, int bottom, int top) {
|
||||
if (decision != UNKNOWN) {
|
||||
return;
|
||||
}
|
||||
if (x < Integer.MIN_VALUE || x > Integer.MAX_VALUE
|
||||
|| z < Integer.MIN_VALUE || z > Integer.MAX_VALUE) {
|
||||
decision = KEEP;
|
||||
return;
|
||||
}
|
||||
DensityColumn density = source.column((int) x, (int) z);
|
||||
Terrain3DColumn raw = density.raw;
|
||||
for (int span = 0; span < raw.spanCount(); span++) {
|
||||
if (raw.ceiling(span) > top || decision != UNKNOWN) {
|
||||
return;
|
||||
}
|
||||
if (raw.floor(span) >= bottom) {
|
||||
visit((int) x, (int) z, density, span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void visit(int x, int z, DensityColumn density, int span) {
|
||||
int state = density.state(span);
|
||||
if (state != UNKNOWN) {
|
||||
decision = state;
|
||||
return;
|
||||
}
|
||||
int slot = visitedSlot(x, z, span);
|
||||
if (visited[slot] != 0) {
|
||||
return;
|
||||
}
|
||||
visited[slot] = count + 1;
|
||||
visitedSlots[count] = slot;
|
||||
xs[count] = x;
|
||||
zs[count] = z;
|
||||
spans[count] = span;
|
||||
columns[count++] = density;
|
||||
volume += density.raw.floor(span) - density.raw.ceiling(span) + 1;
|
||||
if (volume > MAXIMUM_FRAGMENT_VOLUME) {
|
||||
decision = KEEP;
|
||||
}
|
||||
}
|
||||
|
||||
private int visitedSlot(int x, int z, int span) {
|
||||
long coordinate = (long) x << 32 ^ z & 0xffffffffL;
|
||||
int slot = (int) HashCommon.mix(coordinate ^ span * 0x9e3779b97f4a7c15L)
|
||||
& (VISITED_CAPACITY - 1);
|
||||
while (visited[slot] != 0) {
|
||||
int index = visited[slot] - 1;
|
||||
if (xs[index] == x && zs[index] == z && spans[index] == span) {
|
||||
return slot;
|
||||
}
|
||||
slot = (slot + 1) & (VISITED_CAPACITY - 1);
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package art.arcane.iris.engine.terrain;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisGeneratorStyle;
|
||||
import art.arcane.iris.engine.object.IrisTerrain3D;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReferenceArray;
|
||||
|
||||
public final class Terrain3DRuntime {
|
||||
private static final int STEP = 4;
|
||||
private static final int CACHE_STRIPES = 16;
|
||||
private static final long DENSITY_SALT = 0x536E4A11C924B3D7L;
|
||||
private static final long CRACK_SALT = 0x7839A16DC4052EFBL;
|
||||
private static final NodeSample EMPTY_SAMPLE = new NodeSample(0D, 0D, 0D);
|
||||
private static final CompiledProfile DISABLED = new CompiledProfile(null, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, null, null);
|
||||
|
||||
private final Sources sources;
|
||||
private final Options options;
|
||||
private final NoiseFactory noiseFactory;
|
||||
private final BoundedCache<Terrain3DFragmentFilter.DensityColumn> columns;
|
||||
private final BoundedCache<Anchor> anchors;
|
||||
private final Terrain3DFragmentFilter fragments;
|
||||
private final Map<IrisTerrain3D, CompiledProfile> profiles = new IdentityHashMap<>();
|
||||
private final ThreadLocal<ColumnMemo> localColumn = ThreadLocal.withInitial(ColumnMemo::new);
|
||||
private volatile Object cacheGeneration = new Object();
|
||||
|
||||
public Terrain3DRuntime(Sources sources, Options options) {
|
||||
this(sources, options, (style, seed) -> {
|
||||
CNG noise = style.createNoCache(new RNG(seed), options.data());
|
||||
return noise::noiseFastSigned3D;
|
||||
});
|
||||
}
|
||||
|
||||
Terrain3DRuntime(Sources sources, Options options, NoiseFactory noiseFactory) {
|
||||
this.sources = Objects.requireNonNull(sources, "Terrain sources");
|
||||
this.options = Objects.requireNonNull(options, "Terrain options");
|
||||
this.noiseFactory = Objects.requireNonNull(noiseFactory, "Terrain noise factory");
|
||||
columns = new BoundedCache<>(options.maximumColumns());
|
||||
anchors = new BoundedCache<>(Math.max(CACHE_STRIPES, options.maximumColumns() / 4));
|
||||
fragments = new Terrain3DFragmentFilter(this::rawColumn);
|
||||
}
|
||||
|
||||
public boolean active() {
|
||||
return options.enabled();
|
||||
}
|
||||
|
||||
public Terrain3DColumn column(int x, int z) {
|
||||
if (!active()) {
|
||||
return Terrain3DColumn.unshaped(baseHeight(x, z), options.height());
|
||||
}
|
||||
long key = pack(x, z);
|
||||
Object generation = cacheGeneration;
|
||||
ColumnMemo memo = localColumn.get();
|
||||
if (memo.generation == generation && memo.key == key) {
|
||||
return memo.column;
|
||||
}
|
||||
Terrain3DColumn column = fragments.column(x, z);
|
||||
memo.key = key;
|
||||
memo.column = column;
|
||||
memo.generation = generation;
|
||||
return column;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
columns.clear();
|
||||
anchors.clear();
|
||||
fragments.clear();
|
||||
synchronized (profiles) {
|
||||
profiles.clear();
|
||||
}
|
||||
cacheGeneration = new Object();
|
||||
localColumn.remove();
|
||||
}
|
||||
|
||||
private Terrain3DFragmentFilter.DensityColumn rawColumn(int x, int z) {
|
||||
long key = pack(x, z);
|
||||
Terrain3DFragmentFilter.DensityColumn cached = columns.get(key);
|
||||
return cached == null ? columns.putIfAbsent(key,
|
||||
new Terrain3DFragmentFilter.DensityColumn(createColumn(x, z))) : cached;
|
||||
}
|
||||
|
||||
private Terrain3DColumn createColumn(int x, int z) {
|
||||
double baseHeight = baseHeight(x, z);
|
||||
int gridX = Math.floorDiv(x, STEP) * STEP;
|
||||
int gridZ = Math.floorDiv(z, STEP) * STEP;
|
||||
Anchor northWest = anchor(gridX, gridZ);
|
||||
Anchor northEast = anchor(gridX + STEP, gridZ);
|
||||
Anchor southWest = anchor(gridX, gridZ + STEP);
|
||||
Anchor southEast = anchor(gridX + STEP, gridZ + STEP);
|
||||
double dx = (x - gridX) / (double) STEP;
|
||||
double dz = (z - gridZ) / (double) STEP;
|
||||
double amplitude = interpolate(northWest.amplitude(), northEast.amplitude(),
|
||||
southWest.amplitude(), southEast.amplitude(), dx, dz);
|
||||
double crackDepth = interpolate(northWest.crackDepth(), northEast.crackDepth(),
|
||||
southWest.crackDepth(), southEast.crackDepth(), dx, dz);
|
||||
if (amplitude + crackDepth <= 0D) {
|
||||
return Terrain3DColumn.unshaped(baseHeight, options.height());
|
||||
}
|
||||
double crackWidth = interpolate(northWest.profile.crackWidth, northEast.profile.crackWidth,
|
||||
southWest.profile.crackWidth, southEast.profile.crackWidth, dx, dz);
|
||||
int minimum = Math.max(Math.max(1, (int) Math.floor(options.fluidHeight()) + 1),
|
||||
(int) Math.floor(baseHeight - amplitude - crackDepth));
|
||||
int maximum = Math.min(options.height() - 1, (int) Math.ceil(baseHeight + amplitude));
|
||||
if (minimum > maximum) {
|
||||
return Terrain3DColumn.unshaped(baseHeight, options.height());
|
||||
}
|
||||
int[] boundaries = new int[16];
|
||||
int count = 1;
|
||||
boundaries[0] = 0;
|
||||
boolean previousSolid = true;
|
||||
for (int gridY = Math.floorDiv(minimum, STEP) * STEP; gridY <= maximum; gridY += STEP) {
|
||||
NodeSample lowerNW = northWest.sample(gridY);
|
||||
NodeSample lowerNE = northEast.sample(gridY);
|
||||
NodeSample lowerSW = southWest.sample(gridY);
|
||||
NodeSample lowerSE = southEast.sample(gridY);
|
||||
NodeSample upperNW = northWest.sample(gridY + STEP);
|
||||
NodeSample upperNE = northEast.sample(gridY + STEP);
|
||||
NodeSample upperSW = southWest.sample(gridY + STEP);
|
||||
NodeSample upperSE = southEast.sample(gridY + STEP);
|
||||
double lowerDisplacement = interpolate(lowerNW.displacement, lowerNE.displacement,
|
||||
lowerSW.displacement, lowerSE.displacement, dx, dz);
|
||||
double upperDisplacement = interpolate(upperNW.displacement, upperNE.displacement,
|
||||
upperSW.displacement, upperSE.displacement, dx, dz);
|
||||
double lowerCrack = interpolate(lowerNW.crackDistance, lowerNE.crackDistance,
|
||||
lowerSW.crackDistance, lowerSE.crackDistance, dx, dz);
|
||||
double upperCrack = interpolate(upperNW.crackDistance, upperNE.crackDistance,
|
||||
upperSW.crackDistance, upperSE.crackDistance, dx, dz);
|
||||
double lowerDepth = interpolate(lowerNW.crackDepth, lowerNE.crackDepth,
|
||||
lowerSW.crackDepth, lowerSE.crackDepth, dx, dz);
|
||||
double upperDepth = interpolate(upperNW.crackDepth, upperNE.crackDepth,
|
||||
upperSW.crackDepth, upperSE.crackDepth, dx, dz);
|
||||
int lastY = Math.min(maximum, gridY + STEP - 1);
|
||||
for (int y = Math.max(minimum, gridY); y <= lastY; y++) {
|
||||
double dy = (y - gridY) / (double) STEP;
|
||||
double displacement = lerp(lowerDisplacement, upperDisplacement, dy);
|
||||
double fissure = 0D;
|
||||
if (crackDepth > 0D && crackWidth > 0D) {
|
||||
double distance = Math.abs(lerp(lowerCrack, upperCrack, dy));
|
||||
double ridge = Math.max(0D, 1D - distance / crackWidth);
|
||||
fissure = lerp(lowerDepth, upperDepth, dy) * ridge * ridge;
|
||||
}
|
||||
boolean solid = baseHeight + 0.5D - y + displacement - fissure >= 0D;
|
||||
if (solid != previousSolid) {
|
||||
if (count == boundaries.length) {
|
||||
boundaries = Arrays.copyOf(boundaries, count * 2);
|
||||
}
|
||||
boundaries[count++] = solid ? y : y - 1;
|
||||
previousSolid = solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previousSolid) {
|
||||
if (count == boundaries.length) {
|
||||
boundaries = Arrays.copyOf(boundaries, count + 1);
|
||||
}
|
||||
boundaries[count++] = maximum;
|
||||
}
|
||||
return new Terrain3DColumn(baseHeight, minimum, true, Arrays.copyOf(boundaries, count));
|
||||
}
|
||||
|
||||
private Anchor anchor(int x, int z) {
|
||||
long key = pack(x, z);
|
||||
Anchor cached = anchors.get(key);
|
||||
return cached == null ? anchors.putIfAbsent(key, createAnchor(x, z)) : cached;
|
||||
}
|
||||
|
||||
private Anchor createAnchor(int x, int z) {
|
||||
IrisBiome biome = sources.biomes().sample(x, z);
|
||||
CompiledProfile profile = profile(biome == null ? null : biome.getTerrain3D());
|
||||
if (profile == DISABLED) {
|
||||
return new Anchor(x, z, profile, 0D);
|
||||
}
|
||||
double height = baseHeight(x, z);
|
||||
double elevationStrength = smooth((height - options.fluidHeight() - profile.fluidClearance)
|
||||
/ profile.fluidFade);
|
||||
if (elevationStrength <= 0D) {
|
||||
return new Anchor(x, z, profile, 0D);
|
||||
}
|
||||
double slopeStrength = 1D;
|
||||
if (profile.minimumSlope > 0D) {
|
||||
double east = (baseHeight(x + STEP, z) - height) / STEP;
|
||||
double south = (baseHeight(x, z + STEP) - height) / STEP;
|
||||
double slope = StrictMath.sqrt(east * east + south * south);
|
||||
slopeStrength = smooth((slope - profile.minimumSlope) / profile.slopeFade);
|
||||
}
|
||||
return new Anchor(x, z, profile, elevationStrength * slopeStrength);
|
||||
}
|
||||
|
||||
private CompiledProfile profile(IrisTerrain3D config) {
|
||||
if (config == null || !config.isEnabled()) {
|
||||
return DISABLED;
|
||||
}
|
||||
synchronized (profiles) {
|
||||
CompiledProfile cached = profiles.get(config);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
CompiledProfile compiled = compile(config);
|
||||
synchronized (profiles) {
|
||||
CompiledProfile existing = profiles.get(config);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
profiles.put(config, compiled);
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
|
||||
private CompiledProfile compile(IrisTerrain3D config) {
|
||||
config.validate();
|
||||
double amplitude = config.getAmplitude();
|
||||
double crackDepth = config.getCrackDepth();
|
||||
double horizontalScale = config.getHorizontalScale();
|
||||
double verticalScale = config.getVerticalScale();
|
||||
double crackScale = config.getCrackScale();
|
||||
double crackWidth = config.getCrackWidth();
|
||||
double minimumSlope = config.getMinimumSlope();
|
||||
double slopeFade = config.getSlopeFade();
|
||||
double fluidClearance = config.getFluidClearance();
|
||||
double fluidFade = config.getFluidFade();
|
||||
if (amplitude + crackDepth == 0D) {
|
||||
return DISABLED;
|
||||
}
|
||||
long seed = options.seed() ^ config.getSeed();
|
||||
NoiseSource density = amplitude == 0D ? null : noiseFactory.create(
|
||||
Objects.requireNonNull(config.getDensityStyle(), "terrain3D.densityStyle"), seed ^ DENSITY_SALT);
|
||||
NoiseSource cracks = crackDepth == 0D ? null : noiseFactory.create(
|
||||
Objects.requireNonNull(config.getCrackStyle(), "terrain3D.crackStyle"), seed ^ CRACK_SALT);
|
||||
return new CompiledProfile(config, amplitude, crackDepth, crackWidth, crackScale,
|
||||
64D / horizontalScale, 64D / verticalScale, 64D / crackScale,
|
||||
minimumSlope, slopeFade, fluidClearance, fluidFade, density, cracks);
|
||||
}
|
||||
|
||||
private double baseHeight(int x, int z) {
|
||||
double height = sources.heights().sample(x, z);
|
||||
if (!Double.isFinite(height)) {
|
||||
throw new IllegalStateException("Nonfinite terrain height at " + x + "," + z);
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private static double signed(NoiseSource source, double x, double y, double z) {
|
||||
double sampled = source.sample(x, y, z);
|
||||
if (!Double.isFinite(sampled)) {
|
||||
throw new IllegalStateException("Nonfinite terrain3D density at " + x + "," + y + "," + z);
|
||||
}
|
||||
return Math.clamp(sampled, -1D, 1D);
|
||||
}
|
||||
|
||||
private static double interpolate(double northWest, double northEast, double southWest,
|
||||
double southEast, double x, double z) {
|
||||
return lerp(lerp(northWest, northEast, x), lerp(southWest, southEast, x), z);
|
||||
}
|
||||
|
||||
private static double lerp(double lower, double upper, double fraction) {
|
||||
return lower + (upper - lower) * fraction;
|
||||
}
|
||||
|
||||
private static double smooth(double value) {
|
||||
double bounded = Math.clamp(value, 0D, 1D);
|
||||
return bounded * bounded * (3D - 2D * bounded);
|
||||
}
|
||||
|
||||
private static long pack(int x, int z) {
|
||||
return (long) x << 32 ^ z & 0xffffffffL;
|
||||
}
|
||||
|
||||
public record Sources(BaseHeightSource heights, BiomeSource biomes) {
|
||||
public Sources {
|
||||
Objects.requireNonNull(heights, "Base terrain height source");
|
||||
Objects.requireNonNull(biomes, "Base terrain biome source");
|
||||
}
|
||||
}
|
||||
|
||||
public record Options(long seed, int height, double fluidHeight, IrisData data,
|
||||
boolean enabled, int maximumColumns) {
|
||||
public Options {
|
||||
if (height < 2 || height > 4096 || !Double.isFinite(fluidHeight)
|
||||
|| maximumColumns < CACHE_STRIPES) {
|
||||
throw new IllegalArgumentException("Invalid volumetric terrain runtime options");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface BaseHeightSource {
|
||||
double sample(int x, int z);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface BiomeSource {
|
||||
IrisBiome sample(int x, int z);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface NoiseFactory {
|
||||
NoiseSource create(IrisGeneratorStyle style, long seed);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface NoiseSource {
|
||||
double sample(double x, double y, double z);
|
||||
}
|
||||
|
||||
private static final class ColumnMemo {
|
||||
private Object generation;
|
||||
private long key;
|
||||
private Terrain3DColumn column;
|
||||
}
|
||||
|
||||
private final class Anchor {
|
||||
private final int x;
|
||||
private final int z;
|
||||
private final CompiledProfile profile;
|
||||
private final double strength;
|
||||
private final AtomicReferenceArray<NodeSample> samples;
|
||||
|
||||
private Anchor(int x, int z, CompiledProfile profile, double strength) {
|
||||
this.x = x;
|
||||
this.z = z;
|
||||
this.profile = profile;
|
||||
this.strength = strength;
|
||||
samples = strength == 0D ? null
|
||||
: new AtomicReferenceArray<>(Math.ceilDiv(options.height(), STEP) + 1);
|
||||
}
|
||||
|
||||
private double amplitude() {
|
||||
return profile.amplitude * strength;
|
||||
}
|
||||
|
||||
private double crackDepth() {
|
||||
return profile.crackDepth * strength;
|
||||
}
|
||||
|
||||
private NodeSample sample(int y) {
|
||||
if (samples == null) {
|
||||
return EMPTY_SAMPLE;
|
||||
}
|
||||
int index = y / STEP;
|
||||
NodeSample cached = samples.get(index);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
double fade = strength * smooth((y - options.fluidHeight() - profile.fluidClearance)
|
||||
/ profile.fluidFade);
|
||||
double displacement = profile.density == null || fade == 0D ? 0D
|
||||
: profile.amplitude * fade * signed(profile.density,
|
||||
x * profile.horizontalFrequency, y * profile.verticalFrequency, z * profile.horizontalFrequency);
|
||||
double crackDistance = profile.cracks == null || fade == 0D ? 0D
|
||||
: profile.crackScale * 0.5D * signed(profile.cracks,
|
||||
x * profile.crackFrequency, y * profile.crackFrequency * 0.25D, z * profile.crackFrequency);
|
||||
NodeSample computed = new NodeSample(displacement, crackDistance, profile.crackDepth * fade);
|
||||
return samples.compareAndSet(index, null, computed) ? computed : samples.get(index);
|
||||
}
|
||||
}
|
||||
|
||||
private record NodeSample(double displacement, double crackDistance, double crackDepth) {
|
||||
}
|
||||
|
||||
private record CompiledProfile(IrisTerrain3D config, double amplitude, double crackDepth,
|
||||
double crackWidth, double crackScale, double horizontalFrequency,
|
||||
double verticalFrequency, double crackFrequency,
|
||||
double minimumSlope, double slopeFade, double fluidClearance,
|
||||
double fluidFade, NoiseSource density, NoiseSource cracks) {
|
||||
}
|
||||
|
||||
private static final class BoundedCache<T> {
|
||||
private final CacheStripe<T>[] stripes;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private BoundedCache(int maximumSize) {
|
||||
stripes = (CacheStripe<T>[]) new CacheStripe<?>[CACHE_STRIPES];
|
||||
for (int index = 0; index < stripes.length; index++) {
|
||||
stripes[index] = new CacheStripe<>(Math.ceilDiv(maximumSize, CACHE_STRIPES));
|
||||
}
|
||||
}
|
||||
|
||||
private T get(long key) {
|
||||
return stripe(key).get(key);
|
||||
}
|
||||
|
||||
private T putIfAbsent(long key, T value) {
|
||||
return stripe(key).putIfAbsent(key, value);
|
||||
}
|
||||
|
||||
private void clear() {
|
||||
for (CacheStripe<T> stripe : stripes) {
|
||||
stripe.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private CacheStripe<T> stripe(long key) {
|
||||
long mixed = key ^ (key >>> 33);
|
||||
mixed *= 0xff51afd7ed558ccdL;
|
||||
mixed ^= mixed >>> 33;
|
||||
return stripes[(int) mixed & (CACHE_STRIPES - 1)];
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CacheStripe<T> {
|
||||
private final int maximumSize;
|
||||
private final Long2ObjectLinkedOpenHashMap<T> entries = new Long2ObjectLinkedOpenHashMap<>(16);
|
||||
|
||||
private CacheStripe(int maximumSize) {
|
||||
this.maximumSize = maximumSize;
|
||||
}
|
||||
|
||||
private synchronized T get(long key) {
|
||||
return entries.getAndMoveToLast(key);
|
||||
}
|
||||
|
||||
private synchronized T putIfAbsent(long key, T value) {
|
||||
T existing = entries.getAndMoveToLast(key);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
entries.putAndMoveToLast(key, value);
|
||||
if (entries.size() > maximumSize) {
|
||||
entries.removeFirst();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private synchronized void clear() {
|
||||
entries.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionStack;
|
||||
import art.arcane.iris.engine.object.IrisObjectMarker;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.engine.object.IrisStaticObject;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -14,9 +18,57 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackExportClosureTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void exportedSnippetsLoadTerrainAndNestedStylesFromTheTargetPack() throws Exception {
|
||||
Path source = temporary.newFolder("source").toPath();
|
||||
Path target = temporary.newFolder("target").toPath();
|
||||
Path terrain = source.resolve("snippet/terrain-3d/cliffs/main.json");
|
||||
Path density = source.resolve("snippet/style/cliffs/density.json");
|
||||
Path fracture = source.resolve("snippet/style/cliffs/fracture.json");
|
||||
Path decorator = source.resolve("snippet/decorator/ledge.json");
|
||||
for (Path file : List.of(terrain, density, fracture, decorator)) {
|
||||
Files.createDirectories(file.getParent());
|
||||
}
|
||||
Files.writeString(terrain, "{\"amplitude\":57,\"densityStyle\":\"snippet/style/cliffs/density\"}");
|
||||
Files.writeString(density, "{\"style\":\"SIMPLEX\",\"zoom\":1.5,\"fracture\":\"snippet/style/cliffs/fracture\"}");
|
||||
Files.writeString(fracture, "{\"style\":\"PERLIN\",\"zoom\":2}");
|
||||
Files.writeString(decorator, "{\"chance\":0.2}");
|
||||
Files.writeString(source.resolve("snippet/notes.txt"), "not pack data");
|
||||
|
||||
String hash = PackExportClosure.copySnippets(source.toFile(), target.toFile());
|
||||
|
||||
assertFalse(hash.isEmpty());
|
||||
assertEquals(hash, PackExportClosure.copySnippets(source.toFile(), target.toFile()));
|
||||
assertTrue(Files.isRegularFile(target.resolve("snippet/decorator/ledge.json")));
|
||||
assertFalse(Files.exists(target.resolve("snippet/notes.txt")));
|
||||
IrisData data = IrisData.openDatapackCompiler(target.toFile());
|
||||
try {
|
||||
IrisBiome biome = data.getGson().fromJson(
|
||||
"{\"terrain3D\":\"snippet/terrain-3d/cliffs/main\"}", IrisBiome.class);
|
||||
assertEquals(57D, biome.getTerrain3D().getAmplitude(), 0D);
|
||||
assertEquals(1.5D, biome.getTerrain3D().getDensityStyle().getZoom(), 0D);
|
||||
assertEquals(2D, biome.getTerrain3D().getDensityStyle().getFracture().getZoom(), 0D);
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void packsWithoutSnippetsDoNotCreateAnExportFolder() throws Exception {
|
||||
Path source = temporary.newFolder("without-snippets").toPath();
|
||||
Path target = temporary.getRoot().toPath().resolve("absent-export");
|
||||
|
||||
assertEquals("", PackExportClosure.copySnippets(source.toFile(), target.toFile()));
|
||||
assertFalse(Files.exists(target));
|
||||
}
|
||||
|
||||
private static IrisObjectPlacement placement(String objectKey, String... markerKeys) {
|
||||
IrisObjectPlacement placement = new IrisObjectPlacement();
|
||||
placement.setPlace(new KList<>(objectKey));
|
||||
@@ -139,8 +191,8 @@ public class PackExportClosureTest {
|
||||
assertTrue("modded packager must export expression-backed stack blend styles",
|
||||
modded.contains("dm.getExpressionLoader().getPossibleKeys()")
|
||||
&& modded.contains("\"expressions\""));
|
||||
assertTrue("modded packager must retain referenced style snippets",
|
||||
modded.contains("copyStyleSnippets(packFolder, folder)"));
|
||||
assertTrue("modded packager must retain all snippet types and nested references",
|
||||
modded.contains("PackExportClosure.copySnippets(packFolder, folder)"));
|
||||
int moddedValidation = modded.indexOf("PackValidator.validateForPackaging(packFolder)");
|
||||
assertTrue("modded packager must validate before mutating package state",
|
||||
moddedValidation >= 0 && moddedValidation < modded.indexOf("IO.delete(folder)"));
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackTerrain3DValidatorTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void acceptsInlineProfilesAndReferencedProfileAndStyleSnippets() throws Exception {
|
||||
File pack = temporaryFolder.newFolder();
|
||||
write(pack, "biomes/mountains.json", """
|
||||
{"terrain3D":{"amplitude":64,"verticalScale":16,"densityStyle":{"style":"SIMPLEX"}}}
|
||||
""");
|
||||
write(pack, "biomes/cliffs.json", """
|
||||
{"terrain3D":"snippet/terrain-3d/cliffs"}
|
||||
""");
|
||||
write(pack, "snippet/terrain-3d/cliffs.json", """
|
||||
{"$schema":"../../schema.json","crackDepth":32,"densityStyle":"snippet/style/terrain"}
|
||||
""");
|
||||
write(pack, "snippet/style/terrain.json", """
|
||||
{"style":"SIMPLEX","fracture":"snippet/style/warp"}
|
||||
""");
|
||||
write(pack, "snippet/style/warp.json", """
|
||||
{"style":"SIMPLEX","zoom":2,"multiplier":12}
|
||||
""");
|
||||
|
||||
assertTrue(PackTerrain3DValidator.validate(pack).toString(), PackTerrain3DValidator.validate(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsOmissionAndNullWithoutEnablingProfiles() throws Exception {
|
||||
File pack = temporaryFolder.newFolder();
|
||||
write(pack, "biomes/plain.json", "{}");
|
||||
write(pack, "biomes/ocean.json", "{\"terrain3D\":null}");
|
||||
write(pack, "biomes/disabled.json", "{\"terrain3D\":{\"enabled\":false}}");
|
||||
|
||||
assertTrue(PackTerrain3DValidator.validate(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnknownFieldsAndNonBooleanEnableFlags() throws Exception {
|
||||
File pack = temporaryFolder.newFolder();
|
||||
write(pack, "biomes/unknown.json", "{\"terrain3D\":{\"amplitdue\":64}}");
|
||||
write(pack, "biomes/boolean.json", "{\"terrain3D\":{\"enabled\":\"true\"}}");
|
||||
write(pack, "biomes/array.json", "{\"terrain3D\":[]}");
|
||||
write(pack, "biomes/numeric.json", "{\"terrain3D\":{\"amplitude\":\"64\"}}");
|
||||
|
||||
List<String> errors = PackTerrain3DValidator.validate(pack);
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("amplitdue is not a terrain3D field")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("enabled must be a JSON boolean")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("terrain3D must be an object or snippet reference")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("amplitude must be a finite JSON number")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void appliesModelBoundsToInlineAndDisabledSnippetProfiles() throws Exception {
|
||||
File pack = temporaryFolder.newFolder();
|
||||
write(pack, "biomes/amplitude.json", "{\"terrain3D\":{\"amplitude\":129}}");
|
||||
write(pack, "snippet/terrain-3d/disabled.json", "{\"enabled\":false,\"verticalScale\":0}");
|
||||
write(pack, "biomes/nonfinite.json", "{\"terrain3D\":{\"crackDepth\":1e999}}");
|
||||
write(pack, "biomes/seed.json", "{\"terrain3D\":{\"seed\":1.5}}");
|
||||
write(pack, "biomes/huge-seed.json", "{\"terrain3D\":{\"seed\":9223372036854775808}}");
|
||||
|
||||
List<String> errors = PackTerrain3DValidator.validate(pack);
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("amplitude must be finite and between")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("verticalScale must be finite and between")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("crackDepth must be a finite JSON number")));
|
||||
assertTrue(errors.stream().filter(error -> error.contains("seed must be an integer")).count() == 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnavailableAndUnreadableCurrentSnippets() throws Exception {
|
||||
File pack = temporaryFolder.newFolder();
|
||||
write(pack, "biomes/missing.json", "{\"terrain3D\":\"snippet/terrain-3d/missing\"}");
|
||||
write(pack, "biomes/style.json", "{\"terrain3D\":{\"densityStyle\":\"snippet/style/missing\"}}");
|
||||
write(pack, "snippet/terrain-3d/broken.json", "{\"amplitude\":");
|
||||
write(pack, "biomes/path.json", "{\"terrain3D\":\"snippet/terrain-3d/../../outside\"}");
|
||||
|
||||
List<String> errors = PackTerrain3DValidator.validate(pack);
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("unavailable terrain-3d snippet")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("unavailable style snippet")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("contains unreadable JSON")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMalformedNestedStylesAndCyclicStyleReferences() throws Exception {
|
||||
File pack = temporaryFolder.newFolder();
|
||||
write(pack, "biomes/shape.json", "{\"terrain3D\":{\"densityStyle\":true}}");
|
||||
write(pack, "biomes/style.json", "{\"terrain3D\":{\"densityStyle\":{\"style\":\"UNKNOWN\"}}}");
|
||||
write(pack, "biomes/nested.json", """
|
||||
{"terrain3D":{"densityStyle":{"fracture":{"style":"SIMPLEX","zoom":0,"wrong":1}}}}
|
||||
""");
|
||||
write(pack, "biomes/cycle.json", "{\"terrain3D\":{\"densityStyle\":\"snippet/style/cycle\"}}");
|
||||
write(pack, "snippet/style/cycle.json", "{\"fracture\":\"snippet/style/cycle\"}");
|
||||
|
||||
List<String> errors = PackTerrain3DValidator.validate(pack);
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("densityStyle must be an object or snippet reference")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("is not a known noise style")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("fracture.zoom must be between")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("wrong is not a generator style field")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("maximum style nesting depth")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidTerrainProfileBlocksFullPackValidation() throws Exception {
|
||||
File pack = temporaryFolder.newFolder();
|
||||
write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}");
|
||||
write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}");
|
||||
write(pack, "biomes/biome.json", "{\"terrain3D\":{\"amplitude\":129}}");
|
||||
|
||||
PackValidationResult result = PackValidator.validate(pack);
|
||||
assertFalse(result.isLoadable());
|
||||
assertTrue(result.getBlockingErrors().stream()
|
||||
.anyMatch(error -> error.contains("terrain3D.amplitude must be finite and between")));
|
||||
}
|
||||
|
||||
private static void write(File root, String relative, String content) throws Exception {
|
||||
Path path = root.toPath().resolve(relative);
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
@@ -107,6 +108,7 @@ public class BoardSVCSavedBiomeTest {
|
||||
when(natural.getName()).thenReturn("Natural cave");
|
||||
when(flooded.getName()).thenReturn("Flooded cave");
|
||||
when(fixture.engine.getHeight(8, 8)).thenReturn(200);
|
||||
when(fixture.engine.getComplex()).thenReturn(mock(IrisComplex.class));
|
||||
when(fixture.engine.getCaveBiome(8, 160, 8)).thenReturn(natural);
|
||||
when(fixture.engine.getCaveOrMantleBiome(8, 160, 8)).thenReturn(flooded);
|
||||
when(fixture.engine.getDimension()).thenReturn(fixture.environment.dimension());
|
||||
|
||||
@@ -331,7 +331,8 @@ public class DimensionStackLayoutTest {
|
||||
null,
|
||||
null,
|
||||
terrainHeight,
|
||||
fluidHeight
|
||||
fluidHeight,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.actuator.IrisDimensionStackActuator;
|
||||
import art.arcane.iris.engine.actuator.IrisTerrainNormalActuator;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisBiomePaletteLayer;
|
||||
import art.arcane.iris.engine.object.IrisSlopeClip;
|
||||
import art.arcane.iris.engine.image.IrisImageMapRuntime;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumnFixtures;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.context.ChunkedDataCache;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class DimensionTerrain3DCompositionTest {
|
||||
private final Map<String, PlatformBlockState> states = new HashMap<>();
|
||||
private IrisPlatform previousPlatform;
|
||||
|
||||
@Before
|
||||
public void bindPlatform() {
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.block(anyString())).thenAnswer(invocation -> state(invocation.getArgument(0)));
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
|
||||
@After
|
||||
public void restorePlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
if (previousPlatform != null) {
|
||||
IrisPlatforms.bind(previousPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void actualStackPassClearsGapsAndPaintsEveryExposedFloor() {
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(6, 0, 3, 7, 9);
|
||||
DimensionStackLayout layout = DimensionStackLayout.create(32, 0,
|
||||
List.of(input(10, 0, null), input(9, 0, terrain)), new int[]{0});
|
||||
Hunk<PlatformBlockState> blocks = render(layout, 32);
|
||||
assertSame(state("surface"), blocks.getRaw(0, 14, 0));
|
||||
assertSame(state("rock"), blocks.getRaw(0, 13, 0));
|
||||
assertTrue(blocks.getRaw(0, 15, 0).isAir());
|
||||
assertTrue(blocks.getRaw(0, 17, 0).isAir());
|
||||
assertSame(state("surface"), blocks.getRaw(0, 20, 0));
|
||||
for (int y = 11; y <= 20; y++) {
|
||||
assertEquals("y=" + y, layout.isSolid(y), !blocks.getRaw(0, y, 0).isAir());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clippedWorldCeilingFindsTheActualFloorBelowAnUndercut() {
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(6, 0, 3, 7, 9);
|
||||
DimensionStackLayout layout = DimensionStackLayout.create(17, 0,
|
||||
List.of(input(10, 0, null), input(9, 0, terrain)), new int[]{0});
|
||||
assertEquals(14, layout.clippedStackTerrainTopY());
|
||||
assertEquals(14, layout.clippedStackTopY());
|
||||
assertEquals(14, layout.layersBottomToTop().get(1).clippedSurfaceY());
|
||||
assertSame(layout.layersBottomToTop().get(1), layout.topTerrainLayer());
|
||||
assertTrue(render(layout, 17).getRaw(0, 16, 0).isAir());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void laterLayerAirOverwritesLowerTerrainInAnOverlappingSeam() {
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(6, 0, 2, 8, 10);
|
||||
DimensionStackLayout layout = DimensionStackLayout.create(32, 0,
|
||||
List.of(input(20, 0, null), input(10, 0, terrain)), new int[]{-11});
|
||||
Hunk<PlatformBlockState> blocks = render(layout, 32);
|
||||
assertFalse(layout.isSolid(15));
|
||||
assertTrue(blocks.getRaw(0, 15, 0).isAir());
|
||||
assertTrue(layout.isHostFeatureProtectedY(15));
|
||||
assertTrue(layout.isSolid(18));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void referencedStackSelectsEachLedgePaletteFromItsSourceSlope() {
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(15, 0, 5, 10, 21);
|
||||
IrisBiome biome = paletteBiome(false, new IrisSlopeClip(0D, 1D), "grass");
|
||||
DimensionStackLayout.LayerInput upper = materialInput(terrain, biome, 5D);
|
||||
DimensionStackLayout layout = DimensionStackLayout.create(64, 0,
|
||||
List.of(input(10, 0, null), upper), new int[]{0});
|
||||
Hunk<PlatformBlockState> blocks = render(layout, 64);
|
||||
assertSame(state("grass"), blocks.getRaw(0, 16, 0));
|
||||
assertSame(state("rock"), blocks.getRaw(0, 32, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void referencedStackLockedPalettePhaseUsesEachSourceFloorHeight() {
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(15, 0, 5, 10, 21);
|
||||
IrisBiome biome = paletteBiome(true, new IrisSlopeClip(), "a", "b", "c");
|
||||
DimensionStackLayout layout = DimensionStackLayout.create(64, 0,
|
||||
List.of(input(10, 0, null), materialInput(terrain, biome, 0D)), new int[]{0});
|
||||
Hunk<PlatformBlockState> blocks = render(layout, 64);
|
||||
assertSame(state("a"), blocks.getRaw(0, 16, 0));
|
||||
assertSame(state("c"), blocks.getRaw(0, 15, 0));
|
||||
assertSame(state("c"), blocks.getRaw(0, 32, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mirroredUpperPalettesUseSourceSlopesAndLockedLayerPhases() {
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(15, 0, 5, 10, 21);
|
||||
UpperDimensionContext upper = materialUpper(terrain,
|
||||
paletteBiome(false, new IrisSlopeClip(0D, 1D), "grass"), 5D);
|
||||
Hunk<PlatformBlockState> slopeBlocks = renderUpper(upper);
|
||||
assertSame(state("grass"), slopeBlocks.getRaw(0, 58, 0));
|
||||
assertSame(state("rock"), slopeBlocks.getRaw(0, 42, 0));
|
||||
|
||||
upper = materialUpper(terrain, paletteBiome(true, new IrisSlopeClip(), "a", "b", "c"), 0D);
|
||||
Hunk<PlatformBlockState> lockedBlocks = renderUpper(upper);
|
||||
assertSame(state("a"), lockedBlocks.getRaw(0, 58, 0));
|
||||
assertSame(state("c"), lockedBlocks.getRaw(0, 59, 0));
|
||||
assertSame(state("c"), lockedBlocks.getRaw(0, 42, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void actualUpperPassMirrorsGapsAndPaintsEveryInvertedFace() {
|
||||
PlatformBlockState upperRock = state("upper-rock");
|
||||
PlatformBlockState upperSurface = state("upper-surface");
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(15, 0, 5, 10, 20);
|
||||
UpperDimensionContext.Column upperColumn = new UpperDimensionContext.Column(64, 43, terrain);
|
||||
UpperDimensionContext upper = mock(UpperDimensionContext.class);
|
||||
when(upper.sampleColumn(0, 0)).thenReturn(upperColumn);
|
||||
when(upper.getRockBlock(0, 0)).thenReturn(upperRock);
|
||||
when(upper.getSurfaceBlock(0, 0)).thenReturn(upperSurface);
|
||||
Hunk<PlatformBlockState> blocks = renderUpper(upper);
|
||||
|
||||
assertSame(state("upper-surface"), blocks.getRaw(0, 43, 0));
|
||||
assertSame(state("upper-rock"), blocks.getRaw(0, 44, 0));
|
||||
assertTrue(blocks.getRaw(0, 54, 0).isAir());
|
||||
assertTrue(blocks.getRaw(0, 57, 0).isAir());
|
||||
assertSame(state("upper-surface"), blocks.getRaw(0, 58, 0));
|
||||
for (int y = 43; y < 64; y++) {
|
||||
assertEquals("y=" + y, upperColumn.isSolid(y), !blocks.getRaw(0, y, 0).isAir());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clippedOverlappingStacksMatchIndependentVoxelComposition() {
|
||||
Random random = new Random(718223L);
|
||||
Terrain3DColumn first = Terrain3DColumnFixtures.spans(12, 0, 4, 10, 14, 21, 24);
|
||||
Terrain3DColumn second = Terrain3DColumnFixtures.spans(9, 0, 2, 7, 10, 14, 18);
|
||||
for (int iteration = 0; iteration < 150; iteration++) {
|
||||
int height = 8 + random.nextInt(56);
|
||||
DimensionStackLayout layout = DimensionStackLayout.create(height, random.nextInt(10),
|
||||
List.of(input(24, 0, first), input(18, 22, second), input(24, 0, first)),
|
||||
new int[]{random.nextInt(71) - 50, random.nextInt(71) - 50});
|
||||
boolean[] solid = new boolean[height];
|
||||
boolean[] content = new boolean[height];
|
||||
List<DimensionStackLayout.Layer> layers = layout.layersBottomToTop();
|
||||
for (int index = 0; index < layers.size(); index++) {
|
||||
DimensionStackLayout.Layer layer = layers.get(index);
|
||||
for (int y = 0; y < height; y++) {
|
||||
if (index > 0 && y > layers.get(index - 1).contentTopY() && y < layer.localBaseY()) {
|
||||
solid[y] = false;
|
||||
content[y] = false;
|
||||
}
|
||||
if (y >= layer.renderMinY() && y <= layer.renderMaxY()) {
|
||||
int sourceY = y - layer.localBaseY();
|
||||
solid[y] = layer.terrainColumn().isSolid(sourceY);
|
||||
content[y] = solid[y]
|
||||
|| sourceY > layer.normalTerrainHeight() && sourceY <= layer.fluidHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
int highestSolid = 0;
|
||||
int highestContent = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
assertEquals("iteration=" + iteration + ",y=" + y, solid[y], layout.isSolid(y));
|
||||
if (solid[y]) {
|
||||
highestSolid = y;
|
||||
}
|
||||
if (content[y]) {
|
||||
highestContent = y;
|
||||
}
|
||||
}
|
||||
assertEquals("iteration=" + iteration, highestSolid, layout.clippedStackTerrainTopY());
|
||||
assertEquals("iteration=" + iteration, highestContent, layout.clippedStackTopY());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composedSupportRespectsUpperAndStackOwnershipAboveRootTerrain() {
|
||||
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
doReturn(complex).when(engine).getComplex();
|
||||
doReturn(64).when(engine).getHeight();
|
||||
when(complex.terrainColumn(0, 0)).thenReturn(Terrain3DColumnFixtures.spans(12, 0, 4, 15, 20));
|
||||
UpperDimensionContext upper = mock(UpperDimensionContext.class);
|
||||
UpperDimensionContext.Column upperColumn = new UpperDimensionContext.Column(64, 40,
|
||||
Terrain3DColumnFixtures.spans(12, 0, 5, 10, 23));
|
||||
doReturn(upper).when(engine).getUpperContext();
|
||||
when(upper.sampleColumn(0, 0)).thenReturn(upperColumn);
|
||||
when(upper.getEffectiveSurfaceY(0, 0)).thenReturn(40);
|
||||
assertTrue(engine.isTerrainSurfaceSolid(0, 50, 0));
|
||||
assertFalse(engine.isTerrainSurfaceSolid(0, 56, 0));
|
||||
assertFalse(engine.isTerrainSurfaceSolid(0, 30, 0));
|
||||
|
||||
DimensionStackLayout layout = DimensionStackLayout.create(64, 0,
|
||||
List.of(input(20, 0, null), input(30, 0,
|
||||
Terrain3DColumnFixtures.spans(10, 0, 3, 28, 30))), new int[]{0});
|
||||
DimensionStackContext stack = mock(DimensionStackContext.class);
|
||||
doReturn(stack).when(engine).getDimensionStackContext();
|
||||
when(stack.getLayout(0, 0)).thenReturn(layout);
|
||||
assertFalse(engine.isTerrainSurfaceSolid(0, 45, 0));
|
||||
assertTrue(engine.isTerrainSurfaceSolid(0, 50, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stackedHostKeepsOutdoorLedgesInTheSurfaceBiome() {
|
||||
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
doReturn(complex).when(engine).getComplex();
|
||||
DimensionStackContext stack = mock(DimensionStackContext.class);
|
||||
doReturn(stack).when(engine).getDimensionStackContext();
|
||||
DimensionStackLayout.LayerInput bottom = input(24, 0,
|
||||
Terrain3DColumnFixtures.spans(12, 0, 4, 20, 24));
|
||||
DimensionStackLayout layout = DimensionStackLayout.create(64, 10,
|
||||
List.of(bottom, input(9, 0, null)), new int[]{0});
|
||||
when(stack.getLayout(0, 0)).thenReturn(layout);
|
||||
when(complex.isTerrain3DSurface(0, 4, 0)).thenReturn(true);
|
||||
IrisBiome cave = new IrisBiome();
|
||||
doReturn(cave).when(engine).getCaveBiome(0, 2, 0);
|
||||
doReturn(cave).when(engine).getCaveOrMantleBiome(0, 2, 0);
|
||||
assertSame(bottom.biome(), engine.getBiome(0, 4, 0));
|
||||
assertSame(bottom.biome(), engine.getBiomeOrMantle(0, 4, 0));
|
||||
assertSame(cave, engine.getBiome(0, 2, 0));
|
||||
assertSame(cave, engine.getBiomeOrMantle(0, 2, 0));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Hunk<PlatformBlockState> renderUpper(UpperDimensionContext upper) {
|
||||
PlatformBlockState rootRock = state("root");
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisDimension dimension = new IrisDimension().setBedrock(false).setHideOresForHiddenOre(true);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
|
||||
when(engine.getUpperContext()).thenReturn(upper);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 8D));
|
||||
when(complex.getImageMapRuntime()).thenReturn(mock(IrisImageMapRuntime.class));
|
||||
IrisBiome biome = mock(IrisBiome.class);
|
||||
when(biome.generateLayers(any(), anyDouble(), anyDouble(), any(), anyInt(), anyInt(), any(), any()))
|
||||
.thenReturn(new KList<>());
|
||||
ChunkedDataCache<IrisBiome> biomes = mock(ChunkedDataCache.class);
|
||||
when(biomes.get(0, 0)).thenReturn(biome);
|
||||
ChunkedDataCache<IrisRegion> regions = mock(ChunkedDataCache.class);
|
||||
ChunkedDataCache<PlatformBlockState> rocks = mock(ChunkedDataCache.class);
|
||||
when(rocks.get(0, 0)).thenReturn(rootRock);
|
||||
ChunkContext context = mock(ChunkContext.class);
|
||||
when(context.getBiome()).thenReturn(biomes);
|
||||
when(context.getRegion()).thenReturn(regions);
|
||||
when(context.getRock()).thenReturn(rocks);
|
||||
when(context.getRoundedHeight(0, 0)).thenReturn(8);
|
||||
Hunk<PlatformBlockState> blocks = Hunk.newArrayHunk(1, 64, 1);
|
||||
new IrisTerrainNormalActuator(engine).terrainSliver(0, 0, 0, blocks, context);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private Hunk<PlatformBlockState> render(DimensionStackLayout layout, int height) {
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
|
||||
when(engine.getDimensionStackContext()).thenReturn(mock(DimensionStackContext.class));
|
||||
ChunkContext context = mock(ChunkContext.class);
|
||||
when(context.isSpeculativeTerrain()).thenReturn(true);
|
||||
when(context.getDimensionStackLayout(0, 0)).thenReturn(layout);
|
||||
Hunk<PlatformBlockState> output = Hunk.newArrayHunk(1, height, 1);
|
||||
for (int y = 0; y < height; y++) {
|
||||
output.setRaw(0, y, 0, state("root"));
|
||||
}
|
||||
new IrisDimensionStackActuator(engine).onActuate(0, 0, output, false, context);
|
||||
return output;
|
||||
}
|
||||
|
||||
private DimensionStackLayout.LayerInput input(int height, int fluidHeight, Terrain3DColumn terrain) {
|
||||
DimensionTerrainContext context = mock(DimensionTerrainContext.class);
|
||||
when(context.getDimension()).thenReturn(new IrisDimension().setBedrock(false));
|
||||
return new DimensionStackLayout.LayerInput(context, null, null, state("rock"), state("water"),
|
||||
state("surface"), height, fluidHeight, terrain);
|
||||
}
|
||||
|
||||
private DimensionStackLayout.LayerInput materialInput(Terrain3DColumn terrain, IrisBiome biome, double capSlope) {
|
||||
DimensionStackLayout.LayerInput input = input(terrain.topY(), 0, terrain);
|
||||
when(input.terrainContext().getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> capSlope));
|
||||
when(input.terrainContext().getSurfaceSlopeStream(anyInt())).thenAnswer(invocation ->
|
||||
ProceduralStream.ofDouble((x, z) -> (int) invocation.getArgument(0) == terrain.topY() ? capSlope : 0D));
|
||||
return new DimensionStackLayout.LayerInput(input.terrainContext(), biome, null,
|
||||
input.rockBlock(), input.fluidBlock(), null, terrain.topY(), 0, terrain);
|
||||
}
|
||||
|
||||
private UpperDimensionContext materialUpper(Terrain3DColumn terrain, IrisBiome biome, double capSlope) {
|
||||
PlatformBlockState rock = state("rock");
|
||||
UpperDimensionContext upper = mock(UpperDimensionContext.class);
|
||||
when(upper.sampleColumn(0, 0)).thenReturn(new UpperDimensionContext.Column(64, 63 - terrain.topY(), terrain));
|
||||
when(upper.getRockBlock(0, 0)).thenReturn(rock);
|
||||
when(upper.getUpperBiome(0, 0)).thenReturn(biome);
|
||||
when(upper.getDimension()).thenReturn(new IrisDimension().setBedrock(false));
|
||||
when(upper.getSurfaceSlopeStream(anyInt())).thenAnswer(invocation ->
|
||||
ProceduralStream.ofDouble((x, z) -> (int) invocation.getArgument(0) == terrain.topY() ? capSlope : 0D));
|
||||
return upper;
|
||||
}
|
||||
|
||||
private IrisBiome paletteBiome(boolean locked, IrisSlopeClip slope, String... keys) {
|
||||
KList<IrisBiomePaletteLayer> layers = new KList<>();
|
||||
for (String key : keys) {
|
||||
PlatformBlockState block = state(key);
|
||||
IrisBiomePaletteLayer layer = mock(IrisBiomePaletteLayer.class);
|
||||
CNG height = mock(CNG.class);
|
||||
when(layer.getZoom()).thenReturn(1D);
|
||||
when(layer.getMinHeight()).thenReturn(1);
|
||||
when(layer.getMaxHeight()).thenReturn(1);
|
||||
when(layer.getSlopeCondition()).thenReturn(slope);
|
||||
when(layer.getHeightGenerator(any(), any())).thenReturn(height);
|
||||
when(height.fit(anyInt(), anyInt(), anyDouble(), anyDouble())).thenReturn(1);
|
||||
when(layer.get(any(), anyInt(), anyDouble(), anyDouble(), anyDouble(), any())).thenReturn(block);
|
||||
layers.add(layer);
|
||||
}
|
||||
return new IrisBiome().setLockLayers(locked).setLayers(layers);
|
||||
}
|
||||
|
||||
private PlatformBlockState state(String key) {
|
||||
return states.computeIfAbsent(key, value -> {
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
when(block.key()).thenReturn(value);
|
||||
when(block.isAir()).thenReturn(value.equalsIgnoreCase("air") || value.equals("minecraft:air"));
|
||||
return block;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,64 @@ import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumnFixtures;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class DimensionTerrainContextFallbackTest {
|
||||
@Test
|
||||
public void regionMapOnlyTargetsJoinPreparedRegionsWithoutDuplicateIdentities() {
|
||||
IrisRegion declared = new IrisRegion();
|
||||
IrisRegion mapped = new IrisRegion();
|
||||
declared.setLoadKey("declared");
|
||||
mapped.setLoadKey("mapped");
|
||||
IrisRegion excluded = mock(IrisRegion.class);
|
||||
when(excluded.isCompatExcluded()).thenReturn(true);
|
||||
IrisImageMapRuntime imageMaps = mock(IrisImageMapRuntime.class);
|
||||
Set<IrisRegion> mappedRegions = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
mappedRegions.add(declared);
|
||||
mappedRegions.add(mapped);
|
||||
mappedRegions.add(excluded);
|
||||
when(imageMaps.getMappedRegions()).thenReturn(mappedRegions);
|
||||
KList<IrisRegion> regions = DimensionTerrainContext.terrainRegions(List.of(declared), imageMaps);
|
||||
|
||||
assertEquals(2, regions.size());
|
||||
assertSame(declared, regions.get(0));
|
||||
assertTrue(regions.stream().anyMatch(region -> region == mapped));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceLedgeSlopeUsesNeighboringSourceSpansRatherThanTheHighestCap() {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getHeight()).thenReturn(128);
|
||||
when(dimension.getLoadKey()).thenReturn("root");
|
||||
when(complex.hasTerrain3D()).thenReturn(true);
|
||||
when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 40D));
|
||||
when(complex.terrainColumn(-17, 8)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 5, 20, 40));
|
||||
when(complex.terrainColumn(-14, 8)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 8, 20, 60));
|
||||
when(complex.terrainColumn(-17, 11)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 9, 20, 80));
|
||||
DimensionTerrainContext context = DimensionTerrainContext.forStack(engine, dimension);
|
||||
|
||||
assertEquals(5D, context.getSurfaceSlopeStream(5).getDouble(-17, 8), 0D);
|
||||
assertEquals(40D, context.getSurfaceSlopeStream(40).getDouble(-17, 8), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void selfSamplerUsesNaturalValuesOnlyForNonblockingColumns() {
|
||||
@@ -70,4 +120,26 @@ public class DimensionTerrainContextFallbackTest {
|
||||
assertSame(resolvedBiomeValue, context.getBiome(12D, -8D));
|
||||
assertSame(resolvedFluid, context.getFluidBlock(12D, -8D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void selfStackReusesResolvedColumnsAndNaturalFallbackWithoutShapingAgain() {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getHeight()).thenReturn(128);
|
||||
when(dimension.getLoadKey()).thenReturn("root");
|
||||
Terrain3DColumn natural = Terrain3DColumnFixtures.spans(30, 0, 15, 25, 40);
|
||||
Terrain3DColumn resolved = Terrain3DColumnFixtures.spans(30, 0, 12, 28, 35);
|
||||
when(complex.naturalTerrainColumn(-17, 8)).thenReturn(natural);
|
||||
when(complex.terrainColumn(-17, 8)).thenReturn(resolved);
|
||||
DimensionTerrainContext context = DimensionTerrainContext.forStack(engine, dimension);
|
||||
|
||||
assertSame(resolved, context.terrainColumn(-17, 8));
|
||||
when(engine.answersFromNaturalTerrain(-17, 8)).thenReturn(true);
|
||||
assertSame(natural, context.terrainColumn(-17, 8));
|
||||
when(engine.answersFromNaturalTerrain(-17, 8)).thenReturn(false);
|
||||
assertSame(resolved, context.terrainColumn(-17, 8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumnFixtures;
|
||||
import org.junit.Test;
|
||||
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -16,6 +20,43 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class UpperDimensionTransitionTest {
|
||||
@Test
|
||||
public void mirroredDensityPreservesEverySolidSpanAndFace() {
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(80, 0, 30, 40, 60, 100, 110);
|
||||
UpperDimensionContext.Column column = new UpperDimensionContext.Column(128, 17, terrain);
|
||||
assertTrue(column.isSolid(17));
|
||||
assertTrue(column.isSolid(27));
|
||||
assertFalse(column.isSolid(28));
|
||||
assertFalse(column.isSolid(66));
|
||||
assertTrue(column.isSolid(67));
|
||||
assertEquals(67, column.faceY(70));
|
||||
assertEquals(97, column.faceY(110));
|
||||
assertFalse(column.isSolid(128));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upperGapClipsToTheNextRealMirroredFace() {
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
when(dimension.getLoadKey()).thenReturn("main");
|
||||
when(dimension.getUpperDimensionGap()).thenReturn(28);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(80, 0, 30, 40, 60, 100, 110);
|
||||
when(complex.naturalTerrainColumn(-17, 8)).thenReturn(terrain);
|
||||
when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 110D));
|
||||
when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D));
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getHeight()).thenReturn(128);
|
||||
UpperDimensionContext context = UpperDimensionContext.create(engine, dimension);
|
||||
|
||||
assertEquals(97, context.getEffectiveSurfaceY(-17, 8));
|
||||
UpperDimensionContext.Column column = context.sampleColumn(-17, 8);
|
||||
assertFalse(column.isSolid(92));
|
||||
assertTrue(column.isSolid(97));
|
||||
assertEquals(97, column.faceY(110));
|
||||
verify(complex, never()).terrainColumn(anyInt(), anyInt());
|
||||
}
|
||||
@Test
|
||||
public void currentCeilingInputRespectsCurrentLowerTerrainGap() {
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package art.arcane.iris.engine.decorator;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.object.IrisDecorator;
|
||||
import art.arcane.iris.engine.object.IrisSlopeClip;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisSurfaceDecoratorSlopeTest {
|
||||
@Test
|
||||
public void lowerLedgeUsesItsOwnSlopeForVegetation() {
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
IrisSurfaceDecorator surface = mock(IrisSurfaceDecorator.class, CALLS_REAL_METHODS);
|
||||
doReturn(complex).when(surface).getComplex();
|
||||
IrisDecorator vegetation = new IrisDecorator().setSlopeCondition(new IrisSlopeClip(0D, 2.6D));
|
||||
when(complex.hasTerrain3D()).thenReturn(true);
|
||||
when(complex.terrainSurfaceSlope(12, 32, -8)).thenReturn(0D);
|
||||
when(complex.terrainSurfaceSlope(12, 96, -8)).thenReturn(5D);
|
||||
|
||||
assertTrue(surface.isSlopeValid(vegetation, 12, 32, -8));
|
||||
assertFalse(surface.isSlopeValid(vegetation, 12, 96, -8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disabledTerrainKeepsItsExistingSlopeGate() {
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
IrisSurfaceDecorator surface = mock(IrisSurfaceDecorator.class, CALLS_REAL_METHODS);
|
||||
doReturn(complex).when(surface).getComplex();
|
||||
IrisDecorator vegetation = new IrisDecorator().setSlopeCondition(new IrisSlopeClip(0D, 2.6D));
|
||||
when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 5D));
|
||||
|
||||
assertFalse(surface.isSlopeValid(vegetation, 12, 32, -8));
|
||||
vegetation.setForcePlace(true);
|
||||
assertTrue(surface.isSlopeValid(vegetation, 12, 32, -8));
|
||||
}
|
||||
}
|
||||
+6
@@ -1255,6 +1255,10 @@ public class HydrologyFootprintCompilerTest {
|
||||
).compile(courses);
|
||||
|
||||
for (HydrologyColumnSample sample : full.columns().values()) {
|
||||
assertEquals(sample.primarySurfaceLayer().isPresent(),
|
||||
validation.plannedSurface().ownsTerrain(sample.x(), sample.z()));
|
||||
assertEquals(sample.primarySurfaceLayer().isPresent(),
|
||||
validation.withMaterializedSurface(full).plannedSurface().ownsTerrain(sample.x(), sample.z()));
|
||||
assertEquals(
|
||||
"planned surface at " + sample.x() + "," + sample.z(),
|
||||
sample.terrainHeight(),
|
||||
@@ -1294,6 +1298,8 @@ public class HydrologyFootprintCompilerTest {
|
||||
}
|
||||
assertEquals(91, validation.plannedSurface().resolve(80, 80, 91));
|
||||
assertEquals(92, validation.plannedSurface().resolve(80, 80, 92));
|
||||
assertFalse(validation.plannedSurface().ownsTerrain(80, 80));
|
||||
assertFalse(validation.withMaterializedSurface(full).plannedSurface().ownsTerrain(80, 80));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+12
-4
@@ -146,10 +146,18 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
|
||||
}
|
||||
|
||||
private void assertMaterializedPreconditions(HydrologyTile tile, HydrologyTerrainSampler terrain) {
|
||||
HydrologyCaveVoxelViewFactory.PlannedSurface surface = (int x, int z, int naturalHeight) ->
|
||||
tile.footprint().sample(x, z)
|
||||
.map(HydrologyColumnSample::terrainHeight)
|
||||
.orElse(naturalHeight);
|
||||
HydrologyCaveVoxelViewFactory.PlannedSurface surface = new HydrologyCaveVoxelViewFactory.PlannedSurface() {
|
||||
@Override
|
||||
public int resolve(int x, int z, int naturalHeight) {
|
||||
return tile.footprint().sample(x, z).map(HydrologyColumnSample::terrainHeight).orElse(naturalHeight);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ownsTerrain(int x, int z) {
|
||||
HydrologyColumnSample sample = tile.footprint().sample(x, z).orElse(null);
|
||||
return sample != null && sample.primarySurfaceLayer().isPresent();
|
||||
}
|
||||
};
|
||||
CaveVoxelView view = new PlannedSurfaceCaveView(terrain, surface);
|
||||
for (HydrologyCavePlan plan : tile.cavePlans()) {
|
||||
for (Map.Entry<CavePosition, CaveVoxelPrecondition> entry
|
||||
|
||||
+42
-7
@@ -788,9 +788,9 @@ public class HydrologyCaveContainmentPlannerTest {
|
||||
);
|
||||
HydrologyCaveContainmentPlanner.ValidationCache cache =
|
||||
new HydrologyCaveContainmentPlanner.ValidationCache();
|
||||
HydrologyObservedPlannedSurface initialSurface = observedSurface(32);
|
||||
HydrologyObservedPlannedSurface matchingSurface = observedSurface(32);
|
||||
HydrologyObservedPlannedSurface changedSurface = observedSurface(9);
|
||||
HydrologyObservedPlannedSurface initialSurface = observedSurface(32, true);
|
||||
HydrologyObservedPlannedSurface matchingSurface = observedSurface(32, true);
|
||||
HydrologyObservedPlannedSurface changedSurface = observedSurface(9, true);
|
||||
|
||||
HydrologyCavePlan initial = planner.validateAll(
|
||||
plannedSurfaceView(initialSurface),
|
||||
@@ -822,6 +822,31 @@ public class HydrologyCaveContainmentPlannerTest {
|
||||
assertEquals(2L, cache.misses());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plannedSurfaceCacheInvalidatesEqualHeightWhenTerrainOwnershipChanges() {
|
||||
CavePosition center = position(0, 10, 0);
|
||||
HydrologyCaveCandidate candidate = candidate(
|
||||
204L, 10, "water", Map.of(center, HydrologyCaveAction.WET_SOURCE), settings());
|
||||
HydrologyCaveContainmentPlanner.ValidationCache cache =
|
||||
new HydrologyCaveContainmentPlanner.ValidationCache();
|
||||
HydrologyObservedPlannedSurface initialSurface = observedSurface(32, true);
|
||||
HydrologyObservedPlannedSurface matchingSurface = observedSurface(32, true);
|
||||
HydrologyObservedPlannedSurface changedSurface = observedSurface(32, false);
|
||||
|
||||
HydrologyCavePlan initial = planner.validateAll(plannedSurfaceView(initialSurface),
|
||||
List.of(candidate), cache, initialSurface).plans().getFirst();
|
||||
HydrologyCavePlan cached = planner.validateAll(plannedSurfaceView(matchingSurface),
|
||||
List.of(candidate), cache, matchingSurface).plans().getFirst();
|
||||
HydrologyCavePlan changed = planner.validateAll(plannedSurfaceView(changedSurface),
|
||||
List.of(candidate), cache, changedSurface).plans().getFirst();
|
||||
|
||||
assertTrue(initial.accepted());
|
||||
assertEquals(initial, cached);
|
||||
assertEquals(HydrologyCaveRejection.OPEN_SURFACE, changed.rejection());
|
||||
assertEquals(1L, cache.hits());
|
||||
assertEquals(2L, cache.misses());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validationCacheEvictsItsPreviousWorkingSetWithoutResettingStatistics() {
|
||||
CavePosition center = position(0, 10, 0);
|
||||
@@ -1228,9 +1253,18 @@ public class HydrologyCaveContainmentPlannerTest {
|
||||
return new CavePosition(x, y, z);
|
||||
}
|
||||
|
||||
private HydrologyObservedPlannedSurface observedSurface(int resolvedHeight) {
|
||||
HydrologyCaveVoxelViewFactory.PlannedSurface surface =
|
||||
(int x, int z, int naturalHeight) -> resolvedHeight;
|
||||
private HydrologyObservedPlannedSurface observedSurface(int resolvedHeight, boolean terrainOwned) {
|
||||
HydrologyCaveVoxelViewFactory.PlannedSurface surface = new HydrologyCaveVoxelViewFactory.PlannedSurface() {
|
||||
@Override
|
||||
public int resolve(int x, int z, int naturalHeight) {
|
||||
return resolvedHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ownsTerrain(int x, int z) {
|
||||
return terrainOwned;
|
||||
}
|
||||
};
|
||||
return new HydrologyObservedPlannedSurface(surface);
|
||||
}
|
||||
|
||||
@@ -1253,7 +1287,8 @@ public class HydrologyCaveContainmentPlannerTest {
|
||||
|
||||
@Override
|
||||
public boolean isAboveTerrainSurface(CavePosition position) {
|
||||
return position.y() > surface.resolve(position.x(), position.z(), 32);
|
||||
return !surface.ownsTerrain(position.x(), position.z())
|
||||
|| position.y() > surface.resolve(position.x(), position.z(), 32);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ public class MantleWriterOverlayTest {
|
||||
IrisPlatforms.bind(platform);
|
||||
|
||||
engineMantle = mock(EngineMantle.class);
|
||||
when(engineMantle.getComplex()).thenReturn(mock(IrisComplex.class));
|
||||
Engine engine = mock(Engine.class);
|
||||
dimension = mock(IrisDimension.class);
|
||||
mantle = mock(Mantle.class);
|
||||
|
||||
@@ -2,6 +2,7 @@ package art.arcane.iris.engine.mantle;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
@@ -63,6 +64,7 @@ public class MantleWriterPreObjectJournalTest {
|
||||
IrisMatterSupport.ensureRegistered();
|
||||
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
when(engineMantle.getComplex()).thenReturn(mock(IrisComplex.class));
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
matter = new IrisMatter(16, 16, 16);
|
||||
|
||||
+112
-4
@@ -1,8 +1,17 @@
|
||||
package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.engine.hydrology.RiverFootprint;
|
||||
import art.arcane.iris.engine.hydrology.cave.CavePosition;
|
||||
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.mantle.MantleComponent;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.mantle.flag.ReservedFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
@@ -12,15 +21,19 @@ import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -65,8 +78,9 @@ public class MantleHydrologyCaveVoxelViewTest {
|
||||
MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(
|
||||
mantle,
|
||||
128,
|
||||
(x, z) -> 60,
|
||||
(chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ)
|
||||
new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> new MantleHydrologyCaveVoxelView.TerrainColumn(60, false),
|
||||
(x, y, z) -> y <= 60,
|
||||
(chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ))
|
||||
);
|
||||
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 20, 0)));
|
||||
@@ -100,12 +114,106 @@ public class MantleHydrologyCaveVoxelViewTest {
|
||||
MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(
|
||||
mantle,
|
||||
128,
|
||||
(x, z) -> 60,
|
||||
(chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ)
|
||||
new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> new MantleHydrologyCaveVoxelView.TerrainColumn(60, false),
|
||||
(x, y, z) -> y <= 60,
|
||||
(chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ))
|
||||
);
|
||||
|
||||
assertTrue(view.isOpenToSurface(new CavePosition(4, 20, 4)));
|
||||
assertFalse(view.isOpenToSurface(new CavePosition(5, 20, 4)));
|
||||
assertEquals(List.of("0,0"), loaded);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void terrainUndercutsRemainOpenWithoutCaveMatter() {
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt());
|
||||
MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(mantle, 128,
|
||||
new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> new MantleHydrologyCaveVoxelView.TerrainColumn(80, false),
|
||||
(x, y, z) -> y <= 40 || y >= 65 && y <= 80,
|
||||
(chunkX, chunkZ) -> { }));
|
||||
|
||||
assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 40, 0)));
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 41, 0)));
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 64, 0)));
|
||||
assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 65, 0)));
|
||||
assertFalse(view.isOpenToSurface(new CavePosition(0, 50, 0)));
|
||||
assertTrue(view.isOpenToSurface(new CavePosition(0, 81, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void footprintOwnedBedsFillNaturalGapsEvenWhenTheirHeightIsUnchanged() {
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
Engine engine = configuredEngine(mantle);
|
||||
IrisComplex complex = undercutTerrain();
|
||||
HydrologyColumnSample sample = mock(HydrologyColumnSample.class);
|
||||
when(sample.x()).thenReturn(0);
|
||||
when(sample.z()).thenReturn(0);
|
||||
when(sample.terrainHeight()).thenReturn(80);
|
||||
when(sample.primarySurfaceLayer()).thenReturn(Optional.of(mock(HydrologyColumnLayer.class)));
|
||||
RiverFootprint footprint = new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), sample));
|
||||
MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(engine, complex, footprint);
|
||||
|
||||
assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 50, 0)));
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(1, 50, 0)));
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 81, 0)));
|
||||
assertFalse(view.isOpenToSurface(new CavePosition(0, 50, 0)));
|
||||
verify(complex, never()).isNaturalTerrainSolid(0, 50, 0);
|
||||
verify(complex, never()).sampleHydrologyColumn(anyDouble(), anyDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void plannedOwnershipPreservesOwnedBedsAndUnownedUndercutsWithoutHydrologyRecursion() {
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
Engine engine = configuredEngine(mantle);
|
||||
IrisComplex complex = undercutTerrain();
|
||||
HydrologyCaveVoxelViewFactory.PlannedSurface planned = new HydrologyCaveVoxelViewFactory.PlannedSurface() {
|
||||
@Override
|
||||
public int resolve(int x, int z, int naturalHeight) {
|
||||
return x == 0 ? 55 : naturalHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ownsTerrain(int x, int z) {
|
||||
return x == 0;
|
||||
}
|
||||
};
|
||||
doReturn(new MatterCavern(true, "", (byte) 0)).when(mantle).get(0, 20, 0, MatterCavern.class);
|
||||
MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(engine, complex, planned);
|
||||
|
||||
assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 50, 0)));
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(1, 50, 0)));
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 20, 0)));
|
||||
assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 55, 0)));
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 56, 0)));
|
||||
assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 80, 0)));
|
||||
verify(complex, never()).sampleHydrologyColumn(anyDouble(), anyDouble());
|
||||
}
|
||||
|
||||
private static Engine configuredEngine(Mantle<Matter> mantle) {
|
||||
doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt());
|
||||
when(mantle.getWorldHeight()).thenReturn(128);
|
||||
when(mantle.hasFlag(anyInt(), anyInt(), eq(ReservedFlag.CARVED))).thenReturn(true);
|
||||
Engine engine = mock(Engine.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
when(engine.getMantle()).thenReturn(engineMantle);
|
||||
when(engineMantle.getMantle()).thenReturn(mantle);
|
||||
MantleComponent carving = mock(MantleComponent.class);
|
||||
when(carving.isEnabled()).thenReturn(true);
|
||||
when(engineMantle.getRegisteredComponents()).thenReturn(Map.of(ReservedFlag.CARVED, carving));
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static IrisComplex undercutTerrain() {
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 80D));
|
||||
when(complex.isNaturalTerrainSolid(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int y = invocation.getArgument(1);
|
||||
return y <= 40 || y >= 65 && y <= 80;
|
||||
});
|
||||
return complex;
|
||||
}
|
||||
}
|
||||
|
||||
+155
@@ -1,18 +1,35 @@
|
||||
package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.BoundaryColumnGeometry;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.mantle.MantleWriter;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumnFixtures;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.testsupport.BukkitTestServer;
|
||||
import art.arcane.iris.util.project.matter.IrisMatterSupport;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.IrisMatter;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import org.junit.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
@@ -22,6 +39,7 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
@@ -106,6 +124,90 @@ public class ObjectDestinationTransactionTest {
|
||||
assertArrayEquals(new byte[]{0, 0}, transaction.getCarvedColumn(0, 0, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void densityOpeningsReachTransactionsBeforeMantleCarving() {
|
||||
try (TerrainFixture fixture = new TerrainFixture()) {
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0);
|
||||
|
||||
assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0},
|
||||
transaction.getCarvedColumn(0, 0, 16));
|
||||
assertFalse(transaction.isCarved(0, 4, 0));
|
||||
assertTrue(transaction.isCarved(0, 5, 0));
|
||||
assertTrue(transaction.isCarved(0, 9, 0));
|
||||
assertFalse(transaction.isCarved(0, 10, 0));
|
||||
assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 1}, transaction.getCarvedColumn(0, 0, 6));
|
||||
assertArrayEquals(new byte[0], transaction.getCarvedColumn(0, 0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void densityMaskPreservesPrerequisiteJournalAndHydrologyPrecedence() {
|
||||
try (TerrainFixture fixture = new TerrainFixture()) {
|
||||
MatterCavern cavern = new MatterCavern(true, "", (byte) 0);
|
||||
fixture.matter.<MatterCavern>slice(MatterCavern.class).set(0, 3, 0, cavern);
|
||||
fixture.writer.withComponentPriority(2, () -> {
|
||||
fixture.writer.clearData(0, 3, 0, MatterCavern.class);
|
||||
fixture.writer.setData(0, 4, 0, cavern);
|
||||
});
|
||||
fixture.matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class)
|
||||
.set(0, 6, 0, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD));
|
||||
fixture.matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class)
|
||||
.set(0, 12, 0, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR));
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0);
|
||||
|
||||
assertArrayEquals(new byte[]{0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0},
|
||||
transaction.getCarvedColumn(0, 0, 16));
|
||||
assertTrue(transaction.isCarved(0, 3, 0));
|
||||
assertFalse(transaction.isCarved(0, 4, 0));
|
||||
assertFalse(transaction.isCarved(0, 6, 0));
|
||||
assertTrue(transaction.isCarved(0, 12, 0));
|
||||
|
||||
transaction.setData(0, 5, 0, mock(PlatformBlockState.class));
|
||||
|
||||
assertFalse(transaction.isCarved(0, 5, 0));
|
||||
assertEquals(0, transaction.getCarvedColumn(0, 0, 16)[5]);
|
||||
assertTrue(fixture.writer.isPrerequisiteCarved(0, 5, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void additionalTerrainOwnershipExcludesRootDensityOpenings() {
|
||||
try (TerrainFixture fixture = new TerrainFixture()) {
|
||||
when(fixture.engine.isAdditionalTerrainOwned(0, 6, 0)).thenReturn(true);
|
||||
when(fixture.complex.isTerrain3DOpening(0, 6, 0)).thenReturn(false);
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0);
|
||||
|
||||
assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0},
|
||||
transaction.getCarvedColumn(0, 0, 16));
|
||||
assertFalse(transaction.isCarved(0, 6, 0));
|
||||
assertTrue(transaction.isCarved(0, 7, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void historicalGeometryOwnsTransactionalCarvingOverCurrentDensity() {
|
||||
try (TerrainFixture fixture = new TerrainFixture()) {
|
||||
BoundaryColumnGeometry.Voxel solid = new BoundaryColumnGeometry.Voxel(
|
||||
"minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false);
|
||||
BoundaryColumnGeometry.Voxel air = new BoundaryColumnGeometry.Voxel(
|
||||
"minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false);
|
||||
List<BoundaryColumnGeometry.Voxel> voxels = new ArrayList<>(Collections.nCopies(16, solid));
|
||||
voxels.set(2, air);
|
||||
TerrainBoundarySignature historical = mock(TerrainBoundarySignature.class);
|
||||
when(historical.geometry()).thenReturn(BoundaryColumnGeometry.fromVoxels(-64, voxels));
|
||||
when(fixture.engine.getMinHeight()).thenReturn(-64);
|
||||
when(fixture.complex.resolvedTerrainColumn(0, 0)).thenReturn(Optional.of(historical));
|
||||
fixture.matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class)
|
||||
.set(0, 2, 0, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD));
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0);
|
||||
|
||||
assertArrayEquals(new byte[]{0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
|
||||
transaction.getCarvedColumn(0, 0, 16));
|
||||
assertTrue(transaction.isCarved(0, 2, 0));
|
||||
assertFalse(transaction.isCarved(0, 5, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCommitRestoresEveryTouchedDestinationCell() {
|
||||
MantleWriter writer = writer();
|
||||
@@ -239,6 +341,59 @@ public class ObjectDestinationTransactionTest {
|
||||
return writer;
|
||||
}
|
||||
|
||||
private static final class TerrainFixture implements AutoCloseable {
|
||||
private final IrisPlatform previousPlatform;
|
||||
private final IrisComplex complex = mock(IrisComplex.class);
|
||||
private final Engine engine = mock(Engine.class);
|
||||
private final Matter matter;
|
||||
private final MantleWriter writer;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private TerrainFixture() {
|
||||
BukkitTestServer.install();
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
PlatformBlockState air = mock(PlatformBlockState.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(air.isAir()).thenReturn(true);
|
||||
when(registries.air()).thenReturn(air);
|
||||
when(registries.block(anyString())).thenReturn(air);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
IrisMatterSupport.ensureRegistered();
|
||||
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
matter = new IrisMatter(16, 16, 16);
|
||||
when(engineMantle.getEngine()).thenReturn(engine);
|
||||
when(engineMantle.getComplex()).thenReturn(complex);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getDimension()).thenReturn(mock(IrisDimension.class));
|
||||
when(mantle.getWorldHeight()).thenReturn(16);
|
||||
when(mantle.getChunk(0, 0)).thenReturn(chunk);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
when(chunk.exists(0)).thenReturn(true);
|
||||
when(chunk.getOrCreate(0)).thenReturn(matter);
|
||||
when(chunk.get(0)).thenReturn(matter);
|
||||
when(complex.terrainColumn(0, 0)).thenReturn(Terrain3DColumnFixtures.spans(8, 0, 4, 10, 11));
|
||||
for (int y = 5; y <= 9; y++) {
|
||||
when(complex.isTerrain3DOpening(0, y, 0)).thenReturn(true);
|
||||
}
|
||||
writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
writer.close();
|
||||
IrisPlatforms.unbind();
|
||||
if (previousPlatform != null) {
|
||||
IrisPlatforms.bind(previousPlatform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record Marker(String value) {
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.engine.modifier;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.volmlib.util.math.BlockPosition;
|
||||
@@ -127,6 +128,7 @@ public class IrisCarveModifierBiomeBlendTest {
|
||||
private static IrisCarveModifier modifier(Engine engine, RNG rng) throws ReflectiveOperationException {
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(mock(IrisComplex.class)).when(modifier).getComplex();
|
||||
Field field = IrisCarveModifier.class.getDeclaredField("rng");
|
||||
field.setAccessible(true);
|
||||
field.set(modifier, rng);
|
||||
|
||||
+22
@@ -3,12 +3,14 @@ package art.arcane.iris.engine.modifier;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
@@ -48,6 +50,7 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(mock(IrisComplex.class)).when(modifier).getComplex();
|
||||
Field rng = IrisCarveModifier.class.getDeclaredField("rng");
|
||||
rng.setAccessible(true);
|
||||
rng.set(modifier, new RNG(71L));
|
||||
@@ -81,6 +84,25 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
assertSame(resolvedCeiling, ceiling);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void terrainOpeningKeepsItsSurfaceBiomeOverCaveMatter() {
|
||||
IrisBiome surface = new IrisBiome();
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
ProceduralStream<IrisBiome> biomes = mock(ProceduralStream.class);
|
||||
doReturn(true).when(complex).isTerrain3DOpening(40, 42, 44);
|
||||
doReturn(biomes).when(complex).getTrueBiomeStream();
|
||||
doReturn(surface).when(biomes).get(40, 44);
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(complex).when(modifier).getComplex();
|
||||
|
||||
IrisBiome biome = modifier.resolveCaveBoundaryBiome(
|
||||
new MatterCavern(true, "carving/deep", (byte) 0), 40, 42, 44,
|
||||
new IrisDimensionCarvingResolver.State(), new Long2ObjectOpenHashMap<>(), new HashMap<>());
|
||||
|
||||
assertSame(surface, biome);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void gravityFloorLayerRequiresSolidSupportBelowItsTarget() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.engine.modifier;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.actuator.IrisDecorantActuator;
|
||||
import art.arcane.iris.engine.decorator.IrisCeilingDecorator;
|
||||
import art.arcane.iris.engine.decorator.IrisSurfaceDecorator;
|
||||
@@ -176,6 +177,7 @@ public class IrisNaturalCaveScanTest {
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
chunk = mock(MantleChunk.class);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(mock(IrisComplex.class)).when(modifier).getComplex();
|
||||
doReturn(32).when(engine).getHeight();
|
||||
doReturn(new SeedManager(1337L)).when(engine).getSeedManager();
|
||||
doReturn(engineMantle).when(engine).getMantle();
|
||||
|
||||
@@ -28,6 +28,45 @@ public final class IrisBiomeLayerSlopeSourceTest {
|
||||
assertExplicitSlopeStreamControlsSurfacePaletteSelection(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowerLedgeSlopeControlsSurfacePaletteSelection() {
|
||||
assertLowerLedgeSlopeControlsSurfacePaletteSelection(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowerLedgeSlopeControlsLockedSurfacePaletteSelection() {
|
||||
assertLowerLedgeSlopeControlsSurfacePaletteSelection(true);
|
||||
}
|
||||
|
||||
private static void assertLowerLedgeSlopeControlsSurfacePaletteSelection(boolean lockLayers) {
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
IrisBiomePaletteLayer grass = mock(IrisBiomePaletteLayer.class);
|
||||
CNG heightGenerator = mock(CNG.class);
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
RNG rng = new RNG(19L);
|
||||
IrisBiome biome = new IrisBiome().setLockLayers(lockLayers).setLayers(new KList<>(grass));
|
||||
when(complex.hasTerrain3D()).thenReturn(true);
|
||||
when(complex.terrainSurfaceSlope(12, 32, -8)).thenReturn(0D);
|
||||
when(complex.terrainSurfaceSlope(12, 96, -8)).thenReturn(5D);
|
||||
when(grass.getZoom()).thenReturn(1D);
|
||||
when(grass.getMinHeight()).thenReturn(1);
|
||||
when(grass.getMaxHeight()).thenReturn(1);
|
||||
when(grass.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 2.6D));
|
||||
when(grass.getHeightGenerator(any(RNG.class), same(data))).thenReturn(heightGenerator);
|
||||
when(heightGenerator.fit(1, 1, 12D, -8D)).thenReturn(1);
|
||||
when(grass.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(block);
|
||||
|
||||
KList<PlatformBlockState> ledge = biome.generateLayers(
|
||||
new IrisDimension(), 12D, -8D, rng, 1, 32, data, complex);
|
||||
KList<PlatformBlockState> cap = biome.generateLayers(
|
||||
new IrisDimension(), 12D, -8D, rng, 1, 96, data, complex);
|
||||
|
||||
assertEquals(1, ledge.size());
|
||||
assertSame(block, ledge.get(0));
|
||||
assertTrue(cap.isEmpty());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void assertExplicitSlopeStreamControlsSurfacePaletteSelection(boolean lockLayers) {
|
||||
IrisData data = mock(IrisData.class);
|
||||
|
||||
+20
@@ -1,12 +1,32 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.engine.mantle.components.IslandObjectPlacer;
|
||||
import art.arcane.iris.engine.mantle.MantleWriter;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IslandObjectPlacerAnchorFaceTest {
|
||||
|
||||
@Test
|
||||
public void islandMaskOwnsSurfaceSupportAboveRootTerrainAndInsideRootOpenings() {
|
||||
FloatingIslandSample sample = sampleWithBottomAt(100, 0);
|
||||
MantleWriter writer = mock(MantleWriter.class);
|
||||
when(writer.isSurfaceSolid(0, 109, 0)).thenReturn(false);
|
||||
when(writer.isCarved(0, 109, 0)).thenReturn(true);
|
||||
IslandObjectPlacer placer = IslandObjectPlacer.top(writer, (x, z) -> sample, null, 109);
|
||||
|
||||
assertTrue(placer.isSurfaceSolid(0, 109, 0));
|
||||
assertFalse(placer.isCarved(0, 109, 0));
|
||||
assertFalse(placer.isSurfaceSolid(0, 108, 0));
|
||||
assertTrue(placer.isCarved(0, 108, 0));
|
||||
assertFalse(placer.isSurfaceSolid(0, 110, 0));
|
||||
}
|
||||
|
||||
private FloatingIslandSample sampleWithBottomAt(int baseY, int bottomOffset) {
|
||||
boolean[] mask = new boolean[10];
|
||||
mask[bottomOffset] = true;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package art.arcane.iris.engine.terrain;
|
||||
|
||||
public final class Terrain3DColumnFixtures {
|
||||
private Terrain3DColumnFixtures() {
|
||||
}
|
||||
|
||||
public static Terrain3DColumn spans(double baseHeight, int... boundaries) {
|
||||
return new Terrain3DColumn(baseHeight, boundaries[1] + 1, true, boundaries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package art.arcane.iris.engine.terrain;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class Terrain3DFragmentFilterTest {
|
||||
@Test
|
||||
public void removesAnEntire512BlockIslandAcrossChunkBoundaries() {
|
||||
Set<Voxel> blocks = new HashSet<>();
|
||||
for (int x = 12; x <= 19; x++) {
|
||||
for (int z = -20; z <= -13; z++) {
|
||||
for (int y = 24; y <= 31; y++) {
|
||||
blocks.add(new Voxel(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
Fixture fixture = new Fixture(blocks, true);
|
||||
|
||||
for (Voxel block : blocks) {
|
||||
Terrain3DColumn column = fixture.filter.column(block.x(), block.z());
|
||||
assertFalse(column.isSolid(block.y()));
|
||||
assertEquals(8, column.topY());
|
||||
assertEquals(1, column.spanCount());
|
||||
assertTrue(column.isSolid(8));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesThinShelvesConnectedSidewaysAroundACorner() {
|
||||
Set<Voxel> blocks = new HashSet<>();
|
||||
for (int x = 0; x <= 15; x++) {
|
||||
blocks.add(new Voxel(x, 24, 0));
|
||||
}
|
||||
for (int z = 1; z <= 15; z++) {
|
||||
blocks.add(new Voxel(15, 24, z));
|
||||
}
|
||||
for (int y = 9; y < 24; y++) {
|
||||
blocks.add(new Voxel(15, y, 15));
|
||||
}
|
||||
Fixture fixture = new Fixture(blocks, true);
|
||||
|
||||
for (Voxel block : blocks) {
|
||||
assertTrue(fixture.filter.column(block.x(), block.z()).isSolid(block.y()));
|
||||
}
|
||||
assertFalse(fixture.filter.column(0, 0).isSolid(23));
|
||||
assertEquals(24, fixture.filter.column(0, 0).surfaceY(24));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aDiagonalContactDoesNotKeepAnOtherwiseDetachedBlock() {
|
||||
Set<Voxel> blocks = new HashSet<>();
|
||||
for (int y = 9; y <= 24; y++) {
|
||||
blocks.add(new Voxel(0, y, 0));
|
||||
}
|
||||
blocks.add(new Voxel(1, 24, 1));
|
||||
Fixture fixture = new Fixture(blocks, true);
|
||||
|
||||
assertTrue(fixture.filter.column(0, 0).isSolid(24));
|
||||
assertFalse(fixture.filter.column(1, 1).isSolid(24));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesAll513BlocksAfterAPartialComponentClassification() {
|
||||
Set<Voxel> blocks = new HashSet<>();
|
||||
for (int x = 0; x < 513; x++) {
|
||||
blocks.add(new Voxel(x, 24, 0));
|
||||
}
|
||||
Fixture fixture = new Fixture(blocks, true);
|
||||
|
||||
assertTrue(fixture.filter.column(0, 0).isSolid(24));
|
||||
assertTrue(fixture.lookups.get() <= 1 + 4 * 512);
|
||||
for (int x = 512; x >= 0; x--) {
|
||||
assertTrue(fixture.filter.column(x, 0).isSolid(24));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aLargeVerticalIslandNeedsNoNeighbourSampling() {
|
||||
Set<Voxel> blocks = new HashSet<>();
|
||||
for (int y = 24; y < 537; y++) {
|
||||
blocks.add(new Voxel(0, y, 0));
|
||||
}
|
||||
Fixture fixture = new Fixture(blocks, true);
|
||||
|
||||
assertEquals(536, fixture.filter.column(0, 0).topY());
|
||||
assertEquals(1, fixture.lookups.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completeCacheEvictionAndConcurrentQueriesPreserveDecisions() throws Exception {
|
||||
Set<Voxel> blocks = new HashSet<>();
|
||||
for (int x = -18; x <= -15; x++) {
|
||||
for (int z = 14; z <= 17; z++) {
|
||||
for (int y = 24; y <= 27; y++) {
|
||||
blocks.add(new Voxel(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int x = 14; x <= 526; x++) {
|
||||
blocks.add(new Voxel(x, 24, 16));
|
||||
}
|
||||
Fixture evicted = new Fixture(blocks, false);
|
||||
Fixture cached = new Fixture(blocks, true);
|
||||
int[] coordinates = {-18, -17, -16, -15, 14, 15, 16, 526};
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
|
||||
List<Future<Terrain3DColumn>> results = new ArrayList<>();
|
||||
for (int x : coordinates) {
|
||||
results.add(executor.submit(() -> evicted.filter.column(x, 16)));
|
||||
}
|
||||
for (int index = results.size() - 1; index >= 0; index--) {
|
||||
assertEquals(cached.filter.column(coordinates[index], 16),
|
||||
results.get(index).get(10, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedConcurrentQueriesPublishWholeRemovedAndKeptComponents() throws Exception {
|
||||
Set<Voxel> blocks = new HashSet<>();
|
||||
for (int x = 12; x <= 19; x++) {
|
||||
for (int z = -20; z <= -13; z++) {
|
||||
for (int y = 24; y <= 31; y++) {
|
||||
blocks.add(new Voxel(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int x = 32; x <= 544; x++) {
|
||||
blocks.add(new Voxel(x, 24, 16));
|
||||
}
|
||||
Fixture fixture = new Fixture(blocks, true);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
|
||||
List<Future<Terrain3DColumn>> removed = new ArrayList<>();
|
||||
List<Future<Terrain3DColumn>> kept = new ArrayList<>();
|
||||
for (int index = 0; index < 64; index++) {
|
||||
int removedX = 12 + index % 8;
|
||||
int removedZ = -20 + index / 8;
|
||||
int keptX = 32 + index * 8;
|
||||
removed.add(executor.submit(() -> {
|
||||
assertTrue(start.await(10, TimeUnit.SECONDS));
|
||||
return fixture.filter.column(removedX, removedZ);
|
||||
}));
|
||||
kept.add(executor.submit(() -> {
|
||||
assertTrue(start.await(10, TimeUnit.SECONDS));
|
||||
return fixture.filter.column(keptX, 16);
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for (int index = 63; index >= 0; index--) {
|
||||
assertEquals(8, removed.get(index).get(10, TimeUnit.SECONDS).topY());
|
||||
assertTrue(kept.get(index).get(10, TimeUnit.SECONDS).isSolid(24));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void spanFilteringMatchesAnIndependentVoxelComponentOracle() {
|
||||
Random random = new Random(74921);
|
||||
for (int sample = 0; sample < 18; sample++) {
|
||||
Set<Voxel> blocks = new HashSet<>();
|
||||
double probability = 0.12D + sample % 6 * 0.14D;
|
||||
for (int x = -3; x <= 3; x++) {
|
||||
for (int z = -3; z <= 3; z++) {
|
||||
for (int y = 9; y <= 20; y++) {
|
||||
if (random.nextDouble() < probability) {
|
||||
blocks.add(new Voxel(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<Voxel> retained = retainedBlocks(blocks);
|
||||
Fixture fixture = new Fixture(blocks, true);
|
||||
for (int x = 3; x >= -3; x--) {
|
||||
for (int z = -3; z <= 3; z++) {
|
||||
Terrain3DColumn column = fixture.filter.column(x, z);
|
||||
for (int y = 9; y <= 20; y++) {
|
||||
Voxel block = new Voxel(x, y, z);
|
||||
assertEquals("Sample " + sample + " at " + block,
|
||||
retained.contains(block), column.isSolid(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<Voxel> retainedBlocks(Set<Voxel> blocks) {
|
||||
Set<Voxel> remaining = new HashSet<>(blocks);
|
||||
Set<Voxel> retained = new HashSet<>();
|
||||
while (!remaining.isEmpty()) {
|
||||
Voxel start = remaining.iterator().next();
|
||||
remaining.remove(start);
|
||||
Set<Voxel> component = new HashSet<>();
|
||||
ArrayDeque<Voxel> pending = new ArrayDeque<>();
|
||||
pending.add(start);
|
||||
boolean grounded = false;
|
||||
while (!pending.isEmpty()) {
|
||||
Voxel block = pending.removeFirst();
|
||||
component.add(block);
|
||||
grounded |= block.y() == 9;
|
||||
for (Voxel neighbour : List.of(new Voxel(block.x() - 1, block.y(), block.z()),
|
||||
new Voxel(block.x() + 1, block.y(), block.z()),
|
||||
new Voxel(block.x(), block.y() - 1, block.z()),
|
||||
new Voxel(block.x(), block.y() + 1, block.z()),
|
||||
new Voxel(block.x(), block.y(), block.z() - 1),
|
||||
new Voxel(block.x(), block.y(), block.z() + 1))) {
|
||||
if (remaining.remove(neighbour)) {
|
||||
pending.addLast(neighbour);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grounded || component.size() > 512) {
|
||||
retained.addAll(component);
|
||||
}
|
||||
}
|
||||
return retained;
|
||||
}
|
||||
|
||||
private record Voxel(int x, int y, int z) {
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final Set<Voxel> blocks;
|
||||
private final boolean cache;
|
||||
private final int maximumY;
|
||||
private final Map<Long, Terrain3DFragmentFilter.DensityColumn> columns = new ConcurrentHashMap<>();
|
||||
private final AtomicInteger lookups = new AtomicInteger();
|
||||
private final Terrain3DFragmentFilter filter = new Terrain3DFragmentFilter(this::column);
|
||||
|
||||
private Fixture(Set<Voxel> blocks, boolean cache) {
|
||||
this.blocks = Set.copyOf(blocks);
|
||||
this.cache = cache;
|
||||
int highest = 9;
|
||||
for (Voxel block : blocks) {
|
||||
highest = Math.max(highest, block.y());
|
||||
}
|
||||
maximumY = highest + 1;
|
||||
}
|
||||
|
||||
private Terrain3DFragmentFilter.DensityColumn column(int x, int z) {
|
||||
lookups.incrementAndGet();
|
||||
long key = (long) x << 32 ^ z & 0xffffffffL;
|
||||
return cache ? columns.computeIfAbsent(key, ignored -> createColumn(x, z)) : createColumn(x, z);
|
||||
}
|
||||
|
||||
private Terrain3DFragmentFilter.DensityColumn createColumn(int x, int z) {
|
||||
int[] boundaries = new int[maximumY * 2];
|
||||
boundaries[0] = 0;
|
||||
int count = 1;
|
||||
boolean previousSolid = true;
|
||||
for (int y = 9; y <= maximumY; y++) {
|
||||
boolean solid = blocks.contains(new Voxel(x, y, z));
|
||||
if (solid != previousSolid) {
|
||||
boundaries[count++] = solid ? y : y - 1;
|
||||
previousSolid = solid;
|
||||
}
|
||||
}
|
||||
return new Terrain3DFragmentFilter.DensityColumn(
|
||||
new Terrain3DColumn(8, 9, true, Arrays.copyOf(boundaries, count)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package art.arcane.iris.engine.terrain;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisTerrain3D;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class Terrain3DRuntimeTest {
|
||||
@Test
|
||||
public void omittedProfilesPreserveExactHeightsWithoutCreatingNoise() {
|
||||
AtomicInteger compilations = new AtomicInteger();
|
||||
IrisBiome biome = new IrisBiome();
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 100.25D + x * 0.125D, (x, z) -> biome),
|
||||
options(true),
|
||||
(style, seed) -> {
|
||||
compilations.incrementAndGet();
|
||||
return (x, y, z) -> 1D;
|
||||
});
|
||||
|
||||
for (int x = -20; x < 20; x++) {
|
||||
Terrain3DColumn column = runtime.column(x, 3);
|
||||
assertEquals(100.25D + x * 0.125D, column.baseHeight(), 0D);
|
||||
assertEquals(Math.round(column.baseHeight()), column.topY());
|
||||
assertEquals(1, column.spanCount());
|
||||
assertFalse(column.shaped());
|
||||
}
|
||||
assertEquals(0, compilations.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inactiveRuntimeDoesNotResolveBiomesOrNoise() {
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 91.5D, (x, z) -> {
|
||||
throw new AssertionError("Unexpected biome resolution");
|
||||
}), options(false), (style, seed) -> {
|
||||
throw new AssertionError("Unexpected noise compilation");
|
||||
});
|
||||
|
||||
assertFalse(runtime.active());
|
||||
assertEquals(92, runtime.column(0, 0).topY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unchangedColumnsRetainTheOriginalHeightOutsideVoxelBounds() {
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 300.25D, (x, z) -> null),
|
||||
options(false), (style, seed) -> (x, y, z) -> 0D);
|
||||
|
||||
assertEquals(300.25D, runtime.column(0, 0).baseHeight(), 0D);
|
||||
assertEquals(255, runtime.column(0, 0).topY());
|
||||
assertFalse(runtime.column(0, 0).isSolid(256));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void densityAddsRockAndLeavesAirBelowProjectingLedges() {
|
||||
Terrain3DRuntime runtime = runtime(profile(), (x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D));
|
||||
Terrain3DColumn column = runtime.column(0, 0);
|
||||
|
||||
assertTrue(column.shaped());
|
||||
assertTrue(column.topY() > Math.round(column.baseHeight()));
|
||||
assertTrue(column.spanCount() > 1);
|
||||
assertTrue(column.isSolid(104));
|
||||
assertFalse(column.isSolid(84));
|
||||
assertTrue(column.isSolid(60));
|
||||
assertEquals(-1, column.surfaceY(84));
|
||||
for (int span = 0; span < column.spanCount(); span++) {
|
||||
assertEquals(column.floor(span), column.surfaceY(column.ceiling(span)));
|
||||
assertFalse(column.isSolid(column.floor(span) + 1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isolatedDensityPeakIsRemovedFromEveryColumnQuery() {
|
||||
Terrain3DRuntime runtime = runtime(profile(),
|
||||
(x, y, z) -> x == 16D && y == 112D && z == -16D ? 1D : -1D);
|
||||
|
||||
for (int x = 14; x <= 18; x++) {
|
||||
for (int z = -18; z <= -14; z++) {
|
||||
Terrain3DColumn column = runtime.column(x, z);
|
||||
assertEquals(64, column.topY());
|
||||
assertEquals(1, column.spanCount());
|
||||
assertFalse(column.isSolid(112));
|
||||
assertEquals(-1, column.surfaceY(112));
|
||||
assertEquals(64, column.highestSolidY(112));
|
||||
assertEquals(64, column.nearestSurfaceY(112));
|
||||
assertTrue(column.isSolid(64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void latticeMatchesTheSameFieldAcrossPositiveAndNegativeChunkBoundaries() {
|
||||
Terrain3DRuntime.NoiseSource noise = (x, y, z) -> (StrictMath.sin(x * StrictMath.PI / 23D)
|
||||
+ StrictMath.sin(y * StrictMath.PI / 16D)
|
||||
+ StrictMath.cos(z * StrictMath.PI / 29D)) / 3D;
|
||||
Terrain3DRuntime forward = runtime(profile(), noise);
|
||||
Terrain3DRuntime reverse = runtime(profile(), noise);
|
||||
|
||||
for (int x = 20; x >= -20; x--) {
|
||||
reverse.column(x, -17);
|
||||
}
|
||||
for (int x = -20; x <= 20; x++) {
|
||||
Terrain3DColumn column = forward.column(x, -17);
|
||||
assertEquals(column, reverse.column(x, -17));
|
||||
for (int y = 1; y < 160; y++) {
|
||||
double density = 96.5D - y + 32D * interpolated(noise, x, y, -17);
|
||||
assertEquals("at " + x + "," + y + ",-17", density >= 0D, column.isSolid(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void profileContributionsFadeIntoAnOmittedNeighbour() {
|
||||
IrisBiome shaped = new IrisBiome().setTerrain3D(profile());
|
||||
IrisBiome unchanged = new IrisBiome();
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> x < 16 ? shaped : unchanged),
|
||||
options(true), (style, seed) -> (x, y, z) -> 1D);
|
||||
|
||||
assertEquals(128, runtime.column(12, 0).topY());
|
||||
assertEquals(120, runtime.column(13, 0).topY());
|
||||
assertEquals(112, runtime.column(14, 0).topY());
|
||||
assertEquals(104, runtime.column(15, 0).topY());
|
||||
assertEquals(96, runtime.column(16, 0).topY());
|
||||
assertFalse(runtime.column(16, 0).shaped());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void signedFissureInterpolationPreservesNarrowCracksBetweenLatticeNodes() {
|
||||
IrisTerrain3D config = profile().setAmplitude(0).setCrackDepth(24).setCrackWidth(1)
|
||||
.setCrackScale(64);
|
||||
Terrain3DRuntime runtime = runtime(config, (x, y, z) -> (x - 2D) / 32D);
|
||||
|
||||
assertEquals(72, runtime.column(2, 0).topY());
|
||||
assertEquals(96, runtime.column(0, 0).topY());
|
||||
assertEquals(96, runtime.column(4, 0).topY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void waterAndSlopeGatesKeepProtectedTerrainUnchanged() {
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile().setMinimumSlope(0.25D));
|
||||
Terrain3DRuntime flat = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome),
|
||||
options(true), (style, seed) -> (x, y, z) -> -1D);
|
||||
assertFalse(flat.column(0, 0).shaped());
|
||||
|
||||
biome.setTerrain3D(profile().setAmplitude(128).setCrackDepth(128).setFluidClearance(8));
|
||||
Terrain3DRuntime water = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 40D, (x, z) -> biome),
|
||||
new Terrain3DRuntime.Options(12, 256, 32, null, true, 4096),
|
||||
(style, seed) -> (x, y, z) -> -1D);
|
||||
assertFalse(water.column(0, 0).shaped());
|
||||
assertEquals(40, water.column(0, 0).topY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void columnCacheReusesGeometryAndAnchorNoise() {
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
Terrain3DRuntime runtime = runtime(profile(), (x, y, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return StrictMath.sin(y * StrictMath.PI / 16D);
|
||||
});
|
||||
Terrain3DColumn first = runtime.column(0, 0);
|
||||
int initialSamples = samples.get();
|
||||
assertSame(first, runtime.column(0, 0));
|
||||
assertEquals(initialSamples, samples.get());
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
runtime.column(x, z);
|
||||
}
|
||||
}
|
||||
assertTrue(samples.get() <= 49 * 18);
|
||||
int samplesBeforeClear = samples.get();
|
||||
runtime.clear();
|
||||
assertEquals(first, runtime.column(0, 0));
|
||||
assertTrue(samples.get() > samplesBeforeClear);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentAndEvictedColumnsRemainReproducible() throws Exception {
|
||||
Terrain3DRuntime.NoiseSource noise = (x, y, z) -> StrictMath.sin((x + y + z) * StrictMath.PI / 16D);
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 16),
|
||||
(style, seed) -> noise);
|
||||
Terrain3DRuntime reference = runtime(profile(), noise);
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
|
||||
List<Future<Terrain3DColumn>> results = new ArrayList<>();
|
||||
for (int index = 0; index < 128; index++) {
|
||||
int x = index % 32 - 16;
|
||||
results.add(executor.submit(() -> runtime.column(x, 0)));
|
||||
}
|
||||
for (int index = 0; index < results.size(); index++) {
|
||||
assertEquals(reference.column(index % 32 - 16, 0), results.get(index).get(10, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedColumnsKeepCoordinateAndRuntimeGeometrySeparate() {
|
||||
Terrain3DRuntime first = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D + x * 0.25D + z * 0.5D, (x, z) -> null),
|
||||
options(true), (style, seed) -> (x, y, z) -> 0D);
|
||||
Terrain3DRuntime second = runtime(profile(), (x, y, z) -> 1D);
|
||||
|
||||
assertEquals(96D, first.column(0, 0).baseHeight(), 0D);
|
||||
assertEquals(128, second.column(0, 0).topY());
|
||||
assertEquals(96, first.column(0, 0).topY());
|
||||
assertEquals(95.75D, first.column(-1, 0).baseHeight(), 0D);
|
||||
assertEquals(95.5D, first.column(0, -1).baseHeight(), 0D);
|
||||
assertEquals(95.5D, first.column(0, -1).baseHeight(), 0D);
|
||||
assertEquals(96D, first.column(0, 0).baseHeight(), 0D);
|
||||
assertEquals(128, second.column(0, 0).topY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearingRuntimeRefreshesTheLastColumnOnItsCallingThread() {
|
||||
AtomicInteger height = new AtomicInteger(96);
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> height.get(), (x, z) -> biome),
|
||||
options(true), (style, seed) -> (x, y, z) -> 0D);
|
||||
|
||||
assertEquals(96, runtime.column(0, 0).topY());
|
||||
height.set(120);
|
||||
runtime.clear();
|
||||
|
||||
assertEquals(120, runtime.column(0, 0).topY());
|
||||
assertTrue(runtime.column(0, 0).isSolid(110));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearingRuntimeRefreshesLastColumnsOnOtherThreads() throws Exception {
|
||||
AtomicInteger height = new AtomicInteger(96);
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> height.get(), (x, z) -> biome),
|
||||
options(true), (style, seed) -> (x, y, z) -> 0D);
|
||||
CountDownLatch sampled = new CountDownLatch(2);
|
||||
CountDownLatch cleared = new CountDownLatch(1);
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
List<Future<Terrain3DColumn>> results = new ArrayList<>();
|
||||
for (int index = 0; index < 2; index++) {
|
||||
results.add(executor.submit(() -> {
|
||||
Terrain3DColumn before = runtime.column(0, 0);
|
||||
sampled.countDown();
|
||||
assertTrue(cleared.await(10, TimeUnit.SECONDS));
|
||||
assertEquals(96, before.topY());
|
||||
return runtime.column(0, 0);
|
||||
}));
|
||||
}
|
||||
try {
|
||||
assertTrue(sampled.await(10, TimeUnit.SECONDS));
|
||||
height.set(120);
|
||||
runtime.clear();
|
||||
} finally {
|
||||
cleared.countDown();
|
||||
}
|
||||
for (Future<Terrain3DColumn> result : results) {
|
||||
assertEquals(120, result.get(10, TimeUnit.SECONDS).topY());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidCurrentFieldsAndNonfiniteNoiseFailBeforeGeometryEscapes() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> runtime(profile().setAmplitude(129), (x, y, z) -> 0D).column(0, 0));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> runtime(profile().setVerticalScale(Double.NaN), (x, y, z) -> 0D).column(0, 0));
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> runtime(profile(), (x, y, z) -> Double.NaN).column(0, 0));
|
||||
}
|
||||
|
||||
private static Terrain3DRuntime runtime(IrisTerrain3D profile, Terrain3DRuntime.NoiseSource noise) {
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile);
|
||||
return new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome),
|
||||
options(true), (style, seed) -> noise);
|
||||
}
|
||||
|
||||
private static Terrain3DRuntime.Options options(boolean enabled) {
|
||||
return new Terrain3DRuntime.Options(12, 256, 0, null, enabled, 4096);
|
||||
}
|
||||
|
||||
private static IrisTerrain3D profile() {
|
||||
return new IrisTerrain3D().setAmplitude(32).setHorizontalScale(64).setVerticalScale(64)
|
||||
.setMinimumSlope(0).setFluidClearance(0).setFluidFade(1);
|
||||
}
|
||||
|
||||
private static double interpolated(Terrain3DRuntime.NoiseSource noise, int x, int y, int z) {
|
||||
int startX = Math.floorDiv(x, 4) * 4;
|
||||
int startY = Math.floorDiv(y, 4) * 4;
|
||||
int startZ = Math.floorDiv(z, 4) * 4;
|
||||
double density = 0D;
|
||||
for (int dx = 0; dx <= 1; dx++) {
|
||||
for (int dy = 0; dy <= 1; dy++) {
|
||||
for (int dz = 0; dz <= 1; dz++) {
|
||||
double weightX = dx == 0 ? 1D - (x - startX) / 4D : (x - startX) / 4D;
|
||||
double weightY = dy == 0 ? 1D - (y - startY) / 4D : (y - startY) / 4D;
|
||||
double weightZ = dz == 0 ? 1D - (z - startZ) / 4D : (z - startZ) / 4D;
|
||||
density += weightX * weightY * weightZ
|
||||
* noise.sample(startX + dx * 4, startY + dy * 4, startZ + dz * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
return density;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package art.arcane.iris.engine.terrain;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class Terrain3DSurfaceSlopeTest {
|
||||
@Test
|
||||
public void flatLowerLedgeIgnoresTheVaryingUpperCap() {
|
||||
IrisComplex complex = complex();
|
||||
when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120));
|
||||
when(complex.terrainColumn(3, 0)).thenReturn(column(60, 150, 200));
|
||||
when(complex.terrainColumn(0, 3)).thenReturn(column(60, 90, 100));
|
||||
|
||||
assertEquals(0D, complex.terrainSurfaceSlope(0, 60, 0), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowerLedgeUsesTheExistingThreeBlockSlopeMetric() {
|
||||
IrisComplex complex = complex();
|
||||
when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120));
|
||||
when(complex.terrainColumn(3, 0)).thenReturn(column(63, 100, 120));
|
||||
when(complex.terrainColumn(0, 3)).thenReturn(column(64, 100, 120));
|
||||
|
||||
assertEquals(5D, complex.terrainSurfaceSlope(0, 60, 0), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void neighboringContinuousRiverTerrainUsesItsPlacementHeight() {
|
||||
IrisComplex complex = complex();
|
||||
when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120));
|
||||
when(complex.getPlacementHeightStream()).thenReturn(
|
||||
ProceduralStream.ofDouble((x, z) -> x == 3D ? 63D : 64D));
|
||||
|
||||
assertEquals(5D, complex.terrainSurfaceSlope(0, 60, 0), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void topSurfaceAndCaveInteriorsKeepTheExistingSlope() {
|
||||
IrisComplex complex = complex();
|
||||
when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120));
|
||||
when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 7.25D));
|
||||
|
||||
assertEquals(7.25D, complex.terrainSurfaceSlope(0, 120, 0), 0D);
|
||||
assertEquals(7.25D, complex.terrainSurfaceSlope(0, 50, 0), 0D);
|
||||
verify(complex, never()).terrainColumn(3, 0);
|
||||
verify(complex, never()).terrainColumn(0, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unshapedTerrainKeepsTheExistingSlope() {
|
||||
IrisComplex complex = complex();
|
||||
when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 3.5D));
|
||||
|
||||
assertEquals(3.5D, complex.terrainSurfaceSlope(0, 60, 0), 0D);
|
||||
verify(complex, never()).terrainColumn(3, 0);
|
||||
verify(complex, never()).terrainColumn(0, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nearestSurfaceUsesTheLowerFloorOnAnEqualDistance() {
|
||||
Terrain3DColumn column = column(60, 80, 100);
|
||||
|
||||
assertEquals(60, column.nearestSurfaceY(80));
|
||||
assertEquals(100, column.nearestSurfaceY(81));
|
||||
assertEquals(60, column.nearestSurfaceY(Integer.MIN_VALUE));
|
||||
assertEquals(100, column.nearestSurfaceY(Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
private static IrisComplex complex() {
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
doCallRealMethod().when(complex).terrainSurfaceSlope(anyInt(), anyInt(), anyInt());
|
||||
return complex;
|
||||
}
|
||||
|
||||
private static Terrain3DColumn column(int floor, int ceiling, int top) {
|
||||
return new Terrain3DColumn(floor, floor, true, new int[]{0, floor, ceiling, top});
|
||||
}
|
||||
}
|
||||
+40
-1
@@ -13,6 +13,10 @@ String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.orElse('com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')
|
||||
.get()
|
||||
|
||||
configurations {
|
||||
studioProbeApi
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(':core'))
|
||||
compileOnly(libs.spigot)
|
||||
@@ -36,6 +40,9 @@ dependencies {
|
||||
runtimeOnly(libs.oshi)
|
||||
runtimeOnly(libs.byteBuddy.core)
|
||||
runtimeOnly(libs.byteBuddy.agent)
|
||||
studioProbeApi(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('run', JavaExec).configure {
|
||||
@@ -84,6 +91,37 @@ Provider<String> probeJfr = providers.gradleProperty('probeJfr')
|
||||
Provider<String> probeTileX = providers.gradleProperty('probeTileX')
|
||||
Provider<String> probeTileZ = providers.gradleProperty('probeTileZ')
|
||||
Provider<String> probeOutput = providers.gradleProperty('probeOutput')
|
||||
Provider<String> probeStrictGeometry = providers.gradleProperty('probeStrictGeometry').orElse('false')
|
||||
|
||||
tasks.register('terrain3DProbe', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Generates real pack chunks, verifies volumetric terrain spans and required biome overhang coverage, and exports actual block cross-sections.'
|
||||
mainClass = 'art.arcane.iris.probe.Terrain3DProbe'
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
jvmArgs('--add-modules', 'jdk.incubator.vector', '-Djava.awt.headless=true')
|
||||
dependsOn(':core:compileJava')
|
||||
doFirst {
|
||||
Map<String, Provider<String>> required = [
|
||||
probePack: probePack,
|
||||
probeDimension: probeDimension,
|
||||
probeSeed: probeSeed,
|
||||
probeMinimumChunkX: probeMinimumChunkX,
|
||||
probeMaximumChunkX: probeMaximumChunkX,
|
||||
probeMinimumChunkZ: probeMinimumChunkZ,
|
||||
probeMaximumChunkZ: probeMaximumChunkZ,
|
||||
probeRequiredCoverage: probeRequiredCoverage,
|
||||
probeOutput: probeOutput
|
||||
]
|
||||
required.each { String name, Provider<String> value ->
|
||||
if (!value.isPresent() || value.get().isBlank()) {
|
||||
throw new GradleException("terrain3DProbe requires -P${name}=<value>")
|
||||
}
|
||||
}
|
||||
args(probePack.get(), probeDimension.get(), probeSeed.get(), probeMinimumChunkX.get(),
|
||||
probeMaximumChunkX.get(), probeMinimumChunkZ.get(), probeMaximumChunkZ.get(),
|
||||
probeRequiredCoverage.get(), probeOutput.get(), probeStrictGeometry.get(), probeStudio.get())
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('genProbe', JavaExec) {
|
||||
group = 'verification'
|
||||
@@ -221,8 +259,9 @@ tasks.register('riverTransectProbe', JavaExec) {
|
||||
}
|
||||
}
|
||||
|
||||
['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe', 'riverTransectProbe'].each { String taskName ->
|
||||
['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe', 'riverTransectProbe', 'terrain3DProbe'].each { String taskName ->
|
||||
tasks.named(taskName, JavaExec).configure { JavaExec task ->
|
||||
classpath += configurations.studioProbeApi
|
||||
doFirst {
|
||||
if (probeJfr.isPresent()) {
|
||||
String requestedPath = probeJfr.get()
|
||||
|
||||
@@ -5,6 +5,7 @@ import art.arcane.iris.engine.hydrology.HydrologyCandidateKind;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory;
|
||||
import art.arcane.iris.engine.hydrology.DrainageEdge;
|
||||
import art.arcane.iris.engine.hydrology.DrainageNode;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate;
|
||||
@@ -3844,9 +3845,18 @@ public final class HydrologyPackProbe {
|
||||
CaveVoxelView view = new MantleHydrologyCaveVoxelView(
|
||||
engine,
|
||||
engine.getComplex(),
|
||||
(int x, int z, int naturalHeight) -> runtime.sample(x, z)
|
||||
.map(HydrologyColumnSample::terrainHeight)
|
||||
.orElse(naturalHeight)
|
||||
new HydrologyCaveVoxelViewFactory.PlannedSurface() {
|
||||
@Override
|
||||
public int resolve(int x, int z, int naturalHeight) {
|
||||
return runtime.sample(x, z).map(HydrologyColumnSample::terrainHeight).orElse(naturalHeight);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ownsTerrain(int x, int z) {
|
||||
HydrologyColumnSample sample = runtime.sample(x, z).orElse(null);
|
||||
return sample != null && sample.primarySurfaceLayer().isPresent();
|
||||
}
|
||||
}
|
||||
);
|
||||
int minimumX = chunkX << 4;
|
||||
int minimumZ = chunkZ << 4;
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.actuator.IrisTerrainNormalActuator;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.terrain.Terrain3DColumn;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.BitSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
public final class Terrain3DProbe {
|
||||
private static final String PREFIX = "[terrain3d]";
|
||||
private static final int MAXIMUM_CHUNKS_PER_AXIS = 64;
|
||||
private static final int MINIMUM_GAP_HEIGHT = 2;
|
||||
|
||||
private Terrain3DProbe() {
|
||||
}
|
||||
|
||||
record Configuration(File pack, String dimension, long seed, int minimumChunkX, int maximumChunkX,
|
||||
int minimumChunkZ, int maximumChunkZ, Set<String> requiredBiomes, File output,
|
||||
boolean strictGeometry, boolean studio) {
|
||||
Configuration {
|
||||
if (pack == null || !pack.isAbsolute() || dimension == null || dimension.isBlank()
|
||||
|| dimension.chars().anyMatch(Character::isWhitespace)) {
|
||||
throw new IllegalArgumentException("An absolute pack path and nonblank dimension key are required.");
|
||||
}
|
||||
validateAxis(minimumChunkX, maximumChunkX);
|
||||
validateAxis(minimumChunkZ, maximumChunkZ);
|
||||
if (output == null || !output.isAbsolute()) {
|
||||
throw new IllegalArgumentException("The output directory must be absolute.");
|
||||
}
|
||||
requiredBiomes = Set.copyOf(requiredBiomes);
|
||||
}
|
||||
|
||||
static Configuration parse(String[] args) {
|
||||
if (args.length != 11) {
|
||||
throw new IllegalArgumentException("Expected: <pack> <dimension> <seed> <minChunkX> <maxChunkX> "
|
||||
+ "<minChunkZ> <maxChunkZ> <requiredBiomeKeys|- > <outputDir> <strictGeometry> <studio>");
|
||||
}
|
||||
LinkedHashSet<String> required = new LinkedHashSet<>();
|
||||
if (!args[7].equals("-")) {
|
||||
for (String value : args[7].split(",", -1)) {
|
||||
String key = value.trim();
|
||||
if (key.isBlank() || key.chars().anyMatch(Character::isWhitespace)) {
|
||||
throw new IllegalArgumentException("Required biome keys must be comma-separated nonblank load keys.");
|
||||
}
|
||||
required.add(key);
|
||||
}
|
||||
}
|
||||
return new Configuration(new File(args[0]), args[1], Long.parseLong(args[2]),
|
||||
Integer.parseInt(args[3]), Integer.parseInt(args[4]), Integer.parseInt(args[5]),
|
||||
Integer.parseInt(args[6]), required, new File(args[8]),
|
||||
RealPackProbeSupport.parseBoolean(args[9], "strictGeometry"),
|
||||
RealPackProbeSupport.parseBoolean(args[10], "studio"));
|
||||
}
|
||||
|
||||
int chunkCount() {
|
||||
return (maximumChunkX - minimumChunkX + 1) * (maximumChunkZ - minimumChunkZ + 1);
|
||||
}
|
||||
|
||||
private static void validateAxis(int minimum, int maximum) {
|
||||
long width = (long) maximum - minimum + 1;
|
||||
if (width < 1 || width > MAXIMUM_CHUNKS_PER_AXIS
|
||||
|| minimum < Integer.MIN_VALUE / 16 || maximum > Integer.MAX_VALUE / 16 - 1) {
|
||||
throw new IllegalArgumentException("Chunk axes must be ordered, at most 64 chunks wide, and fit block coordinates.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record ColumnEvidence(int additiveBlocks, int retainedAdditiveBlocks, int coveredGaps, int retainedCoveredGaps,
|
||||
int carvedBlocks, int retainedCarvedBlocks, int geometryMismatches,
|
||||
int finalGeometryDifferences, int generatedTop, int maximumGapHeight) {
|
||||
}
|
||||
|
||||
record ColumnMorphology(int solidSpans, int coveredAirBlocks, long[] gapHeightBins,
|
||||
long[] upperSolidThicknessBins) {
|
||||
}
|
||||
|
||||
static final class BiomeEvidence {
|
||||
long sampledColumns;
|
||||
long shapedColumns;
|
||||
long additiveBlocks;
|
||||
long retainedAdditiveBlocks;
|
||||
long coveredGaps;
|
||||
long retainedCoveredGaps;
|
||||
long carvedBlocks;
|
||||
long retainedCarvedBlocks;
|
||||
long geometryMismatches;
|
||||
long finalGeometryDifferences;
|
||||
long heightMismatches;
|
||||
long columnsWithCoveredGaps;
|
||||
long columnsWithMultipleSolidSpans;
|
||||
long additionalSolidSpans;
|
||||
long coveredAirBlocks;
|
||||
final long[] gapHeightBins = new long[5];
|
||||
final long[] upperSolidThicknessBins = new long[5];
|
||||
int maximumGapHeight;
|
||||
final List<String> examples = new ArrayList<>();
|
||||
|
||||
void add(ColumnEvidence evidence, ColumnMorphology morphology, boolean heightMismatch, int x, int z, int minHeight) {
|
||||
shapedColumns++;
|
||||
additiveBlocks += evidence.additiveBlocks();
|
||||
retainedAdditiveBlocks += evidence.retainedAdditiveBlocks();
|
||||
coveredGaps += evidence.coveredGaps();
|
||||
retainedCoveredGaps += evidence.retainedCoveredGaps();
|
||||
carvedBlocks += evidence.carvedBlocks();
|
||||
retainedCarvedBlocks += evidence.retainedCarvedBlocks();
|
||||
geometryMismatches += evidence.geometryMismatches();
|
||||
finalGeometryDifferences += evidence.finalGeometryDifferences();
|
||||
heightMismatches += heightMismatch ? 1 : 0;
|
||||
columnsWithCoveredGaps += morphology.coveredAirBlocks() > 0 ? 1 : 0;
|
||||
columnsWithMultipleSolidSpans += morphology.solidSpans() > 1 ? 1 : 0;
|
||||
additionalSolidSpans += Math.max(0, morphology.solidSpans() - 1);
|
||||
coveredAirBlocks += morphology.coveredAirBlocks();
|
||||
for (int index = 0; index < gapHeightBins.length; index++) {
|
||||
gapHeightBins[index] += morphology.gapHeightBins()[index];
|
||||
upperSolidThicknessBins[index] += morphology.upperSolidThicknessBins()[index];
|
||||
}
|
||||
maximumGapHeight = Math.max(maximumGapHeight, evidence.maximumGapHeight());
|
||||
if (evidence.retainedCoveredGaps() > 0 && examples.size() < 8) {
|
||||
examples.add(x + "," + (evidence.generatedTop() + minHeight) + "," + z);
|
||||
}
|
||||
}
|
||||
|
||||
boolean coversOverhangs() {
|
||||
return retainedCoveredGaps > 0 && retainedAdditiveBlocks > 0;
|
||||
}
|
||||
}
|
||||
|
||||
record Result(String status, Configuration configuration, int generatedChunks, int worldMinimumY,
|
||||
Map<String, BiomeEvidence> biomes, TopologyEvidence topology,
|
||||
List<String> failures, List<String> sections) {
|
||||
}
|
||||
|
||||
record TopologyEvidence(long groundedComponents, long censoredComponents, long detachedComponents,
|
||||
long detachedBlocks, long retainedDetachedBlocks, long[] detachedSizeBins,
|
||||
long fullyRetainedSmallComponents, List<String> examples) {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
int exitCode = 2;
|
||||
try {
|
||||
Configuration configuration = Configuration.parse(args);
|
||||
Result result = run(configuration);
|
||||
System.out.println("IRIS_TERRAIN3D_RESULT version=1 status=" + result.status()
|
||||
+ " dimension=" + configuration.dimension() + " seed=" + configuration.seed()
|
||||
+ " chunks=" + result.generatedChunks() + " biomes=" + result.biomes().size()
|
||||
+ " failures=" + result.failures().size() + " studio=" + configuration.studio()
|
||||
+ " strict_geometry=" + configuration.strictGeometry()
|
||||
+ " output=" + configuration.output().getAbsolutePath());
|
||||
exitCode = result.failures().isEmpty() ? 0 : 1;
|
||||
} catch (Throwable failure) {
|
||||
System.out.println(PREFIX + " FAIL: " + failure.getMessage());
|
||||
failure.printStackTrace(System.out);
|
||||
}
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
static ColumnEvidence inspectColumn(int baseTop, boolean[] predicted, boolean[] terrain,
|
||||
boolean[] generated, boolean[] retainedTerrain) {
|
||||
int height = predicted.length;
|
||||
if (height < 1 || terrain.length != height || generated.length != height || retainedTerrain.length != height) {
|
||||
throw new IllegalArgumentException("All column buffers must have the same positive height.");
|
||||
}
|
||||
int additive = 0;
|
||||
int retainedAdditive = 0;
|
||||
int carved = 0;
|
||||
int retainedCarved = 0;
|
||||
int mismatches = 0;
|
||||
int finalDifferences = 0;
|
||||
int top = -1;
|
||||
for (int y = 0; y < height; y++) {
|
||||
if (terrain[y]) {
|
||||
top = y;
|
||||
}
|
||||
if (predicted[y] != terrain[y]) {
|
||||
mismatches++;
|
||||
}
|
||||
if (predicted[y] != generated[y]) {
|
||||
finalDifferences++;
|
||||
}
|
||||
if (y > baseTop && predicted[y] && terrain[y]) {
|
||||
additive++;
|
||||
retainedAdditive += retainedTerrain[y] ? 1 : 0;
|
||||
}
|
||||
if (y <= baseTop && !predicted[y] && !terrain[y]) {
|
||||
carved++;
|
||||
retainedCarved += !generated[y] ? 1 : 0;
|
||||
}
|
||||
}
|
||||
int gaps = 0;
|
||||
int retainedGaps = 0;
|
||||
int maximumGap = 0;
|
||||
for (int bottom = 1; bottom < height - 1; bottom++) {
|
||||
if (predicted[bottom] || !predicted[bottom - 1] || terrain[bottom]) {
|
||||
continue;
|
||||
}
|
||||
int roof = bottom + 1;
|
||||
while (roof < height && !predicted[roof]) {
|
||||
roof++;
|
||||
}
|
||||
int gapHeight = roof - bottom;
|
||||
if (roof >= height || gapHeight < MINIMUM_GAP_HEIGHT || !terrain[bottom - 1] || !terrain[roof]) {
|
||||
continue;
|
||||
}
|
||||
boolean rawAir = true;
|
||||
boolean finalAir = true;
|
||||
for (int y = bottom; y < roof; y++) {
|
||||
rawAir &= !terrain[y];
|
||||
finalAir &= !generated[y];
|
||||
}
|
||||
if (rawAir) {
|
||||
gaps++;
|
||||
maximumGap = Math.max(maximumGap, gapHeight);
|
||||
if (finalAir && retainedTerrain[bottom - 1] && retainedTerrain[roof]) {
|
||||
retainedGaps++;
|
||||
}
|
||||
}
|
||||
bottom = roof;
|
||||
}
|
||||
return new ColumnEvidence(additive, retainedAdditive, gaps, retainedGaps, carved, retainedCarved,
|
||||
mismatches, finalDifferences, top, maximumGap);
|
||||
}
|
||||
|
||||
static ColumnMorphology inspectMorphology(boolean[] terrain) {
|
||||
int spans = 0;
|
||||
int previousTop = -1;
|
||||
int coveredAir = 0;
|
||||
long[] gaps = new long[5];
|
||||
long[] thickness = new long[5];
|
||||
for (int bottom = 0; bottom < terrain.length; bottom++) {
|
||||
if (!terrain[bottom]) {
|
||||
continue;
|
||||
}
|
||||
int top = bottom;
|
||||
while (top + 1 < terrain.length && terrain[top + 1]) {
|
||||
top++;
|
||||
}
|
||||
if (previousTop >= 0) {
|
||||
int gap = bottom - previousTop - 1;
|
||||
coveredAir += gap;
|
||||
gaps[morphologyBin(gap)]++;
|
||||
thickness[morphologyBin(top - bottom + 1)]++;
|
||||
}
|
||||
spans++;
|
||||
previousTop = top;
|
||||
bottom = top;
|
||||
}
|
||||
return new ColumnMorphology(spans, coveredAir, gaps, thickness);
|
||||
}
|
||||
|
||||
private static int morphologyBin(int size) {
|
||||
return size <= 1 ? 0 : size <= 3 ? 1 : size <= 7 ? 2 : size <= 15 ? 3 : 4;
|
||||
}
|
||||
|
||||
static List<String> coverageFailures(Map<String, BiomeEvidence> biomes, Set<String> required, boolean strict) {
|
||||
ArrayList<String> failures = new ArrayList<>();
|
||||
long shapedColumns = 0;
|
||||
for (Map.Entry<String, BiomeEvidence> entry : biomes.entrySet()) {
|
||||
BiomeEvidence evidence = entry.getValue();
|
||||
shapedColumns += evidence.shapedColumns;
|
||||
if (evidence.geometryMismatches > 0 || evidence.heightMismatches > 0) {
|
||||
failures.add(entry.getKey() + ": terrain output differs from shape/query: blocks="
|
||||
+ evidence.geometryMismatches + ", heights=" + evidence.heightMismatches);
|
||||
}
|
||||
if (strict && evidence.finalGeometryDifferences > 0) {
|
||||
failures.add(entry.getKey() + ": final chunk geometry differs at "
|
||||
+ evidence.finalGeometryDifferences + " blocks in strict mode");
|
||||
}
|
||||
}
|
||||
if (shapedColumns == 0) {
|
||||
failures.add("No shaped terrain columns were generated in the requested rectangle.");
|
||||
}
|
||||
for (String key : required) {
|
||||
BiomeEvidence evidence = biomes.get(key);
|
||||
if (evidence == null || !evidence.coversOverhangs()) {
|
||||
failures.add(key + ": required retained additive terrain and covered air gaps were not both generated");
|
||||
}
|
||||
}
|
||||
return List.copyOf(failures);
|
||||
}
|
||||
|
||||
private static Result run(Configuration configuration) throws Exception {
|
||||
Files.createDirectories(configuration.output().toPath());
|
||||
TreeMap<String, BiomeEvidence> evidence = new TreeMap<>();
|
||||
ArrayList<String> sections = new ArrayList<>();
|
||||
int generatedChunks = 0;
|
||||
int minHeight;
|
||||
TopologyEvidence topology;
|
||||
try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(
|
||||
configuration.pack(), configuration.dimension(), PREFIX);
|
||||
RealPackProbeSupport.EngineSession session = workspace.openEngine(
|
||||
configuration.seed(), configuration.studio(), "terrain3d")) {
|
||||
Engine engine = session.engine();
|
||||
minHeight = engine.getMinHeight();
|
||||
TerrainTopology terrainTopology = new TerrainTopology(
|
||||
(configuration.maximumChunkX() - configuration.minimumChunkX() + 1) * 16,
|
||||
(configuration.maximumChunkZ() - configuration.minimumChunkZ() + 1) * 16);
|
||||
IrisTerrainNormalActuator actuator = new IrisTerrainNormalActuator(engine);
|
||||
for (int chunkZ = configuration.minimumChunkZ(); chunkZ <= configuration.maximumChunkZ(); chunkZ++) {
|
||||
SectionRenderer renderer = new SectionRenderer(configuration,
|
||||
new SectionBounds(chunkZ, engine.getHeight(), minHeight));
|
||||
for (int chunkX = configuration.minimumChunkX(); chunkX <= configuration.maximumChunkX(); chunkX++) {
|
||||
inspectChunk(engine, actuator, chunkX, chunkZ, evidence, renderer, terrainTopology);
|
||||
generatedChunks++;
|
||||
if (generatedChunks % 16 == 0 || generatedChunks == configuration.chunkCount()) {
|
||||
System.out.println(PREFIX + " generated " + generatedChunks + "/" + configuration.chunkCount());
|
||||
}
|
||||
}
|
||||
sections.addAll(renderer.write());
|
||||
}
|
||||
topology = terrainTopology.inspect();
|
||||
failReported("generation", RealPackProbeSupport.settleAndDrain());
|
||||
}
|
||||
List<String> failures = coverageFailures(evidence, configuration.requiredBiomes(), configuration.strictGeometry());
|
||||
Result result = new Result(failures.isEmpty() ? "PASS" : "FAIL", configuration, generatedChunks,
|
||||
minHeight, evidence, topology, failures, sections);
|
||||
Files.writeString(configuration.output().toPath().resolve("terrain3d-summary.json"),
|
||||
new GsonBuilder().setPrettyPrinting().create().toJson(summary(result)) + "\n", StandardCharsets.UTF_8);
|
||||
for (Map.Entry<String, BiomeEvidence> entry : evidence.entrySet()) {
|
||||
BiomeEvidence value = entry.getValue();
|
||||
System.out.println(PREFIX + " biome=" + entry.getKey() + " shaped=" + value.shapedColumns
|
||||
+ " additive=" + value.retainedAdditiveBlocks + " covered_gaps=" + value.retainedCoveredGaps
|
||||
+ " max_gap=" + value.maximumGapHeight + " geometry_errors=" + value.geometryMismatches);
|
||||
}
|
||||
for (String failure : failures) {
|
||||
System.out.println(PREFIX + " FAIL: " + failure);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Object> summary(Result result) {
|
||||
Configuration configuration = result.configuration();
|
||||
Map<String, Object> inputs = Map.ofEntries(
|
||||
Map.entry("pack", configuration.pack().getAbsolutePath()),
|
||||
Map.entry("dimension", configuration.dimension()),
|
||||
Map.entry("seed", configuration.seed()),
|
||||
Map.entry("minimumChunkX", configuration.minimumChunkX()),
|
||||
Map.entry("maximumChunkX", configuration.maximumChunkX()),
|
||||
Map.entry("minimumChunkZ", configuration.minimumChunkZ()),
|
||||
Map.entry("maximumChunkZ", configuration.maximumChunkZ()),
|
||||
Map.entry("requiredBiomes", configuration.requiredBiomes()),
|
||||
Map.entry("strictGeometry", configuration.strictGeometry()),
|
||||
Map.entry("studio", configuration.studio()));
|
||||
return Map.of("status", result.status(), "configuration", inputs, "generatedChunks", result.generatedChunks(),
|
||||
"worldMinimumY", result.worldMinimumY(), "biomes", result.biomes(), "failures", result.failures(),
|
||||
"sections", result.sections(), "topology", result.topology(), "metricDefinitions", Map.of(
|
||||
"morphologyBinInclusiveRanges", List.of("1", "2–3", "4–7", "8–15", "16+"),
|
||||
"detachedSizeBinInclusiveRanges", List.of("1–8", "9–64", "65–512", "513+"),
|
||||
"normalization", "Divide per-biome counts by sampledColumns, including unshaped columns.",
|
||||
"topologyScope", "Actual normal-terrain solid output; excludes additional-layer ownership and placed features. "
|
||||
+ "Components touching rectangle edges, world ceiling, or excluded ownership are censored. "
|
||||
+ "Examples use local block X,Y,Z relative to the rectangle and world minimum Y."));
|
||||
}
|
||||
|
||||
private static void inspectChunk(Engine engine, IrisTerrainNormalActuator actuator, int chunkX, int chunkZ,
|
||||
Map<String, BiomeEvidence> biomes, SectionRenderer renderer,
|
||||
TerrainTopology topology) throws Exception {
|
||||
failReported("before chunk " + chunkX + "," + chunkZ, RealPackProbeSupport.drainReported());
|
||||
RealPackProbeSupport.GeneratedChunk generated = RealPackProbeSupport.generateChunk(engine, chunkX, chunkZ);
|
||||
int height = generated.height();
|
||||
Hunk<PlatformBlockState> terrain = Hunk.newArrayHunk(16, height, 16);
|
||||
IrisComplex complex = engine.getComplex();
|
||||
int blockX = chunkX << 4;
|
||||
int blockZ = chunkZ << 4;
|
||||
actuator.actuate(blockX, blockZ, terrain, false, new ChunkContext(blockX, blockZ, complex));
|
||||
failReported("chunk " + chunkX + "," + chunkZ, RealPackProbeSupport.drainReported());
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
int worldX = blockX + x;
|
||||
int worldZ = blockZ + z;
|
||||
IrisBiome biome = complex.getTrueBiomeStream().get(worldX, worldZ);
|
||||
String key = biome == null ? "<missing>" : biome.getLoadKey();
|
||||
BiomeEvidence aggregate = biomes.computeIfAbsent(key, ignored -> new BiomeEvidence());
|
||||
aggregate.sampledColumns++;
|
||||
Terrain3DColumn column = complex.terrainColumn(worldX, worldZ);
|
||||
boolean[] raw = new boolean[height];
|
||||
boolean[] retained = new boolean[height];
|
||||
BitSet excluded = new BitSet(height);
|
||||
boolean additionalTerrain = engine.getUpperContext() != null || engine.getDimensionStackContext() != null;
|
||||
int rootTop = (int) Math.round(complex.getRawHeightStream().getDouble(worldX, worldZ));
|
||||
for (int y = 0; y < height; y++) {
|
||||
boolean owned = additionalTerrain && engine.isAdditionalTerrainOwned(worldX, y, worldZ);
|
||||
excluded.set(y, owned);
|
||||
PlatformBlockState rawBlock = terrain.get(x, y, z);
|
||||
PlatformBlockState finalBlock = generated.blocks().get(x, y, z);
|
||||
raw[y] = y <= rootTop && !owned && solid(rawBlock);
|
||||
retained[y] = raw[y] && finalBlock != null && rawBlock.matches(finalBlock);
|
||||
}
|
||||
topology.column(worldX - (renderer.configuration.minimumChunkX() << 4),
|
||||
worldZ - (renderer.configuration.minimumChunkZ() << 4),
|
||||
new TopologyColumn(raw, retained, excluded, column != null));
|
||||
ColumnEvidence observation = column == null ? null
|
||||
: inspectShapedColumn(column, terrain, generated.blocks(), x, z);
|
||||
if (observation != null) {
|
||||
int queryTop = (int) Math.round(complex.getRawHeightStream().getDouble(worldX, worldZ));
|
||||
aggregate.add(observation, inspectMorphology(raw),
|
||||
queryTop != column.topY() || queryTop != observation.generatedTop(),
|
||||
worldX, worldZ, engine.getMinHeight());
|
||||
}
|
||||
renderer.column(worldX, z, terrain, generated.blocks(), x, column,
|
||||
(int) Math.round(complex.getBaseTerrainHeightStream().getDouble(worldX, worldZ)), observation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ColumnEvidence inspectShapedColumn(Terrain3DColumn column, Hunk<PlatformBlockState> terrain,
|
||||
Hunk<PlatformBlockState> generated, int x, int z) {
|
||||
int height = terrain.getHeight();
|
||||
boolean[] predicted = new boolean[height];
|
||||
boolean[] raw = new boolean[height];
|
||||
boolean[] complete = new boolean[height];
|
||||
boolean[] retained = new boolean[height];
|
||||
for (int y = 0; y < height; y++) {
|
||||
PlatformBlockState rawBlock = terrain.get(x, y, z);
|
||||
PlatformBlockState finalBlock = generated.get(x, y, z);
|
||||
predicted[y] = column.isSolid(y);
|
||||
raw[y] = solid(rawBlock);
|
||||
complete[y] = finalBlock != null && !finalBlock.isAir();
|
||||
retained[y] = raw[y] && finalBlock != null && rawBlock.matches(finalBlock);
|
||||
}
|
||||
return inspectColumn((int) Math.round(column.baseHeight()), predicted, raw, complete, retained);
|
||||
}
|
||||
|
||||
private static boolean solid(PlatformBlockState block) {
|
||||
return block != null && !block.isAir() && !block.isFluid();
|
||||
}
|
||||
|
||||
private static void failReported(String phase, List<Throwable> reports) throws Exception {
|
||||
if (reports.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
RealPackProbeSupport.printReports(PREFIX, phase, reports);
|
||||
IllegalStateException failure = new IllegalStateException("Engine reported failures during " + phase);
|
||||
for (Throwable report : reports) {
|
||||
failure.addSuppressed(report);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
|
||||
record TopologyColumn(boolean[] solid, boolean[] retained, BitSet excluded, boolean shaped) {
|
||||
TopologyColumn {
|
||||
if (solid.length == 0 || solid.length != retained.length || excluded == null) {
|
||||
throw new IllegalArgumentException("Topology columns need matching nonempty buffers and an ownership mask.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final class TerrainTopology {
|
||||
private final int width;
|
||||
private final int depth;
|
||||
private final List<List<SolidSpan>> columns;
|
||||
private final List<BitSet> excluded;
|
||||
|
||||
TerrainTopology(int width, int depth) {
|
||||
if (width < 1 || depth < 1) {
|
||||
throw new IllegalArgumentException("Topology dimensions must be positive.");
|
||||
}
|
||||
this.width = width;
|
||||
this.depth = depth;
|
||||
this.columns = new ArrayList<>(width * depth);
|
||||
this.excluded = new ArrayList<>(width * depth);
|
||||
for (int index = 0; index < width * depth; index++) {
|
||||
columns.add(null);
|
||||
excluded.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
void column(int x, int z, TopologyColumn column) {
|
||||
if (x < 0 || z < 0 || x >= width || z >= depth || columns.get(z * width + x) != null) {
|
||||
throw new IllegalArgumentException("Each topology column must be supplied exactly once within the rectangle.");
|
||||
}
|
||||
ArrayList<SolidSpan> spans = new ArrayList<>();
|
||||
for (int bottom = 0; bottom < column.solid().length; bottom++) {
|
||||
if (!column.solid()[bottom] || column.excluded().get(bottom)) {
|
||||
continue;
|
||||
}
|
||||
int top = bottom;
|
||||
long retained = column.retained()[bottom] ? 1 : 0;
|
||||
while (top + 1 < column.solid().length && column.solid()[top + 1] && !column.excluded().get(top + 1)) {
|
||||
top++;
|
||||
retained += column.retained()[top] ? 1 : 0;
|
||||
}
|
||||
boolean censored = x == 0 || x == width - 1 || z == 0 || z == depth - 1
|
||||
|| top == column.solid().length - 1 || column.excluded().get(top + 1)
|
||||
|| bottom > 0 && column.excluded().get(bottom - 1);
|
||||
spans.add(new SolidSpan(new SpanBounds(x, z, bottom, top),
|
||||
new SpanObservation(column.shaped(), censored, retained)));
|
||||
bottom = top;
|
||||
}
|
||||
columns.set(z * width + x, spans);
|
||||
excluded.set(z * width + x, (BitSet) column.excluded().clone());
|
||||
}
|
||||
|
||||
TopologyEvidence inspect() {
|
||||
if (columns.contains(null)) {
|
||||
throw new IllegalStateException("All columns must be sampled before topology analysis.");
|
||||
}
|
||||
for (int z = 0; z < depth; z++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int index = z * width + x;
|
||||
if (x > 0) {
|
||||
connect(index, index - 1);
|
||||
}
|
||||
if (z > 0) {
|
||||
connect(index, index - width);
|
||||
}
|
||||
}
|
||||
}
|
||||
long grounded = 0;
|
||||
long censored = 0;
|
||||
long detached = 0;
|
||||
long blocks = 0;
|
||||
long retained = 0;
|
||||
long smallRetained = 0;
|
||||
long[] bins = new long[4];
|
||||
ArrayList<String> examples = new ArrayList<>();
|
||||
for (List<SolidSpan> column : columns) {
|
||||
for (SolidSpan span : column) {
|
||||
if (span.parent != span || !span.shaped) {
|
||||
continue;
|
||||
}
|
||||
if (span.grounded) {
|
||||
grounded++;
|
||||
} else if (span.censored) {
|
||||
censored++;
|
||||
} else {
|
||||
detached++;
|
||||
blocks += span.blocks;
|
||||
retained += span.retained;
|
||||
bins[span.blocks <= 8 ? 0 : span.blocks <= 64 ? 1 : span.blocks <= 512 ? 2 : 3]++;
|
||||
smallRetained += span.blocks <= 64 && span.retained == span.blocks ? 1 : 0;
|
||||
if (examples.size() < 16) {
|
||||
examples.add(span.bounds.x() + "," + span.bounds.bottom() + "," + span.bounds.z()
|
||||
+ " blocks=" + span.blocks + " retained=" + span.retained);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new TopologyEvidence(grounded, censored, detached, blocks, retained, bins,
|
||||
smallRetained, List.copyOf(examples));
|
||||
}
|
||||
|
||||
private void connect(int firstIndex, int secondIndex) {
|
||||
List<SolidSpan> first = columns.get(firstIndex);
|
||||
List<SolidSpan> second = columns.get(secondIndex);
|
||||
censor(first, excluded.get(secondIndex));
|
||||
censor(second, excluded.get(firstIndex));
|
||||
int left = 0;
|
||||
int right = 0;
|
||||
while (left < first.size() && right < second.size()) {
|
||||
SolidSpan a = first.get(left);
|
||||
SolidSpan b = second.get(right);
|
||||
if (a.bounds.bottom() <= b.bounds.top() && b.bounds.bottom() <= a.bounds.top()) {
|
||||
a.union(b);
|
||||
}
|
||||
if (a.bounds.top() < b.bounds.top()) {
|
||||
left++;
|
||||
} else {
|
||||
right++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void censor(List<SolidSpan> spans, BitSet ownership) {
|
||||
for (SolidSpan span : spans) {
|
||||
int owned = ownership.nextSetBit(span.bounds.bottom());
|
||||
if (owned >= 0 && owned <= span.bounds.top()) {
|
||||
span.root().censored = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record SpanBounds(int x, int z, int bottom, int top) {
|
||||
}
|
||||
|
||||
private record SpanObservation(boolean shaped, boolean censored, long retained) {
|
||||
}
|
||||
|
||||
private static final class SolidSpan {
|
||||
private final SpanBounds bounds;
|
||||
private SolidSpan parent = this;
|
||||
private long blocks;
|
||||
private long retained;
|
||||
private boolean shaped;
|
||||
private boolean grounded;
|
||||
private boolean censored;
|
||||
|
||||
private SolidSpan(SpanBounds bounds, SpanObservation observation) {
|
||||
this.bounds = bounds;
|
||||
this.blocks = bounds.top() - bounds.bottom() + 1L;
|
||||
this.retained = observation.retained();
|
||||
this.shaped = observation.shaped();
|
||||
this.grounded = bounds.bottom() == 0;
|
||||
this.censored = observation.censored();
|
||||
}
|
||||
|
||||
private SolidSpan root() {
|
||||
SolidSpan root = this;
|
||||
while (root.parent != root) {
|
||||
root = root.parent;
|
||||
}
|
||||
SolidSpan current = this;
|
||||
while (current.parent != root) {
|
||||
SolidSpan next = current.parent;
|
||||
current.parent = root;
|
||||
current = next;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private void union(SolidSpan other) {
|
||||
SolidSpan first = root();
|
||||
SolidSpan second = other.root();
|
||||
if (first == second) {
|
||||
return;
|
||||
}
|
||||
if (first.blocks < second.blocks) {
|
||||
SolidSpan swap = first;
|
||||
first = second;
|
||||
second = swap;
|
||||
}
|
||||
second.parent = first;
|
||||
first.blocks += second.blocks;
|
||||
first.retained += second.retained;
|
||||
first.shaped |= second.shaped;
|
||||
first.grounded |= second.grounded;
|
||||
first.censored |= second.censored;
|
||||
}
|
||||
}
|
||||
|
||||
private record SectionBounds(int chunkZ, int height, int minimumY) {
|
||||
}
|
||||
|
||||
private static final class SectionRenderer {
|
||||
private static final int LEFT = 48;
|
||||
private static final int HEADER = 58;
|
||||
private final Configuration configuration;
|
||||
private final int chunkZ;
|
||||
private final int height;
|
||||
private final int minimumY;
|
||||
private final BufferedImage[] images = new BufferedImage[16];
|
||||
private final long[] scores = new long[16];
|
||||
private final int[] lowestShape = new int[16];
|
||||
private final int[] highestShape = new int[16];
|
||||
|
||||
private SectionRenderer(Configuration configuration, SectionBounds bounds) {
|
||||
this.configuration = configuration;
|
||||
this.chunkZ = bounds.chunkZ();
|
||||
this.height = bounds.height();
|
||||
this.minimumY = bounds.minimumY();
|
||||
int width = Math.max(720, (configuration.maximumChunkX() - configuration.minimumChunkX() + 1) * 16 + LEFT + 16);
|
||||
for (int z = 0; z < 16; z++) {
|
||||
BufferedImage image = new BufferedImage(width, height * 2 + HEADER * 2 + 28, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = image.createGraphics();
|
||||
graphics.setColor(new Color(0xf1f4f7));
|
||||
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
|
||||
graphics.dispose();
|
||||
images[z] = image;
|
||||
lowestShape[z] = height;
|
||||
}
|
||||
}
|
||||
|
||||
private void column(int worldX, int localZ, Hunk<PlatformBlockState> terrain, Hunk<PlatformBlockState> generated,
|
||||
int localX, Terrain3DColumn shape, int baseHeight, ColumnEvidence observation) {
|
||||
BufferedImage image = images[localZ];
|
||||
int pixelX = LEFT + worldX - (configuration.minimumChunkX() << 4);
|
||||
for (int y = 0; y < height; y++) {
|
||||
image.setRGB(pixelX, HEADER + height - y - 1, color(terrain.get(localX, y, localZ), shape, baseHeight, y));
|
||||
image.setRGB(pixelX, HEADER * 2 + height * 2 - y - 1,
|
||||
color(generated.get(localX, y, localZ), shape, baseHeight, y));
|
||||
}
|
||||
if (observation != null) {
|
||||
scores[localZ] += observation.retainedCoveredGaps() * 1_000L + observation.retainedAdditiveBlocks();
|
||||
lowestShape[localZ] = Math.min(lowestShape[localZ], shape.minY());
|
||||
highestShape[localZ] = Math.max(highestShape[localZ], shape.topY());
|
||||
}
|
||||
}
|
||||
|
||||
private int color(PlatformBlockState block, Terrain3DColumn shape, int baseHeight, int y) {
|
||||
if (block == null || block.isAir()) {
|
||||
return shape != null && y <= shape.topY() && !shape.isSolid(y) ? 0xd8b8ce : 0xeaf1f7;
|
||||
}
|
||||
if (block.isFluid()) {
|
||||
return block.isWater() ? 0x328bbc : 0xe3762b;
|
||||
}
|
||||
if (shape != null && y > baseHeight && shape.isSolid(y)) {
|
||||
return 0xcf9b43;
|
||||
}
|
||||
String key = block.key();
|
||||
if (key.contains("grass") || key.contains("moss") || block.isTreeBlock()) {
|
||||
return 0x497b50;
|
||||
}
|
||||
if (key.contains("snow") || key.contains("ice")) {
|
||||
return 0xc5d8de;
|
||||
}
|
||||
return 0x58616c;
|
||||
}
|
||||
|
||||
private List<String> write() throws Exception {
|
||||
int selected = 8;
|
||||
for (int z = 0; z < scores.length; z++) {
|
||||
if (scores[z] > scores[selected]) {
|
||||
selected = z;
|
||||
}
|
||||
}
|
||||
return List.of(writeSection(selected, "section-z-"), writeSection(8, "fixed-section-z-"));
|
||||
}
|
||||
|
||||
private String writeSection(int selected, String prefix) throws Exception {
|
||||
int worldZ = (chunkZ << 4) + selected;
|
||||
BufferedImage image = images[selected];
|
||||
Graphics2D graphics = image.createGraphics();
|
||||
graphics.setColor(new Color(0x222a34));
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
|
||||
graphics.drawString(configuration.dimension() + " | seed " + configuration.seed() + " | Z " + worldZ
|
||||
+ " | X " + (configuration.minimumChunkX() << 4) + ".." + ((configuration.maximumChunkX() << 4) + 15), LEFT, 19);
|
||||
graphics.drawString("Terrain actuator output | gold: added terrain | mauve: shaped air | green: vegetation", LEFT, 39);
|
||||
graphics.drawString("Complete generated chunk output (all pack features enabled)", LEFT, height + HEADER + 34);
|
||||
graphics.drawString("Block geometry only; colors identify categories, not Minecraft rendering.", LEFT, image.getHeight() - 8);
|
||||
for (int y = 0; y < height; y += 64) {
|
||||
String label = Integer.toString(y + minimumY);
|
||||
graphics.drawString(label, 4, HEADER + height - y - 1);
|
||||
graphics.drawString(label, 4, HEADER * 2 + height * 2 - y - 1);
|
||||
}
|
||||
graphics.dispose();
|
||||
File file = new File(configuration.output(), prefix + worldZ + "-full.png");
|
||||
if (!ImageIO.write(image, "PNG", file)) {
|
||||
throw new IllegalStateException("No PNG writer is available.");
|
||||
}
|
||||
return writeFocused(image, selected, prefix);
|
||||
}
|
||||
|
||||
private String writeFocused(BufferedImage source, int selected, String prefix) throws Exception {
|
||||
int worldZ = (chunkZ << 4) + selected;
|
||||
int minimum = lowestShape[selected] == height ? 0 : Math.max(0, lowestShape[selected] - 16);
|
||||
int maximum = lowestShape[selected] == height ? height - 1 : Math.min(height - 1, highestShape[selected] + 16);
|
||||
int columns = (configuration.maximumChunkX() - configuration.minimumChunkX() + 1) * 16;
|
||||
int scale = Math.min(6, Math.max(1, 640 / columns));
|
||||
int panelHeight = (maximum - minimum + 1) * scale;
|
||||
int panelWidth = columns * scale;
|
||||
int focusedHeader = 80;
|
||||
BufferedImage focused = new BufferedImage(Math.max(720, panelWidth * 2 + LEFT * 2 + 24),
|
||||
panelHeight + focusedHeader + 28, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = focused.createGraphics();
|
||||
graphics.setColor(new Color(0xf1f4f7));
|
||||
graphics.fillRect(0, 0, focused.getWidth(), focused.getHeight());
|
||||
graphics.setColor(new Color(0x222a34));
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
|
||||
graphics.drawString(configuration.dimension() + " | seed " + configuration.seed() + " | Z " + worldZ
|
||||
+ " | X " + (configuration.minimumChunkX() << 4) + ".." + ((configuration.maximumChunkX() << 4) + 15), LEFT, 19);
|
||||
graphics.drawString("Gold: additions | mauve: shaped air | scale " + scale + " px/block", LEFT, 39);
|
||||
graphics.drawString("Terrain actuator output", LEFT, 64);
|
||||
graphics.drawString("Complete generation", LEFT * 2 + panelWidth, 64);
|
||||
for (int panel = 0; panel < 2; panel++) {
|
||||
int destinationLeft = LEFT + panel * (LEFT + panelWidth);
|
||||
int sourceTop = HEADER + panel * (HEADER + height) + height - maximum - 1;
|
||||
graphics.drawImage(source, destinationLeft, focusedHeader, destinationLeft + panelWidth, focusedHeader + panelHeight,
|
||||
LEFT, sourceTop, LEFT + columns, sourceTop + maximum - minimum + 1, null);
|
||||
for (int y = minimum; y <= maximum; y += 16) {
|
||||
graphics.drawString(Integer.toString(y + minimumY), destinationLeft - 44,
|
||||
focusedHeader + (maximum - y) * scale + 10);
|
||||
}
|
||||
}
|
||||
graphics.drawString("Actual block cross-section; colors do not represent Minecraft rendering.", LEFT, focused.getHeight() - 8);
|
||||
graphics.dispose();
|
||||
File output = new File(configuration.output(), prefix + worldZ + ".png");
|
||||
if (!ImageIO.write(focused, "PNG", output)) {
|
||||
throw new IllegalStateException("No PNG writer is available.");
|
||||
}
|
||||
return output.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.BitSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class Terrain3DProbeTest {
|
||||
@Test
|
||||
public void recognizesAddedRockAboveAnAirGapWithFloorAndRoof() {
|
||||
boolean[] column = column(12, 0, 3, 7, 9);
|
||||
Terrain3DProbe.ColumnEvidence evidence = Terrain3DProbe.inspectColumn(6, column, column, column, column);
|
||||
|
||||
assertEquals(3, evidence.additiveBlocks());
|
||||
assertEquals(3, evidence.retainedAdditiveBlocks());
|
||||
assertEquals(3, evidence.carvedBlocks());
|
||||
assertEquals(3, evidence.retainedCarvedBlocks());
|
||||
assertEquals(1, evidence.retainedCoveredGaps());
|
||||
assertEquals(3, evidence.maximumGapHeight());
|
||||
assertEquals(9, evidence.generatedTop());
|
||||
assertEquals(0, evidence.geometryMismatches());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doesNotCountCavesOrFloatingObjectsAsVolumetricTerrain() {
|
||||
boolean[] predicted = column(16, 0, 9);
|
||||
boolean[] generated = column(16, 0, 3, 7, 9, 13, 14);
|
||||
Terrain3DProbe.ColumnEvidence evidence = Terrain3DProbe.inspectColumn(
|
||||
9, predicted, predicted, generated, generated);
|
||||
|
||||
assertEquals(0, evidence.coveredGaps());
|
||||
assertEquals(0, evidence.retainedCoveredGaps());
|
||||
assertEquals(0, evidence.additiveBlocks());
|
||||
assertEquals(0, evidence.retainedAdditiveBlocks());
|
||||
assertEquals(5, evidence.finalGeometryDifferences());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsCoveredGapProofWhenLaterGenerationFillsAirOrRemovesTheRoof() {
|
||||
boolean[] predicted = column(12, 0, 3, 7, 9);
|
||||
boolean[] filled = predicted.clone();
|
||||
filled[5] = true;
|
||||
Terrain3DProbe.ColumnEvidence obstructed = Terrain3DProbe.inspectColumn(6, predicted, predicted, filled, predicted);
|
||||
boolean[] roofRemoved = predicted.clone();
|
||||
roofRemoved[7] = false;
|
||||
Terrain3DProbe.ColumnEvidence open = Terrain3DProbe.inspectColumn(6, predicted, predicted, roofRemoved, roofRemoved);
|
||||
|
||||
assertEquals(1, obstructed.coveredGaps());
|
||||
assertEquals(0, obstructed.retainedCoveredGaps());
|
||||
assertEquals(0, open.retainedCoveredGaps());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detectsOneBlockTerrainErrorsAndIgnoresSingleBlockCracksForOverhangCoverage() {
|
||||
boolean[] predicted = column(12, 0, 3, 5, 9);
|
||||
boolean[] actual = predicted.clone();
|
||||
actual[10] = true;
|
||||
Terrain3DProbe.ColumnEvidence evidence = Terrain3DProbe.inspectColumn(6, predicted, actual, actual, actual);
|
||||
|
||||
assertEquals(0, evidence.coveredGaps());
|
||||
assertEquals(1, evidence.geometryMismatches());
|
||||
assertEquals(10, evidence.generatedTop());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void requiresPositiveBiomeCoverageAndDoesNotPassAnEmptyScan() {
|
||||
assertFalse(Terrain3DProbe.coverageFailures(Map.of(), Set.of(), false).isEmpty());
|
||||
Terrain3DProbe.BiomeEvidence biome = new Terrain3DProbe.BiomeEvidence();
|
||||
biome.shapedColumns = 200;
|
||||
biome.retainedAdditiveBlocks = 4;
|
||||
Map<String, Terrain3DProbe.BiomeEvidence> evidence = Map.of("highlands", biome);
|
||||
|
||||
assertFalse(Terrain3DProbe.coverageFailures(evidence, Set.of("highlands"), false).isEmpty());
|
||||
biome.retainedCoveredGaps = 1;
|
||||
assertTrue(Terrain3DProbe.coverageFailures(evidence, Set.of("highlands"), false).isEmpty());
|
||||
assertFalse(Terrain3DProbe.coverageFailures(evidence, Set.of("missing"), false).isEmpty());
|
||||
biome.finalGeometryDifferences = 1;
|
||||
assertTrue(Terrain3DProbe.coverageFailures(evidence, Set.of("highlands"), false).isEmpty());
|
||||
assertFalse(Terrain3DProbe.coverageFailures(evidence, Set.of("highlands"), true).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnboundedOrMalformedProbeInputs() {
|
||||
String[] inputs = {"/tmp/pack", "overworld", "1337", "-1", "1", "-1", "1", "highlands", "/tmp/output", "false", "true"};
|
||||
Terrain3DProbe.Configuration configuration = Terrain3DProbe.Configuration.parse(inputs);
|
||||
assertEquals(9, configuration.chunkCount());
|
||||
assertTrue(configuration.studio());
|
||||
inputs[4] = "64";
|
||||
assertThrows(IllegalArgumentException.class, () -> Terrain3DProbe.Configuration.parse(inputs));
|
||||
inputs[4] = "1";
|
||||
inputs[7] = "highlands,";
|
||||
assertThrows(IllegalArgumentException.class, () -> Terrain3DProbe.Configuration.parse(inputs));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recordsTinyPerforationsAndThinRoofsWithoutCallingThemLargeOverhangs() {
|
||||
Terrain3DProbe.ColumnMorphology morphology = Terrain3DProbe.inspectMorphology(column(16, 0, 4, 6, 6, 10, 13));
|
||||
|
||||
assertEquals(3, morphology.solidSpans());
|
||||
assertEquals(4, morphology.coveredAirBlocks());
|
||||
assertArrayEquals(new long[]{1, 1, 0, 0, 0}, morphology.gapHeightBins());
|
||||
assertArrayEquals(new long[]{1, 0, 1, 0, 0}, morphology.upperSolidThicknessBins());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void countsOnlyFaceConnectedDetachedTerrainAndReportsRetention() {
|
||||
boolean[][][] voxels = terrainVolume(5, 5);
|
||||
voxels[1][1][7] = true;
|
||||
voxels[2][2][8] = true;
|
||||
Terrain3DProbe.TopologyEvidence evidence = inspectVolume(voxels, new VolumeOptions(false, false, true));
|
||||
|
||||
assertEquals(2, evidence.detachedComponents());
|
||||
assertEquals(2, evidence.detachedBlocks());
|
||||
assertEquals(1, evidence.retainedDetachedBlocks());
|
||||
assertEquals(1, evidence.fullyRetainedSmallComponents());
|
||||
assertArrayEquals(new long[]{2, 0, 0, 0}, evidence.detachedSizeBins());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void followsGroundSupportAcrossAChunkBoundary() {
|
||||
boolean[][][] voxels = terrainVolume(20, 5);
|
||||
for (int x = 14; x <= 17; x++) {
|
||||
voxels[x][2][8] = true;
|
||||
}
|
||||
Arrays.fill(voxels[16][2], 0, 9, true);
|
||||
Terrain3DProbe.TopologyEvidence evidence = inspectVolume(voxels, new VolumeOptions(false, false, false));
|
||||
|
||||
assertEquals(1, evidence.groundedComponents());
|
||||
assertEquals(0, evidence.detachedComponents());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void distinguishesThe64BlockBoundaryWithoutCullingAnything() {
|
||||
boolean[][][] voxels = terrainVolume(12, 12);
|
||||
for (int x = 1; x <= 8; x++) {
|
||||
for (int z = 1; z <= 8; z++) {
|
||||
voxels[x][z][7] = true;
|
||||
}
|
||||
}
|
||||
Terrain3DProbe.TopologyEvidence small = inspectVolume(voxels, new VolumeOptions(false, false, false));
|
||||
voxels[9][8][7] = true;
|
||||
Terrain3DProbe.TopologyEvidence large = inspectVolume(voxels, new VolumeOptions(false, false, false));
|
||||
|
||||
assertArrayEquals(new long[]{0, 1, 0, 0}, small.detachedSizeBins());
|
||||
assertEquals(1, small.fullyRetainedSmallComponents());
|
||||
assertArrayEquals(new long[]{0, 0, 1, 0}, large.detachedSizeBins());
|
||||
assertEquals(0, large.fullyRetainedSmallComponents());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void censorsTruncatedComponentsAndExcludesUnshapedAuthoredTerrain() {
|
||||
boolean[][][] edge = terrainVolume(5, 5);
|
||||
edge[0][2][7] = true;
|
||||
Terrain3DProbe.TopologyEvidence boundary = inspectVolume(edge, new VolumeOptions(false, false, false));
|
||||
boolean[][][] owned = terrainVolume(5, 5);
|
||||
owned[2][2][7] = true;
|
||||
Terrain3DProbe.TopologyEvidence ownership = inspectVolume(owned, new VolumeOptions(true, false, false));
|
||||
Terrain3DProbe.TopologyEvidence unshaped = inspectVolume(owned, new VolumeOptions(false, true, false));
|
||||
|
||||
assertEquals(1, boundary.censoredComponents());
|
||||
assertEquals(0, boundary.detachedComponents());
|
||||
assertEquals(1, ownership.censoredComponents());
|
||||
assertEquals(0, ownership.detachedComponents());
|
||||
assertEquals(0, unshaped.detachedComponents());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPartialTopologyEvidence() {
|
||||
Terrain3DProbe.TerrainTopology topology = new Terrain3DProbe.TerrainTopology(3, 3);
|
||||
topology.column(0, 0, new Terrain3DProbe.TopologyColumn(column(16, 0, 1), column(16, 0, 1), new BitSet(), true));
|
||||
|
||||
assertThrows(IllegalStateException.class, topology::inspect);
|
||||
}
|
||||
|
||||
private static boolean[][][] terrainVolume(int width, int depth) {
|
||||
boolean[][][] result = new boolean[width][depth][16];
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int z = 0; z < depth; z++) {
|
||||
result[x][z][0] = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Terrain3DProbe.TopologyEvidence inspectVolume(boolean[][][] voxels, VolumeOptions options) {
|
||||
Terrain3DProbe.TerrainTopology topology = new Terrain3DProbe.TerrainTopology(voxels.length, voxels[0].length);
|
||||
for (int x = 0; x < voxels.length; x++) {
|
||||
for (int z = 0; z < voxels[x].length; z++) {
|
||||
BitSet excluded = new BitSet();
|
||||
if (options.excludedNeighbor() && x == 3 && z == 2) {
|
||||
excluded.set(7);
|
||||
}
|
||||
boolean[] retained = voxels[x][z].clone();
|
||||
if (options.removeOneBlock() && x == 1 && z == 1) {
|
||||
retained[7] = false;
|
||||
}
|
||||
topology.column(x, z, new Terrain3DProbe.TopologyColumn(voxels[x][z], retained, excluded,
|
||||
!options.unshaped()));
|
||||
}
|
||||
}
|
||||
return topology.inspect();
|
||||
}
|
||||
|
||||
private record VolumeOptions(boolean excludedNeighbor, boolean unshaped, boolean removeOneBlock) {
|
||||
}
|
||||
|
||||
private static boolean[] column(int height, int... boundaries) {
|
||||
boolean[] result = new boolean[height];
|
||||
for (int index = 0; index < boundaries.length; index += 2) {
|
||||
Arrays.fill(result, boundaries[index], boundaries[index + 1] + 1, true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Ledges",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [{"palette": [{"block": "minecraft:stone"}]}],
|
||||
"generators": [{"generator": "flat", "min": 128, "max": 128}],
|
||||
"terrain3D": {
|
||||
"amplitude": 64,
|
||||
"horizontalScale": 96,
|
||||
"verticalScale": 16,
|
||||
"minimumSlope": 0,
|
||||
"slopeFade": 1,
|
||||
"fluidClearance": 0,
|
||||
"fluidFade": 1,
|
||||
"crackDepth": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Volumetric terrain fixture",
|
||||
"version": 1,
|
||||
"mode": {"type": "OVERWORLD"},
|
||||
"regions": ["ledges"],
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {"min": 0, "max": 256},
|
||||
"logicalHeight": 256,
|
||||
"fluidHeight": 0,
|
||||
"caveProfile": {"enabled": false}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"seed": 310,
|
||||
"interpolator": {"function": "NONE", "horizontalScale": 1},
|
||||
"composite": [{"seed": 310, "style": {"style": "FLAT"}}]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Ledges",
|
||||
"landBiomes": ["ledges"],
|
||||
"seaBiomes": ["ledges"],
|
||||
"shoreBiomes": ["ledges"]
|
||||
}
|
||||
Reference in New Issue
Block a user