前段时间做一个可视化大屏的装饰模块,右侧有一块 30° 倾斜的信息卡片,需要在卡片内部铺一层流动的水波背景。需求一句话讲完,但我一开始的直觉是用 CSS clip-path 切个斜角再加 animation,试了半小时发现边界和动效很难对应上——canvas 倾斜矩形绘制波浪效果,最后还是在 Canvas 里用旋转坐标系加正弦叠加实现的。
这类效果在品牌活动页、数据大屏、H5 营销页面里其实很常见:平行四边形卡片、菱形容器、各种倾斜面板里塞一段动态水波。难的点不在于“画波浪”本身,而在于“让波浪长在倾斜矩形里,并且看起来是自然流动的”。这篇文章把我自己的完整实现思路、踩过的坑和最终的封装方式都整理出来,适合已经会用 Canvas 基础 API、但没怎么处理过坐标系变换和动态图形的人看。
先说结论:Canvas 做这件事的原理,就是先通过 translate + rotate 把坐标系掰正,让矩形在“本地坐标系”里变成一个普通的水平矩形,然后在本地坐标系里画双层正弦波,最后用 clip() 把波浪严格限制在矩形内部。听起来不复杂,但这里面的顺序、坐标系选择、动画增量时间的处理,任何一个环节出错,效果都会很怪。
1. 先想清楚:倾斜卡片的波浪,需求到底长什么样
1.1 一个真实到不能再真实的项目场景
那个大屏项目的需求是这样的:屏幕右侧有一块平行四边形卡片,长 320、宽 180,整体旋转 30°,卡片中央要显示“当前水位 68%”,背景是一个动态的水波填充效果。波浪要顺着卡片长边的方向流动,而不是像普通页面里那样水平从左往右流。
刚听到需求的时候,我的第一反应是:波浪不就是 Math.sin() 吗?我可以用一条正弦曲线当水面线,然后把曲线下方的区域填成蓝色,再用 requestAnimationFrame 不断改变相位,让它动起来。但把这个思路放在旋转矩形里,问题就来了:普通水平矩形的波浪线是 y = sin(x),而旋转矩形的波浪线应该沿哪个轴变化?如果直接画水平波浪再整体旋转,边缘对不上,视觉上也会觉得波是“贴上去的”,没有真正嵌进卡片里。
把这个问题抽象一下,本质上就是在问:我们到底要在一个什么样的坐标系里绘制?
如果在全局坐标系画,波浪需要自己处理旋转角度带来的投影变化,代码会变得很绕;如果在矩形本地坐标系画,只需要在旋转后的坐标系里用普通 fillRect 和 sin 曲线逻辑,最后统一交给 Canvas 的变换矩阵去处理。后一种思路显然更清晰,也是我会在这篇文章里重点展开的。
1.2 用 CSS 还是 Canvas:这是一道取舍题
在做倾斜波浪之前,我认真对比过三条技术路线,因为这种效果如果只做静态展示,未必非要上 Canvas。
第一条是纯 CSS 方案。transform: rotate(30deg) 可以轻松做出倾斜卡片,clip-path: polygon() 可以把卡片裁成平行四边形,再叠加一个 @keyframes 位移动画模拟波浪。但这个方案有两个硬伤:一是波浪本身需要一张背景图或者径向渐变去模拟,真实感不足;二是当水波需要跟卡片的倾斜边界精确贴合时,clip-path 的裁剪边界和内部波浪动画的坐标系很容易脱节,尤其是波浪要“贴着长边流动”这种需求,CSS 基本无法优雅实现。
第二条是 SVG 方案。<path> 可以绘制非常平滑的贝塞尔波浪,配合 <animate> 或 SMIL 动画也能动起来。但对于需要逐帧动态变化、交互触发涟漪的场景,SVG 的 DOM 节点操作和动画控制反而更繁琐,而且性能上限明显低于 Canvas 2D。
第三条就是 Canvas。Canvas 的核心优势在于“逐帧程序化绘制”,你可以用任意数学函数生成任意形状,坐标系变换也是第一等公民。对于倾斜矩形 + 动态波浪 + 点击交互这种复合需求,Canvas 是唯一能把代码控制在合理复杂度内、同时性能也足够的方案。
1.3 最容易一开始就踩偏的设计假设
我写第一版的时候想当然地认为:“倾斜矩形里的波浪,就是把普通波浪旋转一下角度而已。”这个假设是错的。
普通水平矩形的波浪,波峰和波谷的连线是水平方向,波浪沿着 x 轴传播。而倾斜矩形里的波浪,如果跟矩形保持相对固定,波浪应该沿着矩形的长边方向传播,也就是本地坐标系的 x 轴。如果你的需求是“卡片倾斜但水面依然水平”(比如模拟一杯倾斜的水),那又是另一套完全不同的坐标系逻辑。先把这两种需求分清,才不会把代码写成一团浆糊。
还有一个很容易忽略的点:波浪不止是一条线。如果只是 stroke() 一条波浪线,它看起来是“画”在矩形上的装饰;但需求里通常想要的是“水面填充”效果,也就是波浪线以上的区域和线以下的区域颜色不同。这要求波浪线必须和矩形边界组成一个闭合的填充路径,而不是单纯画一条曲线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 倾斜矩形三件套:旋转坐标系、路径边界与裁剪
2.1 先画“正矩形”,再用 transform 改变坐标系
Canvas 2D 的绘制模型有点像“在一张透明纸上画画”,translate 移动的是画笔的起点,rotate 旋转的是整张纸。所以画倾斜矩形的第一步,不是去计算四个顶点,而是先把坐标系平移到矩形中心,再旋转一个角度,然后在“被掰正”的坐标系里画一个普通的水平矩形。
javascript复制const canvas = document.getElementById('waveCanvas');
const ctx = canvas.getContext('2d');
const cx = 400;
const cy = 300;
const rectW = 320;
const rectH = 180;
const angle = Math.PI / 6; // 30度
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.fillStyle = '#1a365d';
ctx.fillRect(-rectW / 2, -rectH / 2, rectW, rectH);
ctx.restore();
这段代码的要点有两个。第一,save() 和 restore() 必须成对出现,因为 translate 和 rotate 是叠加的,如果不恢复,后面的所有绘制都会带着这个旋转,排查起来非常痛苦。第二,fillRect 的坐标原点在 (-rectW/2, -rectH/2),中心在本地原点上,这样矩形中心正好和 translate(cx, cy) 对齐,后面做旋转、缩放、对齐都方便。
如果你见过图形编辑器或者 CAD 软件的源码,会发现它们内部几乎都是这套思路:维护一个变换矩阵,绘制时统一在本地坐标系里描述图形,最后由矩阵决定图形出现在屏幕哪个位置。Canvas 的 save / restore 其实是把这件事简化成了栈式管理,我们要做的就是利用好这个栈。
2.2 顶点手算与路径绘制:什么时候才需要这么做
旋转坐标系能覆盖 90% 的绘制需求,但有些场景必须拿到矩形四个顶点的真实屏幕坐标,比如:
- 绘制矩形描边阴影,要求阴影方向和卡片的视觉方向严格一致
- 做鼠标点击命中检测,判断点击点是否落在旋转矩形内
- 在矩形外接区域做一些装饰连线,需要知道角点位置
这时候可以手算四个顶点。一个以中心 (cx, cy) 为圆心、宽 w、高 h、旋转角为 angle 的矩形,四个本地顶点坐标分别是 (-w/2, -h/2)、(w/2, -h/2)、(w/2, h/2)、(-w/2, h/2)。旋转公式是:
javascript复制function getRotatedRectPoints(cx, cy, w, h, angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const hw = w / 2;
const hh = h / 2;
const localPoints = [
{ x: -hw, y: -hh },
{ x: hw, y: -hh },
{ x: hw, y: hh },
{ x: -hw, y: hh }
];
return localPoints.map(p => ({
x: cx + p.x * cos - p.y * sin,
y: cy + p.x * sin + p.y * cos
}));
}
有了这四个点,就可以用 moveTo 和 lineTo 直接画一个多边形路径,也可以用来判断点是否在矩形内。这个函数后面在交互命中检测那一节还会用上。
这里顺便说一个向量旋转的小技巧:当屏幕坐标系 y 轴向下时,上面的公式依然成立,rotate(angle) 会表现为顺时针旋转。这个方向问题很多人第一次都会搞混,你只要记住“Canvas 的旋转正方向是顺时针,和数学课的逆时针相反”就够了。
2.3 clip() 裁剪:让波浪严格呆在矩形内部
有了倾斜矩形,下一步就是把波浪限制在矩形内部。最直接的方法是用 clip()。
思路是:在旋转后的坐标系里,声明一个矩形路径,然后 clip()。裁剪一旦生效,后续所有绘制都只在裁剪区域里可见,直到 restore() 才恢复。
javascript复制ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.beginPath();
ctx.rect(-rectW / 2, -rectH / 2, rectW, rectH);
ctx.clip();
// 在这里画波浪、文字、背景,所有内容都会被裁剪进矩形
// drawWave(ctx, rectW, rectH, waveTime);
ctx.restore();
clip() 是 Canvas 里一个非常有用的状态操作,它和 save/restore 配合起来,可以很方便地实现“只在某个区域内绘制”。写到这里我想提醒一句:clip() 是有开销的,如果一帧里对同一个路径反复 save、clip、restore 几千次,性能会非常难看。通常控制在每帧几十次以内完全没问题;如果数量很多,建议用 Path2D 把路径对象缓存起来,减少路径重建的开销。
另一种不需要 clip() 的思路:在画波浪填充的时候,直接把波浪线的两端延伸到矩形左右边界,底部延伸到矩形底边,这样填充路径本身就在矩形边界上闭合,不需要裁剪。这个方案性能更好,但对波浪顶点的计算精度要求更高,一旦采样步长过大或振幅超出预期,边缘就会露出破绽。作为稳妥方案,我还是建议先用 clip()。
3. 波浪方向跟着矩形走:本地坐标系中的振幅叠加
3.1 先想清楚波浪是“跟着矩形”还是“跟着屏幕”
这一节是整篇文章的核心,也是我最初踩坑最久的地方。
当你进入旋转坐标系之后,波浪的方向问题就变成了一道选择题。如果你希望波浪和矩形保持相对静止——也就是矩形往哪转,波浪就往哪流——那就在本地坐标系里用 sin(x) 计算 y 坐标。波浪会沿着矩形长边方向流动,看起来像矩形自带的水面。
如果你希望波浪始终水平,即矩形倾斜但水面依然平行于屏幕,那就要反过来:在全局坐标系里计算波浪线,但让矩形用 clip() 来限制显示区域。这种情况下,波浪的 x 是屏幕的 x,和矩形的旋转无关。这个效果在物理上更真实,比如一杯倾斜的水,水面是平的;但视觉上少了“水在容器里顺着容器走”的装饰感。
绝大多数设计稿里的倾斜水波,要的都是前者——“波浪跟着矩形走”。我自己也推荐这种方案,因为它的代码逻辑更统一:所有绘制都在本地坐标系,波浪、文字、装饰线都在同一个空间里描述,不需要来回换算。
3.2 两层正弦叠加:用数学堆出真实水波
单一的正弦曲线看起来太规律,像信号发生器,不像水波。真实水波的特征是:一个大波浪上面叠着若干小波纹,长短波之间有相位差。用两层甚至三层正弦叠加,就能模拟出这种效果。
javascript复制function getWaveY(x, t) {
// 第一层:长波,慢速流动,振幅大
const y1 = Math.sin(x * 0.03 + t * 0.9) * 14;
// 第二层:短波,快速流动,振幅小
const y2 = Math.sin(x * 0.013 - t * 0.55 + 1.8) * 9;
return y1 + y2;
}
这里 x * 0.03 是空间频率,控制波浪的波长;t * 0.9 是时间相位,控制流动速度;* 14 是振幅,控制波峰高度。我习惯把第一层设成“波长较大、振幅较大、速度中等”的长波,把第二层设成“波长较小、振幅较小、速度较快”的短波。两层叠加后,波峰和波谷不会完全对齐,看起来就像水面被风拂过一样。
如果你还想要更复杂的形态,可以加第三层:Math.sin(x * 0.05 + t * 1.3) * 3,用来模拟高频碎波。但注意,层数越多计算量越大;以常用的 rectW = 320 为例,采样步长 2px 时每帧要算 160 个点,每层都是一次 sin,三层也才几百次运算,性能无压力。真正要小心的是在小程序容器或者低端设备上,Math.sin 的调用频率会被放大,后面性能章节会细说。
3.3 填充路径的细节:从波峰到容器底部的闭包
有了波浪函数,接下来要生成填充路径。波浪不能只用 stroke() 画一条线,而是要闭合出一个区域,填充成水面。
javascript复制function drawWave(ctx, rectW, rectH, t) {
ctx.beginPath();
// 从左下角开始
ctx.moveTo(-rectW / 2, rectH / 2);
// 从左侧到右侧,逐个采样点生成波曲线
for (let x = -rectW / 2; x <= rectW / 2; x += 2) {
ctx.lineTo(x, getWaveY(x, t));
}
// 闭合到右下角,形成填充区域
ctx.lineTo(rectW / 2, rectH / 2);
ctx.closePath();
ctx.fill();
}
这个路径的顺序是:先移动到矩形左下角,然后沿着波曲线从左画到右,最后从右侧直接闭合到右下角。因为波浪函数的振幅范围小于 rectH,整个填充区域会稳稳落在矩形内部。
采样步长我推荐 2px 或者 1px。步长太大,波浪曲线会变成明显的折线;步长太小(小于 0.5px),路径点太密集,虽然视觉上差不多,但 CPU 白白多算。如果你想要更顺滑的曲线,可以用 quadraticCurveTo 把相邻采样点用二次贝塞尔平滑连接起来,这就是所谓“线段锚点工具”的原理——把采样点当成锚点,再补上控制点。我实际测试下来,步长 1px 的 lineTo 已经足够平滑,而且 lineTo 比 quadraticCurveTo 更省。
填充颜色建议用渐变,不要用纯色。水面的真实感很大程度来自垂直方向的颜色变化:浅色在波峰处,深色在底部。在本地坐标系里创建线性渐变是最方便的:
javascript复制const gradient = ctx.createLinearGradient(0, -rectH / 2, 0, rectH / 2);
gradient.addColorStop(0, 'rgba(76, 178, 255, 0.7)');
gradient.addColorStop(1, 'rgba(12, 62, 132, 0.95)');
ctx.fillStyle = gradient;
注意渐变坐标用的是本地坐标系的 y 方向,从 -rectH/2 到 rectH/2,这样渐变方向和矩形长边垂直,视觉上更自然。
4. 动起来:动画循环、增量时间与渲染性能
4.1 requestAnimationFrame 和 deltaTime 的正确用法
静态波浪画完之后,真正的变化来自动态。动画循环用 requestAnimationFrame 是标准做法,但很多人不知道的是:不能直接把“帧数”当成时间增量。
如果你的设备是 60Hz 刷新率,每帧间隔约 16.7ms;如果是 120Hz 的屏幕,每帧间隔约 8.3ms。如果每帧让 t += 0.1,那么 120Hz 设备上的波浪流动速度会是 60Hz 的两倍。解决办法是用真实时间差 deltaTime 累加。
javascript复制let lastTime = 0;
let waveTime = 0;
function frame(now) {
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
waveTime += dt;
drawFrame(waveTime);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
这里最关键的是 Math.min(dt, 0.05)。如果不做这个限制,用户切到后台再切回来时,now 会突然跳变,dt 可能变成几秒,波浪就会瞬间“跳”一大段。限制在 0.05 秒以内,动画在人眼重新聚焦时会平滑过渡,而不是猛跳一下。
4.2 离屏 Canvas 缓存静态背景
一个普遍的误区是:每一帧把卡片里所有内容重画一遍。实际上卡片里很多内容是不变的:背景渐变、卡片边框、内部网格线、装饰纹理。这些静态内容完全可以画到一个离屏 Canvas 上,每帧用 drawImage 贴回来。
javascript复制// 创建离屏 Canvas
const offscreen = document.createElement('canvas');
offscreen.width = rectW;
offscreen.height = rectH;
const octx = offscreen.getContext('2d');
// 在离屏 Canvas 上绘制静态背景
octx.fillStyle = gradient;
octx.fillRect(0, 0, rectW, rectH);
// 画边框、装饰线等静态内容……
// 主循环里
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.drawImage(offscreen, -rectW / 2, -rectH / 2);
// 再画动态的波浪
ctx.restore();
这个操作本质上就是“把一个 Canvas 的内容赋值给另一个 Canvas”——先用离屏 Canvas 绘制好静态层,每帧通过 drawImage 把它画到主画布上。这比每帧重新建渐变、重新画路径要快得多,因为 createLinearGradient 和 fillRect 虽然单个不贵,但每帧重复做几百次就是明显的性能差异。
在实际项目里,我通常把“静态背景层”和“动态波浪层”分开。动态波浪单独画,静态背景直接用离屏 Canvas 贴,这样即使波浪每帧在变,背景也不会因为重复绘制产生明显的像素抖动。
4.3 高 DPI 屏的清晰度救急方案
如果你直接把 Canvas 的 width 设成 CSS 像素尺寸,在 Retina 屏幕上会明显发虚,尤其是倾斜矩形的斜边,锯齿会非常明显。解决方法是按 devicePixelRatio 缩放 Canvas 的实际像素尺寸。
javascript复制const dpr = window.devicePixelRatio || 1;
const cssWidth = 800;
const cssHeight = 600;
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
ctx.scale(dpr, dpr);
执行 ctx.scale(dpr, dpr) 之后,所有绘制代码都还按 CSS 像素来写,但实际渲染的像素数是物理像素,文字和斜边会清晰很多。这个处理对倾斜边界尤其重要:水平或垂直的矩形边缘,锯齿本来就不明显;但旋转 30° 的斜边,在低分辨率下会像楼梯一样一阶一阶的,做完 DPR 适配之后才勉强算是能看。
顺带提一个移动端容器的经验:微信小程序这类环境里,window.devicePixelRatio 在不同机型上差异很大,有的老机型 DPR 是 2,有的是 3。如果你不做适配,同样一套代码在不同手机上清晰度天差地别。所以通用组件里,这一步不能省。
5. 斜着画的坑:锯齿、点击命中与进阶玩法
5.1 为什么倾斜边界特别容易出锯齿
锯齿问题的根源是:Canvas 在光栅化时,路径边界的像素覆盖度计算是有限的,直线的倾斜角度越接近 0° 或 90°,抗锯齿效果越好;越接近 45°,锯齿越明显。旋转矩形的四条边全都在非水平非垂直角度,所以锯齿几乎无法避免。
DPR 适配能缓解大部分问题,但如果你还想进一步优化,可以考虑在绘制前开启 ctx.imageSmoothingEnabled = true(这对 drawImage 有效,对路径无效),或者画完整个矩形后,轻轻加一层 shadowBlur 制造柔边效果,把锯齿视觉上“糊掉”。
但说实话,DPR 适配 + 适当的颜色对比已经足够日常使用。如果锯齿依然严重,通常不是 Canvas 的问题,而是你用错了坐标:比如在全局坐标系里用 fillRect 画了一个看似倾斜的矩形,实际宽度和高度没有按 DPR 换算,导致渲染分辨率不足。这种情况先检查 canvas.width 是否等于 cssWidth * dpr。
5.2 点击命中检测需要反解旋转矩阵
倾斜矩形和普通矩形最大的区别是:鼠标点击位置是屏幕坐标,而矩形是旋转过的,直接用 x >= left && x <= right 这种 AABB 判断是错的。必须在本地坐标系里做判断。
思路并不复杂:把屏幕坐标先平移到矩形中心,再反向旋转 angle,得到它在本地坐标系里的坐标 (lx, ly),然后看 lx 和 ly 是否落在矩形范围里。
javascript复制function isInsideRotatedRect(px, py, cx, cy, angle, w, h) {
const dx = px - cx;
const dy = py - cy;
const cos = Math.cos(-angle);
const sin = Math.sin(-angle);
const lx = dx * cos - dy * sin;
const ly = dx * sin + dy * cos;
return Math.abs(lx) <= w / 2 && Math.abs(ly) <= h / 2;
}
注意一个常见陷阱:Canvas 的 rotate(angle) 是顺时针旋转,但数学坐标系里通常认为逆时针为正。反解的时候,要用 -angle 作为旋转角,才能把屏幕坐标转回本地坐标。我最初写代码时忘记这个负号,导致点击点永远出现在矩形外面的“镜像位置”,调试了很久才反应过来。
如果矩形角度会动态变化,要确保 angle 变量和绘制时传给 rotate 的是同一个值。如果旋转中心不是矩形中心,则需要先平移再旋转,这里的 cx, cy 要跟绘制时的 translate 保持一致。
5.3 进阶玩法:涟漪触发、双层波与渐变
到这里,一个完整的倾斜矩形波浪效果已经实现了。但实际项目里往往还要加点交互,我分享几个我试过好用的进阶玩法。
点击涟漪:在波浪函数里再加一个“扰动项”。点击矩形后,把点击位置换算成本地坐标 (rx, ry),然后在 getWaveY 里加入一个衰减的正弦波,让波浪从点击点向外扩散。
javascript复制// 用距离衰减的正弦波模拟涟漪
const dist = x - rippleX;
const ripple = rippleStrength * Math.exp(-Math.abs(dist) * 0.01) * Math.sin(dist * 0.2 - waveTime * 6);
rippleStrength 在每次点击时重置为 1,然后在动画循环里乘以 0.96 缓慢衰减。这样看到的视觉效果就是:点击水面后,涟漪从点击处扩散,几秒后消失,非常自然。
双层波浪:画出两条相位互补的波浪,一条填充浅色,一条填充深色,深浅之间稍微错开几个像素,营造“水面下方还有暗流”的层次感。实现上就是分别用 getWaveY(x, t) 和 getWaveY(x, t + 0.6) 生成两条路径,两条路径填充不同透明度的蓝色。
渐变与光照:除了垂直线性渐变,还可以在波浪线附近用径向渐变模拟“阳光在水面上的反射”,只需要在波峰处画几个半透明圆形光斑,或者用 globalCompositeOperation = 'lighter' 叠加一层光晕。注意用完要重置回 source-over,否则后面所有绘制都会受影响。
这几个玩法我都在不同项目里实际跑过,性能上只要控制好采样点数量,60fps 完全没问题。真正要注意的是:交互触发的涟漪要跟坐标变换保持一致,点击命中的换算和涟漪生成的本地坐标是同一套矩阵,别一会儿用全局坐标一会儿用本地坐标,那个 bug 找起来比写代码还费时间。
最后再分享一点个人经验:这种倾斜矩形波浪效果,看起来花哨,但本质上就是“坐标变换 + 正弦函数 + 裁剪”。只要把这三位一体理顺,你可以在任意形状(圆形、扇形、平行四边形)里画出任何动态填充效果。我第一次在项目里用这套代码时,最耗时的不是画波浪本身,而是让点击命中和涟漪触发对齐——后来直接把“屏幕坐标到本地坐标”的逆变换封装成一个工具函数,所有交互都走同一个入口,问题就再也没出现过。
