ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UE5射线检测原理:按通道与按对象类型如何决定碰撞命中

UE5射线检测原理:按通道与按对象类型如何决定碰撞命中 做 UE5 项目做到中后期你会发现蓝图里物理查询节点翻来覆去就那么几个LineTraceByChannel按通道执行线条追踪、LineTraceByObjectType按对象类型执行线条追踪、LineTraceByProfile按碰撞预设执行线条追踪。它们长得几乎一模一样参数区别在很多人眼里就是一个下拉框、一个数组、一个名字于是选节点全看心情先试按通道打不到就切按对象类型再打不到就怀疑碰撞没开。我早期也这么干直到某天做视线检测按 Visibility 通道看不到草丛里的道具换成ByObjectType之后墙居然也被看到了才下决心把两套逻辑从源码层面完整捋了一遍。这篇是 UE5 C 和蓝图对照的射线检测系列中的一节系列 44-4目标是把按通道和按对象类型这两条路径彻底说透蓝图节点背后各自调用了什么 C 函数FCollisionQueryParams和FCollisionObjectQueryParams是怎么构建的物理层最终的过滤依据是什么项目设置的碰撞响应矩阵到底在哪个环节起作用。适合正在学 UE5 C 的开发者也适合已经在蓝图上做功能但想搞清楚为什么它命中了不该命中的东西的人。先给结论方便你带着答案往下看按通道检测本质是查询方声明我要打哪个追踪通道物理层拿被检测物体的碰撞响应矩阵去对照该通道列Block 才会挡按对象类型检测本质是查询方声明我接受哪些物体类型物理层把物体的类型和查询掩码做位运算只要类型在集合里、物体自身碰撞允许被查询命中不管响应矩阵里各个追踪通道列怎么配都可能被命中。下面从蓝图节点一路拆到物理层。1. 蓝图里的三个射线节点看着是三胞胎参数设计却藏着本质区别1.1 入口参数对比下拉框、数组、名字各代表什么在蓝图里搜索 line trace 或 ray核心的单次检测节点就是这三个。把它们并排放在一起看最直观的区别在查询依据这个参数上节点查询依据参数背后的 C 函数BlueprintCallable过滤逻辑Line Trace by ChannelTrace ChannelETraceTypeQuery下拉框UKismetSystemLibrary::LineTraceSingle单条追踪通道Line Trace by Object TypesObject TypesTArrayEObjectTypeQuery数组UKismetSystemLibrary::LineTraceSingleByObjectType对象类型位掩码Line Trace by ProfileProfile Name碰撞预设名字UKismetSystemLibrary::LineTraceSingleByProfile物体自己的响应容器除了这个查询依据参数三者的其余输入几乎一模一样Start / End 定义线段起点终点bTraceComplex决定用简单碰撞还是复杂碰撞Actors to Ignore 忽略指定 ActorIgnore Self 忽略调用者自己Draw Debug Type 控制调试绘制最后输出一个FHitResult Out Hit。注意节点设计上的一个细节ByChannel只给一个下拉框因为通道查询在语义上是原子的——你一次就想打一个通道ByObjectType给的是一个数组因为它天然支持我既要打 Pawn也要打 WorldDynamic这种多类型目标而ByProfile给的是名字因为预设本身已经封装了一整套响应关系。这个差异不是随手设计的它直接反映了两者在底层数据上的组织方式。1.2 输出 Out HitFHitResult 里到底排了哪些信息无论用哪个节点你拿到的都是同一个FHitResult结构。这里有一个新手最容易忽略的点Time 不是距离而是线段上的 0 到 1 百分比。你自己算一下距离的话是(End - Start).Size() * Time。蓝图里如果直接拿 Time 当成距离用射线越长误差越大。FHitResult里几个常用字段的准确定义bBlockingHit是否为阻挡命中。通道查询和对象类型查询默认都是阻塞查询返回的最近命中通常是bBlockingHit true的阻挡点。bStartPenetrating起点已经在某个碰撞体内部时会变成 true此时 Time 为 0Location 等于起点。这对判断是否从墙里开枪特别有用。Location与ImpactPoint纯射线零半径时两者基本一致如果你用的是 SphereTrace、CapsuleTrace 这类有半径的扫描Location 是扫描体中心移动到的位置ImpactPoint 是真正接触点两者会有明显偏差。Actor、Component、BoneName谁被击中了、打在哪个组件/骨骼上。做人物命中反馈时一定要看 BoneName不然头、胸、腿全按同一个点处理。PhysMat命中的物理材质。蓝图节点默认会帮你把这一步配好C 里要手动设置bReturnPhysicalMaterial true做音效和弹孔贴花时要用它。两个节点的FHitResult输出完全一样真正的区别只发生在它之前的那一层过滤。接下来进入源码。2. 按通道检测的完整源码链路从蓝图的 ETraceTypeQuery 到 UWorld::LineTraceSingleByChannel2.1 蓝图节点的 C 入口长什么样蓝图节点不是黑魔法它对应的就是KismetSystemLibrary.cpp里的一个静态函数。LineTraceByChannel节点在 C 侧叫UKismetSystemLibrary::LineTraceSingle它的实现骨架大致是这样示意代码不同 UE 版本细节略有出入但结构稳定// KismetSystemLibrary.cpp示意非逐行拷贝 bool UKismetSystemLibrary::LineTraceSingle( UObject* WorldContextObject, const FVector Start, const FVector End, ETraceTypeQuery TraceChannel, bool bTraceComplex, const TArrayAActor* ActorsToIgnore, EDrawDebugTrace::Type DrawDebugType, FHitResult OutHit, bool bIgnoreSelf, FLinearColor TraceColor, FLinearColor TraceHitColor, float DrawTime) { UWorld* World GEngine-GetWorldFromContextObject( WorldContextObject, EGetWorldErrorMode::LogAndReturnNull); if (!World) { return false; } // 关键步骤 1把蓝图的枚举翻译成物理层通道 ECollisionChannel CollisionChannel UEngineTypes::ConvertToCollisionChannel(TraceChannel); // 关键步骤 2构建查询参数 FCollisionQueryParams Params(TEXT(LineTraceSingle), bTraceComplex); Params.bReturnPhysicalMaterial true; if (bIgnoreSelf) { if (const AActor* SelfActor CastAActor(WorldContextObject)) { Params.AddIgnoredActor(SelfActor); } } for (const AActor* IgnoreActor : ActorsToIgnore) { Params.AddIgnoredActor(IgnoreActor); } // 关键步骤 3把查询丢给 UWorld bool bHit World-LineTraceSingleByChannel( OutHit, Start, End, CollisionChannel, Params, FCollisionResponseParams(ECR_Block)); // 后续按 DrawDebugType 画线/画点 return bHit; }这里三个关键步骤对应了三个问题蓝图枚举怎么变通道、查询参数里装了什么、ECR_Block是什么意思。FCollisionQueryParams里面装的东西很多对你影响最直接的几个是bTraceComplex用简单碰撞还是三角网格级碰撞、bReturnPhysicalMaterial要不要返回物理材质、以及忽略列表。注意bIgnoreSelf在 C 侧默认不会自动忽略调用者必须拿到 WorldContextObject 对应的 Actor 手动AddIgnoredActor。蓝图节点把这个选项暴露成勾选框很贴心但你如果不是通过这个库函数而是直接调UWorld的接口千万别漏了这一步不然主角开枪第一个弹孔通常在自己脑门上。2.2 ConvertToCollisionChannel蓝图枚举到物理通道的桥ETraceTypeQuery是蓝图层的 UENUMECollisionChannel是物理引擎层的老牌枚举。中间翻译的函数就是UEngineTypes::ConvertToCollisionChannel。物理层的ECollisionChannel枚举是 UE 碰撞体系的根它长这样核心部分enum ECollisionChannel { ECC_WorldStatic 0, ECC_WorldDynamic 1, ECC_Pawn 2, ECC_Visibility 3, // 内建追踪通道之一 ECC_Camera 4, // 内建追踪通道之一 ECC_PhysicsBody 5, ECC_Vehicle 6, ECC_Destructible 7, ECC_EngineTraceChannel1 ... ECC_EngineTraceChannel12, // 引擎预留 ECC_GameTraceChannel1 ... ECC_GameTraceChannel18, // 项目自定义通道 ECC_MAX };蓝图层的ETraceTypeQuery前两项固定对应Visibility和Camera从第三项开始对应你在 Project Settings 里新建的自定义 Trace Channel底层就是ECC_GameTraceChannel1到 18。EObjectTypeQuery同理前六项对应 WorldStatic、WorldDynamic、Pawn、PhysicsBody、Vehicle、Destructible后面隐藏项对应自定义对象类型。ConvertToCollisionChannel做的就是把这套蓝图枚举翻译回物理层的通道编号不同引擎版本内部实现可能有数组表或直接强转的差异但对外行为一致最终你拿到的一定是一个ECollisionChannel级别的通道编号。这也是为什么你新建的自定义追踪通道在蓝图节点下拉框里会直接出现并显示你起的名字——它就是被翻译成了ECC_GameTraceChannelN然后在物理层参与过滤。2.3 物理层过滤规则读的是碰撞矩阵里的追踪通道列UWorld::LineTraceSingleByChannel拿到通道号后会把通道、响应参数打包成查询过滤器数据交给 PhysicsScene 走 broadphase 和 narrowphase。到 Chaos / Physics 的窄相过滤时判断逻辑可以简化为这样// Chaos 窄相 PreFilter示意 bool PreFilter(const FShapeFilterData ShapeData, const FQueryFilterData QueryData) { if (QueryData.bObjectTypeQuery) { // 按对象类型查询走另一条路见下一章 return (ShapeData.ObjectType QueryData.ObjectTypeMask) ! 0; } // 按通道查询直接查这个物体对查询通道列的响应 ECollisionResponse Response ShapeData.Responses[QueryData.TraceType]; return Response ECR_Block; // 只有 Block 才会作为阻挡命中返回 }也就是说按通道检测时你声明的是我要打的是这个通道然后物理层反过来查每个碰撞体对这个通道列设置的响应值。这个响应值来自哪里来自 Project Settings 里的碰撞响应矩阵它保存在每个碰撞体组件关联的碰撞预设上。FCollisionResponseParams(ECR_Block)这个参数的含义是告诉物理层这是一次阻塞查询请按 Block 语义返回结果。它把查询方声明为一个默认对所有通道都要求 Block 的查询者真正决定能不能命中还是看被查询物体那一行的响应矩阵数据。3. 按对象类型检测的本质FCollisionObjectQueryParams 的位掩码游戏3.1 蓝图数组怎么被翻译成一个整数掩码LineTraceByObjectTypes节点对应的 C 函数是UKismetSystemLibrary::LineTraceSingleByObjectType。它和按通道的区别集中在一个对象上FCollisionObjectQueryParams。// KismetSystemLibrary.cpp示意 bool UKismetSystemLibrary::LineTraceSingleByObjectType(...) { UWorld* World GEngine-GetWorldFromContextObject(...); if (!World) { return false; } // 关键把蓝图数组翻译成查询掩码 FCollisionObjectQueryParams ObjectParams; for (const TEnumAsByteEObjectTypeQuery ObjectType : ObjectTypes) { ECollisionChannel CollisionChannel UEngineTypes::ConvertToCollisionChannel(ObjectType); ObjectParams.AddObjectTypesToQuery(CollisionChannel); } bool bHit World-LineTraceSingleByObjectType( OutHit, Start, End, Params, FCollisionResponseParams(ECR_Block), ObjectParams); return bHit; }而FCollisionObjectQueryParams内部本质就是一个把通道编号当座位号的位掩码struct FCollisionObjectQueryParams { int32 ObjectTypesToQuery; // 位掩码每一位代表一个对象类型通道 void AddObjectTypesToQuery(ECollisionChannel QueryChannel) { if (QueryChannel ECC_MAX) { ObjectTypesToQuery | (1 QueryChannel); // 把对应位置 1 } } };蓝图里你往 Object Types 数组里塞一个 WorldDynamic引擎就把 WorldDynamic 对应的通道位比如 Bit 1置 1塞一个 Pawn就把 Pawn 对应的位Bit 2置 1。最后合成一个整型掩码物理层做过滤时只需要一次位运算(ShapeData.ObjectType QueryData.ObjectTypeMask) ! 0这就是为什么按对象类型查询可以一次查多个类型而且性能上并不比单通道查询差多少——数组被压成一个整数窄相过滤就是一条位运算指令的事。注意这里最容易出问题的地方蓝图节点这个数组默认往往是空的。空数组意味着ObjectTypesToQuery是 0任何物体的类型位和 0 做与运算都是 0于是查不到任何东西。我见过不止一次有人连线之后发现射线什么都打不到最后发现 Object Types 数组里一个类型都没加。3.2 掩码命中之后物理层还看什么类型位匹配之后物理层不会直接判命中还会检查碰撞体自身的可查询状态CollisionEnabled必须是QueryOnly或PhysicsAndQuery。如果某个物体设成了PhysicsOnly只参与模拟不接受查询两种射线检测都看不到它。物体的响应容器里不能是整体不响应。这里就有意思了按对象类型查询不去查某个追踪通道列它只看物体的类型是否在掩码里。你在响应矩阵里把这个物体对 Visibility、Camera、所有自定义追踪通道都设成 Ignore按通道查询它一个都打不到但按对象类型查询只要类型对上了照样命中。这一点是两套系统最根本的分水岭。简单说按通道读的是列按对象类型读的是行里的类型标签前者受矩阵配置严格控制后者几乎不关心矩阵里那些通道列的配置。3.3 空数组、全部类型、自定义对象类型的细节空数组的问题上面说了。反过来如果你想按对象类型查询所有能挡的东西蓝图里没有一键全选按钮只能手动把六个默认类型都勾上C 侧比较省事新版本引擎的FCollisionObjectQueryParams提供了一个AllObjects的静态实例相当于所有对象类型都查用之前最好翻一下本地头文件确认。自定义对象类型也走同一套机制。在 Project Settings 里新建 Object Type 后它会被分配一个ECC_GameTraceChannelN编号蓝图节点的 Object Types 数组里就会出现带名字的新项。但有个隐藏约束对象类型和追踪通道共用同一批ECC_GameTraceChannel1到 18 的编号池。你把一个通道用掉了它就不能再作为对象类型使用反之亦然。所以项目里通道规划要趁早做中后期加自定义类型时经常会发现编号不够用被迫挤掉已有的命名。4. 碰撞矩阵才是真正的分水岭什么时候 ByObjectType 会命中你不想命中的东西4.1 项目设置里的响应矩阵行和列分别是谁打开 Project Settings → Collision你会看到一张著名的碰撞响应矩阵。很多人在里面改了几次数值但没意识到这张表在物理层究竟怎么读。矩阵的行是物体是什么——也就是 Object Types对象类型。一个静态网格体组件把自己标记为 WorldStatic它就在 WorldStatic 这一行里。矩阵的列可以分为两块左边是 Object Types 列右边是 Trace Channels 列。一个物体 A 碰撞另一个物体 B 时读的是 A 的类型行和 B 的类型列的交点一条射线按某个通道查询时读的是物体行和追踪通道列的交点。换句话说追踪通道列基本就是为射线、扫掠这类 query 准备的只读字典它描述你这个类型的物体对来自这个通道的查询是 Ignore、Overlap 还是 Block。按通道检测之所以语义干净就是因为它把什么物体能被我打到这件事集中到了这张矩阵里所有查询方统一读同一列谁改谁负责。4.2 一个具体例子Ignore 了一切追踪通道的墙假设场景里有一堵墙类型是 WorldDynamic别抬杠我就是故意这么设的美术在它的碰撞预设里把所有 Trace Channels 都设成了 Ignore目的是让它不挡玩家视线、不挡摄像机、不挡准星检测。这时你用它做实验按 Visibility 通道打一道射线打不到墙因为矩阵里 WorldDynamic 行对 Visibility 列是 Ignore。按 Camera 通道打同样打不到。按自定义 Weapon 通道打打不到因为预设里 Weapon 列也是 Ignore。一切符合预期。然后你换成按对象类型查询Object Types 数组里加了 WorldDynamic墙被命中了。尽管它的响应矩阵对每个追踪通道都是 Ignore但对象类型查询根本不关心追踪通道列它只看物体类型是不是 WorldDynamic是而且碰撞是 Query 可用的那就命中。这不是 bug而是两套语义的差异。很多团队第一次遇到这种幽灵命中都会懵最后大多是把本不该被武器打到的物体单独用一个自定义 Object Type 标记或者改用通道查询并规范矩阵配置。结合我自己的经验如果项目里既有视线检测、又有摄像机碰撞、又有武器射线请务必为它们各建一个自定义 Trace Channel。混用 Visibility 做武器射线是新手最常见的埋雷方式因为美术/策划优化视野时会顺手把很多物体的 Visibility 设成 Ignore你的子弹就会跟着一起穿模。4.3 顺带把 Line Trace by Profile 也讲清楚ByProfile是第三个容易混淆的节点。它不填通道下拉框也不填类型数组只填一个碰撞预设名字比如 Pawn、 WorldStatic 或你的自定义 Profile。它查询时物理层直接取被检测物体自己的响应容器看这个物体整体上对查询方是什么态度。ByProfile适合的场景是我不想规定物体的类型也不想规定通道我只想让每个物体用它自己组件上的预设决定一切。它的问题也很明显查询方缺少控制力。物体预设把它自己设成谁都能打那你的射线就能打它设成谁也不理那就打不到。所以ByProfile一般在纯蓝图项目或者组件预设管理比较规范的项目里常用C 项目里我还是更推荐显式通道或显式类型数组至少调用方说的话能算数。5. 实战选型建议与踩坑清单5.1 我的选型经验按语义选不按顺手选经过源码层面的对比我在实际项目里的选型规律基本固定下来了检测需求推荐方案理由摄像机视距、玩家视线、AI 可见性ByChannel用 Visibility 或自定义 Sight 通道想让关卡美术/策划能通过矩阵统一控制什么能挡住视线武器子弹、近战判定ByChannel用自定义 Weapon 通道子弹语义应该是被矩阵严格管制的避免和视野优化互相污染技能范围、AoE 选择、采集拾取ByObjectType类型数组精确列出目标我想打这几类东西是最自然的表达不依赖矩阵配置目标锁定、索敌辅助ByObjectTypePawn / Vehicle视项目而定通常和视觉表现解耦按类型过滤更直观高精度命中头、四肢任意方式 bTraceComplex true 或 SphereTrace简单碰撞下胶囊体/盒体表面不等于网格表面需要大量额外过滤逻辑C 直接调UWorld的 LineTrace 接口蓝图库函数可定制性有限且每次调用都有蓝图开销一个很容易被忽略的经验通道检测查的是物体的响应矩阵对象类型检测查的是物体的类型集合。当你的检测目标是关卡里任何物理上能挡我的东西时别人通常会问你是想让它挡还是不想让它挡你想清楚这个问题选型就完成了大半。5.2 踩坑记录空数组、忽略自身、复杂碰撞、调试绘制我把开发中真踩过、也帮别人排查过的坑集中列一下Object Types 数组留空前面说过掩码为 0射线永远空手而归。蓝图连好线后第一件事就是检查数组里有至少一个类型。bIgnoreSelf 没生效在蓝图节点里这是个勾选框但如果你在 C 里直接调用UWorld接口而不是KismetSystemLibrary必须自己加忽略。否则角色自身碰撞体大的时候射线一出手就是自己。bTraceComplex 默认 false大多数人的第一道射线都是简单碰撞。如果你的检测对象是地形网格、树木这类带复杂碰撞的资产简单碰撞的命中点会明显偏离你眼睛看到的面。要做精确的弹孔、贴花检测记得把 bTraceComplex 打开或者改用球形扫描补精度。用 ByChannel 但目标物体矩阵里对应通道是 Ignore这是打不到里面最隐蔽的原因。排查时先点开目标物体组件看它的碰撞预设确认你查询的通道列是 Block。通道混用Visibility 被同时用来做摄像机、视线、甚至武器检测结果某个物体为了不挡视野设了 Ignore子弹也跟着穿过去。先建独立通道从根上避免。Multi 和 Single 语义搞混LineTraceMulti返回的是从起点到终点按距离排序的所有命中而 Single 只返回最近的那个阻塞命中。做穿透射击、连锁检测时用 Multi做普通拾取、瞄准时用 Single别用 Multi 然后只取 Index 0容易出现顺序理解错误。5.3 排查技巧如何快速确认一次查询到底命中了什么调试射线检测我一般按下面这个顺序来第一把节点的 Draw Debug Type 从 None 改成 For Duration并把 Duration 设到 2 到 3 秒。引擎会直接画出射线和命中点命中点显示一个小球。这一步能立刻排除根本没发射这种低级问题。第二把 Out Hit 拖出来打印GetActorName()和Component的名字。如果命中点看起来在物体附近但 Actor 是空的那大概率是命中了地形代理碰撞或者某个看不见的碰撞体。第三在 C 里加一条日志UE_LOG(LogTemp, Warning, TEXT(Hit Actor: %s, Component: %s, Bone: %s, Blocking: %d, Time: %.3f), *GetNameSafe(Hit.GetActor()), *GetNameSafe(Hit.GetComponent()), *Hit.BoneName.ToString(), Hit.bBlockingHit, Hit.Time);日志会把问题从感觉不对变成数据不对再回到矩阵或碰撞预设上去改就有据可依了。第四怀疑碰撞矩阵问题时不要凭记忆判断。打开 Project Settings → Collision横竖两轴都找到对应的行和列看交点值。矩阵里一个格子写的是 Ignore那无论哪种查询方式它的行为都有明确依据——无非是按通道直接不读它按对象类型根本不看它。我现在的项目习惯是凡是行为语义明确的检测一律建自定义 Trace ChannelWeapon、Sight、Interaction 各一个凡是我就想直接指定打哪几类东西的检测用 ByObjectType。两者混用的时候一定要把通道命名、对象类型用途写进项目文档。否则团队里谁随手把 Visibility 复用到武器上或者把某个道具的类型改成 WorldDynamic 图省事后面就是一连串幽灵命中和子弹穿墙排查起来比当初写好文档费劲得多。
返回列表