☰
Unity 2D帧动画、开关门与光幕的时间轴调度实践
2026/10/27 5:06:34 网站建设 项目流程

1. 先拆需求:三件事其实挂在同一条时间轴上

1.1 帧动画、开关门、光幕到底共享了什么

上周被拉去看一个横版解谜 Demo,场景里就一个角色、两扇门、三道横在走廊里的光幕,结果卡了整整两天。角色帧动画播放速度忽快忽慢,门开了人还能直接穿过去,光幕看着挺唬人但贴脸一看像张塑料膜,站在里面能看见反面贴图的接缝。这三件事单拎出来都不算难,难的是它们都挂在同一条时间轴上:玩家踩到触发区的那一刻,门要转、光幕要灭、角色的帧动画要从待机切到进门动作,然后门再合上、光幕再亮起。任何一个环节错一拍,玩家就会觉得"这游戏怪怪的",但具体哪里怪又说不上来。

我把这类需求叫"三旋钮问题"。帧动画控制的是画面在时间维度上的采样,开关门控制的是空间的可通行性在时间维度上的切换,光幕控制的是视觉阻挡与物理阻挡在时间维度上的同步。三者的输入端都是时间,输出端都是状态,中间都缺不了一个可靠的驱动源。所以真正要设计的不是三个互不相干的脚本,而是一套"什么时候改什么状态、改完由谁通知谁"的约定。

这个认知很重要,因为它直接决定了后面怎么写代码。如果你把它当成三个独立需求,大概率会得到三个各自用Update抢时间的脚本:帧动画在 Update 里累加计时器,门在 Update 里Lerp角度,光幕在 Update 里改材质透明度。跑起来没毛病,一旦出现"门还没开完玩家就冲进去了"这种并发场景,三个脚本就开始互相打架。我在后面第 5 节会专门讲怎么把这堆东西收进一个统一的调度里。

1.2 三条路线的选型对比

在动手之前,先明确一件事:帧动画、开关门、光幕这三块,Unity 官方提供了不止一条路,而且每条路的坑完全不同。我一般会先列个表把边界划清楚,避免写到一半才发现选错了方案。

模块可选方案适用场景主要代价
帧动画Animation Clip + Animator需要状态混合、多段动画切换状态机维护成本高,帧事件依赖 Animator
帧动画手写计时器 + SpriteRenderer帧数少、逻辑驱动强、要求可控需要自己实现丢帧补偿和事件回调
帧动画序列帧 Shader(UV 偏移)大量同质特效(火焰、爆炸)美术资源必须严格等分
开关门旋转 Transform + 协程单开门、双开门、抽屉门需要自己处理碰撞与寻路
开关门Animator 驱动门有复杂形变、多段表现逻辑与表现耦合,改参数要进动画窗口
开关门物理关节(HingeJoint)需要被撞开、被炸飞数值难调,容易抖动
光幕面片 + UV 滚动贴图绝大多数场景,性价比最高表现上限受贴图限制
光幕程序化 Shader需要菲涅尔、扫描线、消隐过渡写 Shader 有门槛,移动端要控指令数
光幕粒子系统 / VFX需要体积感、飘散感性能开销大,遮挡关系难处理

这张表我在项目里反复用过,结论很稳定:帧动画优先手写计时器,开关门优先协程旋转,光幕优先面片加程序化 Shader。原因不复杂——这三块的共同特征是"状态明确、帧数有限、时序要求高",用重型方案反而会被框架的抽象拖累。Animator 适合角色那种几十个状态互相过渡的场景,用在只有"开/关"两个状态的门上,属于杀鸡用牛刀,而且调试的时候你得在 Animator 窗口和 Inspector 之间来回跳,效率极低。

还有一个隐性成本要说清楚:Animator 的 Clip 是序列化在 prefab 里的,合并冲突时几乎无法人工解决;协程加枚举的写法是纯代码,合并冲突最多两行,谁改的、改了什么一目了然。团队协作场景下,这一点比性能还重要。

2. 帧动画:别急着往 Animation 窗口里拖

2.1 导入前不做这几件事,后面全是返工

我见过太多项目在帧动画上翻车,问题九成不在代码,而在美术资源导入设置。序列帧的本质是"同一张图的不同裁剪区域按时间顺序播放",只要裁剪区域的 Pivot、尺寸、采样方式有半点不一致,播放起来就是抖动加错位。所以拿到图集的第一件事不是切图,是统一规范。

首先是Pivot。角色的 Sprite Pivot 必须统一在同一个位置,我一般用Bottom Center,因为角色贴地逻辑最自然。如果一半帧是 Center、一半帧是 Bottom Center,播放起来角色就会上下跳,这种抖动靠代码补不回来。切图时在 Sprite Editor 里选Slice,然后把Pivot设成Bottom,再统一Custom Pivot的 Y 值,比一帧帧手调靠谱得多。

其次是Pixels Per Unit。这个值决定了 Sprite 的世界尺寸。假设原图角色高 64 像素,PPU 设成 64,那角色在世界里就是 1 个单位高;如果场景里的门是 2 个单位高,比例就对得上。要是某批图 PPU 设成 100,角色瞬间缩水三分之一,你会在场景里看到角色比门矮一大截,然后怀疑是不是 Transform 的 Scale 出了问题,查半天。建议整个项目定一个基准 PPU,所有 2D 资源统一。

第三是Filter Mode 和 Compression。像素风资源必须用Point (no filter),否则缩放后糊成一片;手绘风用Bilinear。压缩格式上,带透明通道的序列帧如果用默认压缩,帧与帧之间会出现色块跳变,尤其在纯色背景的角色边缘特别明显。我的做法是:帧动画资源统一打包进 Sprite Atlas,Atlas 的压缩质量拉到最高,或者干脆不压缩(前提是图集总量可控)。

最后是Wrap Mode。单帧 Sprite 的 Wrap Mode 设成Clamp,避免 UV 采样到边缘外像素产生黑边。这个坑在缩放特别大或者摄像机特别近的时候才会暴露,等你发现的时候已经上线了。

提示:切完图先别急着做动画,把第一帧拖进场景,把摄像机拉近到极限看一眼边缘。边缘有黑边、有半透明描边、有锯齿,都是导入设置的问题,这时候改成本最低。

2.2 生成 Clip 的三种方式与适用边界

切好图之后要生成动画数据,常见三条路,我按使用频率排一下。

第一条,Sprite Editor 切图后全选拖进 Hierarchy。Unity 会自动生成一个 Animation Clip 和一个 Animator Controller,帧率默认 12,采样方式Constant。这条路的优点是快,缺点是生成的 Clip 名字是一串哈希,而且 Animator Controller 会被自动塞进场景,后期想改结构很别扭。我一般只在做原型验证的时候用,正式项目会把它删掉重做。

第二条,Animation 窗口手动 K 帧。选中物体,打开 Animation 窗口,Create 一个新 Clip,然后把 Sprite 逐帧拖到Sprite Renderer.sprite轨道上。这条路的可控性最好,你能精确控制每一帧的起始时间,也能在任意时间点插入关键帧做变速。缺点是帧数一多就是体力活,30 帧以上手动拖会疯。我的经验是 12 帧以内手动,超过 12 帧就写脚本。

第三条,编辑器脚本批量生成。这是正式项目的标配。核心思路是拿到一组 Sprite,按文件名排序,用ObjectReferenceKeyframe逐帧写入 Clip,采样率设成Constant,关键帧间隔就是1/fps。

#if UNITY_EDITOR using System.Linq; using UnityEditor; using UnityEngine; public static class SpriteClipGenerator { [MenuItem("Assets/Tools/Generate Sprite Clip From Selection")] static void Generate() { var sprites = Selection.GetFiltered<Sprite>(SelectionMode.Assets) .OrderBy(s => s.name) .ToArray(); if (sprites.Length < 2) { Debug.LogWarning("至少选中两张 Sprite"); return; } const float fps = 12f; var clip = new AnimationClip { frameRate = fps }; var binding = new EditorCurveBinding { type = typeof(SpriteRenderer), path = string.Empty, propertyName = "m_Sprite" }; var keys = new ObjectReferenceKeyframe[sprites.Length]; for (int i = 0; i < sprites.Length; i++) { keys[i] = new ObjectReferenceKeyframe { time = i / fps, value = sprites[i] }; } AnimationUtility.SetObjectReferenceCurve(clip, binding, keys); AnimationUtility.SetEditorCurve(clip, binding, null); string path = AssetDatabase.GetAssetPath(sprites[0]); string dir = System.IO.Path.GetDirectoryName(path); AssetDatabase.CreateAsset(clip, $"{dir}/{sprites[0].name}_Clip.anim"); AssetDatabase.SaveAssets(); } } #endif

