从源码角度浅析cocos2d-js原理

3,398 阅读14分钟

本文主要对webGL使用、cocos2d家族历史简单介绍,并分析cocos2d-js源码关于资源加载、渲染原理,碰撞以及动画使用进行介绍

背景知识

介绍WebGL

WebGL是一套浏览器原生支持的绘制2D 3D图形的Javascirpt API,底层基于OpenGL,而OpenGL是硬件驱动级的图形接口(类似于DirectX),所以WebGL可以实现硬件级别的2D 3D图形渲染,性能一般优于canvas渲染

WebGL的渲染流程:

img

  1. 获取元素
let canvas = document.getElementById("canvas");
  1. 向canvas元素请求绘图上下文
let gl = canvas.getContext("webgl");
  1. 在上下文调用相应绘图函数

    1. 创建着色器程序
//顶点着色器
let VSAHDER_SOURCE = 'void main() {gl_Position, gl_PointSize}'
//片元着色器
let FSAHDER_SOURCE = 'void main() {gl_FragColor}'
  1. 初始化着色器,编译,检查是否编译成功
  2. 绑定着色器到指定程序,判断关联是否成功
if(!initShaders(gl, VSAHDER_SOURCE, FSAHDER_SOURCE )){return}
  1. 执行着色器程序处理顶点、片元
gl.drawSomething(gl.POINTS, 0, 1);

游戏引擎

什么是游戏引擎?为什么我们需要一个游戏引擎?简单说就是帮助开发者实现快速开发:

  • 不仅是关心用户看到什么,还要解决用户频繁交互下的实时渲染问题
  • 能够提供从数据遍历、收集到渲染执行的完整流程
  • 跨平台

cocos2d-js入门介绍

cocos家族的****比较

img

cocos2d-js

  • 是cocos2d-x游戏引擎的js实现版本
  • cocos2d-js通过cocos2d html5和cocos2dx jsbinding实现了跨平台

cocos2d-js 架构图

img

cocos2dcocos2dxcocos2d-jscocos creator
开发语言objective-cc++JS 、JSB、HTML5(是一个开发工具)
扩展方式继承节点继承节点继承节点组合到节点上
渲染OpenGLWebGLWebGL/CanvasWebGL/Canvas
是否跨平台

环境配置

  1. 安装 python2.x + jdk
  2. 下载cocos2dx,并解压,运行目录下的setup.py文件(如果需要创建移动端应用需要配置NDK ROOT),之后根据提示source配置文件让cocos变量生效
  3. 通过webstorm自带的服务器或者xampp等方式来启动本地服务来打开index.html进行游戏预览
  4. 初始化一个新的cocos工程命令
cocos new <project-name> -l js -d <path>
  1. 打包web端可执行的应用
cocos compile -p web -m release 

基础

核心代码

这里分析的是cocos2d的js版本实现,版本是3.17.2,关注~coco2dx-x-3.17.2/web

(ps: 本文涉及的游戏应用相关代码大部分来自cocos2d-js的js-test项目)

img

根目录下有两个js文件比较重要

img

  • CCBoot:主命名空间,引擎所有的核心类,方法,属性,常量在此定义
  • jsb_apis:为Native应用提供api支持

层级关系

img

这里解释一下

  1. Director:导演,负责控制场景创建切换,在整个生命周期中以单例模式存在

  2. Scene

  • 场景切换的基本单位,可以设置不同类型的切换效果、标题等基础渲染信息
  • 同一阶段只允许渲染一个场景,下面是一条场景关系链,appendChild方法可以将多个图层添加到场景上

