HarmonyOS 6 ArkGraphics 3D精讲:坐标、向量与矩阵------初识3D数学的"空间建模"
图形图像和数学一直是密不可分的。工程化地理解数学知识,不是为了让你手撕公式,而是为了帮你更好地理解空间建模------等后面你要自己写 Shader 的时候,会发现今天的基础知识是非常重要的
3D 的世界和物理世界一样,有一套自己的运行规律。就像我们小时候就知道的:月亮绕着地球转,地球绕着太阳转。这个小小的天文常识,放在 3D 场景里,就是父子节点层层嵌套、坐标逐级传递的最直观体现。
之前我们已经让一个立方体在 HarmonyOS 里跑起来了。但只要你继续往下做,就会发现 3D 并不是"把模型加载出来"这么简单:物体为什么出现在这个位置?相机为什么能看到它?怎么实现地球带着月亮一起绕太阳转?
这些问题的答案,都藏在坐标、向量和矩阵里。
这一篇不打算写成数学课。公式能看懂最好,看不懂也没关系------我们只讲 ArkGraphics 3D 里代码实操 的部分:position、rotation、scale、父子节点、相机、光照方向,以及最终怎么屏幕渲染上。
配套代码里我设计了个可以边看边玩的案例:
太阳-地球-月亮公转自转模型,让你亲眼看看层级变换是怎么传递下去的
这一篇只解决一个问题:当我在 ArkGraphics 3D 里改一个节点的坐标、旋转、缩放时,画面为什么会跟着变?

一、为什么绕不开数学
你说得对,这段确实太"教程腔"了。改成下面这样,更像个老手在聊经验:
一、为什么绕不开数学
先别急着看代码,说个实在的。
我做数字孪生这几年,见过不少入坑 3D 的同学,上来就是一顿操作:glTF 加载出来了,相机能动了,立方体也转起来了。然后就觉得"行了,够用了,数学那套以后再说"。
直到有一天,产品提了个需求:让这块仪表盘跟着那个机械臂一起转。
然后就开始懵了。
明明设了坐标,为什么位置不对?明明加了旋转,为什么转的方向是反的?明明父子节点挂好了,为什么子节点到处乱飘?
这就是 3D 开发的"新手墙"------看起来能跑,但一碰就倒。
拿太阳系这个案例来说,如果你只是想让一个圆绕着另一个圆转,2D 动画确实能糊弄过去。但那不是 3D。真正的 3D 场景里,地球和月亮的位置是这样来的:
ts
this.solarRoot.children.append(this.earthOrbit);
this.earthOrbit.children.append(this.earth);
this.earthOrbit.children.append(this.moonOrbit);
this.moonOrbit.children.append(this.moon);
这几行代码背后,是这么一串关系:
text
地球的世界坐标 = 地球公转轨道的变换 × 地球自己的局部坐标
月亮的世界坐标 = 地球公转轨道的变换 × 月亮公转轨道的变换 × 月亮自己的局部坐标
看不懂这个,你会发现:
- 想让月球跟着地球转,结果它围着太阳转
- 想让相机盯着地球,结果视角不知道歪到哪去了
- 想做射线检测点选物体,结果点的位置和实际完全不搭
数学不是用来炫的,是用来定位问题的。
这一篇不教你手撸矩阵。ArkGraphics 3D 已经把矩阵计算封装好了。你只需要知道三件事:
- 写
node.position→ 动的是模型矩阵 - 写
camera.position和朝向 → 动的是视图矩阵 - 创建相机、调视野 → 动的是投影矩阵
记住这三条,剩下的代码怎么写,心里就有底了。
明白,去掉"偷懒2D"这种说法,改成纯粹的技术表述。下面是修改后的版本:
二、坐标系:一个点,四种身份

