Three.js 动效方案

本文做者 陈舒仪

图片来源 Pixabay,做者 Arek Sochahtml

背景

Three.js(下面简称 Three) 做为一个 3D 库,不只减小了咱们学习 OpenGL 和 WebGL 的成本,还大大提高了前端在可视化上给用户带来更多的真实、沉浸式的体验。众所周知,Three 更多的是用 3D 模型 + 投影相机 + 用户交互的方式来构建一个「3D 世界」。 前端

这张专辑,用眼睛去“听” 活动中,在视觉在只能提供「2D 切图」的状况下,须要营造「3D 效果」。为了得到最好视觉体验,仅仅经过贴图很难作到,因此借此机会探索了 Three 的动效方案。git

运动每每是相对的,运动的本质多是「物体动」或「相机动」,本文将从对象动画相机动画上阐述对 Three 的动效探索。github

Three 基础

Camera 相机

Three 提供多种相机,其中应用最广的就是投影相机 (PerspectiveCamera) ,经过投影相机能够模拟人眼所看见的效果。算法

const camera = THREE.PerspectiveCamera(fov, aspect, near, far);
参数 含义 默认值
fov fov 是视景体竖直方向上(非水平!)的张角,人类有接近180度的视角大小。该值可根据具体场景所须要的视角设置。 45
aspect             指定渲染结果的横向尺寸和纵向尺寸的比值。该值一般设置为窗口大小的宽高比。 window.innerWidth / window.innerHeight
near 表示能够看到多近的物体。这个值一般很小。 0.1
far 表示能够看到多远的物体。这个看状况设置,过大会致使渲染过多;过小可能又会看不到。 1000

ps: 在 Three 中是没有「长度单位」这个概念的,它的数值都是根据比例计算得出,所以这里提到的 0.1 或 1000 都没有具体的含义,而是一种相对长度。canvas

相机

能够看到,经过配置透视相机的相关参数,最终被渲染到屏幕上的,是在 nearfar 之间,根据 fov 的值和物体远近 d 肯定渲染高度,再经过 aspect 值来肯定渲染宽度的。segmentfault

Scene 场景

有了相机,咱们还要有场景,场景是为了让咱们设置咱们的空间内「有什么」和「放在哪」的。咱们能够在场景中放置物体,光源还有相机。api

const scene = new THREE.Scene();

是的,建立场景就是这么简单。数组

Group

为了以群的维度去区分场景中的物体,咱们还能够在场景中添加 Group。有了 Group,能够更方便地操做一类物体。
好比建立一个 stoneGroup,并添加到场景中:app

const stoneGroup = new THREE.Group();
stoneGroup.name = 'stoneGroup';

scene.add(stoneGroup);

为 Group 命名,容许咱们经过 name 来获取到对应的 Group:

const group = scene.getObjectByName(name);

Geometry 几何体

Three 提供了多种类型的几何体,能够分为二维网格和三维网格。二维网格顾名思义只有两个维度,能够经过这种几何体建立简单的二维平面;三维网格容许你定义三维物体;在 Three 中定义一个几何体十分简单,只须要选择须要的几何体并传入相应参数建立便可。

查看Three提供的几何体

若是看到 Three 提供的几何体,能够看到有的几何体中它分别提供 GeometeryBufferGeometery 版本,关于这两个的区别,能够看这里 回答

大体意思就是使用 Buffer 版本的几何体相较于普通的几何体会将描述物体的数据存放在缓冲区中,减小内存消耗和 CPU 循环。经过它们提供的方法来看,使用 geometry 无疑是对新手友好的。

建立几何体:

// 建立立方体,传入长、宽和高
var cubeGeometry = new THREE.CubeGeometry(40, 40, 40);
// 建立球体,传入半径、宽片断数量和高片断数量
var sphereGeometry = new THREE.SphereGeometry(20, 100, 100);

Material 材质

定义材质能够帮助咱们决定一个物体在各类环境状况下的具体表现。一样 Three 也提供了多种材质。下面列举几个经常使用的材质。

名称 描述
MeshBasicMaterial 基础材质,用它定义几何体上的简单颜色或线框
MeshPhongMaterial 受光照影响,用来建立光亮的物体
MeshLambertMaterial 受光照影响,用来建立不光亮的物体
MeshDepthMaterial 根据相机远近来决定如何给网格染色

建立材质:

var basicMaterial = new THREE.MeshBasicMaterial({ color: 0x666666 });
var lambertMaterial = new THREE.MeshLambertMaterial({ color: 0x666666 });
var phongMaterial = new THREE.MeshPhongMaterial({ color: 0x666666 });
var wireMaterial = new THREE.MeshBasicMaterial({ wireframe: true, color: 0x666666 });

