Home Blog

Ho chiesto a ChatGPT di creare il game design di un videogioco e l’ho sviluppato in Unity – Scarica gratis il Game Design Document (GDD) e la demo di Cubettiny

L’idea di gioco: Cubettiny

Lavoro nell’ambito dello sviluppo da diversi anni ed ho sempre avuto la passione per i videogiochi, con la propensione al loro sviluppo. Ho creato alcuni giochi per desktop e mobile, tra cui GGWG e Hellpath ma da qualche tempo avevo in testa l’idea di creare un gioco basato su cubi e su una meccanica particolare. Circa 4 anni fa ne avevo anche fatto uno stage demo che potete vedere qui. L’idea alla base è quella di riuscire a fare 0 punti nel livello prendendo più cubi possibili. Purtroppo però l’idea stessa di game design, da una attenta analisi, soffriva di alcuni problemi di posizionamento strategico dei cubi che rendevano il gioco banale ma anche frustrante e pressoché ingiocabile.

L’unica cosa certa era il nome del gioco, che ribattezzai Cubettiny.

L’intelligenza artificiale di ChatGPT e l’approccio usato

In tempi recenti ChatGPT e l’intelligenza artificiale in generale hanno iniziato a fare passi da gigante e mi sono quindi chiesto se fosse stato possibile spiegare alla AI la mia idea di Cubettiny e chiederle di sviluppare un Game Design completamente diverso ma che avesse le peculiarità della mia idea di base.

L’obiettivo, provocatorio, è quello di utilizzare questi strumenti per creare la parte che normalmente è sviluppata da esseri umani cercando di capire se quello che viene proposto è valido a livello di giocabilità, divertimento e sfida. Realizzandolo.

Ho quindi passato circa 3 settimane a lavorare con ChatGPT dandogli davvero poche informazioni sulla mia idea ma chiedendogli invece dettagliatamente come avrebbe sviluppato il gioco. Il mio approccio è stato quello di chiedere differenti versioni di meccaniche e strategie ma poi domandare alla AI quale per lei fosse la migliore in termini di giocabilità e scremare di volta in volta le meccaniche arrivando a punti concreti e definiti. E’ stato un processo complesso e preciso che ha richiesto diverso training nella conversazione con l’intelligenza artificiale e con diverse richieste di riassunto delle meccaniche e conseguenti modifiche basate sulle sue scelte. Ogni qualvolta è servita una lista di caratteristiche ho sempre chiesto più possibilità, le ho fatte valutare e dunque scegliere in modo più raffinato.

Ho aggiunto di mio pugno davvero poche scelte di Game Design e quando l’ho fatto ho sempre chiesto a ChatGPT se fossero valide, quali fossero i punti deboli, i punti di forza e rimandi a giochi commerciali per verificarle.

Soddisfatto di quanto ottenuto grazie alla intelligenza artificiale, ho proseguito nella seconda fase del progetto: la stesura di un documento tecnico.

Creazione del Game Design Document

Nel mondo videoludico il GDD (Game Design Document) è un documento che specifica diverse aree riguardanti il gioco e ne definisce in modo preciso ed articolato le meccaniche, gli approcci, le scelte di marketing e le attività da svolgere. E’ considerato la “bibbia” riguardo al gioco in sviluppo e “dovrebbe” essere seguito alla lettera durante il suo sviluppo tecnico.

Ho chiesto quindi alla intelligenza artificiale di realizzare, man mano che la conversazione prendeva forma, un documento utile allo scopo diviso in diverse aree, nello specifico:

  • Introduzione al gioco
  • Nomi del protagonista
  • Caratteristiche
  • Target di gioco
  • Sistema di monetizzazione
  • Il Gameplay
  • Livelli di gioco (idee)
  • Personalizzazione del giocatore
  • Achievements
  • Fasi di sviluppo e timeline

Ne è uscito un documento di circa 12 pagine che comprende le diverse aree di un GDD e definisce di fatto la struttura del gioco, il suo gameplay e le scelte di marketing da utilizzare… completamente scritto e generato dalla intelligenza artificiale di ChatGPT.

Ho pensato quindi di rilasciare gratuitamente questo documento, frutto di diverse ore di lavoro e training con l’obiettivo di dimostrare come l’intelligenza artificiale possa essere messa al servizio dello sviluppo di videogiochi, in questo caso in modo intensivo e a tratti forzato.

Qui sotto è possibile scaricare il GDD di Cubettiny aggiornato.

Sviluppo della demo di gioco in Unity

Arrivato a questo punto del progetto, da programmatore mi sono chiesto come fosse possibile testare effettivamente la validità delle meccaniche di gioco generate dalla intelligenza artificiale. Per questo motivo ho pensato di realizzare una DEMO funzionante di Cubettiny attraverso l’uso di Unity (motore grafico che uso professionalmente).

L’approccio grafico l’ho preso in prestito dal gioco Bleak Sword DX e dai suoi livelli in stile diorama.

Prototipo early stage di Cubettiny, 2024

Chiaramente il GDD non riporta nel dettaglio il flusso di schermate e menu di gioco ed in questi casi mi sono preso la libertà di svilupparli a piacimento (ed in versione molto semplificata) con l’intenzione che fossero unicamente di supporto all’uso della dimostrazione.

