背景
设计师在用 AE 设计 lottie 动画的时候,在想要实现某个方向上的透视变换的效果的时候,会使用到 AE 中的一个叫做摄像机的东西。摄像机沿着一定的轨迹运动,就能够获得一些类似 3D 的效果。今年的余额宝年年有余活动中,设计师就利用了这个特性,类似于下面这个示意。 不管是 lottie-web 还是 lottie-pixi 都是无法支持这个特性的,也没有在社区中看到有相关的解法。这次年年有余中,我们利用 oasis 引擎,使设计师设计的摄像机能够被正确的渲染出来。
设计师在设计这个效果的时候,是将不同的元素放置在 z 轴方向上,然后通过移动摄像机转换视角,达到了一种不断前进的动画效果。如果把上面的动画旋转一下,从 x 轴正方向往负方向看去,可以看到是下面这个样子。不同的元素摆放在 z 轴中的不同位置。
而摄像机(可以当成是一个眼睛 👀 ),则随着时间的流逝在空间中做位移,就能在时间序列中以不同的视角“拍”下当前的每一帧,从而形成具有透视效果的动画。
概念
下图中的红色箭头展示了摄像机的一个运动轨迹,沿着红色的轨迹运动,可以得到一个类似于文章最开头的动图的效果。
在 AE 中,也可以看到摄像机运动的具体轨迹。
摄像机在每一帧都在“拍照”,这个“拍照”,是一个透视投影。透视投影是为了获得接近真实三维物体的视觉效果而在二维的画布平面上绘图。简单来说,就是近大远小。
这里放出经典的透视投影的模型图。
然而物体看上去的大小,除了与它离眼睛的远近有关,还和物体本身的尺寸有关。视角( fieldOfVIew )可以取代上述两者,直接比较物体看上去的大小。上面的模型图中,近裁剪平面(nearClipPlane),远裁剪平面(farClipPlane)和视角会形成一个视椎体。在视椎体内部的物体是会被投影到摄像机里的,也就是会渲染在画布上,而视椎体外的物体则会被裁剪。
为了模拟人眼近大远小这一个特性,可以利用透视投影来完成。有关透视投影的基本数学推导,在网上可以找到很多描述,这里就不提了。具体的细节可以参见维基百科上的描述。
回到 AE 中,在使用 AE 设计的有摄像机的动画中,我们至少需要依靠下面这三种数据,来描述 AE 中摄像机的运动及其“拍”下的的每一帧。
- 摄像机本身的位移,控制了当前摄像机在哪,也就是 👀 在哪;
- 摄像机朝向的角度(或看向的点),也就是 👀 朝哪里看;在 AE 设计中,可以给摄像机设置旋转角度,也可以给摄像机设置目标点,旋转角度和目标点是互斥的,如果都设置了,目标点会无效,以旋转角度为准。由于 oasis 引擎提供了
transform.lookAt
方法,因此可以推荐设计师直接给摄像机设置目标点,如果设计师设计的是旋转角度,则需要在代码中计算一下目标点。 - 视角,控制了可视范围有多大。
如下图所示。
实现
在 AE 中,使用 bodymovin 插件,导出的摄像机的 json 描述,与导出其他的类型的元素的描述是一样的,只是包括最基本的位移旋转等属性,而缺少了摄像机本身的一些属性。
// bodymovin 导出的摄像机的 lottie json 描述 { "ddd": 0, "ind": 78, "ty": 13, "nm": "摄像机", "sr": 1, "pe": { "a": 0, "k": 1041.667, "ix": 1 }, "ks": { "a": { "a": 1, "k": [ { "i": { "x": 0.667, "y": 1 }, "o": { "x": 0.333, "y": 0 }, "t": 104, "s": [ 401.104, 928, 4495 ], "e": [ 393.104, 360, 4615 ], "to": [ 0, 0, 0 ], "ti": [ 0, 0, 0 ] }, { "t": 150 } ], "ix": 1 }, "p": { "a": 1, "k": [ { "i": { "x": 0.667, "y": 1 }, "o": { "x": 0.382, "y": 0 }, "t": 0, "s": [ 400, 900, -970.667 ], "e": [ 400, 900, 4000 ], "to": [ 2.698, -1.641, 54.22 ], "ti": [ 0, 0, 0 ] }, { "t": 150 } ], "ix": 2 }, "or": { "a": 0, "k": [ 0, 0, 0 ], "ix": 7 }, "rx": { "a": 0, "k": 0, "ix": 8 }, "ry": { "a": 0, "k": 0, "ix": 9 }, "rz": { "a": 0, "k": 0, "ix": 10 } }, "ip": 0, "op": 450, "st": 0, "bm": 0 }
而像下图中的 AE 设置中的一些视角等信息,是缺失的。因此,在没办法获取到这些信息的情况下,我们只好手动将我们这些缺失的信息写入。为了更好的支持 lottie 中的摄像机,需要推动 bodymovin 来修改他们的插件,或者我们给他们提 pr(TODO)。
oasis 引擎是一个 ECS(Entity-Component-System) 架构的引擎,因此对于我们业务来说,我们的 Lottie 动画是一个 Entity,摄像机(Camera)也是一个 Entity ,我们需要给摄像机添加一个 Component 来控制相机的位置和目标点。
在具体的实现中,创建了一个 CameraScript
的 Component
,在 onStart
的时候拿到和设置相机的相关属性,在 onUpdate
的时候去更新相机当前的位置和目标点即可。
import { Script } from 'oasis-engine'; class CameraScript extends Script { onStart() { // 获取到 lottie json 中的相关属性 // 并设置相机的相关参数,包括投影透视、fov、关闭裁剪、远近裁剪面等数据 // ... } onUpdate(deltaTime: number) { // 设置每一帧相机的位置和目标点 // ... } }
上面的 json 中,"p"
节点下的代表的是相机的位置描述,与其他元素的位置描述一样,它是用两条贝塞尔曲线来(位置曲线和速度曲线)描述的,通过这些曲线的起点终点和控制点获取到每一帧的摄像机位置之后,通过调用:
camera.transform.setPosition(x, y, z);
来更新相机的位置。
"a"
节点下的代表的是相机的目标点描述,与"p"
节点下位置描述一样,可以计算获取到每一帧的摄像机的目标点的坐标,然后调用
camera.transform.lookAt(new Vector3(x, y, z));
便可以更新相机的目标点。
需要注意的是,lottie json 中的坐标,与 oasis 中的坐标是不一样的,因此在设置的时候,需要转换一下。lottie json 中的坐标是的原点 (0, 0) 是画布的左上角,而 oasis 中的坐标的原点 (0, 0) 是画布的中心。
// lottie 的坐标 转换成 oasis 里面的坐标 function convertCoords(vector3: Vector3, w = 750, h = 1624, pixelsPerUnit = 128) { const result = vector3.clone(); result.x = (result.x - w / 2) / pixelsPerUnit; result.y = -(result.y - h / 2) / pixelsPerUnit; result.z /= pixelsPerUnit; return result; }
为了能让 z 方向上所有的元素,都能出现在摄像机的视野范围内,需要给摄像机设置远近裁剪面。设计师在 AE 中设计摄像机的时候,已经把摄像机和每一层的元素放置好了,因此,不需要给远近裁剪面设定一个唯一的值,只需满足一定的条件就行了,确保裁剪面的范围能够包括下所有的元素。也就是
0 < nearClipPlane < abs(距离相机最近的图层 - 相机的位置) farClipPlane > abs(距离相机最远图层 - 相机的位置)
有时候,一个 lottie 是可以用不同的速度播放的,在年年有余的小游戏中,也允许通过同时控制 lottie 的播放速度与相机的运动速度来展示出慢速或快速的动画。由于我们是给一个 Camera
增加了一个 Component
来更新相机的位置,因此只需要在 Script
中的 onUpdate
里面,获取到当前的 LottieAnimation
的速度,再同步地算出当前相机的位置,更新给相机即可。
需要注意的一点是,摄像机“拍照”的近大远小的特性,会造成在远处的图片异常的大。在将 lottie 中的图片合并成雪碧图的时候,很容易就超过了 2048x2048 的大小,此时,需要将雪碧图拆分成多个(或者使用 base64的方式),否则会造成闪退。
参考链接
oasis-engine: https://github.com/oasis-engine/engine