Three.js — Textures part 1

Gianluca Lomarco
11 min readAug 8, 2023

--

Nel precedente articolo abbiamo approfondito le geometrie e i loro attributi, e tra questi abbiamo scoperto l'esistenza dell'attributo uv che contiene tutte le coordinate uv dei vertici della geometria. Tali coordinati servono appunto per distribuire le texture sulle superfici delle geometrie, dato che ognuna di esse corrisponde ad un preciso pixel della texture.

Ma che cos'è una texture?

Probabilmente lo sai già, ma una texture è di fatto un'immagine che viene applicata sulla superficie delle nostre geometrie e che può avere effetti molto differenti sull'aspetto di quest'ultime.

Esistono infatti tanti tipi diversi di texture, ognuna con le proprie caratteristiche e con i propri scopi. Questi diversi tipi di texture possono essere applicate contemporaneamente sulle geometrie dato che influenzano aspetti diversi del rendering degli oggetti 3D ed insieme concorrono al raggiungimento di un effetto realistico secondo i principi del Physically Based Rendering (PBR).

Ognuna di queste texture vengono applicate utilizzando le coordinate uv associate ai vertici 3D delle nostre geometrie.

Vediamo quali sono queste texture e come funzionano.

Color Map (Albedo)

La color map, è forse la più semplice tra le texture perché è quella che influenza direttamente il colore. Infatti per ogni punto della geometria corrisponde un pixel della texture. Questo ci permette quindi di prendere un'immagine e distribuirla su tutta la superficie di una geometria.

A seconda del tipo di geometria cambia il tipo di proiezione che viene fatta della texture, per esempio sulla sfera viene fatta una proiezione cilindrica della texture, mentre per esempio, sul cubo viene fatta una proiezione cubica.

Questo è un aspetto che non possiamo cambiare e che viene deciso nel momento in cui viene generata la geometria, momento nel quale ai vertici vengono assegnate le coordinate uv. Per modificare il tipo di proiezione dovremmo necessariamente costruire in modo totalmente custom le geometrie, cosa che richiede non poche conoscenze di geometria e di matematica.

Alpha Map (opacity)

L'alpha map viene usata per determinare la trasparenza delle nostre geometrie. Alpha infatti è il nome del quarto canale dei colori rgba, dove a sta per alpha appunto, e gestisce proprio l'opacità.

Per questo tipo di texture vengono usate immagini in scala di grigi, anche se il canale determinate è il canale green. Queste immagini permettono di determinare quali parti della geometrie saranno opache e quali invece saranno trasparenti e anche con che intensità.

Infatti quando la GPU va calcolare il colore finale dei pixel della geometria va ad assegnare al canale alpha di questi pixel il canale green dei pixel dell'alpha map, come si può vedere dalla porzione di fragment (alphamap_fragment.glsl.js) che gestisce questo aspetto.

diffuseColor.a *= texture2D( alphaMap, vAlphaMapUv ).g;

Dove l'alpha map è bianca (quindi canale green = 1 ) la geometria risulterà completamente opaca, dove invece l'alpha map è nera (quindi canale green = 0 ) la geometria risulterà completamente trasparente, laddove invece la texture è grigia la geometria risulterà parzialmente trasparenze in base all'intensità di grigio.

Normal Map (bump)

Le normal map vengono utilizzate per aumentare il livello di dettaglio delle nostre geometrie senza effettivamente aumentare o modificare il numero dei vertici che le compongono. Vengono utilizzate per aumentare il realismo di quei materiali che non sono perfettamente lisci ma che hanno delle asperità, come per esempio la roccia, i mattoni, gli intonaci, i tessuti e le pelli e così via.

Per rappresentare in maniera fedele tali geometrie infatti dovremmo avere moltissimi vertici. Con la tecnica del normal mapping invece possiamo mantenere le geometrie molto leggere, usando i vertici per definire la geometria a livello macroscopico, lasciando alla texture il compito di aggiungere i dettagli in fase di rendering.

Questa tecnica lavora infatti sulle normali della geometria. Infatti il colore finale dei pixel non dipende solo dal colore dell'oggetto o della color map, ma soprattutto da come la luce incide sulla superficie della geometria che vuol dire che dipende dalla differenza tra le inclinazioni della luce e della normale della faccia a cui il pixel appartiene. Quindi tutti i pixel appartenenti ad una stessa faccia dovrebbero avere le stesse normali dando come risultato una faccia perfettamente piatta e liscia.

Con la normal map la normale di ogni pixel viene modificata usando i colori della texture simulando il fatto che sulla superficie ci siano delle asperità andando a creare delle piccole zone d'ombra senza modificare in alcun modo ne la quantità ne la posizione dei vertici della geometria.

Le normal map si riconoscono molto facilmente perché sono tutte tendenti al viola come colore dominante con porzioni che virano maggiormente sui tre canali rosso, verde e blu. Questo perchè il colore dei pixel (r,g,b) viene interpretato come un vettore (x,y,z) utilizzato per modificare le normali. Per fare in modo che i colori possano rappresentare tutte le possibili inclinazioni dei vettori viene fatta una conversione che possiamo vedere qui sotto:

normal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0; 

per cui il colore grigio (0.5,0.5,0.5) rappresenta il vettore nullo (0,0,0), il colore bianco (1,1,1) rappresenta il vettore (1,1,1) e il colore nero (0,0,0) rappresenta il vettore (-1,-1,-1). Quindi il colore predominante viola corrisponde al colore (0.5,0.5,1.), quindi al vettore (0,0,1) che è il vettore per cui la normale dei vertici non viene modificata, quindi rimane perfettamente perpendicolare alla faccia della geometria.

normalMap con componente y invertita

Come potete vedere nello screen qui sopra è possibile che la normalMap venga generata non seguendo la convenzione per mancini e non quella utilizzata da three.js e che quindi la componente y risulti invertita. Vedete come le ombre vengono generate nella parte superiore dei mattoni, invece che nella parte inferiore?

In tal caso è necessario impostare il valore di scala della normalMap, con cui possiamo anche regolare l'intensità con cui viene applicata la texture, per invertire la componente y, in questo modo.

const material = new THREE.MeshStandardMaterial({
normalMap,
normalScale: new THREE.Vector2(1, -1),
})

E come potete vedere adesso il problema è risolto e le normali sono corrette.

Displacement Map (height)

La displacement map, anche chiamata height map, non influenza direttamente il colore dei pixel bensì influenza la posizione dei vertici. A differenza della normal map quindi la displacement map modifica a tutti gli effetti la mesh, andando a spostare lungo le normali i vertici della geometria in base al colore del pixel letto dalla texture. In corrispondenza di colori più chiari i vertici subiscono spostamenti maggiori, mentre avranno spostamenti minori in corrispondenza di colori più scuri. L'intensità dello spostamento è gestito tramite un parametro (displacementScale) a se stante che è possibile modificare a piacimento.

A seguito di tali spostamenti le normali originali della geometria non hanno più nessuna corrispondenza con la nuova geometria ottenuta è quindi importante combinare una normalMap a questo tipo di texture.

Lavorando con i vertici questa tecnica funzionerà in maniera più dettagliata su quelle geometria con un numero elevato di vertici, che però risulteranno più pesanti, mentre avrà un dettaglio minore su geometrie con pochi vertici.

Diventa quindi molto interessante se utilizzata insieme alla normal map in modo calibrato per ottenere la massima resa con la minima spesa (in termine di numero di vertici).

effetto della displacement map sulla geometria, le sporgenze non generano ombre in quanto le nornamali non vengono aggiornate
effetto combinato di displacementMap e normalMap

Ambient Occlusion

L'ambiente occlusion è una tecnica di ombreggiatura delle geometrie che ne aumenta il realismo andando a simulare il fatto che certe parti di esse siamo più esposte all'illuminazione ambientale di altre. Quindi senza calcolare ombre provenienti da fonti di luce diretta, alcune parte risulteranno meno illuminate di altre per il semplice fatto che c'è maggior occlusione, ovvero che è più difficile che vengano raggiunte dalla luce che quindi le colpisce con maggior attenuazione.

