01. 基础架构与核心概念回顾
在深入复杂的渲染控制之前,我们需要快速对齐 Three.js 的一些核心底层概念。这些往往是初学者容易忽略,但对进阶开发至关重要的基石。
1. 场景图 (Scene Graph) 与矩阵更新
Three.js 的核心架构建立在场景图 (Scene Graph) 之上。这是一个树状结构,其中 THREE.Scene 是根节点,所有物体(Mesh, Group, Light, Camera)都是节点。
1.1 父子层级关系 (Parent-Child Hierarchy)
在场景图中,物体可以添加子物体。理解这一点的关键在于:子物体的变换(位置、旋转、缩放)是相对于其父物体的。
- 相对性:如果你移动父物体,所有子物体会跟着移动,保持它们相对于父物体的相对位置不变。
- 叠加性:子物体在世界中的最终位置 = 父物体的变换 + 子物体的变换。
const parent = new THREE.Group();
scene.add(parent);
const child = new THREE.Mesh(geometry, material);
parent.add(child); // 将 child 添加到 parent
// child.position 是相对于 parent 的坐标
1.2 矩阵:Local vs World
为了数学上描述这些变换,Three.js 使用 4x4 矩阵。每个对象都有两个核心矩阵:
本地矩阵 (Local Matrix /
.matrix)- 存储对象相对于其父级的变换(position, rotation, scale)。
- 当你设置
object.position.x = 10时,你是在修改它的本地属性,需要更新本地矩阵。
世界矩阵 (World Matrix /
.matrixWorld)- 存储对象相对于场景根节点 (Scene) 的绝对变换。
- 这是渲染器最终用来确定物体在屏幕位置的数据。
- 计算公式:
子对象.matrixWorld = 父对象.matrixWorld * 子对象.matrix。
1.3 矩阵更新机制与性能优化
Three.js 采用懒更新或自动更新策略。
默认行为:自动更新
默认情况下,object.matrixAutoUpdate = true。
在每次 renderer.render() 调用时,Three.js 会遍历场景图:
- 检查对象位置/旋转/缩放是否改变。
- 如果改变,重新计算 本地矩阵。
- 递归调用
updateMatrixWorld(),通过累乘父级矩阵计算 世界矩阵。
性能陷阱:默认的全量计算
你的直觉是对的!
Three.js 的默认行为(matrixAutoUpdate = true)实际上并没有内置细粒度的脏标记检查来跳过数学计算。
在 updateMatrixWorld() 的源码实现中(以常见版本为例):
// Three.js 源码简化逻辑
updateMatrixWorld: function ( force ) {
// 只要开启了自动更新,每一帧都会重新计算本地矩阵
if ( this.matrixAutoUpdate ) this.updateMatrix();
// 如果本地矩阵更新了,或者父级强制更新(force),则计算世界矩阵
if ( this.matrixWorldNeedsUpdate || force ) {
// ... 矩阵乘法计算 ...
this.matrixWorldNeedsUpdate = false;
force = true; // 强制所有子节点也更新
}
// ... 递归更新子节点 ...
}
这意味着:只要 matrixAutoUpdate 为 true,无论物体是否移动,Three.js 每一帧都会执行 matrix.compose()(合成位移、旋转、缩放),并往往伴随着世界矩阵的乘法计算。
这就是为什么对于大量静态物体,性能开销会非常大。
性能优化方案
正因为存在上述的“无条件计算”,以下优化变得至关重要:
静态物体必须关闭自动更新:
const staticMesh = new THREE.Mesh(geo, mat);
staticMesh.matrixAutoUpdate = false; // ⛔️ 彻底停止每帧的计算
staticMesh.updateMatrix(); // ✅ 初始化时手动更新一次按需更新: 只有在确实移动了物体时,才手动调用:
function move() {
mesh.position.x += 1;
mesh.updateMatrix(); // 手动通知更新
}使用
InstancedMesh: 对于成千上万个相同的物体,不要创建几千个 Mesh 对象(会有几千次矩阵计算开销),而是使用InstancedMesh,它在 GPU 端一次性处理,极大降低 CPU 矩阵计算压力。
痛点:对于成千上万个静态物体,每帧都检查并重新计算矩阵是非常耗时的 CPU 操作。
优化方案: 如果你知道某个物体是静止的(或者只在特定时刻移动),可以关闭自动更新,改为手动控制。
const object = new THREE.Mesh(geometry, material);
// 1. 关闭自动更新(大幅节省每帧 CPU 开销)
object.matrixAutoUpdate = false;
// 2. 初始设置位置
object.position.set(10, 0, 0);
// 3. 手动更新一次(必须做,否则矩阵是单位矩阵,物体会在原点)
object.updateMatrix();
// 后续如果移动了物体...
function moveObject() {
object.position.y += 5;
// 必须手动通知更新
object.updateMatrix();
}
1.4 深入理解 updateMatrix 与 updateMatrixWorld
object.updateMatrix():- 只根据对象的
.position,.rotation,.scale更新其.matrix(本地矩阵)。 - 不考虑父级。
- 只根据对象的
object.updateMatrixWorld(force):- 更新对象的
.matrixWorld(世界矩阵)。 - 通常需要父级的世界矩阵已经更新。
- 如果
force = true,会强制重新计算。
- 更新对象的
层级更新示例:
const group = new THREE.Group();
group.position.set(10, 0, 0); // 父级在 x=10
const child = new THREE.Mesh();
child.position.set(5, 0, 0); // 子级在 x=5 (相对于父级)
group.add(child);
// 此时:
// child.position.x 是 5
// 渲染前,Three.js 自动计算:
// child 的世界坐标 x 实际上是 15 (10 + 5)
// 如果手动处理:
// 1. 更新父级世界矩阵
group.updateMatrixWorld();
// 2. 此时 child 的世界矩阵也会在递归中被更新
console.log(child.matrixWorld.elements);
2. Geometry 的本质与数据结构
几何体的意义:定义形状与结构
几何体(Geometry)的本质是定义 3D 物体的形状和结构数据,它回答了"这个物体是什么形状"的问题。
几何体 vs 材质:形状 vs 外观
在 Three.js 中,一个完整的 3D 物体需要两个核心组件:
| 组件 | 作用 | 类比 | 控制内容 |
|---|---|---|---|
| Geometry(几何体) | 定义形状 | 骨架 | 顶点位置、法线、UV 坐标、索引 |
| Material(材质) | 定义外观 | 皮肤 | 颜色、纹理、光照、着色器程序 |
关系:
- 几何体:告诉 GPU "在哪里绘制"(顶点位置)和"如何连接"(索引)
- 材质:告诉 GPU "如何绘制"(颜色计算、光照模型、纹理采样)
// 几何体:定义形状(一个立方体)
const geometry = new THREE.BoxGeometry(1, 1, 1);
// 材质:定义外观(红色、有光泽)
const material = new THREE.MeshStandardMaterial({
color: 0xff0000,
metalness: 0.8,
});
// 组合:形状 + 外观 = 完整的 3D 物体
const mesh = new THREE.Mesh(geometry, material);
几何体的核心作用
- 定义顶点位置(position):物体的 3D 形状
- 定义法线(normal):用于光照计算和面朝向
- 定义纹理坐标(uv):将 2D 纹理映射到 3D 表面
- 定义索引(index):复用顶点,减少内存占用
- 定义自定义属性:可以添加任意自定义数据(如颜色、动画权重等)
关键理解:
- 几何体是数据容器,存储的是原始数值(坐标、向量等)
- 几何体本身不包含渲染逻辑,它只是提供数据给材质中的着色器使用
- 同一个几何体可以搭配不同的材质,产生不同的视觉效果
- 同一个材质可以应用到不同的几何体上
// 同一个几何体,不同的材质 = 不同的外观
const geometry = new THREE.SphereGeometry(1, 32, 32);
const mesh1 = new THREE.Mesh(
geometry,
new THREE.MeshBasicMaterial({ color: 0xff0000 }),
);
const mesh2 = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({ color: 0x00ff00 }),
);
// 同一个材质,不同的几何体 = 不同的形状
const material = new THREE.MeshStandardMaterial({ color: 0x0000ff });
const cube = new THREE.Mesh(new THREE.BoxGeometry(), material);
const sphere = new THREE.Mesh(new THREE.SphereGeometry(), material);
BufferGeometry 的数据结构
BufferGeometry 是所有几何体的基石。它直接将数据存储在 Float32Array 等类型化数组中,这些数组直接对应 WebGL 的 Attribute Buffer。
BufferGeometry 的核心属性
- position: 顶点坐标 (x, y, z) -
Float32Array - normal: 法线向量 -
Float32Array - uv: 纹理坐标 -
Float32Array - index: 顶点索引(复用顶点)-
Uint16Array或Uint32Array
needsUpdate 标志:CPU 与 GPU 数据同步
核心原理:CPU 内存(RAM)和 GPU 显存(VRAM)是分离的。
- JavaScript 中的
Float32Array存储在 CPU 内存(RAM)中 - GPU 渲染需要的数据存储在 GPU 显存(VRAM)中
- 两者是完全独立的内存空间
needsUpdate 的作用:
needsUpdate 是一个优化标志,告诉 Three.js:"CPU 内存中的数据已修改,下次渲染时需要重新上传到 GPU"。
- 如果不设置
needsUpdate = true,Three.js 认为数据没有变化,不会执行gl.bufferData()或gl.bufferSubData()操作 - GPU 会继续使用旧的显存数据,你的修改不会生效
- 这是性能优化:避免每帧都上传所有数据,只在数据真正改变时才上传
- 上传后,Three.js 会自动将
needsUpdate重置为false
什么时候需要手动设置 needsUpdate?
Three.js 的很多 API 会自动设置 needsUpdate = true,但有一个重要例外:
| 操作方式 | 是否需要手动设置 needsUpdate | 说明 |
|---|---|---|
geometry.setAttribute("position", new BufferAttribute(...)) | ❌ 不需要 | setAttribute() 内部会自动设置 needsUpdate = true |
geometry.setIndex(new BufferAttribute(...)) | ❌ 不需要 | setIndex() 内部会自动设置 |
new THREE.BufferAttribute(array, 3) | ❌ 不需要 | 创建新的 BufferAttribute 时会自动上传 |
直接修改 attr.array 的内容 | ✅ 必须设置 | Three.js 无法检测到数组内容的改变 |
attr.array = new Float32Array(...) | ✅ 建议设置 | 替换整个数组引用,最好显式设置 |
为什么会有这个区别?
setAttribute()是 Three.js 的公开 API,内部实现会检测到属性被替换,自动设置needsUpdate = true- 直接修改
attr.array的内容是绕过 Three.js API的操作,Three.js 无法检测到数组内容的变化(JavaScript 的限制) - 这是性能优化的权衡:如果每次访问数组都检查是否变化,性能开销太大
代码示例:
// ✅ 方式1:使用 setAttribute(自动设置 needsUpdate)
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3),
);
// 不需要手动设置 needsUpdate,setAttribute 内部已经处理了
// ✅ 方式2:直接修改数组内容(必须手动设置)
const positions = geometry.attributes.position.array;
positions[1] = 100; // 修改数组内容
geometry.attributes.position.needsUpdate = true; // 必须手动设置!
// ❌ 错误:直接修改但不设置 needsUpdate
positions[1] = 100;
// 没有设置 needsUpdate,GPU 不会更新,渲染结果不变!
最佳实践:
- 创建/替换属性:使用
setAttribute(),让 Three.js 自动处理 - 修改现有数据:直接修改数组 + 手动设置
needsUpdate = true(性能最优) - 动画循环中:复用 BufferAttribute,只修改数组内容,避免频繁创建新对象
实际应用:修改几何体形状最高效的方法是直接修改 attributes 数组,并设置 .needsUpdate = true。
const positions = geometry.attributes.position.array;
for (let i = 0; i < positions.length; i += 3) {
positions[i + 1] = Math.sin(time + positions[i]); // 修改 Y 轴
}
geometry.attributes.position.needsUpdate = true; // 告诉 GPU 更新数据
类型化数组的类型与选择
JavaScript 提供了多种类型化数组,每种都有不同的字节大小和数值范围:
| 类型 | 字节/元素 | 数值范围 | 典型用途 | 内存占用 |
|---|---|---|---|---|
Float32Array | 4 | ±3.4×10³⁸ | 顶点坐标、法线、UV、颜色 | 中等 |
Uint16Array | 2 | 0 到 65535 | 顶点索引(< 65535 顶点) | 小 |
Uint32Array | 4 | 0 到 4,294,967,295 | 大型模型索引(> 65535 顶点) | 中等 |
Uint8Array | 1 | 0 到 255 | 纹理像素、颜色通道、二进制数据 | 最小 |
Float64Array | 8 | ±1.7×10³⁰⁸ | 高精度计算(WebGL 不支持) | 大 |
为什么 Three.js 主要使用 Float32Array?
- WebGL 标准:GPU 硬件主要支持 32 位浮点运算
- 精度足够:32 位浮点精度满足绝大多数图形计算需求
- 性能最优:GPU 对 32 位浮点运算优化最好
- 内存平衡:比
Float64Array节省一半内存,同时精度足够
实际使用场景:
// Float32Array - 存储浮点属性(最常用)
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), 3)
);
// Uint16Array - 存储索引(顶点数 < 65535)
geometry.setIndex(
new THREE.BufferAttribute(new Uint16Array([0, 1, 2, 0, 2, 3]), 1)
);
// Uint32Array - 大型模型索引(顶点数 > 65535)
geometry.setIndex(
new THREE.BufferAttribute(new Uint32Array([...]), 1)
);
// Uint8Array - 纹理像素数据(RGBA,每个通道 0-255)
const pixels = new Uint8Array([255, 0, 0, 255]); // 红色
3. Material 的本质与编译过程
材质的本质:不仅仅是表面纹理
材质(Material)的本质是控制整个渲染管线的程序,它定义了:
- 顶点变换(Vertex Shader):控制顶点的位置、法线等属性如何变换
- 表面外观(Fragment Shader):控制每个像素的最终颜色、透明度等
- 渲染状态:深度测试、混合模式、面剔除等 WebGL 状态
因此,材质不仅仅是"表面纹理",而是完整的渲染程序。
Geometry 与 Vertex Shader 的关系:数据源 vs 处理逻辑
重要澄清:几何体定义顶点位置,顶点着色器也能修改顶点位置,它们并不冲突,而是有明确的分工:
| 组件 | 作用 | 时机 | 类比 |
|---|---|---|---|
| Geometry | 定义初始数据(原始形状) | 创建时定义 | 原材料 |
| Vertex Shader | 定义变换逻辑(如何修改数据) | 每帧渲染时执行 | 加工方法 |
关系说明:
Geometry = 数据源:
- 存储顶点的初始位置(如
position: [0, 0, 0, 1, 0, 0, ...]) - 这些数据是静态的(除非手动修改)
- 作为顶点着色器的输入(通过
attribute传入)
- 存储顶点的初始位置(如
Vertex Shader = 处理逻辑:
- 接收 Geometry 的顶点数据作为输入
- 在每帧渲染时动态处理这些数据
- 可以修改顶点位置(如动画、变形效果)
- 输出变换后的位置给后续阶段
实际例子:
// Geometry:定义初始形状(一个平面)
const geometry = new THREE.PlaneGeometry(10, 10, 10, 10);
// geometry.attributes.position 存储了平面的初始顶点位置
// Material:定义如何变换这些顶点(波浪效果)
const material = new THREE.ShaderMaterial({
vertexShader: `
uniform float time;
attribute vec3 position; // ← 来自 Geometry 的初始数据
void main() {
// 基于 Geometry 的初始位置,动态计算新位置
vec3 pos = position; // 读取 Geometry 的原始数据
pos.y += sin(position.x * 0.5 + time) * 0.5; // 动态修改(波浪效果)
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
// ...
});
// 渲染时:
// 1. Geometry 提供初始顶点位置 [0,0,0, 1,0,0, ...]
// 2. Vertex Shader 每帧读取这些数据,并应用波浪变换
// 3. 结果:平面变成动态的波浪形状
关键理解:
- Geometry 定义"是什么":初始形状、基础数据
- Vertex Shader 定义"如何变化":动态变换、动画效果
- 它们配合工作:Geometry 提供数据,Vertex Shader 处理数据
- 类比:Geometry 是"面团"(原始形状),Vertex Shader 是"模具"(如何塑造)
重要技术细节:顶点着色器不会修改原始数据
关键点:顶点着色器不会真正修改 Geometry 中存储的原始数据,它只是在渲染时对数据进行变换计算。
工作原理:
// Geometry 中的原始数据(存储在 CPU/GPU 内存中)
geometry.attributes.position.array = [0, 0, 0, 1, 0, 0, 0, 1, 0, ...];
// ↑ 这些数据在渲染过程中保持不变
// Vertex Shader(每帧执行)
void main() {
vec3 pos = position; // ← 读取 Geometry 的原始数据(只读)
pos.y += sin(position.x + time) * 0.5; // ← 计算新位置(临时变量)
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
// ↑ 输出变换后的位置,但原始 position 数据未改变
}
为什么这样设计?
- 数据安全:原始数据保持不变,可以重复使用
- 性能优化:同一个 Geometry 可以被多个 Mesh 共享
- 灵活性:同一几何体可以搭配不同材质,产生不同效果
- 可逆性:变换是临时的,不会破坏原始数据
实际验证:
const geometry = new THREE.PlaneGeometry(10, 10, 10, 10);
const originalY = geometry.attributes.position.array[1]; // 保存原始 Y 值
// 使用会修改顶点位置的顶点着色器
const material = new THREE.ShaderMaterial({
vertexShader: `
uniform float time;
void main() {
vec3 pos = position;
pos.y += sin(time) * 2.0; // 在着色器中修改位置
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
uniforms: { time: { value: 0 } },
});
// 渲染多帧后...
renderer.render(scene, camera);
// 检查原始数据
console.log(geometry.attributes.position.array[1]); // 仍然是 originalY,未改变!
// ↑ 顶点着色器的修改只影响渲染结果,不影响原始数据
总结:
- ✅ 顶点着色器可以"变换"顶点位置:在渲染时计算新的位置
- ❌ 顶点着色器不会"修改"原始数据:Geometry 中的数据保持不变
- 🔄 每帧重新计算:基于原始数据,每帧都重新计算变换结果
常见应用场景:
- 静态形状:只使用 Geometry,Vertex Shader 只做基本的坐标变换(MVP 矩阵)
- 动态变形:Geometry 提供基础形状,Vertex Shader 添加动画效果(如旗帜飘动、水面波动)
- 程序化生成:Geometry 提供少量顶点,Vertex Shader 通过算法生成复杂形状
材质类型对比
| 材质类型 | 着色器控制 | 适用场景 |
|---|---|---|
| MeshBasicMaterial | Three.js 内置(固定管线) | 简单颜色、不受光照影响 |
| MeshStandardMaterial | Three.js 内置(PBR 管线) | 物理真实感渲染(金属度、粗糙度) |
| MeshPhongMaterial | Three.js 内置(Phong 光照) | 传统光照模型 |
| ShaderMaterial | 完全自定义 Vertex + Fragment Shader | 自定义效果、后处理、特殊渲染 |
| RawShaderMaterial | 完全自定义(无 Three.js 辅助代码) | 完全控制,需要手动处理矩阵等 |
自定义着色器的能力
使用 ShaderMaterial 或 RawShaderMaterial,你可以:
// ✅ 自定义顶点着色器 - 控制顶点位置变换
const material = new THREE.ShaderMaterial({
vertexShader: `
uniform float time;
varying vec3 vPosition;
void main() {
// 可以修改顶点位置(如波浪效果)
vec3 pos = position;
pos.y += sin(position.x * 0.1 + time) * 0.5;
vPosition = pos;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: `
varying vec3 vPosition;
void main() {
// 自定义颜色计算逻辑
vec3 color = vec3(
sin(vPosition.x * 0.1),
cos(vPosition.y * 0.1),
0.5
);
gl_FragColor = vec4(color, 1.0);
}
`,
uniforms: {
time: { value: 0 },
},
});
关键点:
- 顶点着色器:可以修改顶点位置(如波浪、扭曲、变形效果)
- 片段着色器:可以完全自定义颜色计算(如渐变、噪声、特殊光照)
- Uniforms:可以在运行时传入参数,动态控制效果
Material 的编译过程
当你第一次渲染一个使用了新材质的物体时,Three.js 会:
- 根据材质参数生成 GLSL 代码字符串。
- 编译 Vertex Shader 和 Fragment Shader。
- 链接 Program。
卡顿原因:如果在渲染循环中动态创建新材质或修改导致重编译的属性(如 defines),会造成帧率掉落(Shader Compilation Stutter)。
预编译:可以在初始化阶段调用 renderer.compile(scene, camera) 来强制预编译 Shader。
更新材质的常规方法
在材质已经编译完成后,更新材质有以下几种常规方法:
1. 修改材质属性(推荐)
直接修改材质的属性值,Three.js 会在下一帧渲染时自动应用更改,不会触发 Shader 重编译:
// ✅ 修改颜色、透明度等属性(不会重编译)
material.color.setHex(0xff0000);
material.opacity = 0.5;
material.transparent = true;
// ✅ 修改纹理(不会重编译)
material.map = newTexture;
material.map.needsUpdate = true; // 纹理需要更新时设置
// ✅ 修改数值属性(不会重编译)
material.metalness = 0.8;
material.roughness = 0.2;
2. 替换整个材质
如果需要完全替换材质,直接赋值即可:
// 替换材质(会触发新材质的编译,如果该材质首次使用)
mesh.material = newMaterial;
// 对于使用材质数组的情况
mesh.material[0] = newMaterial;
3. 更新 ShaderMaterial 的 Uniforms
对于自定义的 ShaderMaterial,通过更新 uniforms 来改变渲染效果:
// ✅ 更新 uniform 值(不会重编译)
material.uniforms.time.value = Date.now() / 1000;
material.uniforms.color.value.setHex(0x00ff00);
// ❌ 避免修改会导致重编译的属性
// material.defines.SOME_DEFINE = '1'; // 这会触发重编译!
4. 需要触发重编译的情况
以下操作会触发 Shader 重编译,应避免在渲染循环中频繁执行:
// ❌ 这些操作会触发重编译,造成卡顿
material.defines.SOME_DEFINE = "1"; // 修改 defines
material.vertexShader = newShaderCode; // 替换 shader 代码
material.fragmentShader = newShaderCode;
最佳实践:
- 运行时更新:只修改材质的属性值(color, opacity, map 等)和 uniforms,避免修改
defines或 shader 代码 - 初始化阶段:如果需要不同的材质变体,在初始化时创建多个材质实例,运行时切换使用
- 性能优化:对于需要频繁更新的属性(如动画),直接修改属性值,不要创建新材质
4. 渲染管道:Geometry 与 Material 的协作流程
WebGL 渲染管道概览
理解几何体和材质在渲染管道中的位置和作用,对于掌握 Three.js 渲染机制至关重要。
渲染管道流程图
┌─────────────────────────────────────────────────────────────────────┐
│ CPU 端(JavaScript) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Geometry │ │ Material │ │
│ │ (几何体) │ │ (材质) │ │
│ ├──────────────┤ ├──────────────┤ │
│ │ position[] │ │ vertexShader │ │
│ │ normal[] │ │fragmentShader│ │
│ │ uv[] │ │ uniforms │ │
│ │ index[] │ │ textures │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ └────────┬───────────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ Mesh Object │ │
│ │ (组合几何体+材质)│ │
│ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ renderer.render()│ │
│ └────────┬─────────┘ │
└──────────────────┼──────────────────────────────────────────────────┘
│ 数据上传到 GPU
│ (gl.bufferData, gl.texImage2D)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ GPU 端(WebGL 渲染管道) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 阶段 1: 顶点着色器 (Vertex Shader) │ │
│ │ ──────────────────────────────────────────────────────── │ │
│ │ 输入: │ │
│ │ • position (来自 Geometry) │ │
│ │ • normal (来自 Geometry) │ │
│ │ • uv (来自 Geometry) │ │
│ │ • modelViewMatrix (来自 Three.js) │ │
│ │ • projectionMatrix (来自 Camera) │ │
│ │ │ │
│ │ 处理: │ │
│ │ • 执行 Material.vertexShader 代码 │ │
│ │ • 变换顶点位置 (本地坐标 → 裁剪空间) │ │
│ │ • 计算 varying 变量传递给片段着色器 │ │
│ │ │ │
│ │ 输出: │ │
│ │ • gl_Position (裁剪空间坐标) │ │
│ │ • varying 变量 (传递给片段着色器) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 阶段 2: 图元装配 (Primitive Assembly) │ │
│ │ ──────────────────────────────────────────────────────── │ │
│ │ 根据 Geometry.index 连接顶点形成三角形 │ │
│ │ (如果没有 index,则按顺序每3个顶点组成一个三角形) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 阶段 3: 光栅化 (Rasterization) │ │
│ │ ──────────────────────────────────────────────────────── │ │
│ │ 将三角形转换为像素片段 (Fragment) │ │
│ │ 每个像素片段对应屏幕上的一个像素 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 阶段 4: 片段着色器 (Fragment Shader) │ │
│ │ ──────────────────────────────────────────────────────── │ │
│ │ 输入: │ │
│ │ • varying 变量 (来自顶点着色器) │ │
│ │ • uniforms (来自 Material) │ │
│ │ • textures (来自 Material) │ │
│ │ │ │
│ │ 处理: │ │
│ │ • 执行 Material.fragmentShader 代码 │ │
│ │ • 计算光照 (使用 normal) │ │
│ │ • 采样纹理 (使用 uv 坐标) │ │
│ │ • 计算最终颜色 │ │
│ │ │ │
│ │ 输出: │ │
│ │ • gl_FragColor (RGBA 颜色值) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 阶段 5: 逐片段操作 (Per-Fragment Operations) │ │
│ │ ──────────────────────────────────────────────────────── │ │
│ │ • 深度测试 (Depth Test) - Material.depthTest │ │
│ │ • 模板测试 (Stencil Test) │ │
│ │ • 混合 (Blending) - Material.blending │ │
│ │ • 面剔除 (Face Culling) - Material.side │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ 帧缓冲区 │ │
│ │ (屏幕显示) │ │
│ └───────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
各阶段详细说明
| 阶段 | Geometry 的作用 | Material 的作用 | 数据流向 |
|---|---|---|---|
| 顶点着色器 | 提供 position、normal、uv 等属性数据 | 提供 vertexShader 代码,定义如何变换顶点 | Geometry → Material (数据) |
| 图元装配 | 提供 index 数组,定义顶点连接方式 | - | Geometry (索引) |
| 光栅化 | - | - | GPU 自动处理 |
| 片段着色器 | 通过 varying 传递插值后的数据(如 uv、normal) | 提供 fragmentShader 代码、uniforms、textures,定义颜色计算 | Geometry (插值数据) + Material (逻辑) |
| 逐片段操作 | - | 提供渲染状态(depthTest、blending、side 等) | Material (状态) |
关键理解
Geometry = 数据源:
- 在顶点着色器阶段,Geometry 的属性(position, normal, uv)作为输入
- 这些数据通过 WebGL Attribute 传递给顶点着色器
Material = 处理逻辑:
- 顶点着色器阶段:Material 的
vertexShader代码处理 Geometry 的顶点数据 - 片段着色器阶段:Material 的
fragmentShader代码计算最终颜色 - 逐片段操作阶段:Material 的渲染状态控制深度测试、混合等
- 顶点着色器阶段:Material 的
数据流向:
Geometry (数据) → Vertex Shader (Material 代码处理)
→ Rasterization
→ Fragment Shader (Material 代码 + Geometry 插值数据)
→ Per-Fragment Operations (Material 状态)
→ Frame Buffer
实际例子
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 });
// 渲染时:
// 1. Geometry.position[] → 上传到 GPU Attribute Buffer
// 2. Material.vertexShader → 编译并执行,处理 position
// 3. Geometry.index[] → 用于图元装配,形成三角形
// 4. Material.fragmentShader → 编译并执行,计算每个像素颜色
// 5. Material.depthTest → 控制深度测试
// 6. 结果 → 显示在屏幕上
总结:Geometry 提供"原材料"(数据),Material 提供"加工方法"(着色器代码和渲染状态),两者在 GPU 渲染管道的不同阶段协作,最终生成屏幕上的图像。
5. 渲染循环 (Render Loop)
标准的渲染循环:
function animate() {
requestAnimationFrame(animate);
// 1. 物理/逻辑更新
updatePhysics();
// 2. 动画更新
TWEEN.update();
// 3. 渲染
renderer.render(scene, camera);
}
注意:不要在 animate 循环中创建对象(new THREE.Vector3),这会导致频繁的 GC(垃圾回收),引发卡顿。尽量复用对象。
6. 前沿技术:WebGLRenderer 与 WebGPURenderer 的演进
Three.js 的底层正在经历一场巨变。传统的 WebGLRenderer 基于 WebGL(1 和 2)标准,这些标准是对 OpenGL ES 的封装,存在状态机复杂、CPU 负担过重的问题。
而伴随着 WebGPU 标准的落地,Three.js 推出了全新的 WebGPURenderer(通常结合 TSL 和 NodeMaterial 使用)。
为什么关注 WebGPURenderer?
- 更低的 CPU 开销:WebGPU 摆脱了 WebGL 臃肿的全局状态机,支持更高效的 Draw Call 发送,减少了 CPU 端的验证开销。
- Compute Shader 计算着色器:这是最大的质变!可以直接在 GPU 上进行通用计算(GPGPU),例如千万级粒子模拟、布料物理、复杂光栅化处理等,而无需再用 "Ping-Pong" 纹理技巧强行模拟。
- 更现代的 API:多线程友好的命令缓冲区,允许你在 Worker 线程中准备渲染指令。
- 统一渲染后端:
WebGPURenderer虽然名字带 WebGPU,但它在内部支持了降级兼容(WebGPU -> WebGL2),这意味着你用新 API 编写的代码可以自动在不支持 WebGPU 的旧设备上通过 WebGL2 运行。
// 现代 Three.js 的初始化方式(以 r160+ 为例)
import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
// 初始化渲染器,如果浏览器不支持 WebGPU,它会自动 fallback 到 WebGL2
const renderer = new WebGPURenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// NodeMaterial 和 TSL 将在第 05 节深入讲解
思考题
- 如果一个场景中有 100 个静态的建筑 Mesh,你会采取哪些措施来优化它们的矩阵更新开销?如果这些建筑每隔 10 秒才会被移动一次呢?
- 解释
Geometry.setAttribute和直接修改geometry.attributes.position.array的区别。在什么情况下必须手动将needsUpdate设为 true? - "Vertex Shader 改变了物体的形状"——这句话准确吗?为什么调用了带有波浪 Vertex Shader 的材质后,在 CPU 端读取该 Geometry 的位置数组,数值依然没有变化?
- WebGPURenderer 相对于传统的 WebGLRenderer,其核心优势有哪些?为什么我们需要尽早拥抱 WebGPU?