作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Adnan Ademovic's profile image

Adnan Ademovic

Adnan拥有桌面、嵌入式和分布式系统方面的经验. 他对c++、Python和其他语言进行了广泛的研究.

Years of Experience

9

Share

3D图像的世界可能非常令人生畏. 无论你只是想创建一个交互式的3D标志, or design a fully fledged game, 如果你不知道3D渲染的原理, 你被困在使用一个抽象出很多东西的库.

使用库可能是正确的工具,并且 JavaScript 有一个很棒的开源软件吗 three.js. 不过,使用预制解决方案也有一些缺点:

  • 它们可能具有许多您不打算使用的功能. 缩小的以三为底的尺寸.js features is around 500kB, 任何额外的特性(加载实际的模型文件就是其中之一)都会使负载更大. 传输这么多数据只是为了在你的网站上显示一个旋转的标志,这将是一种浪费.
  • 额外的抽象层会使其他简单的修改变得困难. 您在屏幕上为对象着色的创造性方法可以直接实现,也可以需要数十小时的工作来合并到库的抽象中.
  • 虽然库在大多数情况下都得到了很好的优化, 可以为您的用例省去许多附加功能. 渲染器可以使某些程序在显卡上运行数百万次. 从这种过程中删除的每一条指令都意味着较弱的显卡可以毫无问题地处理您的内容.

即使您决定使用高级图形库, 对底层事物有基本的了解可以让你更有效地使用它. 库也可以具有高级特性,比如 ShaderMaterial in three.js. 了解图形渲染的原理可以让您使用这些特性.

WebGL画布上的3D Toptal徽标插图

我们的目标是简要介绍渲染3D图形和使用WebGL实现它们背后的所有关键概念. 你会看到最常见的事情, 即在空白空间中显示和移动3D物体.

The final code 是否可供您随意使用.

Representing 3D Models

首先你需要了解的是3D模型是如何表示的. 模型是由三角形网格构成的. 每个三角形的每个角都由三个顶点表示. 顶点有三个最常见的属性.

Vertex Position

位置是顶点最直观的属性. 它是三维空间中的位置,由三维坐标向量表示. 如果你知道空间中三个点的确切坐标, 你就有了在它们之间画一个简单三角形所需的所有信息. 使模型在渲染时看起来很好, 还有一些东西需要提供给渲染器.

Vertex Normal

具有相同线框的球体,应用了平坦和平滑的阴影

Consider the two models above. 它们由相同的顶点位置组成,但在渲染时看起来完全不同. How is that possible?

除了告诉渲染器我们想要一个顶点的位置, 我们还可以给它一个提示,告诉它表面在那个位置是如何倾斜的. 提示的形式是曲面在模型上特定点处的法线, represented with a 3D vector. 下面的图片应该会给你一个更生动的描述,看看这是如何处理的.

比较平面和平滑阴影的法线

左边和右边的表面分别对应于前面图像中的左边和右边的球. 红色箭头表示为顶点指定的法线, 而蓝色箭头表示渲染器的计算法线应该如何寻找所有顶点之间的点. 该图像显示了二维空间的演示,但同样的原理适用于三维空间.

法线暗示了光线将如何照亮表面. 光线的方向越接近法线,点就越亮. 在法线方向上逐渐变化引起光梯度, 而突然变化之间没有变化导致表面上有恒定的光照, 以及它们之间光照的突然变化.

Texture Coordinates

最后一个重要的属性是纹理坐标,通常称为UV映射. 你有一个模型,还有一个你想要应用的纹理. 纹理上有不同的区域, 表示我们想要应用于模型不同部分的图像. 必须有一种方法来标记哪个三角形应该用纹理的哪个部分表示. 这就是纹理映射的由来.

对于每个顶点,我们标记两个坐标,U和V. 这些坐标表示纹理上的一个位置, U表示横轴, and V the vertical axis. 这些值不是以像素为单位,而是以图像中的百分比位置为单位. 图像的左下角用两个0表示, 而右上方则用两个1表示.

三角形只是通过取三角形中每个顶点的UV坐标来绘制的, 并将在这些坐标之间捕获的图像应用到纹理上.

演示UV贴图,一个补丁突出显示,接缝在模型上可见

你可以在上面的图片中看到UV映射的演示. The spherical model was taken, 并切成足够小的部分,以平铺在二维表面上. 裁剪的接缝处用粗线标出. 其中一个补丁已被高亮显示,因此您可以很好地看到它们是如何匹配的. 你还可以看到微笑中间的一条缝是如何将嘴的一部分分成两个不同的区域的.

线框不是纹理的一部分, 只是覆盖在图像上,这样你就可以看到它们是如何映射在一起的.

Loading an OBJ Model

信不信由你,这就是创建你自己的简单模型加载器所需要知道的一切. The OBJ file format 在几行代码中实现解析器是否足够简单.

该文件列出了a中的顶点位置 v 格式,使用可选的第四个浮点数,我们将忽略它,以保持简单. 顶点法线类似地表示为 vn . 最后,纹理坐标表示为 vt 的第三个可选浮点数,我们将忽略它. 在这三种情况下,浮点数表示各自的坐标. 这三个属性累积在三个数组中.

面由一组顶点表示. 每个顶点用每个属性的索引表示,索引从1开始. 这有很多种表示方式,但我们将坚持 F v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 format, 要求提供所有三个属性, 并将每个面的顶点数限制为三个. 所有这些限制都是为了使加载器尽可能简单, 因为所有其他选项都需要一些额外的琐碎处理才能成为WebGL喜欢的格式.

我们对文件加载器提出了很多要求. That may sound limiting, 但3D建模应用程序倾向于在将模型导出为OBJ文件时设置这些限制.

下面的代码解析一个表示OBJ文件的字符串, 并以一组面孔的形式创建一个模型.

function Geometry (faces) {
  this.faces = faces || []
}

//解析作为字符串传递的OBJ文件
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) {
    //将文件的每一行与各种regex匹配
    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) {
      //添加新的纹理映射点
      uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2])))
    } else if ((result = FACE.exec(line)) != null) {
      // Add new face
      var vertices = []
      //从传入的单索引索引中创建三个顶点
      for (var i = 1; i < 10; i += 3) {
        var part = result.slice(i, i + 3)
        var position = positions[parseInt(部分[0])- 1]
        var uv = uvs[parseInt(part[1]) - 1]
        var normal = normals[parseInt(部分[2])- 1]
        vertices.push(new Vertex(position, normal, uv))
      }
      faces.push(new Face(vertices))
    }
  })

  return new Geometry(faces)
}

//从给定的URL加载一个OBJ文件,并将其作为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 || []
}

函数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
}

The Geometry 结构保存发送模型到图形卡进行处理所需的确切数据. Before you do that though, 您可能希望能够在屏幕上移动模型.

执行空间转换

我们加载的模型中的所有点都是相对于它的坐标系的. If we want to translate, rotate, and scale the model, 我们要做的就是在它的坐标系上做这个运算. Coordinate system A, 相对于坐标系B, 是由它的中心位置定义为一个向量吗 p_ab,以及每个轴的向量, x_ab, y_ab, and z_ab,表示该轴的方向. 如果一个点移动了10 x 坐标系A的轴,那么在坐标系b中,它会朝 x_ab, multiplied by 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

如果我们要变换三维向量 q,我们只需要将变换矩阵与向量相乘

q.x
q.y
q.z
1

这导致点移动 q.x along the new x axis, by q.y along the new y axis, and by q.z along the new z axis. 最后,它使点额外移动 p 向量,这就是为什么我们用1作为乘法的最后一个元素.

使用这些矩阵的最大优点是如果我们要在顶点上进行多次变换, 我们可以将它们合并成一个变换通过将它们的矩阵相乘, 在变换顶点之前.

可以执行各种转换,我们将查看关键的转换.

