Aplikasi WebGPU pertama Anda

1. Pengantar

Logo WebGPU terdiri dari beberapa segitiga biru yang membentuk gaya 'W'.

Terakhir diperbarui: 28-08-2023

Apa itu WebGPU?

WebGPU adalah API baru dan modern untuk mengakses kemampuan GPU Anda dalam aplikasi web.

API Modern

Sebelum WebGPU, ada WebGL, yang menawarkan sebagian dari fitur WebGPU. WebGL memungkinkan konten web yang kaya, dan para developer telah membangun berbagai hal yang luar biasa menggunakannya. Namun, WebGL didasarkan pada OpenGL ES 2.0 API yang dirilis pada tahun 2007, yang didasarkan pada OpenGL API yang lebih lama. GPU telah melalui perkembangan signifikan sejak saat itu, dan API native yang digunakan untuk berinteraksi dengannya juga berkembang dengan adanya Direct3D 12, Metal, dan Vulkan.

WebGPU menghadirkan perkembangan dari API modern ini ke platform web. WebGPU berfokus untuk memungkinkan fitur GPU secara lintas platform, sambil menyajikan API yang terasa alami di web, serta lebih ringkas jika dibandingkan dengan beberapa API native yang digunakan sebagai dasarnya.

Rendering

GPU sering kali dikaitkan dengan proses rendering grafis yang cepat dan penuh detail, dan hal yang sama juga berlaku untuk WebGPU. WebGPU memiliki fitur yang diperlukan untuk mendukung berbagai teknik rendering yang populer saat ini di berbagai GPU desktop dan seluler, serta memberikan jalur untuk menambahkan fitur baru di masa mendatang seiring berkembangnya kemampuan hardware.

Komputasi

Selain melakukan rendering, WebGPU juga membuka potensi GPU Anda untuk menjalankan workload umum yang bersifat sangat paralel. Shader komputasi ini dapat digunakan secara mandiri, tanpa komponen rendering apa pun, atau sebagai bagian terpadu dari pipeline rendering Anda.

Pada codelab hari ini, Anda akan mempelajari cara memanfaatkan kemampuan rendering dan komputasi WebGPU untuk membuat proyek awal sederhana.

Yang akan Anda bangun

Dalam codelab ini, Anda akan membangun Game of Life dari Conway menggunakan WebGPU. Aplikasi Anda akan:

  • Menggunakan kemampuan rendering WebGPU untuk menggambar grafis 2D sederhana.
  • Menggunakan kemampuan komputasi WebGPU untuk melakukan simulasi.

Screenshot produk akhir dari codelab ini

Game of Life dikenal sebagai cellular automata, dengan grid sel yang berubah statusnya seiring waktu berdasarkan serangkaian aturan tertentu. Pada Game of Life, sel menjadi aktif atau tidak aktif bergantung pada seberapa banyak sel di tetangganya yang aktif, yang menghasilkan pola menarik dan fluktuatif yang dapat Anda saksikan.

Yang akan Anda pelajari

  • Cara menyiapkan WebGPU dan mengonfigurasi kanvas.
  • Cara menggambar geometri 2D sederhana.
  • Cara menggunakan shader verteks dan fragmen untuk memodifikasi apa yang sedang digambar.
  • Cara menggunakan shader komputasi untuk melakukan simulasi sederhana.

Codelab ini berfokus untuk memperkenalkan konsep dasar di balik WebGPU. Codelab ini tidak dimaksudkan sebagai tinjauan komprehensif tentang API, dan juga tidak membahas (atau memerlukan) topik yang umumnya terkait, seperti matematika matriks 3D.

Yang akan Anda butuhkan

  • Chrome versi terbaru (113 atau yang lebih baru) di ChromeOS, macOS, atau Windows. WebGPU adalah API lintas browser dan lintas platform, tetapi belum tersedia di semua browser dan platform.
  • Pengetahuan terkait HTML, JavaScript, dan Chrome DevTools.

Pengalaman dengan API Grafis lainnya, seperti WebGL, Metal, Vulkan, atau Direct3D, tidak diperlukan, tetapi jika Anda memiliki pengalaman dalam menggunakannya, Anda kemungkinan akan melihat banyak kemiripan dengan WebGPU yang dapat membantu mempercepat proses pembelajaran Anda.

2. Memulai persiapan

Mendapatkan kode

Codelab ini tidak memiliki dependensi apa pun, dan akan membimbing Anda dalam setiap langkah yang diperlukan untuk membuat aplikasi WebGPU, sehingga Anda tidak memerlukan kode apa pun untuk memulai. Namun, beberapa contoh yang berfungsi dan dapat digunakan sebagai checkpoint tersedia di https://glitch.com/edit/#!/your-first-webgpu-app. Anda dapat melihatnya dan menggunakannya sebagai referensi saat mengalami kesulitan.

Menggunakan Konsol Play

WebGPU merupakan API yang cukup kompleks dengan berbagai aturan yang dimaksudkan untuk memastikan penggunaan yang benar. Selain itu, karena cara kerja API, WebGPU tidak dapat memberikan pengecualian JavaScript umum untuk banyak error, sehingga dapat mempersulit Anda dalam menentukan dengan tepat tempat error tersebut berasal.

Anda akan mengalami masalah saat melakukan pengembangan dengan WebGPU, terutama sebagai seorang pemula, dan hal ini tidak menjadi masalah. Developer API ini memahami tantangan dalam menggunakan pengembangan GPU, dan telah berusaha untuk memastikan bahwa setiap kali kode WebGPU Anda menyebabkan error, Anda akan mendapatkan pesan yang mendetail dan bermanfaat di Konsol Play, yang dapat membantu dalam mengidentifikasi dan menyelesaikan masalah.

Tetap membuka Konsol Play saat mengerjakan aplikasi web apa pun akan sangat membantu, dan hal ini utamanya berlaku di sini.

3. Melakukan inisialisasi WebGPU

Memulai dengan <canvas>

WebGPU dapat digunakan tanpa menampilkan apa pun di layar, jika Anda ingin menggunakannya untuk melakukan komputasi. Namun, jika Anda ingin merender apa pun, seperti yang akan kita lakukan dalam codelab ini, Anda akan memerlukan kanvas. Jadi kanvas menjadi tempat yang baik untuk memulai.

Buat dokumen HTML baru dengan satu elemen <canvas> di dalamnya, serta tag <script> tempat kita akan mengkueri elemen kanvas. (Atau gunakan 00-starter-page.html dari glitch.)

  • Buat file index.html dengan kode berikut:

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>

Meminta adaptor dan perangkat

Sekarang Anda bisa melanjutkan ke bagian WebGPU. Pertama-tama, perlu diketahui bahwa API seperti WebGPU memerlukan waktu yang cukup lama untuk diterapkan ke seluruh ekosistem web. Oleh karenanya, langkah pencegahan awal yang baik adalah memeriksa apakah browser pengguna dapat menggunakan WebGPU

  1. Untuk memeriksa apakah objek navigator.gpu, yang berperan sebagai titik entri WebGPU sudah ada, tambahkan kode berikut:

index.html

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

Idealnya, Anda memberi tahu pengguna jika WebGPU tidak tersedia dengan membuat halaman beralih ke mode yang tidak menggunakan WebGPU. (Mungkin bisa menggunakan WebGL?) Namun, untuk tujuan codelab ini, Anda hanya perlu menampilkan error untuk menghentikan kode agar tidak dijalankan lebih lanjut.

Setelah Anda mengetahui bahwa WebGL tidak didukung oleh browser, langkah pertama dalam melakukan inisialisasi WebGPU adalah membuat aplikasi meminta GPUAdapter. Anda dapat menganggap adaptor sebagai representasi WebGPU dari bagian tertentu hardware GPU di perangkat Anda.

  1. Untuk mendapatkan adaptor, gunakan metode navigator.gpu.requestAdapter(). Metode ini akan menampilkan promise, sehingga akan memudahkan jika Anda memanggilnya dengan await.

index.html

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

Jika tidak ada adaptor yang sesuai yang dapat ditemukan, nilai adapter yang ditampilkan mungkin null, sehingga Anda perlu menangani kemungkinan tersebut. Hal ini mungkin terjadi jika browser pengguna mendukung WebGPU, tetapi hardware GPU tidak memiliki semua fitur yang dibutuhkan untuk menggunakan WebGPU.

Sering kali tidak menjadi masalah jika Anda membiarkan browser untuk memilih adaptor default, seperti yang dilakukan di sini, tetapi untuk kebutuhan lanjutan, ada argumen yang dapat diteruskan ke requestAdapter(), yang menentukan apakah Anda ingin menggunakan hardware daya rendah atau yang berperforma tinggi di perangkat dengan beberapa GPU (seperti laptop tertentu).

Setelah Anda memiliki adaptor, langkah terakhir sebelum dapat bekerja menggunakan GPU adalah meminta GPUDevice. Perangkat merupakan antarmuka utama tempat terjadinya sebagian besar interaksi dengan GPU.

  1. Dapatkan perangkat dengan memanggil adapter.requestDevice(), yang juga akan menampilkan promise.

index.html

const device = await adapter.requestDevice();

Sama halnya dengan requestAdapter(), ada opsi yang dapat diteruskan di sini untuk penggunaan lanjutan seperti mengaktifkan fitur hardware khusus atau meminta batas yang lebih tinggi, tetapi untuk tujuan Anda, opsi default juga dapat berfungsi dengan baik.

Mengonfigurasi Kanvas

Kini setelah Anda memiliki perangkat, ada satu hal lagi yang perlu dilakukan jika ingin menggunakan perangkat untuk menampilkan apa pun di halaman: mengonfigurasi kanvas agar digunakan dengan perangkat yang baru saja dibuat.

  • Untuk melakukan hal ini, pertama-tama minta GPUCanvasContext dari kanvas dengan memanggil canvas.getContext("webgpu"). (Ini adalah panggilan yang sama yang digunakan untuk melakukan inisialisasi konteks Canvas 2D atau WebGL, menggunakan jenis konteks 2d dan webgl.) context yang ditampilkan kemudian harus dikaitkan dengan perangkat menggunakan metode configure(), seperti ini:

index.html

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

Ada beberapa opsi yang dapat diteruskan di sini, tetapi yang paling penting adalah device, yang akan digunakan dengan konteks dan format, yang merupakan format tekstur yang harus digunakan konteks.

Tekstur adalah objek yang digunakan WebGPU untuk menyimpan data gambar, dan setiap tekstur memiliki format yang memungkinkan GPU untuk mengetahui bagaimana data diatur dalam memori. Detail cara kerja memori tekstur berada di luar cakupan codelab ini. Hal penting yang perlu diketahui adalah bahwa konteks kanvas memberi tekstur tempat kode Anda dapat menggambar, dan format yang Anda gunakan dapat memiliki dampak pada efisiensi kanvas dalam menampilkan gambar tersebut. Berbagai jenis perangkat akan memiliki performa terbaik ketika menggunakan format tekstur yang berbeda-beda. Jika Anda tidak menggunakan format yang direkomendasikan untuk perangkat Anda, hal ini dapat menyebabkan adanya salinan memori tambahan di latar belakang sebelum gambar dapat ditampilkan sebagai bagian dari halaman.

Untungnya, Anda tidak perlu mengkhawatirkan hal ini karena WebGPU akan memberi tahu Anda format apa yang harus digunakan untuk kanvas. Dalam hampir semua kasus, sebaiknya Anda menggunakan nilai yang ditampilkan dengan memanggil navigator.gpu.getPreferredCanvasFormat(), seperti yang ditunjukkan di atas.

Membersihkan Kanvas

Kini setelah Anda memiliki perangkat dan kanvas yang telah dikonfigurasi dengan perangkat tersebut, Anda dapat mulai menggunakan perangkat untuk mengubah konten kanvas. Pertama-tama, bersihkan dengan warna yang solid.

Guna melakukannya—atau melakukan hampir segala sesuatu di WebGPU—Anda perlu memberikan beberapa perintah kepada GPU, untuk memberitahukannya tentang apa yang harus dilakukan.

  1. Guna melakukannya, minta perangkat untuk membuat GPUCommandEncoder, yang menyediakan antarmuka untuk merekam perintah GPU.

index.html

const encoder = device.createCommandEncoder();

Perintah-perintah yang perlu Anda kirimkan ke GPU terkait dengan rendering (dalam kasus ini, membersihkan kanvas), jadi langkah berikutnya adalah menggunakan encoder untuk memulai Penerusan Render.

