OOP ECS vs Pure ECS


11

In primo luogo, sono consapevole che questa domanda si collega all'argomento dello sviluppo del gioco, ma ho deciso di porlo qui poiché si tratta in realtà di un problema di ingegneria del software più generale.

Durante il mese scorso, ho letto molto su Entity-Component-Systems e ora sono abbastanza a mio agio con il concetto. Tuttavia, vi è un aspetto che sembra mancare di una "definizione" chiara e diversi articoli hanno suggerito soluzioni radicalmente diverse:

Questa è la domanda se un ECS debba interrompere l'incapsulamento o meno. In altre parole è l' ECS in stile OOP (i componenti sono oggetti con stato e comportamento che incapsulano i dati specifici per loro) rispetto all'ECS puro (i componenti sono strutture in stile c che dispongono solo di dati e sistemi pubblici che forniscono la funzionalità).

Nota che sto sviluppando un Framework / API / Engine. Quindi l'obiettivo è che può essere facilmente esteso da chiunque lo stia usando. Ciò include elementi come l'aggiunta di un nuovo tipo di rendering o componente di collisione.

Problemi con l'approccio OOP

  • I componenti devono accedere ai dati di altri componenti. Ad esempio, il metodo di disegno del componente di rendering deve accedere alla posizione del componente di trasformazione. Questo crea dipendenze nel codice.

  • I componenti possono essere polimorfici, il che introduce ulteriore complessità. Ad esempio, potrebbe esserci un componente di rendering sprite che sovrascrive il metodo di disegno virtuale del componente di rendering.

Problemi con l'approccio puro

  • Poiché il comportamento polimorfico (ad es. Per il rendering) deve essere implementato da qualche parte, è semplicemente esternalizzato nei sistemi. (ad es. il sistema di rendering sprite crea un nodo di rendering sprite che eredita il nodo di rendering e lo aggiunge al motore di rendering)

  • La comunicazione tra i sistemi può essere difficile da evitare. Ad esempio, il sistema di collisione potrebbe aver bisogno del riquadro di delimitazione che viene calcolato da qualsiasi componente di rendering in cemento ci sia. Ciò può essere risolto consentendo loro di comunicare tramite dati. Tuttavia, ciò rimuove gli aggiornamenti istantanei poiché il sistema di rendering aggiornerebbe il componente del riquadro di delimitazione e il sistema di collisione lo userebbe. Ciò può portare a problemi se l'ordine di chiamata delle funzioni di aggiornamento del sistema non è definito. È in atto un sistema di eventi che consente ai sistemi di generare eventi a cui altri sistemi possono abbonarsi i gestori. Tuttavia, questo funziona solo per dire ai sistemi cosa fare, cioè funzioni nulle.

  • Sono necessari ulteriori flag. Prendi ad esempio un componente della mappa delle tessere. Avrebbe dimensioni, dimensioni dei riquadri e campo dell'elenco indice. Il sistema di mappe a tessere gestirà il rispettivo array di vertici e assegnerebbe le coordinate di trama in base ai dati del componente. Tuttavia, ricalcolare l'intera tilemap ogni frame è costoso. Pertanto, sarebbe necessario un elenco per tenere traccia di tutte le modifiche apportate per aggiornarle nel sistema. Nel modo OOP questo potrebbe essere incapsulato dal componente della mappa delle tessere. Ad esempio, il metodo SetTile () aggiornerebbe l'array di vertici ogni volta che viene chiamato.

Anche se vedo la bellezza dell'approccio puro, non capisco davvero che tipo di benefici concreti avrebbe su un OOP più tradizionale. Le dipendenze tra i componenti esistono ancora sebbene siano nascoste nei sistemi. Inoltre avrei bisogno di molte più classi per raggiungere lo stesso obiettivo. Mi sembra una soluzione un po 'troppo ingegnerizzata che non è mai una buona cosa.

Inoltre, non sono così interessato alle prestazioni, quindi questa intera idea di progettazione orientata ai dati e miss cashe non ha molta importanza per me. Voglio solo una bella architettura ^^

Tuttavia, la maggior parte degli articoli e delle discussioni che ho letto suggeriscono il secondo approccio. PERCHÉ?

Animazione

Infine, vorrei porre la domanda su come gestire l'animazione in un ECS puro. Attualmente ho definito un'animazione come un funzione che manipola un'entità sulla base di alcuni progressi tra 0 e 1. Il componente animazione ha un elenco di animatori con un elenco di animazioni. Nella sua funzione di aggiornamento applica quindi le animazioni attualmente attive all'entità.

Nota:

Ho appena letto questo post L'architettura di Entity Component System è orientata per definizione? che spiega il problema un po 'meglio di me. Pur essendo sostanzialmente sullo stesso argomento, non fornisce ancora alcuna risposta sul perché l'approccio dei dati puri sia migliore.


1
Forse una domanda semplice ma seria: conosci i vantaggi / gli svantaggi di ECS? Ciò spiega principalmente il "perché".
Caramiriel,

Bene, capisco il vantaggio di usare componenti come la composizione piuttosto che l'ereditarietà per evitare il diamante della morte attraverso l'ereditarietà multipla. L'uso dei componenti consente anche di manipolare il comportamento in fase di esecuzione. E sono modulari. Quello che non capisco è il motivo per cui si desidera dividere dati e funzioni. La mia attuale implementazione è su github github.com/AdrianKoch3010/MarsBaseProject
Adrian Koch

Beh, non ho abbastanza esperienza con ECS per aggiungere una risposta completa. Ma la composizione non è solo usata per evitare il DoD; puoi anche creare entità (uniche) in fase di runtime difficili da generare usando un approccio OO. Detto questo, la suddivisione dei dati / procedure consente ai dati di essere più facili da ragionare. È possibile implementare la serializzazione, il salvataggio dello stato, l'annullamento / la ripetizione e cose del genere in modo semplice. Dal momento che è facile ragionare sui dati, è anche più facile ottimizzarli. Molto probabilmente puoi dividere le entità in batch (multithreading) o persino scaricarle su altro hardware per sfruttarne appieno il potenziale.
Caramiriel,

"Potrebbe esserci un componente di rendering sprite che sovrascrive il metodo di disegno virtuale del componente di rendering." Direi che non è più ECS se lo fai / lo richiedi.
Wondra,

Risposte:


10

Questo è difficile. Proverò solo ad affrontare alcune delle domande basate sulle mie esperienze particolari (YMMV):

I componenti devono accedere ai dati di altri componenti. Ad esempio, il metodo di disegno del componente di rendering deve accedere alla posizione del componente di trasformazione. Questo crea dipendenze nel codice.

Non sottovalutare la quantità e la complessità (non il grado) di accoppiamento / dipendenze qui. Potresti guardare la differenza tra questo (e questo diagramma è già ridicolmente semplificato a livelli simili a giocattoli, e l'esempio del mondo reale avrebbe interfacce tra per allentare l'accoppiamento):

inserisci qui la descrizione dell'immagine

... e questo:

inserisci qui la descrizione dell'immagine

... o questo:

inserisci qui la descrizione dell'immagine

I componenti possono essere polimorfici, il che introduce ulteriore complessità. Ad esempio, potrebbe esserci un componente di rendering sprite che sovrascrive il metodo di disegno virtuale del componente di rendering.

Così? L'equivalente analogico (o letterale) di un invio virtuale e virtuale può essere invocato tramite il sistema piuttosto che l'oggetto che nasconde il suo stato / dati sottostanti. Il polimorfismo è ancora molto pratico e fattibile con l'implementazione "pura" di ECS quando la vable analogica o il / i puntatore / i di funzione si trasforma in "dati" di sorta per il sistema da invocare.

Poiché il comportamento polimorfico (ad es. Per il rendering) deve essere implementato da qualche parte, è semplicemente esternalizzato nei sistemi. (ad es. il sistema di rendering sprite crea un nodo di rendering sprite che eredita il nodo di rendering e lo aggiunge al motore di rendering)

Così? Spero che questo non stia venendo fuori come sarcasmo (non è il mio intento anche se ne sono stato spesso accusato, ma vorrei poter comunicare meglio le emozioni attraverso il testo), ma in questo caso il comportamento polimorfico di "esternalizzazione" non comporta necessariamente un ulteriore costo per la produttività.

La comunicazione tra i sistemi può essere difficile da evitare. Ad esempio, il sistema di collisione potrebbe aver bisogno del riquadro di delimitazione che viene calcolato da qualsiasi componente di rendering in cemento ci sia.

Questo esempio mi sembra particolarmente strano. Non so perché un renderer restituirebbe i dati alla scena (in genere considero i renderer di sola lettura in questo contesto) o se un renderer sta studiando gli AABB invece di un altro sistema per farlo sia per il renderer che per collisione / fisica (potrei essere bloccato sul nome "componente di rendering" qui). Eppure non voglio essere troppo preso da questo esempio poiché mi rendo conto che non è questo il punto che stai cercando di fare. Tuttavia, la comunicazione tra i sistemi (anche in forma indiretta di lettura / scrittura nel database ECS centrale con sistemi che dipendono piuttosto direttamente dalle trasformazioni effettuate da altri) non dovrebbe essere frequente, se non del tutto necessario. Quello'

