用 GPT‑6 构建可交互 USD 资产:从 USDCraft 到一扇能用于仿真的柜门

0. 简介

USDCraft 面向机器人仿真中的关节物体重建:给定一份可能缺面、粘连的扫描网格和一张参考照片,让预训练语言模型编写资产程序,再通过测量、编译、几何重检和渲染逐步修正。它处理的是一个具体矛盾:扫描保留了实例尺寸,却经常没有独立部件和运动结构;模型知道柜门如何打开,却可能猜错把手位置。论文将两类信息接进同一条建模循环,输出带几何、刚体、碰撞体和关节的 USD。下面以 GPT‑6 Astra 和单门柜为例,解释这套方法如何落到公开 OpenUSD API、可运行 Python 程序和分层验证中,让第一次接触 USD 的读者也能顺着代码建立完整流程。12

这篇教程采用 USDCraft 的方法思想与公开工具相结合 的方式展开。资料核验日期为 2026 年 10 月 18 日,USDCraft 项目主页的 Code 和 Dataset 链接仍标为 TODO,因此文中的 Python 文件是独立教学实现,并非作者 SDK,也不宣称复现论文全部工具。正文引用的性能数字均来自论文;柜体尺寸、质量、合成网格和诊断曲线属于教学设定;实际执行过哪些检查,则以配套 JSON 记录为准。把这三类内容分清,才能判断读到的是研究证据、工程推导,还是已经在本机运行的结果。

1. 先认识资产:USD 需要表达的内容

1.1 从三角网格到可交互物体

一份 OBJ 或 GLB 可以很好地展示柜子的外形,机器人却无法仅凭三角形判断哪块板能转、铰链在哪里、接触时会受到多大阻力。关节资产(articulated asset) 需要把这些信息显式组织起来:柜体与柜门分别是什么刚体;把手是否跟随门运动;哪些表面参与碰撞;门绕哪条轴转动;允许的角度范围是多少。USDCraft 选择重写一个生成资产的程序,让这些变量成为可编辑参数。测量发现把手偏低时,系统可以修改高度;发现扫描把门和框粘在一起时,也能重新建立独立部件,而不必沿着错误网格继续切割。

OpenUSD(Universal Scene Description) 提供描述、组合和交换场景的机制,本身并不执行物理求解。场景中的节点称为 Prim ,整个可组合场景称为 Stage ,节点上的 Schema 表达几何、材质、物理等语义。文件可以写成便于阅读的 .usda,也可以使用二进制 .usdc;.usd 扩展名可以承载相应编码,.usdz 用于打包。机器人场景往往还要借助引用、层和相对资源路径,把同一件资产放入不同任务中。于是,交付目标包含可解析的场景结构、有效物理字段和完整依赖,而不只是一个文件扩展名。34

1.2 视觉几何、碰撞几何与物理参数

同一扇柜门通常需要三种描述。视觉几何 负责门缝、木纹、倒角与外观;碰撞几何 负责与手指、柜框及地面的接触;惯性描述 则决定质量分布和动力学响应。三者可以共享尺寸参数,却不必共享同一套三角形。柜体可由五块盒状板构成,门板和把手另成一组;视觉上以后可以增加细节,碰撞体仍保持简单。这里的关键是,简化必须保住操作相关结构:不能用一个实心长方体填满柜口,也不能用横跨把手和门板的凸包堵住抓握空间。精美外观无法弥补错误的接触几何。56

资产层面 柜门示例 主要检查
几何与外观 柜体薄板、门板、把手 尺寸、位置、开口、材质
运动结构 柜体与门的转动关节 两个刚体、锚点、轴、限位
接触模型 板件盒形碰撞体 门口与把手空隙是否保留
动力学 质量、惯量、摩擦、阻尼 数值合理、单位一致、行为可解释
资产包装 默认 Prim、坐标单位、依赖 独立打开、引用完整、可重复生成

2. 把 USDCraft 放回完整流程

2.1 输入如何分别约束建模

USDCraft 的重建输入包括源网格、参考图像和可选文字要求。网格约束可信表面的尺寸与位置,图像帮助识别物体类型、可见零件和正常使用方式,文字则补充使用目的或类别约定。论文中的 源几何分析(source geometry analysis) 将网格转成带单位的文本,模型据此编写 model.py;宿主程序负责执行、导出和基础检查,模型再选择测量、渲染或局部比较工具修正程序。这里要厘清的是,模型并没有在一次问答中"直接理解整个网格",它使用的是经过工具整理的证据,且仍可追问局部几何。1

生成与重建共享程序化表达,但证据条件不同。仅凭文字生成一个柜子,可以选择合理宽度;重建某个真实柜子,则必须优先保留已经测到的宽度和把手位置。没有扫描的生成流程也能做渲染反馈、关节检查和动态测试,却不能声称与某个物理实例几何一致。进一步看,USDCraft 的价值就在于让知识补全与实例测量共同作用:常识决定"这可能是一扇门",测量决定"这扇门在这里、有这么宽"。任一信息源越过自己的证据范围,都会把一个可解释流程变成难以审查的猜测。

2.2 与分割方法、检索方法和程序化方法的关系

相关工作可以沿着"最后怎样得到部件几何"来理解。Particulate 学习从静态网格预测零件与运动结构,尽量保留输入表面;Articulate-Anything 利用已有资产检索和视觉语言反馈组装对象;Real2Code 将部件重建与代码生成结合,其公开仓库包含分割、形状补全和 CodeLlama 微调模块。Articraft 通过专门 SDK 和执行反馈生成资产,Procedura 则强调具备类型约束的装配连接。USDCraft 在程序化建模中加入同坐标系的几何测量与反复重检,因而能在补面、拆开粘连部件的同时约束实例尺寸。这些路线解决的问题有交集,输入条件与保留几何的方式却并不相同。12--16

工作 核心做法 面对真实扫描时的主要问题
Particulate 从静态网格预测分部件和关节 原网格未观察到的结构难以直接恢复
Real2Code 分割、部件补全与关节代码生成 包含任务训练模块,不能视为纯零训练工具链
Articulate-Anything 资产检索与程序生成、视觉反馈 检索部件与目标实例的尺寸可能不同
Articraft SDK 支持的资产程序生成与验证 论文对照设置缺少源几何测量约束
Procedura 类型化连接、逐件装配和视觉修正 正确装配还需额外证明与实例几何一致
USDCraft 几何证据约束的程序化重建 仍受扫描质量、几何表达和物理假设限制

2.3 一轮修订应留下哪些可检查的东西

以柜门为例,第一轮先建立部件计划:柜体固定形状由五块板组成,门与把手属于同一个可动刚体,两者通过竖直铰链连接。模型依据图像提出这份计划,再用源网格确定外形边界与操作件位置,交给构建器生成候选。若诊断显示门板轮廓接近来源,但整体沿深度方向偏移,下一步就应查询门板前后表面,确认是门的位置、板厚还是源坐标转换有误。一次反馈应当缩小下一次修改的范围。反复要求"做得更像一些",既没有说明要改哪个变量,也无法判断修改是否保住已经正确的几何。

一条可审查的修订记录应关联源证据版本、当前参数、候选文件和本轮工具输出。比如,第二轮修改板厚后,需要重新计算门板位置、惯量和碰撞体,而不是只把渲染图中的板件缩薄;第三轮若仅修显示颜色,就没有理由同时改变关节锚点。下面列的是建议采用的宿主协议,并非论文已经公开的接口或本教程实际执行过的模型对话。它把"提出假设""执行动作"和"确认结果"分开,便于读者理解模型究竟在流程中负责什么,也便于以后把一个教学模板扩成通用建模工具。

环节 柜门中的具体问题 应保存的记录
观察与测量 门板边界在哪,扫描有没有看到背板 原始证据标识、坐标约定、查询区域
建立候选 哪些是独立刚体,门绕哪条轴运动 参数、程序版本、部件与关节清单
执行构建 新参数能否生成完整 USD 构建状态、输出路径、依赖检查
定位差异 偏差属于整体位置还是局部形状 同版本几何、深度和渲染反馈
局部修订 修改板厚、位置或某段轮廓 修改依据、受影响部件、重检项目
判断停止 已知表面与任务相关结构是否合格 各层验证状态、未解决假设

原论文流程图已经列出主要模块,下面这张补充图专门解释"工具输出如何变成下一次程序修改"。图中的回路只表示允许的修订路径,工具顺序仍由建模代理按证据选择,并非每一轮都必须串行执行全部检查。后面的未知表面、关节坐标与验证层级也各有一张解释图,帮助把抽象机制落到柜门例子中。阅读时应把误差、程序变量和重检之间的对应关系看清,避免用一个含糊的"AI 优化"箭头省略真正的工程过程。

3. 第一步:先把单位、坐标和证据登记清楚

3.1 统一到一个可追溯的度量坐标系

本教程约定 米、千克、Z-up,柜子宽度沿 X,深度沿 Y,高度沿 Z,柜门位于负 Y 一侧。输入 GLB 时,必须先应用场景节点变换,再计算整个对象的边界;直接拼接局部顶点会漏掉位移、缩放或旋转。glTF 规范采用米和 Y-up,但转换器可能已经改过方向,真实内容也可能没有遵守原始尺度。正确顺序是读取格式与转换记录、检查实际边界、登记必要变换,再固定整条管线的坐标约定。模型不能在每次修正时按"看起来更合适"的比例重新缩放。7

