用一个粒子效果告别蛇年迎来马年~

0 阅读8分钟

我们即将迎来马年,随手整了一个粒子切换效果,在这里分享给大家,本期功能实现主要是运用了Three.JS!

cover2

1.加载模型

这种物体的形状很难通过纯数学公式推导出来,所以我是在sketchfab上找的两个模型

20260208174832

20260208174916

这两个模型都是.glb类型的,在Three.JS中我们可以通过GLTFLoaderDRACOLoader很轻松的加载这种类型的模型文件!

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

const gltf = await gltfLoader.loadAsync(path);
const model = gltf.scene;

关于DRACOLoader

简单来说,DRACOLoaderThree.js 中专门用来解压经过 Draco 压缩过的 3D 模型的“解压器”。

如果你在开发 WebGL 项目时发现模型文件(通常是 .gltf 或 .glb)太大,导致加载缓慢,你通常会使用 Google 开发的 Draco 算法 对模型进行压缩。而 DRACOLoader 就是为了让浏览器能读懂这些压缩数据而存在的。

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

const modelFiles = [
    {path: '/snake_model.glb', scale: 8, position: {x: 0, y: 0, z: 0}},
    {path: '/horse.glb', scale: 18, position: {x: 0, y: -14, z: 0}}
];


for (const modelConfig of modelFiles) {
    try {
        const gltf = await gltfLoader.loadAsync(modelConfig.path);
        const model = gltf.scene;
        model.scale.set(modelConfig.scale, modelConfig.scale, modelConfig.scale);
        model.position.set(modelConfig.position.x, modelConfig.position.y, modelConfig.position.z);
        model.updateMatrixWorld(true);
        scene.add(model);

        console.log(`Loaded: ${modelConfig.path}`);
    } catch (error) {
        console.error(`Failed to load ${modelConfig.path}:`, error);
    }
}

20260209091146

2.模型粒子化

现在我们的两个模型已经成功加载,我们的模型粒子化的思路是拿到模型的顶点数据然后使用new THREE.Points来展示,所以我们先隐藏我们的模型文件

for (const modelConfig of modelFiles) {
    try {
        ...
        ...
      - scene.add(model);
      + model.visible = false;
    
    } catch (error) {
        
    }
}

2.1 MeshSurfaceSampler

MeshSurfaceSampler 是 Three.js 扩展库(three/examples/jsm/math/MeshSurfaceSampler.js)中的一个实用类。它通过加权随机算法,根据模型表面的几何面积分布,在三角形网格上提取随机点的坐标、法线以及颜色。

通俗的来说我们的模型是由许多个三角形组成的,MeshSurfaceSampler通过算法会判断三角形面积,如果更大的三角形则权重更多被分配的点也就更多!

举个栗子🌰

import { MeshSurfaceSampler } from 'three/examples/jsm/math/MeshSurfaceSampler.js';

// 1. 创建采样器
const sampler = new MeshSurfaceSampler(yourLoadedMesh)
    .setWeightAttribute('color') // 可选:如果有颜色属性,可以按颜色密度采样
    .build();

// 2. 采样循环
const tempPosition = new THREE.Vector3();
const tempNormal = new THREE.Vector3();

for (let i = 0; i < particleCount; i++) {
    sampler.sample(tempPosition, tempNormal);
    
    // 将采样到的位置存入数组或属性中
    positions.push(tempPosition.x, tempPosition.y, tempPosition.z);
}

2.2 合并Mash

从上面的例子我们能看到MeshSurfaceSampler接收的是一个单一的Mesh,但是我们的模型可能会包含多个Mesh,比如本次案例中的都是有两个Mesh,所以在使用MeshSurfaceSampler前我们需要把多个Mesh合并成一个!

BufferGeometryUtils.mergeGeometries 是 Three.js 扩展库 BufferGeometryUtils 中的一个静态方法。它的主要作用是将一组 BufferGeometry 合并成一个单一的几何体。

