Salta al contenuto principale

09 - Cloud Gaming: Streaming con WebRTC ed Edge Node

Il cloud gaming ha un budget di latenza e la fibra ne fissa un pezzo: calcola la distanza massima del server e configura WebRTC per gli input.

Cosa ne pensi di questo articolo?

09 - Cloud Gaming: Streaming con WebRTC ed Edge Node
Game BackendFoto:Brett SaylesPexels

In Breve

Il cloud gaming ha un budget di latenza e la fibra ne fissa un pezzo: calcola la distanza massima del server e configura WebRTC per gli input.

Cloud gaming: il ciclo input-immagine ha un budget, e la fisica ne fissa un pezzo

In un gioco giocato dal cloud, il giocatore preme un tasto, il comando arriva a un server, il server fa girare il gioco, disegna un fotogramma, lo comprime e lo rimanda; il dispositivo lo decomprime e lo mostra. Tutto questo deve succedere abbastanza in fretta da non accorgersene. Non esiste una soglia universale in millisecondi: ne scegli una per il tuo tipo di gioco (uno sparatutto ne tollera meno di un gioco di strategia) e la tratti come un budget da ripartire fra gli stadi del ciclo. Un pezzo di quel budget non lo scegli tu: la velocità di propagazione nella fibra mette un tetto alla distanza fra giocatore e server.

L'articolo parte da quel tetto, con un calcolo che puoi eseguire, e poi segue il ciclo: WebRTC come trasporto, il canale degli input e il suo numero di sequenza, la codifica video sul server, la scelta del nodo edge, il controllo della qualità e la condivisione della GPU. I listati sono TypeScript eseguito con Node 24 su dati sintetici con seme fisso; il frammento per il browser è stato solo compilato. Non ho un server di gioco né una GPU qui, quindi nessun numero di questo articolo è la misura di un servizio reale.

Il percorso dei pacchetti verso un server di gioco dedicato è nell'articolo su game server dedicati e orchestrazione; la sincronizzazione dello stato lato client è in sincronizzazione dello stato e rollback.

Il budget di latenza e il limite della fibra

Il ciclo ha otto stadi: lettura dell'input, rete verso il server, logica di gioco, rendering, codifica, rete di ritorno, decodifica e visualizzazione. Sei li governi con il codice e la scelta dell'hardware. Due, i tratti di rete, hanno un minimo fisico: la luce in fibra procede a circa c diviso per l'indice di rifrazione, che nel vetro vale tipicamente 1,47 (valore generico, non di un cavo specifico). Il listato calcola il minimo teorico di RTT fra due punti con la distanza in linea d'aria, e poi inverte il problema: dato il tuo bersaglio e gli stadi locali che hai misurato, quanta distanza ti resta?

Minimo teorico dell'RTT e distanza massima del server
interface Place { name: string; lat: number; lon: number; }
const toRad = (d: number) => (d * Math.PI) / 180;
function haversineKm(a: Place, b: Place): number {
  const dLat = toRad(b.lat - a.lat), dLon = toRad(b.lon - a.lon);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
  return 2 * 6371 * Math.asin(Math.sqrt(h));
}
// Limite fisico: la luce in fibra viaggia a c/n. n = 1,47 è un valore tipico del vetro, non di un cavo specifico.
const C_KM_S = 299_792.458, N_FIBER = 1.47;
const minRttMs = (km: number) => (2 * km) / (C_KM_S / N_FIBER) * 1000;
const maxKmForRtt = (rttMs: number) => ((rttMs / 1000) / 2) * (C_KM_S / N_FIBER);

const milano: Place = { name: 'Milano', lat: 45.4642, lon: 9.19 };
for (const dc of [
  { name: 'Francoforte', lat: 50.1109, lon: 8.6821 },
  { name: 'Ashburn (Virginia)', lat: 39.0438, lon: -77.4874 },
]) {
  const km = haversineKm(milano, dc);
  console.log(`Milano - ${dc.name}: ${km.toFixed(0)} km in linea d'aria, RTT minimo teorico ${minRttMs(km).toFixed(1)} ms`);
}

