一、光晕逻辑

光晕的逻辑很简单,就是在屏幕上画上一个一个方形的 Mesh,然后采样带 Alpha 通道的光晕贴图,效果就出来了,其中方形 Mesh 的大小、位置、纹理表现全部都由美术配置,因此效果好坏主要取决于光晕贴图以及是否有一套很好的参数/配置

unity 日晕 unity光晕效果_unity 日晕

1.1 Unity URP 光晕

其实 Unity 是支持光晕的,有自带的光晕组件,不过很可惜的是它不能很好的支持 URP:

unity 日晕 unity光晕效果_着色器_02

默认的组件不行的话,就只能自己去实现,不过好在网上已经有人在 HDRP 上实现过了(来源于一个 HDRP Demo):毕竟效果大同小异,直接抄就完事,略微改下就能用,后面的内容也都是在这个基础之上做的分析和优化

1.2 光晕的可见性

光晕(halation)是指在曝光拍摄过程中,强光投射到胶片上时,透过胶片乳剂中在片基表面进行反射,从而致使图像发晕的现象

想要一个科学的光晕效果,需要满足两个条件:

  1. 场景中的太阳可视时,会出现镜头光晕效果
  2. 光晕在屏幕上的位置分布与场景中的太阳方位,和当前视角都有一定关系

场景中的太阳作为主要的平行光源,我们往往只需要其方向,但若想要实现这两个需求,还是要拿到太阳的具体位置,很好办,直接无脑将场景太阳方向乘上一个非常大的值就 OK 了:

half3 D = _ROCLightDir1;
float4 clip = TransformWorldToHClip(GetCameraRelativePositionWS(D * 10000));
float depth = LinearEyeDepth(0, _ZBufferParams);

这一部分是自己做的扩展,原方案的光晕位置是跟着挂载组件的 GameObject 走的

然后就是遮挡判断,需要计算太阳的屏幕空间坐标,并且和当前的摄像机深度缓冲做对比,不过这还有一个问题就是:真实的太阳它不会是一个点,因此我们不能只拿一个世界坐标去进行判断

unity 日晕 unity光晕效果_顶点着色器_03

这个也很好解决,就是在中心点周围随机散点多次,分别采样计算是否被遮挡,然后拿得到的遮挡比率去乘上光晕颜色作为最终的贡献,由于这块是在顶点着色器中做的,而你光晕的贴片顶点很少,因此性能还算 OK 的,当然对于很低端的移动设备不支持在顶点着色器中采样深度贴图的话就不要开光晕了,片段着色器做这个性能爆炸

//thanks, internets
static const uint DEPTH_SAMPLE_COUNT = 32;
static float2 samples[DEPTH_SAMPLE_COUNT] = {
    float2(0.658752441406,-0.0977704077959),
    float2(0.505380451679,-0.862896621227),
    float2(-0.678673446178,0.120453640819),
    //…… 32 组随机数,略
};

float GetOcclusion(float2 screenPos, float depth, float radius, float ratio)
{
    float contrib = 0.0f;
    float sample_Contrib = 1.0 / DEPTH_SAMPLE_COUNT;
    float2 ratioScale = float2(1 / ratio, 1.0);
    for (uint i = 0; i < DEPTH_SAMPLE_COUNT; i++)
    {
        float2 pos = screenPos + (samples[i] * radius * ratioScale);
        pos = pos * 0.5 + 0.5;
        pos.y = 1 - pos.y;
        if (pos.x >= 0 && pos.x <= 1 && pos.y >= 0 && pos.y <= 1)
        {
            float sampledDepth = LinearEyeDepth(SAMPLE_TEXTURE2D_LOD(_CameraDepthTexture, sampler_CameraDepthTexture, pos, 0).r, _ZBufferParams);
            if (sampledDepth >= depth)
                contrib += sample_Contrib;
        }
    }
    return contrib;
}

vert()
{
    float2 screenPos = clip.xy / clip.w;
    float ratio = _ScreenParams.x / _ScreenParams.y;
    float radius = v.worldPosRadius.w;
    float occlusion = GetOcclusion(screenPos, depth, radius, ratio);
}

1.3 Mesh 生成与着色

这一块完全根据配置决定,几乎没有计算量,太简单了,可以直接看代码

