OpenGL知识点整理(2)

292 阅读8分钟

欢迎关注公众号:sumsmile /专注图形学

花了几天时间,整理了以前写的learnopengl笔记,做了删减、补充。

learnopengl地址:learnopengl-英文-pdflearnopengl-中文learnopengl-在线英文

高级光照

Gamma校正

参考之前的一篇文章:理解gamma校正[译],讲清楚了为什么要有Gamma校正

gamma校正有两种方案:

  1. 开启GL_FRAMEBUFFER_SRGB简单的调用glEnable就行:
glEnable(GL_FRAMEBUFFER_SRGB);

开启GL_FRAMEBUFFER_SRGB,在颜色储存到颜色缓冲之前先校正sRGB颜色。sRGB这个颜色空间大致对应于gamma2.2,它也是家用设备的一个标准

  1. 片段着色器中手动实现
void main()
{
    // do super fancy lighting 
    [...]
    // apply gamma correction
    float gamma = 2.2;
    fragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma));
}

是否需要开Gamma校正,要看输入的纹理、和最终的实现效果

阴影

先补充个细节

NDC坐标在片段着色器之后会被转换成[0,1]范围值,之后存储到深度缓冲中,所以深度缓冲中并不是存储(-1~1)

参考:glDepthRange

  1. 阴影的实现原理
  • 光照方向作为相机,生成深度缓冲
// 第一遍pass,framebuffer只需要深度缓冲,color缓冲设置为null,否则会检查报错
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
  • 切换到视角方向,正常渲染,并以深度缓冲作为比较,判断是否在阴影中
// 计算在光照方向,当前深度是否小于深度缓冲的采样值,大于则shadow = 1.0
float shadow = currentDepth > closestDepth  ? 1.0 : 0.0;

实际在工程中平行光不一定是用正交矩阵,正交矩阵的锥体较小,filament中平行光也是用的透视投影近似,可以覆盖更多的空间,阴影稍微歪一点也不影响。

  1. 阴影失真

  • 原因

阴影图的分辨率有限,多个点实际采样的同一个点,则有规律的,部分点通过计算后落到采样深度的后面,则判断成阴影

  • 解决方案

1)将阴影图的深度做一个偏移:

float bias = 0.005;
float shadow = currentDepth > bias + closestDepth  ? 1.0 : 0.0;  

通常调一调也能解决问题,当光照倾斜度更大时,一个像素对应的真实的面积越大,就需要更大的偏移。

一个适应性更好的解决办法是,加入倾斜角度的权重

// 角度越大,dot越小,则取更大的偏移值,0.005保底
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);

悬浮问题--偏移的副作用

如果偏移没调好的话,还是可能产生悬浮,即该有阴影的地方没有阴影

2)剔除正面

glCullFace(GL_FRONT);
RenderSceneToDepthMap();
glCullFace(GL_BACK); // 不要忘记设回原先的culling face

剔除正面也有限制条件,即物体必须有厚度。即利用物体的厚度非常巧妙的、降低了深度缓冲的值

  1. 阴影缺失
  • 阴影面积太小,导致超出阴影面积的地方采样到的值不正确,比如repeat环绕模式,那么采样的深度值比真实值小。

解决办法:阴影图采样方式设置为GL_CLAMP_TO_BORDER,且border为1,保证超出的采样深度为最大值1,表示没有阴影,至少不是黑的

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
GLfloat borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

  • 超出深度图远景面 图中还有整齐的一边是阴影的,原因是顶点放到光照矩阵中计算,超出了光照方向视锥的远端面,计算得到的z值 > 1.0

解决办法:

float ShadowCalculation(vec4 fragPosLightSpace)
{
    [...]
    if(projCoords.z > 1.0)
        shadow = 0.0;

    return shadow;
}

虽然也不对,但是比黑色的要好,而且处于比较远的地方,不太对总比黑的要好。

  1. 阴影锯齿优化,PCF & PCSS
  • PCF(percentage close filter),渐进式过滤
  • PCSS(percentage close soft shadow)原理,渐进式软阴影

参考实时阴影

  • PCF,阴影抗锯齿
