Fluid Engine Development PIC/FLIP 代码分析

把 Fluid Engine Development 看完了,但是仍然感觉不懂

https://github.com/doyubkim/fluid-engine-dev

感觉还是应该了解整体代码怎么写的,所以做个总结

看着看着,感觉还是从底层开始看起

从底层重新开始看的时候,感觉就来了

而且作者也有很多注释,感觉能够体会到别人的思路

他这里也有很多内容,我选择从 PIC/FLIP 开始看起

然后我选择它的 hybrid_liquid_sim 工程

它的 main 文件就很容易找到了,在 src\examples\hybrid_liquid_sim\main.cpp

Animation

Animation 类里面提供了 update 函数,update 函数里面调用了虚函数 onUpdate

虚函数 onUpdate 用于给子类提供具体的实现的

这就完成了第一层的抽象,就是把所有的动画都视为一个可以随时间更新的东西

那么我们用户统一地使用 update 函数去更新它

具体怎么更新,那就是程序员怎么在子类中实现虚函数 onUpdate 的事情了

也就是下层(怎么实现虚函数 onUpdate)对上层(用户调用 update)透明

例如,在 src\examples\hybrid_liquid_sim\main.cpp 中,有不同的求解器和情景可供选择,PIC,FLIP,APIC 等

最后这些求解器都进入函数 runSimulation,在其中,求解器的更新都是只需要一句 solver->update(frame);

就很优雅

PhysicsAnimation

继承 Animation

这个类主要实现了把某一帧拆分成若干个子步骤的功能

具体实现方法就是它 final 重写了虚函数 onUpdate,限定死了怎么处理某一个帧的

因为他一定要保证他自己这个拆分子步骤的功能嘛,所以是 final

具体内容就是,只有帧数实参大于类中存储的当前帧号时,才说明这个类要沿时间推进

