Opprette Toon Water for Internett Del 1

I min nybegynners guide til Shaders fokuserte jeg utelukkende på fragment shaders, som er nok for enhver 2D-effekt og hvert ShaderToy-eksempel. Men det er en hel kategori teknikker som krever vertex shaders. Denne opplæringen vil gå deg gjennom å skape stilisert toon vann mens du introduserer vertex shaders. Jeg vil også introdusere dybdebufferen og hvordan bruke den for å få mer informasjon om scenen din og lage skumlinjer.

Her er hva den endelige effekten skal se ut. Du kan prøve en live demo her (venstre mus for å bane, høyre mus for å panorere, bla hjulet for å zoome).

Spesifikt er denne effekten sammensatt av:

  1. Et oppdelt gjennomsiktig vannmask med fordrevne hjørner for å lage bølger.
  2. Statiske vannlinjer på overflaten.
  3. Fake oppdrift på båtene.
  4. Dynamisk skumlinje rundt kanten av gjenstander i vannet.
  5. En etterprosessforvrengning av alt under vann.

Det jeg liker om denne effekten er at den berører mange forskjellige begreper i datagrafikk, slik at det vil gi oss mulighet til å tegne ideer fra tidligere opplæringsprogrammer, samt utvikle teknikker vi kan bruke for en rekke fremtidige effekter.

Jeg bruker PlayCanvas for dette bare fordi det har en praktisk gratis web-basert IDE, men alt skal gjelde for ethvert miljø som kjører WebGL. Du kan finne en Three.js-versjon av kildekoden på slutten. Jeg antar at du er komfortabel med å bruke fragment shaders og navigere i PlayCanvas-grensesnittet. Du kan pusse opp shaders her og skumme et intro til PlayCanvas her.

Miljøoppsett

Målet med denne delen er å sette opp vårt PlayCanvas-prosjekt og plassere noen miljøobjekter for å teste vannet mot. 

Hvis du ikke allerede har en konto med PlayCanvas, må du registrere deg for en og opprette en ny tomt prosjekt. Som standard bør du ha et par objekter, et kamera og et lys i din scene.

Sette inn modeller

Googles Poly-prosjekt er en veldig god ressurs for 3D-modeller for Internett. Her er båtmodellen jeg brukte. Når du laster ned og pakker ut det, bør du finne en .obj og a .png fil.

  1. Dra begge filene inn i aktivitetsvinduet i PlayCanvas-prosjektet ditt.
  2. Velg materialet som ble opprettet automatisk, og sett dets diffuse kart til .png fil.

Nå kan du dra Tugboat.json inn i scenen din og slett objekter på boks og fly. Du kan skala båten opp hvis den ser for liten ut (jeg setter min til 50).

Du kan legge til andre modeller til din scene på samme måte.

Bane Kamera

For å sette opp et bane-kamera, kopierer vi et skript fra dette PlayCanvas-eksemplet. Gå til den linken, og klikk på Redaktør å gå inn i prosjektet.

  1. Kopier innholdet til mus-input.js og bane-camera.js fra det opplæringsprosjektet i filene med samme navn i ditt eget prosjekt.
  2. Legg til en Manus komponent til kameraet ditt.
  3. Fest de to skriptene til kameraet.

Tips: Du kan opprette mapper i aktivitetsvinduet for å holde orden på organisasjonen. Jeg legger disse to kameraskripter under Skript / Kamera /, Modellen min under Modeller /, og materialet mitt under Materialer /.

Nå, når du starter spillet (spilleknappen øverst til høyre i scenevisningen), bør du kunne se båten din og bane rundt den med musen. 

Inndelt vannoverflate

Målet med denne delen er å generere et oppdelt nett som skal brukes som vannoverflate.

For å generere vannoverflaten skal vi tilpasse noen kode fra denne terrenggenereringsopplæringen. Opprett en ny skriptfil som heter Water.js. Rediger dette skriptet og opprett en ny funksjon kalt GeneratePlaneMesh som ser slik ut:

Water.prototype.GeneratePlaneMesh = funksjon (alternativer) // 1 - Angi standardalternativer hvis ingen er gitt hvis (alternativer === undefined) alternativer = underdivisjoner: 100, bredde: 10, høyde: 10; // 2 - Generer poeng, uvs og indekser var stillinger = []; var uvs = []; var indekser = []; var rad, col; var normals; for (rad = 0; rad <= options.subdivisions; row++)  for (col = 0; col <= options.subdivisions; col++)  var position = new pc.Vec3((col * options.width) / options.subdivisions - (options.width / 2.0), 0, ((options.subdivisions - row) * options.height) / options.subdivisions - (options.height / 2.0)); positions.push(position.x, position.y, position.z); uvs.push(col / options.subdivisions, 1.0 - row / options.subdivisions);   for (row = 0; row < options.subdivisions; row++)  for (col = 0; col < options.subdivisions; col++)  indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + (row + 1) * (options.subdivisions + 1));   // Compute the normals normals = pc.calculateNormals(positions, indices); // Make the actual model var node = new pc.GraphNode(); var material = new pc.StandardMaterial(); // Create the mesh var mesh = pc.createMesh(this.app.graphicsDevice, positions,  normals: normals, uvs: uvs, indices: indices ); var meshInstance = new pc.MeshInstance(node, mesh, material); // Add it to this entity var model = new pc.Model(); model.graph = node; model.meshInstances.push(meshInstance); this.entity.addComponent('model'); this.entity.model.model = model; this.entity.model.castShadows = false; // We don't want the water surface itself to cast a shadow ;

