Twoja pierwsza aplikacja WebGPU

1. Wprowadzenie

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

Ostatnia aktualizacja: 28.08.2023 r.

Co to jest WebGPU?

WebGPU to nowy, nowoczesny interfejs API umożliwiający dostęp do możliwości GPU w aplikacjach internetowych.

Nowoczesny interfejs API

Przed WebGPU istniał WebGL, który oferował podzbiór funkcji WebGPU. Dzięki niej powstała nowa klasa bogatych treści internetowych, a deweloperzy potrafią z nich skonstruować niesamowite treści. 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 przenosi udoskonalenia tych nowoczesnych interfejsów API do platformy internetowej. 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 powiązane z szybkim renderowaniem, szczegółowej grafiki, a WebGPU nie jest wyjątkiem. Zawiera funkcje wymagane do obsługi wielu najpopularniejszych obecnie technik renderowania zarówno w przypadku GPU na komputerach, jak i urządzeniach mobilnych. Zapewnia też możliwość dodawania nowych funkcji w przyszłości w miarę ewoluowania możliwości sprzętowych.

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ń. Tych cieniowań obliczeniowych można używać samodzielnie, bez żadnego komponentu renderowania, lub jako ściśle zintegrowanej części potoku renderowania.

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

Co utworzysz

W tym ćwiczeniu w Codelabs dowiesz się, jak stworzyć Grę życia Conwaya przy użyciu WebGPU. Twoja aplikacja będzie:

  • Wykorzystanie funkcji renderowania WebGPU do rysowania prostej grafiki 2D.
  • Przeprowadź symulację, korzystając z możliwości obliczeniowych WebGPU.

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

„Gra w życie” to automat komórkowy, w którym siatka komórek zmienia stan w czasie w zależności od zestawu reguł. W grze Game of Life komórki stają się aktywne lub nieaktywne w zależności od tego, ile sąsiednich komórek jest aktywnych. W efekcie pojawiają się interesujące wzorce, które zmieniają się w miarę oglądania.

Czego się nauczysz

  • 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ąca recenzja interfejsu API ani nie obejmuje (ani nie wymaga) często powiązanych tematów, takich jak matematyka 3D.

Czego potrzebujesz

  • najnowszą wersję Chrome (113 lub nowszą) w systemie ChromeOS, macOS lub Windows; WebGPU to interfejs API działający na wielu platformach i w różnych przeglądarkach, ale nie został jeszcze wprowadzony we wszystkich miejscach.
  • Znajomość języka HTML, JavaScript i Narzędzi deweloperskich w Chrome.

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

2. Konfiguracja

Pobierz kod

Ć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 kilka praktycznych przykładów, które mogą posłużyć jako punkty kontrolne, znajdziesz na stronie https://glitch.com/edit/#!/your-first-webgpu-app. Jeśli napotkasz problemy, możesz je przejrzeć i odwoływać się do nich, gdy napotkasz problemy.

Używanie 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 zgłaszać typowych wyjątków JavaScript dla wielu błędów, co utrudnia dokładne wskazanie źródła problemu.

Podczas programowania z użyciem WebGPU napotkasz problemy – zwłaszcza dla początkujących – to nie problem. Deweloperzy, którzy tworzą 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 spowoduje błąd, w konsoli programisty otrzymasz bardzo szczegółowe i pomocne komunikaty, które pomogą Ci zidentyfikować i rozwiązać problem.

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

3. Inicjowanie WebGPU

Zacznij od: <canvas>

WebGPU moż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 początek.

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. Przede wszystkim weź pod uwagę to, że rozpowszechnienie interfejsów API takich jak WebGPU w całym ekosystemie internetowym może trochę potrwać. W związku z tym na początek warto sprawdzić, czy przeglądarka użytkownika obsługuje 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 o niedostępności interfejsu WebGPU, ustawiając stronę z powrotem w trybie, który nie korzysta z WebGPU. (Może zamiast tego można użyć WebGL?) Jednak na potrzeby tego ćwiczenia w programie po prostu wyświetli się błąd, który uniemożliwi dalsze wykonywanie kodu.

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

  1. Aby uzyskać przejściówkę, użyj metody navigator.gpu.requestAdapter(). Zwraca obietnicę, 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 mieć wartość null i należy uwzględnić tę możliwość. Może się tak zdarzyć, jeśli przeglądarka użytkownika obsługuje WebGPU, ale jego sprzęt GPU nie zapewnia wszystkich funkcji wymaganych do korzystania z WebGPU.

W większości przypadków możesz pozwolić przeglądarce na wybór domyślnego adaptera, tak jak w tym przypadku, ale w przypadku bardziej zaawansowanych potrzeb można przekazać argumenty do requestAdapter(), które określają, czy urządzenie ma korzystać z urządzenia z wieloma procesorami graficznymi (np. z wieloma GPU).

Gdy masz już adapter, ostatnim krokiem przed rozpoczęciem pracy z GPU jest wysłanie prośby o GPUDevice. Urządzenie to główny interfejs, przez który najczęściej wchodzi w interakcję z GPU.

  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 Canvas

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 obszaru roboczego, dzwoniąc pod numer canvas.getContext("webgpu"). Jest to to samo wywołanie, którego należy użyć do zainicjowania kontekstów Canvas 2D lub WebGL za pomocą typów kontekstów 2d i webgl. 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,
});

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

Tekstury to obiekty, których WebGPU używa do przechowywania danych obrazów. Każda tekstura ma format, który informuje GPU, jak dane są rozmieszczone w pamięci. Szczegóły działania pamięci tekstur wykraczają poza zakres tego ćwiczenia z programowania. Pamiętaj, że kontekst kanwy zawiera tekstury, na które można rysować kod, a format może mieć wpływ na efektywność wyświetlania tych obrazów w obszarze roboczym. Różne typy urządzeń działają najlepiej z różnymi formatami tekstur. Jeśli nie użyjesz preferowanego formatu, w tle mogą zostać utworzone dodatkowe kopie w pamięci, zanim zdjęcie zostanie wyświetlone jako część strony.

