【ThreeJS实战】5个让我帧率翻倍的小技巧,不用改模型

0 阅读9分钟

前言:最近项目优化进入瓶颈,模型不想动(或者动不了),但帧率就是上不去。正当我准备上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×32403840×2160
总像素数1860万829万
GPU像素填充工作量100%44%
帧率25fps60fps
肉眼观感极致清晰几乎没区别

为什么 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%
帧率45fps60fps

适用场景

  • 智慧站房、数字孪生(设备基本不动)
  • 建筑可视化(墙体、地板静态)
  • 不适用:游戏、动画(物体频繁动)

技巧3:强制计算包围球,视锥剔除生效

问题现象

相机只看向10个设备,但Three.js渲染了全部2000个。为什么?

视锥剔除(Frustum Culling)原理

  • 相机有个视野范围(视锥体,像个四棱锥)
  • 物体在视锥体内 → 渲染
  • 物体在视锥体外 → 跳过,省GPU

但视锥剔除有个前提:知道物体的位置和大小,即包围盒(Bounding Box)包围球(Bounding Sphere)

问题所在

  • 有些模型加载后,geometry.boundingSpherenull
  • 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万顶点
帧率12fps55fps

技巧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具体做什么

  1. 把线性颜色转对数空间
  2. 应用S型曲线(亮部压缩,暗部提升)
  3. 转回线性,再转sRGB输出
  4. 每帧每像素都算,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条
帧率48fps60fps
视觉效果电影感稍微平淡

技巧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个技巧你用过几个?在评论区报数,让我看看谁是"优化老司机"😏