Twoja pierwsza aplikacja WebGPU

Pierwsza aplikacja WebGPU

Informacje o tym ćwiczeniu (w Codelabs)

subjectOstatnia aktualizacja: lip 17, 2025
account_circleAutorzy: Brandon Jones, François Beaufort

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, który umożliwia dostęp do funkcji GPU w aplikacjach internetowych.

Nowoczesny interfejs API

Przed WebGPU istniało WebGL, które oferowało podzbiór funkcji WebGPU. Umożliwiła tworzenie nowej klasy bogatych treści internetowych, a deweloperzy stworzyli dzięki niej niesamowite rzeczy. Opierał się jednak na interfejsie OpenGL ES 2.0, który został wprowadzony w 2007 roku i bazował na jeszcze starszym interfejsie OpenGL API. W tym czasie procesory graficzne znacznie się rozwinęły, a także interfejsy API, które są używane do komunikacji z nimi, takie jak Direct3D 12, MetalVulkan.

WebGPU udostępnia na platformie internetowej zalety tych nowoczesnych interfejsów API. Koncentruje się na włączaniu funkcji GPU na różnych platformach, a jednocześnie udostępnia interfejs API, który jest naturalny w internecie i mniej rozbudowany niż niektóre natywne interfejsy API, na których jest oparty.

Renderowanie

Procesory GPU są często kojarzone z szybkim renderowaniem szczegółowej grafiki i nie inaczej jest w przypadku WebGPU. Zawiera funkcje wymagane do obsługi wielu najpopularniejszych obecnie technik renderowania na komputerach i urządzeniach mobilnych. 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 umożliwia wykorzystanie potencjału GPU do wykonywania ogólnych, wysoce równoległych obciążeń. Te shadery obliczeniowe mogą być używane samodzielnie, bez komponentu renderowania, lub jako ściśle zintegrowana część potoku renderowania.

Z tego ćwiczenia w Codelabs dowiesz się, jak wykorzystać możliwości renderowania i obliczeniowe WebGPU, aby utworzyć prosty projekt wprowadzający.

Co utworzysz

W ramach tego ćwiczenia z programowania utworzysz Grę w życie Conwaya za pomocą WebGPU. Twoja aplikacja będzie:

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

Zrzut ekranu przedstawiający gotowy produkt, który powstanie w ramach tych ćwiczeń z programowania

Gra w życie to tak zwany automat komórkowy, w którym siatka komórek zmienia stan w czasie na podstawie określonego zestawu reguł. W grze w życie komórki stają się aktywne lub nieaktywne w zależności od tego, ile sąsiednich komórek jest aktywnych. Prowadzi to do powstawania ciekawych wzorów, które zmieniają się na Twoich oczach.

Czego się nauczysz

  • Jak skonfigurować WebGPU i płótno.
  • Jak narysować prostą geometrię 2D.
  • Jak używać programów cieniujących wierzchołki i fragmenty do modyfikowania rysowanych elementów.
  • Jak używać programów cieniujących do obliczeń w celu przeprowadzenia prostej symulacji.

W tym Codelabie poznasz podstawowe koncepcje 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

  • najnowsza wersja Chrome (113 lub nowsza) na urządzeniu z ChromeOS, macOS lub Windows; WebGPU to interfejs API działający w różnych przeglądarkach i na różnych platformach, ale nie jest jeszcze dostępny wszędzie.
  • Znajomość HTML-a, JavaScriptu i Narzędzi deweloperskich w Chrome.

Znajomość innych interfejsów API grafiki, takich jak WebGL, Metal, Vulkan czy Direct3D, nie jest wymagana, ale jeśli masz z nimi doświadczenie, prawdopodobnie zauważysz wiele podobieństw do WebGPU, które mogą Ci pomóc w szybszym opanowaniu tego interfejsu.

2. Konfiguracja

Pobierz kod

To ćwiczenie nie ma żadnych zależności i zawiera wszystkie kroki potrzebne do utworzenia aplikacji WebGPU, więc nie musisz mieć żadnego kodu, aby zacząć. Przykłady działającego kodu, które mogą służyć jako punkty kontrolne, znajdziesz na stronie https://github.com/GoogleChromeLabs/your-first-webgpu-app-codelab. Możesz je sprawdzić i wykorzystać, jeśli napotkasz trudności.

Skorzystaj z konsoli dewelopera!

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

Podczas tworzenia aplikacji z użyciem WebGPU napotkasz problemy, zwłaszcza na początku. To normalne. Twórcy interfejsu API zdają sobie sprawę z trudności związanych z pracą nad GPU i dołożyli wszelkich starań, aby w przypadku, gdy kod WebGPU spowoduje błąd, w konsoli dewelopera pojawiały się bardzo szczegółowe i pomocne komunikaty, które ułatwią identyfikację i rozwiązanie problemu.

Pozostawienie otwartej konsoli podczas pracy nad dowolną aplikacją internetową jest zawsze przydatne, ale w tym przypadku jest to szczególnie ważne.

3. Inicjowanie WebGPU

Zacznij od <canvas>

WebGPU można używać bez wyświetlania niczego na ekranie, jeśli chcesz tylko wykonywać obliczenia. Jeśli jednak chcesz coś renderować, tak jak to zrobimy w tym laboratorium, potrzebujesz obszaru rysowania. To dobry punkt wyjścia.

Utwórz nowy dokument HTML z jednym elementem <canvas> oraz tagiem <script>, w którym będziemy wysyłać zapytania do elementu canvas. (Możesz też użyć pliku 00-starter-page.html).

  • 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 przejść do szczegółów WebGPU. Po pierwsze, musisz pamiętać, że rozpowszechnienie interfejsów API takich jak WebGPU w całym ekosystemie internetowym może zająć trochę czasu. Dlatego pierwszym dobrym krokiem jest sprawdzenie, czy przeglądarka użytkownika może korzystać z WebGPU.

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

index.html

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

Najlepiej poinformować użytkownika, że WebGPU jest niedostępne, przełączając stronę w tryb, który nie korzysta z WebGPU. (Może zamiast tego można by użyć WebGL?) Na potrzeby tego ćwiczenia wystarczy jednak zgłosić błąd, aby zatrzymać dalsze wykonywanie kodu.

Gdy już wiesz, że przeglądarka obsługuje WebGPU, pierwszym krokiem w inicjowaniu WebGPU w aplikacji jest wysłanie żądania GPUAdapter. Adapter to reprezentacja w WebGPU konkretnego elementu sprzętowego GPU na urządzeniu.

  1. Aby uzyskać adapter, 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 wynosić null, więc musisz uwzględnić taką możliwość. Może się tak zdarzyć, jeśli przeglądarka użytkownika obsługuje WebGPU, ale jego karta graficzna nie ma wszystkich funkcji niezbędnych do korzystania z WebGPU.

W większości przypadków wystarczy pozwolić przeglądarce wybrać domyślny adapter, tak jak w tym przykładzie, ale w bardziej zaawansowanych sytuacjach można przekazać argumenty do requestAdapter(), które określają, czy chcesz używać sprzętu o niskim poborze mocy, czy o wysokiej wydajności na urządzeniach z wieloma układami GPU (np. na niektórych laptopach).

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

  1. Pobierz urządzenie, wywołując funkcję adapter.requestDevice(), która również zwraca obietnicę.

index.html

const device = await adapter.requestDevice();

Podobnie jak w przypadku requestAdapter(), możesz tu przekazywać opcje, aby korzystać z bardziej zaawansowanych funkcji, takich jak włączanie określonych funkcji sprzętowych czy żądanie wyższych limitów. Jednak w Twoim przypadku domyślne ustawienia będą wystarczające.

Konfigurowanie Canvas

Teraz, gdy masz już urządzenie, musisz jeszcze skonfigurować obszar roboczy, aby można było go używać z utworzonym urządzeniem.

  • Aby to zrobić, najpierw poproś o GPUCanvasContext z obszaru roboczego, wywołując canvas.getContext("webgpu"). (Jest to to samo wywołanie, którego używasz do inicjowania kontekstów Canvas 2D lub WebGL, odpowiednio przy użyciu typów kontekstu 2dwebgl). Zwrócony obiekt 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,
});

Możesz tu przekazać kilka opcji, ale najważniejsze z nich to device, z którym będziesz używać kontekstu, oraz format, czyli format tekstury, którego ma 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 laboratorium. Ważne jest, aby wiedzieć, że kontekst elementu canvas udostępnia tekstury, w których kod może rysować, a format, którego używasz, może wpływać na to, jak wydajnie element canvas wyświetla te obrazy. Różne typy urządzeń działają najlepiej, gdy używają różnych formatów tekstur. Jeśli nie używasz preferowanego formatu urządzenia, może to powodować dodatkowe kopie pamięci w tle, zanim obraz będzie mógł być wyświetlany jako część strony.

Na szczęście nie musisz się tym zbytnio przejmować, ponieważ WebGPU informuje, jakiego formatu użyć w przypadku elementu canvas. W większości przypadków należy przekazać wartość zwróconą przez wywołanie funkcji navigator.gpu.getPreferredCanvasFormat(), jak pokazano powyżej.

Czyszczenie obszaru roboczego

Gdy masz już urządzenie i płótno zostało z nim skonfigurowane, możesz zacząć używać urządzenia do zmiany zawartości płótna. Zacznij od wypełnienia go jednolitym kolorem.

Aby to zrobić – lub wykonać w WebGPU niemal dowolną inną czynność – musisz przekazać do procesora graficznego polecenia z instrukcjami.

  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 procesora graficznego, są związane z renderowaniem (w tym przypadku z czyszczeniem obszaru rysowania), więc następnym krokiem jest użycie encoder, aby rozpocząć przekazywanie renderowania.

Podczas renderowania wszystkie operacje rysowania w WebGPU są wykonywane. Każda z nich zaczyna się od wywołania beginRenderPass(), które określa tekstury odbierające dane wyjściowe wszystkich wykonanych poleceń rysowania. Bardziej zaawansowane zastosowania mogą zapewniać kilka tekstur, zwanych załącznikami, które służą do różnych celów, np. do przechowywania głębi renderowanej geometrii lub zapewniania wygładzania krawędzi. W przypadku tej aplikacji wystarczy jednak tylko jeden.

  1. Pobierz teksturę z utworzonego wcześniej kontekstu elementu canvas, wywołując funkcję context.getCurrentTexture(), która zwraca teksturę o szerokości i wysokości w pikselach zgodnych z atrybutami widthheight elementu canvas oraz z wartością format określoną podczas wywoływania funkcji context.configure().

