Tesi Triennale - Unity DOTS

Il Progetto
Progetto di Applicazioni e Giochi Multiplayer su Architettura Unity DOTS è la mia tesi di laurea triennale in Ingegneria Informatica, discussa a marzo 2021 all’Alma Mater Studiorum, Università di Bologna, con relatore il Prof. Paolo Bellavista e correlatore il Dott. Andrea Garbugli.
L’elaborato aveva due obiettivi: analizzare il layout orientato ai dati che Unity stava introducendo con il nuovo Data-Oriented Technology Stack (DOTS), e dimostrare che funzionasse davvero realizzando un prototipo multiplayer giocabile costruito interamente con DOTS — niente GameObject, niente MonoBehaviour nel codice di gioco.


Le due registrazioni qui sopra mostrano la stessa partita da due macchine diverse: un server headless e due client connessi, uno in esecuzione dentro l’editor Unity e uno come applicazione standalone.
Perché DOTS
Per anni Unity ha lavorato per rendere più veloce il proprio motore, ma il collo di bottiglia non è mai stato davvero il motore: era il modo in cui andava scritta la logica delle applicazioni. L’architettura classica si basa su un modello a componenti (GameObject + MonoBehaviour) che è, nella sostanza, orientato agli oggetti: sia GameObject che MonoBehaviour sono classi, per cui ogni “cosa” in scena si porta dietro l’overhead di un oggetto, i suoi dati finiscono sparpagliati in memoria dietro dei riferimenti, e i core multipli delle CPU moderne restano in gran parte inutilizzati.
DOTS sostituisce quel modello con uno basato su ECS — Entità, Componenti e Sistemi — e la sua promessa si riassume in due parole: performance by default. L’idea è che il primo istinto mentre si costruisce qualcosa in Unity dovrebbe già essere una buona approssimazione di basso livello della soluzione giusta, invece di qualcosa che poi va ripensato, ricostruito o buttato via perché non scala.
Il cambiamento sta nella separazione fra dati e comportamento: i dati vivono nei componenti, il comportamento nei sistemi. Le “cose” a runtime smettono di essere oggetti pesanti e diventano semplici indici numerici — le entità, paragonabili alle chiavi di un database. Le entità che condividono lo stesso insieme di componenti vengono memorizzate insieme in blocchi di memoria contigui, il che significa che le cache della CPU smettono di essere saturate dalla miriade di campi che un oggetto si trascina dietro e iniziano a riempirsi dei dati che il codice sta effettivamente per leggere.
Il prototipo si basa su tre package, all’epoca (2021) tutti ancora in preview:
- Entities
0.17— il modello ECS vero e proprio; - Physics
0.6— corpi statici/dinamici, collisioni ed eventi di trigger; - NetCode
0.6— il livello di networking.
Il Prototipo
Il prototipo è un piccolo gioco multiplayer: ogni giocatore controlla un personaggio a forma di capsula, lo muove per la mappa e interagisce con una manciata di oggetti di scena. È volutamente poco ambizioso come gioco e volutamente completo come progetto — l’obiettivo era toccare ogni parte dello stack, dall’handshake della connessione ai trigger fisici.
Connessione
NetCode si basa su un modello a server autoritativo, e suddivide l’applicazione in world separati: un world client, un world server, oppure entrambi quando si preme Play nell’editor. Game.cs contiene il sistema che scorre tutti i world e decide, per ognuno, se connettersi o mettersi in ascolto:
protected override void OnUpdate()
{
EntityManager.DestroyEntity(GetSingletonEntity<InitGameComponent>());
foreach (var world in World.All)
{
var network = world.GetExistingSystem<NetworkStreamReceiveSystem>();
if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
{
world.EntityManager.CreateEntity(typeof(EnableGame));
NetworkEndPoint ep = NetworkEndPoint.LoopbackIpv4;
ep.Port = 7979;
ep = NetworkEndPoint.Parse(ClientServerBootstrap.RequestedAutoConnect, 7979);
network.Connect(ep);
}
else if (world.GetExistingSystem<ServerSimulationSystemGroup>() != null)
{
world.EntityManager.CreateEntity(typeof(EnableGame));
NetworkEndPoint ep = NetworkEndPoint.AnyIpv4;
ep.Port = 7979;
network.Listen(ep);
}
}
}
Questo codice deve essere eseguito una sola volta per avvio dell’applicazione, ed ECS offre un modo elegante per esprimerlo: il sistema richiede il singleton InitGameComponent per aggiornarsi, e la prima cosa che OnUpdate() fa è distruggerlo. Niente booleani, niente Start().
Stabilire la connessione, però, non basta. Prima che comandi e snapshot possano viaggiare, il client deve dichiararsi pronto, e lo fa tramite una RPC: GoInGameClientSystem invia una GoInGameRequest al server, e GoInGameServerSystem la riceve, marca la connessione come “in game” aggiungendo il componente NetworkStreamInGame, e genera una capsula per quel giocatore.
La parte interessante, lato server, è la gestione della proprietà. Ogni oggetto in rete — un ghost, nella terminologia di NetCode — deve sapere a quale connessione appartiene, e questo si può risolvere solo a runtime:
var player = commandBuffer.Instantiate(prefab);
commandBuffer.SetComponent(player, new GhostOwnerComponent {
NetworkId = networkIdFromEntity[reqSrc.SourceConnection].Value
});
commandBuffer.AddBuffer<PlayerInput>(player);
commandBuffer.SetComponent(reqSrc.SourceConnection, new CommandTargetComponent { targetEntity = player });
commandBuffer.DestroyEntity(reqEnt);
Distruggere l’entità della richiesta alla fine non è un dettaglio: se resta lì, il sistema continua a eseguire all’infinito.
Input e Predizione
In un gioco multiplayer non si può semplicemente leggere la tastiera e muovere il personaggio. L’input va salvato in una struttura che implementa ICommandData e spedito al server sotto forma di comando, così che anche quest’ultimo possa applicarlo nella propria simulazione:
public struct PlayerInput : ICommandData
{
public uint Tick { get; set; }
public int horizontal;
public int vertical;
}
Tutto il trucco sta nella proprietà Tick. Registra il tick di simulazione in cui l’input è stato campionato, così il server lo applica nello stesso momento logico in cui l’ha fatto il client, indipendentemente dalla latenza — ed è anche ciò che abilita la predizione lato client di NetCode.
PlayerInputSystem esegue solo lato client, campiona i tasti e li accoda al buffer dei comandi:
var input = default(PlayerInput);
input.Tick = m_ClientSimulationSystemGroup.ServerTick;
if (Input.GetKey("a"))
input.horizontal -= 1;
if (Input.GetKey("d"))
input.horizontal += 1;
if (Input.GetKey("s"))
input.vertical -= 1;
if (Input.GetKey("w"))
input.vertical += 1;
var inputBuffer = EntityManager.GetBuffer<PlayerInput>(localInput);
inputBuffer.AddCommandData(input);
Prima di poterlo fare, però, deve capire quale sia la capsula locale. È il compito del singleton CommandTargetComponent, che nei primi frame non è ancora inizializzato: in quel caso il sistema ripiega sulla scansione delle capsule alla ricerca di quella il cui GhostOwnerComponent.NetworkId corrisponde all’id della connessione locale.
PlayerMovementSystem applica poi il movimento, e lo fa all’interno del GhostPredictionSystemGroup — il che significa che esegue su entrambi i lati, e sul client ri-simula i tick predetti ogni volta che arriva uno snapshot dal server:
var tick = m_GhostPredictionSystemGroup.PredictingTick;
var deltaTime = Time.DeltaTime;
Entities.ForEach((DynamicBuffer<PlayerInput> inputBuffer, ref PhysicsVelocity pv,
in PredictedGhostComponent prediction, in PlayerMovementSpeed pms) =>
{
if (!GhostPredictionSystemGroup.ShouldPredict(tick, prediction))
return;
PlayerInput input;
inputBuffer.GetDataAtTick(tick, out input);
var speed = pms.speed;
if (input.horizontal > 0)
pv.Linear.x += speed * deltaTime;
if (input.horizontal < 0)
pv.Linear.x -= speed * deltaTime;
if (input.vertical > 0)
pv.Linear.z += speed * deltaTime;
if (input.vertical < 0)
pv.Linear.z -= speed * deltaTime;
}).ScheduleParallel();