设源坐标中的点为 p s \mathbf{p}_{s} ps,旋转矩阵为 R \mathbf{R} R,统一缩放为 s s s,平移为 t \mathbf{t} t,则进入建模坐标系的变换可以写成下面的形式。其中 R \mathbf{R} R 用于坐标轴转换, s s s 用于已知单位换算, t \mathbf{t} t 记录原点约定;这些变换需要同时作用于表面、测量区域和关节位置。若真实尺度来自照片估计,必须记录依据和不确定性,不能把该估计包装成精确扫描。论文给出的疑似归一化输入判定规则也属于其具体实验协议,不是识别所有网格单位的通用定律。

p w = s R p s + t . \mathbf{p}{w}=s\mathbf{R}\mathbf{p}{s}+\mathbf{t}. pw=sRps+t.

配套的 geometry_feedback.py 将读入与坐标转换集中到同一个函数。scene.to_geometry() 会将场景图中的节点变换应用到汇总几何,随后才按参数执行 Y-up 到 Z-up 的旋转和统一尺度变换。这个函数没有自动居中,也没有按最长边归一化,目的就是保留后续误差诊断需要的绝对位置。对已经转换好的网格再次使用 y_up_to_z_up=True 会产生重复旋转,因此调用方必须依据输入记录选择开关,而不能用文件扩展名替代坐标检查。

python 复制代码
# 基于 trimesh 场景图 API。
def load_scene_mesh(path, y_up_to_z_up=False, scale=1.0):
    scene = trimesh.load(str(path), force="scene", process=False)
    mesh = scene.to_geometry()
    if not isinstance(mesh, trimesh.Trimesh) or len(mesh.faces) == 0:
        raise ValueError("输入未包含有效三角网格")
    if y_up_to_z_up:
        transform = trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0])
        mesh.apply_transform(transform)
    mesh.apply_scale(scale)
    return mesh

3.2 区分测量、推断和没有证据的区域

源网格只记录采集到的表面。扫描柜门时,内壁和背板可能根本没被相机看见;金属反光处可能缺面;门缝也可能被重建算法粘住。未知空间不等于自由空间,同样也不等于实心材料。判断为自由空间通常需要相机视线、深度量测及其有效范围,单独一份表面网格不能提供完整证据。USDCraft 因此把未采到表面的网格单元标为未知,允许模型依据照片和功能补全,但并未赋予模型任意改写可靠观测的权利。这个区分会直接影响后面怎样解释候选模型多出的表面。

直觉理解:测绘员只拍到房子的正面,并不能据此宣布背面没有墙。扫描缺失像一张没有填写的测量表,留白本身不提供"那里为空"的证据。程序可以先给背墙一个合理厚度,但应把它记成待核实假设;正面已经测出的窗户位置,则不能因为另一种布局更常见就被随意移动。数字资产中的补面和保留观测,遵循的是同一种证据分工。

教程使用宽 0.60 米、深 0.40 米、高 0.80 米、壁厚 18 毫米的单门柜,并把柜体质量 12 千克、门及把手总质量 3 千克写入参数。这些数值都属于 教学假设 ,并非实物扫描结果。配套 evidence.txt 明确说明了这一点,后面生成的缺背板网格也只是用于检查算法行为的合成输入。真实项目应把卡尺测量、扫描边界、图像推断和材料假设分开登记,特别要单独记录把手、按钮、门边等机器人真正接触的区域。

4. 第二步:把几何转换成模型能读懂的证据

4.1 表面支撑网格与轴向切片

把数十万个顶点原样放进提示词,模型难以从中提取有效结构;只给渲染图片,又会丢失尺度和遮挡后的内部表面。USDCraft 采用 轴向几何编码(axial geometry encoding),在共享度量网格上记录观察到的表面,再按高度序列化为 XY 切片,配合边界、凸起、开口和局部内部结构摘要。论文实现的细网格在最长轴上使用 192 个单元,文本切片按预算选用最长轴 32、24 或 16 个单元,并压缩重复切片。它描述的是表面支持的位置,不是传统的实体占据判断。1,附录 A.4

配套代码保留了同网格表面对照的核心,但采用更容易运行的 表面采样简化实现:采样三角形表面,并补入顶点和三角形中心,再映射到网格索引。论文还使用三轴射线交点收集表面证据,本教程没有复现这部分,也没有自动检测开口、导轨或六向深度图。工程上要先认清这个差别:简化版本适合解释和验证数据约定,不能据此报告论文指标;小尺度薄壁、极细构件以及恰好落在网格边界的表面,仍可能受到采样和离散化影响。

python 复制代码
# 返回采到表面的单元,不进行体素填充。
def surface_cells(mesh, origin, pitch, count=120000, seed=7):
    points, _ = trimesh.sample.sample_surface(mesh, count, seed=seed)
    points = np.vstack([points, mesh.vertices, mesh.triangles_center])
    indices = np.floor((points - origin) / pitch).astype(np.int64)
    return set(map(tuple, indices.tolist()))

4.2 文本摘要应帮助定位后续修改

一份有用的文本摘要至少要交代单位、坐标轴、网格原点、单元边长、字符含义和切片顺序。USDCraft 给出的烤面包机示例中,~ 表示采到的表面,. 表示未知,较高切片能显示插槽边界,较低切片主要描出外壳。模型据此可以把"右侧凸起"转为某段坐标范围,再进一步查询其局部边界。下面的 JSON 是建议采用的工程数据结构,而非 USDCraft 的正式返回格式;它将坐标约定、可靠性标签和局部观测放在一起,便于程序检查,也便于模型定位应修改的参数。

json 复制代码
{
  "frame": {"up_axis": "Z", "meters_per_unit": 1.0},
  "grid": {"origin_m": [-0.32, -0.30, -0.02], "pitch_m": 0.01},
  "cell_semantics": {"~": "observed_surface", ".": "unknown"},
  "source_kind": "synthetic_teaching_data",
  "local_queries": [
    {"region": "door_handle", "question": "measure_local_bounds"},
    {"region": "left_hinge", "question": "inspect_surface_orientation"}
  ]
}

这里的关键是,编码需要保留误差能回到哪段程序的线索。如果把门板宽度、铰链位置和把手高度分别命名为参数,切片中的局部偏差就可能对应一个小范围修改;如果程序全是无语义的顶点坐标,即使反馈正确,也很难稳定修复。摘要也不应假装具有精确零件语义:自动发现一个圆柱形凸起,只能说明它具备某种几何特征,究竟是铰链、旋钮还是固定脚,仍需要结合图像、连接方式与正常使用动作判断。

4.3 用缺背板案例区分观测、补全和分辨率

假设扫描只覆盖柜子的正面和两侧,背板没有被采集到。候选补出一块背板后,其表面可能落在源网格未采样的单元中。此时应记录"新增背板来自封闭柜体的结构假设,等待背面图像或实测确认",而不是直接记成重建错误。反过来,如果源网格清楚记录了门把手,候选却把它省略,那么缺失就有明确证据。未知区域允许形成假设,已观测表面要求解释偏差。这项区分来自输入证据的性质,不取决于候选看起来是否更精美,也不能由一个全局相似度替代。

证据记录还应允许被后续观察修订。例如,一张新拍摄的背面照片显示柜子实际为开放式置物架,先前补出的背板就失去依据,需要删除或重新解释;照片确认有背板,也只支持其存在,通常仍不能精确确定厚度。下面是建议维护的假设表,不是已经采集到的实物数据。把每项补全写成可质疑的记录,后续模型就能知道自己该去找什么证据,而不是把第一轮生成的几何误认为真实测量,继而用错误候选继续"证明"自身。

区域 当前证据 可作出的判断 下一步动作
柜门正面 合成来源保留的表面 需要保留已知位置和轮廓 同网格比较,局部测厚
柜子背面 来源中缺失 是否存在背板仍是建模假设 查询背面图像或补扫
把手附近 若来源或照片确有可辨结构 需要建模,精确尺寸仍按测量 查询局部边界与空隙
未采样内腔 没有表面或有效视线记录 不能直接判断实心或自由空间 分开登记几何与功能假设

离散分辨率决定了这些反馈能回答多细的问题。教程的全局比较使用 10 毫米单元,而柜门背面与柜体前沿之间的设计间隔仅为 6 毫米:两片相隔的表面可能落进同一单元,轮廓轻微移动也可能跨越单元边界。因此,全局表面覆盖不能用来认证门缝。对把手抓握空隙、按钮行程和门框边缘,应切换到更细的局部测量或直接距离查询,并在有效碰撞设置下测试运动。单纯提高全局网格分辨率会增加采样、文本和比较成本,局部加密通常更有针对性。

5. 第三步:先生成一个可执行的柜门资产

5.1 Stage、默认 Prim 和坐标元数据

NVIDIA 的公开 OpenUSD Code Samples 分别提供创建 Stage、设置默认 Prim、设置长度单位和设置向上轴的示例。下面四段摘取其核心函数,并保留原始版权与许可证信息;对应文件已放入 sources,原始仓库路径分别是 source/stage/create-stage、set-default-prim、set-stage-linear-units 和 set-stage-up-axis。这些函数看似基础,却决定资产能否被正确引用,以及数值 0.6 到底表示 0.6 米还是 0.6 厘米。配套构建器将四项操作集中执行,使模型无需每轮重复编写样板逻辑。8

