This commit is contained in:
yinsx
2025-12-24 15:15:13 +08:00
commit df085f3f8f
27 changed files with 1926 additions and 0 deletions

140
examples/3d/SDK_USAGE.md Normal file
View File

@ -0,0 +1,140 @@
# BlendShape Animator SDK 使用文档
## 概述
BlendShapeAnimator 是一个引擎无关的形态键动画SDK可以与任何3D引擎配合使用。
## 核心概念
### 形态键适配器 (Morph Target Adapter)
适配器是连接SDK和3D引擎的桥梁需要实现以下接口
```javascript
{
setInfluence(name, value) {
// 设置形态键的影响值 (0-1)
},
getInfluence(name) {
// 获取形态键的当前影响值
return 0;
}
}
```
### 初始化配置
```javascript
const config = {
blendShapeScale: 1.0, // 形态键强度缩放
dataFps: 30, // 动画数据帧率
onStatusChange: (type, msg) => {
// 状态变化回调
},
blinkParams: { // 眨眼参数
intervalMin: 2000,
intervalMax: 5000,
duration: 150,
speed: 100
},
eyeLookParams: { // 眼球移动参数
intervalMin: 2000,
intervalMax: 6000,
durationMin: 1000,
durationMax: 2500,
speed: 250
},
expressionParams: { // 表情参数
intervalMin: 3000,
intervalMax: 8000,
speed: 400
}
};
```
## 使用示例
```javascript
// 1. 创建适配器
const adapter = new BabylonMorphTargetAdapter();
// 2. 初始化SDK
const animator = new BlendShapeAnimator({
blendShapeScale: 1.0,
onStatusChange: (type, msg) => console.log(type, msg)
});
// 3. 设置适配器
animator.setMorphTargetAdapter(adapter);
// 4. 加载模型后构建缓存
BABYLON.SceneLoader.ImportMesh("", "./", "model.glb", scene, (meshes) => {
adapter.buildCache(meshes);
});
// 5. 加载并播放动画
animator.loadAnimationFrames(animationData.frames);
animator.playAnimation();
```
## API 参考
### 核心方法
**`setMorphTargetAdapter(adapter)`**
设置形态键适配器
**`loadAnimationFrames(frames)`**
加载动画帧数据
**`playAnimation()`**
播放动画
**`stopAnimation()`**
停止动画
**`setIdleAnimation(name, target, duration, easing)`**
设置空闲动画
- `name`: 形态键名称
- `target`: 目标值 (0-1)
- `duration`: 持续时间 (ms)
- `easing`: 缓动函数名称
### 空闲动画控制
**`toggleBlink(enabled)`**
开启/关闭随机眨眼
**`toggleEyeLook(enabled)`**
开启/关闭眼球移动
**`toggleRandomExpression(enabled)`**
开启/关闭随机表情
### 配置更新
**`updateConfig(key, value)`**
更新配置参数
## 动画数据格式
```javascript
{
frames: [
{
blendShapes: {
"jawOpen": 0.5,
"mouthSmileLeft": 0.3,
"mouthSmileRight": 0.3
}
}
]
}
```
## 注意事项
- 形态键名称会自动转换为小写进行匹配
- SDK使用 `requestAnimationFrame` 进行动画更新
- 空闲动画会在主动画播放时自动暂停
- 所有形态键值范围为 0-1

View File

@ -0,0 +1,76 @@
// Babylon.js 形态键适配器
class BabylonMorphTargetAdapter {
constructor() {
this.morphTargetCache = {};
}
buildCache(meshes) {
this.morphTargetCache = {};
let totalTargets = 0;
meshes.forEach(mesh => {
const mtm = mesh.morphTargetManager;
if (!mtm) return;
console.log(`网格 ${mesh.name}: ${mtm.numTargets} 个形态键`);
for (let i = 0; i < mtm.numTargets; i++) {
const mt = mtm.getTarget(i);
if (!mt?.name) continue;
const lowerName = mt.name.toLowerCase();
if (!this.morphTargetCache[lowerName]) {
this.morphTargetCache[lowerName] = [];
}
this.morphTargetCache[lowerName].push(mt);
totalTargets++;
if (i < 3) {
console.log(` ${mt.name} -> ${lowerName}`);
}
}
});
console.log(`总计: ${totalTargets} 个形态键映射`);
return totalTargets;
}
setInfluence(name, value) {
const lowerName = name.toLowerCase();
const targets = this.morphTargetCache[lowerName];
if (targets?.length) {
targets.forEach(mt => {
mt.influence = value;
});
}
}
getInfluence(name) {
const lowerName = name.toLowerCase();
const targets = this.morphTargetCache[lowerName];
if (targets?.length) {
return targets[0].influence;
}
return 0;
}
resetAll() {
for (const targets of Object.values(this.morphTargetCache)) {
targets.forEach(mt => {
mt.influence = 0;
});
}
}
getCacheSize() {
return Object.keys(this.morphTargetCache).length;
}
}
// 导出到全局
if (typeof window !== 'undefined') {
window.BabylonMorphTargetAdapter = BabylonMorphTargetAdapter;
}

View File

@ -0,0 +1,63 @@
// Babylon.js 场景管理器
class BabylonSceneManager {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.engine = new BABYLON.Engine(this.canvas, true);
this.scene = null;
this.camera = null;
this.onModelLoaded = null;
}
init() {
this.scene = new BABYLON.Scene(this.engine);
this.scene.clearColor = new BABYLON.Color3(0.1, 0.1, 0.1);
this.camera = new BABYLON.ArcRotateCamera(
"camera",
0,
Math.PI / 2,
2,
new BABYLON.Vector3(0, 1.5, 0),
this.scene
);
this.camera.attachControl(this.canvas, true);
this.camera.minZ = 0.01;
this.camera.lowerRadiusLimit = 1;
this.camera.upperRadiusLimit = 5;
this.scene.createDefaultEnvironment();
this.engine.runRenderLoop(() => {
this.scene.render();
});
window.addEventListener('resize', () => {
this.engine.resize();
});
}
loadModel(modelPath, onSuccess, onError) {
BABYLON.SceneLoader.ImportMesh("", "./", modelPath, this.scene,
(meshes) => {
this.scene.lights.forEach(light => {
light.intensity = 50;
});
if (onSuccess) {
onSuccess(meshes);
}
},
null,
(scene, message) => {
if (onError) {
onError(message);
}
}
);
}
}
// 导出到全局
if (typeof window !== 'undefined') {
window.BabylonSceneManager = BabylonSceneManager;
}

View File

@ -0,0 +1,377 @@
// BlendShape Animation SDK - Engine Agnostic
class BlendShapeAnimator {
constructor(config = {}) {
this.morphTargetAdapter = null;
this.animationFrames = [];
this.isPlaying = false;
this.currentFrameIndex = 0;
this.animationStartTime = 0;
this.idleAnimations = {};
this.blendShapeScale = config.blendShapeScale || 1.0;
this.dataFps = config.dataFps || 30;
// 空闲动画参数
this.blinkParams = config.blinkParams || {
intervalMin: 2000,
intervalMax: 5000,
duration: 150,
speed: 100
};
this.eyeLookParams = config.eyeLookParams || {
intervalMin: 2000,
intervalMax: 6000,
durationMin: 1000,
durationMax: 2500,
speed: 250
};
this.expressionParams = config.expressionParams || {
intervalMin: 3000,
intervalMax: 8000,
speed: 400
};
this.enabledExpressions = new Set();
this.expressionDurations = {};
// 状态标志
this.isBlinkEnabled = false;
this.isEyeLookEnabled = false;
this.isExpressionEnabled = false;
// 定时器
this.blinkInterval = null;
this.eyeLookInterval = null;
// 回调
this.onStatusChange = config.onStatusChange || (() => {});
// 启动空闲动画循环
this._updateIdleAnimations();
}
// 设置形态键适配器
setMorphTargetAdapter(adapter) {
this.morphTargetAdapter = adapter;
}
// 加载动画帧数据
loadAnimationFrames(frames) {
this.animationFrames = frames;
}
// 播放动画
playAnimation() {
if (this.animationFrames.length === 0) {
this.onStatusChange('error', '请先加载动画数据');
return;
}
if (!this.morphTargetAdapter) {
this.onStatusChange('error', '未设置形态键适配器');
return;
}
// 停止随机表情
if (this.isExpressionEnabled && window.ExpressionLibrary) {
window.ExpressionLibrary.randomPlayer.stop();
}
this.stopAnimation();
this.isPlaying = true;
this.currentFrameIndex = 0;
this.animationStartTime = performance.now();
this._animateFrame();
this.onStatusChange('info', '播放中...');
}
// 停止动画
stopAnimation() {
this.isPlaying = false;
// 恢复随机表情
if (this.isExpressionEnabled && window.ExpressionLibrary) {
window.ExpressionLibrary.randomPlayer.start();
}
this.onStatusChange('info', '已停止');
}
// 内部动画帧处理
_animateFrame() {
if (!this.isPlaying) return;
const now = performance.now();
const frameDuration = 1000 / this.dataFps;
const elapsed = now - this.animationStartTime;
const exactFrame = elapsed / frameDuration;
const targetFrameIndex = Math.floor(exactFrame);
if (targetFrameIndex >= this.animationFrames.length) {
this.stopAnimation();
return;
}
const currentFrame = this.animationFrames[targetFrameIndex];
const nextFrame = this.animationFrames[Math.min(targetFrameIndex + 1, this.animationFrames.length - 1)];
const frameProgress = exactFrame - targetFrameIndex;
const smoothProgress = this._easeOutQuad(frameProgress);
for (const key in currentFrame.blendShapes) {
const currentValue = currentFrame.blendShapes[key] || 0;
const nextValue = nextFrame.blendShapes[key] || 0;
const interpolatedValue = this._lerp(currentValue, nextValue, smoothProgress);
const scaledValue = interpolatedValue * this.blendShapeScale;
this.morphTargetAdapter.setInfluence(key, scaledValue);
}
this.currentFrameIndex = targetFrameIndex;
requestAnimationFrame(() => this._animateFrame());
}
// 设置空闲动画
setIdleAnimation(name, target, duration = 200, easing = 'easeOutQuad') {
this.idleAnimations[name] = {
target: target,
duration: duration,
easing: easing,
startTime: null,
startValue: this.idleAnimations[name]?.target || 0
};
}
// 更新空闲动画
_updateIdleAnimations() {
const now = performance.now();
for (const name in this.idleAnimations) {
const anim = this.idleAnimations[name];
if (!this.morphTargetAdapter) continue;
if (!anim.startTime) {
anim.startTime = now;
anim.startValue = this.morphTargetAdapter.getInfluence(name) || 0;
}
const elapsed = now - anim.startTime;
const duration = anim.duration || 200;
let progress = Math.min(elapsed / duration, 1.0);
progress = this._applyEasing(progress, anim.easing);
const currentValue = this._lerp(anim.startValue, anim.target, progress);
this.morphTargetAdapter.setInfluence(name, currentValue);
if (progress >= 1.0) {
if (anim.target === 0) {
delete this.idleAnimations[name];
} else {
anim.startTime = null;
}
}
}
requestAnimationFrame(() => this._updateIdleAnimations());
}
// 眨眼控制
toggleBlink(enabled) {
this.isBlinkEnabled = enabled;
if (enabled) {
this._startRandomBlink();
this.onStatusChange('success', '随机眨眼已开启');
} else {
this._stopRandomBlink();
this.onStatusChange('info', '随机眨眼已关闭');
}
}
_startRandomBlink() {
this._stopRandomBlink();
const scheduleNext = () => {
const delay = this.blinkParams.intervalMin + Math.random() * (this.blinkParams.intervalMax - this.blinkParams.intervalMin);
this.blinkInterval = setTimeout(() => {
if (this.isBlinkEnabled) {
this._doBlink();
scheduleNext();
}
}, delay);
};
scheduleNext();
}
_stopRandomBlink() {
if (this.blinkInterval) {
clearTimeout(this.blinkInterval);
this.blinkInterval = null;
}
}
_doBlink() {
const blinkShapes = ['eyeblinkleft', 'eyeblinkright'];
blinkShapes.forEach(name => {
this.setIdleAnimation(name, 1.0, this.blinkParams.speed, 'easeOutQuad');
});
setTimeout(() => {
blinkShapes.forEach(name => {
this.setIdleAnimation(name, 0, this.blinkParams.speed + 20, 'easeInOutQuad');
});
}, this.blinkParams.duration);
}
// 眼球移动控制
toggleEyeLook(enabled) {
this.isEyeLookEnabled = enabled;
if (enabled) {
this._startRandomEyeLook();
this.onStatusChange('success', '眼球移动已开启');
} else {
this._stopRandomEyeLook();
this._resetEyeLook();
this.onStatusChange('info', '眼球移动已关闭');
}
}
_startRandomEyeLook() {
this._stopRandomEyeLook();
const scheduleNext = () => {
const delay = this.eyeLookParams.intervalMin + Math.random() * (this.eyeLookParams.intervalMax - this.eyeLookParams.intervalMin);
this.eyeLookInterval = setTimeout(() => {
if (this.isEyeLookEnabled) {
this._doRandomEyeLook();
scheduleNext();
}
}, delay);
};
scheduleNext();
}
_stopRandomEyeLook() {
if (this.eyeLookInterval) {
clearTimeout(this.eyeLookInterval);
this.eyeLookInterval = null;
}
}
_doRandomEyeLook() {
const directions = [
{ shapes: {} },
{ shapes: { 'eyelookupleft': 0.3, 'eyelookupright': 0.3 } },
{ shapes: { 'eyelookdownleft': 0.3, 'eyelookdownright': 0.3 } },
{ shapes: { 'eyelookinleft': 0.4, 'eyelookoutright': 0.4 } },
{ shapes: { 'eyelookoutleft': 0.4, 'eyelookinright': 0.4 } }
];
const direction = directions[Math.floor(Math.random() * directions.length)];
const eyeLookShapes = [
'eyelookupleft', 'eyelookupright',
'eyelookdownleft', 'eyelookdownright',
'eyelookinleft', 'eyelookinright',
'eyelookoutleft', 'eyelookoutright'
];
eyeLookShapes.forEach(name => {
this.setIdleAnimation(name, 0, this.eyeLookParams.speed * 0.8, 'easeInOutQuad');
});
for (const [name, value] of Object.entries(direction.shapes)) {
this.setIdleAnimation(name, value, this.eyeLookParams.speed, 'easeInOutQuad');
}
const holdDuration = this.eyeLookParams.durationMin + Math.random() * (this.eyeLookParams.durationMax - this.eyeLookParams.durationMin);
setTimeout(() => {
if (this.isEyeLookEnabled) {
eyeLookShapes.forEach(name => {
this.setIdleAnimation(name, 0, this.eyeLookParams.speed * 1.2, 'easeInOutQuad');
});
}
}, holdDuration);
}
_resetEyeLook() {
const eyeLookShapes = [
'eyelookupleft', 'eyelookupright',
'eyelookdownleft', 'eyelookdownright',
'eyelookinleft', 'eyelookinright',
'eyelookoutleft', 'eyelookoutright'
];
eyeLookShapes.forEach(name => {
this.setIdleAnimation(name, 0, this.eyeLookParams.speed, 'easeInOutQuad');
});
}
// 随机表情控制
toggleRandomExpression(enabled) {
if (!window.ExpressionLibrary) {
this.onStatusChange('error', '表情库未加载');
return;
}
this.isExpressionEnabled = enabled;
if (enabled) {
window.ExpressionLibrary.randomPlayer.intervalMin = this.expressionParams.intervalMin;
window.ExpressionLibrary.randomPlayer.intervalMax = this.expressionParams.intervalMax;
if (!this.isPlaying) {
window.ExpressionLibrary.randomPlayer.start();
}
this.onStatusChange('success', '随机表情已开启');
} else {
window.ExpressionLibrary.randomPlayer.stop();
this.onStatusChange('info', '随机表情已关闭');
}
}
// 缓动函数
_easeOutQuad(t) {
return t * (2 - t);
}
_lerp(start, end, t) {
return start + (end - start) * t;
}
_applyEasing(t, type) {
switch(type) {
case 'easeOutQuad':
return t * (2 - t);
case 'easeInOutQuad':
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
case 'easeInOutCubic':
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
case 'easeOutCubic':
return 1 - Math.pow(1 - t, 3);
case 'linear':
default:
return t;
}
}
// 更新配置
updateConfig(key, value) {
if (key === 'blendShapeScale') {
this.blendShapeScale = value;
} else if (this[key]) {
this[key] = value;
}
}
}
// 导出到全局
if (typeof window !== 'undefined') {
window.BlendShapeAnimator = BlendShapeAnimator;
}

View File

@ -0,0 +1,247 @@
// 表情库系统
const ExpressionLibrary = {
expressions: {
// 原有表情
smile: {
name: '微笑',
blendShapes: {
mouthsmileleft: 0.4,
mouthsmileright: 0.4
},
duration: 2000
},
disgust: {
name: '嫌弃',
blendShapes: {
mouthupperupright: 0.43,
browdownleft: 1,
browdownright: 1,
nosesneerleft: 1,
nosesneerright: 1
},
duration: 2500
},
smirk: {
name: '冷笑',
blendShapes: {
eyeblinkleft: 0.41,
eyeblinkright: 0.41,
mouthsmileleft: 0.3
},
duration: 2000
},
grit: {
name: '咬牙切齿',
blendShapes: {
mouthrolllower: 0.3,
mouthrollupper: 0.44,
mouthshruglower: 0.42,
mouthshrugupper: 0.44,
browdownleft: 1,
browdownright: 1,
nosesneerleft: 1,
nosesneerright: 1
},
duration: 2500
},
// 新增表情
surprise: {
name: '惊讶',
blendShapes: {
eyewidenleft: 0.8,
eyewidenright: 0.8,
browouterupright: 0.7,
browouterupleft: 0.7,
jawopen: 0.4,
mouthfunnel: 0.3
},
duration: 2000
},
sad: {
name: '悲伤',
blendShapes: {
browinnerup: 0.8,
mouthfrownleft: 0.6,
mouthfrownright: 0.6,
mouthlowerdownleft: 0.4,
mouthlowerdownright: 0.4
},
duration: 3000
},
angry: {
name: '生气',
blendShapes: {
browdownleft: 1,
browdownright: 1,
eyesquintleft: 0.5,
eyesquintright: 0.5,
nosesneerleft: 0.7,
nosesneerright: 0.7,
mouthpressleft: 0.6,
mouthpressright: 0.6
},
duration: 2500
},
thinking: {
name: '思考',
blendShapes: {
eyesquintleft: 0.3,
eyesquintright: 0.3,
mouthpucker: 0.4,
eyelookupleft: 0.3,
eyelookupright: 0.3
},
duration: 3000
},
happy: {
name: '开心',
blendShapes: {
mouthsmileleft: 0.8,
mouthsmileright: 0.8,
eyesquintleft: 0.4,
eyesquintright: 0.4,
cheeksquintleft: 0.6,
cheeksquintright: 0.6
},
duration: 2500
},
confused: {
name: '困惑',
blendShapes: {
browouterupleft: 0.6,
browdownright: 0.5,
mouthfrownleft: 0.3,
eyesquintright: 0.3
},
duration: 2500
}
},
// 随机表情播放器
randomPlayer: {
enabled: false,
timeout: null,
currentExpression: null,
intervalMin: 3000,
intervalMax: 8000,
start: function() {
this.enabled = true;
this.scheduleNext();
},
stop: function() {
this.enabled = false;
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.reset();
},
scheduleNext: function() {
if (!this.enabled) return;
const delay = this.intervalMin + Math.random() * (this.intervalMax - this.intervalMin);
this.timeout = setTimeout(() => {
this.playRandom();
}, delay);
},
playRandom: function() {
if (!this.enabled) return;
// 获取启用的表情列表
const enabledKeys = window.enabledExpressions && window.enabledExpressions.size > 0
? Array.from(window.enabledExpressions)
: Object.keys(ExpressionLibrary.expressions);
if (enabledKeys.length === 0) {
this.scheduleNext();
return;
}
const randomKey = enabledKeys[Math.floor(Math.random() * enabledKeys.length)];
const expression = ExpressionLibrary.expressions[randomKey];
this.play(expression);
},
play: function(expression) {
this.currentExpression = expression;
// 从主页面获取动画速度参数
const speed = window.expressionParams?.speed || 400;
// 应用表情
for (const [name, value] of Object.entries(expression.blendShapes)) {
if (window.setIdleAnimation) {
window.setIdleAnimation(name, value, speed, 'easeInOutCubic');
}
}
// 获取表情的key
const expressionKey = Object.keys(ExpressionLibrary.expressions).find(
key => ExpressionLibrary.expressions[key] === expression
);
// 使用用户设置的持续时间,如果没有则使用表情默认时间
const duration = (window.expressionDurations && window.expressionDurations[expressionKey])
|| expression.duration;
setTimeout(() => {
this.reset();
this.scheduleNext();
}, duration);
},
reset: function() {
if (!this.currentExpression) return;
// 从主页面获取参数
const speed = window.expressionParams?.speed || 400;
// 重置表情
for (const name of Object.keys(this.currentExpression.blendShapes)) {
if (window.setIdleAnimation) {
window.setIdleAnimation(name, 0, speed + 100, 'easeInOutCubic');
}
}
this.currentExpression = null;
}
},
// 手动播放指定表情
playExpression: function(expressionName) {
const expression = this.expressions[expressionName];
if (!expression) {
console.warn(`表情 "${expressionName}" 不存在`);
return;
}
this.randomPlayer.play(expression);
},
// 获取所有表情名称
getExpressionNames: function() {
return Object.keys(this.expressions).map(key => ({
key: key,
name: this.expressions[key].name
}));
}
};
// 导出到全局
if (typeof window !== 'undefined') {
window.ExpressionLibrary = ExpressionLibrary;
}

