Shadertoy - medzi matematikou a umením (úvod)

28.08.2022 | 10:12 | Mirecove dristy | Miroslav Bendík
Seriál: Shadertoy

Umenie často nie je len o výsledku, ale aj o spôsobe realizácie a obmedzeniach, ktoré sa museli brať do úvahy. Shadertoy je nástroj, ktorý umožňuje vytvárať obraz a zvuk pomocou malých programov bežiacich na GPU s dosť prísnymi obmedzeniami.

Článok začínam popisom fungovania GPU a ukážkou OpenGL. Pre tých, ktorí poznajú základy OpenGL, alebo ich OpenGL nezaujíma odporúčam rovno preskočiť na kapitolu čo je shadertoy.

OpenGL

Prvá verzia knižnice OpenGL vyvinutej v spoločnosti SGI bola vydaná v roku 1992. API rozhranie je navrhnuté ako stavový automat. Preto sa pred vykreslením najskôr nastavujú atribúty (ako napríklad farba), ktoré budú aplikované na nasledujúce príkazy vykreslenia.

Ahoj obdĺžnik

Začnem najskôr Makefile súborom. Ten stačí umiestniť do adresára s príkladmi a na kompiláciu príkladov slúži príkaz make spustený v adresári s príkladmi. Súbor vyzerá nasledovne (pozor, riadky musia byť odsadené tabulátorom).

.PHONY: all

CC=gcc
CFLAGS=-Wall -Wextra -Werror --std=c99
LDFLAGS=-lGL -lglut -lGLEW
SRCS := $(shell find . -name "*.c")
OBJS := $(addsuffix .o,$(basename $(SRCS)))
BINS := $(addsuffix .bin,$(basename $(SRCS)))

%.o: %.c
	$(CC) -c -o $@ $< $(CFLAGS)

%.bin: %.o
	$(CC) -o $@ $< $(CFLAGS) $(LDFLAGS)

all: $(BINS)

Vykreslenie obdĺžnika s rozdielnou farbou v každom rohu je v OpenGL (na rozdiel od Vulkan API) je veľmi jednoduché:

#include <GL/glut.h>


// Zobrazenie obdĺžnika
void display() {
    // Vyčistenie pozadia
    glClear(GL_COLOR_BUFFER_BIT);

    // Vykreslenie jednotlivých bodov. GPU vie vykresľovať iba trojuholníky,
    // preto sa obdĺžnik vykreslí ako 2 trojuholníky. OpenGL je implementované ako
    // stavový automat, prto sa určuje farba vertexu pred odoslaním do GPU.
    glBegin(GL_TRIANGLE_STRIP);
        glColor3f(1, 0, 0); glVertex3f(-0.9, -0.9, 0);
        glColor3f(0, 1, 0); glVertex3f(-0.9,  0.9, 0);
        glColor3f(0, 0, 1); glVertex3f( 0.9, -0.9, 0);
        glColor3f(1, 1, 1); glVertex3f( 0.9,  0.9, 0);
    glEnd();

    // Ukončenie definície objektov a vykreslenie na obrazovku
    glFlush();
}

// Inicializácia OpenGL a hlavnej slučky
int main(int argc, char** argv) {

    // Režim RGB bez double bufferu
    glutInit(&argc, argv);
    glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB);

    // Vytvorenie okna
    glutInitWindowSize(400, 300);
    glutCreateWindow("Okno");

    // Zaregistrovanie funkcie, ktorá vykreslí obsah okna
    glutDisplayFunc(display);

    // Hlavná slučka, ktorá bude bežať až kým užívateľ nezatvorí okno
    glutMainLoop();
}

Zdrojový kód jednoduchy_obdlznik.c stačí stiahnuť, umiestniť do adresára s Makefile, spustiť make a nakoniec spustiť ./jednoduchy_obdlznik.bin.

Vykreslené okno
Obrázok 1: Vykreslené okno

Do pozornosti dávam funkciu display, v ktorej prebieha vykresľovanie. Grafická karta vie zvyčajne zobraziť pixel, čiaru, alebo trojuholník. Akýkoľvek polygón s väčším počtom vrcholov sa dá poskladať z trojuholníkov, preto nie je dôvod, aby grafické karty vedeli vykresľovať zložitejšie polygóny.

Trojuholníky sú vykreslené pomocou 4 vertexov (bod v priestore definovaný súradnicami). Dva trojuholníky sa skladajú zo 6 vertexov, ale pretože majú spoločnú hranu, dajú sa do GPU nahrať ako triangle strip.