python 复制代码
# NVIDIA-Omniverse/OpenUSD-Code-Samples/source/stage/create-stage/py_usd.py
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from pxr import Usd

def create_new_stage(path: str, stage_name: str = "my_new_stage.usd") -> Usd.Stage:
    layer_path: str = path + stage_name
    stage: Usd.Stage = Usd.Stage.CreateNew(layer_path)
    return stage
python 复制代码
# NVIDIA-Omniverse/OpenUSD-Code-Samples/source/stage/set-default-prim/py_usd.py
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from pxr import Usd

def set_default_prim(stage: Usd.Stage, prim: Usd.Prim):
    stage.SetDefaultPrim(prim)
python 复制代码
# NVIDIA-Omniverse/OpenUSD-Code-Samples/source/stage/set-stage-linear-units/py_usd.py
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from pxr import Usd, UsdGeom

def set_meters_per_unit(stage: Usd.Stage,
                       unit: UsdGeom.LinearUnits = UsdGeom.LinearUnits.centimeters):
    UsdGeom.SetStageMetersPerUnit(stage, unit)
python 复制代码
# NVIDIA-Omniverse/OpenUSD-Code-Samples/source/stage/set-stage-up-axis/py_usd.py
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from pxr import Usd, UsdGeom

def set_up_axis(stage: Usd.Stage, axis: UsdGeom.Tokens):
    UsdGeom.SetStageUpAxis(stage, axis)

将这些函数用到柜门时,应显式传入 metersPerUnit=1.0 和 Z-up,再把 /Cabinet 设成默认 Prim。修改元数据不会自动换算已写入的几何数值:一份按厘米构造的资产,不能仅把单位字段改成米就视为正确转换。默认 Prim 也不等于物理上的固定底座,它只是资产引用的入口。教程把根节点保持为可复用的独立资产,不在内部放地面或 PhysicsScene;任务场景再决定如何摆放、支撑以及是否固定柜体,这样同一资产才可以进入不同场景。

5.2 用参数构造薄板,而不是沿扫描点描边

柜体由左、右、上、下和背面五块薄板组成,柜门由一块板和三段把手组成。构建器在 /Cabinet/body 与 /Cabinet/door 上分别应用 UsdPhysics.RigidBodyAPI,每块板件仅作为刚体内部的几何和碰撞形状,把手因此天然随门运动,不再添加多余关节。函数 box_specs() 同时为 USD、候选网格、惯量计算和姿态图提供参数,避免视觉与碰撞使用不同宽度。单一参数来源 是程序化资产最实际的好处:修改门宽后,所有依赖该参数的几何都会一起重建。

python 复制代码
# 每块板是一个碰撞形状,多个形状共享同一刚体。
body = [
    ("left",   (t, d, h),       (-w / 2 + t / 2, 0, h / 2)),
    ("right",  (t, d, h),       ( w / 2 - t / 2, 0, h / 2)),
    ("bottom", (w - 2*t, d, t), (0, 0, t / 2)),
    ("top",    (w - 2*t, d, t), (0, 0, h - t / 2)),
    ("back",   (w - 2*t, t, h - 2*t), (0, d / 2 - t / 2, h / 2)),
]

复杂对象可在同一表达中混合建模工具:CadQuery 适合尺寸明确的孔、槽、薄壁与机械轮廓,SDF 适合有机曲面及平滑连接,纹理和 PBR 参数负责表面外观。CadQuery 官方快速入门展示了通过工作平面、截面和拉伸建立实体的方式,但 CAD 实体到 USD 网格仍需显式三角化、单位转换和碰撞生成。把 CAD 的毫米参数直接复制给按米解释的 USD,会得到放大一千倍的零件。教程先采用原生盒形几何,目的是让读者把注意力放到证据、部件与关节上,后续替换几何内核时仍保留相同接口。1011

6. 第四步:把两块刚体连接成正确的门

6.1 关节锚点属于两个不同的局部坐标系

转动关节通过 body0、body1 关系指定参与约束的刚体,Prim 在场景树中的父子关系并不自动形成物理连接。localPos0 和 localPos1 分别描述锚点在两个刚体中的位置,localRot0 与 localRot1 描述相应关节坐标系的朝向。若两个刚体的世界变换分别为 T W B 0 \mathbf{T}{WB_0} TWB0 与 T W B 1 \mathbf{T}{WB_1} TWB1,两个局部关节变换为 T B 0 J 0 \mathbf{T}{B_0J_0} TB0J0 与 T B 1 J 1 \mathbf{T}{B_1J_1} TB1J1,则在本教程的零角初始姿态,两侧关节框架应满足下面的关系;运动后则按关节允许的自由度产生相对变化。5

T W B 0 T B 0 J 0 = T W B 1 T B 1 J 1 ( q = 0 ) . \mathbf{T}{WB_0}\mathbf{T}{B_0J_0} =\mathbf{T}{WB_1}\mathbf{T}{B_1J_1} \qquad (q=0). TWB0TB0J0=TWB1TB1J1(q=0).

其中 W W W 代表世界坐标系, B 0 B_0 B0、 B 1 B_1 B1 代表柜体和柜门坐标系, J 0 J_0 J0、 J 1 J_1 J1 代表各自的关节框架。教程将柜体放在世界原点,把门的刚体原点直接放到铰链处,所以 localPos1=(0,0,0),而 localPos0 等于铰链在柜体中的位置。若把同一个世界坐标同时写进两个局部锚点,门的初始位置看起来可能正确,约束求解时却会拉扯两个不重合的位置。配套验证器将两侧锚点重新变换到世界空间,直接检查这种错误。

难点提示:铰链两半各自安装在柜体和门上,施工图需要分别标出"相对柜体装在哪里"与"相对门装在哪里"。门搬动以后,门上的安装坐标并不会变。USD 的两个局部锚点表达的就是这两份安装记录,而不是把同一个世界坐标重复填写两次。检查时把两边记录都还原到世界坐标,看它们是否真的在同一个安装位置相遇。

6.2 轴、正负方向和角度单位必须一起检查

本例中柜门处于负 Y 侧,门板从左侧铰链沿正 X 延伸。按右手规则绕正 Z 转动会把门推向柜内,因此朝外开启采用负角度,限位设置为 −100° 到 0° ,闭合状态对应零位。OpenUSD 标准转动关节的角度属性使用度,很多机器人算法和数值库则使用弧度,两者不能直接混用。physics:axis="Z" 指关节局部框架的轴;如果局部框架已经旋转,它就未必是世界 Z 轴。教程把两个局部旋转都设为单位四元数,先让这层映射清楚可见。

python 复制代码
# hinge 为柜体坐标系中的铰链位置。
joint = UsdPhysics.RevoluteJoint.Define(stage, "/Cabinet/hinge")
joint.CreateBody0Rel().SetTargets([Sdf.Path("/Cabinet/body")])
joint.CreateBody1Rel().SetTargets([Sdf.Path("/Cabinet/door")])
joint.CreateLocalPos0Attr(Gf.Vec3f(*hinge))
joint.CreateLocalPos1Attr(Gf.Vec3f(0, 0, 0))
joint.CreateLocalRot0Attr(Gf.Quatf(1))
joint.CreateLocalRot1Attr(Gf.Quatf(1))
joint.CreateAxisAttr("Z")
joint.CreateLowerLimitAttr(-100.0)
joint.CreateUpperLimitAttr(0.0)

这张图由教学程序的同一份板件参数生成,展示门、把手和铰链在三个姿态下的关系;它不是 Isaac Sim 截图,也不是动力学运行记录。姿态图能帮助发现开门方向反了、把手没有跟着门移动、门穿过柜体等明显问题,却无法证明摩擦、接触和约束求解稳定。这里要保留一个清楚的层次:能把门摆到 100 度,只说明我们构造了相应几何姿态;需要通过外力把门从零位推到那里,才涉及真实的物理求解。

6.3 把同一铰链位置完整换算一遍

默认参数下,门板宽度为 0.596 米,门的刚体原点位于铰链,世界位置为 ( − 0.298 , − 0.215 , 0.400 ) (-0.298,-0.215,0.400) (−0.298,−0.215,0.400) 米;柜体的世界变换是单位变换,两者在零位都没有旋转。于是,柜体侧的 localPos0 要写入这个三元组,门侧的 localPos1 则为零。检查时,前者经柜体变换仍到达同一位置,后者经门的平移也到达该位置。这是两个局部坐标不同、世界安装点相同的具体例子。把手握杆中心另外位于世界坐标 ( 0.22648 , − 0.273 , 0.400 ) (0.22648,-0.273,0.400) (0.22648,−0.273,0.400) 米,表明门的原点无需放在门板或把手中心,选择铰链原点能简化关节表达。

量 柜体一侧 柜门一侧
刚体世界原点,米 (0, 0, 0) (-0.298, -0.215, 0.400)
局部锚点,米 (-0.298, -0.215, 0.400) (0, 0, 0)
零位世界锚点,米 (-0.298, -0.215, 0.400) (-0.298, -0.215, 0.400)
关节局部旋转 单位四元数 单位四元数
关节轴映射 局部 Z 对应世界 Z 局部 Z 对应世界 Z

