Skip to main content

第 3 周:图形学基础概念

📅 学习计划

本周目标: 理解渲染管线、顶点/片段的区别、深度测试和混合模式。

时间安排: 每天 1-2 小时,共 5 天


第 1 天:渲染管线 (Pipeline)

📖 理论:什么是渲染管线?

渲染管线就是一条流水线,把 3D 数据(顶点)变成屏幕上的 2D 像素。

简化流程:

  1. 顶点处理 (Vertex Processing):处理每个点的坐标(3D -> 2D)。
  2. 光栅化 (Rasterization):把连成的三角形“打碎”成像素点。
  3. 片段处理 (Fragment Processing):计算每个像素点的颜色。
  4. 输出合并 (Output Merging):深度测试、混合(透明度处理),最终写入屏幕。

🔑 核心概念:缓冲区 (Buffer) 到底是什么?

你可以把 Buffer 理解为:GPU 侧的一块连续内存,专门用来存“渲染要用的数据”。
WebGL 的渲染其实是:绑定一堆 Buffer + 设定状态 + 跑一遍 Shader(一次 Draw Call)。

为什么 Buffer 很关键?

  • GPU 跑得快,但它不能直接读 JS 数组:顶点数据在 CPU/JS 内存里时,GPU 用不了。要画,就得把数据上传到 GPU。Buffer 就是上传后的存放处。
  • 数据是“批量”用的:一次 draw 往往要访问成千上万个顶点/片元,Buffer 提供连续布局,GPU 能高吞吐顺序读取。
  • 职责清晰:JS 端负责“准备数据”,GPU 端负责“并行计算 + 写入输出缓冲”。