img

  1. Layer:图层是场景组成基本单位,实现前景背景分层处理,接受事件,图层上可以添加各种元素,CCLayer下同时定义了几个子类
  • CCLayerColor:实现了CCRGBAProtocol协议
  • CCLayerGradient:绘画渐变色背景
  • CCLayerMultiplex: 管理多个层,并实现不同层间的切换
  1. Sprite:精灵,2D游戏中常用的控制动画的图像,可以配合Animation类实现动画或者直接传入action参数实现移动、旋转等操作,一般有几种创建方法
  • new cc.Sprite(fileName)通过图片生成
  • new cc.Sprite(fileName, rect)通过图片并进行矩形区域裁剪生成
  • new cc.Sprite(srpiteFrame),通过全局缓冲类SpriteFrameCache中的帧创建

生命周期

所有上面提到的元素都是Node的子类(Sprite、Layer、Menu、Label),绑定了坐标、缩放比例、旋转信息等这些绘制中需要用的基础属性

img

Node拥有自己生命周期可以更方便的进行流程控制

  • ctor:构造函数
  • onEnter:进入Node调用,一般在这里做初始化处理
  • onEnterDidTransitionFinish:进入Node且过渡动画结束时调用
  • onExit:退出Node调用
  • onExitDidTransitionStart:退出Node且过渡动画开始时调用
  • cleanup:被清除时调用

基础使用

类似于腾讯文档在Canvas渲染中定义了不同类型的renderer一样,cocos2dx也会根据场景需求创建相应的渲染对象,例如下面的Sprite对象(精灵图),用来构成2D游戏中人物,建筑等单位,可以设置颜色、播放动画,或者构建出很多复杂的混合模式,使用者只需要传递一些简单参数(图片、坐标、action),就可以对一个节点元素进行我们想要的方式进行渲染。

var LogicTest = ActionManagerTest.extend({
    title:function () {
        return "Logic test";
    },
    onEnter:function () {
        this._super();

        var grossini = new cc.Sprite(s_pathGrossini);
        this.addChild(grossini, 0, 2);
        grossini.x = 200;
        grossini.y = 200;

        grossini.runAction(cc.sequence(
            cc.moveBy(1, cc.p(150, 0)),
            cc.callFunc(this.onBugMe, this))
        );

        if ( autoTestEnabled ) {
            this._grossini = grossini;
        }
    },
    onBugMe:function (node) {
        node.stopAllActions(); 
        node.runAction(cc.scaleTo(2, 2));
    },

    testDuration: 4.0,
    getExpectedResult:function() {
        var ret = [ {"scaleX":2, "scaleY":2} ];
        return JSON.stringify(ret);
    },
    
    getCurrentResult:function() {
        var ret = [ {"scaleX":this._grossini.scaleX, "scaleY":this._grossini.scaleY} ];
        return JSON.stringify(ret);
    }
});

初始化

启动

首先Web通过Index.html加载CCBoot.js和main.js文件(程序入口),如果是Native启动会通过原生代码加载jsb.js,游戏的入口js文件是main.js,main.js重写了cc.game.onStart()在其中做了视图初始化的工作

//设置游戏固定朝向
cc.view.setOrientation(cc.ORIENTATION_LANDSCAPE);
//设置分辨率策略大小
cc.view.setDesignResolutionSize(800, 450, cc.ResolutionPolicy.SHOW_ALL);
//浏览器大小改变后重新计算游戏界面尺寸
cc.view.resizeWithBrowserSize(true);

加载场景

cc.LoaderScene.preload(g_resources, function () {
        if(window.sideIndexBar && typeof sideIndexBar.start === 'function'){
            sideIndexBar.start();
        }else{
            var scene = new cc.Scene();
            //TestController是手动实现的一个Layer的控制器
            //本质也是一种Layer
            scene.addChild(new TestController());
            cc.director.runScene(scene);
        }
    }, this);

最后启动游戏

cc.game.run();

资源加载

img

run()是在CCBoot.js中实现的,如果直接启动的话,会发现TestController这个Layer是undefine的,这是因为工程的脚本文件没有完成加载,查看源码发现CCBoot有俩个方法loadConfigloadText分别完成了对project.json文本的加载和解析,package.json大概如下结构:

{
    "debugMode"     : 1,
    "showFPS"       : true,
    "frameRate"     : 60,
    "noCache"       : false,
    "id"            : "gameCanvas",
    "renderMode"    : 0,
    "engineDir"     : "../../web/",

    "modules"       : ["cocos2d", "extensions", "external"],

    "plugin": {
        "facebook": {
            "appId" : "1426774790893461",
            "xfbml" : true,
            "version" : "v2.0"
        }
    },

    "jsList"        : [
        "src/BaseTestLayer/BaseTestLayer.js",

        "src/tests_resources.js",
    ]
}

CCBoot启动过程发现最终prepare方法中对jsList中的文件做了加载

// Load game scripts
var jsList = config[CONFIG_KEY.jsList];
if (jsList) {
    cc.loader.loadJsWithImg(jsList, function (err) {
        if (err) throw new Error(err);
        self._prepared = true;
        if (cb) cb();
    });
}
else {
    if (cb) cb();
}

在jsList添加"src/tests-main.js"(里面定义了TestController),就可以正常加载;同时观察到cc.loader是一个单例,所有类型的外部资源都可以通过插件机制的模式注册到cc.loader里面,cc.loader的重要方法有

  • load 加载资源的入口API
  • loadJS/loadJsWithImg 加载js文件(区别是是否有loading图)
  • loadXXX(Txt、Img、Binary、BinarySync) 加载 XXX 文件
  • register 注册loader
  • getRes 获取缓存数据
  • release 释放缓存数据

CCLoader注册

cc.loader.register(["font", "eot", "ttf", "woff", "svg", "ttc"], cc._fontLoader);

src目录下会有ressource.js的资源配置文件,cocos2dx v3版本中配置格式如下(配置字体为例),指定loader的类型为font,资源缓存时候的key值为Thonburi.font

var g_fonts = [
    //@face-font for WebFonts
    {
        type:"font",
        name:"Thonburi",
        srcs:["../cpp-tests/Resources/fonts/Thonburi.eot", "../cpp-tests/Resources/fonts/Thonburi.ttf"]
    },
];

场景和资源文件绑定在一个handler中绑定

{
        title:"Font Test",
        resource:g_fonts,  
        platforms: PLATFORM_ALL,
        linksrc:"src/FontTest/FontTest.js",
        testScene:function () {
            return new FontTestScene();
        }},

最后在场景切换的回调函数中完成对相关场景资源的加载

cc.LoaderScene.preload(res, function () {
            var scene = testCase.testScene();
            if (scene) {
                scene.runThisTest();
            }
}, this);

图片处理

cocos对图像资源处理主要利用纹理缓存,一般是在场景切换preload预加载阶段将纹理对象缓存到TextCache中,创建Sprite对象时,程序会默认去TextCache请求数据,如果获取到了直接加载纹理对象,纹理对象会调用底层绘图接口完成像素信息填充

var tex = cc.textureCache.getTextureForKey(filename);
        if (!tex) {
            tex = cc.textureCache.addImage(filename);
        }

声音处理

  • 音乐:播放时间长,一般用作背景音乐
  • 音效:短暂的声音效果

cocos2djs的AudioEngine封装了音乐和音效并提供了多种API调用方法

img img

在播放声音文件的时候会返回一个AudioId,之后可以每次通过这个id对声音进行处理

aId = audioEngine.playEffect("music/sound.mp3", _loop);

audioEngine播放声音资源有两种方式

  • webAudio:兼容性好,但是占用过多缓存
cc.Audio.WebAudio = function (buffer) {
    this.buffer = buffer;
}
  • domAudio(auto-play-audio):只缓存元素,但是兼容性差

初始化渲染器流程

img

渲染

渲染流程

img

  1. 启动:

执行_runMainLoop开启主循环,director执行场景绘制,绘制场景前会先对所有的节点进行循环遍历,之后执行渲染。

  1. 遍历和重排

