From bf9e65252cac055cedde6ffa80c0703c08c93ff3 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 22 Oct 2026 04:00:16 -0400 Subject: [PATCH] Shared object placement layout - Object placement bakes split into one per-object layout (palette, tiles, canopy) and per-variant offsets - Variants beyond the per-object limit are built transiently instead of clearing the variant map - High-variant golden and no-rebuild tests --- .../iris/structure/object/IrisObject.java | 1 + .../structure/object/ObjectPlacementBake.java | 277 ++++++++++-------- .../object/ObjectPlacementBakeTest.java | 58 ++++ .../object/ObjectPlacementGoldenTest.java | 29 ++ 4 files changed, 244 insertions(+), 121 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java index 9b0d1fd52..0c7b25738 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java @@ -146,6 +146,7 @@ public class IrisObject extends IrisRegistrant { transient final AtomicCache> surfaceSupportOffsets = new AtomicCache<>(); transient final AtomicCache floatingFootprint = new AtomicCache<>(); transient final ConcurrentHashMap placementBakes = new ConcurrentHashMap<>(); + transient volatile ObjectPlacementBake.Layout placementLayout; @Getter @Setter private transient TreeGrounding treeGrounding; diff --git a/core/src/main/java/art/arcane/iris/structure/object/ObjectPlacementBake.java b/core/src/main/java/art/arcane/iris/structure/object/ObjectPlacementBake.java index 58470a119..1e86d069f 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/ObjectPlacementBake.java +++ b/core/src/main/java/art/arcane/iris/structure/object/ObjectPlacementBake.java @@ -15,140 +15,73 @@ import java.util.concurrent.ConcurrentHashMap; final class ObjectPlacementBake { private static final int MAXIMUM_VARIANTS_PER_OBJECT = 32; - private final long blocksRevision; - private final long statesRevision; - private final TreeGrounding grounding; - private final int count; + private final Layout layout; + private final Key key; private final int[] offsetX; private final int[] offsetY; private final int[] offsetZ; private final int[] layerY; - private final short[] paletteIndexes; - private final NativeBlockState[] palette; - private final int[] tileBlocks; - private final TileData[] tileValues; - private final long[] canopy; - private final Key key; private volatile double[] exact; - private ObjectPlacementBake(Key key, long blocksRevision, long statesRevision, TreeGrounding grounding, int count, - int[] offsetX, int[] offsetY, int[] offsetZ, int[] layerY, short[] paletteIndexes, - NativeBlockState[] palette, int[] tileBlocks, TileData[] tileValues, long[] canopy) { + private ObjectPlacementBake(Layout layout, Key key, int[] offsetX, int[] offsetY, int[] offsetZ, int[] layerY) { + this.layout = layout; this.key = key; - this.blocksRevision = blocksRevision; - this.statesRevision = statesRevision; - this.grounding = grounding; - this.count = count; this.offsetX = offsetX; this.offsetY = offsetY; this.offsetZ = offsetZ; this.layerY = layerY; - this.paletteIndexes = paletteIndexes; - this.palette = palette; - this.tileBlocks = tileBlocks; - this.tileValues = tileValues; - this.canopy = canopy; } static ObjectPlacementBake of(IrisObject object, SpinKernel spin, boolean ceilingHang, IrisBlockVector translateOffset) { Key key = Key.of(spin, ceilingHang, translateOffset); - ConcurrentHashMap variants = object.placementBakes; - ObjectPlacementBake cached = variants.get(key); - if (cached != null && cached.matches(object)) { - return cached; - } - ObjectPlacementBake built = bake(object, key, spin, ceilingHang, translateOffset); - if (built.matches(object)) { - if (variants.size() >= MAXIMUM_VARIANTS_PER_OBJECT) { - variants.clear(); - } - variants.put(key, built); - } - return built; - } - - private static ObjectPlacementBake bake(IrisObject object, Key key, SpinKernel spin, boolean ceilingHang, - IrisBlockVector translateOffset) { object.readLock.lock(); try { - VectorMap blocks = object.blocks; - VectorMap states = object.states; - long blocksRevision = blocks.modificationRevision(); - long statesRevision = states.modificationRevision(); - TreeGrounding grounding = object.getTreeGrounding(); - boolean hasStates = !states.isEmpty(); - IntArrayList x = new IntArrayList(); - IntArrayList y = new IntArrayList(); - IntArrayList z = new IntArrayList(); - IntArrayList layer = new IntArrayList(); - IntArrayList indexes = new IntArrayList(); - IntArrayList tileBlocks = new IntArrayList(); - List tileValues = new ArrayList<>(); - List palette = new ArrayList<>(); - IdentityHashMap paletteSlots = new IdentityHashMap<>(); - IntArrayList canopyBlocks = new IntArrayList(); - boolean layerDiffers = false; - IrisBlockVector rotated = new IrisBlockVector(0, 0, 0); - VectorMap.Cursor cursor = blocks.cursor(); - int index = 0; - while (cursor.next()) { - IrisBlockVector source = cursor.key(); - NativeBlockState state = cursor.value(); - if (state == null) { - state = IrisObject.States.air(); - } - transform(source, rotated, spin, ceilingHang, translateOffset); - int roundedY = (int) Math.round(rotated.getY()); - int floorY = rotated.getBlockY(); - x.add((int) Math.round(rotated.getX())); - y.add(roundedY); - z.add((int) Math.round(rotated.getZ())); - layer.add(floorY); - layerDiffers |= floorY != roundedY; - Integer slot = paletteSlots.get(state); - if (slot == null) { - slot = palette.size(); - palette.add(state); - paletteSlots.put(state, slot); - } - indexes.add(slot); - if (hasStates) { - TileData tile = states.get(source); - if (tile != null) { - tileBlocks.add(index); - tileValues.add(tile); - } - } - if (grounding != null && grounding.isCanopy(source.getBlockX(), source.getBlockY(), source.getBlockZ())) { - canopyBlocks.add(index); - } - index++; + Layout layout = Layout.of(object); + ConcurrentHashMap variants = object.placementBakes; + ObjectPlacementBake cached = variants.get(key); + if (cached != null && cached.layout == layout) { + return cached; } - if (palette.size() > Short.MAX_VALUE) { - throw new IllegalStateException("Object " + object.getLoadKey() + " has more than " - + Short.MAX_VALUE + " distinct block states"); + ObjectPlacementBake built = bake(object, layout, key, spin, ceilingHang, translateOffset); + if (cached != null) { + variants.replace(key, cached, built); + } else if (variants.size() < MAXIMUM_VARIANTS_PER_OBJECT) { + variants.putIfAbsent(key, built); } - short[] paletteIndexes = new short[index]; - for (int block = 0; block < index; block++) { - paletteIndexes[block] = (short) indexes.getInt(block); - } - long[] canopy = null; - if (!canopyBlocks.isEmpty()) { - canopy = new long[(index + 63) >>> 6]; - for (int block : canopyBlocks) { - canopy[block >>> 6] |= 1L << (block & 63); - } - } - return new ObjectPlacementBake(key, blocksRevision, statesRevision, grounding, index, - x.toIntArray(), y.toIntArray(), z.toIntArray(), layerDiffers ? layer.toIntArray() : null, - paletteIndexes, palette.toArray(new NativeBlockState[0]), - tileBlocks.isEmpty() ? null : tileBlocks.toIntArray(), - tileValues.isEmpty() ? null : tileValues.toArray(new TileData[0]), canopy); + return built; } finally { object.readLock.unlock(); } } + private static ObjectPlacementBake bake(IrisObject object, Layout layout, Key key, SpinKernel spin, boolean ceilingHang, + IrisBlockVector translateOffset) { + int count = layout.count; + int[] x = new int[count]; + int[] y = new int[count]; + int[] z = new int[count]; + int[] layer = new int[count]; + boolean layerDiffers = false; + IrisBlockVector rotated = new IrisBlockVector(0, 0, 0); + VectorMap.Cursor cursor = object.blocks.cursor(); + int index = 0; + while (cursor.next() && index < count) { + transform(cursor.key(), rotated, spin, ceilingHang, translateOffset); + int roundedY = (int) Math.round(rotated.getY()); + int floorY = rotated.getBlockY(); + x[index] = (int) Math.round(rotated.getX()); + y[index] = roundedY; + z[index] = (int) Math.round(rotated.getZ()); + layer[index] = floorY; + layerDiffers |= floorY != roundedY; + index++; + } + if (index != count) { + throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement bake was built"); + } + return new ObjectPlacementBake(layout, key, x, y, z, layerDiffers ? layer : null); + } + private static void transform(IrisBlockVector source, IrisBlockVector target, SpinKernel spin, boolean ceilingHang, IrisBlockVector translateOffset) { target.setX(source.getX()); @@ -163,14 +96,8 @@ final class ObjectPlacementBake { } } - boolean matches(IrisObject object) { - return object.blocks.modificationRevision() == blocksRevision - && object.states.modificationRevision() == statesRevision - && object.getTreeGrounding() == grounding; - } - int count() { - return count; + return layout.count; } int offsetX(int index) { @@ -190,22 +117,23 @@ final class ObjectPlacementBake { } int paletteIndex(int index) { - return paletteIndexes[index]; + return layout.paletteIndexes[index]; } NativeBlockState[] palette() { - return palette; + return layout.palette; } int[] tileBlocks() { - return tileBlocks; + return layout.tileBlocks; } TileData[] tileValues() { - return tileValues; + return layout.tileValues; } boolean canopy(int index) { + long[] canopy = layout.canopy; return canopy != null && (canopy[index >>> 6] & (1L << (index & 63))) != 0L; } @@ -214,6 +142,7 @@ final class ObjectPlacementBake { if (resolved != null) { return resolved; } + int count = layout.count; object.readLock.lock(); try { resolved = new double[count * 3]; @@ -237,6 +166,112 @@ final class ObjectPlacementBake { return resolved; } + static final class Layout { + private final long blocksRevision; + private final long statesRevision; + private final TreeGrounding grounding; + private final int count; + private final short[] paletteIndexes; + private final NativeBlockState[] palette; + private final int[] tileBlocks; + private final TileData[] tileValues; + private final long[] canopy; + + private Layout(long blocksRevision, long statesRevision, TreeGrounding grounding, int count, short[] paletteIndexes, + NativeBlockState[] palette, int[] tileBlocks, TileData[] tileValues, long[] canopy) { + this.blocksRevision = blocksRevision; + this.statesRevision = statesRevision; + this.grounding = grounding; + this.count = count; + this.paletteIndexes = paletteIndexes; + this.palette = palette; + this.tileBlocks = tileBlocks; + this.tileValues = tileValues; + this.canopy = canopy; + } + + private static Layout of(IrisObject object) { + Layout current = object.placementLayout; + if (current != null && current.matches(object)) { + return current; + } + Layout built = build(object); + if (!built.matches(object)) { + return built; + } + object.placementLayout = built; + if (current != null) { + object.placementBakes.clear(); + } + return built; + } + + private static Layout build(IrisObject object) { + VectorMap blocks = object.blocks; + VectorMap states = object.states; + long blocksRevision = blocks.modificationRevision(); + long statesRevision = states.modificationRevision(); + TreeGrounding grounding = object.getTreeGrounding(); + boolean hasStates = !states.isEmpty(); + int size = blocks.size(); + short[] paletteIndexes = new short[size]; + IntArrayList tileBlocks = new IntArrayList(); + List tileValues = new ArrayList<>(); + List palette = new ArrayList<>(); + IdentityHashMap paletteSlots = new IdentityHashMap<>(); + long[] canopy = null; + VectorMap.Cursor cursor = blocks.cursor(); + int index = 0; + while (cursor.next()) { + IrisBlockVector source = cursor.key(); + NativeBlockState state = cursor.value(); + if (state == null) { + state = IrisObject.States.air(); + } + Integer slot = paletteSlots.get(state); + if (slot == null) { + slot = palette.size(); + if (slot >= Short.MAX_VALUE) { + throw new IllegalStateException("Object " + object.getLoadKey() + " has more than " + + Short.MAX_VALUE + " distinct block states"); + } + palette.add(state); + paletteSlots.put(state, slot); + } + if (index == paletteIndexes.length) { + throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement layout was built"); + } + paletteIndexes[index] = (short) slot.intValue(); + if (hasStates) { + TileData tile = states.get(source); + if (tile != null) { + tileBlocks.add(index); + tileValues.add(tile); + } + } + if (grounding != null && grounding.isCanopy(source.getBlockX(), source.getBlockY(), source.getBlockZ())) { + if (canopy == null) { + canopy = new long[(size + 63) >>> 6]; + } + canopy[index >>> 6] |= 1L << (index & 63); + } + index++; + } + if (index != size) { + throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement layout was built"); + } + return new Layout(blocksRevision, statesRevision, grounding, index, paletteIndexes, + palette.toArray(new NativeBlockState[0]), tileBlocks.isEmpty() ? null : tileBlocks.toIntArray(), + tileValues.isEmpty() ? null : tileValues.toArray(new TileData[0]), canopy); + } + + private boolean matches(IrisObject object) { + return object.blocks.modificationRevision() == blocksRevision + && object.states.modificationRevision() == statesRevision + && object.getTreeGrounding() == grounding; + } + } + record Key(boolean rotates, int xMode, int yMode, int zMode, long xCos, long xSin, long yCos, long ySin, long zCos, long zSin, boolean ceilingHang, boolean translating, long translateX, long translateY, long translateZ) { diff --git a/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementBakeTest.java b/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementBakeTest.java index 1a51d26be..c24aa64b0 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementBakeTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementBakeTest.java @@ -4,9 +4,16 @@ import art.arcane.iris.generation.geometry.IrisBlockVector; import art.arcane.iris.testsupport.KeyedBlockState; import org.junit.Test; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Random; +import java.util.Set; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; public class ObjectPlacementBakeTest { @Test @@ -65,6 +72,57 @@ public class ObjectPlacementBakeTest { assertSame(after, ObjectPlacementBake.of(object, spin, false, null)); } + @Test + public void highVariantPlacementsKeepCachedVariantsAndNeverRebuildTheLayout() { + IrisObject object = object(); + IrisObjectRotation pine = new IrisObjectRotation(); + pine.setEnabled(true); + pine.setXAxis(new IrisAxisRotationClamp(true, false, -9D, 9D, 1D)); + pine.setYAxis(new IrisAxisRotationClamp(true, false, 0D, 270D, 90D)); + pine.setZAxis(new IrisAxisRotationClamp(true, false, -9D, 9D, 1D)); + IrisBlockVector translate = new IrisBlockVector(0D, -6D, 0D); + Random random = new Random(1444L); + ObjectPlacementBake first = ObjectPlacementBake.of(object, new SpinKernel(pine, 0, 0, 0), false, translate); + Map settled = null; + Set distinct = new HashSet<>(); + for (int placement = 0; placement < 4000; placement++) { + SpinKernel spin = new SpinKernel(pine, random.nextInt(360), random.nextInt(360), random.nextInt(360)); + ObjectPlacementBake bake = ObjectPlacementBake.of(object, spin, false, translate); + distinct.add(ObjectPlacementBake.Key.of(spin, false, translate)); + assertSame(first.palette(), bake.palette()); + assertTrue(object.placementBakes.size() <= 32); + assertTransformed(object, spin, translate, bake); + if (placement == 2000) { + settled = new HashMap<>(object.placementBakes); + } + } + assertTrue(distinct.size() > 40); + assertEquals(32, settled.size()); + assertEquals(settled.keySet(), object.placementBakes.keySet()); + for (Map.Entry entry : settled.entrySet()) { + assertSame(entry.getValue(), object.placementBakes.get(entry.getKey())); + } + } + + private static void assertTransformed(IrisObject object, SpinKernel spin, IrisBlockVector translate, ObjectPlacementBake bake) { + double[] exact = bake.exact(object, spin); + int index = 0; + for (IrisBlockVector source : object.getBlocks().keys()) { + IrisBlockVector expected = source.clone(); + spin.rotate(expected); + expected.add(translate); + assertEquals((int) Math.round(expected.getX()), bake.offsetX(index)); + assertEquals((int) Math.round(expected.getY()), bake.offsetY(index)); + assertEquals((int) Math.round(expected.getZ()), bake.offsetZ(index)); + assertEquals(expected.getBlockY(), bake.layerY(index)); + assertEquals(expected.getX(), exact[index * 3], 0D); + assertEquals(expected.getY(), exact[index * 3 + 1], 0D); + assertEquals(expected.getZ(), exact[index * 3 + 2], 0D); + index++; + } + assertEquals(bake.count(), index); + } + private static IrisObject object() { IrisObject object = new IrisObject(5, 5, 5); KeyedBlockState stone = new KeyedBlockState("minecraft:stone"); diff --git a/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementGoldenTest.java b/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementGoldenTest.java index 60b4ad93c..1b8141fe6 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementGoldenTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementGoldenTest.java @@ -65,6 +65,7 @@ public class ObjectPlacementGoldenTest { private static final int SEEDS = 5; private static final long GOLDEN = 0x408f9d4113015897L; private static final long MARKER_GOLDEN = 0x60b4bbf1144b239cL; + private static final long HIGH_VARIANT_GOLDEN = 0xf017622340ecac42L; private static final List PALETTE = List.of( "minecraft:stone", @@ -229,6 +230,34 @@ public class ObjectPlacementGoldenTest { assertEquals(Long.toHexString(MARKER_GOLDEN), Long.toHexString(combined)); } + @Test + public void highVariantPlacementsMatchTheRecordedBlockStream() { + Random random = new Random(1444L); + IrisObjectRotation pine = rotation(new IrisAxisRotationClamp(true, false, -9D, 9D, 1D), + new IrisAxisRotationClamp(true, false, 0D, 270D, 90D), new IrisAxisRotationClamp(true, false, -9D, 9D, 1D)); + long combined = 29L; + int writes = 0; + for (int index = 0; index < 16; index++) { + IrisObject object = randomObject(random, index); + object.setLoader(objectData); + IrisObjectPlacement placement = randomPlacement(random).toPlacement("pine"); + placement.setRotation(pine); + for (int seed = 0; seed < 150; seed++) { + RNG rng = new RNG(index * 104_729L + seed); + GoldenPlacer placer = new GoldenPlacer(engine, (seed & 1) == 0); + int y = seed % 3 == 0 ? 90 + (seed & 7) : -1; + int result = object.place(300 + seed, y, -400 + index * 7, placer, placement, rng, null, null, data); + long hash = mix(placer.hash, result); + hash = mix(hash, rng.nextLong()); + combined = mix(combined, hash); + writes += placer.writes; + } + assertTrue(object.placementBakes.size() <= 32); + } + assertTrue(writes > 10_000); + assertEquals(Long.toHexString(HIGH_VARIANT_GOLDEN), Long.toHexString(combined)); + } + private static long mix(long hash, long value) { long mixed = (hash ^ value) * 0x9E3779B97F4A7C15L; return mixed ^ (mixed >>> 29);