☰
Three.js渲染链路与性能边界:从场景图到draw call优化
2026/11/2 9:55:15 网站建设 项目流程

产品同事一句"这块能不能做成能转的3D展示",往往就是前端同学第一次接触 Three.js 的起点。很多人以为它是个"3D 动画库",实际上它更像一层把 WebGL 的繁琐细节包起来的场景描述层:你用对象描述"世界里有什么、相机在哪、光从哪来",它负责把这些翻译成 GPU 能吃的指令。这个定位搞清楚了,后面学起来会顺很多。这篇内容想解决的问题很具体——把 Three.js 的核心抽象、渲染链条、材质光照体系和性能边界一次讲透,让刚上手的人少走几个月的弯路,也让已经能跑起 Demo 的人知道自己卡在哪一层。文中会穿插大量实际项目里踩出来的判断标准和排查顺序,不追求 API 大全,只讲那些真正影响你能不能把东西交付出去的部分。

1. Three.js 站在浏览器 3D 链路的哪一层

刚接触的人常有个误解,觉得 Three.js 是"自己实现了一套 3D 渲染"。不是的。浏览器里真正干活的是 WebGL(现在新版本是 WebGL2,底层对应 OpenGL ES 3.0 的能力集),它给你的东西极其原始:编译着色器、绑定缓冲区、设置状态、发起绘制。你画一个立方体,需要的代码量大概是 Three.js 里写一个Mesh的十几倍,而且这些代码高度重复且难以维护。

Three.js 做的事情是把这一段封装起来,同时提供一套符合直觉的心智模型。你声明一个球体放在 (0, 0, 0),声明一个相机在 (0, 0, 5) 看向原点,声明一盏平行光,然后渲染。至于球体的顶点数据怎么塞进缓冲区、法线怎么传、着色器怎么编译、绘制调用怎么组织,它全包了。

1.1 一次绘制从你的代码走到 GPU 经历了什么

理解这条链路,后面所有"为什么这么慢""为什么这个参数改了没反应"的问题才有答案。以渲染一个网格为例,大致是这样几步:

第一部分是几何数据上传。你创建一个BoxGeometry,Three.js 会在内部生成顶点位置、法线、UV 这些属性数组,包装成BufferAttribute,再交给BufferGeometry管理。首次渲染时,这些数据被送到显存里的顶点缓冲对象(VBO)。注意这一步是有成本的,且通常只发生一次——所以几何体对象该复用的地方一定要复用。

第二部分是着色器编译。每种材质类型、每组启用的特性(有没有贴图、有没有阴影接收、有没有顶点色)都会生成不同的着色器变体。编译发生在第一次渲染该材质的时候,这是新手最常见的"第一帧卡顿"来源。项目里第一次加载新材质时页面顿一下,八成就是它。

第三部分是状态设置与绘制调用。渲染器遍历场景树,对每个可见网格决定用哪个程序、绑定哪些纹理、设置哪些 uniform,然后调用一次drawElements。一次这样的调用就是一个 draw call。draw call 的数量,是移动端 Web 3D 项目最核心的性能指标之一,远比三角形总数更能说明问题。

第四部分是光栅化与片元着色。GPU 把三角形变成像素,跑片元着色器算颜色,写进帧缓冲,最后呈现到 canvas。这一步的开销主要跟屏幕分辨率、像素比、以及着色器的复杂程度相关——所以高分屏上setPixelRatio不设上限,帧率直接腰斩。

把这条链路记住,遇到性能问题就不会瞎猜了:掉帧到底是 CPU 侧提交命令太多(draw call 爆炸),还是 GPU 侧填充率不够(分辨率/后处理/透明叠加太多),这两类的优化手段完全不同。

1.2 为什么值得用一层抽象而不是直接写 WebGL

有个同学问过我,直接用 WebGL 是不是更快、更"底层更强"。理论上你可以对每个细节做极致控制,但代价是:相机矩阵的推导、坐标系的转换、光照公式、阴影贴图的采样、拾取射线求交,全得自己来。一个能用的项目做下来,你基本等于重写了一遍 Three.js 的几何和材质部分,还没它写得稳。

