-
-
Notifications
You must be signed in to change notification settings - Fork 0
Rationale Lighting
This document answers one question: given that Minestom already computes light, and given that
Starlight and Phosphor already exist and are widely regarded as the state of the art, why does
falco-light exist and why is its algorithm the shape it is? The answer is not "because ours is
faster" — that is only true under conditions this document names, and it was not the reason the
engine was written. The reason is structural, and most of the arguments that looked like reasons
turned out, on inspection, not to be. Those failures are recorded here at the same length as the
successes, because they are the part that would otherwise be re-litigated.
Throughout, a claim is marked as one of three things: a measurement from this repository, quoted
with its benchmark name and conditions; a statement established by reading someone else's source,
with the project, file, member and line named; or an argument from the properties of the
algorithm, which nobody measured and which says so. Where a number appears without one of those
three, it is a mistake. Some of the numbers below come from the original investigation rather than
from falco-benchmarks; those carry no ± and no run configuration, and every one of them says so
where it appears.
Minestom is read at version 2026.06.20-26.1.2, which is what this repository compiles against —
settings.gradle.kts declares minestom withoutVersion() and takes the version from
net.onelitefeather:mycelium-bom:1.7.2, whose dependencyManagement pins exactly that string.
Starlight and Phosphor are read at the branches and tags named in the Sources list, and the citations
to them are to a moving branch rather than to a pinned revision wherever the Sources list says
fabric branch. Every measured figure below belongs to a table in Benchmarking or
Project Status; what the ± after a JMH mean covers is stated once, in
Rationale: Measurement, and is not restated here.
The honest starting point is that for the workload Falco was originally built for, a light engine is worth nothing.
When a chunk is loaded from an Anvil region file that already carries SkyLight and BlockLight
tags, those bytes are handed to the section through Light#set, and set clears the section's
update flag. The mechanism is two fields: BlockLight.set assigns isValidBorders = true
(instance/light/BlockLight.java:78-83) and requiresUpdate() returns !isValidBorders (:72-74),
with SkyLight identical at :64-69 and :58-60. LightingChunk recomputes a section only when
that flag is true — if (section.blockLight().requiresUpdate()) at instance/LightingChunk.java:293
and the sky equivalent at :300, with the same guard again at :527-528 — so a pre-lit chunk is
never relit. This was read in Minestom 2026.06.20-26.1.2 and separately confirmed with probe code
that observed requiresUpdate flip from true to false across a set(2048) call [13]. The
structural half of that is checkable in a minute; the
consequence is that against a breakdown of the cost of loading one chunk — NBT parse 62 %, inflate
28 %, palette 4.5 % of the whole load, the remainder unattributed — the light engine is 0 % of
loading a pre-lit world. That breakdown is from the original investigation [13] and from no benchmark
in falco-benchmarks: it carries no ±, and its base is one chunk load on whatever machine that
investigation used. The 0 % does not rest on it. It follows from the flag.
That leaves two workloads where light is actually computed. Both were measured in the original
investigation and not by any benchmark in falco-benchmarks, so they carry no ± and no run
configuration and should be read as magnitudes: relighting a generated chunk that carries no stored
light, at 2.2 ms per chunk or about 92 µs per section, and a runtime block placement, at 0.586 ms per
placed torch [13]. If neither of those is in your server's profile, the correct decision is to not use
this package, and Light Engine says so in its first table.
Stating this first is deliberate. Every performance figure below is real, and none of it matters if the code never executes.
The strongest argument for this engine is not a benchmark. It is that Minestom computes light in exactly one place, and that place is not the default.
Read in Minestom 2026.06.20-26.1.2: LightingChunk extends DynamicChunk, and every entry point
into the built-in lighting is guarded by an instanceof test against that exact class. Ten lines of
instance/LightingChunk.java carry one; the two that decide whether anything happens at all are
if (!(chunk instanceof LightingChunk lighting)) return null; at :371 and :383, and
if (!(c instanceof LightingChunk)) return Set.of(); at :569. A chunk that is not a
LightingChunk receives no light at all; it does not receive worse light, it receives none.
The part that makes this more than a footnote is what InstanceContainer does by default. Its
constructor calls setChunkSupplier(DynamicChunk::new) (instance/InstanceContainer.java:120). A
plain InstanceContainer, unmodified, therefore produces chunks that Minestom's light engine will
not touch. Using the built-in engine is an opt-in that a server has to know about.
Falco takes the opposite position on where light lives. The propagation runs outside the chunk,
references no Minestom type at all, and the finished 2048-byte array is handed in through
Light#set(byte[]), which every Light implementation accepts. That has three consequences worth
naming:
- It works with any chunk from any loader, including
DynamicChunk, including a custom one. - It does not depend on
Light#calculateInternal/calculateExternal, which carry@ApiStatus.Internalon the interface (instance/light/Light.java:37,:43) and may change signature between Minestom versions.Light#setat:35carries no such annotation. One honest qualification, because a reader will find it: both implementations do annotate their override —BlockLight.setat:77andSkyLight.setat:63. The interface declaration is what a caller binds against, and that is the one without the marker, but the signal is not unambiguous and this argument should not be leaned on harder than that. - Because the algorithm knows nothing about Minestom, it is tested against a handful of fake blocks
with no server running. Only
MinestomBlockLightSource, the adapter, needs one.
This is the argument that survives a machine being fast or slow, and it is the one to weigh first.
The search in LightPropagator#propagate is a breadth-first flood fill over a section's 4096
positions. It dequeues a position, reads that position's level from a byte[4096], and for each of
the six faces computes the neighbour index, tests whether the neighbour's entered face is occluded,
and writes level - 1 into the neighbour if that raises it. The neighbour is then queued. Nothing in
that loop ever reads the six neighbours of the dequeued cell in order to decide what the dequeued
cell should be.
That direction — push a level onto the neighbours, rather than recompute a cell by pulling from all
six of its own neighbours — is precisely the property Spottedleaf names as the reason Starlight beats
vanilla Minecraft. Starlight's TECHNICAL_DETAILS.md puts it as vanilla "checking ALL 6 of its
neighbours" against Starlight, "for Starlight it only checks JUST ONE (the block it propagated
from)" [1]. Reading StarLightEngine.performLightIncrease confirms the implementation matches the
document: it iterates a direction set, computes the neighbour position as offX = posX + propagate.x with its y and z siblings, and writes the target level there through
currentNibble.set(localIndex, targetLevel) before queuing it [4]. Established by reading, not by
measuring.
So the headline structural advantage of Starlight was never available to be imported: it was already
the design. This is the single most useful thing in this section, and it is the reason
falco-light did not become a Starlight port.
There is a correction to make here, and it cuts against us rather than for us. Project Status records the push-versus-pull property under Claims about other light engines as the reason there is nothing left to take from Starlight, which is correct. It is easy to read that entry as also implying an advantage over Minestom. It does not, and reading Minestom's source settles it.
net.minestom.server.instance.light.LightCompute.compute (instance/light/LightCompute.java:115)
is likewise a push-based BFS over a ShortArrayFIFOQueue of packed shorts in the layout
[4bit level][4bit y][4bit z][4bit x] — the layout is stated in that method's own javadoc at :112.
It dequeues an entry, unpacks x/y/z and level (:137-142), iterates Direction.values() (:144,
over the DIRECTIONS array declared at :17), and writes lightLevel - 1 into each in-bounds
neighbour whose current level is lower, enqueueing it (:168-169). Minestom is not the vanilla
algorithm; it does not pull, and it does not re-derive a cell from six reads. Established by
reading Minestom 2026.06.20-26.1.2, not by measuring.
The difference between the two engines is therefore not the shape of the search. It is what a single
step of the search costs. Inside that for (Direction direction : DIRECTIONS) loop,
LightCompute.compute resolves getBlock(blockPalette, x, y, z) — the block it is propagating
from — and getBlock(blockPalette, xO, yO, zO) (:158-159), asks each of the two for its
occlusionShape() through its registry entry (:161-162), and calls isOccluded on the pair
(:165). getBlock is Block.fromStateId(palette.get(x, y, z)) (:192-194). Both lookups sit
inside the loop rather than above it, so the source block is resolved again for each direction in
which the neighbour would actually be raised — the pair sits behind
if (getLight(lightArray, newIndex) < newLightLevel) at :157, so it is up to six resolutions of
the same block per dequeued cell rather than exactly six. Falco resolves nothing here:
SectionOpacity#blocksFace is three statements, one array index and one bit test against a six-bit
mask (SectionOpacity.OCCLUSION_BITS = 6).
That is the whole mechanism, and it is why the margin between the two engines moves with how often the question "does light pass through here?" is asked, rather than with how many positions are visited.
Two riders on it, because it is the argument the rest of this page leans on. It counts resolutions
and not time: nothing established here says what one Block.fromStateId plus one occlusionShape()
costs relative to one array index, and no benchmark in this repository isolates that pair. And it is
an explanation offered for the measured result below rather than something the harness separates out
— what the tables support is that the gap widens with occlusion; that this paragraph is why is
argued from the code.
A design detail that is not a preference. Of 1168 block types surveyed in the original
investigation, 364 are uniformly opaque, 644 uniformly transparent and 160 are directional —
slabs, stairs, snow, farmland, dirt paths, lecterns, daylight detectors, stonecutters — which is
13.70 % [13]. Those four counts are a survey from that investigation, taken against the registry of
whatever Minestom version it ran on, which it does not record; they are not re-derived here and the
share should be read as "one block type in seven" rather than to four figures. A first draft using a
single per-block flag produced 472 wrong cells out of 4096 [13].
SectionOpacity therefore stores a six-bit mask per block, and only the face light enters is
tested. Testing the face it leaves as well would keep every emitting block that is opaque in its own
right dark, and glowstone is exactly that; the comment on that branch in LightPropagator#propagate
says so, and MinestomBlockLightSourceTest pins the behaviour on real bottom and top slabs.
For a long stretch the engine lost to Minestom on sparse sections, and the explanation given in the documentation was that the search's shape favoured dense sections. That explanation was wrong, and the benchmark that showed it wrong is the most useful thing built on the light side.
LightEngineStageBenchmark splits both engines into stages and measures them inside the project, in
net.minestom.server.instance.light because the built-in methods are package-private. For one light
source in an open section, in microseconds [10][11]:
| Stage | Before 69381af
|
After | |
|---|---|---|---|
opacity — build the table |
31.33 | 8.07 | changed by the commit |
readStates |
7.70 | 7.23 | untouched — internal control |
propagate — the search itself |
33.85 | 31.41 | untouched by the commit |
collect |
0.24 | 0.23 | untouched — internal control |
falcoFull (measured, not the column sum) |
77.1 | 46.3 |
LightEngineStageBenchmark.falcoOpacity / .falcoReadStates / .falcoPropagate /
.falcoCollect / .falcoFull — the rows are labelled by stage above and the method names carry the
falco prefix in the source — lightSources = 1, occlusionPercent = 0, one thread, one fork with
-Xms512m -Xmx512m, 3 warmup and 5 measurement iterations of 1 s per the class annotation, JMH
1.37, one 16-core machine recorded as not idle, commits 69381af (after) against its parent
(before), no results.json committed. No ± was recorded for any cell. One fork: any ± would
have covered variance inside one JVM only — see Rationale: Measurement.
The table is a genuine before/after across one commit, and it carries its own noise estimate, which
is the reason it is worth more than its missing error bars suggest. Two stages were not touched by
69381af at all: readStates moves by 6 % and collect by 4 % between the two runs. Those two are
an internal control — they put the run-to-run floor of this particular pair of runs at roughly
5 %. Against that floor, opacity going 31.33 → 8.07, a factor of 3.9, is far outside anything the
noise could produce. This is an argument from the table's own structure, not a significance test.
The last row is falcoFull, measured separately rather than summed, which is why it does not equal
the column above it: the stages sum to 73.12 before and 46.94 after, leaving a residual of +3.98 and
−0.64. Exposing that residual is what falcoFull is for.
Two things fall out. First, the search was never the problem. At 33.85 µs the Falco propagation
was already faster than the built-in search, which the same benchmark puts at 53.9 µs — a derived
figure, minestomFull minus minestomQueue, because Minestom's search consumes the queue it is
handed and cannot be measured on a prepared one. Neither operand carries a ±, so the difference
carries none either and 53.9 must not be turned into a ratio. The entire deficit sat in the
preparation. Second, the preparation was a single allocation: the lambda handed to computeIfAbsent
captured the BlockLightSource, so a fresh instance was created per block, and escape analysis did
not remove it because computeIfAbsent is too large to inline — roughly 4096 objects per section.
Replacing the map with a linear-probing table over the raw state id, packing occlusion and emission
together into a short, took the allocation of one call from 74 040 to 8 664 bytes [10]. That
allocation pair is the most robust number on this page: -prof gc allocation counts are
essentially deterministic, so unlike every timing here they are immune to the fork count, to the JIT
and to what else the machine was doing.
The lesson recorded here is not "we made the table faster". It is that an algorithmic explanation was
offered and believed for a measurement that had an allocation as its cause, and that only splitting
the path into stages distinguished the two. Nothing about the search was changed in 69381af, and
the search times before and after — 33.85 and 31.41 — say so.
LightEngineComparisonBenchmark runs both engines over the same section, from a block palette to a
finished 2048-byte array, with MinecraftServer.init() up so that both sides pay the real registry.
Neither side skips its preparation: the built-in path builds its seed queue through
BlockLight.buildInternalQueue, and the Falco path builds its opacity table through the real block
registry rather than a stand-in [12].
µs/op, lower is better [10][11]. The last column gives the conservative bounds on the ratio,
(b − eb) / (a + ea) to (b + eb) / (a − ea), because a ratio can be no more precise than the two
intervals it is built from:
| Sources | Solid | Falco | Minestom | Falco is | Ratio bounds |
|---|---|---|---|---|---|
| 1 | 0 % | 44.5 ± 0.6 | 49.4 ± 1.3 | 1.11× faster | 1.07×–1.16× |
| 8 | 0 % | 98.3 ± 2.4 | 121.1 ± 5.5 | 1.23× faster | 1.15×–1.32× |
| 64 | 0 % | 109.2 ± 1.6 | 126.5 ± 5.6 | 1.16× faster | 1.09×–1.23× |
| 1 | 30 % | 39.3 ± 0.8 | 62.0 ± 2.0 | 1.58× faster | 1.50×–1.66× |
| 8 | 30 % | 119.3 ± 3.5 | 204.2 ± 3.7 | 1.71× faster | 1.63×–1.80× |
| 64 | 30 % | 122.6 ± 1.3 | 206.6 ± 4.2 | 1.68× faster | 1.63×–1.74× |
LightEngineComparisonBenchmark.falco / .minestom, lightSources and occlusionPercent per
row, emissionMix = UNIFORM so every source is glowstone at level 15, one section per operation, one
thread, run as -f 1 -wi 5 -i 10 — an override of the class annotation's 3 warmup and 5 measurement
iterations, and a better configuration than it, since ten iterations narrow the t-multiplier — 1 s
iterations, -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, no
results.json committed. One fork: the ± covers variance between iterations of one JVM, not
between JVM launches — see Rationale: Measurement.
The intervals are disjoint at all six points, so the direction and the magnitude are supported for this run and are stated without hedging. The shape of the result follows directly from the mechanism above: with 30 % solid blocks the search runs into an occlusion question constantly and Falco answers each one from an array, while with 0 % solid it barely asks and the table is paid for and hardly used. 1.11× at one source in an open section is the narrowest margin in the table for exactly that reason.
Three things bound what this table licenses, and none of them touches the six rows themselves. It is
one fork on one machine, so it is a property of that run and not of the software. occlusionPercent
is a benchmark parameter and not a survey of real worlds — 30 % is measured because solid blocks end
a search early, so an entirely open section is the upper bound of the work rather than the typical
case. And every source in this table is at level 15, which the next section shows is the assumption
that flatters Falco most.
What the table cannot be dismissed as, however, is a faster engine computing something else. Both
engines are run over the section in @Setup and the trial is aborted before any measurement if the
2048 bytes differ (verifyBothEnginesAgree), and the measured Minestom side is the real
BlockLight.buildInternalQueue and LightCompute.compute rather than a reimplementation. Those are
facts about the harness, not results from it.
The table above places glowstone everywhere, so every source starts at level 15. Real interiors are
not like that. emissionMix=MIXED, added in 0e8fbb5, keeps the positions and the seed identical
and cycles the sources through glowstone 15, lantern 15, torch 14, redstone torch 7 and magma
block 3, so the levels are the only thing that changes. µs/op [10][11]:
| Sources | Solid | Falco | Minestom | Falco is | Ratio bounds |
|---|---|---|---|---|---|
| 8 | 0 % | 118.97 ± 8.89 | 126.54 ± 9.55 | no difference resolvable | — |
| 8 | 30 % | 116.50 ± 7.63 | 201.46 ± 16.83 | 1.73× faster | 1.49×–2.01× |
| 64 | 0 % | 150.42 ± 32.73 | 162.20 ± 3.11 | no difference resolvable | — |
| 64 | 30 % | 149.42 ± 12.64 | 252.26 ± 9.28 | 1.69× faster | 1.50×–1.91× |
LightEngineComparisonBenchmark.falco / .minestom, emissionMix = MIXED, lightSources and
occlusionPercent per row, one section per operation, one thread, run as -f 1 -wi 3 -i 5 — note
that this is a different configuration from the UNIFORM table above, which used -i 10 — 1 s
iterations, -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, no
results.json committed. One fork: the ± covers variance between iterations of one JVM, not
between JVM launches — see Rationale: Measurement.
Mixed brightness costs Falco more than it costs Minestom, and in the open-sky rows it costs the
lead entirely. At 8 sources and 0 % solid the intervals are [110.1, 127.9] against
[117.0, 136.1]; at 64 sources and 0 % they are [117.7, 183.2] against [159.1, 165.3]. Both
overlap, so neither row supports a factor in either direction, and the 1.06× and 1.08× those two
rows previously printed have been withdrawn. Note what that does not say: an unresolved difference
is not a demonstration that the two are equal. The right statement is that at this precision the
open-sky rows do not separate the engines. The 64-source row is the weaker of the two on Falco's own
side — ±32.73 on a mean of 150.42 is 22 % — which is itself informative: with mixed levels Falco's
own cost becomes less predictable, not just higher.
The size of the regression at 64 sources in an open section is quoted in Project Status as a rise from 112.7 to 150.4 µs, about 33 %. The value 112.7 appears in no published table — the UNIFORM 64 / 0 % row above is 109.2 ± 1.6 — so both that figure and the percentage derived from it are untraceable and are repeated here only so nobody re-derives them by accident.
The cause is a property of the search that the uniform benchmark could not see. A plain FIFO queue
delivers positions in level order only while every source starts at the same level. With mixed
levels, a position lit by a dim source can be reached later by a brighter wave, its level raised, and
the position queued a second time. LightPropagator documents this at its ensureRoom method and
grows the queue for it — and one of the defects fixed in this branch was precisely a queue sized on
the false assumption that a position is queued at most once [10]. The 30 % rows stay disjoint and
keep their lead (1.73× and 1.69× against the uniform table's 1.71× and 1.68×) because there the cheap
opacity answers still dominate what the search spends. That the regression lands only on the rows
where the search dominates, and not on the rows where the opacity table does, is what makes the
mechanism above more than a plausible story — but it is still a mechanism argued from the code, not
one the harness isolates.
This is what the emissionMix parameter exists for, and it is why it is a parameter rather than a
constant. It is also the honest bound on the headline numbers: quote the uniform table without the
mixed one and you have overstated the engine on the workload most servers actually have.
Correctness here is not "better", it is "equal". A faster engine that lights a section even slightly differently is not a faster engine; it is a second lighting of the same world, and the difference surfaces as a patch of wrong brightness that no player can explain and no log mentions.
LightEngineEquivalenceTest compares the two engines byte for byte across 54 scenarios — nine source
counts {0, 1, 2, 4, 8, 16, 64, 128, 512} against six shares of solid blocks
{0, 10, 30, 50, 70, 90} — with every section built from the fixed seed 20260731L so a failure
reproduces. It reaches BlockLight.buildInternalQueue and LightCompute.compute through reflection
rather than by placing the test inside a Minestom package, so no package of the server is split
across two artifacts. It runs with ./gradlew test like any other test. The test also asserts that
every scenario holding at least one source produced light, because two dark sections would agree no
matter what either engine did. LightEngineComparisonBenchmark repeats the same 2048-byte comparison
in its @Setup and aborts the trial on a disagreement, so a faster number cannot come from computing
something else [12].
The part worth remembering is not that the identity holds. It is that until 69381af it was
asserted in three documents and verified by nothing. It rested on a comparison run once by hand;
there was no test, and the benchmark did not check it either although the documentation said it did.
Two agents found this independently at their own ends of the code [10]. A number cited throughout the
project was hanging on nothing, and it happened to be true. That is a statement about our process,
not about the engine, and it is the reason this document insists on labelling every claim by how it
was established.
Each of the following was taken for a promising lead first, and stopped being one only after it was checked. All of them were established by reading source or published documents, not by measuring. They are the most valuable material here, because each one is a piece of work that does not have to be done again.
The idea under investigation was that Starlight stores light in arrays larger than a section, with a
one-block border, so that a propagation never has to test whether it has left the section. It does
not. ca.spottedleaf.starlight.common.light.SWMRNibbleArray declares
public static final int ARRAY_SIZE = 16 * 16 * 16 / (8/4); — 2048 bytes, 4096 nibbles, identical to
vanilla's DataLayer. The class allocates only arrays of exactly that length for storageUpdating
and storageVisible, and its WORKING_BYTES_POOL is a thread-local recycler for the same fixed
size [3].
What does exist is a flat cache in StarLightEngine, indexed x + (z * 5) + (y * 25) over a 5×5
region of chunks plus two spare sections vertically [4]. Project Status describes
these as "a flat byte[] for the column"; that is imprecise, and the correction belongs here. They are declared
protected final LevelChunkSection[] sectionCache; and
protected final SWMRNibbleArray[] nibbleCache; — arrays of section and nibble-array references,
a lookup structure that avoids repeated map access, not a bordered light buffer. Either way, it is
not a border, and there is nothing here to copy.
A large share of what Starlight wins comes from what it replaces rather than from how it propagates.
TECHNICAL_DETAILS.md describes vanilla keeping light in a hashtable from section to nibble array
and updating that map copy-on-write, so that as the number of generated chunks grows, so does the
data in the hashtable and so does the time [1]. It also names per-update hashtable lookups against a
Long2ByteMap that Starlight avoids.
Falco never had that structure. LightNibbles holds a section's levels directly, without an array at
all when the section is uniform, and there is no map from section coordinate to light array anywhere
in the propagation path. A gain measured against a structure we do not have is not available to us.
These are the numbers most often quoted at us. Their provenance, read from the document that publishes them [1]:
- They are measured against Minecraft 1.16.5. The document states its own scope explicitly: "This document is then only valid for versions from 1.16 to 1.19."
- The individual figures are per-operation microbenchmarks — block place at y=254 about 37× faster, block remove at y=254 about 13×, glowstone place about 12×, glowstone remove about 28× — not a whole-server or whole-chunk result.
- The chunk-generation comparison was invalidated by its own author, not by us. The document attributes the gap largely to vanilla's copy-on-write hashtable growing with the number of chunks generated, and states that this "basically invalidates the gen test for 1.20+" [1].
- In the gist announcing that he was ceasing to publish Starlight versions, Spottedleaf names two of Starlight's core ideas as having been taken up by the 1.20 vanilla engine — "The light engine should propagate levels, rather than updates" and "The sky light engine should have dedicated logic to light chunks" — writes that comparisons between the two on 1.20 "aren't clear on which implementation is currently faster", and says of what remains that "Vanilla is still 2x slower, but it is fast enough" [2]. He also states Starlight is "not neccessary to use on 1.20.x anyways". The gist does not say vanilla took everything, and it is quoted here only for the two ideas it names.
None of this makes Starlight a bad piece of work; it is an excellent one, and the push-based propagation it popularised is the design Falco uses. It does mean that a 37× figure carried forward to a 26.x-era server, or to a comparison with Minestom, is being quoted a decade out of context.
Two smaller corrections in the same family, from the original investigation [13]: Starlight does
not use a bucket queue. Its own document states the queue is a plain FIFO, and adds that it is
vanilla that has ordered queues by light level, backed by a LongLinkedOpenHashSet [1]. The
assumption that a priority queue was the trick to import was simply false. And Minestom's own
compute is a plain FIFO over ShortArrayFIFOQueue with no boxing, which the investigation
described as "already clean" — there is no obvious inefficiency there to exploit.
Phosphor is a Fabric mod; its repository was archived on 12 May 2025 and it is read-only [5]. Its README describes the goal but does not enumerate the optimisations, so the enumeration below comes from the source tree.
Every lighting change Phosphor makes is a mixin onto a vanilla class: MixinLightStorage,
MixinSkyLightStorage, MixinChunkToNibbleArrayMap, MixinLevelPropagator,
MixinChunkLightProvider, MixinServerLightingProvider, plus support types such as
LightProviderUpdateTracker, DoubleBufferedLong2ObjectHashMap and ReadonlyChunkNibbleArray [5].
Those are optimisations of vanilla's storage and scheduling. MixinChunkLightProvider, for instance,
caches the two most recently accessed chunk sections in private final ChunkSection[][] cachedChunkSections = new ChunkSection[2][] and tracks pending updates as a
Long2ObjectOpenHashMap<BitSet> keyed by section position, to avoid iterating all positions of a
sub-chunk. None of that has a counterpart in Falco, which holds no chunk-to-nibble map and does no
deferred update scheduling of its own.
The one transferable piece is net.caffeinemc.phosphor.common.block.BlockStateLightInfo, attached to
block states through MixinAbstractBlockState. It is two methods:
public interface BlockStateLightInfo {
VoxelShape[] getExtrudedFaces();
int getLightSubtracted();
}That is a per-block-state cache of occlusion geometry and light attenuation, resolved once and read
many times — which is exactly what SectionOpacity is, at section scope rather than global scope
and packed into a six-bit face mask plus an emission rather than into VoxelShape[]. The
optimisation was already present; only the packaging differs.
Searched for and not found: peer-reviewed work on discrete, integer-level, Minecraft-style flood-fill light propagation. The nearest thing to a canonical reference is a blog post series, Ben Arnold's "Fast Flood Fill Lighting in a Blocky Voxel Game" for Seed of Andromeda [9]; the original site did not resolve when checked for this document (TLS failure), and an archived rendering exists [9a]. Voxel cone tracing and VXGI address continuous radiance transport and do not transfer to a sixteen-level integer flood fill. This is a negative result, so treat it as "we looked and found nothing", not as proof that nothing exists.
| Subject | Basis | Verdict |
|---|---|---|
A custom net.minestom.server.instance.light.Light implementation |
source |
Not built. It compiles and works — a prototype carried a marker byte 0xCD all the way into the LightData record sent to the client [13] — but Section.clone() at instance/Section.java:26-33 calls Light.sky() / Light.block() outright, so a foreign implementation is silently replaced on copy, and the calculation methods it would have to implement carry @ApiStatus.Internal (instance/light/Light.java:37, :43) while set at :35 does not — with the qualification recorded further up, that both implementations annotate their own override of set. Handing results in through Light#set avoids the Section.clone() problem outright and leaves only the weaker of the two annotation signals to rely on. |
| A bucket queue (Dial) | measured, but not by this harness |
Not built, and no longer undecided for lack of evidence. The prior investigation put it at 5–7 % slower at equal source brightness and 32–36 % faster at mixed brightness [10]; those figures come from that investigation and not from any benchmark in falco-benchmarks, so they carry no conditions and no uncertainty here. Since 0e8fbb5 the harness does produce the mixed case, and it shows the regression the investigation predicted. The prerequisite for deciding is met; the change is still not made, and making it would need its own measurement. |
| Parallelising the BFS within one chunk | judgement, from measured magnitudes |
Not built. The work is 50–150 µs, which is the range the tables above sit in. Handing that to another thread is argued to cost more than it saves; nobody built the threaded version to check, so the cost of handing it over is asserted and not measured. Minestom parallelises across chunks in LightingChunk's static Executors.newWorkStealingPool() (instance/LightingChunk.java:38), which is the right granularity, and we do not compete with it. |
| Vector API (JEP 508) | scope |
Ruled out by packaging, not by performance — no vectorised version was written or measured. Tenth incubator round: javac refuses without --add-modules jdk.incubator.vector, the runtime then prints an unsuppressable warning, and the JAR specification has no attribute to carry the flag, so every consumer of the library would have to set a JVM flag [10]. |
| Bit-slicing light levels across voxels | scope | Not built. No precedent in any engine examined here. It would be an original design with an unproven benefit, which is not what this module is for. |
| Beating Minestom on concurrency | source |
Not attempted, because there is nothing there. It would be convenient to repeat the loader's argument about a lock held across expensive work, and it is not true of the light path. LightCompute is purely static and allocates its result buffer per call (instance/light/LightCompute.java:118), BlockLight keeps its buffers in instance fields per section (instance/light/BlockLight.java:20-22), and LightingChunk already dispatches through a work-stealing pool (instance/LightingChunk.java:38). Read, not measured: no lock in that path holds work that could run in parallel. |
Four limits, so that the trade is visible:
-
In an open section with mixed source brightness, no difference between the two engines is
resolvable. Both
MIXED/ 0 % rows overlap, at 8 sources and at 64, and the 64-source row carries a half-width of ±32.73 on a mean of 150.42 on Falco's own side. The uniform open-sky rows are the narrowest margins in the whole comparison (1.11× to 1.23×) even where they do separate. -
An area does not read the diagonal chunks of its ring.
ChunkLightAreabuilds its ring from the face neighbours of every chunk it holds, so a light source in a chunk that touches the area only at a corner is missing from the result. It reaches the area through the chunk between the two, and that chunk is in the ring — but a ring chunk's state is computed from its own block states alone, so it does not carry what its diagonal neighbour sent it. This is the residue of a larger defect that is now fixed:calculateWithNeighboursused to write back all nine chunks of its 3×3, although only the middle one had seen everything that reaches it, so the eight it borrowed had their correct light replaced with a darker one. It now writes only the middle chunk. The argument that the middle chunk is safe is a derivation from the per-block decay rather than a measurement — a source outside the 3×3 is at least 17 blocks away and level 15 cannot survive the trip — and the byte-identity tests cover a single section, not a ring [10]. The same derivation is what says the diagonals matter for an area: they are one chunk away, not two. -
The 3×3 neighbourhood therefore survives the area.
calculateWithNeighbourswas not deprecated in favour ofChunkLightArea, and the reason is exactly the point above: for a single chunk the neighbourhood is the more accurate of the two, because it reads the four diagonals an area of one chunk never sees. For several connected chunks the area is the cheaper one, because it reads each chunk once instead of once per neighbourhood — and this one is measured rather than argued.AreaVsPerChunkBenchmarkcompares one area pass over n chunks against ncalculateWithNeighbourscalls: at one chunk the two are level, which is the predicted control result and the reason the control is in the benchmark at all, and from four chunks on the intervals are disjoint at 3.81×–4.06× (4 chunks), 4.75×–6.08× (9) and 5.52×–6.76× (16) in the area's favour. A second, independent run at nine chunks gave 4.17×–7.17×, which overlaps the first — one of only two repeated-run checks this project has, and it passes. The full table is in Project Status under Measured. Two calls with different trade-offs, not an old one and its replacement. -
Sky light does not scale linearly. Cost per section rises from 84 µs to 104 µs past roughly 64
sections in
ScalingBenchmark, and a least-squares fit over the vanilla range understates the cost at 256 sections by 19.5 %, where the same method lands within 1.8 % for block light [10]. Read the direction, not the digits:ScalingBenchmarkmeasures 3 iterations rather than the 5 of every other class, no table of its fifteensectionCountpoints is published, no residuals of the fit are, and the fit is extrapolated ten times beyond its support — so "about 20 %" is as much as the evidence carries. The direction is independently supported by a mechanism that needs no benchmark: the sky pass seeds a queue at every open cell rather than reading a heightmap.
If you already run LightingChunk and your sections are sparse, there is no obligation to change
anything. What argues for this engine is the case the built-in one does not cover at all — any chunk
that is not a LightingChunk, which by default is every chunk of an InstanceContainer — followed by
sections carrying a real share of solid blocks, where the margin is 1.58× to 1.73×, and the control
over when light is computed that follows from computing it outside the chunk.
- Spottedleaf, Starlight — TECHNICAL_DETAILS.md, PaperMC/Starlight,
fabricbranch. https://github.com/PaperMC/Starlight/blob/fabric/TECHNICAL_DETAILS.md - Spottedleaf, Starlight 1.20 (gist, "The future of the Starlight mod"). https://gist.github.com/Spottedleaf/6cc1acdd03a9b7ac34699bf5e8f1b85c
-
ca.spottedleaf.starlight.common.light.SWMRNibbleArray, PaperMC/Starlight,fabricbranch. https://github.com/PaperMC/Starlight/blob/fabric/src/main/java/ca/spottedleaf/starlight/common/light/SWMRNibbleArray.java -
ca.spottedleaf.starlight.common.light.StarLightEngine, PaperMC/Starlight,fabricbranch. https://github.com/PaperMC/Starlight/blob/fabric/src/main/java/ca/spottedleaf/starlight/common/light/StarLightEngine.java -
Phosphor (phosphor-fabric), CaffeineMC; repository archived 12 May 2025. Source tree of the
mc1.18.x-0.8.1tag and the1.19.x/devbranch. https://github.com/CaffeineMC/phosphor-fabric -
net.caffeinemc.phosphor.common.block.BlockStateLightInfo, phosphor-fabric, tagmc1.18.x-0.8.1. https://github.com/CaffeineMC/phosphor-fabric/blob/mc1.18.x-0.8.1/src/main/java/net/caffeinemc/phosphor/common/block/BlockStateLightInfo.java - Minestom
2026.06.20-26.1.2,net.minestom.server.instance.light.LightComputeandnet.minestom.server.instance.light.BlockLight. Read from the published sources jar; the types are package-private and have no stable per-version web permalink. https://github.com/Minestom/Minestom - Minestom
2026.06.20-26.1.2,net.minestom.server.instance.LightingChunkandnet.minestom.server.instance.InstanceContainer(thesetChunkSupplier(DynamicChunk::new)call in the constructor). Read from the same sources jar. https://github.com/Minestom/Minestom - Ben Arnold, Fast Flood Fill Lighting in a Blocky Voxel Game, Seed of Andromeda. https://www.seedofandromeda.com/blogs/29-fast-flood-fill-lighting-in-a-blocky-voxel-game-pt-1 — the original site does not serve a verifiable certificate; a request to it fails during the TLS handshake rather than returning any status, re-checked when this page was last revised. The content behind it was therefore not read for this document. 9a. Archived and adapted rendering of the same series, which does resolve: https://notverymoe.github.io/md-gamedev-gems/voxel/lighting/soa/index.html
- Status — Anvil chunk loader and light engine, this repository, Project Status.
- Light engine, this repository, Light Engine.
- Benchmarks, this repository, Benchmarking.
- Research: a faster, lower-memory light engine, this repository, Research: Light Engine.
- Falco sources read directly for this document:
falco-light/src/main/java/net/onelitefeather/falco/light/LightPropagator.java,SectionOpacity.java,ChunkLightService.java, andfalco-light/src/test/java/net/onelitefeather/falco/light/LightEngineEquivalenceTest.java.
Most of the measured tables are owned by Benchmarking and
Project Status; a page that carries one of its own says so where the table stands.
What the ± after a JMH mean covers is stated once, in Rationale: Measurement.
Wiki home · Repository · README and quick start · API documentation · Issues · Licence: AGPL-3.0
Start here
- Quick start (README)
- Installation
- Anvil Chunk Loader
- Light Engine
-
Rationale: Instances and Chunks — the third module,
falco-instance
The measured record
Why it is built this way
- Rationale — the index for the five rationale pages
- Research — the index for the five investigations
- Research: Fluent API — the investigation behind the builders; a record, not a reference
Working on the build