index.html

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

Tekstura jest podana jako właściwość view elementu colorAttachment. W przypadku przejść renderowania musisz podać GPUTextureView zamiast GPUTexture, co określa, które części tekstury mają być renderowane. Ma to znaczenie tylko w przypadku bardziej zaawansowanych zastosowań, więc w tym przypadku wywołujesz funkcję createView() bez argumentów na teksturze, co oznacza, że chcesz, aby przepustka renderowania używała całej tekstury.

Musisz też określić, co ma się dziać z teksturą na początku i na końcu procesu renderowania:

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

Gdy renderowanie się rozpocznie, nie musisz nic robić. Przynajmniej na razie. Samo rozpoczęcie przekazywania renderowania za pomocą loadOp: "clear" wystarczy, aby wyczyścić widok tekstury i obszar rysowania.

  1. Zakończ przekazywanie renderowania, dodając to wywołanie bezpośrednio po beginRenderPass():

index.html

pass.end();

Ważne jest, aby pamiętać, że samo wywołanie tych funkcji nie powoduje, że procesor graficzny cokolwiek robi. Rejestrują tylko polecenia, które procesor graficzny ma wykonać później.

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

index.html

const commandBuffer = encoder.finish();
  1. Prześlij bufor poleceń do procesora graficznego za pomocą funkcji queue interfejsu GPUDevice. Kolejka wykonuje wszystkie polecenia procesora graficznego, zapewniając ich prawidłową kolejność i synchronizację. 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 często te 2 kroki są łączone w jeden, tak jak na przykładowych stronach w tym samouczku:

index.html

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

Po przesłaniu poleceń do procesora graficznego pozwól JavaScriptowi przekazać kontrolę z powrotem do przeglądarki. W tym momencie przeglądarka widzi, że bieżąca tekstura kontekstu została zmieniona, i aktualizuje obszar roboczy, aby wyświetlić tę teksturę jako obraz. Jeśli po tym czasie chcesz ponownie zaktualizować zawartość obszaru roboczego, musisz nagrać i przesłać nowy bufor poleceń, ponownie wywołując context.getCurrentTexture(), aby uzyskać nową teksturę dla przepustki renderowania.

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

Czarny obszar roboczy, który oznacza, że WebGPU został użyty do wyczyszczenia zawartości obszaru roboczego.

Wybierz kolor!

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

  1. W wywołaniu encoder.beginRenderPass() dodaj nowy wiersz z symbolem clearValue do colorAttachment, np. tak:

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 informuje etap renderowania, jakiego koloru ma użyć podczas wykonywania operacji clear na początku etapu. Słownik przekazywany do tej funkcji zawiera 4 wartości: r dla czerwonego, g dla zielonego, b dla niebieskiego i a dla alfa (przezroczystość). Każda wartość może mieścić się w zakresie od 0 do 1 i razem opisują wartość tego kanału koloru. 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 } to średni szary.
  • Domyślny kolor to { r: 0, g: 0, b: 0, a: 0 }, czyli przezroczysta czerń.

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

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

Płótno wyczyszczone do ciemnoniebieskiego koloru, aby zademonstrować, jak zmienić domyślny kolor czyszczenia.

4. Rysowanie geometrii

Po ukończeniu tej sekcji aplikacja będzie rysować na płótnie prostą geometrię: kolorowy kwadrat. Ostrzegamy, że może się to wydawać dużo pracy w porównaniu z tak prostym wynikiem, ale dzieje się tak, ponieważ WebGPU został zaprojektowany do bardzo wydajnego renderowania dużej ilości geometrii. Skutkiem ubocznym tej wydajności jest to, że wykonywanie stosunkowo prostych czynności może wydawać się niezwykle trudne, ale jest to oczekiwane, jeśli korzystasz z interfejsu API takiego jak WebGPU – chcesz zrobić coś nieco bardziej złożonego.

Jak działają procesory graficzne

Zanim wprowadzisz kolejne zmiany w kodzie, warto zapoznać się z uproszczonym opisem działania procesorów graficznych, które tworzą kształty widoczne na ekranie. (Jeśli znasz już podstawy renderowania GPU, możesz przejść do sekcji Definiowanie wierzchołków).

W przeciwieństwie do interfejsu API, takiego jak Canvas 2D, który ma wiele kształtów i opcji gotowych do użycia, procesor graficzny obsługuje tylko kilka różnych typów kształtów (lub elementów pierwotnych, jak są one nazywane w WebGPU): punkty, linie i trójkąty. Na potrzeby tego ćwiczenia użyjesz tylko trójkątów.

Procesory graficzne działają niemal wyłącznie z trójkątami, ponieważ mają one wiele przydatnych właściwości matematycznych, które ułatwiają ich przetwarzanie w przewidywalny i wydajny sposób. Prawie wszystko, co rysujesz za pomocą procesora graficznego, musi zostać podzielone na trójkąty, zanim procesor graficzny będzie mógł to narysować. Trójkąty te muszą być zdefiniowane przez punkty narożne.

Te punkty, czyli wierzchołki, są podawane w postaci wartości X, Y i (w przypadku treści 3D) Z, które określają punkt w kartezjańskim układzie współrzędnych zdefiniowanym przez WebGPU lub podobne interfejsy API. Strukturę układu współrzędnych najłatwiej zrozumieć, myśląc o tym, jak odnosi się on do obszaru rysowania na stronie. Niezależnie od tego, jak szerokie lub wysokie jest płótno, lewa krawędź zawsze znajduje się na osi X w pozycji -1, a prawa – w pozycji +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 punkt (0, 0) jest zawsze środkiem obszaru, punkt (-1, -1) jest zawsze lewym dolnym rogiem, a punkt (1, 1) jest zawsze prawym górnym rogiem. Jest to tzw. przestrzeń przycinania.

Prosty wykres przedstawiający znormalizowaną przestrzeń współrzędnych urządzenia.

Wierzchołki rzadko są początkowo definiowane w tym układzie współrzędnych, więc procesory graficzne korzystają z małych programów zwanych shaderami wierzchołków, aby wykonywać obliczenia niezbędne do przekształcenia wierzchołków w przestrzeń przycinania, a także inne obliczenia potrzebne do narysowania wierzchołków. Może na przykład zastosować animację lub obliczyć kierunek od wierzchołka do źródła światła. Te shadery są pisane przez Ciebie, dewelopera WebGPU, i zapewniają niesamowitą kontrolę nad działaniem procesora graficznego.

Następnie procesor graficzny pobiera wszystkie trójkąty utworzone z tych przekształconych wierzchołków i określa, które piksele na ekranie są potrzebne do ich narysowania. Następnie uruchamia inny mały program napisany przez Ciebie, zwany shaderem fragmentów, który oblicza, jaki kolor powinien mieć każdy piksel. Może to być proste zwrócenie koloru zielonego lub bardziej złożone obliczenie kąta powierzchni względem światła słonecznego odbijającego się od innych pobliskich powierzchni, przefiltrowanego przez mgłę i zmodyfikowanego przez to, jak bardzo metaliczna jest powierzchnia. Wszystko zależy od Ciebie, co może być zarówno wzmacniające, jak i przytłaczające.

Wyniki tych kolorów pikseli są następnie gromadzone w teksturze, którą można wyświetlić na ekranie.

Określanie wierzchołków

Jak już wspomnieliśmy, symulacja Gry w życie jest przedstawiana jako siatka komórek. Aplikacja musi mieć możliwość wizualizacji siatki, odróżniając aktywne komórki od nieaktywnych. W tym laboratorium użyjemy metody polegającej na rysowaniu kolorowych kwadratów w aktywnych komórkach i pozostawianiu pustych komórek nieaktywnych.

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, odsunięty od krawędzi, ma współrzędne wierzchołków takie jak te:

Wykres znormalizowanych współrzędnych urządzenia pokazujący współrzędne wierzchołków kwadratu

Aby przekazać te współrzędne do procesora graficznego, musisz umieścić wartości w obiekcie TypedArray. Jeśli nie znasz jeszcze TypedArrays, 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 Uint8Array każdy element tablicy to pojedynczy bajt bez znaku. TypedArrays świetnie nadają się do przesyłania danych do interfejsów API, które są wrażliwe na układ pamięci, takich jak WebAssembly, WebAudio i (oczywiście) WebGPU.

W przypadku kwadratu, ponieważ wartości są ułamkowe, odpowiedni jest symbol Float32Array.

  1. Utwórz tablicę zawierającą wszystkie pozycje wierzchołków na diagramie, umieszczając w kodzie tę deklarację tablicy. Dobrym miejscem na umieszczenie go jest górna część strony, tuż pod przyciskiem 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,
]);

Pamiętaj, że odstępy i komentarze nie mają wpływu na wartości. Są one podane tylko dla Twojej wygody i czytelności. Dzięki temu widać, że każda para wartości tworzy współrzędne X i Y jednego wierzchołka.

Ale jest pewien problem. Pamiętaj, że procesory graficzne działają na podstawie trójkątów. Oznacza to, że musisz podać wierzchołki w grupach po 3. Masz jedną grupę 4-osobową. Rozwiązaniem jest powtórzenie dwóch wierzchołków, aby utworzyć 2 trójkąty, które mają wspólną krawędź przebiegającą przez środek kwadratu.

Diagram pokazujący, jak 4 wierzchołki kwadratu zostaną użyte do utworzenia 2 trójkątów.

Aby utworzyć kwadrat z diagramu, musisz dwukrotnie podać wierzchołki (-0,8, -0,8) i (0,8, 0,8) – raz dla niebieskiego trójkąta i raz dla czerwonego. (Możesz też podzielić kwadrat, łącząc inne dwa rogi – nie ma to znaczenia).

  1. Zaktualizuj poprzednią tablicę vertices, aby wyglądała mniej więcej 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ż na diagramie dla przejrzystości widać odstęp między trójkątami, ich wierzchołki znajdują się dokładnie w tych samych miejscach, a procesor graficzny renderuje je bez przerw. Będzie on renderowany jako pojedynczy, pełny kwadrat.

Tworzenie bufora wierzchołków

Procesor graficzny nie może rysować wierzchołków z danymi z tablicy JavaScript. Procesory GPU mają często własną pamięć, która jest wysoce zoptymalizowana pod kątem renderowania, więc wszystkie 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łków, pamięć po stronie GPU jest zarządzana za pomocą obiektów GPUBuffer. Bufor to blok pamięci łatwo dostępny dla procesora graficznego, który jest oznaczony do określonych celów. Można go traktować jako tablicę TypedArray widoczną dla GPU.

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

