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

Lightmap, fog and sky

Verified against Minecraft 26.2 · Part XI · the sun goes down: every colour on screen, traced back to one keyframe curve.

Stand on a hill and watch the light go. The sky over the taiga slides from blue towards black, the murk closes in until the far trees dissolve, stars come up, the moon takes whatever shape it is owed tonight, and if a storm arrives the scene goes grey and streaked. Five renderers make those colours — Lightmap decides how bright, FogRenderer how far, SkyRenderer and CloudRenderer what is up there, WeatherEffectRenderer what is coming down — and between them they ask one question and nothing else: what is this attribute worth, here, now? The surprise is who they ask. Most of them no longer know what time it is. They ask a probe for a named value at a position and a partial tick, and the day/night curve behind it is keyframes in a data pack. Two still read the raw world clock — the clouds, because they drift, and the rain, whose texture scrolls — but the weather asks nobody for a colour: it seeds each column of rain from that column’s own coordinates, and touches no attribute and no probe at all.

The cast

classwhat it decidesthread
EnvironmentAttributeProbewhat any attribute is worth at the camera, this frameRender thread
LightmapRenderStateExtractorthe lightmap’s ten uniforms, and whether to redraw at allRender thread
Lightmaphow bright, as the 16×16 texture every terrain vertex samplesRender thread
FogRendererhow far you can see, in what colour, and in which mediumRender thread
SkyRendererwhat hangs above the horizon — and which of two skies it isRender thread
CloudRendererthe cloud cells, and the face list built from themCloudRenderer.prepare bakes on a worker, the rest on Render
WeatherEffectRendererwhich columns get rain, which get snow, and how hardRender thread
LevelRendererwhich of those become frame-graph passes at allRender thread

What a renderer has to know about an attribute, and no more

An environment attribute is a named, typed quantity — a colour, a distance, an angle, a moon phase — that the world answers for a position and an instant. EnvironmentAttributeSystem assembles that answer by running a short stack of layers over the attribute’s own default: the dimension, the biome, one layer per timeline the dimension runs, and weather where a dimension can have it. That machinery belongs to environment attributes and timelines; this page assumes it and names only what it consumes. Three consequences shape everything below.

The client resolves the same stack from the same data — it is never sent a resolved colour — and what it adds is EnvironmentAttributeProbe, on the camera. EnvironmentAttributeProbe.tick re-samples the biome neighbourhood once per client tick and rolls each probed value’s new answer down into last tick’s; EnvironmentAttributeProbe.getValue fetches the fresh one lazily, during the frame, and interpolates between the two by partial tick; and any attribute nobody asked for during a tick is evicted. Every renderer here that asks for an attribute at all goes through the probe and never through the system — and one of the five asks for none.

Whether a value smooths or steps is declared on the attribute, not chosen by the renderer — which is why the sky colour slides and EnvironmentAttributes.MOON_PHASE snaps, and why a renderer that wants a different curve must ask for a different attribute.

ClientLevel adds two layers of its own on top of the four, and both are the lightning flash: one lerps EnvironmentAttributes.SKY_COLOR a fifth of the way towards a pale blue-white, the other pins EnvironmentAttributes.SKY_LIGHT_FACTOR to one while the flash lasts. Neither has anything to do with the End’s sky flash, which never enters the stack at all.

What the dimension type and the biome still carry

Some of the old per-dimension and per-biome data survived the migration unchanged. DimensionType.skybox is a three-valued DimensionType.SkyboxDimensionType.Skybox.NONE, DimensionType.Skybox.OVERWORLD, DimensionType.Skybox.END — and it is a branch, not a colour. BiomeSpecialEffects still exists, hollowed out to BiomeSpecialEffects.waterColor, BiomeSpecialEffects.grassColorOverride, BiomeSpecialEffects.grassColorModifier and the foliage colours: every fog and sky colour left it for Biome.getAttributes. None of it crosses the network as pixels — the inputs arrive as registry sync during configuration, attribute maps filtered through EnvironmentAttributeMap.NETWORK_CODEC, then world time and weather during play (protocol phases) — so a data pack retints a dimension without touching the client.

The five askers

rendererwhat it asks forwhen it askswhat it produces
Lightmap, through LightmapRenderStateExtractorhow bright block light and sky light should read, and in what tintonce per tick, at a partial tick of exactly oneten std140 uniforms and one 16×16 texture
FogRendererthe colour of the murk and the six distances it lives betweenonce per frame, inside the camera extractone FogData, uploaded as one UBO slice
SkyRendererwhere the sun, moon and stars are, and how brightonce per framea SkyRenderState for the sky pass
CloudRendererwhat colour the clouds are and how high they sitonce per frame, read for it by LevelExtractora compressed face list, rebaked only when it must be
WeatherEffectRenderernothing, until it is rainingonce per frame, and only thena list of WeatherEffectRenderer.ColumnInstance

The trace: the sun goes down

sequenceDiagram
    participant Time as Timelines
    participant EAS as EnvironmentAttributeSystem
    participant EAP as EnvironmentAttributeProbe
    participant LRSE as LightmapRenderStateExtractor
    participant FR as FogRenderer
    participant SR as SkyRenderer
    participant LR as LevelRenderer
    participant LM as Lightmap

    Note over Time,EAS: per client tick
    Time->>EAS: the keyframe tracks for this world time — SUN_ANGLE, SKY_COLOR, SKY_LIGHT_FACTOR
    EAP->>EAP: tick — Gaussian biome blend, last becomes new, unread attributes evicted, driven from Camera.tick
    LRSE->>LRSE: tick — flicker walk, then needsUpdate is raised
    EAS->>EAS: invalidateTickCache, the last statement of ClientLevel.tick — marks the non-positional values stale, recomputing none of them

    Note over EAP,SR: per frame, extract
    LRSE->>EAP: getValue(SKY_LIGHT_FACTOR, BLOCK_LIGHT_TINT, AMBIENT_LIGHT_COLOR)
    LRSE-->>LM: LightmapRenderState — ten std140 values, plus the flag
    FR->>EAP: getValue(FOG_COLOR, SUNRISE_SUNSET_COLOR, SKY_FOG_END_DISTANCE)
    FR-->>LR: FogData — one colour and six distances, in one UBO
    SR->>EAP: getValue(SUN_ANGLE, MOON_ANGLE, STAR_BRIGHTNESS, MOON_PHASE)
    SR-->>LR: SkyRenderState

    Note over LM,LR: per frame, render
    LM->>LM: render — one three-vertex draw into a 16×16 texture
    LR->>LR: addSkyPass — disc, sunrise fan, sun, moon, stars, dark disc
    LR->>LR: addMainPass — terrain samples the lightmap
    LR->>LR: addCloudsPass, then addWeatherPass

The middle band’s order is a dependency order. GameRenderer.extract runs LightmapRenderStateExtractor.extract, then GameRenderer.extractCamera — where FogRenderer.setupFog stashes its FogData on CameraRenderState.fogData — then LevelExtractor.extract, which drives WeatherEffectRenderer.extractRenderState and SkyRenderer.extractRenderState. Then GameRenderer.renderLevel uploads the fog with FogRenderer.updateBuffer, takes one slice with FogRenderer.getBuffer, and LevelRenderer.render declares the passes.

SkyRenderer.renderSunriseAndSunset is the clearest instance of the pattern. The sunrise fan’s colour is EnvironmentAttributes.SUNRISE_SUNSET_COLOR, an ARGB keyframe track, and its visibility is that colour’s own alpha channel — which the renderer also scales the fan’s depth by. The fade is a property of the data, not of the geometry, so a data pack restyles the sunset without a line of client code changing.

How bright: one draw per tick, and no partial ticks at all

