计算机图形学编程(使用OpenGL和C++)(第2版)学习笔记 08.阴影
阴影
没有阴影的渲染效果如下,看起来不真实:
有阴影的渲染效果如下,看起来真实:
显示阴影有两种方式,一种是原书中的方式,另一种是采用光线追踪技术,该技术可以参考ShaderToy学习笔记 08.阴影
投影阴影
将物体上的点(xW, yW, zW)变换为相应阴影在平面上的点(xS, 0, zS)。之后将其生成的“阴影多边形”绘制出来,通常使用暗色物体与地平面纹理混合作为其纹理。通常,投影阴影会使用暗色的多边形与地平面的纹理混合,以模拟真实的阴影效果。这种方法简单高效,适用于静态或简单的动态场景,但在复杂场景中可能会显得不够真实。
阴影体
阴影体(Shadow Volume)是一种用于生成阴影的技术,它通过几何体的扩展来定义阴影的体积。具体来说,阴影体是从光源出发,通过物体的几何形状向外延伸,形成一个三维的体积,这个体积代表了光线被遮挡的区域。
在渲染时,阴影体会与场景中的其他几何体进行交互,通过计算哪些像素位于阴影体内,从而确定哪些区域应该被渲染为阴影。这种方法可以生成精确的阴影效果,尤其适用于动态场景。
阴影体的优点是可以生成清晰且准确的阴影边界,但其计算量较大,尤其是在处理复杂几何体时,会影响性能。
阴影贴图
阴影贴图(Shadow Mapping)是一种基于纹理的阴影生成技术,用于在3D场景中模拟光源投射的阴影。其基本原理是通过两次渲染过程来确定场景中哪些区域被遮挡。
阴影贴图基于一个非常简明的想法:光线无法“看到”的任何东西都在阴影中。也就是说,如果对象 1 阻挡光线到达对象 2,等同于光线不能“看到”对象 2。
-
第一步:深度图生成
从光源的视角渲染场景,并记录每个像素到光源的深度值,生成一张深度纹理(称为阴影贴图)。这张贴图表示光源视角下场景中最接近光源的表面。这一步不需要实际渲染场景,可以禁用颜色输出。
OpenGL 提供了两种将深度缓冲区深度数据放入纹理单元的方法。- 生成空阴影纹理,然后使用命令 glCopyTexImage2D()将活动的深度缓冲区复制到阴影纹理中。
- 构建一个“自定义帧缓冲区”(而不是使用默认的深度缓冲区),并使用命令 glFrameBufferTexture()将阴影纹理附加到它上面。OpenGL 在 3.0 版中引入该命令,以进一步支持阴影纹理。使用这种方法时,无须将深度缓冲区“复制”到纹理中,因为缓冲区已经附加了纹理,深度信息由 OpenGL 自动放入纹理中。我们采用这种方法。
-
第二步:阴影检测
在实际渲染时,从摄像机的视角渲染场景。对于每个像素,将其转换到光源的视角,并与阴影贴图中的深度值进行比较。如果像素的深度值大于阴影贴图中的值,则说明该像素被遮挡,应渲染为阴影。
优点:
- 支持动态场景,适合实时渲染。
- 实现相对简单,适用于各种光源类型。
缺点:
- 可能出现锯齿或伪影(如阴影边缘的锯齿),需要使用抗锯齿技术(如PCF)。
- 受分辨率限制,高分辨率阴影贴图会占用更多内存和计算资源。
阴影贴图中的深度值可以用Z-Buffer(深度缓冲区)来表示。
Z-buffer(深度缓冲区)是一种用于处理3D图形渲染中深度信息的技术。它通过记录场景中每个像素的深度值(即距离摄像机的距离)来确定哪些物体或部分应该被显示,哪些应该被遮挡。
工作原理:
- 初始化深度缓冲区:在开始渲染时,Z-buffer 会被初始化为一个默认值(通常是最大深度值)。
- 逐像素深度比较:
- 在渲染每个像素时,计算该像素的深度值。
- 将该深度值与 Z-buffer 中对应位置的值进行比较。
- 如果当前像素的深度值更小(即更靠近摄像机),更新 Z-buffer,并渲染该像素。
- 如果当前像素的深度值更大,则忽略该像素(被遮挡)。
- 最终结果:通过这种逐像素的深度比较,Z-buffer 确保了场景中最近的物体被正确渲染,而远处的物体被遮挡。
优点:
- 高效:适合实时渲染,广泛应用于现代图形硬件。
- 通用性:支持复杂的场景和任意几何形状。
- 自动遮挡处理:无需手动排序物体。
缺点:
- 精度问题:Z-buffer 的精度取决于其位深(如 16 位、24 位等)。在远近平面距离较大时,可能会出现深度冲突(Z-fighting)。
- 内存占用:需要额外的内存来存储深度缓冲区。
策略
- 第一轮渲染:从光源的视角渲染场景,生成深度贴图。
- 第二轮渲染:从摄像机的视角渲染场景,使用深度贴图进行阴影检测。如果某个像素的深度值大于阴影贴图中的值,则该像素被遮挡,渲染为阴影。可以通过仅渲染其环境光,来模拟阴影效果。
阴影贴图 第一轮渲染:从光源的视角渲染场景,生成深度贴图
- 将相机移动到光源的位置
- 配置缓冲区和阴影贴图
- 禁用颜色输出
- 构建LookAt矩阵
- 使用LookAt矩阵和投影矩阵构建模型视图投影矩阵,称为shadowMVP
- 每个顶点无需法线,光照,贴图等uniform变量
- 渲染场景,将深度信息写入阴影贴图。无颜色输出
顶点着色器
#version 430layout (location =0 ) in vec3 position;uniform mat4 shadowMVP; // 变换矩阵
void main()
{gl_Position = shadowMVP*vec4(position,1.0);
}
片段着色器
无需输出颜色。由渲染管线自动完成深度信息写入深度缓冲区的步骤。
#version 430void main()
{}
渲染cpp代码
//shadowMVP 矩阵shadowMVP = lightPmatrix * lightVmatrix * mMat;// 从光源的视角渲染场景,生成深度贴图,相较于前面章节代码glClear(GL_DEPTH_BUFFER_BIT);glEnable(GL_DEPTH_TEST);glEnable(GL_CULL_FACE);glFrontFace(GL_CCW);glDepthFunc(GL_LEQUAL);
第二轮渲染:从摄像机的视角渲染场景,使用深度贴图进行阴影检测
从摄像机的视角渲染场景,使用深度贴图进行阴影检测。如果某个像素的深度值大于阴影贴图中的值,则该像素被遮挡,渲染为阴影。可以通过仅渲染其环境光,来模拟阴影效果。
这中间核心要素是拿到阴影贴图中每个像素的深度值,由于OpenGL相机使用[-1,1]的坐标系,而深度贴图使用[0,1]的坐标系,所以需要采用B 矩阵将摄像机空间转换为纹理空间,即先缩放为1/2,再平移1/2
B = [ 0.5 0 0 0.5 0 0.5 0 0.5 0 0 0.5 0.5 0 0 0 1 ] B=\begin{bmatrix} 0.5& 0 & 0 & 0.5\\ 0 & 0.5& 0 & 0.5\\ 0 & 0 & 0.5& 0.5\\ 0 & 0 & 0 & 1 \end{bmatrix} B= 0.500000.500000.500.50.50.51
即shadowMVP2 = B * shadowMVP
步骤
- 构建B 矩阵
- 启用阴影纹理以进行查找
- 启用颜色输出
- 启用GLSL进行第二轮渲染
顶点着色器
#version 430
#version 430// 点光源结构体定义
struct PositionalLight {vec4 ambient; // 环境光分量vec4 diffuse; // 漫反射分量vec4 specular; // 镜面反射分量vec3 position; // 光源位置
};// 材质结构体定义
struct Material {vec4 ambient; // 材质的环境光反射系数vec4 diffuse; // 材质的漫反射系数vec4 specular; // 材质的镜面反射系数float shininess; // 镜面反射的光泽度
};layout(location = 0) in vec3 position;
layout(location = 1) in vec2 tex_coord;
layout(location = 2) in vec3 vertNormal;
out vec2 tc;out vec3 varyingNormal;
out vec3 varyingLightDir;
out vec3 varyingVertPos;uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;uniform vec4 globalAmbient; // 全局环境光uniform PositionalLight light; // 点光源属性
uniform Material material; // 物体材质属性uniform mat4 shadowMVP; // 光源变换矩阵
layout(binding = 0) uniform sampler2DShadow s;out vec4 shadow_coord;void main(void) {gl_Position = proj_matrix * mv_matrix * vec4(position, 1.0);varyingVertPos = (mv_matrix * vec4(position, 1.0)).xyz;varyingNormal = (norm_matrix * vec4(vertNormal, 1.0)).xyz;varyingLightDir = light.position - varyingVertPos;tc = tex_coord;shadow_coord = shadowMVP * vec4(position, 1.0);
}
片段着色器
其中我们采用textureProj
函数来计算阴影值,该函数计算的是投影纹理坐标,其原理如下:
#version 430// 点光源结构体定义
struct PositionalLight {vec4 ambient; // 环境光分量vec4 diffuse; // 漫反射分量vec4 specular; // 镜面反射分量vec3 position; // 光源位置
};// 材质结构体定义
struct Material {vec4 ambient; // 材质的环境光反射系数vec4 diffuse; // 材质的漫反射系数vec4 specular; // 材质的镜面反射系数float shininess; // 镜面反射的光泽度
};in vec3 varyingNormal;
in vec3 varyingLightDir;
in vec3 varyingVertPos;in vec2 tc;
in vec4 shadow_coord; // 传入的阴影坐标
out vec4 color;uniform mat4 mv_matrix;
uniform mat4 proj_matrix;uniform vec4 globalAmbient; // 全局环境光uniform PositionalLight light; // 点光源属性
uniform Material material; // 物体材质属性uniform mat4 norm_matrix; // 法线变换矩阵
uniform mat4 shadowMVP; // 光源变换矩阵
layout(binding = 0) uniform sampler2DShadow s;void main(void) {//vec4 texColor = texture(s,tc);// 标准化光照计算所需的向量vec3 N = normalize(varyingNormal);vec3 L = normalize(varyingLightDir);vec3 V = normalize(-varyingVertPos);// 计算漫反射分量float lambertTerm = max(0, dot(N, L));vec4 Id = light.diffuse * material.diffuse * lambertTerm;// 计算镜面反射分量//vec3 R = reflect(-L,N);vec3 halfVector = normalize(L + V);float specular = pow(max(dot(N, halfVector), 0), material.shininess);vec4 Is = light.specular * material.specular * specular;// 计算环境光分量vec4 Ia = globalAmbient * material.ambient;// 计算最终颜色值 //采用纹理贴图//color = texColor*(globalAmbient+light.ambient + light.diffuse*(max(dot(N,L),0.0f))) +light.specular*pow(max(dot(N,halfVector),0.0f),material.shininess);float notInShadow = 1.0;notInShadow = textureProj(s, shadow_coord);//采用物体材质if(notInShadow > 0.50) {color = Ia + Id + Is;} else {color = Ia;//color=Ia+Id+Is;}}
左图是没有阴影的,右图是添加阴影的。
伪影
上图中有明显的伪影,或称为阴影痤疮,这是由于深度测试的精度问题导致的。在阴影纹理中查找深度信息时计算的纹理坐标通常与实际坐标不完全一至,因此从阴影纹理中查找到的深度值可能与实际深度值存在一定的误差,当误差超过一定阈值时,就会导致阴影痤疮。
阴影痤疮通常发生在没有阴影的表面,常见的一种解决办法是在第1轮中将每个像素稍微移向光源,之后在第2轮中再移回来,这样就可以消除阴影痤疮。
//减少阴影痤疮glEnable(GL_POLYGON_OFFSET_FILL);glPolygonOffset(2.0f, 4.0f);
这是改善后的效果:
关于阴影贴图
有一个好用的插件可以查看图片文件的16进制数据,Hex Editor
我们可以用以下代码将阴影贴图保存为图片文件,方便我们查看。
void saveShadowMapToFile(const char *filename, GLuint shadowTexture, int width, int height)
{glBindTexture(GL_TEXTURE_2D, shadowTexture);GLfloat *data = new GLfloat[width * height];glGetTexImage(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, GL_FLOAT, data);unsigned char *data2 = new unsigned char[width * height * 3];for (int i = 0; i < width * height; i++){data2[i * 3 + 0] = (unsigned char)(data[i] * 255);data2[i * 3 + 1] = (unsigned char)(data[i] * 255);data2[i * 3 + 2] = (unsigned char)(data[i] * 255);}int success = SOIL_save_image(filename, SOIL_SAVE_TYPE_BMP, width, height, 3, data2);if (success == 0){std::cout << "Error saving shadow map to file: " << SOIL_last_result() << std::endl;}else{std::cout << "Shadow map saved to file: " << filename << std::endl;}delete[] data;
}
下图是保存的阴影贴图:
柔和阴影
柔和阴影是阴影的一种,它不是完全硬的,而是有过渡的,过渡区域是阴影和光照区域的混合区域。
上图是现实中的柔和阴影
上图是柔和阴影的半影效果 ,可以理解为半影区域是柔和阴影的过渡区域,半影区域越宽,过渡越柔和。
生成柔和阴影 -百分比邻近滤波(PCF)
PCF的核心思想是对阴影贴图中的多个相邻像素进行采样并平均其结果,而不是仅仅采样单个像素点。具体来说:
-
多点采样:在进行阴影测试时,不仅仅对当前像素对应的阴影贴图位置进行采样,还会对其周围的多个点进行采样
-
深度比较:对每个采样点进行深度比较,判断是否在阴影中
-
结果平均:将所有采样点的阴影测试结果(0表示阴影,1表示非阴影)进行加权平均
这种方法产生的结果是阴影边缘不再是二元的(要么完全在阴影中,要么完全不在),而是有一个平滑的过渡区域,模拟了现实世界中的半影效果。
上图中黄色点是当前像素,其不在阴影中,其周围的8个点中(含自身总计9个点),有3个点在阴影中,因此该像素的颜色可以是半影。
一种用于实现 PCF 的常见算法是对每个像素附近的 4 个纹元进行采样, 其中样本通过像素对应纹元的特定偏移量选择。 对于每个像素, 我们都需要改变偏移量,并用新的偏移量确定采样的 4 个纹元。使用交错的方式改变偏移量的方法被称为抖动,它旨在使柔和阴影的边界不会由于采样点不足而看起来“结块”。
一种常见的方法是假设有 4 种不同偏移模式,每次取其中一种——我们可以通过计算像素的glFragCoord mod 2 值来选择当前像素的偏移模式。之前有提到, glFragCoord 是 vec2 类型,包含像素位置的 x、 y 坐标。因此, mod 计算的结果有 4 种可能的值: (0,0)、 (0,1)、 (1,0)或(1,1)。我们使用glFragCoord mod 2 的结果来从纹元空间(即阴影贴图)的 4 种不同偏移模式中选择一种。
偏移模式通常在 x 和 y 方向上指定,具有−1.5、 −0.5、 +0.5 和+1.5 的不同组合(也可以根据需要进行缩放)。
sx 和 sy 指与正在渲染的像素对应的阴影贴图中的位置(sx,sy),在本章的代码示例中标识为shadow_coord
抖动的4像素PCF采样示例,图中白色的点即为当前(sx,sy)
我们可以在片段着色器中实现 PCF,代码如下:
片段着色器
#version 430// 点光源结构体定义
struct PositionalLight {vec4 ambient; // 环境光分量vec4 diffuse; // 漫反射分量vec4 specular; // 镜面反射分量vec3 position; // 光源位置
};// 材质结构体定义
struct Material {vec4 ambient; // 材质的环境光反射系数vec4 diffuse; // 材质的漫反射系数vec4 specular; // 材质的镜面反射系数float shininess; // 镜面反射的光泽度
};in vec3 varyingNormal;
in vec3 varyingLightDir;
in vec3 varyingVertPos;in vec2 tc;
in vec4 shadow_coord; // 传入的阴影坐标
out vec4 color;uniform mat4 mv_matrix;
uniform mat4 proj_matrix;uniform vec4 globalAmbient; // 全局环境光uniform PositionalLight light; // 点光源属性
uniform Material material; // 物体材质属性uniform mat4 norm_matrix; // 法线变换矩阵
uniform mat4 shadowMVP; // 光源变换矩阵
layout(binding = 0) uniform sampler2DShadow s;float lookup(float ox,float oy) {// 计算阴影贴图坐标// 注意:此处的0.001 是1/windowSize.x ,目前的windowSize.x=1000 。目前屏幕的大小为1000*1000// 第三个参数-0.01 可用于消除阴影痤疮的偏移量float t=textureProj(s,shadow_coord+vec4(ox*0.001*shadow_coord.w,oy*0.001*shadow_coord.w,-0.01,0.0));return t;
}void main(void) {float shadowFactor = 0.0;//vec4 texColor = texture(s,tc);// 标准化光照计算所需的向量vec3 N = normalize(varyingNormal);vec3 L = normalize(varyingLightDir);vec3 V = normalize(-varyingVertPos);// 计算漫反射分量float lambertTerm = max(0, dot(N, L));vec4 Id = light.diffuse * material.diffuse * lambertTerm;// 计算镜面反射分量//vec3 R = reflect(-L,N);vec3 halfVector = normalize(L + V);float specular = pow(max(dot(N, halfVector), 0), material.shininess);vec4 Is = light.specular * material.specular * specular;// 计算环境光分量vec4 Ia = globalAmbient * material.ambient;// 计算最终颜色值 //采用纹理贴图//color = texColor*(globalAmbient+light.ambient + light.diffuse*(max(dot(N,L),0.0f))) +light.specular*pow(max(dot(N,halfVector),0.0f),material.shininess);// PCF float swidth=2.5; //阴影扩散量的宽度vec2 offset=mod(floor(gl_FragCoord.xy),2.0)*swidth;shadowFactor+=lookup(-1.5*swidth+offset.x,1.5*swidth-offset.y);shadowFactor+=lookup(-1.5*swidth+offset.x,-0.5*swidth-offset.y);shadowFactor+=lookup(0.5*swidth+offset.x,1.5*swidth-offset.y);shadowFactor+=lookup(0.5*swidth+offset.x,-0.5*swidth-offset.y);shadowFactor=shadowFactor/4.0;vec4 shadowColor=Ia;vec4 lightdColor=Id+Is;color=vec4(shadowColor.xyz+lightdColor.xyz*shadowFactor,1.0f);}
重点:
lookup
函数,该函数用于计算阴影贴图中的采样点。- shadowFactor,该变量用于存储阴影贴图中的采样结果,用于计算阴影因子。
运行结果 : 能看到左边的阴影要比右边的阴影更柔和,更自然。
疑问:
- 为什么左边有一块区域是有亮光,但其位置在右边是阴影?
- torus左边是有光亮,这块区域是否应有阴影?即其是否被遮挡?还是因光源位置原因造成torus左边是没有被遮挡且有光亮?
参考
- 学习笔记完整代码下载
- ShaderToy学习笔记 08.阴影