Unity TMP打字机平滑淡入效果:原理、实现与性能优化

📅 2026/8/7 6:23:12
Unity TMP打字机平滑淡入效果:原理、实现与性能优化
1. 项目概述为什么我们需要平滑的字符淡入在Unity里做游戏尤其是那些带点叙事性的项目对话系统几乎是绕不开的一环。而对话系统的“门面”很大程度上就是那个让文字一个字一个字蹦出来的“打字机效果”。用Unity自带的UI Text或者更强大的TextMeshProTMP实现一个基础打字机并不难无非就是控制maxVisibleCharacters属性。但不知道你有没有这种感觉当文字“咔哒咔哒”地硬切出来时总少了那么点“质感”尤其是在追求电影感叙事或者氛围渲染的游戏里生硬的字符出现会打断玩家的沉浸感。这就是“平滑字符淡入”要解决的问题。它不是一个刚需功能却是一个能显著提升视觉体验和情感表达的“甜点”。想象一下在悬疑游戏里一条关键的线索信息伴随着字符如幽灵般缓缓浮现或者在温馨的对话中文字像呼吸一样柔和地显现。这种细腻的动画过渡远比生硬的“开关”效果更能牵动玩家的情绪。网络上能找到的实现大多只解决了“有”和“无”的问题对于如何实现真正平滑、可控、高性能的淡入以及其中涉及的TMP底层原理和性能陷阱讨论得并不深入。今天我就结合自己踩过的坑和优化经验手把手带你实现一个功能完整、代码清晰、可直接用于生产环境的TMP打字机淡入组件并附上完整的C#源码。2. 核心思路拆解从硬切到平滑过渡的底层逻辑要理解如何实现淡入首先得明白TMP是如何渲染文字的。TMP不像传统的UI Text它本质上是通过动态生成网格Mesh来渲染每一个字符的。每个字符都是一个由多个顶点构成的小多边形。TMP为这些顶点预定义了丰富的属性比如位置、UV坐标还有我们今天要用的关键——顶点颜色Vertex Color。一个基础打字机效果的核心代码通常是这样的textComponent.maxVisibleCharacters currentVisibleCount;currentVisibleCount从0递增到文本总长度。TMP内部会根据这个值决定渲染哪些字符的网格。在默认情况下字符的顶点颜色是完全不透明的alpha1。当maxVisibleCharacters变化时新出现的字符网格会立刻以不透明状态被绘制出来这就是“硬切”。要让字符淡入我们需要干预这个过程。核心思路是在字符网格被“激活”渲染的同一帧我们手动修改其所有顶点的颜色Alpha值并将其从0完全透明逐渐动画到1完全不透明。这里就引出了第一个关键选择在哪里修改顶点颜色你可能会想到在Update里每帧遍历所有顶点。但这在长文本下是性能灾难。TMP为我们提供了一个完美的切入点OnPreRenderText事件。这是一个TMP在即将提交网格数据给CanvasRenderer进行渲染前调用的回调。在这里修改顶点数据效率最高因为它只在文本需要更新时如maxVisibleCharacters变化、文本内容变化时触发。我们的实现方案流程图如下驱动一个协程Coroutine或异步任务负责按时间间隔递增currentVisibleCount并触发TMP的SetVerticesDirty()来标记顶点数据需要更新。挂钩我们的脚本订阅TMP的TMPro_EventManager.TEXT_CHANGED事件或直接重写OnPreRenderText方法确保在每次文本网格重建时都能介入。干预在顶点数据提交前OnPreRenderText中我们遍历所有顶点。对于每个字符我们根据其索引currentVisibleCount和一个自定义的“淡入进度”来计算其顶点的目标Alpha值。计算淡入进度可以不是简单的0或1。我们可以引入一个“淡入窗口”Fade In Window的概念。例如设置淡入长度为3个字符。那么当前完全显示的字符索引是currentVisibleCount-1而索引为currentVisibleCount-3到currentVisibleCount-1的字符都处于不同程度的淡入状态中。应用将计算出的Alpha值应用到该字符对应的所有顶点颜色上。这个方案的优势在于它完全在TMP的渲染管线内工作修改的是顶点缓冲区本身没有额外的Draw Call性能开销极小且能与TMP的所有其他特性如字体样式、富文本标签良好兼容。3. 完整C#源码实现与逐行解析下面就是完整的、带有详细注释的C#脚本TextMeshProTypewriterWithFade.cs。你可以直接创建一个C#脚本将代码复制进去然后挂载到你的TextMeshPro - Text (UI) GameObject上。using UnityEngine; using TMPro; using System.Collections; using System.Collections.Generic; [RequireComponent(typeof(TextMeshProUGUI))] public class TextMeshProTypewriterWithFade : MonoBehaviour { [Header(打字机基础设置)] [Tooltip(每秒显示的字符数)] public float charactersPerSecond 30f; [Tooltip(开始打字前的初始延迟秒)] public float startDelay 0f; [Header(淡入效果设置)] [Tooltip(字符淡入过程持续的字符数。例如3表示当前字符及其前2个字符都在淡入中。)] [Range(1, 10)] public int fadeLengthInCharacters 3; [Tooltip(淡入动画曲线。X轴(0-1)表示淡入进度Y轴表示Alpha值(0-1)。)] public AnimationCurve fadeCurve AnimationCurve.Linear(0, 0, 1, 1); [Header(运行时控制)] [Tooltip(为true时打字效果会在Start()时自动开始)] public bool playOnStart true; [Tooltip(为true时打字完成后会自动隐藏此组件节省性能)] public bool disableOnComplete false; // 内部状态 private TextMeshProUGUI m_TextComponent; private int m_TotalCharacters; private int m_CurrentVisibleCount 0; private Coroutine m_TypewriterCoroutine; private bool m_IsCompleted false; // 用于存储每个字符的淡入开始时间基于字符索引这是一个优化项 private Listfloat m_CharFadeStartTimes; private float m_TypewriterStartTime; // 公开的事件方便其他脚本订阅 public System.Action OnCharacterRevealed; // 每当一个新字符完全显示时触发 public System.Action OnTypewriterComplete; // 打字全部完成时触发 void Awake() { m_TextComponent GetComponentTextMeshProUGUI(); if (m_TextComponent null) { Debug.LogError(TextMeshProTypewriterWithFade 需要挂载在带有 TextMeshProUGUI 组件的GameObject上。); enabled false; return; } // 初始隐藏所有文字 m_TextComponent.maxVisibleCharacters 0; // 预计算总字符数排除富文本标签 m_TotalCharacters m_TextComponent.textInfo.characterCount; // 初始化淡入时间列表 m_CharFadeStartTimes new Listfloat(new float[m_TotalCharacters]); } void Start() { if (playOnStart) { StartTypewriter(); } } void OnEnable() { // 订阅TMP的文本变更事件这是修改顶点颜色的关键钩子 TMPro_EventManager.TEXT_CHANGED_EVENT.Add(ON_TEXT_CHANGED); } void OnDisable() { // 务必取消订阅防止内存泄漏 TMPro_EventManager.TEXT_CHANGED_EVENT.Remove(ON_TEXT_CHANGED); if (m_TypewriterCoroutine ! null) { StopCoroutine(m_TypewriterCoroutine); m_TypewriterCoroutine null; } } /// summary /// 外部调用开始打字机效果。 /// /summary public void StartTypewriter() { if (m_TypewriterCoroutine ! null) { StopCoroutine(m_TypewriterCoroutine); } m_IsCompleted false; m_CurrentVisibleCount 0; m_TextComponent.maxVisibleCharacters 0; m_TypewriterStartTime Time.time; // 重置淡入开始时间 for (int i 0; i m_CharFadeStartTimes.Count; i) { m_CharFadeStartTimes[i] -1f; // -1 表示尚未开始淡入 } m_TypewriterCoroutine StartCoroutine(TypewriterRoutine()); } /// summary /// 外部调用立即完成所有打字。 /// /summary public void CompleteTypewriter() { if (m_TypewriterCoroutine ! null) { StopCoroutine(m_TypewriterCoroutine); m_TypewriterCoroutine null; } m_CurrentVisibleCount m_TotalCharacters; m_TextComponent.maxVisibleCharacters m_TotalCharacters; m_IsCompleted true; // 强制更新一次顶点确保所有字符以最终状态显示 m_TextComponent.ForceMeshUpdate(); OnTypewriterComplete?.Invoke(); if (disableOnComplete) { enabled false; } } // 打字机主协程 private IEnumerator TypewriterRoutine() { if (startDelay 0) { yield return new WaitForSeconds(startDelay); } float delayPerChar 1f / Mathf.Max(charactersPerSecond, 0.001f); // 防止除零 while (m_CurrentVisibleCount m_TotalCharacters) { m_CurrentVisibleCount; // 记录这个字符开始淡入的时间点 if (m_CurrentVisibleCount - 1 m_CharFadeStartTimes.Count) { m_CharFadeStartTimes[m_CurrentVisibleCount - 1] Time.time; } // 更新TMP可见字符数这会触发TEXT_CHANGED事件进而调用ON_TEXT_CHANGED m_TextComponent.maxVisibleCharacters m_CurrentVisibleCount; OnCharacterRevealed?.Invoke(); // 如果还有下一个字符则等待 if (m_CurrentVisibleCount m_TotalCharacters) { yield return new WaitForSeconds(delayPerChar); } } // 循环结束打字完成 m_IsCompleted true; OnTypewriterComplete?.Invoke(); m_TypewriterCoroutine null; if (disableOnComplete) { enabled false; } } // TMP文本/网格变更事件回调 void ON_TEXT_CHANGED(Object obj) { // 重要确保事件来自我们自己的Text组件避免干扰场景中其他TMP文本 if (obj m_TextComponent) { ModifyVertexColors(); } } /// summary /// 核心方法修改顶点颜色以实现淡入效果。 /// 此方法在每次TMP网格更新时被调用。 /// /summary private void ModifyVertexColors() { // 获取当前文本的网格信息 TMP_TextInfo textInfo m_TextComponent.textInfo; // 获取顶点颜色数组的引用直接修改效率高 Color32[] vertexColors textInfo.meshInfo[0].colors32; int currentIndex m_CurrentVisibleCount - 1; // 当前理论上应完全显示的字符索引 // 遍历所有字符 for (int i 0; i textInfo.characterCount; i) { TMP_CharacterInfo charInfo textInfo.characterInfo[i]; // 跳过不可见字符如空格、换行符它们的vertexIndex通常为0 if (!charInfo.isVisible) { continue; } // 获取该字符在顶点数组中的起始索引 int vertexIndex charInfo.vertexIndex; // 计算该字符的淡入进度 (0 到 1) float fadeProgress CalculateFadeProgressForCharacter(i, currentIndex); // 根据进度从动画曲线获取目标Alpha值 byte targetAlpha (byte)(fadeProgress * 255); // 将该字符四个顶点的颜色Alpha值都设置为目标值 // 注意这里直接修改了顶点颜色数组没有创建新Color32对象避免GC vertexColors[vertexIndex].a targetAlpha; vertexColors[vertexIndex 1].a targetAlpha; vertexColors[vertexIndex 2].a targetAlpha; vertexColors[vertexIndex 3].a targetAlpha; } // 重要修改完顶点颜色数组后必须通知TMP组件更新网格 m_TextComponent.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); } /// summary /// 为核心字符计算淡入进度。 /// 这是效果可定制化的关键函数。 /// /summary /// param namecharIndex当前处理的字符索引/param /// param namecurrentFullVisibleIndex当前应完全显示的字符索引/param /// returns淡入进度0为完全透明1为完全不透明/returns private float CalculateFadeProgressForCharacter(int charIndex, int currentFullVisibleIndex) { // 情况1字符索引超过了当前最大可见范围应完全透明 if (charIndex m_CurrentVisibleCount) { return 0f; } // 情况2字符索引在“淡入窗口”之外且已完全显示应完全不透明 // 例如 fadeLength3, currentIndex5, 那么索引为0,1,2的字符应已完全显示 if (charIndex currentFullVisibleIndex - fadeLengthInCharacters) { return 1f; } // 情况3字符正在淡入窗口中 // 计算它在窗口内的相对位置 (0 到 1)0表示刚进入窗口开始淡入1表示在窗口最前端淡入完成 float relativePosInWindow Mathf.Clamp01((charIndex - (currentFullVisibleIndex - fadeLengthInCharacters)) / (float)fadeLengthInCharacters); // 使用动画曲线映射相对位置到Alpha值 return fadeCurve.Evaluate(relativePosInWindow); } // 提供一个简单的属性供外部查询状态 public bool IsComplete { get { return m_IsCompleted; } } }3.1 关键代码段深度解析1. 事件订阅与性能 (OnEnable/OnDisable,ON_TEXT_CHANGED)TMPro_EventManager.TEXT_CHANGED_EVENT.Add(ON_TEXT_CHANGED);这是整个方案的灵魂。TEXT_CHANGED_EVENT是一个全局静态事件任何TMP文本的网格发生变化包括文本内容改变、maxVisibleCharacters改变、字体大小变化等都会触发。我们在OnEnable时订阅在OnDisable时取消订阅这是标准的观察者模式确保生命周期管理正确避免内存泄漏。在ON_TEXT_CHANGED回调中我们首先检查obj m_TextComponent这是一个至关重要的优化。一个场景中可能有成百上千个TMP文本如果每个淡入脚本都响应所有TMP的变更事件会造成巨大的不必要的计算开销。这个判断确保了每个组件只处理自己所属文本的网格更新。2. 顶点颜色修改 (ModifyVertexColors方法)这是效果实现的核心。我们通过textInfo.meshInfo[0].colors32直接获取到顶点颜色数组的引用。TMP中每个可见字符通常由4个顶点一个矩形构成。charInfo.vertexIndex给出了这个字符第一个顶点在数组中的索引。我们通过CalculateFadeProgressForCharacter计算出这个字符应有的Alpha值一个0-1的float然后转换为0-255的byte然后直接修改这个字符对应的4个顶点的颜色Alpha通道。最后调用m_TextComponent.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32)告诉TMP“我改动了颜色数据请用新数据去更新网格”。这个操作非常高效因为它只更新颜色这一种顶点属性。3. 淡入进度计算 (CalculateFadeProgressForCharacter方法)这是效果表现的控制中枢。它决定了淡入的“形状”。fadeLengthInCharacters这是最重要的参数。它定义了“淡入窗口”的宽度。假设设置为3当前完全显示的字符索引是5那么索引为3、4、5的字符都处于淡入过程中。索引3窗口起始淡入进度为0%索引5窗口末尾淡入进度为100%。索引0、1、2则已完全显示进度100%。fadeCurveAnimationCurve类型参数给了我们极大的灵活性。默认的Linear(0,0,1,1)是线性淡入。你可以把它改成EaseInOut让淡入开始和结束更平滑或者改成一开始变化快后面慢营造不同的节奏感。这是实现“高级感”的关键。4. 时间驱动与状态记录 (TypewriterRoutine,m_CharFadeStartTimes)主协程TypewriterRoutine以固定的时间间隔delayPerChar增加m_CurrentVisibleCount并更新maxVisibleCharacters。我额外引入了m_CharFadeStartTimes列表虽然当前版本的CalculateFadeProgressForCharacter没有使用它但它为更复杂的淡入逻辑预留了空间。例如如果你想实现每个字符的淡入时长固定为0.1秒而不是基于字符索引的线性插值你就可以在这里记录每个字符开始显示的时间然后在计算进度时使用(Time.time - startTime) / 0.1f。当前基于索引的窗口计算方式在视觉效果和性能上是一个很好的平衡。4. 参数详解与高级效果定制组件在Inspector中暴露了多个参数理解它们才能玩转效果。打字机基础设置Characters Per Second打字速度。30是一个比较舒适的阅读速度。对于紧张场景可以调到50-60对于强调重要信息可以调到15-20。注意这个速度会受到帧率影响在协程中使用WaitForSeconds如果游戏卡顿打字也会变慢。对于需要精确时间控制的过场动画可以考虑使用基于Time.deltaTime的累加方式但这会稍微增加代码复杂度。Start Delay开始前的等待时间。用于配合音效、镜头切换等。淡入效果设置核心Fade Length In Characters淡入窗口长度。这是影响效果观感最直接的参数。值1效果接近于无淡入每个字符出现时Alpha就是目标值由曲线决定但可能仍有单帧的插值。值3~5最常用的范围。能产生明显的平滑过渡又不会让文字整体显得太“模糊”或“拖沓”。字符像流水一样依次显现。值10会产生非常长的拖尾效果文字像雾气一样逐渐凝聚。适合一些特殊的艺术表达但可读性会下降。Fade Curve淡入动画曲线。在Project视图中右键可以创建新的AnimationCurve资产。线性默认最普通但最清晰。缓入缓出EaseInOut我的个人推荐。淡入开始和结束更柔和视觉上更“高级”减少了机械感。缓入EaseIn字符出现时很慢然后突然变清晰。可以营造一种“犹豫”或“艰难”的感觉。缓出EaseOut字符出现很快但完全清晰的过程变慢。感觉比较轻快。自定义你可以拉出任何你想要的曲线比如先快后慢再快创造独特的节奏。运行时控制Play On Start勾选后游戏运行或对象激活时自动开始打字。Disable On Complete打字完成后禁用此组件。这是一个重要的性能优化选项。因为只要组件Enable它就会响应TEXT_CHANGED_EVENT并执行ModifyVertexColors。如果对话已经显示完毕且不再变化禁用组件可以完全消除这部分开销。对于大量存在的对话气泡尤其重要。4.1 实现“逐字符独立时长淡入”的进阶修改如前所述当前方案是基于“索引窗口”的淡入。如果你想实现“每个字符都用0.2秒完成淡入”可以修改CalculateFadeProgressForCharacter方法private float CalculateFadeProgressForCharacter(int charIndex, int currentFullVisibleIndex) { if (charIndex m_CurrentVisibleCount) return 0f; float fadeDuration 0.2f; // 每个字符淡入时长 float startTime m_CharFadeStartTimes[charIndex]; if (startTime 0) // 尚未开始 return 0f; float elapsed Time.time - startTime; float progress Mathf.Clamp01(elapsed / fadeDuration); // 可以继续结合fadeCurve return fadeCurve.Evaluate(progress); }同时需要在TypewriterRoutine中正确记录每个字符的startTime。这种方式的淡入是相互独立的一个字符的淡入过程不会影响另一个适合需要精确控制每个字符动画时间的场合但计算量稍大。5. 性能优化与生产环境注意事项在移动端或大型UI界面中性能至关重要。以下是针对此组件的优化要点减少不必要的网格更新我们的脚本只在maxVisibleCharacters变化时由协程驱动通过设置m_TextComponent.maxVisibleCharacters来触发网格更新。不要在Update中频繁设置此值。确保charactersPerSecond设置合理过高的速度如每秒100字会导致每帧都触发更新带来压力。利用TMP_VertexDataUpdateFlags在ModifyVertexColors最后我们调用UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32)。这个参数非常关键它告诉TMP只更新颜色数据。如果你错误地使用了UpdateVertexData(TMP_VertexDataUpdateFlags.All)或ForceMeshUpdate()会导致位置、UV等所有顶点属性都被重新计算和上传开销大得多。禁用已完成组件务必勾选Disable On Complete。一个禁用的组件不会接收TEXT_CHANGED_EVENT也不会执行任何计算。对于一次性对话弹窗这能立即回收性能。富文本标签的处理我们的代码通过charInfo.isVisible跳过了空格和换行符这是正确的。但需要注意的是TMP的富文本标签如color#FF0000,b,i本身不占用字符索引但会影响顶点颜色。我们的方案是在字符层级覆盖顶点颜色这可能会与富文本设置的颜色产生冲突。实测中如果富文本设置了颜色我们的淡入Alpha值会与之相乘。通常这没问题但如果你需要非常精确的颜色控制可能需要在修改顶点颜色时考虑保留原始的RGB值只修改A值。代码修改如下Color32 originalColor vertexColors[vertexIndex]; vertexColors[vertexIndex] new Color32(originalColor.r, originalColor.g, originalColor.b, targetAlpha); // ... 其他三个顶点同理对象池与复用如果你的对话系统频繁创建和销毁带有此组件的UI对象应考虑使用对象池。在从池中取出复用对象时记得调用StartTypewriter()前先重置文本内容和组件的内部状态m_CurrentVisibleCount,m_IsCompleted等。6. 常见问题排查与实战技巧在实际项目集成中你可能会遇到以下问题问题1文字完全不显示或者显示不全。检查步骤确认脚本挂载的GameObject上确实有TextMeshProUGUI组件。检查Awake方法中是否因为找不到TMP组件而enabled false了。在运行时查看Inspector中m_CurrentVisibleCount和m_TotalCharacters的值是否正常。m_TotalCharacters在Awake中计算如果文本在Awake之后被动态修改这个值不会更新。解决方法在StartTypewriter()方法开头重新获取m_TotalCharacters m_TextComponent.textInfo.characterCount;。确保你的文本不是空字符串。问题2淡入效果不起作用文字还是硬切出来。检查步骤首先确认Fade Length In Characters是否大于1。在ModifyVertexColors方法开始处打一个Debug.Log看它是否被调用。如果不调用说明ON_TEXT_CHANGED事件没有触发。检查OnEnable是否执行以及事件订阅是否成功确保脚本所在GameObject是Active的。在ModifyVertexColors中调试输出charIndex,currentFullVisibleIndex和计算出的fadeProgress看数值是否在0-1之间合理变化。检查TMP文本的材质是否支持顶点颜色。绝大多数TMP材质Shader都支持但如果你使用了自定义Shader可能需要确保它处理了顶点颜色的Alpha通道。问题3打字过程中部分字符颜色异常比如变成白色。原因这通常是因为顶点颜色数组的索引错乱。TMP在生成网格时textInfo.characterCount和textInfo.meshInfo[0].colors32的长度是动态的。一个常见陷阱是在文本中包含像#FF0000这样的富文本颜色标签时TMP可能会为了优化而合并一些顶点批次导致vertexIndex的映射并非严格按顺序每4个顶点一个字符。虽然我们的代码通过charInfo.isVisible进行了过滤但在极端复杂的富文本混合情况下可能仍有问题。解决方案采用更稳健的顶点遍历方式。使用TMP_TextInfo中的meshInfo数组循环而不是依赖characterCount。下面是更通用的修改片段private void ModifyVertexColors() { TMP_TextInfo textInfo m_TextComponent.textInfo; // 遍历所有网格通常只有一个 for (int i 0; i textInfo.meshInfo.Length; i) { Color32[] vertexColors textInfo.meshInfo[i].colors32; int vertexCount textInfo.meshInfo[i].vertexCount; // 这种方法下我们需要建立顶点索引到字符索引的映射。 // 一个更简单但略耗性能的方法是遍历所有字符用字符的vertexIndex来定位。 // 我们保留原方法的逻辑因为它对绝大多数情况足够好且效率更高。 // 如果出现颜色异常可以尝试回退到逐字符遍历并严格检查索引边界。 for (int v 0; v vertexCount; v 4) { // 这里需要知道这4个顶点属于哪个字符这需要额外的映射计算。 // 因此原方案在正确性上更有保障除非遇到无法解决的索引错误。 } } // ... 如果采用新循环需要更新顶点数据 }实践中如果遇到颜色异常首先尝试简化文本内容移除不必要的富文本标签。如果问题依旧可以尝试在修改顶点颜色前先备份原始的colors32数组修改后再对比定位是哪个字符的索引出了问题。问题4在UI滚动视图Scroll Rect中打字时出现视觉瑕疵或闪烁。原因Scroll Rect会触发Canvas的重新批处理Rebatch这可能与TMP的顶点更新不同步。解决方案确保你的对话UI不在一个频繁变化的Scroll Rect中。如果必须在滚动视图中可以尝试在打字期间暂时禁用Scroll Rect的滚动或者将Canvas组件的Additional Shader Channels中的TexCoord1,TexCoord2,Normal,Tangent等根据需要开启TMP可能需要这些通道但通常顶点颜色(Color)通道是默认开启的。最根本的方法是将打字机效果与UI布局更新由Scroll Rect驱动分帧进行但这涉及更复杂的架构。实战技巧与音频、事件系统联动公开的OnCharacterRevealed和OnTypewriterComplete事件非常有用。你可以在OnCharacterRevealed中播放一个轻微的“打字音效”创造听觉反馈。在OnTypewriterComplete中可以激活一个“点击继续”的提示图标或者自动跳转到下一句对话。将这些事件与你的对话管理系统、任务系统连接可以实现复杂的叙事逻辑。最后的小提示如果你需要更极致的性能并且项目使用的是Unity较新的版本支持Burst和Jobs可以考虑将ModifyVertexColors中的顶点颜色计算部分移植到JobSystem中并行处理。但对于绝大多数对话系统当前组件提供的性能已经绰绰有余。