任何一个复杂的图形变换都可以分解为一系列基本的变换的组合,例如:平移,缩放,旋转。这些基本变换都可以用线性的数学公式表达:
x1 = x + dx; // dx 表示 x 轴方向的平移量
y1 = y + dy;
z1 = z + dz;
w1 = 1;
// 封装成函数
function translate(dx, dy, dz) {
return function(point) {
const {x, y, z, w} = point.coordinate;
point.coordinate.x = x + dx;
point.coordinate.y = y + dy;
point.coordinate.z = z + dz;
}
}
x1 = x * sx; // sx 表示 x 轴方向的缩放比
y1 = y * sy;
z1 = z * sz;
w1 = 1;
function scale(sx, sy, sz) {
return function(point) {
const {x, y, z, w} = point.coordinate;
point.coordinate.x = x * sx;
point.coordinate.y = y * sy;
point.coordinate.z = z * sz;
}
}
旋转的操作稍微复杂一点,需要简单的数学推导推理一下,才能变为线性表达式。 以z轴方向旋转为例,z轴变换后的坐标不变,x,y坐标值应该如下:
let r = Math.hypot(x, y);
let originAngle = Math.acos(x / r); // In Math, Math.asin(x / r) equals Math.asin(y / r)
x1 = r * Math.cos(originAngle + delta);
y1 = r * Math.sin(originAngle + delta);
从表面上看,这个变换是非线性的,但实际上只需要利用一下简单的三角函数公式,就可以将上述计算变为线性方式.
cos(a + b) = cos(a) * cos(b) - sin(a) * sin(b);
sin(a + b) = sin(a) * cos(b) + cos(a) * sin(b);
x1 = x * Math.cos(delta) - y * Math.sin(delta);
y1 = x * Math.sin(delta) + y * Math.cos(delta);
z1 = z;
w1 = 1;
function rotateZ(angle) {
return function(point) {
const {x, y} = point.coordinate;
point.coordinate.x = x * Math.cos(angle) - y * Math.sin(angle);
point.coordinate.y = x * Math.sin(angle) + y * Math.cos(angle);
}
}
这个过程是最复杂,需要一点数学知识了,矢量的加减法,点乘,叉乘。
let vec1 = [ x1, y1, z1, w1];
let vec2 = [ x2, y2, z2, w2];
function add(vec1, vec2, vec3 = [0, 0, 0, 1]) {
const [ a1, b1, c1, d1] = vec1;
const [ a2, b2, c2, d2] = vec2;
const [ a3, b3, c3, d3] = vec3;
const x1 = a1/d1, y1 = b1/d1, z1 = c1/d1;
const x2 = a2/d2, y2 = b2/d2, z2 = c2/d2;
const x2 = a3/d3, y3 = b3/d3, z3 = c3/d3;
return [ x1 + x2 + x3, y1 + y2 + y3, z1 + z2 + z3, 1];
}
function minus(vec1, vec2) {
const [ a1, b1, c1, d1] = vec1;
const [ a2, b2, c2, d2] = vec2;
const x1 = a1/d1, y1 = b1/d1, z1 = c1/d1;
const x2 = a2/d2, y2 = b2/d2, z1 = c2/d2;
return [ x1 - x2, y1 - y2, z1 - z2, 1];
}
function multiply(number, vec) {
const [ a, b, c, d] = vec;
const x = a/d, y = b/d, z = c/d;
return [ x * number, y * number, z * number, 1];
}
矢量点乘的几何意义:类似于 物理中的功率计算公式,p = F(力) * V(速度)。
function dotProduct(vec1, vec2) {
const [ a1, b1, c1, d1] = vec1;
const [ a2, b2, c2, d2] = vec2;
const x1 = a1/d1, y1 = b1/d1, z1 = c1/d1;
const x2 = a2/d2, y2 = b2/d2, z1 = c2/d2;
return x1 * x2 + y1 * y2 + z1 * z2;
}
function crossProduct(vec1, vec2) {
const [ a1, b1, c1, d1] = vec1;
const [ a2, b2, c2, d2] = vec2;
const x1 = a1/d1, y1 = b1/d1, z1 = c1/d1;
const x2 = a2/d2, y2 = b2/d2, z1 = c2/d2;
return [
(y1 * z2 - z1 * y2),
(z1 * x2 - x1 * z2),
(x1 * y2 - y1 * z2),
1
]
}
矢量叉乘的几何意义:得到一个新的矢量,垂直于两个矢量形成的面,它的长度为两个矢量围成的平行四边形的面积。 利用矢量叉乘可以很容易判断两个点是顺时针方向还是逆时针方向,以及一个点是否与一条直线相交。
在上面的图中,v矢量表示空间某个点,a矢量表示旋转轴,并且a为单位矢量,其长度为1.
从图上可以很容易得出,旋转变换后的矢量 v‘ = Vc + V1 * cos(delta) + V2 * sin(delta).
只需要分别求解出 Vc,V1,V2,旋转变换公式就可以推出来了。
// a 为长度为1的单位向量, angel 为 v和a之间的夹角
// v,a的点乘结果就等于,v * sin(angle)
Vc = multiply(dotProcduct(v, a), a);
// V1 = v - Vc
V1 = minus(v, Vc);
// V2 = a x V1, 因为 V1 和 a 是垂直的,并且a的长度唯一,所以叉乘后的矢量长度和 V1 一致,
// 保证了在同一个圆上
// a x V1 的结果是和 a x v 的结果是一样的
V2 = crossProduct(a, v);
将上面的公式全部展开就可以得到以下的结果:
// @param: vec: 空间点
// @param: delta: 旋转角度(弧度)
// @param: n: 旋转轴矢量, 长度为 1
function rotate(vec, delta, n) {
const cosA = Math.cos(delta);
const sinA = Math.sin(delta);
const Vc = multiply(dotProduct(v, n), n);
const V1 = minus(v, Vc);
const V2 = crossProduct(n, v);
return add(Vc, multiply(cosA, V1), multiply(sinA, V2));
}
任意轴旋转变换的矩阵表达如下:
/**
* Creates a matrix from a given angle around a given axis
* This is equivalent to (but much faster than):
*
* mat4.identity(dest);
* mat4.rotate(dest, dest, rad, axis);
*
* @param {mat4} out mat4 receiving operation result
* @param {Number} rad the angle to rotate the matrix by
* @param {vec3} axis the axis to rotate around
* @returns {mat4} out
*/
export function fromRotation(out, rad, axis) {
let x = axis[0], y = axis[1], z = axis[2];
let len = Math.hypot(x, y, z);
let s, c, t;
if (len < glMatrix.EPSILON) { return null; }
len = 1 / len;
x *= len;
y *= len;
z *= len;
s = Math.sin(rad);
c = Math.cos(rad);
t = 1 - c;
// Perform rotation-specific matrix multiplication
out[0] = x * x * t + c;
out[1] = y * x * t + z * s;
out[2] = z * x * t - y * s;
out[3] = 0;
out[4] = x * y * t - z * s;
out[5] = y * y * t + c;
out[6] = z * y * t + x * s;
out[7] = 0;
out[8] = x * z * t + y * s;
out[9] = y * z * t - x * s;
out[10] = z * z * t + c;
out[11] = 0;
out[12] = 0;
out[13] = 0;
out[14] = 0;
out[15] = 1;
return out;
}
通过上述三种基本的空间变换组合,可以得到任意复杂的空间变换(比如先z轴旋转,再平移,再缩放),表现形式如下:
scale(sx, sy, sz)(translate(dx, dy, dz)(rorateZ(angle)(point)));
很明显地可以看出缺陷:
- 无论怎样设计变换函数的形式,最终得到的都是一个类似这样层层嵌套的形式。
- 上述例子中,任何一种组合变换都对应生成了一个新的组合函数,即使只是一个参数的变化。
- 组合变换无法用持久化的方式来表达,变换步骤无法共享。
为了解决以上问题,应该使用矩阵来表式变换,而不是函数。 相对于函数而言,矩阵可以用少量的数字描述大量的空间中的变换,并且能轻易地在程序间共享。 矩阵的真正厉害之处在于矩阵的组合。当一组特定类型的矩阵连乘起来,它们保留了变换的经过并且是可逆的。 这意味着如果平移、旋转和缩放矩阵组合在一起,当我们使用逆变换并颠倒应用的顺序,可以得到原来的点。
注意:webgl(包括openGL)的矩阵都是以列为主序,这个和数学教材上的默认顺序不同。 原因很简单,矢量一定是按列排列,矢量是用数组表示。 矩阵也是用数组存储,为了保证数组位置统一,所以矩阵也是以列为主序。
- 矩阵 * 向量 (在实际使用中,应该使用类型化数组来提升性能)
function multiplyMatrixAndVector(mat4, vec4) {
const [
c0r0, c0r1, c0r2, c0r3, // 第一列
c1r0, c1r1, c1r2, c1r3, // 第二列
c2r0, c2r1, c2r2, c2r3, // 第三列
c3r0, c3r1, c3r2, c3r3, // 第四列
] = mat4;
const [x, y, z, w] = vec4;
const resultX = (c0r0 * x) + (c1r0 * y) + (c2r0 * z) + (c3r0 * w);
const resultY = (c0r1 * x) + (c1r1 * y) + (c2r1 * z) + (c3r1 * w);
const resultZ = (c0r2 * x) + (c1r2 * y) + (c2r2 * z) + (c3r2 * w);
const resultW = (c0r3 * x) + (c1r3 * y) + (c2r3 * z) + (c3r3 * w);
return [resultX, resultY, resultZ, resultW]
}
- 矩阵 * 矩阵 gl-matrix 中的矩阵乘法运算的实现:https://github.com/toji/gl-matrix
/**
* Multiplies two mat4s
*
* @param {mat4} out the receiving matrix
* @param {mat4} a the first operand
* @param {mat4} b the second operand
* @returns {mat4} out
*/
export function multiply(out, a, b) {
let a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3];
let a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
let a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11];
let a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
// Cache only the current line of the second matrix
let b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3];
out[0] = b0*a00 + b1*a10 + b2*a20 + b3*a30;
out[1] = b0*a01 + b1*a11 + b2*a21 + b3*a31;
out[2] = b0*a02 + b1*a12 + b2*a22 + b3*a32;
out[3] = b0*a03 + b1*a13 + b2*a23 + b3*a33;
b0 = b[4]; b1 = b[5]; b2 = b[6]; b3 = b[7];
out[4] = b0*a00 + b1*a10 + b2*a20 + b3*a30;
out[5] = b0*a01 + b1*a11 + b2*a21 + b3*a31;
out[6] = b0*a02 + b1*a12 + b2*a22 + b3*a32;
out[7] = b0*a03 + b1*a13 + b2*a23 + b3*a33;
b0 = b[8]; b1 = b[9]; b2 = b[10]; b3 = b[11];
out[8] = b0*a00 + b1*a10 + b2*a20 + b3*a30;
out[9] = b0*a01 + b1*a11 + b2*a21 + b3*a31;
out[10] = b0*a02 + b1*a12 + b2*a22 + b3*a32;
out[11] = b0*a03 + b1*a13 + b2*a23 + b3*a33;
b0 = b[12]; b1 = b[13]; b2 = b[14]; b3 = b[15];
out[12] = b0*a00 + b1*a10 + b2*a20 + b3*a30;
out[13] = b0*a01 + b1*a11 + b2*a21 + b3*a31;
out[14] = b0*a02 + b1*a12 + b2*a22 + b3*a32;
out[15] = b0*a03 + b1*a13 + b2*a23 + b3*a33;
return out;
}
对于 webgl 中的着色器程序来说,矩阵和矢量的加减乘除方法已经内置到了 GLSL ES 语言本身。 以下着色器代码中的加减乘除运算默认支持矩阵运算。
const vsSource = `
attribute vec4 aVertexPosition;
attribute vec4 aVertexColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying lowp vec4 vColor;
void main(void) {
gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
vColor = aVertexColor;
}
`
- 平移
const translateMatrix = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
x, y, z, 1
];
- 缩放
const scaleMatrix = [
w, 0, 0, 0,
0, h, 0, 0,
0, 0, d, 0,
0, 0, 0, 1
];
- 旋转
function rotateAroundX(a) {
return [
1, 0, 0, 0,
0, cos(a), sin(a), 0,
0, -sin(a), cos(a), 0,
0, 0, 0, 1
];
}
function rotateAroundY(a) {
return [
cos(a), 0, -sin(a), 0,
0, 1, 0, 0,
sin(a), 0, cos(a), 0,
0, 0, 0, 1
];
}
function rotateAroundZ(a) {
return [
cos(a), sin(a), 0, 0,
-sin(a), cos(a), 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
/**
* Generates a perspective projection matrix with the given bounds.
* Passing null/undefined/no value for far will generate infinite projection matrix.
*
* @param {mat4} out mat4 frustum matrix will be written into
* @param {number} fovy Vertical field of view in radians
* @param {number} aspect Aspect ratio. typically viewport width/height
* @param {number} near Near bound of the frustum
* @param {number} far Far bound of the frustum, can be null or Infinity
* @returns {mat4} out
*/
export function perspective(out, fovy, aspect, near, far) {
let f = 1.0 / Math.tan(fovy / 2), nf;
out[0] = f / aspect;
out[1] = 0;
out[2] = 0;
out[3] = 0;
out[4] = 0;
out[5] = f;
out[6] = 0;
out[7] = 0;
out[8] = 0;
out[9] = 0;
out[11] = -1;
out[12] = 0;
out[13] = 0;
out[15] = 0;
if (far != null && far !== Infinity) {
nf = 1 / (near - far);
out[10] = (far + near) * nf;
out[14] = (2 * far * near) * nf;
} else {
out[10] = -1;
out[14] = -2 * near;
}
return out;
}
注意:这里计算的结果是在上一节的透视投影计算公式中的结果正负值去反。 原因是物体和摄像机的角度是反过来的。 在 webgl 中,默认的 eye point 处于 (0, 0, 0), viewing direction 为z轴负半轴,指向屏幕内部. up direction 默认y轴正方向。
- 小技巧:可以使用css中的 matrix3d 特性
// 从矩阵数组创建matrix3d样式属性
function matrixArrayToCssMatrix(array) {
return "matrix3d(" + array.join(',') + ")";
}
// 获取DOM元素
const ele = document.getElementById('some-ele-id');
// 返回结果如:"matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 100, 0, 1);"
const matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 100, 0, 1];
const matrix3dRule = matrixArrayToCssMatrix( matrix );
// 设置变换
ele.style.transform = matrix3dRule;
物体表面的法线是计算光照颜色的关键信息,当物体的空间位置发生变换时,该表面的法线,也会发生变化。 规则: 新的法线 = 模型矩阵的逆转置矩阵 X 原法线向量。 该公式的证明过程如下:
- 新法线 n’ 和 新的边向量 s‘,它们是垂直的,所以 点乘为 0.
将公式
n' . s' = 0
展开得到:(M' x n) . (M x s) = 0
. - 公式
A . B = At x B
, t为上标,表示转置。 套入上式:(M' x n)t x (M x s) = 0
- 公式:
(A x B)t = Bt x At
, 继续套入上式。nt x (M't x M) x s = 0
, 因为原来的边和法线也是垂直关系,固有n . s = nt x s = 0
. 所以上式中间括号部分的结果相当于单位矩阵,所以 M' 的结果为模型矩阵的逆转置矩阵。
在上一章中,我们使用的对象的方式来表示空间的点,这种方式虽然直观,但是非常低效的,这个只能作为教学方式使用。 在实际应用中,我们需要把坐标和颜色进行分离,分别使用矢量方式来表示坐标和颜色。 创建两个类型化数组(两个连续内存空间), 一个存储坐标信息,一个存储颜色信息。
# 坐标
[ x1, y1, z1, w1, x2, y2, z2, w2, x3, y3, z3, w3, ...]
# 颜色
[ r1, g1, b1, a1, r2, g2, b2, a2, r3, g3, b3, a3, ...]
虽然坐标和颜色分别存储在两个地方,但是它们之间的关系并没有丢失,坐标数组和颜色数组中相同的位置表示的是同一个点。 即便是对空间点进行了变换,它坐标发生了变化,它在数组中的索引仍然不变。
# 第一个点的坐标发生了变化,但是它在 坐标数组和颜色数组中的位置不会变化,(r1,g1,b1,a1)仍然是该点的颜色
# 原始坐标
[ x1, y1, z1, w1, x2, y2, z2, w2, x3, y3, z3, w3, ...]
| | |
v V V
# 变换后的坐标
[ x'1, y'1, z'1, w'1, x'2, y'2, z'2, w'2, x'3, y'3, z'3, w'3, ...]
| | |
V V V
# 颜色
[ r1, g1, b1, a1, r2, g2, b2, a2, r3, g3, b3, a3, ...]
实际上,坐标数组内存空间和颜色数组内存空间,分别对应着 webgl 的顶点着色器程序序中的两段内存空间。 从处理顺序上也可以看出,webgl 应该是先处理坐标数据变换,然后再将坐标数据和颜色数据数据一起再传入片元着色器,开始渲染工作(图形装配 -> 光栅化 -> (深度)颜色缓冲区)。
使用(连续内存空间)矢量分离存储坐标和颜色信息,的确能够很大程度上优化内存空间(但这并不是最重要的)。 最重要的是,它为GPU的并行计算提供了可能,从上面的例子可以看出每个点的计算都是独立的,没有任何彼此依赖。
矩阵本身和是否并行计算没有因果关系,它只是变换关系的浓缩提炼。 实际上,可以使用for循环,对每一个点做矩阵变换。区别是,webgl 中的所谓的逐个点 “for循环” 是GPU并行计算的,这是webgl厉害的地方之一。
- 使用矩阵替代函数来表示变换,能够用少量的数字描述大量的空间中的变换,以及任意变换的组合,并且能轻易地在程序间共享。
- 分离点的坐标和颜色信息,分别使用两段连续内存空间以矢量的方式存储,能够在优化内存的同时,为GPU并行计算创造了条件。
(如果感兴趣的话,建议把 https://github.com/toji/gl-matrix 中涉及到各种变换,自己动手推导一遍。)
使用 canvas 2D api 来模拟 webgl 只能模拟到这里为止了,(只能模拟点阵绘制原理),对于三维线,面的绘制,纹理,光照等特性,已经超出了 canvas 2D 的能力范围。 后面的文章会再深入 webgl 的核心方面。