Three.js 的价值不在于性能极致,而在于把 3D 图形的通用问题标准化。场景图结构、矩阵自动更新、材质分层、内置拾取、动画系统、加载器生态,这些东西只要你自己写过一遍就知道有多耗神。它还有一层隐性的好处:社区里绝大多数 3D 模型格式、纹理压缩方案、后处理效果,都有现成的对接代码,你不需要从零适配。

当然它也有边界。极端定制化的渲染管线(比如某些特殊的光照算法、非真实感渲染的独特风格),你会发现自己要不断绕开它的抽象,这时候直接写自定义着色器甚至自己搭管线反而更干净。判断标准很简单:当你发现自己在和框架对抗,而不是在用它表达意图时,就该考虑往下一层走了。

1.3 引入方式与项目结构的几种取舍

工程上怎么引进来,取决于项目形态。三种常见做法各自适用:

引入方式适用场景注意点
构建工具安装依赖现代前端项目,需要按需打包体积靠 tree-shaking 控制,注意导入路径
CDN 直接引入演示页、单文件 Demo、教学版本固定写死,避免线上行为漂移
局部引入模块老项目里嵌一小块 3D 展示注意与其他全局变量的冲突和加载时机

用构建工具时有个细节值得提醒:Three.js 的模块入口有多个,从主包导入会带上不少你可能用不到的部分。如果体积敏感,可以从具体子路径导入,比如几何体、加载器、控制器各自单独引。另外版本号必须锁死,这个库的演进速度不慢,色彩空间、光照单位的默认值在相近的几个版本里都改过,锁不住版本就会出现"本地好的,线上颜色不对"这种玄学问题。

工程结构上,我习惯把 3D 部分切成四块:场景构建(场景、相机、灯光、模型的装配)、渲染循环(时钟推进、更新、渲染)、交互层(鼠标键盘、射线拾取、控制器)、资源层(加载、缓存、释放)。这样切的好处是渲染循环里只有"调度",没有业务,谁改谁负责,出问题好定位。

2. 场景图、相机与渲染器:三根支柱的真实分工

几乎所有 Three.js 入门代码都是同一个骨架:建场景、建相机、建渲染器、加个物体、开个循环。骨架好抄,但每一行背后的语义,抄的人往往没细想,于是后面就出现"物体加了不显示""移动相机画面不动""窗口缩放就变形"这些经典问题。

2.1 Scene 不是画布,是一棵会被遍历的对象树

Scene的本质是一个容器,它继承自Object3D,所以它本身也是一个节点。你往里加的每个东西都是这棵树的子节点,而每个节点带着自己的位置、旋转、缩放(也就是局部变换矩阵)。渲染的时候,引擎自顶向下把局部矩阵乘起来,得到每个节点的世界矩阵,再根据世界矩阵和相机算出最终的 MVP 矩阵。

这个结构的意义在于组合。想让一个模型围绕某个点公转,又自带自转,最省事的做法就是套两层:外层放公转的父节点,内层放自转的模型。父节点转,子节点会跟着走,这是场景图天然给的,不需要你手算坐标。删掉父节点,整组东西一起消失,管理也方便。

有个概念容易被忽略——"加进场景"和"被渲染"不是一回事。一个对象只要在场景树里、且没被标记为不可见、且包围盒通过了视锥剔除,就会被提交渲染。反过来,如果你创建了对象但忘了add到场景(或它的某个祖先),它永远不会出现在画面上,而且不会报任何错。这是排查"东西不显示"时第一件要确认的事。

还有一点:手动改完位置、旋转之后,如果需要立刻读取世界矩阵(比如做拾取、计算距离),必须先调一次updateMatrixWorld()。渲染循环里渲染器会自动更新,但在循环外读取就得自己触发,否则拿到的是上一帧的旧值。

2.2 相机参数决定你看到什么,透视与正交的取舍

