Refined featurette edge behavior

- Keep coral tips attached to the fan silhouette
- Preserve large leaf weights and disable zero threshold ores
- Reuse formation palette sources across each object
This commit is contained in:
Brian Neumann-Fopiano
2026-09-06 03:58:21 -04:00
9 changed files with 290 additions and 20 deletions
@@ -58,7 +58,7 @@ public class IrisOreGenerator {
}
public PlatformBlockState generate(int x, int y, int z, RNG rng, IrisData data) {
if (!range.contains(y)) {
if (threshold <= 0 || !range.contains(y)) {
return null;
}
@@ -88,16 +88,18 @@ public final class CoralGenerator {
private static void buildFan(CoralCanvas canvas, IrisCoral coral, int height, long seed) {
int half = Math.max(1, coral.getFanWidth());
for (int y = 0; y < height; y++) {
int tippedWidth = -1;
for (int y = height - 1; y >= 0; y--) {
double t = y / (double) Math.max(1, height - 1);
double profile = Math.sin(Math.PI * t);
int w = (int) Math.round(half * profile);
for (int dx = -w; dx <= w; dx++) {
canvas.set(dx, y, 0, CoralCanvas.Role.STRUCTURE);
if (Math.abs(dx) > tippedWidth) {
placeTip(canvas, coral, dx, y, 0, seed + dx);
}
}
}
for (int dx = -half; dx <= half; dx++) {
placeTip(canvas, coral, dx, height - 1, 0, seed + dx);
tippedWidth = Math.max(tippedWidth, w);
}
}
@@ -25,35 +25,48 @@ import art.arcane.iris.engine.object.IrisProceduralBlocks;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.Vector3i;
import art.arcane.volmlib.util.math.RNG;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
public final class FormationBlockResolver {
private FormationBlockResolver() {
private final IrisFormation formation;
private final IrisData data;
private final RNG paletteRng;
private final Int2ObjectOpenHashMap<RNG> strataRngs = new Int2ObjectOpenHashMap<>();
public FormationBlockResolver(IrisFormation formation, IrisData data) {
this.formation = formation;
this.data = data;
paletteRng = new RNG(formation.getSeed());
}
public static PlatformBlockState resolve(IrisFormation f, IrisData data, FormationCanvas.Role role, Vector3i raw) {
public PlatformBlockState resolve(FormationCanvas.Role role, Vector3i raw) {
int x = raw.getBlockX();
int y = raw.getBlockY();
int z = raw.getBlockZ();
RNG paletteRng = new RNG(f.getSeed());
if (role == FormationCanvas.Role.CAP && capDefined(f)) {
PlatformBlockState cap = IrisProceduralBlocks.resolve(f.getCapBlock(), f.getCapPalette(), data, x, y, z, paletteRng);
if (role == FormationCanvas.Role.CAP && capDefined(formation)) {
PlatformBlockState cap = IrisProceduralBlocks.resolve(formation.getCapBlock(), formation.getCapPalette(), data, x, y, z, paletteRng);
if (cap != null) {
return cap;
}
}
if (strataDefined(f)) {
IrisMaterialPalette strata = f.getStrataPalette();
int thickness = Math.max(1, f.getStrataThickness());
if (strataDefined(formation)) {
IrisMaterialPalette strata = formation.getStrataPalette();
int thickness = Math.max(1, formation.getStrataThickness());
int band = Math.floorDiv(y, thickness);
PlatformBlockState strataState = strata.get(new RNG(f.getSeed() + (band * 31L)), x, band, z, data);
RNG strataRng = strataRngs.get(band);
if (strataRng == null) {
strataRng = new RNG(formation.getSeed() + band * 31L);
strataRngs.put(band, strataRng);
}
PlatformBlockState strataState = strata.get(strataRng, x, band, z, data);
if (strataState != null) {
return strataState;
}
}
return IrisProceduralBlocks.resolve(f.getBlock(), f.getBlockPalette(), data, x, y, z, paletteRng);
return IrisProceduralBlocks.resolve(formation.getBlock(), formation.getBlockPalette(), data, x, y, z, paletteRng);
}
private static boolean capDefined(IrisFormation f) {
@@ -62,8 +62,9 @@ public final class FormationGenerator {
}
Map<Vector3i, PlatformBlockState> resolved = new HashMap<>();
FormationBlockResolver resolver = new FormationBlockResolver(f, data);
for (Map.Entry<Vector3i, FormationCanvas.Role> entry : canvas.getCells().entrySet()) {
PlatformBlockState bd = FormationBlockResolver.resolve(f, data, entry.getValue(), entry.getKey());
PlatformBlockState bd = resolver.resolve(entry.getValue(), entry.getKey());
if (bd == null) {
continue;
}
@@ -23,6 +23,7 @@ import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisProceduralBlocks;
import art.arcane.iris.engine.object.IrisProceduralTree;
import art.arcane.iris.engine.object.IrisTreeDecorator;
import art.arcane.iris.engine.object.IrisTreeSecondaryLeaf;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B;
import art.arcane.volmlib.util.math.RNG;
@@ -106,16 +107,16 @@ public final class TreeBlockResolver {
}
private static String pickWeighted(IrisProceduralTree tree, RNG rng) {
int total = 0;
for (art.arcane.iris.engine.object.IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) {
long total = 0;
for (IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) {
total += Math.max(0, s.getWeight());
}
if (total <= 0) {
return null;
}
double r = rng.nextDouble() * total;
double cumulative = 0;
for (art.arcane.iris.engine.object.IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) {
long cumulative = 0;
for (IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) {
cumulative += Math.max(0, s.getWeight());
if (r < cumulative) {
return s.getBlock();
@@ -0,0 +1,66 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class IrisOreGeneratorTest {
@Test
public void zeroThresholdDisablesEvenZeroNoiseSamples() {
Fixture fixture = new Fixture(0.0);
fixture.generator.setThreshold(0.0);
assertNull(fixture.generator.generate(2, 40, 3, fixture.rng, null));
verifyNoInteractions(fixture.noise, fixture.style);
}
@Test
public void positiveThresholdStillIncludesExactCutoffAndVerticalBounds() {
Fixture fixture = new Fixture(0.5);
fixture.generator.setThreshold(0.5);
assertSame(fixture.ore, fixture.generator.generate(2, 30, 3, fixture.rng, null));
assertSame(fixture.ore, fixture.generator.generate(2, 80, 3, fixture.rng, null));
assertNull(fixture.generator.generate(2, 29, 3, fixture.rng, null));
assertNull(fixture.generator.generate(2, 81, 3, fixture.rng, null));
fixture.generator.setThreshold(0.49);
assertNull(fixture.generator.generate(2, 40, 3, fixture.rng, null));
}
@Test
public void fullThresholdIncludesMaximumNoise() {
Fixture fixture = new Fixture(1.0);
fixture.generator.setThreshold(1.0);
assertSame(fixture.ore, fixture.generator.generate(2, 40, 3, fixture.rng, null));
}
private static final class Fixture {
private final RNG rng = new RNG(34L);
private final PlatformBlockState ore = mock(PlatformBlockState.class);
private final CNG noise = mock(CNG.class);
private final IrisGeneratorStyle style = mock(IrisGeneratorStyle.class);
private final IrisOreGenerator generator = new IrisOreGenerator();
private Fixture(double sample) {
IrisMaterialPalette palette = mock(IrisMaterialPalette.class);
when(palette.getPalette()).thenReturn(new KList<>(mock(IrisBlockData.class)));
when(palette.get(any(), anyDouble(), anyDouble(), anyDouble(), isNull())).thenReturn(ore);
when(noise.noise(anyDouble(), anyDouble(), anyDouble())).thenReturn(sample);
when(style.create(any(), isNull())).thenReturn(noise);
generator.setPalette(palette);
generator.setChanceStyle(style);
}
}
}
@@ -0,0 +1,72 @@
package art.arcane.iris.engine.object.coral;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.engine.object.IrisCoral;
import art.arcane.iris.engine.object.IrisCoralForm;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.IrisBlockVector;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class CoralGeneratorTest {
@Test
public void fanTipsFollowTheTopOfEachOccupiedColumn() {
PlatformBlockState structure = mock(PlatformBlockState.class);
PlatformBlockState tip = mock(PlatformBlockState.class);
for (int height : new int[]{2, 7, 14}) {
for (int width : new int[]{1, 3, 11}) {
IrisCoral coral = new IrisCoral().setForm(IrisCoralForm.FAN)
.setHeightMin(height).setHeightMax(height).setFanWidth(width)
.setWaterlogged(false).setBlockPalette(palette(structure));
IrisObject bare = CoralGenerator.generate(coral, 0, new RNG(0L), null);
Map<Integer, Integer> tops = new HashMap<>();
Set<IrisBlockVector> barePositions = new HashSet<>();
for (IrisBlockVector position : bare.getBlocks().keys()) {
barePositions.add(position);
tops.merge(position.getBlockX(), position.getBlockY(), Math::max);
}
coral.setTipPalette(palette(tip));
for (double chance : new double[]{0.0, 0.35, 1.0}) {
coral.setTipChance(chance);
for (long seed = 0; seed < 16; seed++) {
IrisObject decorated = CoralGenerator.generate(coral, 0, new RNG(seed), null);
Set<IrisBlockVector> positions = new HashSet<>();
int tipCount = 0;
for (IrisBlockVector position : decorated.getBlocks().keys()) {
positions.add(position);
if (decorated.getBlocks().get(position) == tip) {
assertTrue("Fan tip must occupy an existing column", tops.containsKey(position.getBlockX()));
assertEquals(tops.get(position.getBlockX()).intValue(), position.getBlockY());
tipCount++;
}
}
assertEquals(barePositions, positions);
if (chance == 1.0) {
assertEquals(tops.size(), tipCount);
}
}
}
}
}
}
private static IrisMaterialPalette palette(PlatformBlockState state) {
IrisBlockData block = mock(IrisBlockData.class);
when(block.getWeight()).thenReturn(1);
when(block.getBlockData(null)).thenReturn(state);
return new IrisMaterialPalette().setPalette(new KList<>(block));
}
}
@@ -0,0 +1,79 @@
package art.arcane.iris.engine.object.formation;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.engine.object.IrisFormation;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.Vector3i;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class FormationBlockResolverTest {
@Test
public void sharedResolverMatchesFreshPaletteSeedsAcrossBands() {
IrisFormation formation = new IrisFormation().setSeed(43191L).setStrataThickness(3);
IrisMaterialPalette body = palette("stone", "andesite");
IrisMaterialPalette cap = palette("granite", "diorite");
IrisMaterialPalette strata = palette("terracotta", "red_terracotta");
formation.setBlockPalette(body);
List<Vector3i> positions = new ArrayList<>();
for (int x = -8; x <= 8; x++) {
for (int y = -12; y <= 24; y++) {
positions.add(new Vector3i(x, y, x - y));
}
}
Set<String> materials = new HashSet<>();
for (boolean withStrata : new boolean[]{false, true}) {
formation.setStrataPalette(withStrata ? strata : null);
for (boolean withCap : new boolean[]{false, true}) {
formation.setCapPalette(withCap ? cap : null);
FormationBlockResolver resolver = new FormationBlockResolver(formation, null);
for (int pass = 0; pass < 2; pass++) {
for (Vector3i position : positions) {
FormationCanvas.Role role = position.getBlockX() % 2 == 0
? FormationCanvas.Role.CAP : FormationCanvas.Role.BODY;
IrisMaterialPalette expectedPalette = withCap && role == FormationCanvas.Role.CAP
? cap : withStrata ? strata : body;
int sampleY = expectedPalette == strata
? Math.floorDiv(position.getBlockY(), formation.getStrataThickness()) : position.getBlockY();
long sampleSeed = formation.getSeed() + (expectedPalette == strata ? sampleY * 31L : 0L);
RNG fresh = new RNG(sampleSeed);
PlatformBlockState expected = expectedPalette.get(fresh,
position.getBlockX(), sampleY, position.getBlockZ(), null);
assertSame(expected, resolver.resolve(role, position));
assertEquals(new RNG(sampleSeed).nextLong(), fresh.nextLong());
materials.add(expected.key());
}
Collections.reverse(positions);
}
}
}
assertTrue(materials.size() == 6);
}
private static IrisMaterialPalette palette(String first, String second) {
return new IrisMaterialPalette().setPalette(new KList<>(block(first), block(second)));
}
private static IrisBlockData block(String material) {
PlatformBlockState state = mock(PlatformBlockState.class);
when(state.key()).thenReturn("minecraft:" + material);
IrisBlockData block = mock(IrisBlockData.class);
when(block.getWeight()).thenReturn(1);
when(block.getBlockData(null)).thenReturn(state);
return block;
}
}
@@ -3,10 +3,13 @@ package art.arcane.iris.engine.object.tree;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisProceduralTree;
import art.arcane.iris.engine.object.IrisTreeSecondaryLeaf;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.HashSet;
@@ -16,9 +19,42 @@ import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class TreeBlockResolverTest {
@Test
public void largeSecondaryWeightsKeepTheirRelativeDistribution() {
IrisProceduralTree tree = new IrisProceduralTree();
tree.setSeed(91874L);
tree.setLeavesPalette(palette("oak_leaves", "birch_leaves"));
PlatformBlockState blossom = mock(PlatformBlockState.class);
PlatformBlockState light = mock(PlatformBlockState.class);
List<PlatformBlockState> normal = new ArrayList<>();
List<PlatformBlockState> large = new ArrayList<>();
TreeBlockCanvas.Cell cell = new TreeBlockCanvas.Cell(
TreeBlockCanvas.Role.SECONDARY_LEAF, TreeBlockCanvas.Axis.NONE, false, -1, null);
try (MockedStatic<B> blocks = mockStatic(B.class)) {
blocks.when(() -> B.getStateOrNull("minecraft:cherry_leaves", false)).thenReturn(blossom);
blocks.when(() -> B.getStateOrNull("minecraft:shroomlight", false)).thenReturn(light);
for (int weight : new int[]{1, Integer.MAX_VALUE}) {
tree.setWeightedSecondaryLeaves(new KList<>(
new IrisTreeSecondaryLeaf("minecraft:cherry_leaves", weight),
new IrisTreeSecondaryLeaf("minecraft:shroomlight", weight)));
List<PlatformBlockState> resolved = weight == 1 ? normal : large;
for (int x = -16; x <= 16; x++) {
for (int y = 0; y < 16; y++) {
resolved.add(TreeBlockResolver.resolve(tree, null, cell,
new TreeBlockCanvas.Vec(x, y, x + y), new RNG(tree.getSeed())));
}
}
}
}
assertTrue(normal.contains(blossom));
assertTrue(normal.contains(light));
assertEquals(normal, large);
}
@Test
public void sharedPaletteRandomSourceMatchesFreshPerVoxelSources() {
IrisProceduralTree tree = new IrisProceduralTree();