Three.js 性能基准测试套件:Draw Call、三角形数、纹理内存的标准度量与对比
Three.js 性能基准测试套件Draw Call、三角形数、纹理内存的标准度量与对比一、Web3 可视化正在吃掉 GPU但你的帧率预算有上限Web3 前端对 Three.js 的依赖正在快速加深。从 NFT 市场的 3D 画廊预览到 DeFi 的链上数据可视化仪表盘再到 Metaverse 的沉浸式空间浏览——Three.js 已经从酷炫加分项变成了部分产品的核心体验层。但 GPU 资源是有限且不可见的。一个 3D 画廊页面可能在你的 M2 MacBook 上跑 60fps但在用户的旧款 Android 手机上降到 12fps。而你甚至不知道问题出在哪里——是 Draw Call 超过了 WebGL 的合理上限是单个 Mesh 的三角形太多还是纹理内存把移动端 2GB 显存吃满了传统的 Web VitalsLCP、INP、CLS只覆盖 DOM 层面的性能完全不触及 GPU 层面的瓶颈。Web3 产品的 Three.js 模块需要一套独立于 DOM 指标的 GPU 性能基准体系覆盖 Draw Call 计数、三角形面数、纹理内存占用、帧率稳定性四个核心维度。这套基准体系贯穿了从 3D 资产加载到最终帧缓冲输出的完整渲染管线。在资产加载阶段系统需完成 .glb/.gltf 模型的解析与纹理解码上传进入渲染管线后WebGLRenderer 执行 Draw Call 并经过 Shader 计算最终基准采集模块会对关键指标进行量化评估。具体的基准目标设定如下Draw Calls / Frame: 目标 500Triangle Count: 目标 500k 可见三角面Texture Memory: 目标 200MB VRAMFPS Stability: 目标 P5 30fpsLoad Time: 目标 3s 首帧这些指标将共同构成基准报告并按设备等级呈现为后续的性能优化提供量化依据。二、原理剖析四个维度的度量逻辑与阈值2.1 Draw Call 计数WebGL 的每个 Draw Call 都需要 CPU → GPU 的同步通信这是现代渲染管线中最大的性能瓶颈之一。Three.js 的renderer.render()每次调用时的 Draw Call 数量可在渲染前后通过renderer.info.render.calls获取。为什么 Draw Call 上限不能像 AAA 游戏那样设到 2000因为 WebGL 的驱动层抽象更高每次 Draw Call 的 CPU 开销比原生 APIVulkan/Metal大 2-3 倍。实测表明WebGL 在移动端超过 300 个 Draw Call 时帧率开始明显波动超过 500 个时 M1 设备也开始受影响。因此设定 500 作为全平台的基线预算。减少 Draw Call 的关键技术是 GPU Instancing——Three.js 的InstancedMesh可以在一个 Draw Call 中绘制数千个相同几何体。2.2 三角形面数三角形面数直接影响 GPU 的顶点着色器负载和片元着色器的覆盖范围。Three.js 中通过遍历场景图累加geometry.attributes.position.count / 3得到总三角形数。移动端 GPU 的三角形吞吐大约是桌面端的 1/5 到 1/350 万三角形在 M2 上可以跑 60fps在 Mali-G52 上可能降到 18fps。因此基准测试需要按设备等级Desktop / Mobile-High / Mobile-Low分别设定阈值并采集实际帧率作为交叉验证。2.3 纹理内存占用纹理内存是最容易被忽略的 GPU 性能杀手。一张 4096×4096 的 RGBA 纹理在 GPU 上占用 67MB 显存一个 NFT 画廊场景如果加载了 20 张高精度藏品图2GB 显存的设备已经被吃掉了 1.3GB——留给帧缓冲和 OS 渲染的空间所剩无几。WebGL 没有原生 API 查询纹理内存需要通过renderer.info.memory.textures获取纹理数量并结合已知的纹理分辨率手动估算。2.4 帧率稳定性单纯的平均帧率avg FPS具有欺骗性——60fps 平均可能掩盖每秒 1 次因 GC 触发掉到 5fps 的严重抖动。应采集 P1/P5 百分位帧率和帧时间标准差Frame Time StdDev。目标设定为 P5 30fps 且 Frame Time StdDev 5ms。三、代码实践Three.js 性能基准采集套件// benchmarks/three-perf-benchmark.ts // 设计决策以独立组件形式注入到任何 Three.js 场景中 // 通过 rAF 钩子采集每帧指标累积后输出 JSON 报告 import * as THREE from three; interface FrameMetrics { frameNumber: number; drawCalls: number; triangles: number; points: number; timestamp: number; frameTimeMs: number; } interface TextureMemory { textureCount: number; estimatedMemoryMB: number; // 纹理显存估算值 } interface BenchmarkReport { deviceInfo: { gpu: string; renderer: string; screenWidth: number; screenHeight: number; pixelRatio: number; }; aggregate: { avgFps: number; p1Fps: number; p5Fps: number; avgFrameTime: number; stdDevFrameTime: number; avgDrawCalls: number; avgTriangles: number; peakDrawCalls: number; peakTriangles: number; totalFrames: number; durationMs: number; }; textureMemory: TextureMemory; loadTime: { firstFrameMs: number; allAssetsLoadedMs: number; }; thresholds: { drawCalls: { value: number; budget: number; passed: boolean }; triangles: { value: number; budget: number; passed: boolean }; fps: { value: number; budget: number; passed: boolean }; textureMem: { value: number; budget: number; passed: boolean }; }; } export class ThreePerfBenchmark { private renderer: THREE.WebGLRenderer; private scene: THREE.Scene; private camera: THREE.Camera; private frames: FrameMetrics[] []; private startTime: number 0; private lastFrameTime: number 0; private running: boolean false; private rafId: number 0; private assetLoadStart: number 0; private firstFrameTime: number 0; // 按设备等级预设的预算——可在构造函数中覆盖 private budgets { drawCalls: 500, triangles: 500_000, fps: 30, textureMemoryMB: 200, }; constructor( renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera, budgets?: Partialtypeof ThreePerfBenchmark.prototype.budgets, ) { this.renderer renderer; this.scene scene; this.camera camera; if (budgets) Object.assign(this.budgets, budgets); } start(durationMs: number 10_000): void { this.frames []; this.startTime performance.now(); this.lastFrameTime this.startTime; this.assetLoadStart this.startTime; this.running true; this.firstFrameTime 0; const tick (now: number) { if (!this.running) return; // 渲染前记录渲染器统计 const prevCalls this.renderer.info.render.calls; const prevTriangles this.renderer.info.render.triangles; // 执行渲染——业务代码在此渲染场景 this.renderer.render(this.scene, this.camera); // 捕获首次渲染时间 if (this.firstFrameTime 0) { this.firstFrameTime performance.now(); } const frameTime now - this.lastFrameTime; this.lastFrameTime now; const frameNumber this.frames.length; this.frames.push({ frameNumber, drawCalls: this.renderer.info.render.calls - prevCalls, triangles: this.renderer.info.render.triangles - prevTriangles, points: this.renderer.info.render.points, timestamp: now, frameTimeMs: frameTime, }); // 运行指定时长后停止 if (now - this.startTime durationMs) { this.rafId requestAnimationFrame(tick); } else { this.running false; } }; this.rafId requestAnimationFrame(tick); } stop(): void { this.running false; cancelAnimationFrame(this.rafId); } getReport(allAssetsLoaded: boolean true): BenchmarkReport { const frameTimes this.frames.map((f) f.frameTimeMs).sort((a, b) a - b); const fpsValues this.frames.map((f) 1000 / f.frameTimeMs).sort((a, b) a - b); const avgFps fpsValues.reduce((s, v) s v, 0) / fpsValues.length; const p1Idx Math.floor(fpsValues.length * 0.01); const p5Idx Math.floor(fpsValues.length * 0.05); const avgDrawCalls this.frames[this.frames.length - 1]?.drawCalls || 0; const peakDrawCalls Math.max(...this.frames.map((f) f.drawCalls)); const avgTriangles this.frames[this.frames.length - 1]?.triangles || 0; const peakTriangles Math.max(...this.frames.map((f) f.triangles)); // 纹理内存估算 const textureInfo this.estimateTextureMemory(); // 计算帧时间标准差 const avgFrameTime frameTimes.reduce((s, v) s v, 0) / frameTimes.length; const variance frameTimes.reduce((s, v) s (v - avgFrameTime) ** 2, 0) / frameTimes.length; const report: BenchmarkReport { deviceInfo: { gpu: this.getGPUInfo(), renderer: WebGL, screenWidth: window.innerWidth, screenHeight: window.innerHeight, pixelRatio: window.devicePixelRatio, }, aggregate: { avgFps, p1Fps: fpsValues[p1Idx] || 0, p5Fps: fpsValues[p5Idx] || 0, avgFrameTime, stdDevFrameTime: Math.sqrt(variance), avgDrawCalls, avgTriangles, peakDrawCalls, peakTriangles, totalFrames: this.frames.length, durationMs: this.frames.length 0 ? this.frames[this.frames.length - 1].timestamp - this.startTime : 0, }, textureMemory: textureInfo, loadTime: { firstFrameMs: this.firstFrameTime - this.assetLoadStart, allAssetsLoadedMs: allAssetsLoaded ? (this.frames[this.frames.length - 1]?.timestamp || 0) - this.startTime : -1, }, thresholds: { drawCalls: { value: avgDrawCalls, budget: this.budgets.drawCalls, passed: avgDrawCalls this.budgets.drawCalls, }, triangles: { value: peakTriangles, budget: this.budgets.triangles, passed: peakTriangles this.budgets.triangles, }, fps: { value: fpsValues[p5Idx] || 0, budget: this.budgets.fps, passed: (fpsValues[p5Idx] || 0) this.budgets.fps, }, textureMem: { value: textureInfo.estimatedMemoryMB, budget: this.budgets.textureMemoryMB, passed: textureInfo.estimatedMemoryMB this.budgets.textureMemoryMB, }, }, }; return report; } private estimateTextureMemory(): TextureMemory { const textureCount this.renderer.info.memory.textures; // 实际项目应遍历 renderer 的纹理列表获取精确分辨率 // 此处给出基于常见纹理尺寸的估算 // 设计决策默认估算 2048x2048 RGBA 16MB/纹理 const avgTextureSizeMB 16; return { textureCount, estimatedMemoryMB: textureCount * avgTextureSizeMB, }; } private getGPUInfo(): string { const canvas document.createElement(canvas); const gl canvas.getContext(webgl2); if (!gl) return Unknown; const debugInfo gl.getExtension(WEBGL_debug_renderer_info); if (debugInfo) { return ( gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || Unknown ); } return Unknown; } } // ---- 使用示例在 3D 场景中嵌入基准 ---- import { ThreePerfBenchmark } from ./three-perf-benchmark; // 在场景初始化后附加基准采集 const benchmark new ThreePerfBenchmark(renderer, scene, camera); // 按设备等级调整预算 const isMobile /Mobi|Android/i.test(navigator.userAgent); if (isMobile) { benchmark new ThreePerfBenchmark(renderer, scene, camera, { drawCalls: 300, triangles: 200_000, textureMemoryMB: 100, }); } benchmark.start(15_000); // 采集 15 秒 // 15 秒后获取报告 setTimeout(() { benchmark.stop(); const report benchmark.getReport(); console.log(Benchmark Report:, JSON.stringify(report, null, 2)); // 将报告发送到自定义性能监控端点 fetch(/api/perf/threejs, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(report), }); }, 15_500);四、边界分析renderer.info的计数器偏差。Three.js 的renderer.info.render.calls统计的是WebGLRenderer.render()被调用期间的累计 Draw Call。如果你在单个 requestAnimationFrame 中调用了多次renderer.render()如渲染到多个 RenderTarget这些计数会叠加导致单帧 Draw Call 虚高。采集脚本需要确保每帧只调用一次render()或在采集时重置计数器renderer.info.reset()。GPU 驱动的帧率下限。在 60Hz 显示器上60fps 的帧时间是 16.67ms。如果 Device 的 GPU 性能不足以在 16.67ms 内完成当前帧的渲染帧率会直接掉到 30fps33.33ms/frame。这会产生一个离散的帧率台阶——不是连续的 55fps → 50fps → 45fps而是 60fps → 30fps → 20fps。所以在解读 FPS 数据时要特别注意是否存在台阶效应它比平均帧率更能说明问题。纹理内存估算的不精确性。WebGL 规范不提供显存查询 API上面使用的估算法误差可能达到 ±50%。对于生产环境建议使用 Chrome DevTools 的 GPU Memory 面板人工验证将验证结果作为校正系数代入估算公式。基准运行时长的影响。10 秒的基准采集容易受 JavaScript GC 事件的干扰——如果恰好在采集期间触发了 Major GCP1 帧率会严重失真。建议采集时长不少于 30 秒并在 JS 层面通过requestIdleCallback或手动gc()需--expose-gc启动参数预热后开始采集。五、总结Three.js 性能基准是一种事前防御——在 3D 场景进入生产之前用可量化的指标回答三个问题这个场景在当前设备上能跑多少帧最可能的瓶颈是 Draw Call 还是三角形还是纹理如果阈值不通过降级策略是什么基准套件的工程定位不是一次性审计工具而应作为 CI 流程的一部分——每次 Three.js 依赖升级、3D 资产更新或场景逻辑修改时在无头浏览器Puppeteer with WebGL上自动执行基准采集与上一版本对比识别 GPU 性能回归。这是一套你在 Node.js 后端永远不会需要的指标维度但对于任何依赖 Three.js 交付核心体验的 Web3 产品它是生产质量的最后一道安全网。