<audio id="audio" title="20 | 如何用WebGL绘制3D物体？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/63/6e2ea014a03e3ab680e227176be88563.mp3"></audio>

你好，我是月影。这一节课开始，我们学习3D图形的绘制。

之前我们主要讨论的都是2D图形的绘制，实际上WebGL真正强大之处在于，它可以绘制各种3D图形，而3D图形能够极大地增强可视化的表现能力。

用WebGL绘制3D图形，其实在基本原理上和绘制2D图形并没有什么区别，只不过是我们把绘图空间从二维扩展到三维，所以计算起来会更加复杂一些。

今天，我们就从绘制最简单的三维立方体，讲到矩阵、法向量在三维空间中的使用，这样由浅入深地带你去了解，如何用WebGL绘制出各种3D图形。

## 如何用WebGL绘制三维立方体

首先，我们来绘制熟悉的2D图形，比如矩形，再把它拓展到三维空间变成立方体。代码如下：

```
// vertex shader  顶点着色器
attribute vec2 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = vec4(a_vertexPosition, 1, 1);
}

```

```
// fragment shader   片元着色器 
#ifdef GL_ES
precision highp float;
#endif

varying vec4 vColor;

void main() {
  gl_FragColor = vColor;
}

```

```
...
// 顶点信息
renderer.setMeshData([{
  positions: [
    [-0.5, -0.5],
    [-0.5, 0.5],
    [0.5, 0.5],
    [0.5, -0.5],
  ],
  attributes: {
    color: [
      [1, 0, 0, 1],
      [1, 0, 0, 1],
      [1, 0, 0, 1],
      [1, 0, 0, 1],
    ],
  },
  cells: [[0, 1, 2], [0, 2, 3]],
}]);
renderer.render();

```

上面的3段代码，分别对应顶点着色器、片元着色器和基本的顶点信息。通过它们，我们就在画布上绘制出了一个红色的矩形。接下来，要想把2维矩形拓展到3维，我们的第一步就是要把顶点扩展到3维。这一步的操作比较简单，我们只需要把顶点从vec2扩展到vec3就可以了。

```
// vertex shader
attribute vec3 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = vec4(a_vertexPosition, 1);
}

```

**然后，我们需要计算立方体的顶点数据**。我们知道一个立方体有8个顶点，这8个顶点能组成6个面。在WebGL中，我们就需要用12个三角形来绘制它。如果每个面的属性相同，我们就可以复用8个顶点来绘制。而如果属性不同，比如每个面要绘制成不同的颜色，或者添加不同的纹理图片，我们还得把每个面的顶点分开。这样的话，我们一共需要24个顶点。

<img src="https://static001.geekbang.org/resource/image/47/4e/47cc2a856e7b2f675467f7484373e74e.jpeg" alt="" title="立方体8个顶点，6个面">

为了方便使用，我们可以写一个JavaScript函数，用来生成立方体6个面的24个顶点，以及12个三角形的索引，而且我直接在这个函数里定义了每个面的颜色。具体的函数代码如下：

```
function cube(size = 1.0, colors = [[1, 0, 0, 1]]) {
  const h = 0.5 * size;
  const vertices = [
    [-h, -h, -h],
    [-h, h, -h],
    [h, h, -h],
    [h, -h, -h],
    [-h, -h, h],
    [-h, h, h],
    [h, h, h],
    [h, -h, h],
  ];

  const positions = [];
  const color = [];
  const cells = [];

  let colorIdx = 0;
  let cellsIdx = 0;
  const colorLen = colors.length;

  function quad(a, b, c, d) {
    [a, b, c, d].forEach((i) =&gt; {
      positions.push(vertices[i]);
      color.push(colors[colorIdx % colorLen]);
    });
    cells.push(
      [0, 1, 2].map(i =&gt; i + cellsIdx),
      [0, 2, 3].map(i =&gt; i + cellsIdx),
    );
    colorIdx++;
    cellsIdx += 4;
  }

  quad(1, 0, 3, 2);
  quad(4, 5, 6, 7);
  quad(2, 3, 7, 6);
  quad(5, 4, 0, 1);
  quad(3, 0, 4, 7);
  quad(6, 5, 1, 2);

  return {positions, color, cells};
}

```

