CSharpGL(48)用ShadowVolume画模型的影子

Shadow Volume

在Per-Fragment Operations & Tests阶段,有一个步骤是模版测试(Stencil Test)。依靠这一步骤,不仅可以实现渲染模型的包围框这样的实用功能,还能创造出一种渲染阴影的算法,即Shadow Volume算法。

用Shadow Mapping方法得到的阴影,在贴近观察时,会看到细微的锯齿。这是因为深度缓存受到分辨率的限制,不可能完全精确地描述贴近观察时的各个Fragment。但Shadow Volume方法得到的阴影是没有这样的锯齿问题的,如下图所示:

对比(左)Shadow Mapping的阴影有锯齿(右)Shadow Volume的阴影更平滑

如图所示,左侧的Shadow Mapping方法得到的阴影在犄角、舌头和下巴部分可以看到比较明显的锯齿,而右侧的Shadow Volume方法得到的阴影则十分平滑。这是由Shadow Volume的实现机制决定的。其机制概括起来就是,根据光源位置(或方向)和模型位置,动态地生成一个不规则的包围盒,将阴影部分包裹起来,保证包围盒内部的Fragment不参与光照计算。上图的包围盒如下图所示:

从6个视角观察包围盒

注意,这个包围盒是实时动态生成的,它会随着光源位置(或方向)的变化而变化。而且,这个包围盒是延伸到无限远的。这样才能正确地渲染出阴影。

那么有2个主要问题:首先,如何动态生成这个包围盒,而且能够覆盖无限远的范围;然后如何根据包围盒判断Fragment是否参与光照计算。

为便于理解问题,这里假设探讨的模型都是由三角形网格拼接组成的。例如对于中国龙模型和Cube模型,其三角形网格结构如下图所示:

三角形网格组成的模型

可以看到,中国龙模型是由非常多的三角形网格拼接而成的,这利于观察光照效果的真实感。Cube模型则仅仅由12个三角形拼接而成,这利于检测程序的正确性。请读者在随书代码中找到任意一个可以渲染中国龙模型的项目,为其添加PolygonModeSwitch开关,近距离观察中国龙模型的三角形网格。

要找到一个模型在光源照射下的包围盒,首先要找到在光源照射下的外围轮廓(Outline)。轮廓的一侧都是能被光源照射到的三角形,另一侧都是不能被光源照射到的三角形(即处于阴影中),例如下图所示:

(左)模型+轮廓线(中)模型(右)轮廓线

在此场景中,在中国龙模型的头部方向上有一个聚光灯光源(下方的Cube模型也是)。图左的白线勾勒出此时的轮廓线在模型上的位置,图中为模型本身,图右为隐藏了模型的轮廓线全貌。

轮廓是由一条条线段组成的。对于组成线段的每个顶点,沿着从光源位置到顶点的方向,无限地延伸出去,就是要找的包围盒的侧面。然后再把轮廓中朝向光源的一侧加上,再把无限远处封口,就得到了一个完整的包围盒。完整的包围盒如下图所示:

从远到近观察完整的包围盒

如图所示,完整的包围盒从模型的位置,无限地延伸到了场景的边缘。图左为地面遮挡了一部分包围盒的情形,图右为隐藏了地面后显示出的更完整的包围盒。可以看到包围盒在最远处仍然呈现出模型的轮廓的形状,且包围盒的近端和远端保持着对应关系。

注意,包围盒是一个完全封闭的盒子,其法线全部指向外侧。这是在构造包围盒时特意设计的。这样才能在后续的判断过程中找到阴影。

动态生成包围盒

第一个问题,如何判断哪个顶点是在轮廓线上的呢?观察下图:

光线L照射到2个三角形上

如图所示,两个三角形的交界处,是一条共享的线段AB。三角形ABC正面向光源L,能够被照射到,另一个三角形ABD则背面向光源L,即处于阴影中。那么这条共享的线段AB就应当成为轮廓线的一部分。如果两个三角形同时正面向光源或同时背面向光源,那么它们之间的共享线段就不需要算到轮廓线里。要判断一个三角形是否正面朝向光源,只需将光源的方向向量L与三角形面的法线向量N做dot乘法,如果结果为负数,说明是正面朝向光源;如果结果为正数,说明是反面朝向光源。

注意,这里使用的是三角形面的法线向量。Vertex Shader只能处理单个的顶点。要处理三角形面这样的对象,就要使用Geometry Shader。为了得知一个三角形与周围哪些三角形有共享边,这里需要使用GL_TRIANGLES_ADJACENCY模式渲染的模型。这样的模型有一个特点,即每个三角形都包含了其周边三角形的信息,如下图所示:

GL_TRIANGLES_ADJACENCY模式的图元

