GLSL Shader特效教程2:平面几何变换
在学习新课之前,我们先回顾和总结一下上个教程中遮罩特效的相关内容。遮罩的基本思想是在原来采样到的纹理(texture)上进行第二次裁剪过滤,其方法包括过滤(filter)、重映射(remapping)、缩放(scale)、变换(transform)等。 纹理(texture)实际上就是RGBA值,可以理解为Photoshop(PS)中的颜色。PS中处理颜色的方法大多都能应用在Shader中,例如混合、渐变、拉伸、过滤(如高斯模糊、风化效果、运动模糊等)、边缘检测、发光、全景视图等。以混合(A、B)为例,可以用公式C = p A + q B来表示,其中p和q参数表示对原来A、B纹理的采样,这些采样可以是经过渐变、拉伸、模糊等处理后得到的纹理。 纹理的采样可以进行多次,合理利用能够产生不同的噪声(noise)效果。例如,使用一张简单的纹理,经过多次采样和变形后,可以模拟云层、下雨、下雪、烟雾、火焰等复杂的特效,关键在于如何运用这些操作。就像PS在不同人手中能做出不同的效果一样。 在遮罩特效中,我们使用了一张被遮罩的背景图片和一个几何圆作为遮罩。通常情况下,被遮罩的背景图片基本保持不变,而遮罩的形状可能会发生变化,例如希望将其变为长方形、椭圆形等其他规则形状,这些规则形状都可以用一定的平面几何来表示。但如果希望遮罩是不规则的呢? 以擦玻璃的效果为例,我们可以直接使用第一节中的圆形遮罩来实现。然而在现实中,擦玻璃(或拨开雪地里的雪)时擦掉的部分并非完美的圆形。此时,如何处理才能让效果看起来更逼真呢?如果想到在原来的圆形上进行噪声采样,思路基本是正确的。但有些效果仅依靠数学几何和噪声很难实现,即便实现了,也可能会导致GPU计算量过大,从而降低效率。 最快速的方法是让美工根据擦玻璃或拨开雪地时的痕迹制作一张遮罩(mask)图片。在Shader中使用背景(background)和遮罩的纹理,根据需求进行混合变换、采样过滤,以达到预期效果。这种方法不仅快捷,而且高效。不过,缺点是多了一张遮罩纹理会占用GPU的内存,这也是有时需要限制纹理大小的原因。 GPU的运算量与显示大小有关。片段着色器(Fragment Shader,以下简称FS)会对每个像素进行计算,显示尺寸越大,计算量也就越大。在GPU绘制场景时,往往需要计算各种视图变换的矩阵,而这些变换在同一帧中是相同的。但GPU在渲染每个像素时都需要计算一次(GPU是并行计算的),这会造成很大的浪费,在一定程度上形成了瓶颈。后来提出了接口块(Interface blocks)技术,统一缓冲区对象(UBO)就是该技术的一个应用。在Shader计算中,可以将这些相同的变换计算放在接口块中传递给Shader,避免GPU在渲染每个像素时进行重复计算。可以通俗地将接口块技术理解为CPU中的缓存(cache)或多线程编程中的内存共享区,但接口块技术仅在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
表示片段着色器(FS)中的坐标,分别为x、y、z、1/w;resolution
表示显示尺寸的大小,通常是窗口屏幕的大小。
操作A得到的值在[0.0, 1.0]之间,这与纹理坐标一致,所以用uv
表示(当然,也可以使用其他符号)。操作B将uv
从[0.0, 1.0]映射到[-1.0, 1.0]之间,熟悉OpenGL的同学知道,这是归一化设备坐标(NDC)。这样,操作A和B就完成了从像素坐标到NDC坐标的变换(这两个变换并非必需)。操作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
float drawCir(vec2 pos, vec2 centre, float radius) {
float dist = distance(pos, centre);
if (dist < radius)
return 1.0;
else
return 0.0;
}
这样就可以在NDC坐标中画出位置在centre
、半径为radius
的圆。但这个圆会有非常明显的锯齿,原因是刚好落在圆环(圆周上)的像素点和刚好落在圆外的像素点值落差太大,前者为1.0,后者为0.0,就像电平一样,从1.0直接下降到0.0,中间没有过渡带。在屏幕上直接画一条直线也会出现明显的锯齿。
因此,需要对其进行平滑处理:
// draw cir
float 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
float 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
float 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进行旋转,较少旋转坐标系。这两者的关系就像坐火车时,既可以选择火车作为参考系(此时地面往后退,角度为负值),也可以选择地面作为参考系(火车前进,角度为正值),但我们习惯以地面为参考系,所以使用坐标轴旋转时会感觉不太自然。不过,方式a的好处在于,如果场景中有多个物体都需要进行相同的旋转,只需要旋转一次坐标系即可,而方式b则需要对每个物体分别进行旋转。随着学习的深入,会发现旋转坐标系更合适,往往能实现意想不到的效果,这就是所谓的“空间扭曲”。例如,对x、y轴进行正弦函数变换,相当于将空间扭曲成一个正弦周期的函数空间,可以使用一些特殊的采样函数来扭曲Shader的坐标系,实现非常梦幻的平面效果。
总结
- 矩阵变换:OpenGL和OpenGL ES采用的是右手、列主序矩阵。
- 旋转的参考系:了解不同旋转参考系的特点和应用场景。
- 空间坐标系扭曲:通过对坐标系进行变换实现特殊的效果。
- Shader平滑处理:在Shader中进行平滑化处理,避免锯齿现象。