if (frame.index > _currentFrame.index) {
	...

初始化

推进的具体办法是,帧号 < 0 时调用函数 initialize 进行初始化,

if (_currentFrame.index < 0) {
    initialize();
}

...

这里涉及到了初始化函数,是因为初始化函数的调用跟帧号有关,所以必须在这里给一个钩子,才能在这个正确的时间调用初始化

每个算法的初始化肯定是不一样的,所以如果我们对初始化的时间没有要求的话,我们肯定不用这么麻烦,但是就是因为他是跟帧号相关,所以才要提早到这里

那么自然地,这个初始化函数应该是一个虚函数

但是它这里的 initialize 还不是虚函数。反而,initialize 里面调用了虚函数 onInitialize,它的设计是让子类去重写这个虚函数

可能这是它自己的设计哲学吧,没懂为什么要额外一层

如果不用初始化,那么就统计要前进的帧数,对每一帧都调用 advanceTimeStep。也就是说,对于每一帧,都尝试拆分子步骤

其实这个函数名和 update 也很像,其实不用纠结,他就是为了完成拆分子步骤

...

int32_t numberOfFrames = frame.index - _currentFrame.index;

for (int32_t i = 0; i < numberOfFrames; ++i) {
    advanceTimeStep(frame.timeIntervalInSeconds);
}

_currentFrame = frame;

固定子步骤数量

具体他是怎么实现拆分子步骤的呢?也很简单

如果你设置了固定子步骤数量 n,那么就把这帧的时间平均分为 frame time/n 份,对每份都执行虚函数 onAdvanceTimeStep 也就是相当于把实际每一步的流体求解又推迟到这个 onAdvanceTimeStep 函数中了

const double actualTimeInterval =
    timeIntervalInSeconds /
    static_cast<double>(_numberOfFixedSubTimeSteps);

for (unsigned int i = 0; i < _numberOfFixedSubTimeSteps; ++i) {
    onAdvanceTimeStep(actualTimeInterval);
    _currentTime += actualTimeInterval;
    ...

为什么说是推迟呢?想象一下如果我们始终不知道要拆分子步骤这件事,那么流体求解部分应该在子类中重写 Animation 类的虚函数 onUpdate

但是现在的话,我们的流体求解部分要放到在 PhysicsAnimation 的子类对虚函数 onAdvanceTimeStep 的重写中了,所以是,我觉得这就像推迟了一个函数

自适应子步骤数量

如果你没有设置固定子步骤数量,那就是你要是用自适应子步骤数量。但是自适应算法应该是有不同的实现的,所以这里给出一个虚函数 numberOfSubTimeSteps 允许子类实现这个自适应的算法

虚函数 numberOfSubTimeSteps 接受剩余的时间作为参数,这应该是自适应算法的常规输入吧

获取了自适应的子步骤数量 n 之后,当前子步骤的时间步是 remaining time/n

剩余的时间 remaining time 最初是 frame time

执行了一步之后是 remaining time -= remaining time/n

执行完一步之后,下一次将 remaining time 输入到虚函数 numberOfSubTimeSteps 获得下次的子步骤数量 n2,下一次的子步骤的时间步就是 remaining time/n2

所以每一个子步骤的时间步可能是不一样的

double remainingTime = timeIntervalInSeconds;
while (remainingTime > kEpsilonD) {
    unsigned int numSteps = numberOfSubTimeSteps(remainingTime);
    double actualTimeInterval =
        remainingTime / static_cast<double>(numSteps);
        
    onAdvanceTimeStep(actualTimeInterval);

    remainingTime -= actualTimeInterval;
    _currentTime += actualTimeInterval;

	...

GridFluidSolver3

这里开始加入了很多东西

我觉得应该从他对虚函数的重写开始看起

其他的他自己定义的函数,看上去像是为了实现它对虚函数的重写中的逻辑,而创建的

初始化

初始化函数中,要初始化边界条件,边界条件就包括了碰撞体和粒子发射器

void GridFluidSolver3::onInitialize() {
    updateCollider(0.0);
    updateEmitter(0.0);

这两个更新函数,就很简单,就是调用各自的 update 而已

_collider->update(...);
_emitter->update(...);

我觉得具体的可以之后再看,比如碰撞体那部分的类是怎么样的

还是回到 GridFluidSolver3

对初始化函数的重写就是这些了,那么下一个是步进函数,对虚函数 onAdvanceTimeStep 的重写

步进 求解框架

void GridFluidSolver3::onAdvanceTimeStep(double timeIntervalInSeconds) {
	beginAdvanceTimeStep(timeIntervalInSeconds);
	computeExternalForces(timeIntervalInSeconds);
	computeViscosity(timeIntervalInSeconds);
	computePressure(timeIntervalInSeconds);
	computeAdvection(timeIntervalInSeconds);
	endAdvanceTimeStep(timeIntervalInSeconds);
}	

这个结构就很清晰,这就是求解 NS 方程要做的事情

前处理 后处理

前后的 beginAdvanceTimeStep endAdvanceTimeStep 分别包括了对虚函数 onBeginAdvanceTimeStep onEndAdvanceTimeStep 的调用,为更具体的流体算法子类提供虚函数接口

额外的是,beginAdvanceTimeStep 里面还有更新碰撞体和发射器,然后还要调用 GridBoundaryConditionSolver 的更新 updateCollider 和速度约束函数 constrainVelocity

当你想要看一下这个 GridBoundaryConditionSolver 是啥的时候,跳到定义,你就会看到他是又是一个类的指针,顺便也看到求解平流、扩散(粘性力)、压力的方法也是调用别的类的指针

AdvectionSolver3Ptr _advectionSolver;
GridDiffusionSolver3Ptr _diffusionSolver;
GridPressureSolver3Ptr _pressureSolver;
GridBoundaryConditionSolver3Ptr _boundaryConditionSolver;

这和碰撞体和发射器也是一样的

GridSystemData3Ptr _grids;
Collider3Ptr _collider;
GridEmitter3Ptr _emitter;

所以现在对于 GridFluidSolver3 就有一个大概的印象了,它定义了求解 NS 方程的大概框架,分解了求解步骤(外部力、粘性力、压力)但是在每个力的具体计算,实际上还是依赖更进一步的定义。这通过提供工具类的示例来实现。比如确定怎么求解压力的话,需要给 GridPressureSolver3Ptr _pressureSolver 赋值。或者说这是一种依赖注入的思路。

有点突然理解了依赖注入!主要是,概念很容易理解,就是调用方需要的东西,由别人来提供。主要是我觉得我似乎理解了为什么要这么做,为什么调用方要的东西要别人来给?因为调用方只知道算法流程,但是具体怎么实现还是要看子类的,或者别的类之类……?总之就是有种老板提方案员工负责执行的感觉

外部力

然后是 computeExternalForces。里面只有一个处理重力的函数 computeGravity

这个函数是第一个涉及到具体怎么计算物理量的函数

因为所有的变量都是 auto,所以理论上应该不用管类型也很容易理解代码

实际上也是这样的,获取速度场,从速度场获取访问器

速度场是有一个遍历所有元素的函数 forEachUIndex,这个函数可以输入一个 lambda 函数,在这个匿名函数里,访问器就相当于物理量,用它来实现物理公式就好了

auto vel = _grids->velocity();
auto u = vel->uAccessor();
auto v = vel->vAccessor();
auto w = vel->wAccessor();

vel->forEachUIndex([&](size_t i, size_t j, size_t k) {
    u(i, j, k) += timeIntervalInSeconds * _gravity.x;
});

vel->forEachVIndex([&](size_t i, size_t j, size_t k) {
    v(i, j, k) += timeIntervalInSeconds * _gravity.y;
});

vel->forEachWIndex([&](size_t i, size_t j, size_t k) {
    w(i, j, k) += timeIntervalInSeconds * _gravity.z;
});

...

这里确实是需要知道一下“场”这个类的概念,才有这种直观的认识。但是既然都过了一遍书本了,基本的印象还是有的。

然后就是要调用一下 applyBoundaryCondition 函数

观察发现,其他计算力的函数后面都有这个

因为 applyBoundaryCondition 里面是使用约束的那个工具类来约束速度,所以我们可以知道它的设计目的就是,每一次更新速度之后,都要约束一下速度。

粘性力 压力

computeViscositycomputePressure 里面,单纯是对 _diffusionSolver_pressureSolversolve 调用

那么它的设计目的就是,粘性力和压力的计算也是根据具体算法来的

平流

computeAdvection 也是单纯对各个变量的 solve 的调用

他看上去复杂,只是因为他要考虑所有标量,所有定义在网格中心的向量 CollocatedVectorGrid3 和定义在网格面上的向量 FaceCenteredGrid3

它把所有向量场都保存在一个列表中,以向量场的基类保存,所以你在遍历这个列表的时候不知道每个元素是 CollocatedVectorGrid3 还是 FaceCenteredGrid3,所以他这里做了两次 cast,把指向基类的指针 cast 到指向子类的指针,指针不为 null 表示这个示例确实是这个子类。

那么现在对这两个子类有不同的处理情况,平流求解器 _advectionSolver 是需要知道场的类型的(表现在函数重载上),但是我们可以把这两种处理情况串行写在一起。因为如果是子类 A 就不会是子类 B,所以实际上只会执行其中一个

auto collocated =
    std::dynamic_pointer_cast<CollocatedVectorGrid3>(grid);
auto collocated0 =
    std::dynamic_pointer_cast<CollocatedVectorGrid3>(grid0);
if (collocated != nullptr) {
    _advectionSolver->advect(*collocated0, *vel,
    ...
}

auto faceCentered =
    std::dynamic_pointer_cast<FaceCenteredGrid3>(grid);
auto faceCentered0 =
    std::dynamic_pointer_cast<FaceCenteredGrid3>(grid0);
if (faceCentered != nullptr && faceCentered0 != nullptr) {
    _advectionSolver->advect(*faceCentered0, *vel,
    ...
}

最后还有速度要平流自己

这里还有另外一个设计就是,如果更新了速度,比如这里平流了速度,那么之后就要跟上对速度的约束 applyBoundaryCondition;如果是更新了标量场或者是向量场,那么之后就要跟上对这个场的外插 extrapolateIntoCollider,这都是为了保证每一步之后,每个场,包括标量场、向量场、速度场都是正确的(不是指没有耗散,我指的是,逻辑上是正确的,更新后的值,不会出现那种新值与旧值并存的情况)

自适应子步骤数量

这里的逻辑也很简单,就是算当前的 CFL 条件数

( max ⁡ V ) Δ t / Δ x (\max{V})\Delta t/\Delta x (maxV)Δtx

这个东西最直观的意义就是速度最快的粒子单位时间走过的格子的数量,希望他不要太大

所以如果他太大的话,就用一个约定的最大值去除,比如你速度最大会走 10 个格子,但是我约定只能走 5 个,所以我就拆成两个子步骤

SDF

传给 _diffusionSolver _pressureSolver _advectionSolver 的碰撞体的 SDF 和碰撞体的速度都是从边界条件工具类 _boundaryConditionSolver 中得来

也就是这个工具类来负责

而流体的 SDF,在这个 GridFluidSolver3 里面是调用了一个虚函数 fluidSdf 这表示子类来负责流体的 SDF 怎么算

边界条件

外插

边界条件这里的怎么约束速度的方法在工具类里面,暂时看不到

但是将值外推到边界外的方法 extrapolateIntoCollider 在这里定义

它定义了三个重载,分别处理不同类型的值,ScalarGrid3 CollocatedVectorGrid3 FaceCenteredGrid3

三种情况的逻辑都是一样的,新建一个标记数组 marker,它的大小和要外插值的网格的大小相当

然后对每一个格子,都基于碰撞体的 SDF 判断这个点在不在碰撞体内,将结果记入 marker

然后得到的 marker 和要插值的值都输入到函数 extrapolateToRegion 中,这是一个更一般的工具函数。因为 marker 一旦构建了,他就可以表示任意边界,不一定是碰撞体的边界,还可以是流体的边界等等,所以需要这么一个工具函数

void GridFluidSolver3::extrapolateIntoCollider(CollocatedVectorGrid3* grid) {
    Array3<char> marker(grid->dataSize());
    auto pos = grid->dataPosition();
    marker.parallelForEachIndex([&](size_t i, size_t j, size_t k) {
        if (isInsideSdf(colliderSdf()->sample(pos(i, j, k)))) {
            marker(i, j, k) = 0;
        } else {
            marker(i, j, k) = 1;
        }
    });

    unsigned int depth = static_cast<unsigned int>(std::ceil(_maxCfl));
    extrapolateToRegion(grid->constDataAccessor(), marker, depth,
                        grid->dataAccessor());
}

在这个工具函数中,指定迭代次数

每一次迭代,都遍历 marker 中的所有元素,对于那些 flag 为 0 的位置,尝试从他上下左右前后 6 个邻居中找 flag 为 1 的元素,如果找到了就把邻居的值统计平均赋给当前位置,并且置当前位置的 flag 为 1

这样的话,刚好在边界外一格远的位置,有可能获得插值,如果是在边界外很远的地方,它的邻居都不在边界内,那么这次迭代之后他依然也不会获得插值

也就是,每一次迭代,都会把边界整体往外拓展一格(如果空间允许的话)

Grid 的尺寸

现在我们看完了 extrapolateToRegion。我们知道了 extrapolateToRegion 只需要知道怎么访问数据就行了,也就是得到 dataAccessor 就行了,dataAccessor 的类型与 ScalarGrid3 CollocatedVectorGrid3 FaceCenteredGrid3 无关,那么这里为什么要分三种情况呢

因为实际上不同类型的 Grid 的尺寸是不一样的,而获取正确的 dataAccessor 需要 Array 具有正确的尺寸

这里展示不同的类定义了不同的尺寸的 getter

Size3 CellCenteredScalarGrid3::dataSize() const {
    // The size of the data should be the same as the grid resolution.
    return resolution();
}

Size3 VertexCenteredScalarGrid3::dataSize() const {
    if (resolution() != Size3(0, 0, 0)) {
        return resolution() + Size3(1, 1, 1);
    } else {
        return Size3(0, 0, 0);
    }
}

怎么从这个 getter 到实际存储数据的类的尺寸?

查找 dataSize 的引用,发现它在 onResize 中被用到,并且这个函数是在 CollocatedVectorGrid3 中被定义的

进一步发现这个 onResizeCollocatedVectorGrid3 FaceCenteredGrid3 这一个层级的

对比就发现,这两个类的区别就在于 CollocatedVectorGrid3 是直接定义一个 Vector 作为 data 了,但是 FaceCenteredGrid3 因为把速度的不同分量定义在不同的面,所以现在速度的不同分量的位置不同了,就不能组成一个 vector 了(组成 vector 就说明三个分量在同一个位置)所以速度要存在三个不同的数组里面

所以 CollocatedVectorGrid3 就只用 resize 一个数组,而 FaceCenteredGrid3 需要 resize 三个数组

void CollocatedVectorGrid3::onResize(
    const Size3& resolution,
    const Vector3D& gridSpacing,
    const Vector3D& origin,
    const Vector3D& initialValue) {
    _data.resize(dataSize(), initialValue);
    ...
}

void FaceCenteredGrid3::onResize(const Size3& resolution,
                                 const Vector3D& gridSpacing,
                                 const Vector3D& origin,
                                 const Vector3D& initialValue) {
    if (resolution != Size3(0, 0, 0)) {
        _dataU.resize(resolution + Size3(1, 0, 0), initialValue.x);
        _dataV.resize(resolution + Size3(0, 1, 0), initialValue.y);
        _dataW.resize(resolution + Size3(0, 0, 1), initialValue.z);
    } else {
        _dataU.resize(Size3(0, 0, 0));
        _dataV.resize(Size3(0, 0, 0));
        _dataW.resize(Size3(0, 0, 0));
    }
    ...
}

这里可能一开始有点陌生,因为我们是顺着 solver 看过来的,没有从数据结构看过来,所以对数据结构可能有点陌生,但是看过了书的话应该有点印象

CollocatedVectorGrid3FaceCenteredGrid3onResize 都是对基类 VectorGrid3 的重写

VectorGrid3 在自己的 resize 中调用了 onResize,说明 onResize 只是一个钩子

之后,VertexCenteredVectorGrid3 CellCenteredVectorGrid3 FaceCenteredGrid3 在自己的构造函数中都会调用 resize,这就实现了最终对 Array 的 resize 的调用

Builder

感觉现在已经知道大概的逻辑了,但是回到 main 开始看的话,他这里创建求解器是使用一个 builder

看了一下,感觉他这思路就是,创建一个 builder 类,这个类提供一些函数用于配置默认参数,最后返回一个实例的共享指针

auto solver =
    PicSolver3::builder()
        .withResolution({resolutionX, 2 * resolutionX, resolutionX})
        .withDomainSizeX(1.0)
        .makeShared();

因为 B 和 C 都是继承自 A,所以只要 B 和 C 的 builder 都是继承自 A 的 builder,那么 B 和 C 配置默认参数的方法就会非常类似

这样子设计,应该是为了初始化配置上的相似性?

因为实际上 B 和 C 也没有什么自己的独特的要初始化的参数,要初始化的全都在基类 A 中定义好了

话又说回来,正是因为这样,才需要继承关系,B 和 C 只需要基类 A 定义的初始化函数,应该是理想的完美继承关系吧

构造函数

然后我打算再看 GridFluidSolver 的构造函数

GridFluidSolver3::GridFluidSolver3(const Size3& resolution,
                                   const Vector3D& gridSpacing,
                                   const Vector3D& gridOrigin) {
    _grids = std::make_shared<GridSystemData3>();
    _grids->resize(resolution, gridSpacing, gridOrigin);

    setAdvectionSolver(std::make_shared<CubicSemiLagrangian3>());
    setDiffusionSolver(std::make_shared<GridBackwardEulerDiffusionSolver3>());
    setPressureSolver(
        std::make_shared<GridFractionalSinglePhasePressureSolver3>());
    setIsUsingFixedSubTimeSteps(false);
}

因为这里前面看的都是 GridFluidSolver 求解 NS 方程的框架,实际上具体的求解逻辑还是要看工具类,而构造函数这里就是给出了怎么配置这些工具类

之后的类如果是继承 GridFluidSolver,比如 PicSolver,那么因为是继承嘛,所以父类构造函数也会被调用,也就相当于这些工具类的配置在 GridFluidSolver 这里就是默认值了

AdvectionSolver

AdvectionSolver 的虚函数 advect 有三个重载,分别处理 ScalarGrid3 CollocatedVectorGrid3 FaceCenteredGrid3

这个虚函数确定了输入的场,输出的场,速度场,还有边界 sdf

感觉看到这里的话,就知道这个代码里面大部分都用 sdf 来表示边界了

就很统一

然后把边界作为参数,就是说明一定是要考虑边界

给人感觉就是规范了怎么求解

SemiLagrangian

同样地,CollocatedVectorGrid3FaceCenteredGrid3 的区别就是前者只是一个场做向后追踪,而后者是三个分量都要分别做向后追踪

向后追踪的时候,也要拆分若干个子步骤,也是跟之前一样,根据 CFL 的物理意义来拆分,也就是所谓的自适应子步骤。算出了子步骤的数量之后,当前子步骤的 dt 也是用 remaining time / n 来算

// Adaptive time-stepping
Vector3D vel0 = flow.sample(pt0);
double numSubSteps
    = std::max(std::ceil(vel0.length() * remainingT / h), 1.0);
dt = remainingT / numSubSteps;

然后找点用的是中点法

// Mid-point rule
Vector3D midPt = pt0 - 0.5 * dt * vel0;
Vector3D midVel = flow.sample(midPt);
pt1 = pt0 - dt * midVel;

然后要考虑边界问题,通过两个点的 SDF 值相乘是否 < 0 来判断这两个点是否是一个在边界外一个在边界内

如果是的话,那就说明向后追踪的时候追到外面去了,那么就要把点钳制在边界上

这里通过线性插值把点定位在边界上

如果遇到了这种跨过边界的情况,那么钳制之后就直接结束循环了,不用继续执行剩下的子步骤了

double phi0 = boundarySdf.sample(pt0);
double phi1 = boundarySdf.sample(pt1);

if (phi0 * phi1 < 0.0) {
    double w = std::fabs(phi1) / (std::fabs(phi0) + std::fabs(phi1));
    pt1 = w * pt0 + (1.0 - w) * pt1;
    break;
}

这就是向后追踪的过程了

最后把向后追踪得到的位置是一个三维世界坐标,不是网格序号,所以要用 sampler,这个 inputSamplerFunc 返回的就是网格的 sampler

auto outputDataPos = output->dataPosition();
auto outputDataAcc = output->dataAccessor();
auto inputDataPos = input.dataPosition();

output->parallelForEachDataPointIndex([&](size_t i, size_t j, size_t k) {
    if (boundarySdf.sample(inputDataPos(i, j, k)) > 0.0) {
        Vector3D pt = backTrace(
            flow, dt, h, outputDataPos(i, j, k), boundarySdf);
        outputDataAcc(i, j, k) = inputSamplerFunc(pt);
    }
});

这里就可以看出来 sampleraccessor 的区别

sampler 是根据世界坐标来获得值的,而 accessor 是根据网格的编号直接访问数组元素

forEachIndex parallelForEachIndex

我突然很好奇的就是,这样子直接 foreach 的话,如果传入的 inputoutput 是同一个的话,又没有做缓存,那么应该会出现数据相关的问题吧

我主要是想确认一下他这里面确实没有更深一层东西来解决数据相关的问题,于是看了一下 forEachIndex

Grid 的 forEachIndex 实际上是调用的 Array 的 ArrayAccessorforEachIndex

最终看 Accessor 的 forEachIndex 发现他这个就是一个单纯的串行的多层循环,用模板把不同层循环都封装成一个了

看到这里的时候也要注意他这个是列优先存储

template <typename T>
template <typename Callback>
void ArrayAccessor<T, 1>::forEachIndex(Callback func) const {
    for (size_t i = 0; i < size(); ++i) {
        func(i);
    }
}

template <typename T>
template <typename Callback>
void ArrayAccessor<T, 2>::forEachIndex(Callback func) const {
    for (size_t j = 0; j < _size.y; ++j) {
        for (size_t i = 0; i < _size.x; ++i) {
            func(i, j);
        }
    }
}

template <typename T>
template <typename Callback>
void ArrayAccessor<T, 3>::forEachIndex(Callback func) const {
    for (size_t k = 0; k < _size.z; ++k) {
        for (size_t j = 0; j < _size.y; ++j) {
            for (size_t i = 0; i < _size.x; ++i) {
                func(i, j, k);
            }
        }
    }
}

那么更进一步,查看 ArrayAccessorparallelForEachIndex 的话,可以看到它们最终都是调用 parallelFor

同样的,parallelForEachIndex 不同的变体只是为了处理不同的维度

最终可以找到 parallelFor 里面有各种方法实现的多线程并行计算

void parallelFor(IndexType start, IndexType end, const Function& func,
                 ExecutionPolicy policy)

至于这个 parallelRangeFor 输入三个维度的范围,最终居然只拆分 z 维度的范围,很神奇

sampler

因为平流那里涉及到了 sampler,所以想看一些具体是怎么做的

CubicSemiLagrangianSemiLagrangian3 的差别也就只在于这个采样器的配置

虽然我知道这里面有插值

记得看书的时候也看到过,一开始书上讲的是线性插值然后讲三次样条插值

ScalarGrid3 CollocatedVectorGrid3 FaceCenteredGrid3 都是默认的 LinearArraySampler

插值方法都要把世界坐标用网格中心和网格单元尺寸进行归一化

这让我突然想到了,其实这里所有的坐标都是在物体坐标系的,如果你这个流体模拟外部还有别的坐标系,跟这个也无关的

所以才没有别的什么额外的归一化操作,比如处理整个 box 的旋转啊什么的,没有,因为 box 自己是一个物体坐标系

然后他这个求重心坐标的方法我还真看不懂

对于线性插值来说,在 i, j, k 和 i+1, j+1, k+1 之间这八个块内三线性插值

对于三次样条插值,在 i-1 i i+1 i+2 之间这 64 个块内做单调 Catmull-Rom 插值

这个单调 Catmull-Rom 在书中也有讲,搜了一下,这个也是可以做文章的,就是说具体怎么设计这个插值函数

但是具体单调 Catmull-Rom 怎么做的还没看懂……看书的时候就没看懂,估计之后要找文章来对应地看

GridDiffusionSolver

GridDiffusionSolver 也就是提供了虚函数,确定了形参

GridForwardEulerDiffusionSolver

GridForwardEulerDiffusionSolver3 更简单,因为是前向的,所以不用算矩阵求解,直接逐项更新就好了

void GridForwardEulerDiffusionSolver3::solve(
    const ScalarGrid3& source,
    double diffusionCoefficient,
    double timeIntervalInSeconds,
    ScalarGrid3* dest,
    const ScalarField3& boundarySdf,
    const ScalarField3& fluidSdf) {
    auto src = source.constDataAccessor();
    Vector3D h = source.gridSpacing();
    auto pos = source.dataPosition();

    buildMarkers(source.resolution(), pos, boundarySdf, fluidSdf);

    source.parallelForEachDataPointIndex(
        [&](size_t i, size_t j, size_t k) {
            if (_markers(i, j, k) == kFluid) {
                (*dest)(i, j, k)
                    = source(i, j, k)
                    + diffusionCoefficient
                    * timeIntervalInSeconds
                    * laplacian(src, _markers, h, i, j, k);
            } else {
                (*dest)(i, j, k) = source(i, j, k);
            }
        });
}

GridBackwardEulerDiffusionSolver

GridBackwardEulerDiffusionSolver 用了后向方法,所以就要求解矩阵了

因为是求解矩阵,所以可以看到多了构建系数矩阵和列向量的步骤,也就是 Ax=b 中的 A 和 b

void GridBackwardEulerDiffusionSolver3::solve(
    const ScalarGrid3& source,
    double diffusionCoefficient,
    double timeIntervalInSeconds,
    ScalarGrid3* dest,
    const ScalarField3& boundarySdf,
    const ScalarField3& fluidSdf) {
    auto pos = source.dataPosition();
    Vector3D h = source.gridSpacing();
    Vector3D c = timeIntervalInSeconds * diffusionCoefficient / (h * h);

    buildMarkers(source.dataSize(), pos, boundarySdf, fluidSdf);
    buildMatrix(source.dataSize(), c);
    buildVectors(source.constDataAccessor(), c);

    if (_systemSolver != nullptr) {
        // Solve the system
        _systemSolver->solve(&_system);

        // Assign the solution
        source.parallelForEachDataPointIndex(
            [&](size_t i, size_t j, size_t k) {
                (*dest)(i, j, k) = _system.x(i, j, k);
            });
    }
}

构建矩阵和列向量的思路在书上确实讲了

书上先讲了一个一维的例子,然后再说,推广到多维也很简单,就是把多维线性化。对于 i j k 的循环,最内层会算出线性化的序号,对于每个点,点的中心是 kc+1,点的邻居是 -c

其实一开始我看这个的话,只是对着书上那个二维例子对照着看,但是没有真正理解三维情况下是什么状况

书上的例子:

[ c + 1 − c 0 . . . 0 0 − c 2 c + 1 − c . . . 0 0 ⋮ ⋮ ⋱ ⋮ ⋮ 0 0 . . . − c c + 1 ] ⋅ f n + 1 = f n \begin{bmatrix} c+1 & -c & 0 & ... & 0 & 0 \\ -c & 2c+1 & -c & ... & 0 & 0 \\ \vdots & \vdots & \ddots & \vdots & \vdots & \\ 0 & 0 & ... & -c & c+1 \end{bmatrix} \cdot f^{n+1} = f^n c+1c0c2c+100c.........c00c+100 fn+1=fn

这里可能有很多坑……或者只是我个人的问题

系数矩阵的意义

第一个是没有注意到线性化的话,或者是没有注意看书的话,那么就会想着,这个矩阵是在表示二维情况,会以为这个矩阵和网格坐标有这样的对应关系

[ ( 0 , 0 ) ( 0 , 1 ) ( 0 , 2 ) . . . ( 0 , n − 1 ) ( 1 , 0 ) ( 1 , 1 ) ( 1 , 2 ) . . . ( 1 , n − 1 ) ⋮ ⋮ ⋱ ⋮ ⋮ ( n − 1 , 0 ) ( n − 1 , 1 ) ( n − 1 , 2 ) . . . ( n − 1 , n − 1 ) ] ⋅ f n + 1 = f n \begin{bmatrix} (0,0) & (0,1) & (0,2) & ... & (0,n-1) \\ (1,0) & (1,1) & (1,2) & ... & (1,n-1) \\ \vdots & \vdots & \ddots & \vdots & \vdots & \\ (n-1,0) & (n-1,1) & (n-1,2) & ... & (n-1,n-1) \end{bmatrix} \cdot f^{n+1} = f^n (0,0)(1,0)(n1,0)(0,1)(1,1)(n1,1)(0,2)(1,2)(n1,2).........(0,n1)(1,n1)(n1,n1) fn+1=fn

然后自己递推到三维的话,就会以为要用到三维矩阵,比如把一些二维矩阵堆叠起来的这种形式……

比如系数矩阵的第一层是

[ ( 0 , 0 , 0 ) ( 0 , 0 , 1 ) ( 0 , 0 , 2 ) . . . ( 0 , 0 , n − 1 ) ( 0 , 1 , 0 ) ( 0 , 1 , 1 ) ( 0 , 1 , 2 ) . . . ( 0 , 1 , n − 1 ) ⋮ ⋮ ⋱ ⋮ ⋮ ( 0 , n − 1 , 0 ) ( 0 , n − 1 , 1 ) ( 0 , n − 1 , 2 ) . . . ( 0 , n − 1 , n − 1 ) ] \begin{bmatrix} (0,0,0) & (0,0,1) & (0,0,2) & ... & (0,0,n-1) \\ (0,1,0) & (0,1,1) & (0,1,2) & ... & (0,1,n-1) \\ \vdots & \vdots & \ddots & \vdots & \vdots & \\ (0,n-1,0) & (0,n-1,1) & (0,n-1,2) & ... & (0,n-1,n-1) \end{bmatrix} (0,0,0)(0,1,0)(0,n1,0)(0,0,1)(0,1,1)(0,n1,1)(0,0,2)(0,1,2)(0,n1,2).........(0,0,n1)(0,1,n1)(0,n1,n1)

第二层是

[ ( 1 , 0 , 0 ) ( 1 , 0 , 1 ) ( 1 , 0 , 2 ) . . . ( 1 , 0 , n − 1 ) ( 1 , 1 , 0 ) ( 1 , 1 , 1 ) ( 1 , 1 , 2 ) . . . ( 1 , 1 , n − 1 ) ⋮ ⋮ ⋱ ⋮ ⋮ ( 1 , n − 1 , 0 ) ( 1 , n − 1 , 1 ) ( 1 , n − 1 , 2 ) . . . ( 1 , n − 1 , n − 1 ) ] \begin{bmatrix} (1,0,0) & (1,0,1) & (1,0,2) & ... & (1,0,n-1) \\ (1,1,0) & (1,1,1) & (1,1,2) & ... & (1,1,n-1) \\ \vdots & \vdots & \ddots & \vdots & \vdots & \\ (1,n-1,0) & (1,n-1,1) & (1,n-1,2) & ... & (1,n-1,n-1) \end{bmatrix} (1,0,0)(1,1,0)(1,n1,0)(1,0,1)(1,1,1)(1,n1,1)(1,0,2)(1,1,2)(1,n1,2).........(1,0,n1)(1,1,n1)(1,n1,n1)

如此堆叠起来

实际上不是的……如果认真看书的话,这个矩阵实际上是描述一维情况下的矩阵

实际上的位置对应关系是这样的,对角线对应一个一维的坐标,其他位置的元素,只是反应一维坐标两两之间的相关性,我这里用 cov 代指

[ x = 0 c o v c o v . . . c o v c o v x = 1 c o v . . . c o v ⋮ ⋮ ⋱ ⋮ ⋮ c o v c o v c o v . . . x = n − 1 ] ⋅ f n + 1 = f n \begin{bmatrix} x=0 & cov & cov & ... & cov \\ cov & x=1 & cov & ... & cov \\ \vdots & \vdots & \ddots & \vdots & \vdots & \\ cov & cov & cov & ... & x=n-1 \end{bmatrix} \cdot f^{n+1} = f^n x=0covcovcovx=1covcovcovcov.........covcovx=n1 fn+1=fn

实际上,自始自终都是,只用这个矩阵就可以代表任意维度的空间的,只要线性化就好了

比如对于 2*2 的空间

[ ( 0 , 0 ) c o v c o v c o v c o v ( 0 , 1 ) c o v c o v c o v c o v ( 1 , 0 ) c o v c o v c o v c o v ( 1 , 1 ) ] ⋅ f n + 1 = f n \begin{bmatrix} (0,0) & cov & cov & cov \\ cov & (0,1) & cov & cov \\ cov & cov & (1,0) & cov \\ cov & cov & cov & (1,1) \end{bmatrix} \cdot f^{n+1} = f^n (0,0)covcovcovcov(0,1)covcovcovcov(1,0)covcovcovcov(1,1) fn+1=fn

对于 nnn 也是同理

邻居数量 k

第二个问题是这个 k 的问题

一开始我还以为是,在这个矩阵里面,某个对角线元素周围有多少个值不为 0 的非对角元素

当然这个想法稍微判断一下就知道是错的

之后才想懂,应该指的是,实际网格中,某个格子上下左右前后有多少个流体邻居

比如 3d 网格中,上下左右前后有 6 个邻居,那么就去查看这 6 个邻居在不在范围内,在的话就邻居数 + 1,也就是 k++,也就是在代码上直接加上一个 c 就可以了

那么对于非对角线元素的赋值也是一样的

// Initialize
row.center = 1.0;
row.right = row.up = row.front = 0.0;

if (i + 1 < size.x) {
    row.center += c.x;
	row.right -=  c.x;
}

if (i > 0) {
    row.center += c.x;
}

if (j + 1 < size.y) {
    row.center += c.y;
    row.up -=  c.y;
}

if (j > 0) {
    row.center += c.y;
}

...

这里是只关注上对角矩阵,因为系数矩阵是对称的。所以这里判断 i + 1 < size.x 的时候有给 right 更新,但是判断 i > 0 的时候没有给 left 更新,因为没有保存 left 等下对角的值

对于边界的情况,如果我们知道是 Dirichlet 边界,或者说是第一类边界条件,那么这个边界条件意味着我们知道边界上的函数值

如果是 Neumann 边界,第二类边界条件,也即是知道边界上的导数值

书上说的是,如果是 Neumann 边界,就把边界内的值外推到边界外,比如 f2 在边界内,f3 在边界外,f3 是 x 的最后一个元素,那么就直接令 f3 = f2

[ 2 − 1 0 − 1 3 − 1 0 − 1 2 ] ⋅ [ f 1 n + 1 f 2 n + 1 f 3 n + 1 ] = [ f 1 n f 2 n f 3 n ] \begin{bmatrix} 2 & -1 & 0 \\ -1 & 3 & -1 \\ 0 & -1 & 2 \end{bmatrix} \cdot \begin{bmatrix} f_1^{n+1} \\ f_2^{n+1} \\ f_3^{n+1} \\ \end{bmatrix} =\begin{bmatrix} f_1^{n} \\ f_2^{n} \\ f_3^{n} \\ \end{bmatrix} 210131012 f1n+1f2n+1f3n+1 = f1nf2nf3n

那么就会可以消去矩阵 A 的最后一行和列向量 b 的最后一个元素了

[ 2 − 1 − 1 2 ] ⋅ [ f 1 n + 1 f 2 n + 1 ] = [ f 1 n f 2 n ] \begin{bmatrix} 2 & -1 \\ -1 & 2 \end{bmatrix} \cdot \begin{bmatrix} f_1^{n+1} \\ f_2^{n+1} \end{bmatrix} =\begin{bmatrix} f_1^{n} \\ f_2^{n} \end{bmatrix} [2112][f1n+1f2n+1]=[f1nf2n]

这里是因为 f3 = f2 所以 3 变成 2 了,因为减去了一个 f2

如果是 Dirichlet 边界,我们假设边界上的值都是已经满足了这个边界条件的,从代码上来说,就是我们认为我们已经在求解之前调用了 applyBoundaryCondition

那么实际上就有 f 3 n = f 3 n + 1 f_3^{n} = f_3^{n+1} f3n=f3n+1

那么我们仍然是遵循之前的思路,把这一行给消去,但是我们现在没有 f2 = f3 这种事

[ 2 − 1 − 1 3 ] ⋅ [ f 1 n + 1 f 2 n + 1 + f 3 n ] = [ f 1 n f 2 n ] \begin{bmatrix} 2 & -1 \\ -1 & 3 \end{bmatrix} \cdot \begin{bmatrix} f_1^{n+1} \\ f_2^{n+1} + f_3^{n} \end{bmatrix} =\begin{bmatrix} f_1^{n} \\ f_2^{n} \end{bmatrix} [2113][f1n+1f2n+1+f3n]=[f1nf2n]

那么现在矩阵中 2 对应位置的对角线元素还是 3

现在转换到代码的话,要知道什么时候在 center 加 c,那么根据上面的边界条件的分析,分析中 2 号对应位置的对角线元素是 3 的话,说明这种边界下也要 +c

实际写代码的时候还要避免那个地方是空气,所以条件判断是 isDirichlet && _markers(i + 1, j, k) != kAir

if (i + 1 < size.x) {
    if ((isDirichlet && _markers(i + 1, j, k) != kAir)
         || _markers(i + 1, j, k) == kFluid) {
        row.center += c.x;
    }

    if (_markers(i + 1, j, k) == kFluid) {
        row.right -=  c.x;
    }
}

if (i > 0
    && ((isDirichlet && _markers(i - 1, j, k) != kAir)
        || _markers(i - 1, j, k) == kFluid)) {
    row.center += c.x;
}
边界条件

还有他这个边界条件是,一旦确定就应用在所有边界上的。就是说,所有边界都是,要不是 Dirichlet,要不是 Neumann

但是我感觉他这里是不是写错了,或者说我还是没有理解他是怎么消去矩阵的最后一行的

比如这个例子中,我觉得最下面两行是

− f 1 n + 1 + 3 f 2 n + 1 − f 3 n + 1 = f 2 n -f_1^{n+1}+3f_2^{n+1}-f_3^{n+1} = f_2^{n} f1n+1+3f2n+1f3n+1=f2n

− f 2 n + 1 + 2 f 3 n + 1 = f 3 n -f_2^{n+1}+2f_3^{n+1} = f_3^{n} f2n+1+2f3n+1=f3n

那么上下两式相加得到

− f 1 n + 1 + 2 f 2 n + 1 + f 3 n + 1 = f 2 n + f 3 n -f_1^{n+1}+2f_2^{n+1}+f_3^{n+1} = f_2^{n} + f_3^{n} f1n+1+2f2n+1+f3n+1=f2n+f3n

我觉得应该一直要这么算

那么对于 Neumann 的话,外推之后得到 f 3 n + 1 = f 2 n + 1 f_3^{n+1}=f_2^{n+1} f3n+1=f2n+1,那么应该是有

− f 1 n + 1 + 3 f 2 n + 1 = f 2 n + f 3 n -f_1^{n+1}+3f_2^{n+1} = f_2^{n} + f_3^{n} f1n+1+3f2n+1=f2n+f3n

实际上还不是这样的

因为他这里还不是一直都是有这两个方程

我再看了一下,他说的 Neumann 边界条件消去矩阵最后一行,不是因为两式联立之后,得到的式子做变化之后,相当于消去矩阵最后一行

当然这是操作上这么说……实际上就是多了边界条件之后,这两个方程就线性相关了,所以可以消去一个

他不是用线性相关的原理消去最后一行的,而是因为现在 f 3 f_3 f3 不是变量了,所以这个方程直接不需要了,不用解变量了嘛

那再看 Dirichlet 的话, f 3 n = f 3 n + 1 f_3^{n} = f_3^{n+1} f3n=f3n+1,所以我觉得式子变成

− f 1 n + 1 + 2 f 2 n + 1 = f 2 n -f_1^{n+1}+2f_2^{n+1} = f_2^{n} f1n+1+2f2n+1=f2n

才对,也就是我觉得矩阵应该变为

[ 2 − 1 − 1 2 ] ⋅ [ f 1 n + 1 f 2 n + 1 ] = [ f 1 n f 2 n ] \begin{bmatrix} 2 & -1 \\ -1 & 2 \end{bmatrix} \cdot \begin{bmatrix} f_1^{n+1} \\ f_2^{n+1} \end{bmatrix} =\begin{bmatrix} f_1^{n} \\ f_2^{n} \end{bmatrix} [2112][f1n+1f2n+1]=[f1nf2n]

但是实际上,代码里面,在判断到 Dirichlet 条件之后,会给 center + c,也会给对应位置的 b + c * f3

if (_boundaryType == Dirichlet && _markers(i, j, k) == kFluid) {
    if (i + 1 < size.x && _markers(i + 1, j, k) == kBoundary) {
        _system.b(i, j, k) += c.x * f(i + 1, j, k);
    }

    if (i > 0 && _markers(i - 1, j, k) == kBoundary) {
        _system.b(i, j, k) += c.x * f(i - 1, j, k);
    }

	...

这里的 f3 代指边界外的 f

所以我就在想,是不是书上所谓的“把 f3 从左边移动到右边”指的是,同样是把 f3 不认为是变量,直接删去最后一行,然后现在剩下

[ 2 − 1 0 − 1 3 − 1 ] ⋅ [ f 1 n + 1 f 2 n + 1 ] = [ f 1 n f 2 n ] \begin{bmatrix} 2 & -1 & 0 \\ -1 & 3 & -1 \end{bmatrix} \cdot \begin{bmatrix} f_1^{n+1} \\ f_2^{n+1} \end{bmatrix} =\begin{bmatrix} f_1^{n} \\ f_2^{n} \end{bmatrix} [211301][f1n+1f2n+1]=[f1nf2n]

那么第二行写成方程就是

− f 1 n + 1 + 3 f 2 n + 1 − f 3 n + 1 = f 2 n -f_1^{n+1}+3f_2^{n+1}-f_3^{n+1} = f_2^{n} f1n+1+3f2n+1f3n+1=f2n

f 3 n + 1 f_3^{n+1} f3n+1 从左边移动到右边就是

− f 1 n + 1 + 3 f 2 n + 1 = f 2 n + f 3 n + 1 -f_1^{n+1}+3f_2^{n+1} = f_2^{n}+f_3^{n+1} f1n+1+3f2n+1=f2n+f3n+1

然后根据边界条件,代入 f 3 n = f 3 n + 1 f_3^{n} = f_3^{n+1} f3n=f3n+1,得

− f 1 n + 1 + 3 f 2 n + 1 = f 2 n + f 3 n -f_1^{n+1}+3f_2^{n+1} = f_2^{n}+f_3^{n} f1n+1+3f2n+1=f2n+f3n

写成矩阵就是

[ 2 − 1 − 1 3 ] ⋅ [ f 1 n + 1 f 2 n + 1 ] = [ f 1 n f 2 n + f 3 n ] \begin{bmatrix} 2 & -1 \\ -1 & 3 \end{bmatrix} \cdot \begin{bmatrix} f_1^{n+1} \\ f_2^{n+1} \end{bmatrix} =\begin{bmatrix} f_1^{n} \\ f_2^{n} + f_3^{n} \end{bmatrix} [2113][f1n+1f2n+1]=[f1nf2n+f3n]

书上的 3.43 就是错的

所以我之前想的那种,用线性相关来解释的想法,一开始就不对……不管是什么类型的边界条件,都是边界值不是变量,所以直接删去那一行

之后我才看到作者的纠错列表

https://fluidenginedevelopment.org/errata/

这里确实是我想的那样是错的

系数矩阵的非对角线元素

假设以 x 方向为例

if (_markers(i, j, k) == kFluid) {
    if (i + 1 < size.x) {
        if ((isDirichlet && _markers(i + 1, j, k) != kAir)
             || _markers(i + 1, j, k) == kFluid) {
            row.center += c.x;
        }

        if (_markers(i + 1, j, k) == kFluid) {
            row.right -=  c.x;
        }
    }

    if (i > 0
        && ((isDirichlet && _markers(i - 1, j, k) != kAir)
            || _markers(i - 1, j, k) == kFluid)) {
        row.center += c.x;
    }

我一开始看的时候,我有一种感觉是,代码的意思是两个三维点在系数矩阵中有三个相关的非对角线位置,但是我看这个二维矩阵,我觉得两个线性化之后的点只会有一个相关的非对角线的位置(都是只考虑上对角)

[ ( 0 , 0 , 0 ) ( 0 , 0 , 1 ) ( 0 , 0 , 2 ) ( 0 , 1 , 0 ) ⋱ ] ⋅ f n + 1 = f n \begin{bmatrix} (0,0,0) \\ & (0,0,1) \\ & & (0,0,2) \\ & & & (0,1,0) \\ & & & & \ddots \end{bmatrix} \cdot f^{n+1} = f^n (0,0,0)(0,0,1)(0,0,2)(0,1,0) fn+1=fn

那他现在是不是 up right front 就是三个了,就多了

之后我才发现,原来是 i j k 和三个不同方向的邻居啊,和 (i+1, j, k) (i, j+1, k) (i, j, k+1)

就是三组点,所以有三个位置

然后因为对于每一个点,都是找序号增加的方向,所以其实不会记录重复的互相关

因为一般来说,比如 0 1 2 3 4,0 与 1 之间有两个位置,分别在上三角和下三角,但是我现在对每个点都是找序号增加的方向,也就是从 0 开始找 1,从 1 开始找 2,不会往回找,所以我找到的位置都是在上三角,但是实际上每一个

迭代算法

理解了这些之后,看矩阵求解就更容易了

雅可比

于是我从雅可比开始看起,主要是一开始不懂怎么乘系数

void FdmJacobiSolver3::relax(const FdmMatrix3& A, const FdmVector3& b,
                             FdmVector3* x_, FdmVector3* xTemp_) {
    Size3 size = A.size();
    FdmVector3& x = *x_;
    FdmVector3& xTemp = *xTemp_;

    A.parallelForEachIndex([&](size_t i, size_t j, size_t k) {
        double r =
            ((i > 0) ? A(i - 1, j, k).right * x(i - 1, j, k) : 0.0) +
            ((i + 1 < size.x) ? A(i, j, k).right * x(i + 1, j, k) : 0.0) +
            ((j > 0) ? A(i, j - 1, k).up * x(i, j - 1, k) : 0.0) +
            ((j + 1 < size.y) ? A(i, j, k).up * x(i, j + 1, k) : 0.0) +
            ((k > 0) ? A(i, j, k - 1).front * x(i, j, k - 1) : 0.0) +
            ((k + 1 < size.z) ? A(i, j, k).front * x(i, j, k + 1) : 0.0);

        xTemp(i, j, k) = (b(i, j, k) - r) / A(i, j, k).center;
    });
}

因为这里写的是 A(i - 1, j, k) 的 right 和 x(i - 1, j, k) 相乘,看上去好像是 i-1 和 i-1 匹配,但是看别的又不是,所以直觉上不知道怎么理解

之后我觉得还是看那个系数矩阵更容易理解

[ ( 0 , 0 , 0 ) ⋱ ( 0 , 1 , 1 ) ( 0 , 1 , 1 ) . r i g h t ⋱ ( 1 , 0 , 1 ) ( 1 , 0 , 1 ) . u p ⋱ ( 1 , 1 , 0 ) ( 1 , 1 , 0 ) . f r o n t ( 1 , 1 , 1 ) ( 1 , 1 , 1 ) . f r o n t ( 1 , 1 , 1 ) . u p ( 1 , 1 , 1 ) . r i g h t ( 1 , 1 , 2 ) ⋱ ( 1 , 2 , 1 ) ⋱ ( 2 , 1 , 1 ) ⋱ ] \begin{bmatrix} (0,0,0) \\ & \ddots \\ & & (0,1,1) & & & & & (0,1,1).right \\ & & & \ddots \\ & & & & (1,0,1) & & & (1,0,1).up \\ & & & & & \ddots \\ & & & & & & (1,1,0) & (1,1,0).front \\ & & & & & & & (1,1,1) & (1,1,1).front & & (1,1,1).up & & (1,1,1).right \\ & & & & & & & & (1,1,2) \\ & & & & & & & & & \ddots \\ & & & & & & & & & & (1,2,1) \\ & & & & & & & & & & & \ddots \\ & & & & & & & & & & & & (2,1,1) \\ & & & & & & & & & & & & & \ddots \end{bmatrix} (0,0,0)(0,1,1)(1,0,1)(1,1,0)(0,1,1).right(1,0,1).up(1,1,0).front(1,1,1)(1,1,1).front(1,1,2)(1,1,1).up(1,2,1)(1,1,1).right(2,1,1)

这样写出来就知道了……比如对于 (1,1,1),他所在的这行,在下三角的部分有 (0,1,1).right, (1,0,1).up, (1,1,0).front,在上三角部分有 (1,1,1).front, (1,1,1).up, (1,1,1).right 对应乘哪个 x 也很清楚了

高斯赛德尔

高斯赛德尔的话,一开始这个 parallelRangeFor 还跟之前的 foreach 不一样,这个是会自己确定线程个数,然后拆分你输入的范围,拆分成各个子区域分别执行,所以回调函数里面要接受 i j k 的起始和终止范围

void FdmGaussSeidelSolver3::relax(const FdmMatrix3& A, const FdmVector3& b,
                                  double sorFactor, FdmVector3* x_) {
    Size3 size = A.size();
    FdmVector3& x = *x_;

    A.forEachIndex([&](size_t i, size_t j, size_t k) {
        double r =
            ((i > 0) ? A(i - 1, j, k).right * x(i - 1, j, k) : 0.0) +
            ((i + 1 < size.x) ? A(i, j, k).right * x(i + 1, j, k) : 0.0) +
            ((j > 0) ? A(i, j - 1, k).up * x(i, j - 1, k) : 0.0) +
            ((j + 1 < size.y) ? A(i, j, k).up * x(i, j + 1, k) : 0.0) +
            ((k > 0) ? A(i, j, k - 1).front * x(i, j, k - 1) : 0.0) +
            ((k + 1 < size.z) ? A(i, j, k).front * x(i, j, k + 1) : 0.0);

        x(i, j, k) = (1.0 - sorFactor) * x(i, j, k) +
                     sorFactor * (b(i, j, k) - r) / A(i, j, k).center;
    });
}

这个形式虽然看上去跟雅可比没有区别,但是雅可比那里是把结果都存到 xTemp,而这里是计算完就写回 x

所以就符合了公式中的 L x n + 1 Lx^{n+1} Lxn+1 也就是高斯赛德尔了

这里还加了一个超松弛的设置,是不是因为不想再专门写一个 SOR 的类啊hhh

毕竟超松弛和高斯赛德尔之间的区别就只在这个系数的话,确实这样也方便

至于红黑高斯赛德尔迭代……之后再看吧

其他的迭代算法也是之后再看吧

GridPressureSolver

solve 函数表明了约定

边界 SDF 的负值表示固体

流体 SDF 的正值表示流体,负值表示空气

这个约定感觉没有什么规律啊

GridSinglePhasePressureSolver

构建矩阵这里还加多了一些判断,用到了 MultiGrid 之类的

跳转到 buildSingleSystem 才跟书上的一样了

这下构造矩阵的思路跟之前那个扩散方程的系数矩阵的构建思路就一样了

都是如果有上下左右的邻居的话,那么就给自己加 1

同时对每一个元素都只记录 right up front 有的话就记为 -1

压缩矩阵或者子网格,他们两个的矩阵构建和求解方法都跟普通的不一样

普通的矩阵构建就是我上面说的,然后求解方法就是常见的那些迭代法,雅可比啊高斯赛德尔啊,共轭梯度啊

然后他在最后把压力应用回速度这里,用的是加上压力梯度

void GridSinglePhasePressureSolver3::applyPressureGradient(
    const FaceCenteredGrid3& input, FaceCenteredGrid3* output) {
    Size3 size = input.resolution();
    auto u = input.uConstAccessor();
    auto v = input.vConstAccessor();
    auto w = input.wConstAccessor();
    auto u0 = output->uAccessor();
    auto v0 = output->vAccessor();
    auto w0 = output->wAccessor();

    const auto& x = pressure();

    Vector3D invH = 1.0 / input.gridSpacing();

    x.parallelForEachIndex([&](size_t i, size_t j, size_t k) {
        if (_markers[0](i, j, k) == kFluid) {
            if (i + 1 < size.x && _markers[0](i + 1, j, k) != kBoundary) {
                u0(i + 1, j, k) =
                    u(i + 1, j, k) + invH.x * (x(i + 1, j, k) - x(i, j, k));
            }
            if (j + 1 < size.y && _markers[0](i, j + 1, k) != kBoundary) {
                v0(i, j + 1, k) =
                    v(i, j + 1, k) + invH.y * (x(i, j + 1, k) - x(i, j, k));
            }
            if (k + 1 < size.z && _markers[0](i, j, k + 1) != kBoundary) {
                w0(i, j, k + 1) =
                    w(i, j, k + 1) + invH.z * (x(i, j, k + 1) - x(i, j, k));
            }
        }
    });
}

他是在实现 u n + 1 = u n − Δ t ∇ p ρ \mathbf{u}^{n+1} = \mathbf{u}^{n} - \Delta t\dfrac{\nabla p}{\rho} un+1=unΔtρp

我在想压力梯度前面不是符号吗?

我觉得是因为他之前构造矩阵的时候是给所有压强前面加了一个负号的,来使得对角线元素为正。而求解的过程不变,这样的话,求解出来的压强相当于带一个额外的负号,最终应用到速度的公式中往压强前面加一个负号就可以补回来了

然后之所以对于 (i,j,k) 这个点,更新的反而是 (i+1,j,k) (i,j+1,k) (i,j,k+1) 的原因,我觉得是因为计算 ∇ p \nabla p p 时候至少要用到两个点,所以最终更新速度的时候就选择了其中一个

就比如说用到了 (i,j,k) 和 (i+1,j,k) 的压强,那么你可以更新 (i,j,k) 或者 (i+1,j,k) 的压强,他选择更新 (i+1,j,k)

GridFractionalSinglePhasePressureSolver

一开始就要构建权重,书上没有详细展开,其实还是有点复杂

每个方向都有一套网格来存储权重

对每一个方向,都要遍历权重网格,对网格的每一个单元,都要计算一次对应位置的权重

计算权重的具体方法是,采样与这个速度方向垂直的四边形面上的四个顶点的 SDF 值,根据这四个值来判断这个网格被占据了多少,最终给出一个占比数字,这个数值就代表流体权重了

具体权重的计算……看不懂……

矩阵的计算……看不懂……

他提到的这个论文 C. Batty, F. Bertails, and R. Bridson. A fast variational framework for accurate solid-fluid coupling 我去看了,将流固耦合的,说是把问题转化成了最小能量的问题

说实话第一眼没看懂,上网搜的话感觉也搜不到讲解

求解的时候没有变化,仍然是普通迭代,压缩矩阵,多重网格三个选择

然后最终应用压强也看不懂

A Fast Variational Framework for Accurate Solid-Fluid Coupling

之后发现好像很多人都在用,这似乎是一个通用的方法,然后一看谷歌学术上它显示的引用数这么多,所以还是硬着头皮看下去

基于最小化动能的观点来解释速度投影

在这里插入图片描述

别人说从能量的角度,泊松方程就是一个最小化相对于压力的动能的方程

求这个最小化动能的过程,又可以理解为投影:压强求解是计算速度在无散度空间和边界条件空间的交集的投影,而投影又是一个在空间中找距离自己最近的点的过程,而如何定义这个最近的点的方法就是通过最小化动能的方法。

这个问题也是拉格朗日约束问题(无散度和边界条件是约束条件?)压强就是拉格朗日约束中的乘子。

感觉说的很有道理……

由动能最小化推出泊松方程

更新速度的公式:

u n + 1 = u ~ − Δ t ρ G p \mathbf{u}^{n+1} = \widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p} un+1=u ρΔtGp

其中 u \mathbf{u} u 是流体速度, ρ \rho ρ 是流体密度, Δ t \Delta t Δt 是单步时间, p \mathbf{p} p 是流体压强,矩阵 G \mathbf{G} G 是压强的有限差分算子

那么系统的动能可以计算为

K E F n + 1 = 1 2 u n + 1 T M F u n + 1 = 1 2 ( u ~ − Δ t ρ G p ) T M F ( u ~ − Δ t ρ G p ) \begin{align*} \mathrm{KE}_F^{n+1} & = \dfrac{1}{2}\mathbf{u}_{n+1}^T \mathbf{M}_F \mathbf{u}_{n+1} \\ & = \dfrac{1}{2}(\widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p})^T \mathbf{M}_F (\widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p}) \end{align*} KEFn+1=21un+1TMFun+1=21(u ρΔtGp)TMF(u ρΔtGp)

其中矩阵 M F \mathbf{M}_F MF 是质量对角阵

因为我们已经分析出来了我们要最小化动能,所以对这个动能求最小值,就是常用的求偏导等于 0 的过程

根据矩阵分析,矩阵运算中求导的规律有

∂ ( x T A x ) ∂ x = x T ( A + A T ) \dfrac{\partial{(\mathbf{x}^T \mathbf{A} \mathbf{x}})}{\partial{\mathbf{x}}} = \mathbf{x}^T (\mathbf{A}+\mathbf{A}^T) x(xTAx)=xT(A+AT)

∂ ( A x ) ∂ x = A \dfrac{\partial{(\mathbf{A} \mathbf{x}})}{\partial{\mathbf{x}}} = \mathbf{A} x(Ax)=A

∂ ( x T A x ) ∂ z = x T ( A + A T ) ∂ x ∂ z \dfrac{\partial{(\mathbf{x}^T \mathbf{A} \mathbf{x}})}{\partial{\mathbf{z}}} = \mathbf{x}^T (\mathbf{A}+\mathbf{A}^T)\dfrac{\partial\mathbf{x}}{\partial\mathbf{z}} z(xTAx)=xT(A+AT)zx

其中 x , z \mathbf{x},\mathbf{z} x,z 是 n*1 列向量, A \mathbf{A} A 是 n*n 矩阵

那么动能的偏导为 0 即

∂ K E F n + 1 ∂ p = ∂ K E F n + 1 ∂ ( u ~ − Δ t ρ G p ) ∂ ( u ~ − Δ t ρ G p ) ∂ p = 1 2 ( u ~ − Δ t ρ G p ) T ( M F + M F T ) ( − Δ t ρ G ) \begin{align*} \dfrac{\partial{\mathrm{KE}_F^{n+1}}}{\partial{\mathbf{p}}} & = \dfrac{\partial{\mathrm{KE}_F^{n+1}}}{\partial{(\widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p})}}\dfrac{\partial{(\widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p})}}{\partial{\mathbf{p}}} \\ & = \dfrac{1}{2}(\widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p})^T(\mathbf{M}_F + \mathbf{M}_F^T)(-\dfrac{\Delta t}{\rho}\mathbf{G}) \end{align*} pKEFn+1=(u ρΔtGp)KEFn+1p(u ρΔtGp)=21(u ρΔtGp)T(MF+MFT)(ρΔtG)

因为 M F \mathbf{M}_F MF 是对角阵,所以 M F = M F T \mathbf{M}_F = \mathbf{M}_F^T MF=MFT,所以原式变为

∂ K E F n + 1 ∂ p = ( u ~ − Δ t ρ G p ) T M F ( − Δ t ρ G ) = 0 \dfrac{\partial{\mathrm{KE}_F^{n+1}}}{\partial{\mathbf{p}}} = (\widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p})^T \mathbf{M}_F (-\dfrac{\Delta t}{\rho}\mathbf{G}) = 0 pKEFn+1=(u ρΔtGp)TMF(ρΔtG)=0

化简得

( u ~ − Δ t ρ G p ) T M F G = 0 (\widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p})^T \mathbf{M}_F \mathbf{G} = 0 (u ρΔtGp)TMFG=0

