从本节开始,我们学习绘制 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、顶点法线,顶点颜色等。一旦有一个信息不同,就必须用两个顶点来表示。
仍然以矩形举例,每个顶点只包含坐标
和颜色
两类信息。如果我们的矩形是纯色的,假设是红色。
//顶点信息
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
为黄蓝渐变。 如下图所示:
我们看一下顶点数组:
//顶点信息
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 个不重复的顶点。
//正方体 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
]
接下来定义六个面包含的顶点索引:
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] // 下面
];
定义六个面的颜色信息:
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] // 下面,青色
]
有了顶点坐标和颜色信息,接下来我们写一个方法生成立方体的顶点属性。 该方法接收三个参数:宽度、高度、深度,返回一个包含组成立方体的顶点坐标、颜色、索引的对象。
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 的正方体:
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 轴旋转
、正交投影
四个方法,如下:
//返回一个单位矩阵
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 中传过来的模型投影变换矩阵,同时将变换矩阵左乘顶点坐标。
// 接收顶点坐标 (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 度,然后将旋转对应的矩阵传给顶点着色器。
//生成单位矩阵
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 坐标也都相同。按照这个逻辑,我们思考下球体的顶点生成过程:
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)
};
}
通过这个函数,我们得到了一个顶点对象,该对象包含所有顶点的坐标信息和索引信息。接下来我们为球体的每个三角面增加颜色信息。
我们知道,如果一个顶点的坐标相同,颜色不同的话,也必须视为两个顶点,否则会产生渐变颜色。因此,我们目前得到的球体的顶点仅仅坐标相同,如果我们要为每一个三角面绘制一种颜色的话,需要额外增加顶点,且不再使用索引绘制
,而是采用顶点数组绘制
。
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;
}
该方法将我们第一步获取的球体顶点数组展开,得到所有三角形的顶点对象。
接着,我们可以为顶点施加颜色了。
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。
生成算法如下:
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 时,得出的形体是椎体:
let coneVertex = createCone(6, 0, 12, 12, 12);
效果如下:
当我们定义上表面和下表面的半径相同,且都不为 0 时,得出的形体是柱体:
let coneVertex = createCone(4, 4, 12, 12, 12);
效果如下:
当我们定义上表面和下表面的半径不同,且都不为 0 时,得出的形体是台体(也可以称为棱锥体):
let coneVertex = createCone(6, 3, 12, 12, 12);
效果如下:
回顾
本节主要教大家掌握使用普通三角面构建复杂形体的思路,顺便让大家简单了解投影变换
和模型变换
的用法(详细的变换我们在中级进阶深入讲解)。
下一节,我们将绘制方法封装一下,练习绘制多个模型。