前言:最近项目优化进入瓶颈,模型不想动(或者动不了),但帧率就是上不去。正当我准备上Blender、搞重型优化的时候,老大扔过来一句话:"先改代码,5分钟见效的那种。"我将信将疑试了试,好家伙,帧率从30fps直接干到60fps,GPU占用从100%降到60%,而且一行模型都没改!今天就把这5个代码小技巧分享出来,每个技巧的前因后果都讲清楚,看完立刻就能用。
如果你也遇到过这种情况:场景复杂、模型动不了,但性能就是差。别急着上重型优化,先看看这5个纯代码技巧,可能改几行就解决了。
技巧1:限制像素比,4K屏手机秒变流畅
问题现象
iPhone 14 Pro的屏幕像素比是3,Three.js默认按window.devicePixelRatio渲染。这意味着什么?
具体计算:
- 你的canvas在CSS里设了
width: 1920px; height: 1080px - Three.js内部会乘以
devicePixelRatio(iPhone 14 Pro是3) - 实际渲染分辨率 = 1920 × 3 = 5760px宽,1080 × 3 = 3240px高
- 总像素数 = 5760 × 3240 = 1860万像素
为什么GPU会去世:
- 普通显示器1920×1080 = 207万像素
- iPhone 14 Pro实际渲染1860万像素,是普通的9倍
- 每帧要填充1860万个像素,片元着色器跑9次工作量
- RTX 3080都顶不住,手机GPU直接爆炸
验证方法:
console.log('devicePixelRatio:', window.devicePixelRatio); // iPhone输出3
console.log('canvas实际尺寸:', renderer.domElement.width, 'x', renderer.domElement.height); // 输出5760 x 3240
解决方案
限制最大像素比为2,超过2的按2算:
// 原来(默认,坑!)
renderer.setPixelRatio(window.devicePixelRatio); // iPhone上=3,渲染5760x3240
// 优化后(限制最大2x)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // iPhone上=2,渲染3840x2160
为什么2x是甜点:
- 2x = 3840×2160 = 829万像素,是3x的44%
- 人眼在手机上超过2x基本看不出区别(视网膜屏极限)
- GPU负担减半,帧率翻倍
效果对比
| 指标 | 原来(3x) | 优化后(2x) |
|---|---|---|
| 实际渲染分辨率 | 5760×3240 | 3840×2160 |
| 总像素数 | 1860万 | 829万 |
| GPU像素填充工作量 | 100% | 44% |
| 帧率 | 25fps | 60fps |
| 肉眼观感 | 极致清晰 | 几乎没区别 |
为什么 iPhone 14 Pro 的像素比是 3?
苹果为了 Retina 清晰度,把 852×393 的逻辑分辨率,做成了 2556×1179 的物理分辨率(3×3 倍)。人眼根本看不出 2x 和 3x 的区别,但 GPU 要多算 125% 的像素。限制 2x 是白捡的性能。
技巧2:静态场景关闭矩阵自动更新
问题现象
场景里有2000个设备,只有相机在动,设备本身完全不动。但Three.js默认每帧做这件事:
// Three.js内部每帧自动执行(你没写,但它做了)
scene.traverse((obj) => {
if (obj.matrixAutoUpdate) {
obj.updateMatrix(); // 重新计算位置/旋转/缩放的矩阵
}
});
为什么这是浪费:
- 2000个设备 × 每帧计算矩阵 = 2000次矩阵运算/帧
- 矩阵运算涉及16个浮点数的乘加,CPU算力白白消耗
- 设备根本没动,算出来的矩阵和上一帧一模一样
矩阵是什么:
- 3D物体的位置、旋转、缩放,最终都要转成4×4的矩阵传给GPU
position.set(10, 0, 0)只是设置属性,矩阵才是GPU认识的格式- 每帧把属性转矩阵,就是
updateMatrix()在做的事
解决方案
物体初始化后,如果确定不动,关闭自动更新:
// 场景加载完成后,冻结所有静态物体
scene.traverse((obj) => {
if (obj.isMesh) {
obj.matrixAutoUpdate = false; // 关闭自动计算
obj.updateMatrix(); // 手动算最后一次,之后frozen
}
});
// 如果某个设备后期需要动了,再单独打开
function moveDevice(device) {
device.matrixAutoUpdate = true; // 恢复自动更新
device.position.x += 10; // 现在改动会生效
}
为什么先updateMatrix()一次:
- 关闭
matrixAutoUpdate后,属性改动不会自动转矩阵 - 必须先手动调用一次,把当前属性转成矩阵存起来
- 之后GPU一直用这个矩阵,直到你重新打开
matrixAutoUpdate
效果对比
| 指标 | 原来 | 优化后 |
|---|---|---|
| CPU每帧矩阵计算 | 2000次 | 0次(静态物体) |
| CPU占用 | 35% | 8% |
| 帧率 | 45fps | 60fps |
适用场景:
- 智慧站房、数字孪生(设备基本不动)
- 建筑可视化(墙体、地板静态)
- 不适用:游戏、动画(物体频繁动)
技巧3:强制计算包围球,视锥剔除生效
问题现象
相机只看向10个设备,但Three.js渲染了全部2000个。为什么?
视锥剔除(Frustum Culling)原理:
- 相机有个视野范围(视锥体,像个四棱锥)
- 物体在视锥体内 → 渲染
- 物体在视锥体外 → 跳过,省GPU
但视锥剔除有个前提:知道物体的位置和大小,即包围盒(Bounding Box)或包围球(Bounding Sphere)。
问题所在:
- 有些模型加载后,
geometry.boundingSphere是null - Three.js无法判断"这个物体在视野外",只能保守地渲染
- 结果:视野外的1900个设备全渲染了
验证方法:
loader.load('model.glb', (gltf) => {
gltf.scene.traverse((obj) => {
if (obj.isMesh) {
console.log('包围球:', obj.geometry.boundingSphere);
// 如果输出null,说明视锥剔除失效
}
});
});
解决方案
手动计算包围球:
loader.load('model.glb', (gltf) => {
gltf.scene.traverse((obj) => {
if (obj.isMesh) {
// 关键!手动计算包围球
obj.geometry.computeBoundingSphere();
// 确保视锥剔除开启(默认true,但确认一下)
obj.frustumCulled = true;
}
});
scene.add(gltf.scene);
});
computeBoundingSphere做了什么:
- 遍历几何体所有顶点,找中心点和最大半径
- 生成一个球体(中心+半径),刚好包住整个物体
- Three.js用这个球体快速判断"在不在视野内"
为什么用包围球不用包围盒:
- 球体判断快(距离公式简单),包围盒要判断6个面
- 球体旋转后不变,包围盒旋转后要重新计算
- 精度稍差(球体可能包住更多空白),但性能更好
效果对比
| 场景 | 原来(无包围球) | 优化后(有包围球) |
|---|---|---|
| 相机看向10个设备 | 渲染2000个 | 渲染10个 |
| GPU顶点处理 | 10亿顶点 | 5000万顶点 |
| 帧率 | 12fps | 55fps |
技巧4:关闭色调映射,后处理开销砍半
问题现象
用了MeshStandardMaterial,帧率比MeshBasicMaterial低很多,为什么?
色调映射(Tone Mapping)是什么:
- 物理渲染(PBR)的亮度范围是0到无限大(HDR)
- 显示器只能显示0到255(8位,LDR)
- 色调映射把HDR的亮度"压"进LDR范围,同时保持对比度
Three.js默认行为(r150+):
renderer.toneMapping = THREE.ACESFilmicToneMapping; // 电影级色调映射
renderer.toneMappingExposure = 1.0;
为什么耗性能:
- 每像素都要算ACES曲线(复杂数学公式)
- 涉及对数、幂运算、颜色空间转换
- 1920×1080画面 = 207万次复杂计算/帧
ACESFilmicToneMapping具体做什么:
- 把线性颜色转对数空间
- 应用S型曲线(亮部压缩,暗部提升)
- 转回线性,再转sRGB输出
- 每帧每像素都算,GPU负担大
解决方案
工业可视化场景不需要电影感,直接关闭或换简单的:
// 方案A:完全关闭(最快)
renderer.toneMapping = THREE.NoToneMapping;
// 方案B:简单线性(稍好,但快)
renderer.toneMapping = THREE.LinearToneMapping;
// 方案C:Reinhard(平衡质量和速度)
renderer.toneMapping = THREE.ReinhardToneMapping;
不同色调映射对比:
| 类型 | 视觉效果 | 性能 | 适用场景 |
|---|---|---|---|
| ACESFilmicToneMapping | 电影感,对比强 | 慢 | 影视、游戏 |
| ReinhardToneMapping | 自然,稍平淡 | 中等 | 一般3D |
| LinearToneMapping | 线性,最平淡 | 快 | 数据可视化 |
| NoToneMapping | 原始颜色 | 最快 | 工业可视化 |
效果对比
| 指标 | ACESFilmic(默认) | NoToneMapping |
|---|---|---|
| 片元着色器指令数 | ~50条 | ~5条 |
| 帧率 | 48fps | 60fps |
| 视觉效果 | 电影感 | 稍微平淡 |
技巧5:后台标签页节流,不抢资源
问题现象
用户切到别的标签页聊微信,你的Three.js场景还在后台疯狂渲染,风扇狂转。
浏览器行为:
requestAnimationFrame在后台标签页不会暂停,只是降频到1fps或保持- Three.js继续渲染,CPU/GPU 100%占用
- 笔记本发热、耗电、风扇噪音
为什么这是问题:
- 用户看不见,渲染完全浪费
- 后台标签页抢资源,前台标签页变卡
- 笔记本用户直接骂娘
解决方案
用Page Visibility API检测标签页是否可见:
let animationId;
let isRunning = true;
function animate() {
if (!isRunning) return; // 暂停时不渲染
animationId = requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
// 监听标签页可见性变化
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 后台:停止渲染
isRunning = false;
cancelAnimationFrame(animationId);
console.log('后台暂停,省电模式');
} else {
// 前台:恢复渲染
isRunning = true;
animate();
console.log('前台恢复,正常渲染');
}
});
为什么不用renderer.setAnimationLoop(null):
- r160版本
setAnimationLoop行为稳定,但r180+可能有WebXR相关改动 requestAnimationFrame+cancelAnimationFrame是浏览器标准,版本无关- 代码更可控,暂停/恢复逻辑清晰
进阶:后台降频(不暂停,只降速):
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 后台:每100ms渲染一帧(10fps,省90%资源)
clearInterval(window.bgInterval);
window.bgInterval = setInterval(() => {
renderer.render(scene, camera);
}, 100);
} else {
// 前台:恢复60fps
clearInterval(window.bgInterval);
animate();
}
});
效果对比
| 场景 | 原来 | 优化后 |
|---|---|---|
| 后台标签页渲染 | 60fps狂跑 | 0fps(暂停)或10fps(降频) |
| CPU/GPU占用 | 100% | 5% |
| 笔记本温度 | 烫手 | 正常 |
| 电池续航 | 2小时 | 6小时 |
总结:5个技巧对比表
| 技巧 | 改动成本 | 效果 | 核心原理 | 适用场景 |
|---|---|---|---|---|
| 限制像素比 | 1行代码 | ⭐⭐⭐⭐⭐ | 减少像素填充工作量 | 所有项目,尤其移动端 |
| 关闭矩阵更新 | 5行代码 | ⭐⭐⭐⭐ | 跳过不必要的矩阵计算 | 静态场景,设备不动 |
| 计算包围球 | 5行代码 | ⭐⭐⭐⭐⭐ | 启用视锥剔除,视野外不渲染 | 大场景,视野外物体多 |
| 关闭色调映射 | 1行代码 | ⭐⭐⭐ | 跳过后处理色调映射 | 工业可视化,不需要电影感 |
| 后台节流 | 10行代码 | ⭐⭐⭐ | 看不见时不浪费资源 | 长时间运行,笔记本用户 |
核心认知:
- 不改模型,纯代码优化,5分钟见效
- 限制像素比和视锥剔除是性价比最高的,必做
- 其他三个看场景需求,静态场景做矩阵冻结,长运行做后台节流
不用Blender,不用压模型,改几行代码就搞定,这才是工程师的浪漫。
下篇预告:《【ThreeJS实战》6个内存泄漏大坑,让你的场景越用越卡(附检测工具)》
互动:这5个技巧你用过几个?在评论区报数,让我看看谁是"优化老司机"😏