桌子设置到了 (1, 0, 0),为什么显示器跟着跑到了别的位置?

前言
上一篇把 SpaceRoom 的基础场景搭起来了:有地面、有立方体、有相机、有灯光。但只要开始往房间里放家具,第一个问题马上就来了。
我在 SpaceRoom 里加了一张桌子,位置设到 (1, 0, 0),看起来没问题。然后往桌子上放显示器,显示器的位置我也设到了 (1, 1, 0)------逻辑很简单:桌子高 1 米,显示器放在桌子中间。
结果跑起来一看,显示器不在桌子上,而是跑到了房间角落。
我当时第一反应是坐标写错了。检查了一遍,没错啊,x=1, y=1, z=0。然后把显示器单独拿出来,位置又对了。问题出在:我把桌子和显示器做成了父子节点关系,桌子是父节点,显示器是子节点。
这就是 3D 开发里最容易踩的坑:世界坐标和局部坐标不是一回事。你给子节点设置的 Position,是相对于父节点的局部坐标,不是相对于整个房间的世界坐标。
这一篇专门解决这个问题:为什么所有家具都直接挂到 Scene 根节点会乱,为什么要建立父子节点结构,以及 Position、Rotation、Scale 在父子节点之间到底怎么叠加。
一、先列清楚:坐标体系里到底有几种坐标
先把概念拆开,不然后面越讲越乱:
| 坐标类型 | 参考原点 | 什么时候用 |
|---|---|---|
| 世界坐标 | Scene 根节点的原点 | 描述一个物体在整个房间里的绝对位置 |
| 局部坐标 | 父节点的原点 | 描述一个物体相对于父节点的位置 |
| 模型坐标 | 模型自己的原点 | 描述模型内部顶点的位置 |
举个例子:桌子放在 (1, 0, 0),显示器放在桌子上。如果显示器是桌子的子节点,那显示器的局部坐标是 (0, 1, 0)------相对于桌子原点,往上 1 米。它的世界坐标才是 (1, 1, 0)。
这就是为什么上一篇我把显示器位置设成 (1, 1, 0) 却跑错了:我以为是世界坐标,实际上它是相对于桌子的局部坐标,所以变成了 (1+1, 0+1, 0+0) = (2, 1, 0)。
这个错误特别常见,尤其是刚开始做 3D 的时候。大家习惯了 ArkUI 里所有组件都直接挂在根节点下面,位置是相对于屏幕的,到了 3D 里一下子不适应了。
二、为什么不能把所有家具都直接挂到根节点
有人会说:那我不用父子节点不就行了?所有家具都直接挂到 Scene 根节点下面,每个都用世界坐标,不就没这个问题了?
短期看确实能跑,但家具一多就会出问题。
比如我要把整个桌子(包括显示器、键盘、台灯)移动到房间另一边。如果它们都是根节点下面的独立节点,我需要手动移动桌子、显示器、键盘、台灯四个节点,一个都不能漏。
更麻烦的是旋转。我想把桌子转个方向,让它对着窗户。如果桌子和显示器是分开的,我转了桌子,显示器还留在原来的方向,看起来就像显示器悬浮在空中对着原来的方向。
这就是为什么要建立父子节点结构:父子节点的 Transform 会自动叠加。父节点移动,子节点跟着移动;父节点旋转,子节点跟着绕着父节点旋转;父节点缩放,子节点跟着缩放。
先看代码怎么建立这个结构:
typescript
import { scene } from '@kit.ArkGraphics3DKit';
import { SceneManager } from './SceneManager';
export class TransformUtils {
/**
* 设置节点位置(局部坐标)
*/
static setPosition(node: scene.Node, x: number, y: number, z: number): void {
node.position = { x, y, z };
}
/**
* 设置节点旋转(欧拉角度,单位度)
*/
static setRotation(node: scene.Node, x: number, y: number, z: number): void {
node.rotation = { x, y, z };
}
/**
* 设置节点缩放
*/
static setScale(node: scene.Node, x: number, y: number, z: number): void {
node.scale = { x, y, z };
}
/**
* 建立父子节点关系
*/
static attachChild(parent: scene.Node, child: scene.Node): void {
parent.addChild(child);
}
}
然后用这个工具类建立桌子和桌面设备的结构:
typescript
// 1. 创建桌子节点
const deskNode = scene.createNode('Desk');
TransformUtils.setPosition(deskNode, 1, 0, 0); // 桌子在世界坐标 (1, 0, 0)
// 2. 创建显示器节点
const monitorNode = scene.createNode('Monitor');
// 显示器是桌子的子节点,位置是相对于桌子的局部坐标
TransformUtils.setPosition(monitorNode, 0, 1, 0); // 桌子高1米,显示器在桌子中间
// 3. 把显示器挂到桌子下面
TransformUtils.attachChild(deskNode, monitorNode);
// 4. 桌子挂到根节点下面
scene.getRoot().addChild(deskNode);
现在显示器的世界坐标是多少?桌子在 (1, 0, 0),显示器相对于桌子在 (0, 1, 0),所以显示器的世界坐标是 (1+0, 0+1, 0+0) = (1, 1, 0)。对了。

