Partikel GPGPU dengan TSL & WebGPU
Dalam pelajaran ini, kita akan membuat ratusan ribu partikel melayang untuk merender model 3D dan teks 3D menggunakan Three Shading Language (TSL) dan WebGPU.
Alih-alih menggunakan wajah, kita menggunakan banyak partikel, memungkinkan kita untuk beralih secara mulus antara model yang berbeda.
Model 3D rubah, buku, dan teks 3D dirender dengan partikel GPGPU! 🚀
Sistem Partikel GPGPU
Sebelum kita masuk ke kode, mari luangkan waktu sejenak untuk memahami apa itu GPGPU dan bagaimana penggunaannya dalam Three.js.
Apa itu GPGPU?
GPGPU (General-Purpose computing on Graphics Processing Units) adalah teknik yang memanfaatkan daya pemrosesan paralel dari GPU untuk melakukan perhitungan yang biasanya ditangani oleh CPU.
Dalam Three.js, GPGPU sering digunakan untuk simulasi real-time, sistem partikel, dan fisika dengan menyimpan dan memperbarui data dalam tekstur daripada bergantung pada perhitungan yang bergantung pada CPU.
Teknik ini memungkinkan shader memiliki kemampuan memory dan compute, yang memungkinkannya melakukan perhitungan kompleks dan menyimpan hasilnya dalam tekstur tanpa intervensi CPU.
Ini memungkinkan perhitungan skala besar yang sangat efisien langsung di GPU.
Berkat TSL, proses untuk membuat simulasi GPGPU menjadi lebih mudah dan lebih intuitif. Dengan menggabungkan node storage dan buffer ke dalam fungsi compute, kita dapat membuat simulasi kompleks dengan kode minimal.
Berikut adalah beberapa ide proyek yang dapat menggunakan GPGPU:
- Sistem partikel
- Simulasi fluida
- Simulasi fisika
- Simulasi boid
- Pemrosesan gambar
Saatnya beralih dari teori ke praktik! Mari kita buat sistem partikel GPGPU menggunakan TSL dan WebGPU.
Sistem Partikel
Paket awal ini adalah template WebGPU ready yang didasarkan pada implementasi pelajaran WebGPU/TSL.
Mari kita ganti mesh berwarna pink dengan komponen baru bernama GPGPUParticles
. Buat file baru bernama GPGPUParticles.jsx
di folder src/components
dan tambahkan kode berikut:
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 });
Tidak ada yang baru di sini, kita membuat komponen GPGPUParticles
yang menggunakan Sprite dengan SpriteNodeMaterial
untuk merender partikel.
Keuntungan menggunakan Sprite
dibandingkan InstancedMesh
adalah lebih ringan dan datang dengan efek billboard secara default.
Mari kita tambahkan komponen GPGPUParticles
ke dalam komponen 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> */} </> ); };
Kita bisa menyingkirkan komponen mesh dan environment.
Kita dapat melihat kotak di tengah layar, ini adalah partikel white sprite. Semuanya berada pada posisi yang sama.
Saatnya untuk mengatur sistem partikel kita!
Buffer / Storage / Instanced Array
Untuk simulasi GPGPU kita, kita memerlukan partikel-partikel kita untuk mengingat position, velocity, age, dan color tanpa menggunakan CPU.
Beberapa hal tidak memerlukan kita untuk menyimpan data. Kita dapat menghitung color berdasarkan age yang dikombinasikan dengan uniforms. Dan kita dapat menghasilkan velocity secara acak menggunakan nilai seed yang tetap.
Tetapi untuk position, karena posisi target dapat berkembang, kita perlu menyimpannya dalam buffer. Sama halnya untuk age, kita ingin mengelola siklus hidup partikel-partikel di GPU.
Untuk menyimpan data di GPU, kita dapat menggunakan storage node. Ini memungkinkan kita untuk menyimpan sejumlah besar data terstruktur yang dapat diperbarui pada GPU.
Agar bisa menggunakan dengan kode minimal, kita akan menggunakan fungsi TSL InstancedArray yang bergantung pada storage node.
Bagian dari Three.js nodes ini belum didokumentasikan, dengan mendalami contoh dan kode sumber kita dapat memahami bagaimana cara kerjanya.
Mari kita siapkan buffer kita di useMemo
di mana kita meletakkan shader nodes kita:
// ... 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
adalah fungsi TSL yang membuat buffer dengan ukuran dan tipe yang ditentukan.
Kode yang sama menggunakan storage node akan terlihat seperti ini:
import { storage } from "three/tsl"; import { StorageInstancedBufferAttribute } from "three/webgpu"; const spawnPositionsBuffer = storage( new StorageInstancedBufferAttribute(nbParticles, 3), "vec3", nbParticles );
Dengan buffer-buffer ini, kita dapat menyimpan position dan age dari setiap partikel dan memperbaruinya di GPU.
Untuk mengakses data di buffer, kita dapat menggunakan .element(index)
untuk mendapatkan nilai pada indeks yang ditentukan.
Dalam kasus kita, kita akan menggunakan instancedIndex
dari setiap partikel untuk mengakses data di 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
adalah fungsi TSL bawaan yang mengembalikan indeks dari instance yang sedang diproses saat ini.
Ini memungkinkan kita untuk mengakses data di buffer untuk setiap partikel.
Kita tidak memerlukannya untuk proyek ini, tetapi dengan bisa mengakses data dari instance lain, kita dapat menciptakan interaksi kompleks antara partikel-partikel. Misalnya, kita dapat menciptakan kawanan burung yang saling mengikuti satu sama lain.
Inisialisasi Perhitungan
Untuk mengatur posisi dan umur partikel, kita perlu membuat fungsi compute yang akan dieksekusi pada GPU pada awal simulasi.
Untuk membuat fungsi compute dengan TSL, kita perlu menggunakan node Fn
, memanggilnya, dan menggunakan metode compute
yang dikembalikannya dengan jumlah partikel:
// ... 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); // ... }, []); // ... }; // ...
Kita membuat fungsi computeInit
yang memberi nilai acak kepada buffer kita.
Fungsi randValue
tidak ada, kita perlu membuatnya sendiri.
Fungsi yang tersedia adalah:
hash(seed)
: Untuk menghasilkan nilai acak berdasarkan seed antara 0 dan 1.range(min, max)
: Untuk menghasilkan nilai acak antara min dan max.
Info lebih lanjut pada Three.js Shading Language Wiki.
Namun fungsi range
mendefinisikan atribut dan menyimpan nilainya. Bukan itu yang kita inginkan.
Mari kita buat fungsi randValue
yang akan mengembalikan nilai acak antara min dan max berdasarkan 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 }) => { // ... }; // ...
Fungsi randValue
menerima nilai min
, max
, dan seed
dan mengembalikan nilai acak antara min dan max berdasarkan seed.
/*#__PURE__*/
adalah komentar yang digunakan untuk tree-shaking. Ini memberitahu bundler untuk menghapus fungsi jika tidak digunakan. Detail lebih lanjut di sini.
Sekarang kita perlu memanggil fungsi computeInit
kita. Ini adalah tugas untuk renderer. Mari kita impor dengan useThree
dan panggil segera setelah deklarasinya:
// ... 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); // ... }, []); // ... }; // ...
Untuk dapat melihatnya, kita perlu mengubah positionNode
dari SpriteNodeMaterial
untuk menggunakan buffer spawnPosition
dan offsetPosition
.
// ... export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... return { uniforms, nodes: { positionNode: spawnPosition.add(offsetPosition), colorNode: uniforms.color, }, }; }, []); // ... }; // ...
Kita menetapkan positionNode
menjadi jumlah dari vektor spawnPosition
dan offsetPosition
.
Apakah ini berhasil? Mari kita periksa!
Mayday! Semuanya putih! ⬜️
Mungkin kita perlu zoom-out sedikit?
Syukurlah, kita bisa melihat partikelnya, mereka hanya terlalu besar hingga mengecat seluruh layar! 😮💨
Mari kita perbaiki dengan mengatur scaleNode
dengan nilai acak:
// ... 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> </> ); }; // ...
Dalam skenario ini, kita dapat menggunakan fungsi range
untuk menghasilkan nilai acak antara 0.001
dan 0.01
.
Sempurna, kita memiliki partikel dengan ukuran dan posisi berbeda! 🎉
Namun, ini agak statis, kita perlu menambahkan beberapa gerakan padanya.
Perbarui perhitungan
Seperti yang kita lakukan untuk fungsi inisialisasi perhitungan, mari kita buat fungsi pembaruan perhitungan yang akan dieksekusi pada setiap frame.
Dalam fungsi ini, kita akan memperbarui position dan age dari partikel-partikel:
// ... 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.