云图三维 连接你·创造的世界 致力于打造国内第一家集查看、建模、装配和渲染于一体的“云端CAD”协作设计平台。
应读者的要求,希望我们成立一个专业的、面向成渝地区的前端开发人员的webgl、Threejs行业QQ交流群,便于大家讨论问题。群里有研究webgl、Threejs大佬哦,欢迎大家加入!——点击链接加入群聊【three.js/webgl重庆联盟群】:jq.qq.com/?_wv=1027&k…
作者介绍
小刚,云图大前端研发工程师,负责云图三维 front 端的开发工作。
前言
在之前的文章中,我们介绍了几何体,材质,光源等基本概念,我们可以利用这些知识创建出一些简单的场景,本次将和大家分享一下,如何利用动画,让我们的场景动起来。
正文
一、动画简介
在threejs 中我们使用renderer.render
方法来绘制场景,此方法将场景和摄像机作为输入,并将单个静止图像输入到HTML<canvas元素>。输出是您可以看到不动的紫色框。
render() {
// draw a single frame
renderer.render(scene, camera);
}
复制代码
本次,我们将为立方体添加一些简单的旋转动画,思考一下添加动画的过程
- 调用
render.render(...)
- 等到绘制下一帧的时间
- 将立方体旋转一点
- 调用
render.render(...)
- 等到绘制下一帧的时间
- 将立方体旋转一点
- ...
在一个成为动画循环的无限循环中,设置这个循环很简单,因为threejs通过renderer.setAnimationLoop
方法为我们完成了所有的工作。
我们还将介绍一下Clock
,一个简单的秒表类,我们可以使用它来保持动画同步,使用毫秒(ms
)作为单位。
一旦我们设置了循环,我们的目标就是以每秒60帧的速率生成稳定的帧流,这意味着我们需要.render
大约每 16 毫秒调用一次。换句话说,我们需要确保在一帧中所做的所有处理都花费少于 16 毫秒。所以在需要更新动画,执行任何其他需要跨帧计算的任务,并在我们打算支持的最低规格硬件上在不到 16 毫秒的时间内渲染帧。在后续的部分,当我们设置循环并为立方体创建一个简单的旋转动画时,将讨论如何最好地实现这一点。
二、使用THREEJS创建动画循环
1.Loop.js 模块
新建Loop
类,这个类将处理所有的循环逻辑和动画系统,首先导入Clock,使用它来保持动画同步,然后使用renderer.render(scene,camera)
生成帧。最后创建启动/停止循环的方法 start
和stop
import { Clock } from 'three';
class Loop {
constructor(camera, scene, renderer) {
this.camera = camera;
this.scene = scene;
this.renderer = renderer;
}
start() {}
stop() {}
}
export { Loop }
复制代码
在World中,将Loop导入
import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createLights } from './components/lights.js';
import { createScene } from './components/scene.js';
import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';
import { Loop } from './systems/Loop.js';
复制代码
将循环作为World 的属性,在整个场景中都能访问到
let camera;
let renderer;
let scene;
let loop;
class World {
constructor(container) {
camera = createCamera();
renderer = createRenderer();
scene = createScene();
loop = new Loop(camera, scene, renderer);
container.append(renderer.domElement);
...
}
复制代码
最后 添加.start
和.stop
到World
render() {
// draw a single frame
renderer.render(scene, camera);
}
start() {
loop.start();
}
stop() {
loop.stop();
}
复制代码
在main.js中调用world.render
和world.start
function main() {
// Get a reference to the container element
const container = document.querySelector('#scene-container');
// create a new world
const world = new World(container);
// draw the scene
world.render();
// start the animation loop
world.start();
}
复制代码
到这一步的时候,整个场景会变黑,但是不要担心,一旦我们完成创建循环,它会马上恢复活力。
2.创建循环.setAnimationLoop
使用threejs中的WebGLRenderer.setAnimationLoop
import { WebGLRenderer } from 'three';
const renderer = new WebGLRenderer();
// start the loop
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
});
复制代码
这时renderer.render一遍一遍的调用生成帧流,可以通过null作为回调来取消正在运行的循环
// stop the loop
renderer.setAnimationLoop(null);
复制代码
在内部,循环是使用.requestAnimationFrame
这个内置的浏览器方法,可以智能地安排帧与显示器的刷新率同步,如果您的硬件跟不上,它会平滑地降低帧率。由于.setAnimationLoop
是最近添加的,较旧的 three.js 示例和教程通常.requestAnimationFrame
直接用于设置循环,这样做非常简单。
3.Loop.start 和 Loop.stop 方法
现在开始创建循环,使用setAnimationLoop
start() {
this.renderer.setAnimationLoop(() => {
// render a frame
this.renderer.render(this.scene, this.camera);
});
}
复制代码
创建对应的stop方法
stop() {
this.renderer.setAnimationLoop(null);
}
复制代码
此时,场景将开始以大约60fps
输出帧,但是看不到任何区别,为啥呢,回顾一下刚才我们所做的操作
- 调用
render.render(...)
- 等到绘制下一帧的时间
- 调用
render.render(...)
- 等到绘制下一帧的时间
是不是发现和本文开头我们描述的循环比较少了点什么,没错,将立方体旋转一点,下面先做一些准备工作
4.移除onResize钩子
首先,让我们整理一下。现在循环正在运行,每当我们调整窗口大小时,都会在循环的下一次迭代中生成一个新帧。看起来不会有任何延迟,所以现在不再需要在调整大小时手动重绘场景了。从世界中移除resizer.onResize
钩子
constructor(container) {
camera = createCamera();
scene = createScene();
renderer = createRenderer();
container.append(renderer.domElement);
const cube = createCube();
const light = createLights();
updatables.push(cube);
scene.add(cube, light);
const resizer = new Resizer(container, camera, renderer);
resizer.onResize = () => {
this.render();
};
}
复制代码
三、动画系统
考虑一个简单的游戏,用户可以在其中探索地图并挑选苹果。以下是您可以添加到此游戏中的一些动画对象:
- 女主角,拥有各种动画,如步行/跑步/跳跃/攀爬/挑选。
- 苹果树。苹果随着时间长大,树叶随风飘扬。
- 一些可怕的蜜蜂会试图把你从花园里赶出去。
- 一个有趣的环境,其中包含水、风、树叶和岩石等物体。
- 以悬停在地面上的旋转立方体的形式加电。
… 等等。每次循环运行时,我们都希望通过将它们向前移动一帧来更新所有这些动画。就在我们渲染每一帧之前,我们会让女主角向前迈出一点点,我们会让每只蜜蜂向她移动,我们会让叶子移动,苹果长大,道具旋转,每一个都有一点点, 几乎是肉眼无法看到的微小量,但随着时间的推移会产生流畅的动画效果。
1.Loop.tick 方法
为了处理上面所说的情况,我们需要一个更新所有动画的函数,并且这个函数应该在每一帧开始时运行一次。然而,update这个词已经在整个 three.js 中被大量使用,所以我们将选择这个词tick。在绘制每一帧之前,我们会让每个动画向前移动一帧。Loop.tick
在类的末尾添加方法Loop
,然后在动画循环中调用它:
start() {
this.renderer.setAnimationLoop(() => {
// tell every animated object to tick forward one frame
this.tick();
// render a frame
this.renderer.render(this.scene, this.camera);
});
}
stop() {
this.renderer.setAnimationLoop(null);
}
tick() {
// Code to update animations will go here
}
复制代码
在实现tick 的时候,需要思考一个问题,我们想要在我们应用的不同地方调用还是将所有tick集中到一起
2.集中式还是分散式
集中式--
如果我们的场景中只有几个动画对象,这可能没问题。当有五十或一百个动画对象的时候,就会显的非常杂乱。它还打破了各种软件设计原则,因为现在Loop
必须深入了解每个动画对象的工作原理。
tick() {
if(controls.state.run) {
character.runAnimation.nextFrame();
}
beeA.moveTowards(character.position);
beeB.moveTowards(character.position);
beeC.moveTowards(character.position);
powerupA.rotation.z += 0.01;
powerupB.rotation.z += 0.01;
powerupC.rotation.z += 0.01;
leafA.rotation.y += 0.01;
// ... and so on
}
复制代码
分散式--
在对象本身上定义更新每个对象的逻辑。每个对象都将使用自己的通用.tick
方法公开该逻辑。现在,Loop.tick
方法会很简单。每一帧,我们将遍历一个动画对象列表,并告诉它们每个.tick
向前一帧。
// somewhere in the Loop class:
this.updatables = [character, beeA, beeB, beeC, powerupA, powerupB, powerupC, leafA, ... ]
...
tick() {
for(const object of this.updatables) {
object.tick();
}
}
复制代码
显而易见,分散式更符合设计应用程序的模块化理念,将每个对象设计为独立的实体,然后在实体上封装它的行为
3.动画对象列表
我们需要循环类中的动画对象列表。为此,我们将使用一个简单的数组,我们称之为 list updatables
。
constructor(camera, scene, renderer) {
this.camera = camera;
this.scene = scene;
this.renderer = renderer;
this.updatables = [];
}
复制代码
接下来, within Loop.tick
,遍历这个列表并调用.tick
中的任何对象。
tick() {
for (const object of this.updatables) {
object.tick();
}
}
复制代码
4.cube.tick方法
添加cube
到updatables
列表之前,它需要一个.tick
方法,所以继续创建一个。在此.tick
方法中定义旋转立方体的逻辑。
每种类型的动画对象都有不同的.tick
方法。比如女主角的tick方法会检查她是在走、跑、跳还是站着不动,然后从其中一个动画中播放一帧,而苹果树的tick方法会检查苹果的成熟度和树叶沙沙作响,邪恶蜜蜂的每一种蜱虫方法都会检查女主人公的位置,然后将蜜蜂移向她一点点。如果她离得足够近,蜜蜂会试图蜇她。
在这里,我们将简单地更新立方体在X,是,和 Z每帧轴少量。这将使它看起来随机翻滚。
function createCube() {
const geometry = new BoxBufferGeometry(2, 2, 2);
const material = new MeshStandardMaterial({ color: 'purple' });
const cube = new Mesh(geometry, material);
cube.rotation.set(-0.5, -0.1, 0.8);
// this method will be called once per frame
cube.tick = () => {
// increase the cube's rotation each frame
cube.rotation.z += 0.01;
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
};
return cube;
}
复制代码
5.添加cube到Loop.updatables
在 World 中,将立方体添加到Loop.updatables
列表中
constructor(container) {
camera = createCamera();
renderer = createRenderer();
scene = createScene();
loop = new Loop(camera, scene, renderer);
container.append(renderer.domElement);
const cube = createCube();
const light = createLights();
loop.updatables.push(cube);
scene.add(cube, light);
const resizer = new Resizer(container, camera, renderer);
}
复制代码
这时就会看到,立方体动起来了
写在最后
在threejs 中使用动画可以使场景更有活力,增强交互性,本文简单介绍了一下最简单的动画使用方法,感兴趣的话,快去试试创建更多好玩,炫酷的效果吧。