2D Motion Physics
The physics layer turns the math helpers into operations on velocities and bodies: cancel motion into a surface, bounce, drag, per-body gravity, steer the spin, aggregate contact normals, cast a ray for the closest hit, predict a flight, snapshot a body. It works on any IPhysxBody2D, so it serves the 2D simulation API as well as hand-written bodies. The gameplay verbs are built on it.
using Altruist.Physx; // Kinematics
using Altruist.Physx.TwoD; // Velocity2D, Ballistics2D, ContactNormals2D, body extensions, ray casts
Conventions: side view with +Y up; gravity is the magnitude (≥ 0) of the pull toward -Y. Body rotations are counter-clockwise (Box2D); a body's local +Y is its "up". Every operation is deterministic and evaluates exactly the formula in its summary, so it replaces the same code written inline without changing a bit.
Velocities (Velocity2D)
Pure functions on Vector2:
| Method | Formula / use |
|---|---|
ApproachSpeed(v, n) | -Dot(v, n): how hard v hits a surface whose normal points at the mover |
CancelInto(v, n, amount = 1) | Remove amount of the motion going into n (unchanged when moving away). Before a jump, or a thrust that cancels opposing motion |
Bounce(v, n, normalSpeed, tangentKeep) | n * normalSpeed + (v - n * Dot(v, n)) * tangentKeep: rebounds and launch pads |
PointVelocity(linear, angular, offset) | Velocity of a rigid-body point: linear + ω × offset |
ClosingSpeed(a, b, along) | Dot(a - b, along): how fast a gains on b in a direction (a rammer on its victim, along its nose); the difference first, as written |
CentripetalAcceleration(v, radius) | (v.X * v.X + v.Y * v.Y) / radius: e.g. extra grip into a surface at speed |
AccelerateAlong(v, dir, accel, dt) | v + dir * accel * dt (negative accel pushes against dir, e.g. grip into a surface) |
ApplyLinearDrag(v, drag, dt) | v * (1 - drag * dt) |
ApplyGravity(v, gravity, dt, scale = 1) | (v.X, v.Y - gravity * dt * scale): per-body gravity when the world's gravity is zero |
KeepMinimumSpeedX(v, min, fallbackDirection) | A body that must never come to rest horizontally |
var rebound = Velocity2D.Bounce(impactVelocity, normal,
normalSpeed: Velocity2D.ApproachSpeed(impactVelocity, normal) * restitution,
tangentKeep: 0.8f);
Kinematics
Dimension-agnostic (Altruist.Physx.Kinematics):
| Method | Meaning |
|---|---|
StoppingDistance(speed, deceleration) | speed * speed / (2 * deceleration) |
TimeToCover(distance, initialSpeed, acceleration, maxSpeed) | Accelerate up to maxSpeed, then cruise. A start above maxSpeed cruises at maxSpeed |
CentripetalAcceleration(speed, radius) | speed * speed / radius |
Ballistics
Ballistics2D answers "where will it be" in closed form and with a cheap fixed-step predictor:
| Method | Meaning |
|---|---|
PositionAt(p, v, gravity, t) / VelocityAt(v, gravity, t) | Closed-form free flight |
ApexHeight(vy, gravity) | How much higher it still rises (0 when not going up) |
LaunchSpeedY(rise, gravity, t) / LaunchVelocity(delta, gravity, t) | The throw that covers delta in exactly t seconds |
Step(ref p, ref v, gravity, dt) | One semi-implicit Euler step |
BounceOnFloor(ref p, ref v, floorY, restitution) | Reflect off a flat floor |
Predict(p, v, gravity, dt, steps[, floorY, restitution]) | State after steps steps (optionally bouncing) |
FirstStepWhere(p, v, gravity, dt, steps, predicate) | First step (1-based) whose position satisfies predicate, or -1 |
FirstZoneEntered(p, v, gravity, dt, steps, zones, floorY = -∞) | (Zone, Step): the first of several Aabb2D zones (checked in order) a step lands in, and the step (1-based); (-1, -1) when none, or when the flight drops below floorY first. Margins: pass zone.Grow(mx, my) |
var throwVelocity = Ballistics2D.LaunchVelocity(target - hand, gravity, t: 0.8f);
var willScore = Ballistics2D.FirstStepWhere(ball.Position, ball.LinearVelocity, gravity, 0.05f, 24,
p => goalZone.Contains(p)) >= 0;
// Which goal does the ball reach first (if any) before touching the floor?
Span<Aabb2D> goals = [theirGoal.Grow(1, -0.3f), ourGoal.Grow(1.5f, 0.5f)];
var (zone, step) = Ballistics2D.FirstZoneEntered(ball.Position, ball.LinearVelocity, gravity, 1f / 30, 60, goals, floorY: radius);
Note:
The stepped predictor is semi-implicit Euler, not the engine's integrator. When a prediction must match the simulation exactly (contacts, restitution, solver), step a scratch world and restore it with BodyState2D.
Contact Normals (ContactNormals2D)
Sum a body's contact normals over a step and tell ground from walls:
var contacts = new ContactNormals2D();
foreach (var c in world.Contacts)
{
if (!c.IsTouching || !IsMine(c)) continue;
var n = c.BodyA == body ? -c.GetWorldManifold().Normal : c.GetWorldManifold().Normal; // out of the surface
contacts.Add(n, up: body.GetWorldVector(Vector2.UnitY), minUpDot: 0.7f);
}
if (contacts.HasSupport) groundNormal = contacts.SupportDirection; // averaged, unit (zero when none)
if (contacts.HasAny) anySurfaceNormal = contacts.AllDirection;
Contact Impacts (ContactImpact2D)
One body (the striker: a bat, a kicker, a vehicle) hitting another (the target: a ball, a puck). Capture it in pre-solve, before the solver changes the velocities, and build a designed response from it. Normal points from the striker into the target.
// Pre-solve:
var impact = ContactImpact2D.Capture(point, normal, striker: car, target: ball);
// After the step: a designed hit.
if (impact.ClosingSpeed < minSpeed || impact.StrikerInto < minSpeed) return; // soft touch or the ball landing on it
var outN = impact.ClosingSpeed * (1 + restitution) * zoneMultiplier + impact.TargetSpeed * speedCarry;
var outT = impact.TargetTangential * (1 - carry) + impact.StrikerTangential * carry;
ball.SetVelocityInFrame(impact.Frame, outN, outT);
ball.AddSpinFromSlip(impact.Slip, ballRadius, spinFactor);
| Member | Formula |
|---|---|
Point, Normal, StrikerVelocity, StrikerSpin, StrikerCenter, TargetVelocity | As captured (Capture reads LinearVelocity, AngularVelocityZ, WorldCenter) |
StrikerPointVelocity | Velocity2D.PointVelocity(StrikerVelocity, StrikerSpin, Point - StrikerCenter): spin included |
ClosingSpeed | Dot(StrikerPointVelocity - TargetVelocity, Normal) |
StrikerInto | Dot(StrikerPointVelocity, Normal): the striker itself moving into the target |
TargetSpeed | TargetVelocity.Length() |
Frame | NormalFrame2D.Left(Normal) (tangent (-n.Y, n.X)) |
TargetTangential / StrikerTangential / Slip | Frame.Across(...) of each; StrikerTangential - TargetTangential |
Body Operations (BodyMotionExtensions2D)
Extension methods on IPhysxBody2D. Each reads the body, applies one formula above, and writes once.
| Group | Methods |
|---|---|
| Read | SpeedAlong(dir), VelocityAtPoint(worldPoint), BoxContains(worldPoint, halfW, halfH, padX, padY), BoxDistanceSquared(worldPoint, halfW, halfH) |
| Linear | SetVelocityInFrame(frame, alongNormal, alongTangent) (frame.Compose(...)), AddVelocityAlong, SetVelocityAlong, ApproachVelocityAlong(dir, target, accel, dt), RedirectVelocityAlong, AccelerateAlong, CancelVelocityInto, BounceVelocity, ReduceSpeed, ClampSpeed, ApplyLinearDrag, ApplyGravity, KeepMinimumSpeedX, BlendMotionFrom(linearBefore, angularBefore, keep) |
| Angular | AddSpinFromSlip(slip, radius, factor) (w + slip / radius * factor), AlignUpToNormal(normal, rate), ArriveAtAngle(target, time, minTime), RotateTowardAngle(target, gain), ApproachAngularVelocity(rate, accel, dt), SteerAngularVelocity(target, gain, maxRate, accel, dt) |
| State | CopyMotionFrom(source), CaptureState() |
Body Snapshots (BodyState2D)
var saved = body.CaptureState(); // position, angle, velocities, awake, enabled
// ... step a what-if rollout ...
saved.Restore(body); // SetTransform, velocities, wake
saved.Restore(body, wake: false, restoreEnabled: true);
Restore writes IsEnabled first (when asked), so an engine re-creates the body's broad-phase entries at the restored place.
Ray Casts (ClosestRayHit2D)
The world's RayCast(from, to, callback) reports hits in no particular order. ClosestRayHit2D keeps the closest hit a filter accepts, and can collect over several rays:
var below = new ClosestRayHit2D(length: reach, filter: f => f.UserData is SurfaceTag);
world.RayCast(leftFoot, leftFoot - up * reach, below);
world.RayCast(rightFoot, rightFoot - up * reach, below); // keeps the nearer of the two
if (below.Found) Land(below.Point, below.Normal, below.Distance);
var hit = world.RayCastClosest(eye, target, f => f.UserData is Wall); // one ray, new callback
Steering (BodySteeringExtensions2D)
Top-down, yaw-convention verbs that move a body directly (kinematic style): GetRotation, DistanceTo, IsFacing(target, halfAngleDegrees), FaceToward(point), TurnToward(point, maxRadPerSec, dt), MoveToward(point, speed, dt), MoveTowardAngle(yaw, speed, dt), Stop, LaunchImpulse(dir, magnitude) / LaunchImpulse(impulse) and LaunchVelocity(velocity) (through ApplyForce).
npc.TurnToward(player.Position, maxAngularSpeedRadPerSec: MathF.PI, dt);
if (npc.IsFacing(player.Position, halfAngleDegrees: 20)) npc.MoveToward(player.Position, speed: 4f, dt);