Twoja pierwsza aplikacja WebGPU

1. Wprowadzenie

Logo WebGPU składa się z kilku niebieskich trójkątów tworzących stylizowaną literę „W”.

Co to jest WebGPU?

WebGPU to nowy, nowoczesny interfejs API umożliwiający korzystanie z możliwości GPU w aplikacjach internetowych.

Nowoczesny interfejs API

Przed WebGPU istniał WebGL, który oferował podzbiór funkcji WebGPU. Umożliwiła ona tworzenie nowych rodzajów bogatych treści internetowych, a deweloperzy stworzyli z jej pomocą niesamowite rzeczy. Był on jednak oparty na interfejsie OpenGL ES 2.0, który został wydany w 2007 roku i oparty był na jeszcze starszym interfejsie OpenGL. W tym czasie procesory graficzne znacznie się rozwinęły, a z nimi współpracujące natywne interfejsy API również ewoluowały, co zaowocowało powstaniem Direct3D 12, MetalVulkana.

WebGPU wprowadza ulepszenia tych nowoczesnych interfejsów API na platformę internetową. Skupia się na włączaniu funkcji GPU w wielu platformach, jednocześnie prezentując interfejs API, który jest naturalny w internecie i mniej obszerny niż niektóre natywne interfejsy API, na których jest oparty.

Renderowanie

Procesory graficzne są często kojarzone z szybkim renderowaniem szczegółowej grafiki, a WebGPU nie jest tu wyjątkiem. Zawiera on funkcje wymagane do obsługi wielu najpopularniejszych obecnie technik renderowania na kartach graficznych na komputery i urządzenia mobilne. Umożliwia też dodawanie nowych funkcji w przyszłości, gdy możliwości sprzętowe będą się rozwijać.

Obliczenia

Oprócz renderowania WebGPU pozwala wykorzystać potencjał GPU do wykonywania ogólnych zadań o dużej równoległości. Te shadery obliczeniowe można używać samodzielnie, bez żadnego komponentu do renderowania, lub jako ściśle zintegrowaną część potoku renderowania.

Z dzisiejszego ćwiczenia w Codelabs dowiesz się, jak wykorzystać zarówno możliwości renderowania, jak i przetwarzania zasobów WebGPU, aby stworzyć prosty projekt wprowadzający.

Co utworzysz

W tym ćwiczeniu z programowania zbudujesz Grę życia Conwaya przy użyciu WebGPU. Twoja aplikacja będzie:

  • Używanie możliwości renderowania WebGPU do rysowania prostej grafiki 2D.
  • Do przeprowadzenia symulacji użyj możliwości obliczeniowych WebGPU.

Zrzut ekranu przedstawiający ostateczną wersję usługi w ramach ćwiczenia z programowania

Gra życia jest rodzajem automatu komórkowego, w którym siatka komórek zmienia stan w czasie na podstawie określonego zbioru reguł. W grze Game of Life komórki stają się aktywne lub nieaktywne w zależności od tego, ile sąsiednich komórek jest aktywnych. W efekcie pojawiają się interesujące wzorce, które zmieniają się w miarę oglądania.

Czego się nauczysz

  • Jak skonfigurować WebGPU i ustawić kanwę.
  • Jak narysować prostą geometrię 2D.
  • Jak używać shaderów wierzchołkowych i fragmentowych, aby modyfikować to, co jest rysowane.
  • Jak używać procesorowych shaderów do przeprowadzania prostej symulacji.

Ten warsztat programowania koncentruje się na omówieniu podstawowych pojęć związanych z WebGPU. Nie jest to wyczerpujące omówienie interfejsu API ani nie obejmuje (ani nie wymaga) często powiązanych tematów, takich jak matematyka macierzy 3D.

Czego potrzebujesz

  • najnowszą wersję Chrome (113 lub nowszą) w systemie ChromeOS, macOS lub Windows; WebGPU to interfejs API obsługujący wiele przeglądarek i platform, ale nie jest jeszcze dostępny wszędzie.
  • Znajomość języka HTML, JavaScript i Narzędzi deweloperskich w Chrome.

Znajomość innych interfejsów API związanych z grafiką, takich jak WebGL, Metal, Vulkan czy Direct3D, nie jest wymagana, ale jeśli masz już z nimi jakieś doświadczenie, zauważysz z pewnością wiele podobieństwa do WebGPU, co może ułatwić Ci rozpoczęcie nauki.

2. Konfiguracja

Pobierz kod

To ćwiczenie nie ma żadnych zależności i pokazuje wszystkie kroki potrzebne do utworzenia aplikacji WebGPU, więc nie musisz pisać żadnego kodu, aby zacząć. Jednak kilka praktycznych przykładów, które mogą posłużyć jako punkty kontrolne, znajdziesz na stronie https://glitch.com/edit/#!/your-first-webgpu-app. Możesz je przejrzeć i wrócić do nich w razie problemów.

Użyj konsoli deweloperów.

WebGPU to dość złożony interfejs API z wiele regułami, które narzucają prawidłowe użycie. Co gorsza, ze względu na sposób działania interfejsu API nie może on wywoływać typowych wyjątków JavaScript w przypadku wielu błędów, co utrudnia dokładne określenie źródła problemu.

Będziesz mieć problemy z rozwojem z użyciem WebGPU, zwłaszcza na początku, ale to normalne. Twórcy tego interfejsu API są świadomi trudności związanych z rozwojem GPU i dołożyli wszelkich starań, aby w przypadku wystąpienia błędu w kodzie WebGPU w konsoli dla deweloperów pojawiały się szczegółowe i przydatne komunikaty, które pomogą zidentyfikować i naprawić problem.

Pozostawienie konsoli otwartej podczas pracy nad dowolną aplikacją internetową jest zawsze przydatne, ale zwłaszcza w tym przypadku.

3. Inicjowanie WebGPU

Zacznij od <canvas>

WebGPU można używać bez wyświetlania czegokolwiek na ekranie, jeśli interesują Cię tylko obliczenia. Jeśli jednak chcesz wyrenderować jakiś element, na przykład w ramach ćwiczenia z programowania, potrzebujesz obszaru roboczego. To dobry punkt wyjścia.

Utwórz nowy dokument HTML z jednym elementem <canvas> oraz tagiem <script>, w którym zapytasz element canvas. (Lub użyj pliku 00-starter-page.html z problemu).

  • Utwórz plik index.html z tym kodem:

index.html

<!doctype html>

<html>
  <head>
    <meta charset="utf-8">
    <title>WebGPU Life</title>
  </head>
  <body>
    <canvas width="512" height="512"></canvas>
    <script type="module">
      const canvas = document.querySelector("canvas");

      // Your WebGPU code will begin here!
    </script>
  </body>
</html>

Zamawianie przejściówki i urządzenia

Teraz możesz zapoznać się z WebGPU. Najpierw pamiętaj, że wdrożenie interfejsów API, takich jak WebGPU, może zająć trochę czasu w całym ekosystemie internetowym. W związku z tym na początek warto sprawdzić, czy przeglądarka użytkownika obsługuje WebGPU.

  1. Aby sprawdzić, czy obiekt navigator.gpu, który służy jako punkt wejścia do WebGPU, istnieje, dodaj ten kod:

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

Najlepiej jest poinformować użytkownika o niedostępności interfejsu WebGPU, ustawiając stronę z powrotem w trybie, który nie korzysta z WebGPU. (Może zamiast tego użyć WebGL?) W ramach tego ćwiczenia po prostu rzucasz błędem, aby zatrzymać dalsze wykonywanie kodu.

Gdy dowiesz się, że przeglądarka obsługuje WebGPU, pierwszym krokiem zainicjowania WebGPU dla aplikacji jest wysłanie żądania GPUAdapter. Jest to raczej reprezentacja określonego elementu GPU w urządzeniu przez WebGPU.

  1. Aby uzyskać przejściówkę, użyj metody navigator.gpu.requestAdapter(). Zwraca obietnicę, więc najwygodniej jest wywołać ją za pomocą await.

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

Jeśli nie można znaleźć odpowiednich adapterów, zwrócona wartość adapter może mieć wartość null i należy uwzględnić tę możliwość. Może się tak zdarzyć, jeśli przeglądarka użytkownika obsługuje WebGPU, ale sprzęt GPU nie ma wszystkich funkcji potrzebnych do korzystania z WebGPU.

W większości przypadków wystarczy, że pozwolisz przeglądarce wybrać domyślny adapter, tak jak tutaj, ale w przypadku bardziej zaawansowanych potrzeb możesz przekazać parametrom requestAdapter() argumenty określające, czy chcesz używać sprzętu o niskiej czy wysokiej mocy na urządzeniach z wieloma układami GPU (np. na niektórych laptopach).

Gdy masz już kartę, ostatnim krokiem przed rozpoczęciem pracy z procesorem graficznym jest wysłanie żądania GPUDevice. Urządzenie jest głównym interfejsem, za pomocą którego odbywa się większość interakcji z procesorem graficznym.

  1. Aby pobrać urządzenie, wywołaj funkcję adapter.requestDevice(), która również zwraca obietnicę.

index.html

const device = await adapter.requestDevice();

Podobnie jak w przypadku requestAdapter(), tutaj też można przekazać opcje do bardziej zaawansowanych zastosowań, takich jak włączenie określonych funkcji sprzętowych lub prośba o podwyższenie limitów, ale do Twoich celów domyślne ustawienia powinny wystarczyć.

Konfigurowanie Canvas

Jeśli chcesz wyświetlać na stronie coś za pomocą urządzenia, musisz jeszcze skonfigurować obszar roboczy do użycia z utworzonym właśnie urządzeniem.

  • Aby to zrobić, najpierw poproś o GPUCanvasContext z płótna, wywołując funkcję canvas.getContext("webgpu"). (to samo wywołanie, które służy do inicjowania kontekstów Canvas 2D lub WebGL odpowiednio przy użyciu typów kontekstów 2dwebgl). Zwrócony identyfikator context musi zostać powiązany z urządzeniem za pomocą metody configure() w ten sposób:

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

Istnieje kilka opcji, które można przekazać w tym miejscu, ale najważniejsze to device, z którym będzie powiązany kontekst, oraz format, czyli format tekstury, którego powinien używać kontekst.

Tekstury to obiekty, których WebGPU używa do przechowywania danych obrazu. Każda tekstura ma format, który informuje GPU, jak dane są rozmieszczone w pamięci. Szczegóły działania pamięci tekstur wykraczają poza zakres tego ćwiczenia z programowania. Należy pamiętać, że kontekst kanwy udostępnia kodowi tekstury do rysowania, a używany format może wpływać na to, jak skutecznie kanwa wyświetla te obrazy. Różne typy urządzeń działają najlepiej przy użyciu różnych formatów tekstur. Jeśli nie używasz preferowanego formatu urządzenia, może to spowodować dodatkowe kopiowanie do pamięci, zanim obraz będzie można wyświetlić jako część strony.

Na szczęście nie musisz się o to martwić, ponieważ WebGPU informuje, którego formatu użyć na potrzeby kanwy. Prawie zawsze warto przekazać wartość zwróconą przez wywołanie funkcji navigator.gpu.getPreferredCanvasFormat(), jak pokazano powyżej.

Wyczyszczenie obszaru roboczego

Teraz, gdy masz urządzenie i skonfigurowany na nim kanwa, możesz zacząć zmieniać zawartość kanwy. Na początek usuń go jednolitym kolorem.

Aby to zrobić (lub wykonać praktycznie dowolną inną czynność w WebGPU), musisz przekazać procesorowi graficznemu odpowiednie polecenia.

  1. W tym celu urządzenie musi utworzyć GPUCommandEncoder, który udostępnia interfejs do rejestrowania poleceń GPU.

index.html

const encoder = device.createCommandEncoder();

Polecenia, które chcesz wysłać do GPU, są powiązane z renderowaniem (w tym przypadku z czyszczeniem płótna), więc następnym krokiem jest użycie encoder, aby rozpocząć renderowanie.

Przechodzenie przez etapy renderowania to moment, w którym wykonywane są wszystkie operacje rysowania w WebGPU. Każdy z nich zaczyna się wywołaniem beginRenderPass(), które definiuje tekstury, które otrzymują dane wyjściowe z wykonanych poleceń rysowania. Bardziej zaawansowane zastosowania mogą zawierać kilka tekstur, zwanych załącznikami, które służą do różnych celów, np. do przechowywania głębi wyrenderowanej geometrii lub do wygładzania krawędzi. W przypadku tej aplikacji wystarczy jednak tylko 1 konto.

  1. Pobierz teksturę z kontekstu płótna utworzonego wcześniej, wywołując funkcję context.getCurrentTexture(), która zwraca teksturę o szerokości i wysokości w pikselach odpowiadających atrybutom widthheight płótna oraz atrybucie format określonym podczas wywołania funkcji context.configure().

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

