之前想把 Live2D 模型放到网页里,做成一个能对话、有表情、会根据鼠标视线跟着你走的“陪伴型 AI 助手”。结果发现资料散落各处:有讲 Live2D 模型怎么导入 Cubism Editor 的,有讲前端怎么加载 .moc3 的,还有讲接入大模型对话的,但很少有人把这三件事串起来讲完整。
这篇文章就是把这套东西完整梳理了一遍:从 Live2D 模型的文件结构、Web 端加载渲染,再到接入 AI 对话能力,最终实现一个“打开网页就能和兔兔聊天、看她动起来”的陪伴型 AI 项目。适合有一点前端基础、想入门 Live2D Web 开发,或者想给 AI 应用加一个虚拟形象的开发者。
涉及到的技术栈包括 Live2D Cubism SDK、pixi-live2d-display、Node.js 后端接口,以及大模型 API 调用。全文按可复现的流程组织,跟着做就能跑通一个最小 Demo。
1. 项目背景与整体设计
1.1 什么是 Live2D
Live2D 是一种 2D 艺术表现形式,它不是 3D 建模,而是通过图像变形、网格绑定、参数控制,让一张插画产生“立体的、会动的”效果。你可以把它理解为:一张分层好的插画素材,在 Cubism Editor 里把眼睛、嘴巴、头发、身体各部位分别绑定到参数上,运行时再根据参数值实时变形渲染。
在 Web 前端领域,Live2D 模型最常见的应用就是看板娘、虚拟主播、角色对话、短视频形象。而配合 AI 对话接口后,Live2D 模型可以变成“有形象、有表情、有回应”的虚拟陪伴助手。
一个 Live2D 模型要能在网页上运行,需要具备两个前提:
- 模型文件本身是 Cubism Editor 导出的格式,常见的是 Cubism 2.1(.moc + .model.json)或 Cubism 4(.moc3 + .model3.json)。
- 网页端有一个能解析并渲染这些文件的运行时库,比如官方 Cubism Web SDK,或者社区封装好的 pixi-live2d-display。
1.2 陪伴型 AI 兔免的功能拆解
回到标题里的项目:陪伴型 AI 兔兔。它的核心体验是,打开页面后能看到一只兔子角色,带有“主人,欢迎回来,我一直在”这类性格化台词,并且能与用户进行自然语言对话。
从功能上拆,这个项目包含三部分:
| 功能模块 | 技术方案 | 用户感知 |
|---|---|---|
| 角色形象展示 | Live2D 模型 + Canvas 渲染 | 兔子在网页上存在,有呼吸动画、表情变化 |
| 交互反馈 | 鼠标追踪、点击触发言语/表情 | 角色会“看”着鼠标,点击会有反馈 |
| AI 对话 | 调用大模型 API,携带角色人设 | 发消息后兔子会开口回复,附带口型动画 |
简单来说,前端负责把 Live2D 兔子渲染出来,并根据对话状态触发说话口型、表情切换;后端负责接收前端消息,调用 AI 接口返回角色化回复;前端再把回复文本变成 Live2D 的“口型参数”和“动作参数”。
1.3 为什么用 Live2D 而不是直接用 3D 模型
有一部分人问:为什么不直接用 3D 角色?原因有两方面。一方面,Live2D 的素材成本低,绘画师只需要提供多图层插画,不需要建高精度 3D 模型,对二次元风格角色来说,Live2D 表现力比 3D 更贴合。另一方面,Live2D 在 Web 端的性能开销远小于实时 3D 渲染,一张 Canvas 就能跑起来,手机端也能比较流畅地运行。
2. 环境准备与版本说明
2.1 运行环境
本文示例以 Web 项目为主,操作系统不限,Windows、macOS、Linux 都可以。只要确保安装了以下工具:
- Node.js 16 或更高版本(建议 18 LTS 以上)。
- npm 或 yarn,用于安装前端依赖。
- 一个可用的现代浏览器,推荐 Chrome 或 Edge。
- 代码编辑器,推荐 VS Code。
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
2.2 需要准备的文件
- Live2D 模型文件:包括
.moc3文件、.model3.json配置、贴图.png文件夹、表情、动作、物理效果等。 - Cubism Web SDK 运行时:本文使用社区开源库
pixi-live2d-display,它对 Cubism 4 的支持比较完整,使用便捷。 - 后端服务:Node.js + Express 即可,负责转发 AI 请求,避免在前端暴露 API Key。
2.3 关于模型资源与版权
Live2D 模型属于美术资源,版权要特别注意。如果做个人学习,建议使用 Live2D 官方示例模型或明确标注“可免费使用”的模型;如果是商用项目,必须确认模型的授权范围。不建议为了图方便去下载来源不明、未经授权的模型,很容易带来版权风险和安全风险。
本文中的项目源码可参考开源仓库my_ai_town的整体结构,重点理解 Live2D 渲染与 AI 对话的整合思路。
3. Live2D 模型文件结构与加载原理
3.1 Cubism 4 模型核心文件
一个标准的 Cubism 4 模型目录通常包含以下文件:
assets/ my-model/ my-model.moc3 my-model.model3.json my-model.2048/ texture_00.png texture_01.png my-model.physics3.json my-model.motion3.json expressions/ exp_01.exp3.json各文件作用如下:
| 文件 | 作用 |
|---|---|
.moc3 | 模型主文件,包含网格、顶点、变形数据,是渲染核心 |
.model3.json | 模型配置文件,声明贴图、物理、表情、动作、参数组等所有资源的路径 |
贴图.png | 分层纹理贴图,在 Cubism Editor 中导出 |
.physics3.json | 物理效果配置,例如头发、耳朵、尾巴的摆动 |
.motion3.json | 动作动画数据,例如“点头”“挥手”“开心跳起” |
.exp3.json | 表情配置,例如“开心”“难过”“眨眼睛” |
前端运行时一般不会直接加载.moc3,而是加载.model3.json。因为.model3.json会告诉运行时去哪里找贴图、动作、物理、表情等资源。
3.2 model3.json 里有哪些关键信息
拿一个简化版本的model3.json举例:
{ "Version": 3, "FileReferences": { "Moc": "my-model.moc3", "Textures": [ "my-model.2048/texture_00.png" ], "Physics": "my-model.physics3.json", "Motions": { "Idle": [ { "File": "motions/idle_01.motion3.json" } ], "TapBody": [ { "File": "motions/tap_body.motion3.json" } ] }, "Expressions": [ { "Name": "Happy", "File": "expressions/happy.exp3.json" } ] }, "Groups": [ { "Target": "Parameter", "Name": "EyeBlink", "Ids": ["ParamEyeLOpen", "ParamEyeROpen"] } ] }这段配置的核心作用:
Moc指向主模型文件。Textures是模型贴图列表。Physics负责物理模拟。Motions定义了动作组。在运行时,可以通过motion组件播放某个动作,比如TapBody下的动作。Expressions定义表情,运行时可以通过expression组件设置。Groups的EyeBlink参数组告诉自动眨眼系统去控制左右眼睛的打开参数。
也就是说,前端拿到的不是“一段动画视频”,而是一个活的模型,所有动作和表情都通过参数实时驱动。
3.3 Live2D 参数驱动的基本概念
Live2D 模型动画的本质是“参数值 -> 网格变形”。比如:
ParamEyeLOpen = 1表示左眼完全睁开。ParamEyeLOpen = 0表示左眼完全闭上。ParamMouthOpenY控制嘴巴张开程度,AI 对话时如果想模拟说话口型,就是持续调整这个参数。
在 Cubism 官方 SDK 里,参数值范围一般是-1到1,具体视参数设定而定。不同模型对同一参数名可能有不同语义,拿到模型后最好先用 Cubism Viewer 或命令行工具查看参数列表。
4. 前端集成 Live2D 兔兔模型
4.1 项目初始化
先建一个 Vite 项目,方便开发调试:
npm create vite@latest live2d-ai-rabbit -- --template vanilla cd live2d-ai-rabbit npm install安装 pixi-live2d-display 和 pixi.js:
npm install pixi.js pixi-live2d-display如果你使用的是 Cubism 4 格式模型,还需要安装cubism4运行时支持。pixi-live2d-display 默认会按 model3.json 的Version字段识别模型格式,并加载对应的 Cubism 运行时。
4.2 模型的放置位置
创建public/assets/rabbit/目录,并把模型整个文件夹放进去:
public/assets/rabbit/ rabbit.moc3 rabbit.model3.json rabbit.1024/ texture_00.png motions/ idle.motion3.json happy.motion3.json expressions/ default.exp3.json注意,model3.json中的路径是相对路径,相对于它自身所在的目录。建议保持 Cubism Editor 导出的目录结构,避免手动修改路径出错。
4.3 加载模型到页面
我们在src/main.js中写加载逻辑:
import * as PIXI from 'pixi.js'; import { Live2DModel } from 'pixi-live2d-display'; window.PIXI = PIXI; (async function () { const app = new PIXI.Application({ view: document.getElementById('canvas'), autoStart: true, resizeTo: window, transparent: true, backgroundAlpha: 0 }); const model = await Live2DModel.from('/assets/rabbit/rabbit.model3.json'); model.anchor.set(0.5, 0.5); model.position.set(window.innerWidth / 2, window.innerHeight / 2); // 初始缩放,根据画布大小调整 const scale = Math.min(window.innerWidth / model.width, window.innerHeight / model.height) * 0.8; model.scale.set(scale); app.stage.addChild(model); // 自动眨眼 model.internalModel.motionManager.eyeBlink = null; })();其中:
Live2DModel.from(url)会请求模型配置文件,然后递归加载贴图、动作、表情等资源。model.anchor.set(0.5, 0.5)把模型原点设置为中心,方便对齐。model.scale.set(scale)根据窗口大小缩放,保证兔子不会超出屏幕。
4.4 鼠标跟随与点击反馈
陪伴感很重要的一点是:鼠标移动时,兔子的视线会跟着走;点击兔子时,她会做出反应。
pixi-live2d-display 对鼠标交互封装得比较直接,我们可以在 ticker 中获取鼠标位置,并设置模型的参数:
let isHit = false; model.on('hit', (hitAreas) => { if (hitAreas.includes('Body')) { isHit = true; model.expression('Happy'); model.motion('TapBody'); setTimeout(() => { isHit = false; }, 1500); } }); app.ticker.add(() => { const { x, y } = app.renderer.events.pointer.global; const localPos = model.toLocal(new PIXI.Point(x, y)); // 视线跟随鼠标 model.internalModel.coreModel.setParameterValueById('ParamAngleX', (localPos.x / model.width) * 20); model.internalModel.coreModel.setParameterValueById('ParamAngleY', (localPos.y / model.height) * 20); });这里ParamAngleX和ParamAngleY是头部转向参数,每个模型命名可能不同,需要根据你实际模型的参数名调整。如果参数名不存在,setParameterValueById可能不会生效,所以最好在开发阶段把模型所有参数打印出来看一遍:
const parameters = model.internalModel.coreModel.getModel().parameters; console.table(parameters);4.5 自动呼吸与眨眼
很多官方模型都内置了呼吸、眨眼行为。但如果你的模型比较“闷”,可以手动加一个简单的呼吸参数循环:
app.ticker.add((delta) => { const t = performance.now() / 1000; const breath = Math.sin(t * 2) * 10; model.internalModel.coreModel.setParameterValueById('ParamBreath', breath); });ParamBreath同样是依赖模型文件本身的参数,不一定每个模型都有。没有的话,自行忽略即可。
5. 让兔免开口说话:AI 对话接入
5.1 为什么不能直接在前端调用大模型
目前各类大模型 API 基本都需要 API Key。如果在前端代码里写死 Key,任何打开页面的人都能通过网络调试面板拿到它,等于把自己的额度公开出去了。所以必须加一层后端代理,前端只向后端发消息,后端再调用大模型接口。
5.2 后端 Mini 服务
新建一个server/目录,初始化:
mkdir server cd server npm init -y npm install express axios cors dotenvserver/index.js内容如下:
const express = require('express'); const cors = require('cors'); const axios = require('axios'); require('dotenv').config(); const app = express(); app.use(cors()); app.use(express.json()); const SYSTEM_PROMPT = `你是陪伴型 AI 角色“兔兔”,性格温柔、活泼、黏人。 你会称呼用户为“主人”。你的说话风格简短可爱,偶尔带一点口语化网络用语。 回复控制在 80 字以内。`; app.post('/api/chat', async (req, res) => { const { message, history = [] } = req.body; if (!message || typeof message !== 'string') { return res.status(400).json({ error: 'message 不能为空' }); } try { const messages = [ { role: 'system', content: SYSTEM_PROMPT }, ...history.slice(-10), { role: 'user', content: message } ]; const response = await axios.post( process.env.LLM_API_URL, { model: process.env.LLM_MODEL, messages }, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.LLM_API_KEY}` } } ); const reply = response.data.choices[0].message.content; res.json({ reply }); } catch (error) { console.error('AI 调用失败:', error.message); res.status(502).json({ error: 'AI 服务暂时不可用' }); } }); app.listen(3001, () => { console.log('Server started: http://localhost:3001'); });.env文件:
LLM_API_URL=https://api.openai.com/v1/chat/completions LLM_MODEL=gpt-4o-mini LLM_API_KEY=你的APIKey这里具体的 API 地址和模型名要根据你实际使用的服务商和版本调整,不局限某一家。关键点是:API Key 只放在服务端。
5.3 前端发送消息并触发口型动画
回到前端,我们需要一个输入框、消息列表,以及“兔子说话时嘴会动”的效果。
先写 HTML 区域:
<div id="chat-panel"> <div id="messages"></div> <div id="input-row"> <input id="msg-input" type="text" placeholder="和兔兔说点什么..." /> <button id="send-btn">发送</button> </div> </div>对应的 JS 逻辑:
async function sendMessage() { const input = document.getElementById('msg-input'); const message = input.value.trim(); if (!message) return; appendMessage('user', message); input.value = ''; const resp = await fetch('http://localhost:3001/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, history }) }); const data = await resp.json(); if (data.reply) { appendMessage('ai', data.reply); await playSpeakAnimation(data.reply); } } function appendMessage(role, text) { const container = document.getElementById('messages'); const item = document.createElement('div'); item.className = role; item.textContent = text; container.appendChild(item); }5.4 口型动画:通过文本长度模拟说话
要实现“开口说话”,最省事的方法是根据回复文本的长度估算说话时长,在时长内持续微调嘴巴开合参数。
function playSpeakAnimation(text) { return new Promise((resolve) => { const coreModel = model.internalModel.coreModel; const duration = Math.min(text.length * 120, 8000); // 每个字约 120ms const startTime = Date.now(); function mouthLoop() { const now = Date.now(); const elapsed = now - startTime; if (elapsed >= duration) { coreModel.setParameterValueById('ParamMouthOpenY', 0); resolve(); return; } // 模拟自然说话:使用正弦波叠加随机幅度 const wave = Math.sin(elapsed / 100) * 0.4 + Math.random() * 0.3; const mouthValue = Math.max(0, Math.min(1, wave)); coreModel.setParameterValueById('ParamMouthOpenY', mouthValue); requestAnimationFrame(mouthLoop); } mouthLoop(); }); }这里的ParamMouthOpenY是 Cubism 标准参数名称,大多数模型都有。如果你的模型嘴巴参数不是这个,可以通过coreModel.parameters找到正确的参数 id。
5.5 完整的联动逻辑
把以上片段组合到一起,一个最小闭环就是:
- 页面加载时渲染 Live2D 模型。
- 用户输入消息。
- 前端把消息发给后端
/api/chat。 - 后端调用大模型,拿到回复。
- 前端把回复显示在消息面板中。
- 同时触发兔子的说话口型动画,营造“她在亲口回复你”的体验。
6. 项目完整目录与部署思路
6.1 完整目录结构
live2d-ai-rabbit/ ├── public/ │ └── assets/ │ └── rabbit/ │ ├── rabbit.model3.json │ ├── rabbit.moc3 │ ├── rabbit.1024/ │ │ └── texture_00.png │ ├── motions/ │ └── expressions/ ├── server/ │ ├── index.js │ ├── .env │ └── package.json ├── src/ │ ├── main.js │ ├── style.css │ └── index.html └── package.json6.2 本地开发启动方式
分别启动后端和前端:
# 终端 1:启动后端 cd server node index.js # 终端 2:启动前端 cd live2d-ai-rabbit npm run dev浏览器访问 Vite 输出的地址,就能看到兔兔渲染出来。
6.3 生产部署注意点
生产环境不要依赖 Vite Dev Server。建议前端构建后将静态文件交给 Nginx 或对象存储,后端单独部署。Nginx 配置里需要把/api/路径反代到 Node.js 服务:
location /api/ { proxy_pass http://127.0.0.1:3001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这样前端请求/api/chat时就不存在跨域问题了,后端代码里的cors()可以视情况保留或收紧。
7. 常见问题与排查思路
7.1 模型加载不出来
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 页面空白,控制台报 404 | model3.json路径不对 | 检查/assets/rabbit/rabbit.model3.json是否可访问 |
| 报错 “Failed to load model” | 模型格式不支持 | 确认模型是 Cubism 4 格式,并安装对应运行时 |
| 贴图全黑或丢失 | 贴图路径大小写不一致 | 保持模型目录原始文件路径,不要重命名 |
还有一种情况是跨域。Live2DModel.from()内部通过 fetch 加载资源,如果模型放在对象存储或 CDN,必须开启 CORS 允许跨域访问。
7.2 参数设置后没有反应
首先确认参数名是否正确。最快的方法:
const model = await Live2DModel.from('/assets/rabbit/rabbit.model3.json'); const params = model.internalModel.coreModel.getModel().parameters; console.table(params.map(p => ({ id: p.id, value: p.value, min: p.min, max: p.max })));然后对照输出结果,找到ParamMouthOpenY、ParamEyeLOpen等标准参数的实际 id。
7.3 AI 接口返回 401 或 429
- 401 表示 API Key 无效或服务商不认这个地址,检查
.env配置。 - 429 表示请求频率超限,说明需要对用户请求做限流,或者使用更高额度的服务。
生产环境建议在后端加一层简单的限流逻辑:
const userRequestCount = new Map(); app.post('/api/chat', async (req, res) => { const userId = req.ip || 'anonymous'; const count = userRequestCount.get(userId) || 0; if (count > 20) { return res.status(429).json({ error: '请求过于频繁,请稍后再试' }); } userRequestCount.set(userId, count + 1); setTimeout(() => userRequestCount.delete(userId), 60 * 1000); // 后续逻辑... });7.4 浏览器内存占用过高
Live2D 模型会占用 GPU 和内存。长时间运行后如果内存持续增长,排查方向包括:
- 是否在循环中反复创建
PIXI.Application实例,正确做法是全局只创建一个。 - 是否在聊天历史里保存了过多消息,建议只保留最近 20 条。
- 模型贴图尺寸是否过大,可以将 2048 贴图压缩为 1024 甚至 512 来减小显存占用。
7.5 模型说话时口型看着不自然
口型不自然通常是嘴巴参数变化太机械导致的。可以叠加两个维度:
- 快速抖动:模拟音节切换。
- 慢速开合:模拟整句话的节奏起伏。
上面代码中的正弦波 + 随机数方案已经够用,真正要调的是duration和振幅比例。建议先录一段真实音频,用音频音量映射到嘴巴参数,效果会自然很多。
8. 最佳实践与工程建议
8.1 模型资源规范化
不要直接把模型丢在 public 根目录。建议按角色名建立独立文件夹,并固定命名规则:
public/models/rabbit/ public/models/human_friend/这样可以一次维护多个角色,后续做“角色切换”功能时,只需替换Live2DModel.from()的 URL。
8.2 聊天上下文管理
AI 对话不是无状态问题。需要把用户的聊天历史和角色性格一起传给模型。但历史消息不能无限增长,否则大模型接口会报 token 超限。建议在服务端维护每个 session 最近 10-20 条消息,或按 token 长度裁剪。
8.3 内容安全
AI 陪伴类应用天然会涉及用户自由输入。在做公开项目时,必须考虑:
- 后端调用大模型前先做基础敏感词过滤,避免恶意内容。
- 大模型返回的内容也要做过滤或审计。
- 不要把用户输入原样输出到前端其他用户的页面上,防止存储型 XSS。
- 未成年用户场景下,需要在系统提示词中强制规定回复边界。
安全底线原则:对不确定的内容,宁可拒绝返回,也不要生成出格回复。
8.4 前端缓存策略
Live2D 资源是一堆静态文件,如果每次都向服务器请求,会很浪费带宽。生产环境建议给模型资源加Cache-Control响应头:
location /assets/ { add_header Cache-Control "public, max-age=86400, immutable"; }8.5 角色性格与对话一致性
陪伴型 AI 最大的体验问题就是角色“时而温柔,时而冷酷”。解决办法是在系统提示词里给出明确人设,并在每次请求时重复传递,而不是只放在第一轮。因为大模型接口是无状态的,并不会自动记住上一轮的系统提示词。
具体做法:
- 在后端维护
SYSTEM_PROMPT常量。 - 每次组装
messages时,第一项都放人设。 - 在人设中补充“禁止使用 ## 标记”、“不要重复自我介绍”、“说话不要超过 80 字”这类硬约束。
8.6 性能优化方向
如果你想让更多用户同时访问,需要关注:
- Live2D 渲染是客户端行为,服务端压力主要来自 AI 接口。
- 可以对 AI 接口做响应缓存,对相同问题在短时间内直接返回已有答案。
- 对图片贴图做压缩,减少模型加载时间。
- 如果模型较大,考虑按需加载,点击“开始对话”后再初始化 Live2D。
9. 项目演示效果与预期
跑通整个项目后,你看到的页面大约长这样:
- 页面左侧是 Live2D 兔兔模型,她会自动呼吸、眨眼,鼠标移过去她会转头“看”你。
- 点击兔子的身体,她会播放一个开心动作,并切换成开心表情。
- 页面右侧是聊天面板,输入“今天好累”后,兔兔会用黏人可爱的语气回复,同时嘴巴持续张合,像是在说话。
- 如果网络断开或 AI 接口失败,页面会提示“AI 服务暂时不可用”,角色停止说话。
到这里,一个“陪伴型 AI 兔兔”的 MVP 就完成了。它足够跑通 Live2D 展示 + 交互反馈 + AI 对话三个关键链路,后续可以继续扩展:
- 给角色加更多动作和表情,按对话情绪自动切换。
- 加入语音合成(TTS),让兔兔真的“说”出来。
- 接入记忆系统,让兔兔记住主人的偏好和重要事项。
- 加入 WebSocket,实现多端消息同步。
这些方向里,最值得优先做的是情感判断与表情联动。现在大模型返回的只是文本,如果能在后端解析出“开心/难过/惊讶”等情绪标签,再来驱动 Live2D 表情参数,整个陪伴体验会上一个台阶。
如果本文对你有帮助,可以收藏备用。实际动手时遇到 Live2D 模型加载、参数命名、AI 接口兼容之类的问题,欢迎在评论区把报错信息发出来,一起排查。