Opprette Toon Water for Web Del 2

Velkommen tilbake til denne tredelte serien om å skape stilisert tonevann i PlayCanvas med vertex shaders. I del 1 dekket vi å sette opp vårt miljø og vannoverflate. Denne delen vil dekke påføring av oppdrift til gjenstander, legge til vannlinjer til overflaten og skape skumlinjene med dybdebufferen rundt kantene på gjenstander som skjærer overflaten. 

Jeg gjorde noen små endringer på scenen min for å få det til å se litt finere ut. Du kan tilpasse scenen din uansett, men det jeg gjorde var:

  • Lagt til fyret og blekksprutmodeller.
  • Lagt til et bakkeplan med farge # FFA457
  • Lagt til en klar farge for kameraet til # 6CC8FF.
  • Lagt til en omgivende farge til scenen til # FFC480 (du finner dette i sceneinnstillingene).

Nedenfor er hva mitt utgangspunkt nå ser ut.

oppdrift 

Den enkleste måten å skape oppdrift er bare å lage et skript som vil skyve objekter opp og ned. Opprett et nytt skript som heter Buoyancy.js og sett inn initialiseringen til:

Buoyancy.prototype.initialize = function () this.initialPosition = this.entity.getPosition (). Klone (); this.initialRotation = this.entity.getEulerAngles (). klone (); // Den opprinnelige tiden er satt til en tilfeldig verdi slik at hvis // dette skriptet er festet til flere objekter, vil de ikke // flytte på samme måte this.time = Math.random () * 2 * Math.PI; ;

Nå, i oppdateringen, øker vi tiden og roterer objektet:

Buoyancy.prototype.update = function (dt) this.time + = 0,1; // Flytt objektet opp og ned var pos = this.entity.getPosition (). Klone (); pos.y = this.initialPosition.y + Math.cos (denne tiden) * 0,07; this.entity.setPosition (pos.x, pos.y, pos.z); // Roter objektet litt var rot = this.entity.getEulerAngles (). Klone (); rot.x = this.initialRotation.x + Math.cos (denne tiden * 0.25) * 1; rot.z = this.initialRotation.z + Math.sin (denne tiden * 0,5) * 2; this.entity.setLocalEulerAngles (rot.x, rot.y, rot.z); ;

Påfør dette skriptet til båten din og se det bobbing opp og ned i vannet! Du kan bruke dette skriptet til flere objekter (inkludert kameraet, prøv det)!

Teksturering av overflaten

Akkurat nå er den eneste måten du kan se bølgene på, ved å se på kantene på vannoverflaten. Legge til en tekstur bidrar til å gjøre bevegelse på overflaten mer synlig og er en billig måte å simulere refleksjoner og kaustik på.

Du kan prøve å finne noen caustics tekstur eller lage din egen. Her er en jeg trakk i Gimp som du kan bruke fritt. Enhver tekstur vil fungere så lenge det kan flises sømløst.

Når du har funnet en tekstur du liker, drar du den inn i prosjektets aktivitetsvindu. Vi må referere til denne teksten i vårt Water.js-skript, så opprett et attributt for det:

Water.attributes.add ('surfaceTexture', type: 'asset', assetType: 'texture', tittel: 'Surface Texture');

Og deretter tilordne den i redaktøren:

Nå må vi sende det til vår skygge. Gå til Water.js og sett inn en ny parameter i CreateWaterMaterial funksjon:

material.setParameter ( 'uSurfaceTexture', this.surfaceTexture.resource);

Gå nå inn Water.frag og erklære vår nye uniform:

enhetlig sampler2D uSurfaceTexture;

Vi er nesten der. For å gjøre teksturet på flyet må vi vite hvor hver piksel er langs masken. Det betyr at vi må passere noen data fra vertex shader til fragment shader.

Varierende variabler

EN varierendevariabel lar deg overføre data fra vertex shader til fragment shader. Dette er den tredje typen spesielle variabelen du kan ha i en shader (de to andre er uniformog Egenskap). Den er definert for hvert toppunkt og er tilgjengelig for hver piksel. Siden det er mye flere piksler enn toppunkter, blir verdien interpolert mellom krysser (det er her navnet "varierende" kommer fra - det varierer fra verdiene du gir det).

For å prøve dette ut, erklærer du en ny variabel i Water.vert som en varierende:

varierende vec2 ScreenPosition;

Og sett den til gl_Position etter at det er blitt beregnet:

ScreenPosition = gl_Position.xyz;

