ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity3D蛇形摆波动模拟:从周期梯度到纯数学驱动的完美循环

Unity3D蛇形摆波动模拟:从周期梯度到纯数学驱动的完美循环 做蛇形摆波动模拟这个项目起因特别简单Unity3D 里做惯了很多“界面 交互”的项目偶尔想回归到纯数学和视觉效果——让一组摆锤自己动起来还能走出波浪。蛇形摆算是我见过性价比最高的物理演示之一十几根单摆挂在同一根横梁上你同时松手它们会因为摆长不同、周期不同在空间里逐渐错开从侧面看是一条正在游动的正弦波过一会儿又全部“合体”回到初始状态。这个项目核心涉及的小角度单摆周期公式、周期梯度设计、摆角解析式更新、场景对象层级与视觉呈现很适合用来练 Unity3D 的脚本数学建模和对象管理。不管你是刚接触 Unity3D 想做点酷东西的新手还是需要在展项、大屏、数字艺术里做动态效果的老手这套方案都可以直接参考。1. 蛇形摆的物理原理与数学建模1.1 从单摆到阵列波动从哪来蛇形摆不是某种复杂的混沌系统本质就是若干个单摆排成一排。单个单摆的周期公式是T 2π√(L/g)其中 L 是摆长g 是当地重力加速度Unity3D 里默认取 9.81 m/s² 比较接近真实世界。周期只由摆长和重力加速度决定跟摆锤质量、释放角度大小小角度范围内基本无关。当我把 15 个摆的摆长设置成梯度变化时每个摆的周期就各不相同。假设所有摆在零时刻同时从同一个摆角释放之后某个时刻 t第 i 个摆的角位置可以写成θᵢ(t) θ₀ · cos(2π·t / Tᵢ φᵢ)因为 Tᵢ 彼此不同所以经过相同的时间 t每个摆转过的相位 2π·t/Tᵢ 就不一样。相邻摆之间的相位差如果沿阵列方向近似线性那么摆锤在空间里的连线就是一条近似的正弦曲线。这条曲线随时间不断变化看起来就像蛇形波浪在横梁上游动。这里面的关键是“相位差沿阵列线性”。要让相位差大致线性就要求每个摆的周期也大致呈线性梯度。如果摆长设置得非常随意那视觉效果就是一堆摆乱晃成不了型。1.2 周期梯度设计的两种方案实际操作中主流的梯度设计有两种我分别说下适用场景。第一种是周期等差方案。直接给每个摆指定周期 Tᵢ T_min (i - 1)·ΔT然后用单摆周期公式反推出需要的摆长Lᵢ g·Tᵢ² / (4π²)这个方案的好处是调参直观。我想让第一个摆周期为 1.0 秒最后一个摆周期为 2.2 秒中间等差过渡直接算即可。视觉上各摆之间的“波浪长度”会比较均匀适合快速搭建演示。第二种是摆长等差方案。让 Lᵢ L_min (i - 1)·ΔL直接按照实验室里摆长等距递增来做。这个方案比较接近实际物理装置摆锤位置均匀但算出来的周期不完全是线性递增因为 T 与 √L 相关。当摆长差异相对较小时周期也近似线性效果差异不大。如果项目是纯数学模拟我更推荐周期等差方案因为调参直观符合人对“波动频率”的直觉。如果项目是要搭一个看起来像真实装置的实验场景摆长等差会更自然因为悬挂点间距均匀摆杆长度变化均匀。1.3 公式与参数换算给一组实际可用的参数供参考。假设用 15 个摆周期从 1.0 秒到 2.2 秒线性递增重力加速度按 9.81 m/s² 计算编号周期 (s)摆长 (m)01.000.24811.090.29421.170.34331.260.39541.340.45151.430.51161.510.57471.600.64181.690.71291.770.786101.860.865111.940.947122.031.033132.111.122142.201.216这套参数下最长的摆约 1.22 米最短约 0.25 米在 Unity3D 场景里比例比较协调。如果场景单位是 1 单位 1 米直接按这个数值摆放即可。注意如果摆长差异过大相邻摆的球可能会出现视觉穿插后面我会讲怎么控制空间间距。2. Unity3D 实现方案选型2.1 Rigidbody HingeJoint 的物理路线最初我考虑过用 Unity3D 物理引擎来做毕竟它自带重力、碰撞、关节。比较常规的做法是每个摆挂一个 HingeJoint把球体 Rigidbody 挂上去让 Joint 的 Anchor 固定在横梁位置然后调节 Motor 和 Limits 让它产生摆动。这条路线做出来确实有真实物理的味道球与球之间可以有碰撞摆动过程会受摩擦力、阻尼影响甚至可以被玩家用手推。但实际做下来遇到几个麻烦第一默认 Rigidbody 虽然 Linear Drag 是 0但长时间物理仿真仍会有数值耗散摆幅会慢慢变小。想让它持续摆下去得给 HingeJoint 加 Motor让系统不断补能调参调起来比较心烦。第二HingeJoint 的坐标轴方向、Anchor 位置很容易搞错。尤其在多个物体挂同一个刚体横梁时连接顺序稍微不对摆就会抽搐。第三物理引擎的步长直接影响稳定性和一致性。如果项目里还有其他物理对象步长不稳定会导致摆动精度没法控制演示效果时好时坏。物理路线适合做“可交互的真实摆”但它不适合做“讲究视觉效果和精确编排的蛇形波”。2.2 纯解析解用三角函数驱动摆锤另一种方案完全绕开物理引擎用解析解驱动每个摆锤。我们在 Update 里根据当前时间 t 和每个摆的周期 Tᵢ通过三角函数直接算出摆角然后设置 Transform 的旋转或坐标位置。这个方案的核心代码模型就是angle amplitude · cos(2π · t / Tᵢ φᵢ)然后用旋转挂点对象的方式让摆锤摆动。因为每一帧都是根据当前时间重新计算角度所以不存在物理引擎那种能量损耗。只要时间连续摆就能一直波动下去而且是严格可预测的。这种方案的另一个优势是相位可控。我可以随意给某几个摆增加相位偏移制造不对称的波浪这是物理引擎很难做到的。做展项、做特效、做编排时解析解几乎是唯一选择。2.3 为什么我最终选择纯数学模拟实际项目里我的需求很明确要一个稳定、循环、视觉优美的蛇形波浪而不是一个需要维护的物理系统。纯数学模拟在代码量、稳定性、可视化效果上都完胜。从性能角度看15 个摆每帧也就 15 次三角函数计算完全不吃力。即便扩展到 100 个摆Transform 更新依然是毫秒级。真实物理引擎要算 100 个关节和碰撞体性能开销和稳定性问题就大得多了。从效果角度看纯数学模拟配合 Time.timeAsDouble 做时间源可以精确控制“什么时候回到初始状态”。如果想让波形永不发散、永远有规律物理引擎反而做不到。当然纯数学模拟也有局限摆之间不会碰撞摆锤遇到障碍物也不会真实反应。但这只是“模拟”而不是“仿真”对视觉演示项目来说完全够用了。3. 从零搭建蛇形摆模拟3.1 场景与对象的组织方式我在 Unity3D 里搭建时场景层级用的是这种结构Root (SnakePendulum 脚本挂在 Root 上) ├── Pivot_0 │ ├── Rod_0 │ └── Bob_0 ├── Pivot_1 │ ├── Rod_1 │ └── Bob_1 └── ...Pivot 是一个只带 Transform 的空物体它位于横梁高度也就是悬挂点。Rod 是摆杆Bob 是摆锤二者都是 Pivot 的子物体。Rod 的本地坐标是 (0, -length/2, 0)缩放高度设为 lengthBob 的本地坐标是 (0, -length, 0)。这样在 Update 里直接旋转 Pivot整个摆就会绕悬挂点摆动而摆锤的运动轨迹就是标准的圆弧。这种层级设计比“直接改球体位置”更简洁因为旋转父物体后球的位置自动满足圆弧约束不需要自己手动算 X、Y 坐标也方便后续调整单个摆的颜色、杆粗细、球大小。挂点之间沿 X 轴均匀排列挂点间距我建议至少大于最大横向位移差。比如摆长为 1.2 米、摆幅 22 度时横向位移大约 1.2 × sin(22°) ≈ 0.45 米最短的摆长约 0.25 米横向位移约 0.09 米。相邻摆位移差接近 0.36 米所以间距取 0.6 米比较安全球和杆不会明显穿模。3.2 周期计算与核心脚本我的核心脚本叫 SnakePendulumSimulation挂在根节点上。脚本里先定义参数public class SnakePendulumSimulation : MonoBehaviour { [Header(阵列参数)] public int count 15; public float spacing 0.6f; [Header(周期梯度)] public float minPeriod 1.0f; public float maxPeriod 2.2f; [Header(摆幅)] public float amplitude 22f; [Header(是否使用整循环模式)] public bool useLoopSync true; public float loopTime 20f; public int baseCycles 18; }真正的创建逻辑放在 Start 方法里。每个摆的周期计算我会抽成一个函数方便后续加不同模式private float GetPeriod(int index) { float t (float)index / Mathf.Max(1, count - 1); float period Mathf.Lerp(minPeriod, maxPeriod, t); if (useLoopSync) { int cycles baseCycles index; period loopTime / cycles; } return period; }如果 useLoopSync 为 true则每个摆在 loopTime 秒内完成整数次摆动这样整个系统会在固定时间点严丝合缝回到初始状态。这对循环演出非常有用。如果只是随便看看用 Lerp 线性周期也够。创建摆锤和摆杆的代码private void CreatePendulum(int index, float length) { GameObject pivotGo new GameObject(Pivot_ index); pivotGo.transform.SetParent(transform, false); pivotGo.transform.localPosition new Vector3(index * spacing, 0f, 0f); GameObject rod GameObject.CreatePrimitive(PrimitiveType.Cylinder); Destroy(rod.GetComponentCollider()); rod.name Rod_ index; rod.transform.SetParent(pivotGo.transform, false); rod.transform.localPosition new Vector3(0f, -length * 0.5f, 0f); rod.transform.localScale new Vector3(0.04f, length * 0.5f, 0.04f); GameObject bob GameObject.CreatePrimitive(PrimitiveType.Sphere); Destroy(bob.GetComponentCollider()); bob.name Bob_ index; bob.transform.SetParent(pivotGo.transform, false); bob.transform.localPosition new Vector3(0f, -length, 0f); bob.transform.localScale Vector3.one * 0.22f; Renderer bobRenderer bob.GetComponentRenderer(); bobRenderer.material.color Color.HSVToRGB((float)index / count, 0.85f, 1f); }值得一提的小细节创建出来的 Primitive 自带 Collider纯数学模拟用不到而且留着会占用物理系统的额外开销我直接 Destroy 掉。Update 里的摆动逻辑void Update() { double time Time.timeAsDouble - startTime; double t useLoopSync ? time % loopTime : time; for (int i 0; i pendulums.Length; i) { double phase 2.0 * Mathf.PI * t / periods[i] phaseOffsets[i]; float angleRad amplitude * Mathf.Deg2Rad * Mathf.Cos((float)phase); pendulums[i].localRotation Quaternion.Euler(0f, 0f, angleRad * Mathf.Rad2Deg); } }注意这里我用了 Time.timeAsDouble 而不是 Time.time。原因是蛇形摆要做长时间循环时float 的秒数在几个小时后会出现精度下降时间跳动会导致波型肉眼可见地抖。Time.timeAsDouble 是双精度循环 20 秒显示绰绰有余。相位计算先用 double算完再转 float能最大限度避免精度问题。3.3 视觉细节杆、球、颜色与轨迹蛇形摆的魅力很大一部分来自视觉节奏所以视觉细节不能省。摆杆我选暗色材质颜色接近 #555555球则用渐变色。为了让颜色和周期顺序对应用一种从冷色到暖色的渐变会非常舒服。上面的代码里已经用了 HSV 渐变从红色到紫色再到红色因为 HSV 的 Hue 从 0 到 1 正好转一圈。实际效果是由红变黄、变绿、变蓝、再变紫整体很鲜艳。我还喜欢在摆锤上挂一个 TrailRenderer让每个球在运动时拉出一条轨迹。设置上需要注意两点TrailRenderer 的 time 不要设太长1.2 秒左右即可否则会留下满屏乱线宽度曲线要前粗后细否则轨迹看上去是一坨。材质用 Unity 默认的 Sprites/Default 或者 URP 下的 Unlit Transparent并关闭阴影投射。如果想做得更精细可以关掉场景里的环境光和方向光只用一个点光源加后期 Bloom摆锤的轨迹会非常梦幻。这个完全看项目调性。3.4 实现完美循环的关键细节蛇形摆想真正“首尾相接”必须保证每个摆都在同一个总时间内完成整数次摆动。这就是 useLoopSync 模式的由来。假设 loopTime 设为 20 秒第一个摆完成 18 次摆动第二个摆完成 19 次第三个完成 20 次依次类推。那么第 i 个摆的周期是Tᵢ loopTime / (baseCycles i)这样排列的周期并非线性而是双曲线型但相邻摆之间周期差会随着 i 增大而略变小。视觉上的波动效果依然成立而且每一个 loopTime 之后所有摆的相位都回到起点刚好形成无缝循环。实际参数我建议这样调先定 loopTime再定 baseCycles。比如 loopTime 20baseCycles 18那么第一个摆周期 1.11 秒第 15 个摆周期 0.606 秒摆长分别是 0.31 米和 0.09 米摆长跨度不够大视觉效果偏弱。更好的做法是把 loopTime 拉大或者让 baseCycles 从 12 左右开始让最慢的摆周期在 1.6 秒以上这样摆长才有接近 1 米的跨度。这个调参过程不用怕试错直接在 Inspector 里改参数运行看循环是否顺畅即可。最佳状态是“波峰从左走到右同时摆群逐渐散开又收拢”节奏很舒服。4. 常见问题与排查实录4.1 波形不对的排查顺序如果你运行后发现场景里没有出现明显的蛇形波浪或者波形很怪不用急着怀疑代码先按顺序排查下面几项先看周期梯度。所有摆周期完全一样的话它们会像一个整体同步摆动永远不会有波浪这是最常见的错误。确认每个摆的周期确实不同可以在 Inspector 里看生成的摆长数值。再看初始时间。如果你用的是 cos 函数所有摆在 t0 时都在最大摆角处初始看起来是一条整齐的斜线大家会一起往回摆稍微过一会波浪才出来。这是正常现象不是 bug。如果你希望运行第一眼就是波浪可以在 phaseOffsets[i] 里手工加入相位偏移但那就不是标准的“同步释放”了。然后看旋转轴。挂点旋转轴是 Z 轴球在 X-Y 平面内摆动。如果场景里摄像机从侧面看看到的是上下方向的摆动。如果旋转轴搞反或者绕 X 轴旋转摆动方向就会跑到 Z 方向上看起来像是每个摆都在前后晃侧面看不出 S 形。遇到这个情况把旋转轴统一即可。最后看摆幅和周期是否匹配。如果周期很短但摆幅很大每个摆像抽风一样狂抖视觉上蛇形波会变得很碎。建议摆幅控制在 15 到 30 度之间周期最低不要小于 0.7 秒。4.2 Unity3D 物理引擎方案踩坑记录如果你还是想试试物理引擎方案我把自己踩过的坑记录下来。首先是 HingeJoint 的 Anchor 和 Connected Anchor 设定Anchor 应该设到摆杆顶端的球体局部位置Connected Anchor 设到横梁上对应位置。很多教程这里说得含糊实际操作时只要一个坐标出错摆就会从奇怪的位置发射出去。第二个坑是 Use Motor。单独设 Motor 的 Target Velocity 还不够还要勾选 Use Motor否则角速度永远为 0。为了避免摆幅衰减最好做一个小闭环读取当前角速度低于目标值就给电机补一个轻微扭矩。这样调起来有点像 PID 控制比较麻烦。第三个坑是 Solver Iterations。如果摆和摆之间偶尔出现抖动把 Time Manager 里的 Fixed Timestep 调小到 0.01 以内同时把 Rigidbody 的 Solver Iterations 提升到 20 以上。代价是物理计算量大增15 个摆还好100 个摆会非常卡。我个人不建议在蛇形摆这种场景里死磕物理引擎。它的优势只能在“你需要球和球碰撞、需要外力推动”时体现。如果是纯演示数学模拟是更优雅的方案。4.3 性能、精度与长时间运行纯数学模拟的性能压力不大但也别掉以轻心。如果你把摆的数量加到 100 以上每帧更新 100 个 Transform 的 localRotation 仍然是可接受的。需要注意的其实是内存和 GameObjects 数量。Unity 场景里挂太多 GameObject 会拖慢编辑器操作建议把摆锤做成可复用的预制体并且在不需要单独访问的时候用 Transform 数组缓存引用避免每次都 Find。长时间运行还有一个隐藏问题float 精度。前面提到 Time.timeAsDouble 是必须的。我实际跑过一个编排放置的蛇形摆连续运行 3 小时后用 Time.time 计算相位的版本开始出现微小的抖动波形不再光滑切换成 double 后一切恢复正常。另外循环模式下的取模操作不要用Time.time % loopTime要用 double 计算后再取模否则小数精度损失会累积到可见的程度。5. 让蛇形摆变成一个可玩的作品5.1 相位偏移与分组建模基础的蛇形摆是一整排同相位释放但你完全可以打破规则。我在项目里加了三个可调参数全局相位偏移、随机相位扰动、分组模式。全局相位偏移会让整条波线整体平移。随机相位扰动会让每个摆的动作略微错开破坏整齐感看起来更像自然界的波动。分组模式则能把 15 个摆拆成 3 组每组分别给一个基础周期偏移形成两组波浪互相穿插的效果。实现非常容易只需在 Update 里把 phaseOffsets[i] 根据分组规则赋值。比如 3 组时第 i 个摆的相位偏移可以设为phaseOffsets[i] (i % 3) * Mathf.PI / 3f这一下就能让视觉丰富度提高几个量级。如果你做的是展项这种可变参数会让同一套模型在一天里不重样。5.2 实时调节振幅和周期项目运行后我想在界面里实时拖拽振幅、周期、循环时间不需要停止播放。这块和普通 UI 绑定一样把 Slider 的 onValueChanged 事件接到脚本公开方法上直接改 amplitude、loopTime 等字段即可。但有两点要注意改周期后已经生成的摆锤物理长度不会自动更新因为摆杆和球是按 Start 时的周期算出来的。如果要周期实时变化最好让球、杆和摆长也同步重建或者干脆在运行时修改杆子的 localScale 和球的 localPosition。第二种做法是通过代码动态重建所有摆。虽然在运行中频繁创建销毁会有 GC 压力但只是手动调参时触发影响很小。我实际用的方案是Inspector 里加一个 Rebuild 按钮点一下调用 RebuildPendulums()重新计算长度并更新所有子物体。5.3 接上声音、视觉与交互的扩展蛇形摆做好之后只要稍加包装就能变成一个完整的互动作品。比如让每个球走过某个位置时触发粒子或声音事件把摆动速度映射成音量或音调。或者连接一个音频频谱把振幅绑定到低频能量上让摆的摆幅跟着音乐节奏呼吸。相机运动也值得考虑。静止侧视只能看到二维波浪如果让相机沿着摆阵列缓慢推进并轻微俯仰视觉张力立刻不一样。用 Cinemachine 的 Dolly Track 就能很方便地实现不需要写额外代码。如果你用 URP 管线再叠加 Bloom 后期效果蛇形摆会成为整个场景里最抓眼球的元素。我现在的习惯是任何 Unity3D 演示项目都尽量做成参数化、可编排的结构而不是一个写死的脚本。蛇形摆非常适合作为这种结构的起点因为物理规律简单、视觉反馈强烈所有参数都可以在界面上打磨。等基础跑通之后你自然会忍不住往里面加越来越多自己的点子。技术本身不难难的只是如何把一套简单规则玩出层次感。做这种项目最开心的地方也正在于此一个公式、一连串参数最后变成一个能在屏幕上一直游动的光带。
返回列表