这样，我们就可以构建出立方体的顶点信息，我在下面给出了12个立方体的顶点。

```
const geometry = cube(1.0, [
  [1, 0, 0, 1],
  [0, 0.5, 0, 1],
  [1, 0, 1, 1],
]);

```

通过上面的代码，我们就能创建出一个棱长为1的立方体，并且六个面的颜色分别是“红、绿、蓝、红、绿、蓝”。

这里我还想补充一点内容，绘制3D图形与绘制2D图形有一点不一样，那就是我们必须要开启**深度检测和启用深度缓冲区**。在WebGL中，我们可以通过`gl.enable(gl.DEPTH_TEST)`，来开启深度检测。

而且，我们在清空画布的时候，也要用`gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);`，来同时清空颜色缓冲区和深度缓冲区。启动和清空深度检测和深度缓冲区这两个步骤，是这个过程中非常重要的一环，但是我们几乎不会用原生的方式来写代码，所以我们了解到这个程度就可以了。

事实上，对于上面这些步骤，为了方便使用，我们还是可以直接使用gl-renderer库。它封装了深度检测，在使用它的时候，我们只要在创建renderer的时候设置一个参数depth: true即可。

现在，我们把这个三维立方体用gl-renderer渲染出来，渲染代码如下：

```
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas, {
  depth: true,
});

const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

renderer.setMeshData([{
  positions: geometry.positions,
  attributes: {
    color: geometry.color,
  },
  cells: geometry.cells,
}]);
renderer.render();

```

<img src="https://static001.geekbang.org/resource/image/39/4d/39bc7f588350b3c990c4cd0e2b616e4d.jpeg" alt="" title="立方体的正视图，在画布上只呈现了一个红色正方形，因为其他面被遮挡住了">

## 投影矩阵：变换WebGL坐标系

结合渲染出来的这个图形，我想让你再仔细观看一下我们刚才调用的代码。

当时立方体的顶点我们是这么定义的：

```
 const vertices = [
    [-h, -h, -h],
    [-h, h, -h],
    [h, h, -h],
    [h, -h, -h],
    [-h, -h, h],
    [-h, h, h],
    [h, h, h],
    [h, -h, h],

```

而立方体的六个面的颜色，我们是这么定义的：

```
//立方体的六个面
  quad(1, 0, 3, 2); // 红 -- 这一面应该朝内
  quad(4, 5, 6, 7);  // 绿 -- 这一面应该朝外
  quad(2, 3, 7, 6);  // 蓝
  quad(5, 4, 0, 1);  // 红
  quad(3, 0, 4, 7);  // 绿
  quad(6, 5, 1, 2);  // 蓝

```

有没有发现问题？我们之前说过，WebGL的坐标系是z轴向外为正，z轴向内为负，所以根据我们调用的代码，赋给靠外那一面的颜色应该是绿色，而不是红色。但是这个立方体朝向我们的一面却是红色，这是为什么呢？

实际上，WebGL默认的**剪裁坐标**的z轴方向，的确是朝内的。也就是说，WebGL坐标系就是一个左手系而不是右手系。但是，基本上所有的WebGL教程，也包括我们前面的课程，一直都在说WebGL坐标系是右手系，这又是为什么呢？

这是因为，规范的直角坐标系是右手坐标系，符合我们的使用习惯。因此，一般来说，不管什么图形库或图形框架，在绘图的时候，都会默认将坐标系从左手系转换为右手系。

**那我们下一步，就是要将WebGL的坐标系从左手系转换为右手系。**关于坐标转换，我们可以通过齐次矩阵来完成。将左手系坐标转换为右手系，实际上就是将z轴坐标方向反转，对应的齐次矩阵如下：

```
[
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, -1, 0,
  0, 0, 0, 1
]

```

这种转换坐标的齐次矩阵，又被称为**投影矩阵**（ProjectionMatrix）。接着，我们就修改一下顶点着色器，将投影矩阵加入进去。这样，画布上显示的就是绿色的正方形了。代码和效果图如下：