Qui sotto è possibile trovare il link a itch.io dove scaricare gratuitamente la DEMO contenente uno stage finito di Cubettiny. La mia intenzione è quello di aggiornarlo man mano anche grazie ai feedback ricevuti.

Considerazioni finali sul progetto

Immagino che qualche game designer possa storcere il naso nel comprendere questo mio progetto personale che invece andrebbe visto come un mero esercizio di stile, una forzatura al sistema creativo delle intelligenze artificiali e un modo per valutare un nuovo alleato nella fase di stesura del game design di un videogame.

Chiaramente è semplice pensare come, tra qualche anno, le meccaniche di gioco possano essere scritte completamente da una AI così come l’implementazione stessa del gioco, magari direttamente dentro l’editor di sviluppo. E’ altrettanto semplice pensare come figure del mondo videoludico possano prematuramente sparire (game designer, game developer, etc) o subire un radicale ridimensionamento.

L’intento del progetto è invece quello di mettere all’attenzione degli addetti ai lavori questi strumenti artificiali non come una minaccia alla propria professione ma come un vero alleato che, volenti o nolenti, stanno entrando nella vita quotidiana e professionale di tutti noi. Sono dell’idea che non debbano fare paura ma che invece debbano essere presi in considerazione sia nelle fasi creative che in quelle più tecniche ed ottenere da loro suggerimenti, idee, punti di vista, informazioni, dettagli, possibilità e analisi.

In un commento sul mio account Bluesky riguardo ad alcuni screenshot di Cubettiny, è stato scritto “You got the AI to do the human side and you took the robot side”. Seppur detto in tono critico, il commento racchiude esattamente il senso del mio esperimento.

…e Cubettiny è divertente? Merita di essere sviluppato ulteriormente?

Come giocare offline un gioco sviluppato con Phaser 3

Durante lo sviluppo di un gioco tramite il framework Javascript Phaser 3 ci si può domanda come poter rendere disponibile il proprio progetto anche durante l’assenza di connessione (si pensi a momenti di down del servizio oppure la modalità aerea del proprio dispositivo mobile).

Di seguito quindi la procedura per sfruttare la potenzialità del Service Worker di Javascript per cachare le risorse durante il primo avvio (con connessione attiva) e rendere così disponibili il gioco anche offline.

Prima di iniziare

E’ bene indicare che il Service Worker per poter funzionare in remoto (su uno spazio web) ha bisogno del protocollo HTTPS, mentre in locale non è strettamente necessario.

Creazione del Service Worker

Creare un file service-worker.js e inserirlo nella root del progetto (prerogativa) e includere il codice seguente:

const CACHE_NAME = 'phaser3-game-dynamic-cache-v1';

// Gestisce l'installazione del Service Worker (senza pre-caricamento delle risorse)
self.addEventListener('install', (event) => {
    event.waitUntil(self.skipWaiting()); // Salta subito allo stato "attivo"
});

// Gestisce le richieste e aggiunge in cache eventuali nuove risorse
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            return response || fetch(event.request).then((networkResponse) => {
                // Aggiungi alla cache solo le risorse che non sono presenti
                return caches.open(CACHE_NAME).then((cache) => {
                    cache.put(event.request, networkResponse.clone());
                    return networkResponse;
                });
            });
        })
    );
});