index.html

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

Pierwszą rzeczą, na którą warto zwrócić uwagę, jest to, że buforowi nadajesz etykietę. Każdemu utworzonemu obiektowi WebGPU można przypisać opcjonalną etykietę. Zdecydowanie warto to zrobić. Etykieta może być dowolnym ciągiem znaków, o ile pomaga zidentyfikować obiekt. Jeśli napotkasz jakieś problemy, etykiety te będą używane w komunikatach o błędach generowanych przez WebGPU, aby pomóc Ci zrozumieć, co poszło nie tak.

Następnie podaj rozmiar bufora w bajtach. Potrzebujesz bufora o rozmiarze 48 bajtów, który uzyskasz, mnożąc rozmiar liczby zmiennoprzecinkowej 32-bitowej ( 4 bajty) przez liczbę liczb zmiennoprzecinkowych w tablicy vertices (12). Na szczęście obiekty TypedArray automatycznie obliczają swoją wartość byteLength, więc możesz jej użyć podczas tworzenia bufora.

Na koniec musisz określić użycie bufora. Jest to co najmniej 1 z tych flag: GPUBufferUsage. Wiele flag jest połączonych za pomocą operatora | ( bitowe LUB). W tym przypadku określasz, że bufor ma być używany do danych wierzchołków (GPUBufferUsage.VERTEX) i że chcesz mieć możliwość kopiowania do niego danych (GPUBufferUsage.COPY_DST).

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

Gdy bufor zostanie utworzony, pamięć, którą zawiera, zostanie zainicjowana wartością zero. Zawartość bufora można zmienić na kilka sposobów, ale najłatwiej jest wywołać funkcję device.queue.writeBuffer() z argumentem TypedArray, który chcesz skopiować.

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

index.html

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

Określanie układu wierzchołków

Masz teraz bufor z danymi wierzchołków, ale z punktu widzenia procesora graficznego jest to tylko blok bajtów. Jeśli chcesz coś narysować, musisz podać więcej informacji. Musisz podać WebGPU więcej informacji o strukturze danych wierzchołków.

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 w rzeczywistości jest dość proste.

Najpierw podajesz arrayStride. Jest to liczba bajtów, o którą procesor graficzny musi przesunąć się do przodu w buforze, gdy szuka następnego wierzchołka. Każdy wierzchołek kwadratu składa się z 2 32-bitowych liczb zmiennoprzecinkowych. Jak już wspomnieliśmy, liczba zmiennoprzecinkowa 32-bitowa ma 4 bajty, więc 2 takie liczby zajmują 8 bajtów.

Następna jest właściwość attributes, która jest tablicą. Atrybuty to poszczególne informacje zakodowane w każdym wierzchołku. Wierzchołki zawierają tylko jeden atrybut (pozycję wierzchołka), ale w bardziej zaawansowanych przypadkach użycia wierzchołki często mają wiele atrybutów, takich jak kolor wierzchołka lub kierunek, w którym jest skierowana powierzchnia geometrii. Nie jest to jednak tematem tego ćwiczenia.

W pojedynczym atrybucie najpierw definiujesz format danych. Pochodzi ona z listy typów GPUVertexFormat, które opisują każdy typ danych wierzchołków, które może zrozumieć procesor graficzny. Każdy wierzchołek ma 2 liczby zmiennoprzecinkowe 32-bitowe, więc używasz formatu float32x2. Jeśli dane wierzchołków składają się z 4 16-bitowych liczb całkowitych bez znaku, użyj na przykład wartości uint16x4. Widzisz wzór?

Następnie offset określa, od którego bajtu w wierzchołku zaczyna się ten atrybut. Musisz się tym przejmować tylko wtedy, gdy bufor ma więcej niż 1 atrybut, co nie zdarzy się w tym samouczku.

Na koniec masz shaderLocation. Jest to dowolna liczba z zakresu od 0 do 15. Musi być unikalna dla każdego zdefiniowanego atrybutu. Łączy ten atrybut z określonym wejściem w programie cieniowania wierzchołków, o czym dowiesz się w następnej sekcji.

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

Pierwsze kroki z shaderami

Masz już dane, które chcesz wyrenderować, ale nadal musisz dokładnie określić, jak ma je przetworzyć procesor graficzny. W dużej mierze jest to zasługa shaderów.

Shadery to małe programy, które piszesz i uruchamiasz na procesorze graficznym. Każdy program cieniujący działa na innym etapie danych: przetwarzanie wierzchołków, przetwarzanie fragmentów lub ogólne obliczenia. Ponieważ są one przetwarzane na procesorze graficznym, mają bardziej sztywną strukturę niż przeciętny kod JavaScript. Ta struktura pozwala im jednak działać bardzo szybko, a co najważniejsze – równolegle.

Shadery w WebGPU są pisane w języku cieniowania o nazwie WGSL (WebGPU Shading Language). WGSL jest pod względem składni podobny do języka Rust i zawiera funkcje, które ułatwiają i przyspieszają typowe zadania wykonywane przez GPU (np. obliczenia wektorowe i macierzowe). Nauka całego języka cieniowania wykracza poza zakres tego laboratorium, ale mamy nadzieję, że poznasz niektóre podstawy, przechodząc przez proste przykłady.

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

  • Utwórz miejsce na wpisanie kodu shadera, kopiując poniższy kod do swojego kodu pod vertexBufferLayout:

index.html

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

Aby utworzyć shadery, wywołaj funkcję device.createShaderModule(), do której możesz opcjonalnie podać label i WGSL code jako ciąg znaków. (Pamiętaj, że w tym przypadku używasz odwróconych apostrofów, aby umożliwić ciągi wielowierszowe). Po dodaniu prawidłowego kodu WGSL funkcja zwraca obiekt GPUShaderModule ze skompilowanymi wynikami.

Definiowanie shadera wierzchołków

Zacznij od shadera wierzchołków, ponieważ od niego zaczyna też GPU.

Shader wierzchołków jest zdefiniowany jako funkcja, a procesor graficzny wywołuje tę funkcję raz dla każdego wierzchołka w vertexBuffer. Ponieważ vertexBuffer ma 6 pozycji (wierzchołków), zdefiniowana funkcja zostanie wywołana 6 razy. Za każdym razem, gdy jest wywoływana, do funkcji przekazywana jest jako argument inna pozycja z vertexBuffer, a zadaniem funkcji shadera wierzchołków jest zwrócenie odpowiedniej pozycji w przestrzeni przycinania.

Warto pamiętać, że niekoniecznie będą one wywoływane w kolejności sekwencyjnej. Zamiast tego procesory graficzne doskonale radzą sobie z równoległym uruchamianiem takich shaderów, potencjalnie przetwarzając setki (a nawet tysiące) wierzchołków jednocześnie. To w dużej mierze odpowiada za niesamowitą szybkość procesorów graficznych, ale ma też swoje ograniczenia. Aby zapewnić maksymalną równoległość, shadery wierzchołków nie mogą się ze sobą komunikować. Każde wywołanie cieniowania może wyświetlać dane tylko jednego wierzchołka naraz i może generować wartości tylko dla jednego wierzchołka.

W WGSL funkcja shadera wierzchołków może mieć dowolną nazwę, ale musi mieć przed sobą atrybut @vertex attribute, aby wskazywać, który etap shadera reprezentuje. WGSL oznacza funkcje słowem kluczowym fn, używa nawiasów do deklarowania argumentów i nawiasów klamrowych do definiowania zakresu.

  1. Utwórz pustą funkcję @vertex, na przykład:

index.html (kod createShaderModule)

@vertex
fn vertexMain() {

}

Nie jest to jednak prawidłowe, ponieważ shader wierzchołków musi zwracać co najmniej ostateczną pozycję przetwarzanego wierzchołka w przestrzeni obcinania. Jest to zawsze wektor 4-wymiarowy. Wektory są tak powszechnie używane w shaderach, że są traktowane jako podstawowe typy danych w języku, z własnymi typami, np. vec4f dla wektora 4-wymiarowego. Podobne typy istnieją też w przypadku wektorów 2D (vec2f) i 3D (vec3f).

  1. Aby wskazać, że zwracana wartość to wymagana pozycja, oznacz ją atrybutem @builtin(position). Symbol -> oznacza, że funkcja zwraca tę wartość.

index.html (kod createShaderModule)

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

}

Oczywiście, jeśli funkcja ma typ zwracany, musisz faktycznie zwrócić wartość w treści funkcji. Możesz utworzyć nowy vec4f do zwrócenia, 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 przycinania.

  1. Zwróć statyczną wartość (0, 0, 0, 1), a będziesz mieć prawidłowy program cieniowania wierzchołków, chociaż taki, który nigdy niczego nie wyświetla, ponieważ procesor graficzny rozpoznaje, że tworzone przez niego trójkąty są tylko jednym punktem, a następnie je 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 tym, co zostało opisane w vertexBufferLayout. Określono shaderLocation jako 0, więc w kodzie WGSL oznacz argument za pomocą @location(0). Format został też zdefiniowany jako float32x2, czyli wektor 2D, więc w WGSL argument jest typu vec2f. Możesz nadać mu dowolną nazwę, ale ponieważ reprezentuje on pozycje wierzchołków, naturalna wydaje się nazwa pos.

  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ę. Pozycja jest wektorem 2D, a typ zwracany to wektor 4D, więc musisz go nieco zmodyfikować. Chodzi o to, aby wziąć 2 składowe z argumentu position i umieścić je w pierwszych 2 składowych wektora zwracanego, pozostawiając ostatnie 2 składowe odpowiednio jako 01.

  1. Zwróć prawidłową pozycję, podając wyraźnie, 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 ponieważ tego rodzaju mapowania są w shaderach bardzo powszechne, możesz też przekazać wektor pozycji jako pierwszy argument w wygodnym skrócie, który oznacza to samo.

  1. Zastąp instrukcję return tym kodem:

index.html (kod createShaderModule)

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

To Twój pierwszy shader wierzchołków. To bardzo proste rozwiązanie, które polega na przekazywaniu pozycji bez zmian, ale jest wystarczające na początek.

Definiowanie shadera fragmentów

Następny jest program cieniowania fragmentów. Shadery fragmentów działają bardzo podobnie do shaderów wierzchołków, ale zamiast być wywoływane dla każdego wierzchołka, są wywoływane dla każdego rysowanego piksela.

