☰
Three.js三维场景物体AI描述:结构化JSON字段设计与标注实践
2026/10/29 17:44:48 网站建设 项目流程

1. 从“AI 描述”说起:为什么你的三维场景需要一份机器能读懂的说明书

做过 Three.js 项目的人大概都有过这种体验:场景搭得漂漂亮亮,模型、材质、灯光都调到位了,结果一旦想让 AI 帮忙做点事情——比如自动生成一段场景解说、自动标注物体、自动生成操作指引,甚至只是让 AI 帮你写个测试用例——它就开始胡言乱语。你告诉它“场景里有个沙发”,它反手给你编出一段“现代简约风格客厅,阳光透过落地窗洒在浅灰色布艺沙发上”的文案,听起来很美,但你的场景里那个沙发其实是低多边形卡通风格,颜色是亮橙色,而且根本没有窗户。

问题出在哪?出在你从来没有正式地、结构化地告诉过 AI 你的世界里到底有什么。

这就是“物体 AI 描述”要解决的事情。它不是给人看的文案,而是给机器看的结构化语义标签。你可以把它理解成给三维场景里的每一个物体发一张身份证,上面写清楚:我叫什么、我在哪、我长什么样、我和谁挨着、我能干什么。有了这张身份证,AI 才能在你的场景里“认路”,才能做出靠谱的判断和生成。

这篇文章适合三类人看:第一类是做 Three.js 或 WebGL 三维项目、想让 AI 介入交互的开发者;第二类是在做空间雷达、3D 点云、三维重建相关产品、需要给点云或网格物体打语义标签的工程师;第三类是对“AI 理解三维世界”这件事好奇、想动手试试的爱好者。不管你是哪一类,接下来的内容都会从思路到代码、从字段设计到踩坑记录,给你一套可以直接抄作业的方案。

我自己的背景是做了几年三维可视化项目,从最早的纯手工标注,到后来写脚本半自动生成,再到现在结合大模型做辅助描述,中间踩过的坑足够写一本小册子。下面这些经验,都是实际项目里验证过的,不是纸上谈兵。

2. 物体 AI 描述的整体设计思路

2.1 先搞清楚:AI 描述和人类描述到底差在哪

很多人第一次做这件事的时候,会本能地按照“写文案”的思路来。比如给一个杯子写描述:“这是一个白色的陶瓷马克杯,放在木桌上,杯口朝上。”这句话对人来说信息量足够,但对 AI 来说,问题很多:白色是哪个白?陶瓷的粗糙度是多少?木桌是什么木?杯口朝上是哪个坐标系下的朝上?“放在”是接触还是悬空?

所以物体 AI 描述的第一条设计原则是:能结构化的绝不写成自然语言,必须写自然语言的也要带上明确的字段名和取值范围。

我通常会把描述拆成几个层次:

  • 身份层:这个物体是什么。包括类别、子类别、可能的别名。
  • 几何层:它在哪、多大、什么朝向。包括位置、旋转、缩放、包围盒。
  • 外观层:它看起来什么样。包括颜色、材质类型、贴图信息。
  • 关系层:它和别的物体什么关系。包括父子、相邻、遮挡、支撑。
  • 功能层:它能干什么、有什么交互可能。包括可点击、可移动、可破坏。

这五层不是每层都要写满,但至少身份层和几何层是必须的。少了这两层,AI 基本没法用。

2.2 为什么选 JSON 作为主要载体

在格式选型上,我试过几种方案:纯文本、YAML、JSON、还有自定义的二进制格式。最后稳定在 JSON 上,原因很实际。

纯文本对 AI 友好,但解析起来太脆弱,稍微换个说法就提取不到字段。YAML 可读性好,但嵌套深了之后缩进容易出错,而且很多前端库对 YAML 的支持不如 JSON 直接。自定义二进制格式性能最好,但调试的时候得写专门的工具,团队协作成本太高。

JSON 的好处是:浏览器原生支持、Three.js 生态里到处都是、大模型对 JSON 的理解也足够好。你可以直接把一个物体的描述对象JSON.stringify之后塞给模型,模型基本能正确理解字段含义。而且 JSON 的嵌套结构天然适合表达“物体-子物体-部件”这种层级关系。

提示:如果你的场景物体数量超过几千个,全量 JSON 会很大。这时候不要一股脑塞给 AI,而是先做检索或过滤,只把相关物体的描述传给模型。这一点后面会展开讲。

2.3 编辑器在其中的角色:不是让你手写 JSON

听到“给每个物体写描述”,很多人的第一反应是:那我岂不是要手动写几百个 JSON?当然不是。这就是编辑器要解决的问题。

