Twoja pierwsza aplikacja WebGPU

1. Wprowadzenie

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

Co to jest WebGPU?

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

Nowoczesny interfejs API

Zanim pojawiło się 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. Jego podstawą było OpenGL ES 2.0 API, który pojawił się w 2007 roku, oparty na jeszcze starszym interfejsie OpenGL API. W tym czasie nastąpiły znaczne zmiany w procesorach graficznych, a natywne interfejsy API używane do ich obsługi uległy także zmianie wraz z zastosowaniem systemów Direct3D 12, Metal i Vulkan.

WebGPU wprowadza ulepszenia tych nowoczesnych interfejsów API na platformę internetową. Skupia się on na włączaniu funkcji GPU na wielu platformach przy jednoczesnym zaprezentowaniu interfejsu API, który wygląda naturalnie w internecie i jest mniej szczegółowy niż niektóre natywne interfejsy API, na których jest oparty.

Renderowanie

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

Obliczenia

Poza renderowaniem WebGPU uwalnia możliwości procesora graficznego także do zwykłych obciążeń równoległych do zwykłych obciążeń. 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 ostateczną wersję usługi w ramach ćwiczenia z programowania

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

Czego się nauczysz

  • Konfigurowanie procesora WebGPU i obszaru roboczego.
  • Sposób rysowania prostej geometrii 2D.
  • Jak używać cieniowania wierzchołków i fragmentów, by zmodyfikować to, co rysowane.
  • Jak przeprowadzić prostą symulację przy użyciu cieniowania obliczeniowego.

Skupia się on na przedstawieniu podstawowych koncepcji stojących za WebGPU. Nie jest to wyczerpujące omówienie interfejsu API ani nie obejmuje (ani nie wymaga) często powiązanych tematów, takich jak matematyka macierzy 3D.

Czego potrzebujesz

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

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

2. Konfiguracja

Pobierz kod

Ćwiczenie w Codelabs nie wymaga żadnych zależności. Poprowadzi Cię przez wszystkie kroki wymagane do utworzenia aplikacji WebGPU, więc do rozpoczęcia pracy nie potrzebujesz żadnego kodu. 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.

Korzystanie z konsoli programisty

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

Będziesz mieć problemy z rozwojem z użyciem WebGPU, zwłaszcza na początku, ale to normalne. Programiści, którzy opracowują interfejs API, wiedzą o wyzwaniach związanych z tworzeniem GPU i dołożyli wszelkich starań, aby za każdym razem, gdy Twój kod WebGPU wywołał błąd, w konsoli programisty otrzymasz bardzo szczegółowe i pomocne komunikaty, które pomogą Ci zidentyfikować i rozwiązać problem.

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

3. Inicjowanie WebGPU

Rozpocznij od <canvas>

WebGPU może być używany bez wyświetlania czegokolwiek na ekranie, jeśli tylko chcesz używać go do wykonywania obliczeń. Jeśli jednak chcesz wyrenderować jakiś element, na przykład w ramach ćwiczenia z programowania, potrzebujesz obszaru roboczego. To dobry punkt wyjścia.

Utwórz nowy dokument HTML z pojedynczym elementem <canvas> oraz tag <script>, w którym wysyłamy zapytanie do elementu canvas. (Lub użyj pliku 00-starter-page.html z problemu).

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

index.html

<!doctype html>

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

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

Zamawianie przejściówki i urządzenia

Teraz możesz przejść do elementów 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 istnieje obiekt navigator.gpu, który służy jako punkt wejścia dla WebGPU, dodaj ten kod:

index.html

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

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

Gdy 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ć przejściówkę, użyj metody navigator.gpu.requestAdapter(). Zwraca obietnicę, dlatego najlepiej jest go wywołać za pomocą funkcji 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 należy uwzględnić tę możliwość. Może się tak zdarzyć, jeśli przeglądarka użytkownika obsługuje WebGPU, ale jego sprzęt GPU nie zapewnia wszystkich funkcji wymaganych 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ć sprzętu o niskiej czy wysokiej mocy na urządzeniach z wieloma układami GPU (np. na niektórych laptopach).

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

  1. Kup urządzenie, dzwoniąc do firmy adapter.requestDevice(), która również zwróci obietnicę.

index.html

const device = await adapter.requestDevice();

Tak jak w przypadku requestAdapter(), istnieją opcje, które można przekazać tutaj w zaawansowanych zastosowaniach, takich jak włączenie określonych funkcji sprzętowych lub żądanie wyższych limitów, ale do Twoich potrzeb ustawienia domyślne działają normalnie.

Konfigurowanie Canvasa

Skoro masz już urządzenie, musisz wykonać jeszcze jedną rzecz, aby użyć go do wyświetlania zawartości strony: skonfiguruj obszar roboczy do używania z nowo utworzonym urządzeniem.

  • Aby to zrobić, najpierw poproś o GPUCanvasContext z płótna, wywołując funkcję canvas.getContext("webgpu"). (to samo wywołanie, które służy do inicjowania kontekstów Canvas 2D lub WebGL odpowiednio przy użyciu typów kontekstów 2dwebgl). Zwrócona wartość context musi zostać powiązana 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 obrazów. 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 można wyświetlić jako część strony.

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

Czyszczenie obszaru roboczego

Po skonfigurowaniu urządzenia i skonfigurowaniu obszaru roboczego możesz zacząć zmieniać zawartość obszaru roboczego na urządzeniu. 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ą związane z renderowaniem (w tym przypadku są to czyszczenie obszaru roboczego), więc następnym krokiem jest użycie encoder w celu rozpoczęcia renderowania.

Proces renderowania polega na wykonywaniu wszystkich operacji rysowania w WebGPU. Każda z nich zaczyna się od wywołania beginRenderPass(), które określa tekstury, które otrzymują dane wyjściowe wszystkich wykonanych poleceń rysowania. W bardziej zaawansowanych zastosowaniach jest dostępnych kilka tekstur nazywanych dodatkami do różnych celów, takich jak przechowywanie głębi renderowanej geometrii lub stosowanie antyaliasing. Jednak na potrzeby tej aplikacji potrzebujesz tylko jednego.

  1. Pobierz teksturę z utworzonego wcześniej kontekstu obszaru roboczego, wywołując metodę context.getCurrentTexture(), która zwraca teksturę o szerokości i wysokości w pikselach pasującej do atrybutów width i height obszaru roboczego, a także do wartości format określonej przy wywołaniu context.configure().