透视相机模拟人眼,近大远小,是绝大多数场景的默认选择。它的几个参数里,fov(视场角)和aspect(宽高比)直接影响构图。aspect必须和 canvas 的实际宽高比一致,否则画面会被拉伸——这就是窗口 resize 时忘记更新相机导致变形的根因,解决方式是 resize 回调里同时更新camera.aspect并调用updateProjectionMatrix()。

near和far这两个参数看着不起眼,却和深度精度直接相关。深度缓冲的精度是有限的,如果near设得特别小(比如 0.0001)而far特别大(比如 100000),远近两端的深度值会挤在一起,表现为模型表面出现条纹状闪烁(z-fighting),尤其是两个面几乎重合的时候。经验做法是让near和far尽量贴近你场景的真实范围,不要图省事写个天文数字。

正交相机没有透视缩短,远处的物体和近处一样大。它在工程类、数据可视化类场景里用得很多,比如建筑平面示意、等距视角的示意图、UI 里的装饰性 3D。切换相机类型时注意,orthographic的构图形参是上下左右边界而不是视场角,换算逻辑不一样。

另外,相机本身也是场景图节点,可以挂到别的对象下面。把相机挂到某个模型底下,模型转相机跟着转,做第一人称视角或者"坐在车里往外看"的效果会很自然。

2.3 渲染循环为什么必须自己写,且不能写歪

Three.js 不接管你的主循环,你得自己驱动。最简写法是用requestAnimationFrame反复调用渲染。这里有几个非常实际的经验点:

第一,别在循环里创建对象。每帧new Vector3()、new Color()这类操作看起来微不足道,但一秒钟六十次,几分钟下来垃圾回收的压力就上来了,表现是隔一会儿卡一下。把需要复用的临时对象提到循环外面,作为模块级变量存在。

第二,用时钟算增量,不要假设每帧固定耗时。不同设备帧率天差地别,若按"每帧移动固定距离"写动画,高刷屏上速度会快一倍。正确做法是拿到两次渲染之间的时间差,乘上速度。Three.js 提供的Clock就是干这个的,注意getDelta()会消耗时间累积,别在同一帧里调两次。

第三,页面不可见时停掉循环。切到别的标签页时继续跑,既浪费电又会让返回来时的状态异常。监听页面可见性变化,隐藏时取消动画帧,显示时重新启动,顺便重置一下时钟,避免累积出一个巨大的时间差导致画面瞬移。

第四,优先用渲染器自带的循环接口(较新版本提供的setAnimationLoop)。它在普通浏览器里等价于requestAnimationFrame,但在一些支持沉浸式显示的环境里会自动适配帧率,省得你自己判断。具体行为随版本演进而异,用之前看一眼当前版本的文档即可。

3. 材质与光照:让画面"正确"远比"好看"难

新手最常问的问题是"为什么我的模型是黑的"。九成情况不是模型坏了,而是材质和光照没配上。这段是 Three.js 里概念密度最高的部分,也是最容易凭感觉乱调的部分。

3.1 材质选型的判断依据

材质决定了光线如何与表面交互。选择哪种,取决于你需要多少"物理正确性"以及能付出多少性能。

基础材质(MeshBasicMaterial)完全不受光照影响,颜色就是你给的颜色。它适合做 UI 元素、纯色示意、发光体、背景贴图。很多人一开始用它,因为"怎么都不会黑",但也就意味着它没有立体感。

兰伯特和冯氏这类材质计算量小,能表现明暗,但对高光和粗糙度的刻画比较粗糙。老项目里常见,现在新项目建议直接跳过。

标准材质(MeshStandardMaterial)基于物理的渲染模型,需要环境贴图才能体现真实感,是目前的主力选择。它的金属度、粗糙度两个参数撑起了绝大多数写实效果。参数越多,着色器越重,移动端上大量使用时要注意。

物理材质(MeshPhysicalMaterial)在标准材质基础上增加了清漆、透射、次表面散射等特性,用于玻璃、车漆、皮肤这类效果。它是性能大户,尤其是开启透射时,慎用在大面积物体上。

选型上有个很实用的判据:先问这个材质在画面里占多大面积、离镜头多近。占屏面积大且近的,值得用重材质;远处的小物件用轻材质,肉眼看不出差别,省下的算力可以花在别处。

