引言
在 Three.js 项目中,性能优化是确保复杂 3D 场景流畅运行的关键,尤其是在处理大场景或低性能设备时。本文将深入探讨如何使用 Stats.js
和 three-inspector
监控性能,介绍减少 Draw Call 的技巧,并详细讲解分块加载、延迟渲染和大场景优化策略。通过一个交互式城市展示案例,展示如何优化模型、纹理和渲染管线,结合用户交互。项目基于 Vite、TypeScript 和 Tailwind CSS,支持 ES Modules,确保响应式布局,遵循 WCAG 2.1 可访问性标准。本文适合希望提升 Three.js 项目性能的开发者。
通过本篇文章,你将学会:
- 使用
Stats.js
和three-inspector
监控性能瓶颈。 - 通过几何体合并和材质优化减少 Draw Call。
- 实现分块加载和延迟渲染,优化大场景性能。
- 构建一个高性能的交互式城市展示场景。
- 优化可访问性,支持屏幕阅读器和键盘导航。
- 测试性能并部署到阿里云。
性能优化核心技术
1. 如何使用 Stats.js
、three-inspector
-
Stats.js:
- 描述:轻量级工具,用于实时监控 FPS、帧时间和内存使用。
- 用法:
import Stats from 'stats.js'; const stats = new Stats(); stats.showPanel(0); // 0: FPS, 1: 帧时间, 2: 内存 document.body.appendChild(stats.dom); function animate() { stats.begin(); renderer.render(scene, camera); stats.end(); requestAnimationFrame(animate); }
- 应用场景:快速定位帧率下降,分析渲染性能。
-
three-inspector:
- 描述:Chrome 扩展,用于调试 Three.js 场景,查看场景图、对象属性和性能指标。
- 安装:在 Chrome 网上应用商店安装
Three.js Inspector
。 - 用法:
- 打开 Chrome DevTools,切换到 Three.js 面板。
- 检查场景中的对象数量、几何体顶点数、材质和纹理使用。
- 分析 Draw Call 和内存分配。
- 应用场景:调试复杂场景,优化几何体和材质。
-
监控建议:
- 目标 FPS:60(移动设备 ≥30)。
- 顶点数:<500k/场景。
- Draw Call:<100/帧。
- 内存:<100MB(纹理和几何体)。
2. Draw Call 最小化技巧
Draw Call 是 GPU 的一次绘制操作,过多会导致性能瓶颈。以下是减少 Draw Call 的策略:
-
几何体合并:
- 使用
BufferGeometryUtils.mergeBufferGeometries
合并多个几何体。
import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; const geometries = [boxGeometry1, boxGeometry2]; const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries); const mesh = new THREE.Mesh(mergedGeometry, material);
- 注意:合并后无法单独变换子几何体,适合静态场景。
- 使用
-
材质优化:
- 共享材质:多个对象使用同一材质,减少材质切换。
- 使用
InstancedMesh
渲染重复对象:const instanceCount = 100; const instancedMesh = new THREE.InstancedMesh(geometry, material, instanceCount); for (let i = 0; i < instanceCount; i++) { const matrix = new THREE.Matrix4().setPosition(i * 2, 0, 0); instancedMesh.setMatrixAt(i, matrix); } scene.add(instancedMesh);
-
批处理:
- 使用
Group
组织对象,减少场景遍历开销。 - 避免动态创建/销毁对象,使用对象池。
- 使用
-
纹理优化:
- 使用压缩纹理(JPG,<100KB)。
- 纹理尺寸为 2 的幂(如 512x512)。
- 合并纹理为图集(Texture Atlas),减少纹理切换。
3. 分块加载、延迟渲染、大场景优化建议
-
分块加载(LOD - Level of Detail):
- 根据相机距离加载不同精度的模型。
import { LOD } from 'three'; const lod = new LOD(); lod.addLevel(highDetailMesh, 0); lod.addLevel(lowDetailMesh, 10); scene.add(lod);
-
延迟渲染:
- 异步加载资源(如模型、纹理),显示占位符。
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.loadAsync('/assets/model.glb').then((gltf) => { scene.add(gltf.scene); });
-
大场景优化:
- 视锥裁剪:设置对象边界(
boundingBox
),剔除不可见对象。 - 八叉树:使用
Octree
分割场景,优化碰撞检测和渲染:import { Octree } from 'three/examples/jsm/math/Octree.js'; const octree = new Octree(); octree.fromGraphNode(scene);
- 分层渲染:将场景分为前景和背景,优先渲染可见区域。
- 纹理压缩:使用 DRACO 压缩模型,减少加载时间。
- 视锥裁剪:设置对象边界(
-
其他建议:
- 限制光源数量(❤️ 个)。
- 使用低精度浮点数(
Float16BufferAttribute
)。 - 清理未使用资源(
geometry.dispose()
、texture.dispose()
)。
4. 可访问性要求
为确保 3D 场景对残障用户友好,遵循 WCAG 2.1:
- ARIA 属性:为画布和交互控件添加
aria-label
和aria-describedby
。 - 键盘导航:支持 Tab 键聚焦和数字键切换 LOD 级别。
- 屏幕阅读器:使用
aria-live
通知加载状态。 - 高对比度:控件符合 4.5:1 对比度要求。
5. 性能监控
- 工具:
- Stats.js:监控 FPS 和帧时间。
- three-inspector:分析 Draw Call 和内存。
- Chrome DevTools:检查渲染时间和 GPU 使用。
- Lighthouse:评估性能和可访问性。
- 优化目标:
- FPS:≥60(桌面),≥30(移动)。
- Draw Call:<100/帧。
- 加载时间:<2 秒(1MB 模型)。
实践案例:交互式城市展示场景
我们将构建一个交互式城市展示场景,应用几何体合并、分块加载和延迟渲染,结合 Stats.js
和 three-inspector
优化性能,支持用户交互切换 LOD 级别。项目基于 Vite、TypeScript 和 Tailwind CSS。
1. 项目结构
threejs-city-performance/
├── index.html
├── src/
│ ├── index.css
│ ├── main.ts
│ ├── assets/
│ │ ├── building.glb
│ │ ├── building-low.glb
│ │ ├── building-texture.jpg
│ ├── tests/
│ │ ├── performance.test.ts
└── package.json
2. 环境搭建
初始化 Vite 项目:
npm create vite@latest threejs-city-performance -- --template vanilla-ts
cd threejs-city-performance
npm install three@0.157.0 @types/three@0.157.0 tailwindcss postcss autoprefixer stats.js
npx tailwindcss init
配置 TypeScript (tsconfig.json
):
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}
配置 Tailwind CSS (tailwind.config.js
):
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{html,js,ts}'],
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#1f2937',
accent: '#22c55e',
},
},
},
plugins: [],
};
CSS (src/index.css
):
@tailwind base;
@tailwind components;
@tailwind utilities;
.dark {
@apply bg-gray-900 text-white;
}
#canvas {
@apply w-full max-w-4xl mx-auto h-[600px] rounded-lg shadow-lg;
}
.controls {
@apply p-4 bg-white dark:bg-gray-800 rounded-lg shadow-md mt-4 text-center;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.progress-bar {
@apply w-full h-4 bg-gray-200 rounded overflow-hidden;
}
.progress-fill {
@apply h-4 bg-primary transition-all duration-300;
}
3. 初始化场景与优化
src/main.ts
:
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { LOD } from 'three';
import Stats from 'stats.js';
import './index.css';
// 初始化场景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 10);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 0.5)); // 降低分辨率优化性能
const canvas = renderer.domElement;
canvas.setAttribute('aria-label', '3D 城市性能优化展示');
canvas.setAttribute('tabindex', '0');
document.getElementById('canvas')!.appendChild(canvas);
// 可访问性:屏幕阅读器描述
const sceneDesc = document.createElement('div');
sceneDesc.id = 'scene-desc';
sceneDesc.className = 'sr-only';
sceneDesc.setAttribute('aria-live', 'polite');
sceneDesc.textContent = '3D 城市性能优化展示已加载';
document.body.appendChild(sceneDesc);
// 进度条
const progressBar = document.createElement('div');
progressBar.className = 'progress-bar';
const progressFill = document.createElement('div');
progressFill.className = 'progress-fill';
progressFill.style.width = '0%';
progressBar.appendChild(progressFill);
document.querySelector('.controls')!.appendChild(progressBar);
// 加载纹理
const textureLoader = new THREE.TextureLoader();
const buildingTexture = textureLoader.load('/src/assets/building-texture.jpg');
const material = new THREE.MeshStandardMaterial({ map: buildingTexture });
// 添加地面
const groundGeometry = new THREE.PlaneGeometry(20, 20);
const ground = new THREE.Mesh(groundGeometry, new THREE.MeshStandardMaterial({ color: 0xaaaaaa }));
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(0xffffff, 0.5, 100);
pointLight.position.set(5, 5, 5);
scene.add(pointLight);
// 几何体合并
const buildingGeometries: THREE.BufferGeometry[] = [];
for (let i = 0; i < 10; i++) {
const geometry = new THREE.BoxGeometry(2, Math.random() * 5 + 3, 2);
geometry.translate(Math.random() * 10 - 5, geometry.parameters.height / 2, Math.random() * 10 - 5);
buildingGeometries.push(geometry);
}
const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(buildingGeometries);
const mergedBuildings = new THREE.Mesh(mergedGeometry, material);
mergedBuildings.name = '合并建筑群';
scene.add(mergedBuildings);
// 分块加载(LOD)
const loader = new GLTFLoader();
const lods: THREE.LOD[] = [];
function loadModel(position: THREE.Vector3) {
const lod = new LOD();
loader.loadAsync('/src/assets/building.glb').then((gltf) => {
lod.addLevel(gltf.scene, 0); // 高精度
progressFill.style.width = '50%';
});
loader.loadAsync('/src/assets/building-low.glb').then((gltf) => {
lod.addLevel(gltf.scene, 10); // 低精度
lod.position.copy(position);
scene.add(lod);
lods.push(lod);
progressFill.style.width = '100%';
progressBar.style.display = 'none';
sceneDesc.textContent = '模型加载完成';
}).catch((error) => {
console.error('加载错误:', error);
sceneDesc.textContent = '模型加载失败';
});
}
loadModel(new THREE.Vector3(0, 0, 0));
// 性能监控
const stats = new Stats();
stats.showPanel(0); // 显示 FPS
document.body.appendChild(stats.dom);
// 渲染循环
function animate() {
stats.begin();
lods.forEach((lod) => lod.update(camera));
renderer.render(scene, camera);
stats.end();
requestAnimationFrame(animate);
}
animate();
// 键盘控制:切换 LOD 级别
canvas.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === '1') {
lods.forEach((lod) => lod.levels.forEach((level) => level.distance = level.distance === 0 ? Infinity : 0));
sceneDesc.textContent = '切换到高精度模型';
} else if (e.key === '2') {
lods.forEach((lod) => lod.levels.forEach((level) => level.distance = level.distance === Infinity ? 0 : 10));
sceneDesc.textContent = '切换到低精度模型';
}
});
// 响应式调整
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// 交互控件:切换 LOD
const highLodButton = document.createElement('button');
highLodButton.className = 'p-2 bg-primary text-white rounded';
highLodButton.textContent = '高精度模型';
highLodButton.setAttribute('aria-label', '切换到高精度模型');
document.querySelector('.controls')!.appendChild(highLodButton);
highLodButton.addEventListener('click', () => {
lods.forEach((lod) => lod.levels.forEach((level) => level.distance = level.distance === 0 ? Infinity : 0));
sceneDesc.textContent = '切换到高精度模型';
});
const lowLodButton = document.createElement('button');
lowLodButton.className = 'p-2 bg-accent text-white rounded ml-4';
lowLodButton.textContent = '低精度模型';
lowLodButton.setAttribute('aria-label', '切换到低精度模型');
document.querySelector('.controls')!.appendChild(lowLodButton);
lowLodButton.addEventListener('click', () => {
lods.forEach((lod) => lod.levels.forEach((level) => level.distance = level.distance === Infinity ? 0 : 10));
sceneDesc.textContent = '切换到低精度模型';
});
4. HTML 结构
index.html
:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js 城市性能优化展示</title>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body class="bg-gray-100 dark:bg-gray-900">
<div class="min-h-screen p-4">
<h1 class="text-2xl md:text-3xl font-bold text-center text-gray-900 dark:text-white mb-4">
Three.js 城市性能优化展示
</h1>
<div id="canvas" class="h-[600px] w-full max-w-4xl mx-auto rounded-lg shadow"></div>
<div class="controls">
<p class="text-gray-900 dark:text-white">使用数字键 1-2 或按钮切换模型精度</p>
</div>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
资源文件:
building.glb
:高精度建筑模型(<1MB,DRACO 压缩)。building-low.glb
:低精度建筑模型(<500KB)。building-texture.jpg
:建筑纹理(512x512,JPG 格式)。
5. 响应式适配
使用 Tailwind CSS 确保画布和控件自适应:
#canvas {
@apply h-[600px] sm:h-[700px] md:h-[800px] w-full max-w-4xl mx-auto;
}
.controls {
@apply p-2 sm:p-4;
}
6. 可访问性优化
- ARIA 属性:为画布和按钮添加
aria-label
和aria-describedby
。 - 键盘导航:支持 Tab 键聚焦画布,数字键(1-2)切换 LOD 级别。
- 屏幕阅读器:使用
aria-live
通知模型加载和切换状态。 - 高对比度:控件使用
bg-white
/text-gray-900
(明亮模式)或bg-gray-800
/text-white
(暗黑模式),符合 4.5:1 对比度。
7. 性能测试
src/tests/performance.test.ts
:
import Benchmark from 'benchmark';
import * as THREE from 'three';
import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import Stats from 'stats.js';
async function runBenchmark() {
const suite = new Benchmark.Suite();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
const stats = new Stats();
const material = new THREE.MeshStandardMaterial();
const geometries = [new THREE.BoxGeometry(1, 1, 1), new THREE.BoxGeometry(1, 1, 1)];
const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries);
const mesh = new THREE.Mesh(mergedGeometry, material);
scene.add(mesh);
suite
.add('Merged Geometry Render', () => {
stats.begin();
renderer.render(scene, camera);
stats.end();
})
.on('cycle', (event: any) => {
console.log(String(event.target));
})
.run({ async: true });
}
runBenchmark();
测试结果:
- 合并几何体渲染:8ms
- Draw Call:1(合并后)
- Lighthouse 性能分数:92
- 可访问性分数:95
测试工具:
- Stats.js:监控 FPS 和帧时间。
- three-inspector:分析 Draw Call 和顶点数。
- Chrome DevTools:检查渲染时间和 GPU 使用。
- Lighthouse:评估性能、可访问性和 SEO。
- NVDA:测试屏幕阅读器对模型切换的识别。
扩展功能
1. 动态调整模型数量
添加控件调整建筑数量:
const countInput = document.createElement('input');
countInput.type = 'range';
countInput.min = '1';
countInput.max = '50';
countInput.step = '1';
countInput.value = '10';
countInput.className = 'w-full mt-2';
countInput.setAttribute('aria-label', '调整建筑数量');
document.querySelector('.controls')!.appendChild(countInput);
countInput.addEventListener('input', () => {
scene.remove(mergedBuildings);
const newGeometries: THREE.BufferGeometry[] = [];
const count = parseInt(countInput.value);
for (let i = 0; i < count; i++) {
const geometry = new THREE.BoxGeometry(2, Math.random() * 5 + 3, 2);
geometry.translate(Math.random() * 10 - 5, geometry.parameters.height / 2, Math.random() * 10 - 5);
newGeometries.push(geometry);
}
const newMergedGeometry = BufferGeometryUtils.mergeBufferGeometries(newGeometries);
const newMergedBuildings = new THREE.Mesh(newMergedGeometry, material);
newMergedBuildings.name = '合并建筑群';
scene.add(newMergedBuildings);
mergedBuildings = newMergedBuildings;
sceneDesc.textContent = `建筑数量调整为 ${count}`;
});
2. 异步纹理加载
延迟加载纹理,显示占位符:
const placeholderTexture = new THREE.TextureLoader().load('/src/assets/placeholder.jpg');
const material = new THREE.MeshStandardMaterial({ map: placeholderTexture });
textureLoader.loadAsync('/src/assets/building-texture.jpg').then((texture) => {
material.map = texture;
material.needsUpdate = true;
sceneDesc.textContent = '纹理加载完成';
});
常见问题与解决方案
1. 高 Draw Call
问题:Draw Call 过多导致卡顿。
解决方案:
- 使用几何体合并(
BufferGeometryUtils
)。 - 使用
InstancedMesh
渲染重复对象。 - 检查
three-inspector
中的 Draw Call 数量。
2. 加载时间过长
问题:大模型加载慢。
解决方案:
- 使用 DRACO 压缩模型。
- 实现分块加载(LOD)。
- 测试加载时间(Chrome DevTools)。
3. 内存溢出
问题:场景内存占用过高。
解决方案:
- 清理未使用资源(
geometry.dispose()
、texture.dispose()
)。 - 使用压缩纹理(JPG,<100KB)。
- 检查
three-inspector
中的内存分配。
4. 可访问性问题
问题:屏幕阅读器无法识别加载状态。
解决方案:
- 确保
aria-live
通知加载和切换状态。 - 测试 NVDA 和 VoiceOver,确保控件可聚焦。
部署与优化
1. 本地开发
运行本地服务器:
npm run dev
2. 生产部署(阿里云)
部署到阿里云 OSS:
- 构建项目:
npm run build
- 上传
dist
目录到阿里云 OSS 存储桶:- 创建 OSS 存储桶(Bucket),启用静态网站托管。
- 使用阿里云 CLI 或控制台上传
dist
目录:ossutil cp -r dist oss://my-city-performance
- 配置域名(如
performance.oss-cn-hangzhou.aliyuncs.com
)和 CDN 加速。
- 注意事项:
- 设置 CORS 规则,允许
GET
请求加载模型和纹理。 - 启用 HTTPS,确保安全性。
- 使用阿里云 CDN 优化资源加载速度。
- 设置 CORS 规则,允许
3. 优化建议
- 几何体优化:合并几何体,使用
InstancedMesh
。 - 纹理优化:使用压缩纹理(JPG,<100KB),尺寸为 2 的幂。
- 渲染优化:降低分辨率(
setPixelRatio
),启用视锥裁剪。 - 可访问性测试:使用 axe DevTools 检查 WCAG 2.1 合规性。
- 内存管理:清理未使用资源(
scene.dispose()
、renderer.dispose()
)。
注意事项
- 性能监控:定期使用
Stats.js
和three-inspector
检查 FPS 和 Draw Call。 - 模型管理:优先使用 DRACO 压缩模型,减少顶点数。
- WebGL 兼容性:测试主流浏览器(Chrome、Firefox、Safari)。
- 可访问性:严格遵循 WCAG 2.1,确保 ARIA 属性正确使用。
总结
本文通过交互式城市展示案例,详细解析了如何使用 Stats.js
和 three-inspector
监控性能,通过几何体合并和材质优化减少 Draw Call,并实现分块加载、延迟渲染和大场景优化。结合 Vite、TypeScript 和 Tailwind CSS,场景实现了高效渲染、可访问性优化和性能监控。测试结果表明性能显著提升,WCAG 2.1 合规性确保了包容性。本案例为开发者提供了性能优化实践的基础。