threejs——粒子瀑布运动

755 阅读6分钟

想写粒子运动很久了,看网上的粒子运动都是从一个图形变成另一个图形的运动,好像不是很炫酷,于是自己搞一个即实用又炫酷的粒子运动...,兄弟们,走曲儿~

2024-12-11 17.36.25.gif

在线体验地址

正文

首先准备一张透明背景的logo图片,尺寸为385*100,代码中会过滤透明像素,并将图片信息缩放至合适的位置。

获取图片信息

新增一个img标签并在图片的onload的回调中创建一个canvas,获取到图片的宽高并设置给canvas,并将图片绘制到canvas中。

 const img = new Image();
img.crossOrigin = "Anonymous"; // 处理跨域情况,如果图片有跨域访问需求的话
img.onload = function () {
    const canvas = document.createElement('canvas');

    canvas.width = img.width;
    canvas.height = img.height;
    const ctx = canvas.getContext('2d');
    if (!ctx) {
        reject(new Error("无法获取canvas的2D上下文"));
        return;
    }
    ctx.drawImage(img, 0, 0);
    
    ...

接下来就是通过canvas获取图片的像素信息了

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

通过调用APIgetImageData,获取从canvas的起点0,0到canvas的终点的所有像素点信息,并遍历信息。

for (let y = 0; y < height; y++) { // 遍历图像的每一行
    for (let x = 0; x < width; x++) { // 遍历图像的每一列
        const index = (y * width + x) * 4; // 计算像素在数据数组中的索引
        const alpha = data[index + 3]; // 获取像素的 alpha 值
        if (alpha === 0) continue; // 过滤完全透明像素

        const r = data[index]; // 获取像素的红色值
        const g = data[index + 1]; // 获取像素的绿色值
        const b = data[index + 2]; // 获取像素的蓝色值
        pixels.push({
            x: (x - width / 2) / 500, // 计算像素的 x 坐标并进行归一化
            y: (-y + height / 2) / 500, // 计算像素的 y 坐标并进行归一化
            color: `rgb(${r},${g},${b})` // 将 RGB 值转换为字符串格式的颜色
        });
    }
}

将获取到的信息存入pixels集合中,数据格式如下,在计算点位的时候/500是将像素点缩小至3d世界合适的位置,如果有条件可以根据图片大小去动态计算,我这里是先除以500后去改变的相机位置。相机位置不适合太远,太远的话,粒子尺寸要设置很大才会出效果,项目中相机位置设置的是export const cameraPosition = new THREE.Vector3(0, -0.6, 1.46);

interface PixelColor { // 定义 PixelColor 接口,表示像素的颜色信息
    x: number; // 像素的 x 坐标
    y: number; // 像素的 y 坐标
    color: string; // 像素的颜色,使用字符串表示
}

由于canvas这里属于前端基础操作,我就不多赘述了。

绘制粒子系统

首次加载顶点信息

首先创建一个BufferGeometry,并将所需的信息添注册进去,比如颜色和顶点信息位置,所以将这些操作提取一个方法createGeometry:(pixelColors: PixelColor[],geometry:THREE.BufferGeometry)=>void

由于后续还需要切换图片获取新的顶点信息去更新BufferGeometry,所以在下面将所有的属性都进行了更新,后续新增的属性也是相同的,代码中initialPositionspositions是相同的内容,文中用于渲染的是initialPositionspositions作为备用为了以后如果再加新的功能

const createGeometry = (pixelColors: PixelColor[],geometry:THREE.BufferGeometry) => {
   
    const positions: number[] = []; // 存储粒子位置的数组
    const colors: number[] = []; // 存储粒子颜色的数组
    const initialPositions: number[] = []; // 存储粒子初始位置的数组
    

    // 初始化粒子数据
    pixelColors.forEach(pixel => { // 遍历每个像素颜色
        const initialX = pixel.x;
        const initialY = pixel.y;
        initialPositions.push(initialX, initialY, 0); // 将初始位置添加到初始位置数组中
        positions.push(initialX, initialY, 0); // 将像素位置添加到位置数组中
        const color = new THREE.Color(pixel.color); // 创建一个 THREE.Color 对象表示颜色
        colors.push(color.r, color.g, color.b); // 将颜色的 RGB 值添加到颜色数组中
       
    });

    // 设置属性
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); // 设置位置属性
    geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); // 设置颜色属性
    geometry.setAttribute('initialPosition', new THREE.Float32BufferAttribute(initialPositions, 3)); // 设置初始位置属性
    geometry.attributes.position.needsUpdate = true; // 标记位置属性需要更新
    geometry.attributes.color.needsUpdate = true; // 标记颜色属性需要更新
    geometry.attributes.initialPosition.needsUpdate = true; // 标记初始位置属性需要更新

}

