time for a snapshot
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Step 06 — Ray vs AABB (two sweeps become one hit)
|
||||
|
||||
This is where step 04 ("collision = the AND of two 1D tests") and step 05 ("the
|
||||
entry/exit time of one sweep") finally fuse. A **moving point vs a static box**.
|
||||
|
||||
## Concept
|
||||
|
||||
A point at `p` moves by `v` over the frame. A static box has a left/right edge
|
||||
(its x-interval) and a top/bottom edge (its y-interval). The point is inside the
|
||||
**box** only while it's inside the x-interval **and** the y-interval *at the same
|
||||
time*.
|
||||
|
||||
So run `sweepInterval` twice:
|
||||
|
||||
```
|
||||
spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w) // the x-edges
|
||||
spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h) // the y-edges
|
||||
```
|
||||
|
||||
Each gives you a time-window `[entry, exit]` during which the point is inside
|
||||
*that one axis's* strip. You're inside the box during the **overlap of the two
|
||||
windows**:
|
||||
|
||||
```
|
||||
entry = max(spanX.entry, spanY.entry) // inside the box once you're inside the LAST axis
|
||||
exit = min(spanX.exit, spanY.exit) // out of the box once you leave the FIRST axis
|
||||
```
|
||||
|
||||
Read those two lines until they feel obvious — they're the whole algorithm:
|
||||
|
||||
- You're only truly *inside the box* once you've entered **both** strips, so the
|
||||
real entry is the **later** of the two entries → `max`.
|
||||
- You **leave** the box the instant you exit **either** strip → the **earlier**
|
||||
exit → `min`.
|
||||
|
||||
### When is there NO hit?
|
||||
|
||||
1. **A span is `null`** — on some axis the point isn't moving and is already
|
||||
outside that strip. It can never be inside the box. Return `null` immediately.
|
||||
2. **`entry > exit`** — the two windows never overlap. The point is inside one
|
||||
strip, then the other, but never both at once. That's the classic "flies past
|
||||
the corner" miss.
|
||||
3. **`entry >= 1` or `exit <= 0`** — the windows overlap, but not *during this
|
||||
frame* (it's entirely in the future, or entirely in the past). Not our problem
|
||||
this frame.
|
||||
|
||||
### The normal (which wall did we hit?)
|
||||
|
||||
When you do collide, you also want to know **which face** you hit, so the response
|
||||
later can push you back the right way. That's the `normal` — a unit vector
|
||||
pointing out of the surface you struck.
|
||||
|
||||
The trick: **the axis you entered *last* is the axis you actually hit.** Compare
|
||||
the two entry times — whichever is larger is the blocking axis:
|
||||
|
||||
- if `spanX.entry > spanY.entry` → you hit a **vertical** wall (left/right face).
|
||||
The normal is horizontal, pointing back against your x-motion:
|
||||
`normal = { x: v.x > 0 ? -1 : 1, y: 0 }`.
|
||||
- otherwise → you hit a **horizontal** wall (top/bottom). The normal is vertical:
|
||||
`normal = { x: 0, y: v.y > 0 ? -1 : 1 }`.
|
||||
|
||||
(Moving right and hitting something → the surface pushes you left → normal `-1`.
|
||||
That sign rule is all there is to it.)
|
||||
|
||||
## Task
|
||||
|
||||
Implement `rayVsAABB(p, v, box)` in `ray.ts`. Return `{ time, normal }` for the
|
||||
entry, or `null` for any of the three no-hit cases. `sweepInterval` is provided
|
||||
in `given.ts` — **reuse it**, don't re-derive it.
|
||||
|
||||
```sh
|
||||
bun test workshop/steps/06-ray-vs-aabb
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
// Finished in earlier steps — reuse, don't rewrite.
|
||||
export type Vec = { x: number; y: number };
|
||||
export const vec = (x: number, y: number): Vec => ({ x, y });
|
||||
|
||||
export type AABB = { x: number; y: number; w: number; h: number };
|
||||
export type Span = { entry: number; exit: number };
|
||||
|
||||
/** Step 05, finished. */
|
||||
export function sweepInterval(
|
||||
p: number,
|
||||
v: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): Span | null {
|
||||
if (v === 0) {
|
||||
return min <= p && p <= max
|
||||
? { entry: -Infinity, exit: Infinity }
|
||||
: null;
|
||||
}
|
||||
|
||||
let t1 = (min - p) / v;
|
||||
let t2 = (max - p) / v;
|
||||
if (t1 > t2) {
|
||||
[t1, t2] = [t2, t1];
|
||||
}
|
||||
|
||||
return { entry: t1, exit: t2 };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { type AABB, vec } from "./given.ts";
|
||||
import { rayVsAABB } from "./ray.ts";
|
||||
|
||||
test("direct hit moving right -> normal points left", () => {
|
||||
// point at (0,5) moving +10 in x; box spans x:[5,8], y:[0,10]
|
||||
const box: AABB = { x: 5, y: 0, w: 3, h: 10 };
|
||||
const hit = rayVsAABB(vec(0, 5), vec(10, 0), box)!;
|
||||
expect(hit.time).toBeCloseTo(0.5); // reaches x=5 at half the frame
|
||||
expect(hit.normal).toEqual({ x: -1, y: 0 });
|
||||
});
|
||||
|
||||
test("passes above the box (not moving in y, outside in y) -> null", () => {
|
||||
const box: AABB = { x: 5, y: 0, w: 3, h: 10 };
|
||||
expect(rayVsAABB(vec(0, 20), vec(10, 0), box)).toBeNull();
|
||||
});
|
||||
|
||||
test("diagonal hit where y is the blocking axis -> vertical normal", () => {
|
||||
// box x:[5,15], y:[8,18]; you enter x at t=0.5 but y only at t=0.8,
|
||||
// so the real entry is 0.8 and the wall you hit is horizontal (top).
|
||||
const box: AABB = { x: 5, y: 8, w: 10, h: 10 };
|
||||
const hit = rayVsAABB(vec(0, 0), vec(10, 10), box)!;
|
||||
expect(hit.time).toBeCloseTo(0.8);
|
||||
expect(hit.normal).toEqual({ x: 0, y: -1 });
|
||||
});
|
||||
|
||||
test("flies past the corner (windows never overlap) -> null", () => {
|
||||
// inside x during [0.5,0.8], inside y during [0,0.1] — never both at once
|
||||
const box: AABB = { x: 5, y: 0, w: 3, h: 1 };
|
||||
expect(rayVsAABB(vec(0, 0), vec(10, 10), box)).toBeNull();
|
||||
});
|
||||
|
||||
test("collision is real but lands after this frame -> null", () => {
|
||||
// too slow: reaches the box at t=5, outside [0,1)
|
||||
const box: AABB = { x: 5, y: 0, w: 2, h: 10 };
|
||||
expect(rayVsAABB(vec(0, 0), vec(1, 0), box)).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type AABB, type Span, sweepInterval, type Vec } from "./given.ts";
|
||||
|
||||
/** A collision: the fraction of the frame at impact, and the surface normal. */
|
||||
export type Hit = { time: number; normal: Vec };
|
||||
|
||||
/**
|
||||
* A point at `p` moving by `v` over this frame, against a static box.
|
||||
* Returns the entry Hit, or null if there is no collision this frame.
|
||||
*
|
||||
* Reuse sweepInterval twice (x and y), then combine:
|
||||
* entry = max of the two entries, exit = min of the two exits.
|
||||
*/
|
||||
export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null {
|
||||
const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w);
|
||||
if (spanX === null) {
|
||||
return null;
|
||||
}
|
||||
const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h);
|
||||
if (spanY === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const span: Span = {
|
||||
entry: Math.max(spanX.entry, spanY.entry),
|
||||
exit: Math.min(spanX.exit, spanY.exit),
|
||||
};
|
||||
|
||||
if (span.entry > span.exit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (span.entry >= 1 || span.exit <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = span.entry;
|
||||
const normal = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
|
||||
if (spanX.entry > spanY.entry) {
|
||||
normal.x = v.x > 0 ? -1 : 1;
|
||||
} else {
|
||||
normal.y = v.y > 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
return {
|
||||
time,
|
||||
normal,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user