我这里说的编辑器,不是指那种通用的富文本编辑器或者 PDF 编辑器,而是面向三维场景的语义标注编辑器。它的核心功能是:你在三维视图里点选一个物体,编辑器自动提取它的几何和外观信息,你只需要补充类别和功能描述,剩下的字段自动生成。

一个合格的标注编辑器至少要有这几个能力:

  • 三维视图与物体列表联动,点列表高亮物体,点物体定位列表项。
  • 自动提取位置、旋转、缩放、包围盒、材质颜色等硬信息。
  • 提供类别下拉和别名输入,减少手打。
  • 支持批量操作,比如给一组物体统一打上“可交互”标签。
  • 导出为标准 JSON,能直接被业务代码或 AI 接口消费。

如果你不想从零写编辑器,也可以先用 Three.js 的TransformControls加一个简单的 HTML 面板凑合,后面再逐步完善。关键是先把字段规范定下来,工具可以慢慢迭代。

3. 核心字段设计与实操要点

3.1 身份层字段:别小看“叫什么”这件事

身份层看起来最简单,其实最容易出问题。我见过太多项目,类别命名随心所欲,今天叫“chair”,明天叫“seat”,后天叫“furniture_chair”,最后 AI 拿到一堆同义词,根本没法统一处理。

我的建议是:主类别用固定枚举,别名用自由文本。主类别是你系统内部约定的、有限的几个大类,比如 furniture、vehicle、building、plant、tool 等。别名则是用户或标注者可以自由填写的,用来兼容不同说法。

{ "identity": { "category": "furniture", "subcategory": "chair", "aliases": ["座椅", "凳子", "seat"], "instanceId": "chair_001" } }

instanceId很重要。同一个场景里可能有多把椅子,AI 需要能区分“第一把椅子”和“第二把椅子”。这个 ID 最好在场景加载时就生成好,并且在整个生命周期里保持不变。

还有一个容易忽略的点:类别名称的语言。如果你的 AI 接口主要用中文提示词,那类别最好也存一份中文;如果主要用英文,就存英文。不要混着来,否则模型有时候会中英夹杂地输出,很难处理。我通常的做法是主字段存英文,aliases 里放中文,这样两边都能覆盖。

3.2 几何层字段:位置、旋转、包围盒怎么写才不坑

几何层是 AI 描述里最“硬”的部分,也是最容易写错的。常见的坑有三个。

第一个坑是坐标系不统一。Three.js 默认是 Y 轴向上,但有些建模软件导出的是 Z 轴向上。如果你直接把建模软件里的坐标写进描述,AI 拿到的“上方”可能和场景里的“上方”不一致。解决办法是在导出描述时统一转换到场景坐标系,并且明确标注坐标系类型。

{ "geometry": { "coordinateSystem": "Y-up", "position": { "x": 1.2, "y": 0.0, "z": -3.4 }, "rotation": { "x": 0, "y": 1.57, "z": 0 }, "scale": { "x": 1, "y": 1, "z": 1 }, "boundingBox": { "min": { "x": 0.8, "y": 0.0, "z": -3.8 }, "max": { "x": 1.6, "y": 0.9, "z": -3.0 } } } }

第二个坑是旋转的表示方式。欧拉角直观但有多解问题,四元数精确但人看不懂。我的做法是两者都存:欧拉角给人看和调试,四元数给程序用。如果场景里物体旋转很复杂,优先信四元数。

第三个坑是包围盒的更新时机。Three.js 里Box3.setFromObject算出来的包围盒是轴对齐的,如果物体旋转了,包围盒会变大。如果你想要更精确的尺寸,得用OBB(有向包围盒),但计算和存储都更麻烦。一般场景用轴对齐包围盒就够了,只有在做精细碰撞或空间雷达分析时才需要 OBB。

注意:包围盒的数值一定要在物体完成所有变换之后再计算。我踩过一次坑,在物体还没加到场景里就算包围盒,结果全是零,排查了半天才发现是时机问题。

3.3 外观层字段:颜色和材质怎么描述才准确

外观层最容易犯的错是只写一个颜色名,比如“红色”。但红色有几百种,AI 拿到“红色”根本没法做精确判断。

我的做法是:颜色名 + 十六进制值 + 材质类型三件套。

{ "appearance": { "colorName": "orange", "colorHex": "#FF6B35", "materialType": "MeshStandardMaterial", "roughness": 0.6, "metalness": 0.1, "hasTexture": false } }

colorName是给人看的,colorHex是给程序用的。materialType能帮 AI 判断这个物体是哑光还是反光、是透明还是实心。roughness和metalness是 PBR 材质的关键参数,如果你的场景用了 PBR,这两个值一定要带上。

