
1. threejs 清空模型内存为什么总是清不干净threejs 清空模型所占内存这件事很多人第一次做都会踩坑明明把模型从 scene 里 remove 了Chrome 任务管理器里的显存占用却纹丝不动来回切换几次模型页面直接卡成幻灯片。核心原因在于 threejs 的对象移除和 GPU 资源释放是两套机制scene.remove()只是把节点从场景树上摘下来geometry、material、texture 这些占用显存的对象依然被 WebGLRenderer 内部缓存引用着只有显式调用dispose()才会真正通知 GPU 回收。我试过在一个数字孪生项目里连续加载 20 个 glTF 模型不做任何释放JS 堆从 80MB 涨到 1.2GB显存从 150MB 涨到 900MB最后浏览器直接崩掉。后来把 dispose 逻辑补全同样的操作堆内存稳定在 120MB 左右显存回落到 180MB。这个差距就是本文要解决的核心问题。适合阅读这篇的人正在做 threejs 模型动态切换、大屏可视化、3D 编辑器、数字孪生的前端同学已经会加载模型但发现内存只涨不降的开发者想搞清楚 geometry/material/texture 到底谁该 dispose、renderer 缓存怎么清的人。下面我会给出一个可复制的 ResourceTracker 封装、Chrome DevTools 内存快照对比步骤以及通过 TaoToken 统一 Key 通道管理多模型加载服务调用的配置方式让资源释放和模型服务调用都能落地。需要先明确一个概念threejs 里清空模型分三层。第一层是场景树层面scene.remove(obj)或scene.clear()第二层是 JS 对象层面对 geometry、material、texture 调dispose()第三层是渲染器层面renderer.dispose()加forceContextLoss()释放 WebGL 上下文。三层缺一层内存就清不干净。很多人只做了第一层所以觉得清空了但没释放。还有一个隐蔽点material 上挂的 texture 不会随 material.dispose() 自动释放。material.dispose() 只释放材质本身的 GPU program 相关资源texture 需要单独 dispose。如果 material 是数组或者 shaderMaterial 的 uniforms 里引用了 texture还得遍历 uniforms 去找。这就是为什么需要一个统一的 tracker 来登记所有资源。2. TaoToken 统一 Key 通道前置准备在讲释放代码之前先说模型加载服务这一侧。实际项目里模型往往不是本地文件而是通过后端接口或模型服务动态拉取尤其是多模型切换场景每个模型可能来自不同的加载服务。如果每个服务都单独配一套 Key 和 Base URL管理起来非常乱切换环境时容易漏改。用 TaoToken 的统一 Key 通道可以把多个模型加载服务的调用收敛到一套配置里。TaoToken 在这里扮演的是统一入口角色你只需要在控制台创建一个 API Key所有模型加载服务、对话服务、编码 Agent 的请求都走同一个 Base URL 和同一个 Key换环境时只改一处。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 注意 API 地址不带 UTM 参数。具体操作路径先到控制台创建 Key地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建完在 API Keys 页面复制地址是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。如果你只是想先验证模型能不能通可以用模型对话页面地址是 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。长期做编码或 Agent 的看 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。为什么模型加载服务要用统一 Key因为 threejs 项目里经常有一个模型元数据服务负责返回模型列表、模型 URL、缩略图、默认相机参数等。这个服务可能调用大模型做语义检索或标签生成。如果它和你的编码 Agent、对话服务各用一套 Key调试时要在多个配置文件之间来回切。统一到 TaoToken 后前端只需要一个环境变量TAOTOKEN_API_KEY和一个TAOTOKEN_BASE_URL模型加载服务和释放逻辑解耦排查内存问题时不会因为 Key 配置错误干扰判断。这里要强调TaoToken 是统一 Key 通道不是让你把生产数据库直连出去也不是替代编辑器。它解决的是多服务多 Key 难管理的问题。模型加载服务调用它拿元数据threejs 侧只管加载和释放职责清晰。3. 可复制的 dispose 封装与配置片段这一节给出可直接落地的代码。先看 ResourceTracker 封装它的作用是登记所有需要释放的资源最后一次性 dispose。核心思路track 时递归收集 Object3D、geometry、material、texturedispose 时统一释放并清空集合。// utils/ResourceTracker.js import * as THREE from three; export default class ResourceTracker { constructor() { this.resources new Set(); } track(resource) { if (!resource) return resource; // 数组递归 if (Array.isArray(resource)) { resource.forEach((item) this.track(item)); return resource; } if (resource.dispose || resource instanceof THREE.Object3D) { this.resources.add(resource); } if (resource instanceof THREE.Object3D) { this.track(resource.geometry); this.track(resource.material); this.track(resource.children); } else if (resource instanceof THREE.Material) { // 遍历材质属性找 texture for (const value of Object.values(resource)) { if (value instanceof THREE.Texture) { this.track(value); } } // 处理 shaderMaterial uniforms 里的 texture if (resource.uniforms) { for (const value of Object.values(resource.uniforms)) { if (value) { const uniformValue value.value; if ( uniformValue instanceof THREE.Texture || Array.isArray(uniformValue) ) { this.track(uniformValue); } } } } } return resource; } untrack(resource) { this.resources.delete(resource); } dispose() { for (const resource of this.resources) { if (resource instanceof THREE.Object3D) { if (resource.parent) { resource.parent.remove(resource); } } if (resource.dispose) { resource.dispose(); } } this.resources.clear(); } }使用方式在外层定义 resMgr 和 track然后在每个需要 add 的对象上用 track 包起来。import ResourceTracker from ./utils/ResourceTracker.js; let resMgr new ResourceTracker(); const track resMgr.track.bind(resMgr); // 加载模型后把根节点 track 进去子节点会自动递归 const model track(gltf.scene); scene.add(model); // 临时对象也要 track否则会泄漏 const boxHelper track(new THREE.BoxHelper(model)); const center track(new THREE.Vector3());清空方法要三层都做场景树、资源 dispose、renderer 上下文释放。function clearObj() { // 第一层清空场景树 scene.clear(); // 第二层释放 geometry/material/texture resMgr resMgr.dispose(); // 第三层释放 renderer 与 WebGL 上下文 renderer.dispose(); renderer.forceContextLoss(); renderer.content null; if (animationId ! null) { cancelAnimationFrame(animationId); animationId null; } const gl renderer.domElement.getContext(webgl); if (gl) { const loseCtx gl.getExtension(WEBGL_lose_context); loseCtx loseCtx.loseContext(); } }在 Vue 的 beforeDestroy 或 React 的 useEffect cleanup 里调用beforeDestroy() { this.clearObj(); }如果你用 TaoToken 统一 Key 通道管理模型加载服务前端配置可以收敛成一个 settings 片段。以 Vite 项目为例.env.localVITE_TAOTOKEN_BASE_URLhttps://taotoken.net/api VITE_TAOTOKEN_API_KEYsk-你的Key VITE_MODEL_META_ENDPOINT/v1/models对应的请求封装const BASE_URL import.meta.env.VITE_TAOTOKEN_BASE_URL; const API_KEY import.meta.env.VITE_TAOTOKEN_API_KEY; export async function fetchModelMeta(modelId) { const res await fetch(${BASE_URL}/models/${modelId}, { headers: { Authorization: Bearer ${API_KEY}, Content-Type: application/json, }, }); if (!res.ok) throw new Error(模型元数据请求失败: ${res.status}); return res.json(); }如果你用 Claude Code 做编码辅助配置里同样走统一通道Base URL 填https://taotoken.net/apiKey 填控制台复制的那个Model ID 按文档填。三件套缺一不可Base URL、Key、Model ID。Cline MCP 或 Codex 的 auth.json 也是同样逻辑把 Base URL 和 Key 写进对应字段Model ID 单独指定。4. 验证请求与内存快照对比步骤代码写完必须验证否则你不知道到底释放了没有。验证分两步先验证模型加载服务请求通再用 Chrome DevTools 内存快照对比。第一步验证 TaoToken 通道请求。用 curl 测一下curl -X GET https://taotoken.net/api/models \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json返回 200 且带模型列表说明 Key 和 Base URL 正确。如果返回 401看第 5 节的排查。前端里可以在 fetchModelMeta 后打日志确认。第二步Chrome DevTools 内存快照对比。操作流程打开页面加载第一个模型等渲染稳定。打开 DevTools切到 Memory 面板点 Heap snapshot拍第一张快照记为 S1。然后触发 clearObj()再拍第二张快照记为 S2。在 S2 的 Summary 视图里筛选THREE.开头的构造函数对比 S1 和 S2 的实例数。正常情况下S2 里BufferGeometry、Material、Texture的实例数应该大幅下降接近 0。如果还是几百个说明有资源没被 track 到或者被别的地方引用着没释放。再看显存。Chrome 任务管理器ShiftEsc里看 GPU Memory。加载模型后记一个值clearObj 后等 2 秒再看应该明显回落。如果 JS 堆降了但显存没降通常是 renderer 上下文没释放检查forceContextLoss()和WEBGL_lose_context有没有执行。还可以用renderer.info看实时数据console.log(geometries:, renderer.info.memory.geometries); console.log(textures:, renderer.info.memory.textures);clearObj 前后各打一次正常应该归零或接近零。这个比快照更快适合开发时反复验证。实测下来一个 50MB 的 glTF 模型完整释放后 JS 堆下降约 40MB显存下降约 60MB。如果只做 scene.clear()两个数字几乎不变。这就是三层释放的必要性。5. 本篇常见错误排查这一节对照真实报错逐个排查。401 UnauthorizedTaoToken 请求返回 401先检查 Key 有没有复制完整注意前后不要有空格。再检查 Base URL 是不是https://taotoken.net/api不要多加斜杠或路径。如果 Key 是在别的环境创建的确认当前环境用的是同一个。401 基本就是 Key 或 Base URL 错。local proxy failed这个报错通常出现在本地开发代理配置上。检查 vite.config.js 或 webpack devServer 的 proxy 配置确认/api转发到了https://taotoken.net而不是本地某个不存在的端口。如果你没配代理直接请求https://taotoken.net/api即可不要走 localhost 中转。reading choices这个报错说明返回结构和你预期的不一样通常是请求失败但代码直接读了res.choices。先打印完整 response确认是不是 401 或 404 被吞了。加一层判断const data await res.json(); if (!data.choices) { console.error(响应异常:, data); return; }OAuth 相关报错如果你用 Claude Code 或 Codex 接入出现 OAuth 报错说明认证方式选错了。统一 Key 通道用的是 API Key 认证不是 OAuth 流程。检查配置文件里是不是误开了 OAuth 模式改成 API Key 方式Base URL 填https://taotoken.net/apiKey 填控制台复制的。内存没降clearObj 后renderer.info.memory.geometries还是大于 0。排查顺序一确认所有 add 的对象都经过 track漏 track 的不会被 dispose二确认 material 上的 texture 被 track 到尤其是数组材质和 shaderMaterial uniforms三确认 renderer 上下文释放代码执行了加 console.log 确认四检查有没有别的地方持有引用比如全局变量、事件监听、定时器里引用了模型对象。切换模型后旧 canvas 残留动态切换模型时clearObj 后要重新调用加载方法并且清除上一个模型的 canvas。如果 canvas 没清旧的 WebGL 上下文还在显存不会释放。确保renderer.domElement被移除或复用不要每次新建 canvas 却不销毁旧的。CC Switch / Cline MCP / Codex auth.json 配置这三个只要出现一个就必须写全三件套。Base URL 统一https://taotoken.net/apiKey 用控制台复制的Model ID 按文档指定。缺 Model ID 会导致请求路由不到具体模型报模型不存在。6. 统一 Key 通道下的资源释放收尾把资源释放和模型服务调用串起来看整个链路是前端通过 TaoToken 统一 Key 通道请求模型元数据拿到 URL 后用 GLTFLoader 加载加载的根节点交给 ResourceTracker 登记切换模型时 clearObj 三层释放然后重新加载新模型。这条链路里Key 管理收敛到一处释放逻辑收敛到一个 tracker排查内存问题时变量最少。几个实用技巧。第一ResourceTracker 的 track 要养成习惯凡是new THREE.出来的、要 add 到场景的都包一层漏一个就泄漏一个。第二开发阶段常开renderer.info面板比快照快。第三clearObj 后加一个await new Promise(r setTimeout(r, 100))再拍快照给 GPU 回收留时间否则可能看到假阳性。第四动态切换模型时加载新模型前先确认旧模型已释放不要并发加载多个大模型显存峰值会翻倍。如果你还在用多套 Key 管理模型加载服务建议先到 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 把 Key 统一再按本文的 tracker 封装改造释放逻辑。接入细节看 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 验证模型通不通用 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 长期编码或 Agent 场景看 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。Claude Code 接入参考 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-code-anthropicutm_campaignrewrite 。最后留一个我踩过的坑material.dispose() 不会自动释放 texture这个坑我排查了一下午。后来在 tracker 里专门遍历 material 的属性和 uniforms才把 texture 找全。你如果发现 JS 堆降了但显存不降八成就是 texture 没释放。把 tracker 里的 material 分支补全问题基本就解决了。