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"); + } }