如何编写Shadersmod光影包(附录)

本节为附录部分,包含了一些参考信息和经验之谈.

链接:上篇 下篇

附录 - 所有可用的顶点属性

变量名 格式 描述
mc_Entity vec4 仅在绘制砖块和天空时有意义,在绘制砖块时,X值代表当前砖块的砖块ID,Y值代表其渲染类型(RenderType)(似乎1.8中不存在了),Z值代表Metadata.绘制天空时,在gbuffers_textured中,X值为-3代表绘制云;在gbuffers_skybasic中,X值为-2时代表绘制特殊的天空背景,比如黄昏时西边的晚霞,想要看它具体是什么样的话,在日落时面朝北方或南方,那个明显亮出一块的就是由它绘制的特殊天空背景.
mc_midTexCoord vec4 st值代表纹理中当前待绘制区域的中点位置
at_tangent vec4 顶点的切线

附录 - 所有可用的一致变量


注意,以下内容均不包括gbuffers_spidereyes,它太过于不常用了...

GBuffer绘制阶段和ShadowMap绘制阶段特有:

变量名 格式 描述 取值
texture sampler2D 物体纹理 这还用说吗:) 不过说起来我还真不知道无纹理的物体的纹理取值是啥呢...纯黑的图像?
tex sampler2D ShadowMap绘制阶段独有,等同于texture
lightmap sampler2D 光照贴图 当前时间的光照贴图
normals sampler2D 法线贴图,只有当前材质包拥有法线图并开启了Shadersmod的法线贴图功能时才有效
specular sampler2D 反射贴图,只有当前材质包拥有反射图并开启了Shadersmod的反射贴图功能时才有效
entityHurt int 绘制实体时特有,表明实体是否处于受攻击时的闪红 0为未受伤害;按作者的说法102代表受伤,反正肯定是大于0...
entityFlash int 绘制实体时特有,用于表示苦力帕爆炸前的闪光 0为无闪光;1~255为即将爆炸的苦力帕

后处理与最终合成阶段特有:

变量名 格式 描述 取值
gcolor / colortex0 sampler2D 相当于颜色缓冲gcolor
gdepth / colortex1 sampler2D 相当于颜色缓冲gdepth
gnormal / colortex2 sampler2D 相当于颜色缓冲gnormal
composite / colortex3 sampler2D 相当于颜色缓冲composite
gaux1 / colortex4 sampler2D 相当于颜色缓冲gaux1
gaux2 / colortex5 sampler2D 相当于颜色缓冲gaux2
gaux3 / colortex6 sampler2D 相当于颜色缓冲gaux3
gaux4 / colortex7 sampler2D 相当于颜色缓冲gaux4
depthtex0 sampler2D或sampler2DShadow 相当于深度缓冲depthtex0
depthtex1 sampler2D或sampler2DShadow 相当于深度缓冲depthtex1
depthtex2 sampler2D或sampler2DShadow 相当于深度缓冲depthtex2

所有阶段都共有:

变量名 格式 描述 取值
shadow sampler2D或sampler2DShadow 见帧缓冲知识点中对shadow的解释.在Shadersmod的代码中你确实可以在ShadowMap绘制阶段访问阴影贴图...但谁知道这会发生什么呢?
watershadow sampler2D或sampler2DShadow 见帧缓冲知识点中对watershadow的解释
shadowtex0 sampler2D或sampler2DShadow 包含水面的阴影贴图
shadowtex1 sampler2D或sampler2DShadow 不包含水面的阴影贴图
shadowcolor / shadowcolor0 sampler2D ShadowMap的颜色 默认值为(1.0, 1.0, 1.0, 1.0)
shadowcolor1 sampler2D ShadowMap的颜色
noisetex sampler2D 噪声图 格式为RGB,每一个分量均是一个范围在[0.0, 1.0]之间的随机数.
heldItemId int 玩家所持物品的物品id -1代表什么也没拿
heldBlockLightValue int 玩家所持砖块的光照强度 范围在0~15,0为不发光,15为最强光(比如火炬)
fogMode int 雾模式 0代表当前渲染的物体不受雾效果影响,9729为线性状渐变,2048为指数状渐变,2049为指数平方状渐变,具体公式可以百度...
fogColor vec3 雾颜色
skyColor vec3 天空颜色
worldTime int 当前游戏世界的时间,范围在0(破晓)~23999(黎明前)
moonPhase int 月相
frameTimeCounter float 以秒为单位计算游戏正式启动(即载入了一个存档/加入了一个服务器)后的时间,这个变量的范围是[0.0, 100000.0).游戏暂停时仍会计时
sunAngle float 太阳角度
shadowAngle float 阴影角度
rainStrength float 雨强度 范围在0.0~1.0,平时为0.0,开始下雨/下雪/雷暴时逐渐递增到1.0,停止后再逐渐递减回0.0
aspectRatio float 屏幕横纵比 等于屏幕宽度除以高度
viewWidth float 屏幕宽度
viewHeight float 屏幕高度
near float 投影矩阵的近裁面 为常数0.05
far float 投影矩阵的远裁面 等于游戏设置内的最大视距乘16
sunPosition vec3 眼坐标系中太阳的位置
moonPosition vec3 眼坐标系中月亮的位置
upPosition vec3 一个"近似"可以表示镜头的正上方朝向的向量 说它是"近似"是因为它只考虑镜头的俯仰角(Pitch),而没考虑镜头的偏转角(Yaw).当玩家水平直视某一方向时,它的值是(0.0, 100.0, 0.0),显然当镜头水平时它的上方向自然是指向Y轴正方向;当玩家的镜头朝向正上方时,它的值是(0.0, 0.0, -100.0);朝向正下方时,它的值为(0.0, 0.0, 100.0).
cameraPosition vec3 玩家实体在世界空间的位置
previousCameraPosition vec3 上一帧的cameraPosition...
gbufferModelView mat4 理论上是当前的模型视图矩阵,但实际上并不是...教学着色器Tutorial_BadMatrixes演示了它不等于gl_ModelViewMatrix.具体解释见另一篇附录吧.
gbufferModelViewInverse mat4 gbufferModelView的逆矩阵 根据另一篇附录的结论,显然用gbufferModelViewInverse乘以一个眼坐标系下的坐标,得到的是一个处于世界坐标系中,但被偏移了-cameraPosition个单位距离的点.
gbufferPreviousModelView mat4 上一帧的gbufferModelView
gbufferProjection mat4 等同于当前的投影矩阵(gl_ProjectionMatrix)
gbufferProjectionInverse mat4 gbufferProjection的逆矩阵
gbufferPreviousProjection mat4 上一帧的gbufferProjection
shadowModelView mat4 ShadowMap的模型视图矩阵
shadowModelViewInverse mat4 shadowModelView的逆矩阵
shadowProjection mat4 ShadowMap的投影矩阵
shadowProjectionInverse mat4 shadowProjection的逆矩阵
wetness float 湿度 一个范围在0.0~1.0之间的数,当下雨时会缓慢增加,雨停后会缓慢下降.
eyeAltitude float 玩家眼部高度 即F3菜单中的eyes pos
eyeBrightness ivec2 玩家所在位置的亮度 X值代表来自砖块的光照;Y值代表白天来自天空的光照,这意味着这个变量忽视了昼夜变化...取值范围均为0~240之间的整数,对应0~15这16个光照等级,注意这个值是突变的,即从无光区走到1亮度区时取值会从0突变到15,如果想要渐变版本,请看下面的.
eyeBrightnessSmooth ivec2 玩家所在位置的亮度的渐变版本 eyeBrightness的渐变版本,会在两个不同亮度之间进行平滑.
terrainTextureSize ivec2 纹理分辨率(尺寸) 唔...也许是MC在后台拼合成的纹理贴图的尺寸? (脑补旧时代MC那256x256的纹理集...)
terrainIconSize int 单个砖块的贴图尺寸 例如默认MC的贴图尺寸是16(像素)
isEyeInWater bool 玩家是否在水下
hideGUI bool 是否隐藏GUI界面
centerDepthSmooth float 屏幕中央的深度缓冲数值,这个是经过插值平滑的,可以用来实现DoF(景深)等特效.
atlasSize ivec2 TODO

附录 - 所有可用的控制参数


控制参数是Shadersmod供光影包调整一些内部参数的方式,比如通过控制参数来控制ShadowMap的分辨率等. 注意:以下所有内容均要求一条控制参数独占一行,且开头不能有缩进,结尾不能有多余的内容.