Farby sú definované trojicou hodnôt s plávajúcou desatinnou čiarkou v rozsahu 0 (čierna) až 1 (maximálna hodnota danej farebnej zložky).

Nie je žiadnym prekvapením, že koordináty vertexov sú definované 3 súradnicami: x, y, a z. Knižnica OpenGL vykreslí trojuholníky nachádzajúce sa v obdĺžniku so súradnicami od -1 po +1 na x aj y osi. Tretia súradnica (hĺbka) sa používa na určenie viditeľnosti. Obdĺžnik v mojom príklade je zobrazený od súradnice -0.9 po 0.9 (teda 5% vzdialenosť od každého okraja).

Súradnicový systém
Obrázok 2: Súradnicový systém

Prvé grafické akcelerátory dokázali prakticky len transformovať 3D scénu skladajúcu sa z trojuholníkov do 2D (čo je podstatne jednoduchšia operácia, než si väčšina ľudí predstavuje) a vykresliť trojuholníky. Moderné grafické akcelerátory sa prakticky vrátili k pôvodnej funkcii - teda vykresľovaniu trojuholníkov. Medzitým bolo samozrejme divoké obdobie, kedy sa výrobcovia snažili naučiť svoje GPU všetko možné (a nemyslím len šialenosti ako NV_path_rendering).

Osvetlenie

V prvom príklade som sa vôbec nestaral o osvetlenie. Jednoducho som vykreslil obdĺžnik pevne danou farbou ako za starých čias s prvými grafickými akcelerátormi. Prvé grafické akcelerátory fungovali presne takto a preto sa osvetlenie buď muselo počítať na CPU, alebo sa pripravili priamo textúry s určitým osvetlením. Výpočet osvetlenia bola tak užitočná funkcia, že výrobcovia GPU začali pridávať hardvérovú podporu výpočtu.

Nasledujúci príklad zobrazí rovnaký obdĺžnik, ale so svetlom umiestneným v pravom hornom rohu. Na výpočet osvetlenia je použitý Phongov osvetľovací model.

#include <GL/glut.h>

// Zobrazenie obdĺžnika
void display() {
    // Vyčistenie pozadia
    glClear(GL_COLOR_BUFFER_BIT);

    // Zapnutie výpočtu osvetlenia
    glEnable(GL_LIGHTING);
    glEnable(GL_LIGHT0);

    // Nastavenie ambientného osvetlenia intenzity 0.1, difúzneho 1.0
    // a umiestnenie svetla do pravého horného rohu
    GLfloat light_ambient[] = {0.1, 0.1, 0.1, 1.0};
    GLfloat light_diffuse[] = {1.0, 1.0, 1.0, 1.0};
    GLfloat light_specular[] = {0.0, 0.0, 0.0, 1.0};
    GLfloat light_position[] = {0.9, 0.9, 0.5, 1.0};

    glLightfv(GL_LIGHT0, GL_AMBIENT, light_ambient);
    glLightfv(GL_LIGHT0, GL_DIFFUSE, light_diffuse);
    glLightfv(GL_LIGHT0, GL_SPECULAR, light_specular);
    glLightfv(GL_LIGHT0, GL_POSITION, light_position);

    // Útlm počítaný z prevrátených štvorcov
    glLightf(GL_LIGHT0, GL_CONSTANT_ATTENUATION, 2.0);
    glLightf(GL_LIGHT0, GL_LINEAR_ATTENUATION, 1.0);
    glLightf(GL_LIGHT0, GL_QUADRATIC_ATTENUATION, 0.5);

    GLfloat material_specular[] = {0.0, 0.0, 0.0, 1.0};
    GLfloat material_shininess[] = {50.0};
    glMaterialfv(GL_FRONT, GL_SPECULAR, material_specular);
    glMaterialfv(GL_FRONT, GL_SHININESS, material_shininess);

    // Vykreslenie trojuholníkov s nastavením farby pre odraz ambientného
    // a difúzneho svetla
    glBegin(GL_TRIANGLE_STRIP);
        GLfloat color1[] = {1.0, 0.0, 0.0, 1.0};
        glMaterialfv(GL_FRONT, GL_AMBIENT, color1);
        glMaterialfv(GL_FRONT, GL_DIFFUSE, color1);
        glVertex3f(-0.9, -0.9, 0);

        GLfloat color2[] = {0.0, 1.0, 0.0, 1.0};
        glMaterialfv(GL_FRONT, GL_AMBIENT, color2);
        glMaterialfv(GL_FRONT, GL_DIFFUSE, color2);
        glVertex3f(-0.9, 0.9, 0);

        GLfloat color3[] = {0.0, 0.0, 1.0, 1.0};
        glMaterialfv(GL_FRONT, GL_AMBIENT, color3);
        glMaterialfv(GL_FRONT, GL_DIFFUSE, color3);
        glVertex3f(0.9, -0.9, 0);

        GLfloat color4[] = {1.0, 1.0, 1.0, 1.0};
        glMaterialfv(GL_FRONT, GL_AMBIENT, color4);
        glMaterialfv(GL_FRONT, GL_DIFFUSE, color4);
        glVertex3f(0.9, 0.9, 0);
    glEnd();

    // Ukončenie definície objektov a vykreslenie na obrazovku
    glFlush();
}

