1. 鼠标点击拾取在 UE C++ 里到底难在哪
做 UE 项目时,鼠标点击拾取几乎是绕不开的需求:点地面让角色走过去、点箱子拾取道具、点敌人锁定目标、点 UI 外的场景物体触发交互。很多刚接触 UE C++ 的朋友第一反应是去查OnClicked事件,结果发现它只对 Actor 的碰撞体生效,而且和鼠标位置、相机、UI 遮挡搅在一起,稍微复杂一点就失灵。
真正的问题在于:鼠标点击本质上是屏幕上的一个二维坐标,而你要操作的是三维世界里的物体。这中间隔着一层「投影反算」——你得把屏幕坐标还原成世界空间里的一条射线,再看这条射线打中了谁。UE 提供了两条主流路径:一条是APlayerController帮你封装好的GetHitResultUnderCursor,几行代码就能拿到命中结果;另一条是手动DeprojectScreenPositionToWorld配合LineTraceSingleByChannel,控制粒度更细,能自定义检测通道、忽略列表和射线长度。
这篇面向需要在项目里快速落地点击交互的开发者,给出两种方法的完整 C++ 类骨架、PlayerController 配置和蓝图调用示例,并说明怎么用 TaoToken 统一 Key/API 通道接入 AI 辅助调试,最后附上验证点击命中 Actor 的日志与断点检查动作。适合已经会写基础 UE C++、但被点击拾取卡住的同学。
2. 前置准备:TaoToken 统一 Key 与 API 通道
在写代码之前,先把调试环节的 AI 通道搭好。UE C++ 的编译报错、射线检测返回空、坐标转换对不上,这些坑排查起来很费时间,用 AI 辅助定位能省不少事。TaoToken 的作用是把模型调用统一到一个 Key 和一套 API 通道上,不用在多个平台之间来回切换配置。
官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api (这个不加 UTM)。你需要先去控制台创建一个 API Key,然后就能在模型对话、编码辅助、Agent 等场景里复用同一个 Key。
具体入口按用途分:
- 想直接对话验证模型效果,走模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- 长期写 UE C++、需要编码辅助或 Agent 能力,走 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
- 管理 Key、查看用量,走控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
- 创建和复制 API Key,走 API Keys 页面:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- 查接入文档、参数说明,走文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
- 如果你用 Claude Code 做 UE 项目,走 ClaudeCodeAnthropic 接入说明:https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite
注意:Key 只放在本地环境变量或项目外的配置文件里,别硬编码进
.cpp提交到仓库。UE 项目经常多人协作,Key 泄露后要重新生成很麻烦。
3. 方法一:GetHitResultUnderCursor 快速落地
3.1 原理与适用场景
GetHitResultUnderCursor是APlayerController提供的封装函数,内部其实调用的就是GetWorld()->LineTraceSingleByChannel,但它帮你做了几件脏活:确认当前有玩家屏幕、把鼠标屏幕坐标转成世界坐标和方向、默认排除 UI 命中。所以只要你的类继承自ACharacter或APawn,就能通过GetController拿到APlayerController直接用。
它适合绝大多数「点一下场景物体」的需求,尤其是原型阶段和交互逻辑不复杂的项目。缺点是检测通道固定为传入的ECollisionChannel,忽略列表和射线长度不好自定义。
3.2 完整 C++ 类骨架
下面是一个挂在角色上的点击拾取组件式写法,直接放在ACharacter子类里也能跑。新建一个UClickPickComponent,或者把逻辑写进你的AMyCharacter。
// ClickPickComponent.h #pragma once #include "CoreMinimal.h" #include "Components/ActorComponent.h" #include "ClickPickComponent.generated.h" UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent)) class MYGAME_API UClickPickComponent : public UActorComponent { GENERATED_BODY() public: UClickPickComponent(); protected: virtual void BeginPlay() override; // 鼠标左键按下时调用 void OnLeftMousePressed(); // 检测通道,默认 ECC_Visibility UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="ClickPick") TEnumAsByte<ECollisionChannel> TraceChannel = ECC_Visibility; // 是否打印命中日志 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="ClickPick") bool bDrawDebugLine = true; private: APlayerController* CachedPC = nullptr; };// ClickPickComponent.cpp #include "ClickPickComponent.h" #include "GameFramework/PlayerController.h" #include "Engine/World.h" #include "DrawDebugHelpers.h" UClickPickComponent::UClickPickComponent() { PrimaryComponentTick.bCanEverTick = false; } void UClickPickComponent::BeginPlay() { Super::BeginPlay(); if (APawn* OwnerPawn = Cast<APawn>(GetOwner())) { CachedPC = Cast<APlayerController>(OwnerPawn->GetController()); } if (CachedPC) { // 绑定输入,需在项目设置里配置 Action Mapping "LeftMouse" CachedPC->InputComponent->BindAction( "LeftMouse", IE_Pressed, this, &UClickPickComponent::OnLeftMousePressed); } } void UClickPickComponent::OnLeftMousePressed() { if (!CachedPC) return; FHitResult Hit; // 关键调用:传引用进去,结果带出来 const bool bHit = CachedPC->GetHitResultUnderCursor(TraceChannel, false, Hit); if (bHit && Hit.bBlockingHit) { AActor* HitActor = Hit.GetActor(); UE_LOG(LogTemp, Warning, TEXT("命中 Actor: %s, 位置: %s"), HitActor ? *HitActor->GetName() : TEXT("None"), *Hit.ImpactPoint.ToString()); if (bDrawDebugLine) { DrawDebugSphere(GetWorld(), Hit.ImpactPoint, 12.f, 12, FColor::Green, false, 2.f); } } else { UE_LOG(LogTemp, Warning, TEXT("未命中任何物体")); } }3.3 PlayerController 与输入配置
在DefaultInput.ini里加一个 Action Mapping,或者直接在项目设置的 Input 里加:
+ActionMappings=(ActionName="LeftMouse",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=LeftMouseButton)如果你不想用输入绑定,也可以在APlayerController子类里重写InputKey,判断EKeys::LeftMouseButton和IE_Pressed,效果一样。蓝图里调用的话,把OnLeftMousePressed标成BlueprintCallable就能在事件图表里连。
4. 方法二:DeprojectScreenPositionToWorld 配合射线检测
4.1 为什么需要手动射线
GetHitResultUnderCursor虽然方便,但有几个限制:它默认忽略 UI,可你有时候就是想点 UI 后面的东西;它用的通道是固定的,没法临时换;射线长度默认到世界尽头,远距离物体也会被命中。手动射线检测把这几步拆开,你能完全控制。
核心两步:先用UGameplayStatics::DeprojectScreenPositionToWorld把鼠标屏幕坐标转成世界空间的起点和方向,再用GetWorld()->LineTraceSingleByChannel发射射线。
4.2 屏幕坐标转世界坐标
// 获取鼠标当前位置 float MouseX, MouseY; if (!CachedPC->GetMousePosition(MouseX, MouseY)) return; FVector WorldOrigin, WorldDirection; // 屏幕坐标 -> 世界坐标 const bool bDeprojected = UGameplayStatics::DeprojectScreenPositionToWorld( MouseX, MouseY, WorldOrigin, WorldDirection); if (!bDeprojected) return;WorldOrigin是相机位置附近的世界坐标,WorldDirection是归一化的方向向量。射线起点用WorldOrigin,终点用WorldOrigin + WorldDirection * TraceDistance。
4.3 LineTraceSingleByChannel 完整实现
void UClickPickComponent::OnLeftMousePressedManual() { if (!CachedPC) return; float MouseX, MouseY; if (!CachedPC->GetMousePosition(MouseX, MouseY)) return; FVector WorldOrigin, WorldDirection; if (!UGameplayStatics::DeprojectScreenPositionToWorld( MouseX, MouseY, WorldOrigin, WorldDirection)) return; const float TraceDistance = 10000.f; // 自定义射线长度 const FVector TraceEnd = WorldOrigin + WorldDirection * TraceDistance; FCollisionQueryParams Params; Params.AddIgnoredActor(GetOwner()); // 忽略自己 Params.bTraceComplex = false; FHitResult Hit; const bool bHit = GetWorld()->LineTraceSingleByChannel( Hit, WorldOrigin, TraceEnd, TraceChannel, Params); if (bHit && Hit.bBlockingHit) { UE_LOG(LogTemp, Warning, TEXT("手动射线命中: %s"), *Hit.GetActor()->GetName()); } if (bDrawDebugLine) { DrawDebugLine(GetWorld(), WorldOrigin, TraceEnd, bHit ? FColor::Green : FColor::Red, false, 2.f, 0, 1.f); } }4.4 两种方法对照
| 对比项 | GetHitResultUnderCursor | 手动 Deproject + LineTrace |
|---|---|---|
| 代码量 | 少,一行调用 | 多,需自己拼起点终点 |
| 通道控制 | 只能传一个通道 | 可动态切换 |
| 射线长度 | 默认到世界尽头 | 完全自定义 |
| 忽略列表 | 内部固定 | 可自由 AddIgnoredActor |
| UI 处理 | 默认排除 | 需自己判断 |
| 适用场景 | 原型、简单交互 | 复杂拾取、自定义规则 |
5. 验证请求与成功结果
5.1 日志与断点检查
编译后运行,在角色上挂好组件,点击场景里的物体,Output Log 里应该看到:
LogTemp: Warning: 命中 Actor: SM_Box_2, 位置: X=320.000 Y=-150.000 Z=88.000如果走手动射线,还会看到绿色 Debug 线从相机射向命中点。断点建议打在OnLeftMousePressed的第一行和bHit判断处,检查Hit.GetActor()是否为空、Hit.ImpactPoint是否合理。
5.2 用 TaoToken 辅助排查
当射线返回空或者命中 Actor 不对时,把报错日志、TraceChannel设置、碰撞预设一起丢给 TaoToken 的模型对话入口,让它帮你分析是通道配错还是碰撞体没开。接入文档里有完整的请求参数说明,照着填就行。长期做 UE 项目的话,Coding Plan 更适合,能把编码辅助和调试问答放在一个通道里。
6. 本篇常见错排查
点击没反应,日志也不打:先确认输入绑定生效。InputComponent在BeginPlay里绑定时,CachedPC可能还是空,因为 Controller 还没 Possess。改成在APawn::SetupPlayerInputComponent或APlayerController::SetupInputComponent里绑定更稳。
bHit 为 true 但 Hit.GetActor() 是 None:命中的可能是地形或碰撞体,Actor 指针为空说明打到了没有 Owner 的组件。检查Hit.GetComponent()和碰撞预设。
射线打不中物体:物体的碰撞预设要响应你用的TraceChannel。默认ECC_Visibility下,很多静态网格的碰撞是Block,但如果你改成了Overlap或Ignore,射线就穿过去了。在物体碰撞设置里确认 Trace 响应。
UI 挡住了点击:GetHitResultUnderCursor第二个参数传false表示不忽略 UI,传true才忽略。手动射线的话,得自己用WidgetBlueprintLibrary判断鼠标是否在 UI 上。
屏幕坐标转世界坐标方向不对:GetMousePosition返回的是视口坐标,如果用了分屏或者自定义视口,得先转换。单屏项目一般没问题。
编译报错找不到 UGameplayStatics:头文件加#include "Kismet/GameplayStatics.h",DrawDebugHelpers.h也要加,否则DrawDebugLine报未定义。
把这些点过一遍,点击拾取基本就稳了。真机上跑之前,记得在打包设置里确认输入映射没被裁剪掉。