Skip to main content

Three.js

写在前面

在高级前端或图形开发面试中,仅仅会调用 Three.js 的 API 拼凑一个场景是远远不够的。面试官更看重你对 WebGL 渲染流水线、矩阵变换、场景图(Scene Graph)、性能瓶颈与优化方案 的深度理解。

Three.js 本质上是对原生 WebGL 的高度面向对象封装,它屏蔽了复杂的着色器(Shader)编写、上下文管理和底层 Buffer 数据绑定,提供了一套更符合人类直觉的 3D 编程模型。

注意:Three.js 默认使用右手坐标系(x轴向右,y轴向上,z轴向屏幕外),这与 OpenGL/WebGL 的默认坐标系一致。


核心架构:场景图与渲染流

1. 场景图 (Scene Graph) 与 矩阵变换

这是 Three.js 中最重要的架构概念。整个 3D 场景在底层是一个有向无环图 (DAG),我们称之为场景图。

  • 场景图中的每一个节点都是一个 Object3D 实例(Mesh, Camera, Light, Group 都继承自它)。
  • 局部矩阵与世界矩阵:每个对象都有一个局部变换矩阵(记录相对于父节点的平移、旋转、缩放)。当渲染器进行渲染时,它会从 Scene 根节点开始递归遍历整棵树,把局部矩阵与父节点的世界矩阵相乘,计算出当前对象的世界矩阵(World Matrix),最终决定它在 3D 空间中的绝对位置。

2. 相机 (Camera) 与 视锥体

决定场景中哪些角度的内容会显示出来。最常用的是基于透视投影的 PerspectiveCamera,它会模拟人眼的视觉效果(近大远小)。

🎯 面试考点:视锥体裁剪 (Frustum Culling) 透视相机的四个参数(fov, aspect, near, far)定义了一个截头锥体(Frustum)。在每一帧渲染前,Three.js 会计算物体的包围盒(Bounding Box/Sphere),如果包围盒完全在这个椎体之外,该物体就不会被提交给 GPU 渲染。这是 Three.js 默认开启的核心性能优化手段。

3. 渲染器 (Renderer) 底层工作流

执行 renderer.render(scene, camera) 时,底层发生了什么?

  1. 更新矩阵:递归遍历 Scene Graph,更新所有有变动对象的世界矩阵。
  2. 收集与分类:收集所有的光源、网格对象(Mesh)。
  3. 视椎体裁剪:丢弃不在相机视野内的 Mesh。
  4. 排序 (Sorting):将不透明对象(从前向后渲染,利用 GPU 深度测试 Early-Z 剔除被遮挡像素)和透明对象(从后向前渲染,利用 Alpha Blending 正确混合颜色)分别排序。
  5. Draw Call 提交:将数据绑定到 WebGL 的 Buffer 中,挂载 Shader,向 GPU 发出 Draw Call 绘制指令。

场景基石:物体组成要素

在 WebGL 层,只有点、线、三角形。Three.js 通过以下概念将其抽象:

几何体 (Geometry / BufferGeometry)

定义了物体的形状和顶点数据。

  • 面试注意点:现代 Three.js 已经废弃了低效的 Geometry,全面拥抱 BufferGeometry。它的底层直接对应 WebGL 的 BufferAttribute(如 position, normal, uv),数据以一维强类型数组(TypedArray)的形式存储在内存中,极大提升了向 GPU 传输数据的效率。

材质 (Material) 与 纹理 (Texture)

  • 材质:定义了几何体表面的光学属性(如何对光照产生反应)。比如不受光照影响的 MeshBasicMaterial,以及基于物理渲染的 MeshStandardMaterial (PBR)。
  • 纹理:包裹到几何体表面的图像数据。一张 PBR 材质通常需要组合多张纹理(如颜色贴图、法线贴图、粗糙度贴图)。

网格 (Mesh)

Mesh = Geometry + Material。它是 3D 场景中最常见的可见对象。除了 Mesh,还有 Points(粒子点云)、Line(线段)等基类。


交互与动画

1. 动画驱动 (RequestAnimationFrame)

3D 场景的动画本质上是不断改变物体的属性并重新调用 render。必须使用 requestAnimationFrame 而不是 setInterval,以保证屏幕刷新率同步并避免后台标签页的无效渲染。复杂骨骼动画通常由 AnimationMixer 驱动。

2. 射线拾取 (Raycaster)