如果物体有贴图,hasTexture设为 true,并且最好再存一个贴图的语义描述,比如“木纹”“砖墙”“布料”。这个描述可以人工填,也可以用图像模型自动生成。我试过用视觉模型给贴图打标签,准确率还不错,但遇到抽象图案就容易翻车,所以关键物体还是人工确认一下。

3.4 关系层字段:让 AI 理解“谁挨着谁”

关系层是很多人会跳过的一层,但它对 AI 理解场景至关重要。举个例子,如果 AI 只知道场景里有一张桌子和一把椅子,它可能给出“桌子和椅子”这样的描述。但如果它知道椅子在桌子旁边、并且朝向桌子,它就能给出“一把面向桌子摆放的椅子”这种更准确的描述。

关系层我通常存这几类:

  • 父子关系:parentId,用于表达组合物体。
  • 空间关系:nearby,列出距离最近的几个物体 ID。
  • 支撑关系:supportedBy,表达“放在什么上面”。
  • 朝向关系:facing,表达“朝向什么”。
{ "relations": { "parentId": null, "nearby": ["table_001", "lamp_002"], "supportedBy": "floor_000", "facing": "table_001" } }

nearby的计算可以用简单的距离排序,也可以用空间雷达的思路做扇形扫描。如果你的场景是动态的,关系层需要定期更新,否则 AI 拿到的就是过期信息。

3.5 功能层字段:给交互留出接口

功能层不是每个物体都有,但只要有交互可能,就值得写。比如一个按钮、一扇门、一个可拾取的道具。

{ "function": { "interactive": true, "actions": ["click", "hover"], "movable": false, "destructible": false, "description": "点击后打开门" } }

actions列出这个物体支持的交互类型,description用一句话说明交互效果。这个描述可以写得稍微自然语言一点,因为它是给 AI 做提示词用的,不是给程序解析的。

实操心得:功能层的description最好控制在 20 字以内,太长了模型容易忽略。而且要用动词开头,比如“点击后打开门”就比“这个门可以被点击然后打开”更好。

4. 实操过程:从零搭建一个物体描述生成流程

4.1 场景准备与物体遍历

假设你已经有一个 Three.js 场景,里面有一堆物体。第一步是遍历场景,找出所有需要描述的物体。

import * as THREE from 'three'; function collectDescribableObjects(scene) { const objects = []; scene.traverse((obj) => { if (obj.isMesh || obj.isGroup) { objects.push(obj); } }); return objects; }

这里有个细节:traverse会遍历所有子物体,包括那些只是用来做分组的空Group。如果你不想给空组写描述,可以在判断里加上obj.children.length > 0 || obj.isMesh。

另外,有些物体是辅助对象,比如网格线、坐标轴、灯光辅助器,这些不应该出现在 AI 描述里。我的做法是给这些辅助对象打一个userData.isHelper = true标记,遍历时跳过。

4.2 自动提取几何与外观信息

接下来是给每个物体提取硬信息。这部分代码比较长,但逻辑很直接。

function extractGeometryInfo(obj) { const box = new THREE.Box3().setFromObject(obj); const position = obj.getWorldPosition(new THREE.Vector3()); const rotation = obj.getWorldQuaternion(new THREE.Quaternion()); const euler = new THREE.Euler().setFromQuaternion(rotation); const scale = obj.getWorldScale(new THREE.Vector3()); return { coordinateSystem: 'Y-up', position: { x: round(position.x), y: round(position.y), z: round(position.z) }, rotation: { x: round(euler.x), y: round(euler.y), z: round(euler.z) }, quaternion: { x: round(rotation.x), y: round(rotation.y), z: round(rotation.z), w: round(rotation.w) }, scale: { x: round(scale.x), y: round(scale.y), z: round(scale.z) }, boundingBox: { min: { x: round(box.min.x), y: round(box.min.y), z: round(box.min.z) }, max: { x: round(box.max.x), y: round(box.max.y), z: round(box.max.z) } } }; } function round(n) { return Math.round(n * 1000) / 1000; }

round函数是为了控制精度。三维坐标保留三位小数足够了,再多就是噪声,还会让 JSON 变大。

外观信息的提取稍微麻烦一点,因为材质可能是数组,也可能嵌套。

function extractAppearanceInfo(obj) { const materials = Array.isArray(obj.material) ? obj.material : [obj.material]; const main = materials[0]; if (!main) return null; return { colorName: guessColorName(main.color), colorHex: '#' + main.color.getHexString(), materialType: main.type, roughness: main.roughness ?? null, metalness: main.metalness ?? null, hasTexture: !!main.map }; }

