PBDClothLearn
PBDClothLearn copied to clipboard
Cloth Simulation by Position Based Dynamics + Unity Job System
基于Position Based Dynamics的布料模拟
之前做过一版简单GPU布料模拟 - GPU布料模拟入门。 里面使用的方式是传统的基于力的算法,即:
- 根据位置约束计算出质点受力
- 根据受力计算出加速度
- 利用加速度迭代速度
- 利用速度迭代位置
如此往复,但这种模拟方式需要比较小的迭代步长才能保持稳定,比较耗费计算量。因此这次准备使用Position Based Dynamics算法来进行模拟, 然后使用Unity的Job System版本实现CPU并行计算。 Position Based Dynamics算法的优势在于,其可以拥有很大的迭代步长,且有优秀的稳定性。而JobSystem配合Burst编译,其运算性能也是非常强劲的。
目前实现的功能有:
- 外力
- 重力
- 风力
- 内部约束
- 距离约束
- 弯曲约束
- 碰撞约束
- 与Sphere、Box、Capsule的碰撞
- 固定约束
- 可以将布料固定到任意位置
- 对场景物体施加反作用力
暂未实现的功能
- 布料自碰撞
- 布料破坏
- GPU模拟版本
- 连续碰撞检测
先放个效果图吧

1. Position Based Dynamics简介
PBD的算法流程图如下

图中x为当前质点位置,p为预测质点位置。可以看到:
- 算法在每一帧开始时,会根据外力和阻尼先于计算一个位置(p)
- 根据当前位置(x)和预测位置(p),去进行碰撞检测,如果发现有碰撞,就生成一个碰撞约束
- 迭代求解所有约束,最终会得到新预测位置p
- 利用p - x 来计算速度v,并将p赋值给x
- 最后再计算一遍速度(处理碰撞反弹之类速度变换)
关于外部力的定义:
像重力、风、可以视为外部力,但是像质点之间的相互作用我们通常视为内部约束,而非力。同样外部物体对质点的碰撞,也视为碰撞约束而不是外部力。
2. 布料模型构建
在物理上,我们通常可以将布料视作由一个个质点组成的结构。质点之间满足一定的约束条件,来模拟布料内部的作用力,如下图所示:
在传统的基于力的模拟中,质点之间的约束通常是用胡克定律以弹簧振子的形式去模拟。但是在基于位置的方案中,我们将会采用另外一种约束公式。
那么具体到实际渲染中,质点代表的是什么呢?
一种方案是,让质点代表骨骼,然后布料的Mesh顶点以权重的方式绑定到骨骼上。这种方案的优势是质点的数量和布料顶点的数量相互独立,也即物理模拟的精度和渲染的精度相互独立。但劣势在于需要手动对布料做骨骼绑定。
另一种方案是直接让质点与布料模型顶点一一对应,然后让三角形的边作为顶点之间的约束。 好处是无需额外的骨骼绑定工作,我们可以针对任意Mesh进行布料模拟(也没那么任意,还是需要满足一定的拓扑条件的),坏处是如果模型顶点较多,将比较耗费性能。
这里作为Demo项目,将直接采用第二种方案。
想要进行布料模拟的Mesh,必须满足以下条件:
- 一条边只被两个三角形共享
- 不封闭
3. 数据结构
因为要使用Unity JobSystem来进行并行计算,所以我们将采用平坦的结构来保存位置、速度之类的信息。
这里我们将使用NativeList来保存所有质点的位置、速度、预测位置、质量等信息。 位置的初始信息可以直接从Mesh中读取,但质点的质量需要通过一定算法生成。
private NativeList<float3> _vertices;
private NativeList<float3> _velocities;
private NativeList<float3> _predictPositions;
private NativeList<float> _masses;
另外这里还引入了Unity.Mathematics
这个库,里面定义了很多与shader类似的数据结构,例如float4
这种。这个数学库是Unity专门针对SIMD进行优化的。
3.1 质量生成算法
通常来说,我们可以给一块布指定其密度(kg/m^2)。 这样的话,我们就可以根据Mesh的面积来计算质量。具体来说就是:
- 遍历Mesh的所有三角面
- 计算每个三角面的面积,乘以密度得到质量
- 将质量三等份,分别累加给每个顶点
这样我们就得到了每个顶点的质量。
代码如下:
//var indices;
//var vertices;
//...
_masses = new NativeList<float>(this.vertexCount,Allocator.Persistent);
_masses.Resize(this.vertexCount,NativeArrayOptions.ClearMemory);
for(var i = 0; i < indices.Length / 3; i++){
var offset = i * 3;
var i0 = indices[offset];
var i1 = indices[offset + 1];
var i2 = indices[offset + 2];
var v0 = vertices[i0];
var v1 = vertices[i1];
var v2 = vertices[i2];
var area = IntersectUtil.GetArea(v0,v1,v2);
var m = area * _setting.density;
var m3 = m / 3;
_masses[i0] += m3;
_masses[i1] += m3;
_masses[i2] += m3;
}
4. 预测位置计算
为了使用JobSystem进行并行计算,我们可以实现一个如下的结构:
struct PositionEstimateJob : IJobParallelFor
{
[ReadOnly]
public NativeArray<float3> positions;
[ReadOnly]
public NativeArray<float3> velocities;
[ReadOnly]
public NativeArray<float3> normals;
[ReadOnly]
public NativeArray<float> masses;
[WriteOnly]
public NativeArray<float3> predictPositions;
public float3 fieldForce;
public float damper;
public float dt;
public void Execute(int index)
{
var p = positions[index];
var v = velocities[index];
var m = masses[index];
//....
}
}
JobSystem将会在多线程上并发执行Execute函数,在这里将会针对每个质点执行一次Execute函数,index为当前执行的任务索引(也即质点索引)。由于在这一步每个质点只更新自己的预测位置,不存在Race Condition,因此可以进行无锁并发。
在这里,我们输入的数据有:
- positions - 位置
- velocities - 速度
- normals - 法线
- masses - 质量
- fieldForce - 外部场力,例如风力
- damper - 阻尼系数
- dt - 迭代时间
输出为:
- predictPositions
位置预测公式如下:
$$ \begin{aligned} \vec{v_1} = \vec{v_0} + (\vec{g} + \frac{\vec{F_{e}}}{m}) \Delta t \newline v_1 = v_1 * \max(1 - \frac{k_d}{m} \Delta t,0) \newline p_1 = p_0 + v_1 * \Delta t \end{aligned} $$
这三条公式,第一步为预测速度,第二步对速度施加阻尼,其中kd为阻尼系数,第三步利用速度预测位置。
g为重力加速度,$F_e$为作用于质点的外力,在本例里我们只用到了风力。 风对质点的作用力,需要投影到质点的法线方向,这也是为什么这个Job的输入需要有法线信息。
随风舞动的效果:

5. 碰撞计算
这里我们将实现针对球体(Sphere)、方体(Box)、胶囊(Capsule)三种基础类型的碰撞检测。为简单起见,目前将采用离散的检测方式,即判断质点是否在相关碰撞体内部。
算法流程为:
for position, predictPosition,index in vertices:
for collider in colliders:
concat = CheckCollision(position,predictPosition,collider)
if concat:
AddCollisionConstraint(index,concat.position,concat.normal)
可以看到,以上伪代码的具体思路为:
- 遍历所有质点,取其位置和预测位置,与所有的碰撞体做检测
- 如果发现进入碰撞体内部,则计算出碰撞体表面距离当前质点最近的一个点
- 对当前质点施加一个碰撞约束,让其在后面的约束迭代中强行离开碰撞体,回到表面去。
5.1 几种基础碰撞体的数据结构
首先是球体,这是最简单的一种碰撞体
public struct SphereDesc{
public float3 center;
public float radius;
}
然后是平行六面体:
public struct BoxDesc{
public float3 min;
//3个边轴,xyz为normalzied的朝向,w为长度
public float4 ax;
public float4 ay;
public float4 az;
}
可以看到我们使用了xyz-min的角、和3个边向量来定义这个Box,严格来说,它能描述三维空间中的任意平行六面体。其中三个边向量,我们用float4保存,并且做了一些预计算。其xyz分量为单位向量,w分量向量长度。相关的预计算可以加速后面的碰撞检测。
胶囊体:
public struct CapsuleDesc{
public float3 c0;
public float4 axis; //xyz为单位化的方向,w为长度
public float radius;
}
Capsule我们可以将其看成两个半球 + 一个圆柱体的组合,如下图所示:

以上的结构数据均定义在世界坐标空间,关于如何判定一个点是否在这些碰撞体的内部,这里将不详细展开,不然又是很长篇。 总之,在IntersectUtil
这个类里,我们实现了相关的函数,以球体为例
public static bool GetClosestSurfacePoint(float3 p, SphereDesc sphere, out ConcatInfo concatInfo)
GetClosestSurfacePoint它接收一个点p坐标,和一个碰撞体描述(这里是SphereDesc),如果发现点在碰撞体内部,就返回true,并且输出一个ConcatInfo结构。
ConcatInfo里保存了碰撞体表面距离p最近一个点的position和normal信息。
我们的目的不仅仅是让这些碰撞体来约束布料,同时布料也要反作用于这些碰撞体(假如是RigidBody的话)。
因此这里额外定义一个刚体描述结构:
public struct RigidbodyDesc{
public float mass;
public float bounciness;
public float3 velocity;
}
然后将RigidBody和Collider组合在一起:
public struct RigidbodyColliderDesc<T> where T:IColliderDesc{
public int entityId;
public T collider;
public RigidbodyDesc rigidbody;
}
其中entityId后面会用来查找Unity对象。
然后定义个结构把所有需要与布料进行碰撞检测的数据整合在一起:
public struct CollidersGroup{
private NativeList<RigidbodyColliderDesc<SphereDesc>> _spheres;
private NativeList<RigidbodyColliderDesc<BoxDesc>> _boxes;
private NativeList<RigidbodyColliderDesc<CapsuleDesc>> _capsules;
}


5.2 CollisionJob
接下来我们要用多线程任务并行执行碰撞检测和反作用力计算。
看一下这个Job的输入输出数据结构:
public struct CollisionJob : IJobParallelFor
{
[ReadOnly]
public float dt;
[ReadOnly]
public NativeArray<float3> positions;
[ReadOnly]
public NativeArray<float3> predictPositions;
[ReadOnly]
public NativeArray<float> masses;
[ReadOnly]
public CollidersGroup collidersGroup;
[WriteOnly]
public NativeArray<CollisionConstraintInfo> collisionConstraints;
public NativeArray<ConstraintType> constraintTypes;
public NativeList<RigidBodyForceApply>.ParallelWriter rigidBodyForceApplies;
}
它会根据当前质点的predictPosition和position信息,与CollidersGroup中的所有碰撞体进行碰撞检测,如果检测到有碰撞发生,则会做如下事情:
- 修正predictPosition,避免碰撞发生
- 将双方速度投影到接触点法线方向,利用动量守恒、动能公式重新计算双方在法线方向的速度
- 将对Collider的速度修正贡献写入到rigidBodyForceApplies中,供后续实际作用到unity rigidboy对象。
- 将对质点的约束信息写入到collisionConstraints中,供后续约束迭代阶段使用.
那么在法线方向上,碰撞引起的速度改变要怎么计算呢? 这其实是高中的物理知识。 两个物体碰撞,要符合动量守恒定律,即:
$m_0v_0 + m_1v_1 = m_0v_0' + m_1v_1'$。
当两个物体是完全弹性碰撞时,他们在动能上要符合以下公式(即动能守恒):
$m_0v_0^2+m_1v_1^2=m_0v_0'^2+m_1v_1'^2$
而如果是完全非弹性碰撞,则动能不守恒,但最终两个物体的速度一致,即:
$v_0' == v_1'$
那么介于这两者之间的情况,我们通常用一个弹性系数C来描述。C = 1表示完全弹性碰撞,C = 0表示完全非弹性碰撞,完全的碰撞速度计算公式如下:
$$ \begin{aligned} v_0' = \frac{(c + 1)m_1v_1 + v_0(m_0-cm_1)}{m_0+m_1} \newline v_1' = \frac{(c + 1)m_0v_0 + v_0(m_1-cm_0)}{m_0+m_1} \end{aligned} $$
总之,利用以上的公式,我们就成功的完成了法线方向的碰撞反馈计算。
实际上,如果两个物体表面存在摩擦力,那么在垂直于法线的另一个方向上也会产生力的相互作用。但是在当前的这个版本里,我们暂且忽略一下吧。
6. 内部约束
在布料模拟中,存在如下几种约束:
- 距离约束
- 弯曲约束
- 固定约束
- 碰撞约束
下面依次来说明.
6.1 距离约束
距离约束就是两个质点之间的距离,必须保持被约束在一定范围之内。

