unity中粒子显示在UI上层 unity怎么加粒子_Sage


这是我偶然发现的一个程序化生成的小demo,并且意外的发现这个Demo可以使用了几个非常典型的GPU特效制作的技巧,改一改都可以出一道TA面试题了。所以在这里推荐给大佬们练练手,给萌新们入入门。

简介


unity中粒子显示在UI上层 unity怎么加粒子_unity粒子系统_02

由圆开始的演化


初次接触到这个案例是在观看一个Houdini系列教学视频的时候(可以看这里),这个视频描述了一个叫Sage Jenson的艺术家做的程序化艺术作品(原作者Sage的博客在这里),而这位艺术家的灵感又是源自于一篇介绍多头绒泡菌的生物学论文(好吧我承认我是在套娃)。在这里会使用Unity的Compute Shader 相关的功能对这个效果进行实现。

我的Unity开源代码公布如下:

Physarum实现github.com

最终效果展示

老规矩先上效果图


unity中粒子显示在UI上层 unity怎么加粒子_Sage_03

从《星空》开始进行2D演化https://www.zhihu.com/video/1220162023923052544

unity中粒子显示在UI上层 unity怎么加粒子_Sage_04

从《星空》开始进行2D演化的几个阶段

unity中粒子显示在UI上层 unity怎么加粒子_Sage_05

从圆球开始进行3D演化https://www.zhihu.com/video/1220523510684090368

unity中粒子显示在UI上层 unity怎么加粒子_unity中粒子显示在UI上层_06

从圆球开始进行3D演化 静帧I

unity中粒子显示在UI上层 unity怎么加粒子_Sage_07

从圆球开始进行3D演化 静帧II

【算法原理】

生物学模型在原Paper里有介绍,但是Sage的博客里介绍的更加清楚,这里借用博客的图来解释一下。

整个细菌的演化系统可以看作是一个巨大的粒子系统,每个细菌是一个粒子。所以上面看到的小白点就代表了在运动中的细菌粒子。为方便计算,假定每个粒子的运动速度恒定,只是运动方向在不断变化。

那么在图中画出的有颜色的部分又是什么呢?每个粒子在每一帧都会通过搜集空间的信息来调整运动的方向,同时也会在空间内留下自己的信息。这里的生物信息用一个一维向量表示,可看做是信息素浓度。在上述图中有颜色的部分其实就代表了信息素的浓度。我们把空间中的生物信息保存在一个二维贴图里,我们把它称之为Trail,并且我们人为的定义所研究的二维空间四方连续(即当粒子走出+x边界时,会从y坐标相同的-x边界点进入空间,其他三个方向类似)。

系统的更新分为以下所述的六个步骤:


unity中粒子显示在UI上层 unity怎么加粒子_贴图_08

细菌网络演化迭代算法示意

  1. SENSE: 每个粒子搜集附近的信息浓度,实际上只需要采样三个点,如上图左上所示。
  2. ROTATE: 根据搜集到的信息浓度,进行运动方向的调整,入上图右上所示。
  3. MOVE: 更新粒子的位置信息。
  4. DEPOSIT:每个粒子留下各自的信息,并存储在Trail里。
  5. DIFFUSE: 更新Trail,每个格点中的信息向四周扩散,可以理解为流体的流动。
  6. DECAY: 更新Trail,每个格点中的信息衰减,一般为百分比衰减,可以理解为挥发。

上述六个步骤,其中1-3是粒子相关,4-6是Trail相关。其中1和4是粒子和Trail交互的步骤,1是需要从Trail读入数据,并更新粒子的信息,4则是需要读取粒子的信息并更新Trail内的数据。

这里有一个小细节,就是在1和4交互步骤里遍历谁的问题。这个问题在CPU上很好解决,但是GPU编程需要考虑架构的问题,即对同一个贴图无法同时进行读写操作,因此在这种数据交互的问题上会比较容易遇到问题。在下面的文章中我会展开讲述这个问题,在此先不表。这个方案仅仅是对于这个案例可行的,未必是最优解,如果有更好的方法欢迎留言评论。

