前军教程网

中小站长与DIV+CSS网页布局开发技术人员的首选CSS学习平台

环境光遮罩SSAO

1.概述

环境光遮蔽(Ambient Occlusion,AO)是一种用于渲染场景中仅以环境光照明时所产生的软阴影。AO描述表面上每个点"可接触光线"的程度。在某些表面上并不接收直接光照,而是接收间接光照。在现实中,光线会以任意方向散射,它的强度是会一直改变的,所以间接被照到的那部分场景也应该有变化的强度,而不是一成不变的环境光。AO的原理就是将某些只接收环境光照的表面变暗。

2.对象空间环境光遮蔽(Object-Space Ambient Occlusion,OSAO)

要计算当前表面上的点所接收的光量,我们可以对当前顶点以Ray Marching的形式计算当前顶点的环境信息,如下图所示

计算投射出去的光线在一定范围内判断是否有击中表面来确定当前的AO分量。但是这种做法太耗时,在实时渲染中几乎不可能实现。

3.屏幕空间环境光遮蔽(Screen-Space Ambitne Occlusion,SSAO)

在2007年,Crytek公司在孤岛危机上推出了屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion,SSAO)。该技术使用屏幕空间下的深度值来计算每个像素的遮蔽因子Ka,并使用该遮蔽因子来确定当前像素的环境光分量的强度。

SSAO的原理是:在屏幕空间下,根据当前片源的的周边片元的深度值来计算上述提到的遮蔽因子Ka。遮蔽因子通过一个球形核心采集周边深度信息,并逐个与当前片元深度值对比而得到。而遮蔽因子=高于当前深度值的样本数/总样本数。如下图所示。

上图中给出一个表面的切片模式显示。图中每个单元共10个样本。其中黄色点表面当前的片元位置,红色样本点表示该采样位置深度值高于当前片元的深度值,而绿色样本点表示采样位置深度值小于当前片元的深度值。以上图为例子我们可以轻易得出这三个点的遮蔽因子,从左到右分别是:4/10=0.4,6/10=0.6,9/10=0.9。

上述图中每个采样点都是用一个法向球体来进行深度值的采样的,这是孤岛危机里面用的方法,因此整体来看会有大约一半的的样本点是在表面的里面,因此整体的画面风格会有点灰蒙蒙的感觉,如下图。

因此,我们使用一种改进的办法——使用法向半球。该方法不使用一个完整的球体,而是使用一个半球体。这样就不会发生一半的样本都在表面的下面了。法向半球如下图:

波纹(Banding)

因此,可以看到SSAO的效果与样本数量呈正相关关系。如果样本数量太少,则采样得到的误差就挺大的,这时由于误差会导致出现一种叫波纹(Banding)的伪影。而样本数量太多则会影响性能。波纹现象如下图所示。

为什么会出现波纹呢?这是因为在精度不足的情况下会采样到深度图的同一个UV坐标,这在表面越远或者表面法线与镜头前向向量夹角越大的时候越明显。离得越远越容易被同一个UV坐标覆盖,同理面法线与镜头前向向量夹角越大则,表面在镜头前的点的相邻像素覆盖得越少。具体如图:

我在移植SSAO到web的时候被这个问题绊了好久。这个与ShadowMapping出现的Banding原理,与锯齿原理也是比较一致的,采样率不足。根据尼奎斯特定理,采样数量得到的最终效果不会超过采样数的一半。所以要想得到一个比较完美的效果,需要比较大的样本数。

随机性旋转(Random Rotation)

如果每个片元采样1000次,则一个1920*1080的屏幕渲染一帧SSAO总共需要1000*1920*1080=2,073,600,000次采样。很明显这样做是不现实的。我们可以增大每个样本的随机性,从而降低每个片源之间相邻深度的一致性。这可以通过引入随机性向量让采样核心稍微旋转一下。具体做法可以引入一个由随机性向量组成的一个噪声图来实现,每个样本从噪声图采样获得一个随机旋转向量。但是由于引入了噪声图,所以最终结果会有噪点。但是这很简单,我们引入一个简单的均值模糊可以解决这个问题。


下图来自于john chapman的博客,很好地诠释了波纹,噪点以及加上模糊后的效果。

延迟渲染实现

