首页 > 代码库 > [ZZ] Shadow Map

[ZZ] Shadow Map

 

 

Shadow Map

如何能够高效的产生更接近真实的阴影一直是视频游戏的一个很有挑战的工作,本文介绍目前所为人熟知的两种阴影技术之一的ShadowMap(阴影图)技术。 
    ShadowMap技术的概念应该说是最早应用在视频游戏中的阴影实现技术,有着非常高效和快速的特点,在实现阴影的同时只需要相对很小的计算负担。 
    ShadowMap绘制阴影主要是通过一张额外的阴影贴图来实现的,在早期的3D游戏中人物等动态运动的物体通常不绘制阴影,而场景内遮蔽关系相对确定的静态物体的阴影通常是在建立模型之初便已绘制到场景的贴图之中,这是利用ShadowMap来实现阴影概念的最初形成,而现在我们说到的 ShadowMap只是在游戏绘制时将阴影动态的绘制到一张阴影贴图上,再利用计算好的阴影贴图来绘制场景而已,整个计算只需要将场景绘制两边,而不需要像ShadowVolume一样额外生成新的模型,所以Shadow可以保持很好的性能表现而与场景的复杂度并无太大关系。 
    ShadowMap的概念很好理解,整个绘制过程分为两个阶段,首先以灯光为视角对场景进行绘制,绘制的结果是将场景内物体相对光源的深度信息写入一张阴影图中(Shadow Map),而不是RGB颜色。第二遍绘制场景时逐像素对比相对光源的深度值与阴影图中的深度,当深度大于阴影图中的深度时,说明该像素位于阴影中,进行相应的阴影混合。因为ShadowMap这种技术的特点,所以非常适合实现锥光源(spot light)下的阴影。对于在点光源(point light)下的利用ShadowMap生成阴影,有一种方法是利用Cubemap,这样六张阴影图可以实现全景点光源的ShadowMap。 
    生成Shadow Map 
    以DirectX中Sample为例,用于生成Shadow Map的Texture是格式为D3DFMT_R32F的RenderTarget,32位的浮点数可以保证深度信息的精度。 
    第一遍的绘制中,设置视角变换和投影矩阵为光源的视角变换和投影矩阵(假设一个相机从光源向外),在Vertex Shader中照常进行顶点空间坐标(这样深度测试会自动得到每个像素最接近光源的点),额外的贴图坐标输出为坐标的z和w坐标。 
    Depth.xy = oPos.zw; 
    在Pixel Shader中最终输出的深度: 
    Color = Depth.x / Depth.y;        // Depth is z / w 
    这个值就是反映场景中在光源照射下的深度信息,值域位于0,1区间,位于近平面时为0,原平面时为1。 
    渲染场景 
    第二遍渲染场景,在Vertex Shader中除了完成坐标转换和贴图坐标转换外,需要额外传递几个参数,观察视角下的空间坐标、法线向量以及转换到光源投影空间下的坐标。前两个用于光照计算,后一个用于阴影深度判断。 
    最后的阴影混合在Pixel Shader中完成,在这里依次判断每个像素是否位于光照影响之下,只需用到该顶点的光向量与光源朝向向量点积的结果与光源照射范围二分之一弧度值的 cos值相比较(通过夹角大小来判断) 
    如果位于光源照射下,则计算该像素在深度图中的uv坐标,采样,然后比较深度值,判断是否位于阴影之内: 
    //换算UV坐标 
    float2 ShadowTexC = 0.5 * vPosLight.xy / vPosLight.w + float2( 0.5, 0.5 ); 
    ShadowTexC.y = 1.0f – ShadowTexC.y; 
    //采样并判断深度 
    LightAmount = (tex2D( g_samShadow, ShadowTexC ) < vPosLight.z / vPosLight.w)? 0.0f: 1.0f; 
    最后根据阴影信息混合颜色即可。 
    Shadow Map的最大优点是高效率和快速,同样也会存在很多局限性,比如不适合点光源,并且在生成的阴影边缘锯齿化很严重。当然,我们也可以通过多次采样混合阴影边缘或者多次渲染进行高斯模糊来提高效果。

 

 

基于Shadow Map的阴影实现

0、简介

