OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping

文章目录

  • Basic shadowmap - shadow map的基础知识
    • Rendering the shadow map - 渲染shadow map
      • Setting up the rendertarget and the MVP matrix - 设置渲染目标和MVP矩阵
      • The shaders - Shader着色器
      • Result - 渲染结果
    • Using the shadow map - 使用shadow map
      • Basic shader - 简单的shader
      • Result - Shadow acne - 渲染结果的阴影粉刺
  • Problems - 问题
    • Shadow acne - 阴影粉刺
    • Peter Panning - 彼得·潘宁
    • Aliasing - 锯齿
      • PCF - 靠近边缘百分比滤波
      • Poisson Sampling - 泊松采样
      • Stratified Poisson Sampling - 分层泊松采样
  • Going further - 进一步探索
    • Early bailing - 提前判定
    • Spot lights - 聚光灯
    • Point lights - 点光源
    • Combination of several lights - 多组光源的使用
    • Automatic light frustum - 自适应的光源视锥体
    • Exponential shadow maps - 指数分布阴影图
    • Light-space perspective Shadow Maps - 光源空间的透视Shadow Maps
    • Cascaded shadow maps - 级联阴影图
    • Conclusion - 总结

最近在学习数学的一些基础知识,发现内容超级多,有时学累了,还是看看别的,再继续学习,效果会好一些,好了,今天就学习一下OpenGL中实现Shadow mapping的内容,翻译一篇文章。

能力有限,如有错误,欢迎指正。

原文:Tutorial 16 : Shadow mapping

(翻译到一般,不小心刷新了原文网页,然后刚好原文网站维护,一直打不开,404之类的,过了大半天,我每隔一小时刷新一下,翻译过程也是断断续续的,终于2020.04.09 18:22又可以打开了,那么继续翻译吧)

在Tutorial 15我们学习了如何创建包含静态光的lightmaps。它能生成非常好的阴影,但对于会动的对象是没用的。

Shadow map是当今(2016)用于创建动态阴影的方法。还好的是它的工作方式是非常的简单的。不好的是,想要它的效果处理好是很难的。

在此教程中,我们将介绍基础的算法知识、缺点,以及实现上的一些技巧来得到更好的效果。以前(2012)要编写实现shadow maps还是一个需要深入研究的话题,现在我们将告诉你如何实现,并根据你想要的效果进一步优化你的shadowmap。

Basic shadowmap - shadow map的基础知识

shadow map的基础算法包含两个pass。首先,在光源的视角下渲染场景。仅计算每个片段的深度。下一步是,与平常一样的渲染场景,但会多了一步去测试当前的片段是否在阴影中。

“判断在阴影”的检测是非常的简单的。如果当前的渲染片段比shadow map上对应的片段的深度距离还要远,这意味着场景中还有其他更靠近光源的对象挡住了。换句话说,就是当前的片段处于阴影中。

如下图,可能会帮助你理解原理:
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第1张图片

Rendering the shadow map - 渲染shadow map

在次教程中,我们只考虑方向光 - 光源假设是非常远的,并且光的射线都假设是平行的。所以,完成shadow map的渲染是使用正交投影矩阵的。正交矩阵就像一个透视矩阵一样,但没有透视效果 - 无论对象的远近,看起来都是一样的。

Setting up the rendertarget and the MVP matrix - 设置渲染目标和MVP矩阵

在Tutorial 14你知道如何渲染场景到一张纹理,让后续的shader可以访问到。

这里我们使用1024x1024 16位的深度格式的纹理来包含shadow map纹理。16位已经够用于shadow map了。你可以随意去更改这时配置值。注意我们使用的是一个深度纹理,而不是深度渲染缓存,因为我们后续还要对它采样。

