VFX Engine

Starter pack

到目前为止,我们已经创建了自定义组件来在我们的3D场景中创建粒子。通常情况下,我们想要做几乎相同的事情:从空间中的一个点发射粒子并随时间对它们进行动画处理。(颜色、大小、位置等)

与其一遍又一遍地重复相同的代码,我们可以创建一个相对通用的 VFX 引擎,可用于创建不同类型的粒子效果。

它具有许多优点:

  • 可重用性:您可以使用相同的引擎在项目中创建不同类型的粒子效果。
  • 性能:该引擎可以优化以高效处理大量粒子,并将多个粒子系统合并为一个。
  • 灵活性:您可以通过更改引擎的参数轻松自定义粒子的行为。
  • 易用性:您只需几行代码即可创建复杂的粒子效果。
  • 避免代码重复:您不必多次编写相同的代码。

我们将在接下来的课程中使用此 VFX 引擎来创建各种效果。虽然您可以跳过此课程并直接使用引擎,但了解它的工作原理将帮助您更深入地掌握如何在3D项目中实现性能和灵活性。

准备好构建您的 VFX 引擎了吗?让我们开始吧!

GPU Particles

我们在前面的课程中看到了如何使用 drei<Instances /> 组件来在我们的3D场景中创建受控粒子。

但这种方法有一个主要限制:我们可以处理的粒子数量受到 CPU 的限制。粒子越多,CPU 就要越多地处理它们,这可能导致性能问题。

这是因为在底层,<Instances /> 组件会在其 useFrame 循环中计算每个 <Instance /> 的位置、颜色和大小。代码可以在这里查看。

对于我们的 VFX Engine,我们希望能够生成比 <Instances /> 组件可以处理的更多粒子。我们将使用 GPU 来处理粒子的颜色、位置和大小。这样我们就能够处理数以十万计的粒子(甚至上百万?👀)而不会有任何性能问题。

实例化 Mesh

虽然我们可以使用 SpritePoints 来创建粒子,但我们将使用 InstancedMesh

它不仅允许我们渲染简单的形状如点或精灵,还可以渲染像立方体、球体和自定义几何体这样的3D形状。

让我们在一个新的 vfxs 文件夹中创建一个名为 VFXParticles.jsx 的组件:

import { useMemo, useRef } from "react";
import { PlaneGeometry } from "three";

export const VFXParticles = ({ settings = {} }) => {
  const { nbParticles = 1000 } = settings;
  const mesh = useRef();
  const defaultGeometry = useMemo(() => new PlaneGeometry(0.5, 0.5), []);
  return (
    <>
      <instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}>
        <meshBasicMaterial color="orange" />
      </instancedMesh>
    </>
  );
};

我们创建了每个粒子的 geometry。在这种情况下,我们使用了一个简单的平面几何,其大小在两个轴上为 0.5。稍后我们将添加一个 prop 来传递我们想要的任何几何体。

instancedMesh 组件接受三个参数:

  • 粒子的 geometry
  • 粒子的 material。我们传递了 null 以在组件内部声明性地定义它。
  • 组件能够处理的 instances 的数量。对于我们来说,它表示可以同时显示的粒子的 最大数量

让我们在 Experience.jsx 文件中用 VFXParticles 组件替换橙色立方体:

// ...
import { VFXParticles } from "./vfxs/VFXParticles";

export const Experience = () => {
  return (
    <>
      {/* ... */}
      <VFXParticles />
    </>
  );
};

场景中间的橙色粒子

你可以看到场景中间有一个橙色粒子。这是我们的 VFXParticles 组件。

我们的粒子数量设置为 1000,但我们只能看到一个。这是因为所有粒子都被渲染在相同的位置信息 (0, 0, 0)。让我们改变这一点。

实例矩阵

实例化的 mesh 使用一个 matrix 来定义每个实例的位置、旋转和缩放。通过更新我们 mesh 的 instanceMatrix 属性,我们可以单独移动、旋转和缩放每个粒子。

对于每个实例,矩阵是一个 4x4 的矩阵,用于表示粒子的变换。Three.js 的 Matrix4 类允许我们 composedecompose 矩阵,以更便于理解的方式设置/获取粒子的位置、旋转和缩放。

VFXParticles 声明的顶部,让我们声明一些临时变量,以便在不频繁重新创建 Vectors 和 Matrices 的情况下操纵粒子:

// ...
import { Euler, Matrix4, PlaneGeometry, Quaternion, Vector3 } from "three";

const tmpPosition = new Vector3();
const tmpRotationEuler = new Euler();
const tmpRotation = new Quaternion();
const tmpScale = new Vector3(1, 1, 1);
const tmpMatrix = new Matrix4();

现在让我们创建一个 emit 函数来设置我们的粒子:

// ...
import { useEffect } from "react";

// ...

export const VFXParticles = ({ settings = {} }) => {
  // ...

  const emit = (count) => {
    for (let i = 0; i < count; i++) {
      const position = [
        randFloatSpread(5),
        randFloatSpread(5),
        randFloatSpread(5),
      ];
      const scale = [
        randFloatSpread(1),
        randFloatSpread(1),
        randFloatSpread(1),
      ];
      const rotation = [
        randFloatSpread(Math.PI),
        randFloatSpread(Math.PI),
        randFloatSpread(Math.PI),
      ];
      tmpPosition.set(...position);
      tmpRotationEuler.set(...rotation);
      tmpRotation.setFromEuler(tmpRotationEuler);
      tmpScale.set(...scale);
      tmpMatrix.compose(tmpPosition, tmpRotation, tmpScale);
      mesh.current.setMatrixAt(i, tmpMatrix);
    }
  };
  useEffect(() => {
    emit(nbParticles);
  }, []);
  // ...
};

emit 函数遍历我们想要发射的粒子数量,并为每个粒子设置随机的位置、旋转和缩放。然后,我们使用这些值组合矩阵,并将其设置到当前索引的实例。

场景中的随机粒子

你可以看到场景中的随机粒子。每个粒子都有随机的位置、旋转和缩放。

为了动画化我们的粒子,我们将定义如 lifetimespeeddirection 之类的属性,以便在 GPU 上进行计算。

在此之前,我们需要切换到一个自定义的 shader material 来处理这些属性,因为我们无法访问和控制 meshBasicMaterial 的属性。

粒子材质

我们的第一个目标是确保在 meshBasicMaterial 和新的 shaderMaterial 之间看不到任何变化。我们将创建一个简单的 shader material,使其与当前的 meshBasicMaterial 渲染粒子的方式相同。

VFXParticles 组件中,让我们创建一个新的 shader material:

// ...
import { shaderMaterial } from "@react-three/drei";
import { extend } from "@react-three/fiber";
import { Color } from "three";

const ParticlesMaterial = shaderMaterial(
  {
    color: new Color("white"),
  },
  /* glsl */ `
varying vec2 vUv;

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0));
  vUv = uv;
}
`,
  /* glsl */ `
uniform vec3 color;
varying vec2 vUv;


void main() {
  gl_FragColor = vec4(color, 1.0);
}`
);

extend({ ParticlesMaterial });

这是一个非常简单的 shader material,它接受一个 color uniform,并以此颜色渲染粒子。这里唯一的新东西是 instanceMatrix,我们用它来获取每个粒子的 positionrotationscale

请注意,我们不需要声明 instanceMatrix 属性,因为这是使用实例化WebGLProgram 的内置属性之一。更多信息可以在这里找到。

让我们用新的 ParticlesMaterial 替换 meshBasicMaterial

<instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}>
  <particlesMaterial color="orange" />
</instancedMesh>

场景中具有橙色颜色的随机粒子

完美!我们的 positionrotationscale 仍然正常工作。粒子以略微不同的橙色渲染。 这是因为我们在 shader material 中没有考虑环境。为了简化,我们会保持这样。

我们现在准备为粒子添加自定义属性以使它们动画化。

实例化缓冲区属性

目前,我们只使用了 instanceMatrix 属性,现在我们将添加自定义属性,以便对每个粒子进行更精细的控制。

为此,我们将使用 Three.js 中的 InstancedBufferAttribute

我们将为粒子添加以下属性:

  • instanceColor: 表示粒子颜色的vector3
  • instanceColorEnd: 表示粒子将随时间变换成的颜色的vector3
  • instanceDirection: 表示粒子移动方向的vector3
  • instanceSpeed: 定义粒子在其方向上移动速度的float
  • instanceRotationSpeed: 确定粒子每个轴旋转速度的vector3
  • instanceLifetime: 定义粒子生命周期的vector2。第一个值 (x) 是开始时间,第二个值 (y) 是生命周期/持续时间。结合一个时间 uniform,我们可以计算出粒子的年龄进度,以及其是活着还是死去

让我们为我们的属性创建不同的缓冲区:

// ...
import { useState } from "react";

// ...

export const VFXParticles = ({ settings = {} }) => {
  // ...

  const [attributeArrays] = useState({
    instanceColor: new Float32Array(nbParticles * 3),
    instanceColorEnd: new Float32Array(nbParticles * 3),
    instanceDirection: new Float32Array(nbParticles * 3),
    instanceLifetime: new Float32Array(nbParticles * 2),
    instanceSpeed: new Float32Array(nbParticles * 1),
    instanceRotationSpeed: new Float32Array(nbParticles * 3),
  });

  // ...
};

// ...

我使用 useState 来创建我们的属性缓冲区,以避免在每次渲染时重新创建它们。我选择不使用 useMemo 方法,因为在组件生命周期内更改粒子的最大数量不是我们想要处理的。

Float32Array 用于存储属性的值。我们将粒子数量乘以属性的组件数量,以获得数组中的值总数。

说明 instanceColor 属性的示意图

instanceColor 属性中,前 3 个值代表第一个粒子的颜色,下 3 个值代表第二个粒子的颜色,以此类推。

让我们首先熟悉一下 InstancedBufferAttribute 以及如何使用它。我们将实现 instanceColor 属性:

// ...
import { DynamicDrawUsage } from "three";

export const VFXParticles = ({ settings = {} }) => {
  // ...

  return (
    <>
      <instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}>
        <particlesMaterial color="orange" />
        <instancedBufferAttribute
          attach={"geometry-attributes-instanceColor"}
          args={[attributeArrays.instanceColor]}
          itemSize={3}
          count={nbParticles}
          usage={DynamicDrawUsage}
        />
      </instancedMesh>
    </>
  );
};

在我们的 <instancedMesh /> 中,我们添加了一个 <instancedBufferAttribute /> 组件以定义 instanceColor 属性。我们将其附加到 mesh 的 geometry-attributes-instanceColor 属性。我们将 attributeArrays.instanceColor 数组作为数据源,设置 itemSize3,因为我们有一个 vector3,将 count 设置为 nbParticles

usage 属性设置为 DynamicDrawUsage,以告诉渲染器数据将被频繁更新。其他可能的值和更多详细信息可以在这里找到。

我们不会在每帧更新它们,而是在每次发射新粒子时更新数据。足以将其视为 DynamicDrawUsage

很好,让我们在文件顶部创建一个虚拟的 tmpColor 变量来操作粒子的颜色:

// ...

const tmpColor = new Color();

现在,让我们更新 emit 函数以设置 instanceColor 属性:

const emit = (count) => {
  const instanceColor = mesh.current.geometry.getAttribute("instanceColor");

  for (let i = 0; i < count; i++) {
    // ...

    tmpColor.setRGB(Math.random(), Math.random(), Math.random());
    instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);
  }
};

我们首先从 mesh 的几何体中获取 instanceColor 属性。然后在我们想要发射的粒子数量上进行循环,为每个粒子设置一个随机颜色。

让我们更新 particlesMaterial 以使用 instanceColor 属性代替 color uniform:

const ParticlesMaterial = shaderMaterial(
  {
    // color: new Color("white"),
  },
  /* glsl */ `
varying vec2 vUv;
varying vec3 vColor;

attribute vec3 instanceColor;

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0));
  vUv = uv;
  vColor = instanceColor;
}
`,
  /* glsl */ `
varying vec3 vColor;
varying vec2 vUv;

void main() {
  gl_FragColor = vec4(vColor, 1.0);
}`
);
// ...

我们在顶点着色器中添加了 attribute vec3 instanceColor;,并设置了 vColor varying 以将颜色传递给片段着色器。然后,我们将 gl_FragColor 设置为 vColor 以用其颜色渲染粒子。

场景中具有随机颜色的随机粒子

我们已成功为每个粒子设置了随机颜色。粒子已用其颜色渲染。

很好,让我们为粒子添加其他属性。首先,更新我们的 emit 函数,以用随机值设置 instanceColorEndinstanceDirectioninstanceLifetimeinstanceSpeedinstanceRotationSpeed 属性:

const emit = (count) => {
  const instanceColor = mesh.current.geometry.getAttribute("instanceColor");
  const instanceColorEnd = mesh.current.geometry.getAttribute("instanceColorEnd");
  const instanceDirection = mesh.current.geometry.getAttribute("instanceDirection");
  const instanceLifetime = mesh.current.geometry.getAttribute("instanceLifetime");
  const instanceSpeed = mesh.current.geometry.getAttribute("instanceSpeed");
  const instanceRotationSpeed = mesh.current.geometry.getAttribute("instanceRotationSpeed");

  for (let i = 0; i < count; i++) {
    // ...

    tmpColor.setRGB(Math.random(), Math.random(), Math.random());
    instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);

    tmpColor.setRGB(Math.random(), Math.random(), Math.random());
    instanceColorEnd.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);

    const direction = [
      randFloatSpread(1),
      randFloatSpread(1),
      randFloatSpread(1),
    ];
    instanceDirection.set(direction, i * 3);

    const lifetime = [randFloat(0, 5), randFloat(0.1, 5)];
    instanceLifetime.set(lifetime, i * 2);

    const speed = randFloat(5, 20);
    instanceSpeed.set([speed], i);

    const rotationSpeed = [
      randFloatSpread(1),
      randFloatSpread(1),
      randFloatSpread(1),
    ];
    instanceRotationSpeed.set(rotationSpeed, i * 3);
  }
};

并为每个属性创建 instancedBufferAttribute 组件:

<instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}>
  <particlesMaterial color="orange" />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceColor"}
    args={[attributeArrays.instanceColor]}
    itemSize={3}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceColorEnd"}
    args={[attributeArrays.instanceColorEnd]}
    itemSize={3}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceDirection"}
    args={[attributeArrays.instanceDirection]}
    itemSize={3}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceLifetime"}
    args={[attributeArrays.instanceLifetime]}
    itemSize={2}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceSpeed"}
    args={[attributeArrays.instanceSpeed]}
    itemSize={1}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceRotationSpeed"}
    args={[attributeArrays.instanceRotationSpeed]}
    itemSize={3}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
</instancedMesh>

现在是为我们的粒子添加运动、颜色和生命周期逻辑的时候了。

粒子生命周期

为了计算我们的粒子的行为,我们需要将经过的时间传递给着色器。我们将使用 shaderMaterialuniforms 属性来将时间传递给它。

让我们更新 ParticlesMaterial 来添加一个 uTime uniform:

const ParticlesMaterial = shaderMaterial(
  {
    uTime: 0,
  },
  /* glsl */ `
uniform float uTime;
// ...
`,
  /* glsl */ `
// ...
`
);

useFrame 循环中,我们将更新 uTime uniform:

// ...
import { useFrame } from "@react-three/fiber";

// ...
export const VFXParticles = ({ settings = {} }) => {
  // ...

  useFrame(({ clock }) => {
    if (!mesh.current) {
      return;
    }
    mesh.current.material.uniforms.uTime.value = clock.elapsedTime;
  });

  // ...
};

顶点着色器中,我们将基于 uTime uniform 和 instanceLifetime 属性计算每个粒子的年龄进度。我们将把进度传递给片段着色器,以使用名为 vProgressvarying 动画粒子。