接下来在调用创建点位的方法即可将图片的位置和颜色信息绘制到场景中,材质稍后讲解

const particleSystem = new THREE.Points(geometry, material); 

4 首次渲染.jpg

向GUI添加属性

下面即可在GUI中添加不同图片的选项,并监听onchange事件,在切换不同地址的图片时候更新不同的粒子。

import GUI from "three/examples/jsm/libs/lil-gui.module.min.js";

const gui = new GUI({ container: document.getElementById('gui') as HTMLElement, width: 300, title: '配置项' });
export const guiParams = {
    imgUrl: `/assets/images/logo.png`,
};
export const imgUrl = gui.add(guiParams, 'imgUrl', ['/assets/images/logo.png', '/assets/images/logo1.png', '/assets/images/logo2.png'])
imgUrl.name('图片');

imgUrl.onChange( async (value) => {
    const imageUrl = `${import.meta.env.VITE_ASSETS_URL}${value}`
    // 调用解析图片方法
    const imageData:ImageData = await parseImage(imageUrl);
    // 更新geometry信息
    createGeometry(imageData.data, particleSystem.geometry)
})

在onchange方法中调用parseImage方法获取图片信息,并将图片信息传入刚才封装的方法createGeometry中。并将创建的粒子系统的geometry传入并更新。

这样前置工作就差不多了。接下来就是重头戏,着色器的开发和控制。

着色器

项目中采用着色器材质(ShaderMaterial)对粒子系统进行渲染

 const material = new THREE.ShaderMaterial({
     uniforms: {}, // 定义着色器的 uniform 变量
     vertexShader: ``, // 顶点着色器代码
     fragmentShader: ``, // 片元着色器代码
     transparent: true, // 启用透明度
     depthWrite: false, // 禁用深度写入
     blending: THREE.AdditiveBlending // 使用加法混合
 })

uniforms用于储存全局变量,供着色器使用,vertexShader顶点着色器用于更新粒子顶点信息,可定制粒子行为(运动、变色)等,fragmentShader用于绘制和丢弃粒子。

变量中储存了以下信息,为了支持高定需求,提取了大量的变量用于配置着色器

uniforms: { // 定义着色器的 uniform 变量
    time: { value: 0 }, // 时间变量
    pointSize: { value: guiParams.pointSize }, // 点大小
    deltaTime: { value: guiParams.deltaTime }, // 时间增量
    brightnessFactor: { value: guiParams.brightnessFactor }, // 亮度因子
    lifeCycle: { value: guiParams.lifeCycle }, // 生命周期
    updateProbability: { value: guiParams.updateProbability } // 更新顶点信息位置的概率
    },

生命周期

首先介绍生命周期,这个概念就是粒子的存活时间,也就是运动距离,需要配置geometry中定义的lifetimes属性配合使用,

lifetimes.push(Math.random() * 4); // 随机生成粒子的生命周期
geometry.setAttribute('lifetime', new THREE.Float32BufferAttribute(lifetimes, 1)); // 设置生命周期属性
geometry.setAttribute('startTime', new THREE.Float32BufferAttribute(startTimes, 1)); // 设置出发时间属性

为每一个粒子创建一个随机的生命周期初始值,和出生时间,并结合全局变量中的lifeCycle(生命周期变量)、共同控制一个粒子从出生到丢弃的时间

在使用这些属性变量之前需要在顶点着色器和片元着色器中声明一下,否则代码会报错

attribute vec3 velocity; // 速度属性
attribute float lifetime; // 生命周期属性
attribute vec3 color; // 颜色属性
attribute vec3 initialPosition; // 初始位置属性
attribute float startTime; // 出发时间属性

uniforms中定义的全局变量也需要这样声明一下uniform float time; // 时间 uniform这里就不一一列举了

float life = -mod(lifetime + time - startTime, lifeCycle); // 计算生命周期

这行代码用于计算生命周期,mod函数用于计算浮点数取模(取余数),作用和js中的%相同,通过将lifetime、time和startTime的计算,然后减去lifeCycle的整数倍,得到粒子在当前生命周期中的时间。这样可以确保粒子的生命周期在每个lifeCycle周期内重复。从前文效果图中可以看到,粒子是从上到下运动的,所以这里计算好的生命周期需要取一个负数,在后面计算位置的时候可以直接将位置取反。

变量updateProbability是用来控制更新概率的,也就是说具体有多少顶点需要按照生命周期规定的时间进行运动,所以代码中需要进行一个过滤:在顶点着色器中设定一个随机数,当随机数小于updateProbability时执行运动,否则顶点保持原有位置不变

 if (fract(sin(dot(initialPosition.xy, vec2(12.9898, 78.233))) * 43758.5453) < updateProbability) {
    pos.z +=  velocity.z * life; 
}

速度控制

这里出现了一个新的属性velocity,用于控制速度,同样是属性变量,在创建geometry的时候声明的一个随机的三维向量,dot 函数用于计算两个向量的点积,sin 函数用于计算正弦值,fract 函数用于获取小数部分,12.9898 和 78.233 是用于生成随机数的常数, 43758.5453 是用于生成随机数的常数,updateProbability在GUI中限制范围0.2-1,这样在变化的时候不会出现小时或者过长的效果,

 velocities.push(
    (Math.random() - 0.5) * 0.1, // 随机生成 x 方向速度
    (Math.random() - 0.5) * 0.1, // 随机生成 y 方向速度
    Math.random() * 0.1 // 随机生成 z 方向速度
);

geometry.setAttribute('velocity', new THREE.Float32BufferAttribute(velocities, 3)); // 设置速度属性

为了让粒子运动真的像瀑布一样的效果,除了随机的运动轨迹和运动周期还是不够的,还需要视觉上的美感,让粒子随着生命周期的衰减,而变得越来越小,越来越透明,所以我们在偏远着色器种还需要调整粒子的尺寸和透明度

vAlpha = 1.0 + life * 0.2; // 计算透明度
                
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); // 计算模型视图位置
gl_Position = projectionMatrix * mvPosition; // 计算投影位置

// 根据距离调整点大小
gl_PointSize = pointSize * (1.0 / -mvPosition.z); // 根据深度调整点大小

这样一来,粒子的运动长度也有了(生命周期),运动速度也有了(velocities),就可以直接在render中调用更新了。

// 更新函数
const clock = new THREE.Clock();
const updateTime = () => { // 定义更新时间的函数
    time += clock.getDelta(); // 增加时间
    material.uniforms.time.value = clock.getElapsedTime(); // 更新材质的 time uniform
};

流速控制

如果只用clock,速度是固定的,如果需要定制速度,需要在GUI中添加一个变量:时间增量;根据不同的时间增量,产生的效果也是不同的,可以同时设定时间增量和生命周期来控制粒子的流速,从而达到不同的效果。

那么修改一下updateTime中的代码如下:

// 更新函数
let time = 0; // 初始化时间变量
const updateTime = () => { // 定义更新时间的函数
    time += guiParams.deltaTime; // 增加时间
    material.uniforms.time.value = time; // 更新材质的 time uniform
};

如有不理解或者作者说错了地方,欢迎私信。兄弟们,撤~

视频演示