three.js小白的学习之路。
最近在学react,就想着用react实现一些简单的three.js的动画特效。正好也刷到了一些three.js的特效,结合我之前的分享的一种图片之间切换的实现效果,决定写一个3D模型之间的粒子切换动画。
1.分析需求
要想实现不同的3D模型之间粒子化切换,大致可以分为一下三个步骤:
- 加载模型
- 模型粒子化
- 粒子模型之间切换
思路还是比较清晰的,但是有几个注意的点:
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组件库写一个按钮,并绑定点击事件,用于二者之间的切换。
在这之前需要做一些准备工作:
- 需要记录当前是那个粒子模型的位置
- 由于GLTFLoader是一个异步的方法,需要通过useState记录一下两个模型
- 需要一个状态判断当前是否正在动画中,以及进行到那种程度了(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,对自己来说也是个不小的挑战。整体的功能已经实现了,美中不足的就是不好获取模型本身的颜色。