diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java index 50dcc3a51..b10fbe171 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java @@ -31,6 +31,14 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.interpolation.IrisInterpolation; import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.FlatNoise; +import art.arcane.volmlib.util.noise.FractalBillowPerlinNoise; +import art.arcane.volmlib.util.noise.FractalBillowSimplexNoise; +import art.arcane.volmlib.util.noise.FractalFBMSimplexNoise; +import art.arcane.volmlib.util.noise.NoiseGenerator; +import art.arcane.volmlib.util.noise.OffsetNoiseGenerator; +import art.arcane.volmlib.util.noise.PerlinNoise; +import art.arcane.volmlib.util.noise.SimplexNoise; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; @@ -50,6 +58,9 @@ import java.util.concurrent.atomic.AtomicReference; public class IrisNoiseGenerator { private static final int GENERATOR_CACHE_SIZE = 32; private static final long GENERATOR_SEED_SALT = 33_955_677L; + private static final int MINIMUM_FRACTURE_DEPTH = 4; + private static final int MAXIMUM_FRACTURE_NODES = 32; + private static final ThreadLocal FRACTURE_SAMPLES = ThreadLocal.withInitial(FractureSamples::new); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @@ -135,6 +146,20 @@ public class IrisNoiseGenerator { return offsetY; } + if (hasDeepFracture()) { + FractureSamples samples = FRACTURE_SAMPLES.get(); + if (!samples.active) { + samples.active = true; + try { + if (samples.prepare(this, superSeed, data, engine)) { + return samples.sample(xv, zv); + } + } finally { + samples.clear(); + } + } + } + double x = xv; double z = zv; int g = 33; @@ -148,17 +173,7 @@ public class IrisNoiseGenerator { g += 819; } - CNG cng = getGenerator(superSeed, data, engine); - double sampleX = (x / zoom) + offsetX; - double sampleZ = (z / zoom) + offsetZ; - double n = cng.noiseFast2D(sampleX, sampleZ) * opacity; - n = negative ? (-n + opacity) : n; - n = (exponent != 1 ? n < 0 ? -Math.pow(-n, exponent) : Math.pow(n, exponent) : n) + offsetY; - n = parametric ? IrisInterpolation.parametric(n, 2D) : n; - n = bezier ? IrisInterpolation.bezier(n) : n; - n = sinCentered ? IrisInterpolation.sinCenter(n) : n; - - return n; + return sample(getGenerator(superSeed, data, engine), x, z); } public KList getAllComposites() { @@ -173,6 +188,65 @@ public class IrisNoiseGenerator { return g; } + private double sample(CNG cng, double x, double z) { + double sampleX = (x / zoom) + offsetX; + double sampleZ = (z / zoom) + offsetZ; + double n = cng.noiseFast2D(sampleX, sampleZ) * opacity; + n = negative ? (-n + opacity) : n; + n = (exponent != 1 ? n < 0 ? -Math.pow(-n, exponent) : Math.pow(n, exponent) : n) + offsetY; + n = parametric ? IrisInterpolation.parametric(n, 2D) : n; + n = bezier ? IrisInterpolation.bezier(n) : n; + n = sinCentered ? IrisInterpolation.sinCenter(n) : n; + + return n; + } + + private boolean hasDeepFracture() { + IrisNoiseGenerator node = this; + for (int depth = 0; depth < MINIMUM_FRACTURE_DEPTH; depth++) { + if (node.getClass() != IrisNoiseGenerator.class || !node.enabled || node.fracture.size() != 1) { + return false; + } + node = node.fracture.get(0); + } + return true; + } + + private boolean hasPureStyle() { + return style != null && style.getClass() == IrisGeneratorStyle.class + && style.getExpression() == null && style.getImageMap() == null + && style.getFracture() == null && style.getCellularFrequency() == 0D + && style.getCacheSize() == 0; + } + + private static boolean isPureGenerator(CNG cng) { + for (int depth = 0; depth < MAXIMUM_FRACTURE_NODES; depth++) { + if (cng.getClass() != CNG.class || cng.getChildren() != null + || cng.getCustomGenerator() != null || cng.getInjector() != CNG.ADD + || !isPureBase(cng.getGenerator())) { + return false; + } + cng = cng.getFracture(); + if (cng == null) { + return true; + } + } + return false; + } + + private static boolean isPureBase(NoiseGenerator generator) { + for (int depth = 0; depth < MAXIMUM_FRACTURE_NODES && generator != null; depth++) { + Class type = generator.getClass(); + if (type != OffsetNoiseGenerator.class) { + return type == SimplexNoise.class || type == PerlinNoise.class || type == FlatNoise.class + || type == FractalBillowPerlinNoise.class || type == FractalBillowSimplexNoise.class + || type == FractalFBMSimplexNoise.class; + } + generator = ((OffsetNoiseGenerator) generator).getBase(); + } + return false; + } + private static final class GeneratorKey { private final IrisData data; private final Engine engine; @@ -208,6 +282,59 @@ public class IrisNoiseGenerator { } } + private static final class FractureSamples { + private final IrisNoiseGenerator[] nodes = new IrisNoiseGenerator[MAXIMUM_FRACTURE_NODES]; + private final CNG[] generators = new CNG[MAXIMUM_FRACTURE_NODES]; + private int size; + private boolean active; + + private boolean prepare(IrisNoiseGenerator node, long superSeed, IrisData data, Engine engine) { + while (size < MAXIMUM_FRACTURE_NODES) { + if (node.getClass() != IrisNoiseGenerator.class || !node.enabled || !node.hasPureStyle() + || node.fracture.size() > 1) { + return false; + } + CNG generator = node.getGenerator(superSeed, data, engine); + if (!isPureGenerator(generator)) { + return false; + } + nodes[size] = node; + generators[size++] = generator; + if (node.fracture.isEmpty()) { + return true; + } + superSeed += node.seed + 33; + node = node.fracture.get(0); + } + return false; + } + + private double sample(double x, double z) { + int leaf = size - 1; + double forward = nodes[leaf].sample(generators[leaf], x, z); + double reverse = nodes[leaf].sample(generators[leaf], z, x); + for (int index = leaf - 1; index >= 0; index--) { + double offset = nodes[index + 1].opacity / 2D; + double forwardX = x + (forward - offset); + double forwardZ = z + (reverse - offset); + forward = nodes[index].sample(generators[index], forwardX, forwardZ); + if (index > 0) { + reverse = nodes[index].sample(generators[index], forwardZ, forwardX); + } + } + return forward; + } + + private void clear() { + for (int index = 0; index < size; index++) { + nodes[index] = null; + generators[index] = null; + } + size = 0; + active = false; + } + } + private static final class CachedGenerator { private final GeneratorKey key; private final CNG generator; diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorFractureTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorFractureTest.java new file mode 100644 index 000000000..774fb7568 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorFractureTest.java @@ -0,0 +1,271 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.interpolation.IrisInterpolation; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.NoiseGenerator; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public final class IrisNoiseGeneratorFractureTest { + @Test + public void deepChainsMatchRecursiveSamplingBits() { + Random coordinates = new Random(718311L); + for (NoiseStyle style : List.of(NoiseStyle.SIMPLEX, NoiseStyle.PERLIN, NoiseStyle.IRIS, + NoiseStyle.FRACTAL_BILLOW_SIMPLEX, NoiseStyle.FRACTAL_FBM_SIMPLEX)) { + for (int depth : new int[]{0, 1, 3, 4, 5, 8}) { + IrisNoiseGenerator root = chain(depth, style).getFirst(); + for (long seed : new long[]{0L, -4193L, Long.MIN_VALUE, Long.MAX_VALUE}) { + assertParity(root, seed, 0D, -0D, null, null); + assertParity(root, seed, -0D, 0D, null, null); + assertParity(root, seed, -30_000_000D, 29_999_999.75D, null, null); + assertParity(root, seed, Math.nextDown(-64D), Math.nextUp(64D), null, null); + for (int index = 0; index < 8; index++) { + assertParity(root, seed, coordinates.nextDouble() * 4096D - 2048D, + coordinates.nextDouble() * 4096D - 2048D, null, null); + } + } + } + } + } + + @Test + public void fourFracturesReducePrimitiveSamplesFromThirtyOneToNine() { + List chain = chain(4, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + reference(chain.getFirst(), 71L, 13.25D, -21.75D, null, null); + for (int index = 0; index < bases.size(); index++) { + verify(bases.get(index), times(1 << index)).noise(anyDouble(), anyDouble()); + clearInvocations(bases.get(index)); + } + + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + for (int index = 0; index < bases.size(); index++) { + verify(bases.get(index), times(index == 0 ? 1 : 2)).noise(anyDouble(), anyDouble()); + } + } + + @Test + public void shallowChainsKeepTheirSamplingCounts() { + List chain = chain(3, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + for (int index = 0; index < bases.size(); index++) { + verify(bases.get(index), times(1 << index)).noise(anyDouble(), anyDouble()); + } + } + + @Test + public void changedParametersAndFracturesRemainVisible() { + List chain = chain(5, NoiseStyle.IRIS); + IrisNoiseGenerator root = chain.getFirst(); + assertParity(root, 19L, 31D, -79D, null, null); + chain.get(3).setOffsetX(-319D).setOffsetZ(17D).setOffsetY(-0.7D) + .setSeed(Long.MAX_VALUE).setOpacity(0.35D).setZoom(17D); + assertParity(root, 19L, 31D, -79D, null, null); + chain.get(4).setEnabled(false); + assertParity(root, 19L, 31D, -79D, null, null); + chain.get(4).setEnabled(true); + chain.get(2).getFracture().add(new IrisNoiseGenerator().setStyle(NoiseStyle.PERLIN.style())); + assertParity(root, 19L, 31D, -79D, null, null); + chain.get(2).getFracture().removeLast(); + chain.getLast().getFracture().add(new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style())); + assertParity(root, 19L, 31D, -79D, null, null); + } + + @Test + public void statefulRuntimeGeneratorsKeepAllEvaluations() { + List chain = chain(4, NoiseStyle.SIMPLEX); + long leafSeed = childSeed(chain, 23L, chain.size() - 1); + StatefulNoise stateful = new StatefulNoise(); + chain.getLast().getGenerator(leafSeed, null, null).setGenerator(stateful); + double expected = reference(chain.getFirst(), 23L, 17D, -31D, null, null); + assertEquals(16, stateful.samples); + stateful.samples = 0; + double actual = chain.getFirst().getNoise(23L, 17D, -31D, null); + assertBits(expected, actual); + assertEquals(16, stateful.samples); + } + + @Test + public void expressionConfigurationDoesNotReuseEvenPreviouslyCachedPureNoise() { + List chain = chain(4, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + chain.getLast().getStyle().setExpression("custom"); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + verify(bases.getLast(), times(16)).noise(anyDouble(), anyDouble()); + chain.getLast().getStyle().setExpression(null).setImageMap("map"); + clearInvocations(bases.getLast()); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + verify(bases.getLast(), times(16)).noise(anyDouble(), anyDouble()); + } + + @Test + public void customStylesKeepTheScalarPath() { + List chain = chain(4, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + chain.getLast().setStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX) { + }); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + verify(bases.getLast(), times(16)).noise(anyDouble(), anyDouble()); + } + + @Test + public void failedSamplingReleasesTheThreadScratch() { + List chain = chain(4, NoiseStyle.SIMPLEX); + List bases = spyBases(chain, 71L); + doThrow(new IllegalStateException("sample failed")).doCallRealMethod() + .when(bases.getLast()).noise(anyDouble(), anyDouble()); + assertThrows(IllegalStateException.class, + () -> chain.getFirst().getNoise(71L, 13.25D, -21.75D, null)); + clearInvocations(bases.getLast()); + chain.getFirst().getNoise(71L, 13.25D, -21.75D, null); + verify(bases.getLast(), times(2)).noise(anyDouble(), anyDouble()); + } + + @Test + public void threadsAndEngineIdentitiesRemainIndependent() throws Exception { + IrisNoiseGenerator root = chain(5, NoiseStyle.IRIS).getFirst(); + IrisData data = mock(IrisData.class); + Engine firstEngine = mock(Engine.class); + Engine secondEngine = mock(Engine.class); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> sampleConcurrently(root, data, firstEngine, 47L, start)); + Future second = executor.submit(() -> sampleConcurrently(root, data, secondEngine, -131L, start)); + start.countDown(); + first.get(15L, TimeUnit.SECONDS); + second.get(15L, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + assertParity(root, 47L, 17D, 19D, data, secondEngine); + assertParity(root, 47L, 17D, 19D, data, firstEngine); + } + + private static void sampleConcurrently(IrisNoiseGenerator root, IrisData data, Engine engine, + long seed, CountDownLatch start) { + try { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + for (int index = 0; index < 64; index++) { + assertParity(root, seed, index * 13.75D, -index * 2.25D, data, engine); + } + } + + private static List chain(int depth, NoiseStyle style) { + List nodes = new ArrayList<>(depth + 1); + for (int index = 0; index <= depth; index++) { + IrisNoiseGenerator node = new IrisNoiseGenerator().setStyle(style.style()) + .setSeed(index * 713L).setZoom(0.75D + index).setOpacity(0.63D + index * 0.01D) + .setOffsetX(index * -0.2D).setOffsetY(index * -0.03D).setOffsetZ(index * 0.4D) + .setNegative(index % 2 == 0).setExponent(index % 3 == 0 ? 1.25D : 1D) + .setParametric(index % 3 == 1).setBezier(index % 3 == 2) + .setSinCentered(index % 4 == 1); + if (!nodes.isEmpty()) { + nodes.getLast().setFracture(new KList<>(node)); + } + nodes.add(node); + } + return nodes; + } + + private static List spyBases(List nodes, long superSeed) { + List bases = new ArrayList<>(nodes.size()); + for (IrisNoiseGenerator node : nodes) { + CNG generator = node.getGenerator(superSeed, null, null); + NoiseGenerator base = spy(generator.getGenerator()); + generator.setGenerator(base); + bases.add(base); + superSeed += node.getSeed() + 33; + } + return bases; + } + + private static long childSeed(List nodes, long seed, int index) { + for (int child = 0; child < index; child++) { + seed += nodes.get(child).getSeed() + 33; + } + return seed; + } + + private static void assertParity(IrisNoiseGenerator node, long seed, double x, double z, + IrisData data, Engine engine) { + assertBits(reference(node, seed, x, z, data, engine), node.getNoise(seed, x, z, data, engine)); + } + + private static void assertBits(double expected, double actual) { + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + } + + private static double reference(IrisNoiseGenerator node, long superSeed, double xv, double zv, + IrisData data, Engine engine) { + if (!node.isEnabled()) { + return node.getOffsetY(); + } + double x = xv; + double z = zv; + int salt = 33; + for (IrisNoiseGenerator child : node.getFracture()) { + if (child.isEnabled()) { + double offset = child.getOpacity() / 2D; + x += reference(child, superSeed + node.getSeed() + salt, xv, zv, data, engine) - offset; + z += reference(child, superSeed + node.getSeed() + salt, zv, xv, data, engine) - offset; + } + salt += 819; + } + double sampleX = (x / node.getZoom()) + node.getOffsetX(); + double sampleZ = (z / node.getZoom()) + node.getOffsetZ(); + double result = node.getGenerator(superSeed, data, engine).noiseFast2D(sampleX, sampleZ) * node.getOpacity(); + result = node.isNegative() ? -result + node.getOpacity() : result; + result = (node.getExponent() != 1D ? result < 0D ? -Math.pow(-result, node.getExponent()) + : Math.pow(result, node.getExponent()) : result) + node.getOffsetY(); + result = node.isParametric() ? IrisInterpolation.parametric(result, 2D) : result; + result = node.isBezier() ? IrisInterpolation.bezier(result) : result; + return node.isSinCentered() ? IrisInterpolation.sinCenter(result) : result; + } + + private static final class StatefulNoise implements NoiseGenerator { + private int samples; + + @Override + public double noise(double x) { + return ++samples * 0.01D; + } + + @Override + public double noise(double x, double z) { + return ++samples * 0.01D; + } + + @Override + public double noise(double x, double y, double z) { + return ++samples * 0.01D; + } + } +}