1. 项目概述:为什么我们需要厚度图?
在Unity的渲染管线中,尤其是在处理皮肤、玉石、蜡、牛奶等半透明材质时,次表面散射(Subsurface Scattering,简称SSS)效果是提升视觉真实感的关键。而厚度图(Thickness Map),正是实现高质量、实时或近似实时次表面散射效果的核心数据源。简单来说,厚度图是一张记录了模型上每个像素点“厚度”信息的灰度图。这里的“厚度”并非指模型的物理厚度,而是指从当前观察点到模型背面(或内部)的“光程”距离的一种近似表达。
想象一下,当你用手指捏住手电筒,光线会透过你的手指,呈现出一种温暖的、内部透光的效果。指尖部分因为肉薄,透过的光多且亮;指根部分肉厚,透过的光则更柔和、更分散。厚度图要存储的,就是这种“薄”和“厚”的信息。在Shader中,我们利用这张图来控制光线在模型内部散射的强度和范围:薄的地方散射弱,颜色更接近表面光照;厚的地方散射强,光线“走”得更远,颜色混合得更充分,从而产生那种通透的质感。
对于Unity开发者而言,获取厚度图通常有几个途径:使用Substance Painter、Marmoset Toolbag等专业贴图绘制/烘焙软件;或者寻找一些Unity插件。但很多时候,我们可能没有这些软件,或者希望将厚度烘焙流程整合到自己的项目管线中,实现自动化。这时,理解厚度图的烘焙原理,并能在Unity内通过代码实现,就成了一项非常实用的技能。这不仅让你能深度定制烘焙流程,适应特殊模型,还能在团队协作和项目构建中减少对外部工具的依赖。
2. 厚度图烘焙的核心原理剖析
要自己实现厚度图烘焙,我们必须先搞清楚它的数学和几何基础。厚度图烘焙的本质,是从模型表面“向内”或“沿法线反方向”探测,估算出该点到模型内部或另一表面的距离。
2.1 核心思想:射线步进与最近距离查询
最直观的烘焙方法是射线步进法。对于模型上的每个顶点(或纹理像素对应的表面点),我们沿着其顶点法线(Vertex Normal)的反方向(即指向模型内部的方向)发射一条射线。然后,检测这条射线是否与模型自身再次相交。如果相交,那么首次相交点与射线起点之间的距离,就可以作为该点“厚度”的一个近似值。
为什么是法线反方向?因为对于封闭网格,表面某点的法线方向是朝外的,其反方向最直接地指向模型内部。这是我们寻找“背面”或“对面”的最短路径假设。
计算公式(概念上)可以表示为:厚度 ≈ RayCast(起点, 方向).distance其中,起点是表面点P,方向是-Normalize(顶点法线)。
然而,这种方法存在几个问题:
- 计算量大:每个点都需要进行一次射线检测,对于高模或高分辨率贴图,性能开销巨大。
- 结果不稳定:对于非均匀厚度的复杂形状,沿单一法线方向探测可能无法找到真正的“厚度”,例如在耳朵、鼻孔等部位,法线反方向可能直接射空。
- 需要完整封闭网格:射线法要求网格是水密的(Watertight),否则射线可能穿出模型得不到交点。
2.2 常见优化与实践方案:环境光遮蔽启发
在实际应用中,更常见、更高效的方法是借鉴环境光遮蔽(Ambient Occlusion, AO)或曲率(Curvature)图的生成思路。AO图计算的是该点被周围几何体遮挡的程度,而“厚度”在某种程度上可以看作是“该点被自身几何体在特定方向上遮挡的程度”。
一种广泛使用的近似方法是“顶点法线方向上的遮蔽”。我们不再寻找确切的交点距离,而是采样法线反方向上的一系列点,检查这些点是否在模型内部。通过统计“内部点”的数量或比例,来估算厚度。这通常通过深度图比较或符号距离场(SDF)来实现。
在Unity中,一种可行的GPU烘焙思路如下:
- 将模型渲染到一张纹理(UV空间),同时输出其观察空间位置和顶点法线。
- 在Shader中,对每个像素,沿着其法线反方向,进行若干次(例如8次)步进。
- 每次步进,得到一个采样点的新位置。我们需要判断这个新位置是否在模型“内部”。
- 判断“内部”的关键是:比较该采样点的深度值与模型在该投影方向上的实际深度值。如果采样点的深度值比实际深度值更“远”(即更靠近摄像机),说明该点在模型表面“之前”,是外部;反之,如果采样点深度值比实际深度值更“近”(即更远离摄像机),说明该点穿透了表面,进入了模型内部。
- 累计处于“内部”的步进次数。最终,
厚度 = (内部步进次数 / 总步进次数)。这个值是一个0到1的归一化值,越接近1表示该点越“厚”。
这种方法比单次射线检测更稳定,对非水密网格的容错性更好,并且非常适合在Fragment Shader中并行化计算,利用GPU进行高速烘焙。
注意:这里描述的“深度比较”法需要在同一个空间(通常是观察空间或裁剪空间)下进行,并且要求有一个代表模型表面深度的参考信息,这通常通过从另一个摄像机角度渲染模型得到深度图来实现,或者使用预计算的SDF纹理。
2.3 厚度信息的存储与编码
烘焙得到的厚度值(一个0到1的浮点数)需要存储在一张纹理中。通常我们使用单通道(R通道)的灰度图来存储。黑色(0)代表最薄,白色(1)代表最厚。
有时为了节省资源或兼容工作流,开发者会将厚度信息编码到现有贴图的某个通道中,例如与AO图共用一张纹理的G通道,或者甚至存储到顶点颜色(Vertex Color)中。存储到顶点色的好处是无需额外的纹理采样,在Shader中直接通过v.color读取,节省带宽和纹理单元,但精度受顶点密度限制,适合低模或对厚度变化要求不高的场合。正如网络资料中提到的“用Unity的插件:Mesh Materializer把厚度信息存储在顶点色里面”,就是这种思路的实践。
3. 在Unity中实现厚度图烘焙的完整流程
下面,我将详细介绍一种基于Unity编辑器扩展和渲染纹理(Render Texture)的厚度图烘焙实现方案。这个方案不依赖第三方插件,核心逻辑在Shader中完成。
3.1 环境准备与项目设置
首先,确保你的Unity项目支持可编程渲染管线。URP或Built-in管线均可,但Shader写法略有不同。本文以Built-in管线为例进行说明,因为其Shader语法更为通用。
- 创建烘焙Shader:在项目中创建一个Unlit Shader,命名为“BakeThickness”。我们将在这个Shader中实现厚度计算逻辑。
- 准备目标模型:确保你的模型有合理的UV(第二套UV通常用于光照贴图,但第一套UV必须不重叠且拉伸小,用于烘焙纹理),并且法线信息正确。
- 创建渲染目标:创建一张Render Texture,格式设为
ARGBFloat或RFloat以获得更高的精度,尺寸根据需求设定(如1024x1024)。
3.2 核心烘焙Shader详解
BakeThickness.shader是整个流程的核心。其目标是渲染模型到一张纹理,输出每个片元的厚度值。
Shader "Hidden/BakeThickness" { Properties { _MainTex ("Base (RGB)", 2D) = "white" {} _ThicknessPower ("Thickness Power", Range(0.1, 5)) = 1.0 _StepScale ("Step Scale", Range(0.01, 0.5)) = 0.1 _Steps ("Steps", Int) = 8 } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldPos : TEXCOORD1; float3 worldNormal : TEXCOORD2; }; sampler2D _MainTex; float4 _MainTex_ST; float _ThicknessPower; float _StepScale; int _Steps; // 假设我们通过全局变量传递了模型的深度图(从正交摄像机渲染得到) sampler2D _CameraDepthTexture; float4x4 _WorldToBakeCameraMatrix; float4x4 _BakeCameraProjectionMatrix; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal = UnityObjectToWorldNormal(v.normal); return o; } float frag (v2f i) : SV_Target { float3 worldPos = i.worldPos; float3 worldNormal = normalize(i.worldNormal); // 厚度累计值 float thickness = 0; // 沿着法线反方向(指向内部)步进 float3 stepDir = -worldNormal; float3 currentPos = worldPos; for (int s = 0; s < _Steps; s++) { // 向前步进 currentPos += stepDir * _StepScale; // 将当前步进点转换到烘焙摄像机的裁剪空间 float4 bakeClipPos = mul(_BakeCameraProjectionMatrix, mul(_WorldToBakeCameraMatrix, float4(currentPos, 1.0))); float2 bakeUV = (bakeClipPos.xy / bakeClipPos.w) * 0.5 + 0.5; // 采样烘焙摄像机深度图中的深度(非线性,0-1范围) float sceneDepth = tex2D(_CameraDepthTexture, bakeUV).r; // 将当前步进点的深度也转换到0-1范围(需要与sceneDepth相同的计算方式) float linearDepth = bakeClipPos.z / bakeClipPos.w; // 在某些投影矩阵下,需要进一步处理。这里简化处理,假设是正交投影,深度是线性的。 // 更严谨的做法是使用 LinearEyeDepth 或 Linear01Depth 函数,但需要知道烘焙摄像机的参数。 // 核心判断:如果当前点深度 > 场景深度,说明当前点在模型表面“内部” // 注意:深度值大的离摄像机更远。这里假设烘焙摄像机从模型一侧拍摄。 // 这个判断逻辑需要根据烘焙摄像机的设置(正交、透视)和深度纹理类型进行调整。 if (linearDepth > sceneDepth) { thickness += 1.0; } } // 归一化厚度,并应用强度系数 thickness /= _Steps; thickness = pow(thickness, _ThicknessPower); // 使用幂运算调整对比度 return thickness; } ENDCG } } FallBack "Diffuse" }关键参数解析:
_StepScale: 每次步进的长度。太小则计算慢且可能无法穿透薄区域,太大则精度低。通常设置为模型包围盒尺寸的一个小比例(如0.01-0.05)。_Steps: 步进次数。次数越多,结果越平滑准确,但性能开销越大。通常8-16次是质量与性能的平衡点。_ThicknessPower: 最终厚度值的幂次方,用于调整厚度图的对比度。>1会让薄厚对比更强烈,<1会让对比更柔和。_CameraDepthTexture: 这是一张从另一个“烘焙摄像机”视角渲染的模型深度图。这个摄像机通常使用正交投影,从模型的一侧(如侧面或顶部)拍摄,以确保能捕捉到模型在厚度探测方向上的深度轮廓。
3.3 编辑器脚本驱动烘焙流程
有了Shader,我们需要一个C#编辑器脚本来自动化整个烘焙流程:设置摄像机、渲染深度图、用厚度Shader渲染到纹理、最后保存为图片。
using UnityEngine; using UnityEditor; using System.IO; public class ThicknessMapBaker : EditorWindow { private GameObject targetModel; private Shader bakeShader; private int textureSize = 1024; private float stepScale = 0.05f; private int steps = 10; private float thicknessPower = 1.5f; private string savePath = "Assets/BakedThickness.png"; [MenuItem("Tools/Bake Thickness Map")] static void Init() { var window = GetWindow<ThicknessMapBaker>(); window.titleContent = new GUIContent("Thickness Baker"); window.Show(); } void OnGUI() { GUILayout.Label("Thickness Map Baking Settings", EditorStyles.boldLabel); targetModel = (GameObject)EditorGUILayout.ObjectField("Target Model", targetModel, typeof(GameObject), true); bakeShader = (Shader)EditorGUILayout.ObjectField("Bake Shader", bakeShader, typeof(Shader), false); textureSize = EditorGUILayout.IntField("Texture Size", textureSize); stepScale = EditorGUILayout.FloatField("Step Scale", stepScale); steps = EditorGUILayout.IntField("Steps", steps); thicknessPower = EditorGUILayout.FloatField("Thickness Power", thicknessPower); savePath = EditorGUILayout.TextField("Save Path", savePath); if (GUILayout.Button("Bake!") && targetModel != null && bakeShader != null) { BakeThickness(); } } void BakeThickness() { // 1. 创建临时摄像机用于渲染深度图 GameObject bakeCamGo = new GameObject("BakeCamera"); Camera bakeCam = bakeCamGo.AddComponent<Camera>(); bakeCam.orthographic = true; // 将摄像机对准模型,并确保其观察方向与模型主要厚度方向垂直(例如,对于人体,从侧面看) Bounds bounds = targetModel.GetComponent<Renderer>().bounds; bakeCam.transform.position = bounds.center + Vector3.right * bounds.extents.x * 2f; // 从右侧看 bakeCam.transform.LookAt(bounds.center); bakeCam.orthographicSize = bounds.extents.y; bakeCam.nearClipPlane = 0.01f; bakeCam.farClipPlane = bounds.size.z * 2f; // 2. 渲染深度图到临时RenderTexture RenderTexture depthRT = RenderTexture.GetTemporary(textureSize, textureSize, 24, RenderTextureFormat.Depth); RenderTexture prevRT = bakeCam.targetTexture; bakeCam.targetTexture = depthRT; bakeCam.Render(); bakeCam.targetTexture = prevRT; // 3. 创建材质并设置参数 Material bakeMat = new Material(bakeShader); bakeMat.SetFloat("_StepScale", stepScale); bakeMat.SetInt("_Steps", steps); bakeMat.SetFloat("_ThicknessPower", thicknessPower); // 需要将深度图和摄像机矩阵传递给Shader,这里需要将深度图复制到一张可读的RT中 RenderTexture readableDepthRT = RenderTexture.GetTemporary(textureSize, textureSize, 0, RenderTextureFormat.RFloat); Graphics.Blit(depthRT, readableDepthRT); bakeMat.SetTexture("_CameraDepthTexture", readableDepthRT); // 传递矩阵(世界到烘焙摄像机,烘焙摄像机投影矩阵) bakeMat.SetMatrix("_WorldToBakeCameraMatrix", bakeCam.worldToCameraMatrix); bakeMat.SetMatrix("_BakeCameraProjectionMatrix", bakeCam.projectionMatrix); // 4. 使用厚度Shader将模型渲染到另一张RT RenderTexture thicknessRT = RenderTexture.GetTemporary(textureSize, textureSize, 0, RenderTextureFormat.ARGBFloat); Graphics.Blit(null, thicknessRT, bakeMat); // 使用Blit渲染全屏Quad,但需要模型信息。更正确的方式是: // 应该使用一个替代渲染流程,将目标模型用bakeMat绘制到thicknessRT。 // 这里简化描述,实际实现可能需要使用CommandBuffer或Graphics.DrawMesh。 // 5. 将RenderTexture保存为PNG Texture2D tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBAFloat, false); RenderTexture.active = thicknessRT; tex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0); tex.Apply(); byte[] bytes = tex.EncodeToPNG(); File.WriteAllBytes(savePath, bytes); AssetDatabase.Refresh(); // 6. 清理资源 RenderTexture.ReleaseTemporary(depthRT); RenderTexture.ReleaseTemporary(readableDepthRT); RenderTexture.ReleaseTemporary(thicknessRT); DestroyImmediate(bakeCamGo); DestroyImmediate(bakeMat); DestroyImmediate(tex); Debug.Log($"Thickness map baked to: {savePath}"); } }实操要点与注意事项:
- 摄像机设置:烘焙深度图用的摄像机最好使用正交投影。透视投影会导致深度值非线性,在边缘部分计算厚度时容易出错。正交投影能提供均匀的深度采样,结果更准确。
- 深度图精度:确保深度RenderTexture的精度足够(如24位或32位)。低精度深度图在比较时会产生带状瑕疵。
- 矩阵传递:将烘焙摄像机的
worldToCameraMatrix和projectionMatrix正确传递到Shader中至关重要,这保证了步进点坐标能正确转换到深度图所在的投影空间进行比较。 - 模型变换:上述示例假设模型在世界空间中进行计算。如果模型有缩放、旋转,需要确保法线转换正确(使用逆转置矩阵),或者将所有计算转移到模型空间或切线空间进行,以简化问题。
- 性能:烘焙过程,尤其是高模和高分辨率输出,是GPU密集型操作。建议在编辑器模式下进行,或作为AssetPostprocessor在模型导入时自动执行。
4. 厚度图在Shader中的实际应用与问题排查
烘焙得到厚度图后,我们如何在URP或Built-in管线的Shader中使用它呢?
4.1 在URP Shader中实现简易次表面散射
以下是一个在URP Lit Shader基础上,添加基于厚度图的次表面散射效果的简化示例:
// 在Properties块添加 _ThicknessMap("Thickness Map", 2D) = "white" {} _SSSColor("SSS Color", Color) = (1,0.7,0.5,1) _SSSPower("SSS Power", Range(0.1, 10)) = 3.0 _SSSScale("SSS Scale", Range(0, 5)) = 1.0 // 在CBUFFER或变量声明处添加 TEXTURE2D(_ThicknessMap); SAMPLER(sampler_ThicknessMap); float4 _ThicknessMap_ST; half4 _SSSColor; half _SSSPower; half _SSSScale; // 在片元着色器中,计算完基础光照后,添加SSS贡献 void frag(...) { // ... 原有的光照计算,得到 surfaceData, lightingData ... // 采样厚度图 float thickness = SAMPLE_TEXTURE2D(_ThicknessMap, sampler_ThicknessMap, uv).r; // 对厚度进行重映射和幂运算,控制散射效果的范围和强度 thickness = pow(thickness, _SSSPower) * _SSSScale; // 简化模型:将厚度作为背面光(或边缘光)的增强因子 // 计算视角方向与光方向的点积,模拟光从背面透射过来 float3 viewDir = GetWorldSpaceNormalizeViewDir(positionWS); float3 lightDir = normalize(_MainLightPosition.xyz); // 假设主光源 float VdotL = dot(viewDir, lightDir); // 当视线方向与光源方向大致相反时(即看透光部分),VdotL为负值 float sssIntensity = saturate(-VdotL); // 结合厚度影响 sssIntensity *= thickness; // 计算SSS颜色贡献 float3 sssColor = _SSSColor.rgb * sssIntensity * _MainLightColor.rgb; // 将SSS颜色加到最终输出上(通常是加法混合) color.rgb += sssColor; return color; }这个实现是一个高度简化的“屏幕空间”风格的SSS,它利用视角与光方向的夹角和厚度图来模拟光线穿透效果。对于更物理的次表面散射,可能需要考虑更多光线散射模型,如扩散剖面(Diffusion Profile)。
4.2 常见问题与排查技巧实录
在烘焙和使用厚度图的过程中,你肯定会遇到各种问题。下面是我踩过的一些坑和解决方案:
问题1:烘焙出的厚度图全黑或全白。
- 排查思路:
- 检查深度图:首先确认用于比较的深度图是否渲染正确。在编辑器里将
_CameraDepthTexture临时输出为颜色查看,确保模型深度信息被正确捕捉。 - 检查矩阵:确保从世界空间到烘焙摄像机裁剪空间的变换矩阵(
_WorldToBakeCameraMatrix和_BakeCameraProjectionMatrix)正确传递到了Shader。一个常见的错误是矩阵乘法的顺序或空间不一致。 - 检查步进方向:确认在Shader中步进方向是
-worldNormal。可以尝试输出步进方向作为颜色来可视化。 - 调整步进参数:
_StepScale可能太大(直接步进到模型外)或太小(没有步进到内部)。_Steps可能太少。尝试调整这些参数,并逐步调试。
- 检查深度图:首先确认用于比较的深度图是否渲染正确。在编辑器里将
问题2:厚度图在模型接缝或UV边界处出现撕裂或瑕疵。
- 原因:这通常是UV问题。烘焙过程依赖于UV来对应纹理像素和模型表面点。如果UV有重叠、拉伸严重或者接缝处顶点被拆分导致法线不连续,就会出问题。
- 解决方案:
- 优化UV:确保用于烘焙厚度图的UV(通常是第一套UV)没有重叠,且拉伸尽可能小。可以使用UV检查工具。
- 使用顶点色存储:如果UV问题无法解决,考虑放弃纹理烘焙,改用网络资料中提到的插件将厚度信息直接烘焙到顶点颜色。这样完全规避了UV问题,但精度取决于顶点密度。
- 后期处理:对烘焙出的厚度图进行一次轻微的高斯模糊,可以平滑接缝处的瑕疵,但会损失一些细节。
问题3:在Shader中使用厚度图时,SSS效果边缘生硬或不自然。
- 原因:厚度图对比度太强,或者SSS计算模型过于简单。
- 解决方案:
- 调整厚度图:在烘焙时或使用时,通过
pow(thickness, _SSSPower)来调整厚度图的对比度。降低_SSSPower可以使薄厚过渡更平滑。 - 模糊厚度图:在导入厚度图时,将其纹理类型设置为“Default”,并勾选“Generate Mip Maps”,或者在Shader采样时使用三线性过滤,可以让边缘过渡更柔和。
- 优化光照计算:上述简易SSS模型仅考虑了主光和视角。可以尝试结合更多光源,或者使用更复杂的散射模型,如将厚度与曲率(Curvature)图结合使用,让散射效果在凹槽和边缘也更明显。
- 调整厚度图:在烘焙时或使用时,通过
问题4:烘焙过程太慢。
- 优化方向:
- 降低烘焙分辨率:如果不是用于特写镜头,512x512的厚度图通常足够。
- 减少步进次数:尝试将
_Steps从16降到8,看看质量是否可接受。 - 模型LOD:对高模烘焙时,可以先用一个简化版的模型(但外形近似)烘焙厚度图,然后用于高模渲染。因为厚度是宏观体积信息,对拓扑细节不敏感。
- 异步或离线烘焙:将烘焙脚本设置为在模型导入时自动执行(使用
AssetPostprocessor),这样构建项目时厚度图已经就绪,不影响运行时性能。
厚度图烘焙是一个融合了几何、渲染和Shader编程的实用技能。自己动手实现一遍,不仅能让你在遇到第三方工具不兼容或效果不满意时游刃有余,更能加深对渲染中“体积”和“光传输”概念的理解。从简单的灰度图开始,逐步尝试将厚度与AO、曲率图结合,你甚至可以创造出风格化渲染中独特的边缘光或内部发光效果。记住,所有参数(步长、次数、强度、对比度)都没有银弹,需要根据你的具体模型和艺术目标进行反复调试和视觉验证。