float shadow = 0.0;
// 计算阴影的宽高
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
// 采样 3 * 3个点
for(int x = -1; x <= 1; ++x)
{
    for(int y = -1; y <= 1; ++y)
    {
        // 按宽高比例取3 * 3范围内的点
        float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; 
        shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;        
    }    
}
// 求平均
shadow /= 9.0;

比这更好一点的算法是泊松采样,或均匀圆盘采样

  • PCSS,渐进式软阴影

真实的场景中,阴影的软硬是变化的,遮挡面越靠近被投影面,阴影越硬,即采样的范围越小。

PCSS在PCF的基础上,增加了动态计算采样半径,此处不做展开了

法线贴图

  1. normal map的作用:使用法线贴图能更好的展示细节

  1. TBN矩阵-切线空间 理解TBN是掌握法线贴图最核心的点,几年前我第一次接触TBN矩阵没看懂,这次终于明白了,理解一下几个点就懂了
  • 法线贴图

法线纹理在设计时默认和OpenGL的坐标系一致,即normal和GL的z轴对齐,和屏幕垂直

实际上,三角面片在空间中的位置朝向是随意的,默认的法线贴图不能直接用,因为采样出来法线朝向屏幕外(不准确,大意如此)

必须将法线纹理做个矩阵变换,挪到三角面片平面上,且使采样的面和三角面片吻合

这个矩阵就叫TBN矩阵

  • 求TBN矩阵

从一个空间变换到另一个空间,本质是求基向量的变换,这点原文中也没提,我就是在这个点上卡了三年了,汗。

法线纹理的基向量是Ax(1,0,0)、Ay(0,1,0)、Az(0,0,1),矩阵A(Ax,Ay,Az) 是单位向量

可以列出矩阵方程:M_tbn * A = M_new M_tbn是要求的变换矩阵,M_new是三角面片对应的基向量矩阵,A是单位向量忽略。

其他的参考learnGL教程上写的即可,教程中求矩阵的逆时,用的事伴随矩阵的方式

矩阵方程

求逆

伴随矩阵求逆

  1. TBN矩阵&法线贴图的使用

有两种方式:

  • 第一种:按上面说的,在片段着色器中,将每个采样出来的法线经过TBN变换到世界坐标系中,后面的光照和普通的光照着色一样
  • 第二种:将光照、顶点通过TBN逆矩阵,变换到法线贴图的默认空间

第二种看起来更复杂,需要求逆,需要求光照、顶点的变换,但是减少了片段着色器的计算,整体效率更优秀

顶点着色器只需要对少量的有限的顶点进行计算,OpenGL管线通过插值得到平面上每个点的属性,最后到片段着色器中。片段着色器需要对每个插值出来的点进行计算,虽然有并发,但是还是得分批次并发。

shader实现参考:
tbn vertex shader
tbn fragment shader

视差贴图

视差贴图也叫位移贴图,通过偏移采样纹理得到凹凸不平的效果。和法线贴图一样,也用到了TBN空间转换。

高度图

带视差贴图的效果

  1. 视差贴图原理

对A点进行着色时,实际看到的是B点,因为凸起挡住了A点,看到的是斜面上的B点

一种近似的O(1)算法,以观察点A的高度作为缩放因子,对view方向放大,得到B出的坐标

bad case,比较陡峭的地方,和实际的位置误差较大

  1. 实际用到的视差贴图

实际用到的视差贴图是反过来的,高度图变成深度图,即越低的地方现在值越大。

想想下,将一个柔软的被单铺在一块石头上,被单顺着石头塌下去,形成一个凸起,被单上的A点现在塌下去了,实际看到的是B点

B点的坐标 = A点的坐标 - 向量P

关键是计算B点的坐标

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    float height =  texture(depthMap, texCoords).r;    
    vec2 p = viewDir.xy / viewDir.z * (height * height_scale);
    return texCoords - p;    
}

这里除以viewDir.z的理解:当z越小,表示观察视角越平,实际观察的点B离A点偏移的越多

  1. 优化视差贴图的边缘失真

采样超出了边界,导致失真,使用discard处理

texCoords = ParallaxMapping(fs_in.TexCoords,  viewDir);
if(texCoords.x > 1.0 || texCoords.y > 1.0 || texCoords.x < 0.0 || texCoords.y < 0.0)
    discard;

  1. 陡峭视差贴图 (Steep Parallax Mapping)

