The Book of Shaders by Patricio Gonzalez Vivo & Jen Lowe

Bahasa Indonesia - Tiếng Việt - 日本語 - 中文版 - 한국어 - Español - Portugues - Français - Italiano - Deutsch - Русский - Polski - English


Cellular Noise (pol. "szum komórkowy")

W 1996 roku, szesnaście lat po oryginalnym algorytmie szumu Perlina i pięć lat przed jego Simplex Noise, Steven Worley napisał pracę zatytułowaną "A Cellular Texture Basis Function". Opisuje w niej technikę teksturowania proceduralnego, która jest obecnie szeroko stosowana przez grafików.

Aby zrozumieć jej zasady, musimy zacząć myśleć w kategoriach iteracji. Zapewne wiesz, co to oznacza: używanie pętli for. Jest tylko jeden haczyk z pętlami for w GLSL: warunek, który sprawdzamy musi być stałą (const). Tak więc nie ma dynamicznych pętli - liczba iteracji musi być stała.

Przyjrzyjmy się przykładowi.

Punkty w polu odległości

Cellular Noise opiera się na polach odległości, a dokładniej odległości do najbliższego ze zbioru punktów. Załóżmy, że chcemy stworzyć pole odległości składające się z 4 punktów. Co musimy zrobić? Cóż, dla każdego piksela chcemy obliczyć odległość do najbliższego punktu. Oznacza to, że musimy iterować po wszystkich punktach, obliczać ich odległości do bieżącego piksela i przechować odległość do tego najbliższego.

    float min_dist = 100.; // Zmienna przechowująca odległość do najbliższego punktu

    min_dist = min(min_dist, distance(st, point_a));
    min_dist = min(min_dist, distance(st, point_b));
    min_dist = min(min_dist, distance(st, point_c));
    min_dist = min(min_dist, distance(st, point_d));

Nie jest to zbyt eleganckie, ale załatwia sprawę. Teraz zaimplementujmy go ponownie, używając tablicy i pętli for.

    float m_dist = 100.;  // minimum distance
    for (int i = 0; i < TOTAL_POINTS; i++) {
        float dist = distance(st, points[i]);
        m_dist = min(m_dist, dist);
    }

Zauważ, jak używamy pętli for do iteracji po tablicy punktów i funkcji min() do śledzenia odległości do najbliższego punktu. Oto krótka działająca implementacja tego pomysłu:

W powyższym kodzie jeden z punktów jest przypisany do pozycji myszy. Pobaw się nim, abyś mógł zrozumieć intuicję stojącą za tym kodem. Następnie spróbuj ćwiczeń:

Kafelkowanie i iteracja

Zapewne zauważyłeś, że pętle for i tablice nie są zbyt dobrymi przyjaciółmi GLSL. Jak już wspomnieliśmy, pętle nie akceptują dynamicznych warunków wyjścia. Ponadto, iteracja przez wiele instancji znacznie zmniejsza wydajność twojego shadera. Oznacza to, że nie możemy użyć tego prostego, brute-force'owego podejścia dla dużych ilości punktów. Musimy znaleźć inną strategię, taką, która wykorzystuje architekturę przetwarzania równoległego GPU.

Jednym ze sposobów podejścia do tego problemu jest podzielenie przestrzeni na rozłączne obszary/komórki. Nie każdy piksel musi sprawdzać odległość do każdego punktu, prawda? Biorąc pod uwagę fakt, że każdy piksel działa w swoim własnym wątku, możemy podzielić przestrzeń na komórki, z których każda ma jeden unikalny punkt do oglądania. Ponadto, aby uniknąć aberracji na krawędziach między komórkami musimy sprawdzić odległości do punktów w sąsiednich komórkach. Jest to główna idea artykułu Stevena Worleya. Ostatecznie każdy piksel musi sprawdzić tylko dziewięć pozycji: punkt własnej komórki i punkty w 8 komórkach wokół niego. Dzieliliśmy już przestrzeń na komórki w rozdziałach o: wzorach kafelkowych, losowości i szumie, więc mam nadzieję, że jesteś już zaznajomiony z tą techniką.

    // skalowanie
    st *= 3.;

    // dzielenie przestrzeni na kafelki
    vec2 i_st = floor(st);
    vec2 f_st = fract(st);