了解了算法之后,是不是很想上手敲代码了呢。感觉这么简明的核心算法,二三十行的代码也就能写完了呀,甚至数学原理比什么快排来说可简单多了。然而悲惨的事实是,就我个人的体验而言,真正调算法的时间也就只占10%(o(╥﹏╥)o),大部分时间还是花在了整体架构的搭建和最后抠细节上面。下面我会把一个我认为比较科学的制作流程给大家分享一下。

【数据结构】

在对核心算法有初步的了解之后,就可以着手设计数据结构了。在设计数据结构的时候,还需要同时考虑数据的生命周期和维护等一系列问题。数据结构的具体细节在开发过程中是会经历无数次迭代的,甚至可能会经历若干次大重构。所以实际的开发过程中,数据结构的大方向找准即可,在前期不需要花太多时间去精雕细琢

参考Sage博客中的内容,一幅图没有几十万个粒子是做不出效果来的(别问我看着2000粒子结果的表情),所以只能考虑全GPU实现。我采用的是GPU粒子+RT贴图绘制的方案。

粒子部分,需要每帧更新每个粒子的位置,动力学部分粒子的速度方向需要被记录,视觉部分可能需要做粒子参数的随机,可能会考虑把标量速度,标量角速度,传感器参数加入进来。存储的容器考虑为一个一维数组,基本上ComputeBuffer可以完成。

Trail部分,是一个二维的网格,很明显就是RenderTexture(以下简称为RT)。由于只要记录一个浮点数,即生物信息浓度,考虑只开r通道的浮点数RT。并且考虑到生物浓度信息的绝对数值是没有意义的,在算法里只需要进行相对数值的计算,所以尽量把存储的数值往1靠保持精度(好吧,其实只是后面为了做视觉效果需要保持在0-1 (¬_¬))


unity中粒子显示在UI上层 unity怎么加粒子_数据结构_09

数据结构示意

粒子

这里我只选择了最基础的信息进行存储:


struct ParticleInfo
{
    float3 pos;
    float3 vel;
};


在C#里定义如下(Physarum2D.cs L50)


private ComputeBuffer particleInfo;


在compute shader里定义如下(Physarum2DUpdate.comput L28)


RWStructuredBuffer<ParticleInfo> ParticleInfoBuffer;


在.shader里定义如下(BillboardParticles.shader L45)


StructuredBuffer<ParticleInfo> ParticleInfoBuffer;


当然上述的ParticleInfo在C#端和Shader端都需要声明,并且位数需要对齐。我用了一个单独的ComputeShader来进行数据结构的初始化。

C#端代码(Physarum2D.cs L156)


particleInfo = new ComputeBuffer(particleCount, Marshal.SizeOf(typeof(ParticleInfo)));
        InitParticle.SetBuffer(InitParticleHandle, ParticleInfoID, particleInfo);
        InitParticle.Dispatch(InitParticleHandle, particleCount / threadNum, 1, 1);


shader端代码(Physarum2DInit.compute L20)


[numthreads(THREAD_NUM,1,1)]
void InitParticle(uint3 id : SV_DispatchThreadID)
{
       ParticleInfoBuffer[id.x].pos = float3( rand2(id.x) * 2.0 * _Size - float2(1,1) * _Size , 0 );
       ParticleInfoBuffer[id.x].vel = normalize(float3(rand2( id.x * 2 ) * 2 - float2(1,1) , 0 ));
}


Trail

实际上用了3张RT,因为同时对一张RT进行读和写是会产生错误的,所以在迭代Trail的时候,需要用两张RT进行左右互博。并且在步骤4 Deposit的时候,为了方便,我会把粒子的信息先采集到一个Deposit贴图里,然后再和原来的进行merge,所以这个Merge的步骤需要3张贴图。

