Unity 大气特效插件分析 - Aura #01

老实说我也不知道该叫啥了这标题wwww

Aura是一个Unity的开源插件,可以实现较为出色的大气效果(如:体积光,体积雾等等):

传送门:
Asset store: https://assetstore.unity.com/packages/vfx/shaders/aura-volumetric-lighting-111664
Github: https://github.com/raphael-ernaelsten/Aura

大概的效果是这个样子的www:

(都是官方的图x)

那么www它到底是怎么实现的呢www?

(本来以为自己肯定什么都看不懂打算这个假期好好研究一下的x 结果在回来的飞机上就稍微弄明白了一点ww(虽然实现确实很直接简单www)果然还是比以前厉害了一点点的x(逃


在Aura的github页面(上面有链接)中提到了这样一张图,也就是实现的流程图:

首先第一部分,对应着Aura里的各种光源(平行(Directional)光、点光源等等)和各种Volume(一块雾)。(Aura这里光源不需要打在雾上(进行散射)从而产生体积光的效果,单一个光源就可以拥有体积光的效果w)
它们只是各种形式的数据结构(这块volume的形状,光源的参数等等)保存在内存中,等待随后的操作将它们打包发送到Compute Shader进行计算得到最终的光照结果。

简单概括一下的话,整个流程大概是:计算各点颜色,累加,应用到渲染结果上。

主要的光照计算过程发生在 Aura::Frustum::ComputeData() (Aura/Classes/Frustum.cs : 147)中。ComputeData() 函数在Aura的主类(Aura.cs)的 UpdateFrustrum()(更新视锥体)函数(Aura.cs : 351)中被调用,而 UpdateFrustrum() 函数又在同一类下的 OnRenderImage(RT, RT) 函数(Aura.cs : 174)中被调用。OnRenderImage 函数完成了绘制图像的操作,两个参数(都是RenderTexture - 两张纹理,就是在屏幕上要显示的当前帧)src和dest分别是这个函数的输入和输出。在函数 OnRenderImage() 中,用PostProcess的方式(使用一个PixelShader(Aura/Shaders/Shaders/PostProcessShader.shader),后述)把 ComputeShader 计算的最终光照结果(一个3D纹理,对应图上最下面的Integrated Volumetric Lighting,是 UpdateFrustrum() 的产物,后述)应用到 Unity 按照常规方法渲染出的图片上面,得到最终的结果。

↑ 输入图像(常规渲染结果)

↑ ComputeShader 计算得到的结果(由于是3D纹理所以用了个gif,由近及远)

↑ 通过 PostProcessShader 把大气效果应用到渲染结果上,得到最终的图像。

(1)光照(大气效果)的计算:

首先,按照设置中的精细程度与渲染范围(在摄像机的Aura组件下有名为Resolution和Range的设置项),把当前 View Space 的视锥体(也就是摄像机眼前的这个锥体)按照精细度分成很多小块进行计算。

↑ 就这个椎体,就是Frustrum。

在把视锥体切成许多小块后,每一个小块就对应着最终3D纹理中的一个体素(是像素概念从2D到3D的延申),同时每个小块之间在这一阶段上没有计算,这个过程是高度并行化的(每个thread计算一个体素),交给了ComputeShader。每一个体素的计算过程在ComputeDataComputeShader.compute 中。

在这之前,还对当前的深度缓冲区做了一些处理( ComputeMaximumDepthComputeShader.compute ),得到了视锥体中的深度信息(在视锥体格子的某一条x,y轴上,摄像机最远能看到哪里):

(计算得到的深度图,主要是对原图的信息进行整合,得到在视锥体的3D纹理清晰度下合适的深度图像)

根据这样一张深度图,剔除掉一些没有作用的体素。(被场景物体遮挡住了)

回过来看光照的计算:

抛开前六万五千行(...)的宏定义(根据用户的设置不同(使用 / 忽略光源等)最多可以产生32768种设置组合,所以定义了这么多宏定义)不管,可以看到接下来的计算过程非常的直接:

(这里很多东西都不是很正确,只能当个大概理解一下ww(这里太菜还没搞懂orz)

1.  首先获得当前体素对应的世界空间坐标。在这里,这个位置是加了一些Jitter(扰动 / 噪声)的,让最终产生的结果更加的柔和(用噪声弥补清晰度上的不足,之后也有很多地方有这种处理)。

2.  计算每个 Volume 对该点的贡献。(带有 "密度" (相当于alpha) 的颜色)

3.  计算每个光源对该点颜色的贡献。首先通过该光源的 ShadowMap 计算自己是否在它的阴影中,如果在阴影中那么这个光源相当于不存在(+0);如果不在阴影中则被光源照亮,当前点的颜色加上光源的颜色(还有相应的衰减)。同时,如果有 Light Cookie 之类的东西也可以在这里计算出来。

4.  对这个点最终得到的颜色进行一些小处理,如非负性等;

5.  利用前一帧计算的结果和当前帧的结果进行混合,让最终得到的结果更加的柔和。这一步很关键,如果不重复利用前一帧的计算结果,最后渲染出的图像上可以看出非常明显的 artifact 。但在混合后,渲染质量有了很大提升。Aura给的默认值是前一帧占90%,当前帧的结果占10%,在60FPS下这是一个较为理想的参数。(怎么momentum都是0.9(x))在混合的过程中,由于摄像机位置的变化,导致前一帧的视锥体与当前帧的视锥体之间有着些许的位移,所以需要通过一系列矩阵变换,把当前帧在视锥内的位置变换到前一帧的视锥中,再对前一帧得到的结果进行取样。

到这里,我们得到了每个点的颜色。这里觉得可以浅显的理解为,这个颜色就是当前格大气被光照之后散射的颜色,相当于一个小光源(的颜色)。但到现在我们只得到了摄像机前每个格子的光源颜色,还没有计算这些光源照到摄像机中的效果。

就不贴代码了,太长而且零零散散(

 

所以第二步就是把每个光源的颜色进行累加啦。

对应的文件为 ComputeAccumulationComputeShader.compute (Accumulation就是累加的意思):

在累加的过程中,我们需要考虑到散射光源在传播路径上的衰减:使用exp(指数)函数作为衰减函数(因为 exp( ax ) 是 x = a * x' (经过单位距离衰减a, a < 1) 的解)。过程也很简单:

1.  每一个thread(threadIdx为x, y, z)计算由当前格子(x, y, z)开始,光线传播到摄像机(x, y, 0)后的最终颜色。这一过程同时考虑到了路上所有格子的光照(不单只有起始点一个格子)。

2.  通过循环计算光从远端传递到摄像机的过程。Aura代码里的循环写得有些绕,是从摄像机(z = 0)循环到当前格的z (从摄像机到当前格方向),然而在计算的时候反过来算衰减,实际上就是从当前格衰减、传播到摄像机的过程。

half4 Accumulate(half4 colorAndDensityFront, half4 colorAndDensityBack)
{
    half transmittance = exp(colorAndDensityBack.w * layerDepth);
    half4 accumulatedLightAndTransmittance = half4(colorAndDensityFront.xyz + colorAndDensityBack.xyz * (1.0f - transmittance) * colorAndDensityFront.w, colorAndDensityFront.w * transmittance);
    // 注意这里 Front + Back * (1.0f - transmittance), 也就是反过来计算。

    return accumulatedLightAndTransmittance;
}

[numthreads(NUM_THREAD_X,NUM_THREAD_Y,NUM_THREAD_Z)]
void RayMarchThroughVolume(uint3 id : SV_DispatchThreadID)
{
    // 获得当前点坐标
    half3 normalizedLocalPos = GetNormalizedLocalPositionWithDepthBias(id);

    #if ENABLE_OCCLUSION_CULLING
    // 遮挡剔除
    [branch]
    if(IsNotOccluded(normalizedLocalPos.z, id.xy)) // TODO : MAYBE COULD BE OPTIMIZED BY USING A MASK VALUE IN THE DATA TEXTURE
    #endif
    {
        // 设置初值
        half4 currentSliceValue = half4(0, 0, 0, 1);
        half4 nextValue = 0;          
        
        // 循环计算衰减
        [loop]
        for(uint z = 0; z < id.z; ++z)
        {
            nextValue = SampleLightingTexture(uint3(id.xy, z));
            currentSliceValue = Accumulate(currentSliceValue, nextValue);
        }
          
        half4 valueAtCurrentZ = SampleLightingTexture(id);
        currentSliceValue = Accumulate(currentSliceValue, valueAtCurrentZ);

        // 将最终得到的结果写入3D纹理
        WriteInOutputTexture(id, currentSliceValue);
    }
}

 

最后一步就是根据最后得到的衰减累加3D纹理,用 PostProcessShader.shader 给图像做最后的上色。方法也很简单,获得当前位置的深度,转换到对应的 View Space (视锥体)坐标,从3D纹理中采样,加入一些噪声之后把当前颜色(计算得到的颜色)叠加到原有图像上面,就得到了最终的结果:

float4 Aura_GetFogValue(float3 screenSpacePosition)
{
    // Aura_VolumetricLightingTexture: 衰减累加得到的3D纹理(ComputeShader的最终结果)
    return tex3Dlod(Aura_VolumetricLightingTexture, float4(screenSpacePosition.xy, Aura_RescaleDepth(screenSpacePosition.z), 0));
}

void Aura_ApplyFog(inout float3 colorToApply, float3 screenSpacePosition)
{
    // 加入一些噪声
    screenSpacePosition.xy += GetBlueNoise(screenSpacePosition.xy, 3).xy;

    float4 fogValue = Aura_GetFogValue(screenSpacePosition);

    // 再加一点噪声
    float4 noise = GetBlueNoise(screenSpacePosition.xy, 4);

    // 叠加颜色
    colorToApply = colorToApply * (fogValue.w + noise.w) + (fogValue.xyz + noise.xyz);
}

fixed4 frag (v2f psIn) : SV_Target
{
    // 转换深度坐标
    float depth = tex2D(_CameraDepthTexture, psIn.uv);
    depth = LinearEyeDepth(depth);

    // _MainTex 为普通渲染得到的最终图像
    float4 backColor = tex2D(_MainTex, psIn.uv);
    Aura_ApplyFog(backColor.xyz, float3(psIn.uv, depth));
    // 见上面的函数定义

    return backColor;
}

 

累加前与累加后的对比:

↑ 累加前

↑ 累加后,注意光束

 

然后我把它随便丢到了一个场景里面www效果还可以ww?因为还没有细调www(所以就看看玩玩(x

因为整个操作是在 View Space 的一定范围里做的,所以场景多大都可以,也算是一个比较让人满意的点www?嘛x

以及这边的累加的作用可以看得更明确一点ww(虽然好像本身就很直观了:

↑ 累加前

↑ 累加后 

以上ww

posted @ 2018-08-23 23:46  Betairy linkzeldagg  阅读(1818)  评论(0编辑  收藏  举报