第十七章:3D 渲染基础

欢迎来到 3D 世界!如果说 2D 渲染是在一张纸上画画,那么 3D 渲染就是在一个房间里搭建舞台。这一章,我们将一步步揭开 3D 渲染的神秘面纱,从最基础的"多一个 Z 轴"开始,直到你能亲手写出一个会旋转、会光照的 3D 圆柱体。

别担心,我会像带你逛博物馆一样,每个概念都先讲一个生活中的故事,再揭开它的数学本质。准备好了吗?Let's go 3D!


目录

  1. 从 2D 到 3D:添加 Z 轴
  2. 3D 变换矩阵:绕 X、Y、Z 轴旋转
  3. 欧拉角与万向节死锁
  4. 轴角旋转与四元数
  5. 透视投影:为什么远处的东西看起来更小
  6. 手动矩阵数学:3D 立方体旋转
  7. 程序化几何:生成圆柱体的顶点和法线
  8. 漫反射光照:朗伯余弦定律
  9. 法向量:它们是什么,为什么重要
  10. 常见问题 Q&A

1. 从 2D 到 3D:添加 Z 轴

生活中的类比:从地图到地球仪

想象你有一张城市的平面图——只有东西和南北,这就是 2D。你可以说"我在中山路 100 号",但无法说"我在 10 楼"。

现在想象你站在一栋大楼里,你不仅需要知道"我在中山路 100 号",还需要知道"我在第 10 层"。这个"第几层"就是 Z 轴——它垂直于地面,指向天空或地底。

在 3D 图形学中,我们使用右手坐标系

  • X 轴:向右(就像 2D 中的 X)
  • Y 轴:向上(就像 2D 中的 Y)
  • Z 轴:指向屏幕外(或者指向屏幕内,取决于约定)

记忆技巧:伸出右手,食指指向 X,中指指向 Y,大拇指就是 Z。这就是"右手坐标系"名字的由来!

本质是什么

从数学角度看,2D 到 3D 的跃迁极其简单:每个点从 (x, y) 变成了 (x, y, z)

但在渲染管线中,这意味着:

维度 顶点数据 变换矩阵 深度处理
2D vec2(x, y) 3x3 矩阵
3D vec3(x, y, z) 4x4 矩阵 深度缓冲(Z-Buffer)

为什么用 4x4 矩阵而不是 3x3?因为 3D 中除了旋转和缩放,还有平移(translation)。3x3 矩阵无法表示平移,但 4x4 齐次坐标矩阵可以。

代码示例

// 2D 顶点:只有 x 和 y
const vertex2D = [0.5, 0.5];

// 3D 顶点:多了 z 坐标
const vertex3D = [0.5, 0.5, 0.5];

// 在 WebGL 中,声明从 attribute vec2 变成了 attribute vec3
const vertexShader = `
  attribute vec3 a_vertexPosition;  // 注意:vec3 而不是 vec2!
  
  void main() {
    gl_Position = vec4(a_vertexPosition, 1.0);
  }
`;

常见误区

误区 1:"Z 轴就是深度"

严格来说,Z 轴只是第三个坐标轴。"深度"是相对于观察者的概念。在观察空间中,Z 确实代表深度;但在世界空间中,Z 可能指向任何方向。

误区 2:"3D 就是 2D 加阴影"

3D 的核心是几何体在三维空间中的表示和变换,阴影只是光照计算的结果之一。没有 3D 几何,就没有真正的 3D 阴影。

动手试一试

练习 1.1:修改下面的代码,创建一个沿着 Z 轴排列的三个点,观察它们在屏幕上的位置变化。

const points = [
  [0, 0, 0],   // 原点
  [0, 0, 0.5], // 靠前
  [0, 0, -0.5] // 靠后
];
// 如果没有透视投影,这三个点在屏幕上会重叠在一起!
// 这是为什么我们需要"透视投影"(第五节会讲)

2. 3D 变换矩阵:绕 X、Y、Z 轴旋转

生活中的类比:转方向盘、翻跟头、拧瓶盖

想象你手里拿着一个手机:

  • 绕 Z 轴旋转:像转方向盘一样,手机平放在桌面上旋转——这是偏航(Yaw)
  • 绕 X 轴旋转:像翻跟头一样,手机前后翻转——这是俯仰(Pitch)
  • 绕 Y 轴旋转:像摇头说"不"一样,手机左右转动——这是翻滚(Roll)

这三个动作,对应着航空领域著名的欧拉角:Yaw-Pitch-Roll。

本质是什么

旋转矩阵的本质是基向量的变换。在 3D 中,标准基向量是:

  • i = (1, 0, 0) —— X 轴方向
  • j = (0, 1, 0) —— Y 轴方向
  • k = (0, 0, 1) —— Z 轴方向

旋转后,这些基向量指向新的方向,而旋转矩阵的每一列就是旋转后的基向量!

推导:绕 Z 轴旋转矩阵

我们先从熟悉的 2D 旋转开始。在 2D 中,点 (x, y) 旋转角度 θ 后变成 (x', y')

