"我们活着只是为了发现美,其余的皆是某种形式的等待."
-Kahlil Gibran
时间回到2015年的那个多雨的盛夏,我在颓废了小半个月后终于挣扎地上线干活,在给CustomSteve做Shadersmod(即喜闻乐见的光影Mod)的兼容时我了解到了一些Shadersmod的特性,在暑假的最后日子里我决定把我之前学到的东西和积累到的经验总结一番,写成一篇关于如何制作MC光影包的教程,结果这一干就是半年,8月我曾乐观地认为能在开学前完成教程,而到了9月末我认为在国庆期间可以完成,当时间到了12月时我肯定这篇教程不会拖到明年,但事实上如今当我写下这段文字时几年来最冷的寒冬都已经快过去了,幸运的是,如今它终于完成了.
上篇 - Shadersmod光影包基础
基础预备
这个教程需要读者具有最基本的线性代数知识,至少了解矩阵的特性.这个教程的上篇对读者能力的最低要求是略微了解OpenGL,至少要知道什么是纹理,以及了解OpenGL的各个坐标系和顶点变换流程.对GLSL的掌握要求并不高,因为在教程中会循序渐进地介绍各种功能和特性.而对于图形学知识,上篇的要求并不高,只要了解一些常识性的东西,比如知道屏幕上的画面是通过光栅化以及像素着色得来的,而不是通过古中国的邪恶蛊术制造的就行了 ☺
为了编写着色器脚本,读者还需要一个除Windows记事本以外的任意现代文本编辑器,比如Notepad++.专业的着色器IDE是可选的,由于Shadersmod光影包开发的特殊性质,在IDE中测试着色器不太可行,但其在编译时的语法检查可以提高不少开发效率,RenderMonkey是一个不错的选择,而ShaderAnalyzer虽然难以胜任编辑器的功能,但它的严格语法检查功能可以用于代码除错,这里强烈推荐安装一个ShaderAnalyzer,虽然它是为ATI显卡设计的,但由于它内置了符合ATI规范的GLSL编译器,因此可以在任何机器上运行.不过它的除错能力偶尔也有失败的时候,比如它无法检测到向vec3变量赋一个vec4值的情况.当你遇到Shadersmod报告着色器错误,但却始终无法找到错误原因时,可以按正常顺序退出游戏,然后在游戏目录中(如果你是使用第三方启动器,有可能是在启动器目录)的logs文件夹里找到shadersmod.txt,在它的结尾处附近往往记录了遇到的着色器编译错误(真是纳闷为什么不设计为直接打印在屏幕上).
最后,本教程是在Windows10和Nvidia GTX970的软硬件环境下撰写和测试的,在截稿前所有章节的最终代码已在Win8.1和Win7与Intel HD4400, Nvidia GTX650M和GTX750M中进行了实机测试并在ShaderAnalyzer中通过了针对ATI驱动规范的静态检查.理论上讲你在Windows环境和A、N、I三家显卡上都不会遇到问题,然而如果你在OSX或Linux甚至是A卡I卡上遇到了问题的话,可能只有靠自行搜集网上的资料来解决了.
Shadersmod、固定管线与着色器
回想一下当你第一次见到Minecraft光影包时的感觉,是不是就像黑魔法一样?为何MC原版那么简陋的画面只要安装上一个Mod就能拥有一个主流游戏的画质?它是怎么绘制阴影和反射的?
一切都要归功于着色器(Shader),着色器就是决定内存或显存中的顶点数据和纹理是如何在GPU上正确(或错误)地变形、组装、光栅化成像素并进行着色然后绘制到屏幕上的黑魔法,遥想在过去消费级显卡尚未出现的年代,编写软件渲染器是每一个3D游戏程序员都必须掌握的事情,在主频只有数百MHz的CPU上以定点数学编写渲染器虽然不是愉快的事情,但至少那时的程序员对每一个像素都有绝对的控制权.而当显卡普及时硬件几乎完全接管了渲染的处理,只留给了程序员一套API,在一些老的图形学或游戏制作相关的书籍中你经常能看到"硬件T&L"这个名词,它指的就是由硬件实现的顶点变换和光照,也就是所谓的固定管线渲染.硬件加速的渲染固然高效,但却让程序员失去了对像素的控制权,开发者只能通过图形API提供的最简单的操作,通过搭积木似的拼凑出想要的效果,你还记得在OpenGL1.0规范中甚至不包括纹理吗?随着时间的推移,可编程电路逐渐取代了单一功能的电路,在原本由CPU一家独大的主板上,另一股不容忽视的计算力已经悄然崛起,显卡已经从只能执行简单任务的劳工变成了可以处理灵活任务的工程师了,但毕竟各家显卡的编程规范不同,程序员如果想编写一个能在大多数主流显卡(要知道那时候的显卡商是百花齐放)上运行的着色程序得学习数款显卡驱动和它们的汇编语言(高级语言?抱歉,没有),毕竟不是每一个图形学程序员都能像约翰卡马克那样一天适配一款显卡,业界需要一个统一的标准,这时作为后起之秀的DirectX却抛下了自诩为业界标准的OpenGL,独自扛起了可编程着色器的大旗,在2000年的DirectX8.0中提供了统一的汇编语言用于编写着色器,而在这一关键时期OpenGL规范的众谋特性却让它没能赶上时代步伐,OpenGL规范是由各家(不管是不是做显卡的)统一协定,这种制度免不了会有各种扯皮,结果是直到2004年9月它才提供了一个可用的着色器语言 - GLSL(OpenGL着色语言),不过相比它的老冤家DirectX来说倒也不算太糟,毕竟DirectX当初提供的是汇编语言,而OpenGL提供的是一个C-like的高级语言,相比之下DirectX到了2004年11月才做出了同为高级语言的HLSL(高级着色语言).着色器语言用于编写一个着色器程序,经由驱动编译成显卡能执行的汇编语言后传入显卡,替代原本的固定管线,以此让开发者得以控制渲染的方式.
Minecraft原版就是采用固定管线进行渲染(据说在1.9要换成等效的着色器形式实现,其实现在显卡中已经不存在固定管线了,所谓的固定管线实际上是一组能实现等效功能的预置着色器),而Shadersmod就是替换了渲染程序,将它们替换成了可编程的GLSL脚本,并交给光影包开发者来开发,当在Shadersmod中载入一个光影包时,内部流程其实就是载入GLSL脚本并交给OpenGL编译,然后替换掉原来的固定管线,将所有的渲染都交给开发者编写的着色器程序来处理.
然而,毕竟Minecraft本身是为实现简单效果的固定管线而设计,有些效果即使是在可编程的着色器中也难以直接实现,为了实现这些复杂的效果,Shadersmod曲线救国,提供了延迟渲染的支持.
何为延迟渲染
Shadersmod为MC引入了延迟渲染(Deferred Rendering)的支持,什么是延迟渲染?这要从最早的光照技术说起,在早期的固定渲染管线时代,光照多采用逐顶点光照,即光照的计算仅在顶点变换时进行,像素的光照效果通过顶点间插值来实现.到了可编程着色器时代,光照开始逐渐采用逐像素光照,光照计算会在每一个像素上进行.到目前为止,这些技术都还属于前向渲染,所有的光照和着色计算都在正常渲染中完成.
然而顶点光照和像素光照都面临一个问题,就是光照的性能消耗跟绘制物体的数量与光源数量之积成正比,每一个物体在渲染时都要计算与每一个光源的光照效果,更遗憾的是其大多都是无用功,因为深度测试是在像素着色之后进行,很多不可见的像素占用了大多数的计算力,一些技术比如Early Z-Test和Z-PrePass便是为了预剔除不可见像素而努力.
这时一个奇特的技术:延迟着色出现了,延迟着色是在首遍渲染时不进行着色,仅将物体的几何信息(比如位置与法线)和材质信息全部输出到被称为G-Buffer的缓冲区,然后在第二遍渲染时按照后处理的方式根据G-Buffer渲染出最终图像,由于G-Buffer中的各像素一定是可见的,因此它保证了我们能将宝贵的计算力用在刀刃上,渲染的复杂度也由之前的O(M*N)变成了O(M+N);此外还有一些技术比如延迟光照(通过三遍渲染来降低G-Buffer的带宽压力)甚至是实验性的延迟贴图(Deferred Texturing...有兴趣的可以谷歌一下),不过这些都与我们无缘了,因为Shadersmod只支持延迟着色...无论如何,这些渲染方法都被统称为延迟渲染,因为它们将部分或全部着色推迟到第二遍或第三遍渲染时进行.
此外还要纠正个误区,就是Shadersmod强迫所有光影包使用延迟渲染,其实你完全可以使用前向渲染,只要将所有着色过程放在绘制G-Buffer的片元着色器即可.
(最后还有个小吐槽:延迟渲染对Minecraft来说真的有必要吗?我觉得用途不大,延迟渲染的意义在于处理巨大数量的光源,如果它Shadersmod真的支持动态光源,彻底一脚踢开游戏的光照系统,完全实现动态光源的话,那延迟渲染是完全有必要的,但问题是它现在并不支持,因此它的意义只局限在能在G-Buffer中收集足够的几何信息,在着色阶段渲染出原本缺乏足够信息绘制不出来的效果)
Karyonix says hello to you - 初识光影包
==================================================
知识点:光影包的组成
一个光影包由4部分组成:G-Buffer绘制,阴影贴图绘制,后处理以及最终合成.
- G-Buffer绘制:此部分的着色器用于将游戏中的物体绘制在G-Buffer当中,该部分所有的着色器的名称均以"gbuffers_"为前缀.具体为:
gbuffers_basic:绘制无纹理的纯颜色,目前只有在绘制天空时会少量用到它...
gbuffers_skybasic:绘制无纹理的天空物体,它与basic唯一的差别是basic不受雾影响,skybasic受雾影响.
gbuffers_skytextured:绘制有纹理的天空物体,比如太阳和月亮
gbuffers_textured:绘制不受光照影响的纹理
gbuffers_textured_lit:绘制受光照影响的纹理
gbuffers_entities:绘制实体
gbuffers_hand:第一人称下绘制手
gbuffers_terrain:绘制砖块
gbuffers_water:绘制半透明物体(不包括纯透明和不透明相间的物体,比如树叶),比如水,染色玻璃(不包括无色的玻璃),染色玻璃板(不包括无色的玻璃板)和冰(不包括浮冰).
gbuffers_weather:绘制天气效果,如下雨和下雪
gbuffers_spidereyes:唔...似乎是观众模式下附体到蜘蛛身上时的蜘蛛视觉效果?
G-Buffer绘制采用了后备链制度,每种着色器均会有自己的后备着色器,如果这种着色器不存在的话,Shadersmod会沿着后备链向后搜索可用的着色器作为替补,如果实在找不到的话,则会使用OpenGL默认的固定管线进行渲染,后备链如下图所示:
图左侧的None即代表OpenGL默认的固定管线渲染,除此之外的任何着色器在不存在时,Shadersmod会从它左方第一个着色器开始,依次向左查找,直到找到一个可用的着色器,或者发现没有着色器可用为止.
举例,如果gbuffers_water不存在,而gbuffers_terrain存在的话,Shadersmod在绘制水体时会用gbuffers_terrain着色器来渲染.如果gbuffers_hand不存在的话,Shadersmod会去查找gbuffers_textured_lit,如果还找不到的话就去查找gbuffers_textured,然后是gbuffers_basic. - Shadow Map绘制:Shadow Map绘制其实是发生在G-Buffer绘制之前,不过在这里我颠倒了写作顺序.Shadow Map即阴影贴图,用于Shadow Mapping - 一种绘制阴影的方法,具体内容会在后文介绍.这一项是可选的,如果没有的话,系统会自动为你生成一个.
- 后处理:后处理包括composite和composite1~9这10个着色器,它们都不是必须存在,当它们存在时,Shadersmod会按照数字顺序(composite视为0)依次调用.在后处理阶段,所有必须的几何信息都已在G-Buffer绘制阶段整理完毕,着色器可以在此阶段修改G-Buffer(比如调整现有的颜色),甚至是写入新内容(比如渲染Bloom特效时需要一块额外的缓冲).
- 最终合成:此阶段的着色器名称为final,这个着色器和后处理着色器没有太多区别,唯一差别是它必须根据G-Buffer信息绘制出最终图像并输出.这个着色器也是可选的,如果它不存在的话,Shadersmod会将帧缓冲上的0号颜色缓冲的内容直接转绘到最终图像上.
每一个着色器均由顶点着色器(后缀名.vsh)和片元着色器(后缀名.fsh,其实就是像素着色器啦...)组成.所有的着色器文件均放在"[minecraft目录]/shaderpacks/[光影包名称]/shaders/"目录下.
===================================================
Make a Shader Pack from scratch - 从零实现固定管线
实现一个固定管线相当无聊,如果你已经对GLSL有一定了解的话,可以跳过这部分,此部分的最终结果可以从这里下载: [SkyDrive] [百度网盘]
==================================================
知识点:GLSL简单介绍
这里会简单介绍一下GLSL,已经熟悉它的人就可以跳过这块了.
GLSL是OpenGL使用的着色器语言,它是一个C-like语言,语法和C极为相似,区别在于:
- 渲染用的着色器是由两部分组成,用于控制顶点变换的顶点着色器和用于控制像素着色的片元着色器.
- 使用#version XXX来声明版本,比如#version 120为使用1.2版GLSL规范.
- 数据类型有限,没有指针,在早期版本(比如1.1,1.2)中标量只有float,int和bool,后期版本加入了uint,double.
- 由于没有内存分配和指针,所有数组必须是定长数组.
- 有矢量数据类型,比如vec2、vec3和vec4分别是二三四维float向量,mat2、mat3和mat4分别是2x2,3x3,4x4 float矩阵,matNxM为N*M矩阵,最大为4x4.对于向量类型,可以通过.xyzw、.rgba或.stpq来访问分量,这三种写法没有实质差别,比如对一个vec4变量somevec,somevec.xyz和somevec.rgb都是抽取前三个分量作为一个vec3,somevec.w和somevec.a都是取最后一个分量作为float变量,这三种写法纯粹是供人类阅读方便的,但是不可以混合使用,比如somevec.xgpw就是找打的写法.
- 有采样源类型,比如sampler2D就是一个2D纹理.
- 有特殊的变量修饰符,比如attribute顶点变量,在顶点着色器中可用,每个顶点均有它独特的值;uniform一致变量,任何着色器均可用,用于存储常量,在一次渲染(即一次DrawCall)中uniform可以视为常量;varying可插值传递变量,由顶点着色器传递给片元着色器的变量,当一个三角形上不同顶点间的输出值不同时,会被插值然后传递给其上的像素.
- 特殊的形参修饰符,in输入参数,也是什么都不写时的默认选项,只可读不可写(wiki上说的是可写但结果不会影响实参,就像C那样,但在我这里有时可以这样做,有时却不行...闹不明白);out输出参数,函数可以向这个参数输出值,结果会被反馈给实参,以此可以实现多输出函数;inout引用参数,即可写又可读,结果会被反馈给实参.
- 对int和相关的整数操作与位运算的支持到1.3才出现.
- 没有字符串
共同点也是有不少的:
- 几乎一摸一样的语法,除了没有字符串和指针以外.
- 同样要求函数声明必须在调用之前.
- 都支持预处理器,格式相同,包括让编译器程序员吐血的多行预处理.
- 支持结构体.
想更多了解GLSL的话,可以看一下官方wiki https://www.opengl.org/wiki/Core_Language_(GLSL) 以及上网搜一些教程啥的.
==================================================
首先我们在shaderpacks目录下新建一个文件夹,名字就叫MyFirstShader吧,然后在里面创建一个叫shaders的文件夹.
然后打开游戏,此时你就已经可以在菜单中选择你的光影包了!
但是打开之后你会发现并没有什么卵用!游戏依然还是原来的样子,这是因为正如上文所言,G-Buffer绘制由于有后备链的存在,在这里全都已经被默认管线取代其功能了,由于ShadowMap没有使用,因此ShadowMap绘制被跳过了,后处理由于没有相关的着色器被直接跳过,最终合成由于没有着色器也被自动处理了.因此自然游戏效果和原来没有什么区别.
现在我们先来实现第一个G-Buffer绘制着色器:gbuffers_basic
在shaders目录下创建2个文件:gbuffers_basic.vsh和gbuffers_basic.fsh,在里面输入:
gbuffers_basic.vsh:
#version 120 varying vec4 color; void main() { gl_Position = ftransform(); color = gl_Color; }
gbuffers_basic.fsh:
#version 120 varying vec4 color; void main() { gl_FragData[0] = color; }
开头的#version 120是用来声明GLSL版本,如果你熟悉OpenGL的话,你应该会知道它出了名的平台差异性,不同的显卡甚至同一种显卡的不同版本驱动对GLSL着色器的处理方式都不尽相同,如果不声明版本的话,有些OpenGL实现会将着色器按照当前平台所支持的最高版本来对待(具体还要分为是按核心版本对待还是兼容版本),有些则会按照最低版本对待,这意味着你的光影包可能在某些显卡上跑的很溜,某些显卡上就跪成椛,因此为谨慎起见这里我们主动声明了GLSL版本为1.2,即#version 120. GLSL1.2为OpenGL2.1所使用的版本,能运行Shadersmod的机器肯定是支持OpenGL2.1的.其实这里使用GLSL1.2完全是为了照顾OSX,Minecraft在OSX上只能使用OpenGL2.1...
然后是varying vec4 color,熟悉低版本GLSL的人都会知道只是声明一个能从顶点着色器传递到片元着色器的可插值变量,在高版本中我们使用in和out来表示传入和传出...不多说了,那些皋大上的东西不是我们这些用1.2的土鳖需要考虑的.
之后是顶点着色器中的gl_Position = ftransform(),这也是低版本GLSL特有的一个福利 - 全自动的MVP(Model, View, Projection)变换,由于在这里我们还不需要计算其它特殊的信息,因此就先使用系统自带的变换了.
最后是片元着色器中的gl_FragData[0] = color,对没使用过MRT的人来说,gl_FragData是个奇怪的东西,MRT即Multi Render Target,指同时向多个缓冲区输出数据,在不启用MRT的时候,我们都是使用gl_FragColor向单个缓冲输出像素颜色.在启用MRT后,我们通过将gl_FragData按照数组的形式来操作,来向帧缓冲中的多个颜色附件输出像素颜色.
==================================================
知识点:帧缓冲
这一部分是面向不了解帧缓冲的人,如果你已经了解相关的内容的话可以跳过.
帧缓冲有很多意思,在Linux上它是一个简单的绘图接口,在硬件上它是输出到显示设备前的一个图像缓冲区,而在图形API中,它指的是一个渲染对象,在没有帧缓冲之前,OpenGL不支持离屏渲染,所有的绘图操作的最终输出对象都是屏幕或屏幕的缓冲区,而在有了帧缓冲之后,OpenGL允许开发者在渲染前绑定上一个帧缓冲,在完成绑定之后,所有的绘图操作的结果都会输出到该帧缓冲上.
帧缓冲还有一个巨大的优势是允许挂载多个附件,众所周知,OpenGL的屏幕缓冲所使用的像素格式在启动时就已经决定了,无法轻易改变,而帧缓冲通过附件的形式,允许开发者随意挂载复数个多种格式的缓冲附件,附件包括颜色缓冲、深度缓冲、模板缓冲和深度模板缓冲这四种,颜色缓冲又根据需求分为RenderBuffer和Texture两种,这里Shadersmod使用的是Texture,也就是纹理,它的好处是既能当做渲染对象,又能作为纹理被采样.
==================================================
之后保存游戏,在游戏里将光影包切换回(none)或(internal),然后再切换回你的光影包. (能够随时重新加载着色器是一件很方便的事,这也是通过在代码中硬编码来控制的固定管线所无法企及的)
这次真成纯色的大色块了...如果你没装其它的什么人物模型Mod的话,此时你的人物应该是几个白色几何体...除了没有纹理外,尝试往深处挖一挖,或者干脆将时间跳到晚上,你会发现周围依然清晰如白昼,显然现在我们还没有光照效果.
那么接下来我们开始制作gbuffers_textured,实现了这个后,我们就至少有了纹理效果了.
复制一份gbuffers_basic(包括它的2个着色器文件),然后改名为gbuffers_textured.vsh/fsh,之后修改它们的内容.
gbuffers_textured.vsh:
#version 120 varying vec4 color; varying vec4 texcoord; void main() { gl_Position = ftransform(); color = gl_Color; texcoord = gl_TextureMatrix[0] * gl_MultiTexCoord0; }
gbuffers_textured.fsh:
#version 120 uniform sampler2D texture; varying vec4 color; varying vec4 texcoord; void main() { gl_FragData[0] = texture2D(texture, texcoord.st) * color; }
与basic相比,textured的顶点着色器变量多了个texcoord,用于表示输出到片元着色器的纹理坐标;主函数中多了个texcoord = gl_TextureMatrix[0] * gl_MultiTexCoord0, gl_MultiTexCoord0表示在启用多重纹理时的0号TextureUnit的坐标,gl_TextureMatrix则是OpenGL中最容易被忽视的矩阵:纹理坐标矩阵,在固定管线时代,它可以实现一些特殊效果,比如被雷劈后的充能苦力帕身上的能量罩的流动效果就是通过纹理坐标矩阵来实现的.
片元阶段则是多了主纹理的采样器texture,在主函数中,我们根据纹理坐标,从采样器中获得纹理的颜色,然后再乘以自带的颜色来获得最终颜色.
再进入游戏重新加载一遍试试,现在已经有纹理效果了,但依然没有光照.
那么接下来我们来实现光照效果,继续复制一份gbuffers_textured,改名为gbuffers_textured_lit.vsh/fsh.内容为:
gbuffers_textured_lit.vsh:
#version 120 varying vec4 color; varying vec4 texcoord; varying vec4 lmcoord; void main() { gl_Position = ftransform(); color = gl_Color; texcoord = gl_TextureMatrix[0] * gl_MultiTexCoord0; lmcoord = gl_TextureMatrix[1] * gl_MultiTexCoord1; }
gbuffers_textured_lit.fsh:
#version 120 uniform sampler2D texture; uniform sampler2D lightmap; varying vec4 color; varying vec4 texcoord; varying vec4 lmcoord; void main() { gl_FragData[0] = texture2D(texture, texcoord.st) * texture2D(lightmap, lmcoord.st) * color; }
这一次我们又引入了新东西:顶点着色阶段的lmcoord,代表在光度图(Lightmap,Minecraft每隔一段时间生成一个光度图,用来表示一个物体在指定光照环境下的亮度)中的坐标,s(x)轴对应人造光源强度,t(y)轴对应环境光源强度.片元阶段则是多了个乘光照颜色.
再次进入游戏测试,此时我们有了光照效果了,但不觉得有什么不对劲? Yep,没有雾效果.
关于是否实现雾效果我挺纠结的,因为那么多光影包没有一个实现雾效果的...而且Shadersmod对雾效果还有Bug,具体可以见附录,但毕竟我们这里是要用着色器实现硬件管线,因此还是加上雾的渲染吧.
处理雾渲染的一个棘手之处在于并非每一种物体都受雾影响,因此需要区分对待,这里总结出了一个规律:
右边的我就懒得写了...按后备链规则,显然它们都是受雾影响的.这张图有一个问题,就是最初我只考虑了线性状雾(Minecraft大部分时间你所看到的雾),而没有考虑指数状雾(在水下和下界看到的),因此称gbuffers_skybasic只有在fogMode不为0时才会有雾,事实上如果严格按照fogMode的定义来的话,根本无需区分哪个有雾哪个没雾...
于是我们就开始按照这个规律开始修改了,首先是处理gbuffers_skytextured,它与gbuffers_textured的唯一区别是前者不受雾影响而后者受,因此我们首先直接复制一份gbuffers_textured,然后将它改名为gbuffers_skytextured.vsh/fsh.
接下来我们修改gbuffers_textured,OpenGL雾的计算规则分为线性,指数状和指数平方状这三种,不过Minecraft只使用了线性雾,因此这里我们就只实现线性雾就行了.噫,其实还是用到指数状了,因此我们要实现线性和指数状两种.
修改gbuffers_textured.vsh,将
gl_Position = ftransform();
修改为
vec4 position = gl_ModelViewMatrix * gl_Vertex; gl_Position = gl_ProjectionMatrix * position; gl_FogFragCoord = length(position.xyz);
然后在gbuffers_textured.fsh中添加:
uniform int fogMode;
然后在"gl_FragData[0] = texture2D(texture, texcoord.st) * color;"的下方加入:
if(fogMode == 9729) gl_FragData[0].rgb = mix(gl_Fog.color.rgb, gl_FragData[0].rgb, clamp((gl_Fog.end - gl_FogFragCoord) / (gl_Fog.end - gl_Fog.start), 0.0, 1.0)); else if(fogMode == 2048) gl_FragData[0].rgb = mix(gl_Fog.color.rgb, gl_FragData[0].rgb, clamp(exp(-gl_FogFragCoord * gl_Fog.density), 0.0, 1.0));
回顾一下我们干了什么,首先我们拆开了ftransform,将原来的MVP变换一条龙拆成了模型视图变换(MV)和投影(P)变换,这是因为雾坐标计算(其实不止雾计算,很多和光照有关的计算也是如此)是发生在MV变换后,P变换之前.在线性规则中雾坐标的计算很简单,就是计算该点到视觉原点的距离,(这里再补充一遍MVP变换的知识,我们在OpenGL中绘制的模型,顶点是被放置到模型坐标系中,在经过模型变换后到了世界坐标系;然后经过视图变换后到了眼坐标系;最后经过投影变换到了剪裁坐标系,此时顶点便脱离了可编程着色管线,要经由固定管线来处理了.)由于我们是在眼坐标系下,视觉原点就在坐标原点,因此直接计算该点到原点的距离就行了.
然后我们在片元着色器中根据雾类型(9729为线性雾,2048为指数雾,具体定义见附录)和其它参数,计算出了雾的浓度,最后对原颜色和雾一起进行混合.
于是按照这个规则如法炮制,复制一份gbuffers_basic,将其改名为gbuffers_skybasic.vsh/fsh, gbuffers_skybasic.vsh的修改同上文的gbuffers_textured.vsh.
最后将gbuffers_textured_lit也按照gbuffers_textured的方法进行修改,这样我们就获得了一个和固定着色管线功能一模一样的光影包,Mojang要到MC1.9才能实现的功能我们现在不到一小时就实现了! (迫真装逼)
顶点着色器实战 - 草的摇摆效果
这一次我们来尝试写第一个光影包新功能:草的摇摆效果.
显然草的摇摆效果是通过顶点着色器来实现,草的渲染是通过让2个正方形以十字交叉的样子渲染在游戏中来实现,我们只要让那2个正方形上方的顶点向一个方向移动就能让草看上去在摆动.
图:通过移动顶点让矩形进行切变. 纹理源自Nickm77的EPR材质包.
首先我们复制一份gbuffers_textured_lit,将其改名为gbuffers_terrain.vsh/fsh,之后准备修改gbuffers_terrain.vsh.
首先我们要解决的是如何判断当前砖块是草,Shadersmod提供了一个顶点属性mc_Entity,它是一个vec4变量,在用来绘制砖块时,X值代表当前砖块的砖块ID,Y值代表其渲染类型(RenderType)(注:我不确定这个值在1.8还是否存在,因为1.8大改了渲染系统,似乎去掉了RenderType了),Z值代表Metadata,不过虽然这些值按说都是整数值,但Shadersmod却是转换成浮点值传过来的.
然后要解决的是如何判断顶点是上面的还是下面的,毕竟草的根部是不动的.由于Minecraft在渲染地形时是按16x16x16为一组的方式来渲染,因此在模型坐标系中判断顶点的高度(Y坐标)是不管用的;因此这里我们通过判断顶点的纹理坐标的方式来判断顶点位置.
然而这里还有2个问题需要解决,首先,Minecraft为了提高渲染效率,在内部会将砖块纹理拼接成一张大的纹理(其实老开发者都知道早期的MC(大概是什么时候来的?1.5以前?还是1.6以前?)就是一张大纹理包含全部的砖块和物品贴图,现在的MC只不过是为了方便开发者,将拼接纹理的工作放在游戏里进行了),这意味着它的纹理坐标不再是(0, 0) (0, 1) (1, 0) 或(1, 1)的形式,而可能是位于0~1之间的任何值,这导致我们没法判断纹理坐标的位置.不过好在Shadersmod提供了一个vec4定点变量mc_midTexCoord(这里不得不吐槽Shadersmod的Wiki文档的糟糕,mc_midTexCoord的作用,以及mc_Entity的Y值和Z值的作用,都完全没有提及,是我在论坛上的帖子里从作者的留言中发现的...看来想完全搞懂Shadersmod得先像读腊肉语录那样把作者的发言全看一遍)来辅助我们判断坐标,mc_midTexCoord的st值代表纹理中当前待绘制区域的中点位置,这样通过对比纹理坐标的T值和mc_midTexCoord的T值就能判断出顶点是顶部的还是底部的.
另一个问题则是MC中的纹理(仅限MC的纹理,不包括帧缓冲的各个附件)是上下颠倒的,或许这是为了在操作上迎合DirectX风格的纹理坐标系,在大部分GUI程序中,坐标系都是规定窗口左上角为原点,T轴(也就是Y轴,在纹理操作上我们习惯用ST指代XY,当然这只是一种约定俗成的习惯,对GLSL来说st和xy没什么区别)向下为正,DirectX也是这样规定的纹理坐标系,以纹理左上角为原点.然而OpenGL规定纹理坐标系和其它坐标系一样,采用T轴朝上为正的设定,这意味着原点位于左下角,这种设计虽然做到了坐标系的操作统一,但确实有违于很多人的习惯,因此这里有一种曲线救国的方式,就是在传入纹理时直接把纹理上下颠倒一下,此时原点依然在纹理左下,T轴也依然朝上为正,但是在操作时却可以认为是原点在左上角而T轴朝下为正.
(注:这里最初有个乌龙,写的是OpenGL本来就以T轴朝下为正,事实上这是错误的,发布前想着把这段改过来,结果居然忘了!)
图:MC的纹理的样子.
图:如果想象成纹理未颠倒但T轴向下的话,就变成正常的纹理了.
在操作的时候,你可以想象成原点在左上角,T轴朝下为正,右下角为(1, 1),因此在判断坐标时纹理坐标的T值小于中心点的T值(即靠近上方)代表此顶点对应着草的高处.这个问题其实也是有一定的历史背景,具体可以见"附录 - ST坐标系,朝上还是朝下?"
最后要解决的是如何让草摆动 - 噫,前文不是已经说过是通过修改顶点位置了吗?但那只能让草看起来像弯了一样,但依然是静止不动的,如果要让它看上去是在摆动,那就需要让它的位置不断变化.Shadersmod为我们提供了两个代表时间的一致变量(uniform),一个是int格式的worldTime,代表当前世界的时间,范围在[0, 24000);另一个是float格式的frameTimeCounter,它是以秒为单位计算游戏正式启动(即载入了一个存档/加入了一个服务器)后的时间,这个变量的范围是[0.0, 100000.0),即每100000秒它便会归零一次,用来避免浮点误差,这个时间约合于27.78小时,显然大于大部分人的Minecraft在不崩溃前所能运行的时间,因此这并不会成为导致运维人员失眠的原因,况且即使它真的归零了...也不会有什么大的影响,不过是那些像sin(frameTimeCounter)这样依赖它的算法产生一个一瞬间的不连续而已.不过需要注意的是,这个值不会因为游戏暂停而停止改变...毕竟即使当游戏暂停时,画面也是在渲染的.(顺便再婊一下Shadersmod的文档,根本没有提frameTimeCounter这个东西)
探究完可行性后我们就可以开始实际编码了,首先在gbuffers_terrain.vsh中添加这几个变量:
uniform float frameTimeCounter; attribute vec4 mc_Entity; attribute vec4 mc_midTexCoord;
然后将
vec4 position = gl_ModelViewMatrix * gl_Vertex;
改成
vec4 position = gl_Vertex; float blockId = mc_Entity.x; if((blockId == 31.0 || blockId == 37.0 || blockId == 38.0) && gl_MultiTexCoord0.t < mc_midTexCoord.t) { float time = frameTimeCounter * 3.0; position.x += sin(time) * 0.2; position.z += sin(time) * 0.2; } position = gl_ModelViewMatrix * position;
首先,我们拆分了position的计算,将获取顶点在模型坐标系中的位置(即获取gl_Vertex变量)和MV变换拆成了两步,因为我们接下来的操作要在模型坐标系中进行.
然后我们获取了砖块ID并判断它的具体类型,这里31是草的ID,37和38是蒲公英和花的ID.此外还根据纹理坐标来判断是否为草的顶部.
现在再来测试一下,现在游戏里的草都像磕了药一样,齐刷刷地朝同一方向来回摆动...
那么接下来我们给它们整点多样性,让不同位置的草朝不同方向摆动如何?简单的解决方案是把XYZ坐标什么的代入公式,让影响最终结果的变量有不止一个,不过在这里我们会尝试使用一个更皋大上的东西:噪声图
噪声图是一组预先生成的随机数,这些随机数的范围在0.0~1.0之间,每个数的最小间隔是1/255(好吧,按人话说就是每个随机数刚开始被生成在0~255之间,然后被除以255.0,归一到0.0~1.0之间),这些数3个一组,被打包在一个纹理中,在需要随机数的时候,系统就不必再去生成,而是直接从噪声图中抽一组随机数来使用.
要启用噪声图,首先要在一个片元着色器中(随便哪个片元着色器都行,但最好只有一个,因此建议在final或composite中的一个)中加入const int noiseTextureResolution = X,其中X为噪声图分辨率,分辨率越高则包含的随机数越多,重复越少,但我认为256就足够了.
接下来,先在gbuffers_terrain.fsh中加入:
const int noiseTextureResolution = 256;
然后在gbuffers_terrain.vsh中添加:
uniform sampler2D noisetex;
之后修改我们原来的代码:
vec4 position = gl_Vertex; float blockId = mc_Entity.x; if((blockId == 31.0 || blockId == 37.0 || blockId == 38.0) && gl_MultiTexCoord0.t < mc_midTexCoord.t) { vec3 noise = texture2D(noisetex, position.xz / 256.0).rgb; float time = frameTimeCounter * 3.0; position.x += sin(noise.x * 10.0 + time) * 0.2; position.z += sin(noise.y * 10.0 + time) * 0.2; } position = gl_ModelViewMatrix * position;
(注:就实用性而言,使用XYZ坐标作为扰动因素其实是性价比最高的,它既能保证质量又足够快速,噪声图会涉及到纹理采样,速度要慢一些,只不过数据量太小体现不出来而已. 这里用到噪声图仅仅只是为了演示它的使用方法.)
这一次改进后,游戏中的花和草已经会向不同的方向摆动了,但如果总是摆动的话会显得有些不自然.而且我们希望它们在雨中摆动的更猛烈一些,我们可以使用float类型的一致变量rainStrength来实现这个,rainStrength代表雨或雪的强度,默认情况下它是0,随着雨或雪的增大会逐渐增加,直到1.0为止,雨和雷暴都是这个值.但似乎在停止时有些问题,它不会逐渐减小,而是继续逐渐增大,然后在雨停的一瞬间骤然变为0,不过在大部分情况下,它工作的还是很好的.
这一次我们在gbuffers_terrain.vsh中加入:
uniform float rainStrength;
然后继续修改之前的代码:
vec4 position = gl_Vertex; float blockId = mc_Entity.x; if((blockId == 31.0 || blockId == 37.0 || blockId == 38.0) && gl_MultiTexCoord0.t < mc_midTexCoord.t) { float blockId = mc_Entity.x; vec3 noise = texture2D(noisetex, position.xz / 256.0).rgb; float maxStrength = 1.0 + rainStrength * 0.5; float time = frameTimeCounter * 3.0; float reset = cos(noise.z * 10.0 + time * 0.1); reset = max( reset * reset, max(rainStrength, 0.1)); position.x += sin(noise.x * 10.0 + time) * 0.2 * reset * maxStrength; position.z += sin(noise.y * 10.0 + time) * 0.2 * reset * maxStrength; } position = gl_ModelViewMatrix * position;
这一次我们引入了最大强度(maxStrength),在默认强度下,它是1.0,当下起雨时,它会逐渐加大,最终导致花草摇摆的幅度更大;reset也被用来限定摆动幅度,用来模拟草摇摆的幅度忽大忽小,毕竟无论什么时候都在死了命地摆总会显得有些奇怪,不过当下雨时是不会停止摆动的.
花草改进的余地还有很多,比如采用更好的算法,这里就不一一赘述了,最后我们来探究一下树叶和两格高度的草的摆动.
树叶的渲染和正常砖块一样,而且树叶不区分上下,任何一个顶点都可以摆动,但难点在于如何让多个邻近的树叶整体摆动,而不让它们撕裂开,如果直接套用草摆动的算法,则每个树叶都会各自为战地按自己的方向摆动,最终结果是整个树看上去被撕裂开一样.下面给出了树叶和高草摇摆的代码.值得一提的是其中的"(position.xz + 0.5) / 16.0",除以16是因为MC以16x16x16为一组来渲染,这意味着在世界坐标系中位置相同的两个顶点在模型坐标系中位置差了整整16个单位,不过好在噪声图的边缘处理模式是Repeat,这意味着当取样点超过一个边时,会重新落回到对面边上,即一个位于(1.1, 0.4)的取样点实际取到的是(0.1, 0.4)上的颜色,因此我们通过除以16而不是15或256,从而让最边沿的点重新落回对边;而加0.5则是为蔓藤准备的,这个值一定意义上是凑出来的...目的是让蔓藤的摆动更贴近树叶的摆动.
if(mc_Entity.x == 18.0 || mc_Entity.x == 106.0 || mc_Entity.x == 161.0 || mc_Entity.x == 175.0) //如果紧接上面的if块的话,这里可以改成else if { vec3 noise = texture2D(noisetex, (position.xz + 0.5) / 16.0).rgb; float maxStrength = 1.0 + rainStrength * 0.5; float time = frameTimeCounter * 3.0; float reset = cos(noise.z * 10.0 + time * 0.1); reset = max( reset * reset, max(rainStrength, 0.1)); position.x += sin(noise.x * 10.0 + time) * 0.07 * reset * maxStrength; position.z += sin(noise.y * 10.0 + time) * 0.07 * reset * maxStrength; }
此部分的最终结果可以从这下载: [SkyDrive] [百度网盘]
片元着色器实战 - 后处理阶段的阴影效果
接下来我们写一个后处理阶段的片元着色器,这个片元着色器通过Shadow Mapping来实现阴影效果.
==================================================
知识点:Shadow Mapping
Shadow Mapping(阴影映射)是一种实时阴影技术,它的思路就是:"判断空间中一点是否处于阴影之中,就是判断从光源位置能否看见该点",说起来很简单,但该如何实现判断点对光源的可见性?
Shadow Mapping的策略是先从光源位置进行一次渲染,渲染后得到的深度缓冲被称为Shadow Map,之后从观察者位置进行正常渲染,正常渲染时根据像素所对应的点在空间中的位置,计算点到光源的距离,之后判断这个距离是否超过离光源最近的点的距离,超过即这个点位于固体之后,无法被光源所照到,是处于阴影之中.
大体的思路已经有了,接下来要解决的是技术细节,首先是在光源位置进行绘制Shadow Map时,镜头朝向何处,对于向太阳光这样的平行光源来说,通常是朝向观察者(玩家)的位置,这样能保证观察者周围的景物都被绘制进Shadow Map.
然后是在正常渲染时如何确定像素所对应的点,对于前向着色,我们可以根据gl_FragCoord和MVP矩阵的逆矩阵来计算出其在模型坐标系中的位置,或者干脆直接将顶点位置通过varying变量从顶点着色器传递给片元着色器,让OpenGL自己去插值去.对于延迟着色,我们可以根据深度缓冲和MVP矩阵的逆矩阵来计算出它的位置,或者直接将它的位置在G-Buffer绘制阶段保存在G-Buffer中,这里我们使用的是前者,通过深度缓冲来重建其位置信息.
最后是如何计算点到光源的距离,以及判断它是否距离光源足够近,可以被照亮.在已有片元位置的情况下,我们可以直接把它的坐标乘以渲染Shadow Map时的MVP矩阵,相当于将这个点放到光源镜头中重新做一次顶点变换,然后判断变换后的坐标落在哪里,计算它的深度值,之后根据坐标取出Shadow Map上相同位置的深度值,通过对比两者的深度,来判断这个点对光源是否可见.
==================================================
首先我们先创建composite.vsh和composite.fsh两个文件,然后为其添加内容,在composite.vsh中添加:
#version 120 varying vec4 texcoord; void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0; }
在composite.fsh中添加:
#version 120 uniform sampler2D gcolor; varying vec4 texcoord; void main() { /* DRAWBUFFERS:0 */ gl_FragData[0] = texture2D(gcolor, texcoord.st); }
这些代码没有什么新意,只是多了个DRAWBUFFERS,它的含义我们要之后再解释,目前先照葫芦画瓢抄上即可,不过需要注意的是"/* DRAWBUFFERS:0 */"的前面不能有缩进!后面也不能有多余的东西!"DRAWBUFFERS:0"前后的俩个空格不要删掉!
之后进游戏重新加载一遍光影包测试一下,如果没问题的话,现在的效果和之前不会有任何变化,因为这个着色器所做的处理仅仅是把G-Buffer中的帧缓冲中的0号纹理的内容重新转绘一遍,没有做任何其他的事情,接下来我们要实现一个最简单的Shadow Mapping,首先在composite.fsh中加入这些变量:
uniform mat4 gbufferProjectionInverse; uniform mat4 gbufferModelViewInverse; uniform mat4 shadowModelView; uniform mat4 shadowProjection; uniform sampler2D shadow; uniform sampler2D depthtex0;
shadow是Shadow Map;depthtex0是绘制G-Buffer时的深度缓冲;gbufferProjectionInverse和gbufferModelViewInverse可以将位于裁剪坐标系中的坐标变换到"光照坐标系",这个坐标系不是OpenGL坐标系的一部分,它类似世界坐标系,但是它的原点位于玩家的位置.一个光照坐标系中的坐标乘以shadowModelView和shadowProjection就能获得该点在Shadow Map中的位置.
在composite.fsh的main函数开头添加:
float depth = texture2D(depthtex0, texcoord.st).x; vec4 viewPosition = gbufferProjectionInverse * vec4(texcoord.s * 2.0 - 1.0, texcoord.t * 2.0 - 1.0, 2.0 * depth - 1.0, 1.0f); viewPosition /= viewPosition.w; vec4 worldPosition = gbufferModelViewInverse * viewPosition;
首先我们取出该像素的深度,然后用该像素在屏幕空间的位置以及深度信息重建出了它的NDC坐标,之后用gbufferProjectionInverse和反向的透视除法(也就是后面的除以w分量,正常的透视除法是发生在投影变换之后,因此反向的透视除法是发生在反投影变换之后),将NDC坐标变换到眼坐标系中,我们将这个坐标记为viewPosition,而worldPosition则是继续变换到光照坐标系时的位置.
之后我们要计算它在Shadow Map中对应的点,这一次我们添加一个函数:
float shadowMapping(vec4 worldPosition) { vec4 shadowposition = shadowModelView * worldPosition; shadowposition = shadowProjection * shadowposition; shadowposition /= shadowposition.w; shadowposition = shadowposition * 0.5 + 0.5; float shadowDepth = texture2D(shadow, shadowposition.st).z; float shade = 0.0; if(shadowDepth < shadowposition.z) shade = 1.0; return shade; }
这一步基本上就是重新实现了一遍顶点的MVP变换,只不过在完成投影矩阵变换后,我们手动完成了一遍从裁剪坐标系到NDC坐标系然后再到屏幕空间的变换,最后我们从中取出深度信息,与预期深度进行对比,并通过返回值来表示是否在阴影中.
然后要在main函数中调用shadowMapping来获取在光照坐标系下的一个点的阴影覆盖程度,在"vec4 worldPosition = gbufferModelViewInverse * viewPosition;"下方加入
float shade = shadowMapping(worldPosition);
在"gl_FragData[0] = texture2D(gcolor, texcoord.st);"下方加入
gl_FragData[0].rgb *= 1.0 - shade * 0.5;
接下来就可以进行测试了,但我劝你先做好心理准备,因为接下来的内容会很惨不忍睹...
进入游戏后你会发现地面布满了条纹式的黑影,这是因为浮点精度不足导致,解决的方法是加入一个偏移值,将"if(shadowDepth < shadowposition.z)"改为:
if(shadowDepth + 0.0001 < shadowposition.z)
现在再次重载光影包,这次就没有黑影了,但是影子的质量依然堪忧,这是由于Shadow Map分辨率不足,无法记录足够的细节造成的,你可以在Shadersmod的控制台中增大Shadow Map分辨率倍数,但这依然不是个好的解决方法.一个流行的解决方案是采用穹式投影,穹式投影有个更广为人知的名字是鱼眼镜头(Fisheye lens),它有个特点就是越靠近投影中心点的场景在画面上占的比重越大,边缘的场景会被挤压,会损失一些细节,但不至于完全消失,这正好符合我们的要求:在近处保留尽可能多的细节.
图:采用穹式投影的Shadow Map,注意边缘的树和山体虽然被挤压变形,但仍未完全消失.
首先创建shadow.vsh和shadow.fsh两个文件.然后在shadow.vsh中添加:
#version 120 #define SHADOW_MAP_BIAS 0.85 varying vec4 texcoord; void main() { gl_Position = ftransform(); float dist = length(gl_Position.xy); float distortFactor = (1.0 - SHADOW_MAP_BIAS ) + dist * SHADOW_MAP_BIAS ; gl_Position.xy /= distortFactor; texcoord = gl_MultiTexCoord0; }
在shadow.fsh中添加:
#version 120 uniform sampler2D texture; varying vec4 texcoord; void main() { gl_FragData[0] = texture2D(texture, texcoord.st); }
显而易见,实现穹式投影的所有操作都在顶点着色器中完成,其中SHADOW_MAP_BIAS为投影系数,关于其不同取值时的效果如下:
很抱歉我忘了截系数为0.85时的截图...显而易见,系数越接近1,则靠近中央的景物占的比重越多,边缘景物被压缩的越厉害.
如果你好奇那些光影包使用的投影系数的话...MrMeepz、Robobo和Trilitons使用的0.6,大多数版本的SEUS和AirLoocke使用的0.8,某一个实验版的SEUS使用的0.9,其余的光影包基本都使用0.85.
完成了Shadow Map的着色器后,我们还得修改原来的阴影算法以适应采用穹式投影的Shadow Map,在composite.fsh中的"shadowposition = shadowProjection * shadowposition;"的下面添加:
float distb = sqrt(shadowposition.x * shadowposition.x + shadowposition.y * shadowposition.y); float distortFactor = (1.0 - SHADOW_MAP_BIAS) + distb * SHADOW_MAP_BIAS; shadowposition.xy /= distortFactor;
然后别忘了加上"#define SHADOW_MAP_BIAS 0.85",现在再测试一下新的阴影效果,这时效果已经可以接受了.
接下来我们要解决的是让远处的阴影能平滑过渡,以及防止天空被渲染上阴影,一个最简单的解决方法是判断其在深度缓冲中的距离,然而这个方法实现起来却不像它的原理那么简单,首先深度缓冲不是线性的,它的前99%几乎都集中在视角最前方,使用默认的非线性深度缓冲难以区分开远处的地面和天空,另一个问题则是深度缓冲没有经过透视修正,即站在某部不动,仅旋转镜头的情况下,某一物体在屏幕中间和在屏幕边缘的深度是不一样的,这导致通过它来实现的阴影渐变会让远方的阴影随着玩家镜头的转动而变化,这显然有违于人类的直觉.因此我们这里使用一个折衷的方案,使用规整化了的光照坐标系坐标来判断,由于这个坐标系的特点是坐标原点在玩家的位置,因此直接求某点到原点的距离便是该点到视角的距离,而规整化的方式就是将这个距离除以Shadersmod提供的一致变量:far,它代表投影矩阵的远裁面,基本可以作为一个最远视距的参考.
首先,在composite.fsh中添加这个变量:
uniform float far;
然后将"float shade = shadowMapping(worldPosition, dist);"改成:
float dist = length(worldPosition.xyz) / far; float shade = shadowMapping(worldPosition, dist);
最后我们改掉整个Shadow Mapping函数,将它换成:
float shadowMapping(vec4 worldPosition, float dist) { if(dist > 0.9) //距离过远(比如远景和天空)的地方就不渲染了 return 0.0; vec4 shadowposition = shadowModelView * worldPosition; shadowposition = shadowProjection * shadowposition;float distb = sqrt(shadowposition.x * shadowposition.x + shadowposition.y * shadowposition.y); float distortFactor = (1.0 - SHADOW_MAP_BIAS) + distb * SHADOW_MAP_BIAS; shadowposition.xy /= distortFactor; shadowposition /= shadowposition.w; shadowposition = shadowposition * 0.5 + 0.5; float shadowDepth = texture2D(shadow, shadowposition.st).z; float shade = 0.0; if(shadowDepth + 0.0001 < shadowposition.z) shade = 1.0; shade -= clamp((dist - 0.7) * 5.0, 0.0, 1.0);//在l处于0.7~0.9的地方进行渐变过渡 shade = clamp(shade, 0.0, 1.0); //避免出现过大或过小 return shade; }
代码看上去有一大段,其实只有4行是新增的,其它的都是之前已有的代码.
现在我们的阴影效果已经好多了,但是问题依然存在,首先就是平行于光线方向的表面会产生大量断裂的黑影,这是由浮点误差造成的,当表面垂直于光线时,有些点被变换到Shadow Map上时落在了阴影区,而有些则落在了无阴影的区域.
有一个解决方案是调整光源的位置,比如通过添加"const float sunPathRotation = [偏移角度];"来偏移光线的角度,但这也无法从根本解决问题,因为游戏中一天总会有那么几个时候光线平行于某个物体的表面,比如正午和日出日落时.
一个永久性的解决方案是判断法线(即表面的垂线)和光线的夹角,对于夹角接近90度的点渐变到完全处于阴影的状态.但现在的问题是,我们还没有法线啊...目前为止,我们的着色器虽然确实是在向G-Buffer中写入数据,但实际上仍和传统的前向着色没有任何区别,我们仅仅使用了一个颜色缓冲,从G-Buffer的最终合成也是由系统来完成,这一次我们来实现一个真正的延迟渲染系统,首先我们要了解的是如何向G-Buffer中写入多个数据.
==================================================
知识点:Shadersmod可用的缓冲区
如果你熟悉OpenGL规范的话,你会知道它要求具备MRT功能的显卡至少支持4个RenderTarget,即至少能同时向4个颜色缓冲输出数据,目前大部分主流显卡都支持8个RenderTarget,因此综合考虑,Shadersmod在G-Buffer中为开发者准备了8个正常渲染时的颜色缓冲和3个深度缓冲,以及Shadow Map渲染时的2个颜色缓冲和2个深度缓冲,它们分别是:
ID | 名称 | 别名 | 默认格式 | 默认数据 |
---|---|---|---|---|
0 | gcolor | colortex0 | RGBA8 | 雾的颜色 |
1 | gdepth | colortex1 | RGBA32F (注) | (1.0, 1.0, 1.0, 1.0) |
2 | gnormal | colortex2 | RGBA8 | (0, 0, 0, 0) |
3 | composite | colortex3 | RGBA8 | (0, 0, 0, 0) |
4 | gaux1 | colortex4 | RGBA8 | (0, 0, 0, 0) |
5 | gaux2 | colortex5 | RGBA8 | (0, 0, 0, 0) |
6 | gaux3 | colortex6 | RGBA8 | (0, 0, 0, 0) |
7 | gaux4 | colortex7 | RGBA8 | (0, 0, 0, 0) |
(注:"请不要误会,我不是针对你,我是说在座的各位,都...唔...都知道RGBA32F指的是4个32位浮点数,但这里有个Bug,如果在下文的'声明缓冲区'中使用别名colortex1而不是gdepth,并且没有手动声明格式,会引发一个Bug(至少是在1.7.10的Shadersmod中),导致gdepth的格式变成RGBA8,即4个8位字节,这一毁灭性的Bug足以导致你的gdepth废掉")
名称和别名是在声明缓冲时用的,声明缓冲类似我们之前使用噪声图时声明噪声图尺寸,你需要在一个片元着色器(随便哪个片元着色器)中起一行加入:
uniform sampler2D [名称或别名];
对GLSL稍有熟悉的人看到这个估计都会喷我:"这不就是使用纹理吗"...对,这其实就是使用纹理...
在声明了缓冲后,系统会为它按默认格式创建一个颜色/深度缓冲并挂载到帧缓冲上,如果希望自定格式的话,可以按如下的代码:
const int [格式] = [随便一个整数,随便,真的]; const int [名称或别名]Format = [格式];
没错...Shadersmod就是这么猎奇,它是纯粹基于字符串判断来判读参数,然而显然GLSL不允许你输入个字符串,因此这里只能变通一下,先声明个和格式名相同的变量或定义(GLSL也支持#define),然后再声明缓冲的格式.
可用的格式有:
格式 | 描述 | 体积 | 规整化 |
---|---|---|---|
R8 | 1个8位定点数,只有R可用 | 8 | 是 |
RG8 | 2个8位定点数,只有R,G可用 | 16 | 是 |
RGB8 | 3个8位定点数,只有R,G,B可用 | 24 | 是 |
RGBA8 | 4个8位定点数 | 32 | 是 |
R16 | 1个16位定点数,只有R可用 | 16 | 是 |
RG16 | 2个16位定点数,只有R,G可用 | 32 | 是 |
RGB16 | 3个16位定点数,只有R,G,B可用 | 48 | 是 |
RGBA16 | 4个16位定点数 | 64 | 是 |
R32F | 1个32位浮点数,只有R可用 | 32 | 否 |
RG32F | 2个32位浮点数,只有R,G可用 | 64 | 否 |
RGB32F | 3个32位浮点数,只有R,G,B可用 | 96 | 否 |
RGBA32F | 4个32位浮点数 | 128 | 否 |
此表描述了所有可用的格式;对于非RGBA的格式来说,在写入gl_FragData时只有格式所具有的那几位有效,虽然你依然必须输出整个vec4...规整化表示输出结果是否必须在0.0~1.0之间,超出的会被clamp,如你所见,所有的定点数都是规整化的,只有浮点数是存进什么值就是什么值.
此外,gaux4还支持一种特殊的格式声明方式,但过于撒比,这里就不介绍了...
一个缓冲在每一帧渲染前都会被刷新成默认数据,这个是无法更改的...
最后还有一个问题是如何控制数据输出,我们可能在整个渲染过程中用到多个缓冲,但在其中一个阶段可能只需要向其中的某几个输出数据,这就用到了Shadersmod的输出控制,设置输出目标的方式是在该阶段的片元着色器中新起一行,加入:
/* DRAWBUFFERS:[各个输出目标的ID] */
输出目标的ID即之前那个表中的缓冲的ID,如果有多个目标,就将它们按你希望的顺序依次排列,比如想向0,1,3这3个颜色缓冲输出的话:
/* DRAWBUFFERS:013 */
注意Shadersmod是严格按照逐行字符串匹配来扫描这些参数,因此不要忘记这里面的两个空格,也不要手贱往前面或后面加东西,比如不能添加缩进,这可能会逼死强迫症,因此将输出对象声明写在文件开头,放在#version的下一行也不失为一种两全其美的选择.
在设置了输出目标后,就可以在gl_FragData中输出数据了,gl_FragData的操作方式类似数组,下表对应之前声明的输出,比如:
/* DRAWBUFFERS:013 */ gl_FragData[0] = vec4(1.0); gl_FragData[1] = vec4(1.0, 2.0, 3.0, 4.0); gl_FragData[2] = vec4(0.0);
是向gcolor中输出一个纯白(@纯白之面),向gdepth输出4个浮点数,向composite中输出一个完全透明的纯黑(噫...玄黑之面?)
如果没有声明输出对象的话,Shadersmod就会默认是向所有可用的颜色缓冲按自然顺序输出,至少理论上是如此,但实际上这里存在一个Bug,在某些显卡下(比如N卡的Maxwell架构系列)不声明输出对象会导致渲染异常,具体内容见附录"DRAWBUFFERS导致的水面异常".
至于缓冲的使用,在后处理和最终合成阶段,每一个缓冲都可以按读取纹理的方式来获取数据,比如:
uniform sampler2D gnormal; //... vec3 data = texture2D(gnormal, texcoord.st).rgb;
就是从gnormal中读取数据.此外除了正常渲染时的颜色缓冲以外,Shadersmod还支持从如下缓冲中读取数据:
名称 | 别名 | 默认格式 | 数据内容 |
---|---|---|---|
shadowtex0 | shadow/watershadow (注) | Depth24 | Shadow Map的深度缓冲,水面被视为一个不透光的固体 |
shadowtex1 | 无/shadow (注) | Depth24 | Shadow Map的深度缓冲,水面被视为一个全透明的物体,即不包含水面 |
shadowcolor0 | shadowcolor | RGBA8? | Shadow Map的颜色,除非你要用到什么奇技淫巧,否则是用不到它的.准确说它也是个RenderTarget,想使用它也需要声明,声明方法和之前的那些一样,但似乎无法更改格式.顺便一提,它的Alpha位你是别想拿来存储数据,因为Shadow Map在绘制时会开启Alpha测试,所以这个Alpha位必须为1.0,否则数据不会被输出... |
shadowcolor1 | RGBA8? | 同上,不同的是它的每一位都能随意使用. | |
depthtex0 | gdepthtex | Depth24 | G-Buffer绘制后的深度缓冲,包含水面和云 |
depthtex1 | Depth24 | G-Buffer绘制后的深度缓冲,它不包含水面,但却包含云...而且是只有当玩家位于云层之下时才包含云,一旦玩家位于云层或更高时,云会骤然消失,不要忘了玩家是很容易到达云层的高度的. | |
depthtex2 | Depth24 | G-Buffer绘制后的深度缓冲,和上面的相同,但不包含手和手上的物品 |
(注:shadowtex0与shadowtex1的别名规则十分猎奇,如果你声明了shadow而没有声明watershadow的话,shadow将指向shadowtex0;如果你声明了watershadow的话,shadow将指向shadowtex1;watershadow无论如何只要被声明了的话一定指向shadowtex0.)
Depth24即24位规整化定点数,假如OpenGL有R24的话,那么它就相当于R24.这几项数据主要在渲染阴影时使用.
==================================================
首先我们需要设计延迟渲染所需的G-Buffer,每个游戏的G-Buffer的设计都不尽相同,比较公认的必不可少的内容有:漫反射颜色、法线和位置.
前者就是我们之前已经完成的在G-Buffer绘制阶段输出的颜色,接下来我们需要解决的是法线,为什么不需要输出位置?因为位置可以通过深度信息来重建嘛,我们刚才不就已经实现了吗?
法线即垂直于一个平面的方向,几乎所有光照计算都需要计算光线与法线的夹角,在GLSL中,我们通过gl_Normal来获取模型坐标系下的法线,然后通过gl_NormalMatrix将它变换到眼坐标系,gl_NormalMatrix相当于一个专门供gl_Normal使用的模型视图矩阵,解释它的原理需要一点点线性代数知识,欲知更多真相,见附录"⑨评gl_NormalMatrix".
然后我们要研究的是如何存储法线,最简单的方式是直接存为3个浮点数,但不觉得这有点太...浪费了吗...有一种办法是通过2个16位半精度浮点数来储存法线,然而Shadersmod却不允许我们使用半精度浮点数作为RenderTarget的格式,这确实是个糟糕的设计,我们不得不像一些使用OpenGLES的移动端那样将法线编码到16位定点数中储存,关于编码方法,这个网站给出了很多种方法,这里采用的是Spheremap Transform.
我决定使用gnormal这个颜色缓冲来存储法线数据,首先要将它的格式从RGBA8改为RG16,在composite.fsh中添加:
const int RG16 = 0; const int gnormalFormat = RG16;
这样便完成gnormal格式的声明了,然后我们要修改所有G-Buffer的着色器,在其中向gnormal写入法线数据,以gbuffers_basic为例,在gbuffers_basic.vsh中添加:
varying vec2 normal; vec2 normalEncode(vec3 n) { vec2 enc = normalize(n.xy) * (sqrt(-n.z*0.5+0.5)); enc = enc*0.5+0.5; return enc; }
然后在它的main函数中添加:
normal = normalEncode(gl_NormalMatrix * gl_Normal);
在gbuffers_basic.fsh中添加:
varying vec2 normal; /* DRAWBUFFERS:02 */
在它的main函数中添加:
gl_FragData[1] = vec4(normal, 0.0, 1.0);
输出一个RG16格式数据的方法和输出RGBA8的方法相同,只不过只有R和G中的数据会被实际输出,不过A的数据虽然没有被输出,但依然会被用于Alpha混合,在MRT中进行Alpha混合和Alpha测试是一件很头疼的事,更详细的内容我们会在附录中讨论.现在我们已经为其中一个着色器完成法线数据输出了,以此类推,为所有的G-Buffer绘制着色器添加法线输出.
然后我们要为composite.fsh添加法线解码,在composite.fsh中添加:
uniform sampler2D gnormal; uniform vec3 sunPosition; vec3 normalDecode(vec2 enc) { vec4 nn = vec4(2.0 * enc - 1.0, 1.0, -1.0); float l = dot(nn.xyz,-nn.xyw); nn.z = l; nn.xy *= sqrt(l); return nn.xyz * 2.0 + vec3(0.0, 0.0, -1.0); }
sunPosition是太阳光的光线向量,这个我们待会会用到.现在向main函数开头添加:
vec3 normal = normalDecode(texture2D(gnormal, texcoord.st).rg);
于是我们便完成了全部的法线数据存储与读取工作!如果你想亲眼体验一下自己的成果的话,可以在最后添加"gl_FragData[0] = vec4(normal, 1.0);",以在屏幕上绘制各点的法线信息,不过请相信我,它们并不好看...
然后我们要大改一下阴影判断的代码:
float shadowMapping(vec4 worldPosition, float dist, vec3 normal) { if(dist > 0.9) return 0.0; float shade = 0.0; //计算法线和光线夹角,最终算出来的结果是位于-1.0~1.0之间的数,1代表光线垂直照射平面,0代表光线与平面平行,小于0代表光线来自平面后方. float angle = dot(normalize(sunPosition), normal); if(angle <= 0.1) //如果角度过小,就直接涂黑 { shade = 1.0; } else { vec4 shadowposition = shadowModelView * worldPosition; shadowposition = shadowProjection * shadowposition; float distb = sqrt(shadowposition.x * shadowposition.x + shadowposition.y * shadowposition.y); float distortFactor = (1.0 - SHADOW_MAP_BIAS) + distb * SHADOW_MAP_BIAS; shadowposition.xy /= distortFactor; shadowposition /= shadowposition.w; shadowposition = shadowposition * 0.5 + 0.5; float shadowDepth = texture2D(shadow, shadowposition.st).z; if(shadowDepth + 0.0001 < shadowposition.z) shade = 1.0; if(angle < 0.2) //如果角度略小的话,就将它过渡到全黑. shade = max(shade, 1.0 - (angle - 0.1) * 10.0); } shade -= clamp((dist - 0.7) * 5.0, 0.0, 1.0);//在l处于0.7~0.9的地方进行渐变过渡 shade = clamp(shade, 0.0, 1.0); //避免出现过大或过小 return shade; }
看上去代码有一大堆,其实真正新增的地方并不多...首先我们把夹角过小,或者干脆背朝光线的面直接涂黑,然后我们将夹角略小的面过渡到全黑.
最后别忘了改一下shadowMapping调用的代码:
float shade = shadowMapping(worldPosition, dist, normal);
现在侧面的效果已经很好了,但是又引发了两个新问题,一个是云朵也被我们染成黑色了...另一个是水面下那些背光的砖块也被染成了黑色.
之前为了解决这两个问题我想了不少拆东墙补西墙的办法...云被染黑是因为它的面正好背朝光源,当时我想出的解决方案有修改着色器让云的法线方向始终和光源方向相反;在G-Buffer中打上特殊的标志位,禁止云接受光照;或者干脆不渲染云,反正到最后我们得替换成体积云.水下阴影的处理最开始是对比depthtex0和depthtex1的深度.后来我才想起一个最简单的办法,直接判断颜色的alpha值...云和水的alpha都是小于1的,我们直接禁掉alpha小于1的像素的背光涂黑就行了.
首先我们要先将gcolor的采样提到最前面,在main函数的开头加入:
vec4 color = texture2D(gcolor, texcoord.st);
然后将原本的"gl_FragData[0] = ..."两行改成:
color.rgb *= 1.0 - shade * 0.5; gl_FragData[0] = color;
给shadowMapping函数加一个alpha参数:
float shadowMapping(vec4 worldPosition, float dist, vec3 normal, float alpha) //在copy的时候别手滑把括号给覆盖掉 233
将阴影测试中的那两个"if(angle < xx)"条件改成:
if(angle <= 0.1 && alpha > 0.99) //... if(angle < 0.2 && alpha > 0.99)
最后修改shadowMapping的调用:
float shade = shadowMapping(worldPosition, dist, normal, color.a);
现在水面和云的阴影已经正常了,现在还有一些小的细节值得改良,首先是远处阴影的过渡,如果你使用默认的shadowDistance设置(它用来配置阴影的范围,具体作用见附录"所有可用的控制参数"),那么这一步基本可以跳过,如果你缩小了默认的shadowDistance范围来换取更高的细节精度的话,那么你就得考虑对远处的阴影进行手工消隐,否则会出现奇怪的"怪圈".
图:黑色怪圈,这是因为某些点在变换到Shadow Map后没有落在有效范围内造成的.
图:合金装备5中的边缘阴影淡化,这个场景是在一个峡谷中,光源位于镜头的左后方,正确的阴影应当如图片右侧中的那样,而图片中上方那些淡化的阴影则是超出Shadow Map范围而被淡化掉的,在实际游戏中可以明显地看到远处原本没有阴影的区域随着自己靠近而被涂上了阴影. 事实上这个问题要比我们现在讨论的要复杂,你会注意到在极远处,比如远处的岩石和土坡上依然有阴影,这里可能有两种原因,一种是游戏采用了CSM(Cascaded Shadow Maps)技术,即将Shadow Map按照精细程度分为多个,在距离玩家最近的区域使用最高精度但覆盖范围最小的Shadow Map,在距离玩家较远的区域使用覆盖范围最广但精度最差的Shadow Map,对于两者之间衔接的区域则采用插值过度,上图所示的情况可能是近处的高精度Shadow Map包含了峡谷的谷顶,而远处的低精度却由于种种原因(LOD?)未能包含峡谷,这种情况我们在Minecraft光影中也可能遇到,如果你尝试迎着光靠近一个非常高(y>120)的山的话你会注意到山脚下的阴影会随着你的靠近而不断发生剧烈变化.而第二种原因就不那么浪漫了,那些阴影说不定是游戏开发时事先烘焙好的...关于烘焙阴影(Baked Shadows)的详情可以自行百度/谷歌,反正我是不感兴趣. (雾)
手工消隐的办法是判断像素变换到Shadow Map上的坐标,如果接近边缘则平滑过渡到没有阴影,不过由于我们使用的是穹面投影,因此在屏幕坐标系判断有些不便,这里我们退一步在剪裁坐标系(也就是经过透视变换后的坐标系)进行判断. 在"shadowposition = shadowProjection * shadowposition;"的下面添加:
float edgeX = abs(shadowposition.x) - 0.9; float edgeY = abs(shadowposition.y) - 0.9;
然后在这个代码块的结尾添加:
shade -= max(0.0, edgeX * 10.0); shade -= max(0.0, edgeY * 10.0);
现在我们现在只考虑了日间的阴影,为了让晚上也有阴影,以及让昼夜交替时阴影能平滑过渡,我们可以在后处理阶段的顶点着色器对时间进行判断(在顶点着色器计算的原因是降低计算量),将composite.vsh修改为:
#version 120 uniform vec3 sunPosition; uniform vec3 moonPosition; uniform int worldTime; varying vec4 texcoord; varying vec3 lightPosition; varying float extShadow; #define SUNRISE 23200 #define SUNSET 12800 #define FADE_START 500 #define FADE_END 250 void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0; if(worldTime >= SUNRISE - FADE_START && worldTime <= SUNRISE + FADE_START) { extShadow = 1.0; if(worldTime < SUNRISE - FADE_END) extShadow -= float(SUNRISE - FADE_END - worldTime) / float(FADE_END); else if(worldTime > SUNRISE + FADE_END) extShadow -= float(worldTime - SUNRISE - FADE_END) / float(FADE_END); } else if(worldTime >= SUNSET - FADE_START && worldTime <= SUNSET + FADE_START) { extShadow = 1.0; if(worldTime < SUNSET - FADE_END) extShadow -= float(SUNSET - FADE_END - worldTime) / float(FADE_END); else if(worldTime > SUNSET + FADE_END) extShadow -= float(worldTime - SUNSET - FADE_END) / float(FADE_END); } else extShadow = 0.0; if(worldTime < SUNSET || worldTime > SUNRISE) lightPosition = normalize(sunPosition); else lightPosition = normalize(moonPosition); }
其中日落SUNSET和日出SUNRISE的时间是取自Shadow Map光源变化时间.在这两个时间光源会从太阳变到月亮,或月亮变到太阳,因此我们的阴影过渡全是针对这两个时间点来进行,变量extShadow代表全局阴影,在时间点前500tick全局阴影会逐渐加重,直到前250tick时场景中所有地方都被涂成有阴影的部分,在后250tick时全局阴影逐渐淡出,直到后500tick时恢复正常.这里的过渡方式之所以采用让全局都涂上阴影而不是让已有的阴影淡出,是为了兼顾室内环境,虽然在日出日落时游戏环境会变暗一些,但总比室内突然变亮好.
然后我们就可以修改composite.fsh了,为它添加变量:
varying float extShadow; varying vec3 lightPosition;
然后将夹角计算改成:
float angle = dot(lightPosition, normal);
然后将shadowMapping中的两个return改成:
return extShadow; //原来的return 0.0; //... return max(shade, extShadow); //原来的return shade;
现在我们的阴影已经基本可以看了,美中不足的是阴影的边缘总是显得有些棱角分明,显然这与现实中的阴影不符,这是因为游戏中的光源在计算光照时都是当做点光源来处理,所有的光线都是发射自一点,而现实中的光源基本都是面光源,由此便形成了半影(一部分光能照到)和本影(所有光都照不到),再考虑到漫反射和空气散射,有明显边缘的纯阴影在现实中几乎很难见到,因此现在Shadow Mapping的发展重点之一便是实现边缘淡化的软阴影,最简单的软阴影技术是PCF(Percentage Closer Filtering),它的思路就是对像素周围几个点的位置均进行采样,然后计算位于阴影区中的采样点的所占比例,然后对此点在无阴影和全阴影之间过渡.看完这个原理之后,你都可以尝试自己手写一个多点采样来实现PCF,不过这里我们会使用硬件提供的PCF来快速实现这个效果,GLSL函数shadow2D是拥有硬件支持的PCF采样的深度比较,在大部分设备中它采用4点的双线性过滤进行采样,最后输出一个介于0.0~1.0之间的浮点数,0.0表示完全处于阴影当中,1.0代表不处于阴影中.
首先我要启用硬件支持的深度比较,在composite.fsh中添加:
const bool shadowHardwareFiltering = true;
启动硬件深度比较后我们就没法手动从Shadow Map中读取值了,如果你现在重载光影包的话会发现阴影消失了.接下来我们将变量shadow的类型从sampler2D改为sampler2DShadow:
uniform sampler2DShadow shadow;
然后将
if(shadowDepth + 0.0001 < shadowposition.z) { shade = 1.0; }
改为:
shade = 1.0 - shadow2D(shadow, vec3(shadowposition.st, shadowposition.z - 0.0001)).z;
最后再删掉这行:
float shadowDepth = texture2D(shadow, shadowposition.st).z;
现在如果你测试的话,会发现地面上再次出现了明暗交替的条纹,这是因为shadow2D对Shadow Map的精度要求太高了,我们的Shadow Map精度远远达不到它的要求,因此只能死命地加大偏移值...但简单地加大偏移值并不完全管用,首先你会注意到远处依然会有这种条纹,因为我们使用的穹面投影的精度是越远越差的,即使你把偏移值改成跟距离成正相关也没法解决所有问题,你会注意到野草等薄面的阴影和本体之间会有一个空隙,而且这个空隙会随着偏移值的增加而越来越大,它是由于过度的偏移造成的,该问题有个学名叫Peter Panning,这个梗出自小飞侠,小飞侠Peter Pan的能力之一就是隐匿自己的影子.对于这个问题业界给出的解决方案是:"少偏移,合理偏移,少绘制薄面"... Shadow Mapping的偏移技术其实有3种,分别是基于光源深度的偏移,基于观察者深度的偏移和基于法线的偏移,我们之前实现的都是基于光源深度的偏移,即在Shadow Map取样时从光源的视角将深度值(即shadowposition.z)微微向近处调整一些;而基于观察者深度的偏移就是在观察者的视角让点拉近一些再变换到Shadow Map上;基于法线的偏移则是在眼坐标系中将像素对应的点沿法线方向稍微偏移一点.根据我的测试基于法线的偏移效果是最好的.
实现基于法线的偏移很简单,将"vec4 worldPosition = gbufferModelViewInverse * viewPosition;"改成:
vec4 worldPosition = gbufferModelViewInverse * (viewPosition + vec4(normal * 0.05 * sqrt(abs(viewPosition.z)), 0.0));
这个偏移方程是根据眼坐标系下点到视觉平面的距离(-position.z,但为了防止不小心真冒出一个正数导致取负后sqrt崩坏,这里用的是取绝对值)的平方根来计算,这个公式很大程度上是我凑出来的...如果把sqrt(abs(position.z))改成pow(abs(position.z), 0.6)对远处的暗纹消除效果更好,其次远景的阴影会因为过度偏移而在边缘出现尖刺,讽刺的是我们恰恰是为了实现软阴影才使用PCF的.我怀疑这可能是默认的1024x1024尺寸的Shadow Map所能取得的最好效果了,如果你觉得这实在难以忍受的话,可以加大Shadow Map的尺寸,除了让玩家在设置中调大ShadowResMul以外,你还可以在光影包中修改基数(默认为1024,最终尺寸为基数乘以ShadowResMul),方法是在任意一个片元着色器中加入:
const int shadowMapResolution = [尺寸基数];
只要改成2048,就能取得还能让人接受的远景效果.
图:使用最终尺寸为1024*1024的Shadow Map的PCF软阴影 (点图放大)
图:使用最终尺寸为2048*2048的Shadow Map的PCF软阴影 (点图放大)
虽然还是有很多很多可改进的空间,但这些内容已经远超我们当初"实战片元着色器"的范围,实时阴影技术如果单拿出来说的话完全可以出一本书(并不是开玩笑,清华大学出版社的"图形学全家桶"就包括《实时阴影技术》一书),因此我只能就此浅尝辄止介绍这些最基本的技术,如果你对阴影映射感兴趣的话,可以看看GPU Gem系列对实时阴影的讨论以及Wiki上Shadow Mapping英文版的附录.这里给出一些尚未解决的问题:
- 在室内环境和下界时场景过暗的问题,可以考虑结合eyeBrightnessSmooth来减淡阴影.
- 夜间环境下过暗的问题,可以考虑结合worldTime和eyeBrightnessSmooth来减淡阴影.
- 阴影在末地无法正常工作,不过别担心,没几个光影包在末地能正常工作...
- 有人造光源时依然有阴影的问题,可以考虑在G-Buffer绘制时向一个颜色缓冲中输出该像素所受人造光源影响的强度,然后禁止向有人造光源的地方绘制阴影.人造光源强度来自lmcoord的s值,获取方式见上文的lmcoord取值方式...
- 你应该注意到了草和树叶的阴影总会在自身染上奇怪的阴影,这是因为绘制Shadow Map时它们是不动的,而在绘制G-Buffer时我们对它们进行了变形...这一次我不得不承认Sonic Ether更厉害一些,他的SEUS无需修改Shadow Map绘制便能正确处理草和树叶的阴影,而我所能想到的解决方案还是像Chocapic13那样在Shadow Map中也对草和树叶做一遍变形...难道SEUS是在对草和树叶做深度测试时沿着法线方向进行了偏移?
该部分的最终成品可以从这里下载: [SkyDrive] [百度网盘]
实战多遍后处理 - 泛光
我们已经有了一个基于片元着色器的后处理特效:阴影.现在我们来尝试实现一个需要多遍渲染的后处理效果:泛光(Bloom)
泛光就是指当极亮和极暗的区域相邻时,亮处的光就会溢出到暗处,它的物理学原理好像是透镜聚焦不准造成的...不管了,当初我的大物2还是靠旁边贵人相助才过的...
图:泛光效果实例,注意图中间的两个窗户. 照片是和姬友去故宫玩时摄于某殿(...没留意拍照时是在哪),另外请留意这张照片,今后我们还要把它拿出来鞭尸,因为它除了泛光,还涉及到了晕影、镜头光晕、炫光、体积光甚至是飞棍(...)等要素 ☺
首先我们先来思考一下泛光实现的方法,既然是亮处的光照溢出到暗处,那我们是不是可以把亮的部分提取出来,然后让它们向外扩散,再覆盖回原图像上呢.这个想法是正确的,但关键是怎么用算法实现"向外扩散"? 一个最简单的办法是模糊,高斯模糊可以实现被模糊图像的颜色相互渗透,对于有明显明暗区分的图像来说,效果就是亮处的颜色向暗处扩散,正好符合我们的要求.高斯模糊的原理简单地说就是在计算模糊后图像的每一个像素点时,从原图像该位置的像素周围取一定数量的采样点,然后按一定的权重将它们混合,这里有个问题就是"一定数量"究竟是多少,简单的高斯混合可以只取9x9区域内的像素,而一个高度模糊的可能需要73x73的区域,前者一次运算"只需"取样81次,而后者一次运算需要取样5329次.不过高斯模糊有个神奇的特性就是可以被分解成横纵两次,先对图像进行一次横向模糊,然后对第一次的结果进行一次纵向模糊,这样最终结果的一个像素点总共只需A+A次,而不是A*A次,上面的73x73区域的5329次采样会骤减到146次,已经到了可接受的范围了.
然后我们就可以着手实现了,首先我们先来实现高光提取,想从图片中找出亮处的最简单办法就是判断像素的亮度,亮度计算可不是三种颜色取最高或者计算三种颜色平均值什么的,而是将三种颜色乘以三个系数然后相加,其中绿色对亮度贡献最大,占71.52%;其次是红色,占21.26%;最后是蓝色,仅仅只占7.22%.因此计算亮度的公式是R*0.2126+G*0.7152+B*0.0722.
将composite.fsh中原来的:
/* DRAWBUFFERS:0 */ color.rgb *= 1.0 - shade * 0.5; gl_FragData[0] = color;
改为:
color.rgb *= 1.0 - shade * 0.5; float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); vec3 highlight = color.rgb * max(brightness - 0.25, 0.0); /* DRAWBUFFERS:01 */ gl_FragData[0] = color; gl_FragData[1] = vec4(highlight, 1.0);
(不要忘了"/* DRAWBUFFERS:01 */"前面不能有缩进)
这一次我们使用colortex1这个颜色缓冲来存储高光数据,colortex1的正式名字是gdepth,默认格式是RGBA32F,也是被建议用来存储深度的,但由于我们是通过深度缓冲来计算深度,因此用不到它,为了不浪费,这里就拿来用于存储高光数据,同样为了防止歧义和混淆,这里使用的是它的别名colortex1,而不是gdepth.首先我们计算了在添加完阴影效果后的亮度,然后在对它进行适当衰减,剔除掉过暗的颜色后,写入了colortex1中.另外别忘了声明它:
const int RGB8 = 0; const int colortex1Format = RGB8;
接下来我们要创建着色器composite1和composite2,然后在它们中进行高斯模糊.
首先先创建composite1.vsh和composite2.vsh,它们的内容均为:
#version 120 varying vec4 texcoord; void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0; }
然后创建composite1.fsh,它的内容是:
#version 120 const int RGB8 = 0; const int colortex3Format = RGB8; uniform sampler2D colortex1; uniform float viewWidth; uniform float viewHeight; varying vec4 texcoord; const float offset[9] = float[] (0.0, 1.4896, 3.4757, 5.4619, 7.4482, 9.4345, 11.421, 13.4075, 15.3941); const float weight[9] = float[] (0.066812, 0.129101, 0.112504, 0.08782, 0.061406, 0.03846, 0.021577, 0.010843, 0.004881); vec3 blur(sampler2D image, vec2 uv, vec2 direction) { vec3 color = texture2D(image, uv).rgb * weight[0]; for(int i = 1; i < 9; i++) { color += texture2D(image, uv + direction * offset[i]).rgb * weight[i]; color += texture2D(image, uv - direction * offset[i]).rgb * weight[i]; } return color; } void main() { /* DRAWBUFFERS:3 */ gl_FragData[0] = vec4(blur(colortex1, texcoord.st, vec2(1.0, 0.0) / vec2(viewWidth, viewHeight)), 1.0); }
这一部分主要是做一次横向的标准差(Sigma)为3,半径((KernelSize-1)/2)为16的高斯模糊,blur为我们定义的高斯模糊函数,其中第三个参数用于决定模糊的方向,由于这里面所有的数值都被归一到0.0~1.0之间,因此为了计算从一个像素到另一个像素所需偏移的距离,需要用1.0除以屏幕宽/高(单位为像素)来获取步长.此外你会注意到这里只进行了8次循环,理论上讲一个半径为16的高斯模糊起码需要16次循环,然而这里利用了OpenGL的纹理线性过滤,一次采样处理两个像素,具体原理可以看这个http://rastergrid.com/blog/2010/09/efficient-gaussian-blur-with-linear-sampling/
weight是事先计算好的权重表,offset是事先计算好的当采用线性过滤时的采样坐标偏移量.用于计算高斯函数的权重分布的在线计算器可以使用http://dev.theomader.com/gaussian-kernel-calculator/
(注:这段代码可能在OSX Snow Leopard(10.6)或更老版本的OSX上无法运行,因为OSX的OpenGL驱动的一个知名bug就是不支持GLSL数组(牛逼啊),如果你希望你的着色器能在7年前的Macbook上运行的话,你就得手动把它展开,不过这天下怎么会有不紧跟肾果步伐的果厨呢?)
而这部分可能最让人困惑的,大概是我们这里没有继续向colortex1输出数据,而是转而向colortex3输出数据.这是因为OpenGL不建议向正在使用的纹理写入数据,在我的机器上这样做会导致纹理花屏,你可能会好奇那为什么在composite着色器中向gcolor写入数据就没有出错,这个我真回答不了...通常来说大部分实现多遍渲染的程序都会在底层准备两套缓冲区,一套用于读,一套用于写,完成一遍渲染后两个缓冲区的职责交换.然而我们的Shadersmod却神奇地独辟蹊径,强行只留了一套缓冲区,这迫使着色器程序员必须手动把8个缓冲区轮番交换使用,将本应由系统完成的两套缓冲相互交替写入手动模拟一遍...
更新:现在有一个较为(伪)科学的解释了,可以见附录"纹理读写的竞态条件",如果暂时不想看的话,可以将规则简单记为:如果一个缓冲区既涉及到读又涉及到写的话,一个像素只能被负责向它写入的那个片元着色器读.例如在composite.fsh中片元着色器只读取gcolor中它对应的那个像素,因此暂时没有问题.composite1.fsh中片元着色器需要从colortex1中读取33个像素的数据,于是就有问题了.
明白composite1.fsh的原理后就可以编写composite2.fsh了,它的内容和1十分相似:
#version 120 uniform sampler2D colortex3; uniform float viewWidth; uniform float viewHeight; varying vec4 texcoord; const float offset[9] = float[] (0.0, 1.4896, 3.4757, 5.4619, 7.4482, 9.4345, 11.421, 13.4075, 15.3941); const float weight[9] = float[] (0.066812, 0.129101, 0.112504, 0.08782, 0.061406, 0.03846, 0.021577, 0.010843, 0.004881); vec3 blur(sampler2D image, vec2 uv, vec2 direction) { vec3 color = texture2D(image, uv).rgb * weight[0]; for(int i = 1; i < 9; i++) { color += texture2D(image, uv + direction * offset[i]).rgb * weight[i]; color += texture2D(image, uv - direction * offset[i]).rgb * weight[i]; } return color; } void main() { /* DRAWBUFFERS:1 */ gl_FragData[0] = vec4(blur(colortex3, texcoord.st, vec2(0.0, 1.0) / vec2(viewWidth, viewHeight)), 1.0); }
它和composite1极为相似,只不过是执行的纵向模糊,然后把结果输出到colortex1.
最后我们要来编写final.vsh/fsh,来完成两个颜色缓冲的合成,首先创建final.vsh并为它添加:
#version 120 varying vec4 texcoord; void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0; }
然后编写final.fsh,添加:
#version 120 uniform sampler2D gcolor; uniform sampler2D colortex1; varying vec4 texcoord; void main() { vec3 color = texture2D(gcolor, texcoord.st).rgb; vec3 highlight = texture2D(colortex1, texcoord.st).rgb; color = color + highlight; gl_FragColor = vec4(color, 1.0); }
现在可以测试了!但是请做好瞎狗眼的准备,而且这次是真瞎...
泛光效果确实有了,但整个屏幕却亮的能晃瞎狗眼...有一个简单的解决方法是直接在brightness后面乘个小于1.0的系数,降低它的亮度,但效果并不是太好,因此我们来整个皋大上的东西 - ToneMapping.
ToneMapping最初是出自摄影学,早在图形程序员王八瞪绿豆似地盯着CRT显示器之前,摄影师们就开始苦恼于照片的曝光问题,最典型的例子是站在室内向室外拍摄,由于室内亮度远小于室外亮度,如果曝光过小,室内将变成一团黑;而如果曝光过大,室外将变成一团光.如果想同时清晰地拍下室内和室外的场景,或者说同时记录暗处与亮处的细节,就需要一种方式,将它们按照一定形式映射在印刷品或底片所能表示的颜色范围内,于是便有了ToneMapping,ToneMapping便是将对比度差异极大的颜色重新映射到可接受的范围,虽然可能略微失真,但起码能同时呈现暗处和亮处的细节.
用于图形学的ToneMapping算法有不少,这里使用的是http://filmicgames.com/archives/75的修改,而那个算法又是改良自自神秘海域2的算法(http://frictionalgames.blogspot.jp/2012/09/tech-feature-hdr-lightning.html)
首先在final.fsh中添加:
float A = 0.15; float B = 0.50; float C = 0.10; float D = 0.20; float E = 0.02; float F = 0.30; float W = 13.134; vec3 uncharted2Tonemap(vec3 x) { return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F))-E/F; }
这一个是神秘海域2的ToneMapping算法,然后在main函数的"color = color + highlight;"的下面添加:
color = pow(color, vec3(1.4)); color *= 6.0; vec3 curr = uncharted2Tonemap(color); vec3 whiteScale = 1.0f/uncharted2Tonemap(vec3(W)); color = curr*whiteScale;
这段程序是从John Hable的原算法改来的,基本就是手动增加曝光和对比度,W的值代表最大可能的颜色,由于基色最大为1.0,泛光颜色最大为0.75,所以W=1.75^1.4*6.0=13.134. 整个程序看着有些奇怪,但实际上效果相当好,没有雾蒙蒙或白晃晃的感觉.
(注:如果还想更亮的话,可以试试这个:
float W = 16.2; //... color = pow(color, vec3(2.5)); color *= 4.0; vec3 curr = uncharted2Tonemap(color); vec3 whiteScale = 1.0f/uncharted2Tonemap(vec3(W)); color = pow(curr*whiteScale, vec3(1.0 / 2.2));
严格意义上讲这段程序是错的...然而它的效果却比上文的要好,出于严谨性我没有将它正式加入教程,有兴趣的话可以试试)
泛光的内容就到这里,今后的更新中会加入一些对泛光的改进,比如如何实现SEUS的爆肝式泛光,早先为了实现SEUS式的泛光做了不少努力,但后来觉得这样做需要修改的略多,超过这一章节的范围了.毕竟泛光这个效果不像阴影那样是做不好就不行的...
该部分的最终成品可以从这里下载: [SkyDrive] [百度网盘]
关于Shadersmod的功能性介绍基本就到这里了,如果你已经是个着色器专家的话,基本就可以跳过后面的章节,直接看附录作为技术手册然后写自己的光影包了.这后面的内容是供想要实现更多着色器特效但不知从何入手的人参考的.
链接:下篇
膜拜szszss大神,在szszss成为图形触的时候我已经成为一只web狗啦
早上看到更新就滚进来了←_←
这次更新的内容。。。可真够看上几个礼拜的Orz...
先在这里感谢博主的辛勤付出(=・ω・=)
昨天刚想找找看看有没有光影包编写的教程,今天就看到更新233
然而一点都看不懂,被虐成椛233
今天在查一些光照相关的东西,在翻阅到sz大大的文章的时候偶然发现一个错误 🙂 lightmap是不会被时刻重绘的,只有在chunk里存储的逐方块亮度表会在光源更新的时候重新计算。lightmap则是预先制作好,然后把当前渲染方块的亮度结合环境状态映射到uv,再进行贴图映射得到实际的亮度值。
Ref: http://www.minecraftforum.net/forums/mapping-and-modding/resource-packs/resource-pack-discussion/1256353-making-lightmaps-an-mcpatcher-tutorial
害怕.jpg ? 待会改掉 233
不,不用改了quq 昨天把mc的光照系统翻了个底朝天,MC自己的lightmap确实是个动态贴图,虽然具体生成的方法没有看到,但是肯定不是完全预先绘制好的(用的方法也是textureManager#loadDynamic啥啥啥)。。啪啪啪实力打脸。mcpatcher的那个lightmap的原理不太清楚,但是估计是对原本的lightmap进行了叠加或者魔改,有时间再详细研究一下= -= 打搅啦
恋恋这个模型在那弄得,能分享下吗?
https://onedrive.live.com/?id=953DEE6BC55393E9%21167&cid=953DEE6BC55393E9 你这是我见过的评论中歪楼歪的最厉害的...
看了这篇教程真的获益匪浅,以前看过一些ShaderMod原作者Karyonix的教程,无奈当时的英语和编程水平还啃不动
因为我会些C语言,因此下来也修改过一些shaderpack,不过只停留在将另一个光影的特效函数之类的移植到一个光影,或者修改下时间权值色彩渐变之类的程度,对于整体架构的认识还很欠缺,更别说从头开始原创什么特效了
今天终于找到这么优秀的中文shader教程了,虽然以后的要学的专业不大可能使用到计算机图形学,不过当一个业余爱好也未尝不可。
BTW~可以把你的教程搬运至百度贴吧 “MC光影吧”吗 (`・ω・´)?
可以啊 ? 将来"拳打SEUS 脚踢Chocapic13"的国产光影包就等着潜伏在贴吧的野生触手来完成了 (雾) 不过说起来...Karyonix写过教程吗 ? 我只见过一个很简陋的Wiki
对,就是那个wiki....以前根本看不下去,噗
话说最新的SEUS v11.0真的是......Excited 感觉MC光影界都需要学习一个(一言不合就开膜)
请问作者大人能不能帮我做一个只有云,没有任何特效,阴影及光效的光影行吗?只要天上的体积云就行,谢谢!
如果我知道读者在看完教程后照着抄代码都抄不出来的话我会很伤心的... ?
您的教程真简单,还可以更吗,天气之类的。
233 这个教程目的只是介绍一下Shadersmod的开发方法之类的,太专业的东西估计不会太深入...
感谢大大!!受益匪浅
博主,你好,请问一下这行代码是什么意思 shadowposition = shadowposition * 0.5 + 0.5; 是将透视后的顶点进行视口变换吗?
yep,将顶点从NDC变换到屏幕坐标系,OpenGL的NDC范围是[-1,1],屏幕坐标系范围是[0,1],所以要除以2再加0.5