给博客添加Live2D看板娘

突然心血来潮想给自己的 Hexo 博客也整一个看板娘,刚刚好手上又有一个比较喜欢的角色模型,于是就开始动手,很快就找到了 hexo-helper-live2d[1]这个插件,配置也非常简单,三下五除二就搞定了,效果还不错,甚至觉得有点无聊,因为没有一点儿挑战性😃

正当我准备收手的时候,我突然发现有点儿不对劲😕:模型除了会根据鼠标位置转头之外就没了,非常单调,这让准完美主义(指强迫症)的我非常难受。由于我手上的模型已经有打包好的多种动作表情,因此我打算先去看看有没有什么办法可以直接调用模型的这些动作。Cubism Editor 就是制作 Live2D 模型的工具,我想大概也可以拿来修改模型从而加载各种动作吧,于是就下载了最新版(4.0)。然而杯具的是无论是 Cubism Editor 还是附带安装的 Viewer 都没法打开我的模型。

百度了相关文章,不出预料果然就是软件版本的问题,现在已有的模型,基本都是用 Cubism Editor 2 开发的,新版本并没有向下兼容,因此是没法用官方的最新软件来编辑的。好在同时发现一个 Live2D Viewer[2] 的软件可以直接打开旧版本模型,只需要安装 Adobe Air 就可以使用。

通过查找软件使用文档(官方是日语,有中文版的镜像站但是完成度不高),基本摸清了这个软件的使用方法和 Live2D 模型的组成结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
textures/             //模型贴图
├──texture_xx.png
└──...
expressions/ //模型表情
├──xx.exp.json
└──...
motions/ //模型动作
├──xx.mtn
└──...
xxx.model.moc //模型数据
xxx.model.json //模型设置
physics.json //物理效果设置
pose.json //姿势设置

分别点击表情和动作文件,可以在旁边的模型预览效果,需要新的效果可以自己调整下方的参数。不过毕竟不是来搞模型设计的,贴图、物理效果、姿势这些不用管了,也不需要自己制作表情和动作,想办法把已有的资源利用起来就好了,那么关键就是 xxx.mode.json 这个文件了。以我这个模型为例:

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
{
"version": "Sample 1.0.0",
"name": "madoka",
"model": "model.moc",
"textures": [
"model.2048/texture_00.png"
],
"physics": "physics.json",
"pose": "pose.json",
"expressions":
[
{"name":"f01","file":"expressions/f01.exp.json"},
{"name":"f02","file":"expressions/f02.exp.json"},
{"name":"f03","file":"expressions/f03.exp.json"},
{"name":"f04","file":"expressions/f04.exp.json"},
{"name":"f05","file":"expressions/f05.exp.json"},
{"name":"f06","file":"expressions/f06.exp.json"},
{"name":"f07","file":"expressions/f07.exp.json"},
{"name":"f08","file":"expressions/f08.exp.json"}
],
"layout":
{
"center_x":0,
"y":1.5,
"width":2.8
},
"hit_areas": [
{"name": "head", "id": "D_REF.HEAD"},
{"name": "body", "id": "D_REF.BODY"}
],
"motions":
{
"idle": [
{"file": "mtn/idle_01.mtn", "fade_in": 2000, "fade_out": 2000},
{"file": "mtn/idle_02.mtn", "fade_in": 2000, "fade_out": 2000},
{"file": "mtn/idle_03.mtn", "fade_in": 2000, "fade_out": 2000},
{"file": "mtn/idle_04.mtn", "fade_in": 2000, "fade_out": 2000}
],
"tap_body": [
{"file": "mtn/tap_body.mtn"}
],
"flick_head": [
{ "file":"mtn/flick_head.mtn"}
]
}
}

参数非常多,但其实需要注意的就这几个:

  • hit_areas:设置模型的可触发区域,一般的模型都有“head”和“body”两个区域,后面的 id 是模型制作时设置的,可以在 Live2D Viewer 里面查看;
  • expressions:设置模型的表情;
  • motions:设置模型的动作;
    • idle:模型闲置时动作;
    • tap_body:点击 hit_areas 中对应的部分时触发的动作,这里对应了“body”;
    • flick_head:同上,这里对应了“head”。

