尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity压痕着色器实战:视觉欺骗实现动态凹陷效果

Unity压痕着色器实战:视觉欺骗实现动态凹陷效果 1. 项目概述为什么我们需要一个“压痕”着色器在Unity里做项目尤其是涉及到角色互动、环境交互或者需要表现物体受力形变的时候我们经常会遇到一个经典难题如何让一个物体在被“按压”或“碰撞”时表面产生逼真的凹陷效果比如角色踩在雪地上留下脚印或者一个重物砸在柔软的泥土上形成坑洞。当然你可以用复杂的物理模拟比如Unity自带的Mesh Collider配合刚体或者更高级的布料、软体物理但那对性能的消耗是巨大的在移动端或者需要大量交互的场景里基本不现实。这时候一个轻量级的解决方案就出现了Indent Shader压痕着色器。它的核心思路非常巧妙——不真正改变模型的网格顶点而是通过Shader在视觉上“欺骗”玩家的眼睛模拟出凹陷的效果。我最近在做一个解谜游戏玩家需要推动箱子在沙地上移动并留下痕迹Indent Shader就成了我的救星。它完美地平衡了效果和性能让我能在手机上流畅运行几十个同时产生压痕的交互点。简单来说Unity-IndentShader 就是一个专门用于在物体表面实时生成视觉压痕效果的着色器工具或方案。它不依赖昂贵的物理计算而是利用渲染管线通过修改像素的法线、深度或颜色信息来营造出凹陷的错觉。这对于提升游戏的沉浸感和交互反馈至关重要尤其适合独立开发者或对性能有严格要求的项目。2. 核心原理拆解视觉欺骗的艺术在深入代码之前我们必须搞清楚Indent Shader到底是怎么“骗”过我们的。理解了原理无论是使用现成的Shader还是自己调试都能事半功倍。它的实现通常围绕以下几个核心概念展开我会用一个“手指按压橡皮泥”的类比来贯穿说明。2.1 基于深度/高度图的压痕这是最主流也是效果相对较好的方法。其核心是使用一张**深度图Depth Map或高度图Height Map**来记录物体表面的“起伏”状态。初始状态想象物体表面是一块完全平整的橡皮泥。我们为它准备一张纯色比如灰色的纹理贴图这张图就是它的“高度记录图”。灰色代表初始高度。产生压痕当发生碰撞比如一个球体压下来我们在Shader中计算碰撞点。在碰撞点对应的纹理坐标UV位置我们去修改这张高度图——将对应像素的颜色值调暗。颜色越暗在视觉上就代表该处“陷”得越深。渲染反馈在表面着色时Shader会采样这张实时被修改的高度图。根据像素的明暗即高度信息动态地计算该点的法线向量。颜色暗的地方凹陷处法线会被计算成指向凹坑内部的方向从而在光照下产生阴影看起来就像真的凹下去了。注意这种方法需要将高度图存储在一张Render Texture中并且需要在每一帧根据碰撞信息去更新它。这意味着你需要一个脚本来管理碰撞事件并将碰撞点的位置、强度和范围转换成对Render Texture的绘制操作。2.2 基于法线扰动的压痕这是一种更轻量级但效果稍逊的方法它不修改高度直接修改法线。原理物体表面的明暗由法线方向和光源方向决定。如果我们直接“掰弯”某个区域像素的法线让它指向错误的方向就能在光照下制造出虚假的凹凸感。实现通常我们准备一张记录法线扰动信息的Render Texture。当碰撞发生时在碰撞点写入一个预设的“凹陷法线图案”比如一个圆形渐变中心法线向下。在表面着色时将物体本身的法线贴图如果有的话和这张扰动纹理进行混合从而得到最终的法线。优缺点优点是计算量小不需要复杂的深度计算。缺点是缺乏真正的深度感在掠射角度观察时容易穿帮而且难以表现多层级、叠加的复杂压痕。2.3 交互数据的管理与传递无论用哪种方法Shader本身只是个“执行者”它需要知道“在哪里压”和“压多深”。这就需要C#脚本作为“指挥官”。碰撞检测通过OnCollisionEnter、OnCollisionStay或OnTriggerEnter等方法获取碰撞接触点的世界坐标、碰撞力度等信息。坐标转换将世界坐标的碰撞点转换到目标物体模型空间下的位置再进一步转换到该物体UV纹理坐标空间的位置。这一步是关键决定了你的压痕能否准确地“画”在物体表面的正确位置。参数传递将计算好的UV坐标、压痕强度、压痕半径等参数传递给Shader。通常通过MaterialPropertyBlock来动态设置Shader的属性以避免创建大量材质实例。渲染纹理更新如果采用高度图/法线图方案脚本还需要负责创建和更新Render Texture将每次碰撞作为一个“绘制命令”提交到这张纹理上。3. 实战构建一个基础的高度图压痕系统理论说再多不如动手做一遍。下面我将带你一步步实现一个基于高度图的基础Indent Shader系统。我们会创建一个可交互的地面当球体落下时地面会留下凹痕。3.1 第一步准备场景与资源创建地面在Unity中创建一个Plane或一个自定义的Mesh作为地面命名为“IndentableGround”。创建压痕器创建一个Sphere挂上Rigidbody作为下落的物体。创建着色器与材质在Project窗口中右键创建 Shader Standard Surface Shader我们先基于这个改比较简单。将其命名为“IndentSurfaceShader”。然后用这个Shader创建一个新的材质赋给“IndentableGround”。3.2 第二步编写C#交互管理器脚本我们需要一个脚本来处理碰撞和更新Shader参数。创建一个C#脚本命名为IndentManager并挂载到“IndentableGround”上。using UnityEngine; [RequireComponent(typeof(Renderer))] public class IndentManager : MonoBehaviour { // 对外暴露的参数可在Inspector中调节 public float indentStrength 0.5f; // 压痕强度 public float indentRadius 0.2f; // 压痕半径 public Texture2D brushTexture; // 笔刷纹理可选用于定义压痕形状 private Renderer targetRenderer; private MaterialPropertyBlock mpb; // 假设我们用一个Vector4数组来传递多次碰撞信息。实际复杂项目可能需要更高效的结构。 private Vector4[] indentPoints new Vector4[10]; // [x, y, radius, strength] private int currentIndentIndex 0; void Start() { targetRenderer GetComponentRenderer(); mpb new MaterialPropertyBlock(); // 初始化数组将所有压痕强度设为0无效 for (int i 0; i indentPoints.Length; i) { indentPoints[i].w 0; } } void OnCollisionEnter(Collision collision) { // 遍历所有碰撞接触点 foreach (ContactPoint contact in collision.contacts) { // 只处理与这个地面的碰撞 if (contact.otherCollider.gameObject this.gameObject) { AddIndent(contact.point); } } } void AddIndent(Vector3 worldPos) { // 1. 将世界坐标转换到模型本地坐标 Vector3 localPos transform.InverseTransformPoint(worldPos); // 2. 假设地面是一个在XZ平面展开的模型UV映射是标准的。将本地坐标归一化到UV空间0-1。 // 注意这个转换非常简化仅适用于Plane等简单网格。复杂网格需要射线检测求UV。 Vector2 uvPos new Vector2(localPos.x 0.5f, localPos.z 0.5f); // Plane中心是(0,0)UV范围是0-1 // 3. 将压痕数据存入数组 indentPoints[currentIndentIndex] new Vector4(uvPos.x, uvPos.y, indentRadius, indentStrength); // 4. 更新MaterialPropertyBlock targetRenderer.GetPropertyBlock(mpb); mpb.SetVectorArray(_IndentPoints, indentPoints); mpb.SetInt(_IndentPointsCount, currentIndentIndex 1); // 传递有效数据个数 targetRenderer.SetPropertyBlock(mpb); // 5. 移动索引循环使用数组 currentIndentIndex (currentIndentIndex 1) % indentPoints.Length; // 当覆盖旧数据时可以选择将旧数据的强度衰减这里简单处理为直接覆盖。 } }关键点解析MaterialPropertyBlock这是高效修改材质参数的关键。它允许你修改Renderer的材质属性而不创建新的材质实例对性能友好。坐标转换transform.InverseTransformPoint将世界坐标转为模型本地坐标。后续的localPos.x 0.5f是针对Unity默认Plane尺寸10x10顶点从-5到5的简易UV映射。对于复杂网格你必须使用MeshCollider和Raycast或者ClosestPoint来获取准确的UV坐标这是实战中最容易出错的地方。数据传递我们将压痕信息位置、半径、强度打包成Vector4数组传递给Shader。Shader中会遍历这个数组来应用所有压痕。3.3 第三步编写Surface Shader实现压痕现在我们来修改之前创建的Surface Shader让它能接收并处理压痕数据。Shader Custom/IndentSurfaceShader { Properties { _Color (Color, Color) (1,1,1,1) _MainTex (Albedo (RGB), 2D) white {} _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 // 添加一个高度图纹理用于模拟表面起伏 _BumpMap (Normal Map, 2D) bump {} _BumpScale (Bump Scale, Range(0,5)) 1.0 } SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM // 使用表面着色器模型 #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; sampler2D _BumpMap; struct Input { float2 uv_MainTex; float2 uv_BumpMap; }; half _Glossiness; half _Metallic; fixed4 _Color; float _BumpScale; // 声明从C#脚本传递来的参数 float4 _IndentPoints[10]; // 与C#数组大小匹配 int _IndentPointsCount; // 函数计算单个压痕对当前像素的高度影响 float ApplySingleIndent(float2 uv, float4 indentData) { float2 indentUV indentData.xy; // 压痕中心UV float radius indentData.z; // 半径 float strength indentData.w; // 强度 // 计算当前像素UV到压痕中心的距离 float distance length(uv - indentUV); // 使用平滑的衰减函数如smoothstep来计算影响权重 float falloff 1.0 - smoothstep(0.0, radius, distance); // 返回高度偏移负值表示凹陷 return -strength * falloff; } void surf (Input IN, inout SurfaceOutputStandard o) { // 基础颜色和纹理 fixed4 c tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Alpha c.a; // 初始化高度偏移为0 float heightOffset 0.0; // 遍历所有有效的压痕点累加它们对当前像素的高度影响 for (int i 0; i _IndentPointsCount; i) { heightOffset ApplySingleIndent(IN.uv_BumpMap, _IndentPoints[i]); } // 关键步骤根据高度偏移扰动法线贴图的UV // 原理凹陷的地方我们应该去采样法线贴图中“看起来更低”的区域。 // 一个简化的方法是直接偏移法线贴图的采样UV。 float2 perturbedUV IN.uv_BumpMap float2(heightOffset * 0.1, 0); // 这里只做了简单的水平偏移作为示例 // 采样扰动后的法线贴图 float3 normal UnpackNormal(tex2D(_BumpMap, perturbedUV)); normal.xy * _BumpScale; o.Normal normalize(normal); } ENDCG } FallBack Diffuse }Shader要点解析ApplySingleIndent函数这是核心。它根据距离计算压痕的影响权重。smoothstep函数能产生边缘平滑的圆形衰减比简单的线性衰减看起来更自然。法线扰动surf函数中我们遍历所有压痕累加heightOffset。然后我们用这个偏移量去扰动法线贴图(_BumpMap)的采样坐标。float2(heightOffset * 0.1, 0)是一个非常基础的模拟实际上更精确的做法是根据高度偏移计算新的法线向量或者使用高度图生成法线的技术如UnpackNormal配合tex2Dgrad。这里为了教程清晰做了简化。性能注意在Shader中使用循环尤其是对可变长度的数组需要小心。我们限制了数组大小为10并在C#中传递了有效计数_IndentPointsCount来控制循环次数。对于大量压痕需要考虑更高级的数据结构如纹理查找或剔除机制。3.4 第四步配置与运行将IndentManager脚本拖到“IndentableGround”上。在Inspector中为IndentManager组件指定一个笔刷纹理比如一个边缘柔和的圆形灰度图并调整indentStrength和indentRadius。为“IndentableGround”的材质选择我们刚编写的“Custom/IndentSurfaceShader”。为该材质分配一个法线贴图Normal Map。这是效果好坏的关键。你可以使用一张砖墙、泥土或沙地的法线贴图。运行游戏将球体拖高然后落下观察碰撞点周围的地面光照变化。你应该能看到随着球体碰撞地面出现了动态的、明暗变化的压痕。4. 进阶优化与问题排查上面的基础系统能跑起来但离“好用”和“好看”还有距离。下面分享一些我踩过坑后总结的进阶技巧和常见问题解决方案。4.1 效果优化方案使用Render Texture作为高度图缓存问题上述方法中压痕是瞬时计算且没有持久化的。球体离开后压痕数据还在数组中会一直影响Shader。而且无法表现压痕的叠加和渐变恢复。解决方案创建一个单通道R通道的Render Texture作为高度图。在C#脚本中使用Graphics.Blit配合一个专用的“绘制笔刷”材质将每次碰撞“画”到这张Render Texture上。Shader采样这张RT来获取高度信息。你还可以每帧对RT进行一点点模糊或数值衰减来模拟压痕的缓慢恢复效果会非常自然。实现更精确的法线计算问题简单的UV偏移法线效果生硬且视角变换时容易失真。解决方案在Shader中实现真正的高度图转法线。在片段着色器中对高度图进行多次采样例如取当前像素左右、上下的高度值使用ddx和ddy函数或手动计算梯度然后根据梯度构造出新的法线向量。代码如下// 假设 tex2D(_HeightMap, i.uv) 采样得到当前高度 float heightCenter tex2D(_HeightMap, i.uv).r; float heightRight tex2D(_HeightMap, i.uv float2(_HeightMap_TexelSize.x, 0)).r; float heightUp tex2D(_HeightMap, i.uv float2(0, _HeightMap_TexelSize.y)).r; // 计算梯度 float3 gradient float3(heightCenter - heightRight, heightCenter - heightUp, 0.05); // Z分量控制凹凸强度 float3 worldNormal normalize(mul(unity_ObjectToWorld, float4(normalize(gradient), 0))).xyz; // 然后将 worldNormal 与物体原本的法线混合或直接使用支持复杂网格与精确UV获取问题我们的简易坐标转换只适用于Plane。解决方案在IndentManager的AddIndent函数中使用射线检测来获取精确的UV。void AddIndent(Vector3 worldPos, Vector3 worldNormal) { Ray ray new Ray(worldPos worldNormal * 0.1f, -worldNormal); // 从碰撞点上方一点向内部发射射线 RaycastHit hit; if (Physics.Raycast(ray, out hit) hit.collider.gameObject this.gameObject) { Vector2 uvPos hit.textureCoord; // 这才是准确的UV坐标 // ... 后续处理 } }这要求地面物体必须有MeshCollider并且勾选Convex如果是凹面体则不能勾选或确保其网格是可射线检测的。4.2 常见问题与排查清单问题现象可能原因排查与解决方案压痕位置完全不对UV坐标计算错误。1. 在Shader中通过o.Albedo float4(IN.uv_BumpMap.x, IN.uv_BumpMap.y, 0, 1);将UV可视化检查其分布。2. 在C#脚本中Debug.Log打印计算出的uvPos确保其在[0,1]范围内。3. 对于复杂网格务必使用射线检测获取hit.textureCoord。压痕形状扭曲/拉伸模型UV分布不均匀或拉伸。1. 在3D建模软件中检查并优化模型的UV展开。2. 如果无法修改模型可以考虑使用三平面映射Triplanar Mapping技术来生成压痕但这会更复杂。压痕边缘有锯齿或硬边笔刷衰减函数太生硬或Render Texture分辨率太低。1. 优化ApplySingleIndent中的衰减函数使用更平滑的曲线如(1 - (distance/radius)^2)^2。2. 提高用于存储高度图的Render Texture的分辨率。3. 在绘制笔刷到RT时使用具有柔和边缘的笔刷纹理。性能开销大帧数下降Shader中循环次数过多或RT更新太频繁。1. 限制同时生效的压痕数量_IndentPointsCount。2. 将多次碰撞合并间隔几帧批量更新一次RT而不是每碰撞一次就Blit一次。3. 考虑使用Compute Shader来高效处理高度图的更新但这属于高级主题。移动设备上效果不佳或无效使用了不支持的Shader特性或精度不足。1. 确保Shader的#pragma target足够低如3.0以兼容更多设备。2. 检查RenderTexture格式是否被支持通常ARGB32或RFloat。3. 简化计算例如将高度图转法线的计算从每像素改为每顶点在顶点着色器中计算虽然精度下降但性能提升。压痕不会消失或恢复没有实现衰减逻辑。1. 在更新Render Texture的脚本中每帧对RT整体应用一个微弱的“恢复”Blit操作例如用一个接近1的系数与RT颜色相乘。2. 或者在Shader中引入一个时间衰减参数让旧压痕的影响力随时间减弱。5. 不同场景下的应用变体Indent Shader的思路可以灵活变通应用到各种场景中。雪地/沙地脚印这是最经典的应用。将上述系统应用到角色脚底的碰撞体上即可。可以针对左右脚使用不同的笔刷纹理脚印形状。水面涟漪将“高度”理解为“水位偏移”。压痕数据用来扰动水面法线贴图或顶点位置就能模拟物体落入水中或雨滴击打水面的涟漪。此时衰减函数可以加上随时间波动的正弦波让涟漪扩散开。可涂抹的墙面/画布将“压痕”理解为“颜料”。使用多张Render Texture分别存储不同颜色通道的高度。碰撞时根据物体颜色向对应的RT通道“绘制”。Shader混合这些通道的高度和颜色就能实现复杂的涂抹、混合效果。动态地形变形简易版对于低多边形风格的地形你可以将高度图的变化通过脚本反馈到Mesh的顶点Y坐标上实现真正的几何形变。但这就不再是纯粹的视觉欺骗了需要配合动态合批或LOD来管理性能。我个人在实际项目中的体会是Indent Shader的成功30%在于Shader编码70%在于数据和逻辑的管理。如何高效、准确地管理碰撞信息、如何设计压痕的叠加与衰减规则、如何与游戏逻辑比如不同物体产生不同深度的压痕结合这些才是让效果从“能用”到“好用”的关键。开始时不要追求过于复杂的效果先把“一个球砸出一个坑”这个流程彻底跑通理解数据从碰撞检测到最终像素着色的完整链条之后再逐步添加缓存、衰减、多笔刷等高级特性路就会顺很多。
返回列表