1. 从"大球自转+小球公转+移动"这个标题里能读出什么
第一次看到"大球自转+小球公转+移动"这个标题,我脑子里蹦出来的第一个画面是太阳系模型——地球一边自转,一边绕着太阳公转,同时整个太阳系还在银河系里移动。这个标题本质上描述的是一个复合运动系统:一个中心天体(大球)自身旋转,一个附属天体(小球)围绕中心天体旋转,同时整个系统还在空间中做整体位移。
这种"自转+公转+整体移动"的三重运动叠加,在图形编程、游戏开发、物理仿真教学里是非常经典的入门案例。它看起来简单,但真正动手写的时候,你会发现坑比想象中多得多——旋转中心搞错了、公转半径算偏了、移动和旋转的坐标系混在一起导致小球"飞出去"、帧率波动导致运动速度不一致……这些问题几乎每个初学者都会踩一遍。
这篇文章适合三类人看:第一类是做图形编程或游戏开发的新手,想搞明白复合运动到底怎么叠加;第二类是需要做物理仿真演示的老师或课程设计者,想找一个可复现的参考方案;第三类是有一定基础但总在坐标系和变换顺序上翻车的开发者,想彻底理清旋转与平移的关系。
我会从最核心的运动分解讲起,把"自转""公转""移动"三个运动拆开揉碎,再讲它们怎么正确叠加,然后给出完整的实操步骤和参数计算方法,最后分享几个我在实际项目中踩过的坑和总结出来的技巧。整篇内容不依赖任何特定引擎,思路通用,你用Canvas、OpenGL、Unity还是Python的matplotlib都能套进去。
2. 三重运动拆解:自转、公转、移动各自到底在做什么
2.1 大球自转:绕自身轴心的旋转
大球自转,说白了就是大球绕着自己的一条轴在转。在二维平面里,这条轴就是穿过大球中心的一个点;在三维空间里,通常是一条穿过球心的直线(比如Y轴)。
自转的核心参数只有两个:旋转角速度和旋转轴。角速度决定了转多快,旋转轴决定了往哪个方向转。很多人写自转的时候容易犯一个错误——把旋转中心设成了画布原点或者屏幕中心,而不是大球自己的中心。结果就是大球不是"自转",而是绕着屏幕某个点"甩"出去了。
正确的做法是:先把坐标系平移到球心,执行旋转,再平移回去。用变换矩阵的语言来说就是:
M_selfRotation = T(center) * R(angle) * T(-center)其中T是平移矩阵,R是旋转矩阵,center是大球的中心坐标。这个"平移-旋转-平移回来"的模式是图形编程里处理绕任意点旋转的标准套路,后面讲公转的时候还会用到类似思路。
自转的角速度通常用"弧度/秒"来表示。如果你习惯用"度/秒",记得转换:弧度 = 度 × π / 180。我见过不少新手直接拿度数往旋转函数里塞,结果球转得跟陀螺失控一样,排查半天才发现是单位问题。
2.2 小球公转:绕大球中心的轨道运动
小球公转,是小球围绕大球的中心做圆周运动。这里的关键点是:公转的旋转中心是大球的中心,不是小球自己的中心,也不是屏幕原点。
公转的数学表达其实很简洁。假设大球中心在(Cx, Cy),公转半径是r,公转角度是θ,那么小球的位置就是:
x = Cx + r * cos(θ) y = Cy + r * sin(θ)这个公式看起来简单,但它隐含了一个重要前提——θ是随时间递增的。所以实际写的时候,你需要维护一个累积角度变量,每帧根据角速度更新:
θ += ω * Δt其中ω是公转角速度,Δt是帧间隔时间。这里就引出了第一个大坑:如果你直接用帧数来累加角度,而不是用时间,那么在不同性能的设备上运动速度会完全不同。60帧的设备上小球转一圈用1秒,30帧的设备上就要2秒。这个坑我在早期做网页动画时踩过,后来统一改成基于时间累加才解决。
另外,公转半径r的设定也有讲究。如果r太小,小球会和大球重叠;如果r太大,小球可能跑出画面。通常r应该大于大球半径加上小球半径,保证两者不穿插。具体留多少余量,取决于你想要视觉效果还是物理真实性。
2.3 整体移动:让整个系统在空间中位移
整体移动是最容易被低估的一环。很多人觉得"移动不就是改一下位置坐标吗",但实际上,当系统里同时存在自转和公转时,移动的叠加方式会直接影响最终效果。
整体移动的本质是:大球中心和小球位置同时加上同一个位移量。也就是说,移动是作用在整个系统上的,不改变大球和小球之间的相对关系。
这里有个关键顺序问题。如果你先计算好小球相对于大球的位置,再加上整体位移,那没问题。但如果你先移动大球,再用移动后的大球中心去算小球公转位置,那小球就会"跟着大球跑",看起来像是公转半径在变化。这两种做法在数学上可以等价,但实现时的坐标系管理复杂度完全不同。
我的建议是:始终在一个"局部坐标系"里计算自转和公转,最后统一叠加整体位移。这样逻辑最清晰,也最不容易出错。具体来说:
小球最终位置 = 大球中心 + 公转偏移量 + 整体位移量 大球最终位置 = 大球中心 + 整体位移量注意大球中心本身也可以随时间变化,如果整体移动是匀速的,那大球中心就是初始中心 + 速度 × 时间。
3. 三重运动叠加时的坐标系陷阱与变换顺序
3.1 为什么"先旋转再平移"和"先平移再旋转"结果完全不同
这是图形编程里最经典的坑之一。旋转和平移是两种不同的变换,它们不满足交换律——先旋转再平移,和先平移再旋转,得到的结果完全不一样。
举个具体例子。假设大球中心在原点,小球在(r, 0)位置。现在要让整个系统向右移动d,同时小球公转90度。
- 先公转再移动:小球先转到
(0, r),再向右移d,最终在(d, r)。 - 先移动再公转:小球先移到
(r+d, 0),再绕大球新中心(d, 0)转90度,最终在(d, r)。
咦,这个例子里结果一样?那是因为移动方向和旋转后的位置恰好对称。换个例子:如果移动方向是任意的,比如向右上移动,结果就会不同。更关键的是,当自转、公转、移动三者同时存在时,变换顺序的错误会导致小球轨迹完全乱掉。
正确的顺序应该是:先自转(对大球自身),再公转(小球绕大球),最后整体移动(两者一起)。用矩阵表达就是:
M_total = M_translate * M_orbit * M_selfRotate这个顺序保证了自转不影响公转中心,公转不影响整体位移方向。
3.2 局部坐标系与全局坐标系的切换时机
在实际编码中,我强烈建议把"局部计算"和"全局渲染"分开。具体做法是:
- 在局部坐标系里,大球中心固定在原点,小球位置由公转角度决定。
- 渲染时,先把坐标系平移到全局位置,再绘制局部坐标里的内容。
这样做的好处是,自转和公转的计算完全不依赖全局位置,代码更干净,调试也更容易。如果你把所有计算都混在全局坐标里,一旦移动速度变化或者方向改变,你就得重新推导所有公式。
用伪代码表示就是:
// 局部计算 localBallPos = (r * cos(θ), r * sin(θ)) localBigBallRotation = φ // 全局渲染 globalCenter = initialCenter + velocity * time drawBigBall(globalCenter, localBigBallRotation) drawSmallBall(globalCenter + localBallPos)这种分层思路在复杂场景里尤其重要。我做过一个项目,里面有十几个天体各自自转公转,如果不用局部坐标系,代码会变成一团乱麻。
3.3 角度累积的数值精度问题
角度θ和φ如果一直累加下去,时间长了会变得非常大。在单精度浮点数下,当角度超过几万弧度时,cos和sin的精度会明显下降,表现为小球运动出现抖动或跳变。
解决办法很简单:定期对角度取模2π。比如每帧更新后执行θ = θ % (2 * Math.PI)。这样角度始终保持在[0, 2π)范围内,精度问题自然消失。
这个技巧在长时间运行的程序里特别重要。我曾经做过一个展示程序,连续跑了几个小时之后小球开始"抽搐",排查了很久才发现是角度累积导致的浮点精度丢失。加上取模之后问题彻底解决。
4. 从零实现:完整步骤与参数计算
4.1 环境准备与基础框架搭建
不管你用什么技术栈,基础框架都差不多。我以最常见的Canvas 2D为例,因为它的坐标系直观,适合讲解。如果你用其他引擎,把对应的绘制API替换掉即可。
首先准备一个画布,获取上下文:
const canvas = document.getElementById('scene'); const ctx = canvas.getContext('2d'); const W = canvas.width; const H = canvas.height;然后定义核心参数。这些参数决定了整个系统的行为:
const bigBall = { radius: 60, center: { x: W / 2, y: H / 2 }, selfAngle: 0, selfSpeed: Math.PI / 2 // 每秒转90度 }; const smallBall = { radius: 15, orbitRadius: 140, orbitAngle: 0, orbitSpeed: Math.PI // 每秒转180度 }; const systemVelocity = { x: 40, y: 20 }; // 每秒移动40像素横向,20像素纵向这里我特意让公转速度是自转速度的两倍,这样视觉上能明显看出两者在独立运动。实际项目中你可以根据需求调整。
4.2 基于时间步长的角度与位置更新
核心更新逻辑必须基于时间差Δt,而不是固定帧步长。这是保证不同设备上运动一致的关键。
let lastTime = performance.now(); function update(currentTime) { const dt = (currentTime - lastTime) / 1000; // 转成秒 lastTime = currentTime; // 更新自转角度 bigBall.selfAngle += bigBall.selfSpeed * dt; bigBall.selfAngle %= 2 * Math.PI; // 更新公转角度 smallBall.orbitAngle += smallBall.orbitSpeed * dt; smallBall.orbitAngle %= 2 * Math.PI; // 更新整体位置 bigBall.center.x += systemVelocity.x * dt; bigBall.center.y += systemVelocity.y * dt; // 边界反弹(可选) if (bigBall.center.x < bigBall.radius || bigBall.center.x > W - bigBall.radius) { systemVelocity.x *= -1; } if (bigBall.center.y < bigBall.radius || bigBall.center.y > H - bigBall.radius) { systemVelocity.y *= -1; } }注意dt的单位是秒,所以速度参数用的是"每秒多少弧度"和"每秒多少像素"。如果你习惯用毫秒,记得统一。
4.3 小球位置的精确计算与绘制
小球的位置计算要严格遵循"大球中心 + 公转偏移"的公式:
function getSmallBallPosition() { return { x: bigBall.center.x + smallBall.orbitRadius * Math.cos(smallBall.orbitAngle), y: bigBall.center.y + smallBall.orbitRadius * Math.sin(smallBall.orbitAngle) }; }绘制的时候,大球要体现自转效果。如果大球是纯色圆,自转看不出来,所以通常会给大球加一条标记线或者用纹理:
function draw() { ctx.clearRect(0, 0, W, H); // 绘制大球 ctx.save(); ctx.translate(bigBall.center.x, bigBall.center.y); ctx.rotate(bigBall.selfAngle); ctx.beginPath(); ctx.arc(0, 0, bigBall.radius, 0, 2 * Math.PI); ctx.fillStyle = '#4A90D9'; ctx.fill(); // 画一条半径线来显示自转 ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(bigBall.radius, 0); ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 3; ctx.stroke(); ctx.restore(); // 绘制小球 const pos = getSmallBallPosition(); ctx.beginPath(); ctx.arc(pos.x, pos.y, smallBall.radius, 0, 2 * Math.PI); ctx.fillStyle = '#E85D5D'; ctx.fill(); }这里用了ctx.save()和ctx.restore()来隔离大球自转的坐标系变换,避免影响后续绘制。这个习惯非常重要,否则坐标系变换会"泄漏"到后面的绘制操作里。
4.4 主循环的组织与帧率无关性验证
把更新和绘制串起来:
function loop(currentTime) { update(currentTime); draw(); requestAnimationFrame(loop); } requestAnimationFrame(loop);验证帧率无关性的方法很简单:在浏览器开发者工具里把CPU降速到6倍,观察小球公转一圈的时间是否仍然是2秒(因为公转速度是π弧度/秒,一圈2π需要2秒)。如果时间明显变长,说明你的更新逻辑依赖了帧数而不是时间。
我实测下来,基于performance.now()的时间差方案在大多数场景下足够精确。如果你需要更高精度,可以用performance.now()的高精度模式,或者用固定时间步长加插值的方式。
5. 实测中容易翻车的几个细节
5.1 小球"飞出去"的三种典型原因
小球飞出去是这类项目最高频的问题。我总结下来主要有三种原因:
第一种是公转中心用错了。如果你把公转中心设成了屏幕原点而不是大球中心,小球就会绕着屏幕角落转,看起来像是飞出去了。检查方法:打印大球中心和小球位置,看小球到原点的距离是否恒定。
第二种是角度单位混用。Math.cos和Math.sin接收的是弧度,如果你传了度数,结果会完全错误。比如cos(90)在弧度制下约等于 -0.45,而不是0。这个坑很隐蔽,因为程序不会报错,只是结果不对。
第三种是移动和公转的叠加顺序错了。如果你先移动大球,再用移动后的大球中心算小球位置,但小球的位置又反过来影响了大球中心(比如做了某种耦合),就会形成正反馈,小球越飞越远。
排查这类问题的通用方法是:先把移动速度设为0,只保留自转和公转,确认小球轨迹是标准圆形;再逐步加入移动,观察轨迹是否变成摆线或螺旋线。如果加入移动后轨迹异常,问题一定出在叠加顺序上。
5.2 自转视觉效果的常见误区
很多人以为给大球加个颜色渐变就能看出自转,实际上纯色圆旋转是看不出来的——因为圆具有旋转对称性。要体现自转,必须打破对称性,常见做法有:
- 在大球上画一条半径线或十字标记
- 用非对称纹理贴图
- 在大球表面画几个不同颜色的小点
我推荐用半径线,实现简单且效果直观。如果你用纹理,注意纹理坐标的旋转方向要和自转方向一致,否则会出现"视觉反转"的诡异效果。
另外,自转速度不宜过快。如果每秒转好几圈,人眼会把它看成模糊的圆盘,反而看不出旋转。一般每秒0.5到2圈比较合适。
5.3 边界处理与运动范围控制
如果整体移动没有边界限制,系统很快就会跑出画面。常见的边界处理有三种:
| 处理方式 | 效果 | 适用场景 |
|---|---|---|
| 反弹 | 碰到边界反向 | 演示、游戏 |
| 环绕 | 从另一边出现 | 无缝滚动背景 |
| 限制 | 停在边界 | 固定展示区域 |
反弹处理要注意:反弹后速度方向取反,但位置要修正回边界内,否则可能连续触发反弹导致抖动。我通常会在反弹时把位置强制设到边界上:
if (bigBall.center.x - bigBall.radius < 0) { bigBall.center.x = bigBall.radius; systemVelocity.x = Math.abs(systemVelocity.x); }这样比简单取反更稳定。
5.4 性能优化:什么时候该用矩阵,什么时候直接算
对于只有两个球体的场景,直接三角函数计算完全够用,没必要上矩阵库。但如果你要扩展到几十个天体,或者需要嵌套多层旋转,矩阵方案会更清晰、更易维护。
我的经验法则是:运动层级超过两层,或者需要频繁组合变换时,用矩阵。否则直接用三角函数,代码更短,调试更方便。
另外,Math.cos和Math.sin的调用频率如果很高,可以考虑用查表法或者缓存。但在现代浏览器上,对于几十个对象的场景,这点开销可以忽略不计,不必过早优化。
6. 这个案例还能怎么扩展
把基础版本跑通之后,这个案例可以往很多方向扩展。比如加入第三个球体,让小球也有自己的卫星,形成"大球自转+小球公转+小小球绕小球公转+整体移动"的四重运动。这时候局部坐标系的分层设计就体现出价值了——你只需要在局部计算里再加一层,全局渲染逻辑几乎不用改。
另一个方向是加入物理效果,比如公转速度随半径变化(模拟开普勒定律),或者加入引力扰动让轨道变成椭圆。这些扩展会让案例从"图形演示"升级为"物理仿真",适合做课程设计或教学演示。
还有一个实用方向是把它做成可交互的:用鼠标拖拽改变整体移动方向,用滚轮调整公转半径,用键盘控制自转速度。这样不仅能加深对运动叠加的理解,还能做成一个不错的交互式教学工具。
我在实际做这类演示时,最喜欢加的一个功能是"轨迹拖尾"——记录小球最近几十帧的位置,用渐变色画出来。这样能直观看到小球的实际运动轨迹是圆形、摆线还是螺旋线,对理解复合运动帮助极大。实现也很简单,用一个数组存历史位置,绘制时按时间衰减透明度即可。
最后分享一个小技巧:调试这类运动系统时,把关键变量(自转角度、公转角度、大球中心、小球位置)实时显示在画面上,比在控制台打印高效得多。你能一眼看出哪个变量在异常变化,排查效率至少提升一倍。