1. 从“AI 描述”说起为什么你的三维场景需要一份机器能读的说明书做过 Three.js 项目的人大概都有过这种体验场景搭好了模型摆进去了灯光也调顺眼了可一旦想让 AI 帮你做点什么——比如自动生成一段场景解说、自动标注物体位置、或者让 AI 助手回答“画面里那个红色的东西是什么”——立刻就卡住了。原因很简单AI 看不见你的场景它只认文字。你辛辛苦苦用代码堆出来的三维世界在 AI 眼里就是一堆它无法解析的二进制和矩阵。这就是“物体 AI 描述”要解决的问题。说白了它就是给你的三维场景里每一个物体写一份机器能读懂的“身份证”把物体的位置、外观、功能、空间关系用结构化的自然语言描述出来让 AI 能够理解你的场景里有什么、在哪里、长什么样、干什么用的。这件事听起来简单做起来坑不少。我在几个 Three.js 项目里反复折腾过这套东西从最开始的手写 JSON 描述到后来结合空间雷达做自动标注再到用编辑器做可视化管理踩过的坑足够写一篇完整的经验贴。这篇文章适合三类人看一是正在做 Three.js 三维可视化项目、想让场景具备 AI 交互能力的开发者二是做 3D 编辑器或场景管理工具、需要给物体附加语义信息的产品设计者三是对“空间雷达”“3D 点云”“AI 描述生成”这些概念感兴趣、想找一个完整落地思路的技术爱好者。不管你是刚接触 Three.js 的新手还是已经做过几个三维项目的老手下面这些内容应该都能帮你少走一些弯路。2. 整体设计思路AI 描述到底该长什么样2.1 为什么不能直接扔给 AI 一张截图很多人第一反应是让 AI 认识场景截个图丢给多模态模型不就行了我一开始也这么想实测下来问题很多。截图丢失了深度信息AI 分不清物体是在前面还是后面截图受光照和视角影响大同一个物体换个角度 AI 就认不出来了截图里的物体没有唯一标识你没法把 AI 的回答映射回场景里的具体对象。更关键的是截图方案在物体数量多、遮挡严重的时候基本不可用。所以正确的思路是不依赖图像而是为每个物体生成一份结构化的文本描述这份描述包含足够的空间信息和语义信息让纯文本模型也能理解场景。这就是“物体 AI 描述”的核心定位——它是场景数据和语言模型之间的桥梁。2.2 描述信息的四个层次我在实际项目中把物体描述拆成四个层次从底到顶依次是标识层物体的唯一 ID、名称、类型。这是最基础的没有唯一 ID 后面所有映射都无从谈起。空间层世界坐标、旋转、缩放、包围盒尺寸。这一层决定了 AI 能不能回答“物体在哪里”“有多大”“朝向哪边”。外观层颜色、材质、贴图特征、大致形状。让 AI 能回答“那个红色的圆柱体是什么”。语义层功能说明、所属分组、与其他物体的关系。这是最高层也是最能体现项目业务价值的部分。这四层不是每层都要写满但标识层和空间层是必须的否则 AI 描述就失去了锚点。外观层和语义层根据项目需求选做但做得越全AI 能回答的问题就越丰富。2.3 为什么选 JSON 加自然语言双轨制描述格式上我试过三种方案纯 JSON、纯自然语言、JSON 加自然语言混合。最后稳定用的是混合方案。纯 JSON 机器友好但 AI 理解起来费劲纯自然语言 AI 好理解但程序不好解析。混合方案是每个物体先给一段结构化的 JSON 数据再给一句自然语言摘要比如{ id: obj_017, name: 红色警示柱, type: safety_cone, position: {x: 3.2, y: 0, z: -1.8}, size: {w: 0.4, h: 0.8, d: 0.4}, color: #ff3300, summary: 一个红色的锥形警示柱位于场景中心偏右前方高度约0.8米用于标记危险区域。 }这样程序可以读 JSON 做逻辑处理AI 可以读 summary 做语义理解两边都不耽误。实测下来这种双轨制在检索和问答场景里表现最稳。3. 核心细节解析空间雷达与坐标系统怎么配合3.1 空间雷达在描述生成中的角色“空间雷达”这个词听起来玄乎其实在 Three.js 项目里它通常指两种东西一种是场景中用于探测物体位置关系的虚拟射线或包围盒检测机制另一种是真实硬件雷达比如 3D 结构光相机、激光雷达采集的点云数据。不管哪种它的核心作用都是提供物体的空间位置信息。我用得最多的是基于 Three.js 的Raycaster和Box3做虚拟空间雷达。具体做法是对场景中每个需要描述的物体用Box3.setFromObject()算出它的世界坐标包围盒然后用Raycaster从物体中心向六个方向发射射线检测它周围有没有其他物体、距离多远。这些检测结果会作为“空间关系”写入 AI 描述。const box new THREE.Box3().setFromObject(object); const center box.getCenter(new THREE.Vector3()); const size box.getSize(new THREE.Vector3()); // 向六个方向探测最近物体 const directions [ new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1) ];这段代码是空间雷达的核心每个方向探测到的最近物体和距离都会成为描述里“空间关系”字段的素材。比如“右侧0.5米处有一个蓝色箱子”这种描述就是从这里来的。3.2 坐标系统一别让 AI 拿到混乱的位置信息这里有一个特别容易踩的坑Three.js 里物体的position是相对于父级的局部坐标而 AI 描述需要的是世界坐标。如果你的场景有嵌套的 Group 结构直接拿object.position写进描述AI 得到的位置全是错的。正确做法是用getWorldPosition()const worldPos new THREE.Vector3(); object.getWorldPosition(worldPos);同样旋转和缩放也要用世界矩阵分解后的值。我建议在生成描述之前先对整个场景做一次遍历把所有物体的世界变换矩阵算出来缓存好避免重复计算。物体多的时候这一步能省不少性能。注意如果场景里有动态物体比如正在移动的模型描述生成要放在动画循环之外或者在物体静止时生成否则你写进描述的位置和 AI 实际“看到”的位置会对不上。3.3 包围盒尺寸的换算与单位统一Three.js 默认没有物理单位你的 1 可能代表 1 米也可能代表 1 厘米。AI 描述里如果不注明单位AI 就没法判断物体大小是否合理。我的做法是在项目初始化时定义一个全局比例尺比如SCALE 1代表 1 米所有描述里的尺寸都按这个比例换算成米并在描述开头注明“以下尺寸单位均为米”。包围盒尺寸的计算要注意一点Box3.getSize()返回的是轴对齐包围盒的尺寸对于旋转过的物体这个尺寸会比物体实际尺寸大。如果你需要精确尺寸得用物体的几何体原始尺寸乘以缩放。这个细节在描述细长物体时特别重要不然 AI 会以为一根斜放的杆子比实际粗很多。4. 实操过程从零搭建一套 AI 描述生成流程4.1 场景遍历与物体筛选第一步是把场景里所有需要描述的物体找出来。不是所有物体都值得写描述——灯光、相机、辅助线这些就不需要。我的筛选规则是只处理Mesh、Points、Line类型的可见物体并且排除掉userData.noDescribe标记为 true 的对象。const describableObjects []; scene.traverse((obj) { if (obj.isMesh obj.visible !obj.userData.noDescribe) { describableObjects.push(obj); } });遍历的时候顺便给每个物体分配一个稳定的唯一 ID。不要用object.id因为 Three.js 的 id 在场景重建时会变。我一般用userData.aiId存一个自己生成的 UUID或者用“类型_序号”的格式比如cone_001、box_003这样可读性更好。4.2 逐物体生成描述数据对每个筛选出来的物体按前面说的四层结构生成数据。这里我贴一段实际项目里用的生成函数核心逻辑function generateObjectDescription(obj) { const box new THREE.Box3().setFromObject(obj); const center box.getCenter(new THREE.Vector3()); const size box.getSize(new THREE.Vector3()); const worldPos new THREE.Vector3(); obj.getWorldPosition(worldPos); const desc { id: obj.userData.aiId, name: obj.userData.aiName || obj.name || 未命名物体, type: obj.userData.aiType || generic, position: { x: (worldPos.x * SCALE).toFixed(2), y: (worldPos.y * SCALE).toFixed(2), z: (worldPos.z * SCALE).toFixed(2) }, size: { w: (size.x * SCALE).toFixed(2), h: (size.y * SCALE).toFixed(2), d: (size.z * SCALE).toFixed(2) }, color: extractDominantColor(obj), summary: }; desc.summary buildSummary(desc, obj); return desc; }extractDominantColor这个函数需要自己实现简单做法是读材质的color属性复杂一点可以采样贴图的主色调。buildSummary就是把结构化数据拼成一句自然语言模板大概是“一个[颜色]的[形状][名称]位于场景[方位描述]尺寸约[长]x[宽]x[高]米[功能说明]”。4.3 空间关系计算与写入空间关系是描述里最有价值也最难算的部分。我的做法是两两计算物体之间的距离只保留距离小于某个阈值比如 2 米的关系对然后按距离排序每个物体最多记录 3 个最近邻居。function computeSpatialRelations(objects, threshold 2.0) { const relations {}; for (let i 0; i objects.length; i) { relations[objects[i].id] []; for (let j 0; j objects.length; j) { if (i j) continue; const dist calcDistance(objects[i], objects[j]); if (dist threshold) { relations[objects[i].id].push({ targetId: objects[j].id, distance: dist.toFixed(2), direction: calcDirection(objects[i], objects[j]) }); } } relations[objects[i].id].sort((a, b) a.distance - b.distance); relations[objects[i].id] relations[objects[i].id].slice(0, 3); } return relations; }calcDirection返回的是“左/右/前/后/上/下”这种方位词计算方法是拿两个物体的世界坐标差值看哪个轴的绝对值最大就归到哪个方位。这里要注意坐标系朝向——Three.js 默认 Y 轴向上Z 轴朝向观察者所以 Z 值大的物体在“前面”。如果你的项目改了坐标系方位词也要跟着调整。4.4 描述数据的组织与输出所有物体的描述生成完之后我一般组织成这样的结构输出{ sceneName: 仓库安全巡检场景, unit: meter, generatedAt: 2025-01-15T10:30:00Z, objectCount: 24, objects: [ ... ], relations: { ... } }这个 JSON 可以直接喂给语言模型做场景问答也可以存进数据库供后续检索。如果场景物体特别多超过 100 个建议分页输出不然一次塞给 AI 会超出上下文限制。我一般按空间区域分块比如“A 区物体”“B 区物体”每块单独生成一份描述。5. 编辑器集成让非技术人员也能维护 AI 描述5.1 为什么需要一个描述编辑器纯代码生成描述有个问题语义层的信息功能说明、业务含义代码是猜不出来的。一个红色的圆柱体代码只能描述成“红色圆柱体”但业务上它可能是“消防栓”也可能是“警示柱”。这部分必须有人来填。所以我一般会在项目里做一个轻量的描述编辑器基于 Three.js 的场景编辑器扩展。核心功能就三个点选物体、编辑描述字段、保存导出。用原生 HTML 加 Three.js 的Raycaster就能做不需要引入重型编辑器框架。5.2 点选与高亮实现点选的核心是射线检测const raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); renderer.domElement.addEventListener(click, (event) { const rect renderer.domElement.getBoundingClientRect(); mouse.x ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y -((event.clientY - rect.top) / rect.height) * 2 1; raycaster.setFromCamera(mouse, camera); const intersects raycaster.intersectObjects(describableObjects); if (intersects.length 0) { selectObject(intersects[0].object); } });选中之后给物体加一个高亮描边我一般用EdgesGeometry加LineSegments做轮廓比改材质颜色更直观也不会破坏原有外观。5.3 描述字段的表单绑定选中物体后右侧面板显示该物体的描述字段用普通的 HTML 表单绑定。每个字段修改后实时更新到userData里同时重新生成 summary。这里有个细节summary 的重新生成要防抖不然用户每敲一个字都重算一遍空间关系会很卡。我一般设 300 毫秒防抖。提示编辑器里的描述修改一定要支持撤销。我试过用命令模式记录每次修改简单可靠。没有撤销功能的编辑器用户误操作一次就不敢用了。5.4 导出与版本管理编辑完的描述要能导出成 JSON 文件同时最好带一个版本号和时间戳。我在项目里用localStorage做草稿自动保存防止浏览器崩溃丢数据。正式导出时生成一个带哈希的文件名方便追溯。如果团队协作可以把导出的 JSON 提交到代码仓库用 Git 做版本管理比在编辑器里做复杂的版本功能省事得多。6. 常见问题与排查技巧实录6.1 描述生成后 AI 还是答非所问最常见的原因是描述里缺少方位参照。AI 拿到一堆绝对坐标是没法理解“左边那个”指的是什么的。解决办法是在场景描述开头加一段全局参照说明比如“以场景中心为原点X 轴正方向为东Z 轴正方向为南”然后在物体描述里用方位词而不是纯坐标。我实测下来加了方位参照之后 AI 回答空间问题的准确率明显提升。6.2 物体太多导致描述 JSON 过大超过 200 个物体的场景全量描述 JSON 可能有好几 MB直接塞给 AI 不现实。我的处理策略是分层先给 AI 一份“场景概览”只包含物体类型统计和大致分布AI 需要细节时再按区域或类型查询具体物体的描述。这其实就是 RAG 的思路把描述数据当知识库按需检索。6.3 动态物体的描述更新时机场景里有移动物体时描述不能只生成一次。我的做法是给动态物体加一个needsUpdate标记在动画循环里检测位置变化超过阈值时重新生成该物体的描述。阈值一般设 0.1 米太小会频繁重算太大描述会滞后。6.4 贴图颜色提取不准用材质color属性提取颜色对于纯色材质没问题但贴图材质的color通常是白色。这时候需要采样贴图。简单做法是用Canvas把贴图画上去读像素取平均色。注意跨域贴图会污染 Canvas需要服务端配 CORS 或者用代理。这个问题我踩过好几次最后统一在资源加载阶段就把主色调算好存进userData避免运行时采样。6.5 常见问题速查表问题现象可能原因排查方法解决方向AI 找不到物体描述里没有唯一 ID检查 JSON 的 id 字段生成稳定 UUID位置描述错误用了局部坐标打印 worldPosition 对比改用 getWorldPosition尺寸明显偏大包围盒未考虑旋转对比几何体原始尺寸用几何体尺寸乘缩放方位词反了坐标系朝向假设错误打印相机和物体坐标按实际坐标系调整描述 JSON 过大物体数量过多统计物体数和文件大小分区域分页输出编辑器修改丢失没有自动保存检查 localStorage加防抖自动保存6.6 几个我踩过的坑第一个坑是 ID 冲突。早期我用物体名称做 ID结果场景里有两个都叫“box”的物体AI 描述里就混了。后来改成 UUID 加可读名称双字段UUID 做映射名称做展示再没出过问题。第二个坑是描述生成时机。我一开始在场景加载完成后立即生成结果有些异步加载的模型还没到位描述里缺了不少物体。后来改成监听所有资源加载完成事件或者延迟 500 毫秒再生成稳定多了。第三个坑是中文描述里的量词。AI 对“一个”“一根”“一块”这些量词很敏感用错了会影响理解。我后来在类型定义里加了量词字段锥形用“个”杆状用“根”板状用“块”描述读起来自然多了AI 的理解也更准。7. 描述质量优化让 AI 真正“看懂”你的场景7.1 描述粒度的取舍描述不是越细越好。我试过把每个物体的顶点数、材质参数都写进去结果 AI 被无关信息干扰回答质量反而下降。后来我定了一个原则只写对“理解场景”有用的信息。什么是理解场景就是能回答“有什么”“在哪里”“什么样”“干什么用”这四个问题。超出这个范围的信息除非业务需要否则不写。7.2 自然语言摘要的模板设计摘要模板我改过很多版最后稳定在一个比较通用的结构[量词][颜色][形状][名称]位于[方位描述]尺寸约[长]x[宽]x[高]米[功能说明][空间关系]。这个模板的好处是信息密度高读起来也顺。功能说明和空间关系是可选的没有就不写。方位描述用“场景中心偏左前方”这种相对描述比纯坐标好懂得多。7.3 用 AI 反哺描述质量有个技巧我一直在用把生成的描述喂给语言模型让它回答几个标准问题比如“场景里最大的物体是什么”“红色物体有几个”“A 物体和 B 物体哪个更靠前”。如果 AI 答错了说明描述有问题回去改。这个反向验证的方法比人工检查效率高得多而且能发现一些自己想不到的盲区。7.4 多语言描述的扩展如果项目需要支持多语言描述生成要做国际化。我的做法是把模板抽成配置每种语言一套模板结构化数据不变只换摘要的拼接方式。这样切换语言只需要换模板不用重新生成数据。实测下来这个方案维护成本最低。8. 性能与规模大场景下的描述生成策略8.1 增量生成与缓存场景物体多的时候全量重新生成描述很耗时。我的做法是给每个物体算一个“描述哈希”由位置、旋转、缩放、材质关键属性拼成。哈希没变就跳过重新生成直接用缓存。实测在 500 个物体的场景里增量生成比全量快 10 倍以上。8.2 Web Worker 卸载计算空间关系计算是 O(n²) 的复杂度物体一多主线程就卡。我把这部分放到 Web Worker 里跑主线程只负责收集数据和展示结果。Worker 里不能直接访问 Three.js 对象所以要先在主线程把物体的世界坐标和包围盒数据提取成纯数据传过去。这个改造做完1000 个物体的场景生成描述也不卡了。8.3 描述数据的存储与检索生成的描述数据如果只在内存里刷新就没了。我一般存两份一份 IndexedDB 做本地缓存一份导出 JSON 做持久化。检索的时候用简单的关键词匹配就够不需要上向量数据库。如果场景特别大、检索需求复杂再考虑上向量检索也不迟。9. 我在这套流程里最看重的几个经验做了几个项目下来我觉得最关键的经验就三条。第一条是描述结构要早定不要边做边改。我第一个项目就是结构改来改去导致历史数据全废重生成花了两天。第二条是编辑器要早做哪怕功能简陋也比纯手写 JSON 强。有了编辑器非技术同事也能参与描述维护效率完全不一样。第三条是反向验证要常态化每次改完描述生成逻辑都跑一遍标准问题集确保 AI 的理解没有退化。最后分享一个我最近在用的技巧把物体描述和场景截图一起喂给多模态模型让模型对比“文字描述的场景”和“截图看到的场景”是否一致。不一致的地方往往就是描述遗漏或错误的地方。这个方法用来做描述质量巡检特别好使比人工一个个核对快多了。