【本文参与征文活动】
有段时间没更新《解读 Cocos Creator 引擎》系列了,今天也不是来更新那个“读了没用”系列,而是分享一下这个有点用的小优化。
本篇内容适用于微信小游戏平台
引擎版本:Cocos Creator 2.4
测试数据由微信云测试平台提供
0.小游戏平台与启动加载/首屏渲染
如果不了解首屏渲染是什么,以下是官方文档和一篇优化帖子:
有一个官方文档没提到的细节是这个耗时统计也会包括开放域项目的耗时,所以如果你的微信小游戏有子域,也记得要一起优化。
本篇所提到的优化首屏渲染的方法,能有效降低微信白屏加载和首屏渲染的耗时(也就是能避免低端机首屏黑屏的现象),但对游戏的实际启动耗时帮助甚微。
接下来我会介绍两种方法:
1.通常的避免修改引擎的方法:减少首场景内容
这是最基本的方法,在 Cocos Creator v2.4 中,引擎支持直接把首场景依赖的资源直接分包,所以也不用额外挑出首场景资源留在包内,只需要勾上“初始场景分包”即可,优化更加方便了。
但不管如何,在此之前我们都要尽量做到首场景的资源越少越好:
最理想的是首场景中只引用一张首屏图片,而在首场景上只绑定一个脚本组件 Main.ts
,下面这段代码是我希望渲染的第一帧不会有任何逻辑,所以把实际加载代码和加载界面的显示都延迟执行,尽量减少逻辑保证首屏快速渲染,但是否有效未经测试。
// Main.ts
onLoad() {
this.scheduleOnce(() => {
this.showLoadScene();
// 其它加载代码
// ...
});
}
做好这些优化后你可能会在微信云测试中得到和这个项目差不多的成绩:
以上云测的耗时是基于包体大小 3.5MB,子域项目 1.0MB 测得,代码包加载涉及到网络,所以可能每次会有些许波动,如果包体比这个小或者没有子域,一般也会在 4500ms - 9000ms 之间。
如果不想修改引擎的话,这应该就是能做的所有事情了,可以知道收效甚微,因为性能的瓶颈是在于包体大小和引擎代码注入与渲染的耗时,必须解决这两个瓶颈才能对启动性能有较大提升。(当然还有一个方法是手动构造 cc.Scene ,官方启动优化贴子里也提到了该方法,但其实效果也不大,就不细讲了)
实际上如果不是受生活所迫或出现了首屏会闪黑屏的情况,我推荐做好上面的就可以了,尽量不对引擎进行修改,把时间花在优化实际游戏加载耗时上。
2.理论极限方法:自行渲染首屏并在分包加载引擎
关于自行渲染首屏的方法,微信官方论坛中也有教程,但我能找到的现存教程,包括微信官方教程我都无法直接使用成功,接下来我会直接给出可照抄的步骤,维持本文的实用性,然后在后面再解释其中的原理,虽然枯燥但建议去理解,授人以鱼,不如授之以渔。
以下会分为两个部分:
一是针对代码注入和首屏渲染的优化,将会在引擎加载之前自行渲染出一张首屏图片,并且修改引擎的 Mini-game-adapters
来兼容引擎的渲染代码。
二是针对代码包的优化,将会把引擎相关的几乎所有内容放在子包中进行加载,只留下必要的首屏代码。
自行渲染首屏图片
1.在项目中创建 build-templates
目录,再创建 wechatgame
目录以准备自定义发布模版(官方介绍)
2.在 wechatgame
目录下新建 first-screen.js
脚本,拷贝以下内容:
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'attribute vec2 a_TexCoord;\n' +
'varying vec2 v_TexCoord;\n' +
'void main() {\n' +
' gl_Position = a_Position;\n' +
' v_TexCoord = a_TexCoord;\n' +
'}\n';
var FSHADER_SOURCE =
'#ifdef GL_ES\n' +
'precision mediump float;\n' +
'#endif\n' +
'uniform sampler2D u_Sampler;\n' +
'varying vec2 v_TexCoord;\n' +
'void main() {\n' +
' gl_FragColor = texture2D(u_Sampler, v_TexCoord);\n' +
'}\n';
function initShaders(gl, vshader, fshader) {
var program = createProgram(gl, vshader, fshader);
if (!program) {
console.log('Failed to create program');
return false;
}
gl.useProgram(program);
gl.program = program;
return true;
}
function createProgram(gl, vshader, fshader) {
// Create shader object
var vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
var fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
if (!vertexShader || !fragmentShader) {
return null;
}
// Create a program object
var program = gl.createProgram();
if (!program) {
return null;
}
// Attach the shader objects
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// Link the program object
gl.linkProgram(program);
// Check the result of linking
var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linked) {
var error = gl.getProgramInfoLog(program);
console.log('Failed to link program: ' + error);
gl.deleteProgram(program);
gl.deleteShader(fragmentShader);
gl.deleteShader(vertexShader);
return null;
}
return program;
}
function loadShader(gl, type, source) {
// Create shader object
var shader = gl.createShader(type);
if (shader == null) {
console.log('unable to create shader');
return null;
}
// Set the shader program
gl.shaderSource(shader, source);
// Compile the shader
gl.compileShader(shader);
// Check the result of compilation
var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!compiled) {
var error = gl.getShaderInfoLog(shader);
console.log('Failed to compile shader: ' + error);
gl.deleteShader(shader);
return null;
}
return shader;
}
function initVertexBuffers(gl, vertices) {
var verticesTexCoords = vertices || new Float32Array([
// Vertex coordinates, texture coordinate
-1, 1, 0.0, 1.0,
-1, -1, 0.0, 0.0,
1, 1, 1.0, 1.0,
1, -1, 1.0, 0.0,
]);
var n = 4; // The number of vertices
// Create the buffer object
var vertexTexCoordBuffer = gl.createBuffer();
if (!vertexTexCoordBuffer) {
console.log('Failed to create the buffer object');
return -1;
}
// Bind the buffer object to target
gl.bindBuffer(gl.ARRAY_BUFFER, vertexTexCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, verticesTexCoords, gl.STATIC_DRAW);
var FSIZE = verticesTexCoords.BYTES_PER_ELEMENT;
//Get the storage location of a_Position, assign and enable buffer
var a_Position = gl.getAttribLocation(gl.program, 'a_Position');
if (a_Position < 0) {
console.log('Failed to get the storage location of a_Position');
return -1;
}
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0);
gl.enableVertexAttribArray(a_Position); // Enable the assignment of the buffer object
// Get the storage location of a_TexCoord
var a_TexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord');
if (a_TexCoord < 0) {
console.log('Failed to get the storage location of a_TexCoord');
return -1;
}
// Assign the buffer object to a_TexCoord variable
gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2);
gl.enableVertexAttribArray(a_TexCoord); // Enable the assignment of the buffer object
return n;
}
function initTextures(gl, n, imgPath) {
var texture = gl.createTexture(); // Create a texture object
if (!texture) {
console.log('Failed to create the texture object');
return false;
}
// Get the storage location of u_Sampler
var u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler');
if (!u_Sampler) {
console.log('Failed to get the storage location of u_Sampler');
return false;
}
var image = wx.createImage(); // Create the image object
if (!image) {
console.log('Failed to create the image object');
return false;
}
// Register the event handler to be called on loading an image
image.onload = function () { loadTexture(gl, n, texture, u_Sampler, image); };
// Tell the browser to load an image
image.src = imgPath;
return true;
}
function loadTexture(gl, n, texture, u_Sampler, image) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // Flip the image's y axis
// Enable texture unit0
gl.activeTexture(gl.TEXTURE0);
// Bind the texture object to the target
gl.bindTexture(gl.TEXTURE_2D, texture);
// Set the texture parameters
// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// Set the texture image
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);
// Set the texture unit 0 to the sampler
gl.uniform1i(u_Sampler, 0);
gl.clear(gl.COLOR_BUFFER_BIT); // Clear <canvas>
gl.drawArrays(gl.TRIANGLE_STRIP, 0, n); // Draw the rectangle
}
function drawImg(imgPath) {
const vertices = new Float32Array([
-1, 1, 0.0, 1.0,
-1, -1, 0.0, 0.0,
1, 1, 1.0, 1.0,
1, -1, 1.0, 0.0,
]);
// Retrieve <canvas> element
// var canvas = document.getElementById('webgl');
// Get the rendering context for WebGL
// var gl = getWebGLContext(canvas);
const gl = wx.__first__canvas.getContext("webgl");
if (!gl) {
console.log('Failed to get the rendering context for WebGL');
return;
}
// Initialize shaders
if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
console.log('Failed to intialize shaders.');
return;
}
// Set the vertex information
var n = initVertexBuffers(gl, vertices);
if (n < 0) {
console.log('Failed to set the vertex information');
return;
}
// Specify the color for clearing <canvas>
gl.clearColor(1.0, 1.0, 1.0, 1.0);
// Set texture
if (!initTextures(gl, n, imgPath)) {
console.log('Failed to intialize the texture.');
return;
}
}
exports.drawImg = drawImg;
该脚本是在网上找的一个简单的 WebGL 渲染图片的代码,我修改了一部分内容,接下来将用该脚本渲染首屏。
3.拷贝首屏图片到同目录下,重命名为 first.jpg
4.再创建一个脚本 game-backup.js
,拷贝以下内容:
"use strict";
// 渲染首屏(开发者工具执行有渲染问题,所以开发者工具上不渲染)
wx.__first__canvas = wx.createCanvas();
const data = wx.getSystemInfoSync();
if (data.platform != "devtools") {
const first_scene = require("./first-screen.js");
first_scene.drawImg("first.jpg");
}
// 加载引擎代码写在这后面
data.platform != "devtools"
的原因是在微信开发者工具上绘制会出现引擎后面绘制的内容都变为白屏的问题,暂时没找到原因,希望能得到解答。
5.在脚本 game-backup.js
的最后一行注释后面加上你当前引擎构建后 game.js
文件中的内容,比如 2.4 版本引擎构建后的内容如下:
// CocosCreator 2.4 game.js
"use strict"; // 这句不用复制
require('adapter-min.js');
__globalAdapter.init();
require('cocos/cocos2d-js-min.js');
__globalAdapter.adaptEngine();
require('./ccRequire');
require('./src/settings'); // Introduce Cocos Service here
require('./main'); // TODO: move to common
// Adjust devicePixelRatio
cc.view._maxPixelRatio = 4;
if (cc.sys.platform !== cc.sys.WECHAT_GAME_SUB) {
// Release Image objects after uploaded gl texture
cc.macro.CLEANUP_IMAGE_CACHE = true;
}
window.boot();
完成以上步骤后,你的目录结构应该和下面的一致
6.打开 Cocos Creator,点击右上角的编辑器进入编辑器安装目录
7.找到 builtin
目录,再依次进入 adapters/platforms/wechat/wrapper/builtin
目录,打开 Canvas.js
文件:
// import HTMLCanvasElement from './HTMLCanvasElement'
import { innerHeight, innerWidth } from './WindowProperties'
let hasModifiedCanvasPrototype = false
let hasInit2DContextConstructor = false
let hasInitWebGLContextConstructor = false
let first = true
export default function Canvas() {
const canvas = first ? wx.__first__canvas ? wx.__first__canvas : wx.createCanvas() : wx.createCanvas()
first = false
canvas.type = 'canvas'
// canvas.__proto__.__proto__.__proto__ = new HTMLCanvasElement()
const _getContext = canvas.getContext
canvas.getBoundingClientRect = () => {
const ret = {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight
}
return ret
}
canvas.style = {
top: '0px',
left: '0px',
width: innerWidth + 'px',
height: innerHeight + 'px',
}
canvas.addEventListener = function (type, listener, options = {}) {
// console.log('canvas.addEventListener', type);
document.addEventListener(type, listener, options);
}
canvas.removeEventListener = function (type, listener) {
// console.log('canvas.removeEventListener', type);
document.removeEventListener(type, listener);
}
canvas.dispatchEvent = function (event = {}) {
console.log('canvas.dispatchEvent' , event.type, event);
// nothing to do
}
Object.defineProperty(canvas, 'clientWidth', {
enumerable: true,
get: function get() {
return innerWidth
}
})
Object.defineProperty(canvas, 'clientHeight', {
enumerable: true,
get: function get() {
return innerHeight
}
})
return canvas
}
以上是我已经修改完毕的内容,如果引擎版本是 2.4 可以尝试直接拷贝,重点是下面这几句代码:
// 修改前
export default function Canvas() {
const canvas = wx.createCanvas()
// 修改后
let first = true
export default function Canvas() {
const canvas = first ? wx.__first__canvas ? wx.__first__canvas : wx.createCanvas() : wx.createCanvas()
first = false
原来引擎是直接调用 wx.createCanvas()
创建 canvas 实例,现在先判断是否是首次调用,是的话并且 wx.__first__canvas
存在则直接使用我们首屏渲染时创建的 canvas 实例,如果不这样做由于后面创建的都是离屏 canvas,引擎渲染的内容都会黑屏。
8.以上步骤完成之后就可以直接构建项目,先暂时不要开启引擎分离,虽然 2.4 与之前的目录结构不一样,但整体还是类似的,构建完成后目录大概会是这样:
至此,构建模版就完成了,在不更换引擎版本的前提下,以后构建就不需要重复以上的步骤,因为使用了自定义构建模版,以后构建后只需要重复第九步的替换工作即可。
9.先试一下能否正常运行,可以的话把 game.js
删除,用 game-backup.js
替换 game.js
这时候由于代码中不在开发者工具上渲染首屏,所以在微信开发者工具上首屏渲染不会生效,你可以把判断代码去除,并注释后面的引擎代码看首屏渲染是否生效,然后打包上传在真机上看一下是否有报错或其他问题。
上图则为做好步骤之后去除开发者工具判断和引擎加载部分的代码后的首屏渲染测试效果。
打包后云测数值的对比可以看到大概提升了20倍左右,在做完首屏渲染优化后,原来项目启动耗时 7174ms 降至到 5000ms 左右,提升约30%。
关于开放域
需要注意的是如果项目还有开放域的话,可能数据没有上图的数据差距这么大,因为开放域还没有优化,也会影响耗时数据。
由于开放域优化的方法和主域是基本一致的,区别是子域由于其实一开始是看不到的,所以直接渲染一个纯色点即可,不需要额外的首屏图片,并且不能用 WebGL 渲染所以换成 2d canvas 来渲染,就不再阐述了。
在文章结尾会放上首屏渲染和引擎子包加载的两个优化后的主域和开放域的 build-templates
目录压缩包,可以自行研究。
(在压缩包内没有涉及到的一个改动是开放域引擎代码的 require 我放在了收到主域的某条消息后再进行,而主域在引擎的首场景加载完后才发送这条消息,以完全避免开放域的启动消耗,但该操作的效果未经测试)
引擎放入子包加载
把首屏渲染优化到极致之后,启动耗时中的大头就是代码包加载和代码注入这两个阶段,在不修改引擎的情况下,建议的优化手段是裁剪引擎模块,只保留首屏代码,其他代码用引擎自带的 Asset Bundle 把其他业务代码放在子包延迟加载,虽然这么做的话可能需要对项目进行大改动,并且引擎代码导致的加载和注入耗时无法优化,但这是从本质上解决问题。
下面会介绍在首屏渲染优化之后的基础上再把引擎放入子包进行加载的操作步骤,但在实际项目中可根据自身情况只做两个优化中的一个,如果把引擎放入子包,那么当前受小游戏平台的限制,引擎分离插件就不能使用了,不过子包第一次加载后即会缓存,不用担心。
这部分优化在做好构建模版后,每次构建都需要把引擎相关文件放入子包目录还是比较麻烦的,如果是开发调试,构建后可以不使用 game-backup.js
替换引擎本身的 game.js
而是像往常一样直接打包上传,这样虽然不会让首屏渲染和子包引擎优化生效,但能节省开发调试的时间。也推荐大家尝试自定义构建流程来自动化这部分工作。
1.依然打开 build-templates/wechatgame
目录,新建脚本文件 engine-loader.js
,拷贝以下内容:
function loadEngine(sub_name) {
if (wx.loadSubpackage) {
_load(sub_name).then((result) => {
if (!result) {
loadEngine(sub_name);
}
});
} else {
require(sub_name + '/game.js');
}
}
function _load(sub_name) {
return new Promise((resolve, reject) => {
const t = new Date().getTime();
const loadTask = wx.loadSubpackage({
name: sub_name,
success: function (res) {
console.log("引擎子包加载完毕", new Date().getTime() - t, "ms");
resolve(true);
},
fail: function (res) {
console.log("引擎子包加载失败", new Date().getTime() - t, "ms");
resolve(false);
}
});
loadTask.onProgressUpdate(res => {
});
});
}
const _loadEngine = loadEngine;
export { _loadEngine as loadEngine };
这部分是加载子包的代码,已经按照官方文档做了旧微信基础库兼容。
2.在 wechatgame
目录下创建 engine
目录,此为引擎子包目录,并在目录内新建脚本文件 game.js
,拷贝以下内容:
console.error("请把引擎相关文件放入子包");
避免忘记把引擎文件放入该目录,所以新建这个默认脚本来提示错误。
3.拷贝引擎构建后的 game.json
文件,往里面的 subpackages
字段声明 engine
子包,如下代码:
{
"deviceOrientation": "portrait",
"openDataContext": "openDataView",
"networkTimeout": {
"request": 5000,
"connectSocket": 5000,
"uploadFile": 5000,
"downloadFile": 5000
},
"subpackages": [
{
"name":"engine",
"root":"engine/"
}
]
}
4.修改 game-backup.js
中的引擎加载代码,使用 engine-loader.js
的函数加载引擎,如下示范代码:
"use strict";
// 渲染首屏(开发者工具执行有渲染问题,所以开发者工具上不渲染)
wx.__first__canvas = wx.createCanvas();
const data = wx.getSystemInfoSync();
if (data.platform != "devtools") {
const first_scene = require("./first-screen.js");
first_scene.drawImg("first.jpg");
}
// 加载引擎
const loader = require("./engine-loader");
loader.loadEngine("engine");
至此,构建模版就完成了,剩下的是每次构建后的文件替换工作:
(以下步骤适用 CocosCreator 2.4 + 版本,其他版本可能不会完全一致)
5.把下面几个引擎的文件夹和文件移动到 engine
目录下,然后重命名 game-backup.js
为 game.js
- adapter.min.js
- ccRequire.js
- cocos
- game.js
- main.js
- src
移动上面几个后还会剩下引擎的 assets
目录,由于读取资源时引擎不会读子包目录内的资源,所以需要拷贝该目录到 engine
目录,也就是主包和子包都有一个 assets
目录,然后删除主包中 assets
目录的 index.js
和 index.js.map
代码相关文件,删除子包中 assets
目录的 config.xxx.json
import
native
的资源相关的文件,也就是主包留下 assets
的资源文件,子包留下 assets
的代码文件。
做完以上步骤则完成了所有工作,在微信开发者工具即可运行看看是否生效,在完成这些工作之后,项目原本 3.5MB 的包体大部分都分摊到了子包中:
项目的开放域目录大概是 860KB,可以预估出如果除去开放域的占用,项目主包大小大概只有 150 KB 左右,做完以上两个优化之后的云测数据也非常可观:
从 7174ms 降至 3467ms,并且每一个启动阶段的耗时都没超过 1000ms,数据提升了 50%。
但令人奇怪的是高端机的耗时是最高的,并且首屏渲染得到的统计图也与预想的不一致,但由于耗时的确大幅下降,就没有去深究,希望懂的朋友能给予解答。
3.原理解析
由于我是在 Cocos Creator 2.4 上做的优化,旧版引擎构建后的目录结构可能不太一致,知道其中做每个步骤的原因就尤为重要:
首屏渲染优化的原理是在 require 引擎代码前先自行渲染出首屏,核心看似是 first-screen.js
,其实不然,WebGL 渲染图片的源码一搜一大把,关键是在于 wx.__first_canvas
这个变量来兼容 CocosCreator 的渲染。
在把首屏渲染出来之后,由于调用了 wx.createCanvas
函数,引擎之后调用创建的不是上屏 Canvas 而是离屏 Canvas,所以需要保存第一次渲染首屏创建出来的 Canvas 实例,并修改引擎在第一次创建时直接读取 wx.__first__canvas
的实例。
这是为什么要做第 6 和第 7 步,否则其实可以不用修改引擎的适配代码。
引擎在子包加载的原理是把引擎相关内容放在子包中,再实现加载子包的代码即可,只有一个引擎读取资源的目录需要注意,本篇使用的是最笨的把资源留在主包的办法,可以尝试修改引擎去解决。
本篇还留下了几个问题,希望一起讨论得到答案:
1.在开发者工具上首屏渲染优化是什么导致出现了引擎渲染时白屏的问题
2.完成两种优化后,耗时虽然明显降低,但统计的三个阶段的分别耗时不符合预期,高端机型比低中端机型耗时还要高
感谢阅读,本篇内容就到此结束了。
首屏渲染模版.zip (28.9 KB)
更新:首屏渲染修复思路
上面文章内提到的 IOS 不生效或统计数据异常的问题,感谢 @我不是真的 提出的 requestAnimationFrame
未调用问题,我改动并测试后的确已解决。
由于文中只是在代码加载后渲染了一次,可能因为没有每帧都进行渲染,这在微信平台上是有问题的,所以只需要把渲染代码按照下面的思路改动:
1.在引擎加载完毕前一直调用 requestAnimationFrame
回调首屏渲染函数 drawImg
直到引擎加载完再停止,这样就会每帧都渲染首屏,一定记得引擎加载完毕后需要停止调用。
2.建议改动渲染文件 first-screen.js
和 game-backup.js
的逻辑,因为每次 drawImg
都会重新加载纹理或其他可复用的数据,很浪费。
下图是修复之后的统计数据,下载代码包网络波动情有可原,其他数据都正常了。