Nå kan du ringe dette i initial funksjon:

Water.prototype.initialize = function () this.GeneratePlaneMesh (underavsnitt: 100, bredde: 10, høyde: 10); ;

Du bør bare se et flatt fly når du starter spillet nå. Men dette er ikke bare et flatt fly. Det er et nett som består av tusen toppunkter. Som en utfordring, prøv å bekrefte dette (det er en god unnskyldning å lese gjennom koden du nettopp har kopiert). 

Utfordring # 1: Forskyv Y-koordinaten til hvert toppunkt med en tilfeldig mengde for å få flyet til å se noe som bildet nedenfor.

bølger

Målet med denne delen er å gi vannoverflaten et tilpasset materiale og lage animerte bølger.

For å få effektene vi ønsker, må vi sette opp et tilpasset materiale. De fleste 3D-motorer vil ha noen forhåndsdefinerte shaders for gjengivelse av objekter og en måte å overstyre dem på. Her er en god referanse for å gjøre dette i PlayCanvas.

Feste en Shader

La oss lage en ny funksjon kalt CreateWaterMaterial som definerer et nytt materiale med en tilpasset shader og returnerer den:

Water.prototype.CreateWaterMaterial = function () // Opprett et nytt blankt materiale var material = ny pc.Material (); // Et navn gjør det bare lettere å identifisere når feilsøking material.name = "DynamicWater_Material"; // Opprett skyggedefinisjonen // Sett inn presisjonen nøyaktig, avhengig av enheten. var gd = this.app.graphicsDevice; var fragmentShader = "presisjon" + gd.precision + "float; \ n"; fragmentShader = fragmentShader + this.fs.resource; var vertexShader = this.vs.resource; // En skyggedefinisjon som brukes til å opprette en ny shader. var shaderDefinition = attributter: aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0,, vshader: vertexShader, fshader: fragmentShader; // Lag skyggeren fra definisjonen this.shader = ny pc.Shader (gd, shaderDefinition); // Bruk skygger til dette materialet material.setShader (this.shader); retur materiale; ;

Denne funksjonen tar tak i vertex- og fragment shader-koden fra skriptattributtene. Så la oss definere dem øverst på filen (etter pc.createScript linje):

Water.attributes.add ('vs', type: 'asset', assetType: 'shader', tittel: 'Vertex Shader'); Water.attributes.add ('fs', type: 'asset', assetType: 'shader', tittel: 'Fragment Shader');

Nå kan vi lage disse shader-filene og legge dem til vårt skript. Gå tilbake til redaktøren, og opprett to nye shader-filer: Water.frag og Water.vert. Fest disse shaders til skriptet ditt som vist nedenfor.

Hvis de nye attributene ikke vises i redigeringsprogrammet, klikker du på Analyser knappen for å oppdatere skriptet.

Legg nå denne grunnleggende shader inn Water.frag:

void main (void) vec4 color = vec4 (0,0,0,0,1,0,0,5); gl_FragColor = farge; 

Og dette i Water.vert:

attributt vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main (void) gl_Position = matrix_viewProjection * matrix_model * vec4 (aPosition, 1.0);  

Til slutt, gå tilbake til Water.js og la den bruke vårt nye tilpassede materiale i stedet for standardmaterialet. Så i stedet for:

var material = ny pc.StandardMaterial ();

Gjøre:

var material = this.CreateWaterMaterial ();

Nå, hvis du starter spillet, skal flyet nå være blått.

Hot Reloading

Så langt har vi nettopp satt opp noen dummy shaders på vårt nye materiale. Før vi kommer til å skrive de virkelige effektene, er en siste ting jeg vil sette opp automatisk kodenavlasting.

Uncommenting the bytte funksjon i en hvilken som helst skriptfil (for eksempel Water.js) gjør det mulig å laste på nytt. Vi ser hvordan du bruker dette senere for å opprettholde staten selv når vi oppdaterer koden i sanntid. Men for nå vil vi bare gjenta shaders når vi har oppdaget en endring. Shaders blir kompilert før de kjøres i WebGL, så vi må gjenskape det tilpassede materialet for å utløse dette.