No Transformation

如果没有发生转换,则 p vector is a zero vector, the x vector is [1, 0, 0], y is [0, 1, 0], and z is [0, 0, 1]. 从现在开始,我们将把这些值作为这些向量的默认值. 应用这些值,我们得到一个单位矩阵:

1 0 0 0
0 1 0 0
0 0 1 0
0 0 0 1

这是链接转换的一个很好的起点.

Translation

翻译框架转换

当我们执行平移时,所有的向量除了 p 向量有它们的默认值. 这就得到了下面的矩阵:

1 0 0 p.x
0 1 0 p.y
0 0 1 p.z
0 0 0   1

Scaling

缩放的帧变换

缩放模型意味着减少每个坐标对点位置的贡献. 不存在由缩放引起的均匀偏移,因此 p vector keeps its default value. 默认的轴向量应该乘以它们各自的缩放因子, 其结果为:

s_x   0   0 0
  0 s_y   0 0
  0   0 s_z 0
  0   0   0 1

Here s_x, s_y, and s_z 表示应用于每个轴的缩放.

Rotation

绕Z轴旋转的帧变换

上图显示了当我们围绕Z轴旋转坐标系时会发生什么.

旋转导致不均匀偏移,所以 p vector keeps its default value. Now things get a bit trickier. 旋转使沿着原坐标系中某一轴的运动向不同的方向移动. 如果我们把一个坐标系绕Z轴旋转45度,沿着 x 轴的原坐标系导致运动之间的对角线方向 x and y 新坐标系中的轴.

To keep things simple, 我们会告诉你变换矩阵是如何围绕主轴旋转的.

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() 0 cos() 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

Implementation

所有这些都可以实现为一个类,存储16个数字,在a中存储矩阵 column-major order.

function Transformation () {
  //创建一个身份转换
  this.字段= [1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]
}

//矩阵相乘,以链式转换
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
}

//乘以平移矩阵
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)
}

//乘以围绕X轴的旋转矩阵
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)
}

//乘以围绕Y轴的旋转矩阵
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)
}

//乘以绕Z轴的旋转矩阵
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)
}

Looking through a Camera

接下来是在屏幕上呈现物体的关键部分:摄像头. There are two key components to a camera; namely, its position, 以及它如何将观察到的物体投射到屏幕上.

摄像机的位置用一个简单的技巧处理. 将相机向前移动一米没有视觉上的区别, 让整个世界向后移动一米. 自然地,我们用后一种方法,将矩阵的逆作为变换.

第二个关键组件是观察到的物体投射到镜头上的方式. 在WebGL中,屏幕上可见的所有内容都位于一个框中. 盒子在每个轴上的跨度在-1和1之间. 所有可见的东西都在那个盒子里. 我们可以用变换矩阵的相同方法来创建投影矩阵.

Orthographic Projection

使用正交投影将矩形空间转换为适当的帧缓冲区尺寸

The simplest projection is orthographic projection. You take a box in space, denoting the width, height and depth, 假设它的中心在零位置. 然后投影调整框的大小,使其适合之前描述的WebGL在其中观察对象的框. 由于我们希望将每个维度的大小调整为2,因此我们将每个轴缩放为1 2/size, whereby size 各自轴的尺寸是多少. 需要注意的是我们用一个负数乘以Z轴. 这样做是因为我们想要翻转这个维数的方向. 最终的矩阵是这样的:

2/width        0        0 0
      0 2/height        0 0
      0        0 -2/depth 0
      0        0        0 1

Perspective Projection

使用透视投影将截锥体转换为适当的帧缓冲区尺寸

我们不会详细介绍这个投影是如何设计的,只使用 final formula这是现在的标准做法. 我们可以通过把投影放在x轴和y轴上的零点来简化它, 使右/左和上/下限制等于 width/2 and height/2 respectively. The parameters n and f represent the near and far clipping planes, 一个点可以被相机捕捉到的最小和最大的距离是什么. 它们由三角形的平行边表示 frustum in the above image.

透视投影通常用。表示 field of view (we’ll use the vertical one), aspect ratio,以及近、远平面距离. 这些信息可以用来计算 width and height,然后根据以下模板创建矩阵:

2*n/宽度0 0 0
        2*n/高度
        0 (f+n)/(n-f) 2*f*n/(n-f)
        0          0          -1           0

计算宽度和高度,可以使用以下公式:

height = 2 * near * Math.tan(fov * Math.PI / 360)
width = aspectRatio * height

FOV(视场)表示相机用镜头捕捉的垂直角度. 宽高比表示图像宽度和高度之间的比率, 并且是基于我们要渲染的屏幕的尺寸.

Implementation

现在我们可以将摄像机表示为存储摄像机位置和投影矩阵的类. 我们还需要知道如何计算逆变换. 求解一般的矩阵逆可能会有问题,但是有一个 simplified approach for our special case.

function Camera () {
  this.position = new Transformation()
  this.投影= new Transformation()
}

Camera.prototype.settorthographic = function (width, height, depth) {
  this.投影= 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 . var
  this.投影= new Transformation()
  this.projection.fields[0] = 1 / height_div_2n
  this.projection.fields[5] = 1 / width_div_2n
  this.projection.字段[10]=(远+近)/(近-远)
  this.projection.fields[10] = -1
  this.projection.字段[14]= 2 * far * near /(近-远)
  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]
  //转置旋转矩阵
  for (var i = 0; i < 3; ++i) {
    for (var j = 0; j < 3; ++j) {
      dest.字段[i * 4 + j] = origin [i + j * 4]
    }
  }

  // p的平移作用为R^T,等于R^-1
  return dest.translate(-x, -y, -z)
}

这是我们开始在屏幕上绘制东西之前需要的最后一块.

用WebGL图形管道绘制对象

你能画的最简单的曲面是三角形. 事实上,你在3D空间中画的大多数东西都是由大量的三角形组成的.

基本了解图形管道的步骤

您需要了解的第一件事是如何在WebGL中表示屏幕. 它是一个三维空间,在-1和1之间 x, y, and z axis. By default this z axis没有使用,但是您对3D图形感兴趣,因此您希望立即启用它.

记住这一点,下面是在这个表面上画一个三角形所需的三个步骤.

您可以定义三个顶点,这将表示您想要绘制的三角形. 你序列化数据并将其发送到GPU(图形处理单元). 有了一个完整的模型,你可以对模型中的所有三角形都这样做. 你给出的顶点位置在你加载的模型的局部坐标空间中. Put simply, 您提供的位置与文件中的位置完全一致, 而不是你在做矩阵变换后得到的.

现在你已经给了GPU顶点, 你告诉GPU在将顶点放置到屏幕上时使用什么逻辑. 这一步将用于应用我们的矩阵变换. GPU非常擅长乘很多4x4矩阵,所以我们将很好地利用这种能力.

在最后一步,GPU将栅格化那个三角形. 栅格化是获取矢量图形并确定需要绘制屏幕的哪些像素以显示矢量图形对象的过程. 在我们的例子中,GPU试图确定哪些像素位于每个三角形内. 对于每个像素,GPU将询问您希望将其涂成什么颜色.

这是画任何你想要的东西所需要的四个元素,它们是a的最简单的例子 graphics pipeline. 下面是对它们的介绍,以及一个简单的实现.

The Default Framebuffer

WebGL应用程序中最重要的元素是WebGL上下文. You can access it with gl = canvas.getContext('webgl'), or use 'experimental-webgl' 以防当前使用的浏览器不支持所有WebGL特性. The canvas 我们引用的是我们想要绘制的画布的DOM元素. 上下文包含许多东西,其中包括默认的framebuffer.

