Skip to content

从本节开始,我们学习绘制 3D 形体,仍然是从简单模型入手,我们首先学习绘制一个立方体。

目标

本节通过绘制常见的立方体、球体、椎体来学习如何使用基本图形构建规则的形体。

通过本节学习,你将掌握如下内容:

  • WebGL 坐标系。
    • 裁剪坐标系。
    • NDC 坐标系。
  • 坐标系变换。
    • 模型变换。
    • 投影变换。
  • 立方体、球体、椎体是如何用三角面组成的。
  • 背面剔除的作用。

WebGL 坐标系

本节开始学习 3D 形体的绘制,与之前几个章节绘制点和面不同,3D 形体的顶点坐标需要包含深度信息 Z 轴 坐标。所以我们先了解一下 WebGL 坐标系 的概念。

后续章节有关于WebGL 坐标系坐标系基本变换原理与算法实现的深入讲解,但是为了本节学习的方便,还是要在此介绍一下坐标系的相关知识。

WebGL 采用左手坐标系,X 轴向右为正,Y 轴向上为正,Z 轴沿着屏幕往里为正,如下图:

!
讲到这里,我想有很多同学会质疑了:WebGL 不是遵循右手坐标系吗,这里怎么成左手坐标系了?

没错,WebGL 是遵循右手坐标系,但仅仅是遵循,是期望大家遵守的规范。其实 WebGL 内部 (裁剪坐标系) 是基于左手坐标系的,Z 轴沿屏幕向里为正方向。如果您迫切想知道为什么?请点击WebGL坐标系章节,该章节会有示例来证明。

WebGL 坐标系 X、Y、Z 三个坐标分量的的范围是【-1,1】,即一个边长为 2 的正方体,原点在正方体中心。这点在之前的章节有介绍,我们也称这个坐标系为标准设备坐标系,简称 NDC 坐标系

大家应该还记得,前面章节我们经常在顶点着色器中使用内置属性 gl_Position,并且在为 gl_Position 赋值之前做了一些坐标系转换(屏幕坐标系转换到裁剪坐标系)操作。

!
为了理解 gl_Position 接收坐标前所做的变换目的,这就需要理解 `gl_Position` 接收什么样的坐标。

gl_Position 接收一个 4 维浮点向量,该向量代表的是裁剪坐标系的坐标。读者可能会问了,裁剪坐标系又是怎么冒出来的?这里先不细说,大家只需要记住,gl_Position 接收的坐标范围是顶点在裁剪坐标系中的坐标就可以了。

裁剪坐标系中的坐标通常由四个分量表示:(x, y, z, w)。请注意,w 分量代表齐次坐标分量,在之前的例子中,w 都是设置成 1 ,这样做的目的是让裁剪坐标系和 NDC 坐标系就保持一致,省去裁剪坐标到 NDC 坐标的转换过程。

gl_Position 接收到裁剪坐标之后,顶点着色器会对坐标进行透视除法,透视除法的公式是 (x/w, y/w, z/w, w/w) ,透视除法过后,顶点在裁剪坐标系中的坐标就会变成 NDC 坐标系中的坐标,各个坐标的取值范围将被限制在【-1,1】之间,如果某个坐标超出这个范围,将会被 GPU 丢弃。

透视除法这个步骤是顶点着色器程序黑盒执行的,对开发者来说是透明的,无法通过编程手段干预。但是我们需要明白有这么一个过程存在。

在之前章节的例子中,我们给出的顶点坐标都是基于屏幕坐标系,然后在顶点着色器中对顶点作简单转换处理,转变成 NDC 坐标。

本节我们不着重讲解坐标系变换,而是为了讲解物体如何由三角形组成,所以会忽略裁剪坐标系之前的一些坐标变换,在 JavaScript 中直接采用裁剪坐标系坐标来表示顶点位置。

如何用三角形构建正方体

一个只包含坐标信息的立方体实际上是由 6 个正方形,每个正方形由两个三角形组成,每个三角形由三个顶点组成,所以一个立方体由 6 个正方形 * 2 个三角形 * 3 个顶点 = 36 个顶点组成,但是这 36个顶点中有很多是重复的,我们很容易发现:一个纯色立方体实际上由 6 个矩形面,或者 8 个不重复的顶点组成。

请谨记,顶点的重复与否,不只取决于顶点的坐标信息一致,还取决于该顶点所包含的其他信息是否一致。比如顶点纹理坐标 uv、顶点法线,顶点颜色等。一旦有一个信息不同,就必须用两个顶点来表示。