x' = x * cos(θ) - y * sin(θ)
y' = x * sin(θ) + y * cos(θ)

写成矩阵形式:

| x' |   | cos(θ)  -sin(θ) |   | x |
| y' | = | sin(θ)   cos(θ) | * | y |

现在扩展到 3D 绕 Z 轴旋转:Z 坐标不变,X 和 Y 按照 2D 旋转:

| x' |   | cos(θ)  -sin(θ)   0 |   | x |
| y' | = | sin(θ)   cos(θ)   0 | * | y |
| z' |   |   0        0      1 |   | z |

在 4x4 齐次坐标中:

Rz(θ) = | cos(θ)  -sin(θ)   0     0 |
        | sin(θ)   cos(θ)   0     0 |
        |   0        0      1     0 |
        |   0        0      0     1 |

推导:绕 X 轴旋转矩阵

绕 X 轴旋转时,X 坐标不变,Y 和 Z 构成一个"YZ 平面"做 2D 旋转:

y' = y * cos(θ) - z * sin(θ)
z' = y * sin(θ) + z * cos(θ)

注意这里的符号!如果你把 Y 当作"x 轴"、Z 当作"y 轴"来看,你会发现和 2D 公式一样。但矩阵写法要小心:

Rx(θ) = | 1     0        0      0 |
        | 0   cos(θ)   sin(θ)   0 |
        | 0  -sin(θ)   cos(θ)   0 |
        | 0     0        0      1 |

注意 Rxsin(θ) 的位置!和 Rz 相比,它出现在 (1,2) 位置是正的,(2,1) 是负的。这是因为 Y 和 Z 的"手性"关系与 X 和 Y 不同。

推导:绕 Y 轴旋转矩阵

绕 Y 轴旋转时,Y 坐标不变,Z 和 X 做 2D 旋转。但注意顺序是 Z→X,不是 X→Z:

z' = z * cos(θ) - x * sin(θ)
x' = z * sin(θ) + x * cos(θ)

重新排列:

x' =  x * cos(θ) + z * sin(θ)
y' =  y
z' = -x * sin(θ) + z * cos(θ)

所以:

Ry(θ) = | cos(θ)   0   sin(θ)   0 |
        |   0      1     0      0 |
        |-sin(θ)   0   cos(θ)   0 |
        |   0      0     0      1 |

代码示例

// 手动构建三个旋转矩阵(来自 akira-graphics/3d-basic/cube.html)

function fromRotation(rotationX, rotationY, rotationZ) {
  // rotationX, rotationY, rotationZ 是弧度值
  
  // 1. 绕 X 轴旋转
  let c = Math.cos(rotationX);
  let s = Math.sin(rotationX);
  const rx = [
    1, 0, 0, 0,
    0, c, s, 0,   // 注意:这里 (1,2)=s, (2,1)=-s
    0, -s, c, 0,
    0, 0, 0, 1,
  ];

  // 2. 绕 Y 轴旋转
  c = Math.cos(rotationY);
  s = Math.sin(rotationY);
  const ry = [
    c, 0, s, 0,   // 注意:(0,2)=s, (2,0)=-s
    0, 1, 0, 0,
    -s, 0, c, 0,
    0, 0, 0, 1,
  ];

  // 3. 绕 Z 轴旋转
  c = Math.cos(rotationZ);
  s = Math.sin(rotationZ);
  const rz = [
    c, s, 0, 0,   // 注意:(0,1)=s, (1,0)=-s
    -s, c, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
  ];

  // 4. 组合:先绕 X,再绕 Y,再绕 Z
  // 矩阵乘法顺序很重要!这里是 rx * ry * rz
  const ret = [];
  multiply(ret, rx, ry);   // ret = rx * ry
  multiply(ret, ret, rz);  // ret = ret * rz = rx * ry * rz

  return ret;
}

矩阵乘法的顺序很重要!

矩阵乘法不满足交换律A * B ≠ B * A

在 3D 变换中,顺序决定了旋转的参考系:

  • Rx * Ry * Rz:先绕自身 Z 转,再绕自身 Y 转,再绕自身 X 转(内禀旋转)
  • 不同的顺序会产生完全不同的结果!

常见误区

误区:"旋转矩阵里的 sin 符号随便放"

不!每个位置的符号都是由坐标系的手性和旋转方向严格决定的。如果你把 Rx 里的 s-s 写反了,物体就会向相反方向旋转。

动手试一试

练习 2.1:只使用 Rx 矩阵,计算点 (1, 0, 0) 绕 X 轴旋转 90 度后的位置。验证你的结果是否合理。

练习 2.2:修改代码,改变矩阵乘法的顺序为 rz * ry * rx,观察立方体的旋转行为有什么不同。


3. 欧拉角与万向节死锁

生活中的类比:云台相机

想象你有一个专业的三轴云台相机

  1. 底座可以左右转(Yaw/偏航)
  2. 中间的支架可以上下俯仰(Pitch/俯仰)
  3. 相机本身可以翻滚(Roll/翻滚)

