In mijn Beginnershandleiding voor Shaders richtte ik me uitsluitend op fragmentschaduwen, wat voldoende is voor elk 2D-effect en elk ShaderToy-voorbeeld. Maar er is een hele categorie technieken die vertex shaders vereisen. Deze zelfstudie begeleidt u bij het maken van gestileerd water van toon terwijl u vertex-shaders introduceert. Ik zal ook de dieptebuffer introduceren en hoe je die kunt gebruiken om meer informatie over je scène te krijgen en om schuimlijnen te maken.
Dit is hoe het uiteindelijke effect eruit zou moeten zien. Je kunt hier een live demo proberen (linker muisknop om te draaien, rechtermuis om te pannen, scrollwiel om in te zoomen).
Concreet bestaat dit effect uit:
Wat ik leuk vind aan dit effect, is dat het veel verschillende concepten in computergraphics raakt, dus het zal ons in staat stellen om te putten uit ideeën uit vorige tutorials, en technieken te ontwikkelen die we kunnen gebruiken voor een verscheidenheid aan toekomstige effecten..
Ik gebruik PlayCanvas hiervoor omdat het een handige gratis web-ID heeft, maar alles moet van toepassing zijn op elke omgeving met WebGL. Je kunt aan het einde een Three.js-versie van de broncode vinden. Ik ga ervan uit dat je je comfortabel voelt met het gebruik van fragment shaders en het navigeren door de PlayCanvas-interface. Je kunt hier shaders opfrissen en hier een intro naar PlayCanvas scrollen.
Het doel van deze sectie is om ons PlayCanvas-project op te zetten en enkele omgevingsobjecten te plaatsen om het water tegen te testen.
Als u nog geen account bij PlayCanvas hebt, meldt u zich aan voor een account en maakt u een nieuw account leeg project. Standaard zou je een paar objecten, een camera en een licht in je scène moeten hebben.
Het Poly-project van Google is echt een geweldige bron voor 3D-modellen voor het web. Dit is het bootmodel dat ik heb gebruikt. Zodra u dat hebt gedownload en uitgepakt, zou u een .obj
en een .png
het dossier.
.png
het dossier.Nu kunt u de slepen Tugboat.json in je scène en verwijder de Box en Plane-objecten. Je kunt de boot opschalen als hij er te klein uitziet (ik heb de mijne op 50 gezet).
U kunt op dezelfde manier andere modellen aan uw scène toevoegen.
Om een baancamera in te stellen, zullen we een script uit dit PlayCanvas-voorbeeld kopiëren. Ga naar die link en klik op Editor om het project te betreden.
mouse-input.js
en orbit-camera.js
van dat zelfstudieproject naar de bestanden met dezelfde naam in uw eigen project.Tip: u kunt mappen in het activatievenster maken om dingen georganiseerd te houden. Ik heb deze twee camerascripts geplaatst onder Scripts / Camera /, mijn model onder Models /, en mijn materiaal onder Materialen /.
Wanneer je nu het spel start (afspeelknop in de rechterbovenhoek van de scènemodus), zou je je boot moeten kunnen zien en er omheen kunnen cirkelen met de muis.
Het doel van deze sectie is om een onderverdeeld net te maken dat we kunnen gebruiken als ons wateroppervlak.
Om het wateroppervlak te genereren, gaan we wat code aanpassen van deze zelfstudie voor terreingeneratie. Maak een nieuw scriptbestand met de naam Water.js
. Bewerk dit script en maak een nieuwe functie met de naam GeneratePlaneMesh
dat ziet er zo uit:
Water.prototype.GeneratePlaneMesh = function (options) // 1 - Stel standaardopties in als er geen zijn opgegeven als (options === undefined) options = subdivisions: 100, width: 10, height: 10; // 2 - Genereer punten, uv's en indexen var posities = []; var uvs = []; var indices = []; var rij, col; var normals; voor (rij = 0; rij <= 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 ;
Nu kun je dit in de initialiseren
functie:
Water.prototype.initialize = function () this.GeneratePlaneMesh (subdivisions: 100, width: 10, height: 10); ;
Je zou maar een plat vlak moeten zien als je het spel nu start. Maar dit is niet alleen een plat vlak. Het is een maaswerk dat uit duizend hoekpunten bestaat. Als een uitdaging, probeer dit te verifiëren (het is een goed excuus om de code te lezen die je zojuist hebt gekopieerd).
Uitdaging # 1: Verplaats de Y-coördinaat van elke vertex willekeurig, zodat het vlak er ongeveer zo uitziet als de afbeelding hieronder.
Het doel van deze sectie is om het wateroppervlak een aangepast materiaal te geven en geanimeerde golven te creëren.
Om de gewenste effecten te krijgen, moeten we een aangepast materiaal maken. De meeste 3D-engines hebben een aantal vooraf gedefinieerde shaders voor rendering-objecten en een manier om deze te overschrijven. Dit is een goede referentie om dit in PlayCanvas te doen.
Laten we een nieuwe functie maken genaamd CreateWaterMaterial
die een nieuw materiaal definieert met een aangepaste arcering en retourneert het:
Water.prototype.CreateWaterMaterial = function () // Maak een nieuw onbewerkt materiaal var material = new pc.Material (); // Een naam maakt het eenvoudig om te identificeren bij het debuggen van material.name = "DynamicWater_Material"; // Maak de arceringsdefinitie // stel de precisie dynamisch in afhankelijk van het apparaat. var gd = this.app.graphicsDevice; var fragmentShader = "precision" + gd.precision + "float; \ n"; fragmentShader = fragmentShader + this.fs.resource; var vertexShader = this.vs.resource; // Een shader-definitie die wordt gebruikt om een nieuwe arcering te maken. var shaderDefinition = attributes: aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0,, vshader: vertexShader, fshader: fragmentShader; // Maak de shader uit de definitie this.shader = new pc.Shader (gd, shaderDefinition); // Pas een shader toe op dit materiële materiaal. SetShader (this.shader); materiaal terugsturen; ;
Deze functie pakt de hoekpunt- en fragmentshadercode uit de scriptattributen. Dus laten we degenen bovenin het bestand definiëren (na de pc.createScript
lijn):
Water.attributes.add ('vs', type: 'asset', assetType: 'shader', titel: 'Vertex Shader'); Water.attributes.add ('fs', type: 'asset', assetType: 'shader', titel: 'Fragment Shader');
Nu kunnen we deze arceringsbestanden maken en deze aan ons script koppelen. Ga terug naar de editor en maak twee nieuwe shader-bestanden: Water.frag en Water.vert. Bevestig deze shaders aan uw script, zoals hieronder getoond.
Als de nieuwe attributen niet in de editor verschijnen, klik dan op ontleden om het script te verversen.
Plaats nu deze standaard shader Water.frag:
void main (void) vec4 color = vec4 (0.0,0.0,1.0,0.5); gl_FragColor = kleur;
En dit in Water.vert:
attribuut vec3 aPosition; uniforme mat4 matrix_model; uniforme mat4 matrix_viewProjection; void main (void) gl_Position = matrix_viewProjection * matrix_model * vec4 (aPosition, 1.0);
Ga tenslotte terug naar Water.js en gebruik het nieuwe aangepaste materiaal in plaats van het standaardmateriaal. Dus in plaats van:
var material = new pc.StandardMaterial ();
Do:
var material = this.CreateWaterMaterial ();
Als je nu het spel start, moet het vliegtuig nu blauw zijn.
Tot nu toe hebben we net wat dummy-shaders op ons nieuwe materiaal gezet. Voordat we de echte effecten gaan schrijven, is een laatste ding dat ik wil instellen automatische herladen van code.
De opmerkingen verwijderen ruil
functie in een willekeurig scriptbestand (zoals Water.js) maakt hot-reloading mogelijk. We zullen later zien hoe we dit kunnen gebruiken om de status te behouden, zelfs als we de code in realtime updaten. Maar voor nu willen we de shaders opnieuw toepassen zodra we een wijziging hebben gedetecteerd. Shaders worden gecompileerd voordat ze worden uitgevoerd in WebGL, dus we moeten het aangepaste materiaal opnieuw maken om dit te activeren.
We gaan controleren of de inhoud van onze shadercode is bijgewerkt en zo ja, het materiaal opnieuw maken. Sla eerst de huidige shaders op in de initialiseren:
// initialiseer code eenmaal per entiteit genaamd Water.prototype.initialize = function () this.GeneratePlaneMesh (); // Sla de huidige shaders op this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;
En in de bijwerken, controleer of er wijzigingen zijn geweest:
// update code genaamd elk frame Water.prototype.update = function (dt) if (this.savedFS! = this.fs.resource || this.savedVS! = this.vs.resource) // Re-create the materiaal zodat de shaders opnieuw kunnen worden gecompileerd var newMaterial = this.CreateWaterMaterial (); // Pas het toe op het model var model = this.entity.model.model; model.meshInstances [0] .material = newMaterial; // Sla de nieuwe shaders op this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;
Om dit te bevestigen, start je het spel en verander je de kleur van het vliegtuig Water.frag naar een meer smaakvol blauw. Zodra u het bestand opslaat, zou het moeten worden bijgewerkt zonder te vernieuwen of opnieuw te starten! Dit was de kleur die ik koos:
vec4 color = vec4 (0.0,0.7,1.0,0.5);
Om waves te maken, moeten we elke hoek in elk frame verplaatsen. Dit klinkt alsof het erg inefficiënt zal zijn, maar elke hoek van elk model wordt al getransformeerd op elk frame dat we renderen. Dit is wat de vertex-arcering doet.
Als u een fragmentshader ziet als een functie die op elke pixel wordt uitgevoerd, een positie neemt en een kleur retourneert, dan een hoekpuntshader is een functie die op elke hoekpunt wordt uitgevoerd, een positie inneemt en een positie retourneert.
De standaard vertex-arcering neemt de wereld positie van een bepaald model en retourneer de scherm positie. Onze 3D-scène wordt gedefinieerd in termen van x, y en z, maar je monitor is een vlak tweedimensionaal vlak, dus we projecteren onze 3D-wereld op ons 2D-scherm. Deze projectie is wat de weergave-, projectie- en modelmatrices voor hun rekening nemen en valt buiten het bestek van deze tutorial, maar als je precies wilt weten wat er bij deze stap gebeurt, is hier een heel mooie gids.
Dus deze regel:
gl_Position = matrix_viewProjection * matrix_model * vec4 (aPosition, 1.0);
neemt een positie
als de 3D-wereldpositie van een bepaalde vertex en transformeert het in gl_Position
, wat de uiteindelijke 2D-schermpositie is. Het voorvoegsel 'a' op aPosition betekent dat deze waarde een is attribuut. Onthoud dat a uniformvariabele is een waarde die we op de CPU kunnen definiëren om door te geven aan een arcering die dezelfde waarde behoudt voor alle pixels / hoekpunten. De waarde van een attribuut komt daarentegen van een rangschikking gedefinieerd op de CPU. De vertex-arcering wordt eenmaal aangeroepen voor elke waarde in die attribuutarray.
U ziet dat deze kenmerken zijn ingesteld in de definitie van de arcering die we hebben ingesteld in Water.js:
var shaderDefinition = attributes: aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0,, vshader: vertexShader, fshader: fragmentShader;
PlayCanvas zorgt voor het instellen en passeren van een reeks van vertex-posities voor een positie
wanneer we dit enum passeren, maar in het algemeen zou je elke array van data kunnen doorgeven aan de vertex shader.
Laten we zeggen dat je het vliegtuig wilt platdrukken door alles te vermenigvuldigen X
waarden met de helft. Moet je veranderen een positie
of gl_Position
?
Laten we proberen een positie
eerste. We kunnen een kenmerk niet rechtstreeks wijzigen, maar we kunnen een kopie maken:
attribuut vec3 aPosition; uniforme mat4 matrix_model; uniforme mat4 matrix_viewProjection; void main (void) vec3 pos = aPosition; pos.x * = 0,5; gl_Position = matrix_viewProjection * matrix_model * vec4 (pos, 1.0);
Het vliegtuig zou nu meer rechthoekig moeten lijken. Niets raars daar. Wat gebeurt er als we in plaats daarvan proberen te wijzigen? gl_Position
?
attribuut vec3 aPosition; uniforme mat4 matrix_model; uniforme 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;
Het ziet er misschien hetzelfde uit tot je de camera begint te draaien. We zijn de schermruimtecoördinaten aan het aanpassen, wat betekent dat het er anders uit zal zien afhankelijk van hoe je ernaar kijkt.
Dus zo kun je de hoekpunten verplaatsen, en het is belangrijk om dit onderscheid te maken tussen of je nu in de wereld bent of op het scherm.
Uitdaging # 2: kun je het hele vlak een paar eenheden naar boven verplaatsen (langs de Y-as) in de hoekshader zonder de vorm te vervormen?
Uitdaging # 3: ik zei gl_Position is 2D, maar gl_Position.z bestaat wel. Kun je wat tests uitvoeren om te bepalen of deze waarde van invloed is op wat dan ook, en zo ja, waarvoor het wordt gebruikt?
Een laatste ding dat we nodig hebben voordat we bewegende golven kunnen maken, is een uniforme variabele om als tijd te gebruiken. Verklaar een uniform in uw hoekpuntshader:
uniforme float uTime;
Ga vervolgens terug naar om deze door te geven aan onze arcering Water.js en definieer een tijdvariabele in de initialisatie:
Water.prototype.initialize = function () this.time = 0; ///// Definieer hier eerst de tijd this.GeneratePlaneMesh (); // Sla de huidige shaders op this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;
Om dit door te geven aan onze shader, gebruiken we dit material.setParameter
. Eerst stellen we een beginwaarde in aan het einde van de CreateWaterMaterial
functie:
// Maak de shader uit de definitie this.shader = new pc.Shader (gd, shaderDefinition); ////////////// Het nieuwe onderdeel material.setParameter ('uTime', this.time); this.material = materiaal; // Bewaar een verwijzing naar dit materiaal //////////////// // Pas shader toe op dit materiële materiaal. SetShader (this.shader); materiaal terugsturen;
Nu in de bijwerken
functie kunnen we de tijd verhogen en toegang krijgen tot het materiaal met behulp van de referentie die we ervoor hebben gemaakt:
this.time + = 0.1; this.material.setParameter (utime ', this.time);
Als laatste stap kopieert u in de functie swap de oude waarde van tijd, zodat zelfs als u de code wijzigt, deze blijft toenemen zonder opnieuw op 0 te hoeven instellen.
Water.prototype.swap = function (oud) this.time = old.time; ;
Nu is alles klaar. Start het spel om zeker te zijn dat er geen fouten zijn. Laten we nu ons vlak verplaatsen door een functie van de tijd in Water.vert
:
pos.y + = cos (uTime)
En je vliegtuig zou nu op en neer moeten gaan! Omdat we nu een swap-functie hebben, kunt u Water.js ook updaten zonder opnieuw te moeten starten. Probeer de tijdsincrementen sneller of langzamer te maken om te bevestigen dat dit werkt.
Uitdaging # 4: kunt u de hoekpunten verplaatsen zodat deze op de onderstaande golf lijkt?
Als een hint heb ik diepgaand gesproken over verschillende manieren om hier golven te creëren. Dat was in 2D, maar dezelfde wiskunde is hier van toepassing. Als je liever gewoon naar de oplossing kijkt, hier is de kern.
Het doel van deze sectie is om het wateroppervlak doorschijnend te maken.
Het is je misschien opgevallen dat de kleur die we in Water.frag retourneren, een alpha-waarde van 0,5 heeft, maar het oppervlak is nog steeds volledig ondoorzichtig. Transparantie is in veel opzichten nog steeds een open probleem in computergraphics. Een goedkope manier om dit te bereiken is om blending te gebruiken.
Normaal gesproken, als een pixel op het punt staat te worden getekend, controleert deze de waarde in de dieptebuffer tegen zijn eigen dieptewaarde (zijn positie langs de Z-as) om te bepalen of de huidige pixel op het scherm moet worden overschreven of zichzelf moet verwijderen. Dit is wat u in staat stelt om een scène correct weer te geven zonder objecten achterstevoren te sorteren.
Met blending kunnen we in plaats van alleen weggooien of overschrijven de kleur van de pixel die al is getekend (de bestemming) combineren met de pixel die op het punt staat te worden getekend (de bron). U kunt hier alle beschikbare overvloeifuncties in WebGL zien.
Om de alpha te laten werken zoals we het verwachten, willen we dat de gecombineerde kleur van het resultaat de bron is vermenigvuldigd met de alpha plus de bestemming vermenigvuldigd met één minus de alpha. Met andere woorden, als de alpha 0,4 is, zou de uiteindelijke kleur moeten zijn:
finalColor = bron * 0,4 + bestemming * 0,6;
In PlayCanvas doet de optie pc.BLEND_NORMAL precies dit.
Om dit mogelijk te maken, hoeft u alleen maar de eigenschap op het materiaal in te stellen CreateWaterMaterial
:
material.blendType = pc.BLEND_NORMAL;
Als je het spel nu start, zal het water doorschijnend zijn! Dit is echter niet perfect. Er ontstaat een probleem als het doorschijnende oppervlak overlapt met zichzelf, zoals hieronder wordt weergegeven.
We kunnen dit oplossen door te gebruiken alpha naar dekking, wat een multi-sampling techniek is om transparantie te bereikenin plaats van mengen:
//material.blendType = pc.BLEND_NORMAL; material.alphaToCoverage = true;
Maar dit is alleen beschikbaar in WebGL 2. Voor de rest van deze tutorial zal ik het mengen gebruiken om het eenvoudig te houden.
Tot nu toe hebben we onze omgeving opgezet en ons doorschijnende wateroppervlak gecreëerd met geanimeerde golven van onze vertex-arcering. Het tweede deel behandelt het drijfvermogen op objecten, het toevoegen van waterlijnen aan het oppervlak en het creëren van de schuimlijnen rond de randen van objecten die het oppervlak kruisen.
Het laatste deel zal betrekking hebben op het toepassen van het onderwater-post-proces distortion-effect en enkele ideeën voor de volgende stap.
Je kunt het voltooide gehoste PlayCanvas-project hier vinden. Een Three.js-poort is ook beschikbaar in deze repository.