Grafică 3D: un tutorial WebGL
Publicat: 2022-03-11Lumea graficii 3D poate fi foarte intimidantă pentru a intra. Indiferent dacă doriți doar să creați un logo 3D interactiv sau să proiectați un joc complet, dacă nu cunoașteți principiile randării 3D, sunteți blocat să utilizați o bibliotecă care abstrage o mulțime de lucruri.
Folosirea unei biblioteci poate fi instrumentul potrivit, iar JavaScript are o sursă deschisă uimitoare sub forma three.js. Există totuși câteva dezavantaje în utilizarea soluțiilor prefabricate:
- Ele pot avea multe caracteristici pe care nu intenționați să le utilizați. Dimensiunea caracteristicilor minime de bază three.js este de aproximativ 500 kB, iar orice caracteristică suplimentară (încărcarea fișierelor model reale este una dintre ele) fac încărcarea utilă și mai mare. Transferul atât de multe date doar pentru a afișa o siglă rotativă pe site-ul dvs. ar fi o risipă.
- Un strat suplimentar de abstractizare poate face ca modificările altfel ușoare să fie greu de făcut. Modul tău creativ de a umbri un obiect pe ecran poate fi fie simplu de implementat, fie necesită zeci de ore de muncă pentru a le incorpora în abstracțiile bibliotecii.
- În timp ce biblioteca este optimizată foarte bine în majoritatea scenariilor, o mulțime de clopote și fluiere pot fi tăiate pentru cazul dvs. de utilizare. Randamentul poate face ca anumite proceduri să ruleze de milioane de ori pe placa grafică. Fiecare instrucțiune eliminată dintr-o astfel de procedură înseamnă că o placă grafică mai slabă poate gestiona conținutul tău fără probleme.
Chiar dacă decideți să utilizați o bibliotecă grafică de nivel înalt, a avea cunoștințe de bază despre lucrurile de sub capotă vă permite să o utilizați mai eficient. Bibliotecile pot avea și funcții avansate, cum ar fi ShaderMaterial
în three.js
. Cunoașterea principiilor randării grafice vă permite să utilizați astfel de caracteristici.
Scopul nostru este să oferim o scurtă introducere a tuturor conceptelor cheie din spatele redării graficelor 3D și a utilizării WebGL pentru a le implementa. Veți vedea cel mai obișnuit lucru care se face, care este afișarea și mutarea obiectelor 3D într-un spațiu gol.
Codul final este disponibil pentru a vă bifurca și a vă juca.
Reprezentarea modelelor 3D
Primul lucru pe care ar trebui să-l înțelegeți este cum sunt reprezentate modelele 3D. Un model este format dintr-o plasă de triunghiuri. Fiecare triunghi este reprezentat de trei vârfuri, pentru fiecare dintre colțurile triunghiului. Există trei cele mai comune proprietăți atașate vârfurilor.
Poziția vârfurilor
Poziția este cea mai intuitivă proprietate a unui vârf. Este poziția în spațiul 3D, reprezentată de un vector 3D de coordonate. Dacă știi coordonatele exacte a trei puncte din spațiu, ai avea toate informațiile de care ai nevoie pentru a desena un triunghi simplu între ele. Pentru ca modelele să arate bine atunci când sunt redate, mai sunt câteva lucruri care trebuie furnizate rendererului.
Vertex Normal
Luați în considerare cele două modele de mai sus. Ele constau din aceleași poziții de vârf, dar arată total diferit atunci când sunt redate. Cum este posibil?
Pe lângă faptul că îi spunem rendererului unde vrem să fie localizat un vârf, îi putem oferi și un indiciu despre modul în care suprafața este înclinată în acea poziție exactă. Sugestia este sub forma normalei suprafeței în acel punct specific al modelului, reprezentată cu un vector 3D. Următoarea imagine ar trebui să vă ofere o privire mai descriptivă asupra modului în care este tratat.
Suprafața din stânga și din dreapta corespund mingii din stânga și respectiv din dreapta din imaginea anterioară. Săgețile roșii reprezintă valorile normale care sunt specificate pentru un vârf, în timp ce săgețile albastre reprezintă calculele rendererului despre cum ar trebui să arate normalul pentru toate punctele dintre vârfuri. Imaginea prezintă o demonstrație pentru spațiul 2D, dar același principiu se aplică și în 3D.
Normal este un indiciu despre modul în care luminile vor ilumina suprafața. Cu cât direcția unei raze de lumină este mai aproape de normal, cu atât punctul este mai luminos. Schimbările treptate în direcția normală cauzează gradienți de lumină, în timp ce schimbări bruște, fără modificări între ele, provoacă suprafețe cu iluminare constantă peste ele și schimbări bruște de iluminare între ele.
Coordonatele texturii
Ultima proprietate semnificativă sunt coordonatele texturii, denumite în mod obișnuit mapare UV. Aveți un model și o textură pe care doriți să o aplicați. Textura are diverse zone pe ea, reprezentând imagini pe care dorim să le aplicăm diferitelor părți ale modelului. Trebuie să existe o modalitate de a marca ce triunghi ar trebui reprezentat cu ce parte a texturii. Aici intervine maparea texturii.
Pentru fiecare vârf, notăm două coordonate, U și V. Aceste coordonate reprezintă o poziție pe textură, cu U reprezentând axa orizontală, iar V axa verticală. Valorile nu sunt în pixeli, ci sunt o poziție procentuală în imagine. Colțul din stânga jos al imaginii este reprezentat cu două zerouri, în timp ce colțul din dreapta sus este reprezentat cu două zerouri.
Un triunghi este doar pictat luând coordonatele UV ale fiecărui vârf din triunghi și aplicând imaginea care este capturată între acele coordonate pe textură.
Puteți vedea o demonstrație de cartografiere UV în imaginea de mai sus. Modelul sferic a fost luat și tăiat în părți suficient de mici pentru a fi aplatizate pe o suprafață 2D. Cusăturile în care s-au făcut tăieturile sunt marcate cu linii mai groase. Unul dintre patch-uri a fost evidențiat, astfel încât să puteți vedea frumos cum se potrivesc lucrurile. De asemenea, puteți vedea cum o cusătură prin mijlocul zâmbetului plasează părți ale gurii în două zone diferite.
Wireframes-urile nu fac parte din textură, ci sunt doar suprapuse peste imagine, astfel încât să puteți vedea cum lucrurile se mapează împreună.
Încărcarea unui model OBJ
Credeți sau nu, acesta este tot ce trebuie să știți pentru a vă crea propriul încărcător de modele simplu. Formatul de fișier OBJ este suficient de simplu pentru a implementa un parser în câteva linii de cod.
Fișierul listează pozițiile vârfurilor într-un format v <float> <float> <float>
, cu un al patrulea float opțional, pe care îl vom ignora, pentru a menține lucrurile simple. Normalele vârfurilor sunt reprezentate în mod similar cu vn <float> <float> <float>
. În cele din urmă, coordonatele texturii sunt reprezentate cu vt <float> <float>
, cu un al treilea float opțional pe care îl vom ignora. În toate cele trei cazuri, flotoarele reprezintă coordonatele respective. Aceste trei proprietăți sunt acumulate în trei matrice.
Fețele sunt reprezentate cu grupuri de vârfuri. Fiecare vârf este reprezentat cu indexul fiecăreia dintre proprietăți, în care indicii încep de la 1. Există mai multe moduri în care acesta este reprezentat, dar vom rămâne la formatul f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3
, solicitând toate cele trei proprietăți să fie furnizate și limitând numărul de vârfuri pe față la trei. Toate aceste limitări sunt făcute pentru a menține încărcătorul cât mai simplu posibil, deoarece toate celelalte opțiuni necesită o procesare suplimentară trivială înainte de a fi într-un format care îi place WebGL.
Am introdus o mulțime de cerințe pentru încărcătorul nostru de fișiere. Acest lucru poate suna limitativ, dar aplicațiile de modelare 3D tind să vă ofere posibilitatea de a seta aceste limitări atunci când exportați un model ca fișier OBJ.
Următorul cod analizează un șir reprezentând un fișier OBJ și creează un model sub forma unei matrice de fețe.
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 }
Structura Geometry
deține datele exacte necesare pentru a trimite un model pe placa grafică pentru a fi procesat. Înainte de a face asta, totuși, probabil că ați dori să aveți capacitatea de a muta modelul pe ecran.
Efectuarea transformărilor spațiale
Toate punctele din modelul pe care l-am încărcat sunt relativ la sistemul său de coordonate. Dacă vrem să traducem, să rotim și să scalam modelul, tot ce trebuie să facem este să efectuăm acea operație pe sistemul său de coordonate. Sistemul de coordonate A, în raport cu sistemul de coordonate B, este definit de poziția centrului său ca vector p_ab
, iar vectorul pentru fiecare dintre axele sale, x_ab
, y_ab
și z_ab
, reprezentând direcția acelei axe. Deci, dacă un punct se mișcă cu 10 pe axa x
a sistemului de coordonate A, atunci — în sistemul de coordonate B — se va deplasa în direcția lui x_ab
, înmulțit cu 10.
Toate aceste informații sunt stocate în următoarea formă de matrice:
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
Dacă vrem să transformăm vectorul 3D q
, trebuie doar să înmulțim matricea de transformare cu vectorul:
qx qy qz 1
Acest lucru face ca punctul să se deplaseze cu qx
de-a lungul noii axe x
, cu qy
de-a lungul noii axe y
și cu qz
de-a lungul noii axe z
. În cele din urmă, face ca punctul să se miște suplimentar cu vectorul p
, motiv pentru care folosim unul ca element final al înmulțirii.
Marele avantaj al folosirii acestor matrici este faptul că, dacă avem de efectuat mai multe transformări pe vârf, le putem îmbina într-o singură transformare prin înmulțirea matricelor lor, înainte de a transforma vârful în sine.
Există diverse transformări care pot fi efectuate și vom arunca o privire asupra celor cheie.
Fără Transformare
Dacă nu au loc transformări, atunci vectorul p
este un vector zero, vectorul x
este [1, 0, 0]
, y
este [0, 1, 0]
, iar z
este [0, 0, 1]
. De acum înainte ne vom referi la aceste valori ca fiind valorile implicite pentru acești vectori. Aplicarea acestor valori ne oferă o matrice de identitate:
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
Acesta este un bun punct de plecare pentru înlănțuirea transformărilor.
Traducere
Când efectuăm translația, atunci toți vectorii, cu excepția vectorului p
, au valorile implicite. Rezultă următoarea matrice:
1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1
Scalare
Scalarea unui model înseamnă reducerea cantității pe care fiecare coordonată o contribuie la poziția unui punct. Nu există o compensare uniformă cauzată de scalare, astfel încât vectorul p
își păstrează valoarea implicită. Vectorii axelor implicite ar trebui înmulțiți cu factorii lor de scalare respectivi, ceea ce are ca rezultat următoarea matrice:
s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1
Aici s_x
, s_y
și s_z
reprezintă scalarea aplicată fiecărei axe.
Rotație
Imaginea de mai sus arată ce se întâmplă când rotim cadrul de coordonate în jurul axei Z.
Rotația nu are ca rezultat un offset uniform, astfel încât vectorul p
își păstrează valoarea implicită. Acum lucrurile devin puțin mai complicate. Rotațiile fac ca mișcarea de-a lungul unei anumite axe în sistemul de coordonate original să se miște într-o direcție diferită. Deci, dacă rotim un sistem de coordonate cu 45 de grade în jurul axei Z, mișcarea de-a lungul axei x
a sistemului de coordonate original provoacă deplasarea într-o direcție diagonală între axa x
și y
în noul sistem de coordonate.
Pentru a menține lucrurile simple, vă vom arăta cum arată matricele de transformare pentru rotații în jurul axelor principale.
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
Implementarea
Toate acestea pot fi implementate ca o clasă care stochează 16 numere, stochând matrice într-o ordine majoră a coloanelor.
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) }
Privind printr-o cameră
Aici intervine partea cheie a prezentării obiectelor pe ecran: camera. Există două componente cheie pentru o cameră; și anume, poziția sa și modul în care proiectează obiectele observate pe ecran.
Poziția camerei este gestionată cu un singur truc simplu. Nu există nicio diferență vizuală între deplasarea camerei cu un metru înainte și deplasarea întregii lumi cu un metru înapoi. Deci, în mod natural, facem aceasta din urmă, aplicând inversul matricei ca transformare.
A doua componentă cheie este modul în care obiectele observate sunt proiectate pe lentilă. În WebGL, tot ceea ce este vizibil pe ecran se află într-o casetă. Caseta se întinde între -1 și 1 pe fiecare axă. Tot ce este vizibil este în acea cutie. Putem folosi aceeași abordare a matricelor de transformare pentru a crea o matrice de proiecție.
Proiecție ortografică
Cea mai simplă proiecție este proiecția ortografică. Luați o casetă în spațiu, indicând lățimea, înălțimea și adâncimea, presupunând că centrul ei este în poziția zero. Apoi proiecția redimensionează caseta pentru a o potrivi în caseta descrisă anterior în care WebGL observă obiectele. Deoarece dorim să redimensionăm fiecare dimensiune la două, scalam fiecare axă cu 2/size
, în care size
este dimensiunea axei respective. Un mic avertisment este faptul că înmulțim axa Z cu un negativ. Acest lucru se face pentru că vrem să inversăm direcția acelei dimensiuni. Matricea finală are următoarea formă:
2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1
Proiecție în perspectivă
Nu vom trece prin detaliile modului în care este proiectată această proiecție, ci doar folosim formula finală, care este aproape standard până acum. O putem simplifica prin plasarea proiecției în poziția zero pe axa x și y, făcând limitele dreapta/stânga și sus/jos egale cu width/2
și respectiv height/2
. Parametrii n
și f
reprezintă planurile de tăiere near
și far
, care reprezintă distanța cea mai mică și cea mai mare pe care un punct poate fi capturat de cameră. Ele sunt reprezentate de laturile paralele ale trunchiului din imaginea de mai sus.
O proiecție în perspectivă este de obicei reprezentată cu un câmp vizual (vom folosi cel vertical), raportul de aspect și distanțele plane apropiate și îndepărtate. Aceste informații pot fi folosite pentru a calcula width
și height
, iar apoi matricea poate fi creată din următorul șablon:
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
Pentru a calcula lățimea și înălțimea, se pot folosi următoarele formule:
height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height
FOV (câmpul vizual) reprezintă unghiul vertical pe care camera îl captează cu obiectivul său. Raportul de aspect reprezintă raportul dintre lățimea și înălțimea imaginii și se bazează pe dimensiunile ecranului pe care îl redăm.
Implementarea
Acum putem reprezenta o cameră ca o clasă care stochează poziția camerei și matricea de proiecție. De asemenea, trebuie să știm cum să calculăm transformări inverse. Rezolvarea inversiilor matriceale generale poate fi problematică, dar există o abordare simplificată pentru cazul nostru special.
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) }
Aceasta este ultima piesă de care avem nevoie înainte de a începe să desenăm lucruri pe ecran.
Desenarea unui obiect cu WebGL Graphics Pipeline
Cea mai simplă suprafață pe care o poți desena este un triunghi. De fapt, majoritatea lucrurilor pe care le desenezi în spațiul 3D constau dintr-un număr mare de triunghiuri.
Primul lucru pe care trebuie să-l înțelegeți este cum este reprezentat ecranul în WebGL. Este un spațiu 3D, care se întinde între -1 și 1 pe axa x , y și z . În mod implicit, această axă z nu este utilizată, dar sunteți interesat de grafica 3D, așa că veți dori să o activați imediat.
Având în vedere acest lucru, următorii sunt trei pași necesari pentru a desena un triunghi pe această suprafață.
Puteți defini trei vârfuri, care ar reprezenta triunghiul pe care doriți să-l desenați. Serializați acele date și le trimiteți către GPU (unitatea de procesare grafică). Cu un întreg model disponibil, puteți face asta pentru toate triunghiurile din model. Pozițiile vârfurilor pe care le oferiți sunt în spațiul de coordonate local al modelului pe care l-ați încărcat. Mai simplu spus, pozițiile pe care le furnizați sunt cele exacte din fișier și nu cea pe care o obțineți după efectuarea transformărilor matriceale.
Acum că ați dat nodurile GPU-ului, îi spuneți GPU-ului ce logică să folosească atunci când plasați nodurile pe ecran. Acest pas va fi folosit pentru a aplica transformările noastre matriceale. GPU-ul este foarte bun la multiplicarea multor matrice 4x4, așa că vom folosi această capacitate.
În ultimul pas, GPU-ul va rasteriza acel triunghi. Rasterizarea este procesul prin care se realizează grafică vectorială și se determină ce pixeli ai ecranului trebuie pictați pentru ca acel obiect de grafică vectorială să fie afișat. În cazul nostru, GPU-ul încearcă să determine ce pixeli sunt localizați în fiecare triunghi. Pentru fiecare pixel, GPU-ul vă va întreba ce culoare doriți să fie vopsit.
Acestea sunt cele patru elemente necesare pentru a desena orice doriți și sunt cel mai simplu exemplu de conductă grafică. Ceea ce urmează este o privire asupra fiecăruia dintre ele și o implementare simplă.
Framebuffer-ul implicit
Cel mai important element pentru o aplicație WebGL este contextul WebGL. Îl puteți accesa cu gl = canvas.getContext('webgl')
, sau utilizați 'experimental-webgl'
ca alternativă, în cazul în care browserul utilizat în prezent nu acceptă încă toate caracteristicile WebGL. canvas
la care ne-am referit este elementul DOM al pânzei pe care vrem să ne desenăm. Contextul conține multe lucruri, printre care se numără framebuffer-ul implicit.
Ați putea descrie vag un framebuffer ca orice buffer (obiect) pe care îl puteți desena. În mod implicit, framebuffer-ul implicit stochează culoarea pentru fiecare pixel al pânzei la care este legat contextul WebGL. După cum este descris în secțiunea anterioară, atunci când desenăm pe framebuffer, fiecare pixel este situat între -1 și 1 pe axa x și y . Ceva pe care l-am menționat, de asemenea, este faptul că, implicit, WebGL nu folosește axa z . Această funcționalitate poate fi activată rulând gl.enable(gl.DEPTH_TEST)
. Grozav, dar ce este un test de adâncime?
Activarea testului de adâncime permite unui pixel să stocheze atât culoarea, cât și adâncimea. Adâncimea este coordonata z a acelui pixel. După ce desenați la un pixel la o anumită adâncime z , pentru a actualiza culoarea acelui pixel, trebuie să desenați într-o poziție z care este mai aproape de cameră. În caz contrar, încercarea de remiză va fi ignorată. Acest lucru permite iluzia 3D, deoarece desenarea obiectelor care se află în spatele altor obiecte va face ca acele obiecte să fie blocate de obiectele din fața lor.
Orice extrageri pe care le efectuați rămân pe ecran până când le spuneți să fie eliminate. Pentru a face acest lucru, trebuie să apelați gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
. Acest lucru șterge atât tamponul de culoare, cât și de adâncime. Pentru a alege culoarea la care sunt setați pixelii șterși, utilizați gl.clearColor(red, green, blue, alpha)
.
Să creăm o redare care folosește o pânză și o șterge la cerere:
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) }
Atașarea acestui script la următorul HTML vă va oferi un dreptunghi albastru strălucitor pe ecran
<!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>
Apelul requestAnimationFrame
face ca bucla să fie apelată din nou de îndată ce se termină randarea cadrului anterior și se termină toate gestionarea evenimentelor.
Obiecte tampon de vârf
Primul lucru pe care trebuie să-l faceți este să definiți vârfurile pe care doriți să le desenați. Puteți face asta descriindu-le prin vectori în spațiul 3D. După aceea, doriți să mutați acele date în memoria RAM GPU, prin crearea unui nou Vertex Buffer Object (VBO).
Un obiect tampon în general este un obiect care stochează o serie de bucăți de memorie pe GPU. Fiind un VBO, denotă doar pentru ce poate folosi GPU-ul memoria. De cele mai multe ori, obiectele tampon pe care le creați vor fi VBO.
Puteți umple VBO luând toate N
noduri pe care le avem și creând o matrice de float cu 3N
elemente pentru poziția vârfurilor și VBO-urile normale ale vârfurilor și 2N
pentru coordonatele texturii VBO. Fiecare grup de trei flotoare, sau două flotoare pentru coordonatele UV, reprezintă coordonatele individuale ale unui vârf. Apoi trecem aceste matrice către GPU, iar vârfurile noastre sunt gata pentru restul conductei.
Deoarece datele sunt acum pe RAM-ul GPU, le puteți șterge din memoria RAM de uz general. Adică, dacă nu doriți să îl modificați ulterior și să îl încărcați din nou. Fiecare modificare trebuie să fie urmată de o încărcare, deoarece modificările din matricele noastre JS nu se aplică VBO-urilor din memoria RAM GPU reală.