仍然以矩形举例,每个顶点只包含坐标颜色两类信息。如果我们的矩形是纯色的,假设是红色。

javascript
//顶点信息
var positions = [
    30, 30, 1, 0, 0, 1,    //V0
    30, 300, 1, 0, 0, 1,   //V1
    300, 300, 1, 0, 0, 1,  //V2
    30, 30, 1, 0, 0, 1,    //V0
    300, 300, 1, 0, 0, 1,  //V2
    300, 30, 1, 0, 0, 1    //V3
]

很明显,V0 和 V2 这两个顶点坐标和颜色完全一致,所以,该顶点是重复的,我们可以忽略重复的顶点。

同样地,还是这样一个矩形,每个顶点还是只包含坐标和颜色两类信息,我们想实现一个渐变矩形,从 V0 -> V1V2 为红绿渐变,从V1V2 -> V3 为黄蓝渐变。 如下图所示:

我们看一下顶点数组:

javascript
//顶点信息
var positions = [
    30, 30, 1, 0, 0, 1,    //V0,红色
    30, 300, 0, 1, 0, 1,   //V1,绿色
    300, 300, 0, 1, 0, 1,  //V2,绿色
    30, 30, 1, 1, 0, 1,  //V4,黄色
    300, 300, 1, 1, 0, 1,//V5,黄色
    300, 30, 0, 0, 1, 1    //V3,蓝色
]

可以看到,虽然 V0 和 V4,V2 和 V5 的顶点坐标一致,但是顶点颜色不一样,所以我们只能把他们当做不同的顶点处理,否则达不到我们想要的效果。

一定要理解重复顶点的定义:两个顶点必须是所有信息一致,才可以称之为重复顶点。

彩色立方体

为了在视觉层面区分出立方体的各个面,接下来我们绘制一个彩色立方体。

立方体是 3 维形体,所以它们的顶点坐标需要从 2 维扩展成 3 维,除了 x、y 坐标,还需要深度值: z 轴坐标。

代码调整

本节代码组织上和之前章节有所不同,主要有以下两点:

  • 顶点属性不再使用一个 buffer 混合存储,改为每个属性对应一个 buffer,便于维护。
  • 顶点坐标我们不再使用屏幕坐标系,而是采用 NDC 坐标系。如果使用屏幕坐标系,会涉及到相对复杂的坐标系变换,大家可能不容易理解。

我们还是按照之前的套路:

  • 定义顶点
  • 传递数据
  • 执行绘制。

首先定义顶点,由于立方体包含六个面,每个面采用同一个颜色,所以我们需要定义 6 个矩形面 * 4 个顶点 = 24 个不重复的顶点。

javascript
    //正方体 8 个顶点的坐标信息
let zeroX = 0.5;
let zeroY = 0.5;
let zeroZ = 0.5;
let positions = [
    [-zeroX, -zeroY, zeroZ],  //V0
    [zeroX, -zeroY, zeroZ],  //V1
    [zeroX, zeroY, zeroZ],   //V2
    [-zeroX, zeroY, zeroZ],  //V3
    [-zeroX, -zeroY, -zeroZ],//V4
    [-zeroX, zeroY, -zeroZ], //V5
    [zeroX, zeroY, -zeroZ],  //V6
    [zeroX, -zeroY, -zeroZ]  //V7
]

接下来定义六个面包含的顶点索引:

javascript
const CUBE_FACE_INDICES = [
  [0, 1, 2, 3], //前面
  [4, 5, 6, 7], //后面
  [0, 3, 5, 4], //左面
  [1, 7, 6, 2], //右面
  [3, 2, 6, 5], //上面
  [0, 4, 7, 1] // 下面
];

定义六个面的颜色信息:

javascript
const FACE_COLORS = [
    [1, 0, 0, 1], // 前面,红色
    [0, 1, 0, 1], // 后面,绿色
    [0, 0, 1, 1], // 左面,蓝色
    [1, 1, 0, 1], // 右面,黄色
    [1, 0, 1, 1], // 上面,品色
    [0, 1, 1, 1]  // 下面,青色
]

有了顶点坐标和颜色信息,接下来我们写一个方法生成立方体的顶点属性。 该方法接收三个参数:宽度、高度、深度,返回一个包含组成立方体的顶点坐标、颜色、索引的对象。