SSAO要求渲染引擎内的逻辑光线先渲染当前场景,获取相机一帧的场景深度,生成一张屏幕空间下的AO图,之后在进行光照计算的时候采样AO贴图的值,并将该值与灯光的环境光分量相乘。而延迟渲染也是要求先渲染场景,再做灯光计算,因此SSAO天然十分适合延迟渲染。下图展示了延迟渲染的步骤。

延迟渲染的SSAO要求4个Render Pass。分别是计算场景几何,计算SSAO,模糊SSAO,计算光照。首先需要创建一个GBuffer用于存储几何信息,GBuffer使用了MRT技术,MRT在OpenGL 2.0和DirectX9中提出。现在大多数图形硬件都支持了,但是要注意的是Playcanvas的RenderTarget做了一层封装,一个RenderTarget只支持一个ColorBuffer和一个DepthBuffer。因此没办法使用延迟着色SSAO,关于这一点,后面会介绍如何在PlayCanvas实现SSAO。

生成GBuffer

在本例中,GBuffer存储了当前相机下场景物体的位置,法线,漫反射,高光反射。在上述代码中,分别生成了世界空间位置和法线缓冲,观察空间位置和法线缓冲,漫反射和高光反射缓冲。其中观察空间位置和法线用户计算SSAO,世界空间位置和法线用于计算光照。

为什么要特地额外生成观察空间位置和法线呢?直接使用世界空间的位置和法线不好吗?原因很简单,需要以相机位置作为坐标原点。

首先,生成一个GBuffer。需要注意一点就是法线和位置的帧缓冲纹理附件的内部格式必须是指定RGB的浮点形式,因为要保证一定的精度,而类型也必须是GL_FLOAT,因为纹理里面存的是向量而不是颜色。

//生成gBuffer
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
//生成gbuffer世界空间位置
glGenTextures(1, &gPositionWS);
glBindTexture(GL_TEXTURE_2D, gPositionWS);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, m_AppWindow->GetWindowSize().x, m_AppWindow->GetWindowSize().y, 0, GL_RGB, GL_FLOAT, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, gPositionWS, 0);
//生成gBuffer相机空间位置
glGenTextures(1, &gPositionVS);
glBindTexture(GL_TEXTURE_2D, gPositionVS);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, m_AppWindow->GetWindowSize().x, m_AppWindow->GetWindowSize().y, 0, GL_RGB, GL_FLOAT, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, gPositionVS, 0);
//生成gbuffer世界空间法线
glGenTextures(1, &gNormalWS);
glBindTexture(GL_TEXTURE_2D, gNormalWS);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, m_AppWindow->GetWindowSize().x, m_AppWindow->GetWindowSize().y, 0, GL_RGB, GL_FLOAT, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, gNormalWS, 0);
//生成gbuffer观察空间法线
glGenTextures(1, &gNormalVS);
glBindTexture(GL_TEXTURE_2D, gNormalVS);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, m_AppWindow->GetWindowSize().x, m_AppWindow->GetWindowSize().y, 0, GL_RGB, GL_FLOAT, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT3, gNormalVS, 0);
//生成漫反射+镜面反射
glGenTextures(1, &gAlbedoSpec);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, m_AppWindow->GetWindowSize().x, m_AppWindow->GetWindowSize().y, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT4, gAlbedoSpec, 0);
std::vector<uint32_t> attachments = { GL_COLOR_ATTACHMENT0,GL_COLOR_ATTACHMENT1,GL_COLOR_ATTACHMENT2,GL_COLOR_ATTACHMENT3,GL_COLOR_ATTACHMENT4 };
glDrawBuffers(attachments.size(), attachments.data());
glGenRenderbuffers(1, &gBufferRBO);
glBindRenderbuffer(GL_RENDERBUFFER, gBufferRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, m_AppWindow->GetWindowSize().x, m_AppWindow->GetWindowSize().y);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, gBufferRBO);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

计算屏幕空间场景信息,存储到GBuffer中

几何阶段是生成当前相机下场景物体的位置,法线,漫反射,高光反射,并将这些信息存储于不同的帧缓冲中。在上述代码中,分别生成了世界空间位置和法线缓冲,观察空间位置和法线缓冲,漫反射和高光反射缓冲。其中观察空间位置和法线用户计算SSAO,世界空间位置和法线用于计算光照。

几何阶段片元着色器就是将场景网格的表面信息存到缓冲,其中法线部分使用顶点着色器传进来的TBN矩阵来进行空间的转换。最后输出到GBuffer中。

