剖析CocosCreator新资源管理系统

1.资源与构建

1.1 creator资源文件基础

在了解引擎如何解析、加载资源之前,我们先来了解一下这些资源文件(图片、Prefab、动画等)的规则,在creator项目目录下有几个与资源相关的目录:

  • assets 所有资源的总目录,对应creator编辑器的资源管理器
  • library 本地资源库,预览项目时使用的目录
  • build 构建后的项目默认目录

在assets目录下,creator会为每个资源文件和目录生成一个同名的.meta文件,meta文件是一个json文件,记录了资源的版本、uuid以及各种自定义的信息(在编辑器的属性检查器中设置),比如prefab的meta文件,就记录了我们可以在编辑器修改的optimizationPolicy和asyncLoadAssets等属性。

{
  "ver": "1.2.7",
  "uuid": "a8accd2e-6622-4c31-8a1e-4db5f2b568b5",
  "optimizationPolicy": "AUTO",     // prefab创建优化策略
  "asyncLoadAssets": false,         // 是否延迟加载
  "readonly": false,
  "subMetas": {}
}

在library目录下的imports目录,资源文件名会被转换成uuid,并取uuid前2个字符进行目录分组存放,creator会将所有资源的uuid到assets目录的映射关系,以及资源和meta的最后更新时间戳放到一个名为uuid-to-mtime.json的文件中,如下所示。

{
  "9836134e-b892-4283-b6b2-78b5acf3ed45": {
    "asset": 1594351233259,
    "meta": 1594351616611,
    "relativePath": "effects"
  },
  "430eccbf-bf2c-4e6e-8c0c-884bbb487f32": {
    "asset": 1594351233254,
    "meta": 1594351616643,
    "relativePath": "effects\\__builtin-editor-gizmo-line.effect"
  },
  ...
}

与assets目录下的资源相比,library目录下的资源合并了meta文件的信息。文件目录则只在uuid-to-mtime.json中记录,library目录并没有为目录生成任何东西。

1.2 资源构建

在项目构建之后,资源会从library目录下移动到构建输出的build目录中,基本只会导出参与构建的场景和resources目录下的资源,及其引用到的资源。脚本资源会由多个js脚本合并为一个js,各种json文件也会按照特定的规则进行打包。我们可以在Bundle的配置界面和项目的构建界面为Bundle和项目设置

1.2.1 图片、图集、自动图集

  • https://docs.cocos.com/creator/manual/zh/asset-workflow/sprite.html
  • https://docs.cocos.com/creator/manual/zh/asset-workflow/atlas.html
  • https://docs.cocos.com/creator/manual/zh/asset-workflow/auto-atlas.html

导入编辑器的每张图片都会对应生成一个json文件,用于描述Texture的信息,如下所示,默认情况下项目中所有的Texture2D的json文件会被压缩成一个,如果选择无压缩,则每个图片都会生成一个Texture2D的json文件。

{
  "__type__": "cc.Texture2D",
  "content": "0,9729,9729,33071,33071,0,0,1"
}

如果将纹理的Type属性设置为Sprite,Creator还会自动生成了SpriteFrame类型的json文件。
图集资源除了图片外,还对应一个图集json,这个json包含了cc.SpriteAtlas信息,以及每个碎图的SpriteFrame信息
自动图集在默认情况下只包含了cc.SpriteAtlas信息,在勾选内联所有SpriteFrame的情况下,会合并所有SpriteFrame

1.2.2 Prefab与场景

  • https://docs.cocos.com/creator/manual/zh/asset-workflow/prefab.html
  • https://docs.cocos.com/creator/manual/zh/asset-workflow/scene-managing.html

场景资源与Prefab资源非常类似,都是一个描述了所有节点、组件等信息的json文件,在勾选内联所有SpriteFrame的情况下,Prefab引用到的SpriteFrame会被合并到prefab所在的json文件中,如果一个SpriteFrame被多个prefab引用,那么每个prefab的json文件都会包含该SpriteFrame的信息。而在没有勾选内联所有SpriteFrame的情况下,SpriteFrame会是单独的json文件。

1.2.3 资源文件合并规则

当Creator将多个资源合并到一个json文件中,我们可以在config.json中的packs字段找到被打包的资源信息,一个资源有可能被重复打包到多个json中。下面举一个例子,展示在不同的选项下,creator的构建规则:

  • a.png 一个单独的Sprite类型图片
  • dir/b.png、c.png、AutoAtlas dir目录下包含2张图片,以及一个AutoAtlas
  • d.png、d.plist 普通图集
  • e.prefab 引用了SpriteFrame a和b的prefab
  • f.prefab 引用了SpriteFrame b的prefab

下面是按不同规则构建后的文件,可以看到,无压缩的情况下生成的文件数量是最多的,不内联的文件会比内联多,但内联可能会导致同一个文件被重复包含,比如e和f这两个Prefab都引用了同一个图片,这个图片的SpriteFrame.json会被重复包含,合并成一个json则只会生成一个文件。

资源文件 无压缩 默认(不内联) 默认(内联) 合并json
a.png a.texture.json + a.spriteframe.json a.spriteframe.json
./dir/b.png b.texture.json + b.spriteframe.json b.spriteframe.json
./dir/c.png c.texture.json + c.spriteframe.json c.spriteframe.json c.spriteframe.json
./dir/AutoAtlas autoatlas.json autoatlas.json autoatlas.json
d.png d.texture.json + d.spriteframe.json d.spriteframe.json d.spriteframe.json
d.plist d.plist.json d.plist.json d.plist.json
e.prefab e.prefab.json e.prefab.json e.prefab.json(pack a+b)
f.prefab f.prefab.json f.prefab.json f.prefab.json(pack b)
g.allTexture.json g.allTexture.json all.json

默认选项在绝大多数情况下都是一个不错的选择,如果是web平台,建议勾选内联所有SpriteFrame这可以减少网络io,提高性能,而原生平台不建议勾选,这可能会增加包体大小以及热更时要下载的内容。对于一些紧凑的Bundle(比如加载该Bundle就需要用到里面所有的资源),我们可以配置为合并所有的json。

2. 理解与使用 Asset Bundle

2.1 创建Bundle

Asset Bundle是creator 2.4之后的资源管理方案,简单地说就是通过目录来对资源进行规划,按照项目的需求将各种资源放到不同的目录下,并将目录配置成Asset Bundle。能够起到以下作用:

  • 加快游戏启动时间
  • 减小首包体积
  • 跨项目复用资源
  • 方便实现子游戏
  • 以Bundle为单位的热更新

Asset Bundle的创建非常简单,只要在目录的属性检查器中勾选配置为bundle即可,其中的选项官方文档都有比较详细的介绍。

其中关于压缩的理解,文档并没有详细的描述,这里的压缩指的并不是zip之类的压缩,而是通过packAssets的方式,把多个资源的json文件合并到一个,达到减少io的目的

在选项上打勾非常简单,真正的关键在于如何规划Bundle,规划的原则在于减少包体、加速启动以及资源复用。根据游戏的模块来规划资源是比较不错的选择,比如按子游戏、关卡副本、或者系统功能来规划。

Bundle会自动将文件夹下的资源,以及文件夹中引用到的其它文件夹下的资源打包(如果这些资源不是在其它Bundle中),如果我们按照模块来规划资源,很容易出现多个Bundle共用了某个资源的情况。可以将公共资源提取到一个Bundle中,或者设置某个Bundle有较高的优先级,构建Bundle的依赖关系,否则这些资源会同时放到多个Bundle中(如果是本地Bundle,这会导致包体变大)。

2.2 使用Bundle

  • 关于加载资源 https://docs.cocos.com/creator/manual/zh/scripting/load-assets.html
  • 关于释放资源 https://docs.cocos.com/creator/manual/zh/asset-manager/release-manager.html

Bundle的使用也非常简单,如果是resources目录下的资源,可以直接使用cc.resources.load来加载

cc.resources.load("test assets/prefab", function (err, prefab) {
    var newNode = cc.instantiate(prefab);
    cc.director.getScene().addChild(newNode);
});

如果是其它自定义Bundle(本地Bundle或远程Bundle都可以用Bundle名加载),可以使用cc.assetManager.loadBundle来加载Bundle,然后使用加载后的Bundle对象,来加载Bundle中的资源。对于原生平台,如果Bundle被配置为远程包,在构建时需要在构建发布面板中填写资源服务器地址。

cc.assetManager.loadBundle('01_graphics', (err, bundle) => {
    bundle.load('xxx');
});

原生或小游戏平台下,我们还可以这样使用Bundle:

  • 如果要加载其它项目的远程Bundle,则需要使用url的方式加载(其它项目指另一个cocos工程)
  • 如果希望自己管理Bundle的下载和缓存,可以放到本地可写路径,并传入路径来加载这些Bundle
// 当复用其他项目的 Asset Bundle 时
cc.assetManager.loadBundle('https://othergame.com/remote/01_graphics', (err, bundle) => {
    bundle.load('xxx');
});

// 原生平台
cc.assetManager.loadBundle(jsb.fileUtils.getWritablePath() + '/pathToBundle/bundleName', (err, bundle) => {
    // ...
});

// 微信小游戏平台
cc.assetManager.loadBundle(wx.env.USER_DATA_PATH + '/pathToBundle/bundleName', (err, bundle) => {
    // ...
});

其它注意项:

  • 加载Bundle仅仅只是加载了Bundle的配置和脚本而已,Bundle中的其它资源还需要另外加载
  • 目前原生的Bundle并不支持zip打包,远程包下载方式为逐文件下载,好处是操作简单,更新方便,坏处是io多,流量消耗大
  • 不同Bundle下的脚本文件不要重名
  • 一个Bundle A依赖另一个Bundle B,如果B没有被加载,加载A时并不会自动加载B,而是在加载A中依赖B的那个资源时报错