两边同时转置,得

G T M F ( u ~ − Δ t ρ G p ) = 0 \mathbf{G}^T \mathbf{M}_F (\widetilde{\mathbf{u}}-\dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p}) = 0 GTMF(u ρΔtGp)=0

拆开括号,得

G T M F u ~ − G T M F Δ t ρ G p = 0 \mathbf{G}^T \mathbf{M}_F \widetilde{\mathbf{u}}- \mathbf{G}^T \mathbf{M}_F \dfrac{\Delta t}{\rho}\mathbf{G}\mathbf{p}= 0 GTMFu GTMFρΔtGp=0

再整理,就是论文中的式 (6)

Δ t ρ 2 G T M F G p = 1 ρ G T M F u ~ \dfrac{\Delta t}{\rho^2} \mathbf{G}^T \mathbf{M}_F \mathbf{G}\mathbf{p} = \dfrac{1}{\rho}\mathbf{G}^T \mathbf{M}_F \widetilde{\mathbf{u}} ρ2ΔtGTMFGp=ρ1GTMFu

这就是一种,带上了质量的泊松方程?

但是论文怎么直接从 (6) 式得到这个 (7) 式的,感觉很跳跃

在这里插入图片描述

感觉他没有解释清楚这个差分算子 G \mathbf{G} G 和这个质量矩阵 M F \mathbf{M}_F MF 的意义

