☰
UE4 C++实现子弹爆炸全流程:粒子、伤害、物理与音效
2026/10/15 13:44:31 网站建设 项目流程

看到这个标题,如果你以为只是给子弹挂一个粒子特效就完事,那就把这个功能想简单了。我最早做的时候也是这么想的,结果子弹飞出去是能炸出火花,但周围的敌人纹丝不动,加了个范围伤害后又发现爆炸总在奇怪的位置触发,最后粒子、伤害、物理、音效几套系统串在一起,才勉强像个正经爆炸。

这篇内容适合两类人:一类是刚接触UE4 C++、盯着蓝图面板无从下手的入门玩家;另一类是已经能用蓝图拖出完整玩法的朋友,想换到C++里理顺逻辑。我尽量用实际走通的方式来写,不堆API文档,先说拆解思路,再走一遍可落地的代码流程,最后把那些文档里不会写的坑一起列出来。如果你手里正有一个射击Demo或者某个跨平台系统项目,需要给子弹、手雷、炮弹这类东西加上范围爆炸效果,这篇文章可以直接当操作手册来用。

1. 先把爆炸效果拆开看

1.1 一颗会爆炸的子弹到底要干哪几件事

一颗子弹命中目标后产生爆炸,这件事在玩家眼里就是一瞬间的反馈,但在引擎层面,这一瞬间至少拆成了四件独立的事情:

第一,视觉反馈。爆炸的那一刻要有火光、烟尘、碎片、冲击波,甚至镜头附近的屏闪。这部分在UE4里通常由粒子系统负责,选定一个爆炸位置,生成一个特效实例。没有视觉反馈,后面的伤害和物理效果做得再好,玩家也不知道“炸了”。

第二,伤害结算。爆炸不是只炸命中点那一个对象,而是影响半径范围内所有能被伤害的对象,包括敌人、破坏物、载具等。UE4里可以直接走RangeDamage这条路径,把爆炸中心和爆炸半径传进去,交给引擎判断到底谁吃了伤害、吃多少。

第三,物理冲击。爆炸会对场景里的物理物体产生推力,把桶吹飞、把木板震开、把角色撞退。做法是在爆炸点施加一个径向冲击力,让所有在半径内并且开了物理模拟的物体自然受力。

第四,听觉反馈。爆炸没有声音就像隔着一层玻璃,哪怕画面做得再炸,缺少低频轰鸣就是不对味。UE4里可以在爆炸点播放一个有衰减距离设置的音效,让远处的玩家听到的是“闷响”,近处的玩家听到的是“炸裂”。

这四个环节缺一不可。很多新手做爆炸只做了第一件事,粒子一出就以为完成了,结果敌人不掉血、物体不被掀飞,玩家试玩一次就会觉得手感很假。你只有把视觉、伤害、物理、音效这四条链路全部接上,才算真正做了一个爆炸效果。

1.2 为什么要用C++而不是全用蓝图

同一个功能蓝图也能做,为什么非要写C++?我先说蓝图在这件事上的几个痛点,再讲C++真正适合的场景。

第一个明显问题是,蓝图节点的组织成本高。一个完整爆炸链路,蓝图里至少要用到Spawn Emitter、Apply Radial Damage、Add Radial Impulse、Play Sound at Location四个大类节点,中间还要穿插Transform换算、返回值和Enable Check。几个节点排下来没问题,排到十几个之后,连线已经乱成蜘蛛网。C++里这些也就是连续几行函数调用,读起来一目了然。

第二个问题是可复用性。你写完一颗子弹,接下来很可能要写手雷、火箭、炮塔炮弹。蓝图版本里要么复制一份再改参数,要么做数据驱动模板,控制起来都比较绕。C++的话,直接写一个基础子弹类,把爆炸半径、伤害、粒子、音效全做成可编辑属性,子类只需要改默认值就能出新武器,不用重复写逻辑。

