Vertex vs Fragment Shaders in de Grafische Pipeline


Shaders zijn kleine programma’s die op de GPU draaien als onderdeel van het tekenen van een scène. Sommige bepalen waar geometrie op het scherm verschijnt, terwijl andere bepalen hoe die geometrie eruitziet zodra ze getekend is. Het eerste nuttige onderscheid in dat proces is dat tussen vertex shaders en fragment shaders. Een vertex shader draait één keer voor elke vertex in je mesh, terwijl een fragment shader draait voor de fragmenten die ontstaan binnen de driehoeksdekking op het scherm, ongeveer één keer per gedekte pixelsample. Die twee fases doen ander werk en draaien op heel verschillende frequenties, en dat verklaart veel van het gedrag, de kosten en de visuele uitvoer die je in realtime rendering ziet.

Dit artikel bouwt een concreet model op van waar elke fase draait, welke data die leest en welke data die produceert. Het begint met de scheiding tussen vertex en fragment, omdat dat de basis vormt voor de rest van de grafische pipeline. Pas wanneer die basis duidelijk is, plaatsen we geometry-, tessellation- en compute-shaders in context.

Pipelinepositie en Datastroom

De meeste realtime 3D-engines volgen een rasterization-pipeline, wat betekent dat ze starten met driehoeksdata en die omzetten naar het 2D-beeld dat je op het scherm ziet. In dat proces komen vertices eerst binnen, worden daarna primitieven zoals driehoeken, zet rasterization die driehoeken om in fragmenten, en worden de overblijvende resultaten uiteindelijk naar de framebuffer geschreven, de beeldbuffer die het huidige frame bevat voordat het getoond wordt. De belangrijkste vraag is dus: wat krijgt elke fase als invoer?

  • Invoer van vertex shader: één vertex per keer (positie, normaal, UV, tangenten, custom attributen)
  • Uitvoer van vertex shader: getransformeerde clip-space-positie plus per-vertexwaarden die vloeiend over de driehoek geïnterpoleerd worden, zoals kleur, normalen of UV’s
  • Invoer van fragment shader: die geïnterpoleerde waarden per fragment plus textures, uniforms en materiaalparameters
  • Uitvoer van fragment shader: één of meer kleur-/dieptewaarden geschreven naar render targets

Sleep de driehoekshoeken en wissel tussen de verschillende fases. De hoofdvergelijking is de belangrijkste: één primitief triggert maar een paar vertex-shader-aanroepen, maar kan uitgroeien tot veel fragment-shader-aanroepen zodra het schermruimte bedekt. De weergave met optionele fases laat daarna zien waar tessellation, geometry en compute zich verhouden tot dat hoofdpad.

In de weergave met optionele fases moet je tessellation en geometry allebei zien als extra verwerking die vóór rasterization gebeurt, niet als iets tussen rasterization en de fragment shader in. Compute shaders zijn opnieuw iets apart: het zijn GPU-programma’s, maar ze zitten helemaal niet op dit pad van driehoek naar fragment.

Een praktische manier om dit te onthouden is bijhouden waar het aantal explosief groeit. Een mesh kan tienduizenden vertices hebben, maar een full-screen draw kan miljoenen fragmenten raken. Omdat het aantal fragmenten vaak veel groter is, kost dure wiskunde in fragment shaders meestal meer frametijd dan dezelfde wiskunde in vertex shaders.

Verantwoordelijkheden van Vertex Shaders

Een vertex shader is meestal verantwoordelijk voor geometrische plaatsing. De meest voorkomende taak is elke vertexpositie vermenigvuldigen met model-, view- en projectiematrices. Hij kan ook waarden voorbereiden voor latere fases, zoals world-space-normalen, texturecoördinaten of effectspecifieke data zoals een maskwaarde, een blendfactor of een richtingsvector die de fragment shader later gebruikt.

Wiskundig ziet de canonieke transformatie er vaak zo uit:

clipPos=PVM[x,y,z,1]T\text{clipPos} = P\,V\,M\,[x,y,z,1]^T

Hier is [x,y,z,1]^T de oorspronkelijke vertexpositie geschreven in homogene coördinaten. M is de modelmatrix, die het object in de wereld plaatst. V is de viewmatrix, die de scène uit het perspectief van de camera uitdrukt. P is de projectiematrix, die die camera-space-positie omzet naar clip space zodat de GPU verder kan richting schermruimte-rendering.