正常情况下,你可以独立控制这三个轴。但想象一下:当你把相机向上仰到垂直朝天时,底座左右转和相机翻滚变成了同一个动作!这时候你失去了一个自由度——这就是万向节死锁(Gimbal Lock)

本质是什么

欧拉角用三个角度 (α, β, γ) 表示旋转,对应绕三个轴的旋转。但问题是:当第二个角度(俯仰)达到 ±90 度时,第一个轴和第三个轴会对齐,导致两个旋转作用在同一个平面上。

数学上,当 β = 90° 时:

Ry(90°) = | 0  0  1  0 |
          | 0  1  0  0 |
          |-1  0  0  0 |
          | 0  0  0  1 |

此时 Rx * Ry(90°) * Rz 中,X 轴和 Z 轴在 Ry(90°) 的作用下变得共线,导致系统从 3 个自由度退化到 2 个自由度。

代码示例

// 来自 akira-graphics/3d-model/euler-angle.html
// 这个 demo 用 dat.GUI 让你调节三个欧拉角

const gui = new dat.GUI();
const palette = {
  alpha: 0,  // 绕 Y 轴(Yaw)
  beta: 0,   // 绕 X 轴(Pitch)
  theta: 0,  // 绕 Z 轴(Roll)
};

gui.add(palette, 'alpha', -180, 180).onChange((val) => {
  mesh.rotation.y = val / 180 * Math.PI;
  renderer.render({scene, camera});
});
gui.add(palette, 'beta', -180, 180).onChange((val) => {
  mesh.rotation.x = val / 180 * Math.PI;
  renderer.render({scene, camera});
});
gui.add(palette, 'theta', -180, 180).onChange((val) => {
  mesh.rotation.z = val / 180 * Math.PI;
  renderer.render({scene, camera});
});

亲自试试看:把 beta(绕 X 的俯仰)调到 90,然后尝试调节 alphatheta。你会发现它们开始影响同一个旋转方向!

常见误区

误区:"万向节死锁会让程序崩溃"

不会崩溃!它只是让你失去一个旋转自由度。在某些角度下,你无法再通过调节三个滑块来实现任意方向的旋转。对于动画来说,这会导致不自然的"跳跃"。

误区:"不用欧拉角就不会遇到万向节死锁"

万向节死锁是顺序旋转的固有属性,不是欧拉角表示法本身的问题。只要你用"先绕 A 再绕 B 再绕 C"的方式组合旋转,就可能遇到死锁。

动手试一试

练习 3.1:打开 akira-graphics/3d-model/euler-angle.html,把飞机模型仰到 90 度(机头朝天),然后尝试分别调节 Yaw 和 Roll。观察是否有一个自由度"丢失"了。

练习 3.2:思考:为什么航空器(飞机、火箭)通常把 Pitch 限制在小于 90 度的范围?(提示:和导航系统的稳定性有关)


4. 轴角旋转与四元数

生活中的类比:拧螺丝

想象你手里有一个螺丝刀:

  • 轴(Axis):螺丝刀指向的方向——旋转围绕这个方向进行
  • 角(Angle):你转了多少度——旋转的大小

这就是轴角表示法(Axis-Angle):任意一个 3D 旋转,都可以表示为"绕某个轴转某个角度"。

这比欧拉角优雅多了!不需要三个角度,只需要一个轴向量 (x, y, z) 和一个角度 θ

本质是什么

轴角表示法虽然直观,但直接用它做旋转计算比较复杂。于是数学家发明了四元数(Quaternion)

四元数是一个形如 q = w + xi + yj + zk 的数,其中 i² = j² = k² = ijk = -1

对于轴角 (axis, angle),对应的四元数是:

θ = angle / 2
q.w = cos(θ)
q.x = axis.x * sin(θ)
q.y = axis.y * sin(θ)
q.z = axis.z * sin(θ)

为什么除以 2?因为四元数旋转的公式里要用 q * v * q⁻¹,这个操作会让角度"翻倍"。

四元数的优势

特性 欧拉角 四元数
万向节死锁
插值(动画) 困难、不自然 平滑(Slerp)
存储空间 3 个数 4 个数
计算效率 需要三角函数 乘法为主,更快
直观性 很直观 不太直观

代码示例

// 来自 akira-graphics/3d-model/axis-angle.html

function updateQuaternion(val) {
  // val 是角度(度)
  const theta = 0.5 * val / 180 * Math.PI;  // 转弧度,再取半角
  const c = Math.cos(theta);
  const s = Math.sin(theta);
  
  // 获取旋转轴(用户通过 GUI 调节的向量)
  const p = new Vec3().copy(points[1]).normalize();
  
  // 构建四元数:q = (x*sin(θ/2), y*sin(θ/2), z*sin(θ/2), cos(θ/2))
  const q = new Quat(p.x * s, p.y * s, p.z * s, c);
  
  // OGL 库会自动用四元数旋转模型
  mesh.quaternion = q;
  renderer.render({scene, camera});
}

