1. 项目概述:为什么我们需要一个2D软体物理系统?
在Unity里做2D游戏,刚体(Rigidbody 2D)和碰撞器(Collider 2D)这套组合拳,对付方块、球体这类“硬邦邦”的物件是绰绰有余了。但当你需要一块晃动的果冻、一个飘动的旗帜、一条扭动的触手,或者一个被风吹得变形的气球时,标准物理引擎就显得力不从心了。它模拟的是刚体运动,物体形状在碰撞中不会改变,这和我们想要的“柔软”、“可变形”的视觉效果背道而驰。这就是“软体物理”(Soft Body Physics)要解决的问题。
简单来说,2D软体物理系统,其核心思想是把一个连续的、柔软的物体,离散化成由许多小质点(我们通常称为“粒子”或“节点”)和连接这些质点的“弹簧”所构成的网络。粒子负责模拟质量、位置和速度,遵循牛顿运动定律;弹簧则负责模拟物体内部的弹性力,当质点间距离被拉伸或压缩时,会产生一个试图恢复原状的力。通过计算每个粒子受到的合力(重力、弹簧力、阻尼力、碰撞力等),并积分更新其速度和位置,我们就能模拟出柔软、有弹性的动态效果。
自己动手实现一套这样的系统,远不止是得到一个酷炫的效果。它能让你:
- 获得完全的掌控权:商业插件或Asset Store的资源往往是黑盒,出了问题难以调试,性能瓶颈找不到,想定制特殊行为更是难上加难。掌握源码,意味着你可以针对自己的游戏进行深度优化和特性定制。
- 深刻理解物理模拟原理:从零实现一遍,你会对数值积分、约束求解、碰撞检测与响应等游戏物理的核心概念有刻骨铭心的理解,这种知识是通用的,价值远超某个具体功能。
- 创造独特的游戏机制:软体物理本身就可以成为一种玩法。比如,一个可以任意揉捏变形的角色,一条需要精准控制摆动来勾住物体的绳子,或者一个环境中的软体障碍物,都能衍生出独一无二的游戏体验。
接下来,我将带你从零开始,拆解一个高性能、易用的2D软体物理系统的实现全过程。我们会从最基础的质点-弹簧模型讲起,逐步加入碰撞、优化,并探讨如何将其优雅地集成到Unity的ECS框架或常规的GameObject工作流中。
2. 核心架构与数学模型拆解
实现一个软体系统,首先要搭建其数学模型。我们选择最经典也最实用的质点-弹簧模型作为基础。
2.1 质点-弹簧模型:系统的骨架
在这个模型中,一个软体对象由两类基本元素构成:
- 质点:拥有质量、位置、速度等属性,是物理模拟的基本单元。
- 弹簧:连接两个质点,为其提供内部约束力。
质点的运动方程遵循牛顿第二定律:F = m * a。在代码中,我们更关心速度和位置的更新。对于每个质点i,在时间步长Δt内:
- 计算合力:
F_total = F_gravity + F_spring + F_damping + F_collision + ... - 计算加速度:
a = F_total / mass - 更新速度:
v_new = v_old + a * Δt - 更新位置:
p_new = p_old + v_new * Δt
这就是显式欧拉积分,最简单,但也最不稳定。稍后我们会讨论更优的积分方法。
弹簧力的计算是核心。对于连接质点A和B的一根弹簧:
- 当前长度:
currentLength = |p_B - p_A| - 形变量:
delta = currentLength - restLength - 胡克定律:弹簧力的大小
F_spring_magnitude = -stiffness * delta。方向沿AB连线,A受到指向B的力,B受到大小相等、方向相反的力。 - 阻尼力:为了消除弹簧永无止境的振荡,我们需要加入与相对速度相反的阻尼力。
F_damping = -damping * (v_B - v_A)在AB方向上的投影。
将弹簧力和阻尼力向量分别加到质点A和B的合力上,就完成了内部力的计算。
注意:
stiffness(刚度)和damping(阻尼)是两个至关重要的参数。刚度太大,软体会显得僵硬,容易产生剧烈震荡甚至爆炸;太小则软趴趴,无法保持形状。阻尼用于消耗能量,使系统稳定下来。通常需要反复调试找到平衡点。
2.2 数值积分方法的选择:稳定性的关键
直接使用显式欧拉法更新位置,在弹簧刚度大或时间步长不稳定的情况下,系统极易失稳,表现为疯狂的抖动甚至质点“飞走”。我们必须使用更稳定的积分器。
韦尔莱积分法:这是游戏物理模拟中非常流行的一种方法。它利用上一帧和当前帧的位置来计算下一帧的位置,对速度的处理更隐式,通常比显式欧拉稳定。
// 伪代码示例 Vector2 tempPosition = currentPosition; currentPosition = currentPosition + (currentPosition - previousPosition) + acceleration * deltaTime * deltaTime; previousPosition = tempPosition; // 速度可以通过 (currentPosition - previousPosition) / deltaTime 近似得到韦尔莱积分法在计算新位置时没有直接使用当前速度,而是用位置差来隐含速度,对于振荡系统有天然的稳定性。
半隐式欧拉法:先更新速度,再用新速度更新位置。
v_new = v_old + a * Δt; p_new = p_old + v_new * Δt;这比显式欧拉稍好,但仍可能不稳定。龙格-库塔法:更高精度的方法,但计算量也更大。对于实时游戏,韦尔莱积分法在稳定性和性能之间取得了很好的平衡,是我们实现的首选。
2.3 系统架构设计:面向数据与可扩展性
在编码之前,设计良好的数据结构至关重要。我们要考虑Unity的两种主要模式:面向对象的GameObject模式和面向数据的ECS模式。
方案一:基于GameObject的托管模式这是最直观的方式,每个质点是一个GameObject,带有自定义的Particle组件,弹簧作为Spring组件挂在两个质点的GameObject上或一个管理类中。
- 优点:易于理解,方便在编辑器内调试和设置初始形状。
- 缺点:性能差。大量GameObject和MonoBehaviour带来的开销巨大,力计算和位置更新涉及大量跨组件调用和缓存不友好。
方案二:基于数组的结构化数据模式我们将所有质点的数据(位置、速度、受力、质量等)存储在普通的C#数组或List<Vector2>中,弹簧数据存储为包含两个质点索引和参数的数组。用一个中心化的SoftBodySystem类在Update或FixedUpdate中循环处理所有数据。
- 优点:性能极佳,数据局部性好,适合批量处理。
- 缺点:编辑器内可视化和调试稍复杂,需要额外编写Gizmos绘制代码。
方案三:基于Unity ECS的极致性能模式使用Entities、Components、Systems。每个质点是一个Entity,拥有Position,Velocity,ForceAccumulator等IComponentData。弹簧可以作为另一个Entity,或作为共享组件。在System中使用IJobEntity或IJobChunk进行并行计算。
- 优点:能最大限度利用多核CPU,性能天花板最高,与Unity未来技术栈契合。
- 缺点:学习曲线陡峭,架构复杂,不适合所有项目。
对于大多数项目和入门理解,方案二(结构化数据模式)是最佳起点。它在性能和易用性之间取得了完美平衡。下文也将主要围绕此模式展开。
3. 核心模块实现详解
我们将系统分解为几个核心模块,逐一实现。
3.1 数据结构定义:模拟的基石
首先,定义核心的数据结构。我们将所有数据集中管理。
using UnityEngine; using System.Collections.Generic; public class SoftBody2D : MonoBehaviour { // 质点数据 public struct Particle { public Vector2 position; public Vector2 previousPosition; // 用于韦尔莱积分 public Vector2 acceleration; public float mass; public float inverseMass; // 常用,预计算避免除零 public bool isFixed; // 是否固定(例如悬挂点) } // 弹簧数据 public struct Spring { public int particleIndexA; public int particleIndexB; public float restLength; public float stiffness; // 刚度系数 public float damping; // 阻尼系数 } // 公开可调的参数 public Vector2 gravity = new Vector2(0, -9.81f); public int solverIterations = 3; // 约束求解迭代次数 public float dampingFactor = 0.99f; // 全局速度阻尼 // 核心数据容器 private List<Particle> particles = new List<Particle>(); private List<Spring> springs = new List<Spring>(); // 用于碰撞的边界(简单示例) public Rect boundary = new Rect(-5, -5, 10, 10); // 初始化方法:根据一个多边形网格或纹理生成质点和弹簧 public void InitializeFromMesh(Mesh mesh) { /* ... */ } public void InitializeFromGrid(int width, int height, float spacing) { /* ... */ } }3.2 物理模拟主循环:一步一步驱动世界
模拟的核心是一个按固定时间步长执行的循环。我们通常在FixedUpdate中调用它,以保证物理稳定性。
void FixedUpdate() { float deltaTime = Time.fixedDeltaTime; // 步骤1:清除上一帧累积的力 ClearForces(); // 步骤2:累积所有作用力 AccumulateForces(deltaTime); // 步骤3:积分运动方程,更新位置(使用韦尔莱积分) Integrate(deltaTime); // 步骤4:处理碰撞约束 SolveCollisions(); // 步骤5:应用弹簧约束(多次迭代使系统更稳定) for (int i = 0; i < solverIterations; i++) { ApplySpringConstraints(deltaTime); } // 步骤6:更新渲染位置(如LineRenderer或Mesh) UpdateVisuals(); }AccumulateForces函数详解: 这个函数负责计算每个质点受到的所有力。首先是全局的重力,然后是每根弹簧对两个端点施加的弹簧力和阻尼力。
void AccumulateForces(float deltaTime) { // 应用重力 for (int i = 0; i < particles.Count; i++) { if (particles[i].isFixed) continue; // F = m * g, 但通常我们直接累加加速度 a = g // 因为我们存储的是加速度,所以这里加的是重力加速度 particles[i] = new Particle { position = particles[i].position, previousPosition = particles[i].previousPosition, acceleration = particles[i].acceleration + gravity, mass = particles[i].mass, inverseMass = particles[i].inverseMass, isFixed = particles[i].isFixed }; } // 应用弹簧力 for (int s = 0; s < springs.Count; s++) { Spring spring = springs[s]; Particle pA = particles[spring.particleIndexA]; Particle pB = particles[spring.particleIndexB]; Vector2 deltaPos = pB.position - pA.position; float currentDistance = deltaPos.magnitude; if (currentDistance == 0) continue; // 防止除零 Vector2 direction = deltaPos / currentDistance; // 胡克定律:力的大小与形变成正比 float displacement = currentDistance - spring.restLength; float springForceMagnitude = spring.stiffness * displacement; // 阻尼力:与相对速度在弹簧方向上的分量成正比 Vector2 relativeVelocity = (pB.position - pB.previousPosition)/deltaTime - (pA.position - pA.previousPosition)/deltaTime; float dampingForceMagnitude = spring.damping * Vector2.Dot(relativeVelocity, direction); float totalForceMagnitude = springForceMagnitude + dampingForceMagnitude; Vector2 force = direction * totalForceMagnitude; // 将力转化为加速度,累加到质点上(注意方向) if (!pA.isFixed) { pA.acceleration += force * pA.inverseMass; } if (!pB.isFixed) { pB.acceleration -= force * pB.inverseMass; // B受到反向力 } // 写回数据(由于struct是值类型,需要写回) particles[spring.particleIndexA] = pA; particles[spring.particleIndexB] = pB; } }Integrate函数详解(韦尔莱积分):
void Integrate(float deltaTime) { for (int i = 0; i < particles.Count; i++) { Particle p = particles[i]; if (p.isFixed) { // 固定点,速度和位置保持不变,但需清除加速度以备下一帧 p.acceleration = Vector2.zero; particles[i] = p; continue; } // 韦尔莱积分核心公式 Vector2 tempPosition = p.position; // newPos = currentPos + (currentPos - prevPos) + a * dt^2 p.position = p.position + (p.position - p.previousPosition) + p.acceleration * deltaTime * deltaTime; p.previousPosition = tempPosition; // 应用全局速度阻尼,使系统能量缓慢衰减,更稳定 Vector2 velocity = (p.position - p.previousPosition) / deltaTime; velocity *= dampingFactor; p.previousPosition = p.position - velocity * deltaTime; // 清除加速度 p.acceleration = Vector2.zero; particles[i] = p; } }3.3 约束求解与碰撞处理:让模拟更真实
单纯积分后,弹簧的长度可能并不等于原长,因为积分步骤会破坏约束。我们需要一个约束求解步骤来修正。
弹簧约束投影(Position-Based Dynamics 思想): 这是一种更稳定、更直观的方法。我们不直接计算力,而是直接计算为了满足约束(弹簧长度等于原长)需要的位置修正。
void ApplySpringConstraints(float deltaTime) { for (int s = 0; s < springs.Count; s++) { Spring spring = springs[s]; Particle pA = particles[spring.particleIndexA]; Particle pB = particles[spring.particleIndexB]; Vector2 delta = pB.position - pA.position; float currentLength = delta.magnitude; if (currentLength == 0) continue; // 计算长度差 float diff = (currentLength - spring.restLength) / currentLength; // 根据质量的倒数分配修正量(质量越大,移动越少) float totalInverseMass = pA.inverseMass + pB.inverseMass; if (totalInverseMass == 0) continue; // 两个都是固定点 Vector2 correction = delta * diff * (1.0f / totalInverseMass); // 修正位置 if (!pA.isFixed) { pA.position += correction * pA.inverseMass; } if (!pB.isFixed) { pB.position -= correction * pB.inverseMass; } particles[spring.particleIndexA] = pA; particles[spring.particleIndexB] = pB; } }实操心得:约束投影法比基于力的方法数值上更稳定,特别是当刚度很大时。多次迭代(
solverIterations)这个约束求解步骤,可以极大地提高系统的刚性和稳定性,模拟出更“结实”的软体。通常2-5次迭代就有很好效果。
简单的边界碰撞处理:
void SolveCollisions() { for (int i = 0; i < particles.Count; i++) { Particle p = particles[i]; if (p.isFixed) continue; Vector2 pos = p.position; bool collided = false; // 左边界 if (pos.x < boundary.xMin) { pos.x = boundary.xMin; collided = true; } // 右边界 else if (pos.x > boundary.xMax) { pos.x = boundary.xMax; collided = true; } // 下边界 if (pos.y < boundary.yMin) { pos.y = boundary.yMin; collided = true; } // 上边界 else if (pos.y > boundary.yMax) { pos.y = boundary.yMax; collided = true; } if (collided) { // 简单碰撞响应:将位置修正到边界,并反转法线方向的速度分量 Vector2 velocity = (p.position - p.previousPosition) / Time.fixedDeltaTime; // 这里简化处理,直接将上一帧位置设为当前位置,使速度近似为零(非弹性碰撞) p.previousPosition = pos; p.position = pos; } particles[i] = p; } }对于更复杂的碰撞(与其他刚体、网格碰撞),需要用到射线检测或分离轴定理进行更精确的检测和响应。
3.4 渲染与可视化:从数据到画面
物理模拟计算的是质点位置,我们需要将其可视化。有两种主流方式:
使用LineRenderer绘制弹簧线:遍历所有弹簧,用
LineRenderer在每帧绘制连接两个质点的线段。这种方式简单,适合表现网状结构(如布料、渔网)。private LineRenderer lineRenderer; void UpdateVisualsWithLines() { if (lineRenderer == null) return; lineRenderer.positionCount = springs.Count * 2; for (int i = 0; i < springs.Count; i++) { lineRenderer.SetPosition(i*2, particles[springs[i].particleIndexA].position); lineRenderer.SetPosition(i*2+1, particles[springs[i].particleIndexB].position); } }动态生成Mesh:根据质点位置,通过三角剖分(如Delaunay三角剖分)生成一个连续的网格,然后应用纹理。这种方式能表现有体积的软体(如果冻、水滴)。
private MeshFilter meshFilter; private MeshRenderer meshRenderer; void UpdateVisualsWithMesh() { Mesh mesh = new Mesh(); Vector3[] vertices = new Vector3[particles.Count]; for (int i = 0; i < particles.Count; i++) vertices[i] = particles[i].position; mesh.vertices = vertices; // 需要根据你的质点连接关系设置triangles和uv // mesh.triangles = ...; // mesh.uv = ...; mesh.RecalculateNormals(); mesh.RecalculateBounds(); meshFilter.mesh = mesh; }注意:动态更新Mesh的顶点数据是性能敏感操作。如果质点数量多,要确保每帧只更新
mesh.vertices,而不是创建新的Mesh对象。对于复杂形状,三角剖分算法本身也有开销,可以考虑预计算拓扑关系。
4. 性能优化与高级特性
当质点数量上升到几百上千时,性能会成为瓶颈。以下是关键的优化方向:
4.1 计算性能优化
- 使用数组代替List:在热循环中,数组的访问速度比List略快,内存布局更紧凑。可以在初始化时分配固定大小的数组。
- 避免在循环中创建临时对象:如
new Vector2()。尽量复用对象池或使用ref关键字操作结构体。 - 减少不必要的数据拷贝:我们的
Particle和Spring是struct,在List中存取会有拷贝。对于极高性能要求,可以考虑使用unsafe代码和指针操作,或者将数据存储在原生内存中。 - 分帧或LOD:对于远处的或不重要的软体,降低其模拟频率(如每2帧更新一次)或减少约束求解的迭代次数。
- 空间分区:当处理软体与大量环境物体的碰撞时,使用四叉树或网格进行空间划分,大幅减少碰撞检测的对数。
4.2 引入弯曲与体积约束
基础的弹簧只能保证边的长度,这可能导致布料过度拉伸或软体在弯曲时出现不自然的褶皱。我们需要更高级的约束:
- 弯曲约束:在布料模拟中,连接两个相邻三角形的对角线,添加一个弹簧,其原长略大于初始长度,或者直接计算相邻三角形的法线夹角,施加一个抵抗弯曲的力。这能让布料看起来更挺括或有自然的褶皱。
- 体积约束:对于3D软体或2D封闭软体(如一滩水),我们希望其内部体积大致保持不变。可以计算软体网格的当前体积(或2D面积),并与初始体积比较,产生一个均匀向内或向外的压力,施加在每个质点上。
4.3 与Unity生态的集成
- 碰撞交互:让软体质点与Unity的2D碰撞体(如
BoxCollider2D,CircleCollider2D)交互。一种方法是将每个质点视为一个微小的CircleCollider2D,但这开销大。更好的方法是:在物理更新步骤中,对每个移动的质点,使用Physics2D.OverlapCircle或Raycast检测与场景碰撞体的交互,然后手动计算碰撞响应(反射速度、摩擦力等)。 - 风力与外力场:实现一个
ForceField系统。可以定义区域(如圆形、矩形),在区域内的质点会受到额外的力(如风力、涡流力)。风力可以有一个基础方向,并加上一些基于噪声的扰动,使其更自然。 - 编辑器工具:创建自定义Editor窗口,允许美术或设计师通过画笔在Sprite上点击来“固定”某些质点,或者直观地调整刚度、阻尼等参数,并实时预览效果。
5. 实战应用与调试技巧
5.1 构建一个可交互的果冻方块
让我们用上面的系统快速搭建一个 demo。
- 初始化:在
Start中,调用InitializeFromGrid(5, 5, 0.2f),生成一个5x5的质点网格,间距0.2。 - 创建弹簧:
- 结构弹簧:连接每个质点与其右方、下方的邻居,形成网格骨架。刚度设高一些(如500)。
- 剪切弹簧:连接每个质点与其右下方的邻居,防止网格被轻易拉斜。刚度可以稍低。
- 弯曲弹簧:连接每个质点与其下下方的邻居(隔一个点),抵抗弯曲。刚度最低(如50)。
- 固定顶点:将顶部一排质点的
isFixed设为true,这样果冻就被“悬挂”起来了。 - 渲染:使用
LineRenderer绘制所有弹簧,或者用Mesh生成一个带纹理的平面。 - 交互:在
Update中检测鼠标点击,使用Physics2D.Raycast(如果为质点添加了碰撞器)或直接计算距离,找到最近的质点,对其施加一个力。
5.2 常见问题与排查清单
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 系统剧烈抖动、爆炸 | 1. 时间步长(deltaTime)不稳定或过大。2. 弹簧刚度过高。 3. 积分方法不稳定(如用了显式欧拉)。 | 1. 确保在FixedUpdate中运行,并使用Time.fixedDeltaTime。2. 降低 stiffness,或增加damping。3. 切换到韦尔莱积分法或半隐式欧拉法。 |
| 软体过于柔软、像面条 | 1. 弹簧刚度过低。 2. 约束求解迭代次数太少。 3. 全局阻尼过大。 | 1. 提高stiffness。2. 增加 solverIterations(3-10)。3. 降低 dampingFactor(如0.998)。 |
| 模拟速度忽快忽慢 | 在Update中运行模拟,使用了Time.deltaTime,而帧率不稳定。 | 务必在FixedUpdate中进行物理计算,以保证固定的模拟频率。 |
| 碰撞时穿模或反应奇怪 | 1. 碰撞检测频率低于物理更新频率。 2. 碰撞响应计算错误,速度修正不当。 3. 质点速度过快,单帧穿透。 | 1. 确保碰撞检测也在FixedUpdate中进行。2. 仔细检查碰撞法线方向和速度反射公式。 3. 使用连续碰撞检测或增加碰撞检测的提前量。 |
| 性能随质点数量增加急剧下降 | 1. 使用了GameObject模式。 2. 算法复杂度高(如O(n²)的碰撞检测)。 3. 每帧都在创建新的Mesh/LineRenderer顶点数组。 | 1. 切换到结构化数据模式或ECS。 2. 为碰撞检测引入空间分区(如网格或四叉树)。 3. 复用数组和Mesh,只更新数据,不创建新对象。 |
| 软体整体漂移或旋转 | 系统存在数值误差累积,或者没有固定点,且受到了微小的不对称力。 | 确保至少有一个质点被固定。或者,可以计算所有质点的平均位置和速度,在每帧结束时施加一个微小的反向力来抵消整体的平移和旋转动量。 |
5.3 调试与可视化辅助
在开发过程中,强大的调试工具能事半功倍。
- 绘制Gizmos:在
OnDrawGizmos中,遍历所有质点和弹簧,用Gizmos.DrawSphere和Gizmos.DrawLine将它们绘制在Scene视图中。可以用颜色区分固定点和自由点,用线条颜色表示弹簧的受力大小(如受拉变红,受压变蓝)。 - 实时参数调节:将
stiffness,damping,solverIterations等关键参数暴露为public变量或放在[SerializedField]中,在Play模式下通过Inspector滑块实时调整,立即看到效果。 - 数据监控:在屏幕上输出一些关键数据,如帧率、最长的弹簧形变、系统的总能量(动能+势能)等,帮助分析系统稳定性。
实现一个完整的2D软体物理系统是一次对游戏物理和软件架构的深度之旅。它从简单的质点-弹簧模型出发,却可以衍生出布料、绳索、流体表面等多种模拟。通过亲手实现,你获得的不只是一个功能模块,更是对物理模拟底层逻辑的深刻洞察力和解决复杂性能问题的能力。当你看到自己代码生成的软体在屏幕上自然摆动、碰撞时,那种成就感是使用现成插件无法比拟的。