Twoja pierwsza aplikacja WebGPU

Pierwsza aplikacja WebGPU

Informacje o tym ćwiczeniu (w Codelabs)

subjectOstatnia aktualizacja: kwi 15, 2025
account_circleAutorzy: Brandon Jones, François Beaufort

1. Wprowadzenie

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

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

Nowoczesny interfejs API

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

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

Renderowanie

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

Obliczenia

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

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

Co utworzysz

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

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

Zrzut ekranu przedstawiający końcowy efekt pracy nad tym ćwiczeniem z programowania

Gra życia jest rodzajem automatu komórkowego, w którym siatka komórek zmienia stan w czasie na podstawie określonego zbioru reguł. W grze o życie komórki stają się aktywne lub nieaktywne w zależności od tego, ile sąsiednich komórek jest aktywnych, co prowadzi do powstawania interesujących wzorów, które zmieniają się w miarę oglądania.

Czego się nauczysz

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

Ten warsztat programistyczny skupia się na omówieniu podstawowych pojęć związanych z WebGPU. Nie jest to wyczerpujący przegląd 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 systemie operacyjnym ChromeOS, macOS lub Windows; WebGPU to interfejs API obsługujący wiele przeglądarek i platform, ale nie jest jeszcze dostępny wszędzie.
  • Znajomość HTML, JavaScriptu i Narzędzi deweloperskich w Chrome.

Znajomość innych interfejsów API do grafiki, takich jak WebGL, Metal, Vulkan czy Direct3D, nie jest wymagana, ale jeśli masz już doświadczenie z tymi interfejsami, prawdopodobnie zauważysz wiele podobieństw do WebGPU, które mogą przyspieszyć Twoją naukę.

2. Konfiguracja

Pobierz kod

To ćwiczenie nie ma żadnych zależności i pokazuje każdy krok potrzebny do utworzenia aplikacji WebGPU, więc nie musisz pisać żadnego kodu, aby zacząć. Jednak na stronie https://glitch.com/edit/#!/your-first-webgpu-app znajdziesz działające przykłady, które mogą służyć jako punkty kontrolne. Możesz je sprawdzić i użyć, jeśli utkniesz.

Użyj konsoli dla deweloperów.

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

Będziesz mieć problemy z rozwojem z użyciem WebGPU, zwłaszcza na początku, ale to nic złego. Deweloperzy, którzy stworzyli ten interfejs API, są świadomi trudności związanych z rozwojem GPU, dlatego dołożyli wszelkich starań, aby w razie wystąpienia błędu w kodzie WebGPU w konsoli dla deweloperów pojawiały się szczegółowe i przydatne komunikaty, które pomogą Ci zidentyfikować i naprawić problem.

Podczas pracy z każdą aplikacją internetową warto mieć otwartą konsolę, ale w tym przypadku jest to szczególnie przydatne.

3. Inicjowanie WebGPU

Zacznij od <canvas>

WebGPU można używać bez wyświetlania czegokolwiek na ekranie, jeśli interesują Cię tylko obliczenia. Jeśli jednak chcesz coś wyrenderować, tak jak będziemy to robić w tym laboratorium kodu, potrzebujesz płótna. To dobry punkt wyjścia.

Utwórz nowy dokument HTML z jednym elementem <canvas> oraz tagiem <script>, w którym zapytasz element canvas. (lub 00-starter-page.html z Glitcha).

  • 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>

Prośba o adapter i urządzenie

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

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

index.html

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

W idealnej sytuacji należy poinformować użytkownika o niedostępności WebGPU, przełączając stronę na tryb, który z niego nie korzysta. (Może zamiast tego użyć WebGL?) W ramach tego ćwiczenia po prostu rzucasz błędem, aby zatrzymać dalsze wykonywanie kodu.

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

  1. Aby uzyskać adapter, użyj metody navigator.gpu.requestAdapter(). Zwraca obietnicę, więc najwygodniej jest go wywoływać 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 być null, więc musisz uwzględnić tę możliwość. Może się tak zdarzyć, jeśli przeglądarka użytkownika obsługuje WebGPU, ale sprzęt GPU nie ma wszystkich funkcji potrzebnych do korzystania z WebGPU.

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

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

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

index.html

const device = await adapter.requestDevice();

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

Konfigurowanie Canvasa

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

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

index.html

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

Dostępnych jest kilka opcji, ale najważniejsze to device, który będzie używany w kontekście, oraz format, czyli format tekstury, którego powinien używać kontekst.

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

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

Wyczyść obszar roboczy

Teraz, gdy masz urządzenie i skonfigurowany na nim kanwa, możesz zacząć zmieniać zawartość kanwy. Na początek wypełnij ją jednolitym kolorem.

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

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

index.html

const encoder = device.createCommandEncoder();

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

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

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

index.html

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

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

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

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

Gdy proces renderowania się rozpocznie, nie musisz nic robić. Przynajmniej na razie. Rozpoczęcie renderowania za pomocą loadOp: "clear" wystarczy do wyczyszczenia widoku tekstury i płótna.

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

index.html

pass.end();

Pamiętaj, że samo wywołanie tych funkcji nie powoduje, że GPU faktycznie coś zrobi. Po prostu rejestrują polecenia, które GPU ma wykonać później.

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