若误把 localPos1 也填成 (-0.298,-0.215,0.400),门侧锚点会再次叠加刚体平移,落到世界坐标 ( − 0.596 , − 0.430 , 0.800 ) (-0.596,-0.430,0.800) (−0.596,−0.430,0.800) 米,与柜体侧相差约 0.5432 米 。此时肉眼看到的门板初始姿态仍可能正常,因为几何变换与关节锚点是分别写入的,只有检查约束或开始求解时才暴露问题。把门原点放在质心同样可行,只需重新求正确局部锚点。对于无缩放的刚体位姿,设世界锚点为 p W J \mathbf{p}{WJ} pWJ,刚体旋转和平移为 R W B \mathbf{R}{WB} RWB、 t W B \mathbf{t}_{WB} tWB,局部锚点由逆变换得到。

p B J = R W B ⊤ ( p W J − t W B ) . \mathbf{p}{BJ}=\mathbf{R}{WB}^{\top} \left(\mathbf{p}{WJ}-\mathbf{t}{WB}\right). pBJ=RWB⊤(pWJ−tWB).

其中 p B J \mathbf{p}_{BJ} pBJ 表示同一安装点在刚体局部坐标系中的位置,旋转矩阵的转置在这里等于逆矩阵。这个公式应分别对两个刚体使用,局部轴方向也要通过相应旋转换算;若模型层级含缩放,还需先整理尺度,不能直接套用纯刚体位姿公式。配套 validate() 用 UsdGeom.XformCache 读取真正的世界变换,再把两侧局部锚点和轴还原检查。检查导出后的 USD 比只检查生成程序中的变量更可靠,因为父级变换、属性精度或导出错误同样会影响最终结果。

7. 第五步:为接触、质量和被动运动写清物理假设

7.1 复合惯量比随手填写一个对角矩阵更可靠

柜体和门都由多个盒体组成。教程先按体积分配总质量,计算各盒体质心与惯量,再用平行轴定理合成为整个刚体的惯量张量,最后求主轴和主惯量写入 MassAPI。这种做法显式依赖"同一组板件采用统一密度"的教学假设,真实门上的金属把手、玻璃或配重应当单独分配质量。质量为正还不够,质心、主轴与惯量也需要相互一致;完全不合理的小惯量可能让很轻微的接触产生剧烈响应,错误质心则会改变重力矩与接触稳定性。

对质量为 m i m_i mi 的第 i i i 个盒体,设其局部质心惯量为 I i \mathbf{I}_i Ii,相对整体质心的位移为 r i \mathbf{r}_i ri,单位矩阵为 E \mathbf{E} E,则本例中各盒体坐标轴一致时可以直接使用下面的合成式。若盒体还具有相对旋转,需要先把各自惯量旋转到公共坐标系。配套 compound_inertia() 没有省掉平行轴项,并以特征分解得到 USD 需要的主惯量及主轴四元数;校验程序还会检查主惯量为正,并满足物理惯量的三角不等式。

I = ∑ i I i + m i ( ( r i ⊤ r i ) E − r i r i ⊤ ) . \mathbf{I}=\sum_i\left\\mathbf{I}_i+ m_i\\left((\\mathbf{r}_i\^\\top\\mathbf{r}_i)\\mathbf{E}-\\mathbf{r}_i\\mathbf{r}_i\^\\top\\right)\\right. I=i∑Ii+mi((ri⊤ri)E−riri⊤).

7.2 接触摩擦、关节摩擦和驱动分别建模

手指接触门把手时的摩擦属于接触材料,柜门铰链自身的阻力属于关节,两者不能用一个数字替代。教程在 UsdShade.Material 上应用 UsdPhysics.MaterialAPI,显式设置静摩擦 0.6、动摩擦 0.4 和恢复系数 0,并以 materialPurpose="physics" 绑定到每个碰撞体。显示颜色只服务于观察,没有被当成物理材料。真实项目还要检查接触双方材料如何组合;最终有效摩擦与求解器的组合规则有关,单个材料字段并不能直接等同于实测接触力。6

柜门应在机器人施力时被动转动,不能由强位置驱动自动打开。教程只添加 零刚度的黏性阻尼驱动,令目标速度为零,将每弧度的阻尼系数换算成每度数值,且不再叠加另一份相同的黏性阻力。这里的 0.20 N·m·s/rad 与 5 N·m 的驱动力上限都是教学选值,干摩擦刻意设为零;它们需要真实实验或目标任务的系统辨识来修订。机器人施力时应克服哪些阻力,必须能从这些字段与假设中解释出来。59

python 复制代码
# angular 参数以度为坐标,按公开物理单位约定换算。
drive = UsdPhysics.DriveAPI.Apply(joint.GetPrim(), "angular")
drive.CreateTypeAttr("force")
drive.CreateStiffnessAttr(0.0)
drive.CreateDampingAttr(0.20 * np.pi / 180)
drive.CreateTargetVelocityAttr(0.0)
drive.CreateMaxForceAttr(5.0)

7.3 重力保持机制需要单独推导

本例的柜门铰链与重力方向平行,柜体直立时,重力对该轴的力矩为零,因此没有用巨大摩擦"保住门角度"的必要。但将同一模板改成水平铰链上翻盖,情况立即不同。设铰链到质心的向量为 r ( q ) \mathbf{r}(q) r(q),转轴单位向量为 a ^ \hat{\mathbf{a}} a^,重力加速度为 g \mathbf{g} g,物体质量为 m m m,则重力对轴的力矩由下面的投影给出。这里 q q q 是关节角度,翻盖的重力矩会随角度变化,串联机构还必须计入下游部件和载荷,不能只计算门板自身。

τ g ( q ) = ( r ( q ) × m g ) ⋅ a ^ . \tau_g(q)=\left(\mathbf{r}(q)\times m\mathbf{g}\right)\cdot\hat{\mathbf{a}}. τg(q)=(r(q)×mg)⋅a^.

一块 3 千克、质心到水平轴距离 0.3 米的翻盖,在最不利姿态下重力矩可达到约 8.83 N·m。若需要它静止悬停,应通过干摩擦、弹簧、配重或真实主动机构提供足够保持能力;阻尼只在运动时产生阻力,不能单独抵消静态重力矩。USDCraft 附录也强调这类物理写作约束,并区分 PhysX 与 Newton 后端的能力。教程只生成标准 USD 加被动阻尼示例,没有创建 Newton 入口,也没有把某个后端的摩擦系数伪装成另一个后端的保持转矩。1,附录 G.2

工程价值:抽屉推起来费力,可能来自滑轨阻力;手指容易打滑,可能来自接触摩擦;翻盖松手后下落,则涉及重力保持。它们可以在同一次操作中同时出现,却由不同机制决定。把问题都归到"增大 friction"会掩盖实际原因。先画受力关系,再决定修改接触材料、关节阻力还是弹簧,才有办法解释调参结果。

8. 第六步:接入 GPT‑6 的工具循环

8.1 模型负责决策,宿主负责执行和记录

OpenAI 官方文档列出的 GPT‑6 Astra 模型标识是 gpt-6-astra,支持图像输入、函数调用和结构化输出,本教程通过 Responses API 连接它,reasoning.effort 采用论文 Astra 设置对应的 low。模型在循环中决定参数和下一步动作,Python 宿主执行工具、保存结果并将反馈传回。函数调用本身不会执行本地 Python,API 只返回工具名称和 JSON 参数;真正创建 USD 的是我们编写的分派器。这种分工使每次修改都能留下参数、输出文件和检查结果,而不是只得到一段声称"已经修复"的自然语言。217

教程先限制模型只调用 build_and_check,它可以改变尺寸、质量和开门范围,但不能生成任意 Python,也不能添加新部件类别。真实 USDCraft 使用更自由的资产程序,本例的能力范围明显更窄,不过基础循环完整:提交证据、模型调用工具、宿主生成资产、返回静态与可选几何反馈、模型继续修订。严格 JSON Schema 保证参数的形状,而 check_params() 继续检查有限数值、尺寸范围、壁厚、质量和角度。格式正确不代表物理正确,范围检查也不能替代源几何一致性检查。

python 复制代码
# 完整工具定义与边界检查见配套文件。
TOOL = {
    "type": "function",
    "name": "build_and_check",
    "description": "构建参数化柜门 USD 并返回静态检查;不证明动态仿真成功。",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {key: {"type": "number"} for key in DEFAULTS},
        "required": list(DEFAULTS),
        "additionalProperties": False,
    },
}

8.2 保留工具输出与推理条目

Responses API 一轮输出可能包含推理条目、函数调用和普通文本,因此应保存整个 response.output,再为每个调用添加具有相同 call_id 的 function_call_output。只保留模型最后一句话,会丢掉后续工具循环需要的上下文。下面摘取配套代码的关键结构,完整版本还包含错误回传、未完成响应检查、最大轮数和逐轮 JSON 落盘,并拒绝在没有成功构建时仅凭文字提前结束。由于同一个资产会被连续修改,示例关闭并行工具调用;针对互不影响的只读测量才适合另行设计并行执行。17