此图展示了一个三角形网格模型的一部分(由6个顶点组成的4个三角形)。其中三角形ACE是Geometry Shader要处理的一个三角形,而三角形ABC、CDE和EFA是与三角形ACE有共享边的三个三角形。这三个三角形被称为三角形ACE的邻接三角形。向Geometry Shader依次传入这6个顶点,即可找到哪些边构成了模型的轮廓线。一般的,模型数据中是不包含邻接信息的,这需要在加载模型后额外计算。

为实现Shadow Volume算法,找轮廓线的任务就是在Geometry Shader中完成的,其代码如下:

  1.  EmitOutline

注意,代码中的lightDir变量指的是从顶点到光源位置的向量,而图示 7‑13中的光源方向向量L是从光源到顶点的向量。两者是相反的。因此在代码中正面朝向光源的三角形面的法向量N与lightDir的dot结果为正数。

第二个问题,有了轮廓线,将轮廓线的每一条线段都延伸出去,分别形成一个四边形,就构成了包围盒的侧面。所有正面朝向光源的三角形,就构成了包围盒的近顶。沿着包围盒侧面的方向,把各个近顶面分别推向无限远处,并且翻转朝向,就构成了包围盒的远底。只需在生成轮廓线的代码基础上稍作修改,即可得到动态生成包围盒的Geometry Shader,代码如下:

  1.  EmitQuad

上文提到,包围盒的远底面是位于无限远处的。这是数学意义上的描述。具体到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实现阴影的渲染的过程如下伪代码所示:

  1. 1 void ShadowVolume(Scene scene, ..)
  2. 2 {
  3. 3 // Render depth info into depth buffer.
  4. 4 RenderDepthInfo(scene, ..);
  5. 5
  6. 6 glEnable(GL_STENCIL_TEST); // enable stencil test.
  7. 7 glClear(GL_STENCIL_BUFFER_BIT); // Clear stencil buffer.
  8. 8 // Extrude shadow volume and save shadow info into stencil buffer.
  9. 9 {
  10. 10 glDepthMask(false); // Disable writing to depth buffer.
  11. 11 glColorMask(false, false, false, false); // Disable writing to color buffer.
  12. 12 glDisable(GL_CULL_FACE); // Disable culling face.
  13. 13
  14. 14 // Set up stencil function and operations.
  15. 15 glStencilFunc(GL_ALWAYS, 0, 0xFF);
  16. 16 glStencilOpSeparate(GL_BACK, GL_KEEP, GL_INCR_WRAP, GL_KEEP);
  17. 17 glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_DECR_WRAP, GL_KEEP);
  18. 18
  19. 19 // Extrude shadow volume.
  20. 20 // Shadow info will be saved into stencil buffer automatically
  21. 21 // according to `glStencilOp...`.
  22. 22 Extrude(scene, ..);
  23. 23
  24. 24 // Reset OpenGL switches.
  25. 25 glEnable(GL_CULL_FACE);
  26. 26 glColorMask(true, true, true, true);
  27. 27 glDepthMask(true);
  28. 28 }
  29. 29 // Render the scene under the light with shadow.
  30. 30 {
  31. 31 // Set up stencil function and operations.
  32. 32 glStencilFunc(GL_EQUAL, 0x0, 0xFF);
  33. 33 glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
  34. 34
  35. 35 // light the scene up.
  36. 36 RenderUnderLight(scene, ..);
  37. 37 }
  38. 38 glDisable (GL_STENCIL_TEST); // disable stencil test.
  39. 39 }

Shadow Volume由3遍渲染完成。

第一遍渲染时,在不考虑阴影的前提下正常渲染场景。此时,Depth Buffer填充了正常的深度信息。这一次渲染的目的是准备好这一深度缓存,渲染的颜色并不重要。因此可以用最简单的Fragment Shader,甚至不使用Fragment Shader。

第二遍渲染前,启用模板测试,并按如下方式设置模板测试的函数和操作:

  1. 1 // Always pass stencil test.
  2. 2 glStencilFunc(GL_ALWAYS, 0, 0xFF);
  3. 3 // If depth test fails for back face, increase value in stencil buffer.
  4. 4 glStencilOpSeparate(GL_BACK, GL_KEEP, GL_INCR_WRAP, GL_KEEP);
  5. 5 // If depth test fails for front face, decrease value in stencil buffer.
  6. 6 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。而包围盒内部的模型(或其一部分)就位于阴影中。所以第二遍渲染的只有包围盒,这样就能区分出阴影部分,如下图所示:

Shadow Volume判断阴影

