Un ghid pentru începători în codarea șablonelor de grafică Partea 3

După ce am stăpânit elementele de bază ale shaderelor, luăm o abordare practică pentru a valorifica puterea GPU pentru a crea un iluminat realist și dinamic.

Prima parte a acestei serii a acoperit fundamentele shaderelor grafice. A doua parte a explicat procedura generală de instalare a shaderelor pentru a servi drept referință pentru orice platformă alegeți. De aici, vom aborda conceptele generale despre shaderele grafice, fără a presupune o platformă specifică. (Din motive de conveniență, toate exemplele de cod vor mai utiliza JavaScript / WebGL.)

Înainte de a merge mai departe, asigurați-vă că aveți o modalitate de a rula shadere că sunteți confortabil cu. (JavaScript / WebGL ar putea fi mai ușor, dar vă încurajez să încercați să urmați pe platforma preferată!) 

Obiective

Până la sfârșitul acestui tutorial, nu numai că veți putea să vă lăudați cu o înțelegere solidă a sistemelor de iluminare, dar veți fi construit unul singur de la zero. 

Iată cum arată rezultatul final (faceți clic pentru a comuta luminile):

Puteți rula și edita acest lucru pe CodePen.

În timp ce multe motoare de jocuri oferă sisteme de iluminat gata făcute, înțelegerea modului în care sunt făcute și modul de a crea propriul dvs. vă oferă mult mai multă flexibilitate în crearea unui aspect unic care se potrivește jocului dvs. Efectele Shader nu trebuie să fie pur cosmetice, ci pot deschide uși la o nouă fascinantă mecanică a jocurilor! 

Cromul este un exemplu foarte bun al acestui lucru; personajul jucătorului poate rula de-a lungul umbrelor dinamice create în timp real:

Noțiuni de bază: Scena noastră inițială

Vom trece peste o mulțime de configurare inițială, deoarece acesta a fost în mod exclusiv tutorialul anterior. Vom începe cu un fragment simplu de fragment care ne va conferi textura:

Puteți rula și edita acest lucru pe CodePen.

Nimic nu prea se întâmplă aici. Codul nostru JavaScript configurează scena noastră și trimite textură pentru a reda, împreună cu dimensiunile ecranului nostru, la shader.

var: uniforme = tex: tip: 't', valoare: textura, // Textura res: type: 'v2', valoare: new THREE.Vector2 (window.innerWidth, window.innerHeight) rezolutia 

În codul nostru GLSL, declarăm și folosim aceste uniforme:

uniform sampler2D tex; uniform vec2 res; void principal () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 culoare = textură2D (tex, pixel); gl_FragColor = culoare; 

Ne asigurăm că ne normalizăm coordonatele pixelilor înainte de a le folosi pentru a desena textura. 

Doar pentru a vă asigura că înțelegeți tot ceea ce se întâmplă aici, iată o provocare caldă:

Provocare: Puteți face textura păstrând în același timp raportul de aspect intact? (Mergeți la voi înșivă, vom trece prin soluția de mai jos.)

Ar trebui să fie destul de evident de ce se întinde, dar iată câteva sfaturi: Uită-te la linia unde ne normalizăm coordonatele:

vec2 pixel = gl_FragCoord.xy / res.xy;

Împărțim a vec2 de a vec2, care este aceeași cu împărțirea fiecărui component individual. Cu alte cuvinte, cele de mai sus sunt echivalente cu:

vec2 pixel = vec2 (0,0,0,0); pixel.x = gl_FragCoord.x / res.x; pixel.y = gl_FragCoord.y / res.y; 

Împărțim x și y cu numere diferite (lățimea și înălțimea ecranului), astfel încât acesta va fi în mod natural întins. 

Ce s-ar întâmpla dacă am împărțit atât x, cât și y gl_FragCoord doar de x res ? Sau, pur și simplu, în locul lui y?

Pentru simplitate, vom păstra codul nostru de normalizare așa cum este pentru restul tutorialului, dar este bine să înțelegem ce se întâmplă aici!