Shadow Mapping是一种基于图像空间的阴影实现方法,其优点是实现简单,适应于大型动态场景;缺点是由于shadow map的分辨率有限,使得阴影边缘容易出现锯齿(Aliasing);关于SM的研究很活跃,主要围绕着阴影抗锯齿,出现了很多SM变种,如PSM,LPSM,VSM等等,在这里http://en.wikipedia.org/wiki/Shadow_mapping可以找到很多SM变种的链接;;SM的实现分为两个pass,第一个pass以投射阴影的灯光为视点渲染得到一幅深度图纹理,该纹理就叫Shadow Map;第二个pass从摄像机渲染场景,但必须在ps中计算像素在灯光坐标系中的深度值,并与Shadow Map中的相应深度值进行比较以确定该像素是否处于阴影区;经过这两个pass最终就可以为场景打上阴影。这篇文章主要总结一下自己在实现基本SM的过程中遇到的一些问题以及解决方法,下面进入正题。

1、生成Shadow Map

为了从灯光角度渲染生成Shadow Map,有两个问题需要解决:一是要渲染哪些物体,二是摄像机的参数怎么设置。对于问题一,显然我们没必要渲染场景中的所有物体,但是只渲染当前摄像机视景体中的物体又不够,因为视景体之外的有些物体也可能投射阴影到视景体之内的物体,所以渲染Shadow Map时,这些物体必须考虑进来,否则可能会出现阴影随着摄像机的移动时有时无的现象,综上,我们只需要渲染位于当前摄像机视景体内的所有物体以及视景体之外但是会投射阴影到视景体之内的物体上的物体,把它们的集合称为阴影投射集,为了确定阴影投射集,可以根据灯光位置以及当前的视景体计算出一个凸壳,位于该凸壳中的物体才需要渲染,如图1所示。对于问题二,灯光处摄像机的视景体应该包含阴影投射集中的所有物体,另外应该让物体尽量占据设置的视口,以提高Shadow Map的精度;对于方向光和聚光灯,摄像机的look向量可以设置为光的发射发向,摄像机的位置设置为灯光所在的位置,为了包含阴影集中的所有物体,可以计算阴影投射集在灯光视图空间中的轴向包围盒,然后根据面向光源的那个面设置正交投影参数,就可以保证投射集中的所有物体都位于灯光视景体中,并且刚好占据整个视口,如图2所示。更详细的信息可以参考《Mathematics for 3D Game Programming and Computer Graphics, Third Edition》一书的10.2节。

图1:灯光位置与视景体构成一个凸壳,与该凸壳相交的物体称为阴影投射集

图2:根据阴影投射集在灯光视图空间中的包围盒来计算正交投影参数

2、生成阴影场景

生成了ShadowMap之后,就可以根据相应灯光的视图矩阵和投影矩阵从摄像机角度渲染带有阴影的场景了。下面把相关的shader代码贴上:

vertex shader:

 

// for projective texturinguniform mat4 worldMatrix;uniform mat4 lightViewMatrix;uniform mat4 lightProjMatrix;varying vec4 projTexCoord;void main(){	// for projective texture mapping	projTexCoord = lightProjMatrix*lightViewMatrix*worldMatrix*pos;	// map project tex coord to [0,1]	projTexCoord.x = (projTexCoord.x + projTexCoord.w)*0.5;	projTexCoord.y = (projTexCoord.y + projTexCoord.w)*0.5;	projTexCoord.z = (projTexCoord.z + projTexCoord.w)*0.5;	gl_Position = gl_ModelViewProjectionMatrix * pos;}

pixel shader(版本一):

 

 

// The shadow mapuniform sampler2DShadow shadowDepthMap;varying vec4 projTexCoord;void main(){	// light computing	vec4 lightColor = Lighting(...);	vec4 texcoord = projTexCoord;	texcoord.x /= texcoord.w;	texcoord.y /= texcoord.w;	texcoord.z /= texcoord.w;	// depth comparison	vec4 color = vec4(1.0,1.0,1.0,1.0);	float depth = texture(shadowDepthMap,vec3(texcoord.xy,0.0));	if(texcoord.z > depth)	{		// this pixel is in shadow area		color = vec4(0.6,0.6,0.6,1.0);	}	gl_FragColor = lightColor*color;}

这样就实现了最基本的Shadow Mapping,对于每个像素只采样一个深度texel,看看效果吧。

 

                                                                图3:最基本的Shadow Mapping

