Come hanno fatto: milioni di piastrelle in Terraria


45

Ho lavorato su un motore di gioco simile a Terraria , principalmente come una sfida, e mentre ho capito la maggior parte di esso, non riesco davvero a avvolgere la mia testa su come gestiscono i milioni di tessere intercambiabili / vendibili il gioco ha contemporaneamente. La creazione di circa 500.000 tessere, ovvero 1/20 di ciò che è possibile fare in Terraria , nel mio motore fa scendere il frame rate da 60 a circa 20, anche se sto ancora visualizzando solo le tessere in vista. Intendiamoci, non sto facendo nulla con le tessere, ma solo conservandole in memoria.

Aggiornamento : codice aggiunto per mostrare come faccio le cose.

Questo fa parte di una classe, che gestisce le tessere e le disegna. Immagino che il colpevole sia la parte "foreach", che itera tutto, anche gli indici vuoti.

...
    public void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        foreach (Tile tile in this.Tiles)
        {
            if (tile != null)
            {
                if (tile.Position.X < -this.Offset.X + 32)
                    continue;
                if (tile.Position.X > -this.Offset.X + 1024 - 48)
                    continue;
                if (tile.Position.Y < -this.Offset.Y + 32)
                    continue;
                if (tile.Position.Y > -this.Offset.Y + 768 - 48)
                    continue;
                tile.Draw(spriteBatch, gameTime);
            }
        }
    }
...

Anche qui è il metodo Tile.Draw, che potrebbe anche fare con un aggiornamento, poiché ogni Tile utilizza quattro chiamate al metodo SpriteBatch.Draw. Questo fa parte del mio sistema di autotiling, che significa disegnare ogni angolo a seconda delle tessere vicine. texture_ * sono Rettangoli, vengono impostati una volta alla creazione del livello, non ogni aggiornamento.

...
    public virtual void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        if (this.type == TileType.TileSet)
        {
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position, texture_tl, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(8, 0), texture_tr, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(0, 8), texture_bl, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(8, 8), texture_br, this.BlendColor);
        }
    }
...

Qualsiasi critica o suggerimento al mio codice è il benvenuto.

Aggiornamento : soluzione aggiunta.

Ecco il metodo Level.Draw finale. Il metodo Level.TileAt controlla semplicemente i valori immessi, per evitare eccezioni OutOfRange.

...
    public void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        Int32 startx = (Int32)Math.Floor((-this.Offset.X - 32) / 16);
        Int32 endx = (Int32)Math.Ceiling((-this.Offset.X + 1024 + 32) / 16);
        Int32 starty = (Int32)Math.Floor((-this.Offset.Y - 32) / 16);
        Int32 endy = (Int32)Math.Ceiling((-this.Offset.Y + 768 + 32) / 16);

        for (Int32 x = startx; x < endx; x += 1)
        {
            for (Int32 y = starty; y < endy; y += 1)
            {
                Tile tile = this.TileAt(x, y);
                if (tile != null)
                    tile.Draw(spriteBatch, gameTime);

            }
        }
    }
...

2
Sei assolutamente sicuro di rendere solo ciò che è nella vista della telecamera, il che significa che è il codice per determinare cosa rendere corretto?
Cooper

5
Il framerrate scende da 60 a 20 fps, solo a causa della memoria allocata? È molto improbabile, ci deve essere qualcosa di sbagliato. Che piattaforma è? Il sistema sta scambiando la memoria virtuale su disco?
Maik Semder,

6
@Drackir In questo caso direi che è sbagliato se esiste persino una classe di tile, dovrebbe fare una matrice di interi di lunghezza adeguata e quando c'è mezzo milione di oggetti, l'overhead di OO non è uno scherzo. Suppongo che sia possibile fare con gli oggetti, ma quale sarebbe il punto? Un array intero è morto semplice da gestire.
aaaaaaaaaaaa,

3
Ahia! Scorrere tutte le tessere e chiamare Disegna quattro volte su ognuna di quelle in vista. Ci sono sicuramente alcuni miglioramenti possibili qui ....
thedaian

