0%

CocosCreator 主循环源码浅析

CocosCreator 主循环源码浅析

标签(空格分隔): CocosCreator


MainLoop

  游戏有好看的界面、有趣的动画,能响应用户的操作,所以才好玩。基本动画的本质是在于随着时间不断地绘制图片,这跟电影放映原理是有些类似的。但不同之处在于,游戏的界面是根据时间、用户输入、定时器等信息动态生成并绘制的,也就是说需要有逻辑控制游戏内容。我们可以将游戏抽象成以下几个内容:

  1. 处理用户事件
  2. 处理定时器事件
  3. 绘图

  应用在运行的过程中,会不断地重复上面的逻辑。我们可以用下面的代码来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
// JavaScript

// onTick 表示要处理的任务
function onTick() {
handleUserEvents(); // 处理用户输入等事件
handleSchedules(); // 处理定时器事件
draw(); // 绘图
...
}

while(true) {
onTick();
}

  上面的代码显然不合理,这会使 CPU 的使用达到 100%,所以需要能每隔一段时间就能执行一次 onTick 的那种。比如定时器:

1
2
// JavaScript
setInterval(onTick, 33) // 大概每秒执行 (1000 / 33) = 30 次

  这样就好多了,但其实我们有更好的选择。屏幕在亮起工作的时候,会不断地刷新,一般手机上是每秒 60 次,在每次屏幕刷新前都执行任务,这会带来几个好处:

  • 平台或浏览器会自动优化刷新时机
  • 窗口没激活时,任务不会工作,能节省 CPU 资源。

  接下来,我们看看不同三大平台(浏览器、iOS 原生、Android 原生)如何实现这个功能。

浏览器中的 requestAnimationFrame

  在浏览器中,window.requestAnimationFrame() 方法方法告诉浏览器您希望执行动画并请求浏览器在下一次重绘之前调用指定的函数来更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。我们利用这个方法来重新实现上面的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// JavaScript
var intervalId, callback;

callback = function() {
/** callback 被调用 */

// 告诉浏览器下次重绘前调用 callback
intervalId = window.requestAnimationFrame(callback);

// 执行任务
onTick();
}

// 告诉浏览器下次重绘前调用 callback
intervalId = window.requestAnimationFrame(callback);

  如上面代码所示,我们通过回调成功地让浏览器在每次重绘前都调用 callback

  iOS 原生的 Core Animation 框架中,有一个 CADisplayLink 类。这是一个计时器对象,它允许应用程序将其绘图与显示的刷新速率同步。它可以绑定一个方法,该方法会被屏幕刷新时调用。我们看一下它的实现方式:

1
2
3
4
5
6
7
// Swift

// 创建绑定了 self 的 onTick 方法的 CADisplayLink 对象
let displaylink = CADisplayLink(target: self, selector: #selector(onTick))

// 注册。接下就系统会在合适的时候调用 onTick 方法
displaylink.add(to: .current, forMode: .defaultRunLoopMode)

  Cocos Creator 在 CCDirectorCaller-ios.mm 文件中使用了 CADisplayLink

Android 下的 GLSurfaceView

  Android 原生有一个类 android.opengl.GLSurfaceView,它内部定义了 Renderer 接口,该接口声明了 void onDrawFrame(GL10 gl) 方法。该方法负责绘制当前帧。我们可以继承这个类并实现该方法:

1
2
3
4
5
6
7
8
9
10
11
// Java
public class Renderer implements GLSurfaceView.Renderer {
@Override
public void onDrawFrame(final GL10 gl) {
onTick();
}

public void onTick() {
//...
}
}

  Cocos Creator 在 org.cocos2dx.lib.Cocos2dxRenderer 中实现了 onDrawFrame 方法。

本文以 Web 平台为主,各平台原理基本相同。接下来不会再涉及其它到原生代码。

MainLoop 简化源码

  在游戏引擎中,上面的循环中执行的方法 onTick 实际上就是 CCDirector 中的 mainLoop 方法。也就是主循环了。下一节中,我们通过源码来了解其中的基本流程。

源码解析

MainLoop 在引擎中被调用的流程

  Cocos Creator 游戏引擎的入口在 main.js 中,它在 cocos2d-js-min.js 加载完后会开始运行游戏:cc.game.run()。源码如下:

1
2
3
4
5
6
7
8
// main.js

function boot () {
//....
cc.game.run();
}

boot();

  在 run 方法中,会根据配置进行引擎的准备工作,包括初始化渲染器、全局视图对象 cc.view、导演 cc.director 还有注册系统各类事件等工作。简化代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// CCGame.js

var game = {
prepare: function() {
// 初始化渲染器
this._initRenderer();

// cc.view 是全局的视图对象。
cc.view = View ? View._getInstance() : null;

// 初始化导演类
cc.director = cc.Director._getInstance();

// 初始化 OpenGLView
cc.director.setOpenGLView(cc.view);

// 注册各类事件
this._initEvents();

// 开始运行主循环
this._runMainLoop();
},

run: function () {
this.prepare();
},

//.... 其它方法
}

cc.game = game;

  从上面的代码中可以看到,引擎在完成初始化工作之后,就会开始运行主循环了:this._runMainLoop(); 我们再看一下这个方法的工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// CCGame.js

var game = {
// ... 其它方法

// 开始运行游戏
_runMainLoop: function () {
var callback, director = cc.director;

callback = function () {
self._intervalId = window.requestAnimationFrame(callback);

// 调用导演类的 mainLoop 方法
director.mainLoop();
}
};

self._intervalId = window.requestAnimationFrame(callback);
}
}