不过还有一点要提:原方案由于可以调节的参数比较多,它们要传入 shader,都需要通过 mesh 的额外通道 uv1、uv2、uv3,并且格式要开 float4,这样就可能会出现通道不够用的情况,特别是有的设备可能都不支持额外的 uv 数据使用 float4,因此有些参数就考虑不要了

struct appdata
{
    float4 vertex: POSITION;
    float2 uv: TEXCOORD0;
    float4 color: COLOR;

    // LensFlare Data : 
    //      * X = RayPos 
    //      * Y = Rotation (< 0 = Auto)
    //      * ZW = Size (Width, Height) in Screen Height Ratio
    nointerpolation float4 lensflare_data: TEXCOORD1;
    // World Position (XYZ) and Radius(W) : 
    nointerpolation float4 worldPosRadius: TEXCOORD2;
    // LensFlare FadeData : 
    //      * X = Near Start Distance
    //      * Y = Near End Distance
    //      * Z = Far Start Distance
    //      * W = Far End Distance
    nointerpolation float4 lensflare_fadeData: TEXCOORD3;
};

很明显,如果我们确保光晕的方向一定跟着场景光源方向走,那么我们就不太需要去传递世界坐标到 shader 中,只需要按照前面的方案无脑拿平行光方向乘上一个很大的数就可以了,这样第二组参数就可以省下来

除此之外,第三个参数用于实现光源距离越近,光晕效果越强的渐进效果,其实也可以不要,目前来看效果是 OK 的,毕竟我们完全可以把太阳当作在无限远的位置,给个固定值就可以了

这样就能砍掉一半的顶点数据

1.4 后续性能优化方案

目前实现是用了多个独立的 subMesh 来绘制多个面片,但其实也可以使用分 UV 的方式,把多张 Flare 贴图合成一张图,并且在一个 Mesh 上进行绘制,这样可以减少到一次 Drawcall

unity 日晕 unity光晕效果_Unity3D_04

除此之外,目前光晕和画质无关,所有画质下都是默认开启的,理论上低配中配可以不给开

二、手机各平台兼容 

很可惜,如果你和我一样是基于前面 HDRP Demo 的光晕效果,在此基础之上优化修改的,那么它还有一个问题,那就是其不能很好的支持的 Andriod 平台(当然如果你不考虑除了 Window 以外的其它平台就当我没说)

问题就出现在文章中的 2.2 光晕光效的可见性部分,当主光源被遮挡时,就不会有光晕的现象,这个判断很简单,因为太阳的位置可以被视作无限远,因此我们只要以太阳中心为圆心,多次随机采样周围屏幕坐标对应的深度信息中有没有被写入数据就可以了,如果有就说明这个屏幕点不可视

但是在 Andriod 平台上,这个深度检测它貌似失效了:即怎么检测都通过,因此无论光源是否可见,光晕效果永远存在,这明显是不对的

unity 日晕 unity光晕效果_unity 日晕_05

2.1 是否当前的手机平台不支持顶点着色器采样纹理

查了下资料,OpenGL 3.0 及以上,DX9 以上都支持顶点着色器中采样纹理,而事实上,现在极大多数设备平台都满足这个要求,因此不是这个原因

  • 但仍然需要注意的是:Unity 若要在顶点着色器中采样纹理,需要指定 mipmap 等级,这就意味着不能使用常规的 API 例如 SAMPLE_TEXTURE2D(…),需要用 SAMPLE_TEXTURE2D_LOD(…, 0) 代替,这是因为 mipmap 的级别的选择需要在片段着色阶段才能获取

2.2 是否是因为指定的着色器模型,默认编译目标设置过高

Shader 中设置的默认编译目标为 5.0,这基本上是能设置的最高等级了:

#pragma target 5.0

后来查了下 Unity3D 对应的官方手册:着色器编译:针对着色器模型和 GPU 功能,其中也说明

unity 日晕 unity光晕效果_着色器_06

如果真如此,那么显然目前几乎所有的主流设备都不支持编译这么高的版本,必然有问题