// The framebuffer, which regroups 0, 1, or more textures, and 0 or 1 depth buffer.
// 创建framebuffer帧缓存对象,并重新编组0个或1个,或是更多的纹理,与0个或1个深度缓存
 GLuint FramebufferName = 0;
 glGenFramebuffers(1, &FramebufferName);
 glBindFramebuffer(GL_FRAMEBUFFER, FramebufferName);

 // Depth texture. Slower than a depth buffer, but you can sample it later in your shader
 // 深度纹理。比深度缓存慢一些,但可以用于后续的shader来采样
 GLuint depthTexture;
 glGenTextures(1, &depthTexture);
 glBindTexture(GL_TEXTURE_2D, depthTexture);
 glTexImage2D(GL_TEXTURE_2D, 0,GL_DEPTH_COMPONENT16, 1024, 1024, 0,GL_DEPTH_COMPONENT, GL_FLOAT, 0);
 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_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);

 glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthTexture, 0);

 glDrawBuffer(GL_NONE); // No color buffer is drawn to.
 // 不需要绘制颜色缓存

 // Always check that our framebuffer is ok
 // 总是需要检测framebuffer是OK的
 if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
 return false;

MVP矩阵用于渲染场景用的,它是在光源位置的视角上计算的,如下:

  • 投影矩阵(Project Matrix)是一个正交矩阵,它将包含所有的东西在一个轴对齐的盒子中,X,Y,Z轴分别是:(-10,10),(-10,10),(-10,20)的大小。这些值的设置将会让我们的整个场景总是可见的范围;这些内容在后续的章节会有进一步说明。
  • 视角矩阵(View Matrix)用于旋转整个世界到相机空间,光源的方向就是的-Z(你可以重新阅读Tutorial 3)
  • 世界矩阵(Model Matrix/World Matrix)根据你想要的来设置就好。
glm::vec3 lightInvDir = glm::vec3(0.5f,2,2);

 // Compute the MVP matrix from the light's point of view
 // 从光源的视角来计算MVP矩阵
 glm::mat4 depthProjectionMatrix = glm::ortho<float>(-10,10,-10,10,-10,20);
 glm::mat4 depthViewMatrix = glm::lookAt(lightInvDir, glm::vec3(0,0,0), glm::vec3(0,1,0));
 glm::mat4 depthModelMatrix = glm::mat4(1.0);
 glm::mat4 depthMVP = depthProjectionMatrix * depthViewMatrix * depthModelMatrix;

 // Send our transformation to the currently bound shader,
 // in the "MVP" uniform
 // 给当前的绑定使用的shader设置uniform MVP
 glUniformMatrix4fv(depthMatrixID, 1, GL_FALSE, &depthMVP[0][0])

The shaders - Shader着色器

这个pass的shader非常简单。顶点着色器就只是简单的计算顶点位置到齐次坐标就好了:

#version 330 core

// Input vertex data, different for all executions of this shader.
// 输入的顶点数据,每个执行的shader都不同
layout(location = 0) in vec3 vertexPosition_modelspace;

// Values that stay constant for the whole mesh.
// 整个mesh渲染时都是保持不变的常量
uniform mat4 depthMVP;

void main(){
 gl_Position =  depthMVP * vec4(vertexPosition_modelspace,1);
}

片段着色器就更加简单:将片段的深度写入到布局限定符为0的寄存器上(例子中是我们的深度纹理中)。

#version 330 core

// Ouput data
// 输出的数据
layout(location = 0) out float fragmentdepth;

void main(){
    // Not really needed, OpenGL does it anyway
    // 其实不需要处理,OpenGL不论如何都会处理的
    fragmentdepth = gl_FragCoord.z;
}

渲染shadow map通常是比普通的渲染快两倍的,因为仅仅写入的是低精度的深度,而不是深度与颜色;处于GPU中内存带宽通常是最大的性能状态。

Result - 渲染结果

渲染结果的纹理看起来是这样的:
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第2张图片

灰暗的颜色意味着一个很小的z值;因此,墙的右上角是比较近于相机的。相反,白色意味着z=1(在齐次坐标),就是非常远的片段。

Using the shadow map - 使用shadow map

Basic shader - 简单的shader

现在我们回到shader部分。每个我们计算的片段,我都必须测试它处于shadow map纹理的“背后”与否。

为了做到这点,我们需要计算当前片段的位置处于在我们创建shadow map时的空间下的位置。所以我们需要变换一次处理:将MVP矩阵与depthMVP矩阵变换一次。

这里有一些技巧。顶点的坐标在应用depthMVP矩阵变换后我们将得到齐次坐标,它的作为值范围都在[-1,1]之间的;但是纹理的采样都范围都必须是[0,1]范围的。

就这个例子而言,一个片段要处于屏幕中间将会是(0,0)的齐次坐标;但采样纹理的中间的话,UV坐标是(0.5,0.5)。