3D 场景里最容易晕的一件事是:同一个点,可以有四种不同的"身份"。
一个立方体的某个顶点,在建模软件里有它的坐标(局部坐标);放到场景里,它有了一个世界位置(世界坐标);相机拍它的时候,它相对于镜头有一个位置(相机坐标);最后显示在屏幕上,它变成了一个像素位置(屏幕坐标)。
它们之间的关系是一条链:
text
局部坐标 → 世界坐标 → 相机坐标 → 屏幕坐标
在 ArkGraphics 3D 里,这条链的每个环节都有对应的 API:
| 坐标系 | 什么意思 | ArkGraphics 3D 里怎么看 |
|---|---|---|
| 局部坐标 | 模型"自己家"的坐标 | glTF 里的顶点、Geometry 的顶点数据 |
| 世界坐标 | 在场景里的真实位置 | Node.position + 父节点的变换叠加 |
| 相机坐标 | 从相机镜头看过去的位置 | Camera 的位置和朝向决定了怎么转 |
| 屏幕坐标 | 最终显示在屏幕上的 2D 坐标 | Component3D 渲染结果、raycast 的输入 |
拿太阳系案例来具体感受一下:地球节点本身并没有每一帧去改它的世界坐标,它的局部位置是固定的:
ts
this.earth.position = { x: this.earthOrbitRadius, y: 0, z: 0 };
this.earthOrbit.children.append(this.earth);
地球之所以会"公转",是因为它的父节点 earthOrbit 在旋转:
ts
this.earthOrbit.rotation = this.makeYRotation(this.earthAngle);
这就是局部坐标和世界坐标的区别------地球在父节点 EarthOrbit 的局部坐标系里,一直老老实实待在 (earthOrbitRadius, 0, 0) 没动过。但因为父节点在转,它的世界坐标每时每刻都在变。
我们调用API时,都是使用的世界坐标系,只有在对模型的node拆解,打组。组内部的操作,往往都是局部坐标系。
再说一下右手坐标系 。ArkGraphics 3D 用的是右手坐标系:X 向右,Y 向上,Z 向屏幕外(相机看向 -Z 方向)。

第 1 篇我们把相机放在 z = 4:
ts
this.camera.position.z = 4;
因为相机看向 -Z 方向,所以相机在 Z 正半轴时,才能看到原点处的物体。
太阳系案例里,我们用 CalcUtils.lookAt 让相机从斜上方看向原点:
ts
CalcUtils.lookAt(
this.camera,
{ x: 0, y: 4.2, z: 7.8 }, // 相机放哪儿
{ x: 0, y: 0, z: 0 }, // 往哪儿看
{ x: 0, y: 1, z: 0 } // 哪边是"上"
);
这三个参数背后就是向量和矩阵在干活。
用 3D 节点来搭太阳系,本质上是把坐标系嵌套这件事摆在台面上。节点结构长这样:
text
SolarRoot
EarthOrbit ← 地球公转的"局部宇宙"
Earth ← 局部位置固定,但跟着父节点转
MoonOrbit ← 月球公转的"局部宇宙"
Moon ← 局部位置固定,但叠了两层变换
你看到的月亮世界位置,是 EarthOrbit 和 MoonOrbit 两个坐标系叠加之后的结果。如果你把这段结构跑通了,以后碰到任何父子嵌套的 3D 场景,心里都会有底。
三、向量:既是位置,也是方向,还能算夹角