因为现在我感觉质量矩阵 M F \mathbf{M}_F MF 中的元素就是定义在面上了,但是压强还是定义在网格中心的。然后 G \mathbf{G} G 又能直接适用于这两个定义在不同位置的物理量?

如果你说 G \mathbf{G} G 就是跟我之前那种简单的泊松方程里面的系数矩阵一样的矩阵的话,那倒是可以稍微理解一点

比如 G p = [ 2 − 1 − 1 2 ] [ p 0 p 1 ] \mathbf{G}\mathbf{p} = \begin{bmatrix} 2 & -1 \\ -1 & 2 \end{bmatrix}\begin{bmatrix} p_0 \\ p_1 \end{bmatrix} Gp=[2112][p0p1] G M F = [ 2 − 1 − 1 2 ] [ m 0 m 1 ] \mathbf{G}\mathbf{M}_F = \begin{bmatrix} 2 & -1 \\ -1 & 2 \end{bmatrix}\begin{bmatrix} m_0 \\ m_1 \end{bmatrix} GMF=[2112][m0m1]

然后对于点 (i,j,k) 压强的 (i+1,j,k) 对应质量的 (i+1/2,j,k) ,速度的 (i+1/2,j,k) 对应质量的 (i+1/2,j,k)

他论文这个公式 (7) 也是把系数矩阵中的负号提到了右边,使得系数矩阵中的对角线上的元素为正

