Three.js — Textures part 2

Gianluca Lomarco
12 min readAug 9, 2023

--

In questa seconda parte dedicata alle texture vediamo alcune possibilità che abbiamo per manipolare il modo in cui le texture vengono mappate sulle geometrie, e scenderemo anche un po in profondità su alcuni aspetti tecnici che riguardano le nostre texture.

Nella prima parte abbiamo visto cos'è una texture, come caricarla e come applicarla ad un materiale, a questo punto vediamo come possiamo trasformarle.

Sappiamo che le diverse geometrie hanno diversi modi di proiettare le texture sulla superficie (UV unwrapping) e che questo è gestito dalle coordinate uv, che determinano quale punto della texture corrisponde ad ogni vertice della geometria.

Una cosa che però possiamo fare, dato che le coordinate uv sono di fatto dei vettori 2D, è applicare delle trasformazioni (scala, traslazione e rotazione) per vedere come cambiamo il modo in cui le nostre texture vengono distribuite sulla superficie geometrica.

Repeat

Tutte le texture hanno la proprietà repeat che possiamo utilizzare per scalare la texture applicata alla geometria. Con questa proprietà infatti possiamo dire quante volte la texture si deve ripetere lungo le due direzioni uv. Repeat infatti è un vettore 2D e ci permette di assegnare quindi 2 valori che verranno utilizzati per scalare la texture. Per esempio:

const map = textureLoader.load(mapSrc)
map.repeat.set(2, 3)

Come si può vedere i valori che assegnamo a repeat ci permettono di decidere quante volte la texture di deve ripetere all'interno della superficie geometrica. Nel nostro esempio la texture verrà ripetuta due volte orizzontalmente (u) e tre volte verticalmente (v). Quindi vuol dire che orizzontalmente la texture verrà dimezzata (1/2), mentre sarà ridotta ad un terzo (1/3) verticalmente. Questo comportare una deformazione dell'immagine che usiamo come texture.

Sicuramente te ne sarai accorto, ma la texture non si sta effettivamente ripetendo, è stata rinpicciolita ma è come se gli ultimi pixel della texture venissero strecciati fino ad occupare lo spazio rimanente. Effettivamente è così, perché di default le texture non vengono ripetute. Per assegnargli questo comportamento dobbiamo modificare altre 2 proprietà:

  • wrapS
  • wrapT

Queste hanno come valore di default la costante THREE.ClampToEdgeWrapping ma possiamo assegnare un valore diverso per determinare se e come le texture devono ripetersi qualora non occupino tutto lo spazio.

const map = textureLoader.load(mapSrc)
map.repeat.set(2, 3)
map.wrapS = THREE.RepeatWrapping
map.wrapT = THREE.RepeatWrapping

Con i valori THREE.RepeatWrapping diciamo alla texture di ripetersi uguale a se stessa.

A questo link potete trovare i possibili valori da usare per il wrapping mode: https://threejs.org/docs/#api/en/constants/Textures.

Offest

Con la proprietà offset, anch'essa un vettore 2D, possiamo decidere come e quanto traslare la texture, rispetto al punto di origine di applicazione. Prima di modificare le proprietà wrapS e wrapT infatti ci siamo accorti che la texture viene applicata a partire dal punto in basso a sinistra.

const map = textureLoader.load(mapSrc)
map.repeat.set(2, 3)
// map.wrapS = THREE.RepeatWrapping
// map.wrapT = THREE.RepeatWrapping
map.offset.set(-0.5, -1)

Occorre fare due precisazioni. I valori che assegnamo a questa proprietà vengono sommati alle coordinate uv dei vertici, per cui per spostare positivamente la texture lungo gli assi u e v occorre assegnare dei valori negativi alla proprietà offset, come si può notare dall'immagine qui sopra.

Un altro aspetto importante è che dovete considerare che i valori che assegnate ad offset sono da intendersi in termini percentuali. Di conseguenza al valore 1 corrisponde a uno spostamento della texture del 100% di se stessa, 0.5 equivale al 50%, e così via.

Rotation

Infine possiamo anche applicare una rotazione alla texture modificando la proprietà rotation della texture.

const map = textureLoader.load(mapSrc)
map.repeat.set(2, 3)
// map.wrapS = THREE.RepeatWrapping
// map.wrapT = THREE.RepeatWrapping
map.offset.set(-0.5, -1)
map.rotation = Math.PI * 0.15