关于两张贴图左右互博,有一个小技巧(Physarum2D.cs L179)


public void SwitchRT()
    {
        var tem = trailRT[0];
        trailRT[0] = trailRT[1];
        trailRT[1] = tem;
    }

    public void UpdateShader()
    {
        ...

        PhysarumUpdate.SetTexture(DiffuseTrailHandle, TrailReadID, trailRT[READ]);
        PhysarumUpdate.SetTexture(DiffuseTrailHandle, TrailWriteID, trailRT[WRITE]);
        PhysarumUpdate.Dispatch(DiffuseTrailHandle, texGroupCount, texGroupCount, 1);
        SwitchRT();

        PhysarumUpdate.SetTexture(DecayTrailHandle, TrailReadID, trailRT[READ]);
        PhysarumUpdate.SetTexture(DecayTrailHandle, TrailWriteID, trailRT[WRITE]);
        PhysarumUpdate.Dispatch(DecayTrailHandle, texGroupCount, texGroupCount, 1);
        SwitchRT();
        ...
    }


嗯,对,就是每次Dispatch完,就交换两个RT的引用,这样就每次读和写的引用都是固定的,就不用去算哪个RT是输入,哪个RT是输出了。

RT的生命周期管理可以参考Physarum2D.cs的SetupRT()和ReleaseBuffers()两个函数。这种管理方法虽然简单但是可能会过时,至少在SRP里已经不再使用,所以这里就不贴代码了。

【可视化】

在制作一个效果的时候,最先会被考虑的往往是效果的算法,效率之类的问题。事实上可视化也是非常重要的一环,在实际生产中可视化水平的高低甚至能很大程度上决定开发的效率(说的就是你Unity)。直接进行算法的实现非常容易暴毙,特别是GPU编程,compute shader的编写宛若黑盒。在写完一大段代码之后,信心满满的按下play键~~~诶,黑了耶,等等,刚刚这里写错了,马上好,马上好~~~~哇哦,紫了~。所以,这里建议在进行比较大的项目的编写时,可以先做好输出结果的可视化。从零开始一步一个脚印的进行实现,慢慢写,比较快。

以这个项目为例,在粒子部分,我们可以“假定”所有粒子开始时随机分布在空间中,以匀速运动。先实现粒子的渲染,渲染制作完成后,再把粒子运动的6个迭代步骤加进来。

GPU粒子

关于GPU粒子,有一个很好的开源项目可以参考。想要深入了解的可以去看看这个项目,这里只讲一下我在这个项目中进行的最基础的实现。

由于粒子的信息被存储在ComputeBuffer里,所以可以很简单的在shader里拿到粒子的位置信息,同时需要使用instance的feature,进行粒子的遍历,在vert shader中完成粒子位置的重新组织。看一下代码就懂了:


StructuredBuffer<ParticleInfo> particles;
StructuredBuffer<float3> quad;

struct v2f
{
	float4 pos : POSITION;
	float2 uv : TEXCOORD0;
	float4 col : COLOR;
};

v2f vert(uint id : SV_VertexID, uint inst : SV_InstanceID)
{
	v2f o;
	float3 q = quad[id];
        // 把粒子位置投影到View空间,进行偏移,然后再投影到屏幕空间,保证Billboard始终面朝相机
	o.pos = mul(UNITY_MATRIX_P, mul(UNITY_MATRIX_V, float4(  particles[inst].pos  , 1.0f)) + float4(q, 0.0f) * _Size);
	o.uv = q + 0.5f;
	o.col = _Color ;
	return o;
}

fixed4 frag(v2f i) : COLOR
{
	return tex2D(_MainTex, i.uv) * i.col * i.col.a;
}


这个quad数组是什么呢,实际上它是一个常量数组,表示了一个billboard面片的6个点(两个三角形,有两个点需要计算两次,实际上模型是4个点)的坐标信息。