// GUI 设置:调节旋转轴的方向
gui.add(palette, 'x', -10, 10).onChange(updateAxis);
gui.add(palette, 'y', -10, 10).onChange(updateAxis);
gui.add(palette, 'z', -10, 10).onChange(updateAxis);
gui.add(palette, 'alpha', -180, 180).onChange(updateQuaternion);

四元数旋转的数学原理

给定四元数 q 和向量 v(表示为纯虚四元数 0 + v.x*i + v.y*j + v.z*k),旋转后的向量是:

v' = q * v * q⁻¹

其中 q⁻¹q 的逆(共轭除以模长平方)。这个公式神奇地保持了向量的长度,同时把它旋转到了新的方向。

常见误区

误区:"四元数就是四维向量"

虽然四元数有 4 个分量,但它不是普通的 4D 向量!它有特殊的乘法规则,而且 w 分量代表实部,x, y, z 代表虚部。

误区:"四元数旋转和矩阵旋转结果不一样"

对于同一个旋转,四元数和 3x3 旋转矩阵是完全等价的。四元数可以转换为旋转矩阵:

// 四元数 (x, y, z, w) 转 3x3 矩阵
out[0] = 1 - 2*y*y - 2*z*z;
out[1] = 2*x*y + 2*w*z;
out[2] = 2*x*z - 2*w*y;
out[3] = 2*x*y - 2*w*z;
out[4] = 1 - 2*x*x - 2*z*z;
out[5] = 2*y*z + 2*w*x;
out[6] = 2*x*z + 2*w*y;
out[7] = 2*y*z - 2*w*x;
out[8] = 1 - 2*x*x - 2*y*y;

动手试一试

练习 4.1:打开 akira-graphics/3d-model/axis-angle.html,把旋转轴设为 (0, 1, 0)(Y 轴),然后调节角度。观察飞机是否像欧拉角的 Yaw 一样旋转。

练习 4.2:把旋转轴设为 (1, 1, 1)(对角线方向),调节角度。这是欧拉角很难做到的"绕斜轴旋转"!


5. 透视投影:为什么远处的东西看起来更小

生活中的类比:铁轨消失在远方

站在铁轨中间往远处看,两条平行的铁轨似乎在远方汇聚成一点。近处的枕木看起来很长,远处的枕木看起来很短——但它们其实一样长!

这就是透视(Perspective):物体离眼睛越远,在视网膜上成的像越小。

本质是什么

在 3D 渲染中,我们需要把 3D 世界"拍扁"到 2D 屏幕上。最简单的拍法是正交投影(Orthographic)——不管远近,大小不变。但正交投影看起来不真实,像工程图纸。

透视投影模拟了人眼和相机的成像原理:

屏幕上的 y = (眼睛到屏幕的距离 / 物体到眼睛的距离) * 物体的实际 y

设眼睛在原点,屏幕在 z = d 处,物体在 (x, y, z)

x_screen = x * (d / z)
y_screen = y * (d / z)

这就是相似三角形原理!

透视投影矩阵

在齐次坐标中,透视投影可以用一个 4x4 矩阵表示。一个简化的透视投影矩阵:

| f/aspect   0      0           0      |
|    0       f      0           0      |
|    0       0   (far+near)/(near-far)  2*far*near/(near-far) |
|    0       0     -1           0      |

其中:

  • f = 1 / tan(fov / 2)fov 是视野角度
  • aspect 是屏幕宽高比
  • nearfar 是近裁剪面和远裁剪面

注意最后一行 (0, 0, -1, 0):它会把 w 分量变成 -z。在齐次坐标除法(Perspective Divide)中,x, y, z 都会除以 w,这就实现了"除以 z"的透视效果!

代码示例

// 来自 akira-graphics/3d-basic/cube.html 中的简化投影
// 这是一个正交投影(没有透视效果)
renderer.uniforms.projectionMatrix = [
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, -1, 0,  // 只是翻转 Z 轴
  0, 0, 0, 1,
];

// OGL 库中的透视投影(来自 axis-angle.html)
const camera = new Camera(gl, {fov: 35});
camera.position.set(0, 0, 10);  // 相机在 z=10 处
camera.lookAt([0, 0, 0]);       // 看向原点

常见误区

误区:"透视投影只是简单地把坐标除以 z"

虽然核心思想是除以 z,但实际的投影矩阵还要处理:

  • 视野角度(FOV)
  • 宽高比(Aspect Ratio)
  • 近远裁剪面(Near/Far Clipping Planes)
  • Z 值的深度缓冲映射(把 z 映射到 [0, 1] 或 [-1, 1])

误区:"透视投影会让平行线相交"

在 3D 空间中,平行线永远不会相交。但在 2D 投影中,它们的投影线会相交于消失点(Vanishing Point)。这是投影的效果,不是 3D 空间本身的性质。

动手试一试

练习 5.1:在 cube.html 中,把立方体的 Z 坐标从 0 改为 2 和 -2,观察它在屏幕上的大小变化。如果没有透视投影,大小会变吗?