La rotazione viene effettuata attorno al punto di applicazione della texture che di default corrisponde all'angolo in basso a sinistra, come avevamo visto in precedenza per la proprietà offset.

Possiamo modificare questo comportamento e fare in modo che venga utilizzato un altro punto modificando la proprietà center, un altro vettore 2D.

Commentando per un attimo l'offset e la rotation e assegnando alla proprietà center i valori x = 0.5 e y = 0.5, notiamo che adesso la texture si posiziona al centro della superficie della geometria.

const map = textureLoader.load(mapSrc)
map.repeat.set(2, 3)
// map.wrapS = THREE.RepeatWrapping
// map.wrapT = THREE.RepeatWrapping
// map.offset.set(-0.5, -1)
// map.rotation = Math.PI * 0.15
map.center.x = 0.5
map.center.y = 0.5
u = 0.5, v = 0.5

Possiamo vedere come cambia assegnando valori differenti.

map.center.x = 0.25
map.center.y = 0.25
u = 0.25, v = 0.25
map.center.x = 0.75
map.center.y = 0.75
u = 0.75, v = 0.75
map.center.x = 0.25
map.center.y = 0.75
u = 0.25, v = 0.75

Da questi esempi possiamo quindi capire che la proprietà center funziona in modo un po particolare. Per esempio, se consideriamo i valori x = 0.25 e y = 0.75, quello che succede è che verrà fatto coincidere il punto sulla superficie della geometria con coordinate uv (0.25,0.75) con il pixel della texture che ha le medesime coordinare (0.25,0.75).

Di conseguenze con i valori (0.5,0.5) viene fatto coincidere il centro della superficie della geometria con il centro della texture.

Modificare il centro di applicazione della texture ha un impatto sia sulla proprietà offset che sulla rotation, come possiamo vedere da questo esempio.

const map = textureLoader.load(mapSrc)
map.repeat.set(2, 3)
// map.wrapS = THREE.RepeatWrapping
// map.wrapT = THREE.RepeatWrapping
// map.offset.set(-0.5, -1)
map.rotation = Math.PI * 0.15
map.center.x = 0.5
map.center.y = 0.5

Mipmapping filtering

Sostituiamo al sfera con un cubo per accorgerci di un aspetto interessante.

In questo caso particolare possiamo notare che le texture applicate sulle facce laterali del cubo risultano molto sfocate ed è come se i pixel della texture si mescolassero insieme. Questo è dovuto a due meccanismi:

  • filtering
  • mipmapping

Mipmapping

Il mip mapping è una tecnica per cui, per ogni texture vengono generate delle versioni della stessa immagine via via più piccole, andando ogni volta a dimezzare le dimensioni fino ad arrivare ad una texture di 1 x 1 pixel.

Ognuna di queste immagini viene inviata alla GPU che deciderà quale di queste texture utilizzare in fase di rendering a seconda di quando spazio occupa nel render la superficie dell'oggetto alla quale è applicata la texture.

Il fatto è che non c'è quasi mai una corrispondenza 1 a 1 tra i pixel del render e i pixel delle texture. quindi la GPU deve scegliere la texture che meglio si adatta al pixel che deve renderizzare.