Vi skal sjekke om innholdet i vår skyggekode har blitt oppdatert, og i så fall gjenskape materialet. Først lagre de nåværende shaders i initial:

// initialiser kode kalt en gang per enhet Water.prototype.initialize = function () this.GeneratePlaneMesh (); // Lagre dagens shaders this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;

Og i Oppdater, sjekk om det har vært noen endringer:

// oppdateringskode kalt hver ramme Water.prototype.update = function (dt) if (this.savedFS! = this.fs.resource || this.savedVS! = this.vs.resource) // Re-create the materiale slik at shaders kan rekompileres var newMaterial = this.CreateWaterMaterial (); // Bruk det på modellen var modell = this.entity.model.model; model.meshInstances [0] .material = newMaterial; // Lagre de nye shaders this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;

Nå, for å bekrefte dette virker, start spillet og endre fargen på flyet inn Water.frag til en mer smakfull blå. Når du har lagret filen, bør den oppdatere uten å oppdatere eller gjenopprette! Dette var fargen jeg valgte:

vec4 farge = vec4 (0,0,0,7,1,0,0,5);

Vertex Shaders

For å skape bølger, må vi flytte hvert toppunkt i nettverket vårt hver ramme. Dette høres ut som om det kommer til å være svært ineffektivt, men hvert vertex av hver modell blir allerede forvandlet på hver ramme vi gir. Dette er hva vertex shader gjør. 

Hvis du tenker på en fragmentskader som en funksjon som kjører på hver piksel, tar en stilling, og returnerer en farge da en vertex shader er en funksjon som kjører på hvert vertex, tar stilling, og returnerer en posisjon.

Standard vertex shader vil ta verdens posisjon av en gitt modell, og returnere skjerm posisjon. Vår 3D-scene er definert i form av x, y og z, men skjermen er et flatt todimensjonalt plan, så vi projiserer vår 3D-verden på vår 2D-skjerm. Denne projeksjonen er hva visning, projeksjon og modellmatriser tar seg av og ligger utenfor omfanget av denne opplæringen, men hvis du vil lære nøyaktig hva som skjer i dette trinnet, er det en veldig fin guide.

Så denne linjen:

gl_Position = matrix_viewProjection * matrix_model * vec4 (aPosition, 1.0);

tar en posisjon som 3D-verdensposisjonen til et bestemt toppunkt og forvandler det til gl_Position, som er den endelige 2D-skjermposisjonen. 'A'-prefikset på aPosition er å indikere at denne verdien er en Egenskap. Husk at a uniformvariabel er en verdi vi kan definere på CPUen for å overføre til en shader som beholder samme verdi over alle piksler / toppunkter. En attributt verdi kommer derimot fra en matrise definert på CPU. Vertex shader kalles en gang for hver verdi i det attributtarrangementet.

Du kan se at disse attributene er satt opp i skyggedefinisjonen vi satt opp i Water.js:

var shaderDefinition = attributter: aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0,, vshader: vertexShader, fshader: fragmentShader;

PlayCanvas tar seg av å sette opp og overføre en rekke toppunktposisjoner for en posisjon når vi går forbi denne enummen, men generelt kan du passere en rekke data til vertex shader.

Flytter snittene

La oss si at du vil klemme flyet ved å multiplisere alt x verdier med halvparten. Skulle du endre en posisjoneller gl_Position?

La oss prøve en posisjon først. Vi kan ikke endre et attributt direkte, men vi kan lage en kopi:

attributt vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main (void) vec3 pos = aPosition; pos.x * = 0,5; gl_Position = matrix_viewProjection * matrix_model * vec4 (pos, 1.0);  

Flyet skal nå se mer rektangulært ut. Ikke noe rart der. Nå hva skjer hvis vi i stedet prøver å endre gl_Position?

attributt vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main (void) vec3 pos = aPosition; //pos.x * = 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4 (pos, 1.0); gl_Position.x * = 0,5;  

Det kan se ut til du begynner å rotere kameraet. Vi endrer skjermplass koordinater, noe som betyr at det kommer til å se annerledes avhengig av hvordan ser du på det.

Så det er slik du kan flytte kryssene, og det er viktig å gjøre dette skillet mellom hvorvidt du er i verden eller på skjermen.

Utfordring # 2: Kan du flytte hele flyflaten noen få enheter opp (langs Y-aksen) i vertex-skyggeren uten å forvride sin form?
Utfordring # 3: Jeg sa gl_Position er 2D, men gl_Position.z eksisterer. Kan du kjøre noen tester for å finne ut om denne verdien påvirker noe, og i så fall hva det brukes til?

Legge til tid

En siste ting vi trenger før vi kan skape bevegelige bølger, er en jevn variabel som kan brukes som tid. Erklære en uniform i vertex shader:

ensartet flyte deg;

