OpenGL 延迟着色

OpenGL 延迟着色(Deferred Shading)

我们之前一直使用的光照方式叫做正向渲染(Forward Rendering)或者正向着色法(Forward Shading),它是一种非常直接的方式,我们根据场景中所有的光源去依次渲染一个一个的物体,这很容易理解和实现,但同时这对程序性能的影响也很大,因为对场景中的所有物体的每个片段都需要根据所有光影进行计算,这是非常大的工作量,而且其实大部分片段着色器的输出都会被之后的输出覆盖,这些计算就被浪费了。

延迟着色法(Deferred Shading)或者说是延迟渲染(Deferred Rendering),为了解决上述问题而诞生了,它大幅度地改变了我们渲染物体的方式。这给我们优化拥有大量光源的场景提供了很多的选择,因为它能够在渲染上百甚至上千光源的同时还能够保持能让人接受的帧率。

它的技术思路主要将3D场景的几何光照信息(位置、法线、材质信息)渲染到render target上,把它们从世界的三维空间转变成屏幕的颜色空间,作为光照计算时的输入,接着,对每一个光源,使用这些信息输入来进行计算生成一帧,然后把这样的一帧(render target)合成到结果的帧缓存上,这样当遍历完所有的光源,计算就完毕了,帧缓存上的图像就是最后的渲染结果。再使用渲染到纹理(render-to-texture)的技术将其展示在窗口中。

它包含两个处理阶段(Pass):在第一个几何处理阶段(Geometry Pass)中,我们先渲染场景一次,获取对象的各种几何信息,并储存在G缓冲(G-buffer)中,这些几何信息将会在之后用来做(更复杂的)光照计算。在第二个光照处理阶段(Lighting Pass),我们渲染一个屏幕大小的矩形,并使用G缓冲中的几何数据对每一个片段计算场景的光照;在每个像素中我们都会对G缓冲进行迭代。光照计算过程还是和以前一样,但是现在需要从对应的G缓冲而不是顶点着色器(和一些uniform变量)那里获取输入变量了。

(我理解就是先渲染一遍场景,渲染的时候先不考虑灯光这些效果,将渲染后的位置、法线、散射光等等信息作为纹理存储到G-buffer中(其实这些信息也可以用来直接输出到窗口中),这时候它们已经是2D的了,然后在光找处理阶段,由这些位置、法散射光等信息,去计算生产片段的颜色,并渲染到屏幕大小的矩形框里。)

几何处理阶段 G-Buffer

将G缓冲之前,要想了解一些什么是帧缓存。我们已经了解了用于写入颜色值的颜色缓冲、用于写入深度信息的深度缓冲和允许我们根据一些条件丢弃特定片段的模板缓冲。这些缓冲结合起来叫做帧缓冲,它被储存在内存中。我们目前所做的所有操作都是在默认帧缓冲的渲染缓冲上进行的。默认的帧缓冲是在你创建窗口的时候生成和配置的(GLUT帮我们做了这些)。OpenGL允许我们定义我们自己的帧缓冲,也就是说我们能够定义我们自己的颜色缓冲,甚至是深度缓冲和模板缓冲。

G缓冲(G-buffer)也是一种帧缓冲,它是用来储存光照相关的数据以及在最后的光照处理阶段中使用的所有数据。它包含了多个颜色缓冲和一个单独的深度渲染缓冲对象(Depth Renderbuffer Object)。

GLuint gBuffer;
GLuint gPosition, gNormal, gAlbedoSpec;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
  • 使用RGB纹理来储存位置和法线的数据,因为位置,法线和RGB一样只有三个分量;

    //位置颜色缓冲
    glGenTextures(1, &gPosition);
    glBindTexture(GL_TEXTURE_2D, gPosition);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, 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, gPosition, 0);
    
    //法线颜色缓冲
    glGenTextures(1, &gNormal);
    glBindTexture(GL_TEXTURE_2D, gNormal);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, 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_ATTACHMENT1, GL_TEXTURE_2D, gNormal, 0);
    
  • 将颜色和镜面强度数据合并到一起,存储到一个单独的RGBA纹理里面,就不需要声明一个额外的颜色缓冲纹理。

    //颜色 + 镜面颜色缓冲
    glGenTextures(1, &gAlbedoSpec);
    glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, 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_ATTACHMENT2, GL_TEXTURE_2D, gAlbedoSpec, 0);
    
  • 由于我们使用了**多渲染目标(Multiple Render Targets)**来在一个渲染处理之内渲染多个颜色缓冲,所以需要显式告诉OpenGL渲染的是和GBuffer关联的哪个颜色缓冲,使用glDrawBuffers函数。

    // - 告诉OpenGL我们将要使用(帧缓冲的)哪种颜色附件来进行渲染
    GLuint attachments[3] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 };
    glDrawBuffers(3, attachments);
    
  • 需要添加渲染缓冲对象(RBO)作为深度缓冲(GL_DEPTH_ATTACHMENT)使用。

    unsigned int rboDepth;
    glGenRenderbuffers(1, &rboDepth);
    glBindRenderbuffer(GL_RENDERBUFFER, rboDepth);
    glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, SCR_WIDTH, SCR_HEIGHT);
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rboDepth);
    
  • 最后不要忘记检查G-缓存完整性以及解绑G缓存

    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
    	std::cout << "Framebuffer not complete!" << std::endl;
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    

