From 153d31b2488771c8ff1f3730d08f6949b6d43812 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 2 Aug 2026 22:51:50 +0200 Subject: [PATCH] perf(light): seed the sky from the heightmap instead of every open cell seedSky queued every position that sees the sky. On a chunk of 24 sections that is up to 98 304 entries, each popped again and expanded six ways to establish that all six neighbours are already at the full level. Lighting a position and queueing it are two different things, and this is where the difference is largest. A position whose four horizontal neighbours are open at the same height can raise nobody: above and below it the column is at the full level or blocked, and so is every neighbour. It still has to be lit, because the output and the border exchange read the levels, but it has no business in the queue. The walk down each column already finds where the sky stops, so it now keeps that per column. The positions that need queueing then fall out as a range rather than a test: a neighbouring column is dark below its own sky bottom, so a column has to queue from its own bottom up to the deepest bottom among its four neighbours. Above that line every neighbour is open. On an open chunk nothing is queued at all. The heightmap is one int per column, so it does not grow with the height of the chunk and is allocated once with the propagator. ChunkLightState#skyTopOf computes the same heightmap a second time, after the propagation. Handing this one over instead is a separate change and is not made here. --- .../falco/light/ChunkLightPropagator.java | 104 +++++- .../falco/light/SkyHeightmapSeedTest.java | 324 ++++++++++++++++++ 2 files changed, 418 insertions(+), 10 deletions(-) create mode 100644 falco-light/src/test/java/net/onelitefeather/falco/light/SkyHeightmapSeedTest.java 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"); + } +}