La camera è l’unico punto in cui il codice ibrido è inevitabile: CameraFollowSystem esegue lato client, trova la capsula riferita da CommandTargetComponent e pilota il buon vecchio transform di Camera.main con un offset letto da un componente PlayerCameraFollowComponent attaccato all’entità.
Portali e Teletrasporti
Due sistemi cambiano il materiale della capsula che attraversa un portale — TemporaryChangeMaterialOnTriggerSystem, che ripristina il materiale originale all’uscita, e PersistentChangeMaterialOnTriggerSystem, che invece no.
Entrambi si basano su StatefulTriggerEvent, un evento di trigger bufferizzato preso dagli UnityPhysicsSamples di Unity. Gli eventi di trigger grezzi dicono soltanto che una sovrapposizione sta avvenendo; bufferizzarli e confrontarli con il frame precedente permette di conoscere i frame esatti di entrata, permanenza e uscita, ed è ciò che rende possibile la variante temporanea:
if (triggerEvent.State == EventOverlapState.Enter)
{
var volumeRenderMesh = EntityManager.GetSharedComponentData<RenderMesh>(e);
var overlappingRenderMesh = EntityManager.GetSharedComponentData<RenderMesh>(otherEntity);
overlappingRenderMesh.material = volumeRenderMesh.material;
commandBuffer.SetSharedComponent(otherEntity, overlappingRenderMesh);
}
Da notare che il ramo di uscita ripristina il materiale a partire da un’entità di riferimento, non da quello che la capsula aveva prima di entrare — così concatenare più portali temporanei riporta sempre al colore originale, e non a quello del portale precedente.


