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) 时,底层发生了什么?
- 更新矩阵:递归遍历 Scene Graph,更新所有有变动对象的世界矩阵。
- 收集与分类:收集所有的光源、网格对象(Mesh)。
- 视椎体裁剪:丢弃不在相机视野内的 Mesh。
- 排序 (Sorting):将不透明对象(从前向后渲染,利用 GPU 深度测试 Early-Z 剔除被遮挡像素)和透明对象(从后向前渲染,利用 Alpha Blending 正确混合颜色)分别排序。
- 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):
- 将鼠标在屏幕上的 2D 坐标归一化(映射到 -1 到 1 的标准化设备坐标 NDC)。
- 基于相机的位置和方向,从鼠标位置向 3D 空间发射一条射线。
- 计算射线与场景中物体包围盒/三角面的相交情况,按距离排序返回结果。
3. 骨骼动画 (Skeletal Animation)
🎯 机器人 / 数字人岗高频题:骨骼动画是怎么驱动复杂角色的?
💡 30秒回答模板 “骨骼动画的核心是骨架 (Skeleton) + 蒙皮 (Skinning)。 在数据层,模型包含层级骨架结构,以及每个顶点对应的骨骼索引 (
skinIndex) 和权重 (skinWeight)。 在运行层,通过AnimationMixer推进时间,计算出当前帧的骨骼变换矩阵。最后在 GPU 顶点着色器中,基于权重把顶点坐标与关联骨骼的矩阵相乘,算出最终位置。这套机制的本质是用极少量的骨骼矩阵运算,去驱动几十万顶点的形变。”
⚡ 为什么能用少量骨骼驱动几十万顶点?
这是机器人与游戏引擎中极致优化的体现,核心在于任务解耦:
CPU 计算量极小:
- CPU 只负责处理“骨骼树”的变换(通常只有 50-100 根骨骼)。
- 计算结果是一个包含几十个矩阵的数组,被称为 骨骼矩阵调色板 (Bone Matrix Palette)。
- 每帧只需上传一次这个小数组到 GPU。
GPU 并行爆炸输出:
- 矩阵 × 向量 的繁重计算被丢到了 GPU。
- GPU 拥有成千上万个核心,可以同时为 20 万个顶点执行着色器代码。
- 每个顶点根据自己的
skinIndex(我是哪根骨头带动的?)去“调色板”里抓取对应的矩阵进行相乘。
数据复用:- 一根“大腿骨”的矩阵,会被该大腿区域的上千个顶点共同引用。矩阵只算一次,渲染时各处复用。
- 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 过多和显存溢出。关于如何进行几何体合并、实例化渲染、纹理模型压缩以及内存泄漏管理等大厂必考的优化方案,详见 性能优化。
知识延伸导航
- 原生 WebGL 基础:渲染管线、Hello Triangle、缓冲区绑定、WebGL vs WebGPU
- 坐标变换与矩阵:MVP 矩阵推导、坐标空间转换、四元数(Quaternion)避免万向节死锁
- 着色器 (Shader):GLSL 语法、ShaderMaterial 自定义材质实战
- 光照与材质:PBR 物理渲染、实时阴影(Shadow Mapping)的实现机制
- Web3D 进阶应用:后期处理(Post-processing)、粒子系统点云实战