传统的布料模拟使用的是基于力的做法,其基本步骤为:
- 根据位置约束计算出质点受力
- 根据受力计算出加速度
- 利用加速度迭代速度
- 利用速度迭代位置
但这种模拟方式需要较小的迭代步长,计算量较大,所以我使用了Position Base Dynamics算法,同时使用了unity的Job System进行并行计算,该算法的优点是可以使用较大的迭代步长,并且Job System配合Burst编译,性能非常可观。
PBD算法的流程如下
图中x为当前质点位置,p为预测质点位置。可以看到:
- 算法在每一帧开始时,会根据外力和阻尼先于计算一个位置(p)
- 根据当前位置(x)和预测位置(p),去进行碰撞检测,如果发现有碰撞,就生成一个碰撞约束
- 迭代求解所有约束,最终会得到新预测位置p
- 利用p - x 来计算速度v,并将p赋值给x
- 最后再计算一遍速度(处理碰撞反弹之类速度变换)
像重力、风、可以视为外部力,但是像质点之间的相互作用我们通常视为内部约束,而非力。同样外部物体对质点的碰撞,也视为碰撞约束而不是外部力。
在物理上,我们通常可以将布料视作由一个个质点组成的结构。质点之间满足一定的约束条件,来模拟布料内部的作用力,如下图所示:
在传统的基于力的模拟中,质点之间的约束通常是用胡克定律以弹簧振子的形式去模拟。但是在基于位置的方案中,我们将会采用另外一种约束公式。
质点与布料模型顶点一一对应,然后让三角形的边作为顶点之间的约束。 好处是无需额外的骨骼绑定工作,我们可以针对满足一定的拓扑条件的任意Mesh进行布料模拟(1:一条边只被两个三角形共享 2:不封闭),坏处是如果模型顶点较多,将比较耗费性能。
通常来说,我们可以给一块布指定其密度(kg/m^2)。 这样的话,我们就可以根据Mesh的面积来计算质量。具体来说就是:
- 遍历Mesh的所有三角面
- 计算每个三角面的面积,乘以密度得到质量
- 将质量三等份,分别累加给每个顶点
_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;
}
为了使用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
位置预测公式如下:
这三条公式,第一步为预测速度,第二步对速度施加阻尼,其中kd为阻尼系数,第三步利用速度预测位置。
g为重力加速度, Fe为作用于质点的外力,在本例里我们只用到了风力。 风对质点的作用力,需要投影到质点的法线方向,这也是为什么这个Job的输入需要有法线信息。
随风舞动的效果:
针对球体(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)
- 遍历所有质点,取其位置和预测位置,与所有的碰撞体做检测
- 如果发现进入碰撞体内部,则计算出碰撞体表面距离当前质点最近的一个点
- 对当前质点施加一个碰撞约束,让其在后面的约束迭代中强行离开碰撞体,回到表面去。
在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;
}
public struct SphereDesc{
public float3 center;
public float radius;
}
public struct CapsuleDesc{
public float3 c0;
public float4 axis; //xyz为单位化的方向,w为长度
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分量向量长度。相关的预计算可以加速后面的碰撞检测。
我们需要用多线程任务并行执行碰撞检测和反作用力计算。
这个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中,供后续约束迭代阶段使用.
在法线方向上,碰撞引起的速度改变符合动量守恒定律
实际上,如果两个物体表面存在摩擦力,那么在垂直于法线的另一个方向上也会产生力的相互作用。但是在当前的这个版本里,我们暂且忽略了。
在布料模拟中,存在如下几种约束:
- 距离约束
- 弯曲约束
- 固定约束
- 碰撞约束
距离约束就是两个质点之间的距离,必须保持被约束在一定范围之内。
通常在布料初始化的时候,会记录下存在距离约束关系的质点之间的距离,称为RestLength。 在后续模拟中,当两个质点间距离小于RestLength时,我们就要将其推开,反之则要将他们拉拢。也即他们之间存在一根无形的弹簧。
两个质点之间的距离约束,要符合下述的迭代公式呢。为p1和p2分别计算出它们的修正量delta_p1和delta_p2。
原论文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;
}
弯曲约束可以用来控制相邻两个面片的对折程度,这符合现实中布料的特性。 例如当我们将一块布料对折放置在平面上,其对折处会有一段弧形隆起。这就是布料内部结构存在的抵抗弯曲的力。 下图展示了一个弯曲约束的效果:
在布料建模中,弯曲约束可以由如下一幅图定义,来自上一节的论文:
两个相邻的三角面,共4个顶点,组成一个约束公式。
两个三角面的法线点乘,给出了三角面的夹角信息,在后续的模拟迭代中,我们只要约束这个夹角就行了。论文中同样给出了完整的计算公式推导过程:
弯曲约束的结构定义:
public struct BendConstraintInfo{
public int vIndex0;
public int vIndex1;
public int vIndex2;
public int vIndex3;
public float rest; // angle
}
这种约束是将某个质点固定在某个坐标上,令其无法移动。 所以这个约束的实现很简单,我们只要让这个质点位置不变,速度为0即可。
我们之前提到了碰撞运算,但是在碰撞计算阶段并不会去直接修改质点的位置,而是生成一个碰撞约束,交由后续约束迭代阶段进行质点位置计算。
目前碰撞约束的结构很简单:
public struct CollisionConstraintInfo{
public float3 concatPosition;
public float3 normal;
public float3 velocity;
}
在约束迭代阶段,质点会将自己的位置尽量往concatPosition去靠近。碰撞约束作为最高级约束,在其生效时,其他的约束不起作用。 velocity保存了碰撞计算得到的质点速度。
考虑到Unity JobSystem的特性,目前实现的约束迭代流程如下:
- Distance Constraint Job - 独立线程计算,把结果写入positionCorrect数组
- Bend Constraint Job - 独立线程计算,依赖第一步完成,同样把结果累加到positionCorrect数组
- Final Constraint Job - 多线程并发计算,会处理固定约束、碰撞约束,并且合并1,2两步生成的positionCorrect.
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;
}
在完成约束迭代计算之后,我们得到了一份最终版本的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。
在先前的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);
}
}
以下是布料下落推动一个球体向前滚动的效果:
在离散碰撞检测下,我们只要检测质点是否位于碰撞体内部即可,但离散检测在速度较快、或者卡顿导致两帧之间物体位移较大的情况下会发生穿透现象。
连续性碰撞检测的出现就是为了修正这个问题。例如对于质点,我们可以将其前后两帧的位置连成一根线,然后判断这根线是否与碰撞体相交。很明显连续性碰撞检测需要的计算量是远大于离散性检测的,手写所有Collider的连续碰撞检测工作量较大,所以并未实现。
布料破坏的原理是比较简单的。即当两个质点之间的距离超出一定阈值,那么视为被撕裂,我们就移除这两个质点之间的距离约束。
但问题在于,我们需要动态去调整Mesh的拓扑结构。当布料被撕裂时,我们需要:
- 在Mesh撕裂位置新增顶点
- 调整附近三角面的顶点索引,插入新增的顶点,以起到让两个三角面分离的效果
- 更新布料的距离约束、弯曲约束关系
以上需要我们在一开始的时候建立好布料的拓扑关系,以快速的根据顶点查询边、三角面等信息。 另一方面,如果存在动态增删布料顶点和相关约束关系的话,对数据结构的设计也是一种考验。
todo