Tekstura jest podawana jako właściwość view obiektu colorAttachment. Przechodzenie przez etapy renderowania wymaga podania wartości GPUTextureView zamiast GPUTexture, która określa, które części tekstury mają zostać wyrenderowane. Ma to znaczenie tylko w bardziej zaawansowanych przypadkach użycia, więc tutaj wywołujesz funkcję createView() bez argumentów dotyczących tekstury, co oznacza, że chcesz, aby pass renderowania używał całej tekstury.

Musisz też określić, co przepustka renderowania ma robić z teksturą na początku i na końcu:

  • Wartość loadOp "clear" wskazuje, że chcesz wyczyścić teksturę po rozpoczęciu renderowania.
  • Wartość storeOp "store" wskazuje, że po zakończeniu renderowania chcesz zapisać w teksturze wyniki rysowania wykonane podczas renderowania.

Po rozpoczęciu procesu renderowania nie musisz nic robić. Przynajmniej na razie. Rozpoczęcie renderowania za pomocą loadOp: "clear" wystarczy do wyczyszczenia widoku tekstury i płótna.

  1. Zakończ renderowanie, dodając po beginRenderPass() następujące wywołanie:

index.html

pass.end();

Pamiętaj, że samo wywołanie tych funkcji nie powoduje, że GPU faktycznie coś zrobi. Rejestrują tylko polecenia, które GPU wykorzystuje później.

  1. Aby utworzyć GPUCommandBuffer, wywołaj finish() w poleceniu w koderze. Bufor poleceń to nieprzezroczysty uchwyt do nagranych poleceń.

index.html

const commandBuffer = encoder.finish();
  1. Prześlij bufor poleceń do GPU, używając queue interfejsu GPUDevice. Kolejka wykonuje wszystkie polecenia GPU, dbając o to, aby ich wykonywanie było dobrze uporządkowane i odpowiednio zsynchronizowane. Metoda submit() kolejki przyjmuje tablicę buforów poleceń, ale w tym przypadku masz tylko jeden.

index.html

device.queue.submit([commandBuffer]);

Po przesłaniu bufora poleceń nie można go ponownie użyć, więc nie musisz go przechowywać. Jeśli chcesz przesłać więcej poleceń, musisz utworzyć kolejny bufor poleceń. Dlatego te 2 czynności są często łączone w jedną, jak w przypadku stron przykładowych w tym Codelab:

index.html

// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);

Po przesłaniu poleceń do GPU pozwól JavaScriptowi zwrócić element sterujący do przeglądarki. W tym momencie przeglądarka widzi, że zmieniono bieżącą teksturę kontekstu, i aktualizuje płótno, aby wyświetlić tę teksturę jako obraz. Jeśli później zechcesz ponownie zaktualizować zawartość obszaru roboczego, musisz zarejestrować i przesłać nowy bufor poleceń, ponownie wywołując funkcję context.getCurrentTexture(), aby uzyskać nową teksturę dla danego procesu renderowania.

  1. Odśwież stronę. Zauważ, że obszar roboczy jest wypełniony czarnym kolorem. Gratulacje! Oznacza to, że udało Ci się utworzyć pierwszą aplikację WebGPU.

czarne tło, które wskazuje, że WebGPU zostało użyte do wyczyszczenia zawartości tła;

Wybierz kolor

Szczerze mówiąc, czarne kwadraty są jednak dość nudne. Zanim przejdziesz do następnej sekcji, poświęć chwilę na spersonalizowanie tego procesu.

  1. W wywołaniu encoder.beginRenderPass() dodaj nowy wiersz z wartością clearValue do elementu colorAttachment, np.:

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    loadOp: "clear",
    clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
    storeOp: "store",
  }],
});

clearValue instruuje kartę renderowania, której koloru ma używać podczas wykonywania operacji clear na początku karty. Przekazany słownik zawiera 4 wartości: r dla czerwonego, g dla zielonego, b dla niebieskiego i a dla alfa (przezroczystość). Każda wartość może się wahać od 0 do 1, a razem opisują one wartość tego kanału kolorów. Na przykład:

  • { r: 1, g: 0, b: 0, a: 1 } jest jasnoczerwony.
  • { r: 1, g: 0, b: 1, a: 1 } jest jasnofioletowy.
  • { r: 0, g: 0.3, b: 0, a: 1 } jest ciemnozielony.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } jest szaro-beżowy.
  • { r: 0, g: 0, b: 0, a: 0 } to domyślny przezroczysty czarny kolor.

Przykładowy kod i zrzuty ekranu w tym ćwiczeniach z programowania są w kolorze ciemnoniebieskim, ale możesz wybrać dowolny kolor.

  1. Po wybraniu koloru odśwież stronę. Wybrany kolor powinien być widoczny na obszarze roboczym.

Obszar roboczy został wyczyszczony na ciemnoniebieski, aby pokazać, jak zmienić domyślny czysty kolor.

4. Narysuj geometrię

Pod koniec tej sekcji Twoja aplikacja narysuje na płótnie prostą figurę geometryczną: kolorowy kwadrat. Pamiętaj, że na pierwszy rzut oka może się wydawać, że to dużo pracy na tak prosty wynik, ale WebGPU jest zaprojektowany tak, aby bardzo wydajnie renderować dużą ilość geometrii. Skutkiem ubocznym tej wydajności jest to, że wykonywanie stosunkowo prostych czynności może wydawać się niezwykle trudne, ale takie jest właśnie założenie, gdy korzystasz z interfejsu API takiego jak WebGPU – chcesz wykonać coś nieco bardziej skomplikowanego.

Jak działają karty graficzne

Zanim wprowadzisz dalsze zmiany kodu, warto zapoznać się z bardzo szybkim i uproszczonym ogólnym omówieniem tego, jak GPU tworzą kształty widoczne na ekranie. (jeśli znasz już podstawy działania renderowania GPU, możesz przejść bezpośrednio do sekcji Definiowanie wierzchołków).

W przeciwieństwie do interfejsu API, takiego jak Canvas 2D, który zawiera wiele gotowych kształtów i opcji, procesor graficzny obsługuje tylko kilka typów kształtów (czyli pierwotnych, jak nazywa je WebGPU): punkty, linie i trójkąty. Na potrzeby tego ćwiczenia w programie używasz tylko trójkątów.

Procesory graficzne pracują prawie wyłącznie z trójkątami, ponieważ mają one wiele przydatnych właściwości matematycznych, które ułatwiają ich przewidywalne i wydajne przetwarzanie. Zanim GPU będzie mogło narysować obiekt, prawie wszystko, co rysujesz za pomocą GPU, musi zostać podzielone na trójkąty. Trójkąty te muszą być zdefiniowane przez ich wierzchołki.

Te punkty, czyli wierzchołki, są podawane w układzie współrzędnych kartezjańskich zdefiniowanym przez WebGPU lub podobne interfejsy API. Strukturę układu współrzędnych najłatwiej sobie wyobrazić, gdy powiążesz ją z płótnem na stronie. Niezależnie od tego, jak szerokie lub wysokie jest tworzywo, jego lewa krawędź jest zawsze na osi X w miejscu -1, a prawa – w miejscu +1. Podobnie dolna krawędź ma zawsze wartość -1 na osi Y, a górna krawędź – wartość +1 na osi Y. Oznacza to, że współrzędne (0, 0) zawsze odpowiadają środkowi siatki, (-1, -1) zawsze lewym dolnym rogowi, a (1, 1) zawsze prawym górnym rogowi. Nazywamy to miejscem na klip.

Prosty wykres przedstawiający znormalizowany układ współrzędnych urządzenia.

Wierzchołki rzadko są zdefiniowane w tym układzie współrzędnych, dlatego procesory graficzne korzystają z małych programów zwanych shaderami wierzchołkowymi, które wykonują wszystkie obliczenia potrzebne do przekształcenia wierzchołków w przestrzeni klipu, a także inne obliczenia potrzebne do narysowania wierzchołków. Może on na przykład stosować animację lub obliczać kierunek od wierzchołka do źródła światła. Te shadery są tworzone przez Ciebie, czyli dewelopera WebGPU, i pozwalają na niesamowitą kontrolę nad działaniem GPU.

Następnie GPU wybiera wszystkie trójkąty utworzone przez te przekształcone wierzchołki i określa, które piksele na ekranie są potrzebne do ich narysowania. Następnie uruchamia inny napisany przez Ciebie mały program, tzw. fragment shader, który oblicza, jaki kolor powinien mieć każdy piksel. Obliczenia mogą być proste, jak np. zwrot zielony lub bardzo złożone, np. obliczanie kąta powierzchni w odniesieniu do światła słonecznego odbijającego się z pobliskich powierzchni, przefiltrowywania przez mgłę i modyfikowania ich metalu. Działanie tej funkcji jest całkowicie pod Twoją kontrolą i może być bardzo motywujące i przytłaczające.

Wyniki tych kolorów pikseli są następnie gromadzone w teksturze, która może być wyświetlana na ekranie.

Definiowanie wierzchołków

Jak już wspomnieliśmy, symulacja The Game of Life jest wyświetlana jako siatka komórek. Aplikacja musi umożliwiać wizualizację siatki, a także odróżnianie komórek aktywnych od nieaktywnych. W tym ćwiczeniu rysujemy kolorowe kwadraty w aktywnych komórkach, a komórki nieaktywne zostawiamy puste.

Oznacza to, że musisz podać procesorowi graficznemu 4 różne punkty, po jednym dla każdego z 4 rogów kwadratu. Na przykład kwadrat narysowany na środku obszaru roboczego, wyciągnięty w pewny sposób z krawędzi, ma takie współrzędne narożnika:

Znormalizowany wykres współrzędnych urządzenia przedstawiający współrzędne rogów kwadratu

Aby przesłać te współrzędne do procesora graficznego, musisz umieścić wartości w TypedArray. Jeśli nie wiesz, czym są tablice typów, to są to grupy obiektów JavaScript, które umożliwiają przydzielanie ciągłych bloków pamięci i interpretowanie każdego elementu w serii jako określonego typu danych. Na przykład w tablicy Uint8Array każdy element to pojedynczy, bez znaku bajt. Tablice TypedTables świetnie nadają się do przesyłania danych w obie strony za pomocą interfejsów API wrażliwych na układ pamięci, takich jak WebAssembly, WebAudio i oczywiście WebGPU.

W przykładzie kwadratu wartości są ułamkowe, więc odpowiednia jest wartość Float32Array.

  1. Utwórz tablicę, która przechowuje wszystkie pozycje wierzchołków na diagramie, umieszczając w kodzie następującą deklarację tablicy. Dobrym miejscem na umieszczenie okna jest górna część ekranu, tuż pod rozmową context.configure().

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8,
   0.8, -0.8,
   0.8,  0.8,
  -0.8,  0.8,
]);

Zwróć uwagę, że odstępy i komentarze nie mają wpływu na wartości; służą one tylko do ułatwienia odczytu. Dzięki temu możesz zobaczyć, że każda para wartości stanowi współrzędne X i Y jednego wierzchołka.

Ale jest jeden problem. Pamiętasz, że GPU działają na podstawie trójkątów? Oznacza to, że wierzchołki trzeba podać w grupach po trzy. Masz jedną grupę liczącą 4 osoby. Rozwiązaniem jest powtórzenie dwóch wierzchołków w celu utworzenia dwóch trójkątów ze wspólną krawędzią przez środek kwadratu.

Schemat pokazujący, jak 4 wierzchołki kwadratu tworzą 2 trójkąty.

Aby utworzyć kwadrat z wykresu, musisz dwukrotnie wstawić wierzchołki (-0,8, -0,8) i (0,8; 0,8) – raz dla niebieskiego trójkąta i drugiego dla czerwonego. (Możesz też podzielić kwadrat z 2 pozostałymi rogami – nie ma to znaczenia).

  1. Zaktualizuj poprzedni tablicę vertices, aby wyglądała tak:

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8, // Triangle 1 (Blue)
   0.8, -0.8,
   0.8,  0.8,

  -0.8, -0.8, // Triangle 2 (Red)
   0.8,  0.8,
  -0.8,  0.8,
]);

Chociaż diagram pokazuje rozdzielenie 2 trójkątów w celu ułatwienia zrozumienia, pozycje wierzchołków są dokładnie takie same, a GPU renderuje je bez przerw. Wyświetli się jako pojedynczy, pełny kwadrat.

Tworzenie bufora wierzchołków

GPU nie może rysować wierzchołków za pomocą danych z tablicy JavaScript. Urządzenia GPU często mają własną pamięć, która jest w wysokim stopniu zoptymalizowana pod kątem renderowania. Dlatego wszelkie dane, których GPU ma używać podczas rysowania, muszą być umieszczone w tej pamięci.