Het belangrijke detail is niet de formule zelf, maar de uitvoerfrequentie. Als je model 20.000 vertices heeft, draait deze shader ongeveer 20.000 keer voor die draw call. Hij draait niet voor elke pixel op het scherm.

In de playground hieronder is de grijze driehoek de inkomende vertexdata en de blauwe driehoek de getransformeerde uitvoer na een vereenvoudigde transformatieketen. Verander rotatie, schaal en translatie en kijk hoe de uitvoer-vertexposities bewegen in normalized device space.

clipPos=PVM[x,y,z,1]T\text{clipPos} = P\,V\,M\,[x,y,z,1]^T | rot=18deg scale=1.10 tx=0.22 ty=0.08

Een praktisch gevolg van deze fasescheiding is dat vertex shaders goed zijn in het vormen en voorbereiden van geometrie, terwijl fragment shaders beter zijn voor fijn beelddetail binnen elke driehoek. Als je pixelachtig uiterlijkwerk naar de vertexfase duwt, krijg je meestal blokkerige of instabiele resultaten, omdat outputs van vertex shaders alleen bekend zijn op de driehoekshoeken en daarna over het oppervlak geïnterpoleerd worden. Interpolatie is nuttig, maar niet gelijk aan echte per-fragment-berekening.

Verantwoordelijkheden van Fragment Shaders

Nadat primitieven zijn gerasteriseerd, genereert de GPU fragmenten. Elk fragment heeft geïnterpoleerde varyings uit de vertexfase. Nu bepaalt de fragment shader het zichtbare oppervlak: basiskleur, textuurdetail, lichtrespons, transparantielogica en soms of een fragment moet worden weggegooid.

Deze fase is waar materialen beelddetail worden. Als je een textuur samplet, normal maps combineert, BRDF-termen berekent, mist toepast en lagen blendt, gebeurt dat werk meestal hier.

In het interactieve voorbeeld ontvangt elk fragment binnen een driehoek geïnterpoleerde kleur- en UV-data. Vervolgens mengt een shaderachtig proces textuur en vertexkleur, past een eenvoudige Lambert-belichtingsterm toe en kan fragmenten onder een drempel weglaten (vergelijkbaar met alpha-cutoutlogica).

Cout=mix(Ctex,Cvertex,α)max(0,NL)C_{out} = \text{mix}(C_{tex}, C_{vertex},\,\alpha) \cdot \max(0, N\cdot L) | alpha=0.55 discard=0.20

Let op wat vloeiend verandert over de driehoek: niet direct de oorspronkelijke vertexwaarden, maar geïnterpoleerde waarden. Die interpolatiestap is een van de belangrijkste redenen waarom vertex- en fragmentfases samen voorkomen. De vertexfase bereidt datapunten aan de uiteinden voor, en de fragmentfase gebruikt continue waarden daartussen om het uiteindelijke uiterlijk te berekenen.

Directe Vergelijking: Vertex vs Fragment

De snelste manier om deze fases te vergelijken is dezelfde vier vragen voor beide te stellen.

  1. Hoe vaak draait het? Vertex shader: één keer per vertex. Fragment shader: één keer per fragment.

  2. Wat is het hoofddoel? Vertex shader: geometrische transformatie en voorbereiding van varyings. Fragment shader: uiteindelijke shading en uitvoer van kleur/diepte.

  3. Welke data domineert de invoer? Vertex shader: mesh-attributen plus transformatie-uniforms. Fragment shader: geïnterpoleerde varyings, textures, lichtbronnen en materiaal-uniforms.

  4. Welk prestatiepatroon is typisch? Vertex shader: schaalt met geometriecomplexiteit. Fragment shader: schaalt met schermdekking en overdraw.

Deze verschillen impliceren praktische optimalisatieregels. Als een effect met per-vertexwiskunde en interpolatie kan worden benaderd, kan het goedkoper zijn. Als nauwkeurigheid pixelprecies moet zijn (speculaire respons, normal mapping, fijn procedureel detail), hoort het in de fragmentfase, ook als de kosten stijgen.

Veelgemaakte Fouten bij Fasekeuze

Een veelvoorkomende fout is te veel logica in fragmenten stoppen zonder naar dekking te kijken. Een full-screen post-effect op 4K kan vele miljoenen shader-aanroepen per frame uitvoeren. Een andere fout is uiterlijklogica te vroeg naar vertices verplaatsen en dan niet begrijpen waarom detail op grote driehoeken instort.

Een eenvoudig beslisproces helpt:

  1. Definieert deze berekening objectplaatsing? Zet het in vertex.
  2. Definieert deze berekening pixeluiterlijk? Zet het in fragment.
  3. Heeft de berekening info van naburige pixels uit al gerenderde data nodig? Dan is het vaak een latere post-process-pass, mogelijk met compute.

Dit proces is niet perfect, maar voorkomt de meeste architectuurfouten in realtime-renderingcode, vooral als je het vergelijkt met technieken zoals ray marching met signed distance fields, die volledig buiten het standaardpad van driehoeksrasterization vallen.

Andere Shadertypes in Context

Geometry Shaders

Geometry shaders draaien per primitief na de vertexfase. Ze kunnen nieuwe primitieven genereren en zijn daardoor bruikbaar voor specifieke effecten zoals layered shadow map-uitvoer of lijnuitbreiding. Toch worden ze vaak vermeden in prestatiekritische paden, omdat ze een throughput-bottleneck kunnen vormen. Veel moderne engines kiezen liever alternatieven zoals instancing, mesh shaders (op ondersteunde API’s) of compute-gedreven generatie.

Tessellation Shaders

Tessellation is opgesplitst in control- en evaluation-fases. Het onderverdeelt patch-primitieven om geometrische dichtheid op de GPU toe te voegen. Dat kan gekromde oppervlakken en displacement mapping verbeteren wanneer detail in schermruimte dat vereist. De afweging is meer complexiteit en hardware-/API-beperkingen, dus veel teams gebruiken dit selectief.

Compute Shaders

Compute shaders zijn niet gekoppeld aan rasterization. Ze voeren algemene GPU-kernels uit over threadgroepen en worden breed gebruikt voor simulatie, culling, particle-updates, voorbereiding van clustered lighting, denoising en post-processing. In moderne renderers werkt compute vaak samen met traditionele graphics-passes in plaats van ze te vervangen, en zulke passes gebruiken vaak procedurele input uit ruisfuncties zoals value noise, Perlin noise en fractale ruis.

Een nuttige mentale kaart is:

  • Vertex + Fragment: kernpad van rastergraphics
  • Geometry + Tessellation: optionele fases voor geometrievergroting/-verfijning
  • Compute: algemeen parallel verwerkingspad dat renderingdata kan leveren of consumeren

Intuïtie Opbouwen voor Echte Projecten

Wanneer je renderproblemen debugt, bepaal dan de fasegrens waar foutieve data voor het eerst verschijnt. Als getransformeerde posities al fout zijn vóór rasterization, controleer vertexlogica. Als geometrie correct is maar kleur of belichting fout is, controleer fragmentlogica. Als topologie of onderverdeling fout is, controleer geometry-/tessellationfases. Als preprocess-buffers fout zijn, controleer compute-kernels.

Je kunt ook op fase-intentie profileren. Hoge vertexkosten volgen vaak uit dichte meshes of zware skinning. Hoge fragmentkosten volgen vaak uit grote schermdekking, dure materiaalwiskunde of overdraw door transparante lagen. Die scheiding geeft direct richting voor optimalisatie-experimenten.

Samenvatting

Vertex- en fragment-shaders verschillen omdat ze op verschillende werkeenheden draaien. Vertex shaders verwerken meshpunten en bereiden geïnterpoleerde data voor. Fragment shaders verwerken gerasteriseerde fragmenten en berekenen het uiteindelijke uiterlijk.

Als je dat uitvoeringsmodel vasthoudt, worden de meeste pipelinebeslissingen duidelijker:

  • Plaatsruimte-wiskunde en voorbereiding van varyings in vertex shaders.
  • Pixelnauwkeurige materiaal- en belichtingslogica in fragment shaders.
  • Gebruik geometry en tessellation alleen wanneer hun specifieke mogelijkheden nodig zijn.
  • Gebruik compute voor algemene GPU-taken buiten de strikte rasterflow.

Dat model schaalt van eenvoudige demo’s naar production renderers en maakt shadercode makkelijker te begrijpen, te optimaliseren en te debuggen.