//Pass 1 Geometry 
//Geometry.vert
#version 450 core
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec2 inTexcoord;
layout(location = 2) in vec3 inNormal;
layout(location = 3) in vec3 inTangent;
layout(location = 4) in vec3 inBitangent;
layout(location = 5) in vec4 inColor;
out vec3 fragPosWS;
out vec3 fragPosVS;
out vec2 fragTexcoord;
out mat3 TBNWS;
out mat3 TBNVS;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
void main()
{
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(inPosition, 1.0);
    fragPosWS = (modelMatrix * vec4(inPosition, 1.0)).xyz;
    fragPosVS = (viewMatrix * modelMatrix * vec4(inPosition, 1.0)).xyz;
    fragTexcoord = inTexcoord;
    vec3 fragNormalWS = normalize(mat3(modelMatrix) * inNormal);
    vec3 fragTangentWS = normalize(mat3(modelMatrix) * inTangent);
    vec3 fragBitangentWS = normalize(mat3(modelMatrix) * inBitangent);
    TBNWS = mat3(fragTangentWS, fragBitangentWS, fragNormalWS);
 
    vec3 fragNormalVS = normalize(mat3(viewMatrix*modelMatrix) * inNormal);
    vec3 fragTangentVS = normalize(mat3(viewMatrix * modelMatrix) * inTangent);
    vec3 fragBitangentVS = normalize(mat3(viewMatrix * modelMatrix) * inBitangent);
    TBNVS = mat3(fragTangentVS, fragBitangentVS, fragNormalVS);
}


//Geometry.frag
#version 450 core
layout(location = 0) out vec3 gPositionWS;
layout(location = 1) out vec3 gPositionVS;
layout(location = 2) out vec3 gNormalWS;
layout(location = 3) out vec3 gNormalVS;
layout(location = 4) out vec4 gAlbedoSpec;
in vec3 fragPosWS;
in vec3 fragPosVS;
in vec2 fragTexcoord;
in mat3 TBNWS;
in mat3 TBNVS;
struct Material
{
    sampler2D diffuse;
    sampler2D specular;
    sampler2D normal;
    float shiness;
};
uniform Material material;
void main()
{
    gPositionWS = fragPosWS;
    gPositionVS = fragPosVS;
    gNormalWS = TBNWS *(texture(material.normal, fragTexcoord).rgb * 2.0 - 1.0);
    gNormalVS = TBNVS *(texture(material.normal, fragTexcoord).rgb * 2.0 - 1.0);
    gAlbedoSpec.rgb = texture(material.diffuse, fragTexcoord).rgb;
    gAlbedoSpec.a = texture(material.specular,fragTexcoord).r;
}

由于后续的光照是在世界空间下进行的所以需要额外的世界空间位置和法线,如果光照是在观察空间下进行的则无需这两个分量。

计算SSAO纹理

这一阶段生成一张SSAO的纹理图并供之后的模糊和计算光照之用,这里为了方便,将一些可调节的参数硬编码到代码中。

//Pass2,SSAO
//SSAO.vert
#version 450 core
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec2 inTexcoord;
layout(location = 2) in vec3 inNormal;
layout(location = 3) in vec3 inTangent;
layout(location = 4) in vec3 inBitangent;
layout(location = 5) in vec4 inColor;
out vec2 fragTexcoord;
void main()
{
    gl_Position = vec4(inPosition, 1.0);
    fragTexcoord = inTexcoord;
}

//SSAO.frag
#version 450 core
layout(location = 0) out vec4 finalColor;
in vec2 fragTexcoord;
layout (binding = 0) uniform sampler2D gPositionVS;
layout (binding = 1) uniform sampler2D gNormalVS;
layout (binding = 2) uniform sampler2D texNoise;
uniform vec3 samples[64];
uniform mat4 projectionMatrix;
uniform vec2 noiseScale;
const int kernelSize = 64;
const float radius = 0.5;
const float bias = 0.025;
void main()
{
    vec3 fragPosVS = texture(gPositionVS, fragTexcoord).xyz;
    vec3 normalVS = normalize(texture(gNormalVS, fragTexcoord).rgb);
    vec3 randomVec =normalize(texture(texNoise, fragTexcoord * noiseScale).xyz);
    vec3 tangent = normalize(randomVec - normalVS * dot(randomVec, normalVS));
    vec3 bitangent = cross(normalVS, tangent);
    mat3 TBN = mat3(tangent, bitangent, normalVS);
    float occlusion = 0.0;
    for (int i = 0; i < kernelSize; ++i)
    {
        vec3 sp = TBN * samples[i];
        sp = fragPosVS + sp * radius;
        vec4 offset = vec4(sp, 1.0);
        offset = projectionMatrix * offset;
        offset.xyz /= offset.w;
        offset.xyz = offset.xyz * 0.5 + 0.5;
        float sampleDepth = texture(gPositionVS, offset.xy).z;
        float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPosVS.z - sampleDepth));
        occlusion += (sampleDepth >= sp.z+bias ? 1.0 : 0.0) * rangeCheck;
    }
    occlusion = 1.0 - (occlusion / kernelSize);
    finalColor = vec4(occlusion,occlusion,occlusion,1.0);
}