向量可以粗暴地理解为"带方向的数字"。在 3D 里,一个 { x, y, z } 可以当位置用,也可以当方向用,还可以表示两个点之间的位移。
举个例子,地球的世界坐标大概是这样的:
ts
const earthWorld = { x: 1.94, y: 0, z: 1.94 };
从太阳(原点)指向地球的方向,就是两个位置相减:
ts
const lightDirection = {
x: earthWorld.x - 0,
y: earthWorld.y - 0,
z: earthWorld.z - 0
};
如果只关心方向、不关心距离,通常会做归一化(把长度变成 1):
ts
const length = Math.hypot(lightDirection.x, lightDirection.y, lightDirection.z);
const normalized = {
x: lightDirection.x / length,
y: lightDirection.y / length,
z: lightDirection.z / length
};
向量的几个基本操作,不用背公式,知道它们是干嘛的就行:
| 操作 | 能干啥 | 什么时候用 |
|---|---|---|
| 加法 | 位移叠加 | 物体从 A 走到 B |
| 减法 | 得到方向 | 从 A 指向 B |
| 点乘 | 判断夹角大小 | 光照强弱、视线和法线的夹角 |
| 叉乘 | 得到垂直方向 | 计算法线、相机的"右方向" |
这次太阳系调试光照的时候,向量就派上用场了。太阳在原点,地球在轨道上跑,光从太阳指向地球。一个球体只有朝向光源的那一面应该亮,背光面应该暗。这个关系用一句话就能说清楚:
text
亮度 ≈ max(0, dot(表面法线, 光线方向))
这就是点乘的威力。点乘越接近 1 → 表面正对光线 → 越亮;接近 0 → 光线擦着表面过 → 比较暗;小于 0 → 光线在背面 → 当前看到的这一面就是黑的。
所以你会发现,当相机正好对着地球的背光面时,地球看起来是黑的------这不是 Bug,是实时光照的真实表现。不过作为教学 demo,地球长期黑着不利于观察,所以案例里加了三个辅助手段:
- 「受光面」相机视角:专门把相机转到能看见被照亮的那一侧
- 「太阳光强」滑条:把光源效果放大,看得更清楚
- 黄色点阵光线:用一串小点标出从太阳到地球的方向
那条黄色点阵不是真光线,是教学辅助线。它的位置是太阳到地球之间的插值:
ts
const t = (i + 1) / (rayCount + 1);
this.lightRayDots[i].position = {
x: earthWorld.x * t,
y: 0,
z: earthWorld.z * t
};
本质就是向量插值:从原点沿着地球的方向走一段。它不影响渲染,只负责告诉你"光是从这边打过来的"。
四、矩阵:平移、旋转、缩放怎么"打包"在一起
如果说向量是"表达位置和方向"的语言,那矩阵就是"表达怎么变"的语言。平移、旋转、缩放,在 3D 图形里都可以用一个矩阵来表示。
ArkGraphics 3D 不需要你手写矩阵,但你写的这些属性,背后都会被转成矩阵:
ts
node.position = { x: 1, y: 0, z: 0 };
node.rotation = { x: 0, y: 0.7, z: 0, w: 0.7 };
node.scale = { x: 1, y: 1, z: 1 };
对应关系大致是这样的:
| 你写的代码 | 背后在做什么 |
|---|---|
position |
构造一个平移矩阵 |
rotation |
构造一个旋转矩阵(通过四元数转换) |
scale |
构造一个缩放矩阵 |
| 父子节点挂载 | 父矩阵 × 子矩阵 |
在之前的例子里,单个立方体的变换很适合用来理解 Model 矩阵:
ts
this.cube.position = {
x: this.translateX,
y: this.translateY,
z: 0
};
this.cube.scale = {
x: this.scaleValue,
y: this.scaleValue,
z: this.scaleValue
};
this.cube.rotation = {
x: 0,
y: Math.sin(halfRadian),
z: 0,
w: Math.cos(halfRadian)
};
这个 demo 还手动算了一个局部点 (0.5, 0.5, 0.5) 经过缩放、旋转、平移之后的世界位置:
ts
const scaledX = localX * this.scaleValue;
const scaledY = localY * this.scaleValue;
const scaledZ = localZ * this.scaleValue;
const rotatedX = scaledX * Math.cos(radian) + scaledZ * Math.sin(radian);
const rotatedZ = -scaledX * Math.sin(radian) + scaledZ * Math.cos(radian);
const worldX = rotatedX + this.translateX;
const worldY = scaledY + this.translateY;
const worldZ = rotatedZ;
这段代码其实就是在表达这个公式:
text
world = Translate × RotateY × Scale × local
顺序很重要。矩阵乘法不满足交换律------先平移再旋转,和先旋转再平移,结果是完全不一样的。地球绕太阳转,就是靠这个顺序实现的。
如果直接让地球自己旋转,那是自转:
ts
this.earth.rotation = this.makeYRotation(angle);
但如果让父节点 EarthOrbit 旋转,地球就会公转:
ts
this.earth.position = { x: this.earthOrbitRadius, y: 0, z: 0 };
this.earthOrbit.children.append(this.earth);
this.earthOrbit.rotation = this.makeYRotation(this.earthAngle);
地球的局部位置没变,但它所在的"局部坐标系"在转。世界坐标 = 父节点矩阵 × 地球局部矩阵。
月亮同理,只是多套了一层:
ts
this.moonOrbit.position = { x: this.earthOrbitRadius, y: 0, z: 0 };
this.earthOrbit.children.append(this.moonOrbit);
this.moon.position = { x: this.moonOrbitRadius, y: 0, z: 0 };
this.moonOrbit.children.append(this.moon);
月亮的最终世界位置不是简单的 moon.position,而是:
text
MoonWorld = EarthOrbit × MoonOrbit × MoonLocal
这就是矩阵最实在的价值。你不需要自己手撸 4x4 矩阵,但必须理解父子节点就是在做矩阵乘法。否则哪天模型不在你预期的位置,或者子节点跟着父节点乱跑,你根本不知道从哪查起。
五、MVP:从 3D 世界到屏幕,中间经历了什么

3D 图形里有一个核心公式,叫 MVP:
text
最终位置 = Projection × View × Model × 局部坐标
这三个矩阵各司其职:
| 矩阵 | 干什么的 | ArkGraphics 里对应什么 |
|---|---|---|
| Model | 局部坐标 → 世界坐标 | Node 的 position/rotation/scale |
| View | 世界坐标 → 相机坐标 | Camera 的位置和朝向 |
| Projection | 相机坐标 → 屏幕空间 | 相机的 FOV、near、far |
好消息是:ArkGraphics 3D 已经把 MVP 的大部分工作封装好了 。你创建 Scene、创建 Camera,然后交给 Component3D:
ts
this.sceneOpt = {
scene: this.scene,
modelType: ModelType.SURFACE
} as SceneOptions;
引擎会自动根据每个节点的变换、相机的视图矩阵和投影矩阵,把 3D 内容画到屏幕上。
但你还是得理解它,因为很多问题就出在 MVP 的某一环:
- 物体位置不对 ?先查 Model:
node.position、node.rotation、node.scale、node.parent - 物体存在但看不见 ?先查 View:
camera.position、lookAt有没有设对 - 物体被"切"掉一半?查 Projection:相机的 near/far 平面是不是太近了/太远了
太阳系案例里的几个视角按钮,本质上就是在改 View 矩阵:
ts
setCameraView(mode: string): void {
if (mode === 'top') {
CalcUtils.lookAt(this.camera, { x: 0, y: 8.2, z: 0.1 }, { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: -1 });
this.cameraMode = '俯视';
return;
}
if (mode === 'lit') {
CalcUtils.lookAt(this.camera, { x: 3.8, y: 2.2, z: 4.8 }, { x: 1.6, y: 0, z: -1.0 }, { x: 0, y: 1, z: 0 });
this.cameraMode = '受光面';
return;
}
CalcUtils.lookAt(this.camera, { x: 0, y: 4.2, z: 7.8 }, { x: 0, y: 0, z: 0 }, { x: 0, y: 1, z: 0 });
this.cameraMode = '斜视';
}
同一个太阳系,Model 没变,只是相机的 View 变了,画面就完全不同。俯视适合看轨道形状,斜视适合感受空间深度,受光面适合观察光照方向。这个按钮比干讲"视图矩阵"直观多了。
六、搭个实验台:太阳-地球-月亮

