feat: raycasting api

This commit is contained in:
DeidaraMC 2024-04-01 21:50:32 -04:00
parent 63f02929ed
commit 640532ea1c
11 changed files with 1097 additions and 0 deletions

View File

@ -0,0 +1,64 @@
package net.minestom.server.collision;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.instance.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.NoSuchElementException;
/**
* The result of a {@link Ray#cast(Block.Getter)}
*/
public interface BlockCastResult extends CastResult {
/**
* Whether this {@link BlockCastResult} has a block collision or not
*
* @return true if there is a block collision, otherwise false
*/
boolean hasBlockCollision();
/**
* Returns the first block collision from the proceeding {@link Ray#cast}
*
* @return the first block collision
* @throws NoSuchElementException when the block cast result has no {@link BlockRayCollision}
*/
@NotNull BlockRayCollision firstBlockCollision();
/**
* Returns the last block collision from the proceeding {@link Ray#cast}
*
* @return the last block collision
* @throws NoSuchElementException when the block cast result has no {@link BlockRayCollision}
*/
@NotNull BlockRayCollision lastBlockCollision();
/**
* Returns an ordered immutable {@link BlockRayCollision} list from the proceeding {@link Ray#cast}.
*
* @return the list of block collisions
*/
@NotNull List<BlockRayCollision> blockCollisions();
record BlockRayCollision(@NotNull Point entry, @NotNull Point exit, @Nullable Vec entrySurfaceNormal,
@Nullable Vec exitSurfaceNormal, @NotNull Block block) implements CastResult.RayCollision {
@Override
public @NotNull Vec entrySurfaceNormal() {
if (entrySurfaceNormal == null) {
throw new NoSuchElementException("Cast ray with Ray.Configuration.computeSurfaceNormals = true");
}
return entrySurfaceNormal;
}
@Override
public @NotNull Vec exitSurfaceNormal() {
if (exitSurfaceNormal == null) {
throw new NoSuchElementException("Cast ray with Ray.Configuration.computeSurfaceNormals = true");
}
return exitSurfaceNormal;
}
}
}

View File

@ -0,0 +1,40 @@
package net.minestom.server.collision;
import org.jetbrains.annotations.NotNull;
import java.util.Collections;
import java.util.List;
record BlockCastResultImpl(@NotNull List<BlockRayCollision> blockCollisions) implements BlockCastResult
{
BlockCastResultImpl {
blockCollisions = Collections.unmodifiableList(blockCollisions);
}
public boolean hasCollision() {
return !blockCollisions.isEmpty();
}
public @NotNull CastResult.RayCollision firstCollision() {
return blockCollisions.getFirst();
}
public @NotNull CastResult.RayCollision lastCollision() {
return blockCollisions.getLast();
}
@Override
public boolean hasBlockCollision() {
return !blockCollisions.isEmpty();
}
@Override
public @NotNull BlockRayCollision firstBlockCollision() {
return blockCollisions.getFirst();
}
@Override
public @NotNull BlockRayCollision lastBlockCollision() {
return blockCollisions.getLast();
}
}

View File

@ -0,0 +1,75 @@
package net.minestom.server.collision;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Vec;
import org.jetbrains.annotations.NotNull;
import java.util.NoSuchElementException;
/**
* The result of any {@link Ray#cast}
*/
public interface CastResult {
/**
* Returns this {@link CastResult} has a collision or not.
*
* @return true if there is any collision, otherwise false
*/
boolean hasCollision();
/**
* Returns the first collision from the proceeding {@link Ray#cast}.
*
* @return the first collision
* @throws NoSuchElementException when the cast result has no {@link RayCollision}
*/
@NotNull RayCollision firstCollision();
/**
* Returns the last collision from the proceeding {@link Ray#cast}.
*
* @return the last collision
* @throws NoSuchElementException when the cast result has no {@link RayCollision}
*/
@NotNull RayCollision lastCollision();
/**
* Represents a pair of intersection entry & exit points
* along with their surface normals.
*/
sealed interface RayCollision permits EntityCastResult.EntityRayCollision, BlockCastResult.BlockRayCollision {
/**
* The {@link Point} where the ray entered the bounding box.
*
* @return the entry point
*/
@NotNull Point entry();
/**
* The {@link Point} where the ray exited the bounding box.
*
* @return the exit point
*/
@NotNull Point exit();
/**
* The surface normal corresponding to the face of the bounding box that
* the ray entered
*
* @throws NoSuchElementException when the {@link Ray} was not configured with
* {@link Ray.Configuration#computeSurfaceNormals()}
* @return the entry surface normal
*/
@NotNull Vec entrySurfaceNormal();
/**
* The surface normal corresponding to the face of the bounding box that
* the ray exited
*
* @throws NoSuchElementException when the {@link Ray} was not configured with
* {@link Ray.Configuration#computeSurfaceNormals()}
* @return the exit surface normal
*/
@NotNull Vec exitSurfaceNormal();
}
}

View File

