Cocos Creator开发踩坑记录:Shader特效、UI组件与性能优化

Cocos Creator开发踩坑记录:Shader特效、UI组件与性能优化

这篇文章整理我在2021年使用Cocos Creator开发游戏时遇到的一些技术问题和解决方案,包括Shader编写、UI组件开发、性能优化等方面。

Shader特效开发

被击闪红效果

使用自定义Material实现角色被攻击时的闪红效果。

Effect文件:attacked.effect

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
CCEffect %{
techniques:
- passes:
- vert: vs
frag: fs
blendState:
targets:
- blend: true
rasterizerState:
cullMode: none
properties:
texture: { value: white }
alphaThreshold: { value: 0.5 }
addColor: { value: [0.8, 0.8, 0.8, 0.8] }
}%

CCProgram vs %{
precision highp float;
#include <cc-global>
#include <cc-local>

in vec3 a_position;
in vec4 a_color;
out vec4 v_color;

#if USE_TEXTURE
in vec2 a_uv0;
out vec2 v_uv0;
#endif

void main () {
vec4 pos = vec4(a_position, 1);

#if CC_USE_MODEL
pos = cc_matViewProj * cc_matWorld * pos;
#else
pos = cc_matViewProj * pos;
#endif

#if USE_TEXTURE
v_uv0 = a_uv0;
#endif

v_color = a_color;
gl_Position = pos;
}
}%

CCProgram fs %{
precision highp float;
#include <alpha-test>

in vec4 v_color;

#if USE_TEXTURE
in vec2 v_uv0;
uniform sampler2D texture;
#endif

uniform LAMYOUM_COM {
vec4 addColor;
}

void main () {
vec4 o = vec4(1, 1, 1, 1);

#if USE_TEXTURE
o *= texture(texture, v_uv0);
#if CC_USE_ALPHA_ATLAS_TEXTURE
o.a *= texture2D(texture, v_uv0 + vec2(0, 0.5)).r;
#endif
#endif

o *= v_color;
ALPHA_TEST(o);

// 添加闪红颜色
o.rgb = o.rgb + addColor.rgb;
gl_FragColor = o;
}
}%

Material配置:

  • USE_TEXTURE:选择true
  • Effect:选择attacked

TypeScript调用:

1
2
3
4
5
6
private attackOne(render: cc.RenderComponent) {
render.setMaterial(0, this.material_attacked); // 被击素材
this.scheduleOnce(() => {
render.setMaterial(0, this.material_normal); // 正常素材
}, 0.1)
}

Shader语法注意事项

Cocos Creator 2.3+对Shader语法要求更严格:

错误: missing semicolon after UBO 'xxxx' declaration

原因: UBO(Uniform Buffer Object)后必须加分号

1
2
3
4
5
6
7
8
9
10
11
// 错误
uniform XXXXX {
float textureWidth;
float speed;
} // 缺少分号

// 正确
uniform XXXXX {
float textureWidth;
float speed;
};

图片变灰效果

使用引擎内置的灰度材质:

1
2
3
4
5
6
7
8
9
10
let grey = true;
let s = node.getComponentsInChildren(cc.Sprite);
for (let i = 0; i < s.length; i++) {
let m = cc.Material.createWithBuiltin(
grey ? cc.Material.BUILTIN_NAME.GRAY_SPRITE : cc.Material.BUILTIN_NAME.SPRITE,
0
);
s[i].setMaterial(0, m);
if (!grey) m.define('USE_TEXTURE', true);
}

字体/图片渐变效果

ColorAssembler2D.ts组件:

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
const { ccclass, property, executeInEditMode, requireComponent, menu } = cc._decorator;

@ccclass
@executeInEditMode
@requireComponent(cc.RenderComponent)
export default class ColorAssembler2D extends cc.Component {

@property
private _colors: cc.Color[] = [];

@property({ type: [cc.Color] })
public get colors() {
return this._colors;
}

public set colors(colors) {
this._colors = colors;
this._updateColors();
}

onEnable() {
cc.director.once(cc.Director.EVENT_AFTER_DRAW, this._updateColors, this);
}

onDisable() {
cc.director.off(cc.Director.EVENT_AFTER_DRAW, this._updateColors, this);
this.node['_renderFlag'] |= cc['RenderFlow'].FLAG_COLOR;
}

private _updateColors() {
const cmp = this.getComponent(cc.RenderComponent);
if (!cmp) return;
const _assembler = cmp['_assembler'];
if (!(_assembler instanceof cc['Assembler2D'])) return;
const uintVerts = _assembler._renderData.uintVDatas[0];
if (!uintVerts) return;
const color = this.node.color;
const floatsPerVert = _assembler.floatsPerVert;
const colorOffset = _assembler.colorOffset;
let count = 0;
for (let i = colorOffset, l = uintVerts.length; i < l; i += floatsPerVert) {
uintVerts[i] = (this.colors[count++] || color)['_val'];
}
}
}

使用方式:

  • 拖拽到图片节点或字体Label节点
  • 设置4个颜色进行渐变(左上、右上、左下、右下)

虚拟摇杆实现

摇杆类型定义

JoystickCommon.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default {
JoystickType: cc.Enum({
FIXED: 0, // 固定位置
FOLLOW: 1, // 跟随触摸
}),

DirectionType: cc.Enum({
FOUR: 4, // 四方向
EIGHT: 8, // 八方向
ALL: 0, // 全方向
}),

SpeedType: cc.Enum({
STOP: 0, // 停止
NORMAL: 1, // 正常速度
FAST: 2 // 快速
})
};

摇杆控制器

Joystick.js:

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import JoystickCommon from 'JoystickCommon'

cc.Class({
extends: cc.Component,

properties: {
camera: {
default: null,
type: cc.Camera,
tooltip: '摄像机',
},
dot: {
default: null,
type: cc.Node,
displayName: 'Dot',
tooltip: '摇杆操纵点',
},
ring: {
default: null,
type: cc.Node,
displayName: 'Ring',
tooltip: '摇杆背景节点',
},
player: {
default: null,
type: cc.Node,
displayName: 'Player',
tooltip: '操控角色',
},
joystickType: {
default: JoystickCommon.JoystickType.FIXED,
type: JoystickCommon.JoystickType,
displayName: 'Touch Type',
tooltip: '触摸类型',
},
directionType: {
default: JoystickCommon.DirectionType.ALL,
type: JoystickCommon.DirectionType,
displayName: 'Direction Type',
tooltip: '方向类型',
},
_stickPos: {
default: null,
type: cc.Node,
tooltip: '摇杆所在位置',
},
_touchLocation: {
default: null,
type: cc.Node,
tooltip: '触摸位置',
},
},

onLoad() {
this._radius = this.ring.width / 2;
this._initTouchEvent();
// 跟随模式下隐藏摇杆
if (this.joystickType == JoystickCommon.JoystickType.FOLLOW) {
this.node.opacity = 0;
}
},

_initTouchEvent() {
const self = this;
self.node.on(cc.Node.EventType.TOUCH_START, self._touchStartEvent, self);
self.node.on(cc.Node.EventType.TOUCH_MOVE, self._touchMoveEvent, self);
self.node.on(cc.Node.EventType.TOUCH_END, self._touchEndEvent, self);
self.node.on(cc.Node.EventType.TOUCH_CANCEL, self._touchEndEvent, self);
},

_touchStartEvent(event) {
let camera_pos = event.getLocation();
let w_pos = this.camera.getScreenToWorldPoint(camera_pos);
const touchPos = this.node.convertToNodeSpaceAR(w_pos);

if (this.joystickType === JoystickCommon.JoystickType.FIXED) {
this._stickPos = this.ring.getPosition();
const distance = touchPos.sub(this.ring.getPosition()).mag();

// 手指在圆圈内触摸
if (this._radius > distance) {
this.dot.setPosition(touchPos);
}
} else if (this.joystickType === JoystickCommon.JoystickType.FOLLOW) {
this._stickPos = touchPos;
this.node.opacity = 255;
this._touchLocation = event.getLocation();
this.ring.setPosition(touchPos);
this.dot.setPosition(touchPos);
}
},

_touchMoveEvent(event) {
if (this.joystickType === JoystickCommon.JoystickType.FOLLOW) {
if (this._touchLocation === event.getLocation()) {
return false;
}
}

let camera_pos = event.getLocation();
let w_pos = this.camera.getScreenToWorldPoint(camera_pos);
const touchPos = this.ring.convertToNodeSpaceAR(w_pos);
const distance = touchPos.mag();

const posX = this._stickPos.x + touchPos.x;
const posY = this._stickPos.y + touchPos.y;
const p = cc.v2(posX, posY).sub(this.ring.getPosition()).normalize();

if (this._radius > distance) {
this.dot.setPosition(cc.v2(posX, posY));
this.player._speedType = JoystickCommon.SpeedType.NORMAL;
} else {
// 保持在圈内
const x = this._stickPos.x + p.x * this._radius;
const y = this._stickPos.y + p.y * this._radius;
this.dot.setPosition(cc.v2(x, y));
this.player._speedType = JoystickCommon.SpeedType.FAST;
}

this.player = this.player.getComponent('Player');
this.player.moveDir = p;
},

_touchEndEvent() {
this.dot.setPosition(this.ring.getPosition());
if (this.joystickType == JoystickCommon.JoystickType.FOLLOW) {
this.node.opacity = 0;
}
this.player._speedType = JoystickCommon.SpeedType.STOP;
},
});

