开发日志 | 在网页里制作一场简单的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 模型的ParamAngleXParamAngleY参数区间。

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模型普遍自带“自动眨眼”,但是在这个项目中我不知为何无法调出模型自带的眨眼。为了为模型赋予灵魂,我不得不自己手写了一个基于时间增量的状态机。我将眨眼过程拆解为三个阶段:

  1. Wait:随机时长(2~5秒),保持睁眼。
  2. Closing:眼睛参数从Base -> 0
  3. 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 });
  });
}
JavaScript

LRC解析引擎

这是开发过程中的重点之一,我希望在音乐播放的同时,可以同步显示歌词。因为可以在网络上直接找到音乐对应的.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的PromiseAsync/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的演出!

在Project Sekai的世界观中,Untitled是一首尚未完成的歌曲,也是通往Sekai的钥匙。当一个人播放这首曲子时,他就会被传送到属于他的Sekai中,并与那里的虚拟歌手们相遇。当人们在Sekai中经历各种各样的故事,最终正视内心,找到了自己“真正的心愿”时,这首”Untitled”就会被填上歌词,拥有名字,变成一首属于他们自己的完整的歌曲。
愿你的心愿世界中也有一个属于你的初音未来。

源代码

GitHub项目地址:Admiral-Spee/Web_Live2D_Show: 一场在网页中的简单Live2D演出

要发表一条评论吗?

您的电子邮箱地址不会被公开。 必填项已标注