function getMergedMeshFromScene(scene) {
    const geometries = [];

    scene.updateMatrixWorld(true);

    scene.traverse((child) => {
        if (child.isMesh) {
            const clonedGeom = child.geometry.clone();
            clonedGeom.applyMatrix4(child.matrixWorld);
            for (const key in clonedGeom.attributes) {
                if (key !== 'position') clonedGeom.deleteAttribute(key);
            }
            geometries.push(clonedGeom);
        }
    });

    // 合并所有几何体
    const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
    return new THREE.Mesh(mergedGeometry);
}

2.3 展示粒子


function generatePositionsFromModel(mesh, totalCount = particleCount) {
    const positions = new Float32Array(totalCount * 3);

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        sampler.sample(tempPosition);
        tempPosition.applyMatrix4(mesh.matrixWorld);
        const i3 = i * 3;
        positions[i3] = tempPosition.x;
        positions[i3 + 1] = tempPosition.y;
        positions[i3 + 2] = tempPosition.z;
    }

    return {positions};
}


 const modelData = generatePositionsFromModel(getMergedMeshFromScene(model), particleCount);
 modelDataArray.push(modelData);

现在我们已经有了模型的顶点坐标只需要使用THREE.Points配合THREE.PointsMaterial

function makeParticles(modelData) {

    const {positions} = modelData;

    const geometry = new THREE.BufferGeometry();

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    
    const material = new THREE.PointsMaterial({
        color: 0xffffff,      
        size: 0.5,             
        sizeAttenuation: true, 
        transparent: true,
        opacity: 0.8
    });

    return new THREE.Points(geometry, material);
}

const particles = makeParticles(modelDataArray[0]);
scene.add(particles);

20260209093343

我们的粒子小蛇就展示出来了,只不过现在这个粒子还很粗糙,我们会在后面优化~

3.粒子切换

现在我们的粒子已经成功展示!根据前面两步我们能知道粒子的展示就是根据模型的顶点来计算的,所以从一个模型切换到另一个模型就是单纯的顶点切换!

function beginMorph(index) {
    isTrans = true;
    prog = 0;

    const fromPts = new Float32Array(particles.geometry.attributes.position.array);
    const modelData = generatePositionsFromModel(getMergedMeshFromScene(rawModel[index]), particleCount);
    const toPts = new Float32Array(modelData.positions);

    particles.userData = {from: fromPts, to: toPts};
}

通过beginMorph我们把当前的粒子状态和目标状态存入到userData中,然后在tick中进行动画处理

const morphSpeed = .03;

const tick = () => {
    window.requestAnimationFrame(tick)
    controls.update()

    if (isTrans) {
        prog += morphSpeed;
        // 使用平滑的缓动函数
        const eased = prog >= 1 ? 1 : 1 - Math.pow(1 - prog, 3);

        const { from, to } = particles.userData;
        const particleArr = particles.geometry.attributes.position.array;

        for (let i = 0; i < particleArr.length; i++) {
            particleArr[i] = from[i] + (to[i] - from[i]) * eased;
        }
        // 通知 GPU 更新
        particles.geometry.attributes.position.needsUpdate = true;
        if (prog >= 1) isTrans = false;
    }


    renderer.render(scene, camera);
}

change

此时我们基础的粒子切换效果就已经实现啦!

4.粒子优化

此时我们的粒子效果还是存在几个问题的!

  • 大小固定/粒子是正方形
  • 没有颜色
  • 效果单调

要解决上面几个问题我们还使用THREE.PointsMaterial就有点不够看了,接下来我们使用THREE.ShaderMaterial搭配自定义着色器来优化效果!

4.1 大小随机化/粒子改为圆形

我们想让粒子的大小产生一个随机变化就要考虑通过顶点着色器中gl_PointSize来随机改变粒子大小!粒子改为圆形就要在片元着色器中修改gl_FragColor!

function generatePositionsFromModel(mesh, totalCount = particleCount) {
    const positions = new Float32Array(totalCount * 3);
    const sizes = new Float32Array(totalCount);
    const rnd = new Float32Array(totalCount * 3);

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        sizes[i] = .7 + Math.random() * 1.1;
        sampler.sample(tempPosition);
        tempPosition.applyMatrix4(mesh.matrixWorld);
        const i3 = i * 3;
        positions[i3] = tempPosition.x;
        positions[i3 + 1] = tempPosition.y;
        positions[i3 + 2] = tempPosition.z;

        rnd[i3] = Math.random() * 10;
        rnd[i3 + 1] = Math.random() * Math.PI * 2;
        rnd[i3 + 2] = .5 + .5 * Math.random();
    }

    return {positions, sizes, rnd};
}

首先修改generatePositionsFromModel方法,针对每个顶点坐标产生一组随机数范围在.7 ~ .77

function makeParticles(modelData) {

    const {positions, sizes, rnd} = modelData;

    const geometry = new THREE.BufferGeometry();

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
    geometry.setAttribute("random", new THREE.BufferAttribute(rnd, 3));

    const material = new THREE.ShaderMaterial({
        uniforms: {time: {value: 0}, hueSpeed: {value: 0.12}},
        vertexShader: ..., 
        fragmentShader: ...,
        transparent: true, 
        depthWrite: false, 
        vertexColors: true, 
        blending: THREE.AdditiveBlending
    });

    return new THREE.Points(geometry, material);
}
uniform float time;
attribute float size;
attribute vec3 random;
varying vec3 vCol;
varying float vR;
void main(){
    vec3 p=position;
    vec4 mv=modelViewMatrix*vec4(p,1.);
    float pulse=.9+.1*sin(time*1.15+random.y);
    gl_PointSize=size*pulse*(350./-mv.z);
    gl_Position=projectionMatrix*mv;
}
uniform float time;
void main() {
    float d = length(gl_PointCoord - vec2(0.5));
    float alpha = 1.0 - smoothstep(0.4, 0.5, d);
    if (alpha < 0.01) discard;
    gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
}

20260209100331

此时的粒子就大小改为随机并且是圆形粒子了~

4.2 粒子添加颜色

粒子添加颜色和上一步的粒子大小类似都需要针对每一个顶点生成一个随机的颜色

const palette = [0xff3c78, 0xff8c00, 0xfff200, 0x00cfff, 0xb400ff, 0xffffff, 0xff4040].map(c => new THREE.Color(c));

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        ...
        ...

        const base = palette[Math.random() * palette.length | 0], hsl = {h: 0, s: 0, l: 0};
        base.getHSL(hsl);
        hsl.h += (Math.random() - .5) * .05;
        hsl.s = Math.min(1, Math.max(.7, hsl.s + (Math.random() - .5) * .3));
        hsl.l = Math.min(.9, Math.max(.5, hsl.l + (Math.random() - .5) * .4));

        const c = new THREE.Color().setHSL(hsl.h, hsl.s, hsl.l);
        colors[i3] = c.r;
        colors[i3 + 1] = c.g;
        colors[i3 + 2] = c.b;

        ...
    }

修改片元着色器

uniform float time;
uniform float hueSpeed;
varying vec3 vCol;
varying float vR;

vec3 hueShift(vec3 c, float h) {
    const vec3 k = vec3(0.57735);
    float cosA = cos(h);
    float sinA = sin(h);
    return c * cosA + cross(k, c) * sinA + k * dot(k, c) * (1.0 - cosA);
}

void main() {
    vec2 uv = gl_PointCoord - 0.5;
    float d = length(uv);

    float core = smoothstep(0.05, 0.0, d);
    float angle = atan(uv.y, uv.x);
    float flare = pow(max(0.0, sin(angle * 6.0 + time * 2.0 * vR)), 4.0);
    flare *= smoothstep(0.5, 0.0, d);
    float glow = smoothstep(0.4, 0.1, d);

    float alpha = core * 1.0 + flare * 0.5 + glow * 0.2;

    vec3 color = hueShift(vCol, time * hueSpeed);
    vec3 finalColor = mix(color, vec3(1.0, 0.95, 0.9), core);
    finalColor = mix(finalColor, color, flare * 0.5 + glow * 0.5);

    if (alpha < 0.01) discard;

    gl_FragColor = vec4(finalColor, alpha);
}

20260209100747

4.3 设置亮度差

现在我们的粒子看着还是略显单调!我们可以给粒子做局部提亮!


function createSparkles() {

    const geo = new THREE.BufferGeometry();
    const pos = new Float32Array(particleSparkCount * 3);
    const size = new Float32Array(particleSparkCount);
    const rnd = new Float32Array(particleSparkCount * 3);

    for (let i = 0; i < particleSparkCount; i++) {
        size[i] = 0.5 + Math.random() * 0.8;
        rnd[i * 3] = Math.random() * 10;
        rnd[i * 3 + 1] = Math.random() * Math.PI * 2;
        rnd[i * 3 + 2] = 0.5 + 0.5 * Math.random();
    }
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    geo.setAttribute('size', new THREE.BufferAttribute(size, 1));
    geo.setAttribute('random', new THREE.BufferAttribute(rnd, 3));

    const mat = new THREE.ShaderMaterial({
        uniforms: {time: {value: 0}},
        vertexShader: `
            uniform float time;
            attribute float size;
            attribute vec3 random;
            void main() {
                vec3 p = position;
                float t = time * 0.25 * random.z;
                float ax = t + random.y, ay = t * 0.75 + random.x;
                float amp = (0.6 + sin(random.x + t * 0.6) * 0.3) * random.z;
                p.x += sin(ax + p.y * 0.06 + random.x * 0.1) * amp;
                p.y += cos(ay + p.z * 0.06 + random.y * 0.1) * amp;
                p.z += sin(ax * 0.85 + p.x * 0.06 + random.z * 0.1) * amp;
                vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
                gl_PointSize = size * (300.0 / -mvPosition.z);
                gl_Position = projectionMatrix * mvPosition;
            }`,
        fragmentShader: `
            uniform float time;
            void main() {
                float d = length(gl_PointCoord - vec2(0.5));
                float alpha = 1.0 - smoothstep(0.4, 0.5, d);
                if (alpha < 0.01) discard;
                gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
            }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
    });

    return new THREE.Points(geo, mat);
}

const particlesSpark = createSparkles(modelDataArray[0])
scene.add(particlesSpark);


const targetPositions = modelDataArray[0].positions;

const particleArr = particles.geometry.attributes.position.array;
const sparkleArr = particlesSpark.geometry.attributes.position.array;

for (let j = 0; j < particleCount; j++) {
    const idx = j * 3;

    // 直接从 targetPositions 拷贝三个连续的数值 (x, y, z)
    particleArr[idx] = targetPositions[idx];
    particleArr[idx + 1] = targetPositions[idx + 1];
    particleArr[idx + 2] = targetPositions[idx + 2];

    // 同步更新闪烁粒子
    if (j < particleSparkCount) {
        sparkleArr[idx] = targetPositions[idx];
        sparkleArr[idx + 1] = targetPositions[idx + 1];
        sparkleArr[idx + 2] = targetPositions[idx + 2];
    }
}

// 必须通知 GPU 更新
particles.geometry.attributes.position.needsUpdate = true;
particlesSpark.geometry.attributes.position.needsUpdate = true;

20260209102034转存失败,建议直接上传图片文件

我们又添加了一个createSparkles然后粒子位置和最开始的模型粒子位置一致,只不过颜色我们设置成白色!但是到这还没结束!我们的提亮魔法还要依靠THREE的后期处理能力!

EffectComposerThree.js 的 后期处理(Post-processing)管理器。它负责管理一个“通道(Pass)”队列。它不再直接将场景渲染到画布上,而是渲染到一个或多个缓冲帧中,经过各种视觉特效处理后,再呈现给用户。

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), .45, .5, .85));
const after = new AfterimagePass();
after.uniforms.damp.value = .92;
composer.addPass(after);
composer.addPass(new OutputPass());
  • UnrealBloomPass 是用来做荧光、发光的效果
  • AfterimagePass 是用来做拖尾影效果

结束语

希望所有人 2026 事事如意!

参考代码

Three.js & GLSL Particle Metamorphosis