Skip to content

Commit

Permalink
feat: Raycast improvements with contact normal (#2899)
Browse files Browse the repository at this point in the history
This PR adds `RayCastHit` returns as part of every raycast not just the physics world query!
  * Additionally added the ray distance and the contact normal for the surface
  • Loading branch information
eonarheim authored Jan 21, 2024
1 parent 2bbc25a commit c73d459
Show file tree
Hide file tree
Showing 13 changed files with 150 additions and 82 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Added

- Added `RayCastHit`as part of every raycast not just the physics world query!
* Additionally added the ray distance and the contact normal for the surface
- Added the ability to log a message once to all log levels
* `debugOnce`
* `infoOnce`
Expand Down
2 changes: 1 addition & 1 deletion src/engine/Collision/ColliderComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class ColliderComponent extends Component<'ex.collider'> {
return [];
}

// If we have a composite lefthand side :(
// If we have a composite left hand side :(
// Might bite us, but to avoid updating all the handlers make composite always left side
let flipped = false;
if (colliderB instanceof CompositeCollider) {
Expand Down
29 changes: 23 additions & 6 deletions src/engine/Collision/Colliders/CircleCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { ClosestLineJumpTable } from './ClosestLineJumpTable';
import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext';
import { Transform } from '../../Math/transform';
import { AffineMatrix } from '../../Math/affine-matrix';
import { BodyComponent } from '../Index';
import { RayCastHit } from '../Detection/RayCastHit';

export interface CircleColliderOptions {
/**
Expand Down Expand Up @@ -105,7 +107,7 @@ export class CircleCollider extends Collider {
* Casts a ray at the Circle collider and returns the nearest point of collision
* @param ray
*/
public rayCast(ray: Ray, max: number = Infinity): Vector {
public rayCast(ray: Ray, max: number = Infinity): RayCastHit | null {
//https://en.wikipedia.org/wiki/Line%E2%80%93sphere_intersection
const c = this.center;
const dir = ray.dir;
Expand All @@ -118,13 +120,21 @@ export class CircleCollider extends Collider {
return null;
} else {
let toi = 0;
// tangent case
if (discriminant === 0) {
toi = -dir.dot(orig.sub(c));
if (toi > 0 && toi < max) {
return ray.getPoint(toi);
const point = ray.getPoint(toi);
return {
point,
normal: point.sub(c).normalize(),
collider: this,
body: this.owner?.get(BodyComponent),
distance: toi
} satisfies RayCastHit;
}
return null;
} else {
} else { // two point
const toi1 = -dir.dot(orig.sub(c)) + discriminant;
const toi2 = -dir.dot(orig.sub(c)) - discriminant;

Expand All @@ -137,9 +147,16 @@ export class CircleCollider extends Collider {
positiveToi.push(toi2);
}

const mintoi = Math.min(...positiveToi);
if (mintoi <= max) {
return ray.getPoint(mintoi);
const minToi = Math.min(...positiveToi);
if (minToi <= max) {
const point = ray.getPoint(minToi);
return {
point,
normal: point.sub(c).normalize(),
collider: this,
body: this.owner?.get(BodyComponent),
distance: minToi
} satisfies RayCastHit;
}
return null;
}
Expand Down
22 changes: 11 additions & 11 deletions src/engine/Collision/Colliders/ClosestLineJumpTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export const ClosestLineJumpTable = {
const rayTowardsOther = new Ray(polygonA.worldPos, otherDirection);
const rayTowardsThis = new Ray(otherWorldPos, thisDirection);

const thisPoint = polygonA.rayCast(rayTowardsOther).add(rayTowardsOther.dir.scale(0.1));
const otherPoint = polygonB.rayCast(rayTowardsThis).add(rayTowardsThis.dir.scale(0.1));
const thisPoint = polygonA.rayCast(rayTowardsOther).point.add(rayTowardsOther.dir.scale(0.1));
const otherPoint = polygonB.rayCast(rayTowardsThis).point.add(rayTowardsThis.dir.scale(0.1));

const thisFace = polygonA.getClosestFace(thisPoint);
const otherFace = polygonB.getClosestFace(otherPoint);
Expand All @@ -134,7 +134,7 @@ export const ClosestLineJumpTable = {

const rayTowardsOther = new Ray(polygon.worldPos, otherDirection);

const thisPoint = polygon.rayCast(rayTowardsOther).add(rayTowardsOther.dir.scale(0.1));
const thisPoint = polygon.rayCast(rayTowardsOther).point.add(rayTowardsOther.dir.scale(0.1));

const thisFace = polygon.getClosestFace(thisPoint);

Expand All @@ -160,7 +160,7 @@ export const ClosestLineJumpTable = {

const rayTowardsOther = new Ray(polygon.worldPos, otherDirection.normalize());

const thisPoint = polygon.rayCast(rayTowardsOther).add(rayTowardsOther.dir.scale(0.1));
const thisPoint = polygon.rayCast(rayTowardsOther).point.add(rayTowardsOther.dir.scale(0.1));

const thisFace = polygon.getClosestFace(thisPoint);

Expand Down Expand Up @@ -200,12 +200,12 @@ export const ClosestLineJumpTable = {
const thisPoint = circleA.rayCast(rayTowardsOther);
const otherPoint = circleB.rayCast(rayTowardsThis);

return new LineSegment(thisPoint, otherPoint);
return new LineSegment(thisPoint.point, otherPoint.point);
},

CircleEdgeClosestLine(circle: CircleCollider, edge: EdgeCollider) {
// https://math.stackexchange.com/questions/1919177/how-to-find-point-on-line-closest-to-sphere
const circleWorlPos = circle.worldPos;
const circleWorldPos = circle.worldPos;

// L1 = P(s) = p0 + s * u, where s is time and p0 is the start of the line
const edgeLine = edge.asLine();
Expand All @@ -215,7 +215,7 @@ export const ClosestLineJumpTable = {
const u = edgeVector;

// Time of minimum distance
let t = (u.x * (circleWorlPos.x - p0.x) + u.y * (circleWorlPos.y - p0.y)) / (u.x * u.x + u.y * u.y);
let t = (u.x * (circleWorldPos.x - p0.x) + u.y * (circleWorldPos.y - p0.y)) / (u.x * u.x + u.y * u.y);

// If time of minimum is past the edge clamp to edge
if (t > 1) {
Expand All @@ -225,11 +225,11 @@ export const ClosestLineJumpTable = {
}

// Minimum distance
const d = Math.sqrt(Math.pow(p0.x + u.x * t - circleWorlPos.x, 2) + Math.pow(p0.y + u.y * t - circleWorlPos.y, 2)) - circle.radius;
const d = Math.sqrt(Math.pow(p0.x + u.x * t - circleWorldPos.x, 2) + Math.pow(p0.y + u.y * t - circleWorldPos.y, 2)) - circle.radius;

const circlex = ((p0.x + u.x * t - circleWorlPos.x) * circle.radius) / (circle.radius + d);
const circley = ((p0.y + u.y * t - circleWorlPos.y) * circle.radius) / (circle.radius + d);
return new LineSegment(u.scale(t).add(p0), new Vector(circleWorlPos.x + circlex, circleWorlPos.y + circley));
const circlex = ((p0.x + u.x * t - circleWorldPos.x) * circle.radius) / (circle.radius + d);
const circley = ((p0.y + u.y * t - circleWorldPos.y) * circle.radius) / (circle.radius + d);
return new LineSegment(u.scale(t).add(p0), new Vector(circleWorldPos.x + circlex, circleWorldPos.y + circley));
},

EdgeEdgeClosestLine(edgeA: EdgeCollider, edgeB: EdgeCollider) {
Expand Down
3 changes: 2 additions & 1 deletion src/engine/Collision/Colliders/Collider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createId, Id } from '../../Id';
import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext';
import { Transform } from '../../Math/transform';
import { EventEmitter } from '../../EventEmitter';
import { RayCastHit } from '../Detection/RayCastHit';

/**
* A collision collider specifies the geometry that can detect when other collision colliders intersect
Expand Down Expand Up @@ -100,7 +101,7 @@ export abstract class Collider implements Clonable<Collider> {
/**
* Return the point on the border of the collision collider that intersects with a ray (if any).
*/
abstract rayCast(ray: Ray, max?: number): Vector;
abstract rayCast(ray: Ray, max?: number): RayCastHit | null;

/**
* Create a projection of this collider along an axis. Think of this as casting a "shadow" along an axis
Expand Down
35 changes: 18 additions & 17 deletions src/engine/Collision/Colliders/CompositeCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { BoundingBox } from '../BoundingBox';
import { CollisionContact } from '../Detection/CollisionContact';
import { DynamicTree } from '../Detection/DynamicTree';
import { DynamicTreeCollisionProcessor } from '../Detection/DynamicTreeCollisionProcessor';
import { RayCastHit } from '../Detection/RayCastHit';
import { Collider } from './Collider';
import { Transform } from '../../Math/transform';

Expand Down Expand Up @@ -191,42 +192,42 @@ export class CompositeCollider extends Collider {
}
return false;
}
rayCast(ray: Ray, max?: number): Vector {
rayCast(ray: Ray, max?: number): RayCastHit | null {
const colliders = this.getColliders();
const points: Vector[] = [];
const hits: RayCastHit[] = [];
for (const collider of colliders) {
const vec = collider.rayCast(ray, max);
if (vec) {
points.push(vec);
const hit = collider.rayCast(ray, max);
if (hit) {
hits.push(hit);
}
}
if (points.length) {
let minPoint = points[0];
let minDistance = minPoint.dot(ray.dir);
for (const point of points) {
const distance = ray.dir.dot(point);
if (hits.length) {
let minHit = hits[0];
let minDistance = minHit.point.dot(ray.dir);
for (const hit of hits) {
const distance = ray.dir.dot(hit.point);
if (distance < minDistance) {
minPoint = point;
minHit = hit;
minDistance = distance;
}
}
return minPoint;
return minHit;
}
return null;
}
project(axis: Vector): Projection {
const colliders = this.getColliders();
const projs: Projection[] = [];
const projections: Projection[] = [];
for (const collider of colliders) {
const proj = collider.project(axis);
if (proj) {
projs.push(proj);
projections.push(proj);
}
}
// Merge all proj's on the same axis
if (projs.length) {
const newProjection = new Projection(projs[0].min, projs[0].max);
for (const proj of projs) {
if (projections.length) {
const newProjection = new Projection(projections[0].min, projections[0].max);
for (const proj of projections) {
newProjection.min = Math.min(proj.min, newProjection.min);
newProjection.max = Math.max(proj.max, newProjection.max);
}
Expand Down
12 changes: 10 additions & 2 deletions src/engine/Collision/Colliders/EdgeCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { ClosestLineJumpTable } from './ClosestLineJumpTable';
import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext';
import { Transform } from '../../Math/transform';
import { AffineMatrix } from '../../Math/affine-matrix';
import { BodyComponent } from '../Index';
import { RayCastHit } from '../Detection/RayCastHit';

export interface EdgeColliderOptions {
/**
Expand Down Expand Up @@ -111,7 +113,7 @@ export class EdgeCollider extends Collider {
/**
* @inheritdoc
*/
public rayCast(ray: Ray, max: number = Infinity): Vector {
public rayCast(ray: Ray, max: number = Infinity): RayCastHit | null {
const numerator = this._getTransformedBegin().sub(ray.pos);

// Test is line and ray are parallel and non intersecting
Expand All @@ -130,7 +132,13 @@ export class EdgeCollider extends Collider {
if (t >= 0 && t <= max) {
const u = numerator.cross(ray.dir) / divisor / this.getLength();
if (u >= 0 && u <= 1) {
return ray.getPoint(t);
return {
distance: t,
normal: this.asLine().normal(),
collider: this,
body: this.owner?.get(BodyComponent),
point: ray.getPoint(t)
} satisfies RayCastHit;
}
}

Expand Down
15 changes: 12 additions & 3 deletions src/engine/Collision/Colliders/PolygonCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import { AffineMatrix } from '../../Math/affine-matrix';
import { Ray } from '../../Math/ray';
import { ClosestLineJumpTable } from './ClosestLineJumpTable';
import { Collider } from './Collider';
import { ExcaliburGraphicsContext, Logger } from '../..';
import { BodyComponent, ExcaliburGraphicsContext, Logger } from '../..';
import { CompositeCollider } from './CompositeCollider';
import { Shape } from './Shape';
import { Transform } from '../../Math/transform';
import { RayCastHit } from '../Detection/RayCastHit';

export interface PolygonColliderOptions {
/**
Expand Down Expand Up @@ -645,24 +646,32 @@ export class PolygonCollider extends Collider {
/**
* Casts a ray into the polygon and returns a vector representing the point of contact (in world space) or null if no collision.
*/
public rayCast(ray: Ray, max: number = Infinity) {
public rayCast(ray: Ray, max: number = Infinity): RayCastHit | null {
// find the minimum contact time greater than 0
// contact times less than 0 are behind the ray and we don't want those
const sides = this.getSides();
const len = sides.length;
let minContactTime = Number.MAX_VALUE;
let contactSide: LineSegment;
let contactIndex = -1;
for (let i = 0; i < len; i++) {
const contactTime = ray.intersect(sides[i]);
if (contactTime >= 0 && contactTime < minContactTime && contactTime <= max) {
minContactTime = contactTime;
contactSide = sides[i];
contactIndex = i;
}
}

// contact was found
if (contactIndex >= 0) {
return ray.getPoint(minContactTime);
return {
collider: this,
distance: minContactTime,
body: this.owner?.get(BodyComponent),
point: ray.getPoint(minContactTime),
normal: contactSide.normal()
} satisfies RayCastHit;
}

// no contact found
Expand Down
33 changes: 5 additions & 28 deletions src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,7 @@ import { BodyComponent } from '../BodyComponent';
import { CompositeCollider } from '../Colliders/CompositeCollider';
import { CollisionGroup } from '../Group/CollisionGroup';
import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext';

export interface RayCastHit {
/**
* The distance along the ray cast in pixels that a hit was detected
*/
distance: number;
/**
* Reference to the collider that was hit
*/
collider: Collider;
/**
* Reference to the body that was hit
*/
body: BodyComponent;
/**
* World space point of the hit
*/
point: Vector;
}
import { RayCastHit } from './RayCastHit';

export interface RayCastOptions {
/**
Expand Down Expand Up @@ -87,12 +69,7 @@ export class DynamicTreeCollisionProcessor implements CollisionProcessor {

const hit = collider.rayCast(ray, maxDistance);
if (hit) {
results.push({
distance: hit.sub(ray.pos).distance(),
point: hit,
collider: collider,
body: maybeBody
});
results.push(hit);
if (!searchAllColliders) {
// returning true exits the search
return true;
Expand Down Expand Up @@ -229,9 +206,9 @@ export class DynamicTreeCollisionProcessor implements CollisionProcessor {
let minTranslate: Vector = new Vector(Infinity, Infinity);
this._dynamicCollisionTree.rayCastQuery(ray, updateDistance + Physics.surfaceEpsilon * 2, (other: Collider) => {
if (!this._pairExists(collider, other) && Pair.canCollide(collider, other)) {
const hitPoint = other.rayCast(ray, updateDistance + Physics.surfaceEpsilon * 10);
if (hitPoint) {
const translate = hitPoint.sub(origin);
const hit = other.rayCast(ray, updateDistance + Physics.surfaceEpsilon * 10);
if (hit) {
const translate = hit.point.sub(origin);
if (translate.size < minTranslate.size) {
minTranslate = translate;
minCollider = other;
Expand Down
28 changes: 28 additions & 0 deletions src/engine/Collision/Detection/RayCastHit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Vector } from '../../Math/vector';
import { Collider } from '../Colliders/Collider';
import { BodyComponent } from '../BodyComponent';


export interface RayCastHit {
/**
* The distance along the ray cast in pixels that a hit was detected
*/
distance: number;
/**
* Reference to the collider that was hit
*/
collider: Collider;
/**
* Reference to the body that was hit
*/
body: BodyComponent;
/**
* World space point of the hit
*/
point: Vector;

/**
* Normal vector of hit collider
*/
normal: Vector;
}
Loading

0 comments on commit c73d459

Please sign in to comment.