【团队内部技术分享】唯耐心不负所爱-当3d粒子遇到前端

2,832 阅读10分钟

盆友们好啊,最近在团队内部分享了 Three.js 相关的内容,正好周末有时间想总结记录一下。

废话不多说,先看 demo 效果
l2qnp-wygzc.gif

看到这个效果,很多小伙伴是不是觉得眼熟呢,或许你想说的是这个:UP2017 预热站

这是腾讯在 2017 年的一个作品,当时着实是深深吸引了我,经过不断地摸索,如今成功地给做了一个 高仿版 demo ,今天我想借用这个项目从实现的角度来进行分享,主要从以下几个方面展开。

  1. 我对 three.js 的探索历程
  2. 产出项目雏形
  3. 实现方案
  4. 细节优化

1. 我对 three.js 的探索历程及简单理解

最初我是在大学时候(2019 年)发现了 这个作品 ,带给我特别震撼的感觉!我发现原来前端能做的不止是管理系统和商城,还可以做出如此具有视觉冲击力的效果。

后来在学习的过程中慢慢做出了这个场景。 Three.js 毕竟和主流的 前端发展方向 不同,甚至可以说是两个行业,所以和我们学习的大多数 技能 是有很多区别的。

Threejs 不是凭空制造出来的 3d 画面 ,而是基于 WebGL 进行的一层抽象与封装。 WebGLHTML5 推出的新的绘图标准,这个 WebGL 非常的不讲武德,语法很像 C 语言,它披着 js 的外壳,来骗,来偷袭我这个工作不到两年的前端,这好嘛,这不好,希望这位同学耗子尾汁,不要再犯这样的聪明,小聪明啊!

不过有前人为我们铺好了路,一个伟大的团队将这个非常不讲武德的 gl 语法 抽象封装成了具有语义化的 Three.js ,并且 Three.js 紧跟潮流兼容了 ESM规范 ,所以我们可以很愉快地在前端工程化项目中使用,

like this,

import { PerspectiveCamera, Scene } from 'three';

// 创建场景
const scene = new Scene();

看到import语法 是不是感觉很亲切呐~

或许你会问,这里scene为何物,我们这行代码在做什么?

接下来我们了解下 Three.js 的三大核心概念:场景相机渲染器

官网已经有了很详细的讲解,在这里我就不做搬运工了,分享一下我的理解吧。

  1. 场景(scene)

    我将他理解为 一个容器 ,想象一下,在我们用 VueReact 时是不是在项目中有一个根标签,通常是idappidrootdiv标签,我们的组件会将 真实 dom 渲染到 根标签 内部,最终页面上呈现内容。

    Three.js 同样也有这个 容器 概念,叫做 场景(scene)场景 中出现的内容就是将来用户有可能看到的内容,所以场景最本质的作用就是呈现 3d 画面

  2. 相机(camera)

    因为 Three.js 提供了一个三维世界,所以我们需要一个观察世界的工具,这个工具就是相机,相机的作用就是 观察场景 。至于为什么叫 相机 而不叫 摄像机 或者 眼睛 呢 🤔🤔🤔,接下来会有说明。

  3. 渲染器(renderer)

    其实 渲染器 并不是一个完全陌生的概念,在 Vue3React 中都有所涉及,ReactDOMReactNative 就是两款渲染器,分别负责 浏览器原生应用 上的 UI 渲染,所以渲染器的作用就是 渲染 UI ,在 Three.js渲染器 的作用是 渲染场景渲染器 每执行一次渲染方法就会渲染一帧的画面,就像我们用相机拍照一样。因此观察世界的工具叫做 相机 ,而不叫 录像机眼睛

    如果想看到连续的画面就需要持续地渲染,浏览器采用的是逐帧渲染,所以我们可以在每一帧都让 渲染器 渲染一次场景,最终就可以看到一个动态的画面。

    经过这么一聊,是不是感觉 Threejs 好像也没有那么陌生啦~

2. 产出项目雏形

正所谓万事开头难,一个良好的开始是成功的一半,当时正好赶上假期有大把时间,于是经过简单的技术调研后就开始这场伟大计划了。

项目雏形在这里我就无私地送给大家了,正是有了这个雏形,才支持我最终完成这个项目。感兴趣的小伙伴可以运行起来,发挥自己的创意将这个小项目做得更流弊更酷炫~

项目雏形戳这里

3. 实现方案

接下来是最流弊的环节了,小伙伴们搬好小板凳认真听哈~

3.1. 技术选型

  1. 项目搭建:Webpack

    前端基础,不解释

  2. 3d 场景:Three.js

    渲染 3d 场景

  3. 粒子动画:tween.js

    因为粒子本质上是我们通过Three.js创建出来的 js 对象,并不是dom 元素,所以我们没法用 Css 做动画,因此只能用 基于 js 的动画库(这里用任何一个 js 动画库都是可以的)去做。

3.2. 首个场景下的粒子星空

我们的首个场景是由粒子组成的星空,首先需要需要初始化粒子。

const particles = new Points(geometry, material);

这里 Points 接收的两个参数分别是 geometry (几何体) 和 material (材质) ,材质和粒子的外形相关,在这里我们需要重点关注的是 geometrygeometry 的意思是 几何体 ,即这个属性决定了粒子最终组合成什么样子。里面有两个重要的属性 verticescolors ,其中 vertices 最为重要,它决定了每个顶点的位置。

这个星空中每个粒子的位置并不是写好的而是随机生成的。

所以我们的 实现思路 很清晰,只要 随机 生成每个顶点的位置就可以得到最终的效果

// 1. 创建几何体
const geometry = new Geometry();

// 2. 为几何体添加【点信息】和【颜色信息】
for (let i = 0; i < 8000; i++) {
  const vertex = new Vector3();
  // 随机生成x,y,z三个坐标轴的位置
  vertex.x = random...;
  vertex.y = random...;
  vertex.z = random...;
  geometry.vertices.push(vertex);
  geometry.colors.push(new Color(255, 255, 255));
}

// 3. 创建粒子系统
const particles = new Points(geometry, material);

// 4. 添加到场景
scene.add(particles);

这就是创建初始场景的思路,主要做了 两件事情

  1. 创建粒子系统,然后添加到场景中。
  2. 做出星空的效果。(我们最终展示时有个星空自转的动画效果,感兴趣的小伙伴可以试着自己写一下)

3.3. 粒子切换到模型

刚才讲到了geometry.vertices,这个属性最终决定了模型展示成成什么样子,

目前我们已经有了星空粒子,接下来只要将每个粒子都移到下一个模型的点位置就可以实现模型切换效果。

那么首先我们需要导入模型,存储它的geometry(或者vertices),

const loader = new JSONLoader();

loader.load('assets/1game.json', geo => {
  glist[0] = geo;
});

然后将当前场景下的粒子移到对应的模型点位置

// 模型的点信息
const nextVertices = glist[index].vertices;
const nextVerticesLength = nextVertices.length;
// 遍历粒子执行动画
geometry.vertices.forEach(function (vtx, i) {
  const o = nextVertices[i % nextVerticesLength];
  new TWEEN.Tween(vtx)
    .to(
      {
        x: o.x,
        y: o.y,
        z: o.z,
      },
      1000
    )
    .easing(TWEEN.Easing.Exponential.In)
    .delay(delay * Math.random())
    .start();
});

[i % nextVerticesLength]看到这行代码,小伙伴们有没有想过为什么取余呢?

想象一下,当前我们的粒子有 10000 个,由于每个模型的点数量是不同的,所以粒子在移动时无法一对一找到去处,多出来的粒子会很尴尬,在这里我们的思路是将多出来的粒子从 0 开始重叠。对应计算方式就是取余。

另外delay的作用是设置动画的延迟时间,在这里让每个粒子的都有自己的动画延迟时间,最终的效果会非常 nice ~

下图为 优化后优化前 的效果对比 c0bem-98fl9.gif

3.4. 文字效果

主要借助 CSS3filteranimation两个重要属性,。

.text {
  animation: activeText 2s 0.5s;
  animation-fill-mode: both;
}
@keyframes activeText {
  0% {
    opacity: 0;
    filter: blur(100px);
  }

  100% {
    opacity: 1;
    filter: blur(0);
  }
}

3.5. 后期处理

何谓 后期处理 ,我们用一组 对比图 来体会下,

默认效果 截屏2021-05-29 下午5.46.56.png

使用了泛光、聚焦和暗角效果后 截屏2021-05-29 下午5.47.47.png

可以看到最终整个画面变得非常有质感

然而,当初在实现这个效果时可并没有那么顺利,因为 后期处理 属于 图形学 范畴的知识,对我们前端来讲难度略大,学习成本也很高。并且 Three.js 文档 也没怎么提这个概念,只能从 源码 中略探一二,所幸我发现了源码中作者提供的几个 example ,而且都支持了 ESM 规范 ,i 了 i 了。

使用起来大概是这样子,

import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';

const renderScene = new RenderPass(scene, camera);

// 后期处理效果,省略N多参数
const bloomPass = new UnrealBloomPass(...);
bloomPass.renderToScreen = true;
bloomPass.threshold = 0;
bloomPass.strength = 0.7;
bloomPass.radius = 0.5;
bloomPass.light = 1;

const composer = new EffectComposer(renderer);
composer.setSize(...size);
composer.addPass(renderScene);
composer.addPass(bloomPass);
...

得到的 composer 是增强后的 renderer ,所谓 增强 是指新加入了后期处理效果,当然 composer 也有自己的名字,叫做 效果组合器 。我们在 渲染场景 时就使用 composer 进行渲染就可以得到 后期处理 的画面了。

like this,

const animation = () => {
  requestAnimationFrame(animation) + composer.render();
  -renderer.render(config);
};

我个人感觉,在学习的过程中,除了拨开云雾见光明时的喜悦,或许在 陌生的领域 发现 熟悉的事物 也是一种幸福,我们通过 import 语法引入需要的 API ,剩下任务的就是根据效果进行优化了🤣🤣🤣。

(ps:对gl 语法的研究目前还没有整理,后续有总结产出会再做分享 😜)

4. 细节优化

  1. Web Worker

    Three.js 的一个缺陷是 性能差 ,因为 3d 场景会有大量的计算工作,这对我们 单线程JavaScript 来讲是不太友好的。🤔🤔🤔 所以我想到可以借助 Web Worker 来做优化。Web Worker 相对于 js 主线程 是单独开启的一个 线程 ,在这个 线程 中我们可以做一些大量运算的工作为 主线程 分担压力,这样一来页面的流畅度就大大增加了。

  2. 对象缓存

    上文有说到,我们的模型大概有几千到一万个粒子,每个粒子进行移动时都创建一个动画对象,导致每次执行动画都会占用大量内存。这里我们可以做 缓存优化

    like this,

    geometry.vertices.forEach(function (vtx, i) {
      const o = nextVertices[i % nextVerticesLength];
      let twInstance = vtx.tweenvtx;
      if (!twInstance) {
        twInstance = new TWEEN.Tween(vtx);
      }
      ...
    });
    

    经过优化后,我们只在 第一次 粒子切换时 创建动画对象,后续只要用这个 缓存的对象 就可以了。

    另外,不要担心存在这么多 对象 会不会造成卡顿问题, Vue2 的每个 响应式数据 同样会额外产生多个对象,一个比较大的项目有超过 10000响应式对象 是很常见的,也并没有出现多么严重的性能问题。所以我们只需要关注过程优化,不要让系统做过多无意义的工作就可以了。

5. 写在结尾

WebGL 其实和我个人发展方向还是有区别的,很多时候我是把Three.js作为一个兴趣爱好坚持着 😂。

之前我身边的很多小伙伴也是很喜欢 3d 这些酷炫的事物,但每次聊到这个他们基本上都会吐槽那些 繁琐的配置不熟悉的编程语言 。这篇文章看到这里 相信你也发现了,我基本上都是在用一个 前端开发者 的角度去看待和思考问题,并没有引入过多新的概念,其实是想让大家对这个陌生的Three.js 项目多一份熟悉感🐳🐳🐳。

另外这个作品开头也讲了是借鉴了 UP2017 预热站 的创意 🤣🤣🤣,在这个 站点 中有一句话叫做 “唯有耐心 不负所爱” ,在这里我把这句话送给大家,希望大家对于喜欢热爱的事情都可以坚持下去,在前进的道路上可以永远 不忘初心,坚持所爱!

结尾奉上原作品的效果图: 截屏2021-05-30 下午5.43.12.png