Three.js粒子特效,shader渲染初探

32,739 阅读39分钟

这大概是个序

关于Three.js,网上有不多不少的零碎教程,有些过于初级,有些云里雾里,而Three.js官网上的示例逼格之高又令人望而却步,这些对于没学过计算机图形学的童鞋来说,就像入门迈槛不知先迈左脚还是右脚,兴趣使然,于是我就先双脚蹦了进去试试水......

本文将以尽量戏剧化的语言描述网页3D世界的构建流程及表面原理(因为深层原理我目前也不懂...),这里你应该/可能/或许/大概/看造化会学会(基本的):

  • 3D场景构建流程
  • 物体添加与外部模型导入
  • 鼠标与场景中的物体交互
  • 3D粒子系统构建
  • 粒子动画
  • 部分shader渲染原理

实诚地说,为了吸引各位看官,少啰嗦,先看东西(gif图可能稍大):

可以观察到成品效果:粒子变换、轻微粒子抖动、粒子模糊、颜色变换及过渡。

键盘走起~


一、3D场景构建流程

先得摆出几个关键词:场景灯光模型材质贴图与纹理相机渲染器

然后我开始装模作样地解释:

上帝说,要有场景!于是就有了场景,场景去纳这万事万物

上帝说,要有光!于是就有了光,灯光去现这大千世界,否则一片漆黑

上帝觉得缺少了些生气,便用泥巴捏了一个小人儿,不叫亚当,她叫小芳。

上帝左看右看,上看下看,这小芳果然生得俊俏,五官精致加长腿,此曰模型;

虽然小芳不是水做的,却也在这晨光的照射下显得皮肤吹弹可破,此曰材质

上帝莫名竟害羞了,挥手便给他穿上一件花格子长裙,配上了乌黑的长发,此曰贴图与纹理

上帝嘴角不扬却满心欣喜,他默默注视着自己的作品,上帝视角仿佛定格在了这一瞬间,这上帝之眼就是相机

上帝之所见如何,由世界入眼之后大脑冥想计算所得,这智慧高效的大脑就是渲染器

接下来预先恭喜你,你可以成为这网页3D世界的一个小上帝。

  1. 整体流程

class ThreeDWorld {
    constructor(canvasContainer) {
        // canvas容器
        this.container = canvasContainer || document.body;
        // 创建场景
        this.createScene();
        // 创建灯光
        this.createLights();
        // 性能监控插件
        this.initStats();
        // 物体添加
        this.addObjs();
        // 轨道控制插件(鼠标拖拽视角、缩放等)
        this.orbitControls = new THREE.OrbitControls(this.camera);
        this.orbitControls.autoRotate = true;
        // 循环更新渲染场景
        this.update();
    }
}
  1. 创建场景

我们需要在该过程中创建Three.js 的相机实例,并设定相机位置,即视线位置;

然后创建渲染器实例,设定其尺寸背景色,同时还开启了它的阴影效果,在光照下会更真实,它的主要工作便是计算当前在自己的尺寸范围下看到所有视象并绘制到canvas上;

最后考虑到可能的屏幕缩放,监听窗口大小变动来动态调整渲染器尺寸与相机横纵比,达到最佳显示效果。

createScene() {
    this.HEIGHT = window.innerHeight;
    this.WIDTH = window.innerWidth;
    // 创建场景
    this.scene = new THREE.Scene();
    // 在场景中添加雾的效果,参数分别代表‘雾的颜色’、‘开始雾化的视线距离’、刚好雾化至看不见的视线距离’
    this.scene.fog = new THREE.Fog(0x090918, 1, 600);
    // 创建相机
    let aspectRatio = this.WIDTH / this.HEIGHT;
    let fieldOfView = 60;
    let nearPlane = 1;
    let farPlane = 10000;
    /**
     * PerspectiveCamera 透视相机
     * @param fieldOfView 视角
     * @param aspectRatio 纵横比
     * @param nearPlane 近平面
     * @param farPlane 远平面
     */
    this.camera = new THREE.PerspectiveCamera(
        fieldOfView,
        aspectRatio,
        nearPlane,
        farPlane
    );

    // 设置相机的位置
    this.camera.position.x = 0;
    this.camera.position.z = 150;
    this.camera.position.y = 0;
    // 创建渲染器
    this.renderer = new THREE.WebGLRenderer({
        // 在 css 中设置背景色透明显示渐变色
        alpha: true,
        // 开启抗锯齿
        antialias: true
    });
    // 渲染背景颜色同雾化的颜色
    this.renderer.setClearColor(this.scene.fog.color);
    // 定义渲染器的尺寸;在这里它会填满整个屏幕
    this.renderer.setSize(this.WIDTH, this.HEIGHT);

    // 打开渲染器的阴影地图
    this.renderer.shadowMap.enabled = true;
    // this.renderer.shadowMapSoft = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
    // 在 HTML 创建的容器中添加渲染器的 DOM 元素
    this.container.appendChild(this.renderer.domElement);
    // 监听屏幕,缩放屏幕更新相机和渲染器的尺寸
    window.addEventListener('resize', this.handleWindowResize.bind(this), false);
}
// 窗口大小变动时调用
handleWindowResize() {
    // 更新渲染器的高度和宽度以及相机的纵横比
    this.HEIGHT = window.innerHeight;
    this.WIDTH = window.innerWidth;
    this.renderer.setSize(this.WIDTH, this.HEIGHT);
    this.camera.aspect = this.WIDTH / this.HEIGHT;
    this.camera.updateProjectionMatrix();
}

解释几点:

当需要模拟物体在远处逐渐模糊的视觉现象,可开启“雾化”效果,此时雾的颜色最好与场景色相同。

关于相机,可分为两种:正交投影相机(OrthographicCamera)透视投影相机(PerspectiveCamera)

先看第一种,正交投影相机,构造函数如下:

OrthographicCamera(left, right, top, bottom, near, far);

这六个投影面围成的区域就是相机投影的可见区域,该相机所看到的物体并不会出现近大远小的视觉现象,如同高中数学几何题中的图例,实际相同长的边远处近处均一样长(怪不得当时有人用尺子量答案...),效果如下:

第二种,透视投影相机,这种更符合人眼看到的近大远小的真实世界,本文均使用该相机。

构造函数如下:

/**  
* PerspectiveCamera 透视相机
* @param fov 视角
* @param aspect 纵横比
* @param near 近平面
* @param far 远平面
*/
PerspectiveCamera(fov, aspect, near, far);

它看到的正方体就是下面这样的:

  1. 创建灯光

这里创建了3种光源:户外光源(HemisphereLight)环境光源(AmbientLight)DirectionalLight(平行光源)

户外光源可以用来模拟靠天越亮,靠地越暗的户外反光效果。

环境光源可作用于物体的任何一个角落,一般设置为近白色的极淡光,用来避免物体某角度下某部分出现完全漆黑的情况。

平行光源是我们使用的主光源,像太阳光平行照射在地面一样,用它来生成阴影效果。

