欢迎关注公众号:sumsmile /专注图形学
花了几天时间,整理了以前写的learnopengl笔记,做了删减、补充。
learnopengl地址:learnopengl-英文-pdf、learnopengl-中文、learnopengl-在线英文
高级光照
Gamma校正
参考之前的一篇文章:理解gamma校正[译],讲清楚了为什么要有Gamma校正
gamma校正有两种方案:
- 开启GL_FRAMEBUFFER_SRGB简单的调用glEnable就行:
glEnable(GL_FRAMEBUFFER_SRGB);
开启GL_FRAMEBUFFER_SRGB,在颜色储存到颜色缓冲之前先校正sRGB颜色。sRGB这个颜色空间大致对应于gamma2.2,它也是家用设备的一个标准
- 片段着色器中手动实现
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
- 阴影的实现原理
- 光照方向作为相机,生成深度缓冲
// 第一遍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)将阴影图的深度做一个偏移:
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
剔除正面也有限制条件,即物体必须有厚度。即利用物体的厚度非常巧妙的、降低了深度缓冲的值
- 阴影缺失
- 阴影面积太小,导致超出阴影面积的地方采样到的值不正确,比如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;
}
虽然也不对,但是比黑色的要好,而且处于比较远的地方,不太对总比黑的要好。
- 阴影锯齿优化,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的基础上,增加了动态计算采样半径,此处不做展开了
法线贴图
- normal map的作用:使用法线贴图能更好的展示细节
- 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教程上写的即可,教程中求矩阵的逆时,用的事伴随矩阵的方式
- TBN矩阵&法线贴图的使用
有两种方式:
- 第一种:按上面说的,在片段着色器中,将每个采样出来的法线经过TBN变换到世界坐标系中,后面的光照和普通的光照着色一样
- 第二种:将光照、顶点通过TBN逆矩阵,变换到法线贴图的默认空间
第二种看起来更复杂,需要求逆,需要求光照、顶点的变换,但是减少了片段着色器的计算,整体效率更优秀
顶点着色器只需要对少量的有限的顶点进行计算,OpenGL管线通过插值得到平面上每个点的属性,最后到片段着色器中。片段着色器需要对每个插值出来的点进行计算,虽然有并发,但是还是得分批次并发。
shader实现参考:
tbn vertex shader
tbn fragment shader
视差贴图
视差贴图也叫位移贴图,通过偏移采样纹理得到凹凸不平的效果。和法线贴图一样,也用到了TBN空间转换。
- 视差贴图原理
对A点进行着色时,实际看到的是B点,因为凸起挡住了A点,看到的是斜面上的B点
一种近似的O(1)算法,以观察点A的高度作为缩放因子,对view方向放大,得到B出的坐标
bad case,比较陡峭的地方,和实际的位置误差较大
- 实际用到的视差贴图
实际用到的视差贴图是反过来的,高度图变成深度图,即越低的地方现在值越大。
想想下,将一个柔软的被单铺在一块石头上,被单顺着石头塌下去,形成一个凸起,被单上的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点偏移的越多
- 优化视差贴图的边缘失真
采样超出了边界,导致失真,使用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;
- 陡峭视差贴图 (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)));
- 视差遮蔽映射(Parallax Occlusion Mapping)
在陡峭视差贴图 (Steep Parallax Mapping)基础上,对找到的两个临界值按按权重做插值,效果更接近真实的情况
本节代码参考learnGl原文,没有复杂的逻辑。
HDR(High Dynamic Range, 高动态范围)
屏幕显示颜色会约束到[0,1]之间,如果场景中有很多超过1的color值,比如10、15、100都会约束到1,体现不出真实的纹理。
- 实现流程
-
把场景渲染到自定义的帧缓冲,帧缓冲可以设置color的取值范围,默认的窗口缓冲不支持设置精度,默认是一个字节8位,自定义缓冲可以设置16或32位浮点类型。另外,即使默认的缓冲支持设置数据类型,最好也通过自定义帧缓冲生成一张2维纹理,最后只需要针对2维纹理做映射计算,性能上有数量级的提升
-
切回到默认缓冲,把帧缓冲渲染到默认窗口,同时,在shader中增加Reinhard(混合渲染)色调映射算法,调整颜色范围
- reinhard映射算法
// reinhard
vec3 result = hdrColor / (hdrColor + vec3(1.0));
- 曝光映射算法
vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
泛光(bloom)
- bloom实现流程:
- MRT(Multiple Render Targets,多渲染目标)输出两个颜色缓冲:一张完整的,一张提取高亮部分
- 对高亮部分做高斯模糊得到模糊图
- 模糊图 + 完整图 => 最后上屏的缓冲
- 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的灵活运用。
- 延迟渲染是相对正向渲染而言,其流程:
- 第一遍光栅化,基于MRT输出G-BUFFER
- 在屏幕空间做光照计算
-
优点:复杂场景下,性能优化明显,因为最后着色只有屏幕空间的像素个数。
-
缺点:不能使用MSAA,最主要的原因是最后在着色阶段,没有几何信息参考
SSAO 屏幕空间环境光遮罩 Ambient Occlusion
-
原理,生成深度图。对着色点随机取值,判断是否在深度图之内,可见的采样点越多,越亮,否则越暗
-
工序经过4道pass
- G-BUFFER g1,位置、法线、反射率等信息
- 基于g1,生成SSAO纹理,后面直接座位环境光衰减因子
- 对g1模糊处理,明暗更柔和
- 走正常的延迟渲染的最后一步,结合g-buffer着色