Three.js 如何解析 URDF 机器人模型

机器人可视化的第一步,是把一个 URDF 文件变成 Three.js 场景里能渲染、能动的东西。

这件事听起来像是「加载一个模型」,但 URDF 根本不是模型文件。它里面没有顶点、没有法线、没有材质贴图,只有一个 XML 骨架,描述机器人由哪些刚体组成、这些刚体怎么连起来。真正的几何体(STL、DAE)是另外的文件。

所以 Three.js 要做的,是把这套「关系描述」翻译成一棵 Object3D 树。这篇分两部分:先讲 URDF 到底描述了什么,再逐段拆 URDFLoader 的源码,看它每一步在干什么。

在 Three.js 里加载出来的 Unitree H1,左侧是由 URDF 关节自动生成的滑条面板

1. 什么是 URDF

URDF 全称 Unified Robot Description Format,是 ROS 里的机器人描述格式,本质就是一个 XML。

它只回答两个问题:

  • 机器人身上有哪些刚体 —— 用 <link> 描述
  • 这些刚体怎么连、怎么动 —— 用 <joint> 描述

URDF 中的 link 与 joint

注意图里右边那张桌子,旁边也标了一句 “also a link”。这是很多人第一次看这张图会卡住的地方:link 不一定是机器人身上的零件。桌子、地面、相机支架,只要它参与坐标变换,就可以是一个 link。

一个真实的 URDF 文件骨架长这样(下面摘自人形机器人 h1_2.urdf):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<robot name="h1_2">

<mujoco>
<compiler meshdir="meshes" discardvisual="false"/>
</mujoco>

<link name="pelvis">
<inertial> ... </inertial>
<visual> ... </visual>
<collision> ... </collision>
</link>

<joint name="left_hip_yaw_joint" type="revolute">
<origin xyz="0 0.0875 -0.1632" rpy="0 0 0"/>
<parent link="pelvis"/>
<child link="left_hip_yaw_link"/>
<axis xyz="0 0 1"/>
<limit lower="-0.43" upper="0.43" effort="200" velocity="23"/>
</joint>

<link name="left_hip_yaw_link"> ... </link>

</robot>

<robot> 是根标签,里面就是交替出现的 <link> 和 <joint>。文件开头那个 <mujoco> 标签是给 MuJoCo 用的扩展,浏览器里的解析器不认识它 —— 后面会看到,解析器只用「认不认识 link / joint / material」来过滤,遇到不认识的标签直接跳过,所以它能安然待在那里。

2. link:一个刚体的三重描述

先从最小的单元看起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<link name="link_4">
<visual>
<geometry>
<mesh filename="link_4.stl"/>
</geometry>
<origin xyz="0 0 0" rpy="0 0 0" />
</visual>
<collision>
<geometry>
<cylinder length="0.5" radius="0.1"/>
</geometry>
<origin xyz="0 0 -0.05" rpy="0 0 0" />
</collision>
</link>

link 标签中的 name 表示该连杆的名称,我们可以自定义。未来 joint 连接 link 的时候,会使用到这个名称。

<visual>:看上去是什么样

<visual> 描述机器人的外观:

  • <geometry> 表示几何形状。这里用 <mesh> 调用了一个在三维软件中提前设计好的蓝色外观,也就是那个 stl 文件,看上去和真实机器人是一致的。
  • <origin> 表示坐标系相对初始位置的偏移,分别是 x、y、z 方向上的平移,和 roll、pitch、yaw 旋转。不需要偏移的话,就全为 0。

<collision>:碰撞时算成什么

第二个部分 <collision> 描述碰撞参数。里边的内容似乎和 <visual> 一样,也有 <geometry> 和 <origin>,看似相同,其实区别还是比较大的:

  • <visual> 重在描述机器人看上去的状态,也就是视觉效果;
  • <collision> 则是描述机器人运动过程中的状态,比如机器人与外界如何接触算作碰撞。

在这个机器人模型中,蓝色部分是通过 <visual> 来描述的。在实际控制过程中,这样复杂的外观在计算碰撞检测时要求的算力较高;为了简化计算,我们把碰撞检测用的模型简化为了绿色框的圆柱体,也就是 <collision> 里边 <geometry> 描述的形状。

<origin> 坐标系偏移也是类似 —— 它可以描述刚体质心的偏移。视觉模型的几何原点未必在零件的旋转中心上,但碰撞体通常要摆在质心附近,两个 <origin> 就此分道扬镳。

还有一个 <inertial>

<link> 里其实还有第三块,上面那段示例没写,但真实文件里都有:

1
2
3
4
5
6
<inertial>
<origin xyz="-0.0004 3.7E-05 -0.046864" rpy="0 0 0"/>
<mass value="5.983"/>
<inertia ixx="49168.411E-06" ixy="-19.869E-06" ixz="-48.460E-06"
iyy="9025.844E-06" iyz="3.431E-06" izz="53155.891E-06"/>
</inertial>

质量、质心位置、惯性张量。这是给物理仿真用的,渲染其实完全用不到。但可视化的时候把它画出来很有价值:你能一眼看出模型的质心在哪、惯量分布合不合理。

机器人模型中的刚体最终要通过关节 joint 连接之后,才能产生相对运动。

URDF 中的关节有六种运动类型:

URDF 的六种关节类型

关节类型 描述
continuous 旋转关节,可以围绕单轴无限旋转
revolute 旋转关节,类似于 continuous,但是有旋转的角度极限
prismatic 滑动关节,沿某一轴线移动的关节,带有位置极限
fixed 固定关节,不允许运动的特殊关节
floating 浮动关节,允许进行平移、旋转运动
planar 平面关节,允许在平面正交方向上平移或者旋转

对应到实际零件:

  1. continuous 描述旋转运动,可以围绕某一个轴无限旋转,比如小车的轮子,就属于这种类型。
  2. revolute 也是旋转关节,和 continuous 类型的区别在于不能无限旋转,而是带有角度限制,比如机械臂的两个连杆,就属于这种运动。
  3. prismatic 是滑动关节,可以沿某一个轴平移,也带有位置的极限,一般直线电机就是这种运动方式。
  4. fixed 固定关节,是唯一一种不允许运动的关节。不过使用还是比较频繁的,比如相机这个连杆,安装在机器人上相对位置不会变化,此时使用的连接方式就是 fixed。
  5. floating 是浮动关节,第六种 planar 是平面关节,这两种使用相对较少。

