Vehicle physics rewrite, Proving Ground + Vehicle Lab, surface maps, water overhaul, asset reorg #9

Merged
polenta merged 21 commits from feature/rigid-body-vehicle-physics into main 2026-10-05 07:54:37 +00:00
Owner

Summary

Vehicle physics — new pure-Go engine/physics package replacing the per-frame legacy model for all vehicles except the on-foot human:

  • Rigid bodies with real forces (SI units), fixed 120 Hz step with render interpolation, implicit gyroscopic term.
  • Contact solver: sequential impulses with friction, warm starting and split-impulse penetration correction; hull-vs-terrain/ramp sampling, box-box SAT for walls and vehicle-vs-vehicle crashes.
  • Components: raycast wheels (suspension, slip-curve tyres with low-speed stick, wheel spin, brakes/ABS), drivetrain with auto gearbox, aero surfaces, buoyancy/hydrodynamics.
  • Car, motorcycle/bicycle (rider lean controller), boat, airplane (wings/tail/fin + tricycle gear), helicopter (rotor, cyclic, tail rotor, optional SAS).
  • Legacy physics kept behind a Vehicle Lab "A/B" toggle until the new model is signed off.

Testing tools

  • Level system with main-menu level select.
  • Proving Ground: skidpad, slalom, brake strip, ramps, bumps, hills, side slope, wall/curb, crash lane, lake, runway, helipads.
  • Vehicle Lab debug panel: pause / single-step / slow-mo, take control, teleport to zones, telemetry overlay, force vectors, CSV recorder; R resets the vehicle upright in place.

Rendering

  • Terrain: normal / AO / roughness maps (texture arrays), parallax occlusion mapping, wet shoreline.
  • Buildings/objects: normal / AO / roughness maps in the PBR shader (no vertex tangents needed).
  • Water: linear-light shading (fixes the milky/grey look), absorption + scattering, Fresnel, sun glint, caustics, dissolving shore foam from procedural noise, warped camera grid with spacing-aware wave fade (no swimming/jitter), physically-paced waves, shore swell.

Assets

  • assets/ reorganised into materials/, models/, decals/, skies/, docs/, library/ with consistent <material>_<map> naming; 17 Poly Haven (CC0) wood sets added. Code, config and saves updated to the new paths. The assets folder itself is still gitignored except the README gifs.

Testing

  • go build ./..., go vet ./... clean.
  • Headless tests pass: go test ./engine/physics/ ./engine/texmaps/ ./engine/render/ ./engine/world/ (35 vehicle-physics scenario tests: settling, acceleration, braking, handbrake on slope, skidpad, crashes, bike balance/slalom, boat float/planing, takeoff, cruise stability, helicopter hover/forward flight).
  • Playtested in the Proving Ground during development (water, foam, parallax iterated on screenshots).
  • Physics feel still needs a full playtest pass per vehicle; shaders can't be compiled on the dev machine without OpenGL.

🤖 Generated with Claude Code

