diff --git a/demo/src/main/java/net/minestom/demo/PlayerInit.java b/demo/src/main/java/net/minestom/demo/PlayerInit.java index 72003b6f6..d4ddb1e4a 100644 --- a/demo/src/main/java/net/minestom/demo/PlayerInit.java +++ b/demo/src/main/java/net/minestom/demo/PlayerInit.java @@ -20,6 +20,7 @@ import net.minestom.server.event.server.ServerTickMonitorEvent; import net.minestom.server.instance.Instance; import net.minestom.server.instance.InstanceContainer; import net.minestom.server.instance.InstanceManager; +import net.minestom.server.instance.LightingChunk; import net.minestom.server.instance.block.Block; import net.minestom.server.inventory.Inventory; import net.minestom.server.inventory.InventoryType; @@ -123,6 +124,7 @@ public class PlayerInit { InstanceContainer instanceContainer = instanceManager.createInstanceContainer(DimensionType.OVERWORLD); instanceContainer.setGenerator(unit -> unit.modifier().fillHeight(0, 40, Block.STONE)); + instanceContainer.setChunkSupplier(LightingChunk::new); if (false) { System.out.println("start"); diff --git a/src/main/java/net/minestom/server/collision/BoundingBox.java b/src/main/java/net/minestom/server/collision/BoundingBox.java index 6e7599a85..38e35ec22 100644 --- a/src/main/java/net/minestom/server/collision/BoundingBox.java +++ b/src/main/java/net/minestom/server/collision/BoundingBox.java @@ -4,6 +4,7 @@ import net.minestom.server.coordinate.Point; import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Entity; +import net.minestom.server.instance.block.BlockFace; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -33,6 +34,11 @@ public final class BoundingBox implements Shape { this(width, height, depth, new Vec(-width / 2, 0, -depth / 2)); } + @Override + public boolean isOccluded(@NotNull Shape shape, @NotNull BlockFace face) { + return false; + } + @Override @ApiStatus.Experimental public boolean intersectBox(@NotNull Point positionRelative, @NotNull BoundingBox boundingBox) { diff --git a/src/main/java/net/minestom/server/collision/CollisionUtils.java b/src/main/java/net/minestom/server/collision/CollisionUtils.java index 43c34a592..dacc00c2b 100644 --- a/src/main/java/net/minestom/server/collision/CollisionUtils.java +++ b/src/main/java/net/minestom/server/collision/CollisionUtils.java @@ -113,7 +113,7 @@ public final class CollisionUtils { }; } - public static Shape parseBlockShape(String str, Registry.BlockEntry blockEntry) { - return ShapeImpl.parseBlockFromRegistry(str, blockEntry); + public static Shape parseBlockShape(String collision, String occlusion, Registry.BlockEntry blockEntry) { + return ShapeImpl.parseBlockFromRegistry(collision, occlusion, blockEntry); } } diff --git a/src/main/java/net/minestom/server/collision/Shape.java b/src/main/java/net/minestom/server/collision/Shape.java index c4f7a04eb..3b2db3d17 100644 --- a/src/main/java/net/minestom/server/collision/Shape.java +++ b/src/main/java/net/minestom/server/collision/Shape.java @@ -1,11 +1,14 @@ package net.minestom.server.collision; import net.minestom.server.coordinate.Point; +import net.minestom.server.instance.block.BlockFace; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @ApiStatus.Experimental public interface Shape { + boolean isOccluded(@NotNull Shape shape, @NotNull BlockFace face); + /** * Checks if two bounding boxes intersect. * diff --git a/src/main/java/net/minestom/server/collision/ShapeImpl.java b/src/main/java/net/minestom/server/collision/ShapeImpl.java index 870c17c2a..3a3f691e2 100644 --- a/src/main/java/net/minestom/server/collision/ShapeImpl.java +++ b/src/main/java/net/minestom/server/collision/ShapeImpl.java @@ -5,28 +5,37 @@ import it.unimi.dsi.fastutil.doubles.DoubleList; import net.minestom.server.coordinate.Point; import net.minestom.server.coordinate.Vec; import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.block.BlockFace; import net.minestom.server.registry.Registry; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; final class ShapeImpl implements Shape { private static final Pattern PATTERN = Pattern.compile("\\d.\\d{1,3}", Pattern.MULTILINE); - private final BoundingBox[] blockSections; + private final BoundingBox[] collisionBoundingBoxes; private final Point relativeStart, relativeEnd; + private final BoundingBox[] occlusionBoundingBoxes; + private final byte blockOcclusion; + private final byte airOcclusion; + private final Registry.BlockEntry blockEntry; private Block block; - private ShapeImpl(BoundingBox[] boundingBoxes, Registry.BlockEntry blockEntry) { - this.blockSections = boundingBoxes; + private ShapeImpl(BoundingBox[] boundingBoxes, BoundingBox[] occlusionBoundingBoxes, Registry.BlockEntry blockEntry) { + this.collisionBoundingBoxes = boundingBoxes; + this.occlusionBoundingBoxes = occlusionBoundingBoxes; this.blockEntry = blockEntry; - // Find bounds + + // Find bounds of collision { double minX = 1, minY = 1, minZ = 1; double maxX = 0, maxY = 0, maxZ = 0; - for (BoundingBox blockSection : blockSections) { + for (BoundingBox blockSection : collisionBoundingBoxes) { // Min if (blockSection.minX() < minX) minX = blockSection.minX(); if (blockSection.minY() < minY) minY = blockSection.minY(); @@ -39,16 +48,26 @@ final class ShapeImpl implements Shape { this.relativeStart = new Vec(minX, minY, minZ); this.relativeEnd = new Vec(maxX, maxY, maxZ); } + + byte airFaces = 0; + byte fullFaces = 0; + for (BlockFace f : BlockFace.values()) { + final byte res = isFaceCovered(computeOcclusionSet(f)); + airFaces |= ((res == 0) ? 0b1 : 0b0) << (byte) f.ordinal(); + fullFaces |= ((res == 2) ? 0b1 : 0b0) << (byte) f.ordinal(); + } + + this.airOcclusion = airFaces; + this.blockOcclusion = fullFaces; } - static ShapeImpl parseBlockFromRegistry(String str, Registry.BlockEntry blockEntry) { + static private BoundingBox[] parseRegistryBoundingBoxString(String str) { final Matcher matcher = PATTERN.matcher(str); DoubleList vals = new DoubleArrayList(); while (matcher.find()) { double newVal = Double.parseDouble(matcher.group()); vals.add(newVal); } - final int count = vals.size() / 6; BoundingBox[] boundingBoxes = new BoundingBox[count]; for (int i = 0; i < count; ++i) { @@ -66,7 +85,36 @@ final class ShapeImpl implements Shape { assert bb.minZ() == minZ; boundingBoxes[i] = bb; } - return new ShapeImpl(boundingBoxes, blockEntry); + return boundingBoxes; + } + + /** + * Computes the occlusion for a given face. + * + * @param covering The rectangle set to check for covering. + * @return 0 if face is not covered, 1 if face is covered partially, 2 if face is fully covered. + */ + private static byte isFaceCovered(List covering) { + if (covering.isEmpty()) return 0; + Rectangle r = new Rectangle(0, 0, 1, 1); + List toCover = new ArrayList<>(); + toCover.add(r); + for (Rectangle rect : covering) { + List nextCovering = new ArrayList<>(); + for (Rectangle toCoverRect : toCover) { + List remaining = getRemaining(rect, toCoverRect); + nextCovering.addAll(remaining); + } + toCover = nextCovering; + if (toCover.isEmpty()) return 2; + } + return 1; + } + + static ShapeImpl parseBlockFromRegistry(String collision, String occlusion, Registry.BlockEntry blockEntry) { + BoundingBox[] collisionBoundingBoxes = parseRegistryBoundingBoxString(collision); + BoundingBox[] occlusionBoundingBoxes = blockEntry.occludes() ? parseRegistryBoundingBoxString(occlusion) : new BoundingBox[0]; + return new ShapeImpl(collisionBoundingBoxes, occlusionBoundingBoxes, blockEntry); } @Override @@ -79,9 +127,32 @@ final class ShapeImpl implements Shape { return relativeEnd; } + @Override + public boolean isOccluded(@NotNull Shape shape, @NotNull BlockFace face) { + final ShapeImpl shapeImpl = ((ShapeImpl) shape); + final boolean hasBlockOcclusion = (((blockOcclusion >> face.ordinal()) & 1) == 1); + final boolean hasBlockOcclusionOther = ((shapeImpl.blockOcclusion >> face.getOppositeFace().ordinal()) & 1) == 1; + + if (blockEntry.lightEmission() > 0) return hasBlockOcclusionOther; + + // If either face is full, return true + if (hasBlockOcclusion || hasBlockOcclusionOther) return true; + + final boolean hasAirOcclusion = (((airOcclusion >> face.ordinal()) & 1) == 1); + final boolean hasAirOcclusionOther = ((shapeImpl.airOcclusion >> face.getOppositeFace().ordinal()) & 1) == 1; + + // If a single face is air, return false + if (hasAirOcclusion || hasAirOcclusionOther) return false; + + // Comparing two partial faces. Computation needed + List allRectangles = shapeImpl.computeOcclusionSet(face.getOppositeFace()); + allRectangles.addAll(computeOcclusionSet(face)); + return isFaceCovered(allRectangles) == 2; + } + @Override public boolean intersectBox(@NotNull Point position, @NotNull BoundingBox boundingBox) { - for (BoundingBox blockSection : blockSections) { + for (BoundingBox blockSection : collisionBoundingBoxes) { if (boundingBox.intersectBox(position, blockSection)) return true; } return false; @@ -91,7 +162,7 @@ final class ShapeImpl implements Shape { public boolean intersectBoxSwept(@NotNull Point rayStart, @NotNull Point rayDirection, @NotNull Point shapePos, @NotNull BoundingBox moving, @NotNull SweepResult finalResult) { boolean hitBlock = false; - for (BoundingBox blockSection : blockSections) { + for (BoundingBox blockSection : collisionBoundingBoxes) { // Update final result if the temp result collision is sooner than the current final result if (RayUtils.BoundingBoxIntersectionCheck(moving, rayStart, rayDirection, blockSection, shapePos, finalResult)) { finalResult.collidedShapePosition = shapePos; @@ -108,4 +179,75 @@ final class ShapeImpl implements Shape { if (block == null) this.block = block = Block.fromStateId((short) blockEntry.stateId()); return block; } + + private List computeOcclusionSet(BlockFace face) { + List rSet = new ArrayList<>(); + for (BoundingBox boundingBox : this.occlusionBoundingBoxes) { + switch (face) { + case NORTH -> // negative Z + { + if (boundingBox.minZ() == 0) + rSet.add(new Rectangle(boundingBox.minX(), boundingBox.minY(), boundingBox.maxX(), boundingBox.maxY())); + } + case SOUTH -> // positive Z + { + if (boundingBox.maxZ() == 1) + rSet.add(new Rectangle(boundingBox.minX(), boundingBox.minY(), boundingBox.maxX(), boundingBox.maxY())); + } + case WEST -> // negative X + { + if (boundingBox.minX() == 0) + rSet.add(new Rectangle(boundingBox.minY(), boundingBox.minZ(), boundingBox.maxY(), boundingBox.maxZ())); + } + case EAST -> // positive X + { + if (boundingBox.maxX() == 1) + rSet.add(new Rectangle(boundingBox.minY(), boundingBox.minZ(), boundingBox.maxY(), boundingBox.maxZ())); + } + case BOTTOM -> // negative Y + { + if (boundingBox.minY() == 0) + rSet.add(new Rectangle(boundingBox.minX(), boundingBox.minZ(), boundingBox.maxX(), boundingBox.maxZ())); + } + case TOP -> // positive Y + { + if (boundingBox.maxY() == 1) + rSet.add(new Rectangle(boundingBox.minX(), boundingBox.minZ(), boundingBox.maxX(), boundingBox.maxZ())); + } + } + } + return rSet; + } + + private static List getRemaining(Rectangle covering, Rectangle toCover) { + List remaining = new ArrayList<>(); + covering = clipRectangle(covering, toCover); + // Up + if (covering.y1() > toCover.y1()) { + remaining.add(new Rectangle(toCover.x1(), toCover.y1(), toCover.x2(), covering.y1())); + } + // Down + if (covering.y2() < toCover.y2()) { + remaining.add(new Rectangle(toCover.x1(), covering.y2(), toCover.x2(), toCover.y2())); + } + // Left + if (covering.x1() > toCover.x1()) { + remaining.add(new Rectangle(toCover.x1(), covering.y1(), covering.x1(), covering.y2())); + } + //Right + if (covering.x2() < toCover.x2()) { + remaining.add(new Rectangle(covering.x2(), covering.y1(), toCover.x2(), covering.y2())); + } + return remaining; + } + + private static Rectangle clipRectangle(Rectangle covering, Rectangle toCover) { + final double x1 = Math.max(covering.x1(), toCover.x1()); + final double y1 = Math.max(covering.y1(), toCover.y1()); + final double x2 = Math.min(covering.x2(), toCover.x2()); + final double y2 = Math.min(covering.y2(), toCover.y2()); + return new Rectangle(x1, y1, x2, y2); + } + + private record Rectangle(double x1, double y1, double x2, double y2) { } } diff --git a/src/main/java/net/minestom/server/instance/AnvilLoader.java b/src/main/java/net/minestom/server/instance/AnvilLoader.java index 9111770d8..6cffffb24 100644 --- a/src/main/java/net/minestom/server/instance/AnvilLoader.java +++ b/src/main/java/net/minestom/server/instance/AnvilLoader.java @@ -101,7 +101,7 @@ public class AnvilLoader implements IChunkLoader { final ChunkReader chunkReader = new ChunkReader(chunkData); - Chunk chunk = new DynamicChunk(instance, chunkX, chunkZ); + Chunk chunk = instance.getChunkSupplier().createChunk(instance, chunkX, chunkZ); synchronized (chunk) { var yRange = chunkReader.getYRange(); if (yRange.getStart() < instance.getDimensionType().getMinY()) { @@ -375,8 +375,8 @@ public class AnvilLoader implements IChunkLoader { ChunkSectionWriter sectionWriter = new ChunkSectionWriter(SupportedVersion.Companion.getLatest(), (byte) sectionY); Section section = chunk.getSection(sectionY); - sectionWriter.setSkyLights(section.getSkyLight()); - sectionWriter.setBlockLights(section.getBlockLight()); + sectionWriter.setSkyLights(section.skyLight().array()); + sectionWriter.setBlockLights(section.blockLight().array()); BiomePalette biomePalette = new BiomePalette(); BlockPalette blockPalette = new BlockPalette(); diff --git a/src/main/java/net/minestom/server/instance/DynamicChunk.java b/src/main/java/net/minestom/server/instance/DynamicChunk.java index 027b182c8..a9db7d7cd 100644 --- a/src/main/java/net/minestom/server/instance/DynamicChunk.java +++ b/src/main/java/net/minestom/server/instance/DynamicChunk.java @@ -39,7 +39,7 @@ import static net.minestom.server.utils.chunk.ChunkUtils.toSectionRelativeCoordi */ public class DynamicChunk extends Chunk { - private List
sections; + protected List
sections; // Key = ChunkUtils#getBlockIndex protected final Int2ObjectOpenHashMap entries = new Int2ObjectOpenHashMap<>(0); @@ -47,7 +47,6 @@ public class DynamicChunk extends Chunk { private long lastChange; final CachedPacket chunkCache = new CachedPacket(this::createChunkPacket); - final CachedPacket lightCache = new CachedPacket(this::createLightPacket); public DynamicChunk(@NotNull Instance instance, int chunkX, int chunkZ) { super(instance, chunkX, chunkZ, true); @@ -61,7 +60,7 @@ public class DynamicChunk extends Chunk { assertLock(); this.lastChange = System.currentTimeMillis(); this.chunkCache.invalidate(); - this.lightCache.invalidate(); + // Update pathfinder if (columnarSpace != null) { final ColumnarOcclusionFieldList columnarOcclusionFieldList = columnarSpace.occlusionFields(); @@ -183,7 +182,7 @@ public class DynamicChunk extends Chunk { this.entries.clear(); } - private synchronized @NotNull ChunkDataPacket createChunkPacket() { + private @NotNull ChunkDataPacket createChunkPacket() { final NBTCompound heightmapsNBT; // TODO: don't hardcode heightmaps // Heightmap @@ -203,20 +202,25 @@ public class DynamicChunk extends Chunk { "WORLD_SURFACE", NBT.LongArray(encodeBlocks(worldSurface, bitsForHeight)))); } // Data - final byte[] data = ObjectPool.PACKET_POOL.use(buffer -> - NetworkBuffer.makeArray(networkBuffer -> { - for (Section section : sections) networkBuffer.write(section); - })); + + final byte[] data; + synchronized (this) { + data = ObjectPool.PACKET_POOL.use(buffer -> + NetworkBuffer.makeArray(networkBuffer -> { + for (Section section : sections) networkBuffer.write(section); + })); + } + return new ChunkDataPacket(chunkX, chunkZ, new ChunkData(heightmapsNBT, data, entries), - createLightData()); + createLightData(true)); } - private synchronized @NotNull UpdateLightPacket createLightPacket() { - return new UpdateLightPacket(chunkX, chunkZ, createLightData()); + @NotNull UpdateLightPacket createLightPacket() { + return new UpdateLightPacket(chunkX, chunkZ, createLightData(false)); } - private LightData createLightData() { + protected LightData createLightData(boolean sendAll) { BitSet skyMask = new BitSet(); BitSet blockMask = new BitSet(); BitSet emptySkyMask = new BitSet(); @@ -227,8 +231,8 @@ public class DynamicChunk extends Chunk { int index = 0; for (Section section : sections) { index++; - final byte[] skyLight = section.getSkyLight(); - final byte[] blockLight = section.getBlockLight(); + final byte[] skyLight = section.skyLight().array(); + final byte[] blockLight = section.blockLight().array(); if (skyLight.length != 0) { skyLights.add(skyLight); skyMask.set(index); diff --git a/src/main/java/net/minestom/server/instance/Instance.java b/src/main/java/net/minestom/server/instance/Instance.java index b7c5affa6..00c5c4bec 100644 --- a/src/main/java/net/minestom/server/instance/Instance.java +++ b/src/main/java/net/minestom/server/instance/Instance.java @@ -34,6 +34,7 @@ import net.minestom.server.timer.Scheduler; import net.minestom.server.utils.ArrayUtils; import net.minestom.server.utils.PacketUtils; import net.minestom.server.utils.chunk.ChunkCache; +import net.minestom.server.utils.chunk.ChunkSupplier; import net.minestom.server.utils.chunk.ChunkUtils; import net.minestom.server.utils.time.Cooldown; import net.minestom.server.utils.time.TimeUnit; @@ -278,6 +279,14 @@ public abstract class Instance implements Block.Getter, Block.Setter, setGenerator(chunkGenerator != null ? new ChunkGeneratorCompatibilityLayer(chunkGenerator) : null); } + public abstract void setChunkSupplier(@NotNull ChunkSupplier chunkSupplier); + + /** + * Gets the chunk supplier of the instance. + * @return the chunk supplier of the instance + */ + public abstract ChunkSupplier getChunkSupplier(); + /** * Gets the generator associated with the instance * diff --git a/src/main/java/net/minestom/server/instance/InstanceContainer.java b/src/main/java/net/minestom/server/instance/InstanceContainer.java index 1c222f04d..6ec45737b 100644 --- a/src/main/java/net/minestom/server/instance/InstanceContainer.java +++ b/src/main/java/net/minestom/server/instance/InstanceContainer.java @@ -328,9 +328,11 @@ public class InstanceContainer extends Instance { if (forkChunk != null) { applyFork(forkChunk, sectionModifier); // Update players - if (forkChunk instanceof DynamicChunk dynamicChunk) { + if (forkChunk instanceof LightingChunk lightingChunk) { + lightingChunk.chunkCache.invalidate(); + lightingChunk.lightCache.invalidate(); + } else if (forkChunk instanceof DynamicChunk dynamicChunk) { dynamicChunk.chunkCache.invalidate(); - dynamicChunk.lightCache.invalidate(); } forkChunk.sendChunk(); } else { @@ -427,6 +429,7 @@ public class InstanceContainer extends Instance { * @param chunkSupplier the new {@link ChunkSupplier} of this instance, chunks need to be non-null * @throws NullPointerException if {@code chunkSupplier} is null */ + @Override public void setChunkSupplier(@NotNull ChunkSupplier chunkSupplier) { this.chunkSupplier = chunkSupplier; } diff --git a/src/main/java/net/minestom/server/instance/LightingChunk.java b/src/main/java/net/minestom/server/instance/LightingChunk.java new file mode 100644 index 000000000..f5409c08c --- /dev/null +++ b/src/main/java/net/minestom/server/instance/LightingChunk.java @@ -0,0 +1,384 @@ +package net.minestom.server.instance; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.collision.Shape; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.block.BlockFace; +import net.minestom.server.instance.light.Light; +import net.minestom.server.network.packet.server.CachedPacket; +import net.minestom.server.network.packet.server.play.data.LightData; +import net.minestom.server.timer.ExecutionType; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import net.minestom.server.utils.NamespaceID; +import net.minestom.server.utils.chunk.ChunkUtils; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +public class LightingChunk extends DynamicChunk { + private int[] heightmap; + final CachedPacket lightCache = new CachedPacket(this::createLightPacket); + boolean sendNeighbours = true; + + enum LightType { + SKY, + BLOCK + } + + private static final Set DIFFUSE_SKY_LIGHT = Set.of( + Block.COBWEB.namespace(), + Block.ICE.namespace(), + Block.HONEY_BLOCK.namespace(), + Block.SLIME_BLOCK.namespace(), + Block.WATER.namespace(), + Block.ACACIA_LEAVES.namespace(), + Block.AZALEA_LEAVES.namespace(), + Block.BIRCH_LEAVES.namespace(), + Block.DARK_OAK_LEAVES.namespace(), + Block.FLOWERING_AZALEA_LEAVES.namespace(), + Block.JUNGLE_LEAVES.namespace(), + Block.OAK_LEAVES.namespace(), + Block.SPRUCE_LEAVES.namespace(), + Block.SPAWNER.namespace(), + Block.BEACON.namespace(), + Block.END_GATEWAY.namespace(), + Block.CHORUS_PLANT.namespace(), + Block.CHORUS_FLOWER.namespace(), + Block.FROSTED_ICE.namespace(), + Block.SEAGRASS.namespace(), + Block.TALL_SEAGRASS.namespace(), + Block.LAVA.namespace() + ); + + public LightingChunk(@NotNull Instance instance, int chunkX, int chunkZ) { + super(instance, chunkX, chunkZ); + } + + private boolean checkSkyOcclusion(Block block) { + if (block == Block.AIR) return false; + if (DIFFUSE_SKY_LIGHT.contains(block.namespace())) return true; + + Shape shape = block.registry().collisionShape(); + boolean occludesTop = Block.AIR.registry().collisionShape().isOccluded(shape, BlockFace.TOP); + boolean occludesBottom = Block.AIR.registry().collisionShape().isOccluded(shape, BlockFace.BOTTOM); + + return occludesBottom || occludesTop; + } + + private void invalidateSection(int coordinate) { + for (int i = -1; i <= 1; i++) { + for (int j = -1; j <= 1; j++) { + Chunk neighborChunk = instance.getChunk(chunkX + i, chunkZ + j); + if (neighborChunk == null) continue; + + if (neighborChunk instanceof LightingChunk lightingChunk) { + lightingChunk.lightCache.invalidate(); + lightingChunk.chunkCache.invalidate(); + } + + for (int k = -1; k <= 1; k++) { + if (k + coordinate < neighborChunk.getMinSection() || k + coordinate >= neighborChunk.getMaxSection()) continue; + neighborChunk.getSection(k + coordinate).blockLight().invalidate(); + neighborChunk.getSection(k + coordinate).skyLight().invalidate(); + } + } + } + } + + @Override + public void setBlock(int x, int y, int z, @NotNull Block block) { + super.setBlock(x, y, z, block); + this.heightmap = null; + + // Invalidate neighbor chunks, since they can be updated by this block change + int coordinate = ChunkUtils.getChunkCoordinate(y); + invalidateSection(coordinate); + + this.lightCache.invalidate(); + } + + public void sendLighting() { + if (!isLoaded()) return; + sendPacketToViewers(lightCache); + } + + public int[] calculateHeightMap() { + if (this.heightmap != null) return this.heightmap; + var heightmap = new int[CHUNK_SIZE_X * CHUNK_SIZE_Z]; + + int minY = instance.getDimensionType().getMinY(); + int maxY = instance.getDimensionType().getMinY() + instance.getDimensionType().getHeight(); + + synchronized (this) { + for (int x = 0; x < CHUNK_SIZE_X; x++) { + for (int z = 0; z < CHUNK_SIZE_Z; z++) { + int height = maxY; + while (height > minY) { + Block block = getBlock(x, height, z, Condition.TYPE); + if (checkSkyOcclusion(block)) break; + height--; + } + heightmap[z << 4 | x] = (height + 1); + } + } + } + + this.heightmap = heightmap; + return heightmap; + } + + @Override + protected LightData createLightData(boolean sendAll) { + BitSet skyMask = new BitSet(); + BitSet blockMask = new BitSet(); + BitSet emptySkyMask = new BitSet(); + BitSet emptyBlockMask = new BitSet(); + List skyLights = new ArrayList<>(); + List blockLights = new ArrayList<>(); + + int index = 0; + for (Section section : sections) { + boolean wasUpdatedBlock = false; + boolean wasUpdatedSky = false; + + if (section.blockLight().requiresUpdate()) { + relightSection(instance, this.chunkX, index + minSection, chunkZ, LightType.BLOCK); + wasUpdatedBlock = true; + } else if (section.blockLight().requiresSend()) { + wasUpdatedBlock = true; + } + + if (section.skyLight().requiresUpdate()) { + relightSection(instance, this.chunkX, index + minSection, chunkZ, LightType.SKY); + wasUpdatedSky = true; + } else if (section.skyLight().requiresSend()) { + wasUpdatedSky = true; + } + + index++; + + final byte[] skyLight = section.skyLight().array(); + final byte[] blockLight = section.blockLight().array(); + + // System.out.println("Relit sky: " + wasUpdatedSky + " block: " + wasUpdatedBlock + " for section " + (index + minSection) + " in chunk " + chunkX + " " + chunkZ); + + if ((wasUpdatedSky || sendAll) && this.instance.getDimensionType().isSkylightEnabled()) { + if (skyLight.length != 0) { + skyLights.add(skyLight); + skyMask.set(index); + } else { + emptySkyMask.set(index); + } + } + + if (wasUpdatedBlock || sendAll) { + if (blockLight.length != 0) { + blockLights.add(blockLight); + blockMask.set(index); + } else { + emptyBlockMask.set(index); + } + } + } + + if (sendNeighbours) { + updateAfterGeneration(this); + sendNeighbours = false; + } + + return new LightData(true, + skyMask, blockMask, + emptySkyMask, emptyBlockMask, + skyLights, blockLights); + } + + private static final Set sendQueue = ConcurrentHashMap.newKeySet(); + private static Task sendingTask = null; + + private static void updateAfterGeneration(LightingChunk chunk) { + for (int i = -1; i <= 1; i++) { + for (int j = -1; j <= 1; j++) { + Chunk neighborChunk = chunk.instance.getChunk(chunk.chunkX + i, chunk.chunkZ + j); + if (neighborChunk == null) continue; + + if (neighborChunk instanceof LightingChunk lightingChunk) { + sendQueue.add(lightingChunk); + } + } + } + + if (sendingTask == null) { + sendingTask = MinecraftServer.getSchedulerManager().scheduleTask(() -> { + sendingTask = null; + + for (LightingChunk f : sendQueue) { + if (f.isLoaded()) { + f.sections.forEach(s -> { + s.blockLight().invalidate(); + s.skyLight().invalidate(); + }); + f.sendLighting(); + sendQueue.remove(f); + } + + f.chunkCache.invalidate(); + } + }, TaskSchedule.tick(5), TaskSchedule.stop(), ExecutionType.ASYNC); + } + } + + private static void flushQueue(Instance instance, Set queue, LightType type) { + var updateQueue = + queue.parallelStream() + .map(sectionLocation -> { + Chunk chunk = instance.getChunk(sectionLocation.blockX(), sectionLocation.blockZ()); + if (chunk == null) return null; + + if (type == LightType.BLOCK) { + return chunk.getSection(sectionLocation.blockY()).blockLight() + .calculateExternal(instance, chunk, sectionLocation.blockY()); + } else { + return chunk.getSection(sectionLocation.blockY()).skyLight() + .calculateExternal(instance, chunk, sectionLocation.blockY()); + } + }) + .filter(Objects::nonNull) + .toList() + .parallelStream() + .flatMap(light -> light.flip().stream()) + .collect(Collectors.toSet()); + + if (updateQueue.size() > 0) { + flushQueue(instance, updateQueue, type); + } + } + + public static void relight(Instance instance, Collection chunks) { + Set toPropagate = chunks + .parallelStream() + .flatMap(chunk -> IntStream + .range(chunk.getMinSection(), chunk.getMaxSection()) + .mapToObj(index -> Map.entry(index, chunk))) + .map(chunkIndex -> { + final Chunk chunk = chunkIndex.getValue(); + final int section = chunkIndex.getKey(); + + chunk.getSection(section).blockLight().invalidate(); + chunk.getSection(section).skyLight().invalidate(); + + return new Vec(chunk.getChunkX(), section, chunk.getChunkZ()); + }).collect(Collectors.toSet()); + + synchronized (instance) { + relight(instance, toPropagate, LightType.BLOCK); + relight(instance, toPropagate, LightType.SKY); + } + } + + private static Set getNearbyRequired(Instance instance, Point point) { + Set collected = new HashSet<>(); + collected.add(point); + + for (int x = point.blockX() - 1; x <= point.blockX() + 1; x++) { + for (int z = point.blockZ() - 1; z <= point.blockZ() + 1; z++) { + Chunk chunkCheck = instance.getChunk(x, z); + if (chunkCheck == null) continue; + + for (int y = point.blockY() - 1; y <= point.blockY() + 1; y++) { + Point sectionPosition = new Vec(x, y, z); + + if (sectionPosition.blockY() < chunkCheck.getMaxSection() && sectionPosition.blockY() >= chunkCheck.getMinSection()) { + Section s = chunkCheck.getSection(sectionPosition.blockY()); + if (!s.blockLight().requiresUpdate() && !s.skyLight().requiresUpdate()) continue; + + collected.add(sectionPosition); + } + } + } + } + + return collected; + } + + private static Set collectRequiredNearby(Instance instance, Point point) { + final Set found = new HashSet<>(); + final ArrayDeque toCheck = new ArrayDeque<>(); + + toCheck.add(point); + found.add(point); + + while (toCheck.size() > 0) { + final Point current = toCheck.poll(); + final Set nearby = getNearbyRequired(instance, current); + nearby.forEach(p -> { + if (!found.contains(p)) { + found.add(p); + toCheck.add(p); + } + }); + } + + return found; + } + + static void relightSection(Instance instance, int chunkX, int sectionY, int chunkZ) { + relightSection(instance, chunkX, sectionY, chunkZ, LightType.BLOCK); + relightSection(instance, chunkX, sectionY, chunkZ, LightType.SKY); + } + + private static void relightSection(Instance instance, int chunkX, int sectionY, int chunkZ, LightType type) { + Chunk c = instance.getChunk(chunkX, chunkZ); + if (c == null) return; + + Set collected = collectRequiredNearby(instance, new Vec(chunkX, sectionY, chunkZ)); + // System.out.println("Calculating " + chunkX + " " + sectionY + " " + chunkZ + " | " + collected.size()); + + synchronized (instance) { + relight(instance, collected, type); + } + } + + private static void relight(Instance instance, Set sections, LightType type) { + Set toPropagate = sections + .parallelStream() + // .stream() + .map(chunkIndex -> { + final Chunk chunk = instance.getChunk(chunkIndex.blockX(), chunkIndex.blockZ()); + final int section = chunkIndex.blockY(); + if (chunk == null) return null; + if (type == LightType.BLOCK) return chunk.getSection(section).blockLight().calculateInternal(chunk.getInstance(), chunk.getChunkX(), section, chunk.getChunkZ()); + else return chunk.getSection(section).skyLight().calculateInternal(chunk.getInstance(), chunk.getChunkX(), section, chunk.getChunkZ()); + }).filter(Objects::nonNull) + .flatMap(lightSet -> lightSet.flip().stream()) + .collect(Collectors.toSet()) + // .stream() + .parallelStream() + .flatMap(sectionLocation -> { + final Chunk chunk = instance.getChunk(sectionLocation.blockX(), sectionLocation.blockZ()); + final int section = sectionLocation.blockY(); + if (chunk == null) return Stream.empty(); + + final Light light = type == LightType.BLOCK ? chunk.getSection(section).blockLight() : chunk.getSection(section).skyLight(); + light.calculateExternal(chunk.getInstance(), chunk, section); + + return light.flip().stream(); + }).collect(Collectors.toSet()); + + flushQueue(instance, toPropagate, type); + } + + @Override + public @NotNull Chunk copy(@NotNull Instance instance, int chunkX, int chunkZ) { + LightingChunk lightingChunk = new LightingChunk(instance, chunkX, chunkZ); + lightingChunk.sections = sections.stream().map(Section::clone).toList(); + lightingChunk.entries.putAll(entries); + return lightingChunk; + } +} \ No newline at end of file diff --git a/src/main/java/net/minestom/server/instance/Section.java b/src/main/java/net/minestom/server/instance/Section.java index f9c4e49bf..789e56c6e 100644 --- a/src/main/java/net/minestom/server/instance/Section.java +++ b/src/main/java/net/minestom/server/instance/Section.java @@ -1,5 +1,6 @@ package net.minestom.server.instance; +import net.minestom.server.instance.light.Light; import net.minestom.server.instance.palette.Palette; import net.minestom.server.network.NetworkBuffer; import org.jetbrains.annotations.NotNull; @@ -7,22 +8,20 @@ import org.jetbrains.annotations.NotNull; import static net.minestom.server.network.NetworkBuffer.SHORT; public final class Section implements NetworkBuffer.Writer { - private Palette blockPalette; - private Palette biomePalette; - private byte[] skyLight; - private byte[] blockLight; + private final Palette blockPalette; + private final Palette biomePalette; + private final Light skyLight; + private final Light blockLight; - private Section(Palette blockPalette, Palette biomePalette, - byte[] skyLight, byte[] blockLight) { + private Section(Palette blockPalette, Palette biomePalette) { this.blockPalette = blockPalette; this.biomePalette = biomePalette; - this.skyLight = skyLight; - this.blockLight = blockLight; + this.skyLight = Light.sky(blockPalette); + this.blockLight = Light.block(blockPalette); } public Section() { - this(Palette.blocks(), Palette.biomes(), - new byte[0], new byte[0]); + this(Palette.blocks(), Palette.biomes()); } public Palette blockPalette() { @@ -33,33 +32,14 @@ public final class Section implements NetworkBuffer.Writer { return biomePalette; } - public byte[] getSkyLight() { - return skyLight; - } - - public void setSkyLight(byte[] skyLight) { - this.skyLight = skyLight; - } - - public byte[] getBlockLight() { - return blockLight; - } - - public void setBlockLight(byte[] blockLight) { - this.blockLight = blockLight; - } - public void clear() { this.blockPalette.fill(0); this.biomePalette.fill(0); - this.skyLight = new byte[0]; - this.blockLight = new byte[0]; } @Override public @NotNull Section clone() { - return new Section(blockPalette.clone(), biomePalette.clone(), - skyLight.clone(), blockLight.clone()); + return new Section(this.blockPalette.clone(), this.biomePalette.clone()); } @Override @@ -68,4 +48,20 @@ public final class Section implements NetworkBuffer.Writer { writer.write(blockPalette); writer.write(biomePalette); } + + public void setSkyLight(byte[] copyArray) { + this.skyLight.set(copyArray); + } + + public void setBlockLight(byte[] copyArray) { + this.blockLight.set(copyArray); + } + + public Light skyLight() { + return skyLight; + } + + public Light blockLight() { + return blockLight; + } } diff --git a/src/main/java/net/minestom/server/instance/SharedInstance.java b/src/main/java/net/minestom/server/instance/SharedInstance.java index d9c401268..a276f003e 100644 --- a/src/main/java/net/minestom/server/instance/SharedInstance.java +++ b/src/main/java/net/minestom/server/instance/SharedInstance.java @@ -6,6 +6,7 @@ import net.minestom.server.instance.block.Block; import net.minestom.server.instance.block.BlockFace; import net.minestom.server.instance.block.BlockHandler; import net.minestom.server.instance.generator.Generator; +import net.minestom.server.utils.chunk.ChunkSupplier; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -75,6 +76,16 @@ public class SharedInstance extends Instance { return instanceContainer.saveChunksToStorage(); } + @Override + public void setChunkSupplier(@NotNull ChunkSupplier chunkSupplier) { + instanceContainer.setChunkSupplier(chunkSupplier); + } + + @Override + public ChunkSupplier getChunkSupplier() { + return instanceContainer.getChunkSupplier(); + } + @Override public @Nullable Generator generator() { return instanceContainer.generator(); diff --git a/src/main/java/net/minestom/server/instance/batch/AbsoluteBlockBatch.java b/src/main/java/net/minestom/server/instance/batch/AbsoluteBlockBatch.java index 91af487eb..d47259dde 100644 --- a/src/main/java/net/minestom/server/instance/batch/AbsoluteBlockBatch.java +++ b/src/main/java/net/minestom/server/instance/batch/AbsoluteBlockBatch.java @@ -6,12 +6,16 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import net.minestom.server.instance.Chunk; import net.minestom.server.instance.Instance; import net.minestom.server.instance.InstanceContainer; +import net.minestom.server.instance.LightingChunk; import net.minestom.server.instance.block.Block; import net.minestom.server.utils.chunk.ChunkUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; @@ -125,6 +129,8 @@ public class AbsoluteBlockBatch implements Batch { final AbsoluteBlockBatch inverse = this.options.shouldCalculateInverse() ? new AbsoluteBlockBatch(inverseOption) : null; synchronized (chunkBatchesMap) { AtomicInteger counter = new AtomicInteger(); + Set updated = ConcurrentHashMap.newKeySet(); + for (var entry : Long2ObjectMaps.fastIterable(chunkBatchesMap)) { final long chunkIndex = entry.getLongKey(); final int chunkX = ChunkUtils.getChunkCoordX(chunkIndex); @@ -146,6 +152,25 @@ public class AbsoluteBlockBatch implements Batch { callback.run(); } } + + Set expanded = new HashSet<>(); + for (Chunk chunk : updated) { + for (int i = -1; i <= 1; ++i) { + for (int j = -1; j <= 1; ++j) { + Chunk toAdd = instance.getChunk(chunk.getChunkX() + i, chunk.getChunkZ() + j); + if (toAdd != null) { + expanded.add(toAdd); + } + } + } + } + + // Update the chunk's light + for (Chunk chunk : expanded) { + if (chunk instanceof LightingChunk dc) { + dc.sendLighting(); + } + } } }); if (inverse != null) inverse.chunkBatchesMap.put(chunkIndex, chunkInverse); diff --git a/src/main/java/net/minestom/server/instance/light/BlockLight.java b/src/main/java/net/minestom/server/instance/light/BlockLight.java new file mode 100644 index 000000000..365321c50 --- /dev/null +++ b/src/main/java/net/minestom/server/instance/light/BlockLight.java @@ -0,0 +1,359 @@ +package net.minestom.server.instance.light; + +import it.unimi.dsi.fastutil.ints.IntArrayFIFOQueue; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.Section; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.block.BlockFace; +import net.minestom.server.instance.palette.Palette; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static net.minestom.server.instance.light.LightCompute.*; + +final class BlockLight implements Light { + private final Palette blockPalette; + + private byte[] content; + private byte[] contentPropagation; + private byte[] contentPropagationSwap; + + private byte[][] borders; + private byte[][] bordersPropagation; + private byte[][] bordersPropagationSwap; + private boolean isValidBorders = true; + private boolean needsSend = true; + private Set toUpdateSet = new HashSet<>(); + + BlockLight(Palette blockPalette) { + this.blockPalette = blockPalette; + } + + @Override + public Set flip() { + if (this.bordersPropagationSwap != null) + this.bordersPropagation = this.bordersPropagationSwap; + + if (this.contentPropagationSwap != null) + this.contentPropagation = this.contentPropagationSwap; + + this.bordersPropagationSwap = null; + this.contentPropagationSwap = null; + + if (toUpdateSet == null) return Set.of(); + return toUpdateSet; + } + + static IntArrayFIFOQueue buildInternalQueue(Palette blockPalette, Block[] blocks) { + IntArrayFIFOQueue lightSources = new IntArrayFIFOQueue(); + // Apply section light + blockPalette.getAllPresent((x, y, z, stateId) -> { + final Block block = Block.fromStateId((short) stateId); + assert block != null; + final byte lightEmission = (byte) block.registry().lightEmission(); + + final int index = x | (z << 4) | (y << 8); + blocks[index] = block; + if (lightEmission > 0) { + lightSources.enqueue(index | (lightEmission << 12)); + } + }); + return lightSources; + } + + private static Block getBlock(Palette palette, int x, int y, int z) { + return Block.fromStateId((short)palette.get(x, y, z)); + } + + private static IntArrayFIFOQueue buildExternalQueue(Instance instance, Block[] blocks, Map neighbors, byte[][] borders) { + IntArrayFIFOQueue lightSources = new IntArrayFIFOQueue(); + + for (BlockFace face : BlockFace.values()) { + Point neighborSection = neighbors.get(face); + if (neighborSection == null) continue; + + Chunk chunk = instance.getChunk(neighborSection.blockX(), neighborSection.blockZ()); + if (chunk == null) continue; + + byte[] neighborFace = chunk.getSection(neighborSection.blockY()).blockLight().getBorderPropagation(face.getOppositeFace()); + if (neighborFace == null) continue; + + for (int bx = 0; bx < 16; bx++) { + for (int by = 0; by < 16; by++) { + final int borderIndex = bx * SECTION_SIZE + by; + byte lightEmission = neighborFace[borderIndex]; + + if (borders != null && borders[face.ordinal()] != null) { + final int internalEmission = borders[face.ordinal()][borderIndex]; + if (lightEmission <= internalEmission) continue; + } + + final int k = switch (face) { + case WEST, BOTTOM, NORTH -> 0; + case EAST, TOP, SOUTH -> 15; + }; + + final int posTo = switch (face) { + case NORTH, SOUTH -> bx | (k << 4) | (by << 8); + case WEST, EAST -> k | (by << 4) | (bx << 8); + default -> bx | (by << 4) | (k << 8); + }; + + Section otherSection = chunk.getSection(neighborSection.blockY()); + + final Block blockFrom = (switch (face) { + case NORTH, SOUTH -> getBlock(otherSection.blockPalette(), bx, by, 15 - k); + case WEST, EAST -> getBlock(otherSection.blockPalette(), 15 - k, bx, by); + default -> getBlock(otherSection.blockPalette(), bx, 15 - k, by); + }); + + if (blocks == null) continue; + Block blockTo = blocks[posTo]; + + if (blockTo == null && blockFrom != null) { + if (blockFrom.registry().collisionShape().isOccluded(Block.AIR.registry().collisionShape(), face.getOppositeFace())) + continue; + } else if (blockTo != null && blockFrom == null) { + if (Block.AIR.registry().collisionShape().isOccluded(blockTo.registry().collisionShape(), face)) + continue; + } else if (blockTo != null && blockFrom != null) { + if (blockFrom.registry().collisionShape().isOccluded(blockTo.registry().collisionShape(), face.getOppositeFace())) + continue; + } + + if (lightEmission > 0) { + final int index = posTo | (lightEmission << 12); + lightSources.enqueue(index); + } + } + } + } + + return lightSources; + } + + @Override + public void copyFrom(byte @NotNull [] array) { + if (array.length == 0) this.content = null; + else this.content = array.clone(); + } + + @Override + public Light calculateInternal(Instance instance, int chunkX, int sectionY, int chunkZ) { + Chunk chunk = instance.getChunk(chunkX, chunkZ); + if (chunk == null) { + this.toUpdateSet = Set.of(); + return this; + } + + this.isValidBorders = true; + + Set toUpdate = new HashSet<>(); + + // Update single section with base lighting changes + Block[] blocks = new Block[SECTION_SIZE * SECTION_SIZE * SECTION_SIZE]; + IntArrayFIFOQueue queue = buildInternalQueue(blockPalette, blocks); + + Result result = LightCompute.compute(blocks, queue); + this.content = result.light(); + this.borders = result.borders(); + + // Propagate changes to neighbors and self + for (int i = -1; i <= 1; i++) { + for (int j = -1; j <= 1; j++) { + Chunk neighborChunk = instance.getChunk(chunkX + i, chunkZ + j); + if (neighborChunk == null) continue; + + for (int k = -1; k <= 1; k++) { + Vec neighborPos = new Vec(chunkX + i, sectionY + k, chunkZ + j); + + if (neighborPos.blockY() >= neighborChunk.getMinSection() && neighborPos.blockY() < neighborChunk.getMaxSection()) { + toUpdate.add(new Vec(neighborChunk.getChunkX(), neighborPos.blockY(), neighborChunk.getChunkZ())); + neighborChunk.getSection(neighborPos.blockY()).blockLight().invalidatePropagation(); + } + } + } + } + + toUpdate.add(new Vec(chunk.getChunkX(), sectionY, chunk.getChunkZ())); + this.toUpdateSet = toUpdate; + + return this; + } + + @Override + public void invalidate() { + invalidatePropagation(); + } + + @Override + public boolean requiresUpdate() { + return !isValidBorders; + } + + @Override + public void set(byte[] copyArray) { + this.content = copyArray.clone(); + } + + @Override + public boolean requiresSend() { + boolean res = needsSend; + needsSend = false; + return res; + } + + private void clearCache() { + this.contentPropagation = null; + this.bordersPropagation = null; + isValidBorders = true; + needsSend = true; + } + + @Override + public byte[] array() { + if (content == null) return new byte[0]; + if (contentPropagation == null) return content; + var res = bake(contentPropagation, content); + if (res == emptyContent) return new byte[0]; + return res; + } + + private boolean compareBorders(byte[] a, byte[] b) { + if (b == null && a == null) return true; + if (b == null || a == null) return false; + + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] > b[i]) return false; + } + return true; + } + + private Block[] blocks() { + Block[] blocks = new Block[SECTION_SIZE * SECTION_SIZE * SECTION_SIZE]; + + blockPalette.getAllPresent((x, y, z, stateId) -> { + final Block block = Block.fromStateId((short) stateId); + assert block != null; + final int index = x | (z << 4) | (y << 8); + blocks[index] = block; + }); + + return blocks; + } + + @Override + public Light calculateExternal(Instance instance, Chunk chunk, int sectionY) { + if (!isValidBorders) clearCache(); + + Map neighbors = Light.getNeighbors(chunk, sectionY); + + Block[] blocks = blocks(); + IntArrayFIFOQueue queue = buildExternalQueue(instance, blocks, neighbors, borders); + LightCompute.Result result = LightCompute.compute(blocks, queue); + + byte[] contentPropagationTemp = result.light(); + byte[][] borderTemp = result.borders(); + + this.contentPropagationSwap = bake(contentPropagationSwap, contentPropagationTemp); + this.bordersPropagationSwap = combineBorders(bordersPropagation, borderTemp); + + Set toUpdate = new HashSet<>(); + + // Propagate changes to neighbors and self + for (var entry : neighbors.entrySet()) { + var neighbor = entry.getValue(); + var face = entry.getKey(); + + byte[] next = borderTemp[face.ordinal()]; + byte[] current = getBorderPropagation(face); + + if (!compareBorders(next, current)) { + toUpdate.add(neighbor); + } + } + + this.toUpdateSet = toUpdate; + return this; + } + + private byte[][] combineBorders(byte[][] b1, byte[][] b2) { + if (b1 == null) return b2; + + byte[][] newBorder = new byte[FACES.length][]; + Arrays.setAll(newBorder, i -> new byte[SIDE_LENGTH]); + for (int i = 0; i < FACES.length; i++) { + newBorder[i] = combineBorders(b1[i], b2[i]); + } + return newBorder; + } + + private byte[] bake(byte[] content1, byte[] content2) { + if (content1 == null && content2 == null) return emptyContent; + if (content1 == emptyContent && content2 == emptyContent) return emptyContent; + + if (content1 == null) return content2; + if (content2 == null) return content1; + + byte[] lightMax = new byte[LIGHT_LENGTH]; + for (int i = 0; i < content1.length; i++) { + // Lower + byte l1 = (byte) (content1[i] & 0x0F); + byte l2 = (byte) (content2[i] & 0x0F); + + // Upper + byte u1 = (byte) ((content1[i] >> 4) & 0x0F); + byte u2 = (byte) ((content2[i] >> 4) & 0x0F); + + byte lower = (byte) Math.max(l1, l2); + byte upper = (byte) Math.max(u1, u2); + + lightMax[i] = (byte) (lower | (upper << 4)); + } + return lightMax; + } + + @Override + public byte[] getBorderPropagation(BlockFace face) { + if (!isValidBorders) clearCache(); + + if (borders == null && bordersPropagation == null) return new byte[SIDE_LENGTH]; + if (borders == null) return bordersPropagation[face.ordinal()]; + if (bordersPropagation == null) return borders[face.ordinal()]; + + return combineBorders(bordersPropagation[face.ordinal()], borders[face.ordinal()]); + } + + @Override + public void invalidatePropagation() { + this.isValidBorders = false; + this.needsSend = false; + this.bordersPropagation = null; + this.contentPropagation = null; + } + + @Override + public int getLevel(int x, int y, int z) { + var array = array(); + int index = x | (z << 4) | (y << 8); + return LightCompute.getLight(array, index); + } + + private byte[] combineBorders(byte[] b1, byte[] b2) { + byte[] newBorder = new byte[SIDE_LENGTH]; + for (int i = 0; i < newBorder.length; i++) { + var previous = b2[i]; + var current = b1[i]; + newBorder[i] = (byte) Math.max(previous, current); + } + return newBorder; + } +} \ No newline at end of file diff --git a/src/main/java/net/minestom/server/instance/light/Light.java b/src/main/java/net/minestom/server/instance/light/Light.java new file mode 100644 index 000000000..43429c1e7 --- /dev/null +++ b/src/main/java/net/minestom/server/instance/light/Light.java @@ -0,0 +1,78 @@ +package net.minestom.server.instance.light; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.BlockFace; +import net.minestom.server.instance.palette.Palette; +import net.minestom.server.utils.Direction; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public interface Light { + static Light sky(@NotNull Palette blockPalette) { + return new SkyLight(blockPalette); + } + + static Light block(@NotNull Palette blockPalette) { + return new BlockLight(blockPalette); + } + + boolean requiresSend(); + + @ApiStatus.Internal + byte[] array(); + + Set flip(); + + void copyFrom(byte @NotNull [] array); + + @ApiStatus.Internal + Light calculateExternal(Instance instance, Chunk chunk, int sectionY); + + @ApiStatus.Internal + byte[] getBorderPropagation(BlockFace oppositeFace); + + @ApiStatus.Internal + void invalidatePropagation(); + + int getLevel(int x, int y, int z); + + @ApiStatus.Internal + Light calculateInternal(Instance instance, int chunkX, int chunkY, int chunkZ); + + void invalidate(); + + boolean requiresUpdate(); + + void set(byte[] copyArray); + + @ApiStatus.Internal + static Map getNeighbors(Chunk chunk, int sectionY) { + int chunkX = chunk.getChunkX(); + int chunkZ = chunk.getChunkZ(); + + Map links = new HashMap<>(); + + for (BlockFace face : BlockFace.values()) { + Direction direction = face.toDirection(); + int x = chunkX + direction.normalX(); + int z = chunkZ + direction.normalZ(); + int y = sectionY + direction.normalY(); + + Chunk foundChunk = chunk.getInstance().getChunk(x, z); + + if (foundChunk == null) continue; + if (y - foundChunk.getMinSection() > foundChunk.getMaxSection() || y - foundChunk.getMinSection() < 0) continue; + + links.put(face, new Vec(foundChunk.getChunkX(), y, foundChunk.getChunkZ())); + } + + return links; + } +} diff --git a/src/main/java/net/minestom/server/instance/light/LightCompute.java b/src/main/java/net/minestom/server/instance/light/LightCompute.java new file mode 100644 index 000000000..9515cd98e --- /dev/null +++ b/src/main/java/net/minestom/server/instance/light/LightCompute.java @@ -0,0 +1,117 @@ +package net.minestom.server.instance.light; + +import it.unimi.dsi.fastutil.ints.IntArrayFIFOQueue; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.block.BlockFace; +import net.minestom.server.instance.palette.Palette; +import net.minestom.server.utils.Direction; +import org.jetbrains.annotations.NotNull; + +import java.util.LinkedList; +import java.util.Objects; + +import static net.minestom.server.instance.light.BlockLight.buildInternalQueue; + +final class LightCompute { + static final BlockFace[] FACES = BlockFace.values(); + static final int LIGHT_LENGTH = 16 * 16 * 16 / 2; + static final int SIDE_LENGTH = 16 * 16; + static final int SECTION_SIZE = 16; + + private static final byte[][] emptyBorders = new byte[FACES.length][SIDE_LENGTH]; + static final byte[] emptyContent = new byte[LIGHT_LENGTH]; + + static @NotNull Result compute(Palette blockPalette) { + Block[] blocks = new Block[4096]; + return LightCompute.compute(blocks, buildInternalQueue(blockPalette, blocks)); + } + + static @NotNull Result compute(Block[] blocks, IntArrayFIFOQueue lightPre) { + if (lightPre.isEmpty()) { + return new Result(emptyContent, emptyBorders); + } + + byte[][] borders = new byte[FACES.length][SIDE_LENGTH]; + byte[] lightArray = new byte[LIGHT_LENGTH]; + + var lightSources = new LinkedList(); + + while (!lightPre.isEmpty()) { + int index = lightPre.dequeueInt(); + + final int x = index & 15; + final int z = (index >> 4) & 15; + final int y = (index >> 8) & 15; + final int newLightLevel = (index >> 12) & 15; + final int newIndex = x | (z << 4) | (y << 8); + + final int oldLightLevel = getLight(lightArray, newIndex); + + if (oldLightLevel < newLightLevel) { + placeLight(lightArray, newIndex, newLightLevel); + lightSources.add(index); + } + } + + while (!lightSources.isEmpty()) { + final int index = lightSources.poll(); + final int x = index & 15; + final int z = (index >> 4) & 15; + final int y = (index >> 8) & 15; + final int lightLevel = (index >> 12) & 15; + + for (BlockFace face : FACES) { + Direction dir = face.toDirection(); + final int xO = x + dir.normalX(); + final int yO = y + dir.normalY(); + final int zO = z + dir.normalZ(); + final byte newLightLevel = (byte) (lightLevel - 1); + // Handler border + if (xO < 0 || xO >= SECTION_SIZE || yO < 0 || yO >= SECTION_SIZE || zO < 0 || zO >= SECTION_SIZE) { + final byte[] border = borders[face.ordinal()]; + final int borderIndex = switch (face) { + case WEST, EAST -> y * SECTION_SIZE + z; + case BOTTOM, TOP -> x * SECTION_SIZE + z; + case NORTH, SOUTH -> x * SECTION_SIZE + y; + }; + border[borderIndex] = newLightLevel; + continue; + } + // Section + final int newIndex = xO | (zO << 4) | (yO << 8); + if (getLight(lightArray, newIndex) + 2 <= lightLevel) { + final Block currentBlock = Objects.requireNonNullElse(blocks[x | (z << 4) | (y << 8)], Block.AIR); + + final Block propagatedBlock = Objects.requireNonNullElse(blocks[newIndex], Block.AIR); + boolean airAir = currentBlock.isAir() && propagatedBlock.isAir(); + if (!airAir && currentBlock.registry().collisionShape().isOccluded(propagatedBlock.registry().collisionShape(), face)) continue; + placeLight(lightArray, newIndex, newLightLevel); + lightSources.add(newIndex | (newLightLevel << 12)); + } + } + } + return new Result(lightArray, borders); + } + + record Result(byte[] light, byte[][] borders) { + Result { + assert light.length == LIGHT_LENGTH : "Only 16x16x16 sections are supported: " + light.length; + } + + public byte getLight(int x, int y, int z) { + return (byte) LightCompute.getLight(light, x | (z << 4) | (y << 8)); + } + } + + private static void placeLight(byte[] light, int index, int value) { + final int shift = (index & 1) << 2; + final int i = index >>> 1; + light[i] = (byte) ((light[i] & (0xF0 >>> shift)) | (value << shift)); + } + + static int getLight(byte[] light, int index) { + if (index >>> 1 >= light.length) return 0; + final int value = light[index >>> 1]; + return ((value >>> ((index & 1) << 2)) & 0xF); + } +} \ No newline at end of file diff --git a/src/main/java/net/minestom/server/instance/light/SkyLight.java b/src/main/java/net/minestom/server/instance/light/SkyLight.java new file mode 100644 index 000000000..7f7ef160e --- /dev/null +++ b/src/main/java/net/minestom/server/instance/light/SkyLight.java @@ -0,0 +1,398 @@ +package net.minestom.server.instance.light; + +import it.unimi.dsi.fastutil.ints.IntArrayFIFOQueue; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.LightingChunk; +import net.minestom.server.instance.Section; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.block.BlockFace; +import net.minestom.server.instance.palette.Palette; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static net.minestom.server.instance.light.LightCompute.*; + +final class SkyLight implements Light { + private final Palette blockPalette; + + private byte[] content; + private byte[] contentPropagation; + private byte[] contentPropagationSwap; + + private byte[][] borders; + private byte[][] bordersPropagation; + private byte[][] bordersPropagationSwap; + private boolean isValidBorders = true; + private boolean needsSend = true; + + private Set toUpdateSet = new HashSet<>(); + + private boolean fullyLit = false; + private static final byte[][] bordersFullyLit = new byte[6][SIDE_LENGTH]; + private static final byte[] contentFullyLit = new byte[LIGHT_LENGTH]; + + static { + Arrays.fill(contentFullyLit, (byte) -1); + for (byte[] border : bordersFullyLit) { + Arrays.fill(border, (byte) 14); + } + } + + SkyLight(Palette blockPalette) { + this.blockPalette = blockPalette; + } + + @Override + public Set flip() { + if (this.bordersPropagationSwap != null) + this.bordersPropagation = this.bordersPropagationSwap; + + if (this.contentPropagationSwap != null) + this.contentPropagation = this.contentPropagationSwap; + + this.bordersPropagationSwap = null; + this.contentPropagationSwap = null; + + if (toUpdateSet == null) return Set.of(); + return toUpdateSet; + } + + static IntArrayFIFOQueue buildInternalQueue(Chunk c, int sectionY) { + IntArrayFIFOQueue lightSources = new IntArrayFIFOQueue(); + + if (c instanceof LightingChunk lc) { + int[] heightmap = lc.calculateHeightMap(); + int maxY = c.getInstance().getDimensionType().getMinY() + c.getInstance().getDimensionType().getHeight(); + int sectionMaxY = (sectionY + 1) * 16 - 1; + int sectionMinY = sectionY * 16; + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int height = heightmap[z << 4 | x]; + + for (int y = Math.min(sectionMaxY, maxY); y >= Math.max(height, sectionMinY); y--) { + int index = x | (z << 4) | ((y % 16) << 8); + lightSources.enqueue(index | (15 << 12)); + } + } + } + } + + return lightSources; + } + + private static Block getBlock(Palette palette, int x, int y, int z) { + return Block.fromStateId((short)palette.get(x, y, z)); + } + + private static IntArrayFIFOQueue buildExternalQueue(Instance instance, Block[] blocks, Map neighbors, byte[][] borders) { + IntArrayFIFOQueue lightSources = new IntArrayFIFOQueue(); + + for (BlockFace face : BlockFace.values()) { + Point neighborSection = neighbors.get(face); + if (neighborSection == null) continue; + + Chunk chunk = instance.getChunk(neighborSection.blockX(), neighborSection.blockZ()); + if (chunk == null) continue; + + byte[] neighborFace = chunk.getSection(neighborSection.blockY()).skyLight().getBorderPropagation(face.getOppositeFace()); + if (neighborFace == null) continue; + + for (int bx = 0; bx < 16; bx++) { + for (int by = 0; by < 16; by++) { + final int borderIndex = bx * SECTION_SIZE + by; + byte lightEmission = neighborFace[borderIndex]; + + if (borders != null && borders[face.ordinal()] != null) { + final int internalEmission = borders[face.ordinal()][borderIndex]; + if (lightEmission <= internalEmission) continue; + } + + if (borders != null && borders[face.ordinal()] != null) { + final int internalEmission = borders[face.ordinal()][borderIndex]; + if (lightEmission <= internalEmission) continue; + } + final int k = switch (face) { + case WEST, BOTTOM, NORTH -> 0; + case EAST, TOP, SOUTH -> 15; + }; + + final int posTo = switch (face) { + case NORTH, SOUTH -> bx | (k << 4) | (by << 8); + case WEST, EAST -> k | (by << 4) | (bx << 8); + default -> bx | (by << 4) | (k << 8); + }; + + Section otherSection = chunk.getSection(neighborSection.blockY()); + + final Block blockFrom = (switch (face) { + case NORTH, SOUTH -> getBlock(otherSection.blockPalette(), bx, by, 15 - k); + case WEST, EAST -> getBlock(otherSection.blockPalette(), 15 - k, bx, by); + default -> getBlock(otherSection.blockPalette(), bx, 15 - k, by); + }); + + if (blocks == null) continue; + Block blockTo = blocks[posTo]; + + if (blockTo == null && blockFrom != null) { + if (blockFrom.registry().collisionShape().isOccluded(Block.AIR.registry().collisionShape(), face.getOppositeFace())) + continue; + } else if (blockTo != null && blockFrom == null) { + if (Block.AIR.registry().collisionShape().isOccluded(blockTo.registry().collisionShape(), face)) + continue; + } else if (blockTo != null && blockFrom != null) { + if (blockFrom.registry().collisionShape().isOccluded(blockTo.registry().collisionShape(), face.getOppositeFace())) + continue; + } + + final int index = posTo | (lightEmission << 12); + + if (lightEmission > 0) { + lightSources.enqueue(index); + } + } + } + } + + return lightSources; + } + + @Override + public void copyFrom(byte @NotNull [] array) { + if (array.length == 0) this.content = null; + else this.content = array.clone(); + } + + @Override + public Light calculateInternal(Instance instance, int chunkX, int sectionY, int chunkZ) { + Chunk chunk = instance.getChunk(chunkX, chunkZ); + this.isValidBorders = true; + + // Update single section with base lighting changes + Block[] blocks = blocks(); + + int queueSize = SECTION_SIZE * SECTION_SIZE * SECTION_SIZE; + IntArrayFIFOQueue queue = new IntArrayFIFOQueue(0); + if (!fullyLit) { + queue = buildInternalQueue(chunk, sectionY); + queueSize = queue.size(); + } + + if (queueSize == SECTION_SIZE * SECTION_SIZE * SECTION_SIZE) { + this.fullyLit = true; + this.content = contentFullyLit; + this.borders = bordersFullyLit; + } else { + Result result = LightCompute.compute(blocks, queue); + this.content = result.light(); + this.borders = result.borders(); + } + + Set toUpdate = new HashSet<>(); + + // Propagate changes to neighbors and self + for (int i = -1; i <= 1; i++) { + for (int j = -1; j <= 1; j++) { + Chunk neighborChunk = instance.getChunk(chunkX + i, chunkZ + j); + if (neighborChunk == null) continue; + + for (int k = -1; k <= 1; k++) { + Vec neighborPos = new Vec(chunkX + i, sectionY + k, chunkZ + j); + + if (neighborPos.blockY() >= neighborChunk.getMinSection() && neighborPos.blockY() < neighborChunk.getMaxSection()) { + toUpdate.add(new Vec(neighborChunk.getChunkX(), neighborPos.blockY(), neighborChunk.getChunkZ())); + neighborChunk.getSection(neighborPos.blockY()).skyLight().invalidatePropagation(); + } + } + } + } + + toUpdate.add(new Vec(chunk.getChunkX(), sectionY, chunk.getChunkZ())); + this.toUpdateSet = toUpdate; + + return this; + } + + @Override + public void invalidate() { + invalidatePropagation(); + } + + @Override + public boolean requiresUpdate() { + return !isValidBorders; + } + + @Override + public void set(byte[] copyArray) { + this.content = copyArray.clone(); + } + + @Override + public boolean requiresSend() { + boolean res = needsSend; + needsSend = false; + return res; + } + + private void clearCache() { + this.contentPropagation = null; + this.bordersPropagation = null; + isValidBorders = true; + needsSend = true; + fullyLit = false; + } + + @Override + public byte[] array() { + if (content == null) return new byte[0]; + if (contentPropagation == null) return content; + var res = bake(contentPropagation, content); + if (res == emptyContent) return new byte[0]; + return res; + } + + private boolean compareBorders(byte[] a, byte[] b) { + if (b == null && a == null) return true; + if (b == null || a == null) return false; + + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] > b[i]) return false; + } + return true; + } + + private Block[] blocks() { + Block[] blocks = new Block[SECTION_SIZE * SECTION_SIZE * SECTION_SIZE]; + + blockPalette.getAllPresent((x, y, z, stateId) -> { + final Block block = Block.fromStateId((short) stateId); + assert block != null; + final int index = x | (z << 4) | (y << 8); + blocks[index] = block; + }); + + return blocks; + } + + @Override + public Light calculateExternal(Instance instance, Chunk chunk, int sectionY) { + if (!isValidBorders) clearCache(); + + Map neighbors = Light.getNeighbors(chunk, sectionY); + Set toUpdate = new HashSet<>(); + + Block[] blocks = blocks(); + IntArrayFIFOQueue queue; + + byte[][] borderTemp = bordersFullyLit; + if (!fullyLit) { + queue = buildExternalQueue(instance, blocks, neighbors, borders); + LightCompute.Result result = LightCompute.compute(blocks, queue); + + byte[] contentPropagationTemp = result.light(); + borderTemp = result.borders(); + this.contentPropagationSwap = bake(contentPropagationSwap, contentPropagationTemp); + this.bordersPropagationSwap = combineBorders(bordersPropagation, borderTemp); + } else { + this.contentPropagationSwap = null; + this.bordersPropagationSwap = null; + } + + // Propagate changes to neighbors and self + for (var entry : neighbors.entrySet()) { + var neighbor = entry.getValue(); + var face = entry.getKey(); + + byte[] next = borderTemp[face.ordinal()]; + byte[] current = getBorderPropagation(face); + + if (!compareBorders(next, current)) { + toUpdate.add(neighbor); + } + } + + this.toUpdateSet = toUpdate; + return this; + } + + private byte[][] combineBorders(byte[][] b1, byte[][] b2) { + if (b1 == null) return b2; + + byte[][] newBorder = new byte[FACES.length][]; + Arrays.setAll(newBorder, i -> new byte[SIDE_LENGTH]); + for (int i = 0; i < FACES.length; i++) { + newBorder[i] = combineBorders(b1[i], b2[i]); + } + return newBorder; + } + + private byte[] bake(byte[] content1, byte[] content2) { + if (content1 == null && content2 == null) return emptyContent; + if (content1 == emptyContent && content2 == emptyContent) return emptyContent; + + if (content1 == null) return content2; + if (content2 == null) return content1; + + byte[] lightMax = new byte[LIGHT_LENGTH]; + for (int i = 0; i < content1.length; i++) { + // Lower + byte l1 = (byte) (content1[i] & 0x0F); + byte l2 = (byte) (content2[i] & 0x0F); + + // Upper + byte u1 = (byte) ((content1[i] >> 4) & 0x0F); + byte u2 = (byte) ((content2[i] >> 4) & 0x0F); + + byte lower = (byte) Math.max(l1, l2); + byte upper = (byte) Math.max(u1, u2); + + lightMax[i] = (byte) (lower | (upper << 4)); + } + return lightMax; + } + + @Override + public byte[] getBorderPropagation(BlockFace face) { + if (!isValidBorders) clearCache(); + + if (borders == null && bordersPropagation == null) return new byte[SIDE_LENGTH]; + if (borders == null) return bordersPropagation[face.ordinal()]; + if (bordersPropagation == null) return borders[face.ordinal()]; + + return combineBorders(bordersPropagation[face.ordinal()], borders[face.ordinal()]); + } + + @Override + public void invalidatePropagation() { + this.isValidBorders = false; + this.needsSend = false; + this.bordersPropagation = null; + this.contentPropagation = null; + } + + @Override + public int getLevel(int x, int y, int z) { + var array = array(); + int index = x | (z << 4) | (y << 8); + return LightCompute.getLight(array, index); + } + + private byte[] combineBorders(byte[] b1, byte[] b2) { + byte[] newBorder = new byte[SIDE_LENGTH]; + for (int i = 0; i < newBorder.length; i++) { + var previous = b2[i]; + var current = b1[i]; + newBorder[i] = (byte) Math.max(previous, current); + } + return newBorder; + } +} \ No newline at end of file diff --git a/src/main/java/net/minestom/server/registry/Registry.java b/src/main/java/net/minestom/server/registry/Registry.java index 5fe07c2cb..bf032291a 100644 --- a/src/main/java/net/minestom/server/registry/Registry.java +++ b/src/main/java/net/minestom/server/registry/Registry.java @@ -170,6 +170,8 @@ public final class Registry { private final boolean air; private final boolean solid; private final boolean liquid; + private final boolean occludes; + private final int lightEmission; private final String blockEntity; private final int blockEntityId; private final Supplier materialSupplier; @@ -189,7 +191,9 @@ public final class Registry { this.jumpFactor = main.getDouble("jumpFactor", 1); this.air = main.getBoolean("air", false); this.solid = main.getBoolean("solid"); + this.occludes = main.getBoolean("occludes", true); this.liquid = main.getBoolean("liquid", false); + this.lightEmission = main.getInt("lightEmission", 0); { Properties blockEntity = main.section("blockEntity"); if (blockEntity != null) { @@ -205,8 +209,9 @@ public final class Registry { this.materialSupplier = materialNamespace != null ? () -> Material.fromNamespaceId(materialNamespace) : () -> null; } { - final String string = main.getString("collisionShape"); - this.shape = CollisionUtils.parseBlockShape(string, this); + final String collision = main.getString("collisionShape"); + final String occlusion = main.getString("occlusionShape"); + this.shape = CollisionUtils.parseBlockShape(collision, occlusion, this); } } @@ -254,10 +259,18 @@ public final class Registry { return solid; } + public boolean occludes() { + return occludes; + } + public boolean isLiquid() { return liquid; } + public int lightEmission() { + return lightEmission; + } + public boolean isBlockEntity() { return blockEntity != null; } diff --git a/src/test/java/net/minestom/server/instance/BlockLightMergeIntegrationTest.java b/src/test/java/net/minestom/server/instance/BlockLightMergeIntegrationTest.java new file mode 100644 index 000000000..b6875218e --- /dev/null +++ b/src/test/java/net/minestom/server/instance/BlockLightMergeIntegrationTest.java @@ -0,0 +1,564 @@ +package net.minestom.server.instance; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.minestom.testing.EnvTest; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +@EnvTest +public class BlockLightMergeIntegrationTest { + @Test + public void testPropagationAir(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(8, 100,8 , Block.TORCH); + + Map expectedLights = new HashMap<>(); + for (int y = -15; y <= 15; ++y) { + expectedLights.put(new Vec(8, 100 + y, 8), Math.max(0, 14 - Math.abs(y))); + } + + LightingChunk.relightSection(instance, 0, 6, 0); + assertLightInstance(instance, expectedLights); + } + + @Test + public void testTorch(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + instance.setGenerator(unit -> { + unit.modifier().fillHeight(39, 40, Block.STONE); + unit.modifier().fillHeight(50, 51, Block.STONE); + }); + + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(1, 40,1 , Block.TORCH); + + Map expectedLights = Map.ofEntries( + entry(new Vec(2, 40, 2), 12) + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + } + + @Test + public void testTorch2(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(1, 40,1 , Block.TORCH); + Map expectedLights = Map.ofEntries( + entry(new Vec(2, 40, 2), 12) + ); + LightingChunk.relightSection(instance, 1, 2, 1); + assertLightInstance(instance, expectedLights); + + instance.setBlock(-2, 40,-2, Block.TORCH); + expectedLights = Map.ofEntries( + entry(new Vec(2, 40, 2), 12) + ); + LightingChunk.relightSection(instance, -1, 2, -1); + assertLightInstance(instance, expectedLights); + } + + @Test + public void testPropagationAir2(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(4, 60,8 , Block.TORCH); + + Map expectedLights = new HashMap<>(); + for (int y = -15; y <= 15; ++y) { + expectedLights.put(new Vec(8, 60 + y, 8), Math.max(0, 10 - Math.abs(y))); + } + for (int y = -15; y <= 15; ++y) { + expectedLights.put(new Vec(-2, 60 + y, 8), Math.max(0, 8 - Math.abs(y))); + } + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + } + + @Test + public void testPropagationAirRemoval(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(4, 100,8 , Block.TORCH); + + LightingChunk.relightSection(instance, 0, 2, 0); + + instance.setBlock(4, 100,8 , Block.AIR); + + Map expectedLights = new HashMap<>(); + for (int y = -15; y <= 15; ++y) { + expectedLights.put(new Vec(8, 100 + y, 8), 0); + } + for (int y = -15; y <= 15; ++y) { + expectedLights.put(new Vec(-2, 100 + y, 8), 0); + } + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + } + + @Test + public void testBorderOcclusion(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(-1, 40, 4, Block.MAGMA_BLOCK); + instance.setBlock(-1, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-2, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 4, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-2, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-1, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-2, 41, 4, Block.STONE); + instance.setBlock(-2, 40, 4, Block.TORCH); + + Map expectedLights = Map.ofEntries( + entry(new Vec(-2, 42, 4), 0), + entry(new Vec(-2, 42, 3), 1), + entry(new Vec(-2, 41, 3), 2), + entry(new Vec(0, 40, 4), 2) + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + } + + @Test + public void testBorderOcclusion2(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(-1, 41, 4, Block.MAGMA_BLOCK); + instance.setBlock(-1, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-2, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 4, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-2, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-1, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-2, 41, 4, Block.STONE); + instance.setBlock(-2, 40, 4, Block.TORCH); + + Map expectedLights = Map.ofEntries( + entry(new Vec(-2, 42, 4), 8), + entry(new Vec(-2, 40, 2), 8), + entry(new Vec(-4, 40, 4), 4) + + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + assertLightInstance(instance, expectedLights); + } + + @Test + public void testBorderOcclusion3(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(0, 40, 8, Block.STONE); + instance.setBlock(1, 40, 8, Block.STONE); + instance.setBlock(0, 41, 7, Block.STONE); + instance.setBlock(1, 41, 7, Block.STONE); + instance.setBlock(2, 40, 7, Block.STONE); + instance.setBlock(1, 40, 6, Block.STONE); + instance.setBlock(0, 40, 6, Block.STONE); + + instance.setBlock(1, 40, 7, Block.TORCH); + instance.setBlock(0, 40, 7, Block.SANDSTONE_SLAB.withProperty("type", "bottom")); + instance.setBlock(-1, 40, 7, Block.SANDSTONE_SLAB.withProperty("type", "top")); + + Map expectedLights = Map.ofEntries( + entry(new Vec(-2, 40, 7), 0) + + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + assertLightInstance(instance, expectedLights); + } + + @Test + public void testBorderCrossing(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + for (int x = -2; x <= 1; ++x) { + for (int z = 5; z <= 20; ++z) { + instance.setBlock(x, 42, z, Block.STONE); + } + } + + for (int z = 5; z <= 20; ++z) { + for (int y = 40; y <= 42; ++y) { + instance.setBlock(1, y, z, Block.STONE); + instance.setBlock(-2, y, z, Block.STONE); + } + } + + for (int y = 40; y <= 42; ++y) { + instance.setBlock(-1, y, 6, Block.STONE); + instance.setBlock(0, y, 8, Block.STONE); + instance.setBlock(-1, y, 10, Block.STONE); + instance.setBlock(0, y, 12, Block.STONE); + instance.setBlock(-1, y, 14, Block.STONE); + instance.setBlock(0, y, 16, Block.STONE); + instance.setBlock(-1, y, 18, Block.STONE); + instance.setBlock(0, y, 20, Block.STONE); + } + + instance.setBlock(-1, 40, 11, Block.TORCH); + + Map expectedLights = Map.ofEntries( + entry(new Vec(-1, 40, 19), 2), + entry(new Vec(0, 40, 19), 3), + entry(new Vec(-1, 40, 16), 7), + entry(new Vec(-1, 40, 13), 12), + entry(new Vec(-1, 40, 7), 8), + entry(new Vec(-3, 40, 4), 1), + entry(new Vec(-3, 40, 5), 0), + entry(new Vec(-1, 40, 20), 1) + + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + assertLightInstance(instance, expectedLights); + } + + @Test + public void testBorderOcclusionRemoval(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(-1, 41, 4, Block.MAGMA_BLOCK); + instance.setBlock(-1, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-2, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 3, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 4, Block.MAGMA_BLOCK); + instance.setBlock(-3, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-2, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-1, 40, 5, Block.MAGMA_BLOCK); + instance.setBlock(-2, 41, 4, Block.STONE); + + + instance.setBlock(-2, 40, 4, Block.TORCH); + + LightingChunk.relightSection(instance, 0, 2, 0); + + instance.setBlock(-2, 40, 4, Block.STONE); + + Map expectedLights = Map.ofEntries( + entry(new Vec(-2, 42, 4), 1), + entry(new Vec(-2, 40, 2), 2), + entry(new Vec(-4, 40, 4), 2) + + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + } + + @Test + public void chunkIntersection(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = 4; x <= 7; x++) { + for (int z = 6; z <= 8; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(94, -35, 128, Block.GLOW_LICHEN.withProperties(Map.of("west", "true"))); + + LightingChunk.relight(instance, instance.getChunks()); + + var val = instance.getChunk(5, 8).getSection(-2).blockLight().getLevel(14, 0, 0); + assertEquals(4, val); + + var val2 = instance.getChunk(5, 8).getSection(-3).blockLight().getLevel(14, 15, 0); + assertEquals(5, val2); + } + + @Test + public void skylight(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = 4; x <= 7; x++) { + for (int z = 6; z <= 8; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(94, 50, 128, Block.STONE); + + LightingChunk.relight(instance, instance.getChunks()); + + var val = lightValSky(instance, new Vec(94, 41, 128)); + assertEquals(14, val); + } + + @Test + public void skylightContained(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = 4; x <= 7; x++) { + for (int z = 6; z <= 8; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(94, 50, 128, Block.STONE); + instance.setBlock(94, 52, 128, Block.STONE); + + instance.setBlock(94, 51, 127, Block.STONE); + instance.setBlock(94, 51, 129, Block.STONE); + instance.setBlock(93, 51, 128, Block.STONE); + instance.setBlock(95, 51, 128, Block.STONE); + + LightingChunk.relight(instance, instance.getChunks()); + + var val = lightValSky(instance, new Vec(94, 51, 128)); + var val2 = lightValSky(instance, new Vec(94, 52, 128)); + assertEquals(0, val2); + assertEquals(0, val); + } + + @Test + public void testDiagonalRemoval(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(-2, 40, 14, Block.TORCH); + + Map expectedLights = Map.ofEntries( + entry(new Vec(-2, 40, 14), 14), + entry(new Vec(-2, 40, 18), 10), + entry(new Vec(2, 40, 18), 6) + + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + assertLightInstance(instance, expectedLights); + + instance.setBlock(-2, 40, 14, Block.AIR); + + expectedLights = Map.ofEntries( + entry(new Vec(-2, 40, 14), 0), + entry(new Vec(-2, 40, 18), 0), + entry(new Vec(2, 40, 18), 0) + + ); + LightingChunk.relightSection(instance, 0, 2, 0); + assertLightInstance(instance, expectedLights); + } + + @Test + public void testDiagonalRemoval2(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(1, 40, 1, Block.TORCH); + instance.setBlock(1, 40, 17, Block.TORCH); + + LightingChunk.relightSection(instance, 0, 2, 0); + + instance.setBlock(1, 40, 17, Block.AIR); + + var expectedLights = Map.ofEntries( + entry(new Vec(-3, 40, 2), 9) + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + } + + @Test + public void testDouble(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(-2, 40, 14, Block.TORCH); + instance.setBlock(1, 40, 27, Block.TORCH); + + var expectedLights = Map.ofEntries( + entry(new Vec(-4, 40, 25), 7), + entry(new Vec(-4, 40, 18), 8) + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + + instance.setBlock(-2, 40, 14, Block.AIR); + + expectedLights = Map.ofEntries( + entry(new Vec(-4, 40, 25), 7), + entry(new Vec(-4, 40, 18), 0) + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + } + + @Test + public void testBlockRemoval(Env env) { + Instance instance = env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + instance.setBlock(0, 40, 0, Block.STONE); + instance.setBlock(1, 40, -1, Block.STONE); + instance.setBlock(0, 40, -2, Block.STONE); + instance.setBlock(-1, 40, -1, Block.STONE); + instance.setBlock(0, 41, -1, Block.STONE); + instance.setBlock(0, 40, -1, Block.GLOWSTONE); + + var expectedLights = Map.ofEntries( + entry(new Vec(-2, 40, -1), 0) + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + + instance.setBlock(-1, 40, -1, Block.AIR); + + expectedLights = Map.ofEntries( + entry(new Vec(-2, 40, -1), 13) + ); + + LightingChunk.relightSection(instance, 0, 2, 0); + + assertLightInstance(instance, expectedLights); + } + + static byte lightVal(Instance instance, Vec pos) { + final Vec modPos = new Vec(((pos.blockX() % 16) + 16) % 16, ((pos.blockY() % 16) + 16) % 16, ((pos.blockZ() % 16) + 16) % 16); + Chunk chunk = instance.getChunkAt(pos.blockX(), pos.blockZ()); + return (byte) chunk.getSectionAt(pos.blockY()).blockLight().getLevel(modPos.blockX(), modPos.blockY(), modPos.blockZ()); + } + + static byte lightValSky(Instance instance, Vec pos) { + final Vec modPos = new Vec(((pos.blockX() % 16) + 16) % 16, ((pos.blockY() % 16) + 16) % 16, ((pos.blockZ() % 16) + 16) % 16); + Chunk chunk = instance.getChunkAt(pos.blockX(), pos.blockZ()); + return (byte) chunk.getSectionAt(pos.blockY()).skyLight().getLevel(modPos.blockX(), modPos.blockY(), modPos.blockZ()); + } + + public static void assertLightInstance(Instance instance, Map expectedLights) { + List errors = new ArrayList<>(); + for (var entry : expectedLights.entrySet()) { + final Integer expected = entry.getValue(); + final Vec pos = entry.getKey(); + + final byte light = lightVal(instance, pos); + + if (light != expected) { + String errorLine = String.format("Expected %d at [%d,%d,%d] but got %d", expected, pos.blockX(), pos.blockY(), pos.blockZ(), light); + System.err.println(); + errors.add(errorLine); + } + } + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (String s : errors) { + sb.append(s).append("\n"); + } + System.err.println(sb); + fail(); + } + } +} \ No newline at end of file diff --git a/src/test/java/net/minestom/server/instance/light/BlockIsOccludedTest.java b/src/test/java/net/minestom/server/instance/light/BlockIsOccludedTest.java new file mode 100644 index 000000000..5cded9c29 --- /dev/null +++ b/src/test/java/net/minestom/server/instance/light/BlockIsOccludedTest.java @@ -0,0 +1,204 @@ +package net.minestom.server.instance.light; + +import net.minestom.server.collision.Shape; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.block.BlockFace; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class BlockIsOccludedTest { + @Test + public void blockAir() { + Shape airBlock = Block.AIR.registry().collisionShape(); + + for (BlockFace face : BlockFace.values()) { + assertFalse(airBlock.isOccluded(airBlock, face)); + } + } + + @Test + public void blockLantern() { + Shape shape = Block.LANTERN.registry().collisionShape(); + Shape airBlock = Block.AIR.registry().collisionShape(); + + for (BlockFace face : BlockFace.values()) { + assertFalse(shape.isOccluded(airBlock, face)); + } + } + + @Test + public void blockSpruceLeaves() { + Shape shape = Block.SPRUCE_LEAVES.registry().collisionShape(); + Shape airBlock = Block.AIR.registry().collisionShape(); + + for (BlockFace face : BlockFace.values()) { + assertFalse(shape.isOccluded(airBlock, face)); + } + } + + @Test + public void blockCauldron() { + Shape shape = Block.CAULDRON.registry().collisionShape(); + Shape airBlock = Block.AIR.registry().collisionShape(); + + for (BlockFace face : BlockFace.values()) { + assertFalse(shape.isOccluded(airBlock, face)); + } + } + + @Test + public void blockSlabBottomAir() { + Shape shape = Block.SANDSTONE_SLAB.registry().collisionShape(); + Shape airBlock = Block.AIR.registry().collisionShape(); + + assertTrue(shape.isOccluded(airBlock, BlockFace.BOTTOM)); + + assertFalse(shape.isOccluded(airBlock, BlockFace.NORTH)); + assertFalse(shape.isOccluded(airBlock, BlockFace.SOUTH)); + assertFalse(shape.isOccluded(airBlock, BlockFace.EAST)); + assertFalse(shape.isOccluded(airBlock, BlockFace.WEST)); + assertFalse(shape.isOccluded(airBlock, BlockFace.TOP)); + } + + @Test + public void blockSlabTopEnchantingTable() { + Shape shape1 = Block.SANDSTONE_SLAB.withProperty("type", "top").registry().collisionShape(); + Shape shape2 = Block.ENCHANTING_TABLE.registry().collisionShape(); + + assertFalse(shape1.isOccluded(shape2, BlockFace.BOTTOM)); + + assertTrue(shape1.isOccluded(shape2, BlockFace.NORTH)); + assertTrue(shape1.isOccluded(shape2, BlockFace.SOUTH)); + assertTrue(shape1.isOccluded(shape2, BlockFace.EAST)); + assertTrue(shape1.isOccluded(shape2, BlockFace.WEST)); + assertTrue(shape1.isOccluded(shape2, BlockFace.TOP)); + } + + @Test + public void blockStairWest() { + Shape shape = Block.SANDSTONE_STAIRS.withProperties(Map.of( + "facing", "west", + "half", "bottom", + "shape", "straight")).registry().collisionShape(); + + Shape airBlock = Block.AIR.registry().collisionShape(); + + assertTrue(shape.isOccluded(airBlock, BlockFace.WEST)); + assertTrue(shape.isOccluded(airBlock, BlockFace.BOTTOM)); + + assertFalse(shape.isOccluded(airBlock, BlockFace.SOUTH)); + assertFalse(shape.isOccluded(airBlock, BlockFace.EAST)); + assertFalse(shape.isOccluded(airBlock, BlockFace.NORTH)); + assertFalse(shape.isOccluded(airBlock, BlockFace.TOP)); + } + + @Test + public void blockSlabBottomStone() { + Shape shape = Block.SANDSTONE_SLAB.registry().collisionShape(); + Shape stoneBlock = Block.STONE.registry().collisionShape(); + + assertTrue(shape.isOccluded(stoneBlock, BlockFace.BOTTOM)); + assertTrue(shape.isOccluded(stoneBlock, BlockFace.NORTH)); + assertTrue(shape.isOccluded(stoneBlock, BlockFace.SOUTH)); + assertTrue(shape.isOccluded(stoneBlock, BlockFace.EAST)); + assertTrue(shape.isOccluded(stoneBlock, BlockFace.WEST)); + assertTrue(shape.isOccluded(stoneBlock, BlockFace.TOP)); + } + + @Test + public void blockStone() { + Shape shape = Block.STONE.registry().collisionShape(); + Shape airBlock = Block.AIR.registry().collisionShape(); + + for (BlockFace face : BlockFace.values()) { + assertTrue(shape.isOccluded(airBlock, face)); + } + } + + @Test + public void blockStair() { + Shape shape = Block.SANDSTONE_STAIRS.registry().collisionShape(); + Shape airBlock = Block.AIR.registry().collisionShape(); + + assertTrue(shape.isOccluded(airBlock, BlockFace.NORTH)); + assertTrue(shape.isOccluded(airBlock, BlockFace.BOTTOM)); + + assertFalse(shape.isOccluded(airBlock, BlockFace.SOUTH)); + assertFalse(shape.isOccluded(airBlock, BlockFace.EAST)); + assertFalse(shape.isOccluded(airBlock, BlockFace.WEST)); + assertFalse(shape.isOccluded(airBlock, BlockFace.TOP)); + } + + @Test + public void blockSlab() { + Shape shape = Block.SANDSTONE_SLAB.registry().collisionShape(); + Shape airBlock = Block.AIR.registry().collisionShape(); + + assertTrue(shape.isOccluded(airBlock, BlockFace.BOTTOM)); + + assertFalse(shape.isOccluded(airBlock, BlockFace.NORTH)); + assertFalse(shape.isOccluded(airBlock, BlockFace.SOUTH)); + assertFalse(shape.isOccluded(airBlock, BlockFace.EAST)); + assertFalse(shape.isOccluded(airBlock, BlockFace.WEST)); + assertFalse(shape.isOccluded(airBlock, BlockFace.TOP)); + } + + @Test + public void blockSlabBottomAndSlabTop() { + Shape shape1 = Block.SANDSTONE_SLAB.registry().collisionShape(); + Shape shape2 = Block.SANDSTONE_SLAB.withProperty("type", "top").registry().collisionShape(); + + assertFalse(shape1.isOccluded(shape2, BlockFace.TOP)); + + assertTrue(shape1.isOccluded(shape2, BlockFace.BOTTOM)); + assertTrue(shape1.isOccluded(shape2, BlockFace.EAST)); + assertTrue(shape1.isOccluded(shape2, BlockFace.WEST)); + assertTrue(shape1.isOccluded(shape2, BlockFace.NORTH)); + assertTrue(shape1.isOccluded(shape2, BlockFace.SOUTH)); + } + + @Test + public void blockSlabBottomAndSlabBottom() { + Shape shape = Block.SANDSTONE_SLAB.registry().collisionShape(); + + assertTrue(shape.isOccluded(shape, BlockFace.BOTTOM)); + assertTrue(shape.isOccluded(shape, BlockFace.TOP)); + + assertFalse(shape.isOccluded(shape, BlockFace.EAST)); + assertFalse(shape.isOccluded(shape, BlockFace.WEST)); + assertFalse(shape.isOccluded(shape, BlockFace.NORTH)); + assertFalse(shape.isOccluded(shape, BlockFace.SOUTH)); + } + + @Test + public void blockStairAndSlabBottom() { + Shape shape1 = Block.STONE_STAIRS.registry().collisionShape(); + Shape shape2 = Block.SANDSTONE_SLAB.registry().collisionShape(); + + assertTrue(shape1.isOccluded(shape2, BlockFace.BOTTOM)); + assertTrue(shape1.isOccluded(shape2, BlockFace.NORTH)); + assertTrue(shape1.isOccluded(shape2, BlockFace.TOP)); + + assertFalse(shape1.isOccluded(shape2, BlockFace.EAST)); + assertFalse(shape1.isOccluded(shape2, BlockFace.WEST)); + assertFalse(shape1.isOccluded(shape2, BlockFace.SOUTH)); + } + + @Test + public void blockStairAndSlabTop() { + Shape shape1 = Block.STONE_STAIRS.registry().collisionShape(); + Shape shape2 = Block.SANDSTONE_SLAB.withProperty("type", "top").registry().collisionShape(); + + assertTrue(shape1.isOccluded(shape2, BlockFace.NORTH)); + assertTrue(shape1.isOccluded(shape2, BlockFace.BOTTOM)); + assertTrue(shape1.isOccluded(shape2, BlockFace.EAST)); + assertTrue(shape1.isOccluded(shape2, BlockFace.WEST)); + assertTrue(shape1.isOccluded(shape2, BlockFace.SOUTH)); + + assertFalse(shape1.isOccluded(shape2, BlockFace.TOP)); + } +} diff --git a/src/test/java/net/minestom/server/instance/light/BlockLightTest.java b/src/test/java/net/minestom/server/instance/light/BlockLightTest.java new file mode 100644 index 000000000..3f6e05498 --- /dev/null +++ b/src/test/java/net/minestom/server/instance/light/BlockLightTest.java @@ -0,0 +1,239 @@ +package net.minestom.server.instance.light; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.palette.Palette; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class BlockLightTest { + + @Test + public void empty() { + var palette = Palette.blocks(); + var result = LightCompute.compute(palette); + for (byte light : result.light()) { + assertEquals(0, light); + } + } + + @Test + public void glowstone() { + var palette = Palette.blocks(); + palette.set(0, 1, 0, Block.GLOWSTONE.stateId()); + var result = LightCompute.compute(palette); + assertLight(result, Map.of( + new Vec(0, 1, 0), 15, + new Vec(0, 1, 1), 14, + new Vec(0, 1, 2), 13)); + } + + @Test + public void doubleGlowstone() { + var palette = Palette.blocks(); + palette.set(0, 1, 0, Block.GLOWSTONE.stateId()); + palette.set(4, 1, 4, Block.GLOWSTONE.stateId()); + + var result = LightCompute.compute(palette); + assertLight(result, Map.of( + new Vec(1, 1, 3), 11, + new Vec(3, 3, 7), 9, + new Vec(1, 1, 1), 13, + new Vec(3, 1, 4), 14)); + } + + @Test + public void glowstoneBorder() { + var palette = Palette.blocks(); + palette.set(0, 1, 0, Block.GLOWSTONE.stateId()); + var result = LightCompute.compute(palette); + assertLight(result, Map.of( + // X axis + new Vec(-1, 0, 0), 13, + new Vec(-1, 1, 0), 14, + new Vec(-1, 2, 0), 13, + new Vec(-1, 3, 0), 12, + // Z axis + new Vec(0, 0, -1), 13, + new Vec(0, 1, -1), 14, + new Vec(0, 2, -1), 13, + new Vec(0, 3, -1), 12)); + } + + @Test + public void glowstoneBlock() { + var palette = Palette.blocks(); + palette.set(0, 1, 0, Block.GLOWSTONE.stateId()); + palette.set(0, 1, 1, Block.STONE.stateId()); + var result = LightCompute.compute(palette); + assertLight(result, Map.of( + new Vec(0, 1, 0), 15, + new Vec(0, 1, 1), 0, + new Vec(0, 1, 2), 11)); + } + + @Test + public void isolated() { + var palette = Palette.blocks(); + palette.set(4, 1, 4, Block.GLOWSTONE.stateId()); + + palette.set(3, 1, 4, Block.STONE.stateId()); + palette.set(4, 1, 5, Block.STONE.stateId()); + palette.set(4, 1, 3, Block.STONE.stateId()); + palette.set(5, 1, 4, Block.STONE.stateId()); + palette.set(4, 2, 4, Block.STONE.stateId()); + palette.set(4, 0, 4, Block.STONE.stateId()); + + var result = LightCompute.compute(palette); + assertLight(result, Map.ofEntries( + // Glowstone + entry(new Vec(4, 1, 4), 15), + // Isolation + entry(new Vec(3, 1, 4), 0), + entry(new Vec(4, 1, 5), 0), + entry(new Vec(4, 1, 3), 0), + entry(new Vec(5, 1, 4), 0), + entry(new Vec(4, 2, 4), 0), + entry(new Vec(4, 0, 4), 0), + // Outside location + entry(new Vec(2, 2, 3), 0))); + } + + @Test + public void isolatedStair() { + var palette = Palette.blocks(); + palette.set(4, 1, 4, Block.GLOWSTONE.stateId()); + palette.set(3, 1, 4, Block.OAK_STAIRS.withProperties(Map.of( + "facing", "east", + "half", "bottom", + "shape", "straight")).stateId()); + palette.set(4, 1, 5, Block.STONE.stateId()); + palette.set(4, 1, 3, Block.STONE.stateId()); + palette.set(5, 1, 4, Block.STONE.stateId()); + palette.set(4, 2, 4, Block.STONE.stateId()); + palette.set(4, 0, 4, Block.STONE.stateId()); + + var result = LightCompute.compute(palette); + assertLight(result, Map.ofEntries( + // Glowstone + entry(new Vec(4, 1, 4), 15), + // Front of stair + entry(new Vec(2, 1, 4), 0))); + } + + @Test + public void isolatedStairOpposite() { + var palette = Palette.blocks(); + palette.set(4, 1, 4, Block.GLOWSTONE.stateId()); + palette.set(3, 1, 4, Block.OAK_STAIRS.withProperties(Map.of( + "facing", "west", + "half", "bottom", + "shape", "straight")).stateId()); + palette.set(4, 1, 5, Block.STONE.stateId()); + palette.set(4, 1, 3, Block.STONE.stateId()); + palette.set(5, 1, 4, Block.STONE.stateId()); + palette.set(4, 2, 4, Block.STONE.stateId()); + palette.set(4, 0, 4, Block.STONE.stateId()); + + var result = LightCompute.compute(palette); + assertLight(result, Map.ofEntries( + // Glowstone + entry(new Vec(4, 1, 4), 15), + // Stair + entry(new Vec(3, 1, 4), 14), + // Front of stair + entry(new Vec(2, 1, 4), 11), + // Others + entry(new Vec(3, 0, 5), 12), + entry(new Vec(3, 0, 3), 12))); + } + + @Test + public void isolatedStairWest() { + var palette = Palette.blocks(); + palette.set(4, 1, 4, Block.GLOWSTONE.stateId()); + palette.set(3, 1, 4, Block.OAK_STAIRS.withProperties(Map.of( + "facing", "west", + "half", "bottom", + "shape", "straight")).stateId()); + palette.set(4, 1, 5, Block.STONE.stateId()); + palette.set(4, 1, 3, Block.STONE.stateId()); + palette.set(5, 1, 4, Block.STONE.stateId()); + palette.set(4, 2, 4, Block.STONE.stateId()); + palette.set(4, 0, 4, Block.STONE.stateId()); + + var result = LightCompute.compute(palette); + assertLight(result, Map.ofEntries( + // Glowstone + entry(new Vec(4, 1, 4), 15), + // Stair + entry(new Vec(3, 1, 4), 14), + // Front of stair + entry(new Vec(2, 1, 4), 11), + // Others + entry(new Vec(3, 0, 5), 12), + entry(new Vec(3, 0, 3), 12), + entry(new Vec(3, 2, 4), 13), + entry(new Vec(3, -1, 4), 10), + entry(new Vec(2, 0, 4), 10))); + } + + @Test + public void isolatedStairSouth() { + var palette = Palette.blocks(); + palette.set(4, 1, 4, Block.GLOWSTONE.stateId()); + palette.set(3, 1, 4, Block.OAK_STAIRS.withProperties(Map.of( + "facing", "south", + "half", "bottom", + "shape", "straight")).stateId()); + palette.set(4, 1, 5, Block.STONE.stateId()); + palette.set(4, 1, 3, Block.STONE.stateId()); + palette.set(5, 1, 4, Block.STONE.stateId()); + palette.set(4, 2, 4, Block.STONE.stateId()); + palette.set(4, 0, 4, Block.STONE.stateId()); + + var result = LightCompute.compute(palette); + assertLight(result, Map.ofEntries( + // Glowstone + entry(new Vec(4, 1, 4), 15), + // Stair + entry(new Vec(3, 1, 4), 14), + // Front of stair + entry(new Vec(2, 1, 4), 13), + // Others + entry(new Vec(3, 0, 5), 10), + entry(new Vec(3, 0, 3), 12))); + } + + void assertLight(LightCompute.Result result, Map expectedLights) { + List errors = new ArrayList<>(); + for (int x = 0; x < 16; x++) { + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + var expected = expectedLights.get(new Vec(x, y, z)); + if (expected != null) { + final byte light = result.getLight(x, y, z); + if (light != expected) { + errors.add(String.format("Expected %d at [%d,%d,%d] but got %d", expected, x, y, z, light)); + } + } + } + } + } + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (String s : errors) { + sb.append(s).append("\n"); + } + System.err.println(sb); + fail(); + } + } +} diff --git a/src/test/java/net/minestom/server/instance/light/LightParityIntegrationTest.java b/src/test/java/net/minestom/server/instance/light/LightParityIntegrationTest.java new file mode 100644 index 000000000..42f9f913c --- /dev/null +++ b/src/test/java/net/minestom/server/instance/light/LightParityIntegrationTest.java @@ -0,0 +1,164 @@ +package net.minestom.server.instance.light; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.*; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.palette.Palette; +import net.minestom.testing.Env; +import net.minestom.testing.EnvTest; +import org.jglrxavpok.hephaistos.mca.AnvilException; +import org.jglrxavpok.hephaistos.mca.BlockState; +import org.jglrxavpok.hephaistos.mca.ChunkSection; +import org.jglrxavpok.hephaistos.mca.RegionFile; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@EnvTest +public class LightParityIntegrationTest { + + @Test + public void test(Env env) throws URISyntaxException, IOException, AnvilException { + assumeTrue(false); + + Map sections = retrieveSections(); + // Generate our own light + + InstanceContainer instance = (InstanceContainer) env.createFlatInstance(); + instance.setChunkSupplier(LightingChunk::new); + instance.setChunkLoader(new AnvilLoader(Path.of("./src/test/resources/net/minestom/server/instance/lighting"))); + + int end = 4; + // Load the chunks + for (int x = 0; x < end; x++) { + for (int z = 0; z < end; z++) { + instance.loadChunk(x, z).join(); + } + } + + LightingChunk.relight(instance, instance.getChunks()); + + int differences = 0; + int differencesZero = 0; + int blocks = 0; + int sky = 0; + + for (Chunk chunk : instance.getChunks()) { + if (chunk.getChunkX() == 0 || chunk.getChunkZ() == 0) { + continue; + } + + if (chunk.getChunkX() == end - 1 || chunk.getChunkZ() == end - 1) { + continue; + } + + for (int sectionIndex = chunk.getMinSection(); sectionIndex < chunk.getMaxSection(); sectionIndex++) { + if (sectionIndex != 3) continue; + + Section section = chunk.getSection(sectionIndex); + + Light sectionLight = section.blockLight(); + Light sectionSkyLight = section.skyLight(); + SectionEntry sectionEntry = sections.get(new Vec(chunk.getChunkX(), sectionIndex, chunk.getChunkZ())); + if (sectionEntry == null) { + continue; + } + + byte[] serverBlock = sectionLight.array(); + byte[] mcaBlock = sectionEntry.block; + + byte[] serverSky = sectionSkyLight.array(); + byte[] mcaSky = sectionEntry.sky; + + for (int x = 0; x < 16; ++x) { + for (int y = 0; y < 16; ++y) { + for (int z = 0; z < 16; ++z) { + int index = x | (z << 4) | (y << 8); + + { + int serverBlockValue = LightCompute.getLight(serverBlock, index); + int mcaBlockValue = mcaBlock.length == 0 ? 0 : LightCompute.getLight(mcaBlock, index); + + if (serverBlockValue != mcaBlockValue) { + if (serverBlockValue == 0) differencesZero++; + else differences++; + blocks++; + } + } + + { + int serverSkyValue = LightCompute.getLight(serverSky, index); + int mcaSkyValue = mcaSky.length == 0 ? 0 : LightCompute.getLight(mcaSky, index); + + if (serverSkyValue != mcaSkyValue) { + if (serverSkyValue == 0) differencesZero++; + else differences++; + sky++; + } + } + } + } + } + } + } + + assertEquals(0, differences); + assertEquals(0, differencesZero); + assertEquals(0, blocks); + assertEquals(0, sky); + } + + record SectionEntry(Palette blocks, byte[] sky, byte[] block) { + } + + private static Map retrieveSections() throws IOException, URISyntaxException, AnvilException { + URL defaultImage = LightParityIntegrationTest.class.getResource("/net/minestom/server/instance/lighting/region/r.0.0.mca"); + assert defaultImage != null; + File imageFile = new File(defaultImage.toURI()); + var regionFile = new RegionFile(new RandomAccessFile(imageFile, "rw"), + 0, 0, -64, 384); + + Map sections = new HashMap<>(); + // Read from anvil + for (int x = 1; x < 3; x++) { + for (int z = 1; z < 3; z++) { + var chunk = regionFile.getChunk(x, z); + if (chunk == null) continue; + + for (int yLevel = chunk.getMinY(); yLevel <= chunk.getMaxY(); yLevel += 16) { + var section = chunk.getSection((byte) (yLevel/16)); + var palette = loadBlocks(section); + var sky = section.getSkyLights(); + var block = section.getBlockLights(); + sections.put(new Vec(x, section.getY(), z), new SectionEntry(palette, sky, block)); + } + } + } + return sections; + } + + private static Palette loadBlocks(ChunkSection section) throws AnvilException { + var palette = Palette.blocks(); + for (int x = 0; x < Chunk.CHUNK_SECTION_SIZE; x++) { + for (int z = 0; z < Chunk.CHUNK_SECTION_SIZE; z++) { + for (int y = 0; y < Chunk.CHUNK_SECTION_SIZE; y++) { + final BlockState blockState = section.get(x, y, z); + final String blockName = blockState.getName(); + Block block = Objects.requireNonNull(Block.fromNamespaceId(blockName)) + .withProperties(blockState.getProperties()); + palette.set(x, y, z, block.stateId()); + } + } + } + return palette; + } +} \ No newline at end of file diff --git a/src/test/java/net/minestom/server/instance/light/WorldRelightIntegrationTest.java b/src/test/java/net/minestom/server/instance/light/WorldRelightIntegrationTest.java new file mode 100644 index 000000000..a5ad6666b --- /dev/null +++ b/src/test/java/net/minestom/server/instance/light/WorldRelightIntegrationTest.java @@ -0,0 +1,67 @@ +package net.minestom.server.instance.light; + +import net.minestom.server.ServerProcess; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.LightingChunk; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.minestom.testing.EnvTest; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.minestom.server.instance.BlockLightMergeIntegrationTest.assertLightInstance; + +@EnvTest +public class WorldRelightIntegrationTest { + private @NotNull Instance createLightingInstance(@NotNull ServerProcess process) { + var instance = process.instance().createInstanceContainer(); + instance.setGenerator(unit -> { + unit.modifier().fillHeight(39, 40, Block.STONE); + unit.subdivide().forEach(u -> u.modifier().setBlock(0, 10, 0, Block.GLOWSTONE)); + unit.modifier().fillHeight(50, 51, Block.STONE); + }); + return instance; + } + + @Test + public void testBorderLava(Env env) { + Instance instance = env.createFlatInstance(); + instance.loadChunk(6, 16).join(); + instance.loadChunk(6, 15).join(); + + instance.setBlock(106, 70, 248, Block.LAVA); + instance.setBlock(106, 71, 249, Block.LAVA); + + Map expectedLights = Map.ofEntries( + entry(new Vec(105, 72, 256), 6) + ); + + LightingChunk.relight(instance, instance.getChunks()); + assertLightInstance(instance, expectedLights); + } + + @Test + public void testBlockRemoval(Env env) { + Instance instance = createLightingInstance(env.process()); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + instance.loadChunk(x, z).join(); + } + } + + LightingChunk.relight(instance, instance.getChunks()); + + var expectedLights = Map.ofEntries( + entry(new Vec(-1, 40, 0), 12), + entry(new Vec(-9, 40, 8), 0), + entry(new Vec(-1, 40, -16), 12), + entry(new Vec(-1, 37, 0), 3), + entry(new Vec(-8, 37, -8), 0) + ); + assertLightInstance(instance, expectedLights); + } +} diff --git a/src/test/resources/net/minestom/server/instance/lighting/region/r.0.0.mca b/src/test/resources/net/minestom/server/instance/lighting/region/r.0.0.mca new file mode 100644 index 000000000..f93c1bc4c Binary files /dev/null and b/src/test/resources/net/minestom/server/instance/lighting/region/r.0.0.mca differ