如何编写Shadersmod光影包(下)

链接:上篇 附录

下篇 - Shadersmod光影包高级应用

实战光线行进 - 体积云

这一次我们通过Ray Marching来实现一个体积云效果.

==================================================
知识点:Ray Marching

Ray Marching(光线行进)是一种渲染方法,正常的渲染方法是将顶点数据进行变换后组装成图元,然后经由光栅器进行光栅化后成为一个个像素.而另一种方法则正好相反 - 从视点中心向观察方向逐像素地投射射线,基于光路可逆的原理,反向查找落在这个像素的光会是来自哪些物体,Ray Marching就是采用这种方法,而它之所以被称为"Marching"则是因为它查找光源的独特方式,有时很难一步查找到射线命中的物体,所以Ray Marching是通过步进的方式,沿着射线一点点前进,并沿途判断是否命中了发光物体.
Ray Marching的一个好处是可以用于渲染气体以及非均匀质地的透明固、液体的,只要让它在命中物体后不停下,而是继续前进,并累积"吸收"到的亮度,当它前行到最大距离,或已吸收到最大亮度时,得到的结果就是最终颜色.另一个好处则是能方便地实现反射和折射等效果,只要根据光线入射角度和入射点的法线按照反射和折射定律,偏转光线方向,或者干脆再发射一条光线,就可以轻松实现在正常渲染中需要各种手段才能实现的效果(想想我们当初为了实现阴影...)
而Ray Marching的缺点则是对数据结构的要求太苛刻,同时边缘锯齿也比较严重.此外,它的性能消耗也是极大的,为了降低消耗,通常会引入估距函数,估距函数用于在当前测试点未能命中物体时,估算还要一次向前前进多少距离才能命中物体,估计函数过于保守会导致测试点迟迟未能命中,浪费大量计算时间,降低效率;而过于激进的话会导致首次命中发生在物体表层之下,甚至是穿透或错过小的物体,因而产生错误的结果.
==================================================

首先还是先思考一下怎么实现体积云效果,我们设定云层是在一个固定的高度,比如150~160的高度之间.当玩家站在地面上时,着色器向观察方向投射射线,当射线到达云层的位置时,会继续沿着射线方向继续前进,并判断是否命中云朵.

在动工之前我们先得把Minecraft的原版云干掉,干掉原版云其实并不难 - 只要你知道怎么弄的话...我很惊讶为什么那么多光影包在有自己的体积云的情况下还保留了原版云,难道就是因为Karyonix没有把判断云朵的关键部分写在文档里吗...

原版云的渲染其实分为两部分,一部分是视线在云层之下时,这时云是不透明的,而当视线到达云层或云层之上时云就会按透明的方式渲染.不过这对我们来说不重要,无论是哪种云都是在gbuffers_textured着色器中渲染的,然而除了云以外还有其他东西也使用gbuffers_textured,因此判断云的关键办法是通过顶点属性mc_Entity,之前我们在制作草木摆动效果时就用到了mc_Entity,文档只说了在渲染地形时mc_Entity.x用于表示砖块类型,实际上在gbuffers_textured中当mc_Entity.x等于-3.0时代表正在渲染云,只要在顶点着色器中将mc_Entity.x提取出来并传入片元着色器,然后在片元着色器中判断它是否<-2.5(如果是!=-3.0可能会有问题) 如果是的话就discard掉就行了.

首先在gbuffers_textured.vsh中加入:

attribute vec4 mc_Entity;
varying float entityType;
//...main函数 {
entityType = mc_Entity.x;
//...}

然后在gbuffers_textured.fsh中加入:

varying float entityType;
//...main函数 {
if(entityType < -2.5)
	discard;
//...}

现在再测试一下,游戏中的云已经消失了,然后我们就可以着手制作体积云,但在此之前先来演示一个概念上的Ray Marching原型,我们来制作一个在世界坐标的(-10, 150, -10)到(10, 160, 10)这一盒型区域内绘制云朵的函数,在composite.fsh中添加:

uniform vec3 cameraPosition;

#define CLOUD_MIN 150.0
#define CLOUD_MAX 160.0
vec3 cloudRayMarching(vec3 startPoint, vec3 direction, vec3 bgColor, float maxDis) {
	vec3 testPoint = startPoint;
	float sum = 0.0;
	direction *= 0.1;
	for(int i = 0; i < 256; i++)
	{
		testPoint += direction;
		if(testPoint.x < 10.0 && testPoint.x > -10.0 && 
		testPoint.z < 10.0 && testPoint.z > -10.0 &&
		testPoint.y > CLOUD_MIN && testPoint.y < CLOUD_MAX)
			sum += 0.01;
	}
	return bgColor + vec3(sum);
}

这个函数有4个参数,世界坐标系下的视点中心,观察方向,原像素颜色和未经规整化的最大距离(1单位长度等于游戏中的1格).
cameraPosition变量则是玩家在世界坐标系中的位置,可以用作表示视点中心.
然后我们还要解决怎么计算观察方向,对于延迟渲染来说这倒非常好解决,由于已经知道了像素所对应的坐标(我们在进行Shadow Mapping时已经计算过了),我们只要将它的坐标减去视点中心,再进行规整化后就能得到方向向量,如果这个坐标是在光照坐标系下(光照坐标系的原点就是视点中心),那就连减去视点中心的步骤都省了,在brightness变量的计算之前添加(也就是在计算泛光亮度的代码之前添加):

vec3 rayDir = normalize(gbufferModelViewInverse * viewPosition).xyz;
if(dist > 0.9999)
	dist = 100.0;
color.rgb = cloudRayMarching(cameraPosition, rayDir, color.rgb, dist * far);

dist是经过规整化后的距离,因此要再乘以最大视距来恢复到正常单位长度.游戏中的天空看起来离玩家很远,其实也还是受最大视距的限制,因此如果保留默认天空的距离的话,今后在实际制作体积云时会发现只有正上方能看见云,稍微远一点的就被裁减掉了...因此这里要手动将天空的距离调到尽可能大,比如100.现在在游戏里飞到(0,150,0)附近,然后重载光影包,就能看到效果了.

2015-12-19_16.56.22

我们确实在世界中心的上空渲染出了一个巨大的立方体,然而你会发现只有飞到距离它极近时才能看到它,这是因为现在的测试是直接从视角原点开始,即使我们的采样次数多达256次,但由于步长极短,大部分采样在测试点尚未到达云的位置时便停止了,显然单方面增加采样次数或加大步长都不能解决问题,因此这里要做一些优化和取舍.
由于云层的高度是已知的,我们可以先让测试点沿着射线方向移动到云层的高度再进行测试,对于不与云层相交的射线,则可以直接放弃测试.此外,对于超过最大测试距离(换句话说,对于那些看不见云层的点)的也是可以直接跳过的.
不过采用这种策略的话就无法正确处理当视角位于云层高度时的情况了,对此只能忍痛放弃云层以及云层以上的渲染,稍后我们会修改程序,让云层无论什么时候始终处于视角之上的高度.

现在将Ray Marhcing的代码改为:

vec3 cloudRayMarching(vec3 startPoint, vec3 direction, vec3 bgColor, float maxDis) {
	if(direction.y <= 0.1)
		return bgColor;
	vec3 testPoint = startPoint;
	float cloudMin = startPoint.y + CLOUD_MIN * (exp(-startPoint.y / CLOUD_MIN) + 0.001);
	float distanceFromCloudLayer = cloudMin - startPoint.y;
	float d = distanceFromCloudLayer / direction.y;
	testPoint += direction * d;
	if(distance(testPoint, startPoint) > maxDis)
		return bgColor;
	float sum = 0.0;
	float cloudMax = cloudMin + (CLOUD_MAX - CLOUD_MIN);
	direction *= 0.1;
	for(int i = 0; i < 256; i++)
	{
		testPoint += direction;
		if(testPoint.x < 10.0 && testPoint.x > -10.0 && 
		testPoint.z < 10.0 && testPoint.z > -10.0 &&
		testPoint.y > cloudMin && testPoint.y < cloudMax)
			sum += 0.01;
	}
	return bgColor + vec3(sum);
}

我们先来细数一下这段代码新增了什么和修改了什么,首先我们在开头加入了射线方向的判断,对于不是指向天空的射线直接放弃测试,为什么这里是<=0.1而不是<=0.0?一个原因是游戏中很少有云能延展到地平线的高度;另一个原因则是这是段dirty hack...这个问题大概是由太阳和月亮在渲染时的深度造成的,想解释详细原因和效果有些困难,直接上图吧: 2015-12-31_02.39.59(2)
还记得之前我们为什么将天空的距离改为100吗?这张图是在"dist=15,direction.y <= 0.0"时的截图,增大dist是为了增加云的可见范围;增大射线方向的剔除范围是为了避免在低处渲染云.两者结合便解决了上图的问题. 言归正题,之后我们重新计算了云的最低高度,这个公式的核心是exp(-startPoint.y / CLOUD_MIN),参考以e为底的指数函数的曲线,你会发现当x位于(-2, 0]时,函数是从1缓慢接近0,然而当x小于-2时,函数会无限接近但不等于0,这恰好满足我们的需要:在一定的高度内玩家能明显感觉到高度越高越接近云,但一旦超过一定范围后,玩家会发现自己只能无限接近云的底层,却永远够不到云.而那"+0.001",则是为了防止真有哪个蛋疼的家伙飞到几千格的高度,让exp(x)过于小从而导致CLOUD_MIN * exp(x) ≈ 0. 在确定了云的最低位置,并保证玩家的视角永远不会超过云层高度后,就可以计算怎样将测试点前移到云层的高度了.最后根据最大距离再做一次剔除,之前你可能已经发现在第三人称下无论在什么位置云都会遮掩住你的人物,这是因为之前我们没有根据最大距离做剔除,现在云在渲染时已经会正确处理深度关系了. (其实这里还有个小优化没有做,distance函数需要一次开平方操作,而它远比一次乘法操作要慢的多,如果将"distance(testPoint, startPoint) > maxDis"替换成"distance2(testPoint, startPoint) > maxDis * maxDis"(distance2是个虚构的函数,代表distance的平方,或者说没有经过开平方的distance)会更一步优化算法.)

2016-01-27_14.26.29

现在我们已经掌握了在一个确定的范围内渲染云的方式了,接下来要解决的是如何确定云的位置,这就涉及到了噪声问题.

==================================================
知识点:再论噪声

"对物理学家来说,凡是数值V随时间T的变动而产生不可预测的变化者,皆为噪声."
-Richard F. Voss, 《The Science of Fractal Images》

之前我们已经提到了噪声图的使用方法,但噪声图只能作为噪声源,很多情况下我们需要的是经过处理的噪声.这里我将噪声根据处理过程分为三个阶段/种类:噪声源、噪声函数、复合噪声函数.

噪声源:
噪声源负责产生一个"随时间T的变动而发生不可预测的变化"的数值,通常的手段是一个Hash函数;或者是实现通过(伪)随机数生成器事先制作一个噪声图,在使用时以查表的方式来获取噪声.

噪声函数:
一个无规律变化的噪声并不适合直接拿来使用,一个最致命的缺陷是:它是离散的,特别是通过随机数生成器产生的噪声图,因此便有了各种连续噪声函数.此外我们还希望噪声函数具有确定性,即如果向噪声函数中代入两个相同的参数值的话,得到的结果也是相同的.
连续噪声函数根据其生成手段分为两大类:基于点(Point based)和基于分段(Lattice based).这两种手段完全不同,相互之间没有可对比性.
基于点的噪声函数最常见的是沃利噪声,其原理是在空间中随机放置一定数量的固定点,每次计算噪声时都把参数值转换成空间中对应的一个采样点,然后计算采样点到最近的N个(N>=1)固定点的距离,根据距离信息来生成噪声,熟悉几何学的人可能会想到沃罗诺伊图,事实上如果将一个简单的沃利噪声的结果转换成颜色并绘制出来的话,基本上就是一副沃罗诺伊图...具体效果可以见这个:https://www.shadertoy.com/view/4tsXRH
然后便是最常用的基于分段的噪声了,基于分段的噪声的原理是对空间进行虚拟的分割,比如对一个2D噪声函数来说,它可以将空间分割成虚拟的网格,每当计算某个采样点的噪声值时,就取其所在网格的4个顶点所对应的噪声源的数值,然后根据采样点在网格中的位置,进行双线性插值,由此获得一个经过平滑的连续噪声值.
基于分段的噪声根据其使用的噪声源还分成两个小类:值噪声(Value nose)和梯度噪声(Gradient noise).值噪声是最简单的,在每个网格的顶点上它直接使用随机数,假如它的噪声源是一个噪声图,而每个顶点都是落在一个像素上的话...靠,这在OpenGL中不就等同于一个手工实现的双线性插值采样器吗...其实也并不完全是,它通常在计算采样点在网格中的位置时会进行一次埃尔米特插值,也就是所谓的平滑插值,这对噪声函数的输出影响不是很大,但对待会的复合噪声函数会有微妙的影响.效果可以见这里:https://www.shadertoy.com/view/lsf3WH
相比之下,梯度噪声就稍微复杂一点,它在每个点上使用的是一个随机的梯度向量,当计算噪声时,会在每个顶点计算顶点到采样点的距离向量与顶点的梯度向量的点乘,然后对这些结果进行双线性插值.最经典的柏林噪声就是梯度噪声,实例可以见这个:https://www.shadertoy.com/view/XdXGW8 (虽然作者强调梯度噪声不等于柏林噪声,柏林噪声是梯度噪声的一种形式,但这个着色器确实使用的是柏林噪声)
这两种噪声的差异还是蛮大的,但现在我们还无需关心,因为在待会我们所使用的复合噪声函数中,它们看起来都差不多...

复合噪声函数:
我们已经知道单一的一个噪声函数是什么样了,然而显然这东西怎么看也不像是自然界中云的样子,那么该怎么用它来表示一个云呢?在数学中一个正弦波怎么看也不像是锯齿波,但通过傅立叶变换,许多个不同频率不同振幅的正弦波叠加在一起便可以近似表示一个锯齿波.对于噪声信号而言这个方法也适用,我们可以通过对同一个噪声函数的不同频率不同振幅下的结果叠加来产生一个变化更复杂的噪声信号,那么这个方法的理论支持又是什么?
首先我们要先聊一个不相关的话题:概率论.说到这个蛋疼的学科,我至今还没忘记当初概率论考试擦线飘过的惊险...好吧歪楼了,概率论中有个被称为分数布朗运动(Fractional Brownian motion,亦被译为分形布朗运动,简称FBM)的东西,要想详细讨论这个东西的性质的话那实在是超过本文的范围和作者的能力了...要论用途的话,FBM可以很好地描述一些自然现象,比如地理与气象;还有一些其他的东西,比如金融,这也是为什么你在搜索引擎中搜索"分数布朗运动"会搜出一堆和股票相关的研究...但至少我们现在知道云的分布比较符合FBM了.
然而如果你搜一下FBM的公式的话,估计你的第一反应多半是"卧槽",这种公式显然要让我们实现一遍有些不太现实,不过好在FBM也有一种"近似",就是分数噪声(Fractal Noise),关于分数噪声和FBM的关系,互联网和书本上对它们的解释众说纷纭.Wiki的分数噪声页上只是含糊地提到"与FBM有密切的关系";在《图形着色器 - 理论与实践》一书中,作者直言不讳地表示FBM就是分数噪声;在Fractal Terrain Generation这个库的Wiki中,对FBM的定义和公式完全照搬分数噪声;而在Scratchapixel的"Noise Part 1"一课中,则称FBM只是计算机图形学家从数学中借来的术语,仅仅只是因为它和分数噪声相似.那么到底谁是正确的呢...我也不知道,我的看法是FBM和分数噪声确实是两个不同的东西,但由于两者的相似性,分数噪声被用来近似表示FBM,天长日久两者在图形学界也被混为一谈了.引用某篇Presentation的吐槽:"分数布朗运动与什么都有关,就是与运动无关;分数噪声与什么都有关,就是与噪声无关!"
在解决完"历史问题"后,我们就可以研究分数噪声该如何得到,获取一个分数噪声十分容易,对一个指定的噪声函数取样,每次取样时都让频率加倍,振幅减半,最后将取样结果相加,得到的就是一个分数噪声,在实际操作中,频率不一定是整加倍,可以是别的数;而为了让结果处于0~1的范围,振幅的减半衰减通常不会有改动.

最后五毛特效糊弄了一张复合噪声函数生成噪音的过程:
SistersNoise
==================================================