@ -0,0 +1,50 @@
package net.minestom.server.collision;
import net.minestom.server.instance.block.Block;
import org.jetbrains.annotations.NotNull;
import java.util.Collection;
import java.util.List;
/**
* The result of a {@link Ray#cast(Block.Getter, Collection)}
*/
public interface EntityBlockCastResult extends BlockCastResult, EntityCastResult {
/**
* Generates a list of entities that intersected the ray
* before the collisionThreshold-th block was intersected.
*
* @param collisionThreshold the amount of blocks the ray could pass through
* before entities are no longer included
* @return the ordered list of entities before the
* block collisionThreshold is reached
*/
@NotNull List<EntityRayCollision> findEntitiesBeforeBlockCollision(int collisionThreshold);
/**
* Generate a list of entities that intersected the ray
* before the ray intersected a block.
*
* @see EntityBlockCastResult#findEntitiesBeforeBlockCollision(int)
*/
@NotNull List<EntityRayCollision> findEntitiesBeforeBlockCollision();
/**
* Generates a list of blocks that intersected the ray
* before the collisionThreshold-th entity was intersected.
*
* @param collisionThreshold the amount of entities the ray could pass through
* before blocks are no longer included
* @return the ordered list of blocks before the
* entity collisionThreshold is reached
*/
@NotNull List<BlockRayCollision> findBlocksBeforeEntityCollision(int collisionThreshold);
/**
* Generate a list of blocks that intersected the ray
* before the ray intersected an entity.
*
* @see EntityBlockCastResult#findBlocksBeforeEntityCollision(int)
*/
@NotNull List<BlockRayCollision> findBlocksBeforeEntityCollision();
}

View File

@ -0,0 +1,81 @@
package net.minestom.server.collision;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
record EntityBlockRaycastResultImpl(@NotNull List<EntityRayCollision> entityCollisions, @NotNull List<BlockRayCollision> blockCollisions,
@NotNull List<RayCollision> collisions) implements EntityBlockCastResult {
EntityBlockRaycastResultImpl {
blockCollisions = Collections.unmodifiableList(blockCollisions);
entityCollisions = Collections.unmodifiableList(entityCollisions);
collisions = Collections.unmodifiableList(collisions);
}
@Override
public @NotNull List<EntityCastResult.EntityRayCollision> findEntitiesBeforeBlockCollision(int collisionThreshold) {
return RaycastUtils.findEntitiesBeforeEntityCollision(collisions, collisionThreshold);
}
@Override
public @NotNull List<EntityCastResult.EntityRayCollision> findEntitiesBeforeBlockCollision() {
return findEntitiesBeforeBlockCollision(1);
}
@Override
public @NotNull List<BlockCastResult.BlockRayCollision> findBlocksBeforeEntityCollision(int collisionThreshold) {
return RaycastUtils.findBlocksBeforeEntityCollision(collisions, collisionThreshold);
}
@Override
public @NotNull List<BlockCastResult.BlockRayCollision> findBlocksBeforeEntityCollision() {
return findBlocksBeforeEntityCollision(1);
}
@Override
public boolean hasCollision() {
return !collisions.isEmpty();
}
@Override
public @NotNull CastResult.RayCollision firstCollision() {
return collisions.getFirst();
}
@Override
public @NotNull CastResult.RayCollision lastCollision() {
return collisions.getLast();
}
@Override
public boolean hasEntityCollision() {
return !entityCollisions.isEmpty();
}
@Override
public @NotNull EntityCastResult.EntityRayCollision firstEntityCollision() {
return entityCollisions.getFirst();
}
@Override
public @NotNull EntityCastResult.EntityRayCollision lastEntityCollision() {
return entityCollisions.getLast();
}
@Override
public boolean hasBlockCollision() {
return !blockCollisions.isEmpty();
}
@Override
public @NotNull BlockRayCollision firstBlockCollision() {
return blockCollisions.getFirst();
}
@Override
public @NotNull BlockRayCollision lastBlockCollision() {
return blockCollisions.getLast();
}
}

View File

@ -0,0 +1,69 @@
package net.minestom.server.collision;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.Entity;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.List;
import java.util.NoSuchElementException;
/**
* The result of a {@link Ray#cast(Collection)}
*/
public interface EntityCastResult extends CastResult {
/**
* Whether this {@link EntityCastResult} has an entity collision or not
*
* @return true if there is an entity collision, otherwise false
*/
boolean hasEntityCollision();
/**
* Returns the first entity collision from the proceeding {@link Ray#cast}
*
* @return the first entity collision
* @throws NoSuchElementException when the entity cast result has no {@link EntityRayCollision}
*/
@NotNull EntityCastResult.EntityRayCollision firstEntityCollision();
/**
* Returns the last entity collision from the proceeding {@link Ray#cast}
*
* @return the last entity collision
* @throws NoSuchElementException when the entity cast result has no {@link EntityRayCollision}
*/
@NotNull EntityCastResult.EntityRayCollision lastEntityCollision();
/**
* Returns an ordered immutable {@link EntityRayCollision} list from the proceeding {@link Ray#cast}.
*
* @return the list of entity collisions
*/
@NotNull List<EntityRayCollision> entityCollisions();
/**
* @param entity the intersected entity
* @see CastResult.RayCollision
*/
record EntityRayCollision(@NotNull Point entry, @NotNull Point exit, @Nullable Vec entrySurfaceNormal,
@Nullable Vec exitSurfaceNormal, @NotNull Entity entity) implements RayCollision {
@Override
public @NotNull Vec entrySurfaceNormal() {
if (entrySurfaceNormal == null) {
throw new NoSuchElementException("Cast ray with Ray.Configuration.computeSurfaceNormals = true");
}
return entrySurfaceNormal;
}
@Override
public @NotNull Vec exitSurfaceNormal() {
if (exitSurfaceNormal == null) {
throw new NoSuchElementException("Cast ray with Ray.Configuration.computeSurfaceNormals = true");
}
return exitSurfaceNormal;
}
}
}

View File

@ -0,0 +1,40 @@
package net.minestom.server.collision;
import org.jetbrains.annotations.NotNull;
import java.util.Collections;
import java.util.List;
record EntityCastResultImpl(@NotNull List<EntityRayCollision> entityCollisions) implements EntityCastResult
{
EntityCastResultImpl {
entityCollisions = Collections.unmodifiableList(entityCollisions);
}
public boolean hasCollision() {
return !entityCollisions.isEmpty();
}
public @NotNull CastResult.RayCollision firstCollision() {
return entityCollisions.getFirst();
}
public @NotNull CastResult.RayCollision lastCollision() {
return entityCollisions.getLast();
}
@Override
public boolean hasEntityCollision() {
return !entityCollisions.isEmpty();
}
@Override
public @NotNull EntityRayCollision firstEntityCollision() {
return entityCollisions.getFirst();
}
@Override
public @NotNull EntityRayCollision lastEntityCollision() {
return entityCollisions.getLast();
}
}

View File

@ -0,0 +1,194 @@
package net.minestom.server.collision;
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.Block;
import net.minestom.server.utils.validate.Check;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import java.util.Collection;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
/**
* Represents an immutable ray which can be cast to find intersections
* through blocks and entities.
*
* @param origin the starting point of this ray
* @param direction the direction of this ray
* @param distance the distance this ray travels
* @param configuration the additional configuration properties for this ray
*/
public record Ray(@NotNull Point origin, @NotNull Vec direction, double distance, @NotNull Configuration configuration) {
/**
* @throws IllegalStateException if distance is less than 0
*/
public Ray {
if (origin instanceof Pos) origin = Vec.fromPoint(origin);
if (!direction.isNormalized()) {
direction = direction.normalize();
}
Check.stateCondition(distance < 0, "Distance cannot be less than 0");
}
@Contract(pure = true)
private Ray(@NotNull Point origin, @NotNull Vec direction, double distance, @NotNull UnaryOperator<Configuration.Builder> unaryOperator) {
this(origin, direction, distance, unaryOperator.apply(Configuration.builder()).build());
}
@Contract(pure = true)
public Ray(@NotNull Point origin, @NotNull Vec direction, double distance, @NotNull Consumer<Configuration.Builder> consumer) {
this(origin, direction, distance, builder -> {
consumer.accept(builder);
return builder;
});
}
@Contract(pure = true)
public Ray(@NotNull Point origin, @NotNull Vec direction, double distance) {
this(origin, direction, distance, Configuration.DEFAULT);
}
@Contract(pure = true)
public @NotNull Ray withOrigin(@NotNull Point origin) {
return new Ray(origin, direction, distance, configuration);
}
@Contract(pure = true)
public @NotNull Ray withDirection(@NotNull Vec direction) {
return new Ray(origin, direction, distance, configuration);
}
@Contract(pure = true)
public @NotNull Ray withDistance(double distance) {
return new Ray(origin, direction, distance, configuration);
}
@Contract(pure = true)
public @NotNull Ray withConfiguration(@NotNull Configuration configuration) {
return new Ray(origin, direction, distance, configuration);
}
@Contract(pure = true)
public @NotNull Ray withConfiguration(@NotNull Consumer<Configuration.Builder> consumer) {
Configuration.Builder builder = Configuration.builder();
consumer.accept(builder);
return new Ray(origin, direction, distance, builder.build());
}
/**
* Cast this ray against the target entity and block bounding boxes.
* <p>
* Note: entity and block collisions are determined independently,
* intersecting blocks will never stop proceeding entities from being collected;
* the resulting {@link EntityBlockCastResult} gives you tools to filter
* blocks/entities with this logic.
*
* @param blockGetter the {@link Block.Getter} supplying blocks to cast the ray against
* @param entities the entities to cast the ray against
* @return an {@link EntityBlockCastResult} containing the intersections of this ray
*/
public @NotNull EntityBlockCastResult cast(@NotNull Block.Getter blockGetter, @NotNull Collection<Entity> entities) {
return RaycastUtils.performEntityBlockCast(this, blockGetter, entities);
}
/**
* Cast this ray against the target entity bounding boxes.
*
* @param entities the entities to cast the ray against
* @return an {@link EntityCastResult} containing the entities intersected
* by this ray
*/
public @NotNull EntityCastResult cast(@NotNull Collection<Entity> entities) {
return RaycastUtils.performEntityCast(this, entities);
}
/**
* Cast this ray against the target block bounding boxes.
*
* @param blockGetter the {@link Block.Getter} supplying blocks to cast the ray against
* @return the {@link BlockCastResult} containing the blocks intersected by this ray
*/
public @NotNull BlockCastResult cast(@NotNull Block.Getter blockGetter) {
return RaycastUtils.performBlockCast(this, blockGetter);
}
/**
* Represent the collision configuration for a given ray
*
* @param blockFilter the filter blocks must pass to be considered for collision
* note: air is implicitly filtered
* (defaults to allow any block)
* @param blockCollisionLimit stop checking for blocks collisions beyond this limit
* (defaults to {@link Integer#MAX_VALUE})
* note: does not stop entity collision checks beyond this limit
* - Useful for optimization
* @param entityFilter the filter entities must pass to be considered for collision
* (defaults to allow any entity)
* @param entityBoundingBoxExpansion amount to expand the entity {@link BoundingBox} axis to
* increase leniency in the ray cast
* (defaults to no expansion)
* @param computeSurfaceNormals true if surface normals should be computed
* (defaults to false)
*/
public record Configuration(@NotNull Predicate<Block> blockFilter, int blockCollisionLimit, @NotNull Predicate<Entity> entityFilter,
@NotNull Vec entityBoundingBoxExpansion, boolean computeSurfaceNormals) {
public static final Configuration DEFAULT = Configuration.builder().build();
/**
* @throws IllegalStateException if bounding box expansion components are less than 0
*/
public Configuration {
Check.stateCondition(entityBoundingBoxExpansion.x() < 0, "Bounding box x expansion cannot be less than 0");
Check.stateCondition(entityBoundingBoxExpansion.y() < 0, "Bounding box y expansion cannot be less than 0");
Check.stateCondition(entityBoundingBoxExpansion.z() < 0, "Bounding box z expansion cannot be less than 0");
}
public static class Builder {
private Predicate<Block> blockFilter = block -> true;
private int blockCollisionLimit = Integer.MAX_VALUE;
private Predicate<Entity> entityFilter = entity -> true;
private Vec entityBoundingBoxExpansion = Vec.ZERO;
private boolean computeNormals = true;
private @NotNull Configuration build() {
return new Configuration(blockFilter, blockCollisionLimit, entityFilter, entityBoundingBoxExpansion, computeNormals);
}
public @NotNull Builder blockFilter(@NotNull Predicate<Block> blockFilter) {
this.blockFilter = blockFilter;
return this;
}
public @NotNull Builder blockCollisionLimit(int blockCollisionLimit) {
this.blockCollisionLimit = blockCollisionLimit;
return this;
}
public @NotNull Builder entityFilter(@NotNull Predicate<Entity> entityFilter) {
this.entityFilter = entityFilter;
return this;
}
public @NotNull Builder entityBoundingBoxExpansion(double x, double y, double z) {
this.entityBoundingBoxExpansion = new Vec(x, y, z);
return this;
}
public @NotNull Builder computeSurfaceNormals(boolean computeNormals) {
this.computeNormals = computeNormals;
return this;
}
private Builder() { }
}
private static @NotNull Builder builder() {
return new Builder();
}
}
}