index.html

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

index.html

device.queue.submit([commandBuffer]);

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

index.html

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

Po przesłaniu poleceń do GPU pozwól JavaScriptowi przekazać kontrolę przeglądarce. W tym momencie przeglądarka widzi, że zmieniono bieżącą teksturę kontekstu, i aktualizuje płótno, aby wyświetlić tę teksturę jako obraz. Jeśli chcesz ponownie zaktualizować zawartość kanwy, musisz nagrać i przesłać nowy bufor poleceń, ponownie wywołując funkcję context.getCurrentTexture(), aby uzyskać nową teksturę dla przejść renderowania.

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

Czarny obszar roboczy, który wskazuje, że WebGPU zostało użyte 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 dostosowanie jej do swoich potrzeb.

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

index.html

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

Element clearValue przekazuje renderowaniu informacje o tym, którego koloru użyć podczas wykonywania operacji clear na początku passu. Podany słownik zawiera 4 wartości: r dla czerwonego, g dla zielonego, b dla niebieskiego i a dla alfa (przezroczystość). Każda wartość może się mieścić w zakresie od 0 do 1, a razem opisują one 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 } jest w kolorze średnio szarym.
  • { r: 0, g: 0, b: 0, a: 0 } to domyślny przezroczysty czarny kolor.

Przykładowy kod i zrzuty ekranu w tym samouczku używają koloru ciemnoniebieskiego, ale możesz wybrać dowolny kolor.

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

Płótno z ciemnoniebieskim kolorem, aby pokazać, jak zmienić domyślny kolor tła.

4. Rysowanie geometrii

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

Jak działa renderowanie na kartach graficznych

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

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

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

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

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

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

Następnie GPU wybiera wszystkie trójkąty utworzone przez te przekształcone wierzchołki i określa, które piksele na ekranie są potrzebne do ich narysowania. Następnie uruchamia inny napisany przez Ciebie mały program, tzw. fragment shader, który oblicza, jaki kolor powinien mieć każdy piksel. To obliczenie może być tak proste jak zwrócenie zielonego koloru lub tak skomplikowane jak obliczenie kąta powierzchni w stosunku do światła słonecznego odbijającego się od innych pobliskich powierzchni, przefiltrowanego przez mgłę i zmodyfikowanego przez metaliczność powierzchni. Wszystko zależy od Ciebie, co może być zarówno inspirujące, jak i przytłaczające.

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

Definiowanie wierzchołków

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

Oznacza to, że musisz podać GPU 4 różne punkty, po jednym dla każdego z 4 rogów kwadratu. Na przykład kwadrat narysowany na środku kanwy, rozciągnięty od krawędzi, ma takie współrzędne narożników:

Wykres znormalizowanych współrzędnych urządzenia pokazujący współrzędne narożników kwadratu

Aby przesłać te współrzędne do procesora graficznego, musisz umieścić wartości w TypedArray. Jeśli nie znasz jeszcze TypedArrays, to jest to grupa obiektów JavaScript, która umożliwia przydzielanie ciągłych bloków pamięci i interpretowanie każdego elementu w serii jako określonego typu danych. Na przykład w tablicy Uint8Array każdy element to pojedynczy, bez znaku bajt. Obiekty TypedArrays doskonale nadają się do wysyłania danych za pomocą interfejsów API, które są wrażliwe na układ pamięci, takich jak WebAssembly, WebAudio i (oczywiście) WebGPU.

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

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

index.html

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

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

Ale jest pewien problem. Pamiętasz, że GPU działają na podstawie trójkątów? Oznacza to, że musisz podać wierzchołki w grupach po 3. Masz jedną grupę liczącą 4 osoby. Rozwiązaniem jest powtórzenie 2 wierzchołków, aby utworzyć 2 trójkąty, które mają wspólną krawędź w środku kwadratu.

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

Aby utworzyć kwadrat z diagramu, musisz podać wierzchołki (-0,8, -0,8) i (0,8, 0,8) dwukrotnie: raz dla niebieskiego trójkąta i raz dla czerwonego. (możesz też podzielić kwadrat na 2 części za pomocą pozostałych 2 narożników; nie ma to znaczenia).

  1. Zmień poprzedni tablicowy zbiór vertices, aby wyglądał tak:

index.html

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

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

Chociaż diagram pokazuje rozdzielenie 2 trójkątów (dla ułatwienia), pozycje wierzchołków są dokładnie takie same, a GPU renderuje je bez przerw. Zostanie on wyrenderowany jako pojedynczy, jednolity kwadrat.

Tworzenie bufora wierzchołków

GPU nie może rysować wierzchołków za pomocą danych z tablicy JavaScript. GPU często mają własną pamięć, która jest w wysokim stopniu zoptymalizowana pod kątem renderowania, dlatego 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łka, pamięci po stronie GPU zarządza się za pomocą obiektów GPUBuffer. Bufor to blok pamięci, który jest łatwo dostępny dla GPU i oznaczony do określonych celów. Możesz sobie wyobrazić, że jest to tablica TypedArray widoczna dla GPU.

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

index.html

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

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

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

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

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

