把一个由扩散模型或NeRF重建出的高面数网格直接放到网页里,最容易出现的问题不是首帧不出来,而是帧率从60掉到18,同时浏览器内存涨到1.2GB以上。Web端AI模型往往省略了人工拓扑与UV整理,三角面多、材质散、纹理大,如果不做资产预处理和渲染资源管理,性能瓶颈会同时出现在CPU、GPU和JS堆上。

一、先给模型资产做减法:Draco与Meshopt的差异
AI生成的模型通常来自三维重建、参数化生成或扩散模型,原始网格的面数往往远超Web场景所需。直接把一个80万面的GLB扔给GLTFLoader,不仅网络下载时间拉长,解码后上传GPU的顶点缓冲也会显著增加。几何压缩的关键不是在画质上做牺牲,而是用更合理的拓扑和量化方式表达同样的表面。
Draco是有损压缩,适合对几何精度容忍度较高的展示场景,能把顶点数据压到原始体积的十分之一左右,但它的解码逻辑较重,必须在Web Worker中运行,否则会阻塞主线程。Meshopt则更偏向无损或轻微有损,压缩率不如Draco极端,但解码速度更快,适合手机浏览器。实际项目里可以先跑一次基准:用同一份模型分别生成Draco和Meshopt版本,比较首帧时间和拖动时的帧率。
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { MeshoptDecoder } from 'three/addons/loaders/MeshoptDecoder.js';
const loader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
dracoLoader.setDecoderConfig({ type: 'js' });
loader.setDRACOLoader(dracoLoader);
loader.setMeshoptDecoder(MeshoptDecoder);
loader.load('/models/ai-scan.glb', (gltf) => {
scene.add(gltf.scene);
});
纹理压缩对内存的影响往往被低估。一张4096×4096的PNG在CPU端解码后占用约64MB,传到GPU后还会启用mipmap,内存再增加约三分之一。使用KTX2/Basis Universal可以让浏览器直接使用GPU原生压缩格式,省去CPU解码和大纹理上传环节。加载时通过KTX2Loader检测设备支持,低端设备回退到普通压缩纹理。
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const ktx2Loader = new KTX2Loader()
.setTranscoderPath('/basis/')
.detectSupport(renderer);
loader.setKTX2Loader(ktx2Loader);
二、把draw call压下去:实例化与LOD是帧率分水岭
帧率卡顿很多时候不是GPU算不过来,而是Draw Call数量太多导致CPU提交开销过大。AI模型在导出时可能没有合并网格,一颗螺丝、一个关节都可能是独立Mesh。浏览器每渲染一个Mesh,JS引擎都需要调用WebGL状态切换和绘制命令,几百个独立部件就会形成几百次draw call,15帧以下很常见。
对于重复出现的部件,可以使用InstancedMesh在一次绘制中提交大量相同几何体。AI场景中的重复结构、阵列零件、植被或用户生成的小物件都适合实例化。对于静态且不重复的网格,则应在离线或加载阶段做合并,减少单独Mesh的数量。
const geometry = new THREE.BoxGeometry(unit, unit, unit);
const material = new THREE.MeshStandardMaterial({ color: 0x5c6bc0 });
const count = 3200;
const mesh = new THREE.InstancedMesh(geometry, material, count);
const matrix = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
matrix.setPosition(
(i % 80) * gap,
Math.floor(i / 80) * gap,
0
);
mesh.setMatrixAt(i, matrix);
}
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);
实例化并不是万能药。如果每个实例的颜色或可见状态不同,需要额外写入InstancedBufferAttribute;如果实例数量非常多,还要注意矩阵更新成本。另一个有效的降帧手段是LOD。远距离物体使用低面数模型,靠近后切换高精度模型。Three.js的LOD对象可以根据相机距离自动切换层级,你只需要提前生成或在线简化出2到3级资产。
移动端还可以配合动态分辨率:在帧率低于阈值时调用renderer.setPixelRatio降到0.75或0.6,画面会轻微变糊,但交互流畅度提升明显。这比直接降低模型细节更适合作为最后一道保护。
三、GPU内存与纹理流的回收策略
浏览器不会自动释放WebGL分配的显存。如果你反复加载和销毁AI模型,却不手动调用dispose,页面内存会持续上涨,最后触发上下文丢失或标签页崩溃。Geometry、BufferAttribute、Texture、RenderTarget都持有GPU资源,必须显式销毁。一个常见做法是遍历场景根节点,对每个Mesh和材质执行递归释放。
function disposeObject(root) {
root.traverse((obj) => {
if (!obj.isMesh && !obj.isPoints) return;
obj.geometry?.dispose();
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
materials.forEach((material) => {
Object.keys(material).forEach((key) => {
const value = material[key];
if (value && value.isTexture) {
value.dispose();
}
});
material.dispose();
});
});
}
释放时机也要设计。如果每次切换页面都立刻释放所有资源,再回到页面又要重新下载和解压,体验反而更差。可以维护一个简单的LRU缓存,保留最近使用的1到2个模型资源。纹理方面,先加载低分辨率占位图,等模型进入视口或加载完成后再流式拉取高清纹理,能大幅降低首屏内存峰值。
监控内存不能只看performance.memory,它只有Chrome且精度有限。更可靠的方式是在开发阶段用浏览器的Task Manager或WebGL Inspector查看GPU内存,在生产环境则监控加载时间、帧率和纹理数量。为纹理设定尺寸上限也很重要,AI生成模型经常附带4096以上的法线贴图,但在Web场景中2048或1024通常已经足够。
四、把重计算移出主线程:Worker与异步上传
几何解压放在Worker里只是第一步。AI模型从服务端返回的数据可能是流式的,比如分块传输的顶点、法线和纹理。如果每到达一个分块就在主线程里重新分配Buffer并全量上传,页面仍会周期性掉帧。应使用Transferable对象把ArrayBuffer转移给Worker解析,再由主线程一次性或批量上传GPU。
动态生成模型时,可以预分配足够大的Float32Array,用BufferAttribute的needsUpdate标记增量更新。这样不会频繁创建和销毁GPU Buffer,避免显存碎片化。
const geometry = new THREE.BufferGeometry();
const vertexCount = expectedVertexCount;
const positionArray = new Float32Array(vertexCount * 3);
geometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3));
function receiveChunk(vertices, offset) {
positionArray.set(vertices, offset * 3);
geometry.attributes.position.needsUpdate = true;
geometry.computeBoundingSphere();
}
此外,requestAnimationFrame中的回调保持轻量。不要在渲染循环里做模型解析、排序或大数组遍历。把耗时任务拆成小片,在空闲帧执行;如果任务超过8毫秒,就主动让出主线程,等下一帧继续。这样即便AI模型持续流式生成,页面也能维持稳定的输入响应和渲染节奏。