SSAO阶段的顶点着色器没啥好说的,就是基础后处理的顶点着色器,传进NDC空间和纹理坐标,并传出纹理坐标等待光栅化阶段对纹理坐标进行光栅化。这里由于方便都传进来所有的属性,要减少一致性缓存的话可以只传进NDC空间位置坐标,然后将位置坐标分量映射到[0,1]区间即可。如下。

#version 450 core
layout(location = 0) in vec3 inPosition;
out vec2 fragTexcoord;
void main()
{
    gl_Position = vec4(inPosition, 1.0);
    fragTexcoord = inPosition*0.5+0.5;
}

接下来是SSAO的核心算法。咱们逐段分析。

layout (binding = 0) uniform sampler2D gPositionVS;
layout (binding = 1) uniform sampler2D gNormalVS;
layout (binding = 2) uniform sampler2D texNoise;

gNormalVS和gPositionVS表示前一个Pass生成的全屏观察空间位置和法线。而texNoise则是前文提到的用于法向半球采样核随机旋转的噪声图。其生成算法如下:

std::uniform_real_distribution<float> randomFloats(0.0, 1.0);
std::default_random_engine generator;
std::vector<glm::vec3> ssaoNoise;
for (uint32_t i = 0; i < 16; ++i)
{
    glm::vec3 noise(randomFloats(generator) * 2.0f - 1.0f, randomFloats(generator) * 2.0f - 1.0f, 0.0f);
    ssaoNoise.emplace_back(noise);
}
glGenTextures(1, &ssaoNoiseTexture);
glBindTexture(GL_TEXTURE_2D, ssaoNoiseTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, 4, 4, 0, GL_RGB, GL_FLOAT, ssaoNoise.data());
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);

这里默认4x4大小即可,原理是对每一个像素取一个随机颜色,在片元着色器经归一化后就变成一个随机向量了。这里有三个需要注意的点:

    1. 纹理寻址方式一定得是GL_REPEAT。因为该纹理是要平铺在全屏幕的,因此要确保任意位置都能采样到。
    2. 纹理的类型是GL_FLOAT,因为纹理里面存储的是向量,与法线和位置的帧缓冲纹理附件同理。
    3. 因为我们的法线半球的核心是朝向z轴的,并且是沿着需要采样的顶点的切线来旋转法线的,因此噪声图的z分量为0。
uniform vec3 samples[64];

这是传进去的法向半球的样本相对采样点的位置。其生成方式如下:

std::uniform_real_distribution<float> randomFloats(0.0, 1.0);
    std::default_random_engine generator;
    for (uint32_t i = 0; i < 64; ++i)
    {
        glm::vec3 sample(randomFloats(generator) * 2.0 - 1.0, randomFloats(generator) * 2.0 - 1.0, randomFloats(generator));
        sample *= randomFloats(generator);
        float scale = float(i) / 64;
        scale = lerp(0.1f, 1.0f, scale * scale);
        sample *= scale;
        ssaoKernel.push_back(sample);
    }

可以看到每个样本的也仍然xy在[-1.0,1.0]区间,但是z轴在[0.0-1.0]区间。因为现在使用的是半球所以z轴使用[0.0-1.0]即可。

sample *= randomFloats(generator);
        float scale = float(i) / 64;
        scale = lerp(0.1f, 1.0f, scale * scale);
        sample *= scale;

