UE4塔防游戏开发实战:从索敌机制到对象池性能优化

发布时间:2026/10/6 13:03:12
UE4塔防游戏开发实战:从索敌机制到对象池性能优化 简介基于UE4.22开发的3D塔防游戏完整项目面向希望将Unreal Engine C与真实游戏玩法结合起来学习的开发者与游戏设计初学者。资源以zip压缩包形式提供约1.01GB包含完整工程文件以及配套的音频、敌人角色网格等素材可直接在对应引擎版本中打开运行。项目展现了在岩石地形四周布置炮塔、连续抵御11波敌人进攻的完整塔防玩法敌人会随波次增加数量与生命值难度曲线清晰节奏与数值设计具有参考价值其中敌人角色网格取自Epic Games官方资产版权来源明确便于二次开发与学习复用。资源说明中还收录了运行时崩溃的排错提示——通过调整项目设置中的地图配置即可规避异常这对刚接触UE4工程设置的读者很有帮助。目前已有647人浏览/学习通过阅读源码并结合UE4.22运行调试可以直观理解炮塔放置逻辑、敌人波次生成机制、AI路径与C类组织方式是系统学习UE4 C游戏开发并落地到实际项目的良好范例。1. 士兵-塔-防御-UE4一个 3D 塔防 Demo 的真正价值士兵-塔-防御-UE4从项目名就能看出来这是一套用 Unreal Engine 4 做的 3D 塔防游戏。我做过两轮类似的 Demo第一轮照着第三人称模板改敌人会走但塔不开火玩家只能干瞪眼第二轮把索敌、开火、波次三块拆开做才算把 UE4 的常用系统串了起来。它适合两类人想从 FPS/第三人称 Demo 转塔防验证手感的人以及会蓝图但搞不清碰撞通道、导航网格怎么配合的人。核心要处理的是三层逻辑——塔与士兵怎么选目标、敌人怎么沿着路接近、波次怎么让难度曲线不恶心这三层跑通你就能往里面扩展火焰塔、减速塔和多路出兵。2. 用 UE4 搭塔防骨架GameMode、玩家输入与放塔指令的落地细节2.1 选模板与场景底子别一上来就开空项目我见过不少人做塔防第一个动作是新建 Blank Project然后从零摆地板、拉灯光结果三天过去了连个会动的角色都没有。常见做法是直接用 UE4 自带的 ThirdPerson 模板选 C 还是蓝图看你自己我习惯用 C 起步因为后面碰撞通道、数据表这些要在代码里写才看得清。这个模板自带了几样塔防必不可少的东西带胶囊体碰撞的 Character、弹簧臂相机、基本移动输入。塔防里的“士兵”通常不是一个需要复杂操作的角色但玩家总得有个视角、有个可以行走的观察位第三人称模板刚好把这些默认做好。项目创建好以后我会把模板里的第三人称角色改成“观察者”把它的移动速度调慢一点作为玩家在战场里巡视的替身。场景方面用一个地面平面加几个 Static Mesh 盒子代表路不需要一开始就做美术。地面平面的碰撞预设保持默认 WorldStatic这样后面放塔时射线能精准落在它上面。光源先放一个 Directional Light 加一个 SkyLight保证编辑器里看得出立体感。2.2 输入映射用 UE4.27 的旧输入系统反而更好入门UE4 从 4.26 开始推 Enhanced Input但做塔防这种“点地面放塔 移动观察者”的玩法用老式 InputComponent 绑定轴映射和动作映射更直白少一层映射上下文。先在 Project Settings → Engine → Input 里把 Axis Mappings 和 Action Mappings 配好AxisMappings 里加 MoveForward、MoveRight 两个轴ActionMappings 里加一个 PlaceTower绑定到鼠标左键。然后写 PlayerController 子类在 SetupInputComponent 里把对应回调挂上// AMyTowerDefensePC.cpp void AMyTowerDefensePC::SetupInputComponent() { Super::SetupInputComponent(); // 轴映射对应 Input Settings 里的 MoveForward InputComponent-BindAxis(MoveForward, this, AMyTowerDefensePC::MoveForward); InputComponent-BindAxis(MoveRight, this, AMyTowerDefensePC::MoveRight); // 动作映射对应鼠标左键按下时尝试放塔 InputComponent-BindAction(PlaceTower, IE_Pressed, this, AMyTowerDefensePC::TryPlaceTower); }逻辑说明这段代码在 Controller 被 Possess 时自动调用把输入事件和 C 处理函数绑定起来。MoveForward 和 MoveRight 由轴映射持续驱动值范围在 -1 到 1 之间受键盘 WASD 控制PlaceTower 只在按下的瞬间触发一次避免鼠标按住时连放一排塔。参数说明BindAxis 的字符串必须和 Project Settings 里 Axis Mappings 的名字完全一致大小写也要一致否则绑了也触发不了。对塔防来说鼠标左键放塔是低频操作所以用 BindAction 的 IE_Pressed 比 BindAxis 更合适后者会在按住期间每帧触发容易误建一堆塔。2.3 放塔指令射线命中地面后的生成逻辑放塔的核心不是 SpawnActor而是“判断鼠标点到了哪里”。常见做法是用 Controller 的 GetHitResultUnderCursorByChannel 朝屏幕方向做一条射线命中点作为塔的落点。这里要注意的是命中地面后要按网格吸附否则塔会歪歪扭扭插在地形里。// AMyTowerDefensePC.cpp void AMyTowerDefensePC::TryPlaceTower() { FHitResult Hit; // 只检测可见性通道命中结果是鼠标指向的最近阻挡物体 GetHitResultUnderCursorByChannel(ECC_Visibility, false, Hit); if (!Hit.bBlockingHit || !Hit.Actor.IsValid()) { return; } // 地面 Actor 上打上 Ground 标签避免塔建到敌人身上 if (!Hit.Actor-ActorHasTag(FName(Ground))) { return; } FVector SnapLoc Hit.ImpactPoint.GridSnap(100.0f); SnapLoc.Z Hit.ImpactPoint.Z; // 高度保持地面高度网格只吸附水平方向 FActorSpawnParameters Params; // 塔和地面碰撞不可靠时强制生成后续由重叠盒接管放置合法性 Params.SpawnCollisionHandlingOverride ESpawnActorCollisionHandlingMethod::AlwaysSpawn; GetWorld()-SpawnActorATowerBase(SelectedTowerClass, SnapLoc, FRotator::ZeroRotator, Params); }逻辑说明先做一条针对 ECC_Visibility 通道的射线检测拿到鼠标指向的最近阻挡点。然后判断命中的 Actor 是否带 Ground 标签防止玩家把塔放到敌人头顶或墙上。GridSnap 让生成点吸附到 1 米网格看起来整齐Z 轴单独保留地面高度不然吸附后塔会悬空。参数说明ECC_Visibility 是屏幕交互最宽松的通道地面、墙面、敌人只要可见都会被命如果你的地面有碰撞但网格没显示可以把 false 改成 true 追踪复杂碰撞但开销会高一点。SelectedTowerClass 应该在玩家点击 UI 或切换武器时赋值不放任何塔就直接点鼠标会白按。3. 塔与士兵的索敌开火闭环LineTrace、碰撞通道与伤害接口怎么搭3.1 塔的目标扫描用重叠盒划定射程用距离排序挑目标塔防里的“索敌”如果不做特殊机制最常见做法是塔底放一个球形碰撞体作为射程范围每 0.25 秒扫描一次范围内存在的敌人选一个当前威胁最高的目标来攻击。威胁排序规则可以很简单距离最近或者按剩余血量百分比排序。下面这段是塔基类的核心扫描逻辑。// ATowerBase.cpp void ATowerBase::ScanForTarget() { if (!CurrentTarget || !IsValid(CurrentTarget)) { CurrentTarget nullptr; } TArrayAActor* OverlappedActors; // 射程碰撞体提前在蓝图里挂好这里只收集重叠对象 GetOverlappingActors(OverlappedActors, AEnemyBase::StaticClass()); AActor* NewTarget nullptr; float BestDistSq TNumericLimitsfloat::Max(); for (AActor* Actor : OverlappedActors) { // 跳过已标记销毁的敌人否则后面开火会打空气 if (!IsValid(Actor)) { continue; } float DistSq FVector::DistSquared(GetActorLocation(), Actor-GetActorLocation()); if (DistSq BestDistSq) { BestDistSq DistSq; NewTarget Actor; } } if (NewTarget) { CurrentTarget NewTarget; } }逻辑说明GetOverlappingActors 返回所有和塔身上 Sphere Collision 重叠的 Actor第二个参数限定了只收集 AEnemyBase 类型。排序用 DistSquared 而不是 Dist因为开平方根每帧调用在大数量敌人时会有肉眼可见的 CPU 开销。拿到最近敌人后写入 CurrentTarget这一步只改指向不直接开火。参数说明扫描频率不要放在 Tick 里每帧跑我一般用 FTimerHandle 定时 0.2 到 0.3 秒扫一次塔的瞄准不需要 60 帧实时刷新。射程碰撞体半径就是塔的攻击范围改蓝图里的 Sphere Radius 即可不要改代码里的常量。3.2 炮口朝向与开火先旋转再发射的时序塔找到目标后不能秒射否则炮口转不过来看起来很假。常见做法是塔的炮口用 Look At 节点持续转向目标转到位后延迟 0.2 秒再开火。这个“转到位”的判断用角度差做计算炮口前方向量和目标方向向量的夹角小于 5 度就允许发射。// ATowerBase.cpp void ATowerBase::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); if (!CurrentTarget || !IsValid(CurrentTarget)) { return; } FVector MuzzleLoc MuzzleComponent-GetComponentLocation(); FVector TargetLoc CurrentTarget-GetActorLocation(); FVector ToTarget TargetLoc - MuzzleLoc; ToTarget.Normalize(); FVector MuzzleForward MuzzleComponent-GetForwardVector(); float Dot FVector::DotProduct(MuzzleForward, ToTarget); // Dot 接近 1 代表方向和目标方向几乎重合 if (Dot 0.996f) { if (!bCanFire) { bCanFire true; GetWorld()-GetTimerManager().SetTimer(FireTimer, this, ATowerBase::FireOnce, 0.12f, false); } } else { bCanFire false; } }逻辑说明这里用点积代替角度计算Dot 等于两个单位向量的夹角余弦值0.996 大约是 5 度以内的近似判断省掉 acos 反三角函数。bCanFire 是一个门闩防止每帧都触发定时器0.12 秒后只开火一次再等待下一次转到位。参数说明Dot 阈值调大比如 0.998炮口要求更准才开火调小0.95则炮口没完全转向也会发射。塔的射速由两个时间控制扫描间隔和开火延迟不要让开火延迟大于扫描间隔否则同一目标会被重复计时。3.3 伤害接口士兵与塔共用一套 DoDamage塔和士兵都会攻击敌人子弹在敌人身上触发伤害时最好走统一的伤害入口这样后面做暴击、减速、元素伤害只需要改一处。我的做法是给敌人基类写一个蓝图可重写的伤害函数子弹碰撞后调用它。// AEnemyBase.cpp void AEnemyBase::ApplyDamage(float BaseDamage, FVector HitFromLocation) { if (!bAlive) { return; } // 减法之外先预留蓝图事件让 BP 处理掉血飘字和受击动画 OnTakeHit_Blueprint(BaseDamage, HitFromLocation); Health - BaseDamage; if (Health 0.0f) { Die(); } }逻辑说明OnTakeHit_Blueprint 是 BlueprintImplementableEvent在 C 里声明为可蓝图覆写实际在敌人蓝图里做 UI 伤害数字、播放受击动画、喷血粒子等工作。C 只负责扣血和死亡判定分工干净。参数说明HitFromLocation 用于计算子弹击中的方向做击退或者受击动画朝向时要用到。如果只传伤害数值后面想做“敌人被击中后转向子弹来源”就得改接口所以一开始就把来源位置传进去。3.4 士兵单位和塔同源但守卫方式不同士兵在这个项目里不应该单开一套逻辑反而应该和塔共用 FiringUnit 基类。我一般建一个 AFiringUnit把扫描、开火、伤害调用都放进去塔和士兵都继承它。差别只在目标优先级和移动能力塔是固定点士兵可以先用 NavMesh 走到某个守卫点然后在守卫点锁定攻击。士兵的重写点只有两个一是在 ArriveAtGuardPoint 之后才开启扫描二是目标选择顺序改成“优先攻击离基地最近的敌人”。这个改动在子类里覆盖 PickTarget 方法就行不需要动父类的开火链路。4. 敌人寻路与波次配置NavMesh 参数和不堆蓝图的波次生成器4.1 NavMesh 参数小项目最容易忽略的 5 个数值塔防敌人从出生点走向基地常用方案是 Navigation Mesh也就是给关卡地面生成导航网格。NavMesh 的相关参数藏在 Project Settings → Navigation System 和关卡里的 NavMeshBoundsVolume 中。调参时我一般只动下面这几项参数默认值建议值作用Agent Radius4236决定敌人寻路时离墙多远越小越容易钻进窄道Agent Max Slope4430超过这个角度视为不可通行塔防地面要平整Agent Max Step Height4032敌人能迈过的台阶高度太高会从高地边缘穿模NavMesh Resolution105导航网格精细度值越小路径越准但构建越慢Use Async Buildfalsetrue勾选后构建导航不卡编辑器线程参数说明Agent Radius 和敌人胶囊体的半径要匹配。你如果做一大一小两种敌人一个半径 20 一个半径 60共用一个 NavMesh 会出问题小敌人走小路大敌人被卡在门口。常见的做法是给大敌人单独建一条大路不要强行调一个通用半径去兼顾。4.2 行为树里不堆蓝图敌人只要三件事塔防敌人的 AI 不需要复杂行为树我给每个敌人配的 AI Controller 只有三个任务走向终点、攻击基地、死亡清理。不用 EQS 也不用感知系统所有目标信息都在出生时写进黑板。# 行为树节点链UE4 编辑器里连出来的效果 EnemyBehaviorTree └── Root └── Selector ├── Sequence │ ├── Task: MoveTo [黑板键 Destination] │ ├── Task: PlayBasicAttack │ └── Task: DestroySelf └── Sequence ├── Task: Wait 0.5 └── Task: MoveTo [黑板键 Destination]逻辑说明这个结构让敌人先移动到终点到达后攻击基地动画播完就销毁自己。如果 MoveTo 因为寻路失败中途退出Fallback 的第二个分支会等 0.5 秒再试一次避免敌人永久卡住。实际做的时候我会把MoveTo的 AcceptableRadius 设成 30太大会导致敌人没碰到基地就开始播放攻击动画。参数说明黑板键 Destination 不要在行为树里直接填坐标而是让 SpawnManager 在生成敌人时用 SetValueAsVector 写进去。这样做的好处是换地图、换出生点不用改行为树只需改生成逻辑。4.3 波次配置用 DataTable 和 Python 脚本生成可调表波次设计是塔防游戏里最值得做成数据驱动的东西。我习惯用 UDataTable 存波次配置CSV 导入策划改表不用重新编译。先定一个行结构// FWaveRow.h USTRUCT(BlueprintType) struct FWaveRow : public FTableRowBase { GENERATED_BODY() // 第几波UI 上显示用 UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 WaveIndex 1; // 软引用类路径运行时按需加载敌人蓝图 UPROPERTY(EditAnywhere, BlueprintReadWrite) TSoftClassPtrAEnemyBase EnemyClass; // 总生成数量 UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Count 10; // 每个敌人的生成间隔秒 UPROPERTY(EditAnywhere, BlueprintReadWrite) float SpawnInterval 1.0f; // 血量倍率从第 5 波开始递增 UPROPERTY(EditAnywhere, BlueprintReadWrite) float HPMultiplier 1.0f; };结构里特意用了 TSoftClassPtr 而不是 UClass 硬引用因为 CSV 导入时填的是路径字符串软引用可以延迟加载启动不把所有敌人蓝图都拖进内存。对应的 CSV 生成脚本放在项目 Tools 目录下用 Python 维护一份# generate_wave_table.py # 目的按难度曲线生成 UE4 DataTable 可导入的 CSV import csv with open(WaveTable.csv, w, newline, encodingutf-8) as f: writer csv.writer(f) # RowName 是 UE4 DataTable 的行主键必须配置 writer.writerow([RowName, WaveIndex, EnemyClass, Count, SpawnInterval, HPMultiplier]) for wave in range(1, 16): # 敌人数量随波次增加但间隔也略微放宽避免同屏压力突增 count 8 int(wave * 1.4) interval max(0.4, 1.0 - wave * 0.03) hp_mul 1.0 (wave - 1) * 0.18 writer.writerow([ fWave_{wave}, wave, /Game/Blueprints/Enemies/BP_EnemyBase.BP_EnemyBase_C, count, f{interval:.2f}, f{hp_mul:.2f} ])逻辑说明RowName 是 UE4 DataTable 的强制列不能为空。EnemyClass 列是蓝图类的软引用路径路径对应 Content 目录下的蓝图位置中间用点号加 _C 结尾。生成器自己在运行时读取这一行数据不需要在关卡里手动放敌人。参数说明HPMultiplier 从第 1 波开始按 1.0 起步每波加 0.18到第 15 波大约 3.5 倍这个曲线对塔防来说前 5 波是教学后面开始逼玩家造塔。SpawnInterval 下限限制到 0.4 秒避免一波敌人生成得像开闸放水一样连携卡死。4.4 波次生成器让数据表真正跑起来生成器用一个 Actor 挂在关卡中启动后从 DataTable 里取行逐条生成敌人。读取行的时候用 RowName 而不是 WaveIndex不然行顺序一乱就找不到数据。// AWaveManager.cpp void AWaveManager::StartNextWave() { if (!WaveTable) { return; } CurrentWaveIndex; // RowName 按 Wave_1 的规律拼出来 FString RowName FString::Printf(TEXT(Wave_%d), CurrentWaveIndex); FWaveRow* Row WaveTable-FindRowFWaveRow(FName(*RowName), TEXT(Wave)); if (!Row) { return; } RemainingCount Row-Count; SpawnInterval Row-SpawnInterval; HPMultiplier Row-HPMultiplier; // 隔 SpawnInterval 秒生成一个敌人 GetWorld()-GetTimerManager().SetTimer(SpawnTimer, this, AWaveManager::SpawnOneEnemy, SpawnInterval, true); }逻辑说明FindRow 返回的是结构体指针直接取字段写入成员变量。定时器循环执行 SpawnOneEnemy每个敌人生成后 RemainingCount 减一减到零就清除定时器并进入下一波。TSoftClassPtr 在这个函数里用 LoadSynchronous 加载首次加载会有一点卡顿但比启动时全加载好。参数说明SpawnOneEnemy 里如果敌人出生点有几个可以用 RandomIntegerInRange 在出生点数组里挑一个不要所有敌人叠在一个坐标出生否则碰撞体会互相挤压。出生点间距至少是 Agent Radius 的 4 倍。5. 常见问题与排查构建光照后发黑、敌人卡墙、塔失灵的 5 个高频坑5.1 构建光照后场景发黑调高曝光也没用现象在编辑器里看场景颜色正常一按 Build Lighting整个场景暗得像夜晚只有灯附近一小圈是亮的。原因最常见是场景用了静态光源几何体没有正确的 Lightmap UV。检查地面和墙面的 Static Mesh 设置看 Generate Lightmap UVs 是否勾选另外忘了在 World Settings 里把 Lightmass 的 Indirect Lighting Quality 调高光照反弹次数不够背光面全黑。解决先打开地面模型的 Asset Details勾选 Generate Lightmap UVs 并重新保存然后 World Settings → Lightmass → Indirect Lighting Quality 调到 4Indirect Lighting Scale 调到 3。这两步做完再重新构建光照。如果还是发黑检查场景里有没有 PostProcess Volume 覆盖了曝光把它的 Auto Exposure 关闭或把 Exposure Compensation 提到 1.5。5.2 敌人卡在墙角或者走着走着像在墙上滑动现象NavMesh 生成之后敌人还是会在路口的墙角停住搓脚行为树一直显示 MoveTo 失败。原因Agent Radius 比敌人实际胶囊半径小或者地面上有很薄的装饰物让 NavMesh 判断为不可通行。更常见的是你改了地形后忘了重新 Build 导航编辑器里 NavMesh 显示的是旧数据。解决先在视图里打开 P 键显示 Navigation Mesh看不清就按 P 键左上角把 Agent Radius 临时调大一点辨色。确认网格覆盖敌人要走的整条路后看墙角有没有碎小的缺口。用编辑器左上角的 Build 下拉菜单重新 Build Paths不要手动拖 NavMesh。如果敌人是飞行怪直接关掉 NavMesh 改用样条线插值移动别硬塞进寻路系统。5.3 塔扫不到敌人碰撞响应全部是 Ignore现象塔范围里明明站了一批敌人GetOverlappingActors 返回的数组是空的塔从头到尾不开火。原因塔的射程 Sphere Collision 和敌人身体不在同一个碰撞通道或者碰撞预设把 Enemy 通道响应设成了 Ignore。塔防项目里最容易翻车的就是新建了敌人类但忘了在 Project Settings → Collision 里给 Enemy 所在的 Object Channel 设置 Pawn 响应。解决在 Project Settings → Collision 新建一个 Object Channel 命名为 Enemy选中敌人的根碰撞体Collision Presets 选 Custom把 Enemy 通道设成 Block再选中塔的 Sphere Collision把 Enemy 通道设成 Overlap。然后回头检查扫描代码里的 GetOverlappingActors 第二个参数限定的类必须和敌人蓝图继承的 C 基类一致。5.4 敌人死亡后塔还是对着尸体打现象敌人血条清空后塔检测到目标没换还在尸体面前转炮口子弹打不中任何东西。原因Die() 里只销毁了骨骼网格和碰撞敌人类本身还存活在场景里或者销毁用的是 SetLifeSpan在下一次扫描计时器触发前没有及时标记。解决在敌人 Die() 里先设置为 delayed kill再把 IsValid 的判断放到扫描代码最前面。如果你用 SetActorHiddenInGame(true) 做“假死亡”等待对象池回收一定要同时 SetActorEnableCollision(false)并让塔的扫描代码在 CurrentTarget 上做 IsValid 检查。凡是索敌逻辑遍历到无效 Actor 就直接跳过这项要在第一轮就写好。5.5 敌人超过 50 个帧率突然掉到 30 以下现象前几波都流畅第 6 波敌人多了以后Profiler 显示 GameThread 占用每帧十几毫秒DrawCall 却不高。原因敌人身上挂了大量每帧执行的蓝图 Tick比如每帧刷新位置、SetActorLocation、检测玩家距离更隐蔽的是子弹生成后没有对象池每个子弹自己还有碰撞体和 Tick几百颗子弹同时存在就会拖垮 GameThread。解决第一步给敌人和士兵的 Tick 打开只在有目标时 Tick距离过滤用 Map 的敌人基类手动频率更新。第二步把子弹从生成式改成对象池复用见下一章。第三步打开控制台执行 stat unit看 GameThread 和 Draw 的时间占比如果 Draw 高再考虑纹理和模型数量不要一卡就怪模型。6. 进阶验证对象池撑住百敌同屏用 stat unit 找性能瓶颈6.1 一个能跑的敌人对象池塔防同屏敌人数量上到七八十的时候反复 SpawnActor 和 DestroyActor 会造成频繁的内存分配GC 也会卡顿。常见的做法是维护一个敌人对象池敌人死亡后不销毁隐藏起来等下一次激活。// APoolManager.cpp AEnemyBase* APoolManager::AcquireAutoEnemy() { for (AEnemyBase* Enemy : Pool) { // 已经隐藏且未参与逻辑的复用 if (Enemy !Enemy-bIsInUse) { Enemy-bIsInUse true; Enemy-SetActorHiddenInGame(false); Enemy-SetActorEnableCollision(true); Enemy-Revive(); // 蓝图事件里重置血量和位置 return Enemy; } } // 池里不够再补一个后续也会归还 AEnemyBase* NewEnemy GetWorld()-SpawnActorAEnemyBase( EnemyClass, FVector::ZeroVector, FRotator::ZeroRotator); NewEnemy-bIsInUse true; Pool.Add(NewEnemy); return NewEnemy; }逻辑说明AcquireAutoEnemy 先从池里找已经隐藏的敌人实例找到就重新激活并调用 Revive 蓝图事件。Revive 里要把血量、半径、动画状态全部重置最容易漏的是到基地的攻击动画没停止复用后敌人会一边走路一边挥拳。池不够时按需补一个新实例不会无限扩张。参数说明池子初始数量按最大波次敌人数量设置比如第 15 波要 80 个池就先预热到 100 个。回收时在 Die() 里 SetActorHiddenInGame(true)、SetActorEnableCollision(false)并立刻把 bIsInUse 改成 false这样 Acquire 的下一次扫描就能找到它。6.2 怎么验证优化真有效控制台三个命令做完对象池别急着说“变快了”用 UE4 控制台跑三个命令看数据stat unit stat fps stat memory逻辑说明stat unit 给三行关键数字Frame、GameThread、Draw。塔防这种玩法Frame 高往往是 GameThread 的问题Draw 高才是渲染瓶颈。stat fps 告诉你实时帧数但不是所有场景都是因为敌人数量跌的要注意区分关卡加载瞬间的卡顿。stat memory 看 GC 之后内存是否回落对象池如果正确反复打几波后内存曲线应该平稳。参数说明这三个命令只在开发环境下可用打包后的游戏没有调试控制台。看帧数建议用无边框窗口跑编辑器 PIE 下的帧率天然比独立进程低优化前后对比不要跨模式比。我的习惯是把塔防性能优化压到“30 个敌人不掉帧”再上对象池因为前期加对象池会让死亡逻辑变复杂敌人数据重置漏一处就是翻车。这个方案我建议你也按这个节奏来先完整跑通闭环再根据 stat unit 的判断做池化如果同屏敌人始终在五十以内对象池能省则省把代码复杂度留给玩法扩展。希望帮到你。本文还有配套的精品资源点击获取