米塔3D对话文本

复刻米塔中带有物理效果的3D对话文本

实机录制

1.文本渲染

从上面的实机画面来看,首先文本是渲染在世界空间的网格而不是屏幕空间的UI(毕竟后续还要添加物理效果),其次渲染出的文本正面是目标颜色加外描边的形式,反面则是纯白,所以先来实现文本正反面的差异化渲染。

要实现这个效果,我们可以想到只要在正常文本渲染的基础上,对背面的像素进行特殊处理即可,当然我这里只是提供一个思路,所以直接使用现有的 TextMeshPro 作为基础来演示。
导入TMP后创建一个 Text 组件,shader选择 TextMeshPro/Distance Field,在DebugSettings中选择CullMode为Off(关闭剔除正反都渲染),打开shader脚本进行编辑。

1
2
3
4
5
6
#pragma target 3.0

fixed4 PixShader(pixel_t input, fixed facing : VFACE) : SV_Target
{
faceColor.rgb *= facing > 0 ? input.color : fixed4(1.0, 1.0, 1.0, input.color.a);
}
  • 我们使用的VFACE变量是在shader模型3.0中引入的,所以需要添加 target 3.0 指令
  • 在片元着色器的函数中我们添加一个新的变量facing,其语义指定为 VFACE,这个变量指示当前片元是三角形正面还是背面
  • 计算颜色值的时候如果facing值大于0,说明当前片元为正面,直接使用顶点着色器输出的颜色,如果小于0则为背面,我们将其rgb设置为白色,透明度则仍然使用顶点着色器输出的值确保正反面透明度的一致性

较老的设备因为不支持shader3.0的新特性不能用VFACE来判断片元的正反,那么这里再提供一个思路,我们可以开启两个PASS,分别渲染正反面,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SubShader
{
Pass
{
Cull Back

fixed4 PixShader(pixel_t input) : SV_Target
{
faceColor.rgb *= input.color;
}
}

Pass
{
Cull Front

fixed4 PixShader(pixel_t input) : SV_Target
{
faceColor.rgb *= fixed4(1.0, 1.0, 1.0, input.color.a);
}
}
}

2.文字生成&排版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void SetDialogueInfo(string text)
{
int index = 0;
int symbolCount = text.Length - 1;
List<DialogueSymbol> dialogueSymbolList = new List<DialogueSymbol>();
while (index <= symbolCount)
{
char c = text[index];
DialogueSymbol symbol = Instantiate(symbolPrefab, dialogueRoot).GetComponent<DialogueSymbol>();
dialogueSymbolList.Add(symbol);
symbol.SetSymbol(c, theme);
symbol.transform.localPosition = new Vector3(index * sizeWidth - symbolCount * sizeWidth * 0.5f, 0f, 0f);
index++;
}
StartCoroutine(FadeInAnimation(dialogueSymbolList));
}

每个文字都有独立的碰撞,需要将文本拆成单独的文字来生成,同时还要考虑文字排版,所以单个的文字(Dialogue Symbol)可以制作成为一个预制体。需要生成文本的时候,计算得到文字个数,依次生成Symbol预制体并设置好具体的文字。

symbol.transform.localPosition = new Vector3(index * sizeWidth - symbolCount * sizeWidth * 0.5f, 0f, 0f);通过 sizeWidth , sizeHeight 计算排版后的文字坐标,这里我直接计算了文本居中后各个文字的坐标,如果需要左对齐右对齐等其他对齐方式,可以在这里修改坐标计算方式实现具体的排版。

需要注意的是为了方便后续文字动画的实现,Symbol预制体实际上由DialogueSymbol脚本外加一个TMP子物体组成,父物体用于文字排版,前面计算得到的文字坐标修改父物体的位置,子物体则为了方便统一动画的实现坐标旋转缩放均保持初始状态。

3.动画&物理模拟

观察不同角色(Theme)的文本动画,其实都是由三部分组成,FadeIn -> Jump -> FadeOut,所以我们拆分成三个阶段来统一管理不同角色的文本动画,每个阶段都会遍历当前文本所有文字,调用文字动画的具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private IEnumerator FadeInAnimation(List<DialogueSymbol> dialogueSymbolList)
{
foreach (DialogueSymbol symbol in dialogueSymbolList)
{
symbol.FadeInAnimation();
yield return new WaitForSeconds(fadeInInterval);
}

yield return new WaitForSeconds(showTime);

StartCoroutine(JumpAnimation(dialogueSymbolList));
}