Penerusan render adalah waktu terjadinya semua operasi penggambaran di WebGPU. Setiap penerusan render dimulai dengan panggilan beginRenderPass(), yang menentukan tekstur yang menerima output dari setiap perintah gambar yang dilakukan. Penggunaan lanjutan dapat menyediakan beberapa tekstur, disebut sebagai lampiran, dengan berbagai tujuan seperti menyimpan kedalaman geometri yang dirender atau menyediakan anti-aliasing. Namun, untuk aplikasi ini, Anda hanya memerlukan satu tekstur.

  1. Dapatkan tekstur dari konteks kanvas yang telah Anda buat sebelumnya dengan memanggil context.getCurrentTexture(), yang menampilkan tekstur dengan lebar dan tinggi piksel yang sesuai dengan atribut width dan height kanvas, serta format yang ditentukan saat Anda memanggil context.configure().

index.html

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

Tekstur tersebut akan diberikan sebagai properti view dari colorAttachment. Penerusan render mengharuskan Anda menyediakan GPUTextureView, bukan GPUTexture, yang memberi tahu bagian mana dari tekstur yang akan dirender. Ini hanya akan diperlukan untuk kasus penggunaan lanjutan, jadi di sini Anda memanggil createView() tanpa argumen pada tekstur, yang menunjukkan bahwa Anda ingin agar penerusan render menggunakan seluruh tekstur.

Anda juga harus menentukan apa yang ingin Anda lakukan pada tekstur saat penerusan render dimulai dan saat berakhir:

  • Nilai loadOp dari "clear" menunjukkan bahwa Anda ingin tekstur dibersihkan saat penerusan render dimulai.
  • Nilai storeOp dari "store" menunjukkan bahwa begitu penerusan render selesai, Anda ingin hasil dari setiap gambar yang dilakukan selama penerusan render disimpan ke dalam tekstur.

Setelah penerusan render dimulai, Anda tidak perlu melakukan apa pun. Setidaknya untuk saat ini. Tindakan memulai penerusan render dengan loadOp: "clear" sudah cukup untuk membersihkan tampilan tekstur dan kanvas.

  1. Akhiri penerusan render dengan menambahkan panggilan berikutnya segera setelah beginRenderPass():

index.html

pass.end();

Penting untuk diketahui bahwa melakukan panggilan ini saja tidak akan membuat GPU melakukan suatu tugas. Panggilan tersebut hanya merekam perintah tersebut untuk nantinya dijalankan oleh GPU.

  1. Untuk membuat GPUCommandBuffer, panggil finish() di encoder perintah. Buffer perintah adalah penanganan yang tidak terlihat untuk perintah yang telah direkam.

index.html

const commandBuffer = encoder.finish();
  1. Kirim buffer perintah ke GPU menggunakan queue dari GPUDevice. Antrean akan menjalankan semua perintah GPU, memastikan bahwa eksekusinya dalam urutan yang benar dan disinkronkan dengan baik. Metode submit() antrean mengambil array dari buffer perintah, meskipun dalam kasus ini Anda hanya memiliki satu buffer perintah.

index.html

device.queue.submit([commandBuffer]);

Setelah Anda mengirimkan command buffer, command buffer tidak dapat digunakan lagi, jadi Anda tidak perlu menyimpannya. Jika ingin mengirimkan lebih banyak perintah, Anda perlu membangun command buffer lain. Itulah mengapa cukup umum melihat dua langkah tersebut digabungkan menjadi satu, seperti yang dilakukan pada halaman contoh untuk codelab ini:

index.html

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

Setelah Anda mengirimkan perintah ke GPU, biarkan JavaScript mengembalikan kontrol ke browser. Pada tahap ini, browser melihat bahwa Anda telah mengubah tekstur konteks saat ini dan akan memperbarui kanvas untuk menampilkan tekstur tersebut sebagai gambar. Jika ingin memperbarui konten kanvas lagi setelah itu, Anda perlu merekam dan mengirimkan buffer perintah baru, dengan memanggil kembali context.getCurrentTexture() guna mendapatkan tekstur baru untuk suatu penerusan render.

  1. Muat ulang halaman. Perhatikan bahwa kanvas menampilkan warna hitam solid. Selamat! Ini berarti bahwa Anda telah berhasil membuat aplikasi WebGPU pertama Anda.

Kanvas berwarna hitam yang menunjukkan bahwa WebGPU telah berhasil digunakan untuk membersihkan konten kanvas.

Memilih warna

Sejujurnya, persegi berwarna hitam terlihat membosankan. Jadi, luangkan waktu untuk sedikit mempersonalisasinya sebelum lanjut ke bagian berikutnya.

  1. Dalam panggilan device.beginRenderPass(), tambahkan baris baru dengan clearValue ke colorAttachment, seperti ini:

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 memberi petunjuk pada penerusan render terkait warna apa yang harus digunakan saat melakukan operasi clear di awal penerusan render. Kamus yang diteruskan padanya berisi empat nilai: r untuk merah, g untuk hijau, b untuk biru, dan a untuk alpha (transparansi). Setiap nilai dapat berkisar dari 0 hingga 1, dan gabungan dari nilai-nilai ini menjelaskan nilai saluran warna tersebut. Contoh:

  • { r: 1, g: 0, b: 0, a: 1 } adalah merah terang.
  • { r: 1, g: 0, b: 1, a: 1 } adalah ungu terang.
  • { r: 0, g: 0.3, b: 0, a: 1 } adalah hijau tua.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } adalah abu-abu muda.
  • { r: 0, g: 0, b: 0, a: 0 } adalah nilai default, hitam transparan.

Kode contoh dan screenshot dalam codelab ini menggunakan warna biru tua, tetapi Anda bebas memilih warna apa pun yang Anda inginkan.

  1. Setelah memilih warna, muat ulang halaman. Seharusnya Anda akan melihat warna yang dipilih dalam kanvas.

Kanvas dibersihkan menjadi warna biru tua untuk menunjukkan cara mengubah warna bersih default.

4. Menggambar geometri

Pada akhir bagian ini, aplikasi Anda akan menggambar beberapa geometri sederhana ke kanvas: yaitu sebuah persegi berwarna. Perlu diingat bahwa dalam proses ini mungkin terasa ada terlalu banyak tugas untuk output yang begitu sederhana, tetapi hal ini karena WebGPU dirancang untuk merender banyak geometri dengan sangat efisien. Efek samping dari efisiensi ini adalah, melakukan hal-hal yang relatif sederhana mungkin terasa sangat kompleks, tetapi itulah yang akan terjadi jika Anda menggunakan API seperti WebGPU—Anda akan melakukan suatu proses dengan sedikit lebih kompleks.

Memahami cara GPU menggambar

Sebelum melakukan perubahan kode lebih lanjut, sangat penting untuk membaca rangkuman yang sangat ringkas dan disederhanakan tentang bagaimana GPU menciptakan bentuk yang Anda lihat di layar. (Anda bisa langsung menuju ke bagian Menentukan Verteks jika Anda sudah familier dengan dasar-dasar cara kerja rendering GPU.)

Berbeda dengan API seperti Canvas 2D yang memiliki banyak bentuk dan opsi yang siap digunakan, GPU sebenarnya hanya menangani beberapa jenis bentuk yang berbeda (atau yang disebut sebagai primitif oleh WebGPU): titik, garis, dan segitiga. Untuk tujuan codelab ini, Anda hanya akan menggunakan segitiga.

GPU hampir secara eksklusif menggunakan segitiga, karena segitiga memiliki banyak sifat matematis yang bagus, sehingga mudah diproses dengan cara yang dapat diprediksi dan efisien. Hampir semua yang Anda gambar dengan GPU perlu dipecah menjadi segitiga sebelum GPU dapat menggambarnya, dan segitiga-segitiga itu harus ditentukan oleh titik sudutnya.

Titik-titik ini, atau verteks, diberikan dalam nilai X, Y, dan (untuk konten 3D) nilai Z yang menentukan suatu titik pada sistem koordinat kartesian yang ditentukan oleh WebGPU atau API serupa. Struktur sistem koordinat ini paling mudah dipahami dengan membayangkannya dalam hubungannya dengan kanvas di halaman Anda. Terlepas dari seberapa lebar atau tinggi kanvas Anda, tepi kiri selalu berada di -1 pada sumbu X, dan tepi kanan selalu berada di +1 pada sumbu X. Demikian pula, tepi bawah selalu berada di -1 pada sumbu Y, dan tepi atas selalu berada di +1 pada sumbu Y. Artinya, (0, 0) selalu menjadi pusat kanvas, (-1, -1) selalu menjadi sudut kiri bawah, dan (1, 1) selalu menjadi sudut kanan atas. Ini disebut sebagai Ruang Klip (Clip Space).

Grafik sederhana yang memvisualisasikan ruang Normalized Device Coordinate.

Pada awalnya, verteks jarang ditentukan dalam sistem koordinat ini, sehingga GPU mengandalkan program kecil yang disebut shader verteks untuk melakukan perhitungan matematika yang diperlukan guna mengubah verteks ke dalam ruang klip, serta perhitungan lain yang diperlukan untuk menggambar verteks. Misalnya, shader tersebut dapat menerapkan beberapa animasi atau menghitung arah dari verteks ke sumber cahaya. Shader ini ditulis oleh Anda, yang merupakan developer WebGPU, dan shader ini memberi kontrol yang luar biasa atas fungsi GPU.

Selanjutnya, GPU mengambil semua segitiga yang terbentuk oleh verteks yang telah diubah ini dan menentukan piksel mana di layar yang diperlukan untuk menggambarnya. Kemudian, GPU menjalankan program kecil lain yang Anda tulis, yang disebut shader fragmen, yang menghitung warna apa yang seharusnya dimiliki setiap piksel. Perhitungan tersebut dapat begitu sederhana, seperti menampilkan warna hijau atau begitu kompleks, sehingga perlu untuk menghitung sudut permukaan relatif terhadap cahaya matahari yang memantul dari permukaan lain di sekitarnya, yang disaring melalui kabut, dan dimodifikasi oleh jenis logam permukaannya. Semuanya sepenuhnya berada di bawah kontrol Anda, yang dapat memberikan kontrol yang luar biasa, tetapi juga dapat menjadi cukup menantang.

Hasil warna piksel tersebut kemudian dikumpulkan ke dalam tekstur, yang kemudian dapat ditampilkan di layar.

Menentukan verteks

Seperti yang telah disebutkan sebelumnya, simulasi Game of Life ditampilkan sebagai grid sel. Aplikasi Anda memerlukan cara untuk memvisualisasikan grid tersebut, untuk membedakan sel aktif dari sel yang tidak aktif. Pendekatan yang digunakan oleh codelab ini adalah dengan menggambar persegi berwarna pada sel aktif dan membiarkan sel yang tidak aktif kosong.

Ini berarti Anda perlu memberi GPU empat titik berbeda, dengan satu titik untuk masing-masing dari empat sudut persegi. Misalnya, sebuah persegi yang digambar di tengah kanvas, ditarik jauh dari pinggiran, akan memiliki koordinat sudut seperti ini:

Grafik Normalized Device Coordinate yang menunjukkan koordinat untuk sudut persegi.

Untuk memberikan feed koordinat tersebut ke GPU, Anda perlu menempatkan nilai-nilai tersebut dalam TypedArray. Jika Anda belum mengetahuinya, TypedArray adalah sekelompok objek JavaScript yang memungkinkan Anda mengalokasikan blok memori yang berurutan dan menginterpretasikan setiap elemen dalam seri sebagai jenis data tertentu. Misalnya, dalam Uint8Array, setiap elemen dalam array adalah byte tunggal tanpa label. TypedArray sangat berguna untuk mengirimkan data secara bolak-balik dengan API yang peka terhadap tata letak memori, seperti WebAssembly, WebAudio, dan (tentu saja) WebGPU.

Untuk contoh persegi, karena nilainya fraksional, Float32Array akan sesuai.

  1. Buatlah sebuah array yang menyimpan semua posisi verteks dalam diagram dengan menempatkan deklarasi array berikut di dalam kode Anda. Tempat yang baik untuk meletakkannya adalah di dekat bagian atas, tepat di bawah panggilan 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,
]);

Perhatikan bahwa spasi dan komentar tidak memiliki efek pada nilai tersebut; ini untuk menyederhanakannya, sehingga membuatnya lebih mudah dibaca. Ini dapat membantu Anda melihat bahwa setiap pasangan nilai membentuk koordinat X dan Y untuk satu verteks.

Namun, ada satu masalah. Masih ingat bahwa GPU bekerja menggunakan segitiga? Artinya, Anda harus menyediakan verteks dalam kelompok berisi tiga verteks. Anda memiliki satu kelompok yang berisi empat verteks. Solusinya adalah mengulang dua verteks untuk membuat dua segitiga yang berbagi satu tepi melalui tengah persegi.

