Karhulla on asiaa

WebGL-shaderien kanssa alkuun

Kimmo Tapala

WebGL on selainten ohjelmointirajapinta, joka mahdollistaa 3D-kiihdytetyn grafiikan esittämisen suoraan selaimessa ilman lisäosia. WebGL perustuu OpenGL ES -standardiin, ja sen avulla voidaan luoda varsin monimutkaisiakin grafiikkasovelluksia, kuten pelejä ja interaktiivisia visualisointeja. WebGL tarjoaa myös mahdollisuuden käyttää GLSL (OpenGL Shading Language) -kieltä, joka on suunniteltu erityisesti grafiikkaprosessoreiden ohjelmointiin. Nämä ns. shaderit ovat ohjelmia, joita suoritetaan grafiikkaprosessorilla ja niissä on yksi mielenkiintoinen ominaisuus: niiden ohjelmointi on aivan hemmetin hauskaa! Tässä artikkelissa perehdytään siihen, miten shadereiden ohjelmoinnin kanssa pääsee alkuun ja tuotetaan yksinkertainen verkkosivu, jota voi käyttää pohjana omille shaderiprojekteille.

Artikkelin lopusta löytyvät linkit tässä käsiteltyyn koodiin GitHubissa sekä selaimessa ajettavaan livedemoon.

Miten WebGL saadaan käyttöön verkkosivulle?

WebGL toimii yhdessä <canvas>-HTML-elementin kanssa siten, että WebGL on oma grafiikkakontekstinsa ko. elementille. Tämä tarkoittaa, että ensiksi tarvitaan HTML-sivu, jolla on <canvas>-elementti. Yksinkertainen HTML-sivun runko voisi siis olla tällanen:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>WebGL shader starter</title>
    <style>
      body {
        background: #192A41;
        font-family: sans-serif;
        margin: 0;
        padding: 0;
        min-height: 100vh;
        overflow-x: hidden;
      }

      .header {
        display: grid;
        box-sizing: border-box;
        justify-content: center;
        align-content: center;
        text-align: center;
        min-height: 100vh;
      }

      .header h1{
        z-index: 1;
        color: #fff;
      }

      canvas {
        width: 100vw;
        height: 100vh;
        position: fixed;
        left: 0;
        top: 0;
        z-index: 0;
      }
    </style>
  </head>
  <body>
    <div class="header">
      <h1>WebGL shader starter</h1>
    </div>
    <canvas></canvas>
  </body>
</html>

Tässä esimerkissä <canvas>-elementti on asetettu täyttämään koko selainikkuna. Toistaiseksi WebGL-kontekstia ei ole kuitenkaan luotu. Sen luomiseen tarvitaan JavaScriptiä, joten lisätään sivulle ennen sulkevaa </body>-tagia <script>-elementti, joka lataa JavaScript-tiedoston:

<script src="script.js"></script>

Tällä hetkellä tiedosto script.js ei vielä sisällä mitään, eikä <canvas>-elementtiä ole vielä alustettu WebGL-kontekstiksi. Hypätään tässä vaiheessa JavaScriptin pariin ja mietitään, miten hommassa päästään eteenpäin.

Mitä tietoja tarvitaan?

Koska haluamme luoda shaderin avulla liikkuvaa grafiikkaa, meidän on jotenkin suhteutettava shaderin toiminta aikaan. Tästä syystä meidän on pidettävä kirjaa ajasta. Toisaalta, meidän on myös aina tiedettävä, minkä kokoinen ja muotoinen piirtoalueemme kulloinkin on. Alustetaan näitä tietoja varten seuraavat muuttujat:

// WebGL-konteksti ja piirtoalue
let gl = null; // WebGL-konteksti
let canvas = null; // Piirtoalue
let shaderProgram; // Shaderiohjelma, joka välitetään GPU:lle

// Perustiedot
let startTime = new Date().getTime(); // Aloitusaika
let aspectRatio; // Piirtoalueen (ikkunan) kuvasuhde

Näiden lisäksi tarvitsemme tiedot geometriasta, eli kolmiulotteisista kolmioista, joiden pinnan väritämme shaderilla. Geometria ilmaistaan verteksipisteinä, jotka välitetään grafiikkaprosessorille tietynlaisessa puskurissa. Verteksitietoja varten tarvitsemme seuraavat muuttujat:

// Verteksitiedot
let vertexArray; // Verteksitaulukko
let vertexBuffer; // Verteksipuskuri, joka välitetään GPU:lle
let vertexNumComponents = 2; // Verteksin komponenttien määrä (x, y)

Lopuksi meidän on vielä esiteltävä shaderiohjelmalle JavaScriptiltä välitettävät tiedot. Tässä tapauksessa käsittelemme kahdenlaista tietoa: uniform-muuttujia, jotka ovat globaaleja aina yhden piirron ajan, ja verteksikohtaisia attribuutteja, jotka asetetaan jokaiselle verteksipisteelle erikseen jokaisella piirrolla. Lisätään näille muuttujat:

// Shaderille välitettävät tiedot (uniformit ja attribuutit)
let uGlobalColor; // Piirtoväri
let uElapsed; // Kulunut aika
let uViewport; // Piirtoalueen ulottuvuudet
let aVertexPosition; // Verteksien sijainti shaderissa

Ohjelman alustaminen

Kun kaikki ohjelman tilaa koskevat muuttujat on alustettu, voidaan aloittaa WebGL-kontekstin luominen ja shaderiohjelman lataaminen. Tätä varten on järkevää luoda oma alustusfunktionsa:

/**
* WebGL:n alustus ja shaderien kääntäminen
*/
function start() {
canvas = document.querySelector("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
gl = canvas.getContext("webgl", { antialias: true, premultipliedAlpha: false });
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

const shaderSet = [
{ type: gl.VERTEX_SHADER, id: "vertex-shader" },
{ type: gl.FRAGMENT_SHADER, id: "fragment-shader" }
];

shaderProgram = buildShaderProgram(shaderSet);
gl.useProgram(shaderProgram);

aspectRatio = canvas.width / canvas.height;

// Verteksipisteiden koordinaatit (kaksi kolmiota)
vertexArray = new Float32Array([
-1.0, 1.0,
1.0, 1.0,
1.0, -1.0,

-1.0, 1.0,
1.0, -1.0,
-1.0, -1.0
]);

vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW);

animate();
}

Tässä on huomattava muutama mielenkiintoinen asia:

  1. Shadereita on kahta tyyppiä: verteksishadereita ja fragmenttishadereita. Molemmat tarvitaan, joskin tulemme käyttämään hyvin yksinkertaista verteksishaderia ja kaikki varsinainen työ tehdään fragmenttishaderissa. Verteksi- ja fragmenttishader yhdessä muodostavat shaderiohjelman.
  2. Shaderit pitää ladata ja kääntää erikseen WebGL:lle. Ne eivät ole JavaScript-koodia, vaan GLSL-koodia, joten niiden lähdekoodi pitää jotenkin saada jostakin — katsotaan kohta, mistä se tässä tapauksessa tulee.
  3. Verteksit ovat kolmiulotteisia koordinaatteja, mutta koska shaderimme on vain kaksiulotteinen, voimme jättää z-ulottuvuuden pois. Kolmiulotteisessa grafiikassa geometria muodostuu kolmioista, joten koska me haluamme esittää suorakaiteenmuotoisen alueen, tarvitsemme sen esittämiseen kaksi kolmiota. Tässä esimerkissä vertexArray-taulukko sisältää näiden kahden kolmion verteksien koordinaatit.

Tämä alustusfunktio kutsuu kahta muuta funktiota: buildShaderProgram ja animate. Näistä ensimmäinen vastaa shaderiohjelman luomisesta ja toinen animaation piirtämisestä. Katsotaan ensin shaderiohjelman luomista.

Shaderiohjelman luominen

Shaderiohjelman luominen ei ole älyttömän hankalaa, mutta siinä on muutama vaihe:

  1. Shaderin GLSL-lähdekoodi pitää saada jostakin.
  2. Shaderin lähdekoodi pitää kääntää grafiikkaprosessorin suoritettavaksi.
  3. Shaderiohjelma pitää linkittää yhdeksi kokonaisuudeksi.

Aiemmin käsitellyssä start-funktiossa kutsuttiin buildShaderProgram-funktiota, joka on itse asiassa abstraktio verteksi- ja fragmenttishaderin käsittelemiseksi yhtenä kokonaisuutena. Toteutus näyttää meidän tapauksessamme tältä:

/**
* Shader-ohjelman rakentaminen ja linkitys
*
* @param {Array} shaderInfo - Taulukko shaderin tiedoista
*
* @return {WebGLProgram} - Shader-ohjelma
*/
function buildShaderProgram(shaderInfo) {
let program = gl.createProgram();
shaderInfo.forEach(info => {
let shader = compileShader(info.id, info.type);
if (shader) {
gl.attachShader(program, shader);
}
});

gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("Shaderin linkitys epäonnistui: ", gl.getProgramInfoLog(program));
return null;
}

return program;
}

Tämä funktio käy läpi argumenttinaan saamansa shaderInfo-taulukon (taulukko sisältää sekä verteksi- että fragmenttishaderin), kääntää siinä määritellyt shaderit ja liittää ne yhdeksi ohjelmaksi. Tässäkään kohdassa ei vielä käsitellä shaderin lähdekoodia, vaan se hommataan erikseen vasta compileShader-funktiossa:

/**
* Shaderin kääntäminen
*
* @param {string} id - Shader-elementin ID HTML:ssä
* @param {number} type - Shaderin tyyppi (VERTEX_SHADER tai FRAGMENT_SHADER)
*
* @return {WebGLShader} - Käännetty shader
*/
function compileShader(id, type) {
let code = document.getElementById(id).textContent;
let shader = gl.createShader(type);

gl.shaderSource(shader, code);
gl.compileShader(shader);

if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("Shaderin käännös epäonnistui: ", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}

return shader;
}

No niin! Nythän se selvisi, mistä shaderit tässä tapauksessa tulevat! Ne on määritetty HTML-sivun <script>elementeissä, joissa on type-attribuutti asetettu arvoon x-shader/x-vertex tai x-shader/x-fragment. Periaatteessa ne voisivat tulla mistä vain (esim. omista tiedostoistaan, jotka ladattaisiin fetch-kutsulla), mutta tässä tapauksessa ne on helppo määritellä suoraan HTML-sivulle. Hypätään kohta takaisin HTML:n puolelle katsomaan, miltä nuo shaderit näyttävät, mutta hoidetaan sitä ennen loput JS-hommat ensin alta pois.

Animaatio ja tapahtumankäsittely

Aikaisemmin käsitellyssä start-funktiossa kutsuttiin myös animate-funktiota, joka on vastuussa asioiden piirtämisestä WebGL-kontekstissa. Tämän funktion tärkeimmät tehtävät ovat:

  1. Piirtoalueen määrittely jokaiselle piirrolle.
  2. Shaderin attribuuttien ja uniform-muuttujien asettaminen.
  3. Verteksipuskurin asettaminen.
  4. Piirtäminen.

Tämä onnistuu meidän tapauksessamme seuraavanlaisella koodilla:

/**
* Yhden animaatioframen piirtäminen
*/
function animate() {
gl.viewport(0, 0, canvas.width, canvas.height);

// Asetetaan uniformit
uGlobalColor = gl.getUniformLocation(shaderProgram, "uGlobalColor");
uElapsed = gl.getUniformLocation(shaderProgram, "uElapsed");
uViewport = gl.getUniformLocation(shaderProgram, "uViewport");

gl.uniform4fv(uGlobalColor, [1.0, 1.0, 1.0, 1.0]);
gl.uniform2fv(uViewport, [canvas.width, canvas.height]);
gl.uniform1f(uElapsed, (new Date().getTime() - startTime) / 1000.0);

// Sidotaan verteksipuskuri ja asetetaan verteksiattribuutit
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

aVertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");
gl.enableVertexAttribArray(aVertexPosition);
gl.vertexAttribPointer(aVertexPosition, vertexNumComponents, gl.FLOAT, false, 0, 0);

// Piirretään kolmiot
gl.drawArrays(gl.TRIANGLES, 0, vertexArray.length / vertexNumComponents);

window.requestAnimationFrame(animate); // Pyydetään seuraavaa animaatioframea

}

Tässä on huomattava, että GLSL:lle välitettävillä tiedoilla on aina oltava tiedossa tietotyypit. Tässä tapauksessa uniform-muuttujat asetetaan seuraavilla metodeilla:

  1. gl.uniform4fv: Neljän liukuluvun vektori (värin RGBA-arvot välillä 0-1).
  2. gl.uniform2fv: Kahden liukuluvun vektori (piirtoalueen leveys ja korkeus).
  3. gl.uniform1f: Yksi liukuluku (kulunut aika sekunteina).

Tässä vektorit ovat siis käytännössa taulukoita, joilla on määritetty pituus. Animaatio tapahtuu kokonaisuudessaan uElapsed-uniform-muuttujan perusteella fragmenttishaderissa, joten tässä funktiossa animointiin riittää se, että ko. uniform-muuttujaa päivitetään jokaisella piirrolla. Lopussa oleva window.requestAnimationFrame(animate)-kutsu pyytää selainta kutsumaan animate-funktiota uudestaan seuraavassa animaatioframessa.

JavaScript-puolella onkin sitten enää jäljellä tapahtumakäsittelijöiden lisääminen. Nämä tarvitaan, jotta animaatio osataan käynnistää sivun latauduttua ja jotta piirtoalueen koko osataan päivittää, kun selainikkunan koko muuttuu. Tapahtumakäsittelijät lisätään seuraavasti:

// Tarvittavat event listenerit
window.addEventListener("load", start, false);
window.addEventListener("resize", () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});

Siinäpä se. Nyt JavaScript-puoli on valmis ja voimme siirtyä varsinaiseen pihviin, eli shadereiden pariin.

Shaderit

Kuten aikaisemmin on jo muutamaan otteeseen mainittu, tarvitaan kaksi shaderia: verteksi- ja fragmenttishaderi. Verteksishaderi ajetaan jokaiselle verteksille, kun taas fragmenttishaderi ajetaan jokaiselle pikselille, joka kuuluu piirtämämme geometrian sisään. Jotta koodin muokkaaminen ja erilaisten shaderikokeiluiden harrastaminen on mahdollisimman helppoa, on shaderit ympätty mukaan HTML-sivulle omiin <script>-elementteihinsä. Nämä shaderit voi oikeastaan liittää HTML-sivulle mihin tahansa kohtaan, kunhan ne on määritetty ennen script.js-tiedoston lataamista. Perinteisesti tähän on käytetty <head>-elementtiä.

Verteksishaderi

Koska tässä tapauksessa verteksishaderin hommana ei ole muuta kuin välittää verteksien koordinaatit fragmenttishaderille, on se hyvin yksinkertainen. Se näyttää tältä:

<script id="vertex-shader" type="x-shader/x-vertex">
attribute vec2 aVertexPosition;

void main() {
gl_Position = vec4(aVertexPosition, 0.0, 1.0);
}
</script>

Tässä oikeastaan tärkeintä on se, että <script>-elementillä on oikeat id– ja type-attribuutit. Shadereilla on aina oltava main-funktio, jotta ne voidaan suorittaa. Tässä tapauksessa main-funktio asettaa vain gl_Position-muuttujan, joka on GLSL:n globaali muuttuja, joka määrittää shaderin piirtämän geometrian sijainnin. Koska shaderimme on vain kaksiulotteinen, z-ulottuvuus asetetaan nollaksi ja w-ulottuvuus ykköseksi. Tämä viimeisenä mainittu w-ulottuvuus ei ole meidän käyttötapauksessamme merkityksellinen, sillä sitä käytetään kolmiulotteisessa grafiikassa perspektiivimuunnoksissa. Tässä tapauksessa se on vain pakollinen osa GLSL:n globaalia muuttujaa, joten se asetetaan ykköseksi.

Fragmenttishaderi

Ja nyt vihdoin se tärkein osuus, eli fragmenttishaderi. Tämä on se osuus koodista, jonka kanssa voit leikkiä aivan mielin määrin. Fragmenttishaderi on se osa shaderia, joka määrittää, minkä värisistä pikseleistä piirtämämme pinta koostuu. Tavallisessa 2D-ohjelmoinnissa on totuttu määrittelemään polkuja ja muotoja, jotka piirretään tietyllä värillä tiettyihin koordinaatteihin. Fragmenttishaderissa tämä kaikki tapahtuu oikeastaan käänteisesti: shaderi saa syötteenä pikselin koordinaatit ja sen perusteella sen pitää tuottaa tulokseksi väri ko. pikselille. Jos siis haluat vaikkapa piirtää ympyrän, sinun on tarkistettava, osuuko pikseli ympyrän sisään ja asetettava pikselin väri sen perusteella.

Me teemme tällä kertaa hieman psykedeelisen oloisen shaderin, joka piirtelee vinkeästi liikahtelevia rinkuloita ja raitoja. Shaderin koodi on tämä:

<script id="fragment-shader" type="x-shader/x-fragment">
#ifdef GL_ES
precision highp float;
#endif

uniform vec4 uGlobalColor;
uniform vec2 uViewport;
uniform float uElapsed;

vec4 rings(vec4 color) {
vec2 center = vec2(uViewport.x / 2.0 + sin(uElapsed) * 120.0, uViewport.y / 2.0);
float dist = distance(vec2(gl_FragCoord.x, gl_FragCoord.y), center);
float xOpa = abs(sin(uElapsed / 10.0 - dist / 30.0 * sin(gl_FragCoord.x / 600.0 + uElapsed / 10.0)));
float yOpa = abs(sin(uElapsed * 1.5 - dist / 680.0));
float opa = 0.0;

if (xOpa * yOpa > 0.5) {
opa = 1.0;
}

return vec4(color.r, color.g, color.b, opa);
}

void main() {
gl_FragColor = rings(uGlobalColor);
}
</script>

Kulloinkin väritettävän pikselin koordinaatit ovat globaalissa gl_FragCoord-vektorissa ja shaderin on asetettava väri globaaliin gl_FragColor-vektoriin. GLSL on kielenä hiukan kuin yksinkertaista C:tä, joten sen ymmärtäminen on kohtuullisen helppoa. Tässä shaderissa on määritelty kolme funktiota:

  1. main: Pakollinen funktio, jonka suorituksen tuloksena saadaan väri kulloinkin käsiteltävälle pikselille.
  2. rings: Piirtelee em. rinkulat ja raidat. Käytännössä pikselin väri on aina sama, mutta sen läpinäkyvyys (opa-muuttuja) vaihtuu laskujen perusteella.

Tätä fragmenttishaderia muuttamalla saat aikaiseksi vaikka mitä! Fragmenttishadereillä on oikeasti tehty huikeita juttuja ja niiden koodaaminen on älyttömän kivaa. Hyvää inspiraatiota voi hakea vaikkapa shadertoy.com -sivustolta, jossa on valtava määrä erilaisia shaderikokeiluja.

Koodi ja demo

Hyvinkin pitkälti juuri tässä blogikirjoituksessa käsitelty koodi on julkisesti saatavilla GitHubissa. Selaimessa ajettava demo on nähtävillä osoitteessa https://karhu-shader-starter.surge.sh/.

Tykkäsitkö tästä jutusta?

0
0
0
0
Kenttä on validointitarkoituksiin ja tulee jättää koskemattomaksi.
Jaa juttu somessa
Tällä viikolla näitä luettiin eniten
  1. 9 tärkeintä Google Analytics -mittaria
  2. WebGL-shaderien kanssa alkuun
  3. Värillä on väliä – etenkin saavutettavuuden kannalta
Viime aikoina eniten reaktioita herättivät
Ota yhteyttä
Tilaa uutiskirje