Replicants interpolate too

The first cut hung the snapshot off Mover::BeginStep, which is inside
Entity::PerformAndWatch's fixed-step interleave - and that interleave sits
entirely inside "if (GetInstance() != ReplicantInstance)". A replicant
never runs it; it reaches the step loop through Simulation::PerformAndWatch
instead. Every remote pod is a replicant, so on a Live Cam the camera was
being interpolated while the car it was watching still stepped. Smoother,
and most of the way to nowhere - which is exactly what "still some
hitching" was.

So the hooks move to Simulation::PerformTo, where both paths meet:
SnapshotRenderOrigin before each Perform, SetRenderStepFraction after the
loop, two virtuals that do nothing by default and are overridden by Entity
because Entity owns the origin. Mover::BeginStep goes back to what it was,
so there is now one mechanism instead of two.

Taking the snapshot inside the step loop is also strictly better placed
than BeginStep was: it lands immediately before the integration, and still
after any BeginStep teleport, so a VTV's scheduled respawn stays a cut.

Entity::PerformAndWatch keeps computing the fraction itself after its
interleave, because there PerformTo is called once per step with a till
one step ahead and so sees no leftover at all - it needs the FRAME's till,
which only the interleave has.

Determinism re-proved, and more thoroughly than the first time. The
scripted lap at 240 fps, interpolation on and off, on both the old build
and this one: all four runs agree to the last decimal at the same
simulation time - pos -15.06739 3.01541 388.02603 at t=15.260. The one
"differing" sample in the raw comparison was the trace sampling at t=1.260
in one run and t=1.280 in the other and then realigning, not divergence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-10 23:36:44 -05:00
co-authored by Claude Opus 5
parent 9e8a94c436
commit adcb81e7fc
5 changed files with 72 additions and 13 deletions
+14
View File
@@ -1425,6 +1425,20 @@ static Logical
return enabled ? True : False;
}
void
Entity::SnapshotRenderOrigin()
{
Check(this);
renderPreviousOrigin = localOrigin;
}
void
Entity::SetRenderStepFraction(Scalar fraction)
{
Check(this);
renderStepFraction = fraction;
}
void
Entity::GetRenderToWorld(LinearMatrix *out)
{
+9
View File
@@ -186,6 +186,15 @@ public:
void
GetRenderToWorld(LinearMatrix *out);
//
// Filled by Simulation::PerformTo around each fixed step, for locally
// simulated entities and replicants alike.
//
void
SnapshotRenderOrigin();
void
SetRenderStepFraction(Scalar fraction);
int
GetDamageZoneIndex(const CString &damage_zone_name) const;
-13
View File
@@ -699,19 +699,6 @@ 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();
+32
View File
@@ -774,6 +774,13 @@ void
while (behind >= step && taken < max_steps)
{
//
// Where this step STARTED, for drawing. Taken here rather than
// in BeginStep so it covers replicants too, and taken after any
// BeginStep teleport (a VTV's scheduled respawn) so a jump
// stays a cut instead of becoming a slide.
//
SnapshotRenderOrigin();
Perform(step);
++gPhysicsStepsTaken;
lastPerformance += step;
@@ -781,6 +788,19 @@ void
++taken;
}
//
// How far past the last completed step the frame being drawn falls.
// Entity::PerformAndWatch computes this again from the FRAME's till
// after its interleave, because there this function is called once
// per step and sees no leftover at all.
//
{
Scalar fraction = behind / step;
if (fraction < (Scalar) 0) fraction = (Scalar) 0;
if (fraction > (Scalar) 1) fraction = (Scalar) 1;
SetRenderStepFraction(fraction);
}
//
// 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
@@ -808,6 +828,18 @@ void
// nothing by default - see the header
}
void
Simulation::SnapshotRenderOrigin()
{
// nothing by default - only an Entity has an origin to snapshot
}
void
Simulation::SetRenderStepFraction(Scalar)
{
// nothing by default - see the header
}
void
Simulation::WatchAndWrite(MemoryStream *update_stream)
{
+17
View File
@@ -170,6 +170,23 @@ public:
virtual void
BeginStep();
//
// Render interpolation hooks, called by PerformTo around the fixed
// step. They live HERE rather than on the entity interleave because a
// REPLICANT never runs that interleave - it reaches PerformTo through
// Simulation::PerformAndWatch instead - and a replicant is exactly what
// every remote pod is. Hanging the snapshot off BeginStep left the
// watched car uninterpolated while the camera watching it was smooth,
// which is most of the way to nowhere.
//
// Defaults do nothing; Entity overrides them because it owns the
// origin. See Entity::GetRenderToWorld.
//
virtual void
SnapshotRenderOrigin();
virtual void
SetRenderStepFraction(Scalar fraction);
//
// The fixed step in seconds, 0 when frame-coupled. Global on purpose:
// a mixed-rate simulation would be a worse bug than either mode.