Gdy bufor jest tworzony po raz pierwszy, pamięć, którą zawiera, jest inicjowana na wartość 0. Treść można zmienić na kilka sposobów, ale najłatwiej jest wywołać metodę device.queue.writeBuffer() z TypedArray, który chcesz skopiować.

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

index.html

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

Definiowanie układu wierzchołka

Teraz masz bufor z danymi wierzchołków, ale z punktu widzenia procesora graficznego jest to po prostu zbiór bajtów. Jeśli chcesz coś narysować, musisz podać nieco więcej informacji. Musisz być w stanie podać WebGPU więcej informacji o strukturze danych wierzchołka.

index.html

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

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

Najpierw podaj arrayStride. To liczba bajtów, które GPU musi pominąć w buforze, gdy szuka kolejnego wierzchołka. Każdy wierzchołek kwadratu składa się z 2 32-bitowych liczb zmiennoprzecinkową. Jak już wspomnieliśmy, 32-bitowa liczba zmiennoprzecinkowa to 4 bajty, więc 2 takie liczby to 8 bajtów.

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

W atrybucie pojedynczym najpierw definiujesz format danych. Wynika to z listy typów GPUVertexFormat, które opisują każdy typ danych wierzchołka zrozumiały dla procesora graficznego. Twoje wierzchołki mają po 2 32-bitowe liczby zmiennoprzecinkowe, więc używasz formatu float32x2. Jeśli dane wierzchołka składają się z czterech 16-bitowych bez znaku, użyjesz zamiast tego uint16x4. Widzisz ten wzór?

Następnie offset określa, ile bajtów od początku wierzchołka zajmuje dany atrybut. Musisz się tym przejmować tylko wtedy, gdy bufor zawiera więcej niż 1 atrybut, który nie będzie widoczny w ramach tego ćwiczenia.

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

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

Zacznij od shaderów

Masz już dane, które chcesz renderować, ale musisz też dokładnie określić procesorowi graficznemu, jak ma je przetworzyć. W dużej mierze odbywa się to za pomocą shaderów.

Shadery to małe programy, które piszesz i uruchamiasz na karcie graficznej. Każdy shader działa na innym etapie przetwarzania danych: przetwarzanie wierzchołków, przetwarzanie fragmentów lub ogólne przetwarzanie. Ponieważ są one wykonywane na GPU, ich struktura jest bardziej sztywna niż w przypadku zwykłego kodu JavaScript. Jednak ta struktura pozwala na ich bardzo szybkie i co najważniejsze równoległe wykonywanie.

Shadery w WebGPU są pisane w języku cieniowania o nazwie WGSL (język cieniowania WebGPU). WGSL pod względem składni jest trochę podobny do Rust, a jego funkcje mają ułatwiać i przyspieszać wykonywanie typowych zadań na procesorze graficznym (takich jak obliczenia wektorów i macierzy). W ramach tego samouczka nie da się nauczyć całego języka cieniowania, ale mam nadzieję, że poznasz podstawy, gdy prześledzimy kilka prostych przykładów.

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

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

index.html

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

Aby utworzyć shadery, które nazywasz device.createShaderModule(), możesz podać opcjonalny ciąg znaków label i WGSL code. (aby umożliwić tworzenie ciągów znaków wielowierszowych, używaj znaków cudzysłowu) Po dodaniu prawidłowego kodu WGSL funkcja zwraca obiekt GPUShaderModule z skompilowanymi wynikami.

Definiowanie shadera wierzchołka

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

Shader wierzchołka jest zdefiniowany jako funkcja, a GPU wywołuje tę funkcję raz na każdy wierzchołek w vertexBuffer. Ponieważ vertexBuffer ma 6 pozycji (wierzchołków), zdefiniowana przez Ciebie funkcja jest wywoływana 6 razy. Za każdym razem, gdy jest wywoływana, do funkcji jako argument przekazywane jest inne położenie z poziomu vertexBuffer. Zadaniem funkcji shadera wierzchołka jest zwrócenie odpowiedniego położenia w przestrzeni klipu.

Pamiętaj, że niekoniecznie będą one wywoływane w kolejności. Zamiast tego procesory graficzne doskonale radzą sobie z uruchamianiem takich shaderów równolegle, co pozwala przetwarzać jednocześnie setki (a nawet tysiące!) wierzchołków. To właśnie decyduje o niesamowitej szybkości GPU, ale wiąże się z pewnymi ograniczeniami. Aby zapewnić ekstremalną równoległość, shadery wierzchołków nie mogą się ze sobą komunikować. Każde wywołanie shadera może widzieć tylko dane pojedynczego wierzchołka naraz i może wyprowadzać tylko wartości pojedynczego wierzchołka.

W WGSL nazwa funkcji shadera wierzchołka może być dowolna, ale musi być poprzedzona @vertex atrybutem, aby wskazać, który etap shadera reprezentuje. WGSL oznacza funkcje za pomocą słowa kluczowego fn, używa nawiasów do deklarowania argumentów i nawiasów klamrowych do definiowania zakresu.

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

index.html (kod createShaderModule)

@vertex
fn vertexMain() {

}

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

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

index.html (kod createShaderModule)

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

}

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

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

index.html (kod createShaderModule)

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