guessColorName需要你自己实现一个颜色到名称的映射。简单做法是用一组预设颜色做最近邻匹配,复杂做法可以调用颜色命名 API。我一般用预设的二十来个基础色,够用了。

4.3 人工补充类别与功能描述

自动提取只能拿到硬信息,类别和功能还得人来补。这就是编辑器发挥作用的地方。

我的编辑器界面大概是这样的:左边是物体列表,中间是三维视图,右边是属性面板。点选物体后,属性面板显示自动提取的信息,并且提供类别下拉、别名输入、功能勾选框。

为了减少重复劳动,我加了两个功能:一是“同类继承”,选中多个物体后可以批量设置类别;二是“模板填充”,预设几种常见物体的描述模板,比如椅子、桌子、门,一键套用。

提示:批量操作一定要加确认步骤。我有一次手滑把整个场景的物体都设成了“可破坏”,结果测试的时候一点就碎,排查了好久。

4.4 导出与校验

描述写完之后,导出成 JSON。但导出之前一定要做校验,否则脏数据流到 AI 那边,问题更难查。

校验我通常检查这几项:

  • 必填字段是否存在:identity.category、geometry.position、geometry.boundingBox。
  • 数值是否合法:位置和包围盒不能是 NaN,缩放不能是零。
  • ID 是否唯一:instanceId不能重复。
  • 关系引用是否有效:nearby和supportedBy里的 ID 必须在场景里存在。
function validateDescription(desc, allIds) { const errors = []; if (!desc.identity?.category) errors.push('缺少 category'); if (!desc.geometry?.position) errors.push('缺少 position'); if (isNaN(desc.geometry.position.x)) errors.push('position.x 非法'); if (desc.relations?.nearby) { desc.relations.nearby.forEach((id) => { if (!allIds.has(id)) errors.push(`nearby 引用了不存在的 ID: ${id}`); }); } return errors; }

校验不通过的物体,导出时标记出来,让人工复查。不要抱着“差不多就行”的心态,AI 对脏数据的容忍度比人低得多。

4.5 把描述喂给 AI 的正确姿势

描述生成好了,怎么用?直接全量塞给模型是最蠢的做法,token 烧得快,效果还差。

我的做法是分三步:

第一步,检索。根据用户的问题或任务,先筛选出相关物体。比如用户问“桌子旁边有什么”,那就先找到桌子,再通过nearby字段找到相关物体。

第二步,裁剪。只把相关物体的描述传给模型,并且去掉模型不需要的字段。比如做空间关系推理时,colorHex和materialType就可以不传。

第三步,组装提示词。把裁剪后的 JSON 转成紧凑格式,加上任务说明,一起发给模型。

function buildPrompt(task, objects) { const compact = objects.map((o) => ({ id: o.identity.instanceId, category: o.identity.category, position: o.geometry.position, nearby: o.relations?.nearby || [] })); return `任务:${task}\n场景物体:${JSON.stringify(compact)}\n请根据以上信息回答。`; }

这样一套下来,token 消耗能降一个数量级,模型回答的准确率反而更高,因为干扰信息少了。

5. 常见问题与排查技巧实录

5.1 物体描述生成了但 AI 还是答非所问

这是最常见的问题。原因通常有三个:字段命名不一致、数值精度太差、缺少关键关系。

排查方法:先把描述 JSON 打印出来,自己读一遍,看看能不能仅凭这些信息回答你的问题。如果你自己都答不出来,AI 更答不出来。

我遇到过一次,位置信息只保留了整数,结果两个相邻物体的坐标看起来一模一样,AI 以为它们是同一个位置。改成保留三位小数后问题就解决了。

5.2 场景物体太多,描述文件大到打不开

几千个物体的全量描述确实会很大。解决办法不是压缩,而是分层。

我的做法是:给每个物体一个importance字段,取值 high、medium、low。导出时默认只导出 high 和 medium,low 的物体按需加载。另外,nearby只存最近的三个,不要存全部。

如果还是大,可以考虑把描述拆成多个文件,按空间区域分片。AI 查询时先定位区域,再加载对应分片。

5.3 动态物体的描述更新不及时

场景里如果有会动的物体,描述必须跟着更新。但每帧都更新所有描述显然不现实。

我的策略是:位置和旋转每 500 毫秒更新一次,关系和功能只在变化时更新。500 毫秒对人来说几乎无感,对 AI 来说也足够新鲜。如果物体移动很快,可以缩短到 200 毫秒,但不要低于 100 毫秒,否则性能吃不消。

