Vertex- en Fragment-Shaders Visualiseren in de Grafische Pipeline

EN NL


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.

Shaderfases in de grafische pipeline

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 twee belangrijkste programmeerbare shaderfases in dit pad zijn de vertex shader vóór rasterization en de fragment shader na rasterization. Optionele shaderfases kunnen data rond dat pad toevoegen, verfijnen of berekenen, maar de overdracht van vertex naar fragment is de basis voor gewone driehoek-rendering. 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

Naarmate de driehoek een groter deel van het rechterpaneel vult, wordt het verschil tussen vertexwerk en fragmentwerk veel duidelijker. De driehoek heeft nog steeds maar drie vertices, maar de gerasteriseerde dekking raakt veel meer schermcellen, waardoor fragmentwerk veel sneller groeit dan vertexwerk. Wanneer je naar Optional stages overschakelt, zie je waar tessellation, geometry en compute zich verhouden tot dat hoofdpad van vertex naar fragment.

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.

Zelfs wanneer de driehoek klein blijft, laat wisselen tussen Vertex work en Fragment work zien dat de volgorde van de fases niet verandert met de werkbelasting. Zodra de driehoek meer van het schermruimte-paneel vult, groeit de fragmentkant zichtbaar in dichtheid terwijl het aantal hoekvertices onveranderd blijft. De weergave Optional stages maakt vervolgens de structurele scheiding duidelijk: tessellation en geometry voeden nog steeds rasterization, terwijl compute ernaast blijft staan als een eigen uitvoeringspad.

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.

Wat is een vertex shader?

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. Direct gezegd is een vertex shader een GPU-programma dat één keer draait voor elke invoervertex in een draw call. Hij kan niet elke gedekte pixel shaden, omdat rasterization nog niet heeft plaatsgevonden. Zijn belangrijkste uitvoer is een clip-space-positie, plus per-vertexwaarden die over het primitief geïnterpoleerd moeten worden voor later fragmentwerk.

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. Door de grijze hoeken te verslepen hervorm je de oorspronkelijke mesh, terwijl het verslepen van het blauwe resultaat, de rotatiehandle of de schaalhandle de getransformeerde posities verandert die de vertexfase aan rasterization zou doorgeven. Het belangrijkste om op te letten is dat al die beweging nog steeds alleen gebeurt door de driehoekshoeken in normalized device space te verplaatsen, zonder nieuw detail in het binnenvlak te maken.

Wanneer de brondriehoek links van vorm verandert, houdt de uitvoer rechts dezelfde per-vertex-transformatie, wat helpt om meshstructuur los te zien van latere plaatsingswerkzaamheden. Rotatie of translatie van de blauwe driehoek laat zien dat de getransformeerde vertices als groep worden verplaatst, en meer schaal laat de driehoek meer schermruimte innemen zonder extra geometrische hoeken te maken. Die grotere schermvoetafdruk hint ook meteen waarom latere fragmentkosten kunnen stijgen terwijl deze fase nog steeds maar drie vertices verwerkt.

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.

Wat is een fragment shader?

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. Direct gezegd is een fragment shader een GPU-programma dat draait voor elk gegenereerd fragment, grofweg elke gedekte pixelsample voordat depth, stencil, blending en render-target-writes het frame afronden. Hij krijgt geïnterpoleerde data binnen in plaats van ruwe meshvertices. Daardoor is dit de juiste plek voor materiaalbeslissingen op pixelniveau.

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.

Het eerste dat hier concreet moet worden, is dat een fragment shader niet drie losse hoekwaarden ontvangt om er vervolgens één te kiezen. Hij krijgt waarden die tijdens rasterization over de driehoek zijn gemengd. Wanneer de probe door de visualisatie beweegt, verandert de geïnspecteerde fragmentkleur vloeiend omdat de GPU de drie vertexuitvoeren met barycentrische gewichten mengt voordat fragment shading begint.

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.

Normalen volgen dezelfde regel. Ze worden vaak één keer per vertex geschreven, daarna over het primitief gemengd, en pas vervolgens genormaliseerd en voor belichting gebruikt. De volgende explorer maakt die overdracht zichtbaar: het veranderen van de vertexnormaal-richtingen hervormt het normaalveld over de driehoek, terwijl het bewegen van de probe het verschil laat zien tussen de ruw gemengde normaal en de genormaliseerde richting die daadwerkelijk bruikbaar is voor shading.

Dit detail is belangrijk omdat veel belichtingsmodellen afhankelijk zijn van die geïnterpoleerde normaal in plaats van van één platte richting voor de hele driehoek. Als de per-fragmentnormaal vloeiend verandert, kunnen diffuse en speculaire respons dat ook doen. Als de invoer te grof is of de belichting te vroeg wordt geëvalueerd, gaat detail verloren dat eigenlijk tussen de vertices zichtbaar zou moeten zijn.

Die afweging wordt duidelijker in de belichtingsvergelijking hieronder. Bij een grove mesh kan de per-vertexversie de highlight alleen op een paar hoeken samplen en het resultaat over de binnenkant interpoleren, waardoor het felle gebied uitgesmeerd of verkeerd geplaatst kan ogen. Terwijl de lichtbron beweegt, blijft de per-fragmentversie trouwer omdat de belichting voor elk bedekt fragment wordt geëvalueerd in plaats van uit een paar vertexsamples te worden benaderd. Meer meshdichtheid verkleint dat verschil, en dat is precies waarom per-vertexbelichting voor sommige oppervlakken acceptabel kan zijn en voor andere duidelijk tekortschiet.

Mesh density

Vertex shader vs fragment shader

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, zoals bij speculaire respons, normal mapping of 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, en het Khronos-overzicht van de rendering pipeline is een bruikbare formele referentie als je de API-volgorde achter die samenvatting wilt zien.

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. Voor de onderliggende curve-intuïtie achter handle-gestuurde ruimtelijke paden is het visualiseren van 3D Bezier-curves en de De Casteljau-constructie een nuttige aanvulling voordat je van curves naar patches gaat. 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. Voor een meer beginnergerichte uitleg van shader-invoer, uitvoer en fasegebonden uitvoering blijft LearnOpenGL’s introductie tot shaders een nuttige aanvulling.

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, en als je een diepere uitleg wilt van de interpolatiestap tussen de programmeerbare fases, is Scratchapixels les over rasterization de meest relevante vervolgstap.

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.