不过也很奇怪,如果真的不支持整个 shader 都应该不生效才对,不应该只是深度测试有问题,因此后面又查了下资料,确保 Unity 在这块的设置是默认向后兼容的,而且你没有用像曲面细分着色器这种必须高版本才支持的功能/特性的话,编译目标版本确实低一些也没关系

后面修改编译目标,打包测试了下,排除了这个原因

2.3 疑似屏幕坐标计算错误

参考其它 shader:通过屏幕坐标采样 depthTexture,都会对 clipPos 做一步 ComputeScreenPos 的操作,再除一个 w,而出现问题的光晕着色器中,并没有这一步,最终我们用于采样的坐标是手算的,所以怀疑是否有算错

o.pos = TransformObjectToHClip(v.pos.xyz);
o.screenUV = ComputeScreenPos(o.pos);
half2 screenUV = i.screenUV.xy / i.screenUV.w;
viewSpaceDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, screenUV);

需要排查这个原因,需要先知道 Unity 的 ComputeScreenPos 到底为我们做了什么事

2.3.1 ComputeScreenPos

直接看源码:

float4 ComputeScreenPos(float4 positionCS)
{
    float4 o = positionCS * 0.5f;
    o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w;
    o.zw = positionCS.zw;
    return o;
}

对于我们的输入 positionCS,也就是经过 TransformObjectToHClip 转换后的坐标是处于裁剪空间中的坐标,其 x, y 分量的范围为

unity 日晕 unity光晕效果_Unity3D_07

,假设我们最终屏幕的宽高分别为 width 和 height,那么真实屏幕空间坐标的计算方法应该是:

screenUV.x = ((pos.x / w) * 0.5 + 0.5) * width;
screenUV.y = ((pos.y / w) * 0.5 + 0.5) * height;

这就是一个标准的

unity 日晕 unity光晕效果_Unity3D_07

 映射到 [0, 1] 范围的操作,再乘上我们的宽和高而此时你再看 ComputeScreenPos 方法内的操作,你就会发现,它的主体其实就是上面的代码乘上 w(先不考虑其中的 _ProjectionParams.x),也就是将

unity 日晕 unity光晕效果_Unity3D_07

 映射到

unity 日晕 unity光晕效果_着色器_10

 范围而非 [0, 1]

Unity 为什么要这样做呢?即为什么不直接帮我们直接映射到 [0, 1]?

  1. 考虑 tex2Dproj 指令,其会在对纹理采样前帮我们把参数除上一个 w 分量,这样你在 ComputeScreenPos 之后直接套用这个方法就是正确的了,只不过我们很少用这个 tex2Dproj 指令
  2. 如果你要在片段着色器中采样,非常不建议在顶点着色器中就提前除以 w 分量,此会导致经过插值到片段着色器后,得到的插值结果不准确,因此最好是先插值再归一化,这是因为投影空间不是线性空间

而对于 _ProjectionParams.x,则是判断我们的投影矩阵是否为翻转矩阵,如果使用了翻转投影矩阵,那么我们同时也要翻转 y 的坐标值,关于是否使用翻转投影矩阵,这个就和平台有关

  • 如果是 Direct3D-like 平台(UNITY_UV_STARTS_AT_TOP = 1),就需要进行翻转
  • 如果是 OpenGL-like 平台(UNITY_UV_STARTS_AT_TOP = 0),则无需翻转

因此使用 ComputeScreenPos 就不需要关心这一部分平台差异了

2.4 Reversed-Z

在做深度测试的时候,我们把太阳的位置当作一个无限远的位置,但是不同平台下,深度最值不一样,这个是个很重要的问题,之前有篇文章也专门介绍过 Reversed-Z

看下原始代码,逻辑中给太阳设定的深度值如下:

float depth = LinearEyeDepth(0, _ZBufferParams);

关于 LinearEyeDepth,即传入深度纹理中的深度值以计算出实际的深度值,这就不详细介绍

很明显,只有在深度发生反转的平台上(DirectX),最值深度才为0,OpenGL 平台没有 Reversed-Z,其深度范围为 [0, 1],最值为1,因此正确的考虑了平台差异的代码应如下:

#if UNITY_REVERSED_Z
    float depth = LinearEyeDepth(0, _ZBufferParams);
#else
    float depth = LinearEyeDepth(1, _ZBufferParams);
#endif

好了,搞定!