Skip to content

feat: client-side 1.17.1 block and sky light in the wasm mesher - #96

Open
sandexzx wants to merge 25 commits into
mainfrom
feat/client-lighting
Open

sandexzx wants to merge 25 commits into
mainfrom
feat/client-lighting

Conversation

@sandexzx

@sandexzx sandexzx commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Client-side 1.17.1 block and sky light in the wasm mesher. Section meshes carry versions so a block edit is not drawn from stale server light or from a neighbor column the mesh did not sample.

Context

Inspired by minecraft-web-client#304 (client-side light engine).

This branch also contains minecraft-renderer#93 (feat/entity-block-lighting), including the opt-in defaults fix (465cf22). #93 stays open as an independently mergeable entity-lighting PR.

In my view this still works roughly and needs review from @zardoy.

How to test in the web client

Use the client integration in minecraft-web-client#599 with a renderer build containing this PR (including lightOwnerWorker.js).

  1. Add ?clientLight=1 to the client URL, or &clientLight=1 if it already has query parameters, and reload.
  2. Manually enable Lighting in Newer Versions in the client settings (newVersionsLighting).
  3. Join a Minecraft 1.17.1 world/server.
  4. Place/remove a torch; build an opaque roof, break a block in it, then close the opening and check that light updates.

Both switches are required to see the recomputed lighting. The URL flag starts the experimental light owner; the setting enables lighting display. Both remain off by default. clientLightTrace=1 is optional diagnostics, not required for testing.

What turns on

enableClientLightOwner stays default false. ?clientLight=1 sets that flag. Spawn also requires the session version to be 1.17.1 (shouldSpawnClientLightOwner). Any other version leaves the flag set, does not start lightOwnerWorker, and does not load the 1.17.1 state-id tables. clientLightTrace=1 is a separate, default-off trace.

With the flag off, or when the version gate refuses spawn, the mesh displays server update_light. With the flag on and version 1.17.1, ClientLightOwner and lightOwnerWorker start, separate from mesh workers. The session stays starting until the worker reports ready, and goes to failed on worker error.

Guarantees

  • Emission, lightBlock, and 2×2×2 occupancy come from 1.17.1 Blocks.java lightLevel registrations. LightEngine::opacity_of is vanilla getOpacity: max(1, getLightBlock()). Levels are 0–15. shape_occludes follows 1.17.1 LayerLightEngine.
  • Sky is its own queue. An open column sources 15 downward. Unknown chunks are not sky 15. Omitted sky sections are absent. An empty bit zeros that section. LIGHT_ONLY is an external boundary and is not rewritten by BFS.
  • Block-light spread of an edit stops at the 3×3 column neighborhood (promote_edit_column). Admission is one column at a time. Missing neighbors are not invented as air. A BlockChange for an unknown column does not jump the queue.
  • shouldAcceptVersionedMesh is per section. Fast A→B→C keeps C. A late reply from the previous column incarnation is dropped when both sides carry columnIncarnation. Fresh topology with stale light is installed for the current frame; covering stays outstanding and is not dispatched again while the pending remesh already covers the requirement. Stale topology with fresh light is rejected. hadErrors is never an empty success.
  • INITIAL_TOPOLOGY_REVISION is 0. nextTopologyRevision starts at 1, so the first edit misses the initial topology cache.
  • provenMeshLightVersion stamps a section from the latest applied version of the 3×3 columns (±16 in x and z) that the mesh actually used. The version on the dirty job is not an input.
  • On owner takeover the display cache is cloned once from incoming server light. Later owner deltas write display in place. Revert copies display back to the last incoming packet and does not zero omitted sections. Packed channels are 2048 bytes. A foreign publication with the wrong length is not written.
  • selectReadySectionFlushes installs a face-adjacent group together. The group flushes when every visible neighbor has arrived, or when the oldest member hits 500 ms. With the owner live, the wait is topology-outstanding. Displayed GPU slots stay separate from unuploaded candidates.
  • A player edit sets urgent. createMeshTickScheduler.kick runs on the next turn. Urgent columns are taken before the bulk cap of 4 (BULK_COLUMNS_PER_TICK). setSectionDirty dispatches immediately when nextDirtyUrgent or a light publication version is set, and does not enter the 100 ms trailing window. The edit stays in interactiveSections until geometry is committed at a publication newer than lightVersionAtEdit. A rejected sectionFinished decrements sectionsWaiting. legacyBootstrap still has to match epoch and incarnation when a required version exists.
  • Entities sample packed column block and sky light on the renderer thread.

Limitations

  • Tables and the worker start are 1.17.1 only.
  • newVersionsLighting (default false in renderer options) only feeds resolveEnableLighting. It does not spawn the owner.
  • With the owner off, singleplayer torch spread is whatever the server sends in update_light. With the owner on, a block edit is BlockChange (setBlockStateIdInner → onBlockChange). Emission for that edit comes from the 1.17.1 tables and does not wait for update_light.
  • The web client needs minecraft-web-client#599 to copy lightOwnerWorker.js from MESHER_DIST_FILES; the old handwritten artifact list omits it.
  • src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm on this branch is the LightEngine build, including commit 879d982.

Mesh version contract vs checks

An owner mesh is expected to carry sessionEpoch, columnIncarnation, worldGeneration, topologyRevision, and lightPublicationVersion. The section requirement carries requiredVersion, worldGeneration, and optionally topologyRevision.

