写在前面
在之前的基础篇中,我们讲到了在绘制点线时如何处理边缘的锯齿,也就是使用smoothstep函数。而模糊参数是一些定值,或者是跟屏幕分辨率相关的数值,例如分辨率宽度的5%等等。但这种方法其实是有一种问题的。这需要我们从绘制的图像说起。
ShaderToy中绘制的很多图像可以说是一种Procedure Texture,过程纹理,即是计算机生成的纹理。拿之前画的圆和线来说,这些圆和线的绘制过程,是我们计算每个fragment到“期望图像”的距离,然后根据距离来判断使用哪种颜色。如果这个距离是在欧式空间,即圆和线的例子中那样,那么直接使用定制或者和屏幕系数相关的值作为模糊参数是没有问题的。但一旦这个距离无法用欧氏距离来表示(有很多这样的情况,例如判断到一条正弦曲线的距离,就无法使用点到直线的距离这样简单的公式了),使用这种参数是无法得到很好的模糊效果的。在ShaderToy中,很多处理方法其实就是在进行图片处理工作。
这种时候,我们就可以利用另一个函数fwidth,和smoothstep等函数合作来达到抗锯齿的目的,而这种方法实际上更加通用。这里还将介绍一种新的函数clamp。
本篇涉及到的原ShaderToy中的例子主要有:
https://www.shadertoy.com/view/4ssSRl
https://www.shadertoy.com/view/ldsSRX
https://www.shadertoy.com/view/ldlSzS
https://www.shadertoy.com/view/MsjSzy
我整理的效果如下:
https://www.shadertoy.com/view/XtB3zw
https://www.shadertoy.com/view/4tB3zm
以及GPU Gems的一篇文章:
http://http.developer.nvidia.com/GPUGems/gpugems_ch24.html
纹理采样的抗锯齿
我们一定听说过抗锯齿这件事情,而不出意外的话,我们肯定也听说过多重采样这个抗锯齿途径。多重采样简单来说就是像素点的颜色是和其相邻的一些像素值有关的。我们把多重采样再一般化,实际上是是一个对图像求卷积的过程。输入图像A,经过某个定义的kernel进行卷积处理后,得到B,我们希望B中的锯齿尽可能少。很多GPU已经提供了一些快速的滤波操作,例如线性滤波等等。多重采样使用的kernel往往是一个长方形,每个点对应的权重是一样的,然后对原图像进行卷积,就可以输出抗锯齿后的图像B了。但是如果我们想要进行更复杂的滤波,就需要自定义kernel。当然,kernel的用处不仅仅是抗锯齿,还可以进行边界检测等等,我后面的文章可能会讲到。这里,我们仅讨论用于抗锯齿的kernel。
举个例子,我们要渲染一个有纹理的长方形,当知道了该像素对应的纹理UV坐标后,如果我们直接利用texture2D访问纹理,那么最终在长方形的边界就会出现明显锯齿。而如果我们利用多重采样的思想,就会在每次计算时,不仅仅对该像素对应的UV进行采样,还会调用多次texture2D函数,最后将结果相加取平均值。
常见的抗锯齿kernel有Box、Cubic等等。更多内容可以参考上面的GPU Gems中的文章。
复杂的滤波操作大都只依赖于,“我们应该对图像滤波多少(也可以理解成模糊多大的范围)”。很容易理解,例如在如果相邻像素的UV值是一样的,那么我们就不需要采样啦,也就是说,这时可以不进行滤波。而现代很多GPU提供了偏导数函数给我们,在GLSL中,这个函数就是fwidth。当调用这个函数时,我们相当于问GPU:“嘿伙计,我想知道,参数这个值在屏幕的横纵方向的像素之间变化了多少?”fwidth函数返回的是X和Y方向偏导数的绝对值的和,而单方向的偏导可以通过ddx和ddy函数得到,这里不涉及了。当然,这些函数由于和像素有关,因此只能在Fragment Shader中访问。
例如,当我们写下fwidth(myVar)时,GPU将会返回myVar这个值在当前像素和它的下一个相邻像素之间的差值(与X和Y方向上的下一个像素上该值差的绝对值和)。也就是说,这个值其实就是直线的线性差值。一旦我们知道了在当前像素上这个值的变化程度,我们就可以进行合适程度的滤波操作。因此,对于一张纹理才说,当我们给定它的UV坐标后,更恰当的方法是不仅仅用这个UV坐标直接采样,还应该考虑其周围方形区域内纹理采样的结果,而这个区域就是ddx和ddy给定的区域。幸运的是,当我们调用tex2D这样的函数时,系统背后已经为我们完成了这个操作。而在一些高级的profiles中,还会允许我们自定义滤波窗口的大小。我们以Unity中的代码为例:
float4 fragColor = tex2D(_MainTex, i.uv);
上述代码等价于:
float4 fragColor = tex2D(_MainTex, i.uv, ddx(i.uv), ddy(i.uv));
我们可以自定义滤波大小:
float4 fragColor = tex2D(_MainTex, i.uv, float2(0), float2(0));
上述代码意味着,我们强制滤波大小为0,也就是说,总是使用最近邻(Nearest-Neighbor)方法进行滤波,不考虑API状态和mipmaps。下面的图分别表示了滤波大小为*1,*5和*0的效果,注意场景视图中mipmap的使用情况,在大小为0是没有使用任何mipmap。
对于不支持这些函数的硬件,可以使用其他方法代替,有兴趣的可以看这篇文章:
http://http.developer.nvidia.com/GPUGems/gpugems_ch25.html
Point Style Sampling
上面的方法在point style sampling时同样可以抗锯齿,可以参见我在ShaderToy中写的一个合并版:https://www.shadertoy.com/view/XtB3zw。实现细节也请移步去那里看。这里只讲关键部分。下面表示了不同放缩程度下的效果:
这里我只分析下里面用到的不同的采样方法(依次对应从左到右的顺序)。代码如下:
vec4 AntiAlias_None(vec2 uv, vec2 texsize) { return texture2D(iChannel0, uv / texsize, -99999.0); } vec4 AntiAliasPointSampleTexture_None(vec2 uv, vec2 texsize) { return texture2D(iChannel0, (floor(uv+0.5)+0.5) / texsize, -99999.0); } vec4 AntiAliasPointSampleTexture_Smoothstep(vec2 uv, vec2 texsize) { vec2 w=fwidth(uv); return texture2D(iChannel0, (floor(uv)+0.5+smoothstep(0.5-w,0.5+w,fract(uv))) / texsize, -99999.0); } vec4 AntiAliasPointSampleTexture_Linear(vec2 uv, vec2 texsize) { vec2 w=fwidth(uv); return texture2D(iChannel0, (floor(uv)+0.5+clamp((fract(uv)-0.5+w)/w,0.,1.)) / texsize, -99999.0); } vec4 AntiAliasPointSampleTexture_ModifiedFractal(vec2 uv, vec2 texsize) { uv.xy -= 0.5; vec2 w=fwidth(uv); return texture2D(iChannel0, (floor(uv)+0.5+min(fract(uv)/min(w,1.0),1.0)) / texsize, -99999.0); }
- 左一:AntiAlias_None对应的是最普通的纹理采样,直接使用Vertex传过来的插值后UV坐标对纹理采样,没有进行point sample。
从效果来看,放大后的效果过于模糊。 - 左二:AntiAliasPointSampleTexture_None对应的是普通的点采样,即我们确保采样的是UV对应的纹理上某个定点像素的颜色,这是通过对UV值四舍五入得到整数值,再加上0.5偏移做到的。
从效果来看,放大后有明显的锯齿,缩小后纹理细节仍很多。 - 左三:AntiAliasPointSampleTexture_Smoothstep对应用fwidth+smoothstep进行点采样,这里的smoothstep是三次插值方法。在对UV值下取整之后,加上0.5偏移,之后还会利用进行smoothstep抗锯齿。fwidth的返回值表明UV值在该点和临近像素之间的变化,这个值帮助我们判断模糊的大小范围。最后根据UV的小数部分进行模糊。
从效果上来看,这种方法的模糊程度相比于后三种来说最高(也不一定都是好事)。 - 左四:AntiAliasPointSampleTexture_Linear对应用fwidth+clamp进行点采样,之所以叫线性方法(Linear)是由于clamp是一个线性插值方法。同样是对UV值下取整之后,加上0.5偏移,然后使用了除法比较来计算模糊区间。clamp方法的是将模糊边界直接与0和1比较得到。
从效果上来看,效果比上一种模糊程度小一点。 - 左五:AntiAliasPointSampleTexture_ModifiedFractal和上一种方法很像,不同的是它直接将UV小数部分和w相除判断结果。
总结一下,我们通过点采样的多种方法来演示如何使用fwidth+smoothstep+clamp进行抗锯齿,而这三者的组合也是非常常见的。
过程纹理的抗锯齿
对于使用过程纹理(Procedure Texture)的shaders来说,知道上面的知识是有助于我们理解如何进行抗锯齿的。
过程纹理往往都是通过一些函数来得到的,像ShaderToy中那样,因此抗锯齿往往就需要在像素间评估函数的值来实现。最简单的情况就是,我们可以在边界处取平均值,像多重采样那样。
考虑我们之前圆和直线的例子,其实就是只考虑了边界。当我们判断该像素在边界时,就将圆的颜色和其他颜色进行混合。但是,如一开始所说,这种抗锯齿方法仅适用于根据欧氏距离判断边界的情况,对于那些不能用欧式距离表示的距离函数来说,这种方法在边界处还是会产生锯齿。
我们还是来画画直线和圆好啦。只是这次我们要画一个由线段和圆组成的正四面体:线段组成了四面体的六条边,每个顶点用圆来表示。为了更具有一般性,我们改变一下之前计算直线和圆的函数:
float line(vec2 pos, vec2 point1, vec2 point2, float width) { vec2 dir0 = point2 - point1; vec2 dir1 = pos - point1; float h = clamp(dot(dir0, dir1)/dot(dir0, dir0), 0.0, 1.0); return (length(dir1 - dir0 * h) - width * 0.5); } float circle(vec2 pos, vec2 center, float radius) { float d = length(pos - center) - radius; return d; }
这里只返回距离值,不计算颜色,以便后面我们对比不同的反锯齿效果。绘制函数也有所改变:
vec2 originalPos = (2.0 * fragCoord - iResolution.xy)/iResolution.yy; vec2 pos = originalPos; // Twist // pos.x += 0.5 * sin(5.0 * pos.y); // Background vec3 col = _BackgroundColor.rgb * (1.0-0.2*length(originalPos)); float speed = 0.2; float l = 0.8; vec3 p0 = vec3(l, 0, pi * 0.5); vec3 p1 = vec3(l, pi * 0.5, pi); vec3 p2 = vec3(l, pi * 0.5, pi + pi * 0.66); vec3 p3 = vec3(l, pi * 0.5, pi + pi * 1.33); vec2 point0 = vec2(cos(p0.z), sin(p0.z)) * sin(p0.y) * p0.x; vec2 point1 = vec2(cos(p1.z), sin(p1.z)) * sin(p1.y) * p1.x; vec2 point2 = vec2(cos(p2.z), sin(p2.z)) * sin(p2.y) * p2.x; vec2 point3 = vec2(cos(p3.z), sin(p3.z)) * sin(p3.y) * p3.x; float d = line(pos, point0, point1, _LineWidth); d = min(d, line(pos, point1, point2, _LineWidth)); d = min(d, line(pos, point2, point3, _LineWidth)); d = min(d, line(pos, point0, point2, _LineWidth)); d = min(d, line(pos, point0, point3, _LineWidth)); d = min(d, line(pos, point1, point3, _LineWidth)); d = min(d, circle(pos, point0, _CircleRadius)); d = min(d, circle(pos, point1, _CircleRadius)); d = min(d, circle(pos, point2, _CircleRadius)); d = min(d, circle(pos, point3, _CircleRadius));
首先,我们把屏幕坐标变换到(-1,1)之间。然后初始化颜色为背景颜色。接着,我们用球面坐标系表示四面体的四个顶点p0~p3。其中,x分量表示了到球心的距离,y分量表示与Z轴的夹角,z分量表示与X轴的夹角。接着,把球面坐标系映射到XY平面内,得到二维点坐标point0~point3。这样就完成了准备工作。接着,我们以此判断距离六条线段和四个顶点圆的距离,取最小值。
接下来就是抗锯齿的部分:
if (originalPos.x < split.x) { col = mix(_OutlineColor.rgb, col, step(0, d - _OutlineWidth)); col = mix(_LineColor.rgb, col, step(0, d)); } else if (originalPos.y > split.y) { float w = _Antialias; col = mix(_OutlineColor.rgb, col, smoothstep(-w, w, d - _OutlineWidth)); col = mix(_LineColor.rgb, col, smoothstep(-w, w, d)); } else { float w = fwidth(0.5*d) * 2.0; col = mix(_OutlineColor.rgb, col, smoothstep(-w, w, d - _OutlineWidth)); col = mix(_LineColor.rgb, col, smoothstep(-w, w, d)); } // Draw split lines col = mix(vec3(0), col, smoothstep(0.005, 0.007, abs(originalPos.x - split.x))); col = mix(col, vec3(0), (1 - smoothstep(0.005, 0.007, abs(originalPos.y - split.y))) * step(split.x, originalPos.x));
为了对比效果,我们把屏幕分割成三个部分:屏幕左半侧不做任何抗锯齿处理,即直接根据距离值(这里还绘制了一条轮廓线,所以多了一行)判断使用哪种颜色;屏幕右半侧又分了两个部分,上半部分使用我们之前的定制处理方法,即根据抗锯齿参数_Antialias来决定模糊的范围;下面部分我们使用新的函数fwidth来处理锯齿。最后两行是用于绘制分割线的。
保存后,我们可以得到下面的效果:
为了更好的效果,我们可以添加时间参数让它旋转起来,并且接受鼠标事件来移动分割线的位置,鼠标事件的使用可以参见更新后的开篇。完整的代码如下:
Shader "shadertoy/AA Line" { Properties{ _CircleRadius ("Circle Radius", Range(0, 0.1)) = 0.05 _OutlineWidth ("Outline Width", Range(0, 0.1)) = 0.01 _OutlineColor ("Outline Color", Color) = (1, 1, 1, 1) _LineWidth ("Line Width", Range(0, 0.1)) = 0.01 _LineColor ("Line Color", Color) = (1, 1, 1, 1) _Antialias ("Antialias Factor", Range(0, 0.05)) = 0.01 _BackgroundColor ("Background Color", Color) = (1, 1, 1, 1) iMouse ("Mouse Pos", Vector) = (100,100,0,0) iChannel0("iChannel0", 2D) = "white" {} iChannelResolution0 ("iChannelResolution0", Vector) = (100,100,0,0) } CGINCLUDE #include "UnityCG.cginc" #pragma target 3.0 #pragma glsl #define vec2 float2 #define vec3 float3 #define vec4 float4 #define mat2 float2x2 #define iGlobalTime _Time.y #define mod fmod #define mix lerp #define atan atan2 #define fract frac #define texture2D tex2D // 屏幕的尺寸 #define iResolution _ScreenParams // 屏幕中的坐标,以pixel为单位 #define gl_FragCoord ((_iParam.srcPos.xy/_iParam.srcPos.w)*_ScreenParams.xy) #define PI2 6.28318530718 #define pi 3.14159265358979 #define halfpi (pi * 0.5) #define oneoverpi (1.0 / pi) float _CircleRadius; float _OutlineWidth; float4 _OutlineColor; float _LineWidth; float4 _LineColor; float _Antialias; float4 _BackgroundColor; fixed4 iMouse; sampler2D iChannel0; fixed4 iChannelResolution0; struct v2f { float4 pos : SV_POSITION; float4 srcPos : TEXCOORD0; }; // precision highp float; v2f vert(appdata_base v) { v2f o; o.pos = mul (UNITY_MATRIX_MVP, v.vertex); o.srcPos = ComputeScreenPos(o.pos); return o; } vec4 main(vec2 fragCoord); fixed4 frag(v2f _iParam) : COLOR0 { vec2 fragCoord = gl_FragCoord; return main(gl_FragCoord); } float line(vec2 pos, vec2 point1, vec2 point2, float width) { vec2 dir0 = point2 - point1; vec2 dir1 = pos - point1; float h = clamp(dot(dir0, dir1)/dot(dir0, dir0), 0.0, 1.0); return (length(dir1 - dir0 * h) - width * 0.5); } float circle(vec2 pos, vec2 center, float radius) { float d = length(pos - center) - radius; return d; } vec4 main(vec2 fragCoord) { vec2 originalPos = (2.0 * fragCoord - iResolution.xy)/iResolution.yy; vec2 pos = originalPos; // Twist // pos.x += 0.5 * sin(5.0 * pos.y); vec2 split = vec2(0, 0); if (iMouse.z > 0.0) { split = (-iResolution.xy + 2.0 * iMouse.xy) / iResolution.yy; } // Background vec3 col = _BackgroundColor.rgb * (1.0-0.2*length(originalPos)); float speed = 0.2; float l = 0.8; vec3 p0 = vec3(l, 0 + speed * iGlobalTime, pi * 0.5 + speed * iGlobalTime); vec3 p1 = vec3(l, pi * 0.5 +speed * iGlobalTime, pi +speed * iGlobalTime); vec3 p2 = vec3(l, pi * 0.5 +speed * iGlobalTime, pi + pi * 0.66 + speed * iGlobalTime); vec3 p3 = vec3(l, pi * 0.5 +speed * iGlobalTime, pi + pi * 1.33 + speed * iGlobalTime); vec2 point0 = vec2(cos(p0.z), sin(p0.z)) * sin(p0.y) * p0.x; vec2 point1 = vec2(cos(p1.z), sin(p1.z)) * sin(p1.y) * p1.x; vec2 point2 = vec2(cos(p2.z), sin(p2.z)) * sin(p2.y) * p2.x; vec2 point3 = vec2(cos(p3.z), sin(p3.z)) * sin(p3.y) * p3.x; float d = line(pos, point0, point1, _LineWidth); d = min(d, line(pos, point1, point2, _LineWidth)); d = min(d, line(pos, point2, point3, _LineWidth)); d = min(d, line(pos, point0, point2, _LineWidth)); d = min(d, line(pos, point0, point3, _LineWidth)); d = min(d, line(pos, point1, point3, _LineWidth)); d = min(d, circle(pos, point0, _CircleRadius)); d = min(d, circle(pos, point1, _CircleRadius)); d = min(d, circle(pos, point2, _CircleRadius)); d = min(d, circle(pos, point3, _CircleRadius)); if (originalPos.x < split.x) { col = mix(_OutlineColor.rgb, col, step(0, d - _OutlineWidth)); col = mix(_LineColor.rgb, col, step(0, d)); } else if (originalPos.y > split.y) { float w = _Antialias; col = mix(_OutlineColor.rgb, col, smoothstep(-w, w, d - _OutlineWidth)); col = mix(_LineColor.rgb, col, smoothstep(-w, w, d)); } else { float w = fwidth(0.5*d) * 2.0; col = mix(_OutlineColor.rgb, col, smoothstep(-w, w, d - _OutlineWidth)); col = mix(_LineColor.rgb, col, smoothstep(-w, w, d)); } // Draw split lines col = mix(vec3(0), col, smoothstep(0.005, 0.007, abs(originalPos.x - split.x))); col = mix(col, vec3(0), (1 - smoothstep(0.005, 0.007, abs(originalPos.y - split.y))) * step(split.x, originalPos.x)); return vec4(col, 1.0); } ENDCG SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma fragmentoption ARB_precision_hint_fastest ENDCG } } FallBack Off }
想看动态效果的可以直接看ShaderToy中的效果:https://www.shadertoy.com/view/4tB3zm
对比结果
上面使用的距离其实还是欧式距离,因此使用定值或是fwidth看起来似乎没什么差别。但是,如果我们对它做少许改变,例如取消下面的注释:
// Twist // pos.x += 0.5 * sin(5.0 * pos.y);
就会发现两者的差异了:
右下角使用fwidth的区域仍然可以保持良好的抗锯齿状态,而右上角已经开始出现一定量的锯齿了。虽然我们可以通过调大定值_Antialias的方法来增大模糊范围,但这样会造成有些区域过于模糊,而且这种根据情况来调整定值的方法会很麻烦。而使用fwidth的话,我们就不需要考虑这些因素,因为它是直接得到和临近像素之间的差值,不依赖与欧氏距离。
写在最后
这篇文章老实说来有点杂,我尽我可能想把问题阐述清楚了。。。总结一下,这篇主要想要介绍fwidth这个函数在抗锯齿方面的应用,除此之外还介绍了clamp,当然我个人还是比较喜欢用smoothstep。。。但一个是线性插值的,一个的三次插值的,性能上有少许差别。效果上也有一点思维差别,这要根据项目需要有所选择。
好啦,下一篇我想写下各种插值曲线的细节,包括贝塞尔曲线,Catmull-Rom样条等等。我自己也总是忘。。。写一篇备忘一下。