#! https://zhuanlan.zhihu.com/p/339879519
本文为对Position Based Dynamics的论文阅读。本文并不会涵盖论文的所有内容,同时也会做相当多的补充,因此内容上会有很大的差异,但所介绍、使用的方法都是原论文中的内容。
下文中略称Position Based Dynamics为PBD。
Position based是相对于传统的force based而言的。一般进行物理模拟时,都是对t时刻的物体进行受力分析,然后根据牛顿第二定律求出t时刻的加速度a,再根据加速度a进行时间积分,由当前时刻的系统状态(速度、位移)求出下一时刻的系统状态:
因为a通常是根据时间发生变化的,所以积分并不好求,一般都只能得到在时间区间中的几个点上的a,所以通常都是看成常微分方程,然后使用一些数值方法来计算。
其实是根据得到的,这也就是个常微分方程。例如用欧拉法求解的话就是,本质就是把上面的那个积分近似成
Force based方法因为不直接操纵位移,所以很容易出现overshooting问题。
通常来说,时间步长越小,方法越数值稳定,也就越不容易出现overshooting问题。但是越小,为了经过相同的时间跨度所需要的迭代次数就越多,计算量也就越大。缩小10倍,那迭代次数就得增大10倍。
所以在步长不能足够小时,通常使用隐式方法来避免overshooting问题,因为隐式方法相对而言比较容易数值稳定。不过隐式方法的计算量通常都大得多,所以force based方法的overshooting问题并不是完全解决的,通常都需要在数值稳定性和计算复杂度之间做个取舍,以求用尽量快的速度获得尽量好的效果。
Position based方法则是抛掉加速度,而直接处理位移x。很多力都可以表示为当前系统中质点位移的函数(例如保守力),例如磁铁的排斥力、弹簧的拉力。
PBD的优势在于:
Position Based Dynamics在每个时间步的位移更新大致可以分成两步:
可以想象成把力分为了两个批次,先后对物体产生作用。显然因为现实中力的作用是同时的,所以这种近似必定会带来一定的误差。具体这个误差是多少,我暂时没想明白咋算。
第一个批次的计算在此不做讨论,原论文中也仅仅只是使用显式欧拉法来简单得到的。
第二个批次的关键点在于如何计算位移变化量,本节剩下的篇幅都将讨论这一问题。
目标是计算位移变化量,使得对每个约束都成立。
首先要做的是将约束形式化定义。原论文中将其分为两类:
原论文中处理inequality约束的方法是首先检查当前是否已经满足该约束,如果满足的话那就跳过,否则将其转换成equality约束进行处理(也就是不等号变等号)。
如此处理inequality约束后,就只剩下equality约束了,而每个equality约束实际上就是一个关于的方程,所有的equality约束就构成一个关于的方程组。想要得到满足所有约束,就是要求解这个方程组。
形式化定义一下。设有个质点,第i个质点的原位移为,并设。设有m个约束函数(包括由inequality约束转换得到的),第j个约束函数为。所要求的就是某个,使得对,都有。也就是求解如下关于的方程组:
通常都是一个非线性函数,例如就连最简单的距离约束函数都是非线性的,所以该方程组通常也都是非线性的。
而另一方面,如果假设在三维空间的话,要求就意味着有个变量要求,而方程数量并不一定等于,这就意味着该方程组在一般情况下要么欠定(,解不唯一),要么过定(,很可能无解)。
原文中为了求解这一方程组,使用了他称之为“Gauss-Seidel-type iteration”的方法,我并没有找到该词的出处,推测是作者自己造的。这个词的含义是引用了求解线性方程组的Gauss-Seidel方法的不可并行性。求解线性方程组的Gauss-Seidel方法因为迭代计算时第个变量的迭代需要在第个变量迭代完才能进行,所以每次迭代都只能串行地逐个迭代每个变量,而不能并行地同时迭代所有变量。“Gauss-Seidel-type”指的就是每次迭代都串行计算的概念。
这里它的串行是“逐个约束”的串行,也就是按照的顺序,逐个求解。每个约束处理完,就根据求解的更新,然后再处理下一个约束。显然这一方法的话,只逐个处理一次很容易会处理完最后一个以后破坏第一个约束,所以作者将这一过程迭代多次。
这里存在的一个问题是,求解每个约束时,因为有个变量,而方程仅仅只有一个,所以有无穷多解。需要增加另外的约束才能求解。
我是觉得这个方程组的求解比较像最优化问题。在欠定的情况下,找到一个尽可能好的解(使某个奖励函数的值尽可能大)。在过定的情况下,找到一个尽可能不差的解(使某个惩罚函数的值尽可能小)。而这个最优化的目标就构成了上面提到的“另外的约束”。
不过原文没有使用最优化的思路,而是直接指定了的方向,这样对每个约束计算的时候仅仅只需要求的大小,这一个变量,就行了。
的方向,原文是基于“动量、角动量守恒”这一出发点指定的。“动量、角动量守恒”是指系统在质点们的位移为时具有的动量、角动量,应与位移为时具有的相同。为什么应该相同呢?
接下来的问题是如何找到这样一个不改变动量、角动量的方向?注意这里的方向指的并非单纯的质点的“位移方向”,还包括了不同质点之间的位移距离的比例。事实上,如果假设所有质点的质量都为1的话,整个系统的质心位移就是,位移变化量就是,也就是的所有分量之和除以n。显然,描述了系统质心位移的变化量。
原文的思路其实就是找一个方向,在这个方向上的都会使,也就是系统质心位移不变,那么动量与角动量当然也不变(速度可以看成是当前时刻位移与上一时刻位移的差值除以时间步长,所以当前时刻位移不变的话,速度当然也不变)。
问题就变成怎么找一个方向使系统质心位移不变。
这里得回过头去看一下约束函数,不难发现,当这一约束函数描述的力产生作用时,其函数值会发生改变。例如,距离约束描述的是棒子的弹力,当棒子把两个小球拉近/弹远一点的时候,函数值就变小了点。
所以约束函数的值的变化可以看成是内力的作用。而内力的作用是不会改变系统质心位移的,设,若物体只做刚体运动(平移、旋转)(此时一定是内力作用的结果),则有如下命题成立:
所以问题就变成了怎么找一个方向使得约束函数的值一定随的改变发生改变。这个问题很简单,沿梯度方向基本是肯定会改变函数值的。原论文就采用了这一方向。
结果就是,取处的作为的方向,即设,其中。
回过头去看一下想要求解的方程组:
根据(3)部分的论述,求解的思路就是对每个约束,使,则得到个关于的一元方程。
使表示进行约束投影前的质点位移,设,其中为关于的一元方程的解。则就是经过一次约束投影后得到的质点位移。至此,就得到了一个完整的约束投影的算法,逐个求解即可。
不过一元方程通常是个非线性方程,挺难求解的。原文中为了降低计算量,用泰勒展开进行了线性化的近似处理:
解出:
则对应的就是:
注意到上面我们在确保系统质心位移不变时,曾假定所有质点的质量都为1,当时是为了方便后面叙述,下面考虑质点质量不同的情况。设质点的质量为,并为了方便而设。为了方便叙述,设为质点质量都为1的时候求出来的位移变化量,而为质点质量不相同的时候求出来的位移变化量。由前述(1)~(4)的方法我们可以求出,现在想要求。
也依然需要满足
为了满足条件2,使系统质心位移不变,假设,其中k为待定系数,则,因为是质量都为1时候求出来的位移变化量,所以,故。
然后为了满足条件1,将其线性化,得到:
而因为,所以将其线性化后可以得到:
将上两式代入得到:
注意到
则有:
接下来只是推测。
我推测应该存在某个条件能够估计出,因为代入,得到,显然满足。
假设,就能得到:
由此得到最终的位移变化量:
TODO 讲讲这些和算法核心无关的内容
呃,这章大概不写了,感觉我也写不出来啥和原论文有差别的地方,我也解读不出来更多东西。
原文里是拿个布料模拟来做demo,那个太NB了,我自己写的简单很多很多。
模拟的东西很简单,就是五个质点,相邻两个之间有长度约束。
先忽略之间的长度约束,它俩比较特别。
用PBD的思路做的话,就是有这么一组约束函数:。
第k次迭代按照如下步骤:
最后提一下关于的处理,在原论文里这块就叫attachment,因为质点0是直接被我提着跑的,它不参与物理计算。所以在每次迭代的时候都是被指定的,而非被物理计算出来的。因此包含的约束导出的虽然包含对的更新,但却会被无效化。所以我们希望投影约束过程中能自动地让为0。为了实现这一目的,原论文中是将质点0的质量设为无限大,也就是使质量的倒数。这确实是很好的策略。我自己耍着玩,用了另一种策略。
我在处理约束时,不使用作为的方向,而是直接指定为其方向。因为关于这个方向是线性关系,所以就等于0了。这么做的话不会保证动量守恒,不过其实假设质点0质量无穷大的时候,由质点0和质点1构成的这个系统的动量也已经无穷大了,所以这块儿守不守恒我感觉问题不大。就结果来看也确实没啥问题。
最后taichi的代码附在附录二中。
import taichi as ti
from random import random
from tqdm import tqdm
from math import sin, pi, sqrt
################
# Support Functions
################
################
# Init taichi
################
ti.init(arch=ti.gpu)
################
# Input data
################
x1 = [-sqrt(3)/2, -1/2]
x2 = [sqrt(3)/2, -1/2]
x3 = [0, 1]
x = [0, 0]
v = [random()*2-1, random()*2-1]
gravityConstant = 0.5
timestep = 0.01
################
# Taichi Tensors
################
################
# Kernels
################
################
# Time Loop Functions
################
def calGravity(x, y):
# From x to y
d = [y[0]-x[0], y[1]-x[1]]
k = d[0]*d[0] + d[1]*d[1]
k = pow(k, 1.5)
k = gravityConstant / k
return [k*d[0], k*d[1]]
def TimeTick(t):
a_list = [
calGravity(x, x1),
calGravity(x, x2),
calGravity(x, x3),
]
a = [0, 0]
for sa in a_list:
a[0] += sa[0]
a[1] += sa[1]
# update
x[0] += timestep * v[0]
x[1] += timestep * v[1]
v[0] += timestep * a[0]
v[1] += timestep * a[1]
################
# Init tensors
################
################
# GUI Settings
################
videoManager = ti.VideoManager(output_dir="./video", framerate=24, automatic_build=False)
gui = ti.GUI(background_color=0x222222)
colors = [0xFF0000, 0x00FFFF, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FF00]
def world2screen(x, y):
L = 1.5
x = (x-(-L))/(2*L)
y = (y-(-L))/(2*L)
return (x,y)
show_on_screen = True
seconds = 5
fps = 24
if show_on_screen:
fps = 60
timestepsPerFrame = int(1.0/fps/timestep)
totalFrame = seconds*fps
if show_on_screen:
totalFrame = 1000000
################
# Main Loop
################
print("v0: ", v)
for frameCount in tqdm(range(totalFrame)):
print(x)
for t in range(timestepsPerFrame):
TimeTick(timestep*t + frameCount/fps)
gui.circle(world2screen(x1[0],x1[1]), color=colors[0], radius=5)
gui.circle(world2screen(x2[0],x2[1]), color=colors[0], radius=5)
gui.circle(world2screen(x3[0],x3[1]), color=colors[0], radius=5)
gui.circle(world2screen(x[0],x[1]), color=colors[1], radius=5)
if show_on_screen:
gui.show()
else:
img = gui.get_image()
videoManager.write_frame(img)
gui.clear()
if not show_on_screen:
videoManager.make_video(gif=True, mp4=False)
import taichi as ti
from random import random
from tqdm import tqdm
from math import sin, pi, sqrt
################
# Support Functions
################
def assignArray2Tensor(t, i, a):
for j in range(len(a)):
t[i][j] = a[j]
def diffDotProd(a, b):
res = 0
for i in range(3):
res += (a[i]-b[i]) * (a[i]-b[i])
return sqrt(res)
################
# Init taichi
################
ti.init(arch=ti.gpu)
################
# Input data
################
def Calx0(t):
loopInt = 4 # s # 2s for a loop
x = sin(t/loopInt*2*pi)
y = sin(t/loopInt*2*pi)
return [x, y, 0]
n = 5
lConst = 0.1
xInitRel = [[0, -lConst*i, 0] for i in range(5)]
gravityConstant = 9.8
timestep = 0.01
################
# Taichi Tensors
################
x = ti.Vector(3, dt=ti.f32, shape=(n))
v = ti.Vector(3, dt=ti.f32, shape=(n))
vPer = ti.Vector(3, dt=ti.f32, shape=(n))
xPer = ti.Vector(3, dt=ti.f32, shape=(n))
a = ti.Vector(3, dt=ti.f32, shape=(n))
l = ti.field(ti.f32, shape=(n))
################
# Kernels
################
@ti.kernel
def CalvPer():
for i in v:
vPer[i] = v[i] + a[i] * timestep
@ti.kernel
def CalxPer_rest():
for i in x:
xPer[i] = x[i] + timestep/2 * (v[i] + vPer[i])
@ti.kernel
def Updatev():
for i in v:
v[i] = 2/timestep * (xPer[i]-x[i]) - v[i]
@ti.kernel
def Updatex():
for i in x:
x[i] = xPer[i]
################
# Time Loop Functions
################
def CalxPer(t):
CalxPer_rest()
xPer[0] = Calx0(t)
def CalPer(t):
CalvPer()
CalxPer(t)
def SolveConstraints():
ITER_NUM = 2
for i in range(ITER_NUM):
# C0
k = l[0]/diffDotProd(xPer[1], xPer[0])-1
for u in range(3):
xPer[1][u] += k * (xPer[1][u]-xPer[0][u])
# Cj
for j in range(1, n-1):
k = 0.5 * (l[j]/diffDotProd(xPer[j+1], xPer[j])-1)
tmp = [0, 0, 0]
for u in range(3):
tmp[u] = k * (xPer[j][u]-xPer[j+1][u])
for u in range(3):
xPer[j+1][u] += k * (xPer[j+1][u]-xPer[j][u])
for u in range(3):
xPer[j][u] += tmp[u]
def Update():
Updatev()
Updatex()
def TimeTick(t):
CalPer(t)
SolveConstraints()
Update()
################
# Init tensors
################
x0 = Calx0(0)
assignArray2Tensor(x, 0, Calx0(0))
for i in range(1, n):
xi = [0, 0, 0]
for u in range(3):
xi[u] = x0[u] + xInitRel[i][u]
assignArray2Tensor(x, i, xi)
for i in range(n):
assignArray2Tensor(v, i, [0,0,0])
assignArray2Tensor(a, 0, [0,0,0])
for i in range(1, n):
assignArray2Tensor(a, i, [0, -gravityConstant, 0])
for i in range(n):
l[i] = lConst
################
# GUI Settings
################
videoManager = ti.VideoManager(output_dir="./video", framerate=24, automatic_build=False)
gui = ti.GUI(background_color=0x222222)
colors = [0xFF0000, 0x00FFFF, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FF00]
def world2screen(x, y):
L = 1.5
x = (x-(-L))/(2*L)
y = (y-(-L))/(2*L)
return (x,y)
show_on_screen = False
seconds = 5
fps = 24
if show_on_screen:
fps = 60
timestepsPerFrame = int(1.0/fps/timestep)
totalFrame = seconds*fps
if show_on_screen:
totalFrame = 1000000
################
# Main Loop
################
for frameCount in tqdm(range(totalFrame)):
print(x)
for t in range(timestepsPerFrame):
TimeTick(timestep*t + frameCount/fps)
for i in range(n):
gui.circle(world2screen(x[i][0],x[i][1]), color=colors[i>0], radius=5)
if show_on_screen:
gui.show()
else:
img = gui.get_image()
videoManager.write_frame(img)
gui.clear()
if not show_on_screen:
videoManager.make_video(gif=True, mp4=False)