## Summary **Vehicle physics** — new pure-Go `engine/physics` package replacing the per-frame legacy model for all vehicles except the on-foot human: - Rigid bodies with real forces (SI units), fixed 120 Hz step with render interpolation, implicit gyroscopic term. - Contact solver: sequential impulses with friction, warm starting and split-impulse penetration correction; hull-vs-terrain/ramp sampling, box-box SAT for walls and vehicle-vs-vehicle crashes. - Components: raycast wheels (suspension, slip-curve tyres with low-speed stick, wheel spin, brakes/ABS), drivetrain with auto gearbox, aero surfaces, buoyancy/hydrodynamics. - Car, motorcycle/bicycle (rider lean controller), boat, airplane (wings/tail/fin + tricycle gear), helicopter (rotor, cyclic, tail rotor, optional SAS). - Legacy physics kept behind a Vehicle Lab "A/B" toggle until the new model is signed off. **Testing tools** - Level system with main-menu level select. - Proving Ground: skidpad, slalom, brake strip, ramps, bumps, hills, side slope, wall/curb, crash lane, lake, runway, helipads. - Vehicle Lab debug panel: pause / single-step / slow-mo, take control, teleport to zones, telemetry overlay, force vectors, CSV recorder; R resets the vehicle upright in place. **Rendering** - Terrain: normal / AO / roughness maps (texture arrays), parallax occlusion mapping, wet shoreline. - Buildings/objects: normal / AO / roughness maps in the PBR shader (no vertex tangents needed). - Water: linear-light shading (fixes the milky/grey look), absorption + scattering, Fresnel, sun glint, caustics, dissolving shore foam from procedural noise, warped camera grid with spacing-aware wave fade (no swimming/jitter), physically-paced waves, shore swell. **Assets** - `assets/` reorganised into `materials/`, `models/`, `decals/`, `skies/`, `docs/`, `library/` with consistent `<material>_<map>` naming; 17 Poly Haven (CC0) wood sets added. Code, config and saves updated to the new paths. The assets folder itself is still gitignored except the README gifs. ## Testing - `go build ./...`, `go vet ./...` clean. - Headless tests pass: `go test ./engine/physics/ ./engine/texmaps/ ./engine/render/ ./engine/world/` (35 vehicle-physics scenario tests: settling, acceleration, braking, handbrake on slope, skidpad, crashes, bike balance/slalom, boat float/planing, takeoff, cruise stability, helicopter hover/forward flight). - Playtested in the Proving Ground during development (water, foam, parallax iterated on screenshots). - Physics feel still needs a full playtest pass per vehicle; shaders can't be compiled on the dev machine without OpenGL. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Replaces Car's scalar-velocity kinematic model with a GTA III/re3-style
rigid body: real mass and moment of inertia, per-wheel raycast suspension
(spring+damper), a friction-circle tyre model for real cornering/skid
behavior, and a gear transmission with shift hysteresis. Adds the shared
impulse core (ApplyMoveForce/ApplyTurnForce, quaternion orientation
integration) as additive fields so every other vehicle type (Boat,
Motorcycle, Bicycle, Airplane, Helicopter, Human) is unaffected and keeps
using the existing scalar model until converted in later phases.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
cgo had no windows LDFLAGS entry for OpenAL, so MSYS2/UCRT64 builds
failed to link with undefined references to al*/alc* symbols. Add
-lopenal, matching the existing linux flag (both link libopenal's
import library).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Two real bugs caused the reported "flies into the sky and spins/rolls
sideways" behavior:

1. ApplySpringCollision ported re3's suspension formula literally,
   including a scaling trick built around re3's own GRAVITY constant — a
   tiny per-frame value (0.008) baked for its 50fps sim. Attalo's gravity
   is a real per-second constant (20), so substituting it into that
   formula unchanged over-scaled the resulting impulse by ~2500x.

2. ApplySpringDamping had no dt scaling at all, so it applied a
   full-strength velocity correction every frame regardless of frame
   time, compounding the instability.

Also fixes a related latent bug: a wheel probe with negative Distance
(fully embedded past the suspension's travel) was treated as "not
grounded" instead of "fully compressed", and anchors each wheel probe to
the car mesh's own bottom (via BoundsY) instead of assuming the vehicle's
transform origin sits at ground level, which isn't guaranteed by any
mesh's authoring convention.

Adds RecomputeInertiaTensorMinDims so a car's inertia tensor can never be
smaller than what its own Wheelbase/TrackWidth wheel positions imply —
otherwise forces applied at the wheels can spin the body far more easily
than its mesh bounds alone would suggest, which is unstable regardless of
the two scaling bugs above. Adds a regression test
(TestCarSuspensionSettlesWithoutExploding) that re-samples ground probes
from live position each step and asserts the car settles to rest instead
of diverging.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Car (and any future rigid-body vehicle) has UsesTerrainSnap()==false, so
it falls through the generic "hard floor for non-snapping vehicles" and
SetFloorY() safety-net blocks every frame. Both used the vehicle's
WorldAABB, which now reflects its full current rotation (since
FlatModelMatrix gained a rigid-orientation path) — a car pitched even 20-
30 degrees has a much taller rotated bounding box than sitting level, so
its lowest corner can dip far below the ground while the car itself is
resting normally. That teleported Position upward every frame to clear
the oversized rotated box, without ever correcting the rotation that
caused it, letting a small tilt get pumped into a runaway pitch while
the car was progressively shoved higher into the air — exactly the
"floating and rotating forward" behavior reported.

Rigid-body vehicles now skip both blocks' AABB-based logic entirely
(their own per-wheel suspension, added in the previous commit, is their
real ground contact) and instead get a much more conservative,
rotation-independent fallback keyed on Position itself, only for a
genuine fell-through-the-world case.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
1. The water buoyancy block skipped every IsRigidBody() vehicle on the
   assumption it would apply its own buoyancy impulse — but no vehicle
   type has that yet (planned for a future Boat conversion). Car has
   BuoyancyFactor but no buoyancy code of its own, so the skip silently
   made buoyancy completely inert for it. Now rigid-body vehicles get the
   same physical buoyancy/drag as the legacy path, applied to
   LinearVelocity instead of the unused VerticalVelocity/Velocity fields.

2. sampleGroundColumn (the engine-side ground sensor feeding each wheel's
   GroundProbe) rejected any surface higher than the probe's current
   position, intending to avoid mistaking a ceiling overhead for the
   ground. But a wheel resting at or near full suspension compression
   routinely has its probe origin marginally below the true terrain
   surface for a frame or two — that's what "compression" means — and
   rejecting the surface in that case drops ground contact (Hit=false)
   entirely for that frame, skipping suspension AND drive force. Terrain
   height is never actually a ceiling (a heightmap only ever represents
   the ground below), so it's now always a valid candidate regardless of
   the probe's height; ramps/shapes/objects (which legitimately could be
   a roof overhead) keep a filtered check, now with a small tolerance for
   the same near-compression case. This was starving Car of sustained
   ground contact, which explains reported near-zero driving speed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
