From 3b74e04951c8615f5b8129c375d823102b75309d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 2 Aug 2026 19:20:18 +0200 Subject: [PATCH 1/2] perf(light): order the two neighbour tests by cost, and stop walking back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both propagators tested the occlusion of a neighbour before its level. The level is one array read; the occlusion is two and a branch, and the level rejects far more often — a position is reached from up to six directions and only the first of them raises it. The two are pure, so the order is free to choose and the cheaper, more selective one goes first. A queued position also carries the face pointing back at whoever queued it, in the bits above its index, and the loop skips that face. Whoever queued it holds a level exactly one higher, so the level test could never pass in that direction; the work was always wasted rather than merely redundant. Seeds carry a face value no face uses, so nothing is skipped for them. Neither changes a single output byte. Storing the travelled face instead of its opposite — the plausible way to get this wrong — turns 20 tests red, so the suite does cover the direction rather than only the levels. --- .../falco/light/ChunkLightPropagator.java | 70 ++++++++++++++++--- .../falco/light/LightPropagator.java | 70 ++++++++++++++++--- 2 files changed, 122 insertions(+), 18 deletions(-) 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 b9a0937..5b6db67 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 @@ -60,6 +60,50 @@ private static BlockFace[] opposites() { return opposites; } + /** + * The index into {@link #FACES} of the opposite of every face. + *

+ * A queued position remembers the face that points back at whoever queued it, and a face is + * cheaper to carry as its index than as a reference. This is that index. + *

+ */ + private static final int[] OPPOSITE_INDEX = oppositeIndexes(); + + /** + * Resolves the index of the opposite of every face once. + * + * @return the index of the opposite of every face, indexed like {@link #FACES} + */ + private static int[] oppositeIndexes() { + int[] indexes = new int[FACES.length]; + + for (int index = 0; index < FACES.length; index++) { + indexes[index] = OPPOSITES[index].ordinal(); + } + return indexes; + } + + /** + * The amount of bits a queued position occupies, leaving the ones above it for the face. + *

+ * A position index is {@code (y << 8) | (z << 4) | x} over a column of at most a few hundred + * blocks, so twenty-four bits carry a column of 65 536 sections — four orders of magnitude past + * anything a dimension declares. + *

+ */ + private static final int POSITION_BITS = 24; + + /** + * The bits of a queue entry which carry the position. + */ + private static final int POSITION_MASK = (1 << POSITION_BITS) - 1; + + /** + * The face value of an entry which nobody queued, so no direction may be skipped for it. + * Six faces occupy the indexes zero to five, which leaves this one free. + */ + private static final int NO_FACE = 7; + private byte[] levels; private int[] queue; @@ -132,7 +176,9 @@ private List search(List sections, int height, int int head = 0; while (head < tail) { - int index = this.queue[head++]; + int entry = this.queue[head++]; + int index = entry & POSITION_MASK; + int arrivedFrom = entry >>> POSITION_BITS; int level = this.levels[index]; if (level <= 1) { @@ -145,6 +191,12 @@ private List search(List sections, int height, int int next = level - 1; for (int faceIndex = 0; faceIndex < FACES.length; faceIndex++) { + // Whoever queued this position sits on the far side of that face and already holds + // a level one higher, so the test below could never pass for it. Skipping the face + // outright is the same result for a sixth less work. + if (faceIndex == arrivedFrom) { + continue; + } BlockFace face = FACES[faceIndex]; int neighbourX = x + face.offsetX(); int neighbourY = y + face.offsetY(); @@ -153,18 +205,20 @@ private List search(List sections, int height, int if (isOutside(neighbourX, neighbourY, neighbourZ, height)) { continue; } - if (blocksFace(sections, neighbourX, neighbourY, neighbourZ, OPPOSITES[faceIndex])) { - continue; - } - int neighbourIndex = index(neighbourX, neighbourY, neighbourZ); + // The level is one array read, the occlusion is two and a branch, and the level + // rejects far more often — a position is reached from up to six directions and only + // the first of them raises it. 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.queue[tail++] = neighbourIndex; + this.queue[tail++] = neighbourIndex | (OPPOSITE_INDEX[faceIndex] << POSITION_BITS); } } return collect(sections.size()); @@ -226,7 +280,7 @@ private int seed(List sections, int height) { int index = index(x, y, z); this.levels[index] = (byte) emission; ensureRoom(tail); - this.queue[tail++] = index; + this.queue[tail++] = index | (NO_FACE << POSITION_BITS); } } } @@ -257,7 +311,7 @@ private int seedSky(List sections, int height) { int index = index(x, y, z); this.levels[index] = LightNibbles.MAX_LEVEL; ensureRoom(tail); - this.queue[tail++] = index; + this.queue[tail++] = index | (NO_FACE << POSITION_BITS); } } } diff --git a/falco-light/src/main/java/net/onelitefeather/falco/light/LightPropagator.java b/falco-light/src/main/java/net/onelitefeather/falco/light/LightPropagator.java index 28203eb..dc2e945 100644 --- a/falco-light/src/main/java/net/onelitefeather/falco/light/LightPropagator.java +++ b/falco-light/src/main/java/net/onelitefeather/falco/light/LightPropagator.java @@ -29,6 +29,7 @@ public final class LightPropagator { private static final BlockFace[] FACES = BlockFace.values(); + private static final int MASK = LightNibbles.DIMENSION - 1; /** * The opposite of every face, in the order of {@link #FACES}. @@ -40,6 +41,46 @@ public final class LightPropagator { */ private static final BlockFace[] OPPOSITES = opposites(); + /** + * The index into {@link #FACES} of the opposite of every face. + *

+ * A queued position remembers the face that points back at whoever queued it, and a face is + * cheaper to carry as its index than as a reference. This is that index. + *

+ */ + private static final int[] OPPOSITE_INDEX = oppositeIndexes(); + + /** + * The amount of bits a queued position occupies, leaving the ones above it for the face. + * A section holds 4096 positions, so twelve bits carry every one of them. + */ + private static final int POSITION_BITS = 12; + + /** + * The bits of a queue entry which carry the position. + */ + private static final int POSITION_MASK = (1 << POSITION_BITS) - 1; + + /** + * The face value of an entry which nobody queued, so no direction may be skipped for it. + * Six faces occupy the indexes zero to five, which leaves this one free. + */ + private static final int NO_FACE = 7; + + /** + * Resolves the index of the opposite of every face once. + * + * @return the index of the opposite of every face, indexed like {@link #FACES} + */ + private static int[] oppositeIndexes() { + int[] indexes = new int[FACES.length]; + + for (int index = 0; index < FACES.length; index++) { + indexes[index] = OPPOSITES[index].ordinal(); + } + return indexes; + } + /** * Resolves the opposite of every face once. * @@ -53,7 +94,6 @@ private static BlockFace[] opposites() { } return opposites; } - private static final int MASK = LightNibbles.DIMENSION - 1; private final byte[] levels; private int[] queue; @@ -98,7 +138,9 @@ public LightNibbles propagate(SectionOpacity opacity) { int head = 0; while (head < tail) { - int index = this.queue[head++]; + int entry = this.queue[head++]; + int index = entry & POSITION_MASK; + int arrivedFrom = entry >>> POSITION_BITS; int level = this.levels[index]; if (level <= 1) { @@ -111,6 +153,12 @@ public LightNibbles propagate(SectionOpacity opacity) { int next = level - 1; for (int faceIndex = 0; faceIndex < FACES.length; faceIndex++) { + // Whoever queued this position sits on the far side of that face and already holds + // a level one higher, so the test below could never pass for it. Skipping the face + // outright is the same result for a sixth less work. + if (faceIndex == arrivedFrom) { + continue; + } BlockFace face = FACES[faceIndex]; int neighbourX = x + face.offsetX(); int neighbourY = y + face.offsetY(); @@ -119,21 +167,23 @@ public LightNibbles propagate(SectionOpacity opacity) { if (isOutside(neighbourX, neighbourY, neighbourZ)) { continue; } + int neighbourIndex = index(neighbourX, neighbourY, neighbourZ); + + // The level is one array read, the occlusion is two and a branch, and the level + // rejects far more often — a position is reached from up to six directions and only + // the first of them raises it. Cheapest and most selective test first. + if (this.levels[neighbourIndex] >= next) { + continue; + } // Only the face light enters decides whether it can pass. Testing the face it // leaves as well would keep every emitting block that is opaque itself dark, and a // glowstone block is exactly that. if (opacity.blocksFace(neighbourX, neighbourY, neighbourZ, OPPOSITES[faceIndex])) { continue; } - - int neighbourIndex = index(neighbourX, neighbourY, neighbourZ); - - if (this.levels[neighbourIndex] >= next) { - continue; - } this.levels[neighbourIndex] = (byte) next; ensureRoom(tail); - this.queue[tail++] = neighbourIndex; + this.queue[tail++] = neighbourIndex | (OPPOSITE_INDEX[faceIndex] << POSITION_BITS); } } return collect(); @@ -159,7 +209,7 @@ private int seed(SectionOpacity opacity) { int index = index(x, y, z); this.levels[index] = (byte) emission; ensureRoom(tail); - this.queue[tail++] = index; + this.queue[tail++] = index | (NO_FACE << POSITION_BITS); } } } From 237d60abab4e6d1ef723a8d1dad78e24ecb1aaa1 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 2 Aug 2026 19:27:19 +0200 Subject: [PATCH 2/2] perf(light): lay the occlusion of a whole column out flat once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chunk propagator reached the occlusion of a neighbour through sections.get(y >> 4) followed by a call into that section, once per face per queued position — an interface call, a bounds check and a null test before the byte anyone wanted. The column is now written into one flat byte[] in prepare(), and the search reads it directly with a precomputed face bit. The layout is free: a section indexes its table with the same (y << 8) | (z << 4) | x the column uses, so a section occupies one contiguous 4096-byte run and goes in with a single arraycopy. A uniform section, which holds no table at all, is filled instead — still the cheaper of the two cases, so the shortcut that makes uniform sections free is not given up, only paid for differently. The buffer is sized and reused like levels and queue, so it costs one byte per position of the largest column an instance has seen and nothing per run. copyOcclusionInto is package private. It hands out the internal layout of the table for the two propagators of this package and is not API. Filling the column with the sections in reverse — the plausible way to get an offset wrong — turns 20 tests red. --- .../falco/light/ChunkLightPropagator.java | 60 +++++++++++++------ .../falco/light/SectionOpacity.java | 31 ++++++++++ 2 files changed, 72 insertions(+), 19 deletions(-) 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 5b6db67..b03769a 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 @@ -104,7 +104,32 @@ private static int[] oppositeIndexes() { */ private static final int NO_FACE = 7; + /** + * The bit of the opposite of every face, ready to be tested against a flat occlusion byte. + */ + private static final int[] OPPOSITE_BIT = oppositeBits(); + + /** + * Resolves the occlusion bit of the opposite of every face once. + * + * @return the bit of the opposite of every face, indexed like {@link #FACES} + */ + private static int[] oppositeBits() { + int[] bits = new int[FACES.length]; + + for (int index = 0; index < FACES.length; index++) { + bits[index] = 1 << OPPOSITE_INDEX[index]; + } + return bits; + } + + /** + * The occlusion bit of the face light enters a block through when it falls straight down. + */ + private static final int TOP_BIT = 1 << BlockFace.TOP.ordinal(); + private byte[] levels; + private byte[] occlusion; private int[] queue; /** @@ -112,6 +137,7 @@ private static int[] oppositeIndexes() { */ public ChunkLightPropagator() { this.levels = new byte[0]; + this.occlusion = new byte[0]; this.queue = new int[0]; } @@ -158,8 +184,16 @@ private int prepare(List sections) { } int height = sections.size() * LightNibbles.DIMENSION; - ensureCapacity(height * LightNibbles.DIMENSION * LightNibbles.DIMENSION); - Arrays.fill(this.levels, 0, height * LightNibbles.DIMENSION * LightNibbles.DIMENSION, (byte) 0); + int blockCount = height * LightNibbles.DIMENSION * LightNibbles.DIMENSION; + ensureCapacity(blockCount); + Arrays.fill(this.levels, 0, blockCount, (byte) 0); + + // The whole column is laid out flat once, so the search reads one array instead of walking + // list, section and null test per face per queued position. It is paid for by one fill or + // one copy per section, both of which the JIT turns into vector stores. + for (int section = 0; section < sections.size(); section++) { + sections.get(section).copyOcclusionInto(this.occlusion, section * LightNibbles.BLOCK_COUNT); + } return height; } @@ -213,7 +247,7 @@ private List search(List sections, int height, int if (this.levels[neighbourIndex] >= next) { continue; } - if (blocksFace(sections, neighbourX, neighbourY, neighbourZ, OPPOSITES[faceIndex])) { + if ((this.occlusion[neighbourIndex] & OPPOSITE_BIT[faceIndex]) != 0) { continue; } this.levels[neighbourIndex] = (byte) next; @@ -248,6 +282,7 @@ private void ensureRoom(int tail) { private void ensureCapacity(int blockCount) { if (this.levels.length < blockCount) { this.levels = new byte[blockCount]; + this.occlusion = new byte[blockCount]; this.queue = new int[blockCount]; } } @@ -305,10 +340,11 @@ private int seedSky(List sections, 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--) { - if (blocksFace(sections, x, y, z, BlockFace.TOP)) { + int index = index(x, y, z); + + if ((this.occlusion[index] & TOP_BIT) != 0) { break; } - int index = index(x, y, z); this.levels[index] = LightNibbles.MAX_LEVEL; ensureRoom(tail); this.queue[tail++] = index | (NO_FACE << POSITION_BITS); @@ -318,20 +354,6 @@ private int seedSky(List sections, int height) { return tail; } - /** - * Checks whether light cannot enter the given position through the given face. - * - * @param sections the light properties of every section - * @param x the x coordinate inside the chunk - * @param y the y coordinate inside the column - * @param z the z coordinate inside the chunk - * @param face the face light would enter through - * @return true if light cannot pass the face, otherwise false - */ - private static boolean blocksFace(List sections, int x, int y, int z, BlockFace face) { - return sections.get(y >> 4).blocksFace(x, y & MASK, z, face); - } - /** * Transfers the calculated levels into one light section per section of the chunk. * 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 4134123..5cfc197 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 @@ -314,6 +314,37 @@ public boolean blocksFace(int x, int y, int z, BlockFace face) { return (mask & (1 << face.ordinal())) != 0; } + /** + * Writes the occluded faces of every block of this section into one flat array. + *

+ * A propagation over a whole chunk reads the occlusion of a neighbour once per face per queued + * position, and reaching it through the section costs an index into the list, an interface call + * and a null test before the byte itself. Laying the whole column out flat once turns all of + * that into a single array read for the rest of the pass. + *

+ *

+ * A uniform section is filled rather than copied, which is why it is still cheaper than one that + * carries a table: the fill writes the same byte over a contiguous range and never touches a + * per position table, because a uniform section holds none. + *

+ *

+ * Package private on purpose. This hands out the internal layout of the table and is meant for + * the two propagators of this package, not for callers. + *

+ * + * @param target the array which receives the occluded faces + * @param offset the index in the target at which this section begins + */ + void copyOcclusionInto(byte[] target, int offset) { + byte[] table = this.occlusion; + + if (table == null) { + Arrays.fill(target, offset, offset + LightNibbles.BLOCK_COUNT, this.uniformOcclusion); + return; + } + System.arraycopy(table, 0, target, offset, LightNibbles.BLOCK_COUNT); + } + /** * Returns the amount of light the block at the given position emits. *