View File

@ -0,0 +1,363 @@
package net.minestom.server.collision;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.Entity;
import net.minestom.server.instance.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
/**
* Utils class for {@link Ray}.
*/
class RaycastUtils
{
/**
* Raycasting section
*/
private static final double NORMAL_EPSILON = 0.000001;
private static final Vec MAX_X_NORMAL = new Vec(1, 0, 0);
private static final Vec MIN_X_NORMAL = new Vec(-1, 0, 0);
private static final Vec MAX_Y_NORMAL = new Vec(0, 1, 0);
private static final Vec MIN_Y_NORMAL = new Vec(0, -1, 0);
private static final Vec MAX_Z_NORMAL = new Vec(0, 0, 1);
private static final Vec MIN_Z_NORMAL = new Vec(0, 0, -1);
static @NotNull EntityBlockCastResult performEntityBlockCast(@NotNull Ray ray, @NotNull Block.Getter blockGetter, @NotNull Collection<Entity> entities) {
List<BlockCastResult.BlockRayCollision> blockCollisions = performBlockCast(ray, blockGetter).blockCollisions();
List<EntityCastResult.EntityRayCollision> entityCollisions = performEntityCast(ray, entities).entityCollisions();
// Create an ordered list with all results
ArrayList<CastResult.RayCollision> allRayCollisions = new ArrayList<>(blockCollisions.size() + entityCollisions.size());
allRayCollisions.addAll(blockCollisions);
allRayCollisions.addAll(entityCollisions);
allRayCollisions.sort(Comparator.comparingDouble(a -> a.entry().distanceSquared(ray.origin())));
return new EntityBlockRaycastResultImpl(entityCollisions, blockCollisions, allRayCollisions);
}
static @NotNull BlockCastResult performBlockCast(@NotNull Ray ray, @NotNull Block.Getter blockGetter) {
Vec reciprocal = new Vec(1 / ray.direction().x(), 1 / ray.direction().y(), 1 / ray.direction().z());
return new BlockCastResultImpl(findIntersectingBlocks(ray, blockGetter, reciprocal));
}
static @NotNull EntityCastResult performEntityCast(@NotNull Ray ray, @NotNull Collection<Entity> entities) {
Vec reciprocal = new Vec(1 / ray.direction().x(), 1 / ray.direction().y(), 1 / ray.direction().z());
return new EntityCastResultImpl(findIntersectingEntities(ray, entities, reciprocal));
}
/**
* @param ray the casting ray
* @param blockGetter the getter containing the target blocks
* @param reciprocal the pre-computed ray direction reciprocal (1/vec)
* @return the intersecting block ray collision list
*/
private static @NotNull List<BlockCastResult.BlockRayCollision> findIntersectingBlocks(@NotNull Ray ray, @NotNull Block.Getter blockGetter,
@NotNull Vec reciprocal) {
// Grab every block coordinate that the ray passes through using
// a 3d digital differential analyzer (line drawing) algorithm
boolean infiniteX = Double.isInfinite(reciprocal.x()), infiniteY = Double.isInfinite(reciprocal.y()), infiniteZ = Double.isInfinite(reciprocal.z());
// Initialization phase
Point origin = ray.origin();
Vec direction = ray.direction();
int blockX = origin.blockX();
int blockY = origin.blockY();
int blockZ = origin.blockZ();
final int stepX = direction.x() > 0 ? 1 : -1;
final int stepY = direction.y() > 0 ? 1 : -1;
final int stepZ = direction.z() > 0 ? 1 : -1;
final double deltaX = Math.abs(1 / direction.x());
final double deltaY = Math.abs(1 / direction.y());
final double deltaZ = Math.abs(1 / direction.z());
double currentX = (direction.x() > 0 ? blockX + 1 - origin.x() : origin.x() - blockX) * deltaX;
double currentY = (direction.y() > 0 ? blockY + 1 - origin.y() : origin.y() - blockY) * deltaY;
double currentZ = (direction.z() > 0 ? blockZ + 1 - origin.z() : origin.z() - blockZ) * deltaZ;
if (Double.isNaN(currentX)) currentX = Double.POSITIVE_INFINITY;
if (Double.isNaN(currentY)) currentY = Double.POSITIVE_INFINITY;
if (Double.isNaN(currentZ)) currentZ = Double.POSITIVE_INFINITY;
int collisionLimit = ray.configuration().blockCollisionLimit();
final List<BlockCastResult.BlockRayCollision> collisions = new ArrayList<>(Math.min(collisionLimit, (int) ray.distance() + 1));
// Execution phase
// Test for block at the beginning of the ray
int collisionCount = appendSuccessfulBlockCollisions(ray, blockGetter, blockX, blockY, blockZ, collisions, 0,
reciprocal, infiniteX, infiniteY, infiniteZ);
while (collisionCount < collisionLimit && Math.min(currentX, Math.min(currentY, currentZ)) <= ray.distance()) {
// Travel the minimum distance needed to progress to the next block coordinate
if (currentX < currentZ && currentX < currentY) {
currentX += deltaX;
blockX += stepX;
}
else if (currentZ < currentY) {
currentZ += deltaZ;
blockZ += stepZ;
}
else {
currentY += deltaY;
blockY += stepY;
}
collisionCount += appendSuccessfulBlockCollisions(ray, blockGetter, blockX, blockY, blockZ, collisions, collisionCount,
reciprocal, infiniteX, infiniteY, infiniteZ);
}
return collisions;
}
/**
* @param ray the casting ray
* @param blockGetter the getter containing the target block
* @param blockX the x coordinate of the block to check against
* @param blockY the y coordinate of the block to check against
* @param blockZ the z coordinate of the block to check against
* @param collisions the array to append any successful block collisions to
* @param collisionCount the current collision count; for exiting when a block has
* multiple bounding boxes and exceeds the config limit
* @param reciprocal the pre-computed ray direction reciprocal (1/vec)
* @param infiniteX pass true if the reciprocal x component is infinity
* @param infiniteY pass true if the reciprocal y component is infinity
* @param infiniteZ pass true if the reciprocal z component is infinity
* @return the amount of blocks added to the collisions array
*/
private static int appendSuccessfulBlockCollisions(@NotNull Ray ray, @NotNull Block.Getter blockGetter,
int blockX, int blockY, int blockZ,
@NotNull List<BlockCastResult.BlockRayCollision> collisions,
int collisionCount,
@NotNull Vec reciprocal,
boolean infiniteX, boolean infiniteY, boolean infiniteZ) {
Block block = blockGetter.getBlock(blockX, blockY, blockZ);
if (block.isAir() || !ray.configuration().blockFilter().test(block)) return 0;
BoundingBox[] boxes = ((ShapeImpl) block.registry().collisionShape()).getCollisionBoundingBoxes();
if (boxes.length == 0) return 0;
final double distanceSquared = ray.distance() * ray.distance();
if (boxes.length == 1) {
final BoundingBox box = boxes[0];
CollisionResult result = findBoundingBoxCollision(ray, box, blockX, blockY, blockZ, reciprocal, infiniteX, infiniteY, infiniteZ);
if (result != null && result.entry().distanceSquared(ray.origin()) <= distanceSquared) {
collisions.add(createBlockRayCollision(result, block, box, blockX, blockY, blockZ, ray.configuration()));
return 1;
}
return 0;
}
// Exit early if the collision limit if the block has multiple bounding boxes
int collisionLimit = ray.configuration().blockCollisionLimit();
List<BlockCastResult.BlockRayCollision> orderedCollisions = new ArrayList<>(boxes.length);
for (BoundingBox box : boxes) {
CollisionResult result = findBoundingBoxCollision(ray, box, blockX, blockY, blockZ, reciprocal, false, false, false);
if (result != null && collisionCount < collisionLimit && result.entry().distanceSquared(ray.origin()) <= distanceSquared) {
orderedCollisions.add(createBlockRayCollision(result, block, box, blockX, blockY, blockZ, ray.configuration()));
collisionCount++;
}
}
// Order multiple collisions by their distance to the ray origin
orderedCollisions.sort(Comparator.comparingDouble(a -> a.entry().distanceSquared(ray.origin())));
collisions.addAll(orderedCollisions);
return orderedCollisions.size();
}
private static @NotNull BlockCastResult.BlockRayCollision createBlockRayCollision(@NotNull CollisionResult collisionResult, @NotNull Block block,
@NotNull BoundingBox box, double offsetX, double offsetY, double offsetZ,
@NotNull Ray.Configuration configuration) {
Vec entrySurfaceNormal = null;
Vec exitSurfaceNormal = null;
if (configuration.computeSurfaceNormals()) {
entrySurfaceNormal = computeSurfaceNormal(collisionResult.entry(), box, offsetX, offsetY, offsetZ);
exitSurfaceNormal = computeSurfaceNormal(collisionResult.exit(), box, offsetX, offsetY, offsetZ);
}
return new BlockCastResult.BlockRayCollision(collisionResult.entry(), collisionResult.exit(), entrySurfaceNormal, exitSurfaceNormal, block);
}
/**
* @param ray the casting ray
* @param entities the entities to test intersections again
* @param reciprocal the pre-computed ray direction reciprocal (1/vec)
* @return the intersecting block ray collision list
*/
private static @NotNull List<EntityCastResult.EntityRayCollision> findIntersectingEntities(@NotNull Ray ray, @NotNull Collection<Entity> entities,
@NotNull Vec reciprocal) {
// Make the assumption that a ray is unlikely to intersect > 1 entity at once
final List<EntityCastResult.EntityRayCollision> collisions = new ArrayList<>(1);
final double maxDistanceSquared = ray.distance() * ray.distance();
Vec boxExpansion = ray.configuration().entityBoundingBoxExpansion();
boolean expandBoundingBox = !boxExpansion.isZero();
boolean infiniteX = Double.isInfinite(reciprocal.x()), infiniteY = Double.isInfinite(reciprocal.y()), infiniteZ = Double.isInfinite(reciprocal.z());
// Check entities
for (Entity entity : entities) {
if (!ray.configuration().entityFilter().test(entity)) continue;
BoundingBox box = expandBoundingBox ? entity.getBoundingBox().expand(boxExpansion.x(), boxExpansion.y(), boxExpansion.z()) : entity.getBoundingBox();
CollisionResult result = findBoundingBoxCollision(ray, box, entity.getPosition(), reciprocal, infiniteX, infiniteY, infiniteZ);
if (result != null && result.entry().distanceSquared(ray.origin()) < maxDistanceSquared) {
collisions.add(createEntityRayCollision(result, entity, box, ray.configuration()));
}
}
// Sort by intersection order
if (collisions.size() > 1) {
collisions.sort(Comparator.comparingDouble(a -> a.entry().distanceSquared(ray.origin())));
}
return collisions;
}
private static @NotNull EntityCastResult.EntityRayCollision createEntityRayCollision(@NotNull CollisionResult collisionResult,
@NotNull Entity entity, @NotNull BoundingBox box,
@NotNull Ray.Configuration configuration) {
Point offset = entity.getPosition();
Vec entrySurfaceNormal = null;
Vec exitSurfaceNormal = null;
if (configuration.computeSurfaceNormals()) {
entrySurfaceNormal = computeSurfaceNormal(collisionResult.entry(), box, offset.x(), offset.y(), offset.z());
exitSurfaceNormal = computeSurfaceNormal(collisionResult.exit(), box, offset.x(), offset.y(), offset.z());
}
return new EntityCastResult.EntityRayCollision(collisionResult.entry(), collisionResult.exit(), entrySurfaceNormal, exitSurfaceNormal, entity);
}
/**
* Get the entry/exit points of a ray on any given aabb bounding box.
* Pre-calculating the reciprocal and infinities saves significant time.
*
* @param ray the casting ray
* @param box the target bounding box
* @param offsetX the x offset of the box to in global space
* @param offsetY the y offset of the box to in global space
* @param offsetZ the z offset of the box to in global space
* @param reciprocal the pre-computed ray direction reciprocal (1/vec)
* @param infiniteX pass true if the reciprocal x component is infinity
* @param infiniteY pass true if the reciprocal y component is infinity
* @param infiniteZ pass true if the reciprocal z component is infinity
* @return a collision result if an intersection occurred, otherwise null
*/
private static @Nullable RaycastUtils.CollisionResult findBoundingBoxCollision(@NotNull Ray ray, @NotNull BoundingBox box,
double offsetX, double offsetY, double offsetZ,
@NotNull Vec reciprocal,
boolean infiniteX, boolean infiniteY, boolean infiniteZ) {
Point origin = ray.origin();
Vec direction = ray.direction();
// Determine the AABB ray intersections fast with the slab method
// t[min/max] represents t in the parametric ray equation
// ray.origin + ray.direction * t
final double tx1, tx2;
if (infiniteX) {
// Explicitly set these to infinity to avoid an edge case
// where 0 is multiplied by infinity
tx1 = Double.NEGATIVE_INFINITY;
tx2 = Double.POSITIVE_INFINITY;
}
else {
tx1 = (box.minX() + offsetX - origin.x()) * reciprocal.x();
tx2 = (box.minX() + box.width() + offsetX - origin.x()) * reciprocal.x();
}
final double ty1, ty2;
if (infiniteY) {
ty1 = Double.NEGATIVE_INFINITY;
ty2 = Double.POSITIVE_INFINITY;
}
else {
ty1 = (box.minY() + offsetY - origin.y()) * reciprocal.y();
ty2 = (box.minY() + box.height() + offsetY - origin.y()) * reciprocal.y();
}
final double tz1, tz2;
if (infiniteZ) {
tz1 = Double.NEGATIVE_INFINITY;
tz2 = Double.POSITIVE_INFINITY;
}
else {
tz1 = (box.minZ() + offsetZ - origin.z()) * reciprocal.z();
tz2 = (box.minZ() + box.depth() + offsetZ - origin.z()) * reciprocal.z();
}
double tEntry = Math.min(tx1, tx2);
double tExit = Math.max(tx1, tx2);
tEntry = Math.max(tEntry, Math.min(ty1, ty2));
tExit = Math.min(tExit, Math.max(ty1, ty2));
tEntry = Math.max(tEntry, Math.min(tz1, tz2));
tExit = Math.min(tExit, Math.max(tz1, tz2));
if (tEntry < 0 || tExit < tEntry) return null;
return new CollisionResult(origin.add(direction.x() * tEntry, direction.y() * tEntry, direction.z() * tEntry),
origin.add(direction.x() * tExit, direction.y() * tExit, direction.z() * tExit));
}
private static @Nullable CollisionResult findBoundingBoxCollision(@NotNull Ray ray, @NotNull BoundingBox box,
@NotNull Point offset,
@NotNull Vec reciprocal,
boolean infiniteX, boolean infiniteY, boolean infiniteZ) {
return findBoundingBoxCollision(ray, box, offset.x(), offset.y(), offset.z(), reciprocal, infiniteX, infiniteY, infiniteZ);
}
/**
* Find the surface normal on a given collision.
*
* @return the normalized surface normal vec
*/
private static @NotNull Vec computeSurfaceNormal(@NotNull Point intersection, @NotNull BoundingBox box,
double offsetX, double offsetY, double offsetZ) {
if (Math.abs(intersection.x() - (box.maxX() + offsetX)) < NORMAL_EPSILON) return MAX_X_NORMAL;
if (Math.abs(intersection.x() - (box.minX() + offsetX)) < NORMAL_EPSILON) return MIN_X_NORMAL;
if (Math.abs(intersection.y() - (box.maxY() + offsetY)) < NORMAL_EPSILON) return MAX_Y_NORMAL;
if (Math.abs(intersection.y() - (box.minY() + offsetY)) < NORMAL_EPSILON) return MIN_Y_NORMAL;
if (Math.abs(intersection.z() - (box.maxZ() + offsetZ)) < NORMAL_EPSILON) return MAX_Z_NORMAL;
else return MIN_Z_NORMAL;
}
/**
* For returning collision data from {@link RaycastUtils#findBoundingBoxCollision}
* to construct {@link CastResult} objects.
*/
private record CollisionResult(@NotNull Point entry, @NotNull Point exit) {}
/**
* Ray helpers section
*/
static @NotNull List<EntityCastResult.EntityRayCollision> findEntitiesBeforeEntityCollision(@NotNull List<CastResult.RayCollision> collisions, int collisionThreshold) {
ArrayList<EntityCastResult.EntityRayCollision> foundCollisions = new ArrayList<>();
int blockCollisionCount = 0;
for (CastResult.RayCollision collision : collisions) {
if (collision instanceof EntityCastResult.EntityRayCollision entityCollision) {
foundCollisions.add(entityCollision);
continue;
}
// Must be a block collision
if (++blockCollisionCount == collisionThreshold) {
break;
}
}
return foundCollisions;
}
static @NotNull List<BlockCastResult.BlockRayCollision> findBlocksBeforeEntityCollision(@NotNull List<CastResult.RayCollision> collisions, int collisionThreshold) {
ArrayList<BlockCastResult.BlockRayCollision> foundCollisions = new ArrayList<>();
int blockCollisionCount = 0;
for (CastResult.RayCollision collision : collisions) {
if (collision instanceof BlockCastResult.BlockRayCollision entityCollision) {
foundCollisions.add(entityCollision);
continue;
}
// Must be an entity collision
if (++blockCollisionCount == collisionThreshold) {
break;
}
}
return foundCollisions;
}
private RaycastUtils() {}
}