Quindi l'ambiente occlusion map è una texture in scala di grigi in cui i pixel ci forniscono l'informazione di quanto una certa parte della geometria sia esposta o meno alla luce ambientale. Dove la texture è scura la geometria risulterà ombreggiata, dove è bianca invece sarà pienamente illuminata.

Non è una tecnica accurata dal punto di vista fisico ma in combinazione ad altre tecniche ci permette di ottenere un maggior realismo senza avere un grosso impatto sulle performance.

Questa tecnica ha effetto sono in presenza di una luce ambientale (AmbientLight) o emisferica (HemisphereLight). Altri tipi di luci non tengono conto dell'ambient occlusion.

combinazione di displacementMap, normalMap e aoMap

Come si può vedere da quest'ultima immagine, l'impatto della aoMap è molto più evidente laddove le geometrie sono illuminate esclusivamente dalla luce ambientale (parte più in ombra).

Metalness

Questa texture, anch'essa in scala di grigi ci permette di sapere quale parte della nostro materiale si comporta come un materiale metallico (bianco) e quale invece no (nero). Questa informazione viene utilizzata per calcolare il colore e i riflessi sulla geometria.

Roughness

La roughness map è una texture in scala di grigi utilizzata per determinare quale parte della geometria è ruvida (bianco) e quale invece è liscia (nero). Questa informazione ci aiuta a capire come la luce viene dissipata dal materiale e ha un impatto sull'intensità e sulla definizione dei riflessi sulle superfici.

Specular

Le specular map vengono utilizzate per indicare la riflettività di una superfici. Influenza quindi la brillantezza e il colore dei riflessi di una superfici colpita dalla luce. Non tutti i materiali di three.js supportato questa texture.

Come caricare una texture

Ora che abbiamo visto i diversi tipi di texture e come funzionano, vediamo come caricare le nostre texture ed applicarle ai materiali.

Per caricare una texture abbiamo bisogno dell'url del file. Dato che stiamo usando Vite come tool di sviluppo questo lo possiamo fare in diversi modi, a seconda che il file si trovi nella cartella /src o nella cartella /public.

In questo esempio useremo la cartella src dove troveremo tutti i file delle nostre texture.

import mapSrc from './src/textures/stone-wall/color.jpg'

JavaScript nativo

Dobbiamo creare un istanza della classe Image e agganciamo una callback alla funzione onload che verrà invocata quando l'immagine sarà completamente caricata dal browser. Per avviare il caricamento assegnamo alla proprietà src l'url del file che abbiamo importato poco fa.

const img = new Image()
img.onload = () => {
const map = new THREE.Texture(image)
console.log(map)
}

img.src = mapSrc

Dentro alla funzione di callback usiamo l'immagine caricata per creare un istanza della classe Texture che poi potremo assegnare ai nostri materiali.

Infatti non possiamo usare direttamente l'immagine caricata perché webGL ha bisogno di avere un formato molto specifico per poter funzionare. La classe Texture provvederà ad effettuare tutte le modifiche necessarie per creare una texture corretta.

A questo punto dobbiamo assegnare al materiale la texture. La variabile map si trova all'interno della funzione di callback, quindi facciamo una piccola modifica.

const img = new Image()
const map = new THREE.Texture(image)
img.onload = () => {
map.needsUpdate = true
}

img.src = mapSrc

...

const material = new THREE.MeshStandardMaterial({ map: map })

Abbiamo creato la texture prima che il file sia completamente caricato e lo abbiamo assegnato alla proprietà map del materiale. All'interno della funzione onload a questo punto dobbiamo ricordarci di assegnare il valore true alla proprietà needsUpdate della texture in modo tale che si aggiorni anche il nostro render.

Three.js TextureLoader