Shadery fragmentów są zawsze wywoływane po shaderach wierzchołków. Procesor graficzny pobiera dane wyjściowe z programów cieniujących wierzchołki i trianguluje 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 załączników kolorów wyjściowych są zawarte w danym trójkącie, a potem wywołuje shader fragmentów dla każdego z tych pikseli. Shader fragmentów zwraca kolor, zwykle obliczany na podstawie wartości wysyłanych do niego z shadera wierzchołków i zasobów, takich jak tekstury, które procesor graficzny zapisuje w załączniku koloru.

Podobnie jak shattery wierzchołków, shattery fragmentów są wykonywane w sposób masowo równoległy. Są one nieco bardziej elastyczne niż shadery wierzchołków pod względem danych wejściowych i wyjściowych, ale można je traktować jako zwracające jeden kolor dla każdego piksela każdego trójkąta.

Funkcja shadera fragmentów WGSL jest oznaczona atrybutem @fragment i zwraca też wartość vec4f. W tym przypadku wektor reprezentuje jednak kolor, a nie pozycję. Wartość zwracana musi mieć atrybut @location, aby wskazać, do którego colorAttachment z wywołania beginRenderPass zapisywany jest zwrócony kolor. Ponieważ masz tylko 1 załącznik, jego lokalizacja to 0.

  1. Utwórz pustą funkcję @fragment, na przykład:

index.html (kod createShaderModule)

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

}

4 elementy 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 jasnoczerwony, który wydaje się odpowiednim kolorem dla Twojego kwadratu. Możesz jednak ustawić dowolny kolor.

  1. Ustaw zwrócony wektor koloru, np. tak:

index.html (kod createShaderModule)

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

To już cały shader fragmentów. Nie jest to zbyt interesujący program, ponieważ ustawia każdy piksel każdego trójkąta na czerwony, ale na razie to wystarczy.

Podsumowując, po dodaniu kodu shadera opisanego powyżej wywołanie createShaderModule będzie wyglądać 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 cieniowania nie można używać do renderowania samodzielnie. Zamiast tego musisz użyć go jako części GPURenderPipeline utworzonego przez wywołanie device.createRenderPipeline(). Potok renderowania określa sposób rysowania geometrii, w tym m.in. to, które shadery są używane, jak interpretować dane w buforach wierzchołków i jaki rodzaj geometrii ma być renderowany (linie, punkty, trójkąty...).

Potok renderowania to najbardziej złożony obiekt w całym interfejsie API, ale nie martw się. Większość wartości, które możesz do niego przekazać, jest opcjonalna. Na początek wystarczy podać tylko kilka z nich.

  • Utwórz potok renderowania, np. taki:

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 potrzebuje layout, który opisuje, jakich typów danych wejściowych (innych niż bufory wierzchołków) potrzebuje potok, ale w tym przypadku nie masz żadnych. Na szczęście możesz na razie przekazać wartość "auto", a potok utworzy własny układ na podstawie shaderów.

Następnie musisz podać szczegóły etapu vertex. module to GPUShaderModule, który zawiera 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 cieniowania możesz mieć wiele funkcji @vertex@fragment). buffers to tablica obiektów GPUVertexBufferLayout, które opisują sposób pakowania danych w buforach wierzchołków używanych w tym potoku. Na szczęście zostało to już zdefiniowane w vertexBufferLayout. W tym miejscu możesz go przekazać.

Na koniec znajdziesz szczegóły etapu fragment. Obejmuje to też moduł cieniowania i punkt wejścia, np. etap wierzchołków. Ostatnim krokiem jest zdefiniowanie targets, z którym ten potok będzie używany. Jest to tablica słowników zawierających szczegóły, takie jak tekstura format, dotyczące załączników kolorów, które potok danych wysyła do wyjścia. Te szczegóły muszą być zgodne z teksturami podanymi w colorAttachments wszystkich przebiegów renderowania, z którymi jest używany ten potok. Proces renderowania korzysta z tekstur z kontekstu elementu canvas i używa formatu zapisanego w canvasFormat, więc musisz tu przekazać ten sam format.

To nie wszystkie opcje, które możesz określić podczas tworzenia potoku renderowania, ale wystarczą na potrzeby tego ćwiczenia.

Narysuj kwadrat

To wszystko, czego potrzebujesz, aby narysować kwadrat.

  1. Aby narysować kwadrat, wróć do pary wywołań encoder.beginRenderPass()pass.end(), a następnie dodaj między nimi 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()

Dostarcza to WebGPU wszystkich informacji niezbędnych do narysowania kwadratu. Najpierw użyj ikony setPipeline(), aby wskazać, którego potoku należy użyć do rysowania. Obejmuje to używane shadery, układ danych wierzchołków 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 elementowi o indeksie 0 w definicji vertex.buffers bieżącego potoku.

Na koniec wykonujesz wywołanie draw(), które po wszystkich wcześniejszych konfiguracjach wydaje się dziwnie proste. Wystarczy podać liczbę wierzchołków, które mają być renderowane. Są one pobierane z aktualnie ustawionych buforów wierzchołków i interpretowane za pomocą aktualnie ustawionego potoku. Możesz po prostu wpisać na stałe wartość 6, ale obliczenie jej na podstawie tablicy wierzchołków (12 liczb zmiennoprzecinkowych / 2 współrzędne na wierzchołek == 6 wierzchołków) oznacza, że jeśli zdecydujesz się zastąpić kwadrat np. okręgiem, będziesz mieć mniej do ręcznej aktualizacji.

  1. Odśwież ekran i (w końcu) zobacz wyniki swojej ciężkiej pracy: jeden duży kolorowy kwadrat.

Pojedynczy czerwony kwadrat wyrenderowany za pomocą WebGPU

5. Rysowanie siatki

Najpierw pogratuluj sobie! Wyświetlenie pierwszych elementów geometrii na ekranie jest często jednym z najtrudniejszych kroków w przypadku większości interfejsów API procesora graficznego. Wszystkie czynności możesz wykonywać w mniejszych krokach, co ułatwi Ci sprawdzanie postępów.

W tej sekcji dowiesz się:

  • Jak przekazywać do shadera zmienne (tzw. uniformy) z JavaScriptu.
  • Jak używać zmiennych uniform, aby zmieniać sposób renderowania.
  • Jak używać instancji do rysowania wielu różnych wariantów tej samej geometrii.

Określ siatkę

Aby wyrenderować siatkę, musisz znać podstawową informację na jej temat. Ile komórek zawiera w szerz i w górę? To zależy od Ciebie jako dewelopera, ale aby ułatwić sobie pracę, potraktuj siatkę jako kwadrat (o takiej samej szerokości i wysokości) i użyj rozmiaru, który jest potęgą liczby 2. (Ułatwi to późniejsze obliczenia). Chcesz go później powiększyć, ale w pozostałej części tej sekcji ustaw rozmiar siatki na 4x4, ponieważ ułatwi to pokazanie niektórych obliczeń używanych w tej sekcji. Później możesz go powiększyć.

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

index.html

const GRID_SIZE = 4;

Następnie musisz zaktualizować sposób renderowania kwadratu, aby zmieścić na płótnie GRID_SIZE × GRID_SIZE kwadratów. Oznacza to, że kwadraty muszą być znacznie mniejsze i musi być ich dużo.

Jednym ze sposobów może być znaczne powiększenie bufora wierzchołków i zdefiniowanie w nim GRID_SIZE razy GRID_SIZE kwadratów o odpowiednim rozmiarze i położeniu. Kod do tego nie byłby wcale taki zły. Wystarczy kilka pętli for i trochę matematyki. Nie jest to jednak najlepsze wykorzystanie GPU, a efekt wymaga więcej pamięci niż to konieczne. W tej sekcji przyjrzymy się podejściu bardziej przyjaznemu dla procesora graficznego.

Tworzenie bufora jednorodnego

Najpierw musisz przekazać do shadera wybrany rozmiar siatki, ponieważ używa on tej informacji do zmiany sposobu wyświetlania elementów. Możesz po prostu zakodować rozmiar w cieniowaniu, ale wtedy za każdym razem, gdy chcesz zmienić rozmiar siatki, musisz ponownie utworzyć cieniowanie i potok renderowania, co jest kosztowne. Lepszym sposobem jest przekazanie rozmiaru siatki do shadera jako zmiennych jednorodnych.

Wcześniej dowiedzieliśmy się, że do każdego wywołania shadera wierzchołków przekazywana jest inna wartość z bufora wierzchołków. Wartość uniform to wartość z bufora, która jest taka sama w przypadku każdego wywołania. Są one przydatne do przekazywania wartości, które są wspólne dla elementu geometrii (np. jego położenia), całej klatki animacji (np. bieżącego czasu) lub nawet całego okresu działania aplikacji (np. preferencji użytkownika).

  • Utwórz bufor jednolity, 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ż jest to prawie dokładnie ten sam kod, którego użyto wcześniej do utworzenia bufora wierzchołków. Dzieje się tak, ponieważ dane uniform są przekazywane do interfejsu WebGPU API za pomocą tych samych obiektów GPUBuffer co wierzchołki. Główna różnica polega na tym, że usage tym razem zawiera GPUBufferUsage.UNIFORM zamiast GPUBufferUsage.VERTEX.

Dostęp do zmiennych uniform w cieniowaniu

  • Zdefiniuj zmienną uniform, 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

Definiuje to w shaderze zmienną o nazwie grid, która jest 2-wymiarowym wektorem zmiennoprzecinkowym pasującym do tablicy skopiowanej właśnie do bufora zmiennych. Określa też, że rozkład jednostajny jest ograniczony wartościami @group(0)@binding(0). Za chwilę dowiesz się, co oznaczają te wartości.

Następnie w innej części kodu shadera możesz użyć wektora siatki w dowolny sposób. W tym kodzie dzielisz pozycję wierzchołka przez wektor siatki. Ponieważ posgrid to wektory 2D, WGSL wykonuje dzielenie poszczególnych składników. Innymi słowy, wynik jest taki sam jak w przypadku vec2f(pos.x / grid.x, pos.y / grid.y).

Tego typu operacje wektorowe są bardzo powszechne w shaderach GPU, ponieważ wiele technik renderowania i obliczeniowych na nich polega.

Oznacza to, że w Twoim przypadku (jeśli użyjesz rozmiaru siatki 4) renderowany kwadrat będzie miał jedną czwartą pierwotnego rozmiaru. To idealne rozwiązanie, jeśli chcesz umieścić 4 takie elementy w wierszu lub kolumnie.