int main(int argc, char** argv) {
    glutInit(&argc, argv);
    glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB);

    glutInitWindowSize(400, 300);
    glutCreateWindow("Okno");

    glutDisplayFunc(display);

    glutMainLoop();
}

Súbor phong.c sa dá skompilovať a spustiť rovnakým spôsobom, ako predchádzajúci príklad.

Obdĺžnik s výpočtom osvetlenia
Obrázok 3: Obdĺžnik s výpočtom osvetlenia

V modeli je definované jediné svetlo LIGHT0. OpenGL podporuje „až“ 8 rôznych zdrojov svetla. Pre svetlo sa definujú parametre ambientnej zložky (svetlo, ktorým sú osvetlené všetky časti modelu rovnomerne bez ohľadu na smer a vzdialenosť svetla), difúznej zložky (svetlo, ktoré sa rozptýli na povrchu) odrazov (specular). Obdobné vlastnosti sa definujú aj pre materiál objektu. Celkové osvetlenie sa vypočíta ako súčet ambientnej zložky, difúznej zložky a odlesku.

Zložky Phongovho osvetľovacieho modelu
Obrázok 4: Zložky Phongovho osvetľovacieho modelu
Vzorec 1 Výpočet finálnej farby

Objekt osvetlený Phongovým osvetľovacím modelom má zvyčajne plastický vzhľad. Pomerne dlhú dobu to bol jediný rozumný model podporovaný GPU. Požiadavky na vyššiu realistickosť donútili výrobcov vymyslieť niečo ďalšie.

Grafické karty boli rozšírené o technológie ako shadow mapping, bump mapping a rôzne rozšírenia špecifické pre konkrétneho výrobcu.

Na zobrazenie realistických materiálov to aj tak nestačilo. Skutočné materiály reagujú na svetlo mnohými spôsobmi. Odporúčam si len tak zbežne pozrieť tento dokument s popisom fyzikálne realistického renderingu. Bolo by ťažko predstaviteľné integrovať do GPU všetky možné spôsoby interakcie objektu so svetlom (napríklad metalické odlesky, odlesky od CD/DVD, šírenie svetla tesne pod povrchom atď). Čo tak dať programátorovi možnosť napísať si vlastný vzorec namiesto tých preddefinovaných?

Shader

Okolo roku 2000 začali prichádzať na trh grafické karty s možnosťou definovať vstupné dáta a operácie nad nimi. Najskôr to boli proprietárne rozšírenia, napríklad NV_register_combiners (GeForce 256), NV_texture_shader (GeForce 3), NV_texture_shader3 (GeForce 4), NV_fragment_program (GeForce FX).

Nie len, že každý výrobca si implementoval programovateľnú časť sám, ale dokonca aj každá generácia GPU sa programovala vlastným nekompatibilným assemblerom až kým v roku 2002 nebol prijatý štandard ARB_vertex_program a ARB_fragment_program. Jazyk bol podobný assembleru, aj keď so zásadnými obmedzeniami. Neobsahoval napríklad vetvenie, ani slučky.

Zastavím sa trochu pri slove vertex a fragment. Vertex shader je program, ktorý sa spustí pre každý bod v priestore (vertex). Čo je ale fragment? V DirectX existujú vertex a pixel shadery. V OpenGL sú ekvivalentom vertex a fragment shadery. Fragment shader je malý program, ktorý sa vykoná pri vykreslení každého pixelu. Prečo teda nie je pomenovaný jednoducho pixel shader? Shadery v DirectX sú pomenované skôr podľa ich výstupu, zatiaľ čo v OpenGL sú pomenované podľa vstupu (vertex shader mení pozíciu vertexov, fragment shader má na vstupe atribúty postačujúce na vykreslenie - tzv. fragmenty a výstupom je pixel).