您可以将framebuffer粗略地描述为可以使用的任何缓冲区(对象). By default, 默认的framebuffer存储WebGL上下文绑定的画布的每个像素的颜色. 如前一节所述, 当我们在帧缓冲区上绘制时, 上的每个像素位于-1到1之间 x and y axis. 我们还提到的是,默认情况下,WebGL不使用 z axis. 该功能可以通过运行来启用 gl.enable(gl.DEPTH_TEST). 很好,但深度测试是什么呢?

启用深度测试允许像素存储颜色和深度. The depth is the z coordinate of that pixel. 在您绘制到某个深度的像素后 z要更新像素的颜色,你需要在a处绘制 z 靠近摄像机的位置. 否则,绘制尝试将被忽略. 这就产生了3D的错觉, 因为绘制在其他物体后面的物体会导致这些物体被它们前面的物体遮挡.

你所执行的任何绘制都将停留在屏幕上,直到你告诉它们清除为止. To do so, you have to call gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT). 这将清除颜色和深度缓冲区. 要选择已清除像素设置的颜色,请使用 gl.clearColor(红色,绿色,蓝色,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(红色,绿色,蓝色){
  gl.clearColor(红色/ 255,绿色/ 255,蓝色/ 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(文档.getElementById (webgl-canvas))
renderer.setClearColor(100, 149, 237)

loop()

function loop () {
  renderer.render()
  requestAnimationFrame(loop)
}

将此脚本附加到以下HTML将在屏幕上显示一个亮蓝色矩形






    
    


The requestAnimationFrame 调用导致循环在上一帧完成渲染和所有事件处理完成后再次被调用.

Vertex Buffer Objects

你需要做的第一件事是定义你想要画的顶点. 你可以通过在三维空间中的向量来描述它们. 之后,您想要通过创建一个新的内存来将该数据移动到GPU内存中 Vertex Buffer Object (VBO).

A Buffer Object 通常是在GPU上存储内存块数组的对象. 它是一个VBO,只是表示GPU可以使用内存做什么. 大多数情况下,您创建的缓冲区对象将是vbo.

你可以通过取全部来填满VBO N 顶点,并创建一个浮点数数组 3N 元素用于顶点位置和顶点法线vbo,以及 2N 为纹理坐标VBO. Each group of three floats, 或者两个浮点数表示UV坐标, 表示顶点的各个坐标. 然后我们将这些数组传递给GPU,我们的顶点已经为管道的其余部分做好了准备.

由于数据现在在GPU RAM上,您可以从通用RAM中删除它. 也就是说,除非您想稍后修改它并再次上传它. 每次修改之后都需要进行上传, 因为JS数组中的修改并不适用于实际GPU RAM中的vbo.

下面的代码示例提供了所描述的所有功能. 需要注意的一个重要事实是,存储在GPU上的变量不会被垃圾收集. 这意味着一旦我们不想再使用它们,我们必须手动删除它们. 我给你们举个例子, 我就不再关注这个概念了. 只有当您计划在整个程序中停止使用某些几何形状时,才需要从GPU中删除变量.

我们还添加了序列化 Geometry class and elements within it.

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(v.x, v.y, v.z)
    })
  })
  return answer
}

Geometry.prototype.normals = function () {
  var answer = []
  this.faces.forEach(function (face) {
    face.vertices.forEach(function (vertex) {
      var v = vertex.normal
      answer.push(v.x, v.y, v.z)
    })
  })
  return answer
}

Geometry.prototype.uvs = function () {
  var answer = []
  this.faces.forEach(function (face) {
    face.vertices.forEach(function (vertex) {
      var v = vertex.uv
      answer.push(v.x, v.y)
    })
  })
  return answer
}

////////////////////////////////

函数VBO (gl,数据,计数){
  //在GPU RAM中创建缓冲区对象,我们可以存储任何东西
  var bufferObject = gl.createBuffer()
  //告诉我们想要作为VBO操作的缓冲区对象
  gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject)
  //写入数据,并设置标志为优化
  //对写入的数据进行罕见的修改
  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 () {
  //释放缓冲区对象占用的内存
  this.gl.deleteBuffer(this.data)
}

The VBO 数据类型在传递的WebGL上下文中生成VBO, 基于作为第二个参数传递的数组.

You can see three calls to the gl context. The createBuffer() call creates the buffer. The bindBuffer() 调用告诉WebGL状态机使用这个特定的内存作为当前的VBO (ARRAY_BUFFER),直至另行通知为止. 之后,我们将当前VBO的值设置为所提供的数据 bufferData().

我们还提供了一个destroy方法,通过使用,从GPU RAM中删除缓冲区对象 deleteBuffer().

您可以使用三个vbo和一个变换来描述网格的所有属性, together with its position.

function Mesh (gl, geometry) {
  var vertexCount = geometry.vertexCount()
  this.position = 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()
}

As an example, 下面是加载模型的方法, 将其属性存储在网格中, and then destroy it:

Geometry.loadOBJ('/assets/model.obj').then(function (geometry) {
  var mesh = new mesh (gl, geometry)
  console.log(mesh)
  mesh.destroy()
})

Shaders

下面是前面描述的将点移动到所需位置并绘制所有单个像素的两步过程. 为此,我们编写了一个在显卡上多次运行的程序. 这个程序通常由至少两个部分组成. The first part is a Vertex Shader, which is run for each vertex, 并输出我们应该在屏幕上放置顶点的位置, among other things. The second part is the Fragment Shader, 对于屏幕上三角形所覆盖的每个像素,哪个运行, 并输出像素应该绘制到的颜色.

Vertex Shaders

假设你想要一个在屏幕上左右移动的模型. 在一种简单的方法中,您可以更新每个顶点的位置并将其重新发送给GPU. 这个过程既昂贵又缓慢. Alternatively, 你会给GPU一个程序来运行每个顶点, 所有这些操作都是通过处理器并行完成的,而处理器正是为完成这些任务而设计的. That is the role of a vertex shader.

顶点着色器是处理单个顶点的渲染管道的一部分. 调用顶点着色器接收单个顶点,并在应用所有可能的顶点转换后输出单个顶点.

Shaders are written in GLSL. 这种语言有很多独特的元素, 但大部分语法都很像c语言, 所以对大多数人来说应该是可以理解的.

有三种类型的变量可以进出顶点着色器, 它们都有一个特定的用途:

  • attribute -这些是保存顶点特定属性的输入. Previously, 我们将顶点的位置描述为一个属性, 以三元素向量的形式. 你可以把属性看作描述一个顶点的值.
  • uniform -这些是相同渲染调用中每个顶点的相同输入. 假设我们想要通过定义一个变换矩阵来移动我们的模型. You can use a uniform variable to describe that. 你也可以指向GPU上的资源,比如纹理. 你可以把制服看作是描述一个模特的价值,或者是模特的一部分.
  • varying -这些是我们传递给片段着色器的输出. 因为一个三角形的顶点可能有数千个像素, 每个像素将接收这个变量的插值值, depending on the position. 如果一个顶点输出500, and another one 100, 位于它们中间的像素将接收300作为该变量的输入. 您可以将变量视为描述顶点之间表面的值.

So, 假设你想创建一个接收位置的顶点着色器, normal, 每个顶点的uv坐标, and a position, view (inverse camera position), 以及每个渲染对象的投影矩阵. 假设你还想根据它们的uv坐标和法线来绘制单个像素. “How would that code look?” you might ask.

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 =投影*视图*模型* vec4(位置,1.);
}

这里的大多数元素应该是不言自明的. 中没有返回值,这是需要注意的关键 main function. 我们想要返回的所有值都被赋值为 varying 变量,或者是特殊变量. Here we assign to gl_Position,这是一个四维向量,因此最后一个维度应该总是被设置为1. 你可能会注意到另一件奇怪的事情是我们构造a的方式 vec4 out of the position vector. You can construct a vec4 by using four floats, two vec2S,或任何其他组成四个元素的组合. 有很多看起来很奇怪的类型转换,一旦你熟悉了变换矩阵,它们就很有意义了.