python 复制代码
# examples/agent_loop.py;历史中必须保留完整 output,而非只存 output_text。
response = client.responses.create(
    model="gpt-6-astra",
    reasoning={"effort": "low"},
    instructions=INSTRUCTIONS,
    tools=[TOOL], input=history,
    parallel_tool_calls=False,
    max_output_tokens=4000,
)
history.extend(response.output)
for call in response.output:
    if call.type != "function_call":
        continue
    result = dispatch(call.name, json.loads(call.arguments), output, source)
    history.append({
        "type": "function_call_output", "call_id": call.call_id,
        "output": json.dumps(result, ensure_ascii=False),
    })

图像可按官方 input_image 结构,与测量文本一起作为输入;配套代码支持本地 PNG 或 JPEG 转为 Base64 数据 URL,并使用 detail="high"。图像负责辨认开门方式、把手形状和缺失部件,尺寸仍以经过登记的测量为准。为了使示例在没有实物照片时也能执行,默认 evidence.txt 足以构建教学柜门,--image 是可选项。加入图像并不会自动获得毫米级定位能力,官方视觉文档也列出了空间定位和准确性限制,因而关键坐标仍需工具测量。18

8.3 从受限模板扩展到通用建模代理

读者配置 OPENAI_API_KEY 后,可以执行下面的命令启动实际模型循环;API 会按账户权限和当时价格计费。本文没有调用付费模型服务,因而配套 agent_loop.py 只完成语法和本地工具分派验证,不能把它的远程调用成功或模型收敛当作已验证结果。可选 --source 接收已经统一为米、Z-up 的网格,宿主会在每次构建后附上表面覆盖反馈。代码仍只调整柜门模板的全局参数,若铰链位置或把手轮廓有误,需要扩展参数集与验证器,模型不能凭现有接口修改它们。

bash 复制代码
# 已在当前环境配置 OPENAI_API_KEY 后,从 examples 目录执行。
python agent_loop.py --evidence evidence.txt --output agent_output

# 可加入真实参考图片;source 必须已经转换到约定的米、Z-up 坐标系。
python agent_loop.py --evidence evidence.txt --image reference.png \
  --source prepared_source.ply --output agent_output

进一步扩展时,工具应覆盖局部边界查询、源网格渲染、程序修改、构建、全局表面对照、局部深度比较和多姿态渲染,每项反馈都携带候选版本。自由代码模式还需要由宿主限制工作目录、依赖与执行时间,并保持源证据只读;这是执行模型生成程序所需的工程边界。测量和渲染结果必须由真实工具生成,不能让模型自行填写"通过"。当前示例会拦截没有成功构建的结束请求,更完整的宿主还应拒绝几何反馈缺失、版本不一致或任务验收失败的候选,避免把文本结束误判为资产完成。

9. 第七步:把"看起来不对"拆成可修改的误差

9.1 漏掉已观察表面与补全未知区域不同

设源网格采到的表面单元集合为 S M S_M SM,第 t t t 轮候选表面集合为 S t S_t St。在相同原点与分辨率下,覆盖率 r t r_t rt 衡量多少已观察表面被候选覆盖; D t − \mathcal{D}_t^{-} Dt− 是候选漏掉的源表面, D t + \mathcal{D}_t^{+} Dt+ 是候选出现在源证据之外的表面。前者提供直接修正线索,后者需要结合图像和物体功能进一步判断。例如缺背板扫描对应的正确柜体,本来就会多出背板;若把两种差异一视同仁地处罚,模型可能通过删掉必要结构来提高一个表面分数。

r t = ∣ S M ∩ S t ∣ ∣ S M ∣ , D t − = S M ∖ S t , D t + = S t ∖ S M . r_t=\frac{|S_M\cap S_t|}{|S_M|},\qquad \mathcal{D}_t^{-}=S_M\setminus S_t,\qquad \mathcal{D}_t^{+}=S_t\setminus S_M. rt=∣SM∣∣SM∩St∣,Dt−=SM∖St,Dt+=St∖SM.

配套 compare_support() 固定源网格给出的坐标原点,将候选放在同一网格中比较,并把额外单元命名为 candidate_in_unknown_cells,而不是直接命名为错误。它返回少量差异坐标,便于定位后续查询;真实宿主还应按区域聚合并映射到命名部件。这里的关键是,覆盖率只是诊断量。增加不合理的表面也可能提高覆盖率,表面在相邻单元间移动又可能使分数突变,因此不能单独把它设成优化终点,更不能通过分别归一化源与候选掩盖尺度或位移错误。

解读:拿一张残缺照片画建筑立面,漏掉照片里清楚存在的窗户是有证据的错误;补出照片之外的侧墙,则需要再看其他证据。两种差异都值得记录,但不能采用同一种处罚方式。对不完整扫描,合理补全恰好会产生"源网格里找不到对应点"的部分。算法应追问这些新增结构的依据,而不是要求候选只保留已经拍到的表面。

9.2 分开诊断位移与轮廓差异

仅看覆盖率,还分不清把手整体偏移与把手轮廓错误。USDCraft 为此使用同一投影网格上的首次表面深度比较。设源深度与候选深度为 d M ( u ) d_M(u) dM(u)、 d t ( u ) d_t(u) dt(u),共同有效的像素集合为 Ω \Omega Ω,残差为 δ ( u ) \delta(u) δ(u);先计算中位数偏移 b b b,再计算去掉偏移后的绝对残差 90% 分位数 e p r o f i l e e_{\mathrm{profile}} eprofile。前者反映沿观察方向的整体位移,后者描述该方向上剩余的轮廓差异;只有共同观察到的区域参与计算,不能把无深度像素直接当零。1

δ ( u ) = d t ( u ) − d M ( u ) , b = median ⁡ u ∈ Ω δ ( u ) , e p r o f i l e = Q 0.9  ⁣ ( { ∣ δ ( u ) − b ∣ : u ∈ Ω } ) . \delta(u)=d_t(u)-d_M(u),\qquad b=\operatorname{median}{u\in\Omega}\delta(u),\qquad e{\mathrm{profile}}=Q_{0.9}\!\left(\{|\delta(u)-b|:u\in\Omega\}\right). δ(u)=dt(u)−dM(u),b=medianu∈Ωδ(u),eprofile=Q0.9({∣δ(u)−b∣:u∈Ω}).

论文示例中的把手偏移为 0.64 毫米,轮廓误差为 3.41 毫米,提示应优先修改轮廓,而不是整体移动把手。下面的教学图使用已知曲线合成深度数据,左侧整体平移 6 毫米,右侧引入幅值 6 毫米的局部形状变化;图中数值由代码计算,未混用论文示例值。共同投影网格、相机方向和尺度必须固定,否则深度残差还会夹杂视角变化。若源区与候选部件没有有效重叠,应返回证据不足,而不是输出一个看似很好的零误差。

python 复制代码
# examples/geometry_feedback.py;输入必须是同视角、同网格的深度数组。
def depth_diagnosis(source_depth, candidate_depth):
    source_depth = np.asarray(source_depth, dtype=float)
    candidate_depth = np.asarray(candidate_depth, dtype=float)
    if source_depth.shape != candidate_depth.shape:
        raise ValueError("深度图必须共享投影网格")
    mask = np.isfinite(source_depth) & np.isfinite(candidate_depth)
    if not mask.any():
        return {"status": "insufficient_overlap"}
    residual = candidate_depth[mask] - source_depth[mask]
    offset = float(np.median(residual))
    profile = float(np.quantile(np.abs(residual - offset), 0.9))
    return {"status": "ok", "jointly_observed_pixels": int(mask.sum()),
            "offset_m": offset, "profile_q90_m": profile}

这里要明确诊断的边界:小偏移和小轮廓残差只能说明该视角共同可见部分相近,不能证明背面的几何、运动范围或拓扑全部正确。重复花纹、薄结构和遮挡会影响对应关系,低重叠时的中位数也可能缺乏代表性。真实工具应报告有效重叠面积、采样分辨率和比较区域,配合至少多个方向观察。模型接到"大偏移、小轮廓误差"后可以优先修改平移;接到"小偏移、大轮廓误差"后可以优先修改截面,但每次修改仍必须重新编译、重新比较。

9.3 从一条诊断落实到一次程序修改

假设局部深度工具报告把手沿比较方向整体偏移 6 毫米,而去偏移后的轮廓残差接近零,合理的候选修订是沿相应深度方向反向平移,再用同视角复测。这里的正负号必须服从工具定义:相机看向哪个方向,深度增加对应世界哪个方向,都应写入报告,不能见到正残差就机械减小世界 X。若多个视角对同一部件给出互相冲突的判断,应先检查选区、遮挡与坐标对齐。测量决定修改建议,重新测量决定修改是否成立,中间还要经过构建和受影响项目的验证。

当前教学工具只暴露柜体尺寸、壁厚、质量和开门角度,因此并不具备单独平移把手的能力。实际扩展时,可以在 box_specs() 中加入把手中心、离门距离和截面等参数,让把手三段几何从同一参数派生,再更新惯量与参数校验。不能用增大整个柜子高度来掩盖把手局部偏高,因为那会改变已正确的顶板、底板和铰链位置。下面的映射表给出通用诊断思路,其中涉及独立把手、铰链或截面的修改都属于扩展方向,不是现有 build_and_check 已支持的调用。