Diagram yang menunjukkan bagaimana empat verteks persegi akan digunakan untuk membentuk dua segitiga.

Untuk membentuk persegi dari diagram, Anda harus mencantumkan verteks (-0,8, -0,8) dan (0,8, 0,8) dua kali, sekali untuk segitiga biru dan sekali lagi untuk segitiga merah. (Anda juga bisa memilih untuk membagi persegi dengan dua sudut lainnya; tidak ada bedanya.)

  1. Perbarui array vertices sebelumnya agar terlihat kurang lebih seperti ini:

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

Meskipun diagram menunjukkan pemisahan antara dua segitiga agar lebih jelas, posisi verteksnya sama persis, dan GPU merendernya tanpa adanya pemisahan ini. Segitiga akan dirender sebagai persegi tunggal dan solid.

Membuat buffer verteks

GPU tidak dapat menggambar verteks dengan data dari array JavaScript. GPU sering kali memiliki memori sendiri yang sangat dioptimalkan untuk rendering, sehingga setiap data yang Anda inginkan untuk digunakan oleh GPU saat menggambar harus ditempatkan di dalam memori tersebut.

Untuk banyak nilai, termasuk data verteks, memori GPU diatur melalui objek GPUBuffer. Buffer adalah blok memori yang mudah diakses oleh GPU dan diberi flag untuk tujuan tertentu. Anda dapat menganggapnya sebagai TypedArray yang terlihat oleh GPU.

  1. Untuk membuat buffer yang menyimpan verteks Anda, tambahkan panggilan berikut ke device.createBuffer() setelah definisi array vertices.

index.html

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

Hal pertama yang perlu diingat adalah memberikan label pada buffer. Setiap objek WebGPU yang Anda buat dapat diberikan label opsional, dan Anda biasanya perlu untuk melakukannya. Label tersebut dapat berupa string apa pun yang membantu Anda mengidentifikasi objek. Jika Anda mengalami masalah, label tersebut digunakan dalam pesan error yang dihasilkan oleh WebGPU untuk membantu Anda memahami apa yang menyebabkan error.

Selanjutnya beri ukuran buffer dalam byte. Anda memerlukan buffer dengan ukuran 48 byte, yang Anda tentukan dengan mengalikan ukuran float 32-bit (4 byte) dengan jumlah float dalam array vertices (12). Untungnya, TypedArrays sudah menghitungbyteLength tersebut untuk Anda, sehingga Anda dapat menggunakannya saat membuat buffer.

Terakhir, Anda perlu menentukan penggunaan buffer. Ini adalah satu atau beberapa flag GPUBufferUsage, dengan beberapa flag yang digabungkan dengan operator | (OR bitwise). Dalam kasus ini, Anda menentukan bahwa Anda ingin agar buffer digunakan untuk data verteks (GPUBufferUsage.VERTEX) dan bahwa Anda juga ingin agar dapat menyalin data ke dalamnya (GPUBufferUsage.COPY_DST).

Objek buffer yang ditampilkan kepada Anda buram—Anda tidak dapat (dengan mudah) memeriksa data yang disimpan di dalamnya. Selain itu, sebagian besar atributnya tidak dapat diubah—Anda tidak dapat mengubah ukuran GPUBuffer setelah dibuat, dan Anda juga tidak dapat mengubah flag penggunaan. Yang dapat Anda ubah adalah konten dari memori buffer tersebut.

Ketika buffer dibuat, memori di dalamnya akan diinisialisasi menjadi nol. Ada beberapa cara untuk mengubah kontennya, tetapi cara yang paling mudah adalah dengan memanggil device.queue.writeBuffer() dengan TypedArray yang memorinya ingin Anda salin.

  1. Untuk menyalin data verteks ke dalam memori buffer, tambahkan kode berikut:

index.html

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

Menentukan tata letak verteks

Kini Anda memiliki buffer dengan data verteks di dalamnya, tetapi bagi keperluan GPU, data tersebut hanyalah merupakan blob byte. Anda perlu menyediakan sedikit informasi tambahan jika akan menggambar sesuatu menggunakan buffer. Anda perlu dapat memberi WebGPU informasi yang lebih banyak tentang struktur data verteks.

index.html

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

Awalnya ini mungkin terlihat membingungkan, tetapi sebenarnya ini cukup mudah untuk diuraikan.

Hal pertama yang Anda berikan adalah arrayStride. Ini adalah jumlah byte yang perlu dilewati GPU di dalam buffer ketika mencari verteks berikutnya. Setiap verteks dari persegi Anda terdiri dari dua angka floating point 32-bit. Seperti yang disebutkan sebelumnya, float 32-bit adalah 4 byte, jadi dua float adalah 8 byte.

Selanjutnya adalah properti attributes, yang merupakan array. Atribut adalah potongan informasi individu yang dienkode ke setiap verteks. Verteks Anda hanya memiliki satu atribut (posisi verteks), tetapi dalam penggunaan lanjutan, sering kali terdapat verteks dengan beberapa atribut di dalamnya, seperti warna verteks atau arah permukaan geometri. Namun, hal ini tidak dibahas dalam codelab ini.

Dalam satu atribut, pertama-tama Anda menentukan format data. Ini berasal dari daftar jenis GPUVertexFormat yang menjelaskan setiap jenis data verteks yang dapat dipahami oleh GPU. Verteks Anda masing-masing memiliki dua float 32-bit, jadi Anda menggunakan format float32x2. Jika data verteks Anda terdiri dari empat bilangan bulat 16-bit yang masing-masing tidak memiliki label, misalnya, Anda akan menggunakan uint16x4. Apakah Anda melihat polanya?

Selanjutnya, offset menggambarkan berapa byte atribut ini dimulai di dalam verteks. Anda sebenarnya hanya perlu memikirkan tentang ini jika buffer Anda memiliki lebih dari satu atribut di dalamnya, yang tidak akan muncul selama codelab ini.

Terakhir, Anda memiliki shaderLocation. Ini adalah angka arbitrer antara 0 dan 15 dan harus unik untuk setiap atribut yang Anda tentukan. Ini menghubungkan atribut ini ke input tertentu dalam shader verteks, yang akan Anda pelajari pada bagian berikutnya.

Perhatikan bahwa meskipun Anda menentukan nilai-nilai ini sekarang, Anda sebenarnya masih belum meneruskan nilai ini ke dalam WebGPU API di mana pun. Hal tersebut akan dilakukan nanti, tetapi akan memudahkan jika Anda memikirkan nilai-nilai ini pada saat menentukan verteks, jadi Anda menyiapkannya sekarang untuk digunakan nanti.

Memulai dengan shader

Sekarang Anda memiliki data yang ingin dirender, tetapi Anda masih perlu memberi tahu GPU cara memprosesnya. Sebagian besar pemrosesan tersebut terjadi dengan shader.

Shader adalah program kecil yang Anda tulis dan jalankan di GPU. Setiap shader beroperasi pada stage data yang berbeda: pemrosesan Verteks, pemrosesan Fragmen, atau Komputasi umum. Karena shader berada di GPU, struktur shader akan lebih kaku daripada JavaScript pada umumnya. Namun, struktur itu memungkinkan shader untuk menjalankan tugas dengan sangat cepat dan, utamanya, secara paralel.

Shader di WebGPU ditulis dalam bahasa shading yang disebut WGSL (WebGPU Shading Language). WGSL, secara sintaksis, agak mirip dengan Rust, dengan fitur yang ditujukan untuk membuat jenis pekerjaan GPU umum (seperti vektor dan matriks matematika) dengan lebih mudah dan lebih cepat. Mengajarkan seluruh bahasa shading jauh melampaui cakupan codelab ini, tetapi semoga Anda akan memahami dasar-dasarnya saat Anda melihat beberapa contoh sederhana.

Shadernya sendiri diteruskan ke WebGPU sebagai string.

  • Buat tempat untuk memasukkan kode shader Anda dengan menyalin berikut ini ke dalam kode Anda di bawah vertexBufferLayout:

index.html

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

Untuk membuat shader, Anda memanggil device.createShaderModule(), tempat Anda memberikan label opsional dan code WGSL sebagai string. (Perhatikan bahwa Anda menggunakan tanda kutip di sini untuk memungkinkan string multi-baris.) Setelah Anda menambahkan beberapa kode WGSL yang valid, fungsi tersebut menampilkan objek GPUShaderModule dengan hasil yang sudah dikompilasi.

Menentukan shader verteks

Mulailah dengan shader verteks karena ini juga menjadi tempat GPU memulai pemrosesannya.

Shader verteks ditentukan sebagai fungsi, dan GPU memanggil fungsi tersebut satu kali untuk setiap verteks dalam vertexBuffer Anda. Karena vertexBuffer memiliki enam posisi (verteks) di dalamnya, fungsi yang Anda tentukan akan dipanggil enam kali. Setiap kali dipanggil, posisi yang berbeda dari vertexBuffer dikirimkan ke fungsi sebagai argumen, dan tugas dari fungsi shader verteks adalah menampilkan posisi yang sesuai dalam ruang klip.

Penting untuk dipahami bahwa fungsi ini tidak selalu dipanggil secara berurutan. Namun, GPU sangat baik dalam menjalankan shader seperti ini secara paralel, yang berpotensi memproses ratusan (atau bahkan ribuan) verteks pada saat bersamaan. Kemampuan inilah yang memungkinkan kecepatan luar biasa GPU, tetapi ini memiliki batasan. Untuk memastikan paralelisasi ekstrem, shader verteks tidak dapat berkomunikasi dengan satu sama lain. Setiap panggilan shader hanya dapat melihat data untuk satu verteks pada satu waktu, dan hanya dapat menghasilkan nilai untuk satu verteks.

Dalam WGSL, fungsi shader verteks dapat dinamai sesuai keinginan Anda, tetapi harus memiliki atribut @vertex di depannya untuk menunjukkan stage shader mana yang diwakilinya. WGSL menunjukkan fungsi dengan kata kunci fn, menggunakan tanda kurung untuk mendeklarasikan argumen, serta menggunakan kurung kurawal untuk menentukan cakupan.

  1. Buatlah fungsi @vertex kosong, seperti ini:

index.html (kode createShaderModule)

@vertex
fn vertexMain() {

}

Namun, itu tidak valid, karena sebuah shader verteks harus menampilkan setidaknya posisi akhir dari verteks yang sedang diproses dalam ruang klip. Ini selalu diberikan sebagai vektor 4 dimensi. Vektor adalah hal yang umum digunakan dalam shader, sehingga vektor diperlakukan sebagai primitif kelas pertama dalam bahasa tersebut, dengan jenisnya sendiri, seperti vec4f untuk vektor 4 dimensi. Ada jenis serupa untuk vektor 2D (vec2f) serta vektor 3D (vec3f).

  1. Untuk menunjukkan bahwa nilai yang ditampilkan adalah posisi yang diperlukan, tandai dengan atribut @builtin(position). Simbol -> digunakan untuk menunjukkan bahwa ini adalah apa yang ditampilkan oleh fungsi.

index.html (kode createShaderModule)

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

}

Tentu saja, jika fungsi memiliki jenis nilai yang ditampilkan, Anda perlu benar-benar menampilkan nilai dalam isi fungsi. Anda dapat membuat vec4f baru untuk ditampilkan, menggunakan sintaks vec4f(x, y, z, w). Semua nilai x, y, dan z adalah angka floating point yang, dalam nilai yang ditampilkan, menunjukkan posisi verteks dalam ruang klip.

  1. Tampilkan nilai statis (0, 0, 0, 1), dan secara teknis Anda memiliki shader verteks yang valid, meski nilai satu tidak akan pernah menampilkan apa pun, karena GPU mengenali bahwa segitiga yang dihasilkannya hanya memiliki satu titik, lalu akan menghapusnya.

index.html (kode createShaderModule)

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

Sebaiknya Anda menggunakan data dari buffer yang Anda buat, dan Anda melakukannya dengan mendeklarasikan argumen untuk fungsi dengan atribut @location() dan jenis yang sesuai dengan yang dideskripsikan dalam vertexBufferLayout. Anda menentukan shaderLocation sebesar 0, jadi dalam kode WGSL Anda, tandai argumen dengan @location(0). Anda juga menentukan format sebagai float32x2, yang merupakan vektor 2D, sehingga dalam WGSL argumen Anda adalah vec2f. Anda dapat menamainya sesuai keinginan, tetapi karena ini mewakili posisi verteks Anda, nama seperti pos akan natural.

  1. Ubah fungsi shader Anda menjadi kode berikut:

index.html (kode createShaderModule)

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