这个调整可以修正直接渲染时得到的片段着色器中的片段坐标,但可以乘以下面的矩阵可以更高效的处理,就是简单的除以了一个2(就是矩阵对角上的系数:将[-1,1]->变化为[-0.5,0.5])然后在平移他们(矩阵中最后的一行,将:[-0.5,0.5]->变化为[0,1])

(译者jave.lin:如果还看不懂我就简单的描述一下:就是对应 ( ( − 1 , 1 ) × 0.5 ) + 0.5 = ( 0 , 1 ) ((-1,1)\times 0.5)+0.5=(0,1) ((1,1)×0.5)+0.5=(0,1),不过它是在CPU层对depthMVP中处理的,在片段着色器就不用每个片段都再执行一次这个转换了,这就是合理使用矩阵复合运算,并将这些公共部分提取到CPU层的威力)

glm::mat4 biasMatrix(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 0.5, 0.0,
0.5, 0.5, 0.5, 1.0
);
glm::mat4 depthBiasMVP = biasMatrix*depthMVP;

现在我们可以编写顶点着色器了。他还是和我们之前的一样,但我们将有2个输出数据而不是1个了:

  • gl_Position 是顶点坐标处于camera下的坐标。
  • ShadowCoord 是顶点坐标处于light space下的坐标(光源空间下)
// Output position of the vertex, in clip space : MVP * position
// 输出顶点的坐标,在clip space下:MVP * position
gl_Position =  MVP * vec4(vertexPosition_modelspace,1);

// Same, but with the light's view matrix
// 同样的,但这是光源视角矩阵下的
ShadowCoord = DepthBiasMVP * vec4(vertexPosition_modelspace,1);

片段着色器是非常简单的:

  • texture(shadowMap, ShadowCoord.xy).z 是光源与最近的深度遮挡对象的距离。
  • ShadowCoord.z 是光源与当前渲染片段的距离。

所以,如果当前渲染的片段比shadowmap中深度遮挡对象的距离值大的话,就意味着处于阴影中(或是说:有其他的对象比当前渲染的对象更加靠近与光源):

float visibility = 1.0;
if ( texture( shadowMap, ShadowCoord.xy ).z  <  ShadowCoord.z){
    visibility = 0.5;
}

当然我们对应的shader也要调整一下。环境光 ambient color不需要调整,它是为了模拟环境下的各个方向的光照,就算处于阴影也有的光照。所以不用处理

color =
 // Ambient : simulates indirect lighting
 // 环境光:模拟间接光
 MaterialAmbientColor +
 // Diffuse : "color" of the object
 // 漫反射:对象的漫反射率颜色
 visibility * MaterialDiffuseColor * LightColor * LightPower * cosTheta+
 // Specular : reflective highlight, like a mirror
 // 高光:反射高光,就是镜面高光一样
 visibility * MaterialSpecularColor * LightColor * LightPower * pow(cosAlpha,5);

Result - Shadow acne - 渲染结果的阴影粉刺

(哈哈,就叫阴影粉刺吧,这英文单词的形容我也是醉了)

这里是之前代码的运行结果。显然,目标思路是达成了,但是质量是无法接受的。
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第3张图片
让我们看看这图像上的每个问题。这份代码有两个工程:shadowmapsshadowmaps_simple;使用哪个就根据你的喜好了。使用simple版本的就是如上图那样丑陋的图像,但是很便于理解的。

Problems - 问题

Shadow acne - 阴影粉刺

非常明显的问题,这个叫:shadow acne - 阴影粉刺:
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第4张图片
这个现象用一张图来简单解释:
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第5张图片
译者jave.lin:这张图可能描述的不够详细,这里我再次重新描述 START

我重新画另几张图:下面是图1:
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第6张图片
首先,ShadowMap纹理的尺寸我们是可以控制的,但不能太大,也不能太小;太大了,内存吃不消,太小了就明显的马赛克,或是可容易出现shadow acne阴影粉刺。

所以上图图1中,我们假设对应的shadow map纹理中的3个像素

再一张:图2
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第7张图片

上图图2中,我们假设绘制帧缓存的分辨率比对应的像素有5个

再一张:图3
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第8张图片

