# Unreal Chaos 导出为 PhysX 物理文件

# 概述

随着 Unreal 5.0 的发布,PhysX 库也从 Unreal 的源代码中被移除。Unreal 自此不再支持 PhysX 库,转而使用自研的 Chaos。随之而来的问题是:项目如果升级到 Unreal 5.0+ 版本,就必须放弃 PhysX 库,对于客户端来说影响比较有限,毕竟 Unreal 官方提供了全套的解决方案。但对于不使用 DS 的服务器来说,如果需要用到物理场景的数据,就变得棘手了起来。

常规管线中,客户端一般会通过 PhysX 库自带的序列化接口,把场景中 AActor 绑定的物理对象加入到导出容器内,经由 PhysX 库完成导出相关的复杂工序。好在 PhysX 库的集成非常方便,很多服务器也会采用相同版本的 PhysX 作为物理库来使用,通过 PhysX 库的反序列化接口,即可轻松实现物理资源的互通

回到正题,当客户端不再使用 PhysX 的情况下,服务器如何获取物理资源成了问题。目前 Unreal 官方没有提供教程来对二者进行转换,同时也没提供集成 Chaos 库的解决方案。因此,才有了本文中提到的工具 ——Chaos 物理导出工具。

本文所讨论的转换版本:Unreal5.2、PhysX 3.4

# PhysX 序列化结构梳理

要实现从 Chaos 到 PhysX 的转换,就得了解 PhysX 序列化的结构。下面会对 PhysX 序列化操作做详细的介绍:

# PhysX 序列化流程

PhysX 基本导出逻辑如下:

PxFoundation* gFoundation = PxCreateFoundation(...);
PxPhysics* gPhysics = PxCreatePhysics(PX_PHYSICS_VERSION, *gFoundation, ...);
PxSerializationRegistry* sr = PxSerialization::createSerializationRegistry(*gPhysics);
PxCollection oCollection = PxCreateCollection();
// 【step.1】添加各种要导出的对象
oCollection.add(PxBase);
// 【step.2】补全依赖
PxSerialization::complete(oCollection, sr);
// 【step.3】导出
PxSerialization::serializeCollectionToBinary(..., oCollection, *sr);

业务层面需要收集应该导出的对象。通过 complete 函数完成对象依赖关系的补全。补全操作交由 PhysX 自动完成,下图是 PhysX 中常见的依赖链路,当导出 Actor 的时候,会连带导出 Shape 和 Material 来确保 Actor 数据的完整性。

Figure 1: Left: Complete Collection, Right: Incomplete Collection

正常情况下,导出的 Collection 必须是 Complete 的,但是也可以显示声明为 InComplete,此时就必须给出一个 ExternalCollection,来保证依赖对象可以在外部查找到,类似于 .so 形式的链接库。当一个对象被多个容器共用时,可以通过该方式减少导出次数。

# 二进制文件结构

Collection 在导出时,会按照固定的结构排列组织:

image-20231227143846566

  • Header:头信息,包含 PhysX 版本平台信息等内容,用于反序列化校验。
  • nbObjectsInCollection:Collection 内的对象数量。
  • manifest nb:实际导出对象的数量。
  • manifest data:导出对象的大小(偏移)+ 类型
  • actor total offset:对象导出后占用的总空间大小,用于反序列化对象后,通过偏移查询 extra data,对应于 export data size。
  • importReferences nb && data:外部引用,表示该 Collection 引用了外部对象(ExternalCollection),记录引用关系,用于恢复对象的时候通过外部引用进行关系补全。
  • exportReferences nb && data:内部引用,导出内部对象的 ID 和对应的下标。提高内部引用恢复过程的寻址效率。
  • internalPtr nb && data:内部地址引用关系,把对象序列化前的地址数值作为唯一 ID 建立的引用映射。
  • internalIdx nb && data:内部编号引用关系,把对象序列化前持有的唯一编号作为 ID 建立的引用映射。
  • export data && export extra data:对象的实际数据,包含两个部分。
    • export data 表示对象的基本信息,这部分数据大小是固定的。
    • extra data 追加信息。包含复杂几何的数据,这部分数据大小通常是可变的。

# Chaos 对象到 PhysX

接下来看看 Chaos 里面物理对象之间的关系图:

image-20231227153124586

通过上述内容,简单整理出两者之间的对应关系:

类型PhysXChaos
Static ActorNpRigidStaticFGeometryParticle
Dynamic ActorNpRigidDynamicFPBDRigidParticle
ShapeNpShapeFPerShapeData
TriangleMeshPxTriangleMeshGeometryLLFTriangleMeshImplicitObject
HeightFieldPxHeightFieldGeometryLLFHeightField
ConvexMeshPxConvexMeshGeometryLLFConvex
BoxPxBoxGeometryTBox
CapsulePxCapsuleGeometryFCapsule
SpherePxSphereGeometryTSphere
PlanePxPlaneGeometryFImplicitPlane3
MaterialNpMaterialFChaosPhysicsMaterial

由于 Chaos 的几何相对更加丰富,因此可以很好的兼容导出类型。但是两边的物理体系不同,PhysX 是基于牛二定律的计算,而 Chaos 是基于位置动力学的,因此某些对象上的属性映射依旧存在差异,下面会对这些属性逐个讲解:

# Material

属性NpMaterialFChaosPhysicsMaterial默认值
内存分配类型mBaseFlagsPxBaseFlagType::eOWNS_MEMORY
对象类型mConcreteTypePxConcreteType::eMATERIAL
mMaterial 属性------------------------------------------------------------------------------
动摩擦dynamicFrictionFChaosPhysicsMaterial.Friction
材质标记flags无对应(有损)0
摩擦组合模式fricRestCombineModeFChaosPhysicsMaterial.FrictionCombineMode
材质编号mMaterialIndex动态生成,需要导出逻辑内实现,保证 IDX 唯一且小于 Uint32 即可
指向自身的指针,历史遗留无意义mNxMaterialnullptr
填充对齐,无意义padding0
恢复系数restitutionFChaosPhysicsMaterial.Restitution
静摩擦staticFrictionFChaosPhysicsMaterial.StaticFriction
  • MaterialIndex,这个编号在 Chaos 中是不存在的概念,因此导出的时候需要自己伪造一个,要求是 UInt32 范围且唯一可以标识某个 Material。
  • flags 这个标记位 Chaos 中不存在对应,但是 Chaos 提供了其他方案来替代,因此这里是有损转换的。

# Geometry

# HeightField

属性PxHeightFieldGeometryLLFHeightField默认值
行缩放rowScaleFHeightField.GetScale().X
高度场数据heightFieldFHeightField
运行时高度场数据heightFieldData运行中填充,不需要导出nullptr
网格标记heightFieldFlagsChaos 中默认全是双面检测PxMeshGeometryFlag::eDOUBLE_SIDED
高度缩放heightScaleFHeightField.GetScale().Z
引用材质materialsFHeightField.GetMaterials()
几何类型mTypePxGeometryType::eHEIGHTFIELD
纯填充无意义paddingFromFlags
列缩放columnScaleFHeightField.GetScale().Y

# ConvexMesh

属性PxConvexMeshGeometryLLFConvex默认值
凸包数据convexMeshFConvex
几何类型mTypePxGeometryType::eCONVEXMESH
gpu 兼容选项gpuCompatible运行中填充,不需要导出false
凸包运行时数据hullData运行中填充,不需要导出nullptr
凸包网格标记meshFlagsChaos 中默认全是紧凑模式PxConvexMeshGeometryFlag::eTIGHT_BOUNDS
纯填充无意义paddingFromFlags
几何缩放scale.scaleFConvex.GetScale()
几何旋转scale.rotationChaos 中几何不存在单独的旋转PxIdentity

# TriangleMesh