javascript
function createCube(width, height, depth) {
  let zeroX = width / 2;
  let zeroY = height / 2;
  let zeroZ = depth / 2;

  let cornerPositions = [
    [-zeroX, -zeroY, -zeroZ],
    [zeroX, -zeroY, -zeroZ],
    [zeroX, zeroY, -zeroZ],
    [-zeroX, zeroY, -zeroZ],
    [-zeroX, -zeroY, zeroZ],
    [-zeroX, zeroY, zeroZ],
    [zeroX, zeroY, zeroZ],
    [zeroX, -zeroY, zeroZ]
  ];
  let colorInput = [
    [255, 0, 0, 1],
    [0, 255, 0, 1],
    [0, 0, 255, 1],
    [255, 255, 0, 1],
    [0, 255, 255, 1],
    [255, 0, 255, 1]
  ];

  let colors = [];
  let positions = [];
  var indices = [];

  for (let f = 0; f < 6; ++f) {
    let faceIndices = CUBE_FACE_INDICES[f];
    let color = colorInput[f];
    for (let v = 0; v < 4; ++v) {
      let position = cornerPositions[faceIndices[v]];
      positions = positions.concat(position);
      colors = colors.concat(color);
    }
    let offset = 4 * f;
    indices.push(offset + 0, offset + 1, offset + 2);
    indices.push(offset + 0, offset + 2, offset + 3);
  }
  indices = new Uint16Array(indices);
  positions = new Float32Array(positions);
  colors = new Float32Array(colors);
  return {
    positions: positions,
    indices: indices,
    colors: colors
  };
}

有了生成立方体顶点的方法,我们生成一个边长为 1 的正方体:

javascript
var cube = createCube(1, 1, 1);

拿到了顶点的信息,就可以用我们熟悉的索引绘制方法来进行绘制了,这部分代码和之前一样,我们就不写了,看下效果:

看到这个红色矩形,有的同学或许有疑问了:

  • 我们定义的立方体的边长都是 1 ,也就是一个正方体,每个面应该是正方形,为什么渲染到屏幕后就成长方形了?

  • 如何才能看到立方体的其他表面?

第一个问题的答案

这是因为,我们给 gl_Position 赋的坐标,在 渲染到屏幕之前,GPU 还会对其做一次坐标变换:视口变换。该变换会将 NDC 坐标转换成对应设备的视口坐标。

假设有一顶点 P(0.5,0.5,0.5,1), gl_Position 接收到坐标后,会经历如下阶段:

  • 首先执行透视除法,将顶点 P 的坐标从裁剪坐标系转换到 NDC 坐标系,转换后的坐标为: P1(0.5 / 1, 0.5 / 1, 0.5 / 1, 1 / 1)。由于 w 分量是 1, 所以 P1 和 P 的坐标一致。

  • 接着,GPU 将顶点渲染到屏幕之前,对顶点坐标执行视口变换。假设我们的 canvas 视口宽度 300,高度 400,顶点坐标在 canvas 中心。那么 3D 坐标转换成 canvas 坐标的算法是:

  • canvas 坐标系 X 轴坐标 = NDC 坐标系下 X 轴坐标 * 300 / 2 = 0.5 * 150 = 75

  • canvas 坐标系 Y 轴坐标 = NDC 坐标系下 Y 轴坐标 * 400 / 2 = 0.5 * 200 = 100

所以会有一个问题,立方体的每个面宽度和高度虽然都是 1 ,但是渲染效果会随着显示设备的尺寸不同而不同。

这个问题该如何解决呢?这就引出了 WebGL 坐标系的一个重要变换:投影变换

第二个问题的答案

因为我们绘制的是立方体,没有施加动画效果,所以我们只能看到立方体前表面,那如何看到其他表面呢?大家稍微一想就能知道,我们可以让立方体转动起来,转起来之后我们就能看到其他表面了。 那如何让立方体转动起来呢? 这就引出了 WebGL 坐标系的另一个重要变换:模型变换

针对这两个问题的解决方案是对顶点施加投影和模型变换,本节我们采用业界常用的变换算法,暂时不做算法原理的讲解,只讲如何使用,让我们的正方体可以正常渲染并且能转动起来。

请谨记:每个转换可以用一个矩阵来表示,转换矩阵相乘,得出的最终矩阵用来表示组合变换。大家先记住这点,在中级进阶中的数学矩阵及运算中我会详细讲解。

让立方体转动起来。

  • 引入模型变换让立方体可以转动,以便我们能观察其他表面。
  • 引入投影变换让我们的正方体能够以正常比例渲染到目标设备,不再随视口的变化而拉伸失真。

为了引入这两个变换,我们需要引入矩阵乘法绕 X 轴旋转绕 Y 轴旋转正交投影四个方法,如下:

javascript
//返回一个单位矩阵
function identity() {}
//计算两个矩阵的乘积,返回新的矩阵。
function multiply(matrixLeft, matrixRight){}
//绕 X 轴旋转一定角度,返回新的矩阵。
function rotationX(angle) {}
//绕 Y 轴旋转一定角度,返回新的矩阵。
function rotateY(m, angle) {}
//正交投影,返回新的矩阵
function ortho(left, right, bottom, top, near, far, target) {}

在顶点着色器中定义一个变换矩阵,用来接收 JavaScript 中传过来的模型投影变换矩阵,同时将变换矩阵左乘顶点坐标。

glsl
    // 接收顶点坐标 (x, y, z)
    precision mediump float;
    attribute vec3 a_Position;
    attribute vec4 a_Color;
    varying vec4 v_Color;
    uniform mat4 u_Matrix;
    void main(){
      gl_Position =  u_Matrix * vec4(a_Position, 1);
      v_Color = a_Color;
    }

增加旋转动画效果:每隔 50 ms 分别绕 X 轴和 Y 轴转动 1 度,然后将旋转对应的矩阵传给顶点着色器。

javascript
//生成单位矩阵
var initMatrix = matrix.identify();
var currentMatrix = null;
var xAngle = 0;
var yAngle = 0;
var deg = Math.PI / 180;
function animate(e) {
      if (timer) {
        clearInterval(timer);
        timer = null;
      } else {
        timer = setInterval(() => {
          xAngle += 1;
          yAngle += 1;
          
          currentMatrix = matrix.rotationX(deg * xAngle);
          currentMatrix = matrix.rotateY(currentMatrix, deg * yAngle);
          gl.uniformMatrix4fv(u_Matrix, false, currentMatrix);
          render(gl);
        }, 50);
      }
    }

我们看下效果:

可以看到,渲染画面不再只是一幅静态的平面了,而是一个能够自由转动的立方体。

立方体的构建比较简单,我们看下如何使用三角面构建球体。

如何用三角面构建球体

!
我们学会了使用三角面构建立方体,那么球体该如何用三角面组成呢?

我们可以将球体按照纬度等分成 n 份,形成 n 个圆面,每个圆面的 Y 坐标都相同,然后将每个圆面按照经度划分成 m 份,形成 m 个顶点,这 m 个顶点的 Y 坐标也都相同。按照这个逻辑,我们思考下球体的顶点生成过程:

javascript
function createSphere(radius, divideByYAxis, divideByCircle) {
  let yUnitAngle = Math.PI / divideByYAxis;
  let circleUnitAngle = (Math.PI * 2) / divideByCircle;
  let positions = [];
  for (let i = 0; i <= divideByYAxis; i++) {
    let yValue = radius * Math.cos(yUnitAngle * i);
    let yCurrentRadius = radius * Math.sin(yUnitAngle * i);

    for (let j = 0; j <= divideByCircle; j++) {
      let xValue = yCurrentRadius * Math.cos(circleUnitAngle * j);
      let zValue = yCurrentRadius * Math.sin(circleUnitAngle * j);
      positions.push(xValue, yValue, zValue);
    }
  }

  let indices = [];
  let circleCount = divideByCircle + 1;
  for (let j = 0; j < divideByCircle; j++) {
    for (let i = 0; i < divideByYAxis; i++) {
      indices.push(i * circleCount + j);
      indices.push(i * circleCount + j + 1);
      indices.push((i + 1) * circleCount + j);

      indices.push((i + 1) * circleCount + j);
      indices.push(i * circleCount + j + 1);
      indices.push((i + 1) * circleCount + j + 1);
    }
  }
  return {
    positions: new Float32Array(positions),
    indices: new Uint16Array(indices)
  };
}

通过这个函数,我们得到了一个顶点对象,该对象包含所有顶点的坐标信息和索引信息。接下来我们为球体的每个三角面增加颜色信息。

我们知道,如果一个顶点的坐标相同,颜色不同的话,也必须视为两个顶点,否则会产生渐变颜色。因此,我们目前得到的球体的顶点仅仅坐标相同,如果我们要为每一个三角面绘制一种颜色的话,需要额外增加顶点,且不再使用索引绘制,而是采用顶点数组绘制

javascript
function transformIndicesToUnIndices(vertex) {
  let indices = vertex.indices;
  let vertexsCount = indices.length;
  let destVertex = {};

  Object.keys(vertex).forEach(function(attribute) {
    if (attribute == 'indices') {
      return;
    }
    let src = vertex[attribute];
    let elementsPerVertex = getElementsCountPerVertex(attribute);
    let dest = [];
    let index = 0;
    for (let i = 0; i < indices.length; i++) {
      for (let j = 0; j < elementsPerVertex; j++) {
        dest[index] = src[indices[i] * elementsPerVertex + j];
        index++;
      }
    }
    let type = getArrayTypeByAttribName();
    destVertex[attribute] = new type(dest);
  });
  return destVertex;
}

该方法将我们第一步获取的球体顶点数组展开,得到所有三角形的顶点对象。

接着,我们可以为顶点施加颜色了。

javascript
function createColorForVertex(vertex) {
  let vertexNums = vertex.positions;
  let colors = [];
  let color = {
    r: 255,
    g: 0,
    b: 0
  };

  for (let i = 0; i < vertexNums.length; i++) {
    if (i % 36 == 0) {
      color = randomColor();
    }
    colors.push(color.r, color.g, color.b, 255);
  }

  vertex.colors = new Uint8Array(colors);
  return vertex;
}

生成球体顶点、增加三角面颜色这两个关键步骤做完之后,我们就可以执行绘制操作了,看下绘制后的效果:

构建椎体、柱体、台体

椎体、柱体、台体可以归为一类构建方法,因为他们都受上表面、下表面、高度这三个因素的影响。 按照这种思路,我们再思考下它们的构建方法:

  • 定义上表面的半径:topRadius。
  • 定义下表面的半径:bottomRadius。
  • 定义高度:height。
  • 定义横截面的切分数量:bottomDivide。
  • 定义垂直方向的切分数量:verticalDivide。

生成算法如下:

javascript
function createCone(
  topRadius,
  bottomRadius,
  height,
  bottomDivide,
  verticalDivide
) {
    
  let vertex = {};
  let positions = [];
  let indices = [];

  for (let i = -1; i <= verticalDivide + 1; i++) {
    let currentRadius = 0;
    if (i > verticalDivide) {
      currentRadius = topRadius;
    } else if (i < 0) {
      currentRadius = bottomRadius;
    } else {
      currentRadius =
        bottomRadius + (topRadius - bottomRadius) * (i / verticalDivide);
    }
    let yValue = (height * i) / verticalDivide - height / 2;
    if (i == -1 || i == verticalDivide + 1) {
      currentRadius = 0;
      if (i == -1) {
        yValue = -height / 2;
      } else {
        yValue = height / 2;
      }
    }

    for (let j = 0; j <= bottomDivide; j++) {
      let xValue = currentRadius * Math.sin((j * Math.PI * 2) / bottomDivide);
      var zValue = currentRadius * Math.cos((j * Math.PI * 2) / bottomDivide);
      positions.push(xValue, yValue, zValue);
    }
  }

  // indices
  let vertexCountPerRadius = bottomDivide + 1;
  for (let i = 0; i < verticalDivide + 2; i++) {
    for (let j = 0; j < bottomDivide; j++) {
      indices.push(i * vertexCountPerRadius + j);
      indices.push(i * vertexCountPerRadius + j + 1);
      indices.push((i + 1) * vertexCountPerRadius + j + 1);

      indices.push(
        vertexCountPerRadius * (i + 0) + j,
        vertexCountPerRadius * (i + 1) + j + 1,
        vertexCountPerRadius * (i + 1) + j
      );
    }
  }

  vertex.positions = new Float32Array(positions);
  vertex.indices = new Uint16Array(indices);
  return vertex;
}

当我们定义上表面的半径为 0 时,得出的形体是椎体:

javascript
let coneVertex = createCone(6, 0, 12, 12, 12);

效果如下:

当我们定义上表面和下表面的半径相同,且都不为 0 时,得出的形体是柱体:

javascript
let coneVertex = createCone(4, 4, 12, 12, 12);

效果如下:

当我们定义上表面和下表面的半径不同,且都不为 0 时,得出的形体是台体(也可以称为棱锥体):

javascript
let coneVertex = createCone(6, 3, 12, 12, 12);

效果如下:

回顾

本节主要教大家掌握使用普通三角面构建复杂形体的思路,顺便让大家简单了解投影变换模型变换的用法(详细的变换我们在中级进阶深入讲解)。

下一节,我们将绘制方法封装一下,练习绘制多个模型。