Zamiast tego chcesz użyć danych z utworzonego bufora. Aby to zrobić, zadeklaruj argument funkcji z atrybutem @location() i typem zgodnym z opisem w funkcji vertexBufferLayout. W kodzie WGSL zaznacz argument shaderLocation jako 0.@location(0) Format został też zdefiniowany jako float32x2, czyli wektor 2D, więc w WGSL Twój argument jest vec2f. Możesz nadać mu dowolną nazwę, ale ponieważ te wartości reprezentują pozycje wierzchołków, nazwa pos wydaje się naturalna.

  1. Zmień funkcję shadera na ten kod:

index.html (kod createShaderModule)

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

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

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

index.html (kod createShaderModule)

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

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

  1. 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 jest nasz początkowy shader wierzchołków. Jest to bardzo proste, ponieważ pozycja jest przekazywana bez zmian, ale wystarcza to na początek.

Definiowanie fragment shadera

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

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

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

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

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

index.html (kod createShaderModule)

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

}

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

  1. Ustaw zwrócony wektor koloru w ten sposób:

index.html (kod createShaderModule)

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

To już gotowy fragment shadera. Nie jest to zbyt interesująca funkcja, ponieważ po prostu zmienia kolor każdego piksela każdego trójkąta na czerwony, ale na razie to wystarczy.

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

index.html

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

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

Tworzenie potoku renderowania

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

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

  • Utwórz ścieżkę renderowania, np.

index.html

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

Każdy potok wymaga layout, który opisuje, jakich typów danych wejściowych (innych niż bufory wierzchołków) potrzebuje, ale Ty ich nie masz. Na szczęście możesz na razie pominąć "auto", a przepływ danych utworzy własny układ z shaderów.

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

Na koniec znajdziesz szczegóły dotyczące etapu fragment. Obejmuje to też modułpunkt wejścia shadera, np. etap wierzchołków. Ostatnim krokiem jest zdefiniowanie targets, z którym jest używany ten potok. Jest to tablica słowników, która zawiera szczegóły (takie jak tekstura format) załączników kolorów generowanych przez potok. Te szczegóły muszą być zgodne z teksturami w colorAttachments wszystkich przejść renderowania, z którymi jest używany ten potok. Przekazywanie danych do renderowania używa tekstur z kontekstu kanwy i wartości zapisanej w polu canvasFormat, więc tutaj przekazujesz ten sam format.

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

Rysowanie kwadratu

Teraz masz już wszystko, czego potrzebujesz, aby narysować kwadrat.

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

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

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

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

Na koniec wykonaj draw() połączenie, które wydaje się dziwnie proste po całej wcześniejszej konfiguracji. Jedyną rzeczą, którą musisz przekazać, jest liczba wierzchołków, które mają zostać wyrenderowane. Ta liczba jest pobierana z obecnie ustawionych buforów wierzchołków i interpretowana za pomocą bieżącego potoku. Możesz po prostu zakodować go na stałe jako 6, ale obliczenie go z tablicy wierzchołków (12 liczb zmiennoprzecinkowych / 2 współrzędnych na wierzchołek = 6 wierzchołków) oznacza, że jeśli zdecydujesz się zastąpić kwadrat np. okręgiem, będziesz musiał ręcznie zaktualizować mniej elementów.

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

Pojedynczy czerwony kwadrat renderowany za pomocą WebGPU

5. Rysowanie siatki

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

W tej sekcji dowiesz się:

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

Definiowanie siatki

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

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

index.html

const GRID_SIZE = 4;

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

Jednym ze sposobów można jest zwiększenie rozmiaru bufora wierzchołków i zdefiniowanie w nim kwadratów o wartości GRID_SIZE × GRID_SIZE o odpowiednim rozmiarze i pozycji. Kod nie byłby aż tak zły. Kilka pętli for i trochę matematyki. Nie wykorzystuje on jednak w pełni możliwości GPU i korzysta z większej ilości pamięci niż jest to konieczne do uzyskania efektu. W tej sekcji omawiamy podejście bardziej przyjazne procesowi graficznemu.

Tworzenie jednolitego bufora

Najpierw musisz przekazać wybrany rozmiar siatki do shadera, ponieważ wykorzystuje on ten rozmiar do zmiany sposobu wyświetlania elementów. Możesz po prostu zakodować rozmiar w shaderze, ale oznacza to, że za każdym razem, gdy chcesz zmienić rozmiar siatki, musisz ponownie utworzyć shader i pipeline renderowania, co jest kosztowne. Lepszym rozwiązaniem jest przekazanie rozmiaru siatki do shadera jako jednolitej.

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

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

index.html

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

Ten kod powinien wyglądać bardzo znajomo, ponieważ jest prawie identyczny z tym, którego wcześniej użyliśmy do utworzenia bufora wierzchołków. Dzieje się tak, ponieważ uniformy są przekazywane do interfejsu WebGPU API za pomocą tych samych obiektów GPUBuffer co wierzchołki. Główną różnicą jest to, że tym razem obiekt usage zawiera GPUBufferUsage.UNIFORM zamiast GPUBufferUsage.VERTEX.

Dostęp do uniformów w shaderze

  • Zdefiniuj strój, dodając ten kod:

index.html (wywołanie createShaderModule)

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

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

// ...fragmentMain is unchanged

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