Så, for å sende dette til vår skygge, gå tilbake til Water.js og definer en tidsvariabel i initialiseringen:

Water.prototype.initialize = function () this.time = 0; ///// Definer først tiden her this.GeneratePlaneMesh (); // Lagre dagens shaders this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;

Nå, for å sende dette til vår skygge, bruker vi material.setParameter. Først setter vi en innledende verdi på slutten av CreateWaterMaterial funksjon:

// Lag skyggeren fra definisjonen this.shader = ny pc.Shader (gd, shaderDefinition); ////////////// Den nye delen material.setParameter ('uTime', this.time); this.material = materiale; // Lagre en henvisning til dette materialet ////////////////// // Bruk shader til dette materialet material.setShader (this.shader); retur materiale;

Nå i Oppdaterfunksjon vi kan øke tiden og få tilgang til materialet ved hjelp av referansen vi opprettet for det:

dette.tid + = 0,1; this.material.setParameter ( 'U Klokkeslett', this.time);

Som et siste trinn, i byttefunksjonen, kopier over den gamle verdien av tiden, slik at selv om du endrer koden, fortsetter den å øke uten å nullstille til 0.

Water.prototype.swap = funksjon (gammel) this.time = old.time; ;

Nå er alt klart. Start spillet for å forsikre deg om at det ikke er noen feil. La oss nå flytte flyet vårt med en funksjon av tiden i Water.vert:

pos.y + = cos (uTime)

Og flyet ditt skal flytte opp og ned nå! Fordi vi har en byttefunksjon nå, kan du også oppdatere Water.js uten å måtte starte på nytt. Prøv å gjøre tidsforhøyelsen raskere eller langsommere for å bekrefte dette.

Utfordring # 4: Kan du flytte kryssene slik at det ser ut som bølgen under? 

Som et snev snakket jeg på dybde om forskjellige måter å skape bølger på. Det var i 2D, men samme matte gjelder her. Hvis du bare vil kikke på løsningen, er det her.

gjennomskinnelighet

Målet med denne delen er å gjøre vannoverflaten gjennomsiktig.

Du har kanskje lagt merke til at fargen vi returnerer i Water.frag har en alphaverdi på 0,5, men overflaten er fortsatt helt ugjennomsiktig. Åpenhet på mange måter er fortsatt et åpent problem i datagrafikk. En billig måte å oppnå det på er å bruke blanding.

Normalt, når en piksel skal trekkes, kontrollerer den verdien i dybdebuffer mot sin egen dybdeverdi (sin posisjon langs Z-aksen) for å bestemme om den nåværende pixel skal overskrives på skjermen eller kassere seg selv. Dette gjør det mulig å gjengi en scene riktig uten å måtte sortere gjenstander tilbake til forsiden. 

Ved å blande, i stedet for å bare kaste bort eller overskrive, kan vi kombinere fargen på pikselet som allerede er tegnet (målet) med pikselet som skal trekkes (kilden). Du kan se alle tilgjengelige blandingsfunksjoner i WebGL her.

For å gjøre alfa-arbeidet slik vi forventer det, ønsker vi at den kombinerte fargen på resultatet skal være kilden multiplisert med alfa pluss destinasjonen multiplisert med en minus alfa. Med andre ord, hvis alfa er 0,4, bør den endelige fargen være:

finalColor = kilde * 0.4 + destinasjon * 0,6;

I PlayCanvas gjør alternativet pc.BLEND_NORMAL akkurat dette.

For å aktivere dette, bare sett egenskapen på materialet inni CreateWaterMaterial:

 material.blendType = pc.BLEND_NORMAL;

Hvis du starter spillet nå, vil vannet være gjennomsiktig! Dette er ikke perfekt, skjønt. Et problem oppstår hvis den gjennomsiktige overflaten overlapper med seg selv, som vist nedenfor.

Vi kan fikse dette ved å bruke alfa til dekning, som er en flersamplingsteknikk for å oppnå gjennomsiktigheti stedet for å blande:

//material.blendType = pc.BLEND_NORMAL; material.alphaToCoverage = true;

Men dette er bare tilgjengelig i WebGL 2. For resten av denne opplæringen bruker jeg blanding for å holde det enkelt.

Sammendrag

Så langt har vi satt opp miljøet vårt og skapt vår gjennomsiktige vannoverflate med animerte bølger fra vår vertex shader. Den andre delen vil dekke påføring av oppdrift på gjenstander, legge vannlinjer til overflaten og skape skumlinjene rundt kantene på gjenstander som krysser overflaten. 

Den endelige delen vil dekke bruk av undervannsprosessforvrengningseffekten og noen ideer for hvor du skal gå neste.

Kildekode

Du finner det ferdige vertsbaserte PlayCanvas-prosjektet her. En Three.js-port er også tilgjengelig i dette depotet.