Grafica 3D: un tutorial WebGL
Pubblicato: 2022-03-11Entrare nel mondo della grafica 3D può essere molto intimidatorio. Sia che tu voglia semplicemente creare un logo 3D interattivo o progettare un gioco a tutti gli effetti, se non conosci i principi del rendering 3D, sei bloccato utilizzando una libreria che astrae molte cose.
L'uso di una libreria può essere lo strumento giusto e JavaScript ne ha uno straordinario open source sotto forma di three.js. Tuttavia, esistono alcuni svantaggi nell'utilizzo di soluzioni predefinite:
- Possono avere molte funzionalità che non prevedi di utilizzare. La dimensione delle funzionalità di base tre.js ridotte è di circa 500 kB e qualsiasi funzionalità aggiuntiva (il caricamento dei file del modello effettivo è una di queste) rende il carico utile ancora più grande. Trasferire così tanti dati solo per mostrare un logo rotante sul tuo sito web sarebbe uno spreco.
- Un ulteriore livello di astrazione può rendere difficili le modifiche altrimenti facili. Il tuo modo creativo di ombreggiare un oggetto sullo schermo può essere semplice da implementare o richiedere decine di ore di lavoro da incorporare nelle astrazioni della libreria.
- Sebbene la libreria sia ottimizzata molto bene nella maggior parte degli scenari, molti campanelli e fischietti possono essere ritagliati per il tuo caso d'uso. Il renderer può far eseguire determinate procedure milioni di volte sulla scheda grafica. Ogni istruzione rimossa da tale procedura significa che una scheda grafica più debole può gestire i tuoi contenuti senza problemi.
Anche se decidi di utilizzare una libreria grafica di alto livello, avere una conoscenza di base delle cose nascoste ti consente di utilizzarla in modo più efficace. Le librerie possono anche avere funzionalità avanzate, come ShaderMaterial
in three.js
. Conoscere i principi del rendering grafico consente di utilizzare tali funzionalità.
Il nostro obiettivo è fornire una breve introduzione a tutti i concetti chiave alla base del rendering di grafica 3D e l'utilizzo di WebGL per implementarli. Vedrai la cosa più comune che viene eseguita, ovvero mostrare e spostare oggetti 3D in uno spazio vuoto.
Il codice finale è disponibile per il fork e il gioco.
Rappresentazione di modelli 3D
La prima cosa che dovresti capire è come vengono rappresentati i modelli 3D. Un modello è costituito da una maglia di triangoli. Ogni triangolo è rappresentato da tre vertici, per ciascuno degli angoli del triangolo. Ci sono tre proprietà più comuni associate ai vertici.
Posizione del vertice
La posizione è la proprietà più intuitiva di un vertice. È la posizione nello spazio 3D, rappresentata da un vettore 3D di coordinate. Se conosci le coordinate esatte di tre punti nello spazio, avresti tutte le informazioni necessarie per disegnare un semplice triangolo tra di loro. Per fare in modo che i modelli abbiano un bell'aspetto durante il rendering, ci sono un altro paio di cose che devono essere fornite al renderer.
Vertice normale
Considera i due modelli sopra. Sono costituiti dalle stesse posizioni dei vertici, ma hanno un aspetto completamente diverso quando vengono renderizzati. Come è possibile?
Oltre a dire al renderer dove vogliamo che si trovi un vertice, possiamo anche dargli un suggerimento su come la superficie è inclinata in quella posizione esatta. Il suggerimento è nella forma della normale della superficie in quel punto specifico del modello, rappresentato con un vettore 3D. L'immagine seguente dovrebbe darti uno sguardo più descrittivo su come viene gestito.
La superficie sinistra e destra corrispondono rispettivamente alla sfera sinistra e destra nell'immagine precedente. Le frecce rosse rappresentano le normali specificate per un vertice, mentre le frecce blu rappresentano i calcoli del renderer su come dovrebbe apparire la normale per tutti i punti tra i vertici. L'immagine mostra una dimostrazione per lo spazio 2D, ma lo stesso principio si applica in 3D.
Il normale è un suggerimento su come le luci illumineranno la superficie. Più la direzione di un raggio di luce è vicina alla normale, più luminoso sarà il punto. Avere cambiamenti graduali nella direzione normale provoca gradienti di luce, mentre cambiamenti bruschi senza cambiamenti intermedi provocano superfici con illuminazione costante su di esse e improvvisi cambiamenti di illuminazione tra di loro.
Coordinate della trama
L'ultima proprietà significativa sono le coordinate della trama, comunemente denominate mappatura UV. Hai un modello e una texture che vuoi applicare ad esso. La trama ha varie aree su di essa, che rappresentano le immagini che vogliamo applicare a diverse parti del modello. Ci deve essere un modo per contrassegnare quale triangolo dovrebbe essere rappresentato con quale parte della trama. È qui che entra in gioco la mappatura delle texture.
Per ogni vertice, segniamo due coordinate, U e V. Queste coordinate rappresentano una posizione sulla trama, con U che rappresenta l'asse orizzontale e V l'asse verticale. I valori non sono in pixel, ma una posizione percentuale all'interno dell'immagine. L'angolo in basso a sinistra dell'immagine è rappresentato con due zeri, mentre l'angolo in alto a destra è rappresentato con due zeri.
Un triangolo viene semplicemente dipinto prendendo le coordinate UV di ciascun vertice nel triangolo e applicando l'immagine catturata tra quelle coordinate sulla trama.
Puoi vedere una dimostrazione della mappatura UV nell'immagine sopra. Il modello sferico è stato preso e tagliato in parti abbastanza piccole da poter essere appiattite su una superficie 2D. Le cuciture dove sono stati realizzati i tagli sono contrassegnate da linee più spesse. Una delle patch è stata evidenziata, quindi puoi vedere bene come le cose corrispondono. Puoi anche vedere come una cucitura nel mezzo del sorriso posiziona parti della bocca in due chiazze diverse.
I wireframe non fanno parte della trama, ma sono semplicemente sovrapposti all'immagine in modo da poter vedere come le cose si mappano insieme.
Caricamento di un modello OBJ
Che tu ci creda o no, questo è tutto ciò che devi sapere per creare il tuo semplice caricatore di modelli. Il formato del file OBJ è abbastanza semplice da implementare un parser in poche righe di codice.
Il file elenca le posizioni dei vertici in un formato v <float> <float> <float>
, con un quarto float opzionale, che ignoreremo, per semplificare le cose. Le normali ai vertici sono rappresentate in modo simile con vn <float> <float> <float>
. Infine, le coordinate della trama sono rappresentate con vt <float> <float>
, con un terzo float opzionale che ignoreremo. In tutti e tre i casi, i float rappresentano le rispettive coordinate. Queste tre proprietà vengono accumulate in tre array.
Le facce sono rappresentate con gruppi di vertici. Ogni vertice è rappresentato con l'indice di ciascuna delle proprietà, per cui gli indici iniziano da 1. Ci sono vari modi per rappresentarlo, ma ci atterremo al formato f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3
, richiedendo la fornitura di tutte e tre le proprietà e limitando a tre il numero di vertici per faccia. Tutte queste limitazioni vengono fatte per mantenere il caricatore il più semplice possibile, poiché tutte le altre opzioni richiedono un'elaborazione extra banale prima che siano in un formato che piace a WebGL.
Abbiamo inserito molti requisiti per il nostro caricatore di file. Può sembrare limitante, ma le applicazioni di modellazione 3D tendono a darti la possibilità di impostare tali limitazioni durante l'esportazione di un modello come file OBJ.
Il codice seguente analizza una stringa che rappresenta un file OBJ e crea un modello sotto forma di una matrice di facce.
function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }
La struttura Geometry
contiene i dati esatti necessari per inviare un modello alla scheda grafica per l'elaborazione. Prima di farlo, però, probabilmente vorresti avere la possibilità di spostare il modello sullo schermo.
Esecuzione di trasformazioni spaziali
Tutti i punti nel modello che abbiamo caricato sono relativi al suo sistema di coordinate. Se vogliamo traslare, ruotare e ridimensionare il modello, tutto ciò che dobbiamo fare è eseguire quell'operazione sul suo sistema di coordinate. Il sistema di coordinate A, relativo al sistema di coordinate B, è definito dalla posizione del suo centro come vettore p_ab
e dal vettore per ciascuno dei suoi assi, x_ab
, y_ab
e z_ab
, che rappresenta la direzione di quell'asse. Quindi, se un punto si sposta di 10 sull'asse x
del sistema di coordinate A, allora, nel sistema di coordinate B, si sposterà nella direzione di x_ab
, moltiplicato per 10.
Tutte queste informazioni sono memorizzate nella seguente forma matriciale:
x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1
Se vogliamo trasformare il vettore 3D q
, dobbiamo solo moltiplicare la matrice di trasformazione per il vettore:
qx qy qz 1
Questo fa sì che il punto si sposti di qx
lungo il nuovo asse x
, di qy
lungo il nuovo asse y
e di qz
lungo il nuovo asse z
. Infine, fa sì che il punto si sposti ulteriormente del vettore p
, motivo per cui usiamo uno come elemento finale della moltiplicazione.
Il grande vantaggio dell'utilizzo di queste matrici è il fatto che se abbiamo più trasformazioni da eseguire sul vertice, possiamo unirle in un'unica trasformazione moltiplicando le loro matrici, prima di trasformare il vertice stesso.
Ci sono varie trasformazioni che possono essere eseguite e daremo un'occhiata a quelle chiave.
Nessuna trasformazione
Se non si verificano trasformazioni, il vettore p
è un vettore zero, il vettore x
è [1, 0, 0]
, y
è [0, 1, 0]
e z
è [0, 0, 1]
. D'ora in poi faremo riferimento a questi valori come valori predefiniti per questi vettori. L'applicazione di questi valori ci fornisce una matrice di identità:
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
Questo è un buon punto di partenza per concatenare le trasformazioni.
Traduzione
Quando eseguiamo la traslazione, tutti i vettori tranne il vettore p
hanno i loro valori predefiniti. Ciò si traduce nella seguente matrice:
1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1
Ridimensionamento
Ridimensionare un modello significa ridurre la quantità che ciascuna coordinata contribuisce alla posizione di un punto. Non esiste un offset uniforme causato dal ridimensionamento, quindi il vettore p
mantiene il suo valore predefinito. I vettori dell'asse predefiniti devono essere moltiplicati per i rispettivi fattori di scala, ottenendo la seguente matrice:
s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1
Qui s_x
, s_y
e s_z
rappresentano il ridimensionamento applicato a ciascun asse.
Rotazione
L'immagine sopra mostra cosa succede quando ruotiamo la cornice delle coordinate attorno all'asse Z.
La rotazione non produce alcun offset uniforme, quindi il vettore p
mantiene il suo valore predefinito. Ora le cose si fanno un po' più complicate. Le rotazioni fanno sì che il movimento lungo un determinato asse nel sistema di coordinate originale si sposti in una direzione diversa. Quindi, se y
un sistema di coordinate di 45 gradi attorno all'asse Z, lo spostamento lungo l'asse x
del sistema di coordinate originale provoca il movimento in direzione diagonale tra l'asse x
nel nuovo sistema di coordinate.
Per semplificare le cose, ti mostreremo semplicemente come le matrici di trasformazione cercano le rotazioni attorno agli assi principali.
Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1
Implementazione
Tutto ciò può essere implementato come una classe che memorizza 16 numeri, memorizzando le matrici in un ordine di colonna principale.
function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }
Guardando attraverso una fotocamera
Ecco la parte fondamentale della presentazione degli oggetti sullo schermo: la fotocamera. Ci sono due componenti chiave in una fotocamera; vale a dire, la sua posizione e il modo in cui proietta gli oggetti osservati sullo schermo.
La posizione della telecamera viene gestita con un semplice trucco. Non c'è differenza visiva tra lo spostamento della telecamera di un metro in avanti e lo spostamento del mondo intero di un metro indietro. Quindi, naturalmente, facciamo quest'ultimo, applicando l'inverso della matrice come trasformazione.
Il secondo componente chiave è il modo in cui gli oggetti osservati vengono proiettati sull'obiettivo. In WebGL, tutto ciò che è visibile sullo schermo si trova in una casella. La casella si estende tra -1 e 1 su ciascun asse. Tutto ciò che è visibile è all'interno di quella scatola. Possiamo usare lo stesso approccio delle matrici di trasformazione per creare una matrice di proiezione.
Proiezione ortogonale
La proiezione più semplice è la proiezione ortografica. Prendi una scatola nello spazio, che denota la larghezza, l'altezza e la profondità, presupponendo che il suo centro sia nella posizione zero. Quindi la proiezione ridimensiona la casella per adattarla alla casella precedentemente descritta all'interno della quale WebGL osserva gli oggetti. Poiché vogliamo ridimensionare ogni dimensione a due, ridimensioniamo ogni asse di 2/size
, dove size
è la dimensione del rispettivo asse. Un piccolo avvertimento è il fatto che stiamo moltiplicando l'asse Z con un negativo. Questo viene fatto perché vogliamo invertire la direzione di quella dimensione. La matrice finale ha questa forma:
2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1
Proiezione prospettica
Non analizzeremo i dettagli di come è progettata questa proiezione, ma useremo semplicemente la formula finale, che ormai è praticamente standard. Possiamo semplificarlo posizionando la proiezione nella posizione zero sull'asse xey, rendendo i limiti destro/sinistro e superiore/inferiore uguali rispettivamente a width/2
e height/2
. I parametri n
e f
rappresentano i piani di ritaglio near
e far
, che sono la distanza minima e massima a cui un punto può essere catturato dalla fotocamera. Sono rappresentati dai lati paralleli del tronco nell'immagine sopra.
Una proiezione prospettica è solitamente rappresentata con un campo visivo (useremo quello verticale), proporzioni e le distanze del piano vicino e lontano. Tali informazioni possono essere utilizzate per calcolare width
e height
, quindi la matrice può essere creata dal seguente modello:
2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0
Per calcolare la larghezza e l'altezza si possono utilizzare le seguenti formule:
height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height
Il FOV (campo visivo) rappresenta l'angolo verticale che la fotocamera cattura con il suo obiettivo. Le proporzioni rappresentano il rapporto tra larghezza e altezza dell'immagine e si basa sulle dimensioni dello schermo su cui stiamo eseguendo il rendering.
Implementazione
Ora possiamo rappresentare una telecamera come una classe che memorizza la posizione della telecamera e la matrice di proiezione. Dobbiamo anche sapere come calcolare le trasformazioni inverse. Risolvere le inversioni di matrice generali può essere problematico, ma esiste un approccio semplificato per il nostro caso speciale.
function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }
Questo è l'ultimo pezzo di cui abbiamo bisogno prima di poter iniziare a disegnare cose sullo schermo.
Disegnare un oggetto con la pipeline grafica WebGL
La superficie più semplice che puoi disegnare è un triangolo. In effetti, la maggior parte delle cose che disegni nello spazio 3D sono costituite da un gran numero di triangoli.
La prima cosa che devi capire è come viene rappresentato lo schermo in WebGL. È uno spazio 3D, compreso tra -1 e 1 sull'asse x , yez . Per impostazione predefinita, questo asse z non viene utilizzato, ma sei interessato alla grafica 3D, quindi ti consigliamo di abilitarlo immediatamente.
Tenendo questo in mente, ciò che segue sono tre passaggi necessari per disegnare un triangolo su questa superficie.
Puoi definire tre vertici, che rappresenterebbero il triangolo che vuoi disegnare. Serializzi quei dati e li invii alla GPU (unità di elaborazione grafica). Con un intero modello disponibile, puoi farlo per tutti i triangoli nel modello. Le posizioni dei vertici che fornisci si trovano nello spazio delle coordinate locali del modello che hai caricato. In parole povere, le posizioni che fornisci sono quelle esatte dal file e non quella che ottieni dopo aver eseguito le trasformazioni di matrice.
Ora che hai assegnato i vertici alla GPU, dici alla GPU quale logica utilizzare quando si posizionano i vertici sullo schermo. Questo passaggio verrà utilizzato per applicare le nostre trasformazioni di matrice. La GPU è molto brava a moltiplicare molte matrici 4x4, quindi faremo buon uso di questa capacità.
Nell'ultimo passaggio, la GPU rasterizzerà quel triangolo. La rasterizzazione è il processo di acquisizione di grafica vettoriale e determinazione di quali pixel dello schermo devono essere dipinti per visualizzare l'oggetto di grafica vettoriale. Nel nostro caso, la GPU sta cercando di determinare quali pixel si trovano all'interno di ciascun triangolo. Per ogni pixel, la GPU ti chiederà di che colore vuoi che venga dipinto.
Questi sono i quattro elementi necessari per disegnare tutto ciò che vuoi e sono l'esempio più semplice di pipeline grafica. Quello che segue è uno sguardo a ciascuno di essi e una semplice implementazione.
Il framebuffer predefinito
L'elemento più importante per un'applicazione WebGL è il contesto WebGL. Puoi accedervi con gl = canvas.getContext('webgl')
o utilizzare 'experimental-webgl'
come fallback, nel caso in cui il browser attualmente utilizzato non supporti ancora tutte le funzionalità di WebGL. La canvas
a cui ci siamo riferiti è l'elemento DOM della tela su cui vogliamo disegnare. Il contesto contiene molte cose, tra cui il framebuffer predefinito.
Potresti descrivere vagamente un framebuffer come qualsiasi buffer (oggetto) su cui puoi attingere. Per impostazione predefinita, il framebuffer predefinito memorizza il colore per ogni pixel della tela a cui è associato il contesto WebGL. Come descritto nella sezione precedente, quando disegniamo sul framebuffer, ogni pixel si trova tra -1 e 1 sull'asse xey . Qualcosa che abbiamo anche menzionato è il fatto che, per impostazione predefinita, WebGL non utilizza l'asse z . Tale funzionalità può essere abilitata eseguendo gl.enable(gl.DEPTH_TEST)
. Ottimo, ma cos'è un test di profondità?
L'abilitazione del test di profondità consente a un pixel di memorizzare sia il colore che la profondità. La profondità è la coordinata z di quel pixel. Dopo aver disegnato su un pixel a una certa profondità z , per aggiornare il colore di quel pixel, devi disegnare in una posizione z più vicina alla fotocamera. In caso contrario, il tentativo di pareggio verrà ignorato. Ciò consente l'illusione del 3D, poiché disegnare oggetti che si trovano dietro altri oggetti farà sì che quegli oggetti vengano occlusi da oggetti davanti a loro.
Tutte le estrazioni che esegui rimangono sullo schermo finché non dici loro di essere cancellate. Per farlo, devi chiamare gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
. Questo cancella sia il colore che il buffer di profondità. Per scegliere il colore su cui sono impostati i pixel cancellati, usa gl.clearColor(red, green, blue, alpha)
.
Creiamo un renderer che utilizzi una tela e la cancelli su richiesta:
function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }
Allegare questo script al seguente HTML ti darà un rettangolo blu brillante sullo schermo
<!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>
La chiamata requestAnimationFrame
fa sì che il ciclo venga richiamato non appena il frame precedente ha terminato il rendering e tutta la gestione degli eventi è terminata.
Oggetti del buffer di vertice
La prima cosa che devi fare è definire i vertici che vuoi disegnare. Puoi farlo descrivendoli tramite vettori nello spazio 3D. Successivamente, vuoi spostare quei dati nella RAM della GPU, creando un nuovo Vertex Buffer Object (VBO).
Un oggetto buffer in generale è un oggetto che memorizza un array di blocchi di memoria sulla GPU. Essendo un VBO denota solo per cosa la GPU può utilizzare la memoria. Il più delle volte, gli oggetti buffer che crei saranno VBO.
Puoi riempire il VBO prendendo tutti gli N
vertici che abbiamo e creando un array di float con 3N
elementi per la posizione del vertice e VBO normali del vertice e 2N
per le coordinate della trama VBO. Ciascun gruppo di tre float, o due float per le coordinate UV, rappresenta le singole coordinate di un vertice. Quindi passiamo questi array alla GPU e i nostri vertici sono pronti per il resto della pipeline.