shouldAcceptVersionedMesh does not require every field to be present:

  • hadErrors rejects.
  • An owner-managed mesh with worldGeneration, lightPublicationVersion, topologyRevision, and sessionEpoch all absent is rejected, except legacyBootstrap when there is no requirement.
  • legacyBootstrap with a requirement rejects a missing lightPublicationVersion.
  • Epoch, incarnation, worldGeneration, topologyRevision, and light version are compared only when that field is present on the mesh (topology also needs required.topologyRevision).
  • No requirement accepts. With the owner off, an unversioned mesh accepts.

Checks

Latest integration/defaults validation:

  • pnpm exec vitest run --maxWorkers=2: 638 tests passed.
  • pnpm typecheck: passed in the renderer.
  • cargo test: 101 tests passed.
  • Web-client unit tests: 124 passed; clean production build with the local renderer passed.
  • Headless Chrome loaded the copied lightOwnerWorker.js and WASM and received ready.
  • Web-client typecheck is not clean with the local renderer: renderer-source global declarations and dependency/type compatibility errors remain. This is not a claim of clean client CI.

pnpm unit-test (vitest) covers accept/reject rules, owner spawn including the 1.17.1 gate, publication skipping the 100 ms window, packed-light length, face-adjacent flush, display clone and revert, sampled-column proof, urgent kick ahead of the 50 ms poll, topology and tick parse caches, and the default-off trace.

cargo test in wasm-mesher covers LightEngine: torch place and remove, stone roof sky, slab occlusion, a 1.17.1 update_light fixture, and edit-column promotion ahead of bulk without materializing an unknown section as air.

Entities now sample packed column light on the renderer thread and multiply
material color, matching block lighting. Light-only 1.18+ reloads drop the
fused raw map_chunk so blocks remesh from the updated column. Authored
material retints (leather armor) keep their new base; distant column ingest
no longer retints every entity.

New Versions Lighting defaults to on. A leaked internal migration flag is
stripped from saved options so it is not a user setting. Torch spread in
singleplayer still needs Flying Squid to emit update_light.
…sky as 15

Omitted sections stay absent in the authoritative cache; sky=15 is display fill only.
LIGHT_ONLY sections with known light are external solver boundaries, not
cells the BFS may rewrite. The invariant is L(p)=max(E(p), neighbor decay);
stale queue entries must not resurrect light. enableClientLightOwner stays off.
minecraft-data only maps names to stateIds. Emission comes from the
1.17.1 lightLevel registrations so lit furnaces, lamps, and candles
are no longer zero. enableClientLightOwner stays off.
Sky is an independent channel with vanilla-like sources (15 down an open column, skip-up on source entries). Seed is a candidate, LIGHT_ONLY 15 is a boundary, and unknown chunks are not treated as sky 15.
… (default off)

Spawn the dedicated owner worker from WorldRendererCommon only when a human flips the flag for live 1.17.1 runs; default stays false so players keep the server-light path.
Sky sources stop on true light-blocking (water/leaves), while neighbor
decay still uses max(1, lightBlock). Slabs and stairs get 2x2x2 occupancy
so complementary faces occlude without treating a bottom slab as stone.
default_test_tables referenced test-only constants without the same gate.
step(5) now budgets event drain and deduped sky columns so ingest cannot run unbounded. The owner session is starting until ready, and load/worker errors go to failed. Keep ?clientLight=1 as the manual switch; default flag stays off.
…sion

Unrelated publications no longer drop a valid remesh; trailing dirty keeps the target revision, and owner-on display light updates only from completed publications.
Opening a path, ingest next to a LIGHT_ONLY boundary, and unload of a supporting column now re-reconcile contacts so residual or missing block light cannot linger.
BFS was rewalking every column height on each source check (~151M Y-steps for 9 air columns). Cache the derived lowest-source Y and invalidate it on block, ingest, availability, sky boundary, unload, and sky-enabled changes.
A view-distance dump was one transaction that published only after every pending column finished. Admit the FIFO prefix of one column so completed lighting can publish while later columns stay queued.
…teId

Main-thread ingest was doing 4096 getBlockStateId calls per section. Read prismarine section palette/data (and skip empty/uniform sections) so load no longer walks every cell on the owner path.
…in-worker

Versioned owner publications were queued behind the 100ms dirty window after a block edit, and remaining solver slices bounced through main setTimeout.
A remesh of an already-visible section is buffered in pendingSectionUpdates
and was released per key: each section waited out its own 500ms deadline and
then went to the GPU alone. On a dig the owner stencil remeshes 27 sections
whose geometries come back on different worker ticks, so their deadlines
expire in different frames — a section was installed while its face neighbour
still held geometry culled against the old block state, and the faces between
them were missing on both sides for a frame (the sky flash).

Group the buffered sections by face adjacency and release a whole group at
once, measuring the deadline from its oldest member. A complete group (every
outstanding neighbour arrived) flushes without waiting for any deadline, so
this is never slower than the per-key policy.
Splits one dig into the terms processColumnTick actually pays: per-column
WASM mesh over the full Y range vs the stencil Y window, redundant neighbour
parses, typed-array copies of the two-step path, and the per-section
world.getBlock walk of the post phase.
…in light delivery trace

Compaction was selecting unuploaded replacement candidates, so remeshes dropped visible faces. Separate displayed from candidate and record a default-off causal trace so the next latency work has a real path.
…play path

Stale unversioned remeshes and an aliased incoming/display cache could roll back topology or overwrite the last genuine server light. Cover by section+revision and detach incoming once on owner takeover.
…ampled columns

An edit column and its 3x3 jump the light queue without inventing missing neighbors. Urgent remeshes skip the 100ms throttle and the 50ms poll, and a mesh light version comes only from columns that were actually meshed.
The state-id tables are the 1.17.1 registry, so another session version keeps server light. Includes prettier formatting.
Worker and IndexedBlock casts no longer overlap their targets under the CI typecheck.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant