[Three.js] 实现两个3D模型之间的粒子化切换

three.js小白的学习之路。

最近在学react,就想着用react实现一些简单的three.js的动画特效。正好也刷到了一些three.js的特效,结合我之前的分享的一种图片之间切换的实现效果,决定写一个3D模型之间的粒子切换动画。

1.分析需求

要想实现不同的3D模型之间粒子化切换,大致可以分为一下三个步骤:

  1. 加载模型
  2. 模型粒子化
  3. 粒子模型之间切换

思路还是比较清晰的,但是有几个注意的点:

1.加载的3D模型一般都不是一个整体,我们需要先将模型进行一个合并,这个可以使用Three.js的扩展库BufferGeometryUtils中的mergeGeometries;

2.需要加载几个模型?看似是两个模型之间切换,但其实是模型之间的粒子,从一个位置运动到另外一个位置。那么我只需要记录了两个模型的顶点位置,然后保证顶点个数一致,对应位置的顶点互相移动即可。所以,我需要计算两个粒子模型的顶点位置,但是只需要加载其中一个粒子模型即可,因为本质就是一套粒子来回切换。

2.加载模型

我们这里加载两个模型,一个是Naruto的模型,一个是沙发模型:

使用three.js 的GLTFLoader加载你想要的模型即可。

3.模型粒子化

1.模型合并

TypeScript 复制代码
  const mergeModel = (model) => {
    model.updateMatrixWorld(true); // 强制更新世界矩阵,保证模型的变换矩阵生效
    const geometries = [];
    model.traverse((child) => {
      if (child.isMesh) {
        // 关键:将几何体转换到世界坐标,否则子 Mesh 的位置/旋转/缩放会丢失
        const clonedGeo = child.geometry.clone();
        clonedGeo.applyMatrix4(child.matrixWorld);
        geometries.push(clonedGeo);
      }
    });

    const mergeGeo = mergeGeometries(geometries);
    const mesh = new Three.Mesh(mergeGeo);
    console.log(mesh);

    return mesh;
  };

合并前,可以看到children中有4个独立的Mesh:

合并后,就只有一个单独的Mesh:

现在就暴露出了这种合并的一个缺点,就是合并后是BufferGeometry,缺少材质信息。因此我可能更喜欢通过Blender等软件进行模型的合并,可以保留材质信息。

2.模型粒子化

我们将之前通过BufferGeometry生成的Mesh从scene删除掉,因为主角不是它。

在将模型粒子化的时候,需要保证两个模型的粒子个数一致,但两个模型的positions.array长度肯定是不一致的,所以我们需要一些特殊的处理。

three.js中的MeshSurfaceSampler扩展库提供了一个MeshSurfaceSampler方法,这个是**网格表面均匀采样器,**通过计算模型的三角形网格面积占比,加上我们想要的粒子个数,去动态的采样,从而保留模型外形的同时,达到我们想要的粒子个数一致的目的:

TypeScript 复制代码
  const count = 50000;
  const pointsModel = (model, totalCount = count) => {
    // 1. 准备一个 Mesh(必须是单一、已合并的 Mesh)
    const sampler = new MeshSurfaceSampler(model).build(); // 预计算面积权重,必须调用

    // 2.记录粒子位置
    const positions = new Float32Array(totalCount * 3);

    // 3.取点
    const _position = new Three.Vector3();
    for (let i = 0; i < totalCount; i++) {
      sampler.sample(_position); // 在表面随机取一个点
      _position.applyMatrix4(model.matrixWorld);
      positions[i * 3] = _position.x;
      positions[i * 3 + 1] = _position.y;
      positions[i * 3 + 2] = _position.z;
    }

    // 4.创建粒子系统
    const geometry = new Three.BufferGeometry();
    geometry.setAttribute("position", new Three.BufferAttribute(positions, 3));
    const material = new Three.PointsMaterial({
      color: 0xffffff,
      size: 0.2,
      sizeAttenuation: true,
      transparent: true,
      opacity: 0.8,
    });
    const particles = new Three.Points(geometry, material);
    scene.add(particles);
  };

效果分别如下:

看起来点位的稀疏程度不一样,但无伤大雅。

4.模型之间切换

两个粒子模型已经准备完毕,接下来就是让二者之间切换。我们用过antd组件库写一个按钮,并绑定点击事件,用于二者之间的切换。

在这之前需要做一些准备工作:

  1. 需要记录当前是那个粒子模型的位置
  2. 由于GLTFLoader是一个异步的方法,需要通过useState记录一下两个模型
  3. 需要一个状态判断当前是否正在动画中,以及进行到那种程度了(PS: useState还能行不?)

我们定义并且给这些变量进行赋值:

TypeScript 复制代码
  const [sofa, setSofa] = React.useState<Three.Points>(null);
  const [naruto, setNaruto] = React.useState<Three.Points>(null);
  const [currentModel, setCurrentModel] = React.useState<Three.Points>(null);
  const [isAni, setIsAni] = React.useState<boolean>(false);
  const [progress, setProgress] = React.useState<number>(0);
  const v = 0.01; // 速度

  const pointsModel = (model, totalCount = count) => {
    // ..................
    const particles = new Three.Points(geometry, material);
    // scene.add(particles);
    return particles;
  };

  DracoLoader("/glb/sofa.glb", (glb) => {
      const merged = mergeModel(glb.scene);
      const model = pointsModel(merged);
      setSofa(model);
      setCurrentModel(model); // 只加载第一个模型即可
      scene.add(model); // 只加载第一个模型即可
    });
  DracoLoader("/glb/naruto.glb", (glb) => {
      glb.scene.scale.multiplyScalar(25);
      const merged = mergeModel(glb.scene);
      const model = pointsModel(merged);
      setNaruto(model);
    });

记录好之后,写点击事件:

TypeScript 复制代码
  const changeModel = (whichOne: boolean) => {
    setWhichOne(!whichOne); // 切换模型,异步
    setIsAni(true); // 开始动画
    setProgress(0); // 重置进度为0

    const curModel = whichOne ? sofa : naruto; // 当前展示的模型
    const tarModel = whichOne ? naruto : sofa; // 目标模型

    const curPos = curModel.geometry.attributes.position;
    const tarPos = tarModel.geometry.attributes.position;
  };

现在就会出现一个问题,我们怎么将这个curPos和tarPos在render中获取到,因为所有的动画进行肯定在render中进行,通过判断isAni决定是否进行动画切换。

我们可以将这两个position数组分别用一个变量记录下来,也可以往对应的model上去塞一个属性或者利用已有的属性。在打印Points模型时,可以看到里面有一个userData:

其实也不一定是userData,也可以是自己新增一个新的属性,这里我们采用后者:

TypeScript 复制代码
  const changeModel = (whichOne: boolean) => {
    setWhichOne(!whichOne); // 切换模型,异步
    setIsAni(true); // 开始动画
    setProgress(0); // 重置进度为0

    const curModel = whichOne ? sofa : naruto; // 当前展示的模型
    const tarModel = whichOne ? naruto : sofa; // 目标模型

    const curPos = curModel.geometry.attributes.position;
    const tarPos = tarModel.geometry.attributes.position;

    currentModel.userData = { curPos, tarPos };
    setTimeout(() => {
      console.log(currentModel);
    }, 0);
  };

然后就是render函数的改造了,将动画逻辑加进去:

TypeScript 复制代码
  const render = () => {
    requestAnimationFrame(render);
    renderer.render(scene, camera);

    if (isAni) {
      setProgress(progress + v);
      if (progress >= 1) {
        setIsAni(false);
      }
      const { curPos, tarPos } = currentModel.userData;
      const currentModelPosition =
        currentModel.geometry.attributes.position.array;
      for (let i = 0; i < count; i++) {
        currentModelPosition[i] =
          tarPos[i] * progress + (1 - progress) * curPos[i];
      }
      currentModel.geometry.attributes.position.needsUpdate = true;
    }
  };

激动的心,颤抖的手,点下change有没有:

翻车了?我们在render中动画逻辑打印一下,看动画有没有执行:

TypeScript 复制代码
 const render = () => {

    if (isAni) {
      console.log("动起来了吗?");
      // ............
    }
  };

嘶,我们发现,render函数并没有打印,这是为什么呢?是因为我们在useEffect中写的render,并且在依赖项中,没有任何依赖,导致render函数中useState变量变了也不会被useEffect察觉到。

那我们在useEffect中注入依赖项?这样肯定也不合理,一个是组件重新渲染,会将模型重新加载一遍,不可取;render函数本身就只需要执行第一次即可,重新渲染,我们又没有手动的将render函数停下来,不可取。

解决方法就是将useState变量改为Ref变量,这样render中引用的就是一个复杂对象,我们在外面改变ref的current下的某一个值,render函数中也会同步改变,我们试一下:

TypeScript 复制代码
const [sofa, setSofa] = React.useState<Three.Points>(null);
  const [naruto, setNaruto] = React.useState<Three.Points>(null);
  //   const [currentModel, setCurrentModel] = React.useState<Three.Points>(null);
  //   const [isAni, setIsAni] = React.useState<boolean>(false);
  //   const [progress, setProgress] = React.useState<number>(0);
  //   const [whichOne, setWhichOne] = React.useState<boolean>(true);
  const currentModelRef = useRef<Three.Points>(null);
  const isAniRef = useRef<boolean>(false);
  const progressRef = useRef<number>(0);
  const whichOneRef = useRef<boolean>(true);
  const v = 0.01; // 速度

const changeModel = (whichOne: boolean) => {
    // setWhichOne(!whichOne); // 切换模型,异步
    // setIsAni(true); // 开始动画
    // setProgress(0); // 重置进度为0
    whichOneRef.current = !whichOne;
    isAniRef.current = true;
    progressRef.current = 0;

    const curModel = whichOneRef.current ? naruto : sofa; // 当前展示的模型
    const tarModel = whichOneRef.current ? sofa : naruto ; // 目标模型

    const curPos = curModel.geometry.attributes.position.array;
    const tarPos = tarModel.geometry.attributes.position.array;

    currentModelRef.current.userData = { curPos, tarPos };
  };

  const render = () => {
    requestAnimationFrame(render);
    renderer.render(scene, camera);

    if (isAniRef.current) {
      console.log("动起来了吗?");
      progressRef.current = progressRef.current + v;
      if (progressRef.current >= 1) {
        isAniRef.current = false;
      }
      const { curPos, tarPos } = currentModelRef.current.userData;
      const currentModelPosition =
        currentModelRef.current.geometry.attributes.position.array;
      for (let i = 0; i < currentModelPosition.length; i++) {
        currentModelPosition[i] =
          tarPos[i] * progressRef.current + (1 - progressRef.current) * curPos[i];
      }
      currentModelRef.current.geometry.attributes.position.needsUpdate = true;
    }
  };

看起来好像成功了,但是后面在点击切换模型,就切换不了,这是为什么呢?

我们打印一下切换前后粒子模型的position矩阵:

可以看到,点击了一次切换以后,后面的当前粒子模型的position矩阵就变成了和目标位置的矩阵一样,且不再变化。所以计算出来的curPos和tarPos就是一样的值,所以动画就不在进行了。

造成的原因也不难想到,就是因为positions是一个Float32Array的复杂对象,我们不能直接赋值,需要复制一份数据出来,不要直接改变模型本身的数据。

但是我们就是要改变粒子模型的positions数组,不改变动画也不成立。所以我们要保存一份原始数据:

TypeScript 复制代码
const changeModel = (whichOne: boolean) => {
    isAniRef.current = true;
    progressRef.current = 0;
    const curModel = whichOneRef.current ? naruto : sofa; // 当前展示的模型
    const tarModel = whichOneRef.current ? sofa : naruto; // 目标模型
    const curPos = curModel.originPos.slice();
    const tarPos = tarModel.originPos.slice();
    currentModelRef.current.userData = { curPos, tarPos };
  };

DracoLoader("/glb/sofa.glb", (glb) => {
      const merged = mergeModel(glb.scene);
      const model = pointsModel(merged);
      model.originPos = model.geometry.attributes.position.array.slice();
      setSofa(model);
      currentModelRef.current = model;
      scene.add(model);
    });
    DracoLoader("/glb/naruto.glb", (glb) => {
      glb.scene.scale.multiplyScalar(25);
      const merged = mergeModel(glb.scene);
      const model = pointsModel(merged);
      model.originPos = model.geometry.attributes.position.array.slice();
      setNaruto(model);
    });

再来看看结果:

成功实现!

5.美中不足

1.方形点

粒子是方形的不好看。点的大小全都是一样的,不好看。

为了改变点的大小和形状,就需要使用shaderMaterial做一些处理,这里使用我之前分享的发亮粒子

TypeScript 复制代码
 const pointsModel = (model, totalCount = count) => {
    // 1. 准备一个 Mesh(必须是单一、已合并的 Mesh)
    const sampler = new MeshSurfaceSampler(model).build(); // 预计算面积权重,必须调用

    // 2.记录粒子位置 和 粒子大小
    const positions = new Float32Array(totalCount * 3);
    const sizes = new Float32Array(totalCount);

    // 3.取点
    const _position = new Three.Vector3();
    for (let i = 0; i < totalCount; i++) {
      sampler.sample(_position); // 在表面随机取一个点
      _position.applyMatrix4(model.matrixWorld);
      positions[i * 3] = _position.x;
      positions[i * 3 + 1] = _position.y;
      positions[i * 3 + 2] = _position.z;

      const size = Math.random() + 0.5;
      sizes[i] = size;
    }

    // 4.创建粒子系统
    const geometry = new Three.BufferGeometry();
    geometry.setAttribute("position", new Three.BufferAttribute(positions, 3));
    geometry.setAttribute("size", new Three.BufferAttribute(sizes, 1));
    // const material = new Three.PointsMaterial({
    //   color: 0xffffff,
    //   size: 0.2,
    //   sizeAttenuation: true,
    //   transparent: true,
    //   opacity: 0.8,
    // });
    const material = new Three.ShaderMaterial({
      uniforms: { time: { value: 0 } },
      vertexShader: `
            attribute float size;
            void main() {
                vec3 newPos = position;
                vec4 mvPosition = modelViewMatrix * vec4(newPos, 1.0);
                gl_PointSize = size * 70.0 / -mvPosition.z;
                gl_Position = projectionMatrix * mvPosition;
            }
        `,
      fragmentShader: `
            void main() {
                float len = length(gl_PointCoord - vec2(0.5));
                float strength = clamp(0.05 * (1.0 / len - 2.0), 0.0, 1.0);
                gl_FragColor = vec4(1.0, 1.0, 1.0, strength);            
            }
        `,
      transparent: true,
      blending: Three.AdditiveBlending,
      depthWrite: false,
    });
    const particles = new Three.Points(geometry, material);
    particles.originalPosition = positions.slice(); // 记录原始位置
    return particles;
  };

效果还行哈,这样就不需要设置粒子的形状,而是自动成为一个个发光的小圆点,串起来,都串起来了。

2.色彩单调

我们在生成粒子模型的时候,顺手生成一个colors数组,并在着色器中进行使用:

TypeScript 复制代码
 const count = 100000;
  const pointsModel = (model, totalCount = count) => {
    // 1. 准备一个 Mesh(必须是单一、已合并的 Mesh)
    const sampler = new MeshSurfaceSampler(model).build(); // 预计算面积权重,必须调用

    // 2.记录粒子位置 和 粒子大小
    const positions = new Float32Array(totalCount * 3);
    const sizes = new Float32Array(totalCount);
    const colors = new Float32Array(totalCount * 3);

    // 3.取点
    const _position = new Three.Vector3();
    for (let i = 0; i < totalCount; i++) {
      sampler.sample(_position); // 在表面随机取一个点
      _position.applyMatrix4(model.matrixWorld);
      positions[i * 3] = _position.x;
      positions[i * 3 + 1] = _position.y;
      positions[i * 3 + 2] = _position.z;

      colors[i * 3] = Math.random();
      colors[i * 3 + 1] = Math.random();
      colors[i * 3 + 2] = Math.random() * 0.5 + 0.5;

      const size = Math.random() + 0.5;
      sizes[i] = size;
    }

    // 4.创建粒子系统
    const geometry = new Three.BufferGeometry();
    geometry.setAttribute("position", new Three.BufferAttribute(positions, 3));
    geometry.setAttribute("size", new Three.BufferAttribute(sizes, 1));
    geometry.setAttribute("color", new Three.BufferAttribute(colors, 3));
    const material = new Three.ShaderMaterial({
      uniforms: { time: { value: 0 } },
      vertexShader: `
            attribute float size;
            attribute vec3 color;
            varying vec3 vColor;
            void main() {
                vColor = color;
                vec3 newPos = position;
                vec4 mvPosition = modelViewMatrix * vec4(newPos, 1.0);
                gl_PointSize = size * 70.0 / -mvPosition.z;
                gl_Position = projectionMatrix * mvPosition;
            }
        `,
      fragmentShader: `
            varying vec3 vColor;
            void main() {
                float len = length(gl_PointCoord - vec2(0.5));
                float strength = clamp(0.05 * (1.0 / len - 2.0), 0.0, 1.0);
                gl_FragColor = vec4(vColor, strength);      
            }
        `,
      transparent: true,
      blending: Three.AdditiveBlending,
      depthWrite: false,
    });
    const particles = new Three.Points(geometry, material);
    particles.originalPosition = positions.slice(); // 记录原始位置
    return particles;
  };

五彩斑斓的,看起来还行。

如果想要模型本身的颜色该怎么办?由于模型并非是单一的颜色值,而是一个纹理贴图,还真不好整,这个后续有思路了再说。也可以问问AI,看他怎么说,有没有好的办法。

或者大家自己烘焙一个贴图出来,然后保留原始模型的UV,通过texture2D方法,设置对应的颜色。

6.总结

这一分享了一个粒子模型之间切换的方法,之前都是Vue3写的,这次是使用React18,对自己来说也是个不小的挑战。整体的功能已经实现了,美中不足的就是不好获取模型本身的颜色。

相关推荐
独隅1 小时前
Chrome插件开发实战详细指南
前端·chrome
喵了几个咪1 小时前
技术复盘:基于 GoWind Admin 实现 Kratos 框架单体轻量化落地
前端·架构
ZC跨境爬虫1 小时前
跟着 MDN 学JavaScript day_6:JavaScript 中的基础数学——数字与运算符
开发语言·前端·javascript·学习·ecmascript
copyer_xyf1 小时前
Python 迭代器与生成器
前端·后端·python
KaMeidebaby9 小时前
卡梅德生物技术快报|PD1 单克隆抗体定制配套 N 糖全谱质控开发
前端·人工智能·算法·数据挖掘·数据分析
nuIl10 小时前
实现一个 Coding Agent(3):工具调用
前端·agent·cursor
nuIl10 小时前
实现一个 Coding Agent(4):ReAct 循环
前端·agent·cursor
nuIl10 小时前
实现一个 Coding Agent(1):一次 LLM 调用
前端·agent·cursor
nuIl10 小时前
实现一个 Coding Agent(2):让 LLM 流式响应
前端·agent·cursor