uniform float uTime;

varying vec2 vUv;
varying vec3 vColor;
varying vec3 vColorEnd;
varying float vProgress;

attribute float instanceSpeed;
attribute vec3 instanceRotationSpeed;
attribute vec3 instanceDirection;
attribute vec3 instanceColor;
attribute vec3 instanceColorEnd;
attribute vec2 instanceLifetime; // x: startTime, y: duration

void main() {
  float startTime = instanceLifetime.x;
  float duration = instanceLifetime.y;
  float age = uTime - startTime;
  vProgress = age / duration;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0));

  vUv = uv;
  vColor = instanceColor;
  vColorEnd = instanceColorEnd;
}

年龄通过从 uTime 中减去 startTime 来计算。然后通过将年龄除以 duration 来计算进度

现在在片段着色器中,我们将基于进度instanceColorinstanceColorEnd 之间插值粒子的颜色:

varying vec3 vColor;
varying vec3 vColorEnd;
varying float vProgress;
varying vec2 vUv;

void main() {
  vec3 finalColor = mix(vColor, vColorEnd, vProgress);
  gl_FragColor = vec4(finalColor, 1.0);
}

Particles changing color over time

我们可以看到粒子的颜色随时间变化,但我们面临一个问题。所有粒子在开始时都是可见的,而它们的开始时间是随机的。我们需要隐藏尚未出生的粒子。

为防止未出生和死亡的粒子被渲染,我们将在片段着色器中使用 discard 关键字:

// ...
void main() {
  if (vProgress < 0.0 || vProgress > 1.0) {
    discard;
  }
  // ...
}

discard 关键字告诉渲染器丢弃当前片段并不渲染它。

完美,现在我们的粒子随着时间的推移出生、存活和消亡。我们现在可以添加移动和旋转逻辑。

粒子运动

通过使用粒子的 方向速度年龄,我们可以计算出它们随时间变化的 位置

顶点着色器 中,我们调整 gl_Position 来考虑粒子的 方向速度

首先,我们标准化 方向,以避免当方向不是单位向量时粒子移动得更快:

vec3 normalizedDirection = length(instanceDirection) > 0.0 ? normalize(instanceDirection) : vec3(0.0);

然后,我们根据 速度年龄 计算粒子的 偏移量

vec3 offset = normalizedDirection * age * instanceSpeed;

获取 实例位置

vec4 startPosition = modelMatrix * instanceMatrix * vec4(position, 1.0);
vec3 instancePosition = startPosition.xyz;

并将 偏移量 应用于它:

vec3 finalPosition = instancePosition + offset;

最后,应用 modelViewMatrix 获取模型视图位置 mvPosition,将位置转换为 世界空间

vec4 mvPosition = modelViewMatrix * vec4(finalPosition, 1.0);

应用 projectionMatrix 将世界位置转换为 相机空间

gl_Position = projectionMatrix * mvPosition;

以下是我们完整的 顶点着色器 到目前为止:

uniform float uTime;

varying vec2 vUv;
varying vec3 vColor;
varying vec3 vColorEnd;
varying float vProgress;

attribute float instanceSpeed;
attribute vec3 instanceRotationSpeed;
attribute vec3 instanceDirection;
attribute vec3 instanceColor;
attribute vec3 instanceColorEnd;
attribute vec2 instanceLifetime; // x: startTime, y: duration

void main() {
  float startTime = instanceLifetime.x;
  float duration = instanceLifetime.y;
  float age = uTime - startTime;
  vProgress = age / duration;

  vec3 normalizedDirection = length(instanceDirection) > 0.0 ? normalize(instanceDirection) : vec3(0.0);
  vec3 offset = normalizedDirection * age * instanceSpeed;

  vec4 startPosition = modelMatrix * instanceMatrix * vec4(position, 1.0);
  vec3 instancePosition = startPosition.xyz;

  vec3 finalPosition = instancePosition + offset;
  vec4 mvPosition = modelViewMatrix * vec4(finalPosition, 1.0);
  gl_Position = projectionMatrix * mvPosition;

  vUv = uv;
  vColor = instanceColor;
  vColorEnd = instanceColorEnd;
}

粒子现在以不同速度向多个方向运动。这很混乱,但这是因为我们的随机值。

通过在 emit 函数中调整我们的随机值来解决这个问题,以更清晰地观察粒子的运动:

for (let i = 0; i < count; i++) {
  const position = [
    randFloatSpread(0.1),
    randFloatSpread(0.1),
    randFloatSpread(0.1),
  ];
  const scale = [randFloatSpread(1), randFloatSpread(1), randFloatSpread(1)];
  const rotation = [
    randFloatSpread(Math.PI),
    randFloatSpread(Math.PI),
    randFloatSpread(Math.PI),
  ];
  tmpPosition.set(...position);
  tmpRotationEuler.set(...rotation);
  tmpRotation.setFromEuler(tmpRotationEuler);
  tmpScale.set(...scale);
  tmpMatrix.compose(tmpPosition, tmpRotation, tmpScale);
  mesh.current.setMatrixAt(i, tmpMatrix);

  tmpColor.setRGB(1, 1, 1);
  instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);

  tmpColor.setRGB(0, 0, 0);
  instanceColorEnd.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);

  const direction = [randFloatSpread(0.5), 1, randFloatSpread(0.5)];
  instanceDirection.set(direction, i * 3);

  const lifetime = [randFloat(0, 5), randFloat(0.1, 5)];
  instanceLifetime.set(lifetime, i * 2);

  const speed = randFloat(1, 5);
  instanceSpeed.set([speed], i);

  const rotationSpeed = [
    randFloatSpread(1),
    randFloatSpread(1),
    randFloatSpread(1),
  ];
  instanceRotationSpeed.set(rotationSpeed, i * 3);
}

开始成形!

稍后我们将添加简单的 UI 控件 来调整变量。现在让我们通过添加旋转逻辑来完善粒子。

在为移动分离方向和速度时,旋转将使用单个 instanceRotationSpeed 属性来定义每个轴的旋转速度。

顶点着色器 中,我们可以根据 旋转速度年龄 计算粒子的 旋转

vec3 rotationSpeed = instanceRotationSpeed * age;

然后,为了能够将这种“偏移旋转”应用于粒子,我们需要将其转换为旋转矩阵:

mat4 rotX = rotationX(rotationSpeed.x);
mat4 rotY = rotationY(rotationSpeed.y);
mat4 rotZ = rotationZ(rotationSpeed.z);
mat4 rotationMatrix = rotZ * rotY * rotX;

rotationXrotationYrotationZ 是返回绕 X、Y、Z 轴的旋转矩阵的函数。我们将在 顶点着色器main 函数之上定义它们:

mat4 rotationX(float angle) {
  float s = sin(angle);
  float c = cos(angle);
  return mat4(
      1,  0,  0,  0,
      0,  c, -s,  0,
      0,  s,  c,  0,
      0,  0,  0,  1
  );
}

mat4 rotationY(float angle) {
  float s = sin(angle);
  float c = cos(angle);
  return mat4(
       c,  0,  s,  0,
       0,  1,  0,  0,
      -s,  0,  c,  0,
       0,  0,  0,  1
  );
}

mat4 rotationZ(float angle) {
  float s = sin(angle);
  float c = cos(angle);
  return mat4(
      c, -s,  0,  0,
      s,  c,  0,  0,
      0,  0,  1,  0,
      0,  0,  0,  1
  );
}

若想了解更多关于旋转矩阵的信息,您可以查看这个维基百科文章,由 Simon Dev 创作的精彩课程 Game Math Explained Simply,或是 The Book of Shaders 中的矩阵部分

最后,我们可以将 旋转矩阵 应用于粒子的 起始位置

vec4 startPosition = modelMatrix * instanceMatrix * rotationMatrix * vec4(position, 1.0);

让我们试试看:

粒子现在正在移动,变色并旋转!✨

完美,我们为我们的 VFX 引擎 打下了坚实的基础。在添加更多功能和控件之前,让我们准备引擎的第二个重要部分:发射器

发射器

在示例和教程中,粒子系统的这个部分常常被忽视。但要把粒子轻松高效地整合到你的项目中,这是至关重要的:

  • 轻松:因为你的 <VFXParticles /> 组件将位于层级结构的顶部,你的发射器可以从场景中的任何子组件生成它们。使得从特定点生成它们,将它们附加到移动对象移动骨骼变得简单。
  • 高效:因为不需要每次想生成粒子时重新创建实例化网格、编译着色器材质和设置属性,你可以复用相同的 VFXParticles 组件,只需调用一个函数来生成具有所需设置的粒子。

useVFX

我们希望能够从项目中的任何地方调用 VFXParticles 组件中的 emit 函数。为此,我们将创建一个名为 useVFX 的自定义 hook,用于注册注销 VFXParticles 组件中的发射器

我们将使用 Zustand,因为它是一种在 React 中管理全局状态的简单高效的方法,具有出色的性能。

让我们将其添加到项目中:

yarn add zustand

vfxs 文件夹中,让我们创建一个 VFXStore.js 文件:

import { create } from "zustand";

export const useVFX = create((set, get) => ({
  emitters: {},
  registerEmitter: (name, emitter) => {
    if (get().emitters[name]) {
      console.warn(`Emitter ${name} already exists`);
      return;
    }
    set((state) => {
      state.emitters[name] = emitter;
      return state;
    });
  },
  unregisterEmitter: (name) => {
    set((state) => {
      delete state.emitters[name];
      return state;
    });
  },
  emit: (name, ...params) => {
    const emitter = get().emitters[name];
    if (!emitter) {
      console.warn(`Emitter ${name} not found`);
      return;
    }
    emitter(...params);
  },
}));

内容包括:

  • emitters:一个对象,用来存储我们 VFXParticles 组件的所有发射器。
  • registerEmitter:一个注册具有特定名称的发射器的函数。
  • unregisterEmitter:一个注销具有特定名称的发射器的函数。
  • emit:一个函数,用来从项目中的任何地方调用具有特定名称和参数的发射器。

让我们将其加入 VFXParticles 组件:

// ...
import { useVFX } from "./VFXStore";

// ...
export const VFXParticles = ({ name, settings = {} }) => {
  // ...
  const registerEmitter = useVFX((state) => state.registerEmitter);
  const unregisterEmitter = useVFX((state) => state.unregisterEmitter);

  useEffect(() => {
    // emit(nbParticles);
    registerEmitter(name, emit);
    return () => {
      unregisterEmitter(name);
    };
  }, []);
  // ...
};

// ...

我们给 VFXParticles 组件增加了一个 name 属性以识别发射器。然后使用 useVFX hook 获取 registerEmitterunregisterEmitter 函数。

useEffect hook 内,使用 registerEmitter 函数注册发射器,当组件挂载时注册发射器,并在卸载时注销发射器。

Experience 组件中,给 VFXParticles 组件添加 name 属性:

// ...
import { VFXParticles } from "./vfxs/VFXParticles";

export const Experience = () => {
  return (
    <>
      {/* ... */}
      <VFXParticles name="sparks" />
    </>
  );
};

VFXEmitter

现在我们有了 useVFX 钩子,可以创建一个 VFXEmitter 组件,该组件负责从我们的 VFXParticles 组件中产生粒子。

vfxs 文件夹中,创建一个 VFXEmitter.jsx 文件:

import { forwardRef, useImperativeHandle, useRef } from "react";
import { useVFX } from "./VFXStore";

export const VFXEmitter = forwardRef(
  ({ emitter, settings = {}, ...props }, forwardedRef) => {
    const {
      duration = 1,
      nbParticles = 1000,
      spawnMode = "time", // time, burst
      loop = false,
      delay = 0,
    } = settings;

    const emit = useVFX((state) => state.emit);

    const ref = useRef();
    useImperativeHandle(forwardedRef, () => ref.current);

    return (
      <>
        <object3D {...props} ref={ref} />
      </>
    );
  }
);
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.