找回密码
 立即注册
查看: 620|回复: 14

[笔记] [游戏物理探秘]三维隐式有限元模拟弹性物体及unity实现

[复制链接]
发表于 2022-1-12 11:44 | 显示全部楼层 |阅读模式
花了几个月终于搞定了三维隐式积分有限元,特此纪念。
三维演示视频
分别用python和unity的computeShader写成,unity场景为Implicit3D.unity。源码在
和gitee上的
接下来会涉及到非常多的公式。如果你有任何不懂的地方,欢迎问我,不过线上交流效率很低,如果你在长沙,也欢迎当面来互相交流。我一个人研究这些东西有点孤单,而且这玩意我花了几个月才弄明白,实在有太多经验想要分享了。然后推荐两个非常棒的参考资料“Dynamic Deformables: Implementation and Production Practicalities  ”和“FEM Simulation of 3D Deformable Solids: A practitioner’s guide to theory, discretization and model reduction. ”
简介

弹性物体模拟的话,简单来说就是物体不希望自己发生变形。如果外力让自己发生变形的话,那么外力就会转变成物体自身的能量,存储在物体内部。更专业的名词就是内能或者应变能。如果力没有持续作用的话,弹性物体会利用将这种能量将自己变回初始的状态。
因此模拟的主要步骤就是,先计算物体的变形程度,再由物体的变形程度计算出总能量,再对总能量求导算出每个顶点的力,然后再用这个力来更新速度,再用速度来更新位置。
我发现学习这种数值计算最有效的方法就是手算例子。比如对于一个二维的三角形,三个顶点的最开始坐标分别是


如果我拉伸这个三角形,让它的三个顶点变成了如下的位置


这时候三角形的面积改变了。根据我们现实中的经验,当我们用力拉伸一个面包时,然后不再用力,那么面包就会变回原来的形状。虽然这是段废话,但是这确实是我们先现实生活的中的经验。
现在假设这个三角形发生了平移,坐标变为如下,


那么这个三角形算不算变形呢?需不需要变回原来的形状呢。想想现实中的例子,当你把一个面包从手上放到桌子上,它是否会跳回你的手上呢?
第三个的例子是旋转。假如现在三个顶点的坐标如下,也就是旋转了90度


现在又算不算变形呢?譬如将手上的面包旋转90度,它会不会自己旋转回去呢?
根据我们在地球上观察到的物理法则,上面两个问题的答案都是“不会”。因此,我们推断,在物体平移和旋转的时候,没有变形,物体内部并没有能量产生。但是如果物体的长度,面积或体积改变,那么就会产生能量。
那么如何衡量物体的变形程度呢?我们会用到有限元中常用的变形梯度(deformation gradient),这是一个矩阵。


小x是现在顶点的位置,大X是顶点初始位置,F是变形梯度。其中dX是对顶点的微分


dx也就是相似的计算,纯位移的情况下,三角形三个顶点的相对位置不发生变化,以刚才说到的平移为例,


不过有的书上对变形梯度是这么写的


Ds 就是现在顶点位置就是dx,Dm就是初始顶点位置就是dX。最终变形梯度计算结果如下


这就是二维情况下,纯位移的形变梯度,是一个单位矩阵。现在我们要通过一些操作,利用变形梯度计算能量,也就是把这个东西变成数字零。纯平移的变形梯度就是单位矩阵。
接下来我们要设计一个能量公式,在纯平移和旋转的时候,得出的结果为零。最容易想到的就是直接让矩阵各个数字的平方相加,然后减去维度数


看起来似乎没问题,但如果三角形面积变小的话,产生的能量就是负数了,也就是外力并没有转换为内能,而是凭空消失了。说明能量公式设计起来并不容易。
线弹性

二维显式线弹性的python完整代码在
和gitee上的
那么既然之前的能量公式有问题,那么我们就换个公式呗。也就是大名鼎鼎的线弹性模型(linear elasticity)


其中varespilon 是 应变张量(strain tensor, green strain)


