Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/exojs-physics/src/PhysicsWorld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ export interface PhysicsWorldOptions {
fixedDelta?: number;
/** Maximum fixed steps per `step` call (spiral-of-death guard). Default `8`. */
maxSubSteps?: number;
/** TGS-Soft sub-steps per fixed step (the solver's stiffness scales with this, not iteration count). Default `4`. Must be ≥ 1. */
/**
* TGS-Soft sub-steps per fixed step (the solver's stiffness scales with this,
* not iteration count). Default `4`. Must be ≥ 1. Values below `2` visibly
* degrade tall-stack stability (a 10-box tower jitters at `1`), so the default
* is load-bearing — do not lower it for performance.
*/
subStepCount?: number;
/** Soft-contact stiffness in Hz (the contact behaves as a damped spring at this frequency). Default `30`. */
contactHertz?: number;
Expand Down Expand Up @@ -86,6 +91,21 @@ export interface AttachOptions {
* stiffness from the iteration count keeps tall towers stable. The detection
* backend sits behind an internal seam, so the solver is swappable without
* touching this public surface.
*
* **Operating envelope.** The soft solver trades a little accuracy for
* robustness, so it has a few documented limits — each stays finite/stable and
* each is pinned by a gate in `dynamics.test.ts`:
* - **Mass ratio** — resting stacks are slop-accurate up to ~100:1. Beyond that
* the velocity-capped soft push-out (`maxBiasVelocity`) lets the lighter body
* settle progressively deeper (≈6px at 500:1, fully through a thin floor by
* ~5000:1) — always finite, never exploding (SG-MR3).
* - **No CCD** — detection runs once per fixed step with no swept test, so a
* body that travels farther than an obstacle's thickness in one step tunnels
* straight through it (it stays finite). Reliably stopping fast projectiles is
* a future bullet-mode feature (SG-X5).
* - **{@link PhysicsWorldOptions.subStepCount}** — the default `4` is
* load-bearing for tall-stack stability; lowering it below `2` visibly
* degrades stacking, so do not reduce it for performance.
*/
export class PhysicsWorld implements BodyOwner {
/** Fires when two solid colliders begin touching. Argument is an immutable snapshot. */
Expand Down
48 changes: 48 additions & 0 deletions packages/exojs-physics/test/dynamics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,34 @@ describe('SG-MR — mass ratios', () => {
expect(floorPenetration).toBeLessThanOrEqual(1);
expect(light.y).toBeLessThan(floorTop); // light box centre still above the floor surface
});

it('SG-MR3: characterises the ~100:1 envelope edge — a crushed light box stays shallowly bounded above the floor (no tunnelling)', () => {
const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } });
const size = 32;
const floorTop = 300;

addFloor(world, floorTop);

const light = addBox(world, 0, floorTop - size / 2 - 1, size, size, { density: 1 });
addBox(world, 0, floorTop - size - size / 2 - 2, size, size, { density: 100 });

advance(world, 5);

expectAllFinite(world);

// ~100:1 is the top of the supported resting envelope: the squeezed light box
// settles a small, bounded distance into the floor (~1.4px here — a few times
// the 0.25px slop, but far less than its 16px half-extent) and its centre
// never sinks below the surface. Past this ratio the velocity-capped soft
// push-out (`maxBiasVelocity`, ContactSolver.ts) lets penetration grow
// sharply (≈6px at 500:1, fully through by ~5000:1) — a documented
// soft-constraint tradeoff, not a defect (see the operating-envelope note on
// PhysicsWorld). This gate pins the edge so a regression into deep
// penetration at 100:1 is caught.
const floorPenetration = light.y + size / 2 - floorTop;
expect(floorPenetration).toBeLessThanOrEqual(2);
expect(light.y).toBeLessThan(floorTop);
});
});

// ── SG-K: kinematic interaction ───────────────────────────────────────────
Expand Down Expand Up @@ -574,6 +602,26 @@ describe('SG-X — failure safety', () => {
expect(starts).toBe(1);
expect(ends).toBe(0);
});

it('SG-X5: a body faster than the floor thickness per frame tunnels but stays finite (no CCD)', () => {
const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } });
const floorTop = 300;

addFloor(world, floorTop); // 40px thick

const bullet = addBox(world, 0, floorTop - 100, 16);
bullet.linearVelocityY = 1e6; // » floor thickness per frame at 1/60 s

advance(world, 1);

// Detection runs once per frame with no swept/continuous test (no CCD), so a
// body that moves farther than the floor thickness in a single frame passes
// straight through. The only contract under such speeds is finiteness — no
// NaN/Inf blow-up — which this gate pins. Reliably stopping fast projectiles
// is a v0.16 CCD / bullet-mode item (see the operating-envelope note).
expectAllFinite(world);
expect(bullet.y).toBeGreaterThan(floorTop); // tunnelled through (documented limitation)
});
});

// ── SG-D: deterministic replay ────────────────────────────────────────────
Expand Down
Loading