index.html

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

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

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

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

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

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

index.html

pass.end();

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

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

index.html

const commandBuffer = encoder.finish();
  1. Prześlij bufor poleceń do GPU, używając queue interfejsu GPUDevice. Kolejka wykonuje wszystkie polecenia GPU, dzięki czemu ich wykonanie jest uporządkowane i prawidłowo synchronizowane. 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ń. Właśnie dlatego te 2 kroki są dość często zwijane do jednego, tak jak na przykładowych stronach ćwiczenia z programowania:

index.html

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

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

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

Czarne płótno wskazujące, że do wyczyszczenia zawartości obszaru roboczego udało się użyć procesora WebGPU.

Wybierz kolor

Szczerze mówiąc, czarne kwadraty są dość nudne. Dlatego poświęć chwilę, zanim przejdziesz do następnej sekcji, aby chociaż odrobinę go spersonalizować.

  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ę wahać od 0 do 1, a razem opisują one wartość tego kanału kolorów. Na przykład:

  • { r: 1, g: 0, b: 0, a: 1 } jest jasnoczerwony.
  • { r: 1, g: 0, b: 1, a: 1 } jest jasnofioletowy.
  • { r: 0, g: 0.3, b: 0, a: 1 } jest ciemnozielony.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } to średni szary.
  • Domyślnym kolorem jest { r: 0, g: 0, b: 0, a: 0 }, czyli przezroczysta czerń.

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

  1. Po wybraniu koloru załaduj ponownie 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

Na końcu tej sekcji aplikacja narysuje w obszarze roboczym prostą geometrię: kolorowy kwadrat. Pamiętaj, że w przypadku tak prostych danych wyjściowych może to wydawać się pracochłonne, ale wynika to z faktu, że procesor WebGPU został zaprojektowany tak, aby bardzo wydajnie renderować duże ilości geometrii. Skutkiem ubocznym tej wydajności jest to, że wykonywanie stosunkowo prostych czynności może wydawać się niezwykle trudne, ale tego właśnie oczekuje się przy korzystaniu z interfejsu API, takiego jak WebGPU – trzeba zrobić coś bardziej złożonego.

Sposób renderowania GPU

Zanim wprowadzisz kolejne zmiany w kodzie, warto szybko i ogólnie zapoznać się z informacjami o tym, jak GPU tworzą kształty, które widzisz 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, np. Canvas 2D, który ma mnóstwo kształtów i opcji do wykorzystania, GPU tak naprawdę obsługuje tylko kilka różnych typów kształtów (określanych przez WebGPU) elementów głównych: punktów, linii i trójkątów. W tym ćwiczeniu będziesz używać tylko trójkątów.

Procesory graficzne działają niemal wyłącznie z trójkątami, ponieważ mają one wiele właściwości matematycznych, dzięki którym można je łatwo przetwarzać w przewidywalny i wydajny sposób. Prawie wszystko, co rysujesz przy użyciu GPU, musi zostać podzielone na trójkąty, zanim układ GPU będzie mógł je rysować, a trójkąty muszą być wyznaczone przez punkty narożników.

Te punkty, lub wierzchołki, są podawane w postaci wartości X, Y i (w przypadku treści 3D) wartości Z, które definiują punkt w kartezjańskim układzie współrzędnych zdefiniowanym przez WebGPU lub podobne interfejsy API. Strukturę układu współrzędnych najłatwiej jest sobie wyobrazić, gdy pomyśli się o jego relacji do płótna na stronie. Niezależnie od tego, jak szerokie lub wysokie jest tworzywo, jego lewa krawędź zawsze znajduje się na osi X w punkcie –1, a prawa – w punkcie +1. I podobnie, dolna krawędź ma zawsze wartość -1 na osi Y, a górna – +1 na osi Y. Oznacza to, że (0, 0) to zawsze środek obszaru roboczego, (-1, -1) to zawsze lewy dolny róg, a (1, 1) to zawsze prawy górny róg. Jest to tzw. Clip Space.

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

Węzły rzadko są 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. Zostały one napisane przez Ciebie, czyli programistę WebGPU, i dają niesamowitą kontrolę nad sposobem działania 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 gry w życie jest przedstawiona jako siatka komórek. Aplikacja musi mieć sposób wizualizacji siatki, który pozwala odróżnić aktywne komórki od nieaktywnych. W tym ćwiczeniu rysujemy kolorowe kwadraty w aktywnych komórkach, a komórki nieaktywne pozostają puste.

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

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

Aby przesłać te współrzędne do procesora graficznego, musisz umieścić wartości w TypedArray. Obiekty TypedSlate to grupa obiektów JavaScript, które umożliwiają przydzielanie przylegających do siebie bloków pamięci i interpretowanie każdego elementu w serii jako określonego typu danych. Na przykład w elemencie Uint8Array każdy element w tej tablicy jest pojedynczym bajtem bez znaku. Tablice TypedTables świetnie nadają się do przesyłania danych w obie strony za pomocą interfejsów API wrażliwych na układ pamięci, takich jak WebAssembly, WebAudio i oczywiście WebGPU.

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

  1. Utwórz tablicę, która zawiera wszystkie pozycje wierzchołków na diagramie, umieszczając w kodzie podaną niżej deklarację tablicy. Najlepiej umieścić ją u góry, tuż pod wywołaniem context.configure().

index.html

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

Pamiętaj, że odstępy i komentarz nie mają wpływu na wartości. ale tylko dla Twojej wygody i czytelniejszej. Dzięki temu widać, że każda para wartości składa się na współrzędne X i Y jednego wierzchołka.

Ale jest problem! Procesory graficzne działają w formie 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.

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

Aby utworzyć kwadrat z diagramu, musisz podać wierzchołki (-0,8, -0,8) i (0,8, 0,8) 2 razy: 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. Zaktualizuj poprzednią tablicę vertices, aby wyglądała mniej więcej tak:

index.html

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

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

Chociaż dla przejrzystości diagramu pokazuje rozgraniczenie między dwoma trójkątami, pozycje wierzchołków są dokładnie takie same, a GPU renderuje je bez luk. 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. Urządzenia GPU często mają własną pamięć, która jest w wysokim stopniu zoptymalizowana pod kątem renderowania. Dlatego wszelkie dane, których GPU ma używać podczas rysowania, muszą być umieszczone w tej pamięci.

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

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

index.html

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

Na początku należy nadać buforowi etykietę. Każdy tworzony przez Ciebie obiekt WebGPU może mieć opcjonalną etykietę. Zdecydowanie warto z niej korzystać. Etykieta to dowolny wybrany przez Ciebie ciąg, o ile pomaga Ci ona zidentyfikować obiekt. Jeśli napotkasz jakieś problemy, etykiety te zostaną użyte 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 TypedArray 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 jedna z flag GPUBufferUsage. Wiele flag jest połączonych z operatorem | ( bitwise OR). W tym przypadku określasz, że bufor ma być używany do danych wierzchołka (GPUBufferUsage.VERTEX), a także że chcesz mieć możliwość kopiowania do niego danych (GPUBufferUsage.COPY_DST).

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

Gdy bufor zostanie utworzony, pamięć, którą zawiera, zostanie zainicjowana 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łków do pamięci bufora, dodaj ten kod:

index.html

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

Definiowanie układu wierzchołka

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

index.html

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

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

Pierwszą rzeczą, jaką podajesz, jest 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 wspomnieliśmy wcześniej, 32-bitowa liczba zmiennoprzecinkowa ma 4 bajty, więc dwie zmiennoprzecinkowe mają 8 bajtów.

Kolejna właściwość attributes, która jest tablicą, Atrybuty to poszczególne elementy informacji zakodowane w każdym wierzchołku. Twoje wierzchołki zawierają tylko 1 atrybut (położenie wierzchołka), ale w bardziej zaawansowanych przypadkach użycia często występują wierzchołki z wieloma atrybutami, takimi jak kolor wierzchołka lub kierunek, na który wskazuje powierzchnia geometrii. Nie jest to jednak możliwe w przypadku tego ćwiczenia z programowania.

W przypadku pojedynczego atrybutu 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. Każdy z wierzchołków składa się z dwóch 32-bitowych liczb zmiennoprzecinkowych, więc należy użyć formatu float32x2. Jeśli dane wierzchołków składają się na przykład z 4 16-bitowych liczb całkowitych bez znaku, użyj parametru uint16x4. Widzisz wzór?

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

Ostatnim elementem jest shaderLocation. Jest to dowolna liczba z zakresu od 0 do 15, która musi być unikalna dla każdego zdefiniowanego atrybutu. Łączy ten atrybut z konkretnym ustawieniem w trybie cieniowania wierzchołków, o czym dowiesz się w następnej sekcji.

Zwróć uwagę, że chociaż definiujesz te wartości, nie przekazujesz ich jeszcze do interfejsu WebGPU API. Teraz już łatwiej będzie Ci się było zastanowić, co to są wartości już na etapie definiowania wierzchołków, by potem skonfigurować je do użycia w późniejszym czasie.

Zacznij od cieniowania

Teraz masz dane, które chcesz renderować, ale musisz jeszcze powiedzieć procesorowi graficznemu, jak dokładnie je przetworzyć. W dużej mierze odbywa się to za pomocą shaderów.

shakery to małe programy, które tworzysz i uruchamiają się w GPU. Każdy rodzaj cieniowania działa na innym etapach przetwarzania danych: przetwarzania Vertex, przetwarzania fragmentów lub ogólnego przetwarzania Compute. Ponieważ są one wykonywane na procesorze graficznym, 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.

Technologia cieniowania w WebGPU jest napisana w języku cieniowania WGSL (WebGPU Shading Language). WGSL pod względem składni jest trochę podobny do Rust, a jego funkcje mają ułatwiać i przyspieszać wykonywanie typowych zadań na procesorze graficznym (np. obliczenia wektorów i macierzy). Nauczanie w całości za pomocą języka cieniowania wykracza poza zakres tego ćwiczenia z programowania, ale mam nadzieję, że dzięki prostym przykładom będziesz w stanie poznać podstawy.

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

  • Utwórz miejsce, aby wpisać kod cieniowania, skopiuj ten kod do kodu pod polem 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. (pamiętaj, że w przypadku ciągów wielowierszowych używasz grawisu). Po dodaniu prawidłowego kodu WGSL funkcja zwraca obiekt GPUShaderModule z skompilowanymi wynikami.

Zdefiniuj cieniowanie wierzchołka

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

Moduł cieniowania wierzchołków jest zdefiniowany jako funkcja, a GPU wywołuje, które działają raz na każdy wierzchołek w obiekcie vertexBuffer. Funkcja vertexBuffer ma 6 pozycji (wierzchołków), więc zdefiniowana funkcja zostanie wywołana 6 razy. Za każdym razem, gdy jest ono wywoływane, do funkcji jest przekazywana inna pozycja z pola vertexBuffer jako argument. Zadaniem funkcji cieniowania wierzchołków jest zwracanie odpowiedniej pozycji w miejscu na klips.

Pamiętaj, że nie zawsze będą one wywoływane w kolejności po kolei. 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 funkcja cieniowania wierzchołków może mieć dowolną nazwę, ale musi zawierać atrybut @vertex, aby wskazać etap cieniowania, który reprezentuje. WGSL oznacza funkcje za pomocą słowa kluczowego fn, używa nawiasów do deklarowania argumentów i nawiasów klamrowych do definiowania zakresu.

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

index.html (kod createShaderModule)

@vertex
fn vertexMain() {

}

To jednak nie jest prawidłowe, ponieważ cieniowanie wierzchołków musi zwracać co najmniej końcową pozycję przetwarzanych wierzchołków w obszarze klipsa. Ma ona zawsze postać wektora czterowymiarowego. Wektory są tak często używane w cieniowaniu, że w języku są traktowane jak podstawowe elementy podstawowe. Mają własne typy, np. vec4f, jeśli chodzi o 4-wymiarowy wektor. Istnieją również podobne typy wektorów 2D (vec2f) i 3D (vec3f).

  1. Aby wskazać, że zwracana wartość jest wymaganą pozycją, oznacz ją za pomocą atrybutu @builtin(position). Symbol -> oznacza, że właśnie to zwraca funkcja.

index.html (kod createShaderModule)

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

}

Oczywiście, jeśli funkcja zwraca typ zwracany, musisz zwrócić wartość w treści 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, y i z to liczby zmiennoprzecinkowe, które w zwracanej wartości wskazują, w którym miejscu w obrębie klipu znajduje się wierzchołek.

  1. Zwraca wartość statyczną (0, 0, 0, 1). Technicznie rzecz biorąc, masz prawidłowy program do cieniowania wierzchołków, jednak taki, który nigdy nie wyświetla żadnych elementów, ponieważ układ graficzny (GPU) rozpoznaje, że tworzone przez niego trójkąty stanowią tylko jeden 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. W tym celu zadeklaruj argument funkcji z atrybutem @location() i wpisz wartość odpowiadającą temu, co opisano w dokumencie vertexBufferLayout. Podano wartość shaderLocation o wartości 0, więc w kodzie WGSL oznacz ten argument za pomocą atrybutu @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 przywrócić tę pozycję. Ponieważ pozycja jest wektorem 2D, a typ zwracany to wektor 4D, musisz go trochę zmodyfikować. Chcesz użyć 2 komponentów z argumentu pozycji i umieścić je w pierwszych 2 komponentach wektora zwrotnego, pozostawiając ostatnie 2 komponenty jako 0 i 1.

  1. Zwracaj prawidłowe położenie, określając wprost, których komponentów pozycjonować chcesz 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 tego rodzaju mapowania są bardzo powszechne w cieniorach, więc możesz w prosty i wygodny sposób przekazać wektor pozycji jako pierwszy argument. Znaczenie będzie to samo.

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

index.html (kod createShaderModule)

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

To Twój początkowy cieniowanie wierzchołków. To bardzo proste, samo przekazanie pozycji bez zmian, ale to wystarczy, aby rozpocząć.

Definiowanie modułu cieniowania fragmentów

Kolejnym krokiem jest fragment shadera. Shadery fragmentów działają bardzo podobnie do shaderów wierzchołkowych, ale zamiast wywoływania dla każdego wierzchołka są wywoływane dla każdego rysowanego piksela.

Mechanizmy cieniowania fragmentów są zawsze wywoływane po cieniowaniu wierzchołków. 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, ustalając, które piksele z dołączonymi kolorami wyjściowymi znajdują się w tym trójkącie, a następnie wywołuje cieniowanie fragmentów raz dla każdego z nich. Moduł cieniowania fragmentów zwraca kolor, zwykle obliczany na podstawie wartości wysłanych do niego z modułu cieniowania wierzchołków i zasobów takich jak tekstury, które GPU zapisuje w przyłączu koloru.

Mechanizmy cieniowania fragmentów działają równolegle, podobnie jak cieniowanie wierzchołków. Są one nieco bardziej elastyczne niż cieniowanie wierzchołkowe pod względem danych wejściowych i wyjściowych, ale można wziąć pod uwagę, że zwracają po prostu jeden kolor na każdy piksel każdego trójkąta.

Funkcja cieniowania fragmentów WGSL jest oznaczona atrybutem @fragment oraz zwraca wartość vec4f. W tym przypadku wektor reprezentuje jednak kolor, a nie pozycję. Zwracana wartość musi mieć atrybut @location, aby wskazać, do którego elementu colorAttachment z wywołania beginRenderPass jest zapisany zwrócony kolor. Masz tylko 1 załącznik, więc lokalizacja to 0.

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

index.html (kod createShaderModule)

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

}

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

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

index.html (kod createShaderModule)

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

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

Podsumowując: po dodaniu opisanego powyżej kodu cieniowania wywołanie createShaderModule wygląda tak:

index.html

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

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

Tworzenie potoku renderowania

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

Potok renderowania jest najbardziej złożonym obiektem w całym interfejsie API, ale bez obaw. Większość wartości, które możesz do niego przekazać, jest opcjonalna, a na początek wystarczy podać kilka.

  • Utwórz potok renderowania w ten sposób:

index.html

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

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

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

Na koniec zapoznaj się z informacjami o etapie fragment. Obejmuje to też moduł shadera i punkt wejścia, np. etap wierzchołków. Ostatnim krokiem jest zdefiniowanie targets, z którym jest używany ten potok. Jest to tablica słowników ze szczegółowymi informacjami (takimi jak tekstura format) dołączanych kolorów, do których potok wysyła dane. Te szczegóły muszą być zgodne z teksturami w colorAttachments wszystkich przejść renderowania, z którymi jest używany ten potok. Karta renderowania wykorzystuje tekstury z kontekstu obszaru roboczego oraz wartość zapisaną w polu canvasFormat, dzięki czemu przekazujesz w tym miejscu ten sam format.

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

Rysowanie kwadratu

Masz teraz wszystko, czego potrzebujesz do narysowania swojego kwadratu.

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

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

W ten sposób procesor WebGPU otrzyma wszystkie informacje potrzebne do narysowania kwadratu. Najpierw użyj setPipeline(), aby wskazać, którego potoku używać 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 je za pomocą 0, ponieważ ten bufor odpowiada 0 elementowi w definicji vertex.buffers bieżącego potoku.

Na koniec wykonaj połączenie draw(), które wydaje się dziwnie proste po całej konfiguracji, która była wymagana wcześniej. 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żna go zakodować na stałe jako 6, ale obliczenie go na podstawie tablicy wierzchołkowej (12 punktów zmiennoprzecinkowych / 2 współrzędne na wierzchołek == 6 wierzchołków) oznacza, że jeśli zdecydujesz się zastąpić kwadrat na przykład okręgiem, ręczne aktualizowanie będzie mniejsze.

  1. Odśwież ekran i zobacz efekty swojej ciężkiej pracy: jeden duży, kolorowy kwadrat.

Pojedynczy czerwony kwadrat renderowany za pomocą WebGPU

5. Rysuj siatkę

Najpierw pochwal się! Wyświetlanie pierwszych elementów geometrii na ekranie jest często jednym z najtrudniejszych etapów w przypadku większości interfejsów API GPU. Wszystkie czynności, które wykonujesz w tym miejscu, możesz wykonać w mniejszych krokach, co ułatwi Ci sprawdzanie postępów.

Z tej sekcji dowiesz się:

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

Definiowanie siatki

Aby renderować siatkę, musisz znać bardzo podstawową informację na jej temat. Ile komórek zawiera (szerokość i wysokość)? To Ty jako deweloper, ale aby ułatwić sobie zadanie, potraktuj siatkę jak kwadrat (o tej samej szerokości i wysokości) i użyj rozmiaru, który daje potęgę dwóch. Dzięki temu łatwiej będzie wykonać obliczenia. Ostatecznie chcesz ją powiększyć, ale na potrzeby reszty tej sekcji ustaw rozmiar siatki na 4 x 4, ponieważ ułatwia to przedstawienie niektórych obliczeń użytych w tej sekcji. Potem zwiększaj skalę kampanii.

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

index.html

const GRID_SIZE = 4;

Następnie musisz zaktualizować sposób renderowania kwadratu, tak aby dopasować go do obszaru roboczego GRID_SIZE razy GRID_SIZE. Oznacza to, że kwadrat musi być znacznie mniejszy i musi być ich dużo.

Jednym ze sposobów możliwości jest znaczne zwiększenie bufora wierzchołków i zdefiniowanie GRID_SIZE-krotności kwadratów o wartości GRID_SIZE, które będą w nim znajdować się we właściwym rozmiarze i położeniu. W rzeczywistości kod nie byłby taki zły! Jeszcze tylko kilka powtórzeń i odrobina matematyki. Nie wykorzystuje to jednak w pełni możliwości GPU i zużywa więcej pamięci niż jest to konieczne do uzyskania danego efektu. W tej sekcji omawiamy podejście bardziej przyjazne procesorowi graficznemu.

Utwórz jednolity bufor

Najpierw musisz przekazać wybrany rozmiar siatki do cieniowania, bo na jego podstawie zmienia się sposób wyświetlania elementów. Możesz po prostu zakodować rozmiar w shaderze, ale oznacza to, że za każdym razem, gdy chcesz zmienić rozmiar siatki, musisz ponownie utworzyć shader i pipeline renderowania, co jest kosztowne. Lepszym sposobem jest podanie rozmiaru siatki do cieniowania w postaci uniformów.

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

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

index.html

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

Powinno to wyglądać znajomo, ponieważ to prawie dokładnie ten sam kod, który został użyty wcześniej do utworzenia bufora wierzchołków. Wynika to z faktu, że uniformy są przekazywane do interfejsu WebGPU API przez te same obiekty GPUBuffer, które są wierzchołkami. Główna różnica polega na tym, że usage zawiera tym razem element GPUBufferUsage.UNIFORM zamiast GPUBufferUsage.VERTEX.

Dostęp do uniformów w programie cieniowania

  • Zdefiniuj jednolity charakter, dodając ten kod:

index.html (wywołanie createShaderModule)

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

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

// ...fragmentMain is unchanged 

Określa on w shaderze zmienną grid, która jest wektorem 2D typu float odpowiadającym tablicy, którą właśnie skopiowano do bufora jednolitego. Określa 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 innym miejscu w kodzie cieniowania możesz używać wektora siatki w dowolny sposób. W tym kodzie dzielimy położenie wierzchołka przez wektor siatki. Ponieważ pos jest wektorem 2D, a grid to wektor 2D, WGSL przeprowadza dzielenie na podstawie komponentów. Inaczej mówiąc, wynik będzie taki sam jak wtedy, gdy powiedz: 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.

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

Tworzenie grupy wiązania

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

Grupa wiązania to zbiór zasobów, które chcesz udostępnić shaderowi w tym samym czasie. Może on zawierać kilka typów buforów (np. jednolity bufor) oraz inne zasoby, takie jak tekstury i próbki, których nie omówiliśmy, ale które są typowymi elementami technik renderowania WebGPU.

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

index.html

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

Oprócz obecnie standardowej label potrzebujesz też elementu layout, który opisuje, jakie typy zasobów zawiera ta grupa powiązań. Zajmiemy się tym dokładniej w przyszłym kroku, ale na razie możesz zapytać swój potok o układ grupy powiązań, ponieważ potok został utworzony przy użyciu layout: "auto". Sprawi to, że potok automatycznie tworzy układy grup powiązań na podstawie powiązań zadeklarowanych w kodzie cieniowania. W tym przypadku poproś go o polecenie getBindGroupLayout(0), gdzie 0 odpowiada @group(0) wpisanemu w cieniowaniu.

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

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

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

Tworzenie powiązania grupy powiązań

Po utworzeniu grupy powiązań nadal musisz wskazać WebGPU, aby używał 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. Opowiadasz, że każdy zasób @binding należący do obszaru @group(0) korzysta z zasobów w tej grupie powiązań.

Teraz jednolity bufor jest narażony na działanie cieniowania.

  1. Odśwież stronę. Wyświetli się wtedy coś takiego:

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

Hurra! Kwadrat jest teraz 4 razy mniejszy niż wcześniej. To niewiele, ale pokazuje, że mundur jest rzeczywiście stosowany i cieniowanie ma teraz 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ę, zgodnie z którą oś X rośnie przy przesuwaniu się w prawo, a oś Y rośnie przy przechodzeniu do góry, załóżmy, że pierwsza komórka znajduje się w lewym dolnym rogu obszaru roboczego. Uzyskasz układ podobny do tego z bieżącą kwadratową geometrią poś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 cieniowaniu metody, która pozwala umieścić geometrię kwadratową w dowolnej z komórek na podstawie współrzędnych komórki.

Po pierwsze, widać, że kwadrat nie jest dobrze wyrównany z żadną z komórek, ponieważ został zdefiniowany tak, aby otaczał środek osi. Musisz przesunąć kwadrat o połowę, aby kwadrat się zmieścił.

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 przesunięcie kwadratu, aby lepiej dopasować go 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 właściwe miejsce za pomocą kodu shadera.

  1. Zmień moduł vertex cieniowania, wpisując ten kod:

index.html (wywołanie createShaderModule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

Spowoduje to przesunięcie wszystkich wierzchołków w górę i w prawo o jeden (pamiętaj, że jest to połowa miejsca klipu) przed podzieleniem go przez rozmiar siatki. Efektem jest kwadrat wyrównany do siatki tuż przy punkcie początkowym.

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

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

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

index.html (wywołanie createShaderModule)

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

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

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

  return vec4f(gridPos, 0, 1); 
}

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

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

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

Jeśli dodasz tę wartość do funkcji gridPos, cofnie ona działanie - 1 w algorytmie, więc nie jest to oczekiwany sposób. Zamiast tego chcesz przesunąć kwadrat tylko o 1 jednostkę siatki (1/4 płótna) w przypadku każdej komórki. Wygląda na to, że musisz ponownie podzielić przez grid.

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

index.html (wywołanie createShaderModule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

Jeśli je teraz odświeżysz, zobaczysz te informacje:

Wizualizacja obszaru roboczego podzielonego na siatkę o wymiarach 4 x 4 z czerwonym kwadratem wyśrodkowanym między komórką (0, 0), komórką (0, 1), komórką (1, 0) i komórką (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 zajmuje 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, który można popełnić podczas rozumowania za pomocą współrzędnych GPU. Na szczęście rozwiązanie tego problemu jest równie proste.

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

index.html (wywołanie createShaderModule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

Zrzut ekranu wygląda tak:

Zrzut ekranu z czerwonym kwadratem na ciemnoniebieskim tle. Czerwony kwadrat narysowany w tym samym położeniu jak na poprzednim diagramie, ale bez nakładki z siatką.

Możesz też ustawić cell na dowolną wartość w granicach siatki, a następnie odświeżyć widok, aby zobaczyć, jak kwadrat wyrenderuje się w wybranym miejscu.

Rysowanie instancji

Teraz, korzystając z funkcji matematycznych, możesz umieścić kwadrat w wybranym miejscu, a następnym krokiem jest wyrenderowanie jednego kwadratu w każdej komórce siatki.

Jednym ze sposobów jest zapisanie współrzędnych komórek w buforze jednolitym, a potem wywołanie funkcji draw raz dla każdego kwadratu w siatce, aktualizując za każdym razem uniform. Byłoby to jednak bardzo wolne, ponieważ GPU za każdym razem musi czekać na zapisanie nowych współrzędnych przez JavaScript. 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 renderowania wielu kopii tej samej geometrii za pomocą pojedynczego wywołania funkcji draw, co jest znacznie szybsze niż wywoływanie funkcji draw po jednej kopii. Każda kopia geometrii nazywana jest instancją.

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

index.html

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

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

Identyczny obraz z poprzednim diagramem, by pokazać, że nic się nie zmieniło.

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

W shaderze oprócz atrybutów wierzchołka, takich jak pos pochodzące z bufora wierzchołka, możesz też uzyskać dostęp do wbudowanych wartości w WGSL. Są to wartości obliczane przez WebGPU, a jedną z nich jest instance_index. instance_index to niepodpisany 32-bitowy numer z zakresu od 0 do number of instances - 1, którego możesz używać w ramach funkcji cieniowania. Jego wartość jest taka sama dla każdego przetworzonego wierzchołka, który jest częścią tej samej instancji. Oznacza to, że cieniowanie wierzchołków jest wywoływane 6 razy z parametrem instance_index o wartości 0 – po jednym razie na każdą pozycję w buforze wierzchołków. 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ć do wejść shadera wbudowany shader instance_index. 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. (Aby to zrobić, użyj nazwy instance, aby dopasować ją do przykładowego kodu). Następnie użyj go w ramach logiki shadera.

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

index.html

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

Dzieje się tak, ponieważ generowane współrzędne komórki to (0, 0), (1, 1), (2, 2)... aż do (15, 15), ale tylko pierwsze cztery z nich mieszczą się w obszarze roboczym. Aby utworzyć siatkę, musisz przekształcić obiekt instance_index w taki sposób, aby każdy indeks był mapowany na unikalną komórkę w siatce:

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

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

  1. Zmień obliczenia w ten sposób:

index.html

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

Po zaktualizowaniu kodu masz w końcu wyczekiwaną 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 z 32 kolumnami czerwonych kwadratów na ciemnoniebieskim tle.

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

6. 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 elementów struct w cieniowaniu

Do tej pory z funkcji cieniowania wierzchołków przekazywano jeden element danych – przekształconą pozycję. Możesz jednak zwrócić znacznie więcej danych z shadera wierzchołkowego, a potem użyć ich w shaderze fragmentów.

Jedynym sposobem na przekazanie danych z modułu cieniowania wierzchołków jest jego zwrócenie. Shader wierzchołka jest zawsze wymagany do zwrócenia pozycji, więc jeśli chcesz zwrócić inne dane, musisz je umieścić w strukturze. Struktury w WGSL to nazwane typy obiektów, które zawierają co najmniej jedną nazwaną właściwość. Właściwości mogą być oznaczone atrybutami, takimi jak @builtin i @location. Deklarujesz je poza funkcjami, a potem możesz w razie potrzeby przekazywać ich wystąpienia do funkcji i z nich. 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 najprostsza:

index.html (wywołanie createShaderModule)

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

Nie przyjmujesz żadnych danych wejściowych, a jako dane wyjściowe przekazujesz jednolity kolor (czerwony). Jeśli jednak shader wiedziałby więcej o geometrii, 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; wystarczy przekazać ją do etapu @fragment.

Aby przekazywać dane między wierzchołkiem a etapami, musisz uwzględnić je w strukturze wyjściowej z wybranym przez nas elementem @location. Ponieważ chcesz przekazać współrzędną komórki, dodaj ją do struktury VertexOutput z wcześniejszego etapu, a potem ustaw ją w funkcji @vertex, zanim wrócisz.

  1. Zmień wartość zwrotną cieniowania wierzchołków 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 otrzymaj wartość, dodając argument z tym samym argumentem @location. (nazwy nie muszą być identyczne, ale jeśli tak jest, łatwiej jest śledzić zmiany).

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ą ze sobą 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 dobrze. Możesz się zastanawiać, dlaczego tylko lewy i dolny wiersz są inne. Dzieje się tak, ponieważ wartości kolorów zwracane przez funkcję @fragment wymagają, aby każdy kanał mieścił się w zakresie od 0 do 1, a wartości spoza tego zakresu są do niego dostosowane. Wartości komórek na każdej osi mieszczą się w zakresie od 0 do 32. Jak widać, pierwszy wiersz i pierwsza kolumna od razu osiąga tę samą wartość (1) w kanale koloru czerwonego lub zielonego, a każda kolejna komórka zmniejsza się do tej samej wartości.

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ą się od zera i kończącą się na 1 wzdłuż każdej osi, co oznacza jeszcze jedno dzielenie przez grid.

  1. Zmień shader fragmentu w ten sposób:

index.html (wywołanie createShaderModule)

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

Po odświeżeniu strony zobaczysz, że nowy kod wydaje dużo ładniejszy gradient kolorów w całej siatce.

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

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

Na szczęście masz do wyboru cały nieużywany kanał kolorów – niebieski. Najlepiej jest, gdy niebieski jest najjaśniejszy tam, gdzie inne kolory są najciemniejsze, a potem blaknie, gdy inne kolory stają się intensywniejsze. Najłatwiej to zrobić, ustawiając kanał zaczynając od punktu 1 i odejmując jedną z wartości w komórce. Może to być c.x lub c.y. Wypróbuj oba i wybierz ten, który Ci odpowiada.

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

Wywołanie createShaderModule

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

Wynik wygląda całkiem nieźle.

Siatka kwadratów w różnych rogach przechodzących z czerwonego przez zielony przez niebieski lub żółty.

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

7. Zarządzanie stanem komórki

Następnie musisz określić, które komórki siatki mają się renderować na podstawie stanu zapisanego na GPU. To ważna kwestia, gdy chcemy przeprowadzić ostateczną symulację!

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ż rozmiar buforów jednolitych jest ograniczony, nie obsługują one 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ń.

Utwórz bufor pamięci

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

  1. Aby utworzyć bufor pamięci dla stanu komórki, użyj 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ć efekt działania bufora na siatce, zacznij od wypełnienia go czymś przewidywalnym.

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

index.html

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

Odczytywanie bufora pamięci w cieniowaniu

Następnie zaktualizuj program do cieniowania, aby sprawdzić zawartość bufora pamięci masowej, zanim wyrenderujesz siatkę. Wygląda to bardzo podobnie jak w przypadku dodania mundurów.

  1. Dodaj do cieniowania ten kod:

index.html

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

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

Następnie w treści funkcji @vertex przeprowadź zapytanie o stan komórki. Stan jest przechowywany w buforze w postaci tablicy płaskiej, więc możesz użyć funkcji instance_index, aby odszukać 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 do wartości 1 pozostawia geometrię bez zmian, a skalowanie do wartości 0 powoduje jej złożenie w pojedynczy punkt, który jest następnie odrzucany przez procesor graficzny.

  1. Zaktualizuj kod cieniowania, aby przeskalować pozycję według stanu aktywności komórki. Aby spełnić wymagania bezpieczeństwa typu WGSL, wartość stanu musi być 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

Aby zobaczyć, jak zmienia się stan komórki, dodaj bufor pamięci masowej do grupy powiązań. Ponieważ jest to część tego samego @group co jednolity bufor, dodaj go do tej samej grupy wiązania w kodzie JavaScript.

  • Dodaj bufor pamięci w ten sposób:

index.html

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

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

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

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

Użyj wzorca bufora ping-ponga

Większość symulacji, takich jak ta, którą tworzysz, zwykle używa co najmniej 2 kopii stanu. Na każdym etapie symulacji odczytują dane z jednej kopii stanu i zapisują na drugiej. Następnie odwróć kartkę 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? Spójrzmy na uproszczony przykład: wyobraź sobie, że piszesz bardzo prostą symulację, w której w każdym kroku przesuwasz wszystkie aktywne bloki o jedną komórkę. 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ż ciągle aktualizujesz stan na miejscu, przesuwasz aktywną komórkę w prawo, a potem spoglądasz na następną komórkę i... cześć! Jest aktywne. Lepiej przesuń go znowu w prawo. Zmiana danych w tym samym czasie, w którym je obserwujesz, powoduje ich uszkodzenie.

Użycie wzorca ping-ponga gwarantuje, że następny krok symulacji zostanie wykonany 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 przydział bufora pamięci masowej w celu utworzenia 2 identycznych buforów:

index.html

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

index.html

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

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

index.html

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

Konfigurowanie pętli renderowania

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

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

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

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

index.html

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

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

To już prawie wszystko, jeśli chodzi o renderowanie. Wszystko gotowe do wyświetlania wyników Twojej symulacji Game of Life w następnym kroku, w którym w końcu zaczniesz korzystać z cieniowania obliczeniowego.

Oczywiście możliwości renderowania WebGPU są znacznie większe niż w przypadku opisanego tutaj małego wycinka kodu, ale reszta wykracza poza zakres tego ćwiczenia z programowania. Mamy nadzieję, że pomoże Ci to zrozumieć, jak działa renderowanie WebGPU, i ułatwia zapoznawanie się z bardziej zaawansowanymi technikami, takimi jak renderowanie 3D.

8. Uruchamianie symulacji

A teraz czas na ostatni ważny element łamigłówki – symulację gry w życie w edytorze obliczeniowym.

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

Wiedzieliśmy już, czym są cieniowanie obliczeniowe, ale co tak naprawdę to jest?

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. Odczytujesz i zapisujesz dane wyłącznie z wybranych przez siebie źródeł, takich jak bufory pamięci masowej. Oznacza to, że zamiast wykonywać ją raz dla każdego wierzchołka, instancji czy piksela, trzeba podać liczbę wywołań funkcji cieniowania. Następnie, 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ć.

Moduły cieniowania Compute trzeba tworzyć w module cieniowania, tak jak wierzchołki wierzchołkowe i fragmentatory, więc musisz je dodać do swojego kodu, aby zacząć z niego korzystać. 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 program do cieniowania obliczeń przy użyciu 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 architekturę sprzętu GPU siatka jest podzielona na grupy robocze. Grupa robocza ma rozmiar X, Y i Z i chociaż może wynosić 1 rozmiar każdego z nich, zwykle zwiększenie wydajności grupy jest korzystne. Do cieniowania wybierz dowolny rozmiar grupy roboczej 8 razy 8. Jest to przydatne, by monitorować je w kodzie JavaScript.

  1. Zdefiniuj stałą wartość rozmiaru grupy roboczej, na przykład:

index.html

const WORKGROUP_SIZE = 8;

Rozmiar grupy roboczej musisz też dodać do funkcji cieniowania, której używasz literałów szablonów JavaScript, aby móc łatwo użyć zdefiniowanej właśnie stałej.

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

index.html (wywołanie Compute 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 cieniowania, dostępne są różne wartości @builtin, które możesz zaakceptować jako dane wejściowe w funkcji cieniowania Compute, by móc określić, którego wywołania używasz, i zdecydować, co musisz zrobić.

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

index.html (wywołanie Compute createShaderModule)

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

}

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

cieniowanie w Compute może też stosować uniformy, których używa się tak samo jak w cieniowanie wierzchołków i fragmentów.

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

index.html (wywołanie Compute createShaderModule)

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

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

}

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

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

index.html (wywołanie funkcji createShaderModule)

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

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

}

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

Następnie musisz mieć sposób mapowania indeksu komórki na tablicę pamięci liniowej. Jest to w podstawie przeciwieństwo tego, co zrobiono w shaderze wierzchołka, gdzie przyjęto liniową instance_index i zmapowano ją na komórkę siatki 2D. (przypominamy, że Twoim algorytmem był vec2f(i % grid.x, floor(i / grid.x))).

  1. Napisz funkcję, która pójdzie w przeciwnym kierunku. Pobiera wartość Y komórki, mnoży ją przez szerokość siatki, a następnie dodaje wartość X komórki.

index.html (wywołanie funkcji createShaderModule)

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

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

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

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

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 wystarczy, aby pokazać, że shader obliczeniowy działa.

  1. Dodaj ten prosty algorytm:

index.html (wywołanie Compute createShaderModule)

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

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

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

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

Używanie układów grup powiązań i potoków

Jedną z rzeczy, które zauważysz po zastosowaniu cieniowania, jest fakt, że wykorzystuje on w dużej mierze te same dane wejściowe (uniformy i bufory pamięci) co potok renderowania. Możesz więc pomyśleć, że wystarczy użyć tych samych grup wiązania. Dobra wiadomość jest taka, że możesz. 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 automatycznie je tworzył, ponieważ podczas jego tworzenia podałeś parametr layout: "auto". To podejście sprawdza się, gdy korzystasz tylko z jednego potoku, ale jeśli masz wiele potoków, które chcesz współdzielić zasoby, musisz utworzyć układ bezpośrednio, a następnie podać go zarówno dla grupy powiązań, jak i potoków.

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 siebie shaderze obliczeniowym potrzebujesz drugiego bufora pamięci. Ponieważ dwa cieniowanie używają tych samych wartości @binding dla jednolitego i pierwszego bufora pamięci masowej, możesz je udostępniać między potokami, a potok renderowania ignoruje drugi bufor, którego nie używa. Chcesz utworzyć układ, który opisuje wszystkie zasoby znajdujące się w grupie powiązań, a nie tylko te używane przez określony potok.

  1. Aby utworzyć ten układ, wywołaj 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żdej pozycji podajesz numer binding dla zasobu, który (jak został nauczony podczas tworzenia grupy powiązań) odpowiada wartości @binding w cieniowaniu. Musisz też podać visibility, czyli flagi GPUShaderStage, które wskazują, które etapy shadera mogą używać zasobu. Chcesz, aby zarówno jednolity, jak i pierwszy bufor pamięci masowej były dostępne w wierzchołku oraz w cieniowaniu obliczeniowym, ale drugi bufor musi być dostępny tylko w tych systemach.

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 w tym celu należy użyć klucza buffer, aby określić opcje dla każdego z nich. Inne opcje to między innymi texture czy sampler, ale nie są one tu potrzebne.

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

Po utworzeniu obiektu bindGroupLayout możesz go przekazać podczas tworzenia grup powiązań, zamiast wysyłać zapytania do tej grupy z poziomu potoku. Oznacza to, że do każdej grupy powiązań musisz dodać nowy wpis w buforze pamięci, aby dopasować się do zdefiniowanego właśnie 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 ],
});

