news 2026/9/16 2:39:39

HarmonyOS用Canvas实现立体几何展开与折叠动画教学

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS用Canvas实现立体几何展开与折叠动画教学

做 HarmonyOS 应用做到第 248 个案例,我逐渐摸到一条规律:真正能让用户记住的,不是多华丽的动效,而是把抽象概念变成“看得见、摸得着”的东西。这次想实现的“立体几何展开与折叠演示”,起因是一位朋友在教初中数学,讲正方体展开图时,学生怎么都理解不了“一个立体盒子怎么摊平成六个面”。当时我就在想,能不能直接做一个可以在鸿蒙设备上交互演示的应用:给一个立体几何体,用户用手指滑动进度条,就能看到几何体从三维状态慢慢展开成一个平面图形,或者反过来从平面图形折回立体。这个项目的核心定位很明确——它是一个面向教学场景的可视化工具,用 ArkTS 和 ArkUI 的 Canvas 能力,把几何体的展开、折叠过程实时渲染出来。

这个项目适合两类人参考:一类是想在 HarmonyOS 上做自定义图形渲染、动画系统的开发者,可以从中看到纯 Canvas 实现 3D 投影和旋转矩阵的完整套路;另一类是对立体几何感兴趣、想做教育类原型的同学,可以照着我这套数据模型,直接搭出自己的演示应用。我不会去接 3D 引擎,也不用 OpenGL,只靠 ArkUI 的 2D Canvas 加一点数学变换,就能把立方体、四棱锥、三棱柱这类常见几何体的展开过程做出来。后面我会把每一步的算法、代码结构和踩过的坑都讲清楚,保证你能照着做出一个能跑、能动、能拖进度条的版本。

1. 项目定位:它到底解决什么问题,以及为什么这么设计

1.1 空间想象力的学习痛点

立体几何展开与折叠,是中小学数学里的经典难点。学生对着教材上的平面插图,要凭空在脑子里把一个正方体折起来,或者把立体图形“剪开”想象成平面图形,这对空间想象能力的要求其实很高。成年人看可能会觉得简单,但对刚接触立体几何的学生来说,很多人在这一步就卡住了。

所以我做这个应用的第一个目标,就是让“展开-折叠”这个过程可见、可逆、可控制。用户不再需要在脑子里补全动画,而是可以直接拖动画面的进度条,随时停在半折叠状态观察结构。这种“中间态”是一张静态图给不了的。当时我调研了一圈,市面上的几何软件虽然多,但要么太重,要么强依赖网络,很少有能在鸿蒙设备上做一个轻量、自由控制展开过程的原生产品。于是这个项目就有了明确的切入点。

1.2 为什么不用 3D 引擎,而是选择 Canvas 自绘

立项时的第一个技术抉择,就是渲染方案。HarmonyOS 上做 3D 效果,可以使用图形引擎和原生 3D 节点,但我最后选择了最“笨”的办法:用 ArkUI 的 Canvas 组件,自己实现投影、旋转、多边形绘制。

原因有三点。第一,这个项目的几何体面数极少,立方体 6 个面、四棱锥 5 个面、三棱柱 5 个面,完全不需要 GPU 级别的 3D 引擎,用软件渲染足够流畅。第二,Canvas 自绘意味着我对每一帧的画面有绝对控制权,展开的中间状态可以精确地用手里的 progress 值算出来,调试起来非常直观。第三,这套方案的工程依赖最少,只要有一个 Canvas 组件和几个数学函数,就能把核心逻辑讲清楚。

1.3 功能范围与交互设定

我最终把功能收敛成三个几何体:立方体、四棱锥、正三棱柱。每个几何体都支持从“展开平面”到“立体状态”的连续动画,中间通过一个进度条控制;页面底部还有播放/暂停按钮,可以自动播放整个展开或折叠过程。

配色上,每个面用不同颜色区分,这样学生在看展开图时,很容易追踪某一个颜色从平面移动到立体上的位置。为了让画面不呆板,我还加了基础透视投影,让不同角度的观察有一点近大远小的立体感。手势方面,这一版先不做自由旋转视角,只做进度控制,避免把演示工具做得过于复杂。

2. 几何数据与数学基础:把三维世界装进代码

2.1 用顶点和面描述几何体

在切入正式代码前,要先建立一套数据模型。任何三维几何体,在计算机里最通用的表示方式是“顶点 + 三角形面”,也就是把每个面拆成若干三角形。立方体的每个面是正方形,所以每个面由两个三角形组成;四棱锥的侧面本身就是三角形,底座正方形再拆成两个三角形。