Sekarang Anda perlu menampilkan posisi tersebut. Karena posisinya adalah vektor 2D dan jenis nilai yang ditampilkan adalah vektor 4D, Anda perlu sedikit mengubahnya. Anda perlu mengambil dua komponen dari argumen posisi tersebut dan menempatkannya di dua komponen pertama vektor yang ditampilkan, serta membiarkan dua komponen terakhir sebagai 0 dan 1.

  1. Tampilkan posisi yang benar dengan secara eksplisit menyatakan komponen posisi yang akan digunakan:

index.html (kode createShaderModule)

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

Namun, karena jenis pemetaan seperti ini sangat umum dalam shader, Anda juga dapat meneruskan vektor posisi sebagai argumen pertama dengan singkatan yang mudah, dan ini akan memberikan hasil yang sama.

  1. Tulis kembali pernyataan return dengan kode berikut:

index.html (kode createShaderModule)

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

Itulah shader verteks pertama Anda. Shader verteks ini sangat sederhana, yang hanya meneruskan posisi tanpa melakukan perubahan yang signifikan, tetapi ini sudah cukup sebagai permulaan.

Menentukan shader fragmen

Selanjutnya adalah shader fragmen. Shader fragmen beroperasi dengan cara yang sangat serupa dengan shader verteks, tetapi bukannya dipanggil untuk setiap verteks, shader fragmen dipanggil untuk setiap piksel yang digambar.

Shader fragmen selalu dipanggil setelah shader verteks. GPU mengambil output dari shader verteks dan melakukan triangulasi pada shader verteks tersebut, membuat segitiga dari setiap tiga titik. Kemudian, GPU merasterisasi setiap segitiga tersebut dengan menentukan piksel mana dari lampiran warna output yang disertakan dalam segitiga tersebut, lalu memanggil shader fragmen satu kali untuk setiap piksel tersebut. Shader fragmen menampilkan warna, yang biasanya dihitung dari nilai yang dikirim kepadanya dari shader verteks dan aset seperti tekstur, yang ditulis oleh GPU ke lampiran warna.

Seperti halnya shader verteks, shader fragmen dijalankan secara paralel dalam jumlah besar. Shader fragmen sedikit lebih fleksibel dibandingkan dengan shader verteks dalam hal input dan output, tetapi Anda dapat menganggap bahwa shader ini hanya menampilkan satu warna untuk setiap piksel dari setiap segitiga.

Fungsi shader fragmen WGSL ditandai dengan atribut @fragment dan juga menampilkan vec4f. Namun, dalam kasus ini, vektor tersebut mewakili warna, bukan posisi. Nilai yang ditampilkan perlu diberi atribut @location untuk menunjukkan ke colorAttachment mana dari pemanggilan beginRenderPass warna yang ditampilkan akan ditulis. Karena Anda hanya memiliki satu lampiran, lokasinya adalah 0.

  1. Buat fungsi @fragment kosong, seperti ini:

index.html (kode createShaderModule)

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

}

Keempat komponen dari vektor yang ditampilkan adalah nilai warna merah, hijau, biru, dan alpha, yang diinterpretasikan dengan cara yang sama seperti clearValue yang Anda setel di beginRenderPass sebelumnya. Jadi, vec4f(1, 0, 0, 1) adalah merah terang, yang sepertinya adalah warna yang baik untuk persegi Anda. Namun, Anda bebas menyetelnya ke warna apa pun yang diinginkan.

  1. Setel vektor warna yang ditampilkan, seperti ini:

index.html (kode createShaderModule)

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

Itulah shader fragmen yang lengkap. Shader fragmen ini memang tidak begitu menarik; shader fragmen ini hanya menyetel setiap piksel dari setiap segitiga menjadi merah, tetapi itu sudah cukup untuk saat ini.

Sebagai ringkasan, setelah menambahkan kode shader yang dijelaskan di atas, pemanggilan createShaderModule Anda sekarang akan terlihat seperti ini:

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

Membuat dan merender pipeline

Sebuah modul shader tidak dapat digunakan untuk melakukan rendering secara mandiri. Namun, Anda harus menggunakannya sebagai bagian dari GPURenderPipeline, yang dibuat dengan memanggil device.createRenderPipeline(). Pipeline render mengontrol cara geometri digambar, termasuk hal-hal seperti shader apa yang digunakan, cara menginterpretasikan data dalam buffer verteks, jenis geometri apa yang harus digambar (garis, titik, segitiga...), dan masih banyak lagi.

Render pipeline adalah objek paling kompleks dalam seluruh API, tetapi jangan khawatir. Sebagian besar nilai yang dapat Anda teruskan kepadanya bersifat opsional, dan Anda hanya perlu menyediakan beberapa nilai untuk memulai.

  • Buat pipeline render, seperti ini:

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

Setiap pipeline memerlukan layout yang menjelaskan jenis input apa (selain dari buffer verteks) yang dibutuhkan oleh pipeline, tetapi pada dasarnya Anda tidak memiliki input apa pun. Untungnya, Anda dapat meneruskan "auto" untuk saat ini, dan pipeline akan membangun tata letaknya sendiri dari shader.

Selanjutnya, Anda harus memberikan detail tentang stage vertex. module adalah GPUShaderModule yang berisi shader verteks, dan entryPoint memberikan nama fungsi dalam kode shader yang dipanggil untuk setiap pemanggilan verteks. (Anda dapat memiliki beberapa fungsi @vertex dan @fragment dalam satu modul shader.) Buffer adalah array objek GPUVertexBufferLayout yang menjelaskan bagaimana data Anda dikemas dalam buffer verteks yang digunakan dengan pipeline ini. Untungnya, Anda sudah menentukannya sebelumnya dalam vertexBufferLayout. Inilah tempat Anda meneruskan informasinya.

Terakhir, Anda memiliki detail tentang stage fragment. Ini juga mencakup modul shader dan entryPoint, seperti stage verteks. Bagian terakhir adalah menentukan targets yang digunakan oleh pipeline ini. Ini adalah array objek yang memberikan detail—seperti format tekstur—dari lampiran warna yang dihasilkan oleh output pipeline. Detail ini harus sesuai dengan tekstur yang diberikan dalam colorAttachments dari setiap penerusan render yang menggunakan pipeline ini. Penerusan render Anda menggunakan tekstur dari konteks kanvas, dan menggunakan nilai yang disimpan dalam canvasFormat untuk formatnya, jadi Anda meneruskan format yang sama di sini.

Itu bahkan belum mendekati semua opsi yang dapat Anda tentukan saat membuat pipeline render, tetapi sudah cukup untuk kebutuhan codelab ini.

Menggambar persegi

Dengan ini, kini Anda memiliki semua yang Anda butuhkan untuk menggambar persegi.

  1. Untuk menggambar persegi, kembali ke pasangan panggilan encoder.beginRenderPass() dan pass.end(), lalu tambahkan perintah baru ini di antara keduanya:

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

Ini memberi WebGPU semua informasi yang diperlukan untuk menggambar persegi. Pertama, Anda menggunakan setPipeline() untuk menunjukkan pipeline mana yang harus digunakan untuk menggambar. Ini mencakup penggunaan shader, tata letak data verteks, dan data status lain yang relevan.

Selanjutnya, Anda memanggil setVertexBuffer() dengan buffer yang berisi verteks untuk persegi Anda. Anda memanggilnya dengan 0 karena buffer ini sesuai dengan elemen ke-0 pada definisi vertex.buffers pipeline saat ini.

Terakhir, Anda melakukan panggilan draw(), yang terlihat cukup sederhana setelah semua persiapan yang sudah dilakukan sebelumnya. Satu-satunya hal yang perlu Anda teruskan adalah jumlah verteks yang harus dirender, yang diambil dari buffer verteks yang saat ini disetel dan diinterpretasikan dengan pipeline yang saat ini disetel. Anda bisa saja menetapkannya secara manual menjadi 6, tetapi menghitungnya dari array verteks (12 float / 2 koordinat per verteks == 6 verteks) berarti bahwa jika Anda memutuskan untuk mengganti persegi dengan, misalnya, lingkaran, ada lebih sedikit yang perlu diperbarui secara manual.

  1. Refresh layar Anda dan (akhirnya) lihat hasil dari semua kerja keras Anda: satu persegi berwarna dan berukuran besar.

Satu persegi berwarna merah yang dirender dengan WebGPU

5. Menggambar grid

Pertama, ucapkan selamat pada diri Anda. Mendapatkan potongan pertama geometri di layar sering kali menjadi salah satu langkah paling sulit dalam sebagian besar API GPU. Semua yang Anda lakukan dari sini bisa dilakukan dalam langkah-langkah yang lebih kecil, sehingga memudahkan Anda untuk memverifikasi progres Anda.

Di bagian ini, Anda akan mempelajari:

  • Cara meneruskan variabel (yang disebut uniform) ke shader dari JavaScript.
  • Cara menggunakan uniform untuk mengubah perilaku rendering.
  • Cara menggunakan instancing untuk menggambar banyak variasi yang berbeda dari geometri yang sama.

Menentukan grid

Untuk merender grid, Anda perlu mengetahui informasi yang sangat mendasar tentangnya. Berapa banyak sel yang dimilikinya, baik dalam hal lebar maupun tingginya? Ini bergantung pada Anda sebagai developer, tetapi untuk memudahkan, anggaplah grid sebagai persegi (yang sama lebar dan tingginya) dan gunakan ukuran yang merupakan pangkat dua. (Ini nantinya akan membuat perhitungan lain lebih mudah.) Nantinya Anda perlu untuk membuatnya lebih besar, tetapi untuk bagian ini, setel ukuran grid menjadi 4 x 4 karena ini memudahkan untuk menunjukkan beberapa perhitungan yang digunakan dalam bagian ini. Anda dapat meningkatkan skalanya setelah itu.

  • Tentukan ukuran grid dengan menambahkan konstanta ke bagian atas kode JavaScript.

index.html

const GRID_SIZE = 4;

Selanjutnya, Anda perlu memperbarui cara merender persegi Anda sehingga GRID_SIZE kali GRID_SIZE-nya bisa pas di dalam kanvas. Ini berarti persegi perlu jauh lebih kecil, dan perseginya harus ada banyak.

Salah satu cara yang bisa Anda lakukan adalah dengan membuat buffer verteks Anda jauh lebih besar dan menentukan persegi senilai GRID_SIZE kali GRID_SIZE di dalamnya, dengan ukuran dan posisi yang tepat. Kodenya tidak akan terlalu kompleks. Hanya beberapa loop for dan sedikit perhitungan. Namun, pendekatan ini juga tidak memaksimalkan penggunaan GPU dan akan menggunakan lebih banyak memori dari yang diperlukan untuk mencapai efek tersebut. Bagian ini membahas pendekatan yang lebih ramah GPU.

Membuat buffer uniform

Pertama, Anda perlu mengomunikasikan ukuran grid yang dipilih ke shader, karena shader akan menggunakannya untuk mengubah tampilan. Anda bisa saja membuat hard code ukuran ke dalam shader, tetapi hal ini berarti bahwa setiap kali Anda ingin mengubah ukuran grid, Anda harus membuat ulang shader dan pipeline render. Proses ini akan memakan biaya. Cara yang lebih baik adalah memberikan ukuran grid ke shader sebagai uniform.

Anda sudah belajar sebelumnya bahwa nilai buffer verteks yang berbeda diteruskan ke setiap panggilan shader verteks. Uniform adalah nilai dari buffer yang sama untuk setiap panggilan. Uniform berguna dalam mengomunikasikan nilai-nilai yang umum untuk suatu potongan geometri (seperti posisinya), bingkai animasi penuh (seperti waktu saat ini), atau bahkan selama seluruh masa pakai aplikasi (seperti preferensi pengguna).

  • Buat buffer uniform dengan menambahkan kode berikut:

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

Kode ini akan terlihat sangat familier, karena hampir sama persis dengan kode yang Anda gunakan untuk membuat buffer verteks sebelumnya. Itu karena uniform dikomunikasikan ke WebGPU API melalui objek GPUBuffer yang sama dengan verteks, yang perbedaan utamanya adalah penggunaan usage kali ini mencakup GPUBufferUsage.UNIFORM, dan bukan GPUBufferUsage.VERTEX.

Mengakses uniform dalam shader

  • Tentukan uniform dengan menambahkan kode berikut:

index.html (Panggilan 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 

Ini menentukan uniform dalam shader Anda, yang disebut grid, yang merupakan vektor float 2D yang sesuai dengan array yang baru saja disalin ke dalam buffer uniform. Ini juga menentukan bahwa uniform terikat pada @group(0) dan @binding(0). Anda akan memahami apa arti nilai-nilai tersebut setelah ini.

Kemudian, di bagian lain dari kode shader, Anda dapat menggunakan vektor grid sesuai kebutuhan. Dalam kode ini, Anda membagi posisi verteks dengan vektor grid. Karena pos adalah vektor 2D dan grid adalah vektor 2D, WGSL melakukan pembagian berdasarkan komponen. Dengan kata lain, hasilnya sama saja dengan vec2f(pos.x / grid.x, pos.y / grid.y).

Operasi vektor semacam ini sangat umum dalam shader GPU karena banyak teknik rendering dan komputasi bergantung pada operasi ini.

Artinya, dalam kasus Anda (jika menggunakan ukuran grid 4), persegi yang digambar akan menjadi seperempat dari ukuran aslinya. Ukuran ini tepat jika Anda ingin memasukkan empat persegi ke dalam satu baris atau kolom.

Membuat Bind Group

Namun, deklarasi uniform dalam shader belum menghubungkannya dengan buffer yang Anda buat. Untuk melakukannya, Anda perlu membuat dan menyetel bind group.

Bind group adalah kumpulan resource yang ingin Anda sediakan ke shader pada saat yang bersamaan. Bind group dapat mencakup beberapa jenis buffer, seperti buffer uniform Anda, dan resource lain seperti tekstur dan samplernya yang tidak dibahas di sini, tetapi merupakan bagian umum dari teknik rendering WebGPU.

  • Buat bind group menggunakan buffer uniform dengan menambahkan kode berikut setelah pembuatan buffer uniform dan pipeline render:

index.html

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

Selain label standar Anda sekarang, Anda juga memerlukan layout yang menjelaskan jenis resource apa yang dimuat oleh bind group ini. Tata letak ini akan Anda pelajari lebih lanjut di langkah selanjutnya, tetapi untuk saat ini, Anda dapat dengan mudah meminta tata letak bind group dari pipeline Anda, karena Anda membuat pipeline dengan layout: "auto". Ini menyebabkan pipeline membuat tata letak bind group secara otomatis dari binding yang Anda deklarasikan dalam kode shader itu sendiri. Dalam kasus ini, Anda memintanya untuk getBindGroupLayout(0), yang mana 0 sesuai dengan @group(0) yang diketik di shader.

Setelah menentukan tata letak, Anda menyediakan array entries. Setiap entri adalah kamus dengan setidaknya nilai berikut:

  • binding, yang sesuai dengan nilai @binding() yang Anda masukkan dalam shader. Dalam kasus ini, 0.
  • resource, yang merupakan resource aktual yang ingin Anda ekspos ke variabel pada indeks binding yang ditentukan. Dalam kasus ini, buffer uniform Anda.

Fungsi ini menampilkan GPUBindGroup, yang merupakan tuas tak terlihat dan tidak dapat diubah. Anda tidak dapat mengubah resource yang ditunjuk oleh bind group setelah dibuat, meskipun Anda dapat mengubah konten resource tersebut. Misalnya, jika Anda mengubah buffer uniform agar berisi ukuran grid yang baru, perubahan tersebut akan tercermin dalam panggilan gambar di masa mendatang yang menggunakan bind group ini.

Melakukan binding pada bind group

Setelah bind group dibuat, Anda masih perlu memberi tahu WebGPU untuk menggunakannya saat menggambar. Untungnya proses ini cukup sederhana.

  1. Kembali ke penerusan render dan tambahkan baris baru ini sebelum metode draw():

index.html

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

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

pass.draw(vertices.length / 2);

0 yang diteruskan sebagai argumen pertama sesuai dengan @group(0) dalam kode shader. Anda menyatakan bahwa setiap @binding yang merupakan bagian dari @group(0) menggunakan resource dalam bind group ini.

Sekarang buffer uniform terekspos ke shader Anda.

  1. Refresh halaman Anda, lalu Anda seharusnya melihat sesuatu seperti ini:

Persegi kecil berwarna merah di tengah-tengah latar belakang biru tua.

Hore! Sekarang ukuran persegi Anda adalah seperempat dari ukurannya sebelumnya! Memang bukan perubahan besar, tetapi itu menunjukkan bahwa uniform Anda benar-benar diterapkan dan bahwa shader sekarang dapat mengakses ukuran grid Anda.

Memanipulasi geometri dalam shader

Jadi sekarang Anda dapat merujuk ukuran grid dalam shader, Anda dapat mulai melakukan beberapa pekerjaan untuk memanipulasi geometri yang digambar agar sesuai dengan pola grid yang diinginkan. Untuk melakukannya, pertimbangkan dengan pasti apa yang ingin Anda capai.

Anda perlu secara konseptual membagi kanvas Anda menjadi sel-sel individual. Untuk menjaga konvensi bahwa sumbu X meningkat saat Anda bergerak ke kanan dan sumbu Y meningkat saat Anda bergerak ke atas, anggap saja bahwa sel pertama berada di sudut kiri bawah kanvas. Ini memberi Anda tata letak yang terlihat seperti ini, dengan geometri persegi Anda saat ini berada di tengah:

Sebuah ilustrasi dari grid konseptual yang akan dibagi dalam ruang Normalized Device Coordinate saat memvisualisasikan setiap sel, dengan geometri persegi yang saat ini sedang dirender di tengahnya.

Tantangan Anda adalah menemukan metode dalam shader yang memungkinkan Anda menempatkan geometri persegi di salah satu sel tersebut berdasarkan koordinat sel.

Pertama, Anda dapat melihat bahwa persegi Anda tidak sejajar dengan salah satu sel karena persegi tersebut ditentukan agar mengelilingi pusat kanvas. Seharusnya persegi bergeser setengah sel agar sejajar dengan batas sel dengan baik di dalam kanvas.

Salah satu cara yang dapat Anda lakukan untuk memperbaikinya adalah dengan memperbarui buffer verteks persegi. Dengan menggeser verteks agar sudut kanan bawahnya berada pada, misalnya, (0,1, 0,1), dan bukan (-0,8, -0,8), Anda akan memindahkan persegi ini agar lebih sejajar dengan batas sel. Namun, karena Anda memiliki kendali penuh atas bagaimana verteks diproses dalam shader, Anda bisa juga dengan mudah memindahkannya ke tempatnya menggunakan kode shader.

  1. Ubah modul shader verteks dengan kode berikut:

index.html (Panggilan 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);
}

Ini akan memindahkan setiap verteks ke atas dan ke kanan sejauh satu unit (yang, ingat, adalah separuh dari ruang klip) sebelum dibagi oleh ukuran grid. Hasilnya adalah persegi yang sejajar dengan baik dengan grid, yang hampir mendekati titik asalnya.

Visualisasi kanvas yang dibagi secara konseptual menjadi grid 4 x 4 dengan sebuah persegi merah di sel (2, 2).

Selanjutnya, karena sistem koordinat kanvas Anda menempatkan (0, 0) di tengah dan (-1, -1) di sudut kiri bawah, seharusnya (0, 0) berada di sudut kiri bawah, sehingga Anda perlu menerjemahkan posisi geometri Anda dengan (-1, -1) setelah dibagi oleh ukuran grid untuk memindahkannya ke sudut tersebut.

  1. Terjemahkan posisi geometri Anda, seperti ini:

index.html (Panggilan 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); 
}

Sekarang persegi Anda berada pada posisi yang baik di sel (0, 0).

Visualisasi kanvas yang secara konseptual terbagi menjadi grid 4 x 4 dengan persegi merah di sel (0, 0)

Apa yang akan terjadi jika Anda ingin menempatkannya di sel yang berbeda? Temukan solusinya dengan menentukan vektor cell dalam shader Anda dan mengisinya dengan nilai statis seperti let cell = vec2f(1, 1).

Jika Anda menambahkannya ke gridPos, tindakan ini akan membatalkan - 1 dalam algoritma, jadi ini bukan hal yang Anda inginkan. Anda hanya ingin memindahkan persegi satu unit grid (seperempat kanvas) untuk setiap sel. Sepertinya Anda perlu melakukan pembagian lagi, dengan grid.

  1. Ubah penempatan grid Anda, seperti ini:

index.html (Panggilan 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);
}

Jika Anda me-refresh sekarang, Anda akan melihat yang berikut:

Visualisasi kanvas yang secara konseptual dibagi menjadi grid 4 x 4 dengan persegi merah yang berada di tengah, di antara sel (0, 0), sel (0, 1), sel (1, 0), dan sel (1, 1).

Hm. Tidak sepenuhnya sesuai harapan Anda.

Alasannya adalah karena koordinat kanvas berkisar antara -1 hingga +1, sebenarnya lebarannya adalah 2 unit. Artinya, jika Anda ingin memindahkan sebuah verteks seperempat unit dari lebar kanvas, Anda harus memindahkannya sejauh 0,5 unit. Ini adalah kesalahan yang mudah terjadi ketika menggunakan logika koordinat GPU. Untungnya, perbaikannya sama mudahnya.

  1. Kalikan offset Anda dengan 2, seperti ini:

index.html (Panggilan 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);
}

Ini akan menghasilkan apa yang Anda inginkan.

Visualisasi kanvas yang secara konseptual dibagi menjadi grid 4 x 4 dengan sebuah persegi merah di sel (1, 1).

Screenshot-nya akan terlihat seperti ini:

Screenshot sebuah persegi merah pada latar belakang biru tua. Persegi merah digambar pada posisi yang sama seperti yang dijelaskan dalam diagram sebelumnya, tetapi tanpa overlay grid.

Selain itu, sekarang Anda dapat menyetel cell ke nilai apa pun dalam batas grid, lalu refresh untuk melihat persegi dirender di lokasi yang diinginkan.

Menggambar instance

Sekarang, setelah Anda dapat menempatkan persegi di lokasi yang Anda inginkan dengan sedikit perhitungan, langkah berikutnya adalah merender satu persegi di setiap sel grid.

Salah satu pendekatan yang dapat Anda lakukan adalah menulis koordinat sel ke dalam buffer uniform, kemudian memanggil draw sekali untuk setiap persegi dalam grid, yang akan memperbarui uniform setiap kali fungsi tersebut dipanggil. Namun, proses ini akan sangat lambat karena GPU harus menunggu koordinat baru yang ditulis oleh JavaScript dalam setiap pemanggilan. Salah satu kunci untuk mendapatkan performa GPU yang baik adalah meminimalkan waktu yang dihabiskannya untuk menunggu bagian lain dari sistem.

Sebagai gantinya, Anda dapat menggunakan teknik yang disebut instancing. Instancing adalah cara memberi tahu GPU untuk menggambar beberapa salinan geometri yang sama dengan satu panggilan ke draw, yang jauh lebih cepat daripada memanggil draw sekali untuk setiap salinan. Setiap salinan geometri disebut sebagai sebuah instance.

  1. Untuk memberi tahu GPU bahwa Anda menginginkan instance persegi yang cukup untuk mengisi grid, tambahkan satu argumen ke panggilan gambar yang sudah ada:

index.html

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

Ini memberi tahu sistem bahwa Anda ingin menggambar enam (vertices.length / 2) verteks dari persegi sebanyak 16 (GRID_SIZE * GRID_SIZE) kali. Namun, jika Anda me-refresh halaman, Anda masih akan melihat berikut ini:

Gambar yang identik dengan diagram sebelumnya, untuk menunjukkan bahwa tidak ada yang berubah.

Mengapa demikian? Nah, itu karena Anda menggambar ke-16 persegi itu di tempat yang sama. Anda perlu memiliki beberapa logika tambahan di dalam shader, yang dapat mengubah posisi geometri berdasarkan setiap instance.

Di dalam shader, selain atribut verteks seperti pos yang berasal dari buffer verteks, Anda juga dapat mengakses apa yang disebut sebagai nilai bawaan WGSL. Ini adalah nilai yang dihitung oleh WebGPU, dan salah satu nilai tersebut adalah instance_index. instance_index adalah angka 32-bit tanpa label dari 0 hingga - number of instances - 1 yang dapat Anda gunakan sebagai bagian dari logika shader. Nilainya sama untuk setiap verteks yang diproses, yang merupakan bagian dari instance yang sama. Artinya, shader verteks Anda dipanggil enam kali dengan instance_index 0, sekali untuk setiap posisi dalam buffer verteks Anda. Kemudian enam kali lagi dengan instance_index1, lalu enam kali lagi dengan instance_index2, dan seterusnya.

Untuk melihat penerapannya, Anda harus menambahkan instance_index yang sudah menjadi bawaan ke input shader. Lakukan ini dengan cara yang sama seperti posisi, tetapi alih-alih memberi tag dengan atribut @location, gunakan @builtin(instance_index), dan beri nama argumennya sesuai keinginan Anda. (Anda bisa memanggilnya instance untuk mencocokkan dengan kode contoh.) Kemudian gunakan atribut tersebut sebagai bagian dari logika shader.

  1. Gunakan instance sebagai pengganti koordinat sel:

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