material

更多材质和相关信息,能够查看 材质

Mesh网格对象

须要添加到场景中,还须要依赖 Mesh。Mesh 是用来定义材质和几何体之间是如何粘合的,建立网格对象能够应用一个或多个材质和几何体。

建立几何体相同材质不一样的网格对象:

var cube = new THREE.Mesh(cubeGeometry, basicMaterial);
var cubePhong = new THREE.Mesh(cubeGeometry, phongMaterial);
scene.add(cube, cubePhong);

建立材质相同几何体不一样的网格对象:

var cube = new THREE.Mesh(cubeGeometry, basicMaterial);
var sphere = new THREE.Mesh(sphereGeometry, basicMaterial);
scene.add(cube, sphere);

建立拥有多个材质几何体的网格对象:

var phongMaterial = new THREE.MeshPhongMaterial({ color: 0x666666 });
var cubeMeshPhong = new THREE.Mesh(cubeGeometry, cubePhongMaterial);
var cubeMeshWire = new THREE.Mesh(cubeGeometry, wireMaterial);
// 网格对象新增材质
cubeMeshPhong.add(cubeMeshWire);
scene.add(cubeMeshPhong);

Renderer 渲染器

有了场景和相机,咱们还须要渲染器把对应的场景用对应的相机可见渲染出来,所以渲染器须要传入场景和相机参数。

// 抗锯齿、canvas 是否支持 alpha 透明度、preserveDrawingBuffer 是否保存 BUFFER 直到手动清除
const renderer = new THREE.WebGLRenderer({
    antialias: true, alpha: true, preserveDrawingBuffer: true
});
renderer.setSize(this.width, this.height);
renderer.autoClear = true;
// 清除颜色,第二个参数为 0 表示彻底透明,适用于须要透出背景的场景
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(window.devicePixelRatio);

为了在相机更新后所看见的场景,须要在循环渲染中加上

renderer.render(scene, camera);

有了相机场景和渲染器,咱们已经能够看到初步的效果了。但3D世界里,静止的物体多无趣啊。因而咱们尝试加入动画效果。

物体动画

Animations

Three为动画提供了一系列方法。

参数 含义
AnimationMixer 做为特定对象的动画混合器,能够管理该对象的全部动画
AnimationAction             为播放器指定对应的片断存储一系列行为,用来指定动画快慢,循环类型等
AnimationClip 表示可重用的动画行为片断,用来指定一个动画的动画效果(放大缩小、上下移动等)
KeyframeTrack 与时间相关的帧序列,传入时间和值,应用在指定对象的属性上。目前有 BooleanKeyframeTrack VectorKeyframeTrack 等。

那么如何建立一个动画呢?下面这个例子给你们解释如何让网格对象进行简单的上下移动。

建立特定对象的动画混合器:

// 建立纹理
const texture = new THREE.TextureLoader().load(img.src);
// 使用纹理建立贴图
const material = new THREE.SpriteMaterial({ map: texture, color: 0x666666 });
// 使用贴图建立贴图对象
const stone = new THREE.Sprite(material);
// 为贴图对象建立动画混合器
const mixer = new THREE.AnimationMixer(stone);

建立动画行为片断:

const getClip = (pos = [0, 0, 0]) => {
    const [x, y, z] = pos;
    const times = [0, 1]; // 关键帧时间数组,离散的时间点序列
    const values = [x, y, z, x, y + 3, z]; // 与时间点对应的值组成的数组
    // 建立位置关键帧对象:0时刻对应位置0, 0, 0   10时刻对应位置150, 0, 0
    const posTrack = new THREE.VectorKeyframeTrack('stone.position', times, values);
    const duration = 1;
    return new THREE.AnimationClip('stonePosClip', duration, [posTrack]);
};

建立动画播放器,肯定动画的表现:

const action = mixer.clipAction(getClip([x, y, z]));
action.timeScale = 1; // 动画播放一个周期的时间
action.loop = THREE.LoopPingPong; // 动画循环类型
action.play(); // 播放

在循环绘制中更新混合器,保证动画的执行:

animate() {
    // 更新动画
    const delta = this.clock.getDelta();
    mixer.update(delta);
    
    requestAnimationFrame(() => {
        animate();
    });
}

image

codepen

贴图动画

有了 Animation 咱们能够很简单地对物体的一些属性进行操做。但一些贴图相关的动画就很难用 Animation 来实现了,好比:

箭头动图

上图这种,没法经过改变物体的位置、大小等属性实现。因而,还有一种方案 —— 贴图动画。

相似在 CSS3 中对序列图片使用 transform 属性改变位置来达到的动画效果,实际上在 Three 中也可使用贴图位移的方式实现。

