尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

虚幻引擎C++屏幕射线检测:从原理到实战的完整实现指南

虚幻引擎C++屏幕射线检测:从原理到实战的完整实现指南 1. 项目概述从屏幕到世界的“触手”在虚幻引擎Unreal Engine里捣鼓过交互功能的开发者对“射线检测”Raycast这个概念一定不陌生。无论是实现FPS游戏的准星瞄准、RTS游戏的单位点选还是AR应用中虚拟物体的放置本质上都是一条从屏幕出发、穿透虚拟世界的“无形触手”去探测和抓取我们感兴趣的目标。这个功能是连接玩家输入与游戏世界反馈最直接的桥梁之一。然而当需求从蓝图Blueprint转向C实现时很多开发者会感到一丝困惑。蓝图节点拖拽固然方便但C能提供更高的性能、更灵活的定制和更清晰的代码结构尤其是在处理复杂的检测逻辑或需要深度集成到游戏框架时。这个项目的核心就是深入虚幻引擎C的底层亲手打造一条从屏幕像素坐标出发精准命中3D世界中物体的射线。这不仅仅是调用一个API那么简单它涉及到视口变换、碰撞通道配置、检测参数调优以及结果的高效处理是一套完整的、可复用的解决方案。如果你正在为你的UE项目寻找一个高效、可靠的屏幕交互方案或者希望深入理解虚幻引擎的碰撞与检测系统那么跟随我一起拆解这个“从屏幕开始的射线检测”的实现过程将会大有裨益。2. 核心原理与前置知识拆解在动手写代码之前我们必须把射线检测背后的“为什么”搞清楚。这能帮助我们在遇到诡异问题时快速定位是数学计算错了还是碰撞设置没配好。2.1 屏幕空间到世界空间的坐标转换链这是整个流程中最关键的一步。我们鼠标点击的屏幕坐标是一个2D点X, Y而虚幻世界是3D的。如何将2D点映射到3D空间的一条射线上其核心链条是屏幕坐标 - 视口坐标 - 投影空间坐标 - 视图空间坐标 - 世界空间坐标。获取玩家控制器与视口首先我们需要拿到当前本地玩家的APlayerController。通过它可以获取到管理渲染的ULocalPlayer和最终的UGameViewportClient。视口Viewport代表了游戏窗口的渲染区域。视口投影矩阵解算通过APlayerController的PlayerCameraManager我们可以获得当前摄像机的视图投影矩阵View-Projection Matrix。这个矩阵封装了摄像机位置、朝向、视野FOV、近远裁剪面等所有信息是进行坐标变换的钥匙。反向投影Deproject我们需要进行一个“反向”操作。通常3D物体是通过矩阵变换投影到2D屏幕的。而现在我们要根据2D屏幕坐标反推出在3D世界空间中从摄像机出发穿过该像素点的射线方向。虚幻引擎提供了APlayerController::DeprojectScreenPositionToWorld这个便捷函数它内部就是完成了上述复杂的矩阵求逆运算直接输出射线的起点摄像机位置和方向向量。注意DeprojectScreenPositionToWorld函数中的屏幕坐标是视口相对坐标。这意味着坐标原点(0,0)在视口的左上角而右下角是(ViewportSize.X, ViewportSize.Y)。如果你从操作系统事件如Windows消息中获取的鼠标坐标是绝对坐标需要先减去游戏窗口在屏幕上的位置偏移才能传入此函数。2.2 虚幻引擎的碰撞查询与通道系统射线检测本质上是物理碰撞查询的一种特殊形式。虚幻引擎的碰撞系统非常强大其过滤机制是我们能精确控制射线“打中谁”和“忽略谁”的基础。碰撞预设Collision Presets与对象通道Object Channel每个UPrimitiveComponent如静态网格体、骨骼网格体都有一个Collision Preset。你可以把它理解为一个快速配置模板它定义了该组件属于哪种“类型”Object Type如WorldStatic, Pawn, PhysicsBody等以及它如何响应其他类型的碰撞或射线。对象通道就是这些“类型”。在项目设置 - 碰撞Project Settings - Collision中你可以看到预定义和自定义的Object Channels。追踪通道Trace Channel这是为射线检测、形状扫描Sweep和重叠检测Overlap专门设计的通道。常见的如Visibility用于可见性检测和Camera用于摄像机碰撞。在碰撞预设中你需要为组件设置其对每一个追踪通道的响应忽略Ignore、重叠Overlap、阻挡Block。对于射线检测我们通常关心的是**阻挡Block**响应。只有当射线使用的通道与目标组件对该通道的响应设置为Block时这次命中才会被记录。检测形状虽然我们常说“射线”但虚幻引擎的检测函数支持多种形状Line Trace最经典的射线一条无限细的线段。Sphere Sweep球形扫描想象成一个球体沿着线段移动。Box Sweep盒体扫描。Capsule Sweep胶囊体扫描。 后三种“扫描”方式可以用来实现更粗的、有体积的检测比如判断一个角色能否通过一个狭窄的通道。2.3 C API 函数族选择虚幻C提供了多层次、多形态的检测函数主要存在于UWorld和UKismetSystemLibrary中。UWorld::LineTraceSingleByChannel这是最常用、最底层的函数。它在世界场景中执行一次单点射线检测并通过追踪通道进行过滤。它提供了最丰富的输入输出参数控制。UKismetSystemLibrary::LineTraceSingle这是一个蓝图函数库Blueprint Function Library中的静态辅助函数。它内部调用了UWorld::LineTraceSingleByChannel但参数包装得更“蓝图友好”在C中使用也同样方便通常代码更简洁。“Multi” vs “Single”以LineTraceSingleByChannel为例Single表示只返回第一个阻挡Block命中的结果。对应的LineTraceMultiByChannel会返回射线路径上所有阻挡命中的结果按距离排序适用于需要穿透检测的情况比如计算子弹穿透多个物体的伤害。“ByChannel” vs “ByObjectType” vs “ByProfile”这是三种过滤方式。ByChannel最推荐、最清晰的方式。明确指定本次检测使用哪个Trace Channel依赖组件对该通道的响应设置。ByObjectType基于对象的Object Type进行过滤逻辑稍复杂不如通道直观。ByProfile使用预设的碰撞配置文件名称适合复杂但固定的碰撞规则。对于从屏幕开始的检测我们99%的情况使用LineTraceSingleByChannel或它的封装就足够了。3. 分步实现构建健壮的屏幕射线检测函数理论铺垫完成现在我们来搭建一个可在项目中随处调用的、健壮的C函数。我将它设计为一个静态辅助函数放在一个诸如GameplayFunctionLibrary的类中。3.1 第一步获取屏幕到世界的射线首先我们需要一个函数来将鼠标位置转换为世界空间中的射线。// 在 YourGameplayFunctionLibrary.h 中 UCLASS() class YOURGAME_API UYourGameplayFunctionLibrary : public UBlueprintFunctionLibrary { GENERATED_BODY() public: /** 将鼠标屏幕位置转换为一条从摄像机出发的世界空间射线。 * param PlayerController 玩家控制器。 * param ScreenPosition 鼠标在视口中的位置视口相对坐标。 * param OutWorldLocation 射线起点的世界位置通常为摄像机位置。 * param OutWorldDirection 射线方向的世界空间单位向量。 * return 是否成功转换。失败可能因为PlayerController无效、摄像机无效等。 */ UFUNCTION(BlueprintPure, Category Gameplay|Raycast) static bool GetMouseWorldRay(APlayerController* PlayerController, const FVector2D ScreenPosition, FVector OutWorldLocation, FVector OutWorldDirection); }; // 在 YourGameplayFunctionLibrary.cpp 中 bool UYourGameplayFunctionLibrary::GetMouseWorldRay(APlayerController* PlayerController, const FVector2D ScreenPosition, FVector OutWorldLocation, FVector OutWorldDirection) { if (!PlayerController) { return false; } // 使用DeprojectScreenPositionToWorld进行核心转换 return PlayerController-DeprojectScreenPositionToWorld( ScreenPosition.X, ScreenPosition.Y, OutWorldLocation, OutWorldDirection ); }实操心得这里我强烈建议将ScreenPosition参数类型定为FVector2D而非两个单独的float。因为现代输入系统如Enhanced Input和Slate UI框架返回的坐标通常是FVector2D这样封装接口更整洁。同时这个函数标记为BlueprintPure意味着它可以在蓝图中安全调用且没有副作用。3.2 第二步执行射线检测并处理结果有了射线起点和方向我们就可以进行检测了。我们将检测逻辑封装进另一个函数它内部会调用上一步的射线获取函数。// 在 YourGameplayFunctionLibrary.h 中继续添加 /** 从当前鼠标位置向世界发射一条射线检测。 * param PlayerController 玩家控制器。 * param TraceChannel 使用的追踪通道如ECC_Visibility, ECC_Camera。 * param MaxDistance 射线最大长度。 * param bTraceComplex 是否使用复杂碰撞精确但耗性能。 * param ActorsToIgnore 需要忽略的Actor数组。 * param OutHitResult 命中的结果。 * param bDrawDebug 是否绘制调试射线仅开发版本有效。 * param DebugDrawDuration 调试绘制持续时间。 * return 是否命中任何物体。 */ UFUNCTION(BlueprintCallable, Category Gameplay|Raycast) static bool MouseLineTraceSingleByChannel( const UObject* WorldContextObject, APlayerController* PlayerController, TEnumAsByteECollisionChannel TraceChannel, float MaxDistance, bool bTraceComplex, const TArrayAActor* ActorsToIgnore, FHitResult OutHitResult, bool bDrawDebug false, float DebugDrawDuration 2.0f ); // 在 YourGameplayFunctionLibrary.cpp 中实现 bool UYourGameplayFunctionLibrary::MouseLineTraceSingleByChannel( const UObject* WorldContextObject, APlayerController* PlayerController, TEnumAsByteECollisionChannel TraceChannel, float MaxDistance, bool bTraceComplex, const TArrayAActor* ActorsToIgnore, FHitResult OutHitResult, bool bDrawDebug, float DebugDrawDuration) { if (!PlayerController) { return false; } // 1. 获取当前鼠标的视口位置 float MouseX, MouseY; if (!PlayerController-GetMousePosition(MouseX, MouseY)) { // 获取鼠标位置失败可能鼠标未捕获或不在窗口内 return false; } // 2. 转换为世界空间射线 FVector WorldLocation, WorldDirection; if (!GetMouseWorldRay(PlayerController, FVector2D(MouseX, MouseY), WorldLocation, WorldDirection)) { return false; } // 3. 计算射线终点 FVector TraceEnd WorldLocation (WorldDirection * MaxDistance); // 4. 准备查询参数 FCollisionQueryParams QueryParams; QueryParams.bTraceComplex bTraceComplex; // 使用简单碰撞还是复杂碰撞 QueryParams.AddIgnoredActors(ActorsToIgnore); // 添加要忽略的Actor // 一个非常重要的技巧忽略发起检测的玩家自身的Pawn避免自瞄 if (APawn* ControlledPawn PlayerController-GetPawn()) { QueryParams.AddIgnoredActor(ControlledPawn); } // 5. 执行射线检测 UWorld* World GEngine-GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull); if (!World) { return false; } bool bHit World-LineTraceSingleByChannel( OutHitResult, WorldLocation, TraceEnd, TraceChannel, QueryParams // 查询参数 // 通常不需要额外的FCollisionResponseParams因为TraceChannel已经定义了过滤 ); // 6. 调试绘制仅在开发和非Shipping版本中有效 #if !UE_BUILD_SHIPPING if (bDrawDebug) { FColor DebugColor bHit ? FColor::Green : FColor::Red; float DebugThickness 2.0f; DrawDebugLine(World, WorldLocation, TraceEnd, DebugColor, false, DebugDrawDuration, 0, DebugThickness); if (bHit) { DrawDebugPoint(World, OutHitResult.Location, 10.0f, FColor::Yellow, false, DebugDrawDuration); DrawDebugSphere(World, OutHitResult.Location, 15.0f, 12, FColor::Blue, false, DebugDrawDuration); } } #endif return bHit; }关键点解析与避坑指南GetMousePosition的可靠性这个函数只在鼠标被游戏捕获例如在游戏模式下且未显示鼠标光标时才返回相对于视口的正确位置。如果你的游戏需要显示光标通常需要通过UI层的点击事件来获取坐标或者使用FSlateApplication::Get().GetCursorPos()并转换为视口坐标。这是一个常见的混淆点。AddIgnoredActors与自忽略忽略列表至关重要。最常见的就是要忽略玩家自己控制的角色ControlledPawn否则射线一发射就打在自己身上了。对于需要忽略多个Actor的情况如忽略队伍成员使用AddIgnoredActors批量添加非常高效。bTraceComplex的选择简单碰撞Simple Collision是网格体的简化碰撞体如包围盒、胶囊体计算快。复杂碰撞Complex Collision使用网格体的实际三角形面精度高但性能消耗大。对于游戏中的实时交互如拾取、瞄准99%的情况使用简单碰撞就足够了。除非你要做极其精确的模型点选如建模软件否则不要轻易开启。调试绘制使用DrawDebugLine和DrawDebugPoint是开发阶段验证射线行为的神器。务必用#if !UE_BUILD_SHIPPING宏包裹确保调试代码不会泄露到发布版本中影响性能。3.3 第三步在角色或控制器中调用与使用现在我们可以在玩家角色或玩家控制器中每帧或响应输入事件时调用这个函数。// 在 YourCharacter.cpp 的 Tick 函数或某个输入处理函数中 void AYourCharacter::Tick(float DeltaTime) { Super::Tick(DeltaTime); // 假设我们每帧都检测用于实时高亮瞄准的目标 FHitResult HitResult; TArrayAActor* ActorsToIgnore; // 这里可以添加需要永久忽略的Actor if (UYourGameplayFunctionLibrary::MouseLineTraceSingleByChannel( this, // WorldContextObject GetControllerAPlayerController(), // PlayerController ECollisionChannel::ECC_Visibility, // 使用Visibility通道 5000.0f, // 最大距离5000单位 false, // 不使用复杂碰撞 ActorsToIgnore, HitResult, true, // 绘制调试 0.0f // 持续到下一帧 )) { // 射线命中了物体 AActor* HitActor HitResult.GetActor(); UPrimitiveComponent* HitComponent HitResult.GetComponent(); // 示例处理命中逻辑 if (HitActor) { // 1. 判断是否是敌人 if (AEnemyCharacter* Enemy CastAEnemyCharacter(HitActor)) { // 高亮敌人或显示准星变化 OnEnemyAimedAt(Enemy); } // 2. 判断是否是可交互物体 else if (IInteractableInterface* Interactable CastIInteractableInterface(HitActor)) { // 显示交互提示 ShowInteractionPrompt(Interactable); } // 3. 获取命中点的详细信息 FVector HitLocation HitResult.Location; FVector HitNormal HitResult.Normal; float HitDistance HitResult.Distance; // ... 其他逻辑 } } else { // 射线未命中任何物体 ClearAimIndicator(); } }4. 高级应用与性能优化策略基础功能实现后我们可以根据项目需求进行深化和优化。4.1 应对不同输入场景的坐标获取如前所述GetMousePosition并非万能。我们需要根据游戏状态选择坐标来源。static bool GetScreenPositionForRaycast(APlayerController* PC, FVector2D OutScreenPos) { if (!PC) return false; // 场景1游戏模式鼠标光标隐藏如第一人称射击 if (!PC-bShowMouseCursor) { return PC-GetMousePosition(OutScreenPos.X, OutScreenPos.Y); } // 场景2游戏模式鼠标光标显示如RTS、策略游戏 else { // 通过视口获取鼠标位置更可靠 ULocalPlayer* LocalPlayer PC-GetLocalPlayer(); if (LocalPlayer LocalPlayer-ViewportClient) { FVector2D MousePixelPos; if (LocalPlayer-ViewportClient-GetMousePosition(MousePixelPos)) { // 将像素坐标转换为视口相对坐标0-1范围或实际分辨率范围需确认 // 通常GetMousePosition返回的就是视口空间坐标但最好查阅引擎版本的具体实现。 // 一个更通用的方法是使用Slate应用框架适用于有复杂UI的情况 // FSlateApplication::Get().GetCursorPos(); OutScreenPos MousePixelPos; return true; } } } // 场景3从UI点击事件传入坐标最精确 // 此场景下坐标应由UI层通过事件参数直接提供不在此函数内处理。 return false; }注意事项当游戏有复杂的UI层如UMG覆盖时从UI点击事件触发的射线检测其坐标应该直接使用UI事件提供的MouseEvent.GetScreenSpacePosition()并经过FSlateApplication::Get().GetCursorPos()和视口变换处理以确保坐标对应的是3D世界而非UI层。4.2 使用ECC_GameTraceChannelX自定义通道预定义的ECC_Visibility和ECC_Camera可能不够用。比如你希望有一条只检测可拾取物品的射线另一条只检测敌人的射线。项目设置中定义通道打开项目设置 - 碰撞Project Settings - Collision在Trace Channels下点击新建...创建一个名为Pickup的通道默认响应可以设为Ignore。配置网格体碰撞响应在可拾取物品的静态网格体或骨骼网格体组件的细节Details面板找到碰撞Collision部分。将Collision Presets设为Custom...然后找到你刚创建的Pickup通道将其响应设置为Block。同时确保Visibility通道的响应可能是Ignore或Overlap这样它就不会被普通的视线检测射线打中。在代码中使用自定义通道你需要找到这个通道的枚举值。它通常被定义为ECC_GameTraceChannel1到ECC_GameTraceChannel18。你可以在EngineTypes.h中查看其定义或者在代码中通过ECollisionChannel::ECC_GameTraceChannel1来引用。更好的做法是在你自己的头文件中用有意义的名称重新定义// 在 YourCollisionDefines.h 中 #define ECC_Pickup ECollisionChannel::ECC_GameTraceChannel1 #define ECC_Enemy ECollisionChannel::ECC_GameTraceChannel2然后在调用射线检测函数时将TraceChannel参数设为ECC_Pickup即可。4.3 性能优化避免每帧Tick检测在Tick中每帧进行射线检测如果玩家数量多或检测复杂可能成为性能瓶颈。优化策略包括按需检测只在玩家移动鼠标、按下特定按键如瞄准键或进行交互时触发检测而不是每帧。降低检测频率使用定时器FTimerHandle或自定义的更新循环每0.1秒或0.05秒检测一次对于大多数交互来说已经足够流畅。使用异步检测虚幻引擎提供了Async Line Trace异步射线检测。它可以将检测任务提交到物理线程避免阻塞游戏线程。这对于长距离、复杂形状的检测非常有用。核心函数是UWorld::AsyncLineTraceByChannel它通过委托Delegate来回调结果。// 异步检测示例 FCollisionQueryParams QueryParams; // ... 设置QueryParams FCollisionResponseParams ResponseParams; // ... 设置ResponseParams通常用默认的即可 TWeakObjectPtrAYourCharacter WeakThis(this); // 使用弱引用防止野指针 World-AsyncLineTraceByChannel( EAsyncTraceType::Single, // 单次命中 StartLocation, EndLocation, TraceChannel, QueryParams, ResponseParams, AYourCharacter::OnAsyncRaycastCompleted, // 静态回调函数 WeakThis // 传递给回调的用户数据 ); // 静态回调函数 void AYourCharacter::OnAsyncRaycastCompleted(const FTraceHandle TraceHandle, FOverlapDatum OverlapDatum) { if (AYourCharacter* Character static_castAYourCharacter*(OverlapDatum.UserData)) { // 处理检测结果 OverlapDatum.OutHits // 注意此回调可能在游戏线程之外触发如需修改游戏对象状态需用AsyncTask或委托排队到游戏线程执行。 } }警告异步回调可能不在游戏线程中执行。如果需要在回调中修改UObject如Actor、Component的状态必须使用AsyncTask或FFunctionGraphTask将操作派发回游戏线程否则会导致崩溃或数据竞争。5. 常见问题排查与实战技巧即使按照步骤操作你也可能会遇到射线“打不中”、“打错人”的情况。下面是我在项目中踩过的坑和解决方法。5.1 问题排查清单现象可能原因排查步骤与解决方案射线始终打不中任何物体1. 碰撞通道不匹配。2. 网格体没有碰撞体。3. 射线起点/终点计算错误。4. 检测距离太短。1.开启调试绘制这是第一步确认射线是否按预期方向和长度发射。2.检查碰撞设置在编辑器中选中目标物体查看其网格体组件的碰撞预设Collision Presets。确保它对所使用的TraceChannel的响应是Block而不是Ignore。3.检查碰撞体在视口中按AltC可以显示碰撞体。确认目标物体有可见的碰撞体绿色线框。如果没有需要在静态网格体编辑器中为其生成简单碰撞如自动凸包生成。4.打印坐标在转换后打印WorldLocation和WorldDirection确认数值合理。射线打中了自己玩家角色忽略列表ActorsToIgnore未包含玩家自身的Pawn。在FCollisionQueryParams中务必调用QueryParams.AddIgnoredActor(GetPawn())。这是最常见的疏忽之一。射线能穿透薄墙或物体1. 使用了LineTraceMulti但逻辑只处理了第一个结果。2. 碰撞体有缝隙或不是封闭的。3. 检测距离内第一个命中的是Overlap响应而非Block。1. 确认你使用的是LineTraceSingle需要阻挡还是LineTraceMulti。2. 检查碰撞体模型确保其能完全覆盖视觉模型。对于薄墙可以考虑使用双面碰撞或略微加厚碰撞体。3. 确认目标物体对通道的响应是Block。在特定视角或距离下检测不稳定1. 近/远裁剪面Near/Far Clip Plane影响。2. 使用bTraceComplex时性能波动或精度问题。1. 确保射线终点距离MaxDistance在摄像机的远裁剪面之内。Deproject函数理论上会处理但自定义射线时需注意。2.尽量避免在实时检测中使用bTraceComplextrue。如果必须用考虑对特定高精度物体单独处理或使用LOD系统为远距离物体切换为简单碰撞。从UI点击时射线位置不准屏幕坐标未正确转换为视口相对坐标。UI坐标通常是绝对的屏幕坐标。需要减去游戏窗口左上角在屏幕上的位置才能得到视口相对坐标。使用FSlateApplication::Get().GetCursorPos()并配合ViewportClient-ScreenToPixel等方法进行精确转换。5.2 实战技巧与心得为射线检测创建专用的“Gameplay Ability”或“Component”如果你的游戏有复杂的交互系统如多种武器瞄准、环境扫描、解谜不要把所有检测逻辑都塞进角色Tick里。可以创建一个URaycastComponent组件负责管理不同用途的射线检测战斗瞄准、交互、环境感知并对外提供清晰的事件委托Delegates如OnTargetHit,OnTargetLost。这样代码更模块化也便于网络同步如果组件被复制。利用HitResult的丰富信息FHitResult结构体是个宝库除了Actor和Component还有Location和ImpactPoint命中点的世界坐标。对于精确打击计算伤害或特效生成至关重要。Normal命中点的法线向量。用于计算子弹反弹、放置物体时对齐表面。Distance命中点距离射线起点的距离。用于计算伤害衰减、判断命中优先级。BoneName对于骨骼网格体命中了哪个骨骼。在实现部位伤害爆头时必不可少。Item对于实例化静态网格体命中了哪个实例。用于大规模放置物体的精确交互。组合使用不同形状的检测不要局限于LineTrace。例如拾取物品可以用SphereSweep在角色前方一个小范围内扫描这样玩家不需要精确瞄准体验更友好。近战攻击用BoxSweep或CapsuleSweep模拟武器的挥砍体积。子弹散射从枪口发射多条轻微随机方向的LineTrace来模拟霰弹枪或精度不高的武器。网络游戏中的射线检测在多人游戏中射线检测必须在服务器端进行权威计算以防止客户端作弊。客户端可以本地进行检测并显示预测性特效如命中火花但最终的命中判定、伤害计算必须由服务器执行并将结果同步给所有客户端。通常客户端将输入如开火时的视角方向、时间戳发送给服务器服务器在相同的逻辑帧和状态下重演射线检测。实现一个健壮的、从屏幕开始的射线检测系统是虚幻C gameplay编程的基石之一。它远不止是调用一个函数而是对引擎的摄像机系统、坐标空间、物理碰撞和性能管理的一次综合实践。从理清坐标转换原理到精心配置碰撞通道再到处理各种边界情况和性能优化每一步都需要仔细考量。希望这篇详尽的拆解能让你在下次实现“指哪打哪”的功能时心中更有底气代码更加稳健。记住多利用调试绘制功能它是你验证逻辑、排查问题最直观的伙伴。
返回列表