Gå nå tilbake til Water.frag og erklære den samme variabelen. Det er ingen måte å få noen feilsøkingsutdata fra en shader, men vi kan bruke farge til visuelt feilsøking. Her er en måte å gjøre dette på:

enhetlig sampler2D uSurfaceTexture; varierende vec3 ScreenPosition; void main (void) vec4 color = vec4 (0,0,0,7,1,0,0,5); // Testing av vår nye varierende variabel farge = vec4 (vec3 (ScreenPosition.x), 1.0); gl_FragColor = farge; 

Flyet skal nå se svart og hvitt, hvor linjen som skiller dem er hvor ScreenPosition.x er 0. Fargeverdier går bare fra 0 til 1, men verdiene i ScreenPosition kan være utenfor dette området. De blir automatisk klemmet, så hvis du ser svart, kan det være 0 eller negativt.

Det vi nettopp har gjort, passerer skjermposisjonen til hvert toppunkt til hver piksel. Du kan se at linjen som adskiller de svarte og hvite sidene, alltid kommer til å ligge i midten av skjermen, uansett hvor overflaten faktisk er i verden.

Utfordring # 1: Opprett en ny variabel variabel for å passere verdensposisjonen i stedet for skjermposisjonen. Visualiser det på samme måte som vi gjorde over. Hvis fargen ikke endres med kameraet, har du gjort dette riktig.

Bruke UVs 

UV-ene er 2D-koordinatene for hvert vertex langs masken, normalisert fra 0 til 1. Dette er akkurat det vi trenger for å prøve tekstur på flyet riktig, og det bør allerede settes opp fra forrige del.

Erklære et nytt attributt i Water.vert (dette navnet kommer fra skyggedefinisjonen i Water.js):

attributt vec2 aUv0;

Og alt vi trenger å gjøre er å sende det til fragment shader, så bare opprett et varierende og sett det til attributtet:

// I Water.vert // Vi erklærer dette sammen med våre andre variabler på toppen varierende vec2 vUv0; // ... // Ned i hovedfunksjonen lagrer vi verdien av attributten // i varierende slik at frag shader kan få tilgang til den vUv0 = aUv0; 

Nå erklærer vi det samme som varierende i fragment shader. For å bekrefte det fungerer, kan vi visualisere det som før, slik at Water.frag nå ser ut som:

enhetlig sampler2D uSurfaceTexture; varierende vec2 vUv0; void main (void) vec4 color = vec4 (0,0,0,7,1,0,0,5); // Bekreft UVs farge = vec4 (vec3 (vUv0.x), 1.0); gl_FragColor = farge; 

Og du bør se en gradient, som bekrefter at vi har en verdi på 0 i den ene enden og en på den andre. Nå, for å faktisk prøve vår tekstur, er alt vi trenger å gjøre:

farge = texture2D (uSurfaceTexture, vUv0);

Og du bør se tekstur på overflaten:

Stilisere strukturen

I stedet for å bare sette tekstur som vår nye farge, la oss kombinere den med det blå vi hadde:

enhetlig sampler2D uSurfaceTexture; varierende vec2 vUv0; void main (void) vec4 color = vec4 (0,0,0,7,1,0,0,5); vec4 WaterLines = texture2D (uSurfaceTexture, vUv0); color.rgba + = WaterLines.r; gl_FragColor = farge; 

Dette virker fordi fargen på tekstur er svart (0) overalt bortsett fra vannlinjene. Ved å legge til det, endrer vi ikke den opprinnelige blå farge unntatt stedene der det er linjer, hvor det blir lysere. 

Dette er ikke den eneste måten å kombinere fargene på, skjønt.

Utfordring # 2: Kan du kombinere fargene på en måte å få den subtilere effekten som vises nedenfor?

Flytte strukturen

Som en endelig effekt ønsker vi at linjene skal bevege seg langs overflaten slik at det ikke ser så statisk ut. For å gjøre dette bruker vi det faktum at enhver verdi gitt til texture2D Funksjon utenfor 0 til 1-området vil vikle rundt (slik at 1,5 og 2,5 begge blir 0,5). Så vi kan øke vår posisjon ved den tidvise ensartede variabelen vi allerede har satt opp og multipliserer posisjonen for å enten øke eller redusere tettheten av linjene i overflaten, slik at vår siste frag shader ser slik ut:

enhetlig sampler2D uSurfaceTexture; ensartet flyte deg; varierende vec2 vUv0; void main (void) vec4 color = vec4 (0,0,0,7,1,0,0,5); vec2 pos = vUv0; // Multiplikasjon med et tall større enn 1 forårsaker // teksten å gjenta oftere pos * = 2.0; // Forskyv hele tekstur slik at den beveger seg langs overflaten pos.y + = uTime * 0,02; vec4 WaterLines = texture2D (uSurfaceTexture, pos); color.rgba + = WaterLines.r; gl_FragColor = farge; 

Skumlinjer og dybdebufferen

Rendering av skumlinjer rundt gjenstander i vann gjør det langt lettere å se hvordan gjenstander er nedsenket og hvor de kutter overflaten. Det gjør også vårt vann ser mye mer troverdig ut. For å gjøre dette må vi på en eller annen måte finne ut hvor kantene er på hvert objekt, og gjør dette effektivt.

Trikset

Det vi ønsker er å kunne fortelle, gitt en piksel på overflaten av vannet, om det er nær et objekt. I så fall kan vi farge det som skum. Det er ingen enkel måte å gjøre dette på (som jeg vet om). For å finne ut dette, skal vi bruke en nyttig problemløsningsteknikk: kom med et eksempel vi vet svaret på, og se om vi kan generalisere det. 

Vurder visningen nedenfor.

Hvilke piksler bør være en del av skummet? Vi vet at det skal se slik ut:

Så la oss tenke på to spesifikke piksler. Jeg har merket to med stjerner under. Den svarte er i skummet. Den røde er det ikke. Hvordan kan vi fortelle dem i en skygge?

Det vi vet er at selv om de to pikslene er tett sammen i skjermrommet (begge gjengis rett på toppen av fyrkroppen), er de faktisk langt fra hverandre i verdensrommet. Vi kan bekrefte dette ved å se på samme scene fra en annen vinkel, som vist nedenfor.

Legg merke til at den røde stjernen ikke er på toppen av fyrkroppen som det så ut, men den sorte stjernen er faktisk. Vi kan fortelle dem fra hverandre ved å bruke avstanden til kameraet, ofte referert til som "dybde", hvor en dybde på 1 betyr at den er svært nær kameraet og en dybde på 0 betyr at den er veldig langt. Men det handler ikke bare om den absolutte verdensavstand eller dybde, til kameraet. Det er dybden sammenlignet med piksel bak.

Se tilbake til første visning. La oss si at fyrkroppen har en dybdeverdi på 0,5. Den svarte stjernens dybde vil være svært nær 0,5. Så det og pikselet bak den har like dybdeverdier. Den røde stjernen ville derimot ha et mye større dybde fordi det ville være nærmere kameraet, si 0,7. Og likevel har piksel bak den, fortsatt på fyret, en dybdeverdi på 0,5, så det er en større forskjell der.

Dette er trikset. Når dybden på pikselet på vannflaten er nær nok til dybden av pikselet, blir den trukket på toppen av, vi er ganske nær kanten av noe, og vi kan gjøre det som skum. 

Så vi trenger mer informasjon enn det er tilgjengelig i en gitt piksel. Vi må på en eller annen måte vite dybden på pikselet som den skal trekkes på. Dette er hvor dybdebufferen kommer inn.

Dybdebufferen

Du kan tenke på en buffer, eller en framebuffer, som bare et off-screen-gjengemål, eller en tekstur. Du vil gjerne vise off-screen når du prøver å lese data tilbake, en teknikk som denne røykeffekten bruker.

Dybdebufferen er et spesielt gjengemål som inneholder informasjon om dybdeverdiene ved hvert piksel. Husk at verdien i gl_Position Beregnet i vertex shader var en skjerm plass verdi, men den hadde også en tredje koordinat, en Z-verdi. Denne Z-verdien brukes til å beregne dybden som er skrevet til dybdebufferen. 

Hensikten med dybdebufferen er å tegne vår scene riktig, uten å måtte sortere gjenstander tilbake til forsiden. Hver piksel som skal trekkes først, konsulterer dybdebufferen. Hvis dens dybdeverdi er større enn verdien i bufferen, blir den tegnet, og dens egen verdi overskriver den i bufferen. Ellers blir det kastet (fordi det betyr at et annet objekt er foran det).

Du kan faktisk slå av å skrive til dybdebufferen for å se hvordan ting vil se uten det. Du kan prøve dette i Water.js:

material.depthTest = false;

Du vil se hvordan vannet alltid vil bli gjengitt på toppen, selv om det ligger bak ugjennomsiktige gjenstander.

Visualisering av dybdebufferen

La oss legge til en måte å visualisere dybdebufferen for feilsøkingsformål. Opprett et nytt skript som heter DepthVisualize.js. Fest dette til kameraet ditt. 