当变形梯度为单位矩阵时,应变张量就是全零矩阵。相当于没有应变。如果你对这些不熟悉,应该看看弹性力学和连续介质力学相关的书籍。在我的代码中,是这么写的
F = np.dot(Ds_new,minv)
strain = (F + F.T) * 0.5 - np.identity(2)
doubleInner = strain[0,0]*strain[0,0] + strain[1,0]*strain[1,0]
                + strain[0,1]*strain[0,1] + strain[1,1]*strain[1,1]
energy = doubleInner * mu + la * 0.5 * np.trace(strain) ** 2在bullet3库中的https://github.com/bulletphysics/bullet3/blob/master/src/BulletSoftBody/btDeformableLinearElasticityForce.h#L208,能量是这么写的
     double elasticEnergyDensity(const btSoftBody::TetraScratch& s)
     {
         double density = 0;
         btMatrix3x3 epsilon = (s.m_F + s.m_F.transpose()) * 0.5 - btMatrix3x3::getIdentity();
         btScalar trace = epsilon[0][0] + epsilon[1][1] + epsilon[2][2];
         density += m_mu * (epsilon[0].length2() + epsilon[1].length2() + epsilon[2].length2());
         density += m_lambda * trace * trace * 0.5;
         return density;
     }这种模型的优点是计算简单友好,适用于处理小变形的情况。比方说,这个三角形的x轴被拉长,顶点变成了下面这种方式


此时dx和变形梯度如下


那么应变张量和能量就是


很好能量算出来了,我们就要根据能量的导数来算力了。先把stress的计算公式甩上来。先让能量对变形梯度求导得到应力,然后应力对结点求导得到真正的力。
接下来要介绍一种很特别的应力(stress)。普通的应力计算公式如下


大意就是应力等于力除以面积。当面积越小越准确。然而我们之前说了弹性物体的两种状态,也就是初始未变形的状态,以及现在已经变形的状态。我们可以很方便给初始未变形的状态上的某个区域某个大小的力,但是对于已经变形的弹性物体,我们就很难计算到底是要给哪个区域,哪些顶点施加力。
因此,我们还是直接说,我们施加的力,是基于初始的未变形的状态的,那么这种应力就是First Piola Kirchhoff stress。
这种解释来自"Continuum Mechanics and Linear Elasticity - An Applied Mathematics Introduction by Ciprian D. Coman"第3.8节


并且“FEM Simulation of 3D Deformable Solids: A practitioner’s guide to theory, discretization and model reduction  ” 第12页也有类似的表述


不过first piola kirchhoff 的缺点就是它是不对称的,所以不便于计算。具体来说,如果矩阵是对称的话,我们不仅能减少一半的计算量,还能玩各种操作来加快计算。first piola kirchhoff 力的另一种计算方式是能量密度对变形梯度求导


Psi 是刚才算出的能量密度,F是变形梯度,P 是first piola krichhoff stress 。对于线弹性力来说,求导如下


并且计算物理中关于这种矩阵求导的非常非常多。在我的代码是这么写的
piola = mu * (F + F.T - 2 * np.identity(2)) + la * (F[0,0] - 1 + F[1,1] - 1) * np.identity(2)又比如Bullet3库是这么写的,地址在https://github.com/bulletphysics/bullet3/blob/master/src/BulletSoftBody/btDeformableLinearElasticityForce.h#L390
void firstPiola(const btSoftBody::TetraScratch& s, btMatrix3x3& P)
     {
         btMatrix3x3 corotated_F = s.m_corotation.transpose() * s.m_F;

         btMatrix3x3 epsilon = (corotated_F + corotated_F.transpose()) * 0.5 - btMatrix3x3::getIdentity();
         btScalar trace = epsilon[0][0] + epsilon[1][1] + epsilon[2][2];
         P = epsilon * btScalar(2) * m_mu + btMatrix3x3::getIdentity() * m_lambda * trace;
     }再手算一遍,假设mu和lambda都是2,那么线弹性能量就是3,那么第一piola krichhoff  应力