练习 5.2:用 OGL 的 Camera 类,尝试不同的 fov 值(比如 20 度和 90 度)。观察视野角度如何影响"近大远小"的效果。


6. 手动矩阵数学:3D 立方体旋转

生活中的类比:折纸盒子

想象你有一张平面的十字形纸板,折起来就是一个立方体。在 3D 渲染中,我们需要告诉计算机:

  1. 立方体有 8 个顶点
  2. 每 4 个顶点组成一个面
  3. 每个面由 2 个三角形组成(因为 GPU 只画三角形)

本质是什么

3D 立方体的渲染流程:

顶点数据 → 模型矩阵(旋转/平移/缩放) → 视图矩阵(相机位置) → 投影矩阵 → 屏幕坐标

cube.html 中,我们简化了流程,只用模型矩阵投影矩阵

立方体的顶点布局

        y
        |
        1--------2
       /|       /|
      / |      / |
     5--------6  |
     |  0-----|--3 -- x
     | /      | /
     |/       |/
     4--------7
    /
   z

8 个顶点的坐标(半边长为 h):

const vertices = [
  [-h, -h, -h],  // 0: 左下后
  [-h,  h, -h],  // 1: 左上后
  [ h,  h, -h],  // 2: 右上前
  [ h, -h, -h],  // 3: 右下后
  [-h, -h,  h],  // 4: 左下前
  [-h,  h,  h],  // 5: 左上前
  [ h,  h,  h],  // 6: 右上前
  [ h, -h,  h],  // 7: 右下前
];

面的构建

每个面由 4 个顶点组成,但 GPU 只认三角形,所以每个面拆成 2 个三角形:

function quad(a, b, c, d) {
  // a, b, c, d 是顶点索引,按顺序构成一个四边形
  [a, b, c, d].forEach((i) => {
    positions.push(vertices[i]);
    color.push(colors[colorIdx % colorLen]);
  });
  // 三角形 1: a-b-c
  // 三角形 2: a-c-d
  cells.push([0, 1, 2].map(i => i + cellsIdx));
  cells.push([0, 2, 3].map(i => i + cellsIdx));
  colorIdx++;
  cellsIdx += 4;
}

// 6 个面
quad(1, 0, 3, 2);  // 后面(z = -h)
quad(4, 5, 6, 7);  // 前面(z = h)
quad(2, 3, 7, 6);  // 右面(x = h)
quad(5, 4, 0, 1);  // 左面(x = -h)
quad(3, 0, 4, 7);  // 底面(y = -h)
quad(6, 5, 1, 2);  // 顶面(y = h)

代码示例

// 完整渲染循环(来自 cube.html)

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

function update() {
  rotationX += 0.003;  // 每帧绕 X 转一点
  rotationY += 0.005;  // 每帧绕 Y 转一点
  rotationZ += 0.007;  // 每帧绕 Z 转一点
  
  // 计算组合旋转矩阵
  renderer.uniforms.modelMatrix = fromRotation(rotationX, rotationY, rotationZ);
  
  requestAnimationFrame(update);
}
update();

renderer.render();

顶点着色器中的变换

uniform mat4 projectionMatrix;
uniform mat4 modelMatrix;
attribute vec3 a_vertexPosition;

void main() {
  // 注意乘法顺序:投影 * 模型 * 顶点
  // 从右往左读:先应用模型变换,再应用投影变换
  gl_Position = projectionMatrix * modelMatrix * vec4(a_vertexPosition, 1.0);
}

常见误区

误区:"立方体有 6 个面,所以只需要 6 * 3 = 18 个顶点"

不对!每个顶点属于 3 个面,但不同面的顶点颜色/法线/纹理坐标可能不同。在这个 demo 中,我们用了24 个顶点(每个面 4 个,不共享),这样每个面可以有独立的颜色。

误区:"矩阵乘法的顺序无所谓"

非常重要!projection * model * vertexmodel * projection * vertex 是完全不同的。矩阵乘法从右往左应用。

动手试一试

练习 6.1:修改 cube.html,给每个面不同的颜色。观察旋转时是否能清楚分辨每个面。

练习 6.2:尝试改变 fromRotation 中的乘法顺序为 rz * ry * rx,观察旋转行为的变化。


7. 程序化几何:生成圆柱体的顶点和法线

生活中的类比:陶艺拉坯

想象你在做陶艺:一团泥在转盘上旋转,你用手把泥塑造成圆柱形。从数学角度看,你在沿着一个圆周重复同一个截面形状。

程序化生成圆柱体也是一样的思路:

  1. 在 XY 平面上画一个圆(底面)
  2. 把这个圆复制一份,抬高到 Z = h(顶面)
  3. 把上下两个圆的对应点连起来(侧面)

本质是什么

圆柱体的参数化表示:

  • 半径 r
  • 高度 h
  • 分段数 segments(圆被分成多少段,越多越圆滑)

圆上第 i 个点的角度是 θ = 2π * i / segments,坐标是:

x = r * cos(θ)
y = r * sin(θ)

顶面和底面(圆盘)

