Lighting
Verified against Minecraft 26.3 · Part IV · A torch is placed on a cave wall: the write queues a task, a worker floods the change, and the sections it touched are published as a copy and mailed to the client.
A player right-clicks a torch onto stone. The block goes into its
LevelChunkSection immediately, the heightmaps move, and then
LevelChunk.setBlockState reaches the light engine, for this torch, only to
call LevelLightEngine.checkBlock, which on the server computes nothing at
all.
It wraps the call in a runnable and puts it on a queue the level tick never
drains itself. There is no light thread and no light phase of
the tick: the flood that turns the torch’s 14 into a sphere of falling
numbers runs because the Server thread went looking for something to do,
usually because the tick finished early. The kick is
ThreadedLevelLightEngine.tryScheduleUpdate, and its usual caller is
ServerChunkCache.MainThreadExecutor.pollTask, which the idle poll
MinecraftServer.pollTaskInternal reaches while the server is ahead of its
schedule, blocked or sprinting, and a synchronous chunk wait reaches in the
middle of a tick. Ask the server engine to light something synchronously and
it refuses: ThreadedLevelLightEngine.runLightUpdates throws.
The cast
| class | what it decides | thread |
|---|---|---|
LevelLightEngine | the facade both sides hold — one LightEngine per layer, the read side, and the padding above and below the world | whoever calls it |
ThreadedLevelLightEngine | the server’s wrapper: every mutator becomes a queued task, and running updates on demand is an error | queued from Server, drained on the light executor |
BlockLightEngine · SkyLightEngine | the algorithm — what a changed block means, and how far the change travels | the light executor (Client: the Render thread) |
LayerLightSectionStorage | the double buffer: which sections hold data, which changed, and when to publish | the same |
DataLayer | 2048 bytes of nibbles for one section, or no bytes at all | whoever holds it |
ChunkSkyLightSources | the chunk’s 256-entry lowest sky source Y table — the one piece of light work that is not deferred | Server, inside the block write |
ServerChunkCache | the LightChunkGetter: which chunks the engine may read, and where the one callback lands | callback on the light executor, body on Server |
ChunkHolder | which sections each watching player is owed, as two BitSets | Server |
From a click to a lit wall
sequenceDiagram
participant LC as LevelChunk
participant TLE as ThreadedLevel<br/>LightEngine
participant SCC as ServerChunkCache
participant BLE as BlockLightEngine
participant LLSS as LayerLight<br/>SectionStorage
participant CH as ChunkHolder
participant CL as ClientLevel
Note over LC,CH: one server tick, inside LevelChunk.setBlockState
LC->>LC: the light properties differ, so the column's sky-light source is repaired
LC->>TLE: checkBlock, as a PRE_UPDATE task at the chunk's queue level
Note over LC,SCC: still the same tick, and nothing has been lit
SCC->>TLE: tryScheduleUpdate, because the poll found no chunk work
Note over TLE,LLSS: the light executor, off the Server thread
TLE->>BLE: the window's PRE tasks reach checkBlock, then runLightUpdates on this layer
BLE->>BLE: checkNode enqueues a pull-in decrease and an emission increase of 14
BLE->>LLSS: setStoredLevel, 14 then 13, 12 and down, as the increases propagate
LLSS->>LLSS: a fresh copy of the updating map is published
LLSS->>SCC: onLightUpdate once per affected section
Note over SCC,CH: back on the Server thread, whenever the posted task is polled
SCC->>CH: sectionLightChanged — it bails below INITIALIZE_LIGHT, and again with no ticking chunk
Note over SCC,CH: end of ServerChunkCache.tickChunks, normally the next tick
SCC->>CH: broadcastChanges, once the chunk tick is over
CH->>CL: Clientbound<br/>LightUpdatePacket,<br/>to a player at the<br/>edge of the view
Note over CL: the next frame, not the next tick
CL->>CL: pollLightUpdates, applyLightData, then runLightUpdates on the client's own engine
A torch placed, and the four boundaries the server’s light crosses before it reaches a player at the edge of the view: the placement and the lighting are in the same tick but not on the same thread, the packet waits for the end of the chunk tick, and the client applies it on a frame rather than a tick. Nothing on this page blocks the Server thread on a light result.
Most sections below walk one stretch of that diagram. Two classes are absent
because they carry no decision: ChunkTaskDispatcher, which holds the queued
task under the chunk’s priority and hands it to the executor, and
ClientPacketListener, which on arrival only wraps the packet in a closure
for later.
Two 4-bit fields, and the sections that may not exist
Light is LightLayer.SKY and LightLayer.BLOCK, computed by separate
machinery that shares only its shape. LevelLightEngine holds one
LightEngine per layer — LevelLightEngine.skyEngine is null in a dimension
whose DimensionType.hasSkyLight is false, and
LevelLightEngine.getLayerListener hands out
LayerLightEventListener.DummyLightLayerEventListener in its place. Readers
rarely see the layers apart: LevelLightEngine.getRawBrightness is the
maximum of block light and sky light minus a darkening term, which is what
LevelReader.getMaxLocalRawBrightness passes LevelReader.getSkyDarken into,
and BlockAndLightGetter.canSeeSky is just sky light at 15.
Storage is per layer and per section, one section taller than the world at
each end — LevelLightEngine.LIGHT_SECTION_PADDING is 1 and
LevelLightEngine.getLightSectionCount is the world’s sections plus two. A
section’s storage is a DataLayer: 16×16×16 nibbles in DataLayer.SIZE
bytes, indexed y then z then x, and allocated lazily. DataLayer.data
starts null and every read answers DataLayer.defaultValue until the first
write forces the array into existence, and DataLayer.fill throws it away
again. DataLayer.isEmpty therefore means homogeneous zero, not all
zeroes I checked, and it is the test both the chunk saver and the packet
builder use to charge nothing for a dark section.
Which sections have a DataLayer at all is decided by
LayerLightSectionStorage.sectionStates, one byte per section holding a
has-data bit and a five-bit count of how many of its 26 neighbours have data
(LayerLightSectionStorage.SectionState). A byte of zero means no storage,
so a section of pure air beside a built-up one is allocated and a section in
the middle of nothing is not. Above the sky column’s top section there is no
storage at all, and SkyLightSectionStorage.getLightValue answers 15 without
looking; its upward walk happens below the top, when a
section inside the column has no DataLayer and the search climbs for one.
The write that queues nothing but a task
LevelChunk.setBlockState asks two questions about light. If the section
flipped between all-air and not — the torch is the first block into an empty
section, or the last one out — LevelLightEngine.updateSectionStatus goes in.
Then, if LightEngine.hasDifferentLightProperties says the old and new states
differ in emission or in dampening, or if either of them uses a shape for
light occlusion, two things happen. The first is ChunkSkyLightSources.update
on the chunk’s own table, and it runs inline, on the Server thread, inside
the block write: the sky column’s lowest source Y for that one (x, z) is
repaired immediately, because everything downstream reads it. The second is
LevelLightEngine.checkBlock, and on the server that is where the work stops.
ProtoChunk.setBlockState does the same pair, but only once the chunk’s
status has reached ChunkStatus.INITIALIZE_LIGHT; before that a generator
writing blocks tells the light engine nothing.
ThreadedLevelLightEngine overrides every mutator the same way. The call
becomes a Runnable tagged ThreadedLevelLightEngine.TaskType.PRE_UPDATE or
ThreadedLevelLightEngine.TaskType.POST_UPDATE and goes through
ChunkMap.lightTaskDispatcher, at the chunk’s own queue level for the five
mutators that belong to one chunk —
LevelLightEngine.checkBlock, LightEventListener.propagateLightSources,
LevelLightEngine.setLightEnabled, ThreadedLevelLightEngine.initializeLight
and ThreadedLevelLightEngine.lightChunk — and at a flat top priority for
bookkeeping such as LevelLightEngine.updateSectionStatus and
LevelLightEngine.queueSectionData. When the dispatcher releases the task it
runs on the light executor, not on the Server thread, and all it does there
is append to ThreadedLevelLightEngine.lightTasks — a plain array list with
no synchronisation, safe precisely because only that executor appends to it.
What actually kicks it
The light executor is a ConsecutiveExecutor named light, built in
ChunkMap’s constructor over the shared background pool. It has no thread of
its own: it takes one task from its queue, runs it on a borrowed pool thread,
and re-registers itself. Two callers ever schedule a batch on it.
ServerChunkCache.MainThreadExecutor.pollTask runs the distance-graph
updates first and calls ThreadedLevelLightEngine.tryScheduleUpdate only if
they had nothing to do — so light propagates in the Server thread’s spare
moments, after the ticket system is quiescent
(when the graphs run). Whichever
caller gets there, ThreadedLevelLightEngine.scheduled, an AtomicBoolean,
keeps exactly one batch in flight. The other caller is
ChunkMap.scheduleUnload, kicking the engine right after it has queued the
nulling of an unloading chunk’s data.
If nobody kicks, the queue is still not unbounded:
ThreadedLevelLightEngine.addTask runs a batch inline as soon as
ThreadedLevelLightEngine.lightTasks has reached a thousand, the number
ThreadedLevelLightEngine.DEFAULT_BATCH_SIZE names — still on the light
executor, still never on the Server thread.
One batch, and what it publishes
The light executor’s stretch of that trace is one batch, and a batch is defined by the two maps it sits between rather than by the flooding it does.
flowchart TD
PRE["ThreadedLevelLightEngine.runUpdate takes a window of up to 1,000 tasks"]
C["LightEngine.checkNode, on every queued position"]
D["LightEngine.propagateDecreases, to an empty queue"]
I["LightEngine.propagateIncreases, to an empty queue"]
UP[("LayerLightSectionStorage.updatingSectionData: the scratch map, cloned per section")]
S["LayerLightSectionStorage.markNewInconsistencies,<br/>then LayerLightSectionStorage.swapSectionMap"]
VIS[("LayerLightSectionStorage.visibleSectionData: volatile, what every reader sees")]
POST["the window's POST_UPDATE tasks run, and the window is dropped"]
PRE --> C
C --> D
D --> I
C -- "writes" --> UP
D -- "writes" --> UP
I -- "writes" --> UP
I --> S
UP -- "copied whole" --> S
S --> VIS
S --> POST
One batch, drawn for one of the two layers it runs in turn between the same PRE and POST tasks, and the two maps that layer is written between: the three stages down the left all write the same scratch map, and none of them writes the one another thread reads. That is why the picture has two cylinders and not one — and why a reader of the visible map gets that layer before the batch or after it, never a half-propagated flood.
The two maps are why no reader of light ever waits on the light engine.
LayerLightSectionStorage.updatingSectionData is the engine’s scratch copy
and no other thread reads it; LayerLightSectionStorage.visibleSectionData is
volatile and is what every reader sees, and what the saver and the packet
builder see wherever no newer layer is still queued.
LayerLightSectionStorage.setStoredLevel clones a section’s DataLayer
through DataLayerStorageMap.copyDataLayer the first time the batch touches
that section, recording it in LayerLightSectionStorage.changedSections, and
LayerLightSectionStorage.swapSectionMap copies the whole updating map into
a new visible map at the end. A reader of the visible map on another thread
sees each layer before the batch or after it, never a half-propagated flood.
Inside a layer the order is fixed. Every position from
LightEngine.blockNodesToCheck goes through LightEngine.checkNode, which
zeroes the stored level where the light that is there must go and enqueues the
rest as work; then LightEngine.propagateDecreases runs the decrease
queue to empty, then LightEngine.propagateIncreases runs the increase queue
to empty. Decreases always finish first because a decrease discovers brighter
neighbours and enqueues them as increase back toward me refills — running
increases first would spread light that is about to be removed. Both queues
hold pairs of longs whose second member is a LightEngine.QueueEntry: four
bits of level, six of allowed directions, and the two flags
LightEngine.QueueEntry.FLAG_FROM_EMPTY_SHAPE and
LightEngine.QueueEntry.FLAG_INCREASE_FROM_EMISSION. The torch’s own entries
are LightEngine.PULL_LIGHT_IN_ENTRY — a level-1 decrease in all six
directions, meaning re-pull from my neighbours — and an emission increase of
14, a torch, Blocks.TORCH or Blocks.WALL_TORCH, being a
BlockBehaviour.Properties.lightLevel of 14.
BlockLightEngine.propagateIncrease then spreads level minus
LightEngine.getOpacity, which is
BlockBehaviour.BlockStateBase.getLightDampening — by default derived rather
than declared: 15 for a solid render, 0 for a state that
BlockBehaviour.BlockStateBase.propagatesSkylightDown, 1 otherwise, though
leaves and tinted glass declare their own — floored
at LightEngine.MIN_OPACITY. It stops where a neighbour is already as bright
and where LightEngine.shapeOccludes. A level of 1 is still written; what
it does not do is propagate, because only a new level above 1 is enqueued
again. That is why a torch at 14 reaches thirteen blocks and no further.
The batch tells the chunk holders which sections changed only through
LightChunkGetter.onLightUpdate, fired from the map swap. The engine has no
idea that ChunkHolder exists.
The sky column is a table, not a flood
Sky light does not use the heightmaps, and it does not usually propagate
downward one block at a time. Each chunk owns a ChunkSkyLightSources
(ChunkAccess.skyLightSources), a bit-packed 256-entry table of the lowest
Y at this (x, z) that still sees the sky, filled by
ChunkSkyLightSources.fillFrom and repaired per block by
ChunkSkyLightSources.update. What ends a column is
ChunkSkyLightSources.isEdgeOccluded: the lower block dampens light at all,
or the two faces occlude each other.
SkyLightEngine.checkNode reads that table and takes one of three paths. A
block at or above the column’s lowest source enqueues a remove-source
decrease and an add-source increase — the expensive case. A block below it
that held light has that light zeroed and decreased away. A block below it
that was already dark, which is what a torch under a solid ceiling is, gets a
pull-in that changes nothing: placing a torch in a cave does no sky work
worth measuring. Before any of the three, SkyLightEngine.updateSourcesInColumn
runs SkyLightEngine.removeSourcesBelow and SkyLightEngine.addSourcesAbove
so the stored 15s agree with the table again.
Two more pieces of the sky model exist because sections may have no storage.
SkyLightEngine.propagateFromEmptySections handles light crossing a section
edge sideways at the bottom row of a section: if the source column had no
data for a run of sections below it, light had been continuing downward
implicitly, so the same level is written straight down the destination column
through that run. And SkyLightSectionStorage.createDataLayer seeds a newly
needed section below existing data by repeating the bottom slice of the
section above it (SkyLightSectionStorage.repeatFirstLayer) rather than
starting dark.
Lit before you ever see it
Generation lights a chunk in two steps, and the first usually turns light
off. ChunkStatusTasks.initializeLight builds the sky table with
ChunkAccess.initializeLightSources, then calls
ThreadedLevelLightEngine.initializeLight, whose PRE task marks every
non-air section as having data and whose POST task sets the column’s enabled
flag to whatever ChunkStatusTasks.isLighted says — false for a freshly
generated chunk, whose persisted status has not reached ChunkStatus.LIGHT.
Enabling is the next step’s doing: ChunkStatusTasks.light calls
ThreadedLevelLightEngine.lightChunk, which for an unlit chunk runs
LevelLightEngine.propagateLightSources, and both
BlockLightEngine.propagateLightSources and
SkyLightEngine.propagateLightSources open by enabling the column before
seeding it — the block engine from LightChunk.findBlockLightSources, the sky
engine from its own table and its four neighbours’. Only then does a POST
task set ChunkAccess.setLightCorrect. A chunk read from disk with
isLightOn set skips the propagation entirely, because
ChunkStatusTasks.isLighted hands ThreadedLevelLightEngine.lightChunk a
flag that says it is lit.
Two consequences are worth naming. ServerChunkCache.getChunkForLighting
hands the engine chunks at ChunkStatus.FEATURES, one status below
ChunkStatus.INITIALIZE_LIGHT — the engine reads chunks the rest of the game
is not allowed to see yet. And nothing waits for light at the send itself: a
chunk reaches ChunkStatus.FULL only after its own ChunkStatus.LIGHT task
has run on the light executor, and ChunkPyramid.GENERATION_PYRAMID gives
ChunkStatus.LIGHT a requirement of ChunkStatus.INITIALIZE_LIGHT at radius
1, so a chunk cannot climb to ChunkStatus.FULL until its neighbours have
their sections marked too (the pyramid,
drawn), and it is sent only
from ChunkMap.prepareTickingChunk, which waits for all eight neighbours at
ChunkStatus.FULL, so each of them has run its own ChunkStatus.LIGHT task
as well. The one real send
dependency, ChunkMap.waitForLightBeforeSending →
ThreadedLevelLightEngine.waitForPendingTasks → ChunkHolder.addSendDependency,
has exactly one caller: EnderDragonFight, holding back the send of the
chunks around a new exit portal until its light is in.
And what unloading throws away
Unloading is the mirror: ChunkMap.scheduleUnload calls
ThreadedLevelLightEngine.updateChunkStatus, which disables the column,
drops the retain flag and nulls every section’s data
(a chunk nobody needs any
more). On load
SerializableChunkData.read
calls LevelLightEngine.retainData once and then
LevelLightEngine.queueSectionData for each saved DataLayer.
Off the server thread and onto the wire
ServerChunkCache.onLightUpdate is the whole of the hop back. It is called
on the light executor, and its body is a task posted to
ServerChunkCache.mainThreadProcessor — no ChunkHolder is touched
off-thread. When the Server thread later runs that task it finds the holder
in the visible map and calls ChunkHolder.sectionLightChanged, which has two
gates and does its one piece of bookkeeping between them. It gives up at once
on a chunk not present at ChunkStatus.INITIALIZE_LIGHT; otherwise it marks
the chunk unsaved (light is saved data); then it gives up again if there is no
ticking chunk to broadcast for. Only past both, for a section in range whose
bit is not already set, does it set one bit in
ChunkHolder.skyChangedLightSectionFilter or
ChunkHolder.blockChangedLightSectionFilter and answer yes, and the posted
task then puts the holder in ServerChunkCache.chunkHoldersToBroadcast. So a
chunk short of ChunkStatus.INITIALIZE_LIGHT is not even marked dirty by a
light change, and one that is loaded but not ticking is
marked dirty and never broadcast.
One packet, and only to the players who cannot work it out
The packet goes out at the end of ServerChunkCache.tickChunks — once a tick
even while the tick rate is frozen, and never at all in a debug world,
whose Level.isDebug guard wraps that whole method.
ServerChunkCache.broadcastChangedChunks calls ChunkHolder.broadcastChanges,
which builds one ClientboundLightUpdatePacket per chunk from the two
filters and sends it before any block-change packet — but not to everyone
watching. It asks ChunkHolder.PlayerProvider.getPlayers with borderOnly
true, so ChunkMap.isChunkOnTrackedBorder keeps only the players for whom
this chunk has an untracked neighbour. A player in the middle of their own
view distance is never sent light for a block change: they were sent the
block, they have every neighbouring chunk, and their own engine will reach
the same numbers. The packet exists for the players who cannot, because the
flood may be arriving from a chunk they do not have. Its
ClientboundLightUpdatePacketData carries four bitsets — a data mask and an
empty mask per layer — and the 2048 bytes of each non-empty changed section,
read through LayerLightEventListener.getDataLayerData, which answers the
queued layer if there is one and the visible map otherwise, never the
updating copy. A section whose DataLayer is empty costs one bit; one with
no DataLayer at all appears in neither mask. The same
ClientboundLightUpdatePacketData rides inside
ClientboundLevelChunkWithLightPacket with both filters null — every section
— when PlayerChunkSender.sendChunk first sends a chunk.
Up to fourteen sections can be dirtied by one torch, and not because a
write marks its neighbours. LayerLightSectionStorage.setStoredLevel marks
only the
sections within one block of the position written
(SectionPos.aroundAndAtBlockPos): one for an interior block, up to eight
for a block on a corner. The 14 comes from the flood, which writes as far as
thirteen blocks away in taxicab distance. Grow that octahedron by the
one-block halo and it can reach three sections deep on every axis, but never
fills the 3×3×3 box of 27, whose outer sections need diagonals the octahedron
does not have. Fourteen sections, across as
many as seven chunk columns — and each of the fourteen is one the client will
have to re-mesh. The real 3×3×3
marking, LayerLightSectionStorage.markSectionAndNeighborsAsAffected, fires
only when a section is first given a DataLayer.
The client lights per frame
The client runs the same LevelLightEngine unwrapped: ClientChunkCache.lightEngine
is a plain one, and its LightChunkGetter.onLightUpdate goes straight to
LevelExtractor.setSectionDirty. It runs from ClientLevel.update, which
Minecraft.renderFrame calls once a frame, not once a tick, so light
converges at your framerate. ClientPacketListener.handleLightUpdatePacket
applies nothing; it pushes a closure onto ClientLevel.lightUpdateQueue, and
ClientLevel.pollLightUpdates runs a tenth of them per frame, at least ten,
or, once a thousand are queued, all of them (what it
does simulate: the two
cadences
owns that budget). Each closure is ClientPacketListener.applyLightData:
ClientPacketListener.readSectionList turns every masked section into a
cloned or empty DataLayer through LevelLightEngine.queueSectionData and
dirties it with its neighbours, and then the chunk’s column is enabled with
LevelLightEngine.setLightEnabled. Only after the polled closures does
ClientLevel.update call LevelLightEngine.runLightUpdates, splicing the
queued layers in and swapping the map exactly as the server does.
Enabling a column is a separate thing from lighting it, and on the client that
distinction reaches all the way to the geometry: a section is not meshed at all
until LevelLightEngine.lightOnInColumn is true for each of its eight
surrounding columns, so a light flag decides whether a section may have a
mesh (the sweep that only looks at what you can
see).
Meanwhile the client had already lit this torch itself:
MultiPlayerGameMode.startPrediction runs the placement locally through the
same LevelChunk.setBlockState, and for the player who placed it, in the
middle of their own view, no light packet follows at all: their own engine’s
numbers are the ones they see.
Questions players ask
Why does the server’s light reach some players a tick after the torch
lands? Only a player at the edge of the view is sent light at all, and for
them the write only queues a task, the queue waits for
the Server thread’s spare moments, the callback is posted back to the Server
thread, and the packet is built at the end of ServerChunkCache.tickChunks —
four hand-offs, only the last of them on a schedule. Every watching client
lights the torch itself when the block arrives; the packet brings what flooded
in from chunks that player does not hold.
Why do the F3 light numbers not match what I see? They are three
numbers, and none of them is the one you are looking at. DebugEntryLight
prints a combined figure and then the two raw layers, and the combined figure
is LevelLightEngine.getRawBrightness called with the time-of-day darkening
argument set to zero — so even it is a noon answer. What renders is the
two layers looked up in the Lightmap texture, which is where the hour, the
dimension and the mob effects arrive (the lightmap).
For a 1.21-era reader. getLightBlock is now
BlockBehaviour.BlockStateBase.getLightDampening, and LightTexture isLightmap(Part XI).DynamicGraphMinFixedPoint,LeveledPriorityQueueandSpatialLongSetstill live inworld/level/lighting, but no light engine uses them any more:DynamicGraphMinFixedPointsurvives forChunkTracker(tickets) andSectionTracker(points of interest) and takesLeveledPriorityQueuewith it, andSpatialLongSethas no callers at all.
Where to look
Follow the torch: LevelChunk.setBlockState ·
LightEngine.hasDifferentLightProperties ·
ThreadedLevelLightEngine.tryScheduleUpdate ·
ThreadedLevelLightEngine.runUpdate · BlockLightEngine.checkNode ·
BlockLightEngine.propagateIncrease ·
LayerLightSectionStorage.swapSectionMap. The sky’s own machinery is a
separate read: ChunkSkyLightSources.update · SkyLightEngine.checkNode ·
SkyLightSectionStorage.getLightValue. Then the way out:
ServerChunkCache.onLightUpdate · ChunkHolder.broadcastChanges ·
ClientPacketListener.applyLightData · ClientLevel.pollLightUpdates. Where
generation turns light on and computes it:
ThreadedLevelLightEngine.initializeLight ·
ThreadedLevelLightEngine.lightChunk.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.