Marching Deep

Progetto del corso di Computer Graphics, UniBo (A.A. 2025/2026)

Fabio Murer 0001237576

1. Introduzione

Il progetto Marching Deep nasce con l'obiettivo di esplorare e sperimentare le tecniche di generazione procedurale di scenari tridimensionali in tempo reale. L'applicazione ricrea un ambiente marino infinito generato proceduralmente, caratterizzato da un fondale oceanico ricco di grotte, tunnel e canaloni sommersi. Questo scenario è esplorabile dall'utente in terza persona nei panni del protagonista, un pesce chiamato Fishy.

Questo lavoro è stato fortemente ispirato dall'articolo di NVIDIA Developer Gems: "Generating Complex Procedural Terrains Using the GPU" (di Ryan Geiss). L'architettura del progetto mette in atto questi concetti semplificandone l'implementazione utilizzando la CPU per la generazione del terreno.

game
Vista del mondo di gioco

2. Struttura del Progetto

Il codice del progetto adotta il paradigma di programmazione a moduli JavaScript. Le risorse sono suddivise come segue:

3. Generazione del Mondo

La mesh geometrica del fondale marino viene generata proceduralmente in tempo reale tramite l'algoritmo di poligonizzazione Marching Cubes. Lo spazio tridimensionale viene suddiviso in una griglia regolare di punti discreti (voxel). In corrispondenza di ciascun vertice del voxel viene valutato un valore di densità scalare ottenuto tramite funzioni matematiche di rumore. L'algoritmo confronta tale valore con una soglia prefissata (Iso-level) per determinare l'indice di configurazione del cubo (0-255) e, tramite tabelle di look-up precalcolate, genera i triangoli che compongono la superfice interna.

Rispetto alla versione classica dell'algoritmo, l'implementazione è stata estesa introducendo il calcolo del gradiente della densità in ogni vertice. Questo gradiente, opportunamente normalizzato, restituisce il vettore normale della superficie, elemento indispensabile per applicare correttamente i modelli di illuminazione locale e il normal mapping nel fragment shader.

Funzione di Densità e Rumore di Perlin

La morfologia dello scenario è generata tramite l'algoritmo del Rumore di Perlin Tridimensionale (basato sull'algoritmo standard di Ken Perlin). Per produrre strutture complesse come fratture, grotte e gallerie ramificate, vengono sovrapposti molteplici strati di rumore (ottave) a frequenze e ampiezze differenti, combinati matematicamente (regolabili nel menu tramite i parametri Scale, Octaves e Gain). Per ragioni di gameplay, la funzione di densità è stata vincolata e modificata in altezza: comprimendo l'intervallo di generazione verticale creando un fondo oceanico piatto.

game
Esempio di rumore di Perlin 2D

Triplanar Texture Mapping e Normal Mapping

Poiché il terreno viene generato in modo completamente procedurale assumendo forme complesse e cave, l'applicazione di coordinate UV standard causerebbe evidenti artefatti di stiramento e distorsione della texture. Per risolvere radicalmente il problema è stata implementata la tecnica del Triplanar Texture Mapping nel fragment shader. La texture della roccia (tiger_rock_512.png) viene campionata tre volte proiettandola lungo i tre piani principali dello spazio ($X, Y, Z$). Il colore finale del pixel è ottenuto calcolando una media pesata dei tre campionamenti, dove i pesi sono determinati dal valore assoluto dei componenti del vettore normale del frammento.

Per accrescere il realismo dello scenario roccioso, alla tecnica triplanare è stata integrata la pipeline di Normal Mapping. Una mappa delle normali (normalmap.png) viene campionata in modo triplanare per estrarre vettori di perturbazione, modificando la normale geometrica prima del calcolo dell'illuminazione, conferendo alla roccia un aspetto rugoso e dettagliato sensibile ai cambiamenti di luce.

game
Generazione del terreno Octaves 1, Scale 0.05, Gain 0.1: spazi aperti
game
Generazione del terreno Octaves 2, Resolution 64: geometrie armoniose
game
Generazione del terreno Octaves 3, Gain 1: geometrie complesse
game
Texture utilizzata: tiger_rock_512
game
Bump Texture utilizzata