内容 描述 控制方式
启用缓冲区 Shadersmod会扫描着色器脚本,自动找出需要启用的颜色缓冲或深度缓冲,不过在使用缓冲时尽量避免跳着用,比如避免放着gaux1和gaux2不用,直接用gaux3去了...系统依然会为gaux1和gaux2分配显存,于是便造成性能浪费 (当然如果你不在乎的话那就当我没说) 在任意一个片元着色器中加入:
uniform sampler2D [缓冲名];
例如:uniform sampler2D gaux1;
效果为启用gaux1
对于深度缓冲可以使用sampler2DShadow代替sampler2D,如果你知道怎么使用sampler2DShadow的话...
设定颜色缓冲格式 所有缓冲区都有默认的格式,有些你不能控制(比如深度缓冲的格式和ShadowMap的格式),但你可以控制G-Buffer的8个颜色缓冲的格式. 在任意一个片元着色器中加入:
const int [格式] = [随便一个整数,随便,真的];
const int [名称或别名]Format = [格式];
例如:
const int RGBA32F = 12450;
const int gaux1Format = RGBA32F;
让gaux1的格式为RGBA32F
启用颜色缓冲的Mipmap 默认的颜色缓冲都不启用Mipmap(不知道是什么东西自行百度...),如果你希望使用Mipmap的话可以启用它,需要注意的颜色缓冲的Mipmap是分阶段的,比如一个颜色缓冲可以在composite、composite2和final中启用Mipmap,而在composite1中不启用Mipmap. 在需要使用Mipmap的后处理或最终合成阶段的片元着色器中加入:
const bool [名称或别名]MipmapEnabled = [是否启用Mipmap];
例如:
在composite1.fsh中加入:
const bool gaux1MipmapEnabled = true;
在composite1阶段中启用gaux1的Mipmap
片元着色器输出 有时你可能不想向所有的颜色缓冲都输出颜色,这时你可以声明只向某几个颜色缓冲输出颜色. 在该片元着色器中新起一行加入:
/* DRAWBUFFERS:[各个输出目标的ID] */
具体方式见上篇的"知识点 - Shadersmod可用的缓冲区"
ShadowMap基准分辨率 默认的ShadowMap基准分辨率为1024,还要乘以游戏中Shadersmod配置中的那个ShadowMap分辨率系数才是实际分辨率.如果你认为默认的分辨率太低的话,可以手动提高它.不过要小心的是太大的分辨率可能会在低端显卡上无法运行,Shadersmod最大的分辨率系数是4.0,乘以默认基准分辨率后是4096*4096,刚好是低端显卡所最大支持的纹理面积. 方法1:在任意一个片元着色器中新起一行加入:
/* SHADOWRES:[基准分辨率] */
方式2:在任意一个片元着色器中加入:
const int shadowMapResolution = [基准分辨率];
ShadowMap覆盖面积 修改ShadowMap覆盖面积其实就是修改其投影矩阵.默认投影矩阵为正交投影,在ShadowMapping技术中正好符合平行光源(比如太阳光),它的默认参数是左右上下裁面均位于±160,近裁面位于0.05,远截面位于256.
如果你有特殊需要的话,也可以将绘制ShadowMap的投影矩阵换为透视投影,按照ShadowMapping技术定义只有点光源才会使用透视投影,换成透视投影后的默认参数为Fov 90°,横纵比为1(准确说是ShadowMap的宽度除以高度,但我想不出来能让两者不相等的办法),近裁面0.05,远裁面256.
声明为正交投影:
方法1:在任意一个片元着色器中新起一行加入:
/* SHADOWHPL:[覆盖面积的半长,默认值为160] */
方式2:在任意一个片元着色器中加入:
const float shadowDistance = [覆盖面积的半长,默认值为160.0];
声明为透视投影:
在任意一个片元着色器中新起一行加入:
/* SHADOWFOV:[投影的Fov,默认值为90] */
ShadowMap位置矫正频率 ShadowMap的焦点并非始终紧随玩家,它是当玩家每移动一段距离时才会矫正位置,这个值默认是2.0. 注意这个特性仅当ShadowMap使用正交投影时才生效,在透视投影下ShadowMap始终会紧随玩家. 在任意一个片元着色器中加入:
const float shadowIntervalSize = [每次矫正时最小移动距离];
ShadowMap的Mipmap 默认情况下ShadowMap不会生成Mipmap,如果你需要它的话可以将其开启. 在任意一个片元着色器中加入:
const bool generateShadowMipmap = [是否生成所有深度缓冲的Mipmap];
const bool generateShadowColorMipmap = [是否生成所有ShadowMap颜色的Mipmap];
如果你只想让某一个生成的话:
const bool shadowtexMipmap = [是否生成shadowtex0的Mipmap];
const bool shadowtex0Mipmap = [是否生成shadowtex0的Mipmap];
const bool shadowtex1Mipmap = [是否生成shadowtex1的Mipmap];
const bool shadowcolor0Mipmap = [是否生成shadowcolor0的Mipmap];
const bool shadowColor0Mipmap = [是否生成shadowcolor0的Mipmap];
const bool shadowcolor1Mipmap = [是否生成shadowcolor1的Mipmap];
const bool shadowColor1Mipmap = [是否生成shadowcolor1的Mipmap];
(注:这种多个参数效果相同的意为一种参数有多个别名,不是说你必须全写上才能生效...)
ShadowMap的硬件支持的深度缓冲比较 默认情况下ShadowMap不会启用ARB_shadow这个扩展引入的硬件支持的深度缓冲比较功能,说白了就是不能使用sampler2DShadow和shadow2D,显然如果你想用那俩个的话需要手动开启硬件支持. 在任意一个片元着色器中加入:
const bool shadowHardwareFiltering = [是否开启ShadowMap的所有深度缓冲的ARB_shadow支持];
如果你只想启用某一个的话:
const bool shadowHardwareFiltering0 = [是否开启shadowtex0的ARB_shadow支持]
const bool shadowHardwareFiltering1 = [是否开启shadowtex1的ARB_shadow支持]
ShadowMap的最邻近过滤 默认情况下ShadowMap使用的是双线性过滤(GL_LINEAR),但如果你有特殊需要的话,可以换成最邻近过滤(GL_NEAREST).相应的,如果你还开启了Mipmap的话,系统会自动使用带Mipmap的最邻近过滤(GL_NEAREST_MIPMAP_NEAREST) 在任意一个片元着色器中加入:
const bool shadowtexNearest = [是否将shadowtex0设为最邻近过滤];
const bool shadowtex0Nearest = [是否将shadowtex0设为最邻近过滤];
const bool shadow0MinMagNearest = [是否将shadowtex0设为最邻近过滤];
const bool shadowtex1Nearest = [是否将shadowtex1设为最邻近过滤];
const bool shadow1MinMagNearest = [是否将shadowtex1设为最邻近过滤];
const bool shadowcolor0Nearest = [是否将shadowcolor0设为最邻近过滤];
const bool shadowColor0Nearest= [是否将shadowcolor0设为最邻近过滤];
const bool shadowColor0MinMagNearest= [是否将shadowcolor0设为最邻近过滤];
const bool shadowcolor1Nearest = [是否将shadowcolor1设为最邻近过滤];
const bool shadowColor1Nearest= [是否将shadowcolor1设为最邻近过滤];
const bool shadowColor1MinMagNearest= [是否将shadowcolor1设为最邻近过滤];
干湿度渐变速率 Shadersmod提供了wetness来作为一个"平滑插值的rainStrength",其渐变速率可以通过湿度变化率(默认值200)和干燥变化率(默认值600)来控制,其中变化率越小,则变化越快,为1时基本上wetness就等同于rainStrength了.其中湿度变化率控制下雨时wetness增加的速率,干燥变化率控制雨停时wetness的递减速率. 方法1:在任意一个片元着色器中新起一行加入:
/* DRYNESSHL:[湿度变化率] */
/* WETNESSHL:[干燥变化率] */
方式2:在任意一个片元着色器中加入:
const float drynessHalflife= [湿度变化率];
const float wetnessHalflife = [干燥变化率];
亮度渐变速率 Shadersmod提供了eyeBrightnessSmooth作为eyeBrightness的渐变版本,其渐变速率(默认为10)可以由eyeBrightnessHalflife来控制,越小则变化越快. 在任意一个片元着色器中加入:
const float eyeBrightnessHalflife = [变化率];
中央深度渐变速率 Shadersmod提供了centerDepthSmooth作为经过平滑插值的屏幕中央深度,其渐变速率(默认为1)可以由centerDepthHalflife来控制,越小则变化越快. 在任意一个片元着色器中加入:
const float centerDepthHalflife = [变化率];
太阳倾斜角 Minecraft默认的太阳直挺挺地东升西落虽然效果不错,但对于阴影绘制来说是个噩梦,因此适当地修改太阳倾斜角可以抖个机灵回避阴影绘制时的问题. 在任意一个片元着色器中加入:
const float sunPathRotation = [太阳倾斜角];
环境光遮蔽强度 环境光遮蔽是近些年游戏引入的一项真实光照技术,早期的光照模型都会忽略光的二次反射问题,如果你看过一些早期OpenGL光照系统的演示的话,肯定会见过类似一个完全漆黑的房间里有一盏灯和它突兀的亮光的场景,现实中这种情景很少见,因为物体除了会被光源直接照亮外,还会被其他亮处的物体的漫反射光照亮.对此一个常见的解决方案是引入环境光,即最低亮度,然而这又带来一个问题就是一些本该不亮的地方也被照亮了...环境光遮蔽技术就是让那些不该亮的地方不亮.其实游戏中已经自带了一定程度上的环境光遮蔽效果,而这个参数就是控制该效果的.默认值为0.8,当改为0.0时场景会很亮,而1.0时则会有些暗.具体效果看下图(点击放大):
0.0:AO0
0.8:AO8
1.0:AO10
在任意一个片元着色器中加入:
const float ambientOcclusionLevel = [强度];
超采样抗锯齿 好吧,这个其实没效果...仅仅是Shadersmod的保留功能. 在任意一个片元着色器中加入:
const int superSamplingLevel = [超采样等级];
噪声图分辨率 Shadersmod默认的噪声图分辨率为256x256,通常来说是够用了,但如果你想获得更多的多样性的话,可以调高噪声图的分辨率来减少重复. 在任意一个片元着色器中加入:
const int noiseTextureResolution = [噪声图分辨率];