由于让样本随机分布在半球内很容易会出现上述的波纹现象,或者在深度起伏比较大的表面上所得到的遮蔽因子并不太准确。因此我们更希望样本整体上更靠近采样点。因此将刚生成的样本再随机相对于采样点有一个线性缩放,并且越先生成的样本越靠近样本点。这样,大部分样本都在采样点周围了。如图。

uniform mat4 projectionMatrix;
uniform vec2 noiseScale;
const int kernelSize = 64;
const float radius = 0.5;
const float bias = 0.025;

projectionMatrix是用于将样本从观察空间转换到屏幕空间用的,而noiseScale则是用于缩放后处理网格的纹理坐标以平铺到屏幕上。kernelSize则是定义法向半球内的样本数。radius是法向半球的半径,用于控制法向半球可采样的区域以及可采样的深度区域范围。bias用于修正采样得到的AO的偏移量,降低采样不足导致的痤疮现象。

    vec3 fragPosVS = texture(gPositionVS, fragTexcoord).xyz;
    vec3 normalVS = normalize(texture(gNormalVS, fragTexcoord).rgb);
    vec3 randomVec =normalize(texture(texNoise, fragTexcoord * noiseScale).xyz);
    vec3 tangent = normalize(randomVec - normalVS * dot(randomVec, normalVS));
    vec3 bitangent = cross(normalVS, tangent);
    mat3 TBN = mat3(tangent, bitangent, normalVS);

前两行代码仅仅是采样前一个Pass得到的位置和法线。第三行用于获得随机旋转向量,之后生成一个用于将法线从切线空间转换到观察空间的TBN矩阵。TBN矩阵通过斯密特正交化来创建一个法线-切线-副切线的正交基,由于tangent中使用了随机旋转向量来稍微偏移因此,该TBN的正交基也不一定恰好正对采样点。

float occlusion = 0.0;
    for (int i = 0; i < kernelSize; ++i)
    {
        vec3 sp = TBN * samples[i];
        sp = fragPosVS + sp * radius;
        vec4 offset = vec4(sp, 1.0);
        offset = projectionMatrix * offset;
        offset.xyz /= offset.w;
        offset.xyz = offset.xyz * 0.5 + 0.5;
        float sampleDepth = texture(gPositionVS, offset.xy).z;
        float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPosVS.z - sampleDepth));
        occlusion += (sampleDepth >= sp.z+bias ? 1.0 : 0.0) * rangeCheck;
    }
    occlusion = 1.0 - (occlusion / kernelSize);
    finalColor = vec4(occlusion,occlusion,occlusion,1.0);

接下来对每个样本进行采样,先前说了法向半球是在切线空间的,因此里面的样本也是在切线空间的,因此要先将样本转换到观察空间,之后确定从当前片元到样本之间的向量。之后,将这个样本从观察空间转换到屏幕空间。接着采样这个样本在观察空间下的深度。

但是直接用采样得到的值作为遮蔽因子会有一个问题。就是在样本相当接近表面时取得的值可能会有误差而导致某些样本取的是表面以下的值。因此加一个范围查询以保证该样本仅在我们定义的范围内影响遮蔽因子。范围查询使用Learnopengl中的做法,根据radius / abs(fragPosVS.z - sampleDepth)确定表面片元的深度到样本的深度是否有大于我们定义的深度半径范围。

在采样循环的最后,我们根据表面片元的的深度和样本的深度和一定的偏移误差修正值之和作比较,如果片元深度值比样本深度值大则表示当前片元被样本位置所遮蔽。并根据范围查询确定遮蔽的量。

最后,将总遮蔽量除以样本数来进行归一化,确定遮蔽因子。最后一步1.0-遮蔽因子是因为最终输出环境光的比例。

SSAO模糊

SSAO模糊使用简单的均值模糊即可,取相邻的像素计算平均的颜色度。

//SSAO Blur
//SSAOBlur.vert
#version 450 core

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec2 inTexcoord;
layout(location = 2) in vec3 inNormal;
layout(location = 3) in vec3 inTangent;
layout(location = 4) in vec3 inBitangent;
layout(location = 5) in vec4 inColor;
out vec2 fragTexcoord;
void main()
{
    gl_Position = vec4(inPosition, 1.0);
    fragTexcoord = inTexcoord;
}

//SSAOBlur.frag
#version 450 core

in vec2 fragTexcoord;

