ThreeJS+ChatGPT 实现前端3D数字人AI互动

9,137 阅读5分钟

这里主要从前端方向讲解AI互动的实现,通过3D数字人问答功能的实例讲解前端具体的实现方式,至于后端接口部分只会讲解实现思路。

ThreeJS实现AI互动的底层技术原理是变形动画,通过变形动画实现数字人的面部表情,说话时的嘴型,来达成用户提问,3D数字人应答的效果,变形动画的使用可以看我之前的文章《ThreeJS 变形动画 geometry.morphTargets 详解》

准备模型

想要实现面部动作,嘴型变化,那么模型需要具备BlendShape(后面简称BS),而且所具备BS数必须达到实现效果的标准,可以看一下官网的模型

image.png 我这里使用的是自己的模型,通过GUI我们可以尝试调节每个BS来看看效果,下面代码关键步骤创建动作混合器,这样后面的面部动作都可以通过它来完成

const loader = new FBXLoader();
  const fbxUrl = new URL(
    `./resources/base/BS_mouth/csf_face_bs.fbx`,
    import.meta.url
  ).href; // 模型地址
  loader.load(fbxUrl, model => {
    console.log(model);

    model.traverse(o => {
      if (o.isMesh) {
        // 找到面部的mesh
        if (o.name === "f_csk_face_002") {
          // 创建混合器
          mixer = new THREE.AnimationMixer(o);

          // 创建GUI
          const gui = new GUI();
          gui.close(); // 默认为关闭状态

          // 添加GUI的操作监听
          for (const [key, value] of Object.entries(
            o.morphTargetDictionary
          )) {
            gui
              .add(o.morphTargetInfluences, value, 0, 1, 0.01)
              .name(key.replace("blendShape1.", ""))
              .listen(o.morphTargetInfluences);
          }
        }
      }
    });

    modelGroup.add(model); // 添加进场景
    setModelAttributes(1); // 设置模型位置和缩放大小
  });

调用接口并得到BS数据

其实AI互动核心的功能是后端实现的,接口返回BS按时间轴变化的数据,然后前端解析并运行BS动画(变形动画)就能实现互动效果

这里的接口使用的语言模型并不是ChatGPT的,但是你们可以基于ChatGPT来实现,简单的说一下实现思路:

  • 通过向ChatGPT提问,得到回答的文字内容
  • 把文字内容通过阿里的TTS转为语音和音素信息,这里的音素信息是:比如转的是“你好”,得到的结果n i h ao,这种汉语拼音字母表中的音节,以及内个音节对应的时间点,例如n 25毫秒开始,i 30毫秒开始,h 45毫秒开始,ao 55毫秒开始
  • 我们需要提前录制好汉语拼音字母表中的每个音节所对应的人脸BS数据,可以通过面捕工具来录制(我们采用的Live Link Face),就能得到每个音节对应的面部表情BS数据
  • 通过音节与BS数据的对应关系,组装成接口数据返回给接口调用者

image.png

这里是我们接口返回的数据,infoList中包含的语音信息和BS信息,因为有时候一段回复可能比较长,就采用了分段的形式来返回,bsList中包含的是一句话的完整BS数据,按时间变化,objectName是TTS转语音之后的音频地址,bsList和objectName的时长是一致的。

解析数据

因为这里的接口数据采用的是将一段话分为多句,我们就一句一句的去解析并生成相应结果,如果这里的解析操作有看不懂的,可以好好看一下《ThreeJS 变形动画 geometry.morphTargets 详解》

