Procedurality

This commit is contained in:
Brian Neumann-Fopiano
2026-06-07 01:55:17 -04:00
parent febe930db0
commit 01d98d2141
13 changed files with 487 additions and 86 deletions
+1 -1
View File
@@ -1 +1 @@
-1963992339 1771127798
@@ -208,9 +208,33 @@ public class IrisProceduralTree {
@Desc("The normalized height where the secondary trunk band ends.") @Desc("The normalized height where the secondary trunk band ends.")
private double secondaryTrunkEnd = 1; 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; 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.") @Desc("The leaf crown configuration.")
private IrisTreeCanopy canopy = new IrisTreeCanopy(); private IrisTreeCanopy canopy = new IrisTreeCanopy();
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.object; package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc; 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.MinNumber;
import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.iris.engine.object.annotations.Snippet;
import lombok.AllArgsConstructor; 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.") @Desc("Elevation angle in degrees from horizontal. 0 is flat, positive points up, negative droops down.")
private double elevation = 0; 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).") @Desc("If true, branches are clamped to never droop below horizontal (useful for upright species).")
private boolean leafStartUp = false; private boolean leafStartUp = false;
@@ -54,6 +54,14 @@ public class IrisTreeCanopy {
@Desc("Fill probability used by the density and noise leaf modes.") @Desc("Fill probability used by the density and noise leaf modes.")
private double leafDensity = 0.85; 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) @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.") @Desc("Optional explicit crown discs. When provided these replace the profile-driven layers and let you sculpt the silhouette by hand.")
private KList<IrisTreeLayer> layers = new KList<>(); private KList<IrisTreeLayer> layers = new KList<>();
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.object; package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc; 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.MinNumber;
import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.iris.engine.object.annotations.Snippet;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
@@ -47,6 +48,11 @@ public class IrisTreeSubBranches {
@Desc("Sub-branch length as a fraction of its parent branch length.") @Desc("Sub-branch length as a fraction of its parent branch length.")
private double lengthScale = 0.5; 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) @MinNumber(0)
@Desc("Leaf ball radius placed at each sub-branch tip.") @Desc("Leaf ball radius placed at each sub-branch tip.")
private int clusterRadius = 1; private int clusterRadius = 1;
@@ -40,12 +40,16 @@ public final class ProceduralTreeGenerator {
TreeBlockCanvas canvas = new TreeBlockCanvas(); TreeBlockCanvas canvas = new TreeBlockCanvas();
long baseSeed = rng.getSeed(); long baseSeed = rng.getSeed();
double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); List<TreeTrunkBuilder.Limb> limbs = TreeTrunkBuilder.build(canvas, tree, height);
boolean wantEndpoints = tree.getDecorators() != null && !tree.getDecorators().isEmpty(); boolean wantEndpoints = tree.getDecorators() != null && !tree.getDecorators().isEmpty();
List<int[]> branchEndpoints = wantEndpoints ? new ArrayList<>() : null; List<int[]> 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) { if (wantEndpoints) {
TreeDecoratorApplier.apply(canvas, tree, baseSeed, branchEndpoints); TreeDecoratorApplier.apply(canvas, tree, baseSeed, branchEndpoints);
@@ -55,6 +59,10 @@ public final class ProceduralTreeGenerator {
TreeRootBuilder.build(canvas, tree, height, baseSeed); TreeRootBuilder.build(canvas, tree, height, baseSeed);
} }
if (tree.isPlausible()) {
TreeSupport.ensureLeavesSupported(canvas, 24);
}
Map<TreeBlockCanvas.Vec, BlockData> resolved = new HashMap<>(); Map<TreeBlockCanvas.Vec, BlockData> resolved = new HashMap<>();
Set<TreeBlockCanvas.Vec> trunkPositions = new HashSet<>(); Set<TreeBlockCanvas.Vec> trunkPositions = new HashSet<>();
Set<TreeBlockCanvas.Vec> leafPositions = new HashSet<>(); Set<TreeBlockCanvas.Vec> leafPositions = new HashSet<>();
@@ -35,7 +35,7 @@ public final class TreeCanopyBuilder {
} }
public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, double[][] offsets, public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, double[][] offsets,
long baseSeed, List<int[]> branchEndpoints) { int branchStartY, long baseSeed, List<int[]> branchEndpoints) {
IrisTreeCanopy canopy = tree.getCanopy(); IrisTreeCanopy canopy = tree.getCanopy();
if (canopy == null) { if (canopy == null) {
canopy = new IrisTreeCanopy(); canopy = new IrisTreeCanopy();
@@ -49,12 +49,32 @@ public final class TreeCanopyBuilder {
double[][] crown = new double[][]{layers[layers.length - 1]}; double[][] crown = new double[][]{layers[layers.length - 1]};
volumeCanopy(canvas, tree, crown, canopy, baseSeed, offsets); 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 { } else {
volumeCanopy(canvas, tree, layers, canopy, baseSeed, offsets); 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) { private static double[][] resolveLayers(IrisTreeCanopy canopy, IrisTreeProfile profile, int height, boolean branchDriven) {
KList<IrisTreeLayer> explicit = canopy.getLayers(); KList<IrisTreeLayer> explicit = canopy.getLayers();
if (explicit != null && !explicit.isEmpty()) { 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, private static void placeLeafDisc(TreeBlockCanvas canvas, IrisProceduralTree tree, int cx, int cy, int cz,
double radius, IrisTreeLeafMode mode, double density, long seed) { double radius, IrisTreeLeafMode mode, double density, long seed) {
double sx = stretchX(tree);
double sz = stretchZ(tree);
RNG rng = new RNG(seed); RNG rng = new RNG(seed);
int ir = (int) Math.ceil(radius); int irx = (int) Math.ceil(radius * sx);
for (int dx = -ir; dx <= ir; dx++) { int irz = (int) Math.ceil(radius * sz);
for (int dz = -ir; dz <= ir; dz++) { for (int dx = -irx; dx <= irx; dx++) {
double dist = Math.sqrt(dx * dx + dz * dz); for (int dz = -irz; dz <= irz; dz++) {
if (!passesLeafTest(mode, dist, radius, density, rng, cx + dx, cy, cz + dz, seed, Math.abs(dx) == ir && Math.abs(dz) == ir)) { 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; continue;
} }
canvas.setLeaf(cx + dx, cy, cz + dz, resolveLeaf(tree, rng)); 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, private static void placeLeafCluster(TreeBlockCanvas canvas, IrisProceduralTree tree, int cx, int cy, int cz,
int radius, IrisTreeLeafMode mode, double density, long seed) { int radius, IrisTreeLeafMode mode, double density, long seed) {
double sx = stretchX(tree);
double sz = stretchZ(tree);
RNG rng = new RNG(seed); 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 dy = -radius; dy <= radius; dy++) {
for (int dz = -radius; dz <= radius; dz++) { for (int dz = -irz; dz <= irz; dz++) {
double dist = Math.sqrt(dx * dx + dy * dy + dz * dz); double ddx = dx / sx;
boolean corner = Math.abs(dx) == radius && Math.abs(dz) == radius; 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)) { if (!passesLeafTest(mode, dist, radius, density, rng, cx + dx, cy + dy, cz + dz, seed, corner)) {
continue; 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, private static boolean passesLeafTest(IrisTreeLeafMode mode, double dist, double radius, double density,
RNG rng, int wx, int wy, int wz, long seed, boolean corner) { RNG rng, int wx, int wy, int wz, long seed, boolean corner) {
switch (mode) { switch (mode) {
@@ -216,14 +258,15 @@ public final class TreeCanopyBuilder {
} }
private static void branchCanopy(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, 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<int[]> branchEndpoints) { List<int[]> branchEndpoints) {
long branchSeed = baseSeed + 9999; long branchSeed = baseSeed + 9999;
RNG branchRng = new RNG(branchSeed); RNG branchRng = new RNG(branchSeed);
IrisTreeSubBranches sub = branches.getSubBranches(); IrisTreeSubBranches sub = branches.getSubBranches();
int depth = branches.getBranchDepth();
int branchIndex = 0; 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 t = y / (double) Math.max(height - 1, 1);
double p = TreeFunctions.branchProbability(branches, t, branchSeed); double p = TreeFunctions.branchProbability(branches, t, branchSeed);
if (branchRng.nextDouble() > p) { if (branchRng.nextDouble() > p) {
@@ -242,27 +285,34 @@ public final class TreeCanopyBuilder {
double branchLen = TreeFunctions.branchLength(branches, t); double branchLen = TreeFunctions.branchLength(branches, t);
double effElevation = branches.isLeafStartUp() ? Math.max(0.0, branches.getElevation()) : branches.getElevation(); double effElevation = branches.isLeafStartUp() ? Math.max(0.0, branches.getElevation()) : branches.getElevation();
int[] end = branchEndpoint(ox, y, oz, az, effElevation, branchLen); 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) { 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); branches.getClusterMode(), branches.getClusterDensity(), branchSeed + y);
if (sub != null) { growSubBranches(canvas, tree, tip[0], tip[1], tip[2], az, effElevation, branchLen, sub, depth, branchSeed + y + 1000L);
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; private static void growSubBranches(TreeBlockCanvas canvas, IrisProceduralTree tree, int ox, int oy, int oz,
double subEl = effElevation + sub.getPitchDelta(); double az, double el, double len, IrisTreeSubBranches sub, int depth, long seed) {
double subLen = branchLen * sub.getLengthScale(); if (sub == null || depth <= 0) {
int[] subEnd = branchEndpoint(end[0], end[1], end[2], subAz, subEl, subLen); return;
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(), int count = Math.max(1, sub.getCount());
sub.getClusterMode(), sub.getClusterDensity(), branchSeed + y + si + 1000L); 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)}; 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) { private static int[] rasterizeBranch(TreeBlockCanvas canvas, int ox, int oy, int oz, int ex, int ey, int ez, double sag) {
int steps = Math.max(Math.max(Math.abs(ex - ox), Math.abs(ey - oy)), Math.max(Math.abs(ez - oz), 1)); int dx = ex - ox;
TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(ex - ox, ey - oy, ez - oz); 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++) { for (int i = 0; i <= steps; i++) {
double t = i / (double) steps; double t = i / (double) steps;
int x = (int) Math.round(ox + (ex - ox) * t); int x = (int) Math.round(ox + dx * t);
int y = (int) Math.round(oy + (ey - oy) * t); int y = (int) Math.round(oy + dy * t - sagBlocks * t * t);
int z = (int) Math.round(oz + (ez - oz) * t); int z = (int) Math.round(oz + dz * t);
if (!canvas.has(x, y, z)) { if (!canvas.has(x, y, z)) {
canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis);
} }
tip = new int[]{x, y, z};
} }
return tip;
} }
} }
@@ -71,7 +71,7 @@ public final class TreeFunctions {
case LOG -> Math.log(1.0 + t * (Math.E - 1.0)); case LOG -> Math.log(1.0 + t * (Math.E - 1.0));
case SIGMOID -> 1.0 / (1.0 + Math.exp(-steepness * (t - 0.5))); case SIGMOID -> 1.0 / (1.0 + Math.exp(-steepness * (t - 0.5)));
case PARABOLIC, EASE_IN_OUT -> smoothstep(t); 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 SQRT -> Math.sqrt(t);
case STEP -> t < 0.5 ? 0.0 : 1.0; case STEP -> t < 0.5 ? 0.0 : 1.0;
case BELL -> bell(t); case BELL -> bell(t);
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.object.tree; package art.arcane.iris.engine.object.tree;
import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisProceduralTree;
import art.arcane.iris.engine.object.IrisTreeRootStyle;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import java.util.ArrayList; import java.util.ArrayList;
@@ -39,7 +40,7 @@ public final class TreeRootBuilder {
return; return;
} }
int depth = rootDepth(height); int depth = tree.getRootDepth() > 0 ? tree.getRootDepth() : autoDepth(height);
RNG rng = new RNG(baseSeed ^ 0x5009L); RNG rng = new RNG(baseSeed ^ 0x5009L);
double cx = 0; double cx = 0;
@@ -55,38 +56,54 @@ public final class TreeRootBuilder {
for (int[] c : cells) { for (int[] c : cells) {
baseRadius = Math.max(baseRadius, Math.hypot(c[0] - cx, c[1] - cz) + 0.5); 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<int[]> cells, double cx, double cz, double baseRadius, int depth) {
int icx = (int) Math.round(cx); int icx = (int) Math.round(cx);
int icz = (int) Math.round(cz); int icz = (int) Math.round(cz);
for (int k = 1; k <= depth; k++) { for (int k = 1; k <= depth; k++) {
double frac = k / (double) depth; double frac = k / (double) depth;
double keepR = baseRadius * (1.0 - 0.6 * frac); double keepR = baseRadius * (1.0 - 0.6 * frac);
for (int[] c : cells) { for (int[] c : cells) {
if (Math.hypot(c[0] - cx, c[1] - cz) <= keepR + 1e-6) { 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<int[]> 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 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++) { for (int li = 0; li < nLegs; li++) {
double ang = 2.0 * Math.PI * li / nLegs + rng.d(-0.25, 0.25); double ang = 2.0 * Math.PI * li / nLegs + rng.d(-0.25, 0.25);
double ca = Math.cos(ang); double ca = Math.cos(ang);
double sa = Math.sin(ang); double sa = Math.sin(ang);
int prevX = (int) Math.round(cx + baseRadius * ca); int prevX = (int) Math.round(cx + baseRadius * ca);
int prevZ = (int) Math.round(cz + baseRadius * sa); int prevZ = (int) Math.round(cz + baseRadius * sa);
for (int k = 1; k <= legLen; k++) { for (int k = 1; k <= depth; k++) {
double frac = k / (double) legLen; double frac = k / (double) depth;
double r = baseRadius + (flare - baseRadius) * frac; double r = baseRadius + (flare - baseRadius) * frac;
int x = (int) Math.round(cx + r * ca); int x = (int) Math.round(cx + r * ca);
int z = (int) Math.round(cz + r * sa); 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) { if (x != prevX || z != prevZ) {
placeRoot(canvas, prevX, -k, prevZ); placeRoot(canvas, prevX, -k, prevZ, TreeBlockCanvas.Axis.Y);
} }
prevX = x; prevX = x;
prevZ = z; prevZ = z;
@@ -94,13 +111,44 @@ public final class TreeRootBuilder {
} }
} }
private static void placeRoot(TreeBlockCanvas canvas, int x, int y, int z) { private static void stilts(TreeBlockCanvas canvas, double cx, double cz, double baseRadius, double flare,
if (!canvas.has(x, y, z)) { int height, int cellCount, RNG rng) {
canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, TreeBlockCanvas.Axis.Y); 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))); return Math.max(2, Math.min(16, (int) Math.round(0.18 * height)));
} }
} }
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<TreeBlockCanvas.Vec, Integer> 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()));
}
}
@@ -26,23 +26,71 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
public final class TreeTrunkBuilder { public final class TreeTrunkBuilder {
public record Limb(double[][] offsets, int branchStartY) {
}
private TreeTrunkBuilder() { private TreeTrunkBuilder() {
} }
public static double[][] build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height) { public static List<Limb> build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height) {
boolean hasSecondary = hasSecondaryTrunk(tree); boolean hasSecondary = hasSecondaryTrunk(tree);
double[][] mainOffsets = leanPath(tree, height);
int forks = Math.max(1, tree.getTrunkForks());
List<Limb> 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[][] offsets = new double[height][2];
double prevCx = 0.0;
double prevCz = 0.0;
for (int y = 0; y < height; y++) { for (int y = 0; y < height; y++) {
double[] lean = leanOffset(tree, y, height); double[] lean = leanOffset(tree, y, height);
double cx = lean[0]; offsets[y][0] = lean[0];
double cz = lean[1]; offsets[y][1] = lean[1];
offsets[y][0] = cx; }
offsets[y][1] = cz; 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); int w = widthAt(tree, y, height);
TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(cx - prevCx, 1.0, cz - prevCz); TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(cx - prevCx, 1.0, cz - prevCz);
TreeBlockCanvas.Role role = trunkRole(tree, hasSecondary, y, height); 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); canvas.setTrunk(xz[0], y, xz[1], role, axis);
} }
if (y > 0) { double shift = Math.hypot(cx - prevCx, cz - prevCz);
double shift = Math.hypot(cx - prevCx, cz - prevCz); if (shift > 1.0) {
if (shift > 1.0) { int steps = (int) Math.ceil(shift);
int steps = (int) Math.ceil(shift); for (int s = 1; s < steps; s++) {
for (int s = 1; s < steps; s++) { double tFill = s / (double) steps;
double tFill = s / (double) steps; double icx = prevCx + (cx - prevCx) * tFill;
double icx = prevCx + (cx - prevCx) * tFill; double icz = prevCz + (cz - prevCz) * tFill;
double icz = prevCz + (cz - prevCz) * tFill; int fillY = (int) Math.round(y - 1 + tFill);
int fillY = (int) Math.round(y - 1 + tFill); for (int[] xz : squarePositions(icx, icz, w)) {
for (int[] xz : squarePositions(icx, icz, w)) { if (!canvas.has(xz[0], fillY, xz[1])) {
if (!canvas.has(xz[0], fillY, xz[1])) { canvas.setTrunk(xz[0], fillY, xz[1], role, axis);
canvas.setTrunk(xz[0], fillY, xz[1], role, axis);
}
} }
} }
} }
@@ -72,14 +118,10 @@ public final class TreeTrunkBuilder {
prevCx = cx; prevCx = cx;
prevCz = cz; prevCz = cz;
} }
markExposedEnds(canvas);
return offsets;
} }
private static void markExposedEnds(TreeBlockCanvas canvas) { private static void markExposedEnds(TreeBlockCanvas canvas) {
List<TreeBlockCanvas.Vec> trunkPositions = new ArrayList<>(canvas.getTrunk()); for (TreeBlockCanvas.Vec v : new ArrayList<>(canvas.getTrunk())) {
for (TreeBlockCanvas.Vec v : trunkPositions) {
if (!canvas.has(v.x(), v.y() + 1, v.z()) || !canvas.has(v.x(), v.y() - 1, v.z())) { 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()); canvas.markExposed(v.x(), v.y(), v.z());
} }
@@ -15,6 +15,7 @@ import java.util.Set;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -68,8 +69,8 @@ public class ProceduralTreeGeneratorTest {
TreeBlockCanvas canvas = new TreeBlockCanvas(); TreeBlockCanvas canvas = new TreeBlockCanvas();
int height = 9; int height = 9;
double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height).get(0).offsets();
TreeCanopyBuilder.build(canvas, tree, height, offsets, 1234L, null); TreeCanopyBuilder.build(canvas, tree, height, offsets, 0, 1234L, null);
assertFalse("canopy produced no leaves", canvas.getLeaf().isEmpty()); assertFalse("canopy produced no leaves", canvas.getLeaf().isEmpty());
@@ -97,8 +98,8 @@ public class ProceduralTreeGeneratorTest {
tree.getCanopy().setMode(mode); tree.getCanopy().setMode(mode);
TreeBlockCanvas canvas = new TreeBlockCanvas(); TreeBlockCanvas canvas = new TreeBlockCanvas();
int height = 10; int height = 10;
double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height).get(0).offsets();
TreeCanopyBuilder.build(canvas, tree, height, offsets, 99L, null); TreeCanopyBuilder.build(canvas, tree, height, offsets, 0, 99L, null);
return canvas; return canvas;
} }
@@ -118,14 +119,63 @@ public class ProceduralTreeGeneratorTest {
TreeBlockCanvas canvas = new TreeBlockCanvas(); TreeBlockCanvas canvas = new TreeBlockCanvas();
int height = 14; int height = 14;
double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height).get(0).offsets();
List<int[]> endpoints = new java.util.ArrayList<>(); List<int[]> 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 recorded no endpoints", endpoints.isEmpty());
assertFalse("branch system placed no leaves", canvas.getLeaf().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<TreeTrunkBuilder.Limb> limbs = TreeTrunkBuilder.build(canvas, tree, height);
assertEquals("a 3-fork tree should yield 3 crown limbs", 3, limbs.size());
Set<Integer> 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 @Test
public void rootsExtendBelowTheBase() { public void rootsExtendBelowTheBase() {
IrisProceduralTree tree = new IrisProceduralTree(); IrisProceduralTree tree = new IrisProceduralTree();
@@ -167,6 +217,28 @@ public class ProceduralTreeGeneratorTest {
assertNull("disconnected leaf must remain unsupported", distances.get(far)); 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<TreeBlockCanvas.Vec, Integer> 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<TreeBlockCanvas.Vec, Integer> 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 @Test
public void plausibilityDistanceCapsAtSeven() { public void plausibilityDistanceCapsAtSeven() {
Set<TreeBlockCanvas.Vec> trunk = new HashSet<>(); Set<TreeBlockCanvas.Vec> trunk = new HashSet<>();