角色控制器

Player.js:

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
import { SpeedType } from 'JoystickCommon'

cc.Class({
extends: cc.Component,

properties: {
moveDir: {
default: cc.v2(0, 1),
displayName: 'Move Dir',
tooltip: '移动方向',
},
_speedType: {
default: SpeedType.STOP,
displayName: 'Speed Type',
type: SpeedType,
},
stopSpeed: {
default: 0,
type: cc.Integer,
tooltip: '停止时速度',
},
normalSpeed: {
default: 100,
type: cc.Integer,
tooltip: '正常速度',
},
fastSpeed: {
default: 200,
type: cc.Integer,
tooltip: '最快速度',
},
cameraNode: {
default: null,
type: cc.Node,
tooltip: '摄像机节点',
},
mapNode: {
default: null,
type: cc.Node,
tooltip: '地图背景',
},
},

onLoad() {
this.cameraMaxX = this.mapNode.width / 2 - cc.winSize.width / 2;
this.cameraMaxY = this.mapNode.height / 2 - cc.winSize.height / 2;
this.nodeMaxX = this.mapNode.width / 2;
this.nodeMaxY = this.mapNode.height / 2;
},

move() {
let newPos = this.node.position.add(
this.moveDir.mul(this._moveSpeed / 60)
);

this.node.setPosition(newPos);

// 角色边界限制
this.node.x = cc.misc.clampf(this.node.x, -this.nodeMaxX, this.nodeMaxX);
this.node.y = cc.misc.clampf(this.node.y, -this.nodeMaxY, this.nodeMaxY);

// 摄像机跟随(带边界限制)
this.cameraNode.setPosition(newPos);
this.cameraNode.x = cc.misc.clampf(
this.cameraNode.x, -this.cameraMaxX, this.cameraMaxX
);
this.cameraNode.y = cc.misc.clampf(
this.cameraNode.y, -this.cameraMaxY, this.cameraMaxY
);
},

updateFrame(dt) {
switch (this._speedType) {
case SpeedType.STOP:
this._moveSpeed = this.stopSpeed;
break;
case SpeedType.NORMAL:
this._moveSpeed = this.normalSpeed;
break;
case SpeedType.FAST:
this._moveSpeed = this.fastSpeed;
break;
}
this.move();
},
});

嵌套滚动列表

实现纵向ScrollView中嵌套横向ScrollView的功能。

外层纵向列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
setScrollTouchBegan(event) {
let scroll = this.scrollView.node.getComponent(cc.ScrollView);
scroll._onTouchBegan(event);
},

setScrollTouchMoved(event) {
let scroll = this.scrollView.node.getComponent(cc.ScrollView);
scroll._onTouchMoved(event);
},

setScrollTouchEnded(event) {
let scroll = this.scrollView.node.getComponent(cc.ScrollView);
scroll._onTouchEnded(event);
},

setScrollTouchCancelled(event) {
let scroll = this.scrollView.node.getComponent(cc.ScrollView);
scroll._onTouchCancelled(event);
},

内层横向列表

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
onLoad() {
this.scroll.node.on(cc.Node.EventType.TOUCH_START, this.onTStart, this);
this.scroll.node.on(cc.Node.EventType.TOUCH_MOVE, this.onTMove, this);
this.scroll.node.on(cc.Node.EventType.TOUCH_END, this.onTEnd, this);
this.scroll.node.on(cc.Node.EventType.TOUCH_CANCEL, this.onTCancelled, this);
},

onDestroy() {
this.scroll.node.off(cc.Node.EventType.TOUCH_START, this.onTStart, this);
this.scroll.node.off(cc.Node.EventType.TOUCH_MOVE, this.onTMove, this);
this.scroll.node.off(cc.Node.EventType.TOUCH_END, this.onTEnd, this);
this.scroll.node.off(cc.Node.EventType.TOUCH_CANCEL, this.onTCancelled, this);
},

onTStart(event) {
this.parentScrollView.setScrollTouchBegan(event);
},

onTMove(event) {
this.parentScrollView.setScrollTouchMoved(event);
},

onTEnd(event) {
this.parentScrollView.setScrollTouchEnded(event);
},

onTCancelled(event) {
this.parentScrollView.setScrollTouchCancelled(event);
},

重要设置

在预制体界面中,去掉勾选ScrollView里的Cancel Inner Events,否则会出现TouchEnd事件丢失,导致滚动界面卡在中间的问题。

