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_MatrixITMV
即inverse(transpose(model * view))
,需要在UnityInput.hlsl
中先定义好。
_ProjectionParams.w
即1/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