所以我们可以延续 GridSinglePhasePressureSolver 中的操作

虽然我觉得 buildSingleSystem 中的 term 是比较符合论文中的公式的

边界处压强的计算?

但是他这个在边界处的处理为什么是算一个分数 theta 然后再去除,有点不理解

if (i + 1 < size.x) {
    term = uWeights(i + 1, j, k) * invHSqr.x;
    double rightPhi = fluidSdf(i + 1, j, k);
    if (isInsideSdf(rightPhi)) {
        row.center += term;
        row.right -= term;
    } else {
        double theta = fractionInsideSdf(centerPhi, rightPhi);
        theta = std::max(theta, 0.01);
        row.center += term / theta;
    }
    (*b)(i, j, k) +=
        uWeights(i + 1, j, k) * input.u(i + 1, j, k) * invH.x;
} else {
    (*b)(i, j, k) += input.u(i + 1, j, k) * invH.x;
}

if (i > 0) {
    term = uWeights(i, j, k) * invHSqr.x;
    double leftPhi = fluidSdf(i - 1, j, k);
    if (isInsideSdf(leftPhi)) {
        row.center += term;
    } else {
        double theta = fractionInsideSdf(centerPhi, leftPhi);
        theta = std::max(theta, 0.01);
        row.center += term / theta;
    }
    (*b)(i, j, k) -= uWeights(i, j, k) * input.u(i, j, k) * invH.x;
} else {
    (*b)(i, j, k) -= input.u(i, j, k) * invH.x;
}