cc.game = game;

  这里在上文介绍过,利用 window.requestAnimationFrame 及其回调函数来实现循环。循环里调用的是 director.mainLoop();

  流程图如下:

1
2
3
4
5
6
7
8
boot=>start: Boot()
gamerun=>inputoutput: cc.game.run()
gameprepare=>inputoutput: cc.game.prepare()
gamerunloop=>inputoutput: cc.game._runMainLoop();
requestAnimationFrame=>inputoutput: window.requestAnimationFrame()
directormainloop=>inputoutput: director.mainLoop();

boot->gamerun->gameprepare->gamerunloop->requestAnimationFrame->directormainloop->requestAnimationFrame

  从上面的流程中,可以看到,director.mainLoop 是引擎的逻辑控制的中枢。接下来,我们看看这个方法内部。

CCDirector

  cc.director 是一个单例对象,管理你的游戏逻辑流程,它还负责同步定时器与显示器的刷新速率。这个对象在引擎做初始化工作时,就被创建了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CCGame.js
var game = {
prepare: function() {
// 初始化导演类
cc.director = cc.Director._getInstance();
}
}

// CCDirector.js
cc.Director._getInstance = function () {
if (cc.Director.firstUseDirector) {
cc.Director.firstUseDirector = false;
cc.Director.sharedDirector = new cc.DisplayLinkDirector();
cc.Director.sharedDirector.init();
}
return cc.Director.sharedDirector;
};

  可以看到,实际上的 cc.directorDisplayLinkDirector 类的实例,这个类继承自 Director :

1
2
3
4
5
6
7
8
// CCDirector.js
cc.DisplayLinkDirector = cc.Director.extend({
//...

mainLoop: function() {
//....
}
});

  mainLoop 方法也是在这个类中实现的。在深入这个方法之前,我们先回顾一下游戏逻辑中最常用的类:cc.Component,下面列出我们常继承或使用的逻辑流程方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class MyComponent extends cc.Component {
/** 用于继承的方法 */

// 当附加到一个激活的节点上或者其节点第一次激活时候调用。onLoad 总是会在任何 start 方法调用前执行,这能用于安排脚本的初始化顺序。
onLoad() {}

// 如果该组件第一次启用,则在所有组件的 update 之前调用。通常用于需要在所有组件的 onLoad 初始化完毕后执行的逻辑。
start() {}

// 如果该组件启用,则每帧调用 update。
update(dt) {}

// 如果该组件启用,则每帧调用 LateUpdate。
lastUpdate() {}

// 当该组件被启用,并且它的节点也激活时。
onEnable() {}

// 当该组件被禁用或节点变为无效时调用。
onDisable() {}

// 当该组件被销毁时调用
onDestroy() {}

/** 下面的方法是需要组件自身主动调用的 */

// 调度一个自定义的回调函数。
// 如果回调函数已调度,那么将不会重复调度它,只会更新时间间隔参数。
schedule(callback, interval, repeat, delay) {
var scheduler = cc.director.getScheduler();
var paused = scheduler.isTargetPaused(this);

scheduler.schedule(callback, this, interval, repeat, delay, paused);
}

// 调度一个只运行一次的回调函数,可以指定 0 让回调函数在下一帧立即执行或者在一定的延时之后执行。
scheduleOnce (callback, delay) {
this.schedule(callback, 0, 0, delay);
}

// 取消调度一个自定义的回调函数。
unschedule(callback) {
cc.director.getScheduler().unschedule(callback_fn, this);
}

// 取消调度所有已调度的回调函数:定制的回调函数以及 'update' 回调函数。动作不受此方法影响。
unscheduleAllCallbacks () {
cc.director.getScheduler().unscheduleAllForTarget(this);
}
}

  再回到 mainLoop,简化后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// CCDirector.js