// Quanto resta alla rete? Gli stadi sono valori che MISURI sul tuo sistema (qui: ipotesi di lavoro).
const target = 100;
const stages: Record<string, number> = { input: 4, gioco: 8, render: 17, codifica: 8, decodifica: 6, schermo: 10 };
const used = Object.values(stages).reduce((a, b) => a + b, 0);
const rttBudget = target - used;
console.log(`bersaglio ${target} ms, stadi locali ${used} ms, resta alla rete (RTT) ${rttBudget} ms`);
console.log(`con quel RTT, anche con un percorso ideale e lineare, il server dista al massimo ${maxKmForRtt(rttBudget).toFixed(0)} km`);
console.log(`al 60 fps un fotogramma dura ${(1000 / 60).toFixed(1)} ms: il rendering da solo ne consuma uno`);

// Output:
// Milano - Francoforte: 518 km in linea d'aria, RTT minimo teorico 5.1 ms
// Milano - Ashburn (Virginia): 6814 km in linea d'aria, RTT minimo teorico 66.8 ms
// bersaglio 100 ms, stadi locali 53 ms, resta alla rete (RTT) 47 ms
// con quel RTT, anche con un percorso ideale e lineare, il server dista al massimo 4793 km
// al 60 fps un fotogramma dura 16.7 ms: il rendering da solo ne consuma uno

Da Milano a Francoforte l'RTT minimo teorico è 5,1 ms su 518 km; da Milano alla Virginia è 66,8 ms su 6.814 km. Un percorso reale è più lungo della linea d'aria, passa da apparati che aggiungono tempo, e quindi l'RTT vero sarà maggiore. Gli stadi locali di stages sono ipotesi mie, da sostituire con i tuoi valori misurati: con 53 ms usati su un bersaglio di 100 restano 47 ms per la rete, e anche col percorso ideale il server non può stare oltre circa 4.800 km. Con il bersaglio e gli stadi di questo esempio un server in Virginia, per un giocatore a Milano, è fuori budget per la sola fisica, a prescindere da quanto sia buono il codice. Al 60 fps, infine, un fotogramma dura 16,7 ms: il rendering da solo ne consuma uno.

  • Misurabili con il codice: lettura dell'input, logica, rendering, codifica, decodifica
  • Dipendenti dall'hardware del giocatore: decodifica e tempo di visualizzazione dello schermo
  • Dipendenti dalla posizione: i due tratti di rete, con il minimo fisico del listato

La ripartizione serve a decidere dove investire. Una codifica più veloce di 3 ms libera 3 ms di RTT, che con l'indice usato sopra valgono circa 300 km in più di distanza dal server, e può essere più economica da ottenere di un nuovo punto di presenza. Questo, però, vale se il tratto di rete è già sotto il budget: se non lo è, nessuna ottimizzazione locale lo recupera.

WebRTC come trasporto

WebRTC porta, con lo stesso stack, video a bassa latenza dal server al giocatore e un canale di dati nella direzione opposta. Include la negoziazione dei percorsi (ICE, con server STUN e TURN per attraversare i NAT), e cifra il traffico. Per il video scegli i codec e deleghi il controllo della congestione al browser; per gli input apri un data channel. La scelta delicata è l'affidabilità di quel canale.

La specifica W3C definisce le opzioni ordered (vero per impostazione predefinita) e maxRetransmits per il data channel; l'RFC 8831 spiega che sotto c'è SCTP con consegna non ordinata e affidabilità parziale. Per un comando di movimento, ritrasmettere un pacchetto perso consegna un input vecchio quando ne sono già arrivati di nuovi, quindi ordered: false e maxRetransmits: 0. Il frammento che segue è lo schema del lato browser: l'ho compilato con il compilatore TypeScript e la libreria DOM, ma non l'ho eseguito perché servono un browser e un server di segnalazione.

