Skip to content

feature/light propagation#916

Open
izekblz wants to merge 14 commits into
ryanhcode:mainfrom
izekblz:main
Open

feature/light propagation#916
izekblz wants to merge 14 commits into
ryanhcode:mainfrom
izekblz:main

Conversation

@izekblz
Copy link
Copy Markdown

@izekblz izekblz commented May 16, 2026

Intro

Another attempt of mine to add light propagation to\from sublevels, now fully done on server side, so injected light also has gameplay effects instead of being client side visual

There are a lot of architectural changes since the last PR, also did a pass over that version to comply with SLA

Current version fully working and can be tested if you wish to check performance, however I fully understand that such changes shouldn't be just merged into main. My goal is to propose the architecture and share my draft for your consideration. Edge cases i haven't fixed will be listed in the end

I have been hosting a server with this updated version for a week and so far hasn't recieved any complaints from players and didn't notice much issues myself. Performance difference between main Sable and this fork is almost negligible, if Spark is to be trusted

Architecture

There are two light injectors (world->plot and reverse) and one opacity mixin

ServerSubLevelLightInjector, world->plot

Runs from ServerLevelPlot.tick() per sub-level. Injects world emitters and emitters from other sublevels (that are injected to world).

tickPlot detects movement and does either:

  • fullRescan -- mostly used on the first tick to get initial state, scans full light box (16 blocks in each direction)
  • incrementalScan -- shift the cached emitter set and scan only the leading slice(s), done on translation
  • boundsDiffScan -- scan only regions added by rotation

scanOtherSubLevelEmitters finds emitters on overlapping sub-levels and projects them in

scanWorldOpaqueIntoPlot projects world and other sub-levels opaque blocks into plot space, stored in plotLocalWorldOpaque

reinject clears old positions in the plot's LevelLightEngine, writes the new ones via setStoredLevel + enqueueIncrease, and runs propagation

ServerSubLevelWorldInjector, plot → world

Runs from ServerSubLevelContainer.tick() once per server tick, after every sub-level's tickPlot has run. Has five phases:

  1. detectMovement -- edge crossing on bounding box corners flags the sub-level for update
  2. rebuildPlotLocalCache (only on block change) reads the plot once and stores a PlotLocalLightData record: arrays of plot positions for emitters, opaque blocks and shape-occluding blocks (slabs/stairs). projectToWorldSpace then transforms those cached positions into world space using the current pose
  3. The merged opaquePositions, opaquePositionsCore (no gap-fills) and shapeOcclusionMap are rebuilt from per sub-level slices, world emitters near changed sub-levels are then re-propagated on the light thread (clear + re-emit), so they respect the new opacity geometry
  4. Sub-levels whose emitters overlap the changed bounds get marked for reinjection
  5. Each changed sub-level's emitters are pushed into the world LevelLightEngine via taskMailbox

Caches

Main optimization since last version i submitted -- sub-levels now cache a lot of stuff to reduce full scans

Cache Description Rebuild time
PlotLocalLightData per sub-level Plot emitter / opaque / shape arrays Block change in plot
cachedPlotSources per sub-level Last world-space emitter projection Sub-level moves or rotates
perSubLevelOpaque, perSubLevelOpaqueCore, perSubLevelShapes Per-sub-level opaque & shape projections; core excludes diagonal-leak gap-fills so sub-level doesn't shadow itself Sub-level moves or rotates
opaquePositions / opaquePositionsCore / shapeOcclusionMap Same but global merge Any perSubLevel change
cachedWorldSources (per sub-level) Last world-emitter scan, so incremental and bounds-diff scans only have to read the delta Sub-level moves or rotates
plotLocalWorldOpaque (per sub-level) World and other sub-levels opaque, projected into plot On move or world block change near the sub-level
injectedPositions (per sub-level) Where this sub-level contribution currently lives in the light engine, so we can clear it before re-injecting Each tick

Known bugs

  • Single block sub-levels don't get lit correctly -- idk why
  • Fully blocking stairs or slabs don't actually block propagation -- would require expensive voxel-shape check, can be done but unsure if worth it
  • Sometimes light source located on sub-level still illuminates scene when fully inside world block -- i have a check for source-in-block but it just doesn't trigger sometimes idk, rare case anyway

izekblz and others added 14 commits May 16, 2026 20:01
…o preserve injections from dynamic light mods (e.g. SodiumDynamicLights). Add backward compatibility shims for EntitySubLevelUtil.getEyePositionInterpolated and getTrackingSubLevel to prevent NoSuchMethodError with Simulated/Create Aeronautics.
…s and other sub-levels. Uses a virtual light spread system with manhattan distance falloff on the client side, injected into both vanilla and Sodium light pipelines.
…minate nearby sub-level blocks. Scans for light emitters near each sub-level's visual position on the server, transforms their positions into plot-local coordinates, and injects them into the plot's light engine for proper propagation. Rescans on light changes, sub-level creation/split, and every 8 blocks of movement.
…s and other sub-levels. Uses a virtual light spread system with manhattan distance falloff on the client side, injected into both vanilla and Sodium light pipelines.
Light-emitting blocks on one sub-level now illuminate nearby other sub-levels.
Emitters are discovered by scanning other sub-levels' plot chunks, transforming
positions to world space, and injecting them into the receiving sub-level's plot
light engine.

Detection: LevelChunkMixin tracks when a block's light emission changes on a
sub-level plot and notifies nearby sub-levels to rescan. Only triggers when
emission actually changes (oldState vs newState), avoiding unnecessary work from
non-light block updates.

Movement: When any sub-level moves, nearby sub-levels are marked for rescan so
they pick up emitters at updated world-space positions.

Cleanup fix: Old injected positions are cleared unconditionally during reinject,
since they are plot-local coordinates that cannot be validated against world state.

Performance: Light updates now use ClientboundLightUpdatePacket instead of full
chunk resends (ClientboundLevelChunkWithLightPacket), avoiding client-side chunk
state resets that caused visual glitches with other mods (e.g. Create ropes) and
reducing network overhead.
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ izekblz
❌ github-actions[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

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.

2 participants