plaza_server_utils 0.7.0

Server-side building blocks for real-time netcode: relevance streaming, delta baselines, lag-compensation rewind, aggregation, and seat allocation. No async runtime, so it builds for wasm.
Documentation
//! How wrong a client's screen was, asked at the instant it was drawing.
//!
//! A client rendering behind the server is *supposed* to be behind: that is
//! what a render delay is for. Comparing what it drew against where the server
//! has things **now** charges it for a delay it chose and the figure grows with
//! the buffer depth even when nothing is wrong. Three separate playgrounds
//! wrote that comparison by hand and two of them made that mistake.
//!
//! [`render_error_at`] takes the instant as a required argument and reads truth
//! from a [`HistoricalStateBuffer`], so measuring against the present has to be
//! done on purpose. The buffer is usually already there: it is the same history
//! a server keeps to rewind a shot.
//!
//! This is a **host or harness** measurement, because it needs the true
//! positions and a joiner never has them. Nothing in `client_utils` can
//! answer this question.
//!
//! ```ignore
//! let error = render_error_at(&history, client.render_at()?, client.render(), |a, b| a.dist(*b));
//! println!("{:.1} px mean, {:.1} px worst", error.mean(), error.worst());
//! ```

use std::fmt::Debug;
use std::hash::Hash;
use std::ops::Sub;

use plaza_client_utils::interpolation::{Interpolatable, ToF32};

use crate::history::HistoricalStateBuffer;

/// An accumulated render error: a mean, a worst case and how many samples
/// stand behind them.
///
/// Holds the sum rather than the mean so results from several clients or
/// several frames can be folded together without weighting a client with two
/// visible entities the same as one with two hundred.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RenderError {
  sum: f64,
  worst: f32,
  samples: u32,
}

impl RenderError {
  pub fn new() -> Self {
    Self::default()
  }

  /// Folds in one measured distance.
  pub fn observe(&mut self, error: f32) {
    if !error.is_finite() {
      return;
    }
    self.sum += error as f64;
    self.worst = self.worst.max(error);
    self.samples += 1;
  }

  /// Folds in another accumulation, sum and worst together.
  pub fn merge(&mut self, other: &RenderError) {
    self.sum += other.sum;
    self.worst = self.worst.max(other.worst);
    self.samples += other.samples;
  }

  /// The mean, or zero when nothing was compared.
  ///
  /// Zero rather than `None` because this is a readout, and a panel that has to
  /// unwrap an accuracy figure grows a branch at every call site. Use
  /// [`samples`](Self::samples) to tell "perfect" from "nothing to say".
  pub fn mean(&self) -> f32 {
    if self.samples == 0 { 0.0 } else { (self.sum / self.samples as f64) as f32 }
  }

  /// The worst single error, which is what a player actually notices.
  pub fn worst(&self) -> f32 {
    self.worst
  }

  pub fn samples(&self) -> u32 {
    self.samples
  }

  pub fn is_empty(&self) -> bool {
    self.samples == 0
  }
}

/// Compares what a client drew against where the server had things **at the
/// instant that client was drawing**.
///
/// `at` is the client's render target rather than the present. It is required
/// so the plain call measures at the render instant; passing `now` is possible
/// but has to be typed on purpose.
///
/// `distance` is supplied by the caller because a state type has no metric this
/// crate can assume, the same reason [`Correction`] hands back two states
/// rather than a scalar.
///
/// Entities absent from the history are skipped rather than counted as zero
/// error: a client drawing something the server never recorded is a different
/// fault and folding it in here would flatter the average. Entities whose
/// samples have aged past the buffer's retention are skipped for the same
/// reason: scoring against the oldest retained state charges the client for a
/// position the server no longer knows.
///
/// [`Correction`]: plaza_client_utils::Correction
pub fn render_error_at<Id, State, Time, D>(
  history: &HistoricalStateBuffer<Id, State, Time>,
  at: Time,
  drawn: impl IntoIterator<Item = (Id, State)>,
  distance: D,
) -> RenderError
where
  Id: Eq + Hash + Clone + Debug,
  State: Clone + Debug + Interpolatable<Time>,
  Time: Copy + Debug + Default + PartialOrd + Ord + Sub<Output = Time> + ToF32,
  D: Fn(&State, &State) -> f32,
{
  let mut out = RenderError::new();
  for (id, drawn_state) in drawn {
    let Some(truth) = history.state_within(&id, at) else { continue };
    out.observe(distance(&drawn_state, &truth));
  }
  out
}