layout(location = 0) out vec4 finalColor;
uniform sampler2D ssaoColorTexture;
uniform int kernelSize;
void main()
{
    vec2 texelSize = 1.0 / vec2(textureSize(ssaoColorTexture,0));
    float fragColor;
    for (int x = -kernelSize/2; x <= kernelSize/2; ++x)
        for (int y = - kernelSize/2; y <= kernelSize/2; ++y)
        {
            vec2 offset = vec2(x, y) * texelSize;
            fragColor += texture(ssaoColorTexture, fragTexcoord + offset).r;
        }
    int pow_kernelSize = kernelSize* kernelSize;
    finalColor =vec4( fragColor / pow_kernelSize,fragColor/ pow_kernelSize,fragColor/ pow_kernelSize,1.0);
}

参数kernelSize用于确定要取多少个相邻的像素,最终将相加得到的值除以核心数。

光照计算

光照计算这里使用简单的Blinn-Phong。灯光也仅以一个简单的ADS平行光来举例。

//Light Calc
//BlinnPhong.vert
#version 450 core

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec2 inTexcoord;
layout(location = 2) in vec3 inNormal;
layout(location = 3) in vec3 inTangent;
layout(location = 4) in vec3 inBitangent;
layout(location = 5) in vec4 inColor;
out vec2 fragTexcoord;
void main()
{
    gl_Position = vec4(inPosition, 1.0);
    fragTexcoord = inTexcoord;
}

//BlinnPhong.frag
#version 450 core
struct PositionLight
{
    vec4 ambient;
    vec4 diffuse;
    vec4 specular;
    vec3 position;
};
in vec2 fragTexcoord;
uniform PositionLight posLight;
uniform vec3 viewPosWS;
layout(binding = 0) uniform sampler2D gPositionWS;
layout(binding = 1) uniform sampler2D gNormal;
layout(binding = 2) uniform sampler2D gAlbedoSpec;
layout(binding = 3) uniform sampler2D ssaoTexture;
out vec4 finalColor;
void main()
{
    float ssao = texture(ssaoTexture, fragTexcoord).r;
    vec3 normalWS = texture(gNormal, fragTexcoord).rgb;
    vec3 positionWS = texture(gPositionWS, fragTexcoord).rgb;
    vec3 diff = texture(gAlbedoSpec, fragTexcoord).rgb;
    float spec = texture(gAlbedoSpec, fragTexcoord).a;
    vec4 ambient = posLight.ambient * vec4(diff, 1.0)* ssao;
    vec4 diffuse = posLight.diffuse * vec4(diff, 1.0) * (max(0.0, dot(normalWS, normalize(posLight.position - positionWS))) * 0.5 + 0.5);
    vec3 viewDir = viewPosWS - positionWS;
    vec3 h = normalize(viewDir + normalize(posLight.position - positionWS));
    vec4 specular = posLight.specular * spec * pow(max(0.0, dot(normalWS, h)), 8.0);
    finalColor = ambient + diffuse + specular;
}

光照计算过程跟普通的前向渲染区别不太大,唯一不同的是顶点是从纹理获取的而不是从顶点着色器取得的。而采样之前取得的SSAO值与ambient值相乘就可以获得环境光遮蔽。

Playcanvas实现

由于Playcanvas的RenderTarget中不支持MRT,只支持一张颜色纹理附件和一张。因此用延迟渲染方式行不通,同时也需要改引擎内部的渲染逻辑。因此只能从一张贴图中获取我们所需要的位置和法线信息。

这里的基本思路是从屏幕空间深度重建观察空间的位置和法线值。遵循的基本原理是屏幕空间深度值是顶点坐标的z值经过一系列变换得到的,所以理论上从屏幕空间深度值经过一系列的逆变换就可以得到观察空间的坐标。

空间逆变换

网格顶点到屏幕顶点的转换经过以下步骤:

对象空间->世界空间->观察空间->裁剪空间->NDC空间->屏幕空间。

由于SSAO要求观察空间,因此从观察空间开始进行推导。我们可以轻易地将屏幕空间坐标和观察空间坐标关联起来,最终可以得到屏幕空间深度值与观察空间深度值的一个关联公式:

float ReconstructDepthVSFromDepthSS(vec2 texcoord)
{
    return -projectionMatrix[3][2] / (2.0*texture(gDepthSS,texcoord).r-1.0 + projectionMatrix[2][2]);
}


重建观察空间位置

有了深度之后就可以很方便地重建位置了。