unity中粒子显示在UI上层 unity怎么加粒子_贴图_10

Quad数组内信息对应正方形的四个顶点

在C#里初始化以及渲染的代码如下(Physarum2D.cs)


public void SetupBuffer()
{
    ...
    quad.SetData(new[]
    {
        new Vector3(-0.5f,0.5f),
        new Vector3(0.5f,0.5f),
        new Vector3(0.5f,-0.5f),
        new Vector3(0.5f,-0.5f),
        new Vector3(-0.5f,-0.5f),
        new Vector3(-0.5f,0.5f)
    });
}
void OnRenderObject()
{
    renderMaterial.SetBuffer("particles", particleInfo); //传递粒子位置信息
    renderMaterial.SetBuffer("quad", quad);              //传递Billboard模型信息

    renderMaterial.SetPass(0);

    Graphics.DrawProceduralNow(MeshTopology.Triangles, 6, particleCount); //进行渲染
}


unity中粒子显示在UI上层 unity怎么加粒子_Sage_11

用匀速运动模型来检查GPU粒子是否正确完成

Trail可视化

由于Trail是用RT进行存储,所以渲染Shader里直接读RT就可以了。在这基础上,我根据浓度进行了进行了一个LUT处理。


unity中粒子显示在UI上层 unity怎么加粒子_Sage_12

进行LUT处理前后的Trail渲染结果

Trail渲染LUT实现代码(VisualizeTrail.shader L48)


fixed4 frag (v2f i) : SV_Target
{
    // sample the texture
    fixed4 trail = tex2D(_MainTex, i.uv);
    fixed4 col = tex2D( _LUT , float2( clamp( trail.r , 0.001 , 0.995) , 0));

    return col;
}


【算法实现】(以及遇到的坑)

墨迹了这么久,终于可以开始做核心算法的实现了。其实只要上述部分打通,核心算法其实实现起来非常的快。

首先核心算法有6个步骤,分为粒子和Trail的更新两个部分。我们先看粒子

  • SENSE: 每个粒子搜集附近的信息浓度,实际上只需要采样三个点。
  • ROTATE: 根据搜集到的信息浓度,进行运动方向的调整。
  • MOVE: 更新粒子的位置信息。

基本就是一个简单的粒子更新嘛。由于每个粒子都是独立的,互相之间不通过粒子模拟的数据影响,所以可以把这三个步骤合并到一起(Physarum2DUpdate.compute L37)


[numthreads(THREAD_NUM,1,1)]
void UpdateParticle(uint3 id : SV_DispatchThreadID)
{
    ...
    // Step I : sense the density
    float densityForward = TrailRead[IDForward.xy].r;
    float densityLeft    = TrailRead[IDLeft.xy].r;
    float densityRight   = TrailRead[IDRight.xy].r;

    // Step II : Rotate according to the density 
    if ( densityForward < densityLeft && densityForward < densityRight )
    {
        // turn randomly 
        vel = rand( pos + _Time.yyy * 5) < 0.5 ? mul( _TurnLeftMat , vel ) : mul( _TurnRightMat , vel);
    }else if ( densityLeft < densityForward && densityForward < densityRight )
    {
        // turn right
        vel = mul( _TurnRightMat , vel );
    }else if ( densityLeft > densityForward && densityForward > densityRight )
    {
        // turn left 
        vel = mul( _TurnLeftMat , vel );
    }

    vel = normalize(vel);

    // Step III : Move the Particles
    pos += vel * _Speed * _DeltaTime;
    pos = RepeatPosition( pos , _Size );
    ParticleInfoBuffer[id.x].pos = pos;
    ParticleInfoBuffer[id.x].vel = vel;
}


有两个小细节需要注意,一个是粒子的运动用旋转矩阵,为了提高效率,矩阵需要在C#中预算好。二是粒子的运动是四方连续的,简单来说就是从右边出去的粒子会再从左边进来,所以需要套一个RepeatPosition来让粒子保持在固定范围内运动。

