首页 / 资讯中心 / 文章详情

Canvas程序化动画实战:让锦鲤自然游动的运动规律

Canvas程序化动画实战:让锦鲤自然游动的运动规律 ★ FEATURED ARTICLE
看到这句话我第一反应是这项目名起得太真实了——从0开始搞一条锦鲤重点是“动起来”。很多人做动物动画第一步就栽在“动”上画了一条很漂亮的鱼却不知道怎么让它游得自然。这篇文章就讲清楚一件事怎么用代码让一条锦鲤在水里游起来而且不是那种贴图平移的假游是带身体摆动、尾鳍甩动、路径有转弯弧度的真游。核心就三个字动规律。适合做前端动效、Canvas动画、数字艺术的朋友哪怕你完全没写过动画按着思路走也能做出一条像样的鱼。1. 先想明白这项目到底是要做“一条假鱼”还是“游动的规律”1.1 拆解标题里的隐性需求标题里面藏着两个关键信息一个是“锦鲤”一个是“游动”。很多人只注意到了锦鲤的视觉性觉得重点是把鱼画好看把鳞片和花纹画精致。但真正决定观感的是“游动”这两个字——鱼的动作是否自然是否让人一眼看上去就觉得“这是条活鱼”。所以这个项目的真正核心不是美术而是运动规律的程序化表达。锦鲤的游动有几个显著特征身体呈S形波浪摆动、尾鳍是主要动力来源、转向时有一个圆润的弧线而不会直角折返。你要用代码模拟的就是这套运动逻辑而不是简单地设一个x坐标让它往右走。把这个想清楚后面所有技术选型都会有方向路径用贝塞尔曲线而不是直线身体用分段绘制而不是整图贴片摆动用正弦波叠加而不是写死动画帧。每一项选择都有对应的“为什么”这不是炫技是锦鲤游动本来的物理逻辑要求。1.2 技术方案对比贴图常移动画 vs 程序化生成动画做鱼动画圈子里最常见的两派做法第一派是美术做序列帧把一条鱼从上游到下游的几十帧图全画好程序按帧播放。第二派是程序化生成用代码实时计算鱼每一帧的姿态、位置、身体弯曲程度。序列帧方案的优点是效果稳定可控特别适合风格固定的游戏角色。缺点也很明显动作是死的鱼只能沿预定方向游想加一条鱼就得重新花美术成本做一套方向不同的素材如果要鱼在水里随机转向、追逐目标序列帧基本撑不住。程序化生成动画的优缺点正好反过来——学习成本高一些要理解运动规律怎么写但一旦做出来一条鱼的骨架可以套到十条鱼身上方向、速度、转弯半径全部参数化随便改。我今天聊的是程序化方案因为你既然标题写了“从0开始”那大概率是想掌握原理而不是想找现成素材。另外程序化方案在动效落地、交互动画这类场景里扩展性更强这个优势会越用越明显。2. 锦鲤身体怎么画分层建模是让“动起来”不穿帮的前提2.1 锦鲤的视觉特征拆解动手写代码前先把鱼的视觉结构拆开。锦鲤不是一条块状色带它有非常清晰的三段视觉层次身体主干、斑纹层、鱼鳍层。身体主干就是那条白色或红色的流线型躯干从头部向尾部收窄最宽处在躯干前三分之一处。斑纹是锦鲤最标志性的视觉元素红白黑三色大块斑驳分布边界自然但不规则。鱼鳍层包括胸鳍、背鳍、尾鳍三块其中尾鳍最为关键因为摆动幅度最大、频率最高是整个游动视觉动效的核心输出口。分层画的意义在于身体主干是刚性的、只是在整体旋转和位移斑纹附着在主干上跟随移动尾鳍则有大范围的独立摆动。如果这三层混在一张图上画后期做摆动效果时必然穿帮——鱼尾一摆整条鱼都在变形看着就像一条橡皮锦鲤。分层的方案可能前期代码多一点但后续调游动效果时你会发现每一层都能独立地“动”这是最干净的做法。2.2 Canvas绘制实现身体轮廓、斑纹层、三层鱼鳍我用Canvas来实现这套分层绘制不用任何图形库因为Canvas的路径API足够完成这件事而且方便做动态变形。身体主干用两条贝塞尔曲线拼出鱼身轮廓。先定鱼头方向比如鱼头朝右那身体上轮廓从吻端开始经过背部最高点后收向尾柄下轮廓对称画出。中间填充白色作为底色再用二次贝塞尔曲线在躯干区域画出红色斑块颜色过渡处保留一点alpha渐变让斑纹有“嵌入身体”的感觉而不是浮在表面。鱼鳍分成三组独立处理。胸鳍用一段窄三角形路径固定在身体前部绘制时加一个基于时间t的摆角背鳍沿躯干背部画一条平缓波浪线尾鳍最特殊它是身体末端一个宽大的分叉结构我用一个五点定位法绘制尾柄点、上叶尖、下叶尖、中间缺口点、尾柄下缘。这样尾鳍可以单独控制张开角度和左右摆动。尾鳍摆动的幅度是胸鳍的三到四倍频率也更快这样才符合“尾鳍驱动”的视觉逻辑。给斑纹扩一点白底红纹是经典配色但如果加水底阴影和背光观感会立刻不一样。我在整体绘制结束后对整条鱼叠加一个径向渐变遮罩模拟水下的明暗关系。这个遮罩不动只在鱼身变向时跟着旋转就能让锦鲤从“平面插画”变成“水下生物”。2.3 为什么分层而不是画一整张图继续说分层的问题这是很多新手会质疑的地方“一张图画完多省事干嘛分成三层”我的回答很直接因为你要的是程序化游动不是静态图。如果鱼是贴图你只能对整张图做平移、旋转、缩放。想模拟尾鳍摆动只能整体轻微摆动整张图效果跟真的鱼游动差距非常大。分层之后尾鳍是独立节点它的摆动角度可以单独驱动胸鳍可以按不同节奏拨水身体主干可以保持刚性只做整体偏转。这样三条线各自运动叠加到一起才是完整自然的鱼游动态。另外分层对将来做变体也友好换一套斑纹颜色就能得到一条新品种的鱼调尾鳍摆动参数就能从锦鲤变成金鱼的游姿。代码层面的复用性让你不用每次从零画起这是程序化动画最实在的收益。3. 游动路径鱼不能直线走路径要带“惯性圈”3.1 从人到鱼的运动直觉差异画路径前先想想人走路和鱼游动在轨迹上的本质差异。人走路从A点到B点一般是直线过去最多到终点时拐个弯。鱼不一样它几乎没有“直线”这个概念。鱼游动时头部方向永远微微偏离前进方向身体在持续修正这导致它的实际运动轨迹是连续的大弧形或S形曲线。有一个特别容易踩的坑新手为了让鱼“动起来”会让鱼沿着直线或线段组成的路径走结果视觉上看起来像一条鱼贴着轨道滑行机械感极强。原因在于直线路径没有给鱼留下“调整姿态”的空间——鱼的前进方向是瞬变的而真实的鱼即使细微地转向也有一个缓冲半径。这个缓冲半径说白了就是鱼的“转弯惯性圈”它在改方向时会先沿着当前方向多滑一点再平滑地切到新方向。我建议任何鱼游路径更新都必须带这个惯性逻辑否则怎么调都像纸片。3.2 分段贝塞尔路径生成算法所以我的做法是把鱼的游动路线设计为分段三次贝塞尔曲线每一段的起点和终点都由“目标点”和“当前点”之间的偏移决定。具体算法流程是这样的预先给鱼设定一组随机目标点相邻目标点之间生成一条三次贝塞尔曲线。生成时控制点不随机取而是取“从目标点沿当前方向延长一定距离”的位置。这样生成的每一段路径在连接处切线方向连续鱼游动到路径切换点的时候不会突然“折角”而是自然地平滑过渡过去。要特别控制曲线的控制点距离。控制点离目标点太近路径会往目标方向急速收缩产生尖锐夹角太远则路径过度延展鱼会在原地打大圈。我调试下来控制点距离取目标点间距的0.3到0.5倍比较合适。这一套下来鱼游动的轨迹就是从一个大弧到另一个大弧的连续曲线每段之间没有速度突变加上后面的摆动就是一条鱼“巡游”的感觉。3.3 沿路径前进的速度与转向控制路径生成出来只是路有了鱼还得在上面走。鱼沿贝塞尔曲线的速度控制是另一个关键点如果每帧都从路径起点重算那鱼的全程速度一快一慢像在踩油门刹车。我的处理方式是用“弧长参数化”。贝塞尔曲线本身的参数t是均匀的但t均匀不代表空间距离均匀——曲线陡峭的地方t走一小段实际距离可能很长。所以要先对路径做距离采样预计算一张“t值到弧长”的映射表。每一帧以“固定像素速度”前进在映射表里反查当前弧长对应的t值取到真正的位置坐标。这样鱼在贝塞尔路径上保持稳定速度入弯出弯也流畅。转向控制也在这层处理。鱼头方向不能只取当前速度方向的切线否则路径切换时鱼头会瞬间甩动。我做一个“方向滞后”处理目标方向是当前路径切线方向实际绘制方向每帧朝目标方向靠拢有限角度这个角度跟鱼速相关速度越快转向越灵敏但有一个上限。这样鱼转弯时头部会自然甩出一个偏移看起来就像它在主动调整姿态而不是被轨道带着走。3.4 实现示意代码下面这段是路径驱动的核心逻辑我抽掉了绘制细节只保留运动循环。代码框架是用JavaScript放在浏览器里能直接看到鱼朝目标点游过去。// 目标点路径生成与控制点计算 function buildPath(from, to, prevDir, segLen) { const dir normalize(to.sub(from)); const ctrl1 from.add(prevDir.scale(segLen * 0.4)); const ctrl2 to.sub(dir.scale(segLen * 0.4)); return new CubicBezier(from, ctrl1, ctrl2, to); } // 每帧更新鱼的位置和朝向 function updateFish(fish, dt) { fish.dist fish.speed * dt; // 弧长前进 const t fish.path.getTByArcLength(fish.dist); const pos fish.path.getPointAt(t); const tangent fish.path.getTangentAt(t); // 转向滞后 const targetAngle Math.atan2(tangent.y, tangent.x); fish.angle approachAngle(fish.angle, targetAngle, fish.turnRate * dt); fish.x pos.x; fish.y pos.y; }注意getTByArcLength就是上面说的弧长映射表查询这一步在路径初始化时算好运行时只做查表。approachAngle是带角度回绕的角度逼近函数否则鱼在0度和360度之间会乱转一圈。4. 游起来身体的波浪摆动与尾鳍动态4.1 鱼游动的波形规律路径解决了鱼去哪的问题摆动解决的是鱼怎么“看着像自己游”的问题。真实鱼类游动时身体从头部到尾部传递的波是一个渐进衰减的S形前半身振幅小后半身振幅大尾鳍位置振幅最大。这个波形规律直接决定了身体每一段的偏移量。我把它简化成一条正弦波沿鱼身传播在鱼身上取几个控制点越靠近尾部相位延迟越大振幅也越大。用几个点来描述鱼的姿态头部控制点振幅基本为0躯干中段有微小位移尾部大幅偏移。把这条波随时间向前推进鱼身就有了连续波动感。头部的“零振幅”特别重要。很多动画把鱼做成了整条鱼左右摇晃看起来像在打摆子。真实鱼眼、鱼头在水里是很稳定的晃动集中在后部。头稳尾摆这是鱼游动视觉真实感的底线。4.2 头/身/尾三节点相位如何分配具体实现时我把鱼身拆成三个关键节点鱼头、鱼身中段、尾柄。三个节点都受到同一个摆动源的驱动但振幅和相位不同。鱼头不直接受摆动偏移只受整体朝向影响。鱼身中段以振幅A1跟随摆动相位比鱼头滞后一个角度比如30度。尾柄振幅最大相位再滞后30度。这样从鱼头到尾柄就形成了一条相位依次递进的弯曲链。相位差的设定直接决定鱼游得“顺不顺”。差太太少鱼身变形成一条弓背生硬差太大整条鱼拧成麻花。我调参时用了一个参照标准鱼尾每摆动一个完整周期身体中段最多只走完半程的相位位移。换成人话说尾部甩完一次腰部刚跟到一半这是最接近真实鱼的节律。尾鳍则在此基础上再叠加一个独立的高频小幅摆动模拟刺尾的细碎动作。4.3 摆动幅度、频率与速度的关系游速越快摆动的频率应该越高但真正的技巧在于摆动频率和游速不是线性同步的。鱼提速时首先是增加单次摆动的力度幅度然后才是提高频率。反过来鱼减速时频率下降比幅度下降更明显。我用的参数组合是摆频与速度成0.6次幂关系摆幅与速度成1.4次幂关系。这样在中低速段频率先温和上升幅度抬升较快高速段的频率不会飙得太夸张幅度已经接近上限。用具体数据举例鱼速为60像素每秒时尾鳍摆动频率大约1.5Hz摆幅角度35度速度到120时频率2.2Hz摆幅55度。这两个参数作为全局基础值再叠加一点点随机噪声每条鱼的节奏就不一样不至于像一个模子刻出来的队列。为了不让鱼的摆动僵硬我用的是正弦波但不会是纯正弦。游动时前半段加速后半段回位的特征用带点不对称的波形来模拟比如基于正弦的二次幂。这样摆过去快、摆回来慢鱼游动会有一种“蓄力出弓”的感觉跟纯正弦那种匀速摆差距很大。4.4 转向时姿态修正不翻肚的秘诀鱼转弯时不光位置沿着贝塞尔路径走身体姿态也需要额外修正否则会出现两个特别假的效果一是鱼头转向了身尾还直挺挺地朝旧方向像一条被拉扯的面条二是鱼身体侧倾过头看起来像要翻肚。我的做法是引入一条“姿态中心线”。正常情况下鱼的身体中线是头尾连线。转弯时路径曲率越大鱼会倾向把身体往弯道内侧弯产生一个额外的“离心补偿”——这个补偿用路径的曲率半径来计算曲率越大弯越急补偿角度越大。把补偿角度叠加到鱼身三个节点的相位位移上鱼在转弯时身体会呈现一个自然的侧弯配合头部方向就不会出现头和身体分家的违和感。侧倾过度的预防鱼的身体偏转角度永远限制在正负35度以内。一旦超过用阻尼系数把回正速度拉大让鱼快速恢复水平姿态。这个阻尼不能太猛不然鱼过了个急弯后会像弹簧一样左右震荡。5. 体验调优与常见问题排查5.1 鱼翻肚、抖动、形态撕裂实操中几乎每个人都会碰到的三座大山鱼翻肚、游动抖动、身体撕裂。鱼翻肚的根源通常是转向叠加出问题。比如方向滞后和路径切线叠加后出现0度和360度回绕角度过渡没做回绕处理。还有一个隐藏原因鱼的X轴缩放和旋转的乘序错了——Canvas里先scale再rotate和先rotate再scale对鱼的方向影响截然不同。我把鱼的绘制顺序固定为平移定位、旋转朝向、再按鱼身局部坐标缩放。这个顺序一乱鱼在转向过程中就会横过来甚至倒过来。抖动多半来自弧长映射表的精度不够。贝塞尔曲线采样点太少弧长插值误差偏大鱼每帧微调幅度就会出现肉眼可见的抖动。把采样数提高或者对弧长表做分段线性插值抖动基本能消除。运动时如果鱼身点位的振幅有突变也容易引起抖动这时要给摆动幅度加一个低通滤波让每帧变化幅度平稳过渡。身体撕裂就是分层绘制时各层节点没有对齐。尾鳍有自己的摆动逻辑但尾鳍根部必须与尾柄末端重合否则游起来会出现尾巴“掉下来”的穿帮画面。每次更新时先更新身体主干和尾柄的位置再把尾鳍的根节点锁定到尾柄末端按尾柄末端的位置来摆动。5.2 性能优化从每帧重建到缓存静态层Canvas动画的常见难点是绘制一多帧率就掉。我的优化思路是把鱼的渲染拆成静态层和动态层。静态层包括鱼身轮廓、斑纹、渐变遮罩这些不随时间变化的元素。动态层只有鱼鳍摆动和整体姿态变化。做法是把一条鱼的静态层在内存中离屏渲染成一张Canvas位图每帧动画只需要drawImage贴这张位图再叠加动态的胸鳍、尾鳍。这样每条鱼每帧的路径绘制量从三四十个贝塞尔点缩减到三四个性能提升非常明显。实测下来同样场景下不缓存时同时渲染12条鱼帧率掉到40fps左右缓存静态层后同帧率轻松跑满60fps还能再塞额外的动态效果。如果你要渲染几十条鱼的鱼群这个缓存优化就是必备方案。还有一个小技巧鱼在转向时是镜像还是重绘如果鱼的斑纹不对称直接镜像会导致花纹反穿。这时候要靠“高分辨率离屏缓存方向旋转”而非简单的scale(-1, 1)来处理。5.3 多鱼共存错相位、错路径、错速度单独一条鱼调好只是第一步多鱼同时游才是“鱼池氛围感”的正确打开方式。但多鱼一叠加如果每条鱼都一样参数画面就会呈现某种诡异的一致性像雨刮器队列。解决办法就是参数差异化每条鱼的路径目标点不同、初始摆动相位不同、摆动频率有小幅偏移。我初始化鱼时给每条鱼生成一个随机初始相位0到2π这样即使它们并排游身体曲线也完全不一样。速度上也做随机偏移比如基准速度上下浮动15%鱼群内自然形成前后错落的位置。鱼与鱼之间还需要避免引力干扰。当两条鱼太靠近时给其中一条施加一个横向推力把它们的间距拉开。这个推力的优先级高于路径跟随但幅度要远低于路径主方向力量否则鱼会出现“被弹开”的机械反应。5.4 常见问题速查表现象可能原因处理方案鱼转头时整条翻过来旋转与缩放顺序错误统一采用先平移、再旋转、后缩放游动时位置抖动弧长映射表采样不足增加路径采样点数做分段插值尾巴甩动太大身体没跟上相位差太小增大尾柄相对中段的相位差鱼看起来在“滑冰”而非游动摆动幅度不足或频率与速度不匹配按速度非线性调幅调频转弯时身体与头部分离欠缺曲率补偿叠加基于路径曲率的侧弯角度多条鱼动作整齐划一参数未随机化错开初始相位、摆频和游速6. 工程化视角让这套动画从Demo变成可扩展的组件6.1 把参数抽成配置文件如果代码里所有魔法数字都散落在各段逻辑中后面调参就是一场灾难。我建议把鱼的体长、游速基准、摆频基准、摆幅上限、转弯灵敏度、相位差这些参数全部抽成一个配置对象每条鱼的实例化都基于配置并提供随机扰动入口。这个做法的好处不是“代码整洁”这么空泛。实际调参时你可能需要同时对比十组参数下的鱼游效果有配置化之后可以直接遍历配置组批量跑效果而不是改一个数刷新一次页面看不清差异。我习惯把配置抬到一个统一命名空间比如fishConfig对象里面再按“基础形态”“游动节律”“路径行为”“渲染优先级”分四组拆好。新人拿到这个配置也能快速改出不同风格的鱼不用在绘制代码里东摸西找。6.2 状态机的必要性鱼的游动状态其实不止“慢速巡游”一种。一条自然状态的鱼会在巡游、加速冲刺、静止悬浮、受惊急转这几个状态之间来回切换。用状态机来管理鱼的行为要比在update里堆一堆if else清晰得多。每帧先判断状态是否需要切换然后只执行当前状态对应的逻辑。状态切换时注意不要突然改变速度或摆动参数加一个线性插值过渡让鱼在切换动作时也保持行为连续性。举个很常见的例子鱼冲刺时速度瞬间拉高如果摆幅也瞬间拉大视觉上会像被电击加一个0.3秒的过渡就能自然很多。状态机在这里的价值不只是结构的清晰也是行为真实感的关键。6.3 从单条鱼到交互场景的扩展思路这套锦鲤游动实现到底还能往外扩哪些方向我列几个自己觉得特别有价值的后续路径供你参考。用户可以给鱼投食鱼群追踪食物目标点游动这时贝塞尔路径的目标点就不只是随机点了而是动态落点。交互类场景里可以用鼠标点击作为鱼群的目标点切换鱼群需要平滑改变路径而不是立刻瞬移这就把第一节的“惯性圈”思想复用上了——路径重新规划时不能丢弃原有速度方向新的路径起点必须用当前运动方向来生成控制点。水面波纹、水草摆动这类环境层可以和鱼的波纹共用一套波形生成逻辑。它们之间的相位可以互相错开形成统一的“水下律动感”。这套波纹逻辑我已经验证过直接复用的话环境层的开发成本很低。自己把一个从零开始的锦鲤游动做通之后最大的感受其实是复杂视觉动效拆到最后都是一层一层的简单规律在叠加。鱼会游了不是因为你写了一堆复杂公式而是你把“身体分层”“路径带惯性”“波形相位递进”这几件小事做对了。最后再分享一个调参时的心法先调路径再调摆动最后调多鱼协调这个顺序不要乱。我接过很多次“游动效果不自然”的求助最后问题都不是出在最后一个环节而是一开始就把鱼的位置用直线规划了后边摆动做再好鱼也像在冰上跳交谊舞。如果你也做一个类似的锦鲤动画建议先跑通单条鱼找一条你觉得“动起来真的顺眼”的参数组合保存好再复制给其他鱼随机扰动。这条路走通之后你会忽然发现水下一个鱼群的动态效果已经没有想象中那么神秘了。
阅读完成 · 觉得有帮助?
咨询建站