Add client-side jitter buffer + steady render clock for smooth playback
Browse files- index.html +90 -39
index.html
CHANGED
|
@@ -352,47 +352,99 @@ async function initClient(){
|
|
| 352 |
if(!state.client) state.client = await Client.connect(window.location.origin);
|
| 353 |
}
|
| 354 |
|
| 355 |
-
// --- Frame decode +
|
| 356 |
-
//
|
| 357 |
-
//
|
| 358 |
-
//
|
| 359 |
-
//
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
try{
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
}
|
| 380 |
-
if(painted){
|
| 381 |
-
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
|
| 382 |
-
frameValue.textContent = blk;
|
| 383 |
-
fpsValue.textContent = fps.toFixed(1);
|
| 384 |
-
}
|
| 385 |
-
}
|
| 386 |
-
}finally{
|
| 387 |
-
decoding = false;
|
| 388 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
}
|
| 390 |
|
| 391 |
-
function paintViaImage(bytes){
|
| 392 |
return new Promise((resolve)=>{
|
| 393 |
const url = URL.createObjectURL(new Blob([bytes], {type:"image/jpeg"}));
|
| 394 |
const im = new Image();
|
| 395 |
-
im.onload = ()=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
im.onerror = ()=>{ URL.revokeObjectURL(url); resolve(false); };
|
| 397 |
im.src = url;
|
| 398 |
});
|
|
@@ -408,10 +460,9 @@ function connectWS(){
|
|
| 408 |
const dv = new DataView(e.data);
|
| 409 |
const blk = dv.getUint32(0);
|
| 410 |
const fps = dv.getUint32(4)/10;
|
| 411 |
-
//
|
| 412 |
-
//
|
| 413 |
-
|
| 414 |
-
drainFrames();
|
| 415 |
}else{
|
| 416 |
try{
|
| 417 |
const msg = JSON.parse(e.data);
|
|
@@ -498,7 +549,7 @@ async function startWorld(){
|
|
| 498 |
async function stopWorld(){
|
| 499 |
try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{}
|
| 500 |
if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; }
|
| 501 |
-
|
| 502 |
stopCtrlLoop();
|
| 503 |
setPlaying(false);
|
| 504 |
state.pressed.clear(); renderKeys();
|
|
|
|
| 352 |
if(!state.client) state.client = await Client.connect(window.location.origin);
|
| 353 |
}
|
| 354 |
|
| 355 |
+
// --- Frame decode + jitter buffer + steady render clock ---
|
| 356 |
+
// Rendering is DECOUPLED from network arrival. WebSocket messages only decode
|
| 357 |
+
// frames into a small jitter buffer; a steady render clock (requestAnimationFrame)
|
| 358 |
+
// then pulls the freshest decoded frame at a smoothed target interval and paints
|
| 359 |
+
// it. This absorbs network jitter and the server's block-boundary bursts so the
|
| 360 |
+
// visible cadence stays even instead of "burst of N frames, then a gap".
|
| 361 |
+
//
|
| 362 |
+
// The buffer is intentionally shallow (JITTER_MAX): if frames arrive faster than
|
| 363 |
+
// we render, older ones are dropped (coalesced) rather than queued, so we never
|
| 364 |
+
// accumulate buffering delay that would later flush as a burst of stale frames.
|
| 365 |
+
const JITTER_MAX = 3; // max decoded frames held before we drop-oldest
|
| 366 |
+
const JITTER_MIN = 1; // frames to buffer before the render clock starts
|
| 367 |
+
let jitterBuf = []; // [{bitmap, blk, fps}] decoded, awaiting display
|
| 368 |
+
let renderPrimed = false; // becomes true once JITTER_MIN frames buffered
|
| 369 |
+
let renderInterval = 1000 / 16; // ms between paints; adapted to server fps (EMA)
|
| 370 |
+
let lastPaint = 0; // performance.now() of last painted frame
|
| 371 |
+
let lastArrival = 0; // performance.now() of last WS frame (for interval est.)
|
| 372 |
+
let arrivalEma = 0; // EMA of inter-arrival gap (ms)
|
| 373 |
+
|
| 374 |
+
async function ingestFrame(bytes, blk, fps){
|
| 375 |
+
// Estimate the true incoming cadence from arrival timing and adapt the render
|
| 376 |
+
// interval toward it (bounded), so the steady clock matches the real fps.
|
| 377 |
+
const now = performance.now();
|
| 378 |
+
if(lastArrival){
|
| 379 |
+
const gap = now - lastArrival;
|
| 380 |
+
arrivalEma = arrivalEma ? (0.2 * gap + 0.8 * arrivalEma) : gap;
|
| 381 |
+
const tgt = Math.min(200, Math.max(20, arrivalEma)); // clamp 5–50 fps
|
| 382 |
+
renderInterval = 0.2 * tgt + 0.8 * renderInterval;
|
| 383 |
+
}
|
| 384 |
+
lastArrival = now;
|
| 385 |
+
|
| 386 |
+
let bitmap = null;
|
| 387 |
try{
|
| 388 |
+
bitmap = await createImageBitmap(new Blob([bytes], {type:"image/jpeg"}));
|
| 389 |
+
}catch(err){
|
| 390 |
+
// Fallback path for browsers without createImageBitmap JPEG support:
|
| 391 |
+
// paint straight to canvas (bypasses buffer but keeps the stream alive).
|
| 392 |
+
await paintViaImage(bytes, blk, fps);
|
| 393 |
+
return;
|
| 394 |
+
}
|
| 395 |
+
jitterBuf.push({ bitmap, blk, fps });
|
| 396 |
+
// Drop-oldest to keep the buffer shallow (avoid stale-frame burst on flush).
|
| 397 |
+
while(jitterBuf.length > JITTER_MAX){
|
| 398 |
+
const stale = jitterBuf.shift();
|
| 399 |
+
if(stale.bitmap && stale.bitmap.close) stale.bitmap.close();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
}
|
| 401 |
+
if(!renderPrimed && jitterBuf.length >= JITTER_MIN){
|
| 402 |
+
renderPrimed = true;
|
| 403 |
+
lastPaint = performance.now() - renderInterval; // paint first frame promptly
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
function paintBitmap(entry){
|
| 408 |
+
viewCtx.drawImage(entry.bitmap, 0, 0, view.width, view.height);
|
| 409 |
+
if(entry.bitmap && entry.bitmap.close) entry.bitmap.close();
|
| 410 |
+
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
|
| 411 |
+
frameValue.textContent = entry.blk;
|
| 412 |
+
fpsValue.textContent = entry.fps.toFixed(1);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
// Steady render clock: runs continuously via rAF while playing; pulls at most one
|
| 416 |
+
// buffered frame per render-interval so display timing is even, not arrival-tied.
|
| 417 |
+
function renderClock(){
|
| 418 |
+
requestAnimationFrame(renderClock);
|
| 419 |
+
if(!renderPrimed || jitterBuf.length === 0) return;
|
| 420 |
+
const now = performance.now();
|
| 421 |
+
if(now - lastPaint < renderInterval) return; // not yet this frame's slot
|
| 422 |
+
const entry = jitterBuf.shift(); // freshest frames kept; oldest painted in order
|
| 423 |
+
lastPaint = now;
|
| 424 |
+
paintBitmap(entry);
|
| 425 |
+
}
|
| 426 |
+
requestAnimationFrame(renderClock);
|
| 427 |
+
|
| 428 |
+
function resetJitterBuffer(){
|
| 429 |
+
for(const e of jitterBuf){ if(e.bitmap && e.bitmap.close) e.bitmap.close(); }
|
| 430 |
+
jitterBuf = [];
|
| 431 |
+
renderPrimed = false;
|
| 432 |
+
lastArrival = 0;
|
| 433 |
+
arrivalEma = 0;
|
| 434 |
}
|
| 435 |
|
| 436 |
+
function paintViaImage(bytes, blk, fps){
|
| 437 |
return new Promise((resolve)=>{
|
| 438 |
const url = URL.createObjectURL(new Blob([bytes], {type:"image/jpeg"}));
|
| 439 |
const im = new Image();
|
| 440 |
+
im.onload = ()=>{
|
| 441 |
+
viewCtx.drawImage(im, 0, 0, view.width, view.height);
|
| 442 |
+
URL.revokeObjectURL(url);
|
| 443 |
+
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
|
| 444 |
+
if(typeof blk !== "undefined") frameValue.textContent = blk;
|
| 445 |
+
if(typeof fps !== "undefined") fpsValue.textContent = fps.toFixed(1);
|
| 446 |
+
resolve(true);
|
| 447 |
+
};
|
| 448 |
im.onerror = ()=>{ URL.revokeObjectURL(url); resolve(false); };
|
| 449 |
im.src = url;
|
| 450 |
});
|
|
|
|
| 460 |
const dv = new DataView(e.data);
|
| 461 |
const blk = dv.getUint32(0);
|
| 462 |
const fps = dv.getUint32(4)/10;
|
| 463 |
+
// Decode into the jitter buffer; the steady render clock paints on its own
|
| 464 |
+
// cadence, so display timing is decoupled from network arrival jitter.
|
| 465 |
+
ingestFrame(e.data.slice(8), blk, fps);
|
|
|
|
| 466 |
}else{
|
| 467 |
try{
|
| 468 |
const msg = JSON.parse(e.data);
|
|
|
|
| 549 |
async function stopWorld(){
|
| 550 |
try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{}
|
| 551 |
if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; }
|
| 552 |
+
resetJitterBuffer();
|
| 553 |
stopCtrlLoop();
|
| 554 |
setPlaying(false);
|
| 555 |
state.pressed.clear(); renderKeys();
|