一个 joint 的完整写法:

1
2
3
4
5
6
7
<joint name="left_hip_yaw_joint" type="revolute">
<origin xyz="0 0.0875 -0.1632" rpy="0 0 0"/>
<parent link="pelvis"/>
<child link="left_hip_yaw_link"/>
<axis xyz="0 0 1"/>
<limit lower="-0.43" upper="0.43" effort="200" velocity="23"/>
</joint>

逐条看:

标签 含义
name 关节名,后续代码里按名字设角度
type 六种运动类型之一
<origin> 关节相对父 link 的位姿 —— 关节长在父杆的哪个位置
<parent link> 父连杆
<child link> 子连杆
<axis> 旋转/滑动的方向轴,在 joint 自己的坐标系里
<limit> lower/upper 是角度或位移范围,effort 是力矩上限,velocity 是速度上限

这里最关键的是 <parent> / <child> 的语义:joint 长在 parent link 上,child link 挂在 joint 下面。

也就是这样一条链:

1
2
3
4
5
6
link (pelvis)
└─ joint (left_hip_yaw_joint)
└─ link (left_hip_yaw_link)
└─ joint (left_hip_pitch_joint)
└─ link (left_hip_pitch_link)
└─ ……

形成一棵 link → joint → link → joint 交替的树。这个结构记牢,下一节会看到 Three.js 就是照着它建树的。

4. Three.js 解析 URDF

整个流程的入口是 load():取到 URDF 文本、交给 processUrdf 解析、再把结果回调出去。这里面有两行容易被看漏的 itemStart / itemEnd。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
load(
url: string,
onLoad: (robot: Object3D) => void,
onProgress?: (progress?: any) => void,
onError?: (err?: any) => void,
) {
const { manager } = this
// 解析URDF文件链接,manager.resolveURL() 可以修改url,若未设置resolveURL方法,返回url
const urdfPath = this.manager.resolveURL(url)
// 开始加载文件,需要在加载完成后,与manager.itemEnd(urdfPath)方法搭配使用
manager.itemStart(urdfPath)
// 请求URDF文件
fetch(urdfPath, this.fetchOptions)
.then((res) => {
if (res.ok) {
onProgress && onProgress(res)
return res.text()
} else {
throw new Error(
`URDFLoader: Failed to load url '${urdfPath}' with error code ${res.status} : ${res.statusText}.`,
)
}
})
.then((data) => {
// 解析URDF文件
const model = this.processUrdf(data, urdfPath)
model && onLoad(model)
// 结束加载
manager.itemEnd(urdfPath)
})
.catch((e) => { /* ... */ })
}

这里没有用 FileLoader,而是直接 fetch + res.text() —— 因为 URDF 是 XML 文本,不需要任何额外的解析钩子。

值得注意的是手写的那对 manager.itemStart(urdfPath) / manager.itemEnd(urdfPath)。

LoadingManager 内部维护一个「还有几件事没做完」的计数器,平时是 FileLoader 自己调 itemStart / itemEnd。但这里用的是原生 fetch,LoadingManager 完全不知情。如果不手动补上这两句,会出现一个很隐蔽的问题:URDF 主文件还没下载完,manager.onLoad 就已经触发了(因为此刻计数器是 0),外部拿到一个还没建好的机器人。

补上之后,URDF 主文件和它引出的所有 STL/贴图,全都算在同一个计数器里,「全部加载完成」这个信号才准。这一点在 4.4 节讲 <mesh> 异步加载时会再回来看。

4.1 把 XML 变成 DOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
processUrdf(data: string | Document | Element, urdfPath: string) {
// urdf标签集合
let children: Element[]
// 将URDF文件解析为DOM元素
if (typeof data == 'string') {
const parser = new DOMParser()
const urdf = parser.parseFromString(data, 'application/xml')
children = Array.from(urdf.children)
} else if (data instanceof Document) {
children = Array.from(data.children)
} else {
children = [data]
}
// 获取<robot>
const robotNode = children.filter((c) => c.nodeName === 'robot').pop()
if (!robotNode) {
return
}
// ...
}

这里没有引入任何 XML 库,直接用浏览器原生的 DOMParser,MIME 类型指定 application/xml。

这是整个解析流程里最省事的一个决定。 一旦拿到 Document,后面所有的查找、遍历、取属性,都可以用现成的 DOM API —— querySelector、getAttribute、children。手写正则或者用 fast-xml-parser 反而要把这些重新发明一遍。

方法签名允许 string | Document | Element 三种输入,是为了让外部可以传入已经解析好的 DOM,避免重复解析。

filter(...).pop() 这一串是防御性写法:parseFromString 在某些浏览器上会在文档前插入节点,取最后一个匹配到的 robot 更稳;解析失败时返回的是一份带 parsererror 的文档,这里则返回 undefined,让调用方拿到 null 而不是崩溃。

4.2 对标签进行分类

1
2
3
4
5
6
// 将<robot>中的一级子元素<link>、<joint>、<material> 归类
const nodes = classifyNodeByName<'link' | 'joint' | 'material'>(robotNode, [
'link',
'joint',
'material',
])

classifyNodeByName 在 utils.ts 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
function classifyNodeByName<key>(node: Element, names: key[]) {
const nodeMap: Map<key, Element[]> = new Map()
for (let name of names) {
nodeMap.set(name, [])
}
for (let child of Array.from(node.children)) {
const key = child.nodeName.toLowerCase() as key
if (names.includes(key)) {
nodeMap.get(key)?.push(child)
}
}
return nodeMap
}

效果是把 <robot> 的直接子元素按标签名分到三个桶里。不在名单里的标签被静默忽略 —— 这就是开头那个 <mujoco> 能安然待着的原因,也是这个加载器能兼容各家厂商私有扩展的原因。

这里有两个细节:

  • node.children 只返回元素节点,注释节点、文本节点天然被排除,所以不用额外判断节点类型。
  • nodeName.toLowerCase() —— XML 规范里标签名是大小写敏感的,但实际文件里有写 <LINK> 的。统一转小写能容忍这种不规范。

分完之后,后面就是三趟独立的循环,互不干扰。

4.3 解析材质

三类里最先处理的是 <material>:

1
2
3
4
5
6
7
8
const materialMap: Map<string, Material> = new Map()

