第 3 周:图形学基础概念
📅 学习计划
本周目标: 理解渲染管线、顶点/片段的区别、深度测试和混合模式。
时间安排: 每天 1-2 小时,共 5 天
第 1 天:渲染管线 (Pipeline)
📖 理论:什么是渲染管线?
渲染管线就是一条流水线,把 3D 数据(顶点)变成屏幕上的 2D 像素。
简化流程:
- 顶点处理 (Vertex Processing):处理每个点的坐标(3D -> 2D)。
- 光栅化 (Rasterization):把连成的三角形“打碎”成像素点。
- 片段处理 (Fragment Processing):计算每个像素点的颜色。
- 输出合并 (Output Merging):深度测试、混合(透明度处理),最终写入屏幕。
🔑 核心概念:缓冲区 (Buffer) 到底是什么?
你可以把 Buffer 理解为:GPU 侧的一块连续内存,专门用来存“渲染要用的数据”。
WebGL 的渲染其实是:绑定一堆 Buffer + 设定状态 + 跑一遍 Shader(一次 Draw Call)。
为什么 Buffer 很关键?
- GPU 跑得快,但它不能直接读 JS 数组:顶点数据在 CPU/JS 内存里时,GPU 用不了。要画,就得把数据上传到 GPU。Buffer 就是上传后的存放处。
- 数据是“批量”用的:一次 draw 往往要访问成千上万个顶点/片元,Buffer 提供连续布局,GPU 能高吞吐顺序读取。
- 职责清晰:JS 端负责“准备数据”,GPU 端负责“并行计算 + 写入输出缓冲”。
常见 Buffer(先有个全局地图,后面会反复遇到):
顶点缓冲(VBO, Vertex Buffer Object)
- 存什么:
position / normal / uv / color / 自定义 attribute等“每个顶点一份”的数据。 - WebGL 里常见绑定点:
gl.ARRAY_BUFFER。 - 结果:Vertex Shader 能从 attribute 里读到这些数据。
- 存什么:
索引缓冲(IBO/EBO, Index Buffer Object)
- 存什么:三角形是由哪些顶点编号组成的(比如
[0,1,2, 2,3,0])。 - WebGL 里常见绑定点:
gl.ELEMENT_ARRAY_BUFFER。 - 作用:复用顶点,减少重复顶点数据,提高效率。
- 存什么:三角形是由哪些顶点编号组成的(比如
帧缓冲(Framebuffer, FBO)及其附着缓冲
- 你看到的“屏幕”本质上也是一个输出目标(默认帧缓冲)。
- 一般会包含(或附着):
- 颜色缓冲(Color Buffer):最终颜色写到这里(显示到屏幕或渲染到纹理)。
- 深度缓冲(Depth Buffer / Z-Buffer):你在第 3 天学的深度测试用的“记事本”。
- 模板缓冲(Stencil Buffer):做遮罩/轮廓/镜面等效果常用。
纹理(Texture)也可以当“数据缓冲”理解
- 虽然名字叫纹理,但它本质是 GPU 上可采样的存储(2D/3D 的数据表)。
- 不仅能存图片,也能存法线、深度、查找表(LUT)甚至通用数据(GPGPU 思路)。
记忆法(面试/实战都好用):
- 输入数据:VBO/IBO(顶点怎么来、怎么组装)
- 输出落点:Color/Depth/Stencil(画到哪里、怎么挡、怎么混)
- 中间传递:Shader 的 attribute/uniform/varying(数据怎么进来、怎么算、怎么算到每个像素)
💻 实践:项目中的管线
查看项目代码: line_shader/index.ts
这个文件定义了一个完整的 ShaderMaterial,包含了管线的两个可编程阶段:
// 1. Vertex Shader (顶点处理)
vertexShader: `
void main() {
// 算出点在哪里
gl_Position = ...
}
`,
// 2. Fragment Shader (片段处理)
fragmentShader: `
void main() {
// 算出颜色是什么
gl_FragColor = ...
}
`
🎯 练习 3.1:脑补管线
想象你要画一个三角形:
- Input: 3 个点 A, B, C。
- Vertex Shader: 算出 A, B, C 在屏幕上的位置。
- Rasterizer: 找出 A, B, C 围成的区域里有哪些像素。
- Fragment Shader: 对每个像素,算出它是红色的。
- Output: 屏幕上出现红色三角形。
📝 今日任务
- 记住管线的主要阶段
- 明白 Vertex Shader 和 Fragment Shader 在管线中的位置
第 2 天:顶点与片段
📖 理论:顶点 vs 片段
| 特性 | 顶点 (Vertex) | 片段 (Fragment) |
|---|---|---|
| 处理对象 | 3D 空间中的点 | 屏幕上的像素候选 |
| 数量 | 相对较少(取决于模型面数) | 非常多(取决于分辨率和物体大小) |
| 主要任务 | 坐标变换 | 颜色计算、光照 |
| 输出 | gl_Position | gl_FragColor |
插值 (Interpolation):
从顶点到片段,数据会自动“渐变”。
比如顶点 A 是红色,顶点 B 是蓝色,中间的片段会自动变成紫色。这叫 varying 变量的插值。
💻 实践:Three.js 中的 Shader
const material = new THREE.ShaderMaterial({
vertexShader: `
varying vec3 vColor; // 传给片段着色器
void main() {
vColor = position; // 把位置当颜色传过去
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec3 vColor; // 接收顶点着色器的值(已插值)
void main() {
gl_FragColor = vec4(vColor, 1.0); // 显示插值后的颜色
}
`,
});
🎯 练习 3.2:修改项目 Shader
在 line_shader/index.ts 中:
// 片段着色器
fragmentShader: `
uniform vec3 diffuse;
uniform float opacity;
void main() {
// 尝试把这里改成固定颜色,比如红色
// gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
gl_FragColor = vec4(diffuse.xyz, opacity);
}
`
修改后,所有的线都会变成红色,无论原来设置什么颜色。这能帮你理解片段着色器控制最终颜色。
📝 今日任务
- 理解顶点和片段的一对多关系
- 理解“插值”的概念
第 3 天:深度测试与混合
📖 理论:深度测试 (Depth Test)
1. 核心问题: 当我们在屏幕上画两个重叠的物体时,怎么保证近的物体遮挡住远的物体?
2. 解决方案:深度缓冲区 (Z-Buffer) GPU 并不像画家那样“先画远的再画近的”(画家算法),而是给屏幕上的每个像素都配了一个“记事本”,用来记录当前这个像素点上,离相机最近的物体有多远。
3. 工作流程(小白版):
想象屏幕是一个由无数个小格子组成的网格(像素)。
- 初始化:每个格子的“记事本”都写上“无穷远”。
- 画第一个物体(比如远处的山):
- 算出山在某个格子上的距离(比如 100 米)。
- 对比:100 米 < 无穷远。
- 通过! 画上山的颜色,并把“记事本”改成 100 米。
- 画第二个物体(比如近处的树):
- 算出树在同一个格子上的距离(比如 10 米)。
- 对比:10 米 < 100 米。
- 通过! 覆盖掉山的颜色,画上树的颜色,把“记事本”改成 10 米。
- 画第三个物体(比如被树挡住的石头):
- 算出石头在同一个格子上的距离(比如 20 米)。
- 对比:20 米 > 10 米。
- 失败! 即使石头应该存在,但因为比记录的 10 米 远,所以直接扔掉,不画也不改记录。
4. 代码怎么写:
// 1. 开启深度测试(启用这个功能)
gl.enable(gl.DEPTH_TEST);
// 2. 每次画新的一帧前,把“记事本”擦干净(重置为无穷远)
gl.clear(gl.DEPTH_BUFFER_BIT);
📖 理论:混合 (Blending)
1. 核心问题: 如果有透明的物体(比如玻璃、水),不能直接覆盖掉后面的物体,而是要透出后面的颜色,这该怎么办?
2. 解决方案:混合公式 GPU 会把新画上去的颜色(Source)和屏幕上原本已经画好的颜色(Destination)按比例混合。
公式(简化版): >
最终颜色 = (新颜色 * 透明度) + (旧颜色 * (1 - 透明度))
3. 举个栗子: 假设背景是红色,你要画一个 50% 透明的蓝色玻璃。
- 旧颜色:红色 (1, 0, 0)
- 新颜色:蓝色 (0, 0, 1)
- 计算:
- 蓝色部分:0 * 0.5 = 0
- 红色部分:1 * 0.5 = 0.5
- 结果:(0.5, 0, 0.5) -> 紫色
⚠️ 重要坑点:绘制顺序 因为混合是基于“屏幕上已有的颜色”计算的,所以必须先画不透明物体,再画透明物体。 如果有多个透明物体,必须从远到近画。
- 错误顺序:先画近处的玻璃(此时背景还没画),再画远处的背景。结果:玻璃后面是空的,背景直接盖住了玻璃。
- 正确顺序:先画背景,再画玻璃。结果:玻璃颜色 + 背景颜色 = 正确的透明效果。
❓ 疑问:谁来负责这个排序?WebGL 吗?
- WebGL:不会自动排序。它只是一个底层 API(像一个听话的画笔),你让它先画谁它就画谁。如果你直接写原生 WebGL,你必须自己在 CPU 端写代码算出每个物体的距离,排好序,然后再依次调用绘制命令。
- Three.js:会自动帮你排序。
- Three.js 引擎内部维护了两个列表:
opaque(不透明)和transparent(透明)。 - 当你设置
material.transparent = true时,Three.js 会把这个物体放入透明列表。 - 在每一帧渲染前,Three.js 会自动计算这些透明物体离相机的距离,从远到近排序,然后再指挥 WebGL 绘制。
- Three.js 引擎内部维护了两个列表:
💻 实践:项目中的 Z-Fighting 问题
1. 现象: 在项目中,你可能会看到两条线如果重叠在一起,会疯狂闪烁(一会儿你上,一会儿我上)。
2. 原因 (Z-Fighting): 当两个物体的深度非常非常接近(或者完全一样)时,GPU 的精度不够用了,它分不清谁前谁后,就会随机乱画。
3. 解决方法(LineRender.ts 中的代码):
// 这里的 i * 0.0005 就是为了解决 Z-Fighting
// 强行给每一条线加一个极小的深度偏移,让它们在数学上“错开”
solid_trajectory_obj.position.z = this.trajectory_pos_z + i * 0.0005 ...;
4. 材质配置 (CustomShaderMaterial.ts):
this.transparent = true; // 告诉 GPU:我有透明度,请开启混合模式!
// 注意:开启透明后,通常需要手动管理渲染顺序(Three.js 会自动帮我们做一部分排序)
🎯 练习 3.3:亲眼看看 Z-Fighting
在 Three.js 场景中创建两个位置完全一样的 Plane(平面),给它们不同的颜色。转动视角,你会看到它们交替闪烁,这就是著名的 Z-Fighting。
🛠️ 常见的 Z-Fighting 解决方法:
物理错开 (Manual Offset) - 最推荐
- 像项目里那样,人为地把两个重叠的物体拉开一点点距离(比如
z + 0.0005)。 - 优点:简单、性能好、完全可控。
- 缺点:如果距离太远可能会看出来穿帮。
- 像项目里那样,人为地把两个重叠的物体拉开一点点距离(比如
多边形偏移 (Polygon Offset)
- 利用 GPU 硬件特性,在计算深度时自动加一个偏移量。
- Three.js 写法:
material.polygonOffset = true;
material.polygonOffsetFactor = 1; // 正值把物体推远,负值把物体拉近
material.polygonOffsetUnits = 1; - 优点:不需要改模型坐标。
对数深度缓冲 (Logarithmic Depth Buffer)
- Three.js
renderer的一个配置项。可以大大提高深度缓冲的精度,特别是在大场景中。 - 写法:
new THREE.WebGLRenderer({ logarithmicDepthBuffer: true }) - 原理(为啥能提高精度):
- 普通深度缓冲在透视投影下是“非线性分布”的(大致接近 (1/z)):近处刻度密、远处刻度稀,所以远处很容易出现 Z-Fighting。
- 对数深度缓冲把“写入深度缓冲的值”改成和 (\log(z)) 相关的形式,让深度刻度在远近范围内分布更均匀(更准确地说:更均匀地分配相对精度),因此特别适合
far很大、场景跨度很大的情况。 - 实现上通常是 shader 里根据片元的深度/距离计算一个 log 深度,并写到
gl_FragDepth(或等价路径)里。
- 缺点:有性能开销(比较小)
- Three.js
调整相机范围 (Near/Far Planes)
缩短
camera.near和camera.far之间的距离。关键技巧:增大near值效果极其明显!💡 到底什么是“精度”?(通俗版) 把深度缓冲区想象成一把只有 6.5 万个刻度的尺子(16 位深度)。GPU 必须用这把尺子去测量场景里所有物体的前后距离。
坑爹的地方在于:这把尺子的刻度是不均匀的! 根据 $1/Z$ 的数学特性,GPU 会把一半的刻度(约 3.2 万个)都密密麻麻地挤在
near到2 * near这一小段距离里。💡 什么是 $1/Z$?为什么深度缓冲要用它?
$Z$ 是什么? 就是物体到相机的实际距离(单位:米)。
- 比如一个物体在 10 米远,$Z = 10$。
- 另一个物体在 100 米远,$Z = 100$。
$1/Z$ 是什么? 就是距离的倒数。
- $Z = 10$ 米 → $1/Z = 0.1$
- $Z = 100$ 米 → $1/Z = 0.01$
- 规律:距离越远,$1/Z$ 越小(但永远不会变成 0)。
为什么透视投影会用到 $1/Z$?(从几何原理理解)
核心思想:透视投影的本质是“远处的物体看起来更小”,这个“缩小比例”正好和 $1/Z$ 成正比。
几何推导(相似三角形):
想象一个简单的透视场景:
- 相机在原点 $(0, 0, 0)$
- 一个物体在 $(x, y, z)$ 位置($z > 0$,在相机前方)
- 投影平面在 $z = d$ 处(比如 $d = 1$,就是近平面)
根据相似三角形,物体投影到屏幕上的坐标是:
$$ x_{screen} = \frac{x \cdot d}{z} = x \cdot \frac{d}{z} $$
关键发现:投影坐标 = 原始坐标 × $\frac{d}{z}$ = 原始坐标 × $d \cdot \frac{1}{z}$
也就是说,投影过程天然就包含了除以 $z$(即乘以 $1/z$)的操作!
为什么不能直接存 $Z$?
- GPU 在计算每个顶点的屏幕位置时,已经算出了 $1/z$(因为要除以 $z$ 才能投影)
- 深度值也是通过同样的投影矩阵计算出来的,所以深度值也包含了 $1/z$ 的项
- 如果存 $Z$,需要再算一次倒数($1/z$),浪费性能
- 更重要的是,透视投影矩阵的数学形式决定了深度值就是 $a + b/z$ 的形式(其中 $a, b$ 是常数),无法避免 $1/z$
$1/Z$ 的“不均匀”特性(这就是问题的根源): 看这个表格,假设
near = 0.1,far = 1000:实际距离 $Z$ (米) $1/Z$ 的值 在深度缓冲中的位置(归一化到 0-1) 0.1 10.0 0.0(起点) 0.2 5.0 约 0.5(才走了 0.1 米就用掉一半精度!) 1.0 1.0 约 0.9 10 0.1 约 0.99 100 0.01 约 0.999 1000 0.001 1.0(终点) 结论:从 0.1 米到 0.2 米,$1/Z$ 从 10.0 掉到 5.0(变化了 5.0),占用了大量精度空间。而从 100 米到 1000 米,$1/Z$ 只从 0.01 变到 0.001(变化了 0.009),精度空间被严重压缩,所以远处容易打架。
情况 1:
near = 0.1- 0.1 米 ~ 0.2 米:分到了 3.2 万个刻度。太奢侈了!连头发丝的厚度都能分清。
- 0.2 米 ~ 1000 米:剩下的 999.8 米,只能共用剩下的 3.2 万个刻度。
- 后果:在远处,每隔几厘米甚至几米才有一个刻度。如果两个物体相距 0.5 米(比如 500 米 和 500.5 米),它们可能都会落在同一个刻度上。GPU 就会觉得“它俩位置完全一样”,于是开始打架闪烁。
情况 2:
near = 1.0- 1.0 米 ~ 2.0 米:分到了 3.2 万个刻度。
- 2.0 米 ~ 1000 米:分剩下的 3.2 万个刻度。
- 结论:虽然还是很偏心,但至少把那种“0.1 米就用掉一半”的极端浪费给治好了,远处的物体能分到更多的刻度,就不容易打架了。
缺点:太近的物体会被切掉(看不见)。
📝 今日任务
- 理解深度缓冲:把它想象成记录“最近距离”的记事本。
- 理解混合:新颜色和旧颜色的加权平均。
- 看懂代码:明白为什么要加
0.0005这种奇怪的偏移量(为了防闪烁)。
第 4-5 天:综合练习
🎯 项目实战:LineRender 渲染流程串讲
任务: 结合前两周知识,彻底搞懂一条线是怎么画出来的。
- 数据:
LineRender接收一串点(x, y)。 - 几何体:
newThreeLine2D把这些点变成一个有厚度的 Mesh(生成很多三角形顶点)。 - 属性:每个顶点都有
position,lineNormal(法线),lineMiter(拐角方向)。 - 顶点着色器:
// 沿法线方向挤出,产生宽度
vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
// 矩阵变换
gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0); - 片段着色器:
// 填充颜色
gl_FragColor = vec4(diffuse, opacity); - 输出:一条有宽度的线出现在屏幕上。
📝 本周总结
- 我知道渲染管线的大致流程。
- 我明白 Vertex Shader 决定位置,Fragment Shader 决定颜色。
- 我理解深度测试解决了遮挡问题。
- 我看懂了 LineRender 是通过“挤出顶点”来模拟线宽的。
下一步: 第 4 周:渲染管线深入