diff --git a/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java b/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java index c95b7400..48fcc366 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java @@ -113,6 +113,10 @@ public static ServerSubLevel assembleBlocks(final ServerLevel level, final Block pipeline.teleport(subLevel, subLevel.logicalPose().position(), subLevel.logicalPose().orientation()); subLevel.updateLastPose(); + // Trigger light rescan for the new sub-level so it picks up world light sources + dev.ryanhcode.sable.render.light_bridge.ServerSubLevelLightInjector.markNeedsFullRescan(subLevel.getUniqueId()); + dev.ryanhcode.sable.render.light_bridge.ServerSubLevelWorldInjector.markNeedsFullRescan(subLevel.getUniqueId()); + SubLevelAssemblyHelper.moveTrackingPoints(level, bounds, subLevel, transform); return subLevel; @@ -136,6 +140,10 @@ public static void kickFromContainingSubLevel(final ServerLevel level, containingPose.transformPosition(subLevel.logicalPose().position()); subLevel.setSplitFrom((ServerSubLevel) containingSubLevel, originalPose); + + // Trigger light rescan for the new split sub-level so it picks up world light sources + dev.ryanhcode.sable.render.light_bridge.ServerSubLevelLightInjector.markNeedsFullRescan(subLevel.getUniqueId()); + dev.ryanhcode.sable.render.light_bridge.ServerSubLevelWorldInjector.markNeedsFullRescan(subLevel.getUniqueId()); } /** @@ -356,6 +364,7 @@ public static void moveBlocks(final ServerLevel level, final AssemblyTransform t } final LevelChunk chunk = resultingAccelerator.getChunk(SectionPos.blockToSectionCoord(newPos.getX()), SectionPos.blockToSectionCoord(newPos.getZ())); + if (chunk == null) continue; chunk.setBlockState(newPos, subLevelState, true); states.add(subLevelState); @@ -381,6 +390,7 @@ public static void moveBlocks(final ServerLevel level, final AssemblyTransform t try { final LevelChunk levelchunk = resultingAccelerator.getChunk(SectionPos.blockToSectionCoord(pos.getX()), SectionPos.blockToSectionCoord(pos.getZ())); + if (levelchunk == null) continue; final BlockState subLevelState = states.get(i); SubLevelAssemblyHelper.markAndNotifyBlock(resultingLevel, pos, levelchunk, Blocks.AIR.defaultBlockState(), subLevelState, 3, 512); } catch (final Exception e) { @@ -398,6 +408,7 @@ public static void moveBlocks(final ServerLevel level, final AssemblyTransform t try { final LevelChunk chunk = accelerator.getChunk(SectionPos.blockToSectionCoord(block.getX()), SectionPos.blockToSectionCoord(block.getZ())); + if (chunk == null) continue; chunk.setBlockState(block, subLevelState, true); } catch (final Exception e) { diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java index c117ecd3..858d287d 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java @@ -3,6 +3,7 @@ import dev.ryanhcode.sable.Sable; import dev.ryanhcode.sable.companion.math.Pose3d; +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelWorldInjector; import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.SubLevel; import dev.ryanhcode.sable.sublevel.storage.SubLevelOccupancySavedData; @@ -71,6 +72,7 @@ public void initialize() { @Override public void tick() { super.tick(); + ServerSubLevelWorldInjector.tick(this.getLevel()); this.holdingChunkMap.processChanges(); } @@ -120,6 +122,8 @@ public void removeSubLevel(final int x, final int z, final SubLevelRemovalReason subLevel.deleteAllEntities(); } + ServerSubLevelWorldInjector.onSubLevelRemoved(this.getLevel(), subLevel.getUniqueId()); + super.removeSubLevel(x, z, reason); if (reason == SubLevelRemovalReason.REMOVED) { diff --git a/common/src/main/java/dev/ryanhcode/sable/mixin/plot/LevelChunkMixin.java b/common/src/main/java/dev/ryanhcode/sable/mixin/plot/LevelChunkMixin.java index d3c5541d..085af965 100644 --- a/common/src/main/java/dev/ryanhcode/sable/mixin/plot/LevelChunkMixin.java +++ b/common/src/main/java/dev/ryanhcode/sable/mixin/plot/LevelChunkMixin.java @@ -4,7 +4,9 @@ import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import dev.ryanhcode.sable.Sable; import dev.ryanhcode.sable.SableCommonEvents; -import dev.ryanhcode.sable.api.SubLevelHelper; +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelLightInjector; +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelWorldInjector; +import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.SubLevel; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; @@ -33,6 +35,13 @@ public class LevelChunkMixin { @Unique private BlockPos sable$blockSet = null; + /** + * Set during {@link #sable$setBlockState} when a block's light emission changes, + * read in {@link #sable$postSetBlockState} so we can poke the server-side light injector once. + */ + @Unique + private boolean sable$emissionDirty = false; + @Inject(method = "setBlockState", at = @At("HEAD")) private void sable$preSetBlockState(final BlockPos pPos, final BlockState pState, final boolean pIsMoving, final CallbackInfoReturnable cir) { @@ -47,25 +56,40 @@ public class LevelChunkMixin { if (subLevel != null) { subLevel.getPlot().onBlockChange(this.sable$blockSet, pState); + + // Any block change on a plot can affect opacity or emission — always notify world injector. + if (this.level instanceof final ServerLevel serverLevel + && subLevel instanceof final ServerSubLevel serverSubLevel) { + ServerSubLevelWorldInjector.onPlotBlockChanged(serverSubLevel); + if (this.sable$emissionDirty) { + ServerSubLevelLightInjector.onPlotBlockLightChanged(serverLevel, serverSubLevel); + } + } } } + this.sable$blockSet = null; + this.sable$emissionDirty = false; } @WrapOperation(method = "setBlockState", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/chunk/LevelChunkSection;setBlockState(IIILnet/minecraft/world/level/block/state/BlockState;)Lnet/minecraft/world/level/block/state/BlockState;")) private BlockState sable$setBlockState(final LevelChunkSection instance, int pX, int pY, int pZ, final BlockState newState, final Operation original) { final BlockState oldState = original.call(instance, pX, pY, pZ, newState); - if (this.level instanceof final ServerLevel serverLevel && oldState != newState) { - pX = this.sable$blockSet.getX(); - pY = this.sable$blockSet.getY(); - pZ = this.sable$blockSet.getZ(); + if (oldState != newState) { + if (oldState.getLightEmission() != newState.getLightEmission()) { + this.sable$emissionDirty = true; + } - SableCommonEvents.handleBlockChange(serverLevel, (LevelChunk) (Object) this, pX, pY, pZ, oldState, newState); + if (this.level instanceof final ServerLevel serverLevel) { + pX = this.sable$blockSet.getX(); + pY = this.sable$blockSet.getY(); + pZ = this.sable$blockSet.getZ(); + + SableCommonEvents.handleBlockChange(serverLevel, (LevelChunk) (Object) this, pX, pY, pZ, oldState, newState); + } } return oldState; } - - } diff --git a/common/src/main/java/dev/ryanhcode/sable/mixin/plot/lighting/server/LightEngineOpacityMixin.java b/common/src/main/java/dev/ryanhcode/sable/mixin/plot/lighting/server/LightEngineOpacityMixin.java new file mode 100644 index 00000000..99972d4e --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/mixin/plot/lighting/server/LightEngineOpacityMixin.java @@ -0,0 +1,41 @@ +package dev.ryanhcode.sable.mixin.plot.lighting.server; + +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelLightInjector; +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelWorldInjector; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.lighting.LightEngine; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(LightEngine.class) +public abstract class LightEngineOpacityMixin { + + @Inject(method = "getOpacity", at = @At("HEAD"), cancellable = true) + private void sable$blockLightAtOpaquePositions(final BlockState state, final BlockPos pos, final CallbackInfoReturnable cir) { + final long packed = pos.asLong(); + if (ServerSubLevelWorldInjector.isOpaqueAt(packed)) { + cir.setReturnValue(16); + return; + } + if (ServerSubLevelLightInjector.isWorldOpaqueInPlot(packed)) { + cir.setReturnValue(16); + } + } + + @Inject(method = "shapeOccludes", at = @At("HEAD"), cancellable = true) + private void sable$shapeOccludesSubLevel(final long sourcePos, final BlockState sourceState, final long targetPos, final BlockState targetState, final Direction direction, final CallbackInfoReturnable cir) { + final byte targetMask = ServerSubLevelWorldInjector.getShapeOcclusion(targetPos); + if (targetMask != 0 && (targetMask & (1 << direction.getOpposite().ordinal())) != 0) { + cir.setReturnValue(true); + return; + } + final byte sourceMask = ServerSubLevelWorldInjector.getShapeOcclusion(sourcePos); + if (sourceMask != 0 && (sourceMask & (1 << direction.ordinal())) != 0) { + cir.setReturnValue(true); + } + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/mixin/plot/lighting/server/ServerChunkCacheLightMixin.java b/common/src/main/java/dev/ryanhcode/sable/mixin/plot/lighting/server/ServerChunkCacheLightMixin.java new file mode 100644 index 00000000..aa33e02d --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/mixin/plot/lighting/server/ServerChunkCacheLightMixin.java @@ -0,0 +1,32 @@ +package dev.ryanhcode.sable.mixin.plot.lighting.server; + +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelLightInjector; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.LightLayer; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Forwards server-side block-light updates to the {@link ServerSubLevelLightInjector} so plots + * can be re-scanned and reinjected if the change occurred near a sub-level. + */ +@Mixin(ServerChunkCache.class) +public class ServerChunkCacheLightMixin { + + @Shadow @Final private ServerLevel level; + + @Inject(method = "onLightUpdate", at = @At("RETURN")) + private void sable$forwardBlockLightUpdate(final LightLayer layer, final SectionPos pos, final CallbackInfo ci) { + if (layer != LightLayer.BLOCK) { + return; + } + + ServerSubLevelLightInjector.onServerLightUpdate(this.level, pos.getX(), pos.getY(), pos.getZ()); + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/physics/impl/rapier/RapierPhysicsPipeline.java b/common/src/main/java/dev/ryanhcode/sable/physics/impl/rapier/RapierPhysicsPipeline.java index 011e0200..b68f0af9 100644 --- a/common/src/main/java/dev/ryanhcode/sable/physics/impl/rapier/RapierPhysicsPipeline.java +++ b/common/src/main/java/dev/ryanhcode/sable/physics/impl/rapier/RapierPhysicsPipeline.java @@ -458,6 +458,7 @@ public void handleChunkSectionAddition(final LevelChunkSection section, final in // if it's only air, all zeros will do. it'll default to empty neighborhood state and 0 (empty) collider ID if (!section.hasOnlyAir()) { final LevelChunk chunk = this.accelerator.getChunk(x, z); + if (chunk == null) return; for (int bx = 0; bx < 16; bx++) { for (int bz = 0; bz < 16; bz++) { diff --git a/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/PlotLocalLightData.java b/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/PlotLocalLightData.java new file mode 100644 index 00000000..368d98e1 --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/PlotLocalLightData.java @@ -0,0 +1,33 @@ +package dev.ryanhcode.sable.render.light_bridge; + +/** + * Cached plot-local block data for a sub-level's light-relevant blocks. + * Populated once on block change, re-projected to world space on movement. + */ +public final class PlotLocalLightData { + + /** Plot-local packed positions of fully opaque blocks (canOcclude && !useShapeForLightOcclusion). */ + public final long[] opaquePositions; + + /** Plot-local packed positions of light-emitting blocks. */ + public final long[] emitterPositions; + + /** Emission level (1-15) parallel to emitterPositions. */ + public final byte[] emitterLevels; + + /** Plot-local packed positions of shape-occluding blocks (useShapeForLightOcclusion). */ + public final long[] shapePositions; + + /** Face occlusion mask (6 bits) parallel to shapePositions. */ + public final byte[] shapeMasks; + + public PlotLocalLightData(final long[] opaquePositions, final long[] emitterPositions, final byte[] emitterLevels, final long[] shapePositions, final byte[] shapeMasks) { + this.opaquePositions = opaquePositions; + this.emitterPositions = emitterPositions; + this.emitterLevels = emitterLevels; + this.shapePositions = shapePositions; + this.shapeMasks = shapeMasks; + } + + public static final PlotLocalLightData EMPTY = new PlotLocalLightData(new long[0], new long[0], new byte[0], new long[0], new byte[0]); +} diff --git a/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/ServerSubLevelLightInjector.java b/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/ServerSubLevelLightInjector.java new file mode 100644 index 00000000..d82424f4 --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/ServerSubLevelLightInjector.java @@ -0,0 +1,642 @@ +package dev.ryanhcode.sable.render.light_bridge; + +import dev.ryanhcode.sable.Sable; +import dev.ryanhcode.sable.companion.math.BoundingBox3dc; +import dev.ryanhcode.sable.companion.math.Pose3dc; +import dev.ryanhcode.sable.sublevel.ServerSubLevel; +import dev.ryanhcode.sable.sublevel.SubLevel; +import dev.ryanhcode.sable.sublevel.plot.PlotChunkHolder; +import dev.ryanhcode.sable.sublevel.plot.ServerLevelPlot; +import dev.ryanhcode.sable.sublevel.plot.SubLevelPlayerChunkSender; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.longs.LongSet; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.level.lighting.LightEngine; +import org.joml.Vector3d; +import org.joml.Vector3dc; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Server-side light injection between sub-levels and the world (world → plot direction). + * Scans world emitters near sub-levels and injects them into the plot's light engine. + */ +public final class ServerSubLevelLightInjector { + + private static final double LIGHT_MARGIN = 15.0; + + private static final ConcurrentHashMap injectedPositions = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap cachedWorldSources = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap lastBlockPositions = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap lastScanBounds = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap lastRescanPositions = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap needsRescan = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap needsReinject = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap needsResend = new ConcurrentHashMap<>(); + + /** + * Plot-local positions that correspond to opaque world blocks near each sub-level. + * Used by the light engine mixin to block light propagation inside plots at world-opaque positions. + */ + private static final ConcurrentHashMap plotLocalWorldOpaque = new ConcurrentHashMap<>(); + + private ServerSubLevelLightInjector() { + } + + /** + * @return true if the given plot-local position corresponds to an opaque world block + * for any sub-level. Called from the light engine mixin during plot propagation. + */ + public static boolean isWorldOpaqueInPlot(final long packedPlotPos) { + for (final LongSet set : plotLocalWorldOpaque.values()) { + if (set.contains(packedPlotPos)) return true; + } + return false; + } + + /** + * Called when block light changes in a world section. May be called from the light thread. + */ + public static void onServerLightUpdate(final ServerLevel level, final int sectionX, final int sectionY, final int sectionZ) { + final var container = dev.ryanhcode.sable.api.sublevel.SubLevelContainer.getContainer(level); + if (container == null) return; + + final double secMinX = SectionPos.sectionToBlockCoord(sectionX); + final double secMinY = SectionPos.sectionToBlockCoord(sectionY); + final double secMinZ = SectionPos.sectionToBlockCoord(sectionZ); + final double secMaxX = secMinX + 16; + final double secMaxY = secMinY + 16; + final double secMaxZ = secMinZ + 16; + + for (final SubLevel subLevel : container.getAllSubLevels()) { + final BoundingBox3dc bounds = subLevel.boundingBox(); + if (bounds == null) continue; + + if (secMaxX + LIGHT_MARGIN >= bounds.minX() && secMinX - LIGHT_MARGIN <= bounds.maxX() + && secMaxY + LIGHT_MARGIN >= bounds.minY() && secMinY - LIGHT_MARGIN <= bounds.maxY() + && secMaxZ + LIGHT_MARGIN >= bounds.minZ() && secMinZ - LIGHT_MARGIN <= bounds.maxZ()) { + final UUID id = subLevel.getUniqueId(); + needsRescan.put(id, Boolean.TRUE); + needsReinject.put(id, Boolean.TRUE); + } + } + } + + /** + * Called when a light-emitting block changes on a sub-level's plot. + */ + public static void onPlotBlockLightChanged(final ServerLevel level, final ServerSubLevel changedSubLevel) { + final var container = dev.ryanhcode.sable.api.sublevel.SubLevelContainer.getContainer(level); + if (container == null) return; + + final BoundingBox3dc changedBounds = changedSubLevel.boundingBox(); + if (changedBounds == null) return; + + for (final SubLevel other : container.getAllSubLevels()) { + if (other == changedSubLevel) continue; + if (boundsOverlap(changedBounds, other.boundingBox())) { + final UUID id = other.getUniqueId(); + needsRescan.put(id, Boolean.TRUE); + needsReinject.put(id, Boolean.TRUE); + } + } + } + + /** + * @return the cached world emitter sources for a sub-level, or null. + */ + public static Long2IntOpenHashMap getCachedWorldSources(final UUID subLevelId) { + return cachedWorldSources.get(subLevelId); + } + + public static void markNeedsFullRescan(final UUID subLevelId) { + needsRescan.put(subLevelId, Boolean.TRUE); + } + + /** + * Called from ServerLevelPlot.tick() before light engine updates. + */ + public static void tickPlot(final ServerLevel level, final ServerSubLevel subLevel, final ServerLevelPlot plot) { + final UUID id = subLevel.getUniqueId(); + + try { + final Vector3dc currentPos = subLevel.logicalPose().position(); + final long currentBlock = BlockPos.asLong( + (int) Math.floor(currentPos.x()), + (int) Math.floor(currentPos.y()), + (int) Math.floor(currentPos.z())); + final Long lastBlock = lastBlockPositions.get(id); + if (lastBlock == null) { + lastBlockPositions.put(id, currentBlock); + needsRescan.put(id, Boolean.TRUE); + } else if (lastBlock != currentBlock) { + final int dx = BlockPos.getX(currentBlock) - BlockPos.getX(lastBlock); + final int dy = BlockPos.getY(currentBlock) - BlockPos.getY(lastBlock); + final int dz = BlockPos.getZ(currentBlock) - BlockPos.getZ(lastBlock); + lastBlockPositions.put(id, currentBlock); + needsReinject.put(id, Boolean.TRUE); + markNearbyForRescan(level, subLevel); + + // Try incremental scan (works for any translation) + if (cachedWorldSources.containsKey(id)) { + incrementalScan(level, subLevel, dx, dy, dz); + } else { + needsRescan.put(id, Boolean.TRUE); + } + } else if (cachedWorldSources.containsKey(id)) { + // Position didn't change but bounds may have changed due to rotation + final BoundingBox3dc bounds = subLevel.boundingBox(); + if (bounds != null) { + final int margin = (int) LIGHT_MARGIN; + final int newMinX = (int) Math.floor(bounds.minX()) - margin; + final int newMinY = Math.max(level.getMinBuildHeight(), (int) Math.floor(bounds.minY()) - margin); + final int newMinZ = (int) Math.floor(bounds.minZ()) - margin; + final int newMaxX = (int) Math.ceil(bounds.maxX()) + margin; + final int newMaxY = Math.min(level.getMaxBuildHeight() - 1, (int) Math.ceil(bounds.maxY()) + margin); + final int newMaxZ = (int) Math.ceil(bounds.maxZ()) + margin; + final int[] prev = lastScanBounds.get(id); + if (prev != null && (newMinX != prev[0] || newMinY != prev[1] || newMinZ != prev[2] + || newMaxX != prev[3] || newMaxY != prev[4] || newMaxZ != prev[5])) { + // Bounds changed (rotation) - do incremental bounds-diff scan + boundsDiffScan(level, subLevel, prev, newMinX, newMinY, newMinZ, newMaxX, newMaxY, newMaxZ); + needsReinject.put(id, Boolean.TRUE); + markNearbyForRescan(level, subLevel); + } + } + } + + if (needsRescan.remove(id, Boolean.TRUE)) { + final BoundingBox3dc bounds = subLevel.boundingBox(); + if (bounds.minX() >= bounds.maxX() && bounds.minZ() >= bounds.maxZ()) { + needsRescan.put(id, Boolean.TRUE); + } else { + fullRescan(level, subLevel); + needsReinject.put(id, Boolean.TRUE); + } + } + + if (needsReinject.remove(id, Boolean.TRUE)) { + final boolean success = reinject(level, subLevel, plot); + if (!success) { + final Long2IntOpenHashMap src = cachedWorldSources.get(id); + if (src != null && !src.isEmpty()) { + needsRescan.put(id, Boolean.TRUE); + } + } + } + } catch (final Throwable ignored) { + } + } + + public static void afterPlotTick(final ServerLevel level, final ServerSubLevel subLevel, final ServerLevelPlot plot) { + final UUID id = subLevel.getUniqueId(); + if (!needsResend.remove(id, Boolean.TRUE)) return; + + for (final PlotChunkHolder holder : plot.getLoadedChunks()) { + if (holder.getChunk() == null) continue; + final ChunkPos globalPos = holder.getPos(); + final var players = dev.ryanhcode.sable.api.sublevel.SubLevelContainer.getContainer(level).getPlayersTracking(globalPos); + for (final ServerPlayer player : players) { + SubLevelPlayerChunkSender.sendLightUpdate(player.connection::send, plot.getLightEngine(), globalPos); + } + } + } + + public static void clear() { + injectedPositions.clear(); + cachedWorldSources.clear(); + lastRescanPositions.clear(); + lastBlockPositions.clear(); + lastScanBounds.clear(); + needsRescan.clear(); + needsReinject.clear(); + needsResend.clear(); + plotLocalWorldOpaque.clear(); + } + + private static void fullRescan(final ServerLevel level, final ServerSubLevel subLevel) { + final BoundingBox3dc bounds = subLevel.boundingBox(); + if (bounds == null) return; + + final UUID id = subLevel.getUniqueId(); + final int margin = (int) LIGHT_MARGIN; + final int minX = (int) Math.floor(bounds.minX()) - margin; + final int minY = Math.max(level.getMinBuildHeight(), (int) Math.floor(bounds.minY()) - margin); + final int minZ = (int) Math.floor(bounds.minZ()) - margin; + final int maxX = (int) Math.ceil(bounds.maxX()) + margin; + final int maxY = Math.min(level.getMaxBuildHeight() - 1, (int) Math.ceil(bounds.maxY()) + margin); + final int maxZ = (int) Math.ceil(bounds.maxZ()) + margin; + + final Long2IntOpenHashMap sources = scanWorldEmitters(level, subLevel, minX, minY, minZ, maxX, maxY, maxZ); + scanOtherSubLevelEmitters(level, subLevel, bounds, margin, sources); + scanWorldOpaqueIntoPlot(level, subLevel, minX, minY, minZ, maxX, maxY, maxZ); + + cachedWorldSources.put(id, sources); + lastRescanPositions.put(id, new Vector3d(subLevel.logicalPose().position())); + lastScanBounds.put(id, new int[]{minX, minY, minZ, maxX, maxY, maxZ}); + } + + /** + * Incremental world scan: shifts cached sources by removing the trailing slice + * and scanning only the leading slice in the direction of movement. + */ + private static void incrementalScan(final ServerLevel level, final ServerSubLevel subLevel, final int dx, final int dy, final int dz) { + final UUID id = subLevel.getUniqueId(); + final BoundingBox3dc bounds = subLevel.boundingBox(); + if (bounds == null) return; + + final Long2IntOpenHashMap sources = cachedWorldSources.get(id); + if (sources == null) return; + + final int margin = (int) LIGHT_MARGIN; + final int minX = (int) Math.floor(bounds.minX()) - margin; + final int minY = Math.max(level.getMinBuildHeight(), (int) Math.floor(bounds.minY()) - margin); + final int minZ = (int) Math.floor(bounds.minZ()) - margin; + final int maxX = (int) Math.ceil(bounds.maxX()) + margin; + final int maxY = Math.min(level.getMaxBuildHeight() - 1, (int) Math.ceil(bounds.maxY()) + margin); + final int maxZ = (int) Math.ceil(bounds.maxZ()) + margin; + + // Remove entries from trailing slice (outside new bounds) + final var iter = sources.long2IntEntrySet().iterator(); + while (iter.hasNext()) { + final var entry = iter.next(); + final int wx = BlockPos.getX(entry.getLongKey()); + final int wy = BlockPos.getY(entry.getLongKey()); + final int wz = BlockPos.getZ(entry.getLongKey()); + if (wx < minX || wx > maxX || wy < minY || wy > maxY || wz < minZ || wz > maxZ) { + iter.remove(); + } + } + + // Scan leading slices on each axis that moved + final int sliceMinX = dx > 0 ? (maxX - dx + 1) : (dx < 0 ? minX : minX); + final int sliceMaxX = dx > 0 ? maxX : (dx < 0 ? (minX - dx - 1) : maxX); + final int sliceMinY = dy > 0 ? (maxY - dy + 1) : (dy < 0 ? minY : minY); + final int sliceMaxY = dy > 0 ? maxY : (dy < 0 ? (minY - dy - 1) : maxY); + final int sliceMinZ = dz > 0 ? (maxZ - dz + 1) : (dz < 0 ? minZ : minZ); + final int sliceMaxZ = dz > 0 ? maxZ : (dz < 0 ? (minZ - dz - 1) : maxZ); + + final BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + LevelChunk lastChunk = null; + int lastChunkX = Integer.MIN_VALUE, lastChunkZ = Integer.MIN_VALUE; + + for (int wy = sliceMinY; wy <= sliceMaxY; wy++) { + for (int wx = sliceMinX; wx <= sliceMaxX; wx++) { + for (int wz = sliceMinZ; wz <= sliceMaxZ; wz++) { + final int cx = wx >> 4, cz = wz >> 4; + if (cx != lastChunkX || cz != lastChunkZ) { + lastChunk = level.getChunkSource().getChunkNow(cx, cz); + lastChunkX = cx; lastChunkZ = cz; + if (lastChunk == null) continue; + final SubLevel atWorld = Sable.HELPER.getContaining(level, new ChunkPos(cx, cz)); + if (atWorld == subLevel) { lastChunk = null; continue; } + } + if (lastChunk == null) continue; + mutable.set(wx, wy, wz); + final int emission = lastChunk.getBlockState(mutable).getLightEmission(); + if (emission > 0) { + sources.put(BlockPos.asLong(wx, wy, wz), emission); + } + } + } + } + + // Also re-scan other sub-level emitters + scanOtherSubLevelEmitters(level, subLevel, bounds, margin, sources); + // Also re-scan world opaque into plot + scanWorldOpaqueIntoPlot(level, subLevel, minX, minY, minZ, maxX, maxY, maxZ); + lastScanBounds.put(id, new int[]{minX, minY, minZ, maxX, maxY, maxZ}); + } + + /** + * Bounds-diff scan for rotation: removes entries outside new bounds, + * scans only the regions in new bounds that weren't in old bounds. + */ + private static void boundsDiffScan(final ServerLevel level, final ServerSubLevel subLevel, + final int[] prev, final int newMinX, final int newMinY, final int newMinZ, + final int newMaxX, final int newMaxY, final int newMaxZ) { + final UUID id = subLevel.getUniqueId(); + final Long2IntOpenHashMap sources = cachedWorldSources.get(id); + if (sources == null) return; + + // Remove entries outside new bounds + final var iter = sources.long2IntEntrySet().iterator(); + while (iter.hasNext()) { + final var entry = iter.next(); + final int wx = BlockPos.getX(entry.getLongKey()); + final int wy = BlockPos.getY(entry.getLongKey()); + final int wz = BlockPos.getZ(entry.getLongKey()); + if (wx < newMinX || wx > newMaxX || wy < newMinY || wy > newMaxY || wz < newMinZ || wz > newMaxZ) { + iter.remove(); + } + } + + // Scan new regions (positions in new bounds but not in old bounds) + final int oldMinX = prev[0], oldMinY = prev[1], oldMinZ = prev[2]; + final int oldMaxX = prev[3], oldMaxY = prev[4], oldMaxZ = prev[5]; + final BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + LevelChunk lastChunk = null; + int lastChunkX = Integer.MIN_VALUE, lastChunkZ = Integer.MIN_VALUE; + + for (int wy = newMinY; wy <= newMaxY; wy++) { + for (int wx = newMinX; wx <= newMaxX; wx++) { + for (int wz = newMinZ; wz <= newMaxZ; wz++) { + // Skip positions that were already in old bounds + if (wx >= oldMinX && wx <= oldMaxX && wy >= oldMinY && wy <= oldMaxY && wz >= oldMinZ && wz <= oldMaxZ) continue; + final int cx = wx >> 4, cz = wz >> 4; + if (cx != lastChunkX || cz != lastChunkZ) { + lastChunk = level.getChunkSource().getChunkNow(cx, cz); + lastChunkX = cx; lastChunkZ = cz; + if (lastChunk == null) continue; + final SubLevel atWorld = Sable.HELPER.getContaining(level, new ChunkPos(cx, cz)); + if (atWorld == subLevel) { lastChunk = null; continue; } + } + if (lastChunk == null) continue; + mutable.set(wx, wy, wz); + final int emission = lastChunk.getBlockState(mutable).getLightEmission(); + if (emission > 0) { + sources.put(BlockPos.asLong(wx, wy, wz), emission); + } + } + } + } + + final int margin = (int) LIGHT_MARGIN; + scanOtherSubLevelEmitters(level, subLevel, subLevel.boundingBox(), margin, sources); + scanWorldOpaqueIntoPlot(level, subLevel, newMinX, newMinY, newMinZ, newMaxX, newMaxY, newMaxZ); + lastScanBounds.put(id, new int[]{newMinX, newMinY, newMinZ, newMaxX, newMaxY, newMaxZ}); + } + + private static Long2IntOpenHashMap scanWorldEmitters(final ServerLevel level, final ServerSubLevel subLevel, + final int minX, final int minY, final int minZ, final int maxX, final int maxY, final int maxZ) { + final Long2IntOpenHashMap sources = new Long2IntOpenHashMap(); + sources.defaultReturnValue(0); + final BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + LevelChunk lastChunk = null; + int lastChunkX = Integer.MIN_VALUE, lastChunkZ = Integer.MIN_VALUE; + + for (int wy = minY; wy <= maxY; wy++) { + for (int wx = minX; wx <= maxX; wx++) { + for (int wz = minZ; wz <= maxZ; wz++) { + final int cx = wx >> 4, cz = wz >> 4; + if (cx != lastChunkX || cz != lastChunkZ) { + lastChunk = level.getChunkSource().getChunkNow(cx, cz); + lastChunkX = cx; + lastChunkZ = cz; + if (lastChunk == null) continue; + final SubLevel atWorld = Sable.HELPER.getContaining(level, new ChunkPos(cx, cz)); + if (atWorld == subLevel) { lastChunk = null; continue; } + } + if (lastChunk == null) continue; + + mutable.set(wx, wy, wz); + final int emission = lastChunk.getBlockState(mutable).getLightEmission(); + if (emission > 0) { + sources.put(BlockPos.asLong(wx, wy, wz), emission); + } + } + } + } + return sources; + } + + private static void scanWorldOpaqueIntoPlot(final ServerLevel level, final ServerSubLevel subLevel, + final int minX, final int minY, final int minZ, final int maxX, final int maxY, final int maxZ) { + final UUID id = subLevel.getUniqueId(); + final Pose3dc pose = subLevel.logicalPose(); + final LongOpenHashSet worldOpaqueInPlot = new LongOpenHashSet(); + final Vector3d plotLocalPos = new Vector3d(); + final BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + LevelChunk lastChunk = null; + int lastChunkX = Integer.MIN_VALUE, lastChunkZ = Integer.MIN_VALUE; + + for (int wy = minY; wy <= maxY; wy++) { + for (int wx = minX; wx <= maxX; wx++) { + for (int wz = minZ; wz <= maxZ; wz++) { + final int cx = wx >> 4, cz = wz >> 4; + if (cx != lastChunkX || cz != lastChunkZ) { + lastChunk = level.getChunkSource().getChunkNow(cx, cz); + lastChunkX = cx; + lastChunkZ = cz; + if (lastChunk == null) continue; + final SubLevel atWorld = Sable.HELPER.getContaining(level, new ChunkPos(cx, cz)); + if (atWorld == subLevel) { lastChunk = null; continue; } + } + if (lastChunk == null) continue; + + mutable.set(wx, wy, wz); + final BlockState worldBlockState = lastChunk.getBlockState(mutable); + if (!worldBlockState.canOcclude() || worldBlockState.useShapeForLightOcclusion()) continue; + + plotLocalPos.set(wx + 0.5, wy + 0.5, wz + 0.5); + pose.transformPositionInverse(plotLocalPos); + + worldOpaqueInPlot.add(BlockPos.asLong( + (int) Math.floor(plotLocalPos.x), + (int) Math.floor(plotLocalPos.y), + (int) Math.floor(plotLocalPos.z))); + } + } + } + + // Also project other sub-levels' opaque world positions into this plot's local space. + // This prevents light from sub-level A passing through sub-level B to reach this sub-level. + final LongSet subLevelOpaqueWorld = ServerSubLevelWorldInjector.getOpaquePositions(); + final LongSet ownGapFills = ServerSubLevelWorldInjector.getGapFillsForSubLevel(id); + for (final long worldPacked : subLevelOpaqueWorld) { + if (ownGapFills.contains(worldPacked)) continue; + plotLocalPos.set( + BlockPos.getX(worldPacked) + 0.5, + BlockPos.getY(worldPacked) + 0.5, + BlockPos.getZ(worldPacked) + 0.5); + pose.transformPositionInverse(plotLocalPos); + worldOpaqueInPlot.add(BlockPos.asLong( + (int) Math.floor(plotLocalPos.x), + (int) Math.floor(plotLocalPos.y), + (int) Math.floor(plotLocalPos.z))); + } + + + for (final PlotChunkHolder holder : subLevel.getPlot().getLoadedChunks()) { + final LevelChunk plotChunk = holder.getChunk(); + if (plotChunk == null) continue; + final int pcBaseX = plotChunk.getPos().getMinBlockX(); + final int pcBaseZ = plotChunk.getPos().getMinBlockZ(); + for (int sIdx = 0; sIdx < plotChunk.getSectionsCount(); sIdx++) { + final var section = plotChunk.getSection(sIdx); + if (section.hasOnlyAir()) continue; + final int pcBaseY = plotChunk.getLevel().getSectionYFromSectionIndex(sIdx) << 4; + for (int bx = 0; bx < 16; bx++) + for (int by = 0; by < 16; by++) + for (int bz = 0; bz < 16; bz++) { + if (!section.getBlockState(bx, by, bz).isAir()) { + worldOpaqueInPlot.remove(BlockPos.asLong(pcBaseX + bx, pcBaseY + by, pcBaseZ + bz)); + } + } + } + } + + plotLocalWorldOpaque.put(id, worldOpaqueInPlot); + } + + private static void scanOtherSubLevelEmitters(final ServerLevel level, final ServerSubLevel subLevel, + final BoundingBox3dc bounds, final int margin, final Long2IntOpenHashMap sources) { + final var container = dev.ryanhcode.sable.api.sublevel.SubLevelContainer.getContainer(level); + if (container == null) return; + + final Vector3d worldPos = new Vector3d(); + for (final SubLevel other : container.getAllSubLevels()) { + if (other == subLevel || !(other instanceof final ServerSubLevel otherServer)) continue; + + final BoundingBox3dc otherBounds = other.boundingBox(); + if (otherBounds == null || !boundsOverlap(bounds, otherBounds)) continue; + + final var otherPlot = otherServer.getPlot(); + if (otherPlot.getBoundingBox() == null) continue; + + final Pose3dc otherPose = other.logicalPose(); + + for (final PlotChunkHolder holder : otherPlot.getLoadedChunks()) { + final var plotChunk = holder.getChunk(); + if (plotChunk == null) continue; + + final int baseX = plotChunk.getPos().getMinBlockX(); + final int baseZ = plotChunk.getPos().getMinBlockZ(); + + for (int sIdx = 0; sIdx < plotChunk.getSectionsCount(); sIdx++) { + final var section = plotChunk.getSection(sIdx); + if (section.hasOnlyAir()) continue; + + final int baseY = plotChunk.getLevel().getSectionYFromSectionIndex(sIdx) << 4; + + for (int x = 0; x < 16; x++) + for (int y = 0; y < 16; y++) + for (int z = 0; z < 16; z++) { + final int em = section.getBlockState(x, y, z).getLightEmission(); + if (em <= 0) continue; + + worldPos.set(baseX + x + 0.5, baseY + y + 0.5, baseZ + z + 0.5); + otherPose.transformPosition(worldPos); + + if (worldPos.x >= bounds.minX() - margin && worldPos.x <= bounds.maxX() + margin + && worldPos.y >= bounds.minY() - margin && worldPos.y <= bounds.maxY() + margin + && worldPos.z >= bounds.minZ() - margin && worldPos.z <= bounds.maxZ() + margin) { + final long packed = BlockPos.asLong( + (int) Math.floor(worldPos.x), + (int) Math.floor(worldPos.y), + (int) Math.floor(worldPos.z)); + final int existing = sources.get(packed); + if (em > existing) sources.put(packed, em); + } + } + } + } + } + } + + private static boolean reinject(final ServerLevel level, final ServerSubLevel subLevel, final ServerLevelPlot plot) { + final UUID subLevelId = subLevel.getUniqueId(); + final Pose3dc pose = subLevel.logicalPose(); + final LevelLightEngine plotLightEngine = plot.getLightEngine(); + if (plotLightEngine.blockEngine == null) return false; + + boolean changed = false; + + final LongSet oldPositions = injectedPositions.remove(subLevelId); + if (oldPositions != null) { + for (final long packed : oldPositions) { + try { + final int oldLevel = plotLightEngine.blockEngine.storage.getStoredLevel(packed); + if (oldLevel > 0) { + plotLightEngine.blockEngine.storage.setStoredLevel(packed, 0); + plotLightEngine.blockEngine.enqueueDecrease( + packed, LightEngine.QueueEntry.decreaseAllDirections(oldLevel)); + changed = true; + } + } catch (final NullPointerException ignored) {} + } + if (changed) { + do { plotLightEngine.runLightUpdates(); } while (plotLightEngine.hasLightWork()); + } + } + + final Long2IntOpenHashMap sources = cachedWorldSources.get(subLevelId); + if (sources == null || sources.isEmpty()) { + if (changed) { + plotLightEngine.blockEngine.storage.swapSectionMap(); + needsResend.put(subLevelId, Boolean.TRUE); + } + return changed; + } + + final LongSet newPositions = new LongOpenHashSet(); + final Vector3d plotLocal = new Vector3d(); + + for (final var entry : sources.long2IntEntrySet()) { + final int wx = BlockPos.getX(entry.getLongKey()); + final int wy = BlockPos.getY(entry.getLongKey()); + final int wz = BlockPos.getZ(entry.getLongKey()); + final int emission = entry.getIntValue(); + + plotLocal.set(wx + 0.5, wy + 0.5, wz + 0.5); + pose.transformPositionInverse(plotLocal); + + if (!plot.contains(plotLocal)) continue; + + final long plotPacked = BlockPos.containing(plotLocal.x, plotLocal.y, plotLocal.z).asLong(); + try { + plotLightEngine.blockEngine.storage.setStoredLevel(plotPacked, emission); + plotLightEngine.blockEngine.enqueueIncrease( + plotPacked, LightEngine.QueueEntry.increaseLightFromEmission(emission, true)); + newPositions.add(plotPacked); + changed = true; + } catch (final NullPointerException ignored) {} + } + + if (!newPositions.isEmpty()) { + injectedPositions.put(subLevelId, newPositions); + } + + if (changed) { + plotLightEngine.blockEngine.storage.swapSectionMap(); + needsResend.put(subLevelId, Boolean.TRUE); + } + + return !newPositions.isEmpty(); + } + + + + private static void markNearbyForRescan(final ServerLevel level, final ServerSubLevel movedSubLevel) { + final var container = dev.ryanhcode.sable.api.sublevel.SubLevelContainer.getContainer(level); + if (container == null) return; + + final BoundingBox3dc movedBounds = movedSubLevel.boundingBox(); + if (movedBounds == null) return; + + for (final SubLevel other : container.getAllSubLevels()) { + if (other == movedSubLevel) continue; + if (boundsOverlap(movedBounds, other.boundingBox())) { + final UUID id = other.getUniqueId(); + needsRescan.put(id, Boolean.TRUE); + needsReinject.put(id, Boolean.TRUE); + } + } + } + + private static boolean boundsOverlap(final BoundingBox3dc a, final BoundingBox3dc b) { + if (a == null || b == null) return false; + return a.maxX() + LIGHT_MARGIN >= b.minX() && a.minX() - LIGHT_MARGIN <= b.maxX() + && a.maxY() + LIGHT_MARGIN >= b.minY() && a.minY() - LIGHT_MARGIN <= b.maxY() + && a.maxZ() + LIGHT_MARGIN >= b.minZ() && a.minZ() - LIGHT_MARGIN <= b.maxZ(); + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/ServerSubLevelWorldInjector.java b/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/ServerSubLevelWorldInjector.java new file mode 100644 index 00000000..d4be8189 --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/render/light_bridge/ServerSubLevelWorldInjector.java @@ -0,0 +1,641 @@ +package dev.ryanhcode.sable.render.light_bridge; + +import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import dev.ryanhcode.sable.companion.math.BoundingBox3dc; +import dev.ryanhcode.sable.companion.math.BoundingBox3ic; +import dev.ryanhcode.sable.companion.math.Pose3dc; +import dev.ryanhcode.sable.sublevel.ServerSubLevel; +import dev.ryanhcode.sable.sublevel.SubLevel; +import dev.ryanhcode.sable.sublevel.plot.PlotChunkHolder; +import dev.ryanhcode.sable.sublevel.plot.ServerLevelPlot; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.longs.LongSet; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.ThreadedLevelLightEngine; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.level.lighting.LightEngine; +import org.joml.Vector3d; +import org.joml.Vector3dc; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Server-side light injection from sub-level plots into the world (plot → world direction). + *

+ * Handles three unified concerns triggered by sub-level movement or block changes: + *

    + *
  • Emitter projection: sub-level light sources → world light engine
  • + *
  • Opacity projection: sub-level opaque blocks → light propagation barrier
  • + *
  • Neighbor notification: when a sub-level changes, nearby sub-levels re-propagate
  • + *
+ */ +public final class ServerSubLevelWorldInjector { + + private static final double LIGHT_MARGIN = 15.0; + + private static final ConcurrentHashMap injectedPositions = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap cachedPlotSources = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap perSubLevelOpaque = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap perSubLevelOpaqueCore = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap perSubLevelShapes = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap lastBoundsCorners = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap needsUpdate = new ConcurrentHashMap<>(); + + /** Plot-local block data cache. Only rebuilt on block change, re-projected on movement. */ + private static final ConcurrentHashMap plotLocalCache = new ConcurrentHashMap<>(); + /** Sub-levels that need their plot-local cache rebuilt (block changed). */ + private static final ConcurrentHashMap needsPlotRescan = new ConcurrentHashMap<>(); + + // --- Global opaque state --- + private static volatile LongSet opaquePositions = new LongOpenHashSet(); + /** Opaque positions without gap-fills - used for sub-level-to-sub-level projection. */ + private static volatile LongSet opaquePositionsCore = new LongOpenHashSet(); + private static volatile boolean opaqueDirty = false; + + /** + * Shape-occluding sub-level blocks in world space: packed pos → 6-bit face mask. + * Bit i set means the block's shape fully covers face Direction.values()[i]. + * Used by the shapeOccludes mixin for slabs/stairs. + */ + private static volatile it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap shapeOcclusionMap = new it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap(); + + private ServerSubLevelWorldInjector() { + } + + // --- Public API --- + + /** + * @return true if a sub-level's opaque block occupies the given packed world position. + * Called from the light engine mixin on the light thread. + */ + public static boolean isOpaqueAt(final long packedPos) { + return opaquePositions.contains(packedPos); + } + + /** + * @return the global set of all sub-level opaque world positions. + */ + public static LongSet getOpaquePositions() { + return opaquePositions; + } + + /** + * @return the gap-fill positions for a specific sub-level (positions to exclude from its own plot). + */ + public static LongSet getGapFillsForSubLevel(final java.util.UUID subLevelId) { + final LongSet full = perSubLevelOpaque.get(subLevelId); + final LongSet core = perSubLevelOpaqueCore.get(subLevelId); + if (full == null || core == null) return new LongOpenHashSet(); + final LongOpenHashSet gaps = new LongOpenHashSet(full); + gaps.removeAll(core); + return gaps; + } + + + /** + * @return the face occlusion mask for a shape-occluding sub-level block at the given position, + * or 0 if none. Bit i set = face Direction.values()[i] is fully occluded. + */ + public static byte getShapeOcclusion(final long packedPos) { + return shapeOcclusionMap.get(packedPos); + } + + /** + * Marks a sub-level as needing a full update (rescan emitters + opacity + reinject). + */ + public static void markNeedsFullRescan(final UUID subLevelId) { + needsPlotRescan.put(subLevelId, Boolean.TRUE); + needsUpdate.put(subLevelId, Boolean.TRUE); + } + + /** + * Called when a block changes on a sub-level's plot that may affect world lighting. + */ + public static void onPlotBlockChanged(final ServerSubLevel subLevel) { + final UUID id = subLevel.getUniqueId(); + needsPlotRescan.put(id, Boolean.TRUE); + needsUpdate.put(id, Boolean.TRUE); + } + + /** + * Called once per server tick after all plots have ticked, from ServerSubLevelContainer.tick(). + */ + public static void tick(final ServerLevel level) { + final var container = SubLevelContainer.getContainer(level); + if (container == null) return; + + final List allSubLevels = container.getAllSubLevels(); + + // Phase 1: Detect movement, run scans for sub-levels that changed + for (final SubLevel subLevel : allSubLevels) { + if (!(subLevel instanceof final ServerSubLevel ssl)) continue; + detectMovement(ssl); + } + + // Phase 2: Process updates and collect which sub-levels changed + final java.util.HashSet changedIds = new java.util.HashSet<>(); + final java.util.ArrayList changedBounds = new java.util.ArrayList<>(4); + + for (final SubLevel subLevel : allSubLevels) { + if (!(subLevel instanceof final ServerSubLevel ssl)) continue; + final UUID id = ssl.getUniqueId(); + + // Rebuild plot-local cache if blocks changed (expensive, rare) + if (needsPlotRescan.remove(id, Boolean.TRUE)) { + rebuildPlotLocalCache(ssl); + } + + if (needsUpdate.remove(id, Boolean.TRUE)) { + // Re-project cached plot-local data to world space (cheap) + projectToWorldSpace(ssl); + opaqueDirty = true; + + final BoundingBox3dc bounds = ssl.boundingBox(); + if (bounds != null) { + changedIds.add(id); + changedBounds.add(bounds); + } + } + } + + // Phase 3: Rebuild global opaque set if anything changed (before reinjections) + if (opaqueDirty) { + opaqueDirty = false; + final LongOpenHashSet merged = new LongOpenHashSet(); + for (final LongSet set : perSubLevelOpaque.values()) { + merged.addAll(set); + } + opaquePositions = merged; + final LongOpenHashSet mergedCore = new LongOpenHashSet(); + for (final LongSet set : perSubLevelOpaqueCore.values()) { mergedCore.addAll(set); } + opaquePositionsCore = mergedCore; + + final it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap mergedShapes = new it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap(); + mergedShapes.defaultReturnValue((byte) 0); + for (final it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap map : perSubLevelShapes.values()) { + for (final var entry : map.long2ByteEntrySet()) { + mergedShapes.mergeByte(entry.getLongKey(), entry.getByteValue(), (a, b) -> (byte) (a | b)); + } + } + shapeOcclusionMap = mergedShapes; + + // Force world light re-propagation: find world emitters near changed sub-levels, + // fully clear their light and re-propagate. Must be done on the light thread. + final net.minecraft.world.level.lighting.LevelLightEngine wle = level.getLightEngine(); + if (wle instanceof final net.minecraft.server.level.ThreadedLevelLightEngine tle) { + // Collect world emitter positions near changed bounds + final it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap worldEmitters = new it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap(); + for (final BoundingBox3dc cb : changedBounds) { + final int margin = 15; + final int mnX = (int) Math.floor(cb.minX()) - margin; + final int mnY = Math.max(level.getMinBuildHeight(), (int) Math.floor(cb.minY()) - margin); + final int mnZ = (int) Math.floor(cb.minZ()) - margin; + final int mxX = (int) Math.ceil(cb.maxX()) + margin; + final int mxY = Math.min(level.getMaxBuildHeight() - 1, (int) Math.ceil(cb.maxY()) + margin); + final int mxZ = (int) Math.ceil(cb.maxZ()) + margin; + final BlockPos.MutableBlockPos mut = new BlockPos.MutableBlockPos(); + net.minecraft.world.level.chunk.LevelChunk lc = null; + int lcx = Integer.MIN_VALUE, lcz = Integer.MIN_VALUE; + for (int wy = mnY; wy <= mxY; wy++) + for (int wx = mnX; wx <= mxX; wx++) + for (int wz = mnZ; wz <= mxZ; wz++) { + final int cx = wx >> 4, cz = wz >> 4; + if (cx != lcx || cz != lcz) { lc = level.getChunkSource().getChunkNow(cx, cz); lcx = cx; lcz = cz; } + if (lc == null) continue; + mut.set(wx, wy, wz); + final int emLvl = lc.getBlockState(mut).getLightEmission(); + if (emLvl > 0) + worldEmitters.put(BlockPos.asLong(wx, wy, wz), emLvl); + } + } + if (!worldEmitters.isEmpty()) { + // On light thread: clear each emitter's light fully, then re-emit + tle.taskMailbox.tell(() -> { + if (tle.blockEngine == null) return; + // Clear + for (final long ep : worldEmitters.keySet()) { + final int stored = tle.blockEngine.storage.getStoredLevel(ep); + if (stored > 0) { + tle.blockEngine.storage.setStoredLevel(ep, 0); + tle.blockEngine.enqueueDecrease(ep, + net.minecraft.world.level.lighting.LightEngine.QueueEntry.decreaseAllDirections(stored)); + } + } + tle.blockEngine.runLightUpdates(); + // Re-emit (skip emitters that are now inside opaque sub-level geometry) + for (final var entry : worldEmitters.long2IntEntrySet()) { + final long ep = entry.getLongKey(); + if (opaquePositionsCore.contains(ep)) continue; + final int em = entry.getIntValue(); + tle.blockEngine.storage.setStoredLevel(ep, em); + tle.blockEngine.enqueueIncrease(ep, + net.minecraft.world.level.lighting.LightEngine.QueueEntry.increaseLightFromEmission(em, true)); + } + tle.blockEngine.runLightUpdates(); + tle.blockEngine.storage.swapSectionMap(); + // Notify clients + final LongOpenHashSet sections = new LongOpenHashSet(); + for (final long ep : worldEmitters.keySet()) { + addNeighborSections(sections, ep); + } + level.getServer().execute(() -> sendWorldLightUpdates(level, sections)); + }); + tle.tryScheduleUpdate(); + } + } + } + + // Phase 4: If any sub-level changed, notify nearby emitting sub-levels + if (!changedBounds.isEmpty()) { + for (final SubLevel subLevel : allSubLevels) { + if (!(subLevel instanceof final ServerSubLevel ssl)) continue; + final UUID id = ssl.getUniqueId(); + // Skip if already updated this tick + if (needsUpdate.containsKey(id)) continue; + final Long2IntOpenHashMap src = cachedPlotSources.get(id); + if ((src == null || src.isEmpty()) && !injectedPositions.containsKey(id)) continue; + + final BoundingBox3dc emitterBounds = ssl.boundingBox(); + if (emitterBounds == null) continue; + + for (final BoundingBox3dc changed : changedBounds) { + if (boundsOverlap(emitterBounds, changed)) { + enqueueReinject(level, id); + break; + } + } + } + } + + // Phase 5: Enqueue reinjections for sub-levels that were scanned in phase 2 + for (final UUID id : changedIds) { + enqueueReinject(level, id); + } + } + + /** + * Cleans up state for a removed sub-level. + */ + public static void onSubLevelRemoved(final ServerLevel level, final UUID subLevelId) { + final LevelLightEngine lightEngine = level.getLightEngine(); + if (!(lightEngine instanceof final ThreadedLevelLightEngine threadedEngine)) return; + + final LongSet oldPositions = injectedPositions.remove(subLevelId); + if (oldPositions != null && !oldPositions.isEmpty() && threadedEngine.blockEngine != null) { + threadedEngine.taskMailbox.tell(() -> { + if (threadedEngine.blockEngine == null) return; + for (final long packed : oldPositions) { + threadedEngine.blockEngine.checkBlock(new BlockPos( + BlockPos.getX(packed), BlockPos.getY(packed), BlockPos.getZ(packed))); + } + threadedEngine.blockEngine.runLightUpdates(); + threadedEngine.blockEngine.storage.swapSectionMap(); + }); + threadedEngine.tryScheduleUpdate(); + } + + cachedPlotSources.remove(subLevelId); + lastBoundsCorners.remove(subLevelId); + needsUpdate.remove(subLevelId); + needsPlotRescan.remove(subLevelId); + plotLocalCache.remove(subLevelId); + perSubLevelOpaqueCore.remove(subLevelId); + if (perSubLevelOpaque.remove(subLevelId) != null || perSubLevelShapes.remove(subLevelId) != null) { + opaqueDirty = true; + } + } + + public static void clear() { + injectedPositions.clear(); + cachedPlotSources.clear(); + perSubLevelOpaque.clear(); + perSubLevelOpaqueCore.clear(); + opaquePositionsCore = new LongOpenHashSet(); + perSubLevelShapes.clear(); + lastBoundsCorners.clear(); + needsUpdate.clear(); + plotLocalCache.clear(); + needsPlotRescan.clear(); + opaquePositions = new LongOpenHashSet(); + shapeOcclusionMap = new it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap(); + opaqueDirty = false; + } + + // --- Internal --- + + private static boolean boundsOverlap(final BoundingBox3dc a, final BoundingBox3dc b) { + return a.maxX() + LIGHT_MARGIN >= b.minX() && a.minX() - LIGHT_MARGIN <= b.maxX() + && a.maxY() + LIGHT_MARGIN >= b.minY() && a.minY() - LIGHT_MARGIN <= b.maxY() + && a.maxZ() + LIGHT_MARGIN >= b.minZ() && a.minZ() - LIGHT_MARGIN <= b.maxZ(); + } + + private static void detectMovement(final ServerSubLevel subLevel) { + final UUID id = subLevel.getUniqueId(); + final BoundingBox3dc bounds = subLevel.boundingBox(); + if (bounds == null) return; + + // Compute 8 bounding box corners as block positions + final long[] currentCorners = new long[] { + BlockPos.asLong((int) Math.floor(bounds.minX()), (int) Math.floor(bounds.minY()), (int) Math.floor(bounds.minZ())), + BlockPos.asLong((int) Math.floor(bounds.maxX()), (int) Math.floor(bounds.minY()), (int) Math.floor(bounds.minZ())), + BlockPos.asLong((int) Math.floor(bounds.minX()), (int) Math.floor(bounds.maxY()), (int) Math.floor(bounds.minZ())), + BlockPos.asLong((int) Math.floor(bounds.maxX()), (int) Math.floor(bounds.maxY()), (int) Math.floor(bounds.minZ())), + BlockPos.asLong((int) Math.floor(bounds.minX()), (int) Math.floor(bounds.minY()), (int) Math.floor(bounds.maxZ())), + BlockPos.asLong((int) Math.floor(bounds.maxX()), (int) Math.floor(bounds.minY()), (int) Math.floor(bounds.maxZ())), + BlockPos.asLong((int) Math.floor(bounds.minX()), (int) Math.floor(bounds.maxY()), (int) Math.floor(bounds.maxZ())), + BlockPos.asLong((int) Math.floor(bounds.maxX()), (int) Math.floor(bounds.maxY()), (int) Math.floor(bounds.maxZ())) + }; + + final long[] lastCorners = lastBoundsCorners.get(id); + if (lastCorners == null) { + lastBoundsCorners.put(id, currentCorners); + needsUpdate.put(id, Boolean.TRUE); + } else { + boolean changed = false; + for (int i = 0; i < 8; i++) { + if (currentCorners[i] != lastCorners[i]) { changed = true; break; } + } + if (changed) { + lastBoundsCorners.put(id, currentCorners); + needsUpdate.put(id, Boolean.TRUE); + } + } + } + + /** + * Rebuilds the plot-local cache by iterating plot chunks. Only called on block change. + */ + private static void rebuildPlotLocalCache(final ServerSubLevel subLevel) { + final UUID id = subLevel.getUniqueId(); + final ServerLevelPlot plot = subLevel.getPlot(); + final BoundingBox3ic plotBounds = plot.getBoundingBox(); + if (plotBounds == null) { + plotLocalCache.put(id, PlotLocalLightData.EMPTY); + return; + } + + final it.unimi.dsi.fastutil.longs.LongArrayList opaqueList = new it.unimi.dsi.fastutil.longs.LongArrayList(); + final it.unimi.dsi.fastutil.longs.LongArrayList emitterList = new it.unimi.dsi.fastutil.longs.LongArrayList(); + final it.unimi.dsi.fastutil.bytes.ByteArrayList emitterLevelList = new it.unimi.dsi.fastutil.bytes.ByteArrayList(); + final it.unimi.dsi.fastutil.longs.LongArrayList shapeList = new it.unimi.dsi.fastutil.longs.LongArrayList(); + final it.unimi.dsi.fastutil.bytes.ByteArrayList shapeMaskList = new it.unimi.dsi.fastutil.bytes.ByteArrayList(); + + for (final PlotChunkHolder holder : plot.getLoadedChunks()) { + final LevelChunk chunk = holder.getChunk(); + if (chunk == null) continue; + + final int baseX = chunk.getPos().getMinBlockX(); + final int baseZ = chunk.getPos().getMinBlockZ(); + + for (int sIdx = 0; sIdx < chunk.getSectionsCount(); sIdx++) { + final LevelChunkSection section = chunk.getSection(sIdx); + if (section.hasOnlyAir()) continue; + + final int baseY = chunk.getLevel().getSectionYFromSectionIndex(sIdx) << 4; + + for (int x = 0; x < 16; x++) { + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + final BlockState state = section.getBlockState(x, y, z); + if (state.isAir()) continue; + + final long plotLocal = BlockPos.asLong(baseX + x, baseY + y, baseZ + z); + + final int emission = state.getLightEmission(); + if (emission > 0) { + emitterList.add(plotLocal); + emitterLevelList.add((byte) emission); + } + + if (state.canOcclude() && !state.useShapeForLightOcclusion()) { + opaqueList.add(plotLocal); + } else if (state.useShapeForLightOcclusion()) { + byte mask = 0; + final BlockPos localPos = new BlockPos(baseX + x, baseY + y, baseZ + z); + for (final net.minecraft.core.Direction dir : net.minecraft.core.Direction.values()) { + final net.minecraft.world.phys.shapes.VoxelShape shape = state.getFaceOcclusionShape(chunk.getLevel(), localPos, dir); + if (net.minecraft.world.phys.shapes.Shapes.faceShapeOccludes(shape, net.minecraft.world.phys.shapes.Shapes.empty())) { + mask |= (byte) (1 << dir.ordinal()); + } + } + if (mask != 0) { + shapeList.add(plotLocal); + shapeMaskList.add(mask); + } + } + } + } + } + } + } + + plotLocalCache.put(id, new PlotLocalLightData( + opaqueList.toLongArray(), + emitterList.toLongArray(), + emitterLevelList.toByteArray(), + shapeList.toLongArray(), + shapeMaskList.toByteArray() + )); + } + + /** + * Projects cached plot-local data to world space using the current pose. Cheap — just matrix transforms. + */ + private static void projectToWorldSpace(final ServerSubLevel subLevel) { + final UUID id = subLevel.getUniqueId(); + final PlotLocalLightData cache = plotLocalCache.get(id); + if (cache == null || cache == PlotLocalLightData.EMPTY) { + cachedPlotSources.put(id, new Long2IntOpenHashMap()); + perSubLevelOpaque.put(id, new LongOpenHashSet()); + perSubLevelShapes.put(id, new it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap()); + return; + } + + final Pose3dc pose = subLevel.logicalPose(); + final Vector3d worldPos = new Vector3d(); + + // Project emitters + final Long2IntOpenHashMap emitters = new Long2IntOpenHashMap(); + emitters.defaultReturnValue(0); + for (int i = 0; i < cache.emitterPositions.length; i++) { + worldPos.set( + BlockPos.getX(cache.emitterPositions[i]) + 0.5, + BlockPos.getY(cache.emitterPositions[i]) + 0.5, + BlockPos.getZ(cache.emitterPositions[i]) + 0.5); + pose.transformPosition(worldPos); + final long packed = BlockPos.asLong((int) Math.floor(worldPos.x), (int) Math.floor(worldPos.y), (int) Math.floor(worldPos.z)); + final int emission = cache.emitterLevels[i] & 0xFF; + if (emission > emitters.get(packed)) { + emitters.put(packed, emission); + } + } + + // Project opaque: project plot-local block centres, then fill any cells along the line between two + // adjacent plot-local blocks whose world projections aren't axis-adjacent. Prevents diagonal light + // leaks under rotation. + final LongOpenHashSet opaque = new LongOpenHashSet(); + // First pass: project centres + final it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap plotToWorld = new it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap(); + for (final long plotLocal : cache.opaquePositions) { + worldPos.set(BlockPos.getX(plotLocal) + 0.5, BlockPos.getY(plotLocal) + 0.5, BlockPos.getZ(plotLocal) + 0.5); + pose.transformPosition(worldPos); + final long worldCell = BlockPos.asLong((int) Math.floor(worldPos.x), (int) Math.floor(worldPos.y), (int) Math.floor(worldPos.z)); + opaque.add(worldCell); + plotToWorld.put(plotLocal, worldCell); + } + // Save core (no gaps) for sub-level-to-sub-level projection + perSubLevelOpaqueCore.put(id, new LongOpenHashSet(opaque)); + // Second pass: fill gaps between adjacent plot-local blocks + for (final long plotLocal : cache.opaquePositions) { + final long wA = plotToWorld.get(plotLocal); + final int ax = BlockPos.getX(wA), ay = BlockPos.getY(wA), az = BlockPos.getZ(wA); + final int px = BlockPos.getX(plotLocal), py = BlockPos.getY(plotLocal), pz = BlockPos.getZ(plotLocal); + // Check 6 face + 12 edge neighbors in plot-local space + final long[] neighbors = { + BlockPos.asLong(px+1,py,pz), BlockPos.asLong(px-1,py,pz), + BlockPos.asLong(px,py+1,pz), BlockPos.asLong(px,py-1,pz), + BlockPos.asLong(px,py,pz+1), BlockPos.asLong(px,py,pz-1), + BlockPos.asLong(px+1,py+1,pz), BlockPos.asLong(px+1,py-1,pz), + BlockPos.asLong(px-1,py+1,pz), BlockPos.asLong(px-1,py-1,pz), + BlockPos.asLong(px+1,py,pz+1), BlockPos.asLong(px+1,py,pz-1), + BlockPos.asLong(px-1,py,pz+1), BlockPos.asLong(px-1,py,pz-1), + BlockPos.asLong(px,py+1,pz+1), BlockPos.asLong(px,py+1,pz-1), + BlockPos.asLong(px,py-1,pz+1), BlockPos.asLong(px,py-1,pz-1) + }; + for (final long neighborPlot : neighbors) { + if (!plotToWorld.containsKey(neighborPlot)) continue; + final long wB = plotToWorld.get(neighborPlot); + final int bx = BlockPos.getX(wB), by = BlockPos.getY(wB), bz = BlockPos.getZ(wB); + // If world cells are not adjacent (Manhattan distance > 1), fill all cells along the line + final int dist = Math.abs(ax-bx) + Math.abs(ay-by) + Math.abs(az-bz); + if (dist > 1) { + final int steps = Math.max(Math.max(Math.abs(ax-bx), Math.abs(ay-by)), Math.abs(az-bz)); + for (int s = 1; s < steps; s++) { + opaque.add(BlockPos.asLong( + ax + (bx-ax) * s / steps, + ay + (by-ay) * s / steps, + az + (bz-az) * s / steps)); + } + } + } + } + + // Project shapes + final it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap shapes = new it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap(); + shapes.defaultReturnValue((byte) 0); + for (int i = 0; i < cache.shapePositions.length; i++) { + worldPos.set( + BlockPos.getX(cache.shapePositions[i]) + 0.5, + BlockPos.getY(cache.shapePositions[i]) + 0.5, + BlockPos.getZ(cache.shapePositions[i]) + 0.5); + pose.transformPosition(worldPos); + final long packed = BlockPos.asLong((int) Math.floor(worldPos.x), (int) Math.floor(worldPos.y), (int) Math.floor(worldPos.z)); + shapes.put(packed, cache.shapeMasks[i]); + } + + cachedPlotSources.put(id, emitters); + perSubLevelOpaque.put(id, opaque); + perSubLevelShapes.put(id, shapes); + } + + /** + * Enqueues light injection work onto the light thread. + */ + private static void enqueueReinject(final ServerLevel level, final UUID id) { + final LevelLightEngine lightEngine = level.getLightEngine(); + if (!(lightEngine instanceof final ThreadedLevelLightEngine threadedEngine)) return; + if (threadedEngine.blockEngine == null) return; + + final Long2IntOpenHashMap sources = cachedPlotSources.get(id); + final LongSet oldPositions = injectedPositions.remove(id); + + threadedEngine.taskMailbox.tell(() -> { + if (threadedEngine.blockEngine == null) return; + + final LongSet sectionsToUpdate = new LongOpenHashSet(); + + // Clear old injected light + if (oldPositions != null && !oldPositions.isEmpty()) { + for (final long packed : oldPositions) { + threadedEngine.blockEngine.checkBlock(new BlockPos( + BlockPos.getX(packed), BlockPos.getY(packed), BlockPos.getZ(packed))); + addNeighborSections(sectionsToUpdate, packed); + } + threadedEngine.blockEngine.runLightUpdates(); + } + + // Inject new emitter positions + final LongSet newPositions = new LongOpenHashSet(); + if (sources != null && !sources.isEmpty()) { + for (final var entry : sources.long2IntEntrySet()) { + final long packed = entry.getLongKey(); + final int emission = entry.getIntValue(); + try { + threadedEngine.blockEngine.storage.setStoredLevel(packed, emission); + threadedEngine.blockEngine.enqueueIncrease( + packed, LightEngine.QueueEntry.increaseLightFromEmission(emission, true)); + newPositions.add(packed); + addNeighborSections(sectionsToUpdate, packed); + } catch (final NullPointerException ignored) {} + } + } + + if (!newPositions.isEmpty()) { + injectedPositions.put(id, newPositions); + } + + if (!sectionsToUpdate.isEmpty()) { + threadedEngine.blockEngine.runLightUpdates(); + threadedEngine.blockEngine.storage.swapSectionMap(); + level.getServer().execute(() -> sendWorldLightUpdates(level, sectionsToUpdate)); + } + }); + + threadedEngine.tryScheduleUpdate(); + } + + private static void addNeighborSections(final LongSet out, final long packed) { + final int sx = SectionPos.blockToSectionCoord(BlockPos.getX(packed)); + final int sy = SectionPos.blockToSectionCoord(BlockPos.getY(packed)); + final int sz = SectionPos.blockToSectionCoord(BlockPos.getZ(packed)); + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + for (int dz = -1; dz <= 1; dz++) { + out.add(SectionPos.asLong(sx + dx, sy + dy, sz + dz)); + } + } + } + } + + private static void sendWorldLightUpdates(final ServerLevel level, final LongSet sections) { + if (sections.isEmpty()) return; + + final LongSet sentChunks = new LongOpenHashSet(); + for (final long sec : sections) { + final int sx = SectionPos.x(sec); + final int sz = SectionPos.z(sec); + final long chunkKey = ChunkPos.asLong(sx, sz); + if (!sentChunks.add(chunkKey)) continue; + + final ChunkPos chunkPos = new ChunkPos(sx, sz); + if (level.getChunkSource().getChunkNow(sx, sz) == null) continue; + + for (final ServerPlayer player : level.players()) { + if (player.getChunkTrackingView().contains(chunkPos)) { + player.connection.send(new net.minecraft.network.protocol.game.ClientboundLightUpdatePacket( + chunkPos, level.getLightEngine(), null, null)); + } + } + } + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java index 1bc7964e..5565db78 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java @@ -11,6 +11,7 @@ import dev.ryanhcode.sable.index.SableTags; import dev.ryanhcode.sable.mixinterface.plot.serialization.LevelChunkTicksExtension; import dev.ryanhcode.sable.platform.SablePlotPlatform; +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelLightInjector; import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.SubLevel; import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem; @@ -127,11 +128,18 @@ private static void logLoadingErrors(final ChunkPos chunkPos, final int y, final */ @Override public void tick() { + // Pull in any nearby world emitters before we start propagating. + final ServerSubLevel sl = this.getSubLevel(); + ServerSubLevelLightInjector.tickPlot(sl.getLevel(), sl, this); + do { this.lightEngine.runLightUpdates(); } while (this.lightEngine.hasLightWork()); this.contraptions.removeIf(contraption -> !contraption.sable$isValid()); + + // Once propagation has settled, ship the resulting light data to tracking players. + ServerSubLevelLightInjector.afterPlotTick(sl.getLevel(), sl, this); } /** diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/SubLevelPlayerChunkSender.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/SubLevelPlayerChunkSender.java index da36c458..d1d6053e 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/SubLevelPlayerChunkSender.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/SubLevelPlayerChunkSender.java @@ -3,6 +3,7 @@ import net.minecraft.network.protocol.Packet; import net.minecraft.network.protocol.game.ClientGamePacketListener; import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; +import net.minecraft.network.protocol.game.ClientboundLightUpdatePacket; import net.minecraft.network.protocol.game.DebugPackets; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.ChunkPos; @@ -28,4 +29,12 @@ public static void sendChunkPoiData(final ServerLevel level, final LevelChunk ch DebugPackets.sendPoiPacketsForChunk(level, chunkPos); } + /** + * Refreshes only the light data for a chunk on a single client, without resending block data. + * Used after the plot light engine repropagates to push the new values without triggering a full chunk reload. + */ + public static void sendLightUpdate(final Consumer> listener, final LevelLightEngine lightEngine, final ChunkPos pos) { + listener.accept(new ClientboundLightUpdatePacket(pos, lightEngine, null, null)); + } + } diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/render/AbstractSableMixinPlugin.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/render/AbstractSableMixinPlugin.java new file mode 100644 index 00000000..9613ef1a --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/render/AbstractSableMixinPlugin.java @@ -0,0 +1,76 @@ +package dev.ryanhcode.sable.sublevel.render; + +import com.mojang.logging.LogUtils; +import foundry.veil.Veil; +import foundry.veil.api.compat.SodiumCompat; +import it.unimi.dsi.fastutil.objects.Object2BooleanMap; +import it.unimi.dsi.fastutil.objects.Object2BooleanOpenHashMap; +import org.slf4j.Logger; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; + +import java.util.List; +import java.util.Set; + +public abstract class AbstractSableMixinPlugin implements IMixinConfigPlugin { + public static final Logger LOGGER = LogUtils.getLogger(); + private final Object2BooleanMap modLoadedCache = new Object2BooleanOpenHashMap<>(); + private boolean sodiumPresent; + private boolean lithiumPresent; + + @Override + public void onLoad(final String mixinPackage) { + this.sodiumPresent = SodiumCompat.isLoaded(); + this.lithiumPresent = Veil.platform().isModLoaded("lithium"); + + if (this.sodiumPresent) { + LOGGER.info("Using Sodium renderer mixins"); + } else { + LOGGER.info("Using Vanilla renderer mixins"); + } + } + + @Override + public String getRefMapperConfig() { + return null; + } + + @Override + public boolean shouldApplyMixin(final String targetClassName, final String mixinClassName) { + if (mixinClassName.startsWith("dev.ryanhcode.sable.mixin.sublevel_render.impl")) { + return this.sodiumPresent ? mixinClassName.startsWith("dev.ryanhcode.sable.mixin.sublevel_render.impl.sodium") : mixinClassName.startsWith("dev.ryanhcode.sable.mixin.sublevel_render.impl.vanilla"); + } + + if (mixinClassName.startsWith("dev.ryanhcode.sable.mixin.plot.lighting.sodium")) { + return this.sodiumPresent; + } + + if (mixinClassName.startsWith("dev.ryanhcode.sable.mixin.compatibility.lithium")) { + return this.lithiumPresent; + } + + if (mixinClassName.startsWith("dev.ryanhcode.sable.mixin.compatibility.") || + mixinClassName.startsWith("dev.ryanhcode.sable.neoforge.mixin.compatibility.") || + mixinClassName.startsWith("dev.ryanhcode.sable.fabric.mixin.compatibility.") + ) { + final String[] parts = mixinClassName.split("\\."); + if (parts.length < 5) { + return true; + } + + final String modid = parts[3].equals("mixin") ? parts[5] : parts[6]; + return this.modLoadedCache.computeIfAbsent(modid, x -> Veil.platform().isModLoaded(modid)); + } + + return true; + } + + @Override + public void acceptTargets(final Set myTargets, final Set otherTargets) { + } + + @Override + public List getMixins() { + return null; + } + +} diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/serialization/SubLevelSerializer.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/serialization/SubLevelSerializer.java index 41feb364..511a9f1d 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/serialization/SubLevelSerializer.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/serialization/SubLevelSerializer.java @@ -11,6 +11,8 @@ import dev.ryanhcode.sable.companion.math.Pose3d; import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.plot.ServerLevelPlot; +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelLightInjector; +import dev.ryanhcode.sable.render.light_bridge.ServerSubLevelWorldInjector; import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason; import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem; import dev.ryanhcode.sable.util.SableNBTUtils; @@ -194,6 +196,10 @@ public static ServerSubLevel fullyLoad(final ServerLevel level, final SubLevelDa subLevel.setUserDataTag(tag.getCompound("user_data")); } + // Ensure the light injectors pick up emitters on the next tick. + ServerSubLevelLightInjector.markNeedsFullRescan(subLevel.getUniqueId()); + ServerSubLevelWorldInjector.markNeedsFullRescan(subLevel.getUniqueId()); + return subLevel; } diff --git a/common/src/main/resources/META-INF/accesstransformer.cfg b/common/src/main/resources/META-INF/accesstransformer.cfg index f24e1c95..f04201b7 100644 --- a/common/src/main/resources/META-INF/accesstransformer.cfg +++ b/common/src/main/resources/META-INF/accesstransformer.cfg @@ -27,6 +27,8 @@ public net.minecraft.world.level.lighting.LevelLightEngine skyEngine public net.minecraft.world.level.lighting.LevelLightEngine blockEngine public net.minecraft.server.level.ThreadedLevelLightEngine runUpdate()V public net.minecraft.server.level.ThreadedLevelLightEngine updateChunkStatus(Lnet/minecraft/world/level/ChunkPos;)V +public net.minecraft.server.level.ThreadedLevelLightEngine taskMailbox + public net.minecraft.client.renderer.block.ModelBlockRenderer CACHE public net.minecraft.server.network.PlayerChunkSender sendChunk(Lnet/minecraft/server/network/ServerGamePacketListenerImpl;Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/level/chunk/LevelChunk;)V public net.minecraft.server.network.ServerCommonPacketListenerImpl connection @@ -65,3 +67,14 @@ public net.minecraft.server.level.ChunkMap getChunks()Ljava/lang/Iterable; public net.minecraft.server.level.ChunkMap saveChunkIfNeeded(Lnet/minecraft/server/level/ChunkHolder;)Z public net.minecraft.server.level.ServerLevel entityManager + +# Sable light bridge - access to light engine internals for world→sub-level light injection +public net.minecraft.world.level.lighting.LightEngine storage +public net.minecraft.world.level.lighting.LayerLightSectionStorage setStoredLevel(JI)V +public net.minecraft.world.level.lighting.LayerLightSectionStorage swapSectionMap()V + +public net.minecraft.world.level.lighting.LightEngine enqueueIncrease(JJ)V +public net.minecraft.world.level.lighting.LightEngine enqueueDecrease(JJ)V +public net.minecraft.world.level.lighting.LightEngine$QueueEntry increaseLightFromEmission(IZ)J + +public net.minecraft.world.level.lighting.LayerLightSectionStorage getStoredLevel(J)I diff --git a/common/src/main/resources/natives/sable_rapier/sable_rapier_binaries.zip.l4z b/common/src/main/resources/natives/sable_rapier/sable_rapier_binaries.zip.l4z index 022e3039..5d54e938 100644 Binary files a/common/src/main/resources/natives/sable_rapier/sable_rapier_binaries.zip.l4z and b/common/src/main/resources/natives/sable_rapier/sable_rapier_binaries.zip.l4z differ diff --git a/common/src/main/resources/sable.accesswidener b/common/src/main/resources/sable.accesswidener index 0258ae45..0a1efcfc 100644 --- a/common/src/main/resources/sable.accesswidener +++ b/common/src/main/resources/sable.accesswidener @@ -36,6 +36,20 @@ accessible field net/minecraft/world/level/lighting/LevelLightEngine blockEngine accessible field net/minecraft/world/level/lighting/LevelLightEngine skyEngine Lnet/minecraft/world/level/lighting/LightEngine; accessible method net/minecraft/server/level/ThreadedLevelLightEngine runUpdate ()V +# Sable light bridge - access to ThreadedLevelLightEngine task mailbox for plot→world injection +accessible field net/minecraft/server/level/ThreadedLevelLightEngine taskMailbox Lnet/minecraft/util/thread/ProcessorMailbox; + +# Sable light bridge - access to light engine internals for light injection +accessible field net/minecraft/world/level/lighting/LightEngine storage Lnet/minecraft/world/level/lighting/LayerLightSectionStorage; +accessible method net/minecraft/world/level/lighting/LayerLightSectionStorage setStoredLevel (JI)V +accessible method net/minecraft/world/level/lighting/LayerLightSectionStorage swapSectionMap ()V +accessible method net/minecraft/world/level/lighting/LayerLightSectionStorage getStoredLevel (J)I +accessible method net/minecraft/world/level/lighting/LightEngine enqueueIncrease (JJ)V +accessible method net/minecraft/world/level/lighting/LightEngine enqueueDecrease (JJ)V +accessible class net/minecraft/world/level/lighting/LightEngine$QueueEntry +accessible method net/minecraft/world/level/lighting/LightEngine$QueueEntry increaseLightFromEmission (IZ)J +accessible method net/minecraft/world/level/lighting/LightEngine$QueueEntry decreaseAllDirections (I)J + extendable method net/minecraft/server/level/GenerationChunkHolder rescheduleChunkTask (Lnet/minecraft/server/level/ChunkMap;Lnet/minecraft/world/level/chunk/status/ChunkStatus;)V accessible method net/minecraft/server/level/ThreadedLevelLightEngine updateChunkStatus (Lnet/minecraft/world/level/ChunkPos;)V diff --git a/common/src/main/resources/sable.mixins.json b/common/src/main/resources/sable.mixins.json index ad2263af..d7fc1e03 100644 --- a/common/src/main/resources/sable.mixins.json +++ b/common/src/main/resources/sable.mixins.json @@ -186,6 +186,9 @@ "plot.ServerLevelMixin", "plot.lighting.BlockAndTintGetterMixin", "plot.lighting.LevelChunkMixin", + "plot.lighting.server.ServerChunkCacheLightMixin", + "plot.lighting.server.LightEngineOpacityMixin", + "plot.serialization.ChunkMapMixin", "plot.serialization.LevelChunkTicksMixin", "portal.EntityMixin",