Partikel GPGPU dengan TSL & WebGPU

Starter pack

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:

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.

GPGPU particles starter pack

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.

White sprite particles

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!

Partikel dengan posisi acak sepenuhnya putih

Mayday! Semuanya putih! ⬜️

Mungkin kita perlu zoom-out sedikit?

Partikel dengan posisi acak yang diperbesar

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

    // ...
  }, []);

  // ...
};

// ...
Three.js logoReact logo

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
Unlock the Full Course – Just $85

One-time payment. Lifetime updates included.