普通视图

发现新文章,点击刷新页面。
昨天以前首页

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

作者 叶智辽
2026年2月13日 17:13

前言:最近项目优化进入瓶颈,模型不想动(或者动不了),但帧率就是上不去。正当我准备上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.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万顶点
帧率 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具体做什么

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

【ThreeJS实战】从86MB到4MB:复杂模型加载优化黑魔法

作者 叶智辽
2026年2月8日 13:34

前言:正当我沉浸在将draw call从52000优化到1的喜悦中无法自拔时,产品经理这时候又杀过来了:"客户说模型加载要30秒,还没进去就关页面了,你优化一下?"我打开Network面板一看,卧槽,86MB的GLB文件!这谁顶得住啊...

如果你也遇到过这种情况:精心打磨的3D场景,本地运行丝滑流畅,一上线用户骂娘——"破网站卡死了"、"怎么还在转圈"、"手机直接闪退"。别急着怪用户网速慢,先看看你的模型是不是太胖了

我这有个复杂模型,几何体+贴图一共86MB,在4G网络下加载需要30秒(Chrome模拟Slow 4G(3mb/s)一直加载...)。今天咱们不讲Blender操作模型(之前用Blender是因为没招,现在有更狠的),直接用命令行黑魔法把它压到4MB!!,加载时间从30秒干到1.5秒

以下是优化前的绝望现场整整加载了30多秒...

image.png

一、优化思路

既然知道了加载为什么那么慢的原因,那我们就可以开始想想该怎么优化了

我目前的思路就是用gltf-transform 先把模型体积压下来,要不然渲染的时候再流畅,客户等到第二十秒的时候关闭浏览器,也没有意义了。。

二、DRACOLoader

ThreeJS DRACOLoader直接无缝解压缩被压缩的模型

安装压缩模型工具(不用Blender,命令行搞定)

# 安装gltf-transform(一行命令搞定Draco压缩+WebP+KTX2)
npm install -g @gltf-transform/cli

至于我为什么选择gltf-transform而不是gltf-pipeline,以下是它们的对比:

特性 gltf-pipeline gltf-transform
Draco压缩 ✅ 支持 ✅ 支持(更快)
WebP纹理 ❌ 不支持 ✅ 支持(关键!)
KTX2/Basis ❌ 不支持 ✅ 支持
安装体积 大(依赖多) 小(WASM核心)
推荐度 ⭐⭐⭐ ⭐⭐⭐⭐⭐

压缩你的GLB(80MB → 4MB)

gltf-transform optimize input.glb output.glb \
  --compress draco \
  --texture-compress webp \
  --texture-size 2048

以下是我压缩之后的体积:

image.png

可以看到,模型的体积得到了巨大的缩减,从原来的86mb到现在的4mb左右!

参数说明

参数 说明 建议值
--texture-compress webp 贴图转WebP格式 必加,体积减半
--texture-compress ktx2 贴图转KTX2(GPU直读) 如果目标设备支持,比WebP更好
--texture-size 2048 限制最大贴图尺寸 必加,4096→2048省4倍显存
--compress draco 启用Draco几何压缩 必加,默认就是sequential模式
--compress-method sequential Draco编码模式 sequential(默认,小体积)或 edgeloop(快解码)
--compress-level 10 Draco压缩级别 0-10,10压最狠但解压慢,建议7-10
--flatten 打平节点层级 如果模型层级太深,加这个减少DrawCall(但会丢失动画)

以下是优化之后的加载时间,就问你快不快!

image.png

Three.js加载代码(关键!)

/**
 * 优化后的 GLB 加载步骤(Draco / gltf-transform)
 *
 * 依赖:Three.js、GLTFLoader、DRACOLoader
 * 解码器:把 three 的 examples/jsm/libs/draco/gltf/ 放到站点 /draco/ 下,或使用 CDN 路径
 */

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';

// ————— 步骤 1:创建 Draco 解码器并指定路径 —————
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
// 或用 CDN(与项目 three 版本一致):'https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/draco/gltf/'

// ————— 步骤 2:把 DRACOLoader 挂到 GLTFLoader 上 —————
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);

// ————— 步骤 3:正常 load,普通 GLB 与 Draco 压缩的 GLB 都能加载 —————
loader.load(
  'https://your-cdn.com/model-optimized.glb',
  (gltf) => {
    scene.add(gltf.scene);
  },
  undefined,
  (err) => console.error(err),
);

// Promise 写法(可选):
export function loadOptimizedGLB(url) {
  return new Promise((resolve, reject) => {
    loader.load(url, resolve, undefined, reject);
  });
}
// 使用方式:const gltf = await loadOptimizedGLB(url);

注意setDecoderPath 指向的是 Draco 的 WASM 解码文件,需要从 Three.js 的 examples/jsm/libs/draco/ 目录复制到你的 public 文件夹,或者用 CDN(上面示例用的是从threejs复制的本地解码文件)。

image.png

image.png

避坑指南

  1. 别重复压缩:Draco是有损压缩,压一次损失一点精度,别压两遍!先备份原文件。
  2. WebP兼容性:虽然现代浏览器都支持WebP,但如果你要兼容IE11(虽然不应该),只能用PNG/JPG。
  3. KTX2谨慎用:KTX2(Basis Universal)压缩率最高,但需要 GPU 支持,老旧手机可能解码失败,建议 WebP 更稳妥。
  4. 量化精度:如果你发现压缩后的模型出现裂缝(顶点没对齐),把 --quantization-position 从 10 调到 14。

还有一件事:Draco是有损压缩,但视觉上几乎看不出差别(工业模型顶点精度够高),解压是在Web Worker里进行的,不会卡主线程。

三、又到了喜闻乐见的前后对比(刺激!)

指标 原始模型 Draco压缩
文件体积 86MB 4MB
4G加载时间 30秒 1.5秒

可以看到加载时间跨度很大,从30秒到1.5秒,足足提升了20倍,客户本来都要睡着了,但现在客户眨了一下眼睛,就发现眼前屏幕里的世界都不一样了~

总结

优化路径:86MB(原始)→ 4MB(Draco+WebP)→ 1.5秒加载完成

核心认知

  • gltf-transform:一站式解决几何体+贴图压缩,不用Blender,一行命令搞定
  • Draco:解决"下载慢"(几何体从18MB压到2MB)
  • WebP:解决"贴图肥"(68MB压到2MB,兼容性最好)

没用到的手段(进阶可选)

  • KTX2:比WebP体积更小且GPU直读,但需要设备支持,老旧手机可能解码失败
  • 分块加载:如果4MB还是大,可以拆成"外壳1MB+细节3MB",首屏秒开

不用Blender,全程命令行+代码搞定,这才是工程师的浪漫。

下篇预告:【ThreeJS实战】GPU还是100%?LOD策略:让远处模型自动"减肥"

互动:你用gltf-transform压了多少倍?我20倍算不算狠?评论区报出你的原始体积vs优化后体积,看看谁是真正的"压王"😏

❌
❌