TL;DR

坑点:

  • 本身也是个预切割的方案,并没有根本上解决碎片太多导致的加载问题
  • c++库引入会有一定风险,且没有官方支持
  • 工具流缺失,没有完备的Unity集成,配套产出可破坏物描述文件工具需要自己维护

但是也并非没有可以借鉴的地方:

  • 实现了一套层次化破坏方案
  • 性能较好(不光是c++库的原因,内部光判定联通就有不少算法层面优化手段,非常值得学习)
  • 可定制的伤害策略
  • Stress Solver,更加精确的垮塌计算
  • 代码格式规整,设计感强,注释很多

Blast概要

底层:NvBlast : 核心算法库,聚焦于层次化结构维护以及破坏时的联通变化计算
上层:NvBlastTk : event system/object manage/process damage/joint
扩展: NvBlastExt : Streee Solver/……

外围工具:

  • AuthoringTool,一个命令行工具,用来输入模型,切分,产出blast文件
  • BlastTool,可视化工具(但是最新的master分支里被干掉了,原因不明)
  • SampleAssetViewer,预览做好的blast资源

该项目本身只负责计算纯几何学上的结构维护,联通判定,破坏计算。
至于游戏对象管理,物理模拟,渲染,统统交给用户。

关于官方的Unity样例

首先会根据blast格式,描述一个chunk集合,然后丢给blast初始化
接着就用blast提供的chunk数据初始化真实的游戏内对象,即GameObject,或者说碎片对象。
事实上,对于所有的碎片实际上也是预先Instantiate出来的:

1
2
3
4
5
6
7
// Actual Cubes
var cubePrefab = Resources.Load<GameObject>("CubePrefab");
_cubes = new GameObject[desc.chunkCount];
for (int i = 0; i < desc.chunkCount; ++i)
{
GameObject cube = GameObject.Instantiate<GameObject>(cubePrefab);
}

构建完毕即可利用damage系统对其施加伤害,伤害计算结束后,重新拿到blast的计算结果,调整游戏内对象状态,该Active的Active,该干掉的干掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
actor.GenerateFracture( _fractureBuffers, damP, programParams );
actor.ApplyFracture( _fractureBuffers );
if ( _fractureBuffers.bondFractureCount + _fractureBuffers.chunkFractureCount > 0 )
{
Split( actor );
}

......

private void Split( NvBlastActor actor )
{
if (split.deletedActor != IntPtr.Zero)
{
......
OnActorDestroyed(actor);
}
for (int i = 0; i < count; i++)
{
......
OnActorCreated(new NvBlastActor(_blastFamily, ptr), localPosition, localRotation);
}
}

使用上的大致流程以及一些关键API:
根据chunk描述构建asset

1
var asset = NvBlastCreateAsset( mem, desc, NvBlastWrapper.GetScratch( (int)scratchSize ), NvBlastWrapper.Log );

根据assest构建family(主要在分配内存)

1
var family = NvBlastAssetCreateFamily( mem, asset.ptr, NvBlastWrapper.Log );

创建actor(初始化,创建联通。。。)

1
var actor = NvBlastFamilyCreateFirstActor( family.ptr, desc, NvBlastWrapper.GetScratch((int)scratchSize), NvBlastWrapper.Log );

伤害

1
2
3
NvBlastActorGenerateFracture( buffers, ptr, program, programParams, NvBlastWrapper.Log, null );
NvBlastActorApplyFracture(IntPtr.Zero, ptr, commands, NvBlastWrapper.Log, null);
UInt32 newActorsCount = NvBlastActorSplit( result, ptr, newActorsMaxCount, NvBlastWrapper.GetScratch((int)scratchSize), NvBlastWrapper.Log, null);

一些议题

1. 如何描述层级结构

离线数据

虽然看不到blast二进制文件,不过于可视化编辑器中可以直接看到。

对于一个可破坏物,其表示为一个树状结构:
举例而言,对于一个原始物件,称作depth 0

切分一次后,称作depth 1

针对某个碎片再次切分,称作 depth 2

这里碎片之间联通是离线弄好的,理论上也可以增删改

内部结构

Assest中:
chunk:

  • 位置
  • 父节点
  • 是否为support
    bond:
  • 两端点
  • 方向
  • 面积
    chunk之间通过bond连接,chunk之下有子层级,整个形成一个树型结构。
    联通关系在代码内部被Support Graph所描述为一个图,官网上的图基本解释的比较清楚了。

破坏如何处理

第一步

构造NvBlastDamageProgram:

  1. NvBlastGraphShaderFunction
  2. NvBlastSubgraphShaderFunction