Lightmap is a 16×16 GpuTexture plus a MappableRingBuffer of uniforms. Lightmap.render writes those uniforms and issues one three-vertex draw with RenderPipelines.LIGHTMAP: the brightness curve lives in the shader and the whole texture is a by-product of it. In 1.21 this was a NativeImage filled pixel by pixel in Java and re-uploaded every frame.

What it draws from is LightmapRenderState: ten values in std140 order — six floats from LightmapRenderState.skyFactor and LightmapRenderState.blockFactor to LightmapRenderState.brightness, then four colours, LightmapRenderState.blockLightTint and LightmapRenderState.skyLightColor from EnvironmentAttributes.BLOCK_LIGHT_TINT and EnvironmentAttributes.SKY_LIGHT_COLOR, the other two from EnvironmentAttributes.AMBIENT_LIGHT_COLOR and EnvironmentAttributes.NIGHT_VISION_COLOR — and an eleventh field, LightmapRenderState.needsUpdate, which is not a uniform at all but the flag that decides whether the draw happens. LightmapRenderStateExtractor.tick runs the torch-flicker random walk in LightmapRenderStateExtractor.blockLightFlicker and raises its own copy of the flag; LightmapRenderStateExtractor.extract copies it across, clears it, and reads the probe alongside Options.gamma, Options.darknessEffectScale, the conduit-power water vision and LightmapRenderStateExtractor.calculateDarknessScale. So the lightmap is recomputed once per tick and not once per frame — and, deliberately, GameRenderer.extract hands the extractor a partial tick of exactly one while FogRenderer.setupFog and SkyRenderer.extractRenderState get the real one. Sky and fog interpolate mid-tick. World lighting steps.

Three leftovers. Lightmap.getBrightness survives but no longer feeds the lightmap: it is a CPU-side duplicate of the shader’s curve, kept for Hud, EntityRenderer’s shadow sampling and ScreenEffectRenderer alone. The packing statics moved out of the texture into LightCoordsUtil, from where a packed value reaches a vertex through VertexConsumer.setLight. And UiLightmap is the 1×1 white DynamicTexture handed out while GameRenderer.useUiLightmap is set.

Two curves that look like one

EnvironmentAttributes.SKY_LIGHT_FACTOR is a visual attribute, spatially interpolated, and the lightmap reads it; EnvironmentAttributes.SKY_LIGHT_LEVEL is a gameplay attribute, not positional, and Level.updateSkyBrightness turns it into Level.skyDarken for mob spawning. Timelines.OVERWORLD_DAY keyframes both, at slightly different times and to different night values — so they look like one number, and a data pack can pull them apart.

How far: one block for the whole frame, filled by a priority walk

FogRenderer’s output is a mutable FogData: FogData.color plus six distances — a start and an end each for the medium and the horizon, then FogData.skyEnd and FogData.cloudEnd. In open air those are EnvironmentAttributes.FOG_COLOR, EnvironmentAttributes.FOG_START_DISTANCE, EnvironmentAttributes.FOG_END_DISTANCE, EnvironmentAttributes.SKY_FOG_END_DISTANCE and EnvironmentAttributes.CLOUD_FOG_END_DISTANCE, and underwater they are EnvironmentAttributes.WATER_FOG_COLOR, EnvironmentAttributes.WATER_FOG_START_DISTANCE and EnvironmentAttributes.WATER_FOG_END_DISTANCE instead. It owns one ring buffer, FogRenderer.regularBuffer, and beside it a second buffer of its own, FogRenderer.emptyBuffer, filled with infinitely far for when fog is off.

There is one fog UBO for the whole frame, not one per pass. LevelRenderer.render takes a single slice and hands the same one to the sky, main, weather and always-on-top passes, and does not hand it to the clouds pass — which reads a cloud fog end out of the same buffer anyway, because the binding is sticky and the shader simply keeps reading what was last bound. The sky and cloud fog ends are separate fields inside that one block, which the shaders choose between, so what a player sees as per-element fog is a shader decision and not a binding.

The list that decides the colour

