Hạt GPGPU với TSL & WebGPU
Trong bài học này, chúng ta sẽ tạo ra hàng trăm nghìn hạt bay để hiển thị mô hình 3D và văn bản 3D sử dụng Three Shading Language (TSL) và WebGPU.
Thay vì sử dụng các mặt (faces), chúng ta sử dụng rất nhiều hạt, cho phép chuyển đổi mượt mà giữa các mô hình khác nhau.
Mô hình 3D của một con cáo, một quyển sách, và văn bản 3D được hiển thị với hạt GPGPU! 🚀
Hệ thống Hạt GPGPU
Trước khi đi sâu vào mã, hãy dành chút thời gian để hiểu GPGPU là gì và cách nó có thể được sử dụng trong Three.js.
GPGPU là gì?
GPGPU (General-Purpose computing on Graphics Processing Units) là kỹ thuật tận dụng sức mạnh xử lý song song của GPU để thực hiện các tính toán thường được xử lý bởi CPU.
Trong Three.js, GPGPU thường được sử dụng cho các mô phỏng thời gian thực, hệ thống hạt và vật lý bằng cách lưu trữ và cập nhật dữ liệu trong textures thay vì dựa vào các tính toán phụ thuộc CPU.
Kỹ thuật này cho phép shaders có khả năng bộ nhớ và tính toán, cho phép chúng thực hiện các phép tính phức tạp và lưu trữ kết quả trong textures mà không cần sự can thiệp của CPU.
Điều này cho phép thực hiện các phép tính quy mô lớn, hiệu quả cao trực tiếp trên GPU.
Nhờ có TSL, quy trình tạo mô phỏng GPGPU trở nên dễ dàng và trực quan hơn. Với các node storage và buffer kết hợp với các hàm compute, chúng ta có thể tạo ra các mô phỏng phức tạp với mã nguồn tối thiểu.
Dưới đây là một số ý tưởng về các dự án mà GPGPU có thể được sử dụng:
- Hệ thống hạt
- Mô phỏng dòng chảy
- Mô phỏng vật lý
- Mô phỏng boid
- Xử lý hình ảnh
Đã đến lúc chuyển từ lý thuyết sang thực hành! Hãy tạo một hệ thống hạt GPGPU sử dụng TSL và WebGPU.
Hệ thống hạt
Gói khởi đầu là một mẫu WebGPU sẵn sàng dựa trên triển khai WebGPU/TSL lesson.
Hãy thay thế mesh màu hồng bằng một thành phần mới có tên là GPGPUParticles
. Tạo một tệp mới có tên GPGPUParticles.jsx
trong thư mục src/components
và thêm đoạn mã sau:
import { extend } from "@react-three/fiber"; import { useMemo } from "react"; import { color, uniform } from "three/tsl"; import { AdditiveBlending, SpriteNodeMaterial } from "three/webgpu"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // uniforms const uniforms = { color: uniform(color("white")), }; return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); return ( <> <sprite count={nbParticles}> <spriteNodeMaterial {...nodes} transparent depthWrite={false} blending={AdditiveBlending} /> </sprite> </> ); }; extend({ SpriteNodeMaterial });
Không có gì mới ở đây, chúng ta đang tạo một thành phần GPGPUParticles
sử dụng Sprite với SpriteNodeMaterial
để render các hạt.
Lợi ích của việc sử dụng Sprite
thay vì InstancedMesh
là nó nhẹ hơn và đi kèm với hiệu ứng billboard mặc định.
Hãy thêm thành phần GPGPUParticles
vào thành phần Experience
:
import { OrbitControls } from "@react-three/drei"; import { GPGPUParticles } from "./GPGPUParticles"; export const Experience = () => { return ( <> {/* <Environment preset="warehouse" /> */} <OrbitControls /> <GPGPUParticles /> {/* <mesh> <boxGeometry /> <meshStandardMaterial color="hotpink" /> </mesh> */} </> ); };
Chúng ta có thể loại bỏ thành phần mesh và environment.
Chúng ta có thể thấy một hình vuông ở giữa màn hình, đây là các hạt sprite trắng. Tất cả nằm ở cùng một vị trí.
Đã đến lúc thiết lập hệ thống hạt của chúng ta!
Mảng đệm / Lưu trữ / Từng phần nhỏ
Đối với mô phỏng GPGPU của chúng ta, chúng ta cần các hạt của mình ghi nhớ vị trí, vận tốc, tuổi thọ và màu sắc mà không cần sử dụng CPU.
Một vài điều không yêu cầu chúng ta phải lưu trữ dữ liệu. Chúng ta có thể tính toán màu sắc dựa trên tuổi thọ kết hợp với uniforms. Và chúng ta có thể tạo ra vận tốc một cách ngẫu nhiên sử dụng một giá trị seed cố định.
Nhưng đối với vị trí, vì vị trí mục tiêu có thể thay đổi, chúng ta cần lưu trữ nó trong một buffer. Tương tự đối với tuổi thọ, chúng ta muốn xử lý chu kỳ sống của các hạt trong GPU.
Để lưu trữ dữ liệu trong GPU, chúng ta có thể sử dụng storage node. Điều này cho phép chúng ta lưu trữ một lượng lớn dữ liệu có cấu trúc có thể được cập nhật trên GPU.
Để sử dụng nó với mã tối thiểu, chúng ta sẽ sử dụng hàm TSL InstancedArray dựa vào storage node.
Phần này của Three.js nodes chưa được tài liệu hóa, chúng ta phải đào sâu qua các ví dụ và mã nguồn mới có thể hiểu cách thức hoạt động của nó.
Hãy chuẩn bị buffer của chúng ta trong useMemo
nơi chúng ta đặt các shader nodes:
// ... import { instancedArray } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // uniforms const uniforms = { color: uniform(color("white")), }; // buffers const spawnPositionsBuffer = instancedArray(nbParticles, "vec3"); const offsetPositionsBuffer = instancedArray(nbParticles, "vec3"); const agesBuffer = instancedArray(nbParticles, "float"); return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); // ... }; // ...
instancedArray
là một hàm TSL tạo ra buffer có kích thước và kiểu xác định.
Mã tương tự khi sử dụng storage node sẽ như sau:
import { storage } from "three/tsl"; import { StorageInstancedBufferAttribute } from "three/webgpu"; const spawnPositionsBuffer = storage( new StorageInstancedBufferAttribute(nbParticles, 3), "vec3", nbParticles );
Với các buffer này, chúng ta có thể lưu trữ vị trí và tuổi thọ của mỗi hạt và cập nhật chúng trong GPU.
Để truy cập dữ liệu trong các buffer, chúng ta có thể sử dụng .element(index)
để lấy giá trị tại chỉ số xác định.
Trong trường hợp của chúng ta, chúng ta sẽ sử dụng instancedIndex
của mỗi hạt để truy cập dữ liệu trong các buffer:
// ... import { instanceIndex } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // ... // buffers const spawnPositionsBuffer = instancedArray(nbParticles, "vec3"); const offsetPositionsBuffer = instancedArray(nbParticles, "vec3"); const agesBuffer = instancedArray(nbParticles, "float"); const spawnPosition = spawnPositionsBuffer.element(instanceIndex); const offsetPosition = offsetPositionsBuffer.element(instanceIndex); const age = agesBuffer.element(instanceIndex); return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); // ... }; // ...
instanceIndex
là hàm TSL tích hợp trả về chỉ số của phiên bản hiện tại đang được xử lý.
Điều này cho phép chúng ta truy cập dữ liệu trong các buffer cho mỗi hạt.
Chúng ta sẽ không cần điều này cho dự án này, nhưng bằng cách có thể truy cập dữ liệu của một phiên bản khác, chúng ta có thể tạo ra các tương tác phức tạp giữa các hạt. Ví dụ, chúng ta có thể tạo ra một đàn chim đi theo nhau.
Tính toán ban đầu
Để thiết lập vị trí và tuổi của các hạt, chúng ta cần tạo một hàm compute sẽ được thực thi trên GPU vào lúc bắt đầu của mô phỏng.
Để tạo một hàm compute với TSL, chúng ta cần sử dụng Fn
node, gọi nó và sử dụng phương thức compute
mà nó trả về với số lượng hạt:
// ... import { Fn } from "three/src/nodes/TSL.js"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // ... const spawnPosition = spawnPositionsBuffer.element(instanceIndex); const offsetPosition = offsetPositionsBuffer.element(instanceIndex); const age = agesBuffer.element(instanceIndex); // init Fn const lifetime = randValue({ min: 0.1, max: 6, seed: 13 }); const computeInit = Fn(() => { spawnPosition.assign( vec3( randValue({ min: -3, max: 3, seed: 0 }), randValue({ min: -3, max: 3, seed: 1 }), randValue({ min: -3, max: 3, seed: 2 }) ) ); offsetPosition.assign(0); age.assign(randValue({ min: 0, max: lifetime, seed: 11 })); })().compute(nbParticles); // ... }, []); // ... }; // ...
Chúng ta tạo một hàm computeInit
để gán giá trị ngẫu nhiên cho các buffers.
Hàm randValue
không tồn tại, chúng ta cần tự tạo nó.
Các hàm mà chúng ta có thể sử dụng là:
hash(seed)
: Để tạo giá trị ngẫu nhiên dựa trên một seed giữa 0 và 1.range(min, max)
: Để tạo giá trị ngẫu nhiên giữa min và max.
Thêm thông tin trên Three.js Shading Language Wiki.
Nhưng hàm range
định nghĩa một thuộc tính và lưu giá trị của nó. Không phải điều chúng ta muốn.
Hãy tạo một hàm randValue
sẽ trả về một giá trị ngẫu nhiên giữa min và max dựa trên một seed:
import { hash } from "three/tsl"; const randValue = /*#__PURE__*/ Fn(({ min, max, seed = 42 }) => { return hash(instanceIndex.add(seed)).mul(max.sub(min)).add(min); }); export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... }; // ...
Hàm randValue
nhận vào giá trị min
, max
, và seed
và trả về một giá trị ngẫu nhiên giữa min và max dựa trên seed.
/*#__PURE__*/
là một chú thích sử dụng cho tree-shaking. Nó cho trình bundler biết để loại bỏ hàm nếu không được sử dụng. Thêm chi tiết ở đây.
Bây giờ chúng ta cần gọi hàm computeInit
của mình. Đây là công việc của renderer. Hãy import nó với useThree
và gọi nó ngay sau khai báo của nó:
// ... import { useThree } from "@react-three/fiber"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const gl = useThree((state) => state.gl); const { nodes, uniforms } = useMemo(() => { // ... const computeInit = Fn(() => { // ... })().compute(nbParticles); gl.computeAsync(computeInit); // ... }, []); // ... }; // ...
Để có thể quan sát nó, chúng ta cần thay đổi positionNode
của SpriteNodeMaterial
để sử dụng buffers spawnPosition
và offsetPosition
.
// ... export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... return { uniforms, nodes: { positionNode: spawnPosition.add(offsetPosition), colorNode: uniforms.color, }, }; }, []); // ... }; // ...
Chúng ta đặt positionNode
bằng tổng vector của spawnPosition
và offsetPosition
.
Nó hoạt động chứ? Hãy kiểm tra!
Mayday! Tất cả đều màu trắng! ⬜️
Phóng to ra một chút?
Phù, chúng ta có thể thấy các hạt, chúng chỉ quá lớn nên đã tô đầy màn hình! 😮💨
Hãy khắc phục điều đó bằng cách đặt scaleNode
với một giá trị ngẫu nhiên:
// ... import { range } from "three/tsl"; // ... export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... const scale = vec3(range(0.001, 0.01)); return { uniforms, nodes: { positionNode: spawnPosition.add(offsetPosition), colorNode: uniforms.color, scaleNode: scale, }, }; }, []); return ( <> <sprite count={nbParticles}> <spriteNodeMaterial {...nodes} transparent depthWrite={false} blending={AdditiveBlending} /> </sprite> </> ); }; // ...
Trong trường hợp này, chúng ta có thể sử dụng hàm range
để tạo giá trị ngẫu nhiên giữa 0.001
và 0.01
.
Hoàn hảo, chúng ta đã có các hạt với kích thước và vị trí khác nhau! 🎉
Tuy nhiên, nó vẫn khá tĩnh, chúng ta cần thêm một chút chuyển động.
Cập nhật compute
Giống như chúng ta đã làm với hàm khởi tạo compute, hãy tạo một hàm cập nhật compute sẽ được thực thi trên mỗi frame.
Trong hàm này, chúng ta sẽ cập nhật vị trí và tuổi thọ của các particle:
// ... import { deltaTime, If } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... const instanceSpeed = randValue({ min: 0.01, max: 0.05, seed: 12 }); // update Fn const computeUpdate = Fn(() => { age.addAssign(deltaTime); If(age.greaterThan(lifetime), () => { age.assign(0); offsetPosition.assign(0); }); offsetPosition.addAssign(vec3(instanceSpeed)); })().compute(nbParticles); // ... }, []); // ... }; // ...
React Three Fiber: The Ultimate Guide to 3D Web Development
✨ You have reached the end of the preview ✨
Go to the next level with Three.js and React Three Fiber!
Get full access to this lesson and the complete course when you enroll:
- 🔓 Full lesson videos with no limits
- 💻 Access to the final source code
- 🎓 Course progress tracking & completion
- 💬 Invite to our private Discord community
One-time payment. Lifetime updates included.