...

我觉得唯一相关的就是论文中提到的对于自由面使用了二阶虚压强边界条件

我记得我看 Fluid Simulation For Computer Graphics 的时候记了对于自由面的处理

假设 (i,j,k) 是流体,(i+1,j,k) 是空气,那么设空气的位置的压强为虚压强 p i + 1 , j , k G p^G_{i+1,j,k} pi+1,j,kG

那么这两个位置的压强之间应该满足自由面上的压强为 0 的条件

( 1 − θ ) p i , j , k + θ p i + 1 , j , k G = 0 (1-\theta)p_{i,j,k}+\theta p^G_{i+1,j,k} = 0 (1θ)pi,j,k+θpi+1,j,kG=0

使用 SDF 来衡量两者对于边界的距离,可以算出系数 θ \theta θ

θ = ϕ i , j , k ϕ i , j , k − ϕ i + 1 , j , k \theta = \dfrac{\phi_{i,j,k}}{\phi_{i,j,k} - \phi_{i+1,j,k}} θ=ϕi,j,kϕi+1,j,kϕi,j,k

解得 p i + 1 , j , k G p^G_{i+1,j,k} pi+1,j,kG

p i + 1 , j , k G = ( θ − 1 ) p i , j , k θ p^G_{i+1,j,k} = \dfrac{(\theta-1)p_{i,j,k}}{\theta} pi+1,j,kG=θ(θ1)pi,j,k