建议你跟着一起边看边玩。虽然样式有点简陋,但它非常适合用来理解坐标、矩阵和父子变换------因为局部坐标、世界坐标、父子嵌套、相机视角、光照方向,全都在一个画面里。
创建流程不复杂:
- 创建空场景
- 创建相机
- 创建太阳的光源
- 创建太阳、地球、月亮的球体
- 用父子节点把轨道关系搭起来
- 每帧让
EarthOrbit和MoonOrbit转一点 - UI 上实时显示坐标变化
初始化代码大致长这样:
ts
Scene.load()
.then(async (result: Scene) => {
this.scene = result;
this.scene.environment.backgroundType = EnvironmentBackgroundType.BACKGROUND_NONE;
let rf: SceneResourceFactory = this.scene.getResourceFactory();
this.camera = await rf.createCamera({ name: 'Article04Camera' });
this.camera.enabled = true;
this.camera.clearColor = { r: 0.02, g: 0.03, b: 0.06, a: 1 };
this.setCameraView('oblique');
await this.createSunPointLights(rf);
await this.createSolarSystem(rf);
this.applyLightingMode();
this.applyOrbitTransforms();
this.sceneOpt = { scene: this.scene, modelType: ModelType.SURFACE } as SceneOptions;
});
节点层级是核心:
ts
this.solarRoot = await rf.createNode({ name: 'SolarRoot' });
this.scene.root.children.append(this.solarRoot);
this.earthOrbit = await rf.createNode({ name: 'EarthOrbit' });
this.solarRoot.children.append(this.earthOrbit);
this.earth = await this.createSphereNode(rf, 'Earth', 0.30, 32, earthMaterial);
this.earth.position = { x: this.earthOrbitRadius, y: 0, z: 0 };
this.earthOrbit.children.append(this.earth);
this.moonOrbit = await rf.createNode({ name: 'MoonOrbit' });
this.moonOrbit.position = { x: this.earthOrbitRadius, y: 0, z: 0 };
this.earthOrbit.children.append(this.moonOrbit);
this.moon = await this.createSphereNode(rf, 'Moon', 0.13, 24, moonMaterial);
this.moon.position = { x: this.moonOrbitRadius, y: 0, z: 0 };
this.moonOrbit.children.append(this.moon);
每一帧只需要改两个父节点的旋转:
ts
this.earthOrbit.rotation = this.makeYRotation(this.earthAngle);
this.moonOrbit.rotation = this.makeYRotation(this.moonAngle);
这就是矩阵继承最直观的体现------地球和月亮都没有被直接设置世界坐标,它们的位置完全由父节点的旋转"带"着走。
为了让数值变化看得见,案例里还手动算了 Earth world、Moon local 和 Moon world:
ts
const earthWorld = this.rotateYPoint(this.earthOrbitRadius, 0, this.earthAngle);
const moonLocal = this.rotateYPoint(this.moonOrbitRadius, 0, this.moonAngle);
const moonWorld = this.rotateYPoint(
this.earthOrbitRadius + moonLocal.x,
moonLocal.z,
this.earthAngle
);
拖拽"地球公转角"和"月亮公转角"的时候,你可以亲眼看到:
- 地球世界坐标随
EarthOrbit旋转变化 - 月亮局部坐标只描述它绕地球的位置
- 月亮世界坐标同时受地球公转和月亮公转影响
三个最适合截图的状态:
| 状态 | 适合观察什么 |
|---|---|
| 俯视轨道 | XZ 平面的轨道形状、坐标数值变化 |
| 斜视 3D | 空间层级、Y 轴深度、真实 3D 感 |
| 受光面 | 光照方向、点乘和明暗关系 |
建议你先看俯视,把轨道和坐标的关系看明白;再看斜视,感受 3D 空间;最后看受光面,理解光照方向怎么影响明暗。
七、五个最容易踩的坑
坑一:把局部坐标当成世界坐标
earth.position = { x: 2.75, y: 0, z: 0 } ------ 这不代表地球的世界坐标永远是 (2.75, 0, 0),它只是说地球在父节点 EarthOrbit 里的局部位置是这个值。父节点一转,世界坐标就跟着变了。
坑二:搞乱父子节点的顺序
月亮不是直接挂在太阳下面,而是挂在 MoonOrbit 下面,MoonOrbit 又挂在 EarthOrbit 下面。这个结构决定了月亮会同时继承地球的公转和它自己绕地球的公转。挂错了层级,运动轨迹就全乱了。
坑三:以为相机只要挪位置就行
只改 camera.position 不处理朝向,画面很可能啥也看不见。CalcUtils.lookAt 的价值就在这里------它用 eye(相机在哪儿)、center(往哪儿看)、up(哪边是上)三个向量,帮你把相机姿态算好。
坑四:光照问题当成颜色问题
地球变黑了,不一定是材质坏了,很可能只是你刚好看到的是背光面。光照强弱和表面法线、光线方向的关系,背后就是点乘。调试光照的时候,可以先打开光线辅助线(案例里的黄色点阵),再切到"受光面"相机视角。
坑五:一上来就想手写矩阵
没必要。ArkGraphics 3D 已经通过 Node、Camera 和 Component3D 封装了大部分矩阵流程。你需要掌握的是每个属性影响哪一段:
text
Node.position/rotation/scale → Model
Camera position/lookAt → View
Camera projection → Projection
Component3D → 输出到屏幕
最后
这一篇"数学浓度"比较大。如果你想要我概况成几句话,那就是:
- 坐标系是分层的:局部、世界、相机、屏幕,不是一回事。
- 向量是方向的语言:位置相减得方向,点乘解释光照强弱。
- 矩阵是变换的语言:平移、旋转、缩放和父子继承,都是矩阵乘法。
- MVP 是渲染的流水线:Model 决定物体在哪儿,View 决定相机怎么看,Projection 决定怎么投到屏幕。
学习的过程不需要会推导所有公式,但希望你开始形成一种判断习惯:画面出问题时,先判断它是坐标问题、向量问题、矩阵问题,还是相机投影问题。这个判断建立起来之后,后面的旋转、四元数、场景图、相机交互、射线检测就不再是孤立的 API,而是一条完整的数学链路在不同环节的落地。
旋转为什么会有万向锁?为什么 ArkGraphics 3D 的节点旋转要用四元数?怎么让一个物体平滑地转向目标方向?这些都要建立在本篇的坐标和向量基础之上。我们后续见。