Ťažko môžme hovoriť o skutočnom programovaní, keď jazyk nepodporuje cykly a vetvenie. To priniesol až v roku 2004 jazyk OpenGL Shading Language (GLSL). Je to vysokoúrovňový jazyk silne inšpirovaný jazykom C. Práve v ňom sa programujú shadery pre shadertoy.

Programovanie v GLSL má určité obmedzenia a vlastnosti vyplývajúce zo spôsobu vykonávania programu. Každý beh (spustenie) nad jedným elementom je nezávislé od všetkých ostatných elementov a nemôže ich ovplyvniť (jeden pixel nemôže ovplyvniť susedný). Pamäť alokovaná pri vykonávaní je samostatná pre každý element. Spustenie nemôže mať žiaden vedľajší efekt. V tomto sa podobá striktne funkcionálnym jazykom.

Striktné požiadavky sú nutné pre to, aby sa dali shadery spustiť masívne paralelne. Také 4k rozlíšenie má 8 294 400 individuálnych pixelov. Pri obnovovacej frekvencii 60 Hz je potrebné vykresliť 497 664 000 pixelov za sekundu. Iná cesta, než masívny paralelizmus tu nie je.

Na úplne paralelné spustenie obrovského množstva programov by GPU potrebovala desaťtisíce univerzálnych jadier. V skutočnosti sa pre dosiahnutie podobného výsledku používa jeden trik. Namiesto jadra pre každý pixel (alebo ľubovoľný element) tu jedno jadro obsluhuje blok o veľkosti najčastejšie 8x8 alebo 8x16 elementov. Program sa vykonáva inštrukciu za inštrukciou pre celý blok pixelov. Čo sa však stane ak program obsahuje podmienku, ktorá sa má vykonať pre polovicu pixelov? V tom prípade sa najskôr nastaví maska pixelov pre jednu vetvu, vykonajú sa všetky inštrukcie vetvy a následne sa zmení maska na druhú vetvu a vykonajú sa zvyšné inštrukcie. Vykonávanie vetiev je operácia, ktorá dokáže v najhoršom prípade pri veľkosti bloku 8x8 pixelov spomaliť výpočet na 1/64 pôvodnej rýchlosti (ak sa výpočet každého elementu musí vykonať v samostatnej vetve).

Obdĺžnik s „moderným“ OpenGL

Predchádzajúci príklad používal API z OpenGL 1.0. Nahrávanie modelu po jednotlivých vertexoch je možno akceptovateľné pri softvérovom renderingu, alebo pri prvých GPU, ktoré dokázali vykresliť pár trojuholníkov. V modernom OpenGL je lepšie minimalizovať volania, ktoré nahrávajú dáta do GPU. Nasledujúci príklad moderny_obdlznik.c vykreslí rovnaký obdĺžnik, ako v prvom príklade, ale tentoraz pomocou shaderov.

#include <GL/glew.h>
#include <GL/freeglut.h>
#include <GL/glut.h>
#include <stdio.h>

// Súradnice vertexov a farby
static const GLfloat vertexBufferData[] = {
    -0.9f, -0.9f, 1, 0, 0,
    -0.9f,  0.9f, 0, 1, 0,
     0.9f, -0.9f, 0, 0, 1,
     0.9f,  0.9f, 1, 1, 1,
};
GLuint vertexBuffer;

// Kód vertex a fragment shadera
static const char vertexShader[] = "#version 330 core\n\
\n\
layout(location = 0) in vec2 position;\n\
layout(location = 1) in vec3 colorAttribute;\n\
\n\
out vec3 fragmentColor;\n\
\n\
void main(){\n\
    gl_Position = vec4(position, 0.0, 1.0);\n\
    fragmentColor = colorAttribute;\n\
}\n\
";
static const char fragmentShader[] = "#version 330 core\n\
in vec3 fragmentColor;\n\
\n\
out vec4 color;\n\
\n\
void main()\n\
{\n\
    color = vec4(fragmentColor, 1.0);\n\
}\n\
";
GLuint shaderProgram;

