OpenGL学习(十一):延迟渲染管线

前言

上一篇文章回顾:OpenGL学习(十)天空盒

上一次更新还是元旦了,因为最近一直忙于期末考试的预习,考完玩了几天,昨天才 run 回家,今天开始更新!

在这之前的博客,我们都是使用 “前向渲染” 来对生成的像素做处理,比如我们可以为每一个像素添加光照,阴影等效果。但是今天我们要引入一种更加高级的渲染方式 ---- 延迟渲染。

注:本文代码基于上一篇博客

延迟渲染简介

延迟渲染是各大计算机游戏引擎常用的一种较为现代的渲染策略,延迟渲染的出现最主要是为了解决了大量光源的光照计算,减少了片元着色器的计算量,使得渲染复杂的场景变得可能!

回顾我们的 OpenGL 的流水线,我们在对三角面片进行光栅化之后,马上生成像素,并且 call GPU 的一个线程,对该像素运行一次片段着色器。这并未考虑到物体之间的遮挡关系!如果此时有另一片三角面片也绘制在同样的地方,前一个三角型的像素就会被遮挡,浪费片段着色器计算力。示意图如下:

在这里插入图片描述

如果场景非常复杂,那么我们会有多重的覆盖关系,那么片元着色器将会被运行很多次。对于一些复杂计算,比如计算光照,阴影等开销大的算法,无效的片元开销是致命的。


延迟渲染的出现解决了这个问题。以光照计算为例,回想我们计算光照需要那些信息?

  • 当前像素的世界坐标
  • 当前像素的法线
  • 当前像素的颜色

那么我们将这些信息先输出到一些纹理上存储起来,随后绘制一个正方形当作我们的 “屏幕”,然后针对每个屏幕上的像素,从纹理中取出需要的颜色,法线,坐标等信息,再进行光照计算。这样无论场景有多么复杂,我们都只需要运行 w × h 次片元着色器,其中 w,h 为屏幕宽高,绝不浪费宝贵的光照计算。

下面是一个典型的延迟渲染管线的大概结构:
在这里插入图片描述

首先是 shadowMap 阶段,我们从光源方向进行渲染,获得阴影贴图。gbuffer 阶段我们正常地渲染,但是不输出颜色,反之我们输出必要的信息到纹理。在后处理阶段我们绘制光影特效,最终输出一帧。

注:
一般后处理着色器可以有很多个,因为要对一些像素做多次 pass,比如计算泛光或者 SSR 等特效
此外,针对多个后处理着色器的管线,我们可以创建一个后处理帧缓冲和多个颜色附件,然后按照顺序将必要的纹理 pass 下去,那么该管线就和 Minecraft 的 optifine 模组提供的管线相似了。


即然提到了 MC 就多嗦两句 p 话:
optifine 提供了 10 个后处理阶段的自定义着色器(composite0 ~ 9)和一个最终合成阶段着色器(final),final 着色器总是工作在后处理阶段之后,用来进行不同帧缓冲数据合成。此外,optifine允许用户任意地操作 8 个颜色纹理附件,但是最终 0 号颜色附件会被输出到屏幕。

变量创建与准备

因为 gbuffer 阶段的渲染需要一些纹理以缓存,我们定义:

  • gcolor 存储基础颜色
  • gdepth 存储像素的深度
  • gworldpos 存储像素的世界坐标
  • gnormal 存储像素的法向量

我们创建如下的全局变量,同时生成对应的着色器:

// 延迟渲染阶段
GLuint gbufferProgram;
GLuint gbufferFBO;  // gbuffer 阶段帧缓冲
GLuint gcolor;      // 基本颜色纹理
GLuint gdepth;      // 深度纹理
GLuint gworldpos;   // 世界坐标纹理
GLuint gnormal;     // 法线纹理

// 后处理阶段
GLuint composite0;

...

// 生成着色器程序对象
gbufferProgram = getShaderProgram("shaders/gbuffer.fsh", "shaders/gbuffer.vsh");
shadowProgram = getShaderProgram("shaders/shadow.fsh", "shaders/shadow.vsh");
debugProgram = getShaderProgram("shaders/debug.fsh", "shaders/debug.vsh");
skyboxProgram = getShaderProgram("shaders/skybox.fsh", "shaders/skybox.vsh");
composite0 = getShaderProgram("shaders/composite0.fsh", "shaders/composite0.vsh");

其中着色器和他们所属的阶段如下:

在这里插入图片描述

随后我们创建一个 gubffer 的帧缓冲,并且指定 3 个颜色附件和一个深度附件,用于存储必要的信息:

// 创建 gubffer 帧缓冲
glGenFramebuffers(1, &gbufferFBO);
glBindFramebuffer(GL_FRAMEBUFFER, gbufferFBO);