三、旋转和缩放:为什么父节点变了子节点也会变
位置好理解,旋转就容易绕晕了。
假设我要把桌子转 90 度,让它对着窗户。我只需要设置桌子节点的旋转:
typescript
// 桌子绕Y轴转90度
TransformUtils.setRotation(deskNode, 0, 90, 0);
这时候显示器会怎么样?它会跟着桌子一起转,绕着桌子的原点转 90 度。这正是我想要的------桌子转了,上面的东西当然要跟着转。
如果显示器是独立节点,我还需要手动给显示器也设一个 90 度旋转,少设一个就穿帮了。
缩放也是一样。有时候我需要把整个桌子缩小一点,比如做一个小尺寸的书桌。我只需要设置桌子的缩放:
typescript
// 整个桌子缩小到0.8倍
TransformUtils.setScale(deskNode, 0.8, 0.8, 0.8);
显示器和键盘都会跟着缩小。这里有个容易踩的坑:子节点的缩放是相对于父节点的缩放再叠加的。如果父节点缩到 0.8,子节点自己再缩到 0.5,那子节点实际的缩放就是 0.8 × 0.5 = 0.4。
这个叠加关系用表格列出来更清楚:
| 操作 | 只改父节点 | 只改子节点 | 父子都改 |
|---|---|---|---|
| 移动 | 子节点跟着移动 | 子节点相对于父节点移动 | 位置叠加 |
| 旋转 | 子节点跟着绕父节点旋转 | 子节点自己旋转 | 旋转叠加 |
| 缩放 | 子节点跟着缩放 | 子节点自己缩放 | 缩放相乘 |
四、模型原点为什么总是不对
还有一个很常见的问题:我把模型位置设到 (0, 0, 0),结果模型悬浮在半空中,或者模型一半埋在地下。
这不是位置设错了,是模型自己的原点不对。
比如一个椅子模型,建模的时候原点可能在椅子的几何中心,也可能在椅子的底部,甚至可能在椅子靠背的顶端。导入到 ArkGraphics 3D 以后,你设置 position = (0, 0, 0),实际上是把模型的原点放到了 (0, 0, 0)。
如果模型的原点在椅子几何中心,那椅子就会有一半埋在地下。如果原点在椅子顶端,椅子就会悬浮在半空。
这个问题怎么解决?两种办法:
第一种是建模的时候把原点放在正确的位置,比如椅子底部中心。但很多时候我们用的是现成模型,改不了。
第二种是在代码里给模型加一个偏移。比如椅子模型的原点在几何中心,高度是 1 米,那我就把椅子位置往上移 0.5 米:
typescript
// 椅子模型原点在几何中心,需要往上偏移半个高度
TransformUtils.setPosition(chairNode, 0, 0.5, 0);
这个问题在做家具布局的时候特别明显。我第一次放沙发的时候,沙发整个埋到地板下面去了,折腾了半天才发现是模型原点的问题。
五、多层嵌套以后怎么排查位置错误
家具多了以后,节点结构可能是这样的:
Scene根节点
└── DeskGroup(桌子组)
├── Desk(桌子)
├── Monitor(显示器)
│ └── Stand(显示器支架)
├── Keyboard(键盘)
└── Lamp(台灯)
三层嵌套以后,子节点的世界坐标就是好几层 Transform 叠加出来的。这时候位置不对怎么排查?
一个实用的办法是临时把所有父节点的位置和旋转都打出来,一层一层算:
typescript
/**
* 递归打印节点的世界坐标
*/
static printWorldPosition(node: scene.Node, depth: number = 0): void {
const indent = ' '.repeat(depth);
console.info(`${indent}Node: ${node.name}, pos: ${JSON.stringify(node.position)}`);
const children = node.getChildren();
children.forEach(child => {
TransformUtils.printWorldPosition(child, depth + 1);
});
}
// 调用:从根节点开始打印
TransformUtils.printWorldPosition(scene.getRoot());
打印出来以后,从根节点开始一层一层加:根节点在 (0,0,0),桌子组在 (1,0,0),显示器相对于桌子组在 (0,1,0),那显示器世界坐标就是 (1,1,0)。
这样一层一层算,总能找到是哪一层的位置设错了。


六、几个最容易踩的坐标坑
把这一篇遇到的坑总结一下:
| 坑 | 现象 | 解决办法 |
|---|---|---|
| 把局部坐标当世界坐标 | 子节点位置比预期偏了父节点的偏移量 | 记住:子节点的 position 是相对于父节点的 |
| 所有家具都挂根节点 | 移动或旋转一个家具,其他相关家具不动 | 建立父子节点结构,相关家具放在一个组里 |
| 模型原点不对 | 模型悬浮或埋在地下 | 给位置加偏移,或者改模型原点 |
| 多层嵌套后位置算不清 | 家具位置不对,不知道哪里错了 | 递归打印每层节点的位置,一层一层算 |
| 父节点缩放导致子节点变形 | 子节点跟着缩得太小或变形 | 注意缩放是叠加的,必要时子节点反向缩放 |
这些问题都不是 API 用错了,是 3D 空间里坐标体系的思维还没转过来。ArkUI 里所有组件都是二维平面上的,位置是相对于父容器的,逻辑相对简单。到了三维空间,有旋转、有缩放、有多层嵌套,坐标关系一下子就复杂了。
总结
第二篇的核心就一句话:节点结构本身就是空间管理的一部分。
- 世界坐标是相对于 Scene 根节点的,局部坐标是相对于父节点的
- 父子节点的 Position、Rotation、Scale 会自动叠加
- 把相关的家具放在一个组里,移动和旋转的时候才会一起动
- 模型原点不对是常见问题,需要手动偏移
- 多层嵌套后位置错了,就递归打印每层节点的位置,一层一层排查
SpaceRoom 现在有了桌子、显示器、键盘、台灯,节点结构也理清楚了。下一篇开始加入真正的 glTF 模型,解决模型加载多了以后越来越卡的问题:Resource 和 Instance 到底有什么区别,为什么同一把椅子放三次,不应该加载三次资源。