每个圆盘是一个扇形三角网格

  • 中心点 (0, 0, ±h/2)
  • 圆周上的点 (r*cos(θ), r*sin(θ), ±h/2)
  • 三角形:中心点 → 第 i 个点 → 第 i+1 个点

侧面(矩形条带)

侧面由一系列矩形组成,每个矩形分成 2 个三角形:

  顶面第 i 个点 (x, y, +h/2)  ──→  顶面第 i+1 个点
           ↑                          ↑
           │                          │
  底面第 i 个点 (x, y, -h/2)  ──→  底面第 i+1 个点

代码示例

// 来自 akira-graphics/3d-basic/cylinder.html

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 normal = [];
  const cap = [[0, 0]];  // 圆心
  const h = 0.5 * height;

  // 1. 生成圆周上的点(在 XY 平面上)
  for (let i = 0; i <= segments; i++) {
    const theta = Math.PI * 2 * i / segments;
    const p = [radius * Math.cos(theta), radius * Math.sin(theta)];
    cap.push(p);
  }

  // 2. 底面圆盘(z = -h,法线朝下)
  positions.push(...cap.map(([x, y]) => [x, y, -h]));
  normal.push(...cap.map(() => [0, 0, -1]));
  for (let i = 1; i < cap.length - 1; i++) {
    cells.push([0, i, i + 1]);  // 中心点 → 第 i 点 → 第 i+1 点
  }

  // 3. 顶面圆盘(z = h,法线朝上)
  let offset = positions.length;
  positions.push(...cap.map(([x, y]) => [x, y, h]));
  normal.push(...cap.map(() => [0, 0, 1]));
  for (let i = 1; i < cap.length - 1; i++) {
    cells.push([offset, offset + i, offset + i + 1]);
  }

  color.push(...positions.map(() => colorCap));

  // 4. 侧面
  offset = positions.length;
  for (let i = 1; i < cap.length; i++) {
    const a = [...cap[i], h];           // 顶面当前点
    const b = [...cap[i], -h];          // 底面当前点
    const nextIdx = i < 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);  // 同一个矩形的 4 个顶点法线相同

    color.push(colorSide, colorSide, colorSide, colorSide);
    cells.push([offset, offset + 1, offset + 2],      // 三角形 1
               [offset, offset + 2, offset + 3]);     // 三角形 2
    offset += 4;
  }

  return {positions, cells, color, normal};
}

常见误区

误区:"圆柱体的侧面法线就是 (x, y, 0)"

对于直立圆柱体,侧面法线确实是水平向外的 (x, y, 0)。但如果圆柱体被旋转或变形,法线必须通过叉积计算。上面的代码用 cross(b-a, c-a) 计算法线,这种方法对任何朝向的圆柱体都适用。

误区:"分段数越多越好"

分段数越多,圆柱体越圆滑,但顶点数也越多(性能开销越大)。对于远处的物体,可以用较少的分段;只有近处的物体才需要高细分。

动手试一试

练习 7.1:修改 cylinder 函数,把 segments 从 30 改为 6。你得到了什么形状?(提示:六棱柱)

练习 7.2:尝试生成一个圆锥体。提示:顶面圆盘的半径设为 0。


8. 漫反射光照:朗伯余弦定律

生活中的类比:手电筒照墙

想象你用手电筒垂直照墙:墙上有一个很亮的圆斑。现在你把角度倾斜,同样的光被分散到更大的面积上,所以每一点看起来都更暗。