在磕磕绊绊地看完噪声的原理后,我们就可以着手开始制作一个云朵噪声发生器了,不过它需要能够支持3D噪声,之前我们看的都是2D噪声,这里我们可以使用一个现成的方案,iq(Shadertoy的站长,人类强者(半雾,有兴趣的话可以看一下他的blog的文章区:http://www.iquilezles.org/www/index.htm 这真是老司机啊...))制作了一个以噪声图为噪声源的3D值噪声函数(https://www.shadertoy.com/view/4sfGzS),之前我们看的那两个都是以Hash函数为噪声源,速度没有噪声图那么快.不过需要注意的是,这个着色器中他使用的是特制的噪声图,噪声图的ga分量等同于偏移(-37, -17)个像素的位置的rb分量,而rb分量则是随机数,据他说这样是为了降低纹理采样频率,一次取样当两次使,但由于我们在Shadersmod中没这好待遇,所以只有在代码中将纹理采样拆开了.

又到了喜闻乐见的写(copy)代码的时候了!在composite.fsh中添加(记得添加到cloudRayMarching的前面,并且强烈建议在CLOUD_MAX的后面,至少是这两个函数,noisetex和frameTimeCounter在哪无所谓):

uniform float frameTimeCounter;
uniform sampler2D noisetex;

float noise(vec3 x)
{
	vec3 p = floor(x);
	vec3 f = fract(x);
	f = smoothstep(0.0, 1.0, f);
	
	vec2 uv = (p.xy+vec2(37.0, 17.0)*p.z) + f.xy;
	float v1 = texture2D( noisetex, (uv)/256.0, -100.0 ).x;
	float v2 = texture2D( noisetex, (uv + vec2(37.0, 17.0))/256.0, -100.0 ).x;
	return mix(v1, v2, f.z);
}

float getCloudNoise(vec3 worldPos) {
	vec3 coord = worldPos;
	coord.x += frameTimeCounter * 5.0;
	coord *= 0.002;
	float n  = noise(coord) * 0.5;   coord *= 3.0;
		  n += noise(coord) * 0.25;  coord *= 3.01;
		  n += noise(coord) * 0.125; coord *= 3.02;
		  n += noise(coord) * 0.0625;
	return max(n - 0.5, 0.0) * (1.0 / (1.0 - 0.5));
}

这段代码由两部分组成,noise函数为噪音函数,其中floor是向下取整,fract为取小数部分,smoothstep是GLSL内置的埃尔米特插值,如果手动写出来的话就是"f = clamp((f - 0.0) / (1.0 - 0.0), 0.0, 1.0); f = f*f*(3.0-2.0*f);",之后的计算就是将整数部的z映射到二维的UV坐标上,然后取当前整数部对应的噪声,和当前整数部z+1时所对应的噪声,再根据小数部的z对两值进行线性插值,以得到连续的三维噪声.
而getCloudNoise有一半是对采样坐标的频率进行调整,修改0.002可以改变云的平均体积,值越小单朵云体积越大但视野内云的数量越少;return中的是渲染云的最小阈值,为了之后计算方便,这里对它们进行了规整化,即输出范围重新映射到了[0.0~1.0) 要修改的话别忘了同时修改两个0.5.暂且我们先不管它俩,毕竟现在我们连云是什么样都还没见到呢...这个函数的其余部分便是一个复合噪声函数,采用的是简单的4次分数噪声,值得注意的是这里没有使用整数次倍频程(n次倍频程=频率x2n) 而是使用的频率x3,这样可以丰富云的细节,在完成本章后,你可以尝试一下频率x2的效果,云底看上去会过于平滑.此外你还可以尝试5次分数噪音,看看细节过多时的效果.至于为什么频率加倍时没有采用整倍数...因为据说在小数位添加少许抖动有助于清除锯齿和瑕疵...反正我是没观察出有什么明显改进,但还是保留下来了.

现在离生成云只差一步在,将cloudRayMarching中的:

if(testPoint.x < 10.0 && testPoint.x > -10.0 && 
	testPoint.z < 10.0 && testPoint.z > -10.0 &&
	testPoint.y > cloudMin && testPoint.y < cloudMax)
		sum += 0.01;

修改为:

if(testPoint.y > cloudMin && testPoint.y < cloudMax)
	sum += getCloudNoise(vec3(testPoint.x, testPoint.y - cloudMin, testPoint.z)) * 0.01;

由于我们的云的高度不是固定的,因此这里要重新计算取样点的高度.现在可以进游戏测试一下了.

2016-01-29_01.37.49

然而我们看到的却是如精斑一样散乱分布的,亮瞎人眼的东西,什么鬼啊!不过不用急着关游戏(但我强烈建议你赶快把光影包换回(none),因此RayMarching是很吃GPU的) 毕竟当初ShadowMapping那么不堪入目我们不也挺过来了...

首先我们先来修正两个小问题:

  • 有些卡/本子有些烫/显卡风扇在读条了: 256次采样其实是没必要的;对于云这样距离远且外形模糊的东西来说,每次0.1个单位长度的步长也是过于奢侈了.由于我们的体积云只支持从云层下方观察,因此可以采用"伪步进"方案,将云视为一层层离散的云片,而不是一个连续的云团,每次步进时不是前进固定的距离,而是直接进入下一层,虽然听上去可能会损失不少细节,但实际测试时效果还是可接受的,而且作为补偿还科技增加云层的厚度来让云显得更有立体感.将"direction *= 0.1;"改成"direction *= 1.0 / direction.y;",将"for(int i = 0; i < 256; i++)"改成"for(int i = 0; i < 32; i++)"
  • 云的高度过低,云层过薄: 将CLOUD_MIN和CLOUD_MAX改成400.0和430.0

然后我们可以开始着手处理云的渲染质量问题了,现在的问题在于我们没有对云做光照处理.所以接下来要制作云的光照函数.
对于写实渲染(Photorealistic rendering)的手段有两类,一类是按照经验公式,另一类是基于物理.前者就是将日常生活中所见的场景拟合出一条经验公式,然后选择合适的参数进行渲染,现实中的积云底层比顶层要暗,我们就在渲染底层时将它涂暗一些,黄昏时云朵朝向落日的一面会被染红,我们就在渲染黄昏的景色时手工调整云的颜色,只要让它们"看起来像真的一样"就好.而后者的渲染则是从原理出发,根据实际的物理公式来计算渲染结果,在日光穿透云朵到达人眼之前,大部分光会被气溶胶吸收或者由于散射而损失掉,但亦会有一部分来自其他光束的散射光"加入"这条光束,采用基于物理的渲染就要计算这些因素而对出射光线造成的影响.如果你对基于物理的方法感兴趣的话,可以参考一下这篇文章http://wiki.nuaj.net/index.php?title=Clouds,Shadertoy上也有现成的实现(第二版https://www.shadertoy.com/view/ldlXzM 第三版https://www.shadertoy.com/view/lssXzM 两版本方法均相同,仅仅只是参数有变化,说实话我觉得他的第二版比第三版要好...) 这里我使用的是iq写的基于经验的云光照,原始版本为https://www.shadertoy.com/view/XslGRr

在开始写(chao)代码之前,我们先要讨论一下光照函数的组成,从直觉来讲似乎只要1个控制参数就够了:该位置相对于光源的厚度,假如知道这个参数的话,只需要增大参数小(向光面)的地方的亮度,减小参数大(背光面)的地方的亮度就行了,但实际上却不可行,不只是因为想获取这个参数有些难,还有个问题是这个算法将云看成了一个有明确边界的固体,而事实上云是由许多不均匀分布的点组成,在边缘很难找到明确的分界.
所以这里我们会采用两个参数,密度和光照,光照函数会在光照函数的所有采样点中逐点统计该点的云密度以及光照强度,最后将它们对应的颜色混合到一起,没错,就是照搬iq的方法... ?
然而还有两个问题,一个是如何判断光照,另一个是如何混合.判断光照最好的办法是在采样点的位置立刻向光源方向再做一组Ray Marching,判断自己到光源之间还隔了多少云,但这种方法有些麻烦,而且是O(NM)的复杂度,因此这里采用的方法是从采样点向光源方向偏移一定距离,然后再次根据复合噪音函数计算一次云密度,根据两点的密度之差来估算收到的光照影响(其实这个方法不是很准确,尽管一点密度高另一点密度低的话,可能是前一点在云内后一点在云外,但也有可能前一点在厚的地方,后一点在薄的地方.正确的估算方法应该采用累计云厚度,不过现在这个效果看起来也不是太差,而且也很简单?) 这个方法的复杂度只有O(N).第二个问题则是如何混合,正确的混合姿势应该是从后往前混合,即先绘制不透明的背景,然后从远往近依次混合透明物体,然而由于我们采用的是由近到远进行Ray Marching,固只能凑合使用从前往后的混合.(相比之下,那片基于物理的渲染的文章中作者就说"从后♂面的方法更符合直觉" 虽然他指的是Ray Marching从远往近)

首先先来加入光照函数,在composite.fsh中添加:

vec4 cloudLighting(vec4 sum, float density, float diff) {  
	vec4 color = vec4(mix(vec3(1.0,0.95,0.9), vec3(0.3,0.315,0.325), density ), density );
	vec3 lighting = mix(vec3(0.7,0.75,0.8), vec3(1.8, 1.6, 1.35), diff);
	color.xyz *= lighting;
	color.a *= 0.4;
	color.rgb *= color.a;
	return sum + color*(1.0-sum.a);
}

3个参数分别是累积颜色、密度和两点间密度的差值.变量color就是由密度控制的基色,而lighting则是由密度间接推出的光照,不用太在意那4个vec3,这些都是经验公式中的魔数...函数的后半部分都是在将现颜色混合入积累的颜色.

之后要小改一下getCloudNoise,我们要让它在超出云层的范围过渡到0,否则通过判断两点间密度来确定光照的方法将会失效,在"vec3 coord = worldPos;"的下方加入:

float v = 1.0;
if(coord.y < CLOUD_MIN)
{
	v = 1.0 - smoothstep(0.0, 1.0, min(CLOUD_MIN - coord.y, 1.0));
}
else if(coord.y > CLOUD_MAX)
{
	v = 1.0 - smoothstep(0.0, 1.0, min(coord.y - CLOUD_MAX, 1.0));
}

然后在函数返回值之前加入:

n *= v;

然后还要添加光源方向的计算,现在的lightPosition是在眼坐标系下,我们需要一个在世界坐标系中的方向,而且由于lightPosition会在日出和黄昏时在太阳和月亮之间直接切换,云层光照又不像阴影那样可以通过一个淡入淡出来掩饰住这个变化,因此待会我们将只使用太阳的位置来计算光源方向.

在composite.vsh("我们也很久没有看见新鲜的顶点着色器了!" 提问,这是哪个红字本里的谁的台词?)中加入:

uniform mat4 gbufferModelViewInverse;
varying vec3 worldSunPosition;

//...main函数 {
worldSunPosition = normalize((gbufferModelViewInverse * vec4(sunPosition, 0.0)).xyz);
//... }

gbufferModelViewInverse是将点变换到世界坐标系中然后平移-cameraPosition个距离,然而由于"vec4(sunPosition, 0.0)"不是点而是个向量,因此不受平移影响,这样我们就得到了一个新鲜的世界坐标系下的光原向量了.

最后就该魔改RayMarching了,将RayMarching替换为 (还有顺手添加上worldSunPosition):

varying vec3 worldSunPosition;

vec3 cloudRayMarching(vec3 startPoint, vec3 direction, vec3 bgColor, float maxDis) {
	if(direction.y <= 0.1)
		return bgColor;
	vec3 testPoint = startPoint;
	float cloudMin = startPoint.y + CLOUD_MIN * (exp(-startPoint.y / CLOUD_MIN) + 0.001);
	float d = (cloudMin - startPoint.y) / direction.y;
	testPoint += direction * d;
	if(distance(testPoint, startPoint) > maxDis)
		return bgColor;
	float cloudMax = cloudMin + (CLOUD_MAX - CLOUD_MIN);
	direction *= 1.0 / direction.y;
	vec4 final = vec4(0.0);
	float fadeout = (1.0 - clamp(length(testPoint) / (far * 100.0) * 6.0, 0.0, 1.0));
	for(int i = 0; i < 32; i++)
	{
		if(final.a > 0.99 || testPoint.y > cloudMax)
			continue;
		testPoint += direction;
		vec3 samplePoint = vec3(testPoint.x, testPoint.y - cloudMin + CLOUD_MIN, testPoint.z);
		float density = getCloudNoise(samplePoint) * fadeout;
		if(density > 0.0)
		{
			float diff = clamp((density - getCloudNoise(samplePoint + worldSunPosition * 10.0)) * 10.0, 0.0, 1.0 );
			final = cloudLighting(final, density, diff);
		}
	}
	final = clamp(final, 0.0, 1.0);
	return bgColor * (1.0 - final.a) + final.rgb;
}

先来钦点...不,清点一下新增和删去了哪些内容,首先我们删掉了sum,取而代之的是final,它代表最终累积到的云颜色.然后我还悄悄地加了一个福利 - 用于淡出远处云朵的变量fadeout,它的计算公式中有2个关键参数,"far*100"是最远视距,出自之前的"dist = 100; dist * far";而那个6.0则是控制淡出范围的魔数,这个数值刚好是天空和雾的边缘.之后在计算采样点samplePoint的时候我改了一下y坐标的公式,否则之前在getCloudNoise的修改就会有问题.之后就是根据采样点获取云密度,如果该位置有云的话,就根据采样点和世界坐标系下的光源方向计算出偏移点,再计算偏移点的云密度与采样点的云密度之差.获得所有信息后,就可以计算此点的光照了.

现在可以着手测试了,把游戏时间调到白天,然后重载光影包.

2016-01-30_04.18.47

现在的效果看起来还不错,你可以尝试"/time set day" "/time set 6000" "/time set 11000",来观察光照对云的影响.觉得云不够厚,缺乏立体感?尝试修改getCloudNoise的返回值,将它的返回值改成:

return smoothstep(0.0, 1.0, pow(max(n - 0.5, 0.0) * (1.0 / (1.0 - 0.5)), 0.5));

这个的原理利用0<a<1的幂函数在x位于0~1时会增大中间值的特点,增加云的平均厚度,最后还顺手来了一个平滑插值,如果嫌碍事的话可以去掉.通过控制最后一个0.5可以改变厚度,不过如果过厚的话会让云显得跟一张饼似的...

2016-01-30_04.39.34

现在只剩一个问题需要解决了:不同时间下的光照颜色,现在整个光照都是由4个颜色控制:低密度的基色、高密度的基色、无光照的颜色系数和有光照的颜色系数,只要在顶点着色器中根据时间计算这4种颜色并实现平滑过渡就可以了.
理论上讲只需要将时间分为4片:白天、黄昏、夜间和黎明,但在我的实现中我将时间分为2x5个点,一套时间点包括开始、中点1、中点2、中点3、结束,黄昏和黎明时各有一套,对于黄昏,当游戏时间在"开始"和"中点1"之间时,游戏从昼间颜色过渡到黄昏的颜色,在"中点1"到"中点2"之间,颜色保持在黄昏时,在"中点2"到"中点3"时,颜色会过渡到无月光时的夜间颜色,在"中点3"到"结束"时过渡到正常的夜间颜色,之所以要加一个无月光的夜间颜色是和我挑选的颜色参数有关,如果你设计出更好的颜色的话,可能完全不需要中点3的无月光颜色过渡.黎明和黄昏差不多,只不过顺序颠倒了一下,先是过渡到无月光,然后过渡到黎明,停留一段时间后最终过渡到昼间颜色.

varying vec3 cloudBase1;
varying vec3 cloudBase2;
varying vec3 cloudLight1;
varying vec3 cloudLight2;

#define SUNSET_START 11500.0
#define SUNSET_MID1 12300.0
#define SUNSET_MID2 13600.0
#define SUNSET_MID3 14200.0
#define SUNSET_END 14500.0
#define SUNRISE_START 21000.0
#define SUNRISE_MID1 22000.0
#define SUNRISE_MID2 22500.0
#define SUNRISE_MID3 23500.0
#define SUNRISE_END 24000.0

const vec3 BASE1_DAY = vec3(1.0,0.95,0.9), BASE2_DAY = vec3(0.3,0.315,0.325);
const vec3 LIGHTING1_DAY = vec3(0.7,0.75,0.8), LIGHTING2_DAY = vec3(1.8, 1.6, 1.35);

const vec3 BASE1_SUNSET = vec3(0.6,0.6,0.72), BASE2_SUNSET = vec3(0.1,0.1,0.1);
const vec3 LIGHTING1_SUNSET = vec3(0.63,0.686,0.735), LIGHTING2_SUNSET = vec3(1.2, 0.84, 0.72);

const vec3 BASE1_NIGHT_NOMOON = vec3(0.27,0.27,0.324), BASE2_NIGHT_NOMOON = vec3(0.05,0.05,0.1);
const vec3 LIGHTING1_NIGHT_NOMOON = vec3(1.5,1.5,1.5), LIGHTING2_NIGHT_NOMOON = vec3(0.8,0.8,0.9);

const vec3 BASE1_NIGHT = vec3(0.075,0.075,0.09), BASE2_NIGHT = vec3(0.05,0.05,0.1);
const vec3 LIGHTING1_NIGHT = vec3(6.0,6.0,6.3), LIGHTING2_NIGHT = vec3(1.0,1.0,1.0);

//...main函数 {
float fTime = float(worldTime);
if(fTime > SUNSET_START && fTime <= SUNSET_MID1)
{
	float n = smoothstep(SUNSET_START, SUNSET_MID1, fTime);
	cloudBase1 = mix(BASE1_DAY, BASE1_SUNSET, n);
	cloudBase2 = mix(BASE2_DAY, BASE2_SUNSET, n);
	cloudLight1 = mix(LIGHTING1_DAY, LIGHTING1_SUNSET, n);
	cloudLight2 = mix(LIGHTING2_DAY, LIGHTING2_SUNSET, n);
}
else if(fTime > SUNSET_MID1 && fTime <= SUNSET_MID2)
{
	cloudBase1 = BASE1_SUNSET;
	cloudBase2 = BASE2_SUNSET;
	cloudLight1 = LIGHTING1_SUNSET;
	cloudLight2 = LIGHTING2_SUNSET;
}
else if(fTime > SUNSET_MID2 && fTime <= SUNSET_MID3)
{
	float n = smoothstep(SUNSET_MID2, SUNSET_MID3, fTime);
	cloudBase1 = mix(BASE1_SUNSET, BASE1_NIGHT_NOMOON, n);
	cloudBase2 = mix(BASE2_SUNSET, BASE2_NIGHT_NOMOON, n);
	cloudLight1 = mix(LIGHTING1_SUNSET, LIGHTING1_NIGHT_NOMOON, n);
	cloudLight2 = mix(LIGHTING2_SUNSET, LIGHTING2_NIGHT_NOMOON, n);
}
else if(fTime > SUNSET_MID3 && fTime <= SUNSET_END)
{
	float n = smoothstep(SUNSET_MID3, SUNSET_END, fTime);
	cloudBase1 = mix(BASE1_NIGHT_NOMOON, BASE1_NIGHT, n);
	cloudBase2 = mix(BASE2_NIGHT_NOMOON, BASE2_NIGHT, n);
	cloudLight1 = mix(LIGHTING1_NIGHT_NOMOON, LIGHTING1_NIGHT, n);
	cloudLight2 = mix(LIGHTING2_NIGHT_NOMOON, LIGHTING2_NIGHT, n);
}
else if(fTime > SUNSET_END && fTime <= SUNRISE_START)
{
	cloudBase1 = BASE1_NIGHT;
	cloudBase2 = BASE2_NIGHT;
	cloudLight1 = LIGHTING1_NIGHT;
	cloudLight2 = LIGHTING2_NIGHT;
}
else if(fTime > SUNRISE_START && fTime <= SUNRISE_MID1)
{
	float n = smoothstep(SUNRISE_START, SUNRISE_MID1, fTime);
	cloudBase1 = mix(BASE1_NIGHT, BASE1_NIGHT_NOMOON, n);
	cloudBase2 = mix(BASE2_NIGHT, BASE2_NIGHT_NOMOON, n);
	cloudLight1 = mix(LIGHTING1_NIGHT, LIGHTING1_NIGHT_NOMOON, n);
	cloudLight2 = mix(LIGHTING2_NIGHT, LIGHTING2_NIGHT_NOMOON, n);
}
else if(fTime > SUNRISE_MID1 && fTime <= SUNRISE_MID2)
{
	float n = smoothstep(SUNRISE_MID1, SUNRISE_MID2, fTime);
	cloudBase1 = mix(BASE1_NIGHT_NOMOON, BASE1_SUNSET, n);
	cloudBase2 = mix(BASE2_NIGHT_NOMOON, BASE2_SUNSET, n);
	cloudLight1 = mix(LIGHTING1_NIGHT_NOMOON, LIGHTING1_SUNSET, n);
	cloudLight2 = mix(LIGHTING2_NIGHT_NOMOON, LIGHTING2_SUNSET, n);
}
else if(fTime > SUNRISE_MID2 && fTime <= SUNRISE_MID3)
{
	cloudBase1 = BASE1_SUNSET;
	cloudBase2 = BASE2_SUNSET;
	cloudLight1 = LIGHTING1_SUNSET;
	cloudLight2 = LIGHTING2_SUNSET;
}
else if(fTime > SUNRISE_MID3 && fTime <= SUNRISE_END)
{
	float n = smoothstep(SUNRISE_MID3, SUNRISE_END, fTime);
	cloudBase1 = mix(BASE1_SUNSET, BASE1_DAY, n);
	cloudBase2 = mix(BASE2_SUNSET, BASE2_DAY, n);
	cloudLight1 = mix(LIGHTING1_SUNSET, LIGHTING1_DAY, n);
	cloudLight2 = mix(LIGHTING2_SUNSET, LIGHTING2_DAY, n);
}
else
{
	cloudBase1 = BASE1_DAY;
	cloudBase2 = BASE2_DAY;
	cloudLight1 = LIGHTING1_DAY;
	cloudLight2 = LIGHTING2_DAY;
}
//... }

这段代码虽然看着很多,但没有什么可说明的...唯一需要提醒的两点是黄昏和黎明用的都是一套颜色(XXX_SUNSET),以及针对夜间的光照系数,由于我们总是使用太阳的位置作为光照方向,而MC中月亮和太阳的位置总是相对的,所以在夜间光照时cloudLight1是作为有光照的系数(本来应该是无光照),而cloudLight2作为无光照的系数,我知道这个设计确实有缺陷,因为在通过密度来判断光照时不能简单地把有光区和无光区做颠倒,因为有些区域本该是无论光源在哪个方向都是照不到光的(比如云的中心),但现在的方法看上去并不是那么糟糕 ☺ 所以我就继续这么做了,如果你实在不喜欢这样的话,可以试试修改无月光时的颜色和光照,将它们改成和光照强度无关,然后在利用无月光时的过渡时改变worldSunPosition的光源.

最后别忘了把cloudLighting中的颜色计算中的4个魔数改成:

//别忘了varying vec3 cloudBase1什么的...
vec4 color = vec4(mix(cloudBase1, cloudBase2, density ), density );
vec3 lighting = mix(cloudLight1, cloudLight2, diff);

现在我们的云已经基本能看了,诚然它还有很多不完善之处并且有可改进空间(毫无疑问这是句客套话,意思是"我知道我做出来的是坨shit但我TM就不干了"),但是针对云的教程就到这里了...我承认这章并不是很完善,但至少我们有了一个能看的云(我认为如果优化一下参数的话可以超越Chocapic13系的云),以及简单学习了一下RayMarching的方法,不过不要高兴的太早,待会我们还要继续跟光线打交道 ?

2016-01-30_15.09.56

对了,如果你的云过于薄的话,你会发现当你站在一个云的下方并向正上方观察时,会出现明显的莫列纹(也就是一圈一圈的花纹),并且会随着你的上下移动而闪烁,正所谓十个莫列纹九个跟浮点精度有关,现在遇到的情况也不例外,它的原因是因为我们的采样点是紧压着CLOUD_MIN出发,而且由于每次步进是步进一整层,最后一次测试又会紧压着CLOUD_MAX,简直就是在刁难FPU啊...解决它的办法不难,给"testPoint += direction * d;"稍微加个小小的偏移就行,比如:

testPoint += direction * (d + 0.01);

最后再讨论一下我们的体积云的改进空间:

  • 天气:我们还没有实现雨天时阴天的效果,要实现这个效果至少需要修改两处,一个是根据天气修改光照系数,一个是根据天气修改getCloudNoise
  • 云阴影:忘了Shadersmod中的那个逗比的云阴影吧,那个只支持原版云,而且没有几个光影包认真实现了它,而且Shadersmod本来对云的处理就有问题...如果要实现云阴影的话,就在阴影测试点对着光源的方向做一次RayMarching看看有没有遇到云,有的话就根据厚度来判断阴影的程度.
  • 真.体积云:我承认我们现在的体积云应该算伪体积云,它几乎就是几十层云纹理叠在一起组成的,然而好像还没有几个光影包实现了真体积云,据我所知SEUS10.2实现了,而且真的很不错(虽然被奇怪的伪影肛的生不如死..."这种打黑枪的快感,又是什么感情呢?" 提问,这是出自哪个红字本的谁的话?) 但是为了效率他采用的1/4分辨率计算然后上采样到屏幕尺寸...而且你猜猜他的RayMarching需要多少步? A.800略小 B.正好800 C.800略多 真·是·悲·伤·呢
  • 光线直穿过云时的亮光:就是当太阳位于云后方时云被照亮的效果,特别是黄昏时尤其明显,这个我还真没仔细想过怎么弄,也许可以根据投影出的射线与光源方向的夹角来增加此处云的亮度?
  • 光环:如果你的体积云不打算支持视角位于云上方的话就可以跳过这个了...根据相函数,光在射入气溶胶时会有一部分光以接近180°的角度"反射"回光源,结果就是当光源直视云朵时,会看到一个彩色的光环,由于并非每个人都像JC那样拥有"My vision is augmented"的发光狗眼,所以平时难以见到这种奇景,不过在乘飞机时,在飞过云层上空时如果观察飞机在云上的影子可以看见围绕影子的光环.
  • 粒子效果遮掩:根据深度缓冲来判断是否渲染云存在一个问题,就是游戏中的粒子效果(包括雨和雪)都没有深度缓冲信息(尽管在渲染时有深度测试,我估计它们是关闭了深度缓冲写入),这导致在渲染时它们会被云遮住...想纠正这个问题我估计得修改gbuffers_skybasic,让它在渲染天空时输出一个特殊值,在渲染其它物体时还要输出个正常值将它抵消掉,只有拥有特殊值的像素才会进行云渲染.

该部分的最终成品可以从这里下载: [SkyDrive] [百度网盘]

实现光线追踪 - 屏幕空间反射

欢迎离开与再一次来到波与粒的境界!也许你还在为RayMarching而吐血(只要你刚才不是一路复制粘贴,而是完整读下来的话),但遗憾的是,我们现在又要和光打交道了!这一次我们来制作一个这个星球和它的卫星上最糟糕的水面反射,没有之一.

镜面反射在图形学上已经有一段历史,说到反射,即使是外行也能想到的最简单的方式是以镜面中心为原点,朝向反射光方向渲染一幅图像,然后再贴回镜面上,事实上过去就是这么干,现在也是这么干,变化的只是手段,早期的方法是利用模板缓冲,在正常绘制时如果遇到镜面就在模板缓冲中加入标记,然后在渲染镜像画面时打开模板测试,这样镜像画面只会被绘制在镜面上,它的缺点是每绘制一个镜面就要进行一遍渲染,而且只能用于渲染平面.

whatashame
图:2000年的DeusEx,图片源自Steam社区. 说起来一代厨嘲笑三代的一个把柄就是2011年推出的三代完全没有任何反射,而且为了掩盖这个问题,游戏中所有的镜子不是被砸了就是被贴上了广告...

后来又发展出了环境立方图(Environment Cube-map),最初的环境立方图只能用于反射静态物体,原理是在游戏开发时制图师在游戏地图中挑选合适的位置放入光探针(Light Probe,亦被称为Environment Probe,可能是为了区别功能,防止与用于生成球谐函数的Light Probe混淆),在地图烘焙阶段每个光探针都会生成一幅静态场景的全景照,并存入环境立方图,在游戏中当需要绘制反射时就挑选距离镜面最近的环境立方图并根据反射光线从中取样,这种方法速度快并且可以处理任意曲面任意角度的反射,缺点是只能反射静态物体,而且如果镜面是可移动的话就会面临在多个环境立方图之间切换的问题.

csscubemap
图:2004年的CS:Source,图片源自网络. 注意AWP瞄准镜上的反射,只有地图上具有环境立方图的地图才会有反射(比如沙1,吊桥),如果没有的话(比如沙2)就只会是雾蒙蒙的一片,之前我还在想V社是采用了什么黑科技能在静态的立方图之间平滑过渡,后来才发现是不过渡,超过了范围直接瞬间切换到更近的立方图...看来他们认为专心玩游戏的人没心思观察瞄准镜,专心看瞄准镜的傻子不会满处跑,事实看来确实如此.

后来随着显卡机能的发展以及帧缓冲和RenderToTexture的出现,环境立方图也可以实时渲染了,实时渲染的环境立方图可以包含动态实体,以此可以实现真正意义上的真实反射,然而这东西渲染起来略慢,毕竟是张全景图,因此人们还在寻找有没有无需多遍渲染,能在一次pass中直接完成的反射.2011年Crytek的员工在关于CryENGINE3的演讲中介绍了它的实时局部反射(Real Time Local Reflections)功能,这个功能后来被称为屏幕空间反射(Screen Space Reflection,简称SSR).

SSR的原理简单可分为如下4步.

  • 根据视点到镜面像素的射线,和镜面的法线,计算出反射光线.
  • 以像素对应的位置为起点,以反射光线为方向,进行Ray Tracing.
  • 在Ray Tracing中找到第一个命中的像素.
  • 取此像素对应的颜色作为反射颜色,如没有命中则返回一个预先指定的颜色.

那么,Ray Tracing又是什么鬼呢...?

==================================================
知识点:Ray Tracing

Ray Tracing(光线追踪)几乎就是Ray Marching的邪恶的孪生兄弟,很多时候它们它们采用的手段是相同的,有时也会被混淆在一起,真要严格区分的话,Ray Tracing的目的是沿射线去追踪查找第一个命中的物体,而Ray Marching是沿射线一路向前沿途收集信息,很多时候它们采用的都是逐步前进采样的方法.Ray Tracing可以被用于计算反射和阴影,绘制固体,以及计算全局光照实现照片级的渲染.
==================================================

SSR的原理基本就是这样,关键在于如何判断命中像素,这里使用的方法是根据深度值来判断,在一次判断中,先计算测试点的深度,这个深度称之为测试深度,然后将测试点换算到屏幕坐标系中得到一个采样点,在深度缓冲中根据采样点获得一个深度值,这个深度值称为采样深度,通过对比测试深度和采样深度,可以判断出测试点是在它在屏幕中对应的像素的前面还是后面,如果它是在前面,说明尚未命中像素,如果在后面,则代表它命中了这个像素.(当然实际操作并没有这么简单,待会会详细说明.)

ssrp1
图:基于深度值的射线追踪概念演示.红色为测试点,蓝色为采样点.

接下里我们就来实现一个在眼坐标系中进行Ray Tracing的"简单"的SSR,首先要解决的问题是如何判定水面,半透明物体绘制有一个专门的G-Buffer着色器gbuffers_water,现在先创建gbuffers_water.vsh和gbuffers_water.fsh,然后为它们添加内容.

在gbuffers_water.vsh中添加:

#version 120

attribute vec4 mc_Entity;

varying vec4 color;
varying vec4 texcoord;
varying vec4 lmcoord;
varying vec2 normal;
varying float attr;

vec2 normalEncode(vec3 n) {
    vec2 enc = normalize(n.xy) * (sqrt(-n.z*0.5+0.5));
    enc = enc*0.5+0.5;
    return enc;
}

void main()
{
	vec4 position = gl_ModelViewMatrix * gl_Vertex;
	float blockId = mc_Entity.x;
	if(mc_Entity.x == 8 || mc_Entity.x == 9)
		attr = 1.0 / 255.0;
	else
		attr = 0.0;
	gl_Position = gl_ProjectionMatrix * position;
	gl_FogFragCoord = length(position.xyz);
	color = gl_Color;
	texcoord = gl_TextureMatrix[0] * gl_MultiTexCoord0;
	lmcoord = gl_TextureMatrix[1] * gl_MultiTexCoord1;
	normal = normalEncode(gl_NormalMatrix * gl_Normal);
}

在gbuffers_water.fsh中添加:

#version 120

uniform int fogMode;
uniform sampler2D texture;
uniform sampler2D lightmap;

varying vec4 color;
varying vec4 texcoord;
varying vec4 lmcoord;
varying vec2 normal;
varying float attr;
/* DRAWBUFFERS:024 */

void main() {
	gl_FragData[0] = texture2D(texture, texcoord.st) * texture2D(lightmap, lmcoord.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));
	gl_FragData[1] = vec4(normal, 0.0, 1.0);
	gl_FragData[2] = vec4(attr, 0.0, 0.0, 1.0);
}

由于gbuffers_water除了渲染水之外还要渲染其它半透明的物体,比如彩色玻璃和冰,理论上说那些东西也是能有镜面反射的,但这里我们只是先制作一个水面反射效果,因此要做一次筛选,流动水和静态水(这里我记不清了...貌似有一个still water是不会流动的水)的id是8和9,我们还需要一个用于标记水面的属性,这里我启用了第五个颜色缓冲,也就是colortex4,接下来要在final.fsh中添加它的声明.

const int R8 = 0;
const int colortex4Format = R8;

由于只用到了红色分量,故此使用R8格式即可,一个byte可以存储256个数值,由于每次渲染前Shadersmod会将colortex4清空为全黑(0.0),故此我们使用0标记普通砖块,使用1/255来标记水.

然后在编写SSR的代码之前,先优化一下final.fsh的结构,在旧的代码中高光和基色的叠加与Tonemap都被写在了一块,现在将它们分离开能使代码更加美观:

vec3 uncharted2Tonemap(vec3 x) {
	return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F))-E/F;
}