如果应用压强的更新式是

u i + 1 / 2 , j , k n + 1 = u i + 1 / 2 , j , k − Δ t ρ i + 1 / 2 , j , k p i + 1 , j , k − p i , j , k Δ x u_{i+1/2,j,k}^{n+1} = u_{i+1/2,j,k} - \dfrac{\Delta t}{\rho_{i+1/2,j,k}}\dfrac{p_{i+1,j,k}-p_{i,j,k}}{\Delta x} ui+1/2,j,kn+1=ui+1/2,j,kρi+1/2,j,kΔtΔxpi+1,j,kpi,j,k

代入得

u i + 1 / 2 , j , k n + 1 = u i + 1 / 2 , j , k − Δ t ρ i + 1 / 2 , j , k ( θ − 1 ) p i , j , k θ − p i , j , k Δ x = u i + 1 / 2 , j , k + Δ t ρ 1 θ p i , j , k Δ x \begin{align*} u_{i+1/2,j,k}^{n+1} & = u_{i+1/2,j,k} - \dfrac{\Delta t}{\rho_{i+1/2,j,k}}\dfrac{\dfrac{(\theta-1)p_{i,j,k}}{\theta}-p_{i,j,k}}{\Delta x} \\ & = u_{i+1/2,j,k} + \dfrac{\Delta t}{\rho}\dfrac{1}{\theta}\dfrac{p_{i,j,k}}{\Delta x} \end{align*} ui+1/2,j,kn+1=ui+1/2,j,kρi+1/2,j,kΔtΔxθ(θ1)pi,j,kpi,j,k=ui+1/2,j,k+ρΔtθ1Δxpi,j,k

但是我现在看他这个构建矩阵的时候,比如考虑右侧,给 center 加上了 m i + 1 / 2 , j , k / Δ x 2 / θ m_{i+1/2,j,k} / \Delta x^2 / \theta mi+1/2,j,kx2/θ,这个跟我所知道的虚压强的式子有一点相似,但是好像又不是

然后他应用梯度也是莫名其妙的

if (i + 1 < size.x && _uWeights[0](i + 1, j, k) > 0.0 &&
    (isInsideSdf(centerPhi) ||
     isInsideSdf(_fluidSdf[0](i + 1, j, k)))) {
    double rightPhi = _fluidSdf[0](i + 1, j, k);
    double theta = fractionInsideSdf(centerPhi, rightPhi);
    theta = std::max(theta, 0.01);

    u0(i + 1, j, k) =
        u(i + 1, j, k) + invH.x / theta * (x(i + 1, j, k) - x(i, j, k));
}

假设什么都不想的话,他似乎就是在正常的公式上,遇到边界的时候除以一个 θ \theta θ

θ \theta θ 的最小值限制为 0.01

原论文发布的程序就是这样,会除以这个 θ \theta θ 的,我觉得是不是 Fluid Engine Development 就直接把他抄过来了

算了,不管了

GridBoundaryConditionSolver

因为 GridFractionalSinglePhasePressureSolver 建议的 GridBoundaryConditionSolverGridFractionalBoundaryConditionSolver3,所以我在想会不会那个论文我有遗漏的地方,所以看了一下这个边界条件求解器的代码

之后发现 GridFractionalBoundaryConditionSolver3 跟那个论文的关系似乎不大?

GridBlockedBoundaryConditionSolver3 是简单的边界处理,首先有一个标记数组,标记哪个网格是固体哪个是液体。对于每一个固体,查找他上下左右前后是否是液体,如果是的话,就给这个液体邻居赋自己在这个轴的方向上的速度