这就是朗伯余弦定律(Lambert's Cosine Law):表面接收到的光照强度,与光线入射方向和表面法线夹角的余弦成正比。

本质是什么

漫反射(Diffuse Reflection)假设光线照射到粗糙表面后,向所有方向均匀散射。表面某一点的亮度只取决于:

  1. 光源强度
  2. 光线方向与表面法线的夹角

数学公式:

brightness = max(dot(lightDirection, normal), 0)

其中 dot(a, b) 是两个单位向量的点积,等于它们夹角的余弦。

  • 当光线垂直照射(夹角 0°):cos(0) = 1,最亮
  • 当光线斜 45° 照射:cos(45°) ≈ 0.707,亮度 70%
  • 当光线平行于表面(夹角 90°):cos(90°) = 0,不亮
  • 当光线从背面来(夹角 > 90°):cos > 90° < 0,取 max(0, ...) 后变为 0

代码示例

// 来自 akira-graphics/3d-basic/cylinder.html 的顶点着色器

const vertex = `
  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);  // 光源在 x 轴方向

  void main() {
    gl_PointSize = 1.0;
    vColor = color;

    // 1. 把顶点变换到世界空间
    vec4 pos = modelMatrix * vec4(a_vertexPosition, 1.0);

    // 2. 计算从顶点指向光源的方向
    vec3 invLight = lightPosition - pos.xyz;

    // 3. 变换法线到世界空间(必须用 normalMatrix!)
    vec3 norm = normalize(normalMatrix * normal);

    // 4. 计算朗伯余弦:点积后取 max(0, ...)
    vCos = max(dot(normalize(invLight), norm), 0.0);

    // 5. 投影到屏幕
    gl_Position = projectionMatrix * pos;
  }
`;

const fragment = `
  precision highp float;

  uniform vec4 lightColor;  // 光源颜色(RGB)和强度(Alpha)
  varying vec4 vColor;      // 顶点颜色
  varying float vCos;       // 光照强度

  void main() {
    // 最终颜色 = 基础颜色 + 光照贡献
    // 光照贡献 = 余弦值 * 光源强度 * 光源颜色
    gl_FragColor.rgb = vColor.rgb + vCos * lightColor.a * lightColor.rgb;
    gl_FragColor.a = vColor.a;
  }
`;

常见误区

误区:"直接用模型矩阵乘法线就行"

不行!如果模型矩阵包含非均匀缩放(比如把物体压扁),直接用模型矩阵乘法线会得到错误的结果。法线需要使用逆转置矩阵(Inverse-Transpose)

// 正确做法:提取 3x3 部分,求逆,再转置
// 在代码中,normalFromMat4 函数做了这件事
renderer.uniforms.normalMatrix = normalFromMat4([], modelMatrix);

为什么?因为法线描述的是"垂直于表面的方向",而缩放会改变"垂直"的含义。逆转置矩阵修正了这个问题。

误区:"漫反射光照和观察者位置无关"

对!这是漫反射的重要特性。在理想的朗伯表面上,无论从哪个角度看,亮度都一样(只要光源方向不变)。这就是为什么月亮、粉笔、未上漆的木头看起来"不反光"。

动手试一试

练习 8.1:修改 lightPosition(0, 1, 0)(从上方照射),观察圆柱体的明暗变化。

练习 8.2:把 lightColor 的 alpha(强度)从 0.8 改为 0.2 和 1.5,观察光照强度的影响。


9. 法向量:它们是什么,为什么重要

生活中的类比:指南针和斜坡

想象你站在一个山坡上。法向量就像你脚下的指南针箭头,它垂直于地面,指向天空。如果这个箭头直指太阳,你被晒得最厉害;如果箭头水平,说明你在悬崖边;如果箭头指地,你在一个倒悬的岩壁下。

在 3D 渲染中,法向量告诉计算机:"这个表面朝向哪个方向?"

本质是什么

**法向量(Normal Vector)**是一个长度为 1 的单位向量,垂直于表面。

对于平面(如三角形),法向量可以通过叉积计算:

给定三角形的三个顶点 A, B, C:
边 1 = B - A
边 2 = C - A
法线 = normalize(cross(边 1, 边 2))

叉积的几何意义:cross(a, b) 产生一个垂直于 ab 所在平面的向量,长度等于 |a||b|sin(θ)

法向量的用途

用途 说明
光照计算 决定表面接收多少光(朗伯定律)
背面剔除 判断三角形是正面还是背面
环境映射 用反射方向采样环境贴图
凹凸映射 通过扰动法线模拟表面细节

代码示例

// 来自 akira-graphics/3d-basic/cylinder.html

import {cross, subtract, normalize} from '../common/lib/math/functions/Vec3Func.js';

// 计算侧面法线
const tmp1 = [];
const tmp2 = [];

for (let i = 1; i < cap.length; i++) {
  const a = [...cap[i], h];
  const b = [...cap[i], -h];
  const nextIdx = i < cap.length - 1 ? i + 1 : 1;
  const c = [...cap[nextIdx], -h];

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

  // 计算法线:cross(边1, 边2),然后归一化
  const norm = [];
  cross(norm, subtract(tmp1, b, a), subtract(tmp2, c, a));
  normalize(norm, norm);

  // 同一个四边形的 4 个顶点共享同一个法线
  normal.push(norm, norm, norm, norm);
}

法线变换的数学

当模型旋转时,法线也要跟着转。但如果模型被非均匀缩放了,直接用模型矩阵乘法线会出错。

举个例子:把一个球压成椭球(Y 方向缩放 0.5)。原来的法线 (0, 1, 0)(朝上的极点),如果直接乘缩放矩阵,变成 (0, 0.5, 0),再归一化还是 (0, 1, 0)——但压扁后的椭球,极点法线应该更"平"一些才对!

解决方案:法线矩阵 = 模型矩阵的 3x3 部分的逆的转置

// normalFromMat4 的实现(简化版)
export function normalFromMat4(out, a) {
  // 1. 提取 3x3 部分
  // 2. 计算行列式 det
  // 3. 计算伴随矩阵(余子式矩阵的转置)
  // 4. 除以 det
  // 结果 = (M₃ₓ₃⁻¹)ᵀ
}

常见误区

误区:"法线就是顶点指向原点的方向"

只有在球体上才成立!对于立方体,顶点 (1, 1, 1) 的法线不是 (1, 1, 1),而是取决于它属于哪个面。立方体顶点的法线通常取相邻面法线的平均值(平滑着色),或者就是面法线(平面着色)。

误区:"法线不需要归一化"

在光照计算中,点积 dot(a, b) 只有在 ab 都是单位向量时才等于夹角的余弦。如果法线长度不是 1,光照计算会出错。所以一定要在变换后 normalize()

动手试一试

练习 9.1:在 cylinder.html 中,故意把法线矩阵去掉(直接用法线而不乘 normalMatrix),然后给模型加一个非均匀缩放(比如 Y 方向压扁)。观察光照有什么异常。

练习 9.2:计算一个三角形的法线:顶点为 A(0, 0, 0)B(1, 0, 0)C(0, 1, 0)。用手算验证叉积结果。


10. 常见问题 Q&A

Q1:为什么 GPU 只画三角形,不画四边形或圆形?

A:三角形有几个独特的优势:

  • 必然共面:三个点一定在一个平面上,而四个点可能不在(扭曲的四边形)
  • 最简单的多边形:任何多边形都可以拆成三角形
  • 插值简单:三角形内部的属性(颜色、法线、纹理坐标)可以用重心坐标线性插值

所以 GPU 的硬件是为三角形优化的。四边形、圆形最终都被转换成三角形。

Q2:4x4 矩阵的最后一列/行到底有什么用?

A:在齐次坐标中,点表示为 (x, y, z, w),其中 w = 1。4x4 矩阵的最后一列 (m03, m13, m23, m33) 通常用于投影(把 w 变成非 1 的值),最后一行 (m30, m31, m32, m33) 用于平移。

标准的变换矩阵最后一行是 (0, 0, 0, 1),但透视投影矩阵的最后一行是 (0, 0, -1, 0),这就是它能把 3D 点"拍扁"到 2D 的秘诀。

Q3:欧拉角、轴角、四元数、矩阵,我到底该用哪个?

A

场景 推荐表示
用户输入(如 GUI 滑块) 欧拉角(最直观)
动画插值 四元数(最平滑,无万向节死锁)
物理模拟 四元数或矩阵
最终提交给 GPU 4x4 矩阵(GPU 硬件优化)
存储/网络传输 四元数(4 个数 vs 矩阵的 16 个)

实际项目中,通常内部用四元数,需要时转换成矩阵传给 GPU。

Q4:为什么我的 3D 模型看起来"里面翻出来了"?

A:可能是**背面剔除(Back-face Culling)的问题。每个三角形有"正面"和"背面",由顶点的环绕顺序(Winding Order)**决定:

  • 逆时针(CCW)排列的顶点是正面
  • 顺时针(CW)排列的是背面

如果你的顶点顺序搞反了,或者法线方向反了,模型就会看起来"透明"或"翻转"。

Q5:深度缓冲(Z-Buffer)是什么?为什么需要它?

A:当多个三角形重叠时,GPU 需要知道哪个在前面、哪个在后面。深度缓冲就是一张和屏幕一样大的图,每个像素记录当前已绘制物体的"深度值"。新像素只有比已有像素更近时,才会被绘制。

cube.html 中,depth: true 开启了深度测试:

const renderer = new GlRenderer(canvas, {
  depth: true,  // 开启深度缓冲
});

没有深度缓冲,后画的物体会覆盖先画的,不管谁近谁远!

Q6:为什么透视投影矩阵里有个 -1

A:在 OpenGL/WebGL 的裁剪空间中,Z 轴指向屏幕内,但相机看向 -Z 方向。透视投影矩阵的 (2, 3) 位置(0-indexed 是索引 14)通常是 -1,这样在齐次除法后,近处的物体 Z 值小,远处的 Z 值大,符合深度缓冲的直觉。

Q7:我能不能不用矩阵,直接用三角函数旋转顶点?

A:可以!对于单个点,直接算:

x' = x * cos(θ) - y * sin(θ);
y' = x * sin(θ) + y * cos(θ);

但如果要组合多个旋转、缩放、平移,矩阵的优势就体现出来了:所有变换先乘成一个矩阵,然后每个顶点只乘一次。对于成千上万个顶点,这节省了大量计算。


总结

恭喜你读完了这一章!让我们回顾一下 3D 渲染的核心脉络:

3D 顶点 (x, y, z)
    ↓
模型变换(旋转/平移/缩放)—— 四元数/欧拉角/矩阵
    ↓
视图变换(相机位置和朝向)
    ↓
投影变换(透视/正交)—— 相似三角形原理
    ↓
裁剪和屏幕映射
    ↓
光栅化(三角形填充像素)
    ↓
片段着色(光照/纹理)—— 法线 × 朗伯定律
    ↓
2D 像素输出

每个环节你都已经在本章中亲手触碰过了。从旋转的立方体到带光照的圆柱体,你写的不再是"平面上的图案",而是"空间中的物体"。

3D 渲染的世界很大,但这一章给你打下了最坚实的地基。下一步?去试试加载一个真正的 3D 模型文件(如 OBJ 或 glTF),或者给圆柱体加上纹理贴图。你已经准备好了!


"3D 渲染不是魔法,只是大量的相似三角形和矩阵乘法。" —— 某位图形学教授