diff --git a/MUNGA/MOVER.cpp b/MUNGA/MOVER.cpp index a20a648..4e0d8a1 100644 --- a/MUNGA/MOVER.cpp +++ b/MUNGA/MOVER.cpp @@ -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()) { diff --git a/MUNGA/MOVER.h b/MUNGA/MOVER.h index cc65298..d193c80 100644 --- a/MUNGA/MOVER.h +++ b/MUNGA/MOVER.h @@ -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 // diff --git a/RP_L4/RPL4ENVIRON.cpp b/RP_L4/RPL4ENVIRON.cpp index 4d77597..c32ad24 100644 --- a/RP_L4/RPL4ENVIRON.cpp +++ b/RP_L4/RPL4ENVIRON.cpp @@ -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"