反馈现象 优先检查 可能的局部修改 修改后必须重查
多个部件共同偏移 源与候选的坐标变换 修正统一位姿,保留尺度依据 世界边界、所有锚点与接触点
单个部件偏移大、轮廓残差小 深度方向、部件安装变换 调整该部件位置 相邻连接、空隙、其他视角
偏移小、轮廓残差大 截面、厚度、曲率与选区 修改该段几何参数 体积、质量分布、碰撞近似
只在运动中穿插 轴、锚点、限位与碰撞过滤 修正连接或局部间隙 中间姿态、连续碰撞、动态接触
新增面位于未见背面 图像与功能支持 保留或撤销补全假设 依赖关系、内腔与新证据

每次修订还应保存"为何改、改了什么、据哪次反馈确认"的简短记录。若局部修改修好了把手,却让门板漏掉更多可靠源表面,应回到同一证据坐标中重新审查,而不是把前一轮分数丢掉。对于带随机采样的反馈,可以保持种子与网格一致,或在必要时检查采样敏感性,避免把抽样波动当成进步。这样的记录既方便人工接手,也使模型在后续轮次里知道哪些参数已经由观测约束,哪些仍是可以讨论的假设。

10. 第八步:让验证覆盖文件、几何、运动与任务

10.1 本地静态检查能够确认什么

构建器会重新打开生成的 USD,检查默认 Prim、米制与 Z-up,遍历刚体及碰撞体,确认质量与主惯量为正、接触材质有效,并比较两个锚点和轴在世界空间中是否一致。它还调用 UsdUtils.ComputeAllDependencies 检查依赖闭包,保证这个示例没有遗失外部纹理或网格。生成的 validation.json 保留 physics_runtime="not_tested",因为读懂并验证属性并不意味着实际物理引擎已经运行。这个区别应当直接出现在机器可读报告里,而不是只留在作者的口头说明中。

python 复制代码
# examples/build_cabinet.py;刷新已缓存的层,再检查当前输出。
layer = Sdf.Layer.FindOrOpen(str(path.resolve()))
layer.Reload(force=True)
stage = Usd.Stage.Open(layer)
assert stage and stage.GetDefaultPrim()
assert UsdGeom.GetStageMetersPerUnit(stage) == 1.0
assert UsdGeom.GetStageUpAxis(stage) == UsdGeom.Tokens.z
layers, assets, unresolved = UsdUtils.ComputeAllDependencies(
    Sdf.AssetPath(str(path.resolve()))
)
assert not unresolved and not assets and len(layers) == 1

10.2 合成缺面输入与可重复诊断

make_figures.py 从教学柜体删除背板,得到 synthetic_source.ply,再比较完整候选与沿 X 平移 20 毫米的候选。测试检查同一网格与自身比较时覆盖率为 1,合理补背板时会产生源未知区域中的新增单元,整体平移后源表面覆盖会变差;它同时检验深度诊断能区分纯平移与轮廓改变。固定随机种子使这次采样在相同环境中可重复,但不同版本或不同分辨率仍可能改变单元边界附近的统计。运行记录保存在 geometry_demo.json,它证明教学实现遵守了这些约定,不代表真实扫描重建成功。

bash 复制代码
# examples 目录;candidate.ply 由 build_cabinet.py 生成。
python geometry_feedback.py output/synthetic_source.ply output/candidate.ply \
  --pitch 0.01

10.3 Isaac Sim 中还需要做哪些实际测试

将 cabinet.usda 引用到 Isaac Sim 测试场景后,场景负责提供重力、PhysicsScene、地面、时间步长与柜体支撑;如果实验需要固定柜体,也由场景添加固定约束。先检查无外力静置时是否下沉或抖动,再对门施加可记录的外力,观察其是否绕正确轴被动转动、是否遵守限位,以及接触后能否稳定停下。不能通过逐帧改写几何变换来代替这项测试,因为那会绕过关节和动力学求解。测试必须记录引擎版本、求解器设置、碰撞近似和实际状态轨迹。9

教程为了简化初始资产,将门与柜体之间的关节连接碰撞关闭。这个选择使相邻框架与门的接触不能由当前模型真实验证,真实任务若依赖门板撞框、密封条或铰链止挡,应重新设计接触过滤与细分碰撞体,并补做对应测试。粗略姿态图、有限个离散状态也不足以证明运动全程无碰撞,应在关键间隙处加密采样或采用连续碰撞检测。USD 静态通过、运动几何合理、动态稳定、机器人任务成功,是四种不同的验证结果,需要分别报告。

10.4 本次实际运行的环境与结果

配套程序已在 Python 3.11.7、OpenUSD 26.8、trimesh 5.1.1、NumPy 2.4.2 和 Matplotlib 3.10.8 环境中执行。输出包含两个刚体、九个碰撞体,两侧关节锚点的世界坐标残差约为 1.05 × 10 − 8 1.05\times10^{-8} 1.05×10−8 米;缺背板合成输入与完整候选的表面覆盖为 94.54%,候选沿 X 错移 20 毫米后为 64.38%。前者没有达到 100% 也说明采样与离散网格会造成局部统计差异,不能把每个未覆盖单元直接等同于真实缺陷。这些数字全部属于本文教学运行,不是 USDCraft 的实物重建结果。

额外检查验证了场景节点变换确实被应用、USD 与反馈网格共享世界边界、非法参数被拒绝、复合惯量与 trimesh 独立计算一致,以及已有 USD 读取器时能够连续重建。后一项尤其容易在交互宿主中遗漏:如果一直读取缓存的旧 Layer,模型可能正在修第二版,诊断却仍报告第一版结果。构建器因此先在匿名层中生成资产再导出,验证前强制刷新文件层。实际结果分别保存在 validation.json、geometry_demo.json、extended_checks.json 和 versions.json,付费 API 与 Isaac Sim 动态状态仍明确列为未测试。

10.5 把停止条件写成与操作有关的证据

柜门最终是否合格,要回到机器人准备执行的动作。如果任务只是粗略判断门向哪侧开启,铰链方向和部件关系是重点;如果要用双指夹住把手,握持面的世界位置、把手与门板之间的净空、碰撞厚度和摩擦就会直接影响成功。检查表因此应由任务倒推,不能给所有对象统一设置一个表面覆盖阈值。全局几何接近,只能说明一部分条件成立。一个体积很小的把手可能只占很少表面采样,却决定整个任务成败,验收时应明确为它保留局部测量与接触测试。

以接触点位置为例,可以先分配保守误差预算。设 e a s s e t e_{\mathrm{asset}} easset 是资产接触点相对实物的定位误差上界, e c a l i b e_{\mathrm{calib}} ecalib 是标定在该点引入的位置误差上界, e c o n t r o l e_{\mathrm{control}} econtrol 是执行偏差上界, ε c o n t a c t \varepsilon_{\mathrm{contact}} εcontact 是操作允许的总位置偏差。各项都在同一坐标系、同一接触点度量,转角误差需先按作用距离换算到该点;以最坏情况分配时,可采用下面的保守条件。这不是机器人任务的充分成功条件,也没有假定随机误差相互独立。

e a s s e t + e c a l i b + e c o n t r o l ≤ ε c o n t a c t . e_{\mathrm{asset}}+e_{\mathrm{calib}}+e_{\mathrm{control}} \leq\varepsilon_{\mathrm{contact}}. easset+ecalib+econtrol≤εcontact.

假设某次教学任务允许总位置偏差 5 毫米,而标定和执行分别已占用 1 毫米与 2 毫米,则留给资产接触点的预算最多为 2 毫米;这些数字只演示分配方法,未在本例机器人上测量。质量、接触力、姿态和摩擦仍要单独检查,不能因为位置预算满足就宣布能抓住把手。若手里只有各项误差的分位数或平均值,也不能直接把它们当成最坏上界相加后承诺可靠性,需要结合实际统计与任务实验重新确定验收标准。

验证层级 必须回答的问题 合适的证据 本教程当前状态
文件与静态结构 字段、单位、依赖和关节引用是否一致 重新打开 USD,检查导出属性 本地检查通过
几何与运动学 实例接触点是否对齐,运动路径是否合理 同坐标测量、多姿态与间隙检查 合成诊断与姿态图已生成;真实实例未测
动力学 外力能否引起正确运动,接触是否稳定 实际引擎状态、力、时间步与碰撞记录 not_tested
机器人任务 指定策略在指定对象上能否完成动作 固定协议、试验次数、失败类型与成功率 not_tested

这一分层也便于安排返工。USD 打不开,先处理资源与场景结构;锚点不重合,先修坐标和连接;引擎内抖动,检查惯量、接触、约束和步长;仿真稳定却在实物上抓空,再排查实例尺寸、标定与策略。每次判断都应回到相应记录,避免仅凭一张好看的截图就跨过后面几层。配套静态报告已经将运行状态留空为未测试,后续只有真正跑过引擎或任务,才能添加对应版本、场景和轨迹证据,更新这一状态。

11. 完整案例:搭建一架能够弹奏的钢琴 USD

11.1 将"能够弹奏"拆成机械、事件和声音三层

钢琴比柜门更适合检验这套建模范式是否通用:柜门只有一个主要运动副,钢琴却需要许多独立琴键、准确排列的接触面、松手回弹,以及音高和按压速度之间的关系。这里将目标限定为一架可受力按键、可产生音符事件的简化钢琴:USD 描述外壳、琴键、踏板及物理属性,仿真宿主读取关节状态,事件层再将动作转换为 MIDI 或交给音源。先实现 C4---B4 的一个八度,即 7 个白键与 5 个黑键,再用相同规则扩展到 MIDI 21---108 的 88 键版本。琴槌、弦列、擒纵机构和音板振动暂不展开。

