Úvod do WebGL a shaderů

Nedávno jsem pracoval na projektu, kde jsem potřeboval použít WebGL. Snažil jsem se vykreslit mnoho tisíc polygonů na mapě v prohlížeči, ale GeoJSON se ukázal být příliš pomalý. Abych to urychlil, chtěl jsem se dostat na nejnižší možnou úroveň a skutečně napsat kód, který by běžel přímo na GPU pomocí WebGL a shaderů. Vždy jsem se chtěl dozvědět o shaderech, ale nikdy jsem neměl příležitost, takže to byla skvělá příležitost naučit se něco nového a zároveň vyřešit velmi specifickou technickou výzvu.

Zpočátku to byl docela boj přijít na to, co musím udělat. Kopírování a vkládání ukázkového kódu často nefungovalo a já jsem opravdu nevěděl, jak přejít od příkladů k vlastnímu řešení, které jsem potřeboval. Jakmile jsem však plně pochopil, jak to do sebe všechno zapadá, najednou mi to v hlavě cvaklo a řešení se ukázalo jako překvapivě snadné. Nejtěžší bylo omotat hlavu kolem některých pojmů. Takže jsem chtěl napsat článek vysvětlující, co jsem se naučil, abych vám pomohl porozumět těmto pojmům a doufám, že vám usnadní psaní vašeho prvního shaderu.

V tomto článku se podíváme na to, jak vykreslit obrázek na stránku s více než 150 řádky kódu! Hloupé, já vím, vzhledem k tomu, že můžeme použít jen <img> tag a hotovo. Ale dělat to je dobré cvičení, protože nás to nutí zavést spoustu důležitých konceptů WebGL.

V tomto článku uděláme následující:

  1. Napíšeme dva shader programy, které GPU sdělí, jak převést seznam souřadnic na barevné trojúhelníky na obrazovce.

  2. Předáme shaderům seznam souřadnic, abychom jim řekli, kam na obrazovku nakreslit trojúhelníky.

  3. Vytvoříme „texturu obrázku“ a nahrajeme obrázek do GPU, aby jej mohl nakreslit na trojúhelníky.

  4. Dáme shaderu jiný seznam souřadnic, aby věděl, které obrazové pixely jsou uvnitř každého trojúhelníku.

Doufejme, že tyto koncepty můžete použít jako výchozí bod k tomu, abyste s WebGL udělali něco opravdu skvělého a užitečného.

I když nakonec použijete knihovnu, která vám pomůže s vaším kódem WebGL, považuji pochopení nezpracovaných volání API v zákulisí za užitečné, abyste věděli, co se skutečně děje, zvláště když se něco pokazí.

Začínáme s WebGL

Chcete-li v prohlížeči používat WebGL, budete muset přidat <canvas> tag na stránku. S plátnem můžete buď kreslit pomocí 2D Canvas API, nebo si můžete vybrat použití 3D WebGL API, buď verze 1 nebo 2. (Vlastně nechápu rozdíl mezi WebGL 1 a 2, ale chtěl bych rád bych se o tom jednoho dne dozvěděl více. Kód a koncepty, o kterých zde budu diskutovat, se však vztahují na obě verze.)

Pokud chcete, aby vaše plátno vyplnilo výřez, můžete začít s tímto jednoduchým HTML:

<!doctype html>
<html lang="en">
    <meta charset="UTF-8">
    <title>WebGL</title>
    <style>
        html, body, canvas {
            width: 100%;
            height: 100%;
            border: 0;
            padding: 0;
            margin: 0;
            position: absolute;
        }
    </style>
    <body>
        <canvas></canvas>
        <script></script>
    </body>
</html>

To vám dá prázdnou, bílou, zbytečnou stránku. K oživení budete potřebovat nějaký JavaScript. Uvnitř <script> přidejte tyto řádky, abyste získali přístup k rozhraní WebGL API pro plátno:

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

Psaní svého prvního programu shader WebGL

WebGL je založen na OpenGL a používá stejný jazyk shaderu. Je to tak, shader programy jsou psány v jejich vlastním jazyce, GLSL, což je zkratka pro Graphics Library Shader Language.