节点力的计算

一个三角形有三个顶点,为了动量守恒,它们的节点力相加必须为零,也就是


节点力的来源于能量对节点位置求导,我们先将能量变成能量密度乘以面积或体积,然后将偏微分分成能量密度对变形梯度求导,以及变形梯度对节点位置求导。前者就是之前的first piola kirchhoff力,后者根据变形梯度的定义更可以直接得到。


但是我们通常这么写,先算出第一个和第二个节点的力,然后根据动量守恒算出第一个节点的力


推导如下



这种节点力还要不同的写法,比如在“Robust Quasistatic Finite Elements and Flesh Simulation  ” 是这么写的


其中AN是三角形的法向量根据面积的权重,P是之前求出的piola应力。也可以写成另一种方法


这些公式在我的代码中是这么写的
gradC1 = np.array([H[0,0],H[1,0]])
gradC2 = np.array([H[0,1],H[1,1]])
gradC0 = - gradC1 - gradC2
         
node_force[element_idx[ie,0],:] += gradC0
node_force[element_idx[ie,1],:] += gradC1
node_force[element_idx[ie,2],:] += gradC2其中bullet3库是这么写的,代码在https://github.com/bulletphysics/bullet3/blob/master/src/BulletSoftBody/btDeformableLinearElasticityForce.h#L263
btMatrix3x3 force_on_node123 = psb->m_tetraScratches[j].m_corotation * P * tetra.m_Dm_inverse.transpose();
btVector3 force_on_node0 = force_on_node123 * grad_N_hat_1st_col;

btSoftBody::Node* node0 = tetra.m_n[0];
btSoftBody::Node* node1 = tetra.m_n[1];
btSoftBody::Node* node2 = tetra.m_n[2];
btSoftBody::Node* node3 = tetra.m_n[3];
size_t id0 = node0->index;
size_t id1 = node1->index;
size_t id2 = node2->index;
size_t id3 = node3->index;

// elastic force
btScalar scale1 = scale * tetra.m_element_measure;
force[id0] -= scale1 * force_on_node0;
force[id1] -= scale1 * force_on_node123.getColumn(0);
force[id2] -= scale1 * force_on_node123.getColumn(1);
force[id3] -= scale1 * force_on_node123.getColumn(2);又比如positionBasedDynamics库是这么算的,地址在https://github.com/InteractiveComputerGraphics/PositionBasedDynamics/blob/master/PositionBasedDynamics/PositionBasedDynamics.cpp#L1046
void PositonBasedDynamics::computeGradCGreen(Real restVolume,
const Matrix3r &invRestMat, const Matrix3r &sigma, Vector3r *J)
{
     Matrix3r H;
     Matrix3r T;
     T = invRestMat.transpose();
     H = sigma * T * restVolume;
     J[0][0] = H(0, 0);
     J[1][0] = H(0, 1);
     J[2][0] = H(0, 2);
     
     J[0][1] = H(1, 0);
     J[1][1] = H(1, 1);
     J[2][1] = H(1, 2);
     
     J[0][2] = H(2, 0);
     J[1][2] = H(2, 1);
     J[2][2] = H(2, 2);

     J[3] = -J[0] - J[1] - J[2];
}继续手推,


也就是


最后,再整理一遍流程


