Progetto del corso di Computer Graphics, UniBo (A.A. 2025/2026)
Fabio Murer 0001237576
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.
Il codice del progetto adotta il paradigma di programmazione a moduli JavaScript. Le risorse sono suddivise come segue:
assets/: Contiene i modelli tridimensionali in formato
.obj (corpo e pinne del pesce), le texture per il
terreno e il protagonista (come tiger_rock_512.png e la
relativa normalmap.png) e le icone per l'interfaccia
utente touch.
index.html: Punto di ingresso dell'applicazione. Definisce la struttura del
DOM, i canvas sovrapposti e ospita il codice sorgente GLSL dei
Vertex e Fragment Shader.
js/: Contiene tutta la logica JavaScript dell'applicazione,
organizzata nelle seguenti categorie:
marchingcubes.js e
marchingcubes_tables.js (implementazione
dell'algoritmo Marching Cubes),
perlinnoise.js (rumore di Perlin 3D),
density.js (definizione della funzione di densità
geometrica), world-worker.js (Web Worker per il
calcolo asincrono dei chunk) e world.js (gestione
dei buffer GPU e rendering della mesh).
fishy.js (gestione del personaggio, del caricamento
mesh, dei movimenti e delle animazioni procedurali delle pinne)
e camera.js (gestione della camera prospettica in
terza persona).
menu.js (implementazione del menu di configurazione
iniziale) e hud.js (Heads-Up Display con le
coordinate e i controlli per dispositivi mobili).
deviceinfo.js (rilevamento del tipo di dispositivo
desktop o mobile), globalgl.js (gestione
centralizzata del contesto WebGL) e la sottocartella
lib/ contenente librerie matematiche e di utilità
(m4.js per le matrici e
webgl-utils.js per la compilazione degli shader).
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.
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.
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.
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.
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:
W, la pinna posteriore incrementa la frequenza di oscillazione.
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.
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:
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.
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.
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:
#my_Canvas, z-index: 1):
Adibito al rendering della scena 3D.
#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.
#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).
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:
gl.DEPTH_TEST): Abilitato
con funzione di confronto gl.LEQUAL per scartare a
livello di hardware tutti i frammenti nascosti da geometrie
posizionate davanti ad essi.
gl.CULL_FACE): Impostato
per scartare le facce posteriori dei poligoni
(gl.BACK). WebGL evita di processare ed eseguire il
rasterizzatore sui triangoli interni o non rivolti verso il punto di
vista della camera.
Di seguito sono elencati gli articoli, le librerie e le guide tecniche utilizzate come punto di partenza e riferimento per lo sviluppo del progetto: