模型动画播放到指定时间触发音频:一条可保存、可预览、可重载的时间轴方案

在 3D 产品展示和展厅演示里,模型动画通常只解决“动起来”的问题。但真实交付场景里,动画还需要承担讲解职责:模型转到某个角度时播放一句介绍,设备部件打开时播一段语音,动画循环时保持背景音乐。本文复盘 SceneView 中“模型自带动画播放到指定时间触发音频”的开发过程,重点讲清楚如何把模型动画、时间轴预览、语音 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 仍然是唯一掌握 AnimationMixerAnimationAction 和真实 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)

这一步让“再次加载场景后播放动画”更稳定:如果浏览器允许播放,音频会直接启动;如果被策略拦截,用户下一次点击页面或按键时会继续播放当前待播音频。

八、边界情况

九、修改文件清单

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 时间对齐,音频播放要有唯一入口,场景重载和浏览器播放策略要被纳入生命周期设计。

当这些边界被理顺后,功能就具备了可交付性:用户可以在编辑器里精确标记语音时间点,保存场景后再次打开仍能恢复,播放过程中也不会出现多个音频互相叠加的混乱体验。