The colour and the darkening come from different places. FogRenderer.FOG_ENVIRONMENTS is an ordered list and the order is the priority: LavaFogEnvironment, PowderedSnowFogEnvironment, BlindnessFogEnvironment, DarknessFogEnvironment, WaterFogEnvironment, and AtmosphericFogEnvironment last, which is what makes it the guaranteed fallback. FogRenderer.computeFogColor makes one pass down that list carrying two independent latches — it takes the colour from the first environment whose FogEnvironment.providesColor is true and the darkness from the first whose FogEnvironment.modifiesDarkness is, which need not be the same one — whereas FogEnvironment.setupFog stops at the first applicable one, and it and FogEnvironment.isApplicable are the class’s only abstract methods. MobEffectFogEnvironment declares FogEnvironment.providesColor false on purpose: blindness and darkness may darken somebody else’s colour, never supply one, and the atmospheric environment sits last precisely so somebody always does. Which medium the camera is in is a FogType (FogType.WATER, FogType.LAVA, FogType.POWDER_SNOW, FogType.ATMOSPHERIC, FogType.NONE), and NONE maps to the atmospheric environment. Rain fog is the only stateful one: AtmosphericFogEnvironment.rainFogMultiplier is an exponential follower, so the murk lags a storm starting rather than snapping to it, and AtmosphericFogEnvironment.updateRainFogState thickens it even in a biome with no precipitation, at half strength.

What is up there: two skies, and a texture that is never bound

SkyRenderer builds every buffer it will ever need in its constructor, from SkyRenderer.buildStars to SkyRenderer.buildMoonPhases against the AtlasIds.CELESTIALS atlas, and per frame fills a SkyRenderState running from SkyRenderState.skybox through EnvironmentAttributes.SUN_ANGLE, EnvironmentAttributes.MOON_ANGLE, EnvironmentAttributes.STAR_ANGLE and EnvironmentAttributes.STAR_BRIGHTNESS to SkyRenderState.endFlashIntensity.

The stars are the same in every world. SkyRenderer.buildStars seeds a fixed constant and rejects samples outside a shell, so SkyRenderer.STAR_COUNT is an attempt count rather than a star count, and they are rebuilt only when a resource reload takes the whole renderer down: LevelExtractor.onResourceManagerReload sets LevelExtractor.shouldResetSkyRenderer and LevelRenderer.addSkyPass closes and reconstructs the entire SkyRenderer, stars, moon phases and all. The moon phase, likewise, is no longer arithmetic on the day count — it is EnvironmentAttributes.MOON_PHASE driven by Timelines.MOON, whose period is MoonPhase.COUNT days, and the renderer picks a sub-quad of an eight-quad buffer by MoonPhase.index.

The End takes a different branch entirely. With DimensionType.Skybox.END, SkyRenderer.extractRenderState fills only the End-flash fields: the sun angle, the moon phase, the sky colour and the dark disc are never sampled. And EndFlashState is not the dragon fight — it is a free-running flash on a six-hundred-tick cycle, seeded per interval for its offset, duration and angles, advanced by EndFlashState.tick in any dimension whose skybox is the End’s. The sky is also skipped five ways, four of them in one method: LevelRenderer.addSkyPass bails in lava, in powder snow, when CameraRenderState reports that a mob effect blocks the sky — which is blindness and darkness folded into one boolean before the method is entered — and when DimensionType.Skybox is NONE, which is the Nether. The fifth is outside it: GameRenderer.renderLevel suppresses the sky when a boss bar wants world fog, with AtmosphericFogEnvironment.setupFog clamping the fog hard in that case.

The clouds, which get no fog and no texture