mainLoop() {
// 计算全局的时间增量,即 dt
this.calculateDeltaTime();

// 每个帧的开始时所触发的事件。
this.emit(cc.Director.EVENT_BEFORE_UPDATE);

// 对最新加入的组件调用 `start` 方法
this._compScheduler.startPhase();

// 调用组件的 `update` 方法
this._compScheduler.updatePhase(this._deltaTime);

// 调用调度器的 `update` 方法
this._scheduler.update(this._deltaTime);

// 调用组件的 `lateUpdate` 方法
this._compScheduler.lateUpdatePhase(this._deltaTime);

// 将在引擎和组件 “update” 逻辑之后所触发的事件。
this.emit(cc.Director.EVENT_AFTER_UPDATE);

// 回收内存
cc.Object._deferredDestroy();

//
if (this._nextScene) {
this.setNextScene();
}

// 访问渲染场景树之前所触发的事件。
this.emit(cc.Director.EVENT_BEFORE_VISIT);
// 访问渲染场景树
this._visitScene();
// 访问渲染场景图之后所触发的事件,渲染队列已准备就绪,但在这一时刻还没有呈现在画布上。
this.emit(cc.Director.EVENT_AFTER_VISIT);

// 绘图渲染
cc.g_NumberOfDraws = 0;
cc.renderer.clear();

cc.renderer.rendering(cc._renderContext);
this._totalFrames++;

// 渲染过程之后所触发的事件。
this.emit(cc.Director.EVENT_AFTER_DRAW);

eventManager.frameUpdateListeners();
}

  上面的代码展示了 mainLoop 所做的事情。可以看到,在各阶段的前后,都有相应的事件发出:EVENT_BEFORE_XXXEVENT_AFTER_XXX。接下来,我们分析除绘图渲染外的逻辑部分。

calculateDeltaTime

  calculateDeltaTime 用于计算本次 mainLoop 调用与上一次调用之间的时间间隔。就是计算 this._deltaTime 计算的值:

1
2
3
4
5
6
7
8
9
10
11
// CCDirector.js
init() {
//...
this._lastUpdate = Date.now();
}

calculateDeltaTime() {
var now = Date.now();
this._deltaTime = (now - this._lastUpdate) / 1000;
this._lastUpdate = now;
}

  计算非常简单,将当前时间与上次计算时的时间相减即可。

组件的生命周期方法

  compScheduler 属性是 ComponentScheduler 类的实例。该类定义在 component-scheduler.js 类中。 在进入这个方法的代码之前,我们先看一下添加组件逻辑。

CCObject

  在 CocosCreator 中的,cc.Nodecc.Component 等大部分类的基类是 CCObject。来看一下它的定义:

1
2
3
4
5
6
class CCObject {
constructor() {
this._name = '';
this._objFlags = 0;
}
}

  可以看到,CCObject 有两个属性。其中值得注意的是 _objFlags,它被引擎用来标识对象的部分状态,这需要用到标志位、与操作、或操作、位移操作符。以下是引擎定义的部分标志位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// CCObject.js

// definitions for CCObject.Flags
var Destroyed = 1 << 0;
var RealDestroyed = 1 << 1;
var ToDestroy = 1 << 2;
var DontSave = 1 << 3;
var EditorOnly = 1 << 4;
var DontDestroy = 1 << 6;
var Destroying = 1 << 7;
var Deactivating = 1 << 8;

var IsOnEnableCalled = 1 << 11;
var IsEditorOnEnableCalled = 1 << 12;
var IsPreloadStarted = 1 << 13;
var IsOnLoadCalled = 1 << 14;
var IsOnLoadStarted = 1 << 15;
var IsStartCalled = 1 << 16;

  从上面的定义的变量名,可以得知标志位的作用。比如,判断一个组件的 onLoad 方法是否调用过:

1
2
3
4
5
6
//CCComponent.js
_isOnLoadCalled: {
get () {
return this._objFlags & IsOnLoadCalled;
}
},

addComponent

  CCNodeCCComponent 都有方法:addComponent。实际上组件的这个方法调用的就是节点的方法:

1
2
3
4
//CCComponent.js
addComponent (typeOrClassName) {
return this.node.addComponent(typeOrClassName);
},

  所以只需要关注 CCNode 里的实现即可,这个方法是定义在其父类 base-node 中的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// base-node.js