🎯 面试高频题:如何在 3D 场景中实现鼠标点击物体的交互? 因为屏幕是 2D 的,场景是 3D 的,直接绑定 click 事件是无效的。需要使用光线投射技术 (Raycasting)

  1. 将鼠标在屏幕上的 2D 坐标归一化(映射到 -1 到 1 的标准化设备坐标 NDC)。
  2. 基于相机的位置和方向,从鼠标位置向 3D 空间发射一条射线。
  3. 计算射线与场景中物体包围盒/三角面的相交情况,按距离排序返回结果。

3. 骨骼动画 (Skeletal Animation)

🎯 机器人 / 数字人岗高频题:骨骼动画是怎么驱动复杂角色的?

💡 30秒回答模板 “骨骼动画的核心是骨架 (Skeleton) + 蒙皮 (Skinning)。 在数据层,模型包含层级骨架结构,以及每个顶点对应的骨骼索引 (skinIndex) 和权重 (skinWeight)。 在运行层,通过 AnimationMixer 推进时间,计算出当前帧的骨骼变换矩阵。最后在 GPU 顶点着色器中,基于权重把顶点坐标与关联骨骼的矩阵相乘,算出最终位置。这套机制的本质是用极少量的骨骼矩阵运算,去驱动几十万顶点的形变。”

⚡ 为什么能用少量骨骼驱动几十万顶点?

这是机器人与游戏引擎中极致优化的体现,核心在于任务解耦

  1. CPU 计算量极小

    • CPU 只负责处理“骨骼树”的变换(通常只有 50-100 根骨骼)。
    • 计算结果是一个包含几十个矩阵的数组,被称为 骨骼矩阵调色板 (Bone Matrix Palette)
    • 每帧只需上传一次这个小数组到 GPU。
  2. GPU 并行爆炸输出

    • 矩阵 × 向量 的繁重计算被丢到了 GPU。
    • GPU 拥有成千上万个核心,可以同时为 20 万个顶点执行着色器代码。
    • 每个顶点根据自己的 skinIndex(我是哪根骨头带动的?)去“调色板”里抓取对应的矩阵进行相乘。
  3. 数据复用:- 一根“大腿骨”的矩阵,会被该大腿区域的上千个顶点共同引用。矩阵只算一次,渲染时各处复用。

    • GPU 做“应用题” (Matrix-Vector):负责体力。将 CPU 算好的矩阵,乘以模型上的几十万个顶点坐标。GPU 擅长这种大规模并行计算。

🚀 进阶:CPU Skinning vs GPU Skinning

特性CPU Skinning (旧/非主流)GPU Skinning (Three.js 默认)
计算位置JS 主线程遍历所有顶点Vertex Shader 并行计算
传输压力极高:每帧都要上传所有顶点坐标极低:每帧仅上传骨骼矩阵数组
瓶颈点PCIe 总线带宽 + CPU 单线程计算GPU 寄存器数量限制 (骨骼数上限)
结论顶点数超过几千就会造成明显掉帧轻松驱动几十万顶点的复杂角色

为什么骨骼数有限制? 因为 GPU 的 Uniforms(寄存器)空间有限。在 Three.js 中,如果骨骼数量超过了 Uniform 限制,它会自动切换到 FloatTexture(将矩阵存入纹理)来绕过这个限制,从而支持更复杂的机器人模型。

🦴 整体数据流与骨架层级

1. 渲染数据流(从模型到屏幕)

建模阶段 (Blender / Maya):
.glb / .fbx 模型
↓ 包含 3 类数据:
① 骨骼树 (Bone Hierarchy)
② 顶点-骨骼权重 (Skinning Weights)
③ 关键帧序列 (Keyframes)

Three.js:
SkinnedMesh + Skeleton + AnimationClip

渲染循环: mixer.update(delta) 每帧推进时间

顶点着色器: 骨骼矩阵 × 蒙皮权重 → 顶点新位置

2. 机器人骨架层级示例 父骨骼变换会级联影响子骨骼(如肩膀转动,整个手臂会跟着转动):

root
├── Hips
│ ├── Spine
│ │ ├── Chest
│ │ │ ├── Neck
│ │ │ │ └── Head
│ │ │ ├── LeftShoulder → LeftArm → LeftForearm → LeftHand
│ │ │ └── RightShoulder → RightArm → RightForearm → RightHand
│ └── LeftUpperLeg → LeftLowerLeg → LeftFoot
│ └── RightUpperLeg → RightLowerLeg → RightFoot

1️⃣ 底层数据与原理

面试官往往会深挖骨骼和蒙皮的底层限制与数学原理:

  • 蒙皮数据结构:在 BufferGeometry 中,每个顶点必须记录绑定信息。
    • skinIndex (Uint16Array):绑定的骨骼索引,每个顶点对应 4 个。
    • skinWeight (Float32Array):每根骨骼的影响权重,和为 1.0。
  • 4 根骨骼的硬限制追问:为什么最多绑 4 根? 因为受限于 GPU 顶点着色器的寄存器数量(vec4 刚好塞下)。如果单顶点绑定更多骨骼,需拆分多个 Render Pass,性能代价巨大。
  • 关键帧插值 (Keyframes)
    • 位置/缩放:线性插值 (Lerp)。
    • 旋转:必须使用四元数 (Quaternion) 进行球面插值 (Slerp)追问:为什么不用欧拉角? 因为欧拉角有万向节死锁(Gimbal Lock,某两轴重合导致丢失自由度)问题,且插值时不平滑(会出现诡异的翻转)。

2️⃣ 源码链路与实战陷阱

Three.js 的骨骼动画体系有明确的职责划分,结合代码更容易理解:

  • AnimationClip:存数据(比如包含"走路"所有 Track 的集合)。
  • AnimationAction:控制器(负责单段 Clip 的 play/pause、播放速度等状态)。
  • AnimationMixer:全局时钟。必须在 requestAnimationFrame 里调用 mixer.update(delta) 推进时间。

基础运行代码模板

// 1. 加载带骨骼动画的模型 (.glb 包含模型结构与 AnimationClip 数组)
const gltf = await new GLTFLoader().loadAsync("robot.glb");
scene.add(gltf.scene);

// 2. 创建播放器 (Mixer) 并绑定模型
const mixer = new THREE.AnimationMixer(gltf.scene);

// 3. 提取动画数据并创建控制器 (Action)
const walkAction = mixer.clipAction(gltf.animations[0]); // 假设 [0] 是走路动画
walkAction.play();

// 4. 渲染循环:驱动时间轴
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
// 关键步:每帧根据真实流逝时间更新动画状态
if (mixer) mixer.update(clock.getDelta());
renderer.render(scene, camera);
}

⚠️ 工程踩坑:动作切换跳变 直接 walk.stop() 后接 run.play() 会导致模型手脚瞬间抽搐(瞬移)。 解法:必须使用动画混合 (Blending)

walkAction.crossFadeTo(runAction, 0.5, true);
walkAction.play();
runAction.play();

底层原理:在 0.5 秒内,两个 Action 同时运行,走路权重 1→0,跑步权重 0→1。GPU 计算顶点时把两个动作的变换结果进行线性加权,实现平滑过渡。

// 🧠 顶点着色器 (Vertex Shader) 伪代码:
// 每一帧,CPU 会把当前所有活跃动画的混合权重传给 GPU

void main() {
// 1. 分别计算两个动作对当前顶点的变换
vec4 posWalk = walkBoneMatrix * vec4(position, 1.0);
vec4 posRun = runBoneMatrix * vec4(position, 1.0);

// 2. 根据混合权重 (alpha) 进行线性插值 (LERP)
// 假设 alpha 从 0.0 (全走路) 变化到 1.0 (全跑步)
float alpha = mixWeight;
vec4 blendedPosition = mix(posWalk, posRun, alpha);

// 3. 最终投影到屏幕
gl_Position = projectionMatrix * modelViewMatrix * blendedPosition;
}

🧠 面试 Q&A 快问快答

  • Q:骨骼动画 vs 顶点动画 (Morph Target) 怎么选?
    • 骨骼动画:适合关节明确的角色(人/机器人/四足)。数据量极小,性能好,支持运行时动态调整 IK(反向动力学)。
    • 顶点动画:适合无规律的软体形变(捏脸、细微的面部表情)。预先烘焙形态,运行时进行权重混合,数据量和显存占用较大。

进阶:性能优化

3D 应用的性能瓶颈通常在于 Draw Call 过多和显存溢出。关于如何进行几何体合并、实例化渲染、纹理模型压缩以及内存泄漏管理等大厂必考的优化方案,详见 性能优化


知识延伸导航