//遍历urdf中的material元素,将其解析成three.js材质,写入materialMap中
nodes.get('material')?.forEach((materialNode: Element) => {
const materialName = materialNode.getAttribute('name')
materialName &&
materialMap.set(materialName, processMaterial(materialNode))
})

对应的是这样的 XML:

1
2
3
4
<material name="steel">
<color rgba="0.7 0.65 0.55 1"/>
<texture filename="../texture/head.jpg"/>
</material>

processMaterial 是这一节的核心 —— 它把一个 <material> 元素翻译成一个 Three.js 材质对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function processMaterial(materialNode: Element) {
const { manager } = _this
// 默认材质
const material = _this.createCommonMaterial()
material.name = materialNode.getAttribute('name') || ''
// 遍历<material> 子元素
for (let child of Array.from(materialNode.children)) {
const nodeName = child.nodeName.toLowerCase()
switch (nodeName) {
case 'color':
// 解析rgba颜色
const rgbaAttr = child.getAttribute('rgba')
if (rgbaAttr) {
const rgba = processTuple(rgbaAttr) as NumberTuple4
// 设置rgb颜色
material.color.setRGB(rgba[0], rgba[1], rgba[2])
// 设置透明度
material.opacity = rgba[3]
material.transparent = rgba[3] < 1
}
break
case 'texture':
// 纹理路径
const filename = child.getAttribute('filename')
if (filename) {
// 纹理加载器
const loader = new TextureLoader(manager)
// 解析纹理路径
const filePath = _this.resolveSubPath(filename)
material.map = loader.load(filePath)
material.map.colorSpace = SRGBColorSpace
}
break
}
}
return material
}

整个函数就一句话可以概括:先取一个默认材质,再拿 XML 里的字段往上覆盖。 但和 Three.js 结合的这四个点,每一个都有讲究。

一、基底是 createCommonMaterial(),不是 new Material()。

第一行先取一份默认材质,再往里填东西。这个默认材质是个三参数的 MeshStandardMaterial(color: 0xcccccc、metalness: 1、roughness: 0.1)。

这意味着即使 <material> 里只写了一个 <color>,最终出来的也是一个 PBR 材质,而不是 MeshBasicMaterial 那种纯色 —— URDF 只描述「什么颜色」,而 Three.js 拿到的是一套完整的 PBR 参数。

二、opacity 和 transparent 必须成对出现。

1
2
material.opacity = rgba[3]
material.transparent = rgba[3] < 1

在 Three.js 里单独设 opacity 是不生效的。 只有 transparent = true 时,材质才会进入透明渲染队列、开启混合。设了 opacity = 0.5 但 transparent = false,渲染出来还是完全不透明 —— 这是 Three.js 里最常见的「为什么我的透明没效果」之一。

那为什么不干脆无条件 transparent = true?因为透明物体要参与排序、通常还要处理深度写入,是有实打实开销的。用 rgba[3] < 1 判一下,只有真正需要透明的材质才付这个代价。

三、setRGB 传了三个参数,没做色彩空间转换。

Color.setRGB(r, g, b) 的第四个参数是 colorSpace,缺省时按 working color space(线性空间) 解释 —— 也就是不做任何转换。

而 URDF 里写的 rgba 值,惯例上是给人看的显示值(sRGB)。上面 h1_2.urdf 里的 rgba="0.1 0.1 0.1 1",按线性解释出来会明显比「看起来的 0.1」要亮。如果发现模型颜色比预期发灰发亮,这里是第一个该看的地方。

四、贴图那一支更微妙。

1
2
3
4
const loader = new TextureLoader(manager)
const filePath = _this.resolveSubPath(filename)
material.map = loader.load(filePath)
material.map.colorSpace = SRGBColorSpace

拆成三个点:

  • loader.load() 是同步返回一个空的 Texture 对象的,图片之后才异步填进去。所以这一行不会阻塞,material.map 立刻就有值,只是此刻 image 还是空的 —— Three.js 会在图片 onload 之后自动上传 GPU 并把 needsUpdate 置为 true。模型先白几帧、贴图再「跳」出来,就是这个机制。
  • TextureLoader 传了 manager,所以贴图也在加载计数器里,manager.onLoad 会等它 —— 和 4.4 里 STL 的处理是同一个道理。
  • colorSpace = SRGBColorSpace 必须显式设。 Three.js 对贴图的默认假设是 NoColorSpace,也就是「这是线性数据,直接用」。但 JPG/PNG 里存的像素值是 sRGB 编码的,少了这一句,未解码的数值会直接进光照计算,结果就是整体偏亮、对比度发灰。

注意三和四是两个相反的方向:setRGB 是把值当作线性用(不转换),texture.colorSpace 是声明「这来自 sRGB」(shader 里会解码)。同一个函数里两条路径的处理并不一致 —— URDF 的颜色语义本身没有严格规定,两边只能各自约定。

最后,material.name = materialNode.getAttribute('name') 把 URDF 里的名字带到了 Three.js 对象上。这不只是好看 —— 调试时能在 scene 树里一眼认出哪个材质是哪个,也方便后续按名字批量替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const linkMap: Map<string, URDFLink> = new Map()

nodes.get('link')?.forEach((linkNode: Element) => {
// link 名称
const linkName = linkNode.getAttribute('name') || ''
// link 图形
const urdfLink = new URDFLink()
urdfLink.name = linkName
// 若当前link 不是任何joint的child,那它就是最根部的link。
if (!robotNode.querySelector(`child[link="${linkName}"]`)) {
urdfRobot.add(urdfLink)
}
// 解析<link>
processLink(linkNode, urdfLink)
linkMap.set(linkName, urdfLink)
})

这段做三件事:建 URDFLink、判断它是不是根、调用 processLink 解析 <link> 内部。processLink 本体很短:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function processLink(linkNode: Element, urdfLink: Group) {
// <link>中的所有子标签
const linkChildren = Array.from(linkNode.children)
// 遍历<link>中的所有子标签
linkChildren.forEach((linkChild) => {
// 子标签类型
const type = linkChild.nodeName.toLowerCase()
if (type === 'inertial') {
// 解析<inertial>,将inertial中的图形添加到urdfLink中
processInertial(linkChild, urdfLink)
} else if (type === 'visual') {
// 解析<visual>,将visual图形添加到urdfLink中
processVisual(linkChild, urdfLink)
} else if (type === 'collision') {
// 解析<collision>,将collision图形添加到urdfLink中
processCollision(linkChild, urdfLink)
}
})
}

和 <joint> 的 switch 是同一个套路:按标签名分派。 但有个重要差别 —— processJoint 里的 switch 主要是收集数据(position、axis、limit 写进 userData),而这里三个分支都是往 urdfLink 上挂子节点。

也就是说,一个 <link> 解析完之后,在 Three.js 里长这样:

1
2
3
4
5
6
7
8
9
10
11
urdfLink (URDFLink / Group)
├─ massHelper ← <inertial>,默认隐藏
├─ inertiaHelper ← <inertial>,默认隐藏
├─ linkVisual ← <visual> 一,带自己的 origin 变换
│ └─ Mesh
├─ linkVisual ← <visual> 二
│ └─ Mesh
├─ collisionHelper ← <collision> 一
│ └─ Mesh
└─ collisionHelper ← <collision> 二
└─ Mesh

数量关系值得记一下,这是 URDF 规范规定的:

子标签 一个 link 里能有几个 挂上去的是什么
<visual> 任意多个 每个一个 LinkVisual
<collision> 任意多个 所有碰撞体共用同一个 Group
<inertial> 最多一个 质心球 + 惯性盒子两个辅助对象

<collision> 那行是唯一一个「多个 XML 节点合成一个 Three.js 对象」的写法 —— 对比 <visual> 是来一个建一个。这个不对称是刻意的:运行时往往要整体开关碰撞体显示、整体换色,合成一个 Group 比一堆散落的 Mesh 好用得多。

还有一点:这三个分支挂上去的东西都挂在 urdfLink 这一层,共享同一个坐标系。它们各自的 <origin> 只是把自己再挪一下。所以「视觉模型在原位、碰撞体偏到质心」这类差异,靠的是各自的 <origin>,而不是挂到不同的父节点上。

其中最常打交道的 <visual> 是这样解析的:

1
2
3
4
5
6
7
function processVisual(visualNode: Element, urdfLink: Group) {
let material = getMaterialFromNode(visualNode)
const linkVisual = new LinkVisual()
linkVisual.name = visualNode.getAttribute('name') || ''
processOriginAndGeometry(visualNode, material, linkVisual)
urdfLink.add(linkVisual)
}

四行做四件事:取材质(按 <material> 的 name 查 materialMap,查不到就用默认材质)、建一个 LinkVisual 容器、把 <origin> 和 <geometry> 解析进去(4.7 的 processOriginAndGeometry)、挂到 link 上。

注意 LinkVisual 只承载 <origin> 的位姿,几何体是挂在它下面的 Mesh。所以一个带两个 <visual> 的 link,是「两个容器各带一个 Mesh」,而不是「一个容器里两个 Mesh 各带偏移」—— 这样每块几何体的 <origin> 互不干扰。

再往里一层:<geometry> 怎么解析

<visual> 和 <collision> 内部都交给同一个函数 processOriginAndGeometry(4.7 展开),它把子标签分成 origin 和 geometry 两路。<geometry> 这一路的入口是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function processGeometry(
geometryNode: Element,
material: Material,
parent: Group,
) {
const geometryChildNode = geometryNode.children[0]
if (!geometryChildNode) { return }
const geoType = geometryChildNode.nodeName.toLowerCase()
switch (geoType) {
case 'mesh':
processMesh(geometryChildNode, material, parent)
break
case 'box':
processBox(geometryChildNode, material, parent)
break
case 'sphere':
processSphere(geometryChildNode, material, parent)
break
case 'cylinder':
processCylinder(geometryChildNode, material, parent)
break
}
}

<geometry> 里只允许有一个子标签,所以直接取 children[0],再按标签名分派。又和 <link>、<joint> 是同一个套路 —— 这个加载器里到处都是「取 children,按 nodeName 分发」。

其中 box、sphere、cylinder 是同步的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 解析<box size="0.224 0.08 0.071"/>
function processBox(boxNode: Element, material: Material, parent: Group) {
const [x, y, z] = processTuple(boxNode.getAttribute('size'))
const boxMesh = new Mesh(new BoxGeometry(x, y, z), material)
parent.add(boxMesh)
}

// 解析<sphere radius="0.0065" />
function processSphere(
sphereNode: Element,
material: Material,
parent: Group,
) {
const radius = parseFloat(sphereNode.getAttribute('radius') || '0')
const sphereMesh = new Mesh(new SphereGeometry(radius, 8, 6), material)
parent.add(sphereMesh)
}

// 解析<cylinder length="0.13" radius="0.053"/>
function processCylinder(
cylinderNode: Element,
material: Material,
parent: Group,
) {
const radius = parseFloat(cylinderNode.getAttribute('radius') || '0')
const length = parseFloat(cylinderNode.getAttribute('length') || '0')
const cylinderMesh = new Mesh(
new CylinderGeometry(radius, radius, length, 6),
material,
)
cylinderMesh.rotation.set(Math.PI / 2, 0, 0)
parent.add(cylinderMesh)
}

三个函数长得出奇地一致,都是同一个套路:

1
读属性 → new 几何体 → new Mesh(几何体, 材质) → parent.add(mesh)

这就是 URDF 几何体到 Three.js 的全部桥接。 没有额外的包装类 —— Mesh 是 Three.js 原生的,BoxGeometry / SphereGeometry / CylinderGeometry 也是,解析器只是把 XML 属性翻译成了构造函数的实参。

对照着看参数:

URDF Three.js 是否直接对应
<box size="x y z"> BoxGeometry(x, y, z) 是
<sphere radius> SphereGeometry(radius, 8, 6) 半径是,段数硬编码
<cylinder radius length> CylinderGeometry(r, r, l, 6) 是,但要额外转 90°
<mesh filename scale> STLLoader / ColladaLoader 异步加载 见下

radius 在 CylinderGeometry 里出现两次,是因为它的签名是 CylinderGeometry(radiusTop, radiusBottom, height, ...) —— 支持上下半径不同的圆台。URDF 的圆柱上下一样粗,所以两个参数传同一个值。

几个容易忽略的点:

球:URDF 只给 radius,Three.js 还必须给宽高段数。这里写死 8, 6,是相当低的多边形数 —— 因为 <sphere> 在这类模型里几乎只出现在碰撞体上,本来就不需要圆。