private IEnumerator JumpAnimation(List<DialogueSymbol> dialogueSymbolList)
{
foreach (DialogueSymbol symbol in dialogueSymbolList)
{
symbol.transform.parent = globalDialogueRoot.transform;
symbol.JumpAnimation();
yield return new WaitForSeconds(jumpInterval);
}

yield return new WaitForSeconds(waitFadeOutTime);

StartCoroutine(FadeOutAnimation(dialogueSymbolList));
}

private IEnumerator FadeOutAnimation(List<DialogueSymbol> dialogueSymbolList)
{
foreach (DialogueSymbol symbol in dialogueSymbolList)
{
symbol.FadeOutAnimation();
yield return new WaitForSeconds(fadeOutInterval);
}

yield return new WaitForSeconds(waitDestroyTime);

foreach (DialogueSymbol symbol in dialogueSymbolList)
{
Destroy(symbol.gameObject);
}
}
  • 通过协程控制动画触发间隔(比如 fadeInInterval )以及每个阶段的时间,这些值可以暴露在面板上方便调整动画效果。
  • 在Jump阶段有symbol.transform.parent = globalDialogueRoot.transform;这样的处理,因为考虑到有的文本是贴在玩家相机前跟随相机移动,还有类似lookOnPlayer这样跟随玩家移动的文本,我们需要在文本进入Jump阶段的时候将其放置到世界空间,不再跟随原来的父物体继续位移,以免影响物理效果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void JumpAnimation()
{
symbolCollider.enabled = true;
symbolRigidbody.useGravity = true;

switch (dialogue3DTheme)
{
case Dialogue3DTheme.Default:
JumpAnimation_Default();
break;
case Dialogue3DTheme.Mita:
break;
case Dialogue3DTheme.Player:
JumpAniamtion_Player();
break;
case Dialogue3DTheme.ChibiMita:
break;
}
}
  • 文字动画的具体实现不再展示,我们设置文本的时候同时设置了主题,触发动画时根据主题触发不同的动画实现即可。
  • 进入Jump阶段的时候打开文字上的碰撞体,开启rigidbody的重力响应,触发物理效果(生成文字预制体的时候碰撞体是关闭的,防止其与地面或其他物体碰撞后改变位置影响文本排版)。
  • 还需要注意的是,文字之间的碰撞需要在设置中取消,否则在触发物理效果后会导致非预期的碰撞导致文字”爆炸飞走”hhh。
1
2
3
4
5
private void JumpAniamtion_Player()
{
Vector3 force = transform.right * Random.Range(-0.5f, -0.25f) + transform.up * Random.Range(1.0f, 1.5f) + transform.forward * Random.Range(-0.25f, 0.25f);
symbolRigidbody.AddForceAtPosition(force, CommonTools.GetRandomVector3(), ForceMode.Impulse);
}
  • Jump阶段的动画一般是通过物理效果实现的,前面提到文字会跟随父物体移动,所以在为其施加力的时候请务必使用文字对象在世界空间的方向向量,比如 transform.right

4.其他功能&可能的优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void Update()
{
if (lookOnPlayer)
{
LookOnPlayerHandler();
}
}

private void LookOnPlayerHandler()
{
Vector3 direction = playerTransform.position - transform.position;
if (direction.magnitude < 0.01f) return;
Quaternion targetRotation = Quaternion.LookRotation(-direction);
targetRotation.x = 0;
targetRotation.z = 0;
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, 5f * Time.deltaTime);
}

计算文本到目标的方向向量,使用 Quaternion.LookRotation 计算出目标旋转使得文本朝向目标物体,同时将x,z轴的旋转置0,保证其仅在y轴旋转。

可能的优化方向:
  • 频繁生成销毁文字预制体会造成内存碎片化以及额外的性能开销,实际上可以进一步抽象文字预制体,通过代码设置其颜色,字体大小等参数,然后维护一个对象池,在池中进行对象复用以降低性能开销。
  • 游戏中还有文本内容离开视野后在下方生成UI的功能,这个实现方法和自动跟随玩家旋转类似,判断文本中心点是否在相机内,离开相机视野后显示底部UI,同时UI文本跟随3D文本同步生成即可。

Github源码传送门————>Dialogue_3DText

Bilibili教程传送门————>小祥带你实现米塔3D对话文本功能

完结撒花~

pid:127432575


米塔3D对话文本
https://baifabaiquan.cn/2025/02/19/MiSideDialogue3DText/
作者
白发败犬
发布于
2025年2月19日
许可协议