Drawing interpolates across the fixed step

The simulation advances in whole 50 Hz steps and the renderer draws
whenever it can, so the drawn position only changed fifty times a second
and was held for however many frames fell inside a step. That is visible
as stepping, and it got WORSE the faster the machine: at 240 fps each
position is held for nearly five frames, which is why a fast PC looked
like a rabbit on crack while TARGETFPS=50 looked perfect. Matching the two
rates hid it, but locking the frame rate to the physics rate throws away
the entire point of having a fixed step.

So drawing now blends. Entity keeps renderPreviousOrigin - its origin at
the start of the step it is in, snapshotted by Mover::BeginStep - and
renderStepFraction, how far through that step the frame falls, which is
the leftover Entity::PerformAndWatch deliberately does not simulate.
GetRenderToWorld blends the two with Origin::Lerp, which already did
position and shortest-arc quaternion with normalisation.

It is RENDER ONLY. localOrigin and localToWorld are untouched, so physics,
collision, scoring, the nav map's queries and the network update records
all still see exact stepped values.

Three call sites. RootRenderable::Execute, which was the single place a
vehicle's transform reached the matrix stack - the renderable already ran
per frame and simply re-read a value that changed at the physics rate. The
eye needed its gate widened as well: it rebuilt the view only when
localToWorld CHANGED, so the world would have glided while the camera went
on stepping and the judder would have moved rather than gone. And a
teleport must stay a cut - that falls out free, because VTV::BeginStep
applies a scheduled respawn and THEN calls Mover::BeginStep, so the
snapshot lands post-teleport and the blend has nothing to travel.

The picture trails the simulation by up to one step, 20 ms at 50 Hz. That
is the standard price of interpolating rather than extrapolating, and much
the lesser evil: guessing forward overshoots and shimmers every time the
guess is corrected.

RP412INTERP=0 turns it off so the stepping can be seen again without a
rebuild.

Determinism proved rather than asserted: a scripted lap (RP412INPUTSCRIPT,
throttle and steering and pitch) at 240 fps with interpolation on and off,
90 PHYSTRACE samples over 22 seconds of driving, zero differ. Two earlier
attempts at that comparison were invalid and both were my method - the
first did not pin RP412SPAWNZONE so the runs began on different pads, and
the second had no input script, so a joystick sitting on the desk drove
the two runs differently. The template warns about the first of those in
as many words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-10 23:14:39 -05:00
co-authored by Claude Opus 5
parent 55e751648f
commit 449ed5d297
4 changed files with 156 additions and 2 deletions
+76
View File
@@ -825,6 +825,21 @@ void
step_till += fixed_step;
}
//
// How far past the last completed step the frame we are
// about to draw falls, as a fraction of one step. This is
// the leftover the fixed-step loop deliberately does not
// simulate - see renderPreviousOrigin in entity.h.
//
{
Scalar leftover = till - GetLastPerformance();
Scalar fraction = (fixed_step > (Scalar) 0)
? (leftover / fixed_step) : (Scalar) 0;
if (fraction < (Scalar) 0) fraction = (Scalar) 0;
if (fraction > (Scalar) 1) fraction = (Scalar) 1;
renderStepFraction = fraction;
}
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
@@ -1059,6 +1074,15 @@ Entity::Entity(
updateOrigin = localOrigin;
localToWorld = localOrigin;
//
// Render interpolation starts with nothing to blend: the previous
// origin IS the current one and no fraction of a step is outstanding,
// so GetRenderToWorld hands back localToWorld until the first step has
// actually been taken.
//
renderPreviousOrigin = localOrigin;
renderStepFraction = (Scalar) 0;
// initialize camera stuff
cameraOffset = Origin::Identity;
@@ -1377,6 +1401,58 @@ Logical
return IsDerivedFrom(*GetClassDerivations());
}
//##########################################################################
// Render interpolation - see entity.h for why, and for why it is render
// only. RP412INTERP=0 turns it off so the stepping it removes can be seen
// again on a test machine without a rebuild.
//##########################################################################
//
static Logical
RenderInterpolationEnabled()
{
static int enabled = -1;
if (enabled < 0)
{
const char *setting = getenv("RP412INTERP");
enabled = (setting != NULL && atoi(setting) == 0) ? 0 : 1;
if (!enabled)
{
DEBUG_STREAM << "Render: interpolation off (RP412INTERP=0) - "
<< "drawn motion steps at the physics rate\n" << std::flush;
}
}
return enabled ? True : False;
}
void
Entity::GetRenderToWorld(LinearMatrix *out)
{
Check(this);
Check_Pointer(out);
//
// Nothing to blend towards: no fraction of a step outstanding, no
// fixed step at all, or interpolation switched off. Hand back exactly
// what every caller used before this existed.
//
if (renderStepFraction <= (Scalar) 0 || !RenderInterpolationEnabled())
{
*out = localToWorld;
return;
}
//
// Origin::Lerp does the position and the shortest-arc quaternion, and
// normalises - which over a 20 ms step is indistinguishable from a
// true slerp and a good deal cheaper.
//
Origin blended;
blended.Lerp(renderPreviousOrigin, localOrigin, renderStepFraction);
*out = blended;
Check_Fpu();
}
//##########################################################################
// Renderer Support
//
+45
View File
@@ -141,6 +141,51 @@ public:
int damageZoneCount;
DamageZone **damageZones;
//######################################################################
//################### Render interpolation #########################
//######################################################################
//
// The simulation advances in whole fixed steps (RP412PHYSICSHZ) and the
// renderer draws whenever it can, so at any frame rate that is not the
// step rate the drawn position only changes 50 times a second and is
// held for however many frames fall inside a step. That is visible as
// stepping, and it gets WORSE the faster the machine: at 240 fps each
// position is held for nearly five frames.
//
// So drawing interpolates. renderPreviousOrigin is this entity's origin
// at the START of the step it is currently in, snapshotted by
// Mover::BeginStep, and renderStepFraction is how far through that step
// the frame being drawn falls. GetRenderToWorld blends the two.
//
// RENDER ONLY. localOrigin and localToWorld are untouched, so physics,
// collision, scoring, the nav map's entity queries and the network
// update records all keep seeing exact stepped values - which is what
// keeps the simulation identical on every machine at every frame rate.
// RP412PHYSTRACE is the proof of that and must not move.
//
// The picture therefore trails the simulation by up to one step (20 ms
// at 50 Hz), which is the standard price and much the lesser evil:
// extrapolating FORWARD instead has to guess, and overshoots into
// shimmer every time the guess is corrected.
//
// A teleport must not be smoothed - sliding a pod 300 metres across the
// map over 20 ms would be far worse than the cut it replaces. That
// falls out for free: VTV::BeginStep applies a scheduled respawn and
// THEN calls Mover::BeginStep, so the snapshot is taken after the
// teleport and the blend has nothing to travel.
//
Origin renderPreviousOrigin;
Scalar renderStepFraction;
//
// The transform to DRAW with. Falls back to localToWorld verbatim when
// interpolation is off, when there is no fixed step to interpolate
// within, or before the first snapshot exists - so the unfixed-step
// path behaves exactly as it always did.
//
void
GetRenderToWorld(LinearMatrix *out);
int
GetDamageZoneIndex(const CString &damage_zone_name) const;
+13
View File
@@ -699,6 +699,19 @@ void
localAcceleration = Motion::Identity;
previousOrigin = localOrigin;
//
// The same snapshot again, for DRAWING. Deliberately not previousOrigin
// itself: that one belongs to the physics, and rendering must not be
// aliased to a member the simulation is free to repurpose.
//
// This runs AFTER a VTV's scheduled respawn has teleported localOrigin,
// because VTV::BeginStep applies the respawn and then calls us. So a
// respawn leaves the two equal and the blend has nothing to travel - a
// teleport stays a cut instead of becoming a 300-metre slide across the
// map. See entity.h.
//
renderPreviousOrigin = localOrigin;
if (++normalizeCount >= 20)
{
localOrigin.angularPosition.Normalize();
+22 -2
View File
@@ -1082,8 +1082,18 @@ void
// DPL_FLUSH_DCS ( myDCS );
// }
myRenderer->GetMatrixStack()->Push();
//
// The transform to DRAW with, blended across the fixed simulation
// step this frame falls inside - see Entity::GetRenderToWorld. This
// is the whole of render interpolation on the vehicle side: the
// renderable already ran once per frame and simply re-read a value
// that only changed at the physics rate.
//
LinearMatrix renderToWorld;
myEntity->GetRenderToWorld(&renderToWorld);
Matrix4x4 tempMatrix;
tempMatrix = myEntity->localToWorld;
tempMatrix = renderToWorld;
myRenderer->GetMatrixStack()->MultMatrix(&tempMatrix.ToD3DMatrix());
//myLocalToWorld = *myRenderer->GetMatrixStack()->GetTop();
@@ -5651,7 +5661,17 @@ void
// time based upon the setting of the eyepoint rotation
//----------------------------------------------------------------------
//
if((myEyepointRotation && *myEyepointRotation != oldEyepointRotation) || oldLocalToWorld != myEntity->localToWorld || mForceUpdate)
//
// The last term is render interpolation. This gate rebuilt the view
// only when the entity's localToWorld CHANGED, which happens at the
// physics rate - so with the world now drawn on a blended transform the
// eye would have gone on stepping, and the judder would simply have
// moved from the scenery to the camera. While a blend is in progress
// the view has to be rebuilt every frame; renderStepFraction is zero
// whenever interpolation is off or idle, so the old behaviour is
// untouched. See Entity::GetRenderToWorld.
//
if((myEyepointRotation && *myEyepointRotation != oldEyepointRotation) || oldLocalToWorld != myEntity->localToWorld || mForceUpdate || myEntity->renderStepFraction > (Scalar) 0)
{
mForceUpdate = false;
oldLocalToWorld = myEntity->localToWorld;