Unity自定义SRP(十五):SSAO
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity自定义SRP(十五):SSAO相关的知识,希望对你有一定的参考价值。
参考技术A SSAO的计算需要摄像机空间的深度法线和位置信息,因此我们需要提前将场景的相应属性渲染到纹理中。CameraRenderer 中声明两个纹理:
同时声明一个shadertag,用于自定义pass:
定义 SetupDepthNormal 方法:
声明两个颜色渲染目标,并声明一个深度渲染目标。注意,滤波模式为 Point 就行了,并且不需要用MSAA。
将渲染目标存在一个 RenderTargetIdentifier 数组中。 SetRenderTarget 的一个变体就是第一个参数可容纳多个颜色缓冲,第二个参数使用1个深度缓冲。
接着定义 DrawDepthNormal 方法:
注意目前只支持不透明物体,若是有进行透明度剔除的物体可以额外进行一个pass来提取。透明物体的话,不太好进行深度的渲染,目前暂时不考虑。
在 Render 中,根据一个布尔值控制渲染到深度法线和位置纹理:
新建一个 DrawDepthNormalPass.hlsl 文件。定义结构体:
注意MRT结构体,对应两个颜色缓冲渲染目标。
顶点着色器中计算观察空间的深度、法线和位置:
unity_MatrixITMV 即 inverse(transpose(model * view)) ,需要在 UnityInput.hlsl 中先定义好。
_ProjectionParams.w 即 1/farPlane ,这里进行这样的操作是变为线性深度。
片元着色器:
注意这里将法线映射一下,免得渲染到颜色纹理时数值丢失。
我们可以查看一下深度法线纹理和位置纹理:
首先定义三个Pass,分别用于计算AO值,模糊AO以及应用AO。
计算AO值。
首先从深度法线纹理获取深度和法线,从位置纹理中获取观察空间位置:
然后采样一个随机噪声纹理,它用于随机旋转采样核心:
纹理的生成在 PostFXStack 中,我定义了一个 SetupSSAO 方法:
纹理的大小为 4*4 ,也就是16个像素,因此定义了一个Vector3数组,大小为16。每个像素的值填入一个随机数。
声明一个Texture2D对象,格式为 RGB24 ,用于存储噪声数组。纹理的滤波模式设为 Point 即可,包裹模式设为 Repeat ,这样噪声纹理就可以平铺在屏幕上。使用 SetPixelData 设置存储的数据,然后 Apply 应用操作。我们还需要传入噪声的UV缩放值,帮助平铺噪声纹理。注意该方法调用一次即可,我们可以使用布尔值 firstInit 控制。
在 SetupSSAO 中同样生成了采样核心。采样核心是一个法向半球,内含许多采样点,这里设置为至多64个。采样核心会根据周边的深度值确定采样点是否被遮蔽,以此来确定遮蔽值。
采样核心定义在切线空间中:
用随机数填充采样点。scale值用于缩放采样点,我们使用一个加速插值让采样点更靠近采样核心。
我们使用随机噪声来构建旋转TBN矩阵:
接着我们遍历一个采样半球:
对于每个采样点,应用旋转矩阵,将其变换到法线所在的观察空间。然后将采样点乘一个半径来调整并加上观察空间位置。
接着我们将采样点变换到裁剪空间,注意自行进行透视除法,并将坐标映射到0-1,毕竟要用来采样深度纹理。 offset 的xy用于采样当前摄像机观察到的最近的片段的深度。
然后进行范围检查,保证采样深度值在采样半径内。接着如果采样深度值大于所存储的采样点中的深度值,也就是被遮住了,那么就贡献遮蔽值,注意乘上范围检查值以保证边缘不会出现遮蔽不当的问题。
最后,除以采样数,用1减去,遮蔽值越大,片段越黑。我们可以使用一个幂来控制强度。
AO图:
接着模糊一下AO图,淡化噪声的影响:
最后的pass合并:
注意,由于没有进行延迟渲染,我们无法直接用AO值去修改环境光值,因此我只好简单地根据阈值叠加,防止灰度叠加在场景中的发光区域上。
shader的定义很简单,在前面加上那三个pass就可以了。
PostFXStack 中,SSAO的渲染定义在 DoSSAO 中:
主要是三个 Draw 函数的调用,对应三个Pass。
PostFXSettings 中添加了SSAO可调节的几个属性,采样核心数量,半径,以及强度:
目前的SSAO效果不是很理想,毕竟那张AO图目前我只是单纯地拿来修改一下场景的灰度。有无AO的对比(未开启发光):
可以看到这么做的话就直接亮了。效果也不是不行,只不过需要好好去调整一下。加上发光的最终效果:
项目地址: https://github.com/Dragon-Baby/CustomRP
OpenGL核心技术之SSAO技术讲解
笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家,特邀编辑,畅销书作者,国家专利发明人;已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术详解》电子工业出版社等。
CSDN视频网址:http://edu.csdn.net/lecturer/144
接着OpenGL核心技术之SSAO技术讲解(一)继续给读者分析SSAO技术,我们需要沿着表面法线方向生成大量的样本。就像我们在这个教程的开始介绍的那样,我们想要生成形成半球形的样本。由于对每个表面法线方向生成采样核心非常困难,也不合实际,我们将在切线空间(Tangent Space)内生成采样核心,法向量将指向正z方向。
假设我们有一个单位半球,我们可以获得一个拥有最大64样本值的采样核心:
std::uniform_real_distribution<GLfloat> randomFloats(0.0, 1.0); // 随机浮点数,范围0.0 - 1.0
std::default_random_engine generator;
std::vector<glm::vec3> ssaoKernel;
for (GLuint i = 0; i < 64; ++i)
{
glm::vec3 sample(
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator)
);
sample = glm::normalize(sample);
sample *= randomFloats(generator);
GLfloat scale = GLfloat(i) / 64.0;
ssaoKernel.push_back(sample);
}
我们在切线空间中以-1.0到1.0为范围变换x和y方向,并以0.0和1.0为范围变换样本的z方向(如果以-1.0到1.0为范围,取样核心就变成球型了)。由于采样核心将会沿着表面法线对齐,所得的样本矢量将会在半球里。
目前,所有的样本都是平均分布在采样核心里的,但是我们更愿意将更多的注意放在靠近真正片段的遮蔽上,也就是将核心样本靠近原点分布。我们可以用一个加速插值函数实现它:
...[接上函数]
scale = lerp(0.1f, 1.0f, scale * scale);
sample *= scale;
ssaoKernel.push_back(sample);
}
lerp
被定义为:
GLfloat lerp(GLfloat a, GLfloat b, GLfloat f)
{
return a + f * (b - a);
}
这就给了我们一个大部分样本靠近原点的核心分布。
每个核心样本将会被用来偏移观察空间片段位置从而采样周围的几何体。我们在教程开始的时候看到,如果没有变化采样核心,我们将需要大量的样本来获得真实的结果。通过引入一个随机的转动到采样核心中,我们可以很大程度上减少这一数量。
通过引入一些随机性到采样核心上,我们可以大大减少获得不错结果所需的样本数量。我们可以对场景中每一个片段创建一个随机旋转向量,但这会很快将内存耗尽。所以,更好的方法是创建一个小的随机旋转向量纹理平铺在屏幕上。
我们创建一个4x4朝向切线空间平面法线的随机旋转向量数组:
std::vector<glm::vec3> ssaoNoise;
for (GLuint i = 0; i < 16; i++)
{
glm::vec3 noise(
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
0.0f);
ssaoNoise.push_back(noise);
}
由于采样核心实验者正z方向在切线空间内旋转,我们设定z分量为0.0,从而围绕z轴旋转。
我们接下来创建一个包含随机旋转向量的4x4纹理;记得设定它的封装方法为GL_REPEAT
,从而保证它合适地平铺在屏幕上。
GLuint noiseTexture;
glGenTextures(1, &noiseTexture);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, 4, 4, 0, GL_RGB, GL_FLOAT, &ssaoNoise[0]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
现在我们有了所有的相关输入数据,接下来我们需要实现SSAO。
SSAO着色器在2D的铺屏四边形上运行,它对于每一个生成的片段计算遮蔽值(为了在最终的光照着色器中使用)。由于我们需要存储SSAO阶段的结果,我们还需要在创建一个帧缓冲对象:
GLuint ssaoFBO;
glGenFramebuffers(1, &ssaoFBO);
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
GLuint ssaoColorBuffer;
glGenTextures(1, &ssaoColorBuffer);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBuffer, 0);
由于环境遮蔽的结果是一个灰度值,我们将只需要纹理的红色分量,所以我们将颜色缓冲的内部格式设置为GL_RED
。
渲染SSAO完整的过程会像这样:
// 几何处理阶段: 渲染到G缓冲中
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
[...]
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 使用G缓冲渲染SSAO纹理
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
glClear(GL_COLOR_BUFFER_BIT);
shaderSSAO.Use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPositionDepth);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
SendKernelSamplesToShader();
glUniformMatrix4fv(projLocation, 1, GL_FALSE, glm::value_ptr(projection));
RenderQuad();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 光照处理阶段: 渲染场景光照
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shaderLightingPass.Use();
[...]
glActiveTexture(GL_TEXTURE3);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
[...]
RenderQuad();
shaderSSAO
这个着色器将对应G缓冲纹理(包括线性深度),噪声纹理和法向半球核心样本作为输入参数:
#version 330 core
out float FragColor;
in vec2 TexCoords;
uniform sampler2D gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D texNoise;
uniform vec3 samples[64];
uniform mat4 projection;
// 屏幕的平铺噪声纹理会根据屏幕分辨率除以噪声大小的值来决定
const vec2 noiseScale = vec2(800.0/4.0, 600.0/4.0); // 屏幕 = 800x600
void main()
{
[...]
}
注意我们这里有一个
noiseScale
的变量。我们想要将噪声纹理平铺(Tile)在屏幕上,但是由于
TexCoords
的取值在0.0和1.0之间,
texNoise
纹理将不会平铺。所以我们将通过屏幕分辨率除以噪声纹理大小的方式计算
TexCoords
的缩放大小,并在之后提取相关输入向量的时候使用。
vec3 fragPos = texture(gPositionDepth, TexCoords).xyz;
vec3 normal = texture(gNormal, TexCoords).rgb;
vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;
由于我们将
texNoise
的平铺参数设置为
GL_REPEAT
,随机的值将会在全屏不断重复。加上
fragPog
和
normal
向量,我们就有足够的数据来创建一个TBN矩阵,将向量从切线空间变换到观察空间。
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
通过使用一个叫做Gramm-Schmidt处理(Gramm-Schmidt Process)的过程,我们创建了一个正交基(Orthogonal Basis),每一次它都会根据randomVec
的值稍微倾斜。注意因为我们使用了一个随机向量来构造切线向量,我们没必要有一个恰好沿着几何体表面的TBN矩阵,也就是不需要逐顶点切线(和双切)向量。
接下来我们对每个核心样本进行迭代,将样本从切线空间变换到观察空间,将它们加到当前像素位置上,并将片段位置深度与储存在原始深度缓冲中的样本深度进行比较。我们来一步步讨论它:
float occlusion = 0.0;
for(int i = 0; i < kernelSize; ++i)
{
// 获取样本位置
vec3 sample = TBN * samples[i]; // 切线->观察空间
sample = fragPos + sample * radius;
[...]
}
这里的kernelSize
和radius
变量都可以用来调整效果;在这里我们分别保持他们的默认值为64
和1.0
。对于每一次迭代我们首先变换各自样本到观察空间。之后我们会加观察空间核心偏移样本到观察空间片段位置上;最后再用radius
乘上偏移样本来增加(或减少)SSAO的有效取样半径。
接下来我们变换sample
到屏幕空间,从而我们可以就像正在直接渲染它的位置到屏幕上一样取样sample
的(线性)深度值。由于这个向量目前在观察空间,我们将首先使用projection
矩阵uniform变换它到裁剪空间。
vec4 offset = vec4(sample, 1.0);
offset = projection * offset; // 观察->裁剪空间
offset.xyz /= offset.w; // 透视划分
offset.xyz = offset.xyz * 0.5 + 0.5; // 变换到0.0 - 1.0的值域
在变量被变换到裁剪空间之后,我们用
xyz
分量除以
w
分量进行透视划分。结果所得的标准化设备坐标之后变换到
[0.0, 1.0]
范围以便我们使用它们去取样深度纹理:
float sampleDepth = -texture(gPositionDepth, offset.xy).w;
我们使用
offset
向量的
x
和
y
分量采样线性深度纹理从而获取样本位置从观察者视角的深度值(第一个不被遮蔽的可见片段)。我们接下来检查样本的当前深度值是否大于存储的深度值,如果是的,添加到最终的贡献因子上。
occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0);
这并没有完全结束,因为仍然还有一个小问题需要考虑。当检测一个靠近表面边缘的片段时,它将会考虑测试表面之下的表面的深度值;这些值将会(不正确地)音响遮蔽因子。我们可以通过引入一个范围检测从而解决这个问题,正如下图所示:
我们引入一个范围测试从而保证我们只当被测深度值在取样半径内时影响遮蔽因子。将代码最后一行换成:
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0) * rangeCheck;
这里我们使用了GLSL的
smoothstep
函数,它非常光滑地在第一和第二个参数范围内插值了第三个参数。如果深度差因此最终取值在
radius
之间,它们的值将会光滑地根据下面这个曲线插值在0.0和1.0之间:
如果我们使用一个在深度值在radius
之外就突然移除遮蔽贡献的硬界限范围检测(Hard Cut-off Range Check),我们将会在范围检测应用的地方看见一个明显的(很难看的)边缘。
最后一步,我们需要将遮蔽贡献根据核心的大小标准化,并输出结果。注意我们用1.0减去了遮蔽因子,以便直接使用遮蔽因子去缩放环境光照分量。
}
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = occlusion;
下面这幅图展示了我们最喜欢的纳米装模型正在打盹的场景,环境遮蔽着色器产生了以下的纹理:
可见,环境遮蔽产生了非常强烈的深度感。仅仅通过环境遮蔽纹理我们就已经能清晰地看见模型一定躺在地板上而不是浮在空中。
现在的效果仍然看起来不是很完美,由于重复的噪声纹理再图中清晰可见。为了创建一个光滑的环境遮蔽结果,我们需要模糊环境遮蔽纹理。
以上是关于Unity自定义SRP(十五):SSAO的主要内容,如果未能解决你的问题,请参考以下文章