BIN
examples/3d/head_a01.glb Normal file

Binary file not shown.

143
examples/3d/index.html Normal file
View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Digital Human - BlendShapes Demo</title>
<link rel="stylesheet" href="./styles.css">
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
<script src="./blendshapeAnimator.js"></script>
<script src="./babylonAdapter.js"></script>
<script src="./babylonScene.js"></script>
<script src="./expressionLibrary.js"></script>
</head>
<body>
<canvas id="renderCanvas"></canvas>
<div class="controls">
<h2>数字人形态键控制</h2>
<div class="input-group">
<label>文字输入</label>
<textarea id="textInput" placeholder="输入文字生成动画...">君不见,黄河之水天上来,奔流到海不复回,君不见,高堂明镜悲白发,朝如青丝暮成雪。</textarea>
</div>
<div class="input-group">
<label>API 地址</label>
<input type="text" id="apiUrl" value="http://localhost:5001/text-to-blendshapes">
</div>
<div class="input-group">
<label>形态键强度: <span id="scaleValue">1.0</span></label>
<input type="range" id="scaleSlider" min="0" max="2" step="0.1" value="1.0"
oninput="updateScale(this.value)"
style="width: 100%; cursor: pointer;">
</div>
<button id="generateBtn" onclick="generateAnimation()">生成动画</button>
<button onclick="playAnimation()">播放动画</button>
<button onclick="stopAnimation()">停止动画</button>
<button onclick="testBlendShape()">测试形态键</button>
<button onclick="resetBlendShapes()">重置形态键</button>
<div class="status" id="status"></div>
</div>
<div class="idle-controls">
<h2>空闲动画控制</h2>
<!-- 随机眨眼 -->
<div class="checkbox-group">
<label>
<button class="collapse-btn" onclick="toggleCollapse(event, 'blinkParams')"></button>
<input type="checkbox" id="blinkEnabled" onchange="toggleBlink()">
<span>随机眨眼</span>
</label>
<div class="param-group" id="blinkParams">
<div class="param-item">
<label>频率间隔 (秒): <span class="param-value" id="blinkIntervalValue">2-5</span></label>
<input type="range" id="blinkIntervalMin" min="1" max="5" step="0.5" value="2"
oninput="updateBlinkInterval()">
<input type="range" id="blinkIntervalMax" min="3" max="10" step="0.5" value="5"
oninput="updateBlinkInterval()">
</div>
<div class="param-item">
<label>闭眼时长 (ms): <span class="param-value" id="blinkDurationValue">150</span></label>
<input type="range" id="blinkDuration" min="100" max="300" step="10" value="150"
oninput="updateBlinkDuration(this.value)">
</div>
<div class="param-item">
<label>动画速度 (ms): <span class="param-value" id="blinkSpeedValue">100</span></label>
<input type="range" id="blinkSpeed" min="50" max="200" step="10" value="100"
oninput="updateBlinkSpeed(this.value)">
</div>
</div>
</div>
<!-- 眼球移动 -->
<div class="checkbox-group">
<label>
<button class="collapse-btn" onclick="toggleCollapse(event, 'eyeLookParams')"></button>
<input type="checkbox" id="eyeLookEnabled" onchange="toggleEyeLook()">
<span>眼球移动</span>
</label>
<div class="param-group" id="eyeLookParams">
<div class="param-item">
<label>频率间隔 (秒): <span class="param-value" id="eyeLookIntervalValue">2-6</span></label>
<input type="range" id="eyeLookIntervalMin" min="1" max="5" step="0.5" value="2"
oninput="updateEyeLookInterval()">
<input type="range" id="eyeLookIntervalMax" min="3" max="10" step="0.5" value="6"
oninput="updateEyeLookInterval()">
</div>
<div class="param-item">
<label>停留时长 (ms): <span class="param-value" id="eyeLookDurationValue">1000-2500</span></label>
<input type="range" id="eyeLookDurationMin" min="500" max="2000" step="100" value="1000"
oninput="updateEyeLookDuration()">
<input type="range" id="eyeLookDurationMax" min="1000" max="4000" step="100" value="2500"
oninput="updateEyeLookDuration()">
</div>
<div class="param-item">
<label>移动速度 (ms): <span class="param-value" id="eyeLookSpeedValue">250</span></label>
<input type="range" id="eyeLookSpeed" min="100" max="500" step="25" value="250"
oninput="updateEyeLookSpeed(this.value)">
</div>
</div>
</div>
<!-- 随机表情 -->
<div class="checkbox-group">
<label>
<button class="collapse-btn" onclick="toggleCollapse(event, 'expressionParams')"></button>
<input type="checkbox" id="expressionEnabled" onchange="toggleRandomExpression()">
<span>随机表情</span>
</label>
<div class="param-group" id="expressionParams">
<div class="param-item">
<label>选择表情:</label>
<div class="expression-list" id="expressionList">
<!-- 动态生成 -->
</div>
</div>
<div class="param-item">
<label>频率间隔 (秒): <span class="param-value" id="expressionIntervalValue">3-8</span></label>
<input type="range" id="expressionIntervalMin" min="2" max="8" step="0.5" value="3"
oninput="updateExpressionInterval()">
<input type="range" id="expressionIntervalMax" min="5" max="15" step="0.5" value="8"
oninput="updateExpressionInterval()">
</div>
<div class="param-item">
<label>动画速度 (ms): <span class="param-value" id="expressionSpeedValue">400</span></label>
<input type="range" id="expressionSpeed" min="200" max="800" step="50" value="400"
oninput="updateExpressionSpeed(this.value)">
</div>
</div>
</div>
</div>
<script src="./main.js"></script>
</body>
</html>

308
examples/3d/main.js Normal file
View File

@ -0,0 +1,308 @@
// 全局变量
let sceneManager;
let morphAdapter;
let animator;
let enabledExpressions = new Set();
let expressionDurations = {};
window.addEventListener('DOMContentLoaded', function () {
init();
initCollapseStates();
initExpressionList();
});
function init() {
// 初始化场景管理器
sceneManager = new BabylonSceneManager('renderCanvas');
sceneManager.init();
// 初始化形态键适配器
morphAdapter = new BabylonMorphTargetAdapter();
// 初始化动画SDK
animator = new BlendShapeAnimator({
blendShapeScale: 1.0,
dataFps: 30,
onStatusChange: showStatus
});
animator.setMorphTargetAdapter(morphAdapter);
// 导出全局变量供表情库使用
window.setIdleAnimation = (name, target, duration, easing) => {
animator.setIdleAnimation(name, target, duration, easing);
};
window.expressionParams = animator.expressionParams;
window.enabledExpressions = enabledExpressions;
window.expressionDurations = expressionDurations;
// 加载3D模型
sceneManager.loadModel('head_a01.glb',
(meshes) => {
showStatus("模型加载成功", "success");
const totalTargets = morphAdapter.buildCache(meshes);
if (totalTargets === 0) {
showStatus("警告: 未找到形态键", "error");
} else {
console.log(`✓ 构建缓存完成,共 ${totalTargets} 个形态键`);
}
},
(message) => {
showStatus("模型加载失败: " + message, "error");
}
);
}
async function generateAnimation() {
const text = document.getElementById('textInput').value.trim();
const apiUrl = document.getElementById('apiUrl').value;
const btn = document.getElementById('generateBtn');
if (!text) {
showStatus("请输入文字", "error");
return;
}
btn.disabled = true;
showStatus("生成中...", "info");
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text, language: 'zh-CN' })
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '请求失败');
}
animator.loadAnimationFrames(data.frames);
console.log("动画数据:", data.frames);
showStatus(`动画生成成功!共 ${data.frames.length}`, "success");
} catch (err) {
showStatus("错误: " + err.message, "error");
} finally {
btn.disabled = false;
}
}
function playAnimation() {
animator.playAnimation();
}
function stopAnimation() {
animator.stopAnimation();
}
function updateScale(value) {
animator.updateConfig('blendShapeScale', parseFloat(value));
document.getElementById('scaleValue').textContent = value;
}
function testBlendShape() {
if (morphAdapter.getCacheSize() === 0) {
showStatus("模型未加载", "error");
return;
}
const testShapes = {
'jawopen': 1.0,
'mouthsmileleft': 1.0,
'mouthsmileright': 1.0
};
let successCount = 0;
for (const [name, value] of Object.entries(testShapes)) {
morphAdapter.setInfluence(name, value);
if (morphAdapter.getInfluence(name) > 0) {
console.log(`${name} = ${value}`);
successCount++;
} else {
console.warn(`✗ 未找到: ${name}`);
}
}
showStatus(`测试: ${successCount}/3 成功`, successCount > 0 ? "success" : "error");
}
function resetBlendShapes() {
morphAdapter.resetAll();
showStatus("已重置", "info");
}
function toggleBlink() {
const enabled = document.getElementById('blinkEnabled').checked;
animator.toggleBlink(enabled);
}
function toggleEyeLook() {
const enabled = document.getElementById('eyeLookEnabled').checked;
animator.toggleEyeLook(enabled);
}
function toggleRandomExpression() {
const enabled = document.getElementById('expressionEnabled').checked;
animator.toggleRandomExpression(enabled);
}
// 参数更新函数
function updateBlinkInterval() {
const min = parseFloat(document.getElementById('blinkIntervalMin').value) * 1000;
const max = parseFloat(document.getElementById('blinkIntervalMax').value) * 1000;
animator.blinkParams.intervalMin = min;
animator.blinkParams.intervalMax = max;
document.getElementById('blinkIntervalValue').textContent = `${min/1000}-${max/1000}`;
}
function updateBlinkDuration(value) {
animator.blinkParams.duration = parseInt(value);
document.getElementById('blinkDurationValue').textContent = value;
}
function updateBlinkSpeed(value) {
animator.blinkParams.speed = parseInt(value);
document.getElementById('blinkSpeedValue').textContent = value;
}
function updateEyeLookInterval() {
const min = parseFloat(document.getElementById('eyeLookIntervalMin').value) * 1000;
const max = parseFloat(document.getElementById('eyeLookIntervalMax').value) * 1000;
animator.eyeLookParams.intervalMin = min;
animator.eyeLookParams.intervalMax = max;
document.getElementById('eyeLookIntervalValue').textContent = `${min/1000}-${max/1000}`;
}
function updateEyeLookDuration() {
const min = parseInt(document.getElementById('eyeLookDurationMin').value);
const max = parseInt(document.getElementById('eyeLookDurationMax').value);
animator.eyeLookParams.durationMin = min;
animator.eyeLookParams.durationMax = max;
document.getElementById('eyeLookDurationValue').textContent = `${min}-${max}`;
}
function updateEyeLookSpeed(value) {
animator.eyeLookParams.speed = parseInt(value);
document.getElementById('eyeLookSpeedValue').textContent = value;
}
function updateExpressionInterval() {
const min = parseFloat(document.getElementById('expressionIntervalMin').value) * 1000;
const max = parseFloat(document.getElementById('expressionIntervalMax').value) * 1000;
animator.expressionParams.intervalMin = min;
animator.expressionParams.intervalMax = max;
document.getElementById('expressionIntervalValue').textContent = `${min/1000}-${max/1000}`;
if (window.ExpressionLibrary) {
window.ExpressionLibrary.randomPlayer.intervalMin = min;
window.ExpressionLibrary.randomPlayer.intervalMax = max;
}
}
function updateExpressionSpeed(value) {
animator.expressionParams.speed = parseInt(value);
document.getElementById('expressionSpeedValue').textContent = value;
}
// 折叠/展开功能
function toggleCollapse(event, paramGroupId) {
event.preventDefault();
event.stopPropagation();
const paramGroup = document.getElementById(paramGroupId);
const btn = event.target;
if (paramGroup.classList.contains('collapsed')) {
paramGroup.classList.remove('collapsed');
paramGroup.style.maxHeight = paramGroup.scrollHeight + 'px';
btn.classList.remove('collapsed');
} else {
paramGroup.style.maxHeight = paramGroup.scrollHeight + 'px';
setTimeout(() => {
paramGroup.classList.add('collapsed');
btn.classList.add('collapsed');
}, 10);
}
}
function initCollapseStates() {
const paramGroups = ['blinkParams', 'eyeLookParams', 'expressionParams'];
paramGroups.forEach(id => {
const paramGroup = document.getElementById(id);
const btn = paramGroup.previousElementSibling.querySelector('.collapse-btn');
paramGroup.classList.add('collapsed');
btn.classList.add('collapsed');
});
}
function initExpressionList() {
if (!window.ExpressionLibrary) {
setTimeout(initExpressionList, 100);
return;
}
const expressionList = document.getElementById('expressionList');
const expressions = window.ExpressionLibrary.getExpressionNames();
expressions.forEach(expr => {
enabledExpressions.add(expr.key);
const exprData = window.ExpressionLibrary.expressions[expr.key];
expressionDurations[expr.key] = exprData.duration;
});
expressions.forEach(expr => {
const item = document.createElement('div');
item.className = 'expression-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `expr_${expr.key}`;
checkbox.checked = true;
checkbox.onchange = () => toggleExpression(expr.key, checkbox.checked);
const label = document.createElement('label');
label.textContent = expr.name;
label.htmlFor = `expr_${expr.key}`;
const durationInput = document.createElement('input');
durationInput.type = 'number';
durationInput.min = '500';
durationInput.max = '10000';
durationInput.step = '100';
durationInput.value = expressionDurations[expr.key];
durationInput.onchange = () => updateExpressionDuration(expr.key, durationInput.value);
const durationLabel = document.createElement('span');
durationLabel.className = 'duration-label';
durationLabel.textContent = 'ms';
item.appendChild(checkbox);
item.appendChild(label);
item.appendChild(durationInput);
item.appendChild(durationLabel);
expressionList.appendChild(item);
});
}
function updateExpressionDuration(key, value) {
expressionDurations[key] = parseInt(value);
}
function toggleExpression(key, enabled) {
if (enabled) {
enabledExpressions.add(key);
} else {
enabledExpressions.delete(key);
}
}
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = 'status show ' + type;
}