vec3 bloom(vec3 color, vec2 uv) {
	return color + texture2D(colortex1, uv).rgb;
}

vec3 tonemapping(vec3 color) {
	color = pow(color, vec3(1.4));
	color *= 6.0;
	vec3 curr = uncharted2Tonemap(color);
	vec3 whiteScale = 1.0f/uncharted2Tonemap(vec3(W));
	color = curr*whiteScale;
	return color;
}

void main() {
	vec3 color =  texture2D(gcolor, texcoord.st).rgb;
	color = bloom(color, texcoord.st);
	color = tonemapping(color);
	gl_FragColor = vec4(color, 1.0);
}

在正式动工前,先来验证一下之前做的水面属性标记有没有效果:

uniform mat4 gbufferProjectionInverse;
uniform sampler2D colortex4;
uniform sampler2D depthtex0;

vec3 waterEffect(vec3 color, vec2 uv, vec3 viewPos, float attr) {
	if(attr == 1.0)
	{
		return vec3(0.0);
	}
	return color;
}

//...main函数
vec3 attrs =  texture2D(colortex4, texcoord.st).rgb;
float depth = texture2D(depthtex0, texcoord.st).r;
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;
float attr = attrs.r * 255.0;
//bloom的后面
color = waterEffect(color, texcoord.st, viewPosition.xyz, attr);
//...}

waterEffect函数之后将用于绘制水面效果,现在重载光影包,如果没问题的话,所有水面将变成纯黑色,而其他景物,包括半透明的砖块,都不会受影响.
(关于为什么把眼坐标系的坐标计算放在main函数中,是因为在之后章节的效果中需要复用这一变量)

2016-02-09_17.08.32

然后要在waterEffect中计算眼坐标系中的反射光线,现在已经有了眼坐标系位置viewPos,由于眼坐标系中的原点就在镜头位置,所以到某点的视线就等于该点坐标,故此只要再从gnormal中获取眼坐标系中的法线,然后根据反射公式就能计算出反射光线了,GLSL已经将反射公式计算封装成了reflect函数.

uniform sampler2D gnormal;

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);
}

vec3 waterRayTarcing(vec3 startPoint, vec3 direction, vec3 color) {
	return direction;
}

vec3 waterEffect(vec3 color, vec2 uv, vec3 viewPos, float attr) {
	if(attr == 1.0)
	{
		vec3 normal = normalDecode(texture2D(gnormal, texcoord.st).rg);
		vec3 viewRefRay = reflect(normalize(viewPos), normal);
		color = waterRayTarcing(viewPos, viewRefRay, color);
	}
	return color;
}

这一次我们创建了waterRayTarcing函数,在这里面执行真正的Ray Tracing,当然现在里面还没有内容,它所做的仅仅只是将射线方向作为颜色返回.如果没问题的话,重载光影包后你将看到的是一个"五颜六色"的海.接下来就该制作一个"真正意义"上的SSR:

uniform float near;
uniform float far;
uniform mat4 gbufferProjection;

vec2 getScreenCoordByViewCoord(vec3 viewCoord) {
	vec4 p = vec4(viewCoord, 1.0);
	p = gbufferProjection * p;
	p /= p.w;
	if(p.z < -1 || p.z > 1)
		return vec2(-1.0);
	p = p * 0.5f + 0.5f;
	return p.st;
}

float linearizeDepth(float depth) {
    return (2.0 * near) / (far + near - depth * (far - near));
}

float getLinearDepthOfViewCoord(vec3 viewCoord) {
	vec4 p = vec4(viewCoord, 1.0);
	p = gbufferProjection * p;
	p /= p.w;
	return linearizeDepth(p.z * 0.5 + 0.5);
}

vec3 waterRayTarcing(vec3 startPoint, vec3 direction, vec3 color) {
	const float stepBase = 0.05;
	vec3 testPoint = startPoint;
	direction *= stepBase;
	bool hit = false;
	vec4 hitColor = vec4(0.0);
	for(int i = 0; i < 40; i++)
	{
		testPoint += direction;
		vec2 uv = getScreenCoordByViewCoord(testPoint);
		float sampleDepth = texture2DLod(depthtex0, uv, 0.0).x;
		sampleDepth = linearizeDepth(sampleDepth);
		float testDepth = getLinearDepthOfViewCoord(testPoint);
		if(sampleDepth < testDepth)
		{
			hitColor = vec4(texture2DLod(gcolor, uv, 0.0).rgb, 1.0);
			break;
		}
	}
	return hitColor.rgb;
}

这一部分首先添加了3个辅助函数,分别用于"眼坐标转屏幕坐标"、"屏幕深度转线性深度"和"获取眼坐标对应屏幕上一点的线性深度".
然后是waterRayTarcing中新增的代码,在一个40次的循环中,测试点会从测试出发点沿射线方向行进,每次固定前进0.05个单位,每次测试时,会先计算当前测试点所对应的屏幕坐标uv,然后获取屏幕坐标对应的深度sampleDepth和测试点在屏幕中的深度testDepth,如果测试点的深度大于sampleDepth,即测试点位于这个像素的"后面"的话,即代表命中,记录该像素的颜色并中断循环.为了测试方便,未命中的情况下返回黑色.

这一次再测试,看到的却是...正常的水面,和少数的黑色和反射?

2016-02-09_23.06.04

按理说,现在是不应该有水面的,出现水面说明有些测试是直接命中了出发点的像素...解决方法就是将出发点沿法线方向偏移一点,将waterEffect中的"color = waterRayTarcing(viewPos, viewRefRay, color);"改成:

color = waterRayTarcing(viewPos + normal * (-viewPos.z / far * 0.2 + 0.05), viewRefRay, color);

这一次测试已经没有水面了,然而可见的反射却少得可怜,这是由于步长过短导致,0.05的步长走完40步后只能移动2.0个长度,而在眼坐标系下仅z轴的分布就可以在-[0.05, 256.0].但是简单地增大步长会导致近处的反射变得非常难看,因此这里需要一个变长的步长,这里使用一个凑出来的经验公式,将stepBase和"testPoint += direction;"改成:

const float stepBase = 0.025;
//...
	testPoint += direction * pow(float(i + 1), 1.46);

2016-02-09_23.42.53

现在水面效果好了很多,然而你会注意到有些反射有些奇怪,特别是当你在第三人称下看自己的反射时...

2016-02-10_03.26.39
Koishi all the way down!

这是因为对反射的命中判定不能简单地以sampleDepth < testDepth作为条件,试想一下,假如在一个远景中,在近处放置一个浮在空中的盒子,正常情况下,反射线应当从盒子后方穿过,然而在现在的测试条件中,却将盒子判定为命中对象. ssrp2
图:由于近处物体导致的命中判断错误,正确结果是命中远景,然而由于在此之前先命中了盒子,故错误地使用盒子作为测试结果.红色为测试点,蓝色为采样点.

修正的方法是在判断中加一个额外的限定条件,要求测试点与采样点之差不能大于一个值,将"if(sampleDepth < testDepth)"改成: [code] if(sampleDepth < testDepth && testDepth - sampleDepth < (1.0 / 2048.0) * (1.0 + testDepth * 200.0 + float(i))) [/code] 这个也是一个拼凑出来的公式,不管怎么说,效果还是可以的. 2016-02-10_03.27.30

Yet another problem,你会注意到有些反射糊成了一团,这是由于步长过大导致,比如说正确的命中像素是A,但是测试点最终停在了A旁边的像素,那么就会使用旁边的像素,而不是A,解决方法是采用二分搜索,每次步进时记录上一个测试点,一旦命中后,就在上一点和这一点之间进行二分搜索,查找正确的命中像素.

ssrp3
图:测试点停在了旁边的像素,而不是正确的命中像素,而二分搜索可以解决这一问题,以一个4次二分搜索为例,第一次搜索从上一点出发,像当前点前进了0.5个长度,此时由于测试深度大于采样深度,故此调转方向,在第二次搜索时向上一点的方向前进0.25个长度,这一次测试深度小于采样深度,故再次调转方向,第三次测试像当前点前进0.125个长度,测试深度依然小于采样深度,所以方向不变,第四次测试保持方向不变前进0.0625个长度,这一次的测试结果是什么已经不重要了,因为最终结果就会使用当前所指的像素.红色为测试点,蓝色为采样点,紫色为像素.