Układ potoku to lista układów grup powiązań (w tym przypadku masz taki układ), których używa co najmniej 1 potok. Kolejność układów grup 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, a jedynie cieniowanie i układ.

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

index.html

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

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

To prowadzi do punktu, w którym trzeba wykorzystać potok obliczeniowy. Biorąc pod uwagę, że renderujesz je w ramach przebiegu renderowania, możesz zgadywać, że musisz wykonać obliczenia w ramach tej funkcji. 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 przejść jest dużo łatwiejsze do uruchomienia niż w przypadku renderowania, ponieważ nie musisz się martwić o dołączone pliki.

Musisz go wykonać przed zakończeniem renderowania, ponieważ umożliwia on przebiegowi renderowania od razu korzystać z najnowszych wyników tego procesu. Właśnie dlatego zwiększasz liczbę step między kartami, aby bufor wyjściowy potoku obliczeniowego stał się buforem wejściowym potoku renderowania.

  1. Następnie w przesłaniu obliczeniowym ustaw potok i grupę wiązania, używając tego samego wzorca przełączania 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ń. Jest to liczba grup roboczych do wykonania, zgodnie z definicją w programie @workgroup_size do cieniowania.

Jeśli chcesz, aby shader był wykonywany 32 razy, 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.

Ukośne paski kolorowych kwadratów od lewego dolnego do prawego górnego rogu na ciemnoniebieskim tle. Ukośne paski składające się z 2 szerokich kwadratów o szerokości od lewego dolnego do prawego górnego rogu na ciemnoniebieskim tle. Odwrócenie poprzedniego obrazu.

