time for a snapshot

This commit is contained in:
Schluffe
2026-07-11 17:48:28 +02:00
parent 135b7c3042
commit 7262752e16
47 changed files with 1712 additions and 61 deletions
+73
View File
@@ -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
```
+28
View File
@@ -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 };
}
+37
View File
@@ -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();
});
+52
View File
@@ -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,
};
}