其他参数要么意义不言而喻,要么就完全一无所知,关于这些参数,可以参考 Live2DViewerEX 文档中[3] SDK 配置这部分内容(Live2DViewerEX 是 Steam 上的一个收费软件,功能类似 Wallpaper Engine,不过集成了 Live2D 的 SDK)。当然最开始我的配置并没有这么完善,因此模型的动作才十分单调,新的配置中,我给模型增加了4个闲置时的动作,可以随机触发,一个点击头部的动作和一个点击身体的动作,这下终于完美了!

这么想的话就太天真了,模型现在确实动作多了起来,随之而来的 B!U!G!也来了。主要有两个问题,一个我设置了4个闲置动作,想的是可以随机触发4个不同的动作,然而实际运行时,每次加载出模型后会随机选择一个动作,然后会反复重复这个动作;另一个问题是我点击头部不会触发 flick_head 这个动作,而是随机切换表情。准完美主义(指强迫症)的我怎么可能允许这种问题存在!于是我又开始了新一轮的学(zuo)习(si)。

通过 hexo-helper-live2d 插件的文档和张鑫旭大佬的博客[4]文章,我了解到 hexo-helper-live2d 插件使用的是 Live2D_SDK_WebGL 官方版的魔改版本 live2d-widget[5]。也就是说可以确定问题来自 live2d-widget 这个插件,作为对比,我下载了官方 SDK,并运行了里面的例子,结果发现官方的示例中,尽管不存在第一个问题,但是单击头部依然是切换表情而非触发动作,此时骑虎难下,只好开始研究示例的代码。

折腾了一整天,终于大概搞懂了 SDK 的运作方法,同时找了下 SDK 的 API 参考[6],不完整,但是也可以了解一下。首先以官方示例(注:这个示例是我稍微魔改过的,修改了显示样式和代码格式,不影响原有功能)介绍一下 SDK 的组成,有兴趣的可以下载这个例子[7]来玩一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注释是我个人对代码的作用理解的,不一定正确

framework/
└──Live2DFramework.js //对核心代码的扩展,提供一个完整的开发框架
lib/
└──live2d.min.js //Live2D的核心代码,混淆+压缩过的
utils/
├──MatrixStack.js //用于图形处理的工具,没学过图形编程不懂
└──ModelSettingJson.js //处理 model.json 文件
src/
├──LAppDefine.js //定义全局变量
├──PlatformManager.js //用来加载本地数据的类
├──LAppModel.js //定义模型相关的类
├──LAppLive2DManager.js //一个封装模型的类
└──SampleApp.js //应用程序
index.html
main.css

然后是文档中描述的关于 Live2D 渲染模型的整个过程:

  1. 初始化 canvas
  2. 创建 webgl 上下文
  3. 初始化 live2d
  4. 从moc文件读取Live2D模型对象
  5. 读取贴图,对 live2DModel 设置贴图、WebGL对象、矩阵
  6. 模型的更新和绘制

通过对 SDK 整体的了解,再来找问题,就轻松多了(个屁),关于第二个问题,点击头部只切换表情不触发动作的,那么只需要找到触发事件的函数,再一个一个跟踪下去就能找到问题。最后找到是在 LAppLive2DManagerLAppLive2DManager.prototype.tapEvent 这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
LAppLive2DManager.prototype.tapEvent = function(x, y) {
if (LAppDefine.DEBUG_LOG)
console.log("tapEvent view x:" + x + " y:" + y);

for (var i = 0; i < this.models.length; i++) {
if (this.models[i].hitTest(LAppDefine.HIT_AREA_HEAD, x, y)) {
if (LAppDefine.DEBUG_LOG)
console.log("Tap face.");

this.models[i].setRandomExpression();
} else if (this.models[i].hitTest(LAppDefine.HIT_AREA_BODY, x, y)) {
if (LAppDefine.DEBUG_LOG)
console.log("Tap body." + " models[" + i + "]");

this.models[i].startRandomMotion(LAppDefine.MOTION_GROUP_TAP_BODY, LAppDefine.PRIORITY_NORMAL);
}
}
return true;
};

可以看到,输出日志 “Tap face” 的下面调用的 setRandomExpression() 方法,字面理解就是设置随机表情,这就是为什么点击头部只会切换表情了,只需要改为下面 “Tap body” 的部分一样,应该就可以触发动作了,因此把这里改成:

1
this.models[i].startRandomMotion(LAppDefine.MOTION_GROUP_FLICK_HEAD,LAppDefine.PRIORITY_NORMAL);

