1. 项目概述与核心价值
最近在做一个虚拟展厅的项目,客户提了个挺有意思的需求:希望展厅里的虚拟客服不仅能回答预设问题,还能像真人一样,根据访客的实时提问,进行自然、流畅的对话,并且最好能配上相应的口型和表情。这听起来像是要造个“数字人”,但预算和时间都有限。经过一番技术选型和折腾,我最终用Unity 2020作为呈现端,讯飞星火大模型提供对话大脑,再结合Motionverse这个工具来驱动虚拟形象,成功搭建了一套原型。整个过程踩了不少坑,也积累了一些心得,今天就把这个“手搓”实时对话虚拟客服的完整方案,包括核心的C#代码,分享给大家。
这个方案的核心价值在于,它不是一个简单的“问答机”。传统的虚拟客服大多基于关键词匹配或简单的决策树,对话生硬,扩展性差。而我们这套方案,借助大模型的强大理解和生成能力,能让虚拟客服真正“听懂”用户的自然语言,并生成符合上下文的、有逻辑的回复。再通过Motionverse将文本回复实时转化为带口型、表情和简单动作的驱动数据,最终在Unity中呈现出一个能“察言观色”、对答如流的虚拟形象。它非常适合用于数字展厅、线上培训、智能终端导览、虚拟主播等对交互自然度有要求的场景。
实现路径可以概括为:Unity作为客户端,负责3D场景渲染、虚拟形象动画播放和用户输入采集;当用户输入文本后,Unity通过HTTP请求调用讯飞星火的对话API;获取到AI生成的文本回复后,再将这段文本发送给Motionverse的语音合成与嘴型驱动服务;Motionverse会返回一段音频流和对应的口型动画数据(通常是音素序列或 blendshape 权重序列);最后,Unity同步播放音频并驱动虚拟形象的口型、面部表情,完成一次交互闭环。
2. 技术栈选型与前期准备
2.1 为什么是Unity 2020 + 讯飞星火 + Motionverse?
在项目启动前,我评估了几个主流方案。Unity的选择几乎是必然的,作为成熟的实时3D内容创作平台,它在渲染、动画、跨平台部署(PC、WebGL、移动端)上有巨大优势,生态完善,资源丰富。选择2020 LTS版本是因为它足够稳定,对URP/HDRP管线支持成熟,且相关的插件和社区资源非常丰富,能避免在新版本上遇到不可预见的兼容性问题。
对话引擎方面,我对比了多家国内可商用的大模型API。最终选择讯飞星火大模型,主要基于几点考虑:首先,它的API调用相对简单明了,文档齐全,提供了非常方便的WebSocket和HTTP两种实时交互方式,这对于需要低延迟对话的场景很关键。其次,讯飞在语音领域积累深厚,其大模型在中文理解和生成上表现相当不错,特别是在多轮对话的连贯性上。最后,它的计费模式清晰,有免费的额度可供开发和测试,降低了前期试错成本。
虚拟形象驱动是另一个关键点。完全自己从零开发一套高质量的语音驱动口型(Lip Sync)和表情系统,需要深厚的音频信号处理和动画绑定知识,工期长。Motionverse这类云端服务提供了“文本/语音 -> 驱动数据”的一站式解决方案。你只需要提供文本和虚拟形象的基础模型(通常需要符合其规范,如具有特定的 blendshape 或骨骼),它就能返回匹配的音频文件和对应的面部动画数据序列,极大简化了开发流程。虽然它可能不如一些顶级离线方案那样高度定制化,但对于快速原型和大多数应用场景来说,其效果和性价比都非常出色。
2.2 开发环境与账号准备
工欲善其事,必先利其器。在开始写代码前,需要准备好以下“弹药”:
- Unity环境:安装 Unity Hub,然后通过它安装Unity 2020.3.x LTS版本。建议创建一个空的3D (URP) 项目,URP管线在移动端和性能优化上更有优势。
- 讯飞星火账号:访问讯飞开放平台官网,注册账号并完成实名认证。在控制台创建一个新应用,获取至关重要的API Key、API Secret和AppID。记下星火大模型API的请求地址(例如
wss://spark-api.xf-yun.com/v1.1/chat等,具体版本以文档为准)。 - Motionverse账号:同样,去Motionverse官网注册并创建应用。你需要获取它的API Key。更重要的是,你需要按照其文档准备你的虚拟形象。通常,你需要一个带有标准面部 blendshape(如ARKit定义的52个 blendshape)的FBX模型,并将其上传到Motionverse平台进行绑定和配置。完成后,你会获得一个属于这个形象的Character ID。
- 必要的Unity插件/包:
- Newtonsoft.Json:用于处理复杂的JSON数据序列化与反序列化。可以通过Unity的Package Manager,从“Add package from git URL”添加
https://github.com/jilleJr/Newtonsoft.Json-for-Unity.git#upm来安装。 - UniTask:优秀的异步编程解决方案,能让我们以更优雅的方式处理大量的异步HTTP请求和WebSocket通信,避免回调地狱。可以通过Package Manager搜索
UniTask安装。 - (可选)TextMeshPro:如果你需要在UI上显示漂亮的对话文字,这是必备品。
- Newtonsoft.Json:用于处理复杂的JSON数据序列化与反序列化。可以通过Unity的Package Manager,从“Add package from git URL”添加
注意:讯飞星火和Motionverse的API Key是最高机密,绝对不要直接硬编码在C#脚本里然后提交到Git等版本控制系统。务必使用Unity的
PlayerPrefs、环境变量,或者更专业的方式如配置文件(运行时加载)配合.gitignore来管理。
3. 核心模块设计与代码实现
整个系统可以划分为三个核心模块:大模型对话模块、虚拟形象驱动模块、Unity客户端整合模块。下面我们逐一拆解,并给出关键的C#代码实现。
3.1 大模型对话模块:与讯飞星火实时通信
讯飞星火提供了WebSocket和HTTP两种接口。为了实现更实时的流式响应(AI一边生成,我们一边接收显示),我们选择WebSocket方式。
首先,我们需要构建符合星火API要求的请求数据。星火的对话是基于“消息”列表的,每条消息有角色(user或assistant)和内容。
using System; using System.Collections.Generic; using Newtonsoft.Json; [Serializable] public class SparkMessage { public string role; // “user” 或 “assistant” public string content; } [Serializable] public class SparkRequestPayload { public SparkMessage[] messages; public int max_tokens = 2048; // 回复的最大token数 public float temperature = 0.7f; // 创造性,越高越随机 public int top_k = 4; // 采样策略 public string domain = “general”; // 领域,如“generalv2” // 还有其他参数如auditing等,可根据需要添加 } [Serializable] public class SparkResponse { public SparkResponseHeader header; public SparkResponsePayload payload; } [Serializable] public class SparkResponseHeader { public int code; // 状态码,0表示成功 public string message; public string sid; public int status; // 对话状态,0代表第一轮,1代表持续对话,2代表结束 } [Serializable] public class SparkResponsePayload { public SparkResponseChoices choices; } [Serializable] public class SparkResponseChoices { public int status; // 文本状态,0代表开始,1代表生成中,2代表结束 public int seq; public List<SparkMessage> text; }接下来是核心的WebSocket通信管理器。我们将使用UnityWebRequest或第三方WebSocket库(如NativeWebSocket)来建立连接。这里以逻辑流程为主:
using System; using System.Collections.Generic; using System.Text; using System.Threading; using Cysharp.Threading.Tasks; using Newtonsoft.Json; using UnityEngine; using UnityEngine.Networking; public class SparkAIClient : MonoBehaviour { private string _apiKey; private string _apiSecret; private string _appId; private string _hostUrl = “wss://spark-api.xf-yun.com/v1.1/chat”; private WebSocket _webSocket; private List<SparkMessage> _conversationHistory = new List<SparkMessage>(); private CancellationTokenSource _cts; public void Initialize(string apiKey, string apiSecret, string appId) { _apiKey = apiKey; _apiSecret = apiSecret; _appId = appId; // 通常需要根据apiKey和apiSecret生成鉴权URL,这里省略鉴权参数拼接过程 string authUrl = GenerateAuthUrl(_hostUrl, _apiKey, _apiSecret); ConnectWebSocket(authUrl).Forget(); } private async UniTaskVoid ConnectWebSocket(string url) { _cts = new CancellationTokenSource(); try { _webSocket = new WebSocket(url); // 假设使用某个WebSocket库 await _webSocket.Connect(); Debug.Log(“讯飞星火WebSocket连接成功”); StartListening(); } catch (Exception e) { Debug.LogError($“连接失败: {e.Message}”); } } private async void StartListening() { var buffer = new byte[1024 * 4]; try { while (_webSocket != null && _webSocket.State == WebSocketState.Open) { var result = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), _cts.Token); if (result.MessageType == WebSocketMessageType.Text) { string jsonStr = Encoding.UTF8.GetString(buffer, 0, result.Count); ProcessSparkResponse(jsonStr); } } } catch (OperationCanceledException) { // 任务被取消,正常断开 } catch (Exception e) { Debug.LogError($“接收消息异常: {e.Message}”); } } public async UniTask<string> SendMessageAsync(string userInput) { if (_webSocket?.State != WebSocketState.Open) { throw new InvalidOperationException(“WebSocket未连接”); } // 1. 更新对话历史 _conversationHistory.Add(new SparkMessage { role = “user”, content = userInput }); // 2. 构建请求 var request = new SparkRequestPayload { messages = _conversationHistory.ToArray() }; string requestJson = JsonConvert.SerializeObject(request); var requestData = new { header = new { app_id = _appId }, parameter = new { chat = new { domain = request.domain, max_tokens = request.max_tokens } }, payload = new { message = new { text = request.messages } } }; string finalRequestJson = JsonConvert.SerializeObject(requestData); // 3. 发送请求 byte[] bytesToSend = Encoding.UTF8.GetBytes(finalRequestJson); await _webSocket.SendAsync(new ArraySegment<byte>(bytesToSend), WebSocketMessageType.Text, true, _cts.Token); // 4. 这里通常需要等待异步响应,我们通过事件或UniTask的AsyncReactiveProperty来传递结果 // 假设我们有一个 `UniTaskCompletionSource<string>` 来等待单次回复完成 var completionSource = new UniTaskCompletionSource<string>(); // `ProcessSparkResponse` 方法在收到完整回复后,会调用 `completionSource.TrySetResult` return await completionSource.Task; } private void ProcessSparkResponse(string json) { var response = JsonConvert.DeserializeObject<SparkResponse>(json); if (response.header.code != 0) { Debug.LogError($“讯飞API错误: {response.header.code} - {response.header.message}”); return; } var choice = response.payload.choices.text[0]; string content = choice.content; // 根据status处理流式输出 if (choice.status == 0) { // 开始接收,可以清空当前显示区域 OnAIResponseStarted?.Invoke(); } // 将content追加显示(流式效果) OnAIResponseReceived?.Invoke(content); if (choice.status == 2) { // 生成结束,将AI回复加入历史 _conversationHistory.Add(new SparkMessage { role = “assistant”, content = /* 累积的所有content */ }); OnAIResponseCompleted?.Invoke(/* 完整的回复 */); } } // 定义事件,用于UI更新或其他模块监听 public event Action<string> OnAIResponseReceived; public event Action OnAIResponseStarted; public event Action<string> OnAIResponseCompleted; private void OnDestroy() { _cts?.Cancel(); _webSocket?.CloseAsync().Forget(); } }实操心得:讯飞星火的流式响应(
status为0,1,2)是实现“逐字打印”效果的关键。在ProcessSparkResponse中,不要等到status==2才更新UI,而是在每次收到数据时就追加内容,这样用户体验会好很多。同时,要注意管理对话历史_conversationHistory的长度,避免超出模型的上下文窗口,可以设计一个简单的“滑动窗口”机制,只保留最近N轮对话。
3.2 虚拟形象驱动模块:对接Motionverse
拿到AI生成的文本回复后,下一步就是让虚拟形象“说”出来。Motionverse的API通常接受一段文本,返回音频文件(如MP3)和对应的动画数据(如JSON格式的音素序列或每帧的blendshape权重)。
首先,定义请求和响应的数据结构:
[Serializable] public class MotionverseTTSRequest { public string text; public string character_id; // 你在Motionverse平台配置的角色ID public string voice_type = “zh-CN-XiaoxiaoNeural”; // 音色,可选 public string language = “zh-CN”; // 可能还有其他参数,如语速、音调等 } [Serializable] public class MotionverseTTSResponse { public int code; public string message; public TTSData data; } [Serializable] public class TTSData { public string audio_url; // 音频文件下载地址 public string animation_data_url; // 动画数据文件下载地址 public float duration; // 音频时长(秒) } // 动画数据可能的结构示例(具体以Motionverse文档为准) [Serializable] public class LipSyncData { public float[] timeline; // 时间点数组 public float[][] blendshape_weights; // 每个时间点对应的blendshape权重数组 public string[] blendshape_names; // blendshape名称,与权重对应 }然后,实现一个驱动管理器,负责请求TTS、下载资源、解析并应用动画:
using System; using System.Collections.Generic; using Cysharp.Threading.Tasks; using Newtonsoft.Json; using UnityEngine; using UnityEngine.Networking; public class MotionverseDriver : MonoBehaviour { private string _apiKey; private string _characterId; private string _apiEndpoint = “https://api.motionverse.cn/v1/tts”; // 示例地址 private AudioSource _audioSource; private SkinnedMeshRenderer _faceRenderer; // 假设面部blendshape绑定在这个Renderer上 private Dictionary<string, int> _blendshapeIndexMap = new Dictionary<string, int>(); public void Initialize(string apiKey, string characterId, AudioSource audioSource, SkinnedMeshRenderer faceRenderer) { _apiKey = apiKey; _characterId = characterId; _audioSource = audioSource; _faceRenderer = faceRenderer; // 预构建blendshape名称到索引的映射,提高查询效率 Mesh mesh = _faceRenderer.sharedMesh; for (int i = 0; i < mesh.blendShapeCount; i++) { string shapeName = mesh.GetBlendShapeName(i); _blendshapeIndexMap[shapeName] = i; } } public async UniTask<bool> SpeakTextAsync(string text) { // 1. 请求TTS string audioUrl = null; string animDataUrl = null; try { var requestData = new MotionverseTTSRequest { text = text, character_id = _characterId }; string json = JsonConvert.SerializeObject(requestData); byte[] postData = Encoding.UTF8.GetBytes(json); using (UnityWebRequest request = new UnityWebRequest(_apiEndpoint, “POST”)) { request.uploadHandler = new UploadHandlerRaw(postData); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader(“Content-Type”, “application/json”); request.SetRequestHeader(“Authorization”, $"Bearer {_apiKey}"); await request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogError($“Motionverse TTS请求失败: {request.error}”); return false; } var response = JsonConvert.DeserializeObject<MotionverseTTSResponse>(request.downloadHandler.text); if (response.code != 0) { Debug.LogError($“Motionverse TTS错误: {response.code} - {response.message}”); return false; } audioUrl = response.data.audio_url; animDataUrl = response.data.animation_data_url; } } catch (Exception e) { Debug.LogError($“请求TTS时发生异常: {e.Message}”); return false; } // 2. 并行下载音频和动画数据 var audioClipTask = DownloadAudioClip(audioUrl); var animDataTask = DownloadAnimationData(animDataUrl); await UniTask.WhenAll(audioClipTask, animDataTask); AudioClip clip = audioClipTask.Result; LipSyncData lipSyncData = animDataTask.Result; if (clip == null || lipSyncData == null) { Debug.LogError(“下载音频或动画数据失败”); return false; } // 3. 同步播放音频和驱动口型 PlayAudioWithLipSync(clip, lipSyncData).Forget(); return true; } private async UniTask<AudioClip> DownloadAudioClip(string url) { using (UnityWebRequest request = UnityWebRequestMultimedia.GetAudioClip(url, AudioType.MPEG)) { await request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { return DownloadHandlerAudioClip.GetContent(request); } return null; } } private async UniTask<LipSyncData> DownloadAnimationData(string url) { using (UnityWebRequest request = UnityWebRequest.Get(url)) { await request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { string json = request.downloadHandler.text; return JsonConvert.DeserializeObject<LipSyncData>(json); } return null; } } private async UniTaskVoid PlayAudioWithLipSync(AudioClip clip, LipSyncData data) { _audioSource.clip = clip; _audioSource.Play(); float startTime = Time.time; int currentFrameIndex = 0; float[] timeline = data.timeline; float[][] weights = data.blendshape_weights; string[] names = data.blendshape_names; // 假设动画数据是均匀时间采样的 while (_audioSource.isPlaying) { float elapsed = Time.time - startTime; // 找到当前时间点对应的数据帧 while (currentFrameIndex < timeline.Length - 1 && elapsed > timeline[currentFrameIndex + 1]) { currentFrameIndex++; } if (currentFrameIndex < weights.Length) { // 应用当前帧的blendshape权重 for (int i = 0; i < names.Length; i++) { if (_blendshapeIndexMap.TryGetValue(names[i], out int index)) { _faceRenderer.SetBlendShapeWeight(index, weights[currentFrameIndex][i] * 100f); // 通常权重是0-1,需要乘以100 } } } await UniTask.Yield(); // 每帧更新 } // 播放结束,重置口型 ResetBlendshapes(); } private void ResetBlendshapes() { for (int i = 0; i < _faceRenderer.sharedMesh.blendShapeCount; i++) { _faceRenderer.SetBlendShapeWeight(i, 0f); } } }注意事项:Motionverse返回的动画数据格式一定要仔细阅读其官方文档。上述
LipSyncData结构仅为示例。实际驱动时,更复杂的方案可能涉及动画状态机(Animator Controller),将blendshape权重作为参数传递给动画控制器,再由控制器混合多个面部动画。此外,网络请求和资源下载是异步操作,必须做好错误处理和加载状态提示,避免界面卡死。
3.3 Unity客户端整合:串联一切
现在,我们需要一个“总指挥”来串联用户输入、AI对话和形象驱动。这个管理器通常挂在场景中的一个空物体上。
using Cysharp.Threading.Tasks; using TMPro; using UnityEngine; using UnityEngine.UI; public class VirtualCustomerServiceManager : MonoBehaviour { [Header(“UI References”)] public TMP_InputField userInputField; public Button sendButton; public TextMeshProUGUI dialogText; // 用于显示对话历史 public GameObject thinkingIndicator; // “正在思考”的提示 [Header(“Service Clients”)] public SparkAIClient sparkClient; public MotionverseDriver motionverseDriver; [Header(“3D Character”)] public Animator characterAnimator; // 虚拟形象的Animator public string talkingStateName = “Talking”; // Animator中“说话”状态的名字 public string idleStateName = “Idle”; private bool _isProcessing = false; private string _fullAIReply = “”; private void Start() { sendButton.onClick.AddListener(OnSendButtonClicked); userInputField.onSubmit.AddListener((_) => OnSendButtonClicked()); // 支持回车发送 // 初始化客户端 sparkClient.Initialize(“YOUR_SPARK_API_KEY”, “YOUR_SPARK_SECRET”, “YOUR_APP_ID”); sparkClient.OnAIResponseStarted += OnAIResponseStarted; sparkClient.OnAIResponseReceived += OnAIResponseReceived; sparkClient.OnAIResponseCompleted += OnAIResponseCompleted; // 假设MotionverseDriver已在Inspector中配置好AudioSource和SkinnedMeshRenderer } private async void OnSendButtonClicked() { string userText = userInputField.text.Trim(); if (string.IsNullOrEmpty(userText) || _isProcessing) { return; } _isProcessing = true; userInputField.interactable = false; sendButton.interactable = false; thinkingIndicator.SetActive(true); // 1. 将用户输入显示到对话框 AppendToDialog($“你: {userText}”); userInputField.text = “”; // 2. 触发角色“倾听”或“思考”动画 characterAnimator.Play(“Thinking”); try { // 3. 发送给讯飞星火并等待回复 _fullAIReply = await sparkClient.SendMessageAsync(userText); // 注意:SendMessageAsync 内部需要适配我们之前用事件流式接收的方式。 // 这里假设它返回的是最终完整的字符串。实际流式处理在事件回调中。 } catch (System.Exception e) { AppendToDialog($“系统: 抱歉,AI服务暂时无法响应。({e.Message})”); _isProcessing = false; thinkingIndicator.SetActive(false); userInputField.interactable = true; sendButton.interactable = true; characterAnimator.Play(idleStateName); return; } // AI回复的显示和驱动将在事件回调中处理 } private void OnAIResponseStarted() { // 清空或准备显示AI回复的区域 _fullAIReply = “”; AppendToDialog(“客服: “); thinkingIndicator.SetActive(false); // 切换到说话动画 characterAnimator.Play(talkingStateName); } private void OnAIResponseReceived(string partialText) { // 流式追加显示 _fullAIReply += partialText; UpdateLastDialogLine($“客服: {_fullAIReply}”); } private async void OnAIResponseCompleted(string fullText) { // 确保显示最终完整文本 UpdateLastDialogLine($“客服: {fullText}”); // 4. 调用Motionverse驱动虚拟形象说话 bool success = await motionverseDriver.SpeakTextAsync(fullText); if (!success) { Debug.LogWarning(“语音合成或驱动失败,将仅显示文字。”); // 即使驱动失败,也播放一个默认的说话动画或直接回到空闲状态 } // 5. 等待语音播放完毕(MotionverseDriver内部会控制) // 我们可以通过监听AudioSource的播放结束,或者简单等待一个估计的时间 // 这里假设MotionverseDriver.SpeakTextAsync在播放完成后才返回 // 实际上,SpeakTextAsync可能立即返回,播放是异步的。我们需要另一种同步机制,例如事件。 // 为了简化,我们这里不等待,由MotionverseDriver在播放完成后自动触发角色回到Idle状态。 // 我们可以在MotionverseDriver里添加一个播放完成的事件,在这里订阅。 OnCharacterFinishedSpeaking(); } private void OnCharacterFinishedSpeaking() { _isProcessing = false; userInputField.interactable = true; sendButton.interactable = true; characterAnimator.Play(idleStateName); } private void AppendToDialog(string line) { dialogText.text += $“\n{line}”; // 可选:自动滚动到最底部 } private void UpdateLastDialogLine(string newLine) { // 简单的实现:找到最后一行替换 string[] lines = dialogText.text.Split(‘\n’); if (lines.Length > 0) { lines[lines.Length - 1] = newLine; dialogText.text = string.Join(“\n”, lines); } } private void OnDestroy() { if (sparkClient != null) { sparkClient.OnAIResponseStarted -= OnAIResponseStarted; sparkClient.OnAIResponseReceived -= OnAIResponseReceived; sparkClient.OnAIResponseCompleted -= OnAIResponseCompleted; } } }这个管理器将UI事件、网络通信、动画控制串联起来,形成了一个完整的交互闭环。用户输入 -> AI思考(流式回复) -> 文本显示 -> 语音合成与口型驱动 -> 动画播放 -> 回归待机。
4. 性能优化与进阶技巧
实现基本功能后,要投入实际应用,还需要考虑性能和体验的优化。
4.1 资源管理与缓存
频繁请求TTS会产生网络延迟和流量消耗。一个重要的优化点是缓存。
- 对话缓存:对于相同的用户输入,AI可能会给出相同或相似的回复。可以在本地建立一个简单的键值对缓存(
Dictionary<string, CachedReply>),键是用户输入文本的Hash,值包含AI回复文本、对应的音频文件本地路径和动画数据。下次遇到相同输入时,直接使用本地资源,大幅提升响应速度。 - 音频与动画数据缓存:将
MotionverseDriver下载的音频Clip和动画数据JSON文件保存到Application.persistentDataPath。在请求前先检查缓存是否存在且未过期。 - 内存管理:注意
AudioClip和动画数据的内存占用。对于不再需要的对话资源,及时使用Resources.UnloadAsset或Destroy来释放。可以使用对象池管理频繁使用的资源。
4.2 动画融合与自然度提升
直接套用Motionverse返回的blendshape数据可能会显得生硬。
- 平滑插值:在
PlayAudioWithLipSync的每帧更新中,不要直接将目标权重设置给Renderer,而是使用Mathf.Lerp或Mathf.MoveTowards进行平滑过渡,消除权重的突变,使口型变化更柔和。 - 基础表情混合:除了口型,可以添加一些随机的、微小的面部动作,如眨眼、轻微的眉毛上扬或下压,让角色看起来更生动。这可以通过一个独立的协程来控制几个基础的blendshape,与口型动画叠加。
- 身体动画:让角色完全静止说话会很奇怪。在Animator Controller中,可以设置一个“Talking”层,当播放语音时,该层权重设为1,播放一些轻微的站姿摆动、手势动画(如思考时托腮、讲解时手势等)。这些动画可以做成多个片段,根据对话内容或随机触发。
4.3 网络与错误处理强化
- 超时与重试:为
UnityWebRequest设置timeout属性,并实现重试逻辑。对于非致命的临时网络错误,可以自动重试1-2次。 - 降级方案:当Motionverse服务不可用时,应有降级方案。例如,可以回退到使用Unity自带的
UnityEngine.Windows.Speech或其它离线TTS引擎合成语音(虽然音质和口型匹配会差很多),或者至少让角色播放一个通用的“说话”动画并显示文字气泡。 - 心跳与重连:对于WebSocket连接,需要实现心跳机制(定期发送Ping/Pong)来保持连接活跃,并监听连接断开事件,自动尝试重连。
4.4 扩展功能设想
- 情绪识别与表达:可以对接情感分析API,对用户输入文本进行情绪判断(积极、消极、愤怒等),然后将情绪标签传递给Motionverse(如果其API支持),或者根据情绪标签在Unity端切换不同的角色基础表情(Blendshape组合)和身体动画。
- 多模态输入:结合Unity的麦克风输入 (
Microphone类) 和语音识别插件(如Unity自制的Unity语音识别模块或第三方SDK),实现语音输入,让交互更自然。 - 知识库定制:讯飞星火大模型支持“联网搜索”和“文档上传”。你可以将产品的FAQ、展厅介绍等文档上传,构建专属知识库,让AI客服的回答更精准、专业。
5. 常见问题与排查实录
在开发和调试过程中,我遇到了不少典型问题,这里列出来供大家参考。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| Unity编辑器运行正常,打包后无法连接WebSocket | 1. 平台兼容性(如WebGL的WebSocket支持)。 2. 安全策略(CORS)。 3. API Key未正确配置到打包后的环境。 | 1.WebGL平台:确认使用的WebSocket库支持WebGL。检查Player Settings中是否启用了相应的网络API。可能需要处理wss://证书问题。2.所有平台:确保API Key不是硬编码,而是通过配置文件或启动参数注入。检查防火墙或安全软件是否阻止了出站连接。 |
| 虚拟形象口型与语音不同步 | 1. 音频播放和动画数据应用的时间基准不一致。 2. 动画数据的时间戳 ( timeline) 与音频采样率不匹配。3. Unity帧率波动导致动画更新不及时。 | 1.统一时钟:确保音频播放 (AudioSource.time) 和动画驱动都基于同一个时间源(如AudioSettings.dspTime或音频播放开始时的Time.time)。2.数据校验:检查Motionverse返回的 duration是否与timeline的最后一个值吻合。如果不吻合,可能需要按比例缩放时间轴。3.使用FixedUpdate:对于口型动画更新,可以考虑在 FixedUpdate中进行,以保证固定的物理帧率,或者使用Time.unscaledDeltaTime进行与帧率无关的插值。 |
| 讯飞星火回复内容不相关或胡言乱语 | 1.temperature参数设置过高,导致随机性太强。2. 对话历史 ( messages) 过长或混乱,超出模型上下文。3. 提示词 ( system角色消息) 未设置或设置不当。 | 1.调整参数:将temperature调低(如0.3-0.5),使输出更确定。调整top_k或top_p。2.管理历史:实现对话历史截断。只保留最近10轮对话,或者在每轮新对话前,用 system角色重新明确指令。3.优化提示词:在 messages数组开头,加入一个role为”system”的消息,内容为“你是一个专业的展厅客服,请用友好、简洁的语言回答问题。如果不知道,请如实告知。” 这能极大地引导AI行为。 |
| Motionverse返回的动画数据无法驱动模型 | 1. 虚拟形象的Blendshape命名与Motionverse数据中的blendshape_names不匹配。2. Blendshape权重范围不匹配(Motionverse返回0-1,Unity需要0-100)。 3. 使用的Renderer不正确(如用了LOD组中的低模)。 | 1.名称映射:打印出模型所有的Blendshape名称和Motionverse返回的名称,进行比对。可能需要一个映射表进行转换。确保上传到Motionverse的模型是最终驱动用的高模。 2.权重转换:确认权重转换公式。 SetBlendShapeWeight需要0-100的值。3.调试显示:写一个调试脚本,将接收到的权重实时以Slider或数字形式显示在UI上,确认数据是否正确接收和解析。 |
| 在移动设备上运行卡顿或发热严重 | 1. 每帧更新所有Blendshape权重,计算量大。 2. 音频解码和动画数据解析在主线程进行,阻塞。 3. 未使用的资源未及时释放,内存泄漏。 | 1.减少更新频率:不是每帧都更新所有Blendshape。可以每2-3帧更新一次,或者只更新变化幅度超过阈值的Blendshape。 2.异步操作:确保所有网络请求、文件下载、JSON解析都在异步任务中完成,避免阻塞主线程。使用 UniTask或async/await。3.性能分析:使用Unity Profiler查看CPU和GPU开销,定位瓶颈。检查是否存在频繁的GC Alloc(如每帧new数组/列表)。 |
最后再分享一个小技巧:在开发初期,可以先用本地预录的音频和动画序列来模拟Motionverse的服务,这样能快速搭建和调试Unity端的播放与驱动逻辑,而不受网络波动和API调用的限制。等核心流程跑通后,再切换到真实的云端服务集成,这样能大大提高开发效率。这套方案虽然涉及多个服务集成,但每个模块的职责相对清晰,拆解开来一步步实现,你会发现打造一个能实时对话的虚拟客服,并没有想象中那么遥不可及。