Tworzenie grupy powiązań

Zadeklarowanie zmiennej uniform w cieniowaniu nie powoduje jednak połączenia jej z utworzonym buforem. Aby to zrobić, musisz utworzyć i skonfigurować grupę powiązań.

Grupa powiązań to zbiór zasobów, które chcesz udostępnić shaderowi w tym samym czasie. Może on zawierać kilka rodzajów buforów, np. bufor jednolity, oraz inne zasoby, takie jak tekstury i samplery, które nie są tu omawiane, ale są powszechnie stosowane w technikach renderowania WebGPU.

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

index.html

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

Oprócz standardowego parametru label musisz też podać parametr layout, który określa, jakie typy zasobów zawiera ta grupa powiązań. W przyszłości zajmiesz się tym dokładniej, ale na razie możesz poprosić potok o układ grupy wiązań, ponieważ został on utworzony za pomocą layout: "auto". Powoduje to, że potok automatycznie tworzy układy grup wiązań na podstawie wiązań zadeklarowanych w samym kodzie shadera. W takim przypadku prosisz o getBindGroupLayout(0), gdzie 0 odpowiada @group(0) wpisanemu w cieniowaniu.

Po określeniu układu podajesz tablicę entries. Każdy wpis to słownik zawierający co najmniej te wartości:

  • binding, która odpowiada wartości @binding() wpisanej w cieniowaniu. W tym przypadku 0.
  • resource, czyli rzeczywisty zasób, który chcesz udostępnić zmiennej pod określonym indeksem powiązania. W tym przypadku będzie to bufor jednolity.

Funkcja zwraca GPUBindGroup, czyli nieprzejrzysty, niezmienny uchwyt. Po utworzeniu grupy powiązań nie możesz zmienić zasobów, na które ona wskazuje, ale możesz zmienić zawartość tych zasobów. Jeśli na przykład zmienisz bufor jednolity, aby zawierał nowy rozmiar siatki, będzie to odzwierciedlone w przyszłych wywołaniach rysowania korzystających z tej grupy powiązań.

Powiązywanie grupy powiązań

Po utworzeniu grupy powiązań musisz jeszcze poinformować WebGPU, że ma jej używać podczas rysowania. Na szczęście jest to dość proste.

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

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. Oznacza to, że każdy @binding należący do @group(0) korzysta z zasobów w tej grupie powiązań.

Teraz bufor jednolity jest dostępny dla shadera.

  1. Odśwież stronę. Powinien pojawić się ekran podobny do tego:

Mały czerwony kwadrat na środku ciemnoniebieskiego tła.

Hurra! Kwadrat jest teraz 4 razy mniejszy niż wcześniej. To niewiele, ale pokazuje, że jednolity kolor jest rzeczywiście stosowany i że program cieniujący ma teraz dostęp do rozmiaru siatki.

Manipulowanie geometrią w cieniowaniu

Teraz, gdy możesz odwoływać się do rozmiaru siatki w cieniowaniu, możesz zacząć manipulować renderowaną geometrią, aby dopasować ją do wybranego wzoru siatki. Aby to zrobić, zastanów się, co dokładnie chcesz osiągnąć.

Musisz podzielić obszar roboczy na poszczególne komórki. Aby zachować konwencję, zgodnie z którą oś X rośnie w prawo, a oś Y rośnie w górę, przyjmij, że pierwsza komórka znajduje się w lewym dolnym rogu obszaru roboczego. Dzięki temu uzyskasz układ podobny do tego, w którym obecna kwadratowa geometria znajduje się pośrodku:

Ilustracja koncepcyjnej siatki, na którą zostanie podzielona znormalizowana przestrzeń współrzędnych urządzenia podczas wizualizacji każdej komórki z aktualnie renderowaną geometrią kwadratu w jej środku.

Twoim zadaniem jest znalezienie w cieniowaniu metody, która pozwoli Ci umieścić kwadrat w dowolnej z tych komórek na podstawie jej współrzędnych.

Po pierwsze, widać, że kwadrat nie jest dobrze wyrównany do żadnej z komórek, ponieważ został zdefiniowany tak, aby otaczać środek obszaru rysowania. Chcesz przesunąć kwadrat o połowę komórki, aby dobrze się w niej mieścił.

Możesz to naprawić, aktualizując bufor wierzchołków kwadratu. Przesuwając wierzchołki tak, aby lewy dolny róg znajdował się np. w punkcie (0,1, 0,1) zamiast (-0,8, -0,8), możesz przesunąć ten kwadrat, aby lepiej pasował do granic komórki. Ponieważ masz pełną kontrolę nad tym, jak wierzchołki są przetwarzane w shaderze, możesz je po prostu przesunąć na miejsce za pomocą kodu shadera.

  1. Zmodyfikuj moduł shadera wierzchołków 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);
}

Powoduje to przesunięcie każdego wierzchołka o 1 piksel w górę i w prawo (co, jak pamiętasz, stanowi połowę przestrzeni przycinania) przed podzieleniem go przez rozmiar siatki. W efekcie otrzymasz kwadrat wyrównany do siatki, który znajduje się tuż obok początku układu współrzędnych.

Wizualizacja obszaru roboczego podzielonego na siatkę 4x4 z czerwonym kwadratem w komórce (2, 2)

Następnie, ponieważ układ współrzędnych płótna umieszcza punkt (0, 0) na środku, a punkt (-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 przenieść 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);
}

Teraz kwadrat jest prawidłowo umieszczony w komórce (0, 0).

Wizualizacja obszaru roboczego 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 cell wektor w cieniowaniu i wypełnij go statyczną wartością, np. let cell = vec2f(1, 1).

Jeśli dodasz to do gridPos, spowoduje to cofnięcie - 1 w algorytmie, więc nie jest to pożądane. Zamiast tego chcesz przesunąć kwadrat tylko o 1 jednostkę siatki (1/4 obszaru roboczego) w przypadku każdej komórki. Wygląda na to, że musisz jeszcze raz podzielić przez grid.

  1. Zmień pozycję siatki, wykonując te czynności:

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 obszaru roboczego podzielonego na siatkę 4x4. Czerwony kwadrat jest wyśrodkowany między komórkami (0, 0), (0, 1), (1, 0) i (1, 1).

Hm. Nie o to Ci chodziło.

Wynika to z tego, że współrzędne płótna mieszczą się w zakresie od -1 do +1, więc w rzeczywistości mają 2 jednostki szerokości. Oznacza to, że jeśli chcesz przesunąć wierzchołek o 1/4 płótna, musisz go przesunąć o 0,5 jednostki. To łatwy błąd podczas rozumowania z użyciem współrzędnych GPU. Na szczęście rozwiązanie jest równie proste.

  1. Pomnóż przesunięcie przez 2, np. tak:

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 otrzymasz dokładnie to, czego chcesz.

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

Zrzut ekranu wygląda tak:

Zrzut ekranu przedstawiający czerwony kwadrat na ciemnoniebieskim tle. Czerwony kwadrat jest narysowany w tym samym miejscu co na poprzednim diagramie, ale bez siatki.

Możesz też ustawić wartość cell w dowolnym miejscu w siatce, a następnie odświeżyć stronę, aby zobaczyć kwadrat w wybranej lokalizacji.

Rysowanie instancji

Teraz, gdy możesz umieścić kwadrat w dowolnym miejscu za pomocą prostych obliczeń, następnym krokiem jest wyrenderowanie jednego kwadratu w każdej komórce siatki.

Jednym ze sposobów jest zapisanie współrzędnych komórek w jednolitym buforze, a następnie wywołanie funkcji draw dla każdego kwadratu w siatce, za każdym razem aktualizując jednolity bufor. Byłoby to jednak bardzo powolne, ponieważ procesor graficzny musiałby za każdym razem czekać, aż JavaScript zapisze nowe współrzędne. Jednym z kluczy do uzyskania dobrej wydajności procesora graficznego jest zminimalizowanie czasu, w którym czeka on na inne części systemu.

Zamiast tego możesz użyć techniki zwanej instancjonowaniem. Instancjonowanie to sposób na poinformowanie procesora graficznego, aby narysował wiele kopii tej samej geometrii za pomocą jednego wywołania funkcji draw, co jest znacznie szybsze niż wywoływanie funkcji draw raz dla każdej kopii. Każda kopia geometrii jest określana jako wystąpienie.

  1. Aby poinformować procesor graficzny, że chcesz, aby kwadratów było wystarczająco dużo, aby wypełnić siatkę, dodaj do istniejącego wywołania rysowania 1 argument:

index.html

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

Informuje to 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:

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

Dlaczego? Dzieje się tak, ponieważ wszystkie 16 kwadratów rysujesz w tym samym miejscu. Musisz dodać do shadera dodatkową logikę, która zmienia położenie geometrii dla każdej instancji.

W cieniowaniu oprócz atrybutów wierzchołków, takich jak pos, które pochodzą z bufora wierzchołków, możesz też uzyskać dostęp do tzw. wartości wbudowanych WGSL. Są to wartości obliczane przez WebGPU, np. instance_index. Wartość instance_index to 32-bitowa liczba bez znaku z zakresu od 0 do number of instances - 1, której możesz używać w logice shadera. Jego wartość jest taka sama dla każdego przetworzonego wierzchołka, który należy do tej samej instancji. Oznacza to, że shader wierzchołków jest wywoływany 6 razy z wartością instance_index równą 0 – raz dla każdej pozycji w buforze wierzchołków. Następnie powtórz to 6 razy z wartością instance_index = 1, potem 6 razy z wartością instance_index = 2 itd.

Aby zobaczyć, jak to działa, musisz dodać wbudowaną funkcję instance_index do danych wejściowych shadera. Zrób to w taki sam sposób jak w przypadku pozycji, ale zamiast tagować ją atrybutem @location użyj atrybutu @builtin(instance_index), a następnie nadaj argumentowi dowolną nazwę. (Możesz nadać mu nazwę instance, aby pasował do przykładowego kodu). Następnie użyj go w logice shadera.

  1. Użyj instance zamiast współrzędnych komórki:

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);
}

Jeśli teraz odświeżysz stronę, zobaczysz, że masz więcej niż jeden kwadrat. Nie możesz jednak zobaczyć wszystkich 16 z nich.

Cztery czerwone kwadraty ułożone po przekątnej od lewego dolnego rogu do prawego górnego rogu na ciemnoniebieskim tle.

