跳到主内容

从 18fps 到 60fps:Three.js 数字孪生大屏避坑实录 ​

项目背景:十万面园区模型把大屏拖到 18fps ​

在数字孪生项目中,3D 大屏通常不是单纯的 Three.js 场景:页面左侧是实时告警,右侧是设备指标,中间是园区、楼宇、管线和设备模型,还叠加了视频流、粒子动效、CSS2D 标签和 ECharts 图表。真正压垮帧率的,往往不是某一个模型,而是“模型解析、GPU 提交、标签重排、图表动画、阴影计算”同时发生。

本次案例中,初始 GLB 文件约 86MB,包含 320 多个 Mesh、约 18 万三角面,在办公电脑独显上首屏 6 秒白屏,加载完成后帧率只有 18~24fps;在集显工控机上甚至会出现页面无响应。优化目标是:首屏可交互小于 3 秒,常规镜头稳定 60fps,低端机至少锁定 30fps。

方案对比:只减面不够,要拆分加载与渲染预算 ​

常见方案有三类:第一是美术侧减面和纹理压缩;第二是使用 Draco 或 Meshopt 压缩模型;第三是运行时合批、实例化和层级裁剪。只做其中一项都容易留下瓶颈。例如 Draco 能显著降低下载体积,但解码会占用 CPU;合批能减少 draw call,但会牺牲对象级交互;实例化适合重复设备,却不适合结构完全不同的建筑主体。

因此最终采用“分层模型 + 渐进加载 + 静态烘焙 + 动态实例化 + CSS/Canvas 混合渲染”的组合方案。

模型分层导出主体/设备/标签压缩与分包Meshopt + KTX2渐进式加载先主体后细节场景渲染LOD/裁剪/实例化静态对象合批、烘焙阴影动态对象InstancedMesh

模型生产:先定预算,再导出 GLB ​

不要把完整 BIM 或 CAD 场景直接扔进 GLTF 导出器。建议在建模阶段确定三个预算:桌面端单模型小于 15MB,移动端首屏小于 8MB;场景 draw call 控制在 120 以内;首屏三角面控制在 10 万面以内,其余通过镜头距离或楼层切换按需加载。

导出时按业务语义拆分:campus-core.glb 放地形、道路、建筑外壳;floor-01.glb 等按楼层放室内细节;devices.glb 只放重复设备的基础几何体。建筑主体使用 Meshopt 压缩,纹理使用 KTX2/Basis,减少 GPU 显存和解码压力。

javascript
// 使用 gltf-transform 离线处理模型
// npm i @gltf-transform/core @gltf-transform/extensions @gltf-transform/functions
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { prune, dedup, weld, simplify, instance } from '@gltf-transform/functions';
import { MeshoptEncoder } from 'meshoptimizer';

const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({
  'meshopt.encoder': MeshoptEncoder,
});

const document = await io.read('input/campus.glb');

await document.transform(
  dedup(),                 // 删除重复材质、纹理和几何体
  weld(),                  // 焊接顶点,提升后续简化质量
  simplify({ ratio: 0.55, error: 0.001 }), // 非关键模型减面
  instance({ min: 3 }),    // 重复设备转为实例引用
  prune()                  // 清理不可见节点和无用资源
);

await io.write('dist/campus-core.glb', document);

Three.js 加载器:首屏先渲染,细节后补齐 ​

运行时不要一次性加载所有 GLB。先显示低精度园区,再按优先级加载设备和标签。加载过程中通过调度器限制网络并发,并在镜头运动期间暂停非关键解析,避免 OrbitControls 拖拽时抢主线程。

typescript
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';

interface LoadTask {
  name: string;
  url: string;
  priority: number;
  ready?: (model: THREE.Object3D) => void;
}

class ModelQueue {
  private loader: GLTFLoader;
  private tasks: LoadTask[] = [];
  private running = false;

  constructor(renderer: THREE.WebGLRenderer) {
    this.loader = new GLTFLoader();
    this.loader.setMeshoptDecoder(MeshoptDecoder);

    const ktx2Loader = new KTX2Loader()
      .setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/basis/')
      .detectSupport(renderer);

    this.loader.setKTX2Loader(ktx2Loader);
  }

  add(task: LoadTask) {
    this.tasks.push(task);
    this.tasks.sort((a, b) => b.priority - a.priority);
    this.run();
  }

  private async run() {
    if (this.running) return;
    this.running = true;

    while (this.tasks.length) {
      // 镜头旋转时只加载最高优先级资源,避免交互掉帧
      if (window.__cameraMoving && this.tasks[0].priority < 10) {
        await new Promise(r => setTimeout(r, 120));
        continue;
      }

      const task = this.tasks.shift()!;
      const gltf = await this.loader.loadAsync(task.url);
      gltf.scene.traverse(obj => {
        if ((obj as THREE.Mesh).isMesh) {
          obj.frustumCulled = true;
          (obj as THREE.Mesh).matrixAutoUpdate = false;
          obj.updateMatrix();
        }
      });
      task.ready?.(gltf.scene);
    }

    this.running = false;
  }
}