如图所示,场景中有一个点光源L位于左上角,一个地板(Floor)上方漂浮着一个立方体(Cube)。光源L照射到Cube和Floor上,Cube投射出自己的阴影,这阴影由包围盒描述处出来。图中ABCD都代表Floor上的一点。A点位于包围盒内部,包围盒的背面的深度测试会失败,所以此处的模板缓存值加1;包围盒正面的深度测试会成功,所以对模板缓存无影响。B点比包围盒更靠近Camera,因此此位置上的包围盒的正面背面的深度测试都会失败,所以此处的模板缓存值加1又减1,保持为0。C点比包围盒更远离Camera,包围盒的正面背面的深度测试都会成功,所以此处的模板缓存值保持不变,即为0。D点与包围盒的任何一部分都没有交集,因此不受包围盒影响,模板缓存在此位置的值保持不变,即为0。

包围盒本身是一个模型,但并不存在于原本的场景中,所以在第二次渲染过程中要通过下述代码来避免将其渲染到最终的场景中:

  1. 1 glDepthMask(false); // Disable writing to depth buffer.
  2. 2 glColorMask(false, false, false, false); // Disable writing to color buffer.

这样就保证了包围盒不改变深度缓存,也不会写入颜色缓存。同时,其他功能仍然能够正常进行。

为了保证包围盒的正面背面都被渲染,需要禁用背面剔除功能:

  1. 1 glDisable(GL_CULL_FACE); // Disable culling face.

第三遍渲染前,重新设置模板测试的函数和操作:

  1. 1 // Draw only if the corresponding stencil value is zero.
  2. 2 glStencilFunc(GL_EQUAL, 0x0, 0xFF);
  3. 3 // prevent updating to the stencil buffer.
  4. 4 glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);

此时设置模板测试仅允许模板缓存值为0的位置通过。也就是说,只有在第二次渲染时位于包围盒外部的模型(或其一部分)才会被渲染并可能影响到最后的Framebuffer。此时已经无需(也不应)修改模板缓存的值,所以设置在任何情形下都让模板缓存的值保持不变。

这时,只需按通常的方式用光照模型渲染场景,即可产生即有光照又有阴影的最终效果。

多光源下的阴影

无论Shadow Mapping还是Shadow Volume都可以简单地应用到有多个光源的场景中。类似于多光源下的光照模型Blinn-Phong,只需分别对每个光源执行一遍Shadow Mapping或Shadow Volume,并且用混合功能将各个光源的照射效果叠加即可。其伪代码如下:

  1. 1 void MultipleLights(Scene scene, ..)
  2. 2 {
  3. 3 // render ambient color.
  4. 4
  5. 5 foreach (var light in scene.Lights)
  6. 6 {
  7. 7 // preparation.
  8. 8
  9. 9 glEnable(GL_BLEND); // enable blending.
  10. 10 glBlend(GL_ONE, GL_ONE); // add lighting info to previous lights.
  11. 11
  12. 12 // light the scene up with specified light.
  13. 13 RenderUnderLight(scene, light, ..);
  14. 14
  15. 15 glDisable(GL_BLEND);
  16. 16 }
  17. 17 }

下图展示了同时用红绿蓝三色光源照射模型的场景:

多光源照射的光和影(左)点光源(中)平行光(右)聚光灯

图中用三个小球描述了光源的位置。对于平行光,小球描述的是光源的方向。

本文介绍了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的最快方式。这里给读者提出几个问题,作为抛砖引玉之用。

  1. 请在Github代码中的Demos\LogicOperation项目中尝试使用各种类型逻辑操作,观察各自的效果。注意,需要将鼠标移动到Cube模型上才能看到逻辑操作的效果。
  2. 任意选择一个示例项目,或者新建一个项目,尝试使用剪切测试(Scissor Test),观察效果。思考剪切测试能够帮助实现什么功能?
  3. 关于模版测试的示例项目Demos\StencilTest中,Cube的包围框的宽度随Cube的原理而逐渐减小。请尝试使用Shader来保证包围框的宽度保持不变。

*****************************************************************************************

博主笔记:阴影体的渲染算法中,我们没看到阴影本身(黑色像素)是如何渲染的,只看到了场景本身的渲染。

那么阴影是如何出来的呢?我的理解是:glclear(gl_color_buffer) 默认是黑色,同时渲染场景时使用stencilbuffer抛弃了对阴影中像素的处理,那么阴影像素就是gl_clear_color,为纯黑色。关于这个,可以参考:https://blog.csdn.net/jxw167/article/details/65435329

