三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity TextMeshPro自定义材质进阶:SDF渲染原理与动态特效实战

Unity TextMeshPro自定义材质进阶:SDF渲染原理与动态特效实战

1. 项目概述:为什么TextMeshPro的自定义材质是UI进阶的必经之路

在Unity UI开发里,TextMeshPro(TMP)几乎是所有项目的标配,它解决了传统UI Text字体模糊、锯齿严重的问题。但很多开发者,包括我早期也是,仅仅停留在“拖一个TMP组件,选个字体,调个颜色”的层面。直到我接手一个需要高度风格化UI的项目,比如赛博朋克风的霓虹灯文字、中世纪羊皮纸上的手写体效果,或者只是想让标题文字带一点内发光和边缘磨损感,才发现默认的TMP材质和着色器根本不够用。这时,自定义字体材质就成了从“能用”到“出彩”的关键跳板。

简单来说,Unity 2022版里的TextMeshPro,其默认的“TextMeshPro/Distance Field”着色器是个非常强大的通用方案,它基于有向距离场(SDF)技术,保证了字体在任何分辨率下都清晰锐利。然而,它的“通用”也意味着“受限”。当你想要实现超出颜色、描边、软阴影这些内置属性的视觉效果时,比如让文字纹理随时间流动、让描边带有噪声扰动、或者实现复杂的混合效果(如溶解、扫描线),就必须深入材质和着色器的层面进行自定义。这不仅仅是换张纹理贴图那么简单,而是涉及到对TMP着色器输入通道的理解、对SDF渲染原理的把握,以及在Unity URP/HDRP管线下的适配。接下来,我会把我从踩坑到熟练的完整经验拆解给你,让你不仅能复现效果,更能理解背后的“为什么”。

2. 核心原理拆解:TMP材质与SDF渲染的运作机制

要自定义,先得懂原理。盲目修改Shader代码就像蒙着眼睛拼图,效率极低。

2.1 SDF(有向距离场)是TMP清晰度的基石

TMP视觉质量远超旧版Text的核心,就在于它使用了SDF字体渲染技术。这里我打个比方:传统的位图字体就像一张固定大小的照片,放大就模糊;而SDF字体更像一个数学公式,它记录的不是像素颜色,而是每个点到字体轮廓边界的“距离”。

具体来说,字体工具(比如TMP自用的Font Asset Creator)会为每个字符生成一张灰度图。这张图上的每个像素值,代表该像素位置到字符轮廓的符号距离:轮廓内部的像素值为正(比如1.0),轮廓外部的像素值为负(比如-1.0),轮廓上的像素值则为0。这个“距离”信息就是SDF纹理。渲染时,TMP的着色器会采样这个SDF值,然后通过一个简单的阈值比较(比如,alpha = smoothstep(0.5 - softness, 0.5 + softness, sdfValue))来生成最终平滑的边缘。这就是为什么你可以把TMP文字放大无数倍依然边缘光滑的原因,因为它在用数学插值“计算”边缘,而不是“拉伸”像素。

注意:理解SDF值是理解所有TMP材质自定义的基础。后续我们添加的任何效果,无论是外发光还是纹理叠加,本质上都是在SDF生成的Alpha通道基础上进行二次加工。

2.2 TMP默认着色器的输入通道剖析

在Unity中选中一个TMP文本,其默认材质使用的Shader通常是“TextMeshPro/Distance Field”。在Shader中,有几个关键的输入通道和属性决定了最终外观:

  1. Face Color(主色):文字表面的基础颜色。通常由顶点颜色(Vertex Color)和材质的主色属性共同决定。
  2. Outline Color & Width(描边色与宽度):这是基于SDF的描边。着色器会在内部轮廓的外部,根据设定的宽度,用第二个颜色进行填充。它是在同一个Draw Call内完成的,性能极佳。
  3. Underlay(底层,即软阴影):在文字下方偏移一定位置绘制的另一层SDF文字,通常用于模拟阴影。可以调整颜色、偏移量和模糊度。
  4. Bevel(斜角):模拟3D凸起效果,通过模拟光照在字体边缘产生高光和阴影。对性能有一定消耗,非特殊风格慎用。
  5. Glow(外发光):在描边之外再叠加一层发光效果。这是通过将SDF的阈值范围向“外部”延伸来实现的。
  6. Texture(纹理):这是自定义的起点。默认材质提供了一个“Main Texture”槽,但它通常被用于承载SDF数据(即字体图集)。而我们自定义材质时,常常需要引入第二张纹理,比如噪声图、细节纹理、蒙版图等,来影响颜色或Alpha。

