请选择 进入手机版 | 继续访问电脑版

查看: 460|回复: 1

[技术] 粒子特效教程 | 多重GPU粒子力场

[复制链接]

1287

主题

2010

帖子

2万

贡献

管理员

Rank: 9Rank: 9Rank: 9

积分
27055
QQ
发表于 2019-2-16 06:05:01 | 显示全部楼层 |阅读模式
我们将分享加拿大游戏特效大神Mirza Beig的粒子特效的系列教程,该系列教程将帮助你了解如何使用粒子系统制作精美的特效。

往期教程回顾:
1、使用粒子实现Logo消融效果
2、倒放粒子系统
3、使用粒子实现Logo显现效果
4、创建3D均匀粒子网格
5、创作绚丽的粒子星系(上)
6、创作绚丽的粒子星系(下)
7、Unity自定义粒子顶点流
8、伴随Simplex噪声的GPU粒子动画
9、GPU粒子力场

上一篇教程《GPU粒子力场》中,我们制作了一个自定义着色器,它能接收表示球体位移影响因子即力场的位置和半径,基于标准化偏移值来移动粒子顶点并给粒子着色。

本篇教程中,我们将用该着色器制作一个特别版本,它能通过使用数组来支持多个力场。下图的预览效果由本教程制作的效果和《创建3D均匀粒子网格》的均匀粒子网格结合而成。

01.gif


Part 1:顶点着色器
创建上一篇教程中着色器文件的副本,然后修改文件名。

02.png


下面的代码中,只是在Field后添加了“s”,使Field一词变为复数形式。