接下来我们需要渲染场景到我们的G缓冲中。

  • 顶点着色器就和之前的普通渲染时的一样,根据绑定的VAO中的物体顶点的坐标aPos、法线aNormal和纹理坐标aTextCoords,以及传入的modelview, projection矩阵经过简单的计算,输出FragPos,TexCoordsNormal到片段着色器中。

    #version 330 core
    layout (location = 0) in vec3 aPos;
    layout (location = 1) in vec3 aNormal;
    layout (location = 2) in vec2 aTexCoords;
    
    out vec3 FragPos;
    out vec2 TexCoords;
    out vec3 Normal;
    
    uniform mat4 model;
    uniform mat4 view;
    uniform mat4 projection;
    
    void main()
    {
        FragPos = vec3(model * vec4(aPos, 1.0));
        TexCoords = aTexCoords;
        Normal = mat3(transpose(inverse(model))) * aNormal;  
    
        gl_Position = projection * view * vec4(FragPos, 1.0);
    }
    
  • 片段着色器中,将从顶点着色器中传入的信息和漫反射纹理以及镜面反射纹理信息一同渲染到G-缓冲中:

    #version 330 core
    layout (location = 0) out vec3 gPosition;
    layout (location = 1) out vec3 gNormal;
    layout (location = 2) out vec4 gAlbedoSpec;
    
    in vec2 TexCoords;
    in vec3 FragPos;
    in vec3 Normal;
    
    uniform sampler2D texture_diffuse1;
    uniform sampler2D texture_specular1;
    
    void main()
    {    
        // 存储片段位置向量到G缓冲中
        gPosition = FragPos;
        // 存储片段法线到G缓冲中
        gNormal = normalize(Normal);
        // 存储漫反射到gAlbedoSpec的rbg分量
        gAlbedoSpec.rgb = texture(texture_diffuse1, TexCoords).rgb;
        // 存储镜面强度到gAlbedoSpec的alpha分量
        gAlbedoSpec.a = texture(texture_specular1, TexCoords).r;
    }  
    

    因为我们使用了多渲染目标,布局指示符(Layout Specifier)告诉了OpenGL我们需要渲染到当前的活跃帧缓冲中的哪一个颜色缓冲。

光照处理阶段

