
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 { RenderTypeOpaque } 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 GetWindowThicknessMapBaker(); 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.AddComponentCamera(); bakeCam.orthographic true; // 将摄像机对准模型并确保其观察方向与模型主要厚度方向垂直例如对于人体从侧面看 Bounds bounds targetModel.GetComponentRenderer().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、曲率图结合你甚至可以创造出风格化渲染中独特的边缘光或内部发光效果。记住所有参数步长、次数、强度、对比度都没有银弹需要根据你的具体模型和艺术目标进行反复调试和视觉验证。