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

1. Giới thiệu

Biểu trưng WebGPU bao gồm một số 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 chức năng của GPU trong ứng dụng web.

API hiện đại

Trước WebGPU, có WebGL cung cấp một số tính năng của WebGPU. Công nghệ này đã tạo ra một lớp nội dung web đa dạng thức mới và các nhà phát triển đã tạo ra những điều tuyệt vời nhờ công nghệ này. Tuy nhiên, công cụ 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 cùng với Direct3D 12, MetalVulkan.

WebGPU mang các tính năng tiên tiến của các API hiện đại này đế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 tự nhiên trên web và ít chi tiết hơn so với một số API gốc mà API này được xây dựng dựa trên đó.

Kết xuất

GPU thường được liên kết với việc kết xuất đồ hoạ nhanh và chi tiết, và WebGPU cũng không 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 máy tính và GPU thiết bị di động, đồng thời cung cấp lộ trình để bổ sung các tính năng mới trong tương lai khi khả năng phần cứng tiếp tục phát triển.

Tính toán

Bên cạnh 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 tổng quát và song song. 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à tính toán của WebGPU để tạo một dự án giới thiệu đơ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ẽ xây dựng Trò chơi của Conway về cuộc sống bằng WebGPU. Ứ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 chức 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 hoàn thiện của lớp học lập trình này

Trò chơi cuộc sống (Game of Life) còn được gọi là automaton dạng lưới, trong đó lưới ô thay đổi trạng thái theo thời gian dựa trên một số quy tắc. Trong Trò chơi cuộc sống, các ô trở nên 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, dẫn đến các mẫu thú vị biến động khi bạn xem.

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 đỉ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 mô phỏng đơn giản.

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

Bạn cần có

  • Phiên bản Chrome mới đâ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ân phối ở mọi nơi.
  • 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, bạn có thể nhận thấy nhiều điểm tương đồng với WebGPU, 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 mã nào để bắt đầu. Tuy nhiên, bạn có thể tham khảo một số ví dụ hoạt động có thể đóng vai trò là điểm kiểm tra tại https://glitch.com/edit/#!/your-first-webgpu-app. Bạn có thể xem và tham khảo các ví dụ này khi gặp khó khăn.

Hãy sử dụng bảng điều khiển dành cho nhà phát triển!

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 hoạt động của API, API không thể đưa ra các ngoại lệ JavaScript thông thường cho nhiều lỗi, khiến 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 phải vấn đề khi phát triển bằng WebGPU, đặc biệt là khi mới bắt đầu. Điều đó không sao! Các nhà phát triển đứng sau API này 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 để đả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 để giúp bạn xác định và khắc phục vấn đề.

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

3. Khởi chạy WebGPU

Bắt đầu bằng <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. Tuy nhiên, 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ó một canvas. Đó là một điểm bắt đầu tốt!

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

  • Tạo tệp index.html bằng 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 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 để truyền tải trên toàn bộ hệ sinh thái web. Do đó, bước phòng ngừa đầu tiên nên làm 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 (dùng làm điểm truy cập cho WebGPU) có tồn tại hay không, hãy thêm 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 không có WebGPU bằng cách chuyển trang về chế độ không sử dụng WebGPU. (Có thể sử dụng WebGL thay thế không?) Tuy nhiên, trong phạm vi lớp học lập trình này, bạn chỉ cần gửi một lỗi để ngăn mã thực thi thêm.

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

  1. Để lấy 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, vì vậy, cách thuận tiện nhất là gọi phương thức này 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 để 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ể được truyền đến requestAdapter() để chỉ định xem bạn muốn sử dụng phần cứng tiết kiệm điện hay phần cứng hiệu suất cao trên các thiết bị có nhiều GPU (như một số máy tính xách tay).

Sau khi có bộ chuyển đổi, bước cuối cùng trước khi bạn 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 mà qua đó hầu hết các hoạt động tương tác với GPU đều diễn ra.

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

Cũng như requestAdapter(), có các tuỳ chọn có thể được truyền tại đây để sử dụng nâng cao hơn, chẳng hạn như bật các tính năng phần cứng cụ thể hoặc yêu cầu giới hạn cao hơn, nhưng đối với mục đích của bạn, các tuỳ chọn mặc định vẫn hoạt động tốt.

Định cấu hình Canvas

Giờ đây khi bạn đã có thiết bị, bạn còn phải làm một việc nữa 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à phương thức này trả về phải được liên kết với thiết bị bằng phương thức configure(), như sau:

index.html

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

Có một vài tuỳ chọn bạn có thể truyền ở đây, nhưng những tuỳ chọn 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 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 bố trí dữ liệu đó 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 kết cấu khác nhau. Nếu bạn không sử dụng định dạng ưu tiên của thiết bị, thì việc này có thể khiến các bản sao bộ nhớ bổ sung xảy ra ở chế độ nền trước khi hình ảnh có thể hiển thị dưới dạng một phần của trang.

May mắn là bạn không cần phải lo lắng về bất kỳ điều gì trong số đó vì WebGPU sẽ cho bạn biết định dạng nào nên dùng cho canvas! 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ư minh hoạ ở trên.

Xoá Canvas

Giờ đây, khi đã có một thiết bị và canvas đã được định cấu hình với thiết bị đó, 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á bằng một màu đồng nhất.

Để 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 việc 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 một Lượt kết xuất.

Lượt kết xuất là khi tất cả các thao tác vẽ trong WebGPU 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 trường hợp 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ữ chiều 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.

  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ề một 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. Các lượt kết xuất yêu cầu bạn cung cấp GPUTextureView thay vì GPUTexture. Lớp này sẽ cho biết phần nào của hoạ tiết cần kết xuấ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ố nào trên hoạ tiết, cho biết rằng bạn muốn quá trình kết xuất sử 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à khi kết thúc:

  • Giá trị loadOp"clear" cho biết bạn muốn xoá hoạ tiết khi quá trình 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ẽ được thực hiện trong lượt kết xuất được lưu vào hoạ tiết.

Sau khi quá trình 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. Việc bắt đầu quá trình kết xuất bằng loadOp: "clear" là đủ để xoá chế độ xem kết cấu và canvas.

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

index.html

pass.end();

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

  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ả lệnh GPU, đảm bảo rằng quá trình thực thi đượ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 bạn thường thấy hai bước đó được thu gọn thành một bước, như được thực hiện trong 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 lệnh đến GPU, hãy để JavaScript trả lại quyền kiểm soát cho trình duyệt. Tại thời điểm đó, trình duyệt sẽ 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 muốn cập nhật lại nội dung canvas sau đó, bạn cần ghi lại và gửi một vùng đệm lệnh mới, gọi lại context.getCurrentTexture() để lấy một hoạ tiết mới cho một lượt kết xuất.

  1. Tải lại trang. Lưu ý rằng canvas được tô 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.

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

Chọn màu!

Tuy nhiên, thật lòng mà nói, các hình vuông màu đen 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 để cá nhân hoá ứng dụng một chút.

  1. Trong lệnh gọi encoder.beginRenderPass(), hãy thêm một dòng mới có 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 lượt kết xuất màu nào sẽ sử dụng khi thực hiện thao tác clear ở đầu lượt. Từ điển được truyền vào 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ùng nhau 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 } là 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 chọn màu, hãy tải lại trang. Bạn sẽ thấy màu đã chọn trong canvas.

Một canvas được xoá thành màu xanh dương đậm để minh hoạ cách thay đổi màu xoá mặc định.

4. Vẽ hình học

Khi kết thúc phần này, ứng dụng của bạn sẽ vẽ một số hình học đơn giản lên canvas: một hình vuông có màu. Xin lưu ý rằng có vẻ như bạn phải làm rất nhiều việc để có được kết quả đơn giản như vậy, nhưng đó là vì WebGPU được thiết kế để kết xuất nhiều hình học một cách rất hiệu quả. Một hiệu ứng phụ của hiệu quả này là việc làm những việc tương đối đơn giản có thể cảm thấy khó khăn bất thường, nhưng đó là điều dự kiến nếu bạn chuyển sang một API như WebGPU – bạn muốn làm một việc phức tạp hơn một chút.

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

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

Không giống như một API như Canvas 2D có 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 hình dạng gốc như WebGPU gọi): đ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 hầu như chỉ hoạt động với tam giác vì tam giác có nhiều thuộc tính toán học thú vị giúp dễ dàng xử lý theo cách dự đoán và hiệu quả. Hầu hết mọi thứ bạn vẽ bằng GPU đều 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ủa chúng.

Các điểm này (hay đỉnh) được cung cấp theo các giá trị X, Y và Z (đối với nội dung 3D) xác định một điểm trên hệ toạ độ Descartes do WebGPU hoặc các API tương tự xác định. Bạn có thể dễ dàng suy nghĩ về cấu trúc của hệ toạ độ theo cách hệ toạ độ liên quan đến 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 cùng 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 cắt.

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ó tên là shader đỉnh để thực hiện mọi phép toán 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 toán khác cần thiết để vẽ các đỉ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ả các tam giác được tạo thành từ 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 thiết để vẽ các tam giác đó. 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. Tính toán đó có thể đơn giản như trả về 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 phản chiếu từ các bề mặt lân cận khác, được lọc qua sương mù và được sửa đổi theo độ kim loại của bề mặt. Bạn có toàn quyền kiểm soát tính năng này, điều này có thể vừa mang lại nhiều quyền lực vừa gây choáng ngợp.

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

Xác định đỉnh

Như đã đề cập trước đó, mô phỏng Trò chơi cuộc sống được hiển thị dưới dạng lưới ô. Ứng dụng của bạn cần có 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 có màu trong các ô đang hoạt động và để trống các ô không hoạt động.

Điều này có nghĩa là bạn cần cung cấp cho GPU 4 điểm khác nhau, mỗi điểm cho một trong 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 theo một cách nào đó, có toạ độ góc như sau:

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

Để cung cấp các toạ độ đó cho GPU, bạn cần đặt các giá trị trong một TypedArray. Nếu bạn chưa quen với TypedArrays, thì đây là một nhóm các đố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 từng phần tử trong chuỗi dưới dạng một loại 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 với 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 vào mã của bạn. Bạn nên đặt mã này ở gần đầu, ngay bên 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ị; chỉ để thuận tiện cho bạn và giúp giá trị dễ đọc hơn. Điều này giúp bạn thấy rằng mỗi cặp giá trị tạo nên toạ độ X và Y cho một đỉnh.

Nhưng có một vấn đề! Bạn còn nhớ GPU hoạt động theo hình tam giác 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 gồm 4 người. Giải pháp là lặp lại hai trong số các đỉnh để tạo ra hai tam giác có chung một cạnh ở 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 tam giác màu xanh dương và một lần cho tam giác 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 đó của bạn thành 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 hai tam giác để rõ ràng, nhưng vị trí đỉnh giống hệt nhau và GPU kết xuất các tam giác này mà không có khoảng trống. Hình ảnh này sẽ hiển thị dưới dạng một hình vuông liền mạch.

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

GPU không thể vẽ các đỉnh bằng 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 quá trình 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ể coi đây là một TypedArray hiển thị trên GPU.

  1. Để tạo vùng đệm chứa các đỉnh, hãy thêm lệnh gọi sau vào device.createBuffer() sau khi xác định 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. Bạn có thể đặt nhãn không bắt buộc cho mọi đối tượng WebGPU mà bạn tạo. Bạn chắc chắn nê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. Nếu bạn gặp bất kỳ vấn đề nào, các nhãn đó sẽ được dùng trong thông báo lỗi mà WebGPU tạo ra để giúp bạn hiểu được vấn đề.

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, được xác định bằng cách nhân kích thước của số thực 32 bit ( 4 byte) với số lượng số thực trong mảng vertices (12). Rất may, TypedArrays đã tính toán byteLength cho bạn, vì vậy, 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 mức sử dụng của 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 bộ đệm cho dữ liệu đỉnh (GPUBufferUsage.VERTEX) và bạn cũng muốn có thể sao chép dữ liệu vào bộ đệm đó (GPUBufferUsage.COPY_DST).

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

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

  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 chứa dữ liệu đỉnh, nhưng đối với GPU, đó 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 có thể cho WebGPU biết thêm về cấu trúc của dữ liệu đỉnh.

index.html

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

Điều này có thể hơi khó hiểu khi mới xem qua lần đầu, nhưng bạn có thể dễ dàng phân tích.

Mục đầu tiên bạn cung cấp là arrayStride. Đây là số byte mà GPU cần để chuyển tiếp trong vùng đệm khi tìm kiếm đỉnh tiếp theo. Mỗi đỉnh của hình vuông được tạo thành từ hai số dấu phẩy động 32 bit. Như đã đề cập trước đó, một 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, đây là một mảng. Thuộc tính là các phần thông tin riêng lẻ được mã hoá vào mỗi đỉnh. Các đỉnh của bạn chỉ chứa một thuộc tính (vị trí đỉnh), nhưng các trường hợp sử dụng nâng cao thường có các đỉnh có nhiều thuộc tính như màu của đỉnh hoặc hướng mà bề mặt hình học đang chỉ. 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, trước tiên, bạn xác định format của dữ liệu. Loại 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. Mỗi đỉnh có hai số thực 32 bit, vì vậy, bạn sử dụng định dạng float32x2. Ví dụ: nếu dữ liệu đỉnh của bạn bao gồm bốn số nguyên 16 bit chưa ký, thì bạn sẽ sử dụng uint16x4. Bạn có thấy quy luật 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 sẽ 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. Thuộc tính này liên kết thuộc tính này với một đầu vào cụ thể trong chương trình đổ bóng đỉnh 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, nhưng bạn chưa thực sự truyền các giá trị đó vào API WebGPU ở bất kỳ đâu. Tính năng này sẽ xuất hiện, nhưng dễ nhất là 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 ngay bây giờ để 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 điều đó 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 khác nhau của dữ liệu: xử lý Đỉnh, xử lý Mảnh hoặc Tính toán chung. Vì nằm trên GPU nên các chương trình này có cấu trúc cứng nhắc hơn so với JavaScript thông thường. Nhưng cấu trúc đó cho phép các hàm này thực thi rất nhanh và quan trọng là song song!

Chương trình đổ bóng trong WebGPU được viết bằng ngôn ngữ đổ bóng có tên là WGSL (Ngôn ngữ đổ bóng WebGPU). Về mặt cú pháp, WGSL có một chút giống với Rust, với các tính năng nhằm giúp các loại GPU phổ biến (như toán vectơ và ma trận) hoạt động dễ dàng và nhanh hơn. Việc dạy toàn bộ ngôn ngữ đổ 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 tìm hiểu một số ví dụ đơn giản.

Bản thân chương trình đổ bóng sẽ đượ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 số mã WGSL hợp lệ, hàm này sẽ trả về một đối tượng GPUShaderModule cùng với kết quả biên dịch.

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

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 được xác định là một hàm và GPU gọi hàm đó một lần cho mỗi đỉnh trong vertexBuffer. Vì vertexBuffer có 6 vị trí (đỉnh) trong đó, nên hà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 với vertexBuffer sẽ được truyền vào hàm dưới dạng đối số và đó là nhiệm vụ của hàm chương trình đổ bóng đỉnh để trả về một vị trí tương ứng trong không gian cắt.

Điều quan trọng là bạn phải hiểu rằng các phương thức này không nhất thiết phải được gọi theo thứ tự tuần tự. Thay vào đó, GPU vượt trội khi chạy các chương trình đổ bóng như thế này song song, 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 rất lớn trong những yếu tố chịu trách nhiệm cho tốc độ đáng kinh ngạc của GPU, nhưng cũng có những hạn chế. Để đảm bảo tính 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 cho hàm chương trình đổ bóng đỉnh theo ý muốn, nhưng hàm này phải có thuộc tính @vertex ở phía trước để cho biết hàm này đạ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 mọi đối số 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 cung cấp dưới dạng vectơ 4 chiều. Vectơ là một thành phần phổ biến trong chương trình đổ bóng, đến nỗi chúng được coi là các thành phần gốc cấp cao nhất trong ngôn ngữ, với các loại 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ị đó bằng thuộc tính @builtin(position). Ký hiệu -> đượ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ề, bạn cần phải 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ố dấu phẩy động, trong giá trị trả về, cho biết vị trí đỉnh nằm trong không gian cắt.

  1. Trả về giá trị tĩnh là (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ù chương trình này không bao giờ hiển thị bất kỳ nội dung nào vì GPU nhận ra rằng các tam giác mà chương trình này tạo ra chỉ là một điểm duy nhất rồi loại bỏ điểm đó.

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 mà bạn đã tạo và bạn thực hiện việc đó bằng cách khai báo đối số cho hàm của mình bằng thuộc tính và loại @location() khớp với nội dung bạn mô tả trong vertexBufferLayout. Bạn đã chỉ định shaderLocation0, vì vậy, 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, đây là một 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ỳ cho biến này, nhưng vì các biến này đại diện cho vị trí đỉnh, nên tên như pos có vẻ phù hợp.

  1. Thay đổi hàm chương trình đổ bóng thành mã sau:

index.html (mã createShaderModule)

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

Và bây giờ, bạn cần trả về 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. Bạn cần 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ề, để lại hai thành phần cuối cùng lần lượt là 01.

  1. Trả về vị trí chính xác bằng cách nêu rõ 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ì những loại ánh xạ này rất phổ biến trong chương trình đổ bóng, nên bạn cũng có thể truyền vectơ vị trí vào dưới dạng đối số đầu tiên theo cách viết tắt thuận tiện và có cùng ý nghĩa.

  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 ban đầu của bạn! Cách 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 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, chương trình đổ bóng mảnh được gọi cho mọi pixel đang được vẽ.

Chương trình đổ bóng mảnh luôn được gọi sau chương trình đổ bóng đỉnh. GPU lấy đầu ra của chương trình đổ bóng đỉnh và tam giác hoá đầu ra đó, tạo tam giác từ các tập hợp ba điểm. Sau đó, chương trình này đưa vào màn hình từng tam giác đó bằng cách xác định những pixel của tệp đính kèm màu đầu ra có trong tam giác đó, sau đó gọi chương trình đổ bóng mảnh một lần cho mỗi pixel đó. Chương trình đổ bóng phân mảnh trả về một màu, thường được tính toán từ các giá trị được gửi đến chương trình này 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 đỉnh, chương trình đổ bóng mảnh được thực thi theo kiểu song song hàng loạt. Các chương trình đổ bóng mảnh linh hoạt hơn một chút so với chương trình đổ bóng đỉnh về đầu vào và đầu ra, nhưng bạn có thể xem xét các chương trình này chỉ trả về một màu cho mỗi pixel của mỗi tam giác.

Hàm chương trình đổ bóng mảnh WGSL được biểu thị bằng thuộc tính @fragment và cũng trả về một vec4f. Tuy nhiên, trong trường hợp này, vectơ biểu thị một màu chứ không phải vị trí. Bạn cần cung cấp thuộc tính @location cho giá trị trả về để cho biết màu được trả về sẽ được ghi vào colorAttachment nào trong lệnh gọi beginRenderPass. 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ư sau:

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 lam và alpha, được diễn giải giống hệ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ẻ như là một màu phù hợp cho hình vuông của bạn. Tuy nhiên, bạn có thể đặt màu bất kỳ mà bạn 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)
}

Và đó là một chương trình đổ bóng mảnh hoàn chỉnh! Đây không phải là một hình ảnh thú vị lắm; nó chỉ đặt mọi pixel 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 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 để kết xuất riêng. Thay vào đó, bạn phải sử dụng nó 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 điều khiển cách vẽ hình học, bao gồm cả những thứ như chương trình đổ bóng được sử dụng, cách diễn giải dữ liệu trong vùng đệm đỉnh, loại hình học cần kết xuất (đường, điểm, tam giác, v.v.) và nhiều thông tin 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 các giá trị bạn có thể truyền vào đề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 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 đều cần một layout mô tả những loại dữ liệu đầ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ó dữ liệu đầu vào nào. Rất may, bạn có thể truyền "auto" vào lúc này và quy trình sẽ tạo 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 với 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 truyền giá trị này vào.

Cuối cùng, bạn có thể xem thông tin chi tiết về giai đoạn fragment. Tệp này cũng bao gồm mô-đunentryPoint của chương trình đổ bóng, giống như giai đoạn đỉnh. Phần cuối cùng là 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) về các tệp đính kèm màu mà quy trình kết xuất ra. Các chi tiết này cần khớp với hoạ tiết được cung cấp 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.

Đó chưa phải là tất cả các tuỳ chọn mà bạn có thể chỉ định khi tạo quy trình kết xuất, nhưng đủ cho nhu cầu của lớp học lập trình này!

Vẽ hình vuông

Vậy là bạn đã có mọi thứ cần thiết để vẽ hình vuông!

  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 này vào giữa chúng:

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

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

Tiếp theo, bạn gọi setVertexBuffer() với vùng đệm chứa các đỉnh cho 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 đó. Bạn chỉ cần truyền vào số đỉnh mà nó sẽ kết xuất, số đỉnh này được lấy từ vùng đệm đỉnh hiện được đặt và diễn giải bằng quy trình hiện được đặt. 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) xem kết quả của tất cả công sức bạn đã bỏ ra: một hình vuông lớn có màu.

Một hình vuông màu đỏ được kết xuất 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! 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 biến (gọi là đồng nhất) đến 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 tính năng tạo bản sao để vẽ nhiều biến thể khác nhau của cùng một hình học.

Xác định lưới

Để hiển thị lưới, bạn cần biết một thông tin rất cơ bản về lưới. Mảng này chứa bao nhiêu ô, cả chiều rộng và chiều cao? Việc 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 một chút, hãy coi lưới là hình vuông (có cùng chiều rộng và chiều cao) và sử dụng kích thước là lũy thừa của 2. (Điều này giúp một số phép toán trở nên dễ dàng hơn sau này.) Cuối cùng, bạn sẽ muốn tăng kích thước lưới lên, nhưng trong phần còn lại của phần này, hãy đặt kích thước lưới thành 4x4 vì điều này giúp bạn dễ dàng minh hoạ một số phép toán được sử dụng trong phần này. Sau đó, hãy mở rộng quy mô!

  • 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 để có thể vừa với GRID_SIZE lần GRID_SIZE trên canvas. Điều đó có nghĩa là hình vuông cần nhỏ hơn nhiều và cần có nhiều hình vuông.

Giờ đây, một cách mà bạn có thể giải quyết vấn đề này là tăng kích thước vùng đệm đỉnh lên đáng kể và xác định GRID_SIZE lần GRID_SIZE hình vuông bên trong vùng đệm đó ở 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 thân thiện 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 mà bạn đã chọn cho chương trình đổ bóng, vì chương trình này sử dụng kích thước đó để thay đổi cách hiển thị. Bạn chỉ cần mã hoá cứng kích thước 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 phải tạo lại chương trình đổ bóng và quy trình kết xuất, điều này sẽ tốn kém. Cách tốt hơn là cung cấp kích thước lưới cho chương trình đổ bóng dưới dạng bộ đồng phục.

Trước đó, bạn đã biết rằng một giá trị khác từ vùng đệm đỉnh được truyền đến mỗi lệnh gọi chương trình đổ bóng đỉnh. Biến đồng nhất là một giá trị 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 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);

Bạn sẽ thấy mã này rất quen thuộc vì nó gần giống với mã bạn đã dùng để tạo vùng đệm đỉnh trước đó! Đó là do các thông số đồng nhất được thông báo đến API WebGPU thông qua cùng một đối tượng GPUBuffer với các đỉnh, đ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 vào các biến đồng nhất trong chương trình đổ bóng

  • Xác định một đồng phục bằng cách thêm 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 xác định một đồng nhất trong chương trình đổ bóng có tên là grid. Đây là một vectơ float 2D khớp với mảng mà bạn vừa sao chép vào vùng đệm đồng nhất. Hàm này cũng chỉ định rằng đồng phục được liên kết tại @group(0)@binding(0). Bạn sẽ tìm hiểu ý nghĩa của các giá trị đó trong giây lát.

Sau đó, ở những nơi khác trong mã chương trình đổ bóng, bạn có thể sử dụng vectơ lưới theo nhu cầu. Trong mã này, bạn chia vị trí đỉnh cho vectơ lưới. Vì pos là vectơ 2D và grid là vectơ 2D, nên WGSL thực hiện phép chia theo thành phần. Nói cách khác, kết quả sẽ giống như khi bạn nói 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 là trong trường hợp của bạn (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. Đó là kích thước hoàn hảo nếu bạn muốn vừa bốn ảnh vào 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 chương trình đó với vùng đệm mà bạn đã tạo. Để làm việc đó, bạn cần tạo và đặt mộ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 cùng một lúc. Nó 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 với 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 thêm trong một bước sau này, nhưng hiện tại, bạn có thể thoải mái 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 từ 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 sẽ 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 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 đã chỉ định. Trong trường hợp này, vùng đệm đồng nhất.

Hàm này trả về một GPUBindGroup, là một handle mờ, 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 đó sẽ được phản ánh bằng các lệnh gọi vẽ trong tương lai bằng cách 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ẽ. May mắn là việc này khá đơn giản.

  1. Quay lại bước 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 đang nói rằng mỗi @binding thuộc @group(0) sử dụng các tài nguyên trong nhóm liên kết này.

Và giờ đây, vùng đệm đồng nhất sẽ hiển thị cho chương trình đổ bóng của bạn!

  1. Làm mới trang rồi bạn sẽ thấy 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! Không nhiều nhưng điều này cho thấy rằng đồng phục 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 của lưới.

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

Giờ đây, bạn 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 khiển hình học mà bạn đang kết xuất cho phù hợp 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 khái niệm, bạn cần chia canvas thành các ô riêng lẻ. Để giữ nguyên quy ước là trục X tăng khi bạn di chuyển sang phải và trục Y tăng khi bạn di chuyển lên trên, giả sử ô đầu tiên nằm ở góc dưới cùng bên trái của canvas. Thao tác này sẽ tạo ra một bố cục như sau, với hình dạng hình vuông hiện tại ở giữa:

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

Thách thức 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 ở 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 ô này được xác định là bao quanh giữa canvas. Bạn nên dịch chuyển hình vuông đi một nửa ô để hình vuông đó nằm ngay ngắn bên trong các ô.

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 để góc dưới cùng bên trái nằm ở (0,1, 0,1) thay vì (-0,8, -0,8), bạn sẽ di chuyển hình vuông này để căn chỉnh với ranh giới ô một cách đẹp mắt 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 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 và sang phải một đơn vị (hãy nhớ rằng đây là một nửa không gian cắt) trước khi chia cho kích thước lưới. Kết quả là một hình vuông được căn chỉnh lưới đẹp mắt ngay bên ngoài gốc.

Hình ảnh trực quan của canvas được chia theo khái niệm thành lưới 4x4 với hình vuông màu đỏ trong ô (2, 2)

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

  1. Dịch vị trí của hình học, 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à giờ đây, hình vuông của bạn đã được định vị chính xác trong ô (0, 0)!

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 ô (0, 0)

Nếu bạn muốn đặt hà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 giá trị đó vào gridPos, thì giá trị này sẽ huỷ - 1 trong thuật toán, vì 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 (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ạn sẽ thấy nội dung sau:

Hình ảnh trực quan của canvas được chia theo khái niệm 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. Không phải là điều bạn muốn.

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

  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à điều này mang lại 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 sơ đồ 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 lưới, sau đó làm mới để xem hình vuông hiển thị ở vị trí mong muốn.

Vẽ các thực thể

Bây giờ, bạn có thể đặt hình vuông ở vị trí mong 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.

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

Thay vào đó, bạn có thể sử dụng một kỹ thuật có tên là tạo bản sao. Tạo bản sao là một cách để yêu cầu GPU vẽ nhiều bản sao của cùng một hình học bằng một lệnh gọi duy nhất đến draw. Cách 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);

Thao tác này cho hệ thống biết rằng bạn muốn hệ thống vẽ 6 (vertices.length / 2) đỉnh 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 nội dung sau:

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 đó ở cùng một vị trí. 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 đến từ vùng đệm đỉnh, bạn cũng có thể truy cập vào những giá trị được gọi là giá trị tích hợp của WGSL. Đây là các giá trị do WebGPU tính toán, trong đó có 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 nó giống nhau cho mọi đỉnh được xử lý thuộc cùng một thực thể. Điều đó có nghĩa là chương trình đổ bóng đỉnh đượ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 đó, lặp lại 6 lần nữa với instance_index1, rồi lặp lại 6 lần nữa với instance_index2, v.v.

Để xem cách hoạt động này, bạn phải thêm instance_index tích hợp sẵn vào dữ liệu đầu vào của chương trình đổ bóng. Làm tương tự như vị trí, nhưng thay vì gắn thẻ bằng thuộc tính @location, hãy sử dụng @builtin(instance_index), sau đó đặt tên cho đối số bất kỳ mà bạn muốn. (Bạn có thể gọi là instance để khớp với mã ví dụ.) Sau đó, hãy sử dụng giá trị này trong 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 cả 16 ứng dụng.

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

Đó là do toạ độ ô mà bạn tạo là (0, 0), (1, 1), (2, 2)... cho đến (15, 15), nhưng chỉ có 4 ô đầu tiên trong số đó vừa với canvas. Để tạo lưới mà bạn muốn, bạn cần chuyển đổi instance_index sao cho mỗi chỉ mục ánh xạ đến một ô duy nhất trong lưới, như sau:

Hình ảnh minh hoạ canvas được chia theo khái niệm 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.

Cách tính toán khá đơn giản. Đối với mỗi giá trị X của ô, bạn muốn có 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ể làm việc đó bằng hàm floor() của WGSL.

  1. Thay đổi các 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 đã có lưới hình vuông mà mình mong đợi từ lâu!

Bốn hàng, mỗi hàng có bốn cột hình vuông màu đỏ trên nền xanh dương đậm.

  1. Bây giờ, khi đã 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! Bạn thực sự có thể làm cho lưới này rất lớn và GPU trung bình của bạn sẽ xử lý tốt lưới này. Bạn sẽ ngừng thấy các hình vuông riêng lẻ từ lâu trước khi gặp phải nút thắt cổ chai về hiệu suất GPU.

6. Điểm cộng: làm cho hình ảnh trở nên sống động hơn!

Tại thời điểm này, bạn có thể dễ dàng chuyển sang phần tiếp theo vì bạn đã đặt nền tảng cho phần còn lại của lớp học lập trình. Tuy lưới các hình vuông đều có cùng một màu sắc có thể sử dụng được, nhưng nó 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 thời điểm này, 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 chóp, sau đó sử dụng dữ liệu đó trong chương trình đổ bóng phân mảnh!

Cách duy nhất để truyền dữ liệu ra khỏi chương trình đổ bóng đỉnh chóp là trả về dữ liệu đó. Bạn luôn phải có chương trình đổ bóng đỉnh để 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 chương trình đổ bóng đỉnh, bạn cần đặt dữ liệu đó vào một cấu trúc. Cấu trúc trong WGSL là các loại đối tượng được đặt tên chứa một hoặc nhiều thuộc tính được đặt 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 đối tượng này bên ngoài mọi hàm, sau đó bạn có thể truyền các thực thể của các đối tượng đó vào và ra khỏi các hàm nếu cần. Ví dụ: hãy xem xét chương trình đổ bóng đỉnh hiện tại:

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ị cùng một nội dung bằng cách sử dụng cấu trúc cho dữ liệu đầ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 việc này yêu cầu bạn tham chiếu đến vị trí đầu vào và chỉ mục thực thể bằng input, đồng thời cấu trúc mà bạn trả về trước tiên cần được khai báo dưới dạng biến và đặt các thuộc tính riêng lẻ. Trong trường hợp này, việc này không tạo ra quá nhiều khác biệt và trên thực tế, làm cho hàm chương trình đổ bóng dài hơn một chút. Tuy nhiên, khi 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 tuyệt vời để giúp sắp xếp dữ liệu.

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

Xin lưu ý rằng hàm @fragment của bạn phải đơn giản nhất có thể:

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 (màu đỏ) làm dữ liệu đầu ra của bạn. Tuy nhiên, nếu chương trình đổ bóng biết thêm về hình học mà nó đang tô màu, thì 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 một chút. Ví dụ: nếu bạn muốn thay đổi màu của mỗi hình vuông dựa trên toạ độ ô của hình vuông đó thì sao? Giai đoạn @vertex biết ô nào đang được kết xuất; bạn chỉ cần truyền ô đó đến giai đoạn @fragment.

Để truyền dữ liệu giữa các giai đoạn đỉnh và mảnh, bạn cần đưa dữ liệu đó vào một cấu trúc đầu ra có @location mà chúng tôi 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 giải pháp thay thế 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 bạn dễ dàng truyền giá trị vì tên và vị trí 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ố ô đó để thay đổi màu sắc. Với bất kỳ mã nào ở trên, kết quả sẽ có dạng như sau:

Một lưới 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.

Chắc chắn là có nhiều màu hơn, nhưng giao diện vẫn chưa đẹ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 dự kiến mỗi kênh sẽ nằm trong phạm vi từ 0 đến 1 và mọi giá trị nằm ngoài phạm vi đó sẽ được cố định ở phạm vi đó. 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, bạn sẽ thấy hàng và cột đầu tiên ngay lập tức đạt giá trị 1 đầy đủ trên kênh màu đỏ hoặc xanh lục và mọi ô sau đó đều được cố định ở cùng một giá trị.

Nếu muốn chuyển đổi mượt mà hơn giữa các màu, bạn cần trả về một giá trị phân đoạn cho mỗi kênh màu, lý tưởng nhất là bắt đầu từ 0 và kết thúc ở 1 dọc theo mỗi trục, tức 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 tạo hiệu ứng chuyển màu đẹp hơn nhiều trên toàn bộ lưới.

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

Mặc dù đây chắc chắn là một điểm 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 mô phỏng Game of Life, một phần lưới khó nhìn sẽ che khuất những gì đang diễn ra. Sẽ rất tuyệt nếu bạn có thể làm sáng hơn.

May mắn thay, bạn có một kênh màu chưa sử dụng (màu xanh dương) mà bạn có thể dùng. Hiệu ứng mà bạn lý tưởng muốn tạo 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, sau đó mờ dần khi các màu khác tăng cường độ. Cách dễ nhất để thực hiện việc đó là đặt kênh start ở giá trị 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ả trông khá ổn!

Một lưới hình vuông chuyển đổi từ màu đỏ sang màu xanh lục, sang màu xanh dương rồi 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! Nhưng vì giao diện này trông đẹp hơn, nên chúng tôi đã đưa giao diện này vào tệp nguồn điểm kiểm tra tương ứng và 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 này.

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 hiển thị, dựa trên một số trạng thái được lưu trữ trên GPU. Điều này rất quan trọng đối với quá trình mô phỏng cuối cùng!

Tất cả những gì bạn cần là một tín hiệu bật/tắt cho mỗi ô, vì vậy, mọi tuỳ chọn cho phép bạn lưu trữ một mảng lớn gồm gần như mọi loại giá trị đều hoạt động. Bạn có thể nghĩ 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 sẽ khó khăn hơn vì bộ đệm đồng nhất có kích thước hạn chế, 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ể được chương trình đổ bóng điện toán ghi vào. 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.

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

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

Vùng đệm bộ nhớ 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, cũng như đọc trong chương trình đổ bóng đỉnh chóp. Chúng có thể rất lớn và không cần kích thước được khai báo cụ thể trong chương trình đổ bóng, điều này khiến chúng giống như bộ nhớ chung. Đó là những gì bạn sử dụng để lưu trữ trạng thái của ô.

  1. Để tạo bộ đệm lưu trữ cho trạng thái ô, hãy sử dụng đoạn mã tạo bộ đệm có vẻ 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,
});

Giống 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 lần này.

Bạn có thể điền bộ đệm theo cách tương tự như trước 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 xem hiệu ứng của vùng đệm trên lưới, hãy bắt đầu bằng cách điền vào vùng đệm một nội dung có thể dự đoán được.

  1. Kích hoạt mỗi ô 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 bộ đệm bộ nhớ 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 giống với grid đồng nhất, nhưng số @binding cần khác. Loại varstorage, để phản ánh các loại vùng đệm khác nhau và thay vì một vectơ duy nhất, 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 trong vùng đệm lưu trữ, nên bạn có thể sử dụng instance_index để tra cứu giá trị cho ô 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? Vì các trạng thái đang hoạt động và không hoạt động mà bạn nhận được từ mảng là 1 hoặc 0, nên bạn có thể điều chỉnh tỷ lệ hình học theo trạng thái đang 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. Cập nhật mã chương trình đổ bóng để điều chỉnh tỷ lệ vị trí theo trạng thái đang hoạt động của ô. Bạn phải truyền giá trị trạng thái vào f32 để đáp ứng các yêu cầu về an toàn 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 bộ nhớ 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 cùng một @group với vùng đệm đồng nhất, nên hãy thêm vùng đệm này vào cùng một nhóm liên kết trong mã JavaScript.

  • Thêm bộ nhớ đệ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 }
  }],
});

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

Khi đó, bạn có thể làm mới và thấy mẫu xuất hiện trong lưới.

Các sọc chéo của các hình vuông nhiều màu đi từ dưới cùng bên trái đến 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 ping-pong

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

Vì sao việc này lại cần thiết? Hãy xem một ví dụ đơn giản: giả sử bạn đang viết một chương trình mô phỏng rất đơn giản, trong đó bạn di chuyển bất kỳ khối nào đang hoạt động sang phải một ô ở mỗi bước. Để dễ hiểu, bạn hãy xác định dữ liệu và mô phỏng 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ã đó, ô đang hoạt động sẽ di chuyển đến cuối mảng chỉ trong một bước! Tại sao? Vì bạn liên tục cập nhật trạng thái tại chỗ, nên bạn di chuyển ô đang hoạt động sang phải, sau đó nhìn vào ô tiếp theo và... ồ! Ứng dụng đang hoạt động! 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 thời điểm quan sát sẽ làm hỏng kết quả.

Bằng cách sử dụng mẫu ping pong, bạn đảm bảo rằng bạn 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ổ bộ đệm bộ nhớ để tạo hai bộ đệ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 bạn hình dung sự khác biệt giữa hai vùng đệm, hãy điền dữ liệu khác nhau vào các vùng đệm đó:

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 bộ nhớ 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 việc đó, 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 vô tận, vẽ nội dung của bạn lên 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 mà sử dụng hàm requestAnimationFrame() để lên lịch gọi lại với tốc độ tương tự như 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, bạn có thể muốn các bản cập nhật diễn ra theo các bước dài hơn để có thể dễ dàng theo dõi hoạt động 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, hãy chọn tốc độ cập nhật cho quá trình mô phỏng của chúng ta (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 hiệ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(). Đảm bảo rằng hàm này cũng cập nhật số bước và sử dụng số bước đó để chọn một 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);

Và 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ị hai vùng đệm trạng thái mà bạn đã tạo.

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

Như vậy, bạn đã hoàn tất phần kết xuất! Bạn đã sẵn sàng hiển thị kết quả của quá trình mô phỏng Game of Life mà bạn tạo trong bước tiếp theo, nơi bạn cuối cùng sẽ bắt đầu sử dụng chương trình đổ bóng điện toán.

Rõ ràng là WebGPU có nhiều khả năng kết xuất hơn 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. Tuy nhiên, hy vọng rằng bạn đã hiểu rõ cách hoạt động của tính năng kết xuất của WebGPU, giúp bạn dễ dàng nắm bắt các kỹ thuật nâng cao hơn như kết xuất 3D.

8. Chạy mô phỏng

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

Cuối cùng thì bạn cũng có thể sử dụng chương trình đổ bóng điện toán!

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

Chương trình đổ bóng điện toán tương tự như chương trình đổ bóng đỉnh và mảnh ở chỗ chúng được thiết kế để chạy với tính song song cực cao trên GPU, nhưng không giống như hai giai đoạn chương trình đổ bóng khác, chúng không có một tập hợp đầu vào và đầu ra cụ thể. Bạn đang đọc và ghi dữ liệu chỉ từ các nguồn mà bạn chọn, chẳng hạn như vùng đệm bộ nhớ. Điều này có nghĩa 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 biết số lần gọi hàm chương trình đổ 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 về lệnh gọi nào đang được xử lý và bạn có thể quyết định dữ liệu nào 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, giống như chương trình đổ bóng đỉnh và mảnh, vì vậy, hãy thêm mô-đun đó vào mã của bạn để 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 thường được dùng cho đồ hoạ 3D, nên chương trình đổ bóng điện toán được cấu trúc sao cho bạn có thể yêu cầu gọi chương trình đổ bóng một số lần cụ thể dọc theo 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 tuân theo lưới 2D hoặc 3D, rất phù hợp với 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 cấu trúc phần cứng GPU, lưới này được chia thành nhóm công việc. Một nhóm công việc có kích thước X, Y và Z, mặc dù mỗi kích thước có thể là 1, nhưng việc tăng kích thước nhóm công việc thường mang lại lợi ích về hiệu suất. Đối với chương trình đổ bóng, hãy chọn kích thước nhóm tác vụ tuỳ ý là 8 lần 8. Điều này rất hữu ích để theo dõi trong mã JavaScript.

  1. Xác định hằng số cho kích thước nhóm công việc, như sau:

index.html

const WORKGROUP_SIZE = 8;

Bạn cũng cần thêm kích thước nhóm công việc vào chính hàm chương trình đổ bóng. Bạn thực hiện việc này bằng cách sử dụng các giá trị cố định mẫu của JavaScript để có thể dễ dàng sử dụng hằng số mà bạn 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 (Lệnh gọi createShaderModule tính toán)

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

}

Điều này 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). (Mọi trục mà bạn bỏ qua đều mặc định là 1, mặc dù bạn ít nhất phải chỉ định trục X.)

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

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

index.html (Lệnh gọi createShaderModule tính toán)

@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 sẵn, đây là một vectơ ba chiều gồm các số nguyên chưa ký cho bạn biết vị trí của bạn trong lưới các 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 sẽ nhận được các số như (0, 0, 0), (1, 0, 0), (1, 1, 0)... cho đến (31, 31, 0), nghĩa là bạn có thể coi đó 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 các biến đồ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 một đồ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 (Lệnh gọi createShaderModule tính toán)

@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ó đầ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 giá trị vào vùng đệm lưu trữ hoặc hoạ tiết là cách duy nhất để lấy kết quả từ chương trình đổ bóng điện toán. Sử dụng phương thức ping-pong mà bạn đã tìm hiểu trước đó; bạn có một vùng đệm bộ nhớ cung cấp trạng thái hiện tại của lưới và một vùng đệm bộ nhớ mà bạn ghi trạng thái mới của lưới vào.

  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 (Lệnh gọi createShaderModule tính toán)

@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 bộ nhớ đầu tiên được khai báo bằng var<storage>, khiến vùng đệm này chỉ có thể đọc, nhưng vùng đệm bộ nhớ 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 đầu ra cho chương trình đổ bóng điện toán. (Không có chế độ bộ nhớ chỉ có thể ghi trong WebGPU).

Tiếp theo, bạn cần có cách để liên kết chỉ mục ô vào mảng bộ nhớ tuyến tính. Về cơ bản, điều này trái ngược 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ó đến 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 với chiều rộng lưới, sau đó cộng với giá trị X của ô.

index.html (Lệnh gọi createShaderModule tính toán)

@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à Game of Life, 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 (Lệnh gọi createShaderModule tính toán)

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

Tạm thời, đó là tất cả những gì bạn cần biết về chương trình đổ bóng điện toán! Tuy nhiên, trước khi có thể xem kết quả, bạn cần thực hiện thêm một số thay đổi.

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

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ác dữ liệu đầu vào giống như quy trình kết xuất (bộ đồng nhất và vùng đệm lưu trữ). Vì vậy, bạn có thể nghĩ rằng bạn chỉ cần sử dụng cùng một nhóm liên kết và hoàn tất việc này, phải không? Tin vui là bạn có thể làm được điều đó! 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ất cứ khi nào tạo một nhóm liên kết, bạn cần cung cấp GPUBindGroupLayout. 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 đó hoạt động tốt khi bạn chỉ sử dụng một quy trình, nhưng nếu 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ì hai 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à đầu tiên, nê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à 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 mà một quy trình cụ thể sử dụng.

  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 chính 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 nhập, bạn cung cấp số binding cho tài nguyên. Tài nguyên này (như bạn đã 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ác cờ GPUShaderStage cho biết giai đoạn chương trình đổ bóng nào có thể sử dụng tài nguyên. Bạn muốn có thể truy cập vào cả vùng đệm lưu trữ đồng nhất và vùng đệm lưu trữ đầu tiên trong chương trình đổ bóng đỉnh và chương trình đổ bóng điện toán, nhưng chỉ cần truy cập được 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 nội dung bạn cần hiển thị. Ở đây, cả ba tài nguyên đều là vùng đệm, vì vậy, bạn sử dụng khoá buffer để xác định các tuỳ chọn cho từng tài nguyên. Các tuỳ chọn khác bao gồm các giá trị như texture hoặc sampler, nhưng bạn không cần những giá trị đó ở đây.

Trong từ điển vùng đệm, bạn đặt các tuỳ chọn như type của vùng đệm đượ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. (Tuy nhiên, bạn phải đặt ít nhất là buffer: {} để mục nhập được xác định là vùng đệm.) Liên kết 1 được cấp kiểu "read-only-storage" vì bạn không sử dụng liên kết này với quyền truy cập read_write trong chương trình đổ bóng, còn liên kết 2 có kiểu "storage" vì bạn sử dụng liên kết này với quyền truy cập read_write!

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

  1. Cập nhật việc 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] }
    }],
  }),
];

Giờ đây, khi 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 bố cục.

  1. Tạo 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, 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 bố cục đó 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 quy trình điện toán bằng 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 vào pipelineLayout mới thay vì "auto", giống như trong quy trình kết xuất đã cập nhật, đả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 một nhóm liên kết.

Thẻ tính toán

Như vậy, bạn sẽ thực sự tận dụng được quy trình điện toán! Vì bạn kết xuất trong một lượt kết xuất, nên có thể đoán rằng bạn cần thực hiện công việc tính toán trong một lượt tính toán. 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 một chút hàm updateGrid.

  1. Di chuyển quá trình tạo bộ mã hoá lên đầu hàm, sau đó bắt đầu một lượt tính toán với bộ mã hoá đó (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...

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

Bạn nên thực hiện lượt kết xuất trước lượt kết xuất vì lượt kết xuất cho phép lượt kết xuất sử dụng ngay kết quả mới nhất từ lượt kết xuất. Đó cũng là lý do bạn tăng số lần step giữa các lượt truyền để vùng đệm đầu ra của quy trình điện toán 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 sẽ điều phối công việc đến chương trình đổ bóng điện toán, cho chương trình 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à con số bạn truyền vào dispatchWorkgroups() không phải là số lần gọi! Thay vào đó, đây là số lượng nhóm công việc cần thực thi, do @workgroup_size xác định trong chương trình đổ bóng.

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

Bây giờ, bạn có thể làm mới trang một lần nữa và sẽ thấy lưới tự đảo ngược theo từng lần cập nhật.

Các sọc chéo của các hình vuông nhiều màu đi từ dưới cùng bên trái đến trên cùng bên phải trên nền màu xanh dương đậm. Các sọc chéo gồm các hình vuông nhiều màu có chiều rộng 2 hình vuông, chạy từ dưới cùng bên trái đến 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 của 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 cần quay lại mã đang khởi chạy nội dung vùng đệm bộ nhớ và cập nhật mã đó để tạo vùng đệm ngẫu nhiên trên mỗi lần tải trang. (Các mẫu thông thường không tạo ra điểm xuất phát thú vị cho Trò chơi cuộc sống.) Bạn có thể tạo giá trị ngẫu nhiên theo ý muốn, nhưng có một cách dễ dàng để bắt đầu và mang lại 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 chạy cellStateArray thành 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);

Cuối cùng, bạn có thể triển khai logic cho quá trình mô phỏng Trò chơi của 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 đối với một ô bất kỳ, có bao nhiêu ô lân cận đang hoạt động. Bạn không quan tâm đến việc cái nào đang hoạt động, chỉ quan tâm đến số lượng.

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

index.html (Lệnh gọi createShaderModule tính toán)

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 tất 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 (Lệnh gọi createShaderModule tính toán)

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

Tuy nhiên, đ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 của bảng? Theo logic cellIndex() hiện tại, dữ liệu 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 của cuộc sống, một cách phổ biến và dễ dàng để giải quyết vấn đề này là để các ô ở cạnh lưới coi 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 quanh.

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

index.html (Lệnh gọi createShaderModule tính toán)

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 4 quy tắc sau:

  • 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ó hai hoặc ba ô lân cận vẫn hoạt động.
  • Mọi ô không hoạt động có đúng 3 ô bên cạnh sẽ trở thành ô hoạt động.
  • Mọi ô có nhiều hơn 3 ô lân cận sẽ không hoạt động.

Bạn có thể thực hiện việc này bằng một loạt các 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 của cuộc sống như sau:

index.html (Lệnh gọi createShaderModule tính toán)

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 cuối cùng hiện 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;
        }
      }
    }
  `
});

Và... chỉ có vậy thôi! Bạn đã hoàn tất! Làm mới trang và xem automaton tế bào mới tạo 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