// Výpis chyby kompilácie ak shader nebol skompilovaný
void printShaderStatus(GLuint shader) {
    GLint res = GL_FALSE;
    int infoLength;

    glGetShaderiv(shader, GL_COMPILE_STATUS, &res);
    glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &infoLength);
    if (res != GL_TRUE) {
        char *msg = malloc(infoLength + 1);
        glGetShaderInfoLog(shader, infoLength, NULL, msg);
        printf("%s", msg);
        free(msg);
    }
}

// Výpis chyby linkovania ak sa program nepodarilo zlinkovať
void printProgramStatus(GLuint program) {
    GLint res = GL_FALSE;
    int infoLength;

    glGetProgramiv(program, GL_LINK_STATUS, &res);
    glGetProgramiv(program, GL_INFO_LOG_LENGTH, &infoLength);
    if (res != GL_TRUE) {
        char *msg = malloc(infoLength + 1);
        glGetProgramInfoLog(program, infoLength, NULL, msg);
        printf("%s", msg);
        free(msg);
    }
}

// Kompilácia ľubovoľného druhu shadera
void compileShader(GLuint shader, const char *code) {
    glShaderSource(shader, 1, &code, NULL);
    glCompileShader(shader);
    printShaderStatus(shader);
}

// Kompilácia shaderov a zlinkovanie do programu
GLuint getShaderProgram(const char *vertexShaderSource, const char *fragmentShaderSource) {
    // Vytvorenie programu
    GLuint program = glCreateProgram();
    // Vytvorenie inštancií shaderov
    GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
    GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    // Kompilácia oboch zdrojových kódov
    compileShader(vertexShader, vertexShaderSource);
    compileShader(fragmentShader, fragmentShaderSource);
    // Priradenie shaderov k jednému programu
    glAttachShader(program, vertexShader);
    glAttachShader(program, fragmentShader);
    // Zlinkovanie programu a výpis chyb ak nebolo linkovanie úspešné
    glLinkProgram(program);
    printProgramStatus(program);
    // Ďalej shader nnemusí byť priradený k programu
    glDetachShader(program, vertexShader);
    glDetachShader(program, fragmentShader);
    return program;
}


// Zobrazenie obdĺžnika
void display() {
    // Vyčistenie pozadia
    glClear(GL_COLOR_BUFFER_BIT);

    // Pri vykresľovaní sa použije shader
    glUseProgram(shaderProgram);

    // Priradenie poľa k atribútu s location(0) (position)
    glEnableVertexAttribArray(0);
    glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
    glVertexAttribPointer(
        0, // Index atribútu (zodpovedá hodnote v location)
        2, // Počet komponentov (zadávame pre každý bod iba 2 súradnice)
        GL_FLOAT, // V poli sú hodnoty s pohyblivou desatinnou čiarkou
        GL_FALSE, // Nemá význam pre typ float
        sizeof(GLfloat)*5, // Rozostup medzi prvkami
        (void *)0 // Posun voči začiatku poľa
    );

    // Priradenie poľa k atribútu s location(1) (colorAttribute)
    glEnableVertexAttribArray(1);
    glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat)*5, (void *)(sizeof(GLfloat)*2));

    // Vykreslenie objektu triangle strip zo 4 vertexov
    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

    glDisableVertexAttribArray(0);
    glDisableVertexAttribArray(1);

    // Zobrazenie na obrazovke
    glFlush();
}

// Inicializácia OpenGL a hlavnej slučky
int main(int argc, char** argv) {
    glutInit(&argc, argv);

    // Explicitne vyžadujem OpenGL 3.3 core profile
    glutInitContextVersion(3, 3);
    glutInitContextProfile(GLUT_CORE_PROFILE);

    // Režim RGBA s double bufferom
    glutInitDisplayMode(GLUT_SINGLE | GLUT_RGBA);

    // Vytvorenie okna
    glutInitWindowSize(400, 300);
    glutCreateWindow("Okno");

    // Inicializácia GLEW musí nasledovať po otvorení okna
    glewInit();

    // Aktivovaný vertex array objekt
    GLuint vertexArray;
    glGenVertexArrays(1, &vertexArray);
    glBindVertexArray(vertexArray);

    // Kompilácia
    shaderProgram = getShaderProgram(vertexShader, fragmentShader);

    // Upload vertexov a farieb do GPU
    glGenBuffers(1, &vertexBuffer);
    glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertexBufferData), vertexBufferData, GL_STATIC_DRAW);

    // Zaregistrovanie funkcie, ktorá vykreslí obsah okna
    glutDisplayFunc(display);

    // Hlavná slučka
    glutMainLoop();
}