感觉像是解决高度变化突然的场景,此时用普通的视差会有畸变。分层用到了循环,会增加计算量,用性能换效果。

畸变

通过分层遍历的方式,找到最接近的偏移值

进一步优化,可以针对观察角度不同,改变分层的数量,越垂直分层越少,越倾斜分层越多

const float minLayers = 8;
const float maxLayers = 32;
float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));
  1. 视差遮蔽映射(Parallax Occlusion Mapping)

在陡峭视差贴图 (Steep Parallax Mapping)基础上,对找到的两个临界值按按权重做插值,效果更接近真实的情况

本节代码参考learnGl原文,没有复杂的逻辑。

HDR(High Dynamic Range, 高动态范围)

屏幕显示颜色会约束到[0,1]之间,如果场景中有很多超过1的color值,比如10、15、100都会约束到1,体现不出真实的纹理。

  1. 实现流程
  • 把场景渲染到自定义的帧缓冲,帧缓冲可以设置color的取值范围,默认的窗口缓冲不支持设置精度,默认是一个字节8位,自定义缓冲可以设置16或32位浮点类型。另外,即使默认的缓冲支持设置数据类型,最好也通过自定义帧缓冲生成一张2维纹理,最后只需要针对2维纹理做映射计算,性能上有数量级的提升

  • 切回到默认缓冲,把帧缓冲渲染到默认窗口,同时,在shader中增加Reinhard(混合渲染)色调映射算法,调整颜色范围

  1. reinhard映射算法
// reinhard
vec3 result = hdrColor / (hdrColor + vec3(1.0));

reinhard函数曲线

  1. 曝光映射算法

vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);

曝光度来控制映射曲线

泛光(bloom)

bloom

  1. bloom实现流程:
  • MRT(Multiple Render Targets,多渲染目标)输出两个颜色缓冲:一张完整的,一张提取高亮部分

  • 对高亮部分做高斯模糊得到模糊图

  • 模糊图 + 完整图 => 最后上屏的缓冲

  1. bloom的流程很简单,有几点细节需要注意
  • MTR使用
for (GLuint i = 0; i < 2; i++)
{
    glBindTexture(GL_TEXTURE_2D, colorBuffers[i]);
    glTexImage2D(
        GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL
    );
    ...
    // attach texture to framebuffer
    glFramebufferTexture2D(
        GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0
    );
}

// 需要显示的告诉GL有多目标输出
GLuint attachments[2] = { GL_COLOR_ATTACHMENT0,   GL_COLOR_ATTACHMENT1 };
glDrawBuffers(2, attachments);
  • fragment shader中判断亮度 > 1, RGB转灰度值算法,注意系数
void main()
{            
    [...] // first do normal lighting calculations and output results
    FragColor = vec4(lighting, 1.0f);
    // Check whether fragment output is higher than threshold, if so output as brightness color
    float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722));
    if(brightness > 1.0)
        BrightColor = vec4(FragColor.rgb, 1.0);
}
  • 二维高斯模糊,可以转换为两次一维度的高斯模糊,即先做横向高斯采样,再接着做纵向采样

核size = 32时,对每个点模糊:
计算量从32 * 32 ==> 32 + 32

  • 模糊一次效果不好,可以模糊多次(用两个帧缓冲来回倒)

延迟渲染 Deferred Shading

延迟渲染没有用到新的API,帧缓冲、MTR的灵活运用。

  1. 延迟渲染是相对正向渲染而言,其流程:

  • 第一遍光栅化,基于MRT输出G-BUFFER
  • 在屏幕空间做光照计算
  1. 优点:复杂场景下,性能优化明显,因为最后着色只有屏幕空间的像素个数。

  2. 缺点:不能使用MSAA,最主要的原因是最后在着色阶段,没有几何信息参考

SSAO 屏幕空间环境光遮罩 Ambient Occlusion

  1. 原理,生成深度图。对着色点随机取值,判断是否在深度图之内,可见的采样点越多,越亮,否则越暗

  2. 工序经过4道pass

  • G-BUFFER g1,位置、法线、反射率等信息
  • 基于g1,生成SSAO纹理,后面直接座位环境光衰减因子
  • 对g1模糊处理,明暗更柔和
  • 走正常的延迟渲染的最后一步,结合g-buffer着色