Lato browser: sessione WebRTC e canale degli input, schema da adattare
// Schema da adattare: richiede un browser e un server di segnalazione. Controllato solo con il compilatore (lib DOM).
interface IceConfig { stunUrl: string; turnUrl: string; username: string; credential: string; }   // le credenziali TURN arrivano dal tuo server, a scadenza breve

function createSession(ice: IceConfig, video: HTMLVideoElement): { pc: RTCPeerConnection; input: RTCDataChannel } {
  const pc = new RTCPeerConnection({
    iceServers: [{ urls: ice.stunUrl }, { urls: ice.turnUrl, username: ice.username, credential: ice.credential }],
    bundlePolicy: 'max-bundle',
  });
  // Input: non ordinato e senza ritrasmissioni. Un comando vecchio ritrasmesso è peggio di un comando perso.
  const input = pc.createDataChannel('input', { ordered: false, maxRetransmits: 0 });
  pc.addTransceiver('video', { direction: 'recvonly' });
  pc.ontrack = (ev) => { video.srcObject = new MediaStream([ev.track]); };
  return { pc, input };
}

async function negotiate(pc: RTCPeerConnection, post: (offerSdp: string) => Promise<string>): Promise<void> {
  await pc.setLocalDescription(await pc.createOffer());
  const answer = await post(pc.localDescription!.sdp);
  await pc.setRemoteDescription({ type: 'answer', sdp: answer });
}

Due note su quel codice. Le credenziali TURN non si scrivono nel client: arrivano dal tuo server, a scadenza breve, perché una credenziale statica nel bundle JavaScript è pubblica. E la direzione recvonly dichiara che questo lato riceve il video e non lo invia: la parte di codifica sta sul server.

Canale non ordinato: il numero di sequenza

Se i pacchetti possono arrivare fuori ordine, il server non può fidarsi dell'ordine di arrivo. Ogni input porta un numero di sequenza, e il server applica solo ciò che è più recente dell'ultimo applicato. Il numero è a 16 bit e ricomincia da zero, quindi «più recente» si decide sulla distanza circolare: il listato usa ((a - b) & 0xffff) < 0x8000, il confronto classico per numeri di sequenza che si avvolgono. Il pacchetto è di 11 byte: due per la sequenza, otto per i due movimenti in virgola mobile e uno per la bitmask dei pulsanti.

Input compatti, ordine per sequenza, 2% di perdita
// Pacchetto di input: 2 byte di numero di sequenza + 2 float32 (movimento) + 1 byte di pulsanti = 11 byte
interface Input { seq: number; dx: number; dy: number; buttons: number; }
function encode(i: Input): ArrayBuffer {
  const buf = new ArrayBuffer(11), v = new DataView(buf);
  v.setUint16(0, i.seq & 0xffff); v.setFloat32(2, i.dx); v.setFloat32(6, i.dy); v.setUint8(10, i.buttons);
  return buf;
}
function decode(buf: ArrayBuffer): Input {
  const v = new DataView(buf);
  return { seq: v.getUint16(0), dx: v.getFloat32(2), dy: v.getFloat32(6), buttons: v.getUint8(10) };
}
// Il numero di sequenza è a 16 bit e ricomincia da zero: "a è più recente di b" si decide sulla distanza circolare
const isNewer = (a: number, b: number) => a !== b && ((a - b) & 0xffff) < 0x8000;