```
attribute vec3 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;
uniform mat4 projectionMatrix;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = projectionMatrix * vec4(a_vertexPosition, 1.0);
}

```

<img src="https://static001.geekbang.org/resource/image/19/40/19ee7c8dbddee10663e83b00eb740040.jpeg" alt="">

投影矩阵不仅可以用来改变z轴坐标，还可以用来实现正交投影、透视投影以及其他的投影变换，在下一节课我们会深入去讲。

## 模型矩阵：让立方体旋转起来

通过前面的操作，我们还是只能看到立方体的一个面，因为我们的视线正好是垂直于z轴的，所以其他的面被完全挡住了。不过，我们可以通过旋转立方体，将其他的面露出来。旋转立方体，同样可以通过矩阵运算来实现。这次我们要用到另一个齐次矩阵，它定义了被绘制的物体变换，这个矩阵叫做**模型矩阵**（ModelMatrix）。接下来，我们就把模型矩阵加入到顶点着色器中，然后将它与投影矩阵相乘，最后再乘上齐次坐标，就得到最终的顶点坐标了。

```
attribute vec3 a_vertexPosition;
attribute vec4 color;


varying vec4 vColor;
uniform mat4 projectionMatrix;
uniform mat4 modelMatrix;


void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = projectionMatrix * modelMatrix * vec4(a_vertexPosition, 1.0);
}

```

接着，我们定义一个JavaScript函数，用立方体沿x、y、z轴的旋转来生成模型矩阵。我们以x、y、z三个方向的旋转得到三个齐次矩阵，然后将它们相乘，就能得到最终的模型矩阵。

```
import {multiply} from '../common/lib/math/functions/Mat4Func.js';

function fromRotation(rotationX, rotationY, rotationZ) {
  let c = Math.cos(rotationX);
  let s = Math.sin(rotationX);
  const rx = [
    1, 0, 0, 0,
    0, c, s, 0,
    0, -s, c, 0,
    0, 0, 0, 1,
  ];

  c = Math.cos(rotationY);
  s = Math.sin(rotationY);
  const ry = [
    c, 0, s, 0,
    0, 1, 0, 0,
    -s, 0, c, 0,
    0, 0, 0, 1,
  ];

  c = Math.cos(rotationZ);
  s = Math.sin(rotationZ);
  const rz = [
    c, s, 0, 0,
    -s, c, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
  ];

  const ret = [];
  multiply(ret, rx, ry);
  multiply(ret, ret, rz);
  return ret;
}

```

最后，我们把这个模型矩阵传给顶点着色器，不断更新三个旋转角度，就能实现立方体旋转的效果，也就可以看到立方体其他各个面了。效果和代码如下所示：

```
let rotationX = 0;
let rotationY = 0;
let rotationZ = 0;

function update() {
  rotationX += 0.003;
  rotationY += 0.005;
  rotationZ += 0.007;
  renderer.uniforms.modelMatrix = fromRotation(rotationX, rotationY, rotationZ);
  requestAnimationFrame(update);
}
update();

```

<img src="https://static001.geekbang.org/resource/image/ff/69/ff4dd2c260eb6aba433b4yye4cef7569.gif" alt="">

到这里，我们就完成了一个旋转的立方体。

## 如何用WebGL绘制圆柱体

立方体还是比较简单的几何体，那类似的，我们还可以构建顶点和三角形，来绘制更加复杂的图形，比如圆柱体、球体等等。这里，我再用绘制圆柱体来举个例子。

我们知道圆柱体的两个底面都是圆，我们可以用割圆的方式对圆进行简单的三角剖分，然后把圆柱的侧面用上下两个圆上的顶点进行三角剖分。

<img src="https://static001.geekbang.org/resource/image/30/01/30a5c31ea777ef8b5f9586d5bb6ed401.jpg" alt="">

具体的算法如下：