createLights() {
    // 户外光源
    // 第一个参数是天空的颜色,第二个参数是地上的颜色,第三个参数是光源的强度
    this.hemisphereLight = new THREE.HemisphereLight(0xaaaaaa, 0x000000, .9);

    // 环境光源
    this.ambientLight = new THREE.AmbientLight(0xdc8874, .2);

    // 方向光是从一个特定的方向的照射
    // 类似太阳,即所有光源是平行的
    // 第一个参数是关系颜色,第二个参数是光源强度
    this.shadowLight = new THREE.DirectionalLight(0xffffff, .9);

    // 设置光源的位置方向
    this.shadowLight.position.set(50, 50, 50);

    // 开启光源投影
    this.shadowLight.castShadow = true;

    // 定义可见域的投射阴影
    this.shadowLight.shadow.camera.left = -400;
    this.shadowLight.shadow.camera.right = 400;
    this.shadowLight.shadow.camera.top = 400;
    this.shadowLight.shadow.camera.bottom = -400;
    this.shadowLight.shadow.camera.near = 1;
    this.shadowLight.shadow.camera.far = 1000;

    // 定义阴影的分辨率;虽然分辨率越高越好,但是需要付出更加昂贵的代价维持高性能的表现。
    this.shadowLight.shadow.mapSize.width = 2048;
    this.shadowLight.shadow.mapSize.height = 2048;

    // 为了使这些光源呈现效果,需要将它们添加到场景中
    this.scene.add(this.hemisphereLight);
    this.scene.add(this.shadowLight);
    this.scene.add(this.ambientLight);
}

如果只有单一光源会是什么效果?例如只有主光源(平行光源)。

初中物理学过,看到的物体颜色是光线照射到物体表面,经过物体表面的吸收后反射回人眼的颜色,我们一般所说的物体颜色,便是指它在太阳光的照射下呈现给我们的视觉颜色,而它的视觉颜色,便是它不吸收的颜色的混合。

有点绕,例如,我们说这个方块是红色(0xff0000)的(在太阳光下),那么一束白光(0xffffff)(太阳光)打过去,两者取“与”(0xff0000 & 0xffffff)得到0xff0000(视觉颜色),仍是红色。

如下图,左边是红色方块,右边是白色方块,采用0xffffff平行光照射:

this.shadowLight = new THREE.DirectionalLight(0xffffff, 1.0);
// 物体添加
addObjs(){
    // 红色方块
    let cube = new THREE.BoxGeometry(20, 20, 20);
    let mat = new THREE.MeshPhongMaterial({
        color: new THREE.Color(0xff0000)
    });
    let m_cube = new THREE.Mesh(cube, mat);
    m_cube.castShadow = true;
    m_cube.position.x = -20;

    // 白色方块
    let cube2 = new THREE.BoxGeometry(20, 20, 20);
    let mat2 = new THREE.MeshPhongMaterial({
        color: new THREE.Color(0xffffff)
    });
    let m_cube2 = new THREE.Mesh(cube, mat2);
    m_cube2.castShadow = true;
    m_cube2.position.x = 20;

    // 物体添加至场景
    this.scene.add(m_cube);
    this.scene.add(m_cube2);
}

若换成0x00ffff的平行光源照射,0xff0000 & 0x00ffff得到0x000000(黑色),所以左边原本红色的方块直接变漆黑了。

this.shadowLight = new THREE.DirectionalLight(0x00ffff, 1.0);

由此可见,在只有单一光源下,不仅场景略显黯淡,当设定的光源色还不是白色时,会出现我们可能觉得意料之外的颜色,所以添加户外光源与环境光源,来弱化这种光色吸收效果,也使场景更加明亮,添加后的效果如下(此时主光源仍是0x00ffff):

  1. 性能监控

使用stats.js插件做性能监控,可以直观地看到渲染帧率,当帧率在60FPS及以上时,人眼看起来会觉得非常流畅,玩吃鸡拼显卡性能也是为了保持高帧率且流畅的游戏体验,所以当发现自己构建的网页3D帧率在复杂场景下帧率下降明显,就得需要考虑性能优化了。

<!-- 在引入three.js库之后引入插件 -->
<script src="./lib/stats.min.js"></script>
initStats() {
    this.stats = new Stats();
    // 将性能监控屏区显示在左上角
    this.stats.domElement.style.position = 'absolute';
    this.stats.domElement.style.bottom = '0px';
    this.stats.domElement.style.zIndex = 100;
    this.container.appendChild(this.stats.domElement);
}

  1. 物体添加

3D物体的构成可分为两个部分:

  • 几何模型(Geometry):它用来承载构成这个几何体的所有顶点信息以及变换属性和方法。 例如创建一个空的几何模型,可以看到基本属性如下
    vertices用于保存顶点位置信息;而faceVertexUvs是一个多维数组,用来保存该模型上的UV映射关系,例如一个贴图该以怎样的位置关系被贴在模型上。它还有一些原生方法,支持模型的拷贝clone,矩阵变换applyMatrix,旋转rotate、缩放scale与平移translate等等。
    当然了,Three.js为我们内置了许多种不同形状的几何模型,常见比如盒子模型(BoxGeometry)圆形模型(CircleGeometry)球体模型(SphereGeometry)圆柱体模型(CylinderGeometry)平面模型(PlaneGeometry),它们均预置好了顶点位置的排列规则及初始的UV映射,其他几何模型及详细使用可参照官方文档。

  • 材质(Materials):可简单的描述为物体在光线照射下表现的反射特征,这些特征反馈到我们人眼就能看到粗糙光滑透明等视觉现象。

常见的材质介绍:

(1) 基础网孔材料(MeshBasicMaterial):一个以简单着色(平面或线框)方式来绘制几何形状的材料。

例如以平面方式绘制的环面扭结模型长这样:

(2)兰伯特网孔材料(MeshLambertMaterial):一种非发光材料(兰伯特)的表面,计算每个顶点;可以理解为它是拥有漫反射表面属性的材质,可用来模拟非光滑粗糙的材质效果。

(3)Phong网孔材料(MeshPhongMaterial):用于表面有光泽的材料,计算每个像素;常用来模拟金属的光泽效果。

(4)着色器材料(ShaderMaterial):使用自定义着色器渲染的材料。着色器(shader)是一段使用 GLSL 语言编写的可在GPU上直接运行的程序,可以实现除内置 materials 之外的效果。(木有图)介绍这个是因为后面实现粒子渲染时会用到。

所以!当我们拥有模型材质后,将他们mesh起来,即将材质应用到该模型,然后设定好位置将它添加到场景,就能得到一个完整的3D物体了!

创建3D物体

先来个最基本的,创建一个立方体BoxGeometry,其构造函数为:

BoxGeometry(width, height, depth, widthSegments, heightSegments, depthSegments)

前三个参数分别代表立方体的,后三个参数代表对应方向上的分段数量;分段数量有什么作用?可以理解为分段数越大,该几何模型就会被划分得更精细,顶点数量就会越多(例如创建球体模型时分段数量足够大,它就足够圆)。

示例,将不同分段的同样大小的立方体放在一起做对比:

// 物体添加
addObjs(){
    // 使用基础网孔材料
    let mat = new THREE.MeshBasicMaterial({
        color: 0xff0000,
        // 绘制为线框
        wireframe: true
    });
    // 创建立方体几何模型
    let cube1 = new THREE.BoxGeometry(10, 20, 30, 1, 1, 1);
    // 混合模型与材质
    let m_cube1 = new THREE.Mesh(cube1, mat);
    let cube2 = new THREE.BoxGeometry(10, 20, 30, 2, 2, 2);
    let m_cube2 = new THREE.Mesh(cube2, mat);
    let cube3 = new THREE.BoxGeometry(10, 20, 30, 3, 3, 3);
    let m_cube3 = new THREE.Mesh(cube3, mat);
    m_cube1.position.x = -30;
    m_cube2.position.x = 0;
    m_cube3.position.x = 30;
    this.scene.add(m_cube1);
    this.scene.add(m_cube2);
    this.scene.add(m_cube3);
}

用贴图材质来创建个“木箱”:

// 物体添加
addObjs(){
    let cube = new THREE.BoxGeometry(20, 20, 20);
    // 使用Phong网孔材料
    let mat = new THREE.MeshPhongMaterial({
        color: new THREE.Color(0xffffff),
        // 导入纹理贴图
        map: THREE.ImageUtils.loadTexture('img/crate.jpg')
    });
    let m_cube = new THREE.Mesh(cube, mat);
    m_cube.castShadow = true;
    this.scene.add(m_cube);
}