帧率保障:静态合批与动态实例化分开处理 ​

建筑、道路、绿化这类不会单独变化的对象,可以合并几何体并使用烘焙阴影;摄像头、温湿度传感器、井盖这类重复且会变色的对象,应使用 InstancedMesh。千万不要为每个传感器创建一个 Mesh 和一个 CSS2DObject,否则设备数量上来后 draw call 和 DOM 节点都会爆炸。

typescript
import * as THREE from 'three';

// 批量更新设备状态:一次 draw call 渲染大量重复设备
export function createDeviceInstances(
  geometry: THREE.BufferGeometry,
  material: THREE.MeshStandardMaterial,
  points: Array<{ x: number; y: number; z: number; status: number }>
) {
  const mesh = new THREE.InstancedMesh(geometry, material, points.length);
  const dummy = new THREE.Object3D();
  const color = new THREE.Color();

  points.forEach((p, i) => {
    dummy.position.set(p.x, p.y, p.z);
    dummy.updateMatrix();
    mesh.setMatrixAt(i, dummy.matrix);

    color.setHex(p.status === 1 ? 0x22c55e : p.status === 2 ? 0xf97316 : 0x94a3b8);
    mesh.setColorAt(i, color);
  });

  mesh.instanceMatrix.needsUpdate = true;
  if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
  mesh.frustumCulled = false; // 实例分布很广时,避免整体被错误剔除
  return mesh;
}

混合渲染也要划清边界:Three.js Canvas 负责真实空间关系,CSS2D 只保留少量重点告警,普通点位使用 Sprite 或 CanvasTexture,统计图表使用 ECharts。CSS 标签数量建议控制在 50 个以内,并在镜头移动时降低透明度或隐藏低频标签。

typescript
const clock = new THREE.Clock();
let lastFrame = performance.now();
let lowFpsCount = 0;

function adaptiveRender(renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.PerspectiveCamera) {
  requestAnimationFrame(() => adaptiveRender(renderer, scene, camera));

  const delta = clock.getDelta();
  renderer.render(scene, camera);

  const now = performance.now();
  const fps = 1000 / (now - lastFrame);
  lastFrame = now;

  // 连续低于阈值时降级,而不是瞬时抖动就频繁切换
  if (fps < 35) {
    lowFpsCount++;
    if (lowFpsCount > 30) {
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1));
      scene.traverse(obj => {
        if ((obj as THREE.Mesh).material) {
          const mat = (obj as THREE.Mesh).material as THREE.MeshStandardMaterial;
          if ('roughnessMap' in mat) mat.normalScale?.set(0.3, 0.3);
        }
      });
    }
  } else {
    lowFpsCount = Math.max(0, lowFpsCount - 1);
  }
}

避坑总结:这些现象比模型面数更危险 ​

  1. 现象:GLB 体积变小了,但首屏仍然卡顿。 原因通常是 Draco 解码集中在主线程,或贴图上传瞬间占用显存。修正方法是优先使用 Meshopt 与 KTX2,首屏只加载核心模型,并给大模型增加 loading 占位,不要把 80MB 资源压成一个文件。

  2. 现象:OrbitControls 一旋转就掉帧,静止时帧率正常。 常见原因是每帧更新所有 Object3D 矩阵、阴影实时渲染、CSS2D 标签过多。应关闭静态对象的 matrixAutoUpdate,阴影改为烘焙或只保留一个方向光实时阴影,镜头运动时隐藏普通标签和 ECharts 动画。

  3. 现象:设备点位超过一千个后页面越来越卡。 这通常是每个点位一个 Mesh、一个 WebSocket 字段映射和一个 DOM 标签造成的。重复设备改为 InstancedMesh,状态批量写入 instanceColor,标签按聚合密度显示,点击时再弹出详情。

  4. 现象:远处模型闪烁或被整块剔除。 多半是合并模型包围盒错误、实例化对象默认裁剪范围异常,或近远裁剪面设置过大导致深度精度不足。应重新计算包围盒,广域实例可关闭 frustumCulled,相机 near 不要设成 0.01,far 按园区真实尺度设置。

  5. 现象:低端机偶发黑屏,控制台提示上下文丢失。 这是显存超限或标签页切后台后 WebGL 资源恢复失败。要监听 webglcontextlost 与 webglcontextrestored,限制像素比不超过 2,纹理采用压缩格式,并提供“性能模式”手动关闭后处理、阴影和粒子。

数字孪生 3D 大屏的优化核心不是追求某一项技术参数,而是把下载、解析、GPU 上传、draw call、DOM 标签和业务动画都纳入同一套帧率预算。先保证首屏可交互,再渐进补齐细节,才能在真实展厅、工控机和普通办公电脑上都稳定交付。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。