```
function cylinder(radius = 1.0, height = 1.0, segments = 30, colorCap = [0, 0, 1, 1], colorSide = [1, 0, 0, 1]) {
  const positions = [];
  const cells = [];
  const color = [];
  const cap = [[0, 0]];
  const h = 0.5 * height;

  // 顶和底的圆
  for(let i = 0; i &lt;= segments; i++) {
    const theta = Math.PI * 2 * i / segments;
    const p = [radius * Math.cos(theta), radius * Math.sin(theta)];
    cap.push(p);
  }

  positions.push(...cap.map(([x, y]) =&gt; [x, y, -h]));
  for(let i = 1; i &lt; cap.length - 1; i++) {
    cells.push([0, i, i + 1]);
  }
  cells.push([0, cap.length - 1, 1]);

  let offset = positions.length;
  positions.push(...cap.map(([x, y]) =&gt; [x, y, h]));
  for(let i = 1; i &lt; cap.length - 1; i++) {
    cells.push([offset, offset + i, offset + i + 1]);
  }
  cells.push([offset, offset + cap.length - 1, offset + 1]);

  color.push(...positions.map(() =&gt; colorCap));

  // 侧面
  offset = positions.length;
  for(let i = 1; i &lt; cap.length; i++) {
    const a = [...cap[i], h];
    const b = [...cap[i], -h];
    const nextIdx = i &lt; cap.length - 1 ? i + 1 : 1;
    const c = [...cap[nextIdx], -h];
    const d = [...cap[nextIdx], h];

    positions.push(a, b, c, d);
    color.push(colorSide, colorSide, colorSide, colorSide);
    cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]);
    offset += 4;
  }

  return {positions, cells, color};
}

```

这样呢，我们就可以绘制出圆柱体了，把前面例子代码里的cube改为cylinder，效果如下图所示：

<img src="https://static001.geekbang.org/resource/image/b1/06/b14b73c131c9bf29887bcbbf8df4d506.gif" alt="">

所以我们看到，用WebGL绘制三维物体，实际上和绘制二维物体没有什么本质不同，都是将图形（对于三维来说，也就是几何体）的顶点数据构造出来，然后将它们送到缓冲区中，再执行绘制。只不过三维图形的绘制需要构造三维的顶点和网格，在绘制前还需要启用深度缓冲区。

## 构造和使用法向量

在前面两个例子中，我们构造出了几何体的顶点信息，包括顶点的位置和颜色信息，除此之外，我们还可以构造几何体的其他信息，其中一种比较有用的信息是顶点的法向量信息。

法向量那什么是法向量呢？法向量表示每个顶点所在的面的法线方向，在3D渲染中，我们可以通过法向量来计算光照、阴影、进行边缘检测等等。法向量非常有用，所以我们也要掌握它的构造方法。

### 1. 构造法向量

对于立方体来说，得到法向量非常简单，我们只要找到垂直于立方体6个面上的线段，再得到这些线段所在向量上的单位向量就行了。显然，标准立方体中6个面的法向量如下：

```
[0, 0, -1]
[0, 0, 1]
[0, -1, 0]
[0, 1, 0]
[-1, 0, 0]
[1, 0, 0]

```

对于圆柱体来说，底面和顶面法线分别是(0, 0, -1)和(0, 0, 1)。侧面的计算稍微复杂一些，需要通过三角网格来计算。具体怎么做呢？

因为几何体是由三角网格构成的，而法线是垂直于三角网格的线，如果要计算法线，我们可以借助三角形的顶点，使用向量的叉积定理来求。我们假设在一个平面内，有向量a和b，n是它们的法向量，那我们可以得到公式：n = a X b。

<img src="https://static001.geekbang.org/resource/image/e5/85/e5bddfd9b16c6f325c0c2e95e428a285.jpeg" alt="">

根据这个公式，我们可以通过以下方法求出侧面的法向量：

```
  const tmp1 = [];
  const tmp2 = [];
  // 侧面
  offset = positions.length;
  for(let i = 1; i &lt; cap.length; i++) {
    const a = [...cap[i], h];
    const b = [...cap[i], -h];
    const nextIdx = i &lt; cap.length - 1 ? i + 1 : 1;
    const c = [...cap[nextIdx], -h];
    const d = [...cap[nextIdx], h];

    positions.push(a, b, c, d);

    const norm = [];
    cross(norm, subtract(tmp1, b, a), subtract(tmp2, c, a));
    normalize(norm, norm);
    normal.push(norm, norm, norm, norm); // abcd四个点共面，它们的法向量相同
    color.push(colorSide, colorSide, colorSide, colorSide);
    cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]);
    offset += 4;
  }

```