然后说说5,6步,由于第5步涉及相邻网格的数据读取的问题,所以需要两张Render Texture 来完成这个计算,一张用于存储上一帧的信息,一张用来存储更新过后的信息。由于GPU计算式并行的,如果只使用一张贴图的话会触发死锁。

代码如下(Physarum2DUpdate.compute L103)


// Step V : Diffuse To the Neighbour
[numthreads(THREAD_NUM,THREAD_NUM,1)]
void DiffuseTrail(uint3 id : SV_DispatchThreadID)
{
    float4 den = TrailRead[id.xy];
    float4 depositDen = DepositTex[id.xy];

    //get the deposit from last step
    den += depositDen;

    den *= ( 1 - _DiffuseRate * 9 );
    for( int i = -1 ; i <= 1 ; ++i )
    {
        for( int j = -1 ; j <= 1 ; ++ j )
        {
            uint3 target = id;
            target.x = (target.x + i + _TrailResolution) % _TrailResolution;
            target.y = (target.y + j + _TrailResolution) % _TrailResolution;

            den += TrailRead[target.xy] * _DiffuseRate;
        }
    }
    TrailWrite[id.xy] = den;
}

// Step VI : Decay the Trail
[numthreads(THREAD_NUM,THREAD_NUM,1)]
void DecayTrail(uint3 id : SV_DispatchThreadID)
{
    float4 den = TrailRead[id.xy];
    den *= lerp( 1.0 , _DecayRate , _DeltaTime) ;
    TrailWrite[id.xy] = den;
}


最后说说比较烦人的步骤4吧。按照一般做法,是遍历每一个粒子,然后把对应粒子的对应的Trail格点找出来,在对应格点上增加一个_DepositRate。但是,这时候就需要考虑GPU编程的并行问题了,在并行运行的情况下,是没有办法同时对一个RT进行读和写的:


Trial[GetTextureID(ParticleID)] += _DepositRate; //无法对同时对一个RT进行读和写


对于这个问题的第一个解决方法,就是逆向思维:既然无法用粒子去改变RT,那么可以反过来在RT端接收来自粒子的影响就好了。所以这个方法是,不对粒子进行遍历,而是对先在网格内进行遍历,每个网格再对所有粒子进行遍历。即对于RT上的每个像素,遍历粒子,考察粒子是否在该像素上方,若是,则积累一个_DepositRate。

这种方法是能够精确的计算出来模拟的结果的,然而代价就是运行的复杂度很高,达到了O(nm^2),n为粒子数,m为贴图尺寸。这会产生什么问题呢,在一个2000粒子的1024*1024贴图规模的环境下,就已经无法保持实时了。用RenderDoc捕捉得到的渲染时间如下:


unity中粒子显示在UI上层 unity怎么加粒子_贴图_13

该方法在每帧渲染时间(单位为微秒)

那么2000粒子的规模能够达到怎么样的效果呢,如图所示:


unity中粒子显示在UI上层 unity怎么加粒子_数据结构_14