Pasul 1: Adăugarea unei surse de lumină

Înainte de a putea face ceva fantezist, trebuie să avem o sursă de lumină. O "sursă de lumină" nu este altceva decât un punct pe care îl trimitem shader-ul nostru. Vom construi o nouă uniformă pentru acest punct:

vară uniforme = // Adăugați variabila noastră de lumină aici light: type: 'v3', valoare: new THREE.Vector3 (), tex: type: 't', value: texture type: 'v2', valoare: new THREE.Vector2 (window.innerWidth, window.innerHeight) // păstrează rezoluția

Am creat un vector cu trei dimensiuni pentru că vrem să îl folosim X și y dupa cum poziţie a luminii pe ecran și a z dupa cum rază

Să setăm câteva valori pentru sursa noastră de lumină în JavaScript:

uniforms.light.value.z = 0.2; // raza noastră

Intenționăm să folosim raza ca procent din dimensiunile ecranului, deci 0.2 ar fi de 20% din ecranul nostru. (Nu este nimic special în această alegere. Am fi putut seta această dimensiune în pixeli, acest număr nu înseamnă nimic până nu facem ceva în codul nostru GLSL.)

Pentru a obține poziția mouse-ului în JavaScript, adăugăm doar un ascultător al evenimentului:

document.onmousemove = funcție (eveniment) // Actualizați sursa de lumină pentru a urmări mouse-ul nostru uniforms.light.value.x = event.clientX; uniforms.light.value.y = event.clientY; 

Acum, să scriem un cod shader pentru a folosi acest punct de lumină. Vom începe cu o sarcină simplă: Vrem ca fiecare pixel din gama noastră de lumină să fie vizibil, iar orice altceva ar trebui să fie negru.

Traducerea în GLSL poate arăta astfel:

uniform sampler2D tex; uniform vec2 res; uniforma vec3 light; // Amintiți-vă să declarați uniforma aici! void principal () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 culoare = textură2D (tex, pixel); // Distanța pixelului curent din poziția luminii float dist = distanța (gl_FragCoord.xy, light.xy); dacă (light.z * res.x> dist) // Verificați dacă acest pixel este fără intervalul gl_FragColor = culoare;  altceva gl_FragColor = vec4 (0.0); 

Tot ce am făcut aici este:

  • Ne-a declarat variabila uniformă a luminii.
  • A folosit funcția de distanță încorporată pentru a calcula distanța dintre poziția luminii și poziția pixelului curent.
  • Verificați dacă această distanță (în pixeli) este mai mare de 20% din lățimea ecranului; dacă da, returnează culoarea acelui pixel, altfel vom reveni negru.
Puteți rula și edita acest lucru pe CodePen.

Uh oh! Se pare ceva ce arată lumina după mouse.

Provocare: Poți rezolva asta? (Din nou, mergeți înainte să mergeți mai departe.)

Fixarea mișcării luminii

S-ar putea să vă amintiți din primul tutorial din această serie că axa y aici este flipped. S-ar putea să fiți tentat să faceți doar:

light.y = res.y - light.y;

Ceea ce este matematic, dar dacă ați făcut asta, shaderul dvs. nu va fi compilat! Problema este că variabilele uniforme nu pot fi schimbate.Pentru a vedea de ce, amintiți-vă acest cod runs pentru fiecare pixel în paralel. Imaginați-vă toate acele nuclee de procesoare care încearcă să schimbe o singură variabilă în același timp. Nu e bine! 

Putem rezolva acest lucru prin crearea unei noi variabile în loc să încercăm să ne edificăm uniforma. Sau mai bine, putem face acest pas inainte de trecerea la shader:

Puteți rula și edita acest lucru pe CodePen.
uniforms.light.value.y = fereastra.innerHeight - event.clientY; 

Acum am definit cu succes intervalul vizibil al scenei noastre. Arată foarte ascuțit, deși ...

Adăugarea unui Gradient

În loc să tăiem pur și simplu la negru când suntem în afara intervalului, putem încerca să creăm un gradient neted către margini. Putem face acest lucru folosind distanța pe care o calculam deja. 

În loc să setați toți pixelii din interiorul domeniului vizibil la culoarea texturii, după cum urmează:

gl_FragColor = culoare;

Putem multiplica aceasta cu un factor de distanta:

gl_FragColor = culoare * (1.0 - dist / (light.z * res.x));
Puteți rula și edita acest lucru pe CodePen.

Acest lucru funcționează pentru că dist este distanța în pixeli între pixelul curent și sursa de lumină. Termenul  (light.z * res.x) este lungimea razei. Deci, când privim pixelul exact la sursa de lumină, dist este 0, așa că am ajuns să înmulțim culoare de 1, care este culoarea completă.

În această diagramă, dist este calculată pentru un anumit pixel arbitrar. dist este diferită în funcție de pixelul în care suntem light.z * res.x este constantă.

Când ne uităm la un pixel la marginea cercului, dist este egal cu lungimea razei, astfel încât ajungem să înmulțim culoare de 0, care este negru. 

Pasul 2: Adăugarea de adâncime

Până acum nu am făcut mai mult decât să facem o mască de gradient pentru textura noastră. Totul arată în continuare apartament. Pentru a înțelege cum să remediem acest lucru, să vedem ce face sistemul nostru de iluminare chiar acum, spre deosebire de ceea ce este presupus a face.

În scenariul de mai sus, vă așteptați A pentru a fi cel mai aprins, deoarece sursa noastră de lumină este direct deasupra capului, cu B și C fiind întunecată, deoarece aproape nici o rază de lumină nu atinge laturile. 

Cu toate acestea, acest lucru arată sistemul nostru actual de lumină:

Toți sunt tratați în mod egal, deoarece singurul factor pe care îl luăm în considerare este distanța pe planul xxy.Acum, ați putea crede că tot ce avem nevoie acum este înălțimea fiecăruia dintre aceste puncte, dar asta nu este chiar corect. Pentru a vedea de ce, luați în considerare acest scenariu:

A este partea de sus a blocului nostru, și B și C sunt laturile ei. D este un alt patch de pământ din apropiere. Putem vedea asta A și D ar trebui să fie cel mai strălucitor, cu D fiind puțin mai întunecată, deoarece lumina ajunge la un unghi. B și C, pe de altă parte, ar trebui să fie foarte întunecată, pentru că aproape nici o lumină nu ajunge la ele, deoarece acestea sunt îndreptate departe de sursa de lumină. 

Nu este înălțimea atât de mult direcția la care se confruntă suprafațade care avem nevoie. Aceasta se numește suprafaţă normal.

Dar cum transmitem această informație shader-ului? Nu putem trimite o gamă gigantică de mii de numere pentru fiecare pixel, nu-i așa? De fapt, deja facem asta! Cu excepția faptului că nu o numim a mulțime, o numim a textură. 

Aceasta este exact ceea ce este o hartă normală; este doar o imagine în care r, g și b valorile fiecărui pixel reprezintă o direcție în locul unei culori. 

Mai sus este o hartă normală simplă. Dacă folosim un picker de culoare, vedem că direcția implicită, "plat" este reprezentată de culoare (0,5, 0,5, 1) (culoarea albastră care ocupă majoritatea imaginii). Aceasta este direcția care arată direct în sus. Valorile x, y și z sunt mapate la valorile r, g și b.

Partea înclinată din dreapta este îndreptată spre dreapta, deci valoarea lui x este mai mare; valoarea x este de asemenea valoarea roșie, motiv pentru care arată mai roșcat / roz. Același lucru este valabil pentru toate celelalte părți. 

Se pare amuzant, deoarece nu este menit să fie redat; este făcută exclusiv pentru a codifica valorile acestor normale de suprafață. 

Deci, să încărcăm această hartă simplă normală pentru a testa cu:

var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg" var normal = THREE.ImageUtils.loadTexture (normalURL);

Și adăugați-o ca una dintre variabilele noastre uniforme:

var uniforme = norm: tip: 't', valoare: normal, // ... restul materialelor noastre aici

Pentru a testa că am încărcat-o corect, să încercăm să o redăm în loc de textura noastră, editând codul GLSL (amintiți-vă, în acest moment, o folosim doar ca textura de fond, mai degrabă decât o hartă normală):

Puteți rula și edita acest lucru pe CodePen.

Pasul al treilea: Aplicarea unui model de iluminare

Acum că avem datele noastre normale de suprafață, trebuie să facem asta implementa un model de iluminat. Cu alte cuvinte, trebuie să spunem suprafeței noastre cum să luăm în considerare toți factorii pe care trebuie să-i calculam luminozitatea finală. 

Modelul Phong este cel mai simplu pe care îl putem implementa. Iată cum funcționează: Având o suprafață cu date normale, cum ar fi:

Pur și simplu calculam unghiul dintre sursa de lumină și suprafața normală:

Cu cât unghiul este mai mic, cu atât este mai luminos pixelul. 

Aceasta înseamnă că pixelii direct sub sursa de lumină, unde diferența de unghi este 0, va fi cea mai luminată. Cele mai întunecate pixeli vor fi acelea care arată în aceeași direcție cu raza de lumină (care ar fi ca partea inferioară a obiectului)

Acum să implementăm acest lucru. 

Deoarece folosim o hartă normală simplă pentru a testa cu, să ne stabilim textura la o culoare solidă, astfel încât să putem spune cu ușurință dacă funcționează. 

Deci, în loc de:

vec4 culoare = textură2D (...);

Să facem un alb solid (sau orice culoare vă place foarte mult):

vec4 culoare = vec4 (1.0); // solid alb

Aceasta este stenoza GLSL pentru crearea unui vec4 cu toate componentele egale cu 1.0.

Iată cum arată algoritmul nostru:

  1. Obțineți vectorul normal la acest pixel.
  2. Obțineți vectorul de direcție a luminii.
  3. Normalizați vectorii.
  4. Calculați unghiul dintre ele.
  5. Multiplicați culoarea finală cu acest factor.

1. Obțineți vectorul normal la acest pixel

Trebuie să știm în ce direcție se confruntă suprafața, astfel încât să putem calcula cât de multă lumină ar trebui să ajungă la acest pixel. Această direcție este stocată în harta noastră normală, astfel încât obținerea vectorului nostru normal înseamnă doar obținerea culorii actuale a pixelului texturii normale:

vec3 NormalVector = textură2D (normă, pixel) .xyz;

Deoarece valoarea alfa nu reprezintă nimic în harta normală, avem nevoie doar de primele trei componente. 

2. Obțineți Vectorul Direcției Luminoase

Acum trebuie să știm în ce direcție ușoară este indicatorul. Ne putem imagina că suprafața noastră de lumină este o lanternă ținută în fața ecranului, la poziția mouse-ului nostru, astfel încât să putem calcula vectorul de direcție a luminii folosind doar distanța dintre sursa de lumină și pixel:

vec3 LightVector = vec3 (lumină.x - gl_FragCoord.x, light.y - gl_FragCoord.y, 60.0);

Trebuie să aibă și o coordonate z (pentru a putea calcula unghiul față de vectorul normal de suprafață tridimensional). Puteți juca cu această valoare. Veți observa că cu cât este mai mică, cu atât contrastul este mai pronunțat între zonele luminoase și întunecate. Vă puteți gândi la acest lucru ca la înălțimea pe care o țineți deasupra scenei; cu cât este mai departe, cu atât este distribuită lumina mai uniformă.

3. Normalizați vectorii

Acum pentru a normaliza:

NormalVector = normalizează (NormalVector); LightVector = normalizează (LightVector);

Utilizăm funcția încorporată normalizată pentru a ne asigura că ambele vectori au o lungime de 1.0. Trebuie să facem acest lucru pentru că vom calcula unghiul folosind produsul dot. Dacă sunteți puțin cam dezagonat în legătură cu modul în care funcționează, ați putea dori să vă spălați pe o parte din algebra dvs. liniară. Pentru scopurile noastre, trebuie doar să știți asta produsul punct va returna cosinusul unghiului dintre doi vectori de lungime egală

4. Calculați unghiul dintre vectorii noștri

Să mergem mai departe și să facem asta cu funcția punctuală încorporată:

float difuz = punct (NormalVector, LightVector);

O numesc difuz doar pentru că așa se numește acest termen în modelul de iluminare Phong, datorită modului în care dictează cât de mult lumină ajunge la suprafața scenei noastre.

5. Înmulțiți culoarea finală cu acest factor

Asta e! Acum mergeți mai departe și multiplicați culoarea cu acest termen. Am mers înainte și am creat o variabilă numită distanceFactor astfel încât ecuația noastră să pară mai ușor de citit:

distanța floatFactor = (1.0 - dist / (light.z * res.x)); gl_FragColor = culoare * difuz * distanțăFactor;

Și avem un model de iluminare de lucru! (Este posibil să doriți să extindeți raza luminii pentru a vedea efectul mai clar.)

Puteți rula și edita acest lucru pe CodePen.

Hmm, ceva pare cam oprit. Se simte ca lumina noastră este înclinată cumva. 

Să ne revedem matematica pentru o secundă aici. Avem acest vector de lumină:

vec3 LightVector = vec3 (lumină.x - gl_FragCoord.x, light.y - gl_FragCoord.y, 60.0);

Ceea ce știm că ne va da (0, 0, 60)când lumina este direct deasupra acestui pixel. După ce o normalizăm, va fi (0, 0, 1).

Amintiți-vă că vrem ca o normă care să îndrepte direct spre lumină să aibă strălucirea maximă. Suprafața noastră implicită normală, orientată în sus, este (0,5, 0,5, 1).

Provocare: Puteți vedea soluția acum? Puteți să o implementați?

Problema este că nu puteți stoca numere negative ca valori de culoare într-o textură. Nu puteți denumi un vector care să indice spre stânga (-0,5, 0, 0). Deci, oamenii care creează hărți normale trebuie să adauge 0.5 la tot. (Sau, în termeni mai generali, trebuie să-și schimbe sistemul de coordonate). Trebuie să știți acest lucru pentru a ști că trebuie să scăpați 0.5 din fiecare pixel înainte de a utiliza harta. 

Iată ce arată demo-ul după scăderea 0.5 de la x și y ale vectorului nostru normal:

Puteți rula și edita acest lucru pe CodePen.

Este o ultimă soluție pe care trebuie să o facem. Amintiți-vă că produsul dotat returnează cosinus a unghiului. Aceasta înseamnă că ieșirea noastră este fixată între -1 și 1. Nu vrem valori negative în culorile noastre și, în timp ce WebGL pare să elimine automat aceste valori negative, s-ar putea să ai un comportament ciudat în altă parte. Putem folosi funcția max built-in pentru a rezolva această problemă, transformând aceasta:

float difuz = punct (NormalVector, LightVector);

În acest caz:

float diffuse = max (punct (NormalVector, LightVector), 0,0);

Acum aveți un model de iluminare de lucru! 

Puteți pune înapoi textura pietrelor și puteți găsi harta reală normală în repo GitHub pentru această serie (sau, direct, aici):

Trebuie doar să schimbăm o linie JavaScript, de la:

var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg"

la:

var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/blocks_normal.JPG"

Și o linie GLSL, de la:

vec4 color = vec4 (1.0); // solid alb

Nu mai este nevoie de albul solid, tragem textura reală, așa cum este:

vec4 culoare = textură2D (tex, pixel);

Iată rezultatul final:

Puteți rula și edita acest lucru pe CodePen.

Sfaturi de optimizare

GPU este foarte eficient în ceea ce face, dar știind ce poate încetini acest lucru este valoroasă. Iată câteva sfaturi despre asta:

branșament

