Welkom terug in deze driedelige serie over het maken van gestileerd water van toon in PlayCanvas met behulp van vertex shaders. In deel 1 hebben we het opzetten van onze omgeving en wateroppervlak besproken. Dit deel behandelt het drijfvermogen op voorwerpen, het toevoegen van waterlijnen aan het oppervlak en het creëren van de schuimlijnen met de dieptebuffer rond de randen van objecten die het oppervlak kruisen.
Ik heb een paar kleine wijzigingen in mijn scène aangebracht om het er iets leuker uit te laten zien. Je kunt je scène naar eigen smaak aanpassen, maar wat ik deed was:
# FFA457
. # 6CC8FF
.# FFC480
(je kunt dit vinden in de scène-instellingen).Hieronder ziet mijn startpunt er nu uit.
De eenvoudigste manier om drijfvermogen te creëren, is gewoon om een script te maken dat objecten op en neer duwt. Maak een nieuw script met de naam Buoyancy.js en stel de initialisatie in op:
Buoyancy.prototype.initialize = function () this.initialPosition = this.entity.getPosition (). Clone (); this.initialRotation = this.entity.getEulerAngles (). clone (); // De initiële tijd is ingesteld op een willekeurige waarde, zodat als // dit script aan meerdere objecten is gekoppeld, ze niet // allemaal op dezelfde manier worden verplaatst. Time = Math.random () * 2 * Math.PI; ;
Nu verhogen we in de update de tijd en roteren we het object:
Buoyancy.prototype.update = function (dt) this.time + = 0.1; // Verplaats het object op en neer var pos = this.entity.getPosition (). Clone (); pos.y = this.initialPosition.y + Math.cos (this.time) * 0.07; this.entity.setPosition (pos.x, pos.y, pos.z); // Roteer het object een beetje var rot = this.entity.getEulerAngles (). Clone (); rot.x = this.initialRotation.x + Math.cos (this.time * 0.25) * 1; rot.z = this.initialRotation.z + Math.sin (this.time * 0.5) * 2; this.entity.setLocalEulerAngles (rot.x, rot.y, rot.z); ;
Pas dit script toe op je boot en kijk hoe hij op en neer in het water dobbert! U kunt dit script op verschillende objecten toepassen (inclusief de camera - probeer het)!
Op dit moment is de enige manier om de golven te zien, door naar de randen van het wateroppervlak te kijken. Door een textuur toe te voegen, wordt beweging op het oppervlak beter zichtbaar en is het een goedkope manier om reflecties en bijtende stoffen te simuleren.
Je kunt proberen om een beetje bijtende textuur te vinden of je eigen textuur te maken. Hier is er een die ik gebruikte in Gimp die je vrijelijk kunt gebruiken. Elke textuur zal werken zolang het naadloos kan worden betegeld.
Zodra je een gewenste textuur hebt gevonden, sleep je deze naar het activavenster van je project. We moeten verwijzen naar deze textuur in ons Water.js-script, dus maak er een attribuut voor:
Water.attributes.add ('surfaceTexture', type: 'asset', assetType: 'texture', title: 'Surface Texture');
En wijs het dan toe in de editor:
Nu moeten we het doorgeven aan onze shader. Ga naar Water.js en stel een nieuwe parameter in de CreateWaterMaterial
functie:
material.setParameter (uSurfaceTexture ', this.surfaceTexture.resource);
Ga nu naar binnen Water.frag en verklaar ons nieuwe uniform:
uniforme sampler2D u Oppervlaktetextuur;
We zijn er bijna. Als u de textuur in het vlak wilt weergeven, moeten we weten waar elke pixel zich langs het net bevindt. Dat betekent dat we wat gegevens van de vertex-shader moeten doorgeven aan de fragmentshader.
EEN wisselendevariabele stelt u in staat om gegevens van de vertex shader door te geven aan de fragmentshader. Dit is het derde type speciale variabele dat u in een arcering kunt hebben (de andere twee zijn dat uniformen attribuut). Het is gedefinieerd voor elke vertex en is toegankelijk voor elke pixel. Omdat er veel meer pixels dan hoekpunten zijn, wordt de waarde geïnterpoleerd tussen hoekpunten (dit is waar de naam "variërend" vandaan komt-deze verschilt van de waarden die u eraan geeft).
Om dit uit te proberen, verklaar een nieuwe variabele in Water.vert als variërend:
variërende vec2 ScreenPosition;
En stel het vervolgens in op gl_Position
nadat het is berekend:
ScreenPosition = gl_Position.xyz;
Ga nu terug naar Water.frag en verklaar dezelfde variabele. Er is geen manier om een foutopsporingsuitvoer in een arcering te krijgen, maar we kunnen kleur gebruiken om visueel te debuggen. Hier is een manier om dit te doen:
uniforme sampler2D u Oppervlaktetextuur; variërende vec3 ScreenPosition; void main (void) vec4 color = vec4 (0.0,0.7,1.0,0.5); // Testen van onze nieuwe variërende variabele kleur = vec4 (vec3 (ScreenPosition.x), 1.0); gl_FragColor = kleur;
Het vliegtuig moet er nu zwart en wit uitzien, waar de lijn die hen scheidt, is ScreenPosition.x
is 0. Kleurwaarden gaan alleen van 0 naar 1, maar de waarden in ScreenPosition
kan buiten dit bereik vallen. Ze worden automatisch vastgeklemd, dus als u zwart ziet, kan dat 0 of negatief zijn.
Wat we zojuist hebben gedaan, is de schermpositie van elk hoekpunt doorgeven aan elke pixel. Je kunt zien dat de lijn tussen de zwarte en witte zijden altijd in het midden van het scherm staat, ongeacht waar het oppervlak zich in de wereld bevindt.
Uitdaging # 1: maak een nieuwe variërende variabele om de wereldpositie te passeren in plaats van de schermpositie. Visualiseer het op dezelfde manier als hierboven. Als de kleur niet verandert met de camera, dan heb je dit goed gedaan.
De UV's zijn de 2D-coördinaten voor elke vertex langs de maas, genormaliseerd van 0 tot 1. Dit is precies wat we nodig hebben om de textuur correct in het vlak te bemonsteren en deze moet al vanaf het vorige deel zijn ingesteld.
Geef een nieuw kenmerk op in Water.vert (deze naam komt van de definitie van de arcering in Water.js):
kenmerk vec2 aUv0;
En alles wat we moeten doen is het doorgeven aan de fragmentshader, dus maak gewoon een variabele aan en stel het in op het attribuut:
// In Water.vert // We verklaren dit samen met onze andere variabelen aan de bovenkant variërend vec2 vUv0; // ... // In de hoofdfunctie slaan we de waarde van het attribuut // op in de variabele zodat de frag-shader er toegang toe heeft vUv0 = aUv0;
Nu verklaren we hetzelfde variërend in de fragmentshader. Om te controleren of het werkt, kunnen we het visualiseren zoals voorheen, zodat Water.frag er nu uitziet als:
uniforme sampler2D u Oppervlaktetextuur; variërende vec2 vUv0; void main (void) vec4 color = vec4 (0.0,0.7,1.0,0.5); // Bevestiging UV-kleur = vec4 (vec3 (vUv0.x), 1,0); gl_FragColor = kleur;
En je zou een verloop moeten zien, wat bevestigt dat we een waarde hebben van 0 aan het ene uiteinde en 1 aan het andere. Om nu echt onze textuur te proeven, is alles wat we moeten doen:
color = texture2D (uSurfaceTexture, vUv0);
En je zou de textuur aan de oppervlakte moeten zien:
In plaats van alleen de textuur in te stellen als onze nieuwe kleur, laten we het combineren met het blauw dat we hadden:
uniforme sampler2D u Oppervlaktetextuur; variërende 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 = kleur;
Dit werkt omdat de kleur van de textuur overal zwart (0) is, behalve de waterlijnen. Door het toe te voegen, veranderen we de originele blauwe kleur niet, behalve de plaatsen waar er lijnen zijn, waar deze helderder worden.
Dit is echter niet de enige manier om de kleuren te combineren.
Uitdaging # 2: kunt u de kleuren op een manier combineren om het subtielere effect hieronder te krijgen?
Als een uiteindelijk effect willen we dat de lijnen langs het oppervlak bewegen, zodat het er niet zo statisch uitziet. Om dit te doen, gebruiken we het feit dat elke waarde die wordt gegeven aan de texture2D
functie buiten het 0 tot 1 bereik zal zich omwikkelen (zodat 1,5 en 2,5 beide 0,5 worden). Dus we kunnen onze positie verhogen met de tijd uniforme variabele die we al hebben ingesteld en de positie vermenigvuldigen om de dichtheid van de lijnen in ons oppervlak te verhogen of te verlagen, waardoor onze uiteindelijke frag shader er als volgt uitziet:
uniforme sampler2D u Oppervlaktetextuur; uniforme float uTime; variërende vec2 vUv0; void main (void) vec4 color = vec4 (0.0,0.7,1.0,0.5); vec2 pos = vUv0; // Door te vermenigvuldigen met een getal groter dan 1, wordt de // -textuur vaker herhaald pos * = 2,0; // De hele textuur verplaatsen zodat deze langs het oppervlak beweegt pos.y + = uTime * 0,02; vec4 WaterLines = texture2D (uSurfaceTexture, pos); color.rgba + = WaterLines.r; gl_FragColor = kleur;
Het renderen van schuimlijnen rond objecten in water maakt het veel gemakkelijker om te zien hoe voorwerpen worden ondergedompeld en waar ze het oppervlak afsnijden. Het maakt ons water er ook veel geloofwaardiger uit. Om dit te doen, moeten we op de een of andere manier achterhalen waar de randen zich op elk object bevinden en dit efficiënt doen.
Wat we willen is om te kunnen zien, gegeven een pixel op het oppervlak van het water, of het dicht bij een object is. Als dat zo is, kunnen we het kleuren als schuim. Er is geen eenvoudige manier om dit te doen (dat weet ik). Dus om dit uit te zoeken, gaan we een nuttige probleemoplossende techniek gebruiken: bedenk een voorbeeld waar we het antwoord op weten, en kijk of we het kunnen generaliseren.
Bekijk het onderstaande overzicht.
Welke pixels moeten deel uitmaken van het schuim? We weten dat het er ongeveer zo uit zou moeten zien:
Laten we dus eens nadenken over twee specifieke pixels. Ik heb er twee gemarkeerd met onderstaande sterren. De zwarte zit in het schuim. De rode is dat niet. Hoe kunnen we ze in een arcering van elkaar onderscheiden??
Wat we weten is dat, hoewel die twee pixels dicht bij elkaar in de schermruimte staan (beide worden recht op het vuurtorenlichaam weergegeven), ze feitelijk ver uit elkaar staan in de wereldruimte. We kunnen dit verifiëren door dezelfde scène vanuit een andere hoek te bekijken, zoals hieronder wordt getoond.
Merk op dat de rode ster niet bovenop het vuurtorenlichaam is zoals het leek, maar de zwarte ster eigenlijk is. We kunnen ze onderscheiden door de afstand tot de camera te gebruiken, gewoonlijk "diepte" genoemd, waarbij een diepte van 1 betekent dat het heel dicht bij de camera ligt en een diepte van 0 betekent dat het erg ver is. Maar het is niet alleen een kwestie van de absolute wereldafstand, of diepte, van de camera. Het is de diepte vergeleken met de pixel erachter.
Kijk terug naar de eerste weergave. Laten we zeggen dat het vuurtorenlichaam een dieptewaarde van 0,5 heeft. De diepte van de zwarte ster zou bijna 0,5 zijn. Dus het en de pixel erachter hebben dezelfde dieptewaarden. De rode ster daarentegen zou een veel grotere diepte hebben, omdat deze dichter bij de camera zou staan, bijvoorbeeld 0,7. En toch heeft de pixel erachter, nog steeds op de vuurtoren, een dieptewaarde van 0,5, dus er is een groter verschil daar.
Dit is de truc. Wanneer de diepte van de pixel op het wateroppervlak dicht genoeg bij de diepte van de pixel ligt die erop is getekend, staan we redelijk dicht bij de rand van iets, en we kunnen het als schuim maken.
We hebben dus meer informatie nodig dan beschikbaar is in een bepaalde pixel. We moeten op de een of andere manier de diepte van de pixel weten die het op het punt staat te worden getekend. Dit is waar de dieptebuffer binnenkomt.
Je kunt een buffer of een framebuffer zien als een niet-doelgericht renderdoel of een textuur. Je zou willen off-screen renderen als je gegevens probeert terug te lezen, een techniek die dit rookeffect gebruikt.
De dieptebuffer is een speciaal renderdoel dat informatie bevat over de dieptewaarden op elke pixel. Onthoud dat de waarde in gl_Position
berekend in de hoekpuntshader was een schermruimtewaarde, maar deze had ook een derde coördinaat, een Z-waarde. Deze Z-waarde wordt gebruikt om de diepte te berekenen die naar de dieptebuffer wordt geschreven.
Het doel van de dieptebuffer is om onze scène correct te tekenen, zonder de noodzaak om objecten van achter naar voren te sorteren. Elke pixel die op het punt staat te worden getekend, raadpleegt eerst de dieptebuffer. Als de dieptewaarde groter is dan de waarde in de buffer, wordt deze getekend en overschrijft de eigen waarde de waarde in de buffer. Anders wordt het weggegooid (omdat het betekent dat er een ander object voor staat).
Je kunt het schrijven naar de dieptebuffer eigenlijk uitschakelen om te zien hoe dingen er zonder zouden uitzien. Je kunt dit proberen in Water.js:
material.depthTest = false;
Je zult zien hoe het water altijd bovenop zal worden weergegeven, zelfs als het zich achter ondoorzichtige voorwerpen bevindt.
Laten we een manier toevoegen om de dieptebuffer te visualiseren voor foutopsporingsdoeleinden. Maak een nieuw script met de naam DepthVisualize.js. Bevestig dit aan uw camera.
Alles wat we moeten doen om toegang te krijgen tot de dieptebuffer in PlayCanvas is om te zeggen:
this.entity.camera.camera.requestDepthMap ();
Dit injecteert dan automatisch een uniform in al onze shaders die we kunnen gebruiken door het te verklaren als:
uniforme sampler2D uDepthMap;
Hieronder staat een voorbeeldscript dat de dieptekaart vraagt en deze bovenaan onze scène plaatst. Het is ingesteld voor hot-reloading.
var DepthVisualize = pc.createScript ('depthVisualize'); // initialiseer de code eenmaal per entiteit genaamd DepthVisualize.prototype.initialize = function () this.entity.camera.camera.requestDepthMap (); this.antiCacheCount = 0; // Om te voorkomen dat de engine onze shader in cache plaatst, zodat we deze live kunnen bijwerken. Setup DepthViz (); ; DepthVisualize.prototype.SetupDepthViz = function () var device = this.app.graphicsDevice; var chunks = pc.shaderChunks; this.fs = "; this.fs + = 'variërend 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 + = 'zweef diepte = punt (rgbaDepth, bitShift);'; this.fs + = 'retourdiepte;'; 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 (depth), 1.0); '; this.fs + =' '; this.shader = chunks.createShaderFromCode (device, chunks.fullscreenQuadVS, this.fs, "renderDepth" + this.antiCacheCount); this.antiCacheCount ++; // We maken handmatig een tekenoproep om de dieptekaart bovenop alles weer te geven this.command = nieuwe pc.Command (pc.LAYER_FX, pc.BLEND_NONE, function () pc.drawQuadWithShader (device, null, this.shader); .bind (this)); this.command.isDepthViz = true; // Markeer het zodat we het later kunnen verwijderen this.app.scene.drawCalls.push (this.command); ; // update-code genaamd elk frame DepthVisualize.prototype.update = functie (dt) ; // swap-methode genaamd script hot-reloading // erft uw scriptstatus hier DepthVisualize.prototype.swap = function (oud) this .antiCacheCount = old.antiCacheCount; // Verwijder de diepte viz draw call for (var i = 0; iProbeer dat te kopiëren en commentaar / commentaar te geven op de regel
this.app.scene.drawCalls.push (this.command);
om de diepteweergave aan te passen. Het zou er ongeveer zo uit moeten zien als de afbeelding hieronder.Uitdaging # 3: Het wateroppervlak wordt niet in de dieptebuffer getrokken. De PlayCanvas-engine doet dit opzettelijk. Kun je erachter komen waarom? Wat is er speciaal aan het watermateriaal? Om het anders te zeggen, op basis van onze diepteregelregels, wat zou er gebeuren als de waterpixels naar de dieptebuffer zouden schrijven?Hint: er is een regel die je kunt veranderen in Water.js waardoor het water naar de dieptebuffer wordt geschreven.
Een ander ding om op te merken is dat ik de dieptewaarde met 30 vermenigvuldig in de ingebedde arcering in de initialiseerfunctie. Dit is alleen om het duidelijk te kunnen zien, omdat anders het bereik van waarden te klein is om als kleurschakeringen te zien.
De truc implementeren
De PlayCanvas-engine bevat een aantal helperfuncties om met dieptewaarden te werken, maar op het moment van schrijven worden ze niet vrijgegeven voor productie, dus we gaan deze zelf instellen.
Definieer de volgende uniformen voor Water.frag:
// Deze uniformen worden allemaal automatisch geïnjecteerd door PlayCanvas uniform sampler2D uDepthMap; uniforme vec4 uScreenSize; uniforme mat4 matrix_view; // We moeten deze zelf een keer instellen vec4 camera_params;Definieer deze helperfuncties boven de hoofdfunctie:
#ifdef GL2 float linearizeDepth (float z) z = z * 2.0 - 1.0; return 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); return-punt (rgbaDepth, bitShift); #endif #endif float getLinearScreenDepth (vec2 uv) #ifdef GL2 return linearizeDepth (texture2D (uDepthMap, uv) .r) * camera_params.y; #else return unpackFloat (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; return getLinearScreenDepth (uv);Geef wat informatie over de camera door aan de arcering in Water.js. Zet dit waar je andere uniformen zoals uTime voorbijgaat:
if (! this.camera) this.camera = this.app.root.findByName ("Camera"). camera; var camera = 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);Ten slotte hebben we de wereldpositie nodig voor elke pixel in onze frag shader. We moeten dit van de hoekshader halen. Dus definieer een variërende in Water.frag:
variërende vec3 WorldPosition;Definieer hetzelfde variërend in Water.vert. Stel het vervolgens in op de vervormde positie in de hoekpuntshader, zodat de volledige code er als volgt uitziet:
attribuut vec3 aPosition; kenmerk vec2 aUv0; variërende vec2 vUv0; variërende vec3 WorldPosition; uniforme mat4 matrix_model; uniforme mat4 matrix_viewProjection; uniforme float uTime; 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;De truc daadwerkelijk implementeren
Nu zijn we eindelijk klaar om de techniek te implementeren die aan het begin van dit gedeelte wordt beschreven. We willen de diepte van de pixel die we hebben vergelijken met de diepte van de pixel erachter. De pixel waar we bij zijn komt van de wereldpositie en de pixel erachter komt van de schermpositie. Grijp dus deze twee diepten:
float worldDepth = getLinearDepth (WorldPosition); float screenDepth = getLinearScreenDepth ();Uitdaging # 4: Een van deze waarden zal nooit groter zijn dan de andere (ervan uitgaande dat depthTest = true). Kun je afleiden welke?We weten dat het schuim zal zijn waar de afstand tussen deze twee waarden klein is. Dus laten we dat verschil bij elke pixel weergeven. Plaats dit onderaan de arcering (en zorg ervoor dat het dieptevisiesscript uit de vorige sectie is uitgeschakeld):
color = vec4 (vec3 (screenDepth - worldDepth), 1.0); gl_FragColor = kleur;Die er ongeveer zo uit zou moeten zien:
Die de randen van elk object dat in realtime in water is ondergedompeld, in realtime uitzoekt! Je kunt dit verschil natuurlijk ook schalen om het schuim dikker / dunner te laten lijken.
Er zijn nu veel manieren waarop je deze uitvoer kunt combineren met de kleur van het wateroppervlak om mooie schuimlijnen te krijgen. Je kunt het als een verloop houden, gebruiken om te samplen van een andere textuur, of het instellen op een specifieke kleur als het verschil kleiner is dan of gelijk is aan een bepaalde drempelwaarde.
Mijn favoriete look is het instellen op een kleur die lijkt op die van de statische waterlijnen, dus mijn laatste hoofdfunctie ziet er zo uit:
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 = klem ((screenDepth - worldDepth), 0.0.1.0); if (Foamline < 0.7) color.rgba += 0.2; gl_FragColor = color;Samenvatting
We creëerden drijfvermogen op objecten die in het water dobberden, we gaven ons oppervlak een bewegende textuur om bijtende stoffen te simuleren, en we zagen hoe we de dieptebuffer konden gebruiken om dynamische schuimlijnen te creëren.
Om dit te voltooien, introduceert het volgende en laatste deel postprocessingeffecten en hoe deze te gebruiken om het onderwatervervormingseffect te creëren.
Broncode
Je kunt het voltooide gehoste PlayCanvas-project hier vinden. Een Three.js-poort is ook beschikbaar in deze repository.