En nybegynners guide til koding av grafikkskyggere Del 3

Etter å ha mestret grunnleggende shaders, tar vi en praktisk tilnærming til å utnytte kraften til GPU for å skape realistisk, dynamisk belysning.

Den første delen av denne serien dekket grunnlaget for grafikkskyggere. Den andre delen forklarte den generelle prosedyren for å sette opp shaders for å fungere som en referanse for hvilken plattform du velger. Fra nå av vil vi takle generelle begreper på grafikkskyggere uten å anta en bestemt plattform. (For enkelhets skyld vil alle kodeeksempler fortsatt bruke JavaScript / WebGL.)

Før du går videre, må du sørge for at du har mulighet til å kjøre shaders som du er komfortabel med. (JavaScript / WebGL kan være enklest, men jeg oppfordrer deg til å prøve å følge med på din favorittplattform!) 

mål

Ved slutten av denne opplæringen vil du ikke bare kunne skryte av en solid forståelse av belysningssystemer, men du har bygget en selv fra grunnen av. 

Slik ser det endelige resultatet ut (klikk for å skifte lysene):

Du kan gaffel og redigere dette på CodePen.

Mens mange spillmotorer tilbyr ferdige belysningssystemer, forstår hvordan de er laget og hvordan du lager din egen, gir du mye mer fleksibilitet i å skape et unikt utseende som passer til spillet ditt. Shadereffekter trenger ikke å være rent kosmetiske heller, de kan åpne dører for fascinerende nye spillmekanikk! 

Chroma er et godt eksempel på dette; spillerens karakter kan kjøre langs de dynamiske skyggene som er opprettet i sanntid:

Komme i gang: Vår første scene

Vi kommer til å hoppe over mye av det opprinnelige oppsettet, siden dette var hva den forrige opplæringen var utelukkende om. Vi starter med et enkelt fragment shader som gjengir tekstur:

Du kan gaffel og redigere dette på CodePen.

Ingenting for fancy skjer her. Vår JavaScript-kode er å sette opp vår scene og sende teksten til å gi sammen med skjermdimensjonene til shader.

var uniformer = tex: type: 't', verdi: tekstur, // Teksturrester: type: 'v2', verdi: nytt THREE.Vector2 (window.innerWidth, window.innerHeight) // Keeps oppløsningen

I GLSL-koden erklærer og bruker vi disse uniformer:

uniform sampler2D tex; uniform vec2 res; void main () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D (tex, pixel); gl_FragColor = farge; 

Vi sørger for å normalisere våre pikselkoordinater før vi bruker dem til å tegne tekstur. 

Bare for å sikre at du forstår alt som skjer her, er det en varm oppfordring:

Utfordring: Kan du gjengi tekstur mens du holder aspektforholdet intakt? (Ta en tur på dette selv, vi vil gå gjennom løsningen nedenfor.)

Det bør være ganske opplagt hvorfor det blir strukket, men her er noen tips: Se på linjen der vi normaliserer koordinatene våre:

vec2 pixel = gl_FragCoord.xy / res.xy;

Vi deler en vec2 av a vec2, som er det samme som å dele hver komponent individuelt. Med andre ord er ovennevnte ekvivalent med:

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

Vi deler vår x og y med forskjellige tall (bredden og høyden på skjermen), så det vil naturlig strekkes ut. 

Hva ville skje hvis vi delte både x og y av gl_FragCoord ved bare x res ? Eller hva med bare y i stedet?

For enkelhets skyld vil vi beholde vår normaliserende kode som er for resten av opplæringen, men det er godt å forstå hva som skjer her!

Trinn 1: Legge til en lyskilde

Før vi kan gjøre noe fancy, må vi ha en lyskilde. En "lyskilde" er ikke noe mer enn et poeng vi sender til vår skygge. Vi skal bygge en ny uniform for dette punktet:

var uniformer = // Legg til vår lysvariabel her lys: type: 'v3', verdi: ny THREE.Vector3 (), tex: type: 't', verdi: tekstur, // Teksturresten: type: 'v2', verdi: ny THREE.Vector2 (window.innerWidth, window.innerHeight) // Holder oppløsningen

Vi opprettet en vektor med tre dimensjoner fordi vi vil bruke x og y som stilling av lyset på skjermen, og z som radius

La oss stille noen verdier for vår lyskilde i JavaScript:

uniforms.light.value.z = 0.2; // Vår radius

Vi har tenkt å bruke radius som en prosentandel av skjermdimensjonene, så 0.2 ville være 20% av skjermen vår. (Det er ikke noe spesielt med dette valget. Vi kunne ha satt dette til en størrelse i piksler. Dette tallet betyr ingenting før vi gjør noe med det i GLSL-koden.)

For å få museposisjonen i JavaScript, legger vi bare til en hendelseslytter:

document.onmousemove = function (event) // Oppdater lyskilden for å følge musen uniforms.light.value.x = event.clientX; uniforms.light.value.y = event.clientY; 

La oss nå skrive noen skyggekode for å benytte dette lyspunktet. Vi starter med en enkel oppgave: Vi ønsker at alle piksler innenfor vårt lysområde skal være synlige, og alt annet skal være svart.

Hvis du oversetter dette til GLSL, kan det se slik ut:

uniform sampler2D tex; uniform vec2 res; uniform vec3 lys; // Husk å erklære uniformet her! void main () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D (tex, pixel); // Avstand av nåværende piksel fra lysposisjonen float dist = avstand (gl_FragCoord.xy, light.xy); hvis (light.z * res.x> dist) // Sjekk om denne piksel er uten rekkevidde gl_FragColor = color;  ellers gl_FragColor = vec4 (0,0); 

Alt vi har gjort her er:

  • Erklært vår lysformige variabel.
  • Brukte den innebygde avstandsfunksjonen til å beregne avstanden mellom lysposisjonen og den nåværende pixelposisjonen.
  • Kontrollert om denne avstanden (i piksler) er større enn 20% av skjermbredden; Hvis så, returnerer vi fargene til den piksel, ellers kommer vi tilbake svart.
Du kan gaffel og redigere dette på CodePen.

UH oh! Noe virker av med hvordan lyset følger musen.

Utfordring: Kan du fikse det? (Igjen, ta en tur selv før vi går gjennom det nedenfor.)

Feste lysets bevegelse

Du kan kanskje huske fra den første opplæringen i denne serien at y-aksen her er vendt. Du kan bli fristet til å bare gjøre:

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

Hvilket er matematisk lyd, men hvis du gjorde det, vil din skygge ikke kompilere! Problemet er det ensartede variabler kan ikke endres.For å se hvorfor, husk det denne koden runs for hver enkelt piksel i parallell. Tenk deg alle de prosessorkjernene som prøver å endre en enkelt variabel samtidig. Ikke bra! 

Vi kan fikse dette ved å opprette en ny variabel i stedet for å prøve å redigere vår uniform. Eller enda bedre, vi kan bare gjøre dette trinnet før passerer den til shader:

Du kan gaffel og redigere dette på CodePen.
uniforms.light.value.y = window.innerHeight - event.clientY; 

Vi har nå klart definert det synlige området i vår scene. Det ser veldig skarpt ut, skjønt ...

Legge til en gradient

I stedet for å bare kutte til svart når vi er utenfor området, kan vi prøve å skape en jevn gradient mot kantene. Vi kan gjøre dette ved å bruke avstanden som vi allerede beregner. 

I stedet for å sette alle piksler i det synlige området til teksturens farge, slik som:

gl_FragColor = farge;

Vi kan multiplisere det med en faktor avstanden:

gl_FragColor = farge * (1.0 - dist / (light.z * res.x));
Du kan gaffel og redigere dette på CodePen.

Dette fungerer fordi dist er avstanden i piksler mellom den nåværende pixel og lyskilden. Begrepet  (light.z * res.x) er radiuslengden. Så når vi ser på pikselet akkurat ved lyskilden, dist er 0, så vi ender opp med å multiplisere farge av 1, som er full farge.

I dette diagrammet, dist er beregnet for noen vilkårlig piksel. dist er forskjellig avhengig av hvilken piksel vi er på, mens light.z * res.x er konstant.

Når vi ser på en piksel i kanten av sirkelen, dist er lik radiuslengden, så vi ender opp med å multiplisere farge av 0, som er svart. 

Trinn 2: Legge til dybde

Så langt har vi ikke gjort mye mer enn å lage en gradientmaske for tekstur. Alt ser fortsatt ut flat. For å forstå hvordan du løser dette, la oss se hva vårt belysningssystem gjør akkurat nå, i motsetning til hva det er ment å gjøre.

I det ovennevnte scenariet, ville du forvente EN å være mest opplyst, siden vår lyskilde er direkte overhead, med B og C å være mørk, siden nesten ingen lysstråler faktisk rammer sidene. 

Men dette er hva vårt nåværende lyssystem ser:

De er alle behandlet like, fordi den eneste faktoren vi tar hensyn til er avstand på xy-planet.Nå kan du kanskje tro at alt vi trenger nå er høyden på hvert av disse punktene, men det er ikke helt riktig. For å se hvorfor, vurder dette scenariet:

EN er toppen av vår blokk, og B og C er sidene av det. D er en annen lapp av bakken i nærheten. Vi kan se det EN og D bør være den lyseste, med D å være litt mørkere fordi lyset når den i en vinkel. B og C, På den annen side bør det være veldig mørkt, fordi nesten ingen lys når dem, siden de vender bort fra lyskilden. 

Det er ikke høyden så mye som retningen som overflaten vender motsom vi trenger. Dette kalles flate normal.

Men hvordan overfører vi denne informasjonen til shader? Vi kan ikke muligens sende et gigantisk utvalg av tusenvis av tall for hver eneste piksel, kan vi? Faktisk gjør vi det allerede! Bortsett fra at vi ikke kaller det en matrise, vi kaller det a tekstur. 

Dette er akkurat det et vanlig kart er; det er bare et bilde hvor r, g og b verdier av hver piksel representerer en retning i stedet for en farge. 

Ovenfor er et enkelt vanlig kart. Hvis vi bruker en fargeplukker, kan vi se at standard, "flat" retning er representert av fargen (0,5, 0,5, 1) (den blå fargen som tar opp flertallet av bildet). Dette er retningen som peker rett opp. Verdiene x, y og z er kartlagt til r, g og b verdiene.

Den skrånende siden til høyre peker mot høyre, så sin x-verdi er høyere; x-verdien er også den røde verdien, og derfor ser den mer rødlig ut. Det samme gjelder for alle de andre sidene. 

Det ser morsomt ut fordi det ikke er ment å bli gjengitt. Det er laget for å kode inn verdiene av disse overflate normaler. 

Så la oss laste dette enkle, normale kartet for å teste med:

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

Og legg det til som en av våre enhetlige variabler:

var uniformer = norm: type: 't', verdi: normal, // ... resten av våre ting her

For å teste at vi har lastet det riktig, la oss prøve å gjøre det istedenfor tekstur ved å redigere GLSL-koden (husk, vi bruker bare den som bakgrunnstekstur, i stedet for et vanlig kart, på dette punktet):

Du kan gaffel og redigere dette på CodePen.

Trinn tre: Bruk av en lysmodell

Nå som vi har våre overflate normale data, må vi implementere en belysningsmodell. Med andre ord må vi fortelle overflaten hvordan vi skal ta hensyn til alle faktorene vi må beregne den endelige lysstyrken. 

Phong-modellen er den enkleste vi kan implementere. Slik fungerer det: Gitt en overflate med vanlige data som dette:

Vi beregner bare vinkelen mellom lyskilden og overflaten normal:

Jo mindre denne vinkelen, jo lysere piksel. 

Dette betyr at piksler rett under lyskilden, hvor vinkeldifferansen er 0, vil være den lyseste. De mørkeste pikslene vil være de som peker i samme retning som lysstrålen (det ville være som undersiden av objektet)

La oss nå implementere dette. 

Siden vi bruker et enkelt vanlig kart for å teste med, la oss sette konsistensen til en solid farge slik at vi lett kan fortelle om det fungerer. 

Så, i stedet for:

vec4 color = texture2D (...);

La oss gjøre det til en solid hvit (eller hvilken som helst farge du virkelig liker):

vec4 farge = vec4 (1.0); // solid hvit

Dette er GLSL shorthand for å lage en vec4 med alle komponenter lik 1.0.

Slik ser vår algoritme ut:

  1. Få den normale vektoren på denne piksel.
  2. Få lysretningsvektoren.
  3. Normaliser vektorer.
  4. Beregn vinkelen mellom dem.
  5. Multipliser den endelige fargen med denne faktoren.

1. Få den vanlige vektoren på dette pikselet

Vi må vite hvilken retning overflaten vender mot, så vi kan beregne hvor mye lys som skal nå denne piksel. Denne retningen er lagret i vårt normale kart, så å få vår normale vektor betyr bare å få den nåværende pixelfargen på normal tekstur:

vec3 NormalVector = texture2D (norm, pixel) .xyz;

Siden alfaverdien ikke representerer noe i det normale kartet, trenger vi bare de tre første komponentene. 

2. Få lysretningsvektoren

Nå må vi vite i hvilken retning vår lys peker. Vi kan tenke oss at vår lyse overflate er en lommelykt foran skjermen, på musens plassering, slik at vi kan beregne lysretningsvektoren ved bare å bruke avstanden mellom lyskilde og piksel:

vec3 LightVector = vec3 (light.x - gl_FragCoord.x, light.y - gl_FragCoord.y, 60,0);

Det må også ha en z-koordinat (for å kunne beregne vinkelen mot den tredimensjonale overflaten normalvektoren). Du kan leke med denne verdien. Du finner at jo mindre det er, jo skarpere er kontrasten mellom de lyse og mørke områdene. Du kan tenke på dette som høyden du holder lommelykten over scenen; jo lengre unna er det, jo mer jevnt lys er fordelt.

3. Normaliser Våre vektorer

Nå for å normalisere:

NormalVector = normaliser (NormalVector); LightVector = normaliser (LightVector);

Vi bruker den innebygde funksjonen til å normalisere for å sikre at begge vektorene våre har en lengde på 1.0. Vi må gjøre dette fordi vi er i ferd med å beregne vinkelen ved hjelp av prikkproduktet. Hvis du er litt uklar på hvordan dette virker, vil du kanskje børste opp litt av din lineære algebra. For vårt formål trenger du bare å vite det punktproduktet returnerer cosinus av vinkelen mellom to vektorer med samme lengde

4. Beregn vinkelen mellom vektorer

La oss gå videre og gjøre det med den innebygde prikkfunksjonen:

float diffuse = dot (NormalVector, LightVector);

Jeg kaller det diffuse bare fordi dette er hva dette begrepet kalles i Phong-belysningsmodellen, på grunn av hvordan det dikterer hvor mye lys som kommer til overflaten av vår scene.

5. Multipliser den endelige fargen med denne faktoren

Det er det! Nå fortsett og multipliser fargen din med denne termen. Jeg gikk videre og opprettet en variabel som ble kalt distanceFactor slik at vår likning ser mer lesbar ut:

flyte distanceFactor = (1.0 - dist / (light.z * res.x)); gl_FragColor = farge * diffus * distanceFactor;

Og vi har en arbeidslysmodell! (Du vil kanskje utvide lysets radius for å se effekten tydeligere.)

Du kan gaffel og redigere dette på CodePen.

Hmm, noe virker litt av. Det føles som om vårt lys er kantet på en eller annen måte. 

La oss revidere våre matematikk for et sekund her. Vi har denne lysvektoren:

vec3 LightVector = vec3 (light.x - gl_FragCoord.x, light.y - gl_FragCoord.y, 60,0);

Det vi vet, vil gi oss (0, 0, 60)når lyset er direkte på toppen av dette pikselet. Etter at vi har normalisert det, vil det bli (0, 0, 1).

Husk at vi vil ha en normal som peker rett opp mot lyset for å få maksimal lysstyrke. Vår standard overflate normal, peker oppover, er (0,5, 0,5, 1).

Utfordring: Kan du se løsningen nå? Kan du implementere det?

Problemet er det Du kan ikke lagre negative tall som fargeverdier i en tekstur. Du kan ikke betegne en vektor som peker til venstre som (-0,5, 0, 0). Så, folk som lager normale kart må legge til 0.5 til alt. (Eller i mer generelle termer, de må skifte sitt koordinatsystem). Du må være oppmerksom på dette for å vite at du bør trekke fra 0.5 fra hver piksel før du bruker kartet. 

Slik ser demoen ut etter at du har trukket av 0.5 fra x og y av vår normale vektor:

Du kan gaffel og redigere dette på CodePen.

Det er en siste løsning vi må gjøre. Husk at punktproduktet returnerer cosinus av vinkelen. Dette betyr at utgangen vår er klemmet mellom -1 og 1. Vi vil ikke ha negative verdier i våre farger, og mens WebGL ser ut til å automatisk kaste bort disse negative verdiene, kan det hende at du får merkelig oppførsel andre steder. Vi kan bruke den innebygde maks-funksjonen til å løse dette problemet ved å gjøre dette:

float diffuse = dot (NormalVector, LightVector);

Inn i dette

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

Nå har du en arbeidsbelysningsmodell! 

Du kan legge tilbake steinstrukturen, og du kan finne sitt virkelige normale kart i GitHub repo for denne serien (eller, direkte her):

Vi trenger bare å endre en JavaScript-linje, fra:

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

til:

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

Og en GLSL-linje, fra:

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

Vi trenger ikke lenger den faste hvite, vi trekker den virkelige tekstur, slik som:

vec4 color = texture2D (tex, pixel);

Og her er sluttresultatet:

Du kan gaffel og redigere dette på CodePen.

Optimaliseringstips

GPUen er veldig effektiv i hva den gjør, men å vite hva som kan bremse det er verdifullt. Her er noen tips om det:

forgrening

En ting om shaders er at det generelt er å foretrekke å unngå forgrening når det er mulig. Mens du sjelden trenger å bekymre deg for en haug med hvis uttalelser om hvilken som helst kode du skriver for CPU, kan de være en stor flaskehals for GPU. 

For å se hvorfor, husk det igjen din GLSL kode rUten på hver piksel på skjermen parallelt. Grafikkortet kan gjøre mange optimaliseringer basert på det faktum at alle piksler må kjøre de samme operasjonene. Hvis det er en haug med hvis uttalelser, men noen av disse optimaliseringene kan begynne å mislykkes, fordi forskjellige piksler vil kjøre forskjellig kode nå. Hvorvidt hvis uttalelser faktisk sakte ting ned ser ut til å være avhengig av spesifikk maskinvare og grafikkort implementering, men det er en god ting å huske på når du prøver å øke hastigheten på din skygger.

Utsatt gjengivelse

Dette er et veldig nyttig konsept når det gjelder belysning. Tenk deg om vi ville ha to lyskilder, eller tre eller et dusin; vi må beregne vinkelen mellom hver overflate normal og hvert lyspunkt. Dette vil raskt redusere skyggen vår til en gjennomgang. Utsatt gjengivelse er en måte å optimalisere det ved å dele arbeidet med vår skygge i flere pass. Her er en artikkel som går inn i detaljene om hva det betyr. Jeg vil sitere den aktuelle delen for våre formål her:

Belysning er hovedgrunnen til å gå en vei mot den andre. I en standard fremføringsrørledning må belysningsberegningene utføres på hvert vertex og på hvert fragment i synlig scene, for hvert lys i scenen.

For eksempel, i stedet for å sende en rekke lyspunkter, kan du i stedet tegne dem alle på en tekstur, som sirkler, med fargen på hver piksel som representerer lysets intensitet. På denne måten kan du beregne den kombinerte effekten av alle lysene i scenen din, og bare sende den endelige teksten (eller buffer som det noen ganger kalles) for å beregne belysningen fra. 

Å lære å dele arbeidet i flere passerer for skyggen er en veldig nyttig teknikk. Sløringseffekter gjør bruk av denne ideen for å fremskynde skyggeren, for eksempel, så vel som effekter som en væske / røykskygge. Det er ikke omfattet av denne opplæringen, men vi kan gå tilbake til teknikken i en fremtidig veiledning!

Neste skritt

Nå som du har en fungerende lysskygge, er det noen ting å prøve å leke med:

  • Prøv å variere høyden (z verdi) av lysvektoren for å se dens effekt
  • Prøv å variere lysets intensitet. (Du kan gjøre dette ved å multiplisere ditt diffuse begrep med en faktor.)
  • Legg til en omgivende termen til lysekvasjonen. (Dette betyr i utgangspunktet å gi det en minimumsverdi, slik at selv mørke områder ikke vil være tone svart. Dette bidrar til at det føles mer realistisk fordi ting i virkeligheten fortsatt lyser selv om det ikke er direkte lys som rammer dem)
  • Prøv å implementere noen av shaders i denne WebGL-opplæringen. Det er gjort med Babylon.js i stedet for Three.js, men du kan hoppe over til GLSL-delene. Spesielt kan celleskygging og Phong-skygge interessere deg.
  • Få inspirasjon fra demoene på GLSL Sandbox og ShaderToy 

referanser

Steinsteinens tekstur og det normale kartet som brukes i denne opplæringen er hentet fra OpenGameArt:

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

Det er mange programmer som kan hjelpe deg med å lage normale kart. Hvis du er interessert i å lære mer om hvordan du lager dine egne vanlige kart, kan denne artikkelen hjelpe.