Anche se il metodo visto precedentemente non è poi così complicato, three.js ci aiuta con diversi loader tutte le volte che dobbiamo caricare un file di qualche tipo. Per le texture abbiamo a disposizione un TextureLoader, veidamo come funziona.

const textureLoader = new THREE.TextureLoader()
const map = textureLoader.load(mapSrc)

Creiamo l'istanza del loader che poi potremo utilizzare per caricate tutte le texture necessarie, e tramite il metodo load gli passiamo src del file di cui vogliamo creare la texture. Questo metodo ci ritorna direttamente la texture che verrà aggiornata non appena il file sarà stato caricato completamente. Esattamente come abbiamo fatto noi poco fa.

In aggiunta però ci permette di impostare 3 callback:

  • load
  • progress (not supported now)
  • error
const textureLoader = new THREE.TextureLoader()
const map = textureLoader.load(
mapSrc,
(texture) => {
console.log('caricamento completato',texture)
},
() => {
// use loader manager for progress indicator
console.log('not supported')
},
(err) => {
console.log('caricamento fallito',err)
},
)

Queste call possono essere utili per eseguire del codice in momenti precisi del caricamento, in particolare load ci permette di sapere quando il caricamento è finito ricevendo la texture come parametro della funzione. Mentre error ci permette di ottenere informazioni sul tipo di errore che ha causato il fallimento.

Loading manager

Quando in una scena abbiamo bisogno di caricare molti assets, quindi molte immagini per le texture, o anche dei modelli 3D, diventa utile gestire il caricamento in maniera organizzata in modo da eseguire il nostro applicativo solo quando tutti i file sono stati caricati completamente, magari mostrando un unica progress bar.

Creiamo un istanza del LoadingManager, e passiamolo al costruttore del TextureLoader.

const loadingManager = new THREE.LoadingManager()
const textureLoader = new THREE.TextureLoader(loadingManager)

E a questo punto possiamo impostare al LoadingManager le seguenti callback:

  • onStart
  • onLoad
  • onProgress
  • onError
const loadingManager = new THREE.LoadingManager()
loadingManager.onStart = (url, itemsLoaded, itemsTotal) =>
{
console.log('caricamento iniziato')
}
loadingManager.onLoad = () =>
{
console.log('caricamento completato')
}
loadingManager.onProgress = (url, itemsLoaded, itemsTotal) =>
{
console.log('caricamento in corso:' + Math.ceil(100 * itemsLoaded / itemsTotal) + '% ' )
}
loadingManager.onError = (url) =>
{
console.log('carcamento fallito')
}

const textureLoader = new THREE.TextureLoader(loadingManager)

E così possiamo caricare tutte le texture necessarie al nostro progetto.

import mapSrc from './src/textures/stone-wall/color.jpg'
import normalMapSrc from './src/textures/stone-wall/normal.jpg'
import dispMapSrc from './src/textures/stone-wall/displacement.jpg'
import aoMapSrc from './src/textures/stone-wall/occlusion.jpg'

...

const map = textureLoader.load(mapSrc)
const normalMap = textureLoader.load(normalMapSrc)
const displacementMap = textureLoader.load(dispMapSrc)
const aoMap = textureLoader.load(aoMapSrc)

...

const material = new THREE.MeshStandardMaterial({
map,
displacementMap,
displacementScale: 0.05,
normalMap,
normalScale: new THREE.Vector2(0.8, -0.8),
aoMap,
aoMapIntensity: 1,
})

Conclusioni

Abbiamo fatto un piccolo tuffo nel rendering realistico, anche se questo è solo un piccolo passo. Ora sappiamo quindi cos'è una texture e inoltre sappiamo che ne abbiamo a disposizioni diversi tipi con le quali possiamo gestire diversi effetti sulle nostre geometrie.

Nel prossimo articolo vedremo come possiamo applicare delle trasformazioni alle texture, utili nel caso ci sia la necessita di spostare, scalare o ruotare una texture sulla superficie della geometria, funzionalità molto ricorrenti nei configuratori 3D di prodotti.

--

--

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