Następnie w innych miejscach w kodzie shadera możesz używać wektora siatki w dowolny sposób. W tym kodzie pozycję wierzchołka dzielimy przez wektor siatki. Ponieważ pos jest wektorem 2D, a grid jest wektorem 2D, WGSL wykonuje podział według komponentó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 popularne w shaderach GPU, ponieważ wiele technik renderowania i przetwarzania na nich bazuje.

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

Tworzenie grupy wiązania

Zadeklarowanie uniformu w shaderze nie łączy go jednak z utworzonym przez Ciebie buforem. Aby to zrobić, musisz utworzyć i skonfigurować grupę wiązania.

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

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

index.html

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

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

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

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

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

Połącz grupę bind

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

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

index.html

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

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

pass.draw(vertices.length / 2);

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

Teraz bufor jednolity jest dostępny dla Twojego shadera.

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

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

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

Modyfikowanie geometrii w shaderze

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

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

Ilustracja siatki koncepcyjnej, na którą podzielono przestrzeń skoordynatów urządzeń z normalizowanymi współrzędnymi, z każdą komórką z obecnie renderowaną geometrią kwadratu w środku.

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

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

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

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

index.html (wywołanie createShaderModule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

Spowoduje to przesunięcie każdego wierzchołka w górę i w prawo o jedną jednostkę (która, jak pamiętasz, jest połową przestrzeni klipu) przed podzieleniem go przez rozmiar siatki. Wynikiem jest kwadrat dobrze dopasowany do siatki, który znajduje się w pobliżu punktu wyjścia.

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

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

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

index.html (wywołanie createShaderModule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

Kwadrat jest teraz ładnie umieszczony w komórce (0, 0).

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

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

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

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

index.html (wywołanie createShaderModule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

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

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

index.html (wywołanie createShaderModule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

Zrzut ekranu wygląda tak:

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

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

Rysowanie instancji

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

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

Zamiast tego możesz użyć techniki zwanej instancjonowaniem. Instancjonowanie to sposób na przekazanie procesorowi graficznemu polecenia narysowania wielu kopii tej samej geometrii za pomocą pojedynczego wywołania funkcji draw, co jest znacznie szybsze niż wywoływanie funkcji draw po jednej dla każdej kopii. Każda kopia geometrii jest nazywana wystąpieniem.

  1. Aby powiedzieć procesorowi graficznemu, że chcesz wypełnić siatkę wystarczającą liczbą kwadratów, dodaj do istniejącego wywołania metody draw jeden argument:

index.html

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

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

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

Dlaczego? To dlatego, że rysujesz wszystkie 16 kwadratów w tym samym miejscu. W shaderze musisz umieścić dodatkową logikę, która zmienia położenie geometrii w poszczególnych wystąpieniach.

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

Aby zobaczyć to w działaniu, musisz dodać wbudowany parametr instance_index do wejść shadera. Zrób to w taki sam sposób jak w przypadku pozycji, ale zamiast tagowania go atrybutem @location użyj atrybutu @builtin(instance_index), a potem nadaj nazwę argumentowi. (możesz nazwać go instance, aby pasował do przykładowego kodu). Następnie użyj go w ramach logiki shadera.

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

index.html

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

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

  return vec4f(gridPos, 0, 1);
}

Jeśli teraz odświeżysz stronę, zobaczysz, że masz więcej niż 1 kwadrat. Nie zobaczysz jednak wszystkich 16 poziomów.

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

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

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

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

  1. Zmień obliczenia w ten sposób:

index.html

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

index.html

const GRID_SIZE = 32;

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

Tada! Teraz możesz stworzyć naprawdę bardzo dużą siatkę, a przeciętna karta graficzna poradzi sobie z tym bez problemu. Przestaniesz widzieć poszczególne kwadraty na długo przed wystąpieniem jakichkolwiek wąskich gardeł wydajności procesora graficznego.

6. Dodatkowy punkt: dodaj więcej kolorów.

W tym momencie możesz przejść do następnej sekcji, ponieważ masz już podstawy potrzebne do dalszej części samouczka. Siatka kwadratów w tym samym kolorze jest przydatna, ale niezbyt ekscytująca, prawda? Na szczęście możesz nieco rozjaśnić obraz, dodając trochę kodu matematycznego i shadera.

Używanie struktur w shaderach

Do tej pory z shadera wierzchołka przekazywany był jeden element danych: przekształcona pozycja. Możesz jednak zwrócić znacznie więcej danych z shadera wierzchołkowego, a następnie użyć ich w shaderze fragmentów.

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

index.html (wywołanie createShaderModule)

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

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

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

index.html (wywołanie createShaderModule)

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

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

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

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

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

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

Przypominamy, że funkcja @fragment jest jak najbardziej uproszczona:

index.html (wywołanie createShaderModule)

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

Nie przyjmujesz żadnych danych wejściowych, a jako dane wyjściowe przekazujesz jednolity kolor (czerwony). Jeśli jednak shader wiedziałby więcej o geometryi, którą koloruje, można by użyć tych dodatkowych danych, aby uczynić ją nieco ciekawszą. Co zrobić, jeśli chcesz zmienić kolor każdego kwadratu na podstawie jego współrzędnych? Etap @vertex wie, która komórka jest renderowana. Musisz ją tylko przekazać etapowi @fragment.

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

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

index.html (wywołanie createShaderModule)

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

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

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

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
 
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. W funkcji @fragment wartość jest zwracana po dodaniu argumentu o tej samej nazwie @location. (nazwy nie muszą być takie same, ale łatwiej jest śledzić elementy, 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. Inną możliwością, ponieważ w Twoim kodzie obie te funkcje są zdefiniowane w tym samym module shadera, jest ponowne użycie struktury wyjściowej etapu @vertex. Ułatwia to przekazywanie wartości, ponieważ nazwy i lokalizacje są spójne.

index.html (wywołanie createShaderModule)

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

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

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

Teraz jest zdecydowanie więcej kolorów, ale nie wygląda to zbyt ładnie. Możesz się zastanawiać, dlaczego tylko rzędy z lewej i z dołu są różne. Dzieje się tak, ponieważ wartości kolorów zwracane przez funkcję @fragment muszą być w zakresie od 0 do 1, a wartości spoza tego zakresu są do niego ograniczane. Wartości komórek na osi poziomej i pionowej mieszczą się w zakresie od 0 do 32. Widać więc, że pierwszy wiersz i pierwsza kolumna natychmiast osiągają wartość 1 w kanale czerwonym lub zielonym, a każda następna komórka ma tę samą wartość.

Jeśli chcesz uzyskać płynne 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 1 na każdej osi, co oznacza jeszcze jedno dzielenie przez grid.

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

index.html (wywołanie createShaderModule)

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

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

Siatka kwadratów, które w różnych rogach zmieniają kolor z czarnego na czerwony, zielony i żółty.

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

Na szczęście masz cały nieużywany kanał kolorów – niebieski – który możesz wykorzystać. Najlepiej jest, gdy niebieski jest najjaśniejszy tam, gdzie inne kolory są najciemniejsze, a potem blaknie, gdy inne kolory stają się intensywniejsze. Najłatwiej jest ustawić początek kanału na 1 i odjąć jedną z wartości komórek. Może to być c.x lub c.y. Wypróbuj obie opcje i wybierz tę, która Ci odpowiada.

  1. Dodaj jaśniejsze kolory do fragment shadera, na przykład w ten sposób:

wywołanie createShaderModule

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

Wynik wygląda całkiem nieźle.

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

To nie jest krok krytyczny. Ponieważ jednak wygląda on lepiej, został dołączony do odpowiedniego pliku źródłowego punktu kontrolnego, a pozostałe zrzuty ekranu w tym CodeLab odzwierciedlają tę bardziej kolorową siatkę.

7. Zarządzanie stanem komórki

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

Wystarczy, że dla każdej komórki będzie sygnał włączania/wyłączania, więc możesz użyć dowolnych opcji, które umożliwiają przechowywanie dużej tablicy niemal dowolnego typu wartości. Możesz pomyśleć, że to kolejny przypadek użycia jednolitych buforów. Można to zrobić, ale jest to trudniejsze, ponieważ rozmiary buforów jednolitych są ograniczone, nie obsługują tablic o dynamicznej wielkości (musisz podać rozmiar tablicy w shaderze) i nie mogą być zapisywane przez shadery obliczeniowe. Ten ostatni element jest najbardziej problematyczny, ponieważ symulację Game of Life chcesz wykonać na GPU w shaderze obliczeniowym.

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

Tworzenie bufora pamięci masowej

Bufory pamięci to bufory ogólnego przeznaczenia, które można odczytywać i zapisywać w shaderach obliczeniowych oraz odczytywać w shaderach wierzchołkowych. Mogą być bardzo duże i nie muszą mieć określonego rozmiaru w shaderze, co czyni je bardziej podobnymi do ogólnej pamięci. Jest on używany do przechowywania stanu komórki.

  1. Aby utworzyć bufor pamięci dla stanu komórki, użyj prawdopodobnie już znajomego fragmentu kodu służącego do tworzenia bufora:

index.html

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

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

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

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

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

index.html

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

Odczyt bufora pamięci w shaderze

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

  1. Zaktualizuj shader za pomocą tego kodu:

index.html

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

Najpierw dodaj punkt wiązania, który znajduje się tuż pod siatką. Chcesz zachować tę samą wartość @group co w formie grid, ale wartość @binding musi być inna. Typ var to storage, aby odzwierciedlać inny typ bufora. Zamiast pojedynczego wektora typ cellState to tablica wartości u32, aby pasować do Uint32Array w JavaScript.

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

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

  1. Zaktualizuj kod shadera, aby skalować pozycję w zależności od aktywnego stanu komórki. Aby spełnić wymagania WGSL dotyczące bezpieczeństwa typów, wartość stanu musi zostać rzutowana na f32:

index.html

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

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

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

Dodawanie bufora pamięci do grupy wiązania

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

  • Dodaj bufor danych w ten sposób:

index.html

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

Upewnij się, że binding nowego wpisu jest zgodne z @binding() odpowiadającej wartości w shaderze.

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

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

Używanie wzorca bufora ping-pong

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

Dlaczego jest to konieczne? Weź pod uwagę uproszczony przykład: wyobraź sobie, że piszesz bardzo prostą symulację, w której wszystkie aktywne bloki przesuwasz o jedną komórkę w prawo na każdym kroku. Aby ułatwić sobie zrozumienie tego procesu, zdefiniuj dane i symulację 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 przejdzie do końca tablicy w jednym kroku. Dlaczego? Ponieważ stale aktualizujesz stan, przenosisz aktywną komórkę w prawo, a potem patrzysz na następną komórkę i... Jest aktywne. Lepiej przesuń go znowu w prawo. Zmiana danych w tym samym czasie, w którym je obserwujesz, powoduje ich zniekształcenie.

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

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

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

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

index.html

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

index.html

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

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

index.html

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

Konfigurowanie pętli renderowania

Do tej pory wykonałeś tylko 1 losowanie na odświeżenie strony, ale teraz chcesz wyświetlać dane aktualizowane w czasie. Aby to zrobić, potrzebujesz prostego pętli renderowania.

Pętla renderowania to nieustannie powtarzająca się pętla, która w określonym odstępie czasu rysuje treści na płótnie. Wiele gier i innych treści, które mają płynnie animować, 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 może też korzystać z tego mechanizmu, ale w tym przypadku prawdopodobnie zechcesz, aby aktualizacje były wyświetlane w większych odstępach czasu, aby łatwiej było Ci śledzić, co dzieje się w ramach symulacji. Zamiast tego możesz samodzielnie zarządzać pętlą, aby kontrolować szybkość aktualizacji symulacji.

  1. Najpierw wybierz częstotliwość aktualizacji symulacji (200 ms jest dobrym wyborem, ale możesz też wybrać wolniejsze lub szybsze tempo), a potem śledź, ile kroków symulacji zostało ukończonych.

index.html

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

index.html

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

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

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

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

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

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

To już prawie wszystko, jeśli chodzi o renderowanie. W następnym kroku, w którym zaczniesz używać shaderów obliczeniowych, będziesz już gotowy do wyświetlania danych wyjściowych symulacji Game of Life.

Oczywiście możliwości WebGPU wykraczają poza zakres tego krótkiego wprowadzenia, ale reszta wykracza poza zakres tego CodeLab. Mamy nadzieję, że uda Ci się w miarę dobrze poznać sposób działania WebGPU, co ułatwia zrozumienie bardziej zaawansowanych technik, takich jak renderowanie 3D.

8. Uruchamianie symulacji

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

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

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

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

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

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

index.html

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

    }`
});

Ponieważ procesory graficzne są często używane do grafiki 3D, shadery obliczeniowe są tak skonstruowane, że możesz określić, ile razy ma być wywoływany shader wzdłuż osi X, Y i Z. Dzięki temu możesz bardzo łatwo wysyłać zadania, które są zgodne 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 charakter architektury sprzętowej GPU ta siatka jest podzielona na grupy robocze. Grupa robocza ma rozmiar X, Y i Z. Chociaż rozmiary mogą być równe 1, często większa wielkość grup roboczych przynosi korzyści w zakresie wydajności. Dla shadera wybierz dowolny rozmiar grupy roboczej 8 x 8. Jest to przydatne w przypadku śledzenia kodu JavaScript.

  1. Zdefiniuj stałą wartość rozmiaru grupy roboczej, na przykład w ten sposób:

index.html

const WORKGROUP_SIZE = 8;

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

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

index.html (wywołanie funkcji createShaderModule)

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

}

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

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

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

index.html (wywołanie createShaderModule w procesorze)

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

}

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

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

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

index.html (wywołanie funkcji createShaderModule)

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

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

}

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

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

index.html (wywołanie funkcji createShaderModule)

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

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

}

Pamiętaj, że pierwszy bufor pamięci jest zadeklarowany za pomocą var<storage>, co powoduje, że jest on tylko do odczytu, ale drugi bufor pamięci jest zadeklarowany za pomocą var<storage, read_write>. Dzięki temu możesz odczytywać i zapisywać dane w buforze, używając go jako wyjścia dla shadera obliczeniowego. (w WebGPU nie ma trybu tylko do zapisu).

Następnie musisz mieć sposób mapowania indeksu komórki na tablicę pamięci liniowej. Jest to w podstawie przeciwieństwo tego, co zrobiliśmy w shaderze wierzchołka, gdzie użyliśmy linearnego instance_index i zmapowaliśmy go na komórkę siatki 2D. (przypominamy, że algorytm do tego celu to vec2f(i % grid.x, floor(i / grid.x))).

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

index.html (wywołanie createShaderModule w procesorze)

@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 to działa, zastosuj bardzo prosty algorytm: jeśli komórka jest włączona, wyłącz ją i na odwrót. To jeszcze nie jest Gra Życia, ale wystarcza, aby pokazać, że shader obliczeniowy działa.

  1. Dodaj prosty algorytm, np.

index.html (wywołanie createShaderModule w procesorze)

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

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

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

To wszystko na temat shadera obliczeniowego – przynajmniej na razie. Zanim jednak zobaczysz wyniki, musisz wprowadzić jeszcze kilka zmian.

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

Z powyższego shadera możesz zauważyć, że używa on w dużej mierze tych samych danych wejściowych (uniformów i buforów pamięci) co twój potok renderowania. Możesz więc pomyśleć, że wystarczy użyć tych samych grup wiązania. Dobra wiadomość jest taka, że możesz to zrobić. Wymaga to tylko nieco więcej ręcznej konfiguracji.

Za każdym razem, gdy tworzysz grupę wiązania, musisz podać GPUBindGroupLayout. Wcześniej uzyskiwałeś to rozmieszczenie, wywołując funkcję getBindGroupLayout() w potoku renderowania, który z kolei tworzył go automatycznie, ponieważ podczas jego tworzenia podałeś parametr layout: "auto". To podejście sprawdza się, gdy używasz tylko jednego potoku, ale jeśli masz kilka strumieni, które mają udostępniać zasoby, musisz utworzyć układ w sposób jawny, a następnie przekazać go zarówno grupie wiązania, jak i strumieniom.

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

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

index.html

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

Ta struktura jest podobna do tworzenia grupy wiązania, ponieważ polega na opisie listy entries. Różnica polega na tym, że zamiast udostępniać zasób, opisujesz, jaki typ zasobu musi on mieć i jak jest używany.

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

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

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

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

  1. Zaktualizuj tworzenie grupy wiązania w ten sposób:

index.html

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

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

Gdy grupa bind została zaktualizowana, aby używać tego wyraźnego układu grupy bind, musisz zaktualizować potok renderowania, aby używać tego samego.

  1. Utwórz GPUPipelineLayout.

index.html

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

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

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

index.html

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

Tworzenie potoku obliczeniowego

Podobnie jak do korzystania z shaderów wierzchołków i fragmentów potrzebny jest potok renderowania, tak do korzystania z shadera obliczeniowego potrzebny jest potok obliczeniowy. Na szczęście potoki obliczeniowe są znacznie mniej skomplikowane niż potoki renderowania, ponieważ nie mają żadnego stanu do ustawienia, tylko shader i layout.

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

index.html

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

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

Obliczanie kart dostępu

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

  1. Przesuń utworzenie kodera na początek funkcji, a potem rozpocznij z nim przetwarzanie (przed step++).

index.html

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

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

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

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

Przetwarzanie musi być wykonane przed renderowaniem, ponieważ pozwala to renderowaniu na natychmiastowe korzystanie z najnowszych wyników z przetwarzania. Z tego powodu zwiększasz też liczbę step między kolejnymi przejściami, aby bufor wyjściowy potoku obliczeniowego stał się buforem wejściowym dla potoku renderowania.

  1. Następnie w przesłaniu obliczeniowym ustaw potok i grupę wiązania, używając tego samego wzorca przełączania się między grupami wiązania, co w przypadku przesyłania renderowania.

index.html

const computePass = encoder.beginComputePass();

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

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

index.html

const computePass = encoder.beginComputePass();

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

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

computePass.end();

Bardzo ważne jest, aby pamiętać, że liczba przekazana do funkcji dispatchWorkgroups() nie jest liczbą wywołań. Zamiast tego jest to liczba grup roboczych do wykonania, określona przez parametr @workgroup_size w shaderze.

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

Teraz możesz odświeżyć stronę. Powinna się ona odwracać przy każdej aktualizacji.

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

Implementacja algorytmu do gry w życie

Zanim zaktualizujesz shader obliczeniowy, aby zaimplementować końcowy algorytm, wróć do kodu inicjującego zawartość bufora pamięci masowej i zaktualizuj go, aby generował losowy bufor przy każdym wczytaniu strony. (zwykłe wzorce nie tworzą zbyt ciekawych punktów początkowych w grze). Wartości możesz losować w dowolny sposób, ale istnieje też prosty sposób na rozpoczęcie, który daje zadowalające wyniki.

  1. Aby każda komórka miała losowy stan początkowy, zaktualizuj inicjalizację cellStateArray do tego kodu:

index.html

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

Teraz możesz wdrożyć logikę symulacji Game of Life. Po wszystkim, co trzeba było zrobić, kod shadera może okazać się zaskakująco prosty.

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

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

index.html (wywołanie funkcji createShaderModule)

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

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

  1. Aby sprawdzić liczbę aktywnych sąsiadów:

index.html (wywołanie createShaderModule w procesorze)

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

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

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

  1. Obsługa zawinięcia siatki z niewielką zmianą funkcji cellIndex().

index.html (wywołanie createShaderModule w procesorze)

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

Użycie operatora % do zawinięcia komórki X i Y, gdy wykracza ona poza rozmiar siatki, zapewnia, że nigdy nie będziesz mieć dostępu poza granice bufora pamięci. Dzięki temu możesz mieć pewność, że liczba activeNeighbors jest przewidywalna.

Następnie stosujesz jedną z 4 reguł:

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

Możesz to zrobić za pomocą serii instrukcji if, ale WGSL obsługuje też instrukcje switch, które są odpowiednie w przypadku tej logiki.

  1. Zaimplementuj logikę gry w życie:

index.html (wywołanie createShaderModule w procesorze)

let i = cellIndex(cell.xy);

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

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

index.html

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

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

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

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

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

      let i = cellIndex(cell.xy);

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

I to wszystko. To już wszystko. Odśwież stronę i obserwuj, jak rośnie Twój nowo utworzony automat komórkowy.

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

9. Gratulacje!

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

Co dalej?

Więcej informacji

Dokumenty referencyjne