Hạt GPGPU với TSL & WebGPU

Starter pack

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ớ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 storagebuffer 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:

Đã đế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.

GPGPU particles starter pack

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 meshenvironment.

White sprite particles

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ọ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í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 spawnPositionoffsetPosition.

// ...

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 spawnPositionoffsetPosition.

Nó hoạt động chứ? Hãy kiểm tra!

Các hạt với vị trí ngẫu nhiên màu trắng hoàn toàn

Mayday! Tất cả đều màu trắng! ⬜️

Phóng to ra một chút?

Các hạt với vị trí ngẫu nhiên phóng to ra

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.0010.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í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);

    // ...
  }, []);

  // ...
};

// ...
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.