mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-07-20 13:54:20 +00:00
Procedurality
This commit is contained in:
Vendored
+1
-1
@@ -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());
|
||||
}
|
||||
|
||||
+78
-6
@@ -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<>();
|
||||
|
||||
Reference in New Issue
Block a user