vec3 ReconstructPositionVSFromDepthVS(vec2 texcoord)
{
    float aspect=1.0/(projectionMatrix[1][1]);
    float tangentHalfFOV = 1.0/(aspect *projectionMatrix[0][0]);
    return vec3(((1.0 - texcoord.x) * 2.0 - 1.0) * tangentHalfFOV * aspect, ((1.0 - texcoord.y) * 2.0 - 1.0) * aspect, 1.0) * ReconstructDepthVSFromDepthSS(texcoord);
}

重建观察空间法线

重建法线的一个基本思路是将当前像素与相邻像素确定一个三角形,并求得该三角形所在平面的法线。而在三维中对两个不同线的向量进行叉积即可获得这两个向量所在平面的法线。从这个思路出发,我们取得当前屏幕空间位置值横竖各一个相邻像素的值来确定一个三角形。如图。

有了这个思路之后重建法线就很简单了,对向量p2p0,p1p0做叉积即可。

vec3 ReconstructNormalVSFromDepthVS(vec3 position)
{
    return normalize(cross(dFdx(position), dFdy(position)));
}

其中,在较高版本的OpenGL中可以使用内置的dFdx和dFdy来获取右边和上边像素的指定信息。而针对WebGL1.0等就要自己手写了。

vec3 ReconstructNormalVSFromPositionDepthVS(vec2 texcoord)
{
    vec2 texcoord0 = texcoord;
    vec2 texcoord1 = texcoord + vec2(1, 0);
    vec2 texcoord2 = texcoord + vec2(0, 1);
    vec3 position0 = ReconstructPositionVSFromDepthVS(texcoord0);
    vec3 position1 = ReconstructPositionVSFromDepthVS(texcoord1);
    vec3 position2 = ReconstructPositionVSFromDepthVS(texcoord2);
    return normalize(cross(position1 - position0, position2 - position0));
}

实现

Playcanvas中除了需要重建位置和法线之外其他的原理性东西与延迟渲染方法没什么太大的不一样。但是一些代码的具体细还是要注意下。

 var postEffectVert = [
            "attribute vec2 inPosition;",
            "varying vec2 vUv0;",
            "void main(void)",
            "{",
            "    gl_Position = vec4(inPosition, 0.0, 1.0);",
            "    vUv0 = inPosition*0.5+0.5;",
            "}"
        ].join("\n"); 