现在我们已经有了一大堆的片段数据储存在G缓冲中供我们使用,通过一个像素一个像素地遍历G缓冲中的各个纹理,将储存在它们里面的内容作为光照算法的输入,来完全计算场景最终的光照颜色。由于所有的G缓冲纹理都代表的是最终变换的片段值,我们只需要对每一个像素执行一次光照运算就行了。

  • 我们将之前声明的位置颜色缓冲法线颜色缓冲颜色+镜面颜色缓冲作为纹理,因为有多个纹理,所以使用纹理单元(Texture Unit:一个纹理的位置值通常称为一个纹理单元)的方式,激活三个纹理单元,绑定uniform采样器到对应的纹理单元;还要使用glUniform1i设置每个纹理采样器的位置值,我们只需要设置一次即可,所以这个会放在渲染循环的前面,然后就是发送光照相关的uniform变量(例如光的位置,颜色)到着色器中,然后渲染屏幕大小的矩形,我们计算后的结果就会像纹理那样贴到这个矩形中。(渲染到纹理的思想)

    {//初始设置
      	...
      	shaderLightingPass.use();
      	shaderLightingPass.setInt("gPosition", 0);
      	shaderLightingPass.setInt("gNormal", 1);
      	shaderLightingPass.setInt("gAlbedoSpec", 2);
      ...
      }
      {//渲染循环
      	...
      	shaderLightingPass.use();
      	glActiveTexture(GL_TEXTURE0);
      	glBindTexture(GL_TEXTURE_2D, gPosition);
      	glActiveTexture(GL_TEXTURE1);
      	glBindTexture(GL_TEXTURE_2D, gNormal);
      	glActiveTexture(GL_TEXTURE2);
      	glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
      	...
      	// 发送光照相关的uniform
      	for (unsigned int i = 0; i < lightPositions.size(); i++)
      	{
      		shaderLightingPass.setVec3("lights[" + std::to_string(i) + "].Position", lightPositions[i]);
      		shaderLightingPass.setVec3("lights[" + std::to_string(i) + "].Color", lightColors[i]);
      	}
      	shaderLightingPass.setVec3("viewPos", glm::vec3(0.0f, 5.0f, 5.0f));
      	// finally render quad
      	RenderQuad();  
      }
      unsigned int quadVAO = 0;
      unsigned int quadVBO;
      void renderQuad()
      {
      	if (quadVAO == 0)
      	{
      		float quadVertices[] = {
      		// positions        // texture Coords
      		-1.0f,  1.0f, 0.0f, 0.0f, 1.0f,
      		-1.0f, -1.0f, 0.0f, 0.0f, 0.0f,
      		 1.0f,  1.0f, 0.0f, 1.0f, 1.0f,
      		 1.0f, -1.0f, 0.0f, 1.0f, 0.0f,
      		};
      		// setup plane VAO
      		glGenVertexArrays(1, &quadVAO);
      		glGenBuffers(1, &quadVBO);
      		glBindVertexArray(quadVAO);
      		glBindBuffer(GL_ARRAY_BUFFER, quadVBO);
      		glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), &quadVertices, GL_STATIC_DRAW);
      		glEnableVertexAttribArray(0);
      		glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
      		glEnableVertexAttribArray(1);
      		glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
      	}
      	glBindVertexArray(quadVAO);
      	glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
      	glBindVertexArray(0);
      }
    
    
  • 光照处理阶段的顶点着色器很简单,就是画一个矩形,没有任何的坐标变换,直接将传进去的顶点坐标和纹理坐标输出就好:

    //顶点着色器
    #version 330 core
    layout (location = 0) in vec3 aPos;
    layout (location = 1) in vec2 aTexCoords;
    out vec2 TexCoords;
    void main()
    {
    	TexCoords = aTexCoords;
    	gl_Position = vec4(aPos, 1.0);
    }
    
    
  • 光照处理阶段的片段着色器,就是从G缓冲中获取到位置、法线、颜色镜面等信息,在G缓冲中它们都是作为纹理,所以我们使用当前片段的纹理坐标直接采样这些数据,将会得到和之前完全一样的片段值,这就像我们在直接渲染几何体。有了必要的逐片段变量和相关的uniform变量根据布林-冯氏光照(Blinn-Phong Lighting)模型,就可以计算光照了。

    #version 330 core
    out vec4 FragColor;
    in vec2 TexCoords;
    
    uniform sampler2D gPosition;
    uniform sampler2D gNormal;
    uniform sampler2D gAlbedoSpec;
    
    struct Light {
    	vec3 Position;
    	vec3 Color;
    };
    const int NR_LIGHTS = 32;
    uniform Light lights[NR_LIGHTS];
    uniform vec3 viewPos;
    
    void main()
    {             
    	// 从G缓冲中获取数据
    	vec3 FragPos = texture(gPosition, TexCoords).rgb;
    	vec3 Normal = texture(gNormal, TexCoords).rgb;
        vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb;
    	float Specular = texture(gAlbedoSpec, TexCoords).a;
    
    	// 然后和往常一样地计算光照
    	vec3 lighting = Albedo * 0.1; // 硬编码环境光照分量
    	vec3 viewDir = normalize(viewPos - FragPos);
    	for(int i = 0; i < NR_LIGHTS; ++i)
    	{
        	// 漫反射
        	vec3 lightDir = normalize(lights[i].Position - FragPos);
        	vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Albedo * lights[i].Color;
        	lighting += diffuse;
    	}
    	FragColor = vec4(lighting, 1.0);
    }
    

优化——光体积

