Skip to main content

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 矩阵。每个对象都有两个核心矩阵:

  1. 本地矩阵 (Local Matrix / .matrix)

    • 存储对象相对于其父级的变换(position, rotation, scale)。
    • 当你设置 object.position.x = 10 时,你是在修改它的本地属性,需要更新本地矩阵。
  2. 世界矩阵 (World Matrix / .matrixWorld)

    • 存储对象相对于场景根节点 (Scene) 的绝对变换。
    • 这是渲染器最终用来确定物体在屏幕位置的数据。
    • 计算公式子对象.matrixWorld = 父对象.matrixWorld * 子对象.matrix

1.3 矩阵更新机制与性能优化

Three.js 采用懒更新或自动更新策略。

默认行为:自动更新

默认情况下,object.matrixAutoUpdate = true。 在每次 renderer.render() 调用时,Three.js 会遍历场景图:

  1. 检查对象位置/旋转/缩放是否改变。
  2. 如果改变,重新计算 本地矩阵
  3. 递归调用 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; // 强制所有子节点也更新
}

// ... 递归更新子节点 ...
}

这意味着:只要 matrixAutoUpdatetrue,无论物体是否移动,Three.js 每一帧都会执行 matrix.compose()(合成位移、旋转、缩放),并往往伴随着世界矩阵的乘法计算。

这就是为什么对于大量静态物体,性能开销会非常大。

性能优化方案

正因为存在上述的“无条件计算”,以下优化变得至关重要:

  1. 静态物体必须关闭自动更新

    const staticMesh = new THREE.Mesh(geo, mat);
    staticMesh.matrixAutoUpdate = false; // ⛔️ 彻底停止每帧的计算
    staticMesh.updateMatrix(); // ✅ 初始化时手动更新一次
  2. 按需更新: 只有在确实移动了物体时,才手动调用:

    function move() {
    mesh.position.x += 1;
    mesh.updateMatrix(); // 手动通知更新
    }
  3. 使用 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 深入理解 updateMatrixupdateMatrixWorld

  • 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);

几何体的核心作用

  1. 定义顶点位置(position):物体的 3D 形状
  2. 定义法线(normal):用于光照计算和面朝向
  3. 定义纹理坐标(uv):将 2D 纹理映射到 3D 表面
  4. 定义索引(index):复用顶点,减少内存占用
  5. 定义自定义属性:可以添加任意自定义数据(如颜色、动画权重等)

关键理解

  • 几何体是数据容器,存储的是原始数值(坐标、向量等)
  • 几何体本身不包含渲染逻辑,它只是提供数据给材质中的着色器使用
  • 同一个几何体可以搭配不同的材质,产生不同的视觉效果
  • 同一个材质可以应用到不同的几何体上
// 同一个几何体,不同的材质 = 不同的外观
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: 顶点索引(复用顶点)- Uint16ArrayUint32Array

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 提供了多种类型化数组,每种都有不同的字节大小和数值范围:

类型字节/元素数值范围典型用途内存占用
Float32Array4±3.4×10³⁸顶点坐标、法线、UV、颜色中等
Uint16Array20 到 65535顶点索引(< 65535 顶点)
Uint32Array40 到 4,294,967,295大型模型索引(> 65535 顶点)中等
Uint8Array10 到 255纹理像素、颜色通道、二进制数据最小
Float64Array8±1.7×10³⁰⁸高精度计算(WebGL 不支持)

为什么 Three.js 主要使用 Float32Array

  1. WebGL 标准:GPU 硬件主要支持 32 位浮点运算
  2. 精度足够:32 位浮点精度满足绝大多数图形计算需求
  3. 性能最优:GPU 对 32 位浮点运算优化最好
  4. 内存平衡:比 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)的本质是控制整个渲染管线的程序,它定义了:

  1. 顶点变换(Vertex Shader):控制顶点的位置、法线等属性如何变换
  2. 表面外观(Fragment Shader):控制每个像素的最终颜色、透明度等
  3. 渲染状态:深度测试、混合模式、面剔除等 WebGL 状态

因此,材质不仅仅是"表面纹理",而是完整的渲染程序

Geometry 与 Vertex Shader 的关系:数据源 vs 处理逻辑