Na szczęście nie musisz się tym przejmować, ponieważ WebGPU wskaże Ci format, który ma być użyty w przypadku obszaru roboczego. 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 usuń go jednolitym kolorem.

Aby to zrobić – lub w zasadzie cokolwiek innego w WebGPU – musisz podać układowi GPU instrukcje, co ma robić.

  1. Aby to zrobić, poproś urządzenie o utworzenie interfejsu 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 elementu colorAttachment. Karty renderowania wymagają wskazania GPUTextureView zamiast GPUTexture, który informuje, na których częściach tekstury ma być renderowana. 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 przepustka renderowania ma robić z teksturą na początku i na końcu:

  • Wartość loadOp o wartości "clear" wskazuje, że tekstura ma zostać wyczyszczona po rozpoczęciu procesu 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 wywołań nie powoduje żadnych działań przez GPU. Rejestrują tylko polecenia, które GPU wykorzystuje później.

  1. Aby utworzyć GPUCommandBuffer, wywołaj finish() w poleceniu w koderze. 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 pobiera 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 użyć ponownie, 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, jak ma to miejsce na przykładowych stronach ćwiczeń 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 wykryje zmianę bieżącej tekstury kontekstu i zaktualizuje obszar roboczy, aby wyświetlić tę teksturę jako obraz. Jeśli później zechcesz ponownie zaktualizować zawartość obszaru roboczego, musisz zarejestrować i przesłać nowy bufor poleceń, ponownie wywołując funkcję context.getCurrentTexture(), aby uzyskać nową teksturę dla danego procesu renderowania.

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

Czarne 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ą jednak dość nudne. Dlatego poświęć chwilę, zanim przejdziesz do następnej sekcji, aby chociaż trochę go spersonalizować.

  1. W wywołaniu encoder.beginRenderPass() dodaj do wiersza colorAttachment nowy wiersz z clearValue w ten sposób:

index.html

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

clearValue instruuje kartę renderowania, której koloru ma używać podczas wykonywania operacji clear na początku karty. Przekazany słownik zawiera 4 wartości: r dla czerwonego, g dla zielonego, b dla niebieskiego i a dla alfa (przezroczystość). Każda wartość mieści się w zakresie od 0 do 1 i razem opisuje wartość 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 średnio 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 pojawić się w odbitce na płótnie.

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

4. Narysuj geometrię

Na końcu tej sekcji aplikacja narysuje w obszarze roboczym kilka prostych geometrii: kolorowy kwadrat. Pamiętaj jednak, ż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ę wyjątkowo 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 za pomocą GPU, możesz przejść 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. Na potrzeby tego ćwiczenia w programie używasz tylko trójkątów.

Procesory graficzne działają niemal wyłącznie z trójkątami, ponieważ mają wiele właściwości matematycznych, dzięki którym można je łatwo przetworzyć 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 materiałów 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 zrozumieć w kontekście jej związku z obszarem roboczym strony. Bez względu na to, jak szeroki lub wysoki jest obszar roboczy, lewa krawędź na osi X jest zawsze na pozycji -1, a prawa jest zawsze na pozycji +1 na osi X. I podobnie, dolna krawędź na osi Y ma zawsze wartość -1, a góra – 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 wizualizujący przestrzeń znormalizowanych współrzędnych urządzenia.

W tym układzie współrzędnych wierzchołki rzadko są początkowo zdefiniowane w tym układzie współrzędnych, więc układy GPU korzystają z małych programów zwanych cieniowaniem wierzchołków, które wykonują wszelkie działania matematyczne niezbędne do przekształcenia wierzchołków w przestrzeń przycinania, a także wykonywania innych obliczeń niezbędnych do narysowania wierzchołków. Na przykład cieniowanie może zastosować animację lub obliczyć kierunek od wierzchołka do źródła światła. Zostały one napisane przez dewelopera WebGPU i dają niesamowitą kontrolę nad sposobem działania GPU.

Następnie GPU bierze pod uwagę 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 program o nazwie cieniowanie fragmentów, który oblicza, jaki kolor powinien mieć każdy piksel. Obliczenia mogą być proste, jak np. zwrot zielony lub bardzo złożone, np. obliczanie kąta powierzchni w odniesieniu do światła słonecznego odbijającego się z pobliskich powierzchni, przefiltrowanego przez mgłę i zmodyfikowania jej metaliczną powierzchnią. Wszystko zależy wyłącznie od Ciebie i może być motywujące, ale też przytłaczające.

Wyniki uzyskane dzięki tym pikselom są następnie kumulowane w teksturę, która może się potem pojawić 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 ćwiczeniach z programowania można rysować kolorowe kwadraty w aktywnych komórkach, a nieaktywne komórki pozostawić puste.

Oznacza to, że musisz dostarczyć GPU z czterema punktami, po jednym na każdy z czterech 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 przekazać te współrzędne do GPU, musisz umieścić wartości w tablicy 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 kwadratowego, ponieważ podane wartości są ułamkowe, odpowiednia jest 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 wierzchołki trzeba podać w grupach po trzy. Masz jedną grupę składającą się z czterech osób. Rozwiązaniem jest powtórzenie dwóch wierzchołków w celu utworzenia dwóch trójkątów ze wspólną krawędzią przez środek kwadratu.

Schemat pokazujący, jak z czterech wierzchołków kwadratu zostaną użyte dwa trójkąty.

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

  1. Zaktualizuj poprzednią tablicę vertices, aby wyglądała 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. Wyświetli się jako pojedynczy, pełny kwadrat.

Tworzenie bufora wierzchołków

Procesor graficzny nie może rysować wierzchołków z danymi z tablicy JavaScript. Procesory graficzne często mają własną pamięć, która jest wysoce zoptymalizowana pod kątem renderowania, więc wszelkie dane, których ma używać podczas pobierania, muszą być w niej umieszczone.

W przypadku wielu wartości, w tym danych wierzchołków, pamięć po stronie GPU jest zarządzana za pomocą obiektów GPUBuffer. Bufor to blok pamięci, który jest łatwo dostępny dla GPU i oznaczany w określonych celach. Można to sobie wyobrazić jako typową tablicę typową widoczną dla 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,
});

Najpierw musisz nadać buforowi etykietę. Każdy utworzony obiekt WebGPU może mieć opcjonalną etykietę, co jest bardzo przydatne. 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 wielkości 48 bajtów, który określasz, mnożąc rozmiar 32-bitowej liczby zmiennoprzecinkowej ( 4 bajty) przez liczbę liczb zmiennoprzecinkowych w tablicy vertices (12). Na szczęście klasy TypedSlates obliczają już swoją wartość byteLength, więc możesz jej użyć przy tworzeniu bufora.

Na koniec musisz określić użycie 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łków (GPUBufferUsage.VERTEX) i że chcesz mieć możliwość kopiowania do niego danych (GPUBufferUsage.COPY_DST).

Zwracany obiekt bufora jest nieprzezroczysty i nie można (z łatwością) sprawdzić przechowywanych 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ć zawartość jego pamięci.

Po pierwszym utworzeniu bufora zawarte w nim pamięć zostanie zainicjowana do zera. Można zmienić jego zawartość na kilka sposobów, ale najprostszym jest wywołanie metody device.queue.writeBuffer() przy użyciu tablicy TypedSlate, którą 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łków

Teraz masz bufor z danymi wierzchołków, ale jeśli chodzi o GPU, to tylko blob 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 wydawać się nieco dezorientujące, ale można go łatwo objaśnić.

Pierwszą rzeczą, którą podajesz, jest arrayStride. Jest to liczba bajtów, które GPU musi przejść do przodu w buforze, gdy szuka następnego wierzchołka. Każdy wierzchołek kwadratu składa się z dwóch 32-bitowych liczb zmiennoprzecinkowych. Jak 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 informacje 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. Dane pochodzą z listy typów GPUVertexFormat opisujących każdy typ danych wierzchołków, które układ GPU jest w stanie zrozumieć. 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.

I wreszcie, jest to shaderLocation. Jest to dowolna liczba z zakresu od 0 do 15, która musi być niepowtarzalna 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ż zdefiniujesz te wartości teraz, w rzeczywistości jeszcze nie są one przekazywane 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

Masz już dane, które chcesz wyrenderować, ale nadal musisz wskazać układowi GPU, jak mają to zrobić. Dzieje się tak głównie dzięki programom do cieniowania.

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ą w układzie GPU, mają sztywną strukturę niż zwykły kod JavaScript. Jednak taka struktura umożliwia im szybkie wykonywanie tych zadań i – co najważniejsze – równolegle.

Technologia cieniowania w WebGPU jest napisana w języku cieniowania WGSL (WebGPU Shading Language). Pod względem składni WGSL przypomina nieco wersję Rust, a jej funkcje mają na celu ułatwienie i przyspieszenie działania popularnych typów GPU (takich jak matematyka wektorowa i matrycowa). 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 moduły do cieniowania 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ć cieniowanie, wywołaj funkcję device.createShaderModule(), do której należy podać opcjonalny ciąg label i WGSL code. (pamiętaj, że w przypadku ciągów wielowierszowych używasz grawisu). Gdy dodasz prawidłowy kod WGSL, funkcja zwróci obiekt GPUShaderModule z skompilowanymi wynikami.

Zdefiniuj cieniowanie wierzchołka

Zacznij od cieniowania wierzchołków, bo tam też zaczyna się 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 układy GPU znakomicie sprawdzają się w równoległym uruchamianiu takich mechanizmów cieniowania, co może przetwarzać setki (a nawet tysiące) wierzchołków jednocześnie. To w dużej mierze odpowiada za niewiarygodną szybkość działania układów GPU, ale wiąże się to z pewnymi ograniczeniami. Aby zapewnić ekstremalną równoległość, cieniowanie wierzchołkowe nie może się ze sobą komunikować. Każde wywołanie cieniowania może wyświetlać tylko dane dotyczące 1 wierzchołka naraz i może generować wartości tylko dla 1 wierzchołka.

W WGSL 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 ze słowem kluczowym fn, deklaruje dowolne argumenty w nawiasach i określa zakres za pomocą nawiasów klamrowych.

  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ć nowy obiekt vec4f do zwrócenia, 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 cieniowanie wierzchołków, jednak taki, który nigdy nie wyświetla żadnych elementów, ponieważ układ graficzny (GPU) rozpoznaje, że generowane 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). Zdefiniowano również format jako float32x2, który jest wektorem 2D, więc w WGSL argument to vec2f. Nazwa może być dowolna, ale ponieważ reprezentują one wierzchołki wierzchołkowe, nazwa w stylu pos wydaje się naturalna.

  1. Zmień funkcję cieniowania 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ż położenie jest wektorem 2D, a zwracanym typem jest wektor 4D, trzeba to nieco zmienić. 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

Kolejny krok to cieniowanie fragmentów. Mechanizmy cieniowania fragmentów działają w bardzo podobny sposób do cieniowania wierzchołków, ale w sposób, który nie jest wywoływany dla każdego wierzchołka, jest wywoływany dla każdego rysowanego piksela.

Mechanizmy cieniowania fragmentów są zawsze wywoływane po cieniowaniu wierzchołków. GPU pobiera dane wyjściowe z cieniowania wierzchołków i trianguluje je, tworząc trójkąty z zestawów 3 punktów. Następnie rasteryzuje każdy z tych trójkątów, 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 {

}

Cztery składowe zwróconego wektora to wartości koloru czerwonego, zielonego, niebieskiego i alfa, które są interpretowane dokładnie tak samo jak wartość clearValue ustawiona wcześniej w zasadzie beginRenderPass. Kolor vec4f(1, 0, 0, 1) jest więc jasnoczerwony, który wygląda na dobry kolor dla Twojego kwadratu. Jednak możesz go dowolnie zmienić.

  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 kompletny cieniowanie fragmentów! 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 go użyć w ramach obiektu GPURenderPipeline utworzonego przez wywołanie funkcji device.createRenderPipeline(). Potok renderowania określa, jak ma być rysowana geometria, m.in. używane moduły cieniowania, jak interpretować dane w buforach wierzchołków, jaki rodzaj geometrii (linie, punkty, trójkąty itp.) mają być renderowane.

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łkowe) potrzebuje, ale tak naprawdę nie masz żadnych danych wejściowych. Na szczęście możesz na razie przekazać zasadę "auto", a potok utworzy własny układ na podstawie cieniowania.

Następnie podaj szczegółowe informacje o etapie vertex. module to GPUShaderModule, który zawiera cieniowanie wierzchołków, a entryPoint nadaje nazwę funkcji w kodzie cieniowania, która jest wywoływana przy każdym wywołaniu wierzchołków. W jednym module cieniowania może być wiele funkcji @vertex i @fragment. Bufory to tablica obiektów GPUVertexBufferLayout opisujących sposób pakowania danych w buforach wierzchołkowych, w których używasz tego potoku. Na szczęście ten parametr został już zdefiniowany na Twoim vertexBufferLayout. Tutaj przekazujesz je dalej.

Na koniec zapoznaj się z informacjami o etapie fragment. Obejmuje on też moduł cieniowania i entryPoint, np. scenę wierzchołkowej. Ostatnim krokiem jest określenie targets, w którym będzie 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 podanymi w polu colorAttachments wszystkich kart renderowania, w których używany jest 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 jeszcze nie wszystko, co można określić podczas tworzenia potoku renderowania, ale na pewno zaspokoi potrzeby tego ćwiczenia z programowania.

Narysuj kwadrat

Masz teraz wszystko, czego potrzebujesz do narysowania swojego kwadratu.

  1. Aby narysować kwadrat, wróć do pary połączeń encoder.beginRenderPass() i pass.end(), a następnie dodaj między nimi te nowe polecenia:

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

W ten sposób procesor WebGPU otrzyma wszystkie informacje potrzebne do narysowania kwadratu. Najpierw za pomocą tabeli setPipeline() możesz wskazać potok, za pomocą którego chcesz rysować. Obejmują one używane cieniowanie, układ danych wierzchołkowych oraz inne istotne dane o stanie.

Następnie wywołasz funkcję setVertexBuffer(), używając bufora zawierającego wierzchołki kwadratu. Wywołujesz je za pomocą 0, ponieważ ten bufor odpowiada 0 elementowi w definicji vertex.buffers bieżącego potoku.

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

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

Jeden czerwony kwadrat renderowany przy użyciu WebGPU

5. Rysuj siatkę

Najpierw poświęć chwilę, aby sobie pogratulować. W przypadku większości interfejsów API GPU wyświetlenie pierwszych elementów geometrii na ekranie często jest jednym z najtrudniejszych kroków. 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 korzystać z stanu, aby narysować wiele różnych wariantów tej samej geometrii.

Definiowanie siatki

Aby wyrenderować siatkę, musisz znać bardzo ważną 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. W końcu chcesz ją powiększyć, ale w pozostałej części tej sekcji ustaw rozmiar siatki na 4 x 4, bo ułatwi to zademonstrowanie działań matematycznych użytych w tej sekcji. Potem zwiększaj skalę kampanii.

  • Zdefiniuj rozmiar siatki, dodając stałą 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 opisujemy podejście bardziej przyjazne dla GPU.

Utwórz jednolity bufor

Najpierw musisz przekazać wybrany rozmiar siatki do cieniowania, bo na jego podstawie zmienia się sposób wyświetlania elementów. Rozmiar możesz po prostu zakodować w cieniowaniu na stałe, ale przy każdej zmianie rozmiaru siatki trzeba ponownie utworzyć cieniowanie i potok renderowania, co jest kosztowne. Lepszym sposobem jest podanie rozmiaru siatki do cieniowania w postaci uniformów.

Wiesz już, że do każdego wywołania cieniowania wierzchołkowego jest przekazywana inna wartość z bufora wierzchołków. Jednolita to wartość z bufora, która jest taka sama przy każdym wywołaniu. Przydają się one do przekazywania wartości, które są wspólne dla danego elementu geometrii (np. jego pozycji), pełnej klatki animacji (np. bieżący czas), a 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 

Definiuje to jednolite w cieniowaniu o nazwie grid, czyli wektor zmiennoprzecinkowy 2D pasujący do tablicy skopiowanej do bufora jednolitego. Określa też, że element jednolity jest powiązany w tych miejscach: @group(0) i @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 powszechne w cieniowaniu GPU, ponieważ bazują na wielu technikach renderowania i obliczenia.

Oznacza to, że (jeśli został użyty rozmiar siatki 4) renderowany kwadrat byłby jedną czwartą jego pierwotnego rozmiaru. To idealne rozwiązanie, jeśli chcesz zmieścić cztery z nich w wierszu lub kolumnie.

Tworzenie grupy powiązań

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

Grupa powiązań to zbiór zasobów, które chcesz jednocześnie udostępnić swojemu programowi cieniowania. 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 przekazujesz tablicę zawierającą entries. Każdy wpis jest słownikiem zawierającym co najmniej te wartości:

  • binding, co odpowiada wartości @binding() wpisanej przez Ciebie w programie cieniowania. 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 bufor jednolity.

Funkcja zwraca GPUBindGroup, który jest nieprzezroczystym, trwałym uchwytem. Po utworzeniu powiązania nie możesz zmienić zasobów, na które wskazuje grupa, ale możesz zmienić zawartość tych zasobów. Jeśli na przykład zmienisz bufor jednolity tak, aby zawierał nowy rozmiar siatki, który będzie uwzględniany w przyszłych wywołaniach rysowania za pomocą tej grupy powiązań.

Tworzenie powiązania grupy powiązań

Po utworzeniu grupy powiązań nadal musisz wskazać WebGPU, aby używał jej podczas rysowania. Na szczęście to dość proste.

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

index.html

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

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

pass.draw(vertices.length / 2);

Wartość 0 przekazana jako pierwszy argument odpowiada funkcji @group(0) w kodzie cieniowania. 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! Twój kwadrat ma teraz jedną czwartą większy niż wcześniej. To niewiele, ale pokazuje, że mundur jest rzeczywiście stosowany i cieniowanie ma teraz dostęp do rozmiaru siatki.

Manipulowanie geometrią w cieniowaniu

Skoro wiesz już, jaki rozmiar siatki ma być w cieniu, możesz zacząć manipulować renderowaną geometrią tak, aby pasowała do wzorca siatki. W tym celu zastanów się, co dokładnie chcesz osiągnąć.

Musisz koncepcyjnie podzielić obszar roboczy na kilka komórek. 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 przedstawiająca koncepcyjną siatkę, w której przestrzeń znormalizowanych współrzędnych urządzenia zostanie podzielona podczas wizualizacji każdej komórki z wyrenderowaną aktualnie kwadratową geometrią.

Twoim zadaniem jest znalezienie w cieniowaniu metody, która pozwala umieścić geometrię kwadratową w dowolnej z komórek na podstawie współrzędnych komórki.

Po pierwsze, możesz zauważyć, że kwadrat nie jest odpowiednio wyrównany względem żadnej z komórek, ponieważ został zdefiniowany tak, aby otaczał środek obszaru roboczego. 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. Przesuwanie wierzchołków tak, aby prawy dolny róg znajdował się na przykład w miejscu (0,1; 0,1) zamiast (-0,8; -0,8), można przenieść ten kwadrat i lepiej go wyrównać z granicami komórek. Masz jednak pełną kontrolę nad sposobem przetwarzania wierzchołków w cieniowaniu, więc możesz po prostu umieścić je we właściwym miejscu za pomocą kodu cieniowania.

  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)

Następnie, ponieważ układ współrzędnych obszaru roboczego umieszcza (0, 0) na środku oraz (-1, -1) w lewym dolnym rogu, a chcesz umieścić (0, 0) w lewym dolnym rogu, musisz przetłumaczyć położenie geometrii przez (-1, -1) po podzieleniu przez rozmiar siatki, by przenieść ją do tego rogu.

  1. Zmień położenie 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 cieniowaniu i wypełniaj go wartością statyczną, 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 jedną jednostkę siatki (jedną czwartą obszaru roboczego) w każdej komórce. Wygląda na to, że musisz ponownie podzielić przez grid.

  1. Zmień położenie siatki 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); // 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. Niezupełnie tak, jak chciałeś.

Dzieje się tak, ponieważ współrzędne obszaru roboczego zaczynają się od -1 do +1, więc mają one 2 jednostki. Oznacza to, że jeśli chcesz przesunąć wierzchoł do jednej czwartej obszaru roboczego, 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 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 masz dokładnie to, czego chcesz.

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.

Możesz zapisać współrzędne komórki w jednolitym buforze, a następnie wywołać metodę draw raz dla każdego kwadratu w siatce i za każdym razem aktualizować jednolitą wartość. 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 skrócenie czasu oczekiwania na inne części systemu.

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

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

index.html

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

Informuje to system, że powinien narysować 6 (vertices.length / 2) wierzchołków Twojego kwadratu 16 (GRID_SIZE * GRID_SIZE) razy. Jednak po odświeżeniu strony zobaczysz te informacje:

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

Dlaczego? To dlatego, że wszystkie 16 z tych kwadratów rysuje się 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 cieniowaniu oprócz atrybutów wierzchołków, takich jak pos, które pochodzą z bufora wierzchołków, masz też dostęp do tak znanych wartości wbudowanych WGSL. Są to wartości obliczane przez WebGPU, a jedna z nich to 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ą tego samego wystąpienia. 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. Potem jeszcze 6 razy z instance_index o wartości 1, kolejne 6 z instance_index o wartości 2 itd.

Aby zobaczyć, jak to działa, musisz dodać do danych wejściowych do cieniowania interfejs instance_index. Zrób to tak samo jak w przypadku pozycji, ale zamiast dodawać do niego atrybut @location, użyj @builtin(instance_index), a następnie nadaj argumentowi dowolną nazwę. (Aby to zrobić, użyj nazwy instance, aby dopasować ją do przykładowego kodu). Następnie użyj jej jako elementu funkcji cieniowania.

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

index.html

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

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {
  
  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i);
  let cellOffset = cell / grid * 2; // Updated
  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 możesz jednak zobaczyć wszystkich 16 z nich.

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 obszaru roboczego podzielonego koncepcyjnie na siatkę 4 x 4, przy której każda komórka odpowiada także liniowemu indeksowi instancji.

Obliczenia w tym zakresie są dość proste. Dla wartości X każdej komórki chcesz określić modulo parametru instance_index i szerokość siatki, które możesz wykonać w WGSL przy użyciu operatora %. Z kolei wartość Y każdej komórki chcesz podzielić w polu instance_index przez szerokość siatki, odrzucając wszelkie ułamkowe części. Służy do tego funkcja floor() 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 wiersze z czterema kolumnami 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! W tej chwili siatka może być naprawdę naprawdę duża, a przeciętny układ graficzny (GPU) poradzi sobie z tym. Poszczególne kwadraty przestaną się wyświetlać na długo, zanim wystąpią wąskie gardła wydajności GPU.

6. Dodatkowa zaleta: spraw, aby kolor stawał się bardziej kolorowy.

Na tym etapie możesz z łatwością przejść do następnej sekcji, bo masz już przygotowane podstawy do pozostałych ćwiczeń z programowania. O ile siatka kwadratów ma ten sam kolor i można z niej korzystać, to nie jest to takie ekscytujące, prawda? Na szczęście możesz zrobić to nieco jaśniej, używając kodu matematycznego i cieniowania.

Używanie elementów struct w cieniowaniu

Do tej pory przekazano z funkcji cieniowania wierzchołków jeden rodzaj danych – pozycję przekształconą. Ale możesz zwrócić znacznie więcej danych z mechanizmu cieniowania wierzchołków, a potem użyć ich w cieniowaniu fragmentów.

Jedynym sposobem na przekazanie danych z modułu cieniowania wierzchołków jest jego zwrócenie. Moduł cieniowania wierzchołków jest zawsze wymagany do zwrócenia pozycji, więc jeśli chcesz zwrócić wraz z nim inne dane, musisz umieścić go w strukturze. Obiekty Struct w WGSL to nazwane typy obiektów zawierające co najmniej jedną właściwość nazwaną. Właściwości można też oznaczać za pomocą atrybutów takich jak @builtin i @location. Deklarujesz je poza funkcjami, a następnie w razie potrzeby możesz przekazywać ich wystąpienia do i z funkcji. Weźmy na przykład obecny program do cieniowania wierzchołków:

index.html (wywołanie createShaderModule)

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

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

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
  
  return  vec4f(gridPos, 0, 1);
}
  • Wyraź to samo, używając struktury danych wejściowych i wyjściowych funkcji:

index.html (wywołanie createShaderModule)

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

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

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

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

Zwróć uwagę, że wymaga to odwołania się do pozycji wejściowej i indeksu instancji za pomocą parametru input, a zwracana najpierw struktura musi być zadeklarowana jako zmienna z ustawionymi jej właściwościami. W tym przypadku nie ma to zbyt dużej różnicy, a czasem wydłuża działanie cieniowania, ale wraz z tym, że cieniowanie staje się coraz bardziej złożone, świetnym sposobem na uporządkowanie danych może być użycie elementów struct.

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

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 wynik przekazujesz jednolity kolor (czerwony). Gdyby cieniowanie wiedział więcej o geometrii, którą koloruje, możesz użyć dodatkowych danych, żeby nieco urozmaicić sprawę. Co na przykład, gdy chcesz zmienić kolor każdego kwadratu w zależności od jego współrzędnych komórki? Etap @vertex wie, która komórka jest renderowana; wystarczy przekazać 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ć takie same, ale łatwiej jest je znaleźć).

index.html (wywołanie createShaderModule)

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

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ą alternatywą****, ponieważ w Twoim kodzie obie te funkcje są zdefiniowane w tym samym module cieniowania, to 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 wykorzystać, aby zmienić kolor. Po dodaniu dowolnego z powyższych kodów dane wyjściowe będą wyglądać tak:

Siatka kwadratów, w której pierwsza kolumna od lewej jest zielona, dolny rząd jest czerwony, a wszystkie pozostałe są żółte.

Jest teraz zdecydowanie więcej kolorów, ale nie wygląda to zbyt ładnie. Być może zastanawiasz się, dlaczego różnią się tylko wiersze lewe i dolne. 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ę pełną wartość w kanale koloru czerwonego lub zielonego, a każda kolejna komórka zmniejsza się do tej samej wartości.

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

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

index.html (wywołanie createShaderModule)

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

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.

Choć to z pewnością pewne ulepszenia, teraz w lewym dolnym rogu znajduje się nieszczęśliwy ciemny róg, w którym siatka staje się czarna. Gdy zaczniesz symulować grę w życie, słabo widoczny fragment siatki zasłania, co się dzieje. Byłoby fajnie to rozjaśnić.

Na szczęście masz do wyboru cały nieużywany kanał kolorów – niebieski. Optymalnym efektem jest uzyskanie najjaśniejszego koloru niebieskiego tam, gdzie pozostałe kolory są najciemniejsze, a następnie wygaszania w miarę intensywności pozostałych kolorów. 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 najbardziej 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 kluczowy krok. Wygląda jednak lepiej, dlatego umieściliśmy go 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ądzaj stanem komórki

Następnie musisz określić, które komórki w siatce są renderowane, na podstawie określonego stanu zapisanego w GPU. To ważna kwestia, gdy chcemy przeprowadzić ostateczną symulację!

Potrzebny jest tylko sygnał włączenia dla każdej komórki, więc wszystkie opcje umożliwiające przechowywanie dużych tabel niemal każdego typu wartości działają. Możesz pomyśleć, że jest to jeszcze jeden przypadek użycia jednolitych buforów. Choć możesz to poprawić, jest to trudniejsze, ponieważ jednolite bufory mają ograniczony rozmiar, nie obsługują tablic o dynamicznych rozmiarach (musisz określić rozmiar tablicy w mechanizmie cieniowania) i nie mogą być w nim zapisywane przez moduły do cieniowania. Ostatni element stanowi problem, bo chcesz przeprowadzić symulację gry w życie na GPU w ramach cieniowania obliczeniowego.

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

Utwórz bufor pamięci

Bufory pamięci masowej to bufory ogólnego przeznaczenia, które można odczytywać i zapisywać w modułach do cieniowania obliczeniowego oraz odczytywać w trybach cieniowania wierzchołków. Mogą być bardzo duże i nie wymagają zadeklarowanego rozmiaru w programie do cieniowania, dzięki czemu są bardziej typowe dla pamięci ogólnej. W ten sposób zapisujesz stan komórki.

  1. Aby utworzyć bufor pamięci masowej dla stanu komórki, użyj prawdopodobnie wyglądającego już dobrze widocznego fragmentu kodu 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 wierzchołków i buforów jednolitych, wywołaj funkcję device.createBuffer() w odpowiednim rozmiarze, a następnie pamiętaj, aby tym razem określić użycie funkcji GPUBufferUsage.STORAGE.

Możesz zapełnić bufor w taki sam sposób jak wcześniej: wpisując wartości do tablicy TypedSlate o tym samym rozmiarze, a następnie wywołując funkcję device.queue.writeBuffer(). Ponieważ chcesz sprawdzić, jaki wpływ na siatkę ma bufor, zacznij od wypełnienia go czymś przewidywalnym.

  1. Aktywuj co trzecią komórkę, używając 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 do tego, w jaki sposób wcześniej dodawano uniformy.

  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 dodaj punkt wiązania, który jest wsuwany tuż pod siatką. Chcesz zachować ten sam format @group co identyfikator grid, ale numer @binding musi być inny. Typ var to storage. Aby odzwierciedlić inny typ bufora, a nie pojedynczy wektor, typ podany dla cellState to tablica wartości u32 w celu dopasowania Uint32Array w JavaScript.

Następnie w treści funkcji @vertex wykonaj zapytanie dotyczące stanu komórki. Ponieważ stan jest przechowywany w płaskiej tablicy w buforze pamięci, możesz użyć funkcji instance_index do wyszukania wartości bieżącej komórki.