第三个是性能。虽然蓝图有编译后的字节码,性能损失在低频率调用里根本感觉不出来,但子弹是射击游戏里一帧内可能生成几十个、每个都要做碰撞检测和事件回调的Actor。用C++写能保证在高火力场景下,爆炸逻辑这部分不会成为瓶颈。这不是说蓝图不能做,而是高频逻辑放到C++更稳。

但是我也不是建议所有东西都写C++。爆炸时的具体粒子资源配置、音效衰减参数、蓝图动画通知,这些属于美术策划调参的范畴,适合留在蓝图里让策划去折腾。C++负责逻辑骨架,蓝图负责资源微调,分工才是最舒服的。

1.3 先想清楚爆炸效果属于哪一类“攻击反馈”

动手之前还得分清楚,你的爆炸是命中即爆,还是延迟引爆?这两者虽然共享伤害和特效逻辑,但触发方式完全不同。

命中即爆,常见于子弹、动能弹头。逻辑是子弹撞到任何物体或敌人时,在该点立刻执行爆炸。这种情况下要格外注意,命中点和爆炸中心可能在极短距离内偏移,因为碰撞反馈坐标可能落在碰撞面边缘,而爆炸中心应该是子弹穿过物体内部的那个点。

延迟引爆,常见于手雷、榴弹发射器、地雷。逻辑是子弹或投射物落地不爆炸,计时结束或者经过特定条件判断后才引爆。这种情况下,爆炸点通常就是投射物当前帧所在的世界坐标,伤害结算和特效生成在同一位置,反而简单。

不管哪种类型,我建议把“爆炸”抽成一个独立函数,比如DoExplosion(const FVector& ExplosionLocation)。因为不管触发时机是命中、定时还是被外部调用,最后执行的逻辑完全相同,都是生成粒子、结算伤害、施加冲击、播放音效。抽成函数后,你后续加“遥控引爆”甚至“被别的子弹引爆”都很方便,只需在某处调一下这个函数。

2. 动手前需要把这几样东西理清楚

2.1 粒子系统到底选Niagara还是Cascade

UE4里加特效有两条路,一个是老牌的Cascade,一个是新版的Niagara。网上吵得厉害,我直接说我实际使用的结论:如果是UE4.26或更新的版本,建议无脑用Niagara。

Niagara在项目里已经被官方确立为主要发展方向,编辑器里新建特效默认就是Niagara系统;Cascade属于维护状态,新功能基本不往里加。爆炸这种需要大量粒子、动态参数、多阶段发射的特效,Niagara的处理粒度更细,比如你可以很方便地让“火光”先爆发,“烟尘”延迟0.15秒再跟上来,“碎片”按物理速度飞出去,这种模块化的时序控制在Niagara里用节点搭就行。

但这不代表Cascade不能做爆炸。如果你的项目里已经有一整套Cascade特效资产,或者团队里美术只会Cascade,那就继续用Cascade也没问题。UE4在运行时兼容两者,C++里对应两套API:传统粒子用UParticleSystem和UParticleSystemComponent,Niagara用UNiagaraSystem和UNiagaraFunctionLibrary。

我这里代码以Niagara为主,因为新建项目默认就能用,而且UNiagaraFunctionLibrary::SpawnSystemAtLocation这个API特别直观,传一个世界坐标、一个资产引用、一个旋转就能生成效果,非常适合爆炸场景。如果你仍旧用Cascade,把后面代码里的Niagara部分换成UGameplayStatics::SpawnEmitterAtLocation就是同一套思路。

2.2 特效触发点、伤害范围和物理冲击的关系

这三者很容易搞混,尤其刚上手时,最容易犯的错就是把特效触发点、伤害中心、物理冲击中心分成三处。实际上它们的原点应该严格保持一致——都是子弹命中瞬间所在的世界坐标。

用一个变量把命中点先存下来,后面所有逻辑都基于这个点操作,而不是各自取当前位置。为什么?因为子弹是高速运动的,碰撞回调触发到特效生成之间可能隔了几帧,子弹已经飞出去一段距离,如果特效在子弹“当前”位置播放,就会看到爆炸点和碰撞点脱离,玩家会觉得这爆炸和我们不在一个平面。