// 创建颜色纹理
glGenTextures(1, &gcolor);
glBindTexture(GL_TEXTURE_2D, gcolor);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, windowWidth, windowHeight, 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);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 将颜色纹理绑定到 0 号颜色附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, gcolor, 0);

// 创建法线纹理
glGenTextures(1, &gnormal);
glBindTexture(GL_TEXTURE_2D, gnormal);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, windowWidth, windowHeight, 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);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 将法线纹理绑定到 1 号颜色附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, gnormal, 0);

// 创建世界坐标纹理
glGenTextures(1, &gworldpos);
glBindTexture(GL_TEXTURE_2D, gworldpos);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, windowWidth, windowHeight, 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);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 将世界坐标纹理绑定到 2 号颜色附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, gworldpos, 0);

// 创建深度纹理
glGenTextures(1, &gdepth);
glBindTexture(GL_TEXTURE_2D, gdepth);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, windowWidth, windowHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
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_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 将深度纹理绑定到深度附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, gdepth, 0);

// 指定附件索引
GLuint attachments[3] = {
    GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 };
glDrawBuffers(3, attachments);
glBindFramebuffer(GL_FRAMEBUFFER, 0);	// 解绑

注意到 glDrawBuffers 函数指定的附件绘制顺序,即是着色器中 gl_FragData 的索引,比如 gl_FragData[0] 对应的是 attachments[0] ,也就是我们的颜色纹理附件!执行如下的操作能向 0 号纹理附件中进行写入一块红色:

gl_FragData[0] = vec4(1, 0, 0, 1);

注:
其实没有必要生成世界坐标纹理,因为可以通过深度纹理和当前像素的屏幕坐标,重建其世界坐标。
Minecraft 的光影模组 optifine 就是这么操作的,但是缺点是必须知道 模型-视图矩阵的逆矩阵
这里我懒得算了,干脆直接缓存世界坐标来用了。。。

shadowMap 阶段

shadowMap 阶段和往常一样,我们正常进行绘制即可,因为最终我们需要的只是一张阴影贴图:

display 函数

...

