尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

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中间的部分用缓动函数做平滑插值。以立方体为例正面固定四个侧面从 progress0 开始旋转直到 progress0.6背面从 progress0.4 开始旋转到 progress1.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 ]; }当几何体完全展开时所有顶点都在 z0 平面上透视效果最弱画面看过去就是标准平面图形当几何体逐渐折叠起来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 也能做出效果很好的三维演示。这个思路放到其他图形项目里同样成立。
返回列表