第9章 高级粒子着色器开发:烟雾、蒸汽与流体特效实战
粒子系统是游戏特效的灵魂。从飘散的烟雾到喷涌的蒸汽,从滴落的黏液到飞溅的液体,粒子特效赋予游戏世界以动态和生命力。然而,Unity内置的粒子着色器往往只能满足基础需求,要实现更丰富、更独特的视觉效果,必须编写自定义粒子着色器。本章将带你深入粒子渲染的底层,从粒子系统的配置开始,逐步构建能够模拟烟雾、蒸汽和黏液滴落的自定义着色器。在此过程中,我们将探讨粒子动画的GPU实现、纹理扰动、顶点拉伸等技术,并结合计算机图形硬件特性,写出高效且视觉惊艳的粒子特效。
9.1 场景准备与粒子系统基础
在动手编码之前,我们需要搭建一个适合测试粒子特效的场景。打开Unity 2021.3.8f1c1,创建一个新场景,命名为“ParticleEffects”。为了便于观察,在场景中添加一个地面平面和一个方向光,并调整摄像机视角至合适位置。我们将在场景中放置多个粒子系统,分别用于测试烟雾、蒸汽和黏液效果。
9.1.1 粒子系统的工作原理与硬件加速
粒子系统本质上是一个动态生成和管理大量微小网格(通常是公告牌)的组件。每个粒子具有位置、速度、生命周期、颜色、大小等属性。在CPU驱动的粒子系统中,这些属性每帧由CPU更新,然后提交给GPU绘制。当粒子数量巨大时,CPU会成为瓶颈。
现代GPU支持GPU粒子,即将粒子模拟放在GPU端,通过着色器更新粒子属性。Unity的粒子系统支持通过自定义顶点流和着色器实现GPU粒子,但本章仍以CPU粒子为主,因为其灵活性更高,易于理解。不过,我们会将粒子外观的计算尽量放在着色器中,以减轻CPU负担。
从计算机组成角度看,粒子渲染涉及大量顶点处理和片元处理。每个粒子对应一个四边形(两个三角形),顶点着色器将粒子的位置、大小等属性转换为屏幕坐标,片元着色器则根据粒子纹理和透明度输出颜色。粒子数量多时,GPU的几何处理能力和纹理带宽是关键。
9.1.2 添加基础粒子系统
在场景中创建三个空对象,分别命名为“SmokeSystem”、“SteamSystem”、“SlimeSystem”。为每个对象添加Particle System组件。我们将分别配置它们以模拟不同效果。
9.2 配置粒子系统参数
粒子系统的参数繁多,调整它们可以产生千变万化的效果。本节将重点介绍与烟雾、蒸汽和黏液相关的关键参数设置。
9.2.1 烟雾效果参数配置
烟雾通常具有以下特点:缓慢上升、扩散、透明度渐变、形状不规则。我们为SmokeSystem设置如下参数:
- Duration:5.00(持续发射)
- Looping:true
- Start Lifetime:5.00(粒子寿命5秒)
- Start Speed:0.5(缓慢上升)
- Start Size:1.0,随机化0.5(大小不一)
- Start Color:灰色,透明度从0.5到0(渐隐)
- Gravity Modifier:-0.1(轻微上升)
- Emission:Rate over Time = 10(每秒10个)
- Shape:Cone,Angle=10,Radius=0.5(锥形发射区域)
- Color over Lifetime:设置渐变,从半透灰到全透
- Size over Lifetime:逐渐变大(模拟扩散)
- Rotation over Lifetime:随机旋转(增加不规则感)
- Renderer:使用默认的粒子材质(稍后替换为自定义材质)
9.2.2 蒸汽效果参数配置
蒸汽比烟雾更轻、更活跃,可能带有扭曲感。我们将蒸汽系统设置为更快的速度、更短的寿命,并准备使用扭曲着色器。
- Start Lifetime:2.0
- Start Speed:1.5
- Start Size:0.5,随机化0.3
- Start Color:白色,透明度0.6
- Gravity Modifier:-0.2(上升更快)
- Emission:Rate = 20
- Shape:Sphere,Radius=0.3
- Color over Lifetime:快速衰减
- Size over Lifetime:先大后小?蒸汽通常扩散后消散,可设逐渐变大
9.2.3 黏液滴落效果参数配置
黏液滴落需要粒子具有拉伸效果,模拟粘稠液体滴落时的形态。我们将在着色器中实现拉伸,粒子系统需提供速度信息。
- Start Lifetime:3.0
- Start Speed:2.0,沿Y轴向下
- Start Size:0.3,随机化0.2
- Start Color:绿色,半透明
- Gravity Modifier:1.0(受重力影响)
- Emission:Rate = 15
- Shape:Cone,指向下方
- Velocity over Lifetime:可模拟空气阻力
- Renderer:启用“Render Alignment”为“View”,使粒子始终面向摄像机(公告牌),并开启“Stretch Billboard”以支持拉伸。
9.3 为粒子系统创建基础材质
在创建自定义着色器之前,我们先为粒子系统准备基础材质。新建一个材质,使用内置的“Particles/Standard Unlit”着色器,设置纹理为一张模糊的圆形渐变图(烟雾常用)。将此材质赋给粒子系统,观察基础效果。这有助于对比后续自定义着色器的改进。
9.4 创建自定义粒子着色器:基础框架
我们将从零开始编写一个粒子着色器,支持纹理、颜色、透明度,并逐步添加扭曲和拉伸功能。新建一个着色器文件,命名为“CustomParticleShader.shader”。
Shader "Custom/Particle/CustomParticleShader"
{
Properties
{
_MainTex ("Particle Texture", 2D) = "white" {}
_Color ("Color", Color) = (1,1,1,1)
_SoftParticleFactor ("Soft Particles Factor", Range(0, 5)) = 1.0
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" "PreviewType"="Plane" }
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
Lighting Off
ZWrite Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_particles
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
float4 color : COLOR;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
UNITY_FOG_COORDS(1)
#ifdef SOFTPARTICLES_ON
float4 projPos : TEXCOORD2;
#endif
};
sampler2D _MainTex;
float4 _Color;
float _SoftParticleFactor;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord.xy;
o.color = v.color * _Color;
#ifdef SOFTPARTICLES_ON
o.projPos = ComputeScreenPos(o.vertex);
COMPUTE_EYEDEPTH(o.projPos.z);
#endif
UNITY_TRANSFER_FOG(o, o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv) * i.color;
#ifdef SOFTPARTICLES_ON
float sceneZ = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.projPos)));
float partZ = i.projPos.z;
float fade = saturate(_SoftParticleFactor * (sceneZ – partZ));
col.a *= fade;
#endif
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
Fallback "Particles/Standard Unlit"
}
这个基础着色器支持顶点颜色、材质颜色、软粒子(粒子与场景深度融合)和雾效。软粒子是通过比较粒子深度与背景深度来实现的,当粒子靠近其他物体时透明度降低,避免生硬的裁剪。
将材质使用此着色器,并赋给粒子系统,观察效果。目前它等同于内置的粒子着色器,后续我们将在此基础上扩展。
9.5 为自定义粒子着色器添加颜色支持
基础着色器已经通过顶点颜色和材质颜色支持了颜色调制。但有时我们需要更精细的控制,例如基于时间的颜色变化。虽然可以在粒子系统的Color over Lifetime模块中设置,但在着色器中做可以更加灵活,例如根据粒子年龄改变色调。
我们将在片元着色器中添加一个基于时间的颜色混合。粒子系统通常会将粒子的归一化年龄存储在顶点颜色的alpha通道或自定义流中。但默认情况下,Unity粒子系统的顶点颜色可以存储颜色,但不直接存储年龄。我们可以通过Custom Data模块将年龄传入顶点颜色,或者在着色器中通过其他方式获得年龄(如纹理坐标)。为了简化,我们假设粒子系统的颜色模块已经提供了渐变。
因此,基础着色器已经足够。但为了展示灵活性,我们可以添加一个_GradientTex属性,根据年龄采样渐变纹理。不过,这需要粒子系统将年龄传递到UV2或类似属性,会增加复杂度,暂不实现。
9.6 添加蒸汽效果:动态扭曲与消散
蒸汽效果的关键在于动态扰动和逐渐消散。我们可以通过两种方式实现扰动:
我们将采用第一种方法,因为它更简单且对粒子形状影响可控。
9.6.1 蒸汽着色器实现
复制基础着色器,重命名为“SteamParticleShader.shader”,并添加以下属性:
Properties
{
// … 原有属性
_DistortTex ("Distortion Texture", 2D) = "black" {}
_DistortSpeed ("Distortion Speed", Vector) = (0.1, 0.1, 0, 0)
_DistortStrength ("Distortion Strength", Range(0, 0.5)) = 0.1
_FadeOutStart ("Fade Out Start", Range(0,1)) = 0.7
}
在v2f结构中添加扭曲UV坐标:
struct v2f
{
// … 原有
float2 uvDistort : TEXCOORD3;
};
顶点着色器中,传递扭曲UV(可以使用主UV,或者根据粒子年龄生成):
o.uvDistort = v.texcoord.xy * _DistortTex_ST.xy + _DistortTex_ST.zw;
// 也可以基于时间偏移
o.uvDistort += _Time.yy * _DistortSpeed.xy;
片元着色器中,采样扰动纹理,生成偏移量,然后扰动主纹理UV:
float2 distort = tex2D(_DistortTex, i.uvDistort).rg * 2 – 1; // 映射到-1..1
float2 uv = i.uv + distort * _DistortStrength;
fixed4 col = tex2D(_MainTex, uv) * i.color;
然后根据粒子年龄(从i.color.a?或从uv?)实现消散。粒子年龄可以通过粒子系统的Custom Data传入,或者通过UV的某个通道。为了简化,我们假设粒子系统的颜色模块已经处理了透明度渐变,着色器只需使用i.color.a即可。
但为了更精细的控制,我们可以让消散在最后阶段加速。例如,如果粒子年龄可以通过某种方式获得,我们可以修改透明度。但粒子年龄在默认情况下无法直接传递到着色器。我们可以通过粒子系统的“Custom Vertex Streams”将年龄(如 normalized age)作为额外的TEXCOORD传入。这需要配置粒子系统,并修改着色器接收该数据。
为了演示,我们假设粒子系统已经将年龄存储在UV2.x中。修改appdata结构:
struct appdata
{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
float4 texcoord2 : TEXCOORD1; // 假设存储年龄等
float4 color : COLOR;
};
然后在v2f中添加年龄:
float age : TEXCOORD4;
顶点着色器传递:
o.age = v.texcoord2.x;
片元中,根据年龄调整透明度:
float fade = saturate((o.age – _FadeOutStart) / (1 – _FadeOutStart));
col.a *= (1 – fade); // 年龄超过_FadeOutStart后逐渐消失
这种方法需要粒子系统在Custom Vertex Streams中输出年龄。具体设置:在Particle System的Renderer模块中,启用“Custom Vertex Streams”,添加“UV2”并选择“Age”或“Normalized Age”。
完整的蒸汽着色器核心部分如下:
// 片元着色器
fixed4 frag (v2f i) : SV_Target
{
// 扰动
float2 distortUv = i.uvDistort + _Time.y * _DistortSpeed.xy;
float2 distort = tex2D(_DistortTex, distortUv).rg * 2 – 1;
float2 uv = i.uv + distort * _DistortStrength;
fixed4 col = tex2D(_MainTex, uv) * i.color;
// 基于年龄的消散
float age = i.age;
float fade = saturate((age – _FadeOutStart) / (1 – _FadeOutStart));
col.a *= (1 – fade);
// 软粒子
#ifdef SOFTPARTICLES_ON
float sceneZ = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.projPos)));
float partZ = i.projPos.z;
float softFade = saturate(_SoftParticleFactor * (sceneZ – partZ));
col.a *= softFade;
#endif
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
将此着色器赋给蒸汽粒子系统,调整参数,即可看到蒸汽翻腾并逐渐消散的效果。
商业实例:《生化危机》系列中的蒸汽管道效果就使用了类似的扰动技术,使蒸汽看起来更真实。结合体积光(God Rays)可以增强氛围。
9.7 创建黏液滴落效果:拉伸与流动
黏液滴落需要粒子在运动方向上有拉伸,模拟粘稠液体的变形。Unity粒子系统内置了拉伸公告牌(Stretched Billboard),但那是简单的基于速度拉伸,无法模拟黏液滴落时的流动感和形状变化。我们将编写一个着色器,实现基于速度的拉伸,并加入流动纹理动画。
9.7.1 拉伸公告牌的原理
拉伸公告牌的原理是将粒子的四边形沿其速度方向拉伸,拉伸比例由速度大小决定。在顶点着色器中,我们需要知道粒子的速度向量,并将其转换到摄像机空间或屏幕空间,然后偏移顶点位置。
Unity粒子系统可以通过Custom Vertex Streams输出速度向量。我们可以在Renderer模块中添加“Velocity”到顶点流。速度是一个float3,我们将它传递到着色器。
9.7.2 黏液着色器实现
新建着色器“SlimeParticleShader.shader”,复制基础框架,添加速度属性。
appdata结构增加速度:
struct appdata
{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
float4 color : COLOR;
float3 velocity : TEXCOORD1; // 速度向量
};
顶点着色器需要将速度转换到世界空间或视图空间,并计算拉伸偏移。常见方法是:将速度投影到视线平面,然后根据速度大小和方向偏移顶点。对于公告牌粒子,我们通常希望拉伸方向平行于速度在视线平面上的投影,并且拉伸只发生在那个方向。
在顶点着色器中,我们可以这样做:
v2f vert (appdata v)
{
v2f o;
// 计算速度在世界空间的大小和方向
float3 worldVel = v.velocity; // 假设已经是世界空间,但粒子系统输出的速度是世界空间吗?文档说Velocity输出的是世界空间速度。
float speed = length(worldVel);
float3 velDir = worldVel / (speed + 0.0001);
// 计算视线方向
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz – worldPos);
// 计算速度在视线平面上的投影(即垂直于视线的分量)
float3 velPerp = velDir – dot(velDir, viewDir) * viewDir;
velPerp = normalize(velPerp);
// 拉伸系数:可调
float stretch = speed * _StretchFactor;
// 根据顶点在四边形中的位置(假设UV决定位置),沿着velPerp方向偏移顶点
// 我们需要知道顶点相对于粒子中心的偏移。通常,粒子的顶点在模型空间中,中心在(0,0,0)。
// 我们可以使用UV来确定顶点是在左边还是右边,从而决定拉伸方向的正负。
// 对于公告牌,顶点通常位于 (-0.5,-0.5) 到 (0.5,0.5) 范围,UV也对应。
// 我们可以用UV.x来判断左右方向:UV.x 0为左,1为右。但更通用的方法是使用顶点坐标。
// 简化:假设粒子四边形顶点在模型空间的x轴方向对应公告牌的右方向,y轴对应上方向。
// 那么拉伸应该沿世界空间的velPerp方向移动顶点。
float4 offset = float4(velPerp * v.vertex.x * stretch, 0); // v.vertex.x 作为方向因子,但这样不够准确。
// 更准确的方法:计算顶点相对于中心的偏移向量,然后将其投影到velPerp方向,按比例移动。
// 我们直接使用顶点坐标的x作为沿velPerp的偏移量(假设四边形在模型空间的x方向对应公告牌的横向)。
// 对于标准粒子,模型空间的顶点坐标范围通常在 [-0.5,0.5] 的x和y,z=0。
// 因此,v.vertex.x 可以表示从左到右的位置。
float3 worldPosOffset = worldPos + velPerp * v.vertex.x * stretch;
o.vertex = mul(UNITY_MATRIX_VP, float4(worldPosOffset, 1.0));
// 其余传递…
}
这只是一个简化的拉伸,实际项目中可能需要更精确的控制,包括考虑粒子的旋转等。为了简化,我们可以利用Unity粒子系统的内置拉伸功能,但通过着色器增加纹理流动。
9.7.3 流动纹理动画
黏液表面通常有流动的纹理,模拟液体的蠕动。我们可以使用另一张噪声纹理,随时间滚动,并将其叠加到主纹理上或用于扭曲。
在着色器中添加流动参数:
_FlowTex ("Flow Texture", 2D) = "black" {}
_FlowSpeed ("Flow Speed", Vector) = (0.1, 0.1, 0, 0)
_FlowStrength ("Flow Strength", Range(0, 1)) = 0.2
在片元着色器中,采样流动纹理,并将其强度加到颜色上或作为扰动。
float2 flowUv = i.uv * _FlowTex_ST.xy + _FlowTex_ST.zw + _Time.y * _FlowSpeed.xy;
float4 flow = tex2D(_FlowTex, flowUv);
// 将流动的灰度值加到颜色上,模拟反射变化
col.rgb += flow.rgb * _FlowStrength;
或者,可以使用流动纹理作为高光遮罩,使黏液看起来更有光泽。
9.7.4 完整黏液着色器核心
综合拉伸和流动,得到以下核心片段(顶点着色器简化):
v2f vert (appdata v)
{
v2f o;
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
float3 worldVel = v.velocity; // 假设世界空间速度
float speed = length(worldVel);
float3 velDir = worldVel / (speed + 0.0001);
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz – worldPos);
float3 velPerp = normalize(velDir – dot(velDir, viewDir) * viewDir);
float stretch = speed * _StretchFactor;
worldPos += velPerp * v.vertex.x * stretch; // 沿速度垂直方向拉伸
o.vertex = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0));
o.uv = v.texcoord.xy;
o.color = v.color * _Color;
// 传递流动UV(可以在片元中计算)
o.flowUv = v.texcoord.xy * _FlowTex_ST.xy + _FlowTex_ST.zw;
// … 其他传递
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv) * i.color;
// 流动扰动
float2 flowUv = i.flowUv + _Time.y * _FlowSpeed.xy;
float4 flow = tex2D(_FlowTex, flowUv);
col.rgb += flow.rgb * _FlowStrength;
// 软粒子、雾等…
return col;
}
将此着色器赋给黏液粒子系统,调整_StretchFactor和流动参数,即可看到粒子在运动方向被拉伸,表面有流动感,模拟黏液滴落。
商业实例:《空洞骑士》中的黏液怪物使用了类似的拉伸和流动效果,使敌人看起来粘稠而动态。在《瑞奇与叮当》中,黏液枪射击出的液体也有明显的拉伸和光泽。
9.8 高级技巧:GPU粒子与性能优化
当粒子数量达到数千时,CPU更新会成为瓶颈。Unity的粒子系统支持将粒子模拟转移到GPU,通过自定义着色器直接操作粒子数据。这需要深入使用Compute Buffers和顶点着色器中的实例化,复杂度较高。但我们可以利用一些技巧优化现有粒子系统:
- 减少Overdraw:粒子往往是半透明的,重叠区域会多次绘制。使用较小的粒子,并控制发射速率。
- 使用软粒子:软粒子可以避免粒子与几何体交界处的硬边,但会增加一次深度纹理采样。在移动平台上可以禁用。
- 合并纹理:将多张粒子纹理合并到图集,减少材质切换。
- LOD:根据距离调整粒子发射速率或大小。
对于蒸汽和烟雾,可以使用较低分辨率的纹理,并在远处禁用扰动计算。
9.9 总结
本章我们深入探索了自定义粒子着色器的开发,从基础粒子系统配置开始,逐步实现了烟雾、蒸汽和黏液滴落三种特效。通过编写着色器代码,我们掌握了纹理扰动、顶点拉伸、基于年龄的消散等技术,并了解了粒子系统与着色器之间的数据传递(如速度、年龄)。这些技术在实际游戏开发中应用广泛,无论是环境氛围还是角色技能,都能通过自定义粒子着色器获得独特而逼真的效果。
在性能方面,我们讨论了粒子渲染的硬件消耗和优化策略。粒子特效的魅力在于其动态性和多样性,而自定义着色器正是实现这种多样性的钥匙。希望本章的内容能激发你创作出更多令人惊叹的粒子效果。在下一章中,我们将转向屏幕特效,探索如何利用后处理实现动态模糊、景深和色彩校正,进一步提升游戏的视觉品质。