Jak wyłączyć komórkę, jeśli stan wskazuje, że jest ona nieaktywna? Ponieważ stan aktywny lub nieaktywny otrzymywany z tablicy to 1 lub 0, możesz skalować geometrię według stanu aktywnego. Skalowanie o 1 pozostawia bez zmian, a skalowanie o 0 powoduje zwijanie geometrii w jeden punkt, który następnie zostaje odrzucony przez GPU.

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

Dodaj bufor pamięci masowej do grupy powiązań

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

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

index.html

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

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

Gdy to zrobisz, odśwież stronę, a wzór pojawi się na siatce.

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

Użyj wzorca bufora ping-ponga

Większość symulacji, takich jak ta, którą tworzysz, zwykle używa co najmniej 2 kopii stanu. Na każdym etapie symulacji odczytują dane z jednej kopii stanu i zapisują na drugiej. W następnym kroku odwróć go i odczytaj stan, do którego napisali. Nazywa się to wzorcem ping-ponga, ponieważ najświeższe informacje o stanie są przesyłane do i z powrotem między kopiami stanu.

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ć ich zrozumienie, definiujesz dane i symulację w języku JavaScript:

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

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

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

Jeśli jednak uruchomisz ten kod, aktywna komórka przemieści się w jednym kroku do końca tablicy. 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ść! Aktywna! Lepiej przesuń je ponownie w prawo. Zmiana danych w czasie ich obserwacji powoduje uszkodzenie wyników.

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(in, out) {
  out[0] = 0;
  for (let i = 1; i < in.length; ++i) {
     out[i] = in[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świetlać 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 też może z tego korzystać, ale w tym przypadku warto przeprowadzać dłuższe aktualizacje, aby można było łatwiej śledzić symulację. 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 wybranych odstępach czasu za pomocą funkcji setInterval(). Sprawdź, czy funkcja aktualizuje też liczbę kroków, i na podstawie tej funkcji wybierz, którą z 2 grup powiązań chcesz powiązać.

index.html

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

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

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

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

Po uruchomieniu aplikacji widać, że obszar roboczy odwraca się, wyświetlając 2 utworzone bufory stanu.

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

Na tym kończymy czynności związane z renderowaniem. 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 jednak nadzieję, że daje Ci ona dość tego, jak działa renderowanie w WebGPU, i ułatwi zrozumienie bardziej zaawansowanych technik, takich jak renderowanie 3D.

8. Uruchamianie symulacji

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

Nareszcie możesz skorzystać z cieniowania obliczeniowego.

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

Moduł cieniowania obliczeniowego jest podobny do wierzchołka i fragmentatora cieniowania, ponieważ działa z równoległym działaniem GPU, ale w przeciwieństwie do pozostałych 2 etapów cieniowania 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 po uruchomieniu cieniowania zobaczysz informację o wywołaniu, które jest przetwarzane. Możesz też zdecydować, do jakich danych będziesz mieć dostęp i które operacje chcesz z niego wykonywać.

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ć, biorąc pod uwagę strukturę innych zastosowanych modułów do cieniowania, główna funkcja cieniowania 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() {

    }`
});

Procesory graficzne są często używane w przypadku grafiki 3D, dlatego cieniowanie obliczeniowe ma taką strukturę, że można zażądać jego konkretnego wywołania na osi X, Y i Z. Pozwala to bardzo łatwo wysyłać prace zgodne z siatką 2D lub 3D, co sprawdza się doskonale w Twoim przypadku. Chcesz wywołać ten cieniowanie GRID_SIZE razy GRID_SIZE razy, raz na każdą komórkę 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. Określ stałą wielkość grupy roboczej w ten sposób:

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 cieniowania w ten sposób:

index.html (wywołanie Compute createShaderModule)

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

}

Informuje to programistę, że praca z tą funkcją jest wykonywana w grupach (8 x 8 x 1). (Każda oś wyłączona domyślnie będzie miała wartość 1, ale trzeba będzie określić przynajmniej 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 takie liczby jak (0, 0, 0), (1, 0, 0), (1, 1, 0)... aż do (31, 31, 0), co oznacza, że możesz traktować go jako indeks komórek, na którym będziesz wykonywać operacje.

Odtwarzacze Compute mogą też korzystać z jednolitych cieni, z których korzystasz podobnie jak w przypadku cieniowania wierzchołków i fragmentów.

  1. Użyj jednolitego cieniowania obliczeniowego, aby określić rozmiar siatki 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. Ale w tym przypadku są potrzebne 2. Ponieważ cieniowanie obliczeniowe nie ma wymaganych danych wyjściowych, takich jak pozycja wierzchołka lub kolor fragmentu, zapisywanie wartości w buforze lub teksturze to jedyny sposób na uzyskanie wyników z tego narzędzia. Użyj poznanej wcześniej metody ping-pong. masz jeden bufor pamięci, który dostarcza treści w bieżącym stanie siatki i jeden, do którego zapisujesz nowy stan siatki.

  1. Udostępnij stan danych wejściowych i wyjściowych komórki jako bufory pamięci masowej w ten sposób:

index.html (wywołanie Compute createShaderModule)

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

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

}

Zauważ, że pierwszy bufor pamięci masowej jest zadeklarowany za pomocą funkcji var<storage>, co oznacza, że jest on tylko do odczytu, a drugi bufor jest deklarowany za pomocą metody 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 pamięci tylko do zapisu).

Musisz mieć sposób na zmapowanie indeksu komórek na linearną tablicę pamięci masowej. Jest to w zasadzie odwrotność niż w trybie cieniowania wierzchołków, czyli do zastosowania liniowego obiektu instance_index w celu zmapowania go 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 Compute createShaderModule)

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

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

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

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

I na koniec, aby przekonać się, że działa, zaimplementuj bardzo prosty algorytm: jeśli komórka jest aktualnie włączona, wyłącza się i na odwrót. To jeszcze nie „Gra w życie”, ale to wystarczy, aby pokazać, że program do cieniowania obliczeniowego 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żyj 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 po prostu użyć tych samych grup powiązań. Dobra wiadomość jest taka, że możesz. Wymaga to jednak nieco więcej ręcznej konfiguracji.

Za każdym razem, gdy tworzysz grupę powiązań, musisz podać GPUBindGroupLayout. Wcześniej ten układ był pobierany przez wywołanie metody getBindGroupLayout() w potoku renderowania, co z kolei spowodowało automatyczne utworzenie tego układu, ponieważ podczas tworzenia został przez Ciebie podany layout: "auto". To podejście sprawdza się, gdy korzystasz tylko z 1 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 jest, weź pod uwagę następujące kwestie: w potokach renderowania używasz jednego bufora jednolitego i jednego bufora pamięci masowej, ale w opisanym właśnie przez Ciebie module do cieniowania Compute potrzebujesz drugiego bufora pamięci masowej. 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
  }]
});

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

W każdej pozycji podajesz numer binding dla zasobu, który (jak został nauczony podczas tworzenia grupy powiązań) odpowiada wartości @binding w cieniowaniu. Podaj też visibility, czyli flagi GPUShaderStage wskazujące, które etapy cieniowania 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 procesorach cieniowania obliczeniowej, ale drugi bufor musi być dostępny tylko w tych systemach.

Na koniec wskazujesz typ używanego zasobu. 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 np. rodzaj używanego bufora (type). Wartość domyślna to "uniform", więc możesz zostawić słownik pusty w celu powiązania 0. (Musisz jednak ustawić co najmniej buffer: {}, tak aby wpis został zidentyfikowany jako bufor). Powiązanie 1 ma typ "read-only-storage", ponieważ nie używasz go z dostępem read_write w obszarze cieniowania, a powiązanie 2 ma typ "storage", ponieważ używasz go z dostępem read_write.

Po utworzeniu 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 proces tworzenia grupy powiązań w następujący sposób:

index.html

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

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

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

  1. Utwórz GPUPipelineLayout.

index.html

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

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 powiązań w tablicy musi odpowiadać atrybutom @group w cieniowaniu. (Oznacza to, że witryna bindGroupLayout jest powiązana z domeną @group(0)).

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

index.html

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

Tworzenie potoku obliczeniowego

Tak samo jak do korzystania z cieniowania wierzchołków i fragmentów jest potrzebny potok renderowania, tak samo potrzebujesz potoku obliczeniowego, z którego będzie korzystać program do cieniowania. Na szczęście potoki obliczeniowe są znacznie mniej skomplikowane niż potoki renderowania, ponieważ nie mają żadnego stanu do ustawienia, 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",
  }
});

Zwróć uwagę, że przekazujesz nową wartość pipelineLayout zamiast "auto", tak jak w przypadku zaktualizowanego potoku renderowania, dzięki czemu zarówno potok renderowania, jak i potok obliczeniowy mogą używać tych samych grup powiązań.

Karty obliczeniowe

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 koderze poleceń, dlatego warto trochę zmienić układ funkcji updateGrid.

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

index.html

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

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

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

Podobnie jak w przypadku potoków obliczeniowych rozpoczęcie pracy z użyciem zasobów obliczeniowych jest znacznie prostsze niż w przypadku ich renderowania, ponieważ nie musisz się martwić o żadne załączniki.

Musisz go wykonać przed zakończeniem renderowania, ponieważ umożliwia on przebiegowi renderowania natychmiast 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 skonfiguruj potok i grupę powiązań w ramach przebiegu obliczeniowego, używając tego samego wzorca do przełączania się między grupami powiązań co w przypadku karnetu renderowania.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. I wreszcie, zamiast rysować tak jak w ramach przebiegu renderowania, wysyłasz pracę do systemu cieniowania Compute, określają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 pamiętaj, że numer, który podajesz w usłudze 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 cieniowanie było wykonywane 32 x 32 razy, aby pokryć całą siatkę, a rozmiar Twojej grupy roboczej to 8 x 8, musisz wysłać grupy robocze 4 x 4 (4 * 8 = 32). Dlatego dzielisz rozmiar siatki przez rozmiar grupy roboczej i przekazujesz tę wartość do funkcji dispatchWorkgroups().

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

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 cieniowanie przetwarzania w celu zaimplementowania ostatecznego algorytmu, wróć do kodu, który inicjuje zawartość bufora pamięci masowej, i zaktualizuj go tak, aby przy każdym wczytaniu strony generował losowy bufor. (Regularne wzory nie sprawiają, że na początku gry w życie są ciekawsze). Możesz w dowolny sposób losowego wyboru wartości, ale istnieje prosty sposób, który pozwoli Ci w prosty sposób uzyskać wiarygodne wyniki.

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

index.html

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

Możesz w końcu wdrożyć logikę symulacji „Gra w życie”. 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 Compute createShaderModule)

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

Funkcja cellActive zwraca 1 wartość, jeśli komórka jest aktywna, więc dodanie wartości zwrotnej wywołania funkcji cellActive w przypadku wszystkich 8 sąsiadujących komórek daje wynik, ile sąsiednich komórek jest aktywnych.

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

index.html (wywołanie Compute createShaderModule)

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

Wiąże się to jednak z drobnym problemem: co się dzieje, gdy komórka, którą sprawdzasz, jest oddalona od krawędzi planszy? Zgodnie z Twoją obecną zasadą logiczną cellIndex() treść może przekraczać następny lub poprzedni wiersz albo wychodzić z granicy bufora.

W przypadku Gry w życie popularnym i łatwym sposobem rozwiązania tego problemu jest umieszczenie komórek na krawędzi siatki traktowanie komórek po przeciwnej krawędzi siatki jak ich sąsiadów, co da pewien efekt zawijania się.

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

Następnie stosujesz jedną z czterech 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żna to zrobić za pomocą serii instrukcji if, ale WGSL obsługuje również instrukcje Switch, które dobrze pasują 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;
  }
}

Dla porównania ostatnie wywołanie modułu cieniowania obliczeniowego wygląda 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;
        }
      }
    }
  `
});

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

Zrzut ekranu pokazujący przykładowy stan z symulacji Game of Life z kolorowymi komórkami renderowanymi 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