上图图3中,我们将绘制当前的片段坐标转换到光源空间上,片段对应的像素中,可以看到2,4号片段对应的shadow map的采样都比当前片段的深度要小(要靠近光源),所以导致误判了2,4号片段是在阴影中的,正确的结果阴影是1到5号像素都是处于光照中的像素的,没有一个是在阴影中的。

解决办法是:将当前绘制片段的深度减去一点点值来偏移,让当前绘制的片段更靠近相机,但这个偏移值不能太大,否能会导致其他的问题,下面会描述到。

也有另一种解决方法,就是shadow map采样的是几何体的背面的深度,但也有一些问题,如果被采集的几何体的体积为零:即:片面,那么无论是正面,还是背面的shadow map值都是一样的。

正背面距离太近也会有这些问题。

OK,下面继续翻译吧。

有兴趣的同学也可以看看:知乎的这篇发问:关于Shadow Mapping产生的Shadow Acne,我的理解是不是有问题?

译者jave.lin:这张图可能描述的不够详细,这里我再次重新描述 END

通常“修复”这个问题就只要添加一个容差间隙值(error margin):我们仅对当前绘制的片段深度(再次说明,在光源空间下的深度)大于shadow map(lightmap)中的采样深度值,才着色处理:

float bias = 0.005;
float visibility = 1.0;
if ( texture( shadowMap, ShadowCoord.xy ).z  <  ShadowCoord.z-bias){
    visibility = 0.5;
}

结果已经好很多了:
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第9张图片

然后,你可能注意到,由于我们的bias 变量偏移了深度后,墙体和地面之间出现了一些更糟糕的问题。相对在地面来说,0.005bias偏移量看起来是够大的,但对于曲面来说还是不够:在圆柱体和球体上,还是有一些瑕疵。
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第10张图片
一个比较通用的方法是:根据倾斜度来修改bias

float bias = 0.005*tan(acos(cosTheta)); // cosTheta is dot( n,l ), clamped between 0 and 1
// cosTheta 是由:dot( n,l )得来的,并将值夹合到 0 到 1 之间,n:法线,l:光源方向
bias = clamp(bias, 0,0.01);

阴影粉刺现在消失了,就算是曲面上的也没了:
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第11张图片
还有另一个技巧,可能有用也可能没用,依赖于你的几何体(这里就是我上面所说的,如果你的几何体是一个片面,这时正背面的shadowmap值都是一样的),就是仅渲染背面信息到shadow map中。这让我们不得不弄一个厚的墙体(下一小节-Peter Panning),但至少阴影粉刺将不会在表面上出现了。
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第12张图片
当渲染shadow map是,剔除正面三角:

// We don't use bias in the shader, but instead we draw back faces,
// 我们不在shader中使用bias来偏移,而使用绘制背面的方式。
// which are already separated from the front faces by a small distance
// 这本身会与正面有一小段距离
// (if your geometry is made this way)
// (如果你的几何体是这种方式来创建的,即:不是零体积的面片)
glCullFace(GL_FRONT); // Cull front-facing triangles -> draw only back-facing triangles
// 剔除正面三角 -> 仅绘制背面三角

而正常的渲染场景时(剔除背面)

glCullFace(GL_BACK); // Cull back-facing triangles -> draw only front-facing triangles
// 剔除背面三角 -> 仅绘制正面三角

这种方式的话,就需要用到bias

Peter Panning - 彼得·潘宁

(译者jave.lin:为何叫:Peter Panning,就是小时候看的:《小飞侠》国外卡通片的主角名字,下面会讲到,就是一个术语
)

现在没有阴影粉刺了,但在地板仍然还是有一些错误的着色,导致墙体像是漂浮起来似的(因此术语叫:“Peter Panning”《小飞侠》国外卡通片的主角名字)。实际上,添加了bias偏移后会让结果更糟糕。
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第13张图片
这个问题非常容易修复:避免太薄的几何体。这有两个优点:

  • 第一:解决了 Peter Panning的问题:几何体厚度比你的bias大,就好了。
  • 第二:你可以开回剔除背面来渲染lightmap(或是叫shadowmap),因为现在,有另一个面向光源的多边形了,这个多边形就可以挡住另一面,这就不需要开启背面剔除来渲染了。