Jaki jest więc plan? Użyjemy współrzędnych kafelka (przechowywanych w części całkowitej, i_st) do skonstruowania losowej pozycji punktu. Funkcja random2f, której użyjemy, przyjmuje vec2 i zwraca vec2 z losową pozycją (o wartościach z zakresu od 0.0 do 1.0). Tak więc, dla każdego kafla będziemy mogli otrzymać jeden punkt losowo położony wewnątrz tego kafla.

    vec2 point = random2(i_st);

Każdy piksel wewnątrz tego kafla (przechowywany w części ułamkowej, f_st) sprawdzi swoją odległość do tego nowego, losowego punktu.

    vec2 diff = point - f_st;
    float dist = length(diff);

Wynik będzie wyglądał tak:

Musimy też sprawdzić odległości do punktów w okolicznych kafelkach, a nie tylko do tego w bieżącym kafelku. W tym celu iterujemy po sąsiednich kafelkach. Nie po wszystkich kafelkach, tylko po tych bezpośrednio otaczających bieżącego. Czyli od -1 (lewy) do 1 (prawy) na osi x oraz od -1 (dolny) do 1 (górny) na osi y. Region 3x3 składający się z 9 kafelków może być iterowany przy użyciu podwójnej pętli for, jak poniżej:

for (int y= -1; y <= 1; y++) {
    for (int x= -1; x <= 1; x++) {
        // Względne położenie sąsiadującego kafelka
        vec2 neighbor = vec2(float(x),float(y));
        ...
    }
}

Teraz możemy obliczyć położenie punktów w każdym z sąsiadujących kafelków z pomocą podwójnej pętli for, dodając przesunięcie sąsiedniego kafelka (neighbor) do współrzędnej bieżącego kafelka (i_st).

        ...
        // Losowe położenie punktu wewnątrz sąsiedniego kafelka
        vec2 point = random2(i_st + neighbor);
        ...

Reszta polega na obliczeniu odległości do każdego z sąsiednich punktów i zapisaniu najmniejszej odległości w zmiennej o nazwie m_dist (z ang. "minimal distance").

        ...
        vec2 diff = neighbor + point - f_st;

        // Odległość do punktu
        float dist = length(diff);

        // Zachowaj mniejszą odległość
        m_dist = min(m_dist, dist);
        ...

Powyższy kod jest inspirowany artykułem Inigo Quileza, w którym zauważa, jak problemy z precyzją bitową mogą być uniknięte poprzez przejście do stosowanej już przez nas przestrzeni kafelkowej (w przeciwieństwie do robienia obliczeń w domyślnych współrzędnych, niepodzielonych na części całkowite i ułamkowe):

Podsumowując: dzielimy przestrzeń na kafelki; każdy piksel liczy odległość do punktu w swoim własnym kafelku i odległość do punktów z otaczających go 8 kafelków; przechowuje najbliższą odległość. Wynikiem jest pole odległości, które wygląda jak w poniższym przykładzie:

Eksploruj tę ideę dalej:

Algorytm ten można również interpretować z perspektywy punktów, a nie bieżących pikseli. W takim przypadku można go opisać jako: każdy punkt rośnie, dopóki nie znajdzie rosnącego obszaru innego punktu. Odzwierciedla to niektóre z zasad wzrostu w naturze. Żywe formy kształtowane są przez napięcie między wewnętrzną siłą do rozszerzania się i wzrostu oraz zewnętrznymi siłami ograniczającymi. Klasyczny algorytm symulujący to zachowanie nazwany został za Georgy Voronoi.