重要澄清:几何体定义顶点位置,顶点着色器也能修改顶点位置,它们并不冲突,而是有明确的分工:

组件作用时机类比
Geometry定义初始数据(原始形状)创建时定义原材料
Vertex Shader定义变换逻辑(如何修改数据)每帧渲染时执行加工方法

关系说明

  1. Geometry = 数据源

    • 存储顶点的初始位置(如 position: [0, 0, 0, 1, 0, 0, ...]
    • 这些数据是静态的(除非手动修改)
    • 作为顶点着色器的输入(通过 attribute 传入)
  2. 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 数据未改变
}

为什么这样设计?

  1. 数据安全:原始数据保持不变,可以重复使用
  2. 性能优化:同一个 Geometry 可以被多个 Mesh 共享
  3. 灵活性:同一几何体可以搭配不同材质,产生不同效果
  4. 可逆性:变换是临时的,不会破坏原始数据

实际验证

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 通过算法生成复杂形状

材质类型对比

材质类型着色器控制适用场景
MeshBasicMaterialThree.js 内置(固定管线)简单颜色、不受光照影响
MeshStandardMaterialThree.js 内置(PBR 管线)物理真实感渲染(金属度、粗糙度)
MeshPhongMaterialThree.js 内置(Phong 光照)传统光照模型
ShaderMaterial完全自定义 Vertex + Fragment Shader自定义效果、后处理、特殊渲染
RawShaderMaterial完全自定义(无 Three.js 辅助代码)完全控制,需要手动处理矩阵等

自定义着色器的能力

使用 ShaderMaterialRawShaderMaterial,你可以:

// ✅ 自定义顶点着色器 - 控制顶点位置变换
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 会:

  1. 根据材质参数生成 GLSL 代码字符串。
  2. 编译 Vertex Shader 和 Fragment Shader。
  3. 链接 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 的作用数据流向
顶点着色器提供 positionnormaluv 等属性数据提供 vertexShader 代码,定义如何变换顶点Geometry → Material (数据)
图元装配提供 index 数组,定义顶点连接方式-Geometry (索引)
光栅化--GPU 自动处理
片段着色器通过 varying 传递插值后的数据(如 uv、normal)提供 fragmentShader 代码、uniformstextures,定义颜色计算Geometry (插值数据) + Material (逻辑)
逐片段操作-提供渲染状态(depthTestblendingside 等)Material (状态)

关键理解

  1. Geometry = 数据源

    • 顶点着色器阶段,Geometry 的属性(position, normal, uv)作为输入
    • 这些数据通过 WebGL Attribute 传递给顶点着色器
  2. Material = 处理逻辑

    • 顶点着色器阶段:Material 的 vertexShader 代码处理 Geometry 的顶点数据
    • 片段着色器阶段:Material 的 fragmentShader 代码计算最终颜色
    • 逐片段操作阶段:Material 的渲染状态控制深度测试、混合等
  3. 数据流向

    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?

  1. 更低的 CPU 开销:WebGPU 摆脱了 WebGL 臃肿的全局状态机,支持更高效的 Draw Call 发送,减少了 CPU 端的验证开销。
  2. Compute Shader 计算着色器:这是最大的质变!可以直接在 GPU 上进行通用计算(GPGPU),例如千万级粒子模拟、布料物理、复杂光栅化处理等,而无需再用 "Ping-Pong" 纹理技巧强行模拟。
  3. 更现代的 API:多线程友好的命令缓冲区,允许你在 Worker 线程中准备渲染指令。
  4. 统一渲染后端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 节深入讲解

思考题

  1. 如果一个场景中有 100 个静态的建筑 Mesh,你会采取哪些措施来优化它们的矩阵更新开销?如果这些建筑每隔 10 秒才会被移动一次呢?
  2. 解释 Geometry.setAttribute 和直接修改 geometry.attributes.position.array 的区别。在什么情况下必须手动将 needsUpdate 设为 true?
  3. "Vertex Shader 改变了物体的形状"——这句话准确吗?为什么调用了带有波浪 Vertex Shader 的材质后,在 CPU 端读取该 Geometry 的位置数组,数值依然没有变化?
  4. WebGPURenderer 相对于传统的 WebGLRenderer,其核心优势有哪些?为什么我们需要尽早拥抱 WebGPU?