4. Generazione Infinita del Mondo tramite Chunk e Web Worker

Per garantire l'illusione di un mondo oceanico infinito senza saturare le risorse hardware, lo spazio di gioco è stato suddiviso in blocchi cubici indipendenti detti Chunk (ciascuno di dimensione pari a $32 \times 32 \times 32$ unità). L'applicazione mantiene attivi e renderizza solo un sottoinsieme locale di chunk disposti attorno al giocatore: il chunk centrale che ospita il pesce e gli 8 chunk immediatamente adiacenti (regolati dal parametro CHUNK_RADIUS = 1).

Durante il nuoto di Fishy, il superamento dei confini di un chunk innesca un aggiornamento dinamico della griglia (scorrimento). I chunk rimasti troppo indietro vengono rimossi dalla memoria WebGL liberando i relativi buffer GPU (gl.deleteBuffer), mentre le nuove porzioni di terreno davanti alla traiettoria del pesce vengono create e caricate. Per prevenire cali improvvisi di frame rate causati dall'oneroso calcolo del Marching Cubes sulla CPU, tutta la logica di generazione dei vertici e delle densità è stata delegata a un thread separato in background tramite un Web Worker (world-worker.js). La comunicazione asincrona tramite messaggi (postMessage) permette di caricare i nuovi chunk in modo asincrono, mantenendo l'esperienza di gioco fluida.

5. Fishy, il Protagonista: Modello e Animazioni Procedurali

Il protagonista del gioco, Fishy, è un modello tridimensionale composto da quattro parti distinte caricate e assemblate separatamente: il corpo centrale (body), le due pinne laterali simmetriche (left fin e right fin, ottenute duplicando e specchiando lo stesso modello geometrico) e la pinna posteriore (rear fin). I modelli poligonali sono stati interamente modellati e texturizzati da zero in Blender ed esportati in formato .obj.

I modelli vengono importati tramite la libreria di utilità load2_mesh.js, la quale è stata modificata rimuovendo le variabili globali per incapsularla correttamente all'interno del sistema a moduli JavaScript ES6 dell'applicazione.

Per rendere i movimenti del pesce naturali e realistici, sono state implementate delle animazioni procedurali basate sul tempo (fishy.js). Il movimento ondulatorio delle pinne viene calcolato tramite funzioni sinusoidali (Math.sin). L'ampiezza e la frequenza di oscillazione sono modificate secondo lo stato dei comandi dell'utente:

game
Fishy, fronte
game
Fishy, retro
game
Texture utilizzata

6. Camera e Punto di Vista

Il sistema adotta una camera prospettica in terza persona che insegue il personaggio principale da dietro. La camera è caratterizzata da un campo visivo di 60 gradi. La posizione spaziale della camera viene calcolata dinamicamente ad ogni frame combinando la posizione corrente del pesce, l'inverso del suo vettore di orientamento (moltiplicato per una distanza di inseguimento configurabile) e un offset verticale sulla coordinata $Y$. La direzione di vista è costantemente orientata verso il pesce sfruttando la matrice di trasformazione lookAt.