首先,咱们要有一个序列图:

箭头序列图

做为纹理加载,而且增长到场景中:

const arrowTexture = new THREE.TextureLoader().load(Arrow);
const material = new THREE.SpriteMaterial({ map: arrowTexture, color: 0xffffff });
const arrow = new THREE.Sprite(material);
scene.add(arrow);

声明 TextAnimator 对象,实现纹理的位移:

function TextureAnimator(texture, tilesHoriz, tilesVert, numTiles, tileDispDuration) {
    // 纹理对象经过引用传入,以后能够直接使用update方法更新纹理位置
    this.tilesHorizontal = tilesHoriz;
    this.tilesVertical = tilesVert;
    // 序列图中的帧数
    this.numberOfTiles = numTiles;
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.repeat.set(1 / this.tilesHorizontal, 1 / this.tilesVertical);

    // 每一帧停留时长
    this.tileDisplayDuration = tileDispDuration;

    // 当前帧停留时长
    this.currentDisplayTime = 0;

    // 当前帧
    this.currentTile = 0;

    // 更新函数,经过这个函数对纹理位移进行更新
    this.update = (milliSec) => {
        this.currentDisplayTime += milliSec;
        while (this.currentDisplayTime > this.tileDisplayDuration) {
            this.currentDisplayTime -= this.tileDisplayDuration;
            this.currentTile++;
            if (this.currentTile === this.numberOfTiles) { this.currentTile = 0; }
            const currentColumn = this.currentTile % this.tilesHorizontal;
            texture.offset.x = currentColumn / this.tilesHorizontal;
            const currentRow = Math.floor(this.currentTile / this.tilesHorizontal);
            texture.offset.y = currentRow / this.tilesVertical;
        }
    };
}
// 传入一个一行里有 13 帧的序列图,每张序列图停留 75ms
const arrowAni = new TextureAnimator(arrowTexture, 13, 1, 13, 75);

在循环绘制中更新,保证动画的执行:

arrowAni.update(delta);

做为引用传入后,对贴图的修改会直接体如今使用该贴图的材质上。

codepen

粒子动画

Three 中还提供了酷炫的粒子动画,使用继承自 Object3D 的 Points 类实现。有了 Points 类咱们能够很方便地把一个几何体渲染成一组粒子,并对它们进行控制。

建立粒子

建立粒子咱们首先须要建立粒子的材质,可使用 PointsMaterial 建立粒子材质。

const texture = new THREE.TextureLoader().load('https://p1.music.126.net/jgzbZtWZhDet2jWzED8BTw==/109951164579600342.png');

material = new THREE.PointsMaterial({
  color: 0xffffff,
  // 映射到材质上的贴图
  map: texture,
  size: 2,
  // 粒子的大小是否和其与摄像机的距离有关,默认值 true
  sizeAttenuation: true,
});

// 开启透明度测试,透明度低于0.5的片断会被丢弃,解决贴图边缘感问题
material.alphaTest = 0.5;

有了粒子材质后,咱们能够应用同一个材质批量建立一组粒子,只须要传入一个简单的几何体。

var particles = new THREE.Points( geometry, material );

若是你传入的是 BoxGeometry 你可能会获得这样的一组粒子

cube粒子

还能够根据传入的 Shape 获得这样一组粒子

fish粒子

粒子运动

但有趣的粒子毫不是静止的,而是有活动、有过程的。但若是本身动手实现一个粒子的运动又很复杂,所以但愿借助一些第三方库实现粒子动画的缓动过程。

tween.js

tween.js 是一个小型的 JS 库,咱们可使用它为咱们的动画声明变化。使用 tween.js 咱们不须要关心运动的中间状态,只须要关注粒子的:

  • 起始位置
  • 最终位置
  • 缓动效果
// srcPosition, targetPosition;
tweens.push(new TWEEN.Tween(srcPosition).easing(TWEEN.Easing.Exponential.In));
// tweens最终位置、缓动时间
tweens[0].to(targetPosition, 5000);
tweens[0].start();、

codepen

其实粒子动画的场景还有不少,咱们能够用他们创造雪花飘散、穿梭效果,本质都是粒子的位置变化。

相机动画

相机在 3D 空间中充当人的眼睛,所以天然的相机动线能够保证交互的天然流畅。

Controls

Three 提供了一系列相机控件来控制场景中的相机轨迹,这些控件适用于大部分场景。使用 Controls 开发者能够再也不须要去关心用户交互和相机移动的问题。

活动中也涉及到 OrbitControls 的使用,他提供了环绕物体旋转、平移和缩放的方法,但因为对使用二维贴图的状况下,旋转和缩放都容易穿帮,须要被禁止。

