benjivers/val Public Public: anyone can read this; the crew can change it.
Files 1c30034c602bf5aa8777f1246b20f6bc05dd22e0
| 1 | /** |
| 2 | * Everybody else, drawn between the things the server said about them. |
| 3 | * |
| 4 | * A remote body is only ever heard about a few dozen times a second, so it |
| 5 | * has to be drawn somewhere in between. The old way was an exponential chase |
| 6 | * — `now.x += (x - now.x) * 0.35` every frame — which is wrong twice: it is a |
| 7 | * different speed at 60Hz than at 144Hz, and it is a pursuit that never |
| 8 | * arrives, so every other player was permanently behind and rubbery. |
| 9 | * |
| 10 | * This is entity interpolation instead. Each body keeps the last few samples |
| 11 | * with the times they landed, and the frame is drawn at `now - delay`, about |
| 12 | * a snapshot interval in the past. That moment falls between two samples on |
| 13 | * hand, so the body moves at exactly the speed the server moved it and |
| 14 | * arrives exactly where the server put it — one interval late, which nobody |
| 15 | * can see. A handful of samples rather than strictly two is what buys the |
| 16 | * delay a margin: a snapshot that arrives late or early still brackets the |
| 17 | * moment being drawn instead of stalling the body. |
| 18 | * |
| 19 | * When a snapshot really is missed the newest pair's own velocity is carried |
| 20 | * forward for one interval and then held: a guess that long is better than a |
| 21 | * stall, and a guess longer than that is fiction. The delay follows the rate |
| 22 | * snapshots actually arrive at, so the same code is right at 15Hz and at |
| 23 | * 30Hz without being told which it is. |
| 24 | * |
| 25 | * Nothing here is ever sent anywhere: it is a drawing, and the server is |
| 26 | * still the only thing that says where anybody is. |
| 27 | */ |
| 28 | |
| 29 | /** The render delay, as a multiple of the interval snapshots arrive at. */ |
| 30 | export const LAG = 1.3; |
| 31 | /** ...never tighter than this, never looser, in seconds. */ |
| 32 | export const LAG_MIN = 0.045; |
| 33 | export const LAG_MAX = 0.13; |
| 34 | /** How many samples a body keeps: enough to bracket the delay, and no more. */ |
| 35 | export const KEEP = 4; |
| 36 | /** How fast the measured interval follows the arrivals. */ |
| 37 | const INTERVAL_PULL = 0.1; |
| 38 | /** How fast the even clock snapshots are filed under is pulled toward the arrivals. */ |
| 39 | const STAMP_PULL = 0.1; |
| 40 | /** How much of the even clock's lateness is read as the interval being wrong. */ |
| 41 | const INTERVAL_TRIM = 0.02; |
| 42 | /** How far the moment drawn may run fast or slow to reach its delay: a share of real time. */ |
| 43 | export const SLEW = 0.02; |
| 44 | /** A render clock this far off its mark is not late, it is stopped: start it again. */ |
| 45 | const RESYNC = 0.25; |
| 46 | /** A gap this long means the body was gone, not slow: start it again. */ |
| 47 | export const GONE = 0.4; |
| 48 | /** Faster than anything can run: a respawn, or a body back from nowhere. */ |
| 49 | const TOP_SPEED = 400; |
| 50 | |
| 51 | const clamp = (n, low, high) => (n < low ? low : n > high ? high : n); |
| 52 | |
| 53 | /** The short way round from one angle to another, in (-π, π]. */ |
| 54 | export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; |
| 55 | |
| 56 | /** |
| 57 | * One timeline for the whole snapshot stream: the bodies, the rate, the even |
| 58 | * clock (`stamp`) and the moment being drawn (`drawAt`). `restarts` counts |
| 59 | * the times a body was started again rather than walked: every one is a jump. |
| 60 | */ |
| 61 | export const makeTimeline = (interval = 1 / 30) => ({ |
| 62 | bodies: new Map(), |
| 63 | interval, |
| 64 | at: 0, |
| 65 | stamp: 0, |
| 66 | drawAt: 0, |
| 67 | drawnNow: 0, |
| 68 | restarts: 0, |
| 69 | }); |
| 70 | |
| 71 | /** |
| 72 | * A snapshot landed at `at` seconds. The gap since the last one is what the |
| 73 | * render delay is built out of; a gap wilder than any real rate (a tab that |
| 74 | * was in the background, a reconnect) says nothing about the rate and is |
| 75 | * left out of it. |
| 76 | * |
| 77 | * Returns the time the snapshot is filed under, which is not the time it |
| 78 | * landed. The server sends thirty a second, evenly; the network hands them |
| 79 | * over in a rhythm of its own, a few milliseconds early or late, sometimes |
| 80 | * two nearly together. A sample filed under its arrival time carries that |
| 81 | * wobble into the walk between samples — a body drawn between a sample that |
| 82 | * came early and one that came late moves slow, then fast — and into the |
| 83 | * age of your own snapshot, which is how far ahead your own keys are |
| 84 | * carried. So arrivals are filed on an even clock instead: one interval |
| 85 | * after the last, pulled a tenth of the way toward when they really came so |
| 86 | * it never drifts. A rate is not a rhythm; this is the rhythm. |
| 87 | */ |
| 88 | export function arrive(timeline, at) { |
| 89 | const gap = at - timeline.at; |
| 90 | const steady = timeline.at > 0 && gap > 1 / 120 && gap < 0.5; |
| 91 | if (steady) timeline.interval += (gap - timeline.interval) * INTERVAL_PULL; |
| 92 | const expected = timeline.stamp + timeline.interval; |
| 93 | const off = at - expected; |
| 94 | // Within an interval of the beat, keep the beat; further out (a pause, a |
| 95 | // reconnect, the rate itself changing) the arrival is the only clock there is. |
| 96 | let stamp = at; |
| 97 | if (timeline.at > 0 && gap < 0.5 && Math.abs(off) < timeline.interval) { |
| 98 | stamp = expected + off * STAMP_PULL; |
| 99 | // Arrivals that keep landing on the same side of the beat mean the beat |
| 100 | // is wrong, not late: trim the interval by a little of the lateness. |
| 101 | timeline.interval += off * INTERVAL_TRIM; |
| 102 | } |
| 103 | // A sample is never filed before the one before it, and never more than |
| 104 | // half a beat before it actually came: a burst of snapshots (a tab coming |
| 105 | // back from the background) must not walk the clock off into the future. |
| 106 | if (stamp <= timeline.stamp) stamp = timeline.stamp + timeline.interval / 4; |
| 107 | stamp = Math.max(Math.min(stamp, at + timeline.interval / 2), timeline.stamp); |
| 108 | timeline.at = at; |
| 109 | timeline.stamp = stamp; |
| 110 | return stamp; |
| 111 | } |
| 112 | |
| 113 | /** How far behind the present this timeline is drawn, in seconds. */ |
| 114 | export const delay = (timeline) => clamp(timeline.interval * LAG, LAG_MIN, LAG_MAX); |
| 115 | |
| 116 | /** |
| 117 | * The moment being drawn, given the clock now. |
| 118 | * |
| 119 | * Not simply `now - delay`: the delay follows the measured rate, and a delay |
| 120 | * that moves between frames moves the moment being drawn with it, so a |
| 121 | * frame after a late snapshot drew twelve milliseconds of motion and the |
| 122 | * next nineteen — the beat of the wire showing through as a stutter. The |
| 123 | * moment drawn now advances at real time, and creeps toward where the delay |
| 124 | * says it should be at no more than two percent of that: a speed nobody can |
| 125 | * see. Only a clock a quarter second off — a tab that was in the background, |
| 126 | * the first frame — is started again where it belongs. |
| 127 | */ |
| 128 | export function renderTime(timeline, now) { |
| 129 | const want = now - delay(timeline); |
| 130 | const dt = now - timeline.drawnNow; |
| 131 | timeline.drawnNow = now; |
| 132 | const ahead = timeline.drawAt + dt; |
| 133 | if (timeline.drawAt === 0 || dt <= 0 || dt > RESYNC || Math.abs(want - ahead) > RESYNC) { |
| 134 | timeline.drawAt = want; |
| 135 | return want; |
| 136 | } |
| 137 | timeline.drawAt = ahead + clamp(want - ahead, -dt * SLEW, dt * SLEW); |
| 138 | return timeline.drawAt; |
| 139 | } |
| 140 | |
| 141 | const mark = (at, body) => ({ t: at, x: body.x, y: body.y, angle: body.angle }); |
| 142 | |
| 143 | /** |
| 144 | * What the server just said about one body. Out-of-order samples are |
| 145 | * dropped; a body that has been away, or that has moved further than anyone |
| 146 | * can run, starts fresh rather than sliding across the map to its new place. |
| 147 | * |
| 148 | * The drawn position lives on the track — one object per body, so a caller |
| 149 | * may hold the answer it was given while asking about somebody else. |
| 150 | */ |
| 151 | export function feed(timeline, id, body, at) { |
| 152 | const track = timeline.bodies.get(id); |
| 153 | if (!track) { |
| 154 | timeline.bodies.set(id, { marks: [mark(at, body)], out: { x: 0, y: 0, angle: 0 } }); |
| 155 | return timeline; |
| 156 | } |
| 157 | const last = track.marks[track.marks.length - 1]; |
| 158 | if (at <= last.t) return timeline; |
| 159 | const gap = at - last.t; |
| 160 | // Two samples filed close together are still a tick apart on the server, |
| 161 | // so a body is allowed at least an interval's run between them. |
| 162 | const jumped = Math.hypot(body.x - last.x, body.y - last.y) > TOP_SPEED * Math.max(gap, timeline.interval) + 4; |
| 163 | if (gap > GONE || jumped) { |
| 164 | track.marks.length = 0; |
| 165 | timeline.restarts += 1; |
| 166 | } |
| 167 | track.marks.push(mark(at, body)); |
| 168 | if (track.marks.length > KEEP) track.marks.shift(); |
| 169 | return timeline; |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Where a body was at a moment, given the samples it has — ascending in |
| 174 | * time, and at least one. |
| 175 | * |
| 176 | * Between two samples this is a straight walk from one to the other at the |
| 177 | * server's own speed. Before the oldest it holds, because running backwards |
| 178 | * to meet a moment we have no answer for would be inventing one. After the |
| 179 | * newest it carries the last pair's velocity for one interval — no more, |
| 180 | * ever — and then holds there. |
| 181 | */ |
| 182 | export function sample(marks, when, out = { x: 0, y: 0, angle: 0 }) { |
| 183 | const last = marks[marks.length - 1]; |
| 184 | if (marks.length === 1 || when >= last.t) return beyond(marks, when, out); |
| 185 | if (when <= marks[0].t) return at(marks[0], marks[0], 0, out); |
| 186 | let i = marks.length - 2; |
| 187 | while (i > 0 && marks[i].t > when) i -= 1; |
| 188 | const from = marks[i]; |
| 189 | const to = marks[i + 1]; |
| 190 | const span = to.t - from.t; |
| 191 | return at(from, to, span > 0 ? (when - from.t) / span : 1, out); |
| 192 | } |
| 193 | |
| 194 | /** Past the newest sample: one interval of the last pair's velocity, held. */ |
| 195 | function beyond(marks, when, out) { |
| 196 | const last = marks[marks.length - 1]; |
| 197 | const before = marks[marks.length - 2]; |
| 198 | if (!before) return at(last, last, 0, out); |
| 199 | const span = last.t - before.t; |
| 200 | if (span <= 0) return at(last, last, 0, out); |
| 201 | return at(before, last, clamp((when - before.t) / span, 1, 2), out); |
| 202 | } |
| 203 | |
| 204 | /** A point `u` of the way from one sample to another; the turn is short. */ |
| 205 | function at(from, to, u, out) { |
| 206 | out.x = from.x + (to.x - from.x) * u; |
| 207 | out.y = from.y + (to.y - from.y) * u; |
| 208 | out.angle = from.angle + shortWay(from.angle, to.angle) * u; |
| 209 | return out; |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * Where to draw a body out of a snapshot. One with no samples yet — the |
| 214 | * first frame it is seen on — is drawn where the server says, which is the |
| 215 | * only honest answer available. |
| 216 | */ |
| 217 | export function bodyAt(timeline, body, when, out = null) { |
| 218 | const track = timeline.bodies.get(body.id); |
| 219 | if (!track) return body; |
| 220 | return sample(track.marks, when, out ?? track.out); |
| 221 | } |