属性PxTriangleMeshGeometryLLFTriangleMeshImplicitObject默认值
运行时材质编号引用materialIndices运行中填充,不需要导出nullptr
引用材质materialsFTriangleMeshImplicitObject.GetMaterials()
三角网格运行时数据meshData运行中填充,不需要导出nullptr
网格标记meshFlagsChaos 中默认全是双面检测PxMeshGeometryFlag::eDOUBLE_SIDED
几何类型mTypePxGeometryType::eTRIANGLEMESH
纯填充无意义paddingFromFlags
几何缩放scale.scaleFTriangleMeshImplicitObject.GetScale()
几何旋转scale.rotationChaos 中几何不存在单独的旋转PxIdentity
三角网格数据triangleMeshFTriangleMeshImplicitObject

其他简单几何就不做介绍了。值得一提的是,由于 PhysX 对于复杂几何的反序列化也涉及到运行时数据,实时数据中有很大一部分是临时计算并存储在内存的,因此实现上非常麻烦,导出的结构也很复杂,这里简单贴一个 Convex 运行时数据的序列化内容:

image-20231227172739798

这些内容恢复起来非常麻烦,Chaos 中的 ConvexMesh 很难与之对应,下面是 ConvexMesh 存储的数据:

TArray<FPlaneType> Planes;
TArray<FVec3Type> Vertices; //copy of the vertices that are just on the convex hull boundary
FAABB3Type LocalBoundingBox;
FConvexStructureData StructureData;
FRealType Volume;
FVec3Type CenterOfMass;
FVec3 UnitMassInertiaTensor;
FRotation3 RotationOfMass;

因此这部分复杂几何的 ExtraData 后面会通过烘培方案进行恢复,这里暂时不做展开,后面再介绍。

# Shape

属性NpShapeFPerShapeData默认值
基础属性------------------------------------------------------------------------------
Actor 指针mActorFGeometryParticle 地址
内存分配类型mBaseFlagsPxBaseFlagType::eOWNS_MEMORY
对象类型mConcreteTypePxConcreteType::eSHAPE
Shape 被引用数mExclusiveAndActorCountChaos 系统默认每个 Shape 都是被 Actor 独占的,引用计数为 EXCLUSIVE_MASKoShape.EXCLUSIVE_MASK
名称mName默认不导出,因此这里也不赋值nullptr
用户数据userDatanullptr
对象类型mConcreteTypeChaos 系统默认每个 Shape 都是被 Actor 独占的ScbType::Enum::eSHAPE_EXCLUSIVE
mShape 属性------------------------------------------------------------------------------
导出 Stream 临时指针mStreamPtr导出时临时赋值的变量,序列化不处理nullptr
所在场景mScene反系列化后添加进入场景时赋值,序列化不处理nullptr
mShape.mShape 属性------------------------------------------------------------------------------
场景查询时的过滤规则mQueryFilterDataFPerShapeData.GetQueryData()
物理模拟时的过滤规则mSimulationFilterDataFPerShapeData.GetSimData()
接触距离mRestOffsetChaos 中没有平替,暂时不设置初值,PX_MAX_F32 表示未初始化(有损)PX_MAX_F32
oShape.mShape.mShape.mCore 属性------------------------------------------------------------------------------
基于 Actor 的坐标位移transform.pFPerShapeData.GetLeafRelativeTransform().GetLocation()
基于 Actor 的坐标旋转transform.qFPerShapeData.GetLeafRelativeTransform().GetRotation()
接触偏移contactOffsetChaos 只能通过 Material 设置,Shape 没有(有损)-1.0f
形状标志mShapeFlagsChaos 没有对应,暂时用默认值(有损)PxShapeFlag::eSCENE_QUERY_SHAPE
材质编号materialIndexFPerShapeData.GetLeafGeometry().GetMaterials()
是否使用自身内存存储材质编号mOwnsMaterialIdxMemory超过一个材质就需要 alloc 分配额外内存,否则用自己的内存
几何数据geometryFPerShapeData.GetLeafGeometry()
  • mRestOffset && contactOffset 在 Chaos 中统一采用 Material 的 SkinWidth 表示,但是也不能完美映射,因为 Shape 可能存在多个 Material,因此这里暂时不做转换。
  • mShapeFlags 的标记也没有 Chaos 的对应,虽然可以通过 Shape 本身的类型在做细分,但感觉用 PxShapeFlag::eSCENE_QUERY_SHAPE 已经够用了,服务器一般情况下不需要做 Simulate 模拟。Trigger 的话可以视情况加上。
  • FPerShapeData.GetLeafGeometry () 内部涉及到缩放和边距,需要进一步解析处理,并转换为 PhysX 的几何数据。