Neplánujem tu písať tutoriál k OpenGL, takže z kódu vyberiem len pár zaujímavostí. Prvým je pole súradníc a farieb:

static const GLfloat vertexBufferData[] = {
    -0.9f, -0.9f, 1, 0, 0,
    -0.9f,  0.9f, 0, 1, 0,
     0.9f, -0.9f, 0, 0, 1,
     0.9f,  0.9f, 1, 1, 1,
};

Všetky dáta nahrávam do GPU jediným volaním glBufferData. Namiesto 3 priestorových súradníc sa posielajú do GPU len 2 súradnice, pretože sa vykresľuje 2D objekt. Chýbajúcu súradnicu sa vygenerujú priamo na GPU pomocou vertex shadera.

Vertex shader je jednoduchý program, ktorý na základe jedného, alebo viacerých vstupov vygeneruje polohu vertxu (4-rozmerný vektor), prípadne ďalšie atribúty s interpolovanou hodnotou medzi bodmi. Pre tento príklad stačí jednoduchý vertex shader:

#version 330 core

layout(location = 0) in vec2 position;
layout(location = 1) in vec3 colorAttribute;

out vec3 fragmentColor;

void main(){
    gl_Position = vec4(position, 0.0, 1.0);
    fragmentColor = colorAttribute;
}

Tento krátky kód hovorí, že má 2 vstupné atribúty. Sú nimi dvojzložkový vektor position na pozícii 0 a trojzložkový colorAttribute na pozícii 1. Výstupnú polohu vertexu reprezentuje vstavaná premenná gl_Position. Kód vec4(position, 0.0, 1.0) pridáva k dvojzložkovému vektoru position nulovú zložku pre os z a štvrtú zložku 1.0 (tá je užitočná pri operáciách s maticami). Okrem povinného gl_Position má shader aj ďalší výstupný atribút fragmentColor s interpolovanou farbou.

Fragment shader je ešte jednoduchší:

#version 330 core

in vec3 fragmentColor;

out vec4 color;

void main()
{
    color = vec4(fragmentColor, 1.0);
}

Vstupná hodnota atribútu fragmentColor sa jednoducho prekopíruje do výstupnej farby color. Dodatočná štvrtá zložka je alfa kanál (priehľadnosť).

Výsledok je rovnaký, ako v prvom príklade aj keď s podstatne zložitejším kódom.

Čo je shadertoy

Shadertoy je webová stránka, ktorá:

Shadertoy web
Obrázok 5: Shadertoy web

V predchádzajúcom texte som vytvoril jednoduchú OpenGL aplikáciu, ktorá zobrazí obdĺžnik s vlastným vertex / fragment shaderom. Súbor shader.c v príkladoch je triviálnou úpravou predchádzajúceho príkladu, akurát obdĺžnik je zobrazený na celej ploche okna a fragment shader sa načítava zo súboru. Program sa dá spustiť príkazom ./shader.bin cesta_k_shaderu.

Na začiatok skúsim jednoduchý shader, ktorý vykreslí jednoliatu červenú plochu:

#version 330 core

out vec4 color;

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

Po uložení ako red.frag a spustení ./shader.bin red.frag sa zobrazí červené okno. Direktíva #version slúži na určenie verzie verzie GLSL jazyka. Hodnota 330 znamená verziu 3.30.

Červené okno
Obrázok 6: Červené okno

Shadertoy vkladá pred užívateľský kód vlastné premenné a funkcie. Preto sa pri zápise shaderov pre shadertoy nepoužíva ako hlavná funkcia main, ale mainImage. Nasledujúci kód by vykreslí rovnaký červený obdĺžnik, ale je predelený na hlavičku, ktorú by automaticky vložil shadertoy a užívateľský kód. Po tejto úprave bude užívateľský kód fungovať ako v shadertoyi, tak aj v narýchlo pripravenom C-kóde.

#version 330 core

const vec2 iResolution = vec2(800.0, 480.0);

out vec4 outColor;

void mainImage(out vec4 fragColor, in vec2 fragCoord);

void main()
{
    mainImage(outColor, gl_FragCoord.xy);
}

// Užívateľský kód

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

Farebný obdĺžnik

Renderer shader.c vie pridať hlavičku ak v súbore chýba. Nasledujúce príklady budú preto bez hlavičiek a budú sa dať spustiť priamo bez úprav vo webovom rozhraní na shadertoy.com. Nasledujúci kód napríklad vykreslí bielu plochu:

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    fragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
Biely obdĺžnik
Obrázok 7: Biely obdĺžnik