自定义材质的目标,就是通过修改或重写着色器,来扩展或改变这些通道的运算方式。例如,让“Texture”通道不仅承载SDF,还能混合一张动态纹理;或者修改“Glow”通道的计算公式,让它产生脉动效果。

3. 实战准备:创建你的第一个自定义TMP材质与着色器

理论说再多不如动手做一遍。我们从最安全、最推荐的方式开始:复制并修改官方着色器。

3.1 步骤一:定位并复制官方Shader

首先,你需要在项目中找到TextMeshPro的默认着色器。它们通常不在常规的Assets目录下,而是作为TextMeshPro包的资源存在。

  1. 在Project窗口的搜索栏中,输入TextMeshPro然后选择In Packages
  2. 找到TextMeshPro包,展开Shaders文件夹。你会看到一系列着色器文件,如TMP_SDF.shader,TMP_SDF-Mobile.shader,TMP_SDF-Surface.shader等。
  3. 关键操作:不要直接修改这些包内的文件!右键点击TMP_SDF.shader(这是功能最全的标准版),选择Show in Explorer(Windows)或Reveal in Finder(Mac),然后在系统的文件管理器中,将这个文件复制一份到你的项目Assets目录下的某个文件夹内,例如Assets/Shaders/CustomTMP。再将复制过来的文件重命名,比如Custom_TMP_SDF.shader

这样做的好处是,你有一个独立的、可任意修改的着色器副本,不会因为TextMeshPro包的更新而被覆盖,也便于版本管理。

3.2 步骤二:创建自定义材质并关联着色器

  1. 在Unity中,右键点击你的自定义着色器文件Custom_TMP_SDF.shader,选择Create -> Material。这会生成一个使用该着色器的新材质球,将其命名为MyTMP_CustomMat
  2. 在场景中创建一个TMP文本(GameObject -> UI -> Text - TextMeshPro)。
  3. 在TMP文本组件的Font Material属性处,将默认的材质替换为你刚刚创建的MyTMP_CustomMat

此时,文本的外观应该和默认的完全一样(前提是你复制后还没修改Shader)。如果文本变成粉色(Missing Shader),请检查材质球引用的着色器路径是否正确。

3.3 步骤三:理解Shader代码结构与关键函数

用VS Code或Rider等编辑器打开你的Custom_TMP_SDF.shader。代码量不小,但我们需要关注的区域有限:

  • Properties块:这里定义了在材质Inspector面板中可见的所有属性(如_FaceColor,_OutlineColor,_UnderlayColor等)。我们要添加新的自定义属性,就是在这里声明。
  • CGPROGRAM/ENDCG块:这是着色器程序的核心。
    • v2f vert (appdata_t v):顶点着色器函数。负责处理顶点位置、UV等。通常我们不需要修改这里,除非你想做顶点动画(如波浪文字)。
    • fixed4 frag (v2f i) : SV_Target:片元(像素)着色器函数。这是我们自定义效果的主战场。所有颜色、纹理混合、特效计算最终都在这里完成。
  • 输入结构体:注意v2f结构体,它定义了从顶点着色器传递给片元着色器的数据。如果你需要传递新的数据(如第二套UV),需要在这里添加。

一个最简单的自定义思路是:在frag函数中,在最终输出颜色col之前,加入我们自己的逻辑。比如,先让TMP按照原有方式计算出颜色和透明度,然后我们再乘上或加上另一个纹理的颜色。

4. 进阶技巧一:为文字添加动态纹理叠加效果

这是最常见的需求之一,让文字表面不是纯色,而是带有金属拉丝、布料纹理、水流等动态效果。

4.1 修改Shader Properties

首先,在Shader文件的Properties块里添加新的纹理和参数。找到_MainTex属性附近,添加如下代码:

// ... 原有的属性 ... _MainTex ("Font Atlas", 2D) = "white" {} _FaceColor ("Face Color", Color) = (1,1,1,1) _FaceDilate ("Face Dilate", Range(-1,1)) = 0 // ... 添加以下自定义属性 ... _DynamicTex ("Dynamic Texture", 2D) = "white" {} _DynamicTex_ST ("Dynamic Tiling/Offset", Vector) = (1,1,0,0) _DynamicIntensity ("Dynamic Intensity", Range(0, 1)) = 0.5 _DynamicSpeedU ("Scroll Speed U", Float) = 0.1 _DynamicSpeedV ("Scroll Speed V", Float) = 0.0
  • _DynamicTex:我们的动态纹理,比如一张噪声图。
  • _DynamicTex_ST:这是Unity的标准命名约定,ST代表Scale和Translation(即Tiling和Offset),方便我们在材质面板中调整平铺和偏移。
  • _DynamicIntensity:控制动态纹理对最终颜色的影响强度。
  • _DynamicSpeedU/V:控制纹理在U和V方向上的滚动速度,用于实现动态效果。

