Unity屏幕受伤特效:从Shader原理到实战实现

📅 2026/8/4 1:16:53 👁️ 阅读次数 📝 编程学习
Unity屏幕受伤特效:从Shader原理到实战实现

1. 项目概述:从“受伤”到“沉浸感”的视觉桥梁

在动作、射击或生存类游戏中,当角色受到攻击时,除了生命值(HP)的减少和音效的反馈,最直观、最能调动玩家情绪的莫过于屏幕视觉特效的变化。一个设计精良的屏幕受伤特效,能瞬间将玩家从“旁观者”拉入“参与者”的角色,让每一次受击都变得真实可感。它不仅仅是画面边缘的一抹红色,而是一个综合了色彩偏移、模糊、扭曲、血迹叠加甚至画面撕裂的复合视觉系统。今天,我们就来深入探讨如何在Unity中,不依赖昂贵的后处理资源包,而是亲手编写Shader,实现一套从原理到实践都清晰可控的逼真屏幕受伤特效。

这个特效的核心目标,是模拟人体在受到创伤时,视觉系统可能产生的生理和心理反应:视线模糊、注意力集中在伤口(屏幕中心向受击点偏移)、色彩感知变化(偏向红色调)以及可能的视野遮挡(血迹)。我们将完全通过编写自定义的Image Effect Shader(后处理着色器)来实现,这意味着它能以极低的性能开销,作用于整个摄像机渲染画面之上,兼容从移动端到PC的各个平台。通过本篇文章,你将不仅获得一套可直接复用的完整Shader代码,更能透彻理解其背后的图形学原理和设计思路,从而具备根据自己游戏风格定制专属受伤反馈的能力。

2. 核心思路与方案选型:为何选择全屏后处理Shader?

在Unity中实现屏幕特效,通常有几种路径:使用粒子系统在UI层叠加、修改摄像机的后处理堆栈(如URP/HDRP的Volume)、或者编写传统的全屏后处理Shader。对于受伤特效这种需要全局、动态、且与场景内容深度交互的效果,全屏后处理Shader是最灵活、性能可控且效果强大的选择。

2.1 为何是全屏后处理?

首先,受伤特效影响的是玩家的整个“视野”,即摄像机看到的一切。粒子系统虽然能模拟飞溅的血滴,但难以实现全局的色彩扭曲、模糊等效果。其次,后处理Shader在GPU上运行,对每个屏幕像素进行一次处理,效率极高。最后,它允许我们访问渲染完成后的最终图像缓冲区,并基于一些自定义参数(如受伤强度、受击点位置)进行数学运算,实现高度定制化的效果。

2.2 传统Built-in管线 vs SRP (URP/HDRP)

我们的方案将主要基于Unity的传统Built-in渲染管线。原因在于其通用性最强,无需绑定特定的渲染管线,代码结构清晰,便于理解原理。在Built-in管线中,我们通过为摄像机挂载一个脚本,该脚本调用OnRenderImage函数,将源渲染纹理(source)和我们编写的Shader材质(Material)传递给Graphics.Blit函数,从而应用特效。理解这个过程后,迁移到URP(编写RenderFeature)或HDRP的思路是相通的,主要是API的差异。

2.3 特效构成分解

一个逼真的受伤特效不是单一效果,而是多个效果的有机叠加。我们将它拆解为以下几个可独立控制也可组合的核心层:

  1. 色彩偏移与染色:模拟视觉充血,将画面整体或局部向红色系偏移,并可能降低饱和度。
  2. 径向模糊与视觉扭曲:模拟因疼痛或冲击导致的视线模糊、失焦,效果从受击点向外辐射。
  3. 动态血迹贴图叠加:在屏幕随机位置或受击点方向叠加半透明的血迹纹理,增加细节。
  4. 屏幕边缘暗角与脉动:受伤时视野收窄,边缘变暗,并且伴随心跳或疼痛的脉搏有节奏地明暗变化。
  5. 画面抖动(可选):强烈的受击可伴随短暂的摄像机抖动,这部分通常在脚本中控制摄像机Transform,但可与Shader参数联动。

我们将编写一个统一的Shader,通过一组可调节的参数来混合这些子效果。

3. Shader核心原理与代码结构拆解

接下来,我们深入到Shader代码内部。一个完整的后处理Shader通常包含一个ShaderLab文件(.shader),里面定义了属性(Properties)、子着色器(SubShader)和通道(Pass)。核心算法在片段着色器(fragment shader)中实现。

3.1 属性(Properties)块定义

这是Shader的对外接口,允许我们在Unity材质检视器中调节参数,也允许脚本通过Material.SetFloat等方法动态修改。