3. 新资源框架剖析

v2.4重构后的新框架代码更加简洁清晰,我们可以先从宏观角度了解一下整个资源框架,资源管线是整个框架最核心的部分,它规范了整个资源加载的流程,并支持对管线进行自定义。

公共文件

  • helper.js 定义了一堆公共函数,如decodeUuid、getUuidFromURL、getUrlWithUuid等等
  • utilities.js 定义了一堆公共函数,如getDepends、forEach、parseLoadResArgs等等
  • deserialize.js 定义了deserialize方法,将json对象反序列化为Asset对象,并设置其__depends__属性
  • depend-util.js 控制资源的依赖列表,每个资源的所有依赖都放在_depends成员变量中
  • cache.js 通用缓存类,封装了一个简易的键值对容器
  • shared.js 定义了一些全局对象,主要是Cache和Pipeline对象,如加载好的assets、下载完的files以及bundles等

Bundle部分

  • config.js bundle的配置对象,负责解析bundle的config文件
  • bundle.js bundle类,封装了config以及加载卸载bundle内资源的相关接口
  • builtins.js 内建bundle资源的封装,可以通过 cc.assetManager.builtins 访问

管线部分

CCAssetManager.js 管理管线,提供统一的加载卸载接口

管线框架

  • pipeline.js 实现了管线的管道组合以及流转等基本功能
  • task.js 定义了一个任务的基本属性,并提供了简单的任务池功能
  • request-item.js 定义了一个资源下载项的基本属性,一个任务可能会生成多个下载项

预处理管线

  • urlTransformer.js parse将请求参数转换成RequestItem对象(并查询相关的资源配置),combine负责转换真正的url
  • preprocess.js 过滤出需要进行url转换的资源,并调用transformPipeline

下载管线

  • download-dom-audio.js 提供下载音效的方法,使用audio标签进行下载
  • download-dom-image.js 提供下载图片的方法,使用Image标签进行下载
  • download-file.js 提供下载文件的方法,使用XMLHttpRequest进行下载
  • download-script.js 提供下载脚本的方法,使用script标签进行下载
  • downloader.js 支持下载所有格式的下载器,支持并发控制、失败重试

解析管线

  • factory.js 创建Bundle、Asset、Texture2D等对象的工厂
  • fetch.js 调用packManager下载资源,并解析依赖
  • parser.js 对下载完成的文件进行解析

其它

  • releaseManager.js 提供资源释放接口、负责释放依赖资源以及场景切换时的资源释放
  • cache-manager.d.ts 在非WEB平台上,用于管理所有从服务器上下载下来的缓存
  • pack-manager.js 处理打包资源,包括拆包,加载,缓存等等

3.1 加载管线

creator使用管线(pipeline)来处理整个资源加载的流程,这样的好处是解耦了资源处理的流程,将每一个步骤独立成一个单独的管道,管道可以很方便地进行复用和组合,并且方便了我们自定义整个加载流程,我们可以创建一些自己的管道,加入到管线中,比如资源加密。

AssetManager内置了3条管线,普通的加载管线、预加载、以及资源路径转换管线,最后这条管线是为前面两条管线服务的。

// 正常加载
this.pipeline = pipeline.append(preprocess).append(load);
// 预加载
this.fetchPipeline = fetchPipeline.append(preprocess).append(fetch);
// 转换资源路径
this.transformPipeline = transformPipeline.append(parse).append(combine);

3.1.1 启动加载管线【加载接口】

接下来我们看一下一个普通的资源是如何加载的,比如最简单的cc.resource.load,在bundle.load方法中,调用了cc.assetManager.loadAny,在loadAny方法中,创建了一个新的任务,并调用正常加载管线pipeline的async方法执行任务。

注意要加载的资源路径,被放到了task.input中、options是一个对象,对象包含了type、bundle和__requestType__等字段

// bundle类的load方法
load (paths, type, onProgress, onComplete) {
  var { type, onProgress, onComplete } = parseLoadResArgs(type, onProgress, onComplete);
  cc.assetManager.loadAny(paths, { __requestType__: RequestType.PATH, type: type, bundle: this.name }, onProgress, onComplete);
},

// assetManager的loadAny方法
loadAny (requests, options, onProgress, onComplete) {
  var { options, onProgress, onComplete } = parseParameters(options, onProgress, onComplete);

  options.preset = options.preset || 'default';
  let task = new Task({input: requests, onProgress, onComplete: asyncify(onComplete), options});
  pipeline.async(task);
},

pipeline由两部分组成 preprocess 和 load。preprocess 由以下管线组成 preprocess、transformPipeline { parse、combine },preprocess实际上只创建了一个子任务,然后交由transformPipeline执行。对于加载一个普通的资源,子任务的input和options与父任务相同。

let subTask = Task.create({input: task.input, options: subOptions});
task.output = task.source = transformPipeline.sync(subTask);

3.1.2 transformPipeline管线【准备阶段】

transformPipeline由parse和combine两个管线组成,parse的职责是为每个要加载的资源生成RequestItem对象并初始化其资源信息(AssetInfo、uuid、config等):

先将input转换成数组进行遍历,如果是批量加载资源,每个加载项都会生成RequestItem

如果输入的item是object,则先将options拷贝到item身上(实际上每个item都会是object,如果是string的话,第一步就先转换成object了)

  • 对于UUID类型的item,先检查bundle,并从bundle中提取AssetInfo,对于redirect类型的资源,则从其依赖的bundle中获取AssetInfo,找不到bundle就报错
  • PATH类型和SCENE类型与UUID类型的处理基本类似,都是要拿到资源的详细信息
  • DIR类型会从bundle中取出指定路径的信息,然后批量追加到input尾部(额外生成加载项)
  • URL类型是远程资源类型,无需特殊处理
function parse (task) {
    // 将input转换成数组
    var input = task.input, options = task.options;
    input = Array.isArray(input) ? input : [ input ];

    task.output = [];
    for (var i = 0; i < input.length; i ++ ) {
        var item = input[i];
        var out = RequestItem.create();
        if (typeof item === 'string') {
            // 先创建object
            item = Object.create(null);
            item[options.__requestType__ || RequestType.UUID] = input[i];
        }
        if (typeof item === 'object') {
            // local options will overlap glabal options
            // 将options的属性复制到item身上,addon会复制options上有,而item没有的属性
            cc.js.addon(item, options);
            if (item.preset) {
                cc.js.addon(item, cc.assetManager.presets[item.preset]);
            }
            for (var key in item) {
                switch (key) {
                    // uuid类型资源,从bundle中取出该资源的详细信息
                    case RequestType.UUID:
                        var uuid = out.uuid = decodeUuid(item.uuid);
                        if (bundles.has(item.bundle)) {
                            var config = bundles.get(item.bundle)._config;
                            var info = config.getAssetInfo(uuid);
                            if (info && info.redirect) {
                                if (!bundles.has(info.redirect)) throw new Error(`Please load bundle ${info.redirect} first`);
                                config = bundles.get(info.redirect)._config;
                                info = config.getAssetInfo(uuid);
                            }
                            out.config = config;
                            out.info = info;
                        }
                        out.ext = item.ext || '.json';
                        break;
                    case '__requestType__':
                    case 'ext':
                    case 'bundle':
                    case 'preset':
                    case 'type': break;
                    case RequestType.DIR:
                        // 解包后动态添加到input列表尾部,后续的循环会自动parse这些资源
                        if (bundles.has(item.bundle)) {
                            var infos = [];
                            bundles.get(item.bundle)._config.getDirWithPath(item.dir, item.type, infos);
                            for (let i = 0, l = infos.length; i < l; i++) {
                                var info = infos[i];
                                input.push({uuid: info.uuid, __isNative__: false, ext: '.json', bundle: item.bundle});
                            }
                        }
                        out.recycle();
                        out = null;
                        break;
                    case RequestType.PATH:
                        // PATH类型的资源根据路径和type取出该资源的详细信息
                        if (bundles.has(item.bundle)) {
                            var config = bundles.get(item.bundle)._config;
                            var info = config.getInfoWithPath(item.path, item.type);

                            if (info && info.redirect) {
                                if (!bundles.has(info.redirect)) throw new Error(`you need to load bundle ${info.redirect} first`);
                                config = bundles.get(info.redirect)._config;
                                info = config.getAssetInfo(info.uuid);
                            }

                            if (!info) {
                                out.recycle();
                                throw new Error(`Bundle ${item.bundle} doesn't contain ${item.path}`);
                            }
                            out.config = config;
                            out.uuid = info.uuid;
                            out.info = info;
                        }
                        out.ext = item.ext || '.json';
                        break;
                    case RequestType.SCENE:
                        // 场景类型,从bundle中的config调用getSceneInfo取出该场景的详细信息
                        if (bundles.has(item.bundle)) {
                            var config = bundles.get(item.bundle)._config;
                            var info = config.getSceneInfo(item.scene);

                            if (info && info.redirect) {
                                if (!bundles.has(info.redirect)) throw new Error(`you need to load bundle ${info.redirect} first`);
                                config = bundles.get(info.redirect)._config;
                                info = config.getAssetInfo(info.uuid);
                            }
                            if (!info) {
                                out.recycle();
                                throw new Error(`Bundle ${config.name} doesn't contain scene ${item.scene}`);
                            }
                            out.config = config;
                            out.uuid = info.uuid;
                            out.info = info;
                        }
                        break;
                    case '__isNative__':
                        out.isNative = item.__isNative__;
                        break;
                    case RequestType.URL:
                        out.url = item.url;
                        out.uuid = item.uuid || item.url;
                        out.ext = item.ext || cc.path.extname(item.url);
                        out.isNative = item.__isNative__ !== undefined ? item.__isNative__ : true;
                        break;
                    default: out.options[key] = item[key];
                }
                if (!out) break;
            }
        }
        if (!out) continue;
        task.output.push(out);
        if (!out.uuid && !out.url) throw new Error('unknown input:' + item.toString());
    }
    return null;
}

