From c9786f2d23732760ca049bf518553d2d6d3b53e6 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 14 Oct 2026 21:38:00 -0400 Subject: [PATCH] Calibrated converted decorator density - Patch decorators converted from features that snap to a leaf-blocked heightmap lose the share of placements that land on tree canopy in the declaring biomes - Canopy is estimated from the tree-like features that run before the decoration in each declaring biome's vegetal step, using their expected per-chunk count and a typical crown footprint - Patches that snap below leaves or run before the trees keep their full density --- .../iris/pack/ingest/CanopyEstimate.java | 81 +++++++++++++++++++ .../iris/pack/ingest/ModFeatureIngester.java | 5 +- .../pack/ingest/PatchDecoratorConverter.java | 5 +- .../pack/ingest/VanillaPlacementChain.java | 25 ++++++ .../iris/pack/ingest/CanopyEstimateTest.java | 66 +++++++++++++++ .../ingest/FeatureConverterFormatTest.java | 25 +++++- .../pack/ingest/ModIngesterFeatureTest.java | 4 +- 7 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/pack/ingest/CanopyEstimate.java create mode 100644 core/src/test/java/art/arcane/iris/pack/ingest/CanopyEstimateTest.java diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/CanopyEstimate.java b/core/src/main/java/art/arcane/iris/pack/ingest/CanopyEstimate.java new file mode 100644 index 000000000..90ce2b477 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/ingest/CanopyEstimate.java @@ -0,0 +1,81 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.pack.ingest; + +import art.arcane.iris.generation.decoration.IrisDecorationStep; +import art.arcane.iris.pack.ingest.catalog.CatalogBiome; +import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature; +import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +final class CanopyEstimate { + static final double CROWN_COLUMNS = 20D; + private static final double UNKNOWN_TREE_COUNT = 1D; + private static final double COLUMNS_PER_CHUNK = 256D; + private static final int VEGETAL_STEP = IrisDecorationStep.VEGETAL_DECORATION.ordinal(); + + private final WorldgenCatalogIndex index; + private final TreeFeatures trees; + + CanopyEstimate(WorldgenCatalogIndex index) { + this.index = index; + this.trees = new TreeFeatures(index); + } + + double occlusion(String featureKey, Set declaringBiomes) { + if (declaringBiomes.isEmpty()) { + return 0D; + } + double total = 0D; + for (String biomeKey : declaringBiomes) { + Optional biome = index.biome(biomeKey); + total += biome.map((CatalogBiome value) -> canopyBefore(value, featureKey)).orElse(0D); + } + return total / declaringBiomes.size(); + } + + private double canopyBefore(CatalogBiome biome, String featureKey) { + if (biome.featureSteps().size() <= VEGETAL_STEP) { + return 0D; + } + List vegetal = biome.featureSteps().get(VEGETAL_STEP); + double treesPerChunk = 0D; + for (String candidate : vegetal) { + if (candidate.equals(featureKey)) { + break; + } + Optional feature = index.placedFeature(candidate); + if (feature.isPresent() && trees.isTree(feature.get())) { + treesPerChunk += treesPerChunk(feature.get()); + } + } + return 1D - Math.exp(-treesPerChunk * CROWN_COLUMNS / COLUMNS_PER_CHUNK); + } + + private static double treesPerChunk(CatalogPlacedFeature tree) { + try { + return VanillaPlacementChain.expectedCount(VanillaJson.placement(tree.placement())); + } catch (IngestConversionException unknownCount) { + return UNKNOWN_TREE_COUNT; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java index 863e9dd7c..5e656b795 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java @@ -41,6 +41,7 @@ final class ModFeatureIngester { private final IngestPackModel model; private final WorldgenCatalogIndex index; private final TreeFeatures trees; + private final CanopyEstimate canopy; private final Map plans = new TreeMap<>(); private final Set nativeKeys = new TreeSet<>(); private final Set vanillaScopedKeys = new TreeSet<>(); @@ -49,6 +50,7 @@ final class ModFeatureIngester { this.model = model; this.index = index; this.trees = new TreeFeatures(index); + this.canopy = new CanopyEstimate(index); Map> declaring = new TreeMap<>(); Map> steps = new TreeMap<>(); for (CatalogBiome biome : index.catalog().biomes()) { @@ -151,7 +153,8 @@ final class ModFeatureIngester { return new FeaturePlan(key, declaring, steps, deposits, null, ""); } if (PatchDecoratorConverter.handles(placed)) { - return new FeaturePlan(key, declaring, steps, null, PatchDecoratorConverter.convert(placed, index), ""); + return new FeaturePlan(key, declaring, steps, null, + PatchDecoratorConverter.convert(placed, index, canopy.occlusion(key, declaring)), ""); } } catch (IngestConversionException e) { return FeaturePlan.passthrough(key, declaring, steps, "generated natively: " + e.getMessage()); diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java b/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java index 83759d3f0..c995b732c 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java @@ -45,7 +45,7 @@ final class PatchDecoratorConverter { return type.equals(SIMPLE_BLOCK) || LEGACY_PATCH_TYPES.contains(type); } - static JsonObject convert(CatalogPlacedFeature feature, WorldgenCatalogIndex index) throws IngestConversionException { + static JsonObject convert(CatalogPlacedFeature feature, WorldgenCatalogIndex index, double canopy) throws IngestConversionException { String type = VanillaJson.namespaced(feature.featureType()); JsonObject config = VanillaJson.featureConfig(feature.config()); VanillaPlacementChain outer = VanillaPlacementChain.surfaceChain(VanillaJson.placement(feature.placement())); @@ -67,6 +67,9 @@ final class PatchDecoratorConverter { if (expected <= 0D) { throw new IngestConversionException("patch placement never places"); } + if (outer.leafBlocked()) { + expected *= 1D - Math.max(0D, Math.min(1D, canopy)); + } List palette = VanillaBlockStates.palette(provider); JsonObject decorator = new JsonObject(); decorator.addProperty("chance", IngestJson.round(columnCoverage(expected))); diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/VanillaPlacementChain.java b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaPlacementChain.java index 70624b952..c239cf879 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/VanillaPlacementChain.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/VanillaPlacementChain.java @@ -27,12 +27,14 @@ import java.util.Set; final class VanillaPlacementChain { private static final Set SURFACE_HEIGHTMAPS = Set.of("MOTION_BLOCKING", "MOTION_BLOCKING_NO_LEAVES", "WORLD_SURFACE", "WORLD_SURFACE_WG"); + private static final Set LEAF_BLOCKED_HEIGHTMAPS = Set.of("MOTION_BLOCKING", "WORLD_SURFACE", "WORLD_SURFACE_WG"); private static final int NOISE_SAMPLES = 201; private final Set whitelist = new LinkedHashSet<>(); private double expectedPlacements = 1D; private double surfaceHitChance = 1D; private boolean surface; + private boolean leafBlocked; private VanillaPlacementChain() { } @@ -67,6 +69,28 @@ final class VanillaPlacementChain { return surfaceHitChance; } + boolean leafBlocked() { + return leafBlocked; + } + + static double expectedCount(JsonArray placement) throws IngestConversionException { + double expected = 1D; + for (JsonElement modifier : placement) { + JsonObject object = VanillaJson.object(modifier, "placement modifier"); + switch (VanillaJson.type(object, "type")) { + case "minecraft:count", "minecraft:count_on_every_layer" -> expected *= Math.max(0D, VanillaIntProviders.stats(object.get("count")).mean()); + case "minecraft:rarity_filter" -> expected /= Math.max(1D, VanillaJson.number(object.get("chance"), "rarity_filter.chance")); + case "minecraft:random_chance" -> expected *= clampProbability(VanillaJson.number(object.get("chance"), "random_chance.chance")); + case "minecraft:noise_threshold_count" -> expected *= (VanillaJson.number(object.get("below_noise"), "noise_threshold_count.below_noise") + + VanillaJson.number(object.get("above_noise"), "noise_threshold_count.above_noise")) / 2D; + case "minecraft:noise_based_count" -> expected *= noiseBasedMean(object); + default -> { + } + } + } + return expected; + } + Set whitelist() { return whitelist; } @@ -99,6 +123,7 @@ final class VanillaPlacementChain { throw new IngestConversionException("heightmap " + heightmap + " is not a dry surface placement"); } surface = true; + leafBlocked = LEAF_BLOCKED_HEIGHTMAPS.contains(heightmap); } private void readVerticalOffset(JsonElement provider) throws IngestConversionException { diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/CanopyEstimateTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/CanopyEstimateTest.java new file mode 100644 index 000000000..53f6f1862 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/ingest/CanopyEstimateTest.java @@ -0,0 +1,66 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.pack.ingest; + +import art.arcane.iris.pack.ingest.catalog.CatalogBiome; +import art.arcane.iris.pack.ingest.catalog.CatalogNamespace; +import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature; +import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog; +import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; + +public class CanopyEstimateTest { + private static final double CANOPY = 1D - Math.exp(-10D * CanopyEstimate.CROWN_COLUMNS / 256D); + + @Test + public void flowersPlacedAfterTreesSeeTheTreeCanopy() { + CanopyEstimate estimate = new CanopyEstimate(WorldgenCatalogIndex.of(catalog())); + + assertEquals(CANOPY, estimate.occlusion("mod:flowers_after", Set.of("mod:woods")), 0.000001D); + assertEquals(0D, estimate.occlusion("mod:flowers_before", Set.of("mod:woods")), 0.000001D); + assertEquals(CANOPY / 2D, estimate.occlusion("mod:flowers_after", Set.of("mod:woods", "mod:meadow")), 0.000001D); + } + + private static WorldgenCatalog catalog() { + List biomes = List.of( + biome("mod:woods", List.of("mod:flowers_before", "mod:oak_trees", "mod:flowers_after")), + biome("mod:meadow", List.of("mod:flowers_after"))); + List features = new ArrayList<>(); + features.add(new CatalogPlacedFeature("mod:oak_trees", "mod:oak_trees", "minecraft:tree", JsonParser.parseString("{}"), + JsonParser.parseString("[{\"type\": \"minecraft:count\", \"count\": 10}, {\"type\": \"minecraft:in_square\"}," + + "{\"type\": \"minecraft:surface_water_depth_filter\", \"max_water_depth\": 0}, {\"type\": \"minecraft:biome\"}]"))); + return new WorldgenCatalog(WorldgenCatalog.FORMAT, "26.3", "fabric", 1L, "canopy", List.of(new CatalogNamespace("mod", "Mod", "1")), + biomes, features, List.of(), List.of(), List.of()); + } + + private static CatalogBiome biome(String key, List vegetal) { + List> steps = new ArrayList<>(11); + for (int index = 0; index < 11; index++) { + steps.add(index == ModIngestFixture.VEGETAL_STEP ? vegetal : List.of()); + } + return new CatalogBiome(key, 0.7F, 0.8F, true, List.of("minecraft:is_overworld"), steps, List.of(), null); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java index e4c5045e4..b1e79e0d2 100644 --- a/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java @@ -114,12 +114,29 @@ public class FeatureConverterFormatTest { + "{\"type\": \"minecraft:heightmap\", \"heightmap\": \"WORLD_SURFACE_WG\"}, {\"type\": \"minecraft:biome\"}," + "{\"type\": \"minecraft:count\", \"count\": 96}," + PATCH_TAIL + "]"); - JsonObject decorator = PatchDecoratorConverter.convert(lavender, index()); + JsonObject decorator = PatchDecoratorConverter.convert(lavender, index(), 0D); assertEquals(1.0D - Math.exp(-(15.0D * 96.0D * 0.25D) / 256.0D), decorator.get("chance").getAsDouble(), 0.000001D); assertTrue(decorator.get("chance").getAsDouble() < 1.0D); } + @Test + public void leafBlockedHeightmapsLoseTheCanopyShareOfTheirPlacements() throws Exception { + CatalogPlacedFeature lavender = feature(PatchDecoratorConverter.SIMPLE_BLOCK, + "{\"type\": \"minecraft:simple_block\", \"to_place\": {\"id\": \"examplemod:lavender\"}}", + "[{\"type\": \"minecraft:count\", \"count\": 4}, {\"type\": \"minecraft:in_square\"}," + + "{\"type\": \"minecraft:heightmap\", \"heightmap\": \"MOTION_BLOCKING\"}, {\"type\": \"minecraft:biome\"}]"); + CatalogPlacedFeature underLeaves = feature(PatchDecoratorConverter.SIMPLE_BLOCK, + "{\"type\": \"minecraft:simple_block\", \"to_place\": {\"id\": \"examplemod:lavender\"}}", + "[{\"type\": \"minecraft:count\", \"count\": 4}, {\"type\": \"minecraft:in_square\"}," + + "{\"type\": \"minecraft:heightmap\", \"heightmap\": \"MOTION_BLOCKING_NO_LEAVES\"}, {\"type\": \"minecraft:biome\"}]"); + + assertEquals(1.0D - Math.exp(-(4.0D * 0.5D) / 256.0D), + PatchDecoratorConverter.convert(lavender, index(), 0.5D).get("chance").getAsDouble(), 0.000001D); + assertEquals(1.0D - Math.exp(-4.0D / 256.0D), + PatchDecoratorConverter.convert(underLeaves, index(), 0.5D).get("chance").getAsDouble(), 0.000001D); + } + @Test public void unnamespacedTypesAndClampedCountsConvert() throws Exception { CatalogPlacedFeature flowers = feature("simple_block", "{\"type\": \"simple_block\", \"to_place\": {\"id\": \"minecraft:poppy\"}}", """ @@ -134,7 +151,7 @@ public class FeatureConverterFormatTest { {"type": "minecraft:biome"}] """); - JsonObject decorator = PatchDecoratorConverter.convert(flowers, index()); + JsonObject decorator = PatchDecoratorConverter.convert(flowers, index(), 0D); assertEquals(1.0D - Math.exp(-(0.5D * 0.2D * 0.25D) / 256.0D), decorator.get("chance").getAsDouble(), 0.000001D); assertEquals("minecraft:grass_block", decorator.getAsJsonArray("whitelist").get(0).getAsJsonObject().get("block").getAsString()); @@ -149,8 +166,8 @@ public class FeatureConverterFormatTest { "{\"type\": \"minecraft:simple_block\", \"to_place\": \"minecraft:flower_plain\"}", "[{\"type\": \"minecraft:heightmap\", \"heightmap\": \"MOTION_BLOCKING\"}]"); - assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(seagrass, index())); - IngestConversionException reference = assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(plains, index())); + assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(seagrass, index(), 0D)); + IngestConversionException reference = assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(plains, index(), 0D)); assertTrue(reference.getMessage(), reference.getMessage().contains("minecraft:flower_plain")); } diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java index d31c71408..24bfcf22b 100644 --- a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java @@ -123,7 +123,9 @@ public class ModIngesterFeatureTest { assertNotNull(mapleFlowers); assertNotNull(forestFlowers); assertEquals(2, forest.getAsJsonArray("decorators").size()); - assertEquals(1.0D - Math.exp(-(1.0D / 4.0D * 32.0D * 0.25D) / 256.0D), forestFlowers.get("chance").getAsDouble(), 0.000001D); + double mapleCanopy = 1.0D - Math.exp(-CanopyEstimate.CROWN_COLUMNS / 256.0D); + double openShare = 1.0D - mapleCanopy / 2.0D; + assertEquals(1.0D - Math.exp(-(1.0D / 4.0D * 32.0D * 0.25D * openShare) / 256.0D), forestFlowers.get("chance").getAsDouble(), 0.000001D); JsonArray palette = forestFlowers.getAsJsonArray("palette"); assertEquals(3, palette.get(0).getAsJsonObject().get("weight").getAsInt()); assertEquals("examplemod:glowbell", palette.get(1).getAsJsonObject().get("block").getAsString());