通常在布料初始化的时候,会记录下存在距离约束关系的质点之间的距离,称为RestLength(也即图中的d)。 在后续模拟中,当两个质点间距离小于RestLength时,我们就要将其推开,反之则要将他们拉拢。也即他们之间存在一根无形的弹簧。
那么两个质点之间的距离约束,要符合怎样的迭代公式呢?我们要如何为p1和p2分别计算出它们的修正量delta_p1和delta_p2呢?
这里直接给出结论公式:
$$ \begin{aligned} \Delta \vec{p_1} = - \frac{m_2}{m_1 + m_2}(|p_1 - p_2| - d) \frac{p_1 - p_2}{|p_1-p_2|} \newline \Delta \vec{p_2} = \frac{m_1}{m_1 + m_2}(|p_1 - p_2| - d) \frac{p_1 - p_2}{|p_1-p_2|} \newline \end{aligned} $$
原论文[1] Matthias Müller, Position Based Dynamics, 2006 中给出了更一般的约束关系的公式推导,有兴趣可以看看,主要是用了牛顿-拉夫逊迭代法。
在本例中,我们会根据三角面信息,为所有存在边连接的点建立距离约束。即:
private void BuildDistConstraints(){
var edges = _meshModifier.edges;
foreach(var e in edges){
this.AddDistanceConstraint(e.vIndex0,e.vIndex1);
}
}
距离约束结构定义:
public struct DistanceConstraintInfo{
public float restLength;
public int vIndex0;
public int vIndex1;
}
6.2 弯曲约束
弯曲约束可以用来控制相邻两个面片的对折程度,这符合现实中布料的特性。 例如当我们将一块布料对折放置在平面上,其对折处会有一段弧形隆起。这就是布料内部结构存在的抵抗弯曲的力。 下图展示了一个弯曲约束的效果:

在布料建模中,弯曲约束可以由如下一幅图定义,这里我直接拿论文中的图:

两个相邻的三角面,共4个顶点,组成一个约束公式。
两个三角面的法线点乘,给出了三角面的夹角信息,在后续的模拟迭代中,我们只要约束这个夹角就行了。论文中同样给出了完整的计算公式推导过程,这里直接截图贴上:

细细品味吧。
弯曲约束的结构定义:
public struct BendConstraintInfo{
public int vIndex0;
public int vIndex1;
public int vIndex2;
public int vIndex3;
public float rest; // angle
}
6.3 固定约束
顾名思义,这种约束是将某个质点固定在某个坐标上,令其无法移动。 所以这个约束的实现很简单,我们只要让这个质点位置不变,速度为0即可。
6.4 碰撞约束
在Part5中,我们提到了碰撞计算。但是在碰撞计算阶段并不会去直接修改质点的位置,而是生成一个碰撞约束,交由后续约束迭代阶段进行质点位置计算。
目前碰撞约束的结构很简单:
public struct CollisionConstraintInfo{
public float3 concatPosition;
public float3 normal;
public float3 velocity;
}
在约束迭代阶段,质点会将自己的位置尽量往concatPosition去靠近。碰撞约束作为最高级约束,在其生效时,其他的约束应该不起作用。 velocity保存了碰撞计算得到的质点速度。
6.5 约束迭代步骤
考虑到Unity JobSystem的特性,目前实现的约束迭代流程如下:
- Distance Constraint Job - 独立线程计算,把结果写入positionCorrect数组
- Bend Constarint Job - 独立线程计算,依赖第一步完成,同样把结果累加到positionCorrect数组
- Final Constraint Job - 多线程并发计算,会处理固定约束、碰撞约束,并且合并1,2两步生成的positionCorrect.
还有一种可选方案是,提供两个独立的positionCorrect数组,这样就可以让1、2步的两个线程同时开始计算。然后两个独立的positionCorrect数组最后在步骤3中合并。
纲领性代码:
private JobHandle StartConstraintsSolveJob(JobHandle depend){
JobHandle jobHandle = depend;
var predictPositions = this._predictPositions;
for(var i = 0; i < this.constraintSolverIteratorCount; i ++){
jobHandle = StartDistanceConstraintsJob(jobHandle,i);
jobHandle = StartBendConstraintsJob(jobHandle,i);
jobHandle = StartFinalConstraintsJob(jobHandle,i);
}
return jobHandle;
}
7. 更新质点位置与速度
在完成约束迭代计算之后,我们得到了一份最终版本的predictPositions。
然后我们利用以下公式完成速度和位置的更新:
velocity = (predictPosition - position)/dt
position = predictPosition
如果质点在之前的碰撞计算中得到了新的速度,那么这里采用碰撞计算的速度作为最新速度,即
public void Execute(int index)
{
if( (constraintTypes[index] & ConstraintType.Collision) == ConstraintType.Collision){
velocities[index] = this.collisionConstraintInfos[index].velocity;
}else{
velocities[index] = (predictPositions[index] - positions[index])/dt;
}
positions[index] = predictPositions[index];
}
这里也设计成一个多线程并发计算的Job。
8. 布料对场景物体施加作用力
在先前的CollisionJob阶段,我们已经完成了碰撞反馈计算,并把对场景Rigidbody的作用速度写入到了NativeList<RigidBodyForceApply>
数组中。
那么接下来,我们只要取得这个数组,查找到对应的Rigidbody,对其使用AddForce()
即可:
private void ApplyPhysicalSimulationToRigidbodies(){
for(var i = 0; i < _rigidBodyForceApplys.Length; i ++){
var forceInfo = _rigidBodyForceApplys[i];
_colliderProxies[forceInfo.entityId].attachedRigidbody.AddForce(forceInfo.velocity,ForceMode.VelocityChange);
}
}
以上是布料下落推动一个球体向前滚动的效果:

10. 暂未实现的功能探讨
10.1 连续性碰撞检测
在离散碰撞检测下,我们只要检测质点是否位于碰撞体内部即可,但离散检测在速度较快、或者卡顿导致两帧之间物体位移较大的情况下会发生穿透现象。
连续性碰撞检测的出现就是为了修正这个问题。例如对于质点,我们可以将其前后两帧的位置连成一根线,然后判断这根线是否与碰撞体相交。很明显连续性碰撞检测需要的计算量是远大于离散性检测的,手写所有Collider的连续碰撞检测工作量还是较大的。。通常可以使用一些三方库来做这个事情。
10.2 关于布料破坏
布料破坏的原理是比较简单的。即当两个质点之间的距离超出一定阈值,那么视为被撕裂,我们就移除这两个质点之间的距离约束。
但这里稍微复杂一点的问题在于,我们需要动态去调整Mesh的拓扑结构。当布料被撕裂时,我们需要:
- 在Mesh撕裂位置新增顶点
- 调整附近三角面的顶点索引,插入新增的顶点,以起到让两个三角面分离的效果
- 更新布料的距离约束、弯曲约束关系
以上就需要我们在一开始的时候建立好布料的拓扑关系,以快速的根据顶点查询边、三角面等信息。 另一方面,如果存在动态增删布料顶点和相关约束关系的话,对数据结构的设计也是一种考验。像我们使用NativeArray这种平坦行存储结构的话,一旦遇到扩容代价都是比较大。
10.3 关于布料自相交
布料自相交实际上就是布料质点与自身三角面进行碰撞检测,避免穿透。这里只能使用连续性的碰撞检测,因为布料本身没有厚度,所以必须根据质点前后两帧的位置信息去判断是否穿到面的另一边。
只是简单的想了一下,整个计算过程还是消耗很大的。因为每个质点,都要与每个三角面进行连续性碰撞检测,复杂度是O(n^2)。暂未研究是否有什么方案可以加速这个计算。
10.4 GPU版本
因为JobSystem的设计本身就是并行计算,因此改写成GPU ComputeShader版本应该是非常方便的。相关的NativeArray都可以转成ComputeBuffer, 然后将碰撞体信息传入GPU即可。 每个并行的Job等同于ComputeShader的一次Dispatch。
但是如果布料要对场景物体施加作用力的话,这里就需要存在一个GPU数据回读到CPU的一个过程。就是上面提到的RigidBodyForceApply
数组信息。 后面我可以再实现一个GPU版本出来。