-
-
Notifications
You must be signed in to change notification settings - Fork 0
Light Engine
A light propagation for a single section that resolves the properties of a block once instead of on
every visit, and stores a uniform section without an array. The algorithm itself knows nothing about
Minestom, which is what makes it testable without a running server. This page covers what the engine
computes, how to call it and where it is deliberately incomplete; it does not restate the Javadoc,
and it is not the methodology page for the numbers it quotes. It is written for someone choosing
between this engine and the one Minestom ships with, and for someone checking whether the claims made
for it hold. All references point at the sources of this repository and at Minestom
2026.06.20-26.1.2.
Experimental. Every public type of
net.onelitefeather.falco.lightis annotated@ApiStatus.Experimental. The API may still change.
Read this before quoting a number. Every figure on this page comes from a JMH benchmark run on one machine, on one JVM, under one load, and none of them is an absolute statement about lighting a world. How the numbers were produced is in Benchmarking; why they are believable and what they do not license is in Rationale: Measurement, which also carries the one definition of what the
±means.
Scope. Block light and sky light for a chunk, across section borders, across chunk borders, and incrementally after a single block changed. Works with any chunk of any loader. Either call it yourself, or hand the instance a chunk supplier and let the light keep itself up to date. See Limits for what is still missing.
- When this is worth using
- Design
- Where the resources are saved
- Correctness details worth knowing
- Compared with the light engine Minestom ships with — the equivalence check first, then eight subsections
- Usage
- Using it with a chunk loader or an instance
- Limits
- Tests
- References
Honest answer first, because it decides whether the package is relevant at all:
| Workload | Does light computation run? |
|---|---|
Loading pre-lit worlds from .mca
|
No. The stored light is applied with Light#set, which clears requiresUpdate(). Nothing is recomputed — unless you call this engine explicitly. |
| Generated worlds without stored light | Yes |
| Runtime block placement | Yes |
For loading pre-built maps from region files — the dominant Falco use case — a light engine
contributes nothing, because that code path never executes. That is a structural statement, not a
measured one: the stored arrays go straight into the section through Light#set, which clears the
update flag, so nothing asks either engine for a result. What the load path does cost instead is NBT
parsing and zlib inflation, and that ordering comes from one-off micro-measurements taken while
designing the loader rather than from any published table — treat it as an order of magnitude and
nothing finer.
Eight types compute the light, each with one responsibility. Only the two on the right know Minestom exists. A further five, listed further down, decide when it is computed and never how.
┌─ engine, no Minestom ─────────────┐ ┌─ adapter ──────────────┐
│ │ │ │
Chunk ──────────────┼──► int[] stateIds │ │ ChunkLightService │
│ │ │ │ │ │
│ ▼ │ │ │ uses │
│ SectionOpacity ◄── BlockLightSource ◄── MinestomBlockLight- │
│ │ (one lookup │ │ Source │
│ ▼ per state) │ │ │
│ ChunkLightPropagator │ │ writes back through │
│ │ (crosses section │ │ Light#set(byte[]) │
│ ▼ borders) │ │ │
│ List<LightNibbles> ──────────────┼───┼──► Chunk sections │
└───────────────────────────────────┘ └────────────────────────┘
| Type | Responsibility |
|---|---|
LightNibbles |
Storage. Two levels per byte, uniform sections without an array. |
BlockLightSource |
Abstraction over "how bright is this block and which faces does it block". |
SectionOpacity |
Precomputed table of those properties for one section. |
LightPropagator |
The breadth-first propagation, with reusable buffers. |
ChunkLightPropagator |
The same search across all sections of a chunk, so light crosses their borders. |
MinestomBlockLightSource |
Answers BlockLightSource from the block registry. |
ChunkLightState |
Keeps a calculated result and updates it incrementally, including the retraction pass and the sky heightmap. |
ChunkLightService |
Reads a chunk, runs the propagation, settles the borders against the neighbours, writes the result back. |
BlockLightSource exists for the same reason PaletteEntryResolver does in the Anvil package: it
keeps the registry out of the algorithm, so the propagation is verified with a handful of fake
blocks and no server at all.
The dominant cost of a naive propagation is not the search but the block lookups. A breadth-first search reaches a block from up to six directions, and resolving palette → block → registry → occlusion shape on each of those visits repeats the same work.
SectionOpacity resolves every distinct state id of a section exactly once when the table is
built and answers from two flat byte[] afterwards — an array index instead of a registry walk.
A section of 4096 stone blocks costs one lookup, which
SectionOpacityTest#testEveryDistinctStateIsResolvedOnlyOnce pins down.
Two further short cuts:
- A section without any emitting block returns immediately with a uniform dark result. No buffer is touched, no queue is built.
- Because a level drops by exactly one per block and the search is breadth-first, every position is reached with its final level on the first visit. No position is ever revisited or re-queued.
-
Uniform sections carry no array.
LightNibbleskeeps a single level and allocates the 2048-byte array only when a level actually differs. Most sections of a world are either fully dark or fully lit, so this is the common case rather than an edge case.fillreleases the array again. -
A fully dark section reports an empty array (
toArray().length == 0), which is how the file format stores "no light" — nothing is written for it. -
The propagator reuses its buffers. The level buffer and the queue are allocated once per instance and cleared per run, so repeated propagation allocates nothing beyond the result. An instance is therefore reusable but thread confined — use one per worker rather than sharing one.
-
The queue is an
int[], not a collection. No boxing, no growth: a section has 4096 positions and each is queued at most once, so the array is sized exactly once. -
Building the table allocates nothing per block. The lookup is a linear probing table over the raw state id, so no key is boxed and no value object is created; a resolved state is packed into a short that carries the occluded faces and the emission together. A run of one repeated state is answered from the previous block instead of the table, because the blocks of a world come in runs. This is what took the build of one section from 74 040 to 8 664 bytes per call, and it is the single largest reason for the times further down.
That pair of numbers is the most robust quantitative claim on this page, and worth more than any timing here. Allocation per invocation is essentially deterministic: it does not move with the fork count, the machine load, the JIT plan or the garbage collector, which is exactly what every timing on this page is vulnerable to. A reader who trusts nothing else can reproduce it with
-prof gcand readgc.alloc.rate.norm.SectionOpacity.ofover one section, before and after the change that removed the per-block lambda; from a-prof gcrun whose output is not committed — parameter values, iteration counts and machine unrecorded.
Occlusion is per face, not per block. A great many block types of the game occlude some faces and
not others — slabs, stairs, snow, farmland, dirt paths, lecterns, stonecutters. A design storing one
flag per block answers those wrongly. SectionOpacity stores a six-bit mask per block;
MinestomBlockLightSourceTest#testABottomSlabBlocksItsBottomFaceOnly and
#testATopSlabBlocksItsTopFaceOnly pin the behaviour down on real block data, which is what makes
this a checked property rather than an assertion.
Whether that is one block type in seven or one in three does not change the design, because a single wrongly answered slab is a visible patch of wrong brightness.
Only the entered face is tested. Light passing from A to B is blocked by the face of B it enters, not by the face of A it leaves. Testing both would leave every emitting block that is opaque itself dark — and glowstone is exactly that. This is checked end to end against the real registry.
Unknown block states are transparent, not fatal. Block.fromStateId indexes an array without a
bounds check and throws for an id outside the known range. The adapter turns that into an absent
block, because a propagation must not lose a whole section over one unknown state.
The face mapping is pinned by a test. The adapter maps its faces onto the server's by ordinal.
testTheFaceOrderMatchesTheOneOfTheServer fails if Minestom ever reorders its enum, which would
otherwise silently shift every occlusion answer to the wrong face.
Everything in this section is about block light. Both the comparison and the equivalence check
call BlockLight.buildInternalQueue and LightCompute.compute, which are the built-in block light
path; no benchmark and no test in this repository puts Falco's sky light next to Minestom's. Sky
light appears further down as a Falco-only feature, and nothing on this page compares it with
anything. That limit is repeated where it belongs, in Limits.
LightEngineComparisonBenchmark runs both engines over the same section, from a block palette to a
finished light array of 2048 bytes. It lives in net.minestom.server.instance.light because the two
methods that make up the built-in path — BlockLight.buildInternalQueue and LightCompute.compute —
are package-private, which is the only way to measure the original instead of a copy of it. Neither
side gets to skip its preparation: the built-in path builds its seed queue, and the Falco path builds
its opacity table through the real block registry rather than a stand-in.
The two engines produce the same block light, and that is checked rather than claimed. This is
the part of the comparison that does not depend on a measurement at all, and it is the part a hostile
reader cannot dismiss, so it comes first.
LightEngineEquivalenceTest#testBothEnginesLightEverySectionIdentically
compares them byte for byte over 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} percent — and runs with ./gradlew test
like any other test. It also asserts that every scenario holding a source actually carried light, so
a pair of dark sections cannot agree by accident, and a second test pins the fully dark section,
which the two engines represent differently — Minestom as an empty array, Falco without an array at
all — onto the same bytes
(LightEngineEquivalenceTest#testBothEnginesAgreeOnASectionWithoutAnyLight). The benchmark repeats
the byte comparison in its @Setup (LightEngineComparisonBenchmark.verifyBothEnginesAgree) and
aborts the trial when the two disagree, which closes the most common way a benchmark lies — winning
time by computing something else. Until recently this document asserted the byte identity while
nothing in the build verified it; the statement happened to be true, but a change that broke it would
have passed unnoticed. Nothing in this section is a statement about correctness — on block light
correctness is equal, not better, and on sky light it is unmeasured against the built-in engine.
Everything below is about time.
One section per operation, score ± error, lower is better. Every source emits level 15; sections
whose sources differ in brightness are measured separately,
further down.
java -jar build/libs/falco-*-jmh.jar "LightEngineComparisonBenchmark.(falco|minestom)" \
-p emissionMix=UNIFORM -f 1 -wi 5 -i 10
| Light sources | Solid blocks | Falco | Minestom | Falco is | Conservative bounds |
|---|---|---|---|---|---|
| 1 | 0 % | 44.5 ± 0.6 µs/op | 49.4 ± 1.3 µs/op | 1.11× faster | 1.07× to 1.16× |
| 8 | 0 % | 98.3 ± 2.4 µs/op | 121.1 ± 5.5 µs/op | 1.23× faster | 1.15× to 1.32× |
| 64 | 0 % | 109.2 ± 1.6 µs/op | 126.5 ± 5.6 µs/op | 1.16× faster | 1.09× to 1.23× |
| 1 | 30 % | 39.3 ± 0.8 µs/op | 62.0 ± 2.0 µs/op | 1.58× faster | 1.50× to 1.66× |
| 8 | 30 % | 119.3 ± 3.5 µs/op | 204.2 ± 3.7 µs/op | 1.71× faster | 1.63× to 1.80× |
| 64 | 30 % | 122.6 ± 1.3 µs/op | 206.6 ± 4.2 µs/op | 1.68× faster | 1.63× to 1.74× |
LightEngineComparisonBenchmark.falco / .minestom, emissionMix = UNIFORM,
lightSources and occlusionPercent as the first two columns, one thread, one fork, 5 warmup and
10 measurement iterations of 1 s — both counts override the class annotation, which specifies 3 and
5 — -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, no results.json
committed. The bounds column is (minestom − error) / (falco + error) to
(minestom + error) / (falco − error), so it is what the two intervals allow rather than the
quotient of the means. One fork: the ± covers variance between iterations of one JVM, not between
JVM launches — see Rationale: Measurement.
All six rows resolve a difference in the same direction, and this is the strongest evidence on the page. The intervals are disjoint at every point, and the confidence level behind them is generous — a 99.9 % interval over ten iterations is wide by construction, so two intervals that do not touch are not a near miss. The finding stands without hedging: on this machine, in this run, Falco's path is the faster of the two everywhere it was measured, by between 1.07× and 1.80× depending on the section.
What it does not license: it is a per-section time from one JVM process on one machine that was not idle, it is not a chunk lighting time and not a tick time, and the single-fork limitation applies in full. The gap on the three open-sky rows is small enough that a different machine could move it; the gap on the three 30 % rows is not.
These numbers replace an earlier set in which Falco lost four of the six scenarios. What changed is
not the algorithm but the table it works from: building SectionOpacity allocated a throwaway lambda
per block, which is broken down below. No repeat run of this table
exists. The two genuine cross-run repeats this project has are the nine-chunk AreaVsPerChunkBenchmark
re-run and the earlier IncrementalVsFullBenchmark run, both cited at their own tables in
Project Status; neither of them is this benchmark.
This is where the two engines are closest, because it is where Falco has the least to gain: nothing blocks the light, so the search rarely has to ask whether it may pass.
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#56B4E9, #E69F00"}}}}%%
xychart-beta
title "No solid blocks: Falco (blue, lower) against Minestom (orange, upper)"
x-axis "Light sources in the section" [1, 8, 64]
y-axis "Microseconds per section, lower is better" 0 --> 220
line [44.5, 98.3, 109.2]
line [49.4, 121.1, 126.5]
xychart-beta draws no legend, so: the first line, the lower one, is Falco (blue); the upper one
is Minestom (orange). The scale runs to 220 although nothing here comes close to it, so that this
chart and the next one can be held against each other.
Why the margin is small here. Before Falco computes anything, it goes through the section once and notes down for every block whether light passes through it. That note costs time before a single ray has moved. In a section with nothing in it, the search hardly ever consults it, so the preparation is paid for and barely used. This is the shape of the workload on which Falco was behind until the preparation itself became cheap enough for the remainder to be earned back; 1.11× at one source is what is left of that, and it is the smallest margin in the table for exactly this reason.
How firm that is. The spreads do not overlap at any of the three points — 44.5 ± 0.6 against 49.4 ± 1.3, 98.3 ± 2.4 against 121.1 ± 5.5, 109.2 ± 1.6 against 126.5 ± 5.6. The direction is established; the size of the gap at one source is small enough that a different machine could move it.
Once 30 % of the blocks are solid, the same mechanism works in the other direction.
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#56B4E9, #E69F00"}}}}%%
xychart-beta
title "30 percent solid blocks: Falco (blue, lower) against Minestom (orange, upper)"
x-axis "Light sources in the section" [1, 8, 64]
y-axis "Microseconds per section, lower is better" 0 --> 220
line [39.3, 119.3, 122.6]
line [62.0, 204.2, 206.6]
Same order and the same colours as before: the first line is Falco (blue), the second Minestom (orange). The distance between the lines is several times what the previous chart shows, on the same scale.
Why. Now the note earns its keep. Solid blocks are exactly what a spreading light keeps running into, and every time it does, the same question comes up again: does light get through here? Falco reads the answer off the note it wrote at the start — one position in an array. Minestom asks the registry again each time. The more often the question is asked, the more the one-off cost of writing the note is worth; and how often it is asked is set by how many light sources are spreading and how much they run into.
That is the whole shape of the result, and it is what the optimisation moved:
flowchart TB
AV["Falco<br/>pays once up front: one registry lookup per<br/>distinct block state of the section<br/>then one array read per question"]
MI["Minestom<br/>pays nothing up front<br/>then one registry lookup per question"]
Q{"How many times does the search ask<br/>'does light pass through here?'"}
AV --> Q
MI --> Q
Q -->|"few times: nearly empty section,<br/>the search runs out quickly"| L["the up-front cost is barely used.<br/>Since it became cheap it is still<br/>earned back, but only just:<br/>1.11× to 1.23× faster, UNIFORM"]
Q -->|"many times: solid blocks everywhere,<br/>every step runs into one"| W["the cheap answers add up:<br/>1.58× to 1.71× faster, UNIFORM"]
The up-front cost has not disappeared, it has shrunk. The break-even point that used to sit inside the measured range now sits below its sparsest configuration, which is why the left branch reads as a small win instead of a loss. A section that is sparser still — no sources at all — is answered without a search or a table at all, so it never reaches this decision.
How firm that is. None of the six spreads overlap. The widest margins, 8 and 64 sources at 30 % solid, are also the ones with the tightest errors on both sides: 119.3 ± 3.5 against 204.2 ± 3.7 and 122.6 ± 1.3 against 206.6 ± 4.2.
LightEngineStageBenchmark splits both engines into their stages, which is what identifies the cost.
For one light source in an open section, in microseconds. This is a genuine before/after across a
single commit — the same benchmark, the same parameters, two builds of Falco:
| readStates | opacity | propagate | collect | falcoFull (measured) | sum of the four stages | |
|---|---|---|---|---|---|---|
before 69381af
|
7.70 | 31.33 | 33.85 | 0.24 | 77.1 | 73.12 |
after 69381af
|
7.23 | 8.07 | 31.41 | 0.23 | 46.3 | 46.94 |
LightEngineStageBenchmark.falcoReadStates / .falcoOpacity / .falcoPropagate /
.falcoCollect / .falcoFull, lightSources = 1, occlusionPercent = 0, one thread, one fork,
3 warmup and 5 measurement iterations of 1 s per the class annotation, -Xms512m -Xmx512m, JMH 1.37,
one 16-core machine recorded as not idle, commit 69381af against its predecessor, no results.json
committed, run dates unrecorded. No
error bars were kept for any cell, so no rule about interval overlap can be applied to this table
and no ratio drawn from it is defensible to more than one significant figure. The last column is
arithmetic on the four preceding ones, not a measurement.
The table validates itself, which is why the two extra columns are worth having. falcoFull is
measured separately rather than summed, so the residual against the stage sum — +3.98 before,
−0.64 after — is what the method exists to expose, and it stays under 6 % on both rows. More
usefully, two stages were not touched by the commit at all: readStates moves by 6 % and collect
by 4 %. Those two are an internal control. Both moved by under 7 % and both moved in the same
direction, which suggests a small systematic shift between the two runs rather than symmetric noise;
collect at 0.23 against 0.24 is one printed digit and constrains little on its own. Even taking the
larger of the two as an upper bound on that shift, opacity going from 31.33 to 8.07 — a factor near
4 — is an order of magnitude outside it. That is the argument the table makes, and it does not depend
on error bars nobody recorded.
Building the opacity table was 41 % of falcoFull before (31.33 of 77.1) and is 17 % of a much
shorter falcoFull after (8.07 of 46.3). The table allocated a throwaway lambda per block; removing
that took the allocation of one call from 74 040 bytes to 8 664, and that allocation figure is the
sturdier half of this claim for the reasons given above.
One number in there corrects the story this document used to tell. The Falco search was already
the faster of the two before the change: 33.9 µs against 53.9 µs for the built-in search. The 53.9 is
derived, not measured — Minestom's search consumes the queue it is handed and cannot be run on a
prepared one, so it is minestomFull − minestomQueue, which the class javadoc states outright. A
difference of two means carries the sum of both uncertainties, and neither operand was published with
one, so 53.9 has no interval and must not be turned into a factor. What it supports is the direction:
the entire deficit came from the preparation, not from the propagation. The earlier text explained the
losses as a property of the algorithm's shape; they were a property of one allocation.
Every scenario above places glowstone, so every source starts at level 15. Real interiors are lit
with torches, lanterns and magma blocks side by side, and emissionMix=MIXED builds exactly that:
the same positions, drawn from the same seed, filled with glowstone 15, lantern 15, torch 14,
redstone torch 7 and magma block 3.
java -jar build/libs/falco-*-jmh.jar "LightEngineComparisonBenchmark.(falco|minestom)" \
-p emissionMix=MIXED -p lightSources=8,64 -f 1 -wi 3 -i 5
| Light sources | Solid blocks | Falco | Minestom | Falco is | Conservative bounds |
|---|---|---|---|---|---|
| 8 | 0 % | 118.97 ± 8.89 µs/op | 126.54 ± 9.55 µs/op | no difference resolvable | — |
| 8 | 30 % | 116.50 ± 7.63 µs/op | 201.46 ± 16.83 µs/op | 1.73× faster | 1.49× to 2.01× |
| 64 | 0 % | 150.42 ± 32.73 µs/op | 162.20 ± 3.11 µs/op | no difference resolvable | — |
| 64 | 30 % | 149.42 ± 12.64 µs/op | 252.26 ± 9.28 µs/op | 1.69× faster | 1.50× to 1.91× |
LightEngineComparisonBenchmark.falco / .minestom, emissionMix = MIXED,
lightSources = 8, 64, occlusionPercent as the second column, one thread, one fork, 3 warmup and
5 measurement iterations of 1 s per the class annotation, -Xms512m -Xmx512m, JMH 1.37, one 16-core
machine recorded as not idle, no results.json committed. These settings are not the ones the
UNIFORM table above was measured at (-wi 5 -i 10 there), so the two tables are two runs and their
rows must not be subtracted from one another. One fork: the ± covers variance between iterations of
one JVM, not between JVM launches — see Rationale: Measurement.
A single source is not measured under MIXED: the first block of the set is glowstone, so a lone
source is the identical section UNIFORM already covers.
Two of the four rows resolve nothing, and the two that do are the ones with solid blocks. At 0 %
solid the intervals overlap in both cases — [110.08, 127.86] against [116.99, 136.09] at 8
sources, and [117.69, 183.15] against [159.09, 165.31] at 64. The means put Falco ahead by 6 %
and 8 % respectively, and neither is established; nor is equality. The 64-source row is the loosest
measurement on this page, with a Falco half-width of 22 % of its mean, and that dispersion is itself
the interesting part: mixed brightness makes Falco's time less predictable, not merely larger.
At 30 % solid the intervals are disjoint on both rows and the lead is the same one the uniform table
shows — 1.73× and 1.69× here against 1.71× and 1.68× there. These are not a repeat of each other:
UNIFORM and MIXED are two different inputs, measured at different iteration counts, so what the
closeness shows is a fact about the mechanism rather than about run-to-run stability. Where solid
blocks are present the cheap opacity answers dominate what the search spends, so the source mixture
barely touches the result. The project's two genuine cross-run repeats are elsewhere — see
Project Status.
Mixed levels cost Falco more than they cost Minestom, and the mechanism is in the search: it
assumes the queued positions are ordered by level, which is true only while every source starts at
the same one. With mixed levels a position can be reached again later by a brighter wave, and the
same positions are touched more than once. Comparing the open-sky rows across the two tables shows
the direction — 109.2 µs at 64 sources under UNIFORM against 150.42 µs under MIXED — but those
are two runs at different iteration counts, so the difference is not a measured quantity and no
percentage is quoted for it here.
If a bucket queue is ever added to the propagator, emissionMix is the parameter that will show
whether it was worth it — the two rows where the intervals overlap are exactly where it would be
expected to help — and that is why it is a parameter rather than a constant.
In the UNIFORM run above, Falco has the smaller absolute half-width at every one of the six points: ± 0.6 to ± 3.5 against ± 1.3 to ± 5.6. Measured relative to the score the picture is almost the same, with one exception — at 8 sources and 30 % solid Minestom is the tighter of the two (1.8 % against 2.9 %). The earlier version of this document reported the opposite across the board, on an earlier run; the drop in allocation is the likeliest reason the Falco spread fell, since it removes the garbage collector from the measurement. Likeliest, not established: nothing here isolates the garbage collector from any other cause.
Two limits on how far that reading goes. The ± is a confidence interval over the measurement
iterations of one JVM process, so it describes dispersion inside a single run and not how either
engine behaves across JVM launches — a comparison of two such intervals is a comparison of two
within-run dispersions and nothing wider. And under MIXED the ordering reverses on the loosest row:
150.42 ± 32.73 against 162.20 ± 3.11, where Falco is the noisier of the two by an order of magnitude.
Whichever engine is the steadier, it is not a property that holds across the parameter space.
The machine that produced every table on this page is recorded as not idle, which is a live candidate explanation for any spread on it and has not been ruled out for any row.
The Anvil comparison in Anvil Chunk Loader turns on a lock that is held across
expensive work. It would be convenient to claim the same thing on the light side, and it is not true.
Minestom's light path is already built for several threads, at 2026.06.20-26.1.2:
LightCompute.compute is static and allocates its result buffer per call
(instance/light/LightCompute.java:115), BlockLight keeps its buffers in instance fields, one
instance per section (instance/light/BlockLight.java:20-22), and LightingChunk already runs its
work on an Executors.newWorkStealingPool() (instance/LightingChunk.java:38). Nothing in there
serialises work that could be running in parallel, so there is no contention to remove. This is a
structural reading of the source, not a measurement — no benchmark on this page contends the light
path from several threads, and none should be quoted as if it did.
This is the one argument for this engine that does not depend on a measurement, and it is the
strongest one. Minestom computes light only inside LightingChunk, which extends DynamicChunk
(instance/LightingChunk.java). Use any other chunk implementation and no light is computed at all.
Falco computes outside the chunk and hands the finished array over through Light#set, which every
chunk accepts.
flowchart TB
subgraph mine["Minestom: the light lives inside one chunk class"]
direction TB
M1["LightingChunk<br/>(extends DynamicChunk)"] --> M2["computes its own light internally"]
M2 --> M3["sections are lit"]
M4["any other Chunk implementation"] --> M5["no light at all"]
end
subgraph falco["Falco: the light is computed outside and handed in"]
direction TB
A1["any Chunk — LightingChunk,<br/>DynamicChunk, your own"] --> A2["ChunkLightService reads the block states"]
A2 --> A3["propagation runs outside the chunk,<br/>knowing nothing about Minestom"]
A3 --> A4["Light#set(byte[]) per section"]
A4 --> A5["sections are lit"]
end
The same property has a second consequence: because the propagation references no Minestom class at all, it can be tested against a handful of fake blocks without a running server. Only the adapter that answers from the real registry needs one.
Being outside the chunk does not mean giving up the convenience of being inside it.
FalcoLightingChunk offers the same one-line setup as LightingChunk while the computation stays in
a service that any chunk type can be handed to — see
Letting the chunk keep its own light. The two are not
alternatives: the chunk is a caller of the service like any other.
The reason this section used to give — the built-in engine is faster on sparsely occupied sections — no longer holds, so it needs restating rather than deleting.
If you already use LightingChunk, there is still no obligation to change anything. The built-in
engine is wired into the server, costs no extra code and no extra call site, and on an open section
lit by sources of differing brightness no difference between the two is resolvable at all — both
MIXED rows at 0 % solid have overlapping intervals. Swapping a working light path for a margin the
measurement does not establish is not a trade at all.
What does argue for this engine is the case the built-in one does not cover: any chunk that is not a
LightingChunk, which includes the chunk type an InstanceContainer uses unless it is told
otherwise. That one is structural and needs no benchmark. After it come the workloads where the
margin is both large and resolved — sections carrying a real share of solid blocks, where the tables
above put Falco ahead by 1.58× to 1.71× under UNIFORM and 1.69× to 1.73× under MIXED, all five
of those rows with disjoint intervals, though across two separate runs whose figures should not be
pooled —
and the control over when light is computed that comes from computing it outside the chunk.
The convenience argument has stopped being one either way. setChunkSupplier(scheduler.supplier())
is the same amount of setup as setChunkSupplier(LightingChunk::new), so choosing between the two is
now a question about the engine rather than about how much wiring it takes.
import net.onelitefeather.falco.light.LightNibbles;
import net.onelitefeather.falco.light.LightPropagator;
import net.onelitefeather.falco.light.MinestomBlockLightSource;
import net.onelitefeather.falco.light.SectionOpacity;
// One per worker thread; it keeps reusable buffers.
LightPropagator propagator = new LightPropagator();
MinestomBlockLightSource source = new MinestomBlockLightSource();
int[] stateIds = new int[LightNibbles.BLOCK_COUNT]; // block states of one section
// ... fill stateIds from a palette ...
LightNibbles light = propagator.propagate(SectionOpacity.of(stateIds, source));
int level = light.get(8, 8, 8);
byte[] stored = light.toArray(); // empty when the section is darkBlockLightSource can be implemented directly to run the engine without a server:
BlockLightSource fake = new BlockLightSource() {
@Override public int emission(int stateId) { return stateId == LAMP ? 15 : 0; }
@Override public boolean blocksFace(int stateId, BlockFace face) { return stateId == STONE; }
};ChunkLightService is the entry point. It reads the block states of a chunk, propagates, and hands
the result to the sections through Light#set(byte[]):
import net.onelitefeather.falco.light.ChunkLightService;
// Keeps no state between calls, so a single instance serves as many threads as you like.
ChunkLightService lighting = new ChunkLightService();
Chunk chunk = instance.loadChunk(0, 0).join();
lighting.calculate(chunk);
int level = lighting.blockLightAt(chunk, 8, 40, 8);This works with any chunk, regardless of which loader produced it — the Anvil loader of Falco,
the one Minestom ships with, or a generated chunk. A test covers the round trip through
FalcoAnvilLoader explicitly.
Two properties make this the stable way in:
-
Light#set(byte[])is not marked internal, unlikecalculateInternal/calculateExternal. The service therefore does not implement theLightinterface and cannot break when the signatures of those internal methods change. -
setclears the update flag of the section, so the server does not recompute what was just written. A wrong result is therefore never corrected on its own, which is why every write path is covered by a test.
One service is enough for a whole server. Unlike LightPropagator above, ChunkLightService
holds exactly one field, the BlockLightSource it was constructed with, and every working buffer
lives in a ChunkLightPropagator built inside the call
(ChunkLightService#calculate,
#calculateSky).
A single instance may therefore be used by as many threads as one likes, and that is a property of
the class layout rather than of a benchmark.
ChunkLightServiceConcurrencyTest#testOneServiceCalculatesTheSameBlockLightFromManyThreads and its
sky-light sibling #testOneServiceCalculatesTheSameSkyLightFromManyThreads hold it in
place. That shape is not a coincidence but the fix to a defect: the service once kept a propagator in
a field, and two threads sharing one service then shared its scratch buffers, which produced wrong
light in the large majority of concurrent calls.
Locking follows the same three-stage split the Anvil loader uses: the block states are read under the
chunk's read lock (ChunkLightService#readStates), the propagation runs with no lock held, and
only the transfer of the result takes the write lock
(ChunkLightService#applyLight,
which brackets its loop with chunk.lockWriteLock() and chunk.unlockWriteLock()).
lighting.calculateSky(chunk);Sky light enters from above and falls straight down without losing a level until something stops it — which is why an open field is fully lit at every height while a cave is dark. Only after the fall is interrupted does it spread like any other light, losing one level per block.
lighting.calculateWithNeighbours(instance, chunkX, chunkZ);Lighting a chunk on its own ends its light at the border, which shows up as a straight dark line every sixteen blocks. This method exchanges the border levels with every already loaded neighbour in both directions, and writes only the chunk in the middle. Neighbours that are not loaded are skipped rather than forced to load.
One round of that exchange is not enough. A source in the corner of a chunk sends light through two borders, and the light that entered a neighbour has to leave it again on another side to arrive in the chunk diagonally behind it. The exchange therefore repeats over the whole area — the chunk and the eight positions around it — until no chunk of it raises a level any more:
| Property | How it is reached |
|---|---|
| Terminates | An injection only ever raises a level, and a level is capped at fifteen, so the repetition walks towards a fixed point. |
| Same result every time | The area is a fixed-size array walked in a fixed order, not a map. Since every step only raises levels, the fixed point does not depend on the order either. |
| Reads a chunk once | The opacity tables of every participating chunk are built once, before the first round, and reused by all of them. |
| Cannot loop forever | The amount of rounds is capped at sixteen, which is one more than the highest level that can exist. Hitting the cap is reported through LOGGER.warn instead of being accepted silently. |
A radius of one chunk is enough because a level of fifteen cannot survive sixteen blocks of travel, so nothing the middle chunk emits can reach a second ring.
The eight chunks around the middle are read and never written, and that is not a shortcut. They only exchanged light inside the 3×3, so whatever they legitimately receive from outside it is missing from their result; writing that back would replace their correct light with a darker one. The middle chunk does not have the problem, and provably so: a source outside the 3×3 is at least seventeen blocks away from it and no path can be shorter than the direct distance, so not even a level of fifteen survives the trip. Writing one chunk instead of nine is therefore cheaper and correct. The method used to write all nine, which is the defect this replaces.
What this method still has over a one-chunk area. The 3×3 includes the four diagonal chunks;
the ring of a ChunkLightArea is built from face neighbours
only. A source in a diagonal chunk reaches the middle chunk through the chunk between them, and the
neighbourhood carries that — an area of a single chunk never reads it. For one chunk this is
therefore the more accurate of the two calls; for several connected chunks the area is the cheaper
one, because it reads every chunk once instead of once per neighbourhood. That an area misses its own
diagonals is a gap of its own, and it is recorded in Project Status rather than
glossed over here.
Everything above is a call you make yourself: you have to notice that a chunk changed and decide when
to recompute it. FalcoLightingChunk removes that step, and it is the same entry point Minestom sets
with setChunkSupplier(LightingChunk::new):
import net.onelitefeather.falco.light.ChunkLightScheduler;
import net.onelitefeather.falco.light.ChunkLightService;
ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService());
instance.setChunkSupplier(scheduler.supplier());That is the whole setup. Keep one scheduler per instance — a second instance handed to the same one
is refused with an IllegalStateException, because the dirty set is keyed by chunk coordinates alone
and every instance of a server ticks with the same timestamp.
Five types, and only one of them holds any behaviour:
| Type | Responsibility |
|---|---|
FalcoLightingChunk |
The drop-in, a DynamicChunk subclass. Five overrides and no computation: setBlock reports the changed position, onLoad reports a change of unknown extent, tick triggers the pass, invalidate drops the cached light packet, and onLightUpdated — its half of LightUpdateAware — resends it. |
ChunkLightScheduler |
Everything else — the dirty set, the once-per-tick trigger, area forming, the executor, back pressure and the staleness rule — so a reader looking for the behaviour finds it in one place. |
ChunkArea |
A chunk coordinate pair, and the flood fill that cuts a dirty set into capped connected groups. Pure arithmetic, no Minestom. |
ChunkLightArea |
Computes one group in a single pass: reads its chunks plus one ring, exchanges borders until settled, writes back the group and never the ring. Keeps the light of each chunk it has computed, so the next pass replays the reported changes on it. |
LightUpdateAware |
The hook Minestom does not have, so a computed result can be delivered without knowing which chunk type it belongs to. |
A change marks the 3×3, not just the chunk it happened in. A lamp on the eastern edge of a chunk belongs in the light of the chunk east of it, and that chunk would otherwise never be told. The ring around an area is the mirror image of the same rule: it is read so the edge of the area is right, and never written, because a ring chunk has not seen what lies on its own far side.
A changed block costs a changed block, not nine chunks. setBlock reports the position that
changed, not merely that its chunk is dirty. The area keeps the light of every chunk it has computed
and replays the reported positions on it, so a placed torch costs one incremental update rather than
nine full chunk searches. The eight neighbours are still marked, because light crosses borders, but
nothing of theirs is discarded: their own blocks did not move, and what arrives across the border is
derived again by every pass anyway. A change that cannot be placed — a chunk that was generated,
loaded, or written past setBlock — is reported as being of unknown extent, and that chunk is
searched again. What this is worth is measured by IncrementalVsFullBenchmark and the full table is
in Project Status under Measured: replaying one changed block against relighting
the 3×3 from block states is 2.07× cheaper on block light (incremental 7 747 ± 402 against full
16 028 ± 778 µs/op, conservative bounds 1.87× to 2.29×) and 5.60× cheaper on sky light
(incremental 7 065 ± 438 against full 39 585 ± 1 512 µs/op, bounds 5.07× to 6.20×). Both pairs of
intervals are disjoint, so both are established for that run. The third figure often quoted alongside them — a tick that pays for both kinds — is derived:
it is the sum of the two rows above, not a measurement of its own, and summing assumes such a tick
shares no work between the two kinds, which is an assumption about the implementation rather than a
measured fact. Propagating the errors gives 55 613 ± 2 290 against 14 812 ± 840, so about 3.7×,
bounds 3.41× to 4.14×.
IncrementalVsFullBenchmark.full / .incremental, sky = false and sky = true, one thread,
one fork, 5 warmup and 10 measurement iterations — an override of the class annotation, which
specifies 3 and 5 — no heap flags, which is the one class in the harness that pins neither -Xms
nor -Xmx while building a real Minestom chunk world, 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.
An independent earlier run of the same benchmark gave 1.96× / 6.40× / 3.95×. That is the most useful sentence available about any number on this page: the ratios reproduce and the third digits do not. Two runs, two JVMs, same direction, same order of magnitude, different last digit — which is exactly what a single-fork measurement should be expected to deliver, and exactly the reason no third digit anywhere here is load-bearing.
What is kept is the light of a chunk alone, before any border exchange, and that is what makes the hard direction tractable. Taking light back is the case an incremental engine gets wrong, because a retraction has to run until light from somewhere else legitimately takes over — and a retraction that had to leave the chunk to find that point would need the neighbours retracted with it. It never has to here: the kept light contains nothing from any neighbour, so every level in it originates inside the chunk and the retraction is complete at the border by construction. The light that crosses borders is not stored at all; it is derived again on every pass, from a copy of the kept light, and an exchange only ever raises levels and so cannot carry a stale glow forward.
The kept light is bounded — roughly 100 KB per chunk and kind of light, for at most 128 chunks by
default (ChunkLightArea.DEFAULT_MAX_CACHED_CHUNKS), least recently used first — and dropping an
entry costs a full propagation and nothing else. The result is the same bytes a full recalculation
produces, and a chunk that cannot be followed incrementally is simply propagated again.
Areas are formed once per tick and capped. Chunk#tick(long) runs per chunk, but a pass has to
see every change of the tick before it groups anything, so the scheduler runs its pass for the first
chunk reporting a timestamp it has not seen. Connected dirty chunks are lit together, and the group
is closed at maxAreaSize chunks, 16 by default, with the remainder starting the next area. The cap
is there because every chunk of an area and of its ring is read and turned into opacity tables inside
one tick; a ChunkLightState of roughly 100 KB per chunk is the smaller half of that bill. The seam
between two parts settles on the following tick, because each part reads the other as its ring.
Nothing ever blocks on a computation. A chunk hands out whatever its sections hold right now, which is the previous result while a new one is in flight. A chunk whose area is still running stays marked but is not submitted again, and a chunk that changed while its area ran has its result discarded and stays dirty rather than being written from block states that are already gone.
// Deterministic: the task runs on the calling thread, so a tick is finished when onTick returns.
ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService(), Runnable::run, 16);The default gives every area its own virtual thread and bounds them with a semaphore at the processor
count, the same shape FalcoAnvilLoader uses for its saves. The bound sits inside the task rather
than around the submission on purpose: acquiring it before starting the thread would block whichever
chunk happened to trigger the pass, and that chunk is being ticked by the server.
A direct executor turns the whole cycle synchronous, which is what makes the tests of this path
deterministic rather than timing-dependent — place a block, call tick, assert the light. A server
that already has a pool can hand that one over instead.
A batch is covered by this too. AbsoluteBlockBatch#apply ends by calling sendLighting() on
every touched chunk that is a LightingChunk and skips every other type, so a FalcoLightingChunk
would never be resent by it. It does not have to be: a batch writes through setBlock, which marks
the chunk here, so the next tick lights the whole touched region and sends it. The result arrives one
tick later than Minestom's would, and it arrives for the ring around the batch as well, which
Minestom's path does not manage.
ChunkLightService#calculate always recomputes the whole chunk. For a single block change that is
wasteful, and ChunkLightState exists for that case. It is what ChunkLightScheduler runs on:
FalcoLightingChunk#setBlock reports the position, ChunkLightArea keeps the light of the chunk and
replays the position on it. Use the type directly only if you drive the engine yourself.
ChunkLightState state = ChunkLightState.blockLight(opacityTables);
// after a block changed at that position
state.update(updatedOpacityTables, x, y, z);
List<LightNibbles> light = state.toSections();Adding brightness is straightforward — it only spreads. Removing it is the hard case and the reason this class exists: when a light source disappears, the brightness it had spread is still stored in every block around it, and spreading again would keep that glow forever. The update therefore runs two passes. The first retracts every level that originated from the changed position and collects the still valid levels it meets at the edge of the retracted area; the second spreads those back in.
Which way an update goes, drawn out:
flowchart TB
C["a block changed at x, y, z"] --> K{"is the position<br/>brighter or darker than before?"}
K -->|brighter| S["one pass: spread outwards.<br/>Levels only ever rise, so nothing<br/>has to be taken back"]
K -->|darker| R1["pass 1: retract.<br/>Walk outwards and clear every level<br/>that came from this position"]
R1 --> R2["at the edge of the cleared area,<br/>collect the levels that came<br/>from somewhere else"]
R2 --> R3["pass 2: spread those collected<br/>levels back in"]
S --> D["result is identical to<br/>a full recalculation"]
R3 --> D
The second pass is not a correction of the first. The light of every other source in the neighbourhood is legitimate and was cleared along with the rest simply because it stood in the way; collecting it at the edge and letting it back in is what puts it back. Skipping the retraction instead and only spreading again would leave the removed source's glow in place for good.
ChunkLightStateTest#testTheIncrementalResultMatchesAFullRecalculation asserts that the incremental
result is identical to a full recalculation, block for block.
Sky light has an origin no block holds: it falls in from above. An update can therefore not tell from the levels alone which positions lost their origin and which gained one, and a state that holds sky light keeps a heightmap for that reason — the highest position that stops the sky, per column.
A block change moves exactly one column of that heightmap, and the difference between the old and the new height names the positions whose origin changed:
| Change | Effect on the column |
|---|---|
| A block is placed above the current height | Everything between the old and the new height falls out of the open sky and gives its level back. What is left is refilled from the sides, which is why a single pillar leaves a level of fourteen below it rather than darkness. |
| The highest blocking block is removed | The column opens down to the next block below, and every position in between receives the full level again and spreads it. |
| The change is below the height | The height stays where it is. The changed position is retracted and refilled from its neighbours, exactly as a block light update works. |
Only the changed column is walked again, so an update no longer re-seeds all two hundred and fifty six columns of the chunk.
SkyLightUpdateTest asserts the result against a full recalculation block for block, for both
directions, for a change that is not in the highest blocking position, and for a seeded sequence of
random changes that verifies the equality after every single one of them.
| Situation | Method |
|---|---|
| A live world where light should simply be right |
setChunkSupplier(scheduler.supplier()), then nothing |
| Chunk loaded without stored light, or generated |
calculate / calculateSky
|
| One chunk loaded and its neighbours matter |
calculateWithNeighbours — its 3×3 includes the four diagonal chunks, which a one-chunk area never reads |
| Several connected chunks at once |
ChunkLightArea#compute — measurably cheaper than one neighbourhood per chunk from four chunks on |
| A single block changed |
ChunkLightState#update, or nothing at all if the scheduler is driving |
-
Sky light is never compared with Minestom's. Every comparison on this page runs against
BlockLight.buildInternalQueueandLightCompute.compute, which are the built-in block light path.LightEngineEquivalenceTestcompares block light only, andLightEngineComparisonBenchmark.falco/.minestommeasure block light only. Falco's sky light is checked against Falco's own full recalculation — that is whatSkyLightUpdateTestandSkyLightTestdo — and against nothing else. No claim of byte identity with the built-in engine is made for sky light on this page, and none may be quoted from it. -
No
Lightimplementation. The engine deliberately does not implementnet.minestom.server.instance.light.Light. It writes its result throughsetinstead, which avoids depending on the internal calculation methods of that interface. -
The exchange covers one ring of chunks.
calculateWithNeighbourssettles the chunk and the eight positions around it, and an area settles its own chunks against a ring. That is enough for the chunks being written, but the ring itself is not settled against its own neighbours further out. -
The ring of an area holds no diagonals. It is built from the face neighbours of every chunk of
the area, so a source in a chunk that touches the area only at a corner is missing from the result.
calculateWithNeighboursreads its diagonals and does not have this gap, which is why it was not deprecated in favour of the area. - An area rebuilds every opacity table on every pass, for each of its chunks and each of its ring chunks, whether or not anything in them moved. Now that the light itself is kept between passes, this is the largest remaining cost of a pass.
- An area split at the cap leaves a seam for one tick. Each part reads the other as its ring, so the following pass settles it.
-
Section.clone()discards foreign light. Should an adapter be built later, note thatSection.clone()callsLight.sky()/Light.block()outright, so any custom implementation is silently replaced on copy.LightingChunk.copy()would have to be overridden. -
LightPropagatorandChunkLightPropagatorare thread confined. Both keep their level buffer and queue in instance fields and clear them per run (LightPropagator), so one instance per worker thread and never a shared one.ChunkLightServiceis the exception and is safe to share, because it builds a propagator inside every call. Nothing enforces the distinction at compile time; getting it wrong produces wrong light rather than an exception, which is the failure mode this engine has already been bitten by once. -
One
ChunkLightSchedulerserves one instance. A second instance handed to the same scheduler is refused with anIllegalStateException, because the dirty set is keyed by chunk coordinates alone and every instance of a server ticks with the same timestamp (ChunkLightScheduler). A server with many worlds needs one scheduler per world, and the memory bound is therefore per world and not global. -
The kept light is capped and the cap is not adaptive.
ChunkLightArea.DEFAULT_MAX_CACHED_CHUNKSis 128 andChunkLightScheduler.DEFAULT_MAX_AREA_SIZEis 16, both plain constants with no measurement behind either value. Dropping an entry costs a full propagation, which is correct but not free; no benchmark in this repository sweeps either constant. -
Nothing here reports what a real server pays. Every measurement on this page is a per-section or
per-chunk average time from a JMH harness, and
Mode.AverageTimereports a mean. A server's felt lighting cost is a tail — the tick that took too long — and no percentile is measured anywhere in the suite. The comparison rows are valid against their own counterparts and against nothing else.
Everything that tests the algorithm itself runs without a Minestom server. Nine classes need one and
use Cyano's MicrotusExtension for it, each because a server is what the test is about:
| Class | Why it needs a server |
|---|---|
MinestomBlockLightSourceTest |
The directional occlusion of a slab is only meaningful against real block data. |
LightEngineEquivalenceTest |
Compares the two engines byte for byte over 54 scenarios; an equivalence claim is only worth something if both sides see the same registry. |
ChunkLightServiceIntegrationTest |
The service reads real chunks and writes through Light#set. |
ChunkLightServiceConcurrencyTest |
The same, from several threads at once. |
ChunkLightAreaTest |
Light crossing a real chunk border, and the ring keeping its own light. |
ChunkLightSchedulerTest |
The tick cycle against a real instance, made deterministic with a direct executor. |
ChunkLightSchedulerConcurrencyTest |
The same cycle under threads, where back pressure and the staleness rule are what is being tested. |
IncrementalLightUpdateTest |
The incremental path has to agree with a full pass over real chunks, not over fake blocks, or the claim it rests on is worth nothing. |
FalcoLightingChunkTest |
The chunk only means anything inside an instance that supplies and ticks it. |
ChunkArea is the deliberate exception on the other side: area forming is coordinate arithmetic with
no Minestom type in it, so ChunkAreaTest runs without a server even though the rule it checks is
about chunks.
LightEngineEquivalenceTest reaches BlockLight.buildInternalQueue and LightCompute.compute
through reflection rather than by placing a test inside a Minestom package, so no package of the
server is split across two artifacts and the queue type of the built-in path stays off the test
classpath.
Falco sources are cited by member name throughout, and every type of the engine lives under
falco-light/src/main/java/net/onelitefeather/falco/light/.
The tests are under
falco-light/src/test/java/net/onelitefeather/falco/light/,
and the two benchmark classes quoted here are
LightEngineComparisonBenchmark,
LightEngineStageBenchmark
and
IncrementalVsFullBenchmark.
Minestom sources cited here, at version 2026.06.20-26.1.2:
net/minestom/server/instance/light/Light.javanet/minestom/server/instance/light/BlockLight.javanet/minestom/server/instance/light/LightCompute.javanet/minestom/server/instance/LightingChunk.javanet/minestom/server/instance/DynamicChunk.javanet/minestom/server/instance/Section.javanet/minestom/server/instance/batch/AbsoluteBlockBatch.javanet/minestom/server/instance/block/Block.java
The methodology behind every number above is in Benchmarking for how to re-run it and in Rationale: Measurement for why it is believable and what it does not license. The design argument that sits behind the engine is in Rationale: Lighting; the loader that produces the chunks it lights, and which shares its three-stage locking shape, is in Anvil Chunk Loader.
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 — a proposal; describes no existing API
Working on the build