GLSL mi připomíná C nebo JavaScript, ale má své vlastní zvláštnosti a je velmi omezený, ale také velmi výkonný. Skvělé na tom je, že běží přímo na GPU místo v CPU. Dokáže tedy velmi rychle dělat věci, které běžné CPU programy nezvládnou. Je optimalizován pro práci s matematickými operacemi pomocí vektorů a matic. Pokud si pamatujete svou maticovou matematiku z hodin algebry, dobře pro vás! Pokud ne, je to v pořádku! Pro tento článek to stejně nebudete potřebovat.

Budeme potřebovat dva typy shaderů:vertex shadery a fragment shadery. Vertex shadery mohou provádět výpočty, aby zjistili, kam každý vrchol (roh trojúhelníku) jde. Shadery fragmentů zjistí, jak obarvit každý fragment (pixel) uvnitř trojúhelníku.

Tyto dva shadery jsou podobné, ale dělají různé věci v různých časech. Nejprve se spustí vertex shader, aby zjistil, kam každý trojúhelník vede, a poté může předat nějaké informace dál do fragment shaderu, takže fragment shader může zjistit, jak malovat každý trojúhelník.

Dobrý den, světe vertex shaderů!

Zde je základní vertex shader, který bude mít vektor se souřadnicí x,y. Vektor je v podstatě jen pole s pevnou délkou. A vec2 je pole se 2 čísly a vec4 je pole se 4 čísly. Tento program tedy vezme globální proměnnou "atributu", vec2 nazvanou "body" (což je název, který jsem si vymyslel).

GPU pak sdělí, že přesně tam bude vertex směřovat tím, že jej přiřadí jiné globální proměnné zabudované do GLSL nazvané gl_Position .

Poběží pro každou dvojici souřadnic, pro každý roh každého trojúhelníku a points bude mít pokaždé jinou hodnotu x,y. Později uvidíte, jak tyto souřadnice definujeme a předáváme.

Zde je naše první "Ahoj, světe!" program vertex shader:

attribute vec2 points;

void main(void) {
    gl_Position = vec4(points, 0.0, 1.0);
}

Nebyl zde zapojen žádný výpočet, kromě toho, že jsme potřebovali změnit vec2 na vec4. První dvě čísla jsou x a y, třetí je z, které prostě nastavíme na 0,0, protože kreslíme 2-rozměrný obrázek a nemusíme se starat o třetí rozměr. (Nevím, jaká je čtvrtá hodnota, ale prostě jsme ji nastavili na 1.0. Z toho, co jsem četl, si myslím, že to má něco společného se zjednodušením maticové matematiky.)

Líbí se mi, že v GLSL jsou vektory základním datovým typem a můžete snadno vytvářet vektory pomocí jiných vektorů. Řádek výše bychom mohli napsat takto:

gl_Position = vec4(points[0], points[1], 0.0, 1.0);

ale místo toho jsme byli schopni použít zkratku a prostě předat body2 jako první argument a GLSL přišla na to, co dělat. Připomíná mi to použití operátoru spread v JavaScriptu:

// javascript
gl_Position = [...points, 0.0, 1.0];

Pokud by tedy jeden z našich rohů trojúhelníku měl x 0,2 a y 0,3, náš kód by efektivně dělal toto:

gl_Position = vec4(0.2, 0.3, 0.0, 1.0);

ale nemůžeme takto napevno zakódovat souřadnice x a y do našeho programu, jinak by všechny trojúhelníky byly pouze jediným bodem na obrazovce. Místo toho používáme atribut vector, takže každý roh (nebo vrchol) může být na jiném místě.

Vybarvování našich trojúhelníků pomocí shaderu fragmentů

Zatímco vertex shadery běží jednou pro každý roh každého trojúhelníku, fragment shadery běží jednou pro každý barevný pixel uvnitř každého trojúhelníku.

Zatímco vertex shadery definují polohu každého vertexu pomocí globální proměnné vec4 nazvané gl_Position , shadery fragmentů fungují tak, že definují barvu každého pixelu pomocí jiné globální proměnné vec4 s názvem gl_FragColor . Zde je návod, jak můžeme vyplnit všechny naše trojúhelníky červenými pixely:

void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

Vektor pro barvu je zde RGBA, tedy číslo mezi 0 a 1 pro každou z červené, zelené, modré a alfa. Takže výše uvedený příklad pouze nastaví každý fragment nebo pixel na jasně červenou s plnou neprůhledností.

Přístup k obrázku uvnitř vašich shaderů

Normálně byste nevyplnili všechny své trojúhelníky stejnou plnou barvou, takže místo toho chceme, aby fragment shader odkazoval na obrázek (nebo "texturu") a vytáhl správnou barvu pro každý pixel uvnitř našich trojúhelníků.

Potřebujeme získat přístup jak k textuře s informacemi o barvě, tak i k některým „souřadnicím textur“, které nám říkají, jak se obrázek mapuje na tvary.

Nejprve upravíme vertex shader pro přístup k souřadnicím a předáme je fragment shaderu:

attribute vec2 points;
attribute vec2 texture_coordinate;

varying highp vec2 v_texture_coordinate;

void main(void) {
    gl_Position = vec4(points, 0.0, 1.0);
    v_texture_coordinate = texture_coordinate;
}

Pokud jste jako já, pravděpodobně se obáváte, že budou potřeba nejrůznější bláznivé trigonometrie, ale nebojte se – díky kouzlu GPU se to ukazuje jako ta nejjednodušší část.

Vezmeme jednu souřadnici textury pro každý vrchol, ale pak ji předáme shaderu fragmentů v varying proměnná, která bude "interpolovat" souřadnice pro každý fragment nebo pixel. Toto je v podstatě procento podél obou rozměrů, takže pro každý konkrétní pixel uvnitř trojúhelníku budeme přesně vědět, který pixel obrázku vybrat.

Obrázek je uložen v 2-rozměrné proměnné vzorníku nazvané sampler . Obdržíme varying souřadnice textury z vertex shaderu a použijte funkci GLSL nazvanou texture2D k navzorkování příslušného jednotlivého pixelu z naší textury.

Zní to složitě, ale ukázalo se, že je to super snadné díky kouzlu GPU. Jediná část, kde musíme provést nějakou matematiku, je přiřadit každou vrcholovou souřadnici našich trojúhelníků se souřadnicemi našeho obrázku a později uvidíme, že se to ukáže být docela snadné.

precision highp float;
varying highp vec2 v_texture_coordinate;
uniform sampler2D sampler;

void main() {
    gl_FragColor = texture2D(sampler, v_texture_coordinate);
}

Kompilace programu se dvěma shadery

Právě jsme se podívali na to, jak napsat dva různé shadery pomocí GLSL, ale nemluvili jsme o tom, jak byste to udělali v JavaScriptu. Tyto GLSL shadery jednoduše potřebujete dostat do řetězců JavaScriptu a pak je můžeme pomocí WebGL API zkompilovat a umístit na GPU.

Někteří lidé rádi vkládají zdrojový kód shaderu přímo do HTML pomocí značek skriptu jako <script type="x-shader/x-vertex"> a poté vytáhněte kód pomocí innerText . Můžete také umístit shadery do samostatných textových souborů a načíst je pomocí fetch . Cokoli vám vyhovuje.

Považuji za nejjednodušší napsat zdrojový kód shaderu přímo v JavaScriptu pomocí řetězců šablon. Tady je to, jak to vypadá:

const vertexShaderSource = `
    attribute vec2 points;
    attribute vec2 texture_coordinate;

    varying highp vec2 v_texture_coordinate;

    void main(void) {
        gl_Position = vec4(points, 0.0, 1.0);
        v_texture_coordinate = texture_coordinate;
    }
`;

const fragmentShaderSource = `
    precision highp float;
    varying highp vec2 v_texture_coordinate;
    uniform sampler2D sampler;

    void main() {
        gl_FragColor = texture2D(sampler, v_texture_coordinate);
    }
`;

Dále musíme vytvořit GL „program“ a přidat do něj tyto dva různé shadery takto:

// create a program (which we'll access later)
const program = gl.createProgram();

// create a new vertex shader and a fragment shader
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

// specify the source code for the shaders using those strings
gl.shaderSource(vertexShader, vertexShaderSource);
gl.shaderSource(fragmentShader, fragmentShaderSource);

// compile the shaders
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);

// attach the two shaders to the program
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);

Nakonec musíme říct GL, aby propojila a použila program, který jsme právě vytvořili. Všimněte si, že můžete používat pouze jeden program najednou:

gl.linkProgram(program);
gl.useProgram(program);

Pokud se s naším programem něco pokazilo, měli bychom chybu zaznamenat do konzole. Jinak tiše selže:

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program));
}

Jak můžete vidět, WebGL API je velmi podrobné. Ale když si pozorně prohlédnete tyto řádky, uvidíte, že nedělají nic moc překvapivého. Tyto kusy kódu jsou ideální pro kopírování a vkládání, protože je těžké si je zapamatovat a jen zřídka se mění. Jediná část, kterou možná budete muset změnit, je zdrojový kód shaderu v řetězcích šablony.

Kreslení trojúhelníků

Nyní, když máme náš program celý zapojený, je čas zadat mu nějaké souřadnice a přimět ho, aby na obrazovce nakreslil nějaké trojúhelníky!

Nejprve musíme pochopit výchozí souřadnicový systém pro WebGL. Je to docela odlišné od vašeho běžného pixelového souřadnicového systému na obrazovce. Ve WebGL je střed plátna 0,0, vlevo nahoře je -1,-1 a vpravo dole je 1,1.

Pokud chceme vykreslit fotografii, potřebujeme mít obdélník. WebGL ale umí pouze kreslit trojúhelníky. Jak tedy nakreslíme obdélník pomocí trojúhelníků? K vytvoření obdélníku můžeme použít dva trojúhelníky. Budeme mít jeden trojúhelník pokrývající levý horní roh a další v pravém dolním rohu, takto:

Chcete-li nakreslit trojúhelníky, budeme muset určit, kde jsou souřadnice tří rohů každého trojúhelníku. Vytvořme pole čísel. Souřadnice x a y obou trojúhelníků budou všechny v jednom poli, jako je toto:

const points = [
    // first triangle
    // top left
    -1, -1,

    // top right
    1, -1,

    // bottom left
    -1, 1,

    // second triangle
    // bottom right
    1, 1,

    // top right
    1, -1,

    // bottom left
    -1, 1,
];

Abychom mohli předat seznam čísel do našeho shader programu, musíme vytvořit "vyrovnávací paměť", pak načíst pole do vyrovnávací paměti a poté říci WebGL, aby použila data z vyrovnávací paměti pro atribut v našem shader programu.

Pole JavaScriptu nemůžeme jen načíst do GPU, musí být striktně napsáno. Takže to zabalíme do Float32Array . Mohli bychom také použít celá čísla nebo jakýkoli typ, který dává smysl pro naše data, ale pro souřadnice dávají největší smysl plováky.

// create a buffer
const pointsBuffer = gl.createBuffer();

// activate the buffer, and specify that it contains an array
gl.bindBuffer(gl.ARRAY_BUFFER, pointsBuffer);

// upload the points array to the active buffer
// gl.STATIC_DRAW tells the GPU this data won't change
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);

Pamatujte, že jsem vytvořil atribut nazvaný "body" v horní části našeho shader programu s řádkem attribute vec2 points; ? Nyní, když jsou naše data ve vyrovnávací paměti a vyrovnávací paměť je aktivní, můžeme vyplnit atribut „body“ souřadnicemi, které potřebujeme:

// get the location of our "points" attribute in our shader program
const pointsLocation = gl.getAttribLocation(program, 'points');

// pull out pairs of float numbers from the active buffer
// each pair is a vertex that will be available in our vertex shader
gl.vertexAttribPointer(pointsLocation, 2, gl.FLOAT, false, 0, 0);

// enable the attribute in the program
gl.enableVertexAttribArray(pointsLocation);

Načtení obrázku do textury

Ve WebGL jsou textury způsob, jak poskytnout hromadu dat v mřížce, kterou lze použít k malování pixelů na tvary. Obrázky jsou zřejmým příkladem, jsou to mřížka hodnot červené, modré, zelené a alfa podél řádků a sloupců. Ale můžete použít textury pro věci, které vůbec nejsou obrázky. Stejně jako všechny informace v počítači to nakonec není nic jiného než seznamy čísel.

Protože jsme v prohlížeči, můžeme k načtení obrázku použít běžný kód JavaScript. Jakmile se obrázek načte, použijeme jej k vyplnění textury.

Pravděpodobně nejjednodušší je nejprve načíst obrázek, než uděláme jakýkoli kód WebGL, a poté spustit celou inicializaci WebGL po načtení obrázku, takže nemusíme na nic čekat, jako je toto:

const img = new Image();
img.src = 'photo.jpg';
img.onload = () => {
    // assume this runs all the code we've been writing so far
    initializeWebGLStuff();
};

Nyní, když se náš obrázek načetl, můžeme vytvořit texturu a nahrát do ní data obrázku.

// create a new texture
const texture = gl.createTexture();

// specify that our texture is 2-dimensional
gl.bindTexture(gl.TEXTURE_2D, texture);

// upload the 2D image (img) and specify that it contains RGBA data
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);

Vzhledem k tomu, že náš obrázek pravděpodobně není čtverec s mocninou dvou rozměrů, musíme také WebGL sdělit, jak vybrat, které pixely má kreslit při zvětšování nebo zmenšování našeho obrázku, jinak vyhodí chybu.

// tell WebGL how to choose pixels when drawing our non-square image
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);

// bind this texture to texture #0
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);

Nakonec chceme získat přístup k této struktuře v našem shader programu. Definovali jsme 2-rozměrnou uniformní proměnnou vzorníku s řádkem uniform sampler2D sampler; , tak řekněme GPU, že by k tomu měla sloužit naše nová textura.

// use the texture for the uniform in our program called "sampler",
gl.uniform1i(gl.getUniformLocation(program, 'sampler'), 0);

Malování trojúhelníků obrázkem pomocí souřadnic textur

Už jsme skoro hotovi! Další krok je velmi důležitý. Musíme našim shaderům říct, jak a kde by měl být náš obrázek namalován na naše trojúhelníky. Chceme, aby levý horní roh našeho obrázku byl namalován v levém horním rohu našeho levého horního trojúhelníku. A tak dále.

Textury obrázků mají jiný souřadnicový systém než naše trojúhelníky, takže na to musíme trochu myslet a bohužel nemůžeme použít úplně stejné souřadnice. Zde je návod, jak se liší:

Souřadnice textury by měly být v přesně stejném pořadí jako naše souřadnice vrcholů trojúhelníku, protože tak se budou společně zobrazovat ve vertex shaderu. Protože náš vertex shader běží pro každý vertex, bude mít také přístup ke každé souřadnici textury a předá ji do shaderu fragmentu jako varying proměnná.

Použijeme téměř stejný kód, jaký jsme použili k nahrání našeho pole souřadnic trojúhelníku, až na to, že jej nyní spojíme s atributem s názvem "souřadnice_textury".

const textureCoordinates = [
    // first triangle
    // top left
    0, 1,

    // top right
    1, 1,

    // bottom left
    0, 0,

    // second triangle
    // bottom right
    1, 0,

    // top right
    1, 1,

    // bottom left
    0, 0,
];

// same stuff we did earlier, but passing different numbers
const textureCoordinateBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordinateBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoordinates), gl.STATIC_DRAW);

// and associating it with a different attribute
const textureCoordinateLocation = gl.getAttribLocation(program, 'texture_coordinate');
gl.vertexAttribPointer(textureCoordinateLocation, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(textureCoordinateLocation);

Poslední krok nakreslete trojúhelníky

Nyní, když máme naše shadery a všechny naše souřadnice a náš obrázek načteny do GPU, jsme připraveni skutečně spustit náš shader program a nechat jej vykreslit náš obrázek na plátno.

K tomu potřebujeme pouze jeden řádek kódu:

gl.drawArrays(gl.TRIANGLES, 0, 6);

To říká WebGL, aby kreslilo trojúhelníky pomocí našeho pole bodů a pole souřadnic textur. Číslo 6 zde znamená, že každých 6 čísel v našich polích definuje jeden trojúhelník. Každý trojúhelník má 3 rohy se souřadnicemi x a y spojenými s každým rohem (nebo vrcholem).

Jen začátek?

Není to úžasné, kolik různých věcí se musíte naučit nakreslit obrázek pomocí GPU? Zjistil jsem, že je to obrovská křivka učení, ale jakmile jsem si zamotal hlavu nad tím, co shadery vlastně dělají, co jsou textury a jak poskytnout shaderům nějaké seznamy čísel a jak to všechno do sebe zapadá, začalo to dávat smysl a Uvědomil jsem si, jak mocné to všechno je.

Doufám, že se vám podařilo zahlédnout trochu té jednoduchosti a síly. Vím, že rozhraní WebGL API může být velmi bolestně podrobné, a stále si nejsem úplně jistý, co přesně každá funkce dělá, a rozhodně je to pro mě nové programovací paradigma, protože GPU je tak odlišné od CPU, ale to je to, co to dělá. tak vzrušující.

Máte zájem o vývoj webových aplikací? Přihlaste se k odběru newsletteru Coding with Jesse!