圆柱:径向段数同样只有 6,也就是个六棱柱。同样出于「简化碰撞检测算力」的考虑 —— 和 2. 节里说的「把复杂外观简化成圆柱体」是同一个动机的延伸。

圆柱还多转了一下:

1
cylinderMesh.rotation.set(Math.PI / 2, 0, 0)

因为两者的轴向不一样:URDF 里圆柱默认沿 Z 轴,Three.js 的 CylinderGeometry 默认沿 Y 轴。绕 X 轴转 90° 正好把 Y 轴映射到 Z 轴:

1
绕 X 轴 +90°:  (0, 1, 0)  →  (0, 0, 1)

注意这一句是加在 Mesh 上的,而不是像 4.7 那样加在外层容器上。这不矛盾 —— 两者管的是不同的事:

1
2
容器 Group   ← 负责「摆在哪」,由 <origin> 决定
└─ Mesh ← 负责「自己长什么样」,轴向修正在这里

<origin> 描述的是这个几何体相对 link 的位姿,属于外部关系,所以设在容器上;圆柱的朝向修正是几何体自身的属性,所以设在 Mesh 上。两者相乘的结果恰好就是 URDF 的语义:先在局部坐标系里沿 Z 轴长出圆柱,再按 <origin> 摆到位。

盒子和球都不需要这一步,两边的轴向本来就一致。

<mesh> 是唯一的异步分支

前三种形状当场 new 出来就完事,<mesh> 不行 —— 它指向一个外部文件,得先下载。加载器把这部分做成了一张按后缀查表的注册表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
meshParsers: {
[k: string]: (filePath, material, manager?) => Promise<Object3D | void>
} = {
stl: (filePath, material, manager = this.manager) => {
const loader = new STLLoader(manager)
return loader.loadAsync(filePath).then(
(geom) => {
const mesh = new Mesh(geom, material)
mesh.castShadow = true
return mesh
},
(err) => { console.error('URDFLoader: Error loading mesh.', err) },
)
},
dae: (filePath, material, manager = this.manager) => {
const loader = new ColladaLoader(manager)
return loader.loadAsync(filePath).then(
(dae) => {
if (!dae || !dae.scene) { return }
const daeScene = dae.scene
daeScene.traverse((obj) => {
if (obj instanceof Mesh) {
obj.material = material
}
})
return daeScene
},
(err) => { console.error('URDFLoader: Error loading mesh.', err) },
)
},
}

用文件后缀查表,意味着要支持新格式不用改 processMesh,往这张表里加一项就行。使用它的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 模型文件的格式
const suffix = filePath.split('.').pop()?.toLowerCase()
if (suffix && meshParsers[suffix]) {
// 模型解析方法
meshParsers[suffix](filePath, material).then((obj) => {
if (!obj) { return }
// 模型缩放
const scaleAttr = meshNode.getAttribute('scale')
if (scaleAttr) {
const [x, y, z] = processTuple(scaleAttr)
obj.scale.multiply(new Vector3(x, y, z))
}
// 将模型添加到visual图形
parent.add(obj)
})
} else {
console.warn(`无法解析以 ${suffix} 为后缀的模型.`)
}

几个点:

一、STL 和 DAE 的产物类型不一样。 STLLoader 只吐 BufferGeometry,所以这里得自己 new Mesh;ColladaLoader 吐的是完整的 dae.scene,一个 Group,还可能带层级,所以那边是 traverse 进去逐个 Mesh 换材质。下游代码不能假设 obj instanceof Mesh —— 这也是 parent.add(obj) 的形参类型是 Object3D 而不是 Mesh 的原因。

二、scale 用 multiply 而不是 set。 DAE 文件自带缩放(Collada 常以厘米为单位),set 会把它覆盖掉,multiply 才是叠加。

三、parent.add(obj) 发生在 Promise 里。

这就是时序问题的根源:processUrdf 从头跑到尾、把 urdfRobot 返回出去的时候,所有 mesh 都还没回来。外部 onLoad 回调拿到的是一个「骨架已经搭好、但身上还光着」的机器人。

回到第 4 节的引子 —— 现在能说清那对 itemStart / itemEnd 的真正价值了。上面每个 new STLLoader(manager) 都传了同一个 manager,所以每个 STL 的加载都会自动进出计数器;而 URDF 主文件由 load() 手动 itemStart。两件事加在一起,manager.onLoad 才真正代表「URDF 和它引出的所有网格都齐了」。

项目里的用法印证了这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const manager = new LoadingManager()
const urdfLoader = new URDFLoader(manager)
urdfLoader.load(robotUrl, (protoRobot: any) => {
// 此时protoRobot 中的模型文件尚未加载完成
protoRobotMapTemp.set(type, protoRobot)
})

manager.onError = () => { console.error('机器人加载出错!') }
manager.onLoad = () => {
newRobotTypeList.forEach((type) => {
const protoRobot = protoRobotMapTemp.get(type)
if (!protoRobot) { return }
// protoRobot 中的模型文件加载完成
protoRobotMap.set(type, protoRobot)
resourceTracker.track(protoRobot)
})
onLoad()
}

onLoad 回调里用「暂存 Map」接住骨架,manager.onLoad 里才把机器人交给正式的表 —— 两级回调,正好对应两个加载阶段。

四、路径解析交给了 resolveSubPath。

1
2
3
4
/*子路径解析方法,可在外部重写*/
resolveSubPath(filename: string) {
return filename
}

默认原样返回。这个钩子存在的原因是:URDF 里的 filename 经常是 package://my_robot/meshes/arm.stl 这种 ROS 包路径,浏览器根本不认识;也经常是相对 URDF 文件自身的路径 ./models/h1_2_description/meshes/pelvis.STL,而 URDF 和网页并不在同一个目录下。

同一个文件在 ROS 里能被 rviz 正确加载、在浏览器里却 404,绝大多数情况都出在这里。把它做成可重写的方法,而不是写死一段路径拼接逻辑,就是让每个项目自己决定映射规则。

函数签名把参数写成 urdfLink: Group 而不是 URDFLink,因为 processLink 只把它当容器用,不关心具体是哪个子类。同理,4.7 里 <visual> 和 <collision> 共用的那个 processOriginAndGeometry,它的 parent 参数也是 Group —— 既能接 LinkVisual,也能接碰撞体的容器。

URDFLink 只是个 Group,没有额外能力:

1
2
3
export class URDFLink extends Group {
userData: { isURDFLink: true; [k: string]: any } = { isURDFLink: true }
}