这样一来,第二个问题就完美解决了,这时候再来看第一个问题。由于已经确定第一个问题是 live2d-widget 的原因,那么这里只需要把它和官方的代码对比一下,很容易就可以找到问题了(个屁)。通过查看网页的请求,可以发现官方代码中,每次随机选择一个闲置动作时,会发出一个 ajax 请求,而 live2d-widget 只请求一次,因此只要找到请求闲置动作的部分,大概率就可以找到问题。(这里每次随机都请求实际是官方代码的BUG,感谢官方的BUG,如果不是官网的BUG我也发现不了这个问题,具体什么BUG下面有讲到)

通过查找调用栈,最后终于找到相关代码,在 LAppModel.js 中的 LAppModel.prototype.startMotion 里面,有一段这样的代码:

1
2
3
4
5
6
7
8
9
if (this.motions[name] == null) {
this.loadMotion(null, this.modelHomeDir + motionName, function(mtn) {
motion = mtn;
thisRef.setFadeInFadeOut(name, no, priority, motion);
});
} else {
motion = this.motions[name];
thisRef.setFadeInFadeOut(name, no, priority, motion);
}

这里面最开始判断使用的“name”应该是指动作的名字,所有动作都保存在 this.motions 中,不过根据代码的上下文来看,这里传入的名字是动作的组名“idle”(详见上面的 config.json,组名就是一组同类型动作的名称),具体的动作名称应该是“motionName” (上述代码前面定义的一个变量,保存的是当前加载的动作的文件名)。根据现在这段代码的意思,当 this.motions 里面没有名为“idle”的动作时,就加载一个随机动作并执行,否则的话就直接执行这个动作 。然而从 config.json 也看到,四个动作没有一个名字叫 ”idle“ 的,因此每次都会随机加载一个动作而不是直接执行这个动作。

再看 live2d-widget 中同样部分(cModel.js)的代码,跟官方的代码不同的地方在于 loadMotion 这个函数:

1
this.loadMotion(name, this.modelHomeDir + motionName, function(mtn) {...}) // cModel.js

live2d-widgetcModel.js 里这里的第一个参数是“name”而官方的代码中是“null”,我顺着找了下这个函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
L2DBaseModel.prototype.loadMotion = function (name/*String*/, path /*String*/, callback) {
var pm = Live2DFramework.getPlatformManager(); //IPlatformManager

if (this.debugMode) pm.log("Load Motion : " + path);

var motion = null;

var thisRef = this;
pm.loadBytes(path, function (buf) {
motion = Live2DMotion.loadMotion(buf);
if (name != null) {
thisRef.motions[name] = motion;
}
callback(motion);
});
}

可以看到,如果参数指定的是一个“name”的话,加载完后就会把这个动作添加到 this.motions 中,然后回到 startMotion 时就会执行 else 后面的内容,即每次都会运行这个 motion,但是上面也说了,这里的“name”实际上是动作的组名 ”idle“,也就是说它会把加载的第一个动作保存为 this.motions.idle,之后无论随机到哪个动作,都只会运行 this.motions.idle 这个动作,因此出现了第一个问题。现在找到问题所在就很简单了,把 live2d-widget 中对应的地方的 name 改成 null 就可以了。

表面上看这样就解决问题了,实际上仔细想一下会发现官方的代码是有问题的,也就是上面说到的官方的BUG。如果每次都传入“idle”作为动作的名称,那么 else 之后的代码永远都不会运行了,这违反了这段代码的原意,仔细观察之后就会发现这段代码应该是这样的:

1
2
3
4
5
6
7
8
9
if (this.motions[motionName] == null) {
this.loadMotion(motionName, this.modelHomeDir + motionName, function(mtn) {
motion = mtn;
thisRef.setFadeInFadeOut(name, no, priority, motion);
});
} else {
motion = this.motions[motionName];
thisRef.setFadeInFadeOut(name, no, priority, motion);
}

这样一来既能保证每次随机到不同的动作,又不需要每次重新请求动作文件,节省了 http 请求的时间。

目前对 Live2D 的了解就到这里了,哪怕是研究了一整天,也只是一点皮毛罢了,真正核心的图形编程我都还没开始入门,因此之后学习了相关知识后,再来写写文章吧。

参考资料


给博客添加Live2D看板娘
https://infiniture.cn/2020/03/03/给博客添加Live2D看板娘/
作者
NickHopps
发布于
2020年3月3日
许可协议