拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Canvas动态水波效果:从正弦波原理到高性能可视化实现

1. 项目缘起从静态到动态的视觉跃迁最近在做一个数据可视化大屏项目客户盯着原型图上的一个静态水位指示器冷不丁来了一句“这个水位能不能像真的水一样有点波纹动起来” 就这一句话把我从简单的矩形填充和渐变色的舒适区里拽了出来。静态的百分比条固然清晰但缺乏生命力而一个微微荡漾的水波不仅能直观传达“液体”的质感更能赋予数据一种生动的、正在变化的暗示。这不正是我们做可视化时追求的“让数据讲故事”吗实现动态水波听起来像是要动用复杂的物理引擎但作为一名前端老手我第一时间想到的就是Canvas和三角函数。Canvas提供了像素级的绘图控制能力而三角函数特别是正弦函数是描述周期性波动最自然的数学工具。两者的结合足以在二维平面上模拟出令人信服的液体表面效果。这个需求的核心不在于模拟流体力学的纳维-斯托克斯方程而在于用最轻量、最高效的方式在浏览器中创造出一种“看起来对”的视觉韵律。今天我就把这个从零构建Canvas动态水波效果的完整过程包括核心原理、代码实现、性能优化以及我踩过的几个坑毫无保留地分享出来。无论你是想为你的仪表盘添加一点灵动还是单纯对Canvas动画感兴趣这篇文章都能给你一份可以直接“抄作业”的代码和透彻的理解。2. 核心原理拆解正弦曲线如何“动”成水波在开始写代码之前我们必须把背后的数学和动画原理吃透。很多人一听到三角函数就头大其实在这里我们只需要理解正弦函数几个关键特性并用一种巧妙的“相位移动”手法让它动起来。2.1 正弦函数波动世界的基石正弦函数y A * sin(ωx φ)这个公式里的每个参数都对应着我们水波的一个视觉属性A(振幅 Amplitude)决定了波峰和波谷的高度差。在我们的水波里它控制着波浪的“汹涌”程度。A值越大波浪起伏越剧烈。ω(角频率 Angular Frequency)由公式ω 2π / λ决定其中λ是波长。它控制着波浪的“密度”。ω越大波长λ越小在相同水平距离内出现的波峰波谷就越多波浪看起来就更细密。φ(初相 Phase)决定了波浪的起始位置。单个静态的正弦曲线φ固定。如果我们在Canvas上用x坐标作为自变量用上面公式计算出y坐标然后从(x, y)到(x1, y1)连点成线就能画出一条优美的静态波浪线。但这只是第一步。2.2 让波浪“流”起来相位的魔法静态波浪不是我们想要的。动态水波的奥秘全在于这个相位φ。想象一下你不是在观察一条固定的波浪而是站在岸边看着水波从左向右或从右向左掠过。对于你视线正前方的某一个固定点比如x 100这个像素列它的水位高度y随着时间在周期性地变化。如何用数学描述让相位φ随时间t变化。我们将公式演进为y A * sin(ωx φ(t))其中φ(t) φ0 speed * t。φ0是初始相位speed是波速相位变化率。这里有一个至关重要的理解我们常见的实现水波横向流动的直觉是“移动整条曲线”但在Canvas的逐帧绘制中更高效的做法是固定 x 坐标让每个 x 点对应的 y 值随着相位变化而上下运动。当speed为正值时随着时间t增加φ(t)增大对于同一个x其sin函数的值会发生变化视觉上就表现为波形整体向左移动因为相位提前了相当于原波形向右平移了。这就是用“时间改变相位”来模拟“空间波形移动”的核心技巧。2.3 组合与遮罩从线到面的关键一跃单一的正弦波看起来太规则、太“假”像塑料布。真实的水面是无数不同频率、不同相位、不同振幅的微小波纹叠加的结果。因此一个提升真实感的技巧是叠加多条正弦波。y A1 * sin(ω1*x φ1(t)) A2 * sin(ω2*x φ2(t)) ...通过叠加2-3条振幅、频率、速度略有差异的波浪就能打破单一波的周期性产生更自然、更随机的波动效果。这比引入复杂的噪声函数如 Perlin Noise要简单高效得多。最后如何让这条波动的“线”变成一片有水位高度的“水”这就需要用到Canvas的裁剪Clipping或全局合成Global Composite Operation技术。我们不是简单地描边stroke这条波浪线而是用fill来填充这条波浪线以上或以下的区域。更常见的做法是先绘制一个代表容器比如水槽、圆形进度条的背景。然后我们创建一个离屏的波浪路径从画布左侧开始沿着计算出的波浪点绘制到右侧再连接到画布底部最后闭合回起点。这个路径区域就是“水”的部分。使用ctx.fill()并设置一个半透明的蓝色填充样式就能看到动态的水体了。通过控制波浪的基准高度比如baseHeight canvas.height * (1 - waterLevel)其中waterLevel是0到1的水位百分比我们可以轻松实现水位的上升和下降动画。3. 环境搭建与基础结构理论清晰了我们开始动手。首先创建一个标准的HTML文件结构。这里没有复杂的依赖只需要一个Canvas元素和一段JavaScript代码。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCanvas动态水波模拟/title style body { margin: 0; padding: 20px; background: #1a1a2e; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: sans-serif; } #container { text-align: center; } canvas { display: block; margin: 20px auto; border-radius: 8px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); } .controls { margin-top: 20px; color: #ccc; } input, button { margin: 5px; padding: 8px 12px; border-radius: 4px; border: 1px solid #555; background: #2d2d44; color: #eee; } button { cursor: pointer; transition: background 0.3s; } button:hover { background: #3a3a5e; } /style /head body div idcontainer h2 stylecolor:#4fc3f7;动态水波模拟器/h2 canvas idwaveCanvas/canvas div classcontrols label水位: input typerange idlevelSlider min0 max100 value50 span idlevelValue50%/span/label button idpauseBtn暂停/继续/button button idresetBtn重置波浪/button /div /div script srcwave.js/script !-- 我们将核心代码写在这里 -- /body /html接下来是重头戏wave.js。我们先搭建一个基本的Wave类和动画循环骨架。// wave.js (function() { use strict; const canvas document.getElementById(waveCanvas); const ctx canvas.getContext(2d); // 动态调整Canvas大小以适应容器并确保高清显示 function resizeCanvas() { const container canvas.parentElement; const displayWidth container.clientWidth * 0.8; // 占用容器80%宽度 const displayHeight Math.min(displayWidth * 0.6, 400); // 限制高度 // 检查物理尺寸是否变化避免不必要的重绘 if (canvas.width ! displayWidth || canvas.height ! displayHeight) { canvas.width displayWidth; canvas.height displayHeight; // 尺寸变化后可能需要重新初始化或触发重绘 if (window.waveSimulator) { window.waveSimulator.initWaveParams(); } } } // 初始调整并监听窗口变化 resizeCanvas(); window.addEventListener(resize, resizeCanvas); // 水波模拟器类 class WaveSimulator { constructor(ctx, width, height) { this.ctx ctx; this.width width; this.height height; // 波浪参数 this.waves []; // 存储多个波浪对象 this.baseLevel 0.5; // 基准水位 (0-1) this.animationId null; this.isAnimating true; this.time 0; // 全局时间变量用于驱动相位变化 // 颜色与样式 this.waterColor rgba(64, 164, 223, 0.8); this.bgColor #0f3460; this.initWaveParams(); this.setupControls(); this.animate(); } // 初始化波浪参数创建多条叠加的波浪 initWaveParams() { this.waves []; // 波浪1主波振幅大速度慢 this.waves.push({ amplitude: this.height * 0.02, // 振幅为画布高度的2% frequency: 0.015, // 角频率控制波长 speed: 0.008, // 波速相位变化率 phase: Math.random() * Math.PI * 2 // 随机初始相位 }); // 波浪2次波振幅小速度快方向相反增加复杂性 this.waves.push({ amplitude: this.height * 0.01, frequency: 0.03, speed: -0.012, phase: Math.random() * Math.PI * 2 }); // 波浪3细节波振幅很小频率高增加水面纹理 this.waves.push({ amplitude: this.height * 0.005, frequency: 0.05, speed: 0.02, phase: Math.random() * Math.PI * 2 }); } // 计算在位置x处当前时间的总波浪高度偏移 getWaveYOffset(x) { let yOffset 0; for (const wave of this.waves) { // 核心公式y A * sin(ω*x φ speed * time) yOffset wave.amplitude * Math.sin(wave.frequency * x wave.phase this.time * wave.speed); } return yOffset; } // 绘制一帧 draw() { const ctx this.ctx; const width this.width; const height this.height; // 1. 清空画布绘制背景 ctx.clearRect(0, 0, width, height); ctx.fillStyle this.bgColor; ctx.fillRect(0, 0, width, height); // 2. 计算当前水位线的垂直位置 const waterLevelY height * (1 - this.baseLevel); // 3. 创建波浪路径 ctx.beginPath(); // 从画布左下方开始 ctx.moveTo(0, height); const segmentCount Math.floor(width); // 采样点数量通常每个像素一个点 const dx width / segmentCount; // x方向的步进 for (let i 0; i segmentCount; i) { const x i * dx; // 计算该x位置波浪的垂直偏移 const yOffset this.getWaveYOffset(x); // 当前点的y坐标 水位线 - 波浪偏移注意坐标系y向下为正 const y Math.max(0, Math.min(height, waterLevelY - yOffset)); ctx.lineTo(x, y); } // 闭合路径到画布右下角 ctx.lineTo(width, height); ctx.closePath(); // 4. 填充波浪区域 ctx.fillStyle this.waterColor; ctx.fill(); // 5. 可选绘制水位线用于调试 ctx.beginPath(); ctx.moveTo(0, waterLevelY); ctx.lineTo(width, waterLevelY); ctx.strokeStyle rgba(255, 255, 255, 0.3); ctx.lineWidth 1; ctx.stroke(); } // 动画循环 animate() { if (!this.isAnimating) return; this.time 0.05; // 控制全局时间流逝速度 this.draw(); this.animationId requestAnimationFrame(() this.animate()); } // 暂停/继续动画 toggleAnimation() { this.isAnimating !this.isAnimating; if (this.isAnimating) { this.animate(); } else { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId null; } } } // 重置波浪相位产生新的随机波形 resetWaves() { for (const wave of this.waves) { wave.phase Math.random() * Math.PI * 2; } } // 绑定控制面板事件 setupControls() { const levelSlider document.getElementById(levelSlider); const levelValue document.getElementById(levelValue); const pauseBtn document.getElementById(pauseBtn); const resetBtn document.getElementById(resetBtn); levelSlider.addEventListener(input, (e) { this.baseLevel e.target.value / 100; levelValue.textContent ${e.target.value}%; }); pauseBtn.addEventListener(click, () { this.toggleAnimation(); pauseBtn.textContent this.isAnimating ? 暂停 : 继续; }); resetBtn.addEventListener(click, () { this.resetWaves(); }); } } // 页面加载完成后初始化模拟器 window.addEventListener(load, () { // 确保Canvas尺寸已更新 resizeCanvas(); window.waveSimulator new WaveSimulator(ctx, canvas.width, canvas.height); }); // 窗口大小变化时更新模拟器尺寸 window.addEventListener(resize, () { if (window.waveSimulator) { // resizeCanvas函数会更新canvas.width/height // 我们需要将新尺寸传递给模拟器实例 window.waveSimulator.width canvas.width; window.waveSimulator.height canvas.height; // 重新初始化波浪参数因为振幅与高度相关 window.waveSimulator.initWaveParams(); } }); })();把index.html和wave.js放在同一个目录下用浏览器打开index.html你应该能看到一个深色背景上有一片蓝色的、正在缓缓波动的“海水”并且可以通过滑块控制水位按钮控制动画的暂停和波浪重置。一个最基础的动态水波已经实现了。但这只是开始它的性能和效果还有巨大的优化空间。4. 性能优化与视觉增强实战上面的基础版本在每次动画帧通常60fps中都需要遍历画布的每个水平像素点segmentCount个点来计算正弦波并重新绘制整个路径和填充。在低性能设备或大画布上这可能会成为性能瓶颈。同时视觉效果也略显单调。我们来逐一优化。4.1 性能杀手高频Math.sin计算与路径重绘Math.sin()是相对耗时的运算。在我们的循环中每帧每个波浪每个点都要计算一次。3条波浪 * 800个点 每帧2400次Math.sin调用。虽然现代浏览器很快但仍有优化余地。优化策略1使用查找表Look-Up Table, LUT正弦函数是周期性的我们可以预先计算一个周期内的值存储在一个数组中。计算yOffset时根据相位角在这个表中查找近似值。这用空间换时间尤其适合固定频率或频率可被采样率整除的情况。但对于我们这种频率和相位实时变化的情况精确的LUT实现较复杂且可能引入锯齿感。优化策略2减少采样点顶点数这是最直接有效的方法。我们真的需要每个像素一个采样点吗对于大多数情况不需要。水波的平滑度更多由曲线的连续性lineTo保证。我们可以将segmentCount大幅减少到画布宽度的 1/2 甚至 1/4。// 修改 draw 方法中的 segmentCount 计算 // const segmentCount Math.floor(width); // 旧每个像素一个点 const segmentCount Math.floor(width / 2); // 新每两个像素一个点 // 或者根据画布大小动态调整 const segmentCount Math.min(200, Math.floor(width / 3)); // 不超过200个点实测下来将800个点降到200个点视觉上几乎看不出区别但计算量减少了60%以上帧率更加稳定。优化策略3使用requestAnimationFrame传递的时间戳我们之前用this.time 0.05来推进时间这依赖于帧率的稳定性。更专业的做法是使用requestAnimationFrame回调函数提供的高精度时间戳timestamp单位毫秒来计算真实流逝的时间deltaTime从而使波浪速度与系统时间而非帧率绑定动画更加平滑且一致。// 修改 animate 方法 animate(timestamp) { if (!this.isAnimating) return; if (!this.lastTimestamp) { this.lastTimestamp timestamp; } const deltaTime timestamp - this.lastTimestamp; this.lastTimestamp timestamp; // 使用真实时间差推进相位0.001是速度系数 this.time deltaTime * 0.001; this.draw(); this.animationId requestAnimationFrame((ts) this.animate(ts)); }4.2 视觉增强从平面色块到逼真水体基础版本只是用半透明蓝色填充缺乏质感。我们可以通过Canvas的渐变和合成轻松提升质感。添加线性渐变模拟深度感水是有深度的靠近水面的地方颜色浅深处颜色深。我们可以用createLinearGradient创建一个从透明到深蓝的垂直渐变来填充。// 在 draw 方法中修改填充样式部分 // ctx.fillStyle this.waterColor; // 旧纯色 // 创建垂直渐变 const waterGradient ctx.createLinearGradient(0, waterLevelY - this.height*0.1, 0, this.height); waterGradient.addColorStop(0, rgba(100, 200, 255, 0.9)); // 水面附近亮蓝色更透明 waterGradient.addColorStop(0.7, rgba(64, 164, 223, 0.8)); // 中间主体色 waterGradient.addColorStop(1, rgba(30, 100, 180, 0.9)); // 水底深蓝色 ctx.fillStyle waterGradient;添加波浪边缘高光模拟菲涅尔效应真实水面的边缘特别是波峰处会因为反射光线而更亮。我们可以通过绘制第二条振幅稍小、相位稍有不同的波浪路径并用更浅、更透明的颜色填充其与主波浪之间的区域来模拟。// 在 draw 方法中主波浪填充之后添加高光层 // ... 主波浪绘制代码 ... // 绘制高光层 ctx.beginPath(); ctx.moveTo(0, height); for (let i 0; i segmentCount; i) { const x i * dx; // 高光波浪使用略小的振幅和略快的速度相位偏移一点 let yOffsetHighlight 0; for (const wave of this.waves) { // 给每条波的相位加一个固定偏移比如 Math.PI/6 yOffsetHighlight wave.amplitude * 0.6 * Math.sin(wave.frequency * x wave.phase Math.PI/6 this.time * wave.speed * 1.2); } const yHighlight Math.max(0, Math.min(height, waterLevelY - yOffsetHighlight)); ctx.lineTo(x, yHighlight); } ctx.lineTo(width, height); ctx.closePath(); const highlightGradient ctx.createLinearGradient(0, waterLevelY - 20, 0, waterLevelY 10); highlightGradient.addColorStop(0, rgba(255, 255, 255, 0.25)); highlightGradient.addColorStop(1, rgba(255, 255, 255, 0)); ctx.fillStyle highlightGradient; ctx.fill();注意添加高光层会增加一倍的路径计算和绘制开销如果性能吃紧可以酌情减少高光层的采样点或只在靠近水面的部分绘制。4.3 集成优化后的完整WaveSimulator类将上述优化点整合我们得到一个增强版的类。为了清晰这里只展示修改和新增的关键部分完整的上下文请参考前面的基础代码。class EnhancedWaveSimulator { constructor(ctx, width, height) { // ... 初始化属性 ... this.lastTimestamp 0; this.initWaveParams(); this.setupControls(); this.animate(0); // 开始动画循环传入初始时间戳 } initWaveParams() { this.waves [ { amplitude: this.height * 0.02, frequency: 0.015, speed: 0.008, phase: Math.random() * Math.PI * 2 }, { amplitude: this.height * 0.012, frequency: 0.025, speed: -0.01, phase: Math.random() * Math.PI * 2 }, { amplitude: this.height * 0.006, frequency: 0.045, speed: 0.015, phase: Math.random() * Math.PI * 2 } ]; } getWaveYOffset(x, time, phaseOffset 0) { let yOffset 0; for (const wave of this.waves) { yOffset wave.amplitude * Math.sin(wave.frequency * x wave.phase phaseOffset time * wave.speed); } return yOffset; } draw() { const ctx this.ctx; const width this.width; const height this.height; const waterLevelY height * (1 - this.baseLevel); // 清空与背景 ctx.clearRect(0, 0, width, height); ctx.fillStyle this.bgColor; ctx.fillRect(0, 0, width, height); // **优化点1减少采样点** const segmentCount Math.min(250, Math.floor(width / 3)); const dx width / segmentCount; // 绘制主水体 ctx.beginPath(); ctx.moveTo(0, height); for (let i 0; i segmentCount; i) { const x i * dx; const yOffset this.getWaveYOffset(x, this.time); const y Math.max(0, Math.min(height, waterLevelY - yOffset)); ctx.lineTo(x, y); } ctx.lineTo(width, height); ctx.closePath(); // **优化点2使用渐变填充** const waterGradient ctx.createLinearGradient(0, waterLevelY - height*0.15, 0, height); waterGradient.addColorStop(0, rgba(120, 210, 255, 0.85)); waterGradient.addColorStop(0.5, rgba(64, 164, 223, 0.8)); waterGradient.addColorStop(1, rgba(30, 100, 180, 0.9)); ctx.fillStyle waterGradient; ctx.fill(); // **优化点3绘制高光层可选根据性能决定** if (this.enableHighlight) { ctx.beginPath(); ctx.moveTo(0, height); // 高光层可以使用更少的采样点 const highlightSegments Math.floor(segmentCount * 0.7); const dxHighlight width / highlightSegments; for (let i 0; i highlightSegments; i) { const x i * dxHighlight; // 使用相位偏移和不同参数计算高光波浪 const yOffsetHighlight this.getWaveYOffset(x, this.time, Math.PI / 6) * 0.7; const yHighlight Math.max(0, Math.min(height, waterLevelY - yOffsetHighlight)); ctx.lineTo(x, yHighlight); } ctx.lineTo(width, height); ctx.closePath(); const highlightGradient ctx.createLinearGradient(0, waterLevelY - 30, 0, waterLevelY 5); highlightGradient.addColorStop(0, rgba(255, 255, 255, 0.3)); highlightGradient.addColorStop(0.7, rgba(255, 255, 255, 0.1)); highlightGradient.addColorStop(1, rgba(255, 255, 255, 0)); ctx.fillStyle highlightGradient; ctx.fill(); } } animate(timestamp) { if (!this.isAnimating) return; // **优化点4使用时间戳计算增量时间** if (this.lastTimestamp timestamp) { const deltaTime timestamp - this.lastTimestamp; // 避免标签页切换后产生巨大的时间差 const clampedDelta Math.min(deltaTime, 100); // 最大100ms间隔 this.time clampedDelta * 0.001; // 乘以速度系数 } this.lastTimestamp timestamp; this.draw(); this.animationId requestAnimationFrame((ts) this.animate(ts)); } // ... 其他方法 (toggleAnimation, resetWaves, setupControls) 保持不变 ... }5. 进阶应用圆形进度水波与交互反馈基础的水波是在矩形区域中运动的但更常见的需求是圆形的水波进度条比如用于表示湿度、电量或任务完成度。实现它的核心思路不变只是将直角坐标(x, y)转换为极坐标(radius, angle)。5.1 极坐标下的水波计算我们不再从左到右画一条水平波浪线而是绕着圆心根据角度计算半径的偏移。定义圆心(cx, cy)和基础半径baseRadius。目标水位用一个百分比progress(0-1) 表示。实际水位半径waterRadius baseRadius * progress。对于圆周上的每一个角度θ从0到2π计算该角度对应的波浪对半径的偏移量waveOffset。公式与之前类似但自变量x替换为角度θ或者θ乘以一个系数来控制波浪在圆周上的密度。waveOffset A * sin(frequency * θ phase time * speed)当前点的半径currentRadius waterRadius waveOffset。注意waveOffset可正可负。将极坐标(currentRadius, θ)转换为直角坐标(x, y)x cx currentRadius * cos(θ),y cy currentRadius * sin(θ)。将这些点用lineTo连接起来形成一个闭合的、波动的圆形路径然后填充。drawCircularWave(cx, cy, baseRadius, progress) { const ctx this.ctx; const waterRadius baseRadius * progress; const angleStep (2 * Math.PI) / 360; // 将圆分为360份1度一份 ctx.beginPath(); // 从角度为0的位置开始 let startAngle 0; let startWaveOffset this.getWaveYOffset(startAngle, this.time); // 注意这里getWaveYOffset需要适配角度输入 let startX cx (waterRadius startWaveOffset) * Math.cos(startAngle); let startY cy (waterRadius startWaveOffset) * Math.sin(startAngle); ctx.moveTo(startX, startY); for (let angle angleStep; angle 2 * Math.PI angleStep; angle angleStep) { const waveOffset this.getWaveYOffset(angle, this.time); const currentRadius waterRadius waveOffset; const x cx currentRadius * Math.cos(angle); const y cy currentRadius * Math.sin(angle); ctx.lineTo(x, y); } ctx.closePath(); // 创建径向渐变填充 const gradient ctx.createRadialGradient(cx, cy, waterRadius * 0.8, cx, cy, baseRadius); gradient.addColorStop(0, rgba(64, 164, 223, 0.9)); gradient.addColorStop(1, rgba(30, 100, 180, 0.7)); ctx.fillStyle gradient; ctx.fill(); // 可选绘制圆形边框 ctx.beginPath(); ctx.arc(cx, cy, baseRadius, 0, 2 * Math.PI); ctx.strokeStyle rgba(255, 255, 255, 0.5); ctx.lineWidth 2; ctx.stroke(); }注意getWaveYOffset函数现在接收角度angle作为x参数。你可能需要调整波浪的frequency参数因为角度的范围是2π而之前画布宽度可能是几百像素。需要试验找到一个看起来合适的波浪密度。5.2 添加交互鼠标涟漪效应让水面能与用户交互比如点击或鼠标移动时产生涟漪能极大提升体验。实现思路是在鼠标位置创建一个新的、随时间衰减的圆形波浪并将其叠加到主波浪的计算中。定义涟漪对象包含圆心(x, y)、当前半径radius、最大半径maxRadius、振幅amplitude随时间衰减和生命周期life。在每一帧更新所有活跃涟漪的半径和振幅例如半径线性增加振幅指数衰减。在计算某点(px, py)的波浪高度时除了原有的正弦波叠加再遍历所有涟漪。计算该点到涟漪圆心的距离d。如果d小于涟漪当前半径则根据距离和涟漪的当前振幅计算一个额外的偏移量例如使用(1 - d/radius) * amplitude * Math.cos(d * someFrequency)来模拟环形波加到总偏移yOffset上。生命周期结束的涟漪从数组中移除。这部分代码稍长但原理清晰。核心是在getWaveYOffset函数中加入对涟漪数组的遍历计算。这会给主波浪带来动态的、局部的扰动效果非常出色。6. 避坑指南与性能压测在实际项目中应用这个水波效果我遇到了几个典型的坑这里分享出来帮你提前避开。坑一Canvas尺寸与CSS样式尺寸混淆这是Canvas绘图最经典的坑。Canvas元素有width和height两个属性Attribute它决定了画布绘图表面的实际像素数。同时它也有CSS的width和height样式这决定了它在页面上显示的大小。如果两者不一致浏览器会自动缩放绘图内容导致图像模糊。务必通过canvas.width和canvas.height属性来设置绘图表面尺寸或者像我们代码中那样将CSS样式尺寸与属性尺寸同步。坑二requestAnimationFrame的递归调用与内存泄漏在animate方法中我们使用requestAnimationFrame(() this.animate())来循环。注意每次调用requestAnimationFrame都会返回一个ID。当组件销毁或动画需要停止时比如页面切换、弹窗关闭必须用cancelAnimationFrame(this.animationId)来取消回调否则动画循环不会停止可能导致内存泄漏和不必要的计算。我们的toggleAnimation方法已经做了处理。坑三波浪参数调校不当导致“抽搐”或“呆板”正弦波叠加的艺术在于参数调校。如果所有波浪的频率成整数倍可能会产生不自然的周期性重复图案看起来像机械振动。如果速度 (speed) 设置得太大波浪会滚动过快失去水的厚重感。我的经验是振幅主波振幅最大次波和细节波依次递减如 2:1:0.5。频率使用非整数的比例如 1, 1.8, 3.2避免谐波。速度有正有负模拟不同方向的能量传递。速度值通常很小0.005到0.03之间。多试没有绝对的最佳值根据画布大小和想要的视觉效果动态调整。可以像我们示例一样提供UI控件来实时调整这些参数方便找到最合适的组合。坑四性能问题排查如果动画卡顿打开浏览器的开发者工具F12的Performance面板录制几秒查看是哪部分代码耗时最长。通常是draw方法中的循环或Math.sin计算。这时可以降低segmentCount。检查是否开启了高光等额外效果必要时关闭。考虑使用OffscreenCanvas如果支持在Web Worker中进行计算但这复杂度较高。对于极其复杂的场景或许需要考虑使用WebGL如Three.js来硬件加速但这超出了本文“轻量模拟”的范畴。经过这些优化和避坑最终得到的水波效果不仅视觉上足够吸引人而且性能在绝大多数现代设备上都能保持60fps的流畅度完全可以胜任数据大屏、加载动画、进度指示器等前端可视化场景。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门