Spawn e Collezionabili
SpawnRandomObjectsAuthoring è un componente di authoring che istanzia un numero arbitrario di entità in punti casuali all’interno di un volume, il tutto configurabile dall’inspector — il conversion workflow trasforma quei campi dell’inspector in puri dati ECS in fase di conversione.


I collezionabili chiudono il cerchio fra fisica, networking e gameplay: PickUpSystem reagisce al trigger, incrementa il PlayerScoreComponent della capsula che l’ha toccato e marca il collezionabile con DeleteTagComponent, che DeleteCollectibleSystem provvede poi a raccogliere. Separare il “marca per l’eliminazione” dall’“elimina” è una scelta molto ECS: le modifiche strutturali sono costose, ed è meglio raggrupparle in un punto noto del frame.

Raccolta dei collezionabili
Build Standalone
Per compilare un’applicazione DOTS in standalone servivano i package com.unity.platforms.* e un asset di Build Configuration, al posto della classica finestra di Build Settings. Nel caso di un’applicazione multiplayer, NetCode legge la proprietà Server Build insieme agli scripting define symbols per decidere cosa sta producendo — solo client, solo server, oppure una build che sceglie il proprio ruolo a runtime, che è ciò che rende possibile il server headless.
Prestazioni
Il prototipo rispondeva alla domanda “si riesce davvero a costruirci un gioco?”. Per rispondere a “è più veloce?” ho realizzato un secondo progetto, usa e getta: una scena per architettura, lo stesso componente spawner in entrambe, e una montagna di cubi a righe che ruotano sul posto — come GameObject mossi da un MonoBehaviour nella prima scena, come entità mosse da un sistema nella seconda.
La logica di rotazione ha volutamente la stessa forma. Architettura classica:
public class Rotator : MonoBehaviour
{
public float speedX, speedY, speedZ;
void Update()
{
transform.Rotate(speedX * Time.deltaTime, speedY * Time.deltaTime, speedZ * Time.deltaTime);
}
}
ECS:
public class RotatorSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime;
Entities.ForEach((ref Rotation rotation, in RotateComponent rc) =>
{
rotation.Value = math.mul(rotation.Value, quaternion.RotateX(math.radians(rc.speed.x * deltaTime)));
rotation.Value = math.mul(rotation.Value, quaternion.RotateY(math.radians(rc.speed.y * deltaTime)));
rotation.Value = math.mul(rotation.Value, quaternion.RotateZ(math.radians(rc.speed.z * deltaTime)));
}).Run();
}
}
Le configurazioni misurate sono cinque, ognuna a una sola modifica di distanza dalla precedente:
- GameObject — architettura classica, rotazione dentro
Update(); - ECS — entità, con la lambda del sistema eseguita sul main thread tramite
Run(); - Jobs — stesso sistema, ma schedulato con
Schedule()su un singolo worker thread; - ParallelJobs —
ScheduleParallel(), distribuendo il lavoro su tutti i worker thread; - Burst — gli stessi job paralleli, compilati in codice nativo dal compilatore Burst.
Tutto è stato eseguito su un Intel Core i7-7700HQ (4 core, 8 processori logici, 2.80GHz), 16GB di RAM, una GeForce GTX 1060, su Windows 10 a 64 bit. Per ogni configurazione e ogni numero di cubi ho campionato 10 frame con il Profiler di Unity e ne ho calcolato la media.

Il Profiler Unity su un singolo frame del prototipo
Risultati
Frame per secondo:
| # cubi | 10 | 100 | 1.000 | 10.000 | 100.000 | 1.000.000 |
|---|---|---|---|---|---|---|
| GameObject | 320 | 285 | 155 | 27,5 | 2,5 | 0,1 |
| ECS | 320 | 300 | 176 | 34 | 3,9 | 0,5 |
| ECS + Jobs | 300 | 305 | 205 | 55 | 6,9 | 0,6 |
| ECS + ParallelJobs | 300 | 305 | 255 | 102 | 13,9 | 1,2 |
| ECS + Burst | 310 | 305 | 290 | 170 | 25,2 | 2,3 |
Tempo CPU totale per frame (ms):
| # cubi | 10 | 100 | 1.000 | 10.000 | 100.000 | 1.000.000 |
|---|---|---|---|---|---|---|
| GameObject | 2,9 | 3,8 | 6,5 | 37,7 | 430 | 5000 |
| ECS | 2,9 | 3,2 | 5,7 | 30 | 267 | 2600 |
| ECS + Jobs | 3,5 | 3,7 | 4,4 | 17,9 | 157 | 1568 |
| ECS + ParallelJobs | 3,2 | 3,4 | 3,8 | 9,8 | 75 | 866 |
| ECS + Burst | 3,2 | 3,2 | 3,7 | 5,8 | 39,8 | 447 |
Tempo impiegato per ruotare i cubi (ms), 100.000 cubi:
| Configurazione | Main thread | Worker thread |
|---|---|---|
| GameObject | 77,842 | — |
| ECS | 91,87 | — |
| ECS + Jobs | 0,043 | 96,98 (1 job) |
| ECS + ParallelJobs | 0,04 | 130,88 (8 job) |
| ECS + Burst | 0,045 | 9,193 (8 job) |

FPS al variare del numero di cubi in rotazione
Leggere i numeri
Fino a un migliaio di cubi va tutto bene ovunque — il frame è dominato dal rendering e l’architettura quasi non si vede. Il divario si apre esattamente dove ci si aspetta, e da lì continua ad allargarsi.
La riga più istruttiva è la seconda. ECS “vanilla” è più lento del MonoBehaviour nel ruotare i cubi — 91,87 ms contro 77,842 ms a 100.000 cubi — eppure l’applicazione gira più veloce, 3,9 FPS contro 2,5. Il motivo è visibile nel profiler: Rotator.Update() costa 0,001 ms, ma Unity la chiama 100.000 volte separatamente, ed è quell’overhead per chiamata a mangiarsi il frame. Il sistema paga invece una sola dispatch e poi itera su dati ben impacchettati. Il tempo CPU totale per frame scende da 430 ms a 267 ms senza toccare l’algoritmo.

100.000 GameObject ruotati da un MonoBehaviour: 76,55 ms su 100.000 istanze di Rotator.Update()
Spostare la lambda in un job non cambia quasi nulla del lavoro in sé (96,98 ms su un worker thread) ma libera quasi completamente il main thread — 0,043 ms — e questo da solo porta il frame da 267 ms a 157 ms. Parallelizzare divide poi il lavoro su 8 job: ogni singolo job diventa più lento in termini di tempo assoluto (130,88 ms), perché competono tutti per gli stessi core, ma eseguono in concorrenza, per cui il tempo totale del frame si dimezza di nuovo.
E poi c’è Burst. Attivare una singola opzione dell’editor — Jobs > Burst > Enable Compilation — compila l’IL dei job in codice nativo e fa scendere il tempo dei job paralleli da 130,88 ms a 9,193 ms, un fattore quattordici, gratis. A 100.000 cubi lo stack completo gira a 25,2 FPS contro i 2,5 dell’architettura classica: dieci volte il framerate, con quelle che sono riconoscibilmente le stesse tre righe di matematica.

Le stesse 100.000 entità con job paralleli compilati con Burst: 8,84 ms su 8 worker thread
Problemi Noti e Sviluppi Futuri
Il prototipo è un progetto di tesi, e in qualche punto si vede — la camera in terza persona ogni tanto inizia a sfarfallare, le simulazioni fisiche che coinvolgono entità dinamiche non sono sincronizzate fra i client, e le build standalone a volte crashano. I passi successivi più ovvi sarebbero la gestione della disconnessione dei client, un menu principale con lobby prepartita, una scoreboard dei giocatori e un sistema di inventario.
Tutto quanto è stato scritto sul DOTS così com’era nel 2021, quando i package erano ancora in preview e le API cambiavano fra una minor version e l’altra. Da allora le cose sono andate parecchio avanti — SystemBase e le API in stile ClientSimulationSystemGroup di questi snippet non sono quello che si scriverebbe oggi — ma la forma del ragionamento, e i numeri, hanno tenuto.
L’intero progetto, prototipo e stress test, si trova nella repository UnityDOTS-Thesis, insieme alla documentazione del prototipo, ai sorgenti LaTeX dell’elaborato e alle slide della presentazione.
Un ringraziamento al mio correlatore Andrea Garbugli, che mi ha proposto l’argomento e mi ha aiutato nella stesura.
alma mater studiorum unibo game multiplayer netcode unity dots ecs c# latex windows
2387 parole
2021-03-11 08:00