From 449ed5d297a77a4efd6f93e214df079e2a47e4a4 Mon Sep 17 00:00:00 2001 From: Cyd Date: Mon, 10 Aug 2026 23:14:39 -0500 Subject: [PATCH] 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) --- MUNGA/ENTITY.cpp | 76 +++++++++++++++++++++++++++++++++++++++++++ MUNGA/ENTITY.h | 45 +++++++++++++++++++++++++ MUNGA/MOVER.cpp | 13 ++++++++ MUNGA_L4/L4VIDRND.cpp | 24 ++++++++++++-- 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/MUNGA/ENTITY.cpp b/MUNGA/ENTITY.cpp index eb4db2c..1631a0a 100644 --- a/MUNGA/ENTITY.cpp +++ b/MUNGA/ENTITY.cpp @@ -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= 20) { localOrigin.angularPosition.Normalize(); diff --git a/MUNGA_L4/L4VIDRND.cpp b/MUNGA_L4/L4VIDRND.cpp index f570820..50e2383 100644 --- a/MUNGA_L4/L4VIDRND.cpp +++ b/MUNGA_L4/L4VIDRND.cpp @@ -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;