算法解释如下
算法第二行是初始化,只需要对元素,也就是每个三角形(或四面体)执行一遍
算法第三行,计算每个元素的Dm或者叫dX,用于之后计算变形梯度
算法第四行,对Dm求逆得到Bm,用于之后计算变形梯度
算法第五行,计算三角形的面积(或四面体的体积),用于之后计算节点力
算法第八行,外层主循环,每个时间步都要执行一遍
算法第九行,将节点力设置为零
算法第十行,内层循环,遍历所有三角形/(四面体)
算法第十一行,计算每个元素的Ds或者叫dx,用于计算变形梯度
算法第十二行,计算变形梯度
算法第十三行,计算first piola kirchhoff 应力
算法第十四到第十六行,计算真正的节点力
那么最简单的二维线弹性显式求解如下
import numpy as np
# 初始化三角形初始位置
node_pos = np.array([[0,0],[1,0],[0,1]],dtype = float)
# 顶点的位置梯度,对应上面算法第三行
Ds = np.array([[node_pos[1,0] - node_pos[0,0],node_pos[2,0] -
node_pos[0,0]],
[node_pos[1,1] - node_pos[0,1],node_pos[2,1] -
node_pos[0,1]]])
# 求逆,用于准备计算形变梯度,对应上面算法第四行
minv = np.linalg.inv(Ds)
# 假设某一时刻,三角形变化到了这样的位置
node_pos = np.array([[0,0],[2,0],[0,1]],dtype = float)
time = 0
timeFinal = 100
areat = np.zeros((timeFinal))
while(time < timeFinal):
     time += 1
     # 形变梯度中的分子,对应上面算法第11行
     Ds_new = np.array([[node_pos[1,0] - node_pos[0,0],node_pos[2,0]
     - node_pos[0,0]],
     [node_pos[1,1] - node_pos[0,1],node_pos[2,1] -
     node_pos[0,1]]])
     # 形变梯度,对应上面算法第12行
     F = np.dot(Ds_new,minv)
     # 应力,也就是varepsilon
     strain = (F + F.T) * 0.5 - np.identity(2)
     # lame常数
     mu = 2
     # lame常数
     la = 2
     #
     doubleInner = strain[0,0]*strain[0,0] + strain[1,0]*strain[1,0]
     doubleInner += strain[0,1]*strain[0,1] + strain[1,1]*strain[1,1]
     # 线弹性的能量计算公式
     energy = doubleInner * mu + la * 0.5 * np.trace(strain) ** 2
     #first piola kirchhoff stress,对应上面算法第13行
     piola = mu * (F + F.T - 2 * np.identity(2)) + la * (F[0,0] - 1 + F[1,1] - 1) * np.identity(2)
     # 三角形面积
     area = 0.5
     # 计算hessian矩阵对应上面算法第14行
     H = - area * np.dot(piola,minv.transpose())
     # 计算节点力,对应上面算法第15和第16行
     gradC1 = np.array([H[0,0],H[1,0]])
     gradC2 = np.array([H[0,1],H[1,1]])
     gradC0 = - gradC1 - gradC2
     invMass = 1
     dt = 0.1
     # 判断是否收敛
     sumGradC = invMass * (gradC0[0]**2 + gradC0[1]**2)
     sumGradC += invMass * (gradC1[0]**2 + gradC1[1]**2)
     sumGradC += invMass * (gradC2[0]**2 + gradC2[1]**2)
     if sumGradC < 1e-10:
         break
     # 校正位置,方法来源于PositionBasedDynamics
     node_pos[0,:] += dt * energy / sumGradC * invMass * gradC0
     node_pos[1,:] += dt * energy / sumGradC * invMass * gradC1
     node_pos[2,:] += dt * energy / sumGradC * invMass * gradC2
     areat[time - 1] = 0.5 * (node_pos[0,0] * (node_pos[1,1] - node_pos[2,1])
                     + node_pos[1,0] * (node_pos[2,1] - node_pos[0,1])
                     + node_pos[2,0] * (node_pos[0,1] - node_pos[1,1]))
     但是不要高兴得太早了,如果使用上面的代码,你会发现甚至算不出正确的结果,无法收敛。因为线弹性忽略了一个非常重要的东西,旋转。因旋转并未造成面积改变。举个例子,仍然是之前那个三角形,不过现在逆时针旋转90度


此时dx和变形梯度如下


那么应变张量和能量就是


因为mu和lambda不为零,那么能量也必不为零,但是,你所做的,仅仅是旋转了物体而已,却也凭空产生能量。所以用线弹性模型很少在各种物理模拟库中见到。
STVK模型

