我最早对“UE5 CameraShake”有明确的概念,是给一个射击项目做命中反馈。当时调了一套后坐力、受击闪白和音效,交给测试一玩,对方第一句话是“开枪没感觉”。我盯着参数看半天,最后把镜头震动加上去,同一套枪械感觉立刻就不一样了。也是从那次之后我才真正把CameraShake当成一个独立模块来对待,而不是“顺手加点抖动”。这篇文章就把我在这条路上梳理过的东西完整写一遍,从系统选型到蓝图和C++实现,再到参数调优和排查经验,给同样在UE5里做战斗反馈的朋友做个参考。
CameraShake在UE5里说简单很简单,一个节点就搞定;说复杂也能很复杂,涉及旧版LegacyCameraShake和新版CameraShakeBase两条技术路线,还有Pattern、衰减、BlendIn/BlendOut这些细节。如果开发时只盯着“抖一下”这个结果,很容易做出廉价感——乱抖、晕车、看不清楚瞄准点。我会尽量把“为什么这么设置有效”也讲透,而不是只给一组参数让你照抄。
1. 反馈系统的关键一环:为什么震屏值得认真做
1.1 画面反馈的优先级很高,震屏属于最直接的“体感层”
玩家操作角色时,他获得反馈的通道大概有三层:第一层是UI反馈,比如血条变化、命中数字、准星扩散;第二层是视听表现,比如枪口火焰、受击特效、打击音效;第三层就是这个项目标题里说的镜头反馈。这个顺序有时候会让人误以为镜头反馈是最后加的花头,但实际体验里它恰恰是最先被大脑处理的信号。
为什么这么说?因为人类对视野内突然发生的整体位移非常敏感。你在玩一个第三人称游戏,主角被怪物拍了一掌,如果画面纹丝不动,玩家大脑得到的判断是“这掌没有力量”;即便血条掉了三分之一,那种受伤感依然是打折的。反过来,一个0.1秒的微小镜头位移,就足以让玩家产生“这一下很重”的体感。手机和主机上的很多动作游戏都会把相机震动做得十分夸张,因为它用极小的成本,就能给大场面增加重量感。
所以震屏的作用不是“画面特效”,它更像是一层低延迟的体感信息,告诉玩家这个事件有多大、多近、多致命。射击游戏里开枪时镜头轻微上扬,是模拟后坐力对射手视野的影响;爆炸时整个画面抖动,是在传递冲击波的存在;角色被控制技能打中时画面猛抽一下,是在强化“你被打断了”这一负面反馈。如果你把这些事件全部去掉震动,游戏会变得“清晰但寡淡”,就像一个武术指导跳完了一整套动作但没有配合任何受力反应,谁都看得出他打得很认真,但谁都不觉得疼。
1.2 新旧两套体系容易混,先分清再动手
UE5里提到相机震动,你会看到两种完全不同的写法。一种是UE4时代沿用下来的ULegacyCameraShake,类名还挂着Legacy,很多人从老教程里复制来的代码基本都是这一套;另一种是UE5新设计的UCameraShakeBase配合CameraShakePattern的组合方式。
我建议新项目不要再走Legacy路线。不是因为它跑不动,而是新体系在设计上把“震动资产”和“震动模式”做了分离,一个CameraShakeBase可以挂载不同的Pattern,也支持更精细的衰减和混合。最直接的优势是,同一段震动你想在爆炸时用、受击时用,不用复制出一堆资产来,只要参数不同就能区分。
有件事很坑:网上大量旧教程里的变量名是OscillationDuration、OscillationBlendInTime这类,这套东西在新Pattern里已经不存在。如果你照着抄,会发现蓝图编译不过,或者C++里找不到属性。看到这类关键词基本就能判断是LegacyCameraShake教程,先确认版本再决定是否采纳。
1.3 震动也要控制预算,不是越多越好
这里说的预算不单指性能。UE5的相机震动本质上是在每一帧给相机叠加一个随时间变化的偏移,性能开销并不大,真正的问题是体验预算:玩家能接受的“无意义画面运动”是有限的。
战斗类游戏里如果每一下攻击、每一次移动、每个特效都带震动,玩家很快就会晕,而且会把震动当成“噪声”忽略掉。我自己的习惯是先把反馈事件分级:核心交互事件(玩家受击、武器开火、爆炸)必须配震动;重要但非致命事件(敌人倒地、远处坍塌)配轻量震动;纯装饰性事件就不给。分级后你会发现,震屏用得越克制,用在刀刃上的那几次就越有冲击力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两种触发方式的选择逻辑
2.1 世界空间触发和相机空间触发到底怎么选
UE5里CameraShake的触发入口很多,但归纳起来是两大方向:一类是以世界空间中的一个位置为震源,比如用UGameplayStatics::PlayWorldCameraShake,需要指定爆炸点坐标和影响半径,玩家离震源越远震动越弱;另一类是直接在当前相机上播放,比如PlayerCameraManager的StartCameraShake,它不关心震源在哪,只要触发就全强度播放。
我来项目里第一版实现的时候,把所有震动都做成了“世界空间触发”,理由是爆炸和受击都有明确的位置信息。后来发现有些事件压根没有世界坐标:玩家自己被沉默技能命中,没有AB爆炸物、没有伤害来源,只是一个状态变化,这种情况硬找震源很别扭。更合理的分法是:震源在外界且依赖距离衰减的场景用PlayWorldCameraShake,比如手雷爆炸、重装敌人砸地、载具爆炸;震源就在“玩家身体内部”或者“纯粹模拟玩家自身感受”的场景用StartCameraShake,比如自己中弹、扣动扳机、角色被击退。
2.2 影响选择的关键点:距离衰减
大多数从没做过震动的开发者第一次用PlayWorldCameraShake,都会忽略InnerRadius和OuterRadius的搭配,导致震动要么在脚下炸不出效果,要么跑到几百米外还在抖。
简单解释这套参数:InnerRadius以内震动强度是满的,OuterRadius以外震动强度是0,两者之间按Falloff的指数衰减。如果你把InnerRadius填0、OuterRadius填300,那只有距离震源0的地方是满幅度,稍微拉开一点就开始衰减,玩家贴脸吃炸弹可能都感受不到完整的冲击。经验是,先确定这个事件的最大影响距离,然后把InnerRadius设为最大距离的40%到60%,Falloff给1.5左右,这样近距离有完整反馈,中距离有过渡,远距离不干扰。
3. 蓝图里的完整实现路径
3.1 先创建一个继承CameraShakeBase的震动资产
在内容浏览器里右键选择“蓝图类”,搜索框输入CameraShakeBase,创建后打开这个蓝图。不要直接在Level蓝图里拼节点,震动参数要放在独立资产里,这样不同事件可以复用,也方便策划微调,不用去翻逻辑。
打开新创建的震动蓝图后,在Class Defaults面板里找到Camera Shake Pattern这一项,把它设置成Perlin Noise CameraShake Pattern。UE5的PerlinNoise模式是官方最常用的连续噪声震动,参数含义比Legacy版本直观很多,可以拆成两个维度:幅度决定抖多大,频率决定抖多密。
这里我给一组常用的起步参数,适合做一个中型的爆炸冲击震动:
- Duration:0.6
- LocationAmplitudeMultiplier:4.0
- LocationFrequencyMultiplier:8.0
- RotationAmplitudeMultiplier:0.8
- RotationFrequencyMultiplier:10.0
- BlendInTime:0.05
- BlendOutTime:0.2
这组参数的意思是:镜头位置方向上会有幅度为4的随机偏移,每秒变化8次左右;旋转方向上有0.8的随机偏转,变化更密集。最后加了一点BlendInTime防止震动一开始就满幅跳跃,再用长一点的BlendOutTime让震动平滑消失,不会出现“画面突然从震动状态硬切回静止”的顿挫。
3.2 在世界空间触发爆炸型震动
回到你的爆炸物蓝图,在爆炸逻辑里调用Play World Camera Shake节点。这个节点的输入包括:世界上下文对象、震动类、震源位置、InnerRadius、OuterRadius、Falloff、是否仅给本地玩家。
比如手雷在半径500内能造成伤害,那么InnerRadius给200、OuterRadius给800比较合理,这样伤害范围内的震动衰减不会太剧烈,伤害范围外的角色也能通过残余震动感知到远处有爆炸。Falloff参数决定衰减曲线,给1.5左右会偏“线性衰减”一点,给2.5以上则会让震动快速收敛到接近零。
调用时有个小坑:世界上下文对象必须是一个有效的UObject,很多人习惯用Self节点,如果你的爆炸物Actor已经被销毁,Self节点会变成无效引用,震动就触发不出来。稳妥的做法是传入一个生命周期稳定的对象,比如GameMode或PlayerController,或者直接传GetWorld()。
3.3 在相机空间循环播放和停止
持续性震动是另一个典型场景,比如角色进入有毒区域持续中毒、机甲启动引擎、列车从主角身边驶过。这类震动不能靠一次性播放解决,需要在进入状态时开始循环震动,离开状态时精确停止。
在震动蓝图的Class Defaults里把Duration设置为0或者勾选无限循环,然后通过获取PlayerCameraManager节点调用Start Camera Shake。注意Start Camera Shake会返回一个UCameraShakeBase实例,如果你需要中途停止,得把这个实例存下来。停止时调用Stop Camera Shake并传入实例即可。
不少新手会犯一个错误:对同一个CameraShake类多次调用Start Camera Shake,以为第二次调用会覆盖之前的。实际上同一类震动可能被叠加到多个实例上,如果你不保存句柄就直接Stop,会留下几个一直在震的残留实例。项目早期我发现角色中毒退出后画面还在抖,排查了半天就是这个原因。解决办法是保存实例引用,或者直接调用Stop All Camera Shakes,把当前播放的全部震动清掉。
4. C++侧的实现和更精细的控制
4.1 用C++声明一个自定义震动类
蓝图资产适合快速迭代,但震动参数要写进代码里共享给多个模块时,C++更好用。新建一个继承UCameraShakeBase的C++类,在构造函数里创建一个UPerlinNoiseCameraShakePattern并设置参数。
头文件里并不需要额外添加太多东西,核心类名就够用:
cpp复制#pragma once
#include "CoreMinimal.h"
#include "Camera/CameraShakeBase.h"
#include "MyExplosionShake.generated.h"
UCLASS()
class MYGAME_API UMyExplosionShake : public UCameraShakeBase
{
GENERATED_BODY()
public:
UMyExplosionShake();
};
构造函数里创建Pattern的这个逻辑要单独说明一下。CameraShakeBase本身只是一个空壳,真正控制震动行为的是它内部持有的Pattern对象。所以你得NewObject一个UPerlinNoiseCameraShakePattern,填好参数后通过SetCameraShakePattern挂上去:
cpp复制#include "MyExplosionShake.h"
#include "Camera/CameraShakePatterns.h"
UMyExplosionShake::UMyExplosionShake()
{
UPerlinNoiseCameraShakePattern* NoisePattern = NewObject<UPerlinNoiseCameraShakePattern>(this);
NoisePattern->Duration = 0.6f;
NoisePattern->LocationAmplitudeMultiplier = 4.0f;
NoisePattern->LocationFrequencyMultiplier = 8.0f;
NoisePattern->RotationAmplitudeMultiplier = 0.8f;
NoisePattern->RotationFrequencyMultiplier = 10.0f;
NoisePattern->BlendInTime = 0.05f;
NoisePattern->BlendOutTime = 0.2f;
NoisePattern->RandomSeed = 12345;
SetCameraShakePattern(NoisePattern);
}
RandomSeed字段值得注意。如果不设置,每次播放都会使用随机种子,同一段震动每次表现都不同;如果想让一次爆炸在三个客户端上表现一致,或者想复现测试中某个“特别难看的抖动”,固定种子就是调试利器。
4.2 从Gameplay代码里触发和停止
C++侧触发世界空间震动很简单,直接调用UGameplayStatics的静态方法:
cpp复制#include "Kismet/GameplayStatics.h"
const float InnerRadius = 200.0f;
const float OuterRadius = 800.0f;
const float Falloff = 1.5f;
UGameplayStatics::PlayWorldCameraShake(
GetWorld(),
UMyExplosionShake::StaticClass(),
ExplosionLocation,
InnerRadius,
OuterRadius,
Falloff
);
相机空间触发需要先拿到PlayerController再拿到CameraManager:
cpp复制APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (PC && PC->PlayerCameraManager)
{
UCameraShakeBase* ShakeInstance = PC->PlayerCameraManager->StartCameraShake(UMyShake::StaticClass());
}
StartCameraShake的返回值就是刚才提到需要保存的实例引用。它比蓝图更灵活的地方在于,蓝图版本无法在运行时修改Pattern的参数,而C++版本可以在需要的时候通过ShakeInstance拿到当前的Pattern,动态调节振幅和频率。
4.3 同时播多段震动时的组合策略
很多时候一次爆炸不仅仅是震动,还要叠加受击反馈和角色硬直。如果每个模块都独立调用StartCameraShake,最终画面很可能是多个随机偏移的简单相加,幅度会超过设计预期,镜头像发疯一样乱甩。
新Pattern体系里提供了CompositeCameraShakePattern,可以把多个子Pattern组合到一个震动资产里统一控制,比如一个低频大幅度子Pattern负责“冲击感”,一个高频小幅子Pattern负责“细碎抖动”。不过这种组合谨慎使用,低频和高频叠加对参数要求很高,振幅稍微调大一点就会晕。
更实用的思路是规定“同一时间同一类型最多只有一个震动在播”。玩家受到小怪攻击时,如果有一个高优先级的大爆炸正在播放,小怪震动就直接丢弃。这类优先级逻辑在C++里用一个简单的Sort和Replace列表就能做,Blueprint里虽然有节点也可以用bool标记控制,但事件一多就会乱。所以涉及多来源震动管理,我强烈建议用C++做,蓝图只做最终触发层的表现。
5. 关键参数打磨技巧,这套组合我觉得最实用
5.1 从“能震”到“合适”,先理解振幅和频率的组合
拿枪械后坐力举例。把RotationFrequencyMultiplier调到20以上会得到高频震动,画面像手机振动一样细碎,能表达“机械零件在运转”的感觉;把振幅调大但频率调低,比如Frequency在3到5之间,会得到类似“起跳落地”的钝感,适合法术轰击、地层断裂这类低频冲击。
实操中我认为最容易被忽略的是BlendInTime。震动如果在一帧内从0跳到满幅,等于给玩家一个突兀的起点,这在第一人称射击里会放大“画面被扯动”的不适感。一个合理的做法是给所有震动加0.03到0.05秒的BlendIn,普通玩家察觉不到这点延迟,但画面会平滑很多。
这里放一张我常用的参数速查表,覆盖几个高频场景,新手可以拿这些底数开始调:
| 场景 | Duration(秒) | 位置振幅 | 位置频率 | 旋转振幅 | 旋转频率 | BlendOut |
|---|---|---|---|---|---|---|
| 狙击开枪后坐 | 0.12 | 0.3 | 15 | 0.15 | 25 | 0.1 |
| 被近战击中 | 0.2 | 1.2 | 10 | 0.6 | 8 | 0.15 |
| 中型爆炸 | 0.6 | 5.0 | 7 | 1.0 | 10 | 0.25 |
| 陨石级大爆炸 | 1.2 | 12.0 | 4 | 2.5 | 5 | 0.5 |
| 引擎怠速循环震动 | 无限 | 0.3 | 3 | 0.1 | 2 | 0.3 |
表格里旋转振幅指的是给相机旋转轴叠加的Perlin噪声幅度,不是最终旋转角度。数值看着小,实际影响很大,2以上的旋转振幅已经非常晃,谨慎用于第一人称。
5.2 衰减范围设置会直接影响打击感
前面提到PlayWorldCameraShake的InnerRadius和OuterRadius,这里再补充一个容易踩的点:衰减指数Falloff的默认值在不同引擎版本里不完全一致,最好在节点上检查一下。
Falloff比较难理解的话,把它想成音频的衰减曲线。Falloff为1是线性衰减:从满幅度均匀降到0;Falloff为2是平方衰减:前半段还很强,后半段快速掉没。爆炸震感应该是平方衰减更符合直觉,因为冲击波和声音都是越远衰减越快。但你在做“全图范围感知型震动”(比如巨大怪物踏地)时,线性衰减反而更好,能让更远距离的玩家依然有可感知的轻微晃动。
5.3 三套“反馈等级”让同一套震动更统一
给战斗系统定义震动输出等级,这个建议来自我之前做过的一个动作游戏原型。我把震动输出分成S、A、B三个等级:S级是屏幕中央晃动不超过1秒的重大冲击,比如BOSS砸地、自爆;A级是普通技能或爆炸,屏幕晃动不超过0.4秒;B级是打击和受击,晃动控制在0.15秒内且不允许旋转轴大幅偏移。
定义好等级后,策划提需求会方便很多。他说“这个技能给个A级反馈”,你只需知道它是中距离范围爆炸还是单体命中,再套模板就行。否则每次都要问“振幅要多大?频率要多少?几秒?”双方都累。
6. 实际开发中我踩过的坑,排查思路都在这里
6.1 震动完全没反应,先别怀疑引擎坏了
项目里至少三次有人跑过来问“为什么我加了震动没效果”,最后原因都很基础但也很隐蔽。最常见的一种是把震动类继承错了,蓝图里创建的类继承的是Actor而非CameraShakeBase,然后又在Start Camera Shake节点里填了这个类,编译没问题但是永远播放不出来。排查方法很简单,选中资产看父类,如果不是CameraShakeBase就果断重建。
另一种常见原因是使用世界空间触发时距离衰减把幅度吃光了。我有一个测试场景把OuterRadius设置成了150,而玩家离爆炸点在1300左右,结果自然是零反馈。记得先在数值上做一次距离自检,别让“触发位置离玩家太远”成为第一个怀疑对象。
再有一种隐蔽情况:震动执行了但它被后续帧的相机逻辑覆盖了。比如你用了弹簧臂CameraBoom或者Timeline在驱动相机旋转,CameraShake叠加的位置偏移在下一帧被SpringArm的插值重置回去。此时调整震动振幅没有意义,要从相机管理链路入手,让CameraShake挂载在最终的相机变换空间里。
6.2 循环震动无法停止
几乎每个项目都会遇到一次循环震动关不掉的Bug。症状是角色已经离开某个区域,画面还在持续抖,甚至切到UI菜单还在抖。
排查顺序是:先确认在你调的Stop逻辑里传的实例和Start返回的一致;再检查是不是同一个震动类在不同地方被启动过多次;最后检查PlayerController的Pawn是否被销毁过,因为Pawn销毁时可能导致持有的ShakeInstance引用失效。
如果你想快速兜底,可以给PlayerCameraManager写一个“清理所有震动”的调用时机,比如角色死亡或切换地图时调用StopAllCameraShakes。这不精细,但能防止“带着震动进下一关”这种恶性Bug出现。
6.3 过场动画里震动被Sequence截断
在Level Sequence过场中直接调用StartCameraShake会经常失效,因为Sequencer在播放时会把相机Cut到CineCameraActor上,或者在每一帧强制更新当前相机,普通CameraManager的震动被你自己的Sequence相机覆盖了。
处理思路有两种。第一种,如果你的过场很短,可以把震动做成Sequence里的相机动画,用Transform的Offset关键帧模拟抖动,虽然麻烦但完全可控;第二种,如果过场同时需要保留Gameplay镜头冲击,可以通过CineCameraActor上的摄像机组件直接触发CameraShake,这就绕开了PlayerCameraManager的覆盖问题。
还有一点,如果项目用了多套相机管理框架,比如第三方摄像机插件或自定义CameraCutter,StartCameraShake不一定作用于你视觉上看到的那台相机。排查时先在相机Actor的OnBeginCameraCut或者相机切换后打个Log,确认当前生效CameraManager是谁。
6.4 如何快速验证一组参数,不反复进游戏呢
我自己做震动参数时不会每次都启动PIE跑一遍完整关卡,那样迭代太慢。最常用的连招是:在第三人称模板里放一个测试Actor,然后在PlayerController的Tick里做一个简单的Debug输入检测,按1爆炸震动、按2受击震动、按3循环震动,方便在编辑器里临时调整参数。
也有朋友会用控制台指令配合蓝图自定义事件来做,具体做法不唯一。核心思路是让参数调整到运行验证的链路足够短,不要每次改一个数字都重编一次关卡。等找到合适的手感后,再把参数固化回震动资产。
最后分享一个我自己的习惯
CameraShake这东西很难纯靠眼睛调准,尤其是长时间盯着屏幕,你会慢慢觉得“抖动不够”,于是越调越大,最后交给别人一测就是晕车现场。所以我会在每次震动完成后等几分钟再回看录屏,甚至让旁边同事看一眼反应再来判断。另外,我会在每个震动资产命名里带上它的“情绪标签”,比如Shake_Hit_Light、Shake_Explosion_Heavy、Shake_Rumble_Loop。名字虽不直接影响逻辑,但对多人协作尤其重要,否则一个叫NewCameraShake的资产放在Content里,三个月后没人知道它是干什么用的。沿着这个思路,如果你也有长期在做的项目,建议从第一天就建一个“震动资产库”,分类放好,后面做任何新玩法都能像取音效一样直接取来用,效率会高很多。
