个性化阅读
专注于IT技术分析

3D图形:WebGL教程

本文概述

3D图形世界可能会令人生畏。无论你是只想创建一个交互式3D徽标, 还是设计一款功能完善的游戏, 如果你不了解3D渲染的原理, 那么你都将无法使用抽象出很多东西的库。

使用库可能只是正确的工具, 而JavaScript具有Three.js形式的令人惊讶的开源代码。但是, 使用预制解决方案有一些缺点:

  • 它们可能具有许多你不打算使用的功能。最小化的three three.js基本功能的大小约为500kB, 任何其他功能(加载实际模型文件就是其中之一)使有效负载更大。传输大量数据只是为了在你的网站上显示旋转的徽标将是浪费。
  • 额外的抽象层会使原本容易进行的修改难以执行。你在屏幕上为对象着色的创造性方法既可以直接实现, 也可以需要数十小时的工作才能整合到库的抽象中。
  • 尽管在大多数情况下都对库进行了很好的优化, 但可以为你的用例省去很多麻烦。渲染器可能导致某些过程在图形卡上运行数百万次。从该过程中删除的每条指令都意味着较弱的图形卡可以毫无问题地处理你的内容。

即使你决定使用高级图形库, 也可以通过对底层事物的基本了解来更有效地使用它。库还可以具有高级功能, 例如three.js中的ShaderMaterial。了解图形渲染的原理可以使你使用这些功能。

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

我们的目标是简要介绍渲染3D图形并使用WebGL实施这些图形背后的所有关键概念。你将看到最常见的操作, 即在空白空间中显示和移动3D对象。

你可以使用最终代码进行操作。

表示3D模型

你需要了解的第一件事是如何表示3D模型。模型由三角形的网格组成。对于三角形的每个角, 每个三角形由三个顶点表示。顶点具有三个最常见的属性。

顶点位置

位置是顶点最直观的属性。它是3D空间中的位置, 由3D坐标矢量表示。如果你知道空间中三个点的确切坐标, 则将获得在它们之间绘制一个简单三角形所需的全部信息。为了使模型在渲染时看起来确实好看, 还需要提供一些其他信息给渲染器。

顶点法线

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

考虑上面的两个模型。它们包含相同的顶点位置, 但渲染时看起来完全不同。那怎么可能?

除了告诉渲染器我们希望顶点位于何处之外, 我们还可以提示表面如何在该确切位置倾斜。提示采用模型上该特定点处的表面法线的形式, 用3D向量表示。下图应该使你更加描述如何处理。

平滑和平滑着色法线之间的比较

左右表面分别对应于上一张图像中的左右球。红色箭头代表为顶点指定的法线, 蓝色箭头代表渲染器对法线在顶点之间所有点的外观的计算。该图显示了2D空间的演示, 但相同的原理适用于3D。

正常状态是光线如何照亮表面的提示。光线的方向与法线越近, 该点越亮。在法线方向上逐渐变化会导致光梯度, 而在其间没有任何变化的情况下进行突然变化会导致在其整个表面上具有恒定照明的表面以及它们之间的照明突然变化。

纹理坐标

最后一个重要属性是纹理坐标, 通常称为UV贴图。你有一个模型和要应用于它的纹理。纹理上有多个区域, 代表我们要应用于模型不同部分的图像。必须有一种方法来标记应该用纹理的哪个部分来表示哪个三角形。那就是纹理贴图的来源。

对于每个顶点, 我们标记两个坐标U和V。这些坐标代表纹理上的位置, U代表水平轴, V代表垂直轴。这些值不是以像素为单位, 而是图像中的百分比位置。图像的左下角用两个0表示, 而右上角用两个1表示。

通过获取三角形中每个顶点的UV坐标, 然后在纹理上的那些坐标之间应用捕获的图像, 即可绘制三角形。

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

你可以在上图看到UV贴图的演示。采取球形模型, 并将其切成足够小以可以展平到2D曲面上的部分。进行切割的接缝处标有较粗的线。其中一个补丁已被突出显示, 因此你可以很好地了解它们的匹配情况。你还可以看到微笑中部的接缝如何将部分嘴分成两个不同的区域。

线框不是纹理的一部分, 而是叠加在图像上, 以便你查看事物如何映射在一起。

加载OBJ模型

信不信由你, 这就是创建你自己的简单模型加载器所需的全部知识。 OBJ文件格式非常简单, 只需几行代码即可实现解析器。

该文件以v <float> <float> <float>格式列出顶点位置, 并带有可选的第四个浮点数(为了使事情简单, 我们将忽略它)。顶点法线类似地用vn <float> <float> <float>表示。最后, 纹理坐标用vt <float> <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
}

几何结构保留将模型发送到图形卡进行处理所需的确切数据。不过, 在执行此操作之前, 你可能希望能够在屏幕上四处移动模型。

执行空间变换

我们加载的模型中的所有点均相对于其坐标系。如果要平移, 旋转和缩放模型, 我们要做的就是在其坐标系上执行该操作。相对于坐标系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, 我们只需要将变换矩阵与向量相乘即可:

q.x
q.y
q.z
1

这将导致该点沿新x轴移动q.x, 沿新y轴移动q.y和沿新z轴移动q.z。最后, 它使该点额外移动了p向量, 这就是为什么我们使用一个作为乘法的最后元素的原因。

使用这些矩阵的最大优势在于, 如果我们要在顶点上执行多个变换, 则可以在变换顶点本身之前通过乘以它们的矩阵将它们合并为一个变换。

可以执行多种转换, 我们将介绍关键转换。

没有转变

如果没有进行任何转换, 则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 p.x
0 1 0 p.y
0 0 1 p.z
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轴旋转的帧变换

上图显示了当我们绕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 / 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代表近裁剪面和远裁剪面, 它们是相机可以捕获的最小和最大距离。在上图中, 它们由平截头体的平行边表示。

透视投影通常用一个视场(我们将使用垂直投影), 纵横比以及近和远平面距离来表示。该信息可用于计算宽度和高度, 然后可以从以下模板创建矩阵:

2*n/width          0           0           0
        0 2*n/height           0           0
        0          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(视野)代表相机用镜头捕捉的垂直角度。长宽比代表图片宽度和高度之间的比例, 并基于我们渲染到的屏幕的尺寸。

实作

现在我们可以将摄像机表示为存储摄像机位置和投影矩阵的类。我们还需要知道如何计算逆变换。解决一般矩阵求逆可能会遇到问题, 但是对于我们的特殊情况有一种简化的方法。

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非常擅长将4×4矩阵相乘, 因此我们将充分利用该功能。

在最后一步, GPU将栅格化该三角形。光栅化是获取矢量图形并确定要显示该矢量图形对象的屏幕上哪些像素需要绘制的过程。在我们的案例中, GPU试图确定哪些像素位于每个三角形内。对于每个像素, GPU都会询问你要绘制哪种颜色。

这是绘制所需内容的四个要素, 它们是图形管道的最简单示例。接下来是对它们的介绍以及一个简单的实现。

默认帧缓冲区

WebGL应用程序最重要的元素是WebGL上下文。如果当前使用的浏览器尚不支持所有WebGL功能, 则可以使用gl = canvas.getContext(‘webgl’)进行访问, 也可以使用’experimental-webgl’作为后备。我们提到的画布是我们要绘制的画布的DOM元素。上下文包含许多内容, 其中包括默认的帧缓冲区。

你可以将framebuffer大致描述为可以使用的任何缓冲区(对象)。默认情况下, 默认帧缓冲区存储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(红色, 绿色, 蓝色, 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 id="webgl-canvas" width="800" height="500"></canvas>
    <script src="script.js"></script>
</body>
</html>

上一帧渲染完成且所有事件处理完成后, requestAnimationFrame调用将导致再次调用该循环。

顶点缓冲对象

你需要做的第一件事是定义要绘制的顶点。你可以通过在3D空间中通过矢量描述它们来实现。之后, 你想通过创建新的顶点缓冲区对象(VBO)将数据移入GPU RAM。

缓冲区对象通常是在GPU上存储一系列内存块的对象。它是一个VBO, 仅表示GPU可以将其用于内存。大多数情况下, 你创建的缓冲区对象将是VBO。

你可以通过获取所有N个顶点并创建一个浮点数组来填充VBO, 该数组的顶点位置和顶点法向VBO包含3N个元素, 纹理坐标VBO包含2N个元素。每组三个浮点, 或用于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(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
}

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

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

着色器

接下来是前面描述的两步过程, 即将点移动到所需位置并绘制所有单个像素。为此, 我们编写了一个在图形卡上运行多次的程序。该程序通常至少由两部分组成。第一部分是一个顶点着色器, 它为每个顶点运行, 并输出我们应该在屏幕上放置顶点的​​位置等。第二部分是”片段着色器”, 它针对屏幕上三角形覆盖的每个像素运行, 并输出该像素应绘制到的颜色。

顶点着色器

假设你想让模型在屏幕上左右移动。在幼稚的方法中, 你可以更新每个顶点的位置并将其重新发送到GPU。这个过程既昂贵又缓慢。或者, 你将为GPU提供一个程序, 以在每个顶点上运行, 并与为完成该任务而构建的处理器并行执行所有这些操作。这就是顶点着色器的作用。

顶点着色器是渲染管线中处理各个顶点的部分。在对顶点进行所有可能的转换后, 对顶点着色器的调用将接收单个顶点并输出单个顶点。

着色器以GLSL编写。这种语言有很多独特的元素, 但是大多数语法非常类似于C, 因此大多数人应该可以理解。

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

  • 属性-这些是保存顶点特定属性的输入。之前, 我们以三元素向量的形式将顶点的位置描述为属性。你可以将属性视为描述一个顶点的值。
  • 统一的-这些输入对于同一渲染调用中的每个顶点都相同。假设我们希望能够通过定义转换矩阵来移动模型。你可以使用统一变量对此进行描述。你也可以指向GPU上的资源, 例如纹理。你可以将制服视为描述模型或模型一部分的值。
  • 变化—这些是我们传递给片段着色器的输出。由于顶点的三角形可能有数千个像素, 因此每个像素将根据位置接收此变量的插值。因此, 如果一个顶点发送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函数中没有返回值。我们希望返回的所有值都被分配给可变变量或特殊变量。在这里, 我们将gl_Position分配给它, 它是一个四维向量, 最后一维应始终设置为一。你可能会注意到的另一件奇怪的事情是我们从位置向量构造vec4的方式。你可以通过使用四个浮点数, 两个vec2或导致四个元素的任何其他组合来构造vec4。一旦你熟悉转换矩阵, 就会发现很多看似奇怪的类型转换。

你还可以看到, 这里我们可以非常轻松地执行矩阵转换。 GLSL专为此类工作而制作。通过将投影, 视图和模型矩阵相乘并将其应用于该位置, 可以计算出输出位置。输出法线仅转换到世界空间。稍后我们将解释为什么我们在正常转换后就停在那里了。

现在, 我们将使其保持简单, 然后继续绘制单个像素。

片段着色器

片段着色器是图形管线中栅格化之后的步骤。它为要绘制的对象的每个像素生成颜色, 深度和其他数据。

实现片段着色器的原理与顶点着色器非常相似。但是, 存在三个主要区别:

  • 不再有变化的输出, 并且属性输入已替换为变化的输入。我们刚刚在流水线中继续前进, 顶点着色器中输出的东西现在是片段着色器中的输入。
  • 现在, 我们唯一的输出是gl_FragColor, 它是vec4。元素分别表示红色, 绿色, 蓝色和alpha(RGBA), 其变量在0到1的范围内。除非你要进行透明处理, 否则应将alpha保持为1。透明度是一个相当高级的概念, 因此我们将坚持使用不透明的对象。
  • 在片段着色器的开始处, 你需要设置浮点精度, 这对于插值非常重要。在几乎所有情况下, 只需坚持以下着色器的代码即可。

考虑到这一点, 你可以轻松编写一个着色器, 根据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.);
}

功能夹仅将对象中的所有浮子限制在给定的范围内。其余代码应该非常简单。

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

将着色器合并为程序

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

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

关于这里发生的事情, 没有太多要说的。每个着色器都被分配了一个字符串作为源并进行编译, 然后我们检查是否存在编译错误。然后, 我们通过链接这两个着色器来创建一个程序。最后, 我们存储指向所有相关属性和后代的指针。

实际绘制模型

最后, 但并非最不重要, 你绘制模型。

首先, 选择要使用的着色器程序。

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

然后, 你将所有与相机相关的制服发送到GPU。每次更换或移动摄像机时, 这些制服仅更改一次。

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

最后, 你将转换和VBO分别分配给制服和属性。由于必须对每个VBO进行此操作, 因此可以将其数据绑定创建为一种方法。

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

然后, 将三个浮点数的数组分配给制服。每个统一类型都有不同的签名, 因此文档和更多文档是你的朋友。最后, 你在屏幕上绘制三角形数组。你告诉绘图调用drawArrays()从哪个顶点开始, 以及要绘制多少个顶点。传递的第一个参数告诉WebGL它应如何解释顶点数组。使用TRIANGLES可以获取三个乘以三个顶点, 并为每个三元组绘制一个三角形。使用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(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)
}
在画布上绘制的对象,其颜色取决于UV坐标

这看起来有些随机, 但是你可以根据球体在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.);
}
在画布上绘制的棕色物体

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

加光

光线和阴影是使我们能够感知物体形状的工具。灯具有多种形状和大小:聚光灯成圆锥形发光, 灯泡向各个方向传播光, 最有趣的是, 太阳太远了, 以至于照耀我们所有的光都散发出来。目的, 朝着同一个方向。

听起来似乎最容易实现, 因为你需要提供的只是所有光线传播的方向。对于在屏幕上绘制的每个像素, 你都要检查光线照射到对象的角度。这是表面法线进入的地方。

演示光线与表面法线之间的角度,适用于平坦和平滑的阴影

你可以看到所有光线沿相同方向流动, 并以不同角度撞击表面, 这些角度基于光线与表面法线之间的角度。它们重合的越多, 光线越强。

如果在光线的归一化向量和表面法线之间执行点积, 则当光线完全垂直地照射表面时, 你将获得-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.);
    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在所有极端情况下如何工作的更多技术细节。
赞(0)
未经允许不得转载:srcmini » 3D图形:WebGL教程

评论 抢沙发

评论前必须登录!