二维显式stvk的代码在
和gitee上的
线弹性模型失败的原因来源于它认为旋转也是变形,所以会产生能量。因此我们要重新设计一个能量公式,让旋转不产生能量。最简单的非线性模型就是St. Venant-Kirchhoff  能量模型,它长下面这样


同时把测量应变的矩阵换成了green lagrange strain,如下


这种应变测量方法也不会把旋转当作变形。这种模型的缺点之后再说。现在只要知道使用这种模型,那么物体旋转时就不会产生能量,甩线弹性几条街就行了。
此时stvk模型的first piola kirchhoff 应力计算如下


仍然使用之前的代码,然后仅仅需要改变三行代码,就能将线弹性模型换成stvk模型
# Green Strain,也就是E
E = (np.dot(F,F.T)- np.identity(2)) * 0.5
# Stvk的能量计算公式
energy = doubleInner * mu + la / 2 * (E[0,0] + E[1,1])**2
#first piola kirchhoff stress
piola = np.dot(F, 2 * mu * E + la * (E[0,0] + E[1,1]) * np.identity(2))最后计算的各顶点位置和形变梯度如下


接下来看看有哪些开源库也写了stvk模型吧。
比如Quasi-Newton-Methods-For-Real-Time-Simulation库,也是是太极图形课主讲刘天添老师的仓库。在其中的constraint_tet.cpp中,地址为https://github.com/ltt1598/Quasi-Newton-Methods-for-Real-time-Simulation-of-Hyperelastic-Materials/blob/db9a9e3ebd6eec08a5682c0bc5f7c1cc3f6f024d/GenPD/GenPD/source/constraint_tet.cpp#L266
有这么一段代码
     case MATERIAL_TYPE_StVK:
     {
         EigenMatrix3 I = EigenMatrix3::Identity();
         EigenMatrix3 E = 0.5*(F.transpose()*F - I);
         P = F * (2 * m_mu*E + m_lambda*E.trace() * I);
         e_this = m_mu*E.squaredNorm() + 0.5*m_lambda*std::pow(E.trace(), 2);
         ScalarType J = F.determinant();
         if (J < 1)
         {
             P += -m_kappa / 24 * std::pow((1 - J) / 6, 2) * J * F.inverse().transpose();
             e_this += m_kappa / 12 * std::pow((1 - J) / 6, 3);
         }
     }著名的positionBasedDynamics库https://github.com/InteractiveComputerGraphics/PositionBasedDynamics/blob/master/PositionBasedDynamics/PositionBasedDynamics.cpp#L1110中
const Real trace = epsilon(0, 0) + epsilon(1, 1) + epsilon(2, 2);
     const Real ltrace = lambda*trace;
     sigma = epsilon * 2.0*mu;
     sigma(0, 0) += ltrace;
     sigma(1, 1) += ltrace;
     sigma(2, 2) += ltrace;
     sigma = F * sigma;

     Real psi = 0.0;
     for (unsigned char j = 0; j < 3; j++)
         for (unsigned char k = 0; k < 3; k++)
             psi += epsilon(j, k) * epsilon(j, k);
     psi = mu*psi + static_cast<Real>(0.5)*lambda * trace*trace;
     energy = restVolume * psi;另一份不错的代码来自耶鲁大学图形组助理教授Theodore Kim的开源库Cubica。他的个人主页http://www.tkim.graphics/,Cubica主页http://www.tkim.graphics/cubica/。嗯,这些教授有很多代码虽然开源了,但并不放到github上,必须通过教授主页才能找到。所以为了找到这些代码真的非常花时间。