我的数据结构是这样的:

export interface Vec3 { x: number; y: number; z: number; } export interface Face { vertices: Vec3[]; // 这个面的顶点,按逆时针顺序 color: string; // 面的颜色,便于视觉区分 } export interface GeometryModel { faces: Face[]; }

这里有个细节值得注意:每个面保存的是自己独立的顶点数组,而不是像工业级网格那样用共享顶点索引。这么做看起来有些浪费内存,但对这个项目来说有巨大优势——当我要把两个面沿公共边“掰开”或“合上”时,只要修改其中一个面的顶点坐标,另一个面完全不受影响,操作起来非常自由,不用担心破坏共享拓扑。这种“面子模型”的代价是多存了一些重复坐标,但换来了算法的大幅简化,在我看来非常划算。

2.2 绕任意轴旋转的 Rodrigues 公式

展开和折叠的本质,是让面绕着某一条棱边旋转。要解决这个问题,第一步是掌握三维空间里“绕任意轴旋转一个点”的数学工具,也就是 Rodgers 的 Rodrigues 旋转公式。

简单说,给定一个点 P,一个经过原点的单位轴向量 K,旋转角度 θ,旋转后的点 P′ 可以用下面的公式计算:

P′ = P·cosθ + (K × P)·sinθ + K·(K·P)·(1 - cosθ)

这个公式的直观理解是:把一个三维旋转拆成三个部分,分别对应点在轴方向上的分量、垂直于轴的分量绕轴转动的效果。用代码实现起来并不长,我在项目里写成了这样:

export function rotateAroundAxis(p: Vec3, axis: Vec3, angle: number): Vec3 { const cos = Math.cos(angle); const sin = Math.sin(angle); const k = normalize(axis); const dot = p.x * k.x + p.y * k.y + p.z * k.z; const cross: Vec3 = { x: k.y * p.z - k.z * p.y, y: k.z * p.x - k.x * p.z, z: k.x * p.y - k.y * p.x }; return { x: p.x * cos + cross.x * sin + k.x * dot * (1 - cos), y: p.y * cos + cross.y * sin + k.y * dot * (1 - cos), z: p.z * cos + cross.z * sin + k.z * dot * (1 - cos) }; }

如果你之前没接触过三维数学,这个地方可能最容易懵。不要紧,你只需要把它当成一个封好的黑盒:传入一个点、一条轴、一个角度,返回一个旋转后的点。后续所有面的旋转都建立在这个函数上。

2.3 围绕一条棱边旋转

有了“绕轴旋转”的基础函数,接下来就是“绕边旋转”。一条棱边由两个端点 A 和 B 定义,旋转轴就是这条边所在的方向。

要让一个顶点 P 绕棱边 AB 旋转 θ 角,分三步走:先把坐标原点平移到 A 点,得到相对向量 PA;然后调用rotateAroundAxis,把 PA 绕 AB 方向旋转 θ;最后把结果再加回 A 点。完整代码如下:

export function rotateAroundEdge(p: Vec3, a: Vec3, b: Vec3, angle: number): Vec3 { const ab = normalize({ x: b.x - a.x, y: b.y - a.y, z: b.z - a.z }); const pa = { x: p.x - a.x, y: p.y - a.y, z: p.z - a.z }; const rotated = rotateAroundAxis(pa, ab, angle); return { x: rotated.x + a.x, y: rotated.y + a.y, z: rotated.z + a.z }; }

这个方法在整个项目里被反复调用,是所有动画的基石。后面要做的无非是确定“每条棱边对应哪个面”,以及“每个面要转多少角度”。

2.4 面向树的层次关系

真正实现动画时,我发现单纯知道“每个面绕哪条边旋转”还不够,还必须明确面与面之间的层次关系。什么情况下需要层次关系?以立方体的十字形展开图为例,正面固定在中间,顶面绕正面顶边旋转,背面又和顶面共用一条边。这时候如果顶面在转动,背面必须跟着顶面一起动,否则背面就会脱离整个结构。

于是我把每个几何体定义成一张“面向树”:根面是固定面,其他面按展开图的连接关系挂在父面下面。每个子节点除了保存自己的顶点,还保存两条信息:与父面共享的棱边、折叠到立体状态时的目标角度。在动画过程中,从根节点出发递归变换所有子节点。这种做法让我处理复杂几何体时不至于搞乱坐标。

3. 展开与折叠动画的核心实现

3.1 立方体十字形展开的折叠序列

以立方体为例,我选择的展开图是教科书里最经典的十字形,六个面分布如下:正面在中间,上下左右各一个面,背面藏在最上方那个面的上方。

在展开图的平面坐标里,我用单位长度 1 表示一个面的边长,正面中心放在原点附近,每个面都用一个四边形的四个顶点描述。要把这个十字形折叠成立方体,得按顺序完成五组旋转:

  • 顶面绕正面顶边向上旋转 90 度;
  • 底面绕正面底边向下旋转 90 度;
  • 左面绕正面左边向左旋转 90 度;
  • 右面绕正面右边向右旋转 90 度;
  • 背面等顶面转到位后,绕顶面与背面的公共边再旋转 90 度,落到立方体背面位置。

注意最后一组旋转有个先后依赖:背面必须先跟随顶面的变换,再执行自己的旋转。这也正是需要面向树的原因。

3.2 从进度值到每个面的旋转角度

动画系统里,最直观的交互是进度条:从 0 拖到 1,画面从完全展开过渡到完全折叠。因此我要做一个映射函数,把统一的进度 progress 映射到每个面的局部旋转角度。

我的做法是给每个节点配置两个时间参数:startRatio 和 endRatio。当 progress 还没到 startRatio 时,角度为 0;到了 endRatio 后就变成 targetAngle;中间的部分用缓动函数做平滑插值。以立方体为例,正面固定,四个侧面从 progress=0 开始旋转直到 progress=0.6,背面从 progress=0.4 开始旋转到 progress=1.0。这样动画有了错落感,逻辑也清晰。

function getCurrentAngle(node: FoldNode, progress: number): number { if (progress <= node.startRatio) { return 0; } if (progress >= node.endRatio) { return node.targetAngle; } const local = (progress - node.startRatio) / (node.endRatio - node.startRatio); const eased = local * local * (3 - 2 * local); return node.targetAngle * eased; }

这里用了 smoothstep 缓动,避免动画开始和结束时出现明显的“顿挫感”。如果你希望动画更有弹性,也可以换成 easeInOutBack 之类的曲线,但教学场景下我建议还是用匀速且平滑的缓动,方便观察。

3.3 从 3D 坐标到 Canvas 画布的投影

几何体经过旋转后,各个面的顶点坐标仍然是三维的,而 Canvas 只能画二维图形,所以必须做投影。我选了透视投影,因为透视会让画面有纵深感。投影核心就两行:

  • 假定相机位于 z 轴正方向很远的位置;
  • 把三维坐标里的 x、y 除以与 z 相关的系数,实现近大远小。

简易代码如下:

function projectPoint(p: Vec3, width: number, height: number): [number, number] { const cameraZ = 8; const scale = 80; const factor = cameraZ / (cameraZ + p.z); return [ width / 2 + p.x * scale * factor, height / 2 - p.y * scale * factor ]; }

当几何体完全展开时,所有顶点都在 z=0 平面上,透视效果最弱,画面看过去就是标准平面图形;当几何体逐渐折叠起来,z 值变大,侧面会产生明显的深度差异,立体感就出来了。

绘制时,我把每个面当作一个多边形,先填充颜色,再描边。为了画面干净,面与面之间用浅色描边区分。

4. ArkUI 页面搭建与交互联动

4.1 界面整体结构

页面布局很简单,自顶向下三层:最上面是 Canvas 画布,用来渲染几何体;中间是形状选择按钮,用一组横向排布的按钮切换立方体、四棱锥、三棱柱;底部是进度滑杆和播放/暂停按钮。

ArkUI 声明式语法很适合这种界面,代码结构大概是下面这样:

@Entry @Component struct UnfoldDemoPage { @State selectedShape: number = 0; @State progress: number = 0; @State isPlaying: boolean = false; private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true)); build() { Column() { Canvas(this.ctx) .width('100%') .layoutWeight(1) .backgroundColor('#F5F7FA') .onReady(() => { this.renderFrame(); }) Row({ space: 12 }) { ForEach(['立方体', '四棱锥', '三棱柱'], (name: string, index: number) => { Button(name) .fontColor(this.selectedShape === index ? '#FFFFFF' : '#333333') .backgroundColor(this.selectedShape === index ? '#007DFF' : '#E5E9F0') .onClick(() => { this.selectedShape = index; this.progress = 0; this.renderFrame(); }) }, (name: string) => name) } .padding(12) Slider({ value: this.progress * 100, min: 0, max: 100 }) .onChange((value: number) => { this.progress = value / 100; this.renderFrame(); }) Button(this.isPlaying ? '暂停' : '播放') .width(100) .onClick(() => { this.isPlaying = !this.isPlaying; this.runAnimation(); }) } .width('100%') .height('100%') } }

4.2 Canvas 的渲染循环

Canvas 不像传统声明式组件那样数据变了就自动刷新,它需要我们在合适的时机主动调用绘制函数。我在项目里把渲染逻辑封装成一个renderFrame()方法:每次进度改变、切换形状、动画播放时,都重新完成“计算顶点坐标 → 投影 → 绘制面”的全流程。

自动播放动画时,我用了定时器调度:

private runAnimation() { if (!this.isPlaying) { clearInterval(this.intervalId); return; } if (this.intervalId) { clearInterval(this.intervalId); } this.intervalId = setInterval(() => { let next = this.progress + 0.005; if (next >= 1) { next = 1; this.isPlaying = false; clearInterval(this.intervalId); } this.progress = next; this.renderFrame(); }, 16); }

实测下来,用 16ms 一帧的定时器在这个面数很少的项目里完全没有压力。如果你的几何体顶点数更多,或者想追求更稳定的帧率,可以换成 requestAnimationFrame,不过原理和这里完全相同。

4.3 形状切换的数据重新载入

切换形状时,最关键的一步是重新加载该形状的几何数据和折叠节点配置。我在每一帧渲染前会根据selectedShape去获取对应的对象,对象里包含面列表、根节点索引、以及每个节点的旋转配置。切换时把进度归零,画面自然回到完全展开状态,用户就能看到一个新的平面展开图。

此处有个小建议:在构建三棱柱和四棱锥的数据时,由于面数和连接关系各不相同,最好单独写好每个几何体的“节点配置函数”,不要用同一个通用构造函数硬套。虽然通用化代码看起来更漂亮,但不同形状的折叠角度和父子关系差异很大,硬套反而容易出错。

5. 实际开发中踩过的坑和优化经验

5.1 面的绘制顺序:深度排序很重要

这个坑几乎直接决定了画面是否正确。刚开始做的时候,我只是按数组顺序绘制所有面,结果立体状态下经常出现“不该在前面看到的面挡住了本来应该可见的面”。

原因是 Canvas 2D 没有深度缓冲,后绘制的多边形会覆盖先绘制的。要解决它,必须在每一帧对所有面做一次深度排序:计算每个面所有顶点在相机空间 z 值的平均值,然后按 z 从远到近排序,先画远的,再画近的。

对简单几何体来说,用“面中心深度”排序精度完全够,也很少出现面穿插的尴尬情况。但如果你将来做更复杂的模型,或者允许用户自由旋转视角,可能就要考虑拆三角形、做更精细的遮挡处理了。

5.2 浮点误差会导致面之间出现裂缝

动画过程中大量使用三角函数和矩阵变换,经过几十次旋转后,共享边上的顶点坐标可能会出现细微差异。这些差异在视觉上表现为两个面之间出现一条细缝,看起来像模型破损。

我用了一个笨但有效的办法:在展开态模型上预先把重复顶点的“原始位置”保存下来。每次动画结束后,检查相邻面公共边上的顶点距离,如果小于一个极小阈值,就把它们强制归一到同一个坐标。对立方体这种拓扑比较密的面,这个方法能明显减少裂缝。

还有一个更省事的思路:绘制多边形时,给所有面加上一条和填充色相同或相近的粗描边。用描边盖住缝隙,视觉上干净得多。我个人在项目的最终版里同时用了描边和归一化,效果最稳。

5.3 动画的计时和精度问题

前面我用定时器累加 progress,实际使用中会有两个小问题。一是定时器间隔不精确,长时间播放后进度会漂移;二是当用户来回拖动滑杆时,动画回调可能和用户操作互相干扰。

改进方式不复杂:播放时记录开始时间,每次回调用“当前时间减去开始时间”计算进度,而不是简单累加增量。拖拽进度时,先把播放停止,等用户松开滑杆再决定是否继续播放。这样逻辑上清晰,也避免了两路写同一个progress变量造成抖动。

5.4 性能与首帧渲染的注意事项

性能方面,这个项目的绘制量实在太小,最复杂的一帧也不过绘制十几个多边形,所以不需要做复杂优化。但首次进入页面时,有一个细节要提醒:Canvas 的onReady回调才是画布真正初始化完成的时机,不要在页面构造时就调用绘制方法,否则拿到的上下文可能还没准备好,导致首帧空白。

我自己的做法是在onReady里做一次强制刷新,后面所有状态变化都主动调renderFrame()。如果希望在滑动滑杆时有更高的响应速度,还可以把绘制函数做得更“函数式”,每次都重新计算全部坐标,不要缓存可能过期的中间结果。

6. 这个项目还能怎么延伸

做到这里,其实只完成了最核心的框架。从一个纯粹的技术 Demo 变成一个真正可用的教学工具,还有很多可以延伸的方向。

首先,几何体类型可以扩展。正方体、四棱锥、三棱柱只是最基础的入门。后续可以加入圆柱、圆锥、正八面体,甚至更复杂的多面体。算法本身并不需要大改,只要为每个形状配置好展开图和折叠角度,渲染和动画系统都能直接复用。其次,交互可以丰富。当前版本只支持进度条控制,如果有时间,我可以给 Canvas 增加手势旋转视角,让用户从任意角度观察几何体。“蜢旋”在解决空间结构理解上特别有价值,因为有些学生要看到侧面和背面才能真正理解面的位置关系。

另外,如果能加上每面的标签,在展开图上直接显示“上面”“下面”“左面”这样的文字,教学效果会更好。ArkUI 的 Canvas 支持绘制文字,实现成本不高,但交互体验会明显提升。最后,如果想做成一个正式的产品,还可以把内容拆成系列课程,每个几何体对应一个小关卡,让学生自己判断“这样展开对不对”,从演示工具变成练习工具。

在实际开发这个案例的过程中,我最大的感受是:算法并不复杂,难的是把几何关系梳理得足够清楚。很多所谓“做不出来”的问题,其实都是因为没有把“每个面属于谁、绕哪条边转、目标角度是多少”这些数据结构定义好。只要你把这些元数据整理清楚,哪怕用最简单的 Canvas 也能做出效果很好的三维演示。这个思路放到其他图形项目里同样成立。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 2:38:49

做wordpress富文本表单这5个注意事项能省一半冤枉钱

做wordpress富文本表单这5个注意事项能省一半冤枉钱 还在为模板网站太丑、功能不够用而头疼?别急着换皮,核心问题往往出在表单交互的底层逻辑上。很多人做wordpress富文本表单,光盯着UI好看,结果上线后客户填不了、后台收不到、数据全乱套。这不仅是美观问题,更是转化率的生死线。…

作者头像 李华
网站建设 2026/9/16 2:38:45

C#上位机与欧姆龙PLC串口通讯:FINS协议帧结构、地址映射与代码实现

简介&#xff1a;这是一份面向工业自动化开发者的C#与欧姆龙PLC通讯入门资源&#xff0c;重点演示如何通过System.IO.Ports串口通信以及FINS协议完成上位机与PLC之间的寄存器读写、数据转换与异常处理&#xff0c;适合有基础C#语法、希望快速上手PLC上位机程序的初学者。压缩包…

作者头像 李华
网站建设 2026/9/16 2:38:37

STM32三相SPWM波输出:从定时器配置到死区调试的全流程指南

简介&#xff1a;基于STM32的三相SPWM波输出压缩包&#xff0c;面向电机控制与逆变器方向的嵌入式开发者&#xff0c;重点解决用STM32高级定时器生成相位差120度的三相正弦脉宽调制波。工程覆盖PWM模式配置、预分频、比较寄存器占空比、死区插入&#xff0c;用三角载波与正弦表…

作者头像 李华
网站建设 2026/9/16 2:37:27

MATLAB实战短时交通流量预测:ARIMA+BP组合模型全解析

简介&#xff1a;这是一份面向交通工程、智能交通系统研究人员与MATLAB初学者的短时交通流量预测源码资源。压缩包内为单个MATLAB脚本文件&#xff0c;大小仅1KB&#xff0c;代码轻量紧凑&#xff0c;聚焦交通流量预测这一核心场景。该脚本围绕数据预处理、特征工程、模型构建、…

作者头像 李华
网站建设 2026/9/16 2:37:01

智慧路灯管理后台系统模板:物联网接入与GIS可视化运维实践

简介&#xff1a;面向城市智慧照明管理场景的后台系统模板&#xff0c;适合物联网平台开发人员、前端工程师及方案集成商快速搭建路灯运维管理界面。模板覆盖设备监控、能耗统计、报警管理、远程控制等核心功能模块&#xff0c;并提供地图可视化与图表展示的交互原型。资源以静…

作者头像 李华
网站建设 2026/9/16 2:36:13

Java网络编程实战:从TCP/IP到Socket与UDP通信全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华