diff --git a/docs/reference/benchmark-catalogue.md b/docs/reference/benchmark-catalogue.md
index 7158c85..32c2a29 100644
--- a/docs/reference/benchmark-catalogue.md
+++ b/docs/reference/benchmark-catalogue.md
@@ -42,7 +42,7 @@ would land inside the `codec` stage and hide the very thing the benchmark isolat
| Benchmark | Kind | Parameters | What it answers |
| --- | --- | --- | --- |
| [`LightNibblesBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/LightNibblesBenchmark.java) `.getUniform` `.getAllocated` `.setUniformUnchanged` `.setAllocating` `.ofArray` | library | – | What the uniform shortcut is worth. A section whose blocks all carry the same level keeps no array and answers from a field; every other section shifts a nibble out of 2048 bytes. Most sections of a world are completely dark or completely sky-lit, so the shortcut is the common path, not the exception. |
-| [`SectionOpacityBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/SectionOpacityBenchmark.java) `.of` | library | `distinctStates` 1, 8, 64, 200 × `resolveCost` 0, 50 | What "resolve each distinct state once" is worth. `distinctStates` sets how often the cache misses; `resolveCost` sets what a miss costs. At `resolveCost = 0` you are measuring the hash map and the two array writes per block, so the table looks like pure overhead. At `resolveCost = 50` you are measuring what it saves: seven resolutions per distinct state instead of seven per block. What 50 tokens corresponds to on a real registry is not established. |
+| [`SectionOpacityBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/SectionOpacityBenchmark.java) `.of` | library | `distinctStates` 1, 8, 64, 200 × `resolveCost` 0, 50 × `lightSources` 0, 4 | What "resolve each distinct state once" is worth. `distinctStates` sets how often the cache misses; `resolveCost` sets what a miss costs. At `resolveCost = 0` you are measuring the hash map and the two array writes per block, so the table looks like pure overhead. At `resolveCost = 50` you are measuring what it saves: seven resolutions per distinct state instead of seven per block. What 50 tokens corresponds to on a real registry is not established. `lightSources` decides whether the section keeps an emission table; the sources replace blocks, so at 4 a section has one distinct state more and `distinctStates = 1` is no longer uniform. Figures published before the parameter existed are the `lightSources = 0` rows. |
| [`LightPropagatorBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/LightPropagatorBenchmark.java) `.propagate` | library | `lightSources` 0, 1, 8, 64 × `occlusionPercent` 0, 25 | How the single-section search scales with the amount of queued positions. `lightSources = 0` is answered without a search at all, which is the case for the overwhelming majority of the sections of a world. `occlusionPercent = 25` is there because solid blocks stop the search early — measuring only an open section reports the worst case and calls it normal. |
| [`ChunkLightPropagatorBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/ChunkLightPropagatorBenchmark.java) `.propagate` `.propagateSky` | library | `sectionCount` 4, 16, 24 × `lightSourcesPerSection` 1, 8 | The same search across section borders, over a flat map (4), a shallow world (16) and a full-height overworld (24). Both searches are measured because the engine runs both per chunk and they behave very differently: block light is bounded by the amount of emitting blocks, sky light seeds nearly every block of an open column. |
| [`AreaVsPerChunkBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/AreaVsPerChunkBenchmark.java) `.area` `.perChunk` | decision | `chunkCount` 1, 4, 9, 16 | Whether area forming earns its complexity: an area of *n* chunks against *n* separate `calculateWithNeighbours` calls. Written as a decision rather than a report — had it not held, the simpler per-chunk design was the better one and area forming was to be dropped rather than tuned. The `chunkCount = 1` row is the control: a lone chunk has no loaded neighbours, so neither side has a ring to read and the two must come out level. |
diff --git a/falco-benchmarks/build.gradle.kts b/falco-benchmarks/build.gradle.kts
index 1e89713..de6321f 100644
--- a/falco-benchmarks/build.gradle.kts
+++ b/falco-benchmarks/build.gradle.kts
@@ -23,6 +23,7 @@ dependencies {
testImplementation(sourceSets["jmh"].output)
testImplementation(project(":falco-anvil"))
testImplementation(project(":falco-instance"))
+ testImplementation(project(":falco-light"))
testImplementation(libs.adventure.nbt)
testImplementation(libs.annotations)
testImplementation(libs.jmh.core)
diff --git a/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/SectionOpacityBenchmark.java b/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/SectionOpacityBenchmark.java
index 82763f0..4bf0da0 100644
--- a/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/SectionOpacityBenchmark.java
+++ b/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/SectionOpacityBenchmark.java
@@ -42,6 +42,12 @@
*
A resolve cost above zero measures what the cache saves. Seven resolutions happen per
* distinct state, one per face plus the emission, instead of seven per block.
*
+ *
+ * A third parameter, the amount of light sources, decides whether the section keeps an emission
+ * table at all. It is there because a change which only made sections without a light source
+ * cheaper once made the whole engine slower on a world full of them, and this benchmark could not
+ * tell.
+ *
*
* @author TheMeinerLP
* @version 1.0.0
@@ -68,6 +74,19 @@ public class SectionOpacityBenchmark {
@Param({"0", "50"})
public int resolveCost;
+ /**
+ * The amount of light emitting blocks the section holds.
+ *
+ * Zero is the section most of a world consists of, and a table keeps no emission for it. Four
+ * is a section below the surface of {@link TerrainAreaBenchmark}, which has to keep one. Without
+ * this a change to the emission path is invisible here and only shows up on the terrain. The
+ * sources replace blocks of the section, so they add one distinct state and turn a uniform
+ * section into a mixed one.
+ *
+ */
+ @Param({"0", "4"})
+ public int lightSources;
+
private int[] stateIds;
private BlockLightSource source;
@@ -82,13 +101,15 @@ public SectionOpacityBenchmark() {
*
* The states start above {@link FakeBlockLightSource#FILLER_BASE} so every one of them behaves
* like air. The benchmark measures how often a state is resolved, not what the answer is, and a
- * mixture of solid and transparent blocks would only add noise to that.
+ * mixture of solid and transparent blocks would only add noise to that. The light sources are
+ * the one exception, because whether a section emits decides which tables it keeps.
*
*/
@Setup(Level.Trial)
public void setUp() {
- this.stateIds = SectionStates.distinct(
- BenchmarkConstants.BLOCK_ENTRIES, this.distinctStates, FakeBlockLightSource.FILLER_BASE
+ this.stateIds = SectionStates.withLightSources(
+ SectionStates.distinct(BenchmarkConstants.BLOCK_ENTRIES, this.distinctStates, FakeBlockLightSource.FILLER_BASE),
+ this.lightSources
);
this.source = new FakeBlockLightSource(this.resolveCost);
}
diff --git a/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/TerrainAreaBenchmark.java b/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/TerrainAreaBenchmark.java
new file mode 100644
index 0000000..30a4aa2
--- /dev/null
+++ b/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/TerrainAreaBenchmark.java
@@ -0,0 +1,312 @@
+package net.onelitefeather.falco.benchmark.light;
+
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.instance.Chunk;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.instance.block.Block;
+import net.onelitefeather.falco.light.ChunkArea;
+import net.onelitefeather.falco.light.ChunkLightArea;
+import net.onelitefeather.falco.light.ChunkLightService;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * The {@link TerrainAreaBenchmark} class measures an area pass over a world that looks like one.
+ *
+ * Why it exists next to {@link AreaPassStageBenchmark}. Every other light benchmark fills a
+ * single slab of sixteen blocks with random stone and leaves the rest of the column as air. That
+ * makes one section per chunk interesting and the other twenty-three uniform, and a uniform section
+ * is the case every stage of the engine answers without a table. The light of such a world settles
+ * in a few blocks, so the benchmarks measure the fixed cost of a pass far more than the search.
+ *
+ *
+ * The world here is the opposite on purpose. Hills rise from solid ground, caves run through the
+ * stone below them and open into the surface as overhangs, and glowstone sits in the caves and on
+ * the ground. Many sections of a column are mixed, light travels across chunk borders through the
+ * caves, and the sky light has to find its way under every overhang. Both kinds of light are
+ * measured, because a tick pays for both.
+ *
+ *
+ * The terrain is computed from seeded value noise in world coordinates rather than per chunk, so
+ * caves and hills continue across borders the way they do in a generated world. The area and its
+ * ring are filled alike for the reason {@link AreaPassStageBenchmark} gives: the ring is read on
+ * every pass, and an empty ring would make reading it look free.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 1.0.0
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(value = 1, jvmArgsAppend = {"-Xms2g", "-Xmx2g"})
+@Warmup(iterations = 3, time = 2)
+@Measurement(iterations = 5, time = 2)
+public class TerrainAreaBenchmark {
+
+ /**
+ * The seed of the terrain, so every run measures the same world.
+ */
+ private static final long SEED = 20261022L;
+
+ /**
+ * The height the hills oscillate around.
+ */
+ private static final int SEA_LEVEL = 64;
+
+ /**
+ * How far the surface rises above or sinks below {@link #SEA_LEVEL}.
+ */
+ private static final int HILL_AMPLITUDE = 24;
+
+ /**
+ * The horizontal size of one hill, in blocks.
+ */
+ private static final int HILL_SCALE = 32;
+
+ /**
+ * The size of one cell of the cave noise, in blocks.
+ */
+ private static final int CAVE_SCALE = 8;
+
+ /**
+ * The noise value above which a position of the ground is a cave. Lower values carve more.
+ */
+ private static final double CAVE_THRESHOLD = 0.62;
+
+ /**
+ * The attempts made per chunk to put a light source into a cave.
+ */
+ private static final int CAVE_SOURCE_ATTEMPTS = 64;
+
+ /**
+ * The light sources placed on the surface of every chunk.
+ */
+ private static final int SURFACE_SOURCES = 4;
+
+ /**
+ * The amount of connected chunks the area holds.
+ */
+ @Param({"1", "16", "64"})
+ private int chunkCount;
+
+ /**
+ * Whether the sky light is computed instead of the block light.
+ */
+ @Param({"false", "true"})
+ private boolean sky;
+
+ private Instance instance;
+ private ChunkLightArea area;
+ private List areaPositions;
+
+ /**
+ * Creates a new benchmark instance.
+ */
+ public TerrainAreaBenchmark() {
+ }
+
+ /**
+ * Builds the square, loads the ring around it and generates the terrain into both.
+ */
+ @Setup(Level.Trial)
+ public void setUp() {
+ if (MinecraftServer.process() == null) {
+ MinecraftServer.init();
+ }
+
+ this.area = new ChunkLightArea(new ChunkLightService());
+ this.instance = MinecraftServer.getInstanceManager().createInstanceContainer();
+ this.areaPositions = new ArrayList<>(this.chunkCount);
+
+ int edge = (int) Math.round(Math.sqrt(this.chunkCount));
+
+ for (int index = 0; index < this.chunkCount; index++) {
+ this.areaPositions.add(new ChunkArea(index % edge, index / edge));
+ }
+
+ Set pass = new LinkedHashSet<>(this.areaPositions);
+
+ for (ChunkArea position : this.areaPositions) {
+ for (int offsetX = -1; offsetX <= 1; offsetX++) {
+ for (int offsetZ = -1; offsetZ <= 1; offsetZ++) {
+ pass.add(new ChunkArea(position.x() + offsetX, position.z() + offsetZ));
+ }
+ }
+ }
+
+ for (ChunkArea position : pass) {
+ generate(this.instance.loadChunk(position.x(), position.z()).join());
+ }
+ }
+
+ /**
+ * Generates hills, caves and light sources into the given chunk.
+ *
+ * @param chunk the chunk to fill
+ */
+ static void generate(Chunk chunk) {
+ int baseX = chunk.getChunkX() * 16;
+ int baseZ = chunk.getChunkZ() * 16;
+ int minY = chunk.getMinSection() * 16;
+ // Seeded per chunk, so a chunk receives the same sources whatever order the ring is loaded in.
+ Random random = new Random(SEED ^ (31L * chunk.getChunkX() + chunk.getChunkZ()) * 0x9E3779B97F4A7C15L);
+
+ chunk.lockWriteLock();
+ try {
+ for (int z = 0; z < 16; z++) {
+ for (int x = 0; x < 16; x++) {
+ int worldX = baseX + x;
+ int worldZ = baseZ + z;
+ int surface = surfaceAt(worldX, worldZ);
+
+ for (int y = minY; y <= surface; y++) {
+ if (!isCave(worldX, y, worldZ)) {
+ chunk.setBlock(worldX, y, worldZ, Block.STONE);
+ }
+ }
+ }
+ }
+
+ for (int attempt = 0; attempt < CAVE_SOURCE_ATTEMPTS; attempt++) {
+ int worldX = baseX + random.nextInt(16);
+ int worldZ = baseZ + random.nextInt(16);
+ int y = minY + random.nextInt(surfaceAt(worldX, worldZ) - minY);
+
+ if (isCave(worldX, y, worldZ)) {
+ chunk.setBlock(worldX, y, worldZ, Block.GLOWSTONE);
+ }
+ }
+
+ for (int placed = 0; placed < SURFACE_SOURCES; placed++) {
+ int worldX = baseX + random.nextInt(16);
+ int worldZ = baseZ + random.nextInt(16);
+ chunk.setBlock(worldX, surfaceAt(worldX, worldZ) + 1, worldZ, Block.GLOWSTONE);
+ }
+ } finally {
+ chunk.unlockWriteLock();
+ }
+ }
+
+ /**
+ * Returns the height of the ground at the given column.
+ *
+ * @param worldX the x coordinate of the column
+ * @param worldZ the z coordinate of the column
+ * @return the y coordinate of the topmost ground block
+ */
+ private static int surfaceAt(int worldX, int worldZ) {
+ double broad = noise(worldX / (double) HILL_SCALE, 0, worldZ / (double) HILL_SCALE, 1);
+ double fine = noise(worldX / (HILL_SCALE / 4.0), 0, worldZ / (HILL_SCALE / 4.0), 2);
+ return SEA_LEVEL + (int) Math.round(((broad * 0.8 + fine * 0.2) * 2 - 1) * HILL_AMPLITUDE);
+ }
+
+ /**
+ * Checks whether the given position of the ground is carved out by a cave.
+ *
+ * @param worldX the x coordinate
+ * @param y the y coordinate
+ * @param worldZ the z coordinate
+ * @return true if the position is air, otherwise false
+ */
+ private static boolean isCave(int worldX, int y, int worldZ) {
+ return noise(worldX / (double) CAVE_SCALE, y / (double) CAVE_SCALE, worldZ / (double) CAVE_SCALE, 3)
+ > CAVE_THRESHOLD;
+ }
+
+ /**
+ * Samples smoothly interpolated value noise.
+ *
+ * @param x the x coordinate in noise cells
+ * @param y the y coordinate in noise cells
+ * @param z the z coordinate in noise cells
+ * @param channel separates independent noise fields that share coordinates
+ * @return a value within [0, 1]
+ */
+ private static double noise(double x, double y, double z, int channel) {
+ int cellX = (int) Math.floor(x);
+ int cellY = (int) Math.floor(y);
+ int cellZ = (int) Math.floor(z);
+ double fractionX = smooth(x - cellX);
+ double fractionY = smooth(y - cellY);
+ double fractionZ = smooth(z - cellZ);
+
+ double bottom = lerp(fractionZ,
+ lerp(fractionX, corner(cellX, cellY, cellZ, channel), corner(cellX + 1, cellY, cellZ, channel)),
+ lerp(fractionX, corner(cellX, cellY, cellZ + 1, channel), corner(cellX + 1, cellY, cellZ + 1, channel)));
+ double top = lerp(fractionZ,
+ lerp(fractionX, corner(cellX, cellY + 1, cellZ, channel), corner(cellX + 1, cellY + 1, cellZ, channel)),
+ lerp(fractionX, corner(cellX, cellY + 1, cellZ + 1, channel), corner(cellX + 1, cellY + 1, cellZ + 1, channel)));
+ return lerp(fractionY, bottom, top);
+ }
+
+ /**
+ * Returns the random value of one corner of the noise lattice.
+ *
+ * @param x the x coordinate of the corner
+ * @param y the y coordinate of the corner
+ * @param z the z coordinate of the corner
+ * @param channel the noise field the corner belongs to
+ * @return a value within [0, 1)
+ */
+ private static double corner(int x, int y, int z, int channel) {
+ long hash = SEED + channel * 0x632BE59BD9B4E019L;
+ hash ^= x * 0x9E3779B97F4A7C15L;
+ hash ^= y * 0xC2B2AE3D27D4EB4FL;
+ hash ^= z * 0x165667B19E3779F9L;
+ hash = (hash ^ (hash >>> 31)) * 0xBF58476D1CE4E5B9L;
+ hash = (hash ^ (hash >>> 29)) * 0x94D049BB133111EBL;
+ hash ^= hash >>> 32;
+ return (hash >>> 11) * 0x1.0p-53;
+ }
+
+ /**
+ * Eases the given fraction so the noise has no visible edges at the cell borders.
+ *
+ * @param fraction a value within [0, 1]
+ * @return the eased value
+ */
+ private static double smooth(double fraction) {
+ return fraction * fraction * (3 - 2 * fraction);
+ }
+
+ /**
+ * Interpolates linearly between two values.
+ *
+ * @param fraction the share of the second value
+ * @param from the first value
+ * @param to the second value
+ * @return the interpolated value
+ */
+ private static double lerp(double fraction, double from, double to) {
+ return from + fraction * (to - from);
+ }
+
+ /**
+ * Computes the light of the whole area, the ring included, and writes the area back.
+ *
+ * @return the chunks which were written
+ */
+ @Benchmark
+ public List wholePass() {
+ return this.area.compute(this.instance, this.areaPositions, this.sky);
+ }
+}
diff --git a/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/support/SectionStates.java b/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/support/SectionStates.java
index a43aa03..afdf333 100644
--- a/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/support/SectionStates.java
+++ b/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/support/SectionStates.java
@@ -100,23 +100,37 @@ public static int[] distinct(int entryCount, int distinctStates, int firstStateI
*/
public static int[] lit(int lightSources, int occlusionPercent) {
int entryCount = BenchmarkConstants.BLOCK_ENTRIES;
-
- if (lightSources < 0 || lightSources > entryCount) {
- throw new IllegalArgumentException(
- "A section of " + entryCount + " entries cannot hold " + lightSources + " light sources"
- );
- }
-
int[] values = new int[entryCount];
Random random = new Random(BenchmarkConstants.SEED);
for (int index = 0; index < entryCount; index++) {
values[index] = random.nextInt(100) < occlusionPercent ? FakeBlockLightSource.SOLID : FakeBlockLightSource.AIR;
}
+ return withLightSources(values, lightSources);
+ }
+
+ /**
+ * Replaces blocks of the given section with light emitting blocks, spread evenly over it.
+ *
+ * The sources are spread evenly instead of being placed randomly. A random placement can
+ * cluster them, and a cluster performs far less work than the same amount of sources spread
+ * over the section because the searches of the cluster overlap immediately.
+ *
+ *
+ * @param values the section to write the sources into, changed in place
+ * @param lightSources the amount of light emitting blocks to place
+ * @return the given section
+ * @throws IllegalArgumentException if the section cannot hold the requested amount of sources
+ */
+ public static int[] withLightSources(int[] values, int lightSources) {
+ int entryCount = values.length;
+
+ if (lightSources < 0 || lightSources > entryCount) {
+ throw new IllegalArgumentException(
+ "A section of " + entryCount + " entries cannot hold " + lightSources + " light sources"
+ );
+ }
- // The sources are spread evenly instead of being placed randomly. A random placement can
- // cluster them, and a cluster performs far less work than the same amount of sources spread
- // over the section because the searches of the cluster overlap immediately.
int stride = Math.max(entryCount / Math.max(lightSources, 1), 1);
for (int source = 0; source < lightSources; source++) {
diff --git a/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/instance/JolMeasurement.java b/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/instance/JolMeasurement.java
index b0556c5..71ed3b2 100644
--- a/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/instance/JolMeasurement.java
+++ b/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/instance/JolMeasurement.java
@@ -92,7 +92,7 @@
* @version 1.0.0
* @since 0.4.0
*/
-final class JolMeasurement {
+public final class JolMeasurement {
/**
* The JOL option that lets a graph walk read a field of a record class of {@code java.base}.
@@ -181,7 +181,7 @@ private JolMeasurement() {
* @throws org.opentest4j.TestAbortedException if JOL is not sizing through the instrumentation
* agent, or if the mode cannot be determined
*/
- static void require() {
+ public static void require() {
final State state = State.CURRENT;
Assumptions.assumeTrue(state.mode != Mode.UNKNOWN,
@@ -224,7 +224,7 @@ static Mode mode() {
*
* @return the description of how the numbers below it were obtained
*/
- static String describe() {
+ public static String describe() {
final State state = State.CURRENT;
return String.format(Locale.ROOT, "jol 0.17, sizes from %s, %s=%s, Serviceability Agent %s",
state.mode.description(), MAGIC_FIELD_OFFSET,
diff --git a/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/light/OpacityTableFootprintTest.java b/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/light/OpacityTableFootprintTest.java
new file mode 100644
index 0000000..580e6a4
--- /dev/null
+++ b/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/light/OpacityTableFootprintTest.java
@@ -0,0 +1,64 @@
+package net.onelitefeather.falco.benchmark.light;
+
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.instance.Instance;
+import net.onelitefeather.falco.benchmark.instance.JolMeasurement;
+import net.onelitefeather.falco.light.ChunkLightService;
+import net.onelitefeather.falco.light.SectionOpacity;
+import org.junit.jupiter.api.Test;
+import org.openjdk.jol.info.GraphLayout;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Measures how many bytes the opacity tables of a generated area retain.
+ *
+ * {@code ChunkLightArea} keeps the tables of every chunk between two passes, so their retained size
+ * is memory a server holds for as long as the chunks stay loaded. The allocation figures of
+ * {@link TerrainAreaBenchmark} cannot show it: they count what a pass creates, not what is left
+ * once it is done. The area is the terrain of that benchmark, so the two describe the same world.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 3.0.2
+ */
+class OpacityTableFootprintTest {
+
+ /**
+ * The edge length of the measured area, in chunks.
+ */
+ private static final int EDGE = 8;
+
+ @Test
+ void testTheTablesOfATerrainArea() {
+ JolMeasurement.require();
+
+ if (MinecraftServer.process() == null) {
+ MinecraftServer.init();
+ }
+ Instance instance = MinecraftServer.getInstanceManager().createInstanceContainer();
+ ChunkLightService service = new ChunkLightService();
+ List> tables = new ArrayList<>(EDGE * EDGE);
+
+ for (int z = 0; z < EDGE; z++) {
+ for (int x = 0; x < EDGE; x++) {
+ var chunk = instance.loadChunk(x, z).join();
+ TerrainAreaBenchmark.generate(chunk);
+ tables.add(service.opacityOf(chunk));
+ }
+ }
+ GraphLayout layout = GraphLayout.parseInstance(tables);
+ int sections = tables.stream().mapToInt(List::size).sum();
+
+ System.out.println(JolMeasurement.describe());
+ System.out.printf("chunks=%d sections=%d bytes=%d bytes/chunk=%d%n",
+ tables.size(), sections, layout.totalSize(), layout.totalSize() / tables.size());
+ System.out.println(layout.toFootprint());
+
+ assertTrue(layout.totalSize() > 0, "the tables retain something");
+ }
+}
diff --git a/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/support/SectionStatesTest.java b/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/support/SectionStatesTest.java
new file mode 100644
index 0000000..8378fa3
--- /dev/null
+++ b/falco-benchmarks/src/test/java/net/onelitefeather/falco/benchmark/support/SectionStatesTest.java
@@ -0,0 +1,58 @@
+package net.onelitefeather.falco.benchmark.support;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests that the light sources the benchmarks place land where the benchmarks expect them.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 3.0.2
+ */
+class SectionStatesTest {
+
+ /**
+ * Counts the light emitting blocks of a section.
+ *
+ * @param values the section to count in
+ * @return the amount of light emitting blocks
+ */
+ private static long sources(int[] values) {
+ return Arrays.stream(values).filter(value -> value == FakeBlockLightSource.GLOWSTONE).count();
+ }
+
+ @Test
+ void testTheRequestedAmountOfSourcesIsPlaced() {
+ for (int lightSources : new int[]{0, 1, 4, 64}) {
+ int[] values = SectionStates.distinct(BenchmarkConstants.BLOCK_ENTRIES, 8, FakeBlockLightSource.FILLER_BASE);
+
+ assertEquals(lightSources, sources(SectionStates.withLightSources(values, lightSources)),
+ "the sources of a section asked for " + lightSources);
+ }
+ }
+
+ @Test
+ void testPlacingSourcesKeepsEveryDistinctState() {
+ int[] values = SectionStates.withLightSources(
+ SectionStates.distinct(BenchmarkConstants.BLOCK_ENTRIES, 200, FakeBlockLightSource.FILLER_BASE), 4
+ );
+
+ assertEquals(201, Arrays.stream(values).distinct().count(), "the sources add exactly one state");
+ }
+
+ @Test
+ void testALitSectionHoldsItsSources() {
+ assertEquals(8, sources(SectionStates.lit(8, 25)));
+ }
+
+ @Test
+ void testTooManySourcesAreRejected() {
+ assertThrows(IllegalArgumentException.class,
+ () -> SectionStates.withLightSources(new int[16], 17));
+ }
+}
diff --git a/falco-light/src/main/java/net/onelitefeather/falco/light/BorderExchange.java b/falco-light/src/main/java/net/onelitefeather/falco/light/BorderExchange.java
new file mode 100644
index 0000000..a9b6f7e
--- /dev/null
+++ b/falco-light/src/main/java/net/onelitefeather/falco/light/BorderExchange.java
@@ -0,0 +1,137 @@
+package net.onelitefeather.falco.light;
+
+import org.jetbrains.annotations.Contract;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * The {@link BorderExchange} class hands the light of every chunk of an area across its borders
+ * until nothing changes any more, injecting only the borders which can still raise anything.
+ *
+ * Every injection only ever raises a level, so the light of a chunk only grows. Once a chunk took in
+ * the border of a neighbour, the same border cannot raise anything in it again — until that
+ * neighbour itself changes. The exchange therefore counts how often every chunk changed and
+ * remembers, per chunk and face, which of those counts it last took the border at. A border whose
+ * neighbour did not change since is skipped. A skipped injection is exactly one which would have
+ * raised nothing, so the exchange settles on the same light, in the same amount of rounds, as one
+ * which injects every border in every round.
+ *
+ *
+ * Instances are not thread safe and serve one exchange.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 3.0.2
+ */
+final class BorderExchange {
+
+ /**
+ * The faces every chunk is exchanged through, in the order the neighbours are given in.
+ */
+ static final BlockFace[] FACES = {BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, BlockFace.EAST};
+
+ private final List states;
+ private final List> opacity;
+ private final int[] neighbours;
+ private final int[] versions;
+ private final int[] taken;
+ private int rounds;
+ private long injections;
+
+ /**
+ * Creates a new exchange over the given chunks.
+ *
+ * @param states the light of every chunk, raised in place by the exchange
+ * @param opacity the light properties of every section of every chunk, indexed like the states
+ * @param neighbours the index of the neighbour per chunk and face, in the order of {@link #FACES},
+ * or a negative value where a chunk has no neighbour
+ * @throws IllegalArgumentException if the given lists and the neighbours do not cover the same chunks
+ */
+ BorderExchange(List states, List> opacity, int[] neighbours) {
+ if (opacity.size() != states.size() || neighbours.length != states.size() * FACES.length) {
+ throw new IllegalArgumentException(
+ "The exchange needs the opacity and the " + FACES.length + " neighbours of each of the "
+ + states.size() + " chunks"
+ );
+ }
+ this.states = states;
+ this.opacity = opacity;
+ this.neighbours = neighbours;
+ this.versions = new int[states.size()];
+ this.taken = new int[neighbours.length];
+ // No version is negative, so the first round takes every border.
+ Arrays.fill(this.taken, -1);
+ }
+
+ /**
+ * Exchanges the borders until no chunk changes any more or the given amount of rounds ran out.
+ *
+ * @param maxRounds the amount of rounds after which the exchange gives up
+ * @return true if the light settled, false if it still changed in the last round
+ */
+ boolean settle(int maxRounds) {
+ for (int round = 0; round < maxRounds; round++) {
+ this.rounds++;
+
+ if (!exchange()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Hands the border of every changed neighbour to every chunk once.
+ *
+ * @return true if at least one chunk raised a level, otherwise false
+ */
+ private boolean exchange() {
+ boolean changed = false;
+
+ for (int chunk = 0; chunk < this.states.size(); chunk++) {
+ for (int face = 0; face < FACES.length; face++) {
+ int slot = chunk * FACES.length + face;
+ int neighbour = this.neighbours[slot];
+
+ if (neighbour < 0) {
+ continue;
+ }
+ int version = this.versions[neighbour];
+
+ if (this.taken[slot] == version) {
+ continue;
+ }
+ this.taken[slot] = version;
+ this.injections++;
+
+ if (this.states.get(chunk).injectBorder(this.opacity.get(chunk), FACES[face], this.states.get(neighbour))) {
+ this.versions[chunk]++;
+ changed = true;
+ }
+ }
+ }
+ return changed;
+ }
+
+ /**
+ * Returns the amount of rounds this exchange ran.
+ *
+ * @return the amount of rounds, including the last one which changed nothing
+ */
+ @Contract(pure = true)
+ int rounds() {
+ return this.rounds;
+ }
+
+ /**
+ * Returns the amount of borders this exchange actually injected.
+ *
+ * @return the amount of injections, without the skipped ones
+ */
+ @Contract(pure = true)
+ long injections() {
+ return this.injections;
+ }
+}
diff --git a/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightArea.java b/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightArea.java
index 9c9658c..5325888 100644
--- a/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightArea.java
+++ b/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightArea.java
@@ -548,6 +548,10 @@ private ChunkLightState solo(
/**
* Searches the whole chunk for its light and counts the search.
+ *
+ * The propagator comes from the pool of the service, so an area and the single chunk calls of
+ * the same service keep one set of buffers between them rather than two.
+ *
*
* @param opacity the light properties of every section of the chunk
* @param sky whether the sky light is computed instead of the block light
@@ -555,7 +559,9 @@ private ChunkLightState solo(
*/
private ChunkLightState propagate(List opacity, boolean sky) {
this.fullPropagations.incrementAndGet();
- return sky ? ChunkLightState.skyLight(opacity) : ChunkLightState.blockLight(opacity);
+ return this.service.withPropagator(propagator -> sky
+ ? ChunkLightState.skyLight(opacity, propagator)
+ : ChunkLightState.blockLight(opacity, propagator));
}
/**
@@ -566,21 +572,6 @@ private ChunkLightState propagate(List opacity, boolean sky) {
* the rounds keeps a case which should not exist from looping forever, and hitting it is
* reported rather than silently accepted.
*
- *
- * @param entries the chunks of the area and of its ring
- */
- private static void exchangeUntilSettled(Map entries) {
- for (int round = 0; round < MAX_EXCHANGE_ROUNDS; round++) {
- if (!exchange(entries)) {
- return;
- }
- }
- LOGGER.warn("The light of an area of {} chunks did not settle after {} exchange rounds",
- entries.size(), MAX_EXCHANGE_ROUNDS);
- }
-
- /**
- * Hands the border of every loaded face neighbour to every chunk of the area once.
*
* The ring takes part in this in both directions. Light which leaves the area, travels through
* a ring chunk and comes back is the only path when the direct way inside the area is walled
@@ -589,26 +580,33 @@ private static void exchangeUntilSettled(Map entries) {
*
*
* @param entries the chunks of the area and of its ring
- * @return true if at least one chunk raised a level, otherwise false
*/
- private static boolean exchange(Map entries) {
- boolean changed = false;
+ private static void exchangeUntilSettled(Map entries) {
+ List positions = List.copyOf(entries.keySet());
+ Map slots = HashMap.newHashMap(positions.size());
+ List states = new ArrayList<>(positions.size());
+ List> opacity = new ArrayList<>(positions.size());
+
+ for (ChunkArea position : positions) {
+ Entry entry = entries.get(position);
+ slots.put(position, states.size());
+ states.add(entry.state());
+ opacity.add(entry.opacity());
+ }
- for (Map.Entry current : entries.entrySet()) {
- Entry entry = current.getValue();
+ int[] neighbours = new int[positions.size() * BorderExchange.FACES.length];
- for (BlockFace face : HORIZONTAL_FACES) {
- @Nullable Entry neighbour = entries.get(current.getKey().neighbour(face));
-
- if (neighbour == null) {
- continue;
- }
- changed |= entry.state().injectBorder(
- entry.opacity(), face, neighbour.state().border(face.opposite())
- );
+ for (int chunk = 0; chunk < positions.size(); chunk++) {
+ for (int face = 0; face < BorderExchange.FACES.length; face++) {
+ @Nullable Integer neighbour = slots.get(positions.get(chunk).neighbour(BorderExchange.FACES[face]));
+ neighbours[chunk * BorderExchange.FACES.length + face] = neighbour == null ? -1 : neighbour;
}
}
- return changed;
+
+ if (!new BorderExchange(states, opacity, neighbours).settle(MAX_EXCHANGE_ROUNDS)) {
+ LOGGER.warn("The light of an area of {} chunks did not settle after {} exchange rounds",
+ entries.size(), MAX_EXCHANGE_ROUNDS);
+ }
}
/**
diff --git a/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightPropagator.java b/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightPropagator.java
index 9b9d644..f62fd24 100644
--- a/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightPropagator.java
+++ b/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightPropagator.java
@@ -166,7 +166,25 @@ public ChunkLightPropagator() {
*/
public List propagate(List sections) {
int height = prepare(sections);
- return search(sections, height, seed(sections, height));
+ search(height, seed(sections, height));
+ return collect(sections.size());
+ }
+
+ /**
+ * Calculates the light of every section of a chunk as one level per position of the column.
+ *
+ * A {@link ChunkLightState} keeps its light in exactly this layout. Handing it the flat levels
+ * spares it packing every section into nibbles only to unpack them again right away.
+ *
+ *
+ * @param sections the light properties of every section of the chunk
+ * @return one level per position, indexed like the positions of a {@link ChunkLightState}
+ * @throws IllegalArgumentException if the chunk holds no section
+ */
+ byte[] propagateLevels(List sections) {
+ int height = prepare(sections);
+ search(height, seed(sections, height));
+ return levelsOf(height);
}
/**
@@ -183,7 +201,52 @@ public List propagate(List sections) {
*/
public List propagateSky(List sections) {
int height = prepare(sections);
- return search(sections, height, seedSky(height));
+ search(height, seedSky(height));
+ return collect(sections.size());
+ }
+
+ /**
+ * Calculates the sky light of every section of a chunk as one level per position of the column.
+ *
+ * @param sections the light properties of every section of the chunk
+ * @return one level per position, indexed like the positions of a {@link ChunkLightState}
+ * @throws IllegalArgumentException if the chunk holds no section
+ * @see #propagateLevels(List)
+ */
+ byte[] propagateSkyLevels(List sections) {
+ int height = prepare(sections);
+ search(height, seedSky(height));
+ return levelsOf(height);
+ }
+
+ /**
+ * Returns where the sky stopped in every column during the last sky run.
+ *
+ * {@link #seedSky(int)} finds this on its way down anyway. A {@link ChunkLightState} keeps the
+ * same heightmap for its updates, and handing it over spares the state a second walk down every
+ * column through the list of sections.
+ *
+ *
+ * @return the highest position which stops the sky per column, or a negative value for an open
+ * column; a new array the caller may keep
+ */
+ int[] skyTops() {
+ int[] skyTops = new int[COLUMN_COUNT];
+
+ for (int column = 0; column < COLUMN_COUNT; column++) {
+ skyTops[column] = this.skyBottom[column] - 1;
+ }
+ return skyTops;
+ }
+
+ /**
+ * Copies the levels of the last run out of the buffer, which the next run overwrites.
+ *
+ * @param height the amount of blocks the column spans vertically
+ * @return one level per position of the column
+ */
+ private byte[] levelsOf(int height) {
+ return Arrays.copyOf(this.levels, height * LightNibbles.DIMENSION * LightNibbles.DIMENSION);
}
/**
@@ -215,12 +278,10 @@ private int prepare(List sections) {
/**
* Spreads the queued levels through the column.
*
- * @param sections the light properties of every section of the chunk
- * @param height the amount of blocks the column spans vertically
- * @param queued the amount of positions which were queued as sources
- * @return the calculated light of every section
+ * @param height the amount of blocks the column spans vertically
+ * @param queued the amount of positions which were queued as sources
*/
- private List search(List sections, int height, int queued) {
+ private void search(int height, int queued) {
int tail = queued;
int head = 0;
@@ -270,7 +331,6 @@ private List search(List sections, int height, int
this.queue[tail++] = neighbourIndex | (OPPOSITE_INDEX[faceIndex] << POSITION_BITS);
}
}
- return collect(sections.size());
}
/**
diff --git a/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightService.java b/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightService.java
index b932c04..c4d54d1 100644
--- a/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightService.java
+++ b/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightService.java
@@ -12,6 +12,9 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.function.Function;
/**
* The {@link ChunkLightService} class calculates the block light of a chunk and hands the result to
@@ -33,9 +36,9 @@
* does not recompute what was just calculated.
*
*
- * A single instance may be used by as many threads as one likes. The service holds nothing beyond
- * the source it was built with, which only answers questions about a block and never changes, so
- * every call brings its own working state and two calls cannot reach each other. That matters more
+ * A single instance may be used by as many threads as one likes. Beyond the source it was built
+ * with, which only answers questions about a block and never changes, the service holds only idle
+ * propagators, and a call takes one for itself alone, so two calls cannot reach each other. That matters more
* here than elsewhere: a server lights the chunks around its players in parallel and keeps one
* service for the whole instance, and because {@code set} clears the update flag, a result which two
* threads had corrupted would never be recomputed. The world would simply carry wrong light.
@@ -57,11 +60,11 @@
* path that carries the obligation.
*
*
- * The working state of a call is the propagator, which keeps buffers and is therefore built per
- * call rather than kept in a field. Its buffers are the entire cost of that choice, and an
- * allocation per chunk is far cheaper than either handing every thread its own service or letting
- * the threads take turns on a shared one, which would give up exactly the parallelism this service
- * exists to allow.
+ * The working state of a call is the propagator, which keeps buffers for a whole column. A call
+ * takes one from a pool, and returns it when it is done, so no two calls ever hold the same one at
+ * once. The pool keeps the parallelism this service exists to allow — a thread which finds it empty
+ * builds a new propagator rather than waiting — and spares a server the buffers of one propagator
+ * per lit chunk, which used to be the largest allocation of every call.
*
*
* This type is experimental. The light engine is new and its API may still change.
@@ -76,8 +79,6 @@ public final class ChunkLightService {
private static final Logger LOGGER = LoggerFactory.getLogger(ChunkLightService.class);
- private static final BlockFace[] HORIZONTAL_FACES = {BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, BlockFace.EAST};
-
/**
* The amount of chunks the exchanged area reaches beyond the chunk in the middle.
* A level of fifteen cannot survive more than one chunk of travel, so a further ring could not
@@ -99,6 +100,17 @@ public final class ChunkLightService {
private final BlockLightSource source;
+ /**
+ * The propagators no call is currently using.
+ *
+ * A propagator holds buffers for a whole column, several hundred kilobytes for a tall world. A
+ * ThreadLocal would not keep them, because a scheduler runs every area on a virtual thread of its
+ * own, so they are kept here instead. The queue never grows beyond the amount of calls that ran
+ * at the same time, which is what a server already bounds by the processor count.
+ *
+ */
+ private final Queue propagators = new ConcurrentLinkedQueue<>();
+
/**
* Creates a service which reads the block properties from the registry of the server.
*/
@@ -126,7 +138,31 @@ public ChunkLightService(BlockLightSource source) {
* @param chunk the chunk to light
*/
public void calculate(Chunk chunk) {
- applyLight(chunk, new ChunkLightPropagator().propagate(opacityOf(chunk)), false);
+ List opacity = opacityOf(chunk);
+ applyLight(chunk, withPropagator(propagator -> propagator.propagate(opacity)), false);
+ }
+
+ /**
+ * Runs the given work with a propagator nobody else is using during the call.
+ *
+ * The propagator is taken from the pool, or built if the pool is empty, and returned afterwards
+ * even if the work fails. The work must not keep it beyond the call, since the next caller
+ * overwrites its buffers.
+ *
+ *
+ * @param work the work to run
+ * @param the type of the result
+ * @return what the work returned
+ */
+ T withPropagator(Function work) {
+ @Nullable ChunkLightPropagator pooled = this.propagators.poll();
+ ChunkLightPropagator propagator = pooled != null ? pooled : new ChunkLightPropagator();
+
+ try {
+ return work.apply(propagator);
+ } finally {
+ this.propagators.offer(propagator);
+ }
}
/**
@@ -197,7 +233,7 @@ public static void applyLight(Chunk chunk, List light, boolean sky
*/
public void calculateSky(Chunk chunk) {
List opacity = opacityOf(chunk);
- List light = new ChunkLightPropagator().propagateSky(opacity);
+ List light = withPropagator(propagator -> propagator.propagateSky(opacity));
applyLight(chunk, light, true);
}
@@ -291,7 +327,7 @@ public void calculateWithNeighbours(Instance instance, int chunkX, int chunkZ) {
List opacity = opacityOf(chunk);
neighbourhood[slot(offsetX, offsetZ)] =
- new NeighbourhoodEntry(chunk, opacity, ChunkLightState.blockLight(opacity));
+ new NeighbourhoodEntry(chunk, opacity, withPropagator(propagator -> ChunkLightState.blockLight(opacity, propagator)));
}
}
return neighbourhood;
@@ -305,58 +341,47 @@ public void calculateWithNeighbours(Instance instance, int chunkX, int chunkZ) {
* @param chunkZ the chunk z coordinate of the middle of the area
*/
private static void exchangeUntilSettled(@Nullable NeighbourhoodEntry[] neighbourhood, int chunkX, int chunkZ) {
- for (int round = 0; round < MAX_EXCHANGE_ROUNDS; round++) {
- if (!exchange(neighbourhood)) {
- return;
+ // The loaded chunks are numbered in slot order, which walks the area in a fixed order so
+ // that two runs over the same chunks do the same work in the same sequence.
+ int[] indices = new int[neighbourhood.length];
+ List states = new ArrayList<>(neighbourhood.length);
+ List> opacity = new ArrayList<>(neighbourhood.length);
+
+ for (int slot = 0; slot < neighbourhood.length; slot++) {
+ @Nullable NeighbourhoodEntry entry = neighbourhood[slot];
+ indices[slot] = entry == null ? -1 : states.size();
+
+ if (entry != null) {
+ states.add(entry.state());
+ opacity.add(entry.opacity());
}
}
- LOGGER.warn(
- "The light of a chunk and its neighbours did not settle after {} exchange rounds chunk=[{},{}]",
- MAX_EXCHANGE_ROUNDS, chunkX, chunkZ
- );
- }
- /**
- * Hands the border of every loaded neighbour to every loaded chunk of the area once.
- *
- * The area is walked in a fixed order so that two runs over the same chunks do the same work
- * in the same sequence.
- *
- *
- * @param neighbourhood the chunks of the exchanged area
- * @return true if at least one chunk raised a level, otherwise false
- */
- private static boolean exchange(@Nullable NeighbourhoodEntry[] neighbourhood) {
- boolean changed = false;
+ int[] neighbours = new int[states.size() * BorderExchange.FACES.length];
for (int offsetZ = -NEIGHBOURHOOD_RADIUS; offsetZ <= NEIGHBOURHOOD_RADIUS; offsetZ++) {
for (int offsetX = -NEIGHBOURHOOD_RADIUS; offsetX <= NEIGHBOURHOOD_RADIUS; offsetX++) {
- @Nullable NeighbourhoodEntry entry = neighbourhood[slot(offsetX, offsetZ)];
+ int chunk = indices[slot(offsetX, offsetZ)];
- if (entry == null) {
+ if (chunk < 0) {
continue;
}
- for (BlockFace face : HORIZONTAL_FACES) {
- int neighbourX = offsetX + face.offsetX();
- int neighbourZ = offsetZ + face.offsetZ();
-
- if (isOutside(neighbourX, neighbourZ)) {
- continue;
- }
-
- @Nullable NeighbourhoodEntry neighbour = neighbourhood[slot(neighbourX, neighbourZ)];
-
- if (neighbour == null) {
- continue;
- }
- changed |= entry.state().injectBorder(
- entry.opacity(), face, neighbour.state().border(face.opposite())
- );
+ for (int face = 0; face < BorderExchange.FACES.length; face++) {
+ int neighbourX = offsetX + BorderExchange.FACES[face].offsetX();
+ int neighbourZ = offsetZ + BorderExchange.FACES[face].offsetZ();
+ neighbours[chunk * BorderExchange.FACES.length + face] =
+ isOutside(neighbourX, neighbourZ) ? -1 : indices[slot(neighbourX, neighbourZ)];
}
}
}
- return changed;
+
+ if (!new BorderExchange(states, opacity, neighbours).settle(MAX_EXCHANGE_ROUNDS)) {
+ LOGGER.warn(
+ "The light of a chunk and its neighbours did not settle after {} exchange rounds chunk=[{},{}]",
+ MAX_EXCHANGE_ROUNDS, chunkX, chunkZ
+ );
+ }
}
/**
diff --git a/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightState.java b/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightState.java
index fa7f717..039ca28 100644
--- a/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightState.java
+++ b/falco-light/src/main/java/net/onelitefeather/falco/light/ChunkLightState.java
@@ -2,6 +2,7 @@
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.VisibleForTesting;
import java.util.ArrayList;
import java.util.Arrays;
@@ -43,11 +44,46 @@ public final class ChunkLightState {
private static final BlockFace[] FACES = BlockFace.values();
private static final int MASK = LightNibbles.DIMENSION - 1;
+ /**
+ * The opposite of every face, indexed like {@link #FACES}, so the search reads it from a table
+ * instead of switching over the face for every neighbour it looks at.
+ */
+ private static final BlockFace[] OPPOSITES = opposites();
+
/**
* The heightmap of a state which holds block light. Block light never reads it.
*/
private static final int[] NO_HEIGHTMAP = new int[0];
+ /**
+ * The retraction queue of a state which has not retracted anything yet.
+ *
+ * Only an update retracts, and most states never see one: a ring chunk of an area pass is
+ * copied, takes part in the border exchange and is thrown away. Sharing an empty queue spares
+ * every such copy 5 KB. Nothing is ever written into it, since the first write grows the queue.
+ *
+ */
+ private static final int[] NO_REMOVALS = new int[0];
+
+ /**
+ * The levels belonging to {@link #NO_REMOVALS}.
+ */
+ private static final byte[] NO_REMOVAL_LEVELS = new byte[0];
+
+ /**
+ * Builds the table {@link #OPPOSITES} holds.
+ *
+ * @return the opposite of every face, indexed like {@link #FACES}
+ */
+ private static BlockFace[] opposites() {
+ BlockFace[] opposites = new BlockFace[FACES.length];
+
+ for (int index = 0; index < FACES.length; index++) {
+ opposites[index] = FACES[index].opposite();
+ }
+ return opposites;
+ }
+
/**
* The height of a column which nothing stops the sky in.
*/
@@ -88,8 +124,8 @@ private ChunkLightState(byte[] levels, int sectionCount, boolean sky, int[] skyT
this.height = sectionCount * LightNibbles.DIMENSION;
this.sky = sky;
this.skyTop = skyTop;
- this.removalQueue = new int[INITIAL_QUEUE_SIZE];
- this.removalLevels = new byte[INITIAL_QUEUE_SIZE];
+ this.removalQueue = NO_REMOVALS;
+ this.removalLevels = NO_REMOVAL_LEVELS;
this.additionQueue = new int[INITIAL_QUEUE_SIZE];
}
@@ -137,16 +173,32 @@ private void ensureRoom(int tail) {
/**
* Makes room for one more entry in the retraction queue.
+ *
+ * A state starts without a retraction queue, see {@link #NO_REMOVALS}, so the first entry
+ * creates it at {@link #INITIAL_QUEUE_SIZE} and every later one doubles it.
+ *
*
* @param tail the amount of entries the queue currently holds
*/
private void ensureRemovalRoom(int tail) {
if (tail == this.removalQueue.length) {
- this.removalQueue = Arrays.copyOf(this.removalQueue, this.removalQueue.length * 2);
- this.removalLevels = Arrays.copyOf(this.removalLevels, this.removalLevels.length * 2);
+ int size = Math.max(INITIAL_QUEUE_SIZE, this.removalQueue.length * 2);
+ this.removalQueue = Arrays.copyOf(this.removalQueue, size);
+ this.removalLevels = Arrays.copyOf(this.removalLevels, size);
}
}
+ /**
+ * Returns whether this state holds a retraction queue of its own.
+ *
+ * @return true once an update retracted something, otherwise false
+ */
+ @VisibleForTesting
+ @Contract(pure = true)
+ boolean holdsRemovalQueue() {
+ return this.removalQueue != NO_REMOVALS;
+ }
+
/**
* Calculates the block light of a chunk and keeps it for later updates.
*
@@ -154,63 +206,46 @@ private void ensureRemovalRoom(int tail) {
* @return the created state
*/
public static ChunkLightState blockLight(List sections) {
- return of(sections, new ChunkLightPropagator().propagate(sections), false);
+ return blockLight(sections, new ChunkLightPropagator());
}
/**
- * Calculates the sky light of a chunk and keeps it for later updates.
+ * Calculates the block light of a chunk with the given propagator and keeps it for later updates.
+ *
+ * A propagator sizes its buffers for a whole column, which is several hundred kilobytes for a
+ * tall world. A caller which builds many states one after another hands in the same propagator
+ * each time instead of having a new one allocated for every chunk.
+ *
*
- * @param sections the light properties of every section of the chunk
+ * @param sections the light properties of every section of the chunk
+ * @param propagator the propagator to calculate with, used by nobody else during the call
* @return the created state
*/
- public static ChunkLightState skyLight(List sections) {
- return of(sections, new ChunkLightPropagator().propagateSky(sections), true);
+ static ChunkLightState blockLight(List sections, ChunkLightPropagator propagator) {
+ return new ChunkLightState(propagator.propagateLevels(sections), sections.size(), false, NO_HEIGHTMAP);
}
/**
- * Builds a state from an already calculated light.
+ * Calculates the sky light of a chunk and keeps it for later updates.
*
* @param sections the light properties of every section of the chunk
- * @param light the calculated light of every section
- * @param sky whether the light is sky light
* @return the created state
*/
- private static ChunkLightState of(List sections, List light, boolean sky) {
- int sectionCount = sections.size();
- byte[] levels = new byte[sectionCount * LightNibbles.BLOCK_COUNT];
-
- for (int section = 0; section < sectionCount; section++) {
- LightNibbles nibbles = light.get(section);
- int base = section * LightNibbles.DIMENSION;
-
- for (int y = 0; y < LightNibbles.DIMENSION; y++) {
- for (int z = 0; z < LightNibbles.DIMENSION; z++) {
- for (int x = 0; x < LightNibbles.DIMENSION; x++) {
- levels[index(x, base + y, z)] = (byte) nibbles.get(x, y, z);
- }
- }
- }
- }
- return new ChunkLightState(levels, sectionCount, sky, sky ? skyTopOf(sections) : NO_HEIGHTMAP);
+ public static ChunkLightState skyLight(List sections) {
+ return skyLight(sections, new ChunkLightPropagator());
}
/**
- * Determines for every column of the chunk where the sky stops.
+ * Calculates the sky light of a chunk with the given propagator and keeps it for later updates.
*
- * @param sections the light properties of every section of the chunk
- * @return the highest position which stops the sky per column
+ * @param sections the light properties of every section of the chunk
+ * @param propagator the propagator to calculate with, used by nobody else during the call
+ * @return the created state
+ * @see #blockLight(List, ChunkLightPropagator)
*/
- @Contract(pure = true)
- private static int[] skyTopOf(List sections) {
- int height = sections.size() * LightNibbles.DIMENSION;
- int[] skyTop = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION];
-
- for (int z = 0; z < LightNibbles.DIMENSION; z++) {
- for (int x = 0; x < LightNibbles.DIMENSION; x++) {
- skyTop[column(x, z)] = columnTop(sections, height, x, z);
- }
- }
- return skyTop;
+ static ChunkLightState skyLight(List sections, ChunkLightPropagator propagator) {
+ byte[] levels = propagator.propagateSkyLevels(sections);
+ return new ChunkLightState(levels, sections.size(), true, propagator.skyTops());
}
/**
@@ -511,7 +546,8 @@ private void spread(List sections, int queued) {
int y = index >> 8;
int next = level - 1;
- for (BlockFace face : FACES) {
+ for (int faceIndex = 0; faceIndex < FACES.length; faceIndex++) {
+ BlockFace face = FACES[faceIndex];
int neighbourX = x + face.offsetX();
int neighbourY = y + face.offsetY();
int neighbourZ = z + face.offsetZ();
@@ -519,15 +555,17 @@ private void spread(List sections, int queued) {
if (isOutside(neighbourX, neighbourY, neighbourZ)) {
continue;
}
- if (blocksFace(sections, neighbourX, neighbourY, neighbourZ, face.opposite())) {
- continue;
- }
int neighbourIndex = index(neighbourX, neighbourY, neighbourZ);
+ // The level is one array read, the occlusion a list lookup, a section and a table,
+ // and the level rejects far more often. Cheapest and most selective test first.
if (this.levels[neighbourIndex] >= next) {
continue;
}
+ if (blocksFace(sections, neighbourX, neighbourY, neighbourZ, OPPOSITES[faceIndex])) {
+ continue;
+ }
this.levels[neighbourIndex] = (byte) next;
ensureRoom(tail);
this.additionQueue[tail++] = neighbourIndex;
@@ -550,10 +588,15 @@ private void spread(List sections, int queued) {
public byte[] border(BlockFace face) {
checkHorizontal(face);
byte[] border = new byte[this.height * LightNibbles.DIMENSION];
+ int start = borderStart(face);
+ int stride = borderStride(face);
+ int slot = 0;
for (int y = 0; y < this.height; y++) {
- for (int offset = 0; offset < LightNibbles.DIMENSION; offset++) {
- border[y * LightNibbles.DIMENSION + offset] = this.levels[borderIndex(face, y, offset)];
+ int index = (y << 8) + start;
+
+ for (int offset = 0; offset < LightNibbles.DIMENSION; offset++, index += stride) {
+ border[slot++] = this.levels[index];
}
}
return border;
@@ -589,19 +632,72 @@ public boolean injectBorder(List sections, BlockFace face, byte[
+ " levels but the given one holds " + border.length
);
}
+ return inject(sections, face, border, 0, LightNibbles.DIMENSION, 1);
+ }
+ /**
+ * Feeds the light of a neighbouring chunk into this one, reading its border straight from it.
+ *
+ * This is what an exchange over an area uses. {@link #border(BlockFace)} copies the edge into a
+ * new array only for it to be read once and thrown away, and an exchange does that for every
+ * face of every chunk in every round. Reading the levels of the neighbour in place gives the
+ * same result without the copy, since nothing writes to the neighbour while this chunk reads it.
+ *
+ *
+ * @param sections the light properties of every section of this chunk
+ * @param face the border the light enters through
+ * @param neighbour the chunk which lies beyond that border
+ * @return true if at least one level of this chunk was raised, otherwise false
+ * @throws IllegalArgumentException if the face is not horizontal or the neighbour has another height
+ * @see #injectBorder(List, BlockFace, byte[])
+ */
+ boolean injectBorder(List sections, BlockFace face, ChunkLightState neighbour) {
+ checkHorizontal(face);
+
+ if (neighbour.height != this.height) {
+ throw new IllegalArgumentException(
+ "This chunk is " + this.height + " blocks high but the given neighbour is " + neighbour.height
+ );
+ }
+ BlockFace opposite = face.opposite();
+ return inject(sections, face, neighbour.levels, borderStart(opposite), 1 << 8, borderStride(opposite));
+ }
+
+ /**
+ * Raises the levels along one border from the given source and spreads them into the chunk.
+ *
+ * The source is either a copied border or the levels of the neighbour itself. Both are walked by
+ * height first and along the border second, so the caller only describes where the walk starts
+ * and how far one step in either direction moves through the source.
+ *
+ *
+ * @param sections the light properties of every section of this chunk
+ * @param face the border the light enters through
+ * @param source the array holding the levels of the neighbouring border
+ * @param sourceStart the index of the bottom block at offset zero in the source
+ * @param sourceRowStride the distance between two heights in the source
+ * @param sourceStride the distance between two offsets in the source
+ * @return true if at least one level of this chunk was raised, otherwise false
+ */
+ private boolean inject(List sections, BlockFace face, byte[] source,
+ int sourceStart, int sourceRowStride, int sourceStride) {
int tail = 0;
+ int start = borderStart(face);
+ int stride = borderStride(face);
for (int y = 0; y < this.height; y++) {
+ int index = (y << 8) + start - stride;
+ int sourceIndex = y * sourceRowStride + sourceStart - sourceStride;
+
for (int offset = 0; offset < LightNibbles.DIMENSION; offset++) {
- int incoming = border[y * LightNibbles.DIMENSION + offset] - 1;
+ // Advanced first, so every continue below still moves on to the next block.
+ index += stride;
+ sourceIndex += sourceStride;
+ int incoming = source[sourceIndex] - 1;
if (incoming <= 0) {
continue;
}
-
- int index = borderIndex(face, y, offset);
-
if (this.levels[index] >= incoming) {
continue;
}
@@ -619,20 +715,38 @@ public boolean injectBorder(List sections, BlockFace face, byte[
}
/**
- * Calculates the index of a block which lies on the given border.
+ * Calculates the index of the first block of the given border at the bottom of the column.
+ *
+ * A border is walked by height first and along the remaining horizontal axis second. The face
+ * is the same for every block of that walk, so it is resolved once into this start and the
+ * {@link #borderStride(BlockFace) stride} instead of once per block.
+ *
*
- * @param face the border the block lies on
- * @param y the y coordinate inside the column
- * @param offset the position along the remaining horizontal axis
- * @return the index of the block
+ * @param face the border to walk
+ * @return the index of the block at offset zero and height zero
+ */
+ @Contract(pure = true)
+ private static int borderStart(BlockFace face) {
+ return switch (face) {
+ case WEST, NORTH -> index(0, 0, 0);
+ case EAST -> index(LightNibbles.DIMENSION - 1, 0, 0);
+ case SOUTH -> index(0, 0, LightNibbles.DIMENSION - 1);
+ case TOP, BOTTOM -> throw new IllegalArgumentException("The face " + face + " is not horizontal");
+ };
+ }
+
+ /**
+ * Calculates how far apart two neighbouring blocks of the given border lie in the index.
+ *
+ * @param face the border to walk
+ * @return the distance between two blocks one offset apart
+ * @see #borderStart(BlockFace)
*/
@Contract(pure = true)
- private static int borderIndex(BlockFace face, int y, int offset) {
+ private static int borderStride(BlockFace face) {
return switch (face) {
- case WEST -> index(0, y, offset);
- case EAST -> index(LightNibbles.DIMENSION - 1, y, offset);
- case NORTH -> index(offset, y, 0);
- case SOUTH -> index(offset, y, LightNibbles.DIMENSION - 1);
+ case WEST, EAST -> index(0, 0, 1);
+ case NORTH, SOUTH -> index(1, 0, 0);
case TOP, BOTTOM -> throw new IllegalArgumentException("The face " + face + " is not horizontal");
};
}
@@ -661,26 +775,10 @@ public List toSections() {
List result = new ArrayList<>(this.sectionCount);
for (int section = 0; section < this.sectionCount; section++) {
- int base = section * LightNibbles.DIMENSION;
- LightNibbles nibbles = LightNibbles.uniform(0);
- boolean uniform = true;
- int first = this.levels[index(0, base, 0)];
-
- for (int y = 0; y < LightNibbles.DIMENSION; y++) {
- for (int z = 0; z < LightNibbles.DIMENSION; z++) {
- for (int x = 0; x < LightNibbles.DIMENSION; x++) {
- int level = this.levels[index(x, base + y, z)];
-
- if (level != first) {
- uniform = false;
- }
- if (level != 0) {
- nibbles.set(x, y, z, level);
- }
- }
- }
- }
- result.add(uniform ? LightNibbles.uniform(first) : nibbles);
+ // The levels of a section lie next to each other, because the index of a position puts
+ // its y coordinate into the highest bits. One pass packs them and detects a uniform
+ // section, where writing them one by one would check and shift every single level.
+ result.add(LightNibbles.ofLevels(this.levels, section * LightNibbles.BLOCK_COUNT));
}
return result;
}
diff --git a/falco-light/src/main/java/net/onelitefeather/falco/light/LightNibbles.java b/falco-light/src/main/java/net/onelitefeather/falco/light/LightNibbles.java
index 75c99e1..3e5e247 100644
--- a/falco-light/src/main/java/net/onelitefeather/falco/light/LightNibbles.java
+++ b/falco-light/src/main/java/net/onelitefeather/falco/light/LightNibbles.java
@@ -133,16 +133,28 @@ static LightNibbles ofLevels(byte[] levels, int offset) {
);
}
- byte[] packed = new byte[ARRAY_LENGTH];
int first = levels[offset];
- int differing = 0;
+ int end = offset + BLOCK_COUNT;
+ int position = offset + 1;
+
+ // Most sections of a world are dark or fully lit, and those are answered without a table.
+ // Looking for the first differing level before packing keeps them from allocating one only
+ // to throw it away, and for every other section the scan stops after a few positions.
+ while (position < end && levels[position] == first) {
+ position++;
+ }
+
+ if (position == end) {
+ return new LightNibbles(checkLevel(first));
+ }
+
+ byte[] packed = new byte[ARRAY_LENGTH];
int outOfRange = 0;
for (int index = 0; index < ARRAY_LENGTH; index++) {
int low = levels[offset + (index << 1)];
int high = levels[offset + (index << 1) + 1];
packed[index] = (byte) ((low & 0x0F) | ((high & 0x0F) << 4));
- differing |= (low ^ first) | (high ^ first);
outOfRange |= low | high;
}
@@ -152,10 +164,6 @@ static LightNibbles ofLevels(byte[] levels, int offset) {
throw new IllegalArgumentException("A light level must be within [0, " + MAX_LEVEL + "]");
}
- if (differing == 0) {
- return new LightNibbles(first);
- }
-
LightNibbles nibbles = new LightNibbles(0);
nibbles.levels = packed;
return nibbles;
diff --git a/falco-light/src/main/java/net/onelitefeather/falco/light/SectionOpacity.java b/falco-light/src/main/java/net/onelitefeather/falco/light/SectionOpacity.java
index 5cfc197..df9ad78 100644
--- a/falco-light/src/main/java/net/onelitefeather/falco/light/SectionOpacity.java
+++ b/falco-light/src/main/java/net/onelitefeather/falco/light/SectionOpacity.java
@@ -3,6 +3,7 @@
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.VisibleForTesting;
import java.util.Arrays;
@@ -61,6 +62,7 @@ public final class SectionOpacity {
*
* @param occlusion the occluded faces of every block, or null for a uniform section
* @param emission the emitted light level of every block, or null for a uniform section
+ * and for a section without any light source
* @param uniformOcclusion the occluded faces of a uniform section
* @param uniformEmission the emitted light level of a uniform section
* @param hasEmission whether any block of the section emits light
@@ -132,7 +134,12 @@ public static SectionOpacity of(int[] stateIds, BlockLightSource source) {
anyEmission |= emitted;
anyOcclusion |= occluded;
}
- return new SectionOpacity(occlusion, emission, (byte) 0, (byte) 0, anyEmission != 0, anyOcclusion == 0);
+
+ // Most sections hold no light source at all, and a table of zeros answers nothing a missing
+ // one does not. It is still filled during the walk, since skipping it there costs more time
+ // than the allocation saves, but it is not kept for as long as the section lives.
+ byte @Nullable [] emissionTable = anyEmission != 0 ? emission : null;
+ return new SectionOpacity(occlusion, emissionTable, (byte) 0, (byte) 0, anyEmission != 0, anyOcclusion == 0);
}
/**
@@ -392,6 +399,17 @@ public boolean isUniform() {
return this.occlusion == null;
}
+ /**
+ * Checks whether this section keeps a table with the emission of every block.
+ *
+ * @return true if the section holds a per position emission table, otherwise false
+ */
+ @VisibleForTesting
+ @Contract(pure = true)
+ boolean holdsEmissionTable() {
+ return this.emission != null;
+ }
+
/**
* Determines whether every block of the given section holds the same state.
*
diff --git a/falco-light/src/test/java/net/onelitefeather/falco/light/BorderExchangeTest.java b/falco-light/src/test/java/net/onelitefeather/falco/light/BorderExchangeTest.java
new file mode 100644
index 0000000..26695c7
--- /dev/null
+++ b/falco-light/src/test/java/net/onelitefeather/falco/light/BorderExchangeTest.java
@@ -0,0 +1,186 @@
+package net.onelitefeather.falco.light;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests that an exchange which skips unchanged neighbours settles on exactly the light an exchange
+ * of every border in every round settles on, and that it really skips them.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 3.0.2
+ */
+class BorderExchangeTest {
+
+ private static final int STONE = 1;
+ private static final int LAMP = 2;
+ private static final int SIZE = 4;
+ private static final int SECTIONS = 2;
+
+ private static final BlockLightSource SOURCE = new BlockLightSource() {
+
+ @Override
+ public int emission(int stateId) {
+ return stateId == LAMP ? 15 : 0;
+ }
+
+ @Override
+ public boolean blocksFace(int stateId, BlockFace face) {
+ return stateId == STONE;
+ }
+ };
+
+ /**
+ * Builds the opacity tables of a grid of chunks with lamps near their edges and stone walls
+ * which force the light to take a detour through other chunks.
+ *
+ * @return the opacity tables of every chunk, row by row
+ */
+ private static List> grid() {
+ List> grid = new ArrayList<>(SIZE * SIZE);
+
+ for (int chunk = 0; chunk < SIZE * SIZE; chunk++) {
+ List sections = new ArrayList<>(SECTIONS);
+
+ for (int section = 0; section < SECTIONS; section++) {
+ int[] states = new int[LightNibbles.BLOCK_COUNT];
+
+ // A wall across the middle of some chunks, so light has to go around it.
+ if (chunk % 3 == 0) {
+ for (int y = 0; y < LightNibbles.DIMENSION; y++) {
+ for (int x = 0; x < LightNibbles.DIMENSION - 1; x++) {
+ states[(y << 8) | (8 << 4) | x] = STONE;
+ }
+ }
+ }
+ if (chunk % 5 == 0 && section == 1) {
+ states[(4 << 8) | ((chunk % 16) << 4) | 15] = LAMP;
+ }
+ if (chunk % 7 == 1 && section == 0) {
+ states[(9 << 8) | (2 << 4)] = LAMP;
+ }
+ sections.add(SectionOpacity.of(states, SOURCE));
+ }
+ grid.add(sections);
+ }
+ return grid;
+ }
+
+ /**
+ * Finds the neighbours of every chunk of the grid, in the order {@link BorderExchange#FACES}
+ * names the faces.
+ *
+ * @return the index of the neighbour per chunk and face, or a negative value if there is none
+ */
+ private static int[] neighbours() {
+ int[] neighbours = new int[SIZE * SIZE * BorderExchange.FACES.length];
+
+ for (int z = 0; z < SIZE; z++) {
+ for (int x = 0; x < SIZE; x++) {
+ for (int face = 0; face < BorderExchange.FACES.length; face++) {
+ int neighbourX = x + BorderExchange.FACES[face].offsetX();
+ int neighbourZ = z + BorderExchange.FACES[face].offsetZ();
+ boolean outside = neighbourX < 0 || neighbourZ < 0 || neighbourX >= SIZE || neighbourZ >= SIZE;
+ neighbours[(z * SIZE + x) * BorderExchange.FACES.length + face] =
+ outside ? -1 : neighbourZ * SIZE + neighbourX;
+ }
+ }
+ }
+ return neighbours;
+ }
+
+ /**
+ * Calculates the light of every chunk of the grid on its own.
+ *
+ * @param grid the opacity tables of every chunk
+ * @return the light of every chunk before any exchange
+ */
+ private static List states(List> grid) {
+ return grid.stream().map(ChunkLightState::blockLight).toList();
+ }
+
+ @Test
+ void testTheExchangeSettlesOnTheLightOfAFullExchange() {
+ List> grid = grid();
+ int[] neighbours = neighbours();
+
+ List expected = states(grid);
+ boolean changed = true;
+
+ // Every border in every round, as the exchange did before it learned to skip.
+ while (changed) {
+ changed = false;
+
+ for (int chunk = 0; chunk < expected.size(); chunk++) {
+ for (int face = 0; face < BorderExchange.FACES.length; face++) {
+ int neighbour = neighbours[chunk * BorderExchange.FACES.length + face];
+
+ if (neighbour >= 0) {
+ changed |= expected.get(chunk).injectBorder(
+ grid.get(chunk), BorderExchange.FACES[face], expected.get(neighbour));
+ }
+ }
+ }
+ }
+
+ List actual = states(grid);
+ assertTrue(new BorderExchange(actual, grid, neighbours).settle(16), "the grid settles");
+
+ for (int chunk = 0; chunk < actual.size(); chunk++) {
+ for (int y = 0; y < SECTIONS * LightNibbles.DIMENSION; y++) {
+ for (int z = 0; z < LightNibbles.DIMENSION; z++) {
+ for (int x = 0; x < LightNibbles.DIMENSION; x++) {
+ assertEquals(expected.get(chunk).get(x, y, z), actual.get(chunk).get(x, y, z),
+ "mismatch in chunk " + chunk + " at " + x + " " + y + " " + z);
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ void testASettledExchangeInjectsNothingAgain() {
+ List> grid = grid();
+ BorderExchange exchange = new BorderExchange(states(grid), grid, neighbours());
+
+ assertTrue(exchange.settle(16), "the grid settles");
+ long injections = exchange.injections();
+
+ assertTrue(exchange.settle(16), "a settled grid stays settled");
+ assertEquals(injections, exchange.injections(), "no neighbour changed, so no border is injected again");
+ }
+
+ @Test
+ void testAnUnchangedNeighbourIsNotInjectedTwice() {
+ List> grid = grid();
+ int[] neighbours = neighbours();
+ int borders = 0;
+
+ for (int neighbour : neighbours) {
+ if (neighbour >= 0) {
+ borders++;
+ }
+ }
+ BorderExchange exchange = new BorderExchange(states(grid), grid, neighbours);
+
+ assertTrue(exchange.settle(16), "the grid settles");
+ assertTrue(exchange.rounds() > 1, "the grid needs more than one round, or the test proves nothing");
+ assertTrue(exchange.injections() < (long) exchange.rounds() * borders,
+ "a round after the first injects only the borders of neighbours which changed");
+ }
+
+ @Test
+ void testAnAreaWhichCannotSettleIsReported() {
+ List> grid = grid();
+ BorderExchange exchange = new BorderExchange(states(grid), grid, neighbours());
+
+ assertFalse(exchange.settle(1), "one round is not enough for light which has to cross several chunks");
+ }
+}
diff --git a/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkBorderLightTest.java b/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkBorderLightTest.java
index d8155d9..8a0d473 100644
--- a/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkBorderLightTest.java
+++ b/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkBorderLightTest.java
@@ -74,6 +74,97 @@ private static int index(int x, int y, int z) {
return (y << 8) | (z << 4) | x;
}
+ /**
+ * Returns the x and z coordinate of a block on the given border, independent of how the state
+ * lays the border out internally.
+ *
+ * @param face the border the block lies on
+ * @param offset the position along the remaining horizontal axis
+ * @return the x coordinate first and the z coordinate second
+ */
+ private static int[] borderColumn(BlockFace face, int offset) {
+ return switch (face) {
+ case WEST -> new int[]{0, offset};
+ case EAST -> new int[]{LightNibbles.DIMENSION - 1, offset};
+ case NORTH -> new int[]{offset, 0};
+ case SOUTH -> new int[]{offset, LightNibbles.DIMENSION - 1};
+ case TOP, BOTTOM -> throw new IllegalArgumentException("The face " + face + " is not horizontal");
+ };
+ }
+
+ @Test
+ void testEveryBorderReadsItsOwnEdge() {
+ int y = 20;
+ int offset = 5;
+
+ for (BlockFace face : List.of(BlockFace.WEST, BlockFace.EAST, BlockFace.NORTH, BlockFace.SOUTH)) {
+ int[] column = borderColumn(face, offset);
+ List sections = airChunk(2);
+ sections.get(y >> 4)[index(column[0], y & 15, column[1])] = LAMP;
+ ChunkLightState state = ChunkLightState.blockLight(tables(sections));
+
+ byte[] border = state.border(face);
+
+ assertEquals(15, border[y * LightNibbles.DIMENSION + offset], "the lamp sits on the " + face + " border");
+ }
+ }
+
+ @Test
+ void testEveryBorderInjectsIntoItsOwnEdge() {
+ int y = 20;
+ int offset = 5;
+
+ for (BlockFace face : List.of(BlockFace.WEST, BlockFace.EAST, BlockFace.NORTH, BlockFace.SOUTH)) {
+ int[] column = borderColumn(face, offset);
+ List tables = tables(airChunk(2));
+ ChunkLightState state = ChunkLightState.blockLight(tables);
+ byte[] border = new byte[2 * LightNibbles.DIMENSION * LightNibbles.DIMENSION];
+ border[y * LightNibbles.DIMENSION + offset] = 15;
+
+ state.injectBorder(tables, face, border);
+
+ assertEquals(14, state.get(column[0], y, column[1]), "the light enters through the " + face + " border");
+ }
+ }
+
+ @Test
+ void testInjectingFromTheNeighbourMatchesInjectingItsBorder() {
+ for (BlockFace face : List.of(BlockFace.WEST, BlockFace.EAST, BlockFace.NORTH, BlockFace.SOUTH)) {
+ // The lamp sits on the edge of the neighbour that faces this chunk.
+ int[] column = borderColumn(face.opposite(), 5);
+ List neighbour = airChunk(2);
+ neighbour.get(1)[index(column[0], 4, column[1])] = LAMP;
+ ChunkLightState neighbourState = ChunkLightState.blockLight(tables(neighbour));
+
+ List tables = tables(airChunk(2));
+ ChunkLightState copied = ChunkLightState.blockLight(tables);
+ ChunkLightState direct = ChunkLightState.blockLight(tables);
+
+ boolean copiedChanged = copied.injectBorder(tables, face, neighbourState.border(face.opposite()));
+ boolean directChanged = direct.injectBorder(tables, face, neighbourState);
+
+ assertEquals(copiedChanged, directChanged, "both injections report the same through " + face);
+
+ for (int y = 0; y < 2 * LightNibbles.DIMENSION; y++) {
+ for (int z = 0; z < LightNibbles.DIMENSION; z++) {
+ for (int x = 0; x < LightNibbles.DIMENSION; x++) {
+ assertEquals(copied.get(x, y, z), direct.get(x, y, z),
+ "mismatch through " + face + " at " + x + " " + y + " " + z);
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ void testANeighbourOfAnotherHeightIsRejected() {
+ List tables = tables(airChunk(2));
+ ChunkLightState state = ChunkLightState.blockLight(tables);
+ ChunkLightState neighbour = ChunkLightState.blockLight(tables(airChunk(1)));
+
+ assertThrows(IllegalArgumentException.class, () -> state.injectBorder(tables, BlockFace.WEST, neighbour));
+ }
+
@Test
void testTheBorderOfAChunkCanBeRead() {
List sections = airChunk(1);
diff --git a/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightServiceConcurrencyTest.java b/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightServiceConcurrencyTest.java
index 1c042a1..3831133 100644
--- a/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightServiceConcurrencyTest.java
+++ b/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightServiceConcurrencyTest.java
@@ -69,8 +69,8 @@ void testOneServiceCalculatesTheSameBlockLightFromManyThreads(Env env) throws In
List chunks = lampChunks(instance);
List> expected = new ArrayList<>(chunks.size());
- // The reference is taken one chunk after the other. A service which keeps nothing between
- // two calls has to reproduce it no matter how many threads call it.
+ // The reference is taken one chunk after the other. A service whose pool never hands one
+ // propagator to two calls at once has to reproduce it no matter how many threads call it.
for (Chunk chunk : chunks) {
ChunkLightService reference = new ChunkLightService();
reference.calculate(chunk);
diff --git a/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightServicePoolTest.java b/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightServicePoolTest.java
new file mode 100644
index 0000000..2bdbeb0
--- /dev/null
+++ b/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightServicePoolTest.java
@@ -0,0 +1,70 @@
+package net.onelitefeather.falco.light;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Pins down how a {@link ChunkLightService} hands out its propagators.
+ *
+ * The pool is what keeps a call from allocating the buffers of a whole column, and it is also the
+ * one piece of state a call shares with the next. Reusing a propagator is the point; handing the same
+ * one to two calls at once would let them overwrite each other's buffers.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 3.0.2
+ */
+class ChunkLightServicePoolTest {
+
+ private static final BlockLightSource SOURCE = new BlockLightSource() {
+
+ @Override
+ public int emission(int stateId) {
+ return 0;
+ }
+
+ @Override
+ public boolean blocksFace(int stateId, BlockFace face) {
+ return false;
+ }
+ };
+
+ @Test
+ void testAPropagatorIsReusedByTheNextCall() {
+ ChunkLightService service = new ChunkLightService(SOURCE);
+
+ ChunkLightPropagator first = service.withPropagator(propagator -> propagator);
+ ChunkLightPropagator second = service.withPropagator(propagator -> propagator);
+
+ assertSame(first, second, "a call after another takes the propagator the first one returned");
+ }
+
+ @Test
+ void testTwoCallsAtOnceHoldDifferentPropagators() {
+ ChunkLightService service = new ChunkLightService(SOURCE);
+
+ service.withPropagator(outer -> {
+ ChunkLightPropagator inner = service.withPropagator(propagator -> propagator);
+ assertNotSame(outer, inner, "a call while another holds a propagator gets one of its own");
+ return null;
+ });
+ }
+
+ @Test
+ void testAPropagatorIsReturnedWhenTheWorkFails() {
+ ChunkLightService service = new ChunkLightService(SOURCE);
+ ChunkLightPropagator[] held = new ChunkLightPropagator[1];
+
+ assertThrows(IllegalStateException.class, () -> service.withPropagator(propagator -> {
+ held[0] = propagator;
+ throw new IllegalStateException("the work failed");
+ }));
+
+ assertSame(held[0], service.withPropagator(propagator -> propagator),
+ "a failed call still hands its propagator back");
+ }
+}
diff --git a/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightStateTest.java b/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightStateTest.java
index 74ffe10..eb564c8 100644
--- a/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightStateTest.java
+++ b/falco-light/src/test/java/net/onelitefeather/falco/light/ChunkLightStateTest.java
@@ -7,6 +7,8 @@
import java.util.Random;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the incremental update of an already calculated chunk light.
@@ -335,4 +337,31 @@ public boolean blocksFace(int stateId, BlockFace face) {
assertEquals(15, state.get(8, 24, 8));
}
+
+ @Test
+ void testAStateCreatesItsRetractionQueueOnlyWhenItRetracts() {
+ List sections = airChunk(2);
+ sections.get(0)[index(8, 8, 8)] = LAMP;
+ ChunkLightState state = ChunkLightState.blockLight(tables(sections));
+ ChunkLightState copy = state.copy();
+
+ assertFalse(state.holdsRemovalQueue(), "a calculated state has retracted nothing");
+ assertFalse(copy.holdsRemovalQueue(), "and neither has its copy");
+
+ // A lamp in open air lit a few thousand positions, so the retraction has to grow the queue
+ // past its first size as well as create it.
+ sections.get(0)[index(8, 8, 8)] = AIR;
+ copy.update(tables(sections), 8, 8, 8);
+
+ assertTrue(copy.holdsRemovalQueue(), "the update retracted the light of the lamp");
+ assertFalse(state.holdsRemovalQueue(), "the state the copy came from shares no queue with it");
+
+ for (int y = 0; y < 2 * LightNibbles.DIMENSION; y++) {
+ for (int z = 0; z < LightNibbles.DIMENSION; z++) {
+ for (int x = 0; x < LightNibbles.DIMENSION; x++) {
+ assertEquals(0, copy.get(x, y, z), "every position the lamp lit is dark again");
+ }
+ }
+ }
+ }
}
diff --git a/falco-light/src/test/java/net/onelitefeather/falco/light/LightNibblesTest.java b/falco-light/src/test/java/net/onelitefeather/falco/light/LightNibblesTest.java
index f7626dc..cb4a7c6 100644
--- a/falco-light/src/test/java/net/onelitefeather/falco/light/LightNibblesTest.java
+++ b/falco-light/src/test/java/net/onelitefeather/falco/light/LightNibblesTest.java
@@ -261,6 +261,29 @@ void testALevelOutsideTheAllowedRangeIsRejected() {
assertThrows(IllegalArgumentException.class, () -> LightNibbles.ofLevels(levels, 0));
}
+ @Test
+ void testARepeatedLevelOutsideTheAllowedRangeIsRejected() {
+ // A uniform section is answered without packing anything, so the range check must not
+ // depend on the packing loop.
+ byte[] levels = new byte[LightNibbles.BLOCK_COUNT];
+ Arrays.fill(levels, (byte) 42);
+
+ assertThrows(IllegalArgumentException.class, () -> LightNibbles.ofLevels(levels, 0));
+ }
+
+ @Test
+ void testALevelArrayWhichDiffersOnlyAtTheLastPositionIsNotUniform() {
+ byte[] levels = new byte[LightNibbles.BLOCK_COUNT];
+ Arrays.fill(levels, (byte) 5);
+ levels[LightNibbles.BLOCK_COUNT - 1] = 9;
+
+ LightNibbles nibbles = LightNibbles.ofLevels(levels, 0);
+
+ assertFalse(nibbles.isUniform());
+ assertEquals(5, nibbles.get(0, 0, 0));
+ assertEquals(9, nibbles.get(15, 15, 15));
+ }
+
@Test
void testALevelArrayWhichIsTooShortIsRejected() {
assertThrows(IllegalArgumentException.class, () -> LightNibbles.ofLevels(new byte[10], 0));
diff --git a/falco-light/src/test/java/net/onelitefeather/falco/light/SectionOpacityTest.java b/falco-light/src/test/java/net/onelitefeather/falco/light/SectionOpacityTest.java
index f305ff4..190aebf 100644
--- a/falco-light/src/test/java/net/onelitefeather/falco/light/SectionOpacityTest.java
+++ b/falco-light/src/test/java/net/onelitefeather/falco/light/SectionOpacityTest.java
@@ -299,6 +299,30 @@ public boolean blocksFace(int stateId, BlockFace face) {
}
}
+ @Test
+ void testAMixedSectionWithoutALightSourceHoldsNoEmissionTable() {
+ int[] states = new int[LightNibbles.BLOCK_COUNT];
+ states[LightNibbles.BLOCK_COUNT - 1] = STONE;
+ SectionOpacity opacity = SectionOpacity.of(states, SOURCE);
+
+ assertFalse(opacity.isUniform(), "the section has to be mixed, or the test proves nothing");
+ assertFalse(opacity.hasEmission());
+ assertFalse(opacity.holdsEmissionTable(), "a table of zeros carries nothing");
+ assertEquals(0, opacity.emission(0, 0, 0));
+ assertEquals(0, opacity.emission(15, 15, 15));
+ }
+
+ @Test
+ void testAMixedSectionWithALightSourceKeepsItsEmissionTable() {
+ int[] states = new int[LightNibbles.BLOCK_COUNT];
+ states[LightNibbles.BLOCK_COUNT - 1] = GLOWSTONE;
+ SectionOpacity opacity = SectionOpacity.of(states, SOURCE);
+
+ assertTrue(opacity.holdsEmissionTable());
+ assertEquals(15, opacity.emission(15, 15, 15));
+ assertEquals(0, opacity.emission(0, 0, 0));
+ }
+
@Test
void testAUniformSectionIsRecognised() {
// Whole sections of a world hold one repeated state. Such a section needs no per position
diff --git a/falco-light/src/test/java/net/onelitefeather/falco/light/SkyHeightmapSeedTest.java b/falco-light/src/test/java/net/onelitefeather/falco/light/SkyHeightmapSeedTest.java
index ec77eb9..a29686d 100644
--- a/falco-light/src/test/java/net/onelitefeather/falco/light/SkyHeightmapSeedTest.java
+++ b/falco-light/src/test/java/net/onelitefeather/falco/light/SkyHeightmapSeedTest.java
@@ -4,10 +4,12 @@
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Deque;
import java.util.List;
import java.util.Random;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
@@ -321,4 +323,45 @@ void testTwoNeighbouringColumnsThatStopOneApartMatchTheReference() {
assertMatchesReference(sections, "a single column with a ceiling");
}
+
+ @Test
+ void testTheHeightmapHandedToTheStateIsWhereEachColumnFirstStopsTheSky() {
+ int sectionCount = 4;
+ Random random = new Random(SEED);
+ int[] ceiling = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION];
+ int[] floor = new int[ceiling.length];
+
+ // A floor under every ceiling, so a column stops the sky twice and only the upper block may
+ // count. A column without a ceiling keeps its floor and stops the sky there instead.
+ for (int column = 0; column < ceiling.length; column++) {
+ floor[column] = random.nextInt(LightNibbles.DIMENSION);
+ ceiling[column] = random.nextInt(4) == 0
+ ? -1
+ : LightNibbles.DIMENSION + random.nextInt((sectionCount - 1) * LightNibbles.DIMENSION);
+ }
+ List sections = airChunk(sectionCount);
+ roof(sections, floor, STONE);
+ roof(sections, ceiling, SLAB);
+
+ int[] expected = new int[ceiling.length];
+
+ for (int column = 0; column < ceiling.length; column++) {
+ expected[column] = ceiling[column] >= 0 ? ceiling[column] : floor[column];
+ }
+ ChunkLightPropagator propagator = new ChunkLightPropagator();
+ propagator.propagateSkyLevels(tables(sections));
+
+ assertArrayEquals(expected, propagator.skyTops(), "the highest block which stops the sky per column");
+ }
+
+ @Test
+ void testAnOpenColumnHandsOverNoHeight() {
+ ChunkLightPropagator propagator = new ChunkLightPropagator();
+ propagator.propagateSkyLevels(tables(airChunk(2)));
+
+ int[] expected = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION];
+ Arrays.fill(expected, -1);
+
+ assertArrayEquals(expected, propagator.skyTops(), "a chunk of air stops the sky nowhere");
+ }
}