Unity自定义SRP(十五):SSAO

1 摄像机深度法线纹理和位置纹理

SSAO的计算需要摄像机空间的深度法线和位置信息,因此我们需要提前将场景的相应属性渲染到纹理中。

CameraRenderer中声明两个纹理:

    static int colorBufferId = Shader.PropertyToID("_CameraColorBuffer"),
                depthBufferId = Shader.PropertyToID("_CameraDepthBuffer"),
                depthNormalTextureId = Shader.PropertyToID("_CameraDepthNormalTexture"),
                positionVSTextureId = Shader.PropertyToID("_CameraPositionVSTexture");

同时声明一个shadertag,用于自定义pass:

    static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit"),
                        litShaderTagId = new ShaderTagId("CustomLit"),
                        depthNormalShaderTagId = new ShaderTagId("DrawDepthNormal");

定义SetupDepthNormal方法:

    void SetupDepthNormal()
    {
        context.SetupCameraProperties(camera);
        buffer.GetTemporaryRT(depthNormalTextureId, bufferSize.x, bufferSize.y, 0, FilterMode.Point, RenderTextureFormat.Default);
        buffer.GetTemporaryRT(positionVSTextureId, bufferSize.x, bufferSize.y, 0, FilterMode.Point, RenderTextureFormat.Default);
        buffer.GetTemporaryRT(depthBufferId, bufferSize.x, bufferSize.y, 32, FilterMode.Point, RenderTextureFormat.Depth);
        RenderTargetIdentifier[] colorBuffersId = new RenderTargetIdentifier[2];
        colorBuffersId[0] = depthNormalTextureId;
        colorBuffersId[1] = positionVSTextureId;
        buffer.SetRenderTarget(colorBuffersId, depthBufferId);
        buffer.ClearRenderTarget(true, true, Color.clear);
        buffer.BeginSample(SampleName);
        ExecuteBuffer();
    }

声明两个颜色渲染目标,并声明一个深度渲染目标。注意,滤波模式为Point就行了,并且不需要用MSAA。

将渲染目标存在一个RenderTargetIdentifier数组中。SetRenderTarget的一个变体就是第一个参数可容纳多个颜色缓冲,第二个参数使用1个深度缓冲。

接着定义DrawDepthNormal方法:

    void DrawDepthNormal(bool useDynamicBatching, bool useGPUInstancing)
    {
        var sortingSettings = new SortingSettings(camera)
        {
            criteria = SortingCriteria.CommonOpaque
        };
        var drawingSettings = new DrawingSettings(depthNormalShaderTagId, sortingSettings)
        {
            enableDynamicBatching = useDynamicBatching,
            enableInstancing = useGPUInstancing,
        };
        var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);

        context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
    }

注意目前只支持不透明物体,若是有进行透明度剔除的物体可以额外进行一个pass来提取。透明物体的话,不太好进行深度的渲染,目前暂时不考虑。

Render中,根据一个布尔值控制渲染到深度法线和位置纹理:

        if (useDepthNormal)
        {
            SetupDepthNormal();
            DrawDepthNormal(useDynamicBatching, useGPUInstancing);
            buffer.ReleaseTemporaryRT(depthNormalTextureId);
            buffer.ReleaseTemporaryRT(depthBufferId);
            Submit();
        }

新建一个DrawDepthNormalPass.hlsl文件。定义结构体:

struct Attributes
{
    float3 positionOS : POSITION;
    float3 normalOS : NORMAL;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float4 normalDepth : TEXCOORD0;
    float3 positionVS : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct MRT
{
    float4 depthNomral : SV_TARGET0;
    float4 positionVS : SV_TARGET1;
};

注意MRT结构体,对应两个颜色缓冲渲染目标。

顶点着色器中计算观察空间的深度、法线和位置:

Varyings DrawDepthNormalPassVertex(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    output.positionCS = TransformObjectToHClip(input.positionOS);
    output.normalDepth.xyz = normalize(mul((float3x3)unity_MatrixITMV, input.normalOS));
    float3 positionWS = TransformObjectToWorld(input.positionOS);
    output.positionVS = TransformWorldToView(positionWS);
    output.normalDepth.w = -(output.positionVS.z * _ProjectionParams.w);
    return output;
}

unity_MatrixITMVinverse(transpose(model * view)),需要在UnityInput.hlsl中先定义好。

_ProjectionParams.w1/farPlane,这里进行这样的操作是变为线性深度。

片元着色器:

MRT DrawDepthNormalPassFragment(Varyings input)
{
    MRT output;
    UNITY_SETUP_INSTANCE_ID(input);
    float4 normalDepth = input.normalDepth;
    normalDepth.xyz = normalDepth.xyz * 0.5 + 0.5;
    output.depthNomral = normalDepth;
    float3 positionVS = input.positionVS;
    output.positionVS = float4(positionVS, 1.0);
    return output;
}

注意这里将法线映射一下,免得渲染到颜色纹理时数值丢失。

我们可以查看一下深度法线纹理和位置纹理:



2 计算SSAO

首先定义三个Pass,分别用于计算AO值,模糊AO以及应用AO。

计算AO值。

首先从深度法线纹理获取深度和法线,从位置纹理中获取观察空间位置:

float4 SSAOPassFragment(Varyings input) : SV_TARGET
{
    float4 depthNormal = SAMPLE_TEXTURE2D(_CameraDepthNormalTexture, sampler_linear_clamp, input.screenUV);
    float3 normal = normalize((depthNormal.xyz - 0.5) * 2);
    float depth = depthNormal.w;
    float3 positionVS = SAMPLE_TEXTURE2D(_CameraPositionVSTexture, sampler_linear_clamp, input.screenUV).xyz;

然后采样一个随机噪声纹理,它用于随机旋转采样核心:

    float3 random = SAMPLE_TEXTURE2D(_SSAONoiseTex, sampler_linear_clamp, input.screenUV * _SSAONoiseScale).rgb;

纹理的生成在PostFXStack中,我定义了一个SetupSSAO方法:

    public void SetupSSAO()
    {
        SSAOSettings ssao = settings.SSAO;
        ...
        Vector3[] noises = new Vector3[16];
        for (int i = 0; i < 16; i++)
        {
            float random = Random.Range(0.0f, 1.0f);
            Vector3 noise = new Vector3(random * 2.0f - 1.0f, random * 2.0f - 1.0f, 0.0f);
            noises[i] = noise;
        }
        Texture2D noiseTex = new Texture2D(4, 4, TextureFormat.RGB24, false, true);
        noiseTex.filterMode = FilterMode.Point;
        noiseTex.wrapMode = TextureWrapMode.Repeat;
        noiseTex.SetPixelData(noises, 0, 0);
        noiseTex.Apply();
        buffer.SetGlobalTexture(SSAONoiseTexId, noiseTex);

        Vector2 noiseScale = new Vector2(bufferSize.x / 4.0f, bufferSize.y / 4.0f);
        buffer.SetGlobalVector(SSAONoiseScaleId, noiseScale);

        firstInit = false;
    }

纹理的大小为4*4,也就是16个像素,因此定义了一个Vector3数组,大小为16。每个像素的值填入一个随机数。

声明一个Texture2D对象,格式为RGB24,用于存储噪声数组。纹理的滤波模式设为Point即可,包裹模式设为Repeat,这样噪声纹理就可以平铺在屏幕上。使用SetPixelData设置存储的数据,然后Apply应用操作。我们还需要传入噪声的UV缩放值,帮助平铺噪声纹理。注意该方法调用一次即可,我们可以使用布尔值firstInit控制。

SetupSSAO中同样生成了采样核心。采样核心是一个法向半球,内含许多采样点,这里设置为至多64个。采样核心会根据周边的深度值确定采样点是否被遮蔽,以此来确定遮蔽值。

采样核心定义在切线空间中:

    public void SetupSSAO()
    {
        SSAOSettings ssao = settings.SSAO;
        Vector4[] kernels = new Vector4[ssao.kernelSize];
        for (int i = 0; i < ssao.kernelSize; i++)
        {
            float random = Random.Range(0.0f, 1.0f);
            Vector4 sample = new Vector4(random * 2.0f - 1.0f, random * 2.0f - 1.0f, random, 0.0f);
            sample = sample.normalized;
            sample *= Random.Range(0.0f, 1.0f);
            float scale = i / ssao.kernelSize;
            scale = Mathf.Lerp(0.1f, 1.0f, scale * scale);
            sample *= scale;
            kernels[i] = sample;
        }
        buffer.SetGlobalVectorArray(SSAOKernelsId, kernels);

用随机数填充采样点。scale值用于缩放采样点,我们使用一个加速插值让采样点更靠近采样核心。

我们使用随机噪声来构建旋转TBN矩阵:

    float3 random = SAMPLE_TEXTURE2D(_SSAONoiseTex, sampler_linear_clamp, input.screenUV * _SSAONoiseScale).rgb;
    float3 tangent = normalize(random - normal * dot(random, normal));
    float3 bitangent = cross(normal, tangent);
    float3x3 TBN = float3x3(tangent, bitangent, normal);

接着我们遍历一个采样半球:

    for (int i = 0; i < _SSAOKernelSize; i++)
    {
        float3 sample = mul(TBN, _SSAOKernels[i].xyz);
        sample = positionVS + sample * _SSAOKernelRadius;
        float4 offset = float4(sample, 1.0);
        offset = mul(glstate_matrix_projection, offset);
        offset.xyz /= offset.w;
        offset.xyz = offset.xyz * 0.5 + 0.5;
        float sampleDepth = -SAMPLE_TEXTURE2D(_CameraDepthNormalTexture, sampler_linear_clamp, offset.xy).w;
        float rangeCheck = smoothstep(0.0, 1.0, _SSAOKernelRadius / abs(depth - sampleDepth));
        occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0) * rangeCheck;
    }

对于每个采样点,应用旋转矩阵,将其变换到法线所在的观察空间。然后将采样点乘一个半径来调整并加上观察空间位置。

接着我们将采样点变换到裁剪空间,注意自行进行透视除法,并将坐标映射到0-1,毕竟要用来采样深度纹理。offset的xy用于采样当前摄像机观察到的最近的片段的深度。

然后进行范围检查,保证采样深度值在采样半径内。接着如果采样深度值大于所存储的采样点中的深度值,也就是被遮住了,那么就贡献遮蔽值,注意乘上范围检查值以保证边缘不会出现遮蔽不当的问题。

最后,除以采样数,用1减去,遮蔽值越大,片段越黑。我们可以使用一个幂来控制强度。

    occlusion = max(0.01, (1.0 - (occlusion / _SSAOKernelSize)));
    occlusion = pow(occlusion, _SSAOStrength);

AO图:


接着模糊一下AO图,淡化噪声的影响:

float4 SSAOBlurPassFragment(Varyings input) : SV_TARGET
{
    float2 texelSize = GetSourceTexelSize().xy;
    float result = 0.0;
    for (int x = -2; x < 2; x++)
    {
        for (int y = -2; y < 2; y++)
        {
            float2 offset = float2(float(x), float(y)) * texelSize;
            result += GetSource(input.screenUV).r;
        }
    }
    return float4(result / (4.0 * 4.0), 0.0, 0.0, 0.0);
}

最后的pass合并:

float4 SSAOCombinePassFragment(Varyings input) : SV_TARGET
{
    float ao = GetSource(input.screenUV).r;
    float3 source = GetSource2(input.screenUV).rgb;
    float brightness = Max3(source.r, source.g, source.b);
    float finalAO = (brightness - 0.6) ? 1 : ao;
    source *= finalAO;
    return float4(source, 1.0);
}

注意,由于没有进行延迟渲染,我们无法直接用AO值去修改环境光值,因此我只好简单地根据阈值叠加,防止灰度叠加在场景中的发光区域上。

shader的定义很简单,在前面加上那三个pass就可以了。

PostFXStack中,SSAO的渲染定义在DoSSAO中:

    bool DoSSAO(int sourceId)
    {
        if(editorNoAO)
        {
            return false;
        }
        SSAOSettings ssao = settings.SSAO;
        int width, height;
        width = bufferSize.x / 2;
        height = bufferSize.y / 2;
        buffer.SetGlobalFloat(SSAOKernelRadiusId, ssao.kernelRadius);
        buffer.SetGlobalFloat(SSAOStrengthId, ssao.strength);
        buffer.SetGlobalInt(SSAOKernelSizeId, ssao.kernelSize);
        buffer.GetTemporaryRT(SSAOId, width, height, 0, FilterMode.Bilinear, RenderTextureFormat.Default);
        Draw(sourceId, SSAOId, Pass.SSAO);
        buffer.GetTemporaryRT(SSAOBlurId, width, height, 0, FilterMode.Bilinear, RenderTextureFormat.Default);
        Draw(SSAOId, SSAOBlurId, Pass.SSAOBlur);
        buffer.ReleaseTemporaryRT(SSAOId);
        buffer.SetGlobalTexture(fxSource2Id, sourceId);
        buffer.GetTemporaryRT(SSAOResultId, width, height, 0, FilterMode.Bilinear, RenderTextureFormat.Default);
        Draw(SSAOBlurId, SSAOResultId, Pass.SSAOCombine);
        buffer.ReleaseTemporaryRT(SSAOBlurId);
        return true;
    }

主要是三个Draw函数的调用,对应三个Pass。

PostFXSettings中添加了SSAO可调节的几个属性,采样核心数量,半径,以及强度:

    [System.Serializable]
    public struct SSAOSettings
    {
        [Range(16f, 64f)]
        public int kernelSize;

        [Range(0.1f, 2.0f)]
        public float kernelRadius;

        [Range(0.5f, 5.0f)]
        public float strength;
    }

    [SerializeField]
    SSAOSettings ScreenSpaceAmbientOcclusion = new SSAOSettings
    {
        kernelSize = 64,
        kernelRadius = 1.0f,
        strength = 1.0f
    };

目前的SSAO效果不是很理想,毕竟那张AO图目前我只是单纯地拿来修改一下场景的灰度。有无AO的对比(未开启发光):




可以看到这么做的话就直接亮了。效果也不是不行,只不过需要好好去调整一下。加上发光的最终效果:



最终效果就不是特别明显了。

项目地址:https://github.com/Dragon-Baby/CustomRP

推荐阅读更多精彩内容