From c73d459c5f1f27d241b37cc267ef9901b07d84ce Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Sat, 20 Jan 2024 18:41:10 -0600 Subject: [PATCH] feat: Raycast improvements with contact normal (#2899) 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 --- CHANGELOG.md | 2 ++ src/engine/Collision/ColliderComponent.ts | 2 +- .../Collision/Colliders/CircleCollider.ts | 29 +++++++++++---- .../Colliders/ClosestLineJumpTable.ts | 22 ++++++------ src/engine/Collision/Colliders/Collider.ts | 3 +- .../Collision/Colliders/CompositeCollider.ts | 35 +++++++++--------- .../Collision/Colliders/EdgeCollider.ts | 12 +++++-- .../Collision/Colliders/PolygonCollider.ts | 15 ++++++-- .../DynamicTreeCollisionProcessor.ts | 33 +++-------------- src/engine/Collision/Detection/RayCastHit.ts | 28 +++++++++++++++ src/engine/Collision/Index.ts | 1 + src/spec/CollisionShapeSpec.ts | 36 ++++++++++++++----- src/spec/CompositeColliderSpec.ts | 14 +++++--- 13 files changed, 150 insertions(+), 82 deletions(-) create mode 100644 src/engine/Collision/Detection/RayCastHit.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2640afb..7182b13ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/src/engine/Collision/ColliderComponent.ts b/src/engine/Collision/ColliderComponent.ts index 3d1e8436f..c0a2675d3 100644 --- a/src/engine/Collision/ColliderComponent.ts +++ b/src/engine/Collision/ColliderComponent.ts @@ -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) { diff --git a/src/engine/Collision/Colliders/CircleCollider.ts b/src/engine/Collision/Colliders/CircleCollider.ts index bd1f5bcd0..ebfbd83c7 100644 --- a/src/engine/Collision/Colliders/CircleCollider.ts +++ b/src/engine/Collision/Colliders/CircleCollider.ts @@ -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 { /** @@ -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; @@ -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; @@ -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; } diff --git a/src/engine/Collision/Colliders/ClosestLineJumpTable.ts b/src/engine/Collision/Colliders/ClosestLineJumpTable.ts index 4fe9a905e..bd8e583da 100644 --- a/src/engine/Collision/Colliders/ClosestLineJumpTable.ts +++ b/src/engine/Collision/Colliders/ClosestLineJumpTable.ts @@ -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); @@ -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); @@ -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); @@ -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(); @@ -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) { @@ -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) { diff --git a/src/engine/Collision/Colliders/Collider.ts b/src/engine/Collision/Colliders/Collider.ts index bae59f014..b7420dcef 100644 --- a/src/engine/Collision/Colliders/Collider.ts +++ b/src/engine/Collision/Colliders/Collider.ts @@ -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 @@ -100,7 +101,7 @@ export abstract class Collider implements Clonable { /** * 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 diff --git a/src/engine/Collision/Colliders/CompositeCollider.ts b/src/engine/Collision/Colliders/CompositeCollider.ts index e053b8313..1f3d97ec7 100644 --- a/src/engine/Collision/Colliders/CompositeCollider.ts +++ b/src/engine/Collision/Colliders/CompositeCollider.ts @@ -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'; @@ -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); } diff --git a/src/engine/Collision/Colliders/EdgeCollider.ts b/src/engine/Collision/Colliders/EdgeCollider.ts index 7311a3c04..2f4a5e97f 100644 --- a/src/engine/Collision/Colliders/EdgeCollider.ts +++ b/src/engine/Collision/Colliders/EdgeCollider.ts @@ -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 { /** @@ -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 @@ -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; } } diff --git a/src/engine/Collision/Colliders/PolygonCollider.ts b/src/engine/Collision/Colliders/PolygonCollider.ts index a9a12f22c..6395ac235 100644 --- a/src/engine/Collision/Colliders/PolygonCollider.ts +++ b/src/engine/Collision/Colliders/PolygonCollider.ts @@ -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 { /** @@ -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 diff --git a/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts b/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts index d52cd8d63..0eb498e09 100644 --- a/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts +++ b/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts @@ -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 { /** @@ -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; @@ -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; diff --git a/src/engine/Collision/Detection/RayCastHit.ts b/src/engine/Collision/Detection/RayCastHit.ts new file mode 100644 index 000000000..d0dd0fab2 --- /dev/null +++ b/src/engine/Collision/Detection/RayCastHit.ts @@ -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; +} diff --git a/src/engine/Collision/Index.ts b/src/engine/Collision/Index.ts index 5286a2518..da7a53d61 100644 --- a/src/engine/Collision/Index.ts +++ b/src/engine/Collision/Index.ts @@ -19,6 +19,7 @@ export * from './Group/CollisionGroupManager'; export * from './Detection/Pair'; export * from './Detection/CollisionContact'; +export * from './Detection/RayCastHit'; export * from './Detection/CollisionProcessor'; export * from './Detection/DynamicTree'; export * from './Detection/DynamicTreeCollisionProcessor'; diff --git a/src/spec/CollisionShapeSpec.ts b/src/spec/CollisionShapeSpec.ts index 2bc790120..b0518c364 100644 --- a/src/spec/CollisionShapeSpec.ts +++ b/src/spec/CollisionShapeSpec.ts @@ -148,11 +148,17 @@ describe('Collision Shape', () => { const rayTangent = new ex.Ray(new ex.Vector(-100, 10), ex.Vector.Right.clone()); const rayNoHit = new ex.Ray(new ex.Vector(-100, 10), ex.Vector.Left.clone()); - const point = circle.rayCast(ray); - const pointTangent = circle.rayCast(rayTangent); + const hit = circle.rayCast(ray); + const point = circle.rayCast(ray).point; + const pointTangent = circle.rayCast(rayTangent).point; const pointNoHit = circle.rayCast(rayNoHit); const pointTooFar = circle.rayCast(ray, 1); + expect(hit.normal).toBeVector(ex.Vector.Left); + expect(hit.distance).toBe(90); + expect(hit.collider).toBe(circle); + expect(hit.body).toBe(actor.body); + expect(point.x).toBe(-10); expect(point.y).toBe(0); @@ -166,13 +172,13 @@ describe('Collision Shape', () => { it('can be raycast against only positive time of impact (toi)', () => { const ray = new ex.Ray(new ex.Vector(0, 0), ex.Vector.Right.clone()); - const point = circle.rayCast(ray); + const point = circle.rayCast(ray).point; expect(point.x).toBe(10); expect(point.y).toBe(0); }); - it('doesnt have axes', () => { + it('doesn\'t have axes', () => { // technically circles have infinite axes expect(circle.axes).toEqual([]); }); @@ -361,7 +367,7 @@ describe('Collision Shape', () => { await expectAsync(canvasElement).toEqualImage('src/spec/images/CollisionShapeSpec/circle-debug.png'); }); - it('can be drawn with actor when in contructor', async () => { + it('can be drawn with actor when in constructor', async () => { const circleActor = new ex.Actor({ pos: new ex.Vector(100, 100), color: ex.Color.Blue, @@ -732,12 +738,18 @@ describe('Collision Shape', () => { const rayTowards = new ex.Ray(new ex.Vector(-100, 0), ex.Vector.Right.clone()); const rayAway = new ex.Ray(new ex.Vector(-100, 0), new ex.Vector(-1, 0)); - const point = polyA.rayCast(rayTowards); + const hit = polyA.rayCast(rayTowards); + const point = hit.point; const noHit = polyA.rayCast(rayAway); const tooFar = polyA.rayCast(rayTowards, 1); expect(point.x).toBeCloseTo(-5, 0.001); expect(point.y).toBeCloseTo(0, 0.001); + expect(hit.normal.x).toBe(-1); + expect(hit.normal.y).toBe(0); + expect(hit.collider).toBe(polyA); + expect(hit.body).toBe(actor.body); + expect(hit.distance).toBe(95); expect(noHit).toBe(null); expect(tooFar).toBe(null, 'The polygon should be too far away for a hit'); }); @@ -873,12 +885,18 @@ describe('Collision Shape', () => { const rayRightTangent = new ex.Ray(new ex.Vector(10, -100), ex.Vector.Down.clone()); const rayNoHit = new ex.Ray(new ex.Vector(5, -100), ex.Vector.Up.clone()); - const midPoint = edge.rayCast(ray); - const leftTan = edge.rayCast(rayLeftTangent); - const rightTan = edge.rayCast(rayRightTangent); + const hit = edge.rayCast(ray); + const midPoint = edge.rayCast(ray).point; + const leftTan = edge.rayCast(rayLeftTangent).point; + const rightTan = edge.rayCast(rayRightTangent).point; const noHit = edge.rayCast(rayNoHit); const tooFar = edge.rayCast(ray, 1); + expect(hit.normal).toBeVector(ex.Vector.Up); + expect(hit.collider).toBe(edge); + expect(hit.body).toBe(actor.body); + expect(hit.distance).toBe(100); + expect(midPoint.x).toBeCloseTo(5, 0.001); expect(midPoint.y).toBeCloseTo(0, 0.001); diff --git a/src/spec/CompositeColliderSpec.ts b/src/spec/CompositeColliderSpec.ts index e2c1bc769..bcdaabe3f 100644 --- a/src/spec/CompositeColliderSpec.ts +++ b/src/spec/CompositeColliderSpec.ts @@ -197,20 +197,26 @@ describe('A CompositeCollider', () => { const rayRight = new Ray(vec(-200, 0), Vector.Right); - const leftBox = compCollider.rayCast(rayRight); + const leftBox = compCollider.rayCast(rayRight).point; expect(leftBox).toEqual(vec(-100, 0)); const rayDown = new Ray(vec(0, -200), Vector.Down); - const topCircle = compCollider.rayCast(rayDown); + const topCircle = compCollider.rayCast(rayDown).point; expect(topCircle).toEqual(vec(0, -50)); const rayUp = new Ray(vec(0, 200), Vector.Up); - const bottomCircle = compCollider.rayCast(rayUp); + const bottomCircle = compCollider.rayCast(rayUp).point; expect(bottomCircle).toEqual(vec(0, 50)); const rayLeft = new Ray(vec(200, 0), Vector.Left); - const rightBox = compCollider.rayCast(rayLeft); + const rightBox = compCollider.rayCast(rayLeft).point; expect(rightBox).toEqual(vec(100, 0)); + + const hit = compCollider.rayCast(rayLeft); + expect(hit.normal).toBeVector(Vector.Right); + expect(hit.distance).toBe(100); + expect(hit.body).toBe(undefined); + expect(hit.collider).toBe(compCollider.getColliders()[1]); }); it('can project onto an axis', () => {