Dzieje się tak, ponieważ wygenerowane 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 obszarze roboczym. Aby utworzyć odpowiednią siatkę, musisz przekształcić instance_index tak, aby każdy indeks był mapowany na unikalną komórkę w siatce, jak w tym przykładzie:

Wizualizacja obszaru roboczego podzielonego na siatkę 4x4, w której każda komórka odpowiada indeksowi instancji liniowej.

Obliczenia są dość proste. Dla każdej wartości X komórki chcesz uzyskać moduloinstance_index i szerokości siatki, co możesz zrobić w WGSL za pomocą operatora %. W przypadku wartości Y każdej komórki chcesz, aby instance_index było podzielone przez szerokość siatki, z pominięciem reszty ułamkowej. 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 zobaczysz długo oczekiwaną siatkę kwadratów.

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

  1. A teraz, gdy już działa, wróć i zwiększ rozmiar siatki.

index.html

const GRID_SIZE = 32;

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

Tada! Możesz teraz powiększyć tę siatkę do bardzo dużych rozmiarów, a przeciętny procesor graficzny poradzi sobie z tym bez problemu. Poszczególne kwadraty przestaną być widoczne na długo przed wystąpieniem wąskich gardeł wydajności procesora graficznego.

6. Dodatkowe punkty: dodaj więcej kolorów!

W tym momencie możesz łatwo przejść do następnej sekcji, ponieważ masz już podstawy do dalszej części tego laboratorium. Siatka kwadratów w tym samym kolorze jest przydatna, ale nie jest zbyt interesująca, prawda? Na szczęście możesz rozjaśnić obraz, dodając trochę więcej matematyki i kodu shadera.

Używanie struktur w shaderach

Do tej pory z shadera wierzchołków przekazywaliśmy 1 rodzaj danych: przekształconą pozycję. Z shadera wierzchołków możesz jednak zwrócić znacznie więcej danych, a potem użyć ich w shaderze fragmentów.

Jedynym sposobem przekazania danych z shadera wierzchołków jest ich zwrócenie. Aby zwrócić pozycję, zawsze wymagany jest program cieniujący wierzchołki, więc jeśli chcesz zwrócić wraz z nią inne dane, musisz umieścić je w strukturze. Struktury w WGSL to nazwane typy obiektów, które zawierają co najmniej jedną nazwaną właściwość. Usługi można też oznaczać atrybutami, takimi jak @builtin i @location. Deklarujesz je poza funkcjami, a potem możesz przekazywać ich instancje do funkcji i z funkcji w razie potrzeby. Weźmy na przykład ten obecny program cieniowania wierzchołków:

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);
}
  • Wyraź to samo za pomocą struktur dla danych wejściowych i wyjściowych funkcji:

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;
}

Zwróć uwagę, że wymaga to odniesienia się do pozycji wejściowej i indeksu instancji za pomocą input, a struktura, którą zwracasz, musi najpierw zostać zadeklarowana jako zmienna i mieć ustawione poszczególne właściwości. W tym przypadku nie ma to większego znaczenia, a funkcja shadera jest nawet nieco dłuższa, ale w przypadku bardziej złożonych shaderów używanie struktur może być świetnym sposobem na uporządkowanie danych.

Przekazywanie danych między funkcjami wierzchołków i fragmentów

Przypominamy, że funkcja @fragment jest bardzo prosta:

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). Gdyby jednak shader miał więcej informacji o geometrii, którą koloruje, można by użyć tych dodatkowych danych, aby uatrakcyjnić obraz. Załóżmy, że chcesz zmienić kolor każdego kwadratu w zależności od jego współrzędnych komórki. Etap @vertex wie, która komórka jest renderowana. Wystarczy przekazać tę informację do etapu @fragment.

Aby przekazywać dane między etapami wierzchołków i fragmentów, musisz uwzględnić je w strukturze wyjściowej z wybranym przez nas @location. Ponieważ chcesz przekazać współrzędne komórki, dodaj je do struktury VertexOutput z wcześniejszego przykładu, a następnie ustaw w funkcji @vertex przed zwróceniem wartości.

  1. Zmień wartość zwracaną shadera wierzchołków, np. tak:

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 odbierz wartość, dodając argument o tym samym @location. (Nazwy nie muszą być takie same, ale łatwiej jest śledzić zmiany, jeśli są identyczne).

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. Innym rozwiązaniem, ponieważ w Twoim kodzie obie te funkcje są zdefiniowane w tym samym module cieniowania, jest ponowne użycie struktury wyjściowej etapu @vertex. Ułatwia to przekazywanie wartości, ponieważ nazwy i lokalizacje są naturalnie 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 wzorca w funkcji @fragment masz dostęp do numeru komórki i możesz go używać do wpływania na kolor. W przypadku każdego z powyższych kodów dane wyjściowe wyglądają tak:

Siatka kwadratów, w której lewa kolumna jest zielona, dolny wiersz jest czerwony, a wszystkie pozostałe kwadraty są żółte.

Kolorów jest teraz zdecydowanie więcej, ale nie wygląda to zbyt dobrze. Możesz się zastanawiać, dlaczego różnią się tylko lewy i dolny wiersz. Dzieje się tak, ponieważ wartości kolorów zwracane przez funkcję @fragment oczekują, że każdy kanał będzie mieścił się w zakresie od 0 do 1, a wszystkie wartości spoza tego zakresu są do niego przycinane. Wartości komórek mieszczą się natomiast w zakresie od 0 do 32 wzdłuż każdej osi. Jak widać, pierwszy wiersz i pierwsza kolumna od razu osiągają wartość 1 w przypadku kanału koloru czerwonego lub zielonego, a każda kolejna komórka jest ograniczana do tej samej wartości.

Jeśli chcesz uzyskać płynniejsze przejście między kolorami, musisz zwrócić wartość ułamkową dla każdego kanału koloru, najlepiej zaczynając od zera i kończąc na jedynce wzdłuż każdej osi, co oznacza kolejne dzielenie przez grid.

  1. Zmień shader fragmentów, np. tak:

index.html (wywołanie createShaderModule)

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

Odśwież stronę, a zobaczysz, że nowy kod zapewnia znacznie lepszy gradient kolorów w całej siatce.

Siatka kwadratów, które w różnych rogach przechodzą od czarnego do czerwonego, zielonego i żółtego.

To z pewnością poprawa, ale w lewym dolnym rogu pojawił się ciemny obszar, w którym siatka staje się czarna. Gdy zaczniesz symulację gry w życie, trudna do zauważenia część siatki zasłoni to, co się dzieje. Warto byłoby to rozjaśnić.

Na szczęście masz cały niewykorzystany kanał koloru – niebieski – którego możesz użyć. Najlepiej, gdyby niebieski był najjaśniejszy tam, gdzie inne kolory są najciemniejsze, a następnie stopniowo zanikał, gdy intensywność innych kolorów wzrasta. Najłatwiej to zrobić, jeśli kanał zaczyna się od 1 i odejmiesz od niego jedną z wartości komórek. Może to być c.x lub c.y. Wypróbuj obie i wybierz tę, która Ci bardziej odpowiada.

  1. Dodaj do shadera fragmentów jaśniejsze kolory, np. tak:

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 przechodzą z czerwonego w zielony, niebieski i żółty.

To nie jest kluczowy krok. Ponieważ wygląda to lepiej, uwzględniliśmy tę zmianę w odpowiednim pliku źródłowym punktu kontrolnego, a pozostałe zrzuty ekranu w tym laboratorium odzwierciedlają tę bardziej kolorową siatkę.

7. Zarządzanie stanem komórki

Następnie musisz określić, które komórki siatki mają być renderowane, na podstawie stanu przechowywanego na GPU. Jest to ważne w przypadku symulacji końcowej.

Wystarczy sygnał włączania i wyłączania dla każdej komórki, więc sprawdzą się wszystkie opcje, które umożliwiają przechowywanie dużej tablicy niemal dowolnego typu wartości. Możesz pomyśleć, że to kolejny przypadek użycia buforów jednolitych. Chociaż możesz to zrobić, jest to trudniejsze, ponieważ bufory jednolite mają ograniczony rozmiar, nie obsługują tablic o dynamicznym rozmiarze (rozmiar tablicy musisz określić w cieniowaniu) i nie można do nich zapisywać danych za pomocą cieniowania obliczeniowego. Ostatni element jest najbardziej problematyczny, ponieważ symulację gry w życie chcesz przeprowadzić na procesorze graficznym w cieniowaniu 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łków. Mogą być bardzo duże i nie wymagają określonego rozmiaru w cieniowaniu, co sprawia, że bardziej przypominają pamięć ogólną. Służy on do przechowywania stanu komórki.

  1. Aby utworzyć bufor pamięci dla stanu komórki, użyj fragmentu kodu tworzenia bufora, który prawdopodobnie już znasz:

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 GPUBufferUsage.STORAGE.

Bufor możesz wypełnić w ten sam sposób co wcześniej, wypełniając tablicę TypedArray o tym samym rozmiarze wartościami, a następnie wywołując 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);

Odczytywanie bufora pamięci w cieniowaniu

Następnie zaktualizuj shader, aby przed wyrenderowaniem siatki sprawdzić zawartość bufora pamięci. Wygląda to bardzo podobnie do sposobu dodawania mundurków w przeszłości.

  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 dodaj punkt wiązania, który znajduje się tuż pod siatką. Chcesz zachować ten sam @group co grid, ale @binding musi być inny. Typ var to storage, aby odzwierciedlać inny typ bufora, a zamiast pojedynczego wektora typ podany dla cellState to tablica wartości u32, aby pasował do Uint32Array w JavaScript.

Następnie w treści @vertex funkcji zapytaj o stan komórki. Stan jest przechowywany w płaskiej tablicy w buforze pamięci, więc możesz użyć instance_index, aby wyszukać wartość bieżącej komórki.

Jak wyłączyć komórkę, jeśli stan wskazuje, że jest nieaktywna? Ponieważ stany aktywne i nieaktywne, które otrzymujesz z tablicy, to 1 lub 0, możesz skalować geometrię według stanu aktywnego. Skalowanie o 1 pozostawia geometrię bez zmian, a skalowanie o 0 powoduje, że geometria zwija się do jednego punktu, który jest następnie odrzucany przez procesor graficzny.

  1. Zaktualizuj kod shadera, aby skalować pozycję według stanu aktywności komórki. Aby spełnić wymagania WGSL dotyczące bezpieczeństwa typów, wartość stanu musi być przekształcona w typ 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;
}

