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 b03769a..9b9d644 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 @@ -1,6 +1,7 @@ package net.onelitefeather.falco.light; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; import java.util.ArrayList; import java.util.Arrays; @@ -128,10 +129,24 @@ private static int[] oppositeBits() { */ private static final int TOP_BIT = 1 << BlockFace.TOP.ordinal(); + /** + * The amount of columns a chunk holds. + */ + private static final int COLUMN_COUNT = LightNibbles.DIMENSION * LightNibbles.DIMENSION; + private byte[] levels; private byte[] occlusion; private int[] queue; + /** + * The lowest position of every column which still sees the open sky. + *
+ * One entry per column, so the size does not depend on the height of the chunk and the array is + * allocated once with the propagator. + *
+ */ + private final int[] skyBottom = new int[COLUMN_COUNT]; + /** * Creates a new propagator without any buffer. The buffers are sized on the first run. */ @@ -168,7 +183,7 @@ public List* Every column is walked from the top of the chunk downwards. As long as light can enter the * block from above it receives the full level, which is why an open column is lit to the very - * bottom. The walk of a column ends at the first block that stops the light. + * bottom. The walk of a column ends at the first block that stops the light, and where it ended + * is kept per column — that is the heightmap this method builds on the way past. + *
+ *+ * Lighting a position and queueing it are two different things, and this is the one place + * where the difference is worth most. An open position whose four horizontal neighbours are + * open at the same height can raise nobody: above and below it the column is already at the + * full level or is blocked, and so is every neighbour. Queueing it costs a pop and six face + * tests to establish that. On an open chunk that used to be every position of every column. + *
+ *+ * What has to be queued is exactly the positions with a darker neighbour, and the heightmap + * gives that as a range rather than a test: a neighbouring column is dark at every height below + * where its own sky stops, so the positions of a column that need queueing are the ones from its + * own bottom up to the deepest bottom among its four neighbours. Above that line every neighbour + * is open, and there is nothing left to give. *
* - * @param sections the light properties of every section - * @param height the amount of blocks the column spans vertically + * @param height the amount of blocks the column spans vertically * @return the amount of queued positions */ - private int seedSky(List+ * A column outside the chunk is left out rather than treated as dark. Light does not leave the + * chunk through the search — the border exchange carries it — and a position at the edge is lit + * either way, because lighting it and queueing it are separate. + *
+ * + * @param x the x coordinate of the column + * @param z the z coordinate of the column + * @return the deepest sky bottom among the horizontal neighbours inside the chunk + */ + @Contract(pure = true) + private int deepestNeighbourBottom(int x, int z) { + int deepest = 0; + + if (x > 0) { + deepest = Math.max(deepest, this.skyBottom[column(x - 1, z)]); + } + if (x < MASK) { + deepest = Math.max(deepest, this.skyBottom[column(x + 1, z)]); + } + if (z > 0) { + deepest = Math.max(deepest, this.skyBottom[column(x, z - 1)]); + } + if (z < MASK) { + deepest = Math.max(deepest, this.skyBottom[column(x, z + 1)]); + } + return deepest; + } + + /** + * Calculates the index of a column inside the heightmap. + * + * @param x the x coordinate inside the chunk + * @param z the z coordinate inside the chunk + * @return the index of the column + */ + @Contract(pure = true) + private static int column(int x, int z) { + return (z << 4) | x; + } + /** * Transfers the calculated levels into one light section per section of the chunk. * 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 new file mode 100644 index 0000000..ec77eb9 --- /dev/null +++ b/falco-light/src/test/java/net/onelitefeather/falco/light/SkyHeightmapSeedTest.java @@ -0,0 +1,324 @@ +package net.onelitefeather.falco.light; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pins the sky propagation against an independent implementation over terrain whose columns stop the + * sky at different heights. + *+ * The propagator lights every position that sees the sky but queues only the ones that can pass the + * light on: a position whose four horizontal neighbours are open at the same height can raise + * nobody. Which positions those are is derived from the heightmap, as the range between a column's + * own sky bottom and the deepest bottom among its neighbours. + *
+ *+ * That range is what the rest of the suite did not cover. Shortening it by one position — the + * plainest way to get it wrong — left all 212 tests green. Every sky test before this one uses + * terrain that is flat, fully open or fully closed, and on flat terrain the range is empty and the + * whole derivation is a no-op. A test that cannot fail is worse than no test, so this one builds + * terrain with a different height in every column and compares position by position. + *
+ *+ * The reference below is the algorithm the propagator used before the heightmap: seed every open + * position, then spread. It is written out here rather than called, because the point is to compare + * against something that does not share the code under test. + *
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 0.1.0 + */ +class SkyHeightmapSeedTest { + + private static final int AIR = 0; + private static final int STONE = 1; + + /** + * A block which stops the sky from above and lets light through from every other side. + *+ * This is the state that makes the difference between a correct queued range and a range one + * short of it. The position a column's sky stops at is the blocker itself, and a blocker which + * occludes every face takes no light from the side either — so getting the top of the range + * wrong is invisible against solid stone. A slab stops the sky and still accepts light + * sideways, which is exactly the case the range has to reach. + *
+ */ + private static final int SLAB = 2; + + /** + * The seed of the terrain, so a failure can be reproduced. + */ + private static final long SEED = 20260802L; + + private static final BlockFace[] FACES = BlockFace.values(); + + private static final BlockLightSource SOURCE = new BlockLightSource() { + + @Override + public int emission(int stateId) { + return 0; + } + + @Override + public boolean blocksFace(int stateId, BlockFace face) { + return switch (stateId) { + case STONE -> true; + case SLAB -> face == BlockFace.TOP; + default -> false; + }; + } + }; + + /** + * Builds a chunk of the given amount of air sections. + * + * @param sectionCount the amount of sections + * @return the state ids of every section + */ + private static List+ * A ceiling rather than a ground is what this test needs. Under solid ground the dark positions + * are the stone itself, which takes no light from any side, so the positions the propagator + * decides not to queue could never have lit anything and an error in that decision stays + * invisible. Under a ceiling the dark positions are air, they are lit sideways from the open + * column beside them, and a missing seed shows up as a level that stays at zero. + *
+ * + * @param sections the state ids of every section + * @param ceiling the y of the ceiling of every column, or a negative value for an open column, + * indexed as {@code (z << 4) | x} + * @param stateId the block the ceiling is made of + */ + private static void roof(List