在本文中,我们将了解如何使用超过 150 行代码将图像渲染到页面!我知道可以只使用一个标签并完成它。但这样做是一个很好的练习,因为它迫使我们引入许多重要的 WebGL 概念。
我最近在一个需要使用 WebGL 的项目上工作。我试图在浏览器中的地图上渲染数千个多边形,但结果证明 GeoJSON 太慢了。为了加快速度,我想尽可能降低到最低水平,并使用 WebGL 和着色器实际编写可以直接在 GPU 上运行的代码。我一直想了解着色器,但一直没有机会,所以这是一个在解决非常具体的技术挑战的同时学习新东西的好机会。
起初,我很难弄清楚我需要做什么。复制和粘贴示例代码通常不起作用,而且我并没有真正了解如何从示例转到我需要的自定义解决方案。然而,一旦我完全理解了这一切是如何结合在一起的,它突然在我脑海中响起,结果证明解决方案非常简单。最困难的部分是围绕一些概念思考。所以,我想写一篇文章解释我学到了什么,帮助你理解这些概念,并希望让你更容易编写你的第一个着色器。
以下是我们将在本文中执行的操作:
希望你可以使用这些概念作为起点,使用 WebGL 做一些非常酷且有用的事情。
即使你最终使用库来帮助编写 WebGL 代码,我发现了解幕后的原始 API 调用对于了解实际发生的情况很有用,尤其是在出现问题时。
要在浏览器中使用 WebGL,你需要向页面添加标签。使用画布,你可以使用 2D Canvas API 进行绘制,也可以选择使用 3D WebGL API版本 1 或 2。我实际上并不了解 WebGL 1 和 2 之间的区别,但我会希望有一天能了解更多。我将在这里讨论的代码和概念适用于这两个版本。
如果想让你的画布填充视口,你可以从这个简单的 HTML 开始:
WebGL
这会给你一个空白的、白色的、无用的页面。你需要一些 JavaScript 来实现它。在
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
WebGL 基于 OpenGL,并使用相同的着色器语言。没错,着色器程序(Shader)是用他们自己的语言 GLSL 编写的,它代表图形库着色器语言。
GLSL 让我想起了 C 或 JavaScript,但它有自己的特点,局限性非常大,但也非常强大。很酷的一点是,它直接在 GPU 上而不是在 CPU 上运行。因此它可以非常快速地完成普通 CPU 程序无法完成的事情。它针对使用向量和矩阵处理数学运算进行了优化。
我们需要两种类型的着色器:顶点(Vertex)着色器和片段(Fragment)着色器。顶点着色器可以进行计算以确定每个顶点(三角形的角)的位置。片段着色器计算出如何为三角形内的每个片段(像素)着色。
这两个着色器相似,但在不同的时间做不同的事情。顶点着色器首先运行,以确定每个三角形的去向,然后它可以将一些信息传递给片段着色器,因此片段着色器可以计算出如何绘制每个三角形。
这是一个基本的顶点着色器,它将接收一个带有 x,y 坐标的向量。向量基本上只是一个具有固定长度的数组。vec2是有 2 个数字的数组,vec4是有 4 个数字的数组。所以,这个程序将采用一个全局“属性”变量,一个名为“points”的 vec2(这是我编的一个名字)。
然后它会告诉 GPU,这正是顶点将要去的地方,方法是将它分配给另一个内置于 GLSL 中的名为gl_Position 的变量。
它将针对三角形的每个顶点运行,并且每次points都有不同的 x,y 值。稍后你将看到我们如何定义和传递这些坐标。
这是我们的第一个“你好,世界!” 顶点着色器程序:
attribute vec2 points;
void main(void) {
gl_Position = vec4(points, 0.0, 1.0);
}
此处不涉及任何计算,只是我们需要将 vec2 转换为 vec4。前两个数字是 x 和 y,第三个是 z,我们只需将其设置为 0.0,因为我们正在绘制二维图片,我们不需要担心第三维。我不知道第四个值是什么意思,但我们只是将其设置为 1.0。根据我的阅读,我认为这与使矩阵数学更容易有关。
我喜欢 GLSL 中的这一点,向量是一种基本数据类型,你可以使用其他向量轻松创建向量。我们可以这样写上面的行:
gl_Position = vec4(points[0], points[1], 0.0, 1.0);
但相反,我们能够使用快捷方式,只需将 vec2 点作为第一个参数传入,GLSL 就知道该怎么做。它让我想起了在 JavaScript 中使用扩展运算符:
// javascript
gl_Position = [...points, 0.0, 1.0];
因此,如果角形角之一的 x 为 0.2,y 为 0.3,我们的代码将有效地执行以下操作:
gl_Position = vec4(0.2, 0.3, 0.0, 1.0);
但是我们不能像这样将 x 和 y 坐标硬编码到我们的程序中,否则所有的三角形都只是屏幕上的一个点。我们使用属性向量(Attribute)代替,以便每个角(或顶点)可以位于不同的位置。
顶点着色器为每个三角形的每个角运行一次,而片段着色器为每个三角形内的每个彩色像素运行一次。
顶点着色器使用名为 gl_Position的全局 vec4 变量定义每个顶点的位置,而片段着色器通过使用名为gl_FragColor 的局 vec4 变量定义每个像素的颜色。以下是我们如何用红色像素填充所有三角形:
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
这里颜色向量是 RGBA,因此红色、绿色、蓝色和 alpha 中的每一个都是介于 0 和 1 之间的数字。所以上面的例子只是将每个片段或像素设置为完全不透明的亮红色。
你通常不会用纯色填充所有三角形,因此,我们希望片段着色器引用图像(或“纹理”)并为三角形内的每个像素提取正确的颜色。
我们需要使用颜色信息访问纹理,以及一些告诉我们图像如何映射到形状上的“纹理坐标”。
首先,我们将修改顶点着色器以访问坐标并将它们传递给片段着色器:
attribute vec2 points;
attribute vec2 texture_coordinate;
varying highp vec2 v_texture_coordinate;
void main(void) {
gl_Position = vec4(points, 0.0, 1.0);
v_texture_coordinate = texture_coordinate;
}
如果你像我一样,可能会担心会需要各种疯狂的三角函数,但别担心 - 事实证明这是最简单的部分,这要归功于 GPU 的魔力。
我们为每个顶点获取一个纹理坐标,然后将其传递给变量中的片段着色器,该varying变量将“插入”每个片段或像素的坐标。这本质上是两个维度的百分比,因此对于三角形内的任何特定像素,我们将准确知道要选择图像的哪个像素。
图像存储在一个名为 sampler的二维采样器变量中。我们从顶点着色器接收varying纹理坐标,并使用GLSL 函数texture2D从纹理中采样适当的单个像素。
这听起来很复杂,但由于 GPU 的魔力,它变得非常简单。我们需要做任何数学运算的唯一部分是将三角形的每个顶点坐标与图像的坐标相关联,稍后我们将看到它变得非常简单。
precision highp float;
varying highp vec2 v_texture_coordinate;
uniform sampler2D sampler;
void main() {
gl_FragColor = texture2D(sampler, v_texture_coordinate);
}
我们刚刚研究了如何使用 GLSL 编写两个不同的着色器,但我们还没有讨论过如何在 JavaScript 中做到这一点。只需要将这些 GLSL 着色器转换为 JavaScript 字符串,然后我们就可以使用 WebGL API 编译它们并将它们放在 GPU 上。
有些人喜欢把shader源代码直接放在HTML中使用
我发现直接在 JavaScript 中使用模板字符串编写着色器源代码是最简单的。看起来是这样的:
const vertexShaderSource = `
attribute vec2 points;
attribute vec2 texture_coordinate;
varying highp vec2 v_texture_coordinate;
void main(void) {
gl_Position = vec4(points, 0.0, 1.0);
v_texture_coordinate = texture_coordinate;
}
`;
const fragmentShaderSource = `
precision highp float;
varying highp vec2 v_texture_coordinate;
uniform sampler2D sampler;
void main() {
gl_FragColor = texture2D(sampler, v_texture_coordinate);
}
`;
接下来,我们需要创建一个 GL“程序”并将这两个不同的着色器添加到其中,如下所示:
// create a program (which we'll access later)
const program = gl.createProgram();
// create a new vertex shader and a fragment shader
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
// specify the source code for the shaders using those strings
gl.shaderSource(vertexShader, vertexShaderSource);
gl.shaderSource(fragmentShader, fragmentShaderSource);
// compile the shaders
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);
// attach the two shaders to the program
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
最后,我们必须告诉 GL 链接并使用我们刚刚创建的程序。请注意,一次只能使用一个程序:
gl.linkProgram(program);
gl.useProgram(program);
如果程序出现问题,我们应该将错误记录到控制台。否则,它将默默地失败:
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
}
如您所见,WebGL API 非常冗长。但是,如果仔细查看这些代码,会发现它们并没有做任何太令人惊讶的事情。这些代码块非常适合复制和粘贴,因为很难记住它们,而且它们很少更改。你可能需要更改的唯一部分是模板字符串中的着色器源代码。
现在我们的程序已经全部连接好,是时候给它一些坐标并让它在屏幕上绘制一些三角形了!
首先,我们需要了解 WebGL 的默认坐标系。它与屏幕上的常规像素坐标系完全不同。在 WebGL 中,画布的中心是 0,0,左上角是 -1,-1,右下角是 1,1。
如果我们想渲染一张照片,需要一个矩形。但是 WebGL 只知道如何绘制三角形。那么我们如何使用三角形绘制一个矩形呢?我们可以使用两个三角形来创建一个矩形。我们将有一个三角形覆盖左上角,另一个覆盖右下角,如下所示:
要绘制三角形,需要指定每个三角形三个角的坐标。让我们创建一个数字数组。两个三角形的 x 和 y 坐标都将在一个数组中,如下所示:
const points = [
// first triangle
// top left
-1, -1,
// top right
1, -1,
// bottom left
-1, 1,
// second triangle
// bottom right
1, 1,
// top right
1, -1,
// bottom left
-1, 1,
];
要将数字列表传递到我们的着色器程序中,我们必须创建一个“缓冲区”,然后将一个数组加载到缓冲区中,然后告诉 WebGL 将缓冲区中的数据用于我们的着色器程序中的属性。
我们不能只将 JavaScript 数组加载到 GPU 中,它必须是严格类型的。所以我们把它包装在一个Float32Array 中。 我们也可以使用整数或任何对我们的数据有意义的类型,但对于坐标,浮点数最有意义。
// create a buffer
const pointsBuffer = gl.createBuffer();
// activate the buffer, and specify that it contains an array
gl.bindBuffer(gl.ARRAY_BUFFER, pointsBuffer);
// upload the points array to the active buffer
// gl.STATIC_DRAW tells the GPU this data won't change
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);
请记住,我在着色器程序的顶部创建了一个名为“points”的属性,带有attribute vec2 points;? 现在我们的数据在缓冲区中,并且缓冲区处于活动状态,我们可以用需要的坐标填充那个“points”属性:
// get the location of our "points" attribute in our shader program
const pointsLocation = gl.getAttribLocation(program, 'points');
// pull out pairs of float numbers from the active buffer
// each pair is a vertex that will be available in our vertex shader
gl.vertexAttribPointer(pointsLocation, 2, gl.FLOAT, false, 0, 0);
// enable the attribute in the program
gl.enableVertexAttribArray(pointsLocation);
在 WebGL 中,纹理是一种在网格中提供大量数据的方法,这些数据可用于将像素绘制到形状上。图像是一个明显的例子,它们是沿行和列的红色、蓝色、绿色和 alpha 值的网格。但是,你可以将纹理用于根本不是图像的事物。就像计算机中的所有信息一样,它最终只是数字列表。
由于我们在浏览器中,我们可以使用常规的 JavaScript 代码来加载图像。加载图像后,我们将使用它来填充纹理。
在我们执行任何 WebGL 代码之前先加载图像可能是最简单的,然后在图像加载后运行整个 WebGL 初始化的东西,所以我们不需要等待任何东西,像这样:
const img = new Image();
img.src = 'photo.jpg';
img.onload = () => {
// assume this runs all the code we've been writing so far
initializeWebGLStuff();
};
现在我们的图像已经加载,我们可以创建一个纹理并将图像数据上传到其中。
// create a new texture
const texture = gl.createTexture();
// specify that our texture is 2-dimensional
gl.bindTexture(gl.TEXTURE_2D, texture);
// upload the 2D image (img) and specify that it contains RGBA data
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
由于我们的图像可能不是2的N次幂,因此还必须告诉 WebGL 在放大或缩小图像时如何选择要绘制的像素,否则会抛出错误。
// tell WebGL how to choose pixels when drawing our non-square 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);
// bind this texture to texture #0
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
最后,我们想在着色器程序中访问这个纹理。我们用代码uniform sampler2D sampler;定义了一个二维 uniform变量,告诉 GPU 应该使用我们的新纹理。
// use the texture for the uniform in our program called "sampler",
gl.uniform1i(gl.getUniformLocation(program, 'sampler'), 0);
我们快完成了!下一步非常重要。我们需要告诉着色器我们的图像应该如何绘制到三角形上。我们希望将图像的左上角绘制在左上三角形的左上角。等等。
图像纹理的坐标系与我们使用的三角形不同,所以我们必须考虑一下,不幸的是不能只使用完全相同的坐标。以下是它们的不同之处:
纹理坐标应该与我们的三角形顶点坐标的顺序完全相同,因为这就是它们在顶点着色器中一起显示的方式。当我们的顶点着色器为每个顶点运行时,它还能够访问每个纹理坐标,并将其作为varying变量传递给片段着色器。
我们将使用与上传三角坐标数组几乎相同的代码,只是现在我们将把它与名为“texture_coordinate”的属性相关联。
const textureCoordinates = [
// first triangle
// top left
0, 1,
// top right
1, 1,
// bottom left
0, 0,
// second triangle
// bottom right
1, 0,
// top right
1, 1,
// bottom left
0, 0,
];
// same stuff we did earlier, but passing different numbers
const textureCoordinateBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordinateBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoordinates), gl.STATIC_DRAW);
// and associating it with a different attribute
const textureCoordinateLocation = gl.getAttribLocation(program, 'texture_coordinate');
gl.vertexAttribPointer(textureCoordinateLocation, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(textureCoordinateLocation);
现在我们已经将着色器、所有坐标和图像加载到 GPU 中,我们已经准备好实际运行着色器程序并让它将我们的图像绘制到画布上。
为此,我们只需要一行代码:
gl.drawArrays(gl.TRIANGLES, 0, 6);
这告诉 WebGL 使用我们的点数组和纹理坐标数组来绘制三角形。这里的数字6意味着我们数组中的每 6 个数字定义一个三角形。每个三角形都有 3 个角,每个角(或顶点)都有一个 x 和 y 坐标。
使用 GPU 绘制图像需要学习许多不同的东西,我发现这存在一个陡峭的学习曲线,但是一旦了解着色器的实际作用、纹理是什么、如何为着色器提供一些数字列表以及它们如何组合在一起,它就开始变得有意义了,并且我意识到这一切有多么强大。
我希望你已经能够看到一些简单和强大的功能。我知道 WebGL API 可能非常冗长,而且我仍然不能完全确定每个函数的作用,这对我来说绝对是一种新的编程范式,因为 GPU 与 CPU 如此不同,但这就是它如此令人兴奋的原因。
原文链接:WebGL着色器简明教程 — BimAnt