给博客加看板娘:从选型到交互,我把 Live2D 的坑走了一遍
为什么要有它
想在博客右下角放一个会眨眼、会跟着鼠标转头的角色。听起来是小事:找个库、装个模型、刷新页面,实际从下午折腾到晚上。
选型:三代格式的版本地狱
一张立绘拆成几十个图层,绑上参数让它们变形,看起来就像活的——它不是视频,也不是 3D,是用代码驱动的纸片人。
麻烦在于格式分了三代,互不兼容:
| 代际 | 模型文件 | 配置 |
|---|---|---|
| Cubism 2 | .moc | model.json |
| Cubism 3/4 | .moc3 | *.model3.json |
| Cubism 5 | .moc3 | *.model3.json |
后两代后缀完全一样,版本号写在文件头第 5 个字节里。我对着文件名猜了半天,最后靠十六进制才读出那是 0x05,即 v5。
运行时更难选。官方 Cubism Core(live2dcubismcore.min.js)只管解析和计算,对版本极其敏感:Core 4 读不了 v5,Core 5 才能向下兼容。live2d.min.js 是 Cubism 2 的老运行时,只能读 .moc,网上教程大多用它——所以教程里的模型搬到新库上统统报错。
封装库这边:live2d-widget 只支持 Cubism 2,pixi-live2d-display 支持 2 和 4 但不自带 Core,还有一个声称自动识别版本的。我选了最后那个,也是出事那个。
模型本身也有坑:27MB 的直播级包,用 PIL 压到 1024 后只有 2.2MB。
库把 Core 打包进了自己身体里
这是整件事最有价值的一条。那个库装上去就报错:
TypeError: g[y[((n + 36) >> 2)]] is not a function
at c_csmReviveMocInPlacec_csmReviveMocInPlace 是 Core 内部解析 .moc3 的函数。这种"某个东西不是函数"的错,是 WASM 解码错位的典型表现——Core 的版本低于模型的版本。
第一反应很自然:先加载官方 Core 5,再加载库。顺序、完成事件、延时初始化全查过,报错一模一样。
答案在库的源码里。把 660KB 的压缩产物拆开搜,看到一行:
window.Live2DCubismCore = i;i 是它自己打包进去的旧版 Core。库一加载,就无条件把我装好的 Core 5 覆盖掉。执行顺序变成:加载 Core 5 → 库把自带的旧 Core 拍到全局 → 解析 v5 的 moc3 → 失败。
加载顺序救不了它。 这不是 bug,是设计:为了开箱即用,它把依赖整个内联进了产物。代价是天花板被焊死——它声称支持新版本,体内的 Core 却停在旧版本,而你从外面既看不见、也换不掉。所谓"升级这个库",本质是等作者重新打包一次,业务代码里做什么都无解。
后来换成三个组件:官方 Core 5 解析 .moc3、pixi.js 渲染、pixi-live2d-display 挂模型。它们都不往全局写 Core,我加载的那份就是生效的那份;代价是画布初始化、缩放适配、鼠标跟随全要自己写,但六十行是我自己的。
静默失败:switchModel 找不到自己的容器
能显示了,下一个需求很自然:点一下换个人。点下去,角色淡出、淡入,动画流畅——还是同一个人。
控制台没有报错,加日志才确认 switchModel() 确实被调用了,参数也对,就是没效果。
往前倒一步。之前为了避开拖动和组件自带动画打架,我把渲染画布从组件创建的容器里搬进自己的容器,再把原容器隐藏。当时这做法是对的,动画确实不打架了。但我没想到 switchModel() 内部要操作它自己创建的那个容器:画布被我搬走,容器被我藏了,它找不到目标,于是什么都不做,安静地失败。
修法不是迁就它,而是绕开:两个实例各挂一个模型、各有自己的容器,"切换"从调用库的接口降级成隐藏一个容器、显示另一个。动画、位置、显隐全归我,组件全程不知道发生过"切换"。第二个实例是首次切换时才现场创建的,为了省内存——这又埋了个时序坑。
交互层:拖动、换人、隐藏、气泡
四个交互:按住拖动、单击换人、双击隐藏(右下角留个小圆点叫回来)、周期性冒气泡。气泡看着最水,细节最多。
一是位置。气泡挂在容器里会被裁掉、压不到头顶,所以它是独立挂载的固定层;每次拖动都得跟着重算,否则就跟角色分家。
二是单击和双击打架:双击隐藏会先触发两次单击,"点两下"变成换两次人再隐藏。用延迟判定——单击先挂 250ms 定时器,双击立刻清掉它。
三是文案按时间分段:清晨催喝水,白天提醒别久坐,傍晚提醒开灯,深夜催睡觉——深夜那几条带催促意味,比卖萌有用。
三个必须踩一遍的坑
坑一:批量隐藏兄弟节点,把整页藏了。 组件右侧自带一条竖条,我写了段"隐藏除 canvas 外所有子元素"。上线后整页正文消失,只剩顶部横幅——因为容器可能是 body,画布直接挂在 body 上时,这句等于隐藏整个页面。现在会先确认容器身份,是 body 就自己包一层。
坑二:getBoundingClientRect() 读到进场动画的中间值。 气泡贴着角色头顶,位置靠它算。冷启动时模型正做淡入加上移 16px 的进场动画,这一刻量到的是中间态,气泡偏高十来像素,动画一结束就错位。解法是别在进场过程中量:等动画结束(或固定 260ms)后算一次,拖动时只在收尾时测。
坑三:rAF 在无头环境不触发。 淡入本来是用 requestAnimationFrame 写的。真实浏览器没问题,用无头浏览器截图验证时模型却消失了:rAF 依赖真实帧调度,虚拟时间下帧不产生,回调从不触发,opacity 停在 0——元素在、DOM 对、就是看不见。改成 setTimeout(fn, 30) 就好,动画本来就不该依赖一个可能不触发的调度器。
首次切换要现场创建那个实例,创建是异步的,画布要等几百毫秒,只能轮询——轮询里别用 rAF 递归,老实 setTimeout(check, 120)。
手机端:触摸事件我从来没绑过
桌面端调完上手机:拖不动、点不动、双击更是不可能。原因很蠢——我只绑了 mousedown / mousemove / mouseup,它们在手机上要么不来,要么来得晚。补 touchstart / touchmove / touchend 还要 preventDefault(),否则页面跟着滚;容器上挂 touch-action: none 更干净。
dblclick 更彻底:移动端浏览器基本不派发它,因为双击在手机上被定义成缩放。要么换成"长按 500ms 隐藏",要么在 touchend 里数两次触碰的间隔(300ms 内算双击)。我选后者,和桌面的 250ms 判定共用一套状态机,只换事件源。
一句话教训
真正卡住我的从来不是"代码写错了",而是几个看起来天经地义、其实从没验证过的前提:变量声明了就有值、rAF 一定在下一帧执行、容器一定是我想的那个元素、鼠标事件在手机上也会来。
封装得越好的库,你违背前提的时候它越安静。 它不抗议,只沉默。