每一个节点的渲染顺序依赖于三个order,优先级globalZOrder>localZOrder>arrivalOrder,相同优先级的order值越大越靠后渲染

  • globalZOrder:通过Node::setGlobalZorder设置
  • localZOrder:通过Node::setLocalZorder设置
  • orderOfArrival: 节点被添加到一个父节点下时设置,由一个全局量累加得到

在绘制之前cocos会以ccScene为起点遍历所有的children节点,visit函数实现如下

img

cocos2dx cocos3.0执行visit()收集每一个节点数据之后并不会马上对计算好的渲染数据进行渲染而是先对数据以RenderCommand为基类进行封装,生成openGL的绘制命令,push进入一个CommandQuere后等待之后按序执行渲染

rej::RenderCommand()
: _type(RenderCommand::Type::UNKNOWN_COMMAND)
, _globalOrder(0)
{
}

每一个cmd里面都保存了webGL绘制需要的数据,LabelTTFWebGLRenderCmd举例来看如下

img

队列里存放了不同类型基础元素的webGL绘图函数命令

img

  • 每次新插入一个chlid或者child被reorder的时候,reorderChildDirty会置为true
  • 每次优先遍历localZOrder小于0的节点,之后是push当前节点,最后是大于0的节点(中序)

cocos2dx-js实际对节点树做结构调整的时候时候使用的是node的localZOrder,localZOrder小于0的node放在左子树,大于0的放在右子树,采用递归的方法对UI树的节点进行中序遍历,排序采用给的冒泡排序(类似于冒泡,非标准),首先会比较节点的localZorder,小的排前,相同的情况下对arrivalOrder进行比较,同样小的排前,之后排前的节点会优先进行渲染

img

  1. 绘制

数据遍历完毕后,director中执行渲染renderer.render(cc._renderContext)开始循环渲染所有元素,这里会将计算好的节点数据传进opeGL的缓冲区对象,然后执行批渲染

img

碰撞

锚点和坐标

cocos2dx使用的为OpenGL坐标系,起始位置(0, 0)位于屏幕的左下角,archorPoint锚点就是一个二维坐标,值从0 ~ 1范围内取

img

node的位置是由position和archor point共同决定的,Layer默认锚点是(0 ,0)即左下角,Sprite的默认锚点是(0.5 , 0.5)即正中心,锚点其实就是图片的原点。可以这样理解,加入有一个图片锚点是(0.5,0.5),也就是图片的中心处,我们将这个点看成图片原点,图片是以这个点位起点的,然后跟position去结合

物理引擎

img

Cocos2d在PhysicsSprite中集成了开源的物理引擎,PhysicsSprite为Sprite的子类,绑定到一个物理Body,多个物理引擎不能同时开启(预处理宏CC_ENABLE_CHIPMUNK_INTEGRATION 、CC_ENABLE_CHIPMUNK_INTEGRATION 、CC_ENABLE_BOX2D_INTEGRATION只能定义一个):

  • Chipmunk
  • Objective-Chipmunk
  • Box2d

当cocos2d的精灵对象绑定了物理引擎后,就可以赋值质量密度、摩擦系数等参数使精灵的表现更贴近于真实物理世界的效果

下面介绍一些在碰撞检测中用到的方法

轴对称包围盒碰撞检测

通常大家熟悉的检测碰撞方法就是这种矩形碰撞检测检测

cc.rect()来创建一个常规矩形

cc.rect = function (x, y, w, h) {
  return {x: x, y: y, width: w, height: h };
};

这里的x和y指的是矩形的左下角,也就是origin,或者(minX, minY)

img

getBoundingBox();方法获取要检测的碰撞物体的范围,然后再根据rectIntersectsRect();方法进行判断需要检测触摸的点和检测点范围是否有重叠,有则发生碰撞

//判断是否相交
cc.rectIntersectsRect = function (rect1, rect2) {
    var max1x = rect1.x + rect1.width,
        max1y = rect1.y + rect1.height,
        max2x = rect2.x + rect2.width,
        max2y = rect2.y + rect2.height;
    return !(max1x < rect2.x || max2x < rect1.x || max1y < rect2.y || max2y < rect1.y);
};