另外,伤害范围半径和物理冲击范围半径可以不等。比如一颗威力大的子弹,爆炸半径为300,伤害衰减到20%,但对场景物体的物理冲击可以只影响150,避免把小纸箱吹飞得过远。这两个参数建议分开暴露到属性面板,而不是共用一个半径,调起来会很爽。

我见过一个比较常见的手感细节:视觉特效半径往往和物理冲击半径一致,但伤害结算半径要更大一点。因为玩家看到火光的范围是有限的,明明有火光,目标站在范围外却不受伤害,会被喷“这都不死”;反过来目标明明只站在烟尘边缘,却被一颗小口径子弹炸掉半条血,又会被喷“太离谱”。让伤害半径比视觉半径略大一些,能在不违和的前提下提高命中宽容度。

2.3 给子弹挂组件之前想清楚的事

子弹Actor本身通常需要三个基础组件:碰撞体、可视化网格体或粒子轨迹、投射组件。组件选择的背后有讲究,不是三个拖上去就完事。

碰撞体建议用球体组件,半径为子弹模型尺寸的0.6到0.8倍。不要直接拿碰撞体做视觉本体,也不要让碰撞体过大,否则子弹还没碰到目标就提前爆炸,看起来像隔空炸人。球体碰撞体还方便做穿透和不穿透逻辑。

可视化这块,如果子弹是实体弹头,就挂UStaticMeshComponent;如果是能量弹或者曳光弹,通常用粒子组件来做飞行轨迹。爆炸效果本身用的是独立组件或Spawn生成,不要复用飞行粒子组件,否则飞行轨迹会被特效打断,飞行到爆炸之间会出现一段空白。

投射组件就是UProjectileMovementComponent,必须设置初始速度、最大速度、重力缩放。很多新手子弹飞出去呈抛物线甚至直接坠落,就是因为没关重力或者速度设置太低。常规子弹直接设重力缩放为0,让它直线飞行;榴弹类则保留重力,让玩家能看到抛物线弹道。

组件设置好之后,还有一个隐藏细节:碰撞体的碰撞预设。子弹应该“Block”敌人和墙体,但“Overlap”其他子弹或无关触发器,否则子弹飞行途中碰到场景里的透明碰撞盒就会爆炸,导致明明是冲墙打的,子弹却在半空炸了。我一般会给子弹单独建一个ProjectilePreset,只让某些对象通道产生Block响应。

3. 代码走读:一颗子弹从命中到爆炸的全流程

3.1 子弹头文件和构造函数

我假设你已经有一个基础子弹Actor类,名字叫ABaseProjectile。头文件里需要声明几个关键成员:碰撞组件、网格组件、投射组件、爆炸特效资产、爆炸范围、伤害数值、音效资产、是否已经爆炸过的标记。

UCLASS() class MYPROJECT_API ABaseProjectile : public AActor { GENERATED_BODY() public: ABaseProjectile(); protected: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component") USphereComponent* CollisionComp; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component") UStaticMeshComponent* MeshComp; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component") UProjectileMovementComponent* MoveComp; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Effect") UNiagaraSystem* ExplosionFX; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Effect") USoundBase* ExplosionSound; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Damage") float BaseDamage = 80.f; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Damage") float ExplosionRadius = 400.f; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Damage") float ExplosionForce = 600.f; UPROPERTY() bool bHasExploded = false; UFUNCTION() void OnHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit); public: void DoExplosion(const FVector& ExplosionLocation); };

构造函数里把三个组件创建出来并建立层级关系:

ABaseProjectile::ABaseProjectile() { PrimaryActorTick.bCanEverTick = false; CollisionComp = CreateDefaultSubobject<USphereComponent>(TEXT("CollisionComp")); RootComponent = CollisionComp; CollisionComp->SetSphereRadius(10.f); CollisionComp->SetCollisionProfileName(TEXT("ProjectilePreset")); MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComp")); MeshComp->SetupAttachment(RootComponent); MeshComp->SetCollisionEnabled(ECollisionEnabled::NoCollision); MoveComp = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("MoveComp")); MoveComp->InitialSpeed = 3000.f; MoveComp->MaxSpeed = 5000.f; MoveComp->ProjectileGravityScale = 0.f; }

注意几个设置项。PrimaryActorTick.bCanEverTick = false这个很关键,子弹不需要每帧Tick,让引擎跳过Tick开销,高频生成子弹时能省不少性能。碰撞组件挂为RootComponent,所有子组件都挂在碰撞体下面,这样位移、旋转都以碰撞体为准。网格体本身关掉碰撞,避免“子弹外壳”再加入一次碰撞检测,导致一次命中被触发两次回调。

3.2 绑定命中事件与碰撞通道

子弹生成后,必须在BeginPlay里把碰撞体的Hit事件绑定到OnHit函数上。在UE4 C++里,组件事件绑定通常写在BeginPlay里,不写在构造函数里,因为Actor组件在构造函数阶段还没完全就绪,有些事件需要等组件注册完成。

void ABaseProjectile::BeginPlay() { Super::BeginPlay(); CollisionComp->OnComponentHit.AddDynamic(this, &ABaseProjectile::OnHit); }

事件绑定之后,要在碰撞预设里让子弹能命中目标。我之前说过建一个ProjectilePreset,这里展开讲到底是什么。

Object Type : Projectile World Static : Block World Dynamic : Block Pawn : Block PhysicsBody : Block Projectile : Overlap

你可以复制引擎自带的Pawn或PhysicsActor预设修改,也可以直接在建好的资产内存里改碰撞响应。关键思路是:子弹对正常挡路的物体是Block,对玩家、敌人、可破坏物是Block;但子弹对自己这个类型是Overlap,避免两颗子弹对撞互炸,也避免子弹和它自己发射的残影碰撞。如果你的玩法里希望子弹能被其他子弹拦截,那再单独调整。

OnHit回调函数签名要注意,别手滑写错。UE4在碰撞回调参数类型上非常严格,尤其const FHitResult&这个引用不能漏。一个标准写法是:

void ABaseProjectile::OnHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit) { if (OtherActor == nullptr || OtherActor == this) { return; } if (bHasExploded) { return; } bHasExploded = true; DoExplosion(Hit.Location); }

用bHasExploded标记是这一节最重要的操作。爆炸一旦执行,就把标记置为true,后续任何碰撞回调都直接返回。你能彻底避开“子弹命中后触发不止一次爆炸”的问题,连监督加设都不用。为什么会有重复触发?因为一个Actor身上可能挂了多个碰撞体,或者子弹穿透后连续碰到两个物体,事件在极短帧内被连续调用,没有标记位就会连着生成好几个爆炸特效。

3.3 写爆炸逻辑:生成粒子、范围伤害、冲击波

爆炸核心逻辑集中在DoExplosion函数里,这个函数接收一个世界坐标,然后依次执行四件事。