在stvk.cpp中,有这么一段代码
MATRIX3 STVK::secondPiolaKirchhoff(MATRIX3& F, bool diagonal)
{
     MATRIX3 C = F.transpose() * F;

     // Green-Lagrange strain tensor
     MATRIX3 E = 0.5 * (C - MATRIX3::I());

    // 2nd Piola-Kirchoff stress tensor
     MATRIX3 S = (_lambda * trace(E) * MATRIX3::I()) + (2 * _mu * E);   

     return S;
}另外,为了提升代码效率,Cubica库非常丧心病狂地用maple和matlab生产了将矩阵运算全部展开的c++代码。【在大佬那里也许是常规操作,但我真的是第一次见到】,原因是从节点位置矩阵,到计算出节点力的过程中,要经过相当多的矩阵乘法,转置,相加相减。其中有些矩阵是对称的,有的矩阵有很多零,导致很多乘法和加法是重复的,还有很多乘零和加零的操作,尤其是使用隐式方法的时候。所以自动生成的代码就是将这些重复的没必要的加法和乘法去掉了。部分代码如下
const double a = nodes[0][0]; // 节点位置
const double b = nodes[0][1];
const double c = nodes[0][2];
const double d = nodes[1][0];
...
const double m = matInv(0,0); // 计算变形梯度用到的Dm或者dX
const double n = matInv(0,1);
const double o = matInv(0,2);
...
const double t1 = d-a;
const double t3 = g-a;
const double t5 = j-a;
const double t7 = t1*m+t3*p+t5*s;
...
const double t55 = t10*o+t12*r+t14*u;
const double t56 = t55*t55;
const double t61 = t19*o+t21*r+t23*u;
const double t62 = t61*t61;
const double t65 = lam*(t8/2.0+t17/2.0+t26/2.0-3.0/2.0+t32/2.0+t38/2.0+t44/2.0+t50/2.0+t56/2.0+t62/2.0);
...
forces[0][0] = -t65*(t7*t66+t31*t68+t49*t70)-mu*(2.0*t75*t66+t81*(t66*t31+t7*t68)+t90*(t66*t49+t7*t70)+2.0*t97*t68+t103*(t68*t49+t31*t70)+2.0*t110*t70);
forces[0][1] = -t65*(t16*t66+t37*t68+t55*t70)-mu*(2.0*t121*t66+t81*(t66*t37+t16*t68)+t90*(t66*t55+t16*t70)+2.0*t134*t68+t103*(t68*t55+t37*t70)+2.0*t142*t70);stvk模型虽然是不错的模型,但是到现在为止我们还用的是显式积分方法,顶点数多,力大,时间步长大的话,就很容易算过头,然后穿模还拉不回来。所以下一篇将使用隐式积分方法,并在unity上实现cpu二维隐式和gpu三维隐式方法。
下一篇:

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2022-1-12 11:52 | 显示全部楼层
好棒,支持一下
发表于 2022-1-12 11:55 | 显示全部楼层
很专业 当时偷懒了 推了一个简单拟合函数 实现 但简单只需要2天实现3d 胶体 https://zhuanlan.zhihu.com/p/361126215
发表于 2022-1-12 11:56 | 显示全部楼层
不错的工作
发表于 2022-1-12 12:02 | 显示全部楼层
矩阵相乘运算 展开到 元素的加减乘除能提高效率?
发表于 2022-1-12 12:11 | 显示全部楼层
从节点位置矩阵,到计算出节点力的过程中,要经过相当多的矩阵乘法,转置,相加相减。其中有些矩阵是对称的,有的矩阵有很多零,导致很多乘法和加法是重复的,还有很多乘零和加零的操作,尤其是使用隐式方法的时候。所以自动生成的代码就是将这些重复的没必要的加法和乘法去掉了。
发表于 2022-1-12 12:11 | 显示全部楼层
这是非线性连续介质力学里面的东西,游戏现在要做柔性体了么,你们游戏方向有要招聘力学方向博士么
发表于 2022-1-12 12:17 | 显示全部楼层
也许你可以试试泽森科工这家公司,他们可能需要力学方向的,网址在zenus
发表于 2022-1-12 12:18 | 显示全部楼层
需要的
发表于 2022-1-12 12:24 | 显示全部楼层
这家公司据我了解是需要程序员多一些,核心力学算法都是现成的,好像不是那么需要力学方向的
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-22 00:35 , Processed in 0.118286 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表