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 propagate(List sections) { */ public List propagateSky(List sections) { int height = prepare(sections); - return search(sections, height, seedSky(sections, height)); + return search(sections, height, seedSky(height)); } /** @@ -323,37 +338,106 @@ private int seed(List sections, int height) { } /** - * Puts every block which sees the open sky into the queue. + * Lights every block which sees the open sky and queues the few of them that can spread it. *

* 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 sections, int height) { - int tail = 0; - + private int seedSky(int height) { for (int z = 0; z < LightNibbles.DIMENSION; z++) { for (int x = 0; x < LightNibbles.DIMENSION; x++) { - for (int y = height - 1; y >= 0; y--) { + int y = height - 1; + + for (; y >= 0; y--) { int index = index(x, y, z); if ((this.occlusion[index] & TOP_BIT) != 0) { break; } this.levels[index] = LightNibbles.MAX_LEVEL; + } + this.skyBottom[column(x, z)] = y + 1; + } + } + + int tail = 0; + + for (int z = 0; z < LightNibbles.DIMENSION; z++) { + for (int x = 0; x < LightNibbles.DIMENSION; x++) { + int until = deepestNeighbourBottom(x, z); + + for (int y = this.skyBottom[column(x, z)]; y < until; y++) { ensureRoom(tail); - this.queue[tail++] = index | (NO_FACE << POSITION_BITS); + this.queue[tail++] = index(x, y, z) | (NO_FACE << POSITION_BITS); } } } return tail; } + /** + * Returns the height below which at least one horizontal neighbour of a column is dark. + *

+ * 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 airChunk(int sectionCount) { + List sections = new ArrayList<>(sectionCount); + + for (int index = 0; index < sectionCount; index++) { + sections.add(new int[LightNibbles.BLOCK_COUNT]); + } + return sections; + } + + /** + * Puts a one block ceiling into every column at its own height, leaving air above and below. + *

+ * 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 sections, int[] ceiling, int stateId) { + for (int z = 0; z < LightNibbles.DIMENSION; z++) { + for (int x = 0; x < LightNibbles.DIMENSION; x++) { + int y = ceiling[(z << 4) | x]; + + if (y >= 0) { + sections.get(y >> 4)[(y & 15) << 8 | (z << 4) | x] = stateId; + } + } + } + } + + /** + * Converts the state ids of a chunk into opacity tables. + * + * @param sections the state ids of every section + * @return the opacity table of every section + */ + private static List tables(List sections) { + return sections.stream().map(states -> SectionOpacity.of(states, SOURCE)).toList(); + } + + /** + * Calculates the sky light the way the propagator did before the heightmap: every open position + * is a seed. + * + * @param tables the opacity table of every section + * @return the level of every position of the column + */ + private static byte[] reference(List tables) { + int height = tables.size() * LightNibbles.DIMENSION; + byte[] levels = new byte[height * LightNibbles.BLOCK_COUNT / LightNibbles.DIMENSION]; + Deque queue = new ArrayDeque<>(); + + for (int z = 0; z < LightNibbles.DIMENSION; z++) { + for (int x = 0; x < LightNibbles.DIMENSION; x++) { + for (int y = height - 1; y >= 0; y--) { + if (tables.get(y >> 4).blocksFace(x, y & 15, z, BlockFace.TOP)) { + break; + } + int index = (y << 8) | (z << 4) | x; + levels[index] = LightNibbles.MAX_LEVEL; + queue.add(index); + } + } + } + + while (!queue.isEmpty()) { + int index = queue.poll(); + int level = levels[index]; + + if (level <= 1) { + continue; + } + int x = index & 15; + int z = (index >> 4) & 15; + int y = index >> 8; + + for (BlockFace face : FACES) { + int nx = x + face.offsetX(); + int ny = y + face.offsetY(); + int nz = z + face.offsetZ(); + + if ((nx | ny | nz) < 0 || nx > 15 || nz > 15 || ny >= height) { + continue; + } + if (tables.get(ny >> 4).blocksFace(nx, ny & 15, nz, face.opposite())) { + continue; + } + int neighbour = (ny << 8) | (nz << 4) | nx; + + if (levels[neighbour] >= level - 1) { + continue; + } + levels[neighbour] = (byte) (level - 1); + queue.add(neighbour); + } + } + return levels; + } + + /** + * Compares the propagator against the reference over the given terrain. + * + * @param sections the state ids of every section + * @param what what the terrain is, for the failure message + */ + private static void assertMatchesReference(List sections, String what) { + List tables = tables(sections); + byte[] expected = reference(tables); + List actual = new ChunkLightPropagator().propagateSky(tables); + int height = tables.size() * LightNibbles.DIMENSION; + + for (int y = 0; y < height; y++) { + for (int z = 0; z < LightNibbles.DIMENSION; z++) { + for (int x = 0; x < LightNibbles.DIMENSION; x++) { + assertEquals( + expected[(y << 8) | (z << 4) | x], + actual.get(y >> 4).get(x, y & 15, z), + () -> what + ": the level differs somewhere in the column" + ); + } + } + } + } + + @Test + void testACeilingAtADifferentHeightInEveryColumnMatchesTheReference() { + int sectionCount = 4; + Random random = new Random(SEED); + int[] ceiling = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION]; + + for (int column = 0; column < ceiling.length; column++) { + // Every fourth column is left open, so the light has somewhere to come from. + ceiling[column] = random.nextInt(4) == 0 + ? -1 + : random.nextInt(sectionCount * LightNibbles.DIMENSION); + } + List sections = airChunk(sectionCount); + roof(sections, ceiling, STONE); + + assertMatchesReference(sections, "a ceiling at a random height per column"); + } + + @Test + void testAStaircaseCeilingMatchesTheReference() { + int sectionCount = 3; + int[] ceiling = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION]; + + for (int z = 0; z < LightNibbles.DIMENSION; z++) { + for (int x = 0; x < LightNibbles.DIMENSION; x++) { + ceiling[(z << 4) | x] = x == 0 ? -1 : x * 3; + } + } + List sections = airChunk(sectionCount); + roof(sections, ceiling, STONE); + + assertMatchesReference(sections, "a ceiling rising along x"); + } + + @Test + void testACeilingWithASingleHoleMatchesTheReference() { + int sectionCount = 3; + int[] ceiling = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION]; + + // One closed ceiling over the whole chunk with a single column left open. Everything under + // it is air and dark, and every level below comes from that one column travelling sideways. + for (int column = 0; column < ceiling.length; column++) { + ceiling[column] = sectionCount * LightNibbles.DIMENSION - 1; + } + ceiling[(8 << 4) | 8] = -1; + + List sections = airChunk(sectionCount); + roof(sections, ceiling, STONE); + + assertMatchesReference(sections, "a ceiling with a single hole"); + } + + @Test + void testASlabCeilingAtADifferentHeightInEveryColumnMatchesTheReference() { + int sectionCount = 4; + Random random = new Random(SEED); + int[] ceiling = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION]; + + for (int column = 0; column < ceiling.length; column++) { + ceiling[column] = random.nextInt(4) == 0 + ? -1 + : random.nextInt(sectionCount * LightNibbles.DIMENSION); + } + List sections = airChunk(sectionCount); + roof(sections, ceiling, SLAB); + + assertMatchesReference(sections, "a slab ceiling at a random height per column"); + } + + @Test + void testASingleSlabBesideAnOpenColumnMatchesTheReference() { + int sectionCount = 2; + int[] ceiling = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION]; + + // The smallest terrain that separates a correct range from one position short of it. Every + // column is open except one, which carries a slab: the slab stops the sky above itself and + // still takes light from its four neighbours, so the topmost position of the queued range + // has somewhere to give its light. Against stone it would have nowhere and the error would + // not show. + for (int column = 0; column < ceiling.length; column++) { + ceiling[column] = -1; + } + ceiling[(8 << 4) | 8] = 20; + + List sections = airChunk(sectionCount); + roof(sections, ceiling, SLAB); + + assertMatchesReference(sections, "a single slab among open columns"); + } + + @Test + void testTwoNeighbouringColumnsThatStopOneApartMatchTheReference() { + int sectionCount = 2; + int[] ceiling = new int[LightNibbles.DIMENSION * LightNibbles.DIMENSION]; + + // The smallest terrain whose queued range is not empty: one column carries a ceiling and + // every other column is open. Its four neighbours then have to be queued for exactly the + // heights at and below that ceiling, which is what an off-by-one in the range drops. + for (int column = 0; column < ceiling.length; column++) { + ceiling[column] = -1; + } + ceiling[(8 << 4) | 8] = 20; + + List sections = airChunk(sectionCount); + roof(sections, ceiling, STONE); + + assertMatchesReference(sections, "a single column with a ceiling"); + } +}