syncDerivedFromOrientation computed Yaw via atan2(-Front.z, -Front.x).
Negating both atan2 arguments rotates the result by exactly 180° from
the convention Entity.updateVectors() actually uses (Front must equal
(cos(Yaw), sin(Pitch), sin(Yaw))*cos(Pitch)).

Front itself stayed correct (confirmed by direct comparison against the
legacy ModelMatrix() render path for the exact yaw value in the user's
saved scene — the matrices were bit-for-bit identical), which is why
the car's own rendering and drive-force direction were fine. But
ThirdPersonCamera.orbitOffset() places the camera at +Distance*Front,
deliberately relying on this exact convention to sit behind the vehicle
for a chase view (see its own comment). With Yaw 180° off, the camera
ended up on the nose side instead, looking at the car driving toward
it — which is what "the car spawns facing backwards" turned out to be;
the car was never actually misoriented.

Found via a test that seeds Orientation from the real yaw value in this
project's saved scene.json (-1156.634°, an accumulated/wrapped angle)
and drives one Update() step: the derived Yaw came out ~180° off from
the value that reproduces the same Front vector. Added
TestSyncDerivedYawMatchesFrontConvention as a permanent regression
check.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Car's new wheel-torque steering used the opposite sign from
applyAckermannYaw (vehicle.go), which Boat/Motorcycle/Bicycle still use
and which Car itself used before this rewrite. Verified empirically
(not just by inspection, since an earlier sign derivation this session
was itself wrong): built independent tests that check which way travel
direction actually curves relative to the vehicle's own Right vector.
The legacy formula turns toward Right on "A" (Steer=-1) — an unintuitive
convention in isolation, but the one every other vehicle and Car's own
prior behavior already used. Car's new formula had A and D swapped
relative to that, which read as "steering is reversed" once driving
finally worked well enough to test it.

Negated Car's steer-angle-to-turn mapping to match. Added
TestCarSteerMatchesLegacyConvention so the two can't drift apart again
without a test failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Boat: discretised Archimedes buoyancy over a 3x3 hull grid, planing lift
at speed, steered propeller thrust (authority fades at speed, matching
re3's steerFactor idea) applied at the stern so turning emerges from
off-center thrust rather than a separate rudder-torque hack, and
submersion-scaled hull/keel drag using the same "cancel a capped
fraction of velocity" pattern as Car's suspension damper (avoiding the
un-scaled-damping bug fixed earlier this session). Engine-side water
buoyancy pass now skips Boat specifically (it applies its own impulse)
rather than every IsRigidBody() vehicle, since Car still relies on the
generic fallback.

Boat's steer sign needed the *opposite* flip from Car's: the propeller
sits at the stern, so deflecting it the same direction as a front wheel
steers the opposite way. Verified with the same empirical
legacy-convention comparison test used for Car, not re-derived by hand.

New tests: settle-without-exploding, sinks-without-buoyancy (sanity
check buoyancy is doing the work), accelerates under throttle, and
steer-matches-legacy-convention.

Also adds Vehicle.ResetOrientation(): levels a vehicle (or Human, via
the same embedded method) while preserving heading and clearing angular
velocity, so a flipped/stuck vehicle can recover. Exposed via the R key
(only when not in building mode, where R already means something else)
and a debug-UI button per vehicle.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Both share a new 2-wheel (front/rear, centerline) version of Car's
per-wheel suspension + friction-circle tyre model (bike.go), factored
into a single updateBikePhysics function reused by both types rather
than duplicated. No re3 reference exists for this — GTA III never
shipped rideable motorcycles (CBike::ProcessBikeWheel is a stub with no
.cpp at all) — so this is a new design: since two centerline wheels give
tire forces no lever arm to roll the vehicle with, an explicit
self-righting/lean-into-turn mechanic drives AngularVelocity's roll
component directly (bypassing the inertia-tensor formalism, since
there's no physical torque to derive magnitudes from — same spirit as
gravity being mass-independent).

Steering sign verified empirically against the shared legacy
applyAckermannYaw convention (matches Car's, since the front wheel plays
the same steering role); confirmed correct on the first try rather than
needing the same fix-and-reverify cycle Car and Boat each needed.

Also fixes a pre-existing gap unrelated to this conversion: scene_config
had no "bicycle" case in either the load-time type switch or
vehicleTypeName, so a Bicycle could never be placed via scene JSON and
would mis-save as "car" if it somehow existed. Added both.

New tests: settle-without-exploding (with an upright-orientation check,
since these are the first vehicles whose stable resting state isn't
"upright" by default — a bike could in principle settle lying on its
side), accelerates-under-throttle, and steer-matches-legacy-convention,
for both types.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Engine thrust, lift proportional to dynamic pressure applied along the
aircraft's own Up axis (banking tilts Up, so part of lift becomes a
horizontal turning force — banked turns emerge from this instead of a
separate yaw formula, loosely modeled on the aerodynamic RC-plane flight
model that exists dormant in re3's shared vehicle code), and pitch/roll
control torques applied as force couples at the nose/tail and wingtips
rather than direct angle writes, so they integrate through AngularVelocity
like any other torque.

Added a weathervane mechanic (yaws the nose toward the actual direction
of travel) that turned out to be necessary, not optional: without it,
banking produces a sideways lift force that curves LinearVelocity, but
nothing makes Orientation follow that curving path, so the aircraft
just slides sideways instead of actually turning. A first version of
this scaled the correction by raw speed with no damping term — a
resonant instability that grew angular velocity past 60 rad/s within 5
simulated seconds once speed climbed. Fixed with a proper spring+damper,
speed-independent.

Also hit and fixed a real methodology bug while verifying pitch/roll
sign: an already-correct sign got flipped based on a multi-second test
result that looked backwards, but was actually the (deliberately
undamped) torque rotating past the target within that window and
crossing back through zero. An isolated, single-step calculation
(bypassing Update() entirely) caught this and confirmed the original
signs were right; both flips are reverted. Tests now check only the
immediate response over a few frames, with cruise airspeed pre-set so
the dynamic-pressure-driven torque has meaningful magnitude from frame
one — see the extensive comments in airplane_test.go and the updated
re3-unit-porting lesson in memory for the general pattern.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Collective thrust along the helicopter's own Up axis (hover-neutral at
full rotor + zero collective input, since thrust is set to exactly
cancel gravity there), cyclic pitch/roll as force couples at the
nose/tail and left/right (translation emerges from tilting the rotor
disk and redirecting thrust, the same way a real helicopter moves,
rather than a direct position-offset strafe the old kinematic version
used), and tail-rotor yaw as a damped direct angular acceleration.
Loosely modeled on re3's dormant FLIGHT_MODEL_HELI (real torque-based
rotor physics present in the shared codebase but, in GTA III itself,
only reachable via cheats/RC vehicles — never driven by any normal
CHeli, which is purely kinematic homing). Kept the existing RotorSpeed
spin-up/decay gameplay gate, now scaling thrust/torque magnitude instead
of writing VerticalVelocity directly.