Funkcia mainImage má ako vstupný parameter súradnice pixelu - fragCoord. Súradnicový systém začína vľavo dole v bode x, y = 0, 0 a končí v x, y = šírka - 1, výška - 1. Parameter nadobúda rovnakú hodnotu, ako globálna premenná gl_FragCoord.

Shadertoy sprístupňuje programátorovi aj rôzne parametre. Medzi ne patrí iResolution s aktuálnym rozlíšením. Je to trojzložkový vektor, obsahujúci šírku (v pixeloch), výšku (v pixeloch) a konštantu 1. Súradnice majú 3 rozmery, pretože výstupom môže byť aj volumetrická (trojrozmerná) textúra, alebo 360° textúra pre VR okuliare.

Pre tento príklad by boli omnoho vhodnejšie súradnice v rozsahu [0, 1] namiesto pixelov. Na to stačí predeliť súradnice rozlíšením okna. Nasledujúci príklad prevedie súradnice na rozsah [0, 1], vykreslí x-ovú súradnicu ako červenú farbu a y-ovú ako modrú.

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    // Dvojzložkový vektor so súradnicami v rozsahu [0, 1]
    vec2 uv = vec2(fragCoord.x / iResolution.x, fragCoord.y / iResolution.y);
    fragColor = vec4(uv.x, 0.0, uv.y, 1.0);
}
Farebný obdĺžnik
Obrázok 8: Farebný obdĺžnik

Na obrázku môžme vidieť, že úroveň červenej zložky narastá v smere zľava doprava a modrej zdola nahor (pretože počiatok súradnicovej sústavy je vľavo dole).

Do pozornosti dávam spôsob prístupu k prvkom vektoru. Vektory môžu mať maximálne 4 zložky a jednotlivé zložky sa indexujú písmenami xyzw, rgba alebo stpq. Medzi jednotlivými zápismi je len sémantický rozdiel, takže keď sa pracuje s farbami, je lepšie použiť rgba, ale program sa bude rovnako správať aj keby boli použité písmená xyzw.

Z viaczložkového vektora sa dá zápisom s bodkou vytiahnuť vektor s menším počtom zložiek, alebo so zložkami v inom poradí. Ak by som chcel získať z trojzložkového vektora iResolution dvojzložkový stačí napísať iResolution.xy.

Matematické operácie s vektormi sa vykonávajú pre každú zložku vektora, takže ak a a b sú dvojzložkové vektory, potom zápis a / b znamená vec2(a.x / b.x, a.y / b.y). Predchádzajúci kód sa dá zjednodušene zapísať ako:

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 uv = fragCoord.xy / iResolution.xy;
    fragColor = vec4(uv.x, 0.0, uv.y, 1.0);
}

V pôvodnom príklade bola zobrazená plocha so súradnicami [-1, 1]. Body obdĺžnika boli vždy vzdialené o 0,1 jednotiek od okraja. Percentuálne je to 0,1 / (1 - (-1)) = 5 % od okraja.

Najjednoduchšie je upraviť súradnice tak, aby plocha obdĺžnika bola v intervale [0, 1] a body mimo obdĺžnika môžu byť pokojne mimo interval. Aby bola lepšie viditeľná transformácia súradníc, bola zvolená šírka okraja 2 * 49 %.

const float border = 0.49; // 49 %

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 uv = (fragCoord.xy / iResolution.xy - border) / (1.0 - border * 2.0);
    fragColor = vec4(uv.x, 0.0, uv.y, 1.0);
}
Farebný obdĺžnik s upravenými súradnicami
Obrázok 9: Farebný obdĺžnik s upravenými súradnicami

Po transformácii sú súradnice nepatriace obdĺžniku mimo interval. Na pridanie čiernych okrajov stačí malý if so 4 podmienkami pre pretečenie na ľavom, pravom, dolnom a hornom okraji:

const float border = 0.05;

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 uv = (fragCoord.xy / iResolution.xy - border) / (1.0 - border * 2.0);

    // Zatemnenie okrajov pomocou podmienky if
    if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) {
        fragColor = vec4(0.0);
        return;
    }

    fragColor = vec4(uv.x, 0.0, uv.y, 1.0);
}
Farebný obdĺžnik s čiernymi okrajmi
Obrázok 10: Farebný obdĺžnik s čiernymi okrajmi

Grafické karty nemajú moc v láske podmienky. Dal by sa kód prepísať tak, aby neobsahoval podmienky?

