UE5物理系统实战:从零开始用C++实现物体碰撞与射线检测(附完整代码)
如果你已经对UE5的蓝图系统驾轻就熟,但总觉得在实现一些复杂的物理交互时,蓝图节点显得力不从心,或者性能上遇到了瓶颈,那么是时候深入C++层面了。物理系统是游戏真实感和交互性的基石,从角色被墙壁阻挡,到拾取物品时触发的音效,再到解谜游戏中精准的射线探测,背后都是物理引擎在默默工作。本文将带你绕过表面的蓝图封装,直接使用C++与UE5的物理引擎对话。我们将从创建一个受重力影响的物理对象开始,逐步实现精确的碰撞事件响应,并最终完成一个高效的射线检测系统。整个过程将伴随着可直接集成到你项目中的完整代码,目标是让你不仅知其然,更能知其所以然,从而在未来的开发中能灵活定制属于自己的物理规则。
1. 构建你的第一个C++物理Actor
在UE5中,一切可放置的对象几乎都继承自AActor。要让一个Actor参与物理模拟,关键在于为其附加一个启用了物理模拟的原始组件(Primitive Component),最常见的就是UStaticMeshComponent。让我们从零开始构建这个基础。
1.1 创建C++类与组件初始化
首先,在编辑器内通过“工具”->“新建C++类”创建一个继承自Actor的类,命名为PhysicsCubeActor。创建完成后,打开对应的头文件(.h)和源文件(.cpp)。
在头文件中,我们需要声明一个静态网格体组件,并将其设置为根组件。同时,为了后续方便调试和配置,我们通过UPROPERTY宏将其暴露给编辑器。
// PhysicsCubeActor.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PhysicsCubeActor.generated.h"
UCLASS()
class YOURPROJECT_API APhysicsCubeActor : public AActor
{
GENERATED_BODY()
public:
// 设置默认属性
APhysicsCubeActor();
protected:
virtual void BeginPlay() override;
private:
// 声明静态网格体组件
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
class UStaticMeshComponent* StaticMeshComp;
};
接下来,在源文件中实现构造函数。这里有几个关键步骤:
- 创建组件:使用
CreateDefaultSubobject函数创建组件实例。这是UE反射系统管理UObject的标准方式。 - 设置根组件:将创建的网格体组件设为根组件(Root Component),这决定了Actor在世界中的变换原点。
- 加载默认资产:为了方便,我们可以直接在构造函数中加载一个引擎内置的基本形状(如立方体)作为网格体。
- 启用物理:这是核心一步,调用
SetSimulatePhysics(true)来告知物理引擎接管该组件的运动。
// PhysicsCubeActor.cpp
#include "PhysicsCubeActor.h"
#include "Components/StaticMeshComponent.h"
#include "UObject/ConstructorHelpers.h"
APhysicsCubeActor::APhysicsCubeActor()
{
// 允许每帧调用Tick函数
PrimaryActorTick.bCanEverTick = true;
// 创建并设置静态网格体组件
StaticMeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMeshComponent"));
RootComponent = StaticMeshComp;
// 从引擎内容中查找并加载一个基本的立方体网格体
static ConstructorHelpers::FObjectFinder<UStaticMesh> MeshAsset(TEXT("/Engine/BasicShapes/Cube.Cube"));
if (MeshAsset.Succeeded())
{
StaticMeshComp->SetStaticMesh(MeshAsset.Object);
}
// 启用物理模拟
StaticMeshComp->SetSimulatePhysics(true);
// 可选:设置质量(单位:千克)
StaticMeshComp->SetMassOverrideInKg(NAME_None, 50.0f, true);
}
void APhysicsCubeActor::BeginPlay()
{
Super::BeginPlay();
}
编译项目后,你可以在内容浏览器中找到这个APhysicsCubeActor类,将其拖入场景。运行游戏,你会看到这个立方体在重力作用下坠落。恭喜,你已经用C++创建了第一个物理对象!
注意:在构造函数中加载资产(如
ConstructorHelpers::FObjectFinder)仅适用于设置默认值。对于需要在运行时动态加载或更改的资产,应在BeginPlay或其他游戏逻辑函数中使用异步加载器。
1.2 物理属性的深度配置
仅仅启用物理模拟只是开始。为了满足不同的游戏需求,我们经常需要精细地调整物理属性。这些属性大多可以通过UStaticMeshComponent或其底层的BodyInstance进行访问和设置。
以下是一些常用且关键的物理属性配置示例,我们可以在构造函数或一个自定义的初始化函数中设置它们:
// 在构造函数中继续配置物理属性
APhysicsCubeActor::APhysicsCubeActor()
{
// ... 之前的创建和加载代码 ...
// 1. 设置线性阻尼(空气阻力)和角阻尼(旋转阻力)
StaticMeshComp->SetLinearDamping(0.01f);
StaticMeshComp->SetAngularDamping(0.05f);
// 2. 约束某些轴上的移动或旋转(例如,制作一个只能在XZ平面滑动的物体)
StaticMeshComp->BodyInstance.bLockXRotation = true;
StaticMeshComp->BodyInstance.bLockYRotation = true;
StaticMeshComp->BodyInstance.bLockZRotation = false; // 允许绕Z轴旋转
// StaticMeshComp->BodyInstance.bLockTranslation = true; // 完全锁定移动
// 3. 设置碰撞响应通道。这里先简单设置,后续章节会详细展开。
// StaticMeshComp->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Block);
// 4. 启用重力(默认就是true,这里演示如何关闭)
// StaticMeshComp->SetEnableGravity(false);
// 5. 设置物理材质(影响摩擦力、弹性等)
// 需要先创建或引用一个PhysicalMaterial资产
// UPhysicalMaterial* MyPhysMat = ...;
// StaticMeshComp->SetPhysMaterialOverride(MyPhysMat);
}
通过组合这些属性,你可以创造出行为各异的物理实体,比如漂浮的幽灵物体、急速旋转的陀螺,或者像冰面一样光滑的移动平台。
2. 碰撞检测:Overlap与Block的实战处理
在UE5的物理世界中,碰撞检测主要分为两种类型:Overlap(重叠)和Block(阻挡)。理解它们的区别是设计正确交互逻辑的前提。
| 检测类型 | 物理响应 | 典型应用场景 | 性能开销 |
|---|---|---|---|
| Overlap | 物体相互穿透,不产生推力。 | 触发器区域、拾取物品、伤害区域、检测玩家进入范围。 | 较低,仅进行体积测试。 |
| Block | 物体相互阻挡,物理引擎会计算反作用力,阻止穿透。 | 墙壁、地面、可移动的箱子、角色之间的碰撞。 | 较高,需进行复杂的约束求解。 |
2.1 绑定与处理Overlap事件
假设我们要让PhysicsCubeActor在与其他Actor(比如玩家角色)重叠时,在屏幕上打印一条日志。我们需要绑定OnComponentBeginOverlap委托。
首先,在头文件中声明委托回调函数:
// PhysicsCubeActor.h
// ... 在类声明内部添加 ...
private:
// 重叠事件委托函数
UFUNCTION()
void OnCompBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
然后,在BeginPlay函数中,将这个函数绑定到我们网格体组件的OnComponentBeginOverlap委托上:
// PhysicsCubeActor.cpp
void APhysicsCubeActor::BeginPlay()
{
Super::BeginPlay();
// 确保组件有效后绑定事件
if (StaticMeshComp)
{
StaticMeshComp->OnComponentBeginOverlap.AddDynamic(this, &APhysicsCubeActor::OnCompBeginOverlap);
}
}
// 实现重叠事件处理函数
void APhysicsCubeActor::OnCompBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
// 简单的有效性检查,避免自己与自己重叠的误报
if (OtherActor && (OtherActor != this) && OtherComp)
{
FString Message = FString::Printf(TEXT("[Overlap] %s 与 %s 发生重叠"), *GetName(), *OtherActor->GetName());
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, Message);
UE_LOG(LogTemp, Warning, TEXT("%s"), *Message);
}
}
现在运行游戏,控制角色走到立方体旁边(确保角色胶囊体与立方体网格体重叠),你就能在屏幕左上角和输出日志中看到重叠信息。
提示:
OnComponentBeginOverlap是组件级别的事件,比OnActorBeginOverlap(Actor级别)更常用,因为它能提供更具体的碰撞组件信息(OtherComp),这对于处理复杂Actor(由多个碰撞体组成)的交互至关重要。
2.2 实现Blocking碰撞与力反馈
Blocking碰撞通常不需要我们手动绑定事件,因为物理引擎会自动处理阻挡和力的计算。但我们可以通过它来施加一些自定义的力或实现特殊效果。例如,让我们的立方体在被角色撞击时,朝反方向弹开。
我们可以在Tick函数中检查速度变化,或者更直接地,通过计算碰撞后的冲量来施加一个力。这里演示一个更主动的方法:在角色控制器中,当按下某个键时,向面前的立方体施加一个冲力。
首先,在玩家角色或控制器类中实现一个函数:
// 在玩家角色类中
void AMyCharacter::ApplyImpulseToPhysicsActor()
{
// 执行一次射线检测,找到面前的Actor(射线检测实现见下一章)
FHitResult Hit;
FVector Start = GetActorLocation();
FVector End = Start + GetActorForwardVector() * 500.0f;
FCollisionQueryParams Params;
Params.AddIgnoredActor(this);
if (GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility, Params))
{
APhysicsCubeActor* HitCube = Cast<APhysicsCubeActor>(Hit.GetActor());
if (HitCube && HitCube->StaticMeshComp)
{
// 获取击中点的法线方向
FVector ImpulseDir = Hit.ImpactNormal;
// 施加一个沿着法线方向的冲力(例如,击退)
float ImpulseStrength = 1000.0f;
HitCube->StaticMeshComp->AddImpulse(ImpulseDir * ImpulseStrength, NAME_None, true);
// 也可以施加一个径向力,产生爆炸效果
// HitCube->StaticMeshComp->AddRadialImpulse(Hit.Location, 300.0f, 5000.0f, ERadialImpulseFalloff::RIF_Constant, true);
}
}
}
将这个函数绑定到某个输入按键(如“F”键),你就能在游戏中“击飞”物理立方体了。这种直接对物理组件施加力的方式,是实现互动玩法的强大工具。
3. 射线检测:从屏幕到世界的精准感知
射线检测(Raycast)是游戏开发中用途最广的技术之一,常用于武器瞄准、物体拾取、视线判断、地形探测等。其原理是从世界空间中的一个起点,沿着一个方向发射一条无形的线段,并检测沿途与哪些物体相交。
3.1 实现基础的单点射线检测
我们将在一个玩家角色类中实现一个简单的射线检测功能:从摄像机位置向前方发射射线,检测击中的物体并高亮显示。
在角色头文件中声明函数和变量:
// MyCharacter.h
// ... 类声明内 ...
public:
// 执行射线检测的函数
void PerformRaycast();
protected:
// 摄像机组件引用,用于获取射线起点和方向
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
class UCameraComponent* FirstPersonCamera;
// 用于存储上次击中的Actor,以便取消高亮
AActor* LastHitActor;
// 高亮材质实例
UMaterialInstanceDynamic* HighlightMaterial;
在角色源文件中实现:
// MyCharacter.cpp
#include "MyCharacter.h"
#include "Camera/CameraComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/World.h"
#include "DrawDebugHelpers.h" // 用于调试绘制
void AMyCharacter::PerformRaycast()
{
if (!FirstPersonCamera) return;
FVector Start = FirstPersonCamera->GetComponentLocation();
// 射线终点:起点 + 摄像机前方向量 * 检测距离
FVector End = Start + (FirstPersonCamera->GetForwardVector() * 10000.0f);
FHitResult HitResult;
FCollisionQueryParams QueryParams;
QueryParams.bTraceComplex = true; // 使用复杂碰撞(基于三角形),更精确但更耗性能
QueryParams.bReturnPhysicalMaterial = true; // 如果需要获取物理材质信息
QueryParams.AddIgnoredActor(this); // 忽略自身,避免射线打到自己
bool bHit = GetWorld()->LineTraceSingleByChannel(
HitResult,
Start,
End,
ECC_Visibility, // 使用“可见性”碰撞通道,这是最常用的之一
QueryParams
);
// 如果上次有高亮对象,先恢复其材质
if (LastHitActor)
{
APhysicsCubeActor* LastCube = Cast<APhysicsCubeActor>(LastHitActor);
if (LastCube && LastCube->StaticMeshComp)
{
// 这里需要你事先保存原始材质,这里简化为移除动态材质
// LastCube->StaticMeshComp->SetMaterial(0, OriginalMaterial);
}
LastHitActor = nullptr;
}
if (bHit)
{
AActor* HitActor = HitResult.GetActor();
if (HitActor)
{
// 在屏幕上显示击中信息
GEngine->AddOnScreenDebugMessage(-1, 1.f, FColor::Red,
FString::Printf(TEXT("击中: %s (距离: %.2f)"), *HitActor->GetName(), HitResult.Distance));
// 尝试高亮击中的物理立方体
APhysicsCubeActor* HitCube = Cast<APhysicsCubeActor>(HitActor);
if (HitCube && HitCube->StaticMeshComp)
{
// 创建或设置一个高亮材质
if (!HighlightMaterial)
{
// 这里需要你有一个基础的高亮材质(如M_Highlight)
UMaterialInterface* BaseMat = LoadObject<UMaterialInterface>(nullptr, TEXT("/Game/Materials/M_Highlight.M_Highlight"));
if (BaseMat)
{
HighlightMaterial = UMaterialInstanceDynamic::Create(BaseMat, this);
HighlightMaterial->SetVectorParameterValue(TEXT("Color"), FLinearColor::Red);
}
}
if (HighlightMaterial)
{
// 保存原始材质(实际项目中应更妥善地管理)
// OriginalMaterial = HitCube->StaticMeshComp->GetMaterial(0);
HitCube->StaticMeshComp->SetMaterial(0, HighlightMaterial);
LastHitActor = HitActor;
}
}
// 调试:在编辑器中绘制击中点和法线
#if WITH_EDITOR
DrawDebugPoint(GetWorld(), HitResult.ImpactPoint, 10.0f, FColor::Green, false, 1.0f);
DrawDebugLine(GetWorld(), HitResult.ImpactPoint, HitResult.ImpactPoint + HitResult.ImpactNormal * 50.0f, FColor::Blue, false, 1.0f);
#endif
}
}
else
{
// 调试:绘制完整的射线(未击中时)
#if WITH_EDITOR
DrawDebugLine(GetWorld(), Start, End, FColor::White, false, 0.5f);
#endif
}
}
最后,在角色输入绑定中(如SetupPlayerInputComponent函数),将PerformRaycast函数绑定到鼠标左键或某个按键上。
3.2 高级射线检测:形状扫描与多目标检测
除了LineTraceSingleByChannel(单射线单目标),UE5还提供了其他强大的检测函数:
LineTraceMultiByChannel:检测射线路径上的所有物体,返回一个TArray<FHitResult>。适用于霰弹枪、激光穿透等效果。TArray<FHitResult> HitResults; if (GetWorld()->LineTraceMultiByChannel(HitResults, Start, End, ECC_Visibility, QueryParams)) { for (const FHitResult& Hit : HitResults) { // 处理每一个被击中的物体 } }SweepSingleByChannel:使用一个碰撞形状(如球体、胶囊体、盒子)沿着路径进行扫描检测。这比射线更符合某些体积性武器的检测逻辑(如挥舞刀剑、爆炸冲击波)。FHitResult SweepHit; FCollisionShape SphereShape = FCollisionShape::MakeSphere(50.0f); // 创建一个半径为50的球体 bool bSweepHit = GetWorld()->SweepSingleByChannel( SweepHit, Start, End, FQuat::Identity, // 旋转 ECC_WorldDynamic, // 碰撞通道 SphereShape, QueryParams );
选择哪种检测方式,取决于具体的游戏需求。单射线检测效率最高,多目标检测和形状扫描则能实现更复杂的交互逻辑,但性能开销也相应增大。
4. 碰撞通道与响应:构建精细的交互规则
当游戏中的物体种类增多时,让所有物体都相互阻挡或重叠会产生混乱。UE5的**碰撞通道(Collision Channel)和响应(Response)**系统就是为了解决这个问题而设计的。它允许你定义物体属于哪些通道,以及它们如何响应来自其他通道的碰撞查询。
4.1 理解预设与自定义通道
在项目设置(Project Settings -> Collision)中,你可以看到一系列预设的碰撞通道,如WorldStatic、WorldDynamic、Pawn、PhysicsBody等。每个UPrimitiveComponent都有一个Collision Responses属性,决定了它对这些预设通道的响应(Ignore, Overlap, Block)。
然而,对于复杂项目,我们经常需要创建自定义通道。例如,为“子弹”、“敌人”、“可破坏物”分别创建通道。
- 创建自定义对象通道:在项目设置的“Collision”->“Object Channels”中,点击“New Object Channel...”,命名为“Bullet”,默认响应设为“Ignore”。
- 创建自定义追踪通道:在“Trace Channels”中,可以创建用于射线检测的通道,例如“SightLine”。
4.2 在C++中配置碰撞响应
创建好通道后,我们可以在C++代码中动态地设置组件的碰撞响应。以下代码演示了如何让我们的PhysicsCubeActor只阻挡“WorldStatic”通道,与“Pawn”通道重叠,并忽略“Bullet”通道(假设子弹有自己的处理逻辑)。
// 在APhysicsCubeActor的构造函数或初始化函数中
APhysicsCubeActor::APhysicsCubeActor()
{
// ... 之前的代码 ...
if (StaticMeshComp)
{
// 1. 首先,获取组件当前的碰撞响应容器
FCollisionResponseContainer& ResponseContainer = StaticMeshComp->GetCollisionResponseToChannels();
// 2. 设置对特定预设通道的响应
ResponseContainer.SetResponse(ECC_WorldStatic, ECR_Block); // 阻挡静态世界物体
ResponseContainer.SetResponse(ECC_WorldDynamic, ECR_Overlap); // 与动态物体重叠
ResponseContainer.SetResponse(ECC_Pawn, ECR_Overlap); // 与Pawn重叠
ResponseContainer.SetResponse(ECC_PhysicsBody, ECR_Ignore); // 忽略其他物理体(避免堆叠时过度推挤)
// 3. 设置对自定义通道的响应(需要先获取通道ID)
// 假设我们通过项目设置创建了“Bullet”对象通道
// ECollisionChannel BulletChannel = ECC_GameTraceChannel1; // 这需要根据实际分配确定
// ResponseContainer.SetResponse(BulletChannel, ECR_Ignore);
// 4. 将修改后的响应设置回组件
// 注意:更推荐在组件创建后,通过蓝图或细节面板预设好Collision Preset,代码中仅做微调。
// StaticMeshComp->SetCollisionResponseToChannels(ResponseContainer);
// 更简洁的方式:直接使用SetCollisionResponseToChannel函数
StaticMeshComp->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Block);
StaticMeshComp->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
// ... 其他通道设置
}
}
通过精细的碰撞通道配置,你可以实现诸如“玩家可以穿过队友但会被敌人阻挡”、“子弹只击中敌人和可破坏物”、“魔法效果只影响特定阵营”等复杂的游戏规则,而无需编写冗长的条件判断代码。这套系统是UE5物理交互能够保持清晰和高效的核心所在。
5. 性能优化与调试技巧
当场景中物理对象成百上千,或者每帧进行大量射线检测时,性能问题就会凸显。这里分享几个在C++层面优化物理性能的实战经验。
首先,善用碰撞预设(Collision Presets)而非全动态设置。 在编辑器中为常用的碰撞类型(如“PhysicsActor”、“TriggerVolume”、“NoCollision”)创建预设,然后在C++中通过StaticMeshComp->SetCollisionProfileName(TEXT("PhysicsActor"))一键应用。这比在运行时逐个设置通道要高效得多,也便于设计师调整。
其次,区分复杂碰撞(Complex Collision)和简单碰撞(Simple Collision)。 默认情况下,静态网格体使用其自带的简化碰撞体(如包围盒、凸包)。bTraceComplex参数为false时,射线检测就使用这个简化体,速度极快。只有需要极高精度(如子弹击中装甲缝隙)时才开启复杂碰撞(基于三角形网格)。对于大量的小型检测,务必使用简单碰撞。
FCollisionQueryParams Params;
Params.bTraceComplex = false; // 默认就是false,确保你不小心改了回来
// 或者针对特定查询关闭复杂碰撞
Params.bTraceComplex = false;
第三,减少不必要的每帧检测。 不是所有射线检测都需要在Tick中执行。例如,拾取物品的检测可以在玩家按下按键时触发;敌人的视线检测可以用一个定时器(FTimerHandle)每0.1-0.2秒执行一次。
// 在BeginPlay中设置一个定时器
GetWorld()->GetTimerManager().SetTimer(SightCheckTimerHandle, this, &AEnemyAI::CheckSightLine, 0.15f, true);
最后,充分利用调试工具。 UE5编辑器提供了强大的物理调试可视化功能。在运行时控制台(按“~”键)输入命令可以显示碰撞体、物理网络、接触点等信息,这对于排查诡异的物理行为至关重要。
// 显示所有碰撞体的形状
show collision
// 显示物理模拟的刚体
show rigidbody
// 显示射线检测的调试信息(需要在代码中启用DrawDebug)
show debugweapon
我在一个拥有大量可互动物理道具的项目中,最初因为所有道具都启用了复杂碰撞且每帧进行Overlap检测,导致了明显的帧率下降。后来通过将大部分道具的碰撞预设改为仅对“Pawn”通道Overlap,并将持续检测改为由玩家靠近时触发的离散检测,性能提升了超过30%。物理系统的优化往往在于“做减法”,明确每个物体“需要”与谁交互,而不是“可以”与谁交互。
&spm=1001.2101.3001.5002&articleId=153621360&d=1&t=3&u=d00d90ddd0b846a782ca2441f40e9cc1)
374

被折叠的 条评论
为什么被折叠?