整个加载器里最漂亮的一行是那个 querySelector。

树的根是「没人把它当孩子」的那个 link。与其建完整棵树再回溯找根,不如换个角度问:这个 link 有没有在某个 <joint><child link="..."> 里出现过? 一次 querySelector 全库扫描就能回答,不需要递归、不需要拓扑排序。

代价是复杂度 O(n²):每个 link 扫一遍全文档。但机器人撑死也就几十上百个 link,这个量级下完全无所谓。

值得一提的是,<joint><parent link="..."> 里也有 link 属性,但选择器写的是 child[link=...],只匹配标签名是 child 的元素,所以不会被父节点干扰。

顺序上也有讲究:这里只把根 link 挂到了 urdfRobot 上,其余 link 此刻都是「孤岛」,还没进树 —— 它们要等 4.6 把 joint 挂成树时才被认领。

4.5 解析 joint

<joint> 是 URDF 里信息量最大的标签。逐段看 processJoint。

第一步:判断它是不是从动关节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function processJoint(jointNode: Element) {
const jointName = jointNode.getAttribute('name') || ''
const jointType = jointNode.getAttribute('type')
const jointChildren = Array.from(jointNode.children)
let jointObj: URDFJoint | URDFMimicJoint
// mimic节点
const mimicTag = jointChildren.find(
(n) => n.nodeName.toLowerCase() === 'mimic',
)
if (mimicTag) {
// 若joint 中存在mimic节点,则此joint会模仿<mimic>中指定的joint
jointObj = new URDFMimicJoint()
// 被模仿关节的名称
const joint = mimicTag.getAttribute('joint') || ''
// 模仿系数
const multiplier = mimicTag.getAttribute('multiplier')
// 偏移量
const offset = mimicTag.getAttribute('offset')
Object.assign(jointObj.userData, {
joint,
multiplier: multiplier ? parseFloat(multiplier) : 1,
offset: offset ? parseFloat(offset) : 0,
})
} else {
// 正常关节
jointObj = new URDFJoint()
}
// ……
}

带 <mimic> 的创建 URDFMimicJoint,其余创建 URDFJoint。两个属性缺省时分别按 1 和 0 处理 —— 这是两个有意义的默认值:不写 multiplier 表示 1:1 跟随,不写 offset 表示零点对齐。它们描述的关系就是 从动角度 = 主动角度 × multiplier + offset,人手的三段手指、机械夹爪的两个爪片都是这个结构。

第二步:给关节挂上坐标系辅助对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 创建辅助对象-关节坐标系 */
const axesMat = new LineBasicMaterial({
vertexColors: true,
toneMapped: false,
depthTest: false,
depthWrite: false,
fog: false,
});
const jointAxisHelper = new JointAxesHelper(0.2, axesMat);
jointAxisHelper.visible = false;
Object.assign(jointAxisHelper.userData, {
isURDFHelper: true,
helperType: "jointAxisHelper",
});
jointObj.add(jointAxisHelper);
jointAxisMap.set(jointName, jointAxisHelper);

每个 joint 一个,用来把关节自身的坐标系画成三根彩色轴,长度 0.2,默认隐藏。

注意它的创建时机 —— 在遍历子标签之前。因为 jointObj.add(jointAxisHelper) 是把它挂成 joint 的子节点,后面无论 joint 被挂到哪个 link 下、怎么变换,这个辅助对象都自动跟随。这也是 4.6 要建立的树结构第一次发挥作用。

第三步:遍历子标签,分五路归档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const { userData } = jointObj
// 遍历关节子标签
jointChildren.forEach((jointChild: Element) => {
// 关节类型
const type = jointChild.nodeName.toLowerCase()
// 将不同类型的关节数据写入关节图形
switch (type) {
case 'origin':
// 关节位置
const xyz = processTuple(jointChild.getAttribute('xyz')) as NumberTuple3
jointObj.position.set(xyz[0], xyz[1], xyz[2])
userData.origPosition.set(xyz[0], xyz[1], xyz[2])
// 欧拉旋转
const rpy = processTuple(jointChild.getAttribute('rpy')) as NumberTuple3
applyEulerZYX(jointObj, rpy)
userData.origQuaternion.copy(jointObj.quaternion)
break
case 'parent':
// 一个子joint连接一个父link
const parent = linkMap.get(jointChild.getAttribute('link') || '')
parent?.add(jointObj)
break
case 'child':
// 一个父joint连接一个子link
const child = linkMap.get(jointChild.getAttribute('link') || '')
child && jointObj.add(child)
break
case 'axis':
// 关节变换轴
const axis = processTuple(jointChild.getAttribute('xyz')) as NumberTuple3
userData.axis.set(axis[0], axis[1], axis[2])
break
case 'limit':
// 旋转范围
const lower = jointChild.getAttribute('lower')
const upper = jointChild.getAttribute('upper')
const { limit } = userData
lower && (limit.lower = parseFloat(lower))
upper && (limit.upper = parseFloat(upper))
break
}
})
jointObj.name = jointName
userData.type = jointType as JointType

jointMap.set(jointName, jointObj)

五个分支各自归档了什么:

子标签 写到哪儿 用途
<origin> position / quaternion + userData.origPosition / origQuaternion 关节相对父杆的位姿,存两份
<parent> 树结构 见 4.6
<child> 树结构 见 4.6
<axis> userData.axis 关节的运动方向轴
<limit> userData.limit.lower / .upper 角度或位移范围

五个分支里,<origin> 和 <axis> 是重点 —— 它们是关节能「动」的全部依据,也是整个解析里和 Three.js 绑得最紧的两处。

一、<origin>:同一个位姿为什么要存两份。

1
2
3
4
5
6
jointObj.position.set(xyz[0], xyz[1], xyz[2])
userData.origPosition.set(xyz[0], xyz[1], xyz[2])
// 欧拉旋转
const rpy = processTuple(jointChild.getAttribute('rpy')) as NumberTuple3
applyEulerZYX(jointObj, rpy)
userData.origQuaternion.copy(jointObj.quaternion)

同一个位姿写了两遍:一遍写进 Three.js 真正拿去渲染的 position / quaternion,一遍存进 userData 的 origPosition / origQuaternion。

为什么要留底稿? 因为关节动起来之后,位姿会被反复改写。驱动关节时,每次都从初始姿态重算新姿态,而不是在上一帧的基础上继续叠加:

1
新姿态 = 初始姿态 ⊕ 新角度

没有 origPosition / origQuaternion 这两个锚点,关节就只能做增量更新 —— 那样既会累积浮点误差,也没法从任意角度跳回任意角度。后面的时间轴回放,靠的正是这一点。

还要注意这四行的顺序:先 position.set,再 applyEulerZYX,最后才 copy 出 origQuaternion。抄底稿必须在旋转算完之后 —— 抄早了,存下来的就是旋转前的单位四元数,底稿等于没存。

applyEulerZYX 里那个 'ZYX' 是本节最大的坑,4.7 单独讲透。

二、<axis>:存的是「关节坐标系里的方向」。

1
2
const axis = processTuple(jointChild.getAttribute('xyz')) as NumberTuple3
userData.axis.set(axis[0], axis[1], axis[2])

这里只是把三个数存进 userData.axis,没做任何坐标变换。这不是偷懒,而是对的 —— URDF 规定 <axis> 表达在 joint 自己的坐标系里,而 joint 的坐标系正是 position / quaternion 所定义的那个。Three.js 后续用到这个轴时,会自己把它带进当前坐标系:

  • 旋转关节:setFromAxisAngle(axis, n) 直接用。因为构造出来的四元数本来就作用在 joint 的局部坐标系上,不需要额外转换。
  • 滑动关节:_tempAxis.copy(axis).applyQuaternion(this.quaternion) —— 得先用当前姿态把轴转到父坐标系,才能拿去做 position 的平移。

如果把 <axis> 当成世界坐标或父 link 坐标来处理,方向就全错了。 这是 URDF 里仅次于 rpy 顺序的第二个方向陷阱 —— 而且比 rpy 更隐蔽,因为轴错了往往只表现为「关节在绕一个奇怪的方向转」,不容易一眼看出是解析阶段的问题。

还有一个细节:<axis> 在 URDF 里是可选的。省略时按 (1, 0, 0) 处理 —— 这个默认值写在 URDFJoint 的 userData 初值里(axis: new Vector3(1, 0, 0)),所以这个分支不执行时,axis 也依然有值。

三、<limit> 只取 lower 和 upper。 标签上还有 effort(力矩上限)和 velocity(速度上限),但那是给动力学和轨迹规划用的,渲染用不上,直接忽略。而且这里是「有才赋值」的写法 —— lower && (limit.lower = ...),属性缺失时保留 userData 里的默认值 { lower: 0, upper: 0 }。

四、userData.type 最后才写。 遍历子标签时用不到类型,而驱动关节时要用,所以在对象完全建好之后一次性写入。

4.6 joint 如何长成场景树

这是全篇最关键的一步。

URDF 给的是关系(谁是谁的父、谁是谁的子);Three.js 要的是变换树(谁挂在谁下面)。4.5 里那个 switch 的 parent 和 child 两个分支,和其他三个不太一样 —— 它们不写数据,而是改结构。转换就靠这两行:

1
2
3
4
5
6
7
8
9
10
case 'parent':
// 一个子joint连接一个父link
const parent = linkMap.get(jointChild.getAttribute('link') || '')
parent?.add(jointObj)
break
case 'child':
// 一个父joint连接一个子link
const child = linkMap.get(jointChild.getAttribute('link') || '')
child && jointObj.add(child)
break

翻译一下:

  • <parent link="pelvis"/> → pelvis.add(joint) —— joint 挂到父 link 下面
  • <child link="left_hip_yaw_link"/> → joint.add(childLink) —— 子 link 挂到 joint 下面

对照 3. 节画的那条链,完全一致:

1
2
3
4
5
URDF                              Three.js 场景图
link (pelvis) pelvis (URDFLink)
└─ joint (hip_yaw) → └─ left_hip_yaw_joint (URDFJoint)
└─ link (hip_yaw) → └─ left_hip_yaw_link (URDFLink)
└─ joint (…) → └─ …

为什么非要用这个结构? 因为 Three.js 的 matrixWorld 是沿父子链自动累积的。pelvis 一动,它下面所有子孙的世界矩阵全部跟着变;left_hip_yaw_joint 转 10 度,它下面整条腿跟着转。

换句话说,URDF 里「关节带动连杆」这个物理语义,被直接编码进了 Three.js 的树结构里。不需要任何额外代码去传播变换 —— 渲染管线自己就做了。

循环的写法上还有一点:这段 forEach 是在处理单个 joint 的子标签时用的 switch,也就是 <origin> / <parent> / <child> / <axis> / <limit> 各自走各自的分支,顺序无关。由于所有 link 都已在 4.4 里建好并写进了 linkMap,joint 之间谁先谁后也不影响结果。

4.7 <origin> 和 rpy:那个必须写 'ZYX' 的地方

关节的位姿从这里来:

1
2
3
4
5
6
7
8
9
10
case 'origin':
// 关节位置
const xyz = processTuple(jointChild.getAttribute('xyz')) as NumberTuple3
jointObj.position.set(xyz[0], xyz[1], xyz[2])
userData.origPosition.set(xyz[0], xyz[1], xyz[2])
// 欧拉旋转
const rpy = processTuple(jointChild.getAttribute('rpy')) as NumberTuple3
applyEulerZYX(jointObj, rpy)
userData.origQuaternion.copy(jointObj.quaternion)
break

xyz 很直白,三个 float 塞进 position。rpy 就藏了个坑,看 applyEulerZYX:

1
2
3
4
5
6
function applyEulerZYX(obj: Object3D, rpy: [number, number, number]) {
tempEuler.set(rpy[0], rpy[1], rpy[2], 'ZYX')
tempQuaternion.setFromEuler(tempEuler)
tempQuaternion.multiply(obj.quaternion)
obj.quaternion.copy(tempQuaternion)
}

四行,每一行都在和 Three.js 的数学类型打交道。 逐行拆开:

行 做什么 涉及的类型
tempEuler.set(r, p, y, 'ZYX') 用 URDF 的 rpy 填一个欧拉角,并指定旋转顺序 Euler
tempQuaternion.setFromEuler(tempEuler) 把欧拉角转成四元数 Euler → Quaternion
tempQuaternion.multiply(obj.quaternion) 叠加上 obj 原有的旋转 Quaternion × Quaternion
obj.quaternion.copy(tempQuaternion) 写回对象 Quaternion