首先先要添加二分搜索的代码,这里我用宏写了一个无分支的二分搜索:

#define BISEARCH(SEARCHPOINT, DIRVEC, SIGN) DIRVEC *= 0.5; \
					SEARCHPOINT+= DIRVEC * SIGN; \
					uv = getScreenCoordByViewCoord(SEARCHPOINT); \
					sampleDepth = linearizeDepth(texture2DLod(depthtex0, uv, 0.0).x); \
					testDepth = getLinearDepthOfViewCoord(SEARCHPOINT); \
					SIGN = sign(sampleDepth - testDepth);

其中,SEARCHPOINT是当前搜索的位置的vec3变量;DIRVEC是从上一点指向当前点的vec3变量;SIGN是一个用于表示符号的float变量,初值为1.0.每次搜索时,DIRVEC折半,然后SEARCHPOINT根据SIGN,向当前点前进/后退|DIRVEC|个长度,然后根据当前的深度值判断下一步是前进(那么SIGN将为1.0)还是后退(那么SIGN将为-1.0).

然后就该添加waterRayTarcing中的内容了,这一次添加的东西不多,但是很乱,首先在函数的变量初始化那一堆添加:

vec3 lastPoint = testPoint;

然后去魔改找到命中点之后的处理:

if(sampleDepth < testDepth && testDepth - sampleDepth < (1.0 / 2048.0) * (1.0 + testDepth * 200.0 + float(i)))
{
	vec3 finalPoint = lastPoint; //finalPoint为二分搜索后的最终位置
	float _sign = 1.0;
	direction = testPoint - lastPoint;
	BISEARCH(finalPoint, direction, _sign);
	BISEARCH(finalPoint, direction, _sign);
	BISEARCH(finalPoint, direction, _sign);
	BISEARCH(finalPoint, direction, _sign);
	uv = getScreenCoordByViewCoord(finalPoint);
	hitColor = vec4(texture2DLod(gcolor, uv, 0.0).rgb, 1.0);
	hit = true;
	break;
}
lastPoint = testPoint;

现在再测试一下,效果已经好多了,对于二分搜索的次数,我认为4次的效果就可以了,此外关于有分支和无分支的二分搜索谁快谁慢我还真没试过...有闲心的可以测试一下,说不定我这里的无分支版本由于爆了L1指令缓存反而更慢呢 (笑)

继续解决问题,你肯定注意到在某些位置会有"花屏"的现象,将如此严重的问题放在这么靠后的位置来讨论,你是不是认为那一定是一个难题?其实也不是,它只是测试点超出了屏幕范围,毕竟SSR只能反射屏幕之内的景物嘛,解决方法很简单,在"vec2 uv = getScreenCoordByViewCoord(testPoint);"后面加一段:

if(uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0)
{
	hit = true;
	break;
}

(为什么超出范围未命中还要hit = true?这是为之后准备的,待会你就明白了.)

效果很明显,这里就不上图了.

然而现在还有一个严重的问题,就是当你靠近和远离某处,或者在创造模式下上升或下降时,会看到反射的像在疯狂地扭曲,如果你眼睛够尖的话,可以发现这些扭曲是分层的,这是由于在循环中,每个循环的步长差异过大所导致,更准确地说,是因为它们是离散的,比如区域A能在第一次循环中命中反射对象,旁边的区域B能在第二次循环中命中反射对象,如果第一次循环与第二次循环的步长不是连续的,势必会导致两个区域命中的位置不连续,理论上讲这会导致断层,但由于各种原因,这里导致的是扭曲.这一问题的解决方法相当猎奇,是在计算步长时引入抖动(Jitter),强行让原本不连续的离散步长表现的近似连续.之前我以为抖动是引入噪声,后来看了别人的代码才知道是引入一个由屏幕位置决定的,范围在[0,1)的连续值,将这个值掺入步长计算,就可以显著清除扭曲效果.

2016-02-10_05.32.02

首先要加入抖动的计算:

uniform float viewWidth;
uniform float viewHeight;

vec3 waterEffect(vec3 color, vec2 uv, vec3 viewPos, float attr) {
	if(attr == 1.0)
	{
		vec3 normal = normalDecode(texture2D(gnormal, texcoord.st).rg);
		vec3 viewRefRay = reflect(normalize(viewPos), normal);
		vec2 uv2 = texcoord.st * vec2(viewWidth, viewHeight);
		float c = (uv2.x + uv2.y) * 0.25;
		float jitter = mod(c, 1.0);
		color = waterRayTarcing(viewPos + normal * (-viewPos.z / far * 0.2 + 0.05), viewRefRay, color, jitter);
	}
	return color;
}

这个抖动计算公式我是取自kode80的SSR着色器中的,接下来要改waterRayTarcing,为它加上抖动值参数,并应用这个参数到步长计算中:

vec3 waterRayTarcing(vec3 startPoint, vec3 direction, vec3 color, float jitter) {
//...
		testPoint += direction * pow(float(i + 1 + jitter), 1.46);

现在再测试一下,扭曲确实没了,但却变成了锯齿...这是由抖动的计算公式决定的,不过并不是很碍眼,如果你实在看不顺眼的话,可以考虑换个公式.

2016-02-10_05.32.19

(注:其实对一个完善的光影包来说,扭曲效果的消除反而是可选的,因为好的光影包通常都会有水面的波动效果,玩家很难分辨出反射的扭曲是由于水的波动造成,还是因光影包固有的缺陷而导致.相反,锯齿效果是无论如何也掩盖不住的,因此如果你打算制作一个功能完整的光影包的话,对扭曲效果的消除要斟酌考虑)

目前,我们对超过范围的反射是一刀切似地直接抛弃,这在游戏中当然显得很不自然,最好的办法是对它们自然过渡,比如在"hitColor = vec4(bloom(texture2DLod(gcolor, uv, 0.0).rgb, uv) * 0.5, 1.0);"的下面加入:

hitColor.a = clamp(1.0 - pow(distance(uv, vec2(0.5))*2.0, 2.0), 0.0, 1.0);

为了使它表现出效果,将后面的"return hitColor.rgb;"改成:

return hitColor.rgb * hitColor.a;

现在再测试一下,已经没有前列腺刹车式的瞬间消隐了.

2016-02-10_05.56.04

现在的反射看起来已经挺不错了,然而现在的水面却无法反射过远的地方的镜像,甚至当镜头贴近水面时可反射的范围都会大大减少,如果要解释原因的话,是因为我们这里采用的是3D反射.
SSR其实根据方式可以分为两种,3D和2D,3D反射就是在某一坐标系内,从起点向射线方向逐步前进,然后计算测试点在屏幕上的位置,这种做法会带来一个显而易见的问题,就是如果入射角和法线夹角接近90度,比如,紧贴水面并平视时,反射线将几乎与视线成一条直线,反应到屏幕上便是即使40遍循环过后,测试点也依然没在屏幕上走几个像素.
而这个问题对2D反射来说就不存在,2D反射的原理是先根据起点、射线方向和最大反射距离,计算出可能的反射终点,然后将起点和终点变换到屏幕坐标系中,此时变换后的起点到终点的方向可以被视为一个2D矢量(在屏幕坐标系中z分量的意义不大),之后利用这个2D的方向,结合一些方法,比如经过透视修正的线性插值,或者光栅化算法之类的,从起点向终点的方向逐像素地测试,这种方法的好处是特别擅长处理3D反射所面临的反射线与视线夹角过小的问题,缺点也显而易见,在近距离反射时镜像点到反射点的距离可以跨幅半个屏幕,逐像素地一点点爬过去,听着就累得慌(开玩笑,其实还是可以采用跨步的方法,即每隔几个像素采样一次,然后用二分搜索来确定实际命中像素).不管怎么说,2DSSR反射作为近两年刚刚出现的技术,还是比较厉害的,一些游戏(比如杀戮地带4,防反信条4)已经采用了2DSSR,可以说这将是今后SSR的趋势,我也确实做了一个2DSSR的版本,看着效果还行,但存在几个严重的问题,故最终采用的还是3DSSR.

2016-02-09_02.42.05
图:2DSSR,有些严重问题在图中未表现出来.

如果你对2DSSR感兴趣的话,可以看这几篇文章:
http://jcgt.org/published/0003/04/04/paper.pdf 2DSSR的原始论文
http://casual-effects.blogspot.hk/2014/08/screen-space-ray-tracing.html 2DSSR的作者在blog上以比较通俗化的语言对2DSSR的讲解
http://www.kode80.com/blog/2015/03/11/screen-space-reflections-in-unity-5/ kode80对2DSSR实际实现的讲解
https://github.com/kode80/kode80SSR/blob/master/Assets/Resources/Shaders/SSR.shader kode80用CG语言写的2DSSR

那么,我们现在的3DSSR就只能坐以待毙了吗,当然不是了,我们还有一个2DSSR做不到的杀必死("莲子可是做不到这样的哦" 提问,这是哪部红字本里的台词?),就是当40遍循环结束后,如果仍未命中,但测试点尚在屏幕范围内的话,就不顾一切直接返回当前位置所对应的颜色,这个听起来比较蠢,但事实上效果相当好,因为此时即使测试点尚未命中反射对象,与反射对象的距离也差不多了.

实现这个很简单,在循环体的后面加上:

if(!hit)
	{
	vec2 uv = getScreenCoordByViewCoord(lastPoint);
	float testDepth = getLinearDepthOfViewCoord(lastPoint);
	float sampleDepth = texture2DLod(depthtex0, uv, 0.0).x;
	sampleDepth = linearizeDepth(sampleDepth);
	if(testDepth - sampleDepth < 0.5)
	{
		hitColor = vec4(texture2DLod(gcolor, uv, 0.0).rgb, 1.0);
		hitColor.a = clamp(1.0 - pow(distance(uv, vec2(0.5))*2.0, 2.0), 0.0, 1.0);
	}
}

这里还要有一步额外的深度判断,因为在第三人称时有可能这个点正好在玩家身上,如果遇到这种情况时,就应该不绘制反射,虽然看着有些奇怪,但远比返回一个错的离谱的结果好.

2016-02-10_06.00.40

2016-02-10_06.01.47

现在的工作只剩如何将反射颜色hitColor和基色color混合了,这里我使用的是"color * (1.0 - hitColor.a) + hitColor.rgb * hitColor.a",它可以被简写为"mix(color, hitColor.rgb, hitColor.a)".将"return hitColor.rgb * hitColor.a;"改成:

return mix(color, hitColor.rgb, hitColor.a);

2016-02-10_19.02.57

水面反射已经基本完善了,但仍有两个小的改进,一个是在水下时水面依然有反射效果,然而这种反射效果往往得不到正确的结果,因此最好将它们取消掉,我的解决方法是在顶点着色器里禁止将世界坐标系法线方向指向下方的顶点标记为水面,把gbuffers_water.vsh中的"if(mc_Entity.x == 8 || mc_Entity.x == 9)"改成:

if(gl_Normal.y > -0.9 && (mc_Entity.x == 8 || mc_Entity.x == 9))

第二个改进则是无论在什么角度,水面都有爆肝式的镜面反射问题,在现实中很少有水面会有如镜子般的反射,通常来说只有当平视远方的水面时才会以反射为主,当俯视近处的水面时,是以来自水底的折射为主,菲涅耳方程描述了某点反射光和折射光比例的关系,想解菲涅耳方程确实有点难,就像当初要我们解分数布朗运动一样,幸运的是,这里同样也有一个菲涅耳方程的近似解,就是"Schlick's approximation",它的公式是:

ebe3046c32ee37cb0bed621127114784

其中,θ是入射光线和法线的夹角,n1是反射光所在介质的折射率,n2是折射光所在介质的折射率,最终结果R(θ)是透射系数,代表反射光所占的比例,当视线与法线接近90°时,R(θ)≈1,也就是反射光几乎占全部,这时反射面就会像镜子一样,而当视线与法线平行时,R(θ)=R0,此时的反射光比例就要由两种介质的折射率来决定了.对于空气-水的介质组合来说,R0=0.02.也就是说这里的透射系数计算公式是:
R(θ) = 0.02 + 0.98 * (1.0 - cos(θ))^5

实现起来很简单,在waterEffect中调用waterRayTarcing之前,加上一段透射系数计算,然后改掉waterRayTarcing的调用:

float fresnel = 0.02 + 0.98 * pow(1.0 - dot(viewRefRay, normal), 5.0);
color = waterRayTarcing(viewPos + normal * (-viewPos.z / far * 0.2 + 0.05), viewRefRay, color, jitter, fresnel);

点乘两个单位长度的向量就可以获得它们的夹角的余弦值,为什么这里计算的是反射光线和法线的夹角,而不是入射光线和法线的夹角?因为两者的数值都一样 ?

然后为waterRayTarcing添加一个透射系数参数和相应的计算:

vec3 waterRayTarcing(vec3 startPoint, vec3 direction, vec3 color, float jitter, float fresnel) {
//...
	return mix(color, hitColor.rgb, hitColor.a * fresnel);

2016-02-11_01.13.58

现在爆肝式镜面反射已经没了.觉得反射太少了?那就把计算反射系数时的^5改成^3,就可以取得Chocapic13的反射效果;去掉反射系数,就可以取得SEUS10.0的反射效果. ?

关于SSR和Ray Tracing的教程就到这里了,虽然我们自嘲这是这个星球和它的卫星上所能找到的最糟的反射,然而实际上效果并不是太糟,与其他光影包相比,这里差的是:1.水面颜色 2.水浪效果 3.水的高光反射 而这些都不是SSR的锅.
说到改进空间的话,我想大概是继续调整各种参数吧...说实话,我在写这个SSR时只有20%的时间是在写代码,80%的时间是在调整参数和观察对比效果.

该部分的最终成品可以从这里下载: [SkyDrive] [百度网盘]

画龙点睛 - 光照着色、颜色调整与伽马校正

本来这章作为倒数第二章应该是压轴的,然而由于超时太久急于截稿,因此暂时这章只能以扯蛋为主了...下一次更新(如果有的话)会加入一些实质性的内容.

尽管我们已经做了这么多工作,但光影包看上去依然和其他光影包有些不同(事实上,它看起来像GMOD中的MC地图),一方面是我们本来做的就很烂(雾),另一方面在于我们现在的着色方式.

自始至终我们都在使用MC默认的光照着色,也就是通过一张预生成的光度图(Lightmap),然后按照查表的方式获取像素的光照颜色,而市面上大多数光影包多采用自行编写的光照代码,要实现自定义的光照,需要在G-Buffer绘制时向一个缓冲区输出光照坐标(lmcoord)数据,它的数据可以作为光照强度的参考.然后在后处理着色器中进行着色.你也可以直接在G-Buffer中计算光照,只不过运算量比较大,一是你需要在顶点着色器中计算一些控制光照的数据,比如不同时间时的环境光颜色,在后处理渲染器中你只需要计算4次,而在G-Buffer着色器中你需要计算数千次;二是G-Buffer绘制时存在很多无效渲染,Minecraft确实有通过OpenGL遮掩查询机制实现剔除的,不过它只能以Chunk为单位来剔除,另外它只有在设置中打开"高级OpenGL"时才会启用这个功能,而那个默认是关闭的(而且它也确实不可靠,我打开它后没少看见黑色区域和区块突然闪烁消失)...这次倒是真能发挥延迟渲染在处理光照时的优势了,当然,为了让后处理着色器区分哪些需要着色(砖块),哪些不需要着色(天空),你可以添加一个特殊的标记量,比如如果你将光照坐标存在一个RGB8的颜色缓冲中,头两位可以用于存放自然光强度和人造光强度,第三位可以存一个非零数,着色器只有在该位不为零时才进行光照着色,由于大部分颜色缓冲区在每帧渲染前会清空成纯黑(0.0),你就不用在不需要光照的G-Buffer着色器中添加输出了,但是有两个例外是gbuffers_weather和gbuffers_hand,它们无论你想不想进行手动光照,都需要添加一个输出,这是因为手和雨的渲染是在最后渲染的,如果它们遮住了有光照的像素,那就必须输出一个值去覆盖掉标记量.此外,如果你想使用雾效果的话,你就需要将雾计算放在光照中进行.

除了自定义光照着色以外,还有一些手段来改变光影包给人带来的感觉,比如颜色调整.

颜色调整(Color Grading)并不是什么新技术,就像Tone Mapping很早以前便被摄影界使用一样,颜色调整很早以前便被电影业使用,你可能好奇为什么好莱坞的电影拍出来即使再烂,场面也都一个个如同大片一样,而你的秒拍视频却总是显得土里土气,其中一个因素是气氛,平面艺术家都知道作品中不同的颜色搭配可以给观看者带来不同的心理感受,在摄影中虽然很难控制场景中的颜色,但通过后期处理依然可以实现对色调的控制.

colorgrading
图:颜色调整例子,图片摘自www.philwesson.com

而在游戏业,颜色调整却是近些年才出现并普及,粗略的估计至少是在可编程着色器之后才有,因为显然没有哪个图形API提供了颜色调整的功能,最常见的颜色调整方法是LUT(Look-up table,也就是速查表),原理上是制作一个256x256x256的三维纹理,每一个维度代表RGB中的一个分量的刻度,之后美工在PS中打开一张有代表性的游戏截图,再打开那个三维纹理,同时调整两者的颜色,当调整到一个理想的颜色配置后,将那个三维纹理导出,在游戏中引擎加载这个纹理作为速查表,在后处理的最后阶段,将当前像素颜色当做纹理坐标,在速查表中查找调整后的颜色并输出.实际中也差不多,只不过考虑到256x256x256的RGB纹理那惊人的尺寸,通常来说会使用16x16x16的纹理来代替,由于纹理取样时的线性插值,效果依然近似不变.

然而这个好方法在Shadersmod中却用不到,原因很简单,我们没法加载LUT纹理!这意味着如果我们想实现颜色调整的话,就得手工在着色器中把PS里的那些颜色调整过程自己实现一遍,光麻烦不说,PS中有些颜色调整算法你知道怎么实现吗 ?

不管怎么说,这里还是列出了一些颜色调整算法的实现,需要注意的是,它们取得的效果与PS中的同类功能不一定相同,比如亮度调整在0.0时就是全黑了,而PS中则永远不会被调到全黑.首先需要说明的是颜色模型,在OpenGL中我们使用的是RGB颜色模型,然而有一些颜色调整算法是针对HSL颜色模型而设计的,这就需要在两种颜色模型间相互转换.

RGB转HSL:

vec3 rgbToHsl(vec3 rgbColor) {
	rgbColor = clamp(rgbColor, vec3(0.0), vec3(1.0));
	float h, s, l;
	float r = rgbColor.r, g = rgbColor.g, b = rgbColor.b;
	float minval = min(r, min(g, b));
	float maxval = max(r, max(g, b));
	float delta = maxval - minval;
	l = ( maxval + minval ) / 2.0;	
	if (delta == 0.0) 
	{
		h = 0.0;
		s = 0.0;
	}
	else
	{
		if ( l < 0.5 )
			s = delta / ( maxval + minval );
		else 
			s = delta / ( 2.0 - maxval - minval );
			
		float deltaR = (((maxval - r) / 6.0) + (delta / 2.0)) / delta;
		float deltaG = (((maxval - g) / 6.0) + (delta / 2.0)) / delta;
		float deltaB = (((maxval - b) / 6.0) + (delta / 2.0)) / delta;
		
		if(r == maxval)
			h = deltaB - deltaG;
		else if(g == maxval)
			h = ( 1.0 / 3.0 ) + deltaR - deltaB;
		else if(b == maxval)
			h = ( 2.0 / 3.0 ) + deltaG - deltaR;
			
		if ( h < 0.0 )
			h += 1.0;
		if ( h > 1.0 )
			h -= 1.0;
	}
	return vec3(h, s, l);
}

HSL转RGB:

float hueToRgb(float v1, float v2, float vH) {
	if (vH < 0.0)
		vH += 1.0;
	if (vH > 1.0)
		vH -= 1.0;
	if ((6.0 * vH) < 1.0)
		return (v1 + (v2 - v1) * 6.0 * vH);
	if ((2.0 * vH) < 1.0)
		return v2;
	if ((3.0 * vH) < 2.0)
		return (v1 + ( v2 - v1 ) * ( ( 2.0 / 3.0 ) - vH ) * 6.0);
	return v1;
}

vec3 hslToRgb(vec3 hslColor) {
	hslColor = clamp(hslColor, vec3(0.0), vec3(1.0));
	float r, g, b;
	float h = hslColor.r, s = hslColor.g, l = hslColor.b;
	if (s == 0.0)
	{
		r = l;
		g = l;
		b = l;
	}
	else
	{
		float v1, v2;
		if (l < 0.5)
			v2 = l * (1.0 + s);
		else
			v2 = (l + s) - (s * l);
	
		v1 = 2.0 * l - v2;
	
		r = hueToRgb(v1, v2, h + (1.0 / 3.0));
		g = hueToRgb(v1, v2, h);
		b = hueToRgb(v1, v2, h - (1.0 / 3.0));
	}
	return vec3(r, g, b);
}

以下是一些常见的颜色调整:

亮度:
针对模型:RGB
输入参数:b - 亮度,0.0为全黑;0.0~1.0为变暗,1.0以为上变亮.

vec3 brightness(vec3 rgbColor, float b) {
	return mix(vec3(0.0), rgbColor, b);
}

对比度:
针对模型:RGB
输入参数:c - 对比度,0.0为全灰,0.0~1.0为降低对比度,1.0以为上提高对比度

vec3 contrast(vec3 rgbColor, float c) {
	return mix(vec3(0.5), rgbColor, c);
}

色阶:
色阶其实就是对RGB中某一个颜色分量进行截断并重新映射到[0.0, 1.0]的范围.

曲线:
和色阶相似,通过一个函数对RGB中某一个颜色分量进行重新映射,考验你拟合函数能力的时刻到了!

饱和度:
针对模型:RGB
输入参数:s - 饱和度,0.0为灰度图像,0.0~1.0为降低饱和度,1.0以上为提高饱和度

vec3 saturation(vec3 rgbColor, float s) {
	float lum = dot(rgbColor, vec3(0.2125, 0.7154, 0.0721));
	return mix(vec3(lum), rgbColor, s);
}

增加自然饱和度:
自然饱和度与饱和度的区别在于,后者会无差别地增加任何颜色的饱和度,而前者对柔和的颜色增加更多的饱和度,对已经很鲜艳的颜色少增加饱和度.下面这个是利用幂函数图像的特点来在HSL模型中实现增加自然饱和度,是的,只能增加...不能减少 ?
针对模型:HSL
输入参数:v - 自然饱和度,0.0完全饱和,0.0~1.0为增加自然饱和度,1.0以上为不自然地降低饱和度

vec3 vibrance(vec3 hslColor, float v) {
	hslColor.g = pow(hslColor.g, v);
	return hslColor;
}

色相/饱和度:
其实这就是HSL模型中的三个颜色分量...

色彩平衡:
色彩平衡是个很奇怪的东西,老实说,我连它的原理和作用都不知道...不过似乎有不少人喜欢用它.然而它的算法也略复杂,这里使用的是GIMP的色彩平衡算法,其中,smh三个参数对应色彩平衡中的三个色彩区域,每个分量代表色彩平衡中的三个值,0.0代表不调整,1.0理论上相当于PS中某个条向右拉到头(+100),-1.0理论上相当于PS中某个条向左拉到头(-100),之所以说是理论,是因为它的实际效果比PS的强度要大一些,想取得和PS的色彩平衡相同的效果,考虑将值缩小为三分之二左右.
针对模型:RGB和HSL
输入参数:s - 阴影 m - 中间调 h - 高光 p - 保持亮度,布尔值

vec3 colorBalance(vec3 rgbColor, vec3 hslColor, vec3 s, vec3 m, vec3 h, bool p) {
	s *= clamp((hslColor.bbb - 0.333) / -0.25 + 0.5, 0.0, 1.0) * 0.7;
	m *= clamp((hslColor.bbb - 0.333) /  0.25 + 0.5, 0.0, 1.0) *
		 clamp((hslColor.bbb + 0.333 - 1.0) / -0.25 + 0.5, 0.0, 1.0) * 0.7;
	h *= clamp((hslColor.bbb + 0.333 - 1.0) /  0.25 + 0.5, 0.0, 1.0) * 0.7;
	vec3 newColor = rgbColor;
	newColor += s;
	newColor += m;
	newColor += h;
	newColor = clamp(newColor, vec3(0.0), vec3(1.0));
	if(p)
	{
		vec3 newHslColor = rgbToHsl(newColor);
		newHslColor.b = hslColor.b;
		newColor = hslToRgb(newHslColor);
	}
	return newColor;
}

不通过LUT进行颜色调整相当昂贵而且效果不佳,因此其他光影包中的颜色调整多局限于饱和度和对比度之类的,主力还是光照着色.

这里给出一个简单的昼间屎绿色色调光照调整,作用在final.fsh的tonemap之后:

vec3 hslColor = rgbToHsl(color);
hslColor = vibrance(hslColor, 0.75);
color = hslToRgb(hslColor);
color = colorBalance(color, hslColor, vec3(0.0), vec3(0.0, 0.12, 0.2), vec3(-0.12, 0.0, 0.2), true);

2016-02-12_04.22.30
(我终于知道为什么ici2cc一定要给CustomSteve加上跳舞功能了...)

此外,还有一个很重要的东西(有多重要呢?理论上应该在制作阴影前就说的...(逃?)),就是伽马校正.

伽马校正(Gamma Correction)解释起来有点麻烦,简单地说,它是用来解决亮度非线性变化的手段,由于显示器的特性,你输出的颜色的亮度在显示器上的变化不是线性的,举例来说,按照常识,RGB颜色(0.5, 0.5, 0.5)的亮度应该是(1.0, 1.0, 1.0)的一半,然而实际在显示器中前者看上去明显要比后者暗很多,远不到一半亮度,事实上,(0.73, 0.73, 0.73)在显示器上才像是(1.0, 1.0, 1.0)的一半亮度.

2016-02-12_05.37.57
左上为(0.5),左下为(0.73),右边为(1.0)

通常来讲,显示器实际输出的亮度,是输入颜色(规整化到[0.0, 1.0]之间)的2.2次幂,这个值在不同的显示器间可能有差异,但基本不会偏离这个值太多,这就导致了一个线性变化的颜色实际显示在屏幕中会是非线性地变化,这种特性会导致很多涉及到渐变的颜色变得很奇怪,也会导致开发者在给经验公式调整参数时难以摸清规律.因此就有了伽马校正,它的工作方式是在最终向屏幕输出颜色前(也就是fina.fsh在输出gl_FragColor前)将这个颜色调整为它的(1.0/2.2)次幂,这是因为显示器的输出颜色可以理解为是X^2.2,为了让显示器输出原本想要的颜色,可以直接向它输入X^(1.0/2.2),这样最终显示出来的结果就是X^(2.2/2.2)=X.

如果你现在就急匆匆地想去把gl_FragColor的输出改成"gl_FragColor = vec4(pow(color, vec3(1.0/2.2)), 1.0)"的话,现实可能会泼你一盆冷水,你会发现屏幕除了变得十分亮以外什么效果也没有,这是因为之前所有的参数都是在不考虑伽马校正的情况下敲定的,就连MC的光照系统也没有考虑伽马修正,整个光影包要经过大修才能使用.你可以尝试先在compsite.fsh中开头加个"color.rgb = pow(color.rgb, vec3(2.2));",来简单预览一下效果,你会注意到最明显的变化是阴影变淡了,因为之前在未经伽马修正时,亮度x0.5得到的结果远比一半亮度要暗,当初设计时我们想着阴影区的效果是亮度减半,实际上几乎是减到了20%.至于颜色变鲜艳了...我觉得这是临时措施的副作用,伽马校正可没这功能.

如果你对伽马校正有兴趣的话,可以看一看这篇文章:http://learnopengl-cn.readthedocs.org/zh/latest/05%20Advanced%20Lighting/02%20Gamma%20Correction/

本章没有可供下载内容,因为没有任何干货 ?

锦上添花 - 常见特效

晕影

Vignette,也就是晕影,或者叫暗角,效果就是屏幕边缘变暗,恐怕是最好实现的后处理特效了.晕影最初是出现在摄影上,有时照片拍出来会因为各种原因在边缘留下一个暗圈(还记得我之前在故宫拍的那张照片不?那个是因为劣质ipad皮套松了导致皮套遮住了镜头...),听起来很糟糕但看起来还不错,我猜是因为晕影酷似人眼的边缘视觉吧.印象中第一个见过的带晕影的游戏是战地3,而到了现在随便什么游戏都整个晕影,不信你看连AVG游戏都有:

thisgamenamedtuozuo
图:AVG游戏都有晕影了,你的光影包还能没有吗?说起来"妹子"你的骆驼趾可够大的啊...等等为什么你挺着杆大♂炮!?

晕影实现起来非常简单,对于不使用着色器的固定管线来说,可以直接事先制作好一张晕影贴图,然后在渲染的最后阶段绘制到屏幕上.使用着色器来实现也很简单,一次全屏后处理,根据像素到屏幕中央的距离减弱亮度,具体的算法决定了最终的晕影效果,比如在final.fsh中加入:

vec3 vignette(vec3 color) {
	float dist = distance(texcoord.st, vec2(0.5f));
	dist = clamp(dist * 1.7 - 0.65, 0.0, 1.0); //各种凑魔数
	dist = smoothstep(0.0, 1.0, dist);
	return color.rgb * (1.0 - dist);
}

//...main函数 {
color = vignette(color);
//...}

2016-02-13_21.54.37

炫光

炫光(Lens flare,也就是镜头光晕)是指照片中与阳光成一条直线排布的那些光环,它的物理原理是光在多个镜片间反射而成,因此实际上谁也没有亲眼看见过这种效果,然而艺术家和导演们还是很热衷于在作品中加入这种特效,玩家老爷们也挺喜欢,因此就加上吧.

制作炫光基本只需要解决3个问题. 1.如何确定光源位置和可见性 2.如何确定光环的位置 3.如何在屏幕上绘制光环

Shadersmod提供了sunPosition这个一致变量作为太阳在眼坐标系中的位置,正好可以用于计算光源在屏幕上的位置,判断光源可见性可以根据像素深度值来判断.
一旦知道光源在屏幕上的位置,光环的位置也很容易计算了,炫光中的光环总是分布在光源与视点中心相连的直线上,并且光环到视点中心的距离,与光源到视点中心的距离成正比.
至于如何在屏幕上绘制光环,最简单的办法是计算像素到圆心的距离,小于一定阈值就绘制上圆的颜色,如果要实现渐变效果,可以对距离施以幂函数或平滑过渡,也可以像分数噪音那样,将多个函数的结果叠加来形成更复杂的效果.

首先要在final.vsh中计算光源和光环在屏幕上的位置:

uniform float viewWidth;
uniform float viewHeight;
uniform vec3 sunPosition;
uniform mat4 gbufferProjection;
uniform sampler2D depthtex0;

varying float sunVisibility;
varying vec2 lf1Pos;
varying vec2 lf2Pos;
varying vec2 lf3Pos;
varying vec2 lf4Pos;

#define LF1POS -0.3
#define LF2POS 0.2
#define LF3POS 0.7
#define LF4POS 0.75

//...main函数 {
vec4 ndcSunPosition = gbufferProjection * vec4(normalize(sunPosition), 1.0);
ndcSunPosition /= ndcSunPosition.w;
vec2 pixelSize = vec2(1.0 / viewWidth, 1.0 / viewHeight);	
sunVisibility = 0.0f;
vec2 screenSunPosition = vec2(-10.0);
lf1Pos = lf2Pos = lf3Pos = lf4Pos = vec2(-10.0);
if(ndcSunPosition.x >= -1.0 && ndcSunPosition.x <= 1.0 &&
	ndcSunPosition.y >= -1.0 && ndcSunPosition.y <= 1.0 &&
	ndcSunPosition.z >= -1.0 && ndcSunPosition.z <= 1.0)
{
	screenSunPosition = ndcSunPosition.xy * 0.5 + 0.5;
	for(int x = -4; x <= 4; x++)
	{
		for(int y = -4; y <= 4; y++)
		{
			float depth = texture2DLod(depthtex0, screenSunPosition.st + vec2(float(x), float(y)) * pixelSize, 0.0).r;
			if(depth > 0.9999)
				sunVisibility += 1.0 / 81.0;
		}
	}
	float shortestDis = min( min(screenSunPosition.s, 1.0 - screenSunPosition.s),
							 min(screenSunPosition.t, 1.0 - screenSunPosition.t));
	sunVisibility *= smoothstep(0.0, 0.2, clamp(shortestDis, 0.0, 0.2));
	
	vec2 dir = vec2(0.5) - screenSunPosition;
	lf1Pos = vec2(0.5) + dir * LF1POS;
	lf2Pos = vec2(0.5) + dir * LF2POS;
	lf3Pos = vec2(0.5) + dir * LF3POS;
	lf4Pos = vec2(0.5) + dir * LF4POS;
}
//...}

首先,这段代码包含了5个传递变量,sunVisibility代表太阳的可见性,它的计算方法是根据太阳中心及周围9x9范围内共计81个像素的深度来判断能否看见阳光,此外它还会根据太阳在屏幕上与屏幕边界的距离进行淡出;lfXPos代表4个光环在屏幕上的位置,如果不在屏幕上的话会被设为(-10.0, -10.0).
在代码中,我们先把太阳在眼坐标系中的位置进行了规整化,然后做了次投影变换和透视除法,然后在NDC中进行了一次裁剪,为什么之前的所有投影变换后都没有裁剪?这是因为之前我们所有进行投影变换的顶点都是已经位于屏幕中的,毫无疑问它们肯定之前在G-Buffer绘制时已经通过裁剪了,因此没有必要再进行一次,而这次太阳的眼坐标系位置很可能是在屏幕之外,故此要进行一次裁剪.后面的计算就没有什么需要特别说明的了.

然后是final.fsh:

uniform float aspectRatio;

varying float sunVisibility;
varying vec2 lf1Pos;
varying vec2 lf2Pos;
varying vec2 lf3Pos;
varying vec2 lf4Pos;

#define MANHATTAN_DISTANCE(DELTA) abs(DELTA.x)+abs(DELTA.y)

#define LENS_FLARE(COLOR, UV, LFPOS, LFSIZE, LFCOLOR) { \
				vec2 delta = UV - LFPOS; delta.x *= aspectRatio; \
				if(MANHATTAN_DISTANCE(delta) < LFSIZE * 2.0) { \
					float d = max(LFSIZE - sqrt(dot(delta, delta)), 0.0); \
					COLOR += LFCOLOR.rgb * LFCOLOR.a * smoothstep(0.0, LFSIZE, d) * sunVisibility;\
				} }

#define LF1SIZE 0.1
#define LF2SIZE 0.15
#define LF3SIZE 0.25
#define LF4SIZE 0.25

const vec4 LF1COLOR = vec4(1.0, 1.0, 1.0, 0.1);
const vec4 LF2COLOR = vec4(0.42, 0.0, 1.0, 0.1);
const vec4 LF3COLOR = vec4(0.0, 1.0, 0.0, 0.1);
const vec4 LF4COLOR = vec4(1.0, 0.0, 0.0, 0.1);

vec3 lensFlare(vec3 color, vec2 uv) {
	if(sunVisibility <= 0.0)
		return color;
	LENS_FLARE(color, uv, lf1Pos, LF1SIZE, LF1COLOR);
	LENS_FLARE(color, uv, lf2Pos, LF2SIZE, LF2COLOR);
	LENS_FLARE(color, uv, lf3Pos, LF3SIZE, LF3COLOR);
	LENS_FLARE(color, uv, lf4Pos, LF4SIZE, LF4COLOR);
	return color;
}

//...main函数 {
color = lensFlare(color, texcoord.st);
//...}

这里是逐一计算各个光环对当前像素点的颜色影响,为了增加效率,每次预先根据曼哈顿距离进行一次剔除(其实这个真的能增加效率吗...唯一慢的操作只有那个开平方,不过如果你的光环绘制涉及到更复杂的计算,那或许这个判断会有意义),有个值得注意的是"delta.x *= aspectRatio",它是对X分量做一次屏幕横纵比的修正,如果没有这一步的话,你的炫光绘制出来会是扁的椭圆,这是因为大多数屏幕是宽大于高,所以同样的数值在Y轴方向上的变化实际是要大于X轴分量上的变化,因此要做一次横纵比的修正.这里的炫光绘制是最简单的一坨圆形的光团,如果想要更复杂的效果,比如真正的空心光环,或者是月牙形的弧光,就需要改良计算公式.最终绘制出来的效果是三团光,其中第三个是由距离极近的红光和绿光叠加成的.

2016-02-13_21.53.25

景深

DOF(Depth of Field)用于模拟人眼或相机的对焦效果,实现起来倒并不难,对原图像做一次模糊,然后对每一个像素,根据它的深度与屏幕中心像素的深度的差值,在原像素和模糊后的像素间进行插值.

然而在此之前,我们先需要进行一次大改,你需要把final.fsh中的bloom、waterEffect和tonemapping这三部分转移到compsite2.fsh中进行,这样在final.fsh中我们才会有一个可供进行模糊操作的"图像",这是一个比较繁琐的操作,这里有一些提示:

需要将uncharted2Tonemap(和它的那些常量)、bloom、getScreenCoordByViewCoord、linearizeDepth、getLinearDepthOfViewCoord、normalDecode、waterRayTarcing(与那个BISEARCH宏)、waterEffect和tonemapping移入compsite2.fsh,其中除了linearizeDepth以外,其他的在final.fsh中都可以删掉了.
需要移入uniform float near; uniform float far; uniform mat4 gbufferModelView; uniform mat4 gbufferProjection; uniform mat4 gbufferProjectionInverse; uniform sampler2D gcolor; uniform sampler2D gnormal; uniform sampler2D colortex4; uniform sampler2D depthtex0;
需要修改bloom,将它的返回值改成"return color + blur(colortex3, uv, vec2(0.0, 1.0) / vec2(viewWidth, viewHeight));"
需要修改compsite2.fsh的main函数:

void main() {
	vec3 color =  texture2D(gcolor, texcoord.st).rgb;
	vec3 attrs =  texture2D(colortex4, texcoord.st).rgb;
	float depth = texture2D(depthtex0, texcoord.st).r;
	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;
	float attr = attrs.r * 255.0;
	color = bloom(color, texcoord.st);
	color = waterEffect(color, texcoord.st, viewPosition.xyz, attr);
	color = tonemapping(color);
/* DRAWBUFFERS:1 */
	gl_FragData[0] = vec4(color, 1.0);
}

在final.fsh中去掉:

color = bloom(color, texcoord.st);
color = waterEffect(color, texcoord.st, viewPosition.xyz, attr);
color = tonemapping(color);

在final.fsh中将所有针对"gcolor"的采样换成针对"colortex1"的,此外它的"uniform sampler2D gcolor和gnormal"可以去掉了.

这样就完成转移了,从colortex1中我们可以获取一个已经完成了高光、水面效果和ToneMapping的像素,然后可以添加DOF特效了:

const float centerDepthHalflife = 0.5;
uniform float centerDepthSmooth;

#define DOF_FADE_RANGE 0.15
#define DOF_CLEAR_RADIUS 0.2
#define DOF_NEARVIEWBLUR

vec3 dof(vec3 color, vec2 uv, float depth) {
	float linearFragDepth = linearizeDepth(depth);
	float linearCenterDepth = linearizeDepth(centerDepthSmooth);
	float delta = linearFragDepth - linearCenterDepth;
	#ifdef DOF_NEARVIEWBLUR
	float fade = smoothstep(0.0, DOF_FADE_RANGE, clamp(abs(delta) - DOF_CLEAR_RADIUS, 0.0, DOF_FADE_RANGE));
	#else
	float fade = smoothstep(0.0, DOF_FADE_RANGE, clamp(delta - DOF_CLEAR_RADIUS, 0.0, DOF_FADE_RANGE));
	#endif
	if(fade < 0.001)
		return color;
	vec2 offset = vec2(1.33333 * aspectRatio / viewWidth, 1.33333 / viewHeight);
	vec3 blurColor = vec3(0.0);
	//0.12456 0.10381 0.12456
	//0.10380 0.08651 0.10380
	//0.12456 0.10381 0.12456
	blurColor += texture2D(colortex1, uv + offset * vec2(-1.0, -1.0)).rgb * 0.12456;
	blurColor += texture2D(colortex1, uv + offset * vec2(0.0, -1.0)).rgb * 0.10381;
	blurColor += texture2D(colortex1, uv + offset * vec2(1.0, -1.0)).rgb * 0.12456;
	blurColor += texture2D(colortex1, uv + offset * vec2(-1.0, 0.0)).rgb * 0.10381;
	blurColor += texture2D(colortex1, uv).rgb * 0.08651;
	blurColor += texture2D(colortex1, uv + offset * vec2(1.0, 0.0)).rgb * 0.10381;
	blurColor += texture2D(colortex1, uv + offset * vec2(-1.0, 1.0)).rgb * 0.12456;
	blurColor += texture2D(colortex1, uv + offset * vec2(0.0, 1.0)).rgb * 0.10381;
	blurColor += texture2D(colortex1, uv + offset * vec2(1.0, 1.0)).rgb * 0.12456;
	return mix(color, blurColor, fade);
}

//...main函数 {
color = dof(color, texcoord.st, depth);
//...}

这段代码原地进行了一次单遍5x5的高斯模糊,由于采用了线性过滤,只需要9次采样即可,同时根据像素的线性深度和centerDepthSmooth的线性深度的差值,在原颜色和模糊后的颜色进行渐变,centerDepthSmooth代表屏幕中心像素的深度,centerDepthHalflife用于控制它的变化速率.这里还有3个控制量,DOF_FADE_RANGE用于控制从模糊开始到完全模糊之间的深度距离,DOF_CLEAR_RADIUS是控制在中心深度的前后多少范围内的像素保持清晰,DOF_NEARVIEWBLUR用于定义是否启用近景模糊,虽然景深在字面意义上只管远景模糊,但由于对焦不只包括远处模糊,还包括近景模糊,因此DOF还应负责近处景物的模糊,然而这个效果并非很好,毕竟我可不想因为望了一眼天,近处的场景就变得贤者状态了...因此近景模糊成了一个可选选项,去掉或注释掉DOF_NEARVIEWBLUR将关闭近景模糊,保留它则启用.

2016-02-14_18.12.37
无近景模糊

2016-02-14_18.12.04
有近景模糊

2016-02-14_18.13.46
无近景模糊

2016-02-14_18.14.24
有近景模糊

运动模糊

Motion Blur用于模拟快速运动的物体在人眼前的模糊效果,运动模糊可以分为两种,一种是别人动你不动,一种是你自己在动,由于条件限制,在Shadersmod中只能实现后者了.
运动模糊的本质是径向模糊(Radial Blur),与高斯模糊的对四周进行采样不同,径向模糊是为每个像素确定一个径向矢量,在模糊时从源像素出发沿着这个矢量进行步进采样,步进的次数和步长决定了模糊的质量.
运动模糊面临的问题就是如何确定这个矢量,如果我们知道这个像素对应的点在上一帧时所对应的屏幕位置,那可以用这两个位置之差作为矢量.正好Shadersmod提供了previousCameraPosition、gbufferPreviousModelView和gbufferPreviousProjection,分别代表上一帧的cameraPosition、gbufferModelView和gbufferProjection,可以用于计算其在屏幕坐标系中的位置.

不用多说了,上车吧:

uniform vec3 cameraPosition;
uniform vec3 previousCameraPosition;
uniform mat4 gbufferModelViewInverse;
uniform mat4 gbufferPreviousModelView;
uniform mat4 gbufferPreviousProjection;

#define MOTIONBLUR_THRESHOLD 0.01
#define MOTIONBLUR_MAX 0.21
#define MOTIONBLUR_STRENGTH 0.5
#define MOTIONBLUR_SAMPLE 5

vec3 motionBlur(vec3 color, vec2 uv, vec4 viewPosition) {
	vec4 worldPosition = gbufferModelViewInverse * viewPosition + vec4(cameraPosition, 0.0);
	vec4 prevClipPosition = gbufferPreviousProjection * gbufferPreviousModelView * (worldPosition - vec4(previousCameraPosition, 0.0));
	vec4 prevNdcPosition = prevClipPosition / prevClipPosition.w;
	vec2 prevUv = (prevNdcPosition * 0.5 + 0.5).st;
	vec2 delta = uv - prevUv;
	float dist = length(delta);
	if(dist > MOTIONBLUR_THRESHOLD)
	{
		delta = normalize(delta);
		dist = min(dist, MOTIONBLUR_MAX) - MOTIONBLUR_THRESHOLD;
		dist *= MOTIONBLUR_STRENGTH;
		delta *= dist / float(MOTIONBLUR_SAMPLE);
		int sampleNum = 1;
		for(int i = 0; i < MOTIONBLUR_SAMPLE; i++)
		{
			uv += delta;
			if(uv.s <= 0.0 || uv.s >= 1.0 || uv.t <= 0.0 || uv.t >= 1.0)
				break;
			color += texture2D(colortex1, uv).rgb;
			sampleNum++;
		}
		color /= float(sampleNum);
	}
	return color;
}

//...main函数 {
color = motionBlur(color, texcoord.st, viewPosition);
//...}

运动模糊的代码包含了4个控制量,MOTIONBLUR_THRESHOLD表示要引发动态模糊时,所需在屏幕空间上最小的移动距离;MOTIONBLUR_MAX表示最大效果时的移动距离,必须要大于MOTIONBLUR_THRESHOLD,如果这个值过大的话,在迅速切换镜头(比如F5,或者/tp)时会引发一瞬间的画面撕裂感;MOTIONBLUR_STRENGTH是一个强度系数,决定模糊强度;MOTIONBLUR_SAMPLE是采样次数,其实这个不是很重要,5次甚至都有点偏多了,你可以考虑降到4次.
在代码中,我们先将像素对应的眼坐标系位置变换到真正的世界坐标系(在光照坐标系中加上cameraPosition),然后一路变换到上一帧对应的NDC坐标,然后算出在屏幕坐标系中的位置.将两坐标之差作为径向矢量,最后是一段玄学的径向模糊.循环中的坐标位置判断是有必要的,如果去掉它,会导致屏幕边缘有严重的黑边.

2016-02-14_20.21.54

至此,本文最后一个特效也制作完了(散花?),现在我们有了一个基本能看的光影包,诚然,它还有很多缺陷,但至少我们已经迈出了第一步,市面上最重要的两个光影包:Chocapic13的光影包和Sonic Ether的SEUS都是经过无数个版本的千锤百炼才达到了今天的效果,远不是一小时的复制粘贴能实现的,事实上,我相信除了ZiWei,没有什么是不学就会的 ? 图形学是个计算机中的小数学,数学中的小计算机,与其他计算机衍生学科相比,它更需要扎实的基础,和无数个昼夜的奋斗与经验积累.最后,你可以在这下载到完整的光影包,但是不包括之前那个屎绿色颜色调整,DOF的近景模糊也被注释掉了. [SkyDrive] [百度网盘]

2016-02-14_21.13.02
提问时间:考验MMD厨的时刻到了,请问这是哪个舞蹈呢? 提示:开始时是背向观众

后记

"一生倾注于文字 - 但它显然徒劳
在保存逝去的事物.
因为在我死后不能想象的未来
谁还会去读?"
-John Updike

我认为,撰写序言和后记大概是写作时最大的乐趣所在,有人用微笑曲线来比喻完成一件工作时最大的乐趣是在工作开始和结束时,毕竟从开头到结束的路途往往异常坎坷,在陶瓷国古人云"行百里者半九十",在西方有人提出过"圆周理论",指出一项工程的实际耗时往往是理想预期中的3倍 - 刚好是圆的直径与周长的比例,显然无论古今中外,填坑比挖坑要难是公认的.老实说,这篇文章在最后阶段的冲刺经历并不是很愉快,一边忍受着Winter Blue时的寻死觅活,一边面对着一次次被打破的自设Deadline,看着屏幕上支离破碎的图形无论如何调整也总是无法绘制正确,这种绝望感几次让我险些放弃,对程序员来说,"It just doesn't work"是最让人崩溃的情景,幸好我挺过来了! ☺

在当初撰写教程时的上百个日夜中,我曾想"等写到后记时我一定要好好吐槽一番..." 然而当我真写到这部分时却是无语凝噎,或许当时我真该把想说的话记下来 ? 为什么要写这篇教程啊,Shadersmod的光影包本身就是一个很小众的"市场"呢,或许是因为当初我单纯想把自己研究Shadersmod的经验记下来,又或是纯粹为了憋一通字装逼?大概是因为我喜欢追求没有人做过的事吧.四年前的初春时我也在撰写一篇教程,也同样是作为一个刚刚入门的半瓶子晃荡的新人在作死地撰写一篇专业文章,当时我在那篇教程中写出过一些啼笑皆非的结论(还好都被及时改掉了w),我相信这篇教程里或多或少也会有些不足,事实上,本文的上半部分在截稿时和撰稿之初已有一些差异,这篇文章的写作过程对我而言也是一次学习的过程,正如那句玩笑"学习一个语言的最好办法是去写一个它的解析器",学习一个事物的最好方式或许是写一部它的教程,因为这会强迫你事无巨细地了解它的每一方面,如果不求甚解的话,我大可大段地从别处复制粘贴代码到我的光影包,幸好我并没有那样做,而是选择尽可能将那些晦涩难懂的原理用朴实的语言转述出来,有人将之称为"二手知识",比喻为"吃别人嚼烂了吐出来的苹果",而我更乐意将其比喻做"吃别人烹调出的熟食".

诚然,如果我们每个人都有一个极漫长的寿命,那么学习很可能会被作为一门艺术,或许我们可以回到像古希腊的学院一样,让学生们与名师面对面以讨论的方式学习,我们将有无穷无尽的时间,去钻研我们喜爱的学科,然而现实是社会为教育分配的时间已经快到了人的"性价比"的底线了,在这个知识和信息都在爆炸的年代,学习已经成了我们日常生活中,像呼吸、吃饭、撸-唔...我是说"锻炼",像锻炼一样重要的事情.尽管学习没有捷径,但我们都知道在两点间位移不变的情况下,是可以有无数种路程的,找出一条路程最短的轨迹,是教育者们永恒的追求,我写出这套"二手知识",也是为了降低入门难度,让更多人能更快地跳入图形学这个大坑 (笑)

说起来,当时我又是怎么跳进这个大坑的啊,我印象中是13年10月,当时ici2cc给我发了个截图,是在Minecraft中渲染一个超低清的⑨,没错,这个就是后来的CustomSteve的原型,当时问我搞不搞,我当然是滋词,于是就跳入大坑了,当时是学着用传统的立即模式在Minecraft中绘制模型,具体的过程记不清了,总之当我在屏幕上终于绘制出一个不畸形的没贴图的小⑤时,我整个人都感觉生无可恋了(笑^2) 或许这种"视觉刺激"远超过之前写过的任何程序吧,至此我就决定去跳这个大坑了,然而那时我还仅停留在OpenGL的API使用上,对底层的原理和更复杂的着色算法还一无所知,就这样混混沌沌地到了2015年的8月,我决定写这篇教程为止,那么现在呢?唔...我想是只学到了一个,就是从当初的什么都不知道,到现在的"知道自己什么都不知道" ? 在这段学习过程中,恐怕是为数不多的在考前复习之外的情境中,体会到"当我学得越多,觉得自己懂得越少"的感觉了.

考虑到这段后记正有向学期总结发展的趋势,我们还是来聊一些"严肃"的话题吧,或许你读完这篇教程后依然不能写出一个基本的着色器(当然我希望我的读者们并不是随便沿着超链接爬行的网络醉汉),也许你依然无法理解那些理论,但至少,我希望你能做到不惑,曾几何时计算机系统(但不要提计算机系统结构!那科我挂了!5天后的2月19日我还要去补考!)在外行眼中甚至如同本文开篇提到的"黑魔法"一样,就像祖父辈的人曾认为电灯要靠人点火,收音机里面藏了个人似的,而现如今虽然计算机与它的知识已经普及,但在这个巨大的学科当中不同分支间的隔阂也日益加剧,单是图形系统这一部分,对于日常工作不涉及到的人来说,也几乎与"蛊术"无疑,或许你读完这篇文章中不会去真的动手写一个光影包(如果你真的要这样做的话,小心那很累哦),但至少你不会再困惑于那些神奇的光影魔法是怎样做到的,不会再在UnityAssetStore上花几十刀买了一个五毛特效还感激涕零地给个好评. ?

话题就这样莫名转到了U3D的黑枪上,我有个习惯是差不多每隔几个月就去跑一趟北京图书大厦,从新书的主题上就可以大致看出时下流行的技术,不过由于写作周期的制约,往往是在某项技术大火后的几个月才会有成书,比如Gradle是在去年9月才有了第一本中文书,然而这对U3D却不是问题,自从在14年初占领了大半个游戏类书架(我在此文中还吐槽过)后,你几乎可以仅凭U3D图书的标题就能知道U3D最新的版本号,足以精确到minor.相比之下,UE我在两年前看到过一两本针对UE3的,针对UE4的我好像没有见到,就连两只脚已经全踏进棺材的XNA都尚有一本书在架(虽然没有看它的封面,不知道是不是那本鱼书),而当初在2D领域与U3D平起平坐的Cocos2d-x现在也只剩寥寥几本了,看起来当年在坛子上一个萌二说的"今后游戏界将只剩下U3D一个引擎"预言快要成真了?好吧,我不知道这会不会成为现实,但我可以肯定André LaMothe在《Windows游戏编程大师技巧》中的结论"一个初学者能在3~6个月中开发出一个Asteroids(爆破彗星)的复刻版就已经很不容易了"已经失效了,甚至于这句话的上一句,"想要开发一个和DOOM或Quake水平相当的游戏作为处女作,显然这是不可能的",都已经快要失效,软件工程的荒蛮时代已经过去,我们可以尽情地站在前人的成果上攀登高塔,能有一个降低门槛、节省时间的东西,何乐而不为呢?既然如此,那为何现在还会有人在学习底层的技术?显然我们的目的不仅仅是省一笔钱,"在一个免费的3A级游戏引擎横行的时代,我们的工作还有意义吗",这是JME引擎 - 一个老牌Java游戏引擎 - 的开发者之一,在去年UE4和U3D宣布免费后,于官博上发的一篇文章的标题,在描述二线引擎存在的意义时,作者写道"如果Unity和UE是游戏开发中的乐高玩具,那么JME就是一个怪蜀黍,给了你一把能打开他的塞满各种电动机床和裸露接线的工作间的钥匙,并且嘱咐你'拜托别把自己命玩进去'(当然,这只是一种逗闷子的夸张比喻,事实上我们是很重视用户体验的).大多数孩子在面对这种程度的自由时都会感到无所适从,但总会有人能做出在安全范围内几乎不可想象的东西."U3D、UE4作为一线的免费引擎,总能满足大部分人的需求,但是总会有人在寻求另一种可能,在两极争霸(甚至更糟的,单极称霸)的背景下开辟一个新的理想国;总会有人愿意去研究终不见天日的底层,为最重要却又最不引人瞩目的基础件添砖加瓦;也总会有人愿意牺牲时间将自己的成果或经验总结成文字、图片、视频或音频,以各种方式传授给后来者.有一段时间我也在给一个Java游戏引擎(但不是JME,猜猜是哪个?☺)写基础件,但工作还是差最后一点,但愿尽快能完成吧.最近一次去书店看到那个引擎居然已经有两本中文书了呢,可喜可贺啊!

刚才说到"另一种可能",其实我也经常在想我的人生的其他可能,如果我从未学过编程,现在我会在干什么?如果当年填志愿时没有在最后时刻清醒过来把专业改回计算机,而是继续脑抽报材料的话,我现在会不会就该王八看绿豆似的盯着炉子玩真人MC呢?如果当初中考没爆豆,而是像我那些初中同学那样上职高的话,我现在是不是就该工作了?如果当初我没接触过东方的话...淦,这个不会发生的 ? 当初曾看到个问自己最想做的职业,我说的是"大学老师,画家,作家",有人吐槽说你就不想当程序员吗?我就地装了个逼:"编程对我来说就像呼吸一样正常,你会把呼吸当做职业吗?" 咳咳...装逼归装逼,了解自己想做什么和能做什么的差别是痛苦但却必要的,而且我从未相信自己能成为一线的码农,说不定哪天当我发现自己数学功底真的不够时,就得回去摆弄看不见的东西(其实也并不糟,前一段时间在写一个解释器,感觉也蛮有意思的),甚至更糟的,改去做企业应用了,可怜我这数字传媒方向啊...

不管怎样,碎碎念就到此为止了,但是本文仍未就此结束,还有许多重要的附录在下一页,我相信对于想开发光影包的人来说是必不可少的.祝各位c♂ding happily!

哎呀,差一点忘了Credits~

感谢这些人:
daxnitro与Karyonix - 光影Mod的原作者和现在的维护者,虽然你俩的光影Mod有那么多缺陷不足和Bug,但依然不妨碍它成为一个好Mod. ?
Chocapic13 - "模范光影包"的作者,拜你宽松的协议所赐,你的光影包成了80%+的光影包的模板,虽然我没有用. ?
Sonic Ether - SEUS的作者,你的光影包几乎成了标杆,代表着MC光影界的最高水准. ?
Inigo Quilez - Shadertoy的站长,感谢你的体积云光照和3D噪音算法,以及你和Pol Jeremias搭建的那么好的一个在线着色器分享网站,虽然从1月份的那次更新起有像小游戏平台发展的趋势. ?
ici2cc - CustomSteve的大♂老♂板,作为一个严厉的监工(雾),感谢你批准我放下CustomSteve(还有你那个最新的枪战Mod,叫什么来的?)的工作专注于撰写这篇文章,以及在撰文过程中的精神支持.顺便拜考前你给我立的那个大Flag所赐,我挂科了! ?
エボシ - 恋恋的模型作者,没有你的模型我怎么能在工作时保持兴♂奋呢? ?
许许多多的CGer - 没有你们的资料和对自己的经验与成果的无私分享,仅凭我的能力是写不出这篇文章的. ?
and you - 能完整的读完全文而没有Alt+F4,我满足了! ?
另外,本文中隐藏了数不胜数的彩蛋!你能发现它们吗?

szszss
2016-2-14