4.2 修改CGPROGRAM中的变量声明

在CGPROGRAM块内部,需要声明与Properties对应的变量,这样片元着色器才能使用它们。找到类似sampler2D _MainTex;的声明区域,添加:

sampler2D _MainTex; float4 _MainTex_ST; // ... 添加 ... sampler2D _DynamicTex; float4 _DynamicTex_ST; half _DynamicIntensity; half _DynamicSpeedU; half _DynamicSpeedV;

4.3 修改片元着色器 (frag函数)

这是核心步骤。我们需要在计算最终颜色前,采样动态纹理并与原有颜色混合。

  1. 计算动态UV:在frag函数内部,首先计算经过时间和速度偏移后的动态纹理UV。

    // i.texcoord.xy 是字体图集UV,我们可能需要一套独立的UV用于动态纹理 // 这里为了简单,我们使用顶点世界位置或屏幕位置来生成UV,以避免与字体UV冲突,更常用的方式是使用第二套UV(需要修改顶点输入结构) // 简单示例:使用屏幕空间坐标(适用于全屏UI特效) float2 dynamicUV = i.screenPos.xy / i.screenPos.w; dynamicUV = TRANSFORM_TEX(dynamicUV, _DynamicTex); // 应用材质面板的Tiling和Offset // 添加基于时间的滚动 dynamicUV.x += _Time.y * _DynamicSpeedU; dynamicUV.y += _Time.y * _DynamicSpeedV;

    实操心得:直接使用字体UV(i.texcoord.xy)会导致动态纹理随着每个字符的UV而破碎。更健壮的做法是在顶点着色器中,将顶点的模型空间位置或世界空间位置传递给片元着色器,归一化后作为动态纹理UV。对于UI,使用i.vertex或计算相对于Canvas的局部位置是更好的选择。

  2. 采样并混合:采样动态纹理,然后与TMP计算出的面部颜色faceColor进行混合。

    // 假设TMP原有计算得到的表面颜色存储在 faceColor 中 fixed4 faceColor = ... // TMP原有的颜色计算逻辑 fixed4 dynamicColor = tex2D(_DynamicTex, dynamicUV); // 混合方式1:相乘(Multiply),常用于给文字上色或变暗 fixed4 finalFaceColor = faceColor * lerp(fixed4(1,1,1,1), dynamicColor, _DynamicIntensity); // 混合方式2:叠加(Overlay)或屏幕(Screen),可以模拟发光、提亮效果 // 这里需要更复杂的混合函数,可以使用Unity内置的Blend操作或自己写公式 // 混合方式3:只使用动态纹理的某个通道(如R)来影响原有颜色的强度 // finalFaceColor.rgb = faceColor.rgb * (1.0 + (dynamicColor.r - 0.5) * _DynamicIntensity);
  3. 确保最终输出:将混合后的finalFaceColor赋值给最终的输出颜色col(在TMP的frag函数中,最终输出变量通常是col)。

修改完成后,保存Shader文件,回到Unity编辑器。材质球Inspector面板会自动更新出你添加的Dynamic Texture等属性。拖入一张噪声图或火焰纹理,调整参数,你就能看到文字表面有了动态纹理效果。

5. 进阶技巧二:实现自定义外发光与边缘特效

TMP自带Glow效果,但它的形状和变化方式是固定的。我们想实现一个随着时间呼吸、或者边缘带有不规则噪波的光晕。

5.1 基于SDF阈值控制的自定义发光

TMP的Glow本质是通过扩展SDF的Alpha阈值范围来实现的。在默认Shader的frag函数中,你会找到类似smoothstepclamp的函数调用,它们决定了从SDF值到最终Alpha的映射关系。我们可以修改这个关系。

