ARTICLE DETAIL

资讯详情

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

UE5 C++ Actor创建与组件初始化:BlueprintType/Blueprintable及位域变量实战

UE5 C++ Actor创建与组件初始化:BlueprintType/Blueprintable及位域变量实战 UE5的C开发绕不开的最基础也最关键的一件事就是创建Actor、初始化组件、把类暴露给蓝图以及定义变量时如何兼顾内存和可读性。这节内容很像新手期的第一道坎很多同学在编辑器里拖拖拽拽能跑出效果但一打开代码文件就懵。标题里这几个元素——创建Actor添加组件并初始化、BlueprintType、Blueprintable、uint8 bMyBit : 1位变量——看着是几个孤立的知识点其实是同一套基本功的三个侧面。这篇东西适合刚把C项目跑起来、想在蓝图和C之间打通任督二脉的开发者也适合写了一阵子蓝图、想逐渐把逻辑移到C里的同学。读完你至少能回答三个问题一个Actor类应该怎么搭骨架才不会在构造函数里踩坑C类怎么设置才能被蓝图完整地使用一个字节的位标记变量到底值不值得用、什么时候用好直接展开。1. 创建Actor和组件初始化从骨架搭起1.1 为什么我推荐用纯C代码创建Actor类在UE5里创建一个Actor表面上有两条路。一条是内容浏览器右键“蓝图类”父类选Actor然后在蓝图编辑器里拖组件、连线另一条是在Visual Studio或Rider上新建C类继承AActor在构造函数里写组件创建代码。标题明确写的是UE5 C所以这节只讲后一条路但我还是想多说一句为什么。我自己的习惯是只要逻辑不是纯可视化表现一律优先用C创建。原因有三一是可复现C代码是文本放进版本控制里几十行改动一眼就能看明白不会像蓝图那样一个拖拽动作就留下大块二进制差异二是可控组件是否准备好、根节点怎么挂、初始参数怎么算每一步都在构造函数里明明白白三是性能虽然蓝图组件创建也没多慢但纯C可以做到整个组件树在构造函数里就固定下来避免蓝图加载时再实例化组件带来的额外开销。这不是说蓝图不好而是分工不同。纯美术向、快速验证、或者需要频繁调参的原型用蓝图方便需要长期维护和严谨工程结构的C更稳。你要记住一个原则蓝图适合描述行为和表现C适合定义规则和骨架。组件树的搭建本质上是骨架不是表现所以放在C构造函数里最合适。编辑器里创建C类的过程其实也就点几下右键内容浏览器“添加C类”父类选Actor填好类名它会自动生成.h和.cpp并且把新类刷新进Visual Studio的工程文件里。注意类名不要用引擎保留字开头也不要和已有类重名否则生成后编译会带上一些历史遗留问题。生成完类的第一件事是看文件里有没有正确的Include和GENERATED_BODY宏这两个少了任何一个引擎都无法挂接反射系统。1.2 构造函数初始化组件的标准写法与要点新生成的Actor类头文件通常长这样UCLASS() class MYGAME_API AMyActor : public AActor { GENERATED_BODY() public: AMyActor(); };MYGAME是你项目的模块名工程创建时自动生成。在构造函数里初始化组件有几个约定俗成的步骤先创建根组件再创建子组件挂到根上最后给组件设置初始参数。根组件决定了Actor的坐标参考系组件挂接关系就是一棵树。完整写法大概这样AMyActor::AMyActor() { // 1. 创建根组件 SceneRoot CreateDefaultSubobjectUSceneComponent(TEXT(SceneRoot)); SetRootComponent(SceneRoot); // 2. 创建子组件并挂接 MeshComp CreateDefaultSubobjectUStaticMeshComponent(TEXT(MeshComp)); MeshComp-SetupAttachment(SceneRoot); // 3. 初始化参数 MeshComp-SetCollisionProfileName(TEXT(BlockAllDynamic)); }你需要在头文件里声明两个组件成员变量UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category Components) class USceneComponent* SceneRoot; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category Components) class UStaticMeshComponent* MeshComp;VisibleAnywhere表示组件在细节面板可见但不可替换适合防止别人把根组件换掉BlueprintReadOnly则允许蓝图读取组件指针但不能重新赋值。组件成员必须用UPROPERTY标记否则UE的垃圾回收系统可能在你还没反应过来的时候就把这些没有根引用的组件当垃圾收走了。这种崩溃特征很典型编辑器里一切正常打包运行偶尔直接崩查了一天发现是UPROPERTY漏了。这里容易犯的三个错我见得太多了第一CreateDefaultSubobject只能在构造函数里调用。它是唯一一个能创建组件并自动注册进Actor组件树的函数。如果在BeginPlay或者其他函数里动态创建组件那要用NewObject EObjectFlags然后手动注册逻辑完全不一样。很多人把CreateDefaultSubobject塞进BeginPlay编译不报错运行时空指针然后就找不到原因。第二不要忘记SetRootComponent。如果你创建了组件却没指定根Actor虽然会自动找默认根但层级关系会混乱尤其在蓝图里看到的那棵组件树和你预期不一致后面排查谁挂谁时非常痛苦。第三SetupAttachment的第二个参数是Socket名。默认省略就是挂根下但如果你想挂到骨骼插槽必须写对第二个参数否则组件会静默挂在根下。这类问题在角色骨骼上最典型装备组件挂不上武器追半天代码才发现是Socket名打错了一个字母。1.3 组件初始化后顺手补上的三件事构造函数只是创建组件、挂树、设初值真正“活过来”要等BeginPlay。组件初始化完成后有三件事最好顺手做掉碰撞体类型设置、移动性和物理约束、网络复制属性。特别是做交互类Actor重叠检测是高频需求。通用写法是这样MeshComp-SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); MeshComp-SetCollisionObjectType(ECC_WorldDynamic); MeshComp-SetCollisionResponseToAllChannels(ECR_Block); MeshComp-SetGenerateOverlapEvents(true);这里补充一句如果你需要Actor在重叠事件里被触发必须用SetGenerateOverlapEvents(true)并且把与目标通道的响应设置成Overlap。很多人卡在“Overlap事件识别不到”上八成是碰撞响应没改成Overlap或者根本没开GenerateOverlapEvents。别问我怎么知道的。碰撞通道不是一句两句能讲完的这期只要记住一个套路默认方块碰撞用BlockAllDynamic做触发器用OverlapAll或自定义Profile。还有一点物理模拟不是默认开启的。如果你要让静态网格组件具有物理效果必须设置SetSimulatePhysics(true)并确保碰撞体类型有Physics。否则你拖一个带物理资产的静态网格进去它也不会自动掉下来。这个“看起来很简单但经常被忽略”的程度仅次于漏写UPROPERTY。2. BlueprintType 与 Blueprintable把C类暴露给蓝图的门道2.1 两个说明符各自管什么UCLASS宏是UE反射系统给C类贴标签的地方。BlueprintType和Blueprintable是其中最常见的两个标签但真有不少人混用。先给一句话版本大家先记住分工Blueprintable允许这个C类作为蓝图子类的父类。写上它你才可以在内容浏览器里右键创建蓝图类并选择这个类作为父类。BlueprintType允许这个类本身成为蓝图中的一种变量类型。写上它你可以在蓝图里创建这个类型的变量、作为函数参数、放在结构体里使用。可以做个类比。Blueprintable决定了“能不能从这个模具里倒出蓝图子类”BlueprintType决定了“这个模具本身能不能作为原料放进别的配方里”。两个标签覆盖的使用面不同经常同时出现但本质上是独立的。在代码里直接写成UCLASS(Blueprintable, BlueprintType) class MYGAME_API AMyInteractiveActor : public AActor意思就是这个类既能被蓝图继承也能被蓝图当变量类型用。很多官方示例类都这么写因为这两个需求实在太常同时出现了。但这不意味着你必须无脑都加后面会说什么时候该省。2.2 实际在蓝图里能看到什么区别我用一个表格让你看得更直观UCLASS说明符内容浏览器右键创建蓝图子类蓝图变量面板可选为变量类型关卡中直接摆放什么都不加否否可以Blueprintable可以否可以BlueprintType否可以可以Blueprintable BlueprintType可以可以可以注意“关卡中直接摆放”这一列。无论加没加说明符C的Actor类都可以被拖进关卡使用。说明符控制的是“蓝图编辑器的操作权限”不是运行时的实例化权限。很多人以为不加Blueprintable就不能在关卡里放这个Actor这是个误解。能不能放在关卡的层级上和能不能被蓝图继承完全是两回事。这是典型的蓝图和C协作场景我先写一个带核心逻辑的C Actor类用Blueprintable让它能作为父类再在C里定义一些数据结构或枚举如果需要蓝图变量引用就加BlueprintType。如果你想在蓝图里做这个Actor类型的变量引用两种都加是最省心的。蓝图变量面板里选择变量类型时你输入类名就能搜到搜不到就检查一下是不是漏了BlueprintType。2.3 单独使用的场景与常见误区Blueprintable但没加BlueprintType最直接的后果是你创建了蓝图子类在蓝图里可以访问它的所有蓝图可见成员但无法声明一个“这个类的变量”来做类型明确的引用。等于每次想引用它都得用Actor类型然后Cast一下。麻烦归麻烦但不是不能用。BlueprintType但没加Blueprintable则是另一种别扭。你可以声明这个类型的变量但这个类不能被子类化也就没法做蓝图层的扩展逻辑。如果这个类只用于数据接口问题不大如果它有具体功能要实现就会很憋屈。所以我的建议很直白写Actor功能类默认UCLASS(Blueprintable)需要引用就加上BlueprintType写纯数据结构大多加BlueprintType写工具类、管理器一般两个都不加避免蓝图错误地依赖内部实现。这里还有一个容易被忽略的细节Blueprintable本身支持更细的说明符比如Blueprintable(NotBlueprintType)这类写法可以控制得更精确。新手阶段不用背那么多变体先把基础的两个弄清。3. uint8位标记变量一个字节装下你的开关状态3.1 位域的基本概念一行代码背后是什么C/C的位域语法是通过冒号限定成员占用的bit数。你写uint8 bMyBit : 1就是告诉编译器这个成员只占1 bit。它读起来像一个布尔实际上存在同一字节里。为什么UE5代码里常见这种写法核心原因是内存和网络复制。一个Actor身上可能有十几个状态开关是否启用、是否已拾取、是否正在冷却、是否被锁定。如果全部用bool一个bool在C里实际占1字节十几个就是十几个字节。看起来不大但在网络复制时每个bool属性都可能是独立的复制通道如果把这一堆开关打包进一个uint8字节一次复制就能同步所有开关对服务器带宽和同步性能是实打实的优化。你可以这么理解一个字节就像一个有8个开关的插线板bMyBit : 1就是占用其中一格。8个bool分散在内存里是8个独立的开关盒打包成位域是1个8位开关盒送数据的时候一次快递就全到。3.2 在Actor类中怎么用直接放在Actor类里和UPROPERTY一起UCLASS(Blueprintable, BlueprintType) class MYGAME_API AMyInteractiveActor : public AActor { GENERATED_BODY() public: AMyInteractiveActor(); UPROPERTY(EditAnywhere, BlueprintReadOnly, Category Interaction) uint8 bMyBit : 1; UPROPERTY(EditAnywhere, BlueprintReadOnly, Category Interaction) uint8 bIsAvailable : 1; UPROPERTY(EditAnywhere, BlueprintReadOnly, Category Interaction) uint8 bUseDisabled : 1; };这里bMyBit、bIsAvailable、bUseDisabled三个标记会被编译器安排进同一个字节。C侧可以直接当作bool来用if (bIsAvailable) { // 可以交互 } else { // 不可交互 }注意变量名以b开头是UE的传统表示布尔。虽然底层是位域但语义上是布尔命名时保留b前缀代码读起来非常自然。这个命名习惯在UE官方代码里到处都是建议从第一天就养成。如果你想读这个字节本身的整数值写法也不复杂但要小心偏移。三个bit在字节低位直接强转成uint8拿到的不是“哪一位为1”的直观值而是整个字节的数值。实际开发里更多需要的还是单个标记的判断按位判断就好。3.3 位域和UPROPERTY搭配时的边界这里划一个重点位域变量虽然C侧清爽、内存侧高效但它在蓝图层并不是一等公民。第一个问题细节面板的显示。位域变量即使加了EditAnywhere蓝图细节面板也可能把它显示成数字而不是你想要的是非勾选框。原因是UE的反射系统能识别它但编辑器UI对位域的支持不是每个版本都完善。如果你指望把所有状态标记做成蓝图勾选框位域不是最佳选择。第二个问题默认值不好设置。你没法直接在细节面板里给位域变量填一个类似“勾选”的初始状态。解决办法是在构造函数里给位域单独赋值AMyInteractiveActor::AMyInteractiveActor() { bMyBit 1; bIsAvailable 1; bUseDisabled 0; }位域变量在C里可以直接赋0或1这是合法的。但这只能作为构造函数里的初始值想在Blueprint默认值面板里改多半不生效。更稳的做法是位域留在C内部做逻辑判断另外提供两个UFUNCTION做蓝图出入口让蓝图层只面对简洁的bool接口public: UFUNCTION(BlueprintCallable, Category Interaction) void SetMyBit(bool bValue); UFUNCTION(BlueprintPure, Category Interaction) bool IsMyBitSet() const;Set里写到位域Get里读位域。这样C侧继续享受内存和复制效率蓝图层拿到的是干净接口两边都不别扭。这个封装思路值得记下来几乎所有的“C类型和蓝图类型不完全兼容”情况都适合用函数做翻译层。4. 完整示例一个带组件和状态标记的可交互Actor4.1 头文件设计现在把前面的知识串成一个完整例子一个可交互拾取物Actor带静态网格组件带三个状态标记并且暴露给蓝图。头文件我完整贴出来#pragma once #include CoreMinimal.h #include GameFramework/Actor.h #include MyPickupActor.generated.h UCLASS(Blueprintable, BlueprintType) class MYGAME_API AMyPickupActor : public AActor { GENERATED_BODY() public: AMyPickupActor(); // 状态标记打包在一个字节里 UPROPERTY(EditAnywhere, BlueprintReadOnly, Category Pickup) uint8 bMyBit : 1; UPROPERTY(EditAnywhere, BlueprintReadOnly, Category Pickup) uint8 bIsAvailable : 1; UPROPERTY(EditAnywhere, BlueprintReadOnly, Category Pickup) uint8 bUseDisabled : 1; // 蓝图访问接口 UFUNCTION(BlueprintCallable, Category Pickup) void SetAvailable(bool bInAvailable); UFUNCTION(BlueprintPure, Category Pickup) bool IsAvailable() const; protected: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category Components) class USceneComponent* SceneRoot; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category Components) class UStaticMeshComponent* MeshComp; virtual void BeginPlay() override; };UFUNCTION(BlueprintPure)表示这个函数不会改变Actor的状态蓝图里它的函数节点没有执行引脚返回结果可以直接连线。你想像bool变量一样直接读出状态就用这个。BlueprintCallable则对应普通函数节点有执行引脚能改变状态。4.2 构造函数与接口实现#include MyPickupActor.h #include Components/SceneComponent.h #include Components/StaticMeshComponent.h AMyPickupActor::AMyPickupActor() { SceneRoot CreateDefaultSubobjectUSceneComponent(TEXT(SceneRoot)); SetRootComponent(SceneRoot); MeshComp CreateDefaultSubobjectUStaticMeshComponent(TEXT(MeshComp)); MeshComp-SetupAttachment(SceneRoot); MeshComp-SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); MeshComp-SetCollisionObjectType(ECC_WorldDynamic); MeshComp-SetCollisionResponseToAllChannels(ECR_Block); MeshComp-SetGenerateOverlapEvents(true); // 初始状态可用启用非禁用 bMyBit 1; bIsAvailable 1; bUseDisabled 0; } void AMyPickupActor::SetAvailable(bool bInAvailable) { bIsAvailable bInAvailable ? 1 : 0; } bool AMyPickupActor::IsAvailable() const { return bIsAvailable ! 0; }有些同学看到bIsAvailable bInAvailable ? 1 : 0会觉得多余但这是写给编译器看的。位域变量从语义上被当成bool用没问题但你做的是窄域转换显式写出来能消除警告也保证意图明确。至于IsAvailable里写bIsAvailable ! 0同样是避免位域和bool之间的隐式转换风险。BeginPlay里还没有任何逻辑需要写但保留一个空的重载是好的后期要处理游戏开始时状态判断直接往里填就行。4.3 蓝图侧的使用步骤代码写好编译通过之后在编辑器里操作就很简单了第一步内容浏览器右键“蓝图类”父类选择刚才的AMyPickupActor。能选到它正是因为UCLASS里加了Blueprintable。第二步打开新创建的蓝图把静态网格指给组件。选中MeshComp在细节面板里给它指定一个Static Mesh资源比如引擎自带的基础立方体。不要忘记把MeshComp的位置稍微偏离根组件否则两个组件重叠在一起视觉效果会很奇怪。第三步放一个“OnComponentBeginOverlap”事件用刚才暴露的IsAvailable做判断满足条件就让可拾取物消失用SetActorHiddenInGame和SetActorEnableCollision做视觉和碰撞的关闭。然后调SetAvailable(false)状态一变UI或音效就能跟着切换。整个过程里的关键点蓝图里能创建AMyPickupActor类型的变量是因为BlueprintType能重写和扩展逻辑是因为Blueprintable。一个UCLASS两个标签同时打开了蓝图的两扇门。真正常见的业务逻辑比如拾取动画、声音播放、掉落物生成放到蓝图子类里做会比在C里堆代码更灵活C部分保持逻辑核心。5. 常见问题速查与避坑心得5.1 为什么蓝图里找不到我刚写的类编译通过后蓝图类列表里却看不到自己的类。先说排查顺序。第一确认UCLASS加了Blueprintable没有这个标签右键菜单永远不会出现它。第二保存所有C文件并编译编辑器有时候要重启或刷新在Visual Studio里编译过了不等于引擎里热加载成功。第三检查类名是否和已有类冲突冲突时UE会默默沿用旧版本。还有一个容易忽略的点如果你的类没有放在游戏模块下而是放在引擎模块或第三方插件模块里类可见性会受模块加载顺序影响。常见做法是开发初期全部放在自己的游戏模块里编译顺序简单调试也容易。5.2 构造函数创建组件后运行时崩溃最常见原因就是我前面说的把CreateDefaultSubobject放错了函数。其次注意组件指针声明时要用UPROPERTY包住否则垃圾回收系统可能把组件当成无人引用的对象回收。还有一个隐蔽坑在构造函数里访问资源、网络或消息队列此时整个世界还没准备好很多东西是无效的。你可以在构造函数里CreateDefaultSubobject但不要尝试加载复杂资源或获取其他Actor。这句话值得写进你的排查清单构造函数里只做组件创建和基础属性设置凡是需要依赖场景世界的初始化都放到BeginPlay里做。5.3 位标记变量在蓝图细节面板显示成数字位域变量配合UPROPERTY后蓝图面板往往会按数字或字节显示这不算报错但很别扭。如果你接受不了这种观感方案有两个一是逐字段暴露一个非位域的uint8属性给蓝图用函数封装读写二是干脆不要依赖蓝图编辑位域全部走UFUNCTION接口蓝图层看到的是干净函数。我个人推荐第二种因为蓝图里调用SetAvailable和IsAvailable其实是更可读的。蓝图连线里如果直接拖出一堆位域变量代码审查时根本看不出逻辑重点。5.4 复制时标志位不同步网络复制时位域本身不是独立的复制属性它们打包在一个字节里。你要保证承载这个字节的UPROPERTY声明了Replicated。如果只是把位域变量标记成BlueprintReadWrite没有在GetLifetimeReplicatedProps里加上对应复制属性客户端就会各拿各的状态多发端根本同步不起来。如果你把三个位域分开复制引擎会尝试对每个位域做复制属性处理这在某些引擎版本里兼容性并不理想。更稳的方案是包装一个承载字节UPROPERTY(Replicated) uint8 FlagByte;然后位域和FlagByte共享同一个字节复制时只注册FlagByte所有位域逻辑在客户端和服务端读的都是同一个字节。这比拆分每个位域一遍遍复制要干净得多。5.5 组件树和蓝图事件绑定的最后提醒组件事件的绑定比如OnComponentBeginOverlap必须在运行时拿到组件引用后才能绑定。构造函数里CreateDefaultSubobject得到的组件指针是可以直接做绑定调用的但Lambda捕获里如果用this注意组件可能在BeginPlay前还未完整初始化。稳妥做法是在BeginPlay里绑定事件void AMyPickupActor::BeginPlay() { Super::BeginPlay(); if (MeshComp) { MeshComp-OnComponentBeginOverlap.AddDynamic(this, AMyPickupActor::OnComponentBeginOverlap); } }事件函数记得标记UFUNCTIONUFUNCTION() void OnComponentBeginOverlap( UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult SweepResult);这个步骤其实已经超出了标题范围但它在你做“带组件的可交互Actor”时几乎必然遇到顺手写在这里省得你在编辑器里用事件蓝图绑定半天找不到入口。越基础的东西越容易被跳过但恰恰是组件树、类说明符、位域标记这种基础决定了后面所有项目代码的骨架。你在一个简单示例里把这些搞熟比在大型项目里临时查文档划算得多。最后分享一个我自己的习惯每个Actor类动手前先问自己三个问题——这个类需要被蓝图继承吗需要被蓝图当变量类型吗状态开关值得用位域打包吗三个问题想清楚了UCLASS和UPROPERTY写起来基本不用改第二遍。代码写得好不好差距往往就在这些“写之前”的功夫里。
返回列表