_marker.forEachIndex([&](size_t i, size_t j, size_t k) {
    if (_marker(i, j, k) == kCollider) {
        if (i > 0 && _marker(i - 1, j, k) == kFluid) {
            Vector3D colliderVel = collider()->velocityAt(uPos(i, j, k));
            u(i, j, k) = colliderVel.x;
        }
        if (i < size.x - 1 && _marker(i + 1, j, k) == kFluid) {
            Vector3D colliderVel
                = collider()->velocityAt(uPos(i + 1, j, k));
            u(i + 1, j, k) = colliderVel.x;
        }

但是 GridFractionalBoundaryConditionSolver3 就复杂很多了

首先先根据碰撞体 SDF 值判断 (i,j,k) 在三个方向上是不是固体。具体的来说,要取网格在这个方向的两个面上的中点处的碰撞体 SDF 值。只有两个点都在碰撞体内时,才把 markder 置为 0,并且赋碰撞体的速度,其他情况都判断为流体

// Assign collider's velocity first and initialize markers
velocity->parallelForEachUIndex([&](size_t i, size_t j, size_t k) {
    Vector3D pt = uPos(i, j, k);
    double phi0 = _colliderSdf->sample(pt - Vector3D(0.5 * h.x, 0.0, 0.0));
    double phi1 = _colliderSdf->sample(pt + Vector3D(0.5 * h.x, 0.0, 0.0));
    double frac = fractionInsideSdf(phi0, phi1);
    frac = 1.0 - clamp(frac, 0.0, 1.0);

    if (frac > 0.0) {
        uMarker(i, j, k) = 1;
    } else {
        Vector3D colliderVel = collider()->velocityAt(pt);
        u(i, j, k) = colliderVel.x;
        uMarker(i, j, k) = 0;
    }
});

因为要满足自由滑行条件,所以要外插速度到固体内,这里就用到了刚刚判断过的 markder 数组

// Free-slip: Extrapolate fluid velocity into the collider
extrapolateToRegion(
    velocity->uConstAccessor(), uMarker, extrapolationDepth, u);
extrapolateToRegion(
    velocity->vConstAccessor(), vMarker, extrapolationDepth, v);
extrapolateToRegion(
    velocity->wConstAccessor(), wMarker, extrapolationDepth, w);

我觉得之所以外插也有一部分原因是为了满足半拉格朗日法的后向追踪的需求,如果他追踪到了故体内的速度,这个速度也应该是正确的外插之后的速度

而要使得速度满足只剩下切线方向的话,还需要进一步操作

接下来就是获取切线方向的速度,加上碰撞体的速度,就是边界的速度

// No-flux: project the extrapolated velocity to the collider's surface
// normal
velocity->parallelForEachUIndex([&](size_t i, size_t j, size_t k) {
    Vector3D pt = uPos(i, j, k);
    if (isInsideSdf(_colliderSdf->sample(pt))) {
        Vector3D colliderVel = collider()->velocityAt(pt);
        Vector3D vel = velocity->sample(pt);
        Vector3D g = _colliderSdf->gradient(pt);
        if (g.lengthSquared() > 0.0) {
            Vector3D n = g.normalized();
            Vector3D velr = vel - colliderVel;
            Vector3D velt = projectAndApplyFriction(
                velr, n, collider()->frictionCoefficient());

            Vector3D velp = velt + colliderVel;
            uTemp(i, j, k) = velp.x;
        } else {
            uTemp(i, j, k) = colliderVel.x;
        }
    } else {
        uTemp(i, j, k) = u(i, j, k);
    }
});

一开始让我迷惑的是,他为什么要把 project 投影 理解成求垂向分量而不是切向分量

在这个注释里面,写的是进行 project

然后函数名写的也是 projectAndApplyFriction,变量名 velt 也是按照切向来命名

这个函数内部获取垂向分量的函数名也是 projected,变量名 velt 也是按照切向来命名

template <size_t N>
inline Vector<double, N> projectAndApplyFriction(
    const Vector<double, N>& vel,
    const Vector<double, N>& normal,
    double frictionCoefficient) {
    Vector<double, N> velt = vel.projected(normal);
    if (velt.lengthSquared() > 0) {
        double veln = std::max(-vel.dot(normal), 0.0);
        velt *= std::max(1.0 - frictionCoefficient * veln / velt.length(), 0.0);
    }

    return velt;
}

等到 projected 内部写的就真的是获取垂向分量了

template <typename T>
Vector<T, 2> Vector<T, 2>::projected(const Vector<T, 2>& normal) const {
    // this - this.n n
    return sub(normal.mul(dot(normal)));
}

之后我才反应过来,这里是获取的法向量的垂向分量

所以其实就是获取切向分量

所以这个函数名还真的没有起错,投影是为了投影到切向分量上去,但是输入的只有法向量,所以获取某向量相对于一个给定法向量的垂向分量就是投影到切向

其实他一开始把梯度输入到 project 函数里面我就应该反应到不对了

现在理解了,为什么要输入梯度,因为要把梯度作为垂向,去求速度在切向上的投影

求出来了之后加上碰撞体的速度就是边界的绝对速度

如果梯度的长度为 0,说明 SDF 域在这一点是均匀的,一个均匀的碰撞体 SDF 表示了这是在碰撞体内部,所以现在速度只有碰撞体的速度

因为算切向速度的时候需要从速度场采样,所以算出来的,边界上的,被修正到只剩下切向速度的速度,在算的时候不能直接赋给数组,这样会破坏旧值的物理意义

所以用了 temp 来存,算完之后再把 temp 赋值回去

// Transfer results
u.parallelForEachIndex([&](size_t i, size_t j, size_t k) {
    u(i, j, k) = uTemp(i, j, k);
});
v.parallelForEachIndex([&](size_t i, size_t j, size_t k) {
    v(i, j, k) = vTemp(i, j, k);
});
w.parallelForEachIndex([&](size_t i, size_t j, size_t k) {
    w(i, j, k) = wTemp(i, j, k);
});

最后就是判断模拟域的边界上速度是否要钳制到 0

// No-flux: Project velocity on the domain boundary if closed
if (closedDomainBoundaryFlag() & kDirectionLeft) {
    for (size_t k = 0; k < u.size().z; ++k) {
        for (size_t j = 0; j < u.size().y; ++j) {
            u(0, j, k) = 0;
        }
    }
}

总的来说看得很爽……虽然原理简单,但是还是需要理解代码的

那么到现在的话,普通的基于网格的计算方法就算是看完了,不考虑压缩矩阵和多重网格的话

PicSolver

构造函数

如果你看 GridFluidSolverfluidSdf 函数的话,你会发现他就是直接返回一个全为负的 SDF

这就是作为父类,把细节提供给子类实现

所以很多继承 GridFluidSolver 的类的构造函数开头都会给自己的 GridSystemData 中添加一个标量场,作为流体 SDF

然后子类对 fluidSdf 的实现就会是返回这个自己的流体 SDF 场

然后因为要用粒子,所以还要创建一个 ParticleSystemData

onBeginAdvanceTimeStep

GridFluidSolver 通过 onAdvanceTimeStep 确定好了求解 NS 方程的步骤,GridFluidSolver 的子类就不用再重写 onAdvanceTimeStep

额外的自定义操作应该看钩子

首先是 onBeginAdvanceTimeStep

void PicSolver3::onBeginAdvanceTimeStep(double timeIntervalInSeconds) {
    updateParticleEmitter(timeIntervalInSeconds);
   
    transferFromParticlesToGrids();

    buildSignedDistanceField();

    extrapolateVelocityToAir();
    applyBoundaryCondition();
}
updateParticleEmitter

update particleEmitter 就是让他每帧添加粒子到 ParticleSystemData 中而已

看来他这个类的功能就是很匹配他的名字,说是 emit,就只能发射粒子,不能对粒子做其他操作

UE 的 Emitter 相当于是控制了粒子的整个生命周期了

果然还是 Emitter 只负责发射粒子比较好

transferFromParticlesToGrids

这个 transferFromParticlesToGrids 就是用线性距离来计算权重的。把粒子定位到一个格子里面,然后把粒子的物理量加权平均到这个格子的八个角点上。

for (size_t i = 0; i < numberOfParticles; ++i) {
    std::array<Point3UI, 8> indices;
    std::array<double, 8> weights;

    uSampler.getCoordinatesAndWeights(positions[i], &indices, &weights);
    for (int j = 0; j < 8; ++j) {
        u(indices[j]) += velocities[i].x * weights[j];
        uWeight(indices[j]) += weights[j];
        _uMarkers(indices[j]) = 1;
    }

具体的权重的计算可以看 getCoordinatesAndWeights

template <typename T, typename R>
void LinearArraySampler3<T, R>::getCoordinatesAndWeights(
    const Vector3<R>& x,
    std::array<Point3UI, 8>* indices,
    std::array<R, 8>* weights) const {
    ssize_t i, j, k;
    R fx, fy, fz;

    JET_ASSERT(
        _gridSpacing.x > 0.0 && _gridSpacing.y > 0.0 && _gridSpacing.z > 0.0);

    const Vector3<R> normalizedX = (x - _origin) * _invGridSpacing;

    const ssize_t iSize = static_cast<ssize_t>(_accessor.size().x);
    const ssize_t jSize = static_cast<ssize_t>(_accessor.size().y);
    const ssize_t kSize = static_cast<ssize_t>(_accessor.size().z);

    getBarycentric(normalizedX.x, 0, iSize - 1, &i, &fx);
    getBarycentric(normalizedX.y, 0, jSize - 1, &j, &fy);
    getBarycentric(normalizedX.z, 0, kSize - 1, &k, &fz);

    const ssize_t ip1 = std::min(i + 1, iSize - 1);
    const ssize_t jp1 = std::min(j + 1, jSize - 1);
    const ssize_t kp1 = std::min(k + 1, kSize - 1);

    (*indices)[0] = Point3UI(i, j, k);
    (*indices)[1] = Point3UI(ip1, j, k);
    (*indices)[2] = Point3UI(i, jp1, k);
    (*indices)[3] = Point3UI(ip1, jp1, k);
    (*indices)[4] = Point3UI(i, j, kp1);
    (*indices)[5] = Point3UI(ip1, j, kp1);
    (*indices)[6] = Point3UI(i, jp1, kp1);
    (*indices)[7] = Point3UI(ip1, jp1, kp1);

    (*weights)[0] = (1 - fx) * (1 - fy) * (1 - fz);
    (*weights)[1] = fx * (1 - fy) * (1 - fz);
    (*weights)[2] = (1 - fx) * fy * (1 - fz);
    (*weights)[3] = fx * fy * (1 - fz);
    (*weights)[4] = (1 - fx) * (1 - fy) * fz;
    (*weights)[5] = fx * (1 - fy) * fz;
    (*weights)[6] = (1 - fx) * fy * fz;
    (*weights)[7] = fx * fy * fz;
}

一开始要把输入坐标转化为网格中的正则化坐标 normalizedX,就是 [0, iSize-1] 这种

这个 getBarycentric 的作用只是把网格坐标输出为一个钳制之后的 floor 的坐标,还有网格坐标的小数部分

虽然它的命名是获取重心坐标,但是我感觉他这获取的就是一个数的整数和小数部分,跟三角形的重心坐标这种几何上的意义似乎没什么关系

template<typename T>
inline void getBarycentric(
    T x,
    ssize_t iLow,
    ssize_t iHigh,
    ssize_t* i,
    T* f) {
    T s = std::floor(x);
    *i = static_cast<ssize_t>(s);

    ssize_t offset = -iLow;
    iLow += offset;
    iHigh += offset;

    if (iLow == iHigh) {
        *i = iLow;
        *f = 0;
    } else if (*i < iLow) {
        *i = iLow;
        *f = 0;
    } else if (*i > iHigh - 1) {
        *i = iHigh - 1;
        *f = 1;
    } else {
        *f = static_cast<T>(x - s);
    }

    *i -= offset;
}

然后之后那个 weights 一看就是计算九个体积而已,因为边长是 0 到 1 的,并且是互补为 1 的,所以最终算出来的值刚好相当于分母除以 1,也就是算出来的体积刚好就可以作为权重

因为是对于每个粒子都要把自己的物理量分到角点上,所以最后肯定要对网格点上的物理量进行归一化,不然就会得到很大的值,并且也没有物理意义了

一般来说我们再把东西分给别人的时候,别人只会存自己得到的值,这里他还存了每次的权重累加起来,最后用自己的物理量除以权重就是归一化后的值了

uWeight.parallelForEachIndex([&](size_t i, size_t j, size_t k) {
    if (uWeight(i, j, k) > 0.0) {
        u(i, j, k) /= uWeight(i, j, k);
    }
});
buildSignedDistanceField

buildSignedDistanceField 也就是遍历 SDF 每一个点,查找给定搜索半径内的粒子,取到最近的那个粒子的距离,用于计算 SDF,之后的操作就是线性的了,比如 minDist - radius

void PicSolver3::buildSignedDistanceField() {
    auto sdf = signedDistanceField();
    auto sdfPos = sdf->dataPosition();
    double maxH = max3(
        sdf->gridSpacing().x, sdf->gridSpacing().y, sdf->gridSpacing().z);
    double radius = 1.2 * maxH / std::sqrt(2.0);
    double sdfBandRadius = 2.0 * radius;

    _particles->buildNeighborSearcher(2 * radius);
    auto searcher = _particles->neighborSearcher();
    sdf->parallelForEachDataPointIndex([&] (size_t i, size_t j, size_t k) {
        Vector3D pt = sdfPos(i, j, k);
        double minDist = sdfBandRadius;
        searcher->forEachNearbyPoint(
            pt, sdfBandRadius, [&] (size_t, const Vector3D& x) {
                minDist = std::min(minDist, pt.distanceTo(x));
            });
        (*sdf)(i, j, k) = minDist - radius;
    });

    extrapolateIntoCollider(sdf.get());
}

这很合理,但是有点刷新的我的印象。我之前一直以为 SDF 域一定要完美地满足函数表达式就是距离函数。

假设粒子均匀分布在一个球体里面,并且足够密集,那么我本来以为一定要求出一个等值面都是完美的球面的 SDF,并且等值面要一直满足距离函数。比如半径为 10,那么表面上的 SDF 为 0,球心附近的 SDF 值应该为 -10 左右才对。但是实际上,这里根据邻居粒子求出的 SDF 不一定是准确的,并且 SDF 的值有一定的范围,并不是无限延伸的。从代码上看,minDist 的范围是 [0, 2.0 * radius],那么 SDF 的范围就是 [-radius, radius],也就是深入到液体内部一定程度之后,SDF 会被钳制到一个负数不会再继续减小,外部也是同理

computeAdvection

它不是通过重写 onEndAdvanceTimeStep 而是通过重写 computeAdvection 来实现后处理的

或许是因为它不需要对网格上的速度平流,而是要对速度平流,所以就直接把所有都写到 computeAdvection

因为 GridFluidSolvercomputeAdvection 定义处理自己的标量场、向量场的平流

现在他直接覆盖的话,就是完全不处理这些场的平流了

这应该是一个特殊的情况吧,因为物理量是由粒子携带的,所以 PIC 还真就不需要平流自己的场

虽然 PIC 也有自己定义 SDF 场,但是这个场是在 PIC 的每次平流函数被调用时的预处理中被重建的。也就是说他是每帧被重建的,并不用平流

void PicSolver3::computeAdvection(double timeIntervalInSeconds) {
    extrapolateVelocityToAir();

    applyBoundaryCondition();

    transferFromGridsToParticles();

    moveParticles(timeIntervalInSeconds);
}

从网格到粒子的计算非常简单,直接从网格里面采样粒子位置的速度,赋给粒子

void PicSolver3::transferFromGridsToParticles() {
    auto flow = gridSystemData()->velocity();
    auto positions = _particles->positions();
    auto velocities = _particles->velocities();
    size_t numberOfParticles = _particles->numberOfParticles();

    parallelFor(kZeroSize, numberOfParticles, [&](size_t i) {
        velocities[i] = flow->sample(positions[i]);
    });
}

最终移动粒子也很简单,就是前向使用中点法

parallelFor(kZeroSize, numberOfParticles, [&](size_t i) {
    Vector3D pt0 = positions[i];
    Vector3D pt1 = pt0;
    Vector3D vel = velocities[i];

    // Adaptive time-stepping
    unsigned int numSubSteps
        = static_cast<unsigned int>(std::max(maxCfl(), 1.0));
    double dt = timeIntervalInSeconds / numSubSteps;
    for (unsigned int t = 0; t < numSubSteps; ++t) {
        Vector3D vel0 = flow->sample(pt0);

        // Mid-point rule
        Vector3D midPt = pt0 + 0.5 * dt * vel0;
        Vector3D midVel = flow->sample(midPt);
        pt1 = pt0 + dt * midVel;

        pt0 = pt1;
    }

	...

之后就是钳制到边界,还有调用 collider 去解算碰撞

FlipSolver

因为其实 PIC 和 FLIP 的区别仅仅在于 FLIP 是更新变量的 Delta,而 PIC 是直接更新整个变量

PIC 先定义好了从粒子到网格,从网格到例子,平流粒子的接口,FLIP 的求解器继承 PIC 的求解器确实可以

现在 FLIP 只需要重载 transferFromParticlesToGridstransferFromGridsToParticles 就好了

又因为 FLIP 还比 PIC 多一套网格,它需要新旧两套网格来计算 Delta

所以 FLIP 作为子类更合理,而不是 PIC 作为子类

transferFromParticlesToGrids

首先调用了 PIC 的 transferFromParticlesToGrids,然后把结果保存在 FLIP 类的三个存储 delta 的数组中

transferFromGridsToParticles

网格上的速度经过压力投影之后会存储在 GridSystemData,这就是新值

然后三个为了存储 delta 的数组,现在存储的还是速度的旧值

所以现在有两套速度了,可以算出 delta 了

// Compute delta
flow->parallelForEachUIndex([&](size_t i, size_t j, size_t k) {
    _uDelta(i, j, k) =
        static_cast<float>(flow->u(i, j, k)) - _uDelta(i, j, k);
});

然后就是直接算出更新后的速度,并且和 PIC 混合了

auto sampler = [uSampler, vSampler, wSampler](const Vector3D& x) {
    const auto xf = x.castTo<float>();
    double u = uSampler(xf);
    double v = vSampler(xf);
    double w = wSampler(xf);
    return Vector3D(u, v, w);
};

// Transfer delta to the particles
parallelFor(kZeroSize, numberOfParticles, [&](size_t i) {
    Vector3D flipVel = velocities[i] + sampler(positions[i]);
    if (_picBlendingFactor > 0.0) {
        Vector3D picVel = flow->sample(positions[i]);
        flipVel = lerp(flipVel, picVel, _picBlendingFactor);
    }
    velocities[i] = flipVel;
});

差不多这些吧,单单看书和代码,感觉好像花了一周……感觉速度太慢了