资讯动态

UE4拼接系统实战:从检测到网络同步的完整实现

发布时间:2026/10/17 5:55:24 来源:尧图企业网站定制
1. 拼接玩法的核心思路与设计拆解1.1 为什么“拼接”在UE4里值得单独拿出来做“拼接”这个词在UE4的语境下可以指向很多种不同的实现方向。有人想做的是场景拼接——把多个关卡片段像积木一样拼成一张完整地图有人想做的是模型拼接——让玩家把散落的零件组合成完整物件还有人想做的是材质拼接——把不同的贴图、材质函数像拼图一样组合出复杂效果。不管哪一种核心诉求都是一样的把原本独立的、零散的资源或逻辑单元通过某种规则组合成一个更大的整体。这个需求在实际项目中非常普遍。比如做建筑类交互Demo需要让玩家把预制墙板、屋顶、门窗拼成一栋房子做载具改装系统需要让玩家把底盘、引擎、轮胎拼成一辆车做关卡编辑器工具需要让策划在编辑器里拖拽不同的场景模块快速搭建地形。这些场景的共同点是拼接单元本身是独立的拼接规则是可配置的拼接结果是可预期的。我选择在UE4里实现这套拼接系统主要基于几个考量。第一UE4的Actor体系天然适合做“可拼接单元”的抽象——每个拼接件就是一个Actor有自己的碰撞、网格、逻辑。第二UE4的蓝图系统让拼接规则的配置变得非常直观不需要写大量C就能实现复杂的拼接判定。第三UE4的物理系统和插槽系统为拼接提供了现成的技术底座比如用PhysicsConstraint做物理拼接用Socket做精确对齐。1.2 拼接系统的整体架构设计在动手之前我先把整个拼接系统拆成了四个核心模块拼接单元定义、拼接检测、拼接执行、拼接后管理。这四个模块各司其职又互相配合。拼接单元定义模块负责描述“什么东西可以被拼接”。每个可拼接的Actor需要携带一组元数据它的拼接类型比如是墙板还是屋顶、它的拼接接口比如哪个面可以拼接、接口的形状和尺寸、它的拼接约束比如只能和同类型的件拼接、拼接后是否锁定。这些信息我用一个自定义的ActorComponent来承载叫它SpliceComponent。这样做的好处是任何Actor只要挂上这个组件就自动获得了被拼接的能力不需要改Actor本身的继承结构。拼接检测模块负责判断“两个单元能不能拼在一起”。这里有两种主流方案一种是基于碰撞的检测用Overlap事件或者LineTrace来感知附近的拼接件另一种是基于距离和角度的数学检测直接遍历场景中所有可拼接单元计算它们之间的距离和朝向是否满足拼接条件。我两种都试过最后选择了混合方案——先用球形Overlap快速筛选出附近的候选单元再用精确的数学计算判断是否满足拼接条件。这样既保证了性能又保证了精度。拼接执行模块负责“把两个单元真正拼起来”。拼接的方式也有两种一种是硬拼接直接把两个Actor的Transform对齐然后用AttachToActor把它们绑在一起另一种是软拼接用PhysicsConstraint把两个Actor连起来保留物理模拟。硬拼接适合建筑搭建类场景软拼接适合载具组装类场景。我在项目里把两种方式都封装成了可切换的选项根据拼接类型自动选择。拼接后管理模块负责“拼完之后怎么办”。拼接完成后需要处理的事情包括更新拼接组的层级结构、记录拼接历史以便撤销、处理拼接组的整体移动和旋转、以及拼接组的序列化和反序列化。这部分逻辑我放在了一个叫SpliceManager的单例Actor里全局管理所有拼接组的状态。1.3 拼接单元的元数据设计每个可拼接单元的元数据是整个系统的基石。我设计的SpliceComponent包含以下核心属性SpliceType枚举类型标记这个单元属于哪一类拼接件。比如Wall、Floor、Roof、Door、Window等。只有同类型或者兼容类型的单元才能拼接。SplicePoints一个数组每个元素描述一个拼接接口。每个接口包含接口的本地坐标位置、接口的法线方向、接口的形状平面、圆柱、球形、接口的尺寸长宽或半径。SpliceConstraints拼接约束比如最大拼接距离、最大角度偏差、是否允许旋转拼接、拼接后是否锁定。SpliceGroupID当前所属的拼接组ID-1表示未拼接。这些元数据在编辑器里配置好之后运行时就可以直接读取使用。我特意把SplicePoints设计成数组是因为一个拼接件可能有多个拼接面。比如一块墙板它的左右两侧和上下两端都可以拼接那就需要四个SplicePoint。提示SplicePoint的本地坐标一定要在建模时就确定好不要等到运行时动态计算。因为建模软件里的坐标系和UE4的坐标系可能有差异提前在模型里标记好接口位置导入UE4后直接读取比运行时用碰撞检测去猜要可靠得多。2. 拼接检测的底层原理与实操细节2.1 球形Overlap快速筛选候选单元拼接检测的第一步是找出“附近有哪些可拼接单元”。这一步我用的是球形Overlap具体实现是在SpliceComponent的Tick里每隔一定时间比如0.1秒做一次球形检测检测半径由SpliceConstraints里的MaxSpliceDistance决定。为什么用球形而不是盒形或者胶囊形因为球形检测在UE4里的性能开销最小而且对于“附近有没有东西”这种粗筛需求来说球形的精度完全够用。盒形检测虽然更精确但计算量更大而且需要处理旋转后的AABB计算代码复杂度也更高。胶囊形则更适合角色碰撞对于静态的拼接件来说没有必要。球形Overlap的实现代码大概长这样// 在SpliceComponent的Tick中 void USpliceComponent::PerformOverlapCheck() { TArrayFOverlapResult OverlapResults; FCollisionShape SphereShape FCollisionShape::MakeSphere(SpliceConstraints.MaxSpliceDistance); FCollisionQueryParams QueryParams; QueryParams.AddIgnoredActor(GetOwner()); GetWorld()-OverlapMultiByChannel( OverlapResults, GetOwner()-GetActorLocation(), FQuat::Identity, ECC_WorldDynamic, SphereShape, QueryParams ); // 筛选出带有SpliceComponent的Actor for (const FOverlapResult Result : OverlapResults) { AActor* OtherActor Result.GetActor(); USpliceComponent* OtherSplice OtherActor-FindComponentByClassUSpliceComponent(); if (OtherSplice OtherSplice ! this) { // 加入候选列表 CandidateSplices.AddUnique(OtherSplice); } } }这段代码的关键点在于忽略自身、只检测WorldDynamic通道、用AddUnique避免重复。WorldDynamic通道是我专门为可拼接单元设置的碰撞通道这样可以把检测范围限制在拼接件之间不会误检测到地形、装饰物等其他Actor。2.2 基于距离和角度的精确判定粗筛出候选单元之后就要做精确判定了。精确判定的核心是找到两个单元上距离最近的一对SplicePoint然后判断这对SplicePoint是否满足拼接条件。具体来说对于候选列表里的每一个其他SpliceComponent我会遍历它的所有SplicePoint同时也遍历自己的所有SplicePoint计算每一对SplicePoint之间的距离和角度偏差。距离用Vector的距离公式角度偏差用两个SplicePoint法线的点积来计算。bool USpliceComponent::CheckSpliceMatch(USpliceComponent* Other, FSplicePoint OutMyPoint, FSplicePoint OutOtherPoint) { float BestDistance MAX_FLT; bool bFoundMatch false; for (FSplicePoint MyPoint : SplicePoints) { FVector MyWorldPos GetOwner()-GetActorTransform().TransformPosition(MyPoint.LocalPosition); FVector MyWorldNormal GetOwner()-GetActorTransform().TransformVector(MyPoint.LocalNormal); for (FSplicePoint OtherPoint : Other-SplicePoints) { FVector OtherWorldPos Other-GetOwner()-GetActorTransform().TransformPosition(OtherPoint.LocalPosition); FVector OtherWorldNormal Other-GetOwner()-GetActorTransform().TransformVector(OtherPoint.LocalNormal); float Distance FVector::Dist(MyWorldPos, OtherWorldPos); float NormalDot FVector::DotProduct(MyWorldNormal, -OtherWorldNormal); // 距离要小于最大拼接距离法线要大致相对点积接近1 if (Distance SpliceConstraints.MaxSpliceDistance NormalDot FMath::Cos(FMath::DegreesToRadians(SpliceConstraints.MaxAngleDeviation))) { if (Distance BestDistance) { BestDistance Distance; OutMyPoint MyPoint; OutOtherPoint OtherPoint; bFoundMatch true; } } } } return bFoundMatch; }这里有几个细节值得展开说。第一法线方向要相对。两个拼接面的法线应该是面对面的所以一个法线和另一个法线的反方向做点积结果接近1才说明它们是对着的。如果点积接近-1说明两个面是背对背的那就不应该拼接。第二取距离最近的一对。因为一个单元可能有多个SplicePoint都能匹配上取距离最近的那一对是最合理的这样拼接后的缝隙最小。第三角度偏差用余弦值比较。因为点积本身就是余弦值所以直接把点积和cos(最大角度偏差)比较就行了不需要真的去算角度。2.3 拼接预览的视觉反馈在实际操作中玩家需要知道“现在这个位置能不能拼”。所以我在检测到可拼接时会在两个SplicePoint之间画一条绿色的虚线并且把候选的拼接件高亮显示。如果不可拼接就不画线或者画一条红色的线表示距离太远或角度不对。视觉反馈的实现我用的是DrawDebugLine和自定义的Material参数。DrawDebugLine只在开发版本里用发布版本里我用的是Niagara粒子系统画一条发光的线。高亮显示则是通过动态材质实例把候选拼接件的材质换成带边缘光效果的版本。注意视觉反馈的更新频率不要太高否则会拖累性能。我的做法是每帧只更新一次视觉反馈而且只在候选列表发生变化时才重新计算。如果候选列表没变就直接复用上一帧的视觉反馈结果。3. 拼接执行的完整流程与参数计算3.1 硬拼接的对齐与绑定硬拼接的核心操作是把被拼接的Actor的Transform调整到与目标SplicePoint对齐的位置然后用AttachToActor把它绑到目标Actor上。对齐的计算过程是这样的假设我要把Actor B拼接到Actor A的SplicePoint PA上B上对应的SplicePoint是PB。那么我需要计算一个Transform使得PB的世界位置和朝向与PA的世界位置和朝向一致。void USpliceComponent::ExecuteHardSplice(USpliceComponent* Other, FSplicePoint MyPoint, FSplicePoint OtherPoint) { AActor* MyOwner GetOwner(); AActor* OtherOwner Other-GetOwner(); // 计算MyPoint的世界Transform FTransform MyPointWorld MyPoint.LocalTransform * MyOwner-GetActorTransform(); // 计算OtherPoint的本地Transform FTransform OtherPointLocal OtherPoint.LocalTransform; // 计算OtherOwner需要的世界Transform // 使得OtherPoint的世界Transform等于MyPointWorld FTransform OtherOwnerNewWorld OtherPointLocal.Inverse() * MyPointWorld; // 应用新的Transform OtherOwner-SetActorTransform(OtherOwnerNewWorld); // 绑定 OtherOwner-AttachToActor(MyOwner, FAttachmentTransformRules::KeepWorldTransform); // 更新拼接组ID Other-SpliceGroupID SpliceGroupID; // 记录拼接历史 USpliceManager::Get()-RecordSplice(this, Other); }这段代码里最关键的是Transform的乘法顺序。UE4的FTransform乘法是左乘所以OtherPointLocal.Inverse() * MyPointWorld的意思是先把世界坐标系的MyPointWorld变换到OtherPoint的本地坐标系得到的结果就是OtherOwner应该处于的世界Transform。这个推导过程需要一点线性代数的知识但记住结论就行目标点的世界Transform乘以自身接口点的本地Transform的逆就是自身Actor应该处于的世界Transform。3.2 软拼接的物理约束配置软拼接不用AttachToActor而是用PhysicsConstraint。具体做法是在兩個SplicePoint的位置创建一个PhysicsConstraintActor把两个Actor的PrimitiveComponent分别设为Constraint的Component1和Component2然后配置约束的线性自由度和角度自由度。void USpliceComponent::ExecuteSoftSplice(USpliceComponent* Other, FSplicePoint MyPoint, FSplicePoint OtherPoint) { AActor* MyOwner GetOwner(); AActor* OtherOwner Other-GetOwner(); // 计算约束位置 FVector ConstraintLocation MyOwner-GetActorTransform().TransformPosition(MyPoint.LocalPosition); // 生成PhysicsConstraintActor FActorSpawnParameters SpawnParams; APhysicsConstraintActor* Constraint GetWorld()-SpawnActorAPhysicsConstraintActor( APhysicsConstraintActor::StaticClass(), ConstraintLocation, FRotator::ZeroRotator, SpawnParams ); // 配置约束 UPhysicsConstraintComponent* ConstraintComp Constraint-GetConstraintComp(); ConstraintComp-SetConstrainedComponents( CastUPrimitiveComponent(MyOwner-GetRootComponent()), NAME_None, CastUPrimitiveComponent(OtherOwner-GetRootComponent()), NAME_None ); // 锁定线性移动保留角度自由 ConstraintComp-SetLinearXLimit(LCM_Locked, 0.0f); ConstraintComp-SetLinearYLimit(LCM_Locked, 0.0f); ConstraintComp-SetLinearZLimit(LCM_Locked, 0.0f); // 角度限制根据拼接类型决定 if (SpliceType ESpliceType::Door) { // 门只允许绕Z轴旋转 ConstraintComp-SetAngularSwing1Limit(ACM_Locked, 0.0f); ConstraintComp-SetAngularSwing2Limit(ACM_Locked, 0.0f); ConstraintComp-SetAngularTwistLimit(ACM_Free, 0.0f); } else { // 其他类型全部锁定 ConstraintComp-SetAngularSwing1Limit(ACM_Locked, 0.0f); ConstraintComp-SetAngularSwing2Limit(ACM_Locked, 0.0f); ConstraintComp-SetAngularTwistLimit(ACM_Locked, 0.0f); } }软拼接的参数配置需要根据拼接类型来调整。比如门需要保留一个旋转自由度窗户可能需要保留两个旋转自由度而墙板则需要全部锁定。这些配置我放在了一个DataTable里每种SpliceType对应一组约束参数运行时直接查表。3.3 拼接组的层级管理与撤销拼接完成后多个Actor会形成一个拼接组。拼接组的管理我用了一个树形结构每个拼接组有一个根Actor其他Actor都是根Actor的子节点。根Actor的SpliceGroupID就是整个组的ID子节点的SpliceGroupID也设为同一个值。当玩家移动拼接组时只需要移动根Actor子节点会自动跟随。当玩家撤销拼接时需要把最后拼接的那个Actor从组里移除恢复它的独立状态。撤销的实现依赖于拼接历史栈每次拼接时把操作记录压栈撤销时弹栈并反向执行。void USpliceManager::UndoSplice() { if (SpliceHistory.Num() 0) return; FSpliceRecord Record SpliceHistory.Pop(); // 解除绑定 Record.OtherActor-DetachFromActor(FDetachmentTransformRules::KeepWorldTransform); // 恢复拼接组ID USpliceComponent* OtherSplice Record.OtherActor-FindComponentByClassUSpliceComponent(); if (OtherSplice) { OtherSplice-SpliceGroupID -1; } // 如果是软拼接还需要销毁PhysicsConstraint if (Record.ConstraintActor) { Record.ConstraintActor-Destroy(); } }提示拼接历史栈的深度不要设太大否则内存占用会很高。我的做法是限制最多记录50步超过50步就丢弃最早的记录。对于大多数搭建场景来说50步撤销足够了。4. 常见问题与排查技巧实录4.1 拼接后位置偏移或抖动这是最常见的问题表现为拼接完成后两个Actor之间有明显的缝隙或者拼接后Actor会轻微抖动。位置偏移的原因通常有三个SplicePoint的本地坐标不准确、Actor的Pivot点不在几何中心、碰撞体的偏移。排查方法是在编辑器里把两个Actor手动摆到拼接位置然后打印出两个SplicePoint的世界坐标看差值是否为零。如果不为零就调整SplicePoint的本地坐标直到差值为零。抖动的原因通常是物理模拟和Attach冲突。如果两个Actor都有物理模拟Attach之后物理引擎还在尝试模拟它们就会产生抖动。解决方法是Attach之后把被拼接Actor的SimulatePhysics设为false或者改用PhysicsConstraint做软拼接。问题现象可能原因排查方法解决方案拼接后有缝隙SplicePoint坐标不准打印世界坐标对比调整SplicePoint本地坐标拼接后抖动物理模拟冲突检查SimulatePhysics关闭物理模拟或改用约束拼接后旋转错乱法线方向不对检查SplicePoint法线修正法线方向拼接检测不到碰撞通道不对检查Overlap通道设置正确的碰撞通道拼接后无法撤销历史栈未记录检查RecordSplice调用确保每次拼接都记录4.2 拼接检测的性能优化当场景中有大量可拼接单元时每帧的Overlap检测会成为性能瓶颈。我试过几种优化方案最后总结出一套组合拳。第一降低检测频率。不需要每帧都检测每0.1秒检测一次足够了。玩家移动拼接件的时候0.1秒的延迟几乎感知不到。第二用空间分区加速。UE4自带的Octree可以用来做空间分区但配置起来比较麻烦。我用的是一种更简单的方法把所有可拼接单元注册到一个全局的SpliceManager里SpliceManager维护一个简单的网格索引每个格子记录落在该格子里的拼接单元。检测时只需要查询附近几个格子不需要遍历全场。第三用异步检测。如果拼接单元特别多比如上千个可以把检测放到异步任务里做主线程只负责应用检测结果。UE4的AsyncTask或者TaskGraph都可以用来做异步检测。// 简单的网格索引实现 void USpliceManager::RegisterSpliceComponent(USpliceComponent* Comp) { FIntVector GridCoord WorldToGrid(Comp-GetOwner()-GetActorLocation()); GridMap.FindOrAdd(GridCoord).Add(Comp); } TArrayUSpliceComponent* USpliceManager::GetNearbySplices(FVector Location, float Radius) { TArrayUSpliceComponent* Result; FIntVector MinCoord WorldToGrid(Location - FVector(Radius)); FIntVector MaxCoord WorldToGrid(Location FVector(Radius)); for (int x MinCoord.X; x MaxCoord.X; x) { for (int y MinCoord.Y; y MaxCoord.Y; y) { for (int z MinCoord.Z; z MaxCoord.Z; z) { FIntVector Coord(x, y, z); if (GridMap.Contains(Coord)) { Result.Append(GridMap[Coord]); } } } } return Result; }4.3 拼接件的序列化与存档拼接系统做完之后下一个问题就是怎么把拼接结果存下来下次打开还能恢复。UE4自带的序列化系统可以序列化Actor的Transform和属性但拼接组的关系需要额外处理。我的做法是在SpliceManager里实现一个SerializeSpliceGroups函数把所有拼接组的信息写成一个JSON结构。每个拼接组记录根Actor的类名和Transform、所有子Actor的类名和相对Transform、拼接类型和拼接参数。反序列化时先Spawn根Actor再Spawn子Actor然后重新执行拼接操作。FString USpliceManager::SerializeSpliceGroups() { TSharedPtrFJsonObject RootObject MakeShareable(new FJsonObject()); TArrayTSharedPtrFJsonValue GroupsArray; for (auto Group : SpliceGroups) { TSharedPtrFJsonObject GroupObject MakeShareable(new FJsonObject()); GroupObject-SetStringField(RootClass, Group.RootActor-GetClass()-GetName()); GroupObject-SetStringField(RootTransform, Group.RootActor-GetActorTransform().ToString()); TArrayTSharedPtrFJsonValue ChildrenArray; for (AActor* Child : Group.ChildActors) { TSharedPtrFJsonObject ChildObject MakeShareable(new FJsonObject()); ChildObject-SetStringField(Class, Child-GetClass()-GetName()); ChildObject-SetStringField(RelativeTransform, Child-GetActorTransform().GetRelativeTransform(Group.RootActor-GetActorTransform()).ToString()); ChildrenArray.Add(MakeShareable(new FJsonValueObject(ChildObject))); } GroupObject-SetArrayField(Children, ChildrenArray); GroupsArray.Add(MakeShareable(new FJsonValueObject(GroupObject))); } RootObject-SetArrayField(Groups, GroupsArray); FString OutputString; TSharedRefTJsonWriter Writer TJsonWriterFactory::Create(OutputString); FJsonSerializer::Serialize(RootObject.ToSharedRef(), Writer); return OutputString; }注意序列化时一定要记录Actor的类名反序列化时用类名去Spawn。如果类名找不到对应的类就跳过这个Actor不要直接崩溃。另外相对Transform比绝对Transform更可靠因为根Actor的位置可能会变用相对Transform可以保证子Actor跟着根Actor一起移动。4.4 拼接件的碰撞与重叠处理拼接完成后两个Actor的碰撞体可能会重叠导致物理引擎产生奇怪的推力。解决方法是拼接完成后把被拼接Actor的碰撞预设改成NoCollision或者把碰撞体缩小到不重叠的程度。但有些场景下碰撞是必须保留的比如玩家需要站在拼接后的平台上。这时候可以用复合碰撞体把多个Actor的碰撞体合并成一个。UE4的PhysicsAsset可以做到这一点但配置起来比较复杂。我的做法是对于需要保留碰撞的拼接组在拼接完成后生成一个统一的碰撞体Actor把原来的碰撞体全部禁用。还有一种情况是拼接检测时两个Actor的碰撞体互相干扰。比如两个墙板靠得很近但还没拼接它们的碰撞体已经重叠了导致Overlap检测一直触发。解决方法是在SpliceComponent里加一个bIsSplicing标志拼接过程中暂时禁用碰撞检测拼接完成后再恢复。5. 拼接系统的扩展方向与实战心得5.1 从手动拼接扩展到自动吸附手动拼接需要玩家精确对准操作门槛比较高。我在项目后期加了一个自动吸附功能当玩家把拼接件移动到距离目标SplicePoint一定范围内时自动把拼接件吸附到目标位置并显示预览效果。自动吸附的实现依赖于前面说的拼接检测。检测到可拼接时不是等玩家松手才拼接而是直接把拼接件的Transform插值到目标位置。插值用VInterpTo或者FMath::Lerp都可以插值速度控制在0.2秒左右太快了玩家反应不过来太慢了感觉拖沓。void USpliceComponent::Tick(float DeltaTime) { if (bIsAutoSnapping CurrentSnapTarget) { FTransform TargetTransform CalculateSnapTransform(CurrentSnapTarget); FTransform CurrentTransform GetOwner()-GetActorTransform(); FTransform NewTransform FTransform( FQuat::Slerp(CurrentTransform.GetRotation(), TargetTransform.GetRotation(), DeltaTime * SnapSpeed), FMath::VInterpTo(CurrentTransform.GetLocation(), TargetTransform.GetLocation(), DeltaTime, SnapSpeed), CurrentTransform.GetScale3D() ); GetOwner()-SetActorTransform(NewTransform); if (NewTransform.Equals(TargetTransform, 0.1f)) { ExecuteHardSplice(CurrentSnapTarget, ...); bIsAutoSnapping false; } } }5.2 拼接规则的脚本化配置硬编码拼接规则有个问题每次改规则都要改代码、重新编译。对于策划来说这太不友好了。所以我把拼接规则抽到了一个DataTable里每种SpliceType对应一行行里配置了可以拼接的类型、最大距离、最大角度、拼接方式等参数。SpliceTypeCompatibleTypesMaxDistanceMaxAngleSpliceMethodWallWall, Floor10.05.0HardFloorWall, Floor, Roof10.05.0HardRoofWall, Roof15.010.0HardDoorWall5.03.0SoftWheelChassis8.015.0Soft这样策划只需要在DataTable里改配置不需要动代码。运行时SpliceComponent从DataTable里读取自己类型的配置然后按照配置去检测和拼接。5.3 多人协作拼接的网络同步如果拼接系统要用在多人游戏里网络同步是绕不开的。我的做法是拼接操作由客户端发起但实际的拼接执行在服务器端做。客户端只负责发送“我想把A拼到B上”的请求服务器验证通过后执行拼接然后把拼接结果广播给所有客户端。网络同步的关键是拼接操作的原子性。两个玩家同时想把同一个拼接件拼到不同的目标上服务器需要决定谁先谁后。我的做法是用一个简单的锁机制服务器收到拼接请求后先检查目标拼接件是否已经被锁定如果没锁定就锁定并执行拼接执行完再解锁。如果已经被锁定就拒绝请求给客户端发一个“操作冲突”的提示。// 服务器端的拼接请求处理 void USpliceManager::Server_RequestSplice_Implementation(AActor* Source, AActor* Target) { if (bIsSpliceLocked) { Client_OnSpliceRejected(Source, Target, TEXT(Another splice is in progress)); return; } bIsSpliceLocked true; // 验证拼接合法性 if (!ValidateSplice(Source, Target)) { bIsSpliceLocked false; Client_OnSpliceRejected(Source, Target, TEXT(Invalid splice)); return; } // 执行拼接 ExecuteSplice(Source, Target); // 广播结果 Multicast_OnSpliceCompleted(Source, Target); bIsSpliceLocked false; }5.4 实战中踩过的坑与经验总结第一个坑是坐标系转换。UE4的Actor Transform包含位置、旋转、缩放三部分在做SplicePoint的世界坐标计算时如果Actor有非均匀缩放TransformPosition和TransformVector的结果会不一样。我一开始没注意这个问题导致拼接后的位置总是差一点。后来统一用TransformPosition来计算位置用TransformVector来计算方向并且把拼接件的缩放锁定为1才解决了这个问题。第二个坑是Attach后的物理状态。AttachToActor之后被Attach的Actor的物理模拟不会自动停止。如果它之前是SimulatePhysicstrueAttach之后它还会继续模拟导致位置抖动。必须在Attach之后手动把SimulatePhysics设为false并且把CollisionProfile改成NoCollision。第三个坑是拼接组的旋转中心。拼接组的根Actor的Pivot点决定了整个组的旋转中心。如果根Actor的Pivot点在角落而不是中心旋转拼接组的时候整个组会绕着角落转看起来很奇怪。解决方法是在创建拼接组时把根Actor的Pivot点调整到组的几何中心。UE4的Actor没有直接的Pivot调整功能我的做法是在根Actor下面挂一个SceneComponent作为实际的根把Pivot点设在这个SceneComponent上。第四个坑是拼接检测的通道冲突。我一开始把可拼接单元的碰撞通道设成了WorldStatic结果和地形的碰撞通道冲突了Overlap检测总是检测到地形。后来专门建了一个自定义的碰撞通道叫SpliceChannel把可拼接单元都设成这个通道检测时只检测这个通道问题就解决了。第五个坑是序列化后的类名丢失。用JSON序列化时如果Actor的类名是蓝图生成的类GetClass()-GetName()返回的可能是类似“BP_Wall_C”这样的名字反序列化时用StaticLoadClass去加载可能会失败。解决方法是在序列化时同时记录类的路径反序列化时用FSoftClassPath去加载。// 序列化时记录类路径 ChildObject-SetStringField(ClassPath, Child-GetClass()-GetPathName()); // 反序列化时用类路径加载 FString ClassPath ChildObject-GetStringField(ClassPath); UClass* ActorClass FSoftClassPath(ClassPath).TryLoadClassAActor(); if (ActorClass) { AActor* NewActor GetWorld()-SpawnActorAActor(ActorClass, Transform); }这套拼接系统从最初的原型到最终稳定运行前后迭代了大概三个月。中间推翻重做了两次一次是因为拼接检测的性能问题一次是因为网络同步的冲突处理。现在回头看最正确的决定是把拼接单元抽象成Component而不是继承体系这让整个系统非常灵活任何Actor只要挂上SpliceComponent就能参与拼接不需要改继承结构。另一个正确的决定是把拼接规则数据化策划可以自己配置拼接规则不需要程序员介入。如果你也在做类似的拼接系统我的建议是先把硬拼接做稳定再考虑软拼接先把单机做稳定再考虑网络同步先把检测做精确再考虑性能优化。顺序反了的话后面会花更多时间在调试上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