Aplikasi WebGPU pertama Anda

1. Pengantar

Logo WebGPU terdiri dari beberapa segitiga biru yang membentuk 'W' bergaya

Terakhir Diperbarui: 13-04-2023

Apa yang dimaksud dengan WebGPU?

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

API Modern

Sebelum WebGPU, ada WebGL, yang menawarkan subset fitur WebGPU. Ini memungkinkan class baru konten web kaya, dan developer telah membuat hal-hal yang menakjubkan dengan konten tersebut. Namun, API ini didasarkan pada OpenGL ES 2.0 API, yang dirilis pada tahun 2007, yang didasarkan pada OpenGL API yang lebih lama. GPU telah berkembang secara signifikan pada waktu itu, dan API native yang digunakan untuk berinteraksi dengannya juga berkembang dengan Direct3D 12, Metal, dan Vulkan.

WebGPU menghadirkan kemajuan API modern ini ke platform web. Platform ini berfokus untuk mengaktifkan fitur GPU secara lintas platform, sekaligus menghadirkan API yang terasa alami di web dan tidak terlalu panjang dibandingkan beberapa API native yang dibuat di dalamnya.

Rendering

Sering kali GPU terkait dengan rendering grafis yang cepat dan mendetail, termasuk WebGPU. Alat ini memiliki fitur yang diperlukan untuk mendukung banyak teknik rendering terpopuler saat ini baik di GPU desktop maupun seluler, serta menyediakan jalur untuk menambahkan fitur baru di masa mendatang seiring dengan berkembangnya kemampuan hardware.

Compute

Selain rendering, WebGPU membuka potensi GPU Anda untuk melakukan beban kerja yang sangat umum dan sangat paralel. Shader komputasi ini dapat digunakan secara mandiri, tanpa komponen rendering, atau sebagai bagian yang terintegrasi erat dari pipeline rendering Anda.

Dalam codelab hari ini, Anda akan mempelajari cara memanfaatkan kemampuan rendering dan komputasi WebGPU untuk membuat project pengantar sederhana.

Yang akan Anda build

Dalam codelab ini, Anda akan mem-build Conway's Game of Life menggunakan WebGPU. Aplikasi Anda akan:

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

Screenshot produk akhir codelab ini

Game of Life dikenal sebagai otomat seluler, dengan petak sel berubah status dari waktu ke waktu berdasarkan beberapa kumpulan aturan. Di Game of Life, sel menjadi aktif atau tidak aktif, bergantung pada jumlah sel di sekitarnya yang aktif, yang mengarah ke pola menarik yang berfluktuasi saat Anda menonton.

Yang akan Anda pelajari

  • Cara menyiapkan WebGPU dan mengonfigurasi kanvas.
  • Cara menggambar geometri 2D sederhana.
  • Cara menggunakan shader vertex dan fragmen untuk mengubah hal yang digambar.
  • Cara menggunakan shader komputasi untuk melakukan simulasi sederhana.

Codelab ini berfokus untuk memperkenalkan konsep dasar di balik WebGPU. Ini tidak dimaksudkan sebagai tinjauan komprehensif API, juga tidak mencakup (atau memerlukan) topik yang sering 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 platform lintas platform, tetapi belum dikirim ke mana pun.
  • Pengetahuan tentang HTML, JavaScript, dan Chrome DevTools.

Pemahaman tentang Graphics API lainnya, seperti WebGL, Metal, Vulkan, atau Direct3D, tidak diperlukan, tetapi jika Anda memiliki pengalaman dengan API grafis tersebut, Anda mungkin akan melihat banyak kesamaan dengan WebGPU yang dapat membantu memulai pembelajaran Anda.

2. Memulai persiapan

Mendapatkan kode

Codelab ini tidak memiliki dependensi apa pun, dan memandu Anda di setiap langkah yang diperlukan untuk membuat aplikasi WebGPU, sehingga Anda tidak memerlukan kode apa pun untuk memulai. Namun, beberapa contoh kerja yang dapat berfungsi sebagai checkpoint tersedia di https://glitch.com/edit/#!/your-first-webgpu-app. Anda dapat melihat dan merujuknya saat pergi jika mengalami kesulitan.

Gunakan konsol developer.

WebGPU adalah API yang cukup kompleks dengan banyak aturan yang memberlakukan penggunaan yang tepat. Lebih buruk lagi, karena cara kerja API, API ini tidak dapat memunculkan pengecualian JavaScript umum untuk banyak error, sehingga sulit untuk menunjukkan dengan tepat dari mana masalah tersebut berasal.

Anda akan mengalami masalah saat mengembangkan dengan WebGPU, terutama sebagai pemula, dan itu bukanlah masalah! Developer di balik API menyadari tantangan dalam bekerja dengan pengembangan GPU, dan telah bekerja keras untuk memastikan bahwa setiap kali kode WebGPU Anda menyebabkan error, Anda akan mendapatkan kembali pesan yang sangat mendetail dan bermanfaat dalam konsol developer yang membantu Anda mengidentifikasi dan memperbaiki masalah.

Mempertahankan konsol agar tetap terbuka saat mengerjakan aplikasi web apa pun selalu berguna, tetapi ini terutama berlaku di sini!

3. Melakukan inisialisasi WebGPU

Mulai dengan <canvas>

WebGPU dapat digunakan tanpa menampilkan apa pun di layar jika Anda hanya ingin menggunakannya untuk melakukan komputasi. Namun, jika ingin merender apa pun, seperti yang akan kita lakukan di codelab, Anda memerlukan kanvas. Itu awal yang baik.

Buat dokumen HTML baru dengan satu elemen <canvas> di dalamnya, serta tag <script> tempat kita 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 dapat mempelajari bit WebGPU! Pertama, Anda harus mempertimbangkan bahwa API seperti WebGPU dapat memerlukan waktu beberapa saat untuk diterapkan di seluruh ekosistem web. Oleh karena itu, langkah pencegahan pertama yang baik adalah memeriksa apakah browser pengguna dapat menggunakan WebGPU.

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

index.html

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

Idealnya, Anda ingin memberi tahu pengguna jika WebGPU tidak tersedia dengan membuat halaman kembali ke mode yang tidak menggunakan WebGPU. (Atau mungkin menggunakan WebGL?) Namun, untuk tujuan codelab ini, Anda cukup menampilkan error guna menghentikan kode dieksekusi lebih lanjut.

Setelah Anda mengetahui bahwa WebGPU didukung oleh browser, langkah pertama dalam menginisialisasi WebGPU untuk aplikasi Anda adalah meminta GPUAdapter. Anda dapat menganggap adaptor sebagai representasi WebGPU dari hardware GPU tertentu di perangkat.

  1. Untuk mendapatkan adaptor, gunakan metode navigator.gpu.requestAdapter(). Metode ini menampilkan promise, sehingga akan lebih mudah untuk memanggilnya dengan await.

index.html

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

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

Biasanya, Anda dapat membiarkan browser memilih adaptor default, seperti yang Anda lakukan di sini, tetapi untuk kebutuhan lanjutan, ada argumen yang dapat diteruskan ke requestAdapter() yang menentukan apakah Anda ingin menggunakan hardware berdaya rendah atau berperforma tinggi di perangkat dengan banyak GPU (seperti beberapa laptop).

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

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

index.html

const device = await adapter.requestDevice();

Seperti halnya requestAdapter(), ada opsi yang dapat diteruskan di sini untuk penggunaan tingkat lanjut seperti mengaktifkan fitur hardware tertentu atau meminta batas yang lebih tinggi, tetapi untuk keperluan Anda, setelan default berfungsi dengan baik.

Mengonfigurasi Kanvas

Setelah memiliki perangkat, ada satu hal lagi yang harus dilakukan jika Anda ingin menggunakannya untuk menampilkan apa pun di halaman: konfigurasikan kanvas untuk digunakan dengan perangkat yang baru saja dibuat.

  • Untuk melakukannya, minta GPUCanvasContext terlebih dahulu dari kanvas dengan memanggil canvas.getContext("webgpu"). (Ini adalah panggilan yang sama yang akan Anda gunakan untuk menginisialisasi konteks Canvas 2D atau WebGL, masing-masing menggunakan jenis konteks 2d dan webgl.) context yang ditampilkan harus dikaitkan dengan perangkat menggunakan metode configure(), seperti:

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 Anda gunakan konteksnya 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 mengetahui cara data tersebut diletakkan dalam memori. Detail cara kerja memori tekstur berada di luar cakupan codelab ini. Hal penting yang perlu diketahui adalah bahwa konteks kanvas menyediakan tekstur untuk digambar kode Anda, dan format yang Anda gunakan dapat berdampak pada seberapa efisien kanvas menampilkan gambar tersebut. Jenis perangkat yang berbeda berfungsi paling baik saat menggunakan format tekstur berbeda, dan jika Anda tidak menggunakan format pilihan perangkat, salinan memori tambahan dapat terjadi di balik layar sebelum gambar dapat ditampilkan sebagai bagian dari halaman.

Untungnya, Anda tidak perlu khawatir tentang hal tersebut karena WebGPU memberi tahu Anda format mana yang harus digunakan untuk kanvas Anda. Dalam hampir semua kasus, Anda ingin meneruskan nilai yang ditampilkan dengan memanggil navigator.gpu.getPreferredCanvasFormat(), seperti yang ditunjukkan di atas.

Menghapus Kanvas

Setelah memiliki perangkat dan kanvas telah dikonfigurasi dengannya, Anda dapat mulai menggunakan perangkat untuk mengubah konten kanvas. Untuk memulai, hapus dengan warna solid.

Untuk melakukannya—atau hampir semua hal lainnya di WebGPU—Anda perlu memberikan beberapa perintah ke GPU yang memerintahkannya.

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

index.html

const encoder = device.createCommandEncoder();

Perintah yang ingin Anda kirim ke GPU terkait dengan rendering (dalam hal ini, menghapus kanvas), sehingga langkah berikutnya adalah menggunakan encoder untuk memulai Render Pass.

Render pass adalah ketika semua operasi gambar di WebGPU terjadi. Masing-masing dimulai dengan panggilan beginRenderPass(), yang menentukan tekstur yang menerima output dari setiap perintah gambar yang dijalankan. Penggunaan yang lebih canggih dapat memberikan beberapa tekstur, yang disebut lampiran, dengan berbagai tujuan seperti menyimpan kedalaman geometri yang dirender atau memberikan antialias. Namun, untuk aplikasi ini, Anda hanya memerlukan satu.

  1. Dapatkan tekstur dari konteks kanvas yang Anda buat sebelumnya dengan memanggil context.getCurrentTexture(), yang menampilkan tekstur dengan lebar dan tinggi piksel yang cocok 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 diberikan sebagai properti view dari colorAttachment. Penerusan render mengharuskan Anda menyediakan GPUTextureView, bukan GPUTexture, yang memberitahukan bagian tekstur mana yang akan dirender. Ini hanya benar-benar penting untuk kasus penggunaan lanjutan, jadi di sini Anda memanggil createView() tanpa argumen pada tekstur, yang menunjukkan bahwa Anda ingin kartu render menggunakan seluruh tekstur.

Anda juga harus menentukan apa yang harus dilakukan oleh render pass dengan tekstur saat dimulai dan saat berakhir:

  • Nilai loadOp dari "clear" menunjukkan bahwa Anda ingin tekstur dihapus saat penerusan render dimulai.
  • Nilai storeOp dari "store" menunjukkan bahwa setelah kartu render selesai, Anda ingin hasil gambar apa pun yang dilakukan selama kartu render disimpan ke dalam tekstur.

Setelah render pass dimulai, Anda tidak perlu melakukan apa pun! Minimal untuk saat ini. Tindakan memulai kartu render dengan loadOp: "clear" sudah cukup untuk menghapus tampilan tekstur dan kanvas.

  1. Akhiri pass render dengan menambahkan panggilan berikut segera setelah beginRenderPass():

index.html

pass.end();

Penting untuk diketahui bahwa hanya melakukan panggilan ini tidak menyebabkan GPU benar-benar melakukan apa pun. Mereka hanya merekam perintah untuk dilakukan GPU nanti.

  1. Untuk membuat GPUCommandBuffer, panggil finish() pada encoder perintah. Buffering perintah adalah handle buram untuk perintah yang direkam.

index.html

const commandBuffer = encoder.finish();
  1. Kirim buffering perintah ke GPU menggunakan queue dari GPUDevice. Antrean melakukan semua perintah GPU, memastikan bahwa eksekusinya dilakukan dengan baik dan disinkronkan dengan benar. Metode submit() antrean mengambil array buffering perintah, meskipun dalam kasus ini Anda hanya memilikinya.

index.html

device.queue.submit([commandBuffer]);

Setelah dikirim, buffering perintah tidak dapat digunakan lagi, sehingga tidak perlu disimpan. Jika ingin mengirimkan lebih banyak perintah, Anda perlu mem-build buffering perintah lain. Itulah sebabnya cukup umum untuk melihat kedua langkah tersebut diciutkan menjadi satu, seperti yang dilakukan di 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 saat itu, browser melihat bahwa Anda telah mengubah tekstur konteks saat ini dan memperbarui kanvas untuk menampilkan tekstur tersebut sebagai gambar. Jika ingin memperbarui konten kanvas lagi setelah itu, Anda perlu merekam dan mengirimkan buffering perintah baru, memanggil context.getCurrentTexture() lagi untuk mendapatkan tekstur baru untuk kartu render.

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

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

Pilih warna!

Jujur saja, kotak hitam itu cukup membosankan. Jadi luangkan waktu sejenak sebelum melanjutkan ke bagian berikutnya untuk mempersonalisasi sedikit.

  1. Pada 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 menginstruksikan pass render warna yang harus digunakan saat melakukan operasi clear di awal kartu. Kamus yang diteruskan ke dalamnya berisi empat nilai: r untuk merah, g untuk hijau, b untuk biru, dan a untuk alfa (transparansi). Setiap nilai dapat berkisar dari 0 hingga 1, dan bersama-sama keduanya mendeskripsikan nilai saluran warna tersebut. Contoh:

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

Kode contoh dan screenshot dalam codelab ini menggunakan warna biru gelap, tetapi Anda dapat memilih warna apa pun yang diinginkan.

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

Kanvas dihapus menjadi warna biru gelap untuk menunjukkan cara mengubah warna default yang jelas.

4. Gambar geometri

Di akhir bagian ini, aplikasi Anda akan menggambar beberapa geometri sederhana ke kanvas: persegi berwarna. Berhati-hatilah sekarang karena tampaknya akan banyak pekerjaan untuk output sederhana, tetapi itu karena WebGPU dirancang untuk merender banyak geometri dengan sangat efisien. Efek samping dari efisiensi ini adalah melakukan hal-hal yang relatif sederhana mungkin terasa luar biasa sulit, namun itulah harapan jika Anda beralih ke API seperti WebGPU—Anda ingin melakukan sesuatu yang sedikit lebih kompleks.

Memahami cara menggambar GPU

Sebelum perubahan kode lainnya, ada baiknya Anda melakukan ringkasan tingkat tinggi yang disederhanakan dan sederhana tentang cara GPU membuat bentuk yang Anda lihat di layar. (Jangan ragu untuk langsung membuka bagian Menentukan Vertices jika Anda sudah memahami dasar-dasar cara kerja rendering GPU.)

Tidak seperti API seperti Canvas 2D yang memiliki banyak bentuk dan opsi untuk Anda gunakan, GPU Anda hanya benar-benar menangani beberapa jenis bentuk (atau primitif yang berbeda yang disebut oleh WebGPU): titik, garis, dan segitiga. Untuk tujuan codelab ini, Anda hanya akan menggunakan segitiga.

GPU bekerja hampir secara eksklusif dengan segitiga karena segitiga memiliki banyak properti matematika yang bagus sehingga mudah untuk diproses dengan cara yang dapat diprediksi dan efisien. Hampir semua yang Anda gambar dengan GPU harus dibagi menjadi segitiga sebelum GPU dapat menggambarnya, dan segitiga tersebut harus ditentukan oleh titik sudutnya.

Titik ini, atau verteks, diberikan dalam nilai X, Y, dan (untuk konten 3D) Z yang menentukan titik di sistem koordinat kartesius yang ditentukan oleh WebGPU atau API serupa. Struktur sistem koordinat paling mudah untuk dipikirkan dalam kaitannya dengan kanvas di halaman Anda. Tidak peduli 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 -1 pada sumbu Y, dan tepi atas adalah +1 pada sumbu Y. Artinya (0, 0) selalu berada di tengah kanvas, (-1, -1) selalu berada di sudut kiri bawah, dan (1, 1) selalu berada di sudut kanan atas. Ini dikenal sebagai Ruang Klip.

Grafik sederhana yang memvisualisasikan ruang Koordinat Perangkat yang Dinormalkan.

Pada awalnya, verteks jarang ditentukan dalam sistem koordinat ini, sehingga GPU mengandalkan program kecil yang disebut shader shader untuk melakukan perhitungan apa pun yang diperlukan untuk mengubah verteks menjadi ruang klip, serta penghitungan lain yang diperlukan untuk menggambar verteks. Misalnya, shader mungkin menerapkan beberapa animasi atau menghitung arah dari verteks ke sumber cahaya. Shader ini ditulis oleh Anda, developer WebGPU, dan memberikan kontrol yang luar biasa terhadap cara kerja GPU.

Dari sana, GPU akan mengambil semua segitiga yang dibuat oleh verteks yang diubah ini dan menentukan piksel mana di layar yang diperlukan untuk menggambarnya. Kemudian, kode ini akan menjalankan program kecil lainnya yang Anda tulis bernama shader fragmen yang menghitung warna yang seharusnya digunakan oleh setiap piksel. Perhitungan tersebut dapat sesederhana hijau kembali atau sekompleks menghitung sudut permukaan relatif terhadap sinar matahari yang memantul dari permukaan lain di sekitarnya, difilter melalui kabut, dan dimodifikasi oleh seberapa logam permukaan tersebut. Hal ini sepenuhnya berada di bawah kendali Anda, yang dapat memberdayakan dan membebani.

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

Menentukan verteks

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

Artinya, Anda harus menyediakan GPU dengan empat titik yang berbeda, satu untuk setiap empat sudut persegi. Misalnya, sebuah persegi yang digambar di tengah kanvas, yang ditarik dari tepi, memiliki koordinat sudut seperti ini:

Grafik Koordinat Perangkat yang Dinormalkan yang menunjukkan koordinat untuk sudut persegi

Untuk memasukkan koordinat tersebut ke GPU, Anda harus menempatkan nilai dalam TypedArray. Jika Anda belum terbiasa dengannya, TypedArrays adalah grup objek JavaScript yang memungkinkan Anda mengalokasikan blok memori yang berdekatan dan menafsirkan setiap elemen dalam rangkaian sebagai jenis data tertentu. Misalnya, dalam Uint8Array, setiap elemen dalam array adalah byte tunggal yang tidak ditandatangani. TypedArray sangat bagus untuk mengirim data bolak-balik dengan API yang sensitif terhadap tata letak memori, seperti WebAssembly, WebAudio, dan (tentu saja) WebGPU.

Untuk contoh persegi, karena nilainya pecahan, Float32Array adalah tepat.

  1. Buat array yang menyimpan semua posisi vertex dalam diagram dengan menempatkan deklarasi array berikut dalam kode Anda. Tempat yang tepat untuk meletakkannya 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 berpengaruh pada nilai; hal ini hanya demi kenyamanan Anda dan membuatnya lebih mudah dibaca. Hal ini membantu Anda melihat bahwa setiap pasangan nilai membentuk koordinat X dan Y untuk satu verteks.

Namun, ada satu masalah! Ingat, GPU berfungsi dalam hal segitiga? Artinya, Anda harus menyediakan verteks dalam grup yang terdiri dari tiga. Anda punya satu grup beranggotakan empat orang. Solusinya adalah mengulangi dua verteks untuk membuat dua segitiga yang berbagi tepi melewati bagian tengah persegi.

Diagram yang menunjukkan cara keempat 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 untuk yang merah. (Anda juga dapat memilih untuk membagi persegi dengan dua sudut lainnya; tidak ada bedanya.)

  1. Perbarui array vertices Anda sebelumnya agar terlihat 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 verteks sama persis, dan GPU merendernya tanpa celah. Ini akan dirender sebagai bentuk persegi tunggal yang solid.

Membuat buffering vertex

GPU tidak dapat menggambar verteks dengan data dari array JavaScript. GPU sering kali memiliki memorinya sendiri yang sangat dioptimalkan untuk rendering, sehingga data apa pun yang Anda inginkan untuk digunakan GPU saat menggambar perlu ditempatkan di memori tersebut.

Untuk banyak nilai, termasuk data vertex, memori sisi GPU dikelola melalui objek GPUBuffer. Buffering adalah blok memori yang mudah diakses oleh GPU dan ditandai untuk tujuan tertentu. Anda dapat menganggapnya sedikit seperti TypedArray yang terlihat oleh GPU.

  1. Untuk membuat buffering guna menyimpan verteks, 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 diperhatikan adalah memberi label kepada buffering. Setiap objek WebGPU yang Anda buat dapat diberi label opsional, dan Anda tentu ingin melakukannya. Label adalah string apa pun yang Anda inginkan, selama membantu Anda mengidentifikasi apa objeknya. Jika Anda mengalami masalah, label tersebut akan digunakan dalam pesan error yang dihasilkan WebGPU untuk membantu Anda memahami apa yang salah.

Berikutnya, berikan ukuran untuk buffering dalam byte. Anda memerlukan buffering dengan 48 byte, yang ditentukan dengan mengalikan ukuran float 32 bit ( 4 byte) dengan jumlah float dalam array vertices (12). Untungnya, TypedArrays sudah menghitung byteLength untuk Anda, sehingga Anda dapat menggunakannya saat membuat buffering.

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

Objek buffering yang ditampilkan kepada Anda buram—Anda tidak dapat (dengan mudah) memeriksa data yang disimpannya. 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 memorinya.

Saat buffer pertama kali dibuat, memori yang ada di dalamnya akan diinisialisasi ke nol. Ada beberapa cara untuk mengubah kontennya, tetapi cara termudah adalah dengan memanggil device.queue.writeBuffer() dengan TypedArray yang ingin Anda salin.

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

index.html

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

Menentukan tata letak vertex

Sekarang Anda memiliki buffer dengan data vertex di dalamnya, tetapi sejauh menyangkut GPU, data tersebut hanya merupakan blob byte. Anda perlu memberikan sedikit informasi lagi jika akan mengambil gambar bersamanya. Anda harus dapat memberi tahu WebGPU lebih lanjut tentang struktur data vertex.

index.html

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

Secara sekilas, hal ini mungkin sedikit membingungkan, tetapi relatif mudah untuk diurai.

Hal pertama yang Anda berikan adalah arrayStride. Ini adalah jumlah byte yang harus dilewati GPU ke depan dalam buffer saat mencari vertex berikutnya. Setiap verteks 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 bagian informasi yang dienkode ke setiap verteks. Verteks Anda hanya berisi satu atribut (posisi verteks), tetapi kasus penggunaan yang lebih canggih sering kali memiliki verteks dengan beberapa atribut di dalamnya, seperti warna verteks atau arah yang ditunjuk permukaan geometri. Namun, hal tersebut berada di luar cakupan codelab ini.

Dalam atribut tunggal, Anda harus menentukan format data terlebih dahulu. Ini berasal dari daftar jenis GPUVertexFormat yang menjelaskan setiap jenis data vertex yang dapat dipahami oleh GPU. Verteks Anda memiliki dua float 32-bit masing-masing, sehingga Anda menggunakan format float32x2. Jika data vertex Anda terdiri dari empat bilangan bulat tanpa tanda tangan 16-bit, misalnya, Anda akan menggunakan uint16x4. Lihat polanya?

Selanjutnya, offset menjelaskan berapa banyak byte dalam verteks yang dimulai oleh atribut tertentu ini. Anda hanya perlu mengkhawatirkan hal ini jika buffering 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 tetapkan. Atribut ini menautkan atribut ini ke input tertentu di shader vertex, yang akan Anda pelajari di bagian berikutnya.

Perhatikan bahwa meskipun Anda mendefinisikan nilai-nilai ini sekarang, Anda sebenarnya belum meneruskannya ke WebGPU API di mana pun. Itu akan muncul, tetapi cara paling mudah adalah memikirkan nilai-nilai ini pada titik saat Anda menentukan verteks, jadi Anda menyiapkannya sekarang untuk digunakan nanti.

Mulai dengan shader

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

Shader adalah program kecil yang Anda tulis dan mengeksekusi di GPU. Setiap shader beroperasi pada tahap data yang berbeda: pemrosesan Vertex, pemrosesan Fragmen, atau Compute umum. Karena elemen tersebut terdapat di GPU, strukturnya lebih kaku daripada rata-rata JavaScript. Namun, struktur tersebut memungkinkannya dieksekusi dengan sangat cepat dan, yang terpenting, secara paralel.

Shader di WebGPU ditulis dalam bahasa bayangan yang disebut WGSL (Bahasa Shading WebGPU). Secara sintaksis, WGSL sedikit mirip dengan Rust, dengan fitur yang ditujukan untuk membuat jenis tugas GPU umum (seperti matematika vektor dan matriks) lebih mudah dan lebih cepat. Mengajarkan keseluruhan bahasa bayangan jauh melampaui cakupan codelab ini, tetapi semoga Anda akan mengambil beberapa dasar saat mempelajari beberapa contoh sederhana.

Shader itu sendiri diteruskan ke WebGPU sebagai string.

  • Buat tempat untuk memasukkan kode shader dengan menyalin kode berikut 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(), yang Anda menyediakan label opsional dan WGSL code sebagai string. (Perhatikan bahwa Anda menggunakan tanda kutip terbalik di sini untuk mengizinkan string multi-baris!) Setelah Anda menambahkan beberapa kode WGSL yang valid, fungsi tersebut akan menampilkan objek GPUShaderModule dengan hasil yang dikompilasi.

Menentukan shader vertex

Mulai dengan shader vertex karena di sanalah GPU juga dimulai.

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

Penting untuk memahami bahwa keduanya juga tidak akan dipanggil secara berurutan. Sebagai gantinya, GPU unggul dalam menjalankan shader seperti ini secara paralel, yang berpotensi memproses ratusan (atau bahkan ribuan) verteks secara bersamaan. Hal ini sangat penting karena mengakibatkan GPU memiliki kecepatan yang luar biasa, tetapi ada beberapa batasan. Untuk memastikan paralelisasi ekstrem, shader vertex tidak dapat saling berkomunikasi. Setiap pemanggilan shader hanya dapat melihat data untuk satu vertex pada satu waktu, dan hanya dapat menampilkan nilai untuk satu vertex.

Di WGSL, fungsi shader vertex dapat diberi nama apa pun yang Anda inginkan, tetapi harus memiliki atribut @vertex di depannya untuk menunjukkan tahap shader mana yang diwakilinya. WGSL menunjukkan fungsi dengan kata kunci fn, menggunakan tanda kurung untuk mendeklarasikan argumen, dan menggunakan tanda kurung kurawal untuk menentukan cakupan.

  1. Buat fungsi @vertex kosong, seperti ini:

index.html (kode createShaderModule)

@vertex
fn vertexMain() {

}

Namun, hal tersebut tidak valid karena shader vertex harus setidaknya menampilkan posisi akhir verteks yang sedang diproses dalam ruang klip. Nilai ini selalu diberikan sebagai vektor 4 dimensi. Vektor adalah hal yang sangat umum digunakan di shader. Oleh karena itu, vektor diperlakukan sebagai primitif kelas satu dalam bahasa, dengan jenisnya sendiri seperti vec4f untuk vektor 4 dimensi. Ada jenis yang sama untuk vektor 2D (vec2f) dan vektor 3D (vec3f), juga!

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

index.html (kode createShaderModule)

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

}

Tentu saja, jika fungsi memiliki jenis nilai yang ditampilkan, Anda harus benar-benar menampilkan nilai dalam isi fungsi. Anda dapat membuat vec4f baru untuk ditampilkan, menggunakan sintaksis vec4f(x, y, z, w). Nilai x, y, dan z semuanya merupakan bilangan floating point yang, dalam nilai yang ditampilkan, menunjukkan tempat simpul berada dalam ruang klip.

  1. Menampilkan nilai statis (0, 0, 0, 1), dan secara teknis Anda memiliki shader vertex yang valid, meskipun shader vertex yang tidak pernah menampilkan apa pun karena GPU mengenali bahwa segitiga yang dihasilkan hanyalah satu titik, lalu menghapusnya.

index.html (kode createShaderModule)

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

Sebagai gantinya, Anda dapat menggunakan data dari buffering yang telah dibuat, dan melakukannya dengan mendeklarasikan argumen untuk fungsi dengan atribut @location() dan jenis yang cocok dengan yang Anda deskripsikan di vertexBufferLayout. Anda menentukan shaderLocation dari 0, jadi dalam kode WGSL Anda, tandai argumen dengan @location(0). Anda juga menentukan formatnya sebagai float32x2, yang merupakan vektor 2D, jadi di WGSL, argumen Anda adalah vec2f. Anda dapat memberi nama sesuka Anda, tetapi karena ini mewakili posisi vertex, nama seperti pos tampak alami.

  1. Ubah fungsi shader ke 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 mengembalikan posisi tersebut. Karena posisinya adalah vektor 2D dan jenis nilai yang ditampilkan adalah vektor 4D, Anda harus sedikit mengubahnya. Yang ingin Anda lakukan adalah mengambil dua komponen dari argumen posisi dan menempatkannya di dua komponen pertama dari vektor hasil, sehingga masing-masing dua komponen terakhir sebagai 0 dan 1.

  1. Tampilkan posisi yang benar dengan menyatakan secara eksplisit komponen posisi mana 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 ini sangat umum di shader, Anda juga dapat meneruskan vektor posisi sebagai argumen pertama dengan cara yang praktis dan memiliki arti yang sama.

  1. Tulis ulang pernyataan return dengan kode berikut:

index.html (kode createShaderModule)

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

Dan itulah shader vertex awal Anda. Sangat sederhana, hanya meneruskan posisi secara efektif tidak berubah, tetapi sudah cukup baik untuk memulai.

Menentukan shader fragmen

Selanjutnya adalah shader fragmen. Shader fragmen beroperasi dengan cara yang sangat mirip dengan shader vertex, tetapi tidak dipanggil untuk setiap verteks, akan dipanggil untuk setiap piksel yang digambar.

Shader fragmen selalu dipanggil setelah shader vertex. GPU mengambil output dari shader vertex dan melakukan triangulasi padanya, sehingga membuat segitiga dari kumpulan tiga titik. Kode ini akan melakukan raster terhadap setiap segitiga tersebut dengan mencari tahu piksel lampiran warna output mana yang disertakan dalam segitiga tersebut, lalu memanggil shader fragmen satu kali untuk setiap piksel. Shader fragmen menampilkan warna, biasanya dihitung dari nilai yang dikirim dari shader vertex dan aset seperti tekstur, yang ditulis GPU ke lampiran warna.

Sama seperti shader vertex, shader fragmen dieksekusi secara paralel secara masif. Keduanya sedikit lebih fleksibel daripada shader vertex dalam input dan outputnya, tetapi Anda dapat mempertimbangkan untuk menampilkan satu warna saja untuk setiap piksel dari setiap segitiga.

Fungsi shader fragmen WGSL dilambangkan dengan atribut @fragment dan juga menampilkan vec4f. Namun, dalam hal ini, vektor merepresentasikan warna, bukan posisi. Nilai yang ditampilkan harus diberi atribut @location agar dapat menunjukkan ke colorAttachment mana dari panggilan beginRenderPass yang ditulis ke warna yang ditampilkan. 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 vektor yang ditampilkan adalah nilai warna merah, hijau, biru, dan alfa, yang ditafsirkan dengan cara yang sama persis seperti clearValue yang Anda tetapkan di beginRenderPass sebelumnya. Jadi, vec4f(1, 0, 0, 1) berwarna merah cerah, yang tampak seperti warna yang bagus untuk kotak Anda. Namun, Anda dapat menentukannya ke warna apa pun yang Anda inginkan.

  1. Tetapkan 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)
}

Dan ini adalah shader fragmen yang lengkap. Hal ini tidak terlalu menarik karena hanya menetapkan setiap piksel dari setiap segitiga ke merah, tetapi itu sudah cukup untuk saat ini.

Sebagai ringkasan, setelah menambahkan kode shader yang dijelaskan di atas, panggilan createShaderModule Anda sekarang 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 pipeline render

Modul shader tidak dapat digunakan untuk rendering sendiri. Sebagai gantinya, Anda harus menggunakannya sebagai bagian dari GPURenderPipeline, yang dibuat dengan memanggil device.createRenderPipeline(). Pipeline render mengontrol cara geometri digambar, termasuk hal-hal seperti shader mana yang digunakan, cara menafsirkan data dalam buffering vertex, jenis geometri apa yang harus dirender (garis, titik, segitiga...), dan banyak lagi.

Pipeline render adalah objek yang paling kompleks di seluruh API, tetapi jangan khawatir. Sebagian besar nilai yang dapat diteruskan ke sana bersifat opsional, dan Anda hanya perlu memberikan 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 (selain buffering vertex) yang diperlukan pipeline, tetapi Anda tidak memilikinya. Untungnya, Anda dapat meneruskan "auto" untuk saat ini, dan pipeline membuat tata letaknya sendiri dari shader.

Selanjutnya, Anda harus memberikan detail tentang tahap vertex. module adalah GPUShaderModule yang berisi shader vertex, dan entryPoint memberikan nama fungsi dalam kode shader yang dipanggil untuk setiap pemanggilan vertex. (Anda dapat memiliki beberapa fungsi @vertex dan @fragment dalam satu modul shader.) Buffering adalah array objek GPUVertexBufferLayout yang menjelaskan cara data Anda dikemas dalam buffering vertex yang Anda gunakan dengan pipeline ini. Untungnya, Anda sudah menentukan ini sebelumnya di vertexBufferLayout. Anda dapat meneruskannya di sini.

Terakhir, Anda memiliki detail tentang tahap fragment. Ini juga mencakup modul shader dan entryPoint, seperti tahap vertex. Bagian terakhir adalah menentukan targets yang digunakan dengan pipeline ini. Ini adalah array kamus yang memberikan detail—seperti tekstur format—dari lampiran warna yang dihasilkan oleh pipeline. Detail ini harus cocok dengan tekstur yang diberikan dalam colorAttachments kartu render yang digunakan dengan pipeline ini. Kartu render Anda menggunakan tekstur dari konteks kanvas, dan menggunakan nilai yang Anda simpan di canvasFormat untuk formatnya, sehingga Anda meneruskan format yang sama di sini.

Itu bahkan tidak dekat dengan semua opsi yang dapat Anda tentukan saat membuat pipeline render, tetapi itu cukup untuk kebutuhan codelab ini!

Gambar persegi

Dan dengan itu, sekarang Anda memiliki semua yang dibutuhkan untuk menggambar persegi Anda!

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

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

Tindakan ini akan memberikan WebGPU dengan semua informasi yang diperlukan untuk menggambar persegi Anda. Pertama, Anda menggunakan setPipeline() untuk menunjukkan pipeline mana yang harus digunakan untuk menggambar. Ini mencakup shader yang digunakan, tata letak data vertex, dan data status yang relevan lainnya.

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

Terakhir, Anda melakukan panggilan draw(), yang terasa aneh setelah semua penyiapan yang dilakukan sebelumnya. Satu-satunya hal yang perlu Anda teruskan adalah jumlah verteks yang harus dirender, yang diambil dari buffer vertex yang ditetapkan saat ini dan menafsirkannya dengan pipeline yang saat ini ditetapkan. Anda cukup melakukan hard code ke 6, tetapi menghitungnya dari array verteks (12 float / 2 koordinat per verteks == 6 verteks) berarti jika Anda pernah memutuskan untuk mengganti kuadrat dengan, misalnya, lingkaran, akan ada lebih sedikit untuk diperbarui secara manual.

  1. Muat ulang layar Anda dan (akhirnya) lihat hasil semua kerja keras Anda: satu persegi besar berwarna.

Sebuah kotak merah yang dirender dengan WebGPU

5. Menggambar petak

Pertama, luangkan waktu untuk mengucapkan selamat kepada diri Anda. Mendapatkan bit pertama geometri di layar sering kali merupakan salah satu langkah paling sulit dengan sebagian besar API GPU. Semua yang Anda lakukan dari sini dapat dilakukan dalam langkah-langkah yang lebih kecil, sehingga mempermudah verifikasi progres Anda.

Di bagian ini, Anda akan mempelajari:

  • Cara meneruskan variabel (disebut seragam) ke shader dari JavaScript.
  • Cara menggunakan seragam untuk mengubah perilaku rendering.
  • Cara menggunakan instance untuk menggambar berbagai varian geometri yang sama.

Menentukan petak

Untuk merender petak, Anda perlu mengetahui informasi yang sangat mendasar tentang petak. Berapa banyak sel yang ada di dalamnya, baik lebar maupun tingginya? Anda bebas menentukan developer, tetapi untuk membuat semuanya lebih mudah, perlakukan petak sebagai persegi (lebar dan tinggi yang sama) dan gunakan ukuran dua kali lipat. (Hal ini mempermudah beberapa soal matematika nanti.) Anda ingin membuatnya lebih besar pada akhirnya, tetapi untuk bagian lainnya, setel ukuran petak Anda ke 4x4 karena akan mempermudah untuk menunjukkan beberapa matematika yang digunakan di bagian ini. Tingkatkan skalanya nanti!

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

index.html

const GRID_SIZE = 4;

Selanjutnya, Anda harus memperbarui cara merender persegi agar dapat memuat GRID_SIZE kali GRID_SIZE dari ukuran tersebut di kanvas. Artinya, kotak tersebut harus jauh lebih kecil dan harus ada banyak.

Sekarang, salah satu cara Anda dapat melakukan pendekatan ini adalah dengan membuat buffering vertex lebih besar secara signifikan dan menentukan persegi senilai GRID_SIZE kali GRID_SIZE di dalamnya dengan ukuran dan posisi yang tepat. Bahkan, kode tersebut tidak akan terlalu buruk! Hanya beberapa loop dan sedikit perhitungan. Namun, hal tersebut juga tidak memaksimalkan penggunaan GPU dan menggunakan lebih banyak memori dari yang diperlukan untuk mencapai efek. Bagian ini membahas pendekatan yang lebih ramah GPU.

Membuat buffering seragam

Pertama, Anda harus memberi tahu ukuran petak yang telah Anda pilih ke shader, karena ukuran tersebut akan digunakan untuk mengubah tampilan. Anda cukup melakukan hard code pada ukuran ke dalam shader, tetapi itu berarti bahwa setiap kali Anda ingin mengubah ukuran petak, Anda harus membuat ulang shader dan merender pipeline, yang mahal. Cara yang lebih baik adalah dengan menyediakan ukuran petak ke shader sebagai seragam.

Anda telah mempelajari sebelumnya bahwa nilai yang berbeda dari buffering vertex diteruskan ke setiap pemanggilan shader vertex. Seragam adalah nilai dari buffering yang sama untuk setiap pemanggilan. Model ini berguna untuk mengomunikasikan nilai yang umum untuk bagian geometri (seperti posisinya), bingkai animasi lengkap (seperti waktu saat ini), atau bahkan seluruh masa pakai aplikasi (seperti preferensi pengguna).

  • Buat buffering seragam 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);

Pengujian ini seharusnya sudah tidak asing lagi, karena kodenya hampir sama persis dengan yang Anda gunakan untuk membuat buffering vertex sebelumnya. Itu karena seragam dikomunikasikan ke WebGPU API melalui objek GPUBuffer yang sama seperti verteks, dengan perbedaan utamanya adalah usage kali ini menyertakan GPUBufferUsage.UNIFORM, bukan GPUBufferUsage.VERTEX.

Mengakses seragam di shader

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

Tindakan ini menentukan seragam di shader yang disebut grid, yang merupakan vektor float 2D yang cocok dengan array yang baru saja Anda salin ke buffering seragam. Atribut ini juga menentukan bahwa seragam tersebut terikat di @group(0) dan @binding(0). Anda akan mempelajari arti nilai tersebut secara instan.

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

Jenis operasi vektor ini sangat umum di shader GPU karena banyak teknik rendering dan komputasi yang mengandalkannya.

Apa artinya dalam kasus Anda adalah (jika Anda menggunakan ukuran petak 4) persegi yang Anda render akan berukuran seperempat dari ukuran aslinya. Ini cocok jika Anda ingin memasukkan empat di antaranya ke dalam satu baris atau kolom.

Membuat Grup Bind

Mendeklarasikan seragam di shader tidak akan menghubungkannya dengan buffering yang Anda buat. Untuk melakukannya, Anda harus membuat dan menetapkan grup ikatan.

Grup binding adalah kumpulan resource yang ingin Anda buat agar dapat diakses oleh shader secara bersamaan. Ini dapat mencakup beberapa jenis buffer, seperti buffer seragam, dan resource lainnya seperti tekstur dan sampler yang tidak dibahas di sini, tetapi merupakan bagian umum dari teknik rendering WebGPU.

  • Buat grup binding dengan buffering seragam dengan menambahkan kode berikut setelah pembuatan buffering seragam 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 sekarang, Anda juga memerlukan layout yang menjelaskan jenis resource yang ada dalam grup binding ini. Ini adalah hal yang akan Anda pelajari lebih lanjut pada langkah berikutnya, tetapi untuk sementara Anda dapat meminta tata letak grup binding karena Anda telah membuat pipeline dengan layout: "auto". Hal itu menyebabkan pipeline membuat tata letak grup binding secara otomatis dari binding yang Anda deklarasikan dalam kode shader itu sendiri. Dalam hal ini, Anda memintanya kepada getBindGroupLayout(0), dengan 0 yang sesuai dengan @group(0) yang Anda ketik di shader.

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

  • binding, yang sesuai dengan nilai @binding() yang Anda masukkan di shader. Dalam hal ini, 0.
  • resource, yang merupakan resource sebenarnya yang ingin Anda ekspos ke variabel pada indeks binding yang ditentukan. Dalam hal ini, buffering seragam Anda.

Fungsi ini menampilkan GPUBindGroup, yang merupakan handle buram dan tidak dapat diubah. Anda tidak dapat mengubah resource yang ditunjuk oleh grup binding setelah dibuat, meskipun Anda dapat mengubah konten resource tersebut. Misalnya, jika Anda mengubah buffering seragam untuk memuat ukuran petak baru, yang ditampilkan oleh panggilan gambar mendatang menggunakan grup binding ini.

Mengikat grup binding

Setelah grup binding dibuat, Anda masih harus memberi tahu WebGPU untuk menggunakannya saat menggambar. Untungnya, hal ini cukup sederhana.

  1. Kembali ke pass 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) di kode shader. Anda mengatakan bahwa setiap @binding yang merupakan bagian dari @group(0) menggunakan resource dalam grup binding ini.

Dan sekarang buffer seragam diekspos ke shader Anda.

  1. Muat ulang halaman, lalu Anda akan melihat tampilan seperti ini:

Persegi merah kecil di tengah latar belakang biru gelap.

Hore! Ukuran kotak Anda sekarang seperempat dari ukuran sebelumnya! Itu tidak banyak, tetapi menunjukkan bahwa seragam Anda sebenarnya diterapkan dan shader sekarang dapat mengakses ukuran petak Anda.

Memanipulasi geometri di shader

Jadi, setelah dapat mereferensikan ukuran petak di shader, Anda dapat mulai melakukan tugas untuk memanipulasi geometri yang Anda render agar sesuai dengan pola petak yang diinginkan. Untuk melakukannya, pertimbangkan dengan tepat apa yang ingin Anda capai.

Anda perlu membagi kanvas secara konseptual ke dalam masing-masing sel. Untuk mempertahankan konvensi bahwa sumbu X meningkat saat Anda bergerak ke kanan dan sumbu Y meningkat saat Anda bergerak ke atas, ucapkan bahwa sel pertama berada di sudut kiri bawah kanvas. Hal ini memberi Anda tata letak yang terlihat seperti ini, dengan geometri persegi Anda saat ini di tengah:

Ilustrasi petak konseptual, ruang Koordinat Perangkat yang Dinormalkan akan dibagi saat memvisualisasikan setiap sel dengan geometri persegi yang saat ini dirender di tengahnya.

Tantangan Anda adalah menemukan metode di shader yang akan memungkinkan Anda memosisikan geometri persegi di salah satu sel tersebut dengan koordinat sel.

Pertama, Anda dapat melihat bahwa kotak Anda tidak sejajar dengan sel mana pun karena didefinisikan untuk mengelilingi tengah kanvas. Anda ingin kotak persegi bergeser setengah sel agar sejajar dengan sel di dalamnya.

Salah satu cara untuk memperbaikinya adalah dengan memperbarui buffering vertex persegi. Dengan menggeser verteks sehingga sudut kanan bawah berada pada, misalnya, (0,1, 0,1) dan bukan (-0,8, -0,8), Anda akan memindahkan kotak ini agar sejajar dengan batas sel. Namun, karena Anda memiliki kontrol penuh atas cara verteks diproses di shader, Anda dapat dengan mudah mendorongnya ke tempatnya menggunakan kode shader.

  1. Ubah modul shader vertex 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 kiri sebesar satu (yang, setengah, ruang klip) sebelum membaginya dengan ukuran petak. Hasilnya adalah persegi yang sejajar dengan petak, tepat di luar titik asal.

Visualisasi kanvas secara konseptual dibagi menjadi petak 4x4 dengan persegi merah dalam sel (2, 2)

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

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

Dan sekarang kotak Anda diposisikan dengan baik dalam sel (0, 0)!

Visualisasi kanvas secara konseptual dibagi menjadi petak 4x4 dengan persegi merah dalam sel (0, 0)

Bagaimana jika Anda ingin menempatkannya di sel lain? Cari tahu dengan mendeklarasikan vektor cell dalam shader dan mengisinya dengan nilai statis seperti let cell = vec2f(1, 1).

Jika Anda menambahkannya ke gridPos, - 1 akan diurungkan dalam algoritme, jadi bukan itu yang Anda inginkan. Sebagai gantinya, Anda dapat memindahkan kotak hanya dengan satu unit petak (seperempat dari kanvas) untuk setiap sel. Sepertinya Anda perlu melakukan pembagian lagi dengan grid!

  1. Ubah posisi petak 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 memuat ulang sekarang, Anda akan melihat hal berikut:

Visualisasi kanvas secara konseptual dibagi menjadi petak 4x4 dengan persegi merah yang berpusat di antara sel (0, 0), sel (0, 1), sel (1, 0), dan sel (1, 1).

Hm. Tidak sesuai dengan keinginan Anda.

Alasannya karena koordinat kanvas berubah dari -1 menjadi +1, maka benar-benar 2 unit. Artinya, jika Anda ingin memindahkan sudut seperempat kanvas, Anda harus memindahkannya 0,5 unit. Ini adalah kesalahan yang mudah dilakukan saat berpikir dengan koordinat GPU. Untungnya, perbaikannya mudah.

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

Dan proses ini akan memberikan apa yang Anda inginkan.

Visualisasi kanvas secara konseptual dibagi menjadi petak 4x4 dengan persegi merah dalam sel (1, 1)

Screenshot terlihat seperti ini:

Screenshot kotak merah dengan latar belakang biru gelap. Kotak merah yang digambar di posisi yang sama seperti yang dideskripsikan dalam diagram sebelumnya, tetapi tanpa overlay petak.

Selain itu, Anda kini dapat menetapkan cell ke nilai apa pun dalam batas petak, lalu memuat ulang untuk melihat render persegi di lokasi yang diinginkan.

Instance gambar

Setelah Anda dapat menempatkan persegi di tempat yang diinginkan dengan sedikit matematika, langkah berikutnya adalah merender satu persegi di setiap sel petak.

Salah satu cara untuk mendekatinya adalah dengan menulis koordinat sel ke buffering yang seragam, lalu memanggil draw satu kali untuk setiap persegi di petak, lalu mengupdate seragam setiap kali melakukannya. Namun, hal tersebut akan sangat lambat, karena GPU harus menunggu koordinat baru ditulis oleh JavaScript setiap saat. Salah satu kunci untuk mendapatkan performa bagus dari GPU adalah dengan meminimalkan waktu yang dihabiskan untuk menunggu di bagian lain pada sistem.

Sebagai gantinya, Anda dapat menggunakan teknik yang disebut pembuatan instance. Membuat instance adalah cara untuk memberi tahu GPU agar 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 instance.

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

index.html

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

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

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

Mengapa demikian? Ini karena Anda menggambar semua 16 kotak tersebut di tempat yang sama. Anda perlu memiliki beberapa logika tambahan di shader yang akan mengubah posisi geometri per instance.

Di shader, selain atribut vertex seperti pos yang berasal dari buffering vertex, Anda juga dapat mengakses apa yang dikenal 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 yang tidak ditandatangani 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 vertex Anda akan dipanggil enam kali dengan instance_index sebesar 0, satu kali untuk setiap posisi dalam buffering vertex. Lalu enam kali lagi dengan instance_index 1, lalu enam kali lagi dengan instance_index 2, dan seterusnya.

Untuk melihat cara kerjanya, Anda harus menambahkan instance_index bawaan ke input shader. Lakukan dengan cara yang sama seperti posisi, tetapi jangan memberi tag dengan atribut @location, gunakan @builtin(instance_index), lalu beri nama argumen apa pun yang Anda inginkan. (Anda dapat menyebutnya instance agar cocok dengan kode contoh.) Kemudian, gunakan 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 Anda memuat ulang sekarang, Anda akan melihat bahwa Anda memang memiliki lebih dari satu kotak! Namun, Anda tidak dapat melihat semuanya.

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

Itu karena koordinat sel yang Anda buat adalah (0, 0), (1, 1), (2, 2)... hingga (15, 15), tetapi hanya empat yang pertama yang muat di kanvas. Untuk membuat petak yang diinginkan, Anda harus mengubah instance_index agar setiap indeks dipetakan ke sel unik dalam petak Anda, seperti ini:

Visualisasi kanvas secara konseptual dibagi menjadi petak 4x4 dengan setiap sel juga sesuai dengan indeks instance linear.

Logikanya cukup mudah. Untuk nilai X setiap sel, Anda ingin modulo instance_index dan lebar petak, yang dapat Anda lakukan di WGSL dengan operator %. Dan untuk nilai Y setiap sel, Anda ingin instance_index dibagi dengan lebar petak, dengan membuang sisa pecahan. Anda dapat melakukannya dengan fungsi floor() WGSL.

  1. Ubah penghitungan, 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 membuat pembaruan pada kode tersebut, Anda akhirnya memiliki petak kotak yang telah lama ditunggu-tunggu!

Empat baris dari empat kolom kotak merah dengan latar belakang biru gelap.

  1. Dan setelah berhasil, kembali dan tingkatkan ukuran petak.

index.html

const GRID_SIZE = 32;

32 baris dari 32 kolom kotak merah dengan latar belakang biru gelap.

Tada! Anda dapat membuat petak ini benar-benar benar-benar besar sekarang dan GPU rata-rata Anda menanganinya dengan baik. Anda akan berhenti melihat setiap kotak jauh sebelum Anda menemukan bottleneck performa GPU.

6. Kredit ekstra: buat lebih berwarna!

Pada tahap ini, Anda dapat dengan mudah melompat ke bagian berikutnya karena Anda telah menyiapkan dasar untuk codelab lainnya. Namun, meskipun petak kotak semua berbagi warna yang sama dapat digunakan, hal ini tidak menarik, bukan? Untungnya, Anda dapat membuat segalanya sedikit lebih cerah dengan sedikit lebih banyak soal matematika dan kode shader.

Menggunakan struct dalam shader

Hingga saat ini, Anda telah meneruskan satu bagian data dari shader vertex: posisi yang diubah. Namun, Anda sebenarnya dapat menampilkan lebih banyak data dari shader vertex, lalu menggunakannya di shader fragmen.

Satu-satunya cara untuk meneruskan data dari shader vertex adalah dengan mengembalikannya. Shader vertex selalu diperlukan untuk menampilkan posisi, jadi jika Anda ingin menampilkan data lain bersama posisi tersebut, Anda harus menempatkannya dalam struct. Struktur dalam WGSL adalah jenis objek bernama yang berisi satu atau beberapa properti bernama. Properti juga dapat di-markup dengan atribut seperti @builtin dan @location. Anda mendeklarasikannya di luar fungsi, dan Anda dapat meneruskan instance di dalam dan di luar fungsi, sesuai kebutuhan. Misalnya, pertimbangkan shader vertex 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);
}
  • Ekspresikan 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 ke posisi input dan indeks instance dengan input, dan struct yang Anda tampilkan terlebih dahulu harus dideklarasikan sebagai variabel dan memiliki properti individualnya. Dalam hal ini, tidak ada terlalu banyak perbedaan, dan bahkan membuat fungsi shader sedikit lebih panjang, tetapi karena shader Anda semakin kompleks, penggunaan struct dapat menjadi cara yang bagus untuk membantu mengatur data Anda.

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. Jika shader tahu lebih banyak tentang geometri yang diwarnainya, Anda dapat menggunakan data tambahan tersebut untuk membuat segalanya sedikit lebih menarik. Misalnya, bagaimana jika Anda ingin mengubah warna setiap persegi berdasarkan koordinat selnya? Tahap @vertex mengetahui sel mana yang sedang dirender; Anda hanya perlu meneruskannya ke tahap @fragment.

Untuk meneruskan data antara tahap verteks dan fragmen, Anda harus menyertakannya dalam struct output dengan @location pilihan kami. Karena Anda ingin meneruskan koordinat sel, tambahkan koordinat tersebut ke struct VertexOutput dari sebelumnya, lalu tetapkan dalam fungsi @vertex sebelum Anda menampilkan.

  1. Ubah nilai yang ditampilkan shader vertex, 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. Dalam fungsi @fragment, terima nilai dengan menambahkan argumen dengan @location yang sama. (Nama tidak harus cocok, tetapi lebih mudah melacaknya jika memang ada)

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. Atau, 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. Alternatif lain**,** karena dalam kode Anda, kedua fungsi ini ditentukan dalam modul shader yang sama, adalah menggunakan kembali struct output tahap @vertex. Hal ini membuat penerusan nilai menjadi mudah karena nama dan lokasinya konsisten secara alami.

index.html (panggilan createShaderModule)

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

Apa pun pola yang Anda pilih, hasilnya adalah Anda memiliki akses ke nomor sel dalam fungsi @fragment dan dapat menggunakannya untuk memengaruhi warna. Dengan salah satu kode di atas, output-nya akan terlihat seperti ini:

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

Tentu saja ada lebih banyak warna, tetapi tidak terlihat bagus. Anda mungkin bertanya-tanya mengapa hanya baris kiri dan bawah yang berbeda. Ini karena nilai warna yang Anda tampilkan dari fungsi @fragment mengharuskan setiap saluran berada dalam rentang 0 hingga 1, dan nilai apa pun di luar rentang tersebut akan dikunci ke sana. Di sisi lain, rentang sel Anda berkisar dari 0 hingga 32 di sepanjang setiap sumbu. Jadi, apa yang Anda lihat di sini adalah baris dan kolom pertama langsung mencapai nilai 1 penuh pada saluran warna merah atau hijau, dan setiap sel setelahnya akan dibulatkan ke nilai yang sama.

Jika menginginkan transisi yang lebih halus antarwarna, Anda harus menampilkan nilai pecahan untuk setiap saluran warna, idealnya mulai dari nol dan berakhir di satu sumbu di setiap sumbu, yang berarti bagi lagi warna 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);
}

Muat ulang halaman, dan Anda dapat melihat bahwa kode baru memang memberikan gradien warna yang jauh lebih baik di seluruh petak.

Petak kotak yang berubah warna dari hitam, merah, hijau, dan kuning di berbagai sudut.

Meskipun hal ini merupakan peningkatan, sekarang ada sudut gelap yang tidak menguntungkan di bagian kiri bawah, yang petak-petaknya menjadi hitam. Saat Anda mulai melakukan simulasi Game of Life, bagian petak yang sulit dilihat akan mengaburkan apa yang terjadi. Saya akan senang mencerahkan itu.

Untungnya, Anda memiliki seluruh saluran warna yang tidak digunakan—biru—yang dapat digunakan. Efek yang idealnya Anda inginkan adalah membuat warna biru menjadi paling terang di mana warna lain paling gelap, lalu memudar saat warna lain tumbuh intensitasnya. Cara termudah untuk melakukannya adalah membuat channel dimulai di 1 dan mengurangi salah satu nilai sel. Nilai dapat berupa c.x atau c.y. Coba keduanya, lalu pilih yang Anda inginkan.

  1. Tambahkan warna yang lebih cerah 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 tampak sangat bagus.

Petak kotak yang berubah warna dari merah, hijau, menjadi biru menjadi kuning di berbagai sudut.

Ini bukan langkah yang penting! Namun, karena terlihat lebih baik, elemen ini akan disertakan dalam file sumber checkpoint yang sesuai, dan screenshot lainnya dalam codelab ini mencerminkan petak yang lebih berwarna ini.

7. Kelola status sel

Berikutnya, Anda perlu mengontrol sel mana yang dirender pada petak, berdasarkan beberapa status yang disimpan di GPU. Hal ini penting untuk simulasi akhir.

Yang Anda butuhkan hanyalah sinyal aktif untuk setiap sel, sehingga opsi apa pun yang memungkinkan Anda menyimpan array besar hampir semua jenis nilai berfungsi. Anda mungkin berpikir bahwa ini adalah kasus penggunaan lain untuk buffering seragam. Meskipun Anda dapat membuatnya, hal ini lebih sulit karena buffer seragam ukurannya terbatas, tidak dapat mendukung array berukuran dinamis (Anda harus menentukan ukuran array di shader), dan tidak dapat ditulis oleh shader komputasi. Item terakhir itu adalah yang paling bermasalah, karena Anda ingin melakukan simulasi Game of Life di GPU dalam shader komputasi.

Untungnya, ada opsi buffering lain yang menghindari semua batasan tersebut.

Membuat buffering penyimpanan

Buffering penyimpanan adalah buffer yang digunakan secara umum yang dapat dibaca dan ditulis dalam shader komputasi, dan dibaca dalam shader vertex. Ukurannya dapat sangat besar, dan tidak memerlukan ukuran tertentu yang dideklarasikan di shader, yang membuatnya jauh lebih seperti memori umum. Itulah yang Anda gunakan untuk menyimpan status sel.

  1. Untuk membuat buffering penyimpanan bagi status sel, gunakan yang saat ini mungkin sudah mulai menjadi cuplikan kode pembuatan buffering yang sudah tidak asing:

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

Sama seperti verteks dan buffer seragam, panggil device.createBuffer() dengan ukuran yang sesuai, lalu pastikan untuk menentukan penggunaan GPUBufferUsage.STORAGE kali ini.

Anda dapat mengisi buffering dengan cara yang sama seperti sebelumnya dengan mengisi TypedArray dengan ukuran yang sama dengan nilai, lalu memanggil device.queue.writeBuffer(). Karena Anda ingin melihat efek buffering pada petak, mulailah dengan mengisinya dengan sesuatu 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 buffering penyimpanan di shader

Berikutnya, update shader untuk melihat konten buffering penyimpanan sebelum Anda merender petak. Tampilannya sangat mirip dengan cara seragam ditambahkan sebelumnya.

  1. Update shader 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 terselip tepat di bawah seragam petak. Anda ingin mempertahankan @group yang sama dengan seragam grid, tetapi nomor @binding harus berbeda. Jenis var adalah storage agar dapat mencerminkan berbagai jenis buffering, dan bukan vektor tunggal, jenis yang Anda berikan untuk cellState adalah array nilai u32 agar cocok dengan Uint32Array di JavaScript.

Berikutnya, isi isi fungsi @vertex, buat 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 menonaktifkan sel jika status menyatakan bahwa sel tidak aktif? Jadi, karena status aktif dan tidak aktif yang Anda dapatkan dari array adalah 1 atau 0, Anda dapat menskalakan geometri menurut status aktif! Menskalakannya dengan 1 meninggalkan geometri saja, dan menskalakannya dengan 0 membuat geometri diciutkan menjadi satu titik, yang kemudian dihapus oleh GPU.

  1. Update kode shader Anda untuk menskalakan posisi berdasarkan status aktif sel. Nilai status harus mentransmisikan status ke 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 buffering penyimpanan ke grup binding

Sebelum Anda dapat melihat status sel diterapkan, tambahkan buffer penyimpanan ke grup binding. Karena ini adalah bagian dari @group yang sama dengan buffering seragam, tambahkan juga ke grup binding yang sama dalam kode JavaScript.

  • Tambahkan buffering 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 binding entri baru cocok dengan @binding() nilai yang terkait di shader.

Dengan demikian, Anda dapat memuat ulang dan melihat polanya muncul di petak.

Garis diagonal kotak persegi panjang dari kiri bawah ke kanan atas dengan latar belakang biru gelap.

Menggunakan pola buffering ping-pong

Sebagian besar simulasi seperti yang Anda buat biasanya menggunakan minimal dua salinan statusnya. Di setiap langkah simulasi, pengguna membaca dari satu salinan status dan menulis ke status lainnya. Kemudian, pada langkah berikutnya, balik dan baca dari status yang mereka tulis sebelumnya. Ini biasanya disebut sebagai pola ping pong karena versi terbaru dari status ini bolak-balik di antara salinan status dari setiap langkah.

Mengapa hal itu diperlukan? Lihat contoh sederhana: bayangkan Anda menulis simulasi yang sangat sederhana, yaitu Anda memindahkan blok aktif ke satu sel di setiap langkah. Agar mudah dipahami, tentukan data dan simulasi 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 akan berpindah hingga akhir array dalam satu langkah! Mengapa demikian? Karena Anda terus memperbarui status di tempatnya, maka Anda memindahkan sel aktif ke kanan, lalu Anda melihat sel berikutnya dan... hai! Sudah aktif! Pindahkan lagi ke kanan. Fakta bahwa Anda mengubah data pada waktu yang sama saat Anda mengamatinya akan merusak hasil.

Dengan menggunakan pola ping pong, Anda memastikan bahwa Anda selalu melakukan langkah simulasi berikutnya dengan hanya 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 buffering penyimpanan untuk membuat dua buffering 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 dua buffer, isi 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 buffering penyimpanan yang berbeda dalam rendering, perbarui juga grup binding agar memiliki dua varian yang berbeda:

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 pemuatan ulang halaman, tetapi sekarang Anda ingin menampilkan pembaruan data dari waktu ke waktu. Untuk melakukannya, Anda memerlukan loop render sederhana.

Loop render adalah loop berulang tanpa henti yang menarik konten ke kanvas pada interval tertentu. Banyak game dan konten lainnya yang ingin menganimasikan dengan lancar menggunakan fungsi requestAnimationFrame() untuk menjadwalkan callback dengan kecepatan yang sama dengan pembaruan layar (60 kali setiap detik).

Aplikasi ini juga dapat menggunakannya. Namun, dalam kasus ini, sebaiknya lakukan update yang berjalan lebih lama sehingga Anda dapat lebih mudah mengikuti simulasi. Kelola loop sendiri sehingga Anda dapat mengontrol tingkat update simulasi.

  1. Pertama, pilih rasio yang akan diperbarui oleh simulasi kami (200 md itu bagus, tetapi Anda bisa lebih lambat atau lebih cepat jika mau), lalu lacak berapa langkah simulasi yang telah diselesaikan.

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 dirender ke fungsi baru. Jadwalkan fungsi tersebut untuk mengulanginya pada interval yang Anda inginkan dengan setInterval(). Pastikan fungsi tersebut juga memperbarui jumlah langkah, dan gunakan untuk memilih mana dari dua grup binding yang akan diikat.

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

Sekarang, saat menjalankan aplikasi, Anda akan melihat kanvas bolak-balik menampilkan dua buffering status yang Anda buat.

Garis diagonal kotak persegi panjang dari kiri bawah ke kanan atas dengan latar belakang biru gelap. Garis vertikal dari kotak warna-warni dengan latar belakang biru gelap.

Dengan itu, Anda hampir menyelesaikan bagian rendering! Anda siap untuk menampilkan output simulasi Game of Life yang Anda buat di langkah berikutnya, tempat Anda akhirnya mulai menggunakan shader komputasi.

Tentu saja, terdapat lebih banyak kemampuan rendering WebGPU daripada bagian kecil yang Anda jelajahi di sini, tetapi sisanya berada di luar cakupan codelab ini. Semoga deskripsi ini dapat membantu Anda memahami cara kerja rendering WebGPU, sehingga membantu mempelajari teknik yang lebih canggih seperti rendering 3D agar lebih mudah dipahami.

8. Menjalankan simulasi

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

Menggunakan shader komputasi, akhirnya!

Anda telah mempelajari abstrak shader secara keseluruhan di codelab ini, tetapi apa sebenarnya yang dimaksud?

Shader komputasi mirip dengan shader fragmen dan fragmen karena keduanya dirancang untuk berjalan dengan paralelisme ekstrem pada GPU, tetapi tidak seperti dua tahap shader lainnya, shader komputasi tidak memiliki kumpulan input dan output tertentu. Anda membaca dan menulis data hanya dari sumber yang dipilih, seperti buffer penyimpanan. Artinya, Anda harus memberi tahu berapa banyak pemanggilan fungsi shader yang diinginkan, bukan mengeksekusinya satu kali untuk setiap verteks, instance, atau piksel. Kemudian, saat menjalankan shader, Anda akan diberi tahu pemanggilan mana yang sedang diproses, dan Anda dapat memutuskan data apa yang akan diakses dan operasi apa yang akan Anda lakukan dari sana.

Shader komputasi harus dibuat di modul shader, seperti halnya shader fragmen dan fragmen, jadi tambahkan shader ke kode Anda untuk memulai. Seperti yang mungkin Anda duga, mengingat struktur shader lain yang telah Anda terapkan, fungsi utama untuk shader komputasi Anda harus 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 grafik 3D, shader komputasi disusun sehingga Anda dapat meminta agar shader tersebut dipanggil beberapa kali di sepanjang sumbu X, Y, dan Z. Hal ini memudahkan Anda mengirim tugas yang sesuai dengan petak 2D atau 3D, yang sangat bagus untuk kasus penggunaan Anda. Anda ingin memanggil shader ini GRID_SIZE kali GRID_SIZE kali, sekali untuk setiap sel simulasi Anda.

Karena sifat arsitektur hardware GPU, petak ini dibagi menjadi grup kerja. Kelompok kerja memiliki ukuran X, Y, dan Z. Meskipun ukurannya masing-masing 1, sering kali ada manfaat performa untuk membuat kelompok kerja Anda sedikit lebih besar. Untuk shader, pilih ukuran kelompok kerja yang agak arbitrer 8 kali 8. Hal ini berguna untuk melacak kode JavaScript Anda.

  1. Tentukan konstanta untuk ukuran kelompok kerja Anda, seperti ini:

index.html

const WORKGROUP_SIZE = 8;

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

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

index.html (Panggilan createShaderModule Compute)

@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). (Sumbu yang Anda tetapkan defaultnya adalah 1, meskipun Anda setidaknya harus menentukan sumbu X.)

Seperti tahapan shader lainnya, ada berbagai nilai @builtin yang dapat Anda terima sebagai input dalam fungsi shader komputasi untuk memberi tahu Anda pemanggilan mana yang Anda gunakan dan memutuskan pekerjaan apa yang harus Anda lakukan.

  1. Tambahkan nilai @builtin, seperti ini:

index.html (Panggilan createShaderModule Compute)

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

}

Anda meneruskan global_invocation_id bawaan, yang merupakan vektor tiga dimensi dari bilangan bulat tanpa tanda tangan yang memberi tahu Anda posisi Anda di petak pemanggilan shader. Anda menjalankan shader ini satu kali untuk setiap sel dalam petak. Anda mendapatkan angka seperti (0, 0, 0), (1, 0, 0), (1, 1, 0)... hingga (31, 31, 0), yang berarti Anda dapat memperlakukannya sebagai indeks sel tempat Anda akan beroperasi.

Shader komputasi juga dapat menggunakan seragam, yang Anda gunakan seperti pada shader vertex dan fragmen.

  1. Gunakan seragam dengan shader komputasi untuk memberi tahu Anda ukuran petak, seperti ini:

index.html (Panggilan createShaderModule Compute)

@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 di shader vertex, Anda juga mengekspos status sel sebagai buffering penyimpanan. Namun, dalam hal ini Anda memerlukan dua di antaranya. Karena shader komputasi tidak memiliki output yang diperlukan, seperti posisi vertex atau warna fragmen, menulis nilai ke buffering 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 buffering penyimpanan yang feed dalam status petak saat ini dan yang Anda tulis status baru petaknya.

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

index.html (Panggilan createShaderModule Compute)

@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 buffering penyimpanan pertama dideklarasikan dengan var<storage>, yang membuatnya hanya-baca, tetapi buffering penyimpanan kedua dideklarasikan dengan var<storage, read_write>. Dengan cara ini, Anda dapat membaca dan menulis ke buffering, menggunakan buffer tersebut sebagai output untuk shader komputasi Anda. (Tidak ada mode penyimpanan hanya tulis di WebGPU).

Berikutnya, Anda harus memiliki cara untuk memetakan indeks sel ke dalam array penyimpanan linear. Ini pada dasarnya kebalikan dari yang Anda lakukan di shader vertex, tempat Anda mengambil instance_index linear dan memetakannya ke sel petak 2D. (Sebagai pengingat, algoritma Anda untuk itu adalah vec2f(i % grid.x, floor(i / grid.x)).)

  1. Tulis fungsi untuk menuju ke arah lain. Fungsi ini mengambil nilai Y sel, mengalikannya dengan lebar petak, lalu menambahkan nilai X sel.

index.html (Panggilan createShaderModule Compute)

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

}

Dan terakhir, untuk mengetahui apakah perintah tersebut berfungsi, terapkan algoritme yang sangat sederhana: jika aktif, sel akan dinonaktifkan, dan sebaliknya. Ini belum termasuk Game of Life, tetapi cukup untuk menunjukkan bahwa shader komputasi berfungsi.

  1. Tambahkan algoritme sederhana, seperti ini:

index.html (Panggilan createShaderModule Compute)

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

Dan itu saja untuk shader komputasi Anda—untuk saat ini! Namun, sebelum Anda dapat melihat hasilnya, ada beberapa perubahan lainnya yang perlu dilakukan.

Menggunakan Tata Letak Grup Bind dan Pipeline

Satu hal yang mungkin Anda perhatikan dari shader di atas adalah sebagian besar menggunakan input yang sama (seragam dan buffer penyimpanan) seperti di pipeline render. Jadi, Anda mungkin berpikir bahwa Anda dapat menggunakan grup pengikat yang sama dan menyelesaikannya, bukan? Kabar baiknya adalah Anda bisa melakukannya. Hanya diperlukan sedikit penyiapan manual agar dapat melakukannya.

Setiap kali membuat grup binding, Anda harus memberikan GPUBindGroupLayout. Sebelumnya, Anda mendapatkan tata letak tersebut dengan memanggil getBindGroupLayout() di pipeline render, yang selanjutnya membuatnya secara otomatis karena Anda menyediakan layout: "auto" saat membuatnya. Pendekatan tersebut 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 grup binding dan pipeline.

Untuk membantu memahami alasannya, pertimbangkan hal ini: dalam pipeline render, Anda menggunakan satu buffering seragam dan satu penyimpanan penyimpanan, tetapi dalam shader komputasi yang baru saja Anda tulis, Anda memerlukan buffering penyimpanan kedua. Karena kedua shader menggunakan nilai @binding yang sama untuk buffer penyimpanan seragam dan penyimpanan pertama, Anda dapat berbagi nilai tersebut di antara pipeline, dan pipeline render mengabaikan buffering penyimpanan kedua yang tidak digunakannya. Anda ingin membuat tata letak yang menjelaskan semua resource yang ada di grup binding, bukan hanya yang digunakan oleh 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 mirip dengan membuat grup binding itu sendiri, yakni Anda menjelaskan daftar entries. Perbedaannya adalah Anda menjelaskan jenis resource yang diperlukan entri dan cara penggunaannya, bukan memberikan resource itu sendiri.

Di setiap entri, Anda memberikan nomor binding untuk resource, yang (seperti yang Anda pelajari saat membuat grup binding) cocok dengan nilai @binding di shader. Anda juga menyediakan visibility, yang merupakan flag GPUShaderStage yang menunjukkan tahap shader mana yang dapat menggunakan resource. Anda ingin buffering penyimpanan seragam dan penyimpanan pertama dapat diakses di shader vertex dan shader komputasi, tetapi buffering penyimpanan kedua hanya dapat diakses di shader komputasi. Anda juga dapat membuat resource dapat diakses oleh shader fragmen dengan flag ini, tetapi Anda tidak perlu melakukannya di sini.

Terakhir, Anda menunjukkan jenis resource yang digunakan. Ini adalah kunci kamus lain, tergantung apa yang perlu diekspos. Di sini, ketiga resource adalah buffering, jadi Anda menggunakan kunci buffer untuk menentukan opsi untuk masing-masing resource. Opsi lainnya mencakup hal-hal seperti texture atau sampler, tetapi Anda tidak memerlukannya di sini.

Dalam kamus buffering, Anda menetapkan opsi seperti type buffering yang digunakan. Default-nya adalah "uniform", sehingga Anda dapat membiarkan kamus kosong untuk binding 0. (Namun, Anda harus menetapkan setidaknya buffer: {}, sehingga entri diidentifikasi sebagai buffering.) Binding 1 diberi jenis "read-only-storage" karena Anda tidak menggunakannya dengan akses read_write di shader, dan binding 2 memiliki jenis "storage" karena Anda menggunakan untuk menggunakannya dengan akses read_write.

Setelah bindGroupLayout dibuat, Anda dapat meneruskannya saat membuat grup binding, bukan membuat kueri grup binding dari pipeline. Dengan melakukannya, Anda perlu menambahkan entri buffering penyimpanan baru ke setiap grup binding agar cocok dengan tata letak yang baru saja Anda tentukan.

  1. Update pembuatan grup binding, 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] }
    }],
  }),
];

Dan setelah grup binding diupdate untuk menggunakan tata letak grup binding eksplisit ini, Anda perlu memperbarui pipeline render untuk menggunakan hal yang sama.

  1. Buat GPUPipelineLayout.

index.html

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

Tata letak pipeline adalah daftar tata letak grup binding (dalam hal ini, Anda memiliki satu) yang digunakan oleh satu atau beberapa pipeline. Urutan tata letak grup binding di array harus sesuai dengan atribut @group di shader. (Artinya, bindGroupLayout dikaitkan dengan @group(0).)

  1. Setelah Anda memiliki tata letak pipeline, perbarui pipeline render untuk menggunakannya, bukan "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

Sama seperti Anda memerlukan pipeline render untuk menggunakan shader vertex dan fragmen, Anda memerlukan pipeline komputasi untuk menggunakan shader komputasi. Untungnya, pipeline komputasi jauh tidak terlalu rumit dibandingkan pipeline render, karena tidak memiliki status yang ditetapkan, 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 yang baru, bukan "auto", seperti dalam pipeline render yang diperbarui, yang memastikan bahwa pipeline render dan pipeline komputasi Anda dapat menggunakan grup binding yang sama.

Kartu pass

Hal ini akan membuat Anda benar-benar memanfaatkan pipeline komputasi. Mengingat bahwa Anda melakukan rendering di render pass, Anda mungkin dapat menebak bahwa Anda perlu melakukan pekerjaan komputasi di pass komputasi. Pekerjaan komputasi dan render dapat terjadi di encoder perintah yang sama, jadi Anda ingin mengacak fungsi updateGrid sedikit.

  1. Pindahkan pembuatan encoder ke bagian atas fungsi, lalu mulai pass komputasi dengannya (sebelum step++).

index.html

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

const computePass = computeEncoder.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 daripada rekan rendering karena Anda tidak perlu khawatir dengan lampiran apa pun.

Sebaiknya jalankan pass komputasi sebelum kartu render karena memungkinkan kartu render untuk segera menggunakan hasil terbaru dari kartu komputasi. Itulah juga alasan Anda menambahkan jumlah step di antara penerusan, sehingga buffering output pipeline komputasi menjadi buffering input untuk pipeline render.

  1. Selanjutnya, setel pipeline dan kelompokkan binding di dalam compute pass, menggunakan pola yang sama untuk beralih di antara grup binding seperti yang Anda lakukan untuk rendering pass.

index.html

const computePass = computeEncoder.beginComputePass();

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

computePass.end();
  1. Terakhir, alih-alih menggambar seperti dalam render pass, Anda mengirimkan pekerjaan ke shader komputasi, yang memberi tahu jumlah kelompok kerja yang ingin Anda eksekusi pada setiap sumbu.

index.html

const computePass = computeEncoder.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 diperhatikan di sini adalah nomor yang Anda teruskan ke dispatchWorkgroups() bukan jumlah pemanggilan. Sebaliknya, ini adalah jumlah kelompok kerja yang akan dieksekusi, seperti yang ditentukan oleh @workgroup_size di shader Anda.

Jika Anda ingin shader berjalan 32x32 kali untuk mencakup seluruh petak, dan ukuran kelompok kerja Anda adalah 8x8, Anda harus mengirim kelompok kerja 4x4 (4 * 8 = 32). Oleh karena itu, Anda membagi ukuran petak dengan ukuran kelompok kerja dan meneruskan nilai tersebut ke dispatchWorkgroups().

Sekarang Anda dapat memuat ulang halaman, dan Anda akan melihat bahwa petak terbalik dengan setiap update.

Garis diagonal kotak persegi panjang dari kiri bawah ke kanan atas dengan latar belakang biru gelap. Persegi diagonal persegi warna-warni dengan lebar dua persegi dari kiri bawah ke kanan atas dengan latar belakang biru gelap. Inversi gambar sebelumnya.

Menerapkan algoritme untuk Game of Life

Sebelum mengupdate shader komputasi untuk menerapkan algoritme akhir, Anda ingin kembali ke kode yang menginisialisasi konten buffering penyimpanan dan memperbaruinya untuk menghasilkan buffering acak pada setiap pemuatan halaman. (Pola biasa tidak menjadi titik awal Game of Life yang sangat menarik.) Anda dapat mengacak nilai sesuai keinginan, tetapi ada cara mudah untuk memulai yang memberikan hasil yang wajar.

  1. Untuk memulai setiap sel dalam status acak, perbarui inisialisasi cellStateArray ke 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);

Kini Anda akhirnya dapat menerapkan logika untuk simulasi Game of Life. Setelah semua yang diperlukan untuk sampai ke sini, kode shader bisa jadi sangat mengecewakan!

Pertama, Anda perlu mengetahui berapa banyak sel tetangganya yang aktif pada sel tertentu. Anda hanya peduli hitungan mana yang aktif.

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

index.html (Panggilan createShaderModule Compute)

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

Fungsi cellActive akan menampilkan satu jika sel aktif, sehingga menambahkan nilai yang ditampilkan untuk memanggil cellActive untuk delapan sel di sekitarnya akan memberi Anda jumlah sel di sekitar yang aktif.

  1. Temukan jumlah tetangga yang aktif, seperti ini:

index.html (Panggilan createShaderModule Compute)

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, hal ini akan menyebabkan masalah kecil: apa yang terjadi jika sel yang Anda periksa berada di tepi papan? Menurut logika cellIndex() Anda saat ini, logika ini dapat tambahan ke baris berikutnya atau sebelumnya, atau melebihi tepi buffering!

Untuk Game of Life, cara umum dan mudah untuk menyelesaikannya adalah dengan menyetel agar sel di tepi petak memperlakukan sel pada tepi petak yang berlawanan sebagai tetangganya, sehingga menciptakan semacam efek melingkar.

  1. Mendukung penggabungan petak dengan perubahan kecil pada fungsi cellIndex().

index.html (Panggilan createShaderModule Compute)

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

Dengan menggunakan operator % untuk menggabungkan sel X dan Y saat diperluas melebihi ukuran petak, Anda memastikan bahwa Anda tidak akan pernah mengakses di luar batas buffering penyimpanan. Dengan begitu, Anda dapat merasa tenang karena jumlah activeNeighbors dapat diprediksi.

Kemudian Anda menerapkan salah satu dari empat aturan:

  • Sel apa pun yang memiliki kurang dari dua tetangga menjadi tidak aktif.
  • Semua sel aktif dengan dua atau tiga tetangga tetap aktif.
  • Semua sel yang tidak aktif dengan tepat tiga tetangga akan menjadi aktif.
  • Sel apa pun dengan lebih dari tiga tetangga menjadi tidak aktif.

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

  1. Terapkan logika Game of Life, seperti ini:

index.html (Panggilan createShaderModule Compute)

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

Sebagai 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... selesai! Selesai! Segarkan halaman dan saksikan pertumbuhan automaton seluler Anda yang baru dibuat!

Screenshot status contoh dari simulasi Game of Life, dengan sel warna-warni yang dirender pada latar belakang biru gelap.

9. Selamat!

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

Apa selanjutnya?

Bacaan lebih lanjut

Dokumen referensi