立方体模型在使用贴图的情况下,会基于自身预置的UV映射把图像重复应用到每个面上,而在尺寸不相符的情况下贴图会被自动拉伸或挤窄去适应该面,例如把立方体尺寸修改下就变成下面这样:

当然了,我们可以针对每个面都赋予不同的贴图,或者修改其UV映射关系,将一张图的不同区域显示到特定的面上,有兴趣的可以戳这儿,查看实现原理。

  1. 场景渲染

做了以上那么多准备工作,终于到了把这小3D世界渲染呈现的时候了:

// 循环更新渲染
update() {
    // 动画插件
    TWEEN.update();
    // 性能监测插件
    this.stats.update();
    // 渲染器执行渲染
    this.renderer.render(this.scene, this.camera);
    // 循环调用
    requestAnimationFrame(() => {
        this.update()
    });
}

这里冒出了一个TWEEN,这是执行动画效果的常用插件,可以设置属性值的随时间变化的过程,而在每一帧渲染的时候都需调用TWEEN.update()使属性值及时更新。

同理,stats插件也在每帧调用其update函数,方便其计算帧率等性能信息。

最关键的,每帧需调用渲染操作,更新场景当下的视觉画面。

最后一步使用requestAnimationFrame重复调用自身,从而达到循环渲染的目的。requestAnimationFrame采用系统时间间隔,保持最佳绘制效率,不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间太长,使用动画卡顿不流畅,网页动画居家旅行必备。

创建3D组合

我们可以将多个不同的物体塞到同一个3D组合里,重拾乐高积木的乐趣。

addObjs(){
    let mat = new THREE.MeshPhongMaterial({
        color: new THREE.Color(0xffffff);
    });
    // 创建一个3D物体组合容器
    let group = new THREE.Object3D();
    let radius = 40;
    let m_cube;
    for (let deg = 0; deg < 360; deg += 30) {
        // 创建白色方块的mesh
        m_cube = new THREE.Mesh(new THREE.BoxGeometry(20, 20, 20), mat);
        // 设置它可以产生投影
        m_cube.castShadow = true;
        // 设置它可以接收其他物体在其表面的投影
        m_cube.receiveShadow = true;
        // 用方块画个圈
        m_cube.position.x = radius * Math.cos(Math.PI * deg / 180);
        m_cube.position.y = radius * Math.sin(Math.PI * deg / 180);
        // z轴位置错落摆放
        m_cube.position.z = deg % 60 ? 5 : -5;
        // 放入容器
        group.add(m_cube);
    }
    // 3D组合添加至场景
    this.scene.add(group);
}


二、 外部模型导入

当需要在网页上呈现复杂模型的时候,就需要从外部导入模型了,不同模型制作软件例如3dmaxBlender,它们可以导出不同格式的3d文件;市面上的3d格式之多恕我孤陋寡闻我大多基本都没听说过...,但Three.js有提供较为全面的模型导入插件,可戳这儿查看。

这里介绍我认为常用的三种3d格式(不敢理直气壮,仅仅是我认为):

  • js/json 前端仔看见这个格外亲切,它是专门为Three.js设计的3D格式,Three.js库中也自带该loader。
  • obj与mtl OBJ是一种简单的三维文件格式,只用来定义对象的几何体。MTL文件通常和OBJ文件一起使用,在一个MTL文件中,定义对象的材质。
  • fbx 是FilmBoX这套软件所使用的格式,其最大的用途是用在诸如在max、maya、softimage等软件间进行模型、材质、动作和摄影机信息的互导,因此在创建三维内容的应用软件之间具有无与伦比的互用性。

