Komme i gang i WebGL, del 2 The Canvas Element for vår første Shader

I den forrige artikkelen skrev vi vår første toppunkt og fragment shaders. Etter å ha skrevet GPU-sidekoden, er det på tide å lære å skrive CPU-siden en. I denne opplæringen og den neste, viser jeg deg hvordan du innlemmer shaders i WebGL-applikasjonen. Vi starter fra begynnelsen, bruker bare JavaScript og ingen tredjepartsbiblioteker. I denne delen vil vi dekke lerretsspesifikke koden. I det neste dekker vi den spesifikke WebGL-spesifikasjonen.

Merk at disse artiklene:

  • anta at du er kjent med GLSL shaders. Hvis ikke, vennligst les den første artikkelen.
  • er ikke ment å lære deg HTML, CSS eller JavaScript. Jeg vil prøve å forklare de vanskelige konseptene når vi møter dem, men du må se etter mer informasjon om dem på nettet. MDN (Mozilla Developer Network) er et utmerket sted å gjøre det.

La oss begynne allerede!

Hva er WebGL?

WebGL 1.0 er et 3D-grafikk-API på lavt nivå for Internett, eksponert gjennom HTML5 Canvas-elementet. Det er en shader-basert API som ligner på OpenGL ES 2.0 API. WebGL 2.0 er det samme, men er basert på OpenGL ES 3.0 i stedet. WebGL 2.0 er ikke helt bakoverkompatibel med WebGL 1.0, men de fleste feilfrie WebGL 1.0-applikasjoner som ikke bruker utvidelser, bør fungere på WebGL 2.0 uten problemer.

Når du skriver denne artikkelen, er implementeringer av WebGL 2.0 fortsatt eksperimentelle i de få nettleserne som implementerer det. De er heller ikke aktivert som standard. Koden vi skal skrive i denne serien er derfor rettet mot WebGL 1.0.

Ta en titt på følgende eksempel (husk å bytte faner og ta noen blikk på koden også):

Dette er koden vi skal skrive. Ja, det tar faktisk litt mer enn hundre linjer med JavaScript for å implementere noe så enkelt. Men ikke bekymre deg, vi tar oss tid til å forklare dem slik at de alle gir mening på slutten. Vi dekker lerretrelatert kode i denne opplæringen og fortsetter til den WebGL-spesifikke koden i den neste.

The Canvas

Først må vi lage et lerret hvor vi viser våre gjengitte ting. 

Dette søte lille torget er vårt lerret! Bytt til HTML se og la oss se hvordan vi har gjort det.

Dette er å fortelle nettleseren at vi ikke vil at vår side skal kunne zoomes på mobile enheter.

Og dette er vårt lerretelement. Hvis vi ikke tildele dimensjoner til lerretet vårt, ville det ha blitt standard til 300 * 150px (CSS piksler). Bytt nå til CSS se for å sjekke hvordan vi stylet den.

lerret … 

Dette er en CSS velger. Dette innebærer at følgende regler skal brukes på alle lerretelementene i dokumentet. 

bakgrunn: # 0f0;

Endelig regelen som skal brukes på lerretelementene. Bakgrunnen er satt til lysegrønn (# 0f0).

Merk: I redigeringen ovenfor er CSS-teksten automatisk knyttet til dokumentet. Når du lager dine egne filer, må du koble til CSS-filen i HTML-filen din slik:

Fortrinnsvis sett det i hode stikkord.

Nå som lerretet er klart, er det på tide å tegne noen ting! Dessverre, mens lerretet der oppe ser bra ut og alt, har vi fortsatt en lang vei å gå før vi kan tegne noe ved hjelp av WebGL. Så skrap WebGL! For denne opplæringen gjør vi en enkel 2D tegning for å forklare noen begreper før du bytter til WebGL. La tegningen være en diagonal linje.

Rendering kontekst

HTML-koden er den samme som forrige eksempel, unntatt denne linjen:   

der vi har gitt en id til lerretelementet slik at vi enkelt kan hente det i JavaScript. CSS er akkurat det samme og en ny JavaScript-fan ble lagt til for å utføre tegningen.

Bytt til JS tab,

window.addEventListener ('load', funksjon () ...);

I eksemplet ovenfor har JavaScript-koden vi har skrevet, festet til dokumenthodet, noe som betyr at den kjører før siden fullfører lasting. Men i så fall vil vi ikke kunne tegne på lerretet, som ennå ikke er opprettet. Det er derfor vi utsetter kjører koden vår til siden laster. For å gjøre dette bruker vi window.addEventListener, spesifisere laste som hendelsen vi vil lytte til og vår kode som en funksjon som kjører når hendelsen utløses.

Går videre:

var lerret = document.getElementById ("lerret");

Husk det vi har tildelt lerretet tidligere i HTML? Her er hvor det blir nyttig. I den ovennevnte linjen henter vi lerretelementet fra dokumentet ved hjelp av dets ID som referanse. Fra nå av blir ting mer interessant,

context = canvas.getContext ('2d');

For å kunne tegne på lerretet må vi først skaffe en tegningskontekst. En kontekst i denne forstand er et hjelperobjekt som avslører den nødvendige tegnings-API og binder den til lerretelementet. Dette betyr at eventuelle påfølgende bruk av API-en ved hjelp av denne konteksten vil bli utført på det aktuelle lerretobjektet.

I dette tilfellet ba vi om en 2d tegning kontekst (CanvasRenderingContext2D) som lar oss bruke vilkårlige 2D tegnefunksjoner. Vi kunne ha bedt om en WebGL, en webgl2 eller a bitmaprenderer kontekster i stedet, hver av dem ville ha avslørt et annet sett med funksjoner.

Et lerret har alltid sin kontekstmodus satt til ingen i utgangspunktet. Deretter, ved å ringe getContext, dens modus endres permanent. Uansett hvor mange ganger du ringer getContext På et lerret, vil det ikke endre sin modus etter at den først ble satt inn. ringe getContext igjen for samme API vil returnere samme kontekstavobjekt returnert ved første bruk. ringe getContext for en annen API kommer tilbake null.

Dessverre kan ting gå galt. I enkelte tilfeller, getContext kan ikke være i stand til å lage en kontekst og vil brann et unntak i stedet. Selv om dette er ganske sjeldent i dag, er det mulig med 2d sammenhenger. Så i stedet for å krasje om dette skjer, innkapslet vi vår kode til en prøve-fangst blokkere:

prøv context = canvas.getContext ('2d');  fangst (unntak) alert ("Umm ... beklager, ingen 2d sammenhenger for deg!" + exception.message); komme tilbake ; 

På denne måten, hvis et unntak kastes, kan vi fange det og vise en feilmelding, og fortsett deretter grasiøst for å slå hodene våre mot veggen. Eller kanskje vise et statisk bilde av en diagonal linje. Mens vi kunne gjøre det, tåler det målet med denne opplæringen!

Forutsatt at vi har oppnådd en kontekst, er alt som er igjen å gjøre, å tegne linjen:

context.beginPath ();

De 2d konteksten husker den siste banen du konstruerte. Hvis du tegner en bane, kasserer den ikke automatisk fra kontekstens minne. beginPath forteller sammenhengen til å glemme tidligere stier og begynne å friske. Så ja, i dette tilfellet kunne vi ha utelatt denne linjen helt, og det ville ha fungert feilfritt, siden det ikke var noen tidligere stier å begynne med.

context.moveTo (0, 0);

En bane kan bestå av flere underveier. flytte til starter en ny understi ved de nødvendige koordinatene.

context.lineTo (30, 30);

Oppretter et linjesegment fra det siste punktet på underbanen til (30, 30). Dette betyr en diagonal linje fra øvre venstre hjørne av lerretet (0, 0) til nederste høyre hjørne (30, 30).

context.stroke ();

Å lage en sti er en ting; tegne det er en annen. hjerneslag forteller konteksten for å tegne alle underveiene i minnet.

beginPath, flytte til, lineTo, og hjerneslag er bare tilgjengelige fordi vi ba om en 2d kontekst. Hvis vi for eksempel ba om en WebGL kontekst, ville disse funksjonene ikke vært tilgjengelige.

Merk: I redigeringen ovenfor er JavaScript-koden automatisk knyttet til dokumentet. Når du lager dine egne filer, må du koble til JavaScript-filen i HTML-filen din slik:

Du bør sette den i hode stikkord.

Dette avslutter vår tegningstrening! Men på en eller annen måte er jeg ikke fornøyd med dette lille lerretet. Vi kan gjøre større enn dette!

Laget av lerret

Vi skal legge til noen regler til vårt CSS for å gjøre lerretet fylt hele siden. Den nye CSS-koden vil se slik ut:

html, kropp høyde: 100%;  kropp margin: 0;  lerret display: block; bredde: 100%; høyde: 100%; bakgrunn: # 888; 

La oss ta det fra hverandre:

html, kropp høyde: 100%; 

De html og kropp elementene behandles som blokkelementer; De bruker hele den tilgjengelige bredden. Men de utvider vertikalt akkurat nok til å pakke inn innholdet. Med andre ord, deres høyder avhenger av barnas høyder. Ved å sette en av barnas høyder til en prosentandel av høyden, vil det oppstå en avhengighetsløkke. Så, med mindre vi eksplisitt tilordner verdier til sine høyder, ville vi ikke kunne sette barna høyder i forhold til dem.

Siden vi ønsker at lerretet kan fylle hele siden (sett høyden til 100% av overordnet), setter vi høyder til 100% (av sidens høyde).

kropp margin: 0; 

Nettlesere har grunnleggende stilark som gir en standardstil til ethvert dokument de gjør. Det kalles brukeragent stilark. Stilene i disse arkene avhenger av den aktuelle nettleseren. Noen ganger kan de til og med justeres av brukeren.

De kropp Elementet har vanligvis en standardmargin i brukeragentens stilark. Vi vil at lerretet kan fylle hele siden, så vi setter sine marginer til 0.

lerret display: block;

I motsetning til blokkelementer er inlineelementer elementer som kan behandles som tekst på vanlig linje. De kan ha elementer før eller etter dem på samme linje, og de har en tom plass under dem hvis størrelse er avhengig av skriftstørrelsen og skriftstørrelsen som er i bruk. Vi ønsker ikke noe tomt rom under lerretet vårt, så vi har bare satt visningsmodus til blokkere.

bredde: 100%; høyde: 100%;

Som planlagt setter vi lerretsmålene til 100% av sidens bredde og høyde.

bakgrunn: # 888;

Vi forklarte allerede det før, gjorde vi ikke?!

Se resultatet av endringene våre ...

...

...

Nei, vi gjorde ikke noe galt! Dette er helt normal oppførsel. Husk dimensjonene vi ga til lerretet i HTML stikkord?

Nå har vi gått og gitt lerretet andre dimensjoner i CSS:

lerret ... bredde: 100%; høyde: 100%; ... 

Viser seg at dimensjonene vi angir i HTML-taggen, kontrollerer indre dimensjoner av lerretet. Lerret er mer eller mindre en bitmap-beholder. Bitmapdimensjonene er uavhengige av hvordan lerretet skal vises i sin endelige posisjon og dimensjoner på siden. Det som definerer disse er ekstrinsiske dimensjoner, de vi satt i CSS.

Som vi kan se, har vår lille 30 * 30 bitmap blitt strukket for å fylle hele lerretet. Dette styres av CSS objekt-fit eiendom, som standard til fylle. Det finnes andre moduser som for eksempel klipp i stedet for skala, men siden fylle vil ikke komme inn i vår måte (faktisk det kan være nyttig), vi vil bare la det være. Hvis du planlegger å støtte Internet Explorer eller Edge, kan du likevel ikke gjøre noe med det. Når du skriver denne artikkelen, støtter de ikke objekt-fit i det hele tatt.

Vær imidlertid klar over at hvordan nettleseren skalerer innholdet, er fortsatt et spørsmål om debatt. CSS-eiendommen bildegjengivelse ble foreslått å håndtere dette, men det er fortsatt eksperimentelt (hvis det støttes i det hele tatt), og det dikterer ikke visse skaleringsalgoritmer. Ikke bare det, kan nettleseren velge å forsømme det helt siden det bare er et hint. Hva dette betyr er at for tiden vil forskjellige nettlesere bruke forskjellige skaleringsalgoritmer for å skalere din bitmappe. Noen av disse har virkelig forferdelige gjenstander, så ikke skaler for mye.

Enten vi tegner ved hjelp av en 2d kontekst eller andre typer sammenhenger (som WebGL), lerretet oppfører seg nesten det samme. Hvis vi vil at vår lille bitmap skal fylle hele lerretet og vi ikke liker å strekke, bør vi se etter endringer i lerretstørrelsen og justere bitmapdimensjonene tilsvarende. La oss gjøre det nå,

Når vi ser på endringene vi har gjort, har vi lagt disse to linjene til JavaScript:

canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight;

Ja, når du bruker 2d Kontekster, det er enkelt å sette inn de interne bitmapdimensjonene på lerretets dimensjoner! Lerretet bredde og høyde blir overvåket, og når noen av dem er skrevet til (selv om det er samme verdi):

  • Den nåværende bitmapet er ødelagt.
  • En ny med de nye dimensjonene blir opprettet.
  • Den nye bitmappen initialiseres med standardverdien (gjennomsiktig svart).
  • Enhver tilknyttet kontekst blir ryddet tilbake til sin opprinnelige tilstand og blir på nytt oppnådd med de nylig angitte koordinaterommet dimensjoner.

Legg merke til at, for å sette begge bredde og høyde, de ovennevnte trinnene utføres to ganger! En gang når du endrer bredde og den andre når du endrer høyde. Nei, det er ingen annen måte å gjøre det på, ikke det jeg vet om.

Vi har også utvidet vår korte linje til å bli den nye diagonalen,

context.lineTo (canvas.width, canvas.height);

i stedet for: 

context.lineTo (30, 30);

Siden vi ikke lenger bruker de opprinnelige 30 * 30 dimensjonene, behøver de ikke lenger i HTML:

Vi kunne ha forlatt dem initialisert til svært små verdier (som 1 * 1) for å lagre overhead for å lage en bitmap ved hjelp av de relativt store standarddimensjonene (300 * 150), initialisere den, slette den og deretter opprette en ny med riktig størrelse vi angir i JavaScript.

...

På andre tanke, la oss bare gjøre det!

Ingen skal noen gang merke forskjellen, men jeg kan ikke bære skylden!

CSS Pixel vs Fysisk Pixel

Jeg ville ha elsket å si det er det, men det er det ikke! offsetWidth og offsetHeight er spesifisert i CSS piksler. 

Her er fangsten. CSS piksler er ikke fysiske piksler. De er tetthetsuavhengige piksler. Avhengig av enhetens fysiske piksler tetthet (og nettleseren din), kan en CSS-piksel svare til en eller flere fysiske piksler.

Setter det blatant, hvis du har en Full HD 5-tommers smarttelefon, da offsetWidth*offsetHeight ville være 640 * 360 i stedet for 1920 * 1080. Visst, det fyller skjermen, men siden de interne dimensjonene er satt til 640 * 360, er resultatet en strukket bitmap som ikke fullt ut bruker enhetens høye oppløsning. For å fikse dette tar vi hensyn til devicePixelRatio:

var pixelRatio = window.devicePixelRatio? window.devicePixelRatio: 1.0; canvas.width = pixelRatio * canvas.offsetWidth; canvas.height = pixelRatio * canvas.offsetHeight;

devicePixelRatio er forholdet mellom CSS-pikselet og det fysiske pikselet. Med andre ord, hvor mange fysiske piksler representerer en enkelt CSS-piksel.

var pixelRatio = window.devicePixelRatio? window.devicePixelRatio: 1.0;

window.devicePixelRatio støttes godt i de fleste moderne nettlesere, men bare hvis det er udefinert, faller vi tilbake til standardverdien av 1.0.

canvas.width = pixelRatio * canvas.offsetWidth; canvas.height = pixelRatio * canvas.offsetHeight;

Ved å multiplisere CSS-dimensjonene med pixelforholdet, er vi tilbake til de fysiske dimensjonene. Nå er vår interne bitmap nøyaktig den samme størrelsen som lerretet og ingen strekk vil forekomme.

Hvis din devicePixelRatio er 1 da vil det ikke være noen forskjell. Men for annen verdi er forskjellen signifikant.

Å svare på størrelsesendringer

Det er ikke alt som er å håndtere lerret liming. Siden vi har angitt våre CSS-dimensjoner i forhold til sidestørrelsen, påvirker endringer i sidestørrelse oss. Hvis vi kjører på en nettleser, kan brukeren endre størrelsen på vinduet manuelt. Hvis vi kjører på en mobil enhet, er vi underlagt orienteringsendringer. Ikke nevner at vi kanskje kjører inne i en iframe som endrer størrelsen sin vilkårlig. For å holde vår interne bitmappe riktig til enhver tid, må vi se etter endringer i siden (vindu) størrelse,

Vi har flyttet vår bitmap resizing koden:

// Hent enhetens pikselforhold, var pixelRatio = window.devicePixelRatio? window.devicePixelRatio: 1.0; // Juster lerretets størrelse, canvas.width = pixelRatio * canvas.offsetWidth; canvas.height = pixelRatio * canvas.offsetHeight;

Til en egen funksjon, adjustCanvasBitmapSize:

funksjon adjustCanvasBitmapSize () // Få enheten pixel ratio, var pixelRatio = window.devicePixelRatio? window.devicePixelRatio: 1.0; hvis ((canvas.width / pixelRatio)! = canvas.offsetWidth) canvas.width = pixelRatio * canvas.offsetWidth; hvis ((canvas.height / pixelRatio)! = canvas.offsetHeight) canvas.height = pixelRatio * canvas.offsetHeight; 

med en liten modifikasjon. Siden vi vet hvor dyrt tildeler verdier til bredde eller høyde er, det ville være uansvarlig å gjøre det unødvendig. Nå har vi bare satt bredde og høyde når de faktisk endres.

Siden vår funksjon åpner lerretet, vil vi deklarere hvor den kan se den. I utgangspunktet ble det deklarert i denne linjen:

var lerret = document.getElementById ("lerret");

Dette gjør det lokalt for vår anonym funksjon. Vi kunne nettopp fjernet Var del og det ville blitt blitt global (eller mer spesifikt a eiendom av globalt objekt, som kan nås gjennom vindu):

lerret = document.getElementById ("lerret");

Imidlertid anbefaler jeg sterkt mot implisitt erklæring. Hvis du alltid erklære dine variabler, vil du unngå mye forvirring. Så i stedet skal jeg erklære det utenfor alle funksjoner:

var lerret; var kontekst;

Dette gjør det også til en eiendom av det globale objektet (med en liten forskjell som ikke plager oss egentlig). Det finnes andre måter å lage en global variabel på - sjekk dem ut i denne StackOverflow-tråden. 

Oh, og jeg har smalt kontekst opp der også! Dette vil vise seg nyttig senere.

La oss nå koble vår funksjon til vinduet endre størrelse på begivenhet:

window.addEventListener ('resize', adjustCanvasBitmapSize);

Fra nå av, når vinduets størrelse endres, adjustCanvasBitmapSize er kalt. Men siden vinduets størrelsesbegivenhet ikke kastes ved første innlasting, vil bitmapen vår fortsatt være 1 * 1. Derfor må vi ringe adjustCanvasBitmapSize en gang av oss selv.

adjustCanvasBitmapSize ();

Dette stort sett tar seg av det ... bortsett fra at når du endrer størrelsen på vinduet, forsvinner linjen! Prøv det i denne demoen.

Heldigvis, dette er å bli forventet. Husk trinnene som videreføres når bitmapet er endret? En av dem var å initialisere den til gjennomsiktig svart. Dette er hva som skjedde her. Bitmapet ble overskrevet med gjennomsiktig svart, og nå ler lerretets grønne bakgrunn gjennom. Dette skjer fordi vi bare tegner vår linje en gang i begynnelsen. Når resize-arrangementet finner sted, blir innholdet ryddet og ikke omtrukket. Å fikse dette burde være enkelt. La oss flytte tegnet vår linje til en egen funksjon:

funksjon drawScene () // Tegn vår linje, context.beginPath (); context.moveTo (0, 0); context.lineTo (canvas.width, canvas.height); context.stroke (); 

og ring denne funksjonen fra innsiden adjustCanvasBitmapSize:

// Redraw alt igjen, drawScene ();

Men på denne måten blir vår scene redrawn når adjustCanvasBitmapSize kalles, selv om ingen endring i dimensjoner fant sted. For å håndtere dette legger vi til en enkel sjekk:

// Avbryt hvis ingenting endret, hvis ((canvas.width / pixelRatio) == canvas.offsetWidth) && ((canvas.height / pixelRatio) == canvas.offsetHeight)) return; 

Sjekk ut det endelige resultatet:

Prøv å endre størrelsen på den her.

Throttling Endre størrelse på hendelser

Så langt har vi det bra! Allikevel kan størrelsen på og størrelsen på nytt lett bli veldig dyr når lerretet ditt er ganske stort og / eller når scenen er komplisert. Videre kan resizing størrelsen på vinduet med musen utløse resizing hendelser med høy hastighet. Derfor smelter vi det. I stedet for:

window.addEventListener ('resize', adjustCanvasBitmapSize);

vi skal bruke:

window.addEventListener ('resize', funksjon onWindowResize (event) // Vent til resizing hendelser flom avgjør, hvis (onWindowResize.timeoutId) window.clearTimeout (onWindowResize.timeoutId); onWindowResize.timeoutId = window.setTimeout (adjustCanvasBitmapSize, 600 ););

Først,

window.addEventListener ('resize', funksjon onWindowResize (event) ...);

i stedet for å ringe direkte adjustCanvasBitmapSize når endre størrelse på Hendelsen er sparken, vi brukte en funksjonsuttrykk å definere ønsket oppførsel. I motsetning til funksjonen vi brukte tidligere for laste hendelse, denne funksjonen er a navngitt funksjon. Å gi et navn til funksjonen gjør det enkelt å referere til det fra selve funksjonen.

hvis (onWindowResize.timeoutId) window.clearTimeout (onWindowResize.timeoutId);

På samme måte som andre objekter, kan egenskaper legges til funksjonsobjekter. I utgangspunktet, timeoutId er udefinert, Derfor er denne setningen ikke utført. Vær forsiktig når du bruker udefinert og null i logiske uttrykk, fordi de kan være vanskelig. Les mer om dem i ECMAScript Language Specification.

Seinere, timeoutId vil holde timeoutID av en adjustCanvasBitmapSize pause:

onWindowResize.timeoutId = window.setTimeout (adjustCanvasBitmapSize, 600);

Dette forsinker ringer adjustCanvasBitmapSize i 600 millisekunder etter at arrangementet er avfyrt. Men det forhindrer ikke hendelsen fra å skyte. Hvis det ikke blir sparket igjen innen disse 600 millisekunder, da adjustCanvasBitmapSize utføres og bitmapet er endret. Ellers, cleartimeout kansellerer den planlagte adjustCanvasBitmapSize og setTimeout planlegger en annen 600 millisekunder i fremtiden. Resultatet er, så lenge brukeren fortsatt tilpasser vinduet, adjustCanvasBitmapSize er ikke kalt. Når brukeren stopper eller pauser en stund, kalles den. Fortsett, prøv det:

Err ... Jeg mener, her.

Hvorfor 600 millisekunder? Jeg tror det ikke er for fort og ikke for sakte, men mer enn noe annet fungerer det bra med å skrive inn / forlate fullskjerm animasjoner, som ikke er omfattet av denne opplæringen.

Dette avslutter vår veiledning for i dag! Vi har dekket alt lerretsspesifikt kode som vi trenger for å sette opp lerretet vårt. Neste gang - hvis Allah vil-vi dekker den WebGL-spesifikke koden og faktisk driver skyggeren. Til da, takk for å lese!

referanser

  • Lerret element i w3c redaktør utkast
  • w3c-versjon der læringsinitialiseringsegenskapen faktisk er dokumentert
  • Lerret element i whatwg live spesifikasjon