Unity动态贴花实现:基于ShaderGraph与模型UV的3D模型纹身系统

发布时间:2026/7/23 7:34:56
Unity动态贴花实现:基于ShaderGraph与模型UV的3D模型纹身系统 1. 项目概述在3D模型上实现动态“纹身”在Unity里做3D项目尤其是角色扮演或者自定义程度高的游戏给模型加个“纹身”或者“贴花”功能是个挺常见的需求。想象一下玩家可以给自己的角色在手臂上纹个龙在脸上画个战痕或者给一辆赛车贴上赞助商Logo这种动态改变模型表面图案的能力能极大提升游戏的沉浸感和个性化体验。这个功能听起来简单不就是贴张图嘛但真做起来你会发现从美术资源准备、Shader编写、到脚本逻辑控制每一步都有不少门道。直接用标准材质球改贴图只能覆盖整个模型没法做到局部、动态、且贴合模型表面起伏的精准“纹身”效果。所以这个项目的核心目标就是利用Unity的ShaderGraph可视化着色器工具配合C#脚本实现一个高性能、易用、且效果自然的动态贴花Decal或者说“纹身”系统。它允许你在运行时将任意2D纹理图案“投射”或“绘制”到3D模型的特定部位并且图案会随着模型的表面法线和UV而正确变形就像真的纹上去一样。相比于网上一些复杂的屏幕空间贴花方案我们这次聚焦于一种更直接、对性能更友好的模型空间方案特别适合角色定制、装备标记等场景。2. 核心思路与方案选型为什么是ShaderGraph 模型空间UV实现动态贴花主流思路有好几种比如投影贴花Projector Decal、屏幕空间贴花Screen Space Decal、以及基于模型顶点数据的方案。投影贴花是Unity的老牌功能但配置繁琐在曲面和复杂模型上容易变形且性能开销不小。屏幕空间贴花效果精准但实现复杂并且依赖于摄像机视角不适合需要持久化保存的“纹身”需求。我们选择的是一条更“物理”的路径利用模型第二套UVUV1或顶点颜色作为“画布”通过ShaderGraph动态混合纹理。其核心思想是准备“画布”要求3D模型除了用于主纹理的UV0之外最好有第二套展开的UVUV1。这套UV就是我们的“纹身绘制区域”。如果模型没有UV1我们也可以用脚本动态计算或使用顶点颜色Color通道作为坐标替代但精度和灵活性会差一些。传递“纹身”信息通过C#脚本将“纹身”的图案Texture、绘制位置UV坐标、旋转、缩放等信息传递给材质球。着色器混合在ShaderGraph中读取这些信息在模型的UV1空间内将“纹身”纹理与模型原有的颜色Albedo进行混合。混合方式可以是普通的Alpha混合也可以是更复杂的叠加、正片叠底等以模拟油墨、疤痕等不同效果。选择ShaderGraph而不是手写Shader是因为它的可视化节点编辑方式让美术和程序都能快速理解、调整效果。而选择模型空间UV方案是因为它的结果与摄像机无关一旦“纹身”被应用它就是模型的一部分可以被保存、加载性能消耗也基本是固定的不会因为摄像机数量或角度变化而增加。注意这个方案的前提是目标模型有可用的第二套UVUV1。在向美术提资源需求时务必明确这一点。对于程序化生成的模型可能需要运行时生成简单的UV1。2.1 工具与资源准备在开始动手前你需要确保Unity工程已经就绪。我使用的是Unity 2022.3 LTS版本这个版本对ShaderGraph和URP通用渲染管线的支持非常稳定。当然2021.3 LTS或更新的版本也都可以。渲染管线确认ShaderGraph必须运行在可编程渲染管线SRP下主要是URP通用渲染管线或HDRP高清渲染管线。对于大多数移动端和PC端项目URP是首选。打开Window - Rendering - Render Pipeline Converter可以检查和转换你的项目到URP。安装ShaderGraph包通过Package ManagerWindow - Package Manager选择Unity Registry搜索并安装 “Shader Graph” 和 “Universal RP” 包。如果你是URP项目通常已经安装。准备3D模型找一个用于测试的模型比如一个简单的角色或一个球体。关键点在3D建模软件如Blender, Maya中确保该模型除了默认的UV0用于漫反射贴图等还有第二套UV通道UV1。在Unity中导入模型后在Import Settings的Model标签页下勾选 “Generate Lightmap UVs” 可能会自动生成一套UV但这套UV通常是为光照贴图优化的可能不符合我们的需求。最稳妥的方式还是在DCC软件中手动展好UV1。准备“纹身”纹理准备一张PNG格式的图案最好是透明背景Alpha通道。图案内容可以是Logo、花纹、文字等。纹理尺寸建议为2的幂次方如256x256, 512x512并设置为Sprite (2D and UI)或Default类型确保Read/Write Enabled被勾选以便脚本可能需要的动态读写。3. 构建ShaderGraph创建“纹身”着色器这是项目的核心我们将一步步在ShaderGraph中搭建混合逻辑。3.1 创建与基础设置在Project窗口中右键选择Create - Shader Graph - URP - Lit Shader Graph命名为TattooShader。双击打开。首先我们需要设置一些图形属性。在Blackboard黑板区域点击“”号添加以下属性_BaseMap(Texture2D) 这是模型原本的漫反射贴图。将其Reference重命名为_MainTex以符合习惯。_BaseColor(Color) 模型的基础色调。_TattooTexture(Texture2D) 这就是我们要绘制的“纹身”图案。这是关键属性。_TattooUVOffset(Vector2) 用于控制纹身在UV1空间中的位置偏移X, Y。_TattooUVScale(Vector2) 用于控制纹身在UV1空间中的缩放X, Y。通常默认是(1,1)。_TattooRotation(Float) 控制纹身的旋转角度度。_TattooColor(Color) 可以给纹身叠加一个颜色用于变色效果。_TattooStrength(Float) 控制纹身的显示强度或混合强度1.0为完全显示0.0为完全隐藏。将这些属性的Exposed复选框勾选这样它们就会在材质球面板上显示并且可以通过脚本修改。3.2 UV采样与变换逻辑这是最关键的步骤我们需要在UV1空间中对_TattooTexture进行采样。获取UV1在Master Stack的Vertex阶段默认的UV节点输出的是第一套UVUV0。为了获取UV1我们需要一个UV节点。创建该节点后在其Inspector面板中将Channel从Default改为UV1。这个节点的输出就是模型顶点数据中的第二套UV坐标。构建UV变换我们需要对UV1进行偏移、缩放和旋转以精确定位纹身。这需要一个变换矩阵或一系列计算节点。一个清晰的方法是使用Tiling And Offset节点和Rotate节点。首先将UV1节点连接到Tiling And Offset节点的UV输入口。将_TattooUVScale属性节点连接到Tiling输入口。这里需要注意Tiling是缩放的反比。如果你想放大纹身让它在UV空间里覆盖更小区域应该设置_TattooUVScale为小于1的值如0.5。更直观的做法是将_TattooUVScale先通过一个Divide除法节点用常数1除以它再将结果给到Tiling。或者我们可以在脚本中处理好这个逻辑这里为了图方便可以先直接用属性连接在脚本中调整数值逻辑。将_TattooUVOffset属性节点连接到Offset输入口。将Tiling And Offset节点的输出连接到一个Rotate节点的UV输入口。将_TattooRotation属性节点连接到Rotate节点的Rotation输入口。注意Rotate节点的Rotation输入是弧度制而我们的属性是角度制。因此中间需要插入一个Degrees To Radians节点进行转换。采样纹身纹理将经过变换的UV即Rotate节点的输出连接到一个Sample Texture 2D节点的UV输入口。将_TattooTexture属性节点连接到该节点的Texture输入口。这个节点的RGBA输出就是我们在指定位置、大小、角度采样到的纹身颜色。3.3 颜色混合与输出采样到纹身颜色后我们需要将其与模型原本的颜色进行混合。采样基础颜色使用另一个Sample Texture 2D节点来采样_MainTex其UV使用默认的UV0可以从一个UV节点获取Channel设为Default。然后将采样结果与_BaseColor属性节点用Multiply节点相乘得到模型的最终基础色BaseColor。应用纹身颜色将纹身采样的RGBA输出与_TattooColor属性节点用Multiply节点相乘得到调整颜色后的纹身色TattooColor。Alpha混合这是最常用的混合方式。我们使用Lerp线性插值节点。将BaseColor连接到Lerp节点的A输入口。将TattooColor的RGB通道连接到Lerp节点的B输入口。混合因子T需要由纹身的Alpha通道和_TattooStrength共同决定。将TattooColor的AAlpha通道与_TattooStrength属性节点用Multiply节点相乘得到最终的混合因子FinalAlpha。将这个FinalAlpha连接到Lerp节点的T输入口。Lerp节点的输出就是混合后的颜色。将其连接到Master Stack的Base Color输入口。处理纹身遮挡可选但重要简单的Alpha混合在复杂光照下可能不真实特别是纹身区域的光照和粗糙度应该与皮肤一致而不是覆盖它。更高级的做法是法线贴图混合如果纹身是凸起的如疤痕可以准备一张法线贴图用类似的方式混合到模型的法线通道上。遮挡金属度/光滑度通常纹身不应该影响皮肤的金属感和光滑度。所以Master Stack的Metallic、Smoothness、Occlusion等输入应该直接使用模型原本的相应贴图或数值不要被纹身节点影响。确保这些输入口连接的是来自模型原始数据的节点。至此一个基础的纹身ShaderGraph就搭建完成了。保存后创建一个材质球Material使用这个Shader。将你的模型拖入场景把这个材质赋给模型。在材质面板你可以手动拖入_MainTex和_TattooTexture并调整_TattooUVOffset等参数应该能看到纹身图案出现在模型基于UV1定义的位置上。4. C#脚本驱动实现运行时动态纹身手动在材质面板调整固然可以但我们的目标是运行时动态控制。这就需要编写C#脚本来驱动Shader的属性。4.1 核心控制器脚本创建一个C#脚本命名为TattooController.cs。这个脚本将挂载到需要纹身功能的模型或其父物体上。using UnityEngine; [RequireComponent(typeof(Renderer))] public class TattooController : MonoBehaviour { [Header(纹身配置)] public Texture2D tattooTexture; // 纹身图案 public Color tattooColor Color.white; // 纹身叠加色 [Range(0, 1)] public float tattooStrength 1.0f; // 纹身强度 public Vector2 uvOffset Vector2.zero; // UV偏移 public Vector2 uvScale Vector2.one; // UV缩放 public float rotation 0f; // 旋转角度 [Header(高级设置)] public string mainTexProperty _MainTex; public string tattooTexProperty _TattooTexture; public string tattooColorProperty _TattooColor; public string tattooStrengthProperty _TattooStrength; public string uvOffsetProperty _TattooUVOffset; public string uvScaleProperty _TattooUVScale; public string rotationProperty _TattooRotation; private Renderer targetRenderer; private MaterialPropertyBlock mpb; void Start() { Initialize(); ApplyTattooProperties(); // 初始化应用一次 } void Initialize() { targetRenderer GetComponentRenderer(); if (targetRenderer null) { Debug.LogError(TattooController: No Renderer found on this GameObject!); return; } mpb new MaterialPropertyBlock(); targetRenderer.GetPropertyBlock(mpb); // 获取现有的属性块 } /// summary /// 应用所有纹身属性到材质 /// /summary public void ApplyTattooProperties() { if (targetRenderer null || mpb null) { Initialize(); if (targetRenderer null) return; } if (tattooTexture ! null) mpb.SetTexture(tattooTexProperty, tattooTexture); mpb.SetColor(tattooColorProperty, tattooColor); mpb.SetFloat(tattooStrengthProperty, tattooStrength); mpb.SetVector(uvOffsetProperty, uvOffset); mpb.SetVector(uvScaleProperty, uvScale); mpb.SetFloat(rotationProperty, rotation); targetRenderer.SetPropertyBlock(mpb); } /// summary /// 动态设置纹身纹理 /// /summary public void SetTattooTexture(Texture2D newTexture) { tattooTexture newTexture; ApplyTattooProperties(); } /// summary /// 动态设置纹身位置UV偏移 /// /summary public void SetTattooPosition(Vector2 newUvOffset) { uvOffset newUvOffset; ApplyTattooProperties(); } /// summary /// 动态设置纹身缩放 /// /summary public void SetTattooScale(Vector2 newUvScale) { uvScale newUvScale; ApplyTattooProperties(); } /// summary /// 动态设置纹身旋转 /// /summary public void SetTattooRotation(float newRotation) { rotation newRotation; ApplyTattooProperties(); } /// summary /// 动态设置纹身显示强度 /// /summary public void SetTattooStrength(float newStrength) { tattooStrength Mathf.Clamp01(newStrength); ApplyTattooProperties(); } // 在Inspector中修改值后实时更新仅在编辑器下 void OnValidate() { if (Application.isPlaying) { ApplyTattooProperties(); } } }脚本核心解析与避坑点使用MaterialPropertyBlock这是关键千万不要直接使用targetRenderer.material来修改属性。因为renderer.material会创建一个该材质的新实例如果多个物体共享同一个材质修改其中一个会导致所有物体都受影响并且会产生新的材质实例增加内存开销和Draw Call。MaterialPropertyBlock允许我们覆盖材质上的特定属性而不会创建新的材质实例性能最佳。属性名匹配脚本中public string定义的属性名如_TattooTexture必须与你在ShaderGraph中创建的Property的Reference名字完全一致大小写敏感。建议将这些名字定义成常量但为了编辑器灵活性这里暴露成了可配置项。初始化时机在Start()中初始化并应用一次。OnValidate方法确保了在Unity编辑器内修改Inspector上的参数时效果能实时刷新这对调试非常有用。UV Scale的语义在Shader中我们直接将uvScale用在了Tiling上这会导致缩放逻辑是反的数值小反而放大。为了更直观你可以在ApplyTattooProperties方法里对uvScale取倒数后再传递给Shadermpb.SetVector(uvScaleProperty, new Vector2(1f/uvScale.x, 1f/uvScale.y));。或者在ShaderGraph里用Divide节点处理。这里需要根据你的美术需求统一约定。4.2 进阶射线检测与精准绘制上面的脚本提供了基础的API。一个更完整的“纹身”功能可能包含通过鼠标点击或触摸在模型表面指定位置绘制纹身。这需要用到射线检测Raycast来获取碰撞点的UV坐标。创建一个新脚本TattooPainter.cs或扩展上面的控制器using UnityEngine; public class TattooPainter : MonoBehaviour { public Camera raycastCamera; // 发射射线的摄像机通常是主摄像机 public LayerMask paintableLayer; // 可绘制层的掩码 public TattooController tattooController; // 关联的纹身控制器 public Texture2D[] tattooBrushes; // 预设的纹身笔刷纹理 public float brushSize 0.1f; // 笔刷大小在UV空间中的缩放 void Update() { if (Input.GetMouseButtonDown(0)) // 例如左键点击绘制 { Ray ray raycastCamera.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, Mathf.Infinity, paintableLayer)) { // 判断击中的是否是目标物体 if (hit.collider.gameObject tattooController.gameObject) { // 获取击中点的纹理坐标UV // 注意这里获取的是MeshCollider上的UV通常是UV0。 // 我们的纹身基于UV1所以这个方法直接获取UV1可能不准。 // 更可靠的方法是如果MeshCollider的Mesh和Renderer的Mesh是同一个且UV1存在可以通过hit.triangleIndex索引顶点数据来获取UV1。 // 这是一个简化示例假设UV0和UV1有映射关系或者模型使用了UV0作为纹身画布。 Vector2 uv hit.textureCoord; // 设置纹身位置将击中点的UV作为偏移 tattooController.SetTattooPosition(uv - new Vector2(brushSize/2, brushSize/2)); // 让纹身中心对准点击点 // 设置纹身缩放模拟笔刷大小 tattooController.SetTattooScale(new Vector2(brushSize, brushSize)); // 随机选择一个笔刷纹理如果有的话 if (tattooBrushes ! null tattooBrushes.Length 0) { int randomIndex Random.Range(0, tattooBrushes.Length); tattooController.SetTattooTexture(tattooBrushes[randomIndex]); } Debug.Log($Tattoo applied at UV: {uv}); } } } } }关于射线检测获取UV1的深度避坑点上面代码中的hit.textureCoord返回的是射线击中点在MeshCollider上的第一套UVUV0。如果你的纹身Shader使用的是UV1那么直接用这个坐标去设置_TattooUVOffset会导致纹身位置错乱因为UV0和UV1是两套不同的坐标映射。解决方案有两种统一使用UV0作为画布这是最简单的方法。在ShaderGraph中将“获取UV1”的节点改为“获取UV0”。这样hit.textureCoord获取的坐标就能直接对应纹身位置。但代价是模型的漫反射贴图通常也用UV0会和纹身共享同一套UV布局可能互相干扰。你需要确保模型的UV0有足够的“空白”区域来绘制纹身。通过三角形索引计算UV1高级这是更精确但复杂的方法。需要获取模型的Mesh数据通过hit.triangleIndex找到被击中的三角形然后根据重心坐标hit.barycentricCoordinate插值计算出该点在UV1上的坐标。这涉及到对Mesh数据的读取和处理性能开销较大适合离线编辑工具不适合高频的运行时绘制。// 伪代码展示思路 Mesh mesh hit.collider.GetComponentMeshFilter().sharedMesh; Vector2[] uv1 mesh.uv2; // 假设uv2对应UV1 int[] triangles mesh.triangles; int hitTriangleIndex hit.triangleIndex * 3; Vector2 uv1A uv1[triangles[hitTriangleIndex]]; Vector2 uv1B uv1[triangles[hitTriangleIndex 1]]; Vector2 uv2C uv1[triangles[hitTriangleIndex 2]]; Vector2 hitUV1 hit.barycentricCoordinate.x * uv1A hit.barycentricCoordinate.y * uv1B hit.barycentricCoordinate.z * uv2C;对于大多数需要运行时动态绘制的游戏推荐使用UV0方案并让美术在展UV0时预留出绘制区域例如将角色的皮肤部分单独展开到一个象限。如果必须使用UV1且需要精准点击可以考虑预计算UV0到UV1的映射关系图一张贴图但实现复杂度更高。5. 性能优化与高级技巧一个基础功能跑通后我们得考虑它在真实项目中的表现。特别是如果场景中有成百上千个带纹身的角色性能问题就会凸显。5.1 合批与GPU Instancing默认情况下每个使用了MaterialPropertyBlock的Renderer如果材质相同但属性不同可能会打断Unity的静态/动态合批。为了优化我们需要启用GPU Instancing。在ShaderGraph中启用Instancing在Graph Inspector中勾选Graph Settings下的Allow Material Overrides和GPU Instancing。这会在生成的Shader代码中添加对UNITY_INSTANCING_BUFFER_START等宏的支持。脚本中设置属性使用MaterialPropertyBlock本身是支持Instancing的。确保你修改的属性都在Shader中正确定义。对于我们要动态修改的纹理和向量属性需要在Shader中将其声明为[PerRendererData]但ShaderGraph的黑板属性默认似乎没有直接暴露这个选项。更常见的做法是即使打断了合批由于我们使用的是PropertyBlockDraw Call的增加也是有限的性能损耗主要在于状态切换。对于大量相同纹身的情况可以考虑将纹身纹理打包成图集Atlas所有角色共享一个材质实例通过PropertyBlock传递不同的UV偏移来选取图集中的不同图案这样可以最大程度保持合批。5.2 纹理图集与多纹身支持一个角色可能需要在不同部位有多个纹身。我们的当前Shader只支持一张纹身贴图。如何支持多个纹理图集Texture Atlas将多个小纹身图案合并到一张大纹理中。在Shader中我们不再传递一个整体的_TattooUVOffset和_TattooUVScale而是传递每个纹身在图集内的矩形区域一个Vector4x, y, width, height。在ShaderGraph中需要先用这个区域信息对UV1进行变换再采样大图集。这需要更复杂的节点计算但一个材质球就能支持无数种纹身组合性能极佳。多通道混合创建支持多个纹身纹理采样的Shader例如_TattooTexture1,_TattooTexture2... 每个都有独立的UV变换和强度属性。在片元着色器里按顺序或特定规则混合。这种方法更灵活但Shader复杂度、材质属性数量和性能开销都会成倍增加。通常2-4个通道是实用上限。运行时合并纹理RenderTexture这是一个更动态但开销较大的方案。创建一个与模型UV1布局对应的RenderTexture作为“皮肤画布”。初始化时将模型的基础颜色渲染到这张RT上。每次添加纹身就像Photoshop一样将纹身图案以正确的变换绘制到这张RT的对应位置。最后在Shader中直接采样这张RT作为模型的_BaseMap。这种方法非常强大支持任意数量、任意混合模式的纹身并且纹身数据就是一张纹理易于保存和加载。但缺点是需要管理RT的生命周期并且绘制操作使用Graphics.Blit有CPU到GPU的开销。对于大多数游戏内的角色定制纹理图集是最推荐的方式。美术预先制作好包含所有可选图案的图集程序只需要传递图案的索引和位置信息即可。5.3 保存与加载纹身数据玩家的纹身配置需要持久化。保存的数据量要尽可能小。图集方案保存每个纹身的图集ID整数和其在模型UV空间中的变换参数位置、缩放、旋转。一个纹身的数据可以压缩成一个Vector4位置x,y, 缩放x, 缩放y加一个float旋转加一个int图案ID。RT方案直接保存生成的RenderTexture为图片文件如PNG。数据量较大但包含所有细节。混合方案保存绘制命令序列纹身ID 变换参数加载时重新“播放”一遍绘制命令来重建RT。节省存储空间但加载时需要计算时间。6. 常见问题与排查实录在实际集成过程中你肯定会遇到各种奇怪的现象。下面是我踩过的一些坑和解决办法。6.1 纹身不显示或显示全黑/全白检查1Shader编译与材质赋值确保ShaderGraph成功编译无红色错误节点。确保模型使用的材质球确实选了你创建的TattooShader。在材质面板手动拖入_TattooTexture看是否显示。如果不显示问题在Shader内。检查2UV通道是否正确这是最常见的问题。在ShaderGraph中确认UV节点的Channel设置为你模型实际用于纹身的通道UV0 或 UV1。在3D软件或Unity的模型导入设置中确认模型确实包含了所需的UV通道。可以在场景中选择模型在Inspector的Mesh Renderer组件下查看Additional Settings里的Lightmap Static等选项有时会影响UV。检查3纹理导入设置确认你的纹身PNG纹理的Alpha Source不是None否则透明通道会丢失。确认Read/Write Enabled被勾选特别是脚本需要动态设置纹理时。纹理类型不要设为Normal map除非它真是法线贴图。检查4PropertyBlock设置在脚本中Debug.Log输出你通过mpb.SetXXX设置的值看看是否传递成功。检查属性名字符串是否与ShaderGraph中的Reference名字完全一致一个空格或大小写错误都会导致失败。确保ApplyTattooProperties()方法被正确调用。6.2 纹身位置、大小、旋转不对位置偏移_TattooUVOffset的(0,0)点对应UV空间的左下角(1,1)对应右上角。调整时注意坐标范围。射线检测获取的UV是击中点的精确坐标直接设置为偏移意味着纹身的左下角会对准该点。如果你希望纹身中心对准点击点需要减去纹身缩放的一半如脚本示例所示。缩放反向如前所述Shader中Tiling参数与直观的“缩放”概念是反的。要么在脚本传入前对uvScale取倒数要么在ShaderGraph中用Divide (1 / UVScale)节点处理。旋转中心ShaderGraph的Rotate节点默认绕UV空间的原点(0,0)旋转。如果你先做了偏移(Offset)再旋转纹身会绕着原点转而不是自身中心转。通常的绘制逻辑是先缩放再旋转最后偏移。这需要你按顺序连接节点UV1-Tiling And Offset(仅Tiling) -Rotate-Tiling And Offset(仅Offset)。或者使用Rotate节点提供的Center输入口来指定旋转中心。6.3 性能问题Draw Call过高罪魁祸首MaterialPropertyBlock与合批即使使用了PropertyBlock如果每个物体的属性都不同Unity仍然无法对它们进行动态合批。解决方案就是前面提到的纹理图集。让所有角色共享同一个材质实例和同一张大纹理通过PropertyBlock传递不同的UV变换参数这些参数是每渲染器数据不影响合批这样相同材质的物体依然有机会合批。检查合批在Game视图右上角点击Stats查看Batches和SetPass calls。给多个物体添加纹身后观察这些数字是否急剧增加。如果增加了说明合批被打破。减少纹身分辨率不是所有纹身都需要1024x1024。根据模型大小和观看距离适当降低纹身纹理的分辨率。避免每帧更新除非是实时绘制功能否则只在纹身发生变化时调用ApplyTattooProperties()。6.4 光影与纹理冲突纹身区域反光不对确保你的ShaderGraph中纹身纹理只影响了Base Color通道。Metallic、Smoothness、Normal、Occlusion等通道应该连接模型原始的属性或贴图。如果纹身是伤疤可以单独采样一张法线贴图连接到Normal但强度要控制好。纹身在特定光照下消失检查你的Shader是否是Lit Shader Graph。如果是Unlit则没有光照交互。在Lit Shader中确保光照计算发生在颜色混合之后。我们的节点连接顺序先混合颜色再将结果给Base Color是正确的。与主纹理重叠如果主纹理_MainTex在UV1区域也有内容纹身会和它混合。通常我们希望纹身画在“干净”的皮肤上。这就需要美术在制作主纹理时将纹身区域的皮肤颜色画成中性色或者我们使用一个遮罩纹理Mask Texture来定义哪些区域可以绘制纹身。这可以通过在Shader中额外采样一张遮罩图并用它的值来影响_TattooStrength实现。最后这个“纹身”系统是一个强大的框架你可以在此基础上扩展出更多效果比如让纹身随时间淡入淡出通过脚本控制_TattooStrength、让纹身随着角色生命值变化而闪烁、或者结合顶点动画做出“发光纹身”的效果。关键在于理解UV映射、着色器混合和属性动态传递这三个核心环节剩下的就是发挥你的创意了。