(我不知道是我对原文这两点的翻译理解有误,还是这两点的描述本身是很有问题的:第一点,bias是为了处理一个应该全亮的表面出现了acne粉刺的问题,而不是为了解决墙体与地板的看似腾空的问题。第二点,使用剔除正面的方式来绘制shadow map,就可以不使用bias的方式来偏移当前绘制的片段深度来消除acne,因为绘制几何体的背面来写入shadow map后,shadow map的深度值通常就会比正面的表面要大,只要几何体的厚度比分辨率导致失真的深度差大的话,就可以不使用bias来偏移了。)

缺点是,你需要绘制更多的三角形了
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第14张图片

Aliasing - 锯齿

前面用过了两个技巧,但现在我们还是注意到阴影的边界有锯齿。话句话说,就是一个像素是白色的,然后旁边的另一个是黑色的,它们之间没有平滑过渡。
在这里插入图片描述

PCF - 靠近边缘百分比滤波

(全称是:Percentage Closer Filter)
最简单改进的方式就是调整shadowmap的采样器sampler2DShadow。然后当你每次对shadowmap进行采样时,其实硬件会采样到附近的纹素,然后比较它们数据,最后使用bilinear滤波后返回[0,1]之间的浮点数。

例如,0.5意味着采样了2个是在阴影里的,和2个是在光照里的。

注意这与depth map中的单采样不同!比较总是范围true或false;PCF返回的是4个"true or false"的插值。
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第15张图片
如你所看到的,阴影的边界平滑了,但还是能看到shadowmap中的纹素块。

Poisson Sampling - 泊松采样

一个简单方式来处理就是采样N次shadowmap,而不是采样一次。使用PCF滤波组合,这将会得到很好的结果,就算是少量的N次。这里的代码是采样4次:

for (int i=0;i<4;i++){
  if ( texture( shadowMap, ShadowCoord.xy + poissonDisk[i]/700.0 ).z  <  ShadowCoord.z-bias ){
    visibility-=0.2;
  }
}

poissonDisk 是一个数组,定义如下:

vec2 poissonDisk[4] = vec2[](
  vec2( -0.94201624, -0.39906216 ),
  vec2( 0.94558609, -0.76890725 ),
  vec2( -0.094184101, -0.92938870 ),
  vec2( 0.34495938, 0.29387760 )
);

这方式取决于当前绘制的片段对应在shadowmap中附近的纹素是否在阴影,来让结果越来越暗:
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第16张图片
700.0 常数是定义采样的分散距离程度。分散距离太小的话,就会再次出现锯齿;太大的话,就会出现:“带条(截图中的没有使用PCF,而是其他16次采样方式,来让这个带条的现象更为明显一些)”
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第17张图片

Stratified Poisson Sampling - 分层泊松采样

我们可以对每个像素采样时选择不同的采样方式来消除这些带条。主要有两种方法:Stratified Poisson 或是 Rotated Poisson(旋转泊松)。Stratified 分层选择不同的采样;Rotated 旋转总是使用相同的,但使用一个随机旋转来让结果看起来不同。在这个教程中我将仅解释stratified分层的版本。

与前面两种方式不同的仅仅是我们使用了一个随机的index索引来对poissonDisk取值:

for (int i=0;i<4;i++){
    int index = // A random number between 0 and 15, different for each pixel (and each i !)
    			// 一个0到15之间的随机整数,每个像素都可能是不一样的(在每第i个)
    visibility -= 0.2*(1.0-texture( shadowMap, vec3(ShadowCoord.xy + poissonDisk[index]/700.0,  (ShadowCoord.z-bias)/ShadowCoord.w) ));
}

我们可以下面的代码来生成一个随机数,返回的数值在[0,1]之间的浮点数:

float dot_product = dot(seed4, vec4(12.9898,78.233,45.164,94.673));
return fract(sin(dot_product) * 43758.5453);

在我们这例子中,seed4可以有其他数值组合得来(为了采样4个不同的采样位置)。我们可以使用 gl_FragCoord(屏幕中像素的位置),或是 Position_worldspace(世界坐标):

//  - A random sample, based on the pixel's screen location.
//  - 一个随机蔡洋,基于像素的屏幕坐标。
//    No banding, but the shadow moves with the camera, which looks weird.
//    不会有带条,但是阴影会随着相机的看的角的或是一直而抖动
int index = int(16.0*random(gl_FragCoord.xyy, i))%16;
//  - A random sample, based on the pixel's position in world space.
//  - 一个随机蔡洋,基于像素的世界坐标
//    The position is rounded to the millimeter to avoid too much aliasing
//    位置控制在毫米内,防止过多的失真锯齿
//int index = int(16.0*random(floor(Position_worldspace.xyz*1000.0), i))%16;