// 从光源方向进行渲染
glUseProgram(shadowProgram);
glBindFramebuffer(GL_FRAMEBUFFER, shadowMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
glViewport(0, 0, shadowMapResolution, shadowMapResolution);

// 光源看向世界坐标原点
shadowCamera.direction = glm::normalize(glm::vec3(0, 0, 0) - shadowCamera.position);
// 传视图矩阵
...
// 传投影矩阵
...

// 从光源方向进行绘制
for (auto m : models)
{
   
    m.draw(shadowProgram);
}

gbuffer 阶段

在 gbuffer 阶段,我们分两个着色器进行绘制。skybox 着色器负责绘制天空盒,而 gbuffer 着色器负责绘制一般物体。所以我们要进行两次 draw call,代码如下:

display 函数

...

// 绘制天空盒 -- 输出到 gbuffer 阶段的 3 张纹理中
glUseProgram(skyboxProgram);
glBindFramebuffer(GL_FRAMEBUFFER, gbufferFBO);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glViewport(0, 0, windowWidth, windowHeight);

// 传视图,投影矩阵
...

// 传cubemap纹理
...

// 传递 zfar 和 znear 方便让天空盒的坐标置于最大视距
...

glDepthMask(GL_FALSE);
skybox.draw(skyboxProgram);
glDepthMask(GL_TRUE);

// ------------------------------------------------------------------------ // 

// 正常绘制 -- 输出到 gbuffer 阶段的 3 张纹理中
glUseProgram(gbufferProgram);

// 传视图矩阵
...
// 传投影矩阵
...

// 正常绘制
for (auto m : models)
{
   
    m.draw(gbufferProgram);
}

值得注意的是,片元着色器不再输出颜色了,反而是输出几何信息到我们的纹理缓存中,下面是 gbuffer.fsh 片元着色器的代码:

#version 330 core

...

void main()
{
   
    gl_FragData[0] = texture2D(texture, texcoord);  // 写入 gcolor
    gl_FragData[1] = vec4(normalize(normal), 0.0);  // 写入 gnormal
    gl_FragData[2] = vec4(worldPos, 1.0);           // 写入 gworldpos
}

对于 skybox 天空盒的绘制则比较特殊,因为天空盒不参与光照阴影的计算,于是我们在其世界坐标中给他一个很大的值,这里利用了透视投影摄像机的 far 参数,我们将天空的距离拉扯至两倍的远截面:

#version 330 core

...

uniform float far;

void main()
{
   
    gl_FragData[0] = textureCube(skybox, texcoord); // 写入 gcolor
    gl_FragData[1] = vec4(vec3(0), 0.0);            // 写入 gnormal
    gl_FragData[2] = vec4(texcoord*far*2, 1.0);     // 写入 gworldpos
}

后处理阶段

我们只需要绘制一个四边形,并且让他铺满屏幕即可。此外,我们传递 gbuffer 阶段绘制的必要的几何信息(就是那些纹理),代码如下:

display 函数

...

// debug着色器输出一个四方形以显示纹理中的数据
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDisable(GL_DEPTH_TEST);   // 需要取消深度测试以保证其覆盖在原画面上
glUseProgram(debugProgram);
glViewport(0, 0, windowWidth, windowHeight);

// 传递 zfar 和 znear 方便转线性深度
...

// 传 gcolor 纹理
...
// 传 gnormal 纹理
...
// 传 gworldpos 纹理
...
// 传 gdepth 纹理
...
// 传阴影深度纹理
...

// 绘制
screen.draw(debugProgram);
glEnable(GL_DEPTH_TEST);

随后我们在着色器中,根据屏幕坐标取 4 个纹理中不同的数据,并且打印在屏幕上,下面是 debug 着色器的代码:

#version 330 core

...

void main()
{
      
    // 屏幕左下显示 gcolor
    if(0<=texcoord.x && texcoord.x<=0.5 && 0<=texcoord.y && texcoord.y<=0.5)
    {
   
        vec2 coord = vec2(texcoord.x*2, texcoord.y*2);
        fColor = vec4(texture2D(gcolor, coord).rgb, 1); 
    }

    // 屏幕右下显示 gnormal
    if(0.5<=texcoord.x && texcoord.x<=1 && 0<=texcoord.y && texcoord.y<=0.5)
    {
   
        vec2 coord = vec2(texcoord.x*2-1, texcoord.y*2);
        fColor = vec4(texture2D(gnormal, coord).rgb, 1); 
    }

    // 屏幕左上显示 gdepth
    if(0<=texcoord.x && texcoord.x<=0.5 && 0.5<=texcoord.y && texcoord.y<=1)
    {
   
        vec2 coord = vec2(texcoord.x*2, texcoord.y*2-1);
        float d = linearizeDepth(texture2D(gdepth, coord).r, near, far);
        //d = texture2D(shadowtex, coord).r;
        fColor = vec4(vec3(d*0.5+0.5), 1); 
    }

    // 屏幕右上显示 gworldpos
    if(0.5<=texcoord.x && texcoord.x<=1 && 0.5<=texcoord.y && texcoord.y<=1)
    {
   
        vec2 coord = vec2(texcoord.x*2-1, texcoord.y*2-1);
        fColor = vec4(texture2D(gworldpos, coord).rgb, 1); 
    }
}

效果如下:

在这里插入图片描述
可以看到 4 个纹理的数据都正常。现在我们开始进行光影的绘制,我们编写 composite0 着色器,片段着色器的内容如下:

#version 330 core

...

void main()
{
      
    fColor.rgb = texture2D(gcolor, texcoord).rgb;
    vec3 worldPos = texture2D(gworldpos, texcoord).xyz;
    vec3 normal = texture2D(gnormal, texcoord).xyz;

    float isInShadow = shadowMapping(shadowtex, shadowVP, vec4(worldPos, 1.0));
    PhongStruct phong = phong(worldPos, cameraPos, lightPos, normal);

    // 如果在阴影中则只有环境光
    if(isInShadow==0) {
   
        fColor.rgb *= phong.ambient + phong.diffuse + phong.specular;
    } else if(isInShadow==1.0) {
   
        fColor.rgb *= phong.ambient;  // only ambient
    }
}

注意这里我们使用了一个小 trick,因为天空盒不属于阴影和光照计算的物体,于是我们通过判断其是否在光源摄像机的视野中,就可以知道它是否是天空盒。判断的代码如下:

// 阴影映射
float shadowMapping(sampler2D tex, mat4 shadowVP, vec4 worldPos) {
   
	// 转换到光源坐标
	vec4 lightPos = shadowVP * worldPos;
	lightPos = vec4(lightPos.xyz/lightPos.w, 1.0);
	lightPos = lightPos*0.5 + 0.5;

    // 超出阴影贴图视野 -- 返回一个特殊值
    if(lightPos.x<0 || lightPos.x>1 || lightPos.y<0 || lightPos.y>1 || lightPos.z<0 || lightPos.z>1) {
   
        return 2.0;
    }

	// 计算shadowmapping
	float closestDepth = texture2D(tex, lightPos.xy).r;	// shadowmap中最近点的深度
	float currentDepth = lightPos.z;	// 当前点的深度
	float isInShadow = (currentDepth>closestDepth+0.005) ? (1.0) : (0.0);

	return isInShadow;
}

然后我们和 debug 着色器类似,也是传纹理,传 uniform,然后 draw call,使用 comopiste0 着色器进行绘制。现在来看 c++ 的代码:

  • 10
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值