1. 虚拟摄像机到底解决了什么问题很多刚开始做 Flutter OpenHarmony 游戏的人都会犯同一个错角色明明在地图里跑屏幕却纹丝不动。你心想“不就是把角色的世界坐标换算成屏幕坐标嘛”于是手动计算每个物体的偏移位置结果越写越乱。等角色走到地图边缘你发现整个画面还在原地敌人刷在屏幕外边碰撞提示也跟丢了。这个时候你就会意识到游戏里其实缺了一个虚拟摄像机。虚拟摄像机不是一个具体的 3D 物体而是你对“视角”的抽象。它负责定义一件事玩家当前应该看到世界里的哪一块区域。有了它你的游戏逻辑只关心世界坐标渲染层只关心屏幕坐标中间那层换算全部交给摄像机。这篇内容的重点就是把这个摄像机做成一个能平滑跟随目标、又能灵活控制偏移的系统并且成功跑在 OpenHarmony 的 Flutter 环境里。这篇文章适合三部分人已经在 Flutter 里写过自定义绘制或简单小游戏、但还没系统实现过镜头的开发者准备把现有 Flutter 游戏迁移到 OpenHarmony 设备上的同学以及那些对“手感”敏感、想弄明白为什么有的镜头跟手、有的镜头像把摄像机泡在胶水里的玩家型开发者。1.1 把“世界”搬到“屏幕”上先看一个基础问题。你的游戏世界里有棵树它的世界坐标是 (500, 300)。你的屏幕宽 800高 480玩家角色在世界坐标 (520, 300)。如果直接把树和角色绘制到 Canvas 上树会显示在屏幕坐标 (500, 300)角色在 (520, 300)两个人挤在一起根本没法玩。真实世界是这个屏幕只是世界里的一个窗口我们需要让这个窗口跟随角色移动。这个“窗口”的本质就是摄像机位置。假设摄像机中心在玩家位置那么树的世界坐标 (500, 300) 减去摄像机位置 (520, 300)得到屏幕坐标 (-20, 0)。也就是说树在屏幕左侧 20 像素外看不见。就这么简单屏幕上每个物体最终坐标 世界坐标 - 摄像机位置 视口中心。把这层逻辑抽出来以后游戏代码里不会出现“物体屏幕坐标 object.x - player.x screenWidth/2”这种到处分散的硬编码。你只需要在绘制前统一对 Canvas 做一次平移变换剩下所有物体仍然按世界坐标绘制。这个思路就是虚拟摄像机的核心意义。1.2 摄像机、镜头与视口我把摄像机这个系统的职责拆成三块对应三个名词摄像机位置、镜头、视口。摄像机位置通常用Vector2或者两个double表示它就是世界坐标系里的一个点代表视野中心。玩家移动时这个点跟着移动。镜头指的是平滑跟随过程中生成的一个“动态视角状态”它可以离目标有一段距离甚至视角可以放大缩小。视口则是屏幕上实际显示的区域尺寸比如 OpenHarmony 设备上的逻辑像素宽高或 Flutter 里Size类型的size。这三者合起来才是一个完整的虚拟摄像机。很多人把“摄像机位置”等同于“玩家位置”这在某些游戏里能成立但在有屏幕震动、有镜头转向、有 UI 偏移需求的游戏里绝对不行。把这个系统拆开之后你会发现大部分复杂的运镜需求都能用“目标位置 偏移量 平滑算法”三个参数组合出来。1.3 为什么在 Flutter 里要自己做摄像机Flutter 本身没有提供现成的摄像机组件它只有Canvas以及 Canvas 上的translate、rotate、scale方法。OpenHarmony 上运行 Flutter 时渲染管线依然是 Flutter 自己的系统组件和 Flutter 视图之间靠平台层桥接。所以你没法指望框架白送你一个镜头跟随必须自己维护状态、自己计算位置、自己应用变换。使用 Flame 这类成熟游戏引擎的朋友可能会有疑问Flame 不是自带CameraComponent吗确实Flame 的摄像机做得很完整我也在项目里用过。但很多从零开始的项目不想引入重型引擎或者需要在同一个 Flutter 视图中实时切换不同类型的摄像机逻辑这时候手写一个轻量的摄像机系统反而更可控也更方便加自定义偏移规则。所以这篇文章以手写为主最后会给出接入 Flame 的对应思路。2. 平滑跟随算法的演进与选型理论上摄像机位置等于目标位置直接camera.position target.position就行。这确实最简单但实际效果像是在玩老式录像机镜头永远是硬切角色跳一下画面跟着跳一下没有任何运镜美感。我们要的其实是“平滑跟随”也就是摄像机位置不能立刻追上目标而是带一点滞后、带一点速度惯性。2.1 线性插值最直觉的做法但别直接逐帧算很多人第一反应是用 Lerp也就是线性插值camera.position camera.position (targetPosition - camera.position) * alpha;这个写法很容易上手但有一个致命问题如果alpha是固定值比如 0.1那么画面会跟帧率强相关。60 FPS 下摄像机每帧移动剩余距离的 10%一秒移动约 90% 的距离30 FPS 下每帧移动剩余距离的 10%一秒只移动约 42% 的距离。结果就是高帧率设备上镜头追得快低帧率设备上镜头追得慢手感完全不一致。如果你非要用 Lerp至少得把alpha换成与耗时相关的形式。下面要说到的指数平滑本质上就是这个思路的修正版。2.2 帧率无关的指数平滑帧率无关的指数平滑公式如下final t 1.0 - math.exp(-lerpSpeed * dt); camera.position camera.position (desiredPosition - camera.position) * t;lerpSpeed越大跟随速度越快越接近硬切lerpSpeed越小跟随越迟缓镜头越有重量感。dt是上一帧到这一帧的耗时单位秒。这个公式为什么帧率无关因为指数平滑的连续解是current desired (initial - desired) * exp(-lerpSpeed * time)。不管你把时间切割成 1/60 还是 1/30经过相同总时长后摄像机都会收敛到几乎一样的位置。这是我在实际项目中最常用、也最推荐入门使用的平滑方式。不过指数平滑有一个特性初期速度很快越接近目标越慢。如果你把lerpSpeed调得很高镜头会迅速冲向目标但终归还是“追”不会出现过头再拉回来的情况。这在很多游戏里已经够用但如果玩家操作速度突变镜头会显得“肉”不够跟手。2.3 临界阻尼平滑不冲过头也不迟到有一类跟随算法来自物理弹簧模型。假设摄像机和一个目标点之间有一根弹簧弹簧的阻尼系数恰好处于临界阻尼状态那么摄像机可以高速逼近目标同时不产生震荡。这就是游戏引擎里常见的SmoothDamp算法也是我觉得手感上限最高的方案。Flutter 生态里没有内置SmoothDamp但可以自己写。核心思想是维护一个每帧计算出来的速度向量_velocity然后通过smoothTime和剩余距离来更新位置。Vector2 smoothDamp( Vector2 current, Vector2 target, Vector2 velocity, double smoothTime, double dt, ) { double omega 2.0 / smoothTime; double x omega * dt; double exp 1.0 / (1.0 x 0.48 * x * x 0.235 * x * x * x); Vector2 change current - target; double maxChange double.infinity; Vector2 temp (velocity omega * change) * dt.toDouble(); velocity (velocity - omega * temp) * exp; Vector2 result target (change temp) * exp; if ((target - current).length maxChange) { // 限制最大移动距离 } return result; }exp这个中间值实际是阻尼退化的权重它的计算方式来自数学上对临界阻尼响应的近似简化。你可以不用深究推导只要记住三个参数smoothTime控制从当前位置到目标位置所需的“预期时间”越大越慢dt必须是真实帧间隔velocity必须跨越帧持续保存不能每次新建。这套算法的优势是镜头在任何情况下都以“刚不过头、不会原地飘”的方式逼近目标。配合坐标偏移使用时手感比指数平滑更细腻。2.4 三种方案怎么取舍我整理了一张表方便你对照着选方案参数优点缺点适合场景线性插值固定αalpha实现最快帧率依赖原型 Demo不追求手感指数平滑lerpSpeed帧率无关、参数少靠近目标时偏肉大多数 2D 游戏临界阻尼平滑smoothTime、velocity手感锐利、不震荡需要维护速度状态平台跳跃、动作游戏如果你刚开始做建议先从指数平滑用起因为只用一个参数调起来非常直观。等有一天你发现“镜头怎么总是拖着角色走”的时候再切换到临界阻尼平滑。多数情况下smoothTime 取 0.1 到 0.3 秒之间手感比较自然。3. 坐标偏移跟随的“上层建筑”摄像机有了平滑跟随以后你只是解决了“能不能看见”的问题。接下来要解决的是“镜头怎么运更有表现力”的问题这就轮到坐标偏移登场。坐标偏移简单说就是目标位置加一个偏移量摄像机实际盯的是“目标位置 偏移量”而不是纯粹的玩家坐标。3.1 偏移量的几种经典用法第一个经典用法是前视偏移。角色向右跑的时候镜头应该向右偏一点让玩家能看到更多前方的敌人和地形。用一句游戏圈里常说的话就是“镜头跑到角色前面去了”。实现方式就是读取角色的速度向量把速度方向作为偏移方向偏移距离和速度大小挂钩。Vector2 lookAheadOffset player.velocity.normalized() * (player.velocity.length / 300.0);速度上限可以参考你自己游戏里的单位长度。我常用的比例是角色当前速度除以一个最大参考速度再乘以最大偏移距离。最大偏移距离控制在 40 到 80 逻辑像素之间不然镜头会“飘”得太厉害。第二个经典用法是战斗焦点偏移。Boss 站在屏幕上方玩家站在屏幕下方如果镜头始终瞄准玩家视觉效果会太单调。这时候可以让镜头中心保持在玩家和 Boss 的连线中点偏下一点让双方都留在画面里。这种偏移通常不是固定的而是根据场上重要物体的位置动态计算。第三个用法是屏幕震动。处理屏震时我会在摄像机位置之外再加一个临时偏移向量这个偏移量由震动波形生成并在几十毫秒内衰减到零。它的好处是非常独立不影响平滑跟随的收敛逻辑。3.2 偏移量怎么跟目标位置相加这里有个很多人忽略的细节偏移量的变化也需要平滑。如果你直接把玩家的实时偏移加到目标位置里再交给摄像机做平滑跟随问题还不大。但如果你在做战斗焦点偏移目标偏移位置会瞬间从左边跳到右边那你看到的画面就会猛甩一下哪怕摄像机有平滑算法也会很突兀。我的做法是给偏移量单独做一套平滑。摄像机最后的“期望位置”是玩家位置加平滑后的偏移。这样偏移的跳变会被过滤掉镜头的运镜节奏也更接近电影镜头。具体逻辑// 每帧先更新偏移状态再算摄像机目标位置 _smoothedOffset smoothDamp(_smoothedOffset, desiredOffset, _offsetVelocity, 0.15, dt); _desiredCameraPosition playerPosition _smoothedOffset;这样做的额外好处是屏震偏移和镜头跟随偏移可以分开控制。屏震偏移随手加在position上不需要考虑平滑因为震动本来就是瞬时效果。3.3 边界限制别让玩家看到“地图外面”坐标偏移用起来很容易上瘾但一到地图边界就会发现镜头偏移之后视野边缘会出现一大块空白区域。遇到这种情况就必须做边界限制。边界限制的思路是先算出摄像机期望位置再根据当前视口尺寸检查这个位置能不能看完整。假设地图宽mapWidth、高mapHeight、视口宽viewportWidth、高viewportHeight那摄像机中心点的有效范围是camera.position.x camera.position.x.clamp( viewportWidth / 2, mapWidth - viewportWidth / 2, ); camera.position.y camera.position.y.clamp( viewportHeight / 2, mapHeight - viewportHeight / 2, );这里要注意如果地图比屏幕还小那最小值会大于最大值clamp会直接炸掉。所以我一般先判断mapWidth viewportWidth再决定是否限制 X 轴Y 轴同理。用 clamp 处理完之后你还需要再算一次偏移量因为边界限制会改变实际位置导致前视偏移失效。解决方法是把边界限制放在最后也就是平滑跟随结束之后再对摄像机位置做 clamp。4. 在 Flutter OpenHarmony 里完成落地理论部分讲得差不多了下面进入实操。我会用一个最小可运行的例子来说明在 OpenHarmony 上也能无缝跑通。整体工程分成两层OpenHarmony 壳工程负责创建 Flutter 容器Flutter 侧负责游戏逻辑和渲染。4.1 OpenHarmony 壳工程与 Flutter 内容如何分工先说壳工程部分。OpenHarmony 应用可以通过一个 Flutter 容器把 Flutter 页面嵌入到应用里。生命周期上需要在壳工程的页面加载完成后启动 Flutter 引擎并把引擎的 Surface 绑定到原生容器上应用退到后台或者销毁时要同步暂停和释放引擎。这部分我踩过不少坑。如果只做游戏我建议把整个游戏画面放在 Flutter 容器内不要让 OpenHarmony 原生组件和 Flutter 界面交错渲染否则事件分发和同步绘制会有很大麻烦。命令行可以用 OpenHarmony 提供的工程工具创建默认应用再添加 Flutter 模块依赖。构建时一定要根据设备和 API 版本选择对应的构建产物不然在模拟器上跑得正常真机上一启动就黑屏。壳工程里还有一个重要细节设置 Flutter 容器的尺寸。OpenHarmony 设备有各种屏幕比例如果 Flutter 容器尺寸没有跟随原生窗口变化摄像机视口就会一直停留在旧尺寸上导致画面比例错乱。所以壳工程要在onWindowSizeChanged之类的事件里主动通知 Flutter 侧游戏引擎再更新视口。4.2 写一个可以复用的 GameCamera现在写 Flutter 侧的核心类。我先不管是不是用了某个游戏引擎直接用 Dart 写一个纯逻辑类import dart:math; class GameCamera { Vector2 position Vector2.zero(); Vector2 _velocity Vector2.zero(); Vector2 target Vector2.zero(); Vector2 desiredOffset Vector2.zero(); Vector2 smoothedOffset Vector2.zero(); Vector2 _offsetVelocity Vector2.zero(); double smoothTime 0.12; double offsetSmoothTime 0.18; Size viewportSize Size.zero; Rect worldBounds Rect.zero; void update(double dt) { // 偏移量平滑 smoothedOffset smoothDamp( smoothedOffset, desiredOffset, _offsetVelocity, offsetSmoothTime, dt, ); // 摄像机跟随目标 target targetPlayer smoothedOffset; position smoothDamp( position, target, _velocity, smoothTime, dt, ); _applyBounds(); } void _applyBounds() { if (worldBounds.isEmpty) return; if (worldBounds.width viewportSize.width) { position.x position.x.clamp( viewportSize.width / 2, worldBounds.width - viewportSize.width / 2, ); } if (worldBounds.height viewportSize.height) { position.y position.y.clamp( viewportSize.height / 2, worldBounds.height - viewportSize.height / 2, ); } } void applyTransform(Canvas canvas) { canvas.translate( viewportSize.width / 2 - position.x, viewportSize.height / 2 - position.y, ); } Vector2 screenToWorld(Vector2 screenPoint) { return Vector2( screenPoint.x position.x - viewportSize.width / 2, screenPoint.y position.y - viewportSize.height / 2, ); } Vector2 worldToScreen(Vector2 worldPoint) { return Vector2( worldPoint.x - position.x viewportSize.width / 2, worldPoint.y - position.y viewportSize.height / 2, ); } }这里我用了Vector2表示二维向量实际项目里可以引入某个线性代数库也可以直接用两个double成员变量。用两个double性能更好因为Vector2在频繁创建时会增加 GC 压力。smoothDamp方法就是上一节提到的手写版本targetPlayer代表玩家世界坐标。每帧玩家位置一变摄像机的目标就跟着变但摄像机位置总是慢半拍追上去。4.3 用 Ticker 驱动摄像机更新摄像机要持续跟随就必须有一个稳定的游戏循环。Flutter 里最简单的做法是用Ticker它是绑定了垂直信号的回调器每帧调用一次比Timer.periodic更适合对帧率敏感的游戏画面。class GamePage extends StatefulWidget { // ... } class _GamePageState extends StateGamePage with SingleTickerProviderStateMixin { late final Ticker _ticker; Duration _lastElapsed Duration.zero; final GameCamera _camera GameCamera(); override void initState() { super.initState(); _ticker createTicker(_onTick)..start(); } void _onTick(Duration elapsed) { final double dt (elapsed - _lastElapsed).inMicroseconds / 1000000.0; _lastElapsed elapsed; _camera.update(dt); setState(() {}); } override Widget build(BuildContext context) { return GestureDetector( onTapDown: (details) { final world _camera.screenToWorld(Vector2(details.localPosition.dx, details.localPosition.dy)); // 处理点击的世界坐标 }, child: CustomPaint( size: Size.infinite, painter: GamePainter(_camera), ), ); } }这段代码里有两个重要点。第一dt一定要自己算不要假设每帧间隔固定是 1/60。OpenHarmony 设备上 Flutter 的帧间隔会在不同场景间变化比如系统通知弹出、电池状态栏变化都会导致帧间隔波动。第二setState只在摄像机画面上需要刷新时调用如果游戏里还有独立于摄像机的 HUD最好把 HUD 放到另一个 Widget 层避免摄像机每帧刷新导致整棵树重建。游戏绘制侧GamePainter的paint方法里先调用_camera.applyTransform(canvas)再画世界然后canvas.restore()恢复坐标继续画 UI 层。4.4 性能优化与绘制策略摄像机系统跑起来之后性能优化要关注四个地方。第一个是减少每帧创建的对象。我见过很多开发者为了计算方便在update里反复新建Vector2跑不到五分钟 GC 就开始频繁工作造成掉帧。解决办法是复用临时向量或者干脆用两个double。第二个是合理裁剪。摄像机只显示世界的一部分理论上可以只绘制摄像机范围内的物体。如果你的世界里有大量静态物体可以先用空间索引或者简单的网格剔除把不在视口内的物体过滤掉。这个优化越早做越好等物体数量到一千以上再开始改工作量会大得多。第三是 Canvas 变换数量。同一个 Canvas 上做多次translate、rotate会积累误差而且影响绘制性能。我建议每个帧只做一次摄像机变换把世界内容全部画在这个变换下UI 层再单独做一次恢复不要让每个物体都调用一次canvas.translate。第四是 OpenHarmony 特有的视口尺寸同步。我调优的时候发现一个问题竖屏设备上首次布局完成后Flutter 容器尺寸和媒体查询返回值并不完全一致差几个像素。原因是原生 Surface 的初始化时机和 Flutter 帧之间存在竞态。解决方法是等第一帧绘制完成后读取MediaQuery.sizeOf(context)和Constraints里的最小值同时更新摄像机的viewportSize。这个坑不深但是排查起来很费时间。5. 常见问题与排查技巧实录这里把我在实际开发中遇到过的典型问题和排查思路整理一下基本都是摄像头系统最常见的病。5.1 画面抖动但不掉帧现象是角色移动时画面会轻微高频抖动帧率并没有掉。原因通常是浮动精度加坐标取整不当。当摄像机位置带小数时Canvas 变换后的物体坐标也有小数GPU 在光栅化时会对非整数坐标做插值导致边缘闪抖动。解决办法是在绘制前对摄像机位置做一个“半像素对齐”也就是把摄像机位置四舍五入到当前设备像素的整数倍。注意是逻辑像素而不是物理像素OpenHarmony 里有自己的像素密度换算规则直接用设备的displayScale判断。void applyTransform(Canvas canvas) { final alignedX position.x.roundToDouble(); final alignedY position.y.roundToDouble(); canvas.translate( viewportSize.width / 2 - alignedX, viewportSize.height / 2 - alignedY, ); }这招对像素风游戏尤其有效但我发现一个副作用屏幕震动幅度很小时会被取整吃掉震动看起来几乎没反应。所以我只在没有剧烈震动时启用像素对齐震动开始时临时关闭。5.2 摄像机“慢半拍”或过冲镜头移动慢半拍一般是 smoothTime 太大或者玩家速度太快而摄像机的最大加速度不够。检查smoothTime如果你用的是临界阻尼平滑建议调低到 0.1 秒左右。过冲则比较诡异。因为临界阻尼算法理论上是不过冲的但是如果你在update里一不小心把目标位置直接覆盖成了摄像机当前位置就会看起来像过冲。排查方法是把target和position都打印出来看有没有一帧里两者突然接近。另一个常见原因是你在smoothDamp的实现里没有正确保存velocity每帧都新建了一个零向量这样算法退化成某种有限差分肯定会震荡。5.3 触摸坐标和世界坐标对不上这个坑我印象最深。玩家点击屏幕你想在点击处生成一个角色结果位置偏了半个屏幕。问题出在screenToWorld的参考坐标。GestureDetector返回的localPosition是针对当前 Widget 的局部坐标。如果你在CustomPaint外面还包了SafeArea、边距或者 Container局部坐标并不是从画布原点开始的。正确做法是拿details.localPosition直接在当前 CustomPaint 的坐标空间做换算调整screenToWorld的基准点。还有一个细节OpenHarmony 的触摸上报可能会经过壳工程转发在某些设备上会有坐标偏移。我遇到过顶部状态栏高度导致全局坐标和 Flutter 视图内坐标差了 24 逻辑像素的情况。排查办法是打印原始事件坐标再和 Flutter 容器在原生窗口里的位置做减法。这个问题通常只在移植游戏到 OpenHarmony 原生壳时出现纯 Flutter 应用反而不会。5.4 窗口变化后画面比例错乱OpenHarmony 应用支持窗口缩放也可能从竖屏切横屏。摄像机视口如果一直用旧尺寸地图显示比例就会拉扁或者拉长。我的做法是监听 Flutter 侧的尺寸变化在LayoutBuilder或MediaQuery改变时调用_camera.viewportSize size。但要注意不要在build过程中直接修改摄像机状态否则会触发重新布局。把尺寸更新放到didChangeDependencies或者WidgetsBinding.instance.addPostFrameCallback里。更稳的方案是让摄像机不直接持有Size而是持有ValueNotifierSize由外部监听尺寸变化。这样摄像机逻辑和渲染层解耦排查问题也容易得多。6. 调手感的一些个人体会写到这里摄像机系统的核心内容基本都覆盖了。最后分享几个我每次调手感都会用的小方法。第一个方法是“录像回看法”。不要只看游戏视角而是把摄像机的位置、目标位置、玩家速度都录下来然后用调试工具把这三条轨迹画在同一张图上。你会发现手感不好通常不是某个参数的问题而是“跟随曲线”在某段时间内斜率突变。比如玩家突然转向时摄像机需要做一个非常大的速度变化这个瞬间就是玩家觉得“镜头顿了一下”的根源。第二个方法是把 smoothTime 和偏移平滑时间分开调。我建议先调smoothTime让跟随本身达到“看一眼就觉得跟手”的程度再调偏移。因为偏移的过度平滑会掩盖跟随问题的本质两个参数一起调最后很容易陷入“越调越不知道改谁”的泥潭。第三个方法也是对 OpenHarmony 上跑 Flutter 游戏尤其重要的一定要在低帧率场景下测试。不要只盯着 60 FPS 的模拟器试着限制 CPU 频率或者同时开几个后台应用看看摄像机在 30 FPS 甚至 20 FPS 下是否还能保持平滑。指数平滑和临界阻尼平滑理论上都帧率无关但实际实现里如果dt突然变大很多算法的中间量会溢出导致镜头跳一下。我在代码里会在计算dt时做一次 clamp限制最大帧间隔不超过 0.1 秒避免切后台回来之后镜头瞬移。最后我想说摄像机手感是个很主观的东西不存在放之四海而皆准的参数。我在一个 2D 横版平台上用的 smoothTime 是 0.09偏移距离是 50 逻辑像素在另一个俯视角 RPG 里smoothTime 却要提到 0.35因为镜头太灵敏会让玩家眩晕。你要做的是把系统抽象出来让每个参数都能独立调整然后带着“这个镜头是要表达速度感、情绪还是纯粹的功能性视角”的意识去调它。这样做出来的镜头才会让人感觉不是一套生硬的贴墙跟踪而是真的有“摄影指导”在运镜。