你也可以看到这里我们可以很容易地进行矩阵变换. GLSL是专门为这种工作而设计的. 通过乘以投影计算输出位置, view, 对矩阵建模并应用到位置上. 输出法线只是转换到世界空间. 稍后我们将解释为什么我们在这里停止了正常的转换.

现在,我们将保持简单,并继续绘制单个像素.

Fragment Shaders

A fragment shader 光栅化后的步骤在图形管道中吗. 它为正在绘制的对象的每个像素生成颜色、深度和其他数据.

实现片段着色器背后的原理与顶点着色器非常相似. 不过,它们有三个主要区别:

  • There are no more varying outputs, and attribute inputs have been replaced with varying inputs. 我们刚刚在我们的生产线上前进了一步, 顶点着色器的输出现在是片段着色器的输入.
  • Our only output now is gl_FragColor, which is a vec4. The elements represent red, green, blue, and alpha (RGBA), respectively, 变量在0到1的范围内. 你应该保持alpha值为1,除非你要做透明处理. 透明是一个相当高级的概念,所以我们将坚持使用不透明的对象.
  • 在片段着色器的开始, 您需要设置浮点精度, 对插值来说,哪个是重要的. 在几乎所有的情况下,只要坚持从下面的着色器线.

With that in mind, 你可以很容易地编写一个着色器,根据U的位置绘制红色通道, 绿色通道基于V的位置, 并将蓝色频道设置为最大.

#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 只是将对象中的所有浮点数限制在给定的范围内. 剩下的代码应该非常简单.

考虑到所有这些,剩下的就是在WebGL中实现它.

将着色器组合到一个程序中

下一步是将着色器合并到一个程序中:

ShaderProgram (gl, vertsc, 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))
    抛出新的错误('编译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))
    抛出新的错误('编译shader失败')
  }

  var program = gl.createProgram()
  gl.attachShader(program, vert)
  gl.attachShader(program, frag)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(程序,gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
    抛出新的错误('链接程序失败')
  }

  this.gl = gl
  this.position = gl.getAttribLocation(计划,“位置”)
  this.normal = gl.“正常”getAttribLocation(程序)
  this.uv = gl.getAttribLocation(计划,“紫外线”)
  this.model = gl.getUniformLocation(计划,“模型”)
  this.view = gl.getUniformLocation(计划,“视图”)
  this.projection = gl.getUniformLocation(计划,“投影”)
  this.vert = vert
  this.frag = frag
  this.program = program
}

//从给定的url加载着色器文件,并返回一个程序作为承诺
ShaderProgram.load = function (gl, vertUrl, fragUrl) {
  return Promise.所有([loadFile (vertUrl) loadFile (fragUrl))).then(function (files) {
    返回新的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)
    })
  }
}

这里发生的事情没什么可说的. 每个着色器被分配一个字符串作为源并编译, 之后,我们检查是否存在编译错误. 然后,我们通过链接这两个着色器创建一个程序. 最后,我们为后代存储指向所有相关属性和制服的指针.

Actually Drawing the Model

最后,但并非最不重要的是,绘制模型.

首先,你选择你想要使用的着色程序.

ShaderProgram.prototype.use = function () {
  this.gl.useProgram(this.program)
}

然后将所有与摄像机相关的制服发送给GPU. 这些制服只在每次镜头变换或移动时更换一次.

Transformation.prototype.sendToGpu = function (gl, uniform,转置){
  gl.uniformMatrix4fv(uniform,转置|| 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, 您将转换和vbo分配给制服和属性, respectively. 由于必须对每个VBO执行此操作,因此可以将其数据绑定创建为方法.

VBO.prototype.bindToAttribute = function (attribute) {
  var gl = this.gl
  //告诉我们想要作为VBO操作的缓冲区对象
  gl.bindBuffer(gl.ARRAY_BUFFER, this.data)
  //在着色器中启用此属性
  gl.enableVertexAttribArray(属性)
  //定义属性数组的格式. 必须匹配shader中的参数吗
  gl.vertexAttribPointer(属性,这个.size, gl.FLOAT, false, 0, 0)
}

然后给制服分配一个包含三个浮点数的数组. 每种制服都有不同的签名,所以 documentation and more documentation are your friends here. 最后,在屏幕上绘制三角形数组. You tell the drawing call drawArrays() 从哪个顶点开始,画多少顶点. 传递的第一个参数告诉WebGL如何解释顶点数组. Using TRIANGLES 取3 * 3的顶点,并为每个三元组画一个三角形. Using POINTS 会为每个经过的顶点画一个点吗. 有更多的选择,但没有必要一次发现所有的东西. 下面是绘制对象的代码:

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)
}

渲染器需要稍微扩展一下,以容纳需要处理的所有额外元素. 应该可以附加一个着色器程序, 并根据当前摄像机位置渲染物体数组.

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)
  })
}

我们可以结合所有元素,最终在屏幕上画出一些东西:

var renderer = new renderer(文档.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)
}

对象绘制在画布上,颜色取决于UV坐标

This looks a bit random, 但是你可以看到球体的不同区域, 基于它们在UV地图上的位置. 你可以改变着色器,把物体涂成棕色. 只需将每个像素的颜色设置为棕色的RGBA:

#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.);
}

画在画布上的棕色物体

看起来不太令人信服. 看起来场景需要一些阴影效果.

Adding Light

光和影是我们感知物体形状的工具. 灯有许多形状和大小:聚光灯在一个锥体上发光, 向四面八方传播光线的灯泡, and most interestingly, the sun, 什么东西离我们如此之远,以至于它照在我们身上的光都辐射出去了, for all intents and purposes, in the same direction.

阳光听起来是最容易实现的, 因为你所需要提供的只是所有光线传播的方向. 对于在屏幕上绘制的每个像素, 你检查光线照射物体的角度. 这就是表面法线的用武之地.

演示光线和表面法线之间的角度,为平面和平滑着色

你可以看到所有的光线都朝同一个方向流动, 以不同的角度撞击表面, 哪些是基于光线和表面法线之间的夹角. 它们越重合,光就越强.

如果你在光线的归一化向量和表面法线之间做点积, 如果光线完全垂直地照射到表面,你会得到-1, 如果射线平行于表面,则为0, 如果从另一边照射,则为1. 所以0和1之间的任何值都不应该加光, 而0到-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.);
    浮动亮度= -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.);
    gl_FragColor = vec4(棕色*亮度,1.);
}

Brown object with sunlight

我们让太阳朝前、左、下的方向照. 你可以看到阴影是多么平滑,即使模型是锯齿状的. 你还可以注意到左下角有多暗. 我们可以添加一个环境光级别,这将使阴影区域更亮.

#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.);
    浮动亮度= -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.);
    float ambientLight = 0.3;
    lightness = ambientLight + (1. - ambientLight) * lightness;
    gl_FragColor = vec4(棕色*亮度,1.);
}

带有阳光和环境光的棕色物体

您可以通过引入轻类来达到同样的效果, 哪个存储光的方向和环境光的强度. 然后你可以改变片段着色器来适应这个添加.

Now the shader becomes:

#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);
    浮动亮度= -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.);
    lightness = ambientLight + (1. - ambientLight) * lightness;
    gl_FragColor = vec4(棕色*亮度,1.);
}

Then you can define the light:

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)
}

在shader program类中,添加所需的制服:

this.ambientLight = gl.getUniformLocation(计划,“ambientLight”)
this.lightDirection = gl.getUniformLocation(计划,“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)
}

如果你做的一切都是对的, 然后渲染的图像应该与上一张图像中的图像相同.

最后一步要考虑的是添加一个实际的纹理到我们的模型. Let’s do that now.

Adding Textures

HTML5对加载图像有很好的支持,所以没有必要做疯狂的图像解析. Images are passed to GLSL as sampler2D 通过告诉着色器要采样的绑定纹理. 可以绑定的纹理数量有限,限制是基于所使用的硬件. A 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() {
    浮动亮度= -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(计划,“扩散”)

最后,我们将实现纹理加载. 如前所述,HTML5提供了加载图像的工具. 我们所需要做的就是将图像发送到GPU:

function Texture (gl, image) {
  var texture = gl.createTexture()
  //设置新创建的纹理上下文为活动纹理
  gl.bindTexture(gl.TEXTURE_2D, texture)
  //设置纹理参数,传递纹理所基于的图像
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
  // Set filtering methods
  //通常着色器会查询像素之间的纹理值,
  //指示如何计算该值
  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're setting right now
  gl.activeTexture(gl['TEXTURE' + binding])
  //选择绑定后,我们设置纹理
  gl.bindTexture(gl.TEXTURE_2D, this.data)
  //最后,我们将使用的绑定ID传递给uniform
  gl.uniform1i(uniform, binding)
  //前3行相当于:
  // texture[i] = this.data
  // uniform = i
}

Texture.load = function (gl, url) {
  return new Promise(function (resolve)) {
    var image = new Image()
    image.onload = function () {
      解析(new Texture(gl, image))
    }
    image.src = url
  })
}

该过程与用于加载和绑定vbo的过程没有太大区别. 主要区别在于我们不再绑定属性, 而是将纹理的索引绑定到一个整数统一. The sampler2D Type只不过是指向纹理的指针偏移量.

现在所需要做的就是扩展 Mesh 类,也处理纹理:

function Mesh (gl, geometry, texture){//添加纹理
  var vertexCount = geometry.vertexCount()
  this.position = 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){//新的
  var geometry = Geometry.loadOBJ(modelUrl)
  var texture = Texture.load(gl, textureUrl)
  return Promise.all([geometry, texture]).then(function (params) {
    返回新网格(gl,参数[0],参数[1])
  })
}

最后的主脚本如下所示:

var renderer = new renderer(文档.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() {
    浮动亮度= -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.);
}

这很简单,就像根据光线是否超过设定的阈值来告诉光线进入它的极端一样.

头部与卡通照明应用

Where to Go Next

学习WebGL的所有技巧和复杂性有许多信息来源. 最好的部分是,如果你找不到与WebGL相关的答案, you can look for it in OpenGL, 因为WebGL基本上是基于OpenGL的一个子集, with some names being changed.

In no particular order, 这里有一些更详细的信息来源, both for WebGL and OpenGL.

Understanding the basics

  • What is WebGL?

    WebGL是一个JavaScript API,它为在兼容的web浏览器中渲染3D图形添加了本地支持, 通过类似于OpenGL的API.

  • 3D模型如何在内存中表示?

    表示3D模型最常见的方法是通过顶点数组, 每一个在空间中都有一个确定的位置, 顶点所属的曲面的法线, 以及用于绘制模型的纹理上的坐标. 然后将这些顶点分成三个一组,形成三角形.

  • What is a vertex shader?

    顶点着色器是处理单个顶点的渲染管道的一部分. 对顶点着色器的调用接收单个顶点, 并在对顶点进行所有可能的转换后输出单个顶点. 这允许对整个对象应用移动和变形.

  • What is a fragment shader?

    片段着色器是渲染管道的一部分,它在屏幕上获取对象的一个像素, 以及该像素位置的对象属性, and can generates color, depth and other data for it.

  • 物体如何在3D场景中移动?

    对象的所有顶点位置都相对于其局部坐标系, 哪个是用4x4单位矩阵表示的. 如果我们移动这个坐标系, 用变换矩阵乘以它, 对象的顶点随之移动.

就这一主题咨询作者或专家.
Schedule a call
Adnan Ademovic's profile image
Adnan Ademovic

Located in 萨拉热窝,波斯尼亚和黑塞哥维那

Member since July 20, 2015

About the author

Adnan拥有桌面、嵌入式和分布式系统方面的经验. 他对c++、Python和其他语言进行了广泛的研究.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Years of Experience

9

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.

\n\n\n\n\n

The requestAnimationFrame 调用导致循环在上一帧完成渲染和所有事件处理完成后再次被调用.

\n\n

Vertex Buffer Objects

\n\n

你需要做的第一件事是定义你想要画的顶点. 你可以通过在三维空间中的向量来描述它们. 之后,您想要通过创建一个新的内存来将该数据移动到GPU内存中 Vertex Buffer Object (VBO).

\n\n

A Buffer Object 通常是在GPU上存储内存块数组的对象. 它是一个VBO,只是表示GPU可以使用内存做什么. 大多数情况下,您创建的缓冲区对象将是vbo.

\n\n

你可以通过取全部来填满VBO N 顶点,并创建一个浮点数数组 3N 元素用于顶点位置和顶点法线vbo,以及 2N 为纹理坐标VBO. Each group of three floats, 或者两个浮点数表示UV坐标, 表示顶点的各个坐标. 然后我们将这些数组传递给GPU,我们的顶点已经为管道的其余部分做好了准备.

\n\n

由于数据现在在GPU RAM上,您可以从通用RAM中删除它. 也就是说,除非您想稍后修改它并再次上传它. 每次修改之后都需要进行上传, 因为JS数组中的修改并不适用于实际GPU RAM中的vbo.

\n\n

下面的代码示例提供了所描述的所有功能. 需要注意的一个重要事实是,存储在GPU上的变量不会被垃圾收集. 这意味着一旦我们不想再使用它们,我们必须手动删除它们. 我给你们举个例子, 我就不再关注这个概念了. 只有当您计划在整个程序中停止使用某些几何形状时,才需要从GPU中删除变量.

\n\n

我们还添加了序列化 Geometry class and elements within it.

\n\n
Geometry.prototype.vertexCount = function () {\n  return this.faces.length * 3\n}\n\nGeometry.prototype.positions = function () {\n  var answer = []\n  this.faces.forEach(function (face) {\n    face.vertices.forEach(function (vertex) {\n      var v = vertex.position\n      answer.push(v.x, v.y, v.z)\n    })\n  })\n  return answer\n}\n\nGeometry.prototype.normals = function () {\n  var answer = []\n  this.faces.forEach(function (face) {\n    face.vertices.forEach(function (vertex) {\n      var v = vertex.normal\n      answer.push(v.x, v.y, v.z)\n    })\n  })\n  return answer\n}\n\nGeometry.prototype.uvs = function () {\n  var answer = []\n  this.faces.forEach(function (face) {\n    face.vertices.forEach(function (vertex) {\n      var v = vertex.uv\n      answer.push(v.x, v.y)\n    })\n  })\n  return answer\n}\n\n////////////////////////////////\n\n函数VBO (gl,数据,计数){\n  //在GPU RAM中创建缓冲区对象,我们可以存储任何东西\n  var bufferObject = gl.createBuffer()\n  //告诉我们想要作为VBO操作的缓冲区对象\n  gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject)\n  //写入数据,并设置标志为优化\n  //对写入的数据进行罕见的修改\n  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW)\n  this.gl = gl\n  this.size = data.length / count\n  this.count = count\n  this.data = bufferObject\n}\n\nVBO.prototype.destroy = function () {\n  //释放缓冲区对象占用的内存\n  this.gl.deleteBuffer(this.data)\n}\n
\n\n

The VBO 数据类型在传递的WebGL上下文中生成VBO, 基于作为第二个参数传递的数组.

\n\n

You can see three calls to the gl context. The createBuffer() call creates the buffer. The bindBuffer() 调用告诉WebGL状态机使用这个特定的内存作为当前的VBO (ARRAY_BUFFER),直至另行通知为止. 之后,我们将当前VBO的值设置为所提供的数据 bufferData().

\n\n

我们还提供了一个destroy方法,通过使用,从GPU RAM中删除缓冲区对象 deleteBuffer().

\n\n

您可以使用三个vbo和一个变换来描述网格的所有属性, together with its position.

\n\n
function Mesh (gl, geometry) {\n  var vertexCount = geometry.vertexCount()\n  this.position = new VBO(gl, geometry).positions(), vertexCount)\n  this.normals = new VBO(gl, geometry.normals(), vertexCount)\n  this.uvs = new VBO(gl, geometry.uvs(), vertexCount)\n  this.vertexCount = vertexCount\n  this.position = new Transformation()\n  this.gl = gl\n}\n\nMesh.prototype.destroy = function () {\n  this.positions.destroy()\n  this.normals.destroy()\n  this.uvs.destroy()\n}\n
\n\n

As an example, 下面是加载模型的方法, 将其属性存储在网格中, and then destroy it:

\n\n
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) {\n  var mesh = new mesh (gl, geometry)\n  console.log(mesh)\n  mesh.destroy()\n})\n
\n\n

Shaders

\n\n

下面是前面描述的将点移动到所需位置并绘制所有单个像素的两步过程. 为此,我们编写了一个在显卡上多次运行的程序. 这个程序通常由至少两个部分组成. The first part is a Vertex Shader, which is run for each vertex, 并输出我们应该在屏幕上放置顶点的位置, among other things. The second part is the Fragment Shader, 对于屏幕上三角形所覆盖的每个像素,哪个运行, 并输出像素应该绘制到的颜色.

\n\n

Vertex Shaders

\n\n

假设你想要一个在屏幕上左右移动的模型. 在一种简单的方法中,您可以更新每个顶点的位置并将其重新发送给GPU. 这个过程既昂贵又缓慢. Alternatively, 你会给GPU一个程序来运行每个顶点, 所有这些操作都是通过处理器并行完成的,而处理器正是为完成这些任务而设计的. That is the role of a vertex shader.

\n\n

顶点着色器是处理单个顶点的渲染管道的一部分. 调用顶点着色器接收单个顶点,并在应用所有可能的顶点转换后输出单个顶点.

\n\n

Shaders are written in GLSL. 这种语言有很多独特的元素, 但大部分语法都很像c语言, 所以对大多数人来说应该是可以理解的.

\n\n

有三种类型的变量可以进出顶点着色器, 它们都有一个特定的用途:

\n\n\n\n

So, 假设你想创建一个接收位置的顶点着色器, normal, 每个顶点的uv坐标, and a position, view (inverse camera position), 以及每个渲染对象的投影矩阵. 假设你还想根据它们的uv坐标和法线来绘制单个像素. “How would that code look?” you might ask.

\n\n
attribute vec3 position;\nattribute vec3 normal;\nattribute vec2 uv;\nuniform mat4 model;\nuniform mat4 view;\nuniform mat4 projection;\nvarying vec3 vNormal;\nvarying vec2 vUv;\n\nvoid main() {\n    vUv = uv;\n    vNormal = (model * vec4) (normal, 0.)).xyz;\n    gl_Position =投影*视图*模型* vec4(位置,1.);\n}\n
\n\n

这里的大多数元素应该是不言自明的. 中没有返回值,这是需要注意的关键 main function. 我们想要返回的所有值都被赋值为 varying 变量,或者是特殊变量. Here we assign to gl_Position,这是一个四维向量,因此最后一个维度应该总是被设置为1. 你可能会注意到另一件奇怪的事情是我们构造a的方式 vec4 out of the position vector. You can construct a vec4 by using four floats, two vec2S,或任何其他组成四个元素的组合. 有很多看起来很奇怪的类型转换,一旦你熟悉了变换矩阵,它们就很有意义了.

\n\n

你也可以看到这里我们可以很容易地进行矩阵变换. GLSL是专门为这种工作而设计的. 通过乘以投影计算输出位置, view, 对矩阵建模并应用到位置上. 输出法线只是转换到世界空间. 稍后我们将解释为什么我们在这里停止了正常的转换.

\n\n

现在,我们将保持简单,并继续绘制单个像素.

\n\n

Fragment Shaders

\n\n

A fragment shader 光栅化后的步骤在图形管道中吗. 它为正在绘制的对象的每个像素生成颜色、深度和其他数据.

\n\n

实现片段着色器背后的原理与顶点着色器非常相似. 不过,它们有三个主要区别:

\n\n\n\n

With that in mind, 你可以很容易地编写一个着色器,根据U的位置绘制红色通道, 绿色通道基于V的位置, 并将蓝色频道设置为最大.

\n\n
#ifdef GL_ES\nprecision highp float;\n#endif\n\nvarying vec3 vNormal;\nvarying vec2 vUv;\n\nvoid main() {\n    vec2 clampedUv = clamp(vUv, 0., 1.);\n    gl_FragColor = vec4(clampedUv, 1., 1.);\n}\n
\n\n

The function clamp 只是将对象中的所有浮点数限制在给定的范围内. 剩下的代码应该非常简单.

\n\n

考虑到所有这些,剩下的就是在WebGL中实现它.

\n\n

将着色器组合到一个程序中

\n\n

下一步是将着色器合并到一个程序中:

\n\n
ShaderProgram (gl, vertsc, fragSrc) {\n  var vert = gl.createShader(gl.VERTEX_SHADER)\n  gl.shaderSource(vert, vertSrc)\n  gl.compileShader(vert)\n  if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) {\n    console.error(gl.getShaderInfoLog(vert))\n    抛出新的错误('编译shader失败')\n  }\n\n  var frag = gl.createShader(gl.FRAGMENT_SHADER)\n  gl.shaderSource(frag, fragSrc)\n  gl.compileShader(frag)\n  if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) {\n    console.error(gl.getShaderInfoLog(frag))\n    抛出新的错误('编译shader失败')\n  }\n\n  var program = gl.createProgram()\n  gl.attachShader(program, vert)\n  gl.attachShader(program, frag)\n  gl.linkProgram(program)\n  if (!gl.getProgramParameter(程序,gl.LINK_STATUS)) {\n    console.error(gl.getProgramInfoLog(program))\n    抛出新的错误('链接程序失败')\n  }\n\n  this.gl = gl\n  this.position = gl.getAttribLocation(计划,“位置”)\n  this.normal = gl.“正常”getAttribLocation(程序)\n  this.uv = gl.getAttribLocation(计划,“紫外线”)\n  this.model = gl.getUniformLocation(计划,“模型”)\n  this.view = gl.getUniformLocation(计划,“视图”)\n  this.projection = gl.getUniformLocation(计划,“投影”)\n  this.vert = vert\n  this.frag = frag\n  this.program = program\n}\n\n//从给定的url加载着色器文件,并返回一个程序作为承诺\nShaderProgram.load = function (gl, vertUrl, fragUrl) {\n  return Promise.所有([loadFile (vertUrl) loadFile (fragUrl))).then(function (files) {\n    返回新的ShaderProgram(gl, files[0], files[1])\n  })\n\n  function loadFile (url) {\n    return new Promise(function (resolve)) {\n      var xhr = new XMLHttpRequest()\n      xhr.Onreadystatechange = function () {\n        if (xhr.readyState == XMLHttpRequest.DONE) {\n          resolve(xhr.responseText)\n        }\n      }\n      xhr.open('GET', url, true)\n      xhr.send(null)\n    })\n  }\n}\n
\n\n

这里发生的事情没什么可说的. 每个着色器被分配一个字符串作为源并编译, 之后,我们检查是否存在编译错误. 然后,我们通过链接这两个着色器创建一个程序. 最后,我们为后代存储指向所有相关属性和制服的指针.

\n\n

Actually Drawing the Model

\n\n

最后,但并非最不重要的是,绘制模型.

\n\n

首先,你选择你想要使用的着色程序.

\n\n
ShaderProgram.prototype.use = function () {\n  this.gl.useProgram(this.program)\n}\n
\n\n

然后将所有与摄像机相关的制服发送给GPU. 这些制服只在每次镜头变换或移动时更换一次.

\n\n
Transformation.prototype.sendToGpu = function (gl, uniform,转置){\n  gl.uniformMatrix4fv(uniform,转置|| false, new Float32Array(this ..fields))\n}\n\nCamera.prototype.use = function (shaderProgram) {\n  this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection)\n  this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view)\n}\n
\n\n

Finally, 您将转换和vbo分配给制服和属性, respectively. 由于必须对每个VBO执行此操作,因此可以将其数据绑定创建为方法.

\n\n
VBO.prototype.bindToAttribute = function (attribute) {\n  var gl = this.gl\n  //告诉我们想要作为VBO操作的缓冲区对象\n  gl.bindBuffer(gl.ARRAY_BUFFER, this.data)\n  //在着色器中启用此属性\n  gl.enableVertexAttribArray(属性)\n  //定义属性数组的格式. 必须匹配shader中的参数吗\n  gl.vertexAttribPointer(属性,这个.size, gl.FLOAT, false, 0, 0)\n}\n
\n\n

然后给制服分配一个包含三个浮点数的数组. 每种制服都有不同的签名,所以 documentation and more documentation are your friends here. 最后,在屏幕上绘制三角形数组. You tell the drawing call drawArrays() 从哪个顶点开始,画多少顶点. 传递的第一个参数告诉WebGL如何解释顶点数组. Using TRIANGLES 取3 * 3的顶点,并为每个三元组画一个三角形. Using POINTS 会为每个经过的顶点画一个点吗. 有更多的选择,但没有必要一次发现所有的东西. 下面是绘制对象的代码:

\n\n
Mesh.prototype.draw = function (shaderProgram) {\n  this.positions.bindToAttribute(shaderProgram.position)\n  this.normals.bindToAttribute(shaderProgram.normal)\n  this.uvs.bindToAttribute(shaderProgram.uv)\n  this.position.sendToGpu(this.gl, shaderProgram.model)\n  this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount)\n}\n
\n\n

渲染器需要稍微扩展一下,以容纳需要处理的所有额外元素. 应该可以附加一个着色器程序, 并根据当前摄像机位置渲染物体数组.

\n\n
Renderer.prototype.setShader = function (shader) {\n  this.shader = shader\n}\n\nRenderer.prototype.Render = function (camera, objects) {\n  this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)\n  var shader = this.shader\n  if (!shader) {\n    return\n  }\n  shader.use()\n  camera.use(shader)\n  objects.forEach(function (mesh) {\n    mesh.draw(shader)\n  })\n}\n
\n\n

我们可以结合所有元素,最终在屏幕上画出一些东西:

\n\n
var renderer = new renderer(文档.getElementById (webgl-canvas))\nrenderer.setClearColor(100, 149, 237)\nvar gl = renderer.getContext()\n\nvar objects = []\n\nGeometry.loadOBJ('/assets/sphere.obj').then(function (data) {\n  objects.push(new Mesh(gl, data))\n})\nShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag')\n             .then(function (shader) {\n               renderer.setShader(shader)\n             })\n\nvar camera = new Camera()\ncamera.setOrthographic(16, 10, 10)\n\nloop()\n\nfunction loop () {\n  renderer.render(camera, objects)\n  requestAnimationFrame(loop)\n}\n
\n\n

\"对象绘制在画布上,颜色取决于UV坐标\"

\n\n

This looks a bit random, 但是你可以看到球体的不同区域, 基于它们在UV地图上的位置. 你可以改变着色器,把物体涂成棕色. 只需将每个像素的颜色设置为棕色的RGBA:

\n\n
#ifdef GL_ES\nprecision highp float;\n#endif\n\nvarying vec3 vNormal;\nvarying vec2 vUv;\n\nvoid main() {\n    vec3 brown = vec3(.54, .27, .07);\n    gl_FragColor = vec4(brown, 1.);\n}\n
\n\n

\"画在画布上的棕色物体\"

\n\n

看起来不太令人信服. 看起来场景需要一些阴影效果.

\n\n

Adding Light

\n\n

光和影是我们感知物体形状的工具. 灯有许多形状和大小:聚光灯在一个锥体上发光, 向四面八方传播光线的灯泡, and most interestingly, the sun, 什么东西离我们如此之远,以至于它照在我们身上的光都辐射出去了, for all intents and purposes, in the same direction.

\n\n

阳光听起来是最容易实现的, 因为你所需要提供的只是所有光线传播的方向. 对于在屏幕上绘制的每个像素, 你检查光线照射物体的角度. 这就是表面法线的用武之地.

\n\n

\"演示光线和表面法线之间的角度,为平面和平滑着色\"

\n\n

你可以看到所有的光线都朝同一个方向流动, 以不同的角度撞击表面, 哪些是基于光线和表面法线之间的夹角. 它们越重合,光就越强.

\n\n

如果你在光线的归一化向量和表面法线之间做点积, 如果光线完全垂直地照射到表面,你会得到-1, 如果射线平行于表面,则为0, 如果从另一边照射,则为1. 所以0和1之间的任何值都不应该加光, 而0到-1之间的数字应该逐渐增加击中物体的光量. 你可以通过在着色器代码中添加一个固定的光来测试这一点.

\n\n
#ifdef GL_ES\nprecision highp float;\n#endif\n\nvarying vec3 vNormal;\nvarying vec2 vUv;\n\nvoid main() {\n    vec3 brown = vec3(.54, .27, .07);\n    vec3 sunlightDirection = vec3(-1., -1., -1.);\n    浮动亮度= -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.);\n    gl_FragColor = vec4(棕色*亮度,1.);\n}\n
\n\n

\"Brown

\n\n

我们让太阳朝前、左、下的方向照. 你可以看到阴影是多么平滑,即使模型是锯齿状的. 你还可以注意到左下角有多暗. 我们可以添加一个环境光级别,这将使阴影区域更亮.

\n\n
#ifdef GL_ES\nprecision highp float;\n#endif\n\nvarying vec3 vNormal;\nvarying vec2 vUv;\n\nvoid main() {\n    vec3 brown = vec3(.54, .27, .07);\n    vec3 sunlightDirection = vec3(-1., -1., -1.);\n    浮动亮度= -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.);\n    float ambientLight = 0.3;\n    lightness = ambientLight + (1. - ambientLight) * lightness;\n    gl_FragColor = vec4(棕色*亮度,1.);\n}\n
\n\n

\"带有阳光和环境光的棕色物体\"

\n\n

您可以通过引入轻类来达到同样的效果, 哪个存储光的方向和环境光的强度. 然后你可以改变片段着色器来适应这个添加.

\n\n

Now the shader becomes:

\n\n
#ifdef GL_ES\nprecision highp float;\n#endif\n\nuniform vec3 lightDirection;\nuniform float ambientLight;\nvarying vec3 vNormal;\nvarying vec2 vUv;\n\nvoid main() {\n    vec3 brown = vec3(.54, .27, .07);\n    浮动亮度= -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.);\n    lightness = ambientLight + (1. - ambientLight) * lightness;\n    gl_FragColor = vec4(棕色*亮度,1.);\n}\n
\n\n

Then you can define the light:

\n\n
function Light () {\n  this.lightDirection = new Vector3(-1, -1, -1)\n  this.ambientLight = 0.3\n}\n\nLight.prototype.use = function (shaderProgram) {\n  var dir = this.lightDirection\n  var gl = shaderProgram.gl\n  gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z)\n  gl.uniform1f(shaderProgram.ambientLight, this.ambientLight)\n}\n
\n\n

在shader program类中,添加所需的制服:

\n\n
this.ambientLight = gl.getUniformLocation(计划,“ambientLight”)\nthis.lightDirection = gl.getUniformLocation(计划,“lightDirection”)\n
\n\n

在程序中,在渲染器中添加一个对新光源的调用:

\n\n
Renderer.prototype.Render = function (camera, light, objects) {\n  this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)\n  var shader = this.shader\n  if (!shader) {\n    return\n  }\n  shader.use()\n  light.use(shader)\n  camera.use(shader)\n  objects.forEach(function (mesh) {\n    mesh.draw(shader)\n  })\n}\n
\n\n

然后循环将略有变化:

\n\n
var light = new Light()\n\nloop()\n\nfunction loop () {\n  renderer.render(camera, light, objects)\n  requestAnimationFrame(loop)\n}\n
\n\n

如果你做的一切都是对的, 然后渲染的图像应该与上一张图像中的图像相同.

\n\n

最后一步要考虑的是添加一个实际的纹理到我们的模型. Let’s do that now.

\n\n

Adding Textures

\n\n

HTML5对加载图像有很好的支持,所以没有必要做疯狂的图像解析. Images are passed to GLSL as sampler2D 通过告诉着色器要采样的绑定纹理. 可以绑定的纹理数量有限,限制是基于所使用的硬件. A sampler2D 可以查询特定位置的颜色吗. 这就是UV坐标的作用. 这是一个例子,我们用采样的颜色代替棕色.

\n\n
#ifdef GL_ES\nprecision highp float;\n#endif\n\nuniform vec3 lightDirection;\nuniform float ambientLight;\nuniform sampler2D diffuse;\nvarying vec3 vNormal;\nvarying vec2 vUv;\n\nvoid main() {\n    浮动亮度= -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.);\n    lightness = ambientLight + (1. - ambientLight) * lightness;\n    gl_FragColor = vec4(texture2D(diffuse, vUv)).rgb * lightness, 1.);\n}\n
\n\n

新的制服必须添加到着色器程序的列表中:

\n\n
this.diffuse = gl.getUniformLocation(计划,“扩散”)\n
\n\n

最后,我们将实现纹理加载. 如前所述,HTML5提供了加载图像的工具. 我们所需要做的就是将图像发送到GPU:

\n\n
function Texture (gl, image) {\n  var texture = gl.createTexture()\n  //设置新创建的纹理上下文为活动纹理\n  gl.bindTexture(gl.TEXTURE_2D, texture)\n  //设置纹理参数,传递纹理所基于的图像\n  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)\n  // Set filtering methods\n  //通常着色器会查询像素之间的纹理值,\n  //指示如何计算该值\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n  this.data = texture\n  this.gl = gl\n}\n\nTexture.prototype.Use = function (uniform, binding) {\n  binding = Number(binding) || 0\n  var gl = this.gl\n  //我们可以绑定多个纹理,这里我们选择其中的绑定\n  // we're setting right now\n  gl.activeTexture(gl['TEXTURE' + binding])\n  //选择绑定后,我们设置纹理\n  gl.bindTexture(gl.TEXTURE_2D, this.data)\n  //最后,我们将使用的绑定ID传递给uniform\n  gl.uniform1i(uniform, binding)\n  //前3行相当于:\n  // texture[i] = this.data\n  // uniform = i\n}\n\nTexture.load = function (gl, url) {\n  return new Promise(function (resolve)) {\n    var image = new Image()\n    image.onload = function () {\n      解析(new Texture(gl, image))\n    }\n    image.src = url\n  })\n}\n
\n\n

该过程与用于加载和绑定vbo的过程没有太大区别. 主要区别在于我们不再绑定属性, 而是将纹理的索引绑定到一个整数统一. The sampler2D Type只不过是指向纹理的指针偏移量.

\n\n

现在所需要做的就是扩展 Mesh 类,也处理纹理:

\n\n
function Mesh (gl, geometry, texture){//添加纹理\n  var vertexCount = geometry.vertexCount()\n  this.position = new VBO(gl, geometry).positions(), vertexCount)\n  this.normals = new VBO(gl, geometry.normals(), vertexCount)\n  this.uvs = new VBO(gl, geometry.uvs(), vertexCount)\n  this.texture = texture // new\n  this.vertexCount = vertexCount\n  this.position = new Transformation()\n  this.gl = gl\n}\n\nMesh.prototype.destroy = function () {\n  this.positions.destroy()\n  this.normals.destroy()\n  this.uvs.destroy()\n}\n\nMesh.prototype.draw = function (shaderProgram) {\n  this.positions.bindToAttribute(shaderProgram.position)\n  this.normals.bindToAttribute(shaderProgram.normal)\n  this.uvs.bindToAttribute(shaderProgram.uv)\n  this.position.sendToGpu(this.gl, shaderProgram.model)\n  this.texture.use(shaderProgram.diffuse, 0) // new\n  this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount)\n}\n\nMesh.load = function (gl, modelUrl, textureUrl){//新的\n  var geometry = Geometry.loadOBJ(modelUrl)\n  var texture = Texture.load(gl, textureUrl)\n  return Promise.all([geometry, texture]).then(function (params) {\n    返回新网格(gl,参数[0],参数[1])\n  })\n}\n
\n\n

最后的主脚本如下所示:

\n\n
var renderer = new renderer(文档.getElementById (webgl-canvas))\nrenderer.setClearColor(100, 149, 237)\nvar gl = renderer.getContext()\n\nvar objects = []\n\nMesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png')\n    .then(function (mesh) {\n      objects.push(mesh)\n    })\n\nShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag')\n             .then(function (shader) {\n               renderer.setShader(shader)\n             })\n\nvar camera = new Camera()\ncamera.setOrthographic(16, 10, 10)\nvar light = new Light()\n\nloop()\n\nfunction loop () {\n  renderer.render(camera, light, objects)\n  requestAnimationFrame(loop)\n}\n
\n\n

\"具有灯光效果的纹理对象\"

\n\n

在这一点上,甚至动画也很容易. 如果你想让相机围绕我们的对象旋转,你可以通过添加一行代码来实现:

\n\n
function loop () {\n  renderer.render(camera, light, objects)\n  camera.position = camera.position.rotateY(Math.PI / 120)\n  requestAnimationFrame(loop)\n}\n
\n\n

\"在相机动画期间旋转头部\"

\n\n

请随意使用着色器. 添加一行代码将把这个现实的照明变成卡通的东西.

\n\n
void main() {\n    浮动亮度= -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.);\n    lightness = lightness > 0.1 ? 1. : 0.; // new\n    lightness = ambientLight + (1. - ambientLight) * lightness;\n    gl_FragColor = vec4(texture2D(diffuse, vUv)).rgb * lightness, 1.);\n}\n
\n\n

这很简单,就像根据光线是否超过设定的阈值来告诉光线进入它的极端一样.

\n\n

\"头部与卡通照明应用\"

\n\n

Where to Go Next

\n\n

学习WebGL的所有技巧和复杂性有许多信息来源. 最好的部分是,如果你找不到与WebGL相关的答案, you can look for it in OpenGL, 因为WebGL基本上是基于OpenGL的一个子集, with some names being changed.

\n\n

In no particular order, 这里有一些更详细的信息来源, both for WebGL and OpenGL.

\n\n\n","as":"div","isContentFit":true,"sharingWidget":{"url":"http://fqom.tzyn.net/javascript/3d-graphics-a-webgl-tutorial","title":"3D Graphics: A WebGL Tutorial","text":null,"providers":["linkedin","twitter","facebook"],"gaCategory":null,"domain":{"name":"developers","title":"Engineering","vertical":{"name":"developers","title":"Developers","publicUrl":"http://fqom.tzyn.net/developers"},"publicUrl":"http://fqom.tzyn.net/developers/blog"},"hashtags":"Tutorial,WebGL,3DGraphics"}}