Properties { _MainTex ("Base (RGB)", 2D) = "white" {} // 源屏幕图像 // 1. 全局色彩控制 _DamageIntensity ("受伤强度", Range(0, 1)) = 0.0 // 核心驱动参数 _RedShift ("红色偏移强度", Range(0, 1)) = 0.5 _Saturation ("饱和度", Range(0, 2)) = 1.0 _VignetteIntensity ("暗角强度", Range(0, 2)) = 0.5 // 2. 径向模糊控制 _BlurCenter ("模糊中心 (屏幕空间XY)", Vector) = (0.5, 0.5, 0, 0) // 受击点 _BlurRadius ("模糊半径", Range(0, 0.1)) = 0.02 _BlurSamples ("采样次数", Int) = 10 // 3. 血迹控制 _BloodTex ("血迹贴图 (A)", 2D) = "white" {} _BloodScale ("血迹缩放", Float) = 1.0 _BloodIntensity ("血迹强度", Range(0, 1)) = 0.3 [HDR] _BloodColor ("血迹颜色", Color) = (1,0,0,1) // 4. 脉动效果 _PulseSpeed ("脉动速度", Float) = 2.0 _PulseAmount ("脉动幅度", Range(0, 0.5)) = 0.1 }

3.2 顶点-片段着色器基本结构

在SubShader的Pass中,我们使用CGPROGRAM编写CG/HLSL代码。

v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; // 计算一个从中心(0.5,0.5)到当前uv的向量,用于径向效果 o.uvCenterVector = v.uv - float2(0.5, 0.5); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 originalColor = tex2D(_MainTex, i.uv); fixed4 finalColor = originalColor; // 在这里,我们将依次叠加各种效果 // finalColor = ApplyColorShift(finalColor, i.uv); // finalColor = ApplyRadialBlur(finalColor, i.uv); // finalColor = ApplyBloodSplatter(finalColor, i.uv); // finalColor = ApplyVignetteAndPulse(finalColor, i.uv); // 最后,根据_DamageIntensity在原始颜色和特效颜色之间插值 // return lerp(originalColor, finalColor, _DamageIntensity); return finalColor; }

注意:在实际编写时,所有效果应基于同一个原始采样originalColor进行分别计算后再混合,而不是层层嵌套finalColor,以避免颜色计算误差累积和性能浪费。上面注释中的链式调用仅为逻辑示意。

4. 关键效果算法实现详解

现在,我们来逐一实现frag函数中提到的各个效果函数。

4.1 色彩偏移与染色(ApplyColorShift)

这个效果模拟视觉充血。我们不只是简单增加红色,而是更科学地调整色相和饱和度。

fixed4 ApplyColorShift(fixed4 color, float2 uv) { // 1. 将RGB颜色转换到HSL/HSV空间进行调整更自然,但为简化,我们使用RGB偏移 // 增加红色通道,轻微减少绿色和蓝色通道 float redBoost = color.r * (1.0 + _RedShift * _DamageIntensity); float greenReduct = color.g * (1.0 - _RedShift * _DamageIntensity * 0.5); float blueReduct = color.b * (1.0 - _RedShift * _DamageIntensity * 0.3); fixed3 shiftedColor = fixed3(redBoost, greenReduct, blueReduct); // 2. 调整饱和度 fixed3 luminance = dot(color.rgb, fixed3(0.299, 0.587, 0.114)); // 计算亮度 shiftedColor = lerp(luminance, shiftedColor, _Saturation); // 3. 可选:根据受伤强度,在屏幕边缘加强红色偏移(模拟视野边缘充血) float distanceFromCenter = length(uv - float2(0.5, 0.5)); float edgeFactor = smoothstep(0.2, 0.8, distanceFromCenter); // 边缘因子 shiftedColor.r += edgeFactor * _DamageIntensity * 0.2; return fixed4(shiftedColor, color.a); }

4.2 径向模糊(ApplyRadialBlur)

径向模糊从受击点_BlurCenter向外发散。其原理是在当前像素点到中心点的方向上,进行多次采样并平均。

fixed4 ApplyRadialBlur(fixed4 originalColor, float2 uv) { // 如果模糊半径或强度为0,直接返回原色以节省性能 if (_BlurRadius <= 0 || _DamageIntensity < 0.1) return originalColor; fixed4 blurredColor = originalColor; float2 blurVector = (_BlurCenter.xy - uv) * _BlurRadius * _DamageIntensity; float2 sampleUV; float totalWeight = 1.0; // 初始权重为1(原始采样) for (int j = 1; j < _BlurSamples; j++) { // 从当前点向中心方向偏移采样 sampleUV = uv + blurVector * (float(j) / float(_BlurSamples - 1)); // 简单的线性加权,离原始点越近的采样权重越高(可选) float weight = 1.0 - (float(j) / float(_BlurSamples)); blurredColor += tex2D(_MainTex, sampleUV) * weight; totalWeight += weight; } blurredColor /= totalWeight; return blurredColor; }

实操心得_BlurSamples(采样次数)是性能与质量的权衡关键。移动端建议控制在5-8次,PC端可到10-15次。另外,确保sampleUV在0到1之间(屏幕空间),否则需要对越界的UV进行钳制(clamp)或包裹(wrap)处理,否则在屏幕边缘会出现拉伸瑕疵。可以使用tex2D(_MainTex, clamp(sampleUV, 0.001, 0.999))来简单处理。

4.3 动态血迹叠加(ApplyBloodSplatter)

血迹效果需要一张带有Alpha通道的纹理(_BloodTex)。为了动态和随机性,我们将在脚本中控制血迹的UV偏移和显示。

fixed4 ApplyBloodSplatter(fixed4 color, float2 uv) { // 计算血迹UV:可以加入随机偏移、旋转和缩放 float2 bloodUV = uv * _BloodScale; // 假设脚本通过Material.SetTextureOffset/SetTextureScale控制了血迹位置 fixed4 bloodSample = tex2D(_BloodTex, bloodUV); // 只使用血迹贴图的Alpha通道来控制形状,颜色用我们定义的_BloodColor fixed3 bloodColor = _BloodColor.rgb; float bloodAlpha = bloodSample.a * _BloodIntensity * _DamageIntensity; // 使用Alpha混合叠加血迹 color.rgb = lerp(color.rgb, bloodColor, bloodAlpha); // 或者使用更真实的叠加模式,如正片叠底(Multiply) // color.rgb = lerp(color.rgb, color.rgb * bloodColor, bloodAlpha * 0.5); return color; }

4.4 暗角与脉动(ApplyVignetteAndPulse)

暗角(Vignette)是屏幕边缘变暗的效果。脉动则是让这个暗角或整体亮度随着时间周期性变化。

fixed4 ApplyVignetteAndPulse(fixed4 color, float2 uv, float2 centerVector) { // 1. 计算基于到中心距离的暗角因子 float distance = length(centerVector) * 2.0; // 映射到0~√2范围,我们归一化到0~1.4 float vignette = smoothstep(0.0, 1.4 * _VignetteIntensity, distance); vignette = 1.0 - vignette; // 中心亮,边缘暗 // 2. 计算脉动因子 float pulse = sin(_Time.y * _PulseSpeed) * 0.5 + 0.5; // 映射到0~1 pulse = lerp(1.0, 1.0 - _PulseAmount, pulse); // 脉动在1和(1-_PulseAmount)之间 // 3. 结合受伤强度:受伤越重,暗角越明显,脉动影响越大 float effectFactor = _DamageIntensity; vignette = lerp(1.0, vignette, effectFactor); pulse = lerp(1.0, pulse, effectFactor); // 4. 应用效果 color.rgb *= vignette * pulse; return color; }

5. 完整的Shader代码整合与优化

将上述所有函数整合到最终的片段着色器中,并注意混合顺序和性能。

fixed4 frag (v2f i) : SV_Target { fixed4 originalColor = tex2D(_MainTex, i.uv); // 提前计算受伤强度因子,用于各个效果 float damageFactor = _DamageIntensity; // 【效果计算】建议顺序:模糊 -> 色彩 -> 血迹 -> 暗角脉动 // 1. 径向模糊(基于原始UV) fixed4 colorBlur = ApplyRadialBlur(originalColor, i.uv); // 2. 色彩偏移(基于模糊后的颜色或原始颜色,根据需求) fixed4 colorShifted = ApplyColorShift(colorBlur, i.uv); // 3. 血迹叠加 fixed4 colorWithBlood = ApplyBloodSplatter(colorShifted, i.uv); // 4. 暗角与脉动 fixed4 finalColor = ApplyVignetteAndPulse(colorWithBlood, i.uv, i.uvCenterVector); // 最终,用damageFactor在原始和特效间插值,实现平滑过渡和关闭 // 注意:如果damageFactor为0,应完全返回originalColor return lerp(originalColor, finalColor, damageFactor); }

5.1 性能优化要点

  1. 分支判断:在Shader开始处,如果_DamageIntensity接近于0,可以直接返回originalColor,跳过所有计算。if (_DamageIntensity < 0.001) return originalColor;。但需注意GPU上分支的性能特性,在移动端,简单的常数比较通常是安全的。
  2. 采样次数_BlurSamples是性能杀手。可以考虑根据_DamageIntensity动态减少采样次数,低强度时用模糊半径更小的3次采样,高强度时再用10次。
  3. 纹理采样:确保血迹贴图_BloodTex尺寸合理(如512x512),并启用Mipmap以减少缓存抖动。
  4. 精度选择:在移动端,将fixed4/half4用于颜色计算,float用于UV和数学运算,以平衡精度和性能。

6. C#驱动脚本编写与参数动态控制

Shader写好了,还需要一个C#脚本来驱动它。这个脚本负责:1. 创建材质;2. 在OnRenderImage中调用Graphics.Blit;3. 提供公共方法给游戏逻辑(如PlayerHealth脚本)来触发和更新受伤状态。

using UnityEngine; [ExecuteInEditMode] // 方便在编辑器预览 [RequireComponent(typeof(Camera))] public class ScreenDamageEffect : MonoBehaviour { public Shader damageShader; private Material _damageMaterial; public Material DamageMaterial { get { if (_damageMaterial == null && damageShader != null && damageShader.isSupported) { _damageMaterial = new Material(damageShader); _damageMaterial.hideFlags = HideFlags.HideAndDontSave; } return _damageMaterial; } } [Range(0f, 1f)] public float damageIntensity = 0f; public float recoverySpeed = 0.5f; // 受伤后恢复速度 public Vector2 hitPoint = new Vector2(0.5f, 0.5f); // 受击点(屏幕空间,0~1) // 血迹参数 public Texture2D bloodTexture; public float bloodScale = 1f; public float bloodIntensity = 0.3f; public Color bloodColor = Color.red; private Vector2 _bloodOffset; // 用于动态移动血迹 void OnEnable() { // 检查Shader和支持 if (damageShader == null || !damageShader.isSupported) { enabled = false; Debug.LogWarning("ScreenDamageEffect Shader missing or not supported."); return; } // 初始化随机血迹偏移 _bloodOffset = new Vector2(Random.Range(0f, 1f), Random.Range(0f, 1f)); } void OnDisable() { if (_damageMaterial != null) { DestroyImmediate(_damageMaterial); } } // 这是应用后处理的关键函数 void OnRenderImage(RenderTexture source, RenderTexture destination) { if (DamageMaterial != null && damageIntensity > 0.001f) { // 1. 将公共变量传递给Shader DamageMaterial.SetFloat("_DamageIntensity", damageIntensity); DamageMaterial.SetVector("_BlurCenter", new Vector4(hitPoint.x, hitPoint.y, 0, 0)); // 2. 动态更新血迹UV偏移(例如,可以模拟血迹滑落) _bloodOffset.y -= Time.deltaTime * 0.1f; DamageMaterial.SetTextureOffset("_BloodTex", _bloodOffset); DamageMaterial.SetTextureScale("_BloodTex", new Vector2(bloodScale, bloodScale)); DamageMaterial.SetTexture("_BloodTex", bloodTexture); DamageMaterial.SetColor("_BloodColor", bloodColor); DamageMaterial.SetFloat("_BloodIntensity", bloodIntensity); // 3. 应用后处理 Graphics.Blit(source, destination, DamageMaterial); } else { // 无特效时直接拷贝 Graphics.Blit(source, destination); } } void Update() { // 自动恢复:受伤强度随时间减弱 if (damageIntensity > 0) { damageIntensity = Mathf.Clamp01(damageIntensity - recoverySpeed * Time.deltaTime); } } // 供外部调用的方法:受到伤害 public void TakeDamage(float intensity, Vector2 screenHitPoint) { damageIntensity = Mathf.Clamp01(damageIntensity + intensity); hitPoint = screenHitPoint; // 每次受击可以重置或改变血迹位置 _bloodOffset = new Vector2(Random.Range(0f, 1f), Random.Range(0.8f, 1f)); // 新血迹从上方随机位置出现 } }

6.1 脚本使用示例

在你的玩家生命值脚本中:

public class PlayerHealth : MonoBehaviour { public float health = 100f; public ScreenDamageEffect screenDamage; // 拖拽赋值 public Camera playerCamera; public void ApplyDamage(float damage, Vector3 worldHitPoint) { health -= damage; // 将世界坐标的受击点转换为屏幕坐标(0~1) Vector3 screenPoint = playerCamera.WorldToViewportPoint(worldHitPoint); // 确保点在屏幕内 if (screenPoint.z > 0 && screenPoint.x >=0 && screenPoint.x <=1 && screenPoint.y >=0 && screenPoint.y <=1) { screenDamage.TakeDamage(damage / 100f, new Vector2(screenPoint.x, screenPoint.y)); // 假设满血100,伤害比例作为强度 } else { // 如果受击点不在屏幕内,使用屏幕中心 screenDamage.TakeDamage(damage / 100f, new Vector2(0.5f, 0.5f)); } } }

7. 效果调试、常见问题与性能调优

即使代码写完了,要获得理想的效果,调试至关重要。

7.1 调试技巧

  1. 分层调试:在Shader中,可以注释掉其他效果,只保留一个(如只开色彩偏移),单独调整其参数,观察效果。使用return fixed4(vignette, vignette, vignette, 1);这样的方式直接输出中间变量(如暗角因子),检查计算是否正确。
  2. 参数动画:在Unity编辑器中,可以为damageIntensity等参数创建简单的动画曲线(Animation),观察从0到1再到0的完整过渡效果是否平滑自然。
  3. 使用Frame Debugger:打开Window -> Analysis -> Frame Debugger,查看OnRenderImagepass是否被正确调用,以及Draw Call的开销。

7.2 常见问题与解决方案

问题现象可能原因解决方案
屏幕全黑或全白Shader编译错误或属性未正确绑定检查Console错误日志。确保Shader文件无误,且C#脚本中damageShader变量已赋值。在Scene视图选中摄像机,查看其ScreenDamageEffect组件的材质球是否创建成功。
特效边缘有硬边或拉伸径向模糊的采样UV超出了纹理坐标[0,1]范围ApplyRadialBlur函数的tex2D采样前,对sampleUV进行钳制:clamp(sampleUV, 0.001, 0.999)
血迹贴图不显示贴图未赋值,或UV计算错误,或Alpha通道为纯黑1. 在Inspector中为bloodTexture赋值一张带透明通道的PNG。2. 检查_BloodIntensity_DamageIntensity是否大于0。3. 在图像软件中确认血迹贴图的Alpha通道有内容。
移动设备上帧率下降明显采样次数过多或Shader计算复杂度高1. 降低_BlurSamples到5以下。2. 简化ApplyColorShift中的计算,或使用查找表(LUT)。3. 考虑只在damageIntensity较高时(如>0.3)开启模糊效果。
受伤特效不随摄像机旋转/移动_BlurCenter使用的是静态屏幕坐标确保TakeDamage方法中传入的screenHitPoint是动态计算得出的。如果是基于世界坐标的受击点,务必每帧或用事件触发时进行转换。
多个后处理效果冲突摄像机挂载了多个OnRenderImage脚本后处理效果会按脚本顺序执行。确保你的受伤特效脚本顺序正确。如果需要组合多个后处理,可以考虑将它们合并到一个Shader中,或者使用Command Buffer进行更精细的控制。

7.3 进阶优化与扩展思路

  1. 基于距离的强度衰减:让_DamageIntensity不仅随时间衰减,也随玩家与危险源(如敌人)的距离增加而衰减,实现更动态的反馈。
  2. 不同伤害类型的特效:可以定义多个Material或使用Shader变体(Keywords),为火焰伤害、冰冻伤害、中毒伤害等设计不同的色彩偏移和血迹样式。
  3. 与角色状态联动:当生命值极低时,可以叠加一个持续性的、高强度的视觉干扰效果(如强烈的噪波、扭曲),提示玩家处于危险状态。
  4. 使用噪声纹理:用一张噪声纹理来驱动血迹形状的随机性和模糊效果的失真度,可以使效果更加自然有机,避免重复感。
  5. URP/HDRP适配:在URP中,你需要创建一个ScriptableRendererFeature和对应的Pass,将绘制命令插入到渲染管线中。核心的Shader算法大部分可以复用,主要是渲染API的调用方式需要改变。

亲手实现一个完整的屏幕受伤特效,远不止是复制粘贴代码。从理解后处理管线,到设计色彩与模糊算法,再到用脚本驱动和调试,每一步都加深了你对Unity渲染和游戏反馈设计的理解。最让我有成就感的一刻,不是在Shader中调出一个漂亮的红色渐变,而是在游戏测试中,被敌人击中时,屏幕那一下恰到好处的反馈让我本能地感到紧张并做出闪避动作——那才是特效设计的终极目标:服务于游戏体验,增强玩家的沉浸感。你可以从本文的代码框架出发,大胆调整参数,甚至加入新的效果层(比如屏幕裂纹、色差等),创造出独一无二的视觉语言。