开发日志 | 在网页里制作一场简单的Live2D演出
最近看了初音未来的电影,深为感动。如果说初音未来“隐藏”在WiFi里,手机里,那么肯定也可以使用某种方式让她真正出现在网页里。
一般来说,在网页上放置二次元角色都会使用Live2D,对应的就需要找到初音未来的Live2D模型和合适的Live2D引擎。一开始模型还比较难找,网上开源的模型都极其古老,几何没有看得上眼的。后来偶然间找到一个网站,里面开源了世界计划游戏中所有的美术素材和模型,甚至有初音未来在电影中新形象的模型(虽然是非官方网站,但感谢世嘉开源:Live2D | Sekai Viewer)。
游戏中使用的Live2D模型都是最新的Live2D Cubism 4.0(.model3.json),我必须找一个能够支持这种格式的引擎,此外还需要能够深度控制动作和表情、不依赖重型框架、能够即插即用。最终我锁定了PixiJS v6配合pixi-live2d-display的组合。PixiJS v6是目前Web端性能最强劲的2D渲染引擎之一,具有强大的绘图能力,而pixi-live2d-display解决了Live2D官方的复杂逻辑,提供了一种简化的API。
鼠标视线追踪
这一功能常见且非常简单,原理是将屏幕坐标映射到 Live2D 模型的ParamAngleX和ParamAngleY参数区间。
window.addEventListener('pointermove', (event) => {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
model.focus(x, y);
});JavaScript自动眨眼状态机
虽然Live2D模型普遍自带“自动眨眼”,但是在这个项目中我不知为何无法调出模型自带的眨眼。为了为模型赋予灵魂,我不得不自己手写了一个基于时间增量的状态机。我将眨眼过程拆解为三个阶段:
- Wait:随机时长(2~5秒),保持睁眼。
- Closing:眼睛参数从
Base->0。 - Opening:眼睛参数从
0->Base。
核心是利用PixiJS的ticker来驱动,并在开始眨眼前记录当前眼睛的开合度,从而防止打断原本的表情(比如角色正处于笑脸的表情,眨眼后依然会回到笑眼,而不是强制全睁)。
// ======= 眨眼 =======
const coreModel = model.internalModel.coreModel;
const EYE_L_ID = 'ParamEyeLOpen';
const EYE_R_ID = 'ParamEyeROpen';
// 眨眼状态机
let blinkState = 'wait'; // 'wait' | 'closing' | 'opening'
let waitTimer = 0;
let phaseTimer = 0;
let nextBlinkTime = 2 + Math.random() * 3; // 下一次眨眼间隔(秒)
const blinkPhaseDuration = 0.08; // 闭眼 / 睁眼各自时长(秒)
// 每次眨眼前记录当时的基础眼睛开合
let baseEyeL = 1.0;
let baseEyeR = 1.0;
app.ticker.add(() => {
if (!coreModel) return;
// 用真实时间,每帧毫秒 / 1000
const dt = app.ticker.deltaMS / 1000;
if (blinkState === 'wait') {
// 等待下一次眨眼
waitTimer += dt;
if (waitTimer >= nextBlinkTime) {
waitTimer = 0;
blinkState = 'closing';
phaseTimer = 0;
// 每次眨眼前,记录当前眼睛基础值
baseEyeL = coreModel.getParameterValueById(EYE_L_ID);
baseEyeR = coreModel.getParameterValueById(EYE_R_ID);
}
} else if (blinkState === 'closing') {
// 从 baseEye -> 0 的闭眼阶段
phaseTimer += dt;
let t = phaseTimer / blinkPhaseDuration;
if (t > 1) t = 1;
const vL = baseEyeL * (1 - t);
const vR = baseEyeR * (1 - t);
coreModel.setParameterValueById(EYE_L_ID, vL);
coreModel.setParameterValueById(EYE_R_ID, vR);
if (t >= 1) {
blinkState = 'opening';
phaseTimer = 0;
}
} else if (blinkState === 'opening') {
// 从 0 -> baseEye 的睁眼阶段
phaseTimer += dt;
let t = phaseTimer / blinkPhaseDuration;
if (t > 1) t = 1;
const vL = baseEyeL * t;
const vR = baseEyeR * t;
coreModel.setParameterValueById(EYE_L_ID, vL);
coreModel.setParameterValueById(EYE_R_ID, vR);
if (t >= 1) {
blinkState = 'wait';
waitTimer = 0;
nextBlinkTime = 2 + Math.random() * 3; // 下一次眨眼间隔再随机
}
}
}JavaScript响应式布局
PC和Mobile的展示逻辑截然不同:
- PC:宽屏,角色应该立在屏幕中下部,不会遮挡过多的画面。
- Mobile:竖屏,角色需要居中放大才能看清,几乎要充斥整个画面。
我封装了一个layoutModel函数,在窗口Resize时自动计算模型的显示尺寸和位置,从而在两种显示设备上都能达到最好的显示效果。
function layoutModel() {
// 用实际 DOM 尺寸,而不是 renderer 内部尺寸
const rect = wrapper.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// —— 手机:人物放屏幕正中,稍微偏下一点 ——
model.anchor.set(0.5, 0.5); // 以人物中心为锚点
model.scale.set(0.25);
model.x = w * 0.5;
model.y = h * 0.5; // 0.5 正中,0.55 稍微偏下
} else {
// —— PC:站在底部中央 ——
model.anchor.set(0.5, 0.35); // 脚附近为锚点
model.scale.set(0.3);
model.x = w * 0.5;
model.y = h * 0.5;
}
}JavaScript渲染优化
在 PC 端直接渲染Canvas问题不大,但在高DPR(设备像素比)的手机上,如果按照默认的window.devicePixelRatio渲染,Canvas的分辨率会爆炸,导致手机发热、掉帧甚至Crash。
我在初始化PIXI.Application前做了一个关键的数学限制,从而降低手机设备的渲染压力。
// 获取设备像素比,但强制限制最大为 2。
// 很多旗舰机 DPR 是 3 或 4,渲染压力成倍增加,但肉眼观感提升有限。
const RESOLUTION = Math.min(window.devicePixelRatio || 1, 2);
const app = new PIXI.Application({
view: canvas,
autoStart: true,
transparent: true,
autoDensity: true, // 仍然自适应 DPR
resolution: RESOLUTION, // 但最多放大到 2 倍
antialias: true,
});JavaScript音乐自动播放策略
开发中遇到的一个问题是Chrome可能会禁止音频自动播放。如果直接调用bgm.play(),控制台会报DOMException。所以我设计了一套Promise链式兜底方案,保证了无论浏览器策略如何严格,用户只要与页面产生交互,演出就能顺利开始。
function startBgmAndLyrics() {
bgm.play().then(() => {
// Case A: 浏览器允许自动播放
startLyrics();
}).catch(err => {
// Case B: 被拦截。注册一次性点击事件。
console.warn("音乐自动播放被浏览器阻止,将在用户点击页面后开始播放和台词。", err);
const resume = () => {
bgm.play().then(() => {
startLyrics();
}).catch(() => {});
document.removeEventListener('click', resume);
};
// 用户点击页面任何地方,立刻开始
document.addEventListener('click', resume, { once: true });
});
}JavaScriptLRC解析引擎
这是开发过程中的重点之一,我希望在音乐播放的同时,可以同步显示歌词。因为可以在网络上直接找到音乐对应的.lrc歌词文件,所以我只需要手动写一个函数来解析.lrc文件即可。
但实际解决中仍有不少问题,比如标准的LRC是[mm:ss.xx]歌词,但在这个项目中,还需要额外考虑空行(间奏部分不显示歌词)和没有时间戳的“续行”(日文歌词和中文翻译分行显示)。
为此我使用了一个强大的正则来捕获时间,并处理了续行和空行的逻辑。配合updateDialog函数在requestAnimationFrame中高频对比audio.currentTime,实现了极其精准的字幕显示。
function parseLRC(text) {
const lines = text.split('\n');
const result = [];
let lastEntry = null;
for (let raw of lines) {
const line = raw.replace(/\r/g, '');
if (!line.trim()) continue; // 空行直接跳过
// 支持 [00:00.59] 或 [00:00] 格式
// Group 1: 分, Group 2: 秒, Group 3: 毫秒
const match = line.match(/\[(\d{2}):(\d{2})(?:\.(\d{1,3}))?]/);
// 计算总秒数
if (match) {
const min = parseInt(match[1], 10);
const sec = parseInt(match[2], 10);
const ms = match[3] ? parseInt(match[3].padEnd(3, '0'), 10) : 0;
const content = line
.replace(/\[\d{2}:\d{2}(?:\.\d{1,3})?]/, '')
.trim(); // 允许为空,用来关气泡
const entry = {
time: min * 60 + sec + ms / 1000,
text: content
};
result.push(entry);
lastEntry = entry;
} else {
// 无时间标签但有文字:作为上一句的“续行”
if (lastEntry) {
if (lastEntry.text) {
lastEntry.text += '\n' + line.trim();
} else {
lastEntry.text = line.trim();
}
}
}
}
return result.sort((a, b) => a.time - b.time);
}
const scriptData = parseLRC(lrcText);
let currentIndex = -1;
let finishedOnce = false;
let lyricsStarted = false;
let dialogTimer = null;
let startTime = 0; // 台词开始的时间戳(毫秒)
// ======= 台词刷新函数 =======
function updateDialog() {
if (!scriptData.length) return;
const elapsed = (Date.now() - startTime) / 1000; // 秒
let idx = currentIndex;
for (let i = scriptData.length - 1; i >= 0; i--) {
if (elapsed >= scriptData[i].time) {
idx = i;
break;
}
}
if (idx !== currentIndex && idx >= 0) {
currentIndex = idx;
const line = scriptData[idx];
if (line.text && line.text.length > 0) {
dialogBox.textContent = line.text;
dialogBox.classList.add('show');
} else {
dialogBox.textContent = "";
dialogBox.classList.remove('show');
}
}
const lastTime = scriptData[scriptData.length - 1].time;
if (!finishedOnce && elapsed >= lastTime) {
finishedOnce = true;
(async () => {
playFaceMotion('face_band_smile_08');
await delay(300);
playBodyMotion('w-adult01-shakehand');
await delay(4000);
playBodyMotion('w-normal01-glad');
})();
if (dialogTimer) {
clearInterval(dialogTimer);
dialogTimer = null;
}
}
}
// ======= 音乐 + 台词启动函数 =======
function startLyrics() {
if (lyricsStarted) return;
lyricsStarted = true;
startTime = Date.now();
dialogTimer = setInterval(updateDialog, 100);
}JavaScript演出编排
把所有的内容都按清单加载出来只是第一步,如果Miku只是傻站在那里,那就不仅没有灵魂,甚至有点惊悚。为了打造沉浸感,我需要像导演一样,安排角色的出场和谢幕。
在技术实现上,我拒绝了传统的setTimeout嵌套,而是利用ES6的Promise和Async/Await语法,将时间轴控制变得像写剧本一样直观。
首先,我封装了一个极其简单的工具函数,将setTimeout包装成Promise。
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}JavaScript在模型加载完毕之后,我没有让角色同时做所有动作,而是在每个动作之间都添加了秒级的停顿。因为把动画安排的过于紧凑可能会产生动画冲突,导致某些动作被吞。不过这种停顿反而产生了某种微妙的自然感。
(async () => {
// 动作组:w-adult12-glad
playBodyMotion('w-adult12-glad');
await delay(1000); // 代码暂停,画面继续渲染
// 表情组:face_band_closeeye_03
playFaceMotion('face_band_closeeye_03');
})();JavaScript当音乐播放结束时,页面并没有戛然而止,而是进入了一段精心编排的谢幕序列。在环境层,背景图开始缓慢淡出(fade-out),聚焦Miku;在角色层,Miku微笑->挥手告别->最终保持开心姿态定格。
通过这种方式,原本枯燥的事件监听回调,变成了一段可读性极强的线性叙事代码。每一行await都是一个镜头切换,每一个playMotion都是Miku的一次表演。
bgm.addEventListener('ended', () => {
// 1. 触发 CSS 动画,背景慢慢变黑,营造落幕感
document.getElementById('live2d-bg').classList.add('fade-out');
// 2. 执行线性的动作序列
(async () => {
// 第一幕:露出笑容
playFaceMotion('face_band_smile_08');
await delay(300); // 稍作停顿
// 第二幕:挥手告别
playBodyMotion('w-adult01-shakehand');
await delay(4000); // 等待挥手动作大致完成
// 第三幕:定格在开心的待机动作
playBodyMotion('w-normal01-glad');
})();
});JavaScript总结
通过这几个功能的组合,再加上必要的美术、音乐、歌词和Style元素,最终得到了一个快速且生动的初音未来。
项目没有引入重型框架,完全使用原生JS使得它非常适合嵌入到现有的任何网站或博客页面中。
虽然原本也考虑过引入Web Audio API分析器来让Miku的口型跟随音频的波形自动开合,但是后来发现这样不好分割演唱和伴奏,观感上反而不如全程闭嘴,所有最后也就没搞。
目前这个彩蛋已经上线,你可以在网站的角落找到彩蛋的入口,然后开始欣赏Miku的演出!
Untitled.wav是什么意思?
在Project Sekai的世界观中,Untitled是一首尚未完成的歌曲,也是通往Sekai的钥匙。当一个人播放这首曲子时,他就会被传送到属于他的Sekai中,并与那里的虚拟歌手们相遇。当人们在Sekai中经历各种各样的故事,最终正视内心,找到了自己“真正的心愿”时,这首”Untitled”就会被填上歌词,拥有名字,变成一首属于他们自己的完整的歌曲。
愿你的心愿世界中也有一个属于你的初音未来。
源代码
GitHub项目地址:Admiral-Spee/Web_Live2D_Show: 一场在网页中的简单Live2D演出