void ABaseProjectile::DoExplosion(const FVector& ExplosionLocation) { if (ExplosionFX) { UNiagaraFunctionLibrary::SpawnSystemAtLocation( GetWorld(), ExplosionFX, ExplosionLocation, FRotator::ZeroRotator, FVector::OneVector, true, true, ENCPoolMethod::AutoRelease ); } if (ExplosionSound) { UGameplayStatics::PlaySoundAtLocation( GetWorld(), ExplosionSound, ExplosionLocation, 1.f, 1.f, 0.f, nullptr, nullptr, nullptr ); } TArray<AActor*> IgnoreActors; IgnoreActors.Add(this); UGameplayStatics::ApplyRadialDamage( GetWorld(), BaseDamage, ExplosionLocation, ExplosionRadius, UDamageType::StaticClass(), IgnoreActors, this, GetInstigatorController(), true ); if (ExplosionForce > 0.f) { TArray<FOverlapResult> Overlaps; FCollisionShape Shape = FCollisionShape::MakeSphere(ExplosionRadius); FCollisionQueryParams QueryParams; QueryParams.AddIgnoredActor(this); QueryParams.bTraceComplex = false; GetWorld()->OverlapMultiByChannel( Overlaps, ExplosionLocation, FQuat::Identity, ECC_PhysicsBody, Shape, QueryParams ); for (const FOverlapResult& Overlap : Overlaps) { UPrimitiveComponent* PrimComp = Overlap.GetComponent(); if (PrimComp && PrimComp->IsSimulatingPhysics()) { PrimComp->AddRadialImpulse( ExplosionLocation, ExplosionRadius, ExplosionForce, ERadialImpulseFalloff::RIF_Linear, true ); } } } }

这段代码每一块都有细节。先说粒子生成部分,SpawnSystemAtLocation的参数里最后一个ENCPoolMethod::AutoRelease是Niagara的实例池回收入口,可以让特效播完后自动会收到Niagara系统池里复用,不产生Actor开销,比手动管理实例生命周期省事得多。

范围伤害这里注意ApplyRadialDamage的最后一个参数bool bFullDamage。为true时,半径内所有对象都吃满额伤害;为false时,引擎会按距离做线性衰减,边缘只有50%伤害。这对爆炸来说显然是false更真实。另外IgnoreActors数组要添加子弹自己,避免子弹炸自己,虽然一般子弹身上没挂伤害组件,但保险起见加进去。

物理冲击部分,我没有直接调用一个现成的“径向伤害并把物体吹飞”的函数,而是用OverlapMultiByChannel手动找出爆炸半径内的所有物理体,再逐个施加AddRadialImpulse。这是因为ApplyRadialDamage只处理能受伤害的Actor,很多纯物理小物件比如桶、箱子、碎石根本不受伤害,你不主动遍历它们,它们永远不会被炸飞。用ECC_PhysicsBody作为碰撞通道去查,能比较准地抓出所有可模拟物理的物体。

AddRadialImpulse的两个参数值得解释:bVelChange设为true时,冲击力会直接改变物体速度,而不是基于质量计算加速度,这样小箱子和大铁桶会被吹得差不多远,视觉冲击力更统一;设为false时,质量大的物体纹丝不动,表现更真实。普通娱乐向项目我建议bVelChange=true,玩起来爽快;写实项目改成false。

3.4 爆炸后的自我清理

爆炸逻辑执行完,子弹Actor不能留在场景里,否则会变成一个无碰撞的幽灵Actor挂在关卡中,积累多了照样卡顿。在DoExplosion函数末尾加上生命周期处理:

SetLifeSpan(0.1f);

实现方式也可以换用定时器,比如手动SetTimer0.1秒后销毁。两种方法效果差不多,SetLifeSpan更简单直接。为什么不用立即Destroy?因为粒子、音效这些效果是独立生成的,它们和子弹Actor不是从属关系,子弹立刻销毁不影响特效。但如果音效或粒子组件是挂在子弹Actor上的,那必须延迟销毁,否则实例还没来得及表现就被一并清掉。我这里用SpawnSystemAtLocation生成的是独立实例,所以延迟0.1秒销毁只是为了给可能的组件清理一个缓冲,不是硬性要求。

还有一点,爆炸后建议把子弹的碰撞检测关掉,避免销毁前最后的残影阶段还触发碰撞事件。实测中,如果不关,极少数情况下会出现子弹销毁瞬间碰撞体还在场景里待了一帧,又把旁边的敌人撞了一下。保险写法:

CollisionComp->SetCollisionEnabled(ECollisionEnabled::NoCollision); MeshComp->SetVisibility(false);

代码写在bHasExploded = true之后,DoExplosion执行的同时就关掉。这样玩家看到的是“子弹撞过来——火花炸开——子弹消失”的连贯画面,而不是子弹留在原地发愣。

4. 常见问题与排查技巧实录

4.1 粒子没出现,到底哪一步断了

粒子不出现是高发问题,排查时不用凭感觉试,按这条链路一步步看。

先看资源引用有没有赋上。属性面板里ExplosionFX是不是空?如果基类这段代码被很多子弹子类继承,子类可能忘了配置Niagara资产。这个最基础但确实最多人犯。

再看Spawn函数是否真的执行到了。在DoExplosion第一行加一个 UE_LOG,LogTemp 输出“Explosion Called at location”。如果日志没有,说明OnHit回调都没触发,问题在碰撞绑定或碰撞预设。如果日志有但没粒子,问题大概率在Niagara资产本身。一个空资产或者某个模块报错,引擎会在输出日志里打Warning,重点看“Niagara”关键字。

还有一个容易踩的坑:粒子定位在调试视图里能撞见,但玩家视角里看不到。这是因为爆炸特效在命中点生成,命中点可能嵌在墙体内部,粒子被墙体遮挡了。遇到这种,要让特效在命中点沿着表面法线往外偏移一点,常见做法是Hit.Location + Hit.Normal * 10.f,把特效中心提到表面外。虽然物理上爆炸中心还是在墙内,但视觉上粒子不被吞掉。

我整理了一份快速定位表,给排查时照着走:

现象可能原因处理办法
完全没有粒子ExplosionFX未赋值检查属性面板资源引用
完全没有粒子且无日志碰撞回调未触发检查OnComponentHit绑定和碰撞预设
粒子在墙体内部命中点是表面坐标沿法线偏移10-20个单位
粒子出来但瞬间消失Niagara资产生命周期过短检查Niagara发射器时长和循环设置
只看到烟尘没看到火光资产内多个Emitter时序重叠检查Niagara各发射器的Delay和SpawnRate

4.2 伤害打不中,目标明明就在爆炸范围里

伤害没生效比粒子不出现还要恼人,因为画面明明炸了,敌人血条就是不掉。逻辑上这里有几个非常常见的坑。

第一个坑:ApplyRadialDamage默认只对Pawn、Resource、Destructible等几个特定频道Block的Actor产生伤害。你的敌人Actor可能只用了一个Custom对象类型,或者碰撞体响应设置成了Overlap,导致范围伤害检测时根本看不到它。解决方法是检查目标Actor的碰撞体是否把Object Type设为“Pawn”,并且对WorldDynamic、Pawn这几个伤害相关通道保持Block。

第二个坑:伤害是每帧都在计算,但你的DamageEvent没被处理。如果你没有在敌人Actor里实现TakeDamage的重写或UDamageType的关联,默认Actor只会收到0伤害。可以测试方式:在敌人Actor的TakeDamage函数里加一行UE_LOG,看伤害是否被调用,如果调用但血量没减,说明血量和伤害计算逻辑在别处断了;连调用都没有,问题就在碰撞和可见性上。

第三个坑:爆炸中心的坐标不对。比如你直接把子弹GetActorLocation()作为爆炸中心,但子弹飞行了3000的初始速度,碰撞回调到达引擎时,子弹可能已经穿进墙里1米多,伤害范围中心在墙内部,敌人站在墙外,距离判定自然吃不到伤害。稳妥的办法是用命中回调里的Hit.Location,或者提前在bool判断后临时存储命中点。我上面代码里DoExplosion(Hit.Location)就是基于这个考虑。

还有一个容易被忽略的参数:ApplyRadialDamage的bFullDamage。我遇到过一个项目,所有子弹伤害都是满值,某一天策划调了一版参数,变成范围衰减,结果边缘伤害掉到20%,配合网络延迟出现了大量“明明炸得很近,对方却没死”的反馈。这不是bug,是参数设计问题,但调试时容易被误判为核心逻辑坏了。建议初期先用bFullDamage=false,方便观察衰减趋势,调好后再决定是否固定。

4.3 同一颗子弹反复触发爆炸

反复爆炸在刚写完第一版时几乎必现,症状有两种:一是同一个位置连续炸出好几团火花,二是子弹已经销毁了,还会莫名再响一声。原因我前面提过一次,就是多次碰撞回调被触发。

典型场景有三个。第一,子弹撞上A物体后,弹道发生了微小反弹,在半帧内又撞上B物体,两次碰撞都调用了OnHit。第二,目标Actor身上有两个碰撞体,比如一个胶囊体加一个静态网格,子弹同时和它们产生碰撞。第三,子弹碰撞体用Sphere且子弹飞行速度极快,同一帧里引擎生成了多个Contact点,回调被多次调用。

解决办法就是我代码里的bHasExploded标记位。注意把标记位置放在DoExplosion调用之前,别放在函数开头。否则如果DoExplosion里执行了延迟销毁,第一帧回调进来已经把标记置true,后面回调都会被挡掉。

如果你还要处理“子弹穿透多个目标”的玩法,比如穿甲弹,那就不能简单用bHasExploded,要改成“爆炸只发生在最后一个命中点”,或者在命中计数达到上限时才爆炸。这种需求就不能在OnHit里直接调DoExplosion,而是维护一个命中数组,遍历结束后统一决定爆炸位置。

4.4 性能告警与内存踩坑

爆炸效果属于高频特效,子弹多发齐射时,一帧内可能生成几十上百个Niagara实例和声音实例。如果每次都直接Spawn不回收,打到弹幕关卡时帧率会直线往下掉。UE4的调试工具里一般能看到“Active Niagara Systems”数量蹭蹭涨,这就是没走实例池的表现。

Niagara的SpawnSystemAtLocation带了池化参数,初始化时传入ENCPoolMethod::AutoRelease后,系统播完会自动回到池子,可以复用。如果你写的老代码用了AActor* SpawnActor的方式去生成Niagara组件,那每个爆炸都是生成一个Actor,开销完全不同,建议重构成直接Spawn系统。

声音同理,PlaySoundAtLocation每次都会创建一个临时音效对象,同样建议控制一次爆炸的音效数量。声音资源自带的衰减距离要设置合理,比如爆炸半径400,音效衰减距离至少800到1000,否则玩家离远一点就完全听不到声,也是很不真实的。

内存方面的坑集中在延迟销毁上。如果子弹爆炸后没有调用SetLifeSpan,大量子弹Actor残留在关卡里,每个还挂着碰撞体、网格、粒子组件,内存会持续增长。UE4自带的内存分析面板里能看到“Level Actors”数量不降,基本就是这个原因。凡是我写的实战项目,爆炸后必加销毁,没有例外。

5. 爆炸效果还能往哪些方向扩展

5.1 延迟引爆和遥控引爆

做完命中即爆之后,最常见的扩展就是延迟引爆。实现方式很简单,在OnHit里不直接调用DoExplosion,而是通过定时器延迟后再调:

FTimerHandle ExplosionTimerHandle; GetWorld()->GetTimerManager().SetTimer( ExplosionTimerHandle, [this]() { if (IsValid(this)) { DoExplosion(GetActorLocation()); } }, 0.8f, false );

注意Lambda里加IsValid(this),否则Actor如果提前被销毁,回调进来就是对已回收对象调函数,UE4会直接崩溃。这种延迟引爆特别适合手雷、黏性炸弹、火箭筒命中后自动延迟炸这一套。遥控引爆就是在Actor上再存一个bool bShouldExplodeOnPress,外部调用时触发同一段爆炸逻辑,函数抽出来的价值这时候就体现出来了。

5.2 爆炸与关卡布景的交互

爆炸真的只做伤害和粒子,还停留在“功能能用”层面。想让爆炸“活在”关卡里,还需要和布景交互。比如爆炸能震碎窗户、打断柱子、点燃干草。这些交互在三A项目里由特效部门配合物理资产完成,小项目也能做轻量版本。

一个比较容易落地的方案是:给破碎物挂一个UDestructibleComponent,然后用ApplyRadialDamage直接炸毁它们。引擎的破坏系统会按预切分块碎裂,你只要在爆炸点附近确保伤害达到阈值即可。实测时要注意,Destructible的伤害事件比普通Actor更敏感,经常出现轻微擦伤就触发碎裂,所以破坏阈值或伤害半径要单独调。

如果项目里没有破坏系统,可以在爆炸命中时用SetStaticMesh替换静态网格体,例如把完整玻璃窗替换成满屏裂痕的贴花,再播放碎玻璃粒子。这个方案的优点是引擎版本无关、不用额外插件,缺点是表现力比Destructible差一些,适合原型和中小体量项目。

5.3 面向群体的武器子弹改进

当你从单发子弹扩展到霰弹枪、火箭炮、多弹头导弹时,C++基类的扩展优势就出来了。继承ABaseProjectile,子类重写DoExplosion或者只是改几个属性默认值。

比如霰弹枪子弹:单颗伤害小,但一次发射泼出去8颗,爆炸半径小但数量多,适合做近身爆发。你可以在子类构造函数里直接ExplosionRadius = 120.f; BaseDamage = 25.f; ExplosionForce = 100.f;,逻辑完全不变,手感完全不同。

再比如多段爆炸导弹:可以先按3.1节代码做一个ABaseProjectile,子类中重写DoExplosion,先做一次小爆炸,然后用定时器再生成一次二次爆炸。同一个DoExplosion函数被多次调用,因为粒子、伤害、物理都是独立实例,做多段伤害很自然。

面向群体的另一个方向是做TeamId区分,也就是队友不被自己人炸伤。ApplyRadialDamage自带的伤害通道判断在单机里够用,但多人项目里一般要自己在命中回调里过滤TeamId,防止队友被自己人的爆炸波及。实现方式也不复杂,给子弹基类加int32 TeamId,爆炸时遍历范围内的Actor,检查TeamId相同则加入IgnoreActors数组。

5.4 用Niagara做“爆炸后再来一道冲击波”

上面讲的爆炸大多是瞬时结果,但真实爆炸是有时间层次的:最先是高亮闪光,然后是扩散的火球,然后是往四周滚动的烟尘,最后是碎片落地。想在UE4里还原这个层次感,Niagara的模块化发射器可以帮你完成。

你可以做一个Niagara资产,里面排三个发射器:第一个发射器在0到0.1秒内爆发大量高亮粒子,颜色偏白,模拟闪光;第二个发射器从0.05秒开始,生成火球粒子,往四周扩散,渐变成橙色和红色;第三个发射器延迟0.3秒,生成烟尘粒子,速度慢、透明度低,慢慢飘散。每个发射器单独控制寿命、初始速度、衰减参数,最终调出来的效果会远超单个粒子发射器。

这属于美术和玩法联动的活,C++侧只需要确保DoExplosion传出的爆炸位置是正确的,Niagara资产内部怎么做时间编排完全不用C++介入。这也是我建议把特效资源单独配置而不是写死在C++里的原因,策划改动特效不用找程序重新编译。

我个人在实际调试中的体会是,爆炸效果写顺畅之后,回看整个流程,C++代码本身其实并不复杂,复杂的是把事件流理顺。什么时候调碰撞、什么时候生成特效、什么时候结算伤害、什么时候清理自己,这四步只要不乱,爆炸就是稳的。真正想在玩家手里出效果,重心反而要放在参数调教上:爆炸半径该多大、伤害衰减多快、粒子停留多久、音效能传多远,这些数值比任何代码技巧都更能影响手感。如果你现在正在做射击相关的内容,我的建议是先按这套结构跑通一颗子弹,然后把参数交给策划或者自己反复试,试到自己都觉得“炸得爽”为止。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询