Collision Response, Penetration and Sliding
What You’ll Learn
Section titled “What You’ll Learn”Detection was half the job. This Section is the other half: what to do once a test says two things are touching.
Penetration depth and the minimum translation vector, which fix a position that is already
wrong. Sliding, which is one subtraction and is what makes running into a wall at an angle
carry you along it — the thing move_and_slide does. Restitution and friction, which turn out
to be two dials on the same split. The slope limit, which is the only thing separating a floor
from a wall. And tunneling: why something fast enough goes straight through a wall, and why
that bug is never reproducible.
Two Wrong Things, Not One
Section titled “Two Wrong Things, Not One”By the time a test reports a contact, two separate things are wrong.
- The position is wrong. The shapes overlap. Something has to move out.
- The velocity is wrong. It is still pointing into the surface, so next frame will overlap again by about the same amount.
Fix only the position and the object buzzes against the wall — pushed out, driven back in, pushed out. Fix only the velocity and it sinks in and stays there, because nothing ever removed the overlap that already exists.
They need different fixes, and it is worth keeping them separate in your head, because they use different numbers: the position fix uses the depth, the velocity fix uses the normal.
Getting Out: the Minimum Translation Vector
Section titled “Getting Out: the Minimum Translation Vector”Section 6.2’s tests already hand you the depth. Section 6.1’s signed distance is the depth for a shape against a plane or a box. So the push-out is:
The interesting part is choosing . For two overlapping boxes, any of the three axes would separate them, and the answers are not equally good:
src/lib/gamedev/demos/mtv.ts /** Three ways out of an overlap, and why the shortest one is the only acceptable answer. */
import { axisOverlaps, boxContact, pushOut } from "../response.ts";
import type { Demo } from "./runner.ts";
// A character has sunk 30 cm into a wide floor.
const FLOOR = { min: { x: -5, y: -1, z: -5 }, max: { x: 5, y: 0, z: 5 } };
const FEET = {
min: { x: -0.4, y: -0.3, z: -0.4 },
max: { x: 0.4, y: 1.5, z: 0.4 },
};
const demo: Demo = (log) => {
for (const { axis, overlap } of axisOverlaps(FLOOR, FEET)) {
log(
`push out along ${axis}`,
`${overlap.toFixed(2)} m`,
axis === "y" ? "the shallowest, so this is the way out" : undefined,
);
}
const contact = boxContact(FLOOR, FEET)!;
log(
"boxContact(floor, feet).normal",
`(${contact.normal.x}, ${contact.normal.y}, ${contact.normal.z})`,
"straight up, as a floor should",
);
log(
"pushOut(position, contact) from y = 0.60",
pushOut({ x: 0, y: 0.6, z: 0 }, contact).y.toFixed(3),
"the depth plus a 1 mm skin, so the next test is not a coin flip",
);
};
export default demo; A character has sunk 30 cm into the floor. Pushing along moves them 30 cm. Pushing along or moves them 5.4 meters — eighteen times further — and reads on screen as being flung sideways for no reason.
So you always take the shortest one, and it has a name: the minimum translation vector. And it
is free, because Section 6.2’s aabbAabb already reports the axis with the shallowest overlap. The
test that found the contact also told you the way out.
Staying Out: the Split
Section titled “Staying Out: the Split”Now the velocity. Everything from here rests on one decomposition — split it into the part heading along the surface normal and the part left over:
That is Section 1.3’s dot product doing its usual job, and Section 1.3’s slide in three
dimensions. Every response on this page is a decision about what to do with those two pieces.
src/lib/gamedev/demos/slide.scene.ts /** A velocity meeting a surface, split into the part it blocks and the part that slides. */
import * as THREE from "three";
import { MAX_SLOPE, analyse, surfaceDirection } from "./slide-shared.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const SLIDING = 0x39d3c3;
const BLOCKED = 0xff7b72;
const INCOMING = 0xf0883e;
const DIM = 0x484f58;
const VIEW = 6;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 300);
const scene = new THREE.Scene();
scene.background = background;
const aspect = width / height;
const camera = new THREE.OrthographicCamera(
-VIEW * aspect,
VIEW * aspect,
VIEW,
-VIEW,
0.1,
100,
);
camera.position.z = 10;
const lineOf = (color: number, dashed = false) => {
const geom = new THREE.BufferGeometry();
const mesh = new THREE.Line(
geom,
dashed
? new THREE.LineDashedMaterial({ color, dashSize: 0.22, gapSize: 0.18 })
: new THREE.LineBasicMaterial({ color }),
);
scene.add(mesh);
return (pts: THREE.Vector3[]) => {
mesh.visible = pts.length > 1;
if (!mesh.visible) return;
geom.setFromPoints(pts);
if (dashed) mesh.computeLineDistances();
};
};
/* Arrows are two lines: the shaft and a pair of head strokes. Kept here rather than using
ArrowHelper so an arrow of length zero can simply disappear. */
const arrowOf = (color: number) => {
const shaft = lineOf(color);
const head = lineOf(color);
return (from: THREE.Vector3, to: THREE.Vector3) => {
const along = to.clone().sub(from);
const length = along.length();
if (length < 0.06) {
shaft([]);
head([]);
return;
}
along.normalize();
const side = new THREE.Vector3(-along.y, along.x, 0);
const back = to
.clone()
.sub(along.clone().multiplyScalar(Math.min(0.34, length * 0.4)));
shaft([from, to]);
head([
back.clone().add(side.clone().multiplyScalar(0.15)),
to,
back.clone().sub(side.clone().multiplyScalar(0.15)),
]);
};
};
const surfaceLine = lineOf(0x8b949e);
const solidHatch = Array.from({ length: 13 }, () => lineOf(DIM));
const normalArrow = arrowOf(DIM);
const incoming = arrowOf(INCOMING);
const blockedArrow = arrowOf(BLOCKED);
const slidingArrow = arrowOf(SLIDING);
const ghost = lineOf(DIM, true);
const show = addReadout(el);
const tilt = addSlider(el, "surface angle", 0, 90, 55, draw);
const aim = addSlider(el, "aim of the velocity", -180, 180, -10, draw);
function draw() {
const a = analyse(tilt(), aim());
const dir = surfaceDirection(tilt());
const along = new THREE.Vector3(dir.x, dir.y, 0);
const normal = new THREE.Vector3(a.normal.x, a.normal.y, 0);
// The surface runs through the origin, with the solid side hatched behind it.
surfaceLine([
along.clone().multiplyScalar(-VIEW * 1.6),
along.clone().multiplyScalar(VIEW * 1.6),
]);
solidHatch.forEach((set, i) => {
const at = along.clone().multiplyScalar(-6 + i);
set([at, at.clone().sub(normal.clone().multiplyScalar(0.7))]);
});
normalArrow(new THREE.Vector3(), normal.clone().multiplyScalar(2));
// The velocity is drawn arriving at the contact point, so the split is visible there.
const v = new THREE.Vector3(a.velocity.x, a.velocity.y, 0);
const contact = new THREE.Vector3();
incoming(contact.clone().sub(v), contact);
const blocked = new THREE.Vector3(a.normalPart.x, a.normalPart.y, 0);
const tangent = new THREE.Vector3(a.tangentPart.x, a.tangentPart.y, 0);
const from = contact.clone().sub(v);
// Both parts start where the velocity did, so v = blocked + sliding reads as a triangle.
blockedArrow(from, from.clone().add(blocked));
slidingArrow(from, from.clone().add(tangent));
ghost([from.clone().add(tangent), contact, from.clone().add(blocked)]);
show(
`slope ${a.slope.toFixed(0)}\u00B0 \u00B7 ` +
(a.heldUp
? `${a.blocked.toFixed(2)} m/s blocked, ${a.sliding.toFixed(2)} m/s slides along \u00B7 `
: `nothing blocked, the velocity already points away from the surface \u00B7 `) +
(a.walkable
? `walkable, under the ${MAX_SLOPE}\u00B0 limit`
: `too steep to stand on, over the ${MAX_SLOPE}\u00B0 limit`),
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; The orange arrow is the velocity arriving at the surface. Red is the part the surface blocks, teal is the part that survives, and the dashed lines close the triangle: blocked plus sliding is exactly the velocity you started with, no more and no less.
Tilt the surface flat and nothing is blocked. Tilt it upright and at 90° everything is, so the teal arrow vanishes and you stop dead. In between you keep some of your speed, and that is not a compromise — it is what running along a wall should feel like.
Swing the aim past the surface and the split stops applying altogether. A velocity already pointing away from the surface must be left alone. Respond to it anyway and the object sticks to walls it was trying to walk away from, which is a bug with a very recognisable feel.
Sliding, bouncing and friction are one formula
Section titled “Sliding, bouncing and friction are one formula”is the restitution, or bounciness. At the blocked part flips completely and the speed is unchanged. At it is removed, and the formula becomes exactly the sliding expression above.
Sliding is bouncing with no bounce. There are not two behaviours here, only one number — which the build check enforces by asserting the two functions agree at every angle it tries.
Friction is the other dial, acting on the other piece: it shrinks the sliding part while restitution scales the blocked one. Between them they cover the whole range from ice to glue.
src/lib/gamedev/demos/bouncing.ts /** Restitution scales speed, so it scales height twice over. */
import { apexAfterBounces, respond } from "../response.ts";
import type { Demo } from "./runner.ts";
const DROP = 2;
const FLOOR = { x: 0, y: 1, z: 0 };
const RUNNING = { x: 5, y: -1, z: 0 };
const demo: Demo = (log) => {
for (const e of [0.5, 0.8, 0.95, 1]) {
log(
`dropped from ${DROP} m with restitution ${e}, apex after 1, 2 and 3 bounces`,
[1, 2, 3]
.map((n) => `${apexAfterBounces(DROP, e, n).toFixed(3)} m`)
.join(", "),
e === 0.95
? "sounds nearly lossless, still down a quarter after three"
: e === 1
? "never settles, which is why nothing uses it"
: undefined,
);
}
// Friction acts on the other half of the split: the part travelling along the surface.
for (const friction of [0, 0.3]) {
const after = respond(RUNNING, FLOOR, 0, friction);
log(
`landing at 5 m/s along the floor with friction ${friction}`,
`${Math.hypot(after.x, after.y, after.z).toFixed(2)} m/s left`,
friction === 0 ? "restitution never touches this part" : undefined,
);
}
};
export default demo; Restitution scales speed, and height goes as the square of speed, so each bounce keeps only of the height. That squaring catches people out. A restitution of 0.95 sounds nearly lossless and still loses a quarter of the height in three bounces. And bounces forever, which is why nothing ships with it.
Floor or Wall? Only a Threshold Says
Section titled “Floor or Wall? Only a Threshold Says”Nothing in the geometry distinguishes a floor from a wall. Both are surfaces with normals. The difference is a number somebody picked:
Around 45° is typical, and the scene above uses 46°. Under the limit, the character stands on it. Over it, the surface is treated as a wall — they slide down instead of standing, which is what stops a player walking up a cliff by holding forward.
Watch the readout flip as you tilt the surface past 46°. The maths did not change at that point; only the label did.
Tunneling: the Bug That Will Not Reproduce
Section titled “Tunneling: the Bug That Will Not Reproduce”Here is the failure that all of the above still allows.
Every test so far asks “am I overlapping right now?” — evaluated at frame positions. But an object does not exist only at frame positions. It jumps from one to the next, and if the jump is bigger than the wall, there is simply no frame at which it was ever inside.
src/lib/gamedev/demos/tunnel.scene.ts /** A sphere fast enough to skip over a wall, and the swept test that catches it anyway. */
import * as THREE from "three";
import {
GAP,
RADIUS,
WALL,
discreteHit,
framePositions,
stepFor,
sweptHit,
} from "./tunnel-shared.ts";
import { makeCanvas, addSlider, addCheckbox, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const CAUGHT = 0x39d3c3;
const MISSED = 0xff7b72;
const DIM = 0x484f58;
const VIEW = 3.2;
const DOTS = 40;
const circle = (radius: number, segments = 30) =>
Array.from({ length: segments + 1 }, (_, i) => {
const a = (i / segments) * Math.PI * 2;
return new THREE.Vector3(Math.cos(a) * radius, Math.sin(a) * radius, 0);
});
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 260);
const scene = new THREE.Scene();
scene.background = background;
const aspect = width / height;
const camera = new THREE.OrthographicCamera(
-VIEW * aspect,
VIEW * aspect,
VIEW,
-VIEW,
0.1,
100,
);
camera.position.z = 10;
// The wall, and the wider window a frame position has to land inside to notice it.
const pane = new THREE.Mesh(
new THREE.PlaneGeometry(WALL.max.x - WALL.min.x, WALL.max.y - WALL.min.y),
new THREE.MeshBasicMaterial({ color: 0x8b949e }),
);
scene.add(pane);
const windowEdges = new THREE.LineSegments(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-GAP / 2, -2.1, 0),
new THREE.Vector3(-GAP / 2, 2.1, 0),
new THREE.Vector3(GAP / 2, -2.1, 0),
new THREE.Vector3(GAP / 2, 2.1, 0),
]),
new THREE.LineDashedMaterial({ color: DIM, dashSize: 0.16, gapSize: 0.14 }),
);
windowEdges.computeLineDistances();
scene.add(windowEdges);
const path = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-VIEW * aspect, 0, 0),
new THREE.Vector3(VIEW * aspect, 0, 0),
]),
new THREE.LineBasicMaterial({ color: DIM }),
);
scene.add(path);
const ring = (color: number) => {
const geom = new THREE.BufferGeometry().setFromPoints(circle(RADIUS));
const mesh = new THREE.Line(geom, new THREE.LineBasicMaterial({ color }));
scene.add(mesh);
return mesh;
};
const ghosts = Array.from({ length: DOTS }, () => ring(DIM));
const marker = ring(CAUGHT);
const show = addReadout(el);
const speed = addSlider(el, "speed", 30, 300, 60, draw, " m/s", 15);
const offset = addSlider(
el,
"where the frames land",
0,
0.95,
0,
draw,
"",
0.05,
);
const swept = addCheckbox(el, "sweep between frames instead", false, draw);
function draw() {
const positions = framePositions(speed(), offset());
ghosts.forEach((g, i) => {
g.visible = i < positions.length;
if (g.visible) g.position.set(positions[i].x, 0, 0);
});
const found = swept()
? sweptHit(speed(), offset())
: discreteHit(speed(), offset());
marker.visible = found !== null;
(marker.material as THREE.LineBasicMaterial).color.setHex(CAUGHT);
if (found) marker.position.set(found.x, 0, 0);
(pane.material as THREE.MeshBasicMaterial).color.setHex(
found === null ? MISSED : 0x8b949e,
);
const step = stepFor(speed());
const lead = `${step.toFixed(2)} m per frame, and the wall plus the radius is only ${GAP.toFixed(2)} m wide \u00B7 `;
show(
found === null
? `${lead}every frame lands clear of it, so nothing was ever detected`
: swept()
? `${lead}the sweep on frame ${found.frame} crosses it, contact at x = ${found.x.toFixed(2)} m`
: `${lead}frame ${found.frame} happens to land inside it, at x = ${found.x.toFixed(2)} m`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; The grey pane is a 10 cm wall, and the dashed lines mark the window the sphere’s centre must land inside to be noticed — 70 cm, once you include the radius. The rings are the sphere’s position at the start of each frame.
Wind the speed up. At 60 m/s the sphere covers a meter per frame, already more than the window, and the wall turns red the moment no frame lands inside it.
Now move the second slider. It only changes where in the stride the frames happen to fall — same speed, same wall, same everything. And the answer flips between caught and missed.
That is the whole reason this bug is so unpleasant. The check sweeps forty offsets at 60 m/s and requires both outcomes to appear, so the claim cannot quietly stop being true. Nothing about the scene is unusual: a bullet at 60 m/s is slow for a bullet.
Sweeping asks a better question
Section titled “Sweeping asks a better question”Tick the checkbox. Instead of “am I overlapping now”, the test becomes “did my movement this frame cross anything?” — and the answer no longer depends on luck, because the movement is continuous even when the sampling is not.
The implementation needs no new geometry at all. A sphere against a box is a point against the box
grown by the radius, so sweeping a sphere is Section 6.2’s rayAabb against a bigger box:
That is exact on the faces and slightly generous at the corners, where the grown shape should really be rounded off. It matters for a shape sliding along an edge and not at all for catching a bullet.
The check confirms the contact lands exactly one radius from the wall — resting against it, not inside it — at every speed it tries, and that the swept test catches every speed and offset combination where the frame test is a coin flip.
source Penetration, sliding, bouncing and sweeping
/**
* What to do once a test says two things are touching. Detection was half the job.
*
* Two problems, and they are separate. **Position** is already wrong - the shapes overlap, so
* something has to be moved out. **Velocity** is still wrong - it is still pointing into the
* surface, so next frame will overlap again by the same amount. Fix only the first and the
* object buzzes against the wall; fix only the second and it sinks in and stays there.
*
* The whole file rests on one decomposition. Split a velocity into the part heading along the
* surface normal and the part left over, and every response is a choice about what to do with
* those two pieces. Sliding throws the normal part away. Bouncing reverses it. Friction shrinks
* the other one. There is no separate formula for any of them.
*/
import type { Vec3 } from "./matrices.ts";
import { aabbAabb, rayAabb, type Aabb } from "./collision.ts";
const add = (a: Vec3, b: Vec3): Vec3 => ({
x: a.x + b.x,
y: a.y + b.y,
z: a.z + b.z,
});
const sub = (a: Vec3, b: Vec3): Vec3 => ({
x: a.x - b.x,
y: a.y - b.y,
z: a.z - b.z,
});
const mul = (a: Vec3, k: number): Vec3 => ({
x: a.x * k,
y: a.y * k,
z: a.z * k,
});
const dot3 = (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + a.z * b.z;
const size = (a: Vec3) => Math.hypot(a.x, a.y, a.z);
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/** Everything a response needs: which way to push, and how far in. */
export type Contact = { normal: Vec3; depth: number };
/**
* A velocity split into the part along the surface normal and the part across it.
*
* `amount` is the signed size of the normal part, and its sign is the useful bit: **negative
* means heading into the surface**, which is the only case that needs responding to. A positive
* amount means the object is already leaving, and interfering with that is what causes an
* object to stick to a wall it was trying to walk away from.
*/
export function decompose(
v: Vec3,
n: Vec3,
): { amount: number; normalPart: Vec3; tangentPart: Vec3 } {
const amount = dot3(v, n);
const normalPart = mul(n, amount);
return { amount, normalPart, tangentPart: sub(v, normalPart) };
}
/**
* Throw away the part heading into the surface and keep the rest. This is sliding.
*
* It is Section 1.3's `slide` in three dimensions, and it is the single most useful line in a
* character controller: what `move_and_slide` does, and why running into a wall at an angle
* carries you along it instead of stopping you dead.
*/
export function slideAlong(v: Vec3, n: Vec3): Vec3 {
return sub(v, mul(n, dot3(v, n)));
}
/**
* Reverse the part heading into the surface, scaled by how bouncy the surface is.
*
* $$v' = v - (1 + e)(v \cdot n)\,n$$
*
* `restitution` of 1 is a perfect bounce that keeps all its speed, and 0 gives back exactly
* `slideAlong`. **Sliding is bouncing with no bounce**, which is worth noticing: there are not
* two separate behaviours here, only one number.
*/
export function reflect(v: Vec3, n: Vec3, restitution = 1): Vec3 {
return sub(v, mul(n, dot3(v, n) * (1 + restitution)));
}
/**
* The full response: bounce the normal part, and shave the tangent part with friction.
*
* `friction` of 0 slides freely along the surface and 1 stops dead in that direction. This is a
* crude model - real friction depends on how hard the surfaces are pressed together - but it is
* what most games use, because a designer can feel what the number does.
*/
export function respond(
v: Vec3,
n: Vec3,
restitution: number,
friction: number,
): Vec3 {
const { amount, tangentPart } = decompose(v, n);
if (amount > 0) return v; // Already leaving. Do not interfere.
return add(
mul(n, -amount * restitution),
mul(tangentPart, 1 - clamp01(friction)),
);
}
/**
* Move a position out of a surface by the penetration depth, plus a little.
*
* That little extra is the **skin**, and it is not sloppiness. Pushing out to exactly zero
* separation leaves the object on the boundary, where the next frame's test can round either
* way - so it reports a contact, then no contact, then a contact, and the object buzzes. A skin
* of a millimeter or two costs nothing visible and removes the whole problem. Unity exposes it
* as `skinWidth` for exactly this reason.
*/
export function pushOut(position: Vec3, contact: Contact, skin = 0.001): Vec3 {
return add(position, mul(contact.normal, contact.depth + skin));
}
/** How deep two boxes overlap on each axis. Negative entries mean that axis is clear. */
export function axisOverlaps(
a: Aabb,
b: Aabb,
): Array<{ axis: "x" | "y" | "z"; overlap: number }> {
return (["x", "y", "z"] as const).map((axis) => ({
axis,
overlap: -Math.max(a.min[axis] - b.max[axis], b.min[axis] - a.max[axis]),
}));
}
/**
* The **minimum translation vector** for two overlapping boxes: the shortest push that separates
* them, as a direction and a distance.
*
* Shortest is the whole point. Any of the three axes would separate them, but the other two move
* the object further than it needs to go, and a character pushed out sideways when they landed on
* a floor reads as being flung. Section 6.2's `aabbAabb` already reports the axis with the
* shallowest overlap, so the MTV falls out of the test that detected the contact.
*/
export function boxContact(a: Aabb, b: Aabb): Contact | null {
const { separation, axis } = aabbAabb(a, b);
if (separation >= 0) return null;
const aCentre = (a.min[axis] + a.max[axis]) / 2;
const bCentre = (b.min[axis] + b.max[axis]) / 2;
const normal: Vec3 = { x: 0, y: 0, z: 0 };
normal[axis] = bCentre >= aCentre ? 1 : -1;
return { normal, depth: -separation };
}
/** How steep a surface is, in degrees from flat. A floor is 0 and a wall is 90. */
export function slopeAngle(
normal: Vec3,
up: Vec3 = { x: 0, y: 1, z: 0 },
): number {
const length = size(normal) * size(up);
if (length < 1e-12) return 0;
const c = dot3(normal, up) / length;
return (Math.acos(Math.min(1, Math.max(-1, c))) * 180) / Math.PI;
}
/**
* Is this surface a floor or a wall? The question a character controller has to ask.
*
* Nothing in the geometry distinguishes them - both are just surfaces with normals. The
* difference is a threshold somebody chose, and it is what stops a player walking up a cliff by
* pressing forward into it. Above the limit, the surface is treated as a wall and the character
* slides down instead of standing on it.
*/
export function isWalkable(normal: Vec3, maxSlopeDegrees: number): boolean {
return slopeAngle(normal) <= maxSlopeDegrees;
}
// ---- Sweeping, and the reason it exists --------------------------------------------------
/** A box grown by a radius on every side. */
export function expandBox(box: Aabb, radius: number): Aabb {
const r = { x: radius, y: radius, z: radius };
return { min: sub(box.min, r), max: add(box.max, r) };
}
/**
* When a moving sphere first touches a box, in seconds, or `null` if it does not within `dt`.
*
* The trick is that a sphere against a box is a **point against a box grown by the radius**, so
* this is Section 6.2's `rayAabb` with no new geometry at all. It is exact on the faces and
* slightly generous at the corners, where the grown shape should really be rounded - which
* matters for a shape sliding along an edge and not at all for catching a bullet.
*
* This is the fix for **tunneling**. A test done only at frame positions asks "am I overlapping
* now", and something fast enough is simply never sampled while it is inside the wall. Sweeping
* asks "did I pass through between then and now", which is a different question and the right
* one.
*/
export function sweepSphereToBox(
centre: Vec3,
velocity: Vec3,
radius: number,
box: Aabb,
dt: number,
): number | null {
const speed = size(velocity);
if (speed < 1e-12) return null;
const direction = mul(velocity, 1 / speed);
const hit = rayAabb(centre, direction, expandBox(box, radius));
if (hit === null) return null;
if (hit.startedInside) return 0;
const time = hit.enter / speed;
return time <= dt ? time : null;
}
/**
* How high a ball reaches after a given number of bounces.
*
* Restitution scales **speed** by `e`, and height goes as the square of speed, so each bounce
* keeps only `e²` of the height. That squaring is why a restitution that sounds lively, say 0.8,
* still loses more than a third of the height every time it lands.
*/
export function apexAfterBounces(
height: number,
restitution: number,
bounces: number,
): number {
return height * Math.pow(restitution, 2 * bounces);
} Where This Shows Up
Section titled “Where This Shows Up”- Every character controller ever written, which is push-out plus slide plus a slope limit, iterated a few times per frame.
- Walking into a doorframe at an angle and gliding through instead of stopping.
- Not walking up cliffs, which is the slope limit and nothing else.
- Bullets and fast projectiles, which need sweeping or they pass through everything thin.
- Bouncing grenades, pinballs and ragdolls, which are restitution and friction.
- Objects that buzz, jitter or vibrate against geometry, which is almost always a missing skin or a push-out fighting a velocity that was never corrected.
- Section 7.2’s fixed timestep, which bounds how far anything moves in one step and therefore how bad the tunneling can get.