再学习新课之前,我们先回顾和总结一下上个教程遮罩特效所讲的内容,遮罩它的基本思想就是在,原来采样到的texture上进行第二次裁剪过滤,方法可以是filter、remapping、scale、transform等。
texture实际上就是rgba,可以理解为ps中的颜色。在ps中颜色的处理方法都可以应用在shader中,例如:混合、渐变、拉伸、过滤(高斯、风化、运动模糊等)、边缘检测、发光、全景视图等。好比混合(A、B),可以用C=p*A+q*B来表示,p和q参数,表示对原来A、B 的texture做采样,可以是经过渐变、拉伸、模糊等之后得到的texture。
texture的采样可以进行多次,利用好可以产生 出不同的noise效果。例如使用一张简单的texture,经过多次采样、变形后,可以模拟云层、下雨、下雪、烟雾、火焰等复杂的特效。关键在于你如何使用。都是PS,在不同的人手里可以P出不同的效果。
在遮罩特效中,使用了一张被遮罩的背景图片和一个几何圆来做遮罩。被遮罩的背景图片基本上不会变的,遮罩的圆形有可能会变化,例如希望是一个长方形,椭圆形等其他形态。只要是规则的都可以使用一定的平面几何来表示。但是如果希望遮罩是不规则的呢?
例如擦玻璃的效果,你可以直接使用第一节中的圆形遮罩来实现。但在现实中擦玻璃(或拨开雪地里的雪),擦掉的部分并不是完美的圆形。这时应该如何处理使得效果看起来更为逼真?如果你想到了在原来的圆形上进行noise采样,思路上基本正确。但有时候有些效果使用数学几何和noise都很难为力,即使实现了,有可能会导致GPU的计算量过大而导致效率下降。
最快速的方法是让美工根据擦玻璃或者拨开雪地时的痕迹p出一张mask。使用background和mask的texture在shader中,根据需求做混合变换、采样过滤后,来达到预期的效果,不但快捷,而且高效。不足的地方在于多了一张mask的texture占用了GPU的内存。这也是为什么有时候需要限制texture的大小。
GPU的运算量是和显示的大小有关系的。fragment shader会对每一个像素进行计算,显示的尺寸越大,计算量也就越大。由于GPU在绘制场景的时候,往往需要计算各种视图变换的矩阵。而这些变换在同一帧中,是一样的。但是GPU在渲染每个像素的时候都需要计算一次(GPU是并行的),非常浪费。在一定程度上造成了瓶颈。后来提出了Interface blocks技术,UBO就是其中的一个应用。在shader的计算中,可以把这些相同的变换等计算放在Interface blocks中传递给shader,避免GPU在渲染的时候,对每次像素都做重复的计算。你可以通俗地把Interface blocks技术理解为CPU中的cache或者多线程编程中的内存共享区。但是Interface blocks技术在ES 3.00以上的版本才支持。
好像跑题了,回到今天的话题:
几何变换
在shader中经常看到如下的代码:
vec2 uv = gl_FragCoord.xy / resolution.xy; //A;
vec2 pos = uv * 2.0 - 1.0; //B;
pos.x *= resolution.x / resolution.y; //C
gl_FragCoord表示的是fragment shader(以后使用FS来表示)中的坐标,分别是x、y、z、1/w;
resolution表示的是显示尺寸的大小,一般是窗口屏幕的大小。
因此A操作得到的值是[0.0, 1.0]之间。这和texture坐标是一样的,所以使用uv来 表示。(当然,你可以使用别的符号来表示)
B操作,把uv从[0.0, 1.0]映射到[-1.0, 1.0]之间。对opengl熟悉的同学,知道这是NDC的坐标。可以理解为坐标NDC化。
这样A和B变完成了从像素坐标到DNC坐标的变换。(这2个变换不是必须的)C主要是为了调整纵横比。
如果使用uv坐标来表示,视图窗口的左下角为笛卡尔坐标系的原点,u即x轴, v为y轴。x、y的范围在[0.0, 1.0]之间。如果使用NDC(pos)坐标来表示,视图窗口的中央为笛卡尔坐标系的原点。
下面以NDC坐标为例,在圆心centre(0.5, 0.0)的位置画一个半径为0.3的圆:
// in main
c = drawCir(pos, centre, 0.3);
gl_FragColor = vec4(c);
// draw cir
drawCir(vec2 pos, vec2 centre, float radius)
{
float dist = distance(pos, centre);
if (dist < radius)
return 1.0;
else
return 0.0;
}
这样变可以在DNC坐标中,画出位置在centre,半径为r的圆。虽然这个圆有点难看,你会看到非常明显的锯齿。造成锯齿的原因是刚好落在圆环(圆周上)的像素点和刚好落在圆之外的像素点值落差太大。即前者为1.0,或者是0.0;好比电平一样,一下子从1.0下降到了0.0,中间没有过渡带。直接在屏幕上一条直线也是一样,会看到很明显的锯齿。
因此要对它做平滑处理:
// draw cir
drawCir(vec2 pos, vec2 centre, float radius)
{
float dist = distance(pos, centre);
if (dist < radius)
return 1.0-dist/radius;
else
return 0.0;
}
这样就好看多了,但是,总觉得不是很亮,而且明到暗的过渡带太长了。
// draw cir
drawCir(vec2 pos, vec2 centre, float radius)
{
float dist = distance(pos, centre);
if (dist < radius)
return 1.0 - pow(dist/radius, 3.0);
else
return 0.0;
}
这样变亮了很多,但是如果希望控制明暗的阀值和下降的快慢,可以这么写:
// draw cir
drawCir(vec2 pos, vec2 centre, float radius)
{
float dist = distance(pos, centre);
if (dist < radius)
return smoothstep(begin, end, 1.0 - pow(dist/radius, 3.0));
else
return 0.0;
}
begin和end控制阀值,两者之差控制下降的快慢。
好的,现在希望圆画任意一个位置,好比(0.5,0.5)或者说希望圆随着时间的变化出现在不同的位置,或者围绕原点做圆周运动。因此我们需要对其做平移变换。
我们选用矩阵来实现平面几何的坐标变换。可以通过旋转坐标系来实现,也可以通过旋转圆在坐标系中的位置来实现。
平面旋转:
vec2 rota(vec2 pos, float rad)
{
float c = cos(rad);
float s = sin(rad);
mat2 m = mat2(c, -s, s, c);
return pos*m;
}
具体推导过程,自己推导下,有利于加深对矩阵和坐标系的理解。建议采用极坐标系来推导,推导会非常简洁明了。
a、通过旋转NDC坐标系来达到效果(使圆逆时针旋转45度):
// in main
pos = rota(pos, -45.0);
c = drawCir(pos, centre, 0.3);
gl_FragColor = vec4(c);
b、通过旋转圆心来达到效果:
// in main
c = drawCir(pos, rota(centre, 45.0), 0.3);
gl_FragColor = vec4(c);
细心的就可以看到前者旋转了-45度,后者是+45度。
在平面几何里,我们一般都是采用b来进行旋转的。很少会旋转坐标系。这2者的关系好比坐火车时,你可以选用火车作为参考系(地面往后面退,故为负值),也可以使用地面来做参考系(火车在前进,故为正)。但我们都习惯使用地面来做参考系。所以使用坐标轴来旋转的时候,总感觉别扭。但使用a的好处在于,如果场景里有很多物体,都需要做相同的旋转时,只需要旋转一次,便可以了。而后者就要对每一个做旋转。当然,这只是一个比较直观的例子,随着深入,你会发现旋转坐标系更合适,往往可以做出意想不到的效果来。这就是所谓的“空间扭曲”。例如你对x、y轴做一个sin函数的变换。相当于你把空间扭曲成了一个sin周期的函数空间。你可以使用一些特别的sampling函数,扭曲shader的坐标系,来实现一些非常梦幻的平面效果。
总结下:
1、矩阵变换,opengl、es采用的是右手、列主序矩阵;
2、旋转的参考系;
3、空间坐标系扭曲;
4、shader中如何平滑化,避免锯齿。