这将会消除掉前面的哪些锯齿,带条的问题,使用一个消耗不低的噪点。但尽管如此,也好过前面的哪些问题。
OpenGL - 阴影映射 - Tutorial 16 : Shadow mapping_第18张图片
查看 tutorial16/ShadowMapping.fragmentshader ,了解三种实现的例子。

Going further - 进一步探索

尽管之前我们用了这么多的技巧,但其实还有非常非常多的方式来提升阴影的效果,下面是一个比较常见的:

Early bailing - 提前判定

这个不好翻译,如果其他同学有更好的翻译可以说一下,欢迎并谢谢

对每个片段的采样可能不是固定的16次(因为采样数量太多了),而是先采样偏离比较远的数据。如果全都在光照下或是阴影下,那么你可以认为16次采样的结果可能都是相同的:这就叫:bail early(提前判定)。如果有其中一些数据是不同的,那么很有可能是处于阴影边界,这时再去采样16次周边的数据。

Spot lights - 聚光灯

处理spot lights聚光灯仅需要很少的改动。最明显的改动就是将正交的投影矩阵改成透视投影矩阵:

glm::vec3 lightPos(5, 20, 20);
// 这里没有注释我就补上:45 度 fov, 1的aspector = width / height, 2 : near, 50 : far
glm::mat4 depthProjectionMatrix = glm::perspective<float>(glm::radians(45.0f), 1.0f, 2.0f, 50.0f);
glm::mat4 depthViewMatrix = glm::lookAt(lightPos, lightPos-lightInvDir, glm::vec3(0,1,0));

使用的是透视截头锥体而不是正交椎体。使用 texture2Dproj来得到 perspective-divide(透视除法)的结果(查看 tutorials 3 - Matrices 教程3 - 矩阵 的脚注)

第二步的在shader中的透视除法。(查看 tutorials 3 - Matrices 教程3 - 矩阵 的脚注。简单的说,perspective projection matrix 透视投影矩阵准确来说根本没有透视。这是由硬件完成的,它是除以投影矩阵变换后的坐标的w的来完成透视的。在这里,我们在shader在模拟这些变换,所以我们手动的执行透视除法(perspective-divide)。顺便说说,正交投影矩阵总是得到齐次坐标的w为1,这就是为何没有透视的原因)

这里有两个方法在GLSL中实现。第二种使用的是内置的 textureProj 函数,但两种方式的结果都是一样的:

if ( texture( shadowMap, (ShadowCoord.xy/ShadowCoord.w) ).z  <  (ShadowCoord.z-bias)/ShadowCoord.w )
if ( textureProj( shadowMap, ShadowCoord.xyw ).z  <  (ShadowCoord.z-bias)/ShadowCoord.w )

Point lights - 点光源

同样的,它只不过是使用cubemap来存depth深度。一个cubemap包含6个纹理,cube上每个面向各一个;还有,采样不是使用UV坐标,而是3D向量来表示采样的方向。

深度值储存在空间中的所有方向,这样才能让点光源的所有方向都有阴影数据。

Combination of several lights - 多组光源的使用

处理多光源的算法,要记住的是每个灯光都需要额外的渲染场景内容来生成shadowmap。这将需要巨量的内存来处理阴影,而你的 bandwidth-limited 带宽限制很快就成为瓶颈。

Automatic light frustum - 自适应的光源视锥体

在这教程中,光源的视锥体是手动的调整参数来包含整个场景的。这种方式当然是有限制的,需要避免这样使用。如果你的地图有 1Km x 1Km 尺寸,而你的shadowmap 1024x1024才处理1平方米的阴影数据;那就真的太挫的方式了。光源的投影矩阵应该尽可能的小。

例如,spot lights 聚光灯的话,它是很经常随意改变投射范围的。