可以看到效果不尽如人意,主要有三个问题(分别对应上图标记):1、物体的背光面当作阴影处理了;2、正对着光的一些像素也划到阴影区去了(Self-shadowing);3、阴影边缘有比较强的锯齿效果。对于问题一,可以判断当前像素是否背着灯光,方法是求得像素在灯光视图空间中的位置以及法线,然后求一个点积就可以了,对于这种像素,不用进行阴影计算即可;问题二产生的原因是深度误差导致的,当物体表面在灯光视图空间中的倾斜度越大时,误差也越大;解决办法有多种,第一种是在进行深度比较时,将深度值减去一个阈值再进行比较,第二种是在生成shadow map时,只绘制背面,即将背面设置反转,第三种方法是使用OpenGL提供的depth offset,在生成shadow map时,给深度值都加上一个跟像素斜率相关的值;第一种方法阈值比较难确定,无法完全解决Self-shadowing问题,第二种方法在场景中都是二维流形物体时可以工作的很好,第三种方法在绝大多数情况都可以工作得很好,这里使用这种方法,如下面代码所示:

 

        // handle depth precision problem 	glEnable(GL_POLYGON_OFFSET_FILL); 	glPolygonOffset(1.0f,1.0f);		// 绘制阴影投射集中的物体	glDisable(GL_POLYGON_OFFSET_FILL);

解决第一和第二个问题后的效果如下面所示图4所示:

 

                                         图4:解决背光面阴影和Self-shadowing问题之后的效果

还有最后一个问题未解决,从上面的图也可看得出来,阴影边缘锯齿比较严重,解决这个问题也有两种较常用方法,第一种方法是用PCF(Percentage Closer Filtering),基本思想是对每个像素从shadow map中采样相邻的多个值,然后对每个值都进行深度比较,如果该像素处于阴影区就把比较结果记为0,否则记为1,最后把比较结果全部加起来除以采样点的个数就可以得到一个百分比p,表示其处在阴影区的可能性,若p为0代表该像素完全处于阴影区,若p为1表示完全不处于阴影区,最后根据p值设定混合系数即可。第二种方法是在阴影渲染pass中不计算光照,只计算阴影,可以得到一幅黑白二值图像,黑色的表示阴影,白色表示非阴影,然后对这幅图像进行高斯模糊,以对阴影边缘进行平滑,以减小据齿效果,最后在光照pass中将像素的光照值与相应二值图像中的值相乘就可以得到打上柔和阴影的场景了;在理论上来说,两种方法都能达到柔和阴影的效果,本文采用的PCF方法,第二种方法后面会尝试,并在效果和速度上与PCF做一下比较,等测试完了会贴到这里来。下面贴上加了PCF的像素shader的代码:

 

// The shadow mapuniform sampler2DShadow shadowDepthMap;varying vec4 projTexCoord;void main(){	// light computing	vec4 lightColor = Lighting(...);	float shadeFactor = 0.0;	shadeFactor += textureProjOffset(shadowDepthMap, projTexCoord, ivec2(-1, -1));	shadeFactor += textureProjOffset(shadowDepthMap, projTexCoord, ivec2(-1, 1));	shadeFactor += textureProjOffset(shadowDepthMap, projTexCoord, ivec2( 1, -1));	shadeFactor += textureProjOffset(shadowDepthMap, projTexCoord, ivec2( 1, 1));		shadeFactor *= 0.25;	// map from [0.0,1.0] to [0.6,1.0]	shadeFactor = shadeFactor * 0.4 + 0.6; 	gl_FragColor = lightColor*shadeFactor;}

另外注意要对shadow map纹理设置以下纹理参数:

 

 

glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_COMPARE_MODE,GL_COMPARE_REF_TO_TEXTURE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_COMPARE_FUNC,GL_LEQUAL);

PCF效果如下图 所示,可以看到阴影边缘变柔和了:

 

                            图5:柔和的阴影边缘(PCF:采样pattern:左上,左下,右下,右上)

不同的采样pattern对结果也会有影响,比如采用下面的采样pattern效果如图6所示:

 

shadeFactor += textureProjOffset(shadowDepthMap, projTexCoord, ivec2(-1.5, 0.5));shadeFactor += textureProjOffset(shadowDepthMap, projTexCoord, ivec2(0.5, 0.5));shadeFactor += textureProjOffset(shadowDepthMap, projTexCoord, ivec2(-1.5, -1.5));shadeFactor += textureProjOffset(shadowDepthMap, projTexCoord, ivec2(0.5, -1.5));

 

                             图6:柔和的阴影边缘(PCF:使用非对齐采样pattern)

最后再贴一个2048分变率的shadow map产生的阴影效果,以作比较,PCF的采样pattern跟图6中一样。

 图7:shadow map大小为2048时的阴影效果(PCF采样pattern与图6一样)。


3、结语

关于Shadow Map的变种有很多,不同的变种针对不同情况不同场景提供SM阴影抗锯齿解决方案,本文实现的只是基本的SM,后面考虑实现某种SM变种,进一步提高阴影的效果。