思路是:不直接使用固定的_GlowOffset_GlowOuter,而是用一个随时间变化的数值或纹理采样值来影响它们。

  1. 添加控制参数:在Properties和变量声明中添加_GlowNoiseTex_GlowPulseSpeed
  2. 修改Alpha计算逻辑:找到计算外发光Alpha(通常是一个叫glow的变量)的代码段。它可能长这样:
    float glow = clamp(sd - _GlowOffset, 0.0, _GlowOuter) / _GlowOuter;
    我们可以将其修改为:
    // 采样一张噪声图,基于UV和时间 float2 glowUV = i.texcoord.xy * _GlowNoiseScale + _Time.y * _GlowPulseSpeed; float noise = tex2D(_GlowNoiseTex, glowUV).r; // 用噪声值扰动Glow的边界 float dynamicGlowOuter = _GlowOuter * (1.0 + (noise - 0.5) * _GlowNoiseStrength); float glow = clamp(sd - _GlowOffset, 0.0, dynamicGlowOuter) / dynamicGlowOuter;
    这样,发光的外边界就会随着噪声纹理动态变化,产生一种“能量不稳定”或“火焰边缘”的视觉效果。

5.2 完全独立于SDF的后期发光(Screen-Space Glow)

上述方法仍然受限于SDF的形状。如果你想实现更自由、更风格化的发光(比如星形光晕、十字星光),就需要采用后期处理(Post-Processing)或Command Buffer的方式。但这超出了单个材质修改的范围,属于渲染管线层面的定制。

一个在UI层内可行的折中方案是:使用两个TMP文本叠在一起。底层文本放大一些,材质使用一个高度模糊且去除了SDF阈值判断的Shader(只采样SDF纹理并做高斯模糊模拟),作为发光层;顶层是正常的文字。通过脚本控制底层文本的颜色和强度变化,来实现复杂的动态发光。这种方法性能开销稍大,但灵活度极高。

6. 进阶技巧三:在URP/HDRP下的适配与性能考量

Unity 2022版本,URP(通用渲染管线)和HDRP(高清渲染管线)已成为主流。TMP官方包提供了对应的Shader变体(如TMP_SDF-URP.shader),但当你自定义Shader时,适配管线是必须考虑的一步。

6.1 URP下的关键修改

  1. Shader头文件:URP Shader通常以"Universal Render Pipeline"等关键字开头,并包含UniversalRenderPipelineShaderGraph相关的Tags。最简单的方法是直接复制TMP_SDF-URP.shader作为基础进行修改,而不是用Built-in管线的Shader。
  2. 函数库与宏:URP Shader使用UnityInput.hlslPackages/com.unity.render-pipelines.universal/ShaderLibrary/下的头文件。颜色空间转换、雾效等函数调用方式与Built-in管线不同。如果你在Shader中使用了UnityCG.cginc中的函数,需要找到URP中的对应函数替换。
  3. 深度与模板测试:UI的渲染顺序和深度测试在URP中尤其重要。确保你的自定义Shader继承了正确的"Queue"="Transparent""IgnoreProjector"="True"标签,并且ZTestZWrite设置合理(UI通常为ZTest [Lequal]ZWrite Off)。

6.2 性能优化要点

自定义材质虽然强大,但滥用会导致性能下降,尤其在移动端。

  1. 纹理采样次数:每增加一张额外的纹理采样,就增加一份GPU开销。评估你的效果是否真的需要多张纹理。尝试将噪声和细节合并到一张纹理的不同通道(RGBA)。
  2. 复杂的数学运算:在frag函数中避免sin,cos,pow等复杂运算的频繁调用,特别是基于_Time的动态计算。如果必须使用,考虑在顶点着色器中计算并插值到片元。
  3. Overdraw:复杂的发光、多重阴影效果会导致同一个像素被绘制多次(Overdraw)。在移动设备上这会严重消耗填充率(Fill Rate)。务必在真机上测试性能。
  4. Shader变体:如果你使用了#pragma multi_compileshader_feature来开关某些效果,要注意它可能会产生的Shader变体数量,这会影响构建时间和内存。只保留必要的变体。

一个实用的技巧是,为你的自定义TMP Shader创建两个版本:一个“高质量”版(包含所有特效),一个“移动端简化”版。通过Unity的Quality Settings或自定义的Shader LOD系统,在不同性能的设备上自动切换。

7. 常见问题排查与实战心得

在实际项目中自定义TMP材质,我踩过不少坑,这里总结几个最典型的:

7.1 问题:自定义材质后,文字渲染出现锯齿或模糊

  • 排查:这几乎总是因为SDF纹理的生成质量或Shader中的_Sharpness(清晰度)参数被意外修改。
  • 解决
    1. 检查字体Asset的生成设置。在Font Asset Creator中,确保Sampling Point Size足够大(通常至少是字体大小的2-3倍),Atlas Resolution也足够高(1024x1024或更高用于包含大量字符的字体)。
    2. 在你的自定义Shader中,确认没有修改_Sharpness参数的计算逻辑。这个参数控制SDF边缘过渡的硬度。
    3. 在材质面板上,检查PaddingDilate相关参数是否被设成了异常值。