龙骨动画动态创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cc.loader.loadResAll('dgb', (err, assets) => {
let node = new cc.Node();
self.node.addChild(node);
let armatureDisplay = node.addComponent(dragonBones.ArmatureDisplay);

for (let i = 0; i < assets.length; i++) {
if (assets[i] instanceof dragonBones.DragonBonesAsset) {
armatureDisplay.dragonAsset = assets[i];
}

if (assets[i] instanceof dragonBones.DragonBonesAtlasAsset) {
armatureDisplay.dragonAtlasAsset = assets[i];
}
}
armatureDisplay.armatureName = 'role';
armatureDisplay.playAnimation('wait');
});

性能优化技巧

统计图片内存占用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 遍历cc.loader._cache
let textures = cc.loader._cache;
let totalMemory = 0;

for (let key in textures) {
let asset = textures[key];
if (asset instanceof cc.Texture2D) {
// 内存占用 = 宽 * 高 * 像素字节数
// png: 4字节 (rgba)
// jpg: 3字节 (rgb)
let pixelBytes = key.endsWith('.png') ? 4 : 3;
let memory = asset.width * asset.height * pixelBytes;
totalMemory += memory;
}
}

console.log('总内存占用: ' + (totalMemory / 1024 / 1024).toFixed(2) + ' MB');

代码执行时间检测

1
2
3
4
5
console.time("saveData time");
this.saveData();
console.timeEnd("saveData time");

// 输出:saveData time: 1.19677734375ms

随机数种子

实现可重复的随机序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
Math.seed = 5;
Math.seededRandom = function(max, min) {
max = max || 1;
min = min || 0;
Math.seed = (Math.seed * 9301 + 49297) % 233280;
var rnd = Math.seed / 233280.0;
return min + rnd * (max - min);
};

// 使用相同种子生成相同随机数
for (var i = 0; i < 10; i++) {
console.log(Math.seededRandom());
}

减少脚本文件大小

  • 启用代码混淆压缩
  • 删除未使用的代码和资源
  • 使用纹理图集合并小图
  • 启用引擎的裁剪功能

Android平台常见问题

M1芯片Mac兼容性问题

问题: Creator打开黑屏

解决方案: 升级到Cocos Creator 2.4.5以上版本

命令行打包日志查看

日志文件位置:

1
C:\Users\Administrator\.CocosCreator\logs
  • CocosCreator.log: Creator构建日志
  • native.log: 编译详细日志

Android Studio调试快捷键

快捷键 功能
Ctrl+P 打开文件
Ctrl+Shift+F 搜索字符串
Ctrl+G 跳转到行
Ctrl+Shift+O 查找方法
Ctrl+D 查找下一个匹配项

字体处理工具

TTF字体裁剪

使用font-spider工具裁剪字体文件,只保留项目使用的字符:

1
npm i -g font-spider

裁剪脚本示例:

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
let fs = require('fs');
let cmd = require('node-cmd');

// 1. 备份原始字体
fs.copyFileSync('./font/XXX.ttf.bak', './font/XXX.ttf');

// 2. 读取项目中的字符
let charMap = {};
let invalid = /[°"§%()\[\]{}=\\?´`'#<>|,;.:+_-]+/g;

function extractChars(path) {
if (fs.existsSync(path)) {
fs.readdirSync(path).forEach(function (file) {
let curPath = path + "/" + file;
if (fs.statSync(curPath).isDirectory()) {
extractChars(curPath);
} else {
let content = fs.readFileSync(curPath, 'UTF-8');
content = content.replace(invalid, "");
for (let char of content) {
charMap[char] = true;
}
}
});
}
}

// 3. 生成HTML用于font-spider
let charString = Object.keys(charMap).join('');
let html = `
<!DOCTYPE html>
<style>
@font-face {
font-family: XXX;
src: url("./font/XXX.ttf");
}
</style>
<div style="font-family: XXX;">${charString}</div>
`;
fs.writeFileSync('./index.html', html);

// 4. 执行裁剪
cmd.runSync('font-spider ./index.html');

// 5. 替换项目字体
fs.copyFileSync('./font/XXX.ttf', '../../assets/resources/font/XXX.ttf');

开发资源推荐

音乐音效网站

网站 类型
https://opengameart.org/ 游戏素材
https://www.aigei.com/sound/class/games_category/ 音效库
https://freesound.org/ 免费音效
https://www.bensound.com/ 背景音乐
https://musmus.main.jp/bgm.html 日系BGM

在线压缩工具

工具 用途
https://www.mp3smaller.com/zh/ MP3压缩
https://www.imagesmaller.com 图片压缩

以上是我2021年Cocos Creator开发中遇到的一些技术问题和解决方案,希望对同样使用Creator的开发者有所帮助。