// 建立轨迹
const controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
controls.enabled = !0;
controls.target = new THREE.Vector3();
controls.minDistance = 0;
controls.maxDistance = 2000;
controls.minPolarAngle = Math.PI / 2;
controls.maxPolarAngle = Math.PI / 2;
// 禁用缩放
controls.enableZoom = !1;
// 禁用旋转
controls.enableRotate !1;
controls.panSpeed = 2;

// 修改控件的默认触摸选项,设置为单指双指都为平移操做
controls.touches = {
    ONE: THREE.TOUCH.PAN,
    TWO: THREE.TOUCH.PAN,
};

this.scene.add(this.camera);

OrbitControl 还容许咱们设置阻尼,设置该值表现为数值越接近 1 越难拖动,开启阻尼后须要咱们手动 update 控件。

controls.enableDamping = !0;
controls.dampingFactor = 0.2;

查看源码能够看到,阻尼的实现就是依赖滑动时的 offset 乘上一个权重,在经过后续的update不断为 panOffset 乘上一个权重实现滑动难,撒手后再滑动一点距离。

// this method is exposed, but perhaps it would be better if we can make it private...
this.update = function () {

    // ...

    return function update() {

        // ...

        // 平移

        if ( scope.enableDamping === true ) {
            // 开启阻尼后会在本来的位移上乘上一个权重
            scope.target.addScaledVector( panOffset, scope.dampingFactor );

        } else {

            scope.target.add( panOffset );

        }

        // ...

        if ( scope.enableDamping === true ) {

            sphericalDelta.theta *= ( 1 - scope.dampingFactor );
            sphericalDelta.phi *= ( 1 - scope.dampingFactor );

            // 若是没有人为操做,随着时间推移,panOffset会愈来愈小
            panOffset.multiplyScalar( 1 - scope.dampingFactor );

        } else {

            sphericalDelta.set( 0, 0, 0 );

            panOffset.set( 0, 0, 0 );

        }

        // ...

    };

}();

官方也提供了 Controls 的 例子 供你们参考。

相机动线

若是不使用 Controls,仅仅是相机从一个点移动到另外一个点,为了更平滑天然的相机轨迹,推荐使用贝塞尔曲线。

贝塞尔曲线是一个由起点、终点和控制点决定的一条时间相关的变化曲线。这里以二阶贝塞尔曲线为例,实现相机的曲线移动。(三维的点有点难说明白,这里用二维坐标来解释)

二阶贝塞尔曲线

上图中小黑点的移动轨迹能够看作相机移动的曲线。

贝塞尔公式

从该公式来看,只须要肯定 p0、p1 和 p2 三个点,在单位时间下咱们能够得到一条肯定的曲线。

可是,换成坐标点要怎么作呢?

// 得到贝塞尔曲线
function getBezier(p1, p2) {
    // 在指定范围内随机生成一个控制点
    const cp = {
        x: p1.x + Math.random() * 100 + 200,
        z: p2.z + Math.random() * 200,
    };

    let t = 0;
    // 贝塞尔曲线公式,根据时间肯定点的位置
    return (deltat) => {
        if (t >= 1) return [p2.x, p2.y];
        t += deltat;
        if (t > 1) t = 1;

        const { x: x1, z: z1 } = p1;
        const { x: cx, z: cz } = cp;
        const { x: x2, z: z2 } = p2;
        const x = (1 - t) * (1 - t) * x1 + 2 * t * (1 - t) * cx + t * t * x2;
        const z = (1 - t) * (1 - t) * z1
            + 2 * t * (1 - t) * cz + t * t * z2;

        return [x, z];
    };
}
const bezier = getBezier(p1, p2);

为了从简,这里只实现了二维坐标的轨迹变化,但三维也是同理。

由于贝塞尔曲线是时间相关曲线,在每一次循环渲染中要传入时间来更新相机位置。

animation() {
    const [x, z] = bezier(clock.getDelta());
    camera.position.x = x;
    camera.position.z = z;
    
    requestAnimationFrame(() => {
            animate();
    });
}

小结

没遇上 Three 的热潮,只能趁着活动需求给本身补补课了。在三维空间中,动画可以让空间中的物体更加生动,而相机的移动带给用户更强的空间感。

本文介绍了基于 Animation 实现物体的简单运动、 Texture 实现贴图动画以及使用 Points 粒子化的物体动画方案;基于 Controls 和贝塞尔曲线的相机动画方案。

对 Three 有兴趣的朋友,能够经过 官方文档 来学习,里面提供的例子覆盖了大部分场景。

以上是我在活动中涉及到的一些动画方案,不免会出现理解误差和表达错误,若是有更多的动效方案欢迎一块儿探讨~

参考资料

本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们
相关文章
相关标签/搜索