Algorytm Voronoi

Konstruowanie diagramów Voronoi z szumu komórkowego jest mniej trudne niż mogłoby się wydawać. Musimy tylko zachować pewną dodatkową informację o punkcie, który jest najbliżej bieżącego piksela. Do tego celu użyjemy vec2 o nazwie m_point (z ang. "minimal point"). Przechowując wektor od bieżącego piksela do najbliższego punktu (zamiast samej odległości) będziemy "przechowywać" "unikalny" identyfikator tego punktu.

    ...
    if( dist < m_dist ) {
        m_dist = dist;
        m_point = point;
    }
    ...

Zauważ, że w poniższym kodzie nie używamy już min do obliczania najbliższej odległości, ale zwykłegpo warunku if. Dlaczego? Ponieważ chcemy zrobić coś więcej za każdym razem, gdy pojawi się nowy bliższy punkt, a mianowicie zapisać jego pozycję (linie 32 do 37).

Zauważ, jak kolor ruchomej komórki (związanej z pozycją myszy) zmienia kolor w zależności od jej położenia. To dlatego, że kolor jest przypisywany przy użyciu wartości (pozycji) najbliższego punktu.

Podnieśmy poprzeczkę, przechodząc na podejście z artykułu Stevena Worleya. Spróbuj zaimplementować to samodzielnie. Możesz skorzystać z pomocy poniższego przykładu, klikając na niego. Zauważ, że oryginalne podejście Stevena Worleya używa zmiennej liczby punktów dla każdego kafla, więcej niż jeden w większości kafli. W tej jego nie-shaderowej implementacji (bo w C, a nie w GLSL) pomaga to przyspieszyć pętlę poprzez wczesne jej opuszczanie. Pętle GLSL nie pozwalają na zmienną liczbę iteracji, więc prawdopodobnie chcesz trzymać się jednego punktu na kafelek.

Gdy już rozgryziesz ten algorytm, pomyśl o ciekawych i kreatywnych jego zastosowaniach.

Extended Voronoi - Leo Solaas (2011)

Cloud Cities - Tomás Saraceno (2011)

Accretion Disc Series - Clint Fulkerson

Vonoroi Puzzle - Reza Ali (2015)

Ulepszenie Voronoi

W 2011 roku Stefan Gustavson zoptymalizował algorytm Stevena Worleya pod GPU, iterując tylko przez macierz 2x2 zamiast 3x3. To znacznie zmniejsza ilość pracy, ale może tworzyć artefakty w postaci nieciągłości na krawędziach między kafelkami. Przyjrzyj się poniższym przykładom.

Później w 2012 roku Inigo Quilez napisał artykuł o tym, jak zrobić Voronoi z ostrymi granicami.

Eksperymenty Inigo z Voronoi nie skończyły się na tym. W 2014 roku napisał artykuł o tym, co nazywa voro-noise. Jest to funkcja, która pozwala na stopniowe interpolowanie między zwykłym szumem a Voronoi. Jego słowami:

"Pomimo tego podobieństwa, faktem jest, że sposób użycia kafelkowania w obu metodach jest inny. Szum interpoluje/uśrednia wartości losowe (jak w Value Noise) lub gradienty (jak w Gradient Noise), podczas gdy Voronoi oblicza odległość do najbliższego punktu w kafelku. Interpolacja dwuliniowa (ang. "bilinear") i wartość minimalna to dwie bardzo różne operacje, ale czy na pewno? Czy można je połączyć w bardziej ogólną metrykę? Gdyby tak było, to zarówno szum jak i Voronoi mogłyby być postrzegane jako szczególne przypadki bardziej ogólnego generatora wzorów kafelkowych?".

Teraz nadszedł czas, abyś przyjrzał się bliżej rzeczom, zainspirował się naturą i znalazł swoje własne ujęcie tej techniki!

Deyrolle glass film - 1831