1. 为什么一张锦鲤图永远不"活"——先把动起来的逻辑拆干净
前阵子刷到一个个人主页,背景是一条大锦鲤,配色、构图都很讲究,唯一的问题是——它是张静态图。看着那条鱼在水里"凝固"了十几秒,我心里就开始痒,干脆自己动手从0开始搞一个会动的锦鲤游动效果。
先说结论:想让鱼"动起来",最忌讳的做法是给一张鱼图片加个左右来回的位移。那样鱼看起来是在滑冰,不是在游泳。真正的游动感来自鱼身体的变形——头往前顶、身子呈波浪状摆动、尾巴大摆幅扫水,这是锦鲤游动的灵魂,也是这个项目最值得花时间的地方。
这个项目做完之后我能让你看到:
- 一条完全用代码画出来、不依赖任何图片素材的锦鲤
- 鱼沿着我指定的路径游,头朝行进方向,转弯时身体自然弯过去
- 尾鳍、胸鳍、背鳍各自摆动,幅度不同、节奏不同
- 背景有水波纹、光斑和上浮气泡,整体像一缸水
适合谁看?如果你已经在用canvas画过圆、画过矩形,知道requestAnimationFrame是干嘛的,但想做点有生命力的东西,这篇文章就是给你写的。我会把从数学模型到渲染细节一条条讲清楚,保证不是贴代码让你自己琢磨。
这个项目的核心不复杂,但需要先把概念拆开。一条鱼在画面里"动起来",其实是三个独立问题同时解决:
- 位移:鱼整体往哪移动,怎么沿着路径走
- 姿态变形:行进过程中鱼身体怎么扭动,让动作看起来真实
- 环境融合:水波、光、气泡这些氛围元素怎么配合,让鱼不像是贴上去的
这三个问题相互独立,但最后要耦合在一起。我先说我的选型为什么是Canvas 2D,而不是 CSS 动画或者 SVG。
CSS 动画擅长操控已有的 DOM 节点,你要是想"让某个元素 从 A 点移到 B 点",它很合适,但如果要"每一帧重新计算鱼身上 12 个节点的位置并重新绘制轮廓",CSS 的表达力不够。SVG 可以用路径变形做尾巴摆动,但动态刷新大量节点的成本高,而且做复杂水纹不如 Canvas 直接。Canvas 2D的ctx.beginPath()+lineTo()+bezierCurveTo()就像是给了我一块画板,每一帧我想怎么改就怎么改,最适合这种逐帧变形的场景。
你不需要担心性能,后面会专门讲优化。先把鱼本身做出来。
2. 鱼的身体不是画出来的,是算出来的——脊线节点设计
大多数人画鱼的习惯是:先画一个椭圆当身体,加个三角尾巴,再点两个眼睛。这种画法画静态图没问题,但要让它动起来就麻烦了——因为整个身体的形状没法"变"。
我的做法是:只画一条脊线,然后用这条脊线"撑"出鱼的身体。
所谓脊线,就是从鱼头到鱼尾的一条弯曲的中心线。鱼的轮廓其实是这条中心线两侧的半宽度决定的。头部的半宽度大,越靠近尾部越细,到最后收成一个尖。这条脊线每一帧都在变化,所以鱼的轮廓也跟着变,游动的扭曲感就出来了。
2.1 节点数组就是一条鱼
具体实现上,我会在鱼的内部维护一个数组,里面存着 N 个节点的位置。从 0 到 N-1,分别代表从头部到尾部。每个节点每帧都更新坐标,然后绘制的时候把这些节点连成轮廓。
class Koi { constructor(x, y, length = 120) { this.x = x; this.y = y; this.bodyLength = length; this.segments = 12; // 脊线节点数 this.nodes = []; for (let i = 0; i < this.segments; i++) { this.nodes.push({ x: 0, y: 0 }); } this.heading = 0; // 当前朝向(弧度) this.turnSpeed = 3.0; // 转向速度 this.swimSpeed = 60; // 游动速度,像素/秒 this.wobblePhase = Math.random() * Math.PI * 2; // 摆动相位随机,让每条鱼不同步 } }这里有个关键思路:节点并不是鱼身上的"贴图位置",而是鱼身体的骨架。每一帧我先算出一条平滑的脊线,然后把节点放到脊线上,最后按节点位置绘制鱼身轮廓。你甚至可以理解为,鱼是个"脊柱动物",节点就是它的脊椎骨。
那脊线怎么算?这涉及到鱼的朝向和摆动。
2.2 鱼头方向与侧向偏移
假设鱼头当前在位置(this.x, this.y),朝向角度是heading。我先算出两个向量:
- 前方向量
forward:(Math.cos(heading), Math.sin(heading)),用来表示鱼往哪游 - 侧向量
perp:(-forward.y, forward.x),指向鱼的侧方,用来做摆动偏移
第i个节点的位置是:
const t = i / (this.segments - 1); // 0 表示头部,1 表示尾部 const along = t * this.bodyLength; // 离头部的距离 // 摆动偏移量:从头部到尾部逐渐增大 const sway = this.getSway(along); nodes[i].x = this.x - forward.x * along + perp.x * sway; nodes[i].y = this.y - forward.y * along + perp.y * sway;看到重点了吗?离头部越远(along越大),摆动幅度越大。鱼的头部要保持稳定,尾部才大幅摇摆。头和尾之间是连续的过渡,这样鱼就不是一个刚体,而是一条会扭动的曲线。
实际上,第一帧跑起来的时候你可能会发现鱼在画面上滑行,但身体还是直的。那是因为sway还没起作用。下一步我们就把摆动函数做出来。
3. 正弦波相位差:鱼尾摆动的真实触感
鱼在水中游动,身体呈现的是一种行波:从头部往后传播的波浪。实现这个效果,用正弦波就够了,关键是相位沿着身体往后延迟。
想象一下你站在海边看波浪:同一列波,远处的浪尖和近处的浪尖不是同时到达的,它们有一个时间差。鱼的脊柱运动也是这个道理——尾部摆动的动作总是比头部慢半拍。这个"慢半拍"在代码里就是相位差。
3.1 位置性相位延迟
我的getSway函数长这样:
getSway(along) { // 振幅系数:头部小,尾巴大。用平方曲线模拟真实鱼的肌肉分布 const amplitudeFactor = Math.pow(along / this.bodyLength, 1.5); // 相位延迟:越靠尾巴,相位滞后越多 const phaseDelay = along * 0.1; // 主摆动频率,通过时间累积 return Math.sin(this.wobblePhase - phaseDelay) * 12 * amplitudeFactor; }几个参数的讲究:
amplitudeFactor用的是 1.5 次方曲线,不是简单的线性,也不是平方。线性会让尾巴看起来摆得突然,平方会让前半段几乎没动作。1.5 次方是从头到尾"渐入佳境"的感觉,既稳定又灵活phaseDelay = along * 0.1,0.1 是相位延迟系数,它决定了整条鱼的身体上同时能看到"几个波",系数越大,身体弯曲越夸张,鱼看起来越"绕"- 12 是最大摆动幅度(像素),这个数值跟鱼身长度有关,大概鱼身长度的 1/10
你就想,正弦波的输入是wobblePhase - phaseDelay,wobblePhase随时间不断增加,于是波形从头部往尾部"流"过去。鱼看起来就是在游动,而不是在颤抖。
这里有个实际调参感受:初始值调好之后,你改动任何一个参数都会立刻改变观感。我把常用参数整理成了对照表,方便你快速找到手感。
| 参数 | 位置 | 作用 | 调大后效果 | 建议范围 |
|---|---|---|---|---|
amplitudeFactor的指数 | 摆动强度函数 | 控制尾巴摆动幅度分布 | 尾部摆动更夸张,头身更稳 | 1.2 ~ 1.8 |
phaseDelay系数 | 相位差计算 | 控制身体弯曲程度 | 身体更卷,转弯感强,过头会绕成麻花 | 0.05 ~ 0.15 |
| 摆动频率 | wobblePhase增长速度 | 控制摆动快慢 | 游动更急促 | 每秒 2 ~ 4 次完整摆动 |
| 最大摆动幅度 | 12 这个常数 | 尾部最大偏移 | 摆动更猛,太大就抽搐了 | 鱼身长度的 8% ~ 15% |
提示:第一次调试时不要频繁改参数,先跑起来,然后只改一个参数,观察两秒。一次改太多你根本不知道是哪个参数让鱼变怪了。
3.2 鱼尾、鱼鳍各有各的节奏
身体摆动是主干道,但一条鱼真正让人觉得"活"的地方在鳍。锦鲤的尾鳍特别大,游动时它的摆动频率其实跟身体摆动是同步的,但幅度更大、延迟更多。我单独给尾巴做了一个函数,在draw阶段覆盖:
drawTail(ctx) { const tailNode = this.nodes[this.segments - 1]; const prevNode = this.nodes[this.segments - 3]; // 尾巴摆动方向:取最后两个节点的连线方向,加上大幅正弦摆动 const tailAngle = Math.atan2(tailNode.y - prevNode.y, tailNode.x - prevNode.x); // 额外叠加一个摆动 const extraSway = Math.sin(this.wobblePhase - this.bodyLength * 0.08) * 15; ctx.save(); ctx.translate(tailNode.x, tailNode.y); ctx.rotate(tailAngle); // 画一个扇形尾鳍 ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(10, -20 - extraSway, 30, -extraSway * 0.6); ctx.quadraticCurveTo(40, -extraSway * 0.2, 35, extraSway * 0.5); ctx.quadraticCurveTo(15, 18 + extraSway, 0, 0); ctx.closePath(); ctx.fillStyle = 'rgba(255, 120, 80, 0.9)'; ctx.fill(); ctx.restore(); }胸鳍和腹鳍的摆动频率刻意低一些,幅度小一些,这样它们仿佛在"划水"而不是"抽搐"。我给它们用的是独立的相位,不跟身体摆动同步。各鳍参数可以参考:
| 鱼鳍 | 频率(相对身体) | 幅度 | 相位来源 |
|---|---|---|---|
| 尾鳍 | 同步 | 大(额外叠加) | 身体摆动相位加深延迟 |
| 胸鳍(两侧) | 略低 | 中 | 独立相位 |
| 背鳍 | 较低 | 小 | 独立相位加缓慢波动 |
用这种做法,你把频率调低之后,鱼就像是在悠闲地滑水;调高它,鱼就像受了惊吓在逃命。这比机械地加位移要自然得多。
4. 鱼要往哪游:轨迹规划与朝向转向
身体摆动做出来之后,鱼已经能在原地"扭"了,但它还是不走。接下来要解决位移问题。
这一步比想象中费神,因为它涉及到一个容易翻车的细节:转向。鱼不能像车子那样瞬间掉头,而且鱼转弯时头的朝向必须和运动方向一致,否则就会出现"鱼横着走"的怪异画面。
4.1 两种轨迹方式:点击跟随与预设路径
我先做了点击跟随。每次鼠标点击画布,就把目标点存下来,鱼每帧往目标点游。这是最直觉的做法,也能让你快速看到游动效果。
setTarget(x, y) { this.targetX = x; this.targetY = y; } update(dt) { const dx = this.targetX - this.x; const dy = this.targetY - this.y; const distance = Math.hypot(dx, dy); if (distance < 5) return; // 到达目标附近就不再追了 const targetAngle = Math.atan2(dy, dx); this.heading = lerpAngle(this.heading, targetAngle, this.turnSpeed * dt); // 朝当前朝向移动 this.x += Math.cos(this.heading) * this.swimSpeed * dt; this.y += Math.sin(this.heading) * this.swimSpeed * dt; this.wobblePhase += dt * 8; // 推进游动相位 }注意这里的lerpAngle,这个函数必须处理角度环绕问题。假设鱼当前朝向是Math.PI - 0.1(接近水平偏左),目标角度是-Math.PI + 0.1(也接近水平偏左,从负方向表示),直接把两个角做线性插值,鱼会愣是转一大圈从另一边掉头——这是新手最容易踩的坑。
正确的写法是把角度差归一化到[-Math.PI, Math.PI]之间:
function lerpAngle(current, target, t) { let diff = target - current; while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; return current + diff * t; }这样鱼转身永远走"近路"。配合turnSpeed在 2~3 左右,鱼会画出一个弧线转弯,看起来很自然。如果你想要鱼走一条固定路线(比如绕圈、八字游),可以用几组控制点做贝塞尔曲线,每隔一段距离设一个路点,鱼追着路点走。原理一样,只是target变成曲线上的采样点。
4.2 游速变化与"呼吸感"
完全匀速的游动也不对,活鱼会有细微的加速减速。我的做法是在swimSpeed上叠加一个低频正弦:
this.swimSpeed = 55 + Math.sin(this.wobblePhase * 0.25) * 8;这个 0.25 的频率很低,产生一个慢呼吸式的浮动。实际观感上,鱼看起来像是在"蓄力—爆发—滑行"的循环,比恒定速度高级一个档次。
同样要注意,转弯的时候如果游太慢会显得笨拙,游太快又失控。我的经验是转弯时把速度降低 20% 左右,给人"鱼在拐弯所以减速"的预期。具体做法:
const speedFactor = 1 - Math.abs(normalizeAngle(targetAngle - this.heading)) / Math.PI * 0.2; this.x += Math.cos(this.heading) * this.swimSpeed * speedFactor * dt;公式的意义是:当targetAngle和heading方向差别越大(差接近 180°),速度就越低。这个微小细节很多人忽略,但有了它鱼的动作才有人味。
5. Canvas渲染细节:从"轮廓"到"像条锦鲤"
骨架、运动、转向都有了,接下来是把这条鱼画得像样。系统学 Canvas 的朋友知道,ctx.fill()填充一个封闭路径是最基础的操作,但锦鲤那种"胖胖的、圆润的身体 + 大尾巴 + 半透明鳍"的质感,需要几个渲染技巧。
5.1 身体轮廓:贝塞尔曲线闭合
节点数组虽然定义了位置,但直接用直线段连接会显得棱角分明,尤其头尾连接处会露馅。我用quadraticCurveTo做平滑,把每个中间节点变成贝塞尔曲线的控制点,让轮廓圆润。
drawBody(ctx) { ctx.beginPath(); // 从头部出发,沿上侧轮廓画到尾部 const top = this.getContourPoints(1); // 1 表示偏上侧 ctx.moveTo(top[0].x, top[0].y); for (let i = 1; i < top.length - 1; i++) { const xc = (top[i].x + top[i + 1].x) / 2; const yc = (top[i].y + top[i + 1].y) / 2; ctx.quadraticCurveTo(top[i].x, top[i].y, xc, yc); } // 沿下侧轮廓闭合 const bottom = this.getContourPoints(-1); for (let i = bottom.length - 1; i >= 0; i--) { const xc = (bottom[i].x + bottom[i + 1].x) / 2; const yc = (bottom[i].y + bottom[i + 1].y) / 2; if (i === bottom.length - 1) ctx.lineTo(bottom[i].x, bottom[i].y); else ctx.quadraticCurveTo(bottom[i + 1].x, bottom[i + 1].y, xc, yc); } ctx.closePath(); }其中getContourPoints(dir)就是沿脊线节点,在垂直于前进方向的方向上偏移半宽度:
getContourPoints(dir) { const points = []; for (let i = 0; i < this.segments; i++) { const t = i / (this.segments - 1); const half = this.bodyWidth * (0.6 + 0.4 * Math.cos(t * Math.PI * 0.5)); const node = this.nodes[i]; // perpDir 是垂直于脊线的方向,dir 控制取左侧还是右侧 points.push({ x: node.x + this.perp.x * half * dir, y: node.y + this.perp.y * half * dir }); } return points; }bodyWidth大概是身体长度的 1/4 到 1/3。Math.cos那一项让身体前半段饱满、后半段收窄,符合锦鲤的体型。
5.2 渐变与色斑:凭配色认出是锦鲤
锦鲤辨识度高,靠的是橙白黑的色斑分布。我用一个patches数组记录色斑区域,绘制时在身体路径内裁切,然后在局部叠加半透明颜色:
drawPatches(ctx) { ctx.save(); // 先用身体路径裁切,保证色斑不出身体范围 ctx.clip(); for (const patch of this.patches) { const t = patch.t; // 色斑中心在身体长度的哪个位置 const node = this.nodes[Math.floor(t * (this.segments - 1))]; const radius = patch.radius; const grad = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, radius); grad.addColorStop(0, patch.color); grad.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(node.x, node.y, radius, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); }橙红斑'rgba(255, 100, 50, 0.8)',黑斑'rgba(30, 30, 30, 0.6)'。用径向渐变替代硬边色块,看起来跟真实鱼身上的晕染色泽更接近。实际操作中我把白底的身体填上之后,叠两三个色斑,一条锦鲤的感觉立刻出来。
5.3 鱼头的表情与点睛作用
身体做好之后,鱼头要单独处理。一个大圆弧是基本形态,但真正的关键是眼睛——白底黑瞳的高光点。眼睛的位置要放在鱼头的前部偏上部分,如果位置偏了整条鱼看起来就像瞎子。我把眼睛做成了两个小圆,加上一个小扇形高光:
drawEyes(ctx) { const headNode = this.nodes[0]; const forward = { x: Math.cos(this.heading), y: Math.sin(this.heading) }; const perp = { x: -forward.y, y: forward.x }; for (const side of [-1, 1]) { const eyeX = headNode.x + forward.x * 8 + perp.x * 6 * side; const eyeY = headNode.y + forward.y * 8 + perp.y * 6 * side; ctx.beginPath(); ctx.arc(eyeX, eyeY, 3.2, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.beginPath(); ctx.arc(eyeX + forward.x * 1.2, eyeY + forward.y * 1.2, 1.8, 0, Math.PI * 2); ctx.fillStyle = '#111'; ctx.fill(); ctx.beginPath(); ctx.arc(eyeX + forward.x * 1.6, eyeY + forward.y * 1.6, 0.7, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); } }这里有件有意思的事情:因为朝向每帧都在变,眼睛也跟着鱼头一起"看前方",所以鱼脑袋转,眼睛必定转,反而是最自然的。如果眼睛单独算朝向,你就会得到一只斜眼鱼。
6. 水波、光斑与气泡:氛围层的加法与减法
鱼本身做到位之后,我把它放进一个空画布里跑了一下,发现一个问题——鱼游得再好,如果没有参照物,画面就显得空,鱼像是在透明玻璃上滑。要让它"在水里",必须加氛围层。
氛围制作的原则是:少即是多,宁愿淡,不可抢。鱼才是主角,背景光斑再好看也不能让观众分心。
6.1 水底渐变与动态水纹
底部先用垂直渐变铺一层深青色(比如'#0c2b33'渐变到'#051318'),营造水体深度。然后在画布中间层叠加几条横向半透明波浪线,用正弦函数在动画循环里滚动:
function drawWaterSurface(ctx, time, width, height) { ctx.strokeStyle = 'rgba(140, 220, 255, 0.10)'; ctx.lineWidth = 1.5; for (let row = 0; row < 5; row++) { ctx.beginPath(); const baseY = height * 0.2 + row * height * 0.18; for (let x = 0; x <= width; x += 6) { const y = baseY + Math.sin(x * 0.01 + time * 0.8 + row * 1.2) * 8 + Math.sin(x * 0.023 - time * 0.5) * 5; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } }两个频率的正弦波叠加,会产生一种"水在流动"又不会太规则的视觉感受。关键是透明度压得很低,0.10这个量级只够给眼睛一个暗示,不会跳出来。
6.2 光斑:水面折射的斑驳感
数道大的 radial gradient 圆形光斑,透明度极低,缓慢漂移,模仿阳光穿过水面在水底形成的亮斑。我把光斑做成一个池子,每 6 个为一组循环:
const lightPatches = []; for (let i = 0; i < 6; i++) { lightPatches.push({ x: Math.random() * canvasWidth, y: Math.random() * canvasHeight, radius: 80 + Math.random() * 120, phase: Math.random() * Math.PI * 2, speed: 0.002 + Math.random() * 0.003 }); } function updateLightPatches(patches, dt, height) { for (const p of patches) { p.y -= p.speed * dt * 30; p.x += Math.sin(p.phase + performance.now() * 0.0002) * 0.05; if (p.y < -p.radius) { p.y = height + p.radius; p.x = Math.random() * width; } } } function drawLightPatches(ctx, patches) { for (const p of patches) { const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.radius); grad.addColorStop(0, 'rgba(255, 240, 180, 0.04)'); grad.addColorStop(1, 'rgba(255, 240, 180, 0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); ctx.fill(); } }0.04的透明度极淡,但多个光斑叠加起来,水面的波光感就出来了。这里不要用ctx.shadowBlur,那个属性性能代价很大,一轮光斑下来 FPS 直接掉 10 以上。
6.3 气泡:给水体增加纵深感
气泡是最后一个氛围元素。粒子系统实现,规则不复杂:
class Bubble { constructor(width, height) { this.reset(width, height); } reset(width, height) { this.x = Math.random() * width; this.y = height + 20 + Math.random() * 40; this.radius = 1 + Math.random() * 3; this.speed = 15 + Math.random() * 25; // 上浮速度,像素/秒 this.phase = Math.random() * Math.PI * 2; } update(dt, width, height) { this.y -= this.speed * dt; this.x += Math.sin(this.phase + this.y * 0.02) * 0.15; if (this.y < -10) this.reset(width, height); } draw(ctx) { ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.stroke(); } }气泡池用固定长度数组,维护一个活跃索引,超过底部的重置到顶部。不要每帧往数组里push新气泡,也不要splice,那样垃圾回收会让动画周期性卡顿。固定池子配合 reset,性能最稳。
氛围层的顺序建议:底色渐变 → 光斑 → 水纹 → 气泡 → 鱼。鱼最后画,保证它永远在最上层,视觉焦点不丢。
7. 性能与踩坑:局部变量、像素比和转身抽搐
跑起来之后,我遇到了一堆莫名其妙的问题,一个个记录下来,准保有一半是你会遇到的。
7.1 高 DPI 屏幕上的模糊问题
第一版在高分辨率屏上,鱼边缘是糊的。原因是 Canvas 的物理像素跟 CSS 像素不是 1:1,画出来的内容在 2 倍屏上会被拉伸。解决方法是根据devicePixelRatio缩放画布的实际尺寸:
const dpr = window.devicePixelRatio || 1; canvas.width = canvasWidth * dpr; canvas.height = canvasHeight * dpr; canvas.style.width = canvasWidth + 'px'; canvas.style.height = canvasHeight + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);这样之后线的清晰度立刻上一个档次。代价是dpr = 2时 Canvas 的实际像素数变成四倍,性能会受影响。如果目标设备性能一般,可以只取dpr = 1.5或直接用 1。
7.2 每帧创建对象的隐形开销
第一次写完,气泡、光斑、鱼身渐变全都直接new或者createRadialGradient,结果 FPS 从 60 掉到 40。排查才发现问题在垃圾回收:每帧创建的对象让 GC 频繁工作,动画就卡。
修复方案:
- 渐变对象可以复用:每帧调用
createRadialGradient是必要的,但你可以把渐变缓存起来,颜色不变就不重建 - 临时数组尽量复用,不要
map出新数组给轮廓函数 - 所有长时间存在的对象写在
init里,Update 只负责更新字段
7.3 转向翻转与"鱼头狂抖"
还有个经典问题:当鱼到达目标点附近时,distance < 5判断生效,鱼停在原地,但身体还在摆动,鱼头会因为正弦波的偏移而静止摆动——观感上鱼是在原地"点头"。如果目标点来回切换,鱼头的转向会不断调整,出现轻微抖动。
我的处理是:到达目标后,让鱼继续沿当前方向滑行一小段距离,而不是立刻停死。这符合真实鱼的滑行习惯,也避免了抖动。具体是在distance < 5时不是返回,而是降低swimSpeed,让鱼慢慢滑过目标点再减速到零。
7.4 参数爆炸时的"橡皮筋失控"
调参阶段最容易出现的画面:鱼的尾巴摆动幅度极大,整个身体拧成一团,或者尾巴像橡皮筋一样疯狂抖动。这多半是phaseDelay系数过大(超过 0.2),或者节点数太少(少于 8 个)。节点太少导致脊线拐弯处缺少平滑过渡,尾部和身体连接处会产生视觉上的"折断感"。经验值是:鱼身长 120 像素时,节点数 10~14 个比较合适,再少就会露馅,再多性能浪费。
我最后用的一组稳定参数:
this.segments = 12; this.wobblePhase += dt * 8; // 每秒约 1.3 次完整摆动 this.turnSpeed = 2.5; this.swimSpeed = 55 + Math.sin(this.wobblePhase * 0.25) * 8;这组参数跑下来,鱼看起来是"稳稳地推着水前进",而不是"被电击了一样抽搐"。这是一个需要反复试的过程,但一旦找到手感,你会对每一帧都有掌控感。
这个项目做到最后,我最深的体会是:动画的生命感不来自复杂的算法,而来自对"相位差"和"衰减"这两个词的理解。鱼游动也好,走路也好,风吹树叶也好,本质上都是"某个动作沿着身体传播,越远越滞后,越远越衰减"。你把这两件事做对,哪怕只用圆形凑出一条鱼,它也是活的。相反,参数再精确,如果忽略了相位滞后,鱼就只能像一块滑动的木头。
做完单条鱼的游动之后,你还可以往两个方向扩展:一是把鱼做成一群,用简单的避让规则让它们保持距离、偶尔改变方向,那就是一个锦鲤群;二是加昼夜循环,让光斑的颜色和透明度随时间变化,氛围会完全不一样。我下一版准备加上"投食"交互,点击水面泛起涟漪,鱼群向涟漪处聚集,想想就很有意思。这个项目从一张静态图开始,到现在已经是我主页上最吸引人的角落了。