这里理论上可以通过描述破坏来定制不同种类的伤害算法(比如仅沿边缘切割等
也可更复杂,这里简化之,简单的构造一个圆形伤害

  1. 坐标
  2. 半径
  3. 伤害值

Q: 如何找到需要破坏的chunk?

此处不和任何物理库相关,所以这里一定是纯几何学作出的判定

第二步

NvBlastActorApplyFracture

这里又分两部分

  1. chunk fracture
    此处会掉chunk血,一旦掉完了就尝试破坏子层级。
    这里伤害数值首先减去父层级当前收到的伤害,然后溢出的伤害会平摊到子层级的每个chunk中。
    然后递归之,继续处理子层级。
    不过这里仅仅做了HP计算,并没有做其他的了,结构化的伤害计算在后面。

  2. bond fracture
    掉血,通知 familyGraph edgeRemoved
    设置node脏位
    构造Fracture Event

第三步

Split

这一步干了最多的活,即根据当前结构中HP情况计算新结构。
上一步会找到graph中脏node,针对每个脏node findIsIands,即判断是否已经不连通了。
每个island有个root,此处会尝试针对每个脏节点findRoute到rootNode。

此处做了很多优化(TODO):

  • TryFastPath
  • Hop Counts

一旦没找到path,就把当前脏节点作为新Island的根节点,建立新的Island。
外部(Unity)获取到当前结构中哪些被断开了,找到对应的GameObject,该干掉干掉,该激活激活。

Stress Solver

Rayfire对此的应对:

  1. 简单的stress判定
  2. 整体破碎完整度,据此判定垮塌

Rayfire的方案会出现,某些伸出来的”厂”形结构,末端看起来很不物理

https://docs.nvidia.com/gameworks/content/gameworkslibrary/blast/1.1/api_docs/files/pageextstress.html
模拟碎片收到的重力和离心力
Blast官网上的例子:子弹打到桌子腿上,桌子腿关节处会断裂

bond stress计算公式:

1
2
stress = (bond.linearStress * stressLinearFactor + bond.angularStress * stressAngularFactor) / hardness;

另外为了加速stress计算,此处设置了一个setting值:graphReductionLevel,计算的时候会归并一些Node,合并为一个大node。
最终解算完毕获取stress的时候,内部stress直接平摊一下,以在效率和效果上进行平衡。

graphReductionLevel is the number of node merge passes. The resulting graph will be roughly 2^graphReductionLevel times smaller than the original.

解算大致流程:
每个Family会有一个solver,solve会接受碎裂信息,同时这个solver会不断更新:

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
void ExtPxStressSolverImpl::update(bool doDamage)
{
for (auto it = m_actors.getIterator(); !it.done(); ++it)
{
if (isStatic)
{
//针对静态actor于solver中施加一个重力
m_solver->addGravityForce(...);
}
else
{
//否则获取旋转速度信息(模拟离心力)
m_solver->addAngularVelocity(...);
}
}

//解算
m_solver->update();

if (doDamage && m_solver->getOverstressedBondCount() > 0)
{
//针对所有超重的bond,生成一个破坏command,施加之
m_solver->generateFractureCommands(commands);
if (commands.bondFractureCount > 0)
{
m_family.getTkFamily().applyFracture(&commands);
}
}
}

迭代计算:
这里大概的意思是,求出bond对应连接的两个Node(碎片)的冲量
然后用其差值作为bond 的 linearStress/angularStress
(用人话说就是如果两个碎片向不同的方向运动,就会将这个关节撕裂)

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
void solve(uint32_t iterationCount, bool warmStart = true)
{
solveInit(warmStart);

for (uint32_t i = 0; i < iterationCount; ++i)
{
iterate();
}
}

void iterate()
{
using namespace physx::shdfnd::aos;
for (BondData& bond : m_bondsData)
{
NodeData* node0 = &m_nodesData[bond.node0];
NodeData* node1 = &m_nodesData[bond.node1];

const PxVec3 vA = node0->velocityLinear - node0->velocityAngular.cross(bond.offset0);
const PxVec3 vB = node1->velocityLinear + node1->velocityAngular.cross(bond.offset0);

const PxVec3 vErrorLinear = vA - vB;
const PxVec3 vErrorAngular = node0->velocityAngular - node1->velocityAngular;

const float weightedMass = 1.0f / (node0->invMass + node1->invMass);
const float weightedInertia = 1.0f / (node0->invI + node1->invI);

const PxVec3 outImpulseLinear = -vErrorLinear * weightedMass * 0.5f;
const PxVec3 outImpulseAngular = -vErrorAngular * weightedInertia * 0.5f;

bond.impulseLinear += outImpulseLinear;
bond.impulseAngular += outImpulseAngular;

const PxVec3 velocityLinearCorr0 = outImpulseLinear * node0->invMass;
const PxVec3 velocityLinearCorr1 = outImpulseLinear * node1->invMass;

const PxVec3 velocityAngularCorr0 = outImpulseAngular * node0->invI - bond.offset0.cross(velocityLinearCorr0) * bond.invOffsetSqrLength;
const PxVec3 velocityAngularCorr1 = outImpulseAngular * node1->invI + bond.offset0.cross(velocityLinearCorr1) * bond.invOffsetSqrLength;

node0->velocityLinear += velocityLinearCorr0;
node1->velocityLinear -= velocityLinearCorr1;

node0->velocityAngular += velocityAngularCorr0;
node1->velocityAngular -= velocityAngularCorr1;
}
}

其他资料