同样的原理,我们也可以通过获取一次点击的position来判断是否命中了某一个矩形元素,但是这里只能对规则的图形或物体进行检测,如果是复杂的图形不友好

SAT碰撞检测

除了给不规则图形外层包一圈boundingRect之后再做轴对称检测情况外,为了提升精度,有时候我们需要对不规则图形进行检测,一般检测凸多边形的碰撞会使用分离轴定理,对于两个凸多边形,当存在直线,使得两个多边形在这条直线上的投影不想交,那么这两个多边形就不想交,这条直线叫做分离轴,所以编写代码一般考虑:确定投影轴、投影到投影轴、判断重叠

找投影轴其实就是找多边形的每一条边的法向量相平行的轴

img

计算多边形在直线上的投影的简单方法是计算所有顶点在直线上的投影,并获得具有最小最大值的坐标

//计算投影坐标伪代码
let p; //p为原始点
y = k*x+b //投影轴
x1=(k*(p[1]-b)+p[0])/(k*k+1);
y1=k*x1+b;
M=[x1 y1]    //投影点坐标       

最后我们对相应坐标值做差值比较之后判断是否有投影相交即可得出碰撞检测结果了

球碰撞检测

img

function isCollision(a, b) {
    //求点到圆心的距离,用到了勾股定理     
    var dis = Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
    return dis < (a.r + b.r)
}

球形和矩形碰撞检测

img

function isCollision(a, closePoint) {
    //求点到圆心的距离,用到了勾股定理     
    var dis = Math.sqrt((a.x -closePoint.x) * (a.x -closePoint.x) + (a.y - closePoint.y) * (a.y - closePoint.y));
    return dis < a.r
}

四叉树

按照上面的方法,如果每两个对象就要计算一次碰撞通常来说性能开销极大,所以一般会通过空间划分的方法将不同的图形进行分组,保证只有组内图形对象可能出现碰撞,这样就大大减少了计算次数

img

这种结构叫做四叉树,四叉树会随着图像对象个数的增多而加深层级,这样递归构建出来的树会设置层级数或者最大子节点元素个数为阈值,来提供终止划分的条件,如果引深到三维空间的话,又会构造出八叉树结构,原理上是类似的

BVH包围体

img

有时候我们要计算的不是物体之间的碰撞,而是某一个时刻一条线是否与图形对象相交,这种情况一般会比较常用到包围体(BVH),熟悉聚类算法的小伙伴应该知道,如果我们有一个二维空间,首先我们随机指定一个中心点,然后计算对象集合中心距离这个中心点的距离,然后重新计算中心点经过不断的迭代后,我们会得到n个簇类(cluster)

img

在BVH中,每一个cluster就是一个矩形包围体,看成一个单一节点按照一定规则构建的完整的树结构

img

它具有下面的特性:

  • 叶子节点代表图形对象,非叶子节点代表图形对象
  • 叶子节点的数量 = 非叶子节点数量 - 1
  • 叶子节点的顺序可随意替换

动画

cocos2d-js动画分为逐帧播放和plist播放(切图帧播放)两种方式

逐帧播放

cocos2d-js通过Animation实现逐帧播放,首先实例化一个Animation实例和一个Sprite精灵对象,设置播放时间,并调用addSpriteFrameWithFile添加所有帧的图片设置每一帧的间隔时间,再通过repeatForever封装成action,赋值给sprite.runAction()

plist播放

plist播放是用一张大图来包含动画过程的所有帧,一般会根据方向将大图里的小图切分到不同的数组里,添加到一个AnimationCache实例将动画缓存起来,根据不同的方向取出相应的帧动画然后同样类似于逐帧动画一样封装成Action传参给sprite.runAction()

参考

  1. cocos2d-x Api文档 docs.cocos2d-x.org/api-ref/cpl…