求出法向量，我们可以使用法向量来实现丰富的效果，比如点光源。下面，我们就在shader中实现点光源效果。

### 2. 法向量矩阵

因为我们在shader中，会使用模型矩阵对顶点进行变换，所以在片元着色器中，我们拿到的是变换后的顶点坐标，这时候，如果我们要应用法向量，需要对法向量也进行变换，我们可以通过一个矩阵来实现，这个矩阵叫做法向量矩阵（NormalMatrix）。它是模型矩阵的逆转置矩阵，不过它非常特殊，是一个3X3的矩阵（mat3），而像模型矩阵、投影矩阵等等矩阵都是4X4的。

得到了法向量和法向量矩阵，我们可以使用法向量和法向量矩阵来实现点光源光照效果。首先，我们要实现如下顶点着色器：

```
attribute vec3 a_vertexPosition;
attribute vec4 color;
attribute vec3 normal;

varying vec4 vColor;
varying float vCos;
uniform mat4 projectionMatrix;
uniform mat4 modelMatrix;
uniform mat3 normalMatrix;

const vec3 lightPosition = vec3(1, 0, 0);

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  vec4 pos =  modelMatrix * vec4(a_vertexPosition, 1.0);
  vec3 invLight = lightPosition - pos.xyz;
  vec3 norm = normalize(normalMatrix * normal);
  vCos = max(dot(normalize(invLight), norm), 0.0);
  gl_Position = projectionMatrix * pos;
}

```

在上面顶点着色器的代码中，我们计算的是位于(1,0,0)坐标处的点光源与几何体法线的夹角余弦。那根据物体漫反射模型，光照强度等于光线与法向量夹角的余弦。

<img src="https://static001.geekbang.org/resource/image/f6/7b/f620694bfbf78143dd99965cc111b97b.jpeg" alt="">

因此，我们求出这个余弦值，就能在片元着色器叠加光照了。操作代码和实现效果如下：

```
#ifdef GL_ES
precision highp float;
#endif

uniform vec4 lightColor;
varying vec4 vColor;
varying float vCos;

void main() {
  gl_FragColor.rgb = vColor.rgb + vCos * lightColor.a * lightColor.rgb;
  gl_FragColor.a = vColor.a;
}

```

<img src="https://static001.geekbang.org/resource/image/e8/03/e87fe067e3fc7461d4fd489e12461003.gif" alt="" title="用法向量计算出的光照效果">

## 要点总结

今天，我们以绘制立方体和圆柱体为例，讲了用WebGL绘制三维几何体的基本原理。3D绘图在原理上和2D绘图几乎是完全一样的，就是构建顶点数据，然后将数据送入缓冲区执行绘制。只是，2D绘图用二维顶点数据，而3D绘图用三维定点数据。

另外，3D绘图时，我们除了构造顶点数据之外，还可以构造其他的数据，比较有用的是法向量。法向量是垂直于物体表面三角网格的向量，使用它可以来计算光照。在片元着色器中我们拿到的是经过模型矩阵变换后的顶点，使用法向量，我们还需要用一个法向量矩阵对它进行变换。法向量矩阵是模型矩阵的逆转置矩阵，它是一个3X3的矩阵，将法向量经过法向量矩阵变换后，我们就可以和片元着色器中的顶点进行运算了。

## 小试牛刀

1. 在今天的课程中，我们绘制出了正立方体。那你能修改例子中的cube函数，构造出非正立方体吗？新的cube函数签名如下：

```
 function cube(width = 1.0, height = 1.0, depth = 1.0, colors = [[1, 0, 0, 1]])

```

1. 你能用我们今天讲的方法绘制出一个正四面体，并给不同的面设置不同的颜色，然后在正四面体上实现点光源光照效果吗？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

## 源码

课程中完整示例代码见 [GitHub仓库](https://github.com/akira-cn/graphics/tree/master/3d-basic)
