这一节了解 WebGL 中的无顶点数据的图形绘制。什么是无顶点数据,也就是顶点着色器没有传入图形顶点,纯粹通过片元着色器绘制。大部分在线 Shader 编辑器如 Shadertoy、glslsandbox 都是通过这种模式来进行创作。
在没有顶点数据的前提下,如何通过控制像素点来绘制图形呢?首先明确我们在着色器中能利用的东西,以我们前面写到的片元着色器为例:
<script id="fragmentShader" type="x-shader/x-fragment">
precision mediump float;
varying vec2 v_TexCoord;
void main() {
gl_FragColor = vec4(v_TexCoord, 1., 1.);
}
</script>
这里会传入一个纹理坐标 v_TexCoord,它是一个二维向量 vec2(x, y),分别表示 x 轴和 y 轴从 0~1 的值。此时你可能会疑问为何不是 -1~1?因为纹理坐标的坐标系是以左下角为原点。
那么从 uv 里,我们可以拿到什么内容就是 uv.x 和 uv.y 所代表的水平坐标和垂直坐标的值。所以,我们可以通过 uv 来触达画布中所有的像素点。接下来我们来试试如何通过 uv 来绘制一条粗线:
See the Pen 1. Draw Line with Shader by ShaderFans (@shaderfans) on CodePen.
将代码拿出来看,逻辑其实很简单 —— 设置某个坐标区间的颜色值而已:
// 获取纹理坐标,命名为 uv
vec2 uv = v_TexCoord;
// 底色是黑色
vec3 color = vec3(0., 0., 0.);
// 当 y 轴坐标介于 0.75~0.8 时,颜色是白色的,反之,则都是黑色的
// 直线有多粗,取决于白色区间有多大
if (uv.y >= 0.75 && uv.y <= 0.8) {
// 向量运算:vec3(0,0,0) + vec3(1,1,1) = vec3(1,1,1)
color += vec3(1., 1., 1.);
}
gl_FragColor = vec4(color, 1.);
基于这个逻辑,我们可以通过色块的叠加,「拼装出」任意的图形:
比如我们仅仅通过简单的图形叠加和上色,就可以在片元着色器中绘制出英国国旗:
See the Pen 1. Draw Flag by ShaderFans (@shaderfans) on CodePen.
如何绘制出斜线呢?可以基于 y=x
这条公式的演变,得出斜线的区间:
y=-x
就是水平翻转后的另外一条斜线,然而在 Shader 中由于纹理坐标只是 0~1,无法使用 y=-x
这条公式得到想要的斜线(斜线不会经过第一象限,所以看不到)。所以解决方案就是通过 y=-x+1
得到位于第一象限,但是水平翻转的斜线(下面的图表是可以交互的,点击右下角 logo 可以跳转并查看公式)。
实际上,Shader 中内置了非常多的函数可以简化我们的很多操作,也许眼尖的你会发现国旗边缘有锯齿,我们放大来看看:
这个时候就可以使用一个内置函数 smoothstep(edge0, edge1, x) 来解决这个问题,它的作用是当 x 小于 edge0,取值为 0。当 x 大于 edge1,取值 1。在这个区间内,取 edge0 和 edge1 的插值。函数内部实现原理是利用了这个公式来计算 hermite 插值:t = (x - edge0) / (edge1 - edge0); y = t * t * (3.0 - 2.0 * t)
,只不过最终值只介于 0~1:
所以我们在边缘处使用这个函数进行平滑处理,即可淡化锯齿,那如何通过 smoothstep()
来绘制图形呢?其实可以利用它的特点:
通过这个内置函数的优化后,得到了一个抗锯齿效果:
下面的 demo 运用了一些颜色相关的运算,各位可以先忽略,我们会在后面小节介绍到颜色的处理。为了更好地了解每一步绘制细节,各位可以逐步注释代码查看效果(不妨试试你是否有更好的写法):
See the Pen 1. Draw Flag with smoothstep() by ShaderFans (@shaderfans) on CodePen.
实践到了现在,不知各位是否留意到,以往我们在数学中使用的枯燥公式,在 Shader 中仿佛如同拥有了灵魂,都能可视化地呈现在画布中。以往晦涩难懂的公式,终于可以在像素间形象地展现在你面前。
然而上面的写法不够聪明,可控性较差,未能发挥出数学的精华,所以就有了距离场 Distance Field 这种技术。
简单来说,我们把画布中每个像素都用一个数字来表达(除了已有的 rgba 色值之外),这个数字表示距离画布中的 2D 几何体表面的距离,基于这种逻辑,假如我们要在画布中展示一条横线,画布中数字就可以这么表示了(图右则为上色后的图案):
进一步扩展:假设要表示内空的图形,则需要再增加一个表示「内外」的数字 ,用来表示像素点是处于立体图形内部(负数)、表面上(0)、还是外部(正数)。
所以 Distance Field 可以区分为 Unsigned Distance Field 和 Signed Distance Field(SDF),后者比前者多了一个表示内外的数值,有了它我们可以分别做内外着色,以及描边的处理。
非常幸运都是,Shader 大佬 IQ 已经为我们提前写好了这些基础图形的 SDF 函数,按需使用即可:
也许你会有疑问,为何 IQ 写的图形看起来这么奇怪,图形轮廓有渐变,而且边缘还有一圈一圈的线,这是为了更清晰地展示图形的构造方式,实际使用的时候不需要考虑他们。这里我通过例子展示如何使用这些 SDF 函数:
See the Pen 1. shaping function by ShaderFans (@shaderfans) on CodePen.
如你所见,上面的 Demo 定义了几种基础图形,使用的是封装好的图形函数以及几个新的内置函数如 dot、normalize、length 等,各位可以通过文档查询和理解相关用法。
现在,各位不妨想想,目前我们的所有图形绘制都是基于「笛卡尔坐标系」来绘制的,换个叫法即「直角坐标系」,它拥有相交于原点的两条数轴,构成了平面直角坐标系。是我们使用最多且最常见的坐标系。
然而,当我们想要绘制一些围绕着圆心的图案如圆形/环、花朵、俯视的山脉等高线时,笛卡尔坐标系并不能满足需求。所以这里需要介绍一种新的坐标系:极坐标系。
在数学中,极坐标系(英语:Polar coordinate system)是一个二维坐标系统,是指在平面内由极点、极轴和极径组成的坐标系。该坐标系统中任意位置可由一个夹角和一段相对原点—极点的距离来表示。
—— 极坐标系 - Wikipedia
根据定义和图解,可以知道直角坐标系和极坐标系的差异:前者通过到原点的水平和垂直距离来表示任意位置,而后者则是通过夹角和到极点的距离来表示任意位置。
为了能让大家更好地理解直角坐标系和极坐标系的关联,这里我是用了一种并非严谨,但有利于形象化理解的一种转换过程来示意:
基于这种逻辑,我们自然可以脑补出以下的图形变换:
那么如何将直角坐标系转换成极坐标系呢?这里就需要用到勾股定理了。既然极坐标系通过夹角和到极点的距离来表示任意位置,那么夹角我们定义为 θ,到极点的距离定义为 r,则可以这么转换:
通过 Shader 内置的数序函数得到了 θ 和 r 在 Shader 中的表达,那么如何在直角坐标系里将图案转换成极坐标系的图案?其实比较简单,只需要简单的替换即可:uv.x = θ; uv.y = r
。如下面 Demo 所示(左边是极坐标图案,右边是笛卡尔坐标系图案):
See the Pen 1. cartesian coordinate by ShaderFans (@shaderfans) on CodePen.
由于很多图形都是基于极坐标系的,所以掌握了极坐标系的相关知识之后,就可以绘制更多不同类型的基础图形了。我们尝试使用四种方法来绘制圆,可以查看下面代码了解极坐标的应用:
See the Pen 17. draw Circle by ShaderFans (@shaderfans) on CodePen.
通过坐标系的简单换算,还可以绘制出各种重复性图案,提升纹理的丰富性。
这里利用的是 fract()
这个函数,它可以将数值转化为 0~1,这样就可以将纹理坐标乘以一个很大的值,而得到若干个 0~1 的格子:
然后还可以通过下面两个巧妙的公式进行差异性的设置:
// 隔行设置
step(1., mod(uv.x, 2.0));
step(1., mod(uv.y, 2.0));
// 四方格设置
float index = 0.0;
index += step(1., mod(_st.x, 2.0));
index += step(1., mod(_st.y, 2.0)) * 2.0;
// |
// 2 | 3
// |
// --------------
// |
// 0 | 1
// |
下面通过两个 Demo 结合上面的公式来演示如何制作 Pattern:
See the Pen 17. draw all kinds of shape by ShaderFans (@shaderfans) on CodePen.
See the Pen Pattern by ShaderFans (@shaderfans) on CodePen.