常见 Buffer(先有个全局地图,后面会反复遇到):

  1. 顶点缓冲(VBO, Vertex Buffer Object)

    • 存什么:position / normal / uv / color / 自定义 attribute 等“每个顶点一份”的数据。
    • WebGL 里常见绑定点:gl.ARRAY_BUFFER
    • 结果:Vertex Shader 能从 attribute 里读到这些数据。
  2. 索引缓冲(IBO/EBO, Index Buffer Object)

    • 存什么:三角形是由哪些顶点编号组成的(比如 [0,1,2, 2,3,0])。
    • WebGL 里常见绑定点:gl.ELEMENT_ARRAY_BUFFER
    • 作用:复用顶点,减少重复顶点数据,提高效率。
  3. 帧缓冲(Framebuffer, FBO)及其附着缓冲

    • 你看到的“屏幕”本质上也是一个输出目标(默认帧缓冲)。
    • 一般会包含(或附着):
      • 颜色缓冲(Color Buffer):最终颜色写到这里(显示到屏幕或渲染到纹理)。
      • 深度缓冲(Depth Buffer / Z-Buffer):你在第 3 天学的深度测试用的“记事本”。
      • 模板缓冲(Stencil Buffer):做遮罩/轮廓/镜面等效果常用。
  4. 纹理(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:脑补管线

想象你要画一个三角形:

  1. Input: 3 个点 A, B, C。
  2. Vertex Shader: 算出 A, B, C 在屏幕上的位置。
  3. Rasterizer: 找出 A, B, C 围成的区域里有哪些像素。
  4. Fragment Shader: 对每个像素,算出它是红色的。
  5. Output: 屏幕上出现红色三角形。

📝 今日任务

  • 记住管线的主要阶段
  • 明白 Vertex Shader 和 Fragment Shader 在管线中的位置

第 2 天:顶点与片段

📖 理论:顶点 vs 片段

特性顶点 (Vertex)片段 (Fragment)
处理对象3D 空间中的点屏幕上的像素候选
数量相对较少(取决于模型面数)非常多(取决于分辨率和物体大小)
主要任务坐标变换颜色计算、光照
输出gl_Positiongl_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. 工作流程(小白版):

想象屏幕是一个由无数个小格子组成的网格(像素)。

  1. 初始化:每个格子的“记事本”都写上“无穷远”。
  2. 画第一个物体(比如远处的山)
    • 算出山在某个格子上的距离(比如 100 米)。
    • 对比:100 米 < 无穷远。
    • 通过! 画上山的颜色,并把“记事本”改成 100 米。
  3. 画第二个物体(比如近处的树)
    • 算出树在同一个格子上的距离(比如 10 米)。
    • 对比:10 米 < 100 米。
    • 通过! 覆盖掉山的颜色,画上树的颜色,把“记事本”改成 10 米。
  4. 画第三个物体(比如被树挡住的石头)
    • 算出石头在同一个格子上的距离(比如 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 吗?

  1. WebGL不会自动排序。它只是一个底层 API(像一个听话的画笔),你让它先画谁它就画谁。如果你直接写原生 WebGL,你必须自己在 CPU 端写代码算出每个物体的距离,排好序,然后再依次调用绘制命令。
  2. Three.js会自动帮你排序
    • Three.js 引擎内部维护了两个列表:opaque(不透明)和 transparent(透明)。
    • 当你设置 material.transparent = true 时,Three.js 会把这个物体放入透明列表。
    • 在每一帧渲染前,Three.js 会自动计算这些透明物体离相机的距离,从远到近排序,然后再指挥 WebGL 绘制。

💻 实践:项目中的 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 解决方法:

  1. 物理错开 (Manual Offset) - 最推荐

    • 像项目里那样,人为地把两个重叠的物体拉开一点点距离(比如 z + 0.0005)。
    • 优点:简单、性能好、完全可控。
    • 缺点:如果距离太远可能会看出来穿帮。
  2. 多边形偏移 (Polygon Offset)

    • 利用 GPU 硬件特性,在计算深度时自动加一个偏移量。
    • Three.js 写法:
      material.polygonOffset = true;
      material.polygonOffsetFactor = 1; // 正值把物体推远,负值把物体拉近
      material.polygonOffsetUnits = 1;
    • 优点:不需要改模型坐标。
  3. 对数深度缓冲 (Logarithmic Depth Buffer)

    • Three.js renderer 的一个配置项。可以大大提高深度缓冲的精度,特别是在大场景中。
    • 写法:new THREE.WebGLRenderer({ logarithmicDepthBuffer: true })
    • 原理(为啥能提高精度):
      • 普通深度缓冲在透视投影下是“非线性分布”的(大致接近 (1/z)):近处刻度密、远处刻度稀,所以远处很容易出现 Z-Fighting。
      • 对数深度缓冲把“写入深度缓冲的值”改成和 (\log(z)) 相关的形式,让深度刻度在远近范围内分布更均匀(更准确地说:更均匀地分配相对精度),因此特别适合 far 很大、场景跨度很大的情况。
      • 实现上通常是 shader 里根据片元的深度/距离计算一个 log 深度,并写到 gl_FragDepth(或等价路径)里。
    • 缺点:有性能开销(比较小)
  4. 调整相机范围 (Near/Far Planes)

    • 缩短 camera.nearcamera.far 之间的距离。关键技巧增大 near 值效果极其明显!

    • 💡 到底什么是“精度”?(通俗版) 把深度缓冲区想象成一把只有 6.5 万个刻度的尺子(16 位深度)。GPU 必须用这把尺子去测量场景里所有物体的前后距离。

      坑爹的地方在于:这把尺子的刻度是不均匀的! 根据 $1/Z$ 的数学特性,GPU 会把一半的刻度(约 3.2 万个)都密密麻麻地挤在 near2 * 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.1far = 1000

        实际距离 $Z$ (米)$1/Z$ 的值在深度缓冲中的位置(归一化到 0-1)
        0.110.00.0(起点)
        0.25.0约 0.5(才走了 0.1 米就用掉一半精度!
        1.01.0约 0.9
        100.1约 0.99
        1000.01约 0.999
        10000.0011.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 渲染流程串讲

任务: 结合前两周知识,彻底搞懂一条线是怎么画出来的。

  1. 数据LineRender 接收一串点 (x, y)
  2. 几何体newThreeLine2D 把这些点变成一个有厚度的 Mesh(生成很多三角形顶点)。
  3. 属性:每个顶点都有 position, lineNormal (法线), lineMiter (拐角方向)。
  4. 顶点着色器
    // 沿法线方向挤出,产生宽度
    vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
    // 矩阵变换
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
  5. 片段着色器
    // 填充颜色
    gl_FragColor = vec4(diffuse, opacity);
  6. 输出:一条有宽度的线出现在屏幕上。

📝 本周总结

  • 我知道渲染管线的大致流程。
  • 我明白 Vertex Shader 决定位置,Fragment Shader 决定颜色。
  • 我理解深度测试解决了遮挡问题。
  • 我看懂了 LineRender 是通过“挤出顶点”来模拟线宽的。

下一步: 第 4 周:渲染管线深入