下面这些数字全部来自我这个基底的内部实现。因为是我自己踩过的坑,所以可以讲得具体一点:每一条都给出机制和实测值,而不是"建议优化性能"这种正确的废话。
这篇讲三件 3D 前端最容易踩、而且不会报错的事:draw call 堆高、点光源数量变化触发全场景重编译、着色器编译阻塞主线程。顺带说说颜色正确性------它也不报错,但不报错不等于不严重。
标签 :Three.js WebGL 性能优化 3D 前端
先说一个判断:性能问题里,最难缠的不是最慢的那个
新手调 3D 性能时,第一反应是"帧率低就降画质"。但实际做过一轮就会发现:真正难缠的,是那些帧率看着还行、但埋着隐患的问题。
它们的特点是------不报错。你在开发机上测,一切正常;换台机器、换个场景、用户一多就出问题,而且查不到原因。
下面三件是我实测撞过并且解掉的,都属于这类。
一、draw call:合批才是根治,删模型不是
机制
每次绘制调用(draw call)都有固定开销。场景里 3000 个物体、每个一次调用,光是"发起调用"这一项就吃掉大量 CPU 时间。
把同一批材质的东西合并成一次调用,调用次数降下来了,但这一次调用里要处理的顶点数据变多了------所以不是无限合批,通常要在"调用次数"和"单次调用数据量"之间找平衡点。
实测
我们做过一次几何合批:
| 场景 | 合批前 draw call | 合批后 | 降幅 |
|---|---|---|---|
| 密集区 | 3064 | 837 | −73% |
| 加载期占位场 | 2150 | 956 | −56% |
密集区是 3D 项目里最容易崩的地方------用户视角转过去就是一片密集模型。这个数字说明:合批之后,密集区的调用开销降到了原来的四分之一不到。
但更值钱的是"占位场"那一行
加载期占位场那个 −56% 容易被忽略,但它解决的是一个体验问题:加载的时候先用一个全图占位场撑住,一个 draw call 覆盖 1071/1071 个对象,模型就位后再用 250 毫秒渐缩退场。
用户永远先看到一个完整的世界,而不是一堆散件。 这一点比帧率数字更影响观感。
⚠️ 不要用"删场景"救帧率
帧率不够就删模型,是把技术问题换成了产品问题。性能问题有治理路径,删内容只是把账欠着------而且用户会感觉到"东西变少了",只是说不清为什么。
二、点光源数量变化:一次改动,8989 毫秒全场冻结
机制
这是三件事里最隐蔽的一个。
场景里用了点光源(PointLight)之后,每当你增删一个点光源,所有材质都要重新编译着色器。因为每个材质的渲染逻辑取决于"当前场景里有几盏灯、分别是什么类型"。
而着色器编译在不同平台上行为不一样:有些平台(Windows 的 ANGLE/D3D11 路径上)是同步且不可中断的。也就是说,编译期间主线程被死死占住,画面完全冻住。
实测
八个多秒的卡死。如果用户正在操作、或者正在加载进一个房间,就是一次"页面死了"级别的体验。
而且它没有任何日志。 帧率监控只会告诉你"这里卡了",不会告诉你是灯的问题。
解法:灯池
思路很简单:不要让灯的数量变化。
固定 12 盏灯常驻在场景里,永远不增不减。需要更多灯的时候,从池子里"租"一盏,设定它的位置、颜色、强度;不用了"归还"------归还时不是移除,而是把强度设为 0 并移到场景外。
因为灯的数量恒定,材质重编译的次数归零。
这个改动的成本很低(就是维护一个灯对象的数组),收益是消除了一类最严重的卡顿。
三、着色器编译阻塞主线程:给编译上"预算"
机制
新网格第一次进入视野时,需要现场编译它的着色器。这个编译是同步的,编译期间画面是死的。
场景小的时候,编译一两个网格感觉不出来。但如果是一次进一个房间,或者角色换装、模型批量入场------几十个网格排队等着编译,画面就一卡一卡地顿。
解法:预热预算
两件事:
- 未预热的网格不入屏 ------ 还没编译完的,不要让它出现在画面里(宁可先不显示)
- 编译后按每帧固定份额结算 ------ 编译完成后,不是立刻全部入屏,而是按每帧 170 毫秒的份额,一个一个入屏
实测
关键不是"最坏情况 386 毫秒"这个数字,而是 "没有任何一帧超过 500 毫秒" 。因为 500 毫秒以上用户能明显感觉到"顿了一下",而 386 毫秒在正常交互里是可以接受的。
四、怎么知道该优化哪一项
上面三件事有个共同点:你不会自动收到提示。 所以需要一个主动的归因手段。
我用的做法是三个诊断接口:
| 接口 | 回答什么问题 |
|---|---|
onBeforeRender 逐对象计数 |
现在这一帧有哪些对象在画、各自调用了几次 |
diagTurnLag 转视角归因 |
转视角卡顿是哪些对象造成的 |
diagTurnSweep 全场扫描 |
整个场景里谁最重 |
为什么必须自己建这个能力:性能问题没有报错日志,你不主动去问,就只能靠猜。而猜的成本极高------你会花一天怀疑模型数量,第二天怀疑贴图,第三天才想到灯。
有了归因接口,能直接看到"密集区 3064 次调用"、"这一帧有 12 盏灯"------问题从"有点卡"变成"这里"。
五、颜色正确性:另一个不会报错的问题
顺手提一个和性能无关、但同样不报错的坑。
Three.js 早期版本有个常见做法:输出按 sRGB 处理,贴图却按线性着色。两者叠加就是双重 gamma,画面会整体偏亮、偏粉,阴影发灰。
这个 bug 不产生任何报错,只表现为"质感差了点"。如果验收标准是"能跑起来",它会一直留在产品里。
验证方式不能靠眼睛。我们做版本升级时的做法是:
- 先立 10 张统一口径的基线截图(固定相机位置、固定参数)
- 写一个 PSNR / diff 工具逐张比对
- 把差异逐项归因
实测后台界面 43.4dB 一致,主世界的差异能全部归因到颜色管线修正,构图和角色像素级一致。
顺带做了一件事:加载前检测 WebGL2 支持,不支持时给一句明确的提示,而不是白屏或控制台刷错。
小结
这几件事的共同点值得再说一遍:
| 问题 | 为什么难发现 | 解法 |
|---|---|---|
| draw call 堆高 | 帧率只是"慢慢降" | 几何合批(实测 −73%) |
| 灯数变化 | 一次冻 8 秒多,无日志 | 灯池(灯数恒定,重编译归零) |
| 着色器编译 | 一卡一卡,不易关联 | 预热预算(每帧 170 毫秒份额) |
| 颜色双重 gamma | 画面"看着不对",不报错 | 基线截图 + PSNR 比对 |
它们都不会告诉你在哪里出错,所以你需要自己造工具去问。
六、为什么上面这些值得一次做完
标题里说「事半功倍」,这里讲清楚是哪一半------因为这句话很容易被读成广告。
上面这四类工作,你换成任何一个项目都要重做一遍。 一个展厅要做一遍,一个培训环境要再做一遍,一个带 AI 角色的空间还是要做一遍。它们和你的业务内容毫无关系:不管你这个世界里放的是展品、课程还是角色,draw call 怎么降、灯池怎么设计、着色器编译怎么摊预算,答案都是同一个。
所以把这四类成本一次做完、放进一个可复用的基底里,所有上层场景共享------这就是标题那句话的意思。省下来的是第二阶段的排查时间,不是第一阶段的编码时间。
(我做的就是这样一个基底:浏览器端、可部署在自有服务器上的 3D 虚拟世界。上面这些数字都出自它的内部实现,技术细节都在代码里。)
需要说清另一半:省下来的不是全部。 按层分一下的话,上面这四类属于底下那几层(渲染与资产管线、性能与多端、实时通信),它们和你的业务无关;最上面那层业务逻辑------你这个空间里放什么、访客能做什么、怎么算完成------该你写还是你写,一个字段都不会因为有基底而少写。 任何"有了基底就不用做业务了"的说法都是错的。**
这里也不给任何时间倍数。 要估的是:你这个场景里那四类问题各占多少工时。说得出数字,才知道哪些该自己写、哪些该拿现成的------这份清单同时也是验收清单。
至于什么时候应该反过来自己写、不用任何基底,下面几种情况是:只有一个静态页面且不需要联机(一个模型加一个网页就够了)、你学的正是这些机制本身(写一遍是最有效的学习方式)、约束是极低运行环境(通用方案塞不进去)、或者产品形态就是一次性交付(基底的价值在第三个月,做完就下线的话收不回来)。
七、"元宇宙"这几个字,具体落在哪些活上
标题里出现了元宇宙,拆开看其实很清楚:
| 元宇宙里常被说成什么 | 实际对应哪些活 | 谁负责 |
|---|---|---|
| "能进去" | 浏览器打开就能走、不用装客户端 | 基底 |
| "有空间" | 3D 场景、模型加载、资产分级、远近裁剪 | 基底 |
| "能一起玩" | 多人同步、移动与转向一致、掉线恢复、近距离语音 | 基底 |
| "能一直在线" | 账号与数据、资产管理、后台、不依赖第三方 | 基底 |
| "有内容" | 这个空间里放什么、访客能做什么、怎么算完成 | 你 |
| "有身份" | 角色身份:我是谁、别人怎么认出我、怎么持久化 | 你 |
| "有经济系统" | 虚拟资产、交易规则、结算逻辑 | 你 |
| "能跨空间" | 世界间互通、身份和资产怎么带过去 | 协议层,规则由你定 |
规律很清楚:被宣传时最强调的往往是最上面那几行,而最花时间的是下面那几行。 "有内容""有身份""经济系统"听起来最有想象力,实际上它们是业务逻辑,写起来快、也没有性能坑;反过来"能进去""有空间""能一直在线"这些听起来平淡的活,占了大半工期,还全都不会报错。
所以一个可复用的基底做的事,恰好是把下面那几行从你的待办里划掉。 你拿到的不是一个现成的世界,而是一块已经通电的地基------上面盖什么,地基是同一块。
边界提前说清:跨空间做的是协议层 (怎么握手、身份怎么迁移、同名怎么处理,实测 4/4 自动改名),规则归你。经济系统的结算、内容审核这类东西我们完全没做,也不打算做。
最后交代边界:上面所有数字都来自我们自己的项目和场景(浏览器端 3D 世界,一台服务器自部署),换个场景数值会不同。能复用的是这些机制和这套归因思路,不是这些数字------你的场景要自己测一遍。文中提到的移动端和 XR 表现我们尚未完整实测,也不做推断。
本文为开发过程中的技术复盘,数据来自项目内验收脚本,可复跑核验。列出的机制均为当前实现的如实描述,不作为后续承诺。