
本文是为布局设计器引入编程着色器的逻辑上的延续。 在其中,我们制作了一个模板,用于使用着色器使用照片创建各种二维效果,并查看了两个示例。 在本文中,我们将添加更多的纹理,在实践中应用Voronoi分割从中创建马赛克,讨论在着色器中创建各种蒙版,关于像素化,还涉及浏览器中仍然存在的古老GLSL语法的一些问题。
就像上次一样,平凡的日常语言将使理论最少,实践和推理最多。 初学者将在此处找到一系列操作,包括提示和有用的注释,而经验丰富的前端供应商可能会提供一些启发性的想法。
上一篇文章中的一项调查表明,网站的WebGL效果主题可能不仅对排版者感兴趣,而且也使我们来自其他专业领域的同事感兴趣。 为了不使它们对最新的ES功能感到困惑,我们特意将自己限制在每个人都可以理解的更传统的语法构造中。 我再次提请读者注意CodePen的内置编辑器会影响他们所做的工作的性能。
但是让我们开始吧...
用于着色器的模板
对于那些尚未阅读上一篇文章的人,我们制作了此模板来使用着色器:
在其中创建一个平面(在我们的示例中为一个正方形),在该平面上绘制了纹理。 没有不必要的依赖关系和非常简单的顶点着色器。 然后我们开发了这个模板,但是现在我们将从片段着色器中尚无逻辑的那一刻开始。
马赛克
马赛克是一面破碎成小区域的平面,其中每个区域都填充有特定的颜色或纹理(例如本例中的纹理)。 我们怎么能把飞机弄成碎片? 显然,您可以将其分成矩形。 但这在SVG的帮助下已经很容易做到,将WebGL拖到这个任务上,使所有事情突如其来。
为了使马赛克有趣,它必须具有不同的形状和大小的碎片。 有一种非常简单但有趣的方法来构造这样的分区。 它被称为Voronoi的镶嵌图或Dirichlet的分区,在Wikipedia上记载,笛卡尔在遥远的十七世纪使用了类似的东西。 这个想法是这样的:
- 在平面上取一组点。
- 对于平面上的每个点,从该集合中找到最接近它的点。
- 仅此而已。 平面分为多个多边形区域,每个区域由集合中的一个点确定。
最好用一个实际的例子来说明这个过程。 生成此分区的算法不同,但是我们将在额头上行动,因为为平面上的每个点计算某些内容只是着色器的任务。 首先,我们需要设置一组随机点。 为了不加载示例代码,我们将为它们创建一个全局变量。
function createPoints() { for (let i = 0; i < NUMBER_OF_POINTS; i++) { POINTS.push([Math.random(), Math.random()]); } }
现在我们需要将它们传递给着色器。 数据是全局数据,因此我们将使用uniform
修饰符。 但是有一个微妙的地方:我们不能只传递一个数组。 看来21世纪已经到了,但是什么也不会发生。 结果,您必须一次转移一个点数组。
for (let i = 0; i < NUMBER_OF_POINTS; i++) { GL.uniform2fv(GL.getUniformLocation(PROGRAM, 'u_points[' + i + ']'), POINTS[i]); }
如今,我们经常会遇到类似的问题,即预期的浏览器与实际的浏览器之间的不一致。 通常,WebGL课程使用THREE.js,并且该库本身就隐藏了一些污垢,就像jQuery在其任务中所做的那样,但是如果删除它,确实会伤及大脑。
在片段着色器中,我们有一个用于点的数组变量。 我们只能创建固定长度的数组。 让我们从10点开始:
#define NUMBER_OF_POINTS 10 uniform vec2 u_points[NUMBER_OF_POINTS];
通过在点的位置绘制圆圈来确保所有这些工作正常。 这种各种几何图元的绘图通常在调试期间使用-它们清晰可见,您可以立即了解其位置和移动位置。
将圆形,线条和其他界标的“绘图”用于构建动画的不可见对象。 这将为它们的工作原理提供明显的线索,特别是如果算法很复杂而无需事先准备就可以快速理解。 然后可以将所有这些注释掉并留给同事-他们会说声谢谢。
for (int i = 0; i < NUMBER_OF_POINTS; i++) { if (distance(texture_coord, u_points[i]) < 0.02) { gl_FragColor = WHITE; break; } }
好啊 让我们也为这些点添加一些动作。 让他们绕圈开始,然后我们将在以后再讨论这个问题。 这些系数也放在眼中,只是稍微减慢了它们的运动并减小了振荡的幅度。
function movePoints(timeStamp) { if (timeStamp) { for (let i = 0; i < NUMBER_OF_POINTS; i++) { POINTS[i][0] += Math.sin(i * timeStamp / 5000.0) / 500.0; POINTS[i][1] += Math.cos(i * timeStamp / 5000.0) / 500.0; } } }
返回到着色器。 对于以后的实验,我们将找到有用的区域,将所有内容划分为若干区域。 因此,我们从集合中找到最接近当前像素的点,并保存该点的编号-这是区域编号。
float min_distance = 1.0; int area_index = 0; for (int i = 0; i < NUMBER_OF_POINTS; i++) { float current_distance = distance(texture_coord, u_points[i]); if (current_distance < min_distance) { min_distance = current_distance; area_index = i; } }
为了测试性能,我们再次用鲜艳的颜色绘制所有内容:
gl_FragColor = texture2D(u_texture, texture_coord); gl_FragColor.g = abs(sin(float(area_index))); gl_FragColor.b = abs(sin(float(area_index)));
在产生类似效果时,经常使用模块(abs)和功能有限(特别是sin和cos)的组合。 一方面,这增加了一些随机性,另一方面,它立即给出了从0到1的归一化结果,这非常方便-我们有很多恰好在这些限制内的值。
我们还将找到与集合中的几个点大致等距的点,并为它们着色。 这个动作没有携带特殊的有效载荷,但是查看结果仍然很有趣。
int number_of_near_points = 0; for (int i = 0; i < NUMBER_OF_POINTS; i++) { if (distance(texture_coord, u_points[i]) < min_distance + EPSILON) { number_of_near_points++; } } if (number_of_near_points > 1) { gl_FragColor.rgb = vec3(1.0); }
您应该得到这样的内容:
这仍然是草案,我们仍将定稿。 但是,现在这种飞机分离的一般概念已经很清楚了。
照片中的马赛克
显然,以这种纯粹的形式并没有太多好处。 您可以和他一起玩耍,开阔眼界并乐在其中,但是在一个真实的网站上,最好再添加几张照片并制作一张马赛克。 让我们重做一些创建纹理的功能,以便有多个纹理。
function createTextures() { for (let i = 0; i < URLS.textures.length; i++) { createTexture(i); } } function createTexture(index) { const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { const texture = GL.createTexture(); GL.activeTexture(GL['TEXTURE' + index]); GL.bindTexture(GL.TEXTURE_2D, texture); GL.pixelStorei(GL.UNPACK_FLIP_Y_WEBGL, true); GL.texImage2D(GL.TEXTURE_2D, 0, GL.RGB, GL.RGB, GL.UNSIGNED_BYTE, image); GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_WRAP_S, GL.CLAMP_TO_EDGE); GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_WRAP_T, GL.CLAMP_TO_EDGE); GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MIN_FILTER, GL.LINEAR); GL.uniform1i(GL.getUniformLocation(PROGRAM, 'u_textures[' + index + ']'), index); }; image.src = URLS.textures[index]; }
没什么异常的,我们只是用index
参数替换了零,然后重用了现有代码来加载三个纹理。 在着色器中,我们现在有一个纹理数组:
#define NUMBER_OF_TEXTURES 3 uniform sampler2D u_textures[NUMBER_OF_TEXTURES];
现在,我们可以使用之前保存的区域号来选择三个纹理之一。 但是...
但是在此之前,我想作一点题外话。 关于酸痛 关于语法。 现代Javascript(有条件地为ES6 +)是一种不错的语言。 它使您可以在思想出现时表达自己的想法,不将框架限制于任何特定的编程范式,为我们提供了一些要点,并且使您可以将更多的精力集中在想法上而不是在实现上。 对于创作者-就是这样。 有人认为它提供了太多的自由,例如切换到TypeScript。 纯C是更严格的语言。 它还允许很多东西,您可以吸引任何东西,但是在JS之后,它被认为有点笨拙,过时或类似。 尽管如此,他仍然很好。 浏览器中存在的GLSL只是其中的一部分。 它不仅比C严格一个数量级,而且仍然缺少许多熟悉的运算符和语法构造。 为WebGL编写或多或少复杂的着色器时,这可能是最大的问题。 在代码变成恐怖的背后,很难看清原始算法。 一些编码人员认为,在他们学习C之前,对他们而言着色器的路径是封闭的。 因此:C的知识在这里不会特别有用。 这是它自己的某种世界。 疯狂,恐龙和拐杖的世界。
如何选择具有一个数字的三个纹理之一-区域号。 平衡来自于将数量除以纹理数量。 好主意。 这里只有指针自己已经写过的%
运算符不在这里。 图片充分说明了理解这一事实的印象:

当然,您说:“是的,没问题,有一个mod
函数-让我们接受它!”。 但是事实证明,她不接受两个整数,只接受小数。 好吧,让它们float
。 我们也有一个float
,但我们需要一个int
。 您必须将所有内容都转换回来,否则将有一次假冒错误的机会。
int texture_index = int(mod(float(area_index), float(NUMBER_OF_TEXTURES)))
这是一个反问:也许比起用标准方法来组装整数除法,要实现余数除法的功能要容易得多? 而且这仍然是一个简单的函数,并且碰巧获得了此类转换的非常深层的嵌入序列,其中不再清楚发生了什么。
好吧,现在就让它保持原样。 只需从所选纹理中获取所需像素的颜色,然后将其分配给变量gl_FragColor
。 那呢 我们已经做到了吗? 然后这只猫再次出现。 访问数组时,不能使用非常数。 而且我们所计算的不再是一个常数。 Ba-dum-tsss !!!
您必须执行以下操作:
if (texture_index == 0) { gl_FragColor = texture2D(u_textures[0], texture_coord); } else if (texture_index == 1) { gl_FragColor = texture2D(u_textures[1], texture_coord); } else if (texture_index == 2) { gl_FragColor = texture2D(u_textures[2], texture_coord); }
同意,这样的代码是通往govnokod.ru的直接途径 ,但是无论如何它还是有所不同。 甚至switch-case
在这里也至少不会以某种方式使这种耻辱成为可能。 实际上,还有另一个不太明显的拐杖可以解决相同的问题:
for (int i = 0; i < 3; i++) { if (texture_index == i) { gl_FragColor = texture2D(u_textures[i], texture_coord); } }
循环计数器加一,编译器可以将其视为一个常数。 但这不适用于一系列纹理-在上一个Chrome中,出现了一个错误,提示无法使用一系列纹理来完成此操作。 使用一组数字,它可以工作。 猜猜为什么它只能用于一个数组,而不适用于另一个数组? 如果您认为JS中的类型转换系统充满魔力,请选择GLSL中的“常量-非常量”系统。 有趣的是,结果还取决于所使用的视频卡,因此在NVIDIA显卡上使用的棘手的拐杖很可能会在AMD上崩溃。
最好避免基于有关编译器的假设做出此类决定。 它们往往会破裂并且难以测试。
悲伤就是悲伤。 但是,如果我们想做有趣的事情,我们需要从所有这些中抽象出来并继续。
此刻,我们得到了一张马赛克的照片。 但是有一个细节:如果这些点非常接近,那么两个区域就会快速过渡。 这不是很漂亮。 您需要添加一些不允许点接近的算法。 您可以选择一个简单的选项,其中检查点之间的距离,如果小于某个值,则将其分开。 该选项并非没有缺点,特别是它有时会导致点的一些扭曲,但是在很多情况下它可能就足够了,尤其是因为此处没有太多的计算。 更高级的选择是移动电荷的系统和“蜘蛛网”,其中成对的点通过不可见的弹簧连接。 如果您有兴趣实施它们,则可以轻松地在高中物理参考书中找到所有公式。
for (let i = 0; i < NUMBER_OF_POINTS; i++) { for (let j = i; j < NUMBER_OF_POINTS; j++) { let deltaX = POINTS[i][0] - POINTS[j][0]; let deltaY = POINTS[i][1] - POINTS[j][1]; let distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (distance < 0.1) { POINTS[i][0] += 0.001 * Math.sign(deltaX); POINTS[i][1] += 0.001 * Math.sign(deltaY); POINTS[j][0] -= 0.001 * Math.sign(deltaX); POINTS[j][1] -= 0.001 * Math.sign(deltaY); } } }
这种方法以及我们在着色器中使用的方法的主要问题是比较所有点。 您不需要成为一个出色的数学家就可以理解,如果我们不做10点而是1000,那么距离计算的数量将是不可思议的。是的,甚至100也足以使一切变慢。 因此,仅将其应用于少量点是有意义的。
如果我们要为大量点制作这样的镶嵌图,则可以使用将平面熟悉的划分为相同的正方形。 想法是在每个正方形中放置一个点,然后仅与相邻正方形中的点进行所有比较。 一个好主意,但是实验表明,在集成了很多显卡的情况下,价格低廉的笔记本电脑仍然无法应对。 因此,在决定使用大量碎片在您的站点上进行镶嵌之前,值得三思而后行。
不要萝卜,不仅要在采矿场上检查手工艺品的性能,还要在普通笔记本电脑上检查手工艺品的性能。 用户将基本上是那些。
根据功能图对平面进行分区
让我们看看将平面分为多个部分的另一种选择。 它将不再需要大的计算能力。 主要思想是采用一些数学函数并建立其图形。 生成的线会将平面分为两部分。 如果我们使用形式为y = f(x)
的函数,则会得到割形式的除法。 用Y替换X,我们可以将水平部分更改为垂直部分。 如果在极坐标中使用该函数,则需要将所有内容转换为笛卡尔坐标系,反之亦然,但是计算的本质不会改变。 在这种情况下,结果不是切成两部分,而是切成孔。 但是,我们将看到第一个选择。
对于每个Y,我们将计算X的值以构成一个垂直截面。 例如,出于这些目的,我们可以采用正弦波,但这太无聊了。 最好一次取几张并折叠。
我们采用几个正弦曲线,将每个正弦曲线与沿Y的坐标和时间相关联,然后将它们相加。 物理学家将这种加法称为叠加。 显然,将整个结果乘以某个数字,就可以改变幅度。 将其放在单独的宏中。 如果将坐标-正弦参数相乘,则频率将改变。 我们已经在上一篇文章中看到了这一点。 我们还从公式中删除了所有正弦波共有的频率修改器。 随着时间的流逝并不是多余的,负号将产生沿相反方向移动线的效果。
float time = u_time * SPEED; float x = (sin(texture_coord.y * FREQUENCY) + sin(texture_coord.y * FREQUENCY * 2.1 + time) + sin(texture_coord.y * FREQUENCY * 1.72 + time * 1.121) + sin(texture_coord.y * FREQUENCY * 2.221 + time * 0.437) + sin(texture_coord.y * FREQUENCY * 3.1122 + time * 4.269)) * AMPLITUDE;
在为我们的功能进行了这样的全局设置之后,我们将面临在相当短的间隔内重复相同动作的问题。 为了解决这个问题,我们需要将所有内容乘以最小公倍数非常大的系数。 还记得随机数发生器中使用的类似工具吗? 在这种情况下,我们没有考虑并从互联网上的一些示例中获取现成的数字,但是没有人愿意尝试我们的价值观。
只需为功能图上方的点选择两个纹理之一,为下方的点选择第二个纹理。 更准确地说,我们都转向了:
if (texture_coord.x - 0.5 > x) { gl_FragColor = texture2D(u_textures[0], texture_coord); } else { gl_FragColor = texture2D(u_textures[1], texture_coord); }
我们收到的声音类似于声波。 更准确地说,它们在示波器上的图像。 实际上,我们可以代替正弦波从某种声音文件中传输数据。 但是处理声音是另一篇文章的主题。
口罩
前面的示例应该引起一个相当合乎逻辑的评论:所有这些看起来都像是SVG中的遮罩(如果您还没有使用它们,请参阅SVG遮罩和哇效果 )中的示例。 只是在这里,我们对它们的处理有所不同。 结果是相同的-有些区域用一种纹理绘制,有些则用另一种纹理绘制。 尚未实现平稳过渡。 因此,让我们做一个。
我们删除所有不必要的内容并返回鼠标的坐标。 以光标所在的中心为中心进行径向渐变,并将其用作遮罩。 在此示例中,与之前的示例相比,着色器行为将更类似于SVG中的蒙版逻辑。 我们需要一个mix
函数和一些距离函数。 第一个参数将混合两种纹理的像素颜色值,将第三个参数作为系数(从0到1),该系数确定结果将优先使用哪个值。 我们将正弦模量作为距离的函数-它将使值在0和1之间平滑变化。
gl_FragColor = mix( texture2D(u_textures[0], texture_coord), texture2D(u_textures[1], texture_coord), abs(sin(length(texture_coord - u_mouse_position / u_canvas_size))))
仅此而已。 让我们看一下结果:
相对于SVG的主要优势显而易见:
与SVG不同,这里我们可以轻松地为各种数学函数建立平滑的渐变,而不必从许多线性渐变中收集它们。
如果您有一个更简单的任务,不需要在过程中计算出如此平滑的过渡或复杂的形式,那么很可能在不使用着色器的情况下更容易实现。 是的,在较弱的硬件上的性能可能会更好。 根据您的任务选择一种工具。
出于教育目的,让我们来看另一个示例。 首先,画一个圆圈,纹理将保留在其中:
gl_FragColor = texture2D(u_textures[0], texture_coord); float dist = distance(texture_coord, u_mouse_position / u_canvas_size); if (dist < 0.3) { return; }
并用斜条纹填充其余部分:
float value = sin((texture_coord.y - texture_coord.x) * 200.0); if (value > 0.0) { gl_FragColor.rgb *= dist; } else { gl_FragColor.rgb *= dist / 10.0; }
接受是相同的-我们将正弦参数相乘以增加条纹的频率; 将获得的值分为两部分; 对于每个半部分,我们都以自己的方式变换像素的颜色。 记住,绘制对角线通常与在X和Y中添加坐标有关。请注意,更改颜色时,我们还会使用到鼠标光标的距离,从而创建一种阴影。 同样,您可以将其用于几何变换,我们将很快在像素化示例中看到这一点。 同时,请看一下此着色器的结果:
简单漂亮。
是的,如果您有点困惑,可以从图像而不是从视频帧制作纹理(网络上有很多示例,您可以很容易地弄清楚它们),然后将我们所有的效果应用于它们。 许多目录网站(如Awwwards)都将这些效果与视频结合使用。
值得再想一想:
没有人会使用其中一种纹理作为蒙版。 我们可以拍照,并在变换中使用其像素的颜色值,无论是其他颜色的变化,向侧面的偏移还是您想到的其他任何东西。
但是回到将飞机分成几部分。
像素化
这种效果在某种程度上是显而易见的,但同时又非常普遍,以至于无法通过。 以与示例中使用噪声发生器相同的方式将平面划分为正方形,然后为每个正方形内的所有像素设置相同的颜色。 它是通过混合来自正方形角的值而获得的,我们已经做过类似的事情。 为此,我们不需要复杂的公式,因此只需将所有值相加并除以4(正方形的角度数)即可。
float block_size = abs(sin(u_time)) / 20.0; vec2 block_position = floor(texture_coord / block_size) * block_size; gl_FragColor = ( texture2D(u_textures[0], block_position) + texture2D(u_textures[0], block_position + vec2(1.0, 0.0) * block_size) + texture2D(u_textures[0], block_position + vec2(0.0, 1.0) * block_size) + texture2D(u_textures[0], block_position + vec2(1.0, 1.0) * block_size) ) / 4.0;
我们再次通过正弦模块将参数之一与时间绑定在一起,以直观地看到更改时会发生什么。
像素波
最后,我们将再次在一个地方应用我们已经知道的技术,将波添加到像素示例中。
float block_size = abs(sin( length(texture_coord - u_mouse_position / u_canvas_size) * 2.0 - u_time)) / 100.0 + 0.001;
我们使用正弦模块来驱动从0到1的所有内容。加上从当前位置到鼠标光标的距离,时间,并选择一些系数使它们看起来都很漂亮。我们在结果中添加一个小常数,以使块大小不为零。
"" , , -. . " ", , . . — . .
总结
, , , , . -. - - . . . , , , .
PS: , WebGL ( ) ? , , . ?