3.2 光照的直觉建立与阴影的代价

三种基础光源值得先建立直觉:环境光提供全局的均匀底色,让暗部不至于纯黑;平行光模拟太阳,方向固定、光线平行,阴影是正交投影;点光源从一点向四周发散,随距离衰减,适合灯泡、火把。此外还有聚光灯(锥形范围)和半球光(天空色与地面色分别影响上下表面,做户外场景很省事)。

光照强度这个参数在版本迭代中变过默认单位,老代码迁到新版本可能整体偏暗或偏亮。遇到"升级后画面全变了",先查光照强度和色彩空间的默认值变化,别急着改材质。

阴影是性价比最低的效果之一,也是最容易拖垮帧率的。开启阴影需要:光源设置castShadow,物体设置castShadow,地面之类的接收面设置receiveShadow,三者缺一画面就没有影子。同时阴影贴图的分辨率、阴影相机的视锥范围都要调,范围开太大精度就散,边缘出现锯齿和断裂;开太小,远处物体的影子被裁掉。

实测下来比较稳的配置是:阴影贴图尺寸按设备能力分档(桌面 2048,移动端 1024 或 512),阴影相机范围紧贴场景实际需要覆盖的区域,阴影类型选软阴影但要评估开销。如果一个场景里只需要一个方向的影子,就别开第二盏投影光源,每多一盏,整个场景的几何体就要多渲染一遍到阴影贴图里。

3.3 色彩空间与色调映射这两个隐形杀手

这是我认为最值得单独拿出来讲的一段,因为它导致的 bug 极其隐蔽——画面不是黑也不是崩,就是"颜色不对劲",说不上哪里错。

渲染器有一个输出色彩空间设置,纹理也有自己的色彩空间设置。颜色贴图(漫反射贴图)应该按 sRGB 解读,而法线贴图、粗糙度贴图、金属度贴图这类数据贴图必须保持线性。搞反了的结果是:整体偏灰发白,或者暗部死黑没有层次。我的习惯是给每张贴图显式指定色彩空间,不依赖默认值,虽然多写几行,但省掉后面几小时的排查。

色调映射负责把高动态范围的颜色压到显示器能显示的范围。开启后,亮部的过曝会更自然,画面整体更有"摄影感";不开启则颜色更"生",但对比强烈。曝光参数配合调整,能让同一个场景在明暗上差出很大一截。这块没有标准答案,靠调,但至少要意识到它的存在——很多人调了半天灯光强度,其实问题出在色调映射。

另外还有雾效。加一点雾能显著改善远处的"硬边"和层次感,成本极低,是提升画面观感性价比最高的手段之一。雾的颜色记得和背景色对齐,否则远处会出现一条生硬的分界线。

4. 帧率掉下来的真实原因与排查顺序

性能问题最怕瞎优化。我看到过有人一上来就把模型面数砍掉一半,结果帧率一点没变,因为瓶颈根本不在三角形数量上。正确的做法是按链路定位瓶颈,从 CPU 到 GPU 逐层排除。

4.1 先量 draw call,再谈其他

渲染器的信息对象里能读到当前帧的渲染统计,包括绘制调用次数、三角形数量、纹理数量等。这是你的第一手数据,别猜。

判断标准大致是这样:桌面端 draw call 控制在几百以内通常没问题,移动端希望压到一百以内甚至更低;三角形数量对现代 GPU 来说不是主要压力,几十万面在桌面端都能跑,但如果配合大量的透明叠加和高分辨率,填充率就会成为瓶颈。

如果 draw call 数量远超预期,往下查三件事:是不是同一个几何体被大量独立实例化了;是不是材质被复制了很多份(材质不同就无法合批);是不是有大量独立的小物件(粒子、标记、装饰)。这三种情况对应三种不同的解法,后面细说。

如果 draw call 很少但依然卡,那大概率是 GPU 侧的问题:片元着色器太复杂、透明物体互相叠加导致过度绘制、后处理链太长、像素比太高。把像素比从设备值降到 1.5 或 1,看帧率变化,这是一个非常快的判断手段——如果降下来立刻流畅,那就是填充率问题,跟模型复杂度没关系。

