Calculate hull visibility on block faces

Instead of a block occluding as a whole, the hull stores which of its six
faces are full, using the occlusion shapes the client culls faces with. A ray
inside a block can only leave it through an open face and enter the next
block through its open opposite face; if only that face is full, the ray
reaches its surface, so the block is visible, but stops there. Slabs, stairs
and stacked slabs now hide what is behind them; double slabs need no special
case anymore. Faces only open up on block updates, after which the rays that
could not get into, out of or past the block are continued.

Hidden blocks occlude by the same decision as the techhider, so waterlogged
blocks count as well when water is hidden. The hull therefore takes the
ObfuscationConfig, which the fight shares with its TechHiderWrapper.
This commit is contained in:
2026-10-31 21:18:24 +01:00
parent e1ff37bea2
commit baa4cf34e5
4 changed files with 147 additions and 80 deletions
+139 -76
View File
@@ -24,24 +24,26 @@ import com.mojang.authlib.GameProfile;
import de.steamwar.core.CraftbukkitWrapper;
import de.steamwar.core.ProtocolWrapper;
import de.steamwar.entity.REntity;
import de.steamwar.techhider.ObfuscationConfig;
import it.unimi.dsi.fastutil.shorts.Short2ObjectArrayMap;
import lombok.AllArgsConstructor;
import lombok.Getter;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.SectionPos;
import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket;
import net.minecraft.network.protocol.game.ClientboundSectionBlocksUpdatePacket;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Tag;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Slab;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.craftbukkit.block.data.CraftBlockData;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
@@ -49,24 +51,28 @@ import org.bukkit.plugin.Plugin;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.stream.IntStream;
public class Hull {
private static final IntVector[] NO_BRANCHES = new IntVector[0];
private static final int ALL_FACES = 0b111111;
private static final byte UNKNOWN_FACES = -1;
private final Plugin plugin;
private final World world;
@Getter
private final HullRegion region;
private final boolean groundVisible;
private final Set<Material> hiddenBlocks;
private final ObfuscationConfig obfuscation;
private final IntVector[] directions;
private final BitSet occluding;
private final byte[] fullFaces; // The faces of each block that are fully covered, see face()
private byte[] fullFacesByState; // Cache, per hull as the hidden blocks differ
private final BitSet visibility;
private final HashMap<IntVector, BitSet> visibilityDirections = new HashMap<>(); // Contains the visible directions of each occluding visible block
private final HashMap<IntVector, BitSet> visibilityDirections = new HashMap<>(); // The blocks the rays of each direction got inside
private final HashMap<IntVector, IntVector[]> branches = new HashMap<>(); // The diagonal directions a ray along a single axis branches into
private final HashSet<IntVector> uncoveredSurface = new HashSet<>();
@@ -76,17 +82,17 @@ public class Hull {
/**
* @param groundVisible whether the bottom layer of the region is uncovered (if the region does not reach the ground)
* @param hiddenBlocks blocks that occlude like solid blocks, as the techhider replaces them by an occluding block
* @param obfuscation the techhider applied to the hull, the blocks it hides occlude as they are replaced by an occluding block
* @param viewers which players may see the hull contents, a new instance for every hull
*/
public Hull(Plugin plugin, World world, HullRegion region, boolean groundVisible, Set<Material> hiddenBlocks, Viewers viewers) {
public Hull(Plugin plugin, World world, HullRegion region, boolean groundVisible, ObfuscationConfig obfuscation, Viewers viewers) {
this.plugin = plugin;
this.world = world;
this.region = region;
this.groundVisible = groundVisible;
this.hiddenBlocks = hiddenBlocks;
this.obfuscation = obfuscation;
this.viewers = viewers;
this.occluding = new BitSet(region.volume());
this.fullFaces = new byte[region.volume()];
this.visibility = new BitSet(region.volume());
directions = IntStream.range(0, 27)
@@ -101,17 +107,42 @@ public class Hull {
}
}
private boolean isOccluding(Material material) {
return material.isOccluding() || hiddenBlocks.contains(material);
/**
* @return the bit of the face of a block in the single-axis direction
*/
private static int face(int dx, int dy, int dz) {
if (dx != 0) return dx > 0 ? 1 : 1 << 1;
if (dy != 0) return dy > 0 ? 1 << 2 : 1 << 3;
return dz > 0 ? 1 << 4 : 1 << 5;
}
private boolean isOccluding(BlockData data) {
return isOccluding(data.getMaterial()) || isDoubleSlab(data);
private int fullFacesOf(BlockState state) {
if (fullFacesByState == null) {
fullFacesByState = new byte[net.minecraft.world.level.block.Block.BLOCK_STATE_REGISTRY.size()];
Arrays.fill(fullFacesByState, UNKNOWN_FACES);
}
int id = net.minecraft.world.level.block.Block.getId(state);
if (fullFacesByState[id] == UNKNOWN_FACES) {
fullFacesByState[id] = (byte) computeFullFaces(state);
}
return fullFacesByState[id];
}
// Double slabs are full blocks, but slab materials do not occlude
private static boolean isDoubleSlab(BlockData data) {
return data instanceof Slab slab && slab.getType() == Slab.Type.DOUBLE;
// The faces that hide what is behind them, as the client culls them
private int computeFullFaces(BlockState state) {
// Hidden blocks are replaced by an occluding block, the same decision as the techhider's
if (obfuscation.hidesBlock(state.getBlock()) || obfuscation.hidesBlockState(state)) return ALL_FACES;
if (!state.canOcclude()) return 0;
VoxelShape shape = state.getOcclusionShape();
int faces = 0;
for (Direction direction : Direction.values()) {
if (net.minecraft.world.level.block.Block.isFaceFull(shape, direction)) {
faces |= face(direction.getStepX(), direction.getStepY(), direction.getStepZ());
}
}
return faces;
}
public boolean isBlockHidden(Player player, int x, int y, int z) {
@@ -247,7 +278,7 @@ public class Hull {
public void fill(boolean visible) {
visibility.set(0, visibility.size(), visible);
occluding.set(0, occluding.size(), !visible);
Arrays.fill(fullFaces, (byte) (visible ? 0 : ALL_FACES));
uncoveredSurface.clear();
for (BitSet directionalVisibility : visibilityDirections.values()) {
directionalVisibility.set(0, directionalVisibility.size(), visible);
@@ -256,67 +287,59 @@ public class Hull {
public void initialize() {
visibility.clear();
occluding.clear();
uncoveredSurface.clear();
for (BitSet directionalVisibility : visibilityDirections.values()) {
directionalVisibility.clear();
}
long start = System.currentTimeMillis();
region.forEach((x, y, z) -> {
Block block = world.getBlockAt(x, y, z);
Material type = block.getType();
// Only slabs need their block data
if (isOccluding(type) || Tag.SLABS.isTagged(type) && isDoubleSlab(block.getBlockData())) {
occluding.set(region.toIndex(x, y, z));
}
});
forEachBorder((root, direction) -> uncoverBlocks(new NullList<>(), root, direction));
ServerLevel level = ((CraftWorld) world).getHandle();
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
region.forEach((x, y, z) -> fullFaces[region.toIndex(x, y, z)] = (byte) fullFacesOf(level.getBlockState(pos.set(x, y, z))));
forEachBorder((root, direction) -> startRay(new NullList<>(), root, direction));
plugin.getLogger().log(Level.INFO, () -> "[HullHider] initialisation finished: " + (System.currentTimeMillis() - start) + " ms, visible blocks: " + visibility.cardinality());
}
public void updateBlockVisibility(Block b, BlockData changedData) {
IntVector root = new IntVector(b.getX(), b.getY(), b.getZ());
if (root.notInRegion(region)) return;
if (root.notInRegion(region)) return;
int id = root.toId(region);
if (!occluding.get(id) || isOccluding(changedData)) return;
occluding.clear(id);
// Faces only open up, the hull never hides anything again
int faces = fullFaces[id] & fullFacesOf(((CraftBlockData) changedData).getState());
if (faces == fullFaces[id]) return;
fullFaces[id] = (byte) faces;
List<IntVector> uncovered = new ArrayList<>();
if (visibility.get(id)) {
for (Map.Entry<IntVector, BitSet> directionalVisibility : visibilityDirections.entrySet()) {
if (directionalVisibility.getValue().get(id)) {
directionalVisibility.getValue().clear(id);
uncoverBlocks(uncovered, root, directionalVisibility.getKey());
}
// Rays inside the block that could not leave it
for (Map.Entry<IntVector, BitSet> directionalVisibility : visibilityDirections.entrySet()) {
if (directionalVisibility.getValue().get(id)) {
directionalVisibility.getValue().clear(id);
uncoverBlocks(uncovered, root, directionalVisibility.getKey());
}
}
// Also if the block was not visible itself, it might have blocked diagonal rays passing it
uncoverDiagonalsBeside(uncovered, root);
// Rays that could not get into the block or past it
uncoverBeside(uncovered, root);
forEachBorderStart(root, direction -> startRay(uncovered, root, direction));
uncoveredSurface.addAll(new HashSet<>(uncovered));
uncoveredSurface.remove(root);
}
/**
* Continues the diagonal rays that could not pass the block while it occluded.
* The block lies between the start and the end of a diagonal step if it is the start moved along some, but not all, axes of the direction.
* Continues the rays inside the blocks in front of the block, or of which a diagonal ray moves past the block.
* The block lies in front of or beside the start of a step if it is the start moved along some or all axes of the direction.
*/
private void uncoverDiagonalsBeside(List<IntVector> uncovered, IntVector root) {
for (IntVector diagonal : directions) {
if (diagonal.sqLength() < 2) continue;
private void uncoverBeside(List<IntVector> uncovered, IntVector root) {
for (IntVector direction : directions) {
if (direction.sqLength() == 0) continue;
for (IntVector part : directions) {
if (part.sqLength() == 0 || part.sqLength() >= diagonal.sqLength() || !isPartOf(part, diagonal)) continue;
if (part.sqLength() == 0 || !isPartOf(part, direction)) continue;
IntVector from = root.add(-part.x, -part.y, -part.z);
if (from.notInRegion(region)) continue;
int fromId = from.toId(region);
if (!occluding.get(fromId) && reachedBy(fromId, diagonal) && canPass(from, diagonal)) {
uncoverBlocks(uncovered, from.add(diagonal), diagonal, visibilityDirections.get(diagonal), NO_BRANCHES);
if (!from.notInRegion(region) && isInside(from.toId(region), direction)) {
uncoverNext(uncovered, from, direction, visibilityDirections.get(direction), branches.get(direction));
}
}
}
@@ -329,13 +352,13 @@ public class Hull {
}
/**
* @return whether a ray moving in the diagonal direction reached the block: a diagonal ray or a branch of a cardinal ray
* @return whether a ray moving in the direction is inside the block: a ray of this direction or a branch of a ray along a single axis
*/
private boolean reachedBy(int id, IntVector diagonal) {
if (visibilityDirections.get(diagonal).get(id)) return true;
private boolean isInside(int id, IntVector direction) {
if (visibilityDirections.get(direction).get(id)) return true;
for (IntVector cardinal : directions) {
if (cardinal.sqLength() == 1 && dot(cardinal, diagonal) == 1 && visibilityDirections.get(cardinal).get(id)) {
if (cardinal.sqLength() == 1 && dot(cardinal, direction) == 1 && visibilityDirections.get(cardinal).get(id)) {
return true;
}
}
@@ -387,6 +410,16 @@ public class Hull {
}
}
// The rays forEachBorder starts at the block
private void forEachBorderStart(IntVector block, Consumer<IntVector> f) {
if (groundVisible && block.y == region.getMinY()) f.accept(new IntVector(0, 1, 0));
if (block.y == region.getMaxY() - 1) f.accept(new IntVector(0, -1, 0));
if (block.z == region.getMinZ()) f.accept(new IntVector(0, 0, 1));
if (block.z == region.getMaxZ() - 1) f.accept(new IntVector(0, 0, -1));
if (block.x == region.getMinX()) f.accept(new IntVector(1, 0, 0));
if (block.x == region.getMaxX() - 1) f.accept(new IntVector(-1, 0, 0));
}
private void forEachBorder(BiConsumer<IntVector, IntVector> f) {
for (int x = region.getMinX(); x < region.getMaxX(); x++) {
for (int z = region.getMinZ(); z < region.getMaxZ(); z++) {
@@ -412,23 +445,31 @@ public class Hull {
}
}
/**
* Starts a ray at the border of the region, coming from outside.
*/
private void startRay(List<IntVector> uncovered, IntVector block, IntVector direction) {
if (faceFull(block.x, block.y, block.z, -direction.x, -direction.y, -direction.z)) {
reveal(uncovered, block, block.toId(region));
} else {
uncoverBlocks(uncovered, block, direction);
}
}
private void uncoverBlocks(List<IntVector> uncovered, IntVector block, IntVector direction) {
uncoverBlocks(uncovered, block, direction, visibilityDirections.get(direction), branches.get(direction));
}
/**
* Continues a ray that got inside the block.
*/
private void uncoverBlocks(List<IntVector> uncovered, IntVector block, IntVector direction, BitSet directionalVisibility, IntVector[] branchDirections) {
if (block.notInRegion(region)) return;
int id = block.toId(region);
if (directionalVisibility.get(id)) return;
directionalVisibility.set(id);
if (!visibility.get(id)) {
visibility.set(id);
uncovered.add(block);
}
if (occluding.get(id)) return;
reveal(uncovered, block, id);
uncoverNext(uncovered, block, direction, directionalVisibility, branchDirections);
for (IntVector branchDirection : branchDirections) {
@@ -441,29 +482,51 @@ public class Hull {
int y = block.y + direction.y;
int z = block.z + direction.z;
// The cheap checks first: most blocks were already reached from this direction
if (!region.contains(x, y, z) || directionalVisibility.get(region.toIndex(x, y, z)) || !canPass(block, direction)) return;
if (!region.contains(x, y, z) || directionalVisibility.get(region.toIndex(x, y, z))) return;
uncoverBlocks(uncovered, new IntVector(x, y, z), direction, directionalVisibility, branchDirections);
if (canMove(block.x, block.y, block.z, direction.x, direction.y, direction.z, true)) {
uncoverBlocks(uncovered, new IntVector(x, y, z), direction, directionalVisibility, branchDirections);
return;
}
// The ray might still reach the face of the next block, mostly it is visible already
int id = region.toIndex(x, y, z);
if (!visibility.get(id) && canMove(block.x, block.y, block.z, direction.x, direction.y, direction.z, false)) {
reveal(uncovered, new IntVector(x, y, z), id);
}
}
private void reveal(List<IntVector> uncovered, IntVector block, int id) {
if (!visibility.get(id)) {
visibility.set(id);
uncovered.add(block);
}
}
/**
* A ray can only move diagonally if it could also get there by moving along the single axes of the direction one after another,
* in any order, without passing an occluding block. Otherwise, it would pass between blocks that only touch at an edge or corner.
* A ray moves along the single axes of its direction one after another, in any order that does not pass a full face.
* So a diagonal ray does not pass between blocks that only touch at an edge or corner.
*
* @param enter whether the ray has to get inside the target block, otherwise reaching its face suffices
*/
private boolean canPass(IntVector block, IntVector direction) {
if (direction.sqLength() <= 1) return true;
// The ray ends there anyway; inside the region, all blocks in between are inside as well
if (!region.contains(block.x + direction.x, block.y + direction.y, block.z + direction.z)) return true;
private boolean canMove(int x, int y, int z, int dx, int dy, int dz, boolean enter) {
if (dx == 0 && dy == 0 && dz == 0) return true;
return canPass(block.x, block.y, block.z, direction.x, direction.y, direction.z);
// The blocks in between have to be entered
return (dx != 0 && canCross(x, y, z, dx, 0, 0, enter || dy != 0 || dz != 0) && canMove(x + dx, y, z, 0, dy, dz, enter))
|| (dy != 0 && canCross(x, y, z, 0, dy, 0, enter || dx != 0 || dz != 0) && canMove(x, y + dy, z, dx, 0, dz, enter))
|| (dz != 0 && canCross(x, y, z, 0, 0, dz, enter || dx != 0 || dy != 0) && canMove(x, y, z + dz, dx, dy, 0, enter));
}
private boolean canPass(int x, int y, int z, int dx, int dy, int dz) {
if (Math.abs(dx) + Math.abs(dy) + Math.abs(dz) <= 1) return true;
/**
* @return whether a ray inside the block can leave it to the neighbouring block in the single-axis direction (and enter it)
*/
private boolean canCross(int x, int y, int z, int dx, int dy, int dz, boolean enter) {
return !faceFull(x, y, z, dx, dy, dz) && (!enter || !faceFull(x + dx, y + dy, z + dz, -dx, -dy, -dz));
}
return (dx != 0 && !occluding.get(region.toIndex(x + dx, y, z)) && canPass(x + dx, y, z, 0, dy, dz))
|| (dy != 0 && !occluding.get(region.toIndex(x, y + dy, z)) && canPass(x, y + dy, z, dx, 0, dz))
|| (dz != 0 && !occluding.get(region.toIndex(x, y, z + dz)) && canPass(x, y, z + dz, dx, dy, 0));
private boolean faceFull(int x, int y, int z, int dx, int dy, int dz) {
return (fullFaces[region.toIndex(x, y, z)] & face(dx, dy, dz)) != 0;
}