RequestItem的初始信息,都是从bundle对象中查询的,bundle的信息则是从bundle自带的config.json文件中初始化的,在打包bundle的时候,会将bundle中的资源信息写入config.json中。

经过parse方法处理后,我们会得到一系列RequestItem,并且很多RequestItem都自带了AssetInfo和uuid等信息,combine方法会为每个RequestItem构建出真正的加载路径,这个加载路径最终会转换到item.url中。

function combine (task) {
    var input = task.output = task.input;
    for (var i = 0; i < input.length; i++) {
        var item = input[i];
        // 如果item已经包含了url,则跳过,直接使用item的url
        if (item.url) continue;

        var url = '', base = '';
        var config = item.config;
        // 决定目录的前缀
        if (item.isNative) {
            base = (config && config.nativeBase) ? (config.base + config.nativeBase) : cc.assetManager.generalNativeBase;
        }
        else {
            base = (config && config.importBase) ? (config.base + config.importBase) : cc.assetManager.generalImportBase;
        }

        let uuid = item.uuid;

        var ver = '';
        if (item.info) {
            if (item.isNative) {
                ver = item.info.nativeVer ? ('.' + item.info.nativeVer) : '';
            }
            else {
                ver = item.info.ver ? ('.' + item.info.ver) : '';
            }
        }

        // 拼接最终的url
        // ugly hack, WeChat does not support loading font likes 'myfont.dw213.ttf'. So append hash to directory
        if (item.ext === '.ttf') {
            url = `${base}/${uuid.slice(0, 2)}/${uuid}${ver}/${item.options.__nativeName__}`;
        }
        else {
            url = `${base}/${uuid.slice(0, 2)}/${uuid}${ver}${item.ext}`;
        }

        item.url = url;
    }
    return null;
}

3.1.3 load管线【加载流程】

load方法做的事情很简单,基本只是创建了新的任务,在loadOneAssetPipeline中执行每个子任务

function load (task, done) {
    if (!task.progress) {
        task.progress = {finish: 0, total: task.input.length};
    }

    var options = task.options, progress = task.progress;
    options.__exclude__ = options.__exclude__ || Object.create(null);
    task.output = [];
    forEach(task.input, function (item, cb) {
        // 对每个input项都创建一个子任务,并交由loadOneAssetPipeline执行
        let subTask = Task.create({
            input: item,
            onProgress: task.onProgress,
            options,
            progress,
            onComplete: function (err, item) {
                if (err && !task.isFinish && !cc.assetManager.force) done(err);
                task.output.push(item);
                subTask.recycle();
                cb();
            }
        });
        // 执行子任务,loadOneAssetPipeline有fetch和parse组成
        loadOneAssetPipeline.async(subTask);
    }, function () {
        // 每个input执行完成后,最后执行该函数
        options.__exclude__ = null;
        if (task.isFinish) {
            clear(task, true);
            return task.dispatch('error');
        }
        gatherAsset(task);
        clear(task, true);
        done();
    });
}

loadOneAssetPipeline如其函数名所示,就是加载一个资源的管线,它分为2步,fetch和parse:

fetch方法用于下载资源文件,由packManager负责下载的实现,fetch会将下载完的文件数据放到item.file中

parse方法用于将加载完的资源文件转换成我们可用的资源对象

对于原生资源,调用parser.parse进行解析,该方法会根据资源类型调用不同的解析方法

  • import资源调用parseImport方法,根据json数据反序列化出Asset对象,并放到assets中
  • 图片资源会调用parseImage、parsePVRTex或parsePKMTex方法解析图像格式(但不会创建Texture对象)
  • 音效资源调用parseAudio方法进行解析
  • plist资源调用parsePlist方法进行解析

对于其它资源

如果uuid在task.options.__exclude__中,则标记为完成,并添加引用计数,否则,根据一些复杂的条件来决定是否加载资源的依赖

var loadOneAssetPipeline = new Pipeline('loadOneAsset', [
    function fetch (task, done) {
        var item = task.output = task.input;
        var { options, isNative, uuid, file } = item;
        var { reload } = options;
        // 如果assets里面已经加载了这个资源,则直接完成
        if (file || (!reload && !isNative && assets.has(uuid))) return done();
        // 下载文件,这是一个异步的过程,文件下载完会被放到item.file中,并执行done驱动管线
        packManager.load(item, task.options, function (err, data) {
            if (err) {
                if (cc.assetManager.force) {
                    err = null;
                } else {
                    cc.error(err.message, err.stack);
                }
                data = null;
            }
            item.file = data;
            done(err);
        });
    },
    // 将资源文件转换成资源对象的过程
    function parse (task, done) {
        var item = task.output = task.input, progress = task.progress, exclude = task.options.__exclude__;
        var { id, file, options } = item;

        if (item.isNative) {
            // 对于原生资源,调用parser.parse进行处理,将处理完的资源放到item.content中,并结束流程
            parser.parse(id, file, item.ext, options, function (err, asset) {
                if (err) {
                    if (!cc.assetManager.force) {
                        cc.error(err.message, err.stack);
                        return done(err);
                    }
                }
                item.content = asset;
                task.dispatch('progress', ++progress.finish, progress.total, item);
                files.remove(id);
                parsed.remove(id);
                done();
            });
        } else {
            var { uuid } = item;
            // 非原生资源,如果在task.options.__exclude__中,直接结束
            if (uuid in exclude) {
                var { finish, content, err, callbacks } = exclude[uuid];
                task.dispatch('progress', ++progress.finish, progress.total, item);

                if (finish || checkCircleReference(uuid, uuid, exclude) ) {
                    content && content.addRef();
                    item.content = content;
                    done(err);
                } else {
                    callbacks.push({ done, item });
                }
            } else {
                // 如果不是reload,且asset中包含了该uuid
                if (!options.reload && assets.has(uuid)) {
                    var asset = assets.get(uuid);
                    // 开启了options.__asyncLoadAssets__,或asset.__asyncLoadAssets__为false,直接结束,不加载依赖
                    if (options.__asyncLoadAssets__ || !asset.__asyncLoadAssets__) {
                        item.content = asset.addRef();
                        task.dispatch('progress', ++progress.finish, progress.total, item);
                        done();
                    }
                    else {
                        loadDepends(task, asset, done, false);
                    }
                } else {
                    // 如果是reload,或者assets中没有,则进行解析,并加载依赖
                    parser.parse(id, file, 'import', options, function (err, asset) {
                        if (err) {
                            if (cc.assetManager.force) {
                                err = null;
                            }
                            else {
                                cc.error(err.message, err.stack);
                            }
                            return done(err);
                        }

                        asset._uuid = uuid;
                        loadDepends(task, asset, done, true);
                    });
                }
            }
        }
    }
]);

3.2 文件下载

creator使用packManager.load来完成下载的工作,当要下载一个文件时,有2个问题需要考虑:

  • 该文件是否被打包了,比如由于勾选了内联所有SpriteFrame,导致SpriteFrame的json文件被合并到prefab中
  • 当前平台是原生平台还是web平台,对于一些本地资源,原生平台需要从磁盘读取
// packManager.load的实现
load (item, options, onComplete) {
  // 如果资源没有被打包,则直接调用downloader.download下载(download内部也有已下载和加载中的判断)
  if (item.isNative || !item.info || !item.info.packs) return downloader.download(item.id, item.url, item.ext, item.options, onComplete);
  // 如果文件已经下载过了,则直接返回
  if (files.has(item.id)) return onComplete(null, files.get(item.id));

  var packs = item.info.packs;
  // 如果pack已经在加载中,则将回调添加到_loading队列,等加载完成后触发回调
  var pack = packs.find(isLoading);
  if (pack) return _loading.get(pack.uuid).push({ onComplete, id: item.id });

  // 下载一个新的pack
  pack = packs[0];
  _loading.add(pack.uuid, [{ onComplete, id: item.id }]);
  let url = cc.assetManager._transform(pack.uuid, {ext: pack.ext, bundle: item.config.name});
  // 下载pack并解包,
  downloader.download(pack.uuid, url, pack.ext, item.options, function (err, data) {
      files.remove(pack.uuid);
      if (err) {
          cc.error(err.message, err.stack);
      }
      // unpack package,内部实现包含2种解包,一种针对prefab、图集等json数组的分割解包,另一种针对Texture2D的content进行解包
      packManager.unpack(pack.packs, data, pack.ext, item.options, function (err, result) {
          if (!err) {
              for (var id in result) {
                  files.add(id, result[id]);
              }
          }
          var callbacks = _loading.remove(pack.uuid);
          for (var i = 0, l = callbacks.length; i < l; i++) {
              var cb = callbacks[i];
              if (err) {
                  cb.onComplete(err);
                  continue;
              }

              var data = result[cb.id];
              if (!data) {
                  cb.onComplete(new Error('can not retrieve data from package'));
              }
              else {
                  cb.onComplete(null, data);
              }
          }
      });
  });
}

3.2.1 Web平台的下载

web平台的download实现如下:

  • 用一个downloaders数组来管理各种资源类型对应的下载方式
  • 使用files缓存来避免重复下载
  • 使用_downloading队列来处理并发下载同一个资源时的回调,并保证时序
  • 支持了下载的优先级、重试等逻辑
download (id, url, type, options, onComplete) {
  // 取出downloaders中对应类型的下载回调
  let func = downloaders[type] || downloaders['default'];
  let self = this;
  // 避免重复下载
  let file, downloadCallbacks;
  if (file = files.get(id)) {
      onComplete(null, file);
  }
  // 如果在下载中,添加到队列
  else if (downloadCallbacks = _downloading.get(id)) {
      downloadCallbacks.push(onComplete);
      for (let i = 0, l = _queue.length; i < l; i++) {
          var item = _queue[i];
          if (item.id === id) {
              var priority = options.priority || 0;
              if (item.priority < priority) {
                  item.priority = priority;
                  _queueDirty = true;
              }
              return;
          }
      }
  }
  else {
      // 进行下载,并设置好下载失败的重试
      var maxRetryCount = options.maxRetryCount || this.maxRetryCount;
      var maxConcurrency = options.maxConcurrency || this.maxConcurrency;
      var maxRequestsPerFrame = options.maxRequestsPerFrame || this.maxRequestsPerFrame;

      function process (index, callback) {
          if (index === 0) {
              _downloading.add(id, [onComplete]);
          }
          if (!self.limited) return func(urlAppendTimestamp(url), options, callback);
          updateTime();

          function invoke () {
              func(urlAppendTimestamp(url), options, function () {
                  // when finish downloading, update _totalNum
                  _totalNum--;
                  if (!_checkNextPeriod && _queue.length > 0) {
                      callInNextTick(handleQueue, maxConcurrency, maxRequestsPerFrame);
                      _checkNextPeriod = true;
                  }
                  callback.apply(this, arguments);
              });
          }

          if (_totalNum < maxConcurrency && _totalNumThisPeriod < maxRequestsPerFrame) {
              invoke();
              _totalNum++;
              _totalNumThisPeriod++;
          }
          else {
              // when number of request up to limitation, cache the rest
              _queue.push({ id, priority: options.priority || 0, invoke });
              _queueDirty = true;

              if (!_checkNextPeriod && _totalNum < maxConcurrency) {
                  callInNextTick(handleQueue, maxConcurrency, maxRequestsPerFrame);
                  _checkNextPeriod = true;
              }
          }
      }

      // retry完成后,将文件添加到files缓存中,从_downloading队列中移除,并执行callbacks回调
      // when retry finished, invoke callbacks
      function finale (err, result) {
          if (!err) files.add(id, result);
          var callbacks = _downloading.remove(id);
          for (let i = 0, l = callbacks.length; i < l; i++) {
              callbacks[i](err, result);
          }
      }

      retry(process, maxRetryCount, this.retryInterval, finale);
  }
}

downloaders是一个map,映射了各种资源类型对应的下载方法,在web平台主要包含以下几类下载方法:

图片类 downloadImage

  • downloadDomImage 使用Html的Image元素,指定其src属性来下载
  • downloadBlob 以文件下载的方式下载图片

文件类,这里可以分为二进制文件、json文件和文本文件

  • downloadArrayBuffer 指定arraybuffer类型调用downloadFile,用于skel、bin、pvr等文件下载
  • downloadText 指定text类型调用downloadFile,用于atlas、tmx、xml、vsh等文件下载
  • downloadJson 指定json类型调用downloadFile,并在下载完后解析json,用于plist、json等文件下载

字体类 loadFont 构建css样式,指定url下载

声音类 downloadAudio

  • downloadDomAudio 创建Html的audio元素,指定其src属性来下载
  • downloadBlob 以文件下载的方式下载音效

视频类 downloadVideo web端直接返回了

脚本 downloadScript 创建Html的script元素,指定其src属性来下载并执行

Bundle downloadBundle 同时下载了Bundle的json和脚本

downloadFile使用了XMLHttpRequest来下载文件,具体实现如下:

function downloadFile (url, options, onProgress, onComplete) {
    var { options, onProgress, onComplete } = parseParameters(options, onProgress, onComplete);
    var xhr = new XMLHttpRequest(), errInfo = 'download failed: ' + url + ', status: ';
    xhr.open('GET', url, true);

    if (options.responseType !== undefined) xhr.responseType = options.responseType;
    if (options.withCredentials !== undefined) xhr.withCredentials = options.withCredentials;
    if (options.mimeType !== undefined && xhr.overrideMimeType ) xhr.overrideMimeType(options.mimeType);
    if (options.timeout !== undefined) xhr.timeout = options.timeout;

    if (options.header) {
        for (var header in options.header) {
            xhr.setRequestHeader(header, options.header[header]);
        }
    }

    xhr.onload = function () {
        if ( xhr.status === 200 || xhr.status === 0 ) {
            onComplete && onComplete(null, xhr.response);
        } else {
            onComplete && onComplete(new Error(errInfo + xhr.status + '(no response)'));
        }

    };

    if (onProgress) {
        xhr.onprogress = function (e) {
            if (e.lengthComputable) {
                onProgress(e.loaded, e.total);
            }
        };
    }

    xhr.onerror = function(){
        onComplete && onComplete(new Error(errInfo + xhr.status + '(error)'));
    };
    xhr.ontimeout = function(){
        onComplete && onComplete(new Error(errInfo + xhr.status + '(time out)'));
    };
    xhr.onabort = function(){
        onComplete && onComplete(new Error(errInfo + xhr.status + '(abort)'));
    };

    xhr.send(null);
    return xhr;
}

3.2.2 原生平台下载

原生平台的引擎相关文件可以在引擎目录的resources/builtin/jsb-adapter/engine目录下,资源加载相关的实现在jsb-loader.js文件中,这里的downloader重新注册了回调函数。

downloader.register({
    // JS
    '.js' : downloadScript,
    '.jsc' : downloadScript,

    // Images
    '.png' : downloadAsset,
    '.jpg' : downloadAsset,
    ...
});

在原生平台下,downloadAsset等方法都会调用download来进行资源的下载,在资源下载之前会调用transformUrl对url进行检测,主要判断该资源是网络资源还是本地资源,如果是网络资源,是否已经下载过了。只有没下载过的网络资源,才需要进行下载。不需要下载的在文件解析的地方会直接读文件。

// func传入的是下载完成之后的处理,比如脚本下载完成后需要执行,此时会调用window.require
// 如果说要下载的是json资源之类的,传入的func是doNothing,也就是直接调用onComplete方法
function download (url, func, options, onFileProgress, onComplete) {
    var result = transformUrl(url, options);
    // 如果是本地文件,直接指向func
    if (result.inLocal) {
        func(result.url, options, onComplete);
    }
    // 如果在缓存中,更新资源的最后使用时间(lru)
    else if (result.inCache) {
        cacheManager.updateLastTime(url)
        func(result.url, options, function (err, data) {
            if (err) {
                cacheManager.removeCache(url);
            }
            onComplete(err, data);
        });
    }
    else {
        // 未下载的网络资源,调用downloadFile进行下载
        var time = Date.now();
        var storagePath = '';
        if (options.__cacheBundleRoot__) {
            storagePath = `${cacheManager.cacheDir}/${options.__cacheBundleRoot__}/${time}${suffix++}${cc.path.extname(url)}`;
        }
        else {
            storagePath = `${cacheManager.cacheDir}/${time}${suffix++}${cc.path.extname(url)}`;
        }
        // 使用downloadFile下载并缓存
        downloadFile(url, storagePath, options.header, onFileProgress, function (err, path) {
            if (err) {
                onComplete(err, null);
                return;
            }
            func(path, options, function (err, data) {
                if (!err) {
                    cacheManager.cacheFile(url, storagePath, options.__cacheBundleRoot__);
                }
                onComplete(err, data);
            });
        });
    }
}

function transformUrl (url, options) {
    var inLocal = false;
    var inCache = false;
    // 通过正则匹配是不是URL
    if (REGEX.test(url)) {
        if (options.reload) {
            return { url };
        }
        else {
            // 检查是否在缓存中(本地磁盘缓存)
            var cache = cacheManager.cachedFiles.get(url);
            if (cache) {
                inCache = true;
                url = cache.url;
            }
        }
    }
    else {
        inLocal = true;
    }
    return { url, inLocal, inCache };
}

downloadFile会调用原生平台的jsb_downloader来下载资源,并保存到本地磁盘中

downloadFile (remoteUrl, filePath, header, onProgress, onComplete) {
  downloading.add(remoteUrl, { onProgress, onComplete });
  var storagePath = filePath;
  if (!storagePath) storagePath = tempDir + '/' + performance.now() + cc.path.extname(remoteUrl);
  jsb_downloader.createDownloadFileTask(remoteUrl, storagePath, header);
},

3.3 文件解析

在loadOneAssetPipeline中,资源会经过fetch和parse两个管线进行处理,fetch负责下载而parse负责解析资源,并实例化资源对象。在parse方法中调用了parser.parse将文件内容传入,解析成对应的Asset对象,并返回。

3.3.1 Web平台解析

Web平台下的parser.parse主要做的是对解析中的文件的管理,为解析中、解析完的文件维护一个列表,避免重复解析。同时维护了解析完成后的回调列表,而真正的解析方法在parsers数组中。

parse (id, file, type, options, onComplete) {
  let parsedAsset, parsing, parseHandler;
  if (parsedAsset = parsed.get(id)) {
      onComplete(null, parsedAsset);
  }
  else if (parsing = _parsing.get(id)){
      parsing.push(onComplete);
  }
  else if (parseHandler = parsers[type]){
      _parsing.add(id, [onComplete]);
      parseHandler(file, options, function (err, data) {
          if (err) {
              files.remove(id);
          }
          else if (!isScene(data)){
              parsed.add(id, data);
          }
          let callbacks = _parsing.remove(id);
          for (let i = 0, l = callbacks.length; i < l; i++) {
              callbacks[i](err, data);
          }
      });
  }
  else {
      onComplete(null, file);
  }
}

parsers映射了各种类型文件的解析方法,下面以图片和普通的asset资源为例:

注意:在parseImport方法中,反序列化方法会将资源的依赖放到asset.__depends__中,结构为数组,数组中每个对象包含3个字段,资源id uuid、owner 对象、prop 属性。比如一个Prefab资源,下面有2个节点,都引用了同一个资源,depends列表需要为这两个节点对象分别记录一条依赖信息 [{uuid:xxx, owner:1, prop:tex}, {uuid:xxx, owner:2, prop:tex}]

// 映射图片格式到解析方法
var parsers = {
  '.png' : parser.parseImage,
  '.jpg' : parser.parseImage,
  '.bmp' : parser.parseImage,
  '.jpeg' : parser.parseImage,
  '.gif' : parser.parseImage,
  '.ico' : parser.parseImage,
  '.tiff' : parser.parseImage,
  '.webp' : parser.parseImage,
  '.image' : parser.parseImage,
  '.pvr' : parser.parsePVRTex,
  '.pkm' : parser.parsePKMTex,
  // Audio
  '.mp3' : parser.parseAudio,
  '.ogg' : parser.parseAudio,
  '.wav' : parser.parseAudio,
  '.m4a' : parser.parseAudio,

  // plist
  '.plist' : parser.parsePlist,
  'import' : parser.parseImport
};

// 图片并不会解析成Asset对象,而是解析成对应的图片对象
parseImage (file, options, onComplete) {
  if (capabilities.imageBitmap && file instanceof Blob) {
      let imageOptions = {};
      imageOptions.imageOrientation = options.__flipY__ ? 'flipY' : 'none';
      imageOptions.premultiplyAlpha = options.__premultiplyAlpha__ ? 'premultiply' : 'none';
      createImageBitmap(file, imageOptions).then(function (result) {
          result.flipY = !!options.__flipY__;
          result.premultiplyAlpha = !!options.__premultiplyAlpha__;
          onComplete && onComplete(null, result);
      }, function (err) {
          onComplete && onComplete(err, null);
      });
  }
  else {
      onComplete && onComplete(null, file);
  }
},

// Asset对象的解析,通过deserialize实现,大致流程是解析json然后找到对应的class,并调用对应class的_deserialize方法拷贝数据、初始化变量,并将依赖资源放到asset.__depends
parseImport (file, options, onComplete) {
  if (!file) return onComplete && onComplete(new Error('Json is empty'));
  var result, err = null;
  try {
      result = deserialize(file, options);
  }
  catch (e) {
      err = e;
  }
  onComplete && onComplete(err, result);
},

3.3.2 原生平台解析

在原生平台下,jsb-loader.js中重新注册了各种资源的解析方法:

parser.register({
    '.png' : downloader.downloadDomImage,
    '.binary' : parseArrayBuffer,
    '.txt' : parseText,
    '.plist' : parsePlist,
    '.font' : loadFont,
    '.ExportJson' : parseJson,
    ...
});

图片的解析方法竟然是downloader.downloadDomImage?跟踪原生平台调试了一下,确实是调用的这个方法,创建了Image对象并指定src来加载图片,这种方式加载本地磁盘的图片也是可以的,但纹理对象又是如何创建的呢?通过Texture2D对应的json文件,creator在加载真正的原生纹理之前,就已经创建好了Texture2D这个Asset对象,而在加载完原生图片资源后,会将Image对象设置为Texture2D对象的_nativeAsset,在这个属性的set方法中,会调用initWithData或initWithElement,这里才真正使用纹理数据创建了用于渲染的纹理对象。

