kyuskoj 发表于 2022-10-16 07:54

基于PhysX的Unity物理插件

最近在学习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#端可以设计成


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;
   
    public int ShapeNum;
   
    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++,进行操作即可

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;
      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年,虽然项目上线,但表现远不如预期,身边的"战友"也越来也少。自己这一年甚至两年几乎毫无进步。最近也想换个环境,大佬帮忙内推,结果HR反馈结果是岗位不匹配,其实大家都心知肚明(太菜)。虽然有自知之明,但连面试机会都没有,内心还是比较失落。总结下来,最大的问题还是没有及时提升自己的能力。回首起来,自己之前做的事情似乎也没有任何记录和思考,这也许是菜鸡的共性吧……
页: [1]
查看完整版本: 基于PhysX的Unity物理插件