7.2 问题:动态纹理在文字上滚动时,边缘出现接缝或扭曲

  • 排查:UV计算方式不当。如果直接使用了字体图集的UV(i.texcoord),由于每个字符的UV都在图集的不同位置,动态纹理会在每个字符上独立滚动,导致整体不连贯。
  • 解决:使用一套与文字形状无关的连续UV。最推荐的方法是:
    1. 在顶点着色器中,将顶点的局部位置v.vertex.xy)或世界位置传递给片元着色器。
    2. 在片元着色器中,将这些位置坐标归一化(例如,除以Canvas的尺寸或一个固定的参考值),作为动态纹理的UV。这样,整个文本块会共享一个连续的纹理空间,滚动效果自然连贯。
    // 在 appdata_t 结构体中添加:float2 texcoord1 : TEXCOORD1; (如果支持) // 或者在顶点着色器中计算屏幕空间位置 o.screenPos = ComputeScreenPos(o.vertex); // 在片元着色器中使用 i.screenPos.xy / i.screenPos.w

7.3 问题:在URP下,自定义Shader的文字不显示或显示异常(如全黑/全白)

  • 排查:URP管线对Shader的输入输出有更严格的要求,特别是光照和颜色空间。
  • 解决
    1. 确保基础色正确:在URP的Unlit Shader中,最终输出颜色可能需要经过Universal2D相关的处理。检查你的frag函数最终返回的颜色是否被正确处理。一个简单的方法是,先注释掉所有自定义计算,只返回TMP原始计算的颜色col,看是否正常。
    2. 检查Alpha混合:UI通常是Alpha Blend。确保Shader的Blend状态设置正确:Blend SrcAlpha OneMinusSrcAlpha
    3. 使用URP内置函数:涉及屏幕坐标、深度等操作时,使用GetScreenPosSampleSceneDepth等URP内置函数,而不是Built-in管线的老方法。

7.4 实战心得:材质参数动画与脚本控制

静态的自定义材质已经很强大了,但如果能让参数动起来,效果会提升一个维度。我通常不推荐在Update里每帧去改MaterialPropertyBlock(虽然可行),因为对批处理不友好。

更好的做法是:

  1. 在Shader中使用时间变量:如上文所示,利用_Time.y在Shader内部驱动动画(UV滚动、强度变化)。这是性能最优的方式。
  2. 使用Animator或Animation组件:直接对材质实例的浮点参数(如_DynamicIntensity)做关键帧动画。Unity的动画系统可以很好地优化这类插值操作。
  3. 对于复杂的、由游戏逻辑驱动的变化:可以编写一个简单的脚本,在值变化时通过MaterialPropertyBlock来设置渲染器的属性。关键技巧:对于UI TextMeshPro,它是CanvasRenderer,你需要通过CanvasRenderer.GetMaterial()获取材质实例来进行设置,并且要注意在适当的时机(如OnEnable,Start)缓存这个MaterialPropertyBlock,避免每帧创建。
using UnityEngine; using TMPro; public class TMPCustomMaterialController : MonoBehaviour { public TMP_Text targetText; private MaterialPropertyBlock _mpb; private int _dynamicIntensityId; void Start() { if (targetText == null) targetText = GetComponent<TMP_Text>(); _mpb = new MaterialPropertyBlock(); _dynamicIntensityId = Shader.PropertyToID("_DynamicIntensity"); // 初始值 SetDynamicIntensity(0.5f); } public void SetDynamicIntensity(float value) { targetText.canvasRenderer.GetMaterial().SetFloat(_dynamicIntensityId, value); // 或者使用PropertyBlock(对于多实例合批更友好) // targetText.canvasRenderer.GetPropertyBlock(_mpb); // _mpb.SetFloat(_dynamicIntensityId, value); // targetText.canvasRenderer.SetPropertyBlock(_mpb); } }

自定义TextMeshPro材质是一个从“使用者”到“创造者”的转变过程。它要求你不仅了解Unity Shader的基础,更要理解TMP渲染的独特机制。开始时可以从复制修改官方Shader入手,实现一个简单的纹理叠加,慢慢再尝试更复杂的动态效果和性能优化。记住,最好的效果往往是克制的,在视觉吸引力和运行性能之间找到平衡点,才是高级UI开发的真谛。当你的游戏UI因为这些细节而脱颖而出时,你就会觉得这些钻研都是值得的。

← 返回列表