11
Non è necessario tutto il partizionamento di fantasia di cui si parla di seguito per visualizzare solo ciò che è in vista. È una piastrella. È già partizionato in una griglia normale. Basta calcolare la tessera in alto a sinistra dello schermo e in basso a destra e disegnare tutto in quel rettangolo.
Blecki,

Risposte:


56

Stai eseguendo il loop tra tutte le 500.000 tessere durante il rendering? In tal caso, probabilmente causerà parte dei tuoi problemi. Se esegui il ciclo attraverso mezzo milione di riquadri durante il rendering e mezzo milione di riquadri quando esegui i segni di spunta 'aggiornamento' su di essi, allora esegui il ciclo attraverso un milione di riquadri per riquadro.

Ovviamente, ci sono modi per aggirare questo. È possibile eseguire i tick di aggiornamento mentre si esegue anche il rendering, risparmiando in tal modo metà del tempo trascorso a scorrere tutti quei riquadri. Ma questo collega il tuo codice di rendering e il tuo codice di aggiornamento in un'unica funzione, ed è generalmente una BAD IDEA .

È possibile tenere traccia delle tessere che si trovano sullo schermo e scorrere solo (e renderizzare) quelle. A seconda di cose come la dimensione delle tessere e le dimensioni dello schermo, ciò potrebbe facilmente ridurre la quantità di tessere che è necessario attraversare e ciò consentirebbe di risparmiare un po 'di tempo di elaborazione.

Infine, e forse l'opzione migliore (la maggior parte dei grandi giochi mondiali lo fanno), è dividere il terreno in regioni. Dividi il mondo in pezzi di, diciamo, tessere 512x512 e carica / scarica le regioni mentre il giocatore si avvicina o si allontana da una regione. Questo ti evita anche di dover passare attraverso tessere lontane per eseguire qualsiasi tipo di 'aggiornamento' tick.

(Ovviamente, se il tuo motore non esegue alcun tipo di aggiornamento tick sui riquadri, puoi ignorare la parte di queste risposte che menziona quelli.)


5
La parte della regione sembra interessante, se ricordo bene, è così che funziona Minecraft, tuttavia, non funziona con il modo in cui il terreno in Terraria si evolve anche se non ci si trova vicino ad esso, come la diffusione dell'erba, la coltivazione di cactus e simili. Modifica : a meno che, naturalmente, applichino il tempo trascorso dall'ultima volta in cui la regione era attiva, quindi eseguono tutte le modifiche che sarebbero avvenute in quel periodo. Potrebbe effettivamente funzionare.
William Mariager,

2
Minecraft usa sicuramente le regioni (e i successivi giochi di Ultima lo hanno fatto, e sono abbastanza sicuro che molti dei giochi di Bethesda lo facciano). Sono meno sicuro di come Terraria gestisce il terreno, ma probabilmente applicano il tempo trascorso a una "nuova" regione o memorizzano l'intera mappa in memoria. Quest'ultimo richiederebbe un elevato utilizzo della RAM, ma la maggior parte dei computer moderni potrebbe gestirlo. Potrebbero esserci anche altre opzioni che non sono state menzionate.
thedaian,

2
@MindWorX: fare tutti gli aggiornamenti quando si ricarica non funzionerà sempre - supponiamo che tu abbia un sacco di area sterile e poi erba. Vai via per anni e poi cammini verso l'erba. Quando il blocco con l'erba si carica, raggiunge e si diffonde in quel blocco ma non si diffonderà nei blocchi più vicini che sono già caricati. Tali cose procedono MOLTO più lentamente del gioco, però: controlla solo un piccolo sottoinsieme delle tessere in ogni ciclo di aggiornamento e puoi far vivere i terreni distanti senza caricare il sistema.
Loren Pechtel,

1
In alternativa (o supplemento) al "recupero" quando una regione si avvicina al giocatore, potresti invece avere una simulazione separata che scorre su ogni regione distante e aggiornandola a un ritmo più lento. Puoi riservare del tempo per questo ogni frame o farlo funzionare su un thread separato. Ad esempio, potresti aggiornare la regione in cui si trova il giocatore più le 6 regioni adiacenti ogni fotogramma, ma anche aggiornare 1 o 2 regioni extra.
Lewis Wakeford,

