Ciao a tutti, questo è il primo post, cercherò di scriverlo al meglio dando il maggior numero di dettagli possibili.
Premessa: recentemente con il mio team abbiamo deciso di iniziare a lavorare nel tempo libero ad un videogioco per pc con concept principale il tempo. L'utente sarà in grado di varcare fratture spazio-temporali e comparire in differenti locazioni in un diverso momento del tempo di un pianeta. Avendo poco tempo libero (lavoriamo tutti), abbiamo deciso di utiizzare Unity 3D v5.6 e scriptare in C# (a lavoro programmo in quel linguaggio) così da poter dedicare maggior tempo allo studio dei componenti e delle soluzioni anziché allo studio di un linguaggio di programmazione. Tagliando verso le varie domande che vorrei sottoporvi e, visto il design, abbiamo deciso di avere 3 scene principali nel gioco: una inizia, una finale ed una centrale nella quale il mondo verrà generato proceduralmente e non avrà limiti (infinito).
Attualmente, ho più che altro sperimentato e studiato cosa il dio google avesse da offrire, e sono giunto al seguente stato (tenete conto che non riesco a lavorarci più di due ore a settimana, e ci lavoro da circa 3 settimane):
- Implementati circa 15 differenti funzioni di noise seamless
- Implementati circa 10 differenti modificatori
- Generazione di heightmap infinita
- Un semplice sistema di LOD in grado di decrementare il numero di vertici e triangoli di un appezzamento di terreno
- Un semplice sistema di culling in grado di disabilitare/abilitare gli appezzamenti di terreno sia per distanza dal giocatore sia per angolazione rispetto alla telecamera del giocatore
- Generazione della mesh interpretando la heightmap
Mancano ancora, per quanto sia abbastanza semplice farlo, rendere asincrone la generazione della heightmap e la generazione della mesh, e non fare tutto sulla CPU.
Dettagli: per quanto concerne l'attuale architettura di queste sperimentazioni, posso dire (tagliando il superfluo) che le classi principali in questione siano poche, le trovate sotto con un po' di pseudo codice misto a codice:
- BaseNoise, classe base astratta per creare i vari noise on top di questa
public abstract class BaseNoise
{
public abstract float CalculateValue(float coordX, float coordY, float coordZ);
}
- BaseFractalNoise, classe derivata da BaseNoise che espone in più un metodo astratto per sommare le ottave di un eventuale DerivedFractalNoise
public abstract class BaseFractalNoise : BaseNoise
{
public override float CalculateValue(float coordX, float coordY, float coordZ)
{
for (int octaveIndex = 0; octaveIndex < this.octavesCount; octaveIndex++)
{
Calcola sample in <x, y, z>
Combina per ottenere il fractal value
}
return sampledValue;
}
protected abstract float CombineOctavesValues(int octaveNumber, float sampledValue, float fractalValue);
}
- Derivati vari di BaseNoise e BaseFractalNoise, tra i quali (i più degni di nota):
a. Perlin Noise
b. Fractal Brownian Noise
c. Ridge Noise,
d. Billow Noise,
e. Lattice Noise
f. Voronoi (celle, fosse, valli)
g. Math Noise (accetta un delegato per generare un noise custom)
h. Turbulence e CustomTurbulence Noise, che prendono in ingresso un altro noise ed hanno come risultato il sample del noise con applicata la turbolenza
i. ecc.....
Ogni noise implementato è seamless, affetto da frequenza, persistenza, lacunarità e sono seedati. Il calcolo di una heightmap nel mio progetto avviene all'interno di 3 cicli for: uno per la coordinata X, uno per la coordinata Y/Z (come vi piace di più) ed uno per il Noise. Scelta bizzarra, giustificata dal fatto che nella mia roadmap c'è l'implementazione di una custom window che mi permetterà di combinare facilmente più noise tra loro ed avere una preview. Dopo aver sommato tutti i noise, e conoscendo la massima altezza e la minima depressione del mondo (scelte nell'editor), normalizzo la heightmap. Pseudocodice:
private float [,] GenerateChunkHeightmap
{
for (int generatorIndex = 0; generatorIndex < noiseGeneratorsCount; generatorIndex++)
{
for (int sampleY = 0; sampleY < chunkSize; sampleY++)
{
for (int sampleX = 0; sampleX < chunkSize; sampleX++)
{
// Something like... chunkHeightmap[sampleX, sampleZ] += generator.CalculateValue();
}
}
}
for (int mapY = 0; mapY < chunkSize; mapY++)
{
for (int mapX = 0; mapX < chunkSize; mapX++)
{
chunkHeightmap[mapX, mapY] = chunkHeightmap[mapX, mapY] + Mathf.Abs(worldMinHeight)) / (worldMaxHeight + Mathf.Abs(worldMinHeight);
}
}
}
Questo per quanto riguarda i noise, per quanto concerne la generazione del mondo, nonostante un mese circa di ricerca, non sono riuscito a trovare nulla di soddisfacente che mi permettesse di avere una generazione infinita con la possibilità di integrare facilmente fiumi e laghi. Tutto ciò che ho trovato a riguardo mi obbligava a generare in anticipo la heightmap di tutto il terreno di gioco quindi ho provato ad inventarmi qualcosa per poterli avere di una qualità accettabile. Dalla mia follia ho pensato di generare il mondo a "chunk di chunk", ovvero generare il mondo a blocchi giganti di heightmap, e poi spezzarle dopo vari post-processing. Nelle seguenti classi chiamerò "Tile" un singolo appezzamento di terreno (contenente la mesh) di dimensione NxN e "Chunk" un contenitore di MxM tiles. Di seguito le classi che partecipano alla generazione del mondo:
- WorldManager, singleton responsabile della gestione dei mondi generati. Quando inizializzato, calcola un massimo numero di chunk visibili dal giocatore per usare culling e LOD (citati all'inizio), nell'update prende la posizione del giocatore per o creare nuove chunk o aggiornare la loro visibilità/LOD. Internamente, mantiene una rappresentazione "stile griglia" delle chunk attualmente generate.
public class WorldManager
{
/// <summary>
/// List of all the noise generators contributing to world generation.
/// </summary>
private List<Noise> worldGenerationNoiseGenerators;
/// <summary>
/// Container for all the generated world <see cref="TEST_WorldChunk"/>s.
/// </summary>
public Dictionary<Vector2, TEST_WorldChunk> worldChunksData;
... Implements Singleton Pattern.
void Start()
{
Inizializza possibili LOD, massima distanza del giocatore, massimo indice di chunk visibile, ecc...
}
void Update()
{
Aggiorna reference alla posizione del giocatore
Calcola posizione del giocatore rispetto alla griglia di chunk
Loop su tutte le chunk tra < -MaxVisibleChunks, MaxVisibleChunks >
{
If.... Ho già generato questa chunk?
Se si... Aggiorno i suoi dati
Se no... Genero la chunk
}
}
}
- WorldChunk, classe che rappresenta un appezzamento di terreno paragonabile ad uno stato (per fare capire, rispetto alla Tile che potrebbe essere considerata una regione). Al suo interno manterrà i dati di ogni Tile con una griglia simile a quella del WorldManager, ma in coordinate locali rispetto a se stessa, quindi ogni Tile al suo interno sarà reperibile in coordinate globali sommando le coordinate della chunk più le sue, ovviamente con i giusti calcoli. Al suo interno manterrà i dati di ogni Tile e sarà responsabile di
a. Generare la heightmap
b. Generare i network dei fiumi, strade, ecc...
c. Generare laghi
d. Generare splatmap
e. Ecc...
f. Creare o aggiornare le sue Tile
public class WorldChunk
{
/// <summary>
/// Container for all the generated chunks' <see cref="TEST_WorldTile"/>.
/// </summary>
public Dictionary<Vector2, TEST_WorldTile> chunkTilesData;
/// <summary>
/// Defines the entire heightmap for this chunk;
/// </summary>
private float[,] chunkHeightmap;
public WorldChunk(int numeroDiTilesPerChunk, int dimensioneTile, ecc...)
{
Costruttore:
Crea griglia di tiles da me gestite
Produce i punti a, b, c, d, e sopra citati
Loop... per ogni Tile che devo generare
Invoca il costruttore di ogni tile da me gestita, passandogli le varie mappe parziali
}
public void UpdateMe()
{
If... Se sono interamente visibile dal giocatore
Se si... Chiamo l'update delle tiles nella mia griglia
Se no... Disabilito me e le mie tiles.
}
}
- WorldTile, classe che rappresenta il vero e proprio appezzamento di terreno, contenente la mesh finale. Responsabile solo di generare la propria mesh, la propria vegetazione, i propri edifici, ecc...
public class WorldTile()
{
/// <summary>
/// This tile generated heightmap.
/// </summary>
public float[,] worldTileHeightmap;
public WorldTile(int dimensioneTile, float[,] laMiaHeightmapParziale, float[,] ilMioRiverNetwork, ecc...)
{
Genero la mia mesh
Genero i miei edifici
Genero i miei fiumi
Genero la mia vegetazione
Ecc....
}
public void UpdateMe()
{
If... Se sono interamente visibile dal giocatore
Se si... Mi abilito, e con me tutti i miei child GameObject, come la mesh, la vegetazione, gli edifici, ecc...
Se no... Mi disabilito, e con me tutti i miei child GameObject, come la mesh, la vegetazione, gli edifici, ecc...
}
}
Quesiti: dette queste noiosissime cose, data la premessa, ed ipotizzando che sia tutto ottimizzato (cosa che non è ma immaginiamo di si), vorrei, prima di procedere con il codice, sapere se mi sto muovendo nella direzione giusta, e, in vista del fatto che vorrei poter passare alla GPU, tramite Compute Shader, la generazione della heightmap/ecc... sapere se l'architettura che ho pensato è semanticamente corretta a parer vostro. Non conosco ancora l'HLSL, il motivo principale di questa domanda è proprio il ricevere un parere di qualcuno che possa indirizzarmi nella migliore direzione per architetturare il codice così da poter massimizzare il guadagno prestazionale per la generazione dei dati. Per farvi capire quanto sia importate massimizzare la potenza di calcolo, come ho detto sopra, il giocatore sarà in grado di attraversare delle fratture temporali che lo sposteranno nel mondo sia di posizione sia di tempo, quindi mi servirà ricreare tutte le chunk e le tiles visibili il più velocemente possibili.
EDIT 1:
Ho implementato una custom threadpool con prioritizzazione così da poter generare i dati per le varie chunk/tiles in ordine di inserimento e di importanza. Per esempio, generare una mesh di una tile è più importante di generare una heightmap di una chunk, quindi se dovessero essere richieste insieme, prima verrebbe processata la mesh e poi la heightmap.
EDIT 2:
Attualmente il codice che ho prodotto consuma troppa memoria RAM. Usanto chunks da 61x61 tiles, con ogni tile da 250 unità/metri, ed una view distance del giocatore di 7000 unità/metri, Unity si porta via 6GB di RAM. Attualmente non genero ancora alcuna mesh, quindi tutto è occupato dalle heightmap che storo nelle varie classi. Ogni chunk possiede la heightmap di tutte le 61x61 tiles e le tiles hanno una loro copia locale per generare la mesh (todo). Non mi sembra il modo corretto di affrontare il problema, anche perché non ho ancora generato i river network, texture splatmaps, lightmaps, collision mesh, ecc.... E se già adesso ho questo memory footprint non andrò da nessuna parte. Qualche consiglio?
Ringrazio tutti del tempo perso per leggere questo post.
Sebastian