Poiché i dati sono ora sulla RAM della GPU, puoi eliminarli dalla RAM per uso generico. Cioè, a meno che tu non voglia modificarlo in seguito e caricarlo di nuovo. Ogni modifica deve essere seguita da un caricamento, poiché le modifiche nei nostri array JS non si applicano ai VBO nella RAM della GPU effettiva.
Di seguito è riportato un esempio di codice che fornisce tutte le funzionalità descritte. Una nota importante da fare è il fatto che le variabili memorizzate sulla GPU non vengono raccolte in modo spazzatura. Ciò significa che dobbiamo eliminarli manualmente una volta che non vogliamo più usarli. Ti daremo solo un esempio di come ciò viene fatto qui e non ci concentreremo su quel concetto più avanti. L'eliminazione di variabili dalla GPU è necessaria solo se si prevede di interrompere l'utilizzo di determinate geometrie in tutto il programma.
Abbiamo anche aggiunto la serializzazione alla nostra classe Geometry
e agli elementi al suo interno.
Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }
Il tipo di dati VBO
genera il VBO nel contesto WebGL passato, in base all'array passato come secondo parametro.
Puoi vedere tre chiamate al contesto gl
. La chiamata createBuffer()
crea il buffer. La chiamata bindBuffer()
dice alla macchina a stati WebGL di utilizzare questa memoria specifica come VBO corrente ( ARRAY_BUFFER
) per tutte le operazioni future, fino a quando non viene specificato diversamente. Successivamente, impostiamo il valore del VBO corrente sui dati forniti, con bufferData()
.
Forniamo anche un metodo di distruzione che elimina il nostro oggetto buffer dalla RAM della GPU, utilizzando deleteBuffer()
.
Puoi usare tre VBO e una trasformazione per descrivere tutte le proprietà di una mesh, insieme alla sua posizione.
function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }
Ad esempio, ecco come possiamo caricare un modello, memorizzarne le proprietà nella mesh e quindi distruggerlo:
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })
Shader
Quello che segue è il processo in due fasi descritto in precedenza per spostare i punti nelle posizioni desiderate e dipingere tutti i singoli pixel. Per fare ciò, scriviamo un programma che viene eseguito più volte sulla scheda grafica. Questo programma consiste in genere di almeno due parti. La prima parte è un Vertex Shader , che viene eseguito per ogni vertice e restituisce dove dovremmo posizionare il vertice sullo schermo, tra le altre cose. La seconda parte è il Fragment Shader , che viene eseguito per ogni pixel coperto da un triangolo sullo schermo e restituisce il colore su cui il pixel deve essere dipinto.
Vertex Shader
Diciamo che vuoi avere un modello che si muova a sinistra ea destra sullo schermo. In un approccio ingenuo, potresti aggiornare la posizione di ciascun vertice e inviarlo nuovamente alla GPU. Questo processo è costoso e lento. In alternativa, potresti fornire un programma per la GPU da eseguire per ogni vertice ed eseguire tutte quelle operazioni in parallelo con un processore creato per fare esattamente quel lavoro. Questo è il ruolo di un vertex shader .
Un vertex shader è la parte della pipeline di rendering che elabora i singoli vertici. Una chiamata al vertex shader riceve un singolo vertice e genera un singolo vertice dopo che tutte le possibili trasformazioni al vertice sono state applicate.
Gli shader sono scritti in GLSL. Ci sono molti elementi unici in questo linguaggio, ma la maggior parte della sintassi è molto simile al C, quindi dovrebbe essere comprensibile alla maggior parte delle persone.
Esistono tre tipi di variabili che entrano ed escono da un vertex shader e tutte servono a un uso specifico:
-
attribute
— Questi sono input che contengono proprietà specifiche di un vertice. In precedenza, abbiamo descritto la posizione di un vertice come attributo, nella forma di un vettore a tre elementi. Puoi considerare gli attributi come valori che descrivono un vertice. -
uniform
— Questi sono gli input che sono gli stessi per ogni vertice all'interno della stessa chiamata di rendering. Diciamo che vogliamo essere in grado di spostare il nostro modello, definendo una matrice di trasformazione. Puoi usare una variabileuniform
per descriverlo. Puoi anche puntare alle risorse sulla GPU, come le trame. Puoi considerare le uniformi come valori che descrivono un modello o una parte di un modello. -
varying
— Questi sono gli output che passiamo allo shader del frammento. Poiché ci sono potenzialmente migliaia di pixel per un triangolo di vertici, ogni pixel riceverà un valore interpolato per questa variabile, a seconda della posizione. Quindi, se un vertice invia 500 come output e un altro 100, un pixel che si trova nel mezzo tra loro riceverà 300 come input per quella variabile. Puoi considerare le variazioni come valori che descrivono le superfici tra i vertici.
So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” potresti chiedere.
attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }
Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main
function. All values that we would want to return are assigned, either to varying
variables, or to special variables. Here we assign to gl_Position
, which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4
out of the position vector. You can construct a vec4
by using four float
s, two vec2
s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.
You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.
For now, we will keep it simple, and move on to painting individual pixels.
Fragment Shaders
A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.
The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:
- There are no more
varying
outputs, andattribute
inputs have been replaced withvarying
inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader. - Our only output now is
gl_FragColor
, which is avec4
. The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects. - At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.
With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }
The function clamp
just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.
With all of this in mind, all that is left is to implement this in WebGL.
Combining Shaders into a Program
The next step is to combine the shaders into a program:
function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }
There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.
Actually Drawing the Model
Last, but not least, you draw the model.
First you pick the shader program you want to use.
ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }
Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.
Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }
Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.
VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }
Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays()
from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES
takes three by three vertices and draws a triangle for each triplet. Using POINTS
would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:
Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }
The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.
Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
We can combine all the elements that we have to finally draw something on the screen:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) }
This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); }
It doesn't look very convincing. It looks like the scene needs some shading effects.
Adding Light
Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.
Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.
You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.
If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); }
Abbiamo impostato il sole per splendere nella direzione avanti-sinistra-basso. Puoi vedere quanto è liscia l'ombreggiatura, anche se il modello è molto frastagliato. Puoi anche notare quanto sia scuro il lato in basso a sinistra. Possiamo aggiungere un livello di luce ambientale, che renderà più luminosa l'area in ombra.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
È possibile ottenere lo stesso effetto introducendo una classe di luce, che memorizza la direzione della luce e l'intensità della luce ambientale. Quindi puoi modificare lo shader del frammento per adattarlo a tale aggiunta.
Ora lo shader diventa:
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
Quindi puoi definire la luce:
function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }
Nella classe del programma shader, aggiungi le uniformi necessarie:
this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')
Nel programma, aggiungi una chiamata alla nuova luce nel renderer:
Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
Il ciclo cambierà quindi leggermente:
var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
Se hai fatto tutto bene, l'immagine renderizzata dovrebbe essere la stessa dell'ultima immagine.
Un ultimo passaggio da considerare sarebbe l'aggiunta di una texture reale al nostro modello. Facciamolo ora.
Aggiunta di trame
HTML5 ha un ottimo supporto per il caricamento delle immagini, quindi non è necessario eseguire un'analisi pazzesca delle immagini. Le immagini vengono passate a GLSL come sampler2D
indicando allo shader quale delle trame associate campionare. C'è un numero limitato di trame che si possono legare e il limite si basa sull'hardware utilizzato. Un sampler2D
può essere interrogato per i colori in determinate posizioni. È qui che entrano in gioco le coordinate UV. Ecco un esempio in cui abbiamo sostituito il marrone con i colori campionati.
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
La nuova divisa deve essere aggiunta all'elenco nel programma shader:
this.diffuse = gl.getUniformLocation(program, 'diffuse')
Infine, implementeremo il caricamento delle texture. Come detto in precedenza, HTML5 fornisce funzionalità per il caricamento delle immagini. Tutto quello che dobbiamo fare è inviare l'immagine alla GPU:
function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }
Il processo non è molto diverso dal processo utilizzato per caricare e associare i VBO. La differenza principale è che non stiamo più legando a un attributo, ma piuttosto l'indice della texture a un'uniforme intera. Il tipo sampler2D
non è altro che un puntatore spostato su una texture.
Ora tutto ciò che deve essere fatto è estendere la classe Mesh
, per gestire anche le trame:
function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }
E lo script principale finale sarebbe il seguente:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
Anche l'animazione è facile a questo punto. Se vuoi che la fotocamera ruoti attorno al nostro oggetto, puoi farlo semplicemente aggiungendo una riga di codice:
function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) }
Sentiti libero di giocare con gli shader. L'aggiunta di una riga di codice trasformerà questa illuminazione realistica in qualcosa di da cartone animato.
void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
È semplice come dire all'illuminazione di andare ai suoi estremi in base al fatto che ha superato una soglia impostata.
Dove andare dopo
Ci sono molte fonti di informazioni per imparare tutti i trucchi e le complessità di WebGL. E la parte migliore è che se non riesci a trovare una risposta relativa a WebGL, puoi cercarla in OpenGL, poiché WebGL è praticamente basato su un sottoinsieme di OpenGL, con alcuni nomi modificati.
In nessun ordine particolare, ecco alcune ottime fonti per informazioni più dettagliate, sia per WebGL che per OpenGL.
- Fondamenti di WebGL
- Imparare WebGL
- Un tutorial OpenGL molto dettagliato che ti guida attraverso tutti i principi fondamentali qui descritti, in modo molto lento e dettagliato.
- E ci sono molti, molti altri siti dedicati a insegnarti i principi della computer grafica.
- Documentazione MDN per WebGL
- Specifiche Khronos WebGL 1.0 per chi è interessato a comprendere i dettagli più tecnici su come dovrebbe funzionare l'API WebGL in tutti i casi limite.