View File

@ -195,6 +195,10 @@ public final class ShapeImpl implements Shape {
return block;
}
BoundingBox @NotNull [] getCollisionBoundingBoxes() {
return collisionBoundingBoxes;
}
private static @NotNull List<Rectangle> computeOcclusionSet(BlockFace face, BoundingBox[] boundingBoxes) {
List<Rectangle> rSet = new ArrayList<>();
for (BoundingBox boundingBox : boundingBoxes) {

View File

@ -0,0 +1,117 @@
package net.minestom.server.collision;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.Entity;
import net.minestom.server.entity.EntityType;
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.List;
import static org.junit.jupiter.api.Assertions.*;
@EnvTest
public class TestRay
{
@Test
public void axisAlignedLines(Env env) {
// Straight axis aligned line (i.e. Vec(1, 0, 0) require special handling
// to ensure they process correctly
var instance = env.createFlatInstance();
instance.loadChunk(-1, 0).join();
instance.loadChunk(0, -1).join();
instance.setBlock(0, 52, 0, Block.DIRT);
instance.setBlock(1, 52, 0, Block.DIRT);
Ray ray = new Ray(new Vec(-1, 52, 0), new Vec(1, 0, 0), 10);
assertEquals(2, ray.cast(instance).blockCollisions().size());
assertEquals(0, ray.withDirection(new Vec(-1, 0, 0)).cast(instance).blockCollisions().size());
ray = new Ray(new Vec(0, 52, 1), new Vec(0, 0, -1), 10);
assertEquals(1, ray.cast(instance).blockCollisions().size());
assertEquals(0, ray.withDirection(new Vec(0, 0, 1)).cast(instance).blockCollisions().size());
ray = new Ray(new Vec(0, 53, 0), new Vec(0, -1, 0), 10);
assertEquals(1, ray.cast(instance).blockCollisions().size());
assertEquals(0, ray.withDirection(new Vec(0, 1, 1)).cast(instance).blockCollisions().size());
}
@Test
public void intersectionDistance(Env env) {
var instance = env.createFlatInstance();
Ray ray = new Ray(new Vec(0, 42.01, 0), new Vec(0, -1, 0), 10);
ray.cast(instance).blockCollisions().forEach(collision -> {
assertTrue(collision.entry().distance(ray.origin()) <= 10);
});
}
@Test
public void blockLimit(Env env) {
var instance = env.createFlatInstance();
Ray ray = new Ray(new Vec(0, 42, 0), new Vec(0, -1, 1), 20, config -> {
config.blockCollisionLimit(8);
});
assertEquals(8, ray.cast(instance).blockCollisions().size());
}
@Test
public void entityBoundingBoxExpansion(Env env) {
var instance = env.createFlatInstance();
Entity entity = new Entity(EntityType.ZOMBIE);
entity.setInstance(instance, new Vec(0, 42, 0));
Ray ray = new Ray(new Vec(-11, 42, 0), new Vec(1, 0, 0), 10);
assertFalse(ray.cast(List.of(entity)).hasEntityCollision());
assertTrue(ray.withConfiguration(config -> config.entityBoundingBoxExpansion(2, 2, 2)).cast(List.of(entity)).hasEntityCollision());
}
@Test
public void findBeforeCollision(Env env) {
var instance = env.createFlatInstance();
Vec direction = new Vec(1, 0, 1).normalize();
Point origin = new Vec(0, 50, 0);
Ray ray = new Ray(origin.add(0, 0.05, 0), direction, 14);
// Make a diagonal line of entities and dirt
instance.setBlock(origin.add(direction.mul(2)), Block.DIRT);
Entity entity1 = new Entity(EntityType.ZOMBIE);
entity1.setInstance(instance, origin.add(direction.mul(4)));
instance.setBlock(origin.add(direction.mul(6)), Block.DIRT);
Entity entity2 = new Entity(EntityType.ZOMBIE);
entity2.setInstance(instance, origin.add(direction.mul(8)));
instance.setBlock(origin.add(direction.mul(10)), Block.DIRT);
Entity entity3 = new Entity(EntityType.ZOMBIE);
entity3.setInstance(instance, origin.add(direction.mul(12)));
assertEquals(0, ray.cast(instance, List.of(entity1, entity2, entity3)).findEntitiesBeforeBlockCollision().size());
assertEquals(1, ray.cast(instance, List.of(entity1, entity2, entity3)).findEntitiesBeforeBlockCollision(2).size());
assertEquals(2, ray.cast(instance, List.of(entity1, entity2, entity3)).findEntitiesBeforeBlockCollision(3).size());
assertEquals(1, ray.cast(instance, List.of(entity1, entity2, entity3)).findBlocksBeforeEntityCollision().size());
assertEquals(2, ray.cast(instance, List.of(entity1, entity2, entity3)).findBlocksBeforeEntityCollision(2).size());
assertEquals(3, ray.cast(instance, List.of(entity1, entity2, entity3)).findBlocksBeforeEntityCollision(3).size());
}
@Test
public void castFromInsideBoundingBox(Env env) {
var instance = env.createFlatInstance();
instance.loadChunk(new Vec(1, 10, 1)).join();
Ray ray = new Ray(new Vec(1, 10, 1), new Vec(1, 2, 1.4), 2);
assertEquals(ray.cast(instance).firstBlockCollision().entry(), ray.origin());
Entity entity = new Entity(EntityType.ZOMBIE);
entity.setInstance(instance, ray.origin());
assertEquals(ray.cast(List.of(entity)).firstEntityCollision().entry(), ray.origin());
}
}