Jika me-refresh halaman sekarang, Anda akan melihat bahwa Anda memang memiliki lebih dari satu persegi. Namun, Anda tidak dapat melihat ke-16 dari persegi tersebut.

Empat persegi merah dalam garis diagonal dari sudut kiri bawah ke sudut kanan atas pada latar belakang biru tua.

Itu karena koordinat sel yang Anda hasilkan adalah (0, 0), (1, 1), (2, 2)... hingga (15, 15), tetapi hanya empat koordinat pertama yang muat di dalam kanvas. Untuk membuat grid yang Anda inginkan, Anda perlu mengubah instance_index sehingga setiap indeks memetakan ke sel unik dalam grid, seperti ini:

Visualisasi kanvas yang secara konseptual dibagi menjadi grid 4 x 4 dengan setiap sel yang juga sesuai dengan indeks instance linear.

Perhitungan matematikanya cukup sederhana. Untuk nilai X setiap sel, Anda ingin mendapatkan modulus dari instance_index dan lebar grid, yang dapat Anda lakukan dalam WGSL dengan operator %. Untuk nilai Y setiap sel, Anda ingin agar instance_index dibagi oleh lebar grid, dan menghapus setiap sisa pecahan. Anda dapat melakukannya dengan fungsi floor() dalam WGSL.

  1. Ubah perhitungannya, seperti ini:

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

Setelah melakukan pembaruan pada kode tersebut, akhirnya Anda memiliki grid persegi yang sudah sangat dinanti-nantikan.

Empat baris dari empat kolom persegi berwarna merah di latar belakang biru tua.

  1. Sekarang setelah berfungsi, kembali dan tingkatkan ukuran grid.

index.html

const GRID_SIZE = 32;

32 baris dan 32 kolom persegi merah pada latar belakang biru tua.

Tada! Anda sekarang dapat membuat grid ini menjadi sangat besar, dan umumnya GPU dapat menanganinya dengan baik. Anda akan berhenti melihat persegi individu jauh sebelum Anda mendapati bottleneck performa GPU.

6. Poin tambahan: membuatnya lebih berwarna

Pada tahap ini, Anda bisa saja langsung menuju ke bagian berikutnya karena Anda telah membuat dasar untuk seluruh codelab. Namun, meski grid persegi yang semuanya memiliki warna yang sama masih dapat berfungsi, grid ini tidak terlalu menarik, bukan? Untungnya, Anda dapat membuat grid lebih cerah dengan sedikit tambahan perhitungan dan kode shader.

Menggunakan struct dalam shader

Sejauh ini, Anda telah meneruskan satu data dari shader verteks: posisi yang telah diubah. Namun, sebenarnya Anda dapat menampilkan banyak data dari shader verteks, lalu menggunakannya dalam shader fragmen.

Satu-satunya cara untuk meneruskan data dari shader verteks adalah dengan menampilkannya. Shader verteks harus selalu menampilkan posisi, jadi jika Anda ingin menampilkan data lain bersamanya, Anda perlu meletakkannya dalam struct. Struct dalam WGSL adalah jenis objek bernama yang berisi satu atau beberapa properti bernama. Properti ini dapat diberi atribut seperti @builtin serta @location. Anda menentukan struct ini di luar semua fungsi shader, lalu Anda dapat meneruskan instance dari struct ini ke dan dari fungsi, sesuai kebutuhan. Misalnya, lihat shader verteks Anda saat ini:

index.html (Panggilan 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);
}
  • Ungkapkan hal yang sama menggunakan struct untuk input dan output fungsi:

index.html (Panggilan 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;
}

Perhatikan bahwa ini mengharuskan Anda untuk merujuk pada posisi input dan indeks instance menggunakan input, dan pertama-tama, struct yang ditampilkan perlu ditentukan sebagai variabel dan masing-masing propertinya harus disetel. Dalam kasus ini, penggunaan struct tidak membuat perbedaan besar, bahkan membuat fungsi shader sedikit lebih panjang, tetapi saat shader Anda menjadi lebih kompleks, menggunakan struct bisa menjadi cara yang baik untuk membantu mengorganisir data.

Meneruskan data antara fungsi verteks dan fragmen

Sebagai pengingat, fungsi @fragment Anda sesederhana mungkin:

index.html (Panggilan createShaderModule)

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

Anda tidak mengambil input apa pun, dan Anda meneruskan warna solid (merah) sebagai output. Namun, jika shader mengetahui lebih banyak informasi tentang geometri yang diwarnainya, Anda dapat menggunakan data tambahan tersebut untuk membuatnya sedikit lebih menarik. Misalnya, bagaimana jika Anda ingin mengubah warna setiap persegi berdasarkan koordinat selnya? Stage @vertex tahu sel mana yang sedang dirender; Anda hanya perlu meneruskannya ke stage @fragment.

Untuk meneruskan data antara stage verteks dan fragmen, Anda perlu menyertakannya dalam sebuah struct output dengan @location pilihan. Karena Anda ingin meneruskan koordinat sel, tambahkan ke struct VertexOutput dari bagian sebelumnya, lalu setel nilainya di fungsi @vertex sebelum Anda menampilkannya.

  1. Ubah nilai shader verteks yang ditampilkan, seperti ini:

index.html (Panggilan 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. Pada fungsi @fragment, terima nilai tersebut dengan menambahkan argumen dengan @location yang sama. (Nama-nama ini tidak harus cocok, tetapi akan lebih mudah untuk melacaknya jika namanya cocok.)

index.html (Panggilan 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. Sebagai alternatif, Anda dapat menggunakan struct:

index.html (Panggilan createShaderModule)

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

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. Atau**,** karena dalam kode Anda kedua fungsi ini ditentukan dalam modul shader yang sama, Anda dapat menggunakan kembali struct output dari stage @vertex. Ini membuat penerusan nilai menjadi mudah karena nama dan lokasi akan secara natural konsisten.

index.html (Panggilan createShaderModule)

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

Terlepas dari pola yang dipilih, hasilnya adalah Anda memiliki akses ke nomor sel di fungsi @fragment dan dapat menggunakannya untuk memengaruhi warna. Dengan salah satu kode di atas, outputnya akan terlihat seperti ini:

Grid persegi dengan kolom paling kiri berwarna hijau, baris paling bawah berwarna merah, dan semua persegi lainnya berwarna kuning.

Ada lebih banyak warna sekarang, tetapi tidak terlihat begitu bagus. Anda mungkin bertanya-tanya mengapa hanya baris kiri dan bawah yang terlihat berbeda. Itu karena nilai warna yang ditampilkan dari fungsi @fragment mengharapkan setiap saluran berada dalam kisaran 0 hingga 1, dan nilai di luar kisaran tersebut akan dibatasi dalam rentang tersebut. Di sisi lain, nilai sel Anda memiliki rentang 0 hingga 32 di sepanjang setiap sumbu. Jadi yang Anda lihat di sini adalah baris dan kolom pertama langsung mencapai nilai maksimum, yaitu 1, pada saluran warna merah atau hijau, dan setiap sel setelah itu dibatasi ke nilai yang sama.

Jika menginginkan transisi antar-warna yang lebih halus, Anda perlu menampilkan nilai pecahan untuk setiap saluran warna, idealnya dimulai dari nol dan berakhir pada satu di sepanjang setiap sumbu, yang berarti Anda perlu melakukan pembagian lagi dengan grid.

  1. Ubah shader fragmen, seperti ini:

index.html (Panggilan createShaderModule)

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

Refresh halaman, dan Anda dapat melihat bahwa kode baru memberikan gradasi warna yang jauh lebih bagus di seluruh grid.

Grid persegi yang bertransisi dari warna hitam, merah, hijau, hingga kuning di sudut-sudut yang berbeda.

Meskipun sudah ada peningkatan dalam warnanya, tetapi kini ada sudut gelap yang tampak tidak bagus di bagian kiri bawah, tempat grid menjadi berwarna hitam. Ketika Anda mulai melakukan simulasi Game of Life, bagian yang sulit dilihat dari grid akan menyembunyikan apa yang sedang terjadi. Sebaiknya Anda mencerahkan bagian tersebut.

Untungnya, Anda memiliki seluruh saluran warna yang tidak terpakai—biru—yang bisa digunakan. Idealnya, efek yang diinginkan adalah membuat biru menjadi paling terang di tempat yang mana warna lain paling gelap, lalu membuatnya memudar saat warna lainnya menjadi semakin terang. Cara termudah untuk melakukannya adalah membuat saluran tersebut dimulai dari 1 dan mengurangi salah satu nilai sel. Saluran biru tersebut bisa jadi c.x atau c.y. Coba keduanya, lalu pilih yang Anda sukai.

  1. Tambahkan warna yang lebih terang ke shader fragmen, seperti ini:

Panggilan createShaderModule

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

Hasilnya terlihat cukup bagus.

Grid persegi yang bertransisi dari merah, ke hijau, ke biru, hingga kuning di sudut-sudut yang berbeda.

Ini bukan langkah yang penting. Namun, karena tampilannya terlihat lebih baik, ini disertakan dalam file sumber checkpoint yang sesuai, dan screenshot lain dalam codelab ini akan mencerminkan grid yang lebih berwarna ini.

7. Mengelola status sel

Selanjutnya, Anda perlu mengontrol sel mana yang dirender pada grid, berdasarkan beberapa status yang disimpan di GPU. Ini merupakan bagian yang penting untuk simulasi akhir.

Yang Anda butuhkan hanyalah sinyal aktif-tidak aktif untuk setiap sel, jadi segala opsi yang memungkinkan Anda menyimpan array besar dari hampir semua jenis nilai akan berfungsi. Anda mungkin berpikir bahwa ini adalah kasus penggunaan lain untuk buffer uniform. Meskipun Anda bisa membuatnya berfungsi, penggunaan buffer uniform akan lebih sulit karena buffer uniform memiliki batasan ukuran, tidak dapat mendukung array berukuran dinamis (Anda harus menentukan ukuran array di dalam shader), dan tidak dapat ditulis oleh shader komputasi. Poin terakhir ini yang paling bermasalah, karena Anda ingin melakukan simulasi Game of Life di GPU dalam shader komputasi.

Untungnya, ada opsi buffer lain yang mampu menghindari semua batasan tersebut.

Membuat buffer penyimpanan

Buffer penyimpanan adalah buffer penggunaan umum yang dapat dibaca dan ditulis di shader komputasi, dan dibaca di shader verteks. Ukuran buffer penyimpanan bisa sangat besar, dan buffer ini tidak memerlukan ukuran yang dideklarasikan secara khusus di dalam shader, yang membuatnya jauh lebih mirip dengan memori umum. Itu yang akan Anda gunakan untuk menyimpan status sel.

  1. Guna membuat buffer penyimpanan untuk status sel Anda, gunakan potongan kode pembuatan buffer yang sekarang mungkin sudah mulai terlihat familier:

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

Seperti halnya dengan buffer verteks dan uniform Anda, panggil device.createBuffer() dengan ukuran yang sesuai, dan kali ini pastikan untuk menentukan penggunaan GPUBufferUsage.STORAGE.

Anda dapat mengisi buffer dengan cara yang sama seperti sebelumnya dengan mengisi TypedArray dengan nilai yang sama, lalu memanggil device.queue.writeBuffer(). Karena Anda ingin melihat efek buffer pada grid, mulailah dengan mengisinya dengan nilai yang dapat diprediksi.

  1. Aktifkan setiap sel ketiga dengan kode berikut:

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

Membaca buffer penyimpanan di shader

Selanjutnya, perbarui shader Anda untuk melihat konten buffer penyimpanan sebelum merender grid. Ini akan terlihat sangat mirip dengan cara uniform ditambahkan sebelumnya.

  1. Perbarui shader Anda dengan kode berikut:

index.html

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

Pertama, Anda menambahkan titik binding, yang ditempatkan tepat di bawah uniform grid. Sebaiknya gunakan @group yang sama seperti uniform grid, tetapi nomor @binding harus berbeda. Jenis var adalah storage, untuk mencerminkan jenis buffer yang berbeda, dan bukannya vektor tunggal, jenis yang Anda berikan untuk cellState adalah array nilai u32, untuk mencocokkan Uint32Array di JavaScript.

Selanjutnya, dalam isi fungsi @vertex, kueri status sel. Karena status disimpan dalam array datar di buffer penyimpanan, Anda dapat menggunakan instance_index untuk mencari nilai sel saat ini.

Bagaimana cara untuk menonaktifkan sel jika statusnya menyatakan bahwa sel tersebut tidak aktif? Nah, karena status aktif dan tidak aktif yang Anda dapatkan dari array adalah 1 atau 0, Anda dapat menyesuaikan geometri menggunakan status aktif. Menskalakannya menjadi 1 akan membiarkan geometri sebagaimana adanya, dan menskalakannya menjadi 0 membuat geometri menjadi titik tunggal, yang akan dihapus oleh GPU.

  1. Perbarui kode shader Anda untuk menskalakan posisi berdasarkan status aktif sel. Nilai status harus dikonversi menjadi f32 untuk memenuhi persyaratan keamanan jenis WGSL:

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

Menambahkan buffer penyimpanan ke bind group

Sebelum Anda dapat melihat efek status sel, tambahkan buffer penyimpanan ke sebuah bind group. Karena ini adalah bagian dari @group yang sama dengan buffer uniform, tambahkan ke bind group yang sama dalam kode JavaScript.

  • Tambahkan buffer penyimpanan, seperti ini:

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

Pastikan bahwa binding dari entri baru ini cocok dengan @binding() dari nilai yang sesuai dalam shader.

Dengan demikian, Anda seharusnya dapat me-refresh dan melihat pola tersebut muncul di grid.

Garis diagonal dari persegi berwarna dari sudut kiri bawah ke sudut kanan atas dengan latar belakang biru tua.

Menggunakan pola buffer ping-pong

Sebagian besar simulasi seperti yang sedang Anda bangun biasanya menggunakan setidaknya dua salinan dari statusnya. Pada setiap langkah simulasi, program akan membaca dari satu salinan status dan menulis ke yang lain. Kemudian, pada langkah berikutnya, program akan beralih dan membaca dari status yang sebelumnya ditulis. Ini umumnya disebut sebagai pola ping-pong karena versi status yang paling terbaru akan beralih antarsalinan status dari setiap langkah.

Mengapa diperlukan? Lihat contoh yang disederhanakan: bayangkan Anda menulis simulasi yang sangat sederhana tempat Anda memindahkan blok aktif satu sel ke kanan pada setiap langkah. Agar lebih mudah dipahami, Anda menentukan data dan simulasi Anda di 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.

Namun, jika Anda menjalankan kode tersebut, sel aktif bergerak ke ujung array dalam satu langkah. Mengapa demikian? Karena Anda terus memperbarui status secara langsung, jadi Anda memindahkan sel aktif ke kanan, lalu Anda melihat sel berikutnya dan... loh! Selnya aktif. Sebaiknya pindahkan ke kanan lagi. Ketika Anda mengubah data pada saat yang sama dengan saat Anda mengamatinya, tindakan ini akan merusak hasilnya.

Dengan menggunakan pola ping-pong, Anda memastikan bahwa Anda selalu melakukan langkah simulasi berikutnya hanya menggunakan hasil dari langkah terakhir.

// 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. Gunakan pola ini dalam kode Anda sendiri dengan memperbarui alokasi buffer penyimpanan untuk membuat dua buffer yang identik:

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. Untuk membantu memvisualisasikan perbedaan antara kedua buffer ini, isi buffer dengan data yang berbeda:

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. Untuk menampilkan buffer penyimpanan yang berbeda dalam rendering, perbarui bind group Anda agar memiliki dua varian yang berbeda juga:

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

Menyiapkan loop render

Sejauh ini, Anda hanya melakukan satu gambar per refresh halaman, tetapi sekarang Anda ingin menampilkan pembaruan data dari waktu ke waktu. Untuk melakukan itu, Anda membutuhkan loop render sederhana.

Loop render adalah loop yang berulang tanpa henti, yang menggambar konten Anda ke kanvas pada interval tertentu. Banyak game dan konten lain yang ingin membuat animasi halus yang menggunakan fungsi requestAnimationFrame() untuk menjadwalkan callback pada frekuensi yang sama dengan refresh layar (60 kali setiap detik).

Aplikasi ini dapat menggunakan opsi itu juga, tetapi dalam kasus ini, Anda mungkin ingin agar pembaruan terjadi dalam langkah yang lebih lama sehingga Anda dapat dengan lebih mudah memperhatikan apa yang dilakukan simulasi. Kelola loop sendiri agar Anda dapat mengontrol frekuensi pembaruan simulasi.

  1. Pertama, pilih frekuensi pembaruan simulasi (200 md bagus, tetapi Anda bisa membuatnya lebih lambat atau lebih cepat sesuai keinginan), lalu pantau berapa banyak langkah simulasi yang telah selesai.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. Kemudian pindahkan semua kode yang saat ini Anda gunakan untuk merender ke dalam fungsi baru. Jadwalkan fungsi tersebut untuk diulang pada interval yang diinginkan dengan setInterval(). Pastikan bahwa fungsi tersebut juga memperbarui hitungan langkah, dan gunakan fungsi tersebut untuk memilih bind group mana yang akan di-binding.

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

Kini saat Anda menjalankan aplikasi, Anda akan melihat bahwa kanvas akan menampilkan dua buffer status yang telah dibuat secara bolak-balik.

Garis diagonal dari persegi berwarna dari sudut kiri bawah ke sudut kanan atas dengan latar belakang biru tua. Garis vertikal dari persegi berwarna di atas latar belakang berwarna biru tua.

Dengan demikian, Anda hampir menyelesaikan bagian rendering. Anda sudah siap untuk menampilkan output simulasi Game of Life yang akan Anda bangun pada langkah berikutnya, tempat Anda akhirnya mulai menggunakan shader komputasi.

Jelas ada banyak lagi kemampuan rendering WebGPU yang lebih dari sekadar bagian kecil yang Anda pelajari di sini, tetapi bagian sisanya berada di luar cakupan codelab ini. Semoga pembahasan dalam codelab ini memberi Anda cukup gambaran tentang cara kerja rendering WebGPU, sehingga memudahkan Anda dalam mempelajari teknik lanjutan seperti rendering 3D.

8. Menjalankan simulasi

Sekarang, untuk bagian besar terakhir dari teka-teki ini: melakukan simulasi Game of Life dalam shader komputasi.

Menggunakan shader komputasi

Anda telah mempelajari shader komputasi secara konseptual sepanjang codelab ini, tetapi apa sebenarnya yang dimaksud dengan shader komputasi?

Shader komputasi mirip dengan shader verteks dan fragmen dalam hal tujuan dirancangnya, yaitu untuk berjalan dengan paralelisme ekstrem di GPU. Namun, berbeda dengan dua stage shader tersebut, shader komputasi tidak memiliki serangkaian input dan output yang spesifik. Anda membaca dan menulis data secara eksklusif dari sumber yang Anda pilih, seperti buffer penyimpanan. Artinya, dalam shader komputasi, bukannya menjalankan shader sekali untuk setiap verteks, instance, atau piksel, Anda harus memberi tahu berapa banyak panggilan fungsi shader yang diinginkan. Kemudian, saat Anda menjalankan shader, Anda diberi tahu panggilan mana yang sedang diproses, dan Anda dapat memutuskan data apa yang akan diakses dan operasi apa yang akan dilakukan dari sana.

Shader komputasi harus dibuat dalam modul shader, sama seperti shader verteks dan fragmen, jadi tambahkan shader komputasi ke kode Anda untuk memulai. Seperti yang mungkin sudah Anda duga, mengingat struktur shader lain yang telah diimplementasikan, fungsi utama untuk shader komputasi perlu ditandai dengan atribut @compute.

  1. Buat shader komputasi dengan kode berikut:

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() {

    }`
});

Karena GPU sering digunakan untuk grafis 3D, shader komputasi dibuat sedemikian rupa sehingga Anda dapat meminta agar shader dipanggil sebanyak jumlah tertentu di sepanjang sumbu X, Y, dan Z. Ini memungkinkan Anda dengan sangat mudah mengirim pekerjaan yang sesuai dengan grid 2D atau 3D, yang cocok untuk kasus penggunaan Anda. Anda perlu memanggil shader ini sebanyak GRID_SIZE kali GRID_SIZE kali, sekali untuk setiap sel simulasi Anda.

Karena sifat arsitektur hardware GPU, grid ini dibagi menjadi workgroup. Sebuah workgroup memiliki ukuran X, Y, dan Z, dan meski masing-masing ukurannya bisa 1, sering kali ada manfaat performa dari pembuatan workgroup yang lebih besar. Untuk shader Anda, pilih ukuran workgroup yang sedikit arbitrer, misalnya 8 kali 8. Sebaiknya ingat ukuran ini dalam konteks kode JavaScript Anda.

  1. Tentukan konstanta untuk ukuran workgroup Anda, seperti ini:

index.html

const WORKGROUP_SIZE = 8;

Anda juga perlu menambahkan ukuran workgroup ke fungsi shader itu sendiri, yang Anda lakukan menggunakan literal template JavaScript, sehingga Anda dapat dengan mudah menggunakan konstanta yang baru saja ditentukan.

  1. Tambahkan ukuran workgroup ke fungsi shader, seperti ini:

index.html (Panggilan createShaderModule komputasi)

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

}

Ini memberi tahu shader bahwa pekerjaan yang dilakukan dengan fungsi ini dilakukan dalam grup (8 x 8 x 1). (Setiap sumbu yang tidak ditentukan akan menggunakan nilai default 1, meskipun Anda setidaknya harus menentukan sumbu X.)

Seperti stage shader lainnya, ada berbagai nilai @builtin yang dapat diterima sebagai input ke fungsi shader komputasi untuk memberi tahu Anda panggilan mana yang sedang dijalankan dan memutuskan pekerjaan apa yang perlu dilakukan.

  1. Tambahkan nilai @builtin, seperti ini:

index.html (Panggilan createShaderModule komputasi)

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

}

Anda meneruskan variabel global_invocation_id bawaan, yang merupakan vektor tiga dimensi dari bilangan bulat tanpa label, yang memberitahukan posisi panggilan shader saat ini dalam grid. Anda menjalankan shader ini sekali untuk setiap sel dalam grid Anda. Anda akan mendapatkan angka seperti (0, 0, 0), (1, 0, 0), (1, 1, 0),... sampai (31, 31, 0), yang berarti bahwa Anda dapat memperlakukannya sebagai indeks sel yang akan dioperasikan.

Sama seperti dalam shader verteks dan fragmen, shader komputasi juga dapat menggunakan uniform.

  1. Gunakan uniform dengan shader komputasi untuk memberitahukan ukuran grid, seperti ini:

index.html (Panggilan createShaderModule komputasi)

@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) {

}

Sama seperti dalam shader verteks, Anda juga mengekspos status sel sebagai buffer penyimpanan. Namun dalam kasus ini, Anda membutuhkan dua buffer penyimpanan. Karena shader komputasi tidak memiliki output yang dibutuhkan, seperti posisi verteks atau warna fragmen, menulis nilai ke buffer penyimpanan atau tekstur adalah satu-satunya cara untuk mendapatkan hasil dari shader komputasi. Gunakan metode ping-pong yang telah Anda pelajari sebelumnya; Anda memiliki satu buffer penyimpanan yang memberi feed tentang status grid saat ini dan satu lagi yang menjadi tempat menulis status baru grid.

  1. Ekspos input sel dan status output sebagai buffer penyimpanan, seperti ini:

index.html (Panggilan createShaderModule komputasi)

@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) {

}

Perhatikan bahwa buffer penyimpanan pertama dideklarasikan dengan var<storage>, yang membuatnya hanya dapat dibaca, tetapi buffer penyimpanan kedua dideklarasikan dengan var<storage, read_write>. Ini memungkinkan Anda membaca dan menulis ke buffer, dan menggunakan buffer tersebut sebagai output untuk shader komputasi. (Tidak ada mode penyimpanan hanya tulis dalam WebGPU).

Selanjutnya, Anda perlu memiliki cara untuk memetakan indeks sel ke dalam array penyimpanan linear. Ini pada dasarnya merupakan kebalikan dari apa yang Anda lakukan di shader verteks, saat Anda mengambil instance_index linear dan memetakannya ke sel grid 2D. (Sebagai pengingat, algoritma Anda untuk melakukannya adalah vec2f(i % grid.x, floor(i / grid.x)))

  1. Tulis fungsi yang melakukan kebalikan dari operasi tersebut. Fungsi tersebut akan mengambil nilai Y sel, mengalikannya dengan lebar grid, lalu menambahkan nilai X sel.

index.html (Panggilan createShaderModule komputasi)

@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) {
  
}

Akhirnya, untuk melihat bahwa ini berfungsi, implementasikan algoritma yang sangat sederhana: jika sebuah sel aktif, nonaktifkan, dan sebaliknya. Ini belum berupa Game of Life, tetapi cukup untuk menunjukkan bahwa shader komputasi berfungsi.

  1. Tambahkan algoritma sederhana, seperti ini:

index.html (Panggilan createShaderModule komputasi)

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

Selesai sudah shader komputasi Anda—setidaknya untuk saat ini. Namun, sebelum Anda dapat melihat hasilnya, ada beberapa perubahan lagi yang perlu dibuat.

Menggunakan Bind Group dan Tata Letak Pipeline

Satu hal yang mungkin Anda perhatikan dari shader di atas adalah bahwa sebagian besar dari shader tersebut menggunakan input yang sama (uniform dan buffer penyimpanan) seperti pipeline render Anda. Jadi mungkin Anda berpikir bahwa Anda bisa dengan mudah menggunakan bind group yang sama dan prosesnya akan selesai, bukan? Berita baiknya adalah Anda bisa melakukannya. Anda hanya perlu untuk melakukan sedikit penyiapan manual.

Setiap kali Anda membuat bind group, Anda perlu menyediakan GPUBindGroupLayout. Sebelumnya, Anda mendapatkan tata letak tersebut dengan memanggil getBindGroupLayout() pada pipeline render, yang kemudian membuatnya secara otomatis karena Anda menyediakan layout: "auto" saat membuatnya. Pendekatan ini berfungsi dengan baik ketika Anda hanya menggunakan satu pipeline, tetapi jika Anda memiliki beberapa pipeline yang ingin berbagi resource, Anda perlu membuat tata letak secara eksplisit, lalu menyediakannya ke bind group dan pipeline.

Untuk membantu memahami alasannya, pertimbangkan hal berikut: dalam pipeline render, Anda menggunakan satu buffer uniform dan satu buffer penyimpanan, tetapi dalam shader komputasi yang baru ditulis, Anda membutuhkan buffer penyimpanan kedua. Karena dua shader menggunakan nilai @binding yang sama untuk uniform dan buffer penyimpanan pertama, keduanya dapat menggunakan nilai tersebut, dan pipeline render mengabaikan buffer penyimpanan kedua, yang tidak digunakan. Anda perlu membuat tata letak yang menjelaskan semua resource yang ada di dalam bind group, bukan hanya yang digunakan oleh suatu pipeline tertentu.

  1. Untuk membuat tata letak tersebut, panggil device.createBindGroupLayout():

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    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
  }]
});

Strukturnya sama dengan membuat bind group itu sendiri, tempat Anda menjelaskan daftar entries. Perbedaannya adalah, Anda perlu menjelaskan jenis resource yang harus dimiliki entri dan bagaimana resource tersebut digunakan, bukannya menyediakan resource itu sendiri.

Dalam setiap entri, Anda memberikan nomor binding untuk resource, yang (seperti yang telah Anda pelajari saat membuat bind group) sesuai dengan nilai @binding dalam shader. Anda juga menyediakan visibility, yang merupakan flag GPUShaderStage yang menunjukkan stage shader mana yang dapat menggunakan resource tersebut. Uniform dan buffer penyimpanan pertama perlu untuk dapat diakses dalam shader verteks dan komputasi, tetapi buffer penyimpanan kedua hanya perlu diakses dalam shader komputasi.

Akhirnya, Anda menentukan jenis resource apa yang digunakan. Jenis ini adalah kunci kamus yang berbeda, tergantung pada apa yang perlu diekspos. Di sini, ketiga resource-nya adalah buffer, jadi Anda menggunakan kunci buffer untuk menentukan opsi untuk masing-masing resource tersebut. Opsi lain mencakup jenis resource seperti texture atau sampler, tetapi Anda tidak memerlukannya di sini.

Dalam kamus buffer, Anda menyetel opsi seperti type buffer yang digunakan. Defaultnya adalah "uniform", jadi Anda dapat membiarkan kamus tetap kosong untuk binding 0. (Namun, Anda setidaknya harus menyetel buffer: {}, agar entri diidentifikasi sebagai buffer.) Binding 1 diberi jenis "read-only-storage" karena Anda tidak menggunakannya dengan akses read_write dalam shader, dan binding 2 memiliki jenis "storage" karena Anda menggunakannya dengan akses read_write.

Setelah bindGroupLayout dibuat, Anda dapat meneruskannya saat membuat bind group, dan bukan mengkueri bind group dari pipeline. Dengan meneruskannya, berarti Anda perlu menambahkan entri buffer penyimpanan baru ke setiap bind group untuk menyesuaikan dengan tata letak yang baru ditentukan.

  1. Perbarui pembuatan bind group, seperti ini:

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

Sekarang, setelah bind group diperbarui agar menggunakan tata letak bind group eksplisit ini, Anda perlu memperbarui pipeline render agar menggunakan tata letak yang sama.

  1. Buat GPUPipelineLayout.

index.html

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

Tata letak pipeline adalah daftar tata letak bind group (dalam kasus ini, Anda hanya memiliki satu) yang digunakan oleh satu atau beberapa pipeline. Urutan tata letak bind group dalam array perlu sesuai dengan atribut @group dalam shader. (Ini berarti bindGroupLayout terkait dengan @group(0).)

  1. Setelah Anda memiliki tata letak pipeline, perbarui pipeline render agar menggunakannya alih-alih "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
    }]
  }
});

Membuat pipeline komputasi

Anda membutuhkan pipeline render untuk menggunakan shader verteks dan fragmen, dan Anda juga membutuhkan pipeline komputasi untuk menggunakan shader komputasi. Untungnya, pipeline komputasi jauh lebih sederhana daripada pipeline render, karena tidak ada status yang perlu disetel, hanya shader dan tata letak.

  • Buat pipeline komputasi dengan kode berikut:

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

Perhatikan bahwa Anda meneruskan pipelineLayout baru, dan bukan "auto", seperti pada pipeline render yang diperbarui, yang memastikan bahwa pipeline render dan pipeline komputasi Anda dapat menggunakan bind group yang sama.

Penerusan komputasi

Proses tersebut membawa Anda pada titik tempat Anda menggunakan pipeline komputasi. Mengingat bahwa Anda melakukan rendering dalam penerusan render, Anda mungkin bisa menebak bahwa Anda perlu melakukan pekerjaan komputasi dalam penerusan komputasi. Pekerjaan komputasi dan render dapat terjadi dalam encoder perintah yang sama, jadi Anda perlu sedikit melakukan shuffle pada fungsi updateGrid.

  1. Pindahkan pembuatan encoder ke bagian atas fungsi, dan mulailah penerusan komputasi dengannya (sebelum 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...

Sama seperti pipeline komputasi, penerusan komputasi jauh lebih sederhana untuk dimulai dibandingkan dengan proses rendering lainnya, karena Anda tidak perlu khawatir dengan lampiran apa pun.

Anda perlu melakukan penerusan komputasi sebelum penerusan render karena tindakan ini memungkinkan penerusan render menggunakan hasil terbaru dari penerusan komputasi. Ini juga alasan Anda meningkatkan jumlah step di antara kedua penerusan ini, sehingga buffer output dari pipeline komputasi menjadi buffer input untuk pipeline render.

  1. Selanjutnya, setel pipeline dan bind group di dalam penerusan komputasi, menggunakan pola yang sama untuk beralih antar-bind group seperti yang telah dilakukan untuk penerusan render.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Terakhir, bukannya menggambar seperti dalam penerusan render, kini Anda mengirimkan pekerjaan ke shader komputasi, dan memberitahukan berapa banyak workgroup yang ingin dijalankan pada setiap sumbu.

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

Hal yang sangat penting untuk diingat di sini adalah bahwa angka yang Anda masukkan ke dalam dispatchWorkgroups() bukanlah jumlah panggilan. Namun, angka ini merupakan jumlah workgroup yang akan dijalankan, sesuai dengan @workgroup_size yang ditentukan dalam shader.

Jika Anda ingin shader dijalankan sebanyak 32 x 32 kali untuk mencakup seluruh grid, dan ukuran workgroup Anda adalah 8 x 8, Anda perlu mengirim 4 x 4 workgroup (4 * 8 = 32). Itulah alasan Anda membagi ukuran grid dengan ukuran workgroup, lalu meneruskan nilai tersebut ke dalam dispatchWorkgroups().

Sekarang Anda dapat me-refresh halaman lagi, dan Anda seharusnya melihat bahwa grid berubah sendiri dengan setiap pembaruan.

Garis diagonal dari persegi berwarna dari sudut kiri bawah ke sudut kanan atas dengan latar belakang biru tua. Garis diagonal berupa persegi berwarna, setiap garis terdiri dari dua persegi, mulai dari pojok kiri bawah hingga pojok kanan atas, dengan latar belakang biru tua. Kebalikan dari gambar sebelumnya.

Mengimplementasikan algoritma untuk Game of Life

Sebelum memperbarui shader komputasi untuk mengimplementasikan algoritma akhir, Anda perlu kembali ke kode yang melakukan inisialisasi konten buffer penyimpanan dan memperbaruinya untuk menghasilkan buffer acak pada setiap pemuatan halaman. (Pola reguler tidak membuat titik awal Game of Life yang menarik.) Anda dapat melakukan pengacakan pada nilai-nilai tersebut sesuai keinginan, tetapi ada cara mudah untuk memulai yang memberikan hasil yang masuk akal.

  1. Untuk memulai setiap sel dalam status acak, perbarui inisialisasi cellStateArray dengan kode berikut:

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

Sekarang Anda akhirnya dapat mengimplementasikan logika untuk simulasi Game of Life. Setelah segala upaya yang diperlukan untuk sampai di tahap ini, kode shader mungkin menjadi terlalu sederhana.

Pertama, Anda perlu mengetahui berapa banyak tetangga sel yang aktif untuk setiap sel tertentu. Anda tidak perlu memikirkan sel mana yang aktif, hanya jumlahnya.

  1. Untuk memudahkan mendapatkan data sel tetangga, tambahkan fungsi cellActive yang menampilkan nilai cellStateIn dari koordinat yang diberikan.

index.html (Panggilan createShaderModule komputasi)

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

Fungsi cellActive menampilkan satu jika sel aktif, jadi menambahkan nilai yang ditampilkan dari pemanggilan cellActive untuk kedelapan sel di sekitarnya akan memberikan jumlah sel tetangga yang aktif.

  1. Temukan jumlah tetangga yang aktif, seperti ini:

index.html (Panggilan createShaderModule komputasi)

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

Namun, ini akan menyebabkan masalah kecil: apa yang terjadi ketika sel yang Anda periksa berada di luar batas papan? Menurut logika cellIndex() Anda saat ini, sel tersebut berada di baris berikutnya atau sebelumnya, atau berada di luar batas buffer.

Untuk Game of Life, cara umum dan mudah untuk menyelesaikan masalah ini adalah dengan membuat sel di tepi grid memperlakukan sel yang ada di tepi grid yang berlawanan sebagai tetangganya, sehingga menciptakan efek wrap-around.

  1. Dukung efek wrap-around grid dengan perubahan kecil pada fungsi cellIndex().

index.html (Panggilan createShaderModule komputasi)

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

Dengan operator % untuk melakukan wrap-around pada sel X dan Y ketika melampaui ukuran grid, Anda memastikan bahwa Anda tidak pernah mengakses di luar batas buffer penyimpanan. Dengan begitu, Anda dapat yakin bahwa jumlah activeNeighbors dapat diprediksi.

Kemudian Anda menerapkan salah satu dari empat aturan:

  • Setiap sel dengan kurang dari dua tetangga menjadi tidak aktif.
  • Setiap sel aktif dengan dua atau tiga tetangga tetap aktif.
  • Setiap sel tidak aktif dengan tepat tiga tetangga menjadi aktif.
  • Setiap sel dengan lebih dari tiga tetangga menjadi tidak aktif.

Anda dapat melakukannya dengan serangkaian pernyataan if, tetapi WGSL juga mendukung pernyataan switch, yang cocok untuk logika ini.

  1. Implementasikan logika Game of Life, seperti ini:

index.html (Panggilan createShaderModule komputasi)

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

Untuk referensi, panggilan modul shader komputasi akhir sekarang terlihat seperti ini:

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

Dan... itu saja! Selesai! Refresh halaman Anda dan lihat pertumbuhan cellular automata yang baru dibangun.

Screenshot contoh status simulasi Game of Life, dengan sel-sel yang berwarna dirender pada latar belakang biru tua.

9. Selamat!

Anda telah membuat versi simulasi Game of Life Conway klasik yang berjalan sepenuhnya di GPU menggunakan WebGPU API!

Apa selanjutnya?

Bacaan lebih lanjut

Dokumen referensi