# Static Actor

属性NpRigidStaticFGeometryParticle默认值
基础属性---------------------------------------------------------------------------------------------------------------
内存分配类型mBaseFlagsPxBaseFlagType::eOWNS_MEMORY
对象类型mConcreteTypePxConcreteType::eRIGID_STATIC
连接器数组mConnectorArray服务器暂时不需要这类约束关系,因此不导出nullptr
场景内的下标mIndex反序列化加入场景后重新赋值,导出为默认值0xFFFFFFFF
名称mName默认不导出,因此这里也不赋值nullptr
用户数据userDatanullptr
mRigidStatic 属性---------------------------------------------------------------------------------------------------------------
所在场景mScene反系列化后添加进入场景时赋值,序列化不处理nullptr
物理对象类型mControlStateScbType::Enum::eRIGID_STATIC
导出 Stream 临时指针mStreamPtr导出时临时赋值的变量,序列化不处理nullptr
mRigidStatic.mStatic 属性---------------------------------------------------------------------------------------------------------------
Actor 标志mActorFlags反序列化会重置,导出无意义0
Actor 类型mActorTypePxActorType::eRIGID_STATIC
聚合 IDmAggregateIDOwnerClient服务器不处理聚合物导出(有损)0
行为标记mClientBehaviorFlagsPhysX3.4 已弃用,导出无意义0
优势组mDominanceGroupChaos 无对应(有损)0
模拟对象指针mSim运行时计算,不用导出nullptr
mRigidStatic.mStatic.mCore 属性---------------------------------------------------------------------------------------------------------------
世界坐标位置body2World.pFGeometryParticle.X()
世界坐标旋转body2World.qFGeometryParticle.R()
标记mFlagsChaos 和 PhysX 用的不同系统,这块没法转换,但服务器一般也不怎么跑 Simulate,影响不大(有损)0
Body2Actor 变换矩阵是否为单位矩阵mIdtBody2ActorStatic 这个属性没意义,不会用上true
求解器迭代次数solverIterationCountsChaos 中只有全局次数,这里暂时用 PhysX 的默认次数(有损)4
mShapeManager 属性---------------------------------------------------------------------------------------------------------------
修剪结构mPruningStructure运行时计算修剪结构,不导出nullptr
场景查询数据mSceneQueryData反序列化会重置,导出无意义0xffffffff
Shape 数据mShapesFGeometryParticle.ShapesArray() 地址
  • mAggregateIDOwnerClient:一般是类似布娃娃之类的骨骼内才有,服务器导出的话基本不用。
  • mDominanceGroup:PhysX 内的独有概念,Chaos 中没有对应。
  • mFlags:PhysX 和 Chaos 用的不同物理体系,这些标记基本上对不上。
  • solverIterationCounts:PhysX 可以单独控制 Actor 而 Chaos 只有全局的,这里暂时用了 PhysX 的默认迭代次数。

# Dynamic Actor

属性NpRigidDynamicFPBDRigidParticle默认值
基础属性---------------------------------------------------------------------------------------------------------------
内存分配类型mBaseFlagsPxBaseFlagType::eOWNS_MEMORY
对象类型mConcreteTypePxConcreteType::eRIGID_DYNAMIC
连接器数组mConnectorArray服务器暂时不需要这类约束关系,因此不导出nullptr
场景内的下标mIndex反序列化加入场景后重新赋值,导出为默认值0xFFFFFFFF
名称mName默认不导出,因此这里也不赋值nullptr
用户数据userDatanullptr
mBody 属性---------------------------------------------------------------------------------------------------------------
运行时标记mBodyBufferFlags反序列化会重置,导出无意义0
运行时角速度mBufferedAngVelocityFPBDRigidParticle.W()
运行时世界坐标位置mBufferedBody2World.pFPBDRigidParticle.X()
运行时世界坐标旋转mBufferedBody2World.qFPBDRigidParticle.R()
运行时睡眠状态mBufferedIsSleeping反序列化会重置,导出无意义1
运行时线性速度mBufferedLinVelocityFPBDRigidParticle.V()
运行时唤醒计数mBufferedWakeCounter运行时数据,导出无意义0.0f
所在场景mScene反系列化后添加进入场景时赋值,序列化不处理nullptr
物理对象类型mControlStateScbType::Enum::eBODY
导出 Stream 临时指针mStreamPtr导出时临时赋值的变量,序列化不处理nullptr
mBody.mBodyCore 属性---------------------------------------------------------------------------------------------------------------
Actor 标志mActorFlags根据 Chaos 的标记做转换(有损)PxActorFlag::eVISUALIZATION
Actor 类型mActorTypePxActorType::eRIGID_DYNAMIC
聚合 IDmAggregateIDOwnerClient服务器不处理聚合物导出(有损)0
行为标记mClientBehaviorFlagsPhysX3.4 已弃用,导出无意义0
优势组mDominanceGroupChaos 无对应(有损)0
模拟对象指针mSim运行时计算,不用导出nullptr
模拟对象数据mSimStateData运行时计算,不用导出nullptr
oDynamic.mBody.mBodyCore.mCore 属性---------------------------------------------------------------------------------------------------------------
角阻尼angularDampingUPrimitiveComponent.GetAngularDamping()
角速度angularVelocityFPBDRigidParticle.W()
body2Actor 位置body2Actor.pChaos 没有对应(有损)PxVec3::ZeroVector
body2Actor 旋转body2Actor.qChaos 没有对应(有损)PxIdentity
世界坐标位置body2World.pFPBDRigidParticle.X()
世界坐标旋转body2World.qFPBDRigidParticle.R()
CCD 推进系数ccdAdvanceCoefficientChaos 没有对应(有损)0.15f
接触报告阈值contactReportThresholdChaos 没有对应(有损)PX_MAX_F32
冻结阈值freezeThresholdChaos 没有对应(有损)0
惯性张量的逆inverseInertiaFPBDRigidParticle.InvI()
质量的逆inverseMassFPBDRigidParticle.InvM()
快速移动isFastMovingChaos 没有对应(有损)false
线性阻尼linearDampingUPrimitiveComponent.GetLinearDamping()
线性速度linearVelocityFPBDRigidParticle.V()
锁定标记lockFlagsFConstraintInstance
最大角速度平方maxAngularVelocitySqChaos 没有对应(有损)49.0f
最大接触冲量maxContactImpulseChaos 没有对应(有损)PX_MAX_F32
最大线速度平方maxLinearVelocitySqChaos 没有对应(有损)PX_MAX_F32
最大穿透偏置maxPenBiasChaos 没有对应(有损)-PX_MAX_F32
标记mFlagsChaos 和 PhysX 用的不同系统,这块没法转换,但服务器一般也不怎么跑 Simulate,影响不大(有损)PxRigidBodyFlag::Enum::eKINEMATIC | PxRigidBodyFlag::Enum::eUSE_KINEMATIC_TARGET_FOR_SCENE_QUERIES
Body2Actor 变换矩阵是否为单位矩阵mIdtBody2ActorChaos 没有这个字段默认为单位矩阵,(有损)true
刚体交互次数numBodyInteractionsChaos 没有对应(有损)0
计数交互次数numCountedInteractionsChaos 没有对应(有损)0
睡眠阈值sleepThresholdChaos 没有对应(有损)0.005f
求解器迭代次数solverIterationCountsChaos 中只有全局次数,这里暂时用 PhysX 的默认次数(有损)(1 & 0xff) << 8 | (4 & 0xff)
求解器唤醒计数器solverWakeCounterChaos 没有对应(有损)0.4f
唤醒计数器wakeCounterChaos 没有对应(有损)0.4f
mShapeManager 属性---------------------------------------------------------------------------------------------------------------
修剪结构mPruningStructure运行时计算修剪结构,不导出nullptr
场景查询数据mSceneQueryData反序列化会重置,导出无意义0xffffffff
Shape 数据mShapesFGeometryParticle.ShapesArray() 地址

Dynamic Actor 相对来说,缺失的属性就比较多了,出去 Static Actor 的那些,还有很多模拟相关的属性:

  • body2Actor:Chaos 中没有从 body 到 actor 的变换。
  • ccdAdvanceCoefficient、contactReportThreshold、freezeThreshold、isFastMoving、maxAngularVelocitySq、maxContactImpulse、maxLinearVelocitySq、maxPenBias、numBodyInteractions、numCountedInteractions、sleepThreshold、solverIterationCounts、solverWakeCounter、wakeCounter:都没对应,全部设置为未初始化或者默认值,这些属性大部分应用在 simulate,目前服务器实际运行 simulate 的对象很少,所以影响不大。

# 特殊规则处理

除去上述的导出对应关系以外,还涉及到很多导出时的特殊规则和转换问题,下面一一列举:

# 几何数据烘培

由于复杂几何的数据存储在 ExportExtraData 中,大小和内容都是不固定的,且要在 Chaos 内提取对应的物理结构和数据十分困难。因为有些数据是运行时的,需要通过特定的计算得到,如果要实现 100% 的兼容,就必须参考 PhysX 的计算逻辑对 Chaos 的数据做类似的计算,你可能会问难道两个引擎运行时数据不一样吗。是的,这块数据真的相差甚远,因此采用了一个折中的方案:导出 Chaos 中几何的基础数据,再通过 PhysX 读取并计算出运行时数据。但会引入其他影响:

  • 计算运行时数据放在了反序列化过程中,会有一定的耗时(这部分耗时原本从序列化转移到加载中)。
  • 精度丢失问题,临时计算出的运行时数据会有一定程度上的精度缺失,但目前看影响不是很大。

# HeightField

根据行列导出每个坐标的高度和材质。

PxHeightFieldDesc heightFieldDesc;
heightFieldDesc.nbRows = iNumRows;
heightFieldDesc.nbColumns = iNumCols;
heightFieldDesc.format = PxHeightFieldFormat::Enum::eS16_TM; // 假设 Chaos 高度值是 32 位浮点数
heightFieldDesc.samples.data = address;
heightFieldDesc.samples.stride = sizeof(PxHeightFieldSample);
address += sizeof(PxHeightFieldSample) * iNumRows * iNumCols;

# ConvexMesh

按照顶点顺序导出所有三角形的顶点坐标即可。

PxConvexMeshDesc convexDesc;
convexDesc.points.count = iVerticesNum;
convexDesc.points.stride = sizeof(PxVec3);
convexDesc.points.data = address;
convexDesc.flags = PxConvexFlag::eCOMPUTE_CONVEX;
address += sizeof(PxVec3) * iVerticesNum;

# TriangleMesh

TriangleMesh 根据顶点数量分为两种结构,顶点数量过多的情况下,记录时会用 PxI32 否则用 PxU16,主要数据是顶点数据和顶点顺序,不同于凸包,三角网格的大部分顶点都可以复用,因此需要额外的索引来记录三角形组成。

PxTriangleMeshDesc triangleDesc;
triangleDesc.points.count = iVerticesNum;
triangleDesc.points.stride = sizeof(PxVec3);
triangleDesc.points.data = address;
address += sizeof(PxVec3) * iVerticesNum;
bool bLarge = *reinterpret_cast<bool*>(address);
address += sizeof(bool);
if (bLarge)
{
    const PxU32 iIndexNum = *reinterpret_cast<PxU32*>(address);
    address += sizeof(PxU32);
    triangleDesc.triangles.count = iIndexNum;
    triangleDesc.triangles.stride = sizeof(PxI32) * 3;
    triangleDesc.triangles.data = address;
    address += sizeof(PxI32) * iIndexNum * 3;
}
else
{
    const PxU32 iIndexNum = *reinterpret_cast<PxU32*>(address);
    address += sizeof(PxU32);
    triangleDesc.triangles.count = iIndexNum;
    triangleDesc.triangles.stride = sizeof(PxU16) * 3;
    triangleDesc.triangles.data = address;
    address += sizeof(PxU16) * iIndexNum * 3;
    triangleDesc.flags |= PxMeshFlag::e16_BIT_INDICES;
}

# HeightField 坐标转换

高度场在 PhysX 和 Chaos 的坐标排列和坐标原点不同,因此需要做额外的转换

  • 简单来说就是做一次平移,做一次旋转,再做一次反向,实现上参考了 Unreal 里的代码,会考虑 Scale 为负的情况下如何变换:
    • 平移:因为两边的 Shape 局部坐标原点不一致。
    • 旋转:因为两边的坐标轴不一样,Unreal 是在 xy-plane 绘制,PhysX 是在 xz-plane 绘制,这里顺带也需要做 Scale 的旋转。
    • 反向:因为两边的绘制顺序不一致。
// =========== shape 层面 变换(平移) ===========
FTransform LandscapeComponentTransform = pLandspaceCmp->GetComponentToWorld();
FMatrix LandscapeComponentMatrix = LandscapeComponentTransform.ToMatrixWithScale();
FVector Scale = LandscapeComponentMatrix.ExtractScaling();
FTransform LandscapeTM = FTransform::Identity;
// 镜像 —— 平移
bool bIsMirrored = LandscapeComponentMatrix.Determinant() < 0.f;
if (!bIsMirrored)
{
    LandscapeTM.SetTranslation(FVector(-pLandspaceCmp->CollisionSizeQuads * pLandspaceCmp->CollisionScale * Scale.X, 0, 0));
}
oShape.mShape.mShape.mCore.transform.q.w = LandscapeTM.GetRotation().W;
oShape.mShape.mShape.mCore.transform.q.x = LandscapeTM.GetRotation().X;
oShape.mShape.mShape.mCore.transform.q.y = LandscapeTM.GetRotation().Y;
oShape.mShape.mShape.mCore.transform.q.z = LandscapeTM.GetRotation().Z;
oShape.mShape.mShape.mCore.transform.p = LandscapeTM.GetLocation();
//scale 实际上也做了旋转
FVector FinalScale(Scale.X * pLandspaceCmp->CollisionScale, Scale.Y* pLandspaceCmp->CollisionScale, Scale.Z* LANDSCAPE_ZSCALE);
oHeightField.rowScale = FinalScale.X;
oHeightField.columnScale = FinalScale.Y;
oHeightField.heightScale = FinalScale.Z;
// =========== actor 层面 变换(旋转) ===========
// 坐标轴变换 —— 旋转
FTransform LandscapeComponentTransform = pLandspaceCmp->GetComponentToWorld();
FMatrix LandscapeComponentMatrix = LandscapeComponentTransform.ToMatrixWithScale();
// Reorder the axes
FVector TerrainX = LandscapeComponentMatrix.GetScaledAxis(EAxis::X);
FVector TerrainY = LandscapeComponentMatrix.GetScaledAxis(EAxis::Y);
FVector TerrainZ = LandscapeComponentMatrix.GetScaledAxis(EAxis::Z);
LandscapeComponentMatrix.SetAxis(0, TerrainX);
LandscapeComponentMatrix.SetAxis(2, TerrainY);
LandscapeComponentMatrix.SetAxis(1, TerrainZ);
// 计算位移矩阵
FTransform LandscapeComponentTransformEd = FTransform(LandscapeComponentMatrix);
oStatic.mRigidStatic.mStatic.mCore.body2World.q.w = LandscapeComponentTransformEd.GetRotation().W;
oStatic.mRigidStatic.mStatic.mCore.body2World.q.x = LandscapeComponentTransformEd.GetRotation().X;
oStatic.mRigidStatic.mStatic.mCore.body2World.q.y = LandscapeComponentTransformEd.GetRotation().Y;
oStatic.mRigidStatic.mStatic.mCore.body2World.q.z = LandscapeComponentTransformEd.GetRotation().Z;
oStatic.mRigidStatic.mStatic.mCore.body2World.p = LandscapeComponentTransformEd.GetLocation();
// =========== 绘制 HeightField(反向) ===========
struct FHeightFieldAccessor
{
    FHeightFieldAccessor(const ULandscapeHeightfieldCollisionComponent::FHeightfieldGeometryRef& InGeometryRef)
        : GeometryRef(InGeometryRef)
            , NumX(InGeometryRef.Heightfield.IsValid() ? InGeometryRef.Heightfield->GetNumCols() : 0)
            , NumY(InGeometryRef.Heightfield.IsValid() ? InGeometryRef.Heightfield->GetNumRows() : 0)
        {
        }
    float GetUnscaledHeight(int32 X, int32 Y) const
    {
        return GeometryRef.Heightfield->GetHeight(X, Y);
    }
    uint8 GetMaterialIndex(int32 X, int32 Y) const
    {
        return GeometryRef.Heightfield->GetMaterialIndex(X, Y);
    }
    const ULandscapeHeightfieldCollisionComponent::FHeightfieldGeometryRef& GeometryRef;
    int32 NumY = 0;
    int32 NumX = 0;
};
FHeightFieldAccessor Accessor(*pLandspaceCmp->HeightfieldRef.GetReference());
int RowMax = Accessor.NumX;
int ColMax = Accessor.NumY;
const bool bIsMirrored = pLandspaceCmp->GetComponentToWorld().GetDeterminant() < 0.f;
if (bIsMirrored)
{
    ColMax = Accessor.NumX;
    RowMax = Accessor.NumY;
}
oOutArc.Serialize(&RowMax, sizeof(PxU32));	// x
oOutArc.Serialize(&ColMax, sizeof(PxU32));	// z
for (int32 x = 0; x < ColMax; ++x)
{
    for (int32 y = 0; y < ColMax; ++y)
    {
        PxHeightFieldSample oSimple;
        if (bIsMirrored)
        {
            const float CurrHeight = Accessor.GetUnscaledHeight(x, y);
            const uint8 MaterialIdx = Accessor.GetMaterialIndex(x, y);
            oSimple.height = CurrHeight;
            oSimple.materialIndex0.mData = MaterialIdx;
            oSimple.materialIndex1.mData = MaterialIdx;
        }
        else
        {
            const float CurrHeight = Accessor.GetUnscaledHeight(ColMax - x - 1, y);
            const uint8 MaterialIdx = Accessor.GetMaterialIndex(ColMax - x - 1, y);
            oSimple.height = CurrHeight;
            oSimple.materialIndex0.mData = MaterialIdx;
            oSimple.materialIndex1.mData = MaterialIdx;
        }
        oOutArc.Serialize(&oSimple, sizeof(PxHeightFieldSample));
    }
}

# 地址对齐和虚函数表问题

PhysX 的导出比较粗暴,通过对对象整个序列化的方式直接导出。因此需要严格遵守对象声明中的地址排布,不然恢复过程中就很容易出现差错:

// 序列化
s.writeData(&obj, sizeof(T));
// 反序列化
T* obj = new (address) T();

因此,如果需要伪造正确的导出结果,类型大小和数据排列就很重要,会影响最终的反序列化:

sizeof(NpShape) = 272
sizeof(NpRigidStatic) = 176
sizeof(NpRigidDynamic) = 384
sizeof(NpMaterial) = 80

可以参考 PhysX 简单移植一下各个类的属性定义,函数的话只需要移植一个虚函数保证结构匹配即可。

# 地址对齐

这里的对齐分为两种:

  • 第一种是每类数据之间的 16 字节对齐
  • 第二种是 header 的起始地址必须是 128 字节对齐。

# 烘培数据导出

由于烘培数据不属于 PhysX 序列化的一部分,但又不希望把反序列化流程复杂化,因此这部分内容暂时插入在序列化文件的头部。由于 geometry 的数据并不依赖任何内容,因此可以单独在业务层解析。

image-20240102171933718