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

前言:最近项目优化进入瓶颈,模型不想动(或者动不了),但帧率就是上不去。正当我准备上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直接爆炸

验证方法

javascript 复制代码
console.log('devicePixelRatio:', window.devicePixelRatio); // iPhone输出3
console.log('canvas实际尺寸:', renderer.domElement.width, 'x', renderer.domElement.height); // 输出5760 x 3240

解决方案

限制最大像素比为2,超过2的按2算:

javascript 复制代码
// 原来(默认,坑!)
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默认每帧做这件事:

javascript 复制代码
// 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()在做的事

解决方案

物体初始化后,如果确定不动,关闭自动更新:

javascript 复制代码
// 场景加载完成后,冻结所有静态物体
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.boundingSpherenull
  • Three.js无法判断"这个物体在视野外",只能保守地渲染
  • 结果:视野外的1900个设备全渲染了

验证方法

javascript 复制代码
loader.load('model.glb', (gltf) => {
  gltf.scene.traverse((obj) => {
    if (obj.isMesh) {
      console.log('包围球:', obj.geometry.boundingSphere); 
      // 如果输出null,说明视锥剔除失效
    }
  });
});

解决方案

手动计算包围球:

javascript 复制代码
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+):

javascript 复制代码
renderer.toneMapping = THREE.ACESFilmicToneMapping; // 电影级色调映射
renderer.toneMappingExposure = 1.0;

为什么耗性能

  • 每像素都要算ACES曲线(复杂数学公式)
  • 涉及对数、幂运算、颜色空间转换
  • 1920×1080画面 = 207万次复杂计算/帧

ACESFilmicToneMapping具体做什么

  1. 把线性颜色转对数空间
  2. 应用S型曲线(亮部压缩,暗部提升)
  3. 转回线性,再转sRGB输出
  4. 每帧每像素都算,GPU负担大

解决方案

工业可视化场景不需要电影感,直接关闭或换简单的:

javascript 复制代码
// 方案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检测标签页是否可见:

javascript 复制代码
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是浏览器标准,版本无关
  • 代码更可控,暂停/恢复逻辑清晰

进阶:后台降频(不暂停,只降速)

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

相关推荐
yuanmenghao4 小时前
Linux 性能实战 | 第 17 篇:strace 系统调用分析与性能调优 [特殊字符]
linux·python·性能优化
工业HMI实战笔记5 小时前
包装机械HMI:快速换型与配方管理的界面解决方案
ui·性能优化·自动化·汽车·交互
Jia ming5 小时前
Linux性能分析工具perf全面解析
linux·性能优化·perf
李元_霸5 小时前
前端监控实践
前端·性能优化
前端程序猿i6 小时前
第 7 篇:性能优化 —— 大量消息下的流畅体验
前端·vue.js·性能优化
叫我一声阿雷吧6 小时前
【JS实战案例】实现图片懒加载(基础版)原生JS+性能优化,新手可直接复现
开发语言·javascript·性能优化·js图片懒加载
晓13139 小时前
第五章 【若依框架:优化】高级特性与性能优化
java·开发语言·性能优化·若依
Charlie_lll10 小时前
学习Three.js–柱状图
前端·3d·three.js
步步为营DotNet10 小时前
深度解读.NET中的DbContext ChangeTracker:实体状态管理与性能优化
oracle·性能优化·.net