为什么中间要绕一下四元数,不直接把 rpy 赋给 obj.rotation?

因为 obj.rotation 是个 Euler,它自己也有一个 order 属性,默认就是 'XYZ'。如果图省事写成:

1
obj.rotation.set(rpy[0], rpy[1], rpy[2])   // ❌ order 还是默认的 'XYZ'

顺序照样是错的 —— 坑并没有绕着走,只是换了个地方。

所以要么额外记得设 obj.rotation.order = 'ZYX',要么就像这里一样:用临时 Euler 指定顺序、转成四元数再写回。后者的好处是只写四元数、不碰 rotation 的 order。

这背后是 Three.js 里的一个设定:Object3D 的 rotation 和 quaternion 是同一个旋转的两个视图,内部靠回调互相同步 —— 写 quaternion 会自动按 rotation 当前的 order 重新解算出对应的欧拉角。所以无论走哪条路,最终存下来的旋转是同一个;差别只在于「用哪个 order 去解释那三个数」。

tempEuler 和 tempQuaternion 是模块级的临时对象。

1
2
const tempQuaternion = new Quaternion()
const tempEuler = new Euler()

定义在 utils.ts 顶部,而不是在函数里 new。这是 Three.js 代码里极常见的写法:几何变换在一帧里会被调用成百上千次,每次 new 两个对象就是上千次堆分配、上千次 GC 压力。复用两个模块级实例,代价是函数不可重入 —— 但在单线程的渲染循环里这不是问题。

关键在第四个参数 'ZYX'。

utils.ts 里紧挨着这段代码画了两张坐标系图:

1
2
3
4
5
6
7
8
THREE.js                 ROS URDF
Y Z
| | X
| | /
.-----X Y--.
/
Z
rpy=zyx rpy=xyz

URDF 的 rpy 是 roll、pitch、yaw,对应绕 X、Y、Z 三个固定轴依次旋转。按旋转矩阵写成:

1
R_urdf = Rz(yaw) · Ry(pitch) · Rx(roll)

注意左边的读法:矩阵连乘从右往左作用,所以 Rx(roll) 先作用,然后 Ry(pitch),最后 Rz(yaw) —— 这才是「依次绕 X、Y、Z 转」的意思。

而 Three.js 的 Euler 默认顺序是 'XYZ',对应:

1
R_three_default = Rx(x) · Ry(y) · Rz(z)

两者是反的。

Three.js 的 'ZYX' 顺序给出的才是 Rz · Ry · Rx,和 URDF 完全对上。所以这一句 'ZYX' 不是可选项 —— 不写就是用默认的 'XYZ',姿态直接算错。

那为什么很多模型看起来「没出问题」?因为只有一个轴有值的时候,两种顺序结果相同:

1
2
Rx(r) · Ry(0) · Rz(0) = Rx(r) · I · I = Rx(r)
Rz(0) · Ry(0) · Rx(r) = I · I · Rx(r) = Rx(r)

rpy="0 0 0" 或者 rpy="0 1.5707963 0" 这类单轴值,怎么排都对。只有 roll、pitch、yaw 里两个以上非零,问题才会暴露 —— 而且是那种「模型没报错、就是姿态歪了」的暴露。前面 h1_2.urdf 里的碰撞体 <origin xyz="0.02 0 0" rpy="0 1.5707963 0"/> 恰好只有 pitch,属于安全的那一类。

第三行用 multiply 而不是 copy,而且顺序不能反。

1
tempQuaternion.multiply(obj.quaternion)

四元数乘法不满足交换律。a.multiply(b) 的结果是 a · b,tempQuaternion 装的是 origin 的旋转,obj.quaternion 是对象身上已有的旋转,所以这一行算出来是:

1
q_结果 = q_origin · q_已有

新来的 origin 旋转在左边,意味着它作用在更外层的坐标系上 —— 先让对象带着自己原有的姿态,再整体套上 origin 的旋转。这正是 <origin> 的语义。

改写成 obj.quaternion.multiply(tempQuaternion)(顺序反了),旋转轴会跟着被转走;改成 copy 则直接把对象原本的姿态丢了。三种写法编译都不报错,只是结果不对 —— 这是四元数代码最难受的地方,出问题时只能靠摆一个已知姿态的模型去对。

<origin> 不只出现在 joint 上,<visual> 和 <collision> 里也有,走的是同一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function processOriginAndGeometry(node, material, parent) {
const visualChildren = Array.from(node.children)
visualChildren.forEach((childNode) => {
const type = childNode.nodeName.toLowerCase()
if (type === 'geometry') {
processGeometry(childNode, material, parent)
} else if (type === 'origin') {
const { xyz, rpy } = processOrigin(childNode)
parent.position.set(xyz[0], xyz[1], xyz[2])
applyEulerZYX(parent, rpy)
}
})
}

这里有个值得注意的写法:<origin> 的位姿设在了 parent 这个 Group 上,而不是设在那块 Mesh 上。拿 <visual> 举例,结构变成了:

1
2
3
urdfLink (URDFLink)
└─ linkVisual (LinkVisual / Group) ← position + quaternion 在这儿
└─ Mesh ← 几何体在局部原点,不动

变换放在容器上,几何体本身保持不动。 这样一块几何体可以被多处复用而不用改它自己的属性,也避免了「多块 mesh 共用一个 <origin>」时反复设置。

5. URDF坐标系转成threejs坐标系

URDF 用的是 ROS 的坐标系:z 轴朝上(x 向前、y 向左、z 向上)。Three.js 默认 y 轴朝上。两者差一次旋转,转换在场景层完成:

1
2
3
4
5
// 矫正z轴朝上的坐标系
tfGroup.rotation.x = -Math.PI / 2
tfGroup.add(robots)
tfGroup.add(paths)
scene.add(tfGroup)

把所有机器人和路径挂进一个 tfGroup,给 tfGroup 绕 x 轴转 -90°,再把 tfGroup 加进场景。整棵子树的局部 z 轴于是对齐世界的 y 轴。

URDFLoader 不做这件事 —— processUrdf 里没有任何一处改坐标轴,URDF 里 xyz 写多少就解析成多少。

参考

Three.js 如何解析 URDF 机器人模型

https://wywzixin.github.io/threejs/urdf-parsing/

作者

wangyawei

发布于

2026-09-18

更新于

2026-09-18

许可协议