|
最近在学习Unity,发现部分PhysX功能和接口在Unity中没有封装,同时没有源码也会带来极大的局限性,如
1. 部分功能缺失,如PhysX的Tank载具、即时模式、几何查询等;
2. 针对某些具体的需求,需要专门定制更为高效的接口,比如视野射线;
3. 部分效果无法实现,如通过运行时修改HeightField实现具有真实物理表现的弹坑;
4. 不能高效集成和物理相关性比较强的第三方库,如NVIDIA的物理破碎Blast;
5. 不能在PhysX的架构上进行功能扩展,比如增加一种新的载具类型;
6. 在某些情况下,无法利用独立线程进行物理模拟。
于是萌生了在Unity中实现一个PhysX插件的想法,顺便学习如何制作Unity原生插件。这里主要记录个人在封装PhysX过程中的部分思考,一些PhysX API细节和Unity插件相关的内容就不班门弄斧了。
数据类型和接口设计
C++应该是绝大多数游戏开发都绕不开的语言,而通常为了开发效率及热更等需求,很大一部分逻辑都利用脚本来实现。所以,一个问题是脚本和C++的通信(更专业的说法叫语言绑定?)。通常,有很多工具来达到这个目的,如Python和C++相互调用有比较流行的pybind11,C#和C++的相互调用也可以使用SWIG。
PhysX官方也提供了一个Unity的PhysX原生插件,利用SWIG来设计接口。个人觉得SWIG生成的接口虽然很通用,但是不够灵活高效(也可能是我比较菜,没看懂……);其次,官方提供的这个插件过于臃肿,虽然十分灵活,但是针对具体项目或Demo这种灵活性会带来一定性能损失。因此,决定自己造个轮子(学习阶段嘛,造点轮子也是有好处的)。
经过思考,决定基于P/Invoke设计相关接口。对于需要在C#和C++之间相互传送的数据类型,都尽量设计成内存布局一致的值类型,这样在双端访问和修改都比较比较一致(不知道有没有坑)。比如,对于创建动态刚体时的初始化数据,C#端可以设计成
[System.Serializable]
[StructLayout(LayoutKind.Sequential)]
public struct RigidDynamicConfig
{
public Bool IsKinematic;
public Bool AutoMassInertia;
public Bool EnableContact;
public Bool EnableTrigger;
public Bool EnableGravity;
public Bool EnableCCD;
public float Mass;
public float LinearDamping;
public float AngularDamping;
public Vector3 CenterOfMass;
public Vector3 MassInertia;
public PhysicsLayer Layer;
[HideInInspector]
public int ShapeNum;
[HideInInspector]
public IntPtr Shapes;
}C++端设计成
struct RigidDynamicConfig
{
Bool IsKinematic = Bool::False;
Bool AutoMassInertia = Bool::False;
Bool EnableContact = Bool::False;
Bool EnableTrigger = Bool::False;
Bool EnableGravity = Bool::True;
Bool EnableCCD = Bool::False;
float Mass = 1.0f;
float LinearDamping = 0.1f;
float AngularDamping = 0.05f;
physx::PxVec3 CenterOfMass = physx::PxVec3(0);
physx::PxVec3 MassInertia = physx::PxVec3(1, 1, 1);
PhysicsLayer Layer = PhysicsLayer::Dynamic;
std::int32_t ShapeNum = 0;
Shape* Shapes = nullptr;
};LayoutKind.Sequential能够有效的保证数据在C#和C++布局的一致性(C#真为C++开发者考虑,点赞)。这里还有个重要的小细节,C#几乎所有原生数据类型的大小都是平台无关的,而C++却没有这个保证,比如int。所以在C++端一定要使用大小确定的类型,比如上面ShapeNum就使用了32位整型来匹配C#的int。
关于数据的传送,其实还有一个问题没有弄清楚(太菜)
public void SyncRigidBodyState()
{
GetRigidDynamicState(_nativePtr, ref RigidDynamicState);
}
EXPORT void STDCALL GetRigidDynamicState(const PxRigidDynamic* rigidDynamic, RigidDynamicActorState* state)
{
state->LinearVelocity = rigidDynamic->getLinearVelocity();
state->AngularVelocity = rigidDynamic->getAngularVelocity();
state->Transform = rigidDynamic->getGlobalPose();
}比如上面这段代码中RigidDynamicState是C#托管类的一个值类型成员,这里本意是将其传送给C++,并由C++更新其数据。但有没有可能在这个过程,GC让C++端拿到的指针变成了野指针呢(其实处理方式非常多,只是自己偷懒了而已)?
至于相关函数的调用,似乎只有借助指针相对来说更令人满意。比如场景模拟,将C#端保存的指针传到C++,进行操作即可
[DllImport(PhysXEnv.PhysXDLL)]
public static extern void Simulate(HandleRef scene, float dt);
EXPORT void STDCALL Simulate(PhysXScene* scene, float dt)
{
PxScene* pxScene = scene->scene;
pxScene->simulate(dt, nullptr, scene->scratchBuffer.buffer, scene->scratchBuffer.bufferSize);
}物理模拟设计
众所周知,物理模拟和渲染表现从本质上看是完全独立的。所以,二者的信息同步就是一个需要思考清楚的问题。
首先是Simulate时机,这里选择的是Unity Update。为什么不选择FixUpdate,有如下原因,
1. FixUpdate的底层原理不是很明白(比较菜),比如,Time.fixedDeltaTime和Time.deltaTime的关系究竟是什么。当fixedDeltaTime = 0.02,deltaTime = 0.03时,这一帧FixUpdate次数是1还是2呢(这个其实可以做个实验验证,但是没有看到相关文档,以及源码实现,根据表现也不好下定论,且这也不是不用FixUpdate的主要原因)。
2. 放在Update中,并且结合事件能够更好地控制相关逻辑和物理模拟的时序问题。
其次,通常来说我们希望物理以一个稳定的帧率进行模拟,根据实践经验来看,60帧是在性能和稳定性权衡比较好的一个选择。所以,可以简单地写出如下逻辑(比较懒就整理伪代码了……),
void Update()
{
float dt = Time.deltaTime;
if (UseSubStep)
{
float simulateTime = _remainTime + dt;
int needSimCount = (int)Mathf.Ceil(simulateTime / SubStepTime);
int simCount = Mathf.Clamp(needSimCount, 0, MaxIterStep);
_remainTime = needSimCount > MaxIterStep ? 0 : simulateTime - simCount * SubStepTime;
for (int i = 0; i < simCount; i++)
{
Scene?.PxSimulate(SubStepTime);
}
Scene?.PostSimulatePhysics(dt, -_remainTime);
}
else
{
Scene?.PxSimulate(dt);
Scene?.PostSimulatePhysics(dt, 0);
}
}
public void PxSimulate(float dt)
{
if (IsValid)
{
_preUpdatePhysics?.Invoke(dt);
Simulate(_nativePtr, dt);
FetchResults(_nativePtr, true);
HandleCallback();
_postUpdatePhysics?.Invoke(dt);
}
}这里的设计其实还有两个细节
1. 当deltaTime比较大时,会把物理模拟的步数限制在一个上限值,防止卡顿情况下物理模拟加剧卡顿过程(代价是物理模拟会稍滞后于真实情况);
2. 物理模拟总是超前于渲染,这样渲染位置可以根据物理位置每帧进行插值。
void PostSimulatePhysics(float dt, float remainTime)
{
RigidDynamic.SyncRigidBodyState();
Vector3 newPos = RigidDynamic.RigidDynamicState.Transform.pos;
Quaternion newRot = RigidDynamic.RigidDynamicState.Transform.rot;
float lerpRatio = dt / (dt + remainTime);
transform.position = Vector3.Lerp(transform.position, newPos, lerpRatio);
transform.rotation = Quaternion.Slerp(transform.rotation, newRot, lerpRatio);
}为什么要插值?设想一下,当渲染帧率高于物理帧的情况下,如果不插值,就会出现某些帧位置在变,某些帧位置不变,给人卡顿的错觉。
物理回调的设计
碰撞回调和触发事件是物理引擎提供的两个最基本的功能(想一下Unity里OnTriggerXXX和OnCollisionXXX)。这两个事件都是在PhysX层都是由fetchResults调用触发的。这里有两种封装方案
1. 在PhysX直接通过函数指针(指向C#的代理)回调到脚本层。这样的问题是,最好只对数据进行读操作。否则可能引发crash,比如在毁掉中删做了删除操作;
2. 将所有信息缓存起来, 然后在fetchResults调用结束后,统一处理。这样的问题是,在回调中实时去取PhysX组件的数据,拿到的是碰撞解算后的数据。比如,两个车100km/h的速度相撞,在回调中拿到的可能只有10km/h(碰撞后的速度)。不过通常脚本会做缓存,所以通过缓存也能拿到正确值。
void SimulationEventCallback::onTrigger(PxTriggerPair* pairs, PxU32 count)
{
for (PxU32 index = 0; index < count; index++)
{
const PxTriggerPair& pair = pairs[index];
const bool removeActor0 = pair.flags & PxTriggerPairFlag::eREMOVED_SHAPE_TRIGGER;
const bool removeActor1 = pair.flags & PxTriggerPairFlag::eREMOVED_SHAPE_OTHER;
const bool notifyActor0 = IsEnableWorld<World1Flag::Trigger>(pair.triggerShape->getSimulationFilterData().word1) && !removeActor0;
const bool notifyActor1 = IsEnableWorld<World1Flag::Trigger>(pair.otherShape->getSimulationFilterData().word1) && !removeActor1;
if (notifyActor0 || notifyActor1)
{
TriggerInfo info;
info.Actor0 = pair.triggerActor;
info.Actor1 = pair.otherActor;
if (!notifyActor0 && notifyActor1)
{
std::swap(info.Actor0, info.Actor1);
}
info.NotifyActor1 = ToBool(notifyActor0 && notifyActor1);
info.TriggerPairFlags = pair.status;
scene->triggerInfos.emplace_back(info);
}
}
}这里采用第2种方式,采用这种方式还有另一个原因,可避免C++和C#之间频繁进行相互调用,在一些脚本语言中这是一个忌讳点(也不知道P/Invok调用的性能消耗怎样)。
Mesh Cooking设计
几何体在PhysX中是非常重要的一个元素,刚体、Trigger的Shape创建等都基于几何体。当然,最简单的做法是运行直接把Mesh数据、地形高度图数据等直接塞给PhysX,PhysX会进行运行时Cooking(某自研引擎真是这么做的,可能在大佬看来不外乎加载时增加亿点点消耗)。所以最好把创建换Shape的数据离线Cook好,个人认为除了会占用一点额外空间外,几乎没有什么槽点(不知道Unity的Prebake原理是不是离线Cook)。
同时利用ScriptableObject只生成一个实例(听起来和单例好像)的特性,可以方便地实现对几何体相关数据进行缓存,进一步加速对拥有相同几何体Shape的创建过程。
IntPtr GetHeightField()
{
if (IntPtr.Zero == _heightField)
{
MemoryInputStream inputStream = new MemoryInputStream();
inputStream.Data = Marshal.UnsafeAddrOfPinnedArrayElement(_data, 0);
inputStream.Size = (uint)_data.Length;
_heightField = CreateHeightField(ref inputStream);
}
return _heightField;
}还有一个细节是,对于在C++申请的内存需要手动释放,
PhysXManager.DeleteMemoryOutputStream(ref outputStream);当然更优雅的方式可能是dispose + using的组合。
展望
后期根据需要会持续加入
1. 物理破碎的支持。基于Blast,运行时的处理不算复杂,Github上也有一些运行时Split的例子。但是麻烦点在于,需要在编辑器中建立一套易用的生成Blast Asset的流程。
2. 角色物理动画的支持。其实最简单的做法是直接利用刚体+约束建模,然后添加到场景进行模拟。但想做UE4那种基于即时模式的物理动画(性能会更高)。
后记
工作4年,虽然项目上线,但表现远不如预期,身边的&#34;战友&#34;也越来也少。自己这一年甚至两年几乎毫无进步。最近也想换个环境,大佬帮忙内推,结果HR反馈结果是岗位不匹配,其实大家都心知肚明(太菜)。虽然有自知之明,但连面试机会都没有,内心还是比较失落。总结下来,最大的问题还是没有及时提升自己的能力。回首起来,自己之前做的事情似乎也没有任何记录和思考,这也许是菜鸡的共性吧…… |
|