Alt vi trenger å gjøre for å få tilgang til dybdebufferen i PlayCanvas, er å si:

this.entity.camera.camera.requestDepthMap (); 

Dette vil da automatisk injisere en uniform i alle våre shaders som vi kan bruke ved å erklære det som:

enhetlig sampler2D uDepthMap;

Nedenfor er et eksempelskript som ber om dybdekartet og gjør det på toppen av vår scene. Den er satt opp for hot-reloading. 

var DepthVisualize = pc.createScript ('deepVisualize'); // initialiser kode kalt en gang per enhet DepthVisualize.prototype.initialize = function () this.entity.camera.camera.requestDepthMap (); this.antiCacheCount = 0; // For å forhindre at motoren cacher vår shader slik at vi kan leve-oppdatere den this.SetupDepthViz (); ; DepthVisualize.prototype.SetupDepthViz = function () var enhet = this.app.graphicsDevice; var chunks = pc.shaderChunks; this.fs = "; this.fs + = 'varierende vec2 vUv0;'; this.fs + = 'uniform sampler2D uDepthMap;'; this.fs + ="; this.fs + = 'float unpackFloat (vec4 rgbaDepth) '; this.fs + = 'const vec4 bitShift = vec4 (1,0 / (256,0 * 256,0 * 256,0), 1,0 / (256,0 * 256,0), 1,0 / 256,0, 1,0);'; this.fs + = 'float depth = dot (rgbaDepth, bitShift);'; this.fs + = 'returdybde;'; this.fs + = ''; this.fs + = "; this.fs + = 'void main (void) '; this.fs + = 'float depth = unpackFloat (texture2D (uDepthMap, vUv0)) * 30.0;'; this.fs + = ' gl_FragColor = vec4 (vec3 (dybde), 1,0); '; this.fs + =' '; this.shader = chunks.createShaderFromCode (enhet, chunks.fullscreenQuadVS, this.fs, "renderDepth" + this.antiCacheCount); this.antiCacheCount ++; // Vi lager manuelt et tegneanrop for å gjøre dybdekartet på toppen av alt this.command = new pc.Command (pc.LAYER_FX, pc.BLEND_NONE, function () pc.drawQuadWithShader (enhet, null, this.shader); .bind (dette)); this.command.isDepthViz = true; // Bare merk det slik at vi kan fjerne det senere this.app.scene.drawCalls.push (this.command); ; // oppdateringskode kalt hver ramme DepthVisualize.prototype.update = function (dt) ; // byttemetode kreves for manuell gjenopplasting // arve skriptstatus her DepthVisualize.prototype.swap = funksjon (gammel) dette .antiCacheCount = old.antiCacheCount; // Fjern dybden viz draw call for (var i = 0; i

Prøv å kopiere det inn, og kommentere / uncomment linjen this.app.scene.drawCalls.push (this.command); å bytte dybdegjengivelsen. Det skal se noe som bildet nedenfor.

Utfordring # 3: Vannoverflaten er ikke trukket inn i dybdebufferen. PlayCanvas-motoren gjør dette med vilje. Kan du finne ut hvorfor? Hva er spesielt med vannmaterialet? For å si det på en annen måte, basert på våre dybdekontrollregler, hva ville skje hvis vannpiksler skrev til dybdebufferen?

Tips: Det er en linje du kan endre i Water.js som vil føre til at vannet skrives til dybdebufferen.

En annen ting å legge merke til er at jeg multipliserer dybdeverdien med 30 i den innebygde shaderen i initialiseringsfunksjonen. Dette er bare for å kunne se det tydelig, fordi ellers utvalg av verdier er for lite til å se som farger av farger.

Gjennomføring av trikset

PlayCanvas-motoren inneholder en hel rekke hjelpesystemer for å jobbe med dybdeverdier, men i skrivingstid blir de ikke sluppet ut i produksjon, så vi skal bare sette dem opp.

Definer følgende uniformer til Water.frag:

// Disse uniformer er alle injisert automatisk av PlayCanvas uniform sampler2D uDepthMap; enhetlig vec4 uScreenSize; uniform mat4 matrix_view; // Vi må sette denne opp oss ensartede vec4 camera_params;

Definer disse hjelperfunksjonene over hovedfunksjonen:

#ifdef GL2 float linearizeDepth (float z) z = z * 2,0-1,0; returner 1.0 / (camera_params.z * z + camera_params.w);  #else #ifndef UNPACKFLOAT #define UNPACKFLOAT float unpackFloat (vec4 rgbaDepth) const vec4 bitShift = vec4 (1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0); returnere punkt (rgbaDepth, bitShift);  #endif #endif float getLinearScreenDepth (vec2 uv) #ifdef GL2 returner linearizeDepth (texture2D (uDepthMap, uv) .r) * camera_params.y; #else returnere utpakkeFloat (texture2D (uDepthMap, uv)) * camera_params.y; #endif float getLinearDepth (vec3 pos) return - (matrix_view * vec4 (pos, 1.0)). z;  float getLinearScreenDepth () vec2 uv = gl_FragCoord.xy * uScreenSize.zw; returner getLinearScreenDepth (uv); 

Pass litt informasjon om kameraet til shader i Water.js. Sett dette der du passerer andre uniformer som uTime:

hvis (! this.camera) this.camera = this.app.root.findByName ("Camera"). kamera;  var kamera = this.camera; var n = camera.nearClip; var f = camera.farClip; var camera_params = [1 / f, f, (1-f / n) / 2, (1 + f / n) / 2]; material.setParameter ('camera_params', camera_params);

Til slutt trenger vi verdensposisjonen for hver piksel i vår frag shader. Vi trenger å få dette fra vertex shader. Så definer en varierende inn Water.frag:

varierende vec3 WorldPosition;

Definer samme varierende inn Water.vert. Sett deretter den til den forvrengte posisjonen i vertex shader, så hele koden vil se ut:

attributt vec3 aPosition; attributt vec2 aUv0; varierende vec2 vUv0; varierende vec3 WorldPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; ensartet flyte deg; void main (void) vUv0 = aUv0; vec3 pos = aPosition; pos.y + = cos (pos.z * 5.0 + uTime) * 0,1 * sin (pos.x * 5.0 + uTime); gl_Position = matrix_viewProjection * matrix_model * vec4 (pos, 1.0); WorldPosition = pos;  

Faktisk implementering av trikset

Nå er vi endelig klare til å implementere teknikken som er beskrevet i begynnelsen av denne delen. Vi ønsker å sammenligne dybden på pikselet vi er på til dybden av pikselet bak den. Pikselet vi er på kommer fra verdensposisjonen, og pikselet bak kommer fra skjermposisjonen. Så ta tak i disse to dybder:

float worldDepth = getLinearDepth (WorldPosition); float screenDepth = getLinearScreenDepth ();
Utfordring # 4: En av disse verdiene vil aldri være større enn den andre (antar depthTest = true). Kan du utlede hvilken?

Vi vet at skummet skal være hvor avstanden mellom disse to verdiene er liten. Så la oss gjengi den forskjellen på hver piksel. Sett dette på bunnen av skyggen din (og pass på at dybdesynkroniseringsskriptet fra forrige seksjon er slått av):

farge = vec4 (vec3 (screenDepth - worldDepth), 1,0); gl_FragColor = farge;

Som skal se slik ut:

Hvilken riktig peker ut kantene til et objekt som er nedsenket i vann i sanntid! Du kan selvfølgelig skala denne forskjellen vi gjør for å få skummet til å bli tykkere / tynnere.

Det er nå mange måter å kombinere denne utgangen med vannoverflaten for å få flotte skumlinjer. Du kan beholde den som en gradient, bruke den til å prøve fra en annen tekstur, eller sett den til en bestemt farge hvis forskjellen er mindre enn eller lik noen terskel.

Mitt favorittlook er å sette det til en farge som ligner på de statiske vannlinjene, så min siste hovedfunksjon ser slik ut:

void main (void) vec4 color = vec4 (0,0,0,7,1,0,0,5); vec2 pos = vUv0 * 2,0; pos.y + = uTime * 0,02; vec4 WaterLines = texture2D (uSurfaceTexture, pos); color.rgba + = WaterLines.r * 0,1; float worldDepth = getLinearDepth (WorldPosition); float screenDepth = getLinearScreenDepth (); float foamLine = klemme ((screenDepth - worldDepth), 0,0,1,0); if (foamLine < 0.7) color.rgba += 0.2;  gl_FragColor = color; 

Sammendrag

Vi skapte oppdrift på gjenstander som flyter i vannet, vi ga overflaten en bevegelig tekstur for å simulere kaustikk, og vi så hvordan vi kunne bruke dybdebufferen til å lage dynamiske skumlinjer.

For å fullføre dette vil neste og siste del introdusere post-prosess effekter og hvordan de skal brukes til å skape undervannsforvridningseffekten.

Kildekode

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