但其实你是否发现延迟渲染它本身并不能支持非常大量的光源,因为我们仍然必须要对场景中每一个光源计算每一个片段的光照分量。真正让大量光源成为可能的是我们能够对延迟渲染管线引用的一个非常棒的优化:光体积(Light Volumes)

之前当我们渲染一个复杂光照场景下的片段着色器时,我们会计算场景中每一个光源的贡献,不管它们离这个片段有多远。很大一部分的光源根本就不会到达这个片段,所以为什么我们还要浪费这么多光照运算呢?所以我们可以根据光体积或半径,只对那些在一个或多个光体积内的片段进行繁重的光照运算就行了,这可以给我们省下来很可观的计算量。

  • 计算一个光影的体积或半径

    光的衰减方程如下:
    F l i g h t = I K c + K l ∗ d + K q ∗ d 2 F_{light} = \frac{I}{K_c + K_l * d + K_q * d^2} Flight=Kc+Kld+Kqd2I
    我们可以计算在 F l i g h t F_{light} Flight为0,即完全黑暗的时候的距离d,但不可能真正等于零,我们选择5/256作为一个合适的光照值。令 I I I为光源最亮的颜色分量 I m a x I_{max} Imax,解方程得:
    K q ∗ d 2 + K l ∗ d + K c − I m a x ∗ 256 5 = 0 K_q * d^2 + K_l * d + K_c - I_{max} * \frac{256}{5} = 0 Kqd2+Kld+KcImax5256=0
    用求根公式来解这个二次方程:
    x = − K l + K l 2 − 4 ∗ K q ∗ ( K c − I m a x ∗ 256 5 ) 2 ∗ K q x = \frac{-K_l + \sqrt{K_l^2 - 4 * K_q * (K_c - I_{max} * \frac{256}{5})}}{2 * K_q} x=2KqKl+Kl24Kq(KcImax5256)
    它给了我们计算x的值的一个通用公式,即光源的光体积半径,只要我们提供了一个常量,线性和二次项参数:

GLfloat constant  = 1.0; 
GLfloat linear    = 0.7;
GLfloat quadratic = 1.8;
GLfloat lightMax  = std::fmaxf(std::fmaxf(lightColor.r, lightColor.g), lightColor.b);
GLfloat radius    = 
  (-linear +  std::sqrtf(linear * linear - 4 * quadratic * (constant - (256.0 / 5.0) * lightMax))) 
  / (2 * quadratic);  

它会返回一个大概在1.0到5.0范围内的半径值,它取决于光的最大强度,可以在发送光照相关的uniform时计算出每个光源的radius并传入片段着色器。

struct Light {
    [...]
    float Linear;
    float Quadratic;
    float Radius;
}; 

void main()
{
    [...]
    for(int i = 0; i < NR_LIGHTS; ++i)
    {
        // 计算光源和该片段间距离
        float distance = length(lights[i].Position - FragPos);
        if(distance < lights[i].Radius)
        {
            // diffuse
            vec3 lightDir = normalize(lights[i].Position - FragPos);
            vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * lights[i].Color;
            // specular
            vec3 halfwayDir = normalize(lightDir + viewDir);  
            float spec = pow(max(dot(Normal, halfwayDir), 0.0), 16.0);
            vec3 specular = lights[i].Color * spec * Specular;
            // attenuation
            float attenuation = 1.0 / (1.0 + lights[i].Linear * distance + lights[i].Quadratic * distance * distance);
            diffuse *= attenuation;
            specular *= attenuation;
            lighting += diffuse + specular;
        }
    }   
}

注意:这种方法在实际场景中是不可行的!GPU和GLSL并不擅长优化循环和分支,因为GPU中着色器的运行是高度并行的,在一个线程中GPU需要对它运行完全一样的着色器代码从而获得高效率。这通常意味着一个着色器运行时总是执行一个if语句所有的分支从而保证着色器运行都是一样的,这使得我们之前的半径检测优化完全变得无用,我们仍然在对所有光源计算光照!使用光体积更好的方法是渲染一个实际的球体,并根据光体积的半径缩放。这些球的中心放置在光源的位置,正好覆盖了光的可视体积,我们使用大体相同的延迟片段着色器来渲染球体。因为球体产生了完全匹配于受影响像素的着色器调用,我们只渲染了受影响的像素而跳过其它的像素。另外两个基于延迟渲染的更流行(并且更高效)的拓展叫做延迟光照(Deferred Lighting)和切片式延迟着色法(Tile-based Deferred Shading)。

效果图:
在这里插入图片描述

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值