var ssaoFrag = [
            "precision " + graphicsDevice.precision + " float;",
            "precision " + graphicsDevice.precision + " sampler2D;",
            "#define NORMAL_ORIENTED_SAMPLE_COUNT " + this.normalOrientedSampleCount,
            "varying vec2 vUv0;",
            "uniform sampler2D gDepthSS;",
            "uniform sampler2D ssaoNoiseTexture;",
            "uniform mat4 matrix_projection;",
            "uniform vec3 samples[NORMAL_ORIENTED_SAMPLE_COUNT];",
            "uniform vec2 noiseScale;",
            "uniform float kernelRadius;",
            "uniform float kernelBias;",
            "float ReconstructDepthVSFromDepthSS(vec2 texcoord)",
            "{",
            "   return (-matrix_projection[3][2] / (2.0*texture2D(gDepthSS,texcoord).r-1.0 + matrix_projection[2][2]));",
            "}",
            "vec3 ReconstructPositionVSFromDepthVS(vec2 texcoord)",
            "{",
            "   float tangentHalfFOV=1.0/(matrix_projection[1][1]);",
            "   float aspect = 1.0/(tangentHalfFOV*matrix_projection[0][0]);",
            "   return vec3(((1.0 - texcoord.x) * 2.0 - 1.0) * tangentHalfFOV * aspect, ((1.0 - texcoord.y) * 2.0 - 1.0) * tangentHalfFOV, 1.0) * ReconstructDepthVSFromDepthSS(texcoord);",
            "}",
            "vec3 ReconstructNormalVSFromPositionDepthVS(vec3 posVS,vec2 texcoord)",
            "{",
            "   vec3 position1 = ReconstructPositionVSFromDepthVS(texcoord + vec2(1, 0));",
            "   vec3 position2 = ReconstructPositionVSFromDepthVS(texcoord + vec2(0, 1));",
            "   return normalize(cross(position1 - posVS, position2 - posVS));",
            "}",
            "vec4 packFloat(float depth)", 
            "{",
                "const vec4 bit_shift = vec4(256.0 * 256.0 * 256.0, 256.0 * 256.0, 256.0, 1.0);",
                "const vec4 bit_mask  = vec4(0.0, 1.0 / 256.0, 1.0 / 256.0, 1.0 / 256.0);",
                "vec4 res = mod(depth * bit_shift * vec4(255), vec4(256) ) / vec4(255);",
                "res -= res.xxyz * bit_mask;",
                "return res;",
            "}",
            "void main()",
            "{",
            "   vec3 fragPosVS =ReconstructPositionVSFromDepthVS(vUv0);",
            "   vec3 normalVS = ReconstructNormalVSFromPositionDepthVS(fragPosVS,vUv0);",
            "   vec3 randomVec=normalize((2.0*texture2D(ssaoNoiseTexture,vUv0*noiseScale)-1.0).xyz);",
            "   vec3 tangent = normalize(randomVec - normalVS * dot(randomVec, normalVS));",
            "   vec3 bitangent = cross(normalVS, tangent);",
            "   mat3 TBN = mat3(tangent, bitangent, normalVS);",
            "   float occlusion=0.0;",
            "   for(int i=0;i<NORMAL_ORIENTED_SAMPLE_COUNT;++i)",
            "   {",
            "       vec3 sp=TBN*samples[i];",
            "       sp=fragPosVS+sp*kernelRadius;",
            "       vec4 offset=vec4(sp,1.0);",
            "       offset=matrix_projection*offset;",
            "       offset.xy/=offset.w;",
            "       offset.xy=offset.xy*0.5+0.5;",
            "       float sampleDepth= ReconstructDepthVSFromDepthSS(offset.xy);",
            "       float rangeCheck=smoothstep(0.0,1.0,kernelRadius/abs(fragPosVS.z-sampleDepth));",
            "       occlusion+=(sampleDepth>=sp.z+kernelBias?1.0:0.0)*rangeCheck;",
            "   }",
            "   occlusion=1.0-(occlusion/float(NORMAL_ORIENTED_SAMPLE_COUNT));",
            "   gl_FragColor=packFloat(occlusion);",
            "}"
        ].join("\n");

        //SSAO blur shader
        var ssaoBlurFrag = [
            "precision " + graphicsDevice.precision + " float;",
            "precision " + graphicsDevice.precision + " sampler2D;",
            "#define blurRadius " + this.blurRadius,
            "#define blurRadiusdiv2 blurRadius/2 ",
            "varying vec2 vUv0;",
            "uniform sampler2D ssaoTexture;",
            "uniform sampler2D colorTexture;",
            "uniform vec2 ssaoTextureSize;",
            "float unpackFloat(vec4 rgbaDepth)", 
            "{",
                "const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0);",
                "float depth = dot(rgbaDepth, bitShift);",
                "return depth;",
            "}",
            "void main()",
            "{",
            "   vec2 texelSize=1.0/ssaoTextureSize;",
            "   float fragAOColor=0.0;",
            "   for(int i=-blurRadiusdiv2;i<=blurRadiusdiv2;++i)",
            "   {",
            "       for(int j=-blurRadiusdiv2;j<=blurRadiusdiv2;++j)",
            "       {",
            "           vec2 offset=vec2(i,j)*texelSize;",
            "           fragAOColor+=unpackFloat(texture2D(ssaoTexture,vUv0+offset));",
            "       }",
            "   }",
            "   float uniBlur=fragAOColor/float(blurRadius*blurRadius);",
            "   gl_FragColor=vec4(texture2D(colorTexture,vUv0).rgb*uniBlur,1.0);",
            "   gl_FragColor=vec4(vec3(uniBlur),1.0);",
            "}"
        ].join("\n");

要注意的是我们这里将AO值通过Playcanvas的内置函数打包成一个vec4向量并且在模糊阶段解包。在采样循环里面我们将之前延迟渲染部分从几何阶段取信息的操作全都换成从深度重建。并且在模糊阶段的最后直接将AO图与场景的颜色贴图进行相乘。这样得出的效果虽然没有延迟渲染方式的对环境光做比例缩放的方法好,但是这种方法可以减少一个Render Pass。否则的话还需要对场景渲染第二次,在逐片元光照计算的时候缩放ambient比例。这样做相当于要将场景渲染两次,性价比不高。

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言