// Generatore con seme (mulberry32)
function rng(seed: number) { return () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
const r = rng(5);

// 600 input (10 s a 60 Hz) su un canale non ordinato: ritardo variabile, 2% persi
const sent = Array.from({ length: 600 }, (_, n) => ({ at: n * (1000 / 60), pkt: encode({ seq: 65_300 + n, dx: n, dy: 0, buttons: 0 }) }));
const arrivals = sent.filter(() => r() > 0.02).map(s => ({ at: s.at + 20 + r() * 40, pkt: s.pkt })).sort((a, b) => a.at - b.at);

let lastApplied = -1, backwardsNaive = 0, backwardsSeq = 0, dropped = 0, lastSeq = -1;
for (const a of arrivals) {
  const i = decode(a.pkt);
  // Strategia 1: applico tutto nell'ordine di arrivo. Conto quante volte il comando "torna indietro"
  if (lastApplied !== -1 && i.dx < lastApplied) backwardsNaive++;
  lastApplied = i.dx;
  // Strategia 2: applico solo i pacchetti più recenti dell'ultimo applicato
  if (lastSeq !== -1 && !isNewer(i.seq, lastSeq)) { dropped++; continue; }
  lastSeq = i.seq;
}
console.log(`arrivati ${arrivals.length} pacchetti su 600; la sequenza attraversa lo zero a 65536`);
console.log(`applicati nell'ordine di arrivo: ${backwardsNaive} volte lo stato torna indietro`);
console.log(`con numero di sequenza: ${dropped} pacchetti vecchi scartati, stato mai indietro`);
console.log(`pacchetto: ${encode({ seq: 1, dx: 0, dy: 0, buttons: 0 }).byteLength} byte`);

// Output:
// arrivati 584 pacchetti su 600; la sequenza attraversa lo zero a 65536
// applicati nell'ordine di arrivo: 96 volte lo stato torna indietro
// con numero di sequenza: 99 pacchetti vecchi scartati, stato mai indietro
// pacchetto: 11 byte

La simulazione invia 600 input a 60 Hz con 20-60 ms di ritardo variabile e il 2% di perdita, con la sequenza che parte da 65.300 e quindi attraversa lo zero a metà. Dei 600, ne arrivano 584. Applicati nell'ordine di arrivo, lo stato torna indietro 96 volte: il giocatore vedrebbe il personaggio scattare all'indietro. Con il numero di sequenza il server scarta 99 pacchetti più vecchi dell'ultimo applicato e lo stato non regredisce mai. I due numeri (96 e 99) misurano cose diverse: il primo conta i passi indietro rispetto al pacchetto precedente, il secondo i pacchetti più vecchi del massimo già visto.

La codifica sul server

Il fotogramma disegnato dalla GPU va compresso prima di partire, e la compressione ha un costo in tempo. L'encoder hardware di NVIDIA espone quattro profili di «tuning»: alta qualità, bassa latenza, latenza ultra-bassa e lossless. La guida del Video Codec SDK associa il cloud gaming alla bassa latenza con bitrate costante (CBR), e alla latenza ultra-bassa quando il canale è strettamente limitato. Non ho una GPU in questa macchina, quindi non riporto tempi di codifica: li misuri tu, sul tuo contenuto e sulla tua scheda.

  • Tuning a bassa latenza con CBR: è l'impostazione che la documentazione indica per il cloud gaming
  • Niente fotogrammi B: un fotogramma B dipende da uno successivo, quindi l'encoder deve trattenere l'uscita e la guida richiede buffer in più (1 + NB) per questo motivo
  • Un solo codec negoziato per sessione: H.264 è il più diffuso in decodifica hardware sui dispositivi, AV1 comprime meglio ma richiede dispositivi che lo decodifichino

Le tre voci portano alla stessa regola: ogni funzione che migliora la compressione guardando il futuro costa latenza. Va bene per la trasmissione di un film e non per un sistema in cui il futuro è il prossimo tasto del giocatore. L'ultima voce è un criterio di scelta, non una misura: verifica sui dispositivi che ti interessano quali decodificatori hardware sono presenti.

Edge: scegliere il nodo con la sonda, non con la mappa

Avvicinare il server al giocatore riduce l'unico stadio che dipende dalla geografia. Il modo ingenuo di scegliere un nodo è tradurre l'indirizzo IP in una posizione e prendere quello più vicino in linea d'aria. Funziona nella maggior parte dei casi, perché la distanza fissa il minimo, ma il percorso reale dipende da come sono collegati gli operatori, non dalla geometria. Una scelta più robusta è far misurare al client l'RTT verso due o tre candidati con un breve scambio di prova e scegliere il migliore fra quelli con capacità libera.

La capacità è l'altro criterio: un nodo vicino ma saturo dà un'esperienza peggiore di uno un po' più lontano e libero. Va deciso prima quale soglia di occupazione dichiara un nodo «pieno» e che cosa succede alla sessione successiva (coda, nodo secondario, rifiuto con un messaggio chiaro). Non riporto una simulazione di questa scelta: i numeri dipenderebbero interamente dai fattori di allungamento dei percorsi che dovrei inventare. La scelta giusta si prova sulla tua rete.

Qualità adattiva: l'isteresi evita il tremolio

Il browser applica da sé un controllo di congestione sul video. Il tuo livello applicativo interviene su qualcosa di più grossolano: risoluzione e bitrate massimo (la specifica W3C prevede un maxBitrate nei parametri di codifica di un mittente). Una regola semplice è associare a ogni livello una soglia di RTT oltre la quale non è più adatto. Il difetto è noto: se l'RTT oscilla vicino a una soglia, il sistema cambia livello di continuo, e l'immagine che salta di qualità è più fastidiosa di una qualità stabile e più bassa.

Soglie nude contro isteresi, su una traccia vicino alla soglia
interface Level { name: string; kbps: number; maxRtt: number; }   // un livello è adatto finché l'RTT resta sotto maxRtt
const LEVELS: Level[] = [
  { name: 'alta', kbps: 8000, maxRtt: 40 }, { name: 'media', kbps: 4000, maxRtt: 70 }, { name: 'bassa', kbps: 2000, maxRtt: 120 },
];
const pick = (rtt: number) => LEVELS.findIndex(l => rtt < l.maxRtt) === -1 ? LEVELS.length - 1 : LEVELS.findIndex(l => rtt < l.maxRtt);

// Soglie nude: cambia appena la media attraversa una soglia
function naive(trace: number[]): number {
  let level = 0, changes = 0;
  for (const rtt of trace) { const t = pick(rtt); if (t !== level) { level = t; changes++; } }
  return changes;
}
// Con isteresi: scende subito, ma risale solo dopo 'hold' campioni consecutivi sotto la soglia con un margine del 15%
function hysteresis(trace: number[], hold: number): number {
  let level = 0, changes = 0, good = 0;
  for (const rtt of trace) {
    const t = pick(rtt);
    if (t > level) { level = t; changes++; good = 0; }                        // peggiora: reagisci subito
    else if (t < level && rtt < LEVELS[level - 1].maxRtt * 0.85) {            // migliora con margine
      if (++good >= hold) { level--; changes++; good = 0; }
    } else good = 0;
  }
  return changes;
}

function rng(seed: number) { return () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
const r = rng(3);
// 300 secondi, un campione al secondo: RTT attorno a 40 ms con rumore di ±8 ms (caso "vicino alla soglia")
const trace = Array.from({ length: 300 }, () => 40 + (r() - 0.5) * 16);
console.log('RTT medio della traccia:', (trace.reduce((a, b) => a + b, 0) / trace.length).toFixed(1), 'ms');
console.log('cambi di livello con soglie nude:', naive(trace));
console.log('cambi di livello con isteresi (hold=5):', hysteresis(trace, 5));

// Output:
// RTT medio della traccia: 40.2 ms
// cambi di livello con soglie nude: 145
// cambi di livello con isteresi (hold=5): 1

Su 300 campioni di RTT attorno a 40 ms con rumore di 8 ms, tutto a ridosso della soglia fra «alta» e «media», le soglie nude cambiano livello 145 volte e la regola con isteresi una sola. La regola con isteresi scende subito quando le cose peggiorano e risale solo dopo cinque campioni consecutivi sotto la soglia con un margine del 15%. Il numero dipende dalla traccia sintetica che ho generato, ma il meccanismo è generale: reagire in fretta in discesa e con prudenza in salita. In un sistema reale conviene anche guardare la perdita di pacchetti e il bitrate stimato, e non solo l'RTT, perché un RTT basso non garantisce banda sufficiente. Come raccogliere queste misure senza far esplodere le metriche è nell'articolo sull'osservabilità di un game backend.

Condividere la GPU

La GPU è la risorsa costosa, e una sessione per GPU è la scelta più semplice e più isolata. Per aumentare la densità esistono più strade: la virtualizzazione con vGPU, il partizionamento hardware con MIG, o più processi sulla stessa scheda. La guida NVIDIA di MIG descrive la partizione di GPU dall'architettura Ampere in avanti in fino a sette istanze separate, indicando carichi CUDA; non dice nulla sul rendering di giochi, quindi non la presento come soluzione per il cloud gaming. Le cifre di «sessioni per GPU» e di «overhead» dipendono dal gioco e dalla scheda, e non ne riporto.

Il numero che serve è tuo e si misura: il costo di una sessione è il costo orario della GPU diviso per il numero di sessioni che girano insieme senza sforare il budget di latenza. Ogni sessione in più sulla scheda allunga i tempi di rendering e codifica per tutte, quindi si sale finché lo stadio più lento resta dentro il budget, e ci si ferma lì.

Quando il cloud gaming non è la risposta

Se i giocatori sono distribuiti dove non puoi avere un nodo a pochi millisecondi, o se il gioco richiede reazioni molto rapide, il calcolo del primo listato ti dirà presto che il budget non torna. In quel caso è meglio un client leggero con il gioco in locale e il server per lo stato condiviso.

Domande frequenti

Quanta latenza è accettabile nel cloud gaming?

Dipende dal gioco: non esiste una soglia universale. Si sceglie un bersaglio in millisecondi, lo si ripartisce fra gli stadi del ciclo e si misura; il tratto di rete ha un minimo fisico fissato dalla distanza.

Perché il canale degli input è non ordinato e senza ritrasmissioni?

Perché un comando vecchio ritrasmesso arriva quando ne sono già arrivati di più recenti. Con ordered false e maxRetransmits a zero si preferisce perdere un input; il server usa il numero di sequenza per ignorare i pacchetti superati.

Come si scelgono le impostazioni dell'encoder per il cloud gaming?

La guida NVENC indica per il cloud gaming il tuning a bassa latenza con bitrate costante, e quello a latenza ultra-bassa se il canale è molto limitato. Evita i fotogrammi B, che trattengono l'uscita dell'encoder.

Che cosa sai fare adesso

Sai impostare il budget di latenza del tuo gioco e calcolare quanta distanza il tuo server può permettersi. Sai configurare il canale degli input di WebRTC, e perché serve un numero di sequenza circolare. Sai quali impostazioni dell'encoder la documentazione indica per il cloud gaming e quali scelte costano latenza. Sai evitare il tremolio della qualità adattiva con l'isteresi. Restano da misurare sul tuo sistema i tempi di codifica, l'RTT reale dei percorsi e il numero di sessioni per GPU.

Fonti

I listati TypeScript sono eseguiti con Node 24 su dati sintetici con seme fisso; gli stadi di latenza del primo listato sono ipotesi di lavoro da sostituire con misure. Il frammento per il browser è stato compilato con tsc 5 (strict, lib DOM) e non eseguito. Nessuna GPU, nessun server di gioco e nessuna rete reale sono stati usati.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti