尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

UE5 C++多人射击:射线检测与命中点网络同步优化实践

发布时间:2026/9/28 23:28:58

资讯中心
01
ARTICLE

UE5 C++多人射击:射线检测与命中点网络同步优化实践

UE5 C++多人射击:射线检测与命中点网络同步优化实践
刚在项目里调一个手感问题联机射击时服务器上的射线检测明明命中了掩体客户端拉回来的弹孔却飘在半空跟实际瞄准点差出好几个厘米。查到最后问题出在两个平时不太起眼的技术点上射线检测的LinetraceByChannel/LinetraceByObjectType什么时候用哪个以及命中点这类Vector在网络同步里到底该用什么类型承载。如果你也在写 UE5 C 多人玩法这篇东西值得看完里面是我实际踩坑后整理的经验。1. 击中判定背后的那套碰撞体系被大多数人忽略的预设结构1.1 一个 Actor 的碰撞配置ObjectType 与 Channel 响应矩阵在UE5里每个参与碰撞的 Actor比如角色、场景网格体、可拾取物身上都有一个CollisionComponent对应的碰撞行为全部集中在它的 Collision Preset 里。打开任意一个 Primitive Component 的 Details 面板你会看到两个关键区块最上面是Collision Enabled往下是Object Type再往下是Collision Responses里针对各个 Channel 的Ignore / Overlap / Block设置。这里先别急着跳过。很多人把 ObjectType 和 Channel 当成同一个东西实际差很远。ObjectType 定义的是“我这个 Actor 是什么种类”比如 Pawn、WorldStatic、WorldDynamic、PhysicsBody、Destructible。Channel 则定义的是“我这个 Actor 对外界查询/碰撞的响应方式”而这个响应是被碰撞预设里那个长长的矩阵控制的。举个例子一个 Static Mesh ActorObjectType 设为 WorldStatic它对 Pawn 通道设置 Block对 Visibility 通道设置 Ignore对 Camera 通道设置 Block。那么当你用ECC_Pawn发起一条射线这个物体能挡住它当你用ECC_Visibility发起射线它会直接透过去。同一个 Actor对不同通道的“存在感”完全不一样。这是理解后面两个 Trace 函数的基础。1.2 Channel 回答“撞不撞”ObjectType 回答“你是谁”用一句大白话总结Channel 是小区门禁的设备ObjectType 是业主身上贴的身份标签。射线用 Channel 查询是在问“按照这个通道的门禁规则沿途有没有会阻挡我的物体”射线用 ObjectType 查询是在问“沿途有没有我想找的那几类东西不管它对外部通道的响应是什么”。这就是为什么做射击玩法时我强烈建议先把这些概念拆清楚。很多新手遇到“射线明明该命中却打空了”的问题十有八九不是射线代码写错了而是被检测 Actor 对当前 Trace Channel 设了 Ignore。你对着它打它直接当你不存在。2. LineTraceByChannel 逐字拆解从参数到命中信息2.1 C 里怎么发起一次射线检测C 里最常用的射线接口在UWorld上跟蓝图里的LineTraceByChannel节点对应。一个完整的调用大概是这样的// 在某个 Actor 或 Character 里 UWorld* World GetWorld(); if (!World) { return; } FHitResult Hit; FVector Start GetActorLocation(); FVector End Start GetActorForwardVector() * 5000.0f; FCollisionQueryParams QueryParams; QueryParams.AddIgnoredActor(this); // 排除自己 QueryParams.bTraceComplex false; // 默认用简单碰撞 QueryParams.bReturnPhysicalMaterial false; // 需要物理材质时打开 bool bHit World-LineTraceByChannel( Hit, Start, End, ECC_Visibility, // 通道按需换 ECC_Camera / ECC_Pawn / ECC_WorldDynamic QueryParams ); if (bHit Hit.bBlockingHit) { // 命中点在 Hit.ImpactPoint命中的 Actor 在 Hit.GetActor() }这里有个容易踩的坑LineTraceByChannel的返回值只代表“有没有找到碰撞”不代表“命中了一个有效的 Actor”。有些情况会返回 true 但Hit.Actor是空比如命中了一个没有 Actor 的组件实际项目里我习惯同时检查bHit Hit.bBlockingHit Hit.Actor.IsValid()三层保险。2.2 FCollisionQueryParams 里值得在意的几个开关AddIgnoredActor把发起者自己加进忽略列表。如果你射线的起点在角色身上不加这一行射线大概率直接打中自己的 Capsule然后你的弹孔永远长在脸上。bTraceComplex默认 false表示用简单碰撞Primitive 的凸包/包围盒做检测设为 true 会去检测 StaticMesh 上的复杂碰撞三角网格级。前者快、适合绝大多数玩法后者准、但开销高只建议在需要精确打到头部三角面等场景打开。bReturnPhysicalMaterial返回物理材质时打开之后做弹孔贴花、脚步声、材质判定都会用到。bReturnFaceIndex需要知道命中面索引时打开比如做贴花对齐或者自定义疤痕效果。这些开关看着不起眼实际对性能影响很大。一个 20 人对战的场景如果每个人都对场景发出 10 条启用复杂碰撞的射线每帧的碰撞开销会肉眼可见地拉高帧率。2.3 读透 FHitResult哪些字段值得存下来发完射线后最重要的就是FHitResult里面的值。我平时最常读的是这两个组合字段含义使用场景Hit.bBlockingHit是否发生了阻挡式命中判断这次 trace 是否有效Hit.Actor/Hit.GetActor()命中的 Actor伤害计算、命中对象逻辑Hit.Component命中的组件判断命中部位Hit.ImpactPoint射线与物体表面的实际交点位置同步、特效坐标Hit.ImpactNormal交点处的表面法线贴花朝向、反弹方向Hit.Time命中点在射线中的归一化位置0~1计算距离Hit.Distance起点到命中点的距离衰减、音效音量Hit.BoneName骨骼网格命中到的骨骼名分部位伤害Hit.FaceIndexbTraceComplex 开启时才有意义面级效果注意ImpactPoint和Location的区别。在射线检测里这俩几乎一样但在碰撞扫掠里不同ImpactPoint是实际接触的那个点Location可能是被模拟对象在扫掠结束后的中心位置。我见过有人把Hit.Location当命中点同步到客户端结果弹孔位置偏了很大一段后来改成ImpactPoint才正常。2.4 调试可视化让射线“看得见”写射线检测一定要配合调试绘制否则全靠猜。C 里最简单的方案DrawDebugLine(World, Start, End, FColor::Red, false, 2.0f, 0, 1.0f); if (bHit) { DrawDebugPoint(World, Hit.ImpactPoint, 10.0f, FColor::Yellow, false, 2.0f); }DrawDebugLine只在游戏内绘制方便你在打包版本里也能快速观察。但要小心发布版本里如果忘记移除会有大量绘制开销。我的习惯是包一个#if UE_ENABLE_DEBUG_DRAWING宏或者项目自定义的CVar线上默认关闭。3. LineTraceByObjectType按“物品类型”筛选才是干净做法3.1 C 调用方式ObjectType 查询的核心是FCollisionObjectQueryParams它负责声明“我要查询哪些类型的物体”。写法如下UWorld* World GetWorld(); FHitResult Hit; FVector Start GetActorLocation(); FVector End Start GetActorForwardVector() * 3000.0f; FCollisionObjectQueryParams ObjectParams; // 只查 Pawn 和 WorldDynamic其他一律忽略 ObjectParams.AddObjectTypesToQuery(ECC_Pawn); ObjectParams.AddObjectTypesToQuery(ECC_WorldDynamic); FCollisionQueryParams QueryParams; QueryParams.AddIgnoredActor(this); bool bHit World-LineTraceByObjectType( Hit, Start, End, ObjectParams, QueryParams );注意参数顺序LineTraceByObjectType把FCollisionObjectQueryParams放在前面FCollisionQueryParams放在后面别跟LineTraceByChannel记混了。3.2 指定多个类型而不是被迫绑定某个通道LineTraceByObjectType最大的优势是你不用关心被检测 Actor 对某个 Trace Channel 的响应如何配置。它只看这个 Actor 的 ObjectType 在不在你的查询集合里以及它的碰撞是否启用。这意味着“我只想打角色不想打场景”这种需求用一个 ObjectType 查询就能干净地表达不需要为每个 Actor 调整碰撞预设。我在做 NPC 视线检测时经常这么用只查ECC_Pawn这样场景里的墙壁、箱子不管怎么配置都不会干扰视线判断。如果用ECC_Visibility通道做还得时刻盯着场景里所有物体的 Visibility 响应维护成本非常高。3.3 什么时候用 Channel、什么时候用 ObjectType一张对照表维度LineTraceByChannelLineTraceByObjectType判断依据目标 Actor 对当前 TraceChannel 的响应Block/Overlap/Ignore目标 Actor 的 ObjectType 是否属于查询集合代表问题“这条射线上按照门禁规则谁会挡住我”“这条射线上有没有我关心的那几类东西”适合场景通用物理世界检测依赖碰撞预设的通道规则按类型筛选玩家、敌机、可破坏物、物品配置复杂度需要被检测物体对相关通道正确配置 Block只需要被检测物体设置了正确的 ObjectType典型误区物体对通道设了 Ignore导致射线穿透想找多个类型时没全加进查询集合性能特点按通道响应过滤可能更符合预期按类型过滤避免通道响应干扰我自己的经验是物理射击命中、场景交互、贴花投影这类“需要和整个世界发生物理关系”的玩法用 ByChannel而角色锁定、视线追踪、AI索敌这类“只对某些类型感兴趣”的玩法用 ByObjectType。两者不是替代关系是分工关系。3.4 Object Channel 和 Trace Channel 的上限与自定义在 Project Settings - Engine - Collision 里Object Channels 和 Trace Channels 都可以自行添加。每添加一个ECC_GameTraceChannel1之类的 Trace Channel项目里就会多一个可选的响应列。自定义 ObjectType 同理。这里有两条经验Trace Channel 的数量不是无限的加多了会比较乱。我一般只维护两三个业务通道比如“子弹通道”“视线通道”“镜头通道”其余需求用 ObjectType 解决。自定义 Trace Channel 的枚举值在项目迭代中尽量别删除否则旧的碰撞预设会错乱。如果实在要删提前排查所有 BP 和 C 里的引用。4. 命中位置值不值得直接同步FVector_NetQuantize 的量化逻辑4.1 为什么普通 FVector 在网络同步里“不够用”多人项目的网络带宽是硬约束。一个FVector属性复制底层是三个 float加起来 12 字节。看起来不多但如果每帧同时在同步多个命中点、投掷物轨迹、玩家位置、载具速度累积起来就非常可观。更重要的是float 精度在不同客户端上可能产生细微差异。服务器计算出的ImpactPoint可能是(1234.5678, 5678.1234, 88.4456)这个精度对游戏表现来说完全没必要——玩家根本看不出 0.0001 单位的差别。直接同步它会带来两个问题一是带宽浪费二是因为浮点计算顺序不同客户端拿到的值可能和服务器差出一点导致弹孔偏移。于是就有了网络量化把连续坐标映射到离散的整数网格上只传输必要精度的整数编码在保证“人眼分辨不出误差”的前提下砍掉不必要的字节。4.2 继承自 FVector 意味着什么接口兼容 自定义序列化FVector_NetQuantize是一个继承自FVector的结构体所以凡是用FVector的地方它基本都能直接顶上。你可以把它赋值给FVector可以参与向量运算可以放进UPROPERTY。真正特别的地方在于它重写了NetSerialize属性复制和 RPC 序列化时不再按原生 float 而是走自定义的量化编码流程。我理解这个概念时用过比例尺做类比地图册上不会把每栋楼的 3D 坐标用小数写到毫米而是给你一个等比例缩放的网格刻度写“横向第 135 格、纵向第 72 格”精度足够信息量却大幅降低。FVector_NetQuantize干的就是这事它把浮点向量成整数版本同步时按整数编码发送反序列化时再转回浮点近似值。这里有个很多人关心的问题它和普通FVector相比到底损失了多少精度实践经验是默认FVector_NetQuantize的量化精度可以满足 99% 的游戏表现需求子弹弹孔、角色位置、投掷物落点统统够用。真到了需要“毫米级”精确同步的场景我反而会怀疑是不是设计出了问题。4.3 变体选择NetQuantize / NetQuantize10 / NetQuantize100UE 不只提供了默认的FVector_NetQuantize还有FVector_NetQuantize10和FVector_NetQuantize100。名字里的数字代表量化编码时的缩放粒度实际效果就是数字越大的变体可表达的范围更大但相对的精度会更粗。类型精度特点推荐使用场景FVector_NetQuantize精度较高适用于精确小范围坐标命中点、弹孔、贴花、近距离伤害标记FVector_NetQuantize10精度中等范围折中投掷物轨迹、角色移动的中间状态FVector_NetQuantize100精度较低但可表达大范围大世界位置同步或对误差不敏感的数据我的建议是拿不准时先用默认FVector_NetQuantize等项目真的有带宽或范围问题再按数据特性往粗粒度换。4.4 大世界场景下别硬用FVector_NetQuantize不是银弹。在一个超大地图里如果直接把世界坐标塞进去量化误差会被世界尺度的偏移放大。我踩过这个坑地图跨度五公里角色走到远处后同步出来的位置开始出现肉眼可见的漂移。解法通常有两种一是同步本地相对坐标比如“相对于 Owning Actor 的偏移”把数值保持在量化类型的舒适范围里二是拆块把超大地图划分成多个子区域每个区域内部位置单独同步。这两招配合FVector_NetQuantize系列大世界也能稳定运行。5. 把两者串起来一次真实的服务器权威命中同步5.1 整体设计链路多人射击玩法里正确做法是服务器权威客户端只负责表现和输入最终命中判定由服务器执行。链路一般是这样客户端开火发送一个 RPC 给服务器参数可以带枪口位置、朝向、武器 ID。服务器收到请求执行LineTraceByChannel做射线检测得到FHitResult。服务器把Hit.ImpactPoint、Hit.ImpactNormal存到带同步的字段里这个字段类型用FVector_NetQuantize。属性复制到客户端后客户端在OnRep里生成弹孔、火花、音效。这套链路有两个关键点客户端永远不直接把命中坐标发给服务器因为客户端结果可能被作弊或误判同时服务器发下来的坐标才会被所有客户端接受保证一致性。5.2 一个精简的 C 结构我在项目里常用这样一个结构USTRUCT() struct FReplicatedHitInfo { GENERATED_BODY() UPROPERTY() FVector_NetQuantize HitLocation; UPROPERTY() FVector_NetQuantize HitNormal; UPROPERTY() TWeakObjectPtrAActor HitActor; };然后在需要同步的 Actor 上加属性UPROPERTY(ReplicatedUsing OnRep_HitInfo) FReplicatedHitInfo LatestHitInfo; UFUNCTION() void OnRep_HitInfo();服务器端写命中时void AMyWeapon::ServerFire_Implementation(FVector ShootDir) { FHitResult Hit; FCollisionQueryParams Params; Params.AddIgnoredActor(this); Params.AddIgnoredActor(GetOwner()); if (World-LineTraceByChannel(Hit, MuzzleLocation, MuzzleLocation ShootDir * MaxRange, ECC_Visibility, Params)) { LatestHitInfo.HitLocation Hit.ImpactPoint; LatestHitInfo.HitNormal Hit.ImpactNormal; LatestHitInfo.HitActor Hit.GetActor(); OnRep_HitInfo(); } }这里FVector_NetQuantize的好处直接体现出来了不管走属性复制还是进一步走 RPC 参数它都会自动用量化后的整数编码传输省下来的带宽在多人对喷或者机器人海战术下非常可观。5.3 同步细节OnRep 里处理特效生成客户端收到OnRep_HitInfo后不建议直接在OnRep里 Play 特效因为属性复制到客户端时特效系统可能还没就绪。稳妥做法是绕一层void AMyWeapon::OnRep_HitInfo() { if (LatestHitInfo.HitActor.IsValid()) { // 在这里生成弹孔 Decal、播放音效、通知动画蒙太奇 SpawnImpactEffect(LatestHitInfo.HitLocation, LatestHitInfo.HitNormal); } }另一个容易忽略的点属性复制只发生在属性变化时。如果两次命中的ImpactPoint恰好量化后映射到同一个整数格子客户端可能收不到更新。这种情况虽然少见但在非常近距离连射时会遇到必要时加一个随命中 ID 的自增计数器保证每次命中都触发OnRep。5.4 实测下来的带宽变化我自己的一个 12 人联机 Demo 里原先每发子弹同步三个裸FVector网络占用的属性复制量稳定在 8KB/s 左右换成FVector_NetQuantize之后同一场景降到 4KB/s 上下。数字不一定通用但方向非常明确凡是不需要float全精度的位置、朝向、速度字段都应该往 NetQuantize 家族靠。6. 我踩过的一些“看起来对却不对”的坑6.1 射线打到自己第一次做第三人称射击时弹孔永远长在主角脸上。原因很简单射线的起点在角色内部射线直接从角色的 Capsule 上穿过LineTraceByChannel立刻返回自己的胶囊体。修复只要一行QueryParams.AddIgnoredActor(this)或AddIgnoredActor(GetOwner())。如果你是 Weapon 类发起的射线别忘了把持枪角色也加入忽略列表。6.2 Channel 预设没吃到射线穿透透明物体很多美术同学做的玻璃窗碰撞预设对Visibility通道是 Ignore就为了让玩家能看到窗后的敌人。结果你 Player 的子弹用ECC_Visibility查询就会直接穿窗玩家开火打不中窗户感觉很“穿模”。这不一定是代码错了而是业务通道没约定好。要么给子弹单独搞一个 Trace Channel并让玻璃对它 Block要么改用LineTraceByObjectType只查 WorldStatic/WorldDynamic逻辑会更稳。6.3 复杂碰撞滥用给头部精确命中开启bTraceComplex true之后每次射击成本明显上升。别全局默认开启在需要高精度判定的武器上单独配置就够了。另外复杂碰撞在骨骼网格体上不一定覆盖完整该用 Physics Asset 的地方还得自己调好。6.4 FVector_NetQuantize 在大世界的精度陷阱前面提到过大世界坐标直接量化会漂移。还有一个隐藏问题如果你把FVector_NetQuantize用于存储“当前世界坐标”当 Actor 移动到远离原点的地方量化误差会放大越远越明显。建议是同步相对坐标服务器同步目标 Actor ID 和相对偏移客户端自己算最终位置。6.5 别忘了调试符号调试时打开的DrawDebugLine如果被提交到线上版本会在客户端一直绘制射线不仅影响美观还会造成渲染开销。用项目无关的CVar或者#if WITH_EDITOR包一层线上严格关闭。最后再分享一点个人习惯现在我写 UE5 C 多人项目时凡是与“位置”相关的复制字段几乎不用裸FVector默认走FVector_NetQuantize系列。射线检测的通道和对象类型选择也固定成一套业务约定物理玩法用 ByChannel逻辑筛选用 ByObjectType。这套约定让我在后续维护和联调时少了很多互相扯皮的情况。如果你刚开始接触这些概念先把视线锁定那类需求改成 ByObjectType 试一次你会立刻感受到“类型筛选”比“通道响应”干净多少。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。