ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HarmonyOS ArkUI实现多角度观察立方体:Canvas手势与三维投影实战

HarmonyOS ArkUI实现多角度观察立方体:Canvas手势与三维投影实战

做了大半年HarmonyOS应用开发,现在回看,真正让我把ArkUI用明白的,往往不是那些功能庞大的业务项目,反而是这种小而完整的工具型Demo。这次的“观察物体 - 多角度观察”就是其中之一。它的需求很朴素:屏幕中央摆一个立体图形,你能用手指拖着它转,随时从正面、侧面、上面各个角度观察它,不需要任何第三方库,纯HarmonyOS自带能力实现。做完之后我最大的感受是:一个小项目把Canvas绘制、手势处理、状态管理和一点3D数学全部串了起来,特别适合用来打通ArkTS开发的“任督二脉”。

如果你正在学HarmonyOS应用开发,或者想给学生做一个立体图形观察教具,又或者想给自己搞一个三维交互的小工具,这篇内容可以帮你一步步把它落到自己的工程里。为了让你能跟着复现,我会把坐标设计、旋转公式、投影换算、手势处理写得尽量细,再把我实测时踩过的坑单独拎出来讲。

1. “多角度观察”这个需求,本质上是什么

1.1 一个教学场景如何转换成交互需求

小学“观察物体”这一节里,学生要对着一个正方体或几个正方体的组合,分别说出从正面、右面、上面看到的平面图形。初中阶段升级为三视图,强调三个方向的投影关系。这个教学内容看似简单,但对空间想象力较弱的学习者来说,没有实物模型时很难理解。

所以一个合格的“多角度观察”Demo,至少要满足三个交互要求:

  1. 被观察的物体不能是静态图片,必须是可以在手里旋转的立体图形;
  2. 要有一键切换标准视角(正面、上面、右面)的能力,对应课堂上的三视图;
  3. 用户需要知道“我现在转到哪个方向了”,否则转着转着就迷失了方位。

这三个需求翻译成技术语言,分别是:三维坐标与投影的计算、一组预设旋转角度、以及一个清晰可读的方位反馈。这样一拆,项目边界就清楚了,后面动手时就不会东加一块西补一块。

1.2 技术落点:Canvas、PanGesture与状态刷新

既然只需要一个可旋转的线框立方体,完全不涉及光照、纹理、粒子这类高级渲染,我决定不引入任何3D引擎或模型文件,直接用ArkUI的Canvas组件把“观察者看到的东西”画出来。整个项目用到的主要技术就三块:

  • CanvasRenderingContext2D:负责把变换后的顶点连接成线,画出立方体;
  • PanGesture(滑动手势):负责把手指滑动方向换算成旋转角度;
  • @State状态 + 手动重绘:负责让每一帧的旋转结果立即显示在屏幕上。

这种“纯手工绘制”的方式还有一个额外好处:所有的数学变换都摊在明面上,读者能真真切切看到从三维坐标到屏幕像素的完整过程,教学效果反而比直接引入引擎黑盒更好。

2. 为什么不用3D引擎:轻量方案的技术选型思考

2.1 直接上模型和引擎,会带来什么问题

HarmonyOS生态里其实有现成的3D能力,比如用XComponent加载GLB模型,配合SceneLoader或底层图形接口,可以展示非常真实的带贴图、带灯光的3D物体。但把它用在“多角度观察”这个场景里,我反而觉得偏重了。加载真实模型需要资源文件、需要处理模型坐标轴对齐、需要处理相机和光照参数,任何一个环节都是新概念,很容易把项目拖向调参泥潭。

而“多角度观察”的核心价值在于旋转观察本身,不在于模型渲染得多么细腻。一个线框立方体已经足够表达“从不同方向看到不同形状”这个知识点。对教学型Demo来说,线框有一个杀手级优势:你能看到后面的棱,能确认物体的厚度,本质是帮助理解“立体”,而不是被表面贴图带偏。

2.2 手工投影的适用边界

手工投影这套方案,本质是对“物体绕观察者旋转”的简化模拟。它适合的边界也很清晰:

  • 需要表现的是简单几何体:立方体、长方体、球体线框、圆柱线框等;
  • 规模在几百个顶点以内,不需要顶点着色器和复杂光照;
  • 想让学习者看清每一行数学代码在干什么。

