1. 项目概述:当Canvas小球遇上斜椭圆
最近在捣鼓一个数据可视化的项目,需要一个动态的、有引导性的视觉元素来吸引用户注意力。我琢磨着,一个沿着优雅轨迹平滑运动的小球,比简单的直线来回或者圆周运动要有意思得多。于是,“让Canvas里的小球沿着一个倾斜的椭圆轨迹运动”这个想法就蹦出来了。听起来是不是比单纯的圆或者正椭圆酷一点?这不仅仅是画个圈那么简单,它涉及到坐标变换、参数方程以及如何让动画看起来既流畅又符合物理直觉。
这个需求其实挺典型的。比如,你想做一个加载动画,让一个光点沿着一个倾斜的轨道循环,暗示“正在处理中”;或者,在图表中,用一个动态的点来勾勒出某个数据分布的边界(想象一个倾斜的置信椭圆);甚至是在游戏里,设计一个非标准的行星轨道。核心就两点:第一,定义出那个“斜椭圆”的路径;第二,让小球能够精准、平滑地沿着这条路径运动。
乍一看,你可能觉得:“这不就是改改圆的参数方程,加个旋转角嘛?”没错,核心思路确实如此,但真动手实现起来,从数学公式到每一帧的像素渲染,中间有不少细节值得抠一抠。比如,如何高效计算每一帧小球的位置?如何确保动画在不同刷新率下的稳定性?倾斜角度和椭圆大小怎么方便地调整?这篇文章,我就把自己从理论推导到代码实现,再到优化调试的整个过程,连同踩过的坑和总结的技巧,完整地梳理一遍。
2. 核心思路与数学原理拆解
2.1 为什么是参数方程?
要让一个点动起来,我们首先得用数学语言描述它的运动轨迹。对于规则图形,参数方程是首选。它用一个独立的参数(通常是时间t或角度θ)来同时表示点的 x 坐标和 y 坐标。
对于中心在原点(0, 0),长半轴为a(在x轴方向),短半轴为b(在y轴方向)的标准椭圆,其参数方程为:
x = a * cos(θ) y = b * sin(θ)当θ从0变化到2π,点(x, y)就画出了一个完整的椭圆。
2.2 引入旋转:从“正”到“斜”
上面是“正着放”的椭圆。要让它“斜”过来,就需要引入旋转。假设我们要将椭圆逆时针旋转φ角度。
一个点(x, y)绕原点旋转φ角度后的新坐标(x’, y’)由旋转矩阵给出:
x’ = x * cos(φ) - y * sin(φ) y’ = x * sin(φ) + y * cos(φ)现在,我们把标准椭圆上的点(a*cos(θ), b*sin(θ))代入这个旋转公式,就得到了倾斜椭圆的参数方程:
x = a * cos(θ) * cos(φ) - b * sin(θ) * sin(φ) y = a * cos(θ) * sin(φ) + b * sin(θ) * cos(φ)这里,θ依然是我们的运动参数(可以理解为轨迹上的“相位角”),φ是椭圆整体的倾斜角度。通过这个方程,我们就能计算出在特定相位θ时,小球在画布上的精确坐标。
2.3 将数学映射到Canvas
Canvas的坐标系原点(0, 0)在左上角,y轴向下为正。这和我们习惯的数学坐标系(原点在中心,y轴向上)不同。为了绘图方便,我们通常会在逻辑计算时使用数学坐标系,然后在绘制前进行一次坐标变换,或者直接将椭圆中心点(centerX, centerY)的偏移量加到最终计算结果上。
因此,最终决定小球在Canvas上位置的公式是:
// 逻辑计算(数学坐标系) let logicX = a * Math.cos(theta) * Math.cos(phi) - b * Math.sin(theta) * Math.sin(phi); let logicY = a * Math.cos(theta) * Math.sin(phi) + b * Math.sin(theta) * Math.cos(phi); // 映射到Canvas坐标系(假设中心点为(centerX, centerY)) let canvasX = centerX + logicX; let canvasY = centerY + logicY; // 注意:这里没有对Y取负,因为Canvas的Y轴向下,我们直接加即可。这里有一个关键点:在数学上,(logicX, logicY)是点在旋转后的椭圆上的坐标。当我们把(centerX, centerY)直接加到它们上面时,相当于先把椭圆平移到以(centerX, centerY)为中心,然后再进行旋转。这个顺序是符合我们直觉的:先确定椭圆的大小和形状(a, b),再确定它的位置(centerX, centerY),最后决定它的朝向(φ)。
注意:如果你希望旋转是绕椭圆中心进行的(这通常是想要的效果),就必须使用上述“先平移后旋转”的等价计算方式,即使用上面的复合公式。千万不要先计算标准椭圆上的点,平移后再尝试旋转,那会得到错误的结果,因为旋转中心变了。
3. 代码实现与核心细节
理论清晰后,我们开始动手编码。我将分步构建一个可交互的示例。
3.1 搭建基础HTML结构与Canvas画布
首先,创建一个简单的HTML文件,包含一个Canvas元素和一些用于控制的滑块。这样我们能实时调整参数,观察效果。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Canvas斜椭圆轨迹运动</title> <style> body { margin: 20px; font-family: sans-serif; background-color: #f5f5f5; } #container { text-align: center; } #myCanvas { display: block; margin: 20px auto; background-color: white; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .control-panel { background-color: #fff; padding: 20px; border-radius: 8px; display: inline-block; box-shadow: 0 2px 8px rgba(0,0,0,0.08); text-align: left; margin-top: 20px; } .control-group { margin-bottom: 15px; } label { display: inline-block; width: 120px; font-weight: bold; color: #333; } input[type="range"] { width: 300px; vertical-align: middle; } .value-display { display: inline-block; width: 50px; text-align: center; font-family: monospace; background-color: #eee; padding: 2px 6px; border-radius: 3px; margin-left: 10px; } </style> </head> <body> <div id="container"> <h2>Canvas小球绕斜椭圆轨迹运动</h2> <canvas id="myCanvas" width="800" height="600"></canvas> <div class="control-panel"> <div class="control-group"> <label for="sliderA">长半轴 a:</label> <input type="range" id="sliderA" min="50" max="200" value="150"> <span id="valueA" class="value-display">150</span> </div> <div class="control-group"> <label for="sliderB">短半轴 b:</label> <input type="range" id="sliderB" min="30" max="150" value="80"> <span id="valueB" class="value-display">80</span> </div> <div class="control-group"> <label for="sliderPhi">倾斜角 φ (度):</label> <input type="range" id="sliderPhi" min="0" max="180" value="45"> <span id="valuePhi" class="value-display">45</span> </div> <div class="control-group"> <label for="sliderSpeed">运动速度:</label> <input type="range" id="sliderSpeed" min="0.5" max="5" step="0.1" value="1.5"> <span id="valueSpeed" class="value-display">1.5</span> </div> </div> </div> <script src="ellipse.js"></script> </body> </html>3.2 JavaScript核心逻辑实现 (ellipse.js)
接下来是重头戏,JavaScript部分。我们使用requestAnimationFrame来实现平滑动画。
// ellipse.js const canvas = document.getElementById('myCanvas'); const ctx = canvas.getContext('2d'); // 椭圆中心点(画布中心) const centerX = canvas.width / 2; const centerY = canvas.height / 2; // 初始参数 let a = 150; // 长半轴 let b = 80; // 短半轴 let phi = 45 * Math.PI / 180; // 倾斜角,转换为弧度 let speed = 1.5; // 角度增量系数,控制小球运动快慢 let theta = 0; // 初始相位角 // 小球属性 const ballRadius = 10; const ballColor = '#3498db'; // 蓝色小球 const trailColor = 'rgba(52, 152, 219, 0.1)'; // 轨迹尾迹颜色 const trailPoints = []; // 用于存储轨迹点,实现尾迹效果 const maxTrailLength = 100; // 尾迹最大长度 // 获取DOM元素并绑定事件 const sliderA = document.getElementById('sliderA'); const sliderB = document.getElementById('sliderB'); const sliderPhi = document.getElementById('sliderPhi'); const sliderSpeed = document.getElementById('sliderSpeed'); const valueA = document.getElementById('valueA'); const valueB = document.getElementById('valueB'); const valuePhi = document.getElementById('valuePhi'); const valueSpeed = document.getElementById('valueSpeed'); // 更新显示值 function updateDisplays() { valueA.textContent = a; valueB.textContent = b; valuePhi.textContent = Math.round(phi * 180 / Math.PI); valueSpeed.textContent = speed.toFixed(1); } // 滑块事件监听 sliderA.addEventListener('input', (e) => { a = parseInt(e.target.value); updateDisplays(); }); sliderB.addEventListener('input', (e) => { b = parseInt(e.target.value); updateDisplays(); }); sliderPhi.addEventListener('input', (e) => { phi = parseInt(e.target.value) * Math.PI / 180; updateDisplays(); }); sliderSpeed.addEventListener('input', (e) => { speed = parseFloat(e.target.value); updateDisplays(); }); // 初始化显示 updateDisplays(); // 根据参数方程计算小球位置 function calculatePosition(theta) { // 应用旋转后的椭圆参数方程 const x = a * Math.cos(theta) * Math.cos(phi) - b * Math.sin(theta) * Math.sin(phi); const y = a * Math.cos(theta) * Math.sin(phi) + b * Math.sin(theta) * Math.cos(phi); // 平移到画布中心 return { x: centerX + x, y: centerY + y }; } // 绘制静态的椭圆轨迹(参考线) function drawEllipsePath() { ctx.save(); ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]); // 虚线 ctx.beginPath(); // 绘制椭圆路径:采样多个点并连线 const points = []; const steps = 100; for (let i = 0; i <= steps; i++) { const t = (i / steps) * 2 * Math.PI; const pos = calculatePosition(t); if (i === 0) { ctx.moveTo(pos.x, pos.y); } else { ctx.lineTo(pos.x, pos.y); } } ctx.closePath(); ctx.stroke(); ctx.restore(); } // 绘制小球运动尾迹 function drawTrail() { if (trailPoints.length < 2) return; ctx.save(); ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (let i = 0; i < trailPoints.length - 1; i++) { const startPoint = trailPoints[i]; const endPoint = trailPoints[i + 1]; // 根据点的索引计算透明度,实现渐变消失效果 const alpha = (i / trailPoints.length) * 0.1; ctx.strokeStyle = `rgba(52, 152, 219, ${alpha})`; ctx.beginPath(); ctx.moveTo(startPoint.x, startPoint.y); ctx.lineTo(endPoint.x, endPoint.y); ctx.stroke(); } ctx.restore(); } // 绘制小球 function drawBall(x, y) { ctx.save(); // 小球阴影,增加立体感 ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'; ctx.shadowBlur = 8; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; // 小球主体 ctx.fillStyle = ballColor; ctx.beginPath(); ctx.arc(x, y, ballRadius, 0, Math.PI * 2); ctx.fill(); // 小球高光,增强质感 ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; ctx.beginPath(); ctx.arc(x - ballRadius * 0.3, y - ballRadius * 0.3, ballRadius * 0.4, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } // 绘制当前参数信息 function drawInfo() { ctx.save(); ctx.fillStyle = '#333'; ctx.font = '16px monospace'; ctx.textAlign = 'left'; ctx.fillText(`a: ${a}, b: ${b}, φ: ${Math.round(phi * 180 / Math.PI)}°, 速度: ${speed.toFixed(1)}`, 20, 30); const pos = calculatePosition(theta); ctx.fillText(`小球位置: (${Math.round(pos.x)}, ${Math.round(pos.y)})`, 20, 55); ctx.restore(); } // 动画循环 function animate() { // 清空画布,用半透明矩形实现尾迹淡出效果 ctx.fillStyle = 'rgba(245, 245, 245, 0.1)'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 绘制参考椭圆路径 drawEllipsePath(); // 计算并更新小球位置 theta += 0.01 * speed; // 更新相位角,速度系数影响变化率 // 保持theta在[0, 2π)范围内,防止数值过大 theta %= (Math.PI * 2); const ballPos = calculatePosition(theta); // 更新尾迹点 trailPoints.push({x: ballPos.x, y: ballPos.y}); if (trailPoints.length > maxTrailLength) { trailPoints.shift(); // 移除最旧的点,保持固定长度 } // 绘制尾迹 drawTrail(); // 绘制小球 drawBall(ballPos.x, ballPos.y); // 绘制信息 drawInfo(); // 循环下一帧 requestAnimationFrame(animate); } // 启动动画 animate();3.3 关键代码段解析
核心计算函数
calculatePosition(theta): 这是整个项目的数学引擎。它严格实现了我们推导出的旋转椭圆参数方程。输入一个相位角theta,输出画布坐标系下的(x, y)坐标。所有动态位置都源于此函数。动画循环
animate():theta += 0.01 * speed;:这是驱动动画的关键。每一帧,相位角增加一个微小量(0.01)乘以速度系数。这个增量值决定了动画的流畅度。0.01是一个经验值,在60fps下能产生平滑运动。speed滑块让用户可以实时调节。theta %= (Math.PI * 2);:使用取模运算确保theta不会无限增长,避免潜在的精度问题,并保证运动是周期性的。- 清空画布采用了
fillRect带低透明度颜色的技巧,而不是完全清除clearRect。这样能自然形成小球运动的拖尾痕迹,视觉效果更好。
尾迹效果实现:
trailPoints数组存储最近N帧小球的位置。每一帧将新位置加入数组末尾,如果数组超过最大长度,则移除头部最旧的位置。绘制时,从旧到新连线,并根据点的“年龄”(在数组中的索引)设置递减的透明度,从而实现从近到远、由深变浅的渐变拖尾效果。这是一个简单但非常有效的视觉增强技巧。实时交互: 通过监听滑块 (
input事件),实时更新椭圆参数a,b,phi和运动速度speed。这些值在下一帧的calculatePosition和animate循环中立即生效,实现了所见即所得的交互体验。phi在输入时被立即转换为弧度制,因为Math.sin和Math.cos需要弧度参数。
实操心得:在调试动画时,我最初用
setInterval来控制帧率,但发现动画流畅度严重依赖定时器的精度,且当页面处于非激活状态时会有问题。换成requestAnimationFrame后,浏览器会自动优化,在下次重绘前调用回调,动画变得无比丝滑,并且会自动暂停在后台标签页,节省系统资源。这是做Canvas动画的首选方式。
4. 性能优化与高级技巧
基础功能实现后,我们可以考虑如何让它更高效、更美观、更实用。
4.1 计算优化:避免重复计算三角函数
在calculatePosition函数和drawEllipsePath函数中,我们频繁调用了Math.cos(theta)、Math.sin(theta)、Math.cos(phi)和Math.sin(phi)。其中phi在单次动画循环中是不变的,theta则在变化。
一个简单的优化是预先计算cosPhi和sinPhi:
const cosPhi = Math.cos(phi); const sinPhi = Math.cos(phi); // 注意:这里有笔误,应该是 Math.sin(phi) function calculatePositionOptimized(theta) { const cosTheta = Math.cos(theta); const sinTheta = Math.sin(theta); const x = a * cosTheta * cosPhi - b * sinTheta * sinPhi; const y = a * cosTheta * sinPhi + b * sinTheta * cosPhi; return { x: centerX + x, y: centerY + y }; }对于绘制静态路径的drawEllipsePath,如果椭圆参数不变,我们甚至可以缓存整个路径的Path2D对象,而不是每帧重新计算100个点。
let cachedPath = null; let cachedParams = { a: 0, b: 0, phi: 0 }; function drawEllipsePathOptimized() { // 如果参数改变,重新计算路径 if (cachedParams.a !== a || cachedParams.b !== b || cachedParams.phi !== phi) { cachedPath = new Path2D(); const steps = 100; for (let i = 0; i <= steps; i++) { const t = (i / steps) * 2 * Math.PI; const pos = calculatePosition(t); // 这里可以用优化后的函数 if (i === 0) { cachedPath.moveTo(pos.x, pos.y); } else { cachedPath.lineTo(pos.x, pos.y); } } cachedPath.closePath(); cachedParams = { a, b, phi }; } ctx.save(); ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]); ctx.stroke(cachedPath); // 直接绘制缓存的路径对象 ctx.restore(); }这样,只有当用户通过滑块改变椭圆形状时,才需要重新构建路径,极大地减少了不必要的计算。
4.2 视觉增强:添加速度感与物理感
目前小球的运动是匀速的(theta线性增加)。但在真实世界中,物体在椭圆轨道上运动的速度并不是均匀的,根据开普勒定律,在相同时间内扫过的面积相等。我们可以模拟这种速度变化来增加真实感。
一个简化的近似是让角速度与当前点到椭圆中心的距离成反比(离中心越近,运动越快)。我们可以修改theta的增量逻辑:
// 在animate函数中,替换掉简单的 theta += 0.01 * speed; function getAngularSpeed(theta) { // 计算当前点到椭圆中心的距离 r const logicX = a * Math.cos(theta) * Math.cos(phi) - b * Math.sin(theta) * Math.sin(phi); const logicY = a * Math.cos(theta) * Math.sin(phi) + b * Math.sin(theta) * cos(phi); const r = Math.sqrt(logicX * logicX + logicY * logicY); // 基础速度与半径成反比,加上一个最小速度防止在长轴端点处过慢 const baseSpeed = 0.01; const minRadius = Math.min(a, b); return (baseSpeed * minRadius) / Math.max(r, minRadius * 0.5); } // 在动画循环中 const angularSpeed = getAngularSpeed(theta); theta += angularSpeed * speed; // 乘以用户控制的速度系数这样,小球在靠近椭圆中心(短轴附近)时会运动得快一些,在远离中心(长轴端点)时会慢一些,动画看起来更有“重量感”和“轨道感”。
4.3 交互扩展:鼠标悬停与点击效果
为了让演示更有趣,可以增加交互。例如,当鼠标靠近小球时,小球变色或放大;点击画布任意位置,让小球瞬间移动到该点对应的椭圆相位上。
鼠标悬停效果:
canvas.addEventListener('mousemove', (event) => { const rect = canvas.getBoundingClientRect(); const mouseX = event.clientX - rect.left; const mouseY = event.clientY - rect.top; const ballPos = calculatePosition(theta); const distance = Math.sqrt((mouseX - ballPos.x) ** 2 + (mouseY - ballPos.y) ** 2); // 如果距离小于阈值,改变小球颜色 ballColor = distance < ballRadius * 2 ? '#e74c3c' : '#3498db'; // 红色高亮 });需要在drawBall函数中使用动态的ballColor变量。
点击跳转相位:这个稍微复杂点,需要解决“从Canvas坐标反推椭圆相位角theta”的逆问题。对于旋转后的椭圆,没有简单的反函数。我们可以采用数值方法近似求解:遍历一个周期内的多个theta值,计算对应位置,找到距离点击点最近的那个theta,然后让小球从这个新相位开始运动。
canvas.addEventListener('click', (event) => { const rect = canvas.getBoundingClientRect(); const clickX = event.clientX - rect.left - centerX; // 转换为以椭圆中心为原点的坐标 const clickY = event.clientY - rect.top - centerY; let minDist = Infinity; let bestTheta = theta; // 默认保持当前相位 // 采样搜索最佳相位 const searchSteps = 200; // 搜索精度 for (let i = 0; i < searchSteps; i++) { const testTheta = (i / searchSteps) * Math.PI * 2; const pos = calculatePosition(testTheta); const logicX = pos.x - centerX; const logicY = pos.y - centerY; const dist = (logicX - clickX) ** 2 + (logicY - clickY) ** 2; // 使用平方距离比较,避免开方 if (dist < minDist) { minDist = dist; bestTheta = testTheta; } } // 平滑过渡到新的相位(可选) // theta = bestTheta; // 直接跳转 // 或者使用线性插值在几帧内过渡过去 });5. 常见问题与调试技巧
在实际编码和调试过程中,我遇到了几个典型问题,这里记录下来供你参考。
5.1 小球运动“卡顿”或“跳帧”
- 现象:动画不流畅,小球移动时有一顿一顿的感觉。
- 排查:
- 检查
requestAnimationFrame:确保动画循环正确使用requestAnimationFrame(animate),而不是setInterval。 - 检查计算负载:在
animate函数中执行了过于复杂的计算或DOM操作。特别是drawEllipsePath如果每帧都采样100个点重新计算,在性能较差的设备上可能成为瓶颈。使用前面提到的Path2D缓存优化。 - 检查相位角增量:
theta的增量 (0.01 * speed) 是否过大?过大的步长会导致小球每次“跳跃”的距离很大,看起来就不连续。可以尝试减小基础增量值。
- 检查
- 解决:优先应用计算缓存优化。确保所有与动画无关的操作(如复杂的DOM更新)移到动画循环之外。
5.2 椭圆轨迹绘制不正确(如变成直线或奇怪的形状)
- 现象:画出来的轨迹不是预期的倾斜椭圆。
- 排查:
- 公式核对:这是最常见的原因。请再次仔细核对旋转椭圆参数方程,确保正负号和乘除顺序完全正确。最容易出错的地方是
sin(phi)和cos(phi)的位置。 - 单位确认:
phi(倾斜角)传入Math.sin和Math.cos时,必须是弧度制。如果你从滑块获取的是角度值,务必乘以Math.PI / 180进行转换。 - 坐标轴方向:确认你是否无意中改变了Canvas的Y轴方向。在我们的公式中,我们直接
centerY + y,默认Canvas的Y轴向下。如果你在别处使用了translate或scale变换,可能会影响结果。
- 公式核对:这是最常见的原因。请再次仔细核对旋转椭圆参数方程,确保正负号和乘除顺序完全正确。最容易出错的地方是
- 调试技巧:单独写一个测试函数,固定
theta为几个特殊值(如0,Math.PI/2,Math.PI),手动计算并console.log出(x, y)坐标,看它们是否符合预期。例如,当theta=0时,小球应在椭圆长轴的端点之一。
5.3 尾迹效果不清晰或残留严重
- 现象:小球后面的轨迹要么太淡看不清,要么残留很久不消失,画面脏乱。
- 排查:
- 清空画布的方式:我使用的是半透明
fillRect来实现淡出。fillStyle的rgba中 alpha 值(最后一个参数)决定了淡出速度。值太小(如0.02)尾迹留存时间长,值太大(如0.3)尾迹很短促。可以调整这个值,比如rgba(245,245,245,0.1)。 - 尾迹点数组长度:
maxTrailLength决定了尾迹的“长度”(帧数)。太短则轨迹短,太长则轨迹可能绕椭圆一圈,显得混乱。通常50-150是一个合理的范围。 - 尾迹绘制样式:
drawTrail函数中,线宽lineWidth和颜色透明度alpha的计算方式会影响视觉效果。我使用的alpha = (i / trailPoints.length) * 0.1会让最旧的点最淡。你可以调整系数0.1来改变最大透明度。
- 清空画布的方式:我使用的是半透明
- 个性化调整:尝试不同的清空策略。比如,你可以不用淡出,而是每帧完全清空 (
ctx.clearRect),然后只绘制当前帧的尾迹线段,这样尾迹会是固定长度且非常清晰,没有渐变效果。两种风格各有千秋。
5.4 在高分辨率屏幕上Canvas模糊
- 现象:在Retina屏或高DPI显示器上,Canvas绘制的内容看起来模糊。
- 原因:Canvas的CSS尺寸(
width和height属性)与它的绘图表面尺寸(canvas.width和canvas.height属性)不一致。通常我们通过CSS设置显示大小,但绘图表面默认是300x150。在高DPI设备上,浏览器会拉伸低分辨率的绘图表面来填充CSS尺寸,导致模糊。 - 解决:在初始化时,根据设备的
devicePixelRatio来动态设置Canvas的绘图表面尺寸。
function setupHighDPICanvas(canvas) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); // 将内部尺寸设置为CSS尺寸的dpr倍 canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; // 缩放上下文,使一个CSS像素对应一个设备像素 const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); // 注意:此后所有绘图坐标和尺寸都应基于CSS像素值,无需再乘以dpr } // 在脚本开始时调用 setupHighDPICanvas(canvas); // 同时,需要将centerX, centerY的计算改为基于canvas的CSS尺寸 // const centerX = canvas.width / (2 * dpr); // 错误,因为ctx.scale已经处理了 // 正确:因为scale后,坐标系已匹配CSS像素 const centerX = canvas.width / (2 * (window.devicePixelRatio || 1)); // 这是一种方法 // 更清晰的方法:直接使用rect const rect = canvas.getBoundingClientRect(); const centerX = rect.width / 2; const centerY = rect.height / 2;这个处理稍显繁琐,但对于追求高清显示的Canvas应用是必要的。
5.5 性能问题排查清单
如果动画仍然感觉不流畅,可以按以下步骤排查:
| 可能原因 | 检查点 | 解决方案 |
|---|---|---|
| 重绘区域过大 | 是否每帧都清空/重绘了整个画布? | 如果只有小球移动,可以尝试只擦除小球上一帧所在区域及其尾迹影响区域(脏矩形渲染),但这在动态背景下实现复杂。对于本项目,全屏清空是可以接受的。 |
| 频繁的复杂计算 | 是否在动画循环中进行了不必要的复杂数学运算、DOM查询? | 缓存计算结果(如cosPhi,sinPhi, 椭圆路径)。将DOM查询移到循环外。 |
| 图层复合开销 | Canvas内容是否非常复杂,且频繁变化? | 对于静态背景(如椭圆路径),绘制到另一个离屏Canvas上,然后每帧直接drawImage过来,减少重复绘制开销。 |
| 浏览器开发者工具 | 使用浏览器的性能分析器(Performance tab)录制几秒动画。 | 查看火焰图,找到耗时最长的函数调用,针对性优化。 |
最后,分享一个调试时的小技巧:我经常在animate函数开头用console.log输出每一帧计算出的theta和位置,或者帧间隔时间,这对于检查动画逻辑和性能瓶颈非常有帮助。当然,发布前记得移除这些日志。