[C#] 纯文本查看 复制代码
Shader "Custom/Particles/GPU Force Fields Unlit (Tutorial)"


然后删除材质的半径和位置属性。我们不再需要这二个属性,因为我们会用C#脚本,将它们直接指定到着色器力场数组中。

[C#] 纯文本查看 复制代码
Properties
{
    _MainTex("Texture", 2D) = "white" {}
 
    _ForceFieldRadius("Force Field Radius", Float) = 4.0
    _ForceFieldPosition("Force Field Position", Vector) = (0.0, 0.0, 0.0, 0.0)
 
    [HDR] _ColourA("Color A", Color) = (0.0, 0.0, 0.0, 0.0)
    [HDR] _ColourB("Color B", Color) = (1.0, 1.0, 1.0, 1.0)
}


我们还将删除了半径和位置的变量,将它们替换为力场数量和紧凑的力场数组。我们会用xyz保存每个力场的位置,用w保存半径。我们会用力场数量来结束迭代每个力场的循环,这样就不必处理整个数组。

本文中我们使用的数组大小为8,你可以按喜好使用更大的数值,例如:64。

[C#] 纯文本查看 复制代码
sampler2D _MainTex;
float4 _MainTex_ST;
 
float _ForceFieldRadius;
float3 _ForceFieldPosition;
 
int _ForceFieldCount;
float4 _ForceFields[8];
 
float4 _ColourA;
float4 _ColourB;


拿一个数组举例,第一个力场的位置和半径会分别定义为_ForceField[0].xyz _ForceField[0].w。

现在我们可以编写用于计算和返回粒子偏移的函数。首先添加第二个参数“forceField”,因为我们没有表示力场半径和位置的全局变量,所以要在循环调用该函数的过程中,传入每个球体的信息。

[C#] 纯文本查看 复制代码
float4 GetParticleOffset(float3 particleCenter, float4 forceField)


在函数顶部创建二个新变量,用于获取半径和位置。

[C#] 纯文本查看 复制代码
float forceFieldRadius = forceField.w;
float3 forceFieldPosition = forceField.xyz;


这便是我们要修改的内容,现在可以将函数中的_ForceFieldRadius和_ForceFieldPosition替换为刚创建的二个新变量。在下面代码第12行的max函数中把0.0改为一个小数,以避免顶点在多重力场中消失。

其它代码保持不变,代码如下。

[C#] 纯文本查看 复制代码
float4 GetParticleOffset(float3 particleCenter, float4 forceField)
{
    float forceFieldRadius = forceField.w;
    float3 forceFieldPosition = forceField.xyz;
     
    float distanceToParticle = distance(particleCenter, forceFieldPosition);
    float forceFieldRadiusAbs = abs(forceFieldRadius);
 
    float3 directionToParticle = normalize(particleCenter - forceFieldPosition);
 
    float distanceToForceFieldRadius = forceFieldRadiusAbs - distanceToParticle;
    distanceToForceFieldRadius = max(distanceToForceFieldRadius, 0.0001);
 
    distanceToForceFieldRadius *= sign(forceFieldRadius);
 
    float4 particleOffset;
 
    particleOffset.xyz = directionToParticle * distanceToForceFieldRadius;
    particleOffset.w = distanceToForceFieldRadius / (forceFieldRadius + 0.0001); // //添加小数来避免被除数为0,以及在r=0.0时出现未定义的颜色或行为。colour/behaviour at r = 0.0.
 
    return particleOffset;
}


修改偏移函数并实现数组后,为了完成多重力场的基本支持,我们只需要使用循环代码更新顶点着色器即可。

遍历整个数组长度比用变量力场数提早结束的速度更快。这是因为编译器默认会先尝试展开循环。这种情况下,我们必须在对应C#脚本中每帧创建一个新数组。

数组受限于力场数量,这样脚本会忽略不活动力场,或把这些力场的半径设为0,使它们不对任何粒子造成影响,这也是避免常量分配的较优解决方案。

[C#] 纯文本查看 复制代码
v2f vert(appdata v)
{
    v2f o;
 
    float3 particleCenter = float3(v.tc0.zw, v.tc1.x);
     
    float3 vertexOffset = 0.0;
 
    for (int i = 0; i < _ForceFieldCount; i++)
    {
        vertexOffset += GetParticleOffset(particleCenter, _ForceFields[i]).xyz;
    }
 
    v.vertex.xyz += vertexOffset;
    o.vertex = UnityObjectToClipPos(v.vertex);
 
    // 从保存在颜色顶点输入的粒子系统接收数据,并将该数据用于初始化颜色
 
    o.color = v.color;
 
    o.tc0.xy = TRANSFORM_TEX(v.tc0, _MainTex);
 
     // 初始化tex coord变量
 
    o.tc0.zw = v.tc0.zw;
    o.tc1 = v.tc1;
 
    UNITY_TRANSFER_FOG(o,o.vertex);
    return o;
}


稍后我们将对顶点着色器进行一些修改,但现在我们先处理片段着色器。

Part 2:片段着色器
片段着色器所更新的代码行为第12-19行。由于多重力场会影响粒子的偏移,所以我们将从整个循环获取最大标准化偏移并使用它。
[C#] 纯文本查看 复制代码
fixed4 frag(v2f i) : SV_Target
{
    // 采样纹理
    fixed4 col = tex2D(_MainTex, i.tc0);
 
    //让纹理颜色和粒子系统的顶点颜色输入相乘
 
    col *= i.color;
 
    float3 particleCenter = float3(i.tc0.zw, i.tc1.x);
 
    float maxNormalizedOffset = 0.0;
 
    for (int i = 0; i < _ForceFieldCount; i++)
    {
        maxNormalizedOffset = max(maxNormalizedOffset, GetParticleOffset(particleCenter, _ForceFields[i]).w);
    }
 
    col = lerp(col * _ColourA, col * _ColourB, maxNormalizedOffset);
 
    col *= col.a;
 
    // 应用模糊效果
    UNITY_APPLY_FOG(i.fogCoord, col);
    return col;
}


由于没有C#脚本填充力场数组,因此我们目前无法看到任何实际变化。

Part 3:GPU力场游戏对象
设置粒子系统的方法是把任何附加了该组件的游戏对象的任何子Transform都视为作力场对象。然后我们可以从父对象动态添加或移除Transform,来创建或销毁力场。

下面详解该脚本和上一片教程中单力场脚本之间的差异。

首先我们在第9行有一个常量内部变量,用来指定与着色器中的数组长度匹配的最大力场数量。

在第11行有Vector4类型的forceFields数组,我们会在Start()函数中将其初始化为最大长度,并指定为着色器中的等价变量。着色器数组此时没有初始化为它的长度,直到受到外部脚本的设置,所以这是我们立即执行此操作的原因。

每一帧我们都用着色器中的子对象数量更新力场数量,然后用循环来提取它们的位置和半径,这些信息会被指定到数组内当前迭代的力场向量中。当更新循环完成后,只要将数据复制到着色器数组即可。

最后,我们在OnDrawGizmos函数中循环处理数组,这样能可视化力场为球体。

下面是完整的C#脚本。
[C#] 纯文本查看 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
[ExecuteInEditMode]
public class GPUParticleForceFields : MonoBehaviour
{
    public Material material;
    const int MAX_FORCE_FIELDS = 8; // 确保该数值匹配着色器全局数组大小。
 
    Vector4[] forceFields;
 
    void Start()
    {
        // 需要设置为支持的最大数组长度,因为它会决定着色器上的实际大小。
 
        forceFields = new Vector4[MAX_FORCE_FIELDS];
        material.SetVectorArray("_ForceFields", forceFields);
    }
 
    void LateUpdate()
    {
        material.SetInt("_ForceFieldCount", transform.childCount);
         
        for (int i = 0; i < transform.childCount; i++)
        {
            Transform childTransform = transform.GetChild(i);
 
            forceFields[i] = new Vector4(
 
                childTransform.position.x, childTransform.position.y, childTransform.position.z, 
                childTransform.lossyScale.x / 2.0f);
        }
 
        material.SetVectorArray("_ForceFields", forceFields);
    }
 
    void OnDrawGizmos()
    {
        for (int i = 0; i < transform.childCount; i++)
        {
            Transform childTransform = transform.GetChild(i);
 
            float radius = childTransform.lossyScale.x / 2.0f;
            Gizmos.DrawWireSphere(childTransform.position, radius);
        }
    }
}


现在我们可以随意进行调整。

03.gif


Part 4:均匀半径
我们将为着色器添加一个可选功能,以帮助缓解加法偏移混合的问题,使力场的混合效果更好,该功能适用于处理一些特别情况。

我们会通过静态开关来控制是否让着色器使用该功能,所以我们需要在材质属性中添加开关和均匀半径。

请注意,这里使用了范围滑块,因为这样在编辑器中更容易调整,你也可以使用常规的数值属性或较大的滑块范围。
[C#] 纯文本查看 复制代码
Properties
{
    _MainTex("Texture", 2D) = "white" {}
 
    [Toggle(_USEUNIFORMRADIUS_ON)] _UseUniformRadius("Use Uniform Radius", Float) = 0.0
    _UniformRadius("Uniform Radius", Range(-10.0, 10.0)) = 1.0
 
    [HDR] _ColourA("Color A", Color) = (0.0, 0.0, 0.0, 0.0)
    [HDR] _ColourB("Color B", Color) = (1.0, 1.0, 1.0, 1.0)
}


我们需要定义开关的关键字。
[C#] 纯文本查看 复制代码
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// 实现模糊效果
#pragma multi_compile_fog
 
#pragma shader_feature _USEUNIFORMRADIUS_ON
 
#include "UnityCG.cginc"


并且定义均匀半径变量。
[C#] 纯文本查看 复制代码
sampler2D _MainTex;
float4 _MainTex_ST;
 
int _ForceFieldCount;
float4 _ForceFields[8];
 
float _UniformRadius;
 
float4 _ColourA;
float4 _ColourB;


我们处理混合的方法是应用均匀半径,然后使用所有力场中最小的偏移值。为了从函数获取最小偏移距离,我们将inout关键字和第一行参数一起使用,以便我们可以传入数值并修改原始数值。

该函数用于更新输入变量,具体方法是将输入变量与当前距离作对比,使它总是最小值。

[C#] 纯文本查看 复制代码
float4 GetParticleOffset(float3 particleCenter, float4 forceField, inout float minDistanceToParticle)
{
    float forceFieldRadius;
    float3 forceFieldPosition = forceField.xyz;
 
    #ifdef _USEUNIFORMRADIUS_ON
        forceFieldRadius = _UniformRadius + 0.0001;
    #else       
        forceFieldRadius = forceField.w;
    #endif
 
    float distanceToParticle = distance(particleCenter, forceFieldPosition);
    float forceFieldRadiusAbs = abs(forceFieldRadius);              
 
    float3 directionToParticle = normalize(particleCenter - forceFieldPosition);
 
    float distanceToForceFieldRadius = forceFieldRadiusAbs - distanceToParticle;
    distanceToForceFieldRadius = max(distanceToForceFieldRadius, 0.0001);
 
    distanceToForceFieldRadius *= sign(forceFieldRadius);
     
    float4 particleOffset; 
     
    particleOffset.xyz = directionToParticle * distanceToForceFieldRadius;
    particleOffset.w = distanceToForceFieldRadius / (forceFieldRadius + 0.0001); // Add small value to prevent divide by zero and undefined colour/behaviour at r = 0.0.
 
    minDistanceToParticle = min(minDistanceToParticle, distanceToParticle);
 
    return particleOffset;
}


我们可以修改顶点着色器部分,使脚本在均匀半径开关打开时,持续更新并使用最小距离。下面代码的红色行是改动的着色器代码。

我们将最小距离变量初始化为一个较大数值,这样后续迭代能保证返回较小数值。我们在代码中使用了99999.0。

[C#] 纯文本查看 复制代码
v2f o;
 
float3 particleCenter = float3(v.tc0.zw, v.tc1.x);
 
float minDistanceToParticle = 99999.0;
 
float3 vertexOffset = 0.0;
 
for (int i = 0; i < _ForceFieldCount; i++)
{
    vertexOffset += GetParticleOffset(particleCenter, _ForceFields[i], minDistanceToParticle).xyz;
}
 
#ifdef _USEUNIFORMRADIUS_ON
 
    float3 normalizedVertexOffset = normalize(vertexOffset);
 
    float uniformRadiusAbs = abs(_UniformRadius);
    float minDistanceToUniformRadius = max(uniformRadiusAbs - minDistanceToParticle, 0.0);
 
    uniformRadiusAbs *= sign(_UniformRadius);
 
    vertexOffset = normalizedVertexOffset * minDistanceToUniformRadius;
 
#endif
 
v.vertex.xyz += vertexOffset;
o.vertex = UnityObjectToClipPos(v.vertex);


我们也需要对片段部分进行类似的改动。

[C#] 纯文本查看 复制代码
col *= i.color;
 
float3 particleCenter = float3(i.tc0.zw, i.tc1.x);
 
float minDistanceToParticle = 99999.0;
float maxNormalizedOffset = 0.0;
 
for (int i = 0; i < _ForceFieldCount; i++)
{
    maxNormalizedOffset = max(maxNormalizedOffset, GetParticleOffset(particleCenter, _ForceFields[i], minDistanceToParticle).w);
}
 
col = lerp(col * _ColourA, col * _ColourB, maxNormalizedOffset);
 
col *= col.a;


着色器现在可以正常使用,我们可以在编辑器看到选项开关。

04.png


下面是Use Uniform Radius选项开启和关闭时的不同效果。

05.gif


下面我们只需要更新C#组件,就可以生成并绘制均匀球体。

[C#] 纯文本查看 复制代码
void OnDrawGizmos()
{
    bool useUniformRadius = material.GetFloat("_UseUniformRadius") == 1.0f ? true : false;
    float uniformRadius = material.GetFloat("_UniformRadius");
 
    for (int i = 0; i < transform.childCount; i++)
    {
        Transform childTransform = transform.GetChild(i);
 
        float radius = useUniformRadius ? uniformRadius : (childTransform.lossyScale.x / 2.0f);
        Gizmos.DrawWireSphere(childTransform.position, radius);
    }
}


这样就实现了我们想要的效果。




着色器代码
下面是完整的着色器代码。

[C#] 纯文本查看 复制代码
Shader "Custom/Particles/GPU Force Fields Unlit (Tutorial)"
{
    Properties
    {
        _MainTex("Texture", 2D) = "white" {}
 
        [Toggle(_USEUNIFORMRADIUS_ON)] _UseUniformRadius("Use Uniform Radius", Float) = 0.0
        _UniformRadius("Uniform Radius", Range(-10.0, 10.0)) = 1.0
     
        [HDR] _ColourA("Color A", Color) = (0.0, 0.0, 0.0, 0.0)
        [HDR] _ColourB("Color B", Color) = (1.0, 1.0, 1.0, 1.0)
    }
 
    SubShader
    {
        Tags { "Queue" = "Transparent" "RenderType" = "Opaque" }
        LOD 100
 
        Blend One One // 加法混合
        ZWrite Off //关闭深度测试
 
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // 应用模糊效果
            #pragma multi_compile_fog
 
            #pragma shader_feature _USEUNIFORMRADIUS_ON
 
            #include "UnityCG.cginc"
 
            struct appdata
            {
                float4 vertex : POSITION;
                fixed4 color : COLOR;
                float4 tc0 : TEXCOORD0;
                float4 tc1 : TEXCOORD1;
            };
 
            struct v2f
            {
                float4 tc0 : TEXCOORD0;
                float4 tc1 : TEXCOORD1;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
                fixed4 color : COLOR;
            };
 
            sampler2D _MainTex;
            float4 _MainTex_ST;
 
            int _ForceFieldCount;
            float4 _ForceFields[8];
 
            float _UniformRadius;
 
            float4 _ColourA;
            float4 _ColourB;
 
            float4 GetParticleOffset(float3 particleCenter, float4 forceField, inout float minDistanceToParticle)
            {
                float forceFieldRadius;
                float3 forceFieldPosition = forceField.xyz;
 
                #ifdef _USEUNIFORMRADIUS_ON
                    forceFieldRadius = _UniformRadius + 0.0001;
                #else       
                    forceFieldRadius = forceField.w;
                #endif
 
                float distanceToParticle = distance(particleCenter, forceFieldPosition);
                float forceFieldRadiusAbs = abs(forceFieldRadius);
 
                float3 directionToParticle = normalize(particleCenter - forceFieldPosition);
 
                float distanceToForceFieldRadius = forceFieldRadiusAbs - distanceToParticle;
                distanceToForceFieldRadius = max(distanceToForceFieldRadius, 0.0001);
 
                distanceToForceFieldRadius *= sign(forceFieldRadius);
 
                float4 particleOffset;
 
                particleOffset.xyz = directionToParticle * distanceToForceFieldRadius;
                particleOffset.w = distanceToForceFieldRadius / (forceFieldRadius + 0.0001); // 添加小数来避免被除数为0,以及在r=0.0时出现未定义的颜色或行为。
 
                minDistanceToParticle = min(minDistanceToParticle, distanceToParticle);
 
                return particleOffset;
            }
 
            v2f vert(appdata v)
            {
                v2f o;
 
                float3 particleCenter = float3(v.tc0.zw, v.tc1.x);
 
                float minDistanceToParticle = 99999.0;
 
                float3 vertexOffset = 0.0;
 
                for (int i = 0; i < _ForceFieldCount; i++)
                {
                    vertexOffset += GetParticleOffset(particleCenter, _ForceFields[i], minDistanceToParticle).xyz;
                }
 
                #ifdef _USEUNIFORMRADIUS_ON
 
                    float3 normalizedVertexOffset = normalize(vertexOffset);
 
                    float uniformRadiusAbs = abs(_UniformRadius);
                    float minDistanceToUniformRadius = max(uniformRadiusAbs - minDistanceToParticle, 0.0);
 
                    uniformRadiusAbs *= sign(_UniformRadius);
 
                    vertexOffset = normalizedVertexOffset * minDistanceToUniformRadius;
 
                #endif
 
                v.vertex.xyz += vertexOffset;
                o.vertex = UnityObjectToClipPos(v.vertex);
 
               //从保存在颜色顶点输入的粒子系统接收数据,并将该数据用于初始化颜色
 
                o.color = v.color;
 
                o.tc0.xy = TRANSFORM_TEX(v.tc0, _MainTex);
 
                // 初始化tex coord变量
 
                o.tc0.zw = v.tc0.zw;
                o.tc1 = v.tc1;
 
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }
 
            fixed4 frag(v2f i) : SV_Target
            {
               // 采样纹理
                fixed4 col = tex2D(_MainTex, i.tc0);
 
                // 让纹理颜色和粒子系统的顶点颜色输入相乘
                col *= i.color;
 
                float3 particleCenter = float3(i.tc0.zw, i.tc1.x);
                 
                float minDistanceToParticle = 99999.0;
                float maxNormalizedOffset = 0.0;
 
                for (int i = 0; i < _ForceFieldCount; i++)
                {
                    maxNormalizedOffset = max(maxNormalizedOffset, GetParticleOffset(particleCenter, _ForceFields[i], minDistanceToParticle).w);
                }
 
                col = lerp(col * _ColourA, col * _ColourB, maxNormalizedOffset);
 
                col *= col.a;
 
           // 应用模糊效果
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}


小结
如何实现多重GPU粒子力场就介绍到这里,希望大家学以致用,牢牢掌握这些制作粒子特效的基础,从而制作出精美的特效。

更多Unity教程,尽在Unity官方中文论坛(UnityChina.cn)。

0

主题

10

帖子

295

贡献

初级UU族—3级

Rank: 3Rank: 3

积分
295
发表于 2019-2-20 07:26:55 | 显示全部楼层
厉害了,真大神
快速回复 返回顶部 返回列表