1
Per tutti coloro che si chiedono, Terraria memorizza i suoi dati di intero riquadro in un array 2d (la dimensione massima è 8400x2400). Quindi utilizza alcuni semplici calcoli matematici per decidere quale sezione delle tessere rendere.
FrenchyNZ,

4

Vedo un enorme errore qui non gestito da nessuna delle risposte. Ovviamente non dovresti mai disegnare e iterare su più tessere di cui hai bisogno. Ciò che è meno ovviamente è come si definiscono effettivamente le tessere. Come vedo che hai fatto una lezione di tessera, lo facevo sempre anche io, ma è un grosso errore. Probabilmente hai ogni sorta di funzioni in quella classe e questo crea molta elaborazione non necessaria.

Dovresti solo ripetere ciò che è veramente necessario da elaborare. Quindi pensa a ciò di cui hai effettivamente bisogno per le piastrelle. Per disegnare sono necessarie solo una trama, ma non si desidera scorrere su un'immagine reale poiché sono grandi da elaborare. Potresti semplicemente creare un int [,] o anche un byte senza segno [,] (se non ti aspetti più di 255 trame di tessere). Tutto quello che devi fare è iterare su questi piccoli array e usare un interruttore o un'istruzione if per disegnare la trama.

Quindi cosa devi aggiornare? Il tipo, la salute e il danno sembrano sufficienti. Tutti questi possono essere memorizzati in byte. Quindi perché non creare una struttura come questa per il ciclo di aggiornamento:

struct TileUpdate
{
public byte health;
public byte type;
public byte damage;
}

Puoi effettivamente usare il tipo per disegnare la tessera. Quindi potresti staccare quello (creane uno proprio) dalla struttura in modo da non scorrere i campi di salute e danni non necessari nel ciclo di estrazione. Ai fini dell'aggiornamento dovresti considerare un'area più ampia del tuo schermo in modo che il mondo di gioco sembri più vivo (le entità cambiano posizione fuori dallo schermo) ma per disegnare le cose hai solo bisogno della tessera che è visibile.

Se si mantiene la suddetta struttura ci vogliono solo 3 byte per riquadro. Quindi per motivi di risparmio e memoria questo è l'ideale. Per la velocità di elaborazione, non importa se si utilizza int o byte, o anche se si dispone di un sistema a 64 bit.


1
Sì, più tardi le iterazioni del mio motore si sono evolute per usarlo. Principalmente per ridurre il consumo di memoria e aumentare la velocità. I tipi ByRef sono lenti rispetto a un array riempito con strutture ByVal. Tuttavia è bene che tu l'abbia aggiunto alla risposta.
William Mariager,

1
Sì, ci siamo imbattuti in questo mentre cercavo alcuni algoritmi casuali. Immediatamente ti ho notato dove stai usando la tua classe di piastrelle nel tuo codice. È un errore molto comune quando sei nuovo. È bello vedere che sei ancora attivo da quando l'hai pubblicato 2 anni fa.
Madmenyo,