不~许~笑!这2000个粒子真的已经很努力了!(其实什么也干不了...

虽然可以用多级cache之类的方法降复杂度,但是工作量会增加的非常大,并且指标不治本。

所以我们需要回到一开始的方案,能不能找到一个方法,既解决同时读写的问题,又把只遍历一遍粒子的方法变成可能?答案是肯定的。

这又要回到一个做渲染时经常会遇到的问题,即效率和精度之间的平衡。学CS的同学经常会碰到空间和时间的取舍。但是在渲染领域,要是能碰到只通过用空间换时间就能解决的问题那可真的是太幸运了。事实上大部分情况下,渲染效率问题还需要通过平衡精度来解决——毕竟只要肉眼看上去正确,数学上的那点区别就不必在意了。于是在这个案例中,我们选择了需要牺牲部分精度来换取渲染的效率。

我们认为,在Trail的一个格点里,无论堆积了多少个粒子,都会产生同样的Deposit积累。也就是说,我们直接把信息素积累的+=变成=,这样RT就从读写变成只写了。实际上这种近似是合理的,因为比如10万个粒子,被1024*1024个网格一分,实际上每个格子里平均也就0.1个,我们可以认为这个案例里的粒子足够稀疏(好吧,其实也没有什么理论的根据,但是从结果来看这个近似是可行的)。但是这只是把新增的部分抽离出来,实际上随着时变的信息素浓度还是需要读写操作的,所以这里的做法是额外再开一个DepositRT,把粒子对Trail的改变都记录到这个DepositRT上,然后再把这个RT和上一帧的TrailRT进行合并。

最终代码如下(Physarum2DUpdate.compute L89)


// Step IV : Deposit from the particle to the trail
[numthreads(THREAD_NUM,1,1)]
void Deposit(uint3 id : SV_DispatchThreadID)
{
    float3 pos = ParticleInfoBuffer[id.x].pos;

    uint3 pID = GetTextureID(pos, _Size, _TrailResolution);
    
    DepositTex[pID.xy] = _DepositRate * _DeltaTime;

}


这种做法的复杂度为o(n),在本人的工作台式机上跑50万个粒子也是杠杠的


unity中粒子显示在UI上层 unity怎么加粒子_unity中粒子显示在UI上层_15

使用新方法在每帧渲染时间(单位为微秒)

小结

上一个图展示一下不同sense angle下形成菌落形态


unity中粒子显示在UI上层 unity怎么加粒子_贴图_16


和论文中的实验结果略有出入,不过就不细抠了,毕竟我们只是做视觉效果的。

2D实现的部分基本已经已经讲完了。这个教程以唠嗑聊心得为主,关于具体的算法细节还是鼓励去github里了解,我就不再多讲了。3D实现与2D类似,但是会遇到一些新的问题,比如3D的可视化就没有2D的那么轻松,同时3D的粒子的算法在数学上也会更加麻烦一些。另外2D部分的初始化现在是随机的,如果初始化需要根据一个贴图来做该怎么办呢。这些内容我会在下篇和大家分享。

夹带一些私货

如果有人问起,TA是什么,或许会有回答Technical Artist,帮助技术和美术实现功能对接,帮助生产规范化,提高生产效率的职位。

仅仅如此吗?

事实上,我相信在每一个TA的心目中都会有一片星辰大海,或是那些让画面表现超越了硬件桎梏的精妙算法,或是那些完美还原了自然界电磁波形态的写实渲染,又或者,像今天介绍的这个项目的原作者Sage Jenson一样,是那些用美妙的数学公式搭建出来的,只存在于虚拟世界中的瑰丽图景。

Procedure Art 也正是在这样一群对虚拟视觉效果有着热爱的人们中发展起来的。我在大学的图形学课时,也曾沉迷过用噪声制作出各种奇奇怪怪的图案,那时候换来的只有助教同学们的不解。幸好后来读研究生时,我逐渐了解了这一种艺术流派,并且学习了各种强大的工具,让自己有机会能够继续在数字视觉的海洋中继续自我放飞。作为一点私心,希望这篇文章能够抛转引玉,能够吸引更多的同学参与到Procedural Art的创作中来。

引用

[1] Sage Jenson,Personal Blog

physarum - Sage Jensonsagejenson.com

unity中粒子显示在UI上层 unity怎么加粒子_数据结构_17


[2]Physarum, Entagma,

Physarum Slime Moldentagma.com

[3]Characteristics of pattern formation and evolution in approximations of Physarum transport networks, Jeff Jones, 2011

Characteristics of pattern formation and evolution in approximations of physarum transport networksuwe-repository.worktribe.com

[4]GPU Particle, Robert-K