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.")
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();
@@ -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;
@@ -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<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;
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;
@@ -40,12 +40,16 @@ public final class ProceduralTreeGenerator {
TreeBlockCanvas canvas = new TreeBlockCanvas();
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();
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) {
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<TreeBlockCanvas.Vec, BlockData> resolved = new HashMap<>();
Set<TreeBlockCanvas.Vec> trunkPositions = 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,
long baseSeed, List<int[]> branchEndpoints) {
int branchStartY, long baseSeed, List<int[]> 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<IrisTreeLayer> 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<int[]> 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;
}
}
@@ -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);
@@ -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<int[]> 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<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 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)));
}
}
@@ -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;
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<Limb> build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height) {
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 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<TreeBlockCanvas.Vec> 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());
}
@@ -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<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 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
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<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
public void plausibilityDistanceCapsAtSeven() {
Set<TreeBlockCanvas.Vec> trunk = new HashSet<>();