
1. 项目概述为什么2D游戏也需要“高级”材质与着色器很多刚接触Unity2D开发的朋友可能会觉得材质和着色器是3D游戏的专属2D游戏不就是一堆Sprite精灵贴图吗这种想法在制作简单的平台跳跃或解谜游戏时或许成立但当你的游戏需要更丰富的视觉反馈尤其是战斗中的“打击感”时传统的帧动画和粒子效果就会显得力不从心。比如角色受击时一闪而过的白光、武器砍中敌人时从接触点扩散的冲击波、或者Boss进入狂暴状态时全身涌动的能量纹理这些效果如果全靠美术一张张画出来工作量巨大且不灵活。这正是“材质与着色器”大显身手的地方。在Unity中材质是着色器的“容器”而着色器是一段运行在GPU上的小程序它决定了每一个像素最终在屏幕上如何被绘制。通过编写或使用现成的着色器我们可以让一张静态的Sprite贴图“活”起来实现基于时间的动态变化、基于物理的交互反馈从而用极低的性能开销创造出极具表现力的视觉效果。今天我们就聚焦于“受击效果”这个高频需求拆解如何利用Unity的Shader Graph可视化着色器编辑器和手写ShaderLab代码打造一套高效、可复用的解决方案。无论你是想实现《空洞骑士》那种干净利落的受击闪白还是《死亡细胞》里带有像素扭曲的打击反馈这篇文章都将为你提供清晰的实现路径和背后的原理。2. 核心思路解析从“贴图替换”到“像素处理”的思维跃迁在深入代码之前我们先要建立一个正确的认知框架。实现2D受击效果通常有几种不同层次的思路2.1 传统动画思路及其局限最直观的方法是使用帧动画。美术绘制一套角色受击状态下的序列帧受击时播放这套动画。这种方法简单直接但缺点明显资源量大每个角色、每种受击状态都需要一套图效果僵硬无法与受击方向、力度动态结合且难以实现屏幕后处理等全局特效。2.2 基于材质的动态着色思路更高级的思路是我们只准备一套角色的基础贴图Idle状态受击效果通过实时改变渲染这张贴图的“方式”来实现。这就是材质和着色器的核心价值。我们可以通过着色器程序在GPU端对贴图的每个像素进行数学运算从而改变其颜色、透明度、位置甚至采样坐标。这种思路的优势在于资源零消耗无需额外美术资源。动态与可调效果强度、颜色、持续时间完全可以通过材质参数Properties实时控制甚至可以根据受击伤害值进行映射。性能高效计算在GPU上并行完成对CPU压力极小。效果丰富可以组合多种算法创造出闪白、溶解、扭曲、外发光等复杂效果。2.3 实现路径选择Shader Graph vs 手写ShaderUnity提供了两种主要方式来创建着色器Shader Graph推荐新手及快速原型一个可视化的节点编辑器通过连接节点来构建着色器逻辑。它直观、易于调试并且与Unity的渲染管线URP/HDRP深度集成。对于大多数2D受击效果Shader Graph完全够用。手写ShaderLab/HLSL代码提供最高的灵活性和控制力可以实现一些非常底层的定制效果。适合有图形学基础、追求极致性能或特殊效果的开发者。本指南将主要以Shader Graph为核心进行讲解因为它能让我们更专注于“效果逻辑”而非“语法细节”并在最后简要探讨手写代码的进阶可能。我们的目标是设计一个通用的“受击着色器”它包含一个“受击强度”_HitEffect参数数值在0到1之间变化0代表无效果1代表效果全开并通过脚本控制这个参数在受击瞬间从1渐变到0。3. 核心Shader Graph节点详解与效果构建让我们打开Unity创建一个URP项目这是使用Shader Graph的前提然后在Shader Graph中开始构建。我们将实现三种最经典且实用的受击效果闪白、溶解和顶点偏移冲击波。3.1 效果一基础闪白Additive Blink闪白效果的本质是在原始颜色上叠加一个白色或其他颜色其强度随时间衰减。创建属性在Blackboard中创建两个属性。_HitEffect(Vector1)范围[0,1]受击强度控制器。_HitColor(Color)受击叠加的颜色默认为白色(1,1,1,1)。构建网络使用Sample Texture 2D节点采样主贴图(_MainTex)和主颜色(_MainColor)。将_HitColor与_HitEffect相乘得到当前帧应叠加的颜色值。使用Add节点将上一步的结果与采样得到的原始颜色相加。注意颜色相加可能导致值超过1.0即过曝这正是我们想要的“闪白”感觉。将Add节点的输出连接到主节点的Base Color。关键技巧为了让白色更“耀眼”可以将_HitColor设置为HDR颜色在属性中勾选并赋予一个大于1的亮度值。可以添加一个Power节点处理_HitEffect例如Power(_HitEffect, 2)让衰减曲线前期变化快、后期变化慢视觉效果上会更自然。3.2 效果二溶解边缘发光Dissolve with Edge Glow溶解效果模拟角色从受击点开始消散边缘伴有高光。创建属性_HitEffect(Vector1)控制溶解进度1为完全显示0为完全溶解。_NoiseTex(Texture 2D)一张噪波贴图用于生成随机的溶解边缘。_EdgeWidth(Vector1)发光边缘的宽度。_EdgeColor(Color)边缘发光颜色。构建网络采样噪波贴图_NoiseTex通常使用UV或Object空间采样获取一个随机值。使用Step节点是关键。Step(A, B)函数在B A时返回1否则返回0。我们将_HitEffect作为阈值A将噪波采样值作为B。这样_HitEffect越低返回1的区域就越少溶解部分越多。Step的输出是一个非0即1的蒙版Mask1代表保留0代表溶解。为了得到边缘我们可以对Step的结果进行一次轻微的模糊或利用Smoothstep。更简单的方法是用Step的原始结果减去一个用_HitEffect - _EdgeWidth作为阈值得到的新Step结果。两者的差值就是边缘区域。将边缘区域与_EdgeColor相乘再与原始颜色乘以保留区域蒙版相加得到最终颜色。将保留区域蒙版连接到主节点的Alpha并将材质的渲染模式Surface Type设置为Transparent这样溶解掉的部分透明度为0就能显示背景。实操心得噪波贴图的质量直接影响溶解效果。使用Tiling And Offset节点缩放噪波UV可以控制溶解颗粒的粗细。将溶解进度_HitEffect与受击点的UV坐标关联需要从脚本传入受击点位置可以实现从受击点局部开始溶解的效果这比全局均匀溶解更有冲击力。3.3 效果三顶点偏移冲击波Vertex Offset Shockwave此效果让角色网格的顶点沿着法线方向膨胀模拟被冲击的形变。核心概念在Shader Graph中Vertex Position节点允许我们修改模型顶点的位置在对象空间或世界空间。这是实现动态形变的基础。创建属性_HitEffect(Vector1)。_HitIntensity(Vector1)顶点偏移的强度。_HitCenter(Vector2)受击中心的UV坐标范围[0,1]。需要从脚本传入。构建网络计算当前顶点UV到受击中心_HitCenter的距离。使用Distance节点或手动计算差值后求长度。根据距离创建一个衰减系数。距离中心越远影响越小。可以使用One Minus1-x或Smoothstep函数来创建一个从中心向外衰减的圆形遮罩。将衰减遮罩与_HitEffect、_HitIntensity相乘得到该顶点最终的偏移强度。获取顶点的法线方向Normal Vector节点。对于2D Sprite通常法线是朝向屏幕外的(0,0,1)或(0,0,-1)。我们需要一个在Sprite平面内XY方向扩张的法线。一个常见技巧是使用Normalize处理顶点位置本身或使用一个固定的向外向量(如(1,1,0))。将计算出的偏移强度与向外法线向量相乘得到偏移向量。将此偏移向量加到Vertex Position节点的Position输出上再将结果连接到主节点的Vertex Position输入口。注意事项2D Sprite默认的网格Quad顶点数很少4个形变效果会很生硬。为了让冲击波平滑必须为Sprite启用“网格变形”。在Sprite的导入设置中将Mesh Type从Full Rect改为Tight或Full Rect并提高Extrude Edges或者使用Sprite Shape。更好的方法是在3D建模软件中创建一个细分程度更高的平面网格然后作为Mesh使用。顶点偏移可能会与其他基于UV的效果冲突需要合理安排节点执行顺序。4. 在Unity中整合C#脚本驱动与性能优化着色器做好了但它自己不会动。我们需要一个C#脚本来在角色受击时驱动材质上的_HitEffect等参数。4.1 编写受击效果控制器HitEffectController.csusing UnityEngine; public class HitEffectController : MonoBehaviour { // 指向渲染受击角色的SpriteRenderer或MeshRenderer public Renderer targetRenderer; // 受击效果的持续时间 public float hitEffectDuration 0.2f; // 受击效果的强度曲线用于控制衰减形状 public AnimationCurve hitEffectCurve AnimationCurve.EaseInOut(0f, 1f, 1f, 0f); private MaterialPropertyBlock _mpb; private int _hitEffectPropertyId; private int _hitCenterPropertyId; private Coroutine _currentHitCoroutine; void Start() { if (targetRenderer null) targetRenderer GetComponentRenderer(); // 使用MaterialPropertyBlock以支持GPU Instancing避免创建新的Material实例 _mpb new MaterialPropertyBlock(); _hitEffectPropertyId Shader.PropertyToID(_HitEffect); _hitCenterPropertyId Shader.PropertyToID(_HitCenter); // 初始化效果为0 SetHitEffect(0f); } // 外部调用此方法来触发受击效果 public void TriggerHitEffect(Vector2? hitPointInUV null) { // 如果已有正在进行的受击效果先停止它可以叠加或覆盖这里选择覆盖 if (_currentHitCoroutine ! null) { StopCoroutine(_currentHitCoroutine); } _currentHitCoroutine StartCoroutine(PlayHitEffectRoutine(hitPointInUV)); } private System.Collections.IEnumerator PlayHitEffectRoutine(Vector2? hitPointInUV) { float elapsedTime 0f; // 设置受击中心点世界坐标转UV坐标 if (hitPointInUV.HasValue) { _mpb.SetVector(_hitCenterPropertyId, hitPointInUV.Value); targetRenderer.SetPropertyBlock(_mpb); } while (elapsedTime hitEffectDuration) { elapsedTime Time.deltaTime; float t elapsedTime / hitEffectDuration; // 根据曲线评估当前强度值 float hitStrength hitEffectCurve.Evaluate(t); SetHitEffect(hitStrength); yield return null; // 等待下一帧 } // 确保效果结束归零 SetHitEffect(0f); _currentHitCoroutine null; } private void SetHitEffect(float strength) { targetRenderer.GetPropertyBlock(_mpb); _mpb.SetFloat(_hitEffectPropertyId, strength); targetRenderer.SetPropertyBlock(_mpb); } // 一个辅助方法将世界空间碰撞点转换为该Renderer的UV空间坐标0-1 public Vector2 WorldPointToUV(Vector3 worldPoint) { if (targetRenderer null) return Vector2.zero; // 计算世界点到Renderer包围盒的相对位置假设Sprite是轴对齐的 Bounds bounds targetRenderer.bounds; Vector3 localPoint transform.InverseTransformPoint(worldPoint); // 将局部坐标归一化到UV空间假设原点在中心需要调整 // 更精确的方法需要结合Sprite的UV映射这里是一个简化版 Vector2 uv new Vector2( (localPoint.x / bounds.extents.x) * 0.5f 0.5f, (localPoint.y / bounds.extents.y) * 0.5f 0.5f ); return uv; } }脚本使用要点MaterialPropertyBlock这是关键性能优化点。直接修改material.SetFloat()会创建新的材质实例破坏合批。使用MaterialPropertyBlock可以在不创建新实例的情况下覆盖材质属性完美支持GPU Instancing。动画曲线通过AnimationCurve控制_HitEffect的变化你可以轻松实现线性衰减、先快后慢、甚至带有一点弹性的复杂缓动效果这比简单的Mathf.Lerp强大得多。受击点传递对于溶解、冲击波等需要中心点的效果通过WorldPointToUV方法或更精确的射线检测UV计算将碰撞点从世界坐标转换为UV坐标并传递给着色器。4.2 在游戏逻辑中调用在你的角色生命值管理或受击检测脚本中void TakeDamage(int damage, Vector3 hitPoint) { // ... 扣血逻辑 ... if (hitEffectController ! null) { // 将世界坐标的受击点转换为UV坐标 Vector2 hitUV hitEffectController.WorldPointToUV(hitPoint); hitEffectController.TriggerHitEffect(hitUV); } }5. 进阶技巧与常见问题排查5.1 组合多种效果一个出色的受击效果往往是多种简单效果的叠加。你可以在一个Shader Graph中同时实现闪白、轻微溶解和顶点偏移。关键在于使用Lerp线性插值或Add节点来混合不同效果对颜色和位置的影响。例如可以用_HitEffect同时驱动所有效果也可以定义_HitEffect_Blink、_HitEffect_Dissolve等多个独立的强度参数进行更精细的控制。5.2 与2D光照和法线贴图结合如果你的2D项目使用了URP的2D Renderer并开启了2D光照受击效果需要与光照系统兼容。确保你的着色器Surface Type设置为Transparent如果有效果或Opaque并且Blending Mode正确。对于闪白效果可能需要使用Multiply正片叠底而不是Add来叠加颜色以避免在亮部过曝。你还可以在受击时动态增强法线贴图的强度模拟受击部位表面细节的变化。5.3 性能优化要点合批Batching是关键确保使用相同材质的角色Sprite能进行静态/动态合批。使用MaterialPropertyBlock是前提。避免在受击时频繁new Material。复杂度权衡顶点偏移效果虽然酷但顶点数多的网格会消耗更多性能。权衡视觉需求和目标平台。后处理方案对于全屏受击效果如屏幕闪红、扭曲可以考虑使用URP的Renderer Feature添加全屏后处理。这样只需一个Draw Call但实现角色个体的受击反馈会更复杂。5.4 常见问题速查表问题现象可能原因解决方案受击效果完全不显示1._HitEffect参数名不匹配。2. Shader未赋值给材质或材质未赋给Renderer。3. 脚本中MaterialPropertyBlock未正确应用。1. 检查Shader中属性名与脚本中PropertyToID字符串是否完全一致大小写敏感。2. 在Inspector面板确认材质关联。3. 确保在修改_mpb后调用了renderer.SetPropertyBlock(_mpb)。效果显示一次后材质变“紫”直接使用renderer.material获取材质导致运行时创建了新的材质实例破坏了合批且新实例可能丢失了Shader引用。永远不要直接使用renderer.material。始终使用MaterialPropertyBlock来修改属性。如果必须获取材质使用renderer.sharedMaterial。顶点偏移效果僵硬像方形膨胀Sprite使用的网格顶点数太少默认Quad只有4个顶点。在Sprite导入设置中提高Mesh Type为Tight并调整Extrude Edges或使用高细分度的自定义Mesh。溶解边缘锯齿感严重使用的噪波贴图分辨率太低或Step函数过渡太硬。使用更高分辨率的噪波图。将Step节点替换为Smoothstep它可以产生平滑的边缘过渡。受击效果在Sprite Atlas图集中错乱UV计算基于单个Sprite的UV但图集打乱了这个映射。在Shader Graph中使用Sprite Atlas节点提供的UV输出来采样纹理而不是默认的UV0。这能确保在图集内正确采样。移动平台上效果卡顿Shader计算复杂度太高或每帧频繁调用SetPropertyBlock。简化Shader减少复杂数学运算如Sin,Noise。确保只在参数变化时调用SetPropertyBlock而不是每帧都调用。5.5 一个实用的调试技巧在Shader Graph中创建一个Vector1类型的_Debug属性并将其输出连接到主节点的Base Color的某个通道如R。在脚本中控制这个_Debug值你可以快速在屏幕上“可视化”着色器内部的某个中间计算结果如距离值、蒙版值这对于排查复杂的节点网络逻辑非常有帮助。实现一个令人满意的受击效果是提升2D游戏打击感和视觉品质的性价比极高的方法。它不再依赖于美术资源的堆砌而是通过程序化的方式赋予静态资源以动态的生命力。从简单的参数驱动到复杂的顶点动画着色器为我们打开了一扇通往高质量视觉反馈的大门。关键在于理解原理、大胆尝试并用MaterialPropertyBlock保护好性能。当你看到角色受击时那一道流畅而有力的反馈你就会明白在GPU上进行的这每一帧计算都是值得的。