1. Giới thiệ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 các ứ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 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, nó dựa trên API OpenGL ES 2.0 (phát hành năm 2007), dựa trên API OpenGL cũ hơn nữa. GPU đã phát triển đáng kể trong thời gian đó và các API gốc được dùng để giao tiếp với GPU cũng đã phát triển cùng với Direct3D 12, Metal và Vulkan.
WebGPU mang đến những tiến bộ của các API hiện đại này cho nền tảng web. Thư viện này tập trung vào việc bật các tính năng của GPU theo cách đa nền tảng, đồng thời cung cấp một API tự nhiên trên web và ít chi tiết hơn so với một số API gốc mà thư viện này được xây dựng dựa trên.
Kết xuất
GPU thường được liên kết với việc kết xuất đồ hoạ chi tiết, tốc độ cao và WebGPU cũng không ngoại lệ. Vulkan 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à thiết bị di động, đồng thời cung cấp một 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 việc kết xuất, WebGPU còn khai thác tiềm năng của GPU để thực hiện các khối lượng công việc có mục đích chung và có tính song song cao. Bạn có thể sử dụng chương trình đổ bóng điện toán này độ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.
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ẽ tạo Trò chơi cuộc sống của Conway bằng WebGPU. Ứng dụng này sẽ:
- Sử dụng các chức năng kết xuất của WebGPU để vẽ đồ hoạ 2D đơn giản.
- Sử dụng khả năng tính toán của WebGPU để thực hiện mô phỏng.
Trò chơi Cuộc sống là một loại trò chơi được gọi là ô tự động, trong đó một lưới gồm các ô thay đổi trạng thái theo thời gian dựa trên một số quy tắc nhất định. Trong trò chơi Cuộc đời, các ô sẽ 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. Điều này dẫn đến những mẫu hình thú vị thay đổi khi bạn xem.
Kiến thức bạn sẽ học được
- Cách thiết lập WebGPU và định cấu hình canvas.
- Cách vẽ hình học 2D đơn giản.
- Cách sử dụng chương trình đổ bóng đỉnh và mảnh để sửa đổi nội dung đang được vẽ.
- Cách sử dụng chương trình đổ bóng điện toán để thực hiện một mô phỏng đơn giản.
Lớp học lập trình này tập trung vào việc giới thiệu các khái niệm cơ bản đằng sau WebGPU. Đây không phải là một bài đánh giá toàn diện về API, cũng như không đề cập (hoặc yêu cầu) các chủ đề thường liên quan như toán học ma trận 3D.
Bạn cần có
- Một 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 ở 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. Tuy nhiên, nếu có kinh nghiệm sử dụng các API này, bạn có thể nhận thấy nhiều điểm tương đồng với WebGPU, điều này có thể giúp bạn bắt đầu học tập một cách nhanh chóng!
2. Bắt đầu thiết lập
Lấy mã nguồn
Lớp học lập trình này không có bất kỳ phần phụ thuộc nào và hướng dẫn bạn từng bước cần thiết để tạo ứng dụng WebGPU, vì vậy bạn không cần mã nào để bắt đầu. Tuy nhiên, bạn có thể xem một số ví dụ hoạt động đóng vai trò là điểm kiểm tra tại https://github.com/GoogleChromeLabs/your-first-webgpu-app-codelab. Bạn có thể xem và tham khảo các tài liệu này trong quá trình làm bài nếu gặp khó khăn.
Sử dụng bảng điều khiển dành cho nhà phát triển!
WebGPU là một API khá phức tạp với nhiều quy tắc bắt buộc việc sử dụng đúng cách. Tệ hơn nữa, do cách hoạt động của API, API này không thể đưa ra các ngoại lệ JavaScript thông thường cho nhiều lỗi, khiến bạn khó xác định chính xác nguồn gốc của vấ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 này là hoàn toàn bình thường! 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 hoạt động phát triển GPU và đã nỗ lực hết mình để đả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 cá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 điều này đặc biệt áp dụng ở đây!
3. Khởi chạy WebGPU
Bắt đầu bằng <canvas>
Bạn có thể sử dụng WebGPU mà không cần hiển thị bất kỳ nội dung nào trên màn hình nếu bạn chỉ muốn dùng nó để thực hiện các phép tính. Nhưng nếu muốn kết xuất bất kỳ nội dung nào, chẳng hạn như những gì chúng ta sẽ làm trong lớp học lập trình, bạn cần có một canvas. Vậy đó là một điểm khởi đầ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>
nơi chúng ta truy vấn phần tử canvas. (Hoặc sử dụng 00-starter-page.html.)
- Tạo tệp
index.html
bằng đoạn mã sau:
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGPU Life</title>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
const canvas = document.querySelector("canvas");
// Your WebGPU code will begin here!
</script>
</body>
</html>
Yêu cầu đầu nối và thiết bị
Giờ đây, bạn có thể tìm hiểu về WebGPU! Trước tiên, bạn nên cân nhắc rằng các API như WebGPU có thể mất một thời gian để lan truyền 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.
- Để kiểm tra xem đối tượng
navigator.gpu
(đóng vai trò là điểm truy cập cho WebGPU) có tồn tại hay không, hãy thêm mã sau:
index.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
Lý tưởng nhất là bạn nên thông báo cho người dùng nếu WebGPU không hoạt động bằng cách để trang quay lại chế độ không dùng WebGPU. (Có thể dùng WebGL thay thế không?) Tuy nhiên, vì mục đích của lớp học lập trình này, bạn chỉ cần trả về một lỗi để ngăn mã thực thi thêm.
Sau khi 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 của bạn là yêu cầu một GPUAdapter
. Bạn có thể coi bộ chuyển đổi là đại diện của WebGPU cho một phần cứng GPU cụ thể trong thiết bị của bạn.
- Để nhận một bộ chuyển đổi, hãy sử dụng phương thức
navigator.gpu.requestAdapter()
. Hàm này trả về một promise, vì vậy, cách thuận tiện nhất là gọi hàm này bằngawait
.
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 phù hợp, giá trị adapter
được trả về có thể là null
, vì vậy, bạn nên xử lý trường hợp này. Đ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.
Hầu hết thời gian, 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. Tuy nhiên, đố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 hiệu suất cao hay tiêu thụ ít điện năng 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ó một 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 một GPUDevice. Thiết bị là giao diện chính mà hầu hết hoạt động tương tác với GPU diễn ra thông qua đó.
- 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 promise.
index.html
const device = await adapter.requestDevice();
Tương tự như requestAdapter()
, có các lựa chọn có thể được truyền ở đây cho các mục đích sử dụng nâng cao 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 giá trị mặc định hoạt động tốt.
Định cấu hình Canvas
Giờ đây, bạn đã có một thiết bị, nhưng vẫn còn một việc nữa cần làm nếu bạn muốn dùng thiết bị đó để hiển thị nội dung trên trang: định cấu hình canvas để dùng với thiết bị mà bạn vừa tạo.
- Để làm việc này, trước tiên, hãy yêu cầu
GPUCanvasContext
từ canvas bằng cách gọicanvas.getContext("webgpu")
. (Đây là lệnh gọi tương tự mà bạn sẽ dùng để khởi tạo ngữ cảnh Canvas 2D hoặc WebGL, bằng cách sử dụng các loại ngữ cảnh2d
vàwebgl
tương ứng.) Sau đó,context
mà phương thức này trả về phải được liên kết với thiết bị bằng phương thứcconfigure()
, như sau:
index.html
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
Có một số lựa chọn có thể được truyền ở đây, nhưng quan trọng nhất là device
mà bạn sẽ sử dụng ngữ cảnh và format
, đây là định dạng hoạ tiết mà ngữ cảnh sẽ sử dụng.
Kết cấu là các đối tượng mà WebGPU 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à bối cảnh canvas cung cấp các hoạ tiết để mã của bạn vẽ vào, đồng thời định dạng mà bạn sử dụng có thể ảnh hưởng đến mức độ hiệu quả của canvas khi hiển thị những hình ảnh đó. Các loại thiết bị khác nhau hoạt động hiệu quả nhất khi sử dụng các định dạng hoạ tiết khác nhau. Nếu bạn không sử dụng định dạng ưu tiên của thiết bị, thì có thể xảy ra các bản sao bộ nhớ bổ sung ở chế độ nền trước khi hình ảnh có thể hiển thị dưới dạng một phần của trang.
Rất may là bạn không cần lo lắng nhiều về bất kỳ điều nào trong số đó vì WebGPU sẽ cho bạn biết định dạng cần dùng cho canvas! Trong hầu hết các trường hợp, bạn muốn truyền giá trị do lệnh gọi navigator.gpu.getPreferredCanvasFormat()
trả về, như minh hoạ ở trên.
Xoá Canvas
Giờ đây, khi đã có một thiết bị và canvas đã được định cấu hình bằng thiết bị đó, bạn có thể bắt đầu sử dụng thiết bị để thay đổi nội dung của canvas. Để bắt đầu, hãy xoá vùng này bằng một màu đồng nhất.
Để làm việc đó (hoặc hầu hết mọi việc khác trong WebGPU), bạn cần cung cấp một số lệnh cho GPU để hướng dẫn GPU làm những việc cần thiết.
- Để làm việc này, hãy yêu cầu thiết bị tạo một
GPUCommandEncoder
.GPUCommandEncoder
cung cấp một giao diện để ghi lại các lệnh GPU.
index.html
const encoder = device.createCommandEncoder();
Các lệnh bạn muốn gửi đến GPU có liên quan đến quá trình kết xuất (trong trường hợp này là xoá canvas), vì vậy, bước tiếp theo là sử dụng encoder
để bắt đầu một Render Pass.
Render pass là khi tất cả các thao tác vẽ trong WebGPU diễn ra. Mỗi lệnh gọi bắt đầu bằng lệnh gọi beginRenderPass()
, xác định các hoạ tiết nhận đầu ra của mọi lệnh vẽ đã thực hiện. Các cách sử dụng nâng cao hơn có thể cung cấp một số hoạ tiết, được gọi là tệp đính kèm, với nhiều mục đích như lưu trữ độ sâu của hình học được kết xuất hoặc cung cấp tính năng khử răng cưa. Tuy nhiên, đối với ứng dụng này, bạn chỉ cần một.
- 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()
. Lệnh này sẽ trả về một hoạ tiết có chiều rộng và chiều cao bằng pixel khớp với các thuộc tínhwidth
vàheight
của canvas, cũng nhưformat
được chỉ định khi bạn gọicontext.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
. Các lượt kết xuất yêu cầu bạn cung cấp một GPUTextureView
thay vì GPUTexture
, cho biết những phần nào của hoạ tiết cần kết xuất. Điều này chỉ thực sự quan trọng đối với các trường hợp sử dụng nâng cao hơn, vì vậy, ở đây bạn gọi createView()
mà không có đối số trên hoạ tiết, cho biết rằng bạn muốn đường truyền kết xuất sử dụng toàn bộ hoạ tiết.
Bạn cũng phải chỉ định những gì bạn muốn lượt kết xuất thực hiện với hoạ tiết khi bắt đầu và khi kết thúc:
- Giá trị
loadOp
là"clear"
cho biết bạn muốn xoá kết cấu khi bắt đầu truyền kết xuất. - Giá trị
storeOp
của"store"
cho biết rằng sau khi hoàn tất đường kết xuất, bạn muốn kết quả của mọi thao tác vẽ được thực hiện trong đường kết xuất sẽ đượ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. Thao tác bắt đầu truyền kết xuất bằng loadOp: "clear"
là đủ để xoá chế độ xem kết cấu và canvas.
- Kết thúc đường kết xuất bằng cách thêm lệnh gọi sau ngay sau
beginRenderPass()
:
index.html
pass.end();
Điều quan trọng là bạn cần biết rằng việc chỉ thực hiện các lệnh gọi này không khiến GPU thực sự làm bất cứ điều gì. Chúng chỉ ghi lại các lệnh để GPU thực hiện sau.
- Để tạo một
GPUCommandBuffer
, hãy gọifinish()
trên bộ mã hoá lệnh. Vùng đệm lệnh là một giá trị nhận dạng không rõ ràng cho các lệnh đã ghi.
index.html
const commandBuffer = encoder.finish();
- Gửi vùng đệm lệnh đến GPU bằng cách dùng
queue
củaGPUDevice
. Hàng đợi thực hiện tất cả các lệnh GPU, đảm bảo rằng các lệnh này được thực thi theo đúng thứ tự và được đồng bộ hoá đúng cách. Phương thứcsubmit()
của hàng đợi sẽ nhận một mảng các 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 bạn gửi một vùng đệm lệnh, vùng đệm đó sẽ không dùng lại được nữa, vì vậy, bạn 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 tại sao bạn thường thấy 2 bước đó được gộp thành một, như trong các trang mẫu của 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 để JavaScript trả quyền kiểm soát cho trình duyệt. Tại thời điểm đó, trình duyệt sẽ nhận thấy rằng bạn đã thay đổi hoạ tiết hiện tại của bối 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 bộ đệm lệnh mới, gọi lại context.getCurrentTexture()
để lấy một kết cấu mới cho một lượt kết xuất.
- Tải lại trang. Lưu ý rằng canvas được tô bằng màu đen. Xin chúc mừng! Điều đó có nghĩa là bạn đã tạo thành công ứng dụng WebGPU đầu tiên của mình.
Chọn màu!
Tuy nhiên, thành thật mà nói, các ô vuông màu đen khá nhàm chán. Vì vậy, hãy dành chút thời gian trước khi chuyển sang phần tiếp theo để cá nhân hoá phần này một chút.
- Trong lệnh gọi
encoder.beginRenderPass()
, hãy thêm một dòng mới cóclearValue
vàocolorAttachment
, 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 đường truyền kết xuất màu mà đường truyền đó nên sử dụng khi thực hiện thao tác clear
vào đầu đường truyền. Từ điển được truyền vào đó chứa 4 giá trị: r
cho màu đỏ, g
cho màu xanh lục, b
cho màu xanh dương và a
cho alpha (độ trong suốt). Mỗi giá trị có thể nằm trong khoảng từ 0
đến 1
và cùng nhau mô tả giá trị của kênh màu đó. Ví dụ:
{ r: 1, g: 0, b: 0, a: 1 }
có màu đỏ tươi.{ r: 1, g: 0, b: 1, a: 1 }
có màu tím tươi.{ r: 0, g: 0.3, b: 0, a: 1 }
có màu xanh lục đậm.{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
có màu xám trung bình.{ r: 0, g: 0, b: 0, a: 0 }
là màu đen trong suốt mặc định.
Mã ví dụ và ảnh chụp màn hình trong lớp học lập trình này sử dụng màu xanh dương đậm, nhưng bạn có thể chọn bất kỳ màu nào mình muốn!
- Sau khi bạn chọn màu, hãy tải lại trang. Bạn sẽ thấy màu mình chọn trên canvas.
4. Vẽ hình học
Khi kết thúc phần này, ứng dụng của bạn sẽ vẽ một số hình học đơn giản lên canvas: một hình vuông có màu. Xin lưu ý rằng có vẻ như bạn sẽ 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 rất nhiều hình học một cách hiệu quả. Một tác dụng phụ của hiệu quả này là việc 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ạn mong đợi nếu bạn chuyển sang một 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 thực hiện thêm bất kỳ thay đổi nào về mã, bạn nên xem nhanh, đơn giản và tổng quan ở cấp cao về cách GPU tạo ra các hình dạng mà bạn thấy trên màn hình. (Bạn có thể chuyển đến phần Xác định đỉnh nếu đã nắm rõ những kiến thức cơ bản về cách hoạt động của quy trình kết xuất GPU.)
Không giống như một API như Canvas 2D có nhiều hình dạng và lựa chọn sẵn sàng để bạn sử dụng, GPU của bạn thực sự chỉ xử lý một số loại hình dạng (hoặc nguyên hàm như WebGPU gọi): điểm, đường thẳng và hình tam giác. Để phục vụ mục đích của lớp học lập trình này, bạn sẽ chỉ sử dụng các hình tam giác.
GPU gần như chỉ hoạt động với các hình tam giác vì hình tam giác có nhiều đặc tính toán học hữu ích giúp chúng dễ dàng xử lý theo cách có thể dự đoán và hiệu quả. Hầu hết mọi thứ bạn vẽ bằng GPU đều cần được chia thành các hình tam giác trước khi GPU có thể vẽ, và các hình tam giác đó phải được xác định bằng các điểm góc của chúng.
Các điểm này (hay còn gọi là đỉnh) được biểu thị bằng 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ệ toạ độ dễ hình dung nhất khi xét đến mối quan hệ giữa hệ toạ độ đó với canvas trên trang của bạn. Bất kể canvas của bạn rộng hay cao đến đâu, cạnh trái luôn ở vị trí -1 trên trục X và cạnh phải luôn ở vị trí +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 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.
Ban đầu, các đỉnh hiếm khi được xác định trong hệ toạ độ này, vì vậy, GPU dựa vào các chương trình nhỏ có tên là trình đổ bóng đỉnh để thực hiện bất kỳ phép toán nào cần thiết để chuyển đổi các đỉnh thành không gian đoạn, cũng như mọi phép tính 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 GPU hoạt động.
Từ đó, GPU sẽ lấy tất cả các tam giác được tạo thành từ những đỉnh đã biến đổi này và xác định những pixel cần thiết trên màn hình để vẽ các tam giác đó. Sau đó, chương trình này chạy một chương trình nhỏ khác mà bạn viết, gọi là chương trình đổ bóng mảnh. Chương trình này tính toán màu sắc mà mỗi pixel nên có. Phép tính đó có thể đơn giản như trả về màu xanh lục hoặc phức tạp như tính toán góc của bề mặt so với ánh sáng mặt trời phản chiếu từ các bề mặt lân cận khác, được lọc qua sương mù và được sửa đổi theo độ kim loại của bề mặt. Bạn hoàn toàn có thể kiểm soát được điều này, vừa có thể giúp bạn tự tin hơn nhưng cũng có thể khiến bạn cảm thấy choáng ngợp.
Sau đó, kết quả của những màu pixel đó được tích luỹ thành một hoạ tiết, rồi có thể hiển thị trên màn hình.
Xác định đỉnh
Như đã đề cập trước đó, mô phỏng The Game of Life được thể hiện dưới dạng một 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 được dùng trong lớp học lập trình này là vẽ các ô 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 sẽ 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, kéo vào từ các cạnh, có toạ độ góc như sau:
Để truyền các toạ độ đó đến GPU, bạn cần đặt các giá trị trong một TypedArray. Nếu chưa quen với TypedArray, 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ý. TypedArray 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.
- Tạo một mảng chứa tất cả các vị trí đỉnh trong sơ đồ bằng cách đặt khai báo mảng sau đây vào mã của bạn. Bạn nên đặt nó ở 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ị; chúng chỉ nhằm giúp bạn thuận tiện hơn và dễ đọc hơn. Điều này giúp bạn thấy rằng mỗi cặp giá trị tạo thành toạ độ X và Y cho một đỉnh.
Nhưng đã xảy ra sự cố! GPU hoạt động theo các tam giác, bạn nhớ chứ? Điều đó có nghĩa là bạn phải cung cấp các đỉnh theo nhóm gồm 3 đỉnh. Bạn có một nhóm gồm 4 người. Giải pháp là lặp lại 2 đỉnh để tạo 2 tam giác có chung một cạnh ở giữa hình vuông.
Để tạo hình vuông từ sơ đồ, bạn phải liệt kê các đỉnh (-0.8, -0.8) và (0.8, 0.8) hai lần, một lần cho tam giác màu xanh dương và một lần cho tam giác màu đỏ. (Bạn cũng có thể chọn chia hình vuông bằng hai góc còn lại; điều này không có gì khác biệt.)
- Cập nhật mảng
vertices
trước đó để có dạng 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ù sơ đồ cho thấy sự tách biệt giữa hai tam giác để rõ ràng hơn, nhưng vị trí đỉnh hoàn toàn giống nhau và GPU kết xuất chúng mà không có khoảng trống. Nút này sẽ hiển thị dưới dạng một hình vuông liền khối.
Tạo vùng đệm đỉnh
GPU không thể vẽ các đỉnh bằng dữ liệu từ một mảng JavaScript. 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 bạn muốn GPU sử dụng trong khi vẽ đều cần được đặt trong 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 mà GPU có thể nhìn thấy.
- Để tạo một vùng đệm chứa các đỉnh, hãy thêm lệnh gọi sau vào
device.createBuffer()
sau khi xác định mảngvertices
.
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 đặt nhãn cho vùng đệm. Bạn có thể đặt nhãn tuỳ chọn cho mọi đối tượng WebGPU mà bạn tạo và chắc chắn bạn nên làm như vậy! Nhãn là bất kỳ chuỗi nào bạn muốn, miễn là nhãn đó giúp bạn xác định đối tượng. Nếu bạn gặp phải bất kỳ vấn đề nào, các nhãn đó sẽ được dùng trong thông báo lỗi mà WebGPU tạo ra để giúp bạn hiểu rõ vấn đề.
Tiếp theo, hãy cho bộ nhớ đệm một size (kích thước) tính bằng byte. Bạn cần một vùng đệm có 48 byte. Bạn xác định vùng đệm này bằng cách nhân kích thước của một số thực 32 bit ( 4 byte) với số lượng số thực trong mảng vertices
(12). Rất may là TypedArray đã tính toán byteLength cho bạn, vì vậy, bạn có thể sử dụng giá trị đó khi tạo vùng đệm.
Cuối cùng, bạn cần chỉ định mức sử dụng của vùng đệm. Đây là một hoặc nhiều cờ GPUBufferUsage
, trong đó nhiều cờ được kết hợp với toán tử |
( bitwise OR). Trong trường hợp này, bạn chỉ định rằng bạn muốn bộ đệm được dùng cho dữ liệu đỉnh (GPUBufferUsage.VERTEX
) và bạn cũng muốn có thể sao chép dữ liệu vào bộ đệm đó (GPUBufferUsage.COPY_DST
).
Đối tượng vùng đệm được trả về cho bạn là đối tượng không trong suốt – bạn không thể (dễ dàng) kiểm tra dữ liệu mà đối tượng này chứa. Ngoài ra, hầu hết các thuộc tính của đối tượng 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. Những gì bạn có thể thay đổi là nội dung trong bộ nhớ của nó.
Khi vùng đệm được tạo ban đầu, bộ nhớ mà vùng đệm này chứa sẽ được khởi tạo thành 0. Có nhiều cách để thay đổi nội dung của đối tượ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.
- Để 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
Giờ đây, bạn có một vùng đệm chứa dữ liệu đỉnh, nhưng theo GPU thì đó chỉ là một blob gồm các byte. Bạn cần cung cấp thêm một chút thông tin nếu muốn vẽ bất cứ thứ gì bằng công cụ này. Bạn cần có thể cho WebGPU biết thêm về cấu trúc của dữ liệu đỉnh.
- Xác định cấu trúc dữ liệu đỉnh bằng từ điển
GPUVertexBufferLayout
:
index.html
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
Lúc đầu, bạn có thể thấy hơi khó hiểu, nhưng tương đối dễ dàng để phân tích.
Điều đầu tiên bạn cung cấp là arrayStride
. Đây là số byte mà GPU cần bỏ qua trong vùng đệm khi tìm kiếm đỉnh tiếp theo. Mỗi đỉnh của hình vuông được tạo thành từ hai số có dấu phẩy động 32 bit. Như đã đề cập trước đó, số thực 32 bit là 4 byte, nên 2 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à những phần thông tin riêng lẻ được mã hoá vào từng đỉ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 với nhiều thuộc tính trong đó, chẳng hạn như màu của một đỉnh hoặc hướng mà bề mặt hình học đang hướng tới. Tuy nhiên, điều đó nằm ngoài phạm vi của lớp học lập trình này.
Trong thuộc tính đơn, trước tiên, bạn xác định format
của dữ liệu. Điều này xuất phát 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. Mỗi đỉnh có 2 số thực 32 bit, vì vậy, bạn sử dụng định dạng float32x2
. Nếu dữ liệu đỉnh của bạn được tạo thành từ 4 số nguyên không dấu 16 bit, thì bạn sẽ sử dụng uint16x4
. Bạn có thấy quy luật không?
Tiếp theo, offset
mô tả số lượng byte mà thuộc tính cụ thể này bắt đầu trong đỉnh. Bạn chỉ cần lo lắng về điều này nếu vùng đệm 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 có shaderLocation
. Đây là một số tuỳ ý từ 0 đến 15 và phải là số duy nhất cho mọi thuộc tính mà bạn xác định. Thuộc tính này liên kết với một đầu vào cụ thể trong chương trình đổ bóng đỉnh. Bạn sẽ tìm hiểu về điều này trong phần tiếp theo.
Lưu ý rằng mặc dù hiện tại bạn xác định các giá trị này, nhưng bạn chưa thực sự truyền chúng vào API WebGPU ở bất kỳ đâu. Điều đó sẽ xảy ra, nhưng cách dễ nhất là nghĩ về những giá trị này tại thời điểm bạn xác định các đỉnh, vì vậy, bạn sẽ thiết lập chúng ngay bây giờ để sử dụng sau này.
Bắt đầu với chương trình đổ bóng
Giờ đây, bạn đã có dữ liệu muốn kết xuất, nhưng bạn vẫn cần cho GPU biết chính xác cách xử lý dữ liệu đó. Phần lớn điều đó xảy ra với chương trình đổ bóng.
Chương trình đổ bóng là những 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ý đoạn chương trình hoặc Tính toán chung. Vì nằm trên GPU, nên các thành phần này có cấu trúc chặt chẽ hơn so với JavaScript thông thường. Nhưng cấu trúc đó cho phép chúng thực thi rất nhanh và quan trọng là song song!
Các chương trình đổ bóng trong WebGPU được viết bằng một ngôn ngữ đổ bóng có tên là WGSL (Ngôn ngữ đổ bóng WebGPU). Về mặt cú pháp, WGSL có phần giống Rust, với các tính năng nhằm giúp các loại công việc phổ biến của GPU (chẳng hạn như toán học vectơ và ma trận) trở nên dễ dàng và nhanh chóng hơn. Việc dạy toàn bộ ngôn ngữ tạo 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 bắt được một số kiến thức cơ bản khi xem qua một số ví dụ đơn giản.
Bản thâ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 vị trí để nhập mã chương trình đổ bóng bằng cách sao chép nội dung 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
dưới dạng chuỗi (không bắt buộc). (Xin lưu ý rằng bạn sử dụng dấu nháy ngược ở đây để cho phép các chuỗi nhiều dòng!) Sau khi bạn thêm một số mã WGSL hợp lệ, hàm này sẽ trả về một đối tượng GPUShaderModule
có kết quả đã biên dịch.
Xác định chương trình đổ bóng đỉnh
Bắt đầu bằng 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) nên hàm mà bạn xác định sẽ được gọi 6 lần. Mỗi lần được gọi, một vị trí khác trong vertexBuffer
sẽ được truyền đến hàm dưới dạng đối số và nhiệm vụ của hàm chương trình đổ bóng đỉnh là trả về một vị trí tương ứng trong không gian đoạn.
Điều quan trọng là bạn phải hiểu rằng các hàm này cũng không nhất thiết được gọi theo thứ tự tuần tự. Thay vào đó, GPU hoạt động hiệu quả khi chạy song song các chương trình đổ bóng như thế này, có thể 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 lớn trong những yếu tố giúp GPU có tốc độ đáng kinh ngạc, nhưng nó cũng có những hạn chế. Để đảm bảo khả năng song song hoá cực cao, 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 các giá trị cho một đỉnh duy nhất.
Trong WGSL, bạn có thể đặt tên cho hàm chương trình đổ bóng đỉnh theo ý muốn, nhưng hàm đó phải có @vertex
thuộc tính ở phía trước để cho biết giai đoạn chương trình đổ bóng mà hàm đó đại diện. WGSL biểu thị các hàm bằng từ khoá fn
, dùng dấu ngoặc đơn để khai báo mọi đối số và dùng dấu ngoặc nhọn để xác định phạm vi.
- 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ì trình đổ bóng đỉnh phải trả về ít nhất vị trí cuối cùng của đỉnh đang được xử lý trong không gian đoạn. Giá trị này luôn được cung cấp dưới dạng vectơ 4 chiều. Vectơ là một thành phần phổ biến trong chương trình đổ bóng đến mức chúng được coi là các nguyên hàm hạng 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ũng có các loại tương tự cho vectơ 2D (vec2f
) và vectơ 3D (vec3f
)!
- Để cho biết giá trị được trả về là vị trí bắt buộc, hãy đánh dấu giá trị đó bằng thuộc tính
@builtin(position)
. Ký hiệu->
được dùng để cho biết đây là giá trị mà hàm trả về.
index.html (mã createShaderModule)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
Tất nhiên, nếu hàm có kiểu dữ liệu trả về, bạn cần thực sự trả về một giá trị trong phần nội dung của hàm. Bạn có thể tạo một vec4f
mới để trả về bằng cách sử dụng cú pháp vec4f(x, y, z, w)
. Các giá trị x
, y
và z
đều là số thực. Trong giá trị trả về, các giá trị này cho biết vị trí của đỉnh trong không gian cắt.
- 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 đó không bao giờ hiển thị bất cứ thứ gì vì GPU nhận ra rằng các hình tam giác mà chương trình tạo ra chỉ là một điểm duy nhất và sau đó loại bỏ điểm đó.
index.html (mã createShaderModule)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}
Thay vào đó, bạn muốn sử dụng dữ liệu từ vùng đệm mà bạn đã tạo. Bạn có thể làm việc này bằng cách khai báo một đối số cho hàm có thuộc tính @location()
và loại khớp với nội dung bạn đã mô tả trong vertexBufferLayout
. Bạn đã chỉ định một shaderLocation
là 0
, vì vậy trong mã WGSL, hãy đánh dấu đối số bằng @location(0)
. Bạn cũng xác định định dạng là float32x2
, tức là vectơ 2D, nên trong WGSL, đối số của bạn là vec2f
. Bạn có thể đặt tên cho nó theo ý muốn, nhưng vì các giá trị này đại diện cho vị trí đỉnh nên tên như pos có vẻ phù hợp.
- 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);
}
Bây giờ, bạn cần trả về vị trí đó. Vì vị trí là một vectơ 2D và kiểu trả về là một vectơ 4D, nên bạn phải thay đổi một chút. Bạn cần lấy 2 thành phần từ đối số vị trí và đặt chúng vào 2 thành phần đầu tiên của vectơ trả về, để 2 thành phần cuối cùng lần lượt là 0
và 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ì các 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à điều này có nghĩa là giống nhau.
- 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);
}
Và đó là chương trình đổ bóng đỉnh ban đầu của bạn! Cách này rất đơn giản, chỉ cần truyền vị trí mà không thay đổi gì, nhưng cũng đủ để bắt đầu.
Xác định chương trình đổ bóng mảnh
Tiếp theo là chương trình đổ bóng mảnh. Chương trình đổ bóng mảnh hoạt động theo cách rất giống với chương trình đổ bóng đỉnh, nhưng thay vì được gọi cho mọi đỉnh, chúng được gọi cho mọi pixel đang được vẽ.
Chương trình đổ bóng mảnh luôn được gọi sau chương trình đổ bóng đỉnh. GPU lấy đầu ra của chương trình đổ bóng đỉnh và triangulates (tam giác hoá) đầu ra đó, tạo ra các tam giác từ các nhóm gồm 3 điểm. Sau đó, nó raster hoá từng tam giác đó bằng cách xác định những pixel của tệp đính kèm màu đầu ra có trong tam giác đó, 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 phân mảnh trả về một màu, thường được tính toán từ các giá trị được gửi đến chương trình này từ chương trình đổ bóng đỉnh và các thành phần như hoạ tiết mà GPU ghi vào tệp đính kèm màu.
Giống như chương trình đổ bóng đỉnh, chương trình đổ bóng mảnh được thực thi theo cách song song trên quy mô lớn. Chúng linh hoạt hơn một chút so với chương trình đổ bóng đỉnh về đầu vào và đầu ra, nhưng bạn có thể 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 sắc chứ không phải vị trí. Giá trị trả về cần được chỉ định một thuộc tính @location
để cho biết colorAttachment
nào từ lệnh gọi beginRenderPass
mà màu được trả về sẽ được ghi vào. Vì bạn chỉ có một tệp đính kèm, nên vị trí là 0.
- Tạo một hàm
@fragment
trống, như sau:
index.html (mã createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
Bốn thành phần của vectơ được trả về là các giá trị màu đỏ, xanh lục, xanh lam và alpha. Các giá trị này được diễn giải theo cách hoàn toàn giống như clearValue
mà bạn đã đặt trong beginRenderPass
trước đó. Vậy vec4f(1, 0, 0, 1)
là màu đỏ tươi, có vẻ là một màu phù hợp cho hình vuông của bạn. Bạn có thể đặt màu tuỳ ý!
- Đặt vectơ màu được trả về, như sau:
index.html (mã createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
Và đó là một chương trình đổ bóng mảnh hoàn chỉnh! Đây không phải là một chương trình thú vị lắm; chương trình này chỉ đặt mọi pixel của mọi tam giác thành màu đỏ, nhưng như vậy là đủ cho hiện tại.
Tóm lại, sau khi thêm mã chương trình đổ bóng như mô tả chi tiết ở trên, lệnh gọi createShaderModule
của bạn sẽ có dạng như sau:
index.html
const cellShaderModule = device.createShaderModule({
label: 'Cell shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`
});
Tạo quy trình kết xuất
Không thể sử dụng riêng một mô-đun chương trình đổ bóng để 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 kiểm soát cách hình học được vẽ, bao gồm những thứ như shader nào được sử dụng, cách diễn giải dữ liệu trong các vùng đệm đỉnh, loại hình học nào sẽ được kết xuất (đường thẳng, điểm, tam giác...), v.v.!
Quy trình kết xuất là đối tượng phức tạp nhất trong toàn bộ API, nhưng đừng lo lắng! Hầu hết các giá trị bạn có thể truyền đến đều là 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, chẳng hạn như:
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 đầu vào mà quy trình cần (ngoài các vùng đệm đỉnh), nhưng bạn thực sự không có bất kỳ loại đầu vào nào. Rất may là bạn có thể truyền "auto"
ngay bây giờ và quy trình sẽ tạo bố cục riêng từ các 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 của bạn, còn entryPoint
cho biết 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
và @fragment
trong một mô-đun chương trình đổ bóng!) buffers là một mảng gồm các đối tượng GPUVertexBufferLayout
mô tả cách dữ liệu của bạn được đóng gói trong các vùng đệm đỉnh mà bạn sử dụng quy trình này. Thật may 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ông tin chi tiết về giai đoạn fragment
. Điều này cũng bao gồm một mô-đun đổ bóng và entryPoint, chẳng hạn như giai đoạn đỉnh. Phần cuối cùng là xác định targets
mà quy trình này được dùng. Đây là một mảng gồm các 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 xuất ra. Những thông tin chi tiết này cần phải khớp với các hoạ tiết được cung cấp trong colorAttachments
của mọi lượt kết xuất mà quy trình này được dùng. Lượt kết xuất của bạn sử dụng các hoạ tiết từ ngữ cảnh canvas và sử dụng giá trị mà bạn đã lưu trong canvasFormat
cho định dạng của nó, vì vậy, bạn sẽ truyền cùng một định dạng tại đây.
Đó thậm chí không phải là tất cả các lựa chọn mà bạn có thể chỉ định khi tạo một 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
Đến đây, bạn đã có mọi thứ cần thiết để vẽ hình vuông!
- Để vẽ hình vuông, hãy quay lại cặp lệnh gọi
encoder.beginRenderPass()
vàpass.end()
, rồi thêm các lệnh mới này vào giữa:
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 dùng setPipeline()
để cho biết nên dùng quy trình nào để vẽ. Trong đó có các chương trình đổ bóng được 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()
bằng vùng đệm chứa các đỉnh cho hình vuông của bạn. Bạn gọi nó bằng 0
vì vùng đệm này tương ứng với phần tử thứ 0 trong định nghĩa vertex.buffers
của quy trình hiện tại.
Cuối cùng, bạn thực hiện lệnh gọi draw()
. Lệnh gọi này có vẻ đơn giản một cách kỳ lạ sau tất cả các bước thiết lập trước đó. Điều duy nhất bạn cần truyền vào là số lượng đỉnh mà nó sẽ kết xuất, số lượng này được lấy từ các 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 thành 6
, nhưng việc tính toán từ mảng đỉnh (12 số thực / 2 toạ độ cho mỗi đỉnh == 6 đỉnh) có nghĩa là nếu bạn quyết định thay thế hình vuông bằng một hình tròn chẳng hạn, thì bạn sẽ ít phải cập nhật theo cách thủ công hơn.
- Làm mới màn hình và (cuối cùng) xem kết quả của tất cả những nỗ lực của bạn: một hình vuông lớn có màu.
5. Vẽ lưới
Trước tiên, hãy dành thời gian tự chúc mừng bản thân! Việc hiển thị những phần đầu tiên của hình học trên màn hình thường là một trong những bước khó nhất đối với hầu hết các API GPU. Mọi việc bạn làm từ đây đều có thể được thực hiện theo các bước nhỏ hơn, giúp bạn dễ dàng xác minh tiến trình của mình trong quá trình thực hiện.
Trong phần này, bạn sẽ tìm hiểu:
- Cách truyền các biến (gọi là uniform) đến chương trình đổ bóng từ JavaScript.
- Cách sử dụng các giá trị đồng nhất để thay đổi hành vi kết xuất.
- Cách sử dụng tính năng tạo nhiều phiên bản để 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ị một lưới, bạn cần biết một thông tin rất cơ bản về lưới đó. Bảng này có bao nhiêu ô, cả về 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 một chút, hãy coi lưới là một hình vuông (cùng chiều rộng và chiều cao) và sử dụng kích thước là luỹ thừa của 2. (Việc này giúp bạn dễ dàng thực hiện một số phép toán sau này.) Bạn muốn tăng kích thước này lên sau, nhưng trong phần còn lại của phần này, hãy đặt kích thước lưới thành 4x4 vì kích thước này giúp bạn dễ dàng minh hoạ một số phép toán được dùng trong phần này. Tă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 hiển thị hình vuông để có thể vừa 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 nhỏ hơn nhiều và cần có nhiều hình vuông.
Giờ đây, một cách mà bạn có thể tiếp cận vấn đề này là tạo vùng đệm đỉnh lớn hơn đáng kể và xác định GRID_SIZE
lần GRID_SIZE
giá trị của các hình vuông bên trong vùng đệm đó ở đúng kích thước và vị trí. Trên thực tế, mã cho việc đó không quá tệ! Chỉ cần một vài vòng lặp for và một chút toán học. Nhưng cách 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 thân thiện hơn với GPU.
Tạo một vùng đệm đồng nhất
Trước tiên, bạn cần truyền đạt kích thước lưới mà bạn đã chọn cho chương trình đổ bóng, vì chương trình này sử dụng kích thước đó để thay đổi cách hiển thị mọi thứ. Bạn chỉ cần mã hoá cứng kích thước vào chương trình đổ bóng, nhưng điều đó có nghĩa là bất cứ khi nào muốn thay đổi kích thước lưới, bạn đều phải tạo lại chương trình đổ bóng và quy trình kết xuất. Điều này rất tốn kém. Cách tốt hơn là cung cấp kích thước lưới cho chương trình đổ bóng dưới dạng uniforms.
Trước đó, bạn đã tìm hiểu rằng một giá trị khác từ vùng đệm đỉnh sẽ được truyền đến mọi lệnh gọi của chương trình đổ bóng đỉnh. Đồ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 khi truyền đạt các giá trị thường gặp cho một phần hình học (chẳng hạn như vị trí), một khung hình hoạt ảnh đầy đủ (chẳng hạn như thời gian hiện tại) hoặc thậm chí toàn bộ vòng đời của ứng dụng (chẳng hạn như lựa chọn ưu tiên của người dùng).
- Tạo một vùng đệm đồng nhất bằng cách thêm đoạn mã sau:
index.html
// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
label: "Grid Uniforms",
size: uniformArray.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);
Đoạn mã này trông rất quen thuộc vì gần như giống hệt đoạn mã mà bạn đã dùng để tạo bộ đệm đỉnh trước đó! Lý do là vì các đối tượng đồng nhất được truyền đến WebGPU API thông qua cùng các đối tượng GPUBuffer mà các đỉnh được truyền, điểm khác biệt chính là usage
lần này bao gồm GPUBufferUsage.UNIFORM
thay vì GPUBufferUsage.VERTEX
.
Truy cập vào các biến đồng nhất trong chương trình đổ bóng
- Xác định một uniform bằng cách thêm mã sau:
index.html (createShaderModule call)
// 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 giá trị đồng nhất trong chương trình đổ bóng của bạn có tên là grid
, đây là một vectơ số thực 2D khớp với mảng mà bạn vừa sao chép vào vùng đệm đồng nhất. Nó cũng chỉ định rằng giá trị đồng nhất được liên kết tại @group(0)
và @binding(0)
. Bạn sẽ tìm hiểu ý nghĩa của những giá trị đó trong giây lát.
Sau đó, ở nơi khác trong mã chương trình đổ bóng, bạn có thể sử dụng vectơ lưới theo cách bạn cần. Trong mã này, bạn chia vị trí đỉnh theo vectơ lưới. Vì pos
là một vectơ 2D và grid
là một 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ả cũng giống như khi bạn nói vec2f(pos.x / grid.x, pos.y / grid.y)
.
Các loại thao tác vectơ này rất phổ biến trong chương trình đổ bóng GPU vì nhiều kỹ thuật kết xuất và tính toán dựa vào chúng!
Điều này có nghĩa là trong trường hợp của bạn (nếu bạn dùng kích thước lưới là 4), hình vuông mà bạn kết xuất sẽ có kích thước bằng 1/4 kích thước ban đầu. Đây là lựa chọn hoàn hảo nếu bạn muốn đặt 4 hình ả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 biến đồng nhất trong chương trình đổ bóng không kết nối biến đó với vùng đệm mà bạn đã tạo. Để làm được việc đó, bạn cần tạo và thiết lập 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 cung cấp cho chương trình đổ bóng cùng một lúc. Nó có thể bao gồm một số loại vùng đệm, chẳng hạn như vùng đệm đồng nhất và các tài nguyên khác như hoạ tiết và bộ lấy mẫu không được đề cập ở đây nhưng là những phần phổ biến của kỹ thuật kết xuất WebGPU.
- Tạo một nhóm liên kết với vùng đệm đồng nhất bằng cách thêm đoạn mã 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
hiện là tiêu chuẩn, bạn cũng cần có layout
mô tả những loại tài nguyên mà nhóm liên kết này chứa. Đây là điều mà bạn sẽ tìm hiểu sâu hơn trong bước tiếp theo, nhưng hiện tại, bạn có thể vui vẻ yêu cầu quy trình của mình cung cấp 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ạo bố cục nhóm liên kết tự động 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 yêu cầu nó getBindGroupLayout(0)
, trong đó 0
tương ứng với @group(0)
mà bạn đã nhập trong chương trình đổ bóng.
Sau khi chỉ định bố cục, bạn sẽ cung cấp một mảng entries
. Mỗi mục là một từ điển có ít nhất các giá trị sau:
binding
, tương ứng với giá trị@binding()
mà bạn đã nhập trong chương trình đổ bóng. Trong trường hợp này,0
.resource
, đây là tài nguyên thực tế mà bạn muốn hiển thị cho biến tại chỉ mục liên kết được chỉ định. Trong trường hợp này, vùng đệm đồng nhất của bạn.
Hàm này trả về một GPUBindGroup
, là một đối tượng xử lý bất biến, không rõ ràng. 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 nhóm đó được 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 một kích thước lưới mới, thì điều đó sẽ được phản ánh bằng các lệnh gọi vẽ trong tương lai bằng nhóm liên kết này.
Liên kết nhóm liên kết
Sau 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 này khi vẽ. Rất may là việc này khá đơn giản.
- Quay lại đường truyền kết xuất và thêm dòng mới này trước phương thức
draw()
:
index.html
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
0
được truyền dưới dạng đối số đầu tiên tương ứng với @group(0)
trong mã chương trình đổ bóng. Bạn đang nói rằng mỗi @binding
là một phần của @group(0)
sẽ sử dụng các tài nguyên trong nhóm liên kết này.
Giờ đây, vùng đệm đồng nhất đã được hiển thị cho chương trình đổ bóng của bạn!
- Làm mới trang, sau đó bạn sẽ thấy nội dung như sau:
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 uniform 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 mà bạn đang kết xuất cho phù hợp với mẫu lưới mong muốn. Để làm được điều đó, hãy cân nhắc chính xác những gì bạn muốn đạt được.
Bạn cần chia canvas thành các ô riêng lẻ. Để giữ quy ước rằng trục X tăng khi bạn di chuyển sang phải và trục Y tăng khi bạn di chuyển lên trên, giả sử ô đầu tiên nằm ở góc dưới cùng bên trái của canvas. Điều này sẽ tạo ra một bố cục như sau, với hình vuông hiện tại ở giữa:
Thử thách của bạn là tìm một phương thức trong chương trình đổ bóng cho phép bạn đặt hình vuông vào bất kỳ ô nào trong số đó dựa trên toạ độ ô.
Trước tiên, bạn có thể thấy rằng hình vuông của bạn không được căn chỉnh đúng cách với bất kỳ ô nào vì nó được xác định là bao quanh tâm của canvas. Bạn nên dịch chuyển hình vuông đi nửa ô để hình vuông nằm gọn trong các ô.
Một cách để khắc phục vấn đề này là cập nhật bộ đệm đỉnh của hình vuông. Bằng cách dịch chuyển các đỉnh sao cho góc dưới cùng bên 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 các đỉnh được xử lý trong chương trình đổ bóng, nên bạn có thể dễ dàng đẩy chúng vào đúng vị trí bằng mã chương trình đổ bóng!
- 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 phải một đơn vị (hãy nhớ rằng đây là một nửa không gian cắt) trước khi chia cho kích thước lưới. Kết quả là một hình vuông được căn chỉnh theo lưới ngay bên ngoài gốc.
Tiếp theo, vì hệ toạ độ của canvas đặt (0, 0) ở tâm và (-1, -1) ở dưới cùng bên trái, còn bạn muốn (0, 0) ở dưới cùng bên trái, nên bạn cần dịch vị trí của hình học theo (-1, -1) sau khi chia cho kích thước lưới để di chuyển vị trí đó vào góc.
- Dịch vị trí của hình học, chẳng hạn như:
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);
}
Giờ thì hình vuông của bạn đã được đặt đúng vị trí trong ô (0, 0)!
Nếu bạn muốn đặt nó vào một ô khác thì sao? Hãy tìm hiểu bằng cách khai báo một vectơ cell
trong chương trình đổ bóng và điền giá trị tĩnh như let cell = vec2f(1, 1)
vào vectơ đó.
Nếu bạn thêm điều đó vào gridPos
, thì điều đó 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 đi một đơn vị lưới (1/4 khung hình) cho mỗi ô. Có vẻ như bạn cần phải chia cho grid
một lần nữa!
- Thay đổi vị trí lưới, chẳng hạn như:
index.html (lệnh gọi createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1); // Cell(1,1) in the image above
let cellOffset = cell / grid; // Compute the offset to cell
let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!
return vec4f(gridPos, 0, 1);
}
Nếu làm mới ngay bây giờ, bạn sẽ thấy những thông tin sau:
Hm. Không phải nội dung bạn muốn.
Lý do là vì toạ độ canvas nằm trong khoảng từ -1 đến +1, nên thực tế là 2 đơn vị. Điều đó có nghĩa là nếu muốn di chuyển một đỉnh bằng 1/4 khung hình, 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à cách khắc phục cũng rất dễ dàng.
- 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 sẽ mang lại cho bạn chính xác những gì bạn muốn.
Ảnh chụp màn hình có dạng như sau:
Ngoài ra, giờ đây, bạn có thể đặt cell
thành bất kỳ giá trị nào trong phạm vi lưới, rồi làm mới để xem hình vuông được kết xuất ở vị trí mong muốn.
Vẽ các thực thể
Giờ đây, bạn có thể đặt hình vuông ở vị trí mình muốn bằng một chút toán học. Bước tiếp theo là hiển thị một hình vuông trong mỗi ô của lưới.
Một cách tiếp cận là ghi toạ độ ô vào vùng đệm đồng nhất, sau đó gọi draw một lần cho mỗi ô vuông trong lưới, cập nhật vùng đệm đồng nhất mỗi lần. Tuy nhiên, điều đó sẽ rất chậm vì GPU phải đợi toạ độ mới được JavaScript ghi mỗi lần. Một trong những yếu tố quan trọng để GPU hoạt động hiệu quả là giảm thiểu thời gian 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 gọi là tạo thực thể. Tạo thực thể là một cách để yêu cầu GPU vẽ nhiều bản sao của cùng một hình học bằng một lệnh gọi duy nhất đến draw
. Cách này nhanh hơn nhiều so với việc gọi draw
một lần cho mỗi bản sao. Mỗi bản sao của hình học được gọi là một thực thể.
- Để 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 lệnh 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 hệ thống vẽ 6 đỉnh (vertices.length / 2
) của hình vuông 16 lần (GRID_SIZE * GRID_SIZE
). Tuy nhiên, nếu làm mới trang, bạn vẫn thấy những nội dung sau:
Tại sao? Lý do là vì bạn vẽ cả 16 ô 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 theo từng phiên bản.
Trong chương trình đổ bóng, ngoài các thuộc tính đỉnh như pos
đến từ vùng đệm đỉnh, bạn cũng có thể truy cập vào những giá trị được gọi là giá trị tích hợp sẵn của WGSL. Đây là những giá trị do WebGPU tính toán và một trong những giá trị đó là instance_index
. instance_index
là một số 32 bit không có dấu từ 0
đến number of instances - 1
mà bạn có thể sử dụng trong lôgic chương trình đổ bóng. Giá trị này là như nhau đối với mọi đỉnh được xử lý thuộc cùng một thực thể. Điều đó có nghĩa là chương trình đổ bóng đỉnh của bạn được gọi 6 lần với instance_index
là 0
, một lần cho mỗi vị trí trong vùng đệm đỉnh. Sau đó, lặp lại 6 lần nữa với instance_index
là 1
, rồi 6 lần nữa với instance_index
là 2
, v.v.
Để xem hiệu ứng này, bạn phải thêm instance_index
tích hợp vào đầu vào của chương trình đổ bóng. Làm tương tự như vị trí, nhưng thay vì gắn thẻ bằng thuộc tính @location
, hãy dùng @builtin(instance_index)
, rồi đặt tên cho đối số theo ý bạn. (Bạn có thể gọi là instance
để khớp với mã ví dụ.) Sau đó, hãy dùng nó làm một phần của logic chương trình đổ bóng!
- 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 ngay bây giờ, bạn sẽ thấy rằng bạn thực sự có nhiều hình vuông! Nhưng bạn không thể nhìn thấy cả 16 người.
Đó là vì các toạ độ ô mà bạn tạo là (0, 0), (1, 1), (2, 2)... cho đến (15, 15), nhưng chỉ có 4 toạ độ đầu tiên trong số đó vừa với canvas. Để tạo lưới mà bạn muốn, bạn cần chuyển đổi instance_index
sao cho mỗi chỉ mục liên kết đến một ô riêng biệt trong lưới, như sau:
Phép tính cho việc này khá đơn giản. Đối với giá trị X của mỗi ô, bạn muốn 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ử %
. Đối với giá trị Y của mỗi ô, bạn muốn instance_index
chia cho chiều rộng lưới, loại bỏ mọi số dư phân số. Bạn có thể làm việc đó bằng hàm floor()
của WGSL.
- 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ó được lưới ô vuông mà mình mong đợi từ lâu!
- Và bây giờ, khi nó đang hoạt động, hãy quay lại và tăng kích thước lưới!
index.html
const GRID_SIZE = 32;
Tada! Giờ đây, bạn có thể tạo một lưới rất lớn và GPU trung bình của bạn vẫn có thể xử lý tốt. Bạn sẽ ngừng thấy các ô vuông riêng lẻ trước khi gặp phải bất kỳ điểm tắc nghẽn nào về hiệu suất GPU.
6. Thêm điểm: làm cho hình ảnh nhiều màu sắc hơn!
Đế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. Mặc dù lưới các ô vuông có cùng màu sắc vẫn có thể sử dụng được, nhưng nó không thực sự thú vị, phải không? Rất may là bạn có thể làm cho mọi thứ sáng hơn một chút bằng cách sử dụng thêm một chút toán học và mã chương trình đổ bóng!
Sử dụng cấu trúc trong chương trình đổ bóng
Cho đến nay, 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 và 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 là trả về dữ liệu đó. Trình đổ bóng đỉnh luô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 vị trí đó, bạn cần đặt vị trí đó vào một cấu trúc. Struct trong WGSL là các loại đối tượng được đặt tên chứa một hoặc nhiều thuộc tính được đặt tên. Bạn cũng có thể đánh dấu các thuộc tính bằng những thuộc tính như @builtin
và @location
. Bạn khai báo chúng bên ngoài mọi hàm, sau đó có thể truyền các thực thể của chúng vào và ra khỏi hàm khi cần. Ví dụ: hãy xem xét chương trình đổ bóng đỉnh hiện tại của bạn:
index.html (lệnh gọi createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
- Thể hiện điều tương tự bằng cách sử dụng các cấu trúc cho đầu vào và đầu ra của hàm:
index.html (lệnh gọi createShaderModule)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
return output;
}
Xin lưu ý rằng bạn cần tham chiếu đến vị trí đầu vào và chỉ mục phiên bản 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 một biến và có các thuộc tính riêng lẻ được đặt. Trong trường hợp này, việc sử dụng struct không có nhiều khác biệt, thậm chí còn khiến hàm chương trình đổ bóng dài hơn một chút. Tuy nhiên, khi chương trình đổ bóng của bạn trở nên phức tạp hơn, việc sử dụng struct có thể là một cách hiệu quả để sắp xếp dữ liệu.
Truyền dữ liệu giữa các hàm đỉnh và hàm mảnh
Xin lưu ý rằng hàm @fragment
của bạn đơn giản nhất có thể:
index.html (lệnh gọi createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
Bạn không lấy bất kỳ dữ liệu đầu vào nào và đang truyền một màu đồng nhất (đỏ) làm đầu ra. Tuy nhiên, nếu chương trình đổ bóng biết thêm về hình học mà chương trình này đang tô màu, thì bạn có thể sử dụng dữ liệu bổ sung đó để làm cho mọi thứ trở nên thú vị hơn một chút. Ví dụ: nếu bạn muốn thay đổi màu của từng ô vuông dựa trên toạ độ ô thì sao? Giai đoạn @vertex
biết ô nào đang được kết xuất; bạn chỉ cần truyền ô đó đến giai đoạn @fragment
.
Để truyền bất kỳ dữ liệu nào giữa các giai đoạn đỉnh và phân 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 ta chọn. Vì bạn muốn truyền toạ độ ô, hãy thêm toạ độ đó vào cấu trúc VertexOutput
từ trước, rồi đặt toạ độ đó trong hàm @vertex
trước khi bạn trả về.
- 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;
}
- Trong hàm
@fragment
, hãy nhận giá trị bằng cách thêm một đối số có cùng@location
. (Tên không bắt buộc phải khớp, nhưng bạn sẽ dễ dàng theo dõi mọi thứ hơn nếu tên khớp!)
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);
}
- Ngoài ra, bạn có thể sử dụng một cấu trúc thay thế:
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);
}
- Một lựa chọn khác là 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, nên bạn có thể sử dụng lại cấu trúc đầu ra của giai đoạn
@vertex
! Điều này giúp việc truyền các giá trị trở nên dễ dàng vì tên và vị trí luôn nhất quán.
index.html (lệnh gọi createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
Dù 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ố ô đó để ảnh hưởng đến màu sắc. Với bất kỳ mã nào ở trên, kết quả sẽ có dạng như sau:
Chắc chắn là giờ đây có nhiều màu sắc hơn, nhưng trông không đẹp mắt cho lắm. Có thể bạn thắc mắc tại sao chỉ có hàng bên trái và hàng dưới cùng là khác nhau. Đó là vì 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 đó đều được giới hạn trong phạm vi này. Mặt khác, giá trị ô của bạn nằm trong khoảng từ 0 đến 32 dọc theo mỗi trục. Vì vậy, bạn có thể thấy rằng hàng và cột đầu tiên ngay lập tức đạt đến giá trị 1 trên kênh màu đỏ hoặc xanh lục, và mọi ô sau đó đều được cố định ở cùng một giá trị.
Nếu muốn chuyển đổi màu mượt mà hơn, bạn cần trả về một giá trị phân số cho mỗi kênh màu, lý tưởng nhất là bắt đầu từ 0 và kết thúc ở 1 dọc theo mỗi trục, tức là bạn cần chia cho grid
một lần nữa!
- Thay đổi chương trình đổ bóng mảnh, chẳng hạn như:
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 rằng mã mới có mang đến cho bạn một dải màu chuyển sắc đẹp mắt hơn nhiều trên toàn bộ lưới.
Mặc dù đó chắc chắn là một điểm cải tiến, nhưng giờ đây lại có một góc tối không mong muốn ở phía dưới bên trái, nơi lưới chuyển sang màu đen. Khi bạn bắt đầu mô phỏng trò chơi Sự 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. Chúng ta nên làm sáng bức ảnh đó.
Rất may là bạn có một kênh màu chưa sử dụng (màu xanh dương) mà bạn có thể dùng. Hiệu ứng mà bạn muốn là màu xanh dương sáng nhất ở nơi các màu khác tối nhất, sau đó mờ dần khi các màu khác tăng cường độ. Cách dễ nhất để thực hiện việc đó là đặt kênh bắt đầu ở 1 và trừ đi một trong các giá trị ô. Giá trị này có thể là c.x
hoặc c.y
. Hãy thử cả hai rồi chọn một kiểu bạn thích!
- 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á đẹp!
Đây không phải là một bước quan trọng! Nhưng vì trông đẹp hơn nên chúng tôi đã đưa nó vào tệp nguồn của đ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 đều 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 sẽ 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!
Bạn chỉ cần tín hiệu bật/tắt cho mỗi ô, vì vậy, mọi lựa chọn cho phép bạn lưu trữ một mảng lớn gồm hầu hết 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 các vùng đệm đồng nhất! Mặc dù bạn có thể thực hiện việc đó, nhưng sẽ khó khăn hơn vì bộ đệm đồng nhất có kích thước hạn chế, không thể hỗ trợ các mảng có kích thước động (bạn phải chỉ định kích thước mảng trong chương trình đổ bóng) và không thể được ghi bởi chương trình đổ bóng điện toán. Mục cuối cùng là mục có nhiều vấn đề nhất, vì bạn muốn thực hiện mô phỏng Game of Life trên GPU trong một chương trình đổ bóng điện toán.
Rất may là có một lựa chọn bộ nhớ đệm khác giúp bạn tránh được tất cả những hạn chế đó.
Tạo vùng đệm lưu trữ
Bộ đệm lưu trữ là bộ đệm đa năng có thể được đọc và ghi trong chương trình đổ bóng tính toán, cũng như được đọc trong chương trình đổ bóng đỉnh. 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 với bộ nhớ chung hơn nhiều. Đó là những gì bạn dùng để lưu trữ trạng thái ô.
- Để tạo một vùng đệm lưu trữ cho trạng thái ô, hãy sử dụng đoạn mã tạo vùng đệm mà có lẽ bạn đã quen thuộc:
index.html
// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);
// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
label: "Cell State",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
Giống như với các vùng đệm đỉnh và vùng đệm đồng nhất, hãy gọi device.createBuffer()
với kích thước phù hợp, sau đó nhớ chỉ định mức sử dụng GPUBufferUsage.STORAGE
lần này.
Bạn có thể điền vào bộ đệm theo cách tương tự như trước đây bằng cách điền các giá trị vào TypedArray có cùng kích thước rồi 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.
- Kích hoạt mọi ô thứ ba bằng mã sau:
index.html
// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);
Đọc bộ đệm lưu trữ trong chương trình đổ bóng
Tiếp theo, hãy cập nhật chương trình đổ bóng để xem nội dung của vùng đệm lưu trữ trước khi bạn kết xuất lưới. Cách này rất giống với cách thêm màu đồng nhất trước đây.
- 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
, nhưng số @binding
cần phải khác. Loại var
là storage
, để phản ánh loại vùng đệm khác, và thay vì một vectơ duy nhất, loại mà bạn cung cấp cho cellState
là một mảng các giá trị u32
, để khớp với Uint32Array
trong JavaScript.
Tiếp theo, trong nội dung 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ì trạng thái hoạt động và không hoạt động mà bạn nhận được từ mảng là 1 hoặc 0, nên bạn có thể điều chỉnh tỷ lệ hình học theo trạng thái hoạt động! Nếu bạn tăng tỷ lệ lên 1, hình học sẽ không thay đổi. Nếu bạn tăng tỷ lệ lên 0, hình học sẽ thu gọn thành một điểm duy nhất, sau đó GPU sẽ loại bỏ điểm này.
- Cập nhật mã chương trình đổ bóng để điều chỉnh vị trí theo trạng thái hoạt động của ô. Giá trị trạng thái phải được truyền đến một
f32
để đáp ứng các yêu cầu về độ an toàn của loại 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 bộ nhớ đệm vào một nhóm liên kết. Vì đây là một phần của cùng một @group
như bộ đệm đồng nhất, nên hãy thêm phần này vào cùng một nhóm liên kết trong mã JavaScript.
- Thêm bộ nhớ đệm, như sau:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
},
// New entry!
{
binding: 1,
resource: { buffer: cellStateStorage }
}],
});
Đảm bảo rằng binding
của mục mới khớp với @binding()
của giá trị tương ứng trong chương trình đổ bóng!
Sau khi thực hiện xong, bạn có thể làm mới và thấy mẫu xuất hiện trong lưới.
Sử dụng mẫu bộ nhớ đệm ping-pong
Hầu hết các hoạt động mô phỏng như hoạt động bạn đang tạo thường sử dụng ít nhất hai bản sao trạng thái. Trong mỗi bước mô phỏng, các luồng này đọc từ một bản sao của trạng thái và ghi vào bản sao còn lại. Sau đó, ở bước tiếp theo, hãy đảo ngược và đọc từ trạng thái mà chúng đã ghi trước đó. Điều này thường được gọi là mẫu ping pong vì phiên bản mới nhất của trạng thái sẽ qua lại giữa các bản sao trạng thái ở mỗi bước.
Tại sao việc này lại cần thiết? Hãy xem một ví dụ đơn giản: giả sử bạn đang viết một chương trình mô phỏng rất đơn giản, trong đó bạn di chuyển mọi khối đang hoạt động sang phải một ô ở mỗi bước. Để dễ hiểu, bạn xác định dữ liệu và mô phỏng trong JavaScript:
// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];
function simulate() {
for (let i = 0; i < state.length-1; ++i) {
if (state[i] == 1) {
state[i] = 0;
state[i+1] = 1;
}
}
}
simulate(); // Run the simulation for one step.
Nhưng nếu bạn chạy mã đó, ô đang hoạt động sẽ di chuyển đến cuối mảng chỉ trong một bước! Tại sao? Vì bạn liên tục cập nhật trạng thái tại chỗ, nên bạn di chuyển ô đang hoạt động sang phải, sau đó bạn xem ô tiếp theo và... ơ hay! Đang hoạt động! Tốt hơn hết là bạn nên di chuyển nó sang phải một lần nữa. Việc bạn thay đổi dữ liệu cùng lúc với việc quan sát dữ liệu 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 chỉ bằ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);
- Hãy sử dụng mẫu này trong mã của riêng bạn bằng cách cập nhật việc phân bổ bộ nhớ đệm để tạo hai bộ nhớ đệ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,
})
];
- Để giúp hình dung sự khác biệt giữa hai vùng đệm, hãy điền dữ liệu khác nhau vào hai vùng đệm này:
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);
- Để hiện 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ó 2 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ỉ thực hiện một thao tác vẽ cho mỗi lần làm mới trang, nhưng giờ đây, bạn muốn cho thấy dữ liệu cập nhật theo thời gian. Để làm được việc đó, bạn cần có một vòng lặp kết xuất đơn giản.
Vòng lặp kết xuất là một vòng lặp lặp lại vô tận, vẽ nội dung của bạn 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 hiệu ứng động mượt mà sẽ sử dụng hàm requestAnimationFrame()
để lên lịch gọi lại với tốc độ tương tự như tốc độ làm mới màn hình (60 lần mỗi giây).
Ứng dụng này cũng có thể sử dụng dữ liệu đó, nhưng trong trường hợp này, bạn có thể muốn các bản cập nhật diễn ra theo các bước dài hơn để có thể dễ dàng theo dõi những gì mô phỏng đang làm. Thay vào đó, hãy tự quản lý vòng lặp để có thể kiểm soát tốc độ cập nhật mô phỏng.
- Trước tiên, hãy chọn tốc độ để mô phỏng cập nhật (200 mili giây là tốc độ phù hợp, nhưng bạn có thể chọn tốc độ 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 thành.
index.html
const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
- Sau đó, di chuyển tất cả mã mà bạn hiện đang dùng để kết xuất vào một hàm mới. Lên lịch để hàm đó lặp lại theo khoảng thời gian bạn muốn bằng
setInterval()
. Đảm bảo rằng hàm này cũng cập nhật số bước và dùng số bước đó để chọn nhóm liên kết nào trong hai nhóm liên kết cần liên kết.
index.html
// Move all of our rendering code into a function
function updateGrid() {
step++; // Increment the step count
// Start a render pass
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
storeOp: "store",
}]
});
// Draw the grid.
pass.setPipeline(cellPipeline);
pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
// End the render pass and submit the command buffer
pass.end();
device.queue.submit([encoder.finish()]);
}
// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);
Giờ đây, khi chạy ứng dụng, bạn sẽ thấy canvas lật qua lại giữa việc hiển thị hai vùng đệm trạng thái mà bạn đã tạo.
Đến đây là bạn đã hoàn tất phần hiển thị! Bạn đã sẵn sàng hiển thị đầu ra của mô phỏng Game of Life mà bạn sẽ tạo ở bước tiếp theo, nơi bạn cuối cùng sẽ bắt đầu sử dụng chương trình đổ bóng điện toán.
Rõ ràng là khả năng kết xuất của WebGPU còn nhiều hơn so với phần nhỏ mà bạn đã khám phá ở đây, nhưng phần còn lại nằm ngoài phạm vi của lớp học lập trình này. Hy vọng rằng điều này sẽ giúp bạn hiểu rõ hơn về cách hoạt động của quá trình kết xuất WebGPU, mặc dù vậy, điều này sẽ giúp bạn dễ dàng nắm bắt các kỹ thuật nâng cao hơn như kết xuất 3D.
8. Chạy mô phỏng
Giờ đây, đến phần quan trọng cuối cùng của câu đố: thực hiện mô phỏng Game of Life trong chương trình đổ bóng điện toán!
Cuối cùng thì bạn cũng có thể sử dụng chương trình đổ bóng điện toán!
Bạn đã tìm hiểu một cách trừu tượng về chương trình đổ bóng điện toán trong suốt lớp học lập trình này, nhưng chính xác thì chúng là gì?
Chương trình đổ bóng điện toán tương tự như chương trình đổ bóng đỉnh và chương trình đổ bóng mảnh ở chỗ chúng được thiết kế để chạy với khả năng song song cực cao trên GPU, nhưng không giống như 2 giai đoạn chương trình đổ bóng còn lại, chúng không có một bộ đầu vào và đầu ra cụ thể. Bạn đang đọc và ghi dữ liệu hoàn toàn từ các nguồn mà bạn chọn, chẳng hạn như vùng đệm lưu trữ. Điều này có nghĩa là thay vì thực thi một lần cho mỗi đỉnh, thực thể hoặc pixel, bạn phải cho biết số lần gọi hàm chương trình đổ bóng mà bạn muốn. Sau đó, khi chạy chương trình đổ bóng, bạn sẽ biết được lệnh gọi nào đang được xử lý và bạn có thể quyết định dữ liệu mà bạn sẽ truy cập cũng như các thao tác mà bạn sẽ thực hiện từ đó.
Bạn phải tạo chương trình đổ bóng tính toán trong một mô-đun chương trình đổ bóng, giống như chương trình đổ bóng đỉnh và chương trình đổ bóng mảnh. Vì vậy, hãy thêm chương trình đó 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 tính toán cần được đánh dấu bằng thuộc tính @compute
.
- Tạo một chương trình đổ bóng tính 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 thường được dùng cho đồ hoạ 3D, nên chương trình đổ bóng điện toán được cấu trúc sao cho bạn có thể yêu cầu chương trình đổ bóng được gọi một số lần cụ thể dọc theo 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ỗi lần cho một ô trong mô phỏng.
Do bản chất của cấu trúc phần cứng GPU, lưới này được chia thành nhóm công việc. Một nhóm công việc có kích thước X, Y và Z, và mặc dù mỗi kích thước có thể là 1, nhưng thường có những lợi ích về hiệu suất khi bạn tạo nhóm công việc lớn hơn một chút. Đối với chương trình đổ bóng, hãy chọn kích thước nhóm công việc là 8 x 8 (tương đối tuỳ ý). Điều này rất hữu ích để theo dõi trong mã JavaScript của bạn.
- Xác định một hằng số cho quy mô nhóm công việc, chẳng hạn như:
index.html
const WORKGROUP_SIZE = 8;
Bạn cũng cần thêm kích thước nhóm làm việc vào chính hàm chương trình đổ bóng. Bạn có thể thực hiện việc này bằng cách sử dụng hằng hàm mẫu của JavaScript để dễ dàng sử dụng hằng số mà bạn vừa xác định.
- 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 (Compute createShaderModule call)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
Điều này cho biết chương trình đổ bóng rằng công việc được thực hiện bằng hàm này được thực hiện theo các nhóm (8 x 8 x 1). (Mọi trục bạn bỏ qua sẽ mặc định là 1, mặc dù bạn phải chỉ định ít nhất trục X.)
Giống như các giai đoạn đổ bóng khác, có nhiều giá trị @builtin
mà bạn có thể chấp nhận làm đầu vào cho hàm đổ bóng điện toán để cho biết bạn đang ở lệnh gọi nào và quyết định công việc bạn cần làm.
- Thêm giá trị
@builtin
, như sau:
index.html (Compute createShaderModule call)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Bạn truyền vào global_invocation_id
tích hợp sẵn. Đây là một vectơ ba chiều gồm các số nguyên không dấu cho biết vị trí của bạn 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 sẽ nhận được các số như (0, 0, 0)
, (1, 0, 0)
, (1, 1, 0)
... cho đến (31, 31, 0)
. Điều này có nghĩa là bạn có thể coi đó là chỉ mục ô mà bạn sẽ thao tác!
Chương trình đổ bóng điện toán cũng có thể sử dụng các giá trị đồng nhất mà bạn sử dụng giống như trong chương trình đổ bóng đỉnh và mảnh.
- Sử dụng một uniform 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 (Compute createShaderModule call)
@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 ô dưới dạng vùng đệm lưu trữ. Nhưng trong trường hợp này, bạn cần có cả hai! 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 các giá trị vào vùng đệm hoặc hoạ tiết lưu trữ là cách duy nhất để nhận 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 lưu trữ cung cấp trạng thái hiện tại của lưới và một vùng đệm lưu trữ mà bạn ghi trạng thái mới của lưới vào.
- 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 (Compute createShaderModule call)
@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>
, khiến vùng đệm này ở trạng thái chỉ đọ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 đầ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 để liên kết chỉ mục ô với mảng lưu trữ tuyến tính. Về cơ bản, đây là thao tác 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ó đến một ô lưới 2D. (Xin lưu ý rằng thuật toán của bạn cho việc đó là vec2f(i % grid.x, floor(i / grid.x))
.)
- Viết một hàm để đi theo hướng ngược lại. Hàm này lấy giá trị Y của ô, nhân giá trị đó với chiều rộng của lưới, rồi cộng thêm giá trị X của ô.
index.html (Compute createShaderModule call)
@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 liệu thuật toán có hoạt động hay không, hãy triển khai một thuật toán thực sự đơn giản: nếu một ô hiện đang bật, thì ô đó sẽ tắt và ngược lại. Đây chưa phải là Game of Life, nhưng đủ để cho thấy chương trình đổ bóng điện toán đang hoạt động.
- Thêm thuật toán đơn giản như sau:
index.html (Compute createShaderModule call)
@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;
}
}
Đến đây là hết phần giới thiệu về chương trình đổ bóng điện toán – hiện tại! Tuy nhiên, trước khi có thể xem kết quả, bạn cần thực hiện thêm một vài thay đổi nữa.
Sử dụng Nhóm liên kết và Bố cục quy trình
Một điều mà bạn có thể nhận thấy từ chương trình đổ bóng ở trên là chương trình này chủ yếu sử dụng cùng một dữ liệu đầu vào (các vùng đệm đồng nhất và vùng đệm lưu trữ) như quy trình kết xuất của bạn. Vậy bạn có thể nghĩ rằng bạn chỉ cần sử dụng cùng một nhóm liên kết là xong, đúng không? Tin vui là bạn có thể làm được! Bạn chỉ cần thiết lập theo cách thủ công thêm một chút để có thể làm việc đó.
Mỗi khi tạo một nhóm liên kết, bạn cần cung cấp một GPUBindGroupLayout
. Trước đây, bạn nhận được bố cục đó bằng cách gọi getBindGroupLayout()
trên quy trình kết xuất. Quy trình này sẽ tự động tạo bố cục đó vì bạn đã cung cấp layout: "auto"
khi tạo. Phương pháp đó hoạt động hiệu quả khi bạn chỉ sử dụng một quy trình duy nhất, nhưng nếu 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 rõ lý do, hãy xem xét điều này: trong các 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ữ, nhưng trong chương trình đổ bóng điện toán mà bạn vừa viết, bạn cần một vùng đệm lưu trữ thứ hai. Vì hai chương trình đổ bóng sử dụng cùng các giá trị @binding
cho vùng đệm lưu trữ đồng nhất và vùng đệm lưu trữ đầ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à quy trình này không sử dụng. Bạn muốn tạo một bố cục mô tả tất cả tài nguyên có trong nhóm liên kết, chứ không chỉ những tài nguyên được một quy trình cụ thể sử dụng.
- Để 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ư cấu trúc tạo chính nhóm liên kết, trong đó bạn mô tả 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 có và cách sử dụng tài nguyên đó thay vì cung cấp chính tài nguyên.
Trong mỗi mục nhập, bạn sẽ cung cấp số binding
cho tài nguyên. Số này (như bạn đã tìm hiểu khi tạo nhóm liên kết) khớp với giá trị @binding
trong chương trình đổ bóng. Bạn cũng cung cấp visibility
, là các cờ GPUShaderStage
cho biết giai đoạn đổ 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 lưu trữ đầu tiên đều có thể truy cập trong các chương trình đổ bóng đỉnh và chương trình đổ bóng điện toán, nhưng vùng đệm lưu trữ thứ hai chỉ cần có thể truy cập trong các 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 những gì bạn cần hiển thị. Ở đây, cả 3 tài nguyên đều là vùng đệm, vì vậy, bạn dùng khoá buffer
để xác định các lựa chọn cho từng tài nguyên. Các lựa chọn khác bao gồm những lựa chọn như texture
hoặc sampler
, nhưng bạn không cần những lựa chọn đó ở đây.
Trong từ điển vùng đệm, bạn đặt các lựa chọn như type
vùng đệm được dùng. Giá trị mặc định là "uniform"
, vì vậy, bạn có thể để trống từ điển để liên kết 0. (Tuy nhiên, bạn phải đặt ít nhất buffer: {}
để mục nhập được xác định là vùng đệm.) Liên kết 1 có kiểu "read-only-storage"
vì bạn không sử dụng liên kết này với quyền truy cập read_write
trong chương trình đổ bóng và liên kết 2 có kiểu "storage"
vì bạn có sử dụng liên kết này với quyền truy cập read_write
!
Sau khi tạo bindGroupLayout
, bạn có thể truyền bindGroupLayout
vào khi tạo các nhóm liên kết thay vì truy vấn nhóm liên kết từ quy trình. Khi làm như vậy, bạn cần thêm một mục bộ nhớ đệm mới vào mỗi nhóm liên kết để khớp với bố cục mà bạn vừa xác định.
- Cập nhật việc tạo nhóm liên kết, chẳng hạn như:
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, khi nhóm liên kết đã được cập nhật để sử dụng bố cục nhóm liên kết rõ ràng này, bạn cần cập nhật quy trình kết xuất để sử dụng cùng một nhóm.
- Tạo một
GPUPipelineLayout
.
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
Bố cục quy trình là danh sách 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)
.)
- Sau khi có bố cục quy trình, hãy cập nhật quy trình kết xuất để sử dụng bố cục đó thay vì
"auto"
.
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: pipelineLayout, // Updated!
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
Tạo quy trình điện toán
Giống như bạn cần một quy trình kết xuất để sử dụng chương trình đổ bóng đỉnh và chương trình đổ bóng mảnh, bạn cần một quy trình điện toán để sử dụng chương trình đổ bóng điện toán. May mắn thay, quy trình tính toán ít phức tạp hơn nhiều so với 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 một quy trình tính 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 pipelineLayout
mới thay vì "auto"
, giống như trong quy trình kết xuất đã cập nhật. Điều này đảm bảo rằng cả quy trình kết xuất và quy trình tính toán đều có thể sử dụng cùng một nhóm liên kết.
Thẻ tính toán
Đến đây là bạn đã có thể sử dụng quy trình tính toán! Vì bạn 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ả 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
.
- 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 bằng bộ mã hoá đó (trước
step++
).
index.html
// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();
const computePass = encoder.beginComputePass();
// Compute work will go here...
computePass.end();
// Existing lines
step++; // Increment the step count
// Start a render pass...
Giống như các quy trình điện toán, các lượt điện toán dễ bắt đầu hơn nhiều so với các lượt kết xuất tương ứng vì bạn không cần lo lắng về bất kỳ tệp đính kèm nào.
Bạn muốn thực hiện lượt truyền tính toán trước lượt truyền kết xuất vì điều này cho phép lượt truyền kết xuất sử dụng ngay kết quả mới nhất từ lượt truyền tính 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.
- Tiếp theo, hãy đặt quy trình và nhóm liên kết 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ư khi bạn thực hiện 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();
- 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 biết số lượng nhóm công việc mà 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 là số lần gọi! Thay vào đó, đây là số lượng nhóm công việc cần thực thi, do @workgroup_size
xác định trong chương trình đổ bóng của bạn.
Nếu muốn chương trình đổ bóng thực thi 32x32 lần để bao phủ toàn bộ lưới và quy mô nhóm làm việc là 8x8, bạn cần gửi 4x4 nhóm làm việc (4 * 8 = 32). Đó là lý do bạn chia kích thước lưới cho kích thước nhóm công việc và truyền giá trị đó vào dispatchWorkgroups()
.
Giờ đây, bạn có thể làm mới trang một lần nữa và sẽ thấy lưới đảo ngược sau mỗi lần cập nhật.
Triển khai thuật toán cho trò chơi Sự sống
Trước khi cập nhật chương trình đổ bóng điện toán để triển khai thuật toán cuối cùng, bạn nên quay lại mã đang khởi tạo nội dung bộ đệm lưu trữ và cập nhật mã đó để tạo bộ đệ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 những điểm bắt đầu thú vị cho trò chơi Cuộc sống.) Bạn có thể ngẫu nhiên hoá các giá trị theo ý muốn, nhưng có một cách dễ dàng để bắt đầu và mang lại kết quả hợp lý.
- Để 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, bạn có thể triển khai logic cho mô phỏng Trò chơi cuộc sống. Sau tất cả những gì cần thiết để đạt được kết quả 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 ô lân cận đang hoạt động đối với một ô bất kỳ. Bạn không quan tâm đến những tài khoản đang hoạt động, mà chỉ quan tâm đến số lượng.
- Để dễ dàng lấy dữ liệu ô lân cận, hãy thêm một hàm
cellActive
trả về giá trịcellStateIn
của toạ độ đã cho.
index.html (Compute createShaderModule call)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
Hàm cellActive
sẽ trả về một nếu ô đang hoạt động. Vì vậy, việc thêm giá trị trả về của lệnh gọi cellActive
cho tất cả 8 ô xung quanh sẽ cho biết có bao nhiêu ô lân cận đang hoạt động.
- Tìm số lượng hàng xóm đang hoạt động, chẳng hạn như:
index.html (Compute createShaderModule call)
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 rìa của bảng? Theo logic cellIndex()
của bạn hiện tại, nó sẽ tràn sang hàng tiếp theo hoặc hàng trước đó, hoặc chạy ra ngoài vùng đệm!
Đối với Trò chơi cuộc sống, một cách phổ biến và dễ dàng để giải quyết vấn đề này là để các ô ở rìa lưới coi các ô ở rìa đối diện của lưới là ô lân cận, tạo ra một loại hiệu ứng bao quanh.
- Hỗ trợ tính năng bao bọc lưới bằng một thay đổi nhỏ đối với hàm
cellIndex()
.
index.html (Compute createShaderModule call)
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ử %
để bao bọc ô 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 các giới hạn của bộ nhớ đệm. Nhờ đó, 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 sau:
- Mọi ô có ít hơn 2 ô lân cận sẽ ngừng hoạt động.
- Mọi ô đang hoạt động có 2 hoặc 3 ô lân cận sẽ vẫn hoạt động.
- Mọi ô không hoạt động có đúng 3 ô lân cận sẽ trở thành ô hoạt động.
- Mọi ô có nhiều hơn 3 ô lân cận sẽ ngừng hoạt động.
Bạn có thể thực hiện việc này bằng một loạt câu lệnh if, nhưng WGSL cũng hỗ trợ câu lệnh switch, rất phù hợp với logic này.
- Triển khai logic Game of Life, như sau:
index.html (Compute createShaderModule call)
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 trình đổ bóng điện toán cuối cùng hiện có dạng như sau:
index.html
const simulationShaderModule = device.createShaderModule({
label: "Life simulation shader",
code: `
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: {
cellStateOut[i] = cellStateIn[i];
}
case 3: {
cellStateOut[i] = 1;
}
default: {
cellStateOut[i] = 0;
}
}
}
`
});
Và... chỉ vậy thôi! Bạn đã hoàn tất! Làm mới trang và xem ô tự động mới tạo phát triển!
9. Xin chúc mừng!
Bạn đã tạo một phiên bản mô phỏng trò chơi Cuộc sống của Conway kiểu cũ, chạy hoàn toàn trên GPU bằng API WebGPU!
Tiếp theo là gì?
- Xem Các mẫu WebGPU
Tài liệu đọc thêm
- WebGPU – Tất cả các lõi, không có canvas nào
- Raw WebGPU
- Kiến thức cơ bản về WebGPU
- Các phương pháp hay nhất về WebGPU