270
examples/3d/styles.css Normal file
View File

@ -0,0 +1,270 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow: hidden;
background: #1a1a1a;
}
#renderCanvas {
width: 100%;
height: 100vh;
display: block;
touch-action: none;
}
.controls {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 10px;
color: white;
max-width: 350px;
max-height: 90vh;
overflow-y: auto;
}
.idle-controls {
position: absolute;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
padding: 20px;
border-radius: 10px;
color: white;
width: 320px;
max-height: 90vh;
overflow-y: auto;
}
.controls h2 {
margin-bottom: 15px;
font-size: 18px;
}
.input-group {
margin-bottom: 15px;
}
.input-group label {
display: block;
margin-bottom: 5px;
font-size: 12px;
color: #aaa;
}
input[type="text"],
textarea {
width: 100%;
padding: 8px;
border: 1px solid #444;
border-radius: 4px;
background: #2a2a2a;
color: white;
font-size: 12px;
}
textarea {
resize: vertical;
min-height: 60px;
}
button {
width: 100%;
padding: 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
margin-top: 10px;
}
button:hover {
opacity: 0.9;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
font-size: 12px;
display: none;
}
.status.show {
display: block;
}
.status.success {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.status.error {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.status.info {
background: rgba(33, 150, 243, 0.2);
color: #2196f3;
}
.checkbox-group {
margin-bottom: 20px;
padding: 15px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.checkbox-group label {
display: flex;
align-items: center;
cursor: pointer;
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
width: 100%;
}
.checkbox-group > label {
margin-bottom: 0;
}
.checkbox-group label > span {
flex: 1;
color: white;
display: inline-block;
}
.checkbox-group input[type="checkbox"] {
width: 18px;
height: 18px;
margin-right: 10px;
cursor: pointer;
flex-shrink: 0;
}
.collapse-btn {
margin-right: 10px;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #aaa;
cursor: pointer;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.3s ease;
transform-origin: center;
user-select: none;
flex-shrink: 0;
width: 32px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.collapse-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.collapse-btn.collapsed {
transform: rotate(-90deg);
}
.param-group {
margin-top: 10px;
padding-left: 28px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.param-group.collapsed {
max-height: 0 !important;
margin-top: 0;
}
.param-item {
margin-bottom: 10px;
}
.param-item label {
display: block;
font-size: 11px;
color: #aaa;
margin-bottom: 3px;
}
.param-item input[type="range"] {
width: 100%;
cursor: pointer;
}
.param-value {
display: inline-block;
min-width: 50px;
text-align: right;
font-size: 11px;
color: #4caf50;
}
.expression-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.expression-item {
display: flex;
align-items: center;
font-size: 12px;
gap: 8px;
}
.expression-item input[type="checkbox"] {
width: 14px;
height: 14px;
margin: 0;
flex-shrink: 0;
}
.expression-item label {
flex: 1;
margin: 0;
cursor: pointer;
font-size: 12px;
}
.expression-item input[type="number"] {
width: 50px;
padding: 2px 4px;
border: 1px solid #444;
border-radius: 3px;
background: #2a2a2a;
color: white;
font-size: 11px;
text-align: center;
}
.expression-item .duration-label {
font-size: 10px;
color: #888;
white-space: nowrap;
}