Grazie a questa configurazione matematica, quando il giocatore ruota il pesce nello spazio, la camera ruota a sua volta mantenendo la corretta distanza dal soggetto. Il sistema offre due comandi per modificare la vista: la rotella del mouse permette di regolare dinamicamente la distanza dal pesce (zoom), mentre premendo la barra spaziatrice (o l'apposito pulsante touch su mobile) è possibile invertire la vista portando la camera sul fronte del pesce.

game
Camera Distante dal soggetto
game
Camera Vicino al soggetto

7. Illuminazione e Shading di Phong

Per dare profondità alla scena sottomarina è stato implementato il modello di illuminazione locale di Phong con calcolo dei parametri effettuato per ogni singolo frammento. Questo garantisce riflessi speculari morbidi e sfumature accurate sulla superficie del pesce e sulle pareti rocciose, calcolate nel fragment shader tramite la normale interpolata.

L'equazione di illuminazione combina tre componenti distinte:

  1. Componente Ambientale ($K_a$): Rappresenta la luce soffusa riflessa dall'ambiente, impostata a un coefficiente basso ($0.2$) per evitare che le zone d'ombra siano completamente nere.
  2. Componente Diffusa ($K_d$): Calcolata tramite la riflessione lambertiana (prodotto scalare tra la direzione della luce e la normale del frammento), determina l'illuminazione di base delle superfici esposte alla sorgente.
  3. Componente Speculare ($K_s$): Modella il riflesso lucido dei materiali, calcolato in base all'angolo tra il vettore di riflessione della luce e la direzione della camera, pesato da un fattore di lucentezza (shininess pari a $32.0$).

La sorgente luminosa è di tipo puntiforme ed è posta in corrispondenza della camera. In questo modo la luce si sposta insieme al punto di vista, illuminando sempre lo spazio di fronte all'osservatore.

game
Mondo senza illuminazione
game
Mondo con illuminazione

8. Simulazione dell'Acqua tramite Nebbia Lineare

L'effetto di immersione sottomarina è stato completato introducendo una nebbia lineare (Fog) volumetrica di colore blu. La nebbia è calcolata all'interno del fragment shader sfruttando la coordinata di profondità gl_FragCoord.z. Tramite la funzione GLSL smoothstep applicata tra un piano di nebbia vicino (fogNear = 0.85) e uno lontano (fogFar = 1.0), viene ricavato il fattore di interpolazione fogAmount:

fogAmount = smoothstep(fogNear, fogFar, gl_FragCoord.z);

Il colore finale del pixel viene miscelato tramite la funzione mix() tra il colore risultante dal modello di Phong e il colore blu puro della nebbia. Configurando il colore di pulizia del background WebGL (gl.clearColor) con la medesima tonalità della nebbia, i chunk di terreno che vengono caricati o distrutti in lontananza risultano mascherati dall'oscurità blu dell'oceano.

game
Grazie alla nebbia si ha un effetto sottomarino

9. Interfaccia Utente e Menu 2D Multi-Canvas

L'architettura grafica dell'interfaccia utente è strutturata sovrapponendo tre livelli di canvas HTML5 indipendenti gestiti tramite la proprietà CSS position: absolute e coordinate di profondità z-index:

  1. Canvas WebGL (#my_Canvas, z-index: 1): Adibito al rendering della scena 3D.
  2. Canvas HUD (#hud_Canvas, z-index: 5): Canvas 2D trasparente che ospita le scritte informative in tempo reale (come le coordinate tridimensionali di Fishy) e l'interfaccia dei pulsanti grafici per i comandi touch. Rimane invisibile nel menu e si attiva durante il gioco.
  3. Canvas Menu (#menu_Canvas, z-index: 10): Canvas 2D dedicato al menu principale. Mostra i controlli di configurazione iniziali e blocca il ciclo di gioco quando attivo.

Il menu di gioco e l'HUD sono stati programmati da zero adottando il paradigma ad oggetti, creando pulsanti e label riutilizzabili. Il menu principale permette di variare dinamicamente i parametri di configurazione del rumore del mondo. Al click su un pulsante, viene invocata la funzione rebuildWorld() che svuota la memoria video dei vecchi chunk e rigenera l'intera mappa. Il layout calcola le distanze in modo proporzionale alla grandezza dello schermo (grazie alle informazioni di deviceinfo.js).

game
Menu di gioco
game
Interfaccia per dispositivi touch

10. Ottimizzazioni Grafiche e Prestazioni

Per massimizzare l'efficienza della pipeline grafica WebGL e mantenere un aggiornamento fluido a 60 FPS stabili, sono state attivate e configurate le seguenti ottimizzazioni hardware:

11. Risorse e Riferimenti

Di seguito sono elencati gli articoli, le librerie e le guide tecniche utilizzate come punto di partenza e riferimento per lo sviluppo del progetto: