Een beginnershandleiding voor codering Grafische Shaders deel 3

Nadat we de basis van shaders onder de knie hebben, nemen we een praktische benadering om de kracht van de GPU te benutten om realistische, dynamische verlichting te creëren.

Het eerste deel van deze serie ging over de fundamenten van grafische shaders. In het tweede deel werd de algemene procedure uitgelegd voor het instellen van shaders om als referentie te dienen voor het platform dat u kiest. Vanaf hier zullen we algemene concepten over grafische shaders aanpakken zonder een specifiek platform aan te nemen. (Voor het gemak gebruiken alle codevoorbeelden nog steeds JavaScript / WebGL.)

Voordat je verder gaat, moet je ervoor zorgen dat je een manier hebt om shaders uit te voeren waar je je prettig bij voelt. (JavaScript / WebGL is misschien het gemakkelijkst, maar ik raad u aan om het volgende te volgen op uw favoriete platform!) 

Goals

Tegen het einde van deze tutorial zul je niet alleen kunnen bogen op een goed begrip van verlichtingssystemen, maar je hebt er ook zelf een gebouwd.. 

Dit is hoe het eindresultaat eruit ziet (klik om de lichten in te schakelen):

U kunt dit vorkelen en bewerken op CodePen.

Hoewel veel game-engines kant-en-klare verlichtingssystemen bieden, geeft het begrip van hoe ze zijn gemaakt en hoe je ze zelf kunt maken, veel meer flexibiliteit bij het creëren van een unieke look die bij je game past. Shader-effecten hoeven ook niet puur cosmetisch te zijn, ze kunnen deuren openen voor fascinerende nieuwe spelmechanismen! 

Chroma is daar een goed voorbeeld van; het personage van de speler kan langs de dynamische schaduwen lopen die in realtime zijn gemaakt:

Aan de slag: onze eerste scène

We gaan veel van de initiële setup overslaan, omdat dit de vorige tutorial was. We beginnen met een eenvoudige fragmentshader die onze textuur weergeeft:

U kunt dit vorkelen en bewerken op CodePen.

Er gebeurt hier niets bijzonders. Onze JavaScript-code stelt onze scène in en verzendt de textuur naar render, samen met onze schermafmetingen, naar de arcering.

var uniforms = tex: type: 't', waarde: texture, // De texture res: type: 'v2', waarde: nieuw THREE.Vector2 (window.innerWidth, window.innerHeight) // Keeps de resolutie 

In onze GLSL-code verklaren en gebruiken we deze uniformen:

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

We zorgen ervoor dat onze pixelcoördinaten worden genormaliseerd voordat we ze gebruiken om de textuur te tekenen. 

Om er zeker van te zijn dat je alles begrijpt wat hier gaande is, is dit een opwarmingswedstrijd:

Uitdaging: Kun je de textuur renderen terwijl je de beeldverhouding intact houdt? (Probeer dit zelf, we zullen hieronder de oplossing doornemen.)

Het moet vrij duidelijk zijn waarom het wordt uitgerekt, maar hier zijn enkele hints: kijk naar de regel waar we onze coördinaten normaliseren:

vec2 pixel = gl_FragCoord.xy / res.xy;

We delen een vec2 door a vec2, wat hetzelfde is als het delen van elke component afzonderlijk. Met andere woorden, het bovenstaande is gelijk aan:

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

We verdelen onze x en y met verschillende getallen (de breedte en hoogte van het scherm), dus het wordt natuurlijk uitgerekt. 

Wat zou er gebeuren als we zowel de x als de y zouden delen gl_FragCoord door alleen de x res ? Of in plaats daarvan alleen de y?

Voor het gemak houden we onze normaliserende code ongewijzigd voor de rest van de zelfstudie, maar het is goed om te begrijpen wat hier gebeurt!

Stap 1: Een lichtbron toevoegen

Voordat we iets bijzonders kunnen doen, moeten we een lichtbron hebben. Een "lichtbron" is niets meer dan een punt dat we naar onze arcering sturen. We zullen voor dit punt een nieuw uniform bouwen:

var uniforms = // Voeg hier onze lichtvariabele toe: type: 'v3', waarde: nieuw THREE.Vector3 (), tex: type: 't', waarde: texture, // De texture res: type: 'v2', waarde: nieuw THREE.Vector2 (window.innerWidth, window.innerHeight) // Bewaart de resolutie

We hebben een vector met drie dimensies gemaakt omdat we de vector willen gebruiken X en Y als de positie van het licht op het scherm en de z als de radius

Laten we een aantal waarden voor onze lichtbron instellen in JavaScript:

uniforms.light.value.z = 0.2; // Onze straal

We zijn van plan de straal te gebruiken als een percentage van de schermafmetingen, dus 0.2 zou 20% van ons scherm zijn. (Er is niets bijzonders aan deze keuze. We hadden dit formaat in pixels kunnen instellen. Dit nummer betekent niets totdat we er iets mee doen in onze GLSL-code.)

Om de positie van de muis in JavaScript te krijgen, voegen we gewoon een gebeurtenislistener toe:

document.onmousemove = function (event) // Update de lichtbron om onze muisuniforms.light.value.x = event.clientX; uniforms.light.value.y = event.clientY; 

Laten we nu wat shader-code schrijven om gebruik te maken van dit lichtpunt. We beginnen met een eenvoudige taak: We willen dat elke pixel binnen ons lichtbereik zichtbaar is en dat al het andere zwart moet zijn.

Dit vertalen naar GLSL kan er ongeveer zo uitzien:

uniforme sampler2D tex; uniforme vec2 res; uniform vec3-licht; // Vergeet niet het uniform hier te verklaren! void main () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D (tex, pixel); // Afstand van de huidige pixel van de lichte positie float dist = afstand (gl_FragCoord.xy, light.xy); if (light.z * res.x> dist) // Controleer of deze pixel geen bereik heeft gl_FragColor = kleur;  else gl_FragColor = vec4 (0.0); 

Het enige wat we hier hebben gedaan is:

  • Aangegeven onze variabele lichtuniform.
  • Gebruikte de ingebouwde afstandsfunctie om de afstand tussen de lichtpositie en de positie van de huidige pixel te berekenen.
  • Gecontroleerd of deze afstand (in pixels) groter is dan 20% van de schermbreedte; zo ja, dan geven we de kleur van die pixel terug, anders keren we zwart terug.
U kunt dit vorkelen en bewerken op CodePen.

Oh Oh! Er lijkt iets mis te zijn met hoe het licht de muis volgt.

Uitdaging: Kun je dat oplossen? (Nogmaals, doe zelf een poging voordat we er doorheen lopen.)

De beweging van het licht verbeteren

Misschien herinnert u zich vanaf de eerste tutorial in deze reeks dat de y-as hier is omgedraaid. Je komt misschien in de verleiding om gewoon te doen:

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

Dat is wiskundig correct, maar als je dat doet, zal je shader niet compileren! Het probleem is dat uniforme variabelen kunnen niet worden gewijzigd.Om te zien waarom, onthoud dat deze code rniet voor elke afzonderlijke pixel parallel. Stelt u zich eens voor dat al die processorkernen tegelijk een enkele variabele proberen te veranderen. Niet goed! 

We kunnen dit oplossen door een nieuwe variabele te maken in plaats van te proberen ons uniform te bewerken. Of beter nog, we kunnen deze stap gewoon doen voor doorgeven aan de arcering:

U kunt dit vorkelen en bewerken op CodePen.
uniforms.light.value.y = window.innerHeight - event.clientY; 

We hebben nu met succes het zichtbare bereik van onze scène gedefinieerd. Het ziet er echter heel scherp uit ...

Een verloop toevoegen

In plaats van eenvoudig in zwart te snijden wanneer we buiten het bereik zijn, kunnen we proberen een vloeiende overgang naar de randen te maken. We kunnen dit doen door de afstand te gebruiken die we al berekenen. 

In plaats van alle pixels binnen het zichtbare bereik in te stellen op de kleur van de textuur, zoals:

gl_FragColor = kleur;

We kunnen dat met een factor van de afstand vermenigvuldigen:

gl_FragColor = color * (1.0 - dist / (light.z * res.x));
U kunt dit vorkelen en bewerken op CodePen.

Dit werkt omdat dist is de afstand in pixels tussen de huidige pixel en de lichtbron. De voorwaarde  (light.z * res.x) is de straallengte. Dus wanneer we de pixel precies bij de lichtbron bekijken, dist is 0, dus we vermenigvuldigen zich uiteindelijk kleur door 1, welke de volledige kleur is.

In dit diagram, dist wordt berekend voor een willekeurige pixel. dist is anders, afhankelijk van welke pixel we zijn light.z * res.x is constant.

Wanneer we naar een pixel aan de rand van de cirkel kijken, dist is gelijk aan de straallengte, dus we vermenigvuldigen zich uiteindelijk kleur door 0, wat zwart is. 

Stap 2: diepte toevoegen

Tot nu toe hebben we niet veel meer gedaan dan een gradiëntmasker maken voor onze textuur. Alles ziet er nog steeds uit vlak. Om te begrijpen hoe dit op te lossen, laten we eens kijken wat ons verlichtingssysteem nu doet, in tegenstelling tot wat het is vermeend Te doen.

In het bovenstaande scenario zou je verwachten EEN om het meest verlicht te zijn, aangezien onze lichtbron direct boven zit, met B en C donker zijn, omdat bijna geen lichtstralen de zijkanten raken. 

Dit is echter wat ons huidige lichtsysteem ziet:

Ze worden allemaal gelijk behandeld, omdat de enige factor waarmee we rekening houden, is afstand op het xy-vlak.Nu zou je kunnen denken dat alles wat we nu nodig hebben de hoogte van elk van die punten is, maar dat klopt niet helemaal. Om te zien waarom, overweeg dan dit scenario:

EEN is de top van ons blok, en B en C zijn de zijkanten ervan. D is een ander stukje grond in de buurt. Dat kunnen we zien EEN en D zou de slimste moeten zijn, met D een beetje donkerder zijn omdat het licht het onder een hoek bereikt. B en C, aan de andere kant moet het erg donker zijn, omdat bijna geen licht hen bereikt, omdat ze van de lichtbron af staan. 

Het is niet zo hoog als de hoogte de richting waarmee het oppervlak wordt geconfronteerddie we nodig hebben. Dit wordt het oppervlakte normaal.

Maar hoe geven we deze informatie door aan de shader? We kunnen onmogelijk een gigantische reeks van duizenden nummers verzenden voor elke afzonderlijke pixel, nietwaar? Eigenlijk doen we dat al! Alleen noemen we het geen rangschikking, we noemen het a structuur. 

Dit is precies wat een normale kaart is; het is gewoon een afbeelding waar de r, g en b waarden van elke pixel vertegenwoordigen een richting in plaats van een kleur. 

Hierboven ziet u een eenvoudige, normale kaart. Als we een kleurenkiezer gebruiken, kunnen we zien dat de standaardrichting "plat" wordt weergegeven door de kleur (0,5, 0,5, 1) (de blauwe kleur die het grootste deel van de afbeelding opneemt). Dit is de richting die recht omhoog wijst. De x-, y- en z-waarden worden toegewezen aan de r-, g- en b-waarden.

De schuine zijde aan de rechterkant wijst naar rechts, dus de x-waarde is hoger; de x-waarde is ook de rode waarde, daarom ziet het er meer roodachtig / roze uit. Hetzelfde geldt voor alle andere partijen. 

Het ziet er grappig uit omdat het niet bedoeld is om te worden weergegeven; het is puur gemaakt om de waarden van deze oppervlaknormalen te coderen. 

Dus laten we deze eenvoudige normale kaart laden om te testen met:

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

En voeg het toe als een van onze uniforme variabelen:

var uniforms = norm: type: 't', waarde: normaal, // ... de rest van onze spullen hier

Om te testen of we het correct hebben geladen, laten we proberen het te renderen in plaats van onze textuur door onze GLSL-code te bewerken (onthoud dat we het op dit moment alleen als achtergrondstructuur gebruiken in plaats van een normale kaart):

U kunt dit vorkelen en bewerken op CodePen.

Stap drie: een verlichtingsmodel toepassen

Nu we onze normale oppervlaktedata hebben, moeten we dat doen een verlichtingsmodel implementeren. Met andere woorden, we moeten ons oppervlak vertellen hoe we rekening moeten houden met alle factoren die we hebben om de uiteindelijke helderheid te berekenen. 

Het Phong-model is de eenvoudigste die we kunnen implementeren. Hier is hoe het werkt: Gegeven een oppervlak met normale gegevens zoals deze:

We berekenen eenvoudig de hoek tussen de lichtbron en de normaal op het oppervlak:

Hoe kleiner deze hoek, hoe helderder de pixel. 

Dit betekent dat pixels direct onder de lichtbron, waarbij het hoekverschil 0 is, het helderst zijn. De donkerste pixels zijn pixels die in dezelfde richting wijzen als de lichtstraal (zoals de onderkant van het object)

Laten we dit nu implementeren. 

Omdat we een eenvoudige normale kaart gebruiken om mee te testen, stellen we onze textuur in op een effen kleur zodat we eenvoudig kunnen zien of het werkt. 

Dus in plaats van:

vec4 color = texture2D (...);

Laten we er een effen wit van maken (of elke kleur die je echt leuk vindt):

vec4 color = vec4 (1.0); // effen wit

Dit is een GLSL-steno voor het maken van een vec4 met alle componenten gelijk aan 1.0.

Dit is hoe ons algoritme eruit ziet:

  1. Download de normale vector op deze pixel.
  2. Haal de lichtrichtingvector.
  3. Normaliseer onze vectoren.
  4. Bereken de hoek ertussen.
  5. Vermenigvuldig de uiteindelijke kleur met deze factor.

1. Haal de normale vector bij deze pixel

We moeten weten in welke richting het oppervlak staat, zodat we kunnen berekenen hoeveel licht deze pixel moet bereiken. Deze richting wordt opgeslagen in onze normale kaart, dus het krijgen van onze normale vector betekent gewoon dat je de huidige pixelkleur van de normale textuur krijgt:

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

Omdat de alpha-waarde niets voorstelt op de normale kaart, hebben we alleen de eerste drie componenten nodig. 

2. Verkrijg de Light Direction Vector

Nu moeten we weten in welke richting onze licht wijst. We kunnen ons voorstellen dat ons lichtoppervlak een zaklamp is die voor het scherm wordt gehouden, op de locatie van onze muis, zodat we de lichtrichtingsvector kunnen berekenen door alleen de afstand tussen de lichtbron en de pixel te gebruiken:

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

Het moet ook een z-coördinaat hebben (om de hoek tegen de driedimensionale oppervlaknormale vector te kunnen berekenen). Je kunt rond spelen met deze waarde. Je zult merken dat hoe kleiner het is, hoe scherper het contrast is tussen de heldere en donkere delen. Je kunt dit zien als de hoogte waarop je je zaklantaarn boven het toneel houdt; hoe verder weg, hoe gelijkmatiger licht wordt verspreid.

3. Normaliseer onze vectoren

Nu te normaliseren:

NormalVector = normaliseren (NormalVector); LightVector = normaliseren (LightVector);

We gebruiken de ingebouwde functie normaliseren om ervoor te zorgen dat beide vectoren een lengte hebben van 1.0. We moeten dit doen omdat we op het punt staan ​​de hoek te berekenen met behulp van het puntproduct. Als je een beetje wazig bent over hoe dit werkt, wil je misschien wat van je lineaire algebra oppoetsen. Voor onze doeleinden moet u dat alleen weten het puntproduct retourneert de cosinus van de hoek tussen twee vectoren van gelijke lengte

4. Bereken de hoek tussen onze vectoren

Laten we doorgaan en dat doen met de ingebouwde puntfunctie:

zweven diffuus = punt (NormalVector, LightVector);

ik noem het diffuus gewoon omdat dit is wat deze term wordt genoemd in het Phong-verlichtingsmodel, vanwege de manier waarop het dicteert hoeveel licht het oppervlak van onze scène bereikt.

5. Vermenigvuldig de uiteindelijke kleur met deze factor

Dat is het! Ga nu door en vermenigvuldig je kleur met deze term. Ik ging door en creëerde een variabele genaamd distanceFactor zodat onze vergelijking er beter leesbaar uitziet:

float distanceFactor = (1.0 - dist / (light.z * res.x)); gl_FragColor = color * diffuse * distanceFactor;

En we hebben een werkend verlichtingsmodel! (Misschien wilt u de straal van uw licht vergroten om het effect duidelijker te zien.)

U kunt dit vorkelen en bewerken op CodePen.

Hmm, iets lijkt een beetje af. Het voelt alsof ons licht op de een of andere manier gekanteld is. 

Laten we onze wiskunde hier even herzien. We hebben deze lichte vector:

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

Wat we weten zal ons geven (0, 0, 60)wanneer het licht zich direct boven deze pixel bevindt. Nadat we het hebben genormaliseerd, zal het zijn (0, 0, 1).

Vergeet niet dat we een normaal willen die direct naar het licht wijst om de maximale helderheid te hebben. Onze standaard oppervlaknormaal, naar boven wijzend, is (0,5, 0,5, 1).

Uitdaging: Zie je de oplossing nu? Kun je het implementeren?

Het probleem is dat u kunt negatieve getallen niet opslaan als kleurwaarden in een structuur. Je kunt niet een vector aangeven die naar links wijst (-0,5, 0, 0). Dus mensen die normale kaarten maken, moeten toevoegen 0.5 naar alles. (Of, in meer algemene termen, ze moeten hun coördinatensysteem verplaatsen). Je moet je hiervan bewust zijn om te weten dat je moet aftrekken 0.5 van elke pixel voordat u de kaart gebruikt. 

Dit is hoe de demo eruit ziet na aftrek 0.5 van de x en y van onze normale vector:

U kunt dit vorkelen en bewerken op CodePen.

Er is nog een laatste oplossing die we moeten maken. Vergeet niet dat het puntproduct de cosinus van de hoek. Dit betekent dat onze uitvoer is geklemd tussen -1 en 1. We willen geen negatieve waarden in onze kleuren, en hoewel WebGL deze negatieve waarden automatisch lijkt te verwijderen, kunt u elders vreemd gedrag vertonen. We kunnen de ingebouwde max-functie gebruiken om dit probleem op te lossen door dit te veranderen:

zweven diffuus = punt (NormalVector, LightVector);

In dit:

zweven diffuus = max (punt (NormalVector, LightVector), 0.0);

Nu heb je een werkend verlichtingsmodel! 

Je kunt de stenen textuur terugzetten en je kunt de echte normale kaart vinden in de GitHub repo voor deze serie (of, direct, hier):

We hoeven slechts één JavaScript-regel te wijzigen, van:

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

naar:

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

En één GLSL-lijn, van:

vec4 color = vec4 (1.0); // effen wit

We hebben niet langer het effen wit nodig, we trekken aan de echte textuur, zoals zo:

vec4 color = texture2D (tex, pixel);

En hier is het eindresultaat:

U kunt dit vorkelen en bewerken op CodePen.

Optimalisatietips

De GPU is zeer efficiënt in wat het doet, maar weten wat het kan vertragen, is waardevol. Hier zijn enkele tips met betrekking tot dat:

vertakking

Een ding over shaders is dat het over het algemeen beter te vermijden is vertakking wanneer mogelijk. Terwijl u zich zelden zorgen hoeft te maken over een aantal als verklaringen over elke code die u schrijft voor de CPU, ze kunnen een groot knelpunt zijn voor de GPU. 

Om te zien waarom, onthoud dat nog een keer uw GLSL-code rniet op elke pixel op het scherm parallel. De grafische kaart kan veel optimalisaties maken op basis van het feit dat alle pixels dezelfde bewerkingen moeten uitvoeren. Als er een hoop is als maar sommige van die optimalisaties kunnen ook mislukken, omdat verschillende pixels nu andere code zullen uitvoeren. Wel of niet als Verklarende woorden eigenlijk vertragen lijkt te zijn afhankelijk van de specifieke hardware en grafische kaart-implementatie, maar het is een goede zaak om in gedachten te houden bij het proberen om uw shader te versnellen.

Uitgestelde weergave

Dit is een zeer bruikbaar concept bij het omgaan met verlichting. Stel je voor dat we twee lichtbronnen wilden hebben, of drie of een dozijn; we zouden de hoek tussen elk normaal oppervlak en elk punt van licht moeten berekenen. Dit zal onze shader snel tot een crawl vertragen. Uitgestelde weergave is een manier om dat te optimaliseren door het werk van onze shader op te splitsen in meerdere passen. Hier is een artikel dat ingaat op de details van wat het betekent. Ik citeer hier het relevante deel voor onze doeleinden:

Verlichting is de belangrijkste reden om van de ene route naar de andere te gaan. In een standaard voorwaartse weergavepijplijn moeten de verlichtingsberekeningen worden uitgevoerd op elk hoekpunt en op elk fragment in de zichtbare scène, voor elk licht in de scène.

In plaats van bijvoorbeeld een reeks lichtpunten te verzenden, kunt u ze in plaats daarvan allemaal op een textuur tekenen, als cirkels, waarbij de kleur op elke pixel de intensiteit van het licht weergeeft. Op deze manier kun je het gecombineerde effect van alle lichten in je scène berekenen en stuur je die laatste textuur (of buffer zoals die soms wordt genoemd) om de verlichting te berekenen van. 

Het leren delen van het werk in meerdere passen voor de arcering is een zeer nuttige techniek. Onscherpte-effecten maken gebruik van dit idee om bijvoorbeeld de arcering te versnellen, evenals effecten zoals een vloeistof / rook-shader. Het is buiten het bereik van deze zelfstudie, maar we kunnen de techniek in een toekomstige zelfstudie opnieuw bekijken!

Volgende stappen

Nu je een werkende lichtshader hebt, zijn hier enkele dingen om te proberen en te spelen met:

  • Probeer de hoogte te variëren (z waarde) van de lichtvector om het effect te zien
  • Probeer de intensiteit van het licht te variëren. (U kunt dit doen door uw diffuse term met een factor te vermenigvuldigen.)
  • Voeg een toe omringend term voor uw lichtvergelijking. (Dit betekent in feite dat je het een minimumwaarde geeft, zodat zelfs donkere gebieden niet pikzwart zijn. Dit maakt het gevoel realistischer omdat dingen in het echte leven nog steeds verlicht zijn, zelfs als er geen direct licht op hen valt)
  • Probeer enkele van de shaders in deze zelfstudie van WebGL te implementeren. Het is gedaan met Babylon.js in plaats van Three.js, maar je kunt doorgaan naar de GLSL-onderdelen. Met name de celschaduw en Phong-arcering kunnen u interesseren.
  • Laat je inspireren door de demo's op GLSL Sandbox en ShaderToy 

Referenties

De stenenstructuur en normale kaart die in deze zelfstudie worden gebruikt, zijn afkomstig uit OpenGameArt:

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

Er zijn veel programma's die u kunnen helpen normale kaarten te maken. Als u meer wilt weten over het maken van uw eigen normale kaarten, kan dit artikel helpen.