附录 - Shadersmod的所有阶段


见上篇的"知识点 - 光影包的组成"

附录 - DRAWBUFFERS导致的水面异常

这个标题我觉得挺蛋疼的,作为一个自己能被自己的想法吓到的人,在深更半夜写下这种诡异的标题我反正是觉得挺毛骨悚然的,管它呢,我们来切入正题.
我想你已经知道"/* DRAWBUFFERS:xxx */"的前面和后面不能加包括tab和空格在内的任何东西这件事情(如果你确实不知道的话,现在也知道了),之前我一直认为如果不声明DRAWBUFFERS的话,会默认按照01234567的顺序来输出,但现在发现不尽然,对于后处理(即composite系着色器)来说,如果不声明DRAWBUFFERS的话,会导致水面出现异常.

2015-10-09_01.50.32
图:没有声明DRAWBUFFERS的composite,输出内容为直接输出gcolor

2015-10-09_01.50.45
图:声明DRAWBUFFERS:0的composite,输出内容为直接输出gcolor

此外,就连最备受推崇的SEUS都中招了...SEUS10.1的水面会有闪烁效果,如果手动加上DRAWBUFFERS:012的话就会解决这个问题.

2015-10-09_01.49.52
图:在GTX970下,未经修改的SEUS10.1,注意图左侧的裂纹

2015-10-09_01.50.13
图:手动在composite中加上DRAWBUFFERS:012的SEUS10.1

如果你在网上搜这个问题的话,你也会注意到很多人都发现了SEUS10.1的水面闪烁或马赛克问题,其中大部分人都指出问题仅发生在使用Maxwell架构的N卡上,刨去那些什么"SEUS是专门为Kepler架构开发的"之类的装(sha)逼言论不说,这个问题确实可能是由于显卡在OpenGL底层处理的方式不同而造成的bug,但我更觉得是Shadersmod没有正确调用OpenGL,导致在旧架构下能勉强运行的dirty code在新架构中挂掉...同样的代码没法保证在不同的显卡下取得相同的效果,这也是OpenGL饱受人诟病的,我之前以为这种差异仅存在于不同品牌的显卡间,现在才发现同一品牌下的不同系列显卡也会有这种问题(而且还都是近些年的高端产品,而不是新品与古董),确实让人大开眼界,也不得不让人想说"你OGL要完啊"..."我爱大GL!我怕她要完啊!" (笑)

附录 - ST坐标系,朝上还是朝下?


