ThreeJS打造粒子效果

1,757 阅读3分钟

大家好,我是王大傻。最近看到一个好玩的粒子效果。之前我们的粒子一般都是用canvas去实现的,简单粗暴。这次呢,我们用ThreeJS来做一个3D的粒子动画。

前期准备

初始化目录

image.png

image.png

这次我们就比较简单,直接用vite创建个项目,然后在Src下面放置不同的素材文件。接下来是我们的一些基础代码。 首先是index.html文件

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      overflow: hidden;
    }
  </style>
</head>

<body>

</body>
<script src="https://unpkg.com/@tweenjs/tween.js@^20.0.0/dist/tween.umd.js"></script>

<script src="./src/index.ts" type="module"></script>

</html>

这段没什么特殊的,就是引入了我们的主文件Index.ts。接下来是我们的package.json文件

{
  "scripts": {
    "start": "vite",
    "dev": "npm start",
    "build": "vite build"
  },
  "dependencies": {
    "@tweenjs/tween.js": "^20.0.3",
    "@types/dat.gui": "^0.7.7",
    "@types/gsap": "^3.0.0",
    "@types/three": "^0.143.2",
    "dat.gui": "^0.7.9",
    "gsap": "^3.11.1",
    "three": "^0.144.0",
    "vite": "^3.0.9"
  }
}

这里我们安装了一些基础常用的依赖文件(Tween和gsap两个选一个就行,都是动画效果库)。然后就是我们的主文件Index.ts

import * as THREE from "three"
import { OrbitControls } from "./../node_modules/three/examples/jsm/controls/OrbitControls"
import gsap from "gsap"

import dat from "dat.gui"

// 1 创建场景
const scene = new THREE.Scene()

// 2 创建相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
)
// 设置相机位置
camera.position.set(0, 0, 10)

// 3 渲染器
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
// 使用渲染器,通过相机将场景渲染进来
renderer.render(scene, camera)



// 5 使用渲染器,通过相机将场景渲染进来
renderer.render(scene, camera)

// 轨道控制器
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true // 阻尼感


function render() {
  controls.update()
  const time = clock.getElapsedTime()

  renderer.render(scene, camera)
  requestAnimationFrame(render)
}
render()

// 坐标辅助器 用于简单模拟3个坐标轴的对象.4
// 红色代表 X 轴. 绿色代表 Y 轴. 蓝色代表 Z 轴.
const axesHelper = new THREE.AxesHelper(5)
scene.add(axesHelper)

// 监听窗口变化 更新视图
window.addEventListener("resize", () => {
  console.log("resize")
  // 更新摄像头
  camera.aspect = window.innerWidth / window.innerHeight
  // 更新摄像头投影矩阵
  camera.updateProjectionMatrix()
  // 设置渲染器的像素比
  renderer.setPixelRatio(window.devicePixelRatio)
  renderer.setSize(window.innerWidth, window.innerHeight)
})

这里我们引入了ThreeJS并做了一些初始化内容。那么接下来就进入我们的开发环节。

开发环节

粒子效果分解

test.gif

如图我们可以看出我们要做的最终效果,他是通过很多的粒子进行位移最终形成我们的模型文件。那么我们将这个需求进行分解:

  • 粒子生成
  • 模型文件载入
  • 根据模型文件粒子进行移动归位
  • 形成我们的模型文件粒子形态

接下来我们根据步骤进行开发

粒子生成

首先我们编写一个粒子生成函数:


const initBox = (obj) => {
  obj.children.forEach((item, index) => {
  const vertices: any[] = [];
    const itemGeometry = item.geometry; // 获取模型的几何体
    const count = itemGeometry.attributes.position.count;
    for (let i = 0; i < count; i++) {
      const x = Math.random() * 100 + 10;
      const y = 0
      const z = Math.random() * 100 + 10;
      vertices.push(x, y, z);
    }
    let geometry = new THREE.BufferGeometry();
    geometry.setAttribute(
      "position", // THREE.BufferAttribute的第一个参数是属性名称,这里我们将其命名为position,表示这个属性是用来控制点的位置的
      new THREE.Float32BufferAttribute(vertices, 3) // 3个为一组,表示一个点的xyz坐标
    );
    const color = new THREE.Color(Math.random(), Math.random(), Math.random());
    const material =new THREE.ShaderMaterial({
      uniforms: {
        uColor: { value: color },
        uTexture: { value: mapDot },
        uTime: {
          value: 0,
        },
      },
      vertexShader: vertexShader,
      fragmentShader: fragmentShader,
      blending: THREE.AdditiveBlending,
      transparent: true,
      depthTest: false,
    });
    const points = new THREE.Points(geometry, material);
    groupOBJ.add(points);
  });
  scene.add(groupOBJ);
};

首先我们需要设想,我们需要知道模型各个部位的顶点数量,并且根据顶点数量进行生成对应数量的粒子,接下来我们需要给粒子生成一个随机位置,这里位置我们可以根据自己喜好来控制,这里我们粒子的材质也是通过shader进行了编写。看代码:

首先是顶点着色器:

attribute vec3 aPosition;
uniform float uTime;
void main(){
    vec4 currentPosition=modelMatrix*vec4(position,1.);
    vec3 direction=aPosition-currentPosition.xyz;
    vec3 targetPosition=currentPosition.xyz+direction*.1*uTime;
    vec4 vPosition=viewMatrix*vec4(targetPosition,1.);
    gl_Position=projectionMatrix*vPosition;
    gl_PointSize=100./vPosition.z;
    
}

其次是片元着色器

uniform sampler2D uTexture;
uniform vec3 uColor;
void main(){
    vec4 uTextureColor = texture2D(uTexture, gl_PointCoord);
    gl_FragColor = vec4(uColor, uTextureColor.x);
}

这里着重讲解下顶点着色器,首先是定义一个四维矩阵,这个矩阵对我们的模型矩阵以及位置信息进行计算,计算完后我们将这个矩阵和我们偏移后的位置aPosition进行换算得到偏移差,最终将这个差值和我们的模型位置信息进行相加,这时候用到我们设置的uTime了,通过动态改变uTime值从而进行动画效果,最终拿到我们的视图矩阵和我们的最终位置矩阵信息相乘得到我们的位置,最后我们还有个gl_PointSize属性,这个就是我们可以操控粒子的大小显示。 接下来那肯定就是我们的模型初始化函数了。 image.png

模型生成

在生成前我们先加入一个星空背景的创建函数

// 创建星空背景
function createSky() {
  const vertices: any[] = [];
  for (let i = 0; i < 1000; i++) {
    const x = THREE.MathUtils.randFloatSpread(2000);
    const y = THREE.MathUtils.randFloatSpread(2000);
    const z = THREE.MathUtils.randFloatSpread(2000);
    vertices.push(x, y, z);
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute(
    "position", // THREE.BufferAttribute的第一个参数是属性名称,这里我们将其命名为position,表示这个属性是用来控制点的位置的
    new THREE.Float32BufferAttribute(vertices, 3) // 3个为一组,表示一个点的xyz坐标
  );
  const material = new THREE.PointsMaterial({
    size: 1, // 点的大小
    map: mapDot, // 纹理贴图
  });
  const skyPoints = new THREE.Points(geometry, material);

  scene.add(skyPoints);
}

然后就是我们的重点模型创建函数

const initOBJMember = (obj) => {
  initBox(obj);
  obj.children.forEach((item, index) => {
    const itemGeometry = item.geometry; // 获取模型的几何体
    const particleSystem = new THREE.Points(
      itemGeometry,
      new THREE.PointsMaterial({
        color: 0xffffff,
      })
    );

    const itemPoints = particleSystem.geometry.getAttribute("position"); // 获取顶点位置
    let randomPositionArray = new Float32Array(itemPoints.count * 3);
    for (let i = 0; i < itemPoints.count; i++) {
      // 放大倍数
      const p = 0.5;
      randomPositionArray[i * 3] = itemPoints.array[i * 3] * p+100;
      randomPositionArray[i * 3 + 1] = itemPoints.array[i * 3 + 1] * p;
      randomPositionArray[i * 3 + 2] = itemPoints.array[i * 3 + 2] * p;
    }
    groupOBJ.children[index].geometry.setAttribute(
      "aPosition",
      new THREE.BufferAttribute(randomPositionArray, 3)
    );
    new TWEEN.Tween(groupOBJ.children[index].material.uniforms.uTime)
      .to({ value: 10 }, Math.random()*3000+500)
      .easing(TWEEN.Easing.Sinusoidal.InOut)
      // .repeat(Infinity)
      // .yoyo(true)
      .delay(1000 * Math.random())
      .onUpdate(() => {
        // countArr.needsUpdate = true; // 告诉渲染器需要更新顶点位置
      })
      .onComplete(() => {})
      .start();
  });
};

这里我们进行了模型的创建,通过获取模型的顶点位置,然后进行模型最终位置的确定,在拿到最终位置后,我们通过向原来的point组中传递我们的位置信息从而使shader进行运行。最后通过操纵不同位置的Utime信息进行运动 来看下我们的最终效果。 test.gif

当然这里我们还要加载模型以及用到其他的一些函数

// 加载OBJ文件
function loadOBIFN() {
  // 创建OBJLoader对象
  const loader = new OBJLoader();
  // 加载OBJ文件
  loader.load(
    // OBJ文件的路径
    "./2.obj",

    // 当OBJ文件加载完成后执行的回调函数
    (obj) => initOBJMember(obj),
    // 正在加载OBJ文件时执行的回调函数
    function (xhr) {
      console.log("加载完成", (xhr.loaded / xhr.total) * 100 + "% loaded");
    },
    // 加载OBJ文件失败时执行的回调函数
    function (error) {
      console.log("An error happened: " + error);
    }
  );
}

在模型加载完成后,我们通过传递给我们模型加载处理函数模型信息进行初始化模型,最后是我们的执行函数

function init() {
  createSky();
  loadOBIFN();
  render();
}
init();

如代码所示,我们先是创建星空再对模型进行加载,在加载完成后,我们通过render函数进行重绘操作。

结语

至此,我们的粒子效果已经完成了,当然,如果你觉得不够炫酷,也欢迎发表自己的想法,比如大傻看到了一个比较好看的效果。有什么需要改进的地方,也欢迎大家指出。

image.png