5.4 常见问题速查表

问题现象可能原因排查方法解决方式
AI 答非所问字段缺失或命名不一致人工读一遍 JSON统一字段命名,补全必填项
描述文件过大全量导出且精度过高查看文件大小和字段数分层导出,降低精度,裁剪 nearby
位置信息不准坐标系未统一对比建模软件和场景坐标导出时统一转换坐标系
关系引用失效物体被删除但描述未更新校验 ID 是否存在删除物体时同步清理关系引用
动态物体描述过期更新频率太低检查更新间隔调整到 200-500 毫秒
颜色描述不准确只存了颜色名检查是否有 colorHex颜色名和十六进制值都存

5.5 几个我踩过的坑

第一个坑:在物体还没加入场景时就算包围盒。结果全是零。后来改成在scene.add之后、下一帧再计算,问题解决。

第二个坑:用obj.position而不是getWorldPosition。如果物体有父级,obj.position是局部坐标,不是世界坐标。AI 需要的是世界坐标,所以一定要用getWorldPosition。

第三个坑:忘了处理材质数组。有些模型一个 mesh 有多个材质,直接读obj.material.color会报错。要先判断是不是数组。

第四个坑:JSON 里的中文没有转义。有些 AI 接口对非 ASCII 字符处理不好,建议在传输前用encodeURIComponent或者直接存英文,中文放 aliases 里。

第五个坑:关系层没有做循环检测。A 的 nearby 里有 B,B 的 nearby 里有 A,如果 AI 做递归推理就会死循环。我的做法是 nearby 只存单向的,或者加一个深度限制。

6. 进阶玩法:让描述系统自己进化

6.1 用视觉模型自动生成外观描述

前面说的外观描述主要靠人工填,但其实可以用视觉模型自动生成。做法是:把物体单独渲染成一张图,背景透明,然后让视觉模型描述这张图。

我试过几个模型,对常见物体的识别准确率还不错,能说出“橙色布艺沙发”“木质圆桌”这种描述。但遇到抽象物体或者低多边形风格就容易翻车,会说成“未知物体”。所以我的做法是:模型生成的结果作为 aliases 的候选,人工确认后再写入。

6.2 用空间雷达思路优化关系层

空间雷达的核心思想是:以物体为中心,向外做扇形扫描,找出各个方向上的邻近物体。这个思路可以用在关系层上,比简单的距离排序更有信息量。

具体做法是:把物体周围分成八个方向,每个方向找最近的物体,记录方向和距离。这样 AI 就能知道“左边是桌子,右边是椅子,前面是门”,而不是只知道“附近有桌子和椅子”。

function scanSurroundings(obj, allObjects, radius = 5) { const origin = obj.getWorldPosition(new THREE.Vector3()); const sectors = { front: null, back: null, left: null, right: null }; allObjects.forEach((other) => { if (other === obj) return; const pos = other.getWorldPosition(new THREE.Vector3()); const dist = origin.distanceTo(pos); if (dist > radius) return; const dir = pos.clone().sub(origin).normalize(); // 根据 dir 判断方向,写入 sectors }); return sectors; }

这个方案在物体数量不多的时候很好用,物体多了之后计算量会上去,需要做空间索引优化。

6.3 描述版本管理

描述不是写一次就完事了,场景会变,描述也要跟着变。我建议给描述加一个version字段和updatedAt时间戳,方便追踪。

如果团队协作,最好把描述文件纳入版本控制,每次修改都有记录。这样出了问题能回滚,也能看出是谁改的。

实操心得:描述文件的 diff 很难读,因为 JSON 格式变化大。我的做法是写一个简单的对比脚本,只输出关键字段的变化,比如“chair_001 的位置从 (1,0,2) 变成 (1.5,0,2)”,这样 review 起来轻松很多。

6.4 和 AI 接口的对接细节

最后说几个对接 AI 接口时的细节。

第一,控制单次请求的物体数量。我一般控制在 20 个以内,超过就分批。模型对太多物体的注意力会分散,回答质量下降。

第二,给模型明确的输出格式要求。比如要求它返回 JSON,并且指定字段。这样后续处理方便很多。

第三,做好错误处理。模型有时候会返回不符合格式的内容,要有兜底逻辑,不能直接崩。

第四,缓存常用查询的结果。有些查询是重复的,比如“场景里有哪些椅子”,缓存起来能省不少 token。

这套东西搭起来之后,你会发现 AI 对你的场景的理解能力有质的提升。以前它只能瞎猜,现在它能准确说出“桌子左边那把橙色椅子”,这种体验是完全不一样的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询