// Pulizia della cache vecchia in fase di attivazione
self.addEventListener('activate', (event) => {
    event.waitUntil(
        caches.keys().then((cacheNames) => {
            return Promise.all(
                cacheNames.map((cacheName) => {
                    if (cacheName !== CACHE_NAME) {
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

Avviare il Service Worker

Nella funzione windows.load, prima di inizializzare l’istanza del proprio gioco di Phaser, inserire il codice seguente che verifica il worker e lo istanzia:

   if ('serviceWorker' in navigator) {
        console.log('Service Worker supportato!');
        navigator.serviceWorker.register('service-worker.js')
            .then((registration) => {
                console.log('Service Worker registrato con successo: ' + registration);
            })
            .catch((error) => {
                console.log( 'Registrazione del Service Worker fallita: ' + error);
            });
    } else {
        console.log('Service Worker non supportato dal browser.');
    }

Gestire il Service Worker

E’ possibile verificare la funzionalità del service worker attraverso gli strumenti per sviluppatore del proprio browser desktop, accedendo alla tab Apllication > Service workers e utilizzando le funzione di Offline e Update On Reload.

Invalidare la cache del Service Worker

Durante lo sviluppo di un applicativo gestito dal Service Worker ci si potrebbe scontrare con l’involontario caricamento delle risorse dalla cache e non da files modificati. Questo significa che il servizio sta ancora leggendo dalla cache e non l’ha refreshata.

Un modo per farlo completamente è quello di usare il versioning del nome della cache.
Nell’esempio presente in questo articolo è stato infatti utilizzata la nomenclatura:

const CACHE_NAME = ‘phaser3-game-dynamic-cache-v1’;

Se necessario è quindi possibile cambiare il numero di versione del suffisso finale della stringa (da v1 a v2, v3, etc) in modo che il Service Worker capisca che è stato modificato e dunque reinizializzarà la cache nuovamente.

ALTERNATIVA: Invalidare singolarmente i files

Una alternativa, se si preferisce non cambiare CACHE_NAME è quella di aggiungere aggiungere dei “cache-busting” URL delle risorse caricate. Ad esempio, invece di /main.js, si può aggiornare il file a /main.js?v=2 ogni volta che se ne fa’ una modifica significativa. Questo metodo forza il browser a ricaricare l’asset in quanto riconosce l’URL come differente.

Verificare lo spazio occupato dalla cache del Service Worker

Per verificare quanto spazio è disponibile nel proprio browser web (può variare, anche di molto, tra i diversi browser desktop e mobile) utilizzare la funzione seguente:

if (navigator.storage && navigator.storage.estimate) {
  navigator.storage.estimate().then(({ quota, usage }) => {
    const quotaMB = (quota / (1024 * 1024)).toFixed(2); // Converti da byte a MB
    const usageMB = (usage / (1024 * 1024)).toFixed(2);
    const remainingMB = (quotaMB - usageMB).toFixed(2);

    console.log(`Spazio totale disponibile: ${quotaMB} MB`);
    console.log(`Spazio utilizzato: ${usageMB} MB`);
    console.log(`Spazio rimanente: ${remainingMB} MB`);
  });
}

I componenti Unity che ho pubblicato sull’Asset Store

Durante lo creazione di progetti Unity possono sorgere le più disparate necessità di integrazione e sviluppo. Nel mio approccio ai progetti preferisco sempre implementare il principio di riusabilità dei componenti e a responsabilità singola.

Per questo motivo spesso mi ritrovo con componenti che possono essere facilmente riutilizzati in altri progetti senza alcuna modifica. Con questo approccio, potenzialmente, possono essere usati anche da altri sviluppatori che incontrano le stesse necessità e difficoltà nei loro progetti.

Unity permette facilmente agli sviluppatori di pubblicare i loro componenti sull’Asset Store, repository di utility creati dalla community, attraverso una semplice procedura di pubblicazione che ne definisce prezzo, descrizione, screenshot e modalità di rilascio.

Negli anni ho quindi pubblicato questi asset, a disposizioni di altri programmatori, sia a pagamento che gratuiti.

Physical Dice Roller

Componente che implementa un sistema di lancio dei dadi basato sulla fisica con sistema di gestione delle ambiguità (risultato incerto).
Ad oggi l’asset più venduto sullo store tra quelli da me pubblicati.
Sviluppato principalmente per il gioco GGWG (info qui)

Social Network Screenshot Creator

Componente per la creazione istantanea di screenshot aventi rapporto di dimensione specifiche per diversi social networks (facebook, instagram, linkedin, x/twitter, youtube) senza necessità di ritagli per la pubblicazione.
Sviluppato per il gioco Starmaster (info qui)

Realtime In Game Log Console – GRATUITO

Componente per l’implementazione di una console leggera a runtime, simile a quella de videogioco Quake. Visualizza tutti gli errori, warning e log intercettati da Unity.
Sviluppata per il gioco Sors Adversa (info qui).

Resource Animation Sequencer – GRATUITO

Componente per caricare massivamente dalla cartella Resources\ dei file di animazione .anim e riprodurli in modo sequenziale senza che siano referenziati nell’Animator Controller. Possibilità di definire il delay e la durata del crossfade tra le animazioni. Basato su eventi.

Template boilerplate per videogiochi 2D con Phaser 3

E’ indubbio che esistono alcune operazioni che ciclicamente vanno realizzate per iniziare un nuovo progetto, in qualsivoglia linguaggio, in qualsivoglia IDE. Per questo motivo nell’articolo verrà proposto il codice sorgente di un template boilerplate per videogiochi 2D scritto con Phaser 3 (in Javascript).

E’ un codice che può essere utilizzato per iniziare un nuovo progetto e modificare al bisogno per adattarlo alle proprie necessità ma che evita di perdere tempo nella configurazione del progetto, delle scene di base e in alcuni processi indispensabili in Phaser 3.

Seppur semplice è realizzato grazie all’esperienza di anni nello sviluppo di videogames e app HTML5 fruibili dal web, il codice è costantemente aggiornato e disponibile gratuitamente.

Di seguito la struttura del progetto:

Quello che non ti dicono di Phaser 3

Phaser 3 è una potente libreria per implementare videogames (prettamente 2D) sul web sfruttando Javascript e HTML5.

E’ indubbiamente una libreria con una community molto attiva ma molto spesso i problemi, e le loro risoluzioni, scalfiscono solo la superficie delle potenzialità di tale libreria in quanto la quasi totalità degli utilizzi si ferma ad un uso basilare.

Di seguito una raccolta, costantemente aggiornata, di spunti e suggerimenti su argomenti non propriamente “di base” ma con cui sicuramente ci si può scontrare appena si entra nel vivo dell’utilizzo di Phaser 3.

Rimuovere i listeners di una scena

Per rimuovere un listener da una scena si usa this.events.removeListener(”); non molti sanno che però la scrittura ufficiale è this.events.off(”) in quanto la prima è un alias di quest’ultima.

E’ bene invece non usare la funzione this.events.removeAllListeners() in quanto questa funzione rimuove anche tutti i listener interni della scena di Phaser. Si consiglia quindi di usare sempre e solo this.events.off(”) rimuovendo manualmente gli eventi creati.

Alla chiusura di una scena i listeners non vengono rimossi

Chiudendo una scena con this.scene.stop(), i vari listeners attivi non vengono rimossi causando problemi nel ricaricamento successivo della scena (esempio click lanciati due volte). E’ bene quindi appoggiarsi all’evento “shutdown” e rimuovere tutti i listeners attivi (vedere punto precedente).

this.events.on('shutdown', () => {
            //Eventi phaser
            this.events.off('shutdown');  //Rimuovere anche shutdown

            //Eventi custom
            this.events.off(KEYS.EVENTS.GAMEOVER);
            this.events.off(KEYS.EVENTS.ADDSCORE);
            this.events.off(KEYS.EVENTS.REMOVESCORE);
}, this);

Disegnare i limiti del mondo fisico

Molto spesso è necessario, per la sola fase di debug, visualizzare i limiti del mondo fisico della scena. E’ possibile ottenerli tramite l’oggetto this.physics.world.bounds e disegnarli nel modo seguente:

drawBounds() {
        const bounds = this.physics.world.bounds;
        const graphics = this.add.graphics();
        graphics.lineStyle(2, 0xFF1111);
        graphics.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
}

Estrusione di Shape SVG in direzioni arbitrarie e tagli ad angolo in Three.js

Introduzione

In alcuni contesti specifici, come il trattamento di elementi meccanici provenienti da progettazione CAD, si rende necessario visualizzare a schermo il risultato di una estrusione di tale tracciato così da poterne vedere in tre dimensioni il risultato di lavorazione finale. Contesti più specifici necessitano di estrudere tali elementi in direzioni arbitrarie mantenendone l’uniformità, così come altri necessitano di giunzioni con tagli ad angolo per creare strutture più complesse… pensiamo ad esempio alle cornici dei quadri.

Per il test di questo articolo è stata utilizzata una Shape rappresentate una semplice forma di cuore, utile soprattutto per testare l’approccio in contesti di curve e archi.

Caricamento di file SVG sotto forma di Shape singole

L’oggetto THREE.Shape (derivato da THREE.Path) è un oggetto che permette di disegnare componenti 2D vettoriali nel canvas 3D di Three.js.
Sebbene sia uno strumento molto potente è anche molto complesso da utilizzare quindi il suo uso più frequente è quello di trovarlo implementato nel THREE.SVGLoader. Tale loader estrapola dal file .svg oggetti Path e Shape così da poterli usare per generare la geometria desiderata.

Qui sotto una procedura standard per ottenere le singole Shape dell’SVG che sfrutta la funzione toShape() delle Path:

 const loader = new THREE.SVGLoader();
        loader.load(
            'heart.svg',
            function (data) {
                const paths = data.paths;
                let counter = 0;
                for (let i = 0; i < paths.length; i++) {
                    const shapes = paths[i].toShapes(true);
                    counter = counter + shapes.length;
                    for (let j = 0; j < shapes.length; j++) {
                        let newShape = shapes[j]resize; //Shape corrente
                        //Operazioni sulla shape
                        //...
                    }
                }
            },

            // called when loading is in progresses
            function (xhr) {
                console.log((xhr.loaded / xhr.total * 100) + '% loaded');
            },
            // called when loading has errors
            function (error) {
                console.log(error);
            }
        );

Normalizzazione delle dimensioni della Shape

Uno dei problemi che si deve affrontare durante il caricamento di una Shape è quello che l’unità di misura della Shape può essere diversa rispetto a quella della scena in cui dobbiamo includerla. Per questo motivo si rende necessario normalizzare le dimensioni della Shape (e non della Geometry o della Shape successive).

Una tecnica è quella di utilizzare la funzione Shape.getSpacedPoints(…) che permette di ottenere tutti i punti che compongono la Shape e quindi rimapparli con un fattore di scala unico oppure con dimensioni desiderate.

function GetShapeResized(shape, targetWidth, targetHeight)
{
    const boundingBox = new THREE.Box2();
    boundingBox.setFromPoints(shape.getSpacedPoints(100)); 

    const originalWidth = boundingBox.max.x - boundingBox.min.x;
    const originalHeight = boundingBox.max.y - boundingBox.min.y;

    const scaleX = targetWidth / originalWidth;
    const scaleY = targetHeight / originalHeight;

    const points = shape.getSpacedPoints(100);
    const resizedPoints = points.map(point => {
        return new THREE.Vector2(point.x * scaleX, point.y * scaleY);
    });

    const resizedShape = new THREE.Shape(resizedPoints);
    return resizedShape;
}

A questo link del blog sono spiegate in dettaglio le 2 tecniche utilizzabili.

Generazione di una geometria unificata

Come detto un file SVG può essere composto da molte Shape e non da una sola Shape. Per questo motivo si rende necessario, al termine del caricamento e della normalizzazione, la creazione di un unico oggetto Geometry e non diversi oggetti, in quanto sarebbero dispendiosi e poco controllabili.

La funzione che ci viene in aiuto, durante il caricamento delle Shape dell’SVG, è la seguente:

const result = THREE.BufferGeometryUtils.mergeBufferGeometries(geometriesArray);

Tale funzione permette di unire in un unica geometria tutte le geometrie presenti nell’array di geometrie passato alla funzione. Tale oggetto può essere poi facilmente convertito in Mesh e aggiunta alla scena corrente che la renderizzerà a video.

Creazione di una estrusione lungo direzioni arbitrarie basate su punti

Three.js implementa di base la funzione THREE.ExtrudeGeometry che permette di estrudere una Shape. Uno dei parametri che si possono passare alla funzione è la path lungo cui estrudere la forma. Sebbene questa sia una funzionalità molto interessante soffre di due problemi principali: crea un’unica geometria finale e non è possibile generare geometrie chiuse. Seppur utile i contesti di applicazione sono molto ristretti spesso a demo o semplici trasformazioni di oggetti da 2D a 3D spessorato.

L’obiettivo è invece quello di implementare una funzione che permetta, passando la forma della Shape e una serie di punti 2D, di estrudere la forma nella direzione di quei punti nello spazio, poter decidere se le componenti geometriche sono separate e implementare un metodo per chiudere l’estrusione generata (affinché non vi sia un sormontare tra la prima geometria e l’ultima). Esistono diversi approcci a questo tipo di attività e vanno tutti nell’usare sapientemente gli indici della geometria calcolando l’angolo tra i bordi del contorno e applicando matrici di taglio/trasformazione, rotazione e traslazione ai punti del profilo per ciascun punto del contorno.

for (let i = 0; i < this.pathPoints.length; i++)
{
    let angle1 = 90;    //Arbitrario
    let angle2 = 45;    //Arbitrario

    let cutAngle = angle2 - angle1;
    let oppositeAngle = (Math.PI * 0.5) + angle2;

    let side = new THREE.Matrix4().set(1, 0, 0, 0, -Math.tan(cutAngle * 0.5 - Math.PI * 0.5), 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
    THREE.ShapeGeometry(shape).attributes.position.applyMatrix4(side);

    let rotation = new THREE.Matrix4().set(Math.cos(oppositeAngle), -Math.sin(oppositeAngle), 0, 0, Math.sin(oppositeAngle), Math.cos(oppositeAngle), 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
    THREE.ShapeGeometry(shape).attributes.position.applyMatrix4(rotation);

    let move = new THREE.Matrix4().set(1, 0, 0, pathPoints[i].x, 0, 1, 0, pathPoints[i].y, 0, 0, 1, 0, 0, 0, 0, 1);
    THREE.ShapeGeometry(shape).attributes.position.applyMatrix4(move);
}
Video dei risultati ottenuti con la tecnica dell’estrusione in direzioni arbitrarie

Tagli ad angoli arbitrari

Come visto il passaggio precedente crea estrusioni lungo direzioni arbitrarie, anche di geometrie che devono essere necessariamente chiuse. E’ possibile però specificare alla funzione di produrre unicamente la Shape estrusa originaria ed utilizzare le estrusioni secondarie unicamente come elemento di rimozione dei punti eccedenti e quindi generare tagli ad angoli arbitrari.

L’espediente che possiamo usare è quantomai semplice: si può definire un booleano che nei cicli di generazione degli indici della geometria risultante escluda quelli che generano la componente grafica dell’estrusione ad angolo. Otterremo così un profilo estruso in due direzioni ma di cui la seconda parte della geometria non viene renderizzata. In sostanza stiamo usando le geometrie estruse unicamente per “scavare” la geometria di interesse. Questo approccio permette di non dover riscrivere la funzione del punto precedente ma invece di riutilizzarla per questo contesto. Seppur con qualche calcolo di troppo risulta comunque la soluzione vincente in termini di implementazione e di riusabilità del codice.

Qui sotto un taglio a 45° in cui viene mostrata, unicamente per debug, l’estrusione usata per generarlo:

Qui sotto due semplici esempi ottenuti con un taglio da 45° e uno da 30°:

Specchiatura della Mesh risultante (se necessario)

Per poter utilizzare la Mesh prodotta anche in situazioni dove il profilo deve trovarsi in situazione specchiata è necessario attuare una semplice operazione di inversione dei punti. L’approccio più semplice è quello di usare un espediente basato sulla scalatura al quale viene passato una unità con valore negativo, sull’asse di specchiatura desiderato.

La semplice funzione da utilizzare è la seguente (specchiatura su asse X):

mesh.scale.multiply(new THREE.Vector3(-1,1,1));

Considerazioni sulla velocità di esecuzione

E’ chiaro che parlando di Three.js e rendering realtime sul web sia strettamente necessario tenere in considerazione la velocità di esecuzione di operazioni così a basso livello. Proprio perchè operazioni a basso livello (siamo partiti da un file SVG caricato da disco, passando da oggetti Path e Shape, da geometrie ricalcolate, estrusioni e tagli arbitrari) esse sono altamente performanti.

Dai nostri test il caricamento di un .svg semplice da disco con la creazione di un riquadro sagomato tramite Shape a forma di cuore ha impiegato tra i 10 e i 15 millisecondi in totale contemplando il caricamento del file, la generazione delle Shape e delle geometrie, la normalizzazione, l’estrusione arbitraria e il taglio di 4 oggetti (uno per lato, colorati nell’immagine sottostante).

Shape estrusa attraverso punti arbitrari (sinistra) e Mesh composta da 4 geometrie estruse e tagliate ad angolo (destra)

Ridimensionare l’oggetto Shape di Three.js

In alcuni contesti specifici dell’uso di Three.js può essere necessario lavorare intensivamente con l’oggetto THREE.Shape (il quale è una estensione dell’oggetto THREE.Path); viene solitamente usato durante il caricamento di file SVG (tramite THREE.SVGLoader) in quanto rappresenta bene un profilo 2D vettoriale.

Lavorando con questo oggetto può rendersi necessario attuare delle operazioni di ridimensionamento o scalatura in quanto spesso le forme posso avere dimensioni eccessive o fuori scala. Per questo motivo di seguito vengono riportate 3 funzioni Javascript fondamentali durante l’uso di THREE.Shape.

Calcolare la dimensione di una Shape

Funzione per il calcolo delle componenti larghezza e altezza (su un piano 2D) dell’oggetto THREE.Shape.

function GetShapeSize(shape)
{
    let boundingBox = new THREE.Box2();
    boundingBox.setFromPoints(shape.getSpacedPoints(100));
    let bbWidth = boundingBox.max.x - boundingBox.min.x;
    let bbHeight = boundingBox.max.y - boundingBox.min.y;
    return { width:bbWidth, height:bbHeight };
}

Ridimensionare una Shape tramite fattore di scala

Funzione per scalare un oggetto THREE.Shape di due fattori di scala distinti (X e Y).

function GetShapeScaled(shape, scaleX, scaleY) 
{
    const points = shape.getSpacedPoints(100);
    const resizedPoints = points.map(point => {
        return new THREE.Vector2(point.x * scaleX, point.y * scaleY);
    });

    const resizedShape = new THREE.Shape(resizedPoints);
    return resizedShape;
}

Ridimensionare una Shape a dimensioni specifiche

Funzione per ridimensionare un oggetto THREE.Shape a valori di larghezza e altezza definiti.

function GetShapeResized(shape, targetWidth, targetHeight)
{
    const boundingBox = new THREE.Box2();
    boundingBox.setFromPoints(shape.getSpacedPoints(100)); 

    const originalWidth = boundingBox.max.x - boundingBox.min.x;
    const originalHeight = boundingBox.max.y - boundingBox.min.y;

    const scaleX = targetWidth / originalWidth;
    const scaleY = targetHeight / originalHeight;

    const points = shape.getSpacedPoints(100);
    const resizedPoints = points.map(point => {
        return new THREE.Vector2(point.x * scaleX, point.y * scaleY);
    });

    const resizedShape = new THREE.Shape(resizedPoints);
    return resizedShape;
}

Quello che non ti dicono di Three.js – Parte 3

Come nella Parte 1 e nella Parte2, ci occuperemo di indicare in questo post tutti quegli spunti e riflessioni su Three.js che da uno studio superficiale della libreria potrebbero sfuggire. Sono argomenti tecnici particolari e profondi che però cercano di mettere in guardia lo sviluppatore da potenziali problemi ed errori.

L’antialiasing sul Render Composer non funziona

E’ bene ricordare che se si utilizza il Render Composer per visualizzare la propria scena, l’attributo antialising:true del renderer non ha valenza. Le uniche due possibilità sono:

  • Aggiungere un FXAA Pass dentro il flusso di render pass prima di renderizzare l’output, avendo cura di passare la risoluzione corretta
  • Passare al WebGLRenderTarget il parametro “samples: 4” (o un valore definito) durante la sua creazione (ma non è compatibile con WebGL1) – Dalla versione 138 di WebGLMultisampleRenderTarget è stato rimosso in favore del parametro samples

Se si utilizza il MaskPass è necessario abilitare lo Stencil Buffer

Se durante l’utilizzo del Render Composer si rende necessario l’inserimento di MaskPass (e ClearMaskPass) è necessario abilitare nel WebGLRenderTarget, passato al momento delle creazione del Composer, il parametro “stencilBuffer: true”

Debuggare il Frame Buffer non è facile

Qualora servisse avere una visualizzazione chiara di tutti i passaggi di rendering del Frame Buffer della scena corrente, l’unica soluzione è affidarsi ad un sistema di monitoraggio esterno. In questo caso si rende molto utile l’estensione Chrome chiamata Spectator.js, installabile attraverso questo link.

Impostare correttamente l’environment map

Quando si imposta l’environmentMap e la background texture di una scena è sempre necessario impostare un ToneMapping del renderer.

Quello che non ti dicono di Three.js – Parte 2

Come nella Parte 1, ci occuperemo di indicare in questo post tutti quegli spunti e riflessioni su Three.js che da uno studio superficiale della libreria potrebbero sfuggire. Sono argomenti tecnici particolari e profondi che però cercano di mettere in guardia lo sviluppatore da potenziali problemi ed errori.

La funzione repeat delle textures di un materiale è condivisa

Da come è strutturata la gestione delle singole textures può sembrare che applicando il .repeat.set questo possa essere fatto indipendentemente su qualsiasi mappa del materiale, ad esempio map(1,1) e emissiveMap(3,3). In realtà non è così in quanto il repeat è condiviso su tutte le mappe del materiale, con questa priorità:

  • color map
  • specular map
  • displacement map
  • normal map
  • bump map
  • roughness map
  • metalness map
  • alpha map
  • emissive map

Questo NON si applica alla “light map”, alla “ao map” e alla “env map” che invece sono riferite ad un uv set differente.

In sostanza Three.js considera che tutto quello che riguarda la definizione del materiale in sé abbia lo stesso repeat, mentre ciò che riguarda la definizione di attività esterne al materiale (appunto luci o ambiente) abbia un repeat differente. E’ quindi inutile impostare tutti i repeat se si hanno mappe diverse ma unicamente quello della color map (se ovviamente si ha la color).

Il calcolo delle dimensioni del BoundingBox di una TextGeometry è sbagliato

Purtroppo il primo approccio che si pensa al calcolo delle dimensioni di un elemento TextGeometry è quello di utilizzare la funzione getSize della geometria, come segue:

let textMeshMeasures = new THREE.Vector3();
textMesh.geometry.boundingBox.getSize(textMeshMeasures);

Purtroppo tale calcolo risulta essere errato in quanto non tiene in considerazioni eventuali scalature, padding etc.

Per eseguire correttamente il calcolo delle dimensioni (width, height, depth) è necessario usare questo approccio (il quale è utilizzato anche nel file BoxHelper di Threejs stesso):

let box = new THREE.Box3();
box = box.setFromObject(textMesh);
let textMeshMeasures = new THREE.Vector3();
box.getSize(textMeshMeasures);

Il raycast può dare problemi con elementi di tipo Line

Ad esempio quando si esegue il raycast per individuare gli oggetti cliccati in scena, l’oggetto Raycaster ritorna un array di intersezioni con tutti gli oggetti coinvolti. Se in scena sono presenti delle mesh creare con THREE.Line può includere intersezioni non veritiere (dopotutto a chi può servire fare un raycast su delle linee). Il consiglio è quindi, prima di interrogare l’array, di eseguire una funzione .filter sull’array.

Per disegnare il raggio è possibile usare questa funzione:

let arrow = new THREE.ArrowHelper(raycaster.ray.direction, raycaster.ray.origin, 100, Math.random() * 0xff0000);

Gestione dello z-fighting

Può capire di dover gestire lo z-fighting per poligoni con la stessa coordinata (ad esempio con z = 0). Lo z-fighting è quell’artefatto grafico dove la pipeline di rendering non capisce quale poligono disegnare prima di un altro e dunque può generare problemi di visualizzazione. Una delle soluzioni è spostare di poco la coordinate per gli elementi che si vogliono renderizzare prima/dopo. In ThreeJS esiste però un altro metodo da applicare al materiale del poligono stesso, da usare nel modo seguente:

material.depthTest: true,
material.depthWrite: false,
material.polygonOffset: true,
material.polygonOffsetFactor: -4

più il valore di Factor è inferiore (anche negativo) e più il poligono viene renderizzato per primo.
E’ da indicare che questo metodo non funziona con poligoni associati a LineBasicMaterial.

Gestione dell’ordine delle trasparenze

Sfortunatamente, l’alpha blending (material.transparent = true) introduce molta complessità nel processo di rendering e rende i risultati molto sensibili all’ordine di rendering. Anche quando si imposta material.opacity=1, si possono comunque ottenere risultati significativamente diversi rispetto a un oggetto opaco con material.transparent = false. Non esistono soluzioni “perfette” che funzionino in tutti i casi, ma ci sono alcune soluzioni alternative che si possono provare:

  • Utilizzare material.DepthWrite = true sui materiali trasparenti
  • Impostare material.transparent=false una volta che l’opacità dell’oggetto è a 1
  • Impostare renderer.sortObjects: true

Quello che non ti dicono di Three.js – Parte 1

Three.js è una potente libreria per implementare WebGL (e quindi grafica 3D realtime) nel browser.

E’ indubbiamente la libreria con la community più attiva (Github, Stackoverflow, etc) ma molto spesso i problemi, e le loro risoluzioni, scalfiscono solo la superficie delle potenzialità di tale libreria in quanto la quasi totalità degli utilizzi si ferma ad un suo uso basilare.

Di seguito una raccolta, costantemente aggiornata, di spunti e riflessioni su argomenti non propriamente “di base” ma con cui sicuramente ci si può scontrare appena si entra nel vivo dell’utilizzo di Three.js

Risolvere aberrazioni visive di Z-buffer dovute alla distanza delle camera

Il problema dello Z-buffer è uno dei più noti in ambito 3D in quanto produce delle aberrazioni visive notevoli in concomitanza con la distanza degli oggetti rispetto alla camera. Questo perché il Depth Buffer non riesce a visualizzare correttamente superfici che si sono in overlap (o quasi).

Per ovviare a questo problema, Three.js espone un parametro del renderer che imposta il Depth Buffer su scala logaritmica e che, se impostato a true (non di default), non mostra aberrazioni di sorta.

renderer.logarithmicDepthBuffer = true;

Qui si può trovare un approfondimento “matematico” al problema.

E’ da segnalare come l’abilitazione di questa opzione possa impattare negativamente sulle performance dell’antialias del renderer.

Ottimizzare le ombre di una scena statica

Qualora si debba lavorare con una scena statica (quindi senza elementi in scena che si muovono come modelli o le luci stesse), è possibile ottimizzare la scena generale disabilitando, dopo qualche istante, l’aggiornamento automatico delle ombre (della shadowMap). Questo farà in modo che nei primi istanti la shadowMap venga generata correttamente ma subito dopo non venga più ricalcolata. Eventualmente, se qualcosa in scena si potrà muovere, si potrà riabilitare e disabilitare tale funzione.

setTimeout(() => { this.renderer.shadowMap.autoUpdate = false; }, 500);

Caricare una immagine HDR ed assegnarla come envMap ad ogni materiale fisico in scena

Per scene statiche è possibile ottimizzare l’uso delle luci escludendo quelle dinamiche in scena (point, direction, etc) ed implementando un ambiente con immagine HDR (un particolare formato “raw” che include tutto lo spettro dei colori). Questa tecnica permette di illuminare gli oggetti in scena utilizzando le informazioni presenti in tale immagine. E’ necessario caricare (attraverso il componente aggiuntivo RGBELoader di Three.js) una immagine .hdr, mapparla come EquirectangularReflectionMapping e impostarla per il background e l’environment della scena, unitamente a specificarla in ogni mappa envMap degli oggetti Mesh presenti.

 new THREE.RGBELoader().load('Contrastata.hdr', function (texture) {
    texture.mapping = THREE.EquirectangularReflectionMapping;
    mainScene.background = texture;
    mainScene.environment = texture;
    mainScene.traverse(function (obj) {
        if (obj instanceof THREE.Mesh) {
            try {
                obj.material.envMap = texture;
                obj.material.envMapIntensity = 1.0;
            } catch{ console.log(obj.name + ": setting envMap ERROR"); }
        }
    });
});

Qui il download della immagine HDR dell’esempio.

Flippare le mappe dei materiali di un file GTLF/GLB sull’asse verticale (V)

Sembra essere una prerogativa del formato .gltf/.glb ma, qualora si volesse applicare una mappa ad un materiale di un file esportato in tale formato, è necessario invertire l’asse verticale della mappa UV (quindi sull’asse V).
Di seguito un modo semplice per flippare entrambi gli assi a piacimento.

let repeatX, repeatY = 1.0;
let flipU = false;
let flipV = true;
texture.center.set(0.5, 0.5);
texture.repeat.set(repeatX * (flipU ? -1 : 1), repeatY * (flipV ? -1 : 1));

Aggiungere un set di coordinate UV2 aggiuntivo ad una mesh

Può capitare di dover aggiungere un set aggiuntivo di coordinate ad una mesh, ad esempio per collocare una aoMap generata custom. Per fare questo l’unico modo plausibile è quello di duplicare il primo set (solitamente già presente) attraverso questa semplice associazione:

child.geometry.setAttribute('uv2', child.geometry.attributes.uv);

Gestire le coordinate UVs in un modello GLTF

Come da guida di Three.js, la mappa di Ambient Occlusion (aoMap) e quella delle luci (lightMap) necessita di un set di UV differente rispetto alle altre mappe dei materiali. Questo perché è logico pensare che il primo set riguardi la possibilità di ruotare, ripetere o scalare a piacimento l’aspetto estetico del materiale, mentre il secondo riguarda informazioni di mappa proveniente da un calcolo di scena che tendenzialmente è fisso (luci, ombre, occlusioni, etc).

Il formato GLTF, per quanto riguarda le UVS, considera questi assunti:

  • Se è presente una sola UV: questa viene impostata sull’attributo geometry.attributes.uv (solitamente la Color Map)
  • Se sono presenti due UVs: sull’attributo geometry.attributes.uv viene associata la Baked Map (es: aoMap), sull’attributo geometry.attributes.uv2 viene associata la Color Map

Come si può immaginare questo crea non pochi problemi in quanto la Color Map (la mappa principale di un materiale, presente praticamente sempre) si può trovare in due posizioni diverse in base ai casi (uv o uv2). Per questo motivo può essere necessario prevedere una modalità per invertire le mappature in modo da riportare la Color Map sempre sulla uv (e non uv2), cosi:

if (child.geometry.attributes.uv != null && child.geometry.attributes.uv2 != null) {
    let tempUV = child.geometry.attributes.uv2;
    child.geometry.setAttribute('uv2', child.geometry.attributes.uv);
    child.geometry.setAttribute('uv', tempUV);
}

Ottimizzare il filtro anisotropico

Il filtro anisotropico applicato alle mappe dei modelli permette di ridurre la sfuocatura delle texture sulla distanza. Di base questo valore è 1, oggettivamente molto basso. In nostro aiuto c’è una funzione del renderer che ritorna il valore anisotropico massimo impostabile dalla scheda video. Tale valore è solitamente un multiplo di 2 (2, 4, 8, 16, etc). Un valore troppo alto però mostra le immagini in modo troppo nette producendo anch’esso (strano a dirsi) artefatti sulla distanza. Per questo motivo il consiglio è di dividere per 2 il valore ritornato dalla funzione del renderer.

texture.anisotropy = renderer.capabilities.getMaxAnisotropy() / 2;