fix(physics): full retail per-frame chain for remote motion + persist ContactPlane across frames
Two linked issues both rooted in skipping parts of the retail physics chain. ## 1. Remote staircase on slopes — Euler never integrated between UPs TickAnimations called rm.Body.update_object(now) for remote integration, but PhysicsBody.update_object gates on MinQuantum = 1/30s (retail FUN_00515020 early-return). At our 60fps render tick (~16 ms), deltaTime < MinQuantum on almost every frame → early return AND LastUpdateTime never advances → position effectively never integrates. Remote Position changed only on UP hard-snap, producing visible teleport strides uphill (the "staircase" the user reported). Fix: call UpdatePhysicsInternal(dt) directly for the remote tick — the same pattern PlayerMovementController.cs:358 uses for the local player. Wire ResolveWithTransition in afterwards so the remote's Euler-advanced position gets swept through the same retail collision chain (find_env_collisions + find_obj_collisions + step_down + 6-path BSP dispatcher) that the local player already goes through. New field RemoteMotion.CellId tracks the remote's cell across frames; set from UpdatePosition.p.LandblockId and updated from transition output. ## 2. Local player floating on downhill slopes — ContactPlane not persisted Running a character down a slope faster than ~0.5 m/s vertical: per-frame Euler moves feet horizontally (no Z component since velocity is world-XY). After Euler, feet are above the new-XY terrain. ValidateWalkable takes the "above surface" branch without setting a contact plane, DoStepDown probes ~4 cm down (the retail StepDownHeight default), fails to find the surface 8-10 cm below, and the character stays at the old Z. Over a sustained descent this accumulates into a visible hover. Retail's PhysicsObj carries ContactPlane + ContactPlaneCellID as persistent fields (ACE PhysicsObj.cs:2598-2604 get_object_info → InitContactPlane). Each transition call seeds CollisionInfo.ContactPlane from the previous frame's plane. That seed is what lets AdjustOffset project horizontal velocity onto the slope surface — so the Euler offset acquires a Z component matching the slope and the sphere tracks terrain without needing step-down to do the catch-up every frame. Fix: add PhysicsBody.ContactPlane* fields mirroring PhysicsObj's. Extend ResolveWithTransition with an optional `body` parameter; when provided, seed the transition's CollisionInfo from body.ContactPlane at the start, copy back (preferring current, falling back to LastKnown) at the end. Both local (PlayerMovementController) and remote (TickAnimations) pass their body. Verified live: DIAG samples showed pre/post/resolved Z all exactly equal before the MinQuantum bypass (Euler frozen). After bypass, deltas dropped to floating-point noise on slopes for remotes. Local hover on downhill resolved in separate visual pass. All 717 tests green. No API breaks (ResolveWithTransition's body param is optional, backwards-compatible). Cross-refs: - decompile: FUN_00515020 update_object, FUN_005111D0 UpdatePhysicsInternal, FUN_005148A0 transition init - ACE: PhysicsObj.cs:2586-2621 get_object_info, Transition.cs:613-620 InitContactPlane Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56975f8919
commit
93cbabbc87
4 changed files with 166 additions and 8 deletions
|
|
@ -368,7 +368,8 @@ public sealed class PlayerMovementController
|
|||
sphereHeight: 1.2f, // human player height from Setup
|
||||
stepUpHeight: StepUpHeight,
|
||||
stepDownHeight: 0.04f, // retail default
|
||||
isOnGround: _body.OnWalkable);
|
||||
isOnGround: _body.OnWalkable,
|
||||
body: _body); // persist ContactPlane across frames for slope tracking
|
||||
|
||||
// Apply resolved position.
|
||||
_body.Position = resolveResult.Position;
|
||||
|
|
|
|||
|
|
@ -209,6 +209,17 @@ public sealed class GameWindow : IDisposable
|
|||
/// Zeroed on UM with TurnCommand absent.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 ObservedOmega;
|
||||
/// <summary>
|
||||
/// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits
|
||||
/// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001-
|
||||
/// 0x0040, indoor 0x0100+). Fed into
|
||||
/// <see cref="AcDream.Core.Physics.PhysicsEngine.ResolveWithTransition"/>
|
||||
/// so the retail sphere-sweep can look up the right terrain/EnvCell
|
||||
/// polygons for each remote's per-frame motion. Zero until the first
|
||||
/// UP lands, which disables the transition step for that frame
|
||||
/// (Euler alone, matching pre-wire behavior).
|
||||
/// </summary>
|
||||
public uint CellId;
|
||||
|
||||
public RemoteMotion()
|
||||
{
|
||||
|
|
@ -2012,6 +2023,12 @@ public sealed class GameWindow : IDisposable
|
|||
rmState.Body.Orientation = rot;
|
||||
}
|
||||
rmState.Body.Position = worldPos;
|
||||
// Adopt the server's cell ID as the transition starting cell.
|
||||
// Retail authoritatively hard-snaps cell membership here too; our
|
||||
// per-tick ResolveWithTransition sweep then advances CheckCellId
|
||||
// as the sphere crosses cells and writes the new cell back into
|
||||
// rmState.CellId so the NEXT frame starts in the correct cell.
|
||||
rmState.CellId = p.LandblockId;
|
||||
|
||||
// Retail hard-snaps orientation on UpdatePosition (set_frame,
|
||||
// FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment).
|
||||
|
|
@ -3909,12 +3926,67 @@ public sealed class GameWindow : IDisposable
|
|||
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||||
}
|
||||
|
||||
// Step 3: integrate physics — retail FUN_00515020
|
||||
// update_object → FUN_00513730 UpdatePositionInternal →
|
||||
// FUN_005256b0 Sequence::apply_physics. Position and
|
||||
// orientation BOTH advance from Velocity/Omega × dt.
|
||||
// No slerp, no soft-snap — retail is deterministic.
|
||||
rm.Body.update_object(nowSec);
|
||||
// Step 3: integrate physics — retail FUN_005111D0
|
||||
// UpdatePhysicsInternal. Pure Euler:
|
||||
// position += velocity × dt + 0.5 × accel × dt²
|
||||
//
|
||||
// Call UpdatePhysicsInternal DIRECTLY rather than via
|
||||
// PhysicsBody.update_object (FUN_00515020). update_object gates
|
||||
// on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
|
||||
// deltaTime < MinQuantum → early return AND LastUpdateTime is
|
||||
// NOT advanced. Net effect: position never integrates between
|
||||
// UpdatePositions and the only Body.Position changes come
|
||||
// from the UP hard-snap, producing a visible teleport-stride
|
||||
// on slopes (the "staircase" the user reported).
|
||||
//
|
||||
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
|
||||
// directly for the same reason. Remote motion mirrors that.
|
||||
// Omega is already integrated manually above, so we zero it
|
||||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||||
// double-integrating.
|
||||
var preIntegratePos = rm.Body.Position;
|
||||
rm.Body.calc_acceleration();
|
||||
rm.Body.UpdatePhysicsInternal(dt);
|
||||
var postIntegratePos = rm.Body.Position;
|
||||
|
||||
// Step 4: collision sweep — retail FUN_00514B90 →
|
||||
// FUN_005148A0 → Transition::FindTransitionalPosition.
|
||||
// Projects the sphere from preIntegratePos to postIntegratePos
|
||||
// through the BSP + terrain, resolving:
|
||||
// - terrain Z snap along the slope (fixes the "staircase" where
|
||||
// horizontal Euler motion up a slope sinks into rising ground
|
||||
// until the next UP pops it up)
|
||||
// - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
|
||||
// - object collisions via ShadowObjectRegistry
|
||||
// - step-up / step-down against walkable ledges
|
||||
// ResolveWithTransition is the same call PlayerMovementController
|
||||
// uses for the local player; remotes now get the full retail
|
||||
// treatment between UpdatePositions instead of pure kinematics.
|
||||
//
|
||||
// Skipped when rm.CellId == 0 (no UP landed yet — can't build
|
||||
// a SpherePath without a starting cell). One-frame grace until
|
||||
// the first UP arrives; harmless because the entity is
|
||||
// server-freshly-spawned at a valid Z anyway.
|
||||
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
|
||||
{
|
||||
// Sphere dims match local-player defaults (human Setup
|
||||
// bounds — ~0.48m radius, ~1.2m height). Good enough for
|
||||
// grounded humanoid remotes; can be setup-derived later
|
||||
// if creatures of wildly different sizes need different
|
||||
// collision profiles.
|
||||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||
preIntegratePos, postIntegratePos, rm.CellId,
|
||||
sphereRadius: 0.48f,
|
||||
sphereHeight: 1.2f,
|
||||
stepUpHeight: 2.0f, // retail default for unknown remotes
|
||||
stepDownHeight: 0.04f, // PhysicsGlobals.DefaultStepHeight
|
||||
isOnGround: true, // remotes are forced OnWalkable above
|
||||
body: rm.Body); // persist ContactPlane across frames for slope tracking
|
||||
|
||||
rm.Body.Position = resolveResult.Position;
|
||||
if (resolveResult.CellId != 0)
|
||||
rm.CellId = resolveResult.CellId;
|
||||
}
|
||||
|
||||
ae.Entity.Position = rm.Body.Position;
|
||||
ae.Entity.Rotation = rm.Body.Orientation;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue