The simulation steps at a fixed rate
RP412PHYSICSHZ names a rate and the simulation advances in whole steps of exactly that size on every machine, whatever the display does. 0 - the default, and the shipped behaviour until the play testers have spoken - is the game as it has always run: the step is however long the last frame took, which makes the frame rate part of the physics. Measured over two seconds of free fall, a 30 fps machine's pod fell three times further than a 144 fps machine's. Two players on the same track were not in the same gravity. With a rate set, the same race is bit-identical across frame rates: 30, 60 and 144 fps produce the same trajectory to the last printed digit, and identical runs reproduce exactly - which was never true of this engine before, at any frame rate. It took three pieces, and every one was found by measuring, not by reading: - Simulation::PerformTo turns lastPerformance into the accumulator it always secretly was: whole steps while time remains, the remainder carried to the next frame. Watchers and update records stay once per frame - stepping is physics, watching is I/O. - Entity::PerformAndWatch interleaves subsystems and entity per STEP. The frame loop ran all subsystems to the frame boundary and then the entity, indistinguishable from correct at one step per frame - which is why thirty years of code never noticed - and wrong at two: the thrusters raycast twice from a vehicle that had not moved, and the hover spring fired twice on one stale height sample. The subsystems are also snapped onto their entity's step grid; each Simulation anchors its grid at its own creation time, a per-run phase no seed could pin. - Mover::BeginStep clears the force accumulator per step. It was cleared once per frame while the thrusters ADD per step, so step two of a frame integrated step one's thrust again - and how many steps a frame holds rides on wall-clock jitter, which is why identical configs measured a quarter-metre apart. The quaternion renormalise counts steps now too, for the same reason. The catch-up clamp is a quarter second of simulation whatever the rate, so a machine that cannot keep up slows down rather than seizing, and does so identically everywhere. The engine's clock counts milliseconds, so rates that do not divide 1000 - 60 among them - quietly run at the neighbouring millisecond step; the log now says so and names the exact ones. 25, 50 and 100 are exact, and all three are verified bit-identical across frame rates and across runs. Verified for a single vehicle settling under gravity and hover. Driving, collisions and the network are the next frontiers, in that order: the collision path writes the victim's state with wall-clock stamps and a hard-coded 0.1 s bounce, which single-player survives and lockstep will not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -745,6 +745,103 @@ void
|
||||
//
|
||||
if (GetInstance() != ReplicantInstance)
|
||||
{
|
||||
//
|
||||
//----------------------------------------------------------------
|
||||
// Fixed-step: the subsystems and the entity advance TOGETHER,
|
||||
// one step at a time, because they read each other mid-flight.
|
||||
// The VTV's hover spring is computed from its thrusters'
|
||||
// measured heights, and each thruster measures from where the
|
||||
// vehicle IS - so thrusters stepped twice against a vehicle
|
||||
// that has not moved yet hand back two identical height
|
||||
// samples, and the spring fires twice on stale data. Measured,
|
||||
// that pod climbs at 30 fps and flies level at 144.
|
||||
//
|
||||
// So the step loop lives HERE, above both: everyone is walked
|
||||
// to the same sub-frame instant before anyone takes the next
|
||||
// step. Watchers and the update stream still run once per
|
||||
// frame, after the loop - stepping is physics, watching is
|
||||
// I/O, and only the first belongs inside.
|
||||
//
|
||||
// The interleave keys off the ENTITY's own clock so a
|
||||
// subsystem created mid-flight (they are made alongside their
|
||||
// owner) can never wedge the loop.
|
||||
//----------------------------------------------------------------
|
||||
//
|
||||
Scalar fixed_step = Simulation::FixedStep();
|
||||
|
||||
if (fixed_step > (Scalar) 0)
|
||||
{
|
||||
//
|
||||
// One grid for the whole vehicle. Every Simulation anchors
|
||||
// its own lastPerformance at its creation time, so an
|
||||
// entity and its subsystems were stepping on grids offset
|
||||
// by a random fraction of a step - deterministic within a
|
||||
// run, DIFFERENT between runs, because creation times ride
|
||||
// on load timing. The thrusters' measurements then landed
|
||||
// a different sub-step distance from the vehicle's
|
||||
// integration every launch, which is physics drift no seed
|
||||
// can pin. Snap the subsystems onto the entity's grid; the
|
||||
// interleave below then keeps everyone in lockstep by
|
||||
// construction, and once aligned this assignment is a
|
||||
// no-op every frame after.
|
||||
//
|
||||
for (int i=0; i<subsystemCount; ++i)
|
||||
{
|
||||
if (subsystemArray[i] &&
|
||||
subsystemArray[i]->IsNonReplicantExecutable())
|
||||
{
|
||||
subsystemArray[i]->SetLastPerformance(
|
||||
GetLastPerformance());
|
||||
}
|
||||
}
|
||||
|
||||
Time step_till = GetLastPerformance();
|
||||
step_till += fixed_step;
|
||||
|
||||
while (step_till <= till)
|
||||
{
|
||||
//
|
||||
// BeginStep on the ENTITY comes before the subsystems
|
||||
// perform: the Mover's force accumulator is cleared
|
||||
// here, and the thrusters then ADD this step's forces
|
||||
// into a clean slate. The first version left that
|
||||
// clear on the per-frame path, so a two-step frame
|
||||
// integrated step one's thrust twice - and since how
|
||||
// many steps land in a frame rides on wall-clock
|
||||
// jitter, no two runs saw the same force history.
|
||||
// Identical configs measured 0.23 apart because of it.
|
||||
//
|
||||
BeginStep();
|
||||
for (int i=0; i<subsystemCount; ++i)
|
||||
{
|
||||
if (subsystemArray[i] &&
|
||||
subsystemArray[i]->IsNonReplicantExecutable())
|
||||
{
|
||||
subsystemArray[i]->BeginStep();
|
||||
subsystemArray[i]->PerformTo(step_till);
|
||||
}
|
||||
}
|
||||
Simulation::PerformTo(step_till);
|
||||
step_till += fixed_step;
|
||||
}
|
||||
|
||||
for (int i=0; i<subsystemCount; ++i)
|
||||
{
|
||||
if (subsystemArray[i] &&
|
||||
subsystemArray[i]->IsNonReplicantExecutable())
|
||||
{
|
||||
subsystemArray[i]->WatchAndWrite(update_stream);
|
||||
}
|
||||
}
|
||||
|
||||
SET_PERFORM_ENTITY();
|
||||
Simulation::WatchAndWrite(update_stream);
|
||||
Check_Fpu();
|
||||
CLEAR_PERFORM_ENTITY();
|
||||
CLEAR_PERFORM_SUBSYSTEMS();
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i=0; i<subsystemCount; ++i)
|
||||
{
|
||||
if (subsystemArray[i])
|
||||
|
||||
+40
-1
@@ -658,9 +658,48 @@ Bye_Bye:
|
||||
//
|
||||
//-----------------------------------------------
|
||||
// Make sure the position quaternion stays stable
|
||||
//
|
||||
// Frame-counting, so it only runs on the frame-coupled path - fixed
|
||||
// steps do the same thing in BeginStep, counted in STEPS, because
|
||||
// "every 20 frames" lands at a different point of the step sequence
|
||||
// on every machine and rounding at different points is drift.
|
||||
//-----------------------------------------------
|
||||
//
|
||||
if (++normalizeCount == 20)
|
||||
if (Simulation::FixedStep() <= (Scalar) 0 && ++normalizeCount >= 20)
|
||||
{
|
||||
localOrigin.angularPosition.Normalize();
|
||||
normalizeCount = 0;
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
// The per-STEP set-up. This is the same work Mover::PerformAndWatch does
|
||||
// once per frame above - and once per frame is exactly wrong under fixed
|
||||
// stepping: the thrusters ADD their forces into localAcceleration every
|
||||
// step, so an accumulator cleared per frame carries step one's thrust
|
||||
// into step two whenever a frame holds two steps. How many steps a frame
|
||||
// holds depends on wall-clock jitter, which made identical runs diverge
|
||||
// by a quarter of a metre while sitting still on the pad.
|
||||
//
|
||||
// Idempotent on purpose: the frame-level copy still runs first on every
|
||||
// path, and repeating this at each step start is a recompute from
|
||||
// current state, not an accumulation.
|
||||
//
|
||||
void
|
||||
Mover::BeginStep()
|
||||
{
|
||||
Check(this);
|
||||
|
||||
localVelocity.linearMotion.MultiplyByInverse(
|
||||
worldLinearVelocity,
|
||||
localToWorld
|
||||
);
|
||||
localAcceleration = Motion::Identity;
|
||||
previousOrigin = localOrigin;
|
||||
|
||||
if (++normalizeCount >= 20)
|
||||
{
|
||||
localOrigin.angularPosition.Normalize();
|
||||
normalizeCount = 0;
|
||||
|
||||
@@ -265,6 +265,14 @@ protected:
|
||||
MemoryStream *update_stream
|
||||
);
|
||||
|
||||
//
|
||||
// Per-step set-up under fixed stepping: clears the force accumulator
|
||||
// the thrusters add into, so each step integrates only its own
|
||||
// forces. See the definition for the frame-jitter bug this closes.
|
||||
//
|
||||
void
|
||||
BeginStep();
|
||||
|
||||
int
|
||||
normalizeCount;
|
||||
Environment
|
||||
|
||||
+196
-11
@@ -621,9 +621,203 @@ void*
|
||||
}
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// RP412PHYSICSHZ - the size of one simulation step, as a rate in hertz.
|
||||
//
|
||||
// The engine simulates TO a timestamp: every entity keeps a lastPerformance
|
||||
// marking how far it has been simulated, and PerformAndWatch hands Perform()
|
||||
// the difference. That difference used to be however long the last frame
|
||||
// took, which made the frame rate part of the physics - explicitly so, since
|
||||
// Mover scales its bounce and penetration thresholds by delta_t.
|
||||
//
|
||||
// Advancing lastPerformance in fixed steps instead makes it the accumulator
|
||||
// a fixed-step loop needs, and every Perform() in the game gets an identical
|
||||
// dt without one of them being touched.
|
||||
//
|
||||
// 0 restores the old behaviour for comparison. The RATE is a game-feel
|
||||
// decision, not a technical one: thirty years of handling constants were
|
||||
// tuned against the DOS build's 40 ms steps, and RP412 has been running
|
||||
// ~18 ms variable ones, so the feel has already drifted. Whatever is chosen
|
||||
// here becomes the canonical physics for pods and PCs alike.
|
||||
//#############################################################################
|
||||
|
||||
static Scalar
|
||||
FixedPhysicsStep()
|
||||
{
|
||||
static Scalar
|
||||
step = (Scalar) -1;
|
||||
|
||||
if (step < (Scalar) 0)
|
||||
{
|
||||
const char
|
||||
*setting = getenv("RP412PHYSICSHZ");
|
||||
|
||||
//
|
||||
// OFF until it is proven. The accumulator below is correct in
|
||||
// isolation but measured WORSE than the frame-coupled path it
|
||||
// replaces - at 30 fps the pod climbs, at 144 it barely moves -
|
||||
// so something else is still rate-dependent and feeding it. Not
|
||||
// a default until the trace says two frame rates agree.
|
||||
//
|
||||
int rate = (setting != NULL) ? atoi(setting) : 0;
|
||||
|
||||
//
|
||||
// Guard the arithmetic rather than the taste: a rate below the
|
||||
// frame rate is a legitimate choice (the pods ran at 25), but a
|
||||
// step of zero or a negative one is not a choice at all.
|
||||
//
|
||||
if (rate < 0)
|
||||
{
|
||||
rate = 0;
|
||||
}
|
||||
if (rate > 1000)
|
||||
{
|
||||
rate = 1000;
|
||||
}
|
||||
step = (rate > 0) ? ((Scalar) 1 / (Scalar) rate) : (Scalar) 0;
|
||||
|
||||
DEBUG_STREAM << "Physics: ";
|
||||
if (rate > 0)
|
||||
{
|
||||
DEBUG_STREAM << "fixed step, " << rate << " Hz";
|
||||
|
||||
//
|
||||
// The engine's clock counts MILLISECONDS, so a step is
|
||||
// really round(1000/rate) ms. A rate that does not divide
|
||||
// 1000 evenly therefore runs at a neighbouring rate wearing
|
||||
// this one's name - 60 asks for 16.67 ms and gets 17, which
|
||||
// is 58.8 Hz. Say so, and name the rates that mean what
|
||||
// they say.
|
||||
//
|
||||
if ((1000 % rate) != 0)
|
||||
{
|
||||
int step_ms = (1000 + rate / 2) / rate;
|
||||
DEBUG_STREAM << " - NOT millisecond-exact, steps will run "
|
||||
<< step_ms << " ms (" << (1000.0f / (float) step_ms)
|
||||
<< " Hz). 25, 50 and 100 are exact";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
DEBUG_STREAM << "frame-coupled (RP412PHYSICSHZ=0)";
|
||||
}
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
//
|
||||
// How far behind one frame may catch up: a quarter second of simulation,
|
||||
// whatever the rate - enough to ride out a texture load or an alt-tab,
|
||||
// short of letting a stalled machine spiral. Counted in steps because the
|
||||
// loop is, so 6 steps at 25 Hz, 12 at 50, 25 at 100.
|
||||
//
|
||||
static int
|
||||
MaximumCatchUpSteps(Scalar step)
|
||||
{
|
||||
int steps = (int)((Scalar) 0.25 / step);
|
||||
return (steps < 4) ? 4 : steps;
|
||||
}
|
||||
|
||||
// how many fixed steps the whole simulation has taken - the trace prints it,
|
||||
// so 'is the step actually fixed' is answered by measurement not by reading
|
||||
long gPhysicsStepsTaken = 0;
|
||||
|
||||
//#############################################################################
|
||||
// Simulation Support
|
||||
//
|
||||
Scalar
|
||||
Simulation::FixedStep()
|
||||
{
|
||||
return FixedPhysicsStep();
|
||||
}
|
||||
|
||||
void
|
||||
Simulation::PerformTo(const Time& till)
|
||||
{
|
||||
Check(this);
|
||||
Check(&till);
|
||||
|
||||
Scalar step = FixedPhysicsStep();
|
||||
|
||||
if (step > (Scalar) 0)
|
||||
{
|
||||
//
|
||||
//------------------------------------------------------------------
|
||||
// Fixed step. The simulation advances in whole steps of the same
|
||||
// size on every machine, and whatever is left over waits for the
|
||||
// next frame - lastPerformance is the accumulator, and always was.
|
||||
//
|
||||
// Before this, the slice was simply however long the last frame
|
||||
// took, so a 30 fps machine integrated gravity in 33 ms steps and
|
||||
// a 144 fps machine in 7 ms ones. Nothing in any Perform()
|
||||
// changes: it is handed a dt it can rely on instead of one that
|
||||
// depended on the graphics card.
|
||||
//
|
||||
// NOTE the caller decides the interleaving. An entity's spring
|
||||
// forces are computed from its subsystems (the VTV reads its
|
||||
// thrusters' measured heights), so the subsystems and the entity
|
||||
// must advance TOGETHER, one step at a time -
|
||||
// Entity::PerformAndWatch owns that loop and hands everyone the
|
||||
// same sub-frame 'till'. Stepping a subsystem all the way to the
|
||||
// frame boundary before its owner moves at all is how the first
|
||||
// attempt at this produced a pod that climbed at 30 fps and flew
|
||||
// level at 144: two spring impulses from one stale height sample.
|
||||
//------------------------------------------------------------------
|
||||
//
|
||||
Scalar behind = till - lastPerformance;
|
||||
int taken = 0;
|
||||
int max_steps = MaximumCatchUpSteps(step);
|
||||
|
||||
while (behind >= step && taken < max_steps)
|
||||
{
|
||||
Perform(step);
|
||||
++gPhysicsStepsTaken;
|
||||
lastPerformance += step;
|
||||
behind -= step;
|
||||
++taken;
|
||||
}
|
||||
|
||||
//
|
||||
// A machine that cannot keep up must not try to buy back the whole
|
||||
// backlog next frame - that costs more time, which makes a bigger
|
||||
// backlog. Drop what could not be run and carry on: the game slows
|
||||
// down rather than seizing, and it does so identically everywhere.
|
||||
//
|
||||
if (taken >= max_steps && behind >= step)
|
||||
{
|
||||
lastPerformance = till;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Scalar slice = till - lastPerformance;
|
||||
lastPerformance = till;
|
||||
|
||||
Perform(slice);
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
void
|
||||
Simulation::BeginStep()
|
||||
{
|
||||
// nothing by default - see the header
|
||||
}
|
||||
|
||||
void
|
||||
Simulation::WatchAndWrite(MemoryStream *update_stream)
|
||||
{
|
||||
Check(this);
|
||||
|
||||
if (!AreWatchersDelayed())
|
||||
{
|
||||
ExecuteWatchers();
|
||||
}
|
||||
WriteSimulationUpdate(update_stream);
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
void
|
||||
Simulation::PerformAndWatch(
|
||||
const Time& till,
|
||||
@@ -633,17 +827,8 @@ void
|
||||
Check(this);
|
||||
Check(&till);
|
||||
|
||||
Scalar slice = till - lastPerformance;
|
||||
lastPerformance = till;
|
||||
|
||||
Perform(slice);
|
||||
if (!AreWatchersDelayed())
|
||||
{
|
||||
ExecuteWatchers();
|
||||
}
|
||||
WriteSimulationUpdate(update_stream);
|
||||
|
||||
Check_Fpu();
|
||||
PerformTo(till);
|
||||
WatchAndWrite(update_stream);
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -147,6 +147,36 @@ public:
|
||||
MemoryStream *update_stream
|
||||
);
|
||||
|
||||
//
|
||||
// The two halves of PerformAndWatch, so an ENTITY can interleave its
|
||||
// subsystems' physics with its own, step by step, and still run the
|
||||
// watchers and the update stream once per frame. PerformTo advances
|
||||
// the simulation to the given time - in fixed steps when
|
||||
// RP412PHYSICSHZ names a rate, in one variable slice otherwise.
|
||||
//
|
||||
void
|
||||
PerformTo(const Time& till);
|
||||
void
|
||||
WatchAndWrite(MemoryStream *update_stream);
|
||||
|
||||
//
|
||||
// Called by the entity interleave at the TOP of every fixed step,
|
||||
// before any subsystem adds its forces for that step. Per-frame set-up
|
||||
// work - clearing a force accumulator, deriving local velocity from
|
||||
// world state - belongs here when the fixed step is on, because "once
|
||||
// per frame" is a wall-clock cadence and the whole point is that wall
|
||||
// clock no longer reaches the physics. Default: nothing.
|
||||
//
|
||||
virtual void
|
||||
BeginStep();
|
||||
|
||||
//
|
||||
// The fixed step in seconds, 0 when frame-coupled. Global on purpose:
|
||||
// a mixed-rate simulation would be a worse bug than either mode.
|
||||
//
|
||||
static Scalar
|
||||
FixedStep();
|
||||
|
||||
void
|
||||
DoNothingOnce(Scalar time_slice);
|
||||
void
|
||||
@@ -155,6 +185,9 @@ public:
|
||||
void
|
||||
SetLastPerformance(const Time& when)
|
||||
{Check(this); Check(&when); lastPerformance = when;}
|
||||
const Time&
|
||||
GetLastPerformance() const
|
||||
{Check(this); return lastPerformance;}
|
||||
void
|
||||
RequestEncore(Encore encore);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user