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
14 changes: 8 additions & 6 deletions CLAUDE.md

Large diffs are not rendered by default.

293 changes: 293 additions & 0 deletions briefs/M1.1.5-integration-euler.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/modules/forge/forge_3d/body.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
//! integrates against; `computeMotion` derives them from a `BodyDescriptor` and
//! its `Shape`. Static and kinematic bodies have zero inverse mass and inertia
//! (infinite effective mass). `Body` is the SoA row `BodyManager` stores.
//!
//! The `force`/`torque` columns are world-space per-tick accumulators (N, N·m):
//! `BodyManager.addForce`/`addTorque` add into them and `integrate` (E2) reads
//! then clears them each fixed tick (the `engine-physics-forge.md` §2 per-tick
//! reset contract). Being independent SoA columns, they leave the
//! position/rotation bulk-sync layout invariant (M1.1.15) untouched.

const std = @import("std");
const api = @import("weld_forge");
Expand Down Expand Up @@ -52,6 +58,12 @@ pub const Body = struct {
linear_velocity: Vec3r,
/// World-space angular velocity.
angular_velocity: Vec3r,
/// Accumulated world-space force (N) for the current tick; `addForce`
/// accumulates, `integrate` reads then clears it each fixed tick.
force: Vec3r,
/// Accumulated world-space torque (N·m) for the current tick; `addTorque`
/// accumulates, `integrate` reads then clears it each fixed tick.
torque: Vec3r,
/// Derived inverse mass/inertia + damping/gravity.
motion: MotionProperties,
/// Collision-shape handle (into a `ShapeStore`).
Expand Down
67 changes: 67 additions & 0 deletions src/modules/forge/forge_3d/body_manager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
//! Execution-log note flagging this against decision 7. Id allocation is
//! deterministic (no hash-map on the path — M1.1.14). World AABBs are computed
//! exactly per primitive on demand.
//!
//! Velocity/force/torque mutators (`setLinearVelocity`/`setAngularVelocity`,
//! `addForce`/`addTorque`/`addImpulse`) and their getters validate the handle
//! and no-op / return null on a stale one (parity with `position`/`rotation`).
//! `addForce`/`addTorque` accumulate into the per-tick `force`/`torque` columns
//! (cleared by `integrate`); `addImpulse` is an immediate `Δv = impulse·inv_mass`
//! (a natural no-op on static/kinematic bodies, `inv_mass == 0`). `addTorque`
//! and `setAngularVelocity` are INTERNAL — the public `PhysicsModule` 3D
//! interface (frozen M1.1.15) carries no angular mutators.

const std = @import("std");
const api = @import("weld_forge");
Expand Down Expand Up @@ -65,6 +74,8 @@ pub const BodyManager = struct {
.rotation = convQuat(desc.rotation),
.linear_velocity = Vec3r.zero,
.angular_velocity = Vec3r.zero,
.force = Vec3r.zero,
.torque = Vec3r.zero,
.motion = body_mod.computeMotion(desc, shape),
.shape = desc.shape,
.body_type = desc.body_type,
Expand Down Expand Up @@ -120,6 +131,62 @@ pub const BodyManager = struct {
return self.bodies.items(.collision_layer)[idx];
}

/// Safe getter: world-space linear velocity, or null if `id` is stale/invalid.
pub fn linearVelocity(self: *const BodyManager, id: BodyId) ?Vec3r {
const idx = self.alloc.validate(id) orelse return null;
return self.bodies.items(.linear_velocity)[idx];
}

/// Safe getter: world-space angular velocity, or null if `id` is stale/invalid.
pub fn angularVelocity(self: *const BodyManager, id: BodyId) ?Vec3r {
const idx = self.alloc.validate(id) orelse return null;
return self.bodies.items(.angular_velocity)[idx];
}

/// Set the world-space linear velocity. No-op on a stale/invalid handle.
pub fn setLinearVelocity(self: *BodyManager, id: BodyId, velocity: Vec3r) void {
const idx = self.alloc.validate(id) orelse return;
self.bodies.items(.linear_velocity)[idx] = velocity;
}

/// Set the world-space angular velocity. No-op on a stale/invalid handle.
/// Internal to `BodyManager`: the public `PhysicsModule` 3D interface has no
/// angular-velocity mutator (frozen decision at M1.1.15).
pub fn setAngularVelocity(self: *BodyManager, id: BodyId, velocity: Vec3r) void {
const idx = self.alloc.validate(id) orelse return;
self.bodies.items(.angular_velocity)[idx] = velocity;
}

/// Accumulate a world-space force (N) into the body's per-tick force
/// accumulator. No-op on a stale/invalid handle. Any live body accumulates
/// (the per-tick clear in `integrate` is uniform); a force must be
/// re-applied every tick it should act.
pub fn addForce(self: *BodyManager, id: BodyId, force: Vec3r) void {
const idx = self.alloc.validate(id) orelse return;
const forces = self.bodies.items(.force);
forces[idx] = forces[idx].add(force);
}

/// Accumulate a world-space torque (N·m) into the body's per-tick torque
/// accumulator. No-op on a stale/invalid handle. Internal to `BodyManager`
/// (the interface has no torque mutator — see `setAngularVelocity`).
pub fn addTorque(self: *BodyManager, id: BodyId, torque: Vec3r) void {
const idx = self.alloc.validate(id) orelse return;
const torques = self.bodies.items(.torque);
torques[idx] = torques[idx].add(torque);
}

/// Apply an instantaneous linear impulse (N·s) as an IMMEDIATE velocity
/// change `Δv = impulse · inv_mass`. No-op on a stale/invalid handle, and
/// naturally a no-op on a static/kinematic body (`inv_mass == 0`) — no
/// `body_type` branch needed.
pub fn addImpulse(self: *BodyManager, id: BodyId, impulse: Vec3r) void {
const idx = self.alloc.validate(id) orelse return;
const inv_mass = self.bodies.items(.motion)[idx].inv_mass;
const vel = self.bodies.items(.linear_velocity);
vel[idx] = vel[idx].add(impulse.scale(inv_mass));
}

/// Safe getter: the exact world-space AABB of the body's shape, or null if
/// `id` (or its shape) is stale/invalid.
pub fn bodyAabb(self: *const BodyManager, store: *const ShapeStore, id: BodyId) ?Aabbr {
Expand Down
107 changes: 107 additions & 0 deletions src/modules/forge/forge_3d/pipeline/integration.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! `forge_3d/pipeline/integration.zig` — semi-implicit (symplectic) Euler
//! integration over the live `BodyManager` SoA store.
//!
//! One pure per-tick pass in ascending slot-index order (deterministic — no hash
//! container on the path, the `BodyManager` discipline; M1.1.14). The store does
//! not compact, so the pass walks `0..bodies.len` and filters liveness with
//! `IdAllocator.isAliveIndex`. This is FREE-FLIGHT integration: broadphase and
//! narrowphase exist but are NOT invoked here, and there is no contact response
//! (Sequential Impulses is M1.1.6) — a dynamic body under gravity follows an
//! unobstructed trajectory.
//!
//! Semi-implicit Euler: velocity is integrated first, position from the *new*
//! velocity. Gravity is applied as an ACCELERATION scaled by `gravity_factor`
//! (mass-independent — Galileo), never as a force. Damping is the Jolt
//! clamped-linear form `v *= max(0, 1 − d·dt)`; the clamp is physical (it
//! prevents a sign flip when `d·dt > 1`), not a geometric epsilon. The
//! `force`/`torque` accumulators are reset every fixed tick for ALL live bodies
//! (`engine-physics-forge.md` §2). `integrate` applies damping exactly once with
//! the `dt` it is given and has no opinion on substeps — call cadence is the
//! orchestrator's concern (M1.1.15).
//!
//! Angular integration mirrors the linear half: the torque is mapped through the
//! world-space inverse inertia `I_world_inv = R · I_local_inv · Rᵀ`, `ω` is
//! integrated then clamp-damped, and the orientation advances by the first-order
//! (linearized) rule `q ← normalize(q + ½·dt·(ω_quat ⊗ q))` with the world-space
//! `ω` on the LEFT. That form divides by no `|ω|`, so it is singularity-free at
//! `ω = 0` (no zero guard needed — the M1.1.4 threshold discipline is honoured).
//! The gyroscopic term `ω × (I·ω)` is dropped (Jolt/PhysX/Bevy default; its
//! explicit integration injects energy).

const config = @import("../config.zig");
const body_manager = @import("../body_manager.zig");

const Real = config.Real;
const Vec3r = config.Vec3r;
const Quatr = config.Quatr;
const Mat3r = config.Mat3r;
const BodyManager = body_manager.BodyManager;

/// Advance every live body one fixed tick of `dt` seconds under world-space
/// `gravity` (m/s²), in ascending slot-index order.
///
/// For each DYNAMIC body — linear: `a = gravity·gravity_factor + force·inv_mass`;
/// `v += a·dt`; `v *= max(0, 1 − linear_damping·dt)`; `x += v·dt` (position from
/// the new velocity). Angular: `α = (R·I_local_inv·Rᵀ)·torque`; `ω += α·dt`;
/// `ω *= max(0, 1 − angular_damping·dt)`; `q ← normalize(q + ½·dt·(ω_quat ⊗ q))`
/// with world-space `ω` on the left. Static and kinematic bodies are not moved
/// (kinematic position-from-velocity is a later additive concern). Every live
/// body then has its `force`/`torque` accumulators cleared for the next tick.
///
/// This is a pure function of the store and `(dt, gravity)`: no broadphase,
/// narrowphase, contacts, or sleeping. The gyroscopic term is dropped.
pub fn integrate(bm: *BodyManager, dt: Real, gravity: Vec3r) void {
const positions = bm.bodies.items(.position);
const rotations = bm.bodies.items(.rotation);
const linear_velocities = bm.bodies.items(.linear_velocity);
const angular_velocities = bm.bodies.items(.angular_velocity);
const forces = bm.bodies.items(.force);
const torques = bm.bodies.items(.torque);
const motions = bm.bodies.items(.motion);
const body_types = bm.bodies.items(.body_type);

const n: u32 = @intCast(bm.bodies.len);
var i: u32 = 0;
while (i < n) : (i += 1) {
// The store never compacts, so dead slots sit between live ones — skip
// them (their stale data must not be integrated).
if (!bm.alloc.isAliveIndex(i)) continue;

if (body_types[i] == .dynamic) {
const mp = motions[i];

// --- Linear ---
// Gravity as an acceleration (mass-independent), plus the accumulated
// force divided by mass. Read `force` before the clear below.
const accel = gravity.scale(mp.gravity_factor).add(forces[i].scale(mp.inv_mass));
// Integrate velocity first (symplectic), then damp, then position.
var v = linear_velocities[i].add(accel.scale(dt));
const damp = @max(@as(Real, 0), 1 - mp.linear_damping * dt);
v = v.scale(damp);
linear_velocities[i] = v;
positions[i] = positions[i].add(v.scale(dt));

// --- Angular ---
// World-space inverse inertia I_world_inv = R · I_local_inv · Rᵀ
// (Rᵀ == R⁻¹ for an orthonormal rotation), then α = I_world_inv·τ.
const r = Mat3r.fromQuat(rotations[i]);
const i_world_inv = r.mul(mp.local_inv_inertia).mul(r.transpose());
const alpha = i_world_inv.mulVec(torques[i]);
var w = angular_velocities[i].add(alpha.scale(dt));
const ang_damp = @max(@as(Real, 0), 1 - mp.angular_damping * dt);
w = w.scale(ang_damp);
angular_velocities[i] = w;
// First-order orientation update: q ← normalize(q + ½·dt·(ω_quat ⊗ q)),
// world-space ω on the LEFT. No |ω| division ⇒ singularity-free at ω = 0.
const wa = w.toArray();
const w_quat = Quatr{ .x = wa[0], .y = wa[1], .z = wa[2], .w = 0 };
const dq = w_quat.mul(rotations[i]).scale(0.5 * dt);
rotations[i] = rotations[i].add(dq).normalize();
}

// Reset the per-tick accumulators for every live body (§2), including
// static/kinematic — the clear is uniform.
forces[i] = Vec3r.zero;
torques[i] = Vec3r.zero;
}
}
15 changes: 15 additions & 0 deletions src/modules/forge/forge_3d/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const broadphase = @import("pipeline/broadphase.zig");
// (engine-zig-conventions.md §13 lazy-analysis guard — an unreferenced module's
// tests are silently skipped).
const narrowphase = @import("pipeline/narrowphase/root.zig");
// M1.1.5 — semi-implicit Euler integration over the `BodyManager` SoA store.
// Re-exported at `Real` below; the comptime pin analyses its acceptance tests.
const integration = @import("pipeline/integration.zig");

// --- Solver scalar + math aliases ---

Expand Down Expand Up @@ -129,6 +132,16 @@ pub fn collideOrderedGeneric(shape_a: SupportShape, pos_a: Vec3r, rot_a: Quatr,
/// The `(normal, closest points, base penetration)` fast-path seed at solver precision.
pub const ContactSeed = narrowphase.ContactSeed(Real);

// --- Integration (semi-implicit Euler) ---

/// Advance every live body one fixed tick of `dt` under world-space `gravity`
/// (m/s²) with semi-implicit Euler + gravity·gravity_factor + clamped-linear
/// damping — the `Real`-bound entry. Free-flight only (no contacts); called by
/// the `step` orchestrator at M1.1.15.
pub fn integrate(bm: *BodyManager, dt: Real, gravity: Vec3r) void {
return integration.integrate(bm, dt, gravity);
}

// Pins so the inline tests + the acceptance suite are analysed when this module
// is built as a test target (engine-zig-conventions.md §13).
comptime {
Expand All @@ -138,7 +151,9 @@ comptime {
_ = body_manager;
_ = broadphase;
_ = narrowphase;
_ = integration;
_ = @import("tests/body_manager_test.zig");
_ = @import("tests/integration_test.zig");
_ = @import("tests/broadphase_test.zig");
_ = @import("tests/gjk_test.zig");
_ = @import("tests/epa_test.zig");
Expand Down
10 changes: 10 additions & 0 deletions src/modules/forge/forge_3d/slot_alloc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,14 @@ pub const IdAllocator = struct {
if (!meta.alive or meta.generation != p.generation) return null;
return p.index;
}

/// Whether the slot at bare column `index` is currently live. Distinct from
/// `validate` (which takes a packed id and checks the generation): `free`
/// does NOT compact, so `slots.items.len` is the high-water mark including
/// dead slots. An index-ascending pass (the integrator) walks that range and
/// must filter liveness per slot — a bare index carries no generation, so
/// `validate` cannot serve here.
pub fn isAliveIndex(self: *const IdAllocator, index: u32) bool {
return index < self.slots.items.len and self.slots.items[index].alive;
}
};
Loading