Ciò può portare a problemi se l'ordine di chiamata delle funzioni di aggiornamento del sistema non è definito.

Questo dovrebbe assolutamente essere definito. L'ECS non è la soluzione definitiva per riorganizzare l'ordine di valutazione dell'elaborazione del sistema di ogni possibile sistema nella base di codice e restituire esattamente lo stesso tipo di risultati all'utente finale che si occupa di frame e FPS. Questa è una delle cose, nel progettare un ECS, che suggerirei almeno fortemente di anticipare un po 'in anticipo (anche se con un sacco di spazio per perdonare per cambiare idea in seguito, a condizione che non stia alterando gli aspetti più critici dell'ordine di invocazione / valutazione del sistema).

Tuttavia, ricalcolare l'intera tilemap ogni frame è costoso. Pertanto, sarebbe necessario un elenco per tenere traccia di tutte le modifiche apportate per aggiornarle nel sistema. Nel modo OOP questo potrebbe essere incapsulato dal componente della mappa delle tessere. Ad esempio, il metodo SetTile () aggiornerebbe l'array di vertici ogni volta che viene chiamato.

Non ho capito bene questo tranne per il fatto che si tratta di una preoccupazione orientata ai dati. E non ci sono insidie ​​sulla rappresentazione e l'archiviazione dei dati in un ECS, inclusa la memoizzazione, per evitare tali insidie ​​delle prestazioni (le più grandi con un ECS tendono a riguardare cose come i sistemi che richiedono query per istanze disponibili di particolari tipi di componenti che è uno dei aspetti più difficili dell'ottimizzazione di un ECS generalizzato). Il fatto che la logica e i dati siano separati in un ECS "puro" non significa che devi improvvisamente ricalcolare le cose che potresti altrimenti memorizzare nella cache / memorizzare in una rappresentazione OOP. Questo è un punto controverso / irrilevante, a meno che non abbia riflettuto su qualcosa di molto importante.

Con l'ECS "puro" è ancora possibile memorizzare questi dati nel componente della mappa delle tessere. L'unica grande differenza è che la logica per aggiornare questo array di vertici si sposterà da qualche parte su un sistema.

Puoi persino appoggiarti all'ECS per semplificare l'invalidazione e la rimozione di questa cache dall'entità se crei un componente separato come TileMapCache. A quel punto quando la cache è desiderata ma non disponibile in un'entità con un TileMapcomponente, è possibile calcolarla e aggiungerla. Quando è invalidato o non è più necessario, è possibile rimuoverlo tramite ECS senza dover scrivere altro codice specifico per tale invalidamento e rimozione.

Le dipendenze tra i componenti esistono ancora sebbene siano nascoste nei sistemi

Non c'è dipendenza tra i componenti in un rappresentante "puro" (non credo sia giusto dire che le dipendenze vengono nascoste qui dai sistemi). I dati non dipendono dai dati, per così dire. La logica dipende dalla logica. E un ECS "puro" tende a promuovere la logica da scrivere in modo da dipendere dal sottoinsieme minimo assoluto di dati e logica (spesso nessuno) che un sistema richiede per funzionare, il che è diverso da molte alternative che spesso incoraggiano a seconda molte più funzionalità di quelle necessarie per l'attività effettiva. Se stai usando il puro ECS giusto, una delle prime cose che dovresti apprezzare sono i vantaggi del disaccoppiamento mentre allo stesso tempo metti in discussione tutto ciò che hai imparato ad apprezzare in OOP sull'incapsulamento e in particolare sul nascondere le informazioni.

Per disaccoppiamento intendo in particolare la quantità di informazioni necessarie ai sistemi per funzionare. Il tuo sistema di movimento non ha nemmeno bisogno di sapere qualcosa di molto più complesso come un Particleo Character(lo sviluppatore del sistema non ha nemmeno bisogno di sapere che tali idee di entità esistono anche nel sistema). Deve solo conoscere i dati minimi nudi come un componente di posizione che potrebbe essere semplice come alcuni float in una struttura. Sono anche meno informazioni e meno dipendenze esterne rispetto a quelle che un'interfaccia pura IMotiontende a portare con sé. È principalmente dovuto a questa minima conoscenza che ogni sistema richiede di funzionare, il che rende l'ECS spesso così indulgente a gestire i cambiamenti di progetto molto imprevisti con il senno di poi senza affrontare rotture dell'interfaccia a cascata in tutto il luogo.

L'approccio "impuro" che suggerisci diminuisce in qualche modo il vantaggio dato che ora la tua logica non è localizzata rigorosamente in sistemi in cui i cambiamenti non causano rotture a cascata. La logica verrebbe ora centralizzata in una certa misura nei componenti a cui accedono più sistemi che ora devono soddisfare i requisiti di interfaccia di tutti i vari sistemi che potrebbero usarlo, e ora è come se ogni sistema avesse bisogno di conoscenza di (dipende da) altro informazioni di quelle strettamente necessarie per lavorare con quel componente.

Dipendenze dai dati

Una delle cose controverse dell'ECS è che tende a sostituire quelle che potrebbero altrimenti essere dipendenze da interfacce astratte con solo dati grezzi, e questa è generalmente considerata una forma meno desiderabile e più stretta di accoppiamento. Ma nei tipi di domini come i giochi in cui ECS può essere molto utile, spesso è più semplice progettare la rappresentazione dei dati in anticipo e mantenerlo stabile piuttosto che progettare cosa si può fare con quei dati a un livello centrale del sistema. È qualcosa che ho dolorosamente osservato anche tra i veterani esperti in codebase che utilizza più di un approccio di interfaccia pura in stile COM con cose come IMotion.

Gli sviluppatori hanno continuato a trovare motivi per aggiungere, rimuovere o modificare funzioni a questa interfaccia centrale, e ogni modifica è stata orribile e costosa perché tenderebbe a spezzare ogni singola classe implementata IMotioninsieme a ogni posto nel sistema che ha usato IMotion. Nel frattempo per tutto il tempo con così tanti cambiamenti dolorosi e in cascata, gli oggetti implementati IMotionstavano semplicemente memorizzando una matrice 4x4 di float e l'intera interfaccia si preoccupava solo di come trasformare e accedere a quei float; la rappresentazione dei dati era stabile fin dall'inizio e molto dolore avrebbe potuto essere evitato se questa interfaccia centralizzata, così incline a cambiare con esigenze di progettazione impreviste, non esistesse nemmeno in primo luogo.

Tutto ciò potrebbe sembrare disgustoso quasi quanto le variabili globali, ma la natura di come l'ECS organizza questi dati in componenti recuperati in modo esplicito per tipo attraverso i sistemi lo rende tale, mentre i compilatori non possono applicare nulla come nascondere le informazioni, i luoghi che accedono e mutano i dati sono generalmente molto espliciti e abbastanza ovvi da mantenere ancora efficacemente gli invarianti e prevedere che tipo di trasformazioni ed effetti collaterali si verificano da un sistema all'altro (in realtà in modi che possono essere probabilmente più semplici e prevedibili di OOP in determinati domini dato come il sistema si trasforma in una sorta di pipeline piatta).

inserisci qui la descrizione dell'immagine

Infine, vorrei porre la domanda su come gestire l'animazione in un ECS puro. Attualmente ho definito un'animazione come un funzione che manipola un'entità sulla base di alcuni progressi tra 0 e 1. Il componente animazione ha un elenco di animatori con un elenco di animazioni. Nella sua funzione di aggiornamento applica quindi le animazioni attualmente attive all'entità.

Siamo tutti pragmatici qui. Anche in Gamedev avrai probabilmente idee / risposte contrastanti. Anche l'ECS più puro è un fenomeno relativamente nuovo, un territorio pionieristico, per il quale le persone non hanno necessariamente formulato le opinioni più forti su come pelare i gatti. La mia reazione istintiva è un sistema di animazione che incrementa questo tipo di avanzamento dell'animazione nei componenti animati per la visualizzazione del sistema di rendering, ma questo ignora tanta sfumatura per la particolare applicazione e contesto.

Con l'ECS non è un proiettile d'argento e mi trovo ancora con la tendenza ad entrare e aggiungere nuovi sistemi, rimuoverne alcuni, aggiungere nuovi componenti, cambiare un sistema esistente per raccogliere quel nuovo tipo di componente, ecc. Non capisco le cose vanno bene per la prima volta ancora. Ma la differenza nel mio caso è che non sto cambiando nulla di centrale quando non riesco a prevedere in anticipo determinate esigenze di progettazione. Non sto ottenendo l'effetto increspato delle rotture a cascata che mi richiedono di andare ovunque e cambiare così tanto codice per gestire alcune nuove esigenze che si accumulano, e questo è piuttosto un risparmio di tempo. Sto trovando anche più facile il mio cervello perché quando mi siedo con un particolare sistema, non ho bisogno di sapere / ricordare molto di tutto ciò che non riguarda i componenti rilevanti (che sono solo dati) per lavorarci.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.