Applied the lesson from Airplane immediately this time: verified every
torque sign (pitch, roll, yaw) with an isolated single-step
AngularVelocity calculation, bypassing Update() entirely, *before*
writing any multi-second test — all three came out correct on the first
try, with no fix-and-revert cycle needed.

This completes the phased rigid-body rewrite from the original plan:
Car, Boat, Motorcycle, Bicycle, Airplane, and Helicopter are all now on
the force/impulse model; only Human remains on the legacy scalar-
velocity system (by design — never in scope for conversion). Phase 7
(deleting the now-dead legacy branches in engine.go) is intentionally
not done yet — those branches are still load-bearing for Human, and the
plan defers cleanup until the user has playtested everything converted
so far.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
heli spin

1. Car/bike drift when stopped: the tyre grip model
   (desiredForce = -velocity*gripRate) is purely velocity-proportional,
   so it has no true zero-velocity equilibrium — any tiny persistent
   disturbance (e.g. slightly asymmetric per-wheel suspension
   compression) settles into a small nonzero perpetual creep instead of
   actually stopping. Added Vehicle.ApplyGroundStiction, called from
   Car and the shared bike physics whenever not actively
   throttling/braking, which firmly cancels residual horizontal
   velocity below a small speed threshold.

2. Boat "shaking too much forwards" (porpoising): the buoyancy impulse
   is a pure spring — as the hull pitches, the submerged sample grid's
   centroid shifts and creates a restoring torque — with no angular
   damping counterpart, so nothing stopped it rocking indefinitely
   along its length. Added submersion-scaled pitch/roll rate damping,
   the same fix pattern as Car's suspension damper.

3. Airplane/Helicopter falling through the ground: unlike every other
   vehicle type, these have no ground-contact mechanism of their own
   (no wheels/suspension, no buoyancy) — the only thing stopping them
   sinking into terrain was the generic "fell through the world"
   safety net, deliberately set to a 5-unit margin on the assumption
   every rigid body would have its own primary ground contact. For
   aircraft that fallback *is* the only ground contact, so they sank
   the full 5 units on every landing before being caught. Tightened the
   margin to 0.3 units specifically for Airplane/Helicopter.

4. Helicopter spinning when turning: cyclic pitch/roll applied a
   constant undamped torque for as long as the stick was held, so
   holding A/D kept accelerating roll rate with nothing to cap it,
   eventually tumbling. Redesigned as a bounded spring+damper toward a
   tilt target proportional to input (like a real rotor disk, which
   tilts to an angle and holds there rather than spinning up
   indefinitely) — unifies with and replaces the separate auto-level
   mechanism. Needed two rounds of sign-checking: an isolated
   single-step check gave one answer, but that check turned out to be
   unreliable until CyclicDamping was raised enough to remove an
   underdamped oscillation in the full system — see the in-code comment
   for the general lesson (an isolated check is only trustworthy once
   the system it approximates is itself well-damped).

New regression tests for all four: TestCarDoesNotDriftWhenStopped,
TestBoatDoesNotPorpoise(UnderThrottle), and
TestHelicopterCyclic{Roll,Pitch}StaysBounded.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
ApplyGroundStiction (added for the drift fix) only ever touched linear
velocity — added ApplyAngularStiction as its rotational counterpart and
wired it into Car (gated on no throttle/brake/steer, same as the linear
version) so a small persistent rotational disturbance has something
damping it to true zero too, not just linear drift.

This was written while investigating a "the car shakes" report. Tried
to reproduce it directly first: settling from rest, settling after a
drop from height, settling on statically uneven terrain (fixed
per-wheel height offsets), and settling on high-frequency noisy terrain
(a short-wavelength sine field, so even a tiny positional shift samples
a different height). None of these produced any shake — the suspension
settles to exactly zero angular/vertical velocity in every scenario
constructed here, including before this commit's addition. So this is
shipped as a reasonable, low-risk improvement on its own merits (the
same class of gap the linear stiction fixed), not as a confirmed fix
for the reported shake — that still needs more specific repro detail
(visual jitter vs. actual movement, constant vs. only while driving,
etc.) before it can be chased further.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
anti-roll, reduce bike lean

