本文简要介绍了目前游戏工业界中软体模拟的一种常用方法Position Based Dynamic的原理以及实现。本文分为两个部分:原理以及实现。原理部分主要解读2005年的论文[1],简要介绍了Position Based Dynamic的思想和原理。在实现部分,本文给出了一个基于Position Based Dynamic框架下的简单三角形模拟程序并详细解读了其代码。
原理 非刚性物体的模拟在当下的游戏产业日渐成为重要的组成部分 (懂得都懂)。相比传统模拟,游戏产业对求解结果的精确性并没有那么高的要求,相反如何快速高效的求出视觉上具有说服力的结果是游戏相关模拟技术关注的重点。因此,Position Based Dynamic(PBD)应运而生。相比于有限元,Material Point Method等更加准确的方法,PBD更关注数值稳定性以及模拟结果的说服力,这点在PBD如何处理碰撞上表现的尤为突出。相比传统方法中通过力的作用修正碰撞结果,PBD在处理碰撞时往往会先估计碰撞后系统的状态,再通过碰撞结果更新当前系统的状态。
PBD的世界 对于PBD方法,世界是由质点 以及约束 构成的。质点和中学物理中学到的质点性质相同,其包含位置 ,速度 以及质量 三个性质,其中位置和速度会受到外力作用也会受到约束限制。一个约束包含受约束的质点集 ,劲度系数 以及约束方程以及条件 。约束方程为 的函数,它可以被看成是系统为了维持稳定所定义的势能函数。具体的其可以分为等式约束以及不等式约束两类,其中,等式约束需要系统满足 ,而不等式约束则需要满足 。对于不等式约束,可以分为两个步骤:1.检测当前状态是否满足 2. 对于不满足1.的约束将其转为 并和其它等式约束一同求解。
至此我们以及得到构建PBD世界的全部元素,质点负责记录世界中每个物体的位置以及状态,外力负责改变质点的运动状态,约束负责制约质点运动以保持物体外形。
PBD的基本步骤 PBD的基本求解步骤如算法1
所示( 下同):
对于每个质点
对每个质点
对每个质点
对每个质点
其中,可以看到系统除了需要记录质点的位置,速度,还有一个额外值 负责记录PBD预测的该质点在下一步迭代中的初始位置。最后该位置会在步骤6中被用于逆向更新质点的速度。对于步骤2.中的dampVelocities,其主要步骤如算法2
所示:
计算系统的质心坐标以及速度
计算系统的平均角速度
让各个质点角速度于系统相对一致
其中 , 满足 。该衰减项能让系统不减少整体能量的同时消弭系统的形变。
对于第4步中的generateCollisionConstrains以及第5步中的projectConstraints将在1.4以及1.3节中详细介绍。
投影约束 由于系统所有约束都被转为等式约束 ,因此我们在求解时可以只考虑等式情况。在理想情况下,系统的所有约束一起构成了一个 的非线性方程组,直接求解这个线性方程组是十分困难的。因此,[1]提出用类似G-S法的迭代方式逐步求解方程组。在每个迭代步骤里,PBD需要逐个遍历每个等式约束 并求出其近似解。假设求解前的状态为 ,对约束方程泰勒展开有:
该方程是欠约束的,为此,我们不妨假设 的方向和约束函数梯度方向相同 ,带入(1)中得到:
因此,对于每个质点的位置 我们有:
对于各个质点质量不相同的系统,作者给出的解决方法是给每个距离乘上权重:
在每次迭代中,PBD会遍历每个约束中并给其中的每个质点 添加一个约束投影距离 。由于这个过程必须在每个约束求解完成后才能开始下一个约束的求解,因此这个过程很难被并行化。[2] 提出通过图着色的方法给约束聚类。他们首先构建了以约束为节点,在两个有公共质点的约束之间创建一条边的计算图。接着,通过图着色算法,同一颜色的约束可以放在同一聚类中,之后便可以以聚类为单位平行求解约束方程。
对于非刚性的约束,其劲度系数 ,因此在计算 的偏移过程中,我们需要乘上系数 :
生成碰撞约束 在所有约束中,有一种特殊约束为碰撞约束。该约束在一个物体于另一个物体碰撞时产生,保证一个物体的质点不会进入另一个物体的内部。假设物体的碰撞点为 ,碰撞表面的法线为 则该约束可以写为 。正如1.1中的讨论结果,我们可以把约束转为等式约束
带入(3)中求解后 为:
可以看到,该结果有十分明确的几何意义:对于进入碰撞点的质点 将其沿着法线方向 移动其到碰撞点距离 在 方向上的投影个距离。
在 步骤中,对每个顶点,PBD会发射一个从 的射线。此时,可以分为三种情况
若 均在物体表面之外,则约束 被满足
若 经过物体表面,令其与物体表面交点为 ,法线为 ,则生成一个约束方程为 劲度系数为 的 的约束
若 均在物体表面内,则找到 距离物体表面最近的交点 以及其法线 ,生成则生成一个约束方程为 劲度系数为 的 的约束
对于动态物体,则可以通过同时将两物体纳入PBD系统中模拟来实现。例如假设物体2有顶点 ,则可以找到对应碰撞点 以及法线 ,将其带入公式(5)中有: ,将该公式带入(3)中便能求解该约束。
实现 一个利用PBD框架实现的刚性三角形模拟代码如下(这个实现起演示作用,并不高效):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 import taichi as titi.init(arch=ti.gpu) c_s_eq_distance = 1 c_c_base = 1024 c_c_ge_surface = c_c_base + 0 k_damp = 1e-2 n_triangle = 1 n_vertex = n_triangle * 3 dt = 1e-2 g = ti.Vector([0 , -1 ]) plane = 0.2 n_max_constraint_cnt = n_triangle + n_vertex * 5 n_max_solve = 5 pixels = ti.Vector.field(3 , dtype=ti.f32, shape=(512 , 512 )) p = ti.Vector.field(2 , dtype=ti.f32, shape=(n_vertex)) x = ti.Vector.field(2 , dtype=ti.f32, shape=(n_vertex)) v = ti.Vector.field(2 , dtype=ti.f32, shape=(n_vertex)) c = ti.Vector.field(3 , dtype=ti.f32, shape=(n_vertex)) con = ti.Vector.field(4 , dtype=ti.f32, shape=(n_max_constraint_cnt)) con_t = ti.field(dtype=ti.f32, shape=(n_max_constraint_cnt)) acc_t = ti.field(dtype=ti.f32,shape=1 ) con_cnt = ti.field(dtype=ti.i32, shape=2 ) acc_t[0 ] = 0 con_cnt[0 ] = 0 con_cnt[1 ] = 0 @ti.kernel def updateVelocity (): for i in v: v[i] = v[i] + dt * g def dampVelocity (): xc = ti.Vector([0 , 0 ]) vc = ti.Vector([0 , 0 ]) for i in range (n_vertex): xc += x[i] vc += v[i] xc /= n_vertex vc /= n_vertex L = 0 I = 0 for i in range (n_vertex): ri = x[i] - xc vi = v[i] - vc L += ri.x * vi.y - ri.y * vi.x I += ri.x * ri.x + ri.y * ri.y w = L / I for i in range (n_vertex): vi = v[i] ri = x[i] - xc v[i] = vi + k_damp * (vc + w * ri - vi) @ti.kernel def updatePosition (): for i in v: p[i] = x[i] + dt * v[i] @ti.kernel def generateConstraints (): for i in v: pi = p[i] xi = x[i] di = pi - xi if di[1 ] < 0 and pi[1 ] < plane: t = (plane - xi[1 ]) / di[1 ] qi = t * di + xi idx = ti.atomic_add(con_cnt[0 ], 1 ) con[idx] = ti.Vector([i, qi[0 ], qi[1 ], 0 ]) con_t[idx] = c_c_ge_surface def projectConstrainsPositions (): for ci in range (con_cnt[0 ]): con_type = con_t[ci] data = con[ci] if con_type == c_s_eq_distance: i = int (data[0 ]) j = int (data[1 ]) d = data[2 ] p1 = p[i] p2 = p[j] norm_p1_p2 = (p1 - p2).norm() n = (p1 - p2) / norm_p1_p2 del_p1 = - (norm_p1_p2 - d) / 2 * n del_p2 = (norm_p1_p2 - d) / 2 * n p[i] += del_p1 p[j] += del_p2 elif con_type == c_c_ge_surface: i = int (data[0 ]) qi = ti.Vector([data[1 ], data[2 ]]) nix = data[3 ] ni = ti.Vector([nix, ti.math.sqrt(1 - nix * nix)]) pi = p[i] pq = pi - qi p[i] += - (pq[0 ] * ni[0 ] + pq[1 ] * ni[1 ]) * ni @ti.kernel def updateState (): for i in v: v[i] = (p[i] - x[i]) / dt x[i] = p[i] def simulation (): con_cnt[0 ] = con_cnt[1 ] updateVelocity() dampVelocity() updatePosition() generateConstraints() for _ in range (n_max_solve): projectConstrainsPositions() updateState() acc_t[0 ] += dt @ti.func def barycentric_coords (p0, p1, p2, P ): v0 = p2 - p0 v1 = p1 - p0 v2 = P - p0 denom = v0.dot(v0) * v1.dot(v1) - v0.dot(v1) * v1.dot(v0) u = (v2.dot(v0) * v1.dot(v1) - v2.dot(v1) * v0.dot(v1)) / denom v = (v2.dot(v1) * v0.dot(v0) - v2.dot(v0) * v0.dot(v1)) / denom return u, v @ti.kernel def paint_triangle (): for i, j in pixels: pos = ti.Vector([i / 512 , j / 512 ]) p0 = x[0 ] p1 = x[1 ] p2 = x[2 ] c0 = c[0 ] c1 = c[1 ] c2 = c[2 ] u,v = barycentric_coords(p0, p1, p2, pos) color = ti.Vector([0 ,0 ,0 ]) if u > 0 and v > 0 and u + v < 1 : color = c0 + (c1 - c0) * u + (c2 - c0) * v pixels[i, j] = color gui = ti.GUI("Triangle" , res=512 ) @ti.kernel def init (): x[0 ] = ti.Vector([0.37 , 0.5 ]) x[1 ] = ti.Vector([0.4 , 0.43 ]) x[2 ] = ti.Vector([0.45 , 0.51 ]) c[0 ] = ti.Vector([1 , 0 , 0 ]) c[1 ] = ti.Vector([1 , 0 , 0 ]) c[2 ] = ti.Vector([1 , 0 , 0 ]) for i in v: j = (i + 1 ) % 3 con[i] = ti.Vector([i, j, (x[i] - x[j]).norm(),0 ]) con_t[i] = c_s_eq_distance con_cnt[0 ] += 1 con_cnt[1 ] += 1 init() while gui.running: simulation() paint_triangle() gui.set_image(pixels) gui.show()
其运行效果如图所示:
场景描述 整个场景包含一个平面以及一个刚体三角形。三角形包含三个质点,每个质点两两之间存在一个等距离约束:
其中 为 在初始状态下的距离。将三角形以及其约束添加到场景中的代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @ti.kernel def init (): x[0 ] = ti.Vector([0.37 , 0.5 ]) x[1 ] = ti.Vector([0.4 , 0.43 ]) x[2 ] = ti.Vector([0.45 , 0.51 ]) c[0 ] = ti.Vector([1 , 0 , 0 ]) c[1 ] = ti.Vector([1 , 0 , 0 ]) c[2 ] = ti.Vector([1 , 0 , 0 ]) for i in v: j = (i + 1 ) % 3 con[i] = ti.Vector([i, j, (x[i] - x[j]).norm(),0 ]) con_t[i] = c_s_eq_distance con_cnt[0 ] += 1 con_cnt[1 ] += 1
模拟整体框架 模拟的代码按照算法1给出的思路实现
1 2 3 4 5 6 7 8 9 10 11 12 13 def simulation (): con_cnt[0 ] = con_cnt[1 ] updateVelocity() dampVelocity() updatePosition() generateConstraints() for _ in range (n_max_solve): projectConstrainsPositions() updateState() acc_t[0 ] += dt
在开始模拟前需要把上一帧碰撞检测添加的碰撞约束清空con_cnt[0] = con_cnt[1]
updateVelocity()
对应算法1
的第一步。由于系统的唯一外力为重力,因此更新时只需加上重力加速度即可
1 2 3 4 @ti.kernel def updateVelocity (): for i in v: v[i] = v[i] + dt * g
dampVelocties()
对应算法1
的第2步,其实现在2.3节中讨论
updatePosition()
对应算法1
的第3步,其就是简单的根据速度更新位置
1 2 3 4 @ti.kernel def updatePosition (): for i in v: p[i] = x[i] + dt * v[i]
generateConstraints()
对应算法1
的第4步,其实现在2.4节中讨论
projectConstrainsPositions()
对应算法1
的第5步,其实现在2.5节中讨论
updateState()
对应算法1
的第6步,根据预测的下一帧位置更新质点位置和速度
1 2 3 4 5 @ti.kernel def updateState (): for i in v: v[i] = (p[i] - x[i]) / dt x[i] = p[i]
dampVelocities dampVelocities按照算法2
的思路实现
根据算法2
的第一步,我们需要计算系统的质心坐标以及速度。由于我们假设当前系统每个质点的质量是1,因此:
1 2 3 4 5 6 7 xc = ti.Vector([0 , 0 ]) vc = ti.Vector([0 , 0 ]) for i in range (n_vertex): xc += x[i] vc += v[i] xc /= n_vertex vc /= n_vertex
根据算法2
的第二步,我们需要计算系统的平均角速度
对于一个二维平面上的系统,其转动惯量为 ,因此我们可以将公式改写为:
实现的代码为:
1 2 3 4 5 6 7 8 9 L = 0 I = 0 for i in range (n_vertex): ri = x[i] - xc vi = v[i] - vc L += ri.x * vi.y - ri.y * vi.x I += ri.x * ri.x + ri.y * ri.y w = L / I
最后根据算法2
的第三步,我们需要对每个质点减去其超出整个系统平均角速度的部分。
1 2 3 4 for i in range (n_vertex): vi = v[i] ri = x[i] - xc v[i] = vi + k_damp * (vc + w * ri - vi)
generateConstraints 目前我们系统中只有三角形到平面一种情况,因此我们只需遍历所有顶点,判断其是否和三角形有交点即可。
为了简化计算过程,这里我们假设上一帧的位置 不在当前平面中。因此我们只需考虑 是否与平面有交点。
对于水平面 ,质点与其有交点需要满足两个条件:
的y值低于水平线
质点有向下运动的趋势
因此我们有:
1 2 if di[1 ] < 0 and pi[1 ] < plane: ...
对于射线 我们可以用以下方程表示:
其与水平面 的交点满足
解得: ,其中
1 2 t = (plane - xi[1]) / di[1] qi = t * di + xi
最后,我们通过原子加给该约束分配一个位置,并把对应信息写到buffer中:
1 2 3 4 idx = ti.atomic_add(con_cnt[0 ], 1 ) con[idx] = ti.Vector([i, qi[0 ], qi[1 ], 0 ]) con_t[idx] = c_c_ge_surface
projectConstrainsPositions 目前我们系统中需要处理约束有两类: 等值约束以及碰撞约束:
1 2 3 4 if con_type == c_s_eq_distance: ... elif con_type == c_c_ge_surface: ...
对于碰撞约束,我们通过公式(6)的结论直接求解
1 2 3 4 5 6 7 8 9 10 i = int (data[0 ]) qi = ti.Vector([data[1 ], data[2 ]]) nix = data[3 ] ni = ti.Vector([nix, ti.math.sqrt(1 - nix * nix)]) pi = p[i] pq = pi - qi p[i] += - (pq[0 ] * ni[0 ] + pq[1 ] * ni[1 ]) * ni
对于等值约束,其约束方程为:
带入公式(3)中解得:
因此,其可以实现为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 i = int (data[0 ]) j = int (data[1 ]) d = data[2 ] p1 = p[i] p2 = p[j] norm_p1_p2 = (p1 - p2).norm() n = (p1 - p2) / norm_p1_p2 del_p1 = - (norm_p1_p2 - d) / 2 * n del_p2 = (norm_p1_p2 - d) / 2 * n p[i] += del_p1 p[j] += del_p2
参考文献 [1] M. Müller, Heidelberger B, Ratcliff M H A J. Position based dynamics[J]. Journal of Visual Communication and Image Representation, 2007(18).
[2] Fratarcangeli M, Pellacini F. Towards a Massively Parallel Solver for Position Based Dynamics[C]. //Proceedings of SIGRAD 2014.