看到这句话,我第一反应是:这项目名起得太真实了——从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 从单条鱼到交互场景的扩展思路
这套锦鲤游动实现到底还能往外扩哪些方向?我列几个自己觉得特别有价值的后续路径,供你参考。
用户可以给鱼投食,鱼群追踪食物目标点游动,这时贝塞尔路径的目标点就不只是随机点了,而是动态落点。交互类场景里可以用鼠标点击作为鱼群的目标点切换,鱼群需要平滑改变路径,而不是立刻瞬移,这就把第一节的“惯性圈”思想复用上了——路径重新规划时不能丢弃原有速度方向,新的路径起点必须用当前运动方向来生成控制点。
水面波纹、水草摆动这类环境层可以和鱼的波纹共用一套波形生成逻辑。它们之间的相位可以互相错开,形成统一的“水下律动感”。这套波纹逻辑我已经验证过,直接复用的话,环境层的开发成本很低。
自己把一个从零开始的锦鲤游动做通之后,最大的感受其实是:复杂视觉动效,拆到最后都是一层一层的简单规律在叠加。鱼会游了,不是因为你写了一堆复杂公式,而是你把“身体分层”“路径带惯性”“波形相位递进”这几件小事做对了。
最后再分享一个调参时的心法:先调路径,再调摆动,最后调多鱼协调,这个顺序不要乱。我接过很多次“游动效果不自然”的求助,最后问题都不是出在最后一个环节,而是一开始就把鱼的位置用直线规划了,后边摆动做再好,鱼也像在冰上跳交谊舞。
如果你也做一个类似的锦鲤动画,建议先跑通单条鱼,找一条你觉得“动起来真的顺眼”的参数组合,保存好,再复制给其他鱼随机扰动。这条路走通之后,你会忽然发现,水下一个鱼群的动态效果,已经没有想象中那么神秘了。