这三个层次需要分别构建。机械层回答"手指按下去,哪一根琴键绕哪条轴运动";事件层回答"何时产生一次音符,持续按住会不会反复触发";声音层回答"这个音符使用什么音色、如何延音与混音"。保存一个 USD 文件不会自动得到可听见的钢琴,声音通路还需要运行时程序。RoboPianist 的公开实现也将钢琴操作任务与音频处理组织为不同模块,但它使用 MuJoCo;下面的教学实现基于 OpenUSD,借鉴这种任务分层,不宣称兼容或复现其训练环境。24--27

层次 本例具体实现 输出与边界
几何与物理 琴壳、独立琴键、X 轴转动副、回弹弹簧、一个延音踏板 piano.usda;仍需实际引擎求解
状态与事件 读取角度、角速度,使用滞回阈值触发 note_on、note_off、CC64
声音 离线简易谐波试听;可另接钢琴采样音源 preview.mid、preview.wav
任务验收 单音、和弦、重触发、踏板延音与机器人按压 软件事件可本地检查,接触任务需另测

11.2 按音符顺序排列琴键,并为黑键保留真实空间

琴键布局应由音符编号生成,不能先等距摆出 88 个同宽长方体,再把一部分涂黑。一个八度中的白键音级是 C、D、E、F、G、A、B,对应 MIDI 编号除以 12 的余数 0、2、4、5、7、9、11;黑键出现在 C/D、D/E、F/G、G/A、A/B 的间隔中,E/F 与 B/C 之间没有黑键。key_layout() 按白键索引确定 X 坐标,在有升号的位置插入半个白键节距的黑键。因此,扩展八度时仍保持正确的二黑键、三黑键分组,也不会在缺少黑键的位置留下错误空槽。

本例白键中心节距设为 23.5 毫米,可见前段宽 22.5 毫米,因此相邻白键前端留有 1 毫米间隙;白键有效前端力臂为 240 毫米,设计下沉 10 毫米。黑键宽 12 毫米、力臂 135 毫米,下沉 8 毫米,初始顶面比白键高 14 毫米。这些都是用于说明构建方法的参数假设。真实重建需要测量目标钢琴的键面、枢轴、键深与行程,尤其不能从一张正面照片准确确定隐藏的支点和配重。88 键版本只有布局范围变化,仍沿用同一组简化参数。

python 复制代码
# 白键用前段和窄后杆组成同一个刚体。
if white:
    boxes = [("front", (0.0225, 0.095, 0.014), (0, -0.1925, -0.007)),
             ("stem", (0.0085, 0.145, 0.014), (0, -0.0725, -0.007))]
else:
    boxes = [("black", (0.012, 0.135, 0.018), (0, -0.0675, -0.009))]

白键后半段收窄,是为了给黑键及其运动留空间;它并未被拆成两根独立刚体,前段和后杆共同使用一份质量、质心与惯量。如果把整根白键做成满宽实心盒,再将黑键部分埋入其中,静止外观可能像键盘,按压时却会发生穿插或碰撞冲突。这里的关键是,视觉上的黑白关系必须对应实际几何间隙。本例采用对称窄后杆代替真实琴键各不相同的切口形状,属于机械几何简化,后续还要在运动范围内检查相邻键和外壳,而不能只查看顶视图。

11.3 琴键用转动副,回弹需要弹簧预载

本例仍采用米、千克、Z-up,X 方向从低音走向高音,演奏者位于负 Y 方向,每个琴键的支点放在后端。绕正 X 轴转动时,位于支点前方的键面向下运动,因此按压角取正值,零位为未按压。设琴键前端到支点的距离为 L L L,转角为 q q q,前端下沉为 h h h,则顶面接触点在本例几何下满足下面的关系。对白键取 L = 0.24 L=0.24 L=0.24 米、最大下沉 0.010 米,最大转角约为 2.388°;实际琴键存在复杂传动,这里只保留手指直接接触所需的一自由度运动。

h = L sin ⁡ q , q max ⁡ = arcsin ⁡ ( h max ⁡ L ) . h=L\sin q,\qquad q_{\max}=\arcsin\left(\frac{h_{\max}}{L}\right). h=Lsinq,qmax=arcsin(Lhmax).

每个琴键都是独立刚体,前后几何共享该刚体,关节连接 /Piano/frame 与 /Piano/Keys/n060 这样的命名节点。支点世界位置在琴壳侧换算为 localPos0,琴键原点已经位于支点,所以 localPos1 取零;局部旋转均为单位四元数,关节轴明确设置为 X。manifest.json 为每个音符保存刚体路径、关节路径、力臂、最大转角和质量,USD 关节上还写入自定义属性 piano:midiNote。运行时由这些记录建立音符映射,不依赖 Prim 遍历顺序猜测某根键属于哪个音。

琴键的重力会使前端向下,单纯把弹簧参考角设为零,会在零位产生零弹簧力矩,未按压的键仍可能下沉。为保持零位并让琴键松手后返回,本例先按复合几何计算质心与惯量,再采样整个行程的重力矩,选择足够的回弹弹簧预载 。设弹簧刚度为 k k k,黏性阻尼为 c c c,参考角为 q r e f q_{\mathrm{ref}} qref,预载力矩为 τ p r e \tau_{\mathrm{pre}} τpre,则使用下面的线性模型。参考角略小于零,零位限位限制琴键向上继续转动,弹簧则提供克服重力的恢复力矩。

τ r e t u r n = − k ( q − q r e f ) − c q ˙ , q r e f = − τ p r e k , τ p r e > max ⁡ q ∣ τ g ( q ) ∣ . \tau_{\mathrm{return}}=-k(q-q_{\mathrm{ref}})-c\dot q,\qquad q_{\mathrm{ref}}=-\frac{\tau_{\mathrm{pre}}}{k},\qquad \tau_{\mathrm{pre}}>\max_q|\tau_g(q)|. τreturn=−k(q−qref)−cq˙,qref=−kτpre,τpre>qmax∣τg(q)∣.

其中 k k k 以 N·m/rad 表示, c c c 以 N·m·s/rad 表示, q ˙ \dot q q˙ 是角速度, τ g ( q ) \tau_g(q) τg(q) 是对应姿态下的重力矩。本例白键质量取 45 克,计算得到重力矩上界约 0.06561 N·m,选择预载约 0.08745 N·m,刚度取 3 N·m/rad,对应参考角约 −1.670°。这组数值只说明如何避免遗漏静态重力,并不代表已经还原真实钢琴手感。键重、摩擦、非线性传动和回弹时间仍需测量与引擎测试,不能由"弹簧足够强"一项替代。

python 复制代码
# stiffness/damping 是 SI 弧度制参数。
drive = UsdPhysics.DriveAPI.Apply(joint.GetPrim(), "angular")
drive.CreateTypeAttr("force")
drive.CreateStiffnessAttr(stiffness * np.pi / 180)
drive.CreateDampingAttr(damping * np.pi / 180)
drive.CreateTargetPositionAttr(float(np.rad2deg(reference_rad)))
drive.CreateTargetVelocityAttr(0.0)
drive.CreateMaxForceAttr(0.5)

这里使用 DriveAPI 近似一根参考角固定的真实回位弹簧,运行中不通过修改目标位置自动压键。它与前面柜门的零刚度阻尼用途不同:琴键确实需要储能和恢复力,柜门示例则没有这项机构假设。USD 角度以度表达,因此刚度和阻尼都要从每弧度换算为每度;力矩上限 0.5 N·m 也应检查是否会截断正常行程中的弹簧作用。外力按键测试时应保持弹簧参考不变,由手指接触产生运动,才能检查实际施力和回弹,而不是播放一段目标角动画。56

11.4 从关节状态产生一次可靠的音符事件

有了机械运动,还需要定义何时"敲响"一个音。本例以 s = q / q max ⁡ s=q/q_{\max} s=q/qmax 表示归一化行程,下压到 0.65 且角速度为正时产生一次 note_on,回弹到 0.20 以下才产生 note_off 并允许下一次触发。按下阈值与释放阈值分开,可以避免接触抖动在一个临界位置反复发音;持续按住同一个键不会每帧生成新音符。两个阈值是软件策略,必须随着采样频率、真实击弦点和任务要求调整,它们并不是 MIDI 标准对机械琴键的规定。

python 复制代码
# PianoEvents.step() 中的核心事件转换。
stroke = np.clip(q / key["max_angle_rad"], 0, 1)
if not self.down[note] and stroke >= 0.65 and dq > 0:
    speed = max(0.0, key["lever_m"] * np.cos(q) * dq)
    velocity = int(np.clip(round(127 * speed / 0.4), 1, 127))
    self.down[note] = True
    events.append({"time_s": time_s, "type": "note_on", "note": note, "velocity": velocity})
elif self.down[note] and stroke <= 0.2:
    self.down[note] = False
    events.append({"time_s": time_s, "type": "note_off", "note": note, "velocity": 0})

