转自:http://class.gd/content/shadow-map%E9%98%B4%E5%BD%B1%E8%B4%B4%E5%9B%BE%E6%8A%80%E6%9C%AF%E4%B9%8B%E6%8E%A2%E2%85%A2
本文来源:
http://www.zwqxin.com/archives/opengl
上篇里的最后最后,提及了几种比较有名的Shadow Map的延展技术,Cascaded Shadow
Maps是其中比较近期才出现的,而且它引进了Cascade(级联,层)这个概念,与另一个颇为我们中国人骄傲的名词PSSM(Parallel-
split Shadow Maps)中的Parallel-split指的是同一个概念。事实上两者的原理是基本一样的。
它先在我们的视锥上动手脚,用几个与近远平面平行的截面把视锥分成几份(Parallel-split);然后针对每一份,通过修改光源投影矩阵,使之后
生成的Shadow
Map中只有该份“Splited视锥”里的物体;这样,在pass1阶段就生成了几张针对不同“Splited视锥”的Shadow
Maps,在渲染阶段,依据像素深度就可以判断该位置应用哪张Shadow Map了。
这样做的好处在上篇已经讲过了。在距离眼睛近的地方,应用的是分辨率高的阴影图,距离眼睛远的地方则是低分辨率。这样是符合视觉特点的,而且没有什么浪费的地方。
如图,假设光从视锥正上方射下来(其他方向同理),按CSM的意思,应该把光源视觉下的投影面放在图示位置(四条短的水平的线)。这里我把视锥分割成四
份,因此需要对应的四张ShadowMap,与人看东西一样,视像面越靠近阴影(假设位于被投影面,图中长水平线),看到的阴影越清晰。反映在生成阴影图
阶段,表现为具体caster(被光源直接照射的投射物表面)在光源投影面上占据的范围大。假设阴影图尺寸是固定的(譬如1024*1024),在第一个
“Splited视锥”和第四个“Splited视锥”里的投射阴影的物体[投射物]大小也相同(其阴影在实际世界里占地面积必然也相同),则其阴影在阴
影图里占的像素数会有很大差别(譬如前者占500,000个,后者可能才占5000个),这就是分辨率的差异。最后把ShdowMap帖在场景里(假设在
世界空间下该种投射物的阴影应该占100,000个像素),前者就会比后者效果好很多。(一个是需要进行OverSampling,另一个就得进行
UnderSampling。)所以越靠近眼睛的、越小的Splited视锥里的阴影越高“画质”,反之则越粗糙(但比起传统Shadow
Map技术也许效果还好一点)——而我们正希望要眼前的事物清晰,远处的事物模糊甚至不表现出影子也可以——CSM(或者说,PSSM)做到了。
重新回头看看技术实现过程。这里有两个主要的技术点,一是“怎么分割视锥”,二是“怎么设置每个小视锥的光源投影矩阵”。
1. Cascade(Split)的准则
从上图和上分析可以看出,“Splited视锥”沿视线的长度(Zfar -
Znear)应该越分越大比较合理,指数增长符合这个规律,但指数增长一般太夸张了,所以配合一个线性增长比较好。在PSSM里,这两种分法叫
logarithmic split scheme和the uniform split
scheme,前者的表达式是经过科学的推导的,这部分也是CSM/PSSM最数学的部分,在GPU
GEMS3里有详细的推导,或者你看PSSM推广人Fan Zhang [HKUST]那篇"Hardware-Accelerated
Parallel-Split Shadow Maps." (IF YOU CAN FIND IT)也该有。它从Shadow-Map
Aliasing(dp/ds,单位阴影图像素单位对应的屏幕像素)的推导开始,找出能满足使perspective
aliasing(由投影缩减效应形成)在各个视锥里均匀分配的分割式。
后者只是一个线性式,但它的调和作用避免了“Splited视锥”的过小与过大,通过一个mix因子混合两式子(我在应用中默认分配logarithmic split scheme的因子是0.75,余者0.25):
02 |
void CCascadingSM::ComputeSplits( float strength, float Dis_Near, float Dis_Far) |
04 |
float distance_scale = Dis_Far / Dis_Near; |
06 |
splitfrust[0].ResightNear(Dis_Near); |
08 |
float partisionFactor = 0.0; |
09 |
float lerpValue1 = 0.0, lerpValue2 = 0.0; |
12 |
for ( int i = 1; i < NumofSplits; ++i) |
14 |
partisionFactor = i / ( float )NumofSplits; |
16 |
lerpValue1 = Dis_Near + partisionFactor * (Dis_Far - Dis_Near); |
18 |
lerpValue2 = Dis_Near * powf(distance_scale, partisionFactor); |
21 |
SplitsZ = (1-strength) * lerpValue1 + strength * lerpValue2; |
23 |
splitfrust[i].ResightNear(SplitsZ * 1.002f); |
24 |
splitfrust[i-1].ResightFar(SplitsZ); |
27 |
splitfrust[NumofSplits-1].ResightFar(Dis_Far); |
2. Crop It !
针对每个光源投影矩阵进行的调整,在CSM/PSSM里称为Crop(这么有诗情画意噶?)。这个过程其实很好理解的,我们在照相的时候,一开始要在
CCD液晶屏的画面上把焦点确定吧——Cascaded Shadow
Maps技术中的光源就是照相者,光源的视像平面就是屏幕,我们是对每个“Splited视锥”都照一张相,因为照的是casters,所以可以说是照人
物相片——把casters所在的“Splited视锥”(对应人物背景)在光源投影空间的中心挪移到视像平面的中心,然后进行光学变焦,使人物背景尽量
充满屏幕,从而突出人物——casters,噢,不,应说是shadows。
恩,这是个具有平移和缩放的线性变换——CROP
MATRIX,合适地构造它,然后乘在光源投影矩阵前面(形成新的投影矩阵),就能完成匹配投影矩阵匹配“Splited视锥”的任务。假如目前处理第i
个分割视锥,生成CropMatrix[i],那么对场景坐标系的变换就是:(CropMatrix[i] *
LightProjectMatrix) * LightViewMatrix * ModelMatrix *
pos。也可认为(CropMatrix[i] * LightProjectMatrix)是二次投影,因为Crop
Matrix实质也是个投影矩阵,而且是个名副其实的Otho正交投影矩阵。
02 |
void CCascadingSM::ApplyCropProjectMatrix(CFrustum &frust) |
04 |
CVector3 maxFrustumCoord, minFrustumCoord; |
06 |
CMatrix16 CurrentMatrix; |
10 |
glGetFloatv(GL_MODELVIEW_MATRIX, CurrentMatrix.mt); |
13 |
GetFrustumAABBCoords(frust, maxFrustumCoord, minFrustumCoord, &CurrentMatrix); |
16 |
float scaleX = 2.0f/(maxFrustumCoord.x - minFrustumCoord.x); |
17 |
float scaleY = 2.0f/(maxFrustumCoord.y - minFrustumCoord.y); |
18 |
float offsetX = -0.5f*(maxFrustumCoord.x + minFrustumCoord.x) * scaleX; |
19 |
float offsetY = -0.5f*(maxFrustumCoord.y + minFrustumCoord.y) * scaleY; |
21 |
CropMatrix = CMatrix16(scaleX, 0.0f, 0.0f, 0.0f, |
22 |
0.0f, scaleY, 0.0f, 0.0f, |
23 |
0.0f, 0.0f, 1.0f, 0.0f, |
24 |
offsetX, offsetY, 0.0f, 1.0f ); |
28 |
glLoadMatrixf(CropMatrix.mt); |
30 |
glOrtho(-1.0, 1.0, -1.0, 1.0, -maxFrustumCoord.z, -minFrustumCoord.z ); |
CropMatrix简直就跟glOrtho生成的矩阵一模一样,功用也一样。只不过这里我没有对Z坐标进行变换,因为把它交给生成光源投影矩阵的
glOrtho了(反而它只变换Z坐标)。前面不是说把坐标都变换到光源投影CLip空间后再提取AABB吗,为什么就到光源视图空间就比较了?因为这里
是平行光的投影,所以用的是正交投影glOrtho,在glOrtho中没有对X,Y坐标进行变换(看看它的spec就知道了,-1与1为参数是不改变
X,Y数值的),所以两个空间下的X,Y坐标是一致的,而CropMatrix正是只变换X,Y坐标,所以实在没必要多此一举。
但有两种情况是“需要多此一举”的。一是光源为点光源且需要透视投影;二是在光源与视锥之间还有其他caster。对第二种情况尤其值得注意。看回我在文
章最上面放的自画示意图,有个打了X的地方,那里假设有只bird,那么它会否对地面产生阴影呢?——按照CSM基础理论,不会!因为
CropMATRIX修改后的光源投影平面已经越过它了,已经看不见它了——我们只能看见视锥里(更准确说是视锥的AABB包围盒里)的物体所留下的阴
影!解决法是把该物件bondingbox在光源视图空间下的最大Z坐标作为上述算法最后的minFrustumCoord.z,使光源投影平面恰在该位
置而不再下降。这样做多了些麻烦,而且该“Splited视锥”对应的Shadow
Map的分辨率会降低,物体离视锥越远,分辨率下降越严重。所以,如非必要投射那样的物体(或者部分穿出视锥之外的物体)的阴影,不必这样做:
先计算普适意义下的光源投影矩阵和视图矩阵(类似传统SM那样),用它们的积Light-ProjectView把各个小视锥变换到CLIP投影空间,用
同样方法得到该空间下的包围盒(特征向量maxFrustumCoord,
minFrustumCoord),这里继续计算的Crop矩阵就需要用到Z值了,因为我们要修改其中的minFrustumCoord.z。让它等于
-1——OPENGL在CLIP投影空间的最小坐标值。没错,即使该物件在光源正体位置之上,也把它计算入要投影的物件集(casters)里(况且平行
光源本来该是无限远而不是在那个虚拟位置上的)。最后依然是:CropMatrix[i] *( LightProjectMatrix *
LightViewMatrix) * ModelMatrix * pos。
02 |
CVector3 maxFrustumCoord, minFrustumCoord; |
04 |
GetFrustumAABBCoords(frust, maxFrustumCoord, minFrustumCoord, &CurrentMV); |
06 |
minFrustumCoord.z = -1.0f; |
09 |
float scaleX = 2.0f/(maxFrustumCoord.x - minFrustumCoord.x); |
10 |
float scaleY = 2.0f/(maxFrustumCoord.y - minFrustumCoord.y); |
11 |
float scaleZ = 2.0f / (maxFrustumCoord.z - minFrustumCoord.z); |
12 |
float offsetX = -0.5f*(maxFrustumCoord.x + minFrustumCoord.x) * scaleX; |
13 |
float offsetY = -0.5f*(maxFrustumCoord.y + minFrustumCoord.y) * scaleY; |
14 |
float offsetZ = -0.5f*(maxFrustumCoord.z + minFrustumCoord.z) * scaleZ; |
17 |
CropMatrix = CMatrix16(scaleX, 0.0f, 0.0f, 0.0f, |
18 |
0.0f, scaleY, 0.0f, 0.0f, |
19 |
0.0f, 0.0f, scaleZ, 0.0f, |
20 |
offsetX, offsetY, 0.0f, 1.0f ); |
3. Cast 阴影
通过上面矩阵配合(0,1)映射矩阵之类的生成shadow maps后,这就来到第二PASS了,它与传统Shadow
Map(Shadow
Map阴影贴图技术之探Ⅰ)一样,只是根据像素深度决定用哪张而已。注意,把视锥分割的是近/远平面,其值是距视点的距离,定义于视图空间——把它变换到
眼睛的屏幕CLIP空间,就能在shader里“分割”像素深度,把像素都分到SplitNum个区域里(应用中我取了4个)。好了,接下来你知道怎么用
if-else来Cast 阴影图了吧。
05 |
const float shadow_color = 0.3; |
06 |
const float depth_error = 0.005; |
08 |
uniform vec3 frustum_far; |
09 |
uniform sampler2DArray shadowmap; |
18 |
if (gl_FragCoord.z < frustum_far.x) |
22 |
else if (gl_FragCoord.z < frustum_far.y) |
26 |
else if (gl_FragCoord.z < frustum_far.z) |
32 |
vec4 shadowTexcoord = gl_TextureMatrix[index] * pos; |
36 |
if (shadowTexcoord.w != 1.0) |
38 |
shadowTexcoord = shadowTexcoord / shadowTexcoord.w; |
42 |
shadowTexcoord = 0.5 * shadowTexcoord + 0.5; |
45 |
float realDepth = shadowTexcoord.z; |
48 |
shadowTexcoord.z = float (index); |
51 |
float depth = texture2DArray(shadowmap, shadowTexcoord.xyz).x; |
55 |
float diff = depth - realDepth; |
59 |
diff = diff / depth_error + 1.0; |
61 |
return vec4(diff < 0.0 ? shadow_color : 1.0) ; |
最后是放出演示DEMO了吧
在该日志将展示DEMO并浅谈一下CSM一些小细节的地方,包括caster-receiver-splitedFrustum组合生成的SCREEN DEPENDENT的crop矩阵。最后是这段时间个人学习Shadow技法的小小总结。