转载 用ShadowVolume画模型的影子的更多相关文章

  1. CSharpGL(48)用ShadowVolume画模型的影子

    CSharpGL(48)用ShadowVolume画模型的影子 在Per-Fragment Operations & Tests阶段,有一个步骤是模版测试(Stencil Test).依靠这一 ...

  2. CSharpGL(44)用ShadowMapping方式画物体的影子

    CSharpGL(44)用ShadowMapping方式画物体的影子 在(前文)已经实现了渲染到纹理(Render To Texture)的功能,在此基础上,本文记录画物体的影子的方式之一——shad ...

  3. [转载]sklearn多分类模型

    [转载]sklearn多分类模型 这篇文章很好地说明了利用sklearn解决多分类问题时的implement层面的内容:https://www.jianshu.com/p/b2c95f13a9ae.我 ...

  4. [转载] Cassandra入门 框架模型 总结

    转载自http://asyty.iteye.com/blog/1202072 一.Cassandra框架二.Cassandra数据模型 Colum / Colum Family, SuperColum ...

  5. [转载]OSI七层模型详解

    OSI 七层模型通过七个层次化的结构模型使不同的系统不同的网络之间实现可靠的通讯,因此其最主要的功能就是帮助不同类型的主机实现数据传输 . 完成中继功能的节点通常称为中继系统.在OSI七层模型中,处于 ...

  6. (转载)C# GDI+ 画简单的图形:直线、矩形、扇形等

    GDI+是一种绘图装置接口, 当拖动窗体是,窗体发生移动,window默认为从窗体移动到另一个地方,先发生擦除后再重新画一个窗体: 而我们自己动手画的图(如下面的线),不会重新画:在属性中,Paint ...

  7. 【转载】使用事件模型 & libev学习

    参考这篇文章: http://www.ibm.com/developerworks/cn/linux/l-cn-edntwk/ 这里面使用的是 libev ,不是libevent Nodejs就是采用 ...

  8. 【转载】高性能IO模型浅析

    服务器端编程经常需要构造高性能的IO模型,常见的IO模型有四种: (1)同步阻塞IO(Blocking IO):即传统的IO模型. (2)同步非阻塞IO(Non-blocking IO):默认创建的s ...

  9. 【转载】CSS 盒子模型

    转处:http://www.cnblogs.com/sunyunh/archive/2012/09/01/2666841.html 说在Web世界里(特别是页面布局),Box Model无处不在.下面 ...

随机推荐

  1. Codeforces 1215F. Radio Stations

    传送门 题目看一半:"woc 裸的 $2-sat$ 白给??" 看完以后:"...???" 如果没有 $f$ 的限制,那就是个白给的 $2-sat$ 问题,但是 ...

  2. 如何获取设置display:none元素及子元素的宽高

    由于元素设置了display:none时,页面便不会对其渲染,导致无法获取其元素的宽高.目前一般的做法都是先对其设置display:block,拿到数据再设置其为display:none.如此便可以了 ...

  3. python3小demo

    总结常用的功能小实例,快速学习并掌握python技能 1.墨迹天气 import requests from lxml.html import etree import json import tim ...

  4. EC元素

    '''判断title是否是一致,返回布尔值'''WebDriverWait(driver,10,0.1).until(EC.title_is("title_text")) '''判 ...

  5. PHP WEB 引擎缓存加速优化

    PHP 缓存加速器介绍 操作码缓存 请求一个 PHP 程序时,PHP 引擎会解析程序,并且将编译码作为特定操作码.这是要执行的代 码的一种二进制表示形式.随后,此操作码有 PHP 引擎执行并丢弃.操作 ...

  6. 【洛谷P1417】烹调方案 贪心+背包dp

    题目大意:一共有 n 件食材,每件食材有三个属性,ai,bi和ci,如果在t时刻完成第i样食材则得到ai-t*bi的美味指数,用第i件食材做饭要花去ci的时间.众所周知,gw的厨艺不怎么样,所以他需要 ...

  7. h5页面弹窗时页面固定(弹窗下面的页面不滑动)

    页面出现弹窗时,底部页面不能随之滑动怎么解决? 只需将页面的body增加一个样式 overflow:hidden;就能解决 jq: //开启弹窗 $('body').attr('style','ove ...

  8. 树莓派开机自动启动Chomium浏览器并打开指定网页

    树莓派开机自动启动Chomium浏览器并打开指定网页 cd /home/pi/.config mkdir autostart cd autostart vi my.desktop [Desktop E ...

  9. Chrome开发者工具面板 F12 调试大全 记录

    面板上包含了Elements面板.Console面板.Sources面板.Network面板.Timeline面板.Profiles面板.Application面板.Security面板.Audits ...

  10. XML DOM (Document Object Model) 定义了访问和操作 XML 文档的标准方法。

    XML DOM DOM 把 XML 文档视为一种树结构.通过这个 DOM 树,可以访问所有的元素.可以修改它们的内容(文本以及属性),而且可以创建新的元素.元素,以及它们的文本和属性,均被视为节点. ...