Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

classwhat it decidesthread
LevelLightEnginethe facade both sides hold — one LightEngine per layer, the read side, and the padding above and below the worldwhoever calls it
ThreadedLevelLightEnginethe server’s wrapper: every mutator becomes a queued task, and running updates on demand is an errorqueued from Server, drained on the light executor
BlockLightEngine · SkyLightEnginethe algorithm — what a changed block means, and how far the change travelsthe light executor (Client: the Render thread)
LayerLightSectionStoragethe double buffer: which sections hold data, which changed, and when to publishthe same
DataLayer2048 bytes of nibbles for one section, or no bytes at allwhoever holds it
ChunkSkyLightSourcesthe chunk’s 256-entry lowest sky source Y table — the one piece of light work that is not deferredServer, inside the block write
ServerChunkCachethe LightChunkGetter: which chunks the engine may read, and where the one callback landscallback on the light executor, body on Server
ChunkHolderwhich sections each watching player is owed, as two BitSetsServer

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 is Lightmap (Part XI). DynamicGraphMinFixedPoint, LeveledPriorityQueue and SpatialLongSet still live in world/level/lighting, but no light engine uses them any more: DynamicGraphMinFixedPoint survives for ChunkTracker (tickets) and SectionTracker (points of interest) and takes LeveledPriorityQueue with it, and SpatialLongSet has 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.