在 3D 产品展示和展厅演示里,模型动画通常只解决“动起来”的问题。但真实交付场景里,动画还需要承担讲解职责:模型转到某个角度时播放一句介绍,设备部件打开时播一段语音,动画循环时保持背景音乐。本文复盘 SceneView 中“模型自带动画播放到指定时间触发音频”的开发过程,重点讲清楚如何把模型动画、时间轴预览、语音 cue 和音频播放生命周期串成一条稳定链路。
一、需求拆解
这个功能表面上是给模型动画加音频,本质上包含四个子系统:
- 动画配置:每条模型动画需要保存背景音乐、循环开关和多个时间点语音。
- 时间轴编辑:用户可以播放、暂停、拖动到指定时间,并用当前时间新增语音点。
- 运行时触发:动画播放过程中检测当前时间是否跨过某个 cue,跨过就播放对应语音。
- 生命周期控制:切场景、停动画、切换动画、再次加载场景时,音频状态需要被正确清理和恢复。
如果只在点击播放时简单调用 new Audio(url).play(),初期演示也许能跑通,但很快会遇到叠音、场景重载不播放、拖动时间轴重复触发、背景音乐和语音互相覆盖等问题。
二、数据结构:把音频挂到模型动画配置上
SceneView 的模型动画来自 GLB/FBX 内置 clip。每个 clip 在场景配置中对应一条 type: 'model' 的动画记录,音频配置直接挂到该动画记录的 audio 字段下:
const modelAnimation = {
id: 'anim-xxx',
type: 'model',
name: 'OpenDoor',
targetId: 'model-1',
speed: 1,
duration: 6.4,
loop: true,
active: true,
audio: {
backgroundUrl: 'https://cdn.example.com/bg.mp3',
backgroundLoop: true,
voiceCues: [
{ time: 1.2, url: 'https://cdn.example.com/step-1.mp3' },
{ time: 3.8, url: 'https://cdn.example.com/step-2.mp3' }
]
}
}
这个结构的好处是保存和加载都很直接:场景序列化时 animations 已经会进入 JSON,音频配置跟随动画一起保存。再次加载场景时,只要动画配置能和模型 clip 重新匹配,音频也自然回到运行时。
三、时间轴编辑:用预览事件隔离编辑态
编辑弹窗里的时间轴提供播放、暂停、拖动定位和“用当前时间新增语音”。这里有一个关键设计:预览编辑不直接修改动画的 active 状态,而是通过事件把预览请求发送给 Viewport。
function dispatchModelAnimPreview(action) {
window.dispatchEvent(new CustomEvent('preview-model-animation', {
detail: {
action,
modelId: form.targetId,
name: form.name,
time: form.currentTime,
duration: form.duration,
playing: form.playing
}
}))
}
这样做有两个目的。第一,编辑弹窗可以临时播放某个动画,不污染保存状态。第二,Viewport 仍然是唯一掌握 AnimationMixer、AnimationAction 和真实 clip 时长的地方,时间轴不会和渲染层各自维护一套动画状态。
四、真实时长回填:不要相信默认配置
模型自带动画的真实时长来自 AnimationClip.duration。编辑面板第一次打开时会向 Viewport 请求动画时长,Viewport 从对应 action 的 clip 中读取真实值并回填:
function requestModelAnimationDuration(modelId, name, callback) {
const mixerState = modelMixers[modelId]
const action = mixerState?.actions.find(item => item.getClip().name === name)
const duration = action?.getClip()?.duration || 0
callback(duration)
}
这个回填非常重要。语音 cue 的时间点必须基于真实动画长度,尤其是 FBX 动画存在 clip duration 与最后关键帧时间不一致的情况时,时间轴范围需要跟运行时保持一致。
五、运行时检测:从上一帧时间跨到当前帧时间
播放语音 cue 不能只判断 currentTime === cue.time,因为渲染帧率和动画步进都是离散的。正确做法是记录上一帧时间,判断动画时间是否从上一帧跨过了 cue 时间:
function updateModelAnimationVoiceCues(key, anim, action) {
const audio = anim.audio || {}
if (!Array.isArray(audio.voiceCues)) return
const state = getModelAnimationAudioState(key)
const currentTime = Number(action.time || 0)
const previousTime = state.lastTime
const wrapped = previousTime !== null && currentTime < previousTime
for (const cue of audio.voiceCues) {
const cueTime = Number(cue.time || 0)
const cueUrl = String(cue.url || '').trim()
if (!cueUrl || !Number.isFinite(cueTime)) continue
const crossed = previousTime === null
? cueTime <= currentTime
: wrapped
? cueTime > previousTime || cueTime <= currentTime
: cueTime > previousTime && cueTime <= currentTime
if (crossed) {
playModelAnimationVoiceCue(state, cueUrl)
}
}
state.lastTime = currentTime
}
这里还处理了循环动画的回绕:当 currentTime 小于 previousTime 时,说明动画从末尾回到开头。此时 cue 可能位于“上一帧到末尾”或“开头到当前帧”两个区间内。
六、单音频播放:背景音乐和语音共享一个播放入口
产品演示里最容易让用户困惑的是叠音。背景音乐正在播放时,时间点语音开始了;或者上一段语音尚未结束,下一段语音又启动了。为保持讲解清晰,运行时只允许一个模型动画音频处于播放状态。
let currentModelAnimationAudio = null
function activateModelAnimationAudio(audio, key, kind) {
if (currentModelAnimationAudio?.audio && currentModelAnimationAudio.audio !== audio) {
stopCurrentModelAnimationAudio()
}
currentModelAnimationAudio = { audio, key, kind }
}
function stopCurrentModelAnimationAudio() {
const current = currentModelAnimationAudio
if (!current?.audio) return
current.audio.pause()
current.audio.currentTime = 0
currentModelAnimationAudio = null
}
语音优先级高于背景音乐。语音播放期间,背景音乐同步逻辑会暂时跳过,避免背景音乐在下一帧又被重新拉起。这样能保证“下一段音频开始前先关闭上一段音频”的规则在所有入口都成立。
七、场景重载与浏览器播放策略
再次加载场景后音频不播放,常见原因有两个。第一,旧场景的音频状态没有清理,新的 action 和旧音频状态错位。第二,浏览器自动播放策略会拦截异步加载后发起的 audio.play()。
清理问题通过场景切换时统一执行 stopAllModelAnimationAudio() 解决;自动播放拦截则需要保留待播放音频,并在下一次用户点击或按键时重试:
let pendingModelAnimationAudioPlay = null
function playModelAnimationAudio(audio, key, kind) {
activateModelAnimationAudio(audio, key, kind)
pendingModelAnimationAudioPlay = { audio, key, kind }
audio.play()
.then(() => {
if (pendingModelAnimationAudioPlay?.audio === audio) {
pendingModelAnimationAudioPlay = null
}
})
.catch(() => {
// 保留 pending,等待下一次用户交互重试
})
}
window.addEventListener('pointerdown', retryPendingModelAnimationAudioPlay, true)
window.addEventListener('keydown', retryPendingModelAnimationAudioPlay, true)
这一步让“再次加载场景后播放动画”更稳定:如果浏览器允许播放,音频会直接启动;如果被策略拦截,用户下一次点击页面或按键时会继续播放当前待播音频。
八、边界情况
- 拖动时间轴:拖动时只 seek 动画,不重复播放之前已经跨过的语音。
- 暂停预览:预览暂停时停止对应音频,避免画面停住但声音继续。
- 切换动画:打开另一条动画编辑前先停止旧预览,避免旧 action 和旧音频继续运行。
- 资源类型:MP3 等音频资源在资源列表中按音频类型展示,避免当作图片缩略图加载。
- 场景卸载:组件卸载、场景切换、动画停止时统一清理背景音乐、语音集合和待播放状态。
九、修改文件清单
3d-editor-lite/src/components/layout/EditorRightPanel.vue
- 模型动画编辑弹窗
- 时间轴播放、暂停、seek 和语音 cue 编辑
- 音频配置保存到 anim.audio
3d-editor-lite/src/components/viewport/EditorViewport.vue
- AnimationAction 预览控制
- 背景音乐与时间点语音触发
- 单音频播放约束
- 浏览器播放失败后的用户交互重试
3d-editor-lite/src/stores/resourceStore.js
- 音频资源类型识别
- MP3 等音频文件的资源列表展示支持
十、结论
模型动画音频时间轴的核心并不在 UI,而在状态边界:编辑态和运行态要隔离,动画时间和音频 cue 要用真实 clip 时间对齐,音频播放要有唯一入口,场景重载和浏览器播放策略要被纳入生命周期设计。
当这些边界被理顺后,功能就具备了可交付性:用户可以在编辑器里精确标记语音时间点,保存场景后再次打开仍能恢复,播放过程中也不会出现多个音频互相叠加的混乱体验。