1. Real bug: the "step up small obstacles" logic (meant for a walking
   character climbing a curb) checked vb.VerticalVelocity <= 0 — a
   field that's never updated for rigid-body vehicles any more (they
   use LinearVelocity instead), so it was permanently stuck at 0,
   making the check always true. Every rigid-body vehicle, including
   Airplane/Helicopter, could "step" over an obstacle within 0.55
   units of height difference instead of colliding with it solidly.
   Fixed by excluding Airplane/Helicopter from step-up entirely (a
   flying vehicle should never step) and using the correct
   LinearVelocity[1] for the other rigid-body types.

2. Debug menu cleanup: hid the generic Acceleration/Friction/Steering
   (MaxSteer/SteerRate/SteerReturn/Wheelbase)/Terrain-Tilt controls for
   every converted rigid-body vehicle, since each now reads its own
   type-specific field instead (EngineAccel, SteerLockDeg, etc. — see
   each type's "Rigid Body (X)" panel) — those generic sliders had zero
   effect for any converted vehicle, Human-only now. Also hid
   MaxSpeed/MaxReverse/Braking for the specific types where they're
   likewise dead (e.g. Helicopter reads neither MaxSpeed nor
   MaxReverse — its speed comes purely from cyclic tilt), and fixed the
   "Vert vel (r/o)" readout to show LinearVelocity.Y instead of the
   same stale always-zero field for rigid bodies. This was very likely
   the actual cause of "I cannot increase the car speed" — the
   prominent, first-reached "Acceleration" slider had no effect on Car
   at all (it reads EngineAccel), while the two sliders that do work
   (Max speed, and Engine accel in the Car-specific panel) were easy to
   miss underneath it.

3. Added a Car anti-roll (sway) bar: a real car's roll amount during
   cornering is limited by a mechanical anti-roll bar; without an
   equivalent, roll was purely emergent from independent per-wheel
   suspension compression with nothing resisting it. New
   AntiRollStiffness/AntiRollDamping fields apply a direct restoring
   torque, same pattern as the bike's self-righting mechanic.

4. Reduced Motorcycle/Bicycle's MaxLeanSin (0.6/0.4 -> 0.35/0.25, i.e.
   roughly 37°/24° -> 20°/14°) and raised LeanDamping, since the
   explicit lean-into-turn target was the most directly controllable
   lever for "the bike leans too much" even though (like the car's
   roll) synthetic testing couldn't reproduce it being excessive in
   isolation.