Keby len tak existovala funkcia, ktorá vráti 1 ak vstupná hodnota prekročí nejakú hranicu a 0 v opačnom prípade. Keby tak potom existovala matematická operácia zodpovedajúca logickému súčinu (AND) …

Funkcia, ktorá vráti 1.0 ak hodnota prekročí hranicu samozrejme existuje a volá sa step(edge, x).

Priebeh funkcie step s edge nastaveným na 0.5
Obrázok 11: Priebeh funkcie step s edge nastaveným na 0.5

Teraz stačí zavolať 4x funkciu step tak, aby vrátila 0 pre každý okraj a 1 pre výplň, skombinovať ich operáciou násobenia a nakoniec výsledkom (nazvime ho útlm - attentuation) vynásobiť farbu, takže pre hodnotu 1 bude mať pôvodnú farbu a pre hodnotu 0 bude výsledkom čierna.

const float border = 0.05;

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    // Výpočet súradníc
    vec2 uv = (fragCoord.xy / iResolution.xy - border) / (1.0 - border * 2.0);

    // Čierny rámček na každej strane
    float borderAttenuation =
        step(0.0, uv.x) * // 0 pre ľavý okraj
        step(0.0, uv.y) * // 0 pre dolný okraj
        step(0.0, 1.0 - uv.x) * // 0 pre pravý okraj
        step(0.0, 1.0 - uv.y); // 0 pre horný okraj

    // Výpočet farby
    vec3 color = vec3(uv.x, 0.0, uv.y);

    // Vynásobenie útlmom a pridanie alfa kanálu (1.0)
    fragColor = vec4(color * borderAttenuation, 1.0);
}

Zostáva nastaviť farby - červenú v ľavom dolnom rohu, modrú v pravom dolnom, zelené v ľavom hornom a nakoniec modrú v pravom hornom rohu.

Výsledná farba sa vypočíta ako mix jednotlivých zložiek. Najskôr sa mixujú farby pre jednu os (napríklad pre x sa mixujú farby na hornej a dolnej hrane) a potom sa zmixujú výsledky.

const float border = 0.05;

// Farby rohov
const vec3 colors[4] = vec3[4](
    vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0),
    vec3(0.0, 1.0, 0.0), vec3(1.0, 1.0, 1.0)
);

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    // Výpočet súradníc
    vec2 uv = (fragCoord.xy / iResolution.xy - border) / (1.0 - border * 2.0);

    // Čierny rámček na každej strane
    float borderAttenuation =
        step(0.0, uv.x) * // 0 pre ľavý okraj
        step(0.0, uv.y) * // 0 pre dolný okraj
        step(0.0, 1.0 - uv.x) * // 0 pre pravý okraj
        step(0.0, 1.0 - uv.y); // 0 pre horný okraj

    // Výpočet farby
    vec3 color = mix(
        mix(colors[0], colors[1], uv.x), // Dolná hrana
        mix(colors[2], colors[3], uv.x), // Horná hrana
        uv.y);

    // Vynásobenie útlmom a pridanie alfa kanálu (1.0)
    fragColor = vec4(color * borderAttenuation, 1.0);
}

Výsledný súbor obdlznik.frag sa dá použiť priamo na webe shadertoy.com.

Shader je kompatibilný s webovou službou shadertoy
Obrázok 12: Shader je kompatibilný s webovou službou shadertoy

Nasledujúca časť seriálu bude o 2D grafike. Na záver pridávam pár príkladov pokročilých shaderov.

Príklady
Obrázok 13:
A) Selfie Girl s making of videom
B) @Party Concert Visuals 2020
C) star ribbon
D) Echeveria II
Príklady
Obrázok 14:
A) Fractal Land
B) Ladybug
C) Happy Jumping
D) Neon World

Prílohy

    • RE: Shadertoy - medzi matematikou a umením (úvod) 30.08.2022 | 15:46
      Avatar Richard Antix  Používateľ

      K renderu "Ladybug" je 1. komentár:

      ollj, 2022-05-05 This shader challenges the best pc grapic cards, good.

       

      Tož, dosiahol som 0,7 fps na svojej podtaktovanej šunke.

      • RE: Shadertoy - medzi matematikou a umením (úvod) 30.08.2022 | 16:45
        Avatar Miroslav Bendík Gentoo  Administrátor

        U mňa 7fps s integrovanou GPU, ale mám display so škálovaním 2x takže renderuje v 4x vyššom rozlíšení než bežne.