第十七章:3D 渲染基础
欢迎来到 3D 世界!如果说 2D 渲染是在一张纸上画画,那么 3D 渲染就是在一个房间里搭建舞台。这一章,我们将一步步揭开 3D 渲染的神秘面纱,从最基础的"多一个 Z 轴"开始,直到你能亲手写出一个会旋转、会光照的 3D 圆柱体。
别担心,我会像带你逛博物馆一样,每个概念都先讲一个生活中的故事,再揭开它的数学本质。准备好了吗?Let's go 3D!
目录
- 从 2D 到 3D:添加 Z 轴
- 3D 变换矩阵:绕 X、Y、Z 轴旋转
- 欧拉角与万向节死锁
- 轴角旋转与四元数
- 透视投影:为什么远处的东西看起来更小
- 手动矩阵数学:3D 立方体旋转
- 程序化几何:生成圆柱体的顶点和法线
- 漫反射光照:朗伯余弦定律
- 法向量:它们是什么,为什么重要
- 常见问题 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 |
注意
Rx中sin(θ)的位置!和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. 欧拉角与万向节死锁
生活中的类比:云台相机
想象你有一个专业的三轴云台相机:
- 底座可以左右转(Yaw/偏航)
- 中间的支架可以上下俯仰(Pitch/俯仰)
- 相机本身可以翻滚(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,然后尝试调节 alpha 和 theta。你会发现它们开始影响同一个旋转方向!
常见误区
误区:"万向节死锁会让程序崩溃"
不会崩溃!它只是让你失去一个旋转自由度。在某些角度下,你无法再通过调节三个滑块来实现任意方向的旋转。对于动画来说,这会导致不自然的"跳跃"。
误区:"不用欧拉角就不会遇到万向节死锁"
万向节死锁是顺序旋转的固有属性,不是欧拉角表示法本身的问题。只要你用"先绕 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是屏幕宽高比near和far是近裁剪面和远裁剪面
注意最后一行 (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 渲染中,我们需要告诉计算机:
- 立方体有 8 个顶点
- 每 4 个顶点组成一个面
- 每个面由 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 * vertex 和 model * projection * vertex 是完全不同的。矩阵乘法从右往左应用。
动手试一试
练习 6.1:修改
cube.html,给每个面不同的颜色。观察旋转时是否能清楚分辨每个面。
练习 6.2:尝试改变
fromRotation中的乘法顺序为rz * ry * rx,观察旋转行为的变化。
7. 程序化几何:生成圆柱体的顶点和法线
生活中的类比:陶艺拉坯
想象你在做陶艺:一团泥在转盘上旋转,你用手把泥塑造成圆柱形。从数学角度看,你在沿着一个圆周重复同一个截面形状。
程序化生成圆柱体也是一样的思路:
- 在 XY 平面上画一个圆(底面)
- 把这个圆复制一份,抬高到 Z = h(顶面)
- 把上下两个圆的对应点连起来(侧面)
本质是什么
圆柱体的参数化表示:
- 半径
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)假设光线照射到粗糙表面后,向所有方向均匀散射。表面某一点的亮度只取决于:
- 光源强度
- 光线方向与表面法线的夹角
数学公式:
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) 产生一个垂直于 a 和 b 所在平面的向量,长度等于 |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) 只有在 a 和 b 都是单位向量时才等于夹角的余弦。如果法线长度不是 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 渲染不是魔法,只是大量的相似三角形和矩阵乘法。" —— 某位图形学教授