In parole povere, quando una superficie, sulla quale è applicata un texture, occupa molto spazio nel render (che spesso vuol dire che l'oggetto è molto vicino alla camera) allora verrà scelta una texture più grande. quando invece lo spazio occupato nel render è molto piccolo, allora la GPU utilizzera una delle mipmap pìu piccole.

Nel nostra esempio di primo l'oggetto è molto vicino alla camera, ma le facce laterali occupano molto poco spazio nel rendering dato che sono viste lateralmente (qui la GPU userà le mipmap più piccole), mentre la faccia frontale occupa moltissimo spazio (qui la mipmap più grande / texture originale). Inoltre entrano in gioco gli algoritmi di filtering.

Filtering

Una volta che la GPU ha scelto quale texture utilizzare in base alle dimensioni effettive della porzione da renderizzare, ci troviamo di fronte ad un altro problema, quello che causa la sfocatura della texture del nostro cubo. Ovvero gli algoritmi di filtering:

  • minification filter
  • magnification filter

Minification filter

Questo algoritmo entra in gioco quando i pixel della texture sono più piccoli dei pixel del render. Me che significa? Vuol dire che la texture è più grande della superficie che ricopre.

In questo scenario la GPU deve decidere quale colore scegliere, ed è qui che entra il gioco il minFilter. Possiamo modificare il tipo di algoritmo da usare scegliendo tra 6 possibili valori:

  • THREE.NearestFilter
  • THREE.LinearFilter
  • THREE.NearestMipmapNearestFilter
  • THREE.NearestMipmapLinearFilter
  • THREE.LinearMipmapNearestFilter
  • THREE.LinearMipmapLinearFilter

Di default viene utilizzato il THREE.LinearMipmapLinearFilter, che va a scegliere le due mipmap più vicine che matchano le dimensione del pixel da texturizzare, e per ognuna di essere usa il LinearFilter (che fa la media dei pixel campionati) per calcorare il colore finale del pixel. Viene infine fatta la media dei due valori ottenuti dalle mipmap.

Esempio di funzionamento del LinearMipmapLinearFilter

Non li vediamo tutti ma a questo link trovate le spiegazioni dei vari algoritmi: https://threejs.org/docs/index.html#api/en/constants/Textures.

Ti invito a provarli e a vedere le differenze. Tiene presente che non c'è una soluzione valida per tutte le casistiche, per cui di volta il volta dovrai cercare quello che più si addice al caso specifico.

Proviamo ad usare il NearestFilter nel nostro esempio per veder come cambia.

map.minFilter = THREE.NearestFilter

Con questo filtro la texture non risulta più sfocata ma sono comparsi numero artefatti, molto frequenti soprattutto quando abbiamo texture geometriche. Questo tipo di artefatti prendono il nome di Moirè patterns e tendenzialmente si cerca di evitare di avere questo tipo di effetto. Ci troviamo quindi di fronte ad un compromesso da fare.

Magnification filter

Questo filtro invece viene utilizzato nel caso contrario al precedente, ovvero quando il pixel della texture è più grande del pixel del render, ovvero quando la texture è più piccola della superficie che ricopre.

Proviamo ad applicare una texture di dimensioni 4x4 pixel, contenente 4 pixel colorati: bianco, rosso, arancione e viola. Questo è l'effetto che otteniamo a causa del magFilter, per cui i 4 pixel vengono mescolati tra loro. In questo caso la texture è molto più piccola della superficie che deve ricoprire.

Per questo filtro possiamo scegliere solo tra 2 algoritmi:

  • THREE.NearestFilter
  • THREE.LinearFilter (default)

Di default viene usato il LinearFilter che, come abbiamo visto fa una media ponderata dei 4 pixel più vicini al pixel dal texturizzare.

Se provassiamo ad utilizzare il NearestFilter otteremmo invece questo risultato:

I colori non vengono più mescolati in quanto con il NearestFilter viene preso solamente un pixel dalla texture, il più vicino al pixel che deve essere texturizzato.

Questo esempio può essere molto vantaggioso se dobbiamo fare un progetto con texture pixellate in stile Minecraft, perché ci permette di avere texture davvero piccolissime, anche di poche centinaia di pixel, mantenendo comunque una resa grafica molto definita e performance molto elevate.

Quando si utilizza il NearestFilter non vengono utilizzate le mipmap, che possiamo quindi disattivare per milgiorare ulteriormente le performance e l'utilizza della memoria della GPU. Infatti la generazione delle mipmap fa a consumare circa un 33% in più di memoria della dimensione della texture originale. Quindi è saggio limitarne l'utilizzo laddove non siano necessarie.

Possiamo evitare le generazione delle mipmap di una texture in questo modo.

map.generateMipmaps = false

Questo può essere utile anche nel caso in cui volessimo generare le mipmap manualmente.

Ottimizzazione

Infine parliamo di ottimizzazione delle texture. Ci sono 3 aspetti da tenere in considerazione quando si preparano le texture per un progetto.

  • il peso del file
  • la dimensione in pixel (risoluzione)
  • il formato del file

Quindi è sconsigliabile prendere a caso delle immagini dal web e utilizzarle perchè potrebbero riservarci qualche problemino, per non parlare dei problemi legati al copyright.

Peso (millemila giga)

Non dobbiamo dimenticarci che tutti i file che usiamo per i nostri progetti, dalle texture, ai modelli 3D, ecc, devono essere scaricati dal browser dell'utente. Quindi è importante cercare un ottimo compromesso tra qualità delle immagini e peso del file per non avere tempi di attesa troppo lunghi per il caricamento. Per questo conviene sfruttare software o web app per comprimere ed ottimizzare le nostre immagini.

Inoltre file troppo pesanti posso crearci problemi anche per la memoria del browser che deve memorizzare questi dati. Questa memoria non è illimitata, soprattutto per i dispositivi mobile, e potremmo far crashare il browser se non stiamo attenti.

Lo stesso problema potremmo averlo se abbiamo file di piccole dimensioni ma in enormi quantità. In questo caso dovremmo fare attenzione a caricare questi assets un po alla volta, solo quando vengono utilizzati e liberare la memoria quando non sono più necessari.

Dimensioni ovvero risoluzione

Indipendentemente dal peso dei file delle nostre texture, la GPU deve memorizzare ogni singolo pixel (non c'è compressione che tenga). Inoltre devono essere memorizzati anche i pixel delle mipmap generate che come abbiamo detto causano un incremento del 33% circa. Come il browser, anche le GPU hanno delle limitazioni che dipendono dal tipo di GPU.

Quindi occorre sempre cercare di ridurre la risoluzione delle texture il più possibile, evitando di esagerare dove non servono alte risoluzioni.

Inoltre la risoluzione ha un impatto anche sulla generazione delle mipmap. Queste vengono generate in maniera progressiva andando a dividere a meta le dimensioni della texture originale fino ad arrivare alla dimensione 1 x 1 pixel. Va da sé quindi che le nostre immagini dovrebbero avere delle dimensioni che siano delle potenze di 2, per esempio 512x512, 1024x512, 2048x1024, …

Non importa che siano quadrate ma che ognuna delle due dimensioni segua questa regola, ovvero sia una potenza di 2. Se così non fosse, le mipmap verrebbero comunque generate ma potrebbero generarsi degli artefatti. Infatti three.js cercherà di adattare le immagini alla potenza di 2 più vicina, che come dicevo potrebbe portare a risultati poco piacevoli.

Formati dei files

Ultima riflessine per quanto riguarda i diversi formati da utilizzare. Più comunemente vengono utilizzate jpeg e png per le texture, ma occorre fare attenzione al fatto che formati diversi spesso significa compressioni dei file diverse.

Soprattutto tra jpeg e png la differenza è sostanziale. Per prima cosa bisogna considerare che il formato png supporta il canale alpha, cosa che può essere utile in alcuni casi. Ma la diferenza principale sta nel tipo di compressione che viene fatta nei jpeg e nei png.

Nei file jpeg viene fatta una compressione di tipo lossy, ovvero che va ad alleggerire il file, ma allo stesso tempo vengono persi dei dati, quindi l'immagine perde di qualità.

Questo potrebbe non essere un problema nel caso delle color map, in quanto questa perdita di dati è tale da essere poco percettibile dall'occhio umano.

Nel caso invece delle altre texture, come le normalMap, displacementMap, alphaMap, questa perdita di dati potrebbe avere un maggior impatto sulla resa delle nostre geometrie.

Proprio per questo è conveniente utilizzare formati come il png che invece usa un tipo di compressione lossless, ovvero senza perdita di dati. Ecco un breve articolo dove puoi approfondire l'argomento.

Infine ci sono moltissimi siti web, soprattutto quelli dedicati ai sowtfare di modellazione 3D, dove è possibile trovare texture di qualità per i materiali più comuni e per quelli più particolari. In alcuni caso sono a pagamento, ma potreste trovarne anche di grauite. Fate attenzione al tipo di licenza di queste texture, in quando potrebbero non permettere l'utilizzo commerciale ma solo quello per progetti personali.

Conclusioni

Si conclude qui la nostra discussione sulle texture. Ora dovresti essere in grado di caricare le tue texture, applicarle alle geometrie e trasformarle a piacimento.

Nel prossimo articolo cominciamo a parlare in maniera più approfondita dei materiali che troviamo in three.js. Cercheremo di capire quanto diversi tipi di materiali abbiamo a disposizione, quali caratteristiche hanno in comune e in cosa differiscono.

Lo scopo principale sarà aiutarti a capire quando usare un materiale invece di un altro e viceversa.

--

--

Gianluca Lomarco

I am a creative developer working with webGL and fullstack Main teacher at Boolean Academy. Now I’am starting a new experience as content creator in YouTube