这段脚本有个细节值得说:OrderBy(s => s.name)。切图后 Sprite 的命名一般是player_00、player_01这种带补零的格式,字符串排序才能保证顺序正确。如果美术命名是player_1、player_2、player_10,字符串排序会把 10 排到 2 前面,动画直接乱套。所以我在项目规范里强制要求序列帧命名补零,两位起步,超过 99 帧就三位。

2.3 一个能直接用的帧播放器

正式项目里我很少用 Animator 播帧动画,原因后面会说。更常见的做法是手写一个播放器,逻辑自己能完全掌控。下面这个版本是我用了好几年的,去掉了一些项目特有的东西,保留核心。

using System; using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] public class SpriteFramePlayer : MonoBehaviour { [Header("帧数据")] public Sprite[] frames; public float fps = 12f; public bool loop = true; public bool playOnEnable = true; [Header("帧事件")] public int[] eventFrames; public event Action<int> OnFrameChanged; public event Action<int> OnFrameEvent; public bool IsPlaying { get; private set; } public int CurrentFrame => _index; SpriteRenderer _sr; float _timer; int _index; void Awake() => _sr = GetComponent<SpriteRenderer>(); void OnEnable() { if (playOnEnable) Play(); } public void Play() { if (frames == null || frames.Length == 0) return; _index = 0; _timer = 0f; IsPlaying = true; ApplyFrame(0); } public void Stop() => IsPlaying = false; public void PlayFrom(int frame) { if (frames == null || frames.Length == 0) return; _index = Mathf.Clamp(frame, 0, frames.Length - 1); _timer = 0f; IsPlaying = true; ApplyFrame(_index); } void Update() { if (!IsPlaying || frames == null || frames.Length < 2) return; float step = 1f / Mathf.Max(0.01f, fps); _timer += Time.deltaTime; // 关键点:用 while 而不是 if,避免低帧率下动画变慢 while (_timer >= step) { _timer -= step; int next = _index + 1; if (next >= frames.Length) { if (!loop) { IsPlaying = false; return; } next = 0; } ApplyFrame(next); } } void ApplyFrame(int i) { _index = i; _sr.sprite = frames[i]; OnFrameChanged?.Invoke(i); if (eventFrames != null && Array.IndexOf(eventFrames, i) >= 0) OnFrameEvent?.Invoke(i); } }

while那一行是整个脚本的核心。很多人会写成if (_timer >= step),在 60 帧的机器上跑得好好的,一到低端设备掉到 20 帧,动画速度立刻变慢一半,因为一帧 Update 只推进了一帧动画。用while把积压的时间一次消化完,就能保证不同帧率下动画速度一致。这个细节在跨平台发布时特别致命,手机上掉帧是常态。

另一个设计是fps用浮点数而不是整数。攻击动画经常需要 15 帧秒、待机 8 帧秒,整数就把路堵死了。真要更精细的控制,可以把fps换成AnimationCurve,让每帧时长单独配,但那已经接近 Clip 的能力了,不如直接用 Clip。

2.4 帧事件:动画和逻辑握手的正确姿势

帧动画真正难的地方不在播,而在"播到第几帧的时候该干别的事"。角色第 5 帧出手,第 5 帧就要开伤害判定;开门动画第 8 帧门轴到位,第 8 帧就要把碰撞体关掉。这类需求的处理方式直接决定了后期维护的痛苦程度。

第一种是 Unity 自带的AnimationEvent。在 Animation 窗口里把时间线拖到某一帧,右键 Add Animation Event,填函数名。优点是所见即所得,缺点是函数名是字符串,拼错了不报错,只在运行时打一行has no receiver的警告;而且函数必须是public,还得挂在同一个 GameObject 上,架构上很别扭。更麻烦的是,一旦你把动画从 Animator 换成手写播放器,这些事件全部失效,得重新接一遍。

第二种是播放器回调加帧号判断,也就是上面脚本里eventFrames那个数组。逻辑侧订阅OnFrameEvent,收到帧号再分发。这种方式的好处是逻辑和表现彻底解耦,播放器不知道自己被谁订阅,换个播放器照样能用。缺点是每加一个事件点,都要去 Inspector 里改数组,配置容易遗漏。

第三种是数据驱动,用一个 ScriptableObject 描述"这个动画每帧的属性",比如第几帧无敌、第几帧可以取消、第几帧有位移。这种做法在动作游戏里很常见,因为帧数据本身就是设计资产,需要策划反复调。我用过一个简化版本:每条帧数据包含frame、type、value三个字段,type是枚举,value是 float 或 string,运行时解析。听着笨,但策划能自己改,不用求程序。

我的建议是按项目规模选:小项目用第二种,配置少、改起来快;中大型项目用第三种,把帧数据抽成资产,后面加招式、加取消窗口都不需要动代码。第一种只在"我就这一个动画,懒得搞"的场景下用。

注意:如果用 AnimationEvent,函数签名只能是void Foo()或void Foo(int)或void Foo(float)或void Foo(string)或void Foo(Object),带两个参数会静默失败。这个坑我在不同项目里至少踩过三次。

2.5 内存与批次:帧动画真正的性能杀手

帧动画最容易被忽视的成本是内存。一张 1024x1024 的 RGBA32 贴图是 4MB,一套 30 帧的角色动画如果每帧一张独立贴图,就是 120MB,这还没算 mipmap。手机上一百多兆的贴图内存,基本等于宣判死刑。

解决路径只有一条:Sprite Atlas。把同一角色的所有帧打进一张图集,有几个好处。第一,运行时的批次会从 30 个降到 1 个,因为所有帧共用同一个材质和同一张贴图,SpriteRenderer 可以直接合批。第二,内存占用从"帧数 × 单图面积"变成"图集面积",图集内部可以利用空白区域,通常能省 30% 到 50%。第三,图集可以单独设置压缩格式和 mipmap 策略,比逐张图控制更灵活。

图集的大小要控制。我一般单张图集不超过 2048x2048,超过就按角色或者按场景拆分。2048 的 RGBA32 图集是 16MB,压缩成 ASTC 4x4 大概是 4MB,对一个角色来说可以接受。图集拆分的维度我优先按"同屏出现"来分,比如玩家一套、敌人一套、场景道具一套,这样运行时不会同时加载所有图集。

还有Overdraw。半透明 Sprite 叠加是移动端 GPU 的主要瓶颈之一,尤其是角色带光效、带拖尾、带影子的时候。检查方式很简单,Scene 视图切成 Overdraw 模式,看角色身上叠了几层。超过 3 层就要考虑合并图层或者改用不透明渲染加 Alpha Test。

3. 开关门:表现、物理、逻辑必须拆开

3.1 门的三层结构

门这个东西看着简单,实际上是个典型的"表现、物理、逻辑"三合一对象,三层混在一起写,后面一定崩。我现在的习惯是开局就把三层拆开。

表现层负责门扇怎么转、转多久、有没有缓动、有没有吱呀声。这一层可以完全由 Transform 或者 Animator 驱动,对外只暴露"开始开""开始关"两个接口。

物理层负责"门关着的时候人过不去,门开着的时候人过得去"。它跟表现层不是强绑定的,因为门在旋转过程中,碰撞体到底算开还是算关,是需要设计的。有的游戏门转到 30 度就能过人,有的必须转到 90 度。物理层还有一块是寻路:NPC 要走门,NavMesh 得知道门什么时候是障碍。

逻辑层负责门的锁定状态、钥匙需求、开关触发条件、被什么事件驱动。这一层完全不关心门长什么样,只发指令。

拆开之后,同一套逻辑层可以驱动旋转门、滑门、电梯门、卷帘门,改的只是表现层。这个复用价值在实际项目里非常大,尤其是那种"美术想换门的样子"的需求,你只需要换一个表现层脚本。

3.2 状态机怎么写才不容易乱

门的状

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

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

立即咨询