Dodaj bufor pamięci do grupy powiązań

Zanim zobaczysz efekt stanu komórki, dodaj bufor pamięci do grupy powiązań. Ponieważ jest to część tego samego @group co bufor jednolity, dodaj go też do tej samej grupy powiązań w kodzie JavaScript.

  • Dodaj bufor pamięci 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 binding nowego wpisu odpowiada @binding() odpowiedniej wartości w cieniowaniu.

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

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

Użyj wzorca bufora ping-pong

Większość symulacji, takich jak ta, którą tworzysz, zwykle używa co najmniej 2 kopii stanu. Na każdym etapie symulacji odczytują stan z jednej kopii i zapisują go w drugiej. Następnie w kolejnym kroku odwróć kartę i odczytaj tekst od miejsca, w którym wcześniej skończyła się pisząca osoba. Jest to zwykle nazywane wzorcem ping-ponga, ponieważ najbardziej aktualna wersja stanu jest przekazywana między kopiami stanu w każdym kroku.

Dlaczego jest to konieczne? Rozważmy uproszczony przykład: załóżmy, że piszesz bardzo prostą symulację, w której w każdym kroku przesuwasz wszystkie aktywne bloki o 1 komórkę w prawo. Aby ułatwić zrozumienie, dane i symulację definiujesz w 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 przesunie się na koniec tablicy w jednym kroku. Dlaczego? Ponieważ stan jest aktualizowany w miejscu, więc przesuwasz aktywną komórkę w prawo, a potem patrzysz na następną komórkę i… hej! Jest aktywna. Lepiej znowu przesuń go w prawo. Fakt, że zmieniasz dane w tym samym czasie, w którym je obserwujesz, zniekształca wyniki.

Stosując wzorzec ping-ponga, masz pewność, że następny krok symulacji wykonasz 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 buforu:

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 wizualnie przedstawić 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 w renderowaniu wyświetlać różne bufory pamięci masowej, zaktualizuj grupy powiązań, 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 po każdym odświeżeniu strony wykonywałeś tylko jedno rysowanie, ale teraz chcesz wyświetlać dane aktualizowane z upływem czasu. Aby to zrobić, potrzebujesz prostej pętli renderowania.

Pętla renderowania to nieskończenie powtarzająca się pętla, która rysuje treści na płótnie w określonych odstępach czasu. Wiele gier i innych treści, które mają być płynnie animowane, używa funkcji requestAnimationFrame() do planowania wywołań zwrotnych z taką samą częstotliwością, z jaką odświeża się ekran (60 razy na sekundę).

Ta aplikacja też może to robić, ale w tym przypadku prawdopodobnie chcesz, aby aktualizacje były przeprowadzane w dłuższych odstępach czasu, dzięki czemu łatwiej będzie Ci śledzić, co robi symulacja. Zamiast tego zarządzaj pętlą samodzielnie, aby kontrolować częstotliwość aktualizacji symulacji.

  1. Najpierw wybierz częstotliwość aktualizacji symulacji (200 ms to dobra wartość, ale możesz ją zmniejszyć lub zwiększyć), a potem śledź, ile kroków symulacji zostało wykonanych.

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 odstępie czasu za pomocą setInterval(). Upewnij się, że funkcja aktualizuje też liczbę kroków, i użyj jej do wybrania, którą z 2 grup powiązań chcesz powiązać.

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);

Teraz, gdy uruchomisz aplikację, zobaczysz, że obszar rysowania przełącza się między dwoma utworzonymi przez Ciebie buforami stanu.

Ukośne 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.

To w zasadzie wszystko, co musisz wiedzieć o renderowaniu. Możesz już wyświetlić wynik symulacji gry w życie, którą utworzysz w następnym kroku, gdzie wreszcie zaczniesz używać shaderów obliczeniowych.

Możliwości renderowania WebGPU są oczywiście znacznie większe niż ten niewielki wycinek, który został tu omówiony, ale reszta wykracza poza zakres tego laboratorium. Mamy nadzieję, że to wystarczy, aby zrozumieć, jak działa renderowanie w WebGPU, i ułatwić poznawanie bardziej zaawansowanych technik, takich jak renderowanie 3D.

8. Uruchamianie symulacji

Teraz ostatni ważny element układanki: przeprowadzenie symulacji Game of Life w programie cieniującym obliczenia.

Wreszcie możesz używać shaderów obliczeniowych!

W tym ćwiczeniu dowiedzieliśmy się, czym są shadery obliczeniowe. Ale czym one właściwie są?