#[cfg(test)]
mod tests {
  use super::*;

  #[derive(Clone, Copy, Debug, PartialEq)]
  struct P(f32);

  impl Interpolatable<u64> for P {
    fn interpolate(&self, other: &Self, t: f32, _a: u64, _b: u64) -> Self {
      P(self.0 + (other.0 - self.0) * t)
    }
  }

  fn distance(a: &P, b: &P) -> f32 {
    (a.0 - b.0).abs()
  }

  /// One entity moving at a constant one unit per millisecond, recorded every
  /// 16 ms, so truth at any instant is exactly that instant's value.
  fn moving() -> HistoricalStateBuffer<u8, P, u64> {
    let mut history = HistoricalStateBuffer::new(64);
    for tick in 0..40u64 {
      let t = tick * 16;
      history.record_state(1, t, P(t as f32));
    }
    history
  }

  #[test]
  fn a_client_drawing_the_past_correctly_has_no_error_at_its_own_instant() {
    // A client 100 ms behind drawing exactly the right thing for where it is,
    // which measuring at the render instant reports as correct.
    let history = moving();
    let now = 39 * 16;
    let at = now - 100;
    let error = render_error_at(&history, at, [(1u8, P(at as f32))], distance);
    assert_eq!(error.samples(), 1);
    assert!(error.mean() < 0.01, "{}", error.mean());
  }

  #[test]
  fn the_same_client_measured_against_the_present_is_charged_for_its_delay() {
    // The comparison against the present, reproduced so the difference is
    // pinned by a test.
    let history = moving();
    let now = 39 * 16;
    let at = now - 100;
    let against_now = render_error_at(&history, now, [(1u8, P(at as f32))], distance);
    assert!(
      (against_now.mean() - 100.0).abs() < 1.0,
      "the naive comparison should cost exactly the delay, got {}",
      against_now.mean()
    );
  }

  #[test]
  fn a_real_error_still_shows_up_at_the_render_instant() {
    // A metric that reports zero for everything would pass the tests above, so
    // a real error has to show up.
    let history = moving();
    let at = 20 * 16;
    let error = render_error_at(&history, at, [(1u8, P(at as f32 + 12.0))], distance);
    assert!((error.mean() - 12.0).abs() < 0.01, "{}", error.mean());
  }

  #[test]
  fn an_entity_the_server_never_recorded_is_skipped_rather_than_scored() {
    let history = moving();
    let error = render_error_at(&history, 100, [(9u8, P(0.0))], distance);
    assert!(error.is_empty(), "an unknown entity was folded into the average");
    assert_eq!(error.mean(), 0.0);
  }

  #[test]
  fn an_entity_whose_samples_aged_out_is_skipped_rather_than_scored() {
    // Retention of 8 keeps ticks 32..40, so an instant at tick 20 is one the
    // server no longer knows. Scoring it against the oldest retained state
    // would charge this client ~192 units for drawing exactly the right thing.
    let mut history = HistoricalStateBuffer::new(8);
    for tick in 0..40u64 {
      let t = tick * 16;
      history.record_state(1, t, P(t as f32));
    }
    let at = 20 * 16;
    let error = render_error_at(&history, at, [(1u8, P(at as f32))], distance);
    assert!(error.is_empty(), "a clamped guess was folded into the average");
  }

  #[test]
  fn merging_weights_by_samples_rather_than_by_client() {
    // A client holding two hundred visible entities and one holding two are not
    // equal evidence, which is why this keeps the sum.
    let mut few = RenderError::new();
    few.observe(10.0);
    let mut many = RenderError::new();
    for _ in 0..99 {
      many.observe(0.0);
    }
    let mut both = few;
    both.merge(&many);
    assert_eq!(both.samples(), 100);
    assert!((both.mean() - 0.1).abs() < 1e-5, "{}", both.mean());
    assert_eq!(both.worst(), 10.0, "and the worst case survives the averaging");
  }

  #[test]
  fn a_non_finite_distance_is_ignored_rather_than_poisoning_the_mean() {
    let mut error = RenderError::new();
    error.observe(5.0);
    error.observe(f32::NAN);
    error.observe(f32::INFINITY);
    assert_eq!(error.samples(), 1);
    assert_eq!(error.mean(), 5.0);
  }
}