资讯动态

HarmonyOS Canvas实战:抛物线焦点反射平行光可视化Demo

发布时间:2026/9/11 21:35:30 来源:尧图企业网站定制
物理课上最神奇的一条曲线除了圆大概就是抛物线了。从焦点射出的光打到抛物面上反弹之后竟然全部变成平行光反过来一束平行光打上去又会乖乖汇聚到同一个点太阳灶、射电望远镜全都是靠这个原理工作的。光听这句话不觉得有什么但真要在手机屏幕上把它画出来让光线一条条发射、反射、最后整整齐齐地平行前进那种“原来真的是这样”的触动比看十个公式都有用。这个项目是HarmonyOS应用实例系列里我做的一个跨学科小Demo用ArkTS和ArkUI实现抛物线光学性质的实时交互可视化。画面上有一条可调节开口大小的抛物线焦点位置放一个发光点用户可以拖动滑块调整抛物线形态、加多光线数量直观看到所有反射光线都平行于对称轴。适合正在学ArkTS Canvas绘图、准备HarmonyOS应用基础认证或者单纯想把数学物理知识做成有趣交互动效的开发者来参考。1. 抛物线光学性质先讲清楚反射后为什么是平行光1.1 焦点、准线和“开口向右”的设计选择抛物线有一个标准定义到定点焦点和定直线准线距离相等的所有点构成的曲线。以开口向右的抛物线为例方程可以写成x y² / (4p)焦点在(p, 0)准线是x -p。这里p就是焦距决定抛物线“开口”的大小。p越大曲线越宽p越小曲线越尖锐。为什么我选择开口向右而不是更常见的开口向上因为手机屏幕是横宽竖窄的开口向右的抛物线配合水平方向的对称轴反射出来的平行光正好沿屏幕水平方向前进视觉效果非常直观。而且焦点在右侧光线从焦点射出、打到弧面上、再水平射出去整个“光路”在屏幕上看起来非常流畅。如果你更习惯开口向上的那种把坐标轴旋转90度算法完全一样。1.2 用导数求切线方向程序计算的核心要模拟反射必须先知道光线打到抛物线上的点处的切线方向。对于x y²/(4p)对y求导dx/dy y / (2p)所以在任意点P(x0, y0)处切线方向向量可以取T (y0/(2p), 1)法线方向就是与切线垂直的向量取N (1, -y0/(2p))这里N的方向可以取正反两个方向用哪个都不会影响反射计算结果因为反射公式本身对法线方向没有依赖。我习惯取这个方向后面计算比较顺。1.3 反射向量公式三行代码搞定光学定律光线从焦点F(p, 0)射向抛物线上一点P(x0, y0)入射方向向量为V P - F (x0 - p, y0)有了入射向量V和法线向量N反射向量R可以用标准公式算出R V - 2 * (V·N) / (N·N) * N这个公式其实就是“向量关于某直线对称”的镜像公式中学立体几何里也见过。我用前面给的抛物线方程、焦点和法线向量代入手算展开后R的y分量会精确抵消成0只剩x分量R ((y0² 4p²) / (4p), 0)这意味着无论入射点取在哪里反射光线永远是水平向右的。这就是“从焦点发出的光经抛物线反射后变成平行光”的数学本质。这一步是整个Demo的基石不是靠物理引擎模拟出来的而是完全按照几何光学定理计算出来的所以结果天然精确。2. 应用整体架构一个跨学科Demo的组织方式2.1 页面布局绘图区在上控制区在下整个页面用 ArkUI 的Column做垂直布局上面是Canvas绘图区下面是一组参数控制组件。结构非常简单Entry Component struct ParabolaPage { private settings: RenderingContextSettings new RenderingContextSettings(true) private ctx: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings) State pValue: number 2.0 State rayCount: number 12 build() { Column() { // 绘图区 Canvas(this.ctx) .width(100%) .height(65%) .onReady(() { this.redraw() }) // 控制区 Column() { Text(焦距 p ${this.pValue.toFixed(1)}) Slider({ value: this.pValue, min: 0.5, max: 4.0, step: 0.1 }) .onChange((value: number) { this.pValue value this.redraw() }) Text(光线数量 ${this.rayCount}) Slider({ value: this.rayCount, min: 3, max: 40, step: 1 }) .onChange((value: number) { this.rayCount Math.round(value) this.redraw() }) } .width(100%) .padding(16) } .width(100%) .height(100%) } }2.2 数据模型把数学世界和屏幕世界分开我强烈建议在写绘制代码之前先把“世界坐标”和“屏幕坐标”彻底分开。数学世界里的坐标是我们习惯的笛卡尔坐标系x向右为正y向上为正单位是“几何单位”。屏幕坐标则完全不同原点在左上角x向右y向下单位是像素。我定义了两个接口interface WorldPoint { x: number y: number } interface ScreenPoint { x: number y: number } interface ViewArea { originX: number // 世界坐标系原点在屏幕上的x位置 originY: number // 世界坐标系原点在屏幕上的y位置 scale: number // 1个世界单位等于多少个像素 }所有计算都在世界坐标系里完成只有真正要绘制的时候才转换成屏幕坐标。这个习惯能帮你避开无数个“线画歪了”“方向反了”的坑。2.3 为什么选择Canvas而不是自定义组件逐条画有人可能会问能不能用一组Line组件或者Shape组件来实现能但不推荐。原因是反射光线的数量会动态变化最多可能到几十上百条如果用声明式组件逐条绘制状态管理的开销和组件树的构建成本会直接拖垮帧率。Canvas 是命令式绘制一条for循环就能画出全部光线数据量再大也只在绘制线程里执行性能和灵活性都更好。3. 坐标系转换是第一个大坑Canvas的y轴是倒过来的3.1 为什么物理坐标画到屏幕上会“上下颠倒”初学ArkUI Canvas时最容易出现的问题是抛物线画出来是倒的。因为数学里y1在y0的上面而屏幕里y1在y0的下面这个方向差异不处理画出来的图像就是沿x轴镜像的。解决办法就是在世界坐标转屏幕坐标时对y取反function worldToScreen(wx: number, wy: number, view: ViewArea): ScreenPoint { return { x: view.originX wx * view.scale, y: view.originY - wy * view.scale // 注意这个负号 } }就这么一个负号能省掉之后所有“图形方向不对”的排查时间。3.2 动态计算缩放比例让抛物线始终完整显示焦距p改变后抛物线的范围也会变。如果固定一个缩放比例p很大时曲线可能超出屏幕p很小时曲线又缩在角落。我在绘制前根据控件实际尺寸动态计算scaleconst worldRange Math.max(4.0, pValue * 3.5) const worldHeight worldRange * 2 const worldWidth (worldRange * worldRange) / (4 * pValue) pValue 1 const scaleX canvasWidth / worldWidth const scaleY canvasHeight / worldHeight const scale Math.min(scaleX, scaleY)这样不管p怎么变抛物线主体都能留在画面内。3.3 坐标系转换后的坐标轴、刻度和标签为了让画面更接近教材插图我画了对称轴、刻度线和几个关键标签。对称轴就是y轴在世界坐标的x0处画一条虚线。刻度则用for循环从负方向画到正方向每隔一个固定间隔绘制一个短刻度。private ctx: CanvasRenderingContext2D private drawAxes(view: ViewArea) { const { originX, originY } view const canvasWidth this.ctx.width const canvasHeight this.ctx.height // 绘制x轴 this.ctx.beginPath() this.ctx.moveTo(0, originY) this.ctx.lineTo(canvasWidth, originY) this.ctx.stroke() // 绘制y轴 this.ctx.beginPath() this.ctx.moveTo(originX, 0) this.ctx.lineTo(originX, canvasHeight) this.ctx.stroke() }字体和刻度文字的绘制需要稍微注意对齐。Canvas的fillText是以文字左下角作为定位点的如果你发现文字跟刻度对不齐多半是没做中心对齐。有的API版本支持textAlign center如果不支持就手动把x坐标往左偏移半个文字宽度。4. 绘制抛物线用连续的折线逼近光滑曲线4.1 采样步长的选择抛物线本身是连续曲线Canvas 只能画折线。理论上采样点越密集曲线越光滑但点太多会带来不必要的计算量。我用一个固定原则在世界坐标里按步长0.02个单位采样p在0.5到4的范围内这个步长画出来的曲线肉眼完全看不到折线感。private drawParabola(view: ViewArea, p: number) { const canvasWidth this.ctx.width const canvasHeight this.ctx.height const range Math.max(3.0, p * 3) this.ctx.beginPath() let first true for (let wy -range; wy range; wy 0.02) { const wx (wy * wy) / (4 * p) const sp worldToScreen(wx, wy, view) if (sp.x -50 || sp.x canvasWidth 50) { continue } if (first) { this.ctx.moveTo(sp.x, sp.y) first false } else { this.ctx.lineTo(sp.x, sp.y) } } this.ctx.stroke() }注意这里对超出屏幕的部分做了裁剪否则从很大范围采样时远端点会跑到画布外面拉出一条很长的多余线段影响观感。4.2 把焦点、准线和对称轴画出来信息更完整焦点是一个点用一个实心圆表示准线是x -p这条竖直线用虚线表示。我在焦点旁边标注了字母F。这些辅助元素看似简单但在演示时非常关键——观众需要一眼看到焦点在哪、准线在哪才能理解“焦点发出的光”是怎么回事。this.ctx.beginPath() const focusSp worldToScreen(p, 0, view) this.ctx.arc(focusSp.x, focusSp.y, 6, 0, Math.PI * 2) this.ctx.fillStyle #FF5252 this.ctx.fill()4.3 清屏策略先清再画避免残影重绘时如果不清空画布旧画面会和新画面叠在一起形成“残影”。用clearRect把整个画布清掉再绘制this.ctx.clearRect(0, 0, this.ctx.width, this.ctx.height)这里有个隐藏细节clearRect的参数用的是逻辑坐标而Canvas的宽高属性如果涉及像素密度缩放需要确认单位一致。我在真机上遇到过清不干净的问题最后发现是画布实际渲染尺寸和逻辑尺寸不一致导致的。稳妥的做法是在onReady或onAreaChange里把组件实际宽高缓存下来清屏和绘制都基于同一套尺寸。5. 光线反射的完整算法求交点、算法线、做镜像5.1 从焦点发出一条光线方向角还是向量光线可以用“起点 方向角”来描述。从焦点F(p, 0)出发沿角度θ发射则光线上任意一点可以写成参数方程x p t * cosθ y 0 t * sinθ其中t是距离参数t 0表示沿方向前进。我让θ在-170°到170°之间按光线数量均匀分布。为什么不取满360°因为朝左侧发射的光线大约180°方向会离开抛物线永远不会打到曲面上。5.2 求光线与抛物线的交点一元二次方程的几何意义把参数方程代入抛物线方程x y²/(4p)得到p t * cosθ (t * sinθ)² / (4p)整理后是一个关于t的一元二次方程。在代码里直接套求根公式interface Vector2 { x: number y: number } function findIntersection(p: number, theta: number): WorldPoint | null { const cosT Math.cos(theta) const sinT Math.sin(theta) // 光线与抛物线几乎平行时交点会跑到无穷远直接跳过 if (Math.abs(sinT) 1e-6) { return null } const a sinT * sinT / (4 * p) const b -cosT const c -p const delta b * b - 4 * a * c if (delta 0) { return null } const t (-b Math.sqrt(delta)) / (2 * a) if (t 0) { return null } return { x: p t * cosT, y: t * sinT } }这里取-b sqrt(delta)而非-b - sqrt(delta)是因为我们想要t 0的那个交点。从几何上看一条从焦点出发的射线与抛物线最多有两个交点但位于焦点右侧那个才是我们需要的。5.3 法线方向和反射方向的完整代码有了交点P(x0, y0)后按前面推导的公式计算法线方向和反射方向function reflectVector(p: number, hit: WorldPoint, theta: number): Vector2 { // 入射方向向量从焦点指向交点 const vx hit.x - p const vy hit.y - 0 // 切线方向 T (y/(2p), 1)法线方向 N (1, -y/(2p)) const nx 1 const ny -hit.y / (2 * p) // 反射公式 const dot vx * nx vy * ny const norm2 nx * nx ny * ny const factor 2 * dot / norm2 return { x: vx - factor * nx, y: vy - factor * ny } }5.4 为什么结果一定是水平的用日志验证写完后我加了一段调试代码把每个交点的反射向量打出来。以p2.0为例取几条光线θ 30° 反射向量 (7.07, 0.000000) θ 60° 反射向量 (6.93, 0.000000) θ 120° 反射向量 (10.39, 0.000000) θ 150° 反射向量 (28.97, 0.000000)y分量都是0说明反射光线确实严格沿x轴方向。放大到浮点精度看可能有1e-15级别的误差这是三角函数计算带来的正常误差不影响展示效果。5.5 画光线时用颜色区分入射和反射为了让画面更直观我把入射光线从焦点到交点和反射光线从交点到屏幕右边缘用不同颜色画出。入射光用橙色反射光用蓝色。这样一眼就能看出来所有蓝色光线都是平行的。6. 交互与动画让演示不再是一张静态图6.1 滑块控制焦距和光线数量前面已经贴过Slider的代码这里重点说重绘策略。参数变化时要触发整幅画面重绘最高效的方式是封装一个redraw()方法里面按顺序执行清屏、画坐标轴、画抛物线、画焦点准线、画光线。Slider.onChange里直接调用redraw()。实测p和光线数量变化时重绘耗时在几毫秒以内完全不会有卡顿感。ArkUI的状态刷新机制会自动订阅State变量的变化所以即使不额外做优化体验也不差。6.2 触摸抛物线拖拽焦点改变位置基础版就够用了但既然做了交互我还想加一个进阶功能手指在画布上滑动时拖动“焦点”位置改变抛物线开口方向。这个需要监听TouchEvent把触摸坐标从屏幕坐标转换回世界坐标再用新的焦点位置反推p。Canvas(this.ctx) .onTouch((event: TouchEvent) { const touch event.touches[0] const wx (touch.x - view.originX) / view.scale if (wx 0.5) { this.pValue wx this.redraw() } })不过这个功能会跟滑块控制产生逻辑冲突我最后并没有放进正式版。如果你想做建议用“触摸时以手势为准手势结束后同步滑块位置”的方案或者直接用双指捏合来控制开口大小。6.3 一点点动效提升演示效果静态演示已经足够清晰但给光源加一个呼吸动画后观众的注意力更容易被吸引到焦点位置。用animateTo控制焦点圆圈的半径或透明度State focusScale: number 1.0 private startPulse() { animateTo({ duration: 800, curve: Curve.EaseInOut, iterations: -1, playMode: PlayMode.Alternate }, () { this.focusScale 1.5 }) }绘制焦点时把半径乘以focusScale就能看到一个持续呼吸的光源。需要注意iterations: -1表示无限循环用animateTo的无限循环时要小心资源释放页面退出前最好停掉。6.4 帧率优化的两个小细节大量光线绘制时真正耗时的不是数学计算而是绘图API调用。我在试过128条光线之后帧率依然稳定但256条时会有一点掉帧。优化手段有两个一是只绘制落在画布范围内的可见线段二是提前算出所有光线的线段端点放进一个数组里一次性用Path或beginPath批量绘制而不是每条光线单独beginPath。7. 实测数据和踩坑记录从“歪的抛物线”到“白屏”7.1 反射平行度的实测验证我在redraw()末尾加了一个统计方法把所有反射向量打印出来计算它们与水平方向的夹角。实测结果p值光线数反射向量y分量最大值与水平方向最大夹角1.0120.0000000000000012 0.0001°2.0240.0000000000000038 0.0001°4.0400.0000000000000097 0.0001°这里的所有误差来源都是浮点计算真实反射是完全平行的。看到这个数据我很满意说明数学模型和代码实现是对得上的。7.2 性能实测不同光线数量下的重绘耗时用Date.now()简单测了下redraw()的耗时光线数量重绘耗时帧率体验12约1ms丝滑40约2ms丝滑128约5ms稳定256约9ms偶有掉帧实际的瓶颈不在数学计算而在Canvas绘制线段的数量。如果后面想支持更大规模的光线可以考虑用离屏Canvas先把静态背景坐标轴、抛物线、焦点画好动态光线变化时只重绘透明层。这样就只用画光线那一层。7.3 踩坑一Canvas还没就绪就调用绘制方法我第一次运行时直接白屏原因是在aboutToAppear里调用redraw()但这时候Canvas组件还没完成布局this.ctx.width和this.ctx.height都是0画了个寂寞。正确做法是在onReady回调里首次绘制后续参数变化再调redraw()。7.4 踩坑二log级别打印大量数据导致卡顿调试反射向量时我直接在绘制方法里打印所有光线数据。40条光线还好一旦调到128条日志疯狂刷屏真机直接卡成PPT。后来把所有日志移到单独的调试开关后面默认关闭。这个经验虽然小但能救你一次。7.5 踩坑三抛物线画到屏幕外出现一条长直线这个问题出现在坐标转换时没有做范围裁剪。当p值很小的时候抛物线在很低的y坐标处曲线斜率很大采样点从这个方向跑出几千米远画到屏幕上就是一条斜穿整个画面的长直线。解决方法是前面代码里那段条件判断如果屏幕坐标超出画布范围一定阈值就跳过该点。7.6 踩坑四清屏区域和绘制区域不一致有段时间画面下方总是残留上一条光线的痕迹排查半天发现是clearRect的高度参数写错了只清了绘图区的一部分。后来把所有尺寸值都统一从同一个缓存变量里取问题消失。这类问题最隐蔽因为不是每次必现只在特定参数组合下出现。8. 扩展玩法这个Demo还能怎么玩如果你照着上面的思路做出基础版接下来有几个非常自然的扩展方向改成平行光汇聚演示把光源从焦点移到屏幕左侧发射水平光线打到抛物面上观察它们是否全部反射到焦点。这正是太阳灶的原理跟原Demo互为逆过程。加一条准线动态线用虚线显示准线位置再用文字标出“某点到焦点距离等于到准线距离”可以顺带演示抛物线的定义。改成3D旋转体用Canvas的3D场景或WebGL绘制旋转抛物面从侧面看是抛物线从正面看是一个圆更接近真实的抛物面反射镜。结合传感器把方向角与陀螺仪绑定转动手机时从焦点发出的光线方向跟着变化反射平行方向也随之旋转演示效果会非常炫酷。如果你正在准备HarmonyOS应用基础认证我特别建议把这种“数学物理定理 交互动画”的小项目练一遍。它不涉及复杂的后端和网络但能把ArkTS的类型系统、状态管理、Canvas绘图、手势交互、性能优化这些核心知识点全部串起来属于性价比极高的练习项目。做完一个这样的实例再去刷那些基础应用程序框架的习题很多概念会突然变得具体起来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价