HarmonyOS 6 ArkGraphics 3D精讲:坐标、向量与矩阵——初识3D数学的“空间建模”

HarmonyOS 6 ArkGraphics 3D精讲:坐标、向量与矩阵------初识3D数学的"空间建模"

图形图像和数学一直是密不可分的。工程化地理解数学知识,不是为了让你手撕公式,而是为了帮你更好地理解空间建模------等后面你要自己写 Shader 的时候,会发现今天的基础知识是非常重要的

3D 的世界和物理世界一样,有一套自己的运行规律。就像我们小时候就知道的:月亮绕着地球转,地球绕着太阳转。这个小小的天文常识,放在 3D 场景里,就是父子节点层层嵌套、坐标逐级传递的最直观体现。

之前我们已经让一个立方体在 HarmonyOS 里跑起来了。但只要你继续往下做,就会发现 3D 并不是"把模型加载出来"这么简单:物体为什么出现在这个位置?相机为什么能看到它?怎么实现地球带着月亮一起绕太阳转?

这些问题的答案,都藏在坐标、向量和矩阵里。

这一篇不打算写成数学课。公式能看懂最好,看不懂也没关系------我们只讲 ArkGraphics 3D 里代码实操 的部分:positionrotationscale、父子节点、相机、光照方向,以及最终怎么屏幕渲染上。

配套代码里我设计了个可以边看边玩的案例:

太阳-地球-月亮公转自转模型,让你亲眼看看层级变换是怎么传递下去的

这一篇只解决一个问题:当我在 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          ← 局部位置固定,但叠了两层变换

你看到的月亮世界位置,是 EarthOrbitMoonOrbit 两个坐标系叠加之后的结果。如果你把这段结构跑通了,以后碰到任何父子嵌套的 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,地球长期黑着不利于观察,所以案例里加了三个辅助手段:

  1. 「受光面」相机视角:专门把相机转到能看见被照亮的那一侧
  2. 「太阳光强」滑条:把光源效果放大,看得更清楚
  3. 黄色点阵光线:用一串小点标出从太阳到地球的方向

那条黄色点阵不是真光线,是教学辅助线。它的位置是太阳到地球之间的插值:

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.positionnode.rotationnode.scalenode.parent
  • 物体存在但看不见 ?先查 View:camera.positionlookAt 有没有设对
  • 物体被"切"掉一半?查 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 变了,画面就完全不同。俯视适合看轨道形状,斜视适合感受空间深度,受光面适合观察光照方向。这个按钮比干讲"视图矩阵"直观多了。


六、搭个实验台:太阳-地球-月亮

建议你跟着一起边看边玩。虽然样式有点简陋,但它非常适合用来理解坐标、矩阵和父子变换------因为局部坐标、世界坐标、父子嵌套、相机视角、光照方向,全都在一个画面里。

创建流程不复杂:

  1. 创建空场景
  2. 创建相机
  3. 创建太阳的光源
  4. 创建太阳、地球、月亮的球体
  5. 用父子节点把轨道关系搭起来
  6. 每帧让 EarthOrbitMoonOrbit 转一点
  7. 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 worldMoon localMoon 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 已经通过 NodeCameraComponent3D 封装了大部分矩阵流程。你需要掌握的是每个属性影响哪一段

text 复制代码
Node.position/rotation/scale → Model
Camera position/lookAt       → View
Camera projection            → Projection
Component3D                  → 输出到屏幕

最后

这一篇"数学浓度"比较大。如果你想要我概况成几句话,那就是:

  1. 坐标系是分层的:局部、世界、相机、屏幕,不是一回事。
  2. 向量是方向的语言:位置相减得方向,点乘解释光照强弱。
  3. 矩阵是变换的语言:平移、旋转、缩放和父子继承,都是矩阵乘法。
  4. MVP 是渲染的流水线:Model 决定物体在哪儿,View 决定相机怎么看,Projection 决定怎么投到屏幕。

学习的过程不需要会推导所有公式,但希望你开始形成一种判断习惯:画面出问题时,先判断它是坐标问题、向量问题、矩阵问题,还是相机投影问题。这个判断建立起来之后,后面的旋转、四元数、场景图、相机交互、射线检测就不再是孤立的 API,而是一条完整的数学链路在不同环节的落地。

旋转为什么会有万向锁?为什么 ArkGraphics 3D 的节点旋转要用四元数?怎么让一个物体平滑地转向目标方向?这些都要建立在本篇的坐标和向量基础之上。我们后续见。

相关推荐
meilindehuzi_a5 小时前
Vibe Coding 实战:我用一条 Prompt 指挥 AI “盲盒式”生成 3D 积木物理世界
3d·prompt
小飞侠是个胖子5 小时前
在 WebGL 中构建高性能 3D 沉浸式系统的三套高阶方案
前端·3d
不知名的老吴7 小时前
CAXA 3D实体设计保姆级下载和安装教程(图文详解)
3d
DisonTangor1 天前
【SIGGRAPH 2026】Pixal3D: 基于图像的像素对齐三维生成
人工智能·3d·开源·aigc
CG_MAGIC1 天前
主流 3D 软件文件互通互导教程
3d·材质·效果图·建模教程·渲云渲染
大江东去浪淘尽千古风流人物1 天前
【Flow4DGS-SLAM】动态环境3DGS-SLAM:光流引导自运动分解与混合4D Gaussian深度解析(CVPR 2026)
3d·slam·vio·光流·动态场景
oo哦哦1 天前
深度解析:星链引擎全域智能营销矩阵系统的技术架构与实践
大数据·矩阵·架构
2601_957787581 天前
多平台矩阵账号防关联技术深度解析:2026年IP隔离与设备指纹的攻防战
网络·tcp/ip·矩阵
BY组态21 天前
数字孪生Web3D效果定制呈现|虚实联动,解锁数字化新范式
3d·信息可视化