3D 图形:WebGL 教程
已发表: 2022-03-11进入 3D 图形的世界可能非常令人生畏。 无论您只是想创建一个交互式 3D 徽标,还是设计一个成熟的游戏,如果您不了解 3D 渲染的原理,您就会被一个抽象出很多东西的库所困。
使用库可能是正确的工具,JavaScript 有一个令人惊叹的开源工具,以 three.js 的形式。 但是,使用预制解决方案有一些缺点:
- 它们可能具有许多您不打算使用的功能。 缩小的基础 three.js 功能的大小约为 500kB,任何额外的功能(加载实际模型文件就是其中之一)使有效负载更大。 传输这么多数据只是为了在您的网站上显示一个旋转的徽标将是一种浪费。
- 额外的抽象层可以使原本简单的修改变得困难。 您在屏幕上为对象着色的创造性方法可以直接实现,也可以需要数十小时的工作才能融入库的抽象中。
- 虽然该库在大多数情况下都得到了很好的优化,但可以为您的用例剪掉很多花里胡哨的东西。 渲染器可以使某些程序在显卡上运行数百万次。 从此类过程中删除的每条指令都意味着较弱的图形卡可以毫无问题地处理您的内容。
即使您决定使用高级图形库,对底层事物的基本知识也可以让您更有效地使用它。 库还可以具有高级功能,例如ShaderMaterial
中的three.js
。 了解图形渲染的原理可以让您使用这些功能。
我们的目标是简要介绍渲染 3D 图形和使用 WebGL 实现它们背后的所有关键概念。 您将看到最常见的操作,即在空白空间中显示和移动 3D 对象。
最终代码可供您分叉和使用。
表示 3D 模型
您需要了解的第一件事是如何表示 3D 模型。 模型由三角形网格构成。 对于三角形的每个角,每个三角形由三个顶点表示。 有三个最常见的属性附加到顶点。
顶点位置
位置是顶点最直观的属性。 它是 3D 空间中的位置,由 3D 坐标向量表示。 如果您知道空间中三个点的确切坐标,您将拥有在它们之间绘制一个简单三角形所需的所有信息。 为了使模型在渲染时看起来真的很好,还需要向渲染器提供一些东西。
顶点法线
考虑上面的两个模型。 它们由相同的顶点位置组成,但在渲染时看起来完全不同。 这怎么可能?
除了告诉渲染器我们想要一个顶点的位置,我们还可以给它一个关于表面在那个确切位置是如何倾斜的提示。 提示采用模型上特定点的表面法线的形式,用 3D 矢量表示。 下图应该让您更清楚地了解它是如何处理的。
左右表面分别对应上图中的左右球。 红色箭头表示为顶点指定的法线,而蓝色箭头表示渲染器对法线应如何查找顶点之间的所有点的计算。 该图像显示了 2D 空间的演示,但同样的原理也适用于 3D。
法线是灯光如何照亮表面的提示。 光线的方向越接近法线,点越亮。 法线方向的渐变会导致光梯度,而中间没有变化的突然变化会导致表面具有恒定的照明,以及它们之间的照明突然变化。
纹理坐标
最后一个重要的属性是纹理坐标,通常称为 UV 映射。 您有一个模型和一个要应用到它的纹理。 纹理上有不同的区域,代表我们想要应用于模型不同部分的图像。 必须有一种方法来标记哪个三角形应该用纹理的哪个部分来表示。 这就是纹理映射的用武之地。
对于每个顶点,我们标记两个坐标,U 和 V。这些坐标代表纹理上的一个位置,其中 U 代表水平轴,V 代表垂直轴。 这些值不是以像素为单位,而是图像中的百分比位置。 图像的左下角用两个 0 表示,而右上角用两个 1 表示。
通过获取三角形中每个顶点的 UV 坐标,并在纹理上应用在这些坐标之间捕获的图像来绘制三角形。
您可以在上图中看到 UV 映射的演示。 采用球形模型,并将其切成小到可以展平到 2D 表面上的部分。 进行切割的接缝用粗线标记。 其中一个补丁已突出显示,因此您可以很好地看到事物的匹配方式。 您还可以看到穿过微笑中间的接缝如何将嘴巴的各个部分分成两个不同的补丁。
线框不是纹理的一部分,只是覆盖在图像上,这样您就可以看到事物是如何映射在一起的。
加载 OBJ 模型
信不信由你,这就是创建自己的简单模型加载器所需的全部知识。 OBJ 文件格式非常简单,只需几行代码即可实现解析器。
该文件以v <float> <float> <float>
格式列出顶点位置,并带有可选的第四个浮点数,我们将忽略它,以保持简单。 顶点法线用vn <float> <float> <float>
类似地表示。 最后,纹理坐标用vt <float> <float>
表示,还有一个可选的第三个浮点数,我们将忽略它。 在所有三种情况下,浮点数代表各自的坐标。 这三个属性累积在三个数组中。
面用一组顶点表示。 每个顶点用每个属性的索引表示,索引从 1 开始。有多种表示方式,但我们将坚持f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3
格式,要求提供所有三个属性,并将每个面的顶点数限制为三个。 所有这些限制都是为了使加载器尽可能简单,因为所有其他选项都需要一些额外的琐碎处理才能采用 WebGL 喜欢的格式。
我们对文件加载器提出了很多要求。 这可能听起来有限制,但 3D 建模应用程序往往使您能够在将模型导出为 OBJ 文件时设置这些限制。
以下代码解析表示 OBJ 文件的字符串,并以面数组的形式创建模型。
function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }
Geometry
结构包含将模型发送到图形卡进行处理所需的确切数据。 不过,在您这样做之前,您可能希望能够在屏幕上移动模型。
执行空间变换
我们加载的模型中的所有点都相对于它的坐标系。 如果我们想要平移、旋转和缩放模型,我们需要做的就是在其坐标系上执行该操作。 相对于坐标系 B 的坐标系 A 由其中心的位置定义为向量p_ab
,其每个轴的向量x_ab
、 y_ab
和z_ab
表示该轴的方向。 因此,如果一个点在坐标系 A 的x
轴上移动 10,那么——在坐标系 B 中——它将向x_ab
的方向移动,乘以 10。
所有这些信息都存储在以下矩阵形式中:
x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1
如果我们想变换 3D 向量q
,我们只需要将变换矩阵与向量相乘:
qx qy qz 1
这导致该点沿新的x
轴移动qx
,沿新的y
轴移动qy
,并沿新的z
轴移动qz
。 最后,它使点额外移动了p
向量,这就是我们使用 1 作为乘法的最后一个元素的原因。
使用这些矩阵的最大优势在于,如果我们要对顶点执行多个变换,我们可以在变换顶点本身之前通过将它们的矩阵相乘来将它们合并为一个变换。
可以执行各种转换,我们将看看关键的转换。
没有转型
如果没有发生变换,则p
向量是零向量, x
向量是[1, 0, 0]
, y
是[0, 1, 0]
, z
是[0, 0, 1]
。 从现在开始,我们将这些值称为这些向量的默认值。 应用这些值会给我们一个单位矩阵:
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
这是链接转换的一个很好的起点。
翻译
当我们执行平移时,除了p
向量之外的所有向量都有它们的默认值。 这导致以下矩阵:
1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1
缩放
缩放模型意味着减少每个坐标对点位置的贡献量。 由于缩放没有统一的偏移量,所以p
向量保持其默认值。 默认轴向量应乘以它们各自的缩放因子,得到以下矩阵:
s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1
这里s_x
、 s_y
和s_z
表示应用于每个轴的缩放。
回转
上图显示了当我们围绕 Z 轴旋转坐标系时会发生什么。
旋转导致没有统一的偏移量,因此p
向量保持其默认值。 现在事情变得有点棘手。 旋转会导致沿原始坐标系中某个轴的移动向不同方向移动。 因此,如果我们将坐标系绕 Z 轴旋转 45 度,沿原始坐标系的x
轴移动会导致新坐标系中x
轴和y
轴之间的对角线方向移动。
为简单起见,我们将向您展示变换矩阵如何寻找围绕主轴的旋转。
Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1
执行
所有这些都可以实现为一个存储 16 个数字的类,以列优先顺序存储矩阵。
function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }
透过相机看
这是在屏幕上呈现对象的关键部分:相机。 相机有两个关键组件; 即它的位置,以及它如何将观察到的物体投射到屏幕上。
相机位置通过一个简单的技巧来处理。 将相机向前移动一米和将整个世界向后移动一米之间没有视觉上的区别。 所以很自然,我们通过将矩阵的逆作为变换来做后者。
第二个关键组成部分是将观察到的物体投影到镜头上的方式。 在 WebGL 中,屏幕上可见的所有内容都位于一个框内。 该框在每个轴上的范围在 -1 和 1 之间。 所有可见的东西都在那个盒子里。 我们可以使用相同的变换矩阵方法来创建投影矩阵。
正交投影
最简单的投影是正交投影。 你在空间中取一个盒子,表示宽度、高度和深度,假设它的中心在零位置。 然后,投影会调整框的大小以使其适合之前描述的 WebGL 观察对象的框。 由于我们想将每个维度调整为 2,我们将每个轴缩放2/size
,其中size
是相应轴的维度。 一个小警告是我们将 Z 轴乘以负数。 这样做是因为我们想要翻转该维度的方向。 最终矩阵具有以下形式:
2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1
透视投影
我们不会详细介绍这个投影是如何设计的,而只是使用最终的公式,这已经是非常标准的了。 我们可以通过将投影放置在 x 和 y 轴上的零位置来简化它,使右/左和上/下限分别等于width/2
和height/2
。 参数n
和f
表示near
和far
裁剪平面,它们是相机可以捕获的点的最小和最大距离。 它们由上图中的平截头体的平行边表示。
透视投影通常用视野(我们将使用垂直的)、纵横比以及近平面和远平面距离来表示。 该信息可用于计算width
和height
,然后可以从以下模板创建矩阵:
2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0
要计算宽度和高度,可以使用以下公式:
height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height
FOV(视野)表示相机用镜头捕捉到的垂直角度。 纵横比表示图像宽度和高度之间的比率,并且基于我们要渲染到的屏幕的尺寸。
执行
现在我们可以将相机表示为一个存储相机位置和投影矩阵的类。 我们还需要知道如何计算逆变换。 解决一般矩阵求逆可能会出现问题,但对于我们的特殊情况,有一种简化的方法。
function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }
这是我们开始在屏幕上绘制东西之前需要的最后一块。
使用 WebGL 图形管道绘制对象
您可以绘制的最简单的表面是三角形。 事实上,您在 3D 空间中绘制的大部分内容都由大量三角形组成。
您需要了解的第一件事是屏幕在 WebGL 中是如何表示的。 它是一个 3D 空间,在x 、 y和z轴上跨越 -1 和 1。 默认情况下不使用此z轴,但您对 3D 图形感兴趣,因此您需要立即启用它。
考虑到这一点,接下来是在此表面上绘制三角形所需的三个步骤。
您可以定义三个顶点,它们代表您要绘制的三角形。 您序列化该数据并将其发送到 GPU(图形处理单元)。 使用可用的整个模型,您可以对模型中的所有三角形执行此操作。 您给出的顶点位置在您加载的模型的局部坐标空间中。 简而言之,您提供的位置是文件中的确切位置,而不是执行矩阵变换后得到的位置。
现在您已经将顶点提供给 GPU,您可以告诉 GPU 在将顶点放置到屏幕上时使用什么逻辑。 这一步将用于应用我们的矩阵变换。 GPU 非常擅长将大量 4x4 矩阵相乘,因此我们将充分利用这种能力。
在最后一步,GPU 将光栅化该三角形。 光栅化是获取矢量图形并确定需要为要显示的矢量图形对象绘制屏幕的哪些像素的过程。 在我们的例子中,GPU 试图确定哪些像素位于每个三角形内。 对于每个像素,GPU 会询问您希望将其绘制成什么颜色。
这些是绘制您想要的任何东西所需的四个元素,它们是图形管道的最简单示例。 接下来是对它们中的每一个的了解,以及一个简单的实现。
默认帧缓冲区
WebGL 应用程序最重要的元素是 WebGL 上下文。 您可以使用gl = canvas.getContext('webgl')
访问它,或者使用'experimental-webgl'
作为后备,以防当前使用的浏览器尚不支持所有 WebGL 功能。 我们提到的canvas
是我们要在其上绘制的画布的 DOM 元素。 上下文包含很多东西,其中包括默认的帧缓冲区。
您可以将帧缓冲区松散地描述为可以绘制的任何缓冲区(对象)。 默认情况下,默认帧缓冲区存储 WebGL 上下文绑定到的画布的每个像素的颜色。 如上一节所述,当我们在帧缓冲区上绘制时,每个像素位于x和y轴上的 -1 和 1 之间。 我们还提到的事实是,默认情况下,WebGL 不使用z轴。 该功能可以通过运行gl.enable(gl.DEPTH_TEST)
来启用。 很好,但什么是深度测试?
启用深度测试允许像素存储颜色和深度。 深度是该像素的z坐标。 绘制到某个深度z的像素后,要更新该像素的颜色,您需要在更靠近相机的z位置进行绘制。 否则,抽签尝试将被忽略。 这允许 3D 的错觉,因为在其他对象后面绘制对象将导致这些对象被它们前面的对象遮挡。
您执行的任何抽奖都会保留在屏幕上,直到您告诉他们清除为止。 为此,您必须调用gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
。 这将清除颜色和深度缓冲区。 要选择清除像素设置的颜色,请使用gl.clearColor(red, green, blue, alpha)
。
让我们创建一个使用画布并根据请求清除它的渲染器:
function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }
将此脚本附加到以下 HTML 将在屏幕上显示一个亮蓝色矩形
<!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>
requestAnimationFrame
调用会导致在前一帧完成渲染并且所有事件处理完成后再次调用循环。
顶点缓冲区对象
您需要做的第一件事是定义要绘制的顶点。 您可以通过 3D 空间中的向量来描述它们来做到这一点。 之后,您希望通过创建一个新的顶点缓冲区对象(VBO) 将该数据移动到 GPU RAM。
缓冲区对象通常是在 GPU 上存储内存块数组的对象。 它是一个 VBO 只是表示 GPU 可以将内存用于什么。 大多数时候,您创建的缓冲区对象将是 VBO。
您可以通过获取我们拥有的所有N
个顶点并创建一个浮点数组来填充 VBO,其中3N
个元素用于顶点位置和顶点法线 VBO, 2N
个元素用于纹理坐标 VBO。 每组三个浮点数或两个用于 UV 坐标的浮点数表示顶点的各个坐标。 然后我们将这些数组传递给 GPU,我们的顶点已准备好用于管道的其余部分。
由于数据现在位于 GPU RAM 上,因此您可以将其从通用 RAM 中删除。 也就是说,除非您想稍后修改它,然后再次上传。 每次修改后都需要上传,因为我们的 JS 数组中的修改不适用于实际 GPU RAM 中的 VBO。
下面是一个代码示例,它提供了所有描述的功能。 需要注意的重要一点是,存储在 GPU 上的变量不会被垃圾收集。 这意味着一旦我们不想再使用它们,就必须手动删除它们。 我们将在这里为您提供一个示例,说明如何做到这一点,并且不会进一步关注该概念。 仅当您计划在整个程序中停止使用某些几何图形时,才需要从 GPU 中删除变量。
我们还向Geometry
类和其中的元素添加了序列化。
Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }
VBO
数据类型基于作为第二个参数传递的数组在传递的 WebGL 上下文中生成 VBO。
您可以看到对gl
上下文的三个调用。 createBuffer()
调用创建缓冲区。 bindBuffer()
调用告诉 WebGL 状态机将此特定内存用作所有未来操作的当前 VBO ( ARRAY_BUFFER
),除非另有说明。 之后,我们使用bufferData()
将当前 VBO 的值设置为提供的数据。
我们还提供了一个 destroy 方法,通过使用deleteBuffer()
从 GPU RAM 中删除我们的缓冲区对象。
您可以使用三个 VBO 和一个转换来描述网格的所有属性及其位置。
function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }
例如,下面是我们如何加载模型,将其属性存储在网格中,然后销毁它:
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })
着色器
接下来是前面描述的将点移动到所需位置并绘制所有单个像素的两步过程。 为此,我们编写了一个在显卡上多次运行的程序。 该程序通常由至少两个部分组成。 第一部分是Vertex Shader ,它为每个顶点运行,并输出我们应该在屏幕上放置顶点的位置,等等。 第二部分是Fragment Shader ,它针对三角形在屏幕上覆盖的每个像素运行,并输出该像素应绘制的颜色。
顶点着色器
假设您想要一个在屏幕上左右移动的模型。 在一种简单的方法中,您可以更新每个顶点的位置并将其重新发送到 GPU。 这个过程既昂贵又缓慢。 或者,您可以为 GPU 提供一个针对每个顶点运行的程序,并与专为完成该工作而构建的处理器并行执行所有这些操作。 这就是顶点着色器的作用。
顶点着色器是处理单个顶点的渲染管道的一部分。 对顶点着色器的调用接收单个顶点并在应用对顶点的所有可能变换后输出单个顶点。
着色器是用 GLSL 编写的。 这种语言有很多独特的元素,但大部分语法都非常类似于 C,因此大多数人应该可以理解。
有三种类型的变量进出顶点着色器,它们都有特定的用途:
-
attribute
——这些是保存顶点特定属性的输入。 之前,我们以三元素向量的形式将顶点的位置描述为一个属性。 您可以将属性视为描述一个顶点的值。 -
uniform
— 这些输入对于同一渲染调用中的每个顶点都是相同的。 假设我们希望能够通过定义一个变换矩阵来移动我们的模型。 您可以使用uniform
变量来描述它。 您也可以指向 GPU 上的资源,例如纹理。 您可以将制服视为描述模型或模型一部分的值。 -
varying
——这些是我们传递给片段着色器的输出。 由于顶点三角形可能有数千个像素,因此每个像素将根据位置接收此变量的插值。 因此,如果一个顶点发送 500 作为输出,另一个发送 100,则位于它们中间的像素将接收 300 作为该变量的输入。 您可以将变量视为描述顶点之间的曲面的值。
因此,假设您要创建一个顶点着色器,它接收每个顶点的位置、法线和 uv 坐标,以及每个渲染对象的位置、视图(逆相机位置)和投影矩阵。 假设您还想根据 uv 坐标和法线绘制单个像素。 “那个代码看起来怎么样?” 你可能会问。

attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }
这里的大部分元素应该是不言自明的。 需要注意的关键是main
函数中没有返回值。 我们想要返回的所有值都被分配给varying
变量或特殊变量。 在这里,我们分配给gl_Position
,这是一个四维向量,因此最后一维应始终设置为 1。 您可能会注意到的另一件奇怪的事情是我们从位置向量构造vec4
的方式。 您可以使用四个float
、两个vec2
或任何其他产生四个元素的组合来构造一个vec4
。 一旦您熟悉了转换矩阵,就会有很多看似奇怪的类型转换非常有意义。
您还可以看到,在这里我们可以非常轻松地执行矩阵变换。 GLSL 专为此类工作而设计。 输出位置是通过将投影、视图和模型矩阵相乘并将其应用于位置来计算的。 The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.
For now, we will keep it simple, and move on to painting individual pixels.
片段着色器
A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.
The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:
- There are no more
varying
outputs, andattribute
inputs have been replaced withvarying
inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader. - Our only output now is
gl_FragColor
, which is avec4
. The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects. - At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.
With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }
The function clamp
just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.
With all of this in mind, all that is left is to implement this in WebGL.
Combining Shaders into a Program
The next step is to combine the shaders into a program:
function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }
There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.
Actually Drawing the Model
Last, but not least, you draw the model.
First you pick the shader program you want to use.
ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }
Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.
Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }
Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.
VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }
Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays()
from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES
takes three by three vertices and draws a triangle for each triplet. Using POINTS
would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:
Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }
The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.
Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
We can combine all the elements that we have to finally draw something on the screen:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) }
This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); }
It doesn't look very convincing. It looks like the scene needs some shading effects.
添加光
Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.
Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.
You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.
If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); }
我们设置了太阳在前左下方向照耀。 您可以看到阴影的平滑程度,即使模型非常参差不齐。 您还可以注意到左下角有多暗。 我们可以添加一个级别的环境光,这将使阴影中的区域更亮。
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
您可以通过引入一个光照类来实现同样的效果,该类存储光照方向和环境光强度。 然后您可以更改片段着色器以适应该添加。
现在着色器变为:
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
然后你可以定义光:
function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }
在着色器程序类中,添加所需的制服:
this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')
在程序中,在渲染器中添加对新灯光的调用:
Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
然后循环将略有变化:
var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
如果你做的一切都正确,那么渲染的图像应该与上一张图像中的相同。
最后一个要考虑的步骤是为我们的模型添加一个实际的纹理。 现在让我们这样做。
添加纹理
HTML5 对加载图片有很好的支持,所以不需要做疯狂的图片解析。 通过告诉着色器要对哪些绑定纹理进行采样,图像作为sampler2D
传递给 GLSL。 可以绑定的纹理数量有限,并且该限制取决于所使用的硬件。 可以在特定位置查询sampler2D
的颜色。 这就是 UV 坐标的用武之地。这是一个示例,我们用采样颜色替换了棕色。
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
必须将新制服添加到着色器程序的列表中:
this.diffuse = gl.getUniformLocation(program, 'diffuse')
最后,我们将实现纹理加载。 如前所述,HTML5 提供了加载图像的功能。 我们需要做的就是将图像发送到 GPU:
function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }
该过程与用于加载和绑定 VBO 的过程没有太大区别。 主要区别在于我们不再绑定到属性,而是将纹理的索引绑定到整数统一。 sampler2D
类型只不过是指向纹理的指针偏移量。
现在需要做的就是扩展Mesh
类来处理纹理:
function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }
最终的主脚本如下所示:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
在这一点上,即使是动画也很容易。 如果您希望相机围绕我们的对象旋转,您只需添加一行代码即可:
function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) }
随意使用着色器。 添加一行代码会将这种逼真的照明变成卡通化的东西。
void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
这就像告诉照明是否超过设定的阈值一样简单。
下一步去哪里
有许多信息来源可用于学习 WebGL 的所有技巧和复杂性。 最好的部分是,如果您找不到与 WebGL 相关的答案,您可以在 OpenGL 中查找它,因为 WebGL 几乎基于 OpenGL 的一个子集,其中一些名称已更改。
没有特别的顺序,这里有一些关于 WebGL 和 OpenGL 的详细信息的重要来源。
- WebGL 基础
- 学习 WebGL
- 一个非常详细的 OpenGL 教程,以非常缓慢和详细的方式指导您了解此处描述的所有基本原理。
- 还有许多其他网站专门教您计算机图形学的原理。
- WebGL 的 MDN 文档
- Khronos WebGL 1.0 规范,如果您有兴趣了解 WebGL API 如何在所有边缘情况下工作的更多技术细节。