Directional lights 方向光,就想太阳光一样,这就有更多的技巧了:就是真的对整个场景做光照。这里有一个计算光源视锥体的方法:

  1. Potential Shadow Receivers,或简写:PSRs,就是指渲染对象同时在光源视锥体,相机视锥体,和场景BB(Bounding Box包围盒)中的对象。就想它的名字一样,就是这些对象是很有可能会处于阴影的:他们在Camera相机与光源下都是可见的。
  2. Potential Shadow Casters,或简写:PSCs,就是指渲染对象包括上面的所有PSRs对象,再加上它们与光源之间的所有对象(译者jave.lin:这里我有点懵,不就是光源视锥体内的对象吗?PSRs有包含了啊)(就是那些本身看不见但投影的阴影能看见的对象)。

所以,要计算light projection matrix(光源投影矩阵),需要拿到所有可见的对象,删除那些太远的,并计算他们的包围盒;添加的对象是处于包围盒与光源之间的,并计算新的包围盒(但这次要与光源的方向对齐)。

(上面说的有些复杂,不太清楚,简单理解为:只要同时处于:object in Camera frustum && object in Light frustum && object in Scene Portion Bounding Box的对象,然后将这些对象的质点坐标都分别转换Light Space下,在使用每个对象的AABB来重新组合一个大的AABB,这个AABB就的大小就决定了Light Project Matrix的各个参数就OK了。)

这些对象集合的精确计算会射击到凸包相交检测,但这个方法会更简单来实现。

这种方法会导致阴影有跳动的问题,当某个对象从视锥体消失时,因为这样可能会导致shadowmap的分别率突然提升。Cascaded Shadow Map(译者jave.lin:级联阴影映射,也是我们经常看到的术语:CSM)就不会有这个问题,但是比较难实现,而且你仍然需要通过时间对阴影过渡平滑处理。

Exponential shadow maps - 指数分布阴影图

指数分布阴影图根据处于阴影但又靠近照亮表面的片段来处理锯齿的,就是一些“处于中间部分”的片段。这与bias偏移有关,但判定阴影结果不再是两种:片段在离照亮的表面越来越远时,片段就会越来越暗。

这很明显时一种模拟的方式,而且当两个对象重叠在一起时会出现艺术品。(这怎么理解,晕,是两个阴影重叠时会有瑕疵或是其他的问题吗?)

Light-space perspective Shadow Maps - 光源空间的透视Shadow Maps

简写:LiSPSM 它是通过调整 light projection matrix 光源投影矩阵,来让距离相机近的部分会有更高的精度。尤其在截头椎体的"duelling frustra"(我理解就是靠近 近截面 那部分)那部分是很重要的:你在用相机看某个方向是,当spot light 聚光灯是与你看的方向是反向是。(这个我可以想象到,例如在一个非常黑暗的地方,前方有一个人用手电筒来照着你,并且手电筒照射到靠近它的石柱,花草树木之类的产生向着我们镜头方向投射的阴影)在距离光源近的地方,你需要shadowmap很高的精度,而当你远离光源时,这时靠近你的镜头近截面的内容的精度会很低,这是需要处理的地方。

然而LiSPSM 是比较难实现的。参考其他的资料来了解详细的实现方式。

Cascaded shadow maps - 级联阴影图

简写 CSM ,它处理的问题基本上与 LiSPSM的是一样的,当方式有点不一样。它是简单的使用了一些(2到4个)shadow map来处理镜头视锥体不同的区域部分。第一个处理1米范围内的,所以你在这很小的一部分空间中会有非常高的精度。而后一块shadow map处理两倍距离的对象。最后一块shadow map处理场景中最大的一部分,但由于透视原因,它的可视部分的重要程度没有近距离的重要。

在编写此文时(2012年),CSM的复杂度与质量度比是最高的。这是个不错的解决方案。

Conclusion - 总结

如你所见,shadow maps是一个复杂的部分。每一年都有不一样的或是改进了的方案发布出来,到今天为止,没有一个方案是完美的。

幸运的是,多数这些方法都可以混着一起使用:如CSM在 LiSPSM一起使用然后最后柔滑阴影使用PCF,等等,所有这些技巧都可以尝试混着使用。

最后,我建议你尽可能的使用预计算的light maps光照贴图(就是我们说的烘焙光照贴图),然后仅对动态对象使用shadow map。并确保两者的视觉质量效果的相当的:因为就算你有一个相当高质量的烘焙光照贴图效果,但配上一个视觉质量不太好shadow map动态阴影,就不太好了。

你可能感兴趣的:(OpenGL,图形)