Modul 1.3: WebGL-primitiver.

Nøkkelord: Graphics pipeline, view frustum, primitiver, interpolering, backface culling, indeksbuffer.

Tegne modeller bestående av flere vertekser

En av de enkleste modeller vi kan fremstille er en enkel trekant bestående av tre vertekser. Verteksene består (minst) av en [x,y,z]-posisjon. Etter hvert skal vi se at man også kan knytte farge, normalvektor (for lysberegning) og/eller en teksturkoordinat til verteksene.

En enkel trekant

Posisjonene angis i forhold til et tenkt lokalt koordinatsystem. Modellen plasseres deretter i et tenkt globalt koordinatsystem ved hjelp av en transformasjon. Transformasjonen utføres ved å multiplisere verteksene med en modellmatrise. Se (Luna, 2006) og (Angel E. & David S., 2015, s. 159). 

Grunnen til at man spesifiserer modellen i et lokalt koordinatsystem er at det er enklere å spesifisere verteksene til en modell i forhold til et lokalt koordinatsystem (for eksempel sentrert om origo) for deretter flytte, rotere eller skalere modellen vha. en modelltransformasjon. Dette betyr at man kan spesifisere hver enkelt modell uavhengig av hverandre for deretter å tilpasse og plassere disse inn i det globale koordinatsystemet. 

Videre vil man, vha. et tenkt (/virtuelt), kamera bestemme hva som skal projiseres på skjermen. Kameraet plasseres i det globale koordinatsystemet og orienteres mot objektet slik at det vises på skjermen (canvaset). 

I tillegg angis en projeksjon som avgrenser det kameraet ser vha. et såkalt frustum. Alt som faller innenfor dette vil bli med på det som vises på skjermen mens figurer som faller utenfor ikke kommer med.

View frustum

Dette betyr at to andre matriser må opprettes nemlig en synsmatrise (view matrix) og en projeksjonsmatrise (projection matrix). Verteksene til trekanten må egentlig multipliseres med en kombinert modell, view og projeksjons- matrise. Når dette er på plass vil trekanten vises på skjermen i forhold kameraets posisjon og angitte projeksjon.

Disse tre matrisene er avgjørende for å kunne generere 3D grafikk vha. WebGL.

Komplett pipeline

Figuren under viser en mer komplett «graphics pipeline». Her er også teksturminne og et ekstra steg etter fragmentshaderen (dette består igjen av flere steg) tatt med. 
Basert på figur fra (Munshi, A. et al, 2009)
Vertekser sendes vha. VertexBuffer, en og en, til verteksshaderen. Normalt vil verteksshaderen utføre diverse transformasjoner, dvs. matrisemultiplikasjoner, på verteksene før de sendes videre i pipelinen ved at gl_Position settes lik transformert verteks.

I neste steg sammenstilles verteksene slik at rasterisering kan utføres. Dette betyr at systemet beregner hvilke piksler som må til for, f.eks., å få tegnet og fylt en trekant.

Pikslene kalles nå for «fragmenter» siden det, som vi skal se, ikke er gitt at alle ender opp i framebufret. For hvert fragment kjøres nå fragmentshaderen. Fragmentet, som enten har fått farge via et parameter (som vist tidligere) eller har fått en farge basert på verteksfarger (som vi skal se etter hvert), kan manipulere og endre fargen men til slutt må fragmentshaderen sette gl_FragColor lik en fargeverdi.

Både gl_Position og gl_FragColor er spesielle GLSL parametre som må settes i henholdsvis verteks- og fragmentshaderen.

I neste steg (fragement/pikseloperasjoner) utføres bl.a. dybdetest. Denne testen kan resultere i at et fragment forkastes fordi det i følge dybdeparametret (z) vil bli liggende bak et annet fragment. Et fragment er altså en kandidat til å ende opp som en piksel i framebufret.

WebGL Primitiver

3D grafikk utformes vha. grunnleggende former som punkter, linjer og trekanter og kalles gjerne primitive objekter eller bare primitiver. Selv store komplekse 3D modeller er oppbygd vha. slike primitiver. Verteksene som utgjør modellen kan for eksempel tegnes som punkter, knyttes sammen som linjer, et sett med selvstendige trekanter eller som en sekvens av trekanter.

WebGL (og OpenGL ES) støtter følgende primitiver:
  • gl.POINTS, tegne et punkt per verteks 
  • gl.LINE, danne usammenhengende linjer. v0 og v1 gir en linje, v2 og v3 en annen linje osv. 
  • gl.LINESTRIP, sammenhengende linjer. v0 og v1 danner en linje, v2 koples til v1, v3 til v2 osv. 
  • gl.LINE_LOOP, som LINESTRIP men i tillegg tegnes det en linje mellom siste og første verteks (vn->v0) 
  • gl.TRIANGLES, tegner separate trekanter. En trekant for hver 3. verteks. 
  • gl.TRIANGLE_STRIP, tegner en serie sammenhengende trekanter. De tre første verteksene danner en trekant. Hver nye verteks fører til en ny trekant. 
  • gl.TRIANGLE_FAN, tegner en «vifte». De tre første verteksene danner en trekant. Hver nye verteks lager en ny trekant som koples til den første verteksen. 
Disse er illustrert under:

WebGL primitiver. Fra (Matsuda, K. et al, 2013). 
Første parameter til gl.drawArrays() bestemmer hvilken type primitiv som brukes. Vi har sett på bruk av POINTS og TRIANGLES.

Punkter

Vha. gl:POINTS kan man tegne punkter av en gitt størrelse. Hver verteks utgjør et punkt og størrelsen kan bestemmes vha. et shaderparameter, gl_PointSize.

Linjer

Verteksene danner usammenhengende linjer. V0 og v1 gir en linje, v2 og v3 en annen linje osv.

Bruker man gl.LINE_STRIP vil de to første verteksene danne en linje, verteks nr 3 koples til verteks nr2 og danner dermed en ny linje. Verteks 4 koples til v3 osv.

Bruk av gl.LINE_LOOP tilsvarer gl.LINESTRIP men i tillegg tegnes det en linje mellom siste og første verteks (vn->v0)

Trekanter

Trekanter kan tegnes på flere måter:

TRIANGLES:
Verteksene kan tegnes som uavhengige trekanter, dvs. hver trekant er spesifisert vha. tre vertekser. Her er det verdt å merke seg at rekkefølgen man oppgir verteksene i har betydning. Disse kan enten oppgis med klokka (CW = ClockWise) eller mot klokka (CCW = CounterClockWise). Mer om dette etter hvert.

TRIANGLE_STRIP:
Dersom gl.TRIANGLE_STRIP brukes vil de tre første verteksene danne en trekant mens hver nye verteks vil danne en ny trekant sammen med to av verteksene fra den forrige trekanten.

Triangle Strip
I en slik triangle strip er det viktig at alle trekanter som utgjør «stripen» har trekanter bestående av vertekser angitt i samme rekkefølge, dvs. enten mot klokka (CCW) eller med klokka. Rekkefølgen man angir verteksene til den første trekanten bestemmer hvordan rekkefølgen til verteksene til alle de andre trekantene oppfattes.

Dersom vi ser på figuren over er verteksene til første trekant angitt i rekkefølgen: v0, v1, v2. Den andre trekanten er da definert av v2, v1, v3, den tredje trekanten er definert av v2, v3, v4 mens den siste er definert av v4, v3, v5.

TRIANGLE_FAN:
Dette er et alternativ til triangel strip. De tre første verteksene utgjør den første trekanten mens hver nye verteks «koples» til den første verteksen i den første trekanten slik at det oppstår en slags vifte-formasjon.

Triangle Fan
I figuren ser vi at v0,v1 og v2 danner første trekant. På samme måte som triangle strips vil må verteksene til påfølgende trekanter være definert i samme rekkefølge (CW eller CCW, her CCW). Den andre trekanten er derfor definert av v0, v2,v3, den tredje av v0, v3, v4 osv.

Backface culling

Ved hjelp av backface culling kan vi få WebGL til å automatisk fjerne mange av trekantene som uansett ikke vil vises i det endelige bildet. Som regel ser man ikke alle sidene til en 3D modell – vi ser kun det som kameraet peker mot. Trekantene som utgjør baksiden kan derfor ofte fjernes.

Dersom culling skal fungere må WebGL vite hva som er frem og bak på trekantene og hvilke som er vendt mot kameraet.

En trekant har to sider – en forside og en bakside. Vi kaller et polygon (her trekant) som har forsiden mot kamera et fremovervendt polygon (front facing polygon) mens et polygon som har forsiden fra kamera kalles et bakovervendt polygon (back facing polygon). Rekkefølgen verteksene er angitt i, dvs. CW eller CCW, er med på å bestemme hva som er forsiden og hva som er baksiden på trekanten.

Dette bestemmes av følgende metodekall:

gl.frontFace(gl.CCW);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);

Det første kallet indikerer at trekanter med vertekser angitt mot klokka er «front-facing». Dette er også standard. Det andre kallet gjør at culling tas i bruk. Som standard er culling ikke i bruk og må evt. aktiveres vha. gl.enable(gl.CULL_FACE). Det tredje kalles indikerer at det er «back facing» trekanter som skal culles (/fjernes, ikke tegnes).

Front & back facing polygon
Figuren illustrerer dette. Til venstre: Når kameraet peker mot trekanten der verteksene er definert mot klokka vil det se mot forsiden av trekanten og den vil tegnes på skjermen. I høyre del av figuren er kameraet flyttet til baksiden og trekanten vil dermed ikke vises på skjermen (den culles).

Dette er en enkel måte å optimalisere på – jo færre vertekser som videresendes i pipelinen jo mer effektivt program. Der er imidlertid ikke alltid at man kan bruke culling.

Dersom man tegner en kube og tillater at man skal kunne bevege seg inne i kuben (dvs. flytte kameraet inn i kuben) vil veggene sett fra innsiden forsvinne/culles.

Dersom man bruker delvis gjennomsiktige objekter/modeller kan man ikke bruke culling. I tilfeller der man bruker ugjennomsiktige modeller, som det ikke skal være mulig å «gå inn i» vil culling kunne brukes.

Figuren under viser et ugjennomsiktig 3D objekt fra oversiden. Her vil bakovervendte polygoner/trekanter kunne fjernes siden de uansett ikke skal vises på skjermen.


Dette betyr at alle trekantene som måtte inngå i baksiden av figuren ikke behandles videre (rasterisering m.m.) og kan dermed fjernes.

Kameraet vil fortsatt se det samme siden forsiden (front facing polygons) uansett dekker over polygonene på baksiden (back facing polygons).

Man kan i mange tilfeller anta at omtrent halvparten av polygonene i en scene vender fra kameraet som betyr at man kan forkaste omtrent halvparten av trekantene – dette har stor betydning i forhold til ytelsen.

Knytte farge til verteksene

Hittil har alle pikslene fått samme farge. I dette avsnittet skal vi se hvordan man kan knytte farge til hver verteks. Vi vil se at dersom vi knytter ulike farger til en trekants vertekser vil fargene interpoleres («utjevnes») mellom verteksene. Dersom vi angir tre vertekser med fargene rød, grønn og blå vil resultatet bli som følger:
Ulike farger angitt i hver av de tre verteksene
For å få til dette må det knyttes fargeverdier til verteksene i tillegg til at shaderne må utvides med et ekstra varying-parameter (mer kommer).

Verteksfarge knyttes til verteksene ved at verteksbufret utvides til også å inneholde fargeinformasjon i form av 4 ekstra float-verdier. Disse vil da inneholde en rød, grønn, blå og alpha-verdi (RGBA) – hver i området 0 til 1.

Verteksshaderen tar i så fall, for hver verteks, imot både et fargeparameter (a_Color) og posisjonsparameter (a_Position).

Fargeparametret kan videresendes til fragmentshaderen vha. et varying-parameter, f.eks. slik:

attribute vec4 a_Position;   // Innkommende verteksposisjon.
attribute vec4 a_Color;      // Innkommende verteksfarge.
varying vec4 v_Color;        // NB! Bruker varying.
void main() {
 gl_Position = a_Position;   // Posisjon.
 v_Color = a_Color;          // Setter varying = innkommende verteksfarge.
};

Tilsvarende varying-parameter må defineres i fragmentshaderen, samme navn og type, slik:

precision mediump float;
varying vec4 v_Color;        // NB! Interpolert fargeverdi.
void main() {
 gl_FragColor = v_Color;    // gl_FragColor = Interpolert fargeverdi.
};

Vi ser at samme varying-parameter er deklarert både i verteks- og i fragmentshaderen. I steget mellom fragment- og verteksshaderen utføres rasterisering, dvs. det beregnes hvilke piksler som skal inngå i trekanten. Siden v_Color er av type varying vil verdien interpoleres slik at hver piksel får forskjellige farger. Verdien til v_Color i fragmentshaderen er dermed ikke den samme som v_Color i verteksshaderen.

Mer om interpolering

Anta at vi, i stedet for en trekant, tegner en linje (gl.LINES) spesifisert av to vertekser som har fargene rød (1.0, 0.0, 0.0) og blå (0.0, 0.0, 1.0). Vi ser bort fra alfaverdien her.

Etter at fargeverdiene til linjas vertekser er tilordnet v_Color i fragmentshaderen vil RGB-verdiene for hvert fragment som utgjør linja beregnes og videresendes til fragmentshaderens v_Color parameter.

Dette kan illustreres slik:
Interpoleringsprosessen
Her ser man hvordan fragmentene mellom to vertekser, som definerer en linje, får ulike farger basert på verteksenes farger (rød og blå). Denne prosessen kalles «interpolering» og skjer automatisk i rasteriseringssteget.

Fargene knyttet til verteksene kan enten legges i samme buffer som posisjonsverdiene (x, y og z) eller de kan legges i et eget buffer. Bruk at et buffer vil normalt være mer effektivt. Legg også merke til bruk av «stride»-parametret i vertexAttribPointer(…). Dette angir antall bytes som hver verteks opptar dvs. antall bytes som posisjon og farge opptar til sammen.

Mer om verteks og indeksbuffer

WebGL har tre metoder som kan brukes til å utføre «tegning», disse er:

gl.drawArrays()
gl.drawElements()

I tillegg bruker man gl.clear() for å renske skjermen før man tegner vha. en eller begge de nevnte metodene.

I forhold til ytelse bør man generelt forsøke å gjøre færrest mulig kall til drawArrays() eller drawElements(). Det er mer effektivt å gjøre et kall for å tegne 200 trekanter i stedet for å ha 100 kall som hver tegner 2 trekanter.

Metoden drawArrays() bruker verteksbufret direkte mens drawElements() bruker et indeksbuffer i tillegg til et verteksbuffer for å unngå dobbeltlagring av verteksdata siden flere trekanter i en figur kan dele samme verteks.

Bruk av gl.drawArrays()

Prototypen til metoden ser slik ut:
  • void drawArrays(GLenum mode, GLint first, GLsizei count);
Metoden tegner primitivene definert av bufret som er bundet vha. gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer) der vertexBuffer er opprettett vha. gl.createBuffer(). Første argument til drawArrays() spesifiserer hvilken type primitiv som skal tegnes. Dette kan f.eks. være gl.TRIANGLES. Den bruker da verteksdataene som er gjort tilgjengelig.

Det andre argumentet i drawArrays(), first, indikerer hvilken indeks i verteksarrayet som skal brukes som første indeks (og verteks). Det siste argumentet, count, spesifiserer antall vertekser som skal brukes.

Oppsummert må man før kall på drawArrays() gjøre følgende:
  • Opprett et verteksbuffer vha. gl.createBuffer(). Dette gir et objekt av type WebGLBuffer. 
  • Binde bufret til gl.ARRAY_BUFFER vha. gl.bindBuffer(gl.ARRAY_BUFFER, buffer). 
  • Last verteksdata inn i bufret vha. gl.bufferData(). 
  • «Enable» verteksarrayet vha. gl.enableVertexAttribArray() 
  • Kople shaderparametre, f.eks. a_Position, til bufferobjektet vha. gl.vertexAttribPointer() 
Dersom modellen består av mange primitiver med delte vertekser kan det være lurt å bruke drawElements() i stedet for drawArrays().

Bruk av indekser og gl.drawElements()

Vi har sett hvordan bruk av triangel strips & fans er med på å minimalisere antall vertekser som brukes til å definere en modell. Her skal vi se hvordan man kan bruke verteksbuffer sammen med et indeksbuffer for i enda større grad gjenbruke vertekser.
 
Verteks og indelsbuffer


Metoden drawElements() bruker et verteksbuffer i tillegg til et indeksbuffer som bestemmer hvordan verteksene sammenstilles til primitiver. Dette betyr også at verteksene kan ligge i vilkårlig rekkefølge i bufret siden det er indeksene som bestemmer rekkefølgen. Det er nå enkelt å gjenbruke en verteks ved at samme indeks brukes flere ganger i indeksarrayet. Dette kalles også «indeksert tegning».

Indeksbufret bindes vha. bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indeksBuffer) metoden.

Anta at et rektangel er definert av fire vertekser; v0, v1, v2 og v3. Dette kan tegnes indeksert vha. indeksene 0, 1, 2, 2, 1, 3. Se figuren under.
Bruk av indeksbuffer (Anyuru, 2012)
I dette eksemplet er det ikke noen besparelse å bruke indekser. I tilfeller der man har 100, 1000- eller millioner av trekanter vil det derimot kunne være veldig mange delte vertekser og bruk av indekser vil da kunne være rasjonelt.

Når man bruker indekser og drawElements() må verteksbufret settes opp som forklart i forrige avsnitt. I tillegg må følgende gjøres:
  • Opprette et indeksbuffer (av type WebGLBuffer) vha. gl.createBuffer(). 
  • Binde indeksbufret til gl.ELEMENT_ARRAY_BUFFER vha. bindBuffer() 
  • Laste indeksdata inn i indeksbufret vha. gl.bufferData(). 
Prototypen til gl.drawElements() ser slik ut:
  • void gl.drawElements(GLenum mode, GLsizei count, GLenum type, GLintptr offset);
Det første argumentet er det samme som drawArrays(), f.eks. gl.TRIANGLES. Det andre argumentet, count, indikerer hvor mange indekser som ligger i indeksbufret. Det tredje argumentet, type, indikerer datatypen til indeksene som ligger i indeksbufret. Verdien til denne kan enten være gl.UNSIGNED_BYTE eller gl.UNSIGNED_SHORT. Det siste argumentet, offset, indikerer første indeksverdi i indeksbufret.

Ingen kommentarer:

Legg inn en kommentar