W przypadku wielu wartości, w tym danych wierzchołka, pamięci po stronie GPU zarządza się za pomocą obiektów GPUBuffer. Bufor to blok pamięci, który jest łatwo dostępny dla GPU i oznaczony do określonych celów. Można to sobie wyobrazić jako tablicę typu TypedSlate widoczną z GPU.

  1. Aby utworzyć bufor do przechowywania wierzchołków, dodaj po definicji tablicy vertices wywołanie funkcji device.createBuffer().

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

Na początku należy nadać buforowi etykietę. Każdy tworzony przez Ciebie obiekt WebGPU może mieć opcjonalną etykietę. Zdecydowanie warto z niej korzystać. Etykieta może być dowolnym ciągiem znaków, o ile tylko pomaga zidentyfikować obiekt. Jeśli wystąpią jakieś problemy, te etykiety są używane w komunikatach o błędach generowanych przez WebGPU, aby ułatwić zrozumienie, co poszło nie tak.

Następnie podaj rozmiar bufora w bajtach. Potrzebujesz bufora o długości 48 bajtów, którą obliczasz przez pomnożenie rozmiaru 32-bitowej liczby zmiennoprzecinkowej ( 4 bajty) przez liczbę liczb zmiennoprzecinkowych w tablicy vertices (12). Na szczęście tablice TypedArrays już dla Ciebie obliczają byteLength, więc możesz go użyć podczas tworzenia bufora.

Na koniec musisz określić użytkowanie bufora. Jest to co najmniej 1 flaga GPUBufferUsage, przy czym wiele flag jest połączonych za pomocą operatora | ( bitowej funkcji OR). W tym przypadku określasz, że bufor ma być używany do danych wierzchołka (GPUBufferUsage.VERTEX), a także że chcesz mieć możliwość kopiowania do niego danych (GPUBufferUsage.COPY_DST).

Zwracany obiekt bufora jest nieprzezroczysty – nie można (łatwo) sprawdzić zawartych w nim danych. Poza tym większości jego atrybutów nie można zmienić – nie można zmienić rozmiaru elementu GPUBuffer po jego utworzeniu ani zmienić flag użycia. Możesz zmienić tylko zawartość pamięci.

Po pierwszym utworzeniu bufora zawarte w nim pamięć zostanie zainicjowana do zera. Treść można zmienić na kilka sposobów, ale najłatwiej jest wywołać metodę device.queue.writeBuffer() z TypedArray, który chcesz skopiować.

  1. Aby skopiować dane wierzchołka do pamięci bufora, dodaj ten kod:

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

Definiowanie układu wierzchołka

Teraz masz bufor z danymi wierzchołków, ale z punktu widzenia karty GPU jest to po prostu zbiór bajtów. Jeśli chcesz coś narysować, musisz podać trochę więcej informacji. Musisz mieć możliwość przekazania WebGPU więcej informacji o strukturze danych wierzchołka.

index.html

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [{
    format: "float32x2",
    offset: 0,
    shaderLocation: 0, // Position, see vertex shader
  }],
};

Na pierwszy rzut oka może to być nieco mylące, ale jest to stosunkowo łatwe do zdefiniowania.

Najpierw podaj arrayStride. Jest to liczba bajtów, które GPU musi przejść do przodu w buforze, gdy szuka następnego wierzchołka. Każdy wierzchołek kwadratu składa się z dwóch 32-bitowych liczb zmiennoprzecinkowych. Jak już wspomnieliśmy, 32-bitowa liczba zmiennoprzecinkowa to 4 bajty, więc 2 takie liczby to 8 bajtów.

Kolejna właściwość attributes, która jest tablicą, Atrybuty to poszczególne elementy informacji zakodowane w każdym wierzchołku. Twoje wierzchołki zawierają tylko 1 atrybut (pozycję wierzchołka), ale w bardziej zaawansowanych zastosowaniach często mają one wiele atrybutów, np. kolor wierzchołka lub kierunek, w którym skierowana jest powierzchnia geometryczna. Jednak nie jest to objęte zakresem tego ćwiczenia.

W atrybucie pojedynczym najpierw definiujesz format danych. Dane pochodzą z listy typów GPUVertexFormat opisujących każdy typ danych wierzchołków, które układ GPU jest w stanie zrozumieć. Twoje wierzchołki mają po 2 32-bitowe liczby zmiennoprzecinkowe, więc używasz formatu float32x2. Jeśli dane wierzchołka składają się z czterech 16-bitowych liczb całkowitych bez znaku, użyjesz zamiast tego parametru uint16x4. Widzisz ten wzór?

Następnie wartość offset określa, ile bajtów ma w wierzchołku dany atrybut. Naprawdę musisz się tym przejmować tylko wtedy, gdy bufor ma więcej niż 1 atrybut, który nie pojawi się w tym ćwiczeniu.

Ostatnim elementem jest shaderLocation. Jest to dowolna liczba z zakresu od 0 do 15, która musi być unikalna dla każdego zdefiniowanego atrybutu. Łączy ten atrybut z określonym wejściem w shaderze wierzchołka, o którym dowiesz się więcej w następnej sekcji.

Zwróć uwagę, że chociaż definiujesz te wartości, nie przekazujesz ich jeszcze do interfejsu WebGPU API. Wkrótce do tego dojdzie, ale najłatwiej jest pomyśleć o tych wartościach w momencie definiowania wierzchołków, więc skonfiguruj je teraz, aby użyć ich później.

Zacznij od shaderów

Masz już dane, które chcesz wyrenderować, ale nadal musisz wskazać układowi GPU, jak mają to zrobić. W dużej mierze odbywa się to za pomocą shaderów.

Shadery to małe programy, które piszesz i uruchamiasz na karcie graficznej. Każdy shader działa na innym etapie przetwarzania danych: przetwarzanie wierzchołków, przetwarzanie fragmentów lub ogólne przetwarzanie. Ponieważ są w układzie GPU, mają sztywną strukturę niż zwykły kod JavaScript. Jednak ta struktura pozwala na ich bardzo szybkie i co najważniejsze równoległe wykonywanie.

Shadery w WebGPU są pisane w języku cieniowania o nazwie WGSL (język cieniowania WebGPU). WGSL pod względem składni jest trochę podobny do Rust, a jego funkcje mają ułatwiać i przyspieszać wykonywanie typowych zadań na procesorze graficznym (np. obliczenia wektorów i macierzy). W ramach tego ćwiczenia nie będziemy omawiać całego języka cieniowania, ale mamy nadzieję, że poznasz podstawy, gdy przeanalizujesz kilka prostych przykładów.

Same shadery są przekazywane do WebGPU jako ciągi znaków.

  • Utwórz miejsce na wpisanie kodu shadera, kopiując ten kod poniżej znaku vertexBufferLayout:

index.html

const cellShaderModule = device.createShaderModule({
  label: "Cell shader",
  code: `
    // Your shader code will go here
  `
});

Aby utworzyć shadery, wywołujesz funkcję device.createShaderModule(), do której przekazujesz opcjonalny ciąg znaków label i WGSL code. (pamiętaj, że w przypadku ciągów wielowierszowych używasz grawisu). Po dodaniu prawidłowego kodu WGSL funkcja zwraca obiekt GPUShaderModule z skompilowanymi wynikami.

Definiowanie shadera wierzchołka

Zacznij od shadera wierzchołkowego, bo od niego zaczyna się też GPU.

Błotnik wierzchołkowy jest zdefiniowany jako funkcja, a GPU wywołuje tę funkcję raz na każdy wierzchołek w vertexBuffer. Ponieważ vertexBuffer ma 6 pozycji (wierzchołków), zdefiniowana przez Ciebie funkcja jest wywoływana 6 razy. Za każdym razem, gdy jest ono wywoływane, do funkcji jest przekazywana inna pozycja z pola vertexBuffer jako argument. Zadaniem funkcji cieniowania wierzchołków jest zwracanie odpowiedniej pozycji w miejscu na klips.

Pamiętaj, że niekoniecznie będą one wywoływane w kolejności. Zamiast tego procesory graficzne doskonale sprawdzają się w wykonowywaniu takich shaderów równolegle, co pozwala przetwarzać jednocześnie setki (a nawet tysiące!) wierzchołków. To w dużej mierze odpowiada za niewiarygodną szybkość działania układów GPU, ale wiąże się to z pewnymi ograniczeniami. Aby zapewnić ekstremalną równoległość, shadery wierzchołków nie mogą się ze sobą komunikować. Każde wywołanie cieniowania może wyświetlać tylko dane dotyczące 1 wierzchołka naraz i może generować wartości tylko dla 1 wierzchołka.

W WGSL funkcję shadera wierzchołka można nazwać dowolnie, ale musi ona mieć przedrostek @vertex atrybut, aby wskazać, który etap shadera reprezentuje. WGSL oznacza funkcje za pomocą słowa kluczowego fn, używa nawiasów do deklarowania argumentów i nawiasów klamrowych do definiowania zakresu.

  1. Utwórz pustą funkcję @vertex w ten sposób:

index.html (kod createShaderModule)

@vertex
fn vertexMain() {

}

Nie jest to jednak prawidłowe, ponieważ shader wierzchołka musi zwracać co najmniej ostateczną pozycję wierzchołka przetwarzanego w przestrzeni klipu. Jest on zawsze podawany jako wektor 4-wymiarowy. Wektory są tak powszechnie używane w shaderach, że są traktowane jako prymitywne typu 1 w języku, z własnymi typami, takimi jak vec4f dla wektora 4-wymiarowego. Istnieją podobne typy wektorów 2D (vec2f) i 3D (vec3f).

  1. Aby wskazać, że zwracana wartość jest wymaganą pozycją, oznacz ją atrybutem @builtin(position). Symbol -> wskazuje, że funkcja zwraca tę wartość.

index.html (kod createShaderModule)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

Oczywiście, jeśli funkcja ma typ zwracany, musisz zwrócić wartość w ciele funkcji. Możesz utworzyć nową funkcję vec4f, która zwróci wartość, używając składni vec4f(x, y, z, w). Wartości x, yz to liczby zmiennoprzecinkowe, które w wartości zwracanej wskazują, gdzie wierzchołek znajduje się w przestrzeni klipu.

  1. Zwracanie statyczne wartości (0, 0, 0, 1) powoduje, że technicznie masz prawidłowy shader wierzchołka, ale taki, który nigdy niczego nie wyświetla, ponieważ procesor graficzny rozpoznaje, że generowane przez niego trójkąty to tylko pojedynczy punkt, i go odrzuca.

index.html (kod createShaderModule)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

Zamiast tego chcesz użyć danych z utworzonego bufora. Aby to zrobić, zadeklaruj argument funkcji z atrybutem @location() i typem zgodnym z opisem w funkcji vertexBufferLayout. Podano wartość shaderLocation o wartości 0, więc w kodzie WGSL oznacz ten argument za pomocą atrybutu @location(0). Zdefiniowano również format jako float32x2, który jest wektorem 2D, więc w WGSL argument to vec2f. Możesz nadać mu dowolną nazwę, ale ponieważ te wartości reprezentują pozycje wierzchołków, nazwa pos wydaje się naturalna.

  1. Zmień funkcję shadera na ten kod:

index.html (kod createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

Teraz musisz zwrócić tę pozycję. Ponieważ pozycja jest wektorem 2D, a typ zwracany to wektor 4D, musisz go trochę zmodyfikować. Musisz wziąć 2 składowe z argumentu position i umieścić je w pierwszych 2 składowych wektora zwracanego, pozostawiając 2 ostatnie składowe jako 01.

  1. Zwracaj prawidłową pozycję, określając, których komponentów pozycji użyć:

index.html (kod createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

Jednak ze względu na to, że tego typu mapowania są bardzo popularne w shaderach, możesz też podać wektor pozycji jako pierwszy argument w wygodnej formie skróconej, co oznacza to samo.

  1. Zmodyfikuj instrukcję return za pomocą tego kodu:

index.html (kod createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

To jest nasz początkowy shader wierzchołków. Jest to bardzo proste, ponieważ pozycja jest przekazywana bez zmian, ale wystarczy na początek.

Definiowanie fragment shadera

Kolejnym krokiem jest fragment shadera. Bardzo podobnie jak shadery wierzchołkowe działają shadery fragmentów, ale zamiast wywoływania dla każdego wierzchołka są wywoływane dla każdego rysowanego piksela.

Shadery fragmentów są zawsze wywoływane po shaderach wierzchołkowych. GPU pobiera dane wyjściowe z shaderów wierzchołkowych i trójkątuje je, tworząc trójkąty z zestawów 3 punktów. Następnie rasteryzuje każdy z tych trójkątów, określając, które piksele z dołączonych kolorów wyjściowych są w nim zawarte, a potem wywołuje shader fragmentu pojedynczo dla każdego z tych pikseli. Fragment shader zwraca kolor, który jest zwykle obliczany na podstawie wartości wysyłanych do niego przez shader wierzchołka i zasobów takich jak tekstury, które GPU zapisuje w załączniku koloru.

Mechanizmy cieniowania fragmentów działają równolegle do siebie, podobnie jak cieniowanie wierzchołków. Są one nieco bardziej elastyczne niż shadery wierzchołkowe pod względem danych wejściowych i wyjściowych, ale można je traktować jako zwracające po prostu jeden kolor dla każdego piksela każdego trójkąta.

Funkcja shadera fragmentu w WGSL jest oznaczona atrybutem @fragment i także zwraca vec4f. W tym przypadku wektor reprezentuje kolor, a nie pozycję. Zwracana wartość musi mieć atrybut @location, aby wskazać, do którego elementu colorAttachment z wywołania beginRenderPass jest zapisany zwrócony kolor. Ponieważ masz tylko 1 załącznik, lokalizacja jest równa 0.

  1. Utwórz pustą funkcję @fragment, np.:

index.html (kod createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

4 składowe zwróconego wektora to wartości kolorów czerwonego, zielonego, niebieskiego i alfa, które są interpretowane dokładnie tak samo jak clearValue ustawione wcześniej w beginRenderPass. vec4f(1, 0, 0, 1) to jaskrawa czerwień, która wydaje się odpowiednim kolorem dla Twojego kwadratu. Możesz jednak ustawić dowolny kolor.

  1. Ustaw zwrócony wektor koloru w następujący sposób:

index.html (kod createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}

To już gotowy fragment shadera. Nie jest to szczególnie interesujące. Po prostu ustawia każdy piksel każdego trójkąta na czerwony, ale na razie wystarczy.

Podsumujmy: po dodaniu kodu shadera, o którym mowa powyżej, wywołanie createShaderModule wygląda teraz tak:

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

    @fragment
    fn fragmentMain() -> @location(0) vec4f {
      return vec4f(1, 0, 0, 1);
    }
  `
});

Tworzenie potoku renderowania

Modułu shadera nie można używać do renderowania samodzielnie. Zamiast tego musisz używać go w ramach GPURenderPipeline, utworzonego przez wywołanie device.createRenderPipeline(). Potok renderowania kontroluje sposób rysowania geometrii, w tym m.in. stosowane shadery, sposób interpretowania danych w buforach wierzchołków czy rodzaj geometrii, która ma być renderowana (linie, punkty, trójkąty itp.).

Pipeline renderowania jest najbardziej złożonym obiektem w całym interfejsie API, ale nie martw się! Większość wartości, które możesz mu przekazać, jest opcjonalna, a na początek wystarczy podać tylko kilka.

  • Utwórz strumień renderowania w ten sposób:

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: "auto",
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

Każdy potok wymaga layout, który opisuje, jakich typów danych wejściowych (innych niż bufory wierzchołkowe) potrzebuje, ale tak naprawdę takich nie ma. Na szczęście możesz na razie pominąć "auto", a system zbuduje własny układ na podstawie shaderów.

Następnie musisz podać szczegóły dotyczące etapu vertex. module to moduł GPUShaderModule zawierający shader wierzchołków, a entryPoint to nazwa funkcji w kodzie shadera, która jest wywoływana przy każdym wywołaniu wierzchołka. (w jednym module shadera możesz mieć wiele funkcji @vertex@fragment). Element buffers to tablica obiektów GPUVertexBufferLayout, które opisują sposób pakowania danych w buforach wierzchołków używanych przez ten potok. Na szczęście masz już zdefiniowane to w swoim pliku vertexBufferLayout. Tutaj przekazujesz go dalej.

Na koniec znajdziesz szczegóły dotyczące etapu fragment. Obejmuje on też moduł cieniowania i entryPoint, np. scenę wierzchołkowej. Ostatnim krokiem jest zdefiniowanie targets, z którym jest używany ten potok. Jest to tablica słowników, która zawiera szczegóły (takie jak tekstura format) załączników kolorów generowanych przez potok. Te szczegóły muszą być zgodne z teksturami w colorAttachments wszystkich przejść renderowania, z którymi jest używany ten potok. Karta renderowania wykorzystuje tekstury z kontekstu obszaru roboczego oraz wartość zapisaną w polu canvasFormat, dzięki czemu przekazujesz w tym miejscu ten sam format.

To w żaden sposób nie wyczerpuje wszystkich opcji, które możesz określić podczas tworzenia potoku renderowania, ale wystarczy na potrzeby tego ćwiczenia.

Rysowanie kwadratu

Teraz masz już wszystko, czego potrzebujesz do narysowania kwadratu.

  1. Aby narysować kwadrat, wróć do pary wywołań encoder.beginRenderPass()pass.end(), a potem dodaj te nowe polecenia:

index.html

// After encoder.beginRenderPass()

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices

// before pass.end()

Dzięki temu WebGPU otrzyma wszystkie informacje potrzebne do narysowania kwadratu. Najpierw użyj setPipeline(), aby wskazać, którego potoku danych użyć do rysowania. Obejmuje to używane shadery, układ danych wierzchołkowych i inne istotne dane stanu.

Następnie wywołujesz funkcję setVertexBuffer() z buforem zawierającym wierzchołki kwadratu. Wywołujesz go za pomocą 0, ponieważ ten bufor odpowiada 0. elementowi w definicji vertex.buffers bieżącego potoku.

Na koniec wykonujesz wywołanie draw(), co po całej konfiguracji wydaje się dziwnie proste. Musisz tylko przekazać liczbę wierzchołków, które ma wyrenderować. Pobiera ona z ustawionych obecnie buforów wierzchołków i interpretuje za pomocą obecnie ustawionego potoku. Można go zakodować na stałe jako 6, ale obliczenie go na podstawie tablicy wierzchołkowej (12 punktów zmiennoprzecinkowych / 2 współrzędne na wierzchołek == 6 wierzchołków) oznacza, że jeśli zdecydujesz się zastąpić kwadrat na przykład okręgiem, ręczne aktualizowanie będzie mniejsze.

  1. Odśwież ekran, aby (w końcu) zobaczyć efekty swojej pracy: jeden duży kolorowy kwadrat.

Jeden czerwony kwadrat renderowany przy użyciu WebGPU

5. Rysuj siatkę

Najpierw pochwal się! Wyświetlanie pierwszych elementów geometrii na ekranie jest często jednym z najtrudniejszych etapów w przypadku większości interfejsów API GPU. Wszystkie czynności możesz wykonywać w ramach mniejszych kroków, co ułatwia sprawdzanie postępów.

W tej sekcji dowiesz się:

  • Jak przekazywać zmienne (nazywane uniformami) do shadera z JavaScriptu.
  • Jak używać uniformów, aby zmienić sposób renderowania.
  • Jak używać instancjonowania do rysowania wielu różnych wariantów tej samej geometrii.

Definiowanie siatki

Aby renderować siatkę, musisz znać bardzo podstawową informację na jej temat. Ile komórek zawiera, zarówno w szerokość, jak i w wysokość? To zależy od Ciebie jako dewelopera, ale aby nieco uprościć sprawę, potraktuj siatkę jako kwadrat (ta sama szerokość i wysokość) i użyj rozmiaru będącego potęgą dwójki. (ułatwi to późniejsze obliczenia). W dalszej części tej sekcji rozmiar siatki będzie wynosił 4 x 4, ponieważ ułatwia to przedstawienie niektórych obliczeń. Potem zwiększaj skalę kampanii.

  • Określ rozmiar siatki, dodając stałą wartość na początku kodu JavaScript.

index.html

const GRID_SIZE = 4;

Następnie musisz zmienić sposób renderowania kwadratu, aby zmieścić na nim GRID_SIZE kopii GRID_SIZE. Oznacza to, że kwadrat musi być znacznie mniejszy, a powinno być ich dużo.

Możesz to zrobić, zwiększając znacznie bufor wierzchołków i określając w nim kwadraty o wartości GRID_SIZE × GRID_SIZE o odpowiednim rozmiarze i pozycji. W rzeczywistości kod nie byłby taki zły! Kilka pętli for i trochę matematyki. Nie wykorzystuje też w pełni możliwości GPU i korzysta z większej ilości pamięci niż jest to konieczne do uzyskania efektu. W tej sekcji omawiamy podejście bardziej przyjazne procesorowi graficznemu.

Tworzenie jednolitego bufora

Najpierw musisz przekazać wybrany rozmiar siatki do shadera, ponieważ wykorzystuje on tę wartość do zmiany sposobu wyświetlania elementów. Możesz po prostu zakodować rozmiar w shaderze, ale oznacza to, że za każdym razem, gdy chcesz zmienić rozmiar siatki, musisz ponownie utworzyć shader i pipeline renderowania, co jest kosztowne. Lepszym sposobem jest podanie rozmiaru siatki do cieniowania w postaci uniformów.

Jak już wiesz, do każdego wywołania shadera wierzchołka przekazywana jest inna wartość z bufora wierzchołków. Jednorodny to wartość z bufora, która jest taka sama w przypadku każdego wywołania. Są one przydatne do przekazywania wartości wspólnych dla elementu geometrii (np. jego położenia), pełnego kadru animacji (np. bieżącego czasu) lub nawet całego okresu użytkowania aplikacji (np. preferencji użytkownika).

  • Utwórz jednolity bufor, dodając ten kod:

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

Powinno to wyglądać znajomo, ponieważ to prawie dokładnie ten sam kod, który został użyty wcześniej do utworzenia bufora wierzchołków. Dzieje się tak, ponieważ uniformy są przekazywane do interfejsu WebGPU API za pomocą tych samych obiektów GPUBuffer co wierzchołki. Główną różnicą jest to, że tym razem obiekt usage zawiera GPUBufferUsage.UNIFORM zamiast GPUBufferUsage.VERTEX.

Dostęp do uniformów w shaderze

  • Zdefiniuj jednolity charakter, dodając ten kod:

index.html (wywołanie createShaderModule)

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos / grid, 0, 1);
}

// ...fragmentMain is unchanged 

Określa on w shaderze zmienną grid, która jest wektorem 2D typu float odpowiadającym tablicy, którą właśnie skopiowano do bufora jednolitego. Określa też, że uniform jest związany na pozycjach @group(0)@binding(0). Za chwilę dowiesz się, co oznaczają te wartości.

Następnie w innych miejscach w kodzie shadera możesz używać wektora siatki w dowolny sposób. W tym kodzie pozycję wierzchołka dzielimy przez wektor siatki. Ponieważ pos jest wektorem 2D, a grid to wektor 2D, WGSL przeprowadza dzielenie na podstawie komponentów. Innymi słowy, efekt jest taki sam jak w przypadku vec2f(pos.x / grid.x, pos.y / grid.y).

Tego typu operacje wektorowe są bardzo popularne w shaderach GPU, ponieważ wiele technik renderowania i obliczania na nich bazuje.

Oznacza to, że (jeśli użyjesz siatki o rozmiarze 4), renderowany kwadrat będzie miał 1/4 pierwotnego rozmiaru. To idealne rozwiązanie, jeśli chcesz umieścić 4 z nich w wierszu lub kolumnie.

Tworzenie grupy wiązania

Jednak zadeklarowanie jednolitego standardu w cieniowaniu nie powoduje połączenia go z utworzonym przez Ciebie buforem. Aby to zrobić, musisz utworzyć i skonfigurować grupę wiązania.

Grupa wiązania to zbiór zasobów, które chcesz udostępnić shaderowi w tym samym czasie. Może ono obejmować kilka typów buforów, takich jak bufor jednolity, oraz inne zasoby, takie jak tekstury i próbki, które nie są tu omawiane, ale są powszechnie używane w technikach renderowania WebGPU.

  • Utwórz grupę wiązania z buforem jednolitym, dodając po utworzeniu bufora jednolitego i potoku renderowania ten kod:

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  }],
});

Oprócz standardowego elementu label musisz też utworzyć element layout, który opisuje, jakie typy zasobów zawiera ta grupa wiązania. W przyszłości przyjrzysz się temu bliżej, ale na razie możesz poprosić potok o przesłanie układu grupy wiązania, ponieważ został on utworzony za pomocą layout: "auto". Spowoduje to, że potok automatycznie tworzy układy grup powiązań na podstawie powiązań zadeklarowanych w kodzie cieniowania. W tym przypadku przesyłasz je do getBindGroupLayout(0), gdzie 0 odpowiada @group(0) wpisanemu w shaderze.

Po określeniu układu podaj tablicę entries. Każdy wpis to słownik z co najmniej tymi wartościami:

  • binding, która odpowiada wartości @binding() wprowadzonej w shaderze. W tym przypadku: 0.
  • resource będący rzeczywistym zasobem, który chcesz udostępnić zmiennej o określonym indeksie powiązania. W tym przypadku jest to jednolity bufor.

Funkcja zwraca wartość GPUBindGroup, która jest nieprzejrzystym, niezmiennym uchwytem. Po utworzeniu grupy wiązania nie możesz zmieniać zasobów, do których ona się odwołuje, ale możesz zmienić zawartość tych zasobów. Jeśli na przykład zmienisz bufor jednolity, aby zawierał nowy rozmiar siatki, zostanie to odzwierciedlone w przyszłych wywołaniach funkcji draw, które korzystają z tej grupy bind.

Połącz grupę bind

Po utworzeniu grupy wiązania musisz jeszcze poinformować WebGPU, aby używała jej podczas rysowania. Na szczęście jest to dość proste.

  1. Wróć do passu renderowania i przed metodą draw() dodaj ten nowy wiersz:

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

Wartość 0 przekazana jako pierwszy argument odpowiada wartości @group(0) w kodzie shadera. Twierdzisz, że każdy @binding, który jest częścią @group(0), używa zasobów z tej grupy wiązania.

Teraz bufor jednolity jest dostępny dla Twojego shadera.

  1. Odśwież stronę. Powinna się wyświetlić strona podobna do tej:

Mały czerwony kwadrat na ciemnoniebieskim tle.

Hurra! Kwadrat ma teraz 1/4 poprzedniego rozmiaru. To niewiele, ale pokazuje, że uniform jest faktycznie stosowany i że shader może teraz uzyskać dostęp do rozmiaru siatki.

Manipulowanie geometrią w cieniowaniu

Teraz, gdy możesz odwoływać się do rozmiaru siatki w shaderze, możesz zacząć modyfikować geometrię, którą renderujesz, aby pasowała do wybranego wzoru siatki. Aby to zrobić, zastanów się, czego dokładnie oczekujesz.

Musisz podzielić kanwę na poszczególne komórki. Aby zachować konwencję, że oś X rośnie, gdy przesuwasz się w prawo, a oś Y rośnie, gdy przesuwasz się w górę, załóżmy, że pierwsza komórka znajduje się w lewym dolnym rogu siatki. W ten sposób uzyskasz układ, który wygląda tak jak ten z obecną geometrią kwadratową w środku:

Ilustracja przedstawiająca koncepcyjną siatkę, w której przestrzeń znormalizowanych współrzędnych urządzenia zostanie podzielona podczas wizualizacji każdej komórki z wyrenderowaną aktualnie kwadratową geometrią.

Twoim zadaniem jest znalezienie w shaderze metody, która umożliwia umieszczenie kwadratowej geometrii w dowolnej z tych komórek na podstawie jej współrzędnych.

Po pierwsze, widać, że kwadrat nie jest dobrze wyrównany z żadną z komórek, ponieważ został zdefiniowany tak, aby otaczał środek osi. Kwadrat powinien być przesunięty o pół komórki, aby dobrze się wpasował.

Jednym ze sposobów rozwiązania tego problemu jest zaktualizowanie bufora wierzchołków kwadratu. Przesunięcie wierzchołków tak, aby prawy dolny róg znajdował się w punktach (0,1, 0,1) zamiast (-0,8, -0,8), spowoduje dopasowanie kwadratu do granic komórek. Ponieważ masz pełną kontrolę nad tym, jak wierzchołki są przetwarzane w stworzonym przez siebie shaderze, możesz je łatwo przesunąć na miejsce za pomocą kodu shadera.

  1. Zmień moduł shadera wierzchołka za pomocą tego kodu:

index.html (wywołanie createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Add 1 to the position before dividing by the grid size.
  let gridPos = (pos + 1) / grid;

  return vec4f(gridPos, 0, 1);
}

Spowoduje to przesunięcie wszystkich wierzchołków w górę i w prawo o jeden (pamiętaj, że jest to połowa miejsca klipu) przed podzieleniem go przez rozmiar siatki. Wynikiem jest kwadrat dobrze dopasowany do siatki, który znajduje się w pobliżu punktu wyjścia.

Wizualizacja siatki 4 x 4 z czerwonym kwadratem w komórce (2, 2)

Ponieważ system współrzędnych na płótnie umieszcza punkt (0, 0) w środku, a (-1, -1) w lewym dolnym rogu, a Ty chcesz, aby punkt (0, 0) znajdował się w lewym dolnym rogu, musisz przesunąć pozycję geometrii o (-1, -1) po podzieleniu przez rozmiar siatki, aby przesunąć ją do tego rogu.

  1. Przetłumacz pozycję geometrii w ten sposób:

index.html (wywołanie createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Subtract 1 after dividing by the grid size.
  let gridPos = (pos + 1) / grid - 1;

  return vec4f(gridPos, 0, 1); 
}

A teraz kwadrat jest odpowiednio umiejscowiony w komórce (0, 0).

Wizualizacja płótna podzielonego na siatkę 4 x 4 z czerwonym kwadratem w komórce (0, 0)

Co zrobić, jeśli chcesz umieścić go w innej komórce? Aby to sprawdzić, zadeklaruj wektor cell w cieniowaniu i wypełniaj go wartością statyczną, np. let cell = vec2f(1, 1).

Jeśli dodasz to do gridPos, cofniesz działanie - 1 w algorytmie, a nie o to Ci chodzi. Zamiast tego chcesz przesunąć kwadrat tylko o 1 jednostkę siatki (1/4 płótna) w przypadku każdej komórki. Wygląda na to, że musisz ponownie podzielić przez grid.

  1. Zmień pozycjonowanie siatki, na przykład:

index.html (wywołanie createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

Jeśli odświeżysz stronę, zobaczysz:

Wizualizacja kanwy podzielonej na siatkę 4 × 4 z czerwonym kwadratem na środku komórek (0, 0), (0, 1), (1, 0) i (1, 1)

Hm. Nie do końca to, czego oczekujesz.

Dzieje się tak, ponieważ współrzędne na płótnie mieszczą się w zakresie od -1 do +1, co oznacza, że w szerzy zajmują 2 jednostki. Oznacza to, że jeśli chcesz przesunąć wierzchołek o 1/4 płótna, musisz przesunąć go o 0,5 jednostki. To łatwy błąd do popełnienia podczas rozumowania za pomocą współrzędnych GPU. Na szczęście rozwiązanie tego problemu jest równie proste.

  1. Pomnóż przesunięcie przez 2, np. w ten sposób:

index.html (wywołanie createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

Dzięki temu uzyskasz dokładnie to, czego potrzebujesz.

Wizualizacja obszaru roboczego podzielonego koncepcyjnie na siatkę 4 x 4 z czerwonym kwadratem w komórce (1, 1)

Zrzut ekranu wygląda tak:

Zrzut ekranu przedstawiający czerwony kwadrat na ciemnoniebieskim tle. Czerwony kwadrat narysowany w tej samej pozycji co na diagramie powyżej, ale bez nakładki siatki.

Możesz też ustawić cell na dowolną wartość w zakresie siatki, a potem odświeżyć, aby wyświetlić renderowanie kwadratowe w wybranej lokalizacji.

Rysowanie instancji

Teraz, gdy wiesz, jak umieścić kwadrat w chcianym miejscu za pomocą kilku obliczeń, możesz w każdej komórce siatki wyrenderować po jednym kwadracie.

Jednym ze sposobów jest zapisanie współrzędnych komórek w buforze jednolitym, a potem wywołanie funkcji draw raz dla każdego kwadratu w siatce, przy każdym wywołaniu aktualizując bufor. Byłoby to jednak bardzo powolne, ponieważ procesor GPU musi za każdym razem czekać, aż nowe współrzędne zostaną zapisane przez JavaScript. Jednym z kluczy do uzyskania dobrej wydajności GPU jest minimalizowanie czasu oczekiwania na inne części systemu.

Zamiast tego możesz wykorzystać technikę zwaną „postoją”. Instancja to sposób na polecenie GPU, by rysował wiele kopii tej samej geometrii za pomocą jednego wywołania funkcji draw, co jest znacznie szybsze niż wywoływanie funkcji draw raz na każdą kopię. Każda kopia geometrii nazywana jest instancją.

  1. Aby poinformować GPU, że potrzebujesz wystarczającej liczby instancji kwadratu do wypełnienia siatki, dodaj 1 argument do istniejącego wywołania rysowania:

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

To informuje system, że chcesz narysować 6 (vertices.length / 2) wierzchołków kwadratu 16 (GRID_SIZE * GRID_SIZE) razy. Jeśli jednak odświeżysz stronę, nadal zobaczysz:

Obraz identyczny jak poprzedni diagram, aby wskazać, że nic się nie zmieniło.

Dlaczego? To dlatego, że rysujesz wszystkie 16 kwadratów w tym samym miejscu. Musisz zastosować w cieniowaniu jakąś dodatkową logikę, która zmienia położenie geometrii w zależności od instancji.

W shaderze oprócz atrybutów wierzchołka, takich jak pos pochodzących z bufora wierzchołka, możesz też uzyskać dostęp do wbudowanych wartości w WGSL. Są to wartości obliczane przez WebGPU, a jedną z nich jest instance_index. instance_index to niepodpisany 32-bitowy numer z zakresu od 0 do number of instances - 1, którego możesz używać w ramach funkcji cieniowania. Jego wartość jest taka sama dla każdego przetworzonego wierzchołka, który jest częścią tej samej instancji. Oznacza to, że shader wierzchołków jest wywoływany 6 razy z wartością instance_index, raz dla każdej pozycji w buforze wierzchołków.0 Następnie jeszcze 6 razy z wartością instance_index = 1, potem jeszcze 6 razy z wartością instance_index = 2 i tak dalej.

Aby zobaczyć, jak to działa, musisz dodać do danych wejściowych do cieniowania interfejs instance_index. Zrób to tak samo jak w przypadku pozycji, ale zamiast dodawać do niego atrybut @location, użyj @builtin(instance_index), a następnie nadaj argumentowi dowolną nazwę. (możesz nazwać go instance, aby pasował do przykładowego kodu). Następnie użyj go w ramach logiki shadera.

  1. Zamiast współrzędnych komórki użyj instance:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {
  
  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i); // Updated
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

Po odświeżeniu strony zobaczysz, że masz więcej niż jeden kwadrat. Nie widzisz jednak wszystkich 16 poziomów.

Cztery czerwone kwadraty na linii ukośnej od lewego dolnego rogu do prawego górnego rogu na ciemnoniebieskim tle.

Dzieje się tak, ponieważ generowane przez Ciebie współrzędne komórek to (0, 0), (1, 1), (2, 2)... aż do (15, 15), ale tylko pierwsze 4 z nich mieszczą się na kanwie. Aby utworzyć odpowiednią siatkę, musisz przekształcić instance_index tak, aby każdy indeks był mapowany na niepowtarzalną komórkę w siatce, np. w ten sposób:

Wizualizacja siatki 4 x 4, w której każda komórka odpowiada liniowemu indeksowi instancji.

Obliczenia są dość proste. W przypadku wartości X każdej komórki chcesz uzyskać modulo instance_index i szerokości siatki, co możesz wykonać w WGSL za pomocą operatora %. W przypadku wartości Y każdej komórki chcesz, aby instance_index było podzielone przez szerokość siatki, a reszta ułamkowa zostanie odrzucona. Możesz to zrobić za pomocą funkcji floor() w WGSL.

  1. Zmień obliczenia w ten sposób:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  // Compute the cell coordinate from the instance_index
  let cell = vec2f(i % grid.x, floor(i / grid.x));

  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

Po wprowadzeniu tej zmiany w kodzie wreszcie masz długo oczekiwaną siatkę kwadratów.

Cztery rzędy po 4 kolumny czerwonych kwadratów na ciemnoniebieskim tle.

  1. Teraz, gdy wszystko działa, wróć i zwiększ rozmiar siatki.

index.html

const GRID_SIZE = 32;

32 wiersze i 32 kolumny czerwonych kwadratów na ciemnoniebieskim tle.

Tadam! Teraz możesz stworzyć naprawdę bardzo dużą siatkę, a przeciętna karta graficzna poradzi sobie z tym bez problemu. Nie zobaczysz już poszczególnych kwadratów na długo przed wystąpieniem jakichkolwiek wąskich gardeł w działaniu procesora graficznego.

6. Dodatkowa zaleta: nadaj jej bardziej kolorowy charakter.

W tym momencie możesz przejść do następnej sekcji, ponieważ masz już podstawy potrzebne do dalszej części samouczka. O ile siatka kwadratów ma ten sam kolor i można z niej korzystać, to nie jest to takie ekscytujące, prawda? Na szczęście możesz nieco rozjaśnić obraz, korzystając z dodatkowych obliczeń i kodu shadera.

Używanie elementów struct w cieniowaniu

Do tej pory z shadera wierzchołka przekazywany był jeden element danych: przekształcona pozycja. Ale możesz zwrócić znacznie więcej danych z mechanizmu cieniowania wierzchołków, a potem użyć ich w cieniowaniu fragmentów.

Jedynym sposobem przekazywania danych z shadera wierzchołka jest zwracanie ich. Shader wierzchołka jest zawsze wymagany do zwrócenia pozycji, więc jeśli chcesz zwrócić inne dane, musisz je umieścić w strukturze. Struktury w WGSL to nazwane typy obiektów, które zawierają co najmniej jedną nazwaną właściwość. Właściwości mogą być oznaczone atrybutami takimi jak @builtin i @location. Deklarujesz je poza funkcjami, a następnie możesz przekazywać ich wystąpienia do i z funkcji odpowiednio do potrzeb. Weźmy na przykład obecny shader wierzchołkowy:

index.html (wywołanie createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> 
  @builtin(position) vec4f {

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
  
  return  vec4f(gridPos, 0, 1);
}
  • To samo można wyrazić, używając struktur na potrzeby funkcji wejściowej i wyjściowej:

index.html (wywołanie createShaderModule)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  return output;
}

Pamiętaj, że musisz odwołać się do pozycji wejściowej i indeksu instancji za pomocą input, a zwracana przez Ciebie struktura musi najpierw zostać zadeklarowana jako zmienna i mieć określone właściwości. W tym przypadku nie ma to większego znaczenia i w fakcie wydłuża funkcję shadera, ale gdy shadery stają się bardziej złożone, użycie struktur może być świetnym sposobem na uporządkowanie danych.

Przesyłanie danych między funkcjami wierzchołka i fragmentu

Przypominamy, że funkcja @fragment jest jak najprostsza:

index.html (wywołanie createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1);
}

Nie przyjmujesz żadnych danych wejściowych, a jako dane wyjściowe przekazujesz jednolity kolor (czerwony). Jeśli jednak shader wiedziałby więcej o geometryi, którą koloruje, można by użyć tych dodatkowych danych, aby uczynić ją nieco ciekawszą. Co na przykład, gdy chcesz zmienić kolor każdego kwadratu w zależności od jego współrzędnych komórki? Na etapie @vertex wiemy, która komórka jest renderowana. Wystarczy, że przekażesz ją do etapu @fragment.

Aby przekazywać dane między wierzchołkiem a etapami, musisz uwzględnić je w strukturze wyjściowej z wybranym przez nas elementem @location. Ponieważ chcesz przekazać współrzędne komórki, dodaj je do struktury VertexOutput z poprzedniego fragmentu kodu, a potem ustaw je w funkcji @vertex przed zwróceniem wyniku.

  1. Zmień wartość zwracaną przez shader wierzchołkowy w ten sposób:

index.html (wywołanie createShaderModule)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
  @location(0) cell: vec2f, // New line!
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. W funkcji @fragment wartość jest zwracana po dodaniu argumentu o tej samej nazwie @location. (Nazwy nie muszą być takie same, ale łatwiej jest je znaleźć).

index.html (wywołanie createShaderModule)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. Możesz też użyć struktury:

index.html (wywołanie createShaderModule)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. Inną możliwością, ponieważ w Twoim kodzie obie te funkcje są zdefiniowane w tym samym module shadera, jest ponowne użycie struktury wyjściowej etapu @vertex. Ułatwia to przekazywanie wartości, ponieważ nazwy i lokalizacje są spójne.

index.html (wywołanie createShaderModule)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}

Niezależnie od wybranego wzoru masz dostęp do numeru komórki w funkcji @fragment i możesz go użyć, aby wpływać na kolor. W przypadku każdego z tych kodów dane wyjściowe wyglądają tak:

Siatka kwadratów, w której najbliższa lewej kolumna jest zielona, dolna krawędź jest czerwona, a pozostałe kwadraty są żółte.

Teraz jest zdecydowanie więcej kolorów, ale nie wygląda to zbyt dobrze. Możesz się zastanawiać, dlaczego tylko lewy i dolny wiersz są inne. Dzieje się tak, ponieważ wartości kolorów zwracane przez funkcję @fragment muszą być w zakresie od 0 do 1, a wartości spoza tego zakresu są do niego ograniczane. Wartości komórek na osi poziomej i pionowej mieszczą się w zakresie od 0 do 32. Jak widać, pierwszy wiersz i pierwsza kolumna od razu osiągają wartość 1 w czerwonym lub zielonym kanale kolorów, a każda następna komórka ma tę samą wartość.

Jeśli chcesz uzyskać płynniejsze przejście między kolorami, dla każdego kanału kolorów musisz zwrócić wartość ułamkową, zaczynając od zera i kończąc na jednej na każdej osi. Oznacza to kolejny podział przez grid.

  1. Zmień cieniowanie fragmentów w ten sposób:

index.html (wywołanie createShaderModule)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell/grid, 0, 1);
}

Odśwież stronę, aby zobaczyć, że nowy kod daje znacznie ładniejszy gradient kolorów na całej siatce.

Siatka kwadratów w różnych rogach przechodzących z czarnego przez czerwony przez zielony i żółty.

To na pewno poprawa, ale w lewym dolnym rogu jest teraz ciemny róg, w którym siatka staje się czarna. Gdy rozpoczniesz symulację Gry w życie, część siatki będzie trudno widoczna i będzie zasłaniać to, co się dzieje. Byłoby fajnie to rozjaśnić.

Na szczęście masz cały nieużywany kanał kolorów – niebieski – który możesz wykorzystać. Optymalnym efektem jest uzyskanie najjaśniejszego koloru niebieskiego tam, gdzie pozostałe są najciemniejsze, a następnie jego wygaszania w miarę intensywności zwiększenia intensywności pozostałych kolorów. Najłatwiej jest ustawić start kanału na 1 i odjąć jedną z wartości komórek. Może to być c.x lub c.y. Wypróbuj obie opcje i wybierz tę, która Ci odpowiada.

  1. Dodaj jaśniejsze kolory do cieniowania fragmentów, na przykład:

wywołanie createShaderModule

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let c = input.cell / grid;
  return vec4f(c, 1-c.x, 1);
}

Wynik wygląda całkiem nieźle.

Siatka kwadratów, które w różnych rogach zmieniają kolor z czerwonego na zielony, a potem na niebieski i żółty.

To nie jest krok krytyczny. Teraz wygląda lepiej, dlatego znajduje się w odpowiednim pliku źródłowym punktu kontrolnego, a pozostałe zrzuty ekranu w tym ćwiczeniu w Codelabs pokazują tę bardziej kolorową siatkę.

7. Zarządzanie stanem komórki

Następnie musisz określić, które komórki siatki mają się renderować na podstawie stanu zapisanego na GPU. Jest to ważne dla końcowej symulacji.

Wystarczy, że dla każdej komórki będzie sygnał włączania/wyłączania, więc możesz użyć dowolnych opcji, które umożliwiają przechowywanie dużej tablicy niemal dowolnego typu wartości. Możesz pomyśleć, że to kolejny przypadek użycia jednolitych buforów. Chociaż możesz to poprawić, jest to trudniejsze, ponieważ jednolite bufory mają ograniczony rozmiar, nie obsługują tablic o dynamicznych rozmiarach (musisz określić rozmiar tablicy w mechanizmie cieniowania) i nie mogą być w nim zapisywane przez moduły do cieniowania. Ten ostatni element jest najbardziej problematyczny, ponieważ symulację Game of Life chcesz wykonać na GPU w shaderze obliczeniowym.

Na szczęście istnieje inna opcja buforowania, która pozwala uniknąć wszystkich tych ograniczeń.

Tworzenie bufora pamięci masowej

Bufory pamięci to bufory ogólnego przeznaczenia, które można odczytywać i zapisywać w shaderach obliczeniowych oraz odczytywać w shaderach wierzchołkowych. Mogą być bardzo duże i nie wymagają zadeklarowanego rozmiaru w programie do cieniowania, dzięki czemu są bardziej typowe dla pamięci ogólnej. Jest on używany do przechowywania stanu komórki.

  1. Aby utworzyć bufor pamięci dla stanu komórki, użyj kodu, który pewnie już Ci się stał znajomy:

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

Podobnie jak w przypadku buforów wierzchołków i buforów jednolitych, wywołaj funkcję device.createBuffer() z odpowiednim rozmiarem, a następnie określ użycie funkcji GPUBufferUsage.STORAGE.

Możesz wypełnić bufor w taki sam sposób jak wcześniej, wypełniając tablicę typu TypedArray o tej samej wielkości wartościami, a potem wywołując funkcję device.queue.writeBuffer(). Ponieważ chcesz zobaczyć wpływ bufora na siatkę, zacznij od wypełnienia go czymś przewidywalnym.

  1. Aktywuj co trzecią komórkę za pomocą tego kodu:

index.html

// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);

Odczyt bufora pamięci w shaderze

Następnie zaktualizuj shader, aby sprawdzić zawartość bufora pamięci przed renderowaniem siatki. Wygląda to bardzo podobnie do tego, jak dodawano wcześniej stroje.

  1. Zaktualizuj shader za pomocą tego kodu:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

Najpierw dodajesz punkt wiązania, który znajduje się pod siatką. Chcesz zachować te same @group co w formie grid, ale numer @binding musi być inny. Typ var to storage. Aby odzwierciedlić inny typ bufora, a nie pojedynczy wektor, typ podany dla cellState to tablica wartości u32 w celu dopasowania Uint32Array w JavaScript.

Następnie w treści funkcji @vertex wykonaj zapytanie dotyczące stanu komórki. Stan jest przechowywany w płaskim tablicy w buforze pamięci, więc możesz użyć funkcji instance_index, aby wyszukać wartość bieżącej komórki.

Jak wyłączyć komórkę, jeśli stan wskazuje, że jest nieaktywna? Ponieważ stany aktywny i nieaktywny z tablicy mają wartości 1 i 0, możesz skalować geometrię według stanu aktywnego. Skalowanie do wartości 1 pozostawia geometrię bez zmian, a skalowanie do wartości 0 powoduje jej skurczenie do pojedynczego punktu, który jest następnie odrzucany przez procesor graficzny.

  1. Zaktualizuj kod cieniowania, aby przeskalować pozycję według stanu aktywności komórki. Aby spełnić wymagania WGSL dotyczące bezpieczeństwa typów, wartość stanu musi zostać rzutowana na f32:

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

Dodawanie bufora pamięci do grupy wiązania

Zanim stan komórki zacznie obowiązywać, dodaj bufor pamięci do grupy wiązania. Ponieważ jest to część tego samego @group co jednolity bufor, dodaj go do tej samej grupy wiązania w kodzie JavaScript.

  • Dodaj bufor danych w ten sposób:

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  },
  // New entry!
  {
    binding: 1,
    resource: { buffer: cellStateStorage }
  }],
});

Upewnij się, że wartość binding nowego wpisu jest zgodna z wartością @binding() odpowiedniej wartości w cieniowaniu.

Po wykonaniu tych czynności możesz odświeżyć stronę, aby zobaczyć wzór w sieci.

Ukośne paski kolorowych kwadratów od lewego dolnego do prawego górnego rogu na ciemnoniebieskim tle.

Użyj wzorca bufora ping-ponga

Większość symulacji, takich jak ta, którą tworzysz, zwykle korzysta z co najmniej 2 kopii stanu. Na każdym kroku symulacji odczytują stan z jednej kopii i zapisują go w innej. Następnie odwróć kartkę i przeczytaj od miejsca, w którym zapisałeś/zapisałaś wcześniej. Jest to powszechnie nazywany wzorcem ping pong, ponieważ najnowsza wersja stanu przeskakuje między kopiami stanu na każdym kroku.

Dlaczego jest to konieczne? Weź pod uwagę uproszczony przykład: wyobraź sobie, że piszesz bardzo prostą symulację, w której wszystkie aktywne bloki przesuwasz o jedną komórkę w prawo na każdym kroku. Aby wszystko było proste, definiujesz dane i symulację w języku JavaScript:

// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];

function simulate() {
  for (let i = 0; i < state.length-1; ++i) {
    if (state[i] == 1) {
      state[i] = 0;
      state[i+1] = 1;
    }
  }
}

simulate(); // Run the simulation for one step.

Jeśli jednak uruchomisz ten kod, aktywna komórka przejdzie do końca tablicy w jednym kroku. Dlaczego? Ponieważ ciągle aktualizujesz stan na miejscu, przesuwasz aktywną komórkę w prawo, a potem spoglądasz na następną komórkę i... cześć! Jest aktywne. Lepiej przesuń go znowu w prawo. Zmiana danych w tym samym czasie, w którym je obserwujesz, powoduje ich uszkodzenie.

Dzięki temu masz pewność, że kolejny krok symulacji będzie wykonywany tylko na podstawie wyników ostatniego kroku.

// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];

function simulate(inArray, outArray) {
  outArray[0] = 0;
  for (let i = 1; i < inArray.length; ++i) {
     outArray[i] = inArray[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. Użyj tego wzorca w swoim kodzie, aktualizując alokację bufora pamięci, aby utworzyć 2 identyczne bufory:

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. Aby ułatwić wizualizację różnic między tymi dwoma buforami, wypełnij je różnymi danymi:

index.html

// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. Aby wyświetlać różne bufory pamięci w renderowaniu, zaktualizuj grupy wiązania, aby miały 2 różne warianty:

index.html

const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
   device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }],
  })
];

Konfigurowanie pętli renderowania

Do tej pory na każdym odświeżeniu strony odbywało się tylko jedno rysowanie, ale teraz chcesz pokazywać, jak dane zmieniają się w miarę upływu czasu. W tym celu potrzebujesz prostego pętli renderowania.

Pętla renderowania to nieustannie powtarzająca się pętla, która w określonym odstępie czasu rysuje treści na płótnie. Wiele gier i innych treści, które mają płynnie animować się, korzysta z funkcji requestAnimationFrame() do planowania wywołań zwrotnych z częstotliwością odświeżania ekranu (60 razy na sekundę).

Ta aplikacja może też korzystać z tego mechanizmu, ale w tym przypadku prawdopodobnie zechcesz, aby aktualizacje były wyświetlane w większych odstępach czasu, aby łatwiej było Ci śledzić przebieg symulacji. Zamiast tego możesz samodzielnie zarządzać pętlą, aby kontrolować szybkość aktualizacji symulacji.

  1. Najpierw wybierz częstotliwość aktualizacji naszej symulacji (wystarczy 200 ms, ale możesz też przyspieszyć lub spowolnić tę symulację), a potem śledzić liczbę ukończonych etapów symulacji.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. Następnie przenieś cały kod, którego obecnie używasz do renderowania, do nowej funkcji. Zaplanuj powtarzanie tej funkcji w wybranym interwale za pomocą funkcji setInterval(). Upewnij się, że funkcja aktualizuje również liczbę kroków, i na jej podstawie wybierz, które z 2 grup wiązania mają zostać połączone.

index.html

// Move all of our rendering code into a function
function updateGrid() {
  step++; // Increment the step count
  
  // Start a render pass 
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
      storeOp: "store",
    }]
  });

  // Draw the grid.
  pass.setPipeline(cellPipeline);
  pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
  pass.setVertexBuffer(0, vertexBuffer);
  pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

  // End the render pass and submit the command buffer
  pass.end();
  device.queue.submit([encoder.finish()]);
}

// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);

Gdy uruchomisz aplikację, zauważysz, że przełącza ona się między wyświetlaniem 2 utworzonych przez Ciebie buforów stanu.

Krzyżowe paski kolorowych kwadratów biegnące od lewego dolnego rogu do prawego górnego rogu na ciemnoniebieskim tle. Pionowe paski kolorowych kwadratów na ciemnoniebieskim tle.

Na tym kończymy czynności związane z renderowaniem. W następnym kroku, w którym zaczniesz używać shaderów obliczeniowych, możesz wyświetlić dane wyjściowe symulacji Game of Life.

Oczywiście możliwości WebGPU wykraczają poza zakres tego krótkiego wprowadzenia, ale reszta wykracza poza zakres tego CodeLab. Mamy nadzieję, że pomoże Ci to zrozumieć, jak działa renderowanie WebGPU, i ułatwia zapoznawanie się z bardziej zaawansowanymi technikami, takimi jak renderowanie 3D.

8. Uruchamianie symulacji

Ostatni ważny element układanki: symulacja Game of Life w shaderze obliczeniowym.

W końcu możesz używać shaderów obliczeniowych

W tym ćwiczeniu dowiesz się więcej o shaderach obliczeniowych.

Shader obliczeniowy jest podobny do shaderów wierzchołkowego i fragmentowego, ponieważ jest przeznaczony do działania z bardzo dużą równoległością na procesorze graficznym, ale w odróżnieniu od pozostałych 2 etapów shadera nie ma określonego zestawu danych wejściowych i wyjściowych. Dane są odczytywane i zapisywane wyłącznie ze źródeł wybranych przez Ciebie, takich jak bufor pamięci masowej. Oznacza to, że zamiast wykonywania raz dla każdego wierzchołka, wystąpienia lub piksela musisz określić, ile razy chcesz wywołać funkcję shadera. Gdy uruchomisz shader, zobaczysz, które wywołanie jest przetwarzane, i będziesz mieć możliwość określenia, do jakich danych chcesz uzyskać dostęp i jakie operacje chcesz wykonać.

Shadery obliczeniowe, podobnie jak shadery wierzchołkowe i fragmentowe, muszą być tworzone w module shadera. Aby rozpocząć, dodaj ten moduł do kodu. Jak można się domyślić, ze względu na strukturę innych zaimplementowanych przez Ciebie shaderów główna funkcja shadera obliczeniowego musi być oznaczona atrybutem @compute.

  1. Utwórz shader obliczeniowy za pomocą tego kodu:

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

Ponieważ procesory graficzne są często używane do grafiki 3D, shadery obliczeniowe są tak skonstruowane, że możesz określić, ile razy ma być wywoływany shader wzdłuż osi X, Y i Z. Pozwala to bardzo łatwo wysyłać prace zgodne z siatką 2D lub 3D, co jest świetnym rozwiązaniem. Chcesz wywołać ten shader GRID_SIZE razy GRID_SIZE razy, po jednym razie dla każdej komórki symulacji.

Ze względu na charakter architektury sprzętowej GPU ta siatka jest podzielona na grupy robocze. Grupa robocza ma rozmiar X, Y i Z i chociaż może wynosić 1 rozmiar każdego z nich, często warto zwiększyć wydajność grupy. Dla shadera wybierz dowolny rozmiar grupy roboczej 8 x 8. Jest to przydatne, by monitorować je w kodzie JavaScript.

  1. Określ stałą wielkość grupy roboczej w ten sposób:

index.html

const WORKGROUP_SIZE = 8;

Musisz też dodać rozmiar grupy roboczej do samej funkcji shadera, co możesz zrobić za pomocą literałów szablonu JavaScript, aby móc łatwo używać zdefiniowanej właśnie stałej.

  1. Dodaj rozmiar grupy roboczej do funkcji shadera w ten sposób:

index.html (wywołanie funkcji createShaderModule)

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {

}

Informuje on shader, że zadanie wykonywane przez tę funkcję jest wykonywane w grupach (8 x 8 x 1). (każda oś, którą pominiesz, zostanie domyślnie ustawiona na 1, ale musisz określić co najmniej oś X).

Podobnie jak w przypadku innych etapów shadera, do funkcji shadera obliczeniowego można podać różne wartości @builtin, aby określić, w którym miejscu jest wywołanie i jakie zadanie należy wykonać.

  1. Dodaj wartość @builtin w ten sposób:

index.html (wywołanie funkcji createShaderModule)

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

Przekazujesz wbudowaną funkcję global_invocation_id, która jest trójwymiarowym wektorem liczb całkowitych bez znaku. Funkcja ta informuje, w którym miejscu na siatce wywołań shadera się znajdujesz. Uruchamiasz ten cieniowanie raz na każdą komórkę siatki. Otrzymujesz liczby takie jak (0, 0, 0), (1, 0, 0), (1, 1, 0)... aż do (31, 31, 0), co oznacza, że możesz je traktować jako indeks komórki, z którą będziesz pracować.

Shadery obliczeniowe mogą też używać uniformów, które są używane tak samo jak w shaderach wierzchołkowych i fragmentowych.

  1. Użyj uniformu w shaderze obliczeniowym, aby określić rozmiar siatki, na przykład w ten sposób:

index.html (wywołanie funkcji createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f; // New line

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

Podobnie jak w shaderze wierzchołkowym, stan komórki jest również udostępniany jako bufor pamięci. Ale w tym przypadku są potrzebne 2. Shadery obliczeniowe nie mają wymaganego wyjścia, takiego jak pozycja wierzchołka czy kolor fragmentu, więc zapisanie wartości do bufora pamięci lub tekstury jest jedynym sposobem na uzyskanie wyników z shadera obliczeniowego. Użyj metody ping-pong, którą poznaliśmy wcześniej. Masz jeden bufor pamięci, który przekazuje bieżący stan siatki, i drugi, do którego zapisujesz nowy stan siatki.

  1. Wyświetl stan wejścia i wyjścia komórki jako bufor pamięci:

index.html (wywołanie funkcji createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;
    
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

Pamiętaj, że pierwszy bufor pamięci jest zadeklarowany za pomocą var<storage>, co powoduje, że jest on tylko do odczytu, ale drugi bufor pamięci jest zadeklarowany za pomocą var<storage, read_write>. Umożliwia to zarówno odczyt, jak i zapis w buforze, przy czym dane z tego bufora są używane jako dane wyjściowe cieniowania obliczeniowego. (w WebGPU nie ma trybu tylko do zapisu).

Musisz mieć sposób na zmapowanie indeksu komórek na linearną tablicę pamięci masowej. Jest to w zasadzie odwrotność niż w trybie cieniowania wierzchołków, czyli w wyniku pobrania liniowego obiektu instance_index i zmapowania go na komórkę siatki 2D. (Przypominamy, że algorytm do tego zadania to vec2f(i % grid.x, floor(i / grid.x))).

  1. Napisać funkcję, która działa w drugim kierunku. Funkcja ta pobiera wartość Y komórki, mnoży ją przez szerokość siatki, a potem dodaje wartość X komórki.

index.html (wywołanie funkcji createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

// New function   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  
}

I na koniec, aby przekonać się, że działa, zastosuj bardzo prosty algorytm: jeśli komórka jest aktualnie włączona, wyłącza się i na odwrót. To jeszcze nie jest Gra Życia, ale wystarczy, aby pokazać, że shader obliczeniowy działa.

  1. Dodaj prosty algorytm, np.

index.html (wywołanie funkcji createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines. Flip the cell state every step.
  if (cellStateIn[cellIndex(cell.xy)] == 1) {
    cellStateOut[cellIndex(cell.xy)] = 0;
  } else {
    cellStateOut[cellIndex(cell.xy)] = 1;
  }
}

To wszystko na temat cieniowania obliczeniowego. Zanim jednak zobaczysz wyniki, musisz wprowadzić jeszcze kilka zmian.

Korzystanie z grupy wiązania i układów przepływu danych

Jedną z rzeczy, które zauważysz po zastosowaniu cieniowania, jest fakt, że wykorzystuje on w dużej mierze te same dane wejściowe (uniformy i bufory pamięci) co potok renderowania. Możesz więc pomyśleć, że wystarczy użyć tych samych grup wiązania. Dobra wiadomość jest taka, że możesz to zrobić. Wymaga to tylko nieco więcej ręcznej konfiguracji.

Za każdym razem, gdy tworzysz grupę wiązania, musisz podać GPUBindGroupLayout. Wcześniej to rozmieszczenie było uzyskiwane przez wywołanie funkcji getBindGroupLayout() w potoku renderowania, który z kolei tworzył je automatycznie, ponieważ podczas tworzenia został podany parametr layout: "auto". To podejście sprawdza się, gdy używasz tylko jednego potoku, ale jeśli masz kilka strumieni, które mają udostępniać zasoby, musisz wyraźnie utworzyć układ, a następnie przekazać go zarówno grupie wiązania, jak i potokom.

Aby zrozumieć, dlaczego tak się dzieje, weź pod uwagę, że w swoich pipeline’ach renderowania używasz jednego jednolitego bufora i jednego bufora pamięci, ale w właśnie napisanym przez Ciebie shaderze obliczeniowym potrzebujesz drugiego bufora pamięci. Ponieważ dwa cieniowanie używają tych samych wartości @binding dla jednolitego i pierwszego bufora pamięci masowej, możesz je udostępniać między potokami, a potok renderowania ignoruje drugi bufor, którego nie używa. Chcesz utworzyć układ, który opisuje wszystkie zasoby obecne w grupie wiązania, a nie tylko te, których używa konkretny potok.

  1. Aby utworzyć taki układ, wywołaj funkcję device.createBindGroupLayout():

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    // Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: {} // Grid uniform buffer
  }, {
    binding: 1,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: { type: "read-only-storage"} // Cell state input buffer
  }, {
    binding: 2,
    visibility: GPUShaderStage.COMPUTE,
    buffer: { type: "storage"} // Cell state output buffer
  }]
});

Przypomina to tworzenie grupy powiązań, ponieważ opisuje listę entries. Różnica polega na tym, że zamiast dostarczać sam zasób, trzeba opisać typ zasobu i sposób jego użycia.

W każdym wpisie podajesz numer binding zasobu, który (jak się dowiedziałeś/-aś podczas tworzenia grupy wiązania) odpowiada wartości @binding w shaderach. Musisz też podać visibility, czyli flagi GPUShaderStage wskazujące, które etapy shadera mogą używać zasobu. Chcesz, aby bufor danych uniform i pierwszy bufor danych były dostępne w shaderach wierzchołkowych i obliczeniowych, ale drugi bufor danych musi być dostępny tylko w shaderach obliczeniowych.

Na koniec wskazujesz, jaki typ zasobu jest używany. Jest to inny klucz słownika, w zależności od tego, co chcesz udostępnić. W tym przypadku wszystkie 3 zasoby są buforami, więc do określenia opcji dla każdego z nich używasz klucza buffer. Inne opcje to na przykład texture lub sampler, ale nie są one potrzebne w tym przypadku.

W słowniku bufora ustawiasz opcje, takie jak type bufora. Wartość domyślna to "uniform", więc możesz pozostawić słownik pusty, aby związać 0. (Musisz jednak ustawić co najmniej buffer: {}, tak aby wpis został zidentyfikowany jako bufor). Powiązanie 1 ma typ "read-only-storage", ponieważ nie używasz go z dostępem read_write w obszarze cieniowania, a powiązanie 2 ma typ "storage", ponieważ używasz go z dostępem read_write.

Po utworzeniu bindGroupLayout możesz go przekazać podczas tworzenia grup wiązania zamiast wysyłać zapytanie do grupy wiązania z poziomu potoku. Oznacza to, że musisz dodać nowy wpis bufora pamięci do każdej grupy wiązania, aby dopasować ją do zdefiniowanego przez Ciebie układu.

  1. Zaktualizuj proces tworzenia grupy powiązań w następujący sposób:

index.html

// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: bindGroupLayout, // Updated Line
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[1] }
    }],
  }),
  device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: bindGroupLayout, // Updated Line

    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
];

Po zaktualizowaniu grupy powiązań tak, aby używała tego jawnego układu grupy powiązań, należy zaktualizować potok renderowania, tak aby używał tego samego układu.

  1. Utwórz GPUPipelineLayout.

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

Schemat potoku to lista układów grupy bind (w tym przypadku masz 1 taki układ), których używa co najmniej 1 potok. Kolejność układów grupy wiązania w tablicy musi być zgodna z atrybutami @group w shaderach. (Oznacza to, że bindGroupLayout jest powiązany z @group(0)).

  1. Gdy już masz układ potoku, zaktualizuj potok renderowania, aby używać go zamiast "auto".

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: pipelineLayout, // Updated!
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

Tworzenie potoku obliczeniowego

Tak samo jak do korzystania z cieniowania wierzchołków i fragmentów jest potrzebny potok renderowania, tak samo potrzebujesz potoku obliczeniowego, z którego będzie korzystać program do cieniowania. Na szczęście potoki obliczeniowe są znacznie mniej skomplikowane niż potoki renderowania, ponieważ nie mają żadnego stanu do ustawienia, tylko shader i layout.

  • Utwórz potok obliczeniowy za pomocą tego kodu:

index.html

// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
  label: "Simulation pipeline",
  layout: pipelineLayout,
  compute: {
    module: simulationShaderModule,
    entryPoint: "computeMain",
  }
});

Pamiętaj, że zamiast "auto" przekazujesz nową wartość pipelineLayout, tak jak w zaktualizowanym procesie renderowania. Dzięki temu zarówno proces renderowania, jak i proces obliczeniowy mogą używać tych samych grup wiązania.

Obliczanie kart dostępu

Dochodzimy do punktu, w którym możesz zacząć korzystać z potoku obliczeniowego. Ponieważ renderowanie odbywa się w ramach passu renderowania, pewnie wiesz, że musisz przeprowadzić obliczenia w ramach passu obliczeń. Obliczenia i renderowanie mogą odbywać się w tym samym kodzie źródłowym, więc warto nieco zmienić funkcję updateGrid.

  1. Przenieś utworzony koder na górę funkcji, a następnie rozpocznij przekazywanie obliczeń z nim (przed step++).

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
  
// Start a render pass...

Podobnie jak w przypadku potoku przetwarzania, przetwarzanie w przelotnym trybie jest dużo łatwiejsze do uruchomienia niż w przypadku renderowania, ponieważ nie musisz się martwić o dołączone pliki.

Przetwarzanie musi być wykonane przed renderowaniem, ponieważ pozwala to renderowaniu na natychmiastowe korzystanie z najnowszych wyników z przetwarzania. Z tego powodu zwiększasz też liczbę step między kolejnymi przepustami, aby bufor wyjściowy w przepływie danych do obliczeń stał się buforem wejściowym w przepływie danych do renderowania.

  1. Następnie skonfiguruj potok i grupę powiązań w ramach przebiegu obliczeniowego, używając tego samego wzorca do przełączania się między grupami powiązań co w przypadku karnetu renderowania.

index.html

const computePass = encoder.beginComputePass();

// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

computePass.end();
  1. Na koniec, zamiast rysowania jak w przesłaniu renderowania, wysyłasz zadanie do shadera obliczeniowego, podając, ile grup roboczych chcesz wykonać na każdej osi.

index.html

const computePass = encoder.beginComputePass();

computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);

computePass.end();

Bardzo ważne jest to, że liczba przekazana do funkcji dispatchWorkgroups() nie jest liczbą wywołań. Jest to liczba grup roboczych do wykonania, zgodnie z definicją w programie @workgroup_size do cieniowania.

Jeśli chcesz, aby shader był wykonywany 32 razy na 32 elementy, aby objąć całą siatkę, a rozmiar grupy roboczej to 8 × 8, musisz wysłać grupy robocze 4 × 4 (4 × 8 = 32). Dlatego dzielisz rozmiar siatki przez rozmiar grupy roboczej i przekazujesz tę wartość do funkcji dispatchWorkgroups().

Teraz możesz ponownie odświeżyć stronę. Siatka powinna odwracać się przy każdej aktualizacji.

Krzyżowe paski kolorowych kwadratów biegnące od lewego dolnego rogu do prawego górnego rogu na ciemnoniebieskim tle. Ukośne paski kolorowych kwadratów o szerokości 2 kwadratów biegnące od lewego dolnego rogu do prawego górnego rogu na ciemnoniebieskim tle. Odwrócenie poprzedniego obrazu.

Implement the algorithm for the Game of Life

Zanim zaktualizujesz shader obliczeniowy, aby zaimplementować ostateczny algorytm, wróć do kodu inicjującego zawartość bufora pamięci masowej i zaktualizuj go, aby generować losowy bufor przy każdym wczytaniu strony. (Regularne wzory nie są dobrym rozwiązaniem na początek w grze Game of Life). Możesz w dowolny sposób losowego wyboru wartości, ale istnieje prosty sposób, który pozwoli Ci w prosty sposób uzyskać wiarygodne wyniki.

  1. Aby każda komórka znajdowała się w losowym stanie, zaktualizuj inicjalizację cellStateArray do tego kodu:

index.html

// Set each cell to a random state, then copy the JavaScript array 
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
  cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

Możesz w końcu wdrożyć logikę symulacji „Gra w życie”. Po wszystkim, co trzeba było zrobić, kod shadera może okazać się zaskakująco prosty.

Najpierw musisz wiedzieć, ile sąsiadów jest aktywnych. Nie interesuje Cię, które z nich są aktywne, tylko ich liczba.

  1. Aby ułatwić uzyskiwanie danych z sąsiednich komórek, dodaj funkcję cellActive, która zwraca wartość cellStateIn dla danej współrzędnej.

index.html (wywołanie funkcji createShaderModule)

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

Funkcja cellActive zwraca 1, jeśli komórka jest aktywna, więc dodanie wartości zwracanej przez funkcję cellActive dla wszystkich ośmiu sąsiednich komórek pozwala określić, ile sąsiednich komórek jest aktywnych.

  1. Znajdź liczbę aktywnych sąsiadów na przykład:

index.html (wywołanie Compute createShaderModule)

fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines:
  // Determine how many active neighbors this cell has.
  let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                        cellActive(cell.x+1, cell.y) +
                        cellActive(cell.x+1, cell.y-1) +
                        cellActive(cell.x, cell.y-1) +
                        cellActive(cell.x-1, cell.y-1) +
                        cellActive(cell.x-1, cell.y) +
                        cellActive(cell.x-1, cell.y+1) +
                        cellActive(cell.x, cell.y+1);

Powoduje to jednak drobny problem: co się stanie, gdy komórka, którą sprawdzasz, znajduje się poza krawędzią planszy? Zgodnie z obecną logiką cellIndex(), dane albo przelewają się do następnego lub poprzedniego wiersza, albo wychodzą poza krawędź bufora.

W przypadku Game of Life częstym i łatwym sposobem rozwiązania tego problemu jest traktowanie komórek na krawędzi siatki jako sąsiadów komórek na przeciwległej krawędzi, co powoduje pewien efekt zawijania.

  1. Obsługa zawinięcia siatki dzięki niewielkiej zmianie w funkcji cellIndex().

index.html (wywołanie funkcji createShaderModule)

fn cellIndex(cell: vec2u) -> u32 {
  return (cell.y % u32(grid.y)) * u32(grid.x) +
         (cell.x % u32(grid.x));
}

Używając operatora % do zawijania komórek X i Y, gdy wykraczają poza rozmiar siatki, masz pewność, że nie będziesz nigdy uzyskiwać dostępu poza granice bufora pamięci masowej. Dzięki temu możesz mieć pewność, że liczba activeNeighbors jest przewidywalna.

Następnie stosujesz jedną z 4 reguł:

  • Komórka z mniej niż 2 sąsiadami staje się nieaktywna.
  • Każda aktywna komórka z 2 lub 3 sąsiadami pozostanie aktywna.
  • Każda nieaktywna komórka z dokładnie 3 sąsiadami staje się aktywna.
  • Każda komórka z większą liczbą niż 3 sąsiedzi stanie się nieaktywna.

Można to zrobić za pomocą serii instrukcji if, jednak WGSL obsługuje również instrukcje Switch, które idealnie pasują do tej logiki.

  1. Zaimplementuj logikę gry w życie:

index.html (wywołanie funkcji createShaderModule)

let i = cellIndex(cell.xy);

// Conway's game of life rules:
switch activeNeighbors {
  case 2: { // Active cells with 2 neighbors stay active.
    cellStateOut[i] = cellStateIn[i];
  }
  case 3: { // Cells with 3 neighbors become or stay active.
    cellStateOut[i] = 1;
  }
  default: { // Cells with < 2 or > 3 neighbors become inactive.
    cellStateOut[i] = 0;
  }
}

Przykładowo, ostateczne wywołanie modułu shadera obliczeniowego wygląda teraz tak:

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

    @group(0) @binding(1) var<storage> cellStateIn: array<u32>;
    @group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

    fn cellIndex(cell: vec2u) -> u32 {
      return (cell.y % u32(grid.y)) * u32(grid.x) +
              (cell.x % u32(grid.x));
    }

    fn cellActive(x: u32, y: u32) -> u32 {
      return cellStateIn[cellIndex(vec2(x, y))];
    }

    @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
    fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
      // Determine how many active neighbors this cell has.
      let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                            cellActive(cell.x+1, cell.y) +
                            cellActive(cell.x+1, cell.y-1) +
                            cellActive(cell.x, cell.y-1) +
                            cellActive(cell.x-1, cell.y-1) +
                            cellActive(cell.x-1, cell.y) +
                            cellActive(cell.x-1, cell.y+1) +
                            cellActive(cell.x, cell.y+1);

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

I to wszystko. To już wszystko. Odśwież stronę i patrz, jak Twój nowo skonstruowany automatyzator komórkowy się rozrasta.

Zrzut ekranu przedstawiający przykładowy stan symulacji Game of Life z kolorowymi komórkami na ciemnoniebieskim tle.

9. Gratulacje!

Utworzyłeś/utworzyłaś wersję klasycznej symulacji gry Conwaya „Game of Life”, która działa całkowicie na GPU za pomocą interfejsu WebGPU API.

Co dalej?

Więcej informacji

Dokumenty referencyjne