//向节点添加一个指定类型的组件类,你还可以通过传入脚本的名称来添加组件。
addComponent (typeOrClassName) {
// 根据 typeOrClassName 找出组件类
var constructor = typeOrClassName;
if (typeof typeOrClassName === 'string') {
constructor = JS.getClassByName(typeOrClassName);
}

// 新建组件并添加到 _components 数组中
var component = new constructor();
component.node = this;
this._components.push(component);

// 如果 node 及其父组件链是 active 状态,则调用组件的 onLoad 方法。
if (this._activeInHierarchy) {
cc.director._nodeActivator.activateComp(component);
}

return component;
}

  添加组件的逻辑非常简单,先找到组件的类,再创建组件对象,并保存对象。值得一提的是 _nodeActivator,这是对象是 NodeActivator 类的实例,在 cc.director 初始化时创建。NodeActivator 是用于执行节点和组件的 activatingdeactivating 操作的类。下面是该类的 activateComp 方法的简化版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// node-activator.js
activateComp: function(comp) {
if (!(comp._objFlags & IsPreloadStarted)) {
comp._objFlags |= IsPreloadStarted;

// __preload 是引擎自定义的组件需要实现的方法。如 CCButton、CCLabel 都实现了该方法
comp.__preload();
}

// 如果 comp 没有执行过 onLoad 方法
if (!(comp._objFlags & IsOnLoadStarted)) {
comp._objFlags |= IsOnLoadStarted;
if (comp.onLoad) {
comp.onLoad();
}
comp._objFlags |= IsOnLoadCalled;
}

if (comp._enabled) {
cc.director._compScheduler.enableComp(comp);
}
}

  activateComp 的工作就是调用组件的 onLoad 方法,并且保证只调用一次。再将组件添加到 _compScheduler 中并启用组件。

ComponentScheduler

  ComponentScheduler 用于统一管理所有组件的生命周期方法,方法包括:startupatelateUpdate_compSchedulercc.director 初始化时创建。从上面的代码中,可以看出通过 enableComp 方法将组件管理起来的。下面是该类代码的简化版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// component-scheduler.js
class ComponentScheduler {
constructor() {
// 用于将组件方法调用延迟到下一帧的数组
this.scheduleInNextFrame = [];

// 标识正在一次循环中
this._updating = false;

this.startInvoker = [];
this.updateInvoker = [];\
this.lateUpdateInvoker =[];
}

// 启用组件
enableComp (comp) {
if (!(comp._objFlags & IsOnEnableCalled)) {
if (comp.onEnable) {
// 调用组件的 onEnable 方法
comp.onEnable();
}
this._onEnabled(comp);
}
}

// 将组件添加到主循环逻辑中
_onEnabled (comp) {
cc.director.getScheduler().resumeTarget(comp);
comp._objFlags |= IsOnEnableCalled;

// schedule
if (this._updating) {
this.scheduleInNextFrame.push(comp);
} else {
this._scheduleImmediate(comp);
}
}

// 将组件添加到要调用的方法数组列表中
_scheduleImmediate (comp) {
if (comp.start && !(comp._objFlags & IsStartCalled)) {
this.startInvoker.add(comp);
}
if (comp.update) {
this.updateInvoker.add(comp);
}
if (comp.lateUpdate) {
this.lateUpdateInvoker.add(comp);
}
}

// 执行延迟到当前帧调用的组件方法
_deferredSchedule () {
var comps = this.scheduleInNextFrame;
for (var i = 0, len = comps.length; i < len; i++) {
var comp = comps[i];
this._scheduleImmediate(comp);
}

// 清空 this.scheduleInNextFrame 数组
comps.length = 0;
}

startPhase () {
// 当前帧开始
this._updating = true;

if (this.scheduleInNextFrame.length > 0) {
this._deferredSchedule();
}

// 调用组件的 start 方法
this.startInvoker.forEach(comp => {
if (!(comp._objFlags & IsStartCalled)) {
comp._objFlags |= IsStartCalled;
comp.start();
}
});
}

updatePhase (dt) {
// 调用组件的 update 方法
this.updateInvoker.forEach(comp => {
comp.update(dt);
});
}

lateUpdatePhase (dt) {
// 调用组件的
this.lateUpdateInvoker.forEach(comp => {
comp.lateUpdate(dt);
});

// 当前帧结束
this._updating = false;
}
}

  可以看到,该类提供了三个方法给外部,用于统一管理组件生命周期方法。它们在前文提到的主循环里有调用:

1
2
3
4
5
6
7
8
mainLoop() {
// ...
this._compScheduler.startPhase();
this._compScheduler.updatePhase(this._deltaTime);
this._scheduler.update(this._deltaTime);
this._compScheduler.lateUpdatePhase(this._deltaTime);
// ...
}

组件生命周期流程

  从上面的代码可以分析出,当添加组件到节点后,组件被托管到 _compScheduler 统一管理。再由 mainLoop 通过 _compScheduler 调用到组件的相应方法。

参考

1、cocos2d-x游戏引擎核心之三——主循环和定时器
2、JavaScript 技术文档
3、CADisplayLink
4、cocos-creator/engine