如果将来要做的是一整个房间的3D漫游,或者带骨骼动画的角色模型,那肯定要换引擎。但在这个Demo里,手写矩阵反而最可控,调试时还能把中间结果打印出来对照。

3. 立方体的数学地基:顶点、棱边与旋转矩阵

3.1 用8个顶点和12条棱定义立方体

一切3D画面的起点都是坐标。我们把立方体放在三维坐标系的原点,让它边长等于2(半边长1),这样数字比较干净,旋转后也不容易超出画布。八个顶点的定义见下表。

顶点xyz位置说明
0-1-1-1背面左下
11-1-1背面右下
211-1背面右上
3-11-1背面左上
4-1-11面前左下
51-11面前右下
6111面前右上
7-111面前左上

这里“背面”和“面前”是按观察者习惯约定:z正方向靠近观察者。实际上随着旋转,每个面都可能朝向观察者,但基础坐标这样定义最直观。

有了顶点,还要知道哪些点之间是棱。立方体有12条棱:前表面4条,后表面4条,前后连接4条。用顶点索引对表示:

private edges: number[][] = [ [0, 1], [1, 2], [2, 3], [3, 0], // 背面四边形 [4, 5], [5, 6], [6, 7], [7, 4], // 面前四边形 [0, 4], [1, 5], [2, 6], [3, 7] // 侧面连接棱 ];

3.2 绕X轴与绕Y轴的旋转矩阵

“多角度观察”的本质,是让观察者的视线方向相对物体改变。数学上等价于让物体绕自身的两个轴旋转:绕Y轴管左右水平转,绕X轴管上下俯仰转。

绕Y轴旋转角度Y的公式是:

x' = x·cosY + z·sinY
y' = y
z' = -x·sinY + z·cosY

绕X轴旋转角度X的公式是:

x' = x
y' = y·cosX - z·sinX
z' = y·sinX + z·cosX

在ArkTS里用数组实现一次旋转,把两种旋转都放在一个函数里。注意执行顺序是“先绕Y、再绕X”,这个顺序在后面的章节会专门讲,别搞反。

private rotate(x: number, y: number, z: number): number[] { const radY = this.angleY * Math.PI / 180; const radX = this.angleX * Math.PI / 180; // 先绕Y轴 const x1 = x * Math.cos(radY) + z * Math.sin(radY); const z1 = -x * Math.sin(radY) + z * Math.cos(radY); // 再绕X轴 const y2 = y * Math.cos(radX) - z1 * Math.sin(radX); const z2 = y * Math.sin(radX) + z1 * Math.cos(radX); return [x1, y2, z2]; }

3.3 透视投影:把三维坐标压到画布上

旋转后得到的是三维坐标,但Canvas只能画二维点。最简单的做法是正交投影:直接把x、y当屏幕坐标,z丢掉。但正交投影完全没有近大远小的效果,转起来显得很“纸片”。

为了让立方体更有立体感,我加了一个非常经典的透视除法:用z做缩放因子。设常量F为“观察距离”,屏幕坐标计算如下:

scale = F / (F + z')
sx = x'·scale
sy = y'·scale

当z'为负(靠近观察者)时,scale大于1,图形放大;z'为正(远离观察者)时,scale小于1,图形缩小。F取5左右最合适,太大透视感消失,太小边缘会拉伸变形。投影的同时,把三维的y轴方向和Canvas的y轴方向做一次翻转,否则画出来的立方体上下是反的:

private project(x: number, y: number, z: number): number[] { const f = 5; const scale = f / (f + z); return [ x * scale * this.unit + this.centerX, -y * scale * this.unit + this.centerY ]; }

其中this.unit是画布上的半边长(比如画布宽320时取120),centerX、centerY是画布中心坐标。

4. 把数学搬进ArkUI:Canvas绘制与手势交互

4.1 初始化画布与绘制函数

在HarmonyOS的ArkUI里,Canvas组件需要绑定一个CanvasRenderingContext2D对象。组件挂载完成后的onReady回调是第一次绘制的好时机;之后每次角度状态变化或手势回调,都需要手动调用绘制函数,让屏幕刷新。

private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(); build() { Column() { Canvas(this.context) .width(320) .height(320) .onReady(() => this.draw()) .gesture(this.panGesture()) .backgroundColor('#EEF1F5') } }

draw函数里第一步是清空画布。很多人第一次写会漏掉这一步,结果旋转之后屏幕上拖出长长的残影。

private draw() { this.context.clearRect(0, 0, 320, 320); // 依次完成:旋转顶点 -> 投影顶点 -> 画棱 }

4.2 12条棱,画的时候按什么顺序

绘制棱的顺序不是随意的。先画远处的、再画近处的,近处的会覆盖远处的线条,这样最接近人眼的遮挡直觉,术语叫“画家算法”。

具体做法是:在绘制每条棱之前,先求出两个端点旋转后z'的平均值,再将所有棱按“z'平均值从大到小”排序。z'越大表示离观察者越远,先画;z'越小越靠前,后画。

let drawEdges = [...this.edges]; drawEdges.sort((a, b) => { const za = (points[a[0]][2] + points[a[1]][2]) / 2; const zb = (points[b[0]][2] + points[b[1]][2]) / 2; return zb - za; // 远的先画 });

还需要在视觉上区分“近棱”和“远棱”。我的经验是:近处的棱用粗实线、深色,远处的棱用细线、浅色。这样即使有遮挡效果,用户依然能感知立方体的完整结构,这对空间观察类教学特别重要。

4.3 手势拖拽:PanGesture的增量计算

拖拽旋转的核心手势是PanGesture。这里有一个非常容易踩的坑:GestureEvent里给到的offsetX是“从手势开始到当前时刻的累计位移”,不是本次回调新增的位移。如果你直接把累计位移当作旋转增量用,手指停住后物体会自己猛转一段,而且每次拖动手感都不一样。

正确做法是记录上一次回调的offsetX和offsetY,每次算出差值再转成角度。

private lastOffsetX: number = 0; private lastOffsetY: number = 0; private handlePanUpdate(event: GestureEvent) { const dx = event.offsetX - this.lastOffsetX; const dy = event.offsetY - this.lastOffsetY; this.lastOffsetX = event.offsetX; this.lastOffsetY = event.offsetY; this.angleY += dx * 0.4; this.angleX -= dy * 0.4; this.draw(); }

灵敏度0.4是我试过比较舒服的值。太小要划很多次,太大会一晃就飞。角度的符号需要留意:手指向右,物体绕Y轴应该正向旋转,所以angleY加dx正数;手指向上,我们希望看到物体顶部更多,此时俯仰角应增大,在Canvas坐标系里dy向上为负,angleX减去这个负值正好是增加。

4.4 标准视角切换与自动旋转

“正面/右面/上面”这几个课堂视角,本质就是一组预设角度:

视角angleXangleY
正面00
右面090
上面900

点击按钮时,把对应角度赋给状态,再调用draw即可。如果你愿意,还可以用animateTo包裹赋值,让视角切换有一个过渡动画,观感会更好。我这里为了代码简单直接瞬时切换,做教学演示时反而是瞬间切换更容易对照。

自动旋转用setInterval每16ms让angleY加1度,相当于60fps的慢速旋转。记得在aboutToDisappear里清除定时器,否则页面退出了定时器还在跑,轻则资源泄漏,重则报错。

5. 实测阶段最容易踩的三个坑

5.1 坐标系原点:图形为什么只剩四分之一

我第一次把project函数写出来时,画布上只有一个四分之一的立方体。原因是Canvas的默认原点在左上角,x向右增大、y向下增大;而我的投影公式一开始直接用了“x、y坐标加中心点”的方式,结果图形从左上角往外扩展,只有x正、y正的部分可见,看起来就像一个被切掉四分之三的残缺方块。

解决的办法是统一用“画布中心为基准”的坐标系,project里显式加上centerX、centerY。同时别忘了y轴翻转:三维空间里y向上,Canvas里y向下,如果不翻转,立方体的顶面和底面会颠倒,拖动手感也完全反过来。

5.2 矩阵乘法顺序:先转X还是先转Y

绕X和绕Y的旋转矩阵相乘,顺序不同结果完全不同。我的方案是“先绕Y、再绕X”。

为什么呢?可以想象你举着一个盒子:先水平转身(绕Y),再抬头低头(绕X),这个顺序符合大多数人对“水平+俯仰”的直觉。如果你先抬头再转身,物体会沿着自身坐标乱翻,拖拽时会出现“滚球效应”,本来想左右转,结果物体在斜着打滚。如果发现旋转方向总是不对,不要同时改多处符号,先用angleY = 90跑一次,看看立方体是否露出右面,再单独调对应矩阵的符号。

5.3 PanGesture增量陷阱:为什么松手后还在转

这个问题在4.3里提过,但值得单独再说一遍。我的第一版代码直接把event.offsetX当作旋转增量:

this.angleY = event.offsetX * 0.4;

结果手指停住时,物体还在继续转,因为offsetX是整个手势的累计位移,不是本帧新增量。更隐蔽的是,如果你连续做多次拖拽,每次手势开始时offsetX都会从0重新累计,手感会忽快忽慢。只有记录lastOffset再做差值,才能保证每次拖拽的速度和位移量一致。

另外,拖拽结束时要记得把lastOffsetX和lastOffsetY清零,否则下一次手势的第一帧会计算出巨大的差值,物体就会跳一下。

6. 让它真正“能用”:教学反馈与可读性细节

6.1 当前角度提示

旋转了半天,用户最需要知道的是“我现在看到的是哪个方向”。我在画布下方加了一行实时文本,显示当前angleX和angleY的整数值,同时在角度靠近0、90、180、270时给出语义提示,比如“正在从右面观察”。

这个功能看似简单,但对教学场景非常重要。学生自由旋转后,如果能在提示下找到标准视角,空间想象的训练效率会高很多。实际实现只需要一个普通Text绑定状态,每次draw前更新文本状态即可。

6.2 棱线颜色的设计

为了让三个空间维度更清晰,我给不同朝向的棱用了不同颜色。注意这里“不同朝向”不是固定死的,而是用旋转后的z值动态决定:z'值较大的一侧(远处)用浅灰,z'值较小的一侧(近处)用深蓝加粗。实测下来,粗深色近棱和浅细远棱搭配,用户能迅速建立“哪根棱在前面”的空间判断。

这个细节让我想起一个很有意思的现象:如果所有棱都用同一粗细和颜色,旋转时立体感会大打折扣,特别是线框立方体,看起来会像一个平面上的几何谜题。

6.3 布局建议:画布周围留出说明区

这种教育型Demo最好不要一屏只放一个Canvas。我把页面分成了上下两区:上半部分是可拖拽画布,下半部分是视角按钮、自动旋转开关和当前角度的文字说明。这样用户既能自由探索,又随时可以回到标准视角校准认知,整个交互闭环是完整的。

如果空间允许,还可以加一个小圆盘指示器,把当前的方位角画成一个指针,指针指向0°表示正面,90°表示右面,180°表示背面。这个指示器用Canvas画并不复杂,但对空间方位感弱的学习者来说,比一串数字直观得多。

7. 从线框立方体到更多可能性

如果还想继续进阶,这个项目有两条很自然的扩展路线。

一是把物体从单一的线框立方体换成多个立方体的组合。小学数学里常见“两个小正方体拼在一起”,或者“品字形三立方体”,做法是把每个子立方体的顶点做一次数组平移,再对12条棱逐个体绘制。这样就能生成不同造型的观察对象,适应不同年级的教学内容。

二是增加三视图联动。在画布旁边同步显示“正面投影、上面投影、右面投影”三张2D图。这三张图的技术实现其实极其简单:分别把angleX和angleY固定为(0,0)、(90,0)、(0,90),复用已有的project函数,再在三个小画布里画出投影结果。这个功能一旦加上,Demo就和课堂上的三视图教学完美对应上了。

如果你想把真实感拉满,还可以用XComponent和GLB模型替代手绘线框。但那时候面临的问题就不再是“怎么画出立方体”,而是“怎么管理模型资源、相机参数和光照”这类新领域。先把线框版做透,再决定要不要往前走,是我比较推荐的学习节奏。

我自己做完这个Demo之后,最大的后遗症是:现在看任何立体图形,都会下意识在脑海里拆分它的顶点和棱。这种“空间拆解”的思维方式,恰恰是做三维交互最值钱的收获。如果你也在学HarmonyOS应用开发,建议别只盯着组件文档,找一个小而完整的场景,亲手把它做出来。就像这个“观察物体”,做完它,你对Canvas、手势和状态管理的理解,都会上一个台阶。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进