The clouds are the first of the two exceptions: their colour and height are EnvironmentAttributes.CLOUD_COLOR and EnvironmentAttributes.CLOUD_HEIGHT, but their drift is raw world time. And the cloud texture is never bound as a texture. CloudRenderer.prepare does the whole job on a worker — reading the image and baking it into CloudRenderer.TextureData through CloudRenderer.packCellData, one 64-bit word per pixel with the colour in the high bits and four neighbour-emptiness flags in the low four — and CloudRenderer.apply is two statements on the client thread that install the result and raise the rebuild flag. CloudRenderer.buildMesh walks cells of CloudRenderer.CELL_SIZE_IN_BLOCKS, writing three bytes per face through CloudRenderer.encodeFace — a compressed face list, expanded to quads in the shader, with CloudRenderer.RelativeCameraPos and CloudStatus deciding which faces exist. It is rebuilt on a reload, when the camera crosses a cell boundary or changes side, or when the CloudStatus changes — and a data pack setting the cloud colour to zero alpha removes the pass entirely.

What is coming down: rebuilt every frame, and seeded from the clock

WeatherEffectRenderer is the second exception: its columns are placed by world position, but the streaks are seeded from raw world time. It holds one WeatherEffectRenderer.vertexBuffer and the precomputed tangent tables WeatherEffectRenderer.columnSizeX and WeatherEffectRenderer.columnSizeZ, and its per-frame product is a list of WeatherEffectRenderer.ColumnInstance records inside a WeatherRenderState. WeatherEffectRenderer.extractRenderState returns immediately when the rain level is zero, so a clear sky costs nothing. Otherwise it loops every column in a square of radius Options.weatherRadius, querying the heightmap and the precipitation at each — every frame, on the CPU. The vertex buffer is rebuilt in WeatherEffectRenderer.render rather than in extract, rain and snow are two indexed draws sharing it, and the world border rides in the same pass. Particles and sound are somebody else’s job: ClientLevel.tickWeatherEffects spawns those per tick within the same radius, next to ClientLevel.animateTick, which scatters EnvironmentAttributes.AMBIENT_PARTICLES.

What is not an attribute

The migration was not total, which is why everything is an attribute now needs a qualifier. DimensionType.ambientLight and DimensionType.cardinalLightType are plain record fields, read directly — and the first of the two no longer reaches the lightmap at all: its two readers are Lightmap.getBrightness, the CPU-side duplicate this page has already said the shader does not use, and one deprecated method on LevelReader. Block tint never moved at all: grass, foliage and water are still BiomeColors reading BiomeSpecialEffects through the four ColorResolvers, with no probe and no layer stack in it. And the clouds still read the world clock, because a value sampled at the camera and lerped by partial tick is the wrong shape for a drift — while the weather reads neither clock nor attribute, seeding each column from its own coordinates.

For a 1.21-era reader. Nearly every per-dimension, per-biome, per-time-of-day visual constant is an environment attribute now, so the names to stop hunting for are: LightTexture (now Lightmap plus LightCoordsUtil), DimensionSpecialEffects and all three subclasses (now DimensionType.skybox plus attributes), FogParameters (now FogData), RenderSystem.setShaderFogColor and its siblings (now one RenderSystem.setShaderFog taking a uniform slice), LevelRenderer.renderSky / renderClouds / renderSnowAndRain (now the LevelRenderer.addSkyPass family of frame-graph passes, declared as visibility and the frame graph describes), and Level.getSkyColor, ClientLevel.getStarBrightness and ClientLevel.effects, all attributes now. The draws went the way of everything in blaze3d, from RenderPipelines.LIGHTMAP and RenderPipelines.SKY to RenderPipelines.WEATHER_DEPTH_WRITE.

Where to look

LightmapRenderStateExtractor.extract first, then Lightmap.render for what it feeds. EnvironmentAttributeProbe.getValue for the question every renderer here asks, and environment attributes and timelines for how it is answered. FogRenderer.computeFogColor for the priority walk. SkyRenderer.extractRenderState and LevelRenderer.addSkyPass for the sky and its two branches, CloudRenderer.buildMesh and WeatherEffectRenderer.extractRenderState for the meshes rebuilt inside the frame, and BiomeColors for the colour system that did not move.


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.