var Texture2D = cc.Class({
    name: 'cc.Texture2D',
    extends: require('../assets/CCAsset'),
    mixins: [EventTarget],

    properties: {
        _nativeAsset: {
            get () {
                // maybe returned to pool in webgl
                return this._image;
            },
            set (data) {
                if (data._data) {
                    this.initWithData(data._data, this._format, data.width, data.height);
                }
                else {
                    this.initWithElement(data);
                }
            },
            override: true
        },

而对于parseJson、parseText、parseArrayBuffer等实现,这里只是简单地调用了文件系统读取文件而已。像一些拿到文件内容之后,需要进一步解析才能使用的资源呢?比如模型、骨骼等资源依赖二进制的模型数据,这些数据的解析在哪里呢?没错,跟上面的Texture2D一样,都是放在对应的Asset资源本身,有些在_nativeAsset字段的setter回调中初始化,而有些会在真正使用这个资源时才惰性地进行初始化。

// 在jsb-loader.js文件中
function parseText (url, options, onComplete) {
    readText(url, onComplete);
}

function parseArrayBuffer (url, options, onComplete) {
    readArrayBuffer(url, onComplete);
}

function parseJson (url, options, onComplete) {
    readJson(url, onComplete);
}

// 在jsb-fs-utils.js文件中
    readText (filePath, onComplete) {
        fsUtils.readFile(filePath, 'utf8', onComplete);
    },

    readArrayBuffer (filePath, onComplete) {
        fsUtils.readFile(filePath, '', onComplete);
    },

    readJson (filePath, onComplete) {
        fsUtils.readFile(filePath, 'utf8', function (err, text) {
            var out = null;
            if (!err) {
                try {
                    out = JSON.parse(text);
                }
                catch (e) {
                    cc.warn('Read json failed: ' + e.message);
                    err = new Error(e.message);
                }
            }
            onComplete && onComplete(err, out);
        });
    },

像图集、Prefab这些资源又是怎么初始化的呢?Creator还是使用parseImport方法进行解析,因为这些资源对应的类型是import,原生平台下并没有覆盖这种类型对应的parse函数,而这些资源会直接反序列化成可用的Asset对象。

3.4 依赖加载

creator将资源分为两大类,普通资源和原生资源,普通资源包括cc.Asset及其子类,如cc.SpriteFrame、cc.Texture2D、cc.Prefab等等。原生资源包括各种格式的纹理、音乐、字体等文件,在游戏中我们无法直接使用这些原生资源,而是需要让creator将他们转换成对应的cc.Asset对象之后才能使用。

在creator中,一个Prefab可能会依赖很多资源,这些依赖也可以分为普通依赖和原生资源依赖,creator的cc.Asset提供了_parseDepsFromJson_parseNativeDepFromJson方法来检查资源的依赖。loadDepends通过getDepends方法搜集了资源的依赖。

loadDepends创建了一个子任务来负责依赖资源的加载,并调用pipeline执行加载,实际上无论有无依赖需要加载,都会执行这段逻辑,加载完成后执行以下重要逻辑:

  • 初始化assset:在依赖加载完成后,将依赖的资源赋值到asset对应的属性后调用asset.onLoad
  • 将资源对应的files和parsed缓存移除,并缓存资源到assets中(如果是场景的话,不会缓存)
  • 执行repeatItem.callbacks列表中的回调(在loadDepends的开头构造,默认记录传入的done方法)
// 加载指定asset的依赖项
function loadDepends (task, asset, done, init) {

    var item = task.input, progress = task.progress;
    var { uuid, id, options, config } = item;
    var { __asyncLoadAssets__, cacheAsset } = options;

    var depends = [];
    // 增加引用计数来避免加载依赖的过程中资源被释放,调用getDepends获取依赖资源
    asset.addRef && asset.addRef();
    getDepends(uuid, asset, Object.create(null), depends, false, __asyncLoadAssets__, config);
    task.dispatch('progress', ++progress.finish, progress.total += depends.length, item);

    var repeatItem = task.options.__exclude__[uuid] = { content: asset, finish: false, callbacks: [{ done, item }] };

    let subTask = Task.create({
        input: depends,
        options: task.options,
        onProgress: task.onProgress,
        onError: Task.prototype.recycle,
        progress,
        onComplete: function (err) {
            // 在所有依赖项加载完成之后回调
            asset.decRef && asset.decRef(false);
            asset.__asyncLoadAssets__ = __asyncLoadAssets__;
            repeatItem.finish = true;
            repeatItem.err = err;

            if (!err) {
                var assets = Array.isArray(subTask.output) ? subTask.output : [subTask.output];
                // 构造一个map,记录uuid到asset的映射
                var map = Object.create(null);
                for (let i = 0, l = assets.length; i < l; i++) {
                    var dependAsset = assets[i];
                    dependAsset && (map[dependAsset instanceof cc.Asset ? dependAsset._uuid + '@import' : uuid + '@native'] = dependAsset);
                }

                // 调用setProperties将对应的依赖资源设置到asset的成员变量中
                if (!init) {
                    if (asset.__nativeDepend__ && !asset._nativeAsset) {
                        var missingAsset = setProperties(uuid, asset, map);
                        if (!missingAsset) {
                            try {
                                asset.onLoad && asset.onLoad();
                            }
                            catch (e) {
                                cc.error(e.message, e.stack);
                            }
                        }
                    }
                }
                else {
                    var missingAsset = setProperties(uuid, asset, map);
                    if (!missingAsset) {
                        try {
                            asset.onLoad && asset.onLoad();
                        }
                        catch (e) {
                            cc.error(e.message, e.stack);
                        }
                    }
                    files.remove(id);
                    parsed.remove(id);
                    cache(uuid, asset, cacheAsset !== undefined ? cacheAsset : cc.assetManager.cacheAsset);
                }
                subTask.recycle();
            }

            // 这个repeatItem可能有很多个地方都加载了它,要通知所有回调加载完成
            var callbacks = repeatItem.callbacks;
            for (var i = 0, l = callbacks.length; i < l; i++) {
                var cb = callbacks[i];
                asset.addRef && asset.addRef();
                cb.item.content = asset;
                cb.done(err);
            }
            callbacks.length = 0;
        }
    });

    pipeline.async(subTask);
}

3.4.1 依赖解析

getDepends (uuid, data, exclude, depends, preload, asyncLoadAssets, config) {
  var err = null;
  try {
      var info = dependUtil.parse(uuid, data);
      var includeNative = true;
      if (data instanceof cc.Asset && (!data.__nativeDepend__ || data._nativeAsset)) includeNative = false;
      if (!preload) {
          asyncLoadAssets = !CC_EDITOR && (!!data.asyncLoadAssets || (asyncLoadAssets && !info.preventDeferredLoadDependents));
          for (let i = 0, l = info.deps.length; i < l; i++) {
              let dep = info.deps[i];
              if (!(dep in exclude)) {
                  exclude[dep] = true;
                  depends.push({uuid: dep, __asyncLoadAssets__: asyncLoadAssets, bundle: config && config.name});
              }
          }

          if (includeNative && !asyncLoadAssets && !info.preventPreloadNativeObject && info.nativeDep) {
              config && (info.nativeDep.bundle = config.name);
              depends.push(info.nativeDep);
          }

      } else {
          for (let i = 0, l = info.deps.length; i < l; i++) {
              let dep = info.deps[i];
              if (!(dep in exclude)) {
                  exclude[dep] = true;
                  depends.push({uuid: dep, bundle: config && config.name});
              }
          }
          if (includeNative && info.nativeDep) {
              config && (info.nativeDep.bundle = config.name);
              depends.push(info.nativeDep);
          }
      }
  }
  catch (e) {
      err = e;
  }
  return err;
},

dependUtil是一个控制依赖列表的单例,通过传入uuid和asset对象来解析该对象的依赖资源列表,返回的依赖资源列表可能包含以下4个字段:

  • deps 依赖的Asset资源
  • nativeDep 依赖的原生资源
  • preventPreloadNativeObject 禁止预加载原生对象,这个值默认是false
  • preventDeferredLoadDependents 禁止延迟加载依赖,默认为false,对于骨骼动画、TiledMap等资源为true
  • parsedFromExistAsset 是否直接从asset.__depends__中取出

dependUtil还维护了_depends缓存来避免依赖的重复查询,这个缓存会在首次查询某资源依赖时添加,当该资源被释放时移除

// 根据json信息获取其资源依赖列表,实际上json信息就是asset对象
parse (uuid, json) {
  var out = null;
  // 如果是场景或者Prefab,data会是一个数组,scene or prefab
  if (Array.isArray(json)) {
      // 如果已经解析过了,在_depends中有依赖列表,则直接返回
      if (this._depends.has(uuid)) return this._depends.get(uuid)
      out = {
          // 对于Prefab或场景,直接使用_parseDepsFromJson方法返回
          deps: cc.Asset._parseDepsFromJson(json),
          asyncLoadAssets: json[0].asyncLoadAssets
      };
  }
  // 如果包含__type__,获取其构造函数,并从json中查找依赖资源 get deps from json
  // 实际测试,预加载的资源会走下面这个分支,预加载的资源并没有把json反序列化成Asset对象
  else if (json.__type__) {
      if (this._depends.has(uuid)) return this._depends.get(uuid);
      var ctor = js._getClassById(json.__type__);
      // 部分资源重写了_parseDepsFromJson和_parseNativeDepFromJson方法
      // 比如cc.Texture2D
      out = {
          preventPreloadNativeObject: ctor.preventPreloadNativeObject,
          preventDeferredLoadDependents: ctor.preventDeferredLoadDependents,
          deps: ctor._parseDepsFromJson(json),
          nativeDep: ctor._parseNativeDepFromJson(json)
      };
      out.nativeDep && (out.nativeDep.uuid = uuid);
  }
  // get deps from an existing asset
  // 如果没有__type__字段,则无法找到它对应的ctor,从asset的__depends__字段中取出依赖
  else {
      if (!CC_EDITOR && (out = this._depends.get(uuid)) && out.parsedFromExistAsset) return out;
      var asset = json;
      out = {
          deps: [],
          parsedFromExistAsset: true,
          preventPreloadNativeObject: asset.constructor.preventPreloadNativeObject,
          preventDeferredLoadDependents: asset.constructor.preventDeferredLoadDependents
      };
      let deps = asset.__depends__;
      for (var i = 0, l = deps.length; i < l; i++) {
          var dep = deps[i].uuid;
          out.deps.push(dep);
      }

      if (asset.__nativeDepend__) {
          // asset._nativeDep会返回类似这样的对象 {__isNative__: true, uuid: this._uuid, ext: this._native}
          out.nativeDep = asset._nativeDep;
      }
  }
  // 第一次找到依赖,直接放到_depends列表中,cache dependency list
  this._depends.add(uuid, out);
  return out;
}

CCAsset默认的_parseDepsFromJson_parseNativeDepFromJson实现如下,_parseDepsFromJson通过调用parseDependRecursively递归json,将json对象及其子对象的所有__uuid__全部找到放到depends数组中。Texture2D、TTFFont、AudioClip的实现为直接返回空数组,而SpriteFrame的实现为返回cc.assetManager.utils.decodeUuid(json.content.texture),这个字段记录了SpriteFrame对应纹理的uuid。

_parseNativeDepFromJson在改asset的_native有值的情况下,会返回{ __isNative__: true, ext: json._native}。实际上大部分的native资源走的是_nativeDep,这个属性的get方法会返回一个包含类似这样的对象{__isNative__: true, uuid: this._uuid, ext: this._native}

_parseDepsFromJson (json) {
      var depends = [];
      parseDependRecursively(json, depends);
      return depends;
},

_parseNativeDepFromJson (json) {
if (json._native) return { __isNative__: true, ext: json._native};
      return null;
}

3.5 资源释放

这一小节重点介绍在Creator中释放资源的三种方式以及其背后的实现,最后介绍在项目中如何排查资源泄露的情况。

3.5.1 Creator的资源释放

Creator支持以下3种资源释放的方式:

释放方式 释放效果
勾选:场景->属性检查器->自动释放资源 在场景切换后,自动释放新场景不使用的资源
引用计数释放res.decRef 使用addRef和decRef维护引用计数,在decRef后引用计数为0时自动释放
手动释放cc.assetManager.releaseAsset(texture); 手动释放资源,强制释放

3.5.2 场景自动释放

当一个新场景运行的时候会执行Director.runSceneImmediate方法,这里调用了_autoRelease来实现老场景资源的自动释放(如果老场景勾选了自动释放资源)。

runSceneImmediate: function (scene, onBeforeLoadScene, onLaunched) {
  // 省略代码...
  var oldScene = this._scene;
  if (!CC_EDITOR) {
      // 自动释放资源
      CC_BUILD && CC_DEBUG && console.time('AutoRelease');
      cc.assetManager._releaseManager._autoRelease(oldScene, scene, persistNodeList);
      CC_BUILD && CC_DEBUG && console.timeEnd('AutoRelease');
  }

  // unload scene
  CC_BUILD && CC_DEBUG && console.time('Destroy');
  if (cc.isValid(oldScene)) {
      oldScene.destroy();
  }
  // 省略代码...
},

最新版本的_autoRelease的实现非常简洁干脆,将持久节点的引用从老场景迁移到新场景,然后直接调用资源的decRef减少引用计数,而是否释放老场景引用的资源,则取决于老场景是否设置了autoReleaseAssets。

// do auto release
_autoRelease (oldScene, newScene, persistNodes) {
  // 所有持久节点依赖的资源自动addRef、并记录到sceneDeps.persistDeps中
  for (let i = 0, l = persistNodes.length; i < l; i++) {
      var node = persistNodes[i];
      var sceneDeps = dependUtil._depends.get(newScene._id);
      var deps = _persistNodeDeps.get(node.uuid);
      for (let i = 0, l = deps.length; i < l; i++) {
          var dependAsset = assets.get(deps[i]);
          if (dependAsset) {
              dependAsset.addRef();
          }
      }
      if (sceneDeps) {
          !sceneDeps.persistDeps && (sceneDeps.persistDeps = []);
          sceneDeps.persistDeps.push.apply(sceneDeps.persistDeps, deps);
      }
  }

  // 释放老场景的依赖
  if (oldScene) {
      var childs = dependUtil.getDeps(oldScene._id);
      for (let i = 0, l = childs.length; i < l; i++) {
          let asset = assets.get(childs[i]);
          asset && asset.decRef(CC_TEST || oldScene.autoReleaseAssets);
      }
      var dependencies = dependUtil._depends.get(oldScene._id);
      if (dependencies && dependencies.persistDeps) {
          var persistDeps = dependencies.persistDeps;
          for (let i = 0, l = persistDeps.length; i < l; i++) {
              let asset = assets.get(persistDeps[i]);
              asset && asset.decRef(CC_TEST || oldScene.autoReleaseAssets);
          }
      }
      dependUtil.remove(oldScene._id);
  }
},

3.5.3 引用计数和手动释放资源

剩下两种释放资源的方式,本质上都是调用releaseManager.tryRelease来实现资源释放,区别在于decRef是根据引用计数和autoRelease来决定是否调用tryRelease,而releaseAsset是强制释放。资源释放的完整流程大致如下图所示:

// CCAsset.js 减少引用
decRef (autoRelease) {
  this._ref--;
  autoRelease !== false && cc.assetManager._releaseManager.tryRelease(this);
  return this;
}

// CCAssetManager.js 手动释放资源
releaseAsset (asset) {
  releaseManager.tryRelease(asset, true);
},

tryRelease支持延迟释放和强制释放2种模式,当传入force参数为true时直接进入释放流程,否则creator会将资源放入待释放的列表中,并在EVENT_AFTER_DRAW事件中执行freeAssets方法真正清理资源。不论何种方式,资源会传入到_free方法处理,这个方法做了以下几件事情。

  • 从_toDelete中移除
  • 在非force释放时,需要检查是否还有其它引用,如果是则返回
  • 从assets缓存中移除
  • 自动释放依赖资源
  • 调用资源的destroy方法销毁资源
  • 从dependUtil中移除资源的依赖记录

checkCircularReference返回值如果大于0,表示资源还有被其它地方引用,其它地方指所有我们addRef的地方,该方法会先记录asset当前的refCount,然后消除掉资源和依赖资源中对asset的引用,这相当于资源A内部挂载了组件B和C,它们都引用了资源A,此时资源A的引用计数为2,而组件B和C其实是要跟着A释放的,而A被B和C引用着,计数就不为0无法释放,所以checkCircularReference先排除了内部的引用。如果资源的refCount减去了内部的引用次数还大于1,说明有其它地方还引用着它,不能释放。

tryRelease (asset, force) {
  if (!(asset instanceof cc.Asset)) return;
  if (force) {
      releaseManager._free(asset, force);
  }
  else {
      _toDelete.add(asset._uuid, asset);
      // 在下次Director绘制完成之后,执行freeAssets
      if (!eventListener) {
          eventListener = true;
          cc.director.once(cc.Director.EVENT_AFTER_DRAW, freeAssets);
      }
  }
}

// 释放资源
_free (asset, force) {
  _toDelete.remove(asset._uuid);

  if (!cc.isValid(asset, true)) return;

  if (!force) {
      if (asset.refCount > 0) {
          // 检查资源内部的循环引用
          if (checkCircularReference(asset) > 0) return;
      }
  }

  // 从缓存中移除
  assets.remove(asset._uuid);
  var depends = dependUtil.getDeps(asset._uuid);
  for (let i = 0, l = depends.length; i < l; i++) {
      var dependAsset = assets.get(depends[i]);
      if (dependAsset) {
          dependAsset.decRef(false);
          releaseManager._free(dependAsset, false);
      }
  }
  asset.destroy();
  dependUtil.remove(asset._uuid);
},

// 释放_toDelete中的资源并清空
function freeAssets () {
  eventListener = false;
  _toDelete.forEach(function (asset) {
      releaseManager._free(asset);
  });
  _toDelete.clear();
}

asset.destroy做了什么?资源对象是如何被释放掉的?像纹理、声音这样的资源又是如何被释放掉的呢?Asset对象本身并没有destroy方法,而是Asset对象所继承的CCObject对象实现了destroy,这里的实现只是将对象放到了一个待释放的数组中,并打上ToDestroy的标记。Director每一帧都会调用deferredDestroy来执行_destroyImmediate进行资源释放,这个方法会对对象的Destroyed标记进行判断和操作、调用_onPreDestroy方法执行回调、以及_destruct方法进行析构。

prototype.destroy = function () {
    if (this._objFlags & Destroyed) {
        cc.warnID(5000);
        return false;
    }
    if (this._objFlags & ToDestroy) {
        return false;
    }
    this._objFlags |= ToDestroy;
    objectsToDestroy.push(this);

    if (CC_EDITOR && deferredDestroyTimer === null && cc.engine && ! cc.engine._isUpdating) {
        // 在编辑器模式下可以立即销毁
        deferredDestroyTimer = setImmediate(deferredDestroy);
    }
    return true;
};

// Director每一帧都会调用这个方法
function deferredDestroy () {
    var deleteCount = objectsToDestroy.length;
    for (var i = 0; i < deleteCount; ++i) {
        var obj = objectsToDestroy[i];
        if (!(obj._objFlags & Destroyed)) {
            obj._destroyImmediate();
        }
    }
    // 当我们在a.onDestroy中调用b.destroy,objectsToDestroy数组的大小会变化,我们只销毁在这次deferredDestroy之前objectsToDestroy中的元素
    if (deleteCount === objectsToDestroy.length) {
        objectsToDestroy.length = 0;
    }
    else {
        objectsToDestroy.splice(0, deleteCount);
    }

    if (CC_EDITOR) {
        deferredDestroyTimer = null;
    }
}

// 真正的资源释放
prototype._destroyImmediate = function () {
    if (this._objFlags & Destroyed) {
        cc.errorID(5000);
        return;
    }
    // 执行回调
    if (this._onPreDestroy) {
        this._onPreDestroy();
    }

    if ((CC_TEST ? (/* make CC_EDITOR mockable*/ Function('return !CC_EDITOR'))() : !CC_EDITOR) || cc.engine._isPlaying) {
        this._destruct();
    }

    this._objFlags |= Destroyed;
};

在这里_destruct做的事情就是将对象的属性清空,比如将object类型的属性置为null,将string类型的属性置为'',compileDestruct方法会返回一个该类的析构函数,compileDestruct先收集了普通object和cc.Class这两种类型下的所有属性,并根据类型构建了一个propsToReset用来清空属性,支持JIT的情况下会根据要清空的属性生成一个类似这样的函数返回function(o) {o.a='';o.b=null;o.['c']=undefined...},而非JIT情况下会返回一个根据propsToReset遍历处理的函数,前者占用更多内存,但效率更高。

prototype._destruct = function () {
    var ctor = this.constructor;
    var destruct = ctor.__destruct__;
    if (!destruct) {
        destruct = compileDestruct(this, ctor);
        js.value(ctor, '__destruct__', destruct, true);
    }
    destruct(this);
};

function compileDestruct (obj, ctor) {
    var shouldSkipId = obj instanceof cc._BaseNode || obj instanceof cc.Component;
    var idToSkip = shouldSkipId ? '_id' : null;

    var key, propsToReset = {};
    for (key in obj) {
        if (obj.hasOwnProperty(key)) {
            if (key === idToSkip) {
                continue;
            }
            switch (typeof obj[key]) {
                case 'string':
                    propsToReset[key] = '';
                    break;
                case 'object':
                case 'function':
                    propsToReset[key] = null;
                    break;
            }
        }
    }
    // Overwrite propsToReset according to Class
    if (cc.Class._isCCClass(ctor)) {
        var attrs = cc.Class.Attr.getClassAttrs(ctor);
        var propList = ctor.__props__;
        for (var i = 0; i < propList.length; i++) {
            key = propList[i];
            var attrKey = key + cc.Class.Attr.DELIMETER + 'default';
            if (attrKey in attrs) {
                if (shouldSkipId && key === '_id') {
                    continue;
                }
                switch (typeof attrs[attrKey]) {
                    case 'string':
                        propsToReset[key] = '';
                        break;
                    case 'object':
                    case 'function':
                        propsToReset[key] = null;
                        break;
                    case 'undefined':
                        propsToReset[key] = undefined;
                        break;
                }
            }
        }
    }

    if (CC_SUPPORT_JIT) {
        // compile code
        var func = '';
        for (key in propsToReset) {
            var statement;
            if (CCClass.IDENTIFIER_RE.test(key)) {
                statement = 'o.' + key + '=';
            }
            else {
                statement = 'o[' + CCClass.escapeForJS(key) + ']=';
            }
            var val = propsToReset[key];
            if (val === '') {
                val = '""';
            }
            func += (statement + val + ';\n');
        }
        return Function('o', func);
    }
    else {
        return function (o) {
            for (var key in propsToReset) {
                o[key] = propsToReset[key];
            }
        };
    }
}

那么_onPreDestroy又做了什么呢?主要是将各种事件、定时器进行注销,对子节点、组件等进行删除,详情可以看下面这段代码。

// Node的_onPreDestroy
_onPreDestroy () {
  // 调用_onPreDestroyBase方法,实际是调用BaseNode.prototype._onPreDestroy,这个方法下面介绍
  var destroyByParent = this._onPreDestroyBase();

  // 注销Actions
  if (ActionManagerExist) {
      cc.director.getActionManager().removeAllActionsFromTarget(this);
  }

  // 移除_currentHovered
  if (_currentHovered === this) {
      _currentHovered = null;
  }

  this._bubblingListeners && this._bubblingListeners.clear();
  this._capturingListeners && this._capturingListeners.clear();

  // 移除所有触摸和鼠标事件监听
  if (this._touchListener || this._mouseListener) {
      eventManager.removeListeners(this);
      if (this._touchListener) {
          this._touchListener.owner = null;
          this._touchListener.mask = null;
          this._touchListener = null;
      }
      if (this._mouseListener) {
          this._mouseListener.owner = null;
          this._mouseListener.mask = null;
          this._mouseListener = null;
      }
  }

  if (CC_JSB && CC_NATIVERENDERER) {
      this._proxy.destroy();
      this._proxy = null;
  }

  // 回收到对象池中
  this._backDataIntoPool();

  if (this._reorderChildDirty) {
      cc.director.__fastOff(cc.Director.EVENT_AFTER_UPDATE, this.sortAllChildren, this);
  }

  if (!destroyByParent) {
      if (CC_EDITOR) {
          // 确保编辑模式下的,节点的被删除后可以通过ctrl+z撤销(重新添加到原来的父节点)
          this._parent = null;
      }
  }
},

// BaseNode的_onPreDestroy
_onPreDestroy () {
  var i, len;

  // 加上Destroying标记
  this._objFlags |= Destroying;
  var parent = this._parent;

  // 根据检测父节点的标记判断是不是由父节点的destroy发起的释放
  var destroyByParent = parent && (parent._objFlags & Destroying);
  if (!destroyByParent && (CC_EDITOR || CC_TEST)) {
      // 从编辑器中移除
      this._registerIfAttached(false);
  }

  // 把所有子节点进行释放,它们的_onPreDestroy也会被执行
  var children = this._children;
  for (i = 0, len = children.length; i < len; ++i) {
      children[i]._destroyImmediate();
  }

  // 把所有的组件进行释放,它们的_onPreDestroy也会被执行
  for (i = 0, len = this._components.length; i < len; ++i) {
      var component = this._components[i];
      component._destroyImmediate();
  }

  // 注销事件监听,比如otherNode.on(type, callback, thisNode) 注册了事件
  // thisNode被释放时,需要注销otherNode身上的监听,避免事件回调到已销毁的对象上
  var eventTargets = this.__eventTargets;
  for (i = 0, len = eventTargets.length; i < len; ++i) {
      var target = eventTargets[i];
      target && target.targetOff(this);
  }
  eventTargets.length = 0;

  // 如果自己是常驻节点,则从常驻节点列表中移除
  if (this._persistNode) {
      cc.game.removePersistRootNode(this);
  }

  // 如果是自己释放的自己,而不是从父节点释放的,要通知父节点,把这个失效的子节点移除掉
  if (!destroyByParent) {
      if (parent) {
          var childIndex = parent._children.indexOf(this);
          parent._children.splice(childIndex, 1);
          parent.emit && parent.emit('child-removed', this);
      }
  }

  return destroyByParent;
},

// Component的_onPreDestroy
_onPreDestroy () {
  // 移除ActionManagerExist和schedule
  if (ActionManagerExist) {
      cc.director.getActionManager().removeAllActionsFromTarget(this);
  }
  this.unscheduleAllCallbacks();

  // 移除所有的监听
  var eventTargets = this.__eventTargets;
  for (var i = eventTargets.length - 1; i >= 0; --i) {
      var target = eventTargets[i];
      target && target.targetOff(this);
  }
  eventTargets.length = 0;

  // 编辑器模式下停止监控
  if (CC_EDITOR && !CC_TEST) {
      _Scene.AssetsWatcher.stop(this);
  }

  // destroyComp的实现为调用组件的onDestroy回调,各个组件会在回调中销毁自身的资源
  // 比如RigidBody3D组件会调用body的destroy方法,而Animation组件会调用stop方法
  cc.director._nodeActivator.destroyComp(this);

  // 将组件从节点身上移除
  this.node._removeComponent(this);
},    

3.5.4 资源释放的问题

最后我们来聊一聊资源释放的问题与定位,在加入引用计数后,最常见的问题还是没有正确增减引用计数导致的内存泄露(循环引用、少调用了decRef或多调用了addRef),以及正在使用的资源被释放的问题(和内存泄露相反,资源被提前释放了)。

从目前的代码来看,如果正确使用了引用计数,新的资源底层是可以避免内存泄露等问题的

这种问题怎么解决呢?首先是定位出哪些资源出了问题,如果是被提前释放,我们可以直接定位到这个资源,如果是内存泄露,当我们发现问题时程序往往已经占用了大量的内存,这种情况下可以切换到一个空场景,并清理资源,把资源清理完后,可以检查assets中残留的资源是否有未被释放的资源。

要了解资源为什么会泄露,可以通过跟踪addRef和decRef的调用得到,下面提供了一个示例方法,用于跟踪某资源的addRef和decRef调用,然后调用资源的dump方法打印出所有调用的堆栈:

public static traceObject(obj : cc.Asset) {
  let addRefFunc = obj.addRef;
  let decRefFunc = obj.decRef;
  let traceMap = new Map();

  obj.addRef = function() : cc.Asset {
      let stack = ResUtil.getCallStack(1);
      let cnt = traceMap.has(stack) ? traceMap.get(stack) + 1 : 1;
      traceMap.set(stack, cnt);
      return addRefFunc.apply(obj, arguments);
  }

  obj.decRef = function() : cc.Asset {
      let stack = ResUtil.getCallStack(1);
      let cnt = traceMap.has(stack) ? traceMap.get(stack) + 1 : 1;
      traceMap.set(stack, cnt);
      return decRefFunc.apply(obj, arguments);
  }

  obj['dump'] = function() {
      console.log(traceMap);
  }
}

以上就是剖析CocosCreator新资源管理系统的详细内容,更多关于CococCreator的资料,请关注我们其他相关文章!

(0)

相关推荐

  • 详解CocosCreator中几种计时器的使用方法

    一.setTimeOut 3秒后打印abc.只执行一次. setTimeout(()=>{console.log("abc"); }, 3000); 删除计时器,3秒后不会输出abc. let timeIndex; timeIndex = setTimeout(()=>{console.log("abc"); }, 3000); clearTimeout(timeIndex); setTimeout这样写,test函数中输出的this是Window对象

  • 如何在CocosCreator中使用http和WebSocket

    CocosCreator版本2.3.4 一.HttpGET Get方式,客户端请求本机地址3000端口,并携带参数url和name,服务端收到后返回name参数. cocos客户端: //访问地址 let url = "http://127.0.0.1:3000/?url=123&name=321"; //新建Http let xhr = new XMLHttpRequest(); //接收数据 xhr.onreadystatechange = function () { if

  • 如何在CocosCreator中做一个List

    CocosCreator版本:2.3.4 cocos没有List组件,所以要自己写.从cocos的example项目中找到assets/case/02_ui/05_listView的demo来改造. 自写一个虚拟列表,有垂直布局,水平布局,网格布局和Padding的List Demo地址:https://files-cdn.cnblogs.com/files/gamedaybyday/cocos2.3.4_ListViewDemo_Grid.7z cocos原来的LayOut做列表,有100个数

  • 解读CocosCreator源码之引擎启动与主循环

    前言 预备 不知道你有没有想过,假如把游戏世界比作一辆汽车,那么这辆"汽车"是如何启动,又是如何持续运转的呢? 如题,本文的内容主要为 Cocos Creator 引擎的启动流程和主循环. 而在主循环的内容中还会涉及到:组件的生命周期和计时器.缓动系统.动画系统和物理系统等... 本文会在宏观上为大家解读主循环与各个模块之间的关系,对于各个模块也会简单介绍,但不会深入到模块的具体实现. 因为如果把每个模块都"摸"一遍,那这篇文章怕是写不完了. Go! 希望大家看完这

  • CocosCreator怎样使用cc.follow进行镜头跟随

    Cocos Creator版本:2.3.4 Demo下载:https://files-cdn.cnblogs.com/files/gamedaybyday/cocos2.3.4_ccfollow.7z 说先来使用下cocoscreator自带的跟随代码,cc.follow. CC.Follow使用 一.设置地图适配容器leftNode 假设这是个横屏跑酷游戏.地图大小1500x1500,那么leftNode高宽为1500x1500,并且widget居左下对齐. 二.设置地图容器map 地图容器也

  • 如何在CocosCreator中使用JSZip压缩

    CocosCreator版本:2.4.2 jszip的实际项目应用 游戏中有大量配置的情况下,文件会变得非常大,所以有些游戏会采用zip包压缩解压 例如如下游戏,将游戏配置config.json压缩成zip包,加载后进行解压使用 拿到他的配置压缩包 将.bin改成.zip,进行解压,得到游戏的所有json配置文件 未压缩之前6M+ 压缩后文件 500KB+,压缩后小了10倍左右. 在cocos中使用jszip 首先,在github上下载jszip库  https://github.com/Stu

  • CocosCreator通用框架设计之资源管理

    如果你想使用Cocos Creator制作一些规模稍大的游戏,那么资源管理是必须解决的问题,随着游戏的进行,你可能会发现游戏的内存占用只升不降,哪怕你当前只用到了极少的资源,并且有使用cc.loader.release来释放之前加载的资源,但之前使用过的大部分资源都会留在内存中!为什么会这样呢? cocos creator 资源管理存在的问题 资源管理主要解决3个问题,资源加载,资源查找(使用),资源释放.这里要讨论的主要是资源释放的问题,这个问题看上去非常简单,在Cocos2d-x中确实也很简

  • 怎样在CocosCreator中使用物理引擎关节

    CocosCreator版本2.4.2 mousejoint鼠标关节 cocos文档定义: 鼠标关节用于使刚体上的一个点追踪一个指定的世界坐标系下的位置. 鼠标关节可以指定一个最大的里来施加一个柔和的约束. 鼠标关节会自动使用 mouse region 节点来注册鼠标事件,并且在触摸移动事件中移动选中的刚体. 注意:一般鼠标关节只在测试环境中使用 说明: 任意cc.Node上添加鼠标关节,设置mouseRegion为Canvas,弹力和阻尼都为1 Mouse Region:鼠标注册节点 Freq

  • Unity3D实现摄像机镜头移动并限制角度

    本文实例为大家分享了Unity3D实现摄像机镜头移动并限制角度的具体代码,供大家参考,具体内容如下 摄像机镜头跟随鼠标移动,并限制上下左右的移动角度 public class ViewFromCream : MonoBehaviour { public int speed=5; public Vector3 vect; private float xcream; private float ycream; public void Update() { CreamView(); } private

  • CocosCreator入门教程之用TS制作第一个游戏

    前提 无论学什么技术知识,官方文档都应该是你第一个教程,所以请先至少阅读新手上路这一节 http://docs.cocos.com/creator/manual/zh/getting-started/ 再来看这篇文章. 这里假设你已经安装成功了 Cocos Creator. TypeScript VS JavaScript 这里当然只会讲优点: 1. ts 是 js 的超集,所有 js 的语法 ts 都支持. 2. ts 支持接近完美的代码提示,js 代码提示接近于没有. 3. ts 有类型定义

  • CocosCreator学习之模块化脚本

    Cocos Creator模块化脚本 Cocos Creator 允许你将代码拆分成多个脚本文件,并且让它们相互调用.这个步骤简称为 模块化. 模块化使你可以在 Cocos Creator 中引用其它脚本文件: 访问其它文件导出的参数 调用其它文件导出的方法 使用其它文件导出的类型 使用或继承其它 Component Cocos Creator 中的 JavaScript 使用和 Node.js 几乎相同的 CommonJS 标准来实现模块化,简单来说: 每一个单独的脚本文件就构成一个模块 每个

随机推荐