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:
+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);
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Reference in New Issue
Block a user