Attention: Here be dragons
This is the latest
(unstable) version of this documentation, which may document features
not available in or compatible with released stable versions of Godot.
Checking the stable version of the documentation...
Preferenze dei dati
Vi siete mai chiesti se sia meglio affrontare il problema X con la struttura dati Y o Z? Questo articolo affronta una varietà di argomenti riguardo questi dilemmi.
Nota
Questo articolo fa riferimento a operazioni "[qualcosa]-tempo". Questa terminologia deriva dalla notazione O-grande dell'analisi algoritmica.
Per farla breve, descrive lo scenario peggiore di durata di esecuzione. In parole povere:
"All'aumentare della dimensione del dominio del problema, la durata di esecuzione dell'algoritmo..."
Tempo costante,
O(1): "...non aumenta."Tempo logaritmico,
O(log n): "...aumenta lentamente."Tempo lineare,
O(n): "...aumenta alla stessa frequenza."Ecc.
Immaginare di dover elaborare 3 milioni di punti dati in un singolo frame. Sarebbe impossibile creare la funzionalità con un algoritmo a tempo lineare, poiché l'enorme quantità di dati aumenterebbe i tempi di esecuzione ben oltre il tempo assegnato. In confronto, un algoritmo a tempo costante potrebbe gestire l'operazione senza problemi.
In generale, gli sviluppatori vogliono evitare il più possibile di coinvolgere operazioni in tempo lineare. Tuttavia, se si mantiene piccola la scala di un'operazione in tempo lineare, e se non è necessario eseguirla frequentemente, allora potrebbe essere accettabile. Bilanciare questi requisiti e scegliere l'algoritmo/struttura dati più adatti al lavoro è parte di ciò che rende preziose le competenze dei programmatori.
Array vs Dictionary vs Object
Godot memorizza tutte le variabili nell'API di scripting nella classe Variant. Le varianti possono memorizzare strutture dati compatibili con Variant come Array e Dictionary, nonché Objects.
Godot implementa Array come Vector<Variant>. Il motore memorizza il contenuto dell'Array in una sezione di memoria contigua, vale a dire che i dati sono disposti in una riga, adiacenti l'uno all'altro.
Nota
Per chi non ha familiarità con il C++, un Vector ("vettore") è il nome dell'oggetto array nelle librerie C++ tradizionali. È un tipo "templato", il che significa che i suoi record possono contenere solo un tipo specifico (indicato da parentesi angolari). Quindi, ad esempio, un PackedStringArray sarebbe qualcosa tipo Vector<String>.
La memorizzazione contigua implica le seguenti prestazioni operative:
Iterazione: la più veloce. Ottimo per i cicli.
Op: tutto quello che fa è incrementare un contatore per arrivare al record successivo.
Inserimento, Cancellazione, Spostamento: dipende dalla posizione. Generalmente lento.
Op: l'aggiunta/rimozione/spostamento di contenuti comporta lo spostamento dei record adiacenti (per fare spazio, o riempire lo spazio).
Aggiunge/rimuove velocemente dalla fine.
Aggiunge/rimuove lentamente da una posizione arbitraria.
Aggiunge/rimuove più lentamente dall'inizio.
Se si effettuano molti inserimenti/rimozioni dall'inizio, allora...
Invertire l'array.
fare un ciclo che effettua le modifiche dell'Array alla fine.
re-Invertire l'array.
In questo modo vengono create solo 2 copie dell'array (comunque tempo costante, ma lento), rispetto alla copia di circa la metà dell'array, in media, N volte (tempo lineare).
Ottenimento, Impostazione: più veloce per posizione. Ad esempio, è possibile richiedere il record 0, 2, 10, ecc., ma non è possibile specificare il record desiderato.
Op: 1 operazione di addizione dalla posizione iniziale dell'array fino all'indice desiderato.
Ricerca: più lenta. Identifica l'indice/posizione di un valore.
Op: è necessario iterare attraverso l'array e confrontare i valori finché non si trova una corrispondenza.
Le prestazioni dipendono anche dalla necessità o meno di una ricerca esaustiva.
Se mantenuti ordinati, le operazioni di ricerca personalizzate possono portare a un tempo logaritmico (relativamente veloce). Gli utenti meno esperti, però, non si troveranno a loro agio con questa soluzione. Si può fare riordinando l'Array dopo ogni modifica e scrivendo un algoritmo di ricerca ordinata.
Godot implementa Dictionary sotto forma di un HashMap<Variant, VariantHasher, StringLikeVariantComparator>. Il motore memorizza un piccolo array (inizializzato a 2^3 o 8 registri) di coppie chiave-valore. Quando si tenta di accedere a un valore, viene fornita una chiave. Quindi effettua l'hashing della chiave, ovvero la converte in un numero. L'"hash" seve per calcolare l'indice nell'array. Sotto forma di un array, l'OHM esegue quindi una rapida ricerca dentro la "tabella" delle chiavi mappate ai valori. Quando l'HashMap diventa troppo piena, aumenta alla potenza di 2 successiva (quindi, 16 registri, poi 32, ecc.) e ricostruisce la struttura.
Gli hash servono a ridurre il rischio di collisioni tra chiavi. In caso di una, la tabella deve ricalcolare un altro indice per il valore che tenga conto della posizione precedente. Nel complesso, ciò risulta in un accesso a tempo costante a tutti i record, a scapito della memoria e di una minore efficienza operativa.
Effettuare l'hashing di ogni chiave un numero arbitrario di volte.
Le operazioni di hash sono a tempo costante, quindi anche se un algoritmo deve eseguirne più di una, purché il numero di calcoli hash non diventi troppo dipendente dalla densità della tabella, il processo rimarrà veloce. Il che porta a...
Mantenere dimensioni in continua crescita per la tabella.
Le HashMap mantengono spazi intervallati di memoria inutilizzata nella tabella, appositamente per ridurre le collisioni di hash e mantenere la velocità degli accessi. Questo è la ragione per cui le loro dimensioni aumentano costantemente in modo esponenziale, con potenze di 2.
Come si può notare, i dizionari sono specializzati in compiti che gli array non svolgono. Un riassunto dei loro dettagli operativi è il seguente:
Iterazione: rapida.
Op: itera sul vettore interno di hash nella mappa. Restituisce ogni chiave. Successivamente, gli utenti utilizzano la chiave per saltare al valore desiderato e restituirlo.
Inserimento, Cancellazione, Spostamento: il più veloce.
Op: effettua l'hash della chiave specificata. Esegue 1 operazione di addizione per cercare il valore appropriato (inizio dell'array + offset). Lo spostamento è composto da due operazioni (un inserimento, una cancellazione). La mappa deve mantenersi per preservare le sue capacità:
aggiornare la lista (List) ordinata dei record.
determinare se la densità della tabella necessita di aumentarne la capacità.
Il Dictionary ricorda l'ordine in cui gli utenti hanno inserito le chiavi. Questo gli permette di eseguire iterazioni affidabili.
Ottenimento, Impostazione: la più veloce. Equivale a una ricerca per chiave.
Op: identico a inserimento/cancellazione/spostamento.
Ricerca: la più lenta. Identifica la chiave di un valore.
Op: è necessario interare attraverso i record e confrontare i valori finché non viene trovata una corrispondenza.
Si noti che Godot non fornisce questa funzionalità pronta all'uso (perché non sono progettati per questo lavoro).
Godot implementa gli oggetti come contenitori di dati semplici, ma dinamici. Gli oggetti interrogano le sorgenti dati quando vengono poste loro delle domande. Ad esempio, per rispondere alla domanda "Hai una proprietà chiamata 'position'?", potrebbe interrogare il suo script o il ClassDB. Ulteriori informazioni su cosa sono gli oggetti e come funzionano sono disponibili nell'articolo Applicare i principi orientati agli oggetti in Godot.
Il dettaglio importante qui è la complessità del compito dell'Object. Ogni volta che esegue una di queste interrogazioni multi-sorgente, esegue numerosi cicli di iterazione e ricerche di HashMap. Inoltre, le interrogazioni sono operazioni in tempo lineare che dipendono dalla grandezza della gerarchia di ereditarietà. Se la classe interrogata dall'oggetto (la sua classe attuale) non trova nulla, la richiesta viene rinviata alla classe padre successiva, fino alla classe Object originale. Sebbene queste siano operazioni veloci da sole, il fatto che debbano effettuare così tante verifiche è ciò che li rende più lenti di entrambe le alternative per ricercare dati.
Nota
Quando gli sviluppatori menzionano la lentezza dell'API di scripting, si riferiscono proprio a questa catena di interrogazioni. Rispetto al codice C++ compilato, in cui l'applicazione sa esattamente dove cercare qualsiasi cosa, è inevitabile che le operazioni dell'API di scripting richiedano molto più tempo. Devono individuare la sorgente di qualsiasi dato rilevante prima di poter tentare di accedervi.
La ragione per cui GDScript è lento è perché ogni operazione che esegue passa attraverso questo sistema.
C# può elaborare alcuni contenuti a velocità più elevate grazie a un bytecode più ottimizzato. Tuttavia, se lo script C# richiama il contenuto di una classe del motore o se lo script tenta di accedere a qualcosa di esterno, passerà attraverso questa pipeline.
NativeScript C++ va ancora oltre e mantiene tutto interno per normalmente. Le chiamate a strutture esterne passeranno attraverso l'API di scripting. In NativeScript C++, la registrazione dei metodi per esporli all'API di scripting è un'attività manuale. È a questo punto che le classi esterne, non C++, utilizzeranno l'API per individuarli.
Quindi, supponendo che uno estenda da Reference per creare una struttura dati, come un Array o un Dictionary, perché scegliere un Object rispetto alle altre due opzioni?
Controllo: Con gli oggetti si ha la possibilità di creare strutture più sofisticate. È possibile sovrapporre astrazioni ai dati per garantire che l'API esterna non cambi in risposta alle modifiche della struttura dati interna. In aggiunta, gli oggetti possono avere segnali, il che consente un comportamento reattivo.
Chiarezza: Gli oggetti sono una sorgente di dati affidabile per quanto riguarda i dati che gli script e le classi del motore definiscono per loro. Le proprietà potrebbero non contenere i valori previsti, ma non è necessario preoccuparsi se la proprietà esista effettivamente.
Comodità: Se si ha già in mente una struttura dati simile, estendere una classe esistente semplifica notevolmente la costruzione della struttura dati. In confronto, gli array e i dizionari non soddisfano tutti i casi d'uso possibili.
Gli oggetti offrono inoltre agli utenti la possibilità di creare strutture di dati ancora più specializzate. Con essi, è possibile progettare le proprie liste, alberi binari di ricerca, heap, alberi splay, grafi, insiemi disgiunti e qualsiasi altra opzione.
"Perché non usare Node per le strutture ad albero?", ci si potrebbe chiedere. Beh, la classe Node contiene cose che non saranno rilevanti per la propria struttura di dati personalizzata. Pertanto, può essere utile costruire un proprio tipo di nodo quando si creano strutture ad albero.
class_name TreeNode
extends Object
var _parent: TreeNode = null
var _children := []
func _notification(p_what):
match p_what:
NOTIFICATION_PREDELETE:
# Destructor.
for a_child in _children:
a_child.free()
using Godot;
using System.Collections.Generic;
// Can decide whether to expose getters/setters for properties later
public partial class TreeNode : GodotObject
{
private TreeNode _parent = null;
private List<TreeNode> _children = [];
public override void _Notification(int what)
{
switch (what)
{
case NotificationPredelete:
foreach (TreeNode child in _children)
{
node.Free();
}
break;
}
}
}
Da qui, si possono poi creare strutture personalizzate con caratteristiche specifiche, limitate solo dalla propria immaginazione.
Enumerazioni: int vs. string
La maggior parte dei linguaggi offre un'opzione per il tipo di enumerazione. GDScript non fa eccezione, ma a differenza della maggior parte degli altri linguaggi, per i valori di enumerazioni, consente di utilizzare sia interi sia stringhe (queste ultime solo quando si utilizza l'annotazione @export_enum in GDScript). La domanda sorge quindi spontanea: "Quale si dovrebbe usare?"
La risposta breve è "quello con cui ci si sente a più agio". Questa è una caratteristica specifica di GDScript e non dello scripting di Godot in generale; il linguaggio da priorità all'usabilità rispetto alle prestazioni.
A livello tecnico, i confronti tra interi (a tempo costante) saranno più rapidi dei confronti tra stringhe (a tempo lineare). Comunque, se si vogliono mantenere le convenzioni di altri linguaggi, è consigliabile usare gli interi.
Il problema principale con l'utilizzo di numeri interi si verifica quando si desidera stampare un valore di enumerazione. Con gli interi, provare a stampare MY_ENUM stamperà 5 o qualcosa del genere, invece di qualcosa come "MyEnum". Per stampare un intero di enumerazione, si dovrebbe scrivere un dizionario che mappi la stringa corrispondente per ogni intero.
Se il motivo principale per cui si sta utilizzando un enumerazione è la stampa di valori e si desidera raggrupparli come concetti correlati, allora ha senso utilizzarli come stringhe. In questo modo, non è necessario accedere a una struttura dati separata durante la stampa.
AnimatedTexture vs AnimatedSprite2D vs AnimationPlayer vs AnimationTree
In quali circostanze si dovrebbe usare ciascuna delle classi di animazione in Godot? La risposta potrebbe non essere immediatamente chiara ai nuovi utenti di Godot.
AnimatedTexture è una texture che il motore disegna come un ciclo animato anziché come un'immagine statica. Gli utenti possono manipolare...
la velocità con cui passa attraverso ogni sezione della texture (FPS).
il numero di regioni contenute nella texture (fotogrammi).
Il RenderingServer di Godot disegna quindi le regioni in sequenza alla frequenza stabilita. La buona notizia è che questo non richiede alcuna logica aggiuntiva da parte del motore. La cattiva notizia è che gli utenti hanno pochissimo controllo.
Si noti inoltre che AnimatedTexture è un oggetto Resource a differenza degli altri oggetti Node discussi qui. Si potrebbe creare un nodo Sprite2D che utilizzi AnimatedTexture come texture. Oppure (cosa che gli altri non possono fare) si potrebbero aggiungere AnimatedTexture come tassello in un TileSet e integrarlo con un TileMapLayer per molti sfondi auto-animati che vengono renderizzati tutti in un'unica chiamata di disegno.
Il nodo AnimatedSprite2D, in combinazione con la risorsa SpriteFrames, consente di creare una varietà di sequenze di animazione tramite spritesheet, di passare da un'animazione all'altra e di controllarne la velocità, offset regionale e orientamento. Questo li rende ideali per il controllo di animazioni 2D basate su frame.
Se è necessario attivare altri effetti in relazione ai cambiamenti dell'animazione (ad esempio, creare effetti di particelle, richiamare funzioni o manipolare altri elementi periferici oltre all'animazione basata sui fotogrammi), sarà necessario utilizzare un nodo AnimationPlayer insieme all'AnimatedSprite2D.
Gli AnimationPlayer sono anche lo strumento di cui si avrà bisogno se si vogliono progettare sistemi di animazione 2D più complessi, come...
Animazioni ritagliate: modificare le trasformazioni degli sprite durante l'esecuzione.
Animazioni di mesh 2D: definendo un'area per la texture dello sprite e agganciando uno scheletro ad essa. Poi, animando le ossa, si allargano e si contraggono nella texture in proporzione alle relazioni tra le ossa.
Un misto di quanto sopra.
Sebbene sia necessario un AnimationPlayer per progettare ciascuna delle singole sequenze di animazione di un gioco, può anche essere utile combinare le animazioni per ottenere fusioni, ovvero per consentire transizioni fluide tra di esse. Potrebbe anche esserci una struttura gerarchica tra le animazioni, pianificata per il loro oggetto. In questi casi, AnimationTree è particolarmente utile. Una guida dettagliata sull'utilizzo di AnimationTree è disponibile qui.