
WebGL已经存在很长时间了,关于着色器的文章很多,其中有一系列的经验教训。 但是在大多数情况下,它们对于布局设计师来说太复杂了。 最好说它们涵盖了游戏引擎开发人员而不是布局设计人员所需的大量信息。 他们立即开始构建一个复杂的场景,一台照相机,一个灯光……在常规的站点上,用照片创建一对效果,所有这些知识都是多余的。 结果,人们制作了非常复杂的建筑结构,并编写了很长的着色器,以实现非常简单的本质动作。
所有这些都促使人们对使用着色器的那些方面进行了介绍,这对于布局设计人员在站点上使用图片创建各种2D效果很有用。 当然,针对它们本身很少在界面设计中使用这一事实进行调整。 我们将在不带第三方库的纯JS中制作一个起始模板,并考虑基于像素偏移创建一些流行效果的想法,这在SVG上很难做到,但是同时可以使用着色器轻松实现。
假定读者已经熟悉canvas
,概述了WebGL是什么,并且对数学知识很少。 为了简化对与它们一起使用的技术的实际了解,而不是在学术上简单地描述一些要点,而不是对其内部厨房的完整理论或学习术语的描述。 有一些聪明的书。
应当立即指出,CodePen中集成到文章中的编辑者有能力影响他们所做工作的性能。 因此,在写评论说Macbook上的速度变慢之前,请确保问题不是来自它们。
主要思想
什么是着色器?
什么是片段着色器? 这本质上是一个小程序。 它对anvas
上的每个像素执行。 如果我们有一个大小为1000x500px的canvas
,则此程序将执行500,000次,每次将其当前运行的像素坐标作为其输入参数。 这一切都在GPU上以各种并行线程进行。 在中央处理器上,这样的计算将花费更长的时间。
顶点着色器也是一个程序,但不是针对canvas
上的每个像素执行的,而是针对形状的每个顶点执行的,因此所有物体都在三维空间中构建。 也平行于所有顶点。 因此,输入接收顶点的坐标,而不是像素。
在我们的任务中,还会发生以下情况:
- 我们获取一组矩形顶点的坐标,然后在该坐标上“绘制”照片。
- 每个顶点的顶点着色器考虑其在空间中的位置。 对于我们来说,这将归结为一种特殊情况-与屏幕平行的平面。 我们不需要的3D照片。 随后投影到屏幕平面上什么也没说。
- 进一步,对于每个可见片段,在我们的上下文中,对于所有像素片段,将执行片段着色器,它获取照片和当前坐标,对特定像素进行计数并给出颜色。
- 如果片段着色器中没有逻辑,则所有行为将类似于
canvas
的drawImage()
方法。 但是随后,我们添加了这种逻辑,并得到了很多有趣的东西。
这是一个非常简化的描述,但应该清楚是谁做的。
关于语法的一些知识
着色器以GLSL-OpenGL着色语言编写。 这种语言与C非常相似。 在这里描述整个概要和标准方法没有任何意义,但是您始终可以使用备忘单:
每个着色器都有一个主要功能,从该功能开始执行。 着色器的标准输入参数及其工作结果的输出是通过带有前缀gl_
特殊变量实现的。 它们是预先保留的,并且可以在这些相同的着色器中使用。 因此,顶点着色器的顶点坐标位于gl_Position
变量中,片段着色器的片段(像素)坐标位于gl_FragCoord
等中。 您总是可以在同一备忘单中找到可用特殊变量的完整列表。
GLSL中变量的主要类型非常简单-void, bool
, int
, float
...如果您使用任何类似C的语言,您都已经看到它们了。 还有其他类型,尤其是不同维度的向量vec2
, vec3
, vec4
。 我们将不断将它们用于坐标和颜色。 我们可以创建的变量具有三个重要的修改:
- 统一 -各种意义上的全局数据。 从外部传递,所有顶点和片段着色器调用均相同。
- 属性 -此数据传输更加精确,并且每次着色器调用可能有所不同。
- 变化 -需要将数据从顶点着色器传输到片段着色器。
为着色器中的所有变量加上u / a / v前缀很有用,以使您更容易理解数据来自何处。
我认为,值得立即举一个实际的例子,以便立即观察所有这些情况,而又不加载内存。
烹饪开始模板
让我们从JS开始。 正如通常在使用canvas
时发生的那样,我们需要它和上下文。 为了不加载示例代码,我们将创建全局变量:
const CANVAS = document.getElementById(IDs.canvas); const GL = canvas.getContext('webgl');
调整浏览器窗口大小时,跳过与canvas
大小及其重新计算有关的时刻。 该代码包含在示例中,通常取决于布局的其余部分。 专注于他没有任何意义。 让我们继续使用WebGL进行操作。
function createProgram() { const shaders = getShaders(); PROGRAM = GL.createProgram(); GL.attachShader(PROGRAM, shaders.vertex); GL.attachShader(PROGRAM, shaders.fragment); GL.linkProgram(PROGRAM); GL.useProgram(PROGRAM); }
首先,我们编译着色器(稍低一些),创建一个程序,将两个着色器都添加到其中并建立链接。 此时,将检查着色器的兼容性。 还记得从顶点传递到片段的各种变量吗? -特别是在这里检查它们的集合,以便在此过程的后期,不会发现尚未传输或传输了某些东西,但根本没有传输过。 当然,这种检查不会揭示逻辑错误,我认为这是可以理解的。
顶点的坐标将存储在一个特殊的缓冲区数组中,并将分块(一个顶点)传输到每个着色器调用。 接下来,我们描述使用这些片段的一些细节。 首先,我们将通过a_position
属性a_position
使用着色器中顶点的坐标。 可以用不同的方式来称呼,不要紧。 我们得到它的位置(这有点像C中的指针,但不是指针,而是仅存在于程序中的实体编号)。
const vertexPositionAttribute = GL.getAttribLocation(PROGRAM, 'a_position');
接下来,我们指示将通过此变量传递带有坐标的数组(在着色器本身中,我们已经将其视为向量)。 WebGL将独立找出应将形状中哪些点的坐标传递给哪个着色器调用。 我们只为将要传输的向量数组设置参数:dimension-2(我们将传输坐标(x,y)
),它由数字组成并且未标准化。 最后一个参数对我们来说并不有趣,我们默认保留零。
GL.enableVertexAttribArray(vertexPositionAttribute); GL.vertexAttribPointer(vertexPositionAttribute, 2, GL.FLOAT, false, 0, 0);
现在,使用我们平面的顶点坐标创建缓冲区本身,然后将在其上显示照片。 “ 2d”坐标更清晰,但是对于我们的任务,这是最重要的。
function createPlane() { GL.bindBuffer(GL.ARRAY_BUFFER, GL.createBuffer()); GL.bufferData( GL.ARRAY_BUFFER, new Float32Array([ -1, -1, -1, 1, 1, -1, 1, 1 ]), GL.STATIC_DRAW ); }
这个正方形足以满足我们所有的示例。 STATIC_DRAW
表示缓冲区被加载一次,然后将被重用。 我们不会再上传任何内容。
在继续介绍着色器本身之前,让我们看一下它们的编译:
function getShaders() { return { vertex: compileShader( GL.VERTEX_SHADER, document.getElementById(IDs.shaders.vertex).textContent ), fragment: compileShader( GL.FRAGMENT_SHADER, document.getElementById(IDs.shaders.fragment).textContent ) }; } function compileShader(type, source) { const shader = GL.createShader(type); GL.shaderSource(shader, source); GL.compileShader(shader); return shader; }
我们从页面上的元素获取着色器代码,创建一个着色器并进行编译。 从理论上讲,您可以将着色器代码存储在单独的文件中,并在组装过程中将其作为字符串加载到正确的位置,但是CodePen并没有提供此类机会。 许多课程建议使用JS直接在行中编写代码,但是这种语言并不能将其变成一种方便的语言。 虽然当然有味道和颜色...
如果在编译过程中发生错误,该脚本将继续运行,并在控制台中显示一些没有太大意义的警告。 编译后查看日志很有用,以免使您的头脑不了解那里未编译的内容:
console.log(GL.getShaderInfoLog(shader));
WebGL提供了几种不同的选项来跟踪在编译着色器和创建程序时的问题,但实际上,事实证明,我们无论如何都无法修复。 因此,通常我们会以“先跌后跌”的思想为指导,并且我们不会在代码中进行大量额外的检查。
让我们继续着色器本身
因为我们只有一个平面,我们将不做任何事情,所以一个简单的顶点着色器就足够了,我们一开始就可以做到。 主要工作将集中在片段着色器上,所有后续示例都将与它们相关。
尝试使用或多或少有意义的变量名来编写着色器代码。 在网络上,您会找到一些示例,其中具有200个连续文本的功能强大的数学函数将由一个字母的变量组合而成,但是仅仅因为有人这么做并不意味着它值得重复。 这种方法不是“使用GL的特殊性”,而是一种普通复制的粘贴源代码的方法,该方法粘贴了上个世纪的代码,这些代码在青年时期就对变量名的长度有所限制。
首先,顶点着色器。 a_position
(x,y)
具有坐标(x,y)
的2d向量将被传输到a_position
属性变量。 着色器应返回四个值(x,y,z,w)
的向量。 它不会在空间中移动任何东西,因此在z轴上,我们只需将所有内容清零并将w的值设置为标准单位。 如果您想知道为什么有四个而不是三个坐标,那么可以使用网络搜索“统一坐标”。
<script id='vertex-shader' type='x-shader/x-vertex'> precision mediump float; attribute vec2 a_position; void main() { gl_Position = vec4(position, 0, 1); } </script>
工作结果记录在特殊变量gl_Position
。 着色器没有完整意义上的return
,它们将所有工作结果记录在为此专门保留的变量中。
请注意float数据类型的precision作业。 为了避免移动设备上的某些问题,精度应该比highp差,并且两个着色器的精度都应该相同。 此处以示例为例,但这是在手机上使用着色器完全关闭这种效果的一种很好的做法。
片段着色器始终将返回相同的颜色。 我们的正方形将占据整个canvas
,因此实际上我们在这里为每个像素设置颜色:
<script id='fragment-shader' type='x-shader/x-fragment'> precision mediump float; #define GOLD vec4(1.0, 0.86, 0.6, 1.0) void main() { gl_FragColor = GOLD; } </script>
您可以注意描述颜色的数字。 这是所有RGBA排字机所熟悉的,只有经过标准化处理。 值不是从0到255的整数,而是从0到1的小数。顺序是相同的。
不要忘记对实际项目中的所有魔术常数使用预处理器-这使代码更易于理解,而又不影响性能(在C语言中,替换在编译过程中发生)。
值得注意的是有关预处理器的另一点:
在各种课程中使用常量检查#ifdef GL_ES缺乏实际意义。 在当今的浏览器中,根本没有其他GL选项存在。
但是现在该看看结果了:
金色正方形表示着色器正在按预期工作。 在继续使用照片之前,与他们一起玩耍是有意义的。
渐变和变换向量
通常,WebGL教程从绘制渐变开始。 这没有什么实际意义,但是要注意几点。
void main() { gl_FragColor = vec4(gl_FragCoord.zxy / 500.0, 1.0); }
在此示例中,我们使用当前像素的坐标作为颜色。 您经常会在网上的示例中看到这一点。 两者都是向量。 因此,没有人会费心混合所有内容。 TypeScript传播者应该在这里发动攻击。 重要的一点是我们如何仅从向量中获取部分坐标。 属性.x
, .y
, .z
, .xy
, .zy
, .xyz
, .zyx
, .xyzw
等 以不同的顺序可以使您以某种顺序以另一个向量的形式拉出向量的元素。 实施非常方便。 同样,可以像我们所做的那样,通过将缺失值相加,从较低维的向量中生成较高维的向量。
始终明确声明数字的小数部分。 这里没有自动转换int-> float。
制服和时间的流逝
下一个有用的例子是制服的使用。 这是所有着色器调用的最常见数据。 我们以与属性变量大致相同的方式获取它们的位置,例如:
GL.getUniformLocation(PROGRAM, 'u_time')
然后我们可以在每帧之前设置它们的值。 与向量一样,这里有许多类似的方法,以单词uniform
开头,然后是变量的维数(对于数字为1,对于向量为2、3或4)和类型(f-浮点数,i-int,v-向量) 。
function draw(timeStamp) { GL.uniform1f(GL.getUniformLocation(PROGRAM, 'u_time'), timeStamp / 1000.0); GL.drawArrays(GL.TRIANGLE_STRIP, 0, 4); window.requestAnimationFrame(draw); }
实际上,我们并不总是需要接口的60fps。 很有可能在requestAnimationFrame中添加一个减慢速度并减少重绘帧的频率。
例如,我们将更改填充颜色。 在着色器中,所有基本数学函数均可用sin
, cos
, tan
, asin
, acos
, atan
, pow
, exp
, log
, sqrt
, abs
等。 我们将使用其中两个。
uniform float u_time; void main() { gl_FragColor = vec4( abs(sin(u_time)), abs(sin(u_time * 3.0)), abs(sin(u_time * 5.0)), 1.0); }
这种动画中的时间是一个相对的概念。 在这里,我们使用requestAnimationFrame
提供的值,但是我们可以设置自己的“时间”。 这个想法是,如果某些参数是通过时间的函数来描述的,那么我们可以将时间转向相反的方向,放慢速度,加快速度或返回其原始状态。 这会很有帮助。
但是有足够的抽象示例,让我们继续使用图片。
将图片加载到纹理中
为了使用图片,我们需要创建一个纹理,然后将其渲染到飞机上。 首先,加载图像本身:
function createTexture() { const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => {
加载后,创建一个纹理并指示其编号将为0。在WebGL中,同时可以有许多纹理,我们必须明确指示与哪些后续命令相关。 在我们的示例中,将只有一个纹理,但是我们仍然明确指出它将为零。
const texture = GL.createTexture(); GL.activeTexture(GL.TEXTURE0); GL.bindTexture(GL.TEXTURE_2D, texture);
它仍然可以添加图片。 我们还立即说它需要沿Y轴翻转,因为 在WebGL中,轴是上下颠倒的:
GL.pixelStorei(GL.UNPACK_FLIP_Y_WEBGL, true); GL.texImage2D(GL.TEXTURE_2D, 0, GL.RGB, GL.RGB, GL.UNSIGNED_BYTE, image);
从理论上讲,纹理应为正方形。 更准确地说,它们的大小应等于2的幂-32px,64px,128px等。 但是我们都知道,没有人会处理照片,每次照片的比例都不同。 即使canvas
的尺寸完全适合纹理,也会导致错误。 因此,我们用图像的极端像素将整个空间填充到平面的边缘。 尽管这似乎有些拐杖,但这是标准做法。
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);
仍然可以将纹理转移到着色器。 这些数据是所有人都共有的,因此我们使用uniform
修饰符。
GL.uniform1i(GL.getUniformLocation(PROGRAM, 'u_texture'), 0);
现在,我们可以使用片段着色器中纹理的颜色。 但是我们也希望图片占据整个canvas
。 如果图像和canvas
的比例相同,那么此任务就变得微不足道了。 首先,我们将canvas
大小转移到着色器(每次更改大小时都必须这样做):
GL.uniform1f(GL.getUniformLocation(PROGRAM, 'u_canvas_size'), Math.max(CANVAS.height, CANVAS.width));
并将其划分为:
uniform sampler2D u_texture; uniform float u_canvas_size; void main() { gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size); }
此时,您可以暂停并冲泡茶。 我们已经完成了所有准备工作,并继续创造各种效果。
特效
在创造各种效果中,直觉和实验起着重要的作用。 通常,您可以将复杂的算法替换为完全简单的算法,并得到相似的结果。 最终用户不会注意到差异,但是我们会加快工作速度并简化支持。 WebGL没有提供用于调试着色器的明智工具,因此对我们来说,拥有可以放在整个头部的小代码对我们来说是有益的。
更少的代码意味着更少的问题。 而且更容易阅读。 始终检查在网络上找到的着色器是否有不必要的操作。 碰巧您可以删除一半的代码,并且什么都不会改变。
让我们玩一下着色器。 我们的大多数效果将基于以下事实:我们返回的不是位于该位置的纹理上像素的颜色,而是返回一些相邻像素的颜色。 尝试将坐标的标准函数的结果添加到坐标中很有用。 时间也将是有用的-执行的结果将更容易跟踪,最后,我们仍将制作动画效果。 让我们尝试使用正弦:
gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size + sin(u_time + gl_FragCoord.y))
结果很奇怪。 显然,一切都以太大的幅度运动。 将所有内容除以某个数字:
gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size + sin(u_time + gl_FragCoord.y) / 250.0)
已经更好了。 现在很明显,我们有点兴奋。 从理论上讲,为了增加每个波,我们需要划分正弦参数-坐标。 让我们做吧:
gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size + sin(u_time + gl_FragCoord.y / 30.0) / 250.0)
相似的效果通常伴随着系数的选择。 这是通过肉眼完成的。 与烹饪一样,一开始很难猜测,但随后它会自行发生。 最主要的是至少大致了解所得公式中该系数的影响。 选择系数后,将它们放入宏(第一个示例)并给出有意义的名称是有意义的。
弯曲的镜子,自行车和实验
思维是好的。 是的,有现成的算法可以解决一些我们可以采取和使用的问题。 , .
, " ", . 怎么办
, , ? . , rand() - . , , , , . . . , . . . -, . . , , , . , "":
float rand(vec2 seed) { return fract(sin(dot(seed, vec2(12.9898,78.233))) * 43758.5453123); }
, , , NVIDIA ATI . , .
, , :
gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size + rand(gl_FragCoord.xy) / 100.0)
:
gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size + rand(gl_FragCoord.xy + vec2(sin(u_time))) / 250.0)
, , :
, . , , . — . 怎么做? . .
0 1, - . 5 — . , .
vec2 texture_coord = gl_FragCoord.xy / u_canvas_size; gl_FragColor = texture2D(u_texture, texture_coord + rand(floor(texture_coord * 5.0) + vec2(sin(u_time))) / 100.0);
, - . - . , , . ?
, , , - . , . , .. -. , . . , , . .
sin
cos
, . . .
gl_FragColor = texture2D(u_texture, texture_coord + vec2( noise(texture_coord * 10.0 + sin(u_time + texture_coord.x * 5.0)) / 10.0, noise(texture_coord * 10.0 + cos(u_time + texture_coord.y * 5.0)) / 10.0));
. fract
. 1 1 — :
float noise(vec2 position) { vec2 block_position = floor(position); float top_left_value = rand(block_position); float top_right_value = rand(block_position + vec2(1.0, 0.0)); float bottom_left_value = rand(block_position + vec2(0.0, 1.0)); float bottom_right_value = rand(block_position + vec2(1.0, 1.0)); vec2 computed_value = fract(position);
. WebGL smoothstep
, :
vec2 computed_value = smoothstep(0.0, 1.0, fract(position))
, . , X :
return computed_value.x;
… , , ...
- , , ... .
y — , . ?
return length(computed_value);
.
. 0.5 — .
return mix(top_left_value, top_right_value, computed_value.x) + (bottom_left_value - top_left_value) * computed_value.y * (1.0 - computed_value.x) + (bottom_right_value - top_right_value) * computed_value.x * computed_value.y - 0.5;
:
, , , .
, , . - .
uniform-, . 0 1, 0 — , 1 — .
uniform float u_intensity;
:
gl_FragColor = texture2D(u_texture, texture_coord + vec2(noise(texture_coord * 10.0 + sin(u_time + texture_coord.x * 5.0)) / 10.0, noise(texture_coord * 10.0 + cos(u_time + texture_coord.y * 5.0)) / 10.0) * u_intensity);
, .
( 0 1), .
, , , . — requestAnimationFrame. , FPS.
, . uniform-.
document.addEventListener('mousemove', (e) => { let rect = CANVAS.getBoundingClientRect(); MOUSE_POSITION = [ e.clientX - rect.left, rect.height - (e.clientY - rect.top) ]; GL.uniform2fv(GL.getUniformLocation(PROGRAM, 'u_mouse_position'), MOUSE_POSITION); });
, . — , .
void main() { vec2 texture_coord = gl_FragCoord.xy / u_canvas_size; vec2 direction = u_mouse_position / u_canvas_size - texture_coord; float dist = distance(gl_FragCoord.xy, u_mouse_position) / u_canvas_size; if (dist < 0.4) { gl_FragColor = texture2D(u_texture, texture_coord + u_intensity * direction * dist * 1.2 ); } else { gl_FragColor = texture2D(u_texture, texture_coord); } }
- . .
. , .
. Glitch- , SVG. . — . ? — , , , .
float random_value = rand(vec2(texture_coord.y, u_time)); if (random_value < 0.05) { gl_FragColor = texture2D(u_texture, vec2(texture_coord.x + random_value / 5.0, texture_coord.y)); } else { gl_FragColor = texture2D(u_texture, texture_coord); }
" ?" — , . .
. — , .
float random_value = rand(vec2(floor(texture_coord.y * 20.0), u_time));
. , :
gl_FragColor = texture2D(u_texture, vec2(texture_coord.x + random_value / 4.0, texture_coord.y)) + vec4(vec3(random_value), 1.0)
. — . , — .r
, .g
, .b
, .rg
, .rb
, .rgb
, .bgr
, ... .
:
float random_value = u_intensity * rand(vec2(floor(texture_coord.y * 20.0), u_time));
结果如何?
, , . , , — .