CSharpGL(48)用ShadowVolume画模型的影子
CSharpGL(48)用ShadowVolume画模型的影子
Shadow Volume
在Per-Fragment Operations & Tests阶段,有一个步骤是模版测试(Stencil Test)。依靠这一步骤,不仅可以实现渲染模型的包围框这样的实用功能,还能创造出一种渲染阴影的算法,即Shadow Volume算法。
用Shadow Mapping方法得到的阴影,在贴近观察时,会看到细微的锯齿。这是因为深度缓存受到分辨率的限制,不可能完全精确地描述贴近观察时的各个Fragment。但Shadow Volume方法得到的阴影是没有这样的锯齿问题的,如下图所示:
#version 330 layout (triangles_adjacency) in; // six vertices in layout (triangle_strip, max_vertices = 18) out; // 4 per quad * 3 triangle vertices + 6 for near/far caps in vec3 PosL[]; // an array of 6 vertices (triangle with adjacency) uniform vec3 gLightPos; // point light's position. uniform mat4 gProjectionView; uniform mat4 gWorld; float EPSILON = 0.0001; // Emit a quad using a triangle strip void EmitQuad(vec3 StartVertex, vec3 EndVertex) { vec3 LightDir; LightDir = normalize(StartVertex - gLightPos); // Vertex #1: the starting vertex (just a tiny bit below the original edge) gl_Position = gProjectionView * vec4((StartVertex + LightDir * EPSILON), 1.0); EmitVertex(); // Vertex #2: the starting vertex projected to infinity gl_Position = gProjectionView * vec4(LightDir, 0.0); EmitVertex(); LightDir = normalize(EndVertex - gLightPos); // Vertex #3: the ending vertex (just a tiny bit below the original edge) gl_Position = gProjectionView * vec4((EndVertex + LightDir * EPSILON), 1.0); EmitVertex(); // Vertex #4: the ending vertex projected to infinity gl_Position = gProjectionView * vec4(LightDir , 0.0); EmitVertex(); EndPrimitive(); } void main() { vec3 worldPos[6]; // vertexes’ position in world space. worldPos[0] = vec3(gWorld * vec4(PosL[0], 1.0)); worldPos[1] = vec3(gWorld * vec4(PosL[1], 1.0)); worldPos[2] = vec3(gWorld * vec4(PosL[2], 1.0)); worldPos[3] = vec3(gWorld * vec4(PosL[3], 1.0)); worldPos[4] = vec3(gWorld * vec4(PosL[4], 1.0)); worldPos[5] = vec3(gWorld * vec4(PosL[5], 1.0)); vec3 e1 = worldPos[2] - worldPos[0]; vec3 e2 = worldPos[4] - worldPos[0]; vec3 e3 = worldPos[1] - worldPos[0]; vec3 e4 = worldPos[3] - worldPos[2]; vec3 e5 = worldPos[4] - worldPos[2]; vec3 e6 = worldPos[5] - worldPos[0]; vec3 Normal = normalize(cross(e1,e2)); vec3 LightDir; LightDir = normalize(gLightPos - worldPos[0]); // Handle only light facing triangles if (dot(Normal, LightDir) > 0) { Normal = cross(e3,e1); if (dot(Normal, LightDir) <= 0) { EmitQuad(worldPos[0], worldPos[2]); } Normal = cross(e4,e5); LightDir = normalize(gLightPos - worldPos[2]); if (dot(Normal, LightDir) <= 0) { EmitQuad(worldPos[2], worldPos[4]); } Normal = cross(e2,e6); LightDir = normalize(gLightPos - worldPos[4]); if (dot(Normal, LightDir) <= 0) { EmitQuad(worldPos[4], worldPos[0]); } // render the front(near) cap LightDir = (normalize(worldPos[0] - gLightPos)); gl_Position = gProjectionView * vec4((worldPos[0] + LightDir * EPSILON), 1.0); EmitVertex(); LightDir = (normalize(worldPos[2] - gLightPos)); gl_Position = gProjectionView * vec4((worldPos[2] + LightDir * EPSILON), 1.0); EmitVertex(); LightDir = (normalize(worldPos[4] - gLightPos)); gl_Position = gProjectionView * vec4((worldPos[4] + LightDir * EPSILON), 1.0); EmitVertex(); EndPrimitive(); // render the back(far) cap LightDir = worldPos[0] - gLightPos; gl_Position = gProjectionView * vec4(LightDir, 0.0); EmitVertex(); LightDir = worldPos[4] - gLightPos; gl_Position = gProjectionView * vec4(LightDir, 0.0); EmitVertex(); LightDir = worldPos[2] - gLightPos; gl_Position = gProjectionView * vec4(LightDir, 0.0); EmitVertex(); EndPrimitive(); } }EmitQuad [p]上文提到,包围盒的远底面是位于无限远处的。这是数学意义上的描述。具体到OpenGL,其实并不需要描述一个无限远的顶点,只需要找到此顶点投影到近裁剪面上的投影位置即可。简单来说,只需将从光源到轮廓线上的顶点的向量作为xyz坐标,以0为w坐标,即可得到此投影位置。
从数学上理解此问题需要一些晦涩的推导过程,这里从OpenGL Pipeline的角度来理解即可。一般的,OpenGL描述顶点位置都是用vec4(x, y, z, 1)。在Pipeline从Clip Space到NDC Space的变换过程中,会将所有顶点的xyz坐标都除以w,所以vec4(x, y, z, w)、vec4(x/w, y/w, z/w, 1)和vec4(nx, ny, nz, nw)描述的都是同一个位置。试想,如果保持xyz的值不变,而不断减小w的值,那么这个坐标描述的位置会越来越远;当w减小到0时,这个坐标描述的就是一个无限远的位置。那么,沿着光源L到顶点的方向,走到无限远的那个位置,只能是vec4(LightDir, 0)。
判断
使用Stencil Buffer和Depth Buffer实现阴影的渲染的过程如下伪代码所示:
void ShadowVolume(Scene scene, ..) { // Render depth info into depth buffer. RenderDepthInfo(scene, ..); glEnable(GL_STENCIL_TEST); // enable stencil test. glClear(GL_STENCIL_BUFFER_BIT); // Clear stencil buffer. // Extrude shadow volume and save shadow info into stencil buffer. { glDepthMask(false); // Disable writing to depth buffer. glColorMask(false, false, false, false); // Disable writing to color buffer. glDisable(GL_CULL_FACE); // Disable culling face. // Set up stencil function and operations. glStencilFunc(GL_ALWAYS, 0, 0xFF); glStencilOpSeparate(GL_BACK, GL_KEEP, GL_INCR_WRAP, GL_KEEP); glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_DECR_WRAP, GL_KEEP); // Extrude shadow volume. // Shadow info will be saved into stencil buffer automatically // according to `glStencilOp...`. Extrude(scene, ..); // Reset OpenGL switches. glEnable(GL_CULL_FACE); glColorMask(true, true, true, true); glDepthMask(true); } // Render the scene under the light with shadow. { // Set up stencil function and operations. glStencilFunc(GL_EQUAL, 0x0, 0xFF); glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); // light the scene up. RenderUnderLight(scene, ..); } glDisable (GL_STENCIL_TEST); // disable stencil test. }
Shadow Volume由3遍渲染完成。
第一遍渲染时,在不考虑阴影的前提下正常渲染场景。此时,Depth Buffer填充了正常的深度信息。这一次渲染的目的是准备好这一深度缓存,渲染的颜色并不重要。因此可以用最简单的Fragment Shader,甚至不使用Fragment Shader。
第二遍渲染前,启用模板测试,并按如下方式设置模板测试的函数和操作:
// Always pass stencil test. glStencilFunc(GL_ALWAYS, 0, 0xFF); // If depth test fails for back face, increase value in stencil buffer. glStencilOpSeparate(GL_BACK, GL_KEEP, GL_INCR_WRAP, GL_KEEP); // If depth test fails for front face, decrease value in stencil buffer. glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_DECR_WRAP, GL_KEEP);
这里设置模版测试对于每个像素都是通过的,且通过后将对应像素位置的模板缓存值设置为0。当模板测试完成后,对于包围盒背面的Fragment,如果深度测试失败,那么模版缓存的值加1;对于包围盒正面的Fragment,如果深度测试失败,那么模板缓存的值减1。
这样设置的结果是,位于包围盒内部的模型(或其一部分),包围盒的背面的深度测试会失败,所以此处的模板缓存值加1;包围盒正面的深度测试会成功,所以对模板缓存无影响。比包围盒更靠近Camera的模型(或其一部分),包围盒的正面背面的深度测试都会失败,所以此处的模板缓存值加1又减1,保持为0。比包围盒更远离Camera的模型(或其一部分),包围盒的正面背面的深度测试都会成功,所以此处的模板缓存值保持不变,即为0。而在包围盒涉及不到的位置,模板缓存也保持不变,即为0。
也就是说,只有包围盒内部的模型(或其一部分)对应的模板缓存值是大于0的,其它位置的模板缓存值都保持为0。而包围盒内部的模型(或其一部分)就位于阴影中。所以第二遍渲染的只有包围盒,这样就能区分出阴影部分,如下图所示:
[/p]Shadow Volume判断阴影
如图所示,场景中有一个点光源L位于左上角,一个地板(Floor)上方漂浮着一个立方体(Cube)。光源L照射到Cube和Floor上,Cube投射出自己的阴影,这阴影由包围盒描述处出来。图中ABCD都代表Floor上的一点。A点位于包围盒内部,包围盒的背面的深度测试会失败,所以此处的模板缓存值加1;包围盒正面的深度测试会成功,所以对模板缓存无影响。B点比包围盒更靠近Camera,因此此位置上的包围盒的正面背面的深度测试都会失败,所以此处的模板缓存值加1又减1,保持为0。C点比包围盒更远离Camera,包围盒的正面背面的深度测试都会成功,所以此处的模板缓存值保持不变,即为0。D点与包围盒的任何一部分都没有交集,因此不受包围盒影响,模板缓存在此位置的值保持不变,即为0。
包围盒本身是一个模型,但并不存在于原本的场景中,所以在第二次渲染过程中要通过下述代码来避免将其渲染到最终的场景中:
glDepthMask(false); // Disable writing to depth buffer. glColorMask(false, false, false, false); // Disable writing to color buffer.
这样就保证了包围盒不改变深度缓存,也不会写入颜色缓存。同时,其他功能仍然能够正常进行。
为了保证包围盒的正面背面都被渲染,需要禁用背面剔除功能:
glDisable(GL_CULL_FACE); // Disable culling face.
第三遍渲染前,重新设置模板测试的函数和操作:
// Draw only if the corresponding stencil value is zero. glStencilFunc(GL_EQUAL, 0x0, 0xFF); // prevent updating to the stencil buffer. glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
此时设置模板测试仅允许模板缓存值为0的位置通过。也就是说,只有在第二次渲染时位于包围盒外部的模型(或其一部分)才会被渲染并可能影响到最后的Framebuffer。此时已经无需(也不应)修改模板缓存的值,所以设置在任何情形下都让模板缓存的值保持不变。
这时,只需按通常的方式用光照模型渲染场景,即可产生即有光照又有阴影的最终效果。
多光源下的阴影
无论Shadow Mapping还是Shadow Volume都可以简单地应用到有多个光源的场景中。类似于多光源下的光照模型Blinn-Phong,只需分别对每个光源执行一遍Shadow Mapping或Shadow Volume,并且用混合功能将各个光源的照射效果叠加即可。其伪代码如下:
void MultipleLights(Scene scene, ..) { // render ambient color. foreach (var light in scene.Lights) { // preparation. glEnable(GL_BLEND); // enable blending. glBlend(GL_ONE, GL_ONE); // add lighting info to previous lights. // light the scene up with specified light. RenderUnderLight(scene, light, ..); glDisable(GL_BLEND); } }
下图展示了同时用红绿蓝三色光源照射模型的场景:
多光源照射的光和影(左)点光源(中)平行光(右)聚光灯
图中用三个小球描述了光源的位置。对于平行光,小球描述的是光源的方向。
总结
本文介绍了Shadow Volume渲染阴影的方法。Shadow Volume的实现相对复杂,对模型的规范性有一定的要求,但是阴影的分辨率很高。如果将Shadow Mapping类比作位图,那么Shadow Volume可以类比作矢量图。
r Shadow Mapping的思路是什么?
两遍渲染:首先从光源位置渲染场景,得到深度缓存;然后依据深度缓存判断Fragment是否位于阴影中。
r Shadow Volume的思路是什么?
两遍渲染:首先动态生成阴影包围盒,并设置模版缓存;然后依据模版缓存的状态判断Fragment是否位于阴影中。
r 多光源的阴影如何实现?
依次对每个光源运用Shadow Mapping或Shadow Volume算法。
r Shadow Volume最可能的失败原因是什么?
创建OpenGL Render Context时没有指定创建模版缓存。
问题
带着问题实践是学习OpenGL的最快方式。这里给读者提出几个问题,作为抛砖引玉之用。
- 请在Github代码中的Demos\LogicOperation项目中尝试使用各种类型逻辑操作,观察各自的效果。注意,需要将鼠标移动到Cube模型上才能看到逻辑操作的效果。
- 任意选择一个示例项目,或者新建一个项目,尝试使用剪切测试(Scissor Test),观察效果。思考剪切测试能够帮助实现什么功能?
- 关于模版测试的示例项目Demos\StencilTest中,Cube的包围框的宽度随Cube的原理而逐渐减小。请尝试使用Shader来保证包围框的宽度保持不变。
- 阴影锥(shadow volume)原理与展望---真实的游戏效果的实现
- Volume Shadow Copy Service(VSS)如何工作
- (转)Shadow Map & Shadow Volume
- Stencil Shadow Volume的Z-pass和Z-fail算法
- Stencil Shadow Volume Using HLSL 2
- Maya模型转流体插件 Poly2Volume
- 阴影锥(Shadow Volume)
- 【转】阴影锥(shadow volume)原理与展望---真实的游戏效果的实现
- 盒子模型——盒子阴影box-shadow
- 【iOS开发-48】九宫格布局案例:自动布局、字典转模型运用、id和instancetype区别、xib重复视图运用及与nib关系
- 阴影之, Shadow Volume - 完
- 计算用于阴影剔除的包围体(shadow culling volume)
- Stencil Shadow Volume
- 48、tensorflow入门二,线性模型的拟合
- .net动态加载程序集和影子复制(Dynamic Load Assembly and Shadow Copy)
- ShadowMap & ShadowVolume
- 48.结构体位域获取内存模型
- 数据模型的又一本经典:《The Data Model Resource Book Volume 3 Universal Patterns for Data Modeling》
- 关于VSS(Volume Shadow Copy Service)一
- Volume Shadow Copy Service(VSS)如何工作