ST坐标系即OpenGL的纹理采样器所使用的坐标系,弄清其T轴到底朝下还是朝上是很重要的,因为朝上意味着纹理可以是左下角置于原点,而朝下意味着左上角可以置于原点.当我真的想确认这一点时被网上众说纷纭的说法吓尿了,大部分书籍和网上的资料都说是T轴朝上,而一部分网站和在MC中实际测试结果是朝下,最后我的结论是:确实是T轴朝上的,但MC对纹理做了一次垂直翻转...

在大部分绘图软件中,图像的原点都被标记在左上角,Y坐标向下为正;如果你有过在Windows上的GUI编程经验的话,也会知道屏幕坐标系是左上角为原点,Y轴朝下为正;DirectX的纹理取样器便顺应大众的习惯,它使用的坐标是T轴朝下为正.然而OpenGL却截然相反,它将原点定为图像的左下角,并规定T轴向上为正.

20100531_DX_OpenGL
图:DX与OGL在ST坐标系上的区别,图片摘自TheDev.log (可能需翻墙)

应付这种反人类设计的方法有许多,一种方式是手动封装一遍操作纹理坐标的函数,对y轴的操作全改成1-y;而另一种更取巧的方法是传入纹理时直接传进去一个垂直翻转的,这样就可以按照T轴朝下的坐标系来操作了.

stc1
stc2
图:图片在经过垂直翻转后,便可以按照T轴朝下的方式来操作了.

那么那些说OpenGL的ST坐标系朝下的是怎么回事?我猜这和他们使用的图像库有关...一副图像在读入成位图格式时,其数据是按照从下往上还是从上往下的顺序和文件本身以及图像库本身都有关系,那些人可能碰巧使用了一个自动翻转图像的库,因此得出了OpenGL ST坐标朝下的结论.
更新:其实还有一个原因,glTexImage2D这个函数在上传纹理时是从左下角开始,按照从左到右,从下到上的方式传入数据,这意味着如果图像载入是按照从上到下,从左往右的方式读取的话,未经任何处理的图像在被载入成纹理后刚好是上下颠倒的,不留意的人会误以为坐标系是朝下为正.

此外,你可以通过教学着色器Tutorial_STCoordinate来亲自验证MC中的纹理坐标,在Tutorial_STCoordinate中,黑色代表纹理坐标靠近(0,0),红色代表靠近(1,0),绿色代表靠近(0,1),黄色代表靠近(1,1).

2015-09-13_19.29.07
2015-09-13_19.29.13

附录 - gbufferModelView与shadowModelView ~ 追寻特大型矩阵之谜

刨去逗闷子的标题,gbufferModelView与shadowModelView的特性确实挺让人困惑,官方wiki上对gbufferModelView的描述是"在设置完相机变换后的4x4模型视图矩阵,和之前的用途稍有不同,所以名字有一些歧义",而对"shadowModelView"的描述则干脆是"生成ShadowMap时用的模型视图矩阵".但实际上使用时它们绝对没法取代gl_ModelViewMatrix,教学着色器Tutorial_BadMatrixes演示了这一点,那么gbufferModelView和shadowModelView到底是什么?

gbufferModelView的描述提到了"设置完相机变换后",那么它和cameraPosition会不会有什么关系?经过测试确实有...
通过gbufferModelViewInverse * gl_ModelViewMatrix * gl_Vertex可以得到的是一个奇怪的坐标,但如果将这个坐标加上cameraPosition,得到的就是世界坐标.因此我们可以得出一个公式:
Mc*Mgmv-1*Mmv*V = Mm*V
其中Mc是将坐标平移cameraPosition个单位的变换矩阵,Mgmv是gbufferModelView,那么:
Mc*Mgm-1*Mgv-1*Mv*Mm*V = Mm*V
Mc*Mgm-1*Mgv-1*Mv = I
Mc = Mv-1*Mgv*Mgm
Mv*Mc = Mgv*Mgm = Mgmv
于是结论是gbufferModelView等于Mv*Mc,Mv为视图矩阵,Mc是一个将物体平移cameraPosition个单位距离的4x4矩阵.

附录 - ⑨评gl_NormalMatrix


好吧,居然还真有人对这个东西的来历和实质感兴趣...gl_NormalMatrix是对法线向量进行模型视图变换的矩阵,简单地说,gl_NormalMatrix在大部分情况下就是gl_ModelViewMatrix的左上3x3部分,即大部分情况下gl_NormalMatrix * gl_Normal等于(gl_ModelViewMatrix * vec4(gl_Normal, 0.0)).xyz,那么什么才算是少部分情况?当模型视图矩阵涉及到缩放时.
缩放分为统一缩放和非统一缩放,统一缩放是指在xyz轴上进行相同倍数的缩放,显然这种情况下法线向量在经过模型视图矩阵变换后方向依然不变,只是长度不为1了,经过归一化修正后依然能保持结果正确;然而当不同轴上的缩放倍数不同,即进行非统一缩放时问题就来了,法线向量在经过变换后会指向错误的方向,比如一个紧贴xy轴的等腰直角三角形的斜边的法线N是(1,1)(未归一化到单位长度,下同),切线T是(1,-1),显然N·T=0,符合法线垂直于切线的定义,然而如果将三角形沿y轴伸长到2倍,那么变形后的切线显然依然是(1,-2),而法线却不可能是(1,2),因为显然(1,-2)·(1,2)≠0.

normalmat1 normalmat2
图片源自lighthouse3d

因此我们需要一个特殊的矩阵能够让法线向量进行正确的模型视图变换,这个矩阵就是gl_NormalMatrix,它的来源我倒觉得不像是人为拟定的,而更像是从现有的公式下反推出来的,还记得我们刚才提到的法线与切线的公式,N·T=0不?现在我们就用它来反推出gl_NormalMatrix,不过这里需要说明的是,由于接下来我们要用的是矩阵运算,因此必须把向量点乘转化为矩阵乘法,方法就是将两个向量都写为矩阵,然后将第一个矩阵进行转置,即A·B=MAT * MB,A和B为2个向量,MA和MB是它们的矩阵形式.
首先我们设n和t为变换前的法线和切线向量,n`和t`为变换后的法线和切线向量,我们得到了两个约束条件:
n·t=0
n`·t`=0
如果将两个向量计算公式转化为矩阵计算公式的话,那么就是:
NT * T = 0
N`T * T` = 0
其中N、N`、T和T`均为其小写对应的向量的齐次坐标矩阵,即xyz直接对应原向量的xyz,w为0.
然后我们来研究如何表示T`,按照切线的定义,t等于其所在平面上任意两点p0、p1之差,即
t=p0-p1
即使转换成矩阵形式,只要两者的齐次项系数w相等,等式也是成立的
T=P0-P1
而t`则等于经过变换后的p0`和p1`两点之差,即
t`=p0`-p1`
T` = Mmv * P0 - Mmv * P1
其中Mmv为模型视图矩阵,按照矩阵乘法的结合律,有
T` = Mmv * (P0 - P1) = Mmv * T
将T`代回原式,有
N`T * Mmv * T = 0
接下来我们研究的是如何用一个矩阵表示N`的变换,我们设那个矩阵为Mn,于是便有
N` = Mn * N
(Mn * N)T * Mmv * T = 0
NT * MnT * Mmv * T = 0
于是我们最后一步就是解上式,如果要直接硬上的话确实有点难算,但如果用到数学中鬼畜的"不妨设",那么这个问题就迎刃而解了,所以不妨设
MnT * Mmv = I
那么原式就变成
NT * I * T = NT * T = 0 成立
因此,我们只需要解MnT * Mmv = I就行了,结果显然是:
Mn = (Mmv-1)T
因此,Mn在数学上等于gl_ModelViewMatrix的逆矩阵的转置,由于gl_Normal是三维向量,而非是四维齐次坐标,因此gl_NormalMatrix只有Mn的左上3x3部分.
如果你还没看晕的话,你大概会想起开头我提到在大部分情况下gl_NormalMatrix就是gl_ModelViewMatrix的左上3x3,也就是说在大部分情况下
Mn = Mmv = (Mmv-1)T
如果你对数学足够敏感,或者在线性代数课上睡的觉(jue)比较少的话,那么你大概会发现在这种情况下Mmv是个正交矩阵,只有正交矩阵才会满足Mmv-1 = MmvT的特性,也才会有Mmv = (Mmv-1)T.变换矩阵在几何意义上只涉及到旋转时,它始终是满足正交性的.

附录 - 浅谈控制流与并行运算


想在着色器中通过条件分支来实现优化异常困难,不只因为执行条件分支本身对GPU来说就是一件困难的事,还因为一些GPU实现在底层就根本不支持分支.
众所周知,GPU的并行运算是通过许多个核心同时处理数据来实现,Nvidia在2006年发布的Tesla架构中引入了SIMT(Single instruction, multiple thread)的概念,复数个核心作为线程被编为一组(GLSL规范要求4个一组) 运行时组内所有的线程都会执行相同的指令,但可以有不同的结果,比如如果一个线程负责处理一个像素的话,一条纹理取样指令可以让组内所有的线程都根据当前像素对应的UV从纹理中取得不同的颜色.
然而当引入条件分支时事情就有些棘手了,在一个IF-ELSE中很可能组内某几个线程进入一条控制流路径,其它线程进入另一条,这时SIMT不但帮不上忙,反而会阻碍分支的执行,因此大部分GPU使用的策略是执行全部的路径,但对不符合分支条件的线程采取关闭或结果不写回的手段,这也是为什么条件分支在着色器中优化效果有限的原因,当然这并不代表就没有效果,对于组内所有线程都不满足条件的分支GPU是不会执行的,因此在着色器中一些大型的早期分支依然会有一定的优化效果.
然而仍然存在一种会对分枝产生影响的情况 - 并行性依赖指令(不用搜了,这个名词是我随口造的?),我们都知道texture纹理取样指令对Mipmap纹理可以自行判断最佳的LOD,那么它是怎么在只知道一个纹理坐标的情况下实现的?先聊个题外话,在GLSL中还有几个很有意思的指令:dFdx、dFdy以及一系列它们的衍生指令,它们的作用是根据输入值返回它在X/Y方向上的导数...这个鬼畜指令的原理就是依赖了并行性,或者说依赖了SIMT的特性,由于SIMT要求组内所有的线程在运行时都是在执行同一条指令,因此当一个线程执行并发性依赖指令时,它周围所有的线程也都是在执行这个指令,这些线程只要相互交换数据,就可以根据已有的信息来执行一些特殊的操作,比如结合相邻线程所执行的dFdx中的参数以及自己的参数来计算导数,纹理取样时判断LOD的原理也是根据相邻线程的UV与自己的UV的差值来判断最佳的LOD.在存在此类指令时,OpenGL可能会强制一些本来已经通过IF-RETURN来提前结束的分支继续执行下去,以便获取这些信息.
此外,还有一个关于片元着色阶段的一个错觉是"所有的像素真的是同时并行处理的"...想想看,一个1920x1080的屏幕会有二百万个像素,老黄再疯狂也造不出单卡二百万核心的卡...所以在概念上虽然所有的像素都可以并行处理,但实际上还是要分个先后顺序的,这个又涉及到了一个问题:纹理读写的竞态条件,具体见下一节.

附录 - 纹理读写的竞态条件


上一个附录提到了GPU其实做不到所有的像素都并行处理,这在使用帧缓冲和RenderToTexture时会有个问题,如果你向一个正在读的纹理写入数据的话,可能会引发各种喜闻乐见的事情.
这个问题具体还分为两种,一种是在多次DrawCall时,着色器向正在读取的纹理写入数据,这个问题其实类似于CPU的多线程时内存可见性,OpenGL在引入Texture barrier前不要求实现保证第二次DrawCall一定能从纹理中读取到第一次DrawCall写入的数据,在没有Texture barrier时想要破解这个问题也很简单,重新绑定一下帧缓冲和纹理就行了,Shadersmod在进行多遍后处理时也这么做了,于是帮我们回避了一个问题.我们面临的是第二种问题,在一次DrawCall时对同一张纹理即读即写,在正篇中我已经提到"如果对一个缓冲区既涉及到读又涉及到写的话,一个像素只能由负责向它写入的那个片元着色器读",显然当一个GPU在分批处理屏幕上的像素时,如果一个对纹理即读即写的片元着色器需要从其他位置读取像素的话,就会面临一个问题,它读到的可能是尚未被着色器处理过的像素,也可能读到已经被处理过的像素.在编程时程序员往往期望读到尚未被改写过的纹理,如果它恰巧读到已经被写入过的纹理的话,程序的运行结果肯定会与预期不符.

2015-10-25_19.32.51

事实上,OpenGL并不鼓励对纹理即读即写 - 即使是之前所说的一个像素只能由负责向它写入的那个片元着色器读,这里它能运行只能说是显卡允许这样做,而不是规范允许这样做,正确的处理姿势是使用PingPong技术,准备两块帧缓冲区然后每次DrawCall读一块写一块,完成后两块交换.Shadersmod只留了一块缓冲区,看上去是省了一点显存(其实能省多少啊...MC本来就不吃显存,一个RGBA8格式的1920x1080的颜色缓冲有8MB大小,8块颜色缓冲外加它们的交换缓冲大约128MB,能开光影的显卡哪个不是1G起底),但实际上却多了不少麻烦,所以这时我们要么手动在多块帧缓冲间来回写入,要么冒险利用显卡特性在一个纹理上小心地同时读写.

附录 - 那些年,那些对苹果的黑枪


尽管理论上OpenGL在各个平台中都拥有相同的特性,但事实上它就和早期使用AWT的Java GUI程序一样,属于"一次编写,到处测试".
先来钦点一番巨软,Windows对OpenGL虽然一直不用正眼看它,但讽刺的是Windows是对OpenGL支持最好的,除了那个需要double-boot的启动方式(想要安全地创建OpenGL环境必须得传入有效参数,而想要获得所有可用的有效参数必须得先有个OpenGL环境...所以主流采用的方案是先用最低的参数配置启动一个OpenGL环境,查到可用参数后立刻销毁掉旧环境然后再去正式启动)有些蹩脚,以及手动查询函数指针的方式有些奇怪(其实也不是很奇怪,OpenGL确实允许这样做)以外,别处还真没有什么大问题;在Linux上近些年随着显卡驱动的改善,对OpenGL的支持也不错;那么说到苹果...好吧,《Windows游戏编程大师技巧》的作者André LaMothe直言不讳地说游戏程序员都不喜欢OSX,苹果对OpenGL的态度近些年一直处于一个微妙的状态,或许这和当年OpenGL推出核心模式时有关,在Khronos接管OpenGL主持权时,面临的是一个腐朽的,充满各种旧时代的各种厂家的奇技淫巧的API,为此Khronos花了三年的时间策划了OpenGL3.2时的"分裂",在OpenGL3.2时整个API集被分为两部分,兼容模式(Compatibility Profile)拥有全部的API,你能从中找到1992年推出OpenGL1.0时的API,也能看到2009年OpenGL3.2中的DirectX10风格API;而核心模式(Core Profile)则是它的子集,只保留一套精简的高性能API,旧的API只保留必要的部分,凡是拥有更新的替代品的全部剔除掉.在谋划两个模式时厂商们自然是根据自身的技术实力和利益在支持派和反对派中站队,Nvidia和苹果都是站在了支持派的队伍中,其中Nvidia是喊得声最大的,而苹果是做的最积极的,在苹果的最新驱动中就压根没提供兼容模式,然而当尘埃落定时Nvidia却态度急转,声称兼容模式依然有必要存在,被所有人摆了一遭的苹果似乎就此黑化了,对驱动的改进从此也不再上心,时至今日OSX还具有三大操作系统上最猎奇的OpenGL模式,它有两个模式:支持3.2以及更高的核心模式以及只支持2.1的遗留模式(Legacy)(事实上OSX的GUI系统乃至整个图形系统就是个猎奇,你知道它有四套OpenGL接口吗?),这意味着开发者要么使用最新的API并抛弃掉所有旧的,要么就只能停留在OpenGL2.1时代,通过扩展来调用新功能.不但如此,苹果的显驱对OpenGL的更新支持也明显不如当初那么勤快了,明眼人都能一眼看出抛弃了"业界标准"的苹果心里打着什么算盘,果然这两年苹果发布了Metal这套自己做的图形API,还号称效率比OpenGL提高了多少多少,法克,就凭你那态度在OSX上OpenGL能快的了吗...最近,原来的OpenGLNG,现在的Vulkan在被宣布后苹果对它的态度一直不冷不热,小伙伴们都猜得出被Khronos卖了一次的苹果在好不容易造出自家的图形API却又被Khronos和ATI的Vulkan(ATI的Mantle是Vulkan的原型)拆台时的心情,也都做好了最坏的准备.

附录 - 已知的Shadersmod Bug


声明gdepth时如果使用的是colortex1而不是gdepth,并且没有声明默认格式的话,其格式会被初始化为RGBA8而不是RGBA32F.
composite中如果没有添加DRAWBUFFERS的话在绘制水面时会有问题,似乎由于OpenGL API没有被正确调用有关,具体见上文的"附录 - DRAWBUFFERS导致的水面异常".
一旦启用ShadowMap绘制的话,水下的雾会有问题,估计作者在绘制ShadowMap时改了雾参数然后忘了将它重置了...
某些关于ShadowMap的变量在光影包之间没有被正确地隔离,在多个光影包之间切换可能导致某些旧的光影包中的控制参数被错误地保留到新的光影包中,具体是哪些参数尚不清楚...已确认的有shadowDistance(如果新光影包未声明的话,会沿用旧光影包的设定,而不是重置回默认的160)
ShadowMap绘制阶段没有关闭Alpha测试,因为它被设计为能自适应树叶等自带透明部分的纹理(这也是为什么你在ShadowMap绘制时必须输出一个纹理颜色的原因),如果你希望在ShadowMap绘制时向颜色纹理输出其他数据,比如打包成RGBA8的高精度深度数据的话,就得使用昂贵的MRT,在shadowcolor1中输出你需要的数据,然后在shadowcolor/shadowcolor0中输出纹理颜色. (其实透明剔除这种事情可以由开发者在着色器中完成,不是吗?)
称不上Bug的Bug,准确说是一个设计疏忽,就是即使只用到ShadowMap绘制阶段输出的颜色纹理(shadowcolor/shadowcolor0和shadowcolor1),而没用到深度缓冲(shadow/shadowtex0和shadowtex1)的话,也必须声明深度缓冲,否则系统不会启动ShadowMap绘制.
depthtex1在玩家位于云层以上高度时不包括云我觉得与其说是特性倒不如说是Bug,因为游戏的云渲染分两种情况,玩家在云层下(y<=128)和玩家在云层上(y>128),估计作者忘了处理第二种情况了...
迄今为止见过的最无语的Bug,在末地时Shadersmod会错误地开启所有颜色缓冲的Alpha混合,而且混合方程好像还弄错了,这导致如果你想让你的光影包能在末地正常工作的话所有颜色缓冲的Alpha位就都不能用了,必须得输出1.0.不过不用虚,我还没见过几个光影包能在末地正常工作,最好的也不过是把末地当正常世界的沙滩来渲染,居然还上了个炫光和体积云...牛逼啊,其余的光影包在末地就是一团黑.
在末地和下界sunPosition和moonPosition会变得无法使用,因为它们的数值将始终保持在你切换世界前那一刻的值(说白了就是在没有天空的世界系统不更新sunPosition和moonPosition的值),而眼坐标系下的坐标又不可能在不实时更新的情况下使用...

附录 - 主流光影包的技术特性


这里记述了主流的光影包的高配版本的技术特性,供有心研(piao)究(qie)的人参考.
唔...我把它们写在一个Excel表格里了,但还需要整理一番.

附录 - 所有下载内容


这里包含了本文中所有可供下载的内容:
[SkyDrive] [百度网盘]

附录 - 教学着色器


教学着色器用于演示一些游戏内的特性,可以从这里下载: [SkyDrive] [百度网盘]

Tutorial_DefaultShadowMapping
演示了默认的Shadow Map
2015-09-13_23.33.31

Tutorial_StereographicShadowMapping
演示了系数为0.85的球面投影Shadow Map
2015-09-13_23.33.48

Tutorial_LinearDepth
演示了概念上的线性深度缓冲,注意这并非是真实的深度缓冲,而是根据3张深度贴图重建来的线性深度缓冲,黑色代表距离近,白色代表距离远,青色代表只有depthtex0有而depthtex1和2没有的点,蓝色代表depthtex0和1有而depthtex2没有的点.
2015-09-13_23.28.42

Tutorial_LightSpacePosition
演示了光照坐标系下的像素位置.XYZ坐标被直接映射到RGB上.
2015-09-13_23.42.49

Tutorial_WorldSpacePosition
演示了世界坐标系下的像素位置.XYZ坐标被直接映射到RGB上.
2015-09-13_23.42.41

Tutorial_STCoordinate
演示了顶点的ST坐标,黑色为靠近(0,0)的一点,红色为靠近(1,0)的一点,绿色为靠近(0,1)的一点,黄色为靠近(1,1)的一点.
2015-09-13_19.00.28

Tutorial_BadMatrixes
演示了gbufferModelView并不能取代gl_ModelViewMatrix,或者说是前者并不等价于后者.
2015-09-13_23.49.05