Investigated the car/bike leaning complaint directly first (cornering
roll and acceleration-pitch diagnostics, mirroring the earlier "car
shake" investigation): both settle to small, quickly-damped angles in
every synthetic scenario tried here, same pattern as the shake report.
Shipped the anti-roll bar and lean reduction as reasonable, well-
motivated improvements on their own merits, not as confirmed fixes for
a reproduced issue.

New test: TestCarAntiRollLimitsCorneringLean.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Vehicle-vehicle collisions previously just zeroed each side's own
inward velocity independently — no momentum crossed between bodies,
no restitution, and no torque from an off-center hit, despite
ApplyTurnForce/GetEffectiveMass/PointVelocity already existing unused
in rigidbody.go for exactly this. Adds ResolveTwoBodyCollision, a
standard reduced-mass collision impulse (mirroring the kind of physics
re3's CPhysical::ApplyCollision produces, restructured around this
codebase's own primitives rather than porting its literal formula) and
wires it into the engine's vehicle-vehicle collision pass when both
sides are rigid bodies. Mixed pairs (e.g. a rigid car against the
still-legacy walking Human) keep the old one-sided behavior.

Adds a per-vehicle Elasticity tunable (default 0.15, deliberately low)
with scene-JSON round-tripping following the existing Weight/
BuoyancyFactor pattern.
Buoyancy averaged all 9 hull-grid samples with equal weight and a plain
linear submersion ramp, so the bow resisted partial dipping identically
to amidships and a fast water-entry felt no different from gentle
bobbing — the audit against re3's cBuoyancy flagged this as the single
biggest source of "doesn't feel like a hull."

Adds: per-sample HullWeights (bow weighted lighter, mirroring a tapered
hull cross-section structurally, not re3's literal per-cell values,
which were reverse-engineered for a different mesh); a tunable
SubmersionExponent so a lightly-dipped sample contributes less than a
linear ramp would (approximating a V-hull/keel); and SplashDragMult, an
extra vertical-velocity cancel keyed off a frame-over-frame jump in
submerged volume, approximating re3's delta-volume splash response on
fast water entry.

New tests confirm each mechanism actually changes behavior (bow weight
lower than center, partial submersion contributes less under the
exponent, fast entry damps harder than steady bobbing) rather than just
existing unused. All existing boat tests still pass unchanged.
Four structural gaps identified against re3's Automobile/Vehicle/
Transmission source, none of them tuning-constant differences:

- Gears only changed target speed, never how hard the engine pulled to
  get there, so every gear felt equally punchy. gearAccelMul ports the
  shape of cTransmission::CalculateDriveAcceleration's speedMul curve
  (low gears pull harder, tapering to 1x in top gear).
- Drive was hardcoded rear-wheel-only. Adds DriveType (rear/front/awd,
  zero value preserves existing behavior) mirroring
  HasFrontWheelDrive/HasRearWheelDrive.
- No handbrake existed as a mechanic distinct from the brake pedal.
  Adds VehicleInput.Handbrake, a rear-only high-force lock independent
  of Brake/BrakeBiasFront (re3's bIsHandbrakeOn path), wired to Left
  Shift while driving a Car.
- Every grounded wheel got a full independent force budget regardless
  of how many wheels were actually touching, so a car resting on 3
  wheels had more total grip/drive force than one sitting flat on 4.
  groundedShare scales brake/drive/coast force by groundedCount/4,
  structurally mirroring ProcessWheel's wheelsOnGround division
  (bounded to [0,1], so it can only ever reduce force, never amplify
  it — a no-op in the common all-4-grounded case).

New tests cover each mechanism directly. The wheels-on-ground test
initially compared 4-grounded against 2-grounded directly and got the
expected result backwards — the front wheels' own rolling-resistance
drag confounded the comparison — rewritten to hold front wheels off
the ground in both cases so only groundedShare differs.

All existing car tests pass unchanged; existing gearMul default (top
gear, 5-gear default table) evaluates to exactly 1, so today's tuned
feel at cruising speed is preserved.
Same "spring without its damper" bug class already fixed twice before
(Car's suspension, Boat's buoyancy/round 5) — the audit against re3's
Vehicle.cpp found it again in two more places:

- Airplane pitch had zero rate damping, the only axis with none (roll
  has auto-level, yaw has the weathervane). A held elevator is
  correctly undamped by design (a real one held over does eventually
  loop the plane), but a real tail also aerodynamically resists
  rotation independent of the elevator — without it, an induced pitch
  rate had nothing at all opposing it once the stick was released.
  PitchDampingMult adds this as -tailSpeed*|tailSpeed|, which always
  opposes pitch rate by construction (no isolated sign-check needed,
  unlike the control torques it sits next to).

- Helicopter collective thrust had no climb-rate self-damping (re3:
  fThrust -= fRotorFallOff*Dot(moveSpeed,Up)) and no linear drag at
  all on horizontal motion (re3: vecMoveResistance) — unlike every
  other converted vehicle type, nothing opposed a helicopter's
  translation once moving.

Both re-derived structurally in Attalo's own units/mass rather than
porting re3's literal constants, per this project's established
lesson. New tests needed two rounds of tuning to get right: the first
pitch-damping test drove several frames of induction-then-release
through the full Update() loop and saw pitch rate grow instead of
shrink — the still-large rate rotated the aircraft enough within that
window that roll auto-level/weathervane (which act on the *current*
rotated axes) leaked into the reading. Fixed by setting AngularVelocity
directly and checking after exactly one Update() call, since every
torque term reads the same pre-rotation axes within a single step.

All existing airplane/helicopter tests pass unchanged at the new
defaults.
Both types have per-axis mechanisms that shape rotation (Airplane's
pitch damping/auto-level/weathervane, Helicopter's cyclic spring+damper
and yaw damping), but none of them is a hard ceiling: Airplane's
pitch/roll are deliberately undamped while actively held, and both
types' spring-based mechanisms can still momentarily exceed their own
target-based bound before the damper catches up (e.g. right after a
large input step). MaxAngularSpeed clamps the full AngularVelocity
vector magnitude every frame, after all other torques, as a blunt
safety/feel ceiling on top — not a substitute for the tuning that
shapes rotation before it gets there.

Default 3.0 rad/s (~172°/s) for both. Tests set an extreme starting
AngularVelocity directly and confirm one Update() call clamps it,
rather than relying on a multi-second simulation to organically reach
the cap.
Real, reported bug ("the helicopter is spinning to the side and won't
stop"), confirmed synthetically rather than guessed at: sustained
aggressive cyclic (Steer=1, Throttle=-1 for 2s — plausible if a player
is fighting to correct an existing spin) tips the helicopter past
level, and on release AngularVelocity grew for several seconds instead
of decaying, settling into a persistent multi-rad/s oscillation.

Root cause: pitchSin/rollSin (dot(Front/Right, worldUp), the cyclic
spring's small-angle proxy for tilt) is not monotonic with the true
rotation angle once the helicopter has genuinely tipped past ~90° — the
spring's (target-actual) error term can be wrongly signed there and
reinforce rotation instead of opposing it. Compounded by this
helicopter's shape giving it a pitch moment of inertia *between* its
roll and yaw moments (RecomputeInertiaTensorMinDims' small height
dimension vs. its width/length) — the classic intermediate-axis
("tennis racket"/Dzhanibekov) instability for a tumbling rigid body.
Verified empirically that just raising CyclicDamping only shrinks the
resonant peak with diminishing returns rather than fixing it, since
it's damping a structurally wrong error signal, not correcting it.

Fix, in two parts:
- Gate the cyclic spring's stiffness term off past CyclicValidSin,
  leaving only the always-correctly-signed rate-damping term active
  beyond it (a pure damper can never reinforce rotation, unlike a
  position-error spring built on an ambiguous proxy). MaxCyclicSin
  (0.35) never approaches this threshold during ordinary flight.
- Add a large-angle-robust recovery torque (Up.Cross(worldUp), valid
  at any tilt unlike the small-angle proxy) that engages once actually
  tipped past RecoveryUpThresh. Sign verified with an isolated
  single-step check before use, not derived by hand, per this
  project's established discipline around rotation signs.

Applied the same stiffness gate to Airplane's roll auto-level, which
has the identical structural issue (confirmed via the same kind of
synthetic hold-then-release trace) — milder there since its own
damping term eventually wins out on its own, but genuinely improved.

Net result on the reported scenario: peak angular speed after release
dropped from unbounded/non-settling to 0.54 rad/s, settling to exact
zero within ~2s. New tests cover the isolated torque sign, the
full-system recovery, and confirm ordinary cyclic/aileron handling is
unaffected.
Vehicle physics
- New pure-Go engine/physics package: rigid bodies with real forces and an
  implicit gyroscopic term, fixed 120 Hz step with render interpolation,
  sequential-impulse contact solver (friction, warm start, split impulse),
  hull-vs-terrain/ramp sampling and box-box SAT for walls and vehicle-vs-vehicle.
- Components: raycast wheels (suspension, slip-curve tyres with low-speed
  stick, wheel spin, brake rows), drivetrain, aero surfaces, buoyancy/hydro.
- Car, motorcycle/bicycle (rider lean controller), boat, airplane (aero
  surfaces + tricycle gear) and helicopter (rotor, cyclic, tail rotor, SAS)
  built on it; engine/phys_bridge.go drives them and keeps the legacy model
  available as an A/B toggle. Headless scenario tests for every vehicle.

Testing tools
- Level system with main-menu level select; Proving Ground test track.
- Vehicle Lab debug panel: pause/step/slow-mo, teleport to zones, telemetry
  overlay, force vectors, CSV recorder; R resets the vehicle in place.

Rendering
- Terrain normal/AO/roughness maps (texture arrays) and parallax occlusion
  mapping; wet shoreline.
- Building/object surface maps in the PBR shader (derivative tangent frame).
- Water rewritten in linear light: absorption/scattering, Fresnel, GGX glint,
  caustics, dissolving shore foam from procedural noise, warped camera grid
  with spacing-aware wave fade, physically-paced waves, shore swell.

Assets
- Reorganised assets/ (materials/, models/, decals/, skies/, docs/, library/),
  consistent <material>_<map> naming, 17 Poly Haven wood sets, updated all
  code/config/save references; README gifs moved to assets/docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
polenta deleted branch feature/rigid-body-vehicle-physics 2026-10-05 07:54:41 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
polenta/Attalo!9
No description provided.