/** 获取动画数据 这里是解析某一句话的BS动画*/
const getAnimationClip = bsList => {
  const tracks = []; // 关键帧动画数据集
  const morphs = {}; // 每个BS按时间轴顺序变化的权重值
  const timeAxis = []; // 时间轴,取结尾时间

  // 因为模型的BS数量以及命名不一定与接口数据完全重合,所以我们自己做了一个映射关系
  // BSNameMap 模型中BS名称与接口BS名称对应关系
  // BSIndexMap 模型中BS名称在变形动画权重数组中的下标对应关系
  Object.keys(BSIndexMap).forEach(key => {
    morphs[key] = []; // 初始化morphs
  });

  bsList.forEach((ele, index) => {
    const { endTime } = ele;
    timeAxis.push(endTime / 1000); // push到时间轴中
    // 按时间节点给BS权重赋值
    Object.keys(morphs).forEach(key => {
      // 如果能从接口数据中取到BS权重,则push给当前BS
      if (ele[BSNameMap[key]]) {
        morphs[key].push(ele[BSNameMap[key]]);
      } else {
        // 如果取不到则沿用上一个时间节点的数据或者设置为默认权重0
        morphs[key].push(morphs[key][index - 1] || 0);
      }
    });
  });
  console.log(morphs);

  // 根据morphs来生成每个BS按时间轴变化的关键帧动画
  Object.keys(morphs).forEach(key => {
    // 控制BS权重值按时间轴timeAxis进行变化
    const track = new THREE.KeyframeTrack(
      `.morphTargetInfluences[${BSIndexMap[key]}]`,
      timeAxis,
      morphs[key]
    );
    tracks.push(track);
  });

  // 创建一个剪辑clip对象,命名"default"
  // 这个剪辑对象可以用来播放,播放后的动作就是当前解析的这句话的面部表情动作
  const clip = new THREE.AnimationClip(
    "default",
    timeAxis[timeAxis.length - 1],
    tracks
  );

  return clip;
};

当然也需要解析音频数据

/** 下载音频 */
const getAudio = objectName => {
  return new Promise((resolve, reject) => {
    const data = {
      objectName
    };
    // 通过接口下载音频
    downloadFile(data)
      .then((res: any) => {
        // 根据二进制流生成audio对象并返回
        const blob = new Blob([res.data], { type: "audio/wav" });
        const url = window.URL.createObjectURL(blob);
        const audio: any = document.createElement("audio");
        audio.src = url;
        audio.oncanplay = () => {
          resolve(audio);
        };
      })
      .catch(e => {
        reject(e);
      });
  });
};

播放AI互动效果

接口返回给我们数据之后,通过上面的解析,我们将所有解析的数据存储在一个数组中

  // 调用接口
  chat(data).then(async (res: IResult) => {
    const { code, data } = res;
    if (code === 200) {
      const { history: history_, infoList: infoList_ } = data;
      history.value = history_;
      // 解析并存储
      infoList.value = [];
      for (let i = 0; i < infoList_.length; i++) {
        const ele = infoList_[i];
        const audio = await getAudio(ele.objectName);
        const clip = getAnimationClip(ele.bsList);
        infoList.value.push({
          ...ele,
          audio,
          clip
        });
      }
      // 播放
      playBS();
    }

播放的话就比较简单了,因为音频的时长和BS动画的时长是一致的,只要保证他们同时播放就ok,并在其播放完成之后再播放下一个

/** 播放bs */
let playIndex = 0; // 当前播放的下标
let AnimationAction;
const playBS = () => {
  // 判断是否全部都播放完毕
  if (playIndex < infoList.value.length) {
    infoList.value[playIndex].audio.play(); // 播放音频
    AnimationAction = mixer.clipAction(infoList.value[playIndex].clip); //返回动画操作对象
    AnimationAction.loop = THREE.LoopOnce; //不循环播放
    AnimationAction.reset();
    AnimationAction.play();
    infoList.value[playIndex].audio.onended = () => {
      playIndex++; // 播放完成之后继续播放下一个
      playBS();
    };
  }
};

接下来我们看一下实际效果,因为只是测试demo,很多细节都没优化,但是实现思路就是按照上面的来实现的,需要真正的使用时还是需要优化一下效果的。

AI互动.gif