Un lucru despre shadere este că, în general, este preferabil să se evite branșament ori de câte ori este posibil. În timp ce rareori trebuie să vă faceți griji pentru o grămadă de dacă declarații cu privire la orice cod pe care îl scrieți pentru CPU, ele pot constitui un obstacol major pentru GPU. 

Pentru a vedea de ce, amintiți-vă din nou codul dvs. GLSL rlipiți pe fiecare pixel de pe ecran în paralel. Placa grafică poate face o mulțime de optimizări pe baza faptului că toți pixelii trebuie să execute aceleași operații. Dacă există o grămadă de dacă declarații, totuși, unele dintre aceste optimizări ar putea începe să eșueze, deoarece diferiți pixeli vor rula cod diferit acum. Fie că este sau nu dacă afirmațiile care încetinesc lucrurile încet pare să depindă de implementarea specifică a hardware-ului și a plăcii grafice, dar este un lucru bun să țineți minte atunci când încercați să accelerați shader-ul.

Repartizarea amânată

Acesta este un concept foarte util în cazul iluminării. Imaginați-vă dacă vrem să avem două surse de lumină, trei, sau o duzină; ar trebui să calculam unghiul dintre fiecare suprafață normală și fiecare punct de lumină. Acest lucru va încetini rapid shader noastre la un crawl. Repartizarea amânată este o modalitate de a optimiza acest lucru prin împărțirea muncii shader-ului nostru în mai multe treceri. Iată un articol care intră în detaliile a ceea ce înseamnă. Voi cita partea relevantă pentru scopurile noastre aici:

Lumina este principalul motiv pentru a merge pe o rută față de cealaltă. Într-o conductă standard de randare înainte, calculele de iluminare trebuie efectuate pe fiecare vârf și pe fiecare fragment din scena vizibilă, pentru fiecare lumină din scenă.

De exemplu, în loc să trimiteți o serie de puncte luminoase, este posibil să le atrageți pe textură, ca cercuri, cu culoarea fiecărui pixel reprezentând intensitatea luminii. În acest fel, veți putea calcula efectul combinat al tuturor luminilor din scenă și trimiteți doar textura finală (sau tampon, așa cum se numește uneori) pentru a calcula iluminatul din. 

Învățarea de a împărți lucrarea în mai multe treceri pentru shader este o tehnică foarte utilă. Efectele de blur utilizează această idee pentru a accelera shaderul, de exemplu, precum și efecte precum un shader fluid / fum. Ea nu face parte din acest tutorial, dar am putea revizui tehnica într-un tutorial viitor!

Pasii urmatori

Acum că aveți un shader de iluminare de lucru, iată câteva lucruri pe care să încercați să le jucați:

  • Încercați să modificați înălțimea (z valoare) a vectorului de lumină pentru a vedea efectul său
  • Încercați să modificați intensitatea luminii. (Puteți face acest lucru prin înmulțirea termenului dvs. difuz cu un factor.)
  • Adăugați un înconjurător la ecuația voastră ușoară. (Aceasta înseamnă, în principiu, o valoare minimă, astfel încât zonele întunecate nu vor fi negre, ceea ce îi face să se simtă mai realist, deoarece lucrurile din viața reală sunt încă aprinse, chiar dacă nu există nici o lumină directă care să le lovească)
  • Încercați să implementați unele dintre shaderele din acest tutorial WebGL. Sa făcut cu Babylon.js în loc de Three.js, dar puteți sări la părțile GLSL. În special, umbrirea celulelor și umbrirea Phong ar putea să te intereseze.
  • Inspirați-vă de demo-urile pe GLSL Sandbox și ShaderToy 

Referințe

Textura pietrelor și harta normală folosită în acest tutorial sunt preluate din OpenGameArt:

http://opengameart.org/content/50-free-textures-4-normalmaps

Există numeroase programe care vă pot ajuta să creați hărți normale. Dacă sunteți interesat să aflați mai multe despre cum să creați propriile hărți normale, acest articol vă poate ajuta.