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

1. Giới thiệu

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

WebGPU là gì?

WebGPU là một API mới, hiện đại để truy cập vào các 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, API này dựa trên API OpenGL ES 2.0 được phát hành vào năm 2007, dựa trên API OpenGL cũ hơn. GPU đã phát triển đáng kể trong thời gian đó và các API gốc dùng để giao tiếp với GPU cũng đã phát triển cùng với Direct3D 12, MetalVulkan.

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

Kết xuất

GPU thường liên quan đến khả năng kết xuất đồ hoạ nhanh, chi tiết và WebGPU cũng không phải là ngoại lệ. API này có các tính năng cần thiết để hỗ trợ nhiều kỹ thuật kết xuất phổ biến nhất hiện nay trên cả GPU 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.

Điện toán

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

Trong lớp học lập trình hôm nay, bạn sẽ tìm hiểu cách tận dụng cả khả năng kết xuất và 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ẽ sử dụng WebGPU để xây dựng Trò chơi cuộc sống của Conway. Ứng dụng này sẽ:

  • Sử dụng khả năng kết xuất của WebGPU để vẽ đồ hoạ 2D đơn giản.
  • Sử dụng các 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 của cuộc sống được gọi là một automaton tế bào, trong đó lưới tế bào 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 dạng đỉnh và mảnh để sửa đổi nội dung đang được vẽ.
  • Cách sử dụng chương trình đổ bóng điện toán để thực hiện một mô phỏng đơn giản.

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

Bạn cần có

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

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

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

Lấy mã

Lớp học lập trình này không có phần phụ thuộc nào và sẽ hướng dẫn bạn từng bước cần thiết để tạo ứng dụng WebGPU, vì vậy, bạn không cần lập trình để bắt đầu. Tuy nhiên, 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 Play Console!

WebGPU là một API khá phức tạp với nhiều quy tắc thực thi việc sử dụng đúng cách. Tệ hơn nữa, do cách 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ị nội dung nào trên màn hình nếu chỉ muốn dùng WebGPU để tính toán. Nhưng nếu muốn kết xuất bất kỳ nội dung nào, như chúng ta sẽ làm trong lớp học lập trình này, bạn cần có canvas. Đó là một điểm bắt đầu tốt!

Tạo một tài liệu HTML mới 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ề các bit 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 WebGPU không khả dụng bằng cách đặt trang quay lại chế độ không sử dụng WebGPU. (Có lẽ công cụ này dùng WebGL để thay thế?) Tuy nhiên, trong phạm vi của lớp học lập trình này, bạn chỉ cần tạo một lỗi để ngăn mã thực thi thêm.

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

  1. Để tải một bộ chuyển đổi, hãy sử dụng phương thức navigator.gpu.requestAdapter(). Phương thức này trả về một lời hứa, 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 bộ chuyển đổi thích hợp, giá trị adapter được trả về có thể là null, vì vậy, bạn nê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 diễn ra hầu hết các hoạt động tương tác với GPU.

  1. Lấy thiết bị bằng cách gọi adapter.requestDevice(). Phương thứ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, bạn đã có một thiết bị, bạn cần làm thêm một việc nữa nếu muốn sử dụng thiết bị đó để hiển thị nội dung trên trang: định cấu hình canvas để sử dụng với thiết bị bạn vừa tạo.

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

index.html

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

Có một vài tuỳ chọn có thể được truyền tại đây, nhưng quan trọng nhất là device mà bạn sẽ sử dụng cùng với ngữ cảnh và format, đây là định dạng kết cấu mà ngữ cảnh sẽ 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 hoạ tiết khác nhau và nếu bạn không sử dụng định dạng ưu tiên của thiết bị, điều đó có thể khiến các bản sao bộ nhớ thừa diễn ra trong nền trước khi hình ảnh có thể hiển thị như một phần của trang.

May mắn là bạn không 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ư trình bày ở trên.

Xoá Canvas

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

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

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

index.html

const encoder = device.createCommandEncoder();

Các lệnh bạn muốn gửi đến GPU có liên quan đến 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 lớp 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 đầu ra của mọi lệnh vẽ được thực hiện. Cách sử dụng nâng cao hơn có thể cung cấp một số hoạ tiết, được gọi là tệp đính kèm, với nhiều mục đích như lưu trữ độ sâu của hình học được kết xuất hoặc cung cấp tính năng khử răng cưa. Tuy nhiên, đối với ứng dụng này, bạn chỉ cần một.

  1. Lấy hoạ tiết từ ngữ cảnh canvas mà bạn đã tạo trước đó bằng cách gọi context.getCurrentTexture(). Thao tác này sẽ trả về hoạ tiết có chiều rộng và chiều cao pixel khớp với 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",
  }]
});

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

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

  • Giá trị loadOp "clear" cho biết bạn muốn xoá hoạ tiết khi lượt kết xuất bắt đầu.
  • Giá trị storeOp của "store" cho biết rằng sau khi hoàn tất lượt kết xuất, bạn sẽ muốn kết quả của mọi bản vẽ đượ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à 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 đây ngay sau beginRenderPass():

index.html

pass.end();

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

  1. Để tạo GPUCommandBuffer, hãy gọi finish() trên bộ mã hoá lệnh. Vùng đệm lệnh là một tay cầm mờ cho các lệnh đã ghi.

index.html

const commandBuffer = encoder.finish();
  1. Gửi vùng đệm lệnh đến GPU bằng queue của GPUDevice. Hàng đợi thực hiện tất cả các lệnh GPU, đảm bảo rằng việc thực thi các lệnh đó được sắp xếp hợp lý và đồng bộ hoá đúng cách. Phương thức submit() của hàng đợi sẽ nhận 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 đó, vì vậy, không cần giữ lại vùng đệm đó. 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ư đã 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 các lệnh đến GPU, hãy cho phép JavaScript trả lại quyền kiểm soát cho trình duyệt. Khi đó, trình duyệt thấy rằng bạn đã thay đổi hoạ tiết hiện tại của ngữ cảnh và cập nhật canvas để hiển thị hoạ tiết đó dưới dạng hình ảnh. Nếu 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 ý canvas sẽ có màu đen. Xin chúc mừng! Điều đó có nghĩa là bạn đã tạo thành công ứng dụng WebGPU đầu tiên.

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

Chọn màu!

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

  • { r: 1, g: 0, b: 0, a: 1 } có màu đỏ tươi.
  • { r: 1, g: 0, b: 1, a: 1 } có màu tím sáng.
  • { r: 0, g: 0.3, b: 0, a: 1 } có màu xanh lục đậm.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } 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 mình đã chọn trong canvas.

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

4. Vẽ hình học

Ở cuối phần này, ứng dụng của bạn sẽ vẽ một số hình học đơn giản vào canvas: một hình vuông được tô màu. 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ả. Tác dụng phụ của tính hiệu quả này là thực hiện những việc tương đối đơn giản có thể cảm thấy khó khăn một cách bất thường, nhưng đó là điều bình thường nếu bạn chuyển sang sử dụng API như WebGPU — bạn muốn làm điều gì đó phức tạp hơn một chút.

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

Trước khi có thêm bất cứ thay đổi nào về mã, bạn nên thực hiện một bài tổng quan rất nhanh, đơn giản và ở cấp cao về cách GPU tạo ra hình dạng mà bạn thấy trên màn hình. (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 chỉ hoạt động với tam giác vì tam giác có nhiều tính chất toán học thú vị giúp dễ dàng xử lý theo cách dễ dự đoán và hiệu quả. Hầu hết mọi thứ bạn vẽ bằng GPU cần được chia thành các tam giác trước khi GPU có thể vẽ và các tam giác đó phải được xác định bằng các điểm góc.

Các điểm này (hay đỉnh) được cung cấp theo các giá trị X, Y và (đối với nội dung 3D) Z 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. Cấu trúc của hệ thống toạ độ dễ hình dung nhất xét theo mối quan hệ giữa chúng với canvas trên trang của bạn. Bất kể canvas của bạn rộng hay cao bao nhiêu, cạnh trái luôn ở -1 trên trục X và cạnh phải luôn ở +1 trên trục X. Tương tự, cạnh dưới luôn là -1 trên trục Y và cạnh trên cùng là +1 trên trục Y. Điều đó có nghĩa là (0, 0) luôn là tâm của canvas, (-1, -1) luôn là góc dưới cùng bên trái và (1, 1) luôn là góc trên cùng bên phải. Đây được gọi là Không gian 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ỏ gọi 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 do bạn (nhà phát triển WebGPU) viết và chúng cung cấp khả năng kiểm soát đáng kinh ngạc đối với cách hoạt động của GPU.

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

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

Xác định đỉnh

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

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

Nhưng có một vấn đề! GPU hoạt động theo tam giác, bạn còn nhớ không? Điều đó có nghĩa là bạn phải cung cấp các đỉnh theo nhóm ba. Bạn có một nhóm 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 sử dụng 4 đỉnh của hình vuông để tạo thành 2 hình tam giác.

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

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

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8, // Triangle 1 (Blue)
   0.8, -0.8,
   0.8,  0.8,

  -0.8, -0.8, // Triangle 2 (Red)
   0.8,  0.8,
  -0.8,  0.8,
]);

Mặc dù biểu đồ cho thấy sự phân tách giữa hai tam giác để rõ ràng, nhưng vị trí đỉnh là 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. Nó sẽ hiển thị dưới dạng một hình vuông đồng nhất.

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

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

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

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

index.html

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

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

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

Cuối cùng, bạn cần chỉ định cách sử dụng vùng đệm. Đây là một hoặc nhiều cờ GPUBufferUsage, trong đó nhiều cờ được kết hợp với toán tử | (bit 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 không rõ ràng. Bạn không thể (dễ dàng) kiểm tra dữ liệu mà đối tượng này giữ. Ngoài ra, hầu hết thuộc tính của thành phần này đều không thể thay đổi – bạn không thể đổi kích thước GPUBuffer sau khi tạo cũng như không thể thay đổi cờ sử dụng. Bạn có thể thay đổi nội dung của bộ nhớ.

Khi vùng đệm được tạo ban đầu, bộ nhớ trong vùng đệm sẽ được khởi tạo về 0. Có một số cách để thay đổi nội dung của 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 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 muốn vẽ bất kỳ nội dung nào bằng công cụ này. Bạn cần cho WebGPU biết thêm về cấu trúc của dữ liệu đỉnh (vertex).

index.html

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

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

Mục đầu tiên bạn cung cấp là arrayStride. Đây là số byte mà GPU cần bỏ qua để tiến trong vùng đệm khi tìm đỉnh tiếp theo. Mỗi đỉnh của hình vuông được tạo thành từ hai số có dấu phẩy động 32 bit. Như đã đề cập trước đó, một số thực 32 bit là 4 byte, do đó, hai số 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 thông tin riêng lẻ được mã hoá thành mỗi đỉnh. Các đỉnh của bạn chỉ chứa một thuộc tính (vị trí đỉnh), nhưng 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. Mã này được lấy từ danh sách các loại GPUVertexFormat mô tả từng loại dữ liệu đỉnh mà GPU có thể hiểu được. Các đỉnh của bạn có 2 số thực có độ chính xác đơn 32 bit, nên bạn sẽ sử dụng định dạng float32x2. Ví dụ: nếu dữ liệu đỉnh của bạn được tạo thành từ bốn số nguyên 16 bit chưa ký, thì bạn nên sử dụng uint16x4. Bạn có thấy hoa văn không?

Tiếp theo, offset mô tả số byte vào đỉnh mà thuộc tính cụ thể này bắt đầu. Bạn chỉ cần lo lắng về vấn đề này nếu vùng đệm của bạn có nhiều thuộc tính, điều này sẽ không xảy ra 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. Phương thức này liên kết thuộc tính này với một dữ liệu đầu vào cụ thể trong chương trình đổ bóng đỉnh (vertex) mà bạn sẽ tìm hiểu trong phần tiếp theo.

Lưu ý rằng mặc dù bạn đã xác định các giá trị này, nhưng bạn chưa thực sự truyền các giá trị đó vào API WebGPU ở bất kỳ đâu. Chúng ta sẽ tìm hiểu về điều này sau, nhưng cách dễ nhất để nghĩ về các giá trị này là tại thời điểm bạn xác định các đỉnh, vì vậy, bạn sẽ thiết lập các giá trị này ngay bây giờ để sử dụng sau.

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

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

Chương trình đổ bóng là các chương trình nhỏ mà bạn viết và thực thi trên GPU. Mỗi chương trình đổ bóng hoạt động trên một giai đoạn 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 chúng có cấu trúc cứng hơn so với JavaScript thông thường. Nhưng cấu trúc đó cho phép họ thực thi rất nhanh và cực kỳ quan trọng là phải thực thi 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 giảng dạy toàn bộ ngôn ngữ tô bóng nằm ngoài phạm vi của lớp học lập trình này, nhưng hy vọng bạn sẽ nắm được một số kiến thức cơ bản khi xem qua một số ví dụ đơn giản.

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

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

index.html

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

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

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

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

Chương trình đổ bóng đỉnh đượ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 từ vertexBuffer sẽ được truyền vào hàm làm đối số. Đồng thời, nhiệm vụ của hàm đổ bóng đỉnh (vertex) là trả về một vị trí tương ứng trong không gian cắt (clip).

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

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

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ối thiểu 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, vì vậy, 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. Ngoài ra, còn có các loại tương tự cho vectơ 2D (vec2f) và vectơ 3D (vec3f)!

  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). Biểu tượng -> được dùng để cho biết đây là giá trị mà hàm trả về.

index.html (mã createShaderModule)

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

}

Tất nhiên, nếu hàm có kiểu dữ liệu trả về, 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 đã tạo và thực hiện việc đó bằng cách khai báo đối số cho hàm bằng thuộc tính @location() và kiểu khớp với nội dung bạn mô tả trong vertexBufferLayout. Bạn đã chỉ định shaderLocation0, do đó, trong mã WGSL, hãy đánh dấu đối số bằng @location(0). Bạn cũng đã xác định định dạng là float32x2, đâ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 tuỳ thích, nhưng vì các vị trí này thể hiện vị trí đỉnh của bạn, nên một cái tên như pos có vẻ tự nhiên.

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

  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 (vertex) ban đầu! 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, các chương trình này được gọi cho mọi pixel được vẽ.

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

Giống 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. Phương thức này linh hoạt hơn một chút so với chương trình đổ bóng đỉnh về dữ liệu đầu vào và đầu ra, nhưng bạn có thể coi chúng chỉ đơn giản là trả về một màu cho mỗi pixel của mỗi tam giác.

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, không phải một vị trí. Giá trị trả về cần được cung cấp thuộc tính @location để cho biết colorAttachment nào qua lệnh gọi beginRenderPass mà màu trả về được ghi vào. Vì bạn chỉ có một tệp đính kèm nên vị trí là 0.

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

index.html (mã createShaderModule)

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

}

Bốn thành phần của vectơ được trả về là các giá trị màu đỏ, xanh lục, xanh 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ẻ là một màu hợp lý 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)
}

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

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

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

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

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

Bạn không thể sử dụng mô-đun chương trình đổ bóng để tự kết xuất. Thay vào đó, bạn phải sử dụng 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ông tin như chương trình đổ bóng nào được sử dụng, cách diễn giải dữ liệu trong vùng đệm đỉnh, loại hình học nào sẽ được 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 giá trị bạn có thể chuyển vào lớp này đều không bắt buộc và bạn chỉ cần cung cấp một vài giá trị để bắt đầu.

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

index.html

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

Mỗi quy trình đề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à 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 là bạn đã xác định điều này trước đó trong vertexBufferLayout! Đâ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. Lớp này cũng bao gồm một mô-đun chương trình đổ bóng và entryPoint, chẳng hạn như giai đoạn đỉnh (vertex). 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 phải khớp với hoạ tiết đã 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() để chỉ ra quy trình nào sẽ được dùng để 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 hàm này 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ạ sau khi thực hiện tất cả các bước thiết lập trước đó. Bạn chỉ cần truyền vào số lượng đỉnh mà nó sẽ kết xuất, số lượng 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) là xem thành quả của tất cả nỗ lực của bạn: một hình vuông lớn, nhiều màu sắc.

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

5. Vẽ lưới

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

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

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

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

index.html

const GRID_SIZE = 4;

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

Giờ đây, 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 này 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 ứng. Phần này xem xét một phương pháp phù hợp hơn với GPU.

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

Trước tiên, bạn cần thông báo kích thước lưới đã chọn cho chương trình đổ bóng, vì chương trình sẽ sử dụng kích thước này để thay đổi cách hiển thị của nội dung. Bạn 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. Một cách hay hơn là cung cấp kích thước lưới cho chương trình đổ bóng dưới dạng đồng nhất.

Trước đó, bạn đã tìm hiểu rằng một giá trị khác từ vùng đệm đỉnh được truyền đến mọi lệnh gọi của 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 là tính đồng nhất được truyền đến API WebGPU thông qua cùng đối tượng GPUBuffer như đỉnh, với điểm khác biệt chính là usage lần này bao gồm GPUBufferUsage.UNIFORM thay vì GPUBufferUsage.VERTEX.

Truy cập vào các biến đồng nhất trong chương trình đổ bóng

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

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

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

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

// ...fragmentMain is unchanged 

Thao tác này 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ư việc cho biết vec2f(pos.x / grid.x, pos.y / grid.y).

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

Điều này có nghĩa trong trường hợp của bạn là (nếu bạn sử dụng kích thước lưới là 4), hình vuông mà bạn kết xuất sẽ bằng một phần tư kích thước ban đầu. Đó 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 thuộc tí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. Dữ liệu này có thể bao gồm một số loại vùng đệm, chẳng hạn như vùng đệm đồng nhất và các tài nguyên khác như hoạ tiết và bộ lấy mẫu không được đề cập ở đây nhưng là các phần phổ biến của kỹ thuật kết xuất WebGPU.

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

index.html

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

Ngoài label tiêu chuẩn hiện có, bạn cũng cần có layout mô tả loại tài nguyên có trong nhóm liên kết này. Đây là điều mà bạn sẽ tìm hiểu sâu hơn trong tương lai, nhưng hiện tại, bạn có thể vui lòng yêu cầu quy trình của mình về bố cục nhóm liên kết vì bạn đã tạo quy trình bằng layout: "auto". Điều đó khiến quy trình tự động tạo bố cục nhóm liên kết 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 nhập là một từ điển có ít nhất các giá trị sau:

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

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 các 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 các 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, điều này sẽ được phản ánh bằng các lệnh gọi vẽ trong tương lai sử dụng nhóm liên kết này.

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

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

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

index.html

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

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

pass.draw(vertices.length / 2);

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

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

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

Theo lý thuyết, bạn cần chia canvas của mình thành từng ô riêng lẻ. Để tuân theo quy ước rằng trục X tăng lên khi bạn di chuyển sang phải và trục Y tăng khi bạn di chuyển lên, hãy giả sử rằng ô đầu tiên nằm ở góc dưới cùng bên trái của canvas. Thao tác 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 được kết xuất ở 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 học hình vuông trong bất kỳ ô nào trong số đó dựa trên toạ độ của ô.

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 vì hình vuông được xác định để bao quanh tâm củ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 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. Sửa đổ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 trên và sang bên phải theo một (hãy nhớ là một nửa không gian đoạn video) trước khi chia nó cho kích thước lưới. Kết quả 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) ở chính giữa và (-1, -1) ở phía dưới bên trái và bạn muốn (0, 0) ở phía dưới bên trái, nên bạn cần dịch vị trí hình học theo (-1, -1) sau khi chia cho kích thước lưới để di chuyển hình đó vào góc đó.

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

Hình ảnh minh hoạ canvas được chia thành 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 (bằng một phần tư của canvas) cho mỗi ô. Có vẻ như bạn cần thực hiện một phép chia khác cho grid!

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

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

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

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

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

Nếu làm mới ngay, bạn sẽ thấy nội dung sau:

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

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

Lý do là vì toạ độ canvas chuyển từ -1 đến +1, nên thực ra là trên 2 đơn vị. Đ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! Rất may là việc khắc phục cũng rất dễ dàng.

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

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

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

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

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

Và đ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 biểu đồ trước, nhưng không có lớp phủ lưới.

Hơn nữa, giờ đây, bạn có thể đặt cell thành bất kỳ giá trị nào trong giới hạn lưới, sau đó làm mới để xem kết xuất hình vuông ở 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.

Có một cách để tiếp cận đối tượng này là ghi toạ độ ô vào vùng đệm đồng nhất, sau đó gọi lệnh draw một lần cho mỗi hình vuông trong lưới, đồng thời cập nhật giá trị đồng nhất mỗi lần. Tuy nhiên, sẽ rất chậm vì GPU phải đợi toạ độ mới được JavaScript viết mỗi lần. Một trong những 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. Gây kích thước là một cách để yêu cầu GPU vẽ nhiều bản sao của cùng một hình bằng một lệnh gọi đến draw. Phương pháp này nhanh hơn nhiều so với việc gọi draw một lần cho mỗi bản sao. Mỗi bản sao của hình học được gọi là một phiên bản.

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

index.html

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

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

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

Tại sao? Đó là vì bạn vẽ cả 16 hình vuông đó ở 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ở mỗi thực thể.

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

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

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

index.html

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

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

  1. Thay đổi 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!

4 hàng gồm 4 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ẽ không còn thấy từng ô vuông trước khi gặp bất kỳ điểm tắc nghẽn nào về hiệu suất GPU.

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

Đến đây, bạn có thể dễ dàng chuyển sang phần tiếp theo vì bạn đã đặt nền móng cho phần còn lại của lớp học lập trình này. Tuy 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 bây giờ, bạn đã truyền một phần dữ liệu ra khỏi chương trình đổ bóng đỉnh: vị trí đã chuyển đổi. Nhưng trên thực tế, bạn có thể trả về nhiều dữ liệu hơn từ chương trình đổ bóng đỉnh 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 đó. Chương trình đổ bóng đỉnh luôn cần phải trả về một vị trí, vì vậy, nếu muốn trả về bất kỳ dữ liệu nào khác cùng với dữ liệu đó, bạn cần đặt dữ liệu này trong một cấu trúc. Cấu trúc trong WGSL là các loại đối tượng được đặt tên và chứa một hoặc nhiều thuộc tính có tên. Bạn cũng có thể đánh dấu các thuộc tính này 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ị điều tương tự bằng cách sử dụng cấu trúc cho đầu vào và đầu ra của hàm:

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

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

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

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

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

Xin lưu ý rằng 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, điều này không tạo ra quá nhiều khác biệt và thực tế là làm cho chương trình đổ bóng hoạt động lâu hơn một chút, nhưng khi các chương trình đổ bóng trở nên phức tạp hơn, việc sử dụng cấu trúc có thể là một cách hay để giúp bạn sắp xếp dữ liệu.

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

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

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

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

Bạn không nhận bất kỳ dữ liệu đầu vào nào và truyền một màu đồng nhất (màu đỏ) làm dữ liệu đầu ra. Tuy nhiên, nếu chương trình đổ bóng biết nhiều hơn về hình học mà nó đang tô màu, bạn có thể sử dụng dữ liệu bổ sung đó để làm cho mọi thứ trở nên thú vị hơn. Ví dụ: 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 chuyển tiếp đế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 gồm các hình vuông, trong đó cột ngoài cùng bên trái có màu xanh lục, hàng dưới cùng có màu đỏ và tất cả các hình vuông khác có màu vàng.

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

Nếu muốn chuyển đổi 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 cung cấp cho bạn độ dốc màu đẹp hơn nhiều trên toàn bộ lưới.

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

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

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

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

Lệnh gọi createShaderModule

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

Kết quả trông khá ổn!

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

Đây không phải là 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ể làm việc đó, nhưng việc này 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 vùng đệm lưu trữ cho trạng thái ô, hãy sử dụng cái mà hiện tại có thể bắt đầu là một đoạn mã tạo vùng đệm quen thuộc:

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

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

Bạn có thể điền 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 từng ô thứ ba bằng mã sau:

index.html

// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);

Đọc 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 bộ nhớ trước khi kết xuất lưới. Việc này trông rất giống với những lần thêm đồng phục trước đây.

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

index.html

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

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

Tiếp theo, trong phần nội dung của hàm @vertex, hãy truy vấn trạng thái của ô. Vì trạng thái được lưu trữ trong một mảng phẳng 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. Hãy cập nhật mã chương trình đổ bóng để điều chỉnh tỷ lệ vị trí theo trạng thái hoạt động của ô. Giá trị trạng thái phải được truyền tới f32 để đáp ứng các yêu cầu về an toàn về kiểu của WGSL:

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

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

Trước khi bạn có thể thấy trạng thái ô có hiệu lực, hãy thêm vùng đệm lưu trữ vào một nhóm liên kết. Vì thuộc 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 vùng đệm lưu trữ, như sau:

index.html

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

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

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

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

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

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

Tại sao cần phải làm vậy? Hãy xem xét 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. Để đảm bảo mọi thứ dễ hiểu, bạn xác định dữ liệu và mô phỏng của mình trong JavaScript:

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

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

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

Nhưng nếu bạn chạy mã đó, ô hoạt động sẽ di chuyển đến cuối mảng chỉ bằng một bước! Tại sao? 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à... ồ! Đã kích hoạt! Tốt hơn là bạn nên di chuyển lại sang phải. Việc bạn thay đổi dữ liệu cùng lúc với 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 trực quan hoá sự khác biệt giữa hai vùng đệm, hãy điền dữ liệu khác nhau giữa hai 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 lưu trữ khác nhau trong quá trình kết xuất, hãy cập nhật các nhóm liên kết để có hai biến thể khác nhau:

index.html

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

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

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

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

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

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

index.html

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

index.html

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

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

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

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

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.

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

Rõ ràng là khả năng kết xuất của WebGPU còn rất nhiều so với phần nhỏ mà bạn đã khám phá ở đây, nhưng phần còn lại nằm ngoài phạm vi của lớp học lập trình này. Tuy nhiên, hy vọng rằng bài viết này đã giúp bạn hiểu rõ cách hoạt động của tính năng kết xuất của WebGPU, từ đó 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 hoạt động 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 cũng phải 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ương trình đổ bóng điện toán thực sự 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 độc quyền từ những nguồn bạn chọn, chẳng hạn như vùng đệm lưu trữ. Tức là thay vì thực thi một lần cho mỗi đỉnh, thực thể hoặc pixel, bạn phải cho nó biết số lệnh gọi hàm đổ bóng mà bạn muốn. Sau đó, khi chạy chương trình đổ bóng, bạn sẽ được thông báo lệnh gọi nào đang được xử lý và bạn có thể quyết định dữ liệu nào mình sẽ truy cập và thao tác nào sẽ thực hiện từ đó.

Bạn phải tạo chương trình đổ bóng điện toán trong mô-đun chương trình đổ bóng, 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, dựa trên cấu trúc của các chương trình đổ bóng khác mà bạn đã triển khai, hàm chính cho chương trình đổ bóng điện toán cần được đánh dấu bằng thuộc tính @compute.

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

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

Vì GPU được sử dụng thường xuyên cho đồ hoạ 3D, nên chương trình đổ bóng điện toán có cấu trúc để bạn có thể yêu cầu chương trình đổ bóng được gọi một số lần cụ thể trên trục X, Y và Z. Điều này giú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 kiến trúc phần cứng GPU, lưới này được chia thành các nhóm làm việc. Mỗi nhóm làm việc có quy mô X, Y và Z. Mặc dù quy mô có thể là 1, nhưng thường thì nhóm làm việc của bạn sẽ có quy mô lớn hơn một chút về hiệu suất. Đối với chương trình đổ bóng của bạn, hãy chọn quy mô nhóm công việc tương đối tuỳ ý là 8 x 8. Điều này rất hữu ích để theo dõi trong mã JavaScript của bạn.

  1. Xác định hằng số cho 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 kích thước 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 báo cho chương trình đổ bóng hoạt động với hàm này được thực hiện trong các nhóm (8 x 8 x 1). (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.)

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

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

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

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

}

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

Chương trình đổ bóng điện toán cũng có thể sử dụng 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 chế độ đồng nhất với chương trình đổ bóng điện toán để cho bạn biết kích thước lưới, như sau:

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

}

Giống như trong chương trình đổ bóng đỉnh, bạn cũng hiển thị trạng thái của ô dưới dạng vùng đệm bộ nhớ. 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 (Tính toán lệnh gọi createShaderModule)

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

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

}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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, bạn cần thực hiện một vài thay đổi nữa trước khi có thể thấy kết quả.

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

Một điều mà bạn có thể nhận thấy từ chương trình đổ bóng ở trên là chương trình này chủ yếu sử dụng cùng một dữ liệu đầu vào (bộ đồng nhất và vùng đệm lưu trữ) như quy trình kết xuất. Bạn có thể nghĩ rằng mình chỉ cần sử dụng cùng các nhóm liên kết và hoàn thành nó, đúng không? Tin vui là bạn có thể! Bạn chỉ cần thiết lập thêm một chút theo cách thủ công là có thể làm được 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ó bố cục đó bằng cách gọi getBindGroupLayout() trên quy trình kết xuất, từ đó tự động tạo bố cục đó vì bạn đã cung cấp layout: "auto" khi tạo. Phương pháp này hoạt động hiệu quả khi bạn chỉ sử dụng một quy trình, nhưng nếu bạn có nhiều quy trình muốn chia sẻ tài nguyên, bạn cần tạo bố cục một cách rõ ràng, sau đó cung cấp bố cục đó cho cả nhóm liên kết và quy trình.

Để hiểu lý do, hãy xem xét điều này: trong quy trình kết xuất, bạn sử dụng một vùng đệm đồng nhất và một vùng đệm lưu trữ duy nhất, nhưng trong chương trình đổ bóng điện toán mà bạn vừa viết, bạn cần có vùng đệm lưu trữ thứ hai. Vì 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 được sử dụng trong một quy trình cụ thể.

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

index.html

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

Cấu trúc này tương tự như việc tạo 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, bạn cung cấp số binding cho tài nguyên. Số này (như đã tìm hiểu khi tạo nhóm liên kết) khớp với giá trị @binding trong chương trình đổ bóng. Bạn cũng cung cấp visibility, là cá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ả vùng đệm lưu trữ đồng nhất và vùng đệm đầu tiên có thể truy cập được trong chương trình đổ bóng đỉnh và vùng đệm điện toán, nhưng chỉ cần truy cập vào vùng đệm lưu trữ thứ hai trong chương trình đổ bóng điện toán.

Cuối cùng, bạn 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 những mục như texture hoặc sampler, nhưng bạn không cần những mục đó ở đây.

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

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

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

index.html

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

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

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

  1. Tạo 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 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

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

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

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

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

Bạn muốn truyền điện toán trước lượt kết xuất vì phương thức này cho phép lượt kết xuất sử dụng ngay lập tức các kết quả mới nhất từ lượt điện toán. Đó cũng là lý do bạn tăng số lượng 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à liên kết nhóm bên trong lượt tính 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ư bạn làm cho lượt kết xuất.

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à số bạn truyền vào dispatchWorkgroups() không phải số lệnh gọi! Thay vào đó, đây là số lượng nhóm công việc cần thực thi, như được @workgroup_size xác định trong chương trình đổ bóng.

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

Bây giờ, bạn có thể làm mới 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 những hình vuông nhiều màu sắc chạy từ dưới cùng bên trái tới trên cùng bên phải trên nền màu xanh dương đậm. Các sọc chéo 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 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ể sắp xếp ngẫu nhiên các giá trị theo ý muốn. Tuy nhiên, có một cách dễ dàng để bắt đầu nhằm cung cấp kết quả hợp lý.

  1. Để bắt đầu mỗi ô ở trạng thái ngẫu nhiên, hãy cập nhật quá trình khởi 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);

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

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

  1. Để 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 cả 8 ô xung quanh sẽ cho bạn biết số lượng ô lân cận đang hoạt động.

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

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

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

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 bảng? Theo logic cellIndex() hiện tại, dòng này sẽ tràn sang hàng tiếp theo hoặc hàng trước hoặc chạy ra khỏi cạnh của vùng đệm!

Đối với Trò chơi 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ợ bao bọc lưới với 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 bốn quy tắc:

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

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

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

index.html (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 sau cùng hiện sẽ có dạng như sau:

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

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

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

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

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

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

Và... thế là xong! 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 (Trò chơi của cuộc sống) 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