One late packet is not the new rate
Another player's pod is moved between updates by dead reckoning, which
advances it toward the projected position by a fraction of the remaining
gap each step:
percent = time_slice / ((nextUpdate - lastPerformance) + time_slice)
That fraction is decided by nextUpdate, so predicting when the next
packet lands is not cosmetic - it sets how far every step moves. The
prediction was labelled HACK in the original source and deserved it: the
next gap was assumed to equal the previous gap. On a LAN the gaps were
all alike and it held. Over Steam a single late packet doubles the
expected gap, percent collapses, the pod barely advances for one step and
then catches up on the next - a visible tick.
Measured on a live connection, in the replicant's own step grid: 7 spikes
in 5 seconds, about 1.4 a second, matching the reported symptom, with
percent bottoming out at 0.014 against a normal range of 0.27 to 0.95.
Predict from the median of the last eight gaps instead. A median has a
breakdown point of half its samples, so one straggler - or three - moves
it not at all, while a genuine change in the send rate still carries it
within a few updates. Gaps that are non-positive (duplicate or reordered)
or multi-second (a join or a stall) never enter the window, and the
window is reset outright when the stream is interrupted.
Against the measured jitter pattern the blend fraction holds 0.282..0.286
where it previously swung 0.095..0.294 - a spread fifty times smaller.
This changes how remote pods MOVE, not merely how they are drawn, so it
feeds collisions with them as well. RP412NETPREDICT=0 restores the old
prediction for comparison on the same build, and the environ.ini entry
says to keep the setting alike on every machine in a race.
The RP412CAMLOG trace now also reports the interval being predicted and
the worst one-step-ahead miss, scored per entity rather than through the
file-scope statics the percent readings use - those are written by
whichever mover ran last, which is exactly the crossed frame of reference
this whole investigation kept tripping over.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+182
-5
@@ -21,6 +21,40 @@
|
||||
static Logical gLastLerpUsed = False;
|
||||
static Scalar gLastPercent = 0.0f;
|
||||
|
||||
//
|
||||
// Bounds on the replication interval estimate, in seconds.
|
||||
//
|
||||
// The first pair decide what is allowed into the sample window at all: a
|
||||
// non-positive gap is a duplicate or a reordered packet, and a multi-second
|
||||
// one is a join, a pause or a stall. Neither says anything about the rate
|
||||
// the sender is actually keeping.
|
||||
//
|
||||
// The second pair are a backstop on the answer, set deliberately wide so
|
||||
// that in every sane case the median decides it and these never bind.
|
||||
//
|
||||
static const Scalar kMinimumUpdateInterval = 0.001f;
|
||||
static const Scalar kOutlierUpdateInterval = 2.0f;
|
||||
static const Scalar kMinimumPredictedInterval = 0.010f;
|
||||
static const Scalar kMaximumPredictedInterval = 1.0f;
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
// RP412NETPREDICT=0 restores the original single-sample prediction, so the
|
||||
// two can be compared on the same build and the same connection.
|
||||
//
|
||||
static Logical
|
||||
UseMedianPrediction()
|
||||
{
|
||||
static int cached = -1;
|
||||
|
||||
if (cached < 0)
|
||||
{
|
||||
const char *setting = getenv("RP412NETPREDICT");
|
||||
cached = (setting && *setting == '0') ? 0 : 1;
|
||||
}
|
||||
return cached ? True : False;
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
//############################### Mover #################################
|
||||
//#############################################################################
|
||||
@@ -640,6 +674,7 @@ void
|
||||
static Scalar mean_step = 0.0f;
|
||||
static Scalar min_percent = 1.0f;
|
||||
static Scalar max_percent = 0.0f;
|
||||
static Scalar worst_error = 0.0f;
|
||||
|
||||
++steps;
|
||||
if (have_last)
|
||||
@@ -676,7 +711,9 @@ void
|
||||
<< stalls << " stall(s), " << lerped
|
||||
<< " lerped, percent " << min_percent << ".."
|
||||
<< max_percent << ", mean step " << mean_step
|
||||
<< "m\n" << std::flush;
|
||||
<< "m, predicting " << predictedInterval
|
||||
<< "s worst miss " << worst_error << "s\n"
|
||||
<< std::flush;
|
||||
}
|
||||
next_say = ((Scalar) Now()) + 5.0f;
|
||||
steps = 0;
|
||||
@@ -685,6 +722,13 @@ void
|
||||
lerped = 0;
|
||||
min_percent = 1.0f;
|
||||
max_percent = 0.0f;
|
||||
worst_error = 0.0f;
|
||||
}
|
||||
{
|
||||
Scalar missed =
|
||||
(predictionError < 0.0f) ? -predictionError : predictionError;
|
||||
|
||||
if (missed > worst_error) { worst_error = missed; }
|
||||
}
|
||||
if (gLastLerpUsed)
|
||||
{
|
||||
@@ -820,6 +864,105 @@ void
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
void
|
||||
Mover::ResetUpdateIntervals()
|
||||
{
|
||||
Check(this);
|
||||
|
||||
updateIntervalCount = 0;
|
||||
updateIntervalWrite = 0;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
// Estimate how long until the next update for this entity arrives.
|
||||
//
|
||||
// This is not a cosmetic guess. DeadReckon blends toward the projected
|
||||
// origin by
|
||||
//
|
||||
// percent = time_slice / ((nextUpdate - lastPerformance) + time_slice)
|
||||
//
|
||||
// so the prediction sets how far every single step moves. The original code
|
||||
// predicted the next gap from the one previous gap. On a LAN that was fine,
|
||||
// because the gaps were all alike. Over the internet a late packet doubles
|
||||
// the prediction, percent collapses toward zero, the entity barely advances
|
||||
// for a step and then catches up on the following ones - which is a visible
|
||||
// tick. Measured on a live Steam connection at about 1.4 a second, with
|
||||
// percent bottoming out at 0.014 against a normal range of 0.27 to 0.95.
|
||||
//
|
||||
// A median has a breakdown point of half its samples, so one straggler - or
|
||||
// three - moves it not at all, while a real change in the send rate still
|
||||
// carries it within a few updates. That is the whole trick: ignore the
|
||||
// outlier, follow the trend.
|
||||
//
|
||||
Scalar
|
||||
Mover::PredictUpdateInterval(Scalar latest)
|
||||
{
|
||||
Check(this);
|
||||
|
||||
//
|
||||
// Only plausible gaps go into the window. Letting a join or a stall in
|
||||
// would poison the estimate for the next eight updates - precisely when
|
||||
// the entity is most conspicuous, just after it appears.
|
||||
//
|
||||
if (latest > kMinimumUpdateInterval && latest < kOutlierUpdateInterval)
|
||||
{
|
||||
updateIntervals[updateIntervalWrite] = latest;
|
||||
updateIntervalWrite = (updateIntervalWrite + 1) % UpdateIntervalSamples;
|
||||
if (updateIntervalCount < UpdateIntervalSamples)
|
||||
{
|
||||
updateIntervalCount++;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Too few samples to hold an opinion. Fall back to the old behaviour
|
||||
// rather than inventing a rate we have no evidence for.
|
||||
//
|
||||
if (updateIntervalCount < 3)
|
||||
{
|
||||
return latest;
|
||||
}
|
||||
|
||||
//
|
||||
// Insertion sort - the window is eight samples, and this runs once per
|
||||
// arriving packet per entity.
|
||||
//
|
||||
Scalar sorted[UpdateIntervalSamples];
|
||||
int i;
|
||||
|
||||
for (i = 0; i < updateIntervalCount; i++)
|
||||
{
|
||||
sorted[i] = updateIntervals[i];
|
||||
}
|
||||
for (i = 1; i < updateIntervalCount; i++)
|
||||
{
|
||||
Scalar value = sorted[i];
|
||||
int j = i - 1;
|
||||
|
||||
while (j >= 0 && sorted[j] > value)
|
||||
{
|
||||
sorted[j + 1] = sorted[j];
|
||||
j--;
|
||||
}
|
||||
sorted[j + 1] = value;
|
||||
}
|
||||
|
||||
Scalar median = sorted[updateIntervalCount / 2];
|
||||
|
||||
if (median < kMinimumPredictedInterval)
|
||||
{
|
||||
median = kMinimumPredictedInterval;
|
||||
}
|
||||
if (median > kMaximumPredictedInterval)
|
||||
{
|
||||
median = kMaximumPredictedInterval;
|
||||
}
|
||||
return median;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
void
|
||||
@@ -833,15 +976,45 @@ void
|
||||
{
|
||||
|
||||
//
|
||||
//-------------------------------------------
|
||||
// HACK - precalculation for next update time
|
||||
//-------------------------------------------
|
||||
//---------------------------------------
|
||||
// Precalculation for next update time
|
||||
//---------------------------------------
|
||||
//
|
||||
nextUpdate = Now();
|
||||
Scalar diff = nextUpdate - lastUpdate;
|
||||
if (diff < 10.0f)
|
||||
{
|
||||
nextUpdate.ticks += nextUpdate.ticks - lastUpdate.ticks;
|
||||
if (UseMedianPrediction())
|
||||
{
|
||||
Scalar predicted = PredictUpdateInterval(diff);
|
||||
|
||||
//
|
||||
// Score the previous prediction against the gap that
|
||||
// actually just elapsed - a true one-step-ahead error,
|
||||
// kept per entity so a trace reads the entity it is
|
||||
// watching and not whichever one updated last.
|
||||
//
|
||||
if (predictedInterval > 0.0f)
|
||||
{
|
||||
predictionError = predictedInterval - diff;
|
||||
}
|
||||
predictedInterval = predicted;
|
||||
|
||||
nextUpdate += predicted;
|
||||
}
|
||||
else
|
||||
{
|
||||
nextUpdate.ticks += nextUpdate.ticks - lastUpdate.ticks;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//
|
||||
// The stream was interrupted - a join, a pause, a long
|
||||
// stall. Nothing recorded before it describes the rate
|
||||
// now, so start the window over.
|
||||
//
|
||||
ResetUpdateIntervals();
|
||||
}
|
||||
|
||||
//
|
||||
@@ -1860,6 +2033,10 @@ Mover::Mover(
|
||||
updateAcceleration.angularMotion = localAcceleration.linearMotion;
|
||||
nextUpdate = lastUpdate;
|
||||
|
||||
ResetUpdateIntervals();
|
||||
predictedInterval = 0.0f;
|
||||
predictionError = 0.0f;
|
||||
|
||||
normalizeCount = 0;
|
||||
if (IsInitialStasis())
|
||||
{
|
||||
|
||||
@@ -289,6 +289,33 @@ protected:
|
||||
Time
|
||||
nextUpdate;
|
||||
|
||||
//
|
||||
// Recent gaps between replication updates for this entity, as a ring,
|
||||
// and the running estimate drawn from them. The original code predicted
|
||||
// the next gap from the single previous gap; see PredictUpdateInterval
|
||||
// for why that stalls a step every time a packet runs late.
|
||||
//
|
||||
enum {UpdateIntervalSamples = 8};
|
||||
Scalar
|
||||
updateIntervals[UpdateIntervalSamples];
|
||||
int
|
||||
updateIntervalCount,
|
||||
updateIntervalWrite;
|
||||
|
||||
//
|
||||
// The interval last predicted, and how wrong the prediction before it
|
||||
// proved to be once the gap it described actually elapsed. Per entity,
|
||||
// so a trace reads the entity it is watching.
|
||||
//
|
||||
Scalar
|
||||
predictedInterval,
|
||||
predictionError;
|
||||
|
||||
Scalar
|
||||
PredictUpdateInterval(Scalar latest);
|
||||
void
|
||||
ResetUpdateIntervals();
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Collision support
|
||||
//
|
||||
|
||||
@@ -255,6 +255,25 @@ namespace
|
||||
"# would rather have those 20 ms than smooth motion.\n"
|
||||
"#RP412INTERP=0\n"
|
||||
"\n"
|
||||
"# Set 0 to go back to the original guess at when the next update for\n"
|
||||
"# another player's pod will arrive. On by default.\n"
|
||||
"#\n"
|
||||
"# Between updates, another player's pod is moved by dead reckoning: it\n"
|
||||
"# advances toward where it is projected to be by a fraction of the gap\n"
|
||||
"# each step, and that fraction is decided by when the next update is\n"
|
||||
"# expected. The original code expected the next gap to match the one\n"
|
||||
"# before it, which held on a LAN where the gaps were all alike. Over the\n"
|
||||
"# internet one late packet doubles the expected gap, the fraction\n"
|
||||
"# collapses, the pod barely moves for a step and then catches up - a\n"
|
||||
"# tick roughly once a second on a live connection.\n"
|
||||
"#\n"
|
||||
"# The default instead takes the middle value of the last eight gaps, so\n"
|
||||
"# one straggler is ignored while a real change in the rate is still\n"
|
||||
"# followed. This changes how other players' pods MOVE, not merely how\n"
|
||||
"# they are drawn, so it affects collisions with them too - keep it the\n"
|
||||
"# same on every machine in a race.\n"
|
||||
"#RP412NETPREDICT=0\n"
|
||||
"\n"
|
||||
"# How long one background pass may spend drawing cockpit gauges, in\n"
|
||||
"# milliseconds. The gauges and the MFD/map displays are redrawn in the\n"
|
||||
"# time left over after the 3D view; on a big, busy map there is none\n"
|
||||
|
||||
Reference in New Issue
Block a user