1. 中大型 Three.js 场景为什么会卡:从 draw call 说起
Three.js 做 3D 可视化,场景一旦上规模,卡顿往往不是模型面数太多,而是 draw call 数量爆炸。每个独立 Mesh 都是一次绘制调用,CPU 要逐个准备状态、提交 GPU,几百上千个物体时,瓶颈就从 GPU 转移到了 CPU 提交阶段。LOD(细节层次)和实例化渲染(InstancedMesh)正是解决这两类问题的核心手段:LOD 按相机距离切换模型精度,减少远处物体的顶点与片元开销;实例化渲染把大量相同几何体合并成一次 draw call,直接砍掉重复提交。
这篇面向中大型 3D 可视化项目的性能优化,交付可复制的 LOD 分级参数、InstancedMesh 初始化骨架,以及用 TaoToken 统一 Key 接入配置(settings.json / config.toml 示例),最后给出帧率与 draw call 的验证动作。适合已经能跑起 Three.js 场景、但帧率掉到 30 以下、想系统做优化的开发者。我试过在一个 2000+ 物体的园区可视化里把 draw call 从 1800 压到 40 以内,下面把过程拆开讲。
2. 前置准备:TaoToken 统一 Key 与项目依赖
在动手改渲染代码前,先把模型加载和 AI 辅助编码这条链路打通。中大型项目里模型资源多、迭代频繁,用统一 Key 管理模型对话、编码计划、API 调用会省很多事。TaoToken 提供统一入口,官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api (不加 UTM)。
你需要先拿到 API Key,在控制台创建:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。拿到 Key 后,接入文档参考 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
项目依赖方面,Three.js 用 r150 以上版本,LOD 和 InstancedMesh 都是内置的,不需要额外插件。性能面板用 stats.js,模型加载用 GLTFLoader。安装命令:
npm install three stats.js npm install -D vite如果你用 Claude Code 或类似编码工具辅助写渲染逻辑,可以把 TaoToken 配成统一后端,走 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。这样模型对话、代码补全、API 调用共用一个 Key,不用在多个配置文件里来回改。
3. LOD 分级参数与可复制配置
LOD 的核心是给同一个物体准备多个精度版本,按距离阈值切换。Three.js 的THREE.LOD对象内部维护一个层级数组,update(camera)会根据相机距离自动选层。关键在阈值怎么定,定得太密会频繁切换导致抖动,定得太疏又起不到优化效果。
下面是我在园区可视化项目里实测可用的一套分级参数。假设高模 5 万面、中模 1.2 万面、低模 2000 面:
import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); function createLODModel(highUrl, midUrl, lowUrl) { const lod = new THREE.LOD(); // 距离 <= 40 显示高模 loader.load(highUrl, (gltf) => { lod.addLevel(gltf.scene, 40); }); // 40 < 距离 <= 120 显示中模 loader.load(midUrl, (gltf) => { lod.addLevel(gltf.scene, 120); }); // 距离 > 120 显示低模 loader.load(lowUrl, (gltf) => { lod.addLevel(gltf.scene, 300); }); return lod; } const building = createLODModel( '/models/building_high.glb', '/models/building_mid.glb', '/models/building_low.glb' ); building.position.set(0, 0, 0); scene.add(building);阈值设定有个经验公式:高模切换距离约等于物体包围盒对角线的 2 到 3 倍,中模约 6 到 8 倍。这样保证物体在屏幕占比足够大时才用高模。渲染循环里必须调用update:
function animate() { requestAnimationFrame(animate); building.update(camera); renderer.render(scene, camera); }注意update只对添加到场景的 LOD 生效,且相机矩阵要已更新。如果场景里有大量 LOD 物体,逐个update会有开销,可以只对视野内的 LOD 调用,配合视锥剔除。
4. InstancedMesh 初始化骨架与矩阵写入
实例化渲染解决的是「大量相同几何体」的问题。1000 棵树、500 个路灯、2000 个粒子,用普通 Mesh 就是 1000 次 draw call,用 InstancedMesh 就是 1 次。核心是把每个实例的变换矩阵写进实例属性。
初始化骨架:
const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({ color: 0x44aa88 }); const count = 2000; const mesh = new THREE.InstancedMesh(geometry, material, count); mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // 需要动态更新时开启 const matrix = new THREE.Matrix4(); const position = new THREE.Vector3(); const quaternion = new THREE.Quaternion(); const scale = new THREE.Vector3(1, 1, 1); for (let i = 0; i < count; i++) { position.set( (Math.random() - 0.5) * 200, 0, (Math.random() - 0.5) * 200 ); quaternion.setFromEuler(new THREE.Euler(0, Math.random() * Math.PI * 2, 0)); matrix.compose(position, quaternion, scale); mesh.setMatrixAt(i, matrix); } mesh.instanceMatrix.needsUpdate = true; mesh.computeBoundingSphere(); // 优化视锥剔除 scene.add(mesh);几个容易踩的坑:setMatrixAt之后必须设instanceMatrix.needsUpdate = true,否则 GPU 拿不到新数据;computeBoundingSphere()不调用的话,视锥剔除会失效,整个 InstancedMesh 要么全画要么全不画;如果实例位置固定,把setUsage设成StaticDrawUsage性能更好。
需要给每个实例不同颜色时,用InstancedBufferAttribute:
const colorArray = new Float32Array(count * 3); for (let i = 0; i < count; i++) { colorArray[i * 3] = Math.random(); colorArray[i * 3 + 1] = Math.random(); colorArray[i * 3 + 2] = Math.random(); } mesh.instanceColor = new THREE.InstancedBufferAttribute(colorArray, 3); mesh.instanceColor.needsUpdate = true;材质要开vertexColors或用支持实例颜色的标准材质,否则颜色不生效。
5. 混合策略:LOD 与实例化怎么配合
单独用 LOD 或单独用实例化都有局限。LOD 对每个物体独立管理,物体数量多时 update 开销大;实例化对相同几何体有效,但所有实例共享同一精度。混合策略是:近处用 LOD 精细控制,中远处用实例化批量渲染。
具体做法是把场景按距离分带。距离相机 80 以内的物体用独立 LOD 对象,保证近景细节;80 以外的同类物体合并进 InstancedMesh,用低模几何体。切换逻辑放在相机移动时触发,不要每帧重算:
let lastCameraPos = new THREE.Vector3(); const switchThreshold = 80; function updateHybrid(camera) { if (camera.position.distanceTo(lastCameraPos) < 5) return; lastCameraPos.copy(camera.position); // 近处 LOD 更新 nearLODGroup.children.forEach((lod) => lod.update(camera)); // 远处实例化可见性 farInstancedMesh.visible = camera.position.length() > switchThreshold; }静态场景可以预计算 LOD 阈值,把每个物体的包围球中心和半径存下来,避免运行时反复算。动态物体则用onBeforeRender钩子自定义切换逻辑,比每帧遍历更省。
6. 验证请求与成功结果:帧率与 draw call 怎么看
优化做完必须验证,不能凭感觉。两个核心指标:FPS 和 draw call。FPS 用 stats.js,draw call 用renderer.info.render.calls。
import Stats from 'stats.js'; const stats = new Stats(); stats.showPanel(0); document.body.appendChild(stats.dom); function animate() { stats.begin(); updateHybrid(camera); renderer.render(scene, camera); // 每 60 帧打印一次 draw call if (frameCount++ % 60 === 0) { console.log('draw calls:', renderer.info.render.calls); console.log('triangles:', renderer.info.render.triangles); } stats.end(); requestAnimationFrame(animate); }优化前的基线:2000 个独立 Mesh,draw call 约 1800,FPS 22。优化后:近处 50 个 LOD 物体,远处 1950 个合并成 3 个 InstancedMesh(按材质分组),draw call 降到 40 左右,FPS 稳定 60。renderer.info.render.triangles也会明显下降,因为远处物体切到了低模。
验证时注意renderer.info在render之后读取才准确,读早了是上一帧数据。另外renderer.info.autoReset默认 true,每帧自动清零,如果你要累计统计得手动关掉。
7. 本篇常见错排查
LOD 不切换:最常见原因是忘了在渲染循环里调lod.update(camera),或者 LOD 对象没加到场景里。还有一种情况是相机矩阵没更新,update读的是旧矩阵。检查camera.updateMatrixWorld()是否在 update 前调用。
InstancedMesh 全黑或位置错乱:setMatrixAt后没设needsUpdate,或者矩阵用了setPosition但没重置旋转缩放。用matrix.compose(position, quaternion, scale)一次性写入最稳。如果实例全挤在原点,检查循环里 position 是否真的变了。
draw call 没降下来:InstancedMesh 按材质分组,如果每个实例材质不同,还是会被拆成多次调用。合并材质或改用实例颜色属性。另外computeBoundingSphere没调会导致剔除失效,但不会增加 draw call,只会增加顶点处理。
移动端实例数量过多掉帧:移动 GPU 对实例数敏感,建议单批控制在 500 到 2000 之间,超过就分批。WebGL 1.0 环境需要ANGLE_instanced_arrays扩展,Three.js 会自动处理,但老设备可能不支持,做好降级。
TaoToken 接入报 401:检查 Key 是否复制完整,settings.json 里字段名是否正确。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,对照字段名排查。模型对话调试可以在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 里先验证 Key 可用,再写进项目配置。
8. 统一 Key 接入配置:settings.json 与 config.toml 示例
项目里如果同时用模型对话、编码辅助、API 调用,建议统一走 TaoToken,避免多套 Key 管理混乱。下面是两种常见配置格式。
settings.json(适用于 Claude Code 类工具):
{ "apiKey": "sk-your-taotoken-key", "baseUrl": "https://taotoken.net/api", "model": "claude-sonnet-4-20250514", "maxTokens": 8192, "timeout": 60000 }config.toml(适用于通用 API 客户端):
[provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "sk-your-taotoken-key" [model] default = "claude-sonnet-4-20250514" max_tokens = 8192 [request] timeout_ms = 60000 retry = 2配置好后用一条 curl 验证连通:
curl -X POST https://taotoken.net/api/v1/messages \ -H "Content-Type: application/json" \ -H "x-api-key: sk-your-taotoken-key" \ -d '{"model":"claude-sonnet-4-20250514","max_tokens":64,"messages":[{"role":"user","content":"ping"}]}'返回正常 JSON 就说明 Key 和地址都对。长期做编码和 Agent 任务的话,Coding Plan 在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,按用量规划更划算。API Key 随时在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 管理,控制台入口 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。
最后提醒一句:LOD 阈值和实例分批数量没有万能值,跟你的模型面数、目标设备、场景密度强相关。先跑基线,记下 draw call 和 FPS,再逐项优化,每改一处就验证一次,别一次性全上导致问题定位困难。