今天我们要进入高级光照的环节了:
Advanced Lighting
之前的学习中,我们的光照模型采用的是比较简单的phong光照模型,也就是光照强度由环境光加上漫反射光加上镜面反射组成。
用一张图足以解释:
就这么简单,针对夹角过大的部分,我们的点积为0,这个时候我们会粗暴地将这部分镜面反射的光照强度设置为0,于是导致了上图中的现象。
采取半程向量和法线的夹角而不是光源与反射光线的夹角的好处就是,我们的夹角永远不会大于90度了,这样避免了突兀的阴影出现的情况。半程向量的获取方法确实非常简单,我们只需要把光线向量和观察向量相加之后归一化即可。
vec3 lightDir = normalize(lightPos - FragPos);
vec3 viewDir = normalize(viewPos - FragPos);
vec3 halfwayDir = normalize(lightDir + viewDir);
这个是GLSL的代码。
float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
vec3 specular = lightColor * spec;
剩下的就是点乘并保证不小于0即可。
我们主要在片元着色器上实现这一步骤:
#version 330 core
out vec4 FragColor;
in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} fs_in;
uniform sampler2D floorTexture;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform bool blinn;
void main()
{
vec3 color = texture(floorTexture, fs_in.TexCoords).rgb;
// ambient
vec3 ambient = 0.05 * color;
// diffuse
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
vec3 normal = normalize(fs_in.Normal);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * color;
// specular
vec3 viewDir = normalize(viewPos - fs_in.FragPos);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = 0.0;
if(blinn)
{
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);
}
else
{
vec3 reflectDir = reflect(-lightDir, normal);
spec = pow(max(dot(viewDir, reflectDir), 0.0), 8.0);
}
vec3 specular = vec3(0.3) * spec; // assuming bright white light color
FragColor = vec4(ambient + diffuse + specular, 1.0);
}
如果我们打开布林-冯开关,我们的镜面反射光的计算公式会发生变化,会额外计算一个半程向量然后与法线点乘。
大体上还是可以看出区别的。
Gamma Correction
既然说到所谓的伽马矫正,那么我们当然优先得知道什么是伽马值。
所谓的伽马值,本质上其实是一个亮度和信号电压的映射关系,更准确地说,这个亮度是我们人眼感受到的亮度。
简单地说就是,我们的伽马矫正是基于我们的显示器的空间来做,而显示器本身的亮度/颜色配置并不是一个线性的配置,中间亮度被压缩,在非线性的空间内进行非线性变换就会导致问题。伽马校正的本质,是通过数学补偿显示器的非线性,确保物理计算在线性空间正确,而最终输出适配显示器的伽马曲线。
OpenGL中内建得有gamma矫正的帧缓冲,我们可以直接使用。
OpenGL中默认设备的伽马值为2.2,我们在OpenGL中开启GL_FRAMEBUFFER_SRGB即可。
第二种做法则是我们手动的去乘以伽马值的倒数次幂,这样的话我们必须手动地对所有片元着色器使用这个伽马矫正方法。同时我们还要注意,自己手动实现伽马矫正还有一些额外的细节需要注意。
我们的纹理是基于显示器的RGB空间来实现的,如果我们采用手动伽马矫正,那么虽然我们的显示器亮度颜色空间变成了线性空间,但是我们的纹理就会收到影响变成完全不同的效果。
OpenGL也为我们做好了这个东西,我们用GL_SRGB来使用纹理即可。
我们来用一个实例体现出有无伽马矫正的区别:
unsigned int loadTexture(const char* path, bool gammaCorrection);
我们在这里将是否开启伽马矫正传进函数中。
这个函数的具体写法是:
unsigned int loadTexture(char const* path, bool gammaCorrection)
{
unsigned int textureID;
glGenTextures(1, &textureID);
int width, height, nrComponents;
unsigned char* data = stbi_load(path, &width, &height, &nrComponents, 0);
if (data)
{
GLenum internalFormat;
GLenum dataFormat;
if (nrComponents == 1)
{
internalFormat = dataFormat = GL_RED;
}
else if (nrComponents == 3)
{
internalFormat = gammaCorrection ? GL_SRGB : GL_RGB;
dataFormat = GL_RGB;
}
else if (nrComponents == 4)
{
internalFormat = gammaCorrection ? GL_SRGB_ALPHA : GL_RGBA;
dataFormat = GL_RGBA;
}
glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, width, height, 0, dataFormat, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
stbi_image_free(data);
}
else
{
std::cout << "Texture failed to load at path: " << path << std::endl;
stbi_image_free(data);
}
return textureID;
}
现在我们来看具体的片元着色器如何修改:
#version 330 core
out vec4 FragColor;
in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} fs_in;
uniform sampler2D floorTexture;
uniform vec3 lightPositions[4];
uniform vec3 lightColors[4];
uniform vec3 viewPos;
uniform bool gamma;
vec3 BlinnPhong(vec3 normal, vec3 fragPos, vec3 lightPos, vec3 lightColor)
{
// diffuse
vec3 lightDir = normalize(lightPos - fragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// specular
vec3 viewDir = normalize(viewPos - fragPos);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// simple attenuation
float max_distance = 1.5;
float distance = length(lightPos - fragPos);
float attenuation = 1.0 / (gamma ? distance * distance : distance);
diffuse *= attenuation;
specular *= attenuation;
return diffuse + specular;
}
void main()
{
vec3 color = texture(floorTexture, fs_in.TexCoords).rgb;
vec3 lighting = vec3(0.0);
for(int i = 0; i < 4; ++i)
lighting += BlinnPhong(normalize(fs_in.Normal), fs_in.FragPos, lightPositions[i], lightColors[i]);
color *= lighting;
if(gamma)
color = pow(color, vec3(1.0/2.2));
FragColor = vec4(color, 1.0);
}
除了之前实现的布林-冯光照模型以外,我们最大的改进就是根据外部传入的bool值伽马来决定是否开启伽马矫正,true的话就让颜色值除以一个2.2即可。
显然:有伽马矫正的图的光线分布更柔合,更偏向中间值,而无伽马矫正的光线看起来更极端。归根结底,还是因为无伽马矫正的场景的光线强度和颜色的曲线不够平缓,较小的亮度变化也会导致巨大的颜色落差。
Shadow Mapping
目前是没有完美的阴影算法的,阴影贴图是其中一种方法来模拟阴影的实现。
简单地说,我们的阴影映射的思路就是从光源处出发进行深度测试,比较场景中各个物体的深度值来判断阴影。
我们来看具体的代码:
const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
// create depth texture
unsigned int depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 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_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// attach depth texture as FBO's depth buffer
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);
效果如图:
这样我们就完成了基于光源的深度测试,我们现在在此基础上进行阴影渲染:
// 1. 渲染深度贴图
simpleDepthShader.use();
simpleDepthShader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, woodTexture);
renderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. 用深度贴图渲染主场景(有阴影)
shader.use();
shader.setInt("diffuseTexture", 0);
shader.setInt("shadowMap", 1);
...
renderScene(shader);
这时候我们就实现了基本的阴影渲染了,这个渲染的过程就是我们先根据之前生成的深度贴图获取到各个片元到摄像机的深度值,然后我们再正常进行渲染的过程,如果这个片元处于阴影之下(深度值更大)我们就把该片元的亮度减小,也就是形成阴影的效果,这样我们就能实现基本的阴影效果了,但是这个效果很“硬”,也就是边缘会产生锯齿。
如何生成所谓的“软”阴影呢?这个时候我们就要多一些对阴影本身的处理:阴影偏移(Bias)和PCF软阴影(多点采样模糊)技术。
// 计算偏移
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
// PCF软阴影
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
for(int x = -1; x <= 1; ++x)
{
for(int y = -1; y <= 1; ++y)
{
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;
效果如图:
Point Shadows
之前我们提到的阴影贴图有关的内容都是基于平行光假设来做的,但是显然我们还要考虑其他光照模型的阴影如点光源:
算法和定向阴影映射差不多:我们从光的透视图生成一个深度贴图,基于当前fragment位置来对深度贴图采样,然后用储存的深度值和每个fragment进行对比,看看它是否在阴影中。定向阴影映射和万向阴影映射的主要不同在于深度贴图的使用上。
对于深度贴图,我们需要从一个点光源的所有渲染场景,普通2D深度贴图不能工作;如果我们使用立方体贴图会怎样?因为立方体贴图可以储存6个面的环境数据,它可以将整个场景渲染到立方体贴图的每个面上,把它们当作点光源四周的深度值来采样。
// 片元着色器
float ShadowCalculation(vec3 fragPos)
{
vec3 fragToLight = fragPos - lightPos;
float closestDepth = texture(depthMap, fragToLight).r;
closestDepth *= far_plane;
float currentDepth = length(fragToLight);
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
return shadow;
}
换句话说,针对点光源和平行光源的阴影贴图实现最大的差异就是我们如何去使用深度贴图:对于平行光或者说定向光来说,我们每个像素只用考虑一个方向的深度;但是对于点光源来说,每个像素实际的接收到光照的方向不同,因此需要一个更全面的量化深度的方式:
其实思路也很简单:我们用六张深度贴图组成一个立方体深度贴图即可,这样每个深度贴图都能获取一个深度值,这样的话针对这六个方向我们也能分别生成定向光一样的阴影,从而达到效果如图:
当然,我们依然可以进行软阴影的升级:
float shadow = 0.0;
float bias = 0.15;
int samples = 20;
float viewDistance = length(viewPos - fragPos);
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;
for(int i = 0; i < samples; ++i)
{
float closestDepth = texture(depthMap, fragToLight + gridSamplingDisk[i] * diskRadius).r;
closestDepth *= far_plane;
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
shadow /= float(samples);