还有vtk格式与ply格式,先不介绍了。(因为我手头没这些模型...(´-ι_-`)

使用前记得先引入对应插件:

<script src="./lib/OBJLoader.js"></script>
<script src="./lib/MTLLoader.js"></script>
<script src="./lib/inflate.min.js"></script>
<script src="./lib/FBXLoader.js"></script>

其中出现了个inflate.min.js,是FBXLoader在使用时所必须的插件。

接下来写个自己的loader,可以并发加载多个不同格式的模型:

// 自定义模型加载器
loader(pathArr) {
    // 各类loader实例
    let jsonLoader = new THREE.JSONLoader();
    let fbxLoader = new THREE.FBXLoader();
    let mtlLoader = new THREE.MTLLoader();
    let objLoader = new THREE.OBJLoader();
    let basePath, pathName, pathFomat;
    if (Object.prototype.toString.call(pathArr) !== '[object Array]') {
        pathArr = new Array(1).fill(pathArr.toString());
    }
    let promiseArr = pathArr.map((path) => {
        // 模型基础路径
        basePath = path.substring(0, path.lastIndexOf('/') + 1);
        // 模型名称
        pathName = path.substring(path.lastIndexOf('/') + 1, path.lastIndexOf('.'));
        // 后缀为js或json的文件统一当做js格式处理
        pathName = pathName === 'json' ? 'js' : pathName;
        // 模型格式
        pathFomat = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
        switch (pathFomat) {
            case 'js':
                return new Promise(function(resolve) {
                    jsonLoader.load(path, (geometry, material) => {
                        resolve({
                            // 对于js文件,加载到的模型与材质分开放置
                            geometry: geometry,
                            material: material
                        })
                    });
                });
                break;
            case 'fbx':
                return new Promise(function(resolve) {
                    fbxLoader.load(path, (object) => {
                        resolve(object);
                    });
                });
                break;
            case 'obj':
                return new Promise(function(resolve) {
                    objLoader.load(path, (object) => {
                        resolve(object);
                    });
                });
                break;
            case 'mtl':
                return new Promise(function(resolve) {
                    mtlLoader.setPath(basePath);
                    mtlLoader.load(pathName + '.mtl', (mtl) => {
                        resolve(mtl);
                    });
                });
                break;
            case 'objmtl':
                return new Promise(function(resolve, reject) {
                    mtlLoader.setPath(basePath);
                    mtlLoader.load(`${pathName}.mtl`, (mtl) => {
                        mtl.preload();
                        objLoader.setMaterials(mtl);
                        objLoader.setPath(basePath);
                        objLoader.load(pathName + '.obj', resolve, undefined, reject);
                    });
                });
                break;
            default:
                return '';
        }
    });
    return Promise.all(promiseArr);
}

以上除了单独加载js/jsonfbxobjmtl文件外,还加了个自定义的objmtl,方便将同名的objmtl文件mesh好后返回。需注意,这里列的仅仅是静态模型的导入,像有些可以包含动画信息的3D格式例如js/jsonfbx,要执行其动画效果需额外处理,Three.js官网示例有各类loader的详细使用方法其动画执行代码,需要时可去参考。

试验下:

addObjs() {
    this.loader(['obj/bumblebee/bumblebee.FBX', 'obj/teapot.js', 'obj/monu9.objmtl']).then((result) => {
        let bumblebee = result[0];
        // 加载的js/json格式需手动mesh
        let teapot = new THREE.Mesh(result[1].geometry, result[1].material);
        let monu = result[2];

        // 按场景要求缩放及位移

        bumblebee.scale.x = 0.03;
        bumblebee.scale.y = 0.03;
        bumblebee.scale.z = 0.03;
        bumblebee.rotateX(-Math.PI / 2);
        bumblebee.position.y -= 30;

        teapot.applyMatrix(new THREE.Matrix4().makeTranslation(0, -30, 20));
        teapot.scale.x = 0.2;
        teapot.scale.y = 0.2;
        teapot.scale.z = 0.2;

        monu.applyMatrix(new THREE.Matrix4().makeTranslation(0, -30, 0));
    
        // 开启投影    
        this.onShadow(monu);
        this.onShadow(bumblebee);
        this.onShadow(teapot);
        // 添加至场景
        this.scene.add(bumblebee);
        this.scene.add(teapot);
        this.scene.add(monu);
    });
}
// 递归遍历模型及模型子元素并开启投影
onShadow(obj) {
    if (obj.type === 'Mesh') {
        obj.castShadow = true;
        obj.receiveShadow = true;
    }
    if (obj.children && obj.children.length > 0) {
        obj.children.forEach((item) => {
            this.onShadow(item);
        })
    }
    return;
}

于是得到了大黄蜂纪念碑谷地图中魏然屹立却又凝视着地上莫名冒出来的小茶壶的一番景象 ( ˙-˙ )。

如果觉得还是js/json格式用得爽,想把其他3D格式转换成js/json,除使用软件如Blender转换导出外,网上还有个小工具可以完成这件事 —— convert_to_threejs.py


三、 鼠标与场景中的物体交互

  1. 轨道控制

还记得在最开始constructor函数里创建的orbitControls插件吗?将它引入并开启后,鼠标便可以控制场景的旋转缩放位移,这些只是看上去的效果,它操控的其实是相机位置,缩放时将相机拉远拉近,旋转时将相机位置围绕场景中心点旋转,位移时需要更多一些的计算调整相机位置,使场景看上去在平移。

该控件还有可细调的参数,例如阻尼系数控制旋转或缩放范围等等,可搜索查阅。

<script src="./lib/OrbitControls.js"></script>
// 轨道控制插件
this.orbitControls = new THREE.OrbitControls(this.camera);
this.orbitControls.autoRotate = true;

  1. 鼠标点击与悬浮交互

由于3D世界里的物体不能像网页dom那样,直接绑定“click”等事件,那鼠标在屏幕上点击,该如何确定里面的物体是被点击到了呢?

Three.js提供了射线(Raycaster)类,可以从一点到另一点发射射线,返回射线经过的物体甚至距离。

所以用鼠标点击来拾取物体,可概括为以下流程:

(1)获取鼠标点击的屏幕坐标点

(2)根据canvas窗口大小与屏幕坐标点,计算该点映射到3D场景中的场景坐标点

(3)由视线位置向点击的场景坐标点发射射线;

(4)获取射线的返回信息,射线会按经过物体的顺序收集相应信息并放入数组,通常从该数组首项提取到拾取的物体。

一段简单的鼠标点击拾取物体代码如下:

this.container.addEventListener("mousedown", (event) => {
    let mouse = new THREE.Vector2();
    let raycaster = new THREE.Raycaster();
    // 计算鼠标点击位置转换到3D场景后的位置
    mouse.x = (event.clientX / this.renderer.domElement.clientWidth) * 2 - 1;
    mouse.y = -(event.clientY / this.renderer.domElement.clientHeight) * 2 + 1;
    // 由当前相机(视线位置)像点击位置发射线
    raycaster.setFromCamera(mouse, this.camera);
    let intersects = raycaster.intersectObjects(this.scene.children, true)
    if (intersects.length > 0) {
        // 拿到射线第一个照射到的物体
        console.log(intersects[0].object);
    }
});

但是,身为前端仔,还是觉得要是能像一般网页那样为物体绑定点击事件该多好。

于是我封装了这么个东西(仅供参考):

addMouseListener() {
    // 层层往上寻找模型的父级,直至它是场景下的直接子元素
    function parentUtilScene(obj) {
        if (obj.parent.type === 'Scene') return obj;
        while (obj.parent && obj.parent.type !== 'Scene') {
            obj = obj.parent;
        }
        return obj;
    }
    // canvas容器内鼠标点击事件添加
    this.container.addEventListener("mousedown", (event) => {
        this.handleRaycasters(event, (objTarget) => {
            // 寻找其对应父级为场景下的直接子元素
            let object = parentUtilScene(objTarget);
            // 调用拾取到的物体的点击事件
            object._click && object._click(event);
            // 遍历场景中除当前拾取外的其他物体,执行其未被点击到的事件回调
            this.scene.children.forEach((objItem) => {
                if (objItem !== object) {
                    objItem._clickBack && objItem._clickBack();
                }
            });
        });
    });
    // canvas容器内鼠标移动事件添加
    this.container.addEventListener("mousemove", (event) => {
        this.handleRaycasters(event, (objTarget) => {
            // 寻找其对应父级为场景下的直接子元素
            let object = parentUtilScene(objTarget);
            // 鼠标移动到拾取物体上且未离开时时,仅调用一次其悬浮事件方法
            !object._hover_enter && object._hover && object._hover(event);
            object._hover_enter = true;
            // 遍历场景中除当前拾取外的其他物体,执行其未有鼠标悬浮的事件回调
            this.scene.children.forEach((objItem) => {
                if (objItem !== object) {
                    objItem._hover_enter && objItem._hoverBack && objItem._hoverBack();
                    objItem._hover_enter = false;
                }
            });
        })
    });
    // 为所有3D物体添加上“on”方法,可监听物体的“click”、“hover”事件
    THREE.Object3D.prototype.on = function(eventName, touchCallback, notTouchCallback) {
        switch (eventName) {
            case "click":
                this._click = touchCallback ? touchCallback : undefined;
                this._clickBack = notTouchCallback ? notTouchCallback : undefined;
                break;
            case "hover":
                this._hover = touchCallback ? touchCallback : undefined;
                this._hoverBack = notTouchCallback ? notTouchCallback : undefined;
                break;
            default:;
        }
    }
}
// 射线处理
handleRaycasters(event, callback) {
    let mouse = new THREE.Vector2();
    let raycaster = new THREE.Raycaster();
    mouse.x = (event.clientX / this.renderer.domElement.clientWidth) * 2 - 1;
    mouse.y = -(event.clientY / this.renderer.domElement.clientHeight) * 2 + 1;
    raycaster.setFromCamera(mouse, this.camera);
    let intersects = raycaster.intersectObjects(this.scene.children, true)
    if (intersects.length > 0) {
        callback && callback(intersects[0].object);
    }
}

虽然带了尽可能详尽的注释,但我不得不跳出来解释下上面代码干了什么事情。

鼠标在屏幕里移动时,整个canvas容器绑定的mousedownmouseupmousemove事件(touch类的事件先放一放)会是鼠标与3D场景之间交互的纽带。

所以我在3D对象的基类THREE.Object3D上增加了个叫“on”的原型方法,目前只支持自定义的clickhover事件,当调用时,该原型方法仅是在该3D对象上挂载了对应的回调处理函数;

例如绑定click事件,将会将剩下两个入参touchCallbacknotTouchCallback赋值到该3D对象的_click_clickBack方法属性上,分表代表点击到该物体的回调函数点击了却没有点击到自己时候的回调函数(后面那个回调看上去没卵用,在某些情况下却好使,不需要的话不传就好了);

然后为整个canvas容器绑定原生的mousedownmousemove事件;

mousedown触发时,发射线拿到点击到的第一个物体,看它身上是否有_click方法,有就执行;接着,遍历场景中除自身之外的直系子元素(就是被scene.add方法加入至场景的那些物体),看看它们身上有_clickBack方法,有就执行下。

mousemove用来模拟物体的hover事件,当鼠标在屏幕移动时,同之前一样用射线获取到鼠标接触到的物体,执行其_hover方法,场景中的其他物体执行其_hoverBack方法,值得注意的是,我多添加了一个_hover_enter的标志变量来区分鼠标在当前物体的状态,是鼠标已在物体之上还是鼠标已离开物体,避免回调函数重复执行。

但是!当点击物体的时候,你以为你的射线小能手帮你拿到的就一定是你看到的这个物体的本体了吗!!!!∑(゚Д゚ノ)ノ

当使用复杂的外部模型的时候,导入得到的可能是一个物体组合(Group),它里面的children共同组装成了它本体的样子;例如导入了大黄蜂模型,你想实现在点击到它的时候它能够原地旋转360度,于是你biu一个射线发了出去,自信满满地将返回的第一个物体进行变换,结果你可能发现,只是它的一只胳膊开始跳舞,亦或者空气突然安静

为了解决可能存在的上述问题,上面的代码里添加了parentUtilScene函数,用来层层往上寻找物体的父级容器,直至它是场景中的直系子元素,这样拿到的才是该模型的完整体。

示例,为上述导入的模型添加点鼠标交互:

addObjs(){
    this.loader(['obj/bumblebee/bumblebee.FBX', 'obj/teapot.js', 'obj/monu9.objmtl']).then((result) => {
        let bumblebee = result[0];
        let teapot = new THREE.Mesh(result[1].geometry, result[1].material);
        let monu = result[2];

        bumblebee.scale.x = 0.03;
        bumblebee.scale.y = 0.03;
        bumblebee.scale.z = 0.03;
        bumblebee.rotateX(-Math.PI / 2);
        bumblebee.position.y -= 30;

        teapot.applyMatrix(new THREE.Matrix4().makeTranslation(0, -30, 20));
        teapot.scale.x = 0.2;
        teapot.scale.y = 0.2;
        teapot.scale.z = 0.2;

        monu.applyMatrix(new THREE.Matrix4().makeTranslation(0, -30, 0));

        this.onShadow(monu);
        this.onShadow(bumblebee);
        this.onShadow(teapot);
        // 大黄蜂模型被点击时向z轴移动一段距离
        bumblebee.on("click", function() {
            let tween = new TWEEN.Tween(this.position).to({
                z: -10
            }, 200).easing(TWEEN.Easing.Quadratic.InOut).start();
        });
        // 茶壶模型在鼠标悬浮时放大,鼠标离开时缩小
        teapot.on("hover", function() {
            let tween = new TWEEN.Tween(this.scale).to({
                x: 0.3,
                y: 0.3,
                z: 0.3
            }, 200).easing(TWEEN.Easing.Quadratic.InOut).start();
        }, function() {
            let tween = new TWEEN.Tween(this.scale).to({
                x: 0.2,
                y: 0.2,
                z: 0.2
            }, 200).easing(TWEEN.Easing.Quadratic.InOut).start();
        });

        this.scene.add(bumblebee);
        this.scene.add(teapot);
        this.scene.add(monu);
    });
}


四、 3D粒子系统构建

上面为了大概解释清楚Three.js基本价值观,弘扬社会主义我为人人、知识共享的精神风貌,我现在居然才开始着手实现文章开头的粒子效果... 痛苦流涕顿首顿首 ヾ(༎ຶД༎ຶ)ノ"

不,我只是被风沙迷了眼。

咳~

一段最简单的粒子系统代码:

// 创建球体模型
let ball = new THREE.SphereGeometry(40, 30, 30);
// 创建粒子材料
let pMaterial = new THREE.PointsMaterial({
        // 粒子颜色
        color: 0xffffff,
        // 粒子大小
        size: 2
    });
// 创建粒子系统
let particleSystem = new THREE.ParticleSystem(ball, pMaterial);
// 加入场景
this.scene.add(particleSystem);

粒子系统读取模型中的vertices属性,即所有顶点位置,结合粒子材质来创建粒子效果,以上代码效果如下;可以观察到,粒子默认会展示为方块形状,若要修改,可以在构建粒子材质时时传入map属性,应用一张图片或者应用canvas的绘图结果,具体后面会提到。


五、 粒子变换

粒子的变换说到底就是粒子的位置颜色尺寸的变换,渲染方式根据计算场景的不同可分为CPU渲染GPU渲染

针对粒子渲染粗暴来说,

将所有粒子的状态全部维护在js代码中进行计算,属于CPU渲染

将粒子的状态信息维护在shader(着色器)代码中进行计算,属于GPU渲染

当我们只需简单改变各个粒子的位置时,使用CPU渲染性能ok也易于理解,倘若同时对所有粒子进行多状态的变化,使用GPU渲染会更加流畅。

这里会使用GPU渲染方法,即需要编写shader程序。

还是之前的同样简单的粒子效果,使用最基本的shader介入后代码如下:

<!-- html中加入shader代码 -->
<!-- 顶点着色器代码 -->
<script type="x-shader/x-vertex" id="vertexshader">
    void main() {
        gl_PointSize = 4.;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
</script>
<!-- 片元着色器代码 -->
<script type="x-shader/x-fragment" id="fragmentshader">
    uniform vec3 color;
    void main() {
        gl_FragColor = vec4(color, 1.0);
    }
</script>
addObjs(){
    // 加载星球大战里那个叫“BB-8”的机器人模型
    this.loader(['obj/robot.fbx']).then((result) => {
        // 提取出其几何模型
        let robotObj = result[0].children[1].geometry;
        // 适当变换使其完整在屏幕显示
        robotObj.scale(0.08, 0.08, 0.08);
        robotObj.rotateX(-Math.PI / 2);
        robotObj.applyMatrix(new THREE.Matrix4().makeTranslation(0, 10, 0));
        // 把它变成粒子
        this.addPartice(robotObj);
    });
}
// 将几何模型变成几何缓存模型
toBufferGeometry(geometry) {
    if (geometry.type === 'BufferGeometry') return geometry;
    return new THREE.BufferGeometry().fromGeometry(geometry);
}
// 模型转化成粒子
addPartice(obj) {
    obj = this.toBufferGeometry(obj);
    // 传递给shader的属性
    let uniforms = {
        // 传递的颜色属性
        color: {
            type: 'v3', // 指定变量类型为三维向量
            value: new THREE.Color(0xffffff)
        }
    };
    // 创建着色器材料
    let shaderMaterial = new THREE.ShaderMaterial({
        // 传递给shader的属性
        uniforms: uniforms,
        // 获取顶点着色器代码
        vertexShader: document.getElementById('vertexshader').textContent,
        // 获取片元着色器代码
        fragmentShader: document.getElementById('fragmentshader').textContent,
        // 渲染粒子时的融合模式
        blending: THREE.AdditiveBlending,
        // 关闭深度测试
        depthTest: false,
        // 开启透明度
        transparent: true
    });
    let particleSystem = new THREE.Points(obj, shaderMaterial);
    this.scene.add(particleSystem);
}

效果如下:

什么!这不和上一段短短几行代码实现的效果一毛一样吗!为啥还要多写这么多东西!

但是在这基础上只改一行代码:

<script type="x-shader/x-vertex" id="vertexshader">
    void main() {
        // 这是被修改的那一行
        gl_PointSize = 4. + 2. * sin(position.y / 4.);
        
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
</script>
<script type="x-shader/x-fragment" id="fragmentshader">
    uniform vec3 color;
    void main() {
        gl_FragColor = vec4(color, 1.0);
    }
</script>

就能实现粒子尺寸沿Y轴周期性变化的效果:

现在又轮到我开始装模作样地解释事情的来龙去脉:

1. 着色器(shader)是什么?

(看名字好像就是用来上色的)

着色器是屏幕上呈现画面之前的最后一步,用它可以对先前渲染的结果做修改,包括对颜色、位置等等信息的修改,甚至可以对先前渲染的结果做后处理,实现高级的渲染效果。

常用的着色器分为顶点着色器(Vertex Shader)片元着色器(Fragment Shader)

顶点着色器:每个顶点都调用一次的程序,在这之中能够访问到顶点的位置颜色法向量等信息,对他们进行适当的计算能实现特定的渲染效果,也可以将这些值传递到片元着色器中。

片元着色器:每个片元都调用一次的程序,在这之中,可以访问到片元在二维屏幕上的坐标、深度信息、颜色等信息。通过改变这些值,可以实现特定的渲染效果。

2. shader程序里基本有哪些东西?

shader程序是一种类C语言,void main(){}是程序的入口,在这之前需声明需使用到的传递来的变量,例如uniform vec3 color;,分别代表变量传递类型 变量类型 变量名;

(“变量传递类型”这名字是我自己取的,便于理解)

变量传递类型有三种:

  • attribute:用来接受CPU传递过来的顶点数据,一般用来接收从js代码中传递来的顶点坐标法线纹理坐标顶点颜色等信息,attribute 只能在顶点着色器中被声明与使用
  • uniform:可以在顶点着色器和片元着色器中共享使用,且声明方式相同,一般用来声明变换矩阵材质光照参数颜色等信息。
  • varying:它是vertex和fragment shader之间做数据传递用的。一般vertex shader修改varying变量的值,然后fragment shader使用该varying变量的值。因此varying变量在vertex和fragment shader二者之间的声明必须是一致的

变量类型有以下几种:

  • void:和C语言的void一样,无类型。
  • bool:布尔类型。
  • int:有符号整数。
  • float 浮点数。
  • vec2, vec3, vec4: 2,3,4维向量,也可以理解为2,3,4长度的数组。
  • bvec2, bvec3, bvec4:2,3,4维的布尔值的向量。
  • ivec2, ivec3, ivec4: 2,3,4维的int值的向量。
  • mat2, mat3, mat4: 2x2, 3x3, 4x4的浮点数矩阵。
  • sampler2D:纹理。
  • samplerCube:Cube纹理。

由于是类C语言,不像js那样会对变量类型进行自动隐式转换,所以变量在使用前需严格声明,而且在数字运算时,相同类型的数字才能进行加减乘除,例如1 + 1.0会报错。

变量精度

用它们在变量类型前做修饰(例如varying highp float my_number

  • highp:16bit,浮点数范围(-2^62, 2^62),整数范围(-2^16, 2^16)
  • mediump:10bit,浮点数范围(-2^14, 2^14),整数范围(-2^10, 2^10)
  • lowp:8bit,浮点数范围(-2, 2),整数范围(-2^8, 2^8)

如果想设置所有的float都是高精度的,可以在Shader顶部声明precision highp float;,这样就不需要为每一个变量声明精度了。

shader中向量的访问:

当我们有一个vec4的四维向量时:

(这灵活的取值也是惊到我了)

  • vec4.x, vec4.y, vec4.z, vec4.w 通过x,y,z,w可以分别取出4个值。
  • vec4.xy, vec4.xx, vec4.xz, vec4.xyz 任意组合可以取出多种维度的向量。
  • vec4.r, vec4.g, vec4.b, vec4.a 还可以通过r,g,b,a分别取出4个值,同上可以任意组合。
  • vec4.s, vec4.t, vec4.p, vec4.q 还可以通过s,t,p,q分别取出4个值,同上可以任意组合。
  • vec3和vec2也是类似,只是变量相对减少,比如vec3只有x,y,z,vec2只有x,y。

shader内置变量

  • gl_Position:用于vertex shader, 写顶点位置;被图元收集、裁剪等固定操作功能所使用;其内部声明是:highp vec4 gl_Position;
  • gl_PointSize:用于vertex shader, 写光栅化后的点大小,像素个数;其内部声明是:mediump float gl_Position;
  • gl_FragColor:用于Fragment shader,写fragment color;被后续的固定管线使用;mediump vec4 gl_FragColor;
  • gl_FragData:用于Fragment shader,是个数组,写gl_FragData[n] 为data n;被后续的固定管线使用;mediump vec4 gl_FragData[gl_MaxDrawBuffers]; gl_FragColor和gl_FragData是互斥的,不会同时写入;
  • gl_FragCoord: 用于Fragment shader,只读,Fragment相对于窗口的坐标位置 x,y,z,1/w; 这个是固定管线图元差值后产生的;z 是深度值;mediump vec4 gl_FragCoord;
  • gl_FrontFacing: 用于判断 fragment是否属于 front-facing primitive,只读;bool gl_FrontFacing;
  • gl_PointCoord:仅用于 point primitive, mediump vec2 gl_PointCoord;

shader内置函数:

3. shader程序该怎么写?

先看一遍vertex shader的基本的代码:

(之所以包在script便签里只是为了方便three.js获取其文本值)

<!-- 顶点着色器-->
<script type="x-shader/x-vertex" id="vertexshader">
    void main() {
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
</script>

vertex shader中必须指定gl_Position的值,因为顶点着色器的最重要的任务就是计算出顶点的在屏幕上的真实位置,没有计算该值就相当于该顶点着色器无效了。

那么后面没声明又突然冒出来的projectionMatrixmodelViewMatrixposition又是什么鬼?

看名字,分别是投影矩阵模型视图矩阵位置;它们是顶点着色器运行时被自动计算好了传入的。那为什么gl_Position要这么计算?

position好说,指当代表下顶点的位置的三维向量,是模型里顶点的那些xyz,但是只知道这个值,渲染器是不足以画出它在哪儿的,因为电脑屏幕是二维的,相机位置也是可能变化的,用来显示的窗口大小也是不固定的...

所以就需要用到矩阵变换

首先祭出大神画的示意图:

口述下过程:一个物体的三维坐标向量,乘以模型视图矩阵后,能够得到它在视图坐标系中的位置,也就是它相对于摄像机的坐标位置;接着乘以投影矩阵,将每个点的位置一巴掌拍到了二维平面上,得到它在投影坐标系中的位置;再乘以视口矩阵,得到它在屏幕坐标系中的位置,也就是我们端坐在电脑前看到的模样。

所以可以得到变换公式:

由于矩阵乘法比较耗时,而视图矩阵模型矩阵又通常不变,所以根据矩阵结合律,可以先将它们的乘积 先计算并缓存下来,这便是modelViewMatrix,而模型点坐标从原本的三维向量(position)被扩展到了四维向量(vec4(position, 1.0)),是因为其他的矩阵其实是4*4的,不将点坐标扩展到四维便无法相乘;

问题又来了,三维世界里为什么要搞出四维的矩阵?

简单解释,三维矩阵可以实现位置的旋转缩放,但若想平移,还需补充一个齐次的一维,较详细的解释可参考这篇文章

弄清楚这些后,我们想使用vertex shader花里胡哨的动态改变模型点的位置,就可以在这个相乘过程中的任意位置下文章。

轮到vertex shader的基本的代码了:

<script type="x-shader/x-fragment" id="fragmentshader">
    uniform vec3 color;
    void main() {
        gl_FragColor = vec4(color, 1.0);
    }
</script>

片元着色器的终极任务就是计算顶点颜色,所以在该段程序里必须给出gl_FragColor的值,它同位置坐标一样,也是一个四维向量,前三维可以理解为rbg的色值,只不过范围在(0.0,1.0),第四维代表颜色透明度。一般顶点的初始颜色可由CPU通过uniform传入,再经过自己一顿花里胡哨的计算之后给到gl_FragColor

另外这里面向量的赋值方法很有趣也很符合直觉;

gl_FragColor = vec4(color, 1.0);将三维向量color自动展开成了四维向量的前三位;

如果这么写:gl_FragColor = vec4(1.0, color);,那么传递的颜色值中的蓝色通道值就作用在了透明度上。

当然,最老实的一种:gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);,心无杂念纯净至白。


六、 shader渲染粒子特效

铺垫了这么多!终于开工了开工了!

1. 粒子位移变换

哗啦啦上代码:

addObjs() {
    // 加载了两个模型,用于粒子变换
    this.loader(['obj/robot.fbx', 'obj/Guitar/Guitar.fbx']).then((result) => {
        let robot = result[0].children[1].geometry;
        let guitarObj = result[1].children[0].geometry;
        guitarObj.scale(1.5, 1.5, 1.5);
        guitarObj.rotateX(-Math.PI / 2);
        robot.scale(0.08, 0.08, 0.08);
        robot.rotateX(-Math.PI / 2);
        this.addPartices(robot, guitarObj);
    });
}
// 几何模型转缓存几何模型
toBufferGeometry(geometry) {
    if (geometry.type === 'BufferGeometry') return geometry;
    return new THREE.BufferGeometry().fromGeometry(geometry);
}
// 粒子变换
addPartices(obj1, obj2) {
    obj1 = this.toBufferGeometry(obj1);
    obj2 = this.toBufferGeometry(obj2);
    let moreObj = obj1
    let lessObj = obj2;
    // 找到顶点数量较多的模型
    if (obj2.attributes.position.array.length > obj1.attributes.position.array.length) {
        [moreObj, lessObj] = [lessObj, moreObj];
    }
    let morePos = moreObj.attributes.position.array;
    let lessPos = lessObj.attributes.position.array;
    let moreLen = morePos.length;
    let lessLen = lessPos.length;
    // 根据最大的顶点数开辟数组空间,同于存放顶点较少的模型顶点数据
    let position2 = new Float32Array(moreLen);
    // 先把顶点较少的模型顶点坐标放进数组
    position2.set(lessPos);
    // 剩余空间重复赋值
    for (let i = lessLen, j = 0; i < moreLen; i++, j++) {
        j %= lessLen;
        position2[i] = lessPos[j];
        position2[i + 1] = lessPos[j + 1];
        position2[i + 2] = lessPos[j + 2];
    }
    // sizes用来控制每个顶点的尺寸,初始为4
    let sizes = new Float32Array(moreLen);
    for (let i = 0; i < moreLen; i++) {
        sizes[i] = 4;
    }
    // 挂载属性值
    moreObj.addAttribute('size', new THREE.BufferAttribute(sizes, 1));
    moreObj.addAttribute('position2', new THREE.BufferAttribute(position2, 3));
    // 传递给shader共享的的属性值
    let uniforms = {
        // 顶点颜色
        color: {
            type: 'v3',
            value: new THREE.Color(0xffffff)
        },
        // 传递顶点贴图
        texture: {
            value: this.getTexture()
        },
        // 传递val值,用于shader计算顶点位置
        val: {
            value: 1.0
        }
    };
    // 着色器材料
    let shaderMaterial = new THREE.ShaderMaterial({
        uniforms: uniforms,
        vertexShader: document.getElementById('vertexshader').textContent,
        fragmentShader: document.getElementById('fragmentshader').textContent,
        blending: THREE.AdditiveBlending,
        depthTest: false,// 这个不设置的话,会导致带透明色的贴图始终会有方块般的黑色背景
        transparent: true
    });
    // 创建粒子系统
    let particleSystem = new THREE.Points(moreObj, shaderMaterial);
    let pos = {
        val: 1
    };
    // 使val值从0到1,1到0循环往复变化
    let tween = new TWEEN.Tween(pos).to({
        val: 0
    }, 1500).easing(TWEEN.Easing.Quadratic.InOut).delay(1000).onUpdate(callback);
    let tweenBack = new TWEEN.Tween(pos).to({
        val: 1
    }, 1500).easing(TWEEN.Easing.Quadratic.InOut).delay(1000).onUpdate(callback);
    tween.chain(tweenBack);
    tweenBack.chain(tween);
    tween.start();
    // 每次都将更新的val值赋值给uniforms,让其传递给shader
    function callback() {
        particleSystem.material.uniforms.val.value = this.val;
    }
    // 粒子系统添加至场景
    this.scene.add(particleSystem);
    this.particleSystem = particleSystem;
}
// 用canvas画了个带渐变的圆,将该图像作为纹理返回
getTexture(canvasSize = 64) {
    let canvas = document.createElement('canvas');
    canvas.width = canvasSize;
    canvas.height = canvasSize;
    canvas.style.background = "transparent";
    let context = canvas.getContext('2d');
    let gradient = context.createRadialGradient(canvas.width / 2, canvas.height / 2, canvas.width / 8, canvas.width / 2, canvas.height / 2, canvas.width / 2);
    gradient.addColorStop(0, '#fff');
    gradient.addColorStop(1, 'transparent');
    context.fillStyle = gradient;
    context.beginPath();
    context.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2, true);
    context.fill();
    let texture = new THREE.Texture(canvas);
    texture.needsUpdate = true;
    return texture;
}

shader代码如下:

<script type="x-shader/x-vertex" id="vertexshader">
    attribute float size;
    attribute vec3 position2;
    uniform float val;
    void main() {
        vec3 vPos;
        // 变动的val值引导顶点位置的迁移
        vPos.x = position.x * val + position2.x * (1. - val);
        vPos.y = position.y * val + position2.y * (1. - val);
        vPos.z = position.z * val + position2.z * (1. - val);
        vec4 mvPosition = modelViewMatrix * vec4( vPos, 1.0 );
        gl_PointSize = 4.;
        gl_Position = projectionMatrix * mvPosition;
    }
</script>
<script type="x-shader/x-fragment" id="fragmentshader">
    uniform vec3 color;
    uniform sampler2D texture;
    void main() 
        gl_FragColor = vec4( color, 1.0 );
        // 顶点颜色应用上2D纹理
        gl_FragColor = gl_FragColor * texture2D( texture, gl_PointCoord );
    }
</script>

超简单地概括上面的流程:

(1)加载了两个几何模型并它们变成缓存几何模型(效率更高)

(2)用点多的那个模型构建粒子系统,保存另一个模型的顶点位置至position2属性

(3)js只维护并改变val值并更传递到顶点着色器,着色点根据val值计算,使顶点坐标在position(顶点多的模型的顶点位置)position2(顶点少的模型的顶点位置)之间过渡。

效果如下:

2. 粒子尺寸变换

之前代码里往shader里传入了size属性但并没有用到,现在用js改变这这个值并传递到shader。

在循环渲染的函数里根据时间修改size值:

update() {
    TWEEN.update();
    this.stats.update();
    // 动态改变size大小
    let time = Date.now() * 0.005;
    if (this.particleSystem) {
        let bufferObj = this.particleSystem.geometry;
        // 粒子系统缓缓旋转
        this.particleSystem.rotation.y = 0.01 * time;
        let sizes = bufferObj.attributes.size.array;
        let len = sizes.length;
        for (let i = 0; i < len; i++) {
            sizes[i] = 1.5 * (2.0 + Math.sin(0.02 * i + time));
        }
        // 需指定属性需要被更新
        bufferObj.attributes.size.needsUpdate = true;
    }
    this.renderer.render(this.scene, this.camera);
    requestAnimationFrame(() => {
        this.update()
    });
}

vertex shader中,只需要把

gl_PointSize = 4.;

替换为

// size在之前已经用attribute声明过了
gl_PointSize = size;

然后这粒子变换效果就带了些许 buling buling 亮晶晶(gif图上传被压缩了,所以显得在快放):

3. 粒子模糊效果

这里为了模拟粒子越远越模糊的效果,先计算粒子的模型视图坐标,即从相机看到的粒子坐标,然后跟据其z轴值计算粒子的尺寸透明度,离视线越远的粒子,使其尺寸越大,同时透明度越小

<script type="x-shader/x-vertex" id="vertexshader">
    attribute float size;
    attribute vec3 position2;
    uniform float val;
    // 颜色透明度
    varying float opacity;
    void main() {
        // 开始产生模糊的z轴分界
        float border = -150.0;
        // 最模糊的z轴分界
        float min_border = -160.0;
        // 最大透明度
        float max_opacity = 1.0;
        // 最小透明度
        float min_opacity = 0.03;
        // 模糊增加的粒子尺寸范围
        float sizeAdd = 20.0;
        
    	vec3 vPos;
        vPos.x = position.x * val + position2.x * (1.-val);
        vPos.y = position.y* val + position2.y * (1.-val);
        vPos.z = position.z* val + position2.z * (1.-val);

        vec4 mvPosition = modelViewMatrix * vec4( vPos, 1.0 );
        // z轴坐标越小越模糊,即越远越模糊
        if(mvPosition.z > border){
            opacity = max_opacity;
            gl_PointSize = size;
        }else if(mvPosition.z < min_border){
            opacity = min_opacity;
            gl_PointSize = size + sizeAdd;
        }else{
            // 模糊程度随距离远近线性增长
            float percent = (border - mvPosition.z)/(border - min_border);
            opacity = (1.0-percent) * (max_opacity - min_opacity) + min_opacity;
            gl_PointSize = percent * (sizeAdd) + size;  
        }
        gl_Position = projectionMatrix * mvPosition;
    }
</script>
<script type="x-shader/x-fragment" id="fragmentshader">
	uniform vec3 color;
	uniform sampler2D texture;
	varying float opacity;
	void main() {
	    // 根据传递过来的透明度值设置颜色
		gl_FragColor = vec4(color, opacity);
		gl_FragColor = gl_FragColor * texture2D( texture, gl_PointCoord );
	}
</script>

改造之后的效果如下:

当然,我们可以暂时去掉模型的变换动画,直接用鼠标拖拽模型,更好的观察粒子随距离的变化。

4. 粒子彩色分布

(原谅我又把代码扔一遍) 这里做了小小改动,增加了varying vec3 vColor,在vertex shader中根据顶点在模型视图坐标系中的y轴坐标计算了vColor的值,并传递至fragment shader,从而使粒子产生横向分布的彩色条纹。

<script type="x-shader/x-vertex" id="vertexshader">
    attribute float size;
    attribute vec3 position2;
    uniform float val;
    // 颜色透明度
    varying float opacity;
    // 传递给片元着色器的颜色值
    varying vec3 vColor;
    void main() {
        // 开始产生模糊的z轴分界
        float border = -150.0;
        // 最模糊的z轴分界
        float min_border = -160.0;
        // 最大透明度
        float max_opacity = 1.0;
        // 最小透明度
        float min_opacity = 0.03;
        // 模糊增加的粒子尺寸范围
        float sizeAdd = 20.0;
        
    	vec3 vPos;
        vPos.x = position.x * val + position2.x * (1.-val);
        vPos.y = position.y* val + position2.y * (1.-val);
        vPos.z = position.z* val + position2.z * (1.-val);

        vec4 mvPosition = modelViewMatrix * vec4( vPos, 1.0 );
        // z轴坐标越小越模糊,即越远越模糊
        if(mvPosition.z > border){
            opacity = max_opacity;
            gl_PointSize = size;
        }else if(mvPosition.z < min_border){
            opacity = min_opacity;
            gl_PointSize = size + sizeAdd;
        }else{
            // 模糊程度随距离远近线性增长
            float percent = (border - mvPosition.z)/(border - min_border);
            opacity = (1.0-percent) * (max_opacity - min_opacity) + min_opacity;
            gl_PointSize = percent * (sizeAdd) + size;  
        }
        float positionY = vPos.y;
        
       //  根据y轴坐标计算传递的顶点颜色值
        vColor.x = abs(sin(positionY));
        vColor.y = abs(cos(positionY));
        vColor.z = abs(cos(positionY));
        gl_Position = projectionMatrix * mvPosition;
    }
</script>
<script type="x-shader/x-fragment" id="fragmentshader">
	uniform vec3 color;
	uniform sampler2D texture;
	varying float opacity;
	varying vec3 vColor;
	void main() {
	    // 根据传递过来的颜色及透明度值计算最终颜色
		gl_FragColor = vec4(vColor * color, opacity);
		gl_FragColor = gl_FragColor * texture2D( texture, gl_PointCoord );
	}
</script>

5. 粒子颜色变换

一成不变的彩色略显单调,再改造下粒子运行动画时候的属性值,在每轮动画结束后随机生成新的粒子颜色值,然后在粒子模型变换过程中,同时将旧颜色过渡到新颜色。

let tween = new TWEEN.Tween(pos).to({
    val: 0
}, 1500).easing(TWEEN.Easing.Quadratic.InOut).delay(2000).onUpdate(updateCallback).onComplete(completeCallBack.bind(pos, 'go'));
let tweenBack = new TWEEN.Tween(pos).to({
    val: 1
}, 1500).easing(TWEEN.Easing.Quadratic.InOut).delay(2000).onUpdate(updateCallback).onComplete(completeCallBack.bind(pos, 'back'));
tween.chain(tweenBack);
tweenBack.chain(tween);
tween.start();
// 动画持续更新的回调函数
function updateCallback() {
    particleSystem.material.uniforms.val.value = this.val;
    // 颜色过渡
    if (this.nextcolor) {
        let val = (this.order === 'back' ? (1 - this.val) : this.val);
        let uColor = particleSystem.material.uniforms.color.value;
        uColor.r = this.color.r + (this.nextcolor.r - this.color.r) * val;
        uColor.b = this.color.b + (this.nextcolor.b - this.color.b) * val;
        uColor.g = this.color.g + (this.nextcolor.g - this.color.g) * val;
    }
}
// 每轮动画完成时的回调函数
function completeCallBack(order) {
    let uColor = particleSystem.material.uniforms.color.value;
    // 保存动画顺序状态
    this.order = order;
    // 保存旧的粒子颜色
    this.color = {
        r: uColor.r,
        b: uColor.b,
        g: uColor.g
    }
    // 随机生成将要变换后的粒子颜色
    this.nextcolor = {
        r: Math.random(),
        b: Math.random(),
        g: Math.random()
    }
}
this.scene.add(particleSystem);
this.particleSystem = particleSystem;

至此,我们就一步步实现了文章开头出现的粒子特效!

(忍不住再放一遍图)

当然,数学再好一点,结合一些神奇的非线性函数,可以玩出更华丽丽的效果 ~


现在是大概真写完了

我没想到这篇文章写起来刹不住... (´-ι_-`)

感谢各位看官耐心看我絮絮叨叨了这么多 ~

虽然这些还仅仅只是网页虚拟三维世界里的冰山一角,但能够对有兴趣的初学者有所裨益,本篇的目的就达到了;我得承认我也只是多自己翻了点资料的初学者,文章里若有理解误区,望路过的大神瞥眼发现后友情提醒,提前拜谢 ~

代码地址:(拷贝下来需在服务器上运行)

github.com/youngdro/3D…

里面有颗耐心等待被戳的star,你看它长这样:★

在线预览传送门

--------- 致谢分割线 ---------

本文借鉴了以下两篇文章的部分代码与图片,向前辈致敬:

three.js粒子效果(分别基于CPU&GPU实现)

卡通渲染(上)

--------- 广告分割线 ---------

往期段子文:

console觉醒之路,打印个动画如何?

node基金爬虫,自导自演了解一下?