Wdróż algorytm Gry w życie

Zanim zaktualizujesz shader obliczeniowy, aby zaimplementować końcowy algorytm, wróć do kodu inicjującego zawartość bufora pamięci i zaktualizuj go, aby generował losowy bufor przy każdym wczytaniu strony. (Regularne wzory nie sprawiają, że na początku gry w życie są ciekawsze). 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 znajdowała się w losowym stanie, zaktualizuj inicjalizację cellStateArray do tego kodu:

index.html

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

Możesz w końcu wdrożyć logikę symulacji „Gra w życie”. Gdy wszystko się zajęło, kod cieniowania może być rozczarowująco prosty.

Przede wszystkim musisz sprawdzić, ile jest aktywnych sąsiadów danej komórki. Nieważne, które z nich są aktywne, liczy się tylko ich liczba.

  1. Aby ułatwić sobie uzyskiwanie danych sąsiedniej komórki, 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 Compute createShaderModule)

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

Powoduje to jednak drobny problem: co się 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 zawijania siatki z niewielką zmianą w funkcji cellIndex().

index.html (wywołanie Compute createShaderModule)

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ł:

  • Każda komórka, która ma mniej niż 2 sąsiadów, staje się nieaktywna.
  • Każda aktywna komórka z 2 lub 3 sąsiadami pozostanie aktywna.
  • Każda nieaktywna komórka z dokładnie 3 sąsiadami staje się aktywna.
  • Każda komórka z więcej niż 3 sąsiadami staje się nieaktywna.

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

  1. Wdróż logikę „Gra w życie” w ten sposób:

index.html (wywołanie Compute createShaderModule)

let i = cellIndex(cell.xy);

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

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

index.html

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

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

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

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

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

      let i = cellIndex(cell.xy);

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

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

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

9. Gratulacje!

Udało Ci się stworzyć wersję klasycznej symulacji „Gra w życie” Conwaya, która działa w całości na Twoim GPU za pomocą interfejsu WebGPU API.

Co dalej?

Więcej informacji

Dokumentacja