From 01d98d2141f1b665fe6eb911c9e90800878c391c Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 7 Jun 2026 01:55:17 -0400 Subject: [PATCH] Procedurality --- core/plugins/Iris/cache/instance | 2 +- .../engine/object/IrisProceduralTree.java | 26 +++- .../iris/engine/object/IrisTreeBranches.java | 11 ++ .../iris/engine/object/IrisTreeCanopy.java | 8 ++ .../iris/engine/object/IrisTreeRootStyle.java | 31 +++++ .../engine/object/IrisTreeSubBranches.java | 6 + .../object/tree/ProceduralTreeGenerator.java | 12 +- .../engine/object/tree/TreeCanopyBuilder.java | 130 +++++++++++++----- .../engine/object/tree/TreeFunctions.java | 2 +- .../engine/object/tree/TreeRootBuilder.java | 76 ++++++++-- .../iris/engine/object/tree/TreeSupport.java | 91 ++++++++++++ .../engine/object/tree/TreeTrunkBuilder.java | 94 +++++++++---- .../tree/ProceduralTreeGeneratorTest.java | 84 ++++++++++- 13 files changed, 487 insertions(+), 86 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeRootStyle.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java diff --git a/core/plugins/Iris/cache/instance b/core/plugins/Iris/cache/instance index 7e70949d1..a0ef9d4d8 100644 --- a/core/plugins/Iris/cache/instance +++ b/core/plugins/Iris/cache/instance @@ -1 +1 @@ --1963992339 \ No newline at end of file +1771127798 \ No newline at end of file diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java index 5fc9216ae..00a36c6f4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java @@ -208,9 +208,33 @@ public class IrisProceduralTree { @Desc("The normalized height where the secondary trunk band ends.") private double secondaryTrunkEnd = 1; - @Desc("If true, a tapering taproot and flared buttress legs extend below the base so the tree connects to the ground on uneven terrain.") + @Desc("If true, a root system extends below (and around) the base so the tree connects to the ground on uneven terrain.") private boolean roots = true; + @Desc("How the root system is built when roots is true.") + private IrisTreeRootStyle rootStyle = IrisTreeRootStyle.BUTTRESS; + + @MinNumber(0) + @Desc("Override the root depth/reach in blocks. 0 means auto (scales with tree height).") + private int rootDepth = 0; + + @MinNumber(0) + @Desc("Override the root flare radius in blocks. 0 means auto.") + private double rootFlare = 0; + + @MinNumber(1) + @MaxNumber(6) + @Desc("How many separate trunks the tree splits into above forkHeight. 1 is a single trunk.") + private int trunkForks = 1; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The normalized height where the trunk splits into forks.") + private double forkHeight = 0.5; + + @Desc("The outward lean angle in degrees of each fork.") + private double forkAngle = 25; + @Desc("The leaf crown configuration.") private IrisTreeCanopy canopy = new IrisTreeCanopy(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java index 74dfb6818..7b9cdc1ce 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java @@ -19,6 +19,7 @@ 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.AllArgsConstructor; @@ -101,6 +102,16 @@ public class IrisTreeBranches { @Desc("Elevation angle in degrees from horizontal. 0 is flat, positive points up, negative droops down.") private double elevation = 0; + @MinNumber(0) + @MaxNumber(1) + @Desc("How much each branch sags/droops along its length (catenary). 0 is a straight branch; higher values arc the branch and its tip downward.") + private double sag = 0; + + @MinNumber(0) + @MaxNumber(6) + @Desc("How many recursive levels of sub-branches to grow. 0 is none, 1 is a single level, 2+ produces fractal branching.") + private int branchDepth = 1; + @Desc("If true, branches are clamped to never droop below horizontal (useful for upright species).") private boolean leafStartUp = false; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java index 0a1e981f4..be8fee597 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java @@ -54,6 +54,14 @@ public class IrisTreeCanopy { @Desc("Fill probability used by the density and noise leaf modes.") private double leafDensity = 0.85; + @MinNumber(0.1) + @Desc("Horizontal X stretch of the crown for elliptical or wind-blown shapes. 1 is circular.") + private double crownStretchX = 1; + + @MinNumber(0.1) + @Desc("Horizontal Z stretch of the crown for elliptical or wind-blown shapes. 1 is circular.") + private double crownStretchZ = 1; + @ArrayType(min = 1, type = IrisTreeLayer.class) @Desc("Optional explicit crown discs. When provided these replace the profile-driven layers and let you sculpt the silhouette by hand.") private KList layers = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeRootStyle.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeRootStyle.java new file mode 100644 index 000000000..99b3aeafd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeRootStyle.java @@ -0,0 +1,31 @@ +/* + * 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.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How a procedural tree's root system is built below (and around) the trunk base.") +public enum IrisTreeRootStyle { + @Desc("A single tapering taproot straight down from the base.") + TAPROOT, + @Desc("A taproot plus a ring of flared buttress legs that splay outward as they descend (default).") + BUTTRESS, + @Desc("Visible above-ground prop roots that arch out and down from the lower trunk to the ground (mangrove style).") + STILT +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java index 19b45123d..d68190191 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java @@ -19,6 +19,7 @@ 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.AllArgsConstructor; @@ -47,6 +48,11 @@ public class IrisTreeSubBranches { @Desc("Sub-branch length as a fraction of its parent branch length.") private double lengthScale = 0.5; + @MinNumber(0) + @MaxNumber(1) + @Desc("How much each sub-branch sags/droops along its length (catenary).") + private double sag = 0; + @MinNumber(0) @Desc("Leaf ball radius placed at each sub-branch tip.") private int clusterRadius = 1; diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java index 35413bf7d..d89c6e501 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java @@ -40,12 +40,16 @@ public final class ProceduralTreeGenerator { TreeBlockCanvas canvas = new TreeBlockCanvas(); long baseSeed = rng.getSeed(); - double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); + List limbs = TreeTrunkBuilder.build(canvas, tree, height); boolean wantEndpoints = tree.getDecorators() != null && !tree.getDecorators().isEmpty(); List branchEndpoints = wantEndpoints ? new ArrayList<>() : null; - TreeCanopyBuilder.build(canvas, tree, height, offsets, baseSeed, branchEndpoints); + int limbIndex = 0; + for (TreeTrunkBuilder.Limb limb : limbs) { + TreeCanopyBuilder.build(canvas, tree, height, limb.offsets(), limb.branchStartY(), baseSeed + limbIndex * 131L, branchEndpoints); + limbIndex++; + } if (wantEndpoints) { TreeDecoratorApplier.apply(canvas, tree, baseSeed, branchEndpoints); @@ -55,6 +59,10 @@ public final class ProceduralTreeGenerator { TreeRootBuilder.build(canvas, tree, height, baseSeed); } + if (tree.isPlausible()) { + TreeSupport.ensureLeavesSupported(canvas, 24); + } + Map resolved = new HashMap<>(); Set trunkPositions = new HashSet<>(); Set leafPositions = new HashSet<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java index 6f869f0d7..ae59834af 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java @@ -35,7 +35,7 @@ public final class TreeCanopyBuilder { } public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, double[][] offsets, - long baseSeed, List branchEndpoints) { + int branchStartY, long baseSeed, List branchEndpoints) { IrisTreeCanopy canopy = tree.getCanopy(); if (canopy == null) { canopy = new IrisTreeCanopy(); @@ -49,12 +49,32 @@ public final class TreeCanopyBuilder { double[][] crown = new double[][]{layers[layers.length - 1]}; volumeCanopy(canvas, tree, crown, canopy, baseSeed, offsets); } - branchCanopy(canvas, tree, height, branches, baseSeed, offsets, branchEndpoints); + placeApexCap(canvas, tree, height, layers, offsets, baseSeed); + branchCanopy(canvas, tree, height, branches, branchStartY, baseSeed, offsets, branchEndpoints); } else { volumeCanopy(canvas, tree, layers, canopy, baseSeed, offsets); } } + private static void placeApexCap(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, double[][] layers, + double[][] offsets, long baseSeed) { + int ocx = 0; + int ocz = 0; + if (offsets != null && offsets.length > 0) { + ocx = (int) Math.round(offsets[offsets.length - 1][0]); + ocz = (int) Math.round(offsets[offsets.length - 1][1]); + } + double maxR = 2; + for (double[] layer : layers) { + maxR = Math.max(maxR, layer[1]); + } + int capRadius = Math.max(2, Math.min(3, (int) Math.round(maxR * 0.35))); + IrisTreeCanopy canopy = tree.getCanopy(); + IrisTreeLeafMode mode = canopy != null ? canopy.getMode() : IrisTreeLeafMode.TRIMMED; + double density = canopy != null ? canopy.getLeafDensity() : 1.0; + placeLeafCluster(canvas, tree, ocx, height - 1, ocz, capRadius, mode, density, baseSeed + 5555L); + } + private static double[][] resolveLayers(IrisTreeCanopy canopy, IrisTreeProfile profile, int height, boolean branchDriven) { KList explicit = canopy.getLayers(); if (explicit != null && !explicit.isEmpty()) { @@ -118,12 +138,18 @@ public final class TreeCanopyBuilder { private static void placeLeafDisc(TreeBlockCanvas canvas, IrisProceduralTree tree, int cx, int cy, int cz, double radius, IrisTreeLeafMode mode, double density, long seed) { + double sx = stretchX(tree); + double sz = stretchZ(tree); RNG rng = new RNG(seed); - int ir = (int) Math.ceil(radius); - for (int dx = -ir; dx <= ir; dx++) { - for (int dz = -ir; dz <= ir; dz++) { - double dist = Math.sqrt(dx * dx + dz * dz); - if (!passesLeafTest(mode, dist, radius, density, rng, cx + dx, cy, cz + dz, seed, Math.abs(dx) == ir && Math.abs(dz) == ir)) { + int irx = (int) Math.ceil(radius * sx); + int irz = (int) Math.ceil(radius * sz); + for (int dx = -irx; dx <= irx; dx++) { + for (int dz = -irz; dz <= irz; dz++) { + double ddx = dx / sx; + double ddz = dz / sz; + double dist = Math.sqrt(ddx * ddx + ddz * ddz); + boolean corner = Math.abs(dx) == irx && Math.abs(dz) == irz; + if (!passesLeafTest(mode, dist, radius, density, rng, cx + dx, cy, cz + dz, seed, corner)) { continue; } canvas.setLeaf(cx + dx, cy, cz + dz, resolveLeaf(tree, rng)); @@ -133,12 +159,18 @@ public final class TreeCanopyBuilder { private static void placeLeafCluster(TreeBlockCanvas canvas, IrisProceduralTree tree, int cx, int cy, int cz, int radius, IrisTreeLeafMode mode, double density, long seed) { + double sx = stretchX(tree); + double sz = stretchZ(tree); RNG rng = new RNG(seed); - for (int dx = -radius; dx <= radius; dx++) { + int irx = (int) Math.ceil(radius * sx); + int irz = (int) Math.ceil(radius * sz); + for (int dx = -irx; dx <= irx; dx++) { for (int dy = -radius; dy <= radius; dy++) { - for (int dz = -radius; dz <= radius; dz++) { - double dist = Math.sqrt(dx * dx + dy * dy + dz * dz); - boolean corner = Math.abs(dx) == radius && Math.abs(dz) == radius; + for (int dz = -irz; dz <= irz; dz++) { + double ddx = dx / sx; + double ddz = dz / sz; + double dist = Math.sqrt(ddx * ddx + dy * dy + ddz * ddz); + boolean corner = Math.abs(dx) == irx && Math.abs(dz) == irz; if (!passesLeafTest(mode, dist, radius, density, rng, cx + dx, cy + dy, cz + dz, seed, corner)) { continue; } @@ -148,6 +180,16 @@ public final class TreeCanopyBuilder { } } + private static double stretchX(IrisProceduralTree tree) { + IrisTreeCanopy c = tree.getCanopy(); + return c == null ? 1.0 : Math.max(0.1, c.getCrownStretchX()); + } + + private static double stretchZ(IrisProceduralTree tree) { + IrisTreeCanopy c = tree.getCanopy(); + return c == null ? 1.0 : Math.max(0.1, c.getCrownStretchZ()); + } + private static boolean passesLeafTest(IrisTreeLeafMode mode, double dist, double radius, double density, RNG rng, int wx, int wy, int wz, long seed, boolean corner) { switch (mode) { @@ -216,14 +258,15 @@ public final class TreeCanopyBuilder { } private static void branchCanopy(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, - IrisTreeBranches branches, long baseSeed, double[][] offsets, + IrisTreeBranches branches, int branchStartY, long baseSeed, double[][] offsets, List branchEndpoints) { long branchSeed = baseSeed + 9999; RNG branchRng = new RNG(branchSeed); IrisTreeSubBranches sub = branches.getSubBranches(); + int depth = branches.getBranchDepth(); int branchIndex = 0; - for (int y = 0; y < height; y++) { + for (int y = Math.max(0, branchStartY); y < height; y++) { double t = y / (double) Math.max(height - 1, 1); double p = TreeFunctions.branchProbability(branches, t, branchSeed); if (branchRng.nextDouble() > p) { @@ -242,27 +285,34 @@ public final class TreeCanopyBuilder { double branchLen = TreeFunctions.branchLength(branches, t); double effElevation = branches.isLeafStartUp() ? Math.max(0.0, branches.getElevation()) : branches.getElevation(); int[] end = branchEndpoint(ox, y, oz, az, effElevation, branchLen); + int[] tip = rasterizeBranch(canvas, ox, y, oz, end[0], end[1], end[2], branches.getSag()); - rasterizeBranch(canvas, ox, y, oz, end[0], end[1], end[2]); if (branchEndpoints != null) { - branchEndpoints.add(new int[]{end[0], end[1], end[2], ox, oz}); + branchEndpoints.add(new int[]{tip[0], tip[1], tip[2], ox, oz}); } - placeLeafCluster(canvas, tree, end[0], end[1], end[2], branches.getClusterRadius(), + placeLeafCluster(canvas, tree, tip[0], tip[1], tip[2], branches.getClusterRadius(), branches.getClusterMode(), branches.getClusterDensity(), branchSeed + y); - if (sub != null) { - int count = Math.max(1, sub.getCount()); - for (int si = 0; si < count; si++) { - double yawOffset = sub.getYawDelta() * (si - (count - 1) / 2.0); - double subAz = az + yawOffset; - double subEl = effElevation + sub.getPitchDelta(); - double subLen = branchLen * sub.getLengthScale(); - int[] subEnd = branchEndpoint(end[0], end[1], end[2], subAz, subEl, subLen); - rasterizeBranch(canvas, end[0], end[1], end[2], subEnd[0], subEnd[1], subEnd[2]); - placeLeafCluster(canvas, tree, subEnd[0], subEnd[1], subEnd[2], sub.getClusterRadius(), - sub.getClusterMode(), sub.getClusterDensity(), branchSeed + y + si + 1000L); - } - } + growSubBranches(canvas, tree, tip[0], tip[1], tip[2], az, effElevation, branchLen, sub, depth, branchSeed + y + 1000L); + } + } + + private static void growSubBranches(TreeBlockCanvas canvas, IrisProceduralTree tree, int ox, int oy, int oz, + double az, double el, double len, IrisTreeSubBranches sub, int depth, long seed) { + if (sub == null || depth <= 0) { + return; + } + int count = Math.max(1, sub.getCount()); + for (int si = 0; si < count; si++) { + double yawOffset = sub.getYawDelta() * (si - (count - 1) / 2.0); + double subAz = az + yawOffset; + double subEl = el + sub.getPitchDelta(); + double subLen = len * sub.getLengthScale(); + int[] end = branchEndpoint(ox, oy, oz, subAz, subEl, subLen); + int[] tip = rasterizeBranch(canvas, ox, oy, oz, end[0], end[1], end[2], sub.getSag()); + placeLeafCluster(canvas, tree, tip[0], tip[1], tip[2], sub.getClusterRadius(), + sub.getClusterMode(), sub.getClusterDensity(), seed + si * 131L); + growSubBranches(canvas, tree, tip[0], tip[1], tip[2], subAz, subEl, subLen, sub, depth - 1, seed + si * 131L + 17L); } } @@ -275,17 +325,27 @@ public final class TreeCanopyBuilder { return new int[]{ox + (int) Math.round(dx), oy + (int) Math.round(dy), oz + (int) Math.round(dz)}; } - private static void rasterizeBranch(TreeBlockCanvas canvas, int ox, int oy, int oz, int ex, int ey, int ez) { - int steps = Math.max(Math.max(Math.abs(ex - ox), Math.abs(ey - oy)), Math.max(Math.abs(ez - oz), 1)); - TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(ex - ox, ey - oy, ez - oz); + private static int[] rasterizeBranch(TreeBlockCanvas canvas, int ox, int oy, int oz, int ex, int ey, int ez, double sag) { + int dx = ex - ox; + int dy = ey - oy; + int dz = ez - oz; + double horizontal = Math.sqrt(dx * dx + dz * dz); + double sagBlocks = sag * horizontal; + int steps = Math.max(Math.max(Math.abs(dx), Math.abs(dz)), + Math.max((int) (Math.abs(dy) + Math.ceil(sagBlocks)), 1)); + TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(dx, dy, dz); + + int[] tip = new int[]{ox, oy, oz}; for (int i = 0; i <= steps; i++) { double t = i / (double) steps; - int x = (int) Math.round(ox + (ex - ox) * t); - int y = (int) Math.round(oy + (ey - oy) * t); - int z = (int) Math.round(oz + (ez - oz) * t); + int x = (int) Math.round(ox + dx * t); + int y = (int) Math.round(oy + dy * t - sagBlocks * t * t); + int z = (int) Math.round(oz + dz * t); if (!canvas.has(x, y, z)) { canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); } + tip = new int[]{x, y, z}; } + return tip; } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java index 671c36038..1c70b8522 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java @@ -71,7 +71,7 @@ public final class TreeFunctions { case LOG -> Math.log(1.0 + t * (Math.E - 1.0)); case SIGMOID -> 1.0 / (1.0 + Math.exp(-steepness * (t - 0.5))); case PARABOLIC, EASE_IN_OUT -> smoothstep(t); - case EXPONENTIAL -> t * t; + case EXPONENTIAL -> Math.pow(t, Math.max(1.0, steepness)); case SQRT -> Math.sqrt(t); case STEP -> t < 0.5 ? 0.0 : 1.0; case BELL -> bell(t); diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java index 287658a19..5302ed52f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.object.tree; import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeRootStyle; import art.arcane.volmlib.util.math.RNG; import java.util.ArrayList; @@ -39,7 +40,7 @@ public final class TreeRootBuilder { return; } - int depth = rootDepth(height); + int depth = tree.getRootDepth() > 0 ? tree.getRootDepth() : autoDepth(height); RNG rng = new RNG(baseSeed ^ 0x5009L); double cx = 0; @@ -55,38 +56,54 @@ public final class TreeRootBuilder { for (int[] c : cells) { baseRadius = Math.max(baseRadius, Math.hypot(c[0] - cx, c[1] - cz) + 0.5); } + double flare = tree.getRootFlare() > 0 ? tree.getRootFlare() : baseRadius + Math.max(2.0, depth * 0.6); + IrisTreeRootStyle style = tree.getRootStyle(); + switch (style) { + case TAPROOT -> taproot(canvas, cells, cx, cz, baseRadius, depth); + case BUTTRESS -> { + taproot(canvas, cells, cx, cz, baseRadius, depth); + buttress(canvas, cells, cx, cz, baseRadius, depth, flare, rng); + } + case STILT -> { + taproot(canvas, cells, cx, cz, baseRadius, Math.min(depth, 3)); + stilts(canvas, cx, cz, baseRadius, flare, height, cells.size(), rng); + } + } + } + + private static void taproot(TreeBlockCanvas canvas, List cells, double cx, double cz, double baseRadius, int depth) { int icx = (int) Math.round(cx); int icz = (int) Math.round(cz); - for (int k = 1; k <= depth; k++) { double frac = k / (double) depth; double keepR = baseRadius * (1.0 - 0.6 * frac); for (int[] c : cells) { if (Math.hypot(c[0] - cx, c[1] - cz) <= keepR + 1e-6) { - placeRoot(canvas, c[0], -k, c[1]); + placeRoot(canvas, c[0], -k, c[1], TreeBlockCanvas.Axis.Y); } } - placeRoot(canvas, icx, -k, icz); + placeRoot(canvas, icx, -k, icz, TreeBlockCanvas.Axis.Y); } + } + private static void buttress(TreeBlockCanvas canvas, List cells, double cx, double cz, double baseRadius, + int depth, double flare, RNG rng) { int nLegs = Math.max(4, Math.min(12, cells.size() + 2)); - int legLen = depth; - double flare = baseRadius + Math.max(2.0, depth * 0.6); for (int li = 0; li < nLegs; li++) { double ang = 2.0 * Math.PI * li / nLegs + rng.d(-0.25, 0.25); double ca = Math.cos(ang); double sa = Math.sin(ang); int prevX = (int) Math.round(cx + baseRadius * ca); int prevZ = (int) Math.round(cz + baseRadius * sa); - for (int k = 1; k <= legLen; k++) { - double frac = k / (double) legLen; + for (int k = 1; k <= depth; k++) { + double frac = k / (double) depth; double r = baseRadius + (flare - baseRadius) * frac; int x = (int) Math.round(cx + r * ca); int z = (int) Math.round(cz + r * sa); - placeRoot(canvas, x, -k, z); + placeRoot(canvas, x, -k, z, TreeBlockCanvas.Axis.Y); if (x != prevX || z != prevZ) { - placeRoot(canvas, prevX, -k, prevZ); + placeRoot(canvas, prevX, -k, prevZ, TreeBlockCanvas.Axis.Y); } prevX = x; prevZ = z; @@ -94,13 +111,44 @@ public final class TreeRootBuilder { } } - private static void placeRoot(TreeBlockCanvas canvas, int x, int y, int z) { - if (!canvas.has(x, y, z)) { - canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, TreeBlockCanvas.Axis.Y); + private static void stilts(TreeBlockCanvas canvas, double cx, double cz, double baseRadius, double flare, + int height, int cellCount, RNG rng) { + int stiltTop = Math.max(2, Math.min(7, (int) Math.round(0.25 * height))); + int nProps = Math.max(4, Math.min(10, cellCount + 2)); + for (int p = 0; p < nProps; p++) { + double ang = 2.0 * Math.PI * p / nProps + rng.d(-0.25, 0.25); + double ca = Math.cos(ang); + double sa = Math.sin(ang); + int sx = (int) Math.round(cx + baseRadius * ca); + int sz = (int) Math.round(cz + baseRadius * sa); + int fx = (int) Math.round(cx + flare * ca); + int fz = (int) Math.round(cz + flare * sa); + rasterizeRoot(canvas, sx, stiltTop, sz, fx, -2, fz); } } - private static int rootDepth(int height) { + private static void rasterizeRoot(TreeBlockCanvas canvas, int ox, int oy, int oz, int ex, int ey, int ez) { + int dx = ex - ox; + int dy = ey - oy; + int dz = ez - oz; + int steps = Math.max(Math.max(Math.abs(dx), Math.abs(dy)), Math.max(Math.abs(dz), 1)); + TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(dx, dy, dz); + for (int i = 0; i <= steps; i++) { + double t = i / (double) steps; + int x = (int) Math.round(ox + dx * t); + int y = (int) Math.round(oy + dy * t); + int z = (int) Math.round(oz + dz * t); + placeRoot(canvas, x, y, z, axis); + } + } + + private static void placeRoot(TreeBlockCanvas canvas, int x, int y, int z, TreeBlockCanvas.Axis axis) { + if (!canvas.has(x, y, z)) { + canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); + } + } + + private static int autoDepth(int height) { return Math.max(2, Math.min(16, (int) Math.round(0.18 * height))); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java new file mode 100644 index 000000000..340577976 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java @@ -0,0 +1,91 @@ +/* + * 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.engine.object.tree; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class TreeSupport { + private static final int LEGAL_DISTANCE = 6; + private static final int REACH_GAP = 4; + + private TreeSupport() { + } + + public static void ensureLeavesSupported(TreeBlockCanvas canvas, int maxTendrils) { + for (int iter = 0; iter < maxTendrils; iter++) { + Map dist = TreePlausibility.computeDistances(canvas.getTrunk(), canvas.getLeaf()); + + TreeBlockCanvas.Vec worstLeaf = null; + TreeBlockCanvas.Vec worstWood = null; + int worstGap = -1; + + for (TreeBlockCanvas.Vec leaf : new ArrayList<>(canvas.getLeaf())) { + Integer d = dist.get(leaf); + if (d != null && d <= LEGAL_DISTANCE) { + continue; + } + TreeBlockCanvas.Vec nearest = null; + int best = Integer.MAX_VALUE; + for (TreeBlockCanvas.Vec wood : canvas.getTrunk()) { + int cd = chebyshev(leaf, wood); + if (cd < best) { + best = cd; + nearest = wood; + if (cd <= 1) { + break; + } + } + } + if (nearest != null && best > worstGap) { + worstGap = best; + worstLeaf = leaf; + worstWood = nearest; + } + } + + if (worstLeaf == null) { + return; + } + growTendril(canvas, worstWood, worstLeaf); + } + } + + private static void growTendril(TreeBlockCanvas canvas, TreeBlockCanvas.Vec from, TreeBlockCanvas.Vec to) { + int dx = to.x() - from.x(); + int dy = to.y() - from.y(); + int dz = to.z() - from.z(); + int length = Math.max(Math.max(Math.abs(dx), Math.abs(dy)), Math.max(Math.abs(dz), 1)); + int steps = Math.max(1, length - REACH_GAP); + TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(dx, dy, dz); + + for (int i = 1; i <= steps; i++) { + double t = i / (double) length; + int x = from.x() + (int) Math.round(dx * t); + int y = from.y() + (int) Math.round(dy * t); + int z = from.z() + (int) Math.round(dz * t); + canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); + } + } + + private static int chebyshev(TreeBlockCanvas.Vec a, TreeBlockCanvas.Vec b) { + return Math.max(Math.max(Math.abs(a.x() - b.x()), Math.abs(a.y() - b.y())), Math.abs(a.z() - b.z())); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java index 4f33e75b0..e69365a12 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java @@ -26,23 +26,71 @@ import java.util.ArrayList; import java.util.List; public final class TreeTrunkBuilder { + public record Limb(double[][] offsets, int branchStartY) { + } + private TreeTrunkBuilder() { } - public static double[][] build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height) { + public static List build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height) { boolean hasSecondary = hasSecondaryTrunk(tree); + double[][] mainOffsets = leanPath(tree, height); + int forks = Math.max(1, tree.getTrunkForks()); + List limbs = new ArrayList<>(); + if (forks == 1) { + placeColumn(canvas, tree, mainOffsets, height, 0, height - 1, hasSecondary); + limbs.add(new Limb(mainOffsets, 0)); + markExposedEnds(canvas); + return limbs; + } + + int forkY = Math.max(1, Math.min(height - 2, (int) Math.round(tree.getForkHeight() * (height - 1)))); + placeColumn(canvas, tree, mainOffsets, height, 0, forkY, hasSecondary); + + double baseCx = mainOffsets[forkY][0]; + double baseCz = mainOffsets[forkY][1]; + double reachMax = (height - forkY) * Math.tan(Math.toRadians(tree.getForkAngle())); + + for (int f = 0; f < forks; f++) { + double az = Math.toRadians((360.0 / forks) * f); + double[][] forkOffsets = new double[height][2]; + for (int y = 0; y <= forkY; y++) { + forkOffsets[y][0] = mainOffsets[y][0]; + forkOffsets[y][1] = mainOffsets[y][1]; + } + for (int y = forkY + 1; y < height; y++) { + double p = (y - forkY) / (double) Math.max(1, height - 1 - forkY); + double reach = reachMax * p; + forkOffsets[y][0] = baseCx + reach * Math.sin(az); + forkOffsets[y][1] = baseCz + reach * Math.cos(az); + } + placeColumn(canvas, tree, forkOffsets, height, forkY + 1, height - 1, hasSecondary); + limbs.add(new Limb(forkOffsets, forkY)); + } + + markExposedEnds(canvas); + return limbs; + } + + private static double[][] leanPath(IrisProceduralTree tree, int height) { double[][] offsets = new double[height][2]; - double prevCx = 0.0; - double prevCz = 0.0; - for (int y = 0; y < height; y++) { double[] lean = leanOffset(tree, y, height); - double cx = lean[0]; - double cz = lean[1]; - offsets[y][0] = cx; - offsets[y][1] = cz; + offsets[y][0] = lean[0]; + offsets[y][1] = lean[1]; + } + return offsets; + } + private static void placeColumn(TreeBlockCanvas canvas, IrisProceduralTree tree, double[][] offsets, int height, + int yStart, int yEnd, boolean hasSecondary) { + double prevCx = yStart > 0 ? offsets[yStart - 1][0] : 0.0; + double prevCz = yStart > 0 ? offsets[yStart - 1][1] : 0.0; + + for (int y = yStart; y <= yEnd; y++) { + double cx = offsets[y][0]; + double cz = offsets[y][1]; int w = widthAt(tree, y, height); TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(cx - prevCx, 1.0, cz - prevCz); TreeBlockCanvas.Role role = trunkRole(tree, hasSecondary, y, height); @@ -51,19 +99,17 @@ public final class TreeTrunkBuilder { canvas.setTrunk(xz[0], y, xz[1], role, axis); } - if (y > 0) { - double shift = Math.hypot(cx - prevCx, cz - prevCz); - if (shift > 1.0) { - int steps = (int) Math.ceil(shift); - for (int s = 1; s < steps; s++) { - double tFill = s / (double) steps; - double icx = prevCx + (cx - prevCx) * tFill; - double icz = prevCz + (cz - prevCz) * tFill; - int fillY = (int) Math.round(y - 1 + tFill); - for (int[] xz : squarePositions(icx, icz, w)) { - if (!canvas.has(xz[0], fillY, xz[1])) { - canvas.setTrunk(xz[0], fillY, xz[1], role, axis); - } + double shift = Math.hypot(cx - prevCx, cz - prevCz); + if (shift > 1.0) { + int steps = (int) Math.ceil(shift); + for (int s = 1; s < steps; s++) { + double tFill = s / (double) steps; + double icx = prevCx + (cx - prevCx) * tFill; + double icz = prevCz + (cz - prevCz) * tFill; + int fillY = (int) Math.round(y - 1 + tFill); + for (int[] xz : squarePositions(icx, icz, w)) { + if (!canvas.has(xz[0], fillY, xz[1])) { + canvas.setTrunk(xz[0], fillY, xz[1], role, axis); } } } @@ -72,14 +118,10 @@ public final class TreeTrunkBuilder { prevCx = cx; prevCz = cz; } - - markExposedEnds(canvas); - return offsets; } private static void markExposedEnds(TreeBlockCanvas canvas) { - List trunkPositions = new ArrayList<>(canvas.getTrunk()); - for (TreeBlockCanvas.Vec v : trunkPositions) { + for (TreeBlockCanvas.Vec v : new ArrayList<>(canvas.getTrunk())) { if (!canvas.has(v.x(), v.y() + 1, v.z()) || !canvas.has(v.x(), v.y() - 1, v.z())) { canvas.markExposed(v.x(), v.y(), v.z()); } diff --git a/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java index 7bb9e7b01..8743b4ba5 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java @@ -15,6 +15,7 @@ import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -68,8 +69,8 @@ public class ProceduralTreeGeneratorTest { TreeBlockCanvas canvas = new TreeBlockCanvas(); int height = 9; - double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); - TreeCanopyBuilder.build(canvas, tree, height, offsets, 1234L, null); + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height).get(0).offsets(); + TreeCanopyBuilder.build(canvas, tree, height, offsets, 0, 1234L, null); assertFalse("canopy produced no leaves", canvas.getLeaf().isEmpty()); @@ -97,8 +98,8 @@ public class ProceduralTreeGeneratorTest { tree.getCanopy().setMode(mode); TreeBlockCanvas canvas = new TreeBlockCanvas(); int height = 10; - double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); - TreeCanopyBuilder.build(canvas, tree, height, offsets, 99L, null); + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height).get(0).offsets(); + TreeCanopyBuilder.build(canvas, tree, height, offsets, 0, 99L, null); return canvas; } @@ -118,14 +119,63 @@ public class ProceduralTreeGeneratorTest { TreeBlockCanvas canvas = new TreeBlockCanvas(); int height = 14; - double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height).get(0).offsets(); List endpoints = new java.util.ArrayList<>(); - TreeCanopyBuilder.build(canvas, tree, height, offsets, 777L, endpoints); + TreeCanopyBuilder.build(canvas, tree, height, offsets, 0, 777L, endpoints); assertFalse("branch system recorded no endpoints", endpoints.isEmpty()); assertFalse("branch system placed no leaves", canvas.getLeaf().isEmpty()); } + @Test + public void trunkForksProduceMultipleLimbs() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunk("minecraft:oak_log"); + tree.setTrunkForks(3); + tree.setForkHeight(0.4); + tree.setForkAngle(30); + + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int height = 18; + List limbs = TreeTrunkBuilder.build(canvas, tree, height); + + assertEquals("a 3-fork tree should yield 3 crown limbs", 3, limbs.size()); + Set topXs = new HashSet<>(); + for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { + if (v.y() == height - 1) { + topXs.add(v.x()); + } + } + assertTrue("forks should spread the trunk into separate tops", topXs.size() >= 2); + } + + @Test + public void recursiveBranchesAddMoreWood() { + int shallow = branchWoodCount(1); + int deep = branchWoodCount(3); + assertTrue("deeper recursion should add more branch wood", deep > shallow); + } + + private int branchWoodCount(int depth) { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunk("minecraft:oak_log"); + tree.setLeaves("minecraft:oak_leaves"); + IrisTreeBranches branches = new IrisTreeBranches(); + branches.setProbabilityFunction(IrisTreeBranchProbability.CONSTANT); + branches.setProbabilityConstant(1.0); + branches.setLengthFunction(IrisTreeFunction.CONSTANT); + branches.setLengthConstant(4); + branches.setBranchDepth(depth); + branches.setSubBranches(new art.arcane.iris.engine.object.IrisTreeSubBranches()); + tree.getCanopy().setBranches(branches); + + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int height = 14; + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height).get(0).offsets(); + TreeCanopyBuilder.build(canvas, tree, height, offsets, 0, 555L, new java.util.ArrayList<>()); + return canvas.getTrunk().size(); + } + @Test public void rootsExtendBelowTheBase() { IrisProceduralTree tree = new IrisProceduralTree(); @@ -167,6 +217,28 @@ public class ProceduralTreeGeneratorTest { assertNull("disconnected leaf must remain unsupported", distances.get(far)); } + @Test + public void supportTendrilsMakeFarLeavesLegal() { + TreeBlockCanvas canvas = new TreeBlockCanvas(); + canvas.setTrunk(0, 0, 0, TreeBlockCanvas.Role.TRUNK, TreeBlockCanvas.Axis.Y); + for (int x = 1; x <= 14; x++) { + canvas.setLeaf(x, 0, 0, TreeBlockCanvas.Role.LEAF); + } + + Map before = TreePlausibility.computeDistances(canvas.getTrunk(), canvas.getLeaf()); + assertNull("the far leaf should start unsupported", before.get(new TreeBlockCanvas.Vec(14, 0, 0))); + + TreeSupport.ensureLeavesSupported(canvas, 24); + + Map after = TreePlausibility.computeDistances(canvas.getTrunk(), canvas.getLeaf()); + assertFalse("leaves should remain after support", canvas.getLeaf().isEmpty()); + for (TreeBlockCanvas.Vec leaf : canvas.getLeaf()) { + Integer d = after.get(leaf); + assertNotNull("every leaf must be reachable from wood after support", d); + assertTrue("every leaf must be within legal decay distance after support", d <= 6); + } + } + @Test public void plausibilityDistanceCapsAtSeven() { Set trunk = new HashSet<>();