3
Non hai bisogno di uno healtho damage. È possibile mantenere un piccolo buffer delle posizioni delle tessere selezionate più di recente e il danno su ciascuna. Se viene selezionata una nuova tessera e il buffer è pieno, rotolare via la posizione più vecchia da essa prima di aggiungere quella nuova. Questo limita il numero di tessere che puoi estrarre alla volta, ma esiste comunque un limite intrinseco (approssimativamente #players * pick_tile_size). Puoi mantenere questo elenco per giocatore se ciò lo rende più semplice. Dimensioni non importa per la velocità; dimensioni ridotte significano più riquadri in ciascuna cache della CPU.
Sean Middleditch,

@SeanMiddleditch Hai ragione, ma non era questo il punto. Il punto era fermarsi e pensare a ciò di cui hai effettivamente bisogno per disegnare le tessere sullo schermo e fare i tuoi calcoli. Dato che l'OP utilizzava un'intera classe, ho fatto un semplice esempio, semplice fa molto nel progresso dell'apprendimento. Ora sblocca il mio argomento per la densità di pixel, per favore, sei ovviamente un programmatore, prova a guardare quella domanda con l'occhio di un artista CG. Dal momento che questo sito è anche per CG per i giochi afaik.
Madmenyo,

2

Esistono diverse tecniche di codifica che potresti utilizzare.

RLE: Quindi inizi con una coordinata (x, y) e poi conti quante tessere uguali esistono fianco a fianco (lunghezza) lungo uno degli assi. Esempio: (1,1,10,5) significherebbe che a partire dalla coordinata 1,1 ci sono 10 tessere affiancate del tipo 5.

L'array enorme (bitmap): ogni elemento dell'array si attacca al tipo di riquadro che risiede in quell'area.

EDIT: Ho appena incontrato questa eccellente domanda qui: funzione di seme casuale per la generazione di mappe?

Il generatore di rumore Perlin sembra essere una buona risposta.


Non sono davvero sicuro che stai rispondendo alla domanda che viene posta, dal momento che stai parlando di codifica (e rumore perlin), e la domanda sembra avere a che fare con problemi di prestazioni.
thedaian,

1
Usando la codifica corretta (come il rumore perlin) non puoi quindi preoccuparti di tenere tutte quelle tessere in memoria contemporaneamente .. invece chiedi semplicemente al tuo codificatore (generatore di rumore) di dirti cosa dovrebbe apparire in una data coordinata. C'è un equilibrio qui tra i cicli della CPU e l'utilizzo della memoria e un generatore di rumore può aiutare con quell'atto di bilanciamento.
Sam Axe

2
Il problema qui è che se stai realizzando un gioco che consente agli utenti di modificare il terreno in qualche modo, semplicemente usando un generatore di rumore per "memorizzare" tali informazioni non funzionerebbero. Inoltre, la generazione del rumore è piuttosto costosa, così come la maggior parte delle tecniche di codifica. È meglio salvare i cicli della CPU per le cose di gioco reali (fisica, collisioni, illuminazione, ecc.) Il rumore di Perlin è ottimo per la generazione del terreno e la codifica è buona per salvare su disco, ma in questo caso ci sono modi migliori per gestire la memoria.
thedaian,

Un'ottima idea, tuttavia, come il thedaian, l'utente interagisce con il terreno, il che significa che non riesco a recuperare matematicamente le informazioni. Guarderò comunque il rumore del perlin, per generare il terreno di base.
William Mariager,

1

Probabilmente dovresti partizionare il tilemap come già suggerito. Ad esempio con la struttura Quadtree per sbarazzarsi di qualsiasi potenziale elaborazione (ad esempio anche semplicemente looping) delle tessere non necessarie (non visili). In questo modo si elabora solo ciò che potrebbe essere necessario elaborare e aumentare le dimensioni del set di dati (mappa delle tessere) non comporta alcuna penalità di prestazione pratica. Naturalmente, supponendo che l'albero sia ben bilanciato.

Non voglio sembrare noioso o niente ripetendo il "vecchio", ma quando ottimizzi, ricorda sempre di usare le ottimizzazioni supportate dalla tua toolchain / compilatore, dovresti sperimentarle un po '. E sì, l'ottimizzazione prematura è la radice di tutti i mali. Fidati del tuo compilatore, lo sa meglio di te nella maggior parte dei casi, ma sempre, sempremisurare due volte e non fare mai affidamento sulle stime. Non si tratta di implementare rapidamente l'algoritmo più veloce purché non si sappia dove si trovi il collo di bottiglia. Ecco perché dovresti usare un profiler per trovare i percorsi (caldi) più lenti del codice e concentrarti sull'eliminazione (o sull'ottimizzazione). La conoscenza a basso livello dell'architettura di destinazione è spesso essenziale per eliminare tutto ciò che l'hardware ha da offrire, quindi studia quelle cache della CPU e scopri cos'è un predittore di filiali. Guarda cosa ti dice il tuo profiler su hit / miss della cache / branch. E come mostra una forma di struttura ad albero dei dati, è meglio avere strutture di dati intelligenti e algoritmi stupidi, piuttosto che il contrario. I dati vengono prima di tutto, quando si tratta di prestazioni. :)


+1 per "l'ottimizzazione prematura è la radice di tutti i mali" Sono colpevole di questo :(
thedaian

16
-1 un quadrifoglio? Un po 'eccessivo? Quando tutte le tessere hanno le stesse dimensioni in un gioco 2D come questo (che sono per terrari), è estremamente facile capire quale gamma di tessere sono sullo schermo - è solo l' estrema sinistra (sullo schermo) al tessere in basso a destra.
BlueRaja - Danny Pflughoeft,

1

Non si tratta di troppi call call? Se si inseriscono tutte le trame delle tessere delle mappe in un'unica immagine - atlante delle tessere, durante il rendering non si verificherà alcun cambio di trama. E se si raggruppano tutte le tessere in una singola maglia, è necessario pescarle in una chiamata di sorteggio.

A proposito della pubblicità dinamica ... Forse Quad Tree non è una cattiva idea. Supponendo che le tessere vengano messe in foglia e che i nodi non foglia siano solo maglie in batch dai suoi bambini, root dovrebbe contenere tutte le tessere raggruppate in una mesh. La rimozione di un riquadro richiede aggiornamenti dei nodi (mesh rebatching) fino alla radice. Ma a ogni livello di albero c'è solo un quarto della mesh rigettata che non dovrebbe essere così tanto, 4 * tree_height mesh si unisce?

Oh, e se usi questo albero nell'algoritmo di ritaglio, visualizzerai non sempre il nodo root ma alcuni dei suoi figli, quindi non devi nemmeno aggiornare / rinviare tutti i nodi fino a root, ma fino al nodo (non foglia) che sei rendering al momento.

Solo i miei pensieri, nessun codice, forse la sua assurdità.


0

@arrival ha ragione. Il problema è il codice di disegno. Stai costruendo un array di 4 * 3000 + comandi di disegno quad (oltre 24000 comandi di disegno poligono ) per fotogramma. Quindi questi comandi vengono elaborati e reindirizzati alla GPU. Questo è piuttosto male.

Ci sono alcune soluzioni

  • Rendi blocchi di grandi dimensioni (ad esempio, le dimensioni dello schermo) di tessere in una trama statica e disegnalo in una sola chiamata.
  • Usa gli shader per disegnare i riquadri senza inviare la geometria alla GPU ogni fotogramma (ad esempio, memorizza la mappa dei riquadri sulla GPU).

0

Quello che devi fare è dividere il mondo in regioni. La generazione del terreno del rumore di Perlin può usare un seme comune, in modo che anche se il mondo non è pregenerato, il seme farà parte dell'algo del rumore, che giunge bene il terreno più nuovo alle parti esistenti. In questo modo, non è necessario calcolare più di un piccolo buffer prima della visualizzazione dei giocatori alla volta (alcune schermate attorno a quella corrente).

In termini di gestione di cose come piante che crescono in aree molto lontane dalla schermata corrente dei giocatori, puoi avere dei timer per esempio. Questi timer dovrebbero scorrere i file che memorizzano informazioni sugli impianti, la loro posizione, ecc. Devi semplicemente leggere / aggiornare / salvare i file nei timer. Quando il giocatore raggiunge di nuovo quelle parti del mondo, il motore legge normalmente i file e presenta sullo schermo i dati dell'impianto più recenti.

Ho usato questa tecnica in un gioco simile che ho realizzato l'anno scorso, per la raccolta e l'agricoltura. Il giocatore poteva camminare lontano dai campi e, al ritorno, gli oggetti erano stati aggiornati.


-2

Ho pensato a come gestire tanti miliardi di blocchi e l'unica cosa che mi viene in mente è il Flyweight Design Pattern. Se non lo sai, ti consiglio vivamente di leggerlo: potrebbe aiutare molto quando si tratta di risparmiare memoria ed elaborazione: http://en.wikipedia.org/wiki/Flyweight_pattern


3
-1 Questa risposta è troppo generica per essere utile e il link fornito non risolve direttamente questo particolare problema. Il modello Flyweight è uno dei molti, molti modi per gestire in modo efficiente set di dati di grandi dimensioni; potresti approfondire come applicare questo schema nel contesto specifico della memorizzazione di tessere in un gioco simile a Terraria?
postgoodism
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.