Mai jos este un exemplu de cod care oferă toate funcționalitățile descrise. O notă importantă de făcut este faptul că variabilele stocate pe GPU nu sunt colectate de gunoi. Asta înseamnă că trebuie să le ștergem manual odată ce nu mai dorim să le mai folosim. Vă vom oferi doar un exemplu despre cum se face acest lucru aici și nu ne vom concentra asupra acestui concept mai departe. Ștergerea variabilelor din GPU este necesară numai dacă intenționați să nu mai utilizați anumite geometrii în întregul program.
Am adăugat, de asemenea, serializarea clasei noastre Geometry
și elementelor din cadrul acesteia.
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) }
Tipul de date VBO
generează VBO în contextul WebGL transmis, pe baza matricei transmise ca al doilea parametru.
Puteți vedea trei apeluri în contextul gl
. createBuffer()
creează tamponul. bindBuffer()
spune mașinii de stare WebGL să folosească această memorie specifică ca VBO curent ( ARRAY_BUFFER
) pentru toate operațiunile viitoare, până când se spune altfel. După aceea, setăm valoarea VBO-ului curent la datele furnizate, cu bufferData()
.
Oferim, de asemenea, o metodă de distrugere care șterge obiectul nostru buffer din RAM GPU, utilizând deleteBuffer()
.
Puteți folosi trei VBO și o transformare pentru a descrie toate proprietățile unei rețele, împreună cu poziția acesteia.
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() }
De exemplu, iată cum putem încărca un model, să-i stocăm proprietățile în rețea și apoi să-l distrugem:
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })
Shaders
Ceea ce urmează este procesul descris anterior în două etape de mutare a punctelor în pozițiile dorite și de pictare a tuturor pixelilor individuali. Pentru a face acest lucru, scriem un program care rulează de mai multe ori pe placa grafică. Acest program constă de obicei din cel puțin două părți. Prima parte este un Vertex Shader , care este rulat pentru fiecare vârf și iese unde ar trebui să plasăm vârful pe ecran, printre altele. A doua parte este Fragment Shader , care este rulat pentru fiecare pixel pe care un triunghi îl acoperă pe ecran și emite culoarea pe care pixelul ar trebui pictat.
Vertex Shaders
Să presupunem că doriți să aveți un model care se mișcă în stânga și în dreapta pe ecran. Într-o abordare naivă, puteți actualiza poziția fiecărui vârf și îl retrimiteți către GPU. Acest proces este costisitor și lent. Alternativ, ați oferi un program pentru ca GPU să ruleze pentru fiecare vârf și ați face toate acele operațiuni în paralel cu un procesor care este construit pentru a face exact acea lucrare. Acesta este rolul unui vertex shader .
Un vertex shader este partea din conducta de randare care procesează vârfuri individuale. Un apel către vertex shader primește un singur vârf și emite un singur vârf după ce sunt aplicate toate transformările posibile la vârf.
Shaders sunt scrise în GLSL. Există o mulțime de elemente unice în acest limbaj, dar cea mai mare parte a sintaxei este foarte asemănătoare C, așa că ar trebui să fie de înțeles pentru majoritatea oamenilor.
Există trei tipuri de variabile care intră și ies dintr-un vertex shader și toate servesc unei utilizări specifice:
-
attribute
— Acestea sunt intrări care dețin proprietăți specifice ale unui vârf. Anterior, am descris poziția unui vârf ca atribut, sub forma unui vector cu trei elemente. Puteți privi atributele ca valori care descriu un vârf. -
uniform
— Acestea sunt intrări care sunt aceleași pentru fiecare vârf din același apel de randare. Să presupunem că vrem să ne putem muta modelul, prin definirea unei matrice de transformare. Puteți folosi o variabilăuniform
pentru a descrie asta. Puteți indica și resurse de pe GPU, cum ar fi texturi. Puteți privi uniformele ca valori care descriu un model sau o parte a unui model. -
varying
— Acestea sunt ieșiri pe care le transmitem la fragment shader. Deoarece există potențial mii de pixeli pentru un triunghi de vârfuri, fiecare pixel va primi o valoare interpolată pentru această variabilă, în funcție de poziție. Deci, dacă un vârf trimite 500 ca ieșire, iar altul 100, un pixel care se află la mijloc între ele va primi 300 ca intrare pentru acea variabilă. Puteți privi variațiile ca valori care descriu suprafețele dintre vârfuri.
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?” ai putea întreba.
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.); }
Am așezat soarele să strălucească în direcția înainte-stânga-jos. Puteți vedea cât de netedă este umbrirea, deși modelul este foarte zimțat. De asemenea, puteți observa cât de întunecată este partea din stânga jos. Putem adăuga un nivel de lumină ambientală, care va face zona din umbră mai luminoasă.
#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.); }
Puteți obține același efect prin introducerea unei clase de lumină, care stochează direcția luminii și intensitatea luminii ambientale. Apoi puteți schimba shader-ul de fragmente pentru a se adapta la această adăugare.
Acum shaderul devine:
#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.); }
Apoi puteți defini lumina:
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) }
În clasa programului shader, adăugați uniformele necesare:
this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')
În program, adăugați un apel la noua lumină din redare:
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) }) }
Bucla se va schimba apoi ușor:
var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
Dacă ați făcut totul corect, atunci imaginea redată ar trebui să fie aceeași cu cea din ultima imagine.
Un ultim pas de luat în considerare ar fi adăugarea unei texturi reale modelului nostru. Să facem asta acum.
Adăugarea de texturi
HTML5 are un suport excelent pentru încărcarea imaginilor, așa că nu este nevoie să faceți o analiză nebună a imaginilor. Imaginile sunt transmise la GLSL ca sampler2D
, spunându-i shader-ului care dintre texturile legate trebuie eșantionată. Există un număr limitat de texturi pe care le-ar putea lega, iar limita se bazează pe hardware-ul utilizat. Un sampler2D
poate fi interogat pentru culori în anumite poziții. Aici intervin coordonatele UV. Iată un exemplu în care am înlocuit maro cu culorile eșantionate.
#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.); }
Noua uniformă trebuie adăugată la lista în programul shader:
this.diffuse = gl.getUniformLocation(program, 'diffuse')
În cele din urmă, vom implementa încărcarea texturii. După cum sa spus anterior, HTML5 oferă facilități pentru încărcarea imaginilor. Tot ce trebuie să facem este să trimitem imaginea către 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 }) }
Procesul nu este mult diferit de procesul utilizat pentru încărcarea și legarea VBO-urilor. Principala diferență este că nu mai legăm la un atribut, ci mai degrabă legăm indexul texturii la o uniformă întreagă. Tipul sampler2D
nu este altceva decât un indicator decalat către o textură.
Acum tot ce trebuie făcut este să extindeți clasa Mesh
, pentru a gestiona și texturile:
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]) }) }
Și scenariul principal final ar arăta după cum urmează:
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) }
Chiar și animarea este ușoară în acest moment. Dacă doriți ca camera să se rotească în jurul obiectului nostru, o puteți face doar adăugând o linie de cod:
function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) }
Simțiți-vă liber să vă jucați cu shaders. Adăugarea unei linii de cod va transforma această iluminare realistă în ceva desenat.
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.); }
Este la fel de simplu ca să-i spui luminii să intre în extreme, în funcție de dacă a depășit un prag stabilit.
Unde să mergi mai departe
Există multe surse de informații pentru a învăța toate trucurile și complexitățile WebGL. Și cea mai bună parte este că, dacă nu puteți găsi un răspuns care se referă la WebGL, îl puteți căuta în OpenGL, deoarece WebGL se bazează aproape pe un subset de OpenGL, unele nume fiind schimbate.
În nicio ordine anume, iată câteva surse excelente pentru informații mai detaliate, atât pentru WebGL, cât și pentru OpenGL.
- Fundamentele WebGL
- Învățarea WebGL
- Un tutorial OpenGL foarte detaliat care vă ghidează prin toate principiile fundamentale descrise aici, într-un mod foarte lent și detaliat.
- Și există multe, multe alte site-uri dedicate să vă învețe principiile graficii pe computer.
- Documentația MDN pentru WebGL
- Specificația Khronos WebGL 1.0 pentru dacă sunteți interesat să înțelegeți mai multe detalii tehnice despre cum ar trebui să funcționeze API-ul WebGL în toate cazurile marginale.