Shader obliczeniowy jest podobny do shaderów wierzchołków i fragmentów, ponieważ jest przeznaczony do działania z dużą równoległością na procesorze graficznym, ale w przeciwieństwie do pozostałych 2 etapów shaderów nie ma określonego zestawu danych wejściowych i wyjściowych. Odczytujesz i zapisujesz dane wyłącznie ze wybranych źródeł, takich jak bufory pamięci. Oznacza to, że zamiast wykonywać funkcję cieniowania raz dla każdego wierzchołka, instancji lub piksela, musisz określić, ile razy chcesz ją wywołać. Gdy uruchomisz shader, dowiesz się, 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 muszą być tworzone w module shadera, tak samo jak shadery wierzchołków i fragmentów, więc na początek dodaj to do kodu. Jak można się domyślić, biorąc pod uwagę strukturę innych zaimplementowanych przez Ciebie programów cieniujących, główna funkcja programu cieniującego obliczenia musi być oznaczona atrybutem @compute.

  1. Utwórz program cieniowania obliczeń 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() {

    }`
});

GPU są często używane do grafiki 3D, więc shadery obliczeniowe są tak skonstruowane, że możesz zażądać, aby shader był wywoływany określoną liczbę razy wzdłuż osi X, Y i Z. Dzięki temu możesz bardzo łatwo rozdzielać pracę, która jest zgodna z siatką 2D lub 3D, co jest bardzo przydatne w Twoim przypadku. Chcesz wywołać ten shader GRID_SIZE razy GRID_SIZE razy, po jednym razie dla każdej komórki symulacji.

Ze względu na architekturę sprzętową procesorów graficznych ta siatka jest podzielona na grupy robocze. Grupa robocza ma rozmiar X, Y i Z. Chociaż każdy z tych rozmiarów może wynosić 1, zwiększenie rozmiaru grup roboczych często przynosi korzyści w zakresie wydajności. W przypadku shadera wybierz nieco arbitralny rozmiar grupy roboczej 8 x 8. Warto śledzić to w kodzie JavaScript.

  1. Zdefiniuj stałą dla rozmiaru grupy roboczej, np. tak:

index.html

const WORKGROUP_SIZE = 8;

Musisz też dodać rozmiar grupy roboczej do samej funkcji shadera. Możesz to zrobić za pomocą literałów szablonu JavaScript, aby łatwo używać zdefiniowanej przed chwilą stałej.

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

index.html (wywołanie funkcji Compute createShaderModule)

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

}

Informuje to shader, że praca wykonywana za pomocą tej funkcji jest wykonywana w grupach (8 x 8 x 1). (Każda oś, którą pominiesz, ma domyślnie wartość 1, ale musisz określić co najmniej oś X).

Podobnie jak w przypadku innych etapów shadera, istnieje wiele wartości @builtin, które możesz zaakceptować jako dane wejściowe funkcji shadera obliczeniowego, aby określić, które wywołanie jest aktualnie wykonywane, i zdecydować, jakie zadanie należy wykonać.

  1. Dodaj wartość @builtin, np. tak:

index.html (wywołanie funkcji Compute createShaderModule)

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

}

Przekazujesz wbudowany wektor global_invocation_id, który jest trójwymiarowym wektorem liczb całkowitych bez znaku informującym o tym, gdzie w siatce wywołań cieniowania się znajdujesz. Ten shader uruchamiasz raz dla każdej komórki w siatce. Otrzymasz liczby takie jak (0, 0, 0), (1, 0, 0), (1, 1, 0)... aż do (31, 31, 0), co oznacza, że możesz traktować je jako indeks komórki, na której będziesz wykonywać operacje.

Shadery obliczeniowe mogą też używać zmiennych uniform, których używa się tak samo jak w przypadku shaderów wierzchołków i fragmentów.

  1. Użyj zmiennej uniform w shaderze obliczeniowym, aby określić rozmiar siatki, np.:

index.html (wywołanie funkcji Compute 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 przypadku shadera wierzchołków, stan komórki jest też udostępniany jako bufor pamięci. W tym przypadku potrzebujesz jednak dwóch takich urządzeń. Shadery obliczeniowe nie mają wymaganego wyjścia, takiego jak pozycja wierzchołka czy kolor fragmentu, więc zapisywanie wartości w buforze pamięci lub teksturze to jedyny sposób na uzyskanie wyników z shadera obliczeniowego. Użyj metody ping-ponga, której używaliśmy wcześniej. Masz jeden bufor pamięci, który zawiera bieżący stan siatki, i drugi, do którego zapisujesz nowy stan siatki.

  1. Udostępnij stan wejścia i wyjścia komórki jako bufory pamięci, np. w ten sposób:

index.html (wywołanie funkcji Compute 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) {

}

Zwróć uwagę, że pierwszy bufor pamięci jest zadeklarowany za pomocą var<storage>, co sprawia, że jest on tylko do odczytu, ale drugi bufor pamięci jest zadeklarowany za pomocą var<storage, read_write>. Umożliwia to odczytywanie i zapisywanie w buforze, który jest używany jako dane wyjściowe shadera obliczeniowego. (W WebGPU nie ma trybu pamięci tylko do zapisu).

Następnie musisz mieć sposób mapowania indeksu komórki na liniową tablicę pamięci. Jest to w zasadzie odwrotność tego, co zostało zrobione w programie cieniowania wierzchołków, gdzie liniowy wektor instance_index został przypisany do komórki siatki 2D. (Przypominamy, że Twój algorytm to vec2f(i % grid.x, floor(i / grid.x))).

  1. Napisz funkcję, która będzie działać w drugą stronę. Pobiera wartość Y komórki, mnoży ją przez szerokość siatki, a następnie dodaje wartość X komórki.

index.html (wywołanie funkcji Compute 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) {
 
}

Na koniec, aby sprawdzić, czy działa, wdróż bardzo prosty algorytm: jeśli komórka jest włączona, wyłącza się i odwrotnie. To jeszcze nie Gra w życie, ale wystarczy, aby pokazać, że shader obliczeniowy działa.

  1. Dodaj prosty algorytm, np. taki:

index.html (wywołanie funkcji Compute 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 na razie wszystko, co musisz wiedzieć o shaderach obliczeniowych. Zanim jednak zobaczysz wyniki, musisz wprowadzić jeszcze kilka zmian.

Korzystanie z grup powiązań i układów potoków

Z powyższego shadera możesz zauważyć, że w dużej mierze używa on tych samych danych wejściowych (uniforms i storage buffers) co potok renderowania. Możesz więc pomyśleć, że wystarczy użyć tych samych grup powiązań. Mamy dobrą wiadomość: możesz to zrobić. Wymaga to jednak nieco bardziej skomplikowanej konfiguracji ręcznej.

Za każdym razem, gdy tworzysz grupę powiązań, musisz podać GPUBindGroupLayout. Wcześniej można było uzyskać ten układ, wywołując funkcję getBindGroupLayout() w potoku renderowania, która z kolei tworzyła go automatycznie, ponieważ podczas tworzenia układu podano wartość layout: "auto". To podejście sprawdza się, gdy używasz tylko jednego potoku, ale jeśli masz wiele potoków, które chcą współdzielić zasoby, musisz jawnie utworzyć układ, a następnie udostępnić go zarówno grupie powiązań, jak i potokom.

Aby zrozumieć, dlaczego tak jest, weź pod uwagę, że w potokach renderowania używasz jednego bufora jednorodnego i jednego bufora pamięci, ale w napisanym właśnie shaderze obliczeniowym potrzebujesz drugiego bufora pamięci. Ponieważ oba shadery używają tych samych wartości @binding dla jednolitego i pierwszego bufora pamięci, możesz je udostępniać między potokami. Potok renderowania ignoruje drugi bufor pamięci, którego nie używa. Chcesz utworzyć układ opisujący wszystkie zasoby znajdujące się w grupie powiązań, a nie tylko te, które są używane przez 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
  }]
});

Struktura tego procesu jest podobna do tworzenia samej grupy powiązań, ponieważ opisujesz listę entries. Różnica polega na tym, że zamiast podawać sam zasób, opisujesz, jakim typem zasobu musi być wpis i jak jest używany.

W każdym wpisie podajesz numer binding zasobu, który (jak wiesz z tworzenia grupy powiązań) odpowiada wartości @binding w shaderach. Podajesz też visibility, czyli flagi GPUShaderStage, które wskazują, które etapy shadera mogą korzystać z zasobu. Chcesz, aby zarówno jednolity bufor pamięci, jak i pierwszy bufor pamięci były dostępne w shaderach wierzchołków i shaderach obliczeniowych, ale drugi bufor pamięci musi być dostępny tylko w shaderach obliczeniowych.

Na koniec wskaż, jakiego rodzaju zasób jest używany. Jest to inny klucz słownika w zależności od tego, co chcesz udostępnić. Wszystkie 3 zasoby to bufory, więc do zdefiniowania opcji każdego z nich używasz klucza buffer. Inne opcje to np. texture lub sampler, ale w tym przypadku nie są one potrzebne.

W słowniku bufora możesz ustawić opcje, np. jaki type bufora jest używany. Domyślna wartość to "uniform", więc możesz pozostawić słownik pusty, aby powiązać wartość 0. (Musisz jednak ustawić co najmniej wartość buffer: {}, 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 cieniowaniu, a powiązanie 2 ma typ "storage", ponieważ używasz go z dostępem read_write.

Po utworzeniu elementu bindGroupLayout możesz przekazać go podczas tworzenia grup powiązań zamiast wysyłać zapytanie o grupę powiązań z potoku. Oznacza to, że musisz dodać nowy wpis bufora pamięci do każdej grupy powiązań, aby dopasować go do zdefiniowanego układu.

  1. Zaktualizuj tworzenie grupy powiązań, np. tak:

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] }
    }],
  }),
];

A teraz, gdy grupa powiązań została zaktualizowana, aby używać tego jawnego układu grupy powiązań, musisz zaktualizować potok renderowania, aby używać tego samego układu.

  1. Utwórz GPUPipelineLayout.

index.html

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

Układ potoku to lista układów grup wiązań (w tym przypadku masz jeden), z których korzysta co najmniej 1 potok. Kolejność układów grup wiązań w tablicy musi odpowiadać atrybutom @group w shaderach. (Oznacza to, że bindGroupLayout jest powiązany z @group(0)).

  1. Gdy uzyskasz 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
    }]
  }
});

Utwórz potok obliczeniowy

Podobnie jak do używania shaderów wierzchołków i fragmentów potrzebujesz potoku renderowania, do używania shadera obliczeniowego potrzebujesz potoku obliczeniowego. Na szczęście potoki obliczeniowe są znacznie mniej skomplikowane niż potoki renderowania, ponieważ nie mają żadnego stanu do ustawienia, tylko shader i układ.

  • 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",
  }
});

Zauważ, że przekazujesz nowy element pipelineLayout zamiast "auto", tak jak w zaktualizowanym potoku renderowania. Dzięki temu zarówno potok renderowania, jak i potok obliczeniowy mogą używać tych samych grup powiązań.

Karty obliczeniowe

W ten sposób możesz zacząć korzystać z potoku obliczeniowego. Biorąc pod uwagę, że renderowanie odbywa się w przepustce renderowania, możesz się domyślać, że obliczenia musisz wykonywać w przepustce obliczeniowej. Obliczenia i renderowanie mogą odbywać się w tym samym koderze poleceń, więc warto trochę zmienić kolejność funkcji updateGrid.

  1. Przenieś tworzenie kodera na początek funkcji, a potem rozpocznij za jego pomocą przebieg obliczeniowy (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 potoki obliczeniowe, przebiegi obliczeniowe są znacznie prostsze do uruchomienia niż ich odpowiedniki renderujące, ponieważ nie musisz się martwić żadnymi załącznikami.

Chcesz wykonać przepustkę obliczeniową przed przepustką renderowania, ponieważ umożliwia to natychmiastowe wykorzystanie najnowszych wyników przepustki obliczeniowej przez przepustkę renderowania. Dlatego też zwiększasz liczbę step między przebiegami, aby bufor wyjściowy potoku obliczeniowego stał się buforem wejściowym potoku renderowania.

  1. Następnie ustaw potok i grupę powiązań w przepustce obliczeniowej, używając tego samego wzorca przełączania między grupami powiązań co w przypadku przepustki renderowania.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Zamiast rysować jak w przypadku przepustki renderowania, wysyłasz pracę do shadera obliczeniowego, informując go, 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, aby pamiętać, że liczba przekazywana do funkcji dispatchWorkgroups() nie jest liczbą wywołań. Jest to liczba grup roboczych do wykonania, określona przez @workgroup_size w shaderze.

Jeśli chcesz, aby program cieniujący był wykonywany 32 x 32 razy w celu pokrycia całej siatki, a rozmiar grupy roboczej wynosi 8 x 8, musisz wysłać 4 x 4 grupy robocze (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ę. Zobaczysz, że siatka odwraca się przy każdej aktualizacji.

Ukośne 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.

Wdrażanie algorytmu gry w życie

Zanim zaktualizujesz shader obliczeniowy, aby zaimplementować ostateczny algorytm, wróć do kodu, który inicjuje zawartość bufora pamięci, i zaktualizuj go tak, aby przy każdym wczytaniu strony generował losowy bufor. (Regularne wzory nie są zbyt interesującymi punktami wyjścia w grze w życie). Wartości możesz losować w dowolny sposób, ale istnieje prosty sposób, który daje zadowalające wyniki.

  1. Aby każda komórka zaczynała się w losowym stanie, zaktualizuj inicjowanie cellStateArray do poniższego 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);

Teraz możesz wreszcie wdrożyć logikę symulacji gry w życie. Po tym wszystkim kod shadera może być rozczarowująco prosty.

Najpierw musisz wiedzieć, ile aktywnych sąsiadów ma każda komórka. Nie interesuje Cię, które z nich są aktywne, tylko ich liczba.

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

index.html (wywołanie funkcji Compute createShaderModule)

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

Funkcja cellActive zwraca wartość 1, jeśli komórka jest aktywna, więc dodanie wartości zwracanej przez wywołanie funkcji cellActive dla wszystkich 8 sąsiednich komórek daje liczbę aktywnych sąsiednich komórek.

  1. Sprawdź liczbę aktywnych sąsiadów, np. w ten sposób:

index.html (wywołanie funkcji 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);

Prowadzi to jednak do drobnego problemu: co się stanie, gdy sprawdzana komórka znajduje się poza krawędzią planszy? Zgodnie z Twoją obecną logiką cellIndex() albo przepełnia się do następnego lub poprzedniego wiersza, albo wykracza poza bufor.

W przypadku gry w życie powszechnym i łatwym sposobem na rozwiązanie tego problemu jest traktowanie komórek na krawędzi siatki jako sąsiadów komórek na przeciwległej krawędzi siatki, co tworzy efekt zawijania.

  1. Obsługa zawijania siatki z niewielką zmianą funkcji cellIndex().

index.html (wywołanie funkcji Compute 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ą one poza rozmiar siatki, masz pewność, że nigdy nie uzyskasz dostępu poza granice bufora pamięci. Dzięki temu możesz mieć pewność, że liczba activeNeighbors jest przewidywalna.

Następnie zastosuj jedną z 4 reguł:

  • Każda komórka z mniej niż 2 sąsiadami staje się nieaktywna.
  • Każda aktywna komórka z 2 lub 3 sąsiadami pozostaje aktywna.
  • Każda nieaktywna komórka z dokładnie 3 sąsiadami staje się aktywna.
  • Każda komórka z więcej niż 3 sąsiadami staje się nieaktywna.

Możesz to zrobić za pomocą serii instrukcji if, ale WGSL obsługuje też instrukcje switch, które dobrze pasują do tej logiki.

  1. Zaimplementuj logikę gry w życie, np. w ten sposób:

index.html (wywołanie funkcji Compute 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;
  }
}

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 obserwuj, jak rozwija się Twój automat komórkowy.

Zrzut ekranu z przykładowym stanem symulacji gry w życie. Kolorowe komórki są renderowane na ciemnoniebieskim tle.

9. Gratulacje!

Utworzono wersję klasycznej symulacji Gry w życie Conwaya, która działa w całości na GPU przy użyciu interfejsu WebGPU API.

Co dalej?

Więcej informacji

Dokumentacja