进一步看,MIDI 的 velocity 表示起音力度,不能直接把最终压下深度当作力度。代码在触发时用 L cos ⁡ ( q ) q ˙ L\cos(q)\dot q Lcos(q)q˙ 估计键面竖直速度,再将其映射到 1---127;0.4 米/秒只是本例映射参考,不是从实物标定得到的击键速度。真实钢琴的琴槌速度与键面速度之间还有传动关系,若要研究音色和触键,应另行辨识。事件层要使用每个物理采样的角度、角速度与时间戳,而不是读取截图中键面像素,也不能假定渲染帧率就是物理更新频率。

在 Isaac Sim 中,接入位置应是物理步进完成后的回调:从实际关节接口读取状态,按关节路径或名称映射到 MIDI 编号,将角度和速度统一为弧度与弧度每秒,再调用 PianoEvents.step()。不要从未被同步的 USD 初始变换中读取所谓"当前按压角"。不同版本的 articulation 接口和状态缓冲方式需要按实际安装文档适配;本例提供了引擎无关的事件处理器,尚未提供或验证这一引擎适配器。初始化也应在琴键释放、踏板抬起时同步状态,避免场景重置后残留上一轮的按键记录。

11.5 延音踏板改变音源状态,琴键仍然正常释放

本例只实现右侧延音踏板的功能,用一个独立转动刚体和回位弹簧表达脚踏运动;没有假装同时实现柔音与选择性延音。踏板行程达到 0.60 时发送 MIDI 控制器 CC64 值 127,回到 0.20 以下发送值 0;MIDI 规范规定 CC64 的值 0---63 为关闭、64---127 为开启。机械踏板阈值与 MIDI 开关阈值属于不同层,不能把二者混为一个数。若后续研究半踏板效果,还需要连续阻尼和音源支持,简单开关事件不足以表达。25

踩住踏板时,琴键松开仍立即发送 note_off,音源根据 CC64 保留相应声部,踏板抬起后再进入衰减。延音不应通过扣留 note_off 来实现两次 ,否则再次按同一音、松踏板和清理状态时容易产生长鸣或错误截断。voice_intervals() 将琴键是否按住与声部是否释放分别保存,能够区分"键已松开但仍在延音"和"键仍按住"。多个音同时按下各有自己的状态,和弦因此不需要通过串行轮播单音来伪装;同一音再次击键,也应产生新的一次起音。2425

声音输出有两条可选路径:本例用 Python 写出 MIDI 文件,并以简单谐波与包络生成 WAV,方便确认音高、起音和踏板事件确实连通;若需要钢琴音色,可以使用 FluidSynth 等支持 SoundFont 的音源,将同一组音符和控制器消息送入其 MIDI 接口。音源、采样库及音频设备需要单独配置,试听信号不等于真实钢琴声学模拟。音频回调还应独立于物理线程处理,记录事件时间并关注缓冲延迟,不应为了等待播放声音阻塞物理求解。26

11.6 让 GPT‑6 维护钢琴程序,而不是逐键猜坐标

把这项任务交给 GPT‑6 时,输入需要包含键盘范围、坐标与单位、独立部件计划、每种键的尺寸、按压方向、行程与回弹模型,以及声音由外部音源处理的约定。适合模型修改的变量包括键宽、键深、枢轴位置、质量、弹簧参数和触发阈值;音符排列规则、弧度与度的换算、状态到事件的协议应由宿主检查。前面的 agent_loop.py 只认识柜门参数,不能直接调用它生成钢琴;需要另外注册钢琴构建与测量工具,或者让代码生成代理修改并执行本节脚本,再把真实报告回传。

一条具体修订链可以这样展开:扫描或卡尺显示白键前端宽度正确,但黑键前沿过于靠近演奏者,先调整黑键长度或安装位置,再检查白键后杆与黑键的间隙;按键测试发现释放后无法回位,则查看支点、惯量、重力矩、预载和阻尼,而不是提高音符音量;某次轻触发出多个音,应该检查阈值滞回和采样记录。换句话说,每种错误都应回到产生它的那一层。模型能统筹修改,但不能用声音已经响起来证明物理按压正确,也不能靠一段正确旋律证明扫描尺寸一致。

12. 目前仍然困难的对象和机制

USDCraft 论文列出了细网格结构和柔性物体的限制。购物车金属网、球拍细线的局部间距和连接关系难以准确重建;电线、布料或伞面的静止形状,即使已经被程序生成,也不等于弯曲、拉伸和接触耦合经过验证。闭环机构、齿轮配合、密封件预紧和复杂摩擦同样需要超出基础刚体树的表达与测试。把这些对象全部近似成多个可动刚体可能在视觉上成立,却无法自动保留力学行为,使用时应根据操作任务决定哪些机制必须建模,哪些可以明确简化。

对初次落地的团队,最合适的起点是尺寸可测、运动明确、接触结构简单的对象:柜门、抽屉、按钮或单轴旋钮。先用确定性构建器输出独立 USD,检查单位、开口和锚点,再让模型依据真实反馈调整参数,最后进入物理引擎和机器人任务。这个顺序能够让每一步失败都有明确归属。GPT‑6 的作用是把图像语义、局部测量和程序修改组织起来;资产的可信程度,则由证据是否可靠、工具是否执行,以及目标动作是否真的通过测试共同决定。

13. 参考资料

  • 1 Zhang, C. 等,USDCraft: Geometrically Grounded Programmatic Modeling of Articulated 3D Assets for Simulation ,论文全文与附录,项目主页。作者来自 NVIDIA、NTU 与 PKU,论文为预印本;方法与所有引用实验数字以此为准。
  • 2 OpenAI,GPT‑6 Astra 模型文档。用于核验模型标识、输入模态、函数调用与推理设置,账户访问和价格以调用时官方资料为准。
  • 3 OpenUSD,Introduction to USD。解释 Prim、Stage、组合与资产组织,适合建立 USD 的整体概念。
  • 4 OpenUSD,Hello World Tutorial。从 Python 创建、保存和检查一个最小场景。
  • 5 OpenUSD,Physics Schema。刚体、关节、局部位置与旋转、限位和驱动属性的依据。
  • 6 OpenUSD,Rigid Body Physics White Paper。质量、惯量、物理材料与单位约定的深入说明。
  • 7 Khronos,glTF 2.0 Specification。用于确认坐标、米制和节点变换,实际输入仍需检查转换历史。
  • 8 NVIDIA,OpenUSD Code Samples。正文四段基础函数来自 source/stage/ 下对应目录,Apache-2.0 许可证信息已保留。
  • 9 NVIDIA Isaac Sim,Simulation Fundamentals。将 USD 物理属性放回实际求解、碰撞与仿真上下文。
  • 10 CadQuery,Quickstart。机械部件的参数化实体建模教程。
  • 11 CadQuery,Examples。孔、壳体、工作平面与组合建模的可运行实例。
  • 17 OpenAI,Function Calling。正文工具 Schema、函数结果回传和保留输出条目的 API 依据。
  • 18 OpenAI,Images and Vision。图像输入、Base64 数据 URL、细节等级和视觉能力边界。
  • 20 NVIDIA,USD Tutorials and Examples。可作为更完整的场景操作练习材料,与 Code Samples 交叉阅读。
  • 21 trimesh,文档与 API。配套程序使用场景图变换、表面采样和基本几何生成;本地精确版本记录在 examples/output/versions.json。
  • 24 MIDI Association,Summary of MIDI 1.0 Messages。核对 Note On、Note Off、velocity 与控制器消息的含义。
  • 25 MIDI Association,MIDI 1.0 Control Change Messages。CC64 为延音踏板,0---63 关闭,64---127 开启;第 11 节的软件行程阈值另行定义。
  • 26 FluidSynth,官方 API 文档。按 MIDI、SoundFont、合成器与音频输出划分接口,适合接入外部钢琴音源;本例未安装或运行 FluidSynth。
相关推荐
七牛云行业应用1 天前
MIT “权重空间编程”:GPT-6 Astra 修改 0.016% 的 Qwen 权重,MATH-500 从 7.9% 升至 48.1%
人工智能·gpt·ai
一缕82年的清风1 天前
智能体“自适应慢思考”的算力黑洞:当 Claude 5.5 与 GPT-6.1 Sol 陷入推理自旋,为什么高并发架构必须重构“认知预算熔断与快慢推测执行”?
gpt·claude·gemini
AI 编程助手GPT2 天前
GPT 排的日志时间线差一小时?用 zoneinfo 复现夏令时回拨
人工智能·python·gpt
阿愚先生2 天前
【从零手写GPT(一)】:RTX 5080 环境踩坑,我装错了两次 PyTorch
人工智能·pytorch·gpt
一缕82年的清风2 天前
智能体“自主决策”的虚假繁荣:当 Claude 5.5 与 GPT-6.1 深入生产,为什么顶级架构师都在重回“确定性状态机”?
gpt·claude
AI 编程助手GPT2 天前
GPT 改完配置别直接覆盖:用 git diff 审查两份文件
git·gpt
一缕82年的清风3 天前
算力平民化神话破产:当推理成本暴降 90%,为什么生产级 Agentic 架构反而更贵更脆弱了?
gpt·claude
西索斯coding4 天前
gpt-image-2.5-sunburst 和 gemini-3.1-flash-image 差多少?60 组项目素材的成功成本评测框架
java·linux·gpt·ai
一枕槐安4874 天前
大模型 API 调用封装:统一适配 GPT、文心一言、通义千问
人工智能·gpt·文心一言·大模型api·流式输出·统一封装·多模态路由