Ứng dụng WebGPU đầu tiên của bạn

1. Giới thiệu

Biểu trưng WebGPU có nhiều hình tam giác màu xanh dương tạo thành chữ "W" cách điệu

WebGPU là gì?

WebGPU là một API mới, hiện đại để truy cập vào các tính năng của GPU trong các ứng dụng web.

API hiện đại

Trước WebGPU, có WebGL, cung cấp một nhóm nhỏ các tính năng của WebGPU. Nó đã tạo ra một lớp nội dung web phong phú mới và các nhà phát triển đã tạo ra những điều tuyệt vời với nó. Tuy nhiên, API này dựa trên API OpenGL ES 2.0 được phát hành vào năm 2007, dựa trên API OpenGL cũ hơn. GPU đã phát triển đáng kể trong thời gian đó và các API gốc dùng để giao tiếp với GPU cũng phát triển như Direct3D 12, MetalVulkan.

WebGPU mang những tiến bộ của những API hiện đại này lên nền tảng web. API này tập trung vào việc bật các tính năng GPU theo cách đa nền tảng, đồng thời trình bày một API mang tính tự nhiên trên web và ít chi tiết hơn so với một số API gốc được xây dựng dựa trên API này.

Kết xuất

GPU thường liên quan đến khả năng kết xuất đồ hoạ nhanh, chi tiết và WebGPU cũng không phải là ngoại lệ. API này có các tính năng cần thiết để hỗ trợ nhiều kỹ thuật kết xuất phổ biến nhất hiện nay trên cả GPU dành cho máy tính và thiết bị di động, đồng thời mở ra một lộ trình để bổ sung các tính năng mới trong tương lai khi các chức năng phần cứng không ngừng phát triển.

Điện toán

Ngoài khả năng kết xuất hình ảnh, WebGPU còn khai phá tiềm năng của GPU trong việc thực hiện các khối lượng công việc chung, tải song song cao độ. Bạn có thể sử dụng trình đổ bóng tính toán này một cách độc lập mà không cần bất kỳ thành phần kết xuất nào, hoặc dùng như một phần được tích hợp chặt chẽ trong quy trình kết xuất hình ảnh.

Trong lớp học lập trình hôm nay, bạn sẽ tìm hiểu cách tận dụng cả khả năng kết xuất và điện toán của WebGPU để tạo một dự án nhập môn đơn giản!

Sản phẩm bạn sẽ tạo ra

Trong lớp học lập trình này, bạn sẽ sử dụng WebGPU để xây dựng Trò chơi cuộc sống của Conway. Ứng dụng này sẽ:

  • Sử dụng khả năng kết xuất của WebGPU để vẽ đồ hoạ 2D đơn giản.
  • Sử dụng các tính năng điện toán của WebGPU để thực hiện quá trình mô phỏng.

Ảnh chụp màn hình sản phẩm cuối cùng của lớp học lập trình này

Trò chơi sinh tồn là trò chơi tự động di động, trong đó một lưới các ô thay đổi trạng thái theo thời gian dựa trên một số bộ quy tắc. Trong Trò chơi của sự sống, các ô hoạt động hoặc không hoạt động tuỳ thuộc vào số lượng ô lân cận đang hoạt động. Điều này dẫn đến các mô hình thú vị biến động khi bạn theo dõi.

Kiến thức bạn sẽ học được

  • Cách thiết lập WebGPU và định cấu hình canvas.
  • Cách vẽ hình học 2D đơn giản.
  • Cách sử dụng chương trình đổ bóng dạng đỉnh và mảnh để sửa đổi nội dung đang được vẽ.
  • Cách sử dụng chương trình đổ bóng điện toán để thực hiện một hoạt động mô phỏng đơn giản.

Lớp học lập trình này tập trung giới thiệu các khái niệm cơ bản về WebGPU. Bài đánh giá này không nhằm mục đích đánh giá toàn diện về API, cũng không đề cập (hoặc yêu cầu) các chủ đề thường xuyên có liên quan như toán học ma trận 3D.

Bạn cần có

  • Sử dụng phiên bản Chrome gần đây (113 trở lên) trên ChromeOS, macOS hoặc Windows. WebGPU là một API đa trình duyệt, đa nền tảng nhưng chưa được phát hành rộng rãi đến mọi nơi.
  • Có kiến thức về HTML, JavaScript và Công cụ của Chrome cho nhà phát triển.

Bạn không bắt buộc phải quen thuộc với các API Đồ hoạ khác, chẳng hạn như WebGL, Metal, Vulkan hoặc Direct3D, nhưng nếu đã có kinh nghiệm sử dụng các API này, có thể bạn sẽ nhận thấy nhiều điểm tương đồng với WebGPU nhằm giúp bạn bắt đầu học tập!

2. Bắt đầu thiết lập

Lấy mã

Lớp học lập trình này không có phần phụ thuộc nào và sẽ hướng dẫn bạn từng bước cần thiết để tạo ứng dụng WebGPU, vì vậy, bạn không cần lập trình để bắt đầu. Tuy nhiên, một số ví dụ về cách hoạt động có thể làm điểm kiểm tra có tại https://glitch.com/edit/#!/your-first-webgpu-app. Bạn có thể xem và tham khảo chúng trong quá trình học nếu gặp khó khăn.

Hãy sử dụng Play Console!

WebGPU là một API khá phức tạp với nhiều quy tắc thực thi việc sử dụng đúng cách. Tệ hơn nữa, do cách thức hoạt động của API này nên API này không thể tăng các ngoại lệ JavaScript thông thường cho nhiều lỗi, khiến cho việc xác định chính xác nguồn gốc của vấn đề trở nên khó khăn hơn.

Bạn sẽ gặp vấn đề khi phát triển bằng WebGPU, đặc biệt là với người mới bắt đầu, và điều này hoàn toàn bình thường! Các nhà phát triển phía sau API nhận thức được những thách thức khi làm việc với việc phát triển GPU và đã nỗ lực làm việc để đảm bảo rằng bất cứ khi nào mã WebGPU của bạn gây ra lỗi, bạn sẽ nhận được thông báo rất chi tiết và hữu ích trong bảng điều khiển dành cho nhà phát triển nhằm giúp bạn xác định và khắc phục sự cố.

Giữ bảng điều khiển ở trạng thái mở trong khi làm việc trên bất kỳ ứng dụng web nào luôn hữu ích, nhưng điều này đặc biệt áp dụng ở đây!

3. Khởi chạy WebGPU

Bắt đầu bằng một <canvas>

Bạn có thể sử dụng WebGPU mà không cần hiển thị bất kỳ nội dung gì trên màn hình nếu tất cả những gì bạn muốn là sử dụng nó để tính toán. Nhưng nếu muốn kết xuất bất kỳ nội dung nào, như chúng ta sẽ làm trong lớp học lập trình này, bạn cần có canvas. Đó là một điểm bắt đầu tốt!

Tạo một tài liệu HTML mới chứa một phần tử <canvas>, cũng như một thẻ <script> để truy vấn phần tử canvas. (Hoặc sử dụng 00-starter-page.html từ sự cố.)

  • Tạo tệp index.html bằng đoạn mã sau:

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>

Yêu cầu bộ chuyển đổi và thiết bị

Bây giờ bạn có thể tìm hiểu các thông tin về WebGPU! Trước tiên, bạn nên cân nhắc rằng các API như WebGPU có thể mất một chút thời gian để phổ biến trên toàn bộ hệ sinh thái web. Do đó, bước đầu tiên nên thực hiện là kiểm tra xem trình duyệt của người dùng có thể sử dụng WebGPU hay không.

  1. Để kiểm tra xem đối tượng navigator.gpu đóng vai trò là điểm truy cập cho WebGPU có tồn tại hay không, hãy thêm đoạn mã sau:

index.html

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

Tốt nhất là bạn nên thông báo cho người dùng nếu WebGPU không khả dụng bằng cách đặt trang quay lại chế độ không sử dụng WebGPU. (Có lẽ công cụ này dùng WebGL để thay thế?) Tuy nhiên, trong phạm vi của lớp học lập trình này, bạn chỉ cần tạo một lỗi để ngăn mã thực thi thêm.

Khi bạn biết rằng trình duyệt hỗ trợ WebGPU, bước đầu tiên để khởi chạy WebGPU cho ứng dụng là yêu cầu GPUAdapter. Bạn có thể xem bộ chuyển đổi là đại diện của WebGPU về một phần cứng GPU cụ thể trong thiết bị của mình.

  1. Để tải một bộ chuyển đổi, hãy sử dụng phương thức navigator.gpu.requestAdapter(). Phương thức này trả về một lời hứa (promise) nên để thuận tiện nhất là gọi lệnh bằng await.

index.html

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

Nếu không tìm thấy trình chuyển đổi phù hợp, thì giá trị adapter được trả về có thể là null, vì vậy, bạn cần xử lý khả năng đó. Điều này có thể xảy ra nếu trình duyệt của người dùng hỗ trợ WebGPU nhưng phần cứng GPU của họ không có tất cả các tính năng cần thiết để sử dụng WebGPU.

Trong hầu hết trường hợp, bạn chỉ cần cho phép trình duyệt chọn một bộ chuyển đổi mặc định, như bạn làm ở đây, nhưng đối với các nhu cầu nâng cao hơn, có các đối số có thể chuyển đến requestAdapter() chỉ định xem bạn muốn sử dụng phần cứng hiệu suất cao hay công suất thấp trên các thiết bị có nhiều GPU (như một số máy tính xách tay).

Sau khi bạn có bộ chuyển đổi, bước cuối cùng trước khi có thể bắt đầu làm việc với GPU là yêu cầu GPUDevice. Thiết bị là giao diện chính diễn ra hầu hết các hoạt động tương tác với GPU.

  1. Lấy thiết bị bằng cách gọi adapter.requestDevice(). Thao tác này cũng trả về một lời hứa.

index.html

const device = await adapter.requestDevice();

Giống như requestAdapter(), bạn có thể chuyển các tuỳ chọn vào đây để sử dụng những tính năng nâng cao hơn như bật một số tính năng phần cứng cụ thể hoặc yêu cầu giới hạn cao hơn. Tuy nhiên, các giá trị mặc định hoạt động tốt đối với mục đích của bạn.

Định cấu hình Canvas

Giờ đây khi bạn đã có thiết bị, bạn còn một việc nữa phải làm nếu muốn sử dụng thiết bị đó để hiển thị nội dung bất kỳ trên trang: định cấu hình canvas để sử dụng với thiết bị bạn vừa tạo.

  • Để thực hiện việc này, trước tiên, hãy yêu cầu GPUCanvasContext từ canvas bằng cách gọi canvas.getContext("webgpu"). (Đây cũng là lệnh gọi mà bạn sẽ sử dụng để khởi chạy bối cảnh Canvas 2D hoặc WebGL, sử dụng kiểu ngữ cảnh 2dwebgl tương ứng.) Sau đó, context mà hàm trả về phải được liên kết với thiết bị bằng phương thức configure(), chẳng hạn như:

index.html

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

Bạn có thể truyền một vài tuỳ chọn ở đây, nhưng quan trọng nhất là device bạn sẽ sử dụng bối cảnh và format, là định dạng kết cấu mà bối cảnh nên sử dụng.

Kết cấu (texture) là các đối tượng mà WebGPU sử dụng để lưu trữ dữ liệu hình ảnh và mỗi kết cấu có một định dạng cho phép GPU biết cách dữ liệu đó được bố trí trong bộ nhớ. Thông tin chi tiết về cách hoạt động của bộ nhớ kết cấu nằm ngoài phạm vi của lớp học lập trình này. Điều quan trọng cần biết là canvas cung cấp hoạ tiết cho mã của bạn để vẽ và định dạng bạn sử dụng có thể có tác động đến mức độ hiệu quả của canvas hiển thị những hình ảnh đó. Các loại thiết bị khác nhau hoạt động tốt nhất khi sử dụng các định dạng hoạ tiết khác nhau và nếu bạn không sử dụng định dạng ưu tiên của thiết bị, điều đó có thể khiến các bản sao bộ nhớ thừa xảy ra trong nền trước khi hình ảnh có thể hiển thị như một phần của trang.

May mắn là bạn không phải lo lắng nhiều về những điều đó vì WebGPU sẽ cho bạn biết nên sử dụng định dạng nào cho canvas của mình! Trong hầu hết các trường hợp, bạn muốn truyền giá trị được trả về bằng cách gọi navigator.gpu.getPreferredCanvasFormat(), như trình bày ở trên.

Xoá Canvas

Giờ đây, khi đã có thiết bị và canvas đã được định cấu hình, bạn có thể bắt đầu sử dụng thiết bị để thay đổi nội dung của canvas. Để bắt đầu, hãy xoá màu này bằng một khối đồng màu.

Để thực hiện việc đó, hoặc gần như mọi thứ khác trong WebGPU, bạn cần cung cấp một số lệnh cho GPU để hướng dẫn GPU cần làm.

  1. Để thực hiện việc này, hãy yêu cầu thiết bị tạo một GPUCommandEncoder. Lớp này cung cấp một giao diện để ghi lại các lệnh GPU.

index.html

const encoder = device.createCommandEncoder();

Các lệnh bạn muốn gửi đến GPU có liên quan đến quá trình kết xuất (trong trường hợp này là xoá canvas). Vì vậy, bước tiếp theo là sử dụng encoder để bắt đầu Render Pass.

Lượt kết xuất hình ảnh là khi tất cả các thao tác vẽ trong WebGPU đều diễn ra. Mỗi phần tử bắt đầu bằng một lệnh gọi beginRenderPass(). Lệnh gọi này xác định các hoạ tiết nhận kết quả của mọi lệnh vẽ được thực hiện. Các cách sử dụng nâng cao hơn có thể cung cấp một số hoạ tiết, được gọi là tệp đính kèm, với nhiều mục đích như lưu trữ độ sâu của hình học được kết xuất hoặc cung cấp tính năng khử răng cưa. Tuy nhiên, đối với ứng dụng này, bạn chỉ cần một mã.

  1. Lấy hoạ tiết từ ngữ cảnh canvas mà bạn đã tạo trước đó bằng cách gọi context.getCurrentTexture(). Thao tác này sẽ trả về hoạ tiết có chiều rộng và chiều cao pixel khớp với các thuộc tính widthheight của canvas và format được chỉ định khi bạn gọi context.configure().

index.html

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

Hoạ tiết được gán làm thuộc tính view của colorAttachment. Thẻ kết xuất yêu cầu bạn cung cấp GPUTextureView thay vì GPUTexture. Lớp này cho biết cần kết xuất vào phần nào của hoạ tiết. Điều này chỉ thực sự quan trọng đối với các trường hợp sử dụng nâng cao hơn, vì vậy, ở đây bạn gọi createView() mà không có đối số cho hoạ tiết, cho biết bạn muốn lượt kết xuất dùng toàn bộ hoạ tiết.

Bạn cũng phải chỉ định những gì bạn muốn lượt kết xuất thực hiện với hoạ tiết khi bắt đầu và kết thúc:

  • Giá trị loadOp "clear" cho biết bạn muốn xoá hoạ tiết khi lượt kết xuất bắt đầu.
  • Giá trị storeOp của "store" cho biết rằng sau khi hoàn tất lượt kết xuất, bạn sẽ muốn kết quả của mọi bản vẽ thực hiện trong lượt kết xuất được lưu vào hoạ tiết.

Khi lượt kết xuất bắt đầu, bạn không cần làm gì cả! Ít nhất là ở thời điểm hiện tại. Chỉ cần bắt đầu lượt kết xuất bằng loadOp: "clear" là đủ để xoá thành phần hiển thị hoạ tiết và canvas.

  1. Kết thúc lượt kết xuất bằng cách thêm lệnh gọi sau đây ngay sau beginRenderPass():

index.html

pass.end();

Quan trọng là bạn cần biết rằng việc chỉ thực hiện các lệnh gọi này không khiến GPU thực sự làm gì cả. Chúng chỉ là các lệnh ghi lại để GPU thực hiện sau này.

  1. Để tạo GPUCommandBuffer, hãy gọi finish() trên bộ mã hoá lệnh. Vùng đệm lệnh là một tay điều khiển mờ đối với các lệnh được ghi.

index.html

const commandBuffer = encoder.finish();
  1. Gửi vùng đệm lệnh đến GPU bằng queue của GPUDevice. Hàng đợi thực hiện tất cả các lệnh GPU, đảm bảo rằng việc thực thi các lệnh đó được sắp xếp hợp lý và đồng bộ hoá đúng cách. Phương thức submit() của hàng đợi sẽ lấy một mảng vùng đệm lệnh, mặc dù trong trường hợp này, bạn chỉ có một vùng đệm.

index.html

device.queue.submit([commandBuffer]);

Sau khi gửi vùng đệm lệnh, bạn không thể sử dụng lại vùng đệm này. Vì vậy, bạn không cần giữ lại vùng đệm này. Nếu muốn gửi thêm lệnh, bạn cần tạo một vùng đệm lệnh khác. Đó là lý do tại sao chúng ta thường thấy hai bước được thu gọn thành một, như được thực hiện trên các trang mẫu cho lớp học lập trình này:

index.html

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

Sau khi bạn gửi các lệnh đến GPU, hãy cho phép JavaScript trả lại quyền kiểm soát cho trình duyệt. Khi đó, trình duyệt thấy rằng bạn đã thay đổi hoạ tiết hiện tại của ngữ cảnh và cập nhật canvas để hiển thị hoạ tiết đó dưới dạng hình ảnh. Nếu sau đó muốn cập nhật lại nội dung canvas, bạn cần ghi và gửi vùng đệm lệnh mới, gọi lại context.getCurrentTexture() để lấy hoạ tiết mới cho lượt kết xuất.

  1. Tải lại trang. Lưu ý canvas sẽ có màu đen. Xin chúc mừng! Điều đó có nghĩa là bạn đã tạo thành công ứng dụng WebGPU đầu tiên của mình.

Một canvas màu đen cho biết đã sử dụng WebGPU thành công để xoá nội dung canvas.

Chọn màu!

Tuy nhiên, thành thật mà nói những hình vuông màu đen trông khá nhàm chán. Vì vậy, hãy dành chút thời gian trước khi chuyển sang phần tiếp theo để điều chỉnh phần này một chút.

  1. Trong lệnh gọi encoder.beginRenderPass(), hãy thêm một dòng mới chứa clearValue vào colorAttachment như sau:

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 hướng dẫn quá trình kết xuất truyền màu nào nên sử dụng khi thực hiện thao tác clear ở đầu lượt truyền. Từ điển được truyền vào kiểu này chứa 4 giá trị: r cho đỏ, g cho xanh lục, b cho xanh dươnga cho alpha (độ trong suốt). Mỗi giá trị có thể nằm trong khoảng từ 0 đến 1 và các giá trị này cùng mô tả giá trị của kênh màu đó. Ví dụ:

  • { r: 1, g: 0, b: 0, a: 1 } có màu đỏ tươi.
  • { r: 1, g: 0, b: 1, a: 1 } có màu tím sáng.
  • { r: 0, g: 0.3, b: 0, a: 1 } có màu xanh lục đậm.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } có màu xám trung bình.
  • { r: 0, g: 0, b: 0, a: 0 } là màu đen trong suốt mặc định.

Mã ví dụ và ảnh chụp màn hình trong lớp học lập trình này sử dụng màu xanh dương đậm, nhưng bạn có thể chọn bất kỳ màu nào mình muốn!

  1. Sau khi bạn đã chọn màu, hãy tải lại trang. Bạn sẽ thấy màu mình đã chọn trong canvas.

Một canvas đã chuyển sang màu xanh dương đậm để minh hoạ cách thay đổi màu trong suốt mặc định.

4. Vẽ hình học

Ở cuối phần này, ứng dụng của bạn sẽ vẽ một số hình học đơn giản vào canvas: một hình vuông được tô màu. Hãy lưu ý rằng có vẻ có nhiều công việc cho đầu ra đơn giản như vậy, nhưng đó là vì WebGPU được thiết kế để hiển thị rất nhiều hình học rất hiệu quả. Tác dụng phụ của tính hiệu quả này là thực hiện những việc tương đối đơn giản có thể cảm thấy khó khăn một cách bất thường, nhưng đó là điều bình thường nếu bạn chuyển sang sử dụng API như WebGPU — bạn muốn làm điều gì đó phức tạp hơn một chút.

Tìm hiểu cách GPU vẽ

Trước khi có thêm bất cứ thay đổi nào về mã, bạn nên thực hiện một bài tổng quan rất nhanh, đơn giản và ở cấp cao về cách GPU tạo ra hình dạng mà bạn thấy trên màn hình. (Vui lòng chuyển đến phần Xác định đỉnh nếu bạn đã nắm rõ kiến thức cơ bản về cách hoạt động của kết xuất đồ hoạ GPU.)

Không giống như một API như Canvas 2D có rất nhiều hình dạng và tuỳ chọn sẵn sàng để bạn sử dụng, GPU của bạn thực sự chỉ xử lý một vài loại hình dạng (hoặc nguyên tắc khác nhau) như được gọi bằng WebGPU: điểm, đường và tam giác. Trong mục đích của lớp học lập trình này, bạn sẽ chỉ sử dụng tam giác.

GPU chỉ hoạt động với tam giác vì tam giác có nhiều tính chất toán học thú vị giúp dễ dàng xử lý theo cách dễ dự đoán và hiệu quả. Hầu hết mọi thứ bạn vẽ bằng GPU cần được chia thành các tam giác trước khi GPU có thể vẽ và các tam giác đó phải được xác định bằng các điểm góc.

Các điểm hoặc đỉnh này được cho trước theo giá trị X, Y và (đối với nội dung 3D) Z mà xác định một điểm trên hệ toạ độ Cartesian do WebGPU hoặc các API tương tự xác định. Cấu trúc của hệ thống toạ độ dễ hình dung nhất xét theo mối quan hệ giữa chúng với canvas trên trang của bạn. Bất kể canvas của bạn rộng hay cao bao nhiêu, cạnh trái luôn ở -1 trên trục X và cạnh phải luôn ở +1 trên trục X. Tương tự, cạnh dưới luôn là -1 trên trục Y và cạnh trên cùng là +1 trên trục Y. Điều đó có nghĩa là (0, 0) luôn là tâm của canvas, (-1, -1) luôn là góc dưới cùng bên trái và (1, 1) luôn là góc trên cùng bên phải. Đây được gọi là Không gian đoạn video.

Một biểu đồ đơn giản trực quan hoá không gian Toạ độ của thiết bị được chuẩn hoá.

Ban đầu, các đỉnh hiếm khi được xác định trong hệ toạ độ này, vì vậy GPU dựa vào các chương trình nhỏ được gọi là chương trình đổ bóng đỉnh để thực hiện bất kỳ phép toán nào cần thiết để biến đổi các đỉnh thành không gian cắt, cũng như mọi phép tính khác cần thiết để vẽ đỉnh. Ví dụ: chương trình đổ bóng có thể áp dụng một số ảnh động hoặc tính toán hướng từ đỉnh đến nguồn sáng. Các chương trình đổ bóng này là do bạn, nhà phát triển WebGPU viết và cung cấp khả năng kiểm soát đáng kinh ngạc đối với cách hoạt động của GPU.

Từ đó, GPU sẽ lấy tất cả tam giác được tạo bởi các đỉnh đã biến đổi này và xác định những pixel nào trên màn hình cần vẽ chúng. Sau đó, Android chạy một chương trình nhỏ khác mà bạn viết tên là chương trình đổ bóng mảnh để tính toán màu của mỗi pixel. Việc tính toán này có thể đơn giản như trả lại màu xanh lục hoặc phức tạp như tính toán góc của bề mặt so với ánh sáng mặt trời bật lên từ các bề mặt khác gần đó, bị lọc qua sương mù và thay đổi theo độ kim loại của bề mặt. Việc này hoàn toàn thuộc quyền kiểm soát của bạn, có thể vừa sức mạnh, vừa choáng ngợp.

Sau đó, kết quả của các màu pixel đó được tích luỹ vào hoạ tiết, sau đó có thể hiển thị trên màn hình.

Xác định đỉnh

Như đã đề cập trước đó, phần mô phỏng Trò chơi cuộc sống được hiển thị dưới dạng lưới gồm các ô. Ứng dụng của bạn cần có một cách để trực quan hoá lưới, phân biệt các ô đang hoạt động với các ô không hoạt động. Phương pháp mà lớp học lập trình này sử dụng sẽ là vẽ các hình vuông màu trong các ô đang hoạt động và để trống các ô không hoạt động.

Tức là bạn sẽ cần cung cấp cho GPU 4 điểm khác nhau, mỗi điểm tương ứng với 4 góc của hình vuông. Ví dụ: một hình vuông được vẽ ở giữa canvas, được kéo vào từ các cạnh cách một cách, có toạ độ góc như sau:

Biểu đồ Toạ độ thiết bị được chuẩn hoá hiển thị toạ độ của các góc của hình vuông

Để cung cấp các toạ độ đó cho GPU, bạn cần đặt các giá trị vào TypedArray. Nếu bạn chưa quen với khái niệm này, TypedArrays là nhóm đối tượng JavaScript cho phép bạn phân bổ các khối bộ nhớ liền kề và diễn giải mỗi phần tử trong chuỗi dưới dạng một kiểu dữ liệu cụ thể. Ví dụ: trong Uint8Array, mỗi phần tử trong mảng là một byte đơn, chưa ký. TypedArrays rất phù hợp để gửi dữ liệu qua lại bằng các API nhạy cảm với bố cục bộ nhớ, chẳng hạn như WebAssembly, WebAudio và (tất nhiên) WebGPU.

Đối với ví dụ về hình vuông, vì các giá trị là phân số nên Float32Array là phù hợp.

  1. Tạo một mảng chứa tất cả các vị trí đỉnh trong sơ đồ bằng cách đặt phần khai báo mảng sau đây trong mã của bạn. Bạn nên đặt mã này ở gần phía trên cùng, ngay dưới lệnh gọi 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,
]);

Lưu ý rằng dấu cách và chú thích không ảnh hưởng đến các giá trị; để bạn thuận tiện và dễ đọc hơn. Điều này giúp bạn thấy rằng mọi cặp giá trị tạo thành toạ độ X và Y của một đỉnh.

Nhưng có một vấn đề! GPU hoạt động theo tam giác, bạn còn nhớ không? Điều đó có nghĩa là bạn phải cung cấp các đỉnh theo nhóm ba. Bạn có một nhóm 4 người. Giải pháp là lặp lại hai trong số các đỉnh để tạo ra hai hình tam giác có chung một cạnh qua giữa hình vuông.

Sơ đồ cho thấy cách dùng 4 đỉnh của hình vuông để tạo 2 tam giác.

Để tạo hình vuông từ sơ đồ, bạn phải liệt kê các đỉnh (-0,8, -0,8) và (0,8, 0,8) hai lần, một lần cho hình tam giác màu xanh và một lần cho hình màu đỏ. (Bạn cũng có thể chọn chia hình vuông với hai góc còn lại; cách này không tạo ra sự khác biệt.)

  1. Cập nhật mảng vertices trước đó như sau:

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

Mặc dù biểu đồ cho thấy sự phân tách giữa 2 tam giác rõ ràng, nhưng các vị trí đỉnh hoàn toàn giống nhau và GPU hiển thị chúng mà không có khoảng trống nào. Nó sẽ hiển thị dưới dạng một hình vuông đồng nhất.

Tạo vùng đệm đỉnh

GPU không thể vẽ các đỉnh có dữ liệu từ một mảng JavaScript. Các GPU thường có bộ nhớ riêng được tối ưu hoá cao để kết xuất, vì vậy, mọi dữ liệu mà bạn muốn GPU sử dụng trong khi vẽ đều cần được đặt vào bộ nhớ đó.

Đối với nhiều giá trị, bao gồm cả dữ liệu đỉnh, bộ nhớ phía GPU được quản lý thông qua các đối tượng GPUBuffer. Vùng đệm là một khối bộ nhớ mà GPU có thể dễ dàng truy cập và được gắn cờ cho một số mục đích nhất định. Bạn có thể hình dung về trò chơi này giống như một TypedArray có thể nhìn thấy được bằng GPU.

  1. Để tạo vùng đệm lưu giữ các đỉnh, hãy thêm lệnh gọi sau đây vào device.createBuffer() sau phần định nghĩa mảng vertices.

index.html

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

Điều đầu tiên cần lưu ý là bạn gán một nhãn cho vùng đệm. Mỗi đối tượng WebGPU bạn tạo đều có thể được gắn một nhãn tuỳ chọn và bạn chắc chắn muốn làm như vậy! Nhãn là bất kỳ chuỗi nào bạn muốn, miễn là nhãn giúp bạn xác định đối tượng là gì. Nếu bạn gặp bất kỳ sự cố nào, các nhãn đó được sử dụng trong thông báo lỗi mà WebGPU tạo ra để giúp bạn hiểu đã xảy ra sự cố gì.

Tiếp theo, hãy cung cấp kích thước cho vùng đệm tính bằng byte. Bạn cần một vùng đệm có 48 byte mà bạn xác định bằng cách nhân kích thước của số thực có độ chính xác đơn 32 bit ( 4 byte) với số lượng số thực có độ chính xác đơn trong mảng vertices (12). Thật may là TypedArrays đã tính toán byteLength cho bạn để bạn có thể sử dụng giá trị đó khi tạo vùng đệm.

Cuối cùng, bạn cần chỉ định cách sử dụng vùng đệm. Đây là một hoặc nhiều cờ GPUBufferUsage, trong đó nhiều cờ được kết hợp với toán tử | ( bitwise OR). Trong trường hợp này, bạn chỉ định rằng bạn muốn sử dụng vùng đệm cho dữ liệu đỉnh (GPUBufferUsage.VERTEX) và cũng muốn có thể sao chép dữ liệu vào đó (GPUBufferUsage.COPY_DST).

Đối tượng vùng đệm được trả về cho bạn không rõ ràng. Bạn không thể (dễ dàng) kiểm tra dữ liệu mà đối tượng này giữ. Ngoài ra, hầu hết thuộc tính của thành phần này đều không thể thay đổi – bạn không thể đổi kích thước GPUBuffer sau khi tạo cũng như không thể thay đổi cờ sử dụng. Bạn có thể thay đổi nội dung trong bộ nhớ của thiết bị.

Khi vùng đệm được tạo ban đầu, bộ nhớ trong vùng đệm sẽ được khởi tạo về 0. Có một số cách để thay đổi nội dung của lớp, nhưng cách dễ nhất là gọi device.queue.writeBuffer() bằng TypedArray mà bạn muốn sao chép.

  1. Để sao chép dữ liệu đỉnh vào bộ nhớ của vùng đệm, hãy thêm đoạn mã sau:

index.html

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

Xác định bố cục đỉnh

Bây giờ, bạn đã có một vùng đệm với dữ liệu đỉnh, nhưng theo như GPU có liên quan thì đó chỉ là một blob byte. Bạn cần cung cấp thêm một chút thông tin nếu bạn định vẽ bất cứ thứ gì với thông tin đó. Bạn cần cho WebGPU biết thêm về cấu trúc của dữ liệu đỉnh (vertex).

index.html

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

Việc này có thể hơi rắc rối khi mới nhìn qua nhưng lại tương đối dễ phân tích.

Mục đầu tiên bạn cung cấp là arrayStride. Đây là số byte mà GPU cần bỏ qua để tiến trong vùng đệm khi tìm đỉnh tiếp theo. Mỗi đỉnh của hình vuông được tạo thành từ hai số có dấu phẩy động 32 bit. Như đã đề cập trước đó, số thực có độ chính xác đơn 32 bit là 4 byte, vì vậy, hai số thực có kích thước là 8 byte.

Tiếp theo là thuộc tính attributes, là một mảng. Thuộc tính là các thông tin riêng lẻ được mã hoá thành mỗi đỉnh. Các đỉnh của bạn chỉ chứa một thuộc tính (vị trí đỉnh), nhưng những trường hợp sử dụng nâng cao hơn thường có các đỉnh chứa nhiều thuộc tính như màu của một đỉnh hoặc hướng mà bề mặt hình học đang trỏ đến. Tuy nhiên, nội dung đó nằm ngoài phạm vi của lớp học lập trình này.

Trong một thuộc tính duy nhất, trước tiên, bạn cần xác định format của dữ liệu. Mã này được lấy từ danh sách các loại GPUVertexFormat mô tả từng loại dữ liệu đỉnh mà GPU có thể hiểu được. Các đỉnh của bạn có 2 số thực có độ chính xác đơn 32 bit, nên bạn sẽ sử dụng định dạng float32x2. Ví dụ: nếu dữ liệu đỉnh của bạn được tạo thành từ bốn số nguyên 16 bit chưa ký, thì bạn nên sử dụng uint16x4. Bạn có thấy hoa văn không?

Tiếp theo, offset mô tả số byte vào đỉnh mà thuộc tính cụ thể này bắt đầu. Bạn thực sự chỉ phải lo lắng về vấn đề này nếu vùng đệm có nhiều thuộc tính, do đó các thuộc tính sẽ không xuất hiện trong lớp học lập trình này.

Cuối cùng, bạn có shaderLocation. Đây là một số tuỳ ý từ 0 đến 15 và phải là duy nhất cho mọi thuộc tính mà bạn xác định. Phương thức này liên kết thuộc tính này với một dữ liệu đầu vào cụ thể trong chương trình đổ bóng đỉnh (vertex) mà bạn sẽ tìm hiểu trong phần tiếp theo.

Lưu ý rằng mặc dù bạn xác định các giá trị này ngay bây giờ, nhưng thực ra bạn vẫn chưa truyền chúng vào WebGPU API. Tính năng này sẽ xuất hiện, nhưng bạn sẽ dễ dàng nghĩ về các giá trị này tại điểm bạn xác định các đỉnh của mình, vì vậy, bạn đang thiết lập các giá trị này để sử dụng sau này.

Bắt đầu với chương trình đổ bóng

Bây giờ, bạn đã có dữ liệu muốn kết xuất, nhưng bạn vẫn cần cho GPU biết chính xác cách xử lý dữ liệu đó. Phần lớn những điều này xảy ra với chương trình đổ bóng.

Chương trình đổ bóng là các chương trình nhỏ mà bạn viết và thực thi trên GPU. Mỗi chương trình đổ bóng hoạt động trên một giai đoạn dữ liệu khác nhau: xử lý Đỉnh, xử lý Mảnh hoặc Điện toán chung. Vì nằm trên GPU, nên chúng có cấu trúc cứng hơn so với JavaScript thông thường. Nhưng cấu trúc đó cho phép họ thực thi rất nhanh và quan trọng là thực thi song song!

Chương trình đổ bóng trong WebGPU được viết bằng ngôn ngữ tô bóng có tên là WGSL (Ngôn ngữ tạo bóng WebGPU). Theo cú pháp, WGSL có phần giống như Rust, với các tính năng nhằm làm cho các loại GPU phổ biến hoạt động (như toán học vectơ và ma trận) dễ dàng và nhanh chóng hơn. Việc giảng dạy toàn bộ ngôn ngữ tô bóng nằm ngoài phạm vi của lớp học lập trình này, nhưng hy vọng bạn sẽ nắm được một số kiến thức cơ bản khi xem qua một số ví dụ đơn giản.

Các chương trình đổ bóng được truyền vào WebGPU dưới dạng chuỗi.

  • Tạo một nơi để nhập mã chương trình đổ bóng bằng cách sao chép đoạn mã sau vào mã bên dưới vertexBufferLayout:

index.html

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

Để tạo chương trình đổ bóng, bạn sẽ gọi device.createShaderModule(), trong đó bạn phải cung cấp một label và WGSL code (không bắt buộc) dưới dạng một chuỗi. (Lưu ý rằng bạn sử dụng dấu phẩy ngược ở đây để cho phép các chuỗi có nhiều dòng!) Sau khi bạn thêm một mã WGSL hợp lệ, hàm sẽ trả về một đối tượng GPUShaderModule kèm theo kết quả đã biên dịch.

Xác định chương trình đổ bóng đỉnh (vertex)

Bắt đầu với chương trình đổ bóng đỉnh vì đó cũng là nơi GPU bắt đầu!

Chương trình đổ bóng đỉnh (vertex) được định nghĩa là một hàm và GPU sẽ gọi hàm đó một lần cho mỗi đỉnh trong vertexBuffer. Vì vertexBuffer có 6 vị trí (đỉnh) nên hàm mà bạn xác định sẽ được gọi 6 lần. Mỗi lần được gọi, một vị trí khác từ vertexBuffer sẽ được truyền vào hàm làm đối số. Đồng thời, nhiệm vụ của hàm đổ bóng đỉnh (vertex) là trả về một vị trí tương ứng trong không gian cắt (clip).

Quan trọng là bạn phải hiểu rằng các thiết bị này không nhất thiết phải được gọi theo thứ tự. Thay vào đó, GPU có thể chạy song song các chương trình đổ bóng như vậy, có khả năng xử lý hàng trăm (hoặc thậm chí hàng nghìn!) đỉnh cùng một lúc! Đây là một phần quan trọng góp phần tạo ra tốc độ đáng kinh ngạc cho GPU, nhưng cũng có một số hạn chế. Để đảm bảo quá song song, các chương trình đổ bóng đỉnh không thể giao tiếp với nhau. Mỗi lệnh gọi chương trình đổ bóng chỉ có thể xem dữ liệu cho một đỉnh tại một thời điểm và chỉ có thể xuất giá trị cho một đỉnh duy nhất.

Trong WGSL, bạn có thể đặt tên hàm đổ bóng đỉnh theo bất kỳ tên nào mình muốn, nhưng phải có thuộc tính @vertex phía trước để cho biết nó đại diện cho giai đoạn đổ bóng nào. WGSL biểu thị các hàm bằng từ khoá fn, sử dụng dấu ngoặc đơn để khai báo bất kỳ đối số nào và sử dụng dấu ngoặc nhọn để xác định phạm vi.

  1. Tạo một hàm @vertex trống như bên dưới:

index.html (mã createShaderModule)

@vertex
fn vertexMain() {

}

Tuy nhiên, điều đó không hợp lệ, vì chương trình đổ bóng đỉnh phải trả về ít nhất vị trí cuối cùng của đỉnh đang được xử lý trong không gian cắt. Giá trị này luôn được cho sẵn dưới dạng vectơ 4 chiều. Vectơ là một thứ phổ biến để sử dụng trong chương trình đổ bóng, nên chúng được coi là dữ liệu gốc hạng nhất trong ngôn ngữ, với các kiểu riêng như vec4f cho vectơ 4 chiều. Vectơ 2D (vec2f) và vectơ 3D (vec3f) cũng có các kiểu tương tự!

  1. Để cho biết giá trị được trả về là vị trí bắt buộc, hãy đánh dấu giá trị này bằng thuộc tính @builtin(position). Biểu tượng -> được dùng để cho biết đây là giá trị mà hàm trả về.

index.html (mã createShaderModule)

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

}

Tất nhiên, nếu hàm có kiểu dữ liệu trả về, thì bạn phải thực sự trả về một giá trị trong phần nội dung của hàm. Bạn có thể tạo vec4f mới để trả về bằng cú pháp vec4f(x, y, z, w). Các giá trị x, yz đều là số thực dấu phẩy động, trong giá trị trả về, cho biết vị trí của đỉnh nằm trong không gian clip (clip).

  1. Trả về một giá trị tĩnh của (0, 0, 0, 1) và về mặt kỹ thuật, bạn có một chương trình đổ bóng đỉnh hợp lệ, mặc dù một chương trình không bao giờ hiển thị gì vì GPU nhận biết rằng các tam giác được tạo ra chỉ là một điểm duy nhất rồi loại bỏ nó.

index.html (mã createShaderModule)

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

Thay vào đó, bạn muốn sử dụng dữ liệu từ vùng đệm đã tạo và thực hiện việc đó bằng cách khai báo đối số cho hàm bằng thuộc tính @location() và kiểu khớp với nội dung bạn mô tả trong vertexBufferLayout. Bạn đã chỉ định shaderLocation0, do đó, trong mã WGSL, hãy đánh dấu đối số bằng @location(0). Bạn cũng đã xác định định dạng là float32x2, là vectơ 2D, vì vậy trong WGSL, đối số của bạn là vec2f. Bạn có thể đặt tên bất kỳ mà bạn muốn, nhưng vì các vị trí này thể hiện vị trí đỉnh của bạn, nên một cái tên như pos có vẻ tự nhiên.

  1. Thay đổi hàm đổ bóng thành đoạn mã sau:

index.html (mã createShaderModule)

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

Giờ thì bạn cần trả lại vị trí đó. Vì vị trí là vectơ 2D và loại trả về là vectơ 4D, bạn phải thay đổi nó một chút. Việc bạn muốn làm là lấy hai thành phần từ đối số vị trí và đặt chúng vào hai thành phần đầu tiên của vectơ trả về, để hai thành phần cuối cùng có dạng 01 tương ứng.

  1. Trả về vị trí chính xác bằng cách nêu rõ các thành phần vị trí cần sử dụng:

index.html (mã createShaderModule)

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

Tuy nhiên, vì các kiểu ánh xạ này rất phổ biến trong chương trình đổ bóng, bạn cũng có thể truyền vectơ vị trí vào làm đối số đầu tiên theo cách viết tắt rất thuận tiện và nó cũng có ý nghĩa tương tự.

  1. Viết lại câu lệnh return bằng mã sau:

index.html (mã createShaderModule)

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

Đó là chương trình đổ bóng đỉnh (vertex) ban đầu! Việc này rất đơn giản, chỉ cần truyền vị trí không thay đổi một cách hiệu quả, nhưng bạn chỉ cần bắt đầu.

Xác định chương trình đổ bóng mảnh

Tiếp theo là chương trình đổ bóng mảnh. Chương trình đổ bóng mảnh hoạt động theo cách rất giống với chương trình đổ bóng đỉnh, nhưng thay vì được gọi cho mọi đỉnh, các chương trình này được gọi cho mọi pixel được vẽ.

Chương trình đổ bóng mảnh luôn được gọi sau chương trình đổ bóng đỉnh (vertex). GPU nhận đầu ra của chương trình đổ bóng đỉnh và tạo tam giác, tạo ra các tam giác từ tập hợp 3 điểm. Sau đó, Google tạo điểm ảnh tạo điểm ảnh cho từng tam giác bằng cách xác định pixel nào của tệp đính kèm màu đầu ra được bao gồm trong tam giác đó, rồi gọi chương trình đổ bóng phân mảnh một lần cho mỗi pixel đó. Chương trình đổ bóng mảnh trả về một màu, thường được tính từ các giá trị được gửi đến từ chương trình đổ bóng đỉnh và các thành phần như hoạ tiết mà GPU ghi vào tệp đính kèm màu.

Tương tự như chương trình đổ bóng dạng đỉnh (vertex), chương trình đổ bóng mảnh được thực thi theo kiểu song song rất lớn. Phương thức này linh hoạt hơn một chút so với chương trình đổ bóng đỉnh về dữ liệu đầu vào và đầu ra, nhưng bạn có thể coi chúng chỉ đơn giản là trả về một màu cho mỗi pixel của mỗi tam giác.

Biểu thị hàm đổ bóng mảnh WGSL bằng thuộc tính @fragment và cũng trả về vec4f. Tuy nhiên, trong trường hợp này, vectơ biểu thị một màu, không phải một vị trí. Giá trị trả về cần được cung cấp thuộc tính @location để cho biết colorAttachment nào qua lệnh gọi beginRenderPass mà màu trả về được ghi vào. Vì bạn chỉ có một tệp đính kèm, nên vị trí là 0.

  1. Tạo một hàm @fragment trống như bên dưới:

index.html (mã createShaderModule)

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

}

Bốn thành phần của vectơ được trả về là các giá trị màu đỏ, xanh lục, xanh dương và alpha. Các giá trị này được diễn giải chính xác theo cách tương tự như clearValue mà bạn đã đặt trong beginRenderPass trước đó. Vì vậy, vec4f(1, 0, 0, 1) có màu đỏ tươi, có vẻ là một màu hợp lý cho hình vuông của bạn. Tuy nhiên, bạn có thể tuỳ ý đặt thành bất kỳ màu nào mình muốn!

  1. Đặt vectơ màu được trả về, như sau:

index.html (mã createShaderModule)

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

Quả là một chương trình đổ bóng mảnh hoàn chỉnh! Đó không phải là một điều thực sự thú vị; nó chỉ đặt mọi điểm ảnh của mọi tam giác thành màu đỏ, nhưng hiện tại như vậy là đủ.

Tóm lại, sau khi thêm mã chương trình đổ bóng được nêu chi tiết ở trên, lệnh gọi createShaderModule của bạn giờ đây sẽ có dạng như sau:

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

Tạo quy trình kết xuất

Không thể sử dụng mô-đun chương trình đổ bóng để tự kết xuất. Thay vào đó, bạn phải sử dụng thành phần này như một phần của GPURenderPipeline, được tạo bằng cách gọi device.createRenderPipeline(). Quy trình kết xuất sẽ kiểm soát hình dạng cách vẽ, bao gồm những yếu tố như chương trình đổ bóng nào được sử dụng, cách diễn giải dữ liệu trong vùng đệm đỉnh, loại hình học sẽ được kết xuất (đường, điểm, hình tam giác, v.v.) và nhiều thứ khác!

Quy trình kết xuất là đối tượng phức tạp nhất trong toàn bộ API, nhưng bạn đừng lo lắng! Hầu hết giá trị bạn có thể chuyển vào lớp này đều không bắt buộc và bạn chỉ cần cung cấp một vài giá trị để bắt đầu.

  • Tạo một quy trình kết xuất như sau:

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

Mỗi quy trình cần có một layout mô tả loại đầu vào (ngoài vùng đệm đỉnh) mà quy trình cần, nhưng bạn thực sự không có. Rất may là hiện tại bạn có thể truyền "auto" và quy trình sẽ xây dựng bố cục riêng từ chương trình đổ bóng.

Tiếp theo, bạn phải cung cấp thông tin chi tiết về giai đoạn vertex. module là GPUShaderModule chứa chương trình đổ bóng đỉnh và entryPoint cung cấp tên của hàm trong mã chương trình đổ bóng được gọi cho mọi lệnh gọi đỉnh. (Bạn có thể có nhiều hàm @vertex@fragment trong một mô-đun chương trình đổ bóng!) Vùng đệm là một mảng các đối tượng GPUVertexBufferLayout mô tả cách dữ liệu của bạn được đóng gói trong vùng đệm đỉnh mà bạn sử dụng quy trình này. May mắn thay, bạn đã xác định điều này trước đó trong vertexBufferLayout của mình! Đây là nơi bạn chuyển thông tin.

Cuối cùng, bạn có thể xem thông tin chi tiết về giai đoạn fragment. Lớp này cũng bao gồm một mô-đun chương trình đổ bóng và entryPoint, chẳng hạn như giai đoạn đỉnh (vertex). Bit cuối cùng dùng để xác định targets mà quy trình này được sử dụng. Đây là một mảng từ điển cung cấp thông tin chi tiết (chẳng hạn như hoạ tiết format) của các tệp đính kèm màu mà quy trình đưa ra. Các chi tiết này cần phải khớp với hoạ tiết đã cho trong colorAttachments của mọi lượt kết xuất mà quy trình này được sử dụng. Lượt kết xuất của bạn sử dụng hoạ tiết trong ngữ cảnh canvas và dùng giá trị bạn đã lưu trong canvasFormat cho định dạng, vì vậy, bạn sẽ truyền cùng định dạng vào đây.

Các tuỳ chọn này thậm chí chưa đủ để đáp ứng nhu cầu của lớp học lập trình này!

Vẽ hình vuông

Giờ đây, bạn đã có mọi thông tin cần thiết để vẽ hình vuông của mình!

  1. Để vẽ hình vuông, hãy quay lại cặp lệnh gọi encoder.beginRenderPass()pass.end(), sau đó thêm các lệnh mới sau đây:

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

Công cụ này cung cấp cho WebGPU tất cả thông tin cần thiết để vẽ hình vuông của bạn. Trước tiên, bạn sử dụng setPipeline() để cho biết sẽ dùng quy trình nào để vẽ. Dữ liệu này bao gồm chương trình đổ bóng được sử dụng, bố cục dữ liệu đỉnh (vertex) và dữ liệu trạng thái có liên quan khác.

Tiếp theo, bạn gọi setVertexBuffer() bằng vùng đệm chứa các đỉnh của hình vuông. Bạn gọi nó bằng 0 vì vùng đệm này tương ứng với phần tử thứ 0 trong định nghĩa vertex.buffers của quy trình hiện tại.

Cuối cùng, bạn thực hiện lệnh gọi draw(). Lệnh gọi này có vẻ đơn giản đến lạ thường sau tất cả các bước thiết lập trước đó. Điều duy nhất bạn cần truyền vào là số lượng đỉnh mà lớp này sẽ kết xuất, lấy từ bộ đệm đỉnh đang được thiết lập và diễn giải bằng quy trình hiện đã thiết lập. Bạn có thể mã hoá cứng nó thành 6, nhưng tính toán từ mảng các đỉnh (12 floats / 2 toạ độ trên mỗi đỉnh == 6 đỉnh) có nghĩa là nếu bạn quyết định thay thế hình vuông, ví dụ như một hình tròn, thì sẽ có ít hơn để cập nhật bằng tay.

  1. Làm mới màn hình và (cuối cùng) là xem thành quả của tất cả nỗ lực của bạn: một hình vuông lớn, nhiều màu sắc.

Một hình vuông màu đỏ được hiển thị bằng WebGPU

5. Vẽ lưới

Trước tiên, hãy dành chút thời gian để tự chúc mừng bản thân! Việc tải các bit hình học đầu tiên lên màn hình thường là một trong những bước khó nhất với hầu hết các API GPU. Bạn có thể thực hiện mọi việc mình làm từ đây trong các bước nhỏ hơn. Nhờ đó, bạn có thể xác minh tiến trình của mình dễ dàng hơn trong quá trình thực hiện.

Trong phần này, bạn sẽ tìm hiểu:

  • Cách truyền các biến (còn gọi là đồng nhất) vào chương trình đổ bóng từ JavaScript.
  • Cách sử dụng đồng nhất để thay đổi hành vi kết xuất.
  • Cách sử dụng khoảng cách để vẽ nhiều biến thể của cùng một hình học.

Xác định lưới

Để kết xuất lưới, bạn cần biết một phần thông tin rất cơ bản về lưới. Tệp này chứa bao nhiêu ô, cả chiều rộng và chiều cao? Điều này tuỳ thuộc vào bạn với tư cách là nhà phát triển, nhưng để mọi thứ dễ dàng hơn, hãy xem lưới là hình vuông (cùng chiều rộng và chiều cao) và sử dụng kích thước luỹ thừa của 2. (Điều đó giúp một số phép toán sau này trở nên dễ dàng hơn.) Cuối cùng, bạn sẽ muốn làm cho màn hình lớn hơn, nhưng đối với phần còn lại, hãy thiết lập kích thước lưới thành 4x4 vì bạn sẽ dễ dàng minh hoạ một số phép toán được sử dụng trong phần này hơn. Hãy mở rộng quy mô sau!

  • Xác định kích thước lưới bằng cách thêm một hằng số vào đầu mã JavaScript.

index.html

const GRID_SIZE = 4;

Tiếp theo, bạn cần cập nhật cách kết xuất hình vuông sao cho hình vuông có thể gấp GRID_SIZE lần GRID_SIZE hình vuông trên canvas. Điều đó có nghĩa là hình vuông cần phải nhỏ hơn rất nhiều và cần nhiều hơn.

Giờ đây, có một cách để bạn có thể tiếp cận việc này bằng cách làm cho vùng đệm đỉnh của bạn lớn hơn đáng kể và xác định gấp GRID_SIZE lần gấp GRID_SIZE giá trị của các hình vuông bên trong ở kích thước và vị trí phù hợp. Trên thực tế, mã nguồn cho việc đó cũng không quá tệ! Bạn chỉ cần dùng một vài vòng lặp for và một vài phép toán. Tuy nhiên, việc đó cũng không tận dụng tối đa GPU và sử dụng nhiều bộ nhớ hơn mức cần thiết để đạt được hiệu quả. Phần này xem xét một phương pháp phù hợp hơn với GPU.

Tạo vùng đệm đồng nhất

Trước tiên, bạn cần thông báo kích thước lưới đã chọn cho chương trình đổ bóng, vì chương trình sẽ sử dụng kích thước này để thay đổi cách hiển thị của nội dung. Bạn có thể mã hoá cứng kích thước này vào chương trình đổ bóng, nhưng điều đó có nghĩa là mỗi khi muốn thay đổi kích thước lưới, bạn sẽ phải tạo lại quy trình đổ bóng và kết xuất, việc này rất tốn kém. Một cách hay hơn là cung cấp kích thước lưới cho chương trình đổ bóng dưới dạng đồng nhất.

Trước đó, bạn đã biết rằng một giá trị khác từ vùng đệm đỉnh (vertex) được truyền đến mỗi lệnh gọi chương trình đổ bóng đỉnh (vertex). Đồng nhất là một giá trị từ một vùng đệm giống nhau cho mọi lệnh gọi. Chúng rất hữu ích trong việc truyền đạt những giá trị phổ biến cho một phần hình học (như vị trí của nó), toàn bộ khung ảnh động (như thời gian hiện tại) hay thậm chí là toàn bộ thời gian hoạt động của ứng dụng (như lựa chọn ưu tiên của người dùng).

  • Tạo vùng đệm đồng nhất bằng cách thêm đoạn mã sau:

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

Điều này trông rất quen thuộc, vì nó gần như giống hệt với mã mà bạn đã sử dụng để tạo vùng đệm đỉnh trước đó! Lý do là tính đồng nhất được truyền đến API WebGPU thông qua cùng đối tượng GPUBuffer như đỉnh, với điểm khác biệt chính là usage lần này bao gồm GPUBufferUsage.UNIFORM thay vì GPUBufferUsage.VERTEX.

Truy cập đồng phục trong chương trình đổ bóng

  • Xác định kiểu đồng nhất bằng cách thêm đoạn mã sau:

index.html (lệnh gọi 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 

Thao tác này sẽ xác định giá trị đồng nhất trong chương trình đổ bóng có tên là grid. Đây là vectơ có độ chính xác đơn 2D khớp với mảng mà bạn vừa sao chép vào vùng đệm đồng nhất. Mã này cũng chỉ định rằng giá trị đồng nhất được liên kết tại @group(0)@binding(0). Bạn sẽ nhanh chóng hiểu được ý nghĩa của những giá trị đó.

Sau đó, ở nơi khác trong mã chương trình đổ bóng, bạn có thể sử dụng vectơ lưới theo ý muốn. Trong mã này, bạn chia vị trí đỉnh bằng vectơ lưới. Vì pos là vectơ 2D và grid là vectơ 2D, WGSL thực hiện phép chia thành phần. Nói cách khác, kết quả sẽ giống như việc cho biết vec2f(pos.x / grid.x, pos.y / grid.y).

Các loại toán tử vectơ này rất phổ biến trong chương trình đổ bóng GPU vì nhiều kỹ thuật kết xuất và tính toán dựa vào chúng!

Điều này có nghĩa trong trường hợp của bạn là (nếu bạn sử dụng kích thước lưới là 4), hình vuông mà bạn kết xuất sẽ bằng một phần tư kích thước ban đầu. Thật hoàn hảo nếu bạn muốn điều chỉnh bốn mục trong một hàng hoặc cột!

Tạo Nhóm liên kết

Tuy nhiên, việc khai báo đồng nhất trong chương trình đổ bóng sẽ không kết nối thuộc tính đó với vùng đệm mà bạn đã tạo. Để làm điều đó, bạn cần tạo và đặt nhóm liên kết.

Nhóm liên kết là một tập hợp các tài nguyên mà bạn muốn cho phép chương trình đổ bóng truy cập vào cùng lúc. Dữ liệu này có thể bao gồm một số loại vùng đệm, chẳng hạn như vùng đệm đồng nhất và các tài nguyên khác như hoạ tiết và bộ lấy mẫu không được đề cập ở đây nhưng là các phần phổ biến của kỹ thuật kết xuất WebGPU.

  • Tạo một nhóm liên kết có vùng đệm đồng nhất bằng cách thêm mã sau đây sau khi tạo vùng đệm đồng nhất và quy trình kết xuất:

index.html

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

Ngoài label tiêu chuẩn hiện có, bạn cũng cần có layout mô tả loại tài nguyên có trong nhóm liên kết này. Đây là điều mà bạn sẽ tìm hiểu sâu hơn trong tương lai, nhưng hiện tại, bạn có thể vui lòng yêu cầu quy trình của mình về bố cục nhóm liên kết vì bạn đã tạo quy trình bằng layout: "auto". Điều đó khiến quy trình tự động tạo bố cục nhóm liên kết qua các liên kết mà bạn đã khai báo trong chính mã chương trình đổ bóng. Trong trường hợp này, bạn hãy yêu cầu getBindGroupLayout(0), trong đó 0 tương ứng với @group(0) bạn đã nhập vào chương trình đổ bóng.

Sau khi chỉ định bố cục, bạn cung cấp một mảng entries. Mỗi mục là một từ điển có ít nhất các giá trị sau:

  • binding, tương ứng với giá trị @binding() mà bạn đã nhập vào chương trình đổ bóng. Trong trường hợp này là 0.
  • resource – đây là tài nguyên thực tế mà bạn muốn hiển thị cho biến tại chỉ mục liên kết được chỉ định. Trong trường hợp này, vùng đệm đồng nhất của bạn.

Hàm trả về một GPUBindGroup. Đây là một ô điều khiển mờ và không thể thay đổi. Bạn không thể thay đổi tài nguyên mà một nhóm liên kết trỏ đến sau khi tạo, mặc dù bạn có thể thay đổi nội dung của những tài nguyên đó. Ví dụ: nếu bạn thay đổi vùng đệm đồng nhất để chứa kích thước lưới mới, thì điều này sẽ được phản ánh bằng các lệnh gọi vẽ trong tương lai sử dụng nhóm liên kết này.

Liên kết nhóm liên kết

Bây giờ nhóm liên kết đã được tạo, bạn vẫn cần phải yêu cầu WebGPU sử dụng nhóm này khi vẽ. Thật may là việc này khá đơn giản.

  1. Quay lại lượt kết xuất và thêm dòng mới này trước phương thức draw():

index.html

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

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

pass.draw(vertices.length / 2);

0 được truyền dưới dạng đối số đầu tiên tương ứng với @group(0) trong mã chương trình đổ bóng. Bạn cho biết rằng mỗi @binding thuộc @group(0) đều sử dụng các tài nguyên trong nhóm liên kết này.

Giờ đây, vùng đệm đồng nhất tiếp xúc với chương trình đổ bóng của bạn!

  1. Hãy làm mới trang và sau đó bạn sẽ thấy nội dung như sau:

Một hình vuông nhỏ màu đỏ ở giữa nền xanh dương đậm.

Thật tuyệt! Hình vuông của bạn giờ đã lớn hơn 1/4 kích thước trước đây! Con số này không nhiều, nhưng điều này cho thấy rằng chế độ đồng nhất của bạn thực sự đã được áp dụng và chương trình đổ bóng hiện có thể truy cập vào kích thước lưới của bạn.

Thao tác với hình học trong chương trình đổ bóng

Giờ đây khi đã có thể tham chiếu kích thước lưới trong chương trình đổ bóng, bạn có thể bắt đầu thực hiện một số thao tác để điều chỉnh hình học đang kết xuất cho vừa với mẫu lưới mong muốn. Để làm được điều đó, hãy xem xét chính xác những gì bạn muốn đạt được.

Về mặt lý thuyết, bạn cần chia canvas của mình thành từng ô riêng lẻ. Để tuân theo quy ước rằng trục X tăng lên khi bạn di chuyển sang phải và trục Y tăng khi bạn di chuyển lên, hãy giả sử rằng ô đầu tiên nằm ở góc dưới cùng bên trái của canvas. Thao tác đó cung cấp cho bạn một bố cục như sau, với hình vuông hiện tại ở giữa:

Hình minh hoạ lưới khái niệm, không gian Toạ độ của thiết bị được chuẩn hoá sẽ được phân chia khi hiển thị từng ô với hình vuông đang được kết xuất ở chính giữa.

Thử thách của bạn là tìm một phương thức trong chương trình đổ bóng cho phép bạn định vị hình vuông trong bất kỳ ô nào được cung cấp toạ độ ô.

Trước tiên, bạn có thể thấy hình vuông không được căn chỉnh đẹp mắt với bất kỳ ô nào do ô được xác định là bao quanh giữa canvas. Bạn muốn hình vuông dịch chuyển nửa ô để ô này nằm vừa khít bên trong.

Bạn có thể khắc phục vấn đề này bằng cách cập nhật vùng đệm đỉnh của hình vuông. Bằng cách dịch chuyển các đỉnh sao cho góc dưới cùng bên phải nằm ở, ví dụ, (0,1, 0,1) thay vì (-0,8, -0,8), bạn sẽ di chuyển hình vuông này để thẳng hàng với ranh giới ô độc đáo hơn. Tuy nhiên, vì bạn có toàn quyền kiểm soát cách xử lý các đỉnh trong chương trình đổ bóng nên bạn chỉ cần sử dụng mã chương trình đổ bóng để chèn các đỉnh đó vào đúng vị trí!

  1. Thay đổi mô-đun chương trình đổ bóng đỉnh bằng đoạn mã sau:

index.html (lệnh gọi 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);
}

Thao tác này sẽ di chuyển mọi đỉnh lên trên và sang bên phải theo một (hãy nhớ là một nửa không gian đoạn video) trước khi chia nó cho kích thước lưới. Kết quả thu được là một hình vuông căn chỉnh đẹp mắt với lưới ngay ở gốc đó.

Hình ảnh minh hoạ canvas được chia thành lưới 4x4 với một hình vuông màu đỏ trong ô (2, 2)

Tiếp theo, vì hệ toạ độ của canvas đặt (0, 0) ở chính giữa và (-1, -1) ở phía dưới bên trái và bạn muốn (0, 0) ở phía dưới bên trái, nên bạn cần dịch vị trí hình học theo (-1, -1) sau khi chia cho kích thước lưới để di chuyển hình đó vào góc đó.

  1. Dịch vị trí hình học của bạn, như sau:

index.html (lệnh gọi 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); 
}

Và bây giờ hình vuông của bạn được đặt độc đáo trong ô (0, 0)!

Hình ảnh minh hoạ canvas được chia thành lưới 4x4 với một hình vuông màu đỏ trong ô (0, 0)

Nếu bạn muốn đặt mã này vào một ô khác thì sao? Hãy tính toán bằng cách khai báo vectơ cell trong chương trình đổ bóng và điền vào đó bằng một giá trị tĩnh như let cell = vec2f(1, 1).

Nếu bạn thêm thẻ đó vào gridPos, thao tác này sẽ huỷ - 1 trong thuật toán và đây không phải là điều bạn muốn. Thay vào đó, bạn chỉ muốn di chuyển hình vuông theo một đơn vị lưới (bằng một phần tư của canvas) cho mỗi ô. Có vẻ như bạn cần thực hiện một phép chia khác cho grid!

  1. Thay đổi vị trí lưới như sau:

index.html (lệnh gọi 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);
}

Nếu làm mới ngay bây giờ, bạn sẽ thấy như sau:

Hình ảnh về canvas được chia thành một lưới 4x4 với một hình vuông màu đỏ ở giữa ô (0, 0), ô (0, 1), ô (1, 0) và ô (1, 1)

Ừm. Đây không phải là điều bạn muốn.

Lý do là vì toạ độ canvas đi từ -1 đến +1, nên thực ra là trên 2 đơn vị. Điều đó có nghĩa là nếu bạn muốn di chuyển một đỉnh bằng một phần tư canvas qua, bạn phải di chuyển nó 0,5 đơn vị. Đây là một sai lầm dễ mắc phải khi suy luận bằng toạ độ GPU! Rất may là việc khắc phục cũng rất dễ dàng.

  1. Nhân độ lệch của bạn với 2, như sau:

index.html (lệnh gọi 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);
}

Và công cụ này cung cấp cho bạn chính xác những gì bạn muốn.

Hình ảnh minh hoạ canvas được chia thành một lưới 4x4 với một hình vuông màu đỏ trong ô (1, 1)

Ảnh chụp màn hình sẽ có dạng như sau:

Ảnh chụp màn hình một hình vuông màu đỏ trên nền xanh dương đậm. Hình vuông màu đỏ được vẽ ở cùng vị trí như mô tả trong biểu đồ trước, nhưng không có lớp phủ lưới.

Hơn nữa, giờ đây, bạn có thể đặt cell thành bất kỳ giá trị nào trong ranh giới của lưới, sau đó làm mới để xem hình vuông hiển thị ở vị trí mong muốn.

Vẽ thực thể

Giờ đây, bạn có thể đặt hình vuông ở nơi bạn muốn bằng một chút toán học, bước tiếp theo là kết xuất một hình vuông trong mỗi ô của lưới.

Có một cách để tiếp cận đối tượng này là ghi toạ độ ô vào vùng đệm đồng nhất, sau đó gọi lệnh draw một lần cho mỗi hình vuông trong lưới, đồng thời cập nhật giá trị đồng nhất mỗi lần. Tuy nhiên, sẽ rất chậm vì GPU phải đợi toạ độ mới được JavaScript viết mỗi lần. Một trong những chìa khoá để đạt được hiệu suất tốt từ GPU là giảm thiểu thời gian mà GPU phải chờ đợi các phần khác của hệ thống!

Thay vào đó, bạn có thể dùng một kỹ thuật gọi là tạo khoảng không quảng cáo. Gây kích thước là một cách để yêu cầu GPU vẽ nhiều bản sao của cùng một hình bằng một lệnh gọi đến draw. Phương pháp này nhanh hơn nhiều so với việc gọi draw một lần cho mỗi bản sao. Mỗi bản sao của hình được gọi là thực thể.

  1. Để cho GPU biết rằng bạn muốn có đủ các thực thể của hình vuông để lấp đầy lưới, hãy thêm một đối số vào hàm gọi vẽ hiện có:

index.html

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

Điều này cho hệ thống biết rằng bạn muốn vẽ sáu đỉnh (vertices.length / 2) của hình vuông 16 (GRID_SIZE * GRID_SIZE) lần. Tuy nhiên, nếu làm mới trang, bạn vẫn thấy những nội dung sau:

Một hình ảnh giống hệt với sơ đồ trước đó để cho biết rằng không có gì thay đổi.

Tại sao? Đó là vì bạn đã vẽ cả 16 hình vuông đó tại cùng một chỗ. Bạn cần có thêm một số logic trong chương trình đổ bóng để định vị lại hình học trên cơ sở từng phiên bản.

Trong chương trình đổ bóng, ngoài các thuộc tính đỉnh như pos lấy từ vùng đệm đỉnh, bạn cũng có thể truy cập vào các giá trị tích hợp sẵn của WGSL. Đây là các giá trị do WebGPU tính toán và một trong những giá trị đó là instance_index. instance_index là một số 32 bit chưa ký từ 0 đến number of instances - 1 mà bạn có thể sử dụng trong logic chương trình đổ bóng. Giá trị của biến này là như nhau đối với mọi đỉnh được xử lý trên cùng một thực thể. Tức là chương trình đổ bóng đỉnh (vertex) được gọi 6 lần với instance_index0, một lần cho mỗi vị trí trong vùng đệm đỉnh. Sau đó, 6 lần nữa với instance_index1, sau đó là 6 lần nữa với instance_index2, v.v.

Để thực hiện được việc này, bạn phải thêm instance_index được tích hợp sẵn vào dữ liệu đầu vào của chương trình đổ bóng. Thực hiện việc này tương tự như đối với vị trí, nhưng thay vì gắn thẻ đối số đó bằng thuộc tính @location, hãy sử dụng @builtin(instance_index), sau đó đặt tên bất kỳ cho đối số đó. (Bạn có thể gọi hàm này là instance để khớp với mã ví dụ.) Sau đó, hãy sử dụng thuộc tính này như một phần của logic chương trình đổ bóng!

  1. Sử dụng instance thay cho toạ độ ô:

index.html

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

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

  return vec4f(gridPos, 0, 1);
}

Nếu làm mới bây giờ, bạn sẽ thấy rằng thực sự có nhiều ô vuông! Tuy nhiên, bạn không thể xem toàn bộ 16 mục.

Bốn hình vuông màu đỏ trên một đường chéo từ góc dưới bên trái tới góc trên cùng bên phải trên nền xanh dương đậm.

Đó là do các toạ độ ô bạn tạo ra là (0, 0), (1, 1), (2, 2)... cho đến (15, 15), nhưng chỉ bốn vị trí đầu tiên phù hợp trên canvas. Để tạo lưới như mong muốn, bạn cần chuyển đổi instance_index để mỗi chỉ mục ánh xạ tới một ô duy nhất trong lưới, như sau:

Hình ảnh về canvas được chia thành một lưới 4x4, trong đó mỗi ô cũng tương ứng với một chỉ mục thực thể tuyến tính.

Việc tính toán cho điều đó khá đơn giản. Đối với giá trị X của mỗi ô, bạn cần lấy modulo của instance_index và chiều rộng lưới. Bạn có thể thực hiện việc này trong WGSL bằng toán tử %. Và đối với giá trị Y của mỗi ô, bạn muốn instance_index chia cho chiều rộng lưới, hãy loại bỏ mọi phần dư phân số. Bạn có thể thực hiện việc đó bằng hàm floor() của WGSL.

  1. Thay đổi phép tính như sau:

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

Sau khi cập nhật mã, cuối cùng bạn sẽ có lưới ô vuông mà bạn đã chờ đợi từ lâu!

4 hàng gồm 4 cột hình vuông màu đỏ trên nền xanh dương đậm.

  1. Và giờ đây khi tính năng này đã hoạt động, hãy quay lại và tăng kích thước lưới!

index.html

const GRID_SIZE = 32;

32 hàng gồm 32 cột hình vuông màu đỏ trên nền xanh dương đậm.

Tada! Thực tế, bạn có thể làm cho lưới này thực sự lớn, thực sự lớn và GPU trung bình của bạn sẽ xử lý tốt. Bạn sẽ không còn thấy từng ô vuông trước khi gặp bất kỳ điểm tắc nghẽn nào về hiệu suất GPU.

6. Thêm tín dụng: tăng thêm màu sắc!

Đến đây, bạn có thể dễ dàng chuyển sang phần tiếp theo vì bạn đã đặt nền móng cho phần còn lại của lớp học lập trình này. Tuy nhiên, mặc dù lưới ô vuông cùng màu vẫn có thể phục vụ được, nhưng điều này không thực sự thú vị phải không? May mắn là bạn có thể làm mọi thứ sáng sủa hơn với thêm một chút mã toán học và chương trình đổ bóng!

Sử dụng cấu trúc trong chương trình đổ bóng

Cho đến bây giờ, bạn đã truyền một phần dữ liệu ra khỏi chương trình đổ bóng đỉnh: vị trí đã chuyển đổi. Nhưng trên thực tế, bạn có thể trả về nhiều dữ liệu hơn từ chương trình đổ bóng đỉnh, sau đó sử dụng dữ liệu trong chương trình đổ bóng mảnh!

Cách duy nhất để truyền dữ liệu ra khỏi chương trình đổ bóng đỉnh là trả về dữ liệu đó. Chương trình đổ bóng đỉnh luôn cần phải trả về một vị trí, vì vậy, nếu muốn trả về bất kỳ dữ liệu nào khác cùng với dữ liệu đó, bạn cần đặt dữ liệu này trong một cấu trúc. Cấu trúc trong WGSL là các loại đối tượng được đặt tên và chứa một hoặc nhiều thuộc tính có tên. Bạn cũng có thể đánh dấu các thuộc tính bằng các thuộc tính như @builtin@location. Bạn khai báo các lớp này bên ngoài hàm bất kỳ, sau đó có thể truyền các thực thể của hàm vào và ra khỏi hàm khi cần. Ví dụ: xem xét chương trình đổ bóng đỉnh hiện tại của bạn:

index.html (lệnh gọi 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);
}
  • Biểu thị điều tương tự bằng cách sử dụng cấu trúc cho đầu vào và đầu ra của hàm:

index.html (lệnh gọi 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;
}

Xin lưu ý rằng bạn phải tham chiếu vị trí đầu vào và chỉ mục thực thể bằng input. Trước tiên, cấu trúc mà bạn trả về cần được khai báo dưới dạng biến và thiết lập các thuộc tính riêng lẻ. Trong trường hợp này, điều này không tạo ra quá nhiều khác biệt và thực tế là làm cho chương trình đổ bóng hoạt động lâu hơn một chút, nhưng khi các chương trình đổ bóng trở nên phức tạp hơn, việc sử dụng cấu trúc có thể là một cách hay để giúp bạn sắp xếp dữ liệu.

Truyền dữ liệu giữa hàm đỉnh và hàm mảnh

Xin lưu ý rằng hàm @fragment của bạn càng đơn giản càng tốt:

index.html (lệnh gọi createShaderModule)

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

Bạn không nhận bất kỳ dữ liệu đầu vào nào và bạn đang chuyển ra một màu đồng nhất (đỏ) làm đầu ra của mình. Tuy nhiên, nếu chương trình đổ bóng biết nhiều hơn về hình học mà nó đang tô màu, bạn có thể sử dụng dữ liệu bổ sung đó để làm cho mọi thứ trở nên thú vị hơn. Ví dụ: điều gì xảy ra nếu bạn muốn thay đổi màu của từng hình vuông dựa trên toạ độ ô? Giai đoạn @vertex biết ô nào đang được kết xuất; bạn chỉ cần chuyển tiếp đến giai đoạn @fragment.

Để truyền dữ liệu giữa các giai đoạn của đỉnh và mảnh, bạn cần đưa dữ liệu đó vào một cấu trúc đầu ra với @location mà chúng ta chọn. Vì bạn muốn truyền toạ độ của ô, hãy thêm toạ độ đó vào cấu trúc VertexOutput trước đó, sau đó đặt toạ độ trong hàm @vertex trước khi quay lại.

  1. Thay đổi giá trị trả về của chương trình đổ bóng đỉnh, như sau:

index.html (lệnh gọi 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. Trong hàm @fragment, hãy nhận giá trị bằng cách thêm một đối số có cùng @location. (Các tên này không nhất thiết phải khớp nhau, nhưng bạn sẽ dễ dàng theo dõi mọi thứ nếu có!)

index.html (lệnh gọi 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. Ngoài ra, bạn có thể sử dụng một cấu trúc:

index.html (lệnh gọi createShaderModule)

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

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. Một cách khác là sử dụng lại cấu trúc đầu ra của giai đoạn @vertex, vì trong mã của bạn, cả hai hàm này đều được xác định trong cùng một mô-đun chương trình đổ bóng! Điều này giúp việc truyền các giá trị trở nên dễ dàng vì tên và vị trí là nhất quán một cách tự nhiên.

index.html (lệnh gọi createShaderModule)

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

Bất kể bạn chọn mẫu nào, kết quả là bạn có quyền truy cập vào số ô trong hàm @fragment và có thể sử dụng số ô đó để điều chỉnh màu. Với bất kỳ mã nào ở trên, kết quả sẽ có dạng như sau:

Một lưới gồm các hình vuông, trong đó cột ngoài cùng bên trái có màu xanh lục, hàng dưới cùng có màu đỏ và tất cả các hình vuông khác có màu vàng.

Giờ thì chắc chắn là đã có thêm nhiều màu, nhưng hình thức trông không đẹp lắm. Bạn có thể thắc mắc tại sao chỉ có hàng bên trái và hàng dưới cùng khác nhau. Đó là do các giá trị màu mà bạn trả về từ hàm @fragment mong muốn mỗi kênh nằm trong khoảng từ 0 đến 1 và mọi giá trị ngoài phạm vi đó đều được gắn với giá trị đó. Mặt khác, giá trị ô của bạn nằm trong khoảng từ 0 đến 32 dọc theo mỗi trục. Vì vậy, những gì bạn thấy ở đây là hàng và cột đầu tiên ngay lập tức đạt đến giá trị 1 đầy đủ trên kênh màu đỏ hoặc xanh lục và mọi ô sau đó đều gắn với cùng một giá trị.

Nếu muốn chuyển đổi suôn sẻ hơn giữa các màu, bạn cần trả về giá trị phân số cho mỗi kênh màu, lý tưởng nhất là bắt đầu từ 0 và kết thúc tại một trục dọc, có nghĩa là một phép chia khác cho grid!

  1. Thay đổi chương trình đổ bóng mảnh như sau:

index.html (lệnh gọi createShaderModule)

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

Làm mới trang và bạn có thể thấy mã mới cung cấp cho bạn độ dốc màu đẹp hơn nhiều trên toàn bộ lưới.

Một lưới các hình vuông chuyển từ màu đen, sang đỏ, xanh lục và vàng ở các góc khác nhau.

Mặc dù đây chắc chắn là một sự cải tiến, nhưng giờ đây sẽ có một góc tối không tốt ở phía dưới bên trái, nơi lưới trở nên màu đen. Khi bạn bắt đầu thực hiện mô phỏng Trò chơi cuộc sống, một phần khó nhìn thấy của lưới sẽ che khuất những gì đang diễn ra. Tôi nghĩ bạn nên tăng độ sáng của đèn đó.

May mắn thay, bạn có một kênh màu hoàn toàn chưa sử dụng (xanh lam) mà bạn có thể sử dụng. Hiệu ứng lý tưởng mà bạn muốn là màu xanh dương sáng nhất tại vị trí mà các màu khác tối nhất và sau đó mờ dần khi các màu khác tăng cường độ. Cách dễ nhất để làm việc đó là để kênh bắt đầu từ 1 và trừ đi một trong các giá trị ô. Đó có thể là c.x hoặc c.y. Hãy thử cả hai, sau đó chọn phương thức bạn thích!

  1. Thêm màu sáng hơn vào chương trình đổ bóng mảnh, như sau:

Lệnh gọi createShaderModule

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

Kết quả có vẻ khá đẹp!

Một lưới các hình vuông chuyển từ màu đỏ, sang màu xanh lục và màu xanh dương sang màu vàng ở các góc khác nhau.

Đây không phải là một bước quan trọng! Tuy nhiên, vì giao diện đẹp hơn nên nó đã được đưa vào tệp nguồn điểm kiểm tra tương ứng. Các ảnh chụp màn hình còn lại trong lớp học lập trình này phản ánh lưới nhiều màu sắc hơn.

7. Quản lý trạng thái ô

Tiếp theo, bạn cần kiểm soát những ô nào trên lưới sẽ kết xuất, dựa trên trạng thái nào đó được lưu trữ trên GPU. Điều này rất quan trọng cho quá trình mô phỏng cuối cùng!

Tất cả những gì bạn cần là tín hiệu bật tắt cho mỗi ô, do đó, mọi tuỳ chọn cho phép bạn lưu trữ một mảng lớn với gần như mọi loại giá trị đều hoạt động. Bạn có thể cho rằng đây là một trường hợp sử dụng khác cho vùng đệm đồng nhất! Mặc dù bạn có thể thực hiện việc đó, nhưng khó khăn hơn vì các vùng đệm đồng nhất bị giới hạn về kích thước, không thể hỗ trợ các mảng có kích thước động (bạn phải chỉ định kích thước mảng trong chương trình đổ bóng) và không thể ghi vào bằng chương trình đổ bóng điện toán. Mục cuối cùng là khó khăn nhất vì bạn muốn mô phỏng Trò chơi cuộc sống trên GPU trong chương trình đổ bóng điện toán.

Rất may là có một tuỳ chọn vùng đệm khác giúp tránh tất cả những hạn chế đó.

Tạo vùng đệm lưu trữ

Vùng đệm lưu trữ là vùng đệm sử dụng chung có thể đọc và ghi vào chương trình đổ bóng điện toán và đọc trong chương trình đổ bóng đỉnh. Các bộ nhớ này có thể rất lớn và không cần kích thước khai báo cụ thể trong chương trình đổ bóng nên giống bộ nhớ chung hơn. Đây là mã mà bạn sử dụng để lưu trữ trạng thái ô.

  1. Để tạo vùng đệm lưu trữ cho trạng thái ô, hãy sử dụng cái mà hiện tại có thể bắt đầu là một đoạn mã tạo vùng đệm quen thuộc:

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

Tương tự như với đỉnh và vùng đệm đồng nhất, hãy gọi device.createBuffer() với kích thước thích hợp, sau đó nhớ chỉ định cách sử dụng GPUBufferUsage.STORAGE cho lần này.

Bạn có thể điền sẵn các giá trị vào vùng đệm theo cách tương tự như trước đây bằng cách điền các giá trị vào TypedArray có cùng kích thước, sau đó gọi device.queue.writeBuffer(). Vì bạn muốn thấy tác động của vùng đệm trên lưới, hãy bắt đầu bằng cách lấp đầy vùng đệm bằng nội dung nào đó có thể dự đoán.

  1. Kích hoạt từng ô thứ ba bằng mã sau:

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

Đọc vùng đệm lưu trữ trong chương trình đổ bóng

Tiếp theo, hãy cập nhật chương trình đổ bóng để xem nội dung của vùng đệm lưu trữ trước khi kết xuất lưới. Việc này trông rất giống với những lần thêm đồng phục trước đây.

  1. Cập nhật chương trình đổ bóng bằng đoạn mã sau:

index.html

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

Trước tiên, bạn thêm điểm liên kết nằm ngay bên dưới lưới đồng nhất. Bạn muốn giữ nguyên @group như grid đồng nhất, nhưng số @binding cần phải khác. Loại varstorage, để phản ánh loại vùng đệm khác nhau và thay vì một vectơ, loại mà bạn cung cấp cho cellState là một mảng các giá trị u32 để khớp với Uint32Array trong JavaScript.

Tiếp theo, trong phần nội dung của hàm @vertex, hãy truy vấn trạng thái của ô. Vì trạng thái được lưu trữ trong một mảng phẳng thuộc vùng đệm lưu trữ, nên bạn có thể sử dụng instance_index để tra cứu giá trị của ô hiện tại!

Làm cách nào để tắt một ô nếu trạng thái cho biết ô đó không hoạt động? Do các trạng thái hoạt động và không hoạt động mà bạn nhận được từ mảng là 1 hoặc 0, bạn có thể điều chỉnh tỷ lệ hình học theo trạng thái hoạt động! Việc chia tỷ lệ theo tỷ lệ 1 sẽ chỉ giữ lại hình học và việc điều chỉnh theo tỷ lệ 0 sẽ khiến hình học thu gọn thành một điểm duy nhất, sau đó GPU sẽ loại bỏ.

  1. Hãy cập nhật mã chương trình đổ bóng để điều chỉnh tỷ lệ vị trí theo trạng thái hoạt động của ô. Giá trị trạng thái phải được truyền tới f32 để đáp ứng các yêu cầu về an toàn về kiểu của 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;
}

Thêm vùng đệm lưu trữ vào nhóm liên kết

Trước khi bạn có thể thấy trạng thái ô có hiệu lực, hãy thêm vùng đệm lưu trữ vào một nhóm liên kết. Vì thuộc tính này thuộc cùng một @group với vùng đệm đồng nhất, nên bạn cũng thêm phần này vào cùng một nhóm liên kết trong mã JavaScript.

  • Thêm vùng đệm lưu trữ, như sau:

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

Hãy đảm bảo rằng binding của mục mới khớp với @binding() của giá trị tương ứng trong chương trình đổ bóng!

Với vị trí đó, bạn sẽ có thể làm mới và thấy mẫu xuất hiện trong lưới.

Các đường sọc chéo của những hình vuông nhiều màu sắc chạy từ dưới cùng bên trái tới trên cùng bên phải trên nền màu xanh dương đậm.

Sử dụng mẫu vùng đệm bóng bàn

Hầu hết các mô phỏng như mô phỏng bạn đang xây dựng thường sử dụng ít nhất hai bản sao trạng thái của chúng. Trong mỗi bước của quá trình mô phỏng, các trình mô phỏng sẽ đọc từ một bản sao của trạng thái và ghi vào bản sao khác. Sau đó, ở bước tiếp theo, hãy lật mã và đọc từ trạng thái mà các em đã viết sang trước đó. Đây thường được gọi là mẫu ping bàn vì phiên bản mới nhất của trạng thái sẽ gửi qua lại giữa các bản sao trạng thái cho mỗi bước.

Vì sao việc đó lại cần thiết? Xem ví dụ đơn giản: tưởng tượng rằng bạn đang viết một mô phỏng rất đơn giản, trong đó bạn di chuyển bất kỳ khối đang hoạt động nào sang phải theo một ô mỗi bước. Để đảm bảo mọi thứ dễ hiểu, bạn xác định dữ liệu và mô phỏng của mình trong 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.

Nhưng nếu bạn chạy mã đó, ô hoạt động sẽ di chuyển đến cuối mảng chỉ bằng một bước! Tại sao? Do bạn liên tục cập nhật trạng thái tại chỗ, do đó bạn di chuyển ô đang hoạt động sang phải, sau đó nhìn vào ô tiếp theo và... thân mến! Đã kích hoạt! Tốt hơn là bạn nên di chuyển lại sang phải. Việc bạn thay đổi dữ liệu cùng lúc với việc bạn quan sát thấy dữ liệu đó sẽ làm hỏng kết quả.

Bằng cách sử dụng mẫu bóng bàn, bạn đảm bảo rằng mình luôn thực hiện bước tiếp theo của quá trình mô phỏng bằng cách chỉ sử dụng kết quả của bước cuối cùng.

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

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

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. Sử dụng mẫu này trong mã của riêng bạn bằng cách cập nhật mức phân bổ vùng đệm lưu trữ để tạo 2 vùng đệm giống hệt nhau:

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. Để giúp trực quan hoá sự khác biệt giữa hai vùng đệm, hãy điền dữ liệu khác nhau vào chúng:

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. Để hiển thị các vùng đệm lưu trữ khác nhau trong quá trình kết xuất, hãy cập nhật các nhóm liên kết để có hai biến thể khác nhau:

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

Thiết lập vòng lặp kết xuất

Cho đến nay, bạn chỉ mới thực hiện một lần vẽ mỗi lần làm mới trang, nhưng bây giờ bạn muốn hiển thị dữ liệu cập nhật theo thời gian. Để làm được điều đó, bạn cần có một vòng lặp kết xuất đơn giản.

Vòng lặp kết xuất là một vòng lặp lặp lại liên tục giúp đưa nội dung của bạn vào canvas theo một khoảng thời gian nhất định. Nhiều trò chơi và nội dung khác muốn tạo ảnh động một cách mượt mà sử dụng hàm requestAnimationFrame() để lên lịch các lệnh gọi lại ở cùng tốc độ làm mới màn hình (60 lần mỗi giây).

Ứng dụng này cũng có thể sử dụng tính năng đó, nhưng trong trường hợp này, có lẽ bạn nên thực hiện việc cập nhật theo các bước dài hơn để có thể dễ dàng theo dõi quá trình mô phỏng. Tự quản lý vòng lặp để có thể kiểm soát tốc độ cập nhật mô phỏng của bạn.

  1. Trước tiên, chọn tốc độ để mô phỏng của chúng tôi cập nhật (200 mili giây là tốt, nhưng bạn có thể chậm hơn hoặc nhanh hơn nếu muốn), sau đó theo dõi số bước mô phỏng đã hoàn tất.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. Sau đó, hãy di chuyển tất cả mã bạn đang sử dụng để kết xuất vào một hàm mới. Lên lịch lặp lại hàm đó vào khoảng thời gian bạn muốn bằng setInterval(). Hãy đảm bảo rằng hàm này cũng cập nhật số bước và sử dụng giá trị đó để chọn nhóm nào trong hai nhóm liên kết cần liên kết.

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

Giờ đây, khi chạy ứng dụng, bạn sẽ thấy canvas lật qua lại giữa việc hiển thị 2 vùng đệm trạng thái đã tạo.

Các đường sọc chéo của những hình vuông nhiều màu sắc chạy từ dưới cùng bên trái tới trên cùng bên phải trên nền màu xanh dương đậm. Các sọc dọc gồm những hình vuông sặc sỡ trên nền xanh dương đậm.

Vậy là bạn đã hoàn thành phần kết xuất của nhiều nội dung! Giờ thì bạn đã có thể hiển thị kết quả của quá trình mô phỏng trò chơi cuộc sống mà bạn tạo ở bước tiếp theo. Cuối cùng, bạn cũng có thể bắt đầu sử dụng chương trình đổ bóng điện toán.

Rõ ràng là khả năng kết xuất của WebGPU còn rất nhiều so với phần nhỏ mà bạn đã khám phá ở đây, nhưng phần còn lại nằm ngoài phạm vi của lớp học lập trình này. Hy vọng rằng tài liệu này đã cho bạn biết đầy đủ về cách hoạt động của tính năng kết xuất hình ảnh của WebGPU, qua đó giúp bạn dễ dàng khám phá các kỹ thuật nâng cao hơn như kết xuất 3D.

8. Chạy hoạt động mô phỏng

Bây giờ, đến phần quan trọng cuối cùng của câu đố: thực hiện mô phỏng Trò chơi cuộc sống trong chương trình đổ bóng điện toán!

Cuối cùng cũng phải dùng chương trình đổ bóng điện toán!

Bạn đã tìm hiểu về chương trình đổ bóng điện toán trong suốt lớp học lập trình này một cách trừu tượng, nhưng chính xác thì đó là gì?

Tương tự như các chương trình đổ bóng dạng đỉnh và mảnh, ở chỗ các chương trình này được thiết kế để chạy song song trên GPU, nhưng không giống như 2 giai đoạn trong chương trình đổ bóng còn lại, các chương trình này không có tập hợp đầu vào và đầu ra cụ thể. Bạn đang đọc và ghi dữ liệu độc quyền từ những nguồn bạn chọn, chẳng hạn như vùng đệm lưu trữ. Tức là thay vì thực thi một lần cho mỗi đỉnh, thực thể hoặc pixel, bạn phải cho nó biết số lệnh gọi hàm đổ bóng mà bạn muốn. Sau đó, khi chạy chương trình đổ bóng, bạn sẽ được thông báo lệnh gọi nào đang được xử lý và bạn có thể quyết định dữ liệu nào mình sẽ truy cập và thao tác nào sẽ thực hiện từ đó.

Bạn phải tạo chương trình đổ bóng điện toán trong mô-đun chương trình đổ bóng, tương tự như chương trình đổ bóng đỉnh và mảnh. Vì vậy, hãy thêm chương trình đó vào mã để bắt đầu. Như bạn có thể đoán, căn cứ theo cấu trúc của các chương trình đổ bóng khác mà bạn đã triển khai, bạn cần đánh dấu hàm chính của chương trình đổ bóng điện toán bằng thuộc tính @compute.

  1. Tạo chương trình đổ bóng điện toán bằng đoạn mã sau:

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

    }`
});

Vì GPU được sử dụng thường xuyên cho đồ hoạ 3D, chương trình đổ bóng điện toán có cấu trúc để bạn có thể yêu cầu chương trình đổ bóng được gọi một số lần cụ thể trên trục X, Y và Z. Điều này cho phép bạn dễ dàng điều phối công việc phù hợp với lưới 2D hoặc 3D, điều này rất phù hợp cho trường hợp sử dụng của bạn! Bạn muốn gọi chương trình đổ bóng này GRID_SIZE lần GRID_SIZE lần, một lần cho mỗi ô của quá trình mô phỏng.

Do bản chất của kiến trúc phần cứng GPU, lưới này được chia thành các nhóm làm việc. Mỗi nhóm làm việc có quy mô X, Y và Z. Mặc dù quy mô có thể là 1, nhưng thường thì nhóm làm việc của bạn sẽ có quy mô lớn hơn một chút về hiệu suất. Đối với chương trình đổ bóng của bạn, hãy chọn một quy mô nhóm công việc tương đối tuỳ ý là 8 x 8. Điều này rất hữu ích để theo dõi trong mã JavaScript của bạn.

  1. Xác định hằng số cho quy mô nhóm công việc như sau:

index.html

const WORKGROUP_SIZE = 8;

Bạn cũng cần thêm quy mô nhóm công việc vào chính hàm chương trình đổ bóng. Bạn có thể thực hiện việc này bằng cách sử dụng giá trị cố định mẫu của JavaScript để có thể dễ dàng sử dụng hằng số vừa xác định.

  1. Thêm quy mô nhóm công việc vào hàm chương trình đổ bóng, như sau:

index.html (Tính toán lệnh gọi createShaderModule)

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

}

Điều này báo cho chương trình đổ bóng hoạt động với hàm này được thực hiện trong các nhóm (8 x 8 x 1). (Bất kỳ trục nào bạn để mặc định là 1, mặc dù ít nhất bạn phải chỉ định trục X.)

Tương tự như các giai đoạn khác trong chương trình đổ bóng, có nhiều giá trị @builtin bạn có thể chấp nhận làm dữ liệu đầu vào cho hàm đổ bóng điện toán để cho biết bạn đang thực hiện lệnh gọi nào và quyết định việc bạn cần làm.

  1. Thêm giá trị @builtin như sau:

index.html (Tính toán lệnh gọi createShaderModule)

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

}

Bạn truyền vào global_invocation_id tích hợp. Vectơ ba chiều của số nguyên chưa ký cho biết bạn đang ở vị trí nào trong lưới lệnh gọi chương trình đổ bóng. Bạn chạy chương trình đổ bóng này một lần cho mỗi ô trong lưới. Bạn nhận được các số như (0, 0, 0), (1, 0, 0), (1, 1, 0)... cho đến hết (31, 31, 0), nghĩa là bạn có thể coi đây là chỉ mục ô mà bạn sẽ thao tác!

Chương trình đổ bóng điện toán cũng có thể sử dụng đồng nhất mà bạn sử dụng giống như trong chương trình đổ bóng đỉnh và mảnh.

  1. Sử dụng chế độ đồng nhất với chương trình đổ bóng điện toán để cho bạn biết kích thước lưới, như sau:

index.html (Tính toán lệnh gọi createShaderModule)

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

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

}

Tương tự như trong chương trình đổ bóng đỉnh, bạn cũng hiển thị trạng thái ô dưới dạng vùng đệm lưu trữ. Nhưng trong trường hợp này, bạn cần hai trong số đó! Vì chương trình đổ bóng điện toán không có dữ liệu đầu ra bắt buộc, chẳng hạn như vị trí đỉnh hoặc màu mảnh, nên việc ghi các giá trị vào vùng đệm lưu trữ hoặc hoạ tiết là cách duy nhất để thu được kết quả của chương trình đổ bóng điện toán. Sử dụng phương pháp bóng bàn mà bạn đã tìm hiểu trước đó; bạn có một vùng đệm lưu trữ cấp dữ liệu cho trạng thái hiện tại của lưới và một vùng đệm lưu trữ dùng để ghi trạng thái mới của lưới.

  1. Hiển thị trạng thái đầu vào và đầu ra của ô dưới dạng vùng đệm lưu trữ, như sau:

index.html (Tính toán lệnh gọi createShaderModule)

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

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

}

Xin lưu ý rằng vùng đệm lưu trữ đầu tiên được khai báo bằng var<storage>, nghĩa là vùng đệm này chỉ có thể đọc, nhưng vùng đệm lưu trữ thứ hai được khai báo bằng var<storage, read_write>. Điều này cho phép bạn vừa đọc vừa ghi vào vùng đệm, sử dụng vùng đệm đó làm dữ liệu đầu ra cho chương trình đổ bóng điện toán. (Không có chế độ lưu trữ chỉ ghi trong WebGPU).

Tiếp theo, bạn cần có cách ánh xạ chỉ mục ô vào mảng lưu trữ tuyến tính. Về cơ bản, điều này ngược lại với những gì bạn đã làm trong chương trình đổ bóng đỉnh, trong đó bạn lấy instance_index tuyến tính và ánh xạ nó vào một ô lưới 2D. (Xin nhắc lại, thuật toán của bạn cho việc đó là vec2f(i % grid.x, floor(i / grid.x)).)

  1. Viết một hàm để đi theo hướng khác. Hàm này lấy giá trị Y của ô, nhân giá trị đó với chiều rộng lưới, sau đó cộng giá trị X của ô.

index.html (Tính toán lệnh gọi createShaderModule)

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

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

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

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

Và cuối cùng, để đảm bảo tính năng này đang hoạt động, hãy triển khai một thuật toán thực sự đơn giản: nếu một ô hiện đang bật thì ô đó sẽ tắt và ngược lại. Đây chưa phải là Trò chơi của cuộc sống, nhưng đủ để cho thấy chương trình đổ bóng điện toán đang hoạt động.

  1. Thêm thuật toán đơn giản như sau:

index.html (Tính toán lệnh gọi createShaderModule)

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

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

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

Đó là toàn bộ nội dung trong chương trình đổ bóng điện toán của bạn! Tuy nhiên, bạn cần thực hiện một vài thay đổi nữa trước khi có thể thấy kết quả.

Sử dụng Bố cục quy trình và nhóm liên kết

Một điều mà bạn có thể nhận thấy từ chương trình đổ bóng ở trên là chương trình này chủ yếu sử dụng cùng các đầu vào (đồng nhất và vùng đệm lưu trữ) như quy trình kết xuất của bạn. Bạn có thể nghĩ rằng mình chỉ cần sử dụng cùng các nhóm liên kết và hoàn thành nó, đúng không? Tin vui là bạn có thể! Bạn chỉ cần thiết lập thủ công hơn một chút để có thể thực hiện việc đó.

Bạn cần cung cấp GPUBindGroupLayout mỗi khi tạo nhóm liên kết. Trước đây, bạn có được bố cục đó bằng cách gọi getBindGroupLayout() trên quy trình kết xuất. Quy trình này tự động tạo bố cục này vì bạn đã cung cấp layout: "auto" khi tạo nó. Phương pháp này hoạt động hiệu quả khi bạn chỉ sử dụng một quy trình duy nhất, nhưng nếu bạn có nhiều quy trình muốn chia sẻ tài nguyên, bạn cần tạo bố cục một cách rõ ràng, sau đó cung cấp bố cục đó cho cả nhóm liên kết và quy trình.

Để hiểu lý do, hãy xem xét điều này: trong quy trình kết xuất, bạn sử dụng một vùng đệm đồng nhất và một vùng đệm lưu trữ duy nhất, nhưng trong chương trình đổ bóng điện toán mà bạn vừa viết, bạn cần có vùng đệm lưu trữ thứ hai. Vì 2 chương trình đổ bóng sử dụng cùng một giá trị @binding cho vùng đệm lưu trữ đồng nhất và vùng đệm đầu tiên, bạn có thể chia sẻ các giá trị đó giữa các quy trình và quy trình kết xuất sẽ bỏ qua vùng đệm lưu trữ thứ hai mà vùng đệm này không sử dụng. Bạn muốn tạo một bố cục mô tả tất cả tài nguyên có trong nhóm liên kết, chứ không chỉ những tài nguyên được sử dụng trong một quy trình cụ thể.

  1. Để tạo bố cục đó, hãy gọi device.createBindGroupLayout():

index.html

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

Cấu trúc này tương tự như việc tạo nhóm liên kết, trong đó bạn mô tả danh sách entries. Điểm khác biệt là bạn mô tả loại tài nguyên mà mục nhập phải là và cách sử dụng tài nguyên đó thay vì cung cấp chính tài nguyên đó.

Trong mỗi mục, bạn cung cấp số binding cho tài nguyên. Số này (như đã tìm hiểu khi tạo nhóm liên kết) khớp với giá trị @binding trong chương trình đổ bóng. Bạn cũng cung cấp visibility là cờ GPUShaderStage cho biết giai đoạn trong chương trình đổ bóng có thể sử dụng tài nguyên. Bạn muốn cả vùng đệm lưu trữ đồng nhất và vùng đệm đầu tiên có thể truy cập được trong chương trình đổ bóng đỉnh và vùng đệm điện toán, nhưng chỉ cần truy cập vào vùng đệm lưu trữ thứ hai trong chương trình đổ bóng điện toán.

Cuối cùng, bạn cần cho biết loại tài nguyên đang được sử dụng. Đây là một khoá từ điển khác, tuỳ thuộc vào thông tin bạn cần cung cấp. Ở đây, cả 3 tài nguyên đều là vùng đệm nên bạn sẽ sử dụng khoá buffer để xác định các tuỳ chọn cho mỗi tài nguyên. Các tuỳ chọn khác bao gồm những mục như texture hoặc sampler, nhưng bạn không cần những mục đó ở đây.

Trong từ điển vùng đệm, bạn đặt các tuỳ chọn như type vùng đệm nào được sử dụng. Giá trị mặc định là "uniform", vì vậy, bạn có thể để trống từ điển để liên kết 0. (Mặc dù vậy, tối thiểu bạn phải đặt buffer: {} để mục nhập được xác định là vùng đệm.) Liên kết 1 được cấp một loại "read-only-storage" vì bạn không sử dụng với quyền truy cập read_write trong chương trình đổ bóng, còn liên kết 2 có loại "storage" vì bạn sử dụng với quyền truy cập read_write!

Sau khi tạo bindGroupLayout, bạn có thể truyền mã này vào khi tạo nhóm liên kết thay vì truy vấn nhóm liên kết qua quy trình. Việc này có nghĩa là bạn cần thêm mục mới trong vùng đệm lưu trữ vào mỗi nhóm liên kết để phù hợp với bố cục bạn vừa xác định.

  1. Cập nhật quy trình tạo nhóm liên kết như sau:

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

Và giờ đây, nhóm liên kết đã được cập nhật để sử dụng bố cục nhóm liên kết rõ ràng này, bạn cần cập nhật quy trình kết xuất để sử dụng cùng một thứ.

  1. Tạo một GPUPipelineLayout.

index.html

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

Bố cục quy trình là danh sách các bố cục nhóm liên kết (trong trường hợp này là bạn có một bố cục) mà một hoặc nhiều quy trình sử dụng. Thứ tự của bố cục nhóm liên kết trong mảng cần tương ứng với các thuộc tính @group trong chương trình đổ bóng. (Điều này có nghĩa là bindGroupLayout được liên kết với @group(0).)

  1. Sau khi bạn có bố cục quy trình, hãy cập nhật quy trình kết xuất để sử dụng thay vì "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
    }]
  }
});

Tạo quy trình điện toán

Tương tự như việc cần một quy trình kết xuất để sử dụng chương trình đổ bóng mảnh và đỉnh, bạn cần có một quy trình điện toán để sử dụng chương trình đổ bóng điện toán. May mắn là quy trình tính toán ít phức tạp hơn quy trình kết xuất vì chúng không có bất kỳ trạng thái nào để thiết lập mà chỉ có chương trình đổ bóng và bố cục.

  • Tạo một quy trình tính toán bằng đoạn mã sau:

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

Lưu ý rằng bạn truyền pipelineLayout mới thay vì "auto", giống như quy trình kết xuất mới cập nhật. Quy trình này đảm bảo rằng cả quy trình kết xuất và quy trình điện toán đều có thể sử dụng cùng nhóm liên kết.

Tính toán thẻ và vé

Như vậy, bạn sẽ thực sự tận dụng được quy trình điện toán! Giả sử bạn kết xuất trong một lượt kết xuất, bạn có thể đoán được mình cần thực hiện công việc điện toán trong một lượt điện toán. Cả hai công việc tính toán và kết xuất đều có thể diễn ra trong cùng một bộ mã hoá lệnh, vì vậy, bạn nên xáo trộn hàm updateGrid một chút.

  1. Di chuyển quy trình tạo bộ mã hoá lên đầu hàm rồi bắt đầu truyền điện toán với hàm này (trước step++).

index.html

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

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

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

Tương tự như quy trình tính toán, việc bắt đầu các lượt truyền điện toán đơn giản hơn nhiều so với các lượt kết xuất hình ảnh tương ứng vì bạn không cần phải lo lắng về tệp đính kèm.

Bạn muốn truyền điện toán trước lượt kết xuất vì phương thức này cho phép lượt kết xuất sử dụng ngay lập tức các kết quả mới nhất từ lượt điện toán. Đó cũng là lý do khiến bạn tăng số lượng step giữa các lần truyền, để vùng đệm đầu ra của quy trình tính toán sẽ trở thành vùng đệm đầu vào cho quy trình kết xuất.

  1. Tiếp theo, hãy thiết lập quy trình và nhóm liên kết bên trong thẻ điện toán, sử dụng cùng một mẫu để chuyển đổi giữa các nhóm liên kết như khi bạn thực hiện đối với lượt kết xuất hình ảnh.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Cuối cùng, thay vì vẽ như trong một lượt kết xuất, bạn điều phối công việc đến chương trình đổ bóng điện toán, cho nó biết số lượng nhóm công việc bạn muốn thực thi trên mỗi trục.

index.html

const computePass = encoder.beginComputePass();

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

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

computePass.end();

Một điều rất quan trọng cần lưu ý ở đây là số bạn truyền vào dispatchWorkgroups() không phải số lệnh gọi! Thay vào đó, đây là số lượng nhóm công việc cần thực thi, như được @workgroup_size xác định trong chương trình đổ bóng.

Nếu bạn muốn chương trình đổ bóng thực thi 32x32 lần để bao phủ toàn bộ lưới của bạn và quy mô nhóm công việc của bạn là 8x8, bạn cần điều phối các nhóm công việc 4x4 (4 * 8 = 32). Đó là lý do bạn chia kích thước lưới cho quy mô nhóm công việc rồi chuyển giá trị đó vào dispatchWorkgroups().

Bây giờ, bạn có thể làm mới lại trang và thấy rằng lưới tự đảo ngược với mỗi lần cập nhật.

Các đường sọc chéo của những hình vuông nhiều màu sắc chạy từ dưới cùng bên trái tới trên cùng bên phải trên nền màu xanh dương đậm. Các sọc chéo của những hình vuông đầy màu sắc có hai hình vuông rộng từ dưới cùng bên trái tới trên cùng bên phải trên nền màu xanh dương đậm. Đảo ngược hình ảnh trước đó.

Triển khai thuật toán cho Trò chơi cuộc sống

Trước khi cập nhật chương trình đổ bóng điện toán để triển khai thuật toán cuối cùng, bạn nên quay lại mã đang khởi chạy nội dung vùng đệm lưu trữ và cập nhật mã đó để tạo một vùng đệm ngẫu nhiên vào mỗi lần tải trang. (Các quy luật thông thường không tạo ra điểm bắt đầu cho Trò chơi cuộc sống thật thú vị.) Bạn có thể sắp xếp ngẫu nhiên các giá trị theo ý muốn. Tuy nhiên, có một cách dễ dàng để bắt đầu nhằm cung cấp kết quả hợp lý.

  1. Để bắt đầu mỗi ô ở trạng thái ngẫu nhiên, hãy cập nhật quá trình khởi tạo cellStateArray thành đoạn mã sau:

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

Bây giờ, cuối cùng bạn đã có thể triển khai logic cho mô phỏng Trò chơi cuộc sống. Sau tất cả những gì cần thiết để đến đây, mã đổ bóng có thể đơn giản đến mức đáng thất vọng!

Trước tiên, bạn cần biết có bao nhiêu thành phần lân cận đang hoạt động đối với một ô cụ thể. Bạn không quan tâm đến việc nào đang hoạt động mà chỉ quan tâm đến số lượng.

  1. Để việc lấy dữ liệu ô lân cận dễ dàng hơn, hãy thêm hàm cellActive trả về giá trị cellStateIn của toạ độ đã cho.

index.html (Tính toán lệnh gọi createShaderModule)

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

Hàm cellActive trả về một giá trị nếu ô đang hoạt động. Vì vậy, việc thêm giá trị trả về của lệnh gọi cellActive cho cả 8 ô xung quanh sẽ cho bạn biết số lượng ô lân cận đang hoạt động.

  1. Tìm số lượng lân cận đang hoạt động như sau:

index.html (Tính toán lệnh gọi createShaderModule)

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

Điều này dẫn đến một vấn đề nhỏ: điều gì sẽ xảy ra khi ô bạn đang kiểm tra nằm ngoài cạnh bảng? Theo logic cellIndex() hiện tại, dòng này sẽ tràn sang hàng tiếp theo hoặc hàng trước hoặc chạy ra khỏi cạnh của vùng đệm!

Đối với Trò chơi cuộc sống, một cách phổ biến và dễ dàng để giải quyết vấn đề này là đặt các ô ở cạnh lưới xem các ô ở cạnh đối diện của lưới là các ô lân cận, tạo ra một loại hiệu ứng bao bọc.

  1. Hỗ trợ bao bọc lưới với một thay đổi nhỏ đối với hàm cellIndex().

index.html (Tính toán lệnh gọi createShaderModule)

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

Bằng cách sử dụng toán tử % để gói ô X và Y khi ô này vượt quá kích thước lưới, bạn đảm bảo rằng bạn không bao giờ truy cập bên ngoài giới hạn của vùng đệm lưu trữ. Do đó, bạn có thể yên tâm rằng số lượng activeNeighbors có thể dự đoán được.

Sau đó, bạn áp dụng một trong bốn quy tắc:

  • Bất kỳ ô nào có ít hơn hai lân cận sẽ bị vô hiệu hoá.
  • Mọi ô đang hoạt động có 2 hoặc 3 thiết bị lân cận sẽ luôn hoạt động.
  • Mọi ô không hoạt động có đúng 3 ô lân cận sẽ được kích hoạt.
  • Bất kỳ ô nào có nhiều hơn 3 lân cận sẽ bị vô hiệu hoá.

Bạn có thể thực hiện việc này với một loạt câu lệnh if, nhưng WGSL cũng hỗ trợ các câu lệnh chuyển đổi, rất phù hợp với logic này.

  1. Triển khai logic Trò chơi cuộc sống, như sau:

index.html (Tính toán lệnh gọi createShaderModule)

let i = cellIndex(cell.xy);

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

Để tham khảo, lệnh gọi mô-đun chương trình đổ bóng điện toán sau cùng hiện sẽ có dạng như sau:

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

Chỉ vậy thôi! Bạn đã hoàn tất! Hãy làm mới trang của bạn và xem máy tự động di động mới được xây dựng của bạn phát triển!

Ảnh chụp màn hình một trạng thái mẫu trong mô phỏng Trò chơi cuộc sống, với các ô nhiều màu sắc được kết xuất trên nền xanh dương đậm.

9. Xin chúc mừng!

Bạn đã tạo một phiên bản mô phỏng Game of Life kinh điển của Conway chạy hoàn toàn trên GPU bằng API WebGPU!

Tiếp theo là gì?

Tài liệu đọc thêm

Tài liệu tham khảo