From b03f1c90e11665c139ae108ac0c3c27a19590957 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 21 Oct 2026 19:53:23 -0400 Subject: [PATCH] Streamlined chunk prefill - Read interpolator corner bounds through a per-thread chunk block and skip groups whose cell corners are all zero - Prefilled chunk context caches inline on generation threads with heights ahead of biomes - Kept the burst-pool prefill only for the platform main thread, with heights and biomes in one task - Painted surface fluid from the chunk context cache instead of re-sampling the fluid stream --- .../iris/generation/context/ChunkContext.java | 86 +++--- .../iris/generation/runtime/IrisComplex.java | 181 ++++++++++++- .../stage/IrisTerrainNormalActuator.java | 6 +- .../context/ChunkContextPrefillPlanTest.java | 58 ++++- .../DimensionTerrain3DCompositionTest.java | 1 + .../runtime/IrisComplexChunkCornersTest.java | 244 ++++++++++++++++++ ...TerrainNormalActuatorColumnParityTest.java | 3 +- ...risTerrainNormalActuatorHydrologyTest.java | 2 + ...risTerrainNormalActuatorStrataCutTest.java | 2 + 9 files changed, 528 insertions(+), 55 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexChunkCornersTest.java diff --git a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java index bcb02f8dc..f9718154c 100644 --- a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java @@ -17,8 +17,6 @@ import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.iris.generation.concurrent.MultiBurst; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.CompletableFuture; public class ChunkContext { @@ -95,38 +93,11 @@ public class ChunkContext { if (hydrologyRuntime != null && (resolvedPlan.height || resolvedPlan.biome)) { hydrologyRuntime.prepareChunkColumns(x, z); } - List fillTasks = new ArrayList<>(6); - if (resolvedPlan.height) { - fillTasks.add(() -> height.fillRounded(roundedHeight)); - } - if (resolvedPlan.biome) { - fillTasks.add(new PrefillFillTask(biome)); - } - if (resolvedPlan.rock) { - fillTasks.add(new PrefillFillTask(rock)); - } - if (resolvedPlan.fluid) { - fillTasks.add(new PrefillFillTask(fluid)); - } - if (resolvedPlan.region) { - fillTasks.add(new PrefillFillTask(region)); - } - if (resolvedPlan.cave) { - fillTasks.add(new PrefillFillTask(cave)); - } - - if (!shouldPrefillAsync(resolvedPlan, fillTasks.size())) { - for (Runnable fillTask : fillTasks) { - fillTask.run(); - } + if (shouldPrefillAsync(resolvedPlan, complex.isPlatformMainThread())) { + prefillAsync(resolvedPlan); } else { - List> futures = new ArrayList<>(fillTasks.size()); - for (Runnable fillTask : fillTasks) { - futures.add(CompletableFuture.runAsync(fillTask, MultiBurst.burst)); - } - for (CompletableFuture future : futures) { - future.join(); - } + prefillSurface(resolvedPlan); + prefillPalettes(resolvedPlan); } if (capturePrefillMetric) { metrics.getContextPrefill().put((System.nanoTime() - totalStartNanos) / 1_000_000D); @@ -134,14 +105,46 @@ public class ChunkContext { } } - static boolean shouldPrefillAsync(PrefillPlan prefillPlan, int fillTaskCount) { - if (prefillPlan == PrefillPlan.NATURAL_TERRAIN || fillTaskCount <= 1 || !IrisPlatforms.isBound()) { + static boolean shouldPrefillAsync(PrefillPlan prefillPlan, boolean platformMainThread) { + if (!platformMainThread || prefillPlan == PrefillPlan.NATURAL_TERRAIN || prefillPlan == PrefillPlan.NONE + || !IrisPlatforms.isBound()) { return false; } return !MultiBurst.burst.ownsCurrentThread(); } + private void prefillSurface(PrefillPlan prefillPlan) { + if (prefillPlan.height) { + height.fillRounded(roundedHeight); + } + if (prefillPlan.biome) { + biome.fill(); + } + } + + private void prefillPalettes(PrefillPlan prefillPlan) { + if (prefillPlan.rock) { + rock.fill(); + } + if (prefillPlan.fluid) { + fluid.fill(); + } + if (prefillPlan.region) { + region.fill(); + } + if (prefillPlan.cave) { + cave.fill(); + } + } + + private void prefillAsync(PrefillPlan prefillPlan) { + CompletableFuture surface = CompletableFuture.runAsync(() -> prefillSurface(prefillPlan), MultiBurst.burst); + CompletableFuture palettes = CompletableFuture.runAsync(() -> prefillPalettes(prefillPlan), MultiBurst.burst); + surface.join(); + palettes.join(); + } + public void setTerrainBiomeOutput(Hunk terrainBiomes) { this.terrainBiomes = terrainBiomes; } @@ -295,17 +298,4 @@ public class ChunkContext { public record TransitionTerrainColumn(Terrain3DColumn solids, int fluidHeight) { } - - private static final class PrefillFillTask implements Runnable { - private final ChunkedDataCache dataCache; - - private PrefillFillTask(ChunkedDataCache dataCache) { - this.dataCache = dataCache; - } - - @Override - public void run() { - dataCache.fill(); - } - } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index 3632864ee..e983da85b 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -1384,6 +1384,10 @@ public class IrisComplex implements DataProvider { return false; } + public boolean isPlatformMainThread() { + return terrainEngine.getPlatformHooks().isMainThread(); + } + public ProceduralStream getRawHeightStream() { return heightStream; } @@ -1891,13 +1895,82 @@ public class IrisComplex implements DataProvider { return h; } + int grid = biomeBoundsSamplingStep; + if (!ChunkCorners.supports(grid)) { + for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) { + h += interpolateGenerators(engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, frozenGenerators[interpolatorIndex], x, z, seed); + } + return h; + } + + GridBoundsCache cache = gridBoundsCache.get(); + int xi = (int) Math.floor(x); + int zi = (int) Math.floor(z); + int gx = xi & ~(grid - 1); + int gz = zi & ~(grid - 1); + double fx = (x - gx) / grid; + double fz = (z - gz) / grid; + ChunkCorners corners = cache.chunkCorners(xi >> 4, zi >> 4, frozenInterpolators.length, grid); + int cornerX = (gx - corners.originX) / grid; + int cornerZ = (gz - corners.originZ) / grid; + int cell = cornerZ * corners.cellsPerAxis + cornerX; + long heightSeed = seed + 239945; for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) { - h += interpolateGenerators(engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, frozenGenerators[interpolatorIndex], x, z, seed); + IrisGenerator[] generators = frozenGenerators[interpolatorIndex]; + if (generators.length == 0) { + continue; + } + int state = corners.cellState(interpolatorIndex, cell); + if (state == ChunkCorners.SILENT) { + continue; + } + long b00 = cornerAt(cache, engine, corners, interpolatorIndex, cornerX, cornerZ); + long b10 = fx == 0D ? 0L : cornerAt(cache, engine, corners, interpolatorIndex, cornerX + 1, cornerZ); + long b01 = fz == 0D ? 0L : cornerAt(cache, engine, corners, interpolatorIndex, cornerX, cornerZ + 1); + long b11 = fx == 0D || fz == 0D ? 0L : cornerAt(cache, engine, corners, interpolatorIndex, cornerX + 1, cornerZ + 1); + if (state == ChunkCorners.UNKNOWN) { + corners.classify(interpolatorIndex, cornerX, cornerZ, cell); + if (silentBounds(b00) && silentBounds(b10) && silentBounds(b01) && silentBounds(b11)) { + continue; + } + } + double lo; + double hi; + if (fx == 0D && fz == 0D) { + lo = boundsLow(b00); + hi = boundsHigh(b00); + } else if (fz == 0D) { + lo = biLerp(boundsLow(b00), boundsLow(b10), boundsLow(b00), boundsLow(b10), fx, fz); + hi = biLerp(boundsHigh(b00), boundsHigh(b10), boundsHigh(b00), boundsHigh(b10), fx, fz); + } else if (fx == 0D) { + lo = biLerp(boundsLow(b00), boundsLow(b00), boundsLow(b01), boundsLow(b01), fx, fz); + hi = biLerp(boundsHigh(b00), boundsHigh(b00), boundsHigh(b01), boundsHigh(b01), fx, fz); + } else { + lo = biLerp(boundsLow(b00), boundsLow(b10), boundsLow(b01), boundsLow(b11), fx, fz); + hi = biLerp(boundsHigh(b00), boundsHigh(b10), boundsHigh(b01), boundsHigh(b11), fx, fz); + } + h += averageGeneratorHeights(generators, lo, hi, x, z, heightSeed); } return h; } + private long cornerAt(GridBoundsCache cache, Engine engine, ChunkCorners corners, int interpolatorIndex, int column, int row) { + if (corners.known(interpolatorIndex, column, row)) { + return corners.corner(interpolatorIndex, column, row); + } + long packed = cornerBounds(cache, engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, + frozenGenerators[interpolatorIndex], corners.originX + column * corners.grid, corners.originZ + row * corners.grid); + if (Double.isFinite(boundsLow(packed)) && Double.isFinite(boundsHigh(packed))) { + corners.remember(interpolatorIndex, column, row, packed); + } + return packed; + } + + private static boolean silentBounds(long packed) { + return (packed & 0x7FFFFFFF7FFFFFFFL) == 0L; + } + private NoiseBounds naturalHeightBounds( Engine engine, KList overlayNoise, @@ -2268,6 +2341,26 @@ public class IrisComplex implements DataProvider { private final long[] packed = new long[GRID_BOUNDS_CACHE_SIZE]; private final boolean[] valid = new boolean[GRID_BOUNDS_CACHE_SIZE]; private final BoundsSampler sampler = new BoundsSampler(); + private ChunkCorners recentCorners; + private ChunkCorners previousCorners; + + private ChunkCorners chunkCorners(int chunkX, int chunkZ, int groups, int grid) { + ChunkCorners recent = recentCorners; + if (recent != null && recent.matches(chunkX, chunkZ, groups, grid)) { + return recent; + } + ChunkCorners previous = previousCorners; + if (previous != null && previous.matches(chunkX, chunkZ, groups, grid)) { + previousCorners = recent; + recentCorners = previous; + return previous; + } + ChunkCorners reused = previous != null && previous.fits(groups, grid) ? previous : new ChunkCorners(groups, grid); + reused.reset(chunkX, chunkZ); + previousCorners = recent; + recentCorners = reused; + return reused; + } private int slot(int cornerX, int cornerZ, int interpolatorIndex) { long h = (cornerX * 0x9E3779B97F4A7C15L) ^ (cornerZ * 0xC2B2AE3D27D4EB4FL) ^ (interpolatorIndex * 0x165667B19E3779F9L); @@ -2276,6 +2369,92 @@ public class IrisComplex implements DataProvider { } } + private static final class ChunkCorners { + private static final int UNKNOWN = 0; + private static final int SILENT = 1; + private static final int ACTIVE = 2; + + private final int groups; + private final int grid; + private final int cornersPerAxis; + private final int cellsPerAxis; + private final long[] packed; + private final boolean[] known; + private final byte[] cellStates; + private int chunkX; + private int chunkZ; + private int originX; + private int originZ; + + private ChunkCorners(int groups, int grid) { + this.groups = groups; + this.grid = grid; + cellsPerAxis = 16 / grid; + cornersPerAxis = cellsPerAxis + 1; + packed = new long[groups * cornersPerAxis * cornersPerAxis]; + known = new boolean[packed.length]; + cellStates = new byte[groups * cellsPerAxis * cellsPerAxis]; + } + + private static boolean supports(int grid) { + return grid >= 2 && grid <= 16 && Integer.bitCount(grid) == 1; + } + + private boolean fits(int groups, int grid) { + return this.groups == groups && this.grid == grid; + } + + private boolean matches(int chunkX, int chunkZ, int groups, int grid) { + return this.chunkX == chunkX && this.chunkZ == chunkZ && fits(groups, grid); + } + + private void reset(int chunkX, int chunkZ) { + this.chunkX = chunkX; + this.chunkZ = chunkZ; + originX = chunkX << 4; + originZ = chunkZ << 4; + Arrays.fill(known, false); + Arrays.fill(cellStates, (byte) UNKNOWN); + } + + private int cellState(int group, int cell) { + return cellStates[group * cellsPerAxis * cellsPerAxis + cell]; + } + + private void setCellState(int group, int cell, int state) { + cellStates[group * cellsPerAxis * cellsPerAxis + cell] = (byte) state; + } + + private boolean known(int group, int column, int row) { + return known[index(group, column, row)]; + } + + private long corner(int group, int column, int row) { + return packed[index(group, column, row)]; + } + + private void remember(int group, int column, int row, long value) { + int index = index(group, column, row); + packed[index] = value; + known[index] = true; + } + + private void classify(int group, int column, int row, int cell) { + int first = index(group, column, row); + int second = first + cornersPerAxis; + if (!known[first] || !known[first + 1] || !known[second] || !known[second + 1]) { + return; + } + boolean silent = silentBounds(packed[first]) && silentBounds(packed[first + 1]) + && silentBounds(packed[second]) && silentBounds(packed[second + 1]); + setCellState(group, cell, silent ? SILENT : ACTIVE); + } + + private int index(int group, int column, int row) { + return (group * cornersPerAxis + row) * cornersPerAxis + column; + } + } + /** * Reusable per-thread bounds provider. Holds the biome memo and the lazily computed generator * bounds for one sampleBoundsRaw pass so the pass allocates nothing; {@link #bind} diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java index 6a61d308f..7c39f3338 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java @@ -160,6 +160,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator biomeCache; private final ChunkedDataCache regionCache; private final ChunkedDataCache rockCache; + private final ChunkedDataCache fluidCache; private final UpperDimensionContext upperContext; private final IrisOreBands dimensionSurfaceOres; private final IrisOreBands dimensionUndergroundOres; @@ -192,6 +193,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator platforms = mockStatic(IrisPlatforms.class)) { + platforms.when(IrisPlatforms::isBound).thenReturn(true); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NO_CAVE, false)); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.ALL, false)); + platforms.verifyNoInteractions(); + } + } + + @Test + public void platformMainThreadKeepsBlockingPrefillOffTheTickThread() { + try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { + platforms.when(IrisPlatforms::isBound).thenReturn(true); + assertTrue(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NO_CAVE, true)); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NONE, true)); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NATURAL_TERRAIN, true)); + } + } + + @Test + public void inlinePrefillFinishesHeightsBeforeTheBiomePassStarts() { + AtomicInteger caveCalls = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + AtomicInteger biomeCalls = new AtomicInteger(); + AtomicInteger rockCalls = new AtomicInteger(); + AtomicInteger fluidCalls = new AtomicInteger(); + AtomicInteger regionCalls = new AtomicInteger(); + AtomicInteger heightsSeenByFirstBiome = new AtomicInteger(-1); + try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { + platforms.when(IrisPlatforms::isBound).thenReturn(true); + createContext(ChunkContext.PrefillPlan.NO_CAVE, caveCalls, heightCalls, biomeCalls, rockCalls, + fluidCalls, regionCalls, null, () -> heightsSeenByFirstBiome.compareAndSet(-1, heightCalls.get())); + } + + assertEquals(256, heightsSeenByFirstBiome.get()); + assertEquals(256, biomeCalls.get()); } @Test @@ -110,7 +143,7 @@ public class ChunkContextPrefillPlanTest { AtomicInteger regionCalls = new AtomicInteger(); try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { platforms.when(IrisPlatforms::isBound).thenReturn(true); - assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NATURAL_TERRAIN, 5)); + assertFalse(ChunkContext.shouldPrefillAsync(ChunkContext.PrefillPlan.NATURAL_TERRAIN, true)); ChunkContext context = createContext(ChunkContext.PrefillPlan.NATURAL_TERRAIN, caveCalls, heightCalls, biomeCalls, rockCalls, fluidCalls, regionCalls); @@ -228,6 +261,22 @@ public class ChunkContextPrefillPlanTest { AtomicInteger fluidCalls, AtomicInteger regionCalls, IrisHydrologyRuntime runtime + ) { + return createContext(prefillPlan, caveCalls, heightCalls, biomeCalls, rockCalls, fluidCalls, regionCalls, + runtime, () -> { + }); + } + + private ChunkContext createContext( + ChunkContext.PrefillPlan prefillPlan, + AtomicInteger caveCalls, + AtomicInteger heightCalls, + AtomicInteger biomeCalls, + AtomicInteger rockCalls, + AtomicInteger fluidCalls, + AtomicInteger regionCalls, + IrisHydrologyRuntime runtime, + Runnable biomeObserver ) { IrisComplex complex = mock(IrisComplex.class); @@ -244,6 +293,7 @@ public class ChunkContextPrefillPlanTest { ProceduralStream biomeStream = mock(ProceduralStream.class); IrisBiome biome = mock(IrisBiome.class); doAnswer(invocation -> { + biomeObserver.run(); biomeCalls.incrementAndGet(); return biome; }).when(biomeStream).get(anyDouble(), anyDouble()); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java index 8a77db249..5a9f60e57 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java @@ -351,6 +351,7 @@ public class DimensionTerrain3DCompositionTest { when(context.getBiome()).thenReturn(biomes); when(context.getRegion()).thenReturn(regions); when(context.getRock()).thenReturn(rocks); + when(context.getFluid()).thenReturn(mock(ChunkedDataCache.class)); when(context.getRoundedHeight(0, 0)).thenReturn(rootHeight); Hunk blocks = Hunk.newArrayHunk(1, 64, 1); new IrisTerrainNormalActuator(engine).paint(0, 0, blocks, context); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexChunkCornersTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexChunkCornersTest.java new file mode 100644 index 000000000..6149c12ca --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexChunkCornersTest.java @@ -0,0 +1,244 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisInterpolator; +import art.arcane.volmlib.util.interpolation.NoiseBounds; +import art.arcane.volmlib.util.interpolation.NoiseBoundsProvider; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.SplittableRandom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +public class IrisComplexChunkCornersTest { + @Test + public void chunkCornerHeightsMatchPerGroupGridSamplingBitForBit() throws Exception { + for (int step : new int[]{2, 4, 8, 16}) { + IrisInterpolator[] interpolators = { + new PatchInterpolator(0, 1D), + new PatchInterpolator(1, -3D), + new ZeroInterpolator(true), + new PatchInterpolator(2, 0.5D), + new ZeroInterpolator(false) + }; + IrisGenerator[][] generators = { + {new WaveGenerator(1D), new WaveGenerator(0.5D)}, + {new WaveGenerator(-0.25D)}, + {new WaveGenerator(2D)}, + {new WaveGenerator(0.75D), new WaveGenerator(-1D), new WaveGenerator(0.125D)}, + {new WaveGenerator(4D)} + }; + IrisComplex candidate = createComplex(step, interpolators, generators); + IrisComplex reference = createComplex(step, interpolators, generators); + SplittableRandom random = new SplittableRandom(step * 31L); + for (int sample = 0; sample < 60_000; sample++) { + double x = coordinate(random); + double z = coordinate(random); + assertEquals("step " + step + " at " + x + "," + z, + Double.doubleToRawLongBits(expectedHeight(reference, interpolators, generators, x, z)), + Double.doubleToRawLongBits(interpolatedHeight(candidate, x, z))); + } + } + } + + @Test + public void silentCellsNeverEvaluateTheirGenerators() throws Exception { + WaveGenerator quiet = new WaveGenerator(1D); + WaveGenerator loud = new WaveGenerator(1D); + IrisInterpolator[] interpolators = {new ZeroInterpolator(true), new PatchInterpolator(0, 2D)}; + IrisGenerator[][] generators = {{quiet}, {loud}}; + IrisComplex complex = createComplex(4, interpolators, generators); + + for (int x = 0; x < 64; x++) { + for (int z = 0; z < 64; z++) { + interpolatedHeight(complex, x, z); + } + } + + assertEquals(0, quiet.invocations); + assertTrue(loud.invocations > 0); + } + + @Test + public void sparseSamplesFetchOnlyTheCornersTheirColumnsUse() throws Exception { + CountingInterpolator counting = new CountingInterpolator(); + IrisInterpolator[] interpolators = {counting}; + IrisGenerator[][] generators = {{new WaveGenerator(1D)}}; + IrisComplex complex = createComplex(4, interpolators, generators); + + for (int sample = 0; sample < 32; sample++) { + interpolatedHeight(complex, sample * 64D, sample * -64D); + } + assertEquals(32, counting.calls); + + interpolatedHeight(complex, 2048D + 1D, 0D); + assertEquals(34, counting.calls); + interpolatedHeight(complex, 4096D, 1D); + assertEquals(36, counting.calls); + interpolatedHeight(complex, 8192D + 1D, 1D); + assertEquals(40, counting.calls); + } + + @Test + public void nonFiniteCornersFailTheSameWayAndAreRetried() throws Exception { + NaNCornerInterpolator interpolator = new NaNCornerInterpolator(); + IrisInterpolator[] interpolators = {interpolator}; + IrisGenerator[][] generators = {{new WaveGenerator(1D)}}; + IrisComplex complex = createComplex(4, interpolators, generators); + + for (int attempt = 0; attempt < 2; attempt++) { + try { + interpolatedHeight(complex, 2D, 2D); + fail("A non-finite corner must fail the column"); + } catch (InvocationTargetException expected) { + assertTrue(expected.getCause() instanceof IllegalStateException); + } + } + assertEquals(2, interpolator.nanCalls); + IrisComplex reference = createComplex(4, interpolators, generators); + assertEquals(Double.doubleToRawLongBits(expectedHeight(reference, interpolators, generators, 0D, 0D)), + Double.doubleToRawLongBits(interpolatedHeight(complex, 0D, 0D))); + } + + private static double coordinate(SplittableRandom random) { + int base = random.nextInt(-80, 80); + return switch (random.nextInt(4)) { + case 0 -> base; + case 1 -> base + random.nextInt(4) * 0.25D; + case 2 -> base + random.nextDouble(); + default -> base * 1031 + random.nextInt(-3, 3); + }; + } + + private static double expectedHeight( + IrisComplex complex, + IrisInterpolator[] interpolators, + IrisGenerator[][] generators, + double x, + double z + ) throws Exception { + Method interpolate = IrisComplex.class.getDeclaredMethod("interpolateGenerators", Engine.class, + IrisInterpolator.class, int.class, IrisGenerator[].class, double.class, double.class, long.class); + interpolate.setAccessible(true); + double height = 0; + for (int group = 0; group < interpolators.length; group++) { + height += (double) interpolate.invoke(complex, null, interpolators[group], group, generators[group], x, z, 31L); + } + return height; + } + + private static double interpolatedHeight(IrisComplex complex, double x, double z) throws Exception { + Method method = IrisComplex.class.getDeclaredMethod("getInterpolatedHeight", Engine.class, + double.class, double.class, long.class); + method.setAccessible(true); + return (double) method.invoke(complex, null, x, z, 31L); + } + + private static IrisComplex createComplex(int step, IrisInterpolator[] interpolators, IrisGenerator[][] generators) throws Exception { + IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + setField(complex, "biomeBoundsSamplingStep", step); + setField(complex, "generatorBounds", new HashMap<>()); + setField(complex, "biomeBuffetGenerators", new HashMap<>()); + setField(complex, "frozenInterpolators", interpolators); + setField(complex, "frozenGenerators", generators); + Class cacheClass = Class.forName("art.arcane.iris.generation.runtime.IrisComplex$GridBoundsCache"); + Constructor cacheConstructor = cacheClass.getDeclaredConstructor(); + cacheConstructor.setAccessible(true); + setField(complex, "gridBoundsCache", ThreadLocal.withInitial(() -> newCache(cacheConstructor))); + return complex; + } + + private static Object newCache(Constructor constructor) { + try { + return constructor.newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private static void setField(IrisComplex complex, String name, Object value) throws Exception { + Field field = IrisComplex.class.getDeclaredField(name); + field.setAccessible(true); + field.set(complex, value); + } + + private static final class PatchInterpolator extends IrisInterpolator { + private final int salt; + private final double scale; + + private PatchInterpolator(int salt, double scale) { + this.salt = salt; + this.scale = scale; + } + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + long cell = Math.floorDiv((long) x, 24L) * 73_856_093L ^ Math.floorDiv((long) z, 24L) * 19_349_663L ^ salt * 83_492_791L; + if (Math.floorMod(cell, 3L) != 0L) { + return new NoiseBounds(0D, 0D); + } + double low = (x * 0.0625D - z * 0.03125D) * scale; + return new NoiseBounds(low, low + 3.75D * scale + Math.floorMod(cell, 5L)); + } + } + + private static final class ZeroInterpolator extends IrisInterpolator { + private final boolean negative; + + private ZeroInterpolator(boolean negative) { + this.negative = negative; + } + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + return negative ? new NoiseBounds(-0D, 0D) : new NoiseBounds(0D, -0D); + } + } + + private static final class CountingInterpolator extends IrisInterpolator { + private int calls; + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + calls++; + return new NoiseBounds(x * 0.001D, x * 0.001D + 2D); + } + } + + private static final class NaNCornerInterpolator extends IrisInterpolator { + private int nanCalls; + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + if (x == 4D && z == 4D) { + nanCalls++; + return new NoiseBounds(Double.NaN, 1D); + } + return new NoiseBounds(0.25D, 0.5D); + } + } + + private static final class WaveGenerator extends IrisGenerator { + private final double amplitude; + private int invocations; + + private WaveGenerator(double amplitude) { + this.amplitude = amplitude; + } + + @Override + public double getHeight(double x, double z, long seed) { + invocations++; + return Math.sin(x * 0.37D + seed) * Math.cos(z * 0.21D) * amplitude; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorColumnParityTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorColumnParityTest.java index 0534903c4..846b43c13 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorColumnParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorColumnParityTest.java @@ -175,7 +175,6 @@ public class IrisTerrainNormalActuatorColumnParityTest { when(complex.terrainColumn(eq(X), eq(Z), hydrology == null ? isNull() : any(HydrologyColumnSample.class))) .thenReturn(terrainColumn); when(complex.naturalTerrainColumn(X, Z)).thenReturn(terrainColumn); - when(complex.resolveSurfaceFluid(anyDouble(), anyDouble())).thenReturn(fluid); when(context.getRoundedHeight(0, 0)).thenReturn(he); when(context.getBiome()).thenReturn(new ChunkedDataCache<>( ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> biome)), X, Z, false)); @@ -183,6 +182,8 @@ public class IrisTerrainNormalActuatorColumnParityTest { ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), X, Z, false)); when(context.getRock()).thenReturn(new ChunkedDataCache<>( ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), X, Z, false)); + when(context.getFluid()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> fluid, Interpolated.of(value -> 0D, value -> fluid)), X, Z, false)); when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), anyInt(), anyInt(), eq(data), eq(complex))).thenAnswer(invocation -> layers("layer", layerCount)); when(biome.generateSeaLayers(anyDouble(), anyDouble(), any(RNG.class), anyInt(), eq(data))) diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java index 3b9577f2b..d9e5cc60c 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java @@ -259,6 +259,8 @@ public class IrisTerrainNormalActuatorHydrologyTest { ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), 11, -4, false)); when(context.getRock()).thenReturn(new ChunkedDataCache<>( ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), 11, -4, false)); + when(context.getFluid()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> null, Interpolated.of(value -> 0D, value -> null)), 11, -4, false)); when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>(List.of(grass))); Hunk output = Hunk.newArrayHunk(1, 64, 1); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java index e1a350da8..6b1cdb959 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java @@ -155,6 +155,8 @@ public class IrisTerrainNormalActuatorStrataCutTest { ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), 11, -4, false)); when(context.getRock()).thenReturn(new ChunkedDataCache<>( ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), 11, -4, false)); + when(context.getFluid()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> null, Interpolated.of(value -> 0D, value -> null)), 11, -4, false)); when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>(List.of(grass, dirt))); when(biome.generateCeilingLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class),