4.2 几何体与材质复用:一个反复出现的错误

写循环批量生成物体时,最典型的错误是这样:每次循环都创建新的几何体和新的材质。看起来代码很自然,实际后果是每个物体都是独立的绘制批次,一百个物体就是一百次 draw call,而且显存里躺着一百份一模一样的顶点数据。

正确做法是把几何体和材质提到循环外面创建一次,循环里只创建网格对象并引用它们。这样一百个物体在显存里只有一份数据,虽然还是各自绘制,但至少内存和上传开销省下来了。这个改动通常不改变 draw call 数量,但能显著降低显存占用和首帧时间,是必须做的基础功。

更彻底的做法是实例化渲染。引擎提供的实例化网格(InstancedMesh)能用一次绘制调用画出成百上千个同形状物体,每个实例有自己的变换矩阵,还能带实例颜色。适合的场景很明确:大量完全相同的几何体,只是位置、朝向、颜色不同——花草树木、砖块、人群、数据点。不适合的是每个物体形状都不一样的情况,那时候该考虑合并几何体。

合并几何体(把多个静态几何体烘焙成一个)是另一个思路,适合那些永远不动、永远一起出现的装饰性物体。合并之后 draw call 直接降到一次,代价是失去了独立控制的能力,且合并过程本身有 CPU 开销,动态场景里不要用。

4.3 显存泄漏与资源释放的对号入座

这条是线上项目翻车率最高的地方,尤其是单页应用里反复进出 3D 页面。

显卡上的资源(几何体的缓冲、纹理、着色器程序)不受浏览器垃圾回收管理。你把对象从场景里移除、把变量置空,JS 侧的内存是回收了,但显存里的那份数据还占着。来回切换几次,显存就爆了,表现是页面卡死甚至直接崩掉。

释放的规则是谁创建谁释放,且纹理要单独释放:几何体调自己的释放方法,材质调自己的释放方法,材质上挂的每一张贴图也要分别释放。材质的释放不会连带释放它的贴图,这一点特别容易漏。

一个稳妥的写法是给资源加一层登记:加载和创建的资源统一扔进一个集合,页面销毁时遍历集合逐个释放,然后清空集合。这比事后到处找漏网之鱼靠谱得多。另外,切换页面时记得停掉渲染循环、移除事件监听、释放控制器——这些不是显存问题,但同样是泄漏源。

提示:判断是否泄漏有个土办法,反复进出同一个 3D 页面十几次,观察内存曲线。如果每次回来都比上一次高一个台阶且不回落,基本可以确定有资源没释放。

5. 模型加载与资源链路:线上最容易翻车的一段

Demo 阶段用手搓的几何体就够了,真做项目一定是美术给模型。这一段看着简单——加载、加进场景、完事——但实际环节多,每个环节都能出问题。

5.1 格式选择与压缩链路怎么定

glTF 是当前的事实标准,文本版可读性好、体积大,二进制版(.glb)把模型、贴图、材质、动画打成一个文件,体积小、加载快,线上优先选它。它本质上是一份描述文件,真正的几何数据可能内嵌也可能外链,打包前确认一下别漏文件。

模型体积的大头通常不在几何,而在贴图。压缩要分层做:几何部分用网格压缩,能把顶点数据压到原来的几分之一,代价是解压需要额外的解码模块;贴图用 GPU 友好的压缩格式,直接以压缩态驻留显存,既省带宽又省显存,代价是需要一套转码工具链,且不同平台的格式支持有差异。

这里有个判断标准值得记住:只在网络传输上省是不够的,贴图最终要解压成原始尺寸进显存。一张 4K 的图不管传输时压得多小,进显存就是几十兆。所以贴图该降分辨率就降,该分块就分块,别指望压缩算法变魔术。

5.2 首屏体验与加载策略的权衡

3D 内容体积天然比普通页面大,硬等会让人觉得页面挂了。常见的处理组合是:先渲染一个极简的占位场景(背景色、几根线框、甚至只是一个转圈的进度),主资源在后台加载,进度回调里更新百分比,加载完成后一次性淡入。

进度回调给的是"已加载字节比例"还是"已完成项比例",不同加载器行为不同,用它做进度条时最好自己再做一次平滑,避免数字跳变。另外要注意,模型解析(尤其是压缩网格的解码)是同步阻塞的,会在主线程上卡一下,大模型建议放进 Worker 里解析,解析完再把结果传回主线程装配。

还有个容易忽视的点:加载完成不等于渲染就绪。模型刚进场景的第一次渲染会触发着色器编译,可能卡个几百毫秒。如果在意这个体验,可以提前用一次离屏渲染把材质"预热",或者在加载遮罩消失前先渲染一帧。

5.3 几个真实踩过的坑

第一个坑是坐标系和朝向。不同建模工具导出的模型,坐标轴朝向、单位尺度都不一样。同一批模型有的朝前有的朝后,表现为"明明摆好了,一进去是背对镜头的"。解决办法是统一在导出环节确认轴向,或者在加载后统一做一次旋转校正,别在代码里针对每个模型写特例。

第二个坑是材质参数在导出时丢失。有些自定义着色效果在导出后变成灰模,因为目标格式不支持那个节点。这时候要么在引擎侧重建材质,要么在导出前烘焙成贴图。我的习惯是收到模型先做一次"导入体检":看一眼材质数量、贴图是否齐全、有没有动画、包围盒尺寸多少,几个数字心里有数。

第三个坑是包围盒和中心点。美术做模型时原点可能不在几何中心,导致旋转时物体绕着奇怪的点甩出去。加载后算一下包围盒,把几何体平移到中心,或者用一个父节点包一层做偏移,能省掉后面一堆对齐问题。顺便,知道包围盒尺寸也方便你自动算相机距离,不用手动试参数。

第四个坑是多实例共享同一份资源。同一个模型在场景里出现多次时,几何体和材质应该是共享的,只有变换不同。如果每次加载都重新解析一遍文件,内存直接翻倍。加载层做一层缓存,按资源地址做键,命中就复用。

6. 从能跑到能交付:给不同阶段的上手路径

最后一件事想聊的是学习路径,因为 Three.js 的 API 面太宽,漫无目的地翻文档很容易陷进去出不来。

刚开始的阶段,目标应该定得很低:能跑通一个转动的立方体,理解场景、相机、渲染器三者的关系,知道窗口变化要更新什么。然后往上加:换几何体、换材质、加一盏光、加一个控制器让鼠标能拖动视角。这一圈下来,你对整条链路就有手感了。

第二个阶段重点解决"加载真实模型"和"交互拾取"。拾取这块值得专门花时间,因为它是 3D 页面产生业务价值的主要途径——点中一个物体弹信息、悬停高亮、拖拽摆放,底层都是射线求交。射线求交的计算成本跟物体数量成正比,物体多的时候要注意优化,比如先用包围盒粗筛。

第三个阶段才是性能和画质。什么时候该合并、什么时候该实例化、阴影怎么配、后处理加不加,这些前面都讲过判断依据。这个阶段最有效的方法不是背优化清单,而是养成看统计数据的习惯:每次改动前后记一下绘制调用数、三角形数、帧时间,有对比才有判断。

注意:别过早引入复杂的渲染特性。见过不少项目一开始就上后处理、上全局光照、上体积雾,结果在目标设备上连三十帧都保不住,最后全拆掉重做。先把基础场景在最低配设备上跑顺,再逐项往上加,加一项测一次,这才是能落地的顺序。

我个人在实际项目里的体会是,Three.js 真正的门槛不在 API 记忆,而在对渲染链路的直觉。有了这个直觉,你看到画面不对能立刻缩小范围:是没进场景、是被剔除了、是材质黑了、是光没打到、还是颜色空间错了。这五种情况的排查顺序,比记一百个参数名有用。至于那些更花哨的效果,等基础打牢了再回头看,会发现它们不过是同一套逻辑的组合。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询