5.过场编辑器设计

  1. 5.过场编辑器设计
    1. 5.1 为什么基于 Timeline 做过场编辑器
      1. Timeline 自带的能力
      2. 做过场编辑器还缺什么
      3. 要在 Timeline 之上搭什么
    2. 5.2 整体架构:CutsceneRoot
      1. 核心思路:一个过场 = 一个 Prefab
      2. Prefab 层级结构
      3. 播放生命周期
        1. 加载资源
        2. 开始播放
        3. 播放结束
        4. 生命周期图
        5. 伪代码
      4. 运行时相机绑定
      5. 自定义镜头混合表
    3. 5.3 CutsceneElement:参与者的统一抽象
      1. 为什么需要这层抽象
      2. 接口与基类
      3. 异步加载管线
      4. ModelElement 和 RoleModelElement
      5. 各类型简述
    4. 5.4 自定义轨道体系
      1. 问题:十几种轨道的重复样板代码
      2. 解决方案:BaseTrack 四件套
      3. ClipBehaviour:Clip 级行为基类
      4. MixerBehaviour:Track 级混合基类
      5. BaseTrack:轨道胶水层
      6. BaseClip:Clip 资产胶水层
      7. 自定义轨道实现
      8. 外部回调接口
        1. 基础类型
        2. 事件中心
        3. 过场事件定义
        4. 使用示例
      9. 笔者项目中已实现的轨道类型一览
      10. 参数化 Signal 扩展
        1. 原生 Signal 回顾
        2. 泛型扩展
        3. 发射端
        4. 接收端
        5. 编辑器操作流程
        6. 小结
    5. 5.5 SubCutscene:子过场管理
      1. 为什么要拆
      2. SubCutsceneTrack:对 ControlTrack 的扩展
      3. 整体结构
      4. 创建流程
      5. 编辑器体验
      6. 对话分支跳转
        1. 建立 SubCutscene 索引
        2. 跳转实现
        3. 举个具体的例子
        4. 完整流程
      7. 拆分带来的好处
      8. director.Pause() 之后角色动画僵住了?
        1. Playable Graph 与状态机的优先级
        2. 让状态机跑起来的前提
        3. AnimStatePlayTrack:控制暂停后播什么动画
        4. 编辑器里长什么样
        5. 串起来看
    6. 5.6 编辑器设计思路
      1. 整体架构
      2. 代码组织:类图
      3. CutsceneEditWindow:主窗口
      4. BasePage:页面基类
      5. 过场数据的存储结构
      6. HomePage:过场列表
        1. 顶部工具栏
        2. 左侧面板:过场列表(DrawList)
        3. 右侧面板:选中项详情(DrawContent)
        4. 双击进入编辑
        5. 新建过场的内部流程
        6. 加载已有过场
      7. EditPage:过场编辑主界面
        1. BasePanel:功能面板基类
        2. 各功能面板的职责划分
      8. CutsceneEditCore:编辑核心
        1. 资源选择器
        2. 添加 NPC 演员的完整流程
        3. 添加虚拟相机的流程
      9. Timeline 扩展方法
      10. 保存与退出
        1. 编辑器的帧更新驱动
        2. 保存
          1. 为什么不能直接保存
          2. TimelinePreviewUpdate()
          3. Save()
          4. SaveElementData()
          5. SavePrefabData()
        3. 退出
      11. 编辑器预览:CutsceneBuild 场景
    7. 5.7 效果展示
    8. 5.8 总结

5.过场编辑器设计


5.1 为什么基于 Timeline 做过场编辑器

Timeline 自带的能力

之前的博客花了不少篇幅讲 Playable 和 Timeline 的底层原理,现在我们开始尝试拿它们来搭一套过场编辑器。

Timeline 本身已经提供了不少现成的能力:

  • PlayableGraph 运行时——前面 Playable 系列讲过的可编程求值框架,动画、音频的每帧混合求值都走这条路
  • 可视化时间轴编辑器——轨道、Clip、Marker 的拖拽式编排界面,不用自己写 GUI
  • 原生轨道——AnimationTrack / AudioTrack / ActivationTrack / CinemachineTrack 等,常用的场景演出能力开箱即用
  • Clip 混合与权重——源码浅析里看过的 EvaluateAt、MixIn/MixOut,Timeline 帮我们把权重算好写到 Mixer
  • ControlTrack 嵌套——ControlClip 可以激活子 PlayableDirector,天然支持时间线嵌套时间线

按时间轴编排、混合、播放多种类型内容,这些 Timeline 都替我们做了。

做过场编辑器还缺什么

上面这些拿来播过场没问题,但要搭一套完整的过场编辑器,还有几块得自己补:

  • 资源的加载和生命周期:Timeline 的 Binding 解决了”轨道绑哪个对象”,但不管”这个对象从哪来”。比如一个 NPC 角色,需要异步加载 Prefab → 实例化到场景 → 找到 Animator → 绑到动画轨道 → 播完销毁;虚拟相机也一样,得创建 CinemachineVirtualCamera 组件、设参数、播完回收。这些全得自己处理。
  • 游戏逻辑触发:过场里弹对话框、播字幕、做幕帘渐变、触发音效、切场景,原生轨道覆盖不了,得写自定义轨道。十几种自定义轨道如果每个从零写,样板代码会很繁杂。
  • 大型过场的组织:一段几分钟的过场所有轨道塞一条 Timeline 里,轨道数量爆炸、编辑器卡、多人协作冲突,得有办法把长过场拆成多个可独立编辑的片段。
  • 编辑器工具链:策划和美术不会直接操作 TimelineAsset API,得有一套可视化的编辑器面板来简化操作。

要在 Timeline 之上搭什么

整理一下,需要在 Timeline 之上补四块东西:CutsceneElement(参与者抽象)、自定义轨道体系SubCutscene(子过场管理)、编辑器工具链。由一个 CutsceneRoot 总控组件把它们串起来:

graph TB
    subgraph build["要补的"]
        B["CutsceneElement
参与者抽象"] C["自定义轨道体系"] D["SubCutscene
子过场管理"] E["编辑器工具链"] end subgraph timeline["Timeline 已有的"] F["PlayableGraph 运行时"] G["可视化时间轴编辑器"] H["原生轨道
Animation / Audio / Cinemachine"] I["ControlTrack 嵌套能力"] end A["CutsceneRoot
过场总控"] --> B & C & D & E C -.-> F E -.-> G C -.-> H D -.-> I

5.2 整体架构:CutsceneRoot

核心思路:一个过场 = 一个 Prefab

整套过场系统的核心思路其实很简单——一个过场就是一个 Prefab。编辑器里配好的所有数据(相机参数、角色绑定、轨道编排、子过场拆分……)全部存在这个 Prefab 和它引用的 Timeline 资源文件上。运行时播一段过场,本质就是:加载 Prefab → 实例化 → 拿到上面的 PlayableDirector → Director.Play()。结束后销毁实例,干干净净。

Prefab 根节点上挂一个 CutsceneRoot 组件(MonoBehaviour),它是整个过场的总控,持有三样核心东西:

  • MasterDirector:主 PlayableDirector,驱动主 TimelineAsset
  • allElements:所有 CutsceneElement 的列表——相机、角色、特效、路径等”参与者”(详见 5.3)
  • SubCutsceneGroup:子过场集合(详见 5.5)

Prefab 层级结构

一个过场 Prefab 的 Hierarchy 长这样:

CutsceneRoot (Prefab 根节点,挂 CutsceneRoot 组件)
├── Cutscene Camera          // 编辑用相机(运行时会切到主场景相机)
├── Timeline                 // 主 PlayableDirector + 主 TimelineAsset
├── CameraGroup              // 虚拟相机节点(CameraElement 挂在这下面)
├── RoleGroup                // 角色模型节点(RoleModelElement 等挂在这下面)
├── EffectGroup              // 特效节点
├── PathGroup                // 路径节点
├── LightingGroup            // 灯光节点
└── SubCutsceneGroup         // 子过场节点
    ├── [001]SubCutscene     // 子 PlayableDirector + 子 TimelineAsset
    ├── [002]SubCutscene
    └── ...

每个分组节点下面挂的就是对应类型的 CutsceneElement 组件,CutsceneRoot 通过 GetComponentsInChildren<BaseCutsceneElement>() 收集所有参与者。

播放生命周期

对外部来说,播放一段过场只需要一行调用——上层管理器(比如 CutsceneManager)暴露一个 Play(cutsceneName, endCallback) 方法,内部自动完成”加载 Prefab → 实例化 → 加载资源 → 播放 → 结束回收”的全链路。外部完全不用关心加载细节。

CutsceneRoot 内部把这个链路分成三步:加载资源 → 开始播放 → 播放结束

加载资源

StartLoading() 收集 Prefab 下所有 CutsceneElement(相机、角色、特效、路径等),逐个调它们的 OnCreate()。每个 Element 在 OnCreate() 里做自己的事——RoleModelElement 异步加载角色 Prefab,CameraElement 创建虚拟相机组件,EffectElement 预加载特效。各 Element 并行加载,完成后各自调 AcceptCreateDoneNotice() 告诉 Root “我好了”。Root 用一个 pendingElements 集合跟踪谁还没完成,集合清空时说明全部就绪,自动进入下一步。

注意这里是预加载——不管某个角色是第 1 秒出场还是第 50 秒才出场,只要 Element 挂在 Prefab 里就会一次性全部加载。好处是播放过程中不会有异步加载卡顿,代价是过场开始前的等待时间和角色数量成正比。

还没到出场时间的角色怎么办?项目里做了一条自定义的 DeactivationTrack,Mixer 里的逻辑就几行:有 Clip 权重 > 0 时把绑定的 GameObject SetActive(false),没有 Clip 覆盖时保持 active。策划在不需要某个角色出现的时间段放一个 DeactivationClip,那段时间角色就会被隐藏。也可以用原生的 ActivationTrack(逻辑正好反过来:Clip 窗口内激活、窗口外失活),或者在 Element 的生命周期里自己控制,看项目习惯。下面截图里 Inspector 选中的就是一个 DeactivationClip


开始播放

Play() 先调 SetStartSetting() 做环境准备。这个方法里放的是跟项目相关的全局设置,每个项目不一样——比如我们项目用了 Cinemachine,就需要在这里找到场景主相机的 CinemachineBrain,通过 Director.SetGenericBinding() 把 Camera Track 绑上去。之后广播 OnPlayStart() 通知各 Element 做开场准备,最后 masterDirector.Play() 启动 Timeline。

播放结束

Timeline 播到头触发 Director.stopped 事件。Root 收到后先 SetEndSetting() 恢复环境,再广播 OnPlayComplete() 让各 Element 做清理(销毁模型实例、回收相机等),最后隐藏 Prefab、触发外部的结束回调。

生命周期图

sequenceDiagram
    participant Mgr as CutsceneManager
    participant Root as CutsceneRoot
    participant Elem as CutsceneElement(相机/角色/特效...)
    participant Dir as PlayableDirector

    Mgr->>Mgr: 加载过场 Prefab 并实例化
    Mgr->>Root: 拿到 CutsceneRoot 组件

    Note over Mgr,Dir: 加载资源
    Mgr->>Root: StartLoading(onLoaded)
    Root->>Elem: 收集所有 Element,逐个调 OnCreate()
    Note right of Elem: 各 Element 并行加载
(异步取 Prefab / 创建组件) Elem-->>Root: 完成后调 AcceptCreateDoneNotice() Note over Root: pendingElements 清空 → 全部就绪 Root-->>Mgr: onLoaded 回调 Note over Mgr,Dir: 开始播放 Mgr->>Root: Play(endCallback) Root->>Root: SetStartSetting():绑定运行时相机 Root->>Elem: 广播 OnPlayStart() Root->>Dir: masterDirector.Play() Note over Dir: Timeline 驱动各轨道每帧评估... Note over Mgr,Dir: 播放结束 Dir-->>Root: Director.stopped 事件 Root->>Root: SetEndSetting():恢复环境 Root->>Elem: 广播 OnPlayComplete():各 Element 清理 Root->>Root: 隐藏 Prefab Root-->>Mgr: endCallback()

伪代码

外部只需要一行 CutsceneManager.Play("CutsceneName", onEnd)StartLoading → Play 的链式调用对外透明:

// CutsceneManager.cs — 上层管理器,对外只暴露一个 Play 方法
public void Play(string cutsceneName, Action onEndCallback)
{
    // 根据名字加载过场 Prefab 并实例化到场景
    GameObject cutsceneObj = LoadAndInstantiate($"Cutscene/{cutsceneName}");
    CutsceneRoot cutsceneRoot = cutsceneObj.GetComponent<CutsceneRoot>();

    // 链式调用:先加载所有 Element 资源,全部就绪后在回调里启动播放
    cutsceneRoot.StartLoading(onLoadDone: () =>
    {
        // 播放结束时通知外部,并销毁整个过场实例
        cutsceneRoot.Play(onPlayEnd: _ =>
        {
            onEndCallback?.Invoke();
            Destroy(cutsceneObj);
        });
    });
}
// CutsceneRoot.cs — 挂在过场 Prefab 根节点上的核心组件
public class CutsceneRoot : MonoBehaviour
{
    /// <summary>
    /// 主 Timeline 导演组件
    /// </summary>
    [SerializeField] private PlayableDirector masterDirector;

    /// <summary>
    /// 所有参与过场的 CutsceneElement(相机、角色、特效、路径等)
    /// </summary>
    public List<BaseCutsceneElement> allElements = new List<BaseCutsceneElement>();

    /// <summary>
    /// 正在加载的 Element 集合,集合清空说明全部就绪
    /// </summary>
    private HashSet<ICutsceneElement> pendingElements = new HashSet<ICutsceneElement>();

    /// <summary>
    /// 加载完成回调
    /// </summary>
    private Action onLoadDoneCallback;

    /// <summary>
    /// 播放结束回调
    /// </summary>
    private Action<CutsceneRoot> onPlayEndCallback;

    /// <summary>
    /// 当前是否正在播放
    /// </summary>
    public bool IsPlaying { get; protected set; }

    /// <summary>
    /// 资源加载器:运行时走 AssetBundle 异步加载,编辑器下走 AssetDatabase 同步加载
    /// </summary>
    private CutsceneLoader loader = new CutsceneLoader();

    /// <summary>
    /// 加载资源的统一入口,Element 调这个方法加载自己需要的 Prefab
    /// 内部委托给 CutsceneLoader,加载完成后回调 callback
    /// </summary>
    public void LoadAsset(string assetPath, GameObject attachObj, Action<object> callback)
    {
        loader.LoadAsset(assetPath, attachObj, callback);
    }

    /* ========== 加载阶段 ========== */

    /// <summary>
    /// 收集 Prefab 下所有 CutsceneElement 并触发各自的异步加载
    /// </summary>
    public void StartLoading(Action onLoadDone = null)
    {
        this.onLoadDoneCallback = onLoadDone;
        List<BaseCutsceneElement> elementList = GetAllElements();
        pendingElements.Clear();

        // 没有任何 Element 的情况(比如空过场),直接视为完成
        if (elementList.Count == 0)
        {
            CheckAllElementsReady();
            return;
        }

        // 关键:先把所有 Element 登记到 pending 集合,再逐个触发 OnCreate()
        // 不能边加边触发——同步完成的 Element 会立刻移除自己导致集合提前清空
        foreach (BaseCutsceneElement element in elementList)
            pendingElements.Add(element);

        foreach (BaseCutsceneElement element in elementList)
            element.OnCreate();  // 各 Element 内部做自己的资源加载
    }

    /// <summary>
    /// 每个 Element 加载完成后主动调此方法上报
    /// </summary>
    public void AcceptCreateDoneNotice(ICutsceneElement finishedElement)
    {
        pendingElements.Remove(finishedElement);
        CheckAllElementsReady();
    }

    /// <summary>
    /// pending 全部清空 → 通知上层加载完成
    /// </summary>
    private void CheckAllElementsReady()
    {
        if (pendingElements.Count == 0)
        {
            onLoadDoneCallback?.Invoke();
            onLoadDoneCallback = null;
        }
    }

    /* ========== 播放阶段 ========== */

    public void Play(Action<CutsceneRoot> onPlayEnd = null)
    {
        if (IsPlaying) OnEnd();  // 如果上一段还没结束,先强制收尾

        this.onPlayEndCallback = onPlayEnd;
        masterDirector.extrapolationMode = DirectorWrapMode.None;  // 播完即停,不循环

        SetStartSetting();         // 项目相关的环境准备(绑定运行时相机等)
        gameObject.SetActive(true);
        NoticePlayStart();          // 广播 OnPlayStart() 给所有 Element
        masterDirector.Play();      // 启动 Timeline
        IsPlaying = true;
    }

    /* ========== 结束阶段 ========== */

    private void OnEnd()
    {
        SetEndSetting();            // 恢复环境(相机、输入等)
        gameObject.SetActive(false);
        NoticePlayComplete();       // 广播 OnPlayComplete(),各 Element 做清理
        onPlayEndCallback?.Invoke(this);
        onPlayEndCallback = null;
        IsPlaying = false;
    }
}

注意 StartLoading 里分了两个 foreach——先全部登记到 pendingElements,再逐个触发 OnCreate()。这里不能合成一个循环边加边触发,因为像 CameraElement 这种 OnCreate() 是同步完成的,调完立刻就会 AcceptCreateDoneNotice 把自己从集合里移除。如果这时候集合里才登记了一个,移除后就清空了,CheckAllElementsReady 直接判定”全部完成”,后面的 Element 还没开始加载呢。

运行时相机绑定

前面提到 SetStartSetting() 是项目相关的环境准备,其中最重要的一步就是 BindCamera()。在看代码之前,先理清过场里”相机”这套东西的关系,不然容易搞混。

Cinemachine 的工作模式是这样的:场景里有一台真实相机(挂了 Camera 组件的 GameObject),上面再挂一个 CinemachineBrain 组件。CinemachineBrain 不直接控制相机位置,而是去找场景里优先级最高的那台虚拟相机(CinemachineVirtualCamera),把真实相机的 Position / Rotation / FOV 对齐到虚拟相机上。

过场 Prefab 的 CameraGroup 节点下挂着若干 CameraElement,每个 CameraElement 会创建一台虚拟相机——全景机位、角色特写机位、跟随机位等等。这些虚拟相机定义的是”机位方案”,本身不渲染画面。真正渲染画面的是场景里那台带 CinemachineBrain 的主相机,CinemachineBrain 根据 Timeline 上 Camera Track 的 Clip 安排,在不同时间段激活不同的虚拟相机,主相机就跟着切。

过场 Prefab 里自带一个”编辑用相机”(Cutscene Camera),方便在编辑器里预览镜头效果。但运行时不用它——得让 Timeline 的 Camera Track 绑到场景里真正的主相机(的 CinemachineBrain)上,这样 Timeline 才能驱动主相机在各个虚拟相机之间切换。

BindCamera() 做的就是这件事。”Camera Track” 是每个过场 Timeline 创建时就自带的标准轨道,绑定时通过轨道名查找:

/// <summary>
/// 在 SetStartSetting() 中调用,把 Camera Track 绑定到场景主相机的 CinemachineBrain
/// </summary>
protected void BindCamera()
{
    GameObject sceneCamera = GameObject.FindWithTag("MainCamera");
    if (sceneCamera == null) return;

    CinemachineBrain cBrain = sceneCamera.GetComponent<CinemachineBrain>();
    if (cBrain != null)
    {
        // 主 Timeline 的 Camera Track 绑到主相机
        GetMasterDirector().BindTrack("Camera Track", cBrain);

        // 每个 SubCutscene 都有自己的 Camera Track,也要绑
        BindSubCutsceneCamera(cBrain);
    }
}

主 Timeline 和每个 SubCutscene 都有各自的 Camera Track,所以要遍历 SubCutsceneGroup 下所有子 Director 逐个绑定。

自定义镜头混合表

理解了上面的关系之后,这一节就好懂了。Cinemachine 在虚拟相机之间切换时会做混合过渡(Blend),默认所有切换都用同一条曲线和时长。但实际演出经常需要不同的过渡效果——比如全景机位切到角色特写想要硬切(0 秒),特写切到跟随镜头想慢慢过渡(2 秒),任意机位切到某个戏剧性角度永远用一条指定的缓动曲线。

CutsceneRoot 上维护了一张 customBlends 混合配置表(编辑器的”设置”面板可以可视化编辑),每一项配置一对虚拟相机名(From / To)和对应的混合曲线 + 时长。SetStartSetting() 里调 SetBlendOverride() 把这张表挂到 CinemachineCore.GetBlendOverride 委托上,Cinemachine 每次切虚拟相机时就会来查这张表,按配置做对应的过渡效果:

/// <summary>
/// 过场专用的镜头混合配置表,每一项指定 From/To 相机名和混合参数
/// </summary>
public List<CinemachineBlenderSettings.CustomBlend> customBlends
    = new List<CinemachineBlenderSettings.CustomBlend>();

/// <summary>
/// 把 customBlends 注入 CinemachineCore,过场期间全局生效
/// </summary>
protected void SetBlendOverride()
{
    CinemachineCore.GetBlendOverride = (fromVcam, toVcam, defaultBlend, owner) =>
    {
        bool gotAnyToMe = false;
        bool gotMeToAny = false;
        CinemachineBlendDefinition anyToMe = defaultBlend;
        CinemachineBlendDefinition meToAny = defaultBlend;

        foreach (CinemachineBlenderSettings.CustomBlend blend in customBlends)
        {
            // 优先级 1:精确匹配(A → B),直接返回
            if (blend.From == fromVcam.Name && blend.To == toVcam.Name)
                return blend.Blend;

            // 优先级 2:任意 → B(From 填 **any**,To 填具体相机名)
            if (blend.From == kBlendFromAnyCameraLabel)
            {
                if (blend.To == toVcam.Name)
                {
                    if (!gotAnyToMe) anyToMe = blend.Blend;
                    gotAnyToMe = true;
                }
                else if (blend.To == kBlendFromAnyCameraLabel)
                {
                    defaultBlend = blend.Blend;  // Any → Any 覆盖全局默认
                }
            }
            // 优先级 3:A → 任意(From 填具体相机名,To 填 **any**)
            else if (blend.To == kBlendFromAnyCameraLabel && blend.From == fromVcam.Name)
            {
                if (!gotMeToAny) meToAny = blend.Blend;
                gotMeToAny = true;
            }
        }

        if (gotAnyToMe) return anyToMe;   // 命中"任意 → B"
        if (gotMeToAny) return meToAny;   // 命中"A → 任意"
        return defaultBlend;               // 都没命中,走默认
    };
}

/// <summary>
/// 过场结束时清除委托,恢复 Cinemachine 默认行为
/// </summary>
protected void SetBlendOverrideNone()
{
    CinemachineCore.GetBlendOverride = null;
}

匹配优先级总结:精确匹配(A→B)> 任意→B > A→任意 > 默认。遍历整张表只要命中精确匹配就立刻返回,其余两种先记下来,遍历完再按优先级取。过场结束时 SetEndSetting()SetBlendOverrideNone() 把委托置空,恢复 Cinemachine 的默认混合行为。


5.3 CutsceneElement:参与者的统一抽象

为什么需要这层抽象

上一节讲相机绑定时已经能感觉到——过场里的”参与者”(相机、角色、特效、路径)都有一套类似的流程要走。Timeline 的 Binding 只管”轨道绑谁”,不管”这个对象从哪来、怎么创建、播完怎么回收”。注意项目里有些喜欢命名成Contrast,我觉得命名成Element更合适。

拿 NPC 角色来说,一个角色出场得走这么一圈:

  • 根据配置的资源路径异步加载角色 Prefab
  • 实例化到 RoleGroup 节点下
  • 拿到 Animator 组件,Director.SetGenericBinding(animTrack, animator) 绑到动画轨道
  • 过场结束后销毁模型实例

虚拟相机也差不多:

  • 创建 CinemachineVirtualCamera 组件,设好 FOV、Follow、LookAt
  • 绑到 Camera Track
  • 播完回收组件

每种参与者的具体创建逻辑不同,但生命周期模式是一样的——创建 → 绑定 → 播放 → 回收。CutsceneElement 就是把这个模式抽出来,让每种参与者只实现自己不同的那部分。

上面截图里,左侧 Hierarchy 的 RoleGroup 下有个 [002]Spider_102010 节点,右侧 Inspector 上能看到它挂了一个 RoleModelElement 组件(继承 BaseCutsceneElement,也就是 MonoBehaviour),配着 assetPath(模型资源路径)、no(编号)、关联轨道名这些字段。这些 Element 不需要策划手动挂——过场编辑器里点”添加角色”时,编辑器工具(CutsceneEditCore)会自动在对应分组下建子节点、挂组件、填默认参数。

Prefab 资产里只存了 Element 节点和它上面的配置字段,角色模型本身并不在 Prefab 里。 模型的 Mesh、材质、动画这些重资产只通过 assetPath 做路径引用,不序列化进过场 Prefab。一个过场可能有五六个角色,全嵌进去 Prefab 会很大,而且同一个角色在多个过场出场还会冗余。运行时 CutsceneRoot 的 StartLoading() 遍历所有 Element 逐个调 OnCreate(),各 Element 根据 assetPath 异步加载模型 Prefab、Instantiate 到自己节点下,再做轨道绑定。在 Project 窗口里直接看这个 Prefab(不打开编辑),Element 节点下面是空的——Hierarchy 里带 (Clone) 后缀的子对象都是动态加载出来的。

编辑器里之所以也能看到模型,并不是 [ExecuteInEditMode]Awake 自动触发了加载。实际链路是:过场编辑器工具 CutsceneEditCore 打开一个过场时,先把 Prefab 实例化到编辑场景里,然后**主动调用 CutsceneRoot.StartLoading()**,走的是和运行时相同的 Element 遍历 + OnCreate() 流程,只是加载方式不同——编辑器下走 AssetDatabase.LoadAssetAtPath 同步加载,运行时走 AssetBundle 异步加载。[ExecuteInEditMode] 的作用是让 CutsceneRoot 和各 Element 的 Update / LateUpdate 等帧循环方法在编辑器下不点 Play 也能跑——编辑器里拖 Timeline 进度条预览时,这些每帧逻辑得正常执行才能驱动预览效果。但加载入口本身不是 [ExecuteInEditMode] 触发的,是编辑器工具显式调的。

接口与基类

classDiagram
    class ICutsceneElement {
        <>
        +OnCreate() 统一创建入口,CutsceneRoot 调用
    }

    class BaseCutsceneElement {
        <>
        +CutsceneRoot CutsceneRoot 所属的 CutsceneRoot 引用(属性)
        +int no 编号,用于轨道命名和排序
        +ECreateState CreateState 当前创建状态(None/Creating/Done)
        +OnCreate() 设状态为 Creating,调 Create()
        +Create()* 子类实现:加载资源、创建组件
        +OnCreateDone() 标记完成,通知 Root 移除 pending
        +OnPlayStart()* 过场开始播放时回调
        +OnPlayComplete()* 过场播放结束时回调
        +Dispose()* 释放资源,重置状态
        +OnDestroySelf()* 销毁自身节点
    }

    class CameraElement {
        <<虚拟相机参与者>>
        +CinemachineVirtualCamera vcam 创建出的虚拟相机组件
        +Create() 创建 CinemachineVirtualCamera,设 FOV/Body/Aim
    }

    class ModelElement {
        <<3D 模型参与者基类>>
        +GameObject bindGo 加载后的模型实例引用
        +string assetPath 模型资源路径
        +Create() 异步加载 Prefab → 实例化到自身节点下
    }

    class RoleModelElement {
        <>
        +Create() 加载模型 + 找 Animator 绑动画轨道
    }

    class PlayerModelElement {
        <<玩家角色>>
        +Create() 不新建模型,绑定场景中已有的玩家对象
    }

    class InteractModelElement {
        <<交互物件>>
        +Create() 加载交互物件模型
    }

    class PathElement {
        <<路径参与者>>
        +CinemachineSmoothPath path 创建出的路径组件
        +Create() 创建路径,供相机或模型沿路径运动
    }

    class EffectElement {
        <<特效参与者>>
        +Create() 预加载特效 Prefab,挂到指定节点
    }

    ICutsceneElement <|.. BaseCutsceneElement : 实现接口
    BaseCutsceneElement <|-- CameraElement : 继承
    BaseCutsceneElement <|-- ModelElement : 继承
    BaseCutsceneElement <|-- PathElement : 继承
    BaseCutsceneElement <|-- EffectElement : 继承
    ModelElement <|-- RoleModelElement : 继承
    ModelElement <|-- PlayerModelElement : 继承
    ModelElement <|-- InteractModelElement : 继承
    BaseCutsceneElement --> "1" CutsceneRoot : 多对一,每个 Element 属于一个 Root
  • ICutsceneElement:接口,只定义 OnCreate(),是 CutsceneRoot 统一调用的入口
  • BaseCutsceneElement:继承 MonoBehaviour(挂在 Prefab 节点上),实现完整的生命周期骨架,子类只需覆写 Create() 等虚方法
  • CameraElement / ModelElement / PathElement / EffectElement:直接继承 BaseCutsceneElement,各自实现不同的资源创建逻辑
  • RoleModelElement / PlayerModelElement / InteractModelElement:继承 ModelElement,复用模型加载逻辑。NPC 需要新建模型,玩家绑定场景中已有的对象,交互物件加载方式和 NPC 类似
  • 关系上,多个 Element 属于同一个 CutsceneRoot(多对一),CutsceneRoot 通过 GetComponentsInChildren 收集所有 Element

BaseCutsceneElement 实现了完整的生命周期骨架:

// BaseCutsceneElement.cs
[ExecuteInEditMode]
public class BaseCutsceneElement : MonoBehaviour, ICutsceneElement
{
    /// <summary>
    /// 缓存字段,由属性惰性赋值
    /// </summary>
    private CutsceneRoot _cutsceneRoot;

    /// <summary>
    /// 所属的 CutsceneRoot(向上查找并缓存)
    /// Element 挂在 Prefab 子节点上,Root 在根节点,GetComponentInParent 一定能找到
    /// </summary>
    public CutsceneRoot CutsceneRoot
    {
        get
        {
            if (_cutsceneRoot == null)
                _cutsceneRoot = GetComponentInParent<CutsceneRoot>();
            return _cutsceneRoot;
        }
    }

    /// <summary>
    /// 当前创建状态(None / Creating / Done)
    /// </summary>
    public ECreateState CreateState { protected set; get; }

    /// <summary>
    /// CutsceneRoot 统一调用的入口,设状态为 Creating 后调 Create()
    /// </summary>
    public void OnCreate()
    {
        CreateState = ECreateState.Creating;
        Create();
    }

    /// <summary>
    /// 子类覆写:执行具体的资源加载、组件创建、轨道绑定等
    /// </summary>
    public virtual void Create() { }

    /// <summary>
    /// 子类在 Create() 完成后必须手动调用,标记完成并通知 Root
    /// </summary>
    public virtual void OnCreateDone()
    {
        CreateState = ECreateState.Done;
        CutsceneRoot.AcceptCreateDoneNotice(this);
    }

    /// <summary>
    /// 过场开始播放时回调
    /// </summary>
    public virtual void OnPlayStart() { }

    /// <summary>
    /// 过场播放结束时回调
    /// </summary>
    public virtual void OnPlayComplete() { }

    /// <summary>
    /// 释放资源,重置创建状态
    /// </summary>
    public virtual void Dispose() { CreateState = ECreateState.None; }

    /// <summary>
    /// 销毁自身节点
    /// </summary>
    public virtual void OnDestroySelf() { }
}

注意 OnCreateDone() 是子类”必须显式调用”的。因为有些 Element 的 Create() 是异步的(比如加载 Prefab),不能在 Create() 返回时就认为完成了,得等加载回调来了才调 OnCreateDone()

异步加载管线

整个加载流程的关键在于 CutsceneRoot 和各 Element 之间的协作模式:

flowchart LR
    A["StartLoading()"] --> B["收集所有
CutsceneElement"] B --> C["逐个触发
OnCreate()"] C --> D{"各 Element
并行加载"} D --> E1["CameraElement
创建虚拟相机"] D --> E2["RoleModelElement
异步加载 Prefab"] D --> E3["EffectElement
异步加载特效"] E1 --> F["OnCreateDone()
从 pending 集合移除"] E2 --> F E3 --> F F --> G{"pending
清空?"} G -->|否| F G -->|是| H["触发完成回调
可以 Play 了"]

各 CutsceneElement 在 Create() 里需要加载资源——比如 RoleModelElement 要加载角色 Prefab,EffectElement 要加载特效 Prefab。这些加载请求统一走 CutsceneLoader.LoadAsset(),它根据 InGame(判断当前是运行时还是编辑器环境)走两条路:

  • 运行时:通过项目封装的资源管理器异步加载。这里的 ResMgr 代表项目自己的资源管理系统,具体实现因项目而异
  • 编辑器:走 AssetDatabase.LoadAssetAtPath 同步加载,方便编辑器预览时立刻拿到资源
// CutsceneLoader.cs
public class CutsceneLoader
{
    /// <summary>
    /// 本轮加载的所有资源句柄,过场销毁时统一 Dispose 释放
    /// </summary>
    private List<AssetHandle> _assetHandleList = new List<AssetHandle>();

    /// <summary>
    /// 统一资源加载接口
    /// </summary>
    /// <param name="assetName">资源路径,如 "Assets/Models/NPC_001.prefab"</param>
    /// <param name="attachObj">资源要挂到的节点,比如 RoleModelElement 自身的 GameObject</param>
    /// <param name="callback">加载完成回调,返回加载好的资源对象</param>
    public void LoadAsset(string assetName, GameObject attachObj, Action<object> callback)
    {
        if (InGame)
        {
            // 运行时异步加载(ResMgr 是项目封装的资源管理系统,如 Resources / AssetBundle / Addressable / YooAsset)
            // handle 是资源句柄,类似 YooAsset 的 AssetHandle、Addressables 的 AsyncOperationHandle
            AssetHandle handle = ResMgr.LoadAssetAsync(assetName);
            // Register:注册异步加载完成的回调,加载完毕后 asset 就是拿到的资源对象
            handle.Register(asset =>
            {
                // 异步回来时先检查挂点是否还在
                // 比如 RoleModelElement 要把角色 Prefab 挂到 RoleGroup 下的某个节点,
                // 但如果过场中途被强制结束,这个节点可能已经被销毁了
                if (attachObj != null)
                {
                    _assetHandleList.Add(handle);  // 记录句柄,过场结束时统一释放
                    callback(asset);
                }
                else
                {
                    handle.Dispose();  // 挂点没了,直接释放资源,避免泄漏
                }
            });
        }
        else
        {
            // 编辑器下同步加载
            Object asset = AssetDatabase.LoadAssetAtPath<Object>(assetName);
            callback(asset);
        }
    }
}

ModelElement 和 RoleModelElement

BaseCutsceneElement 定义了骨架,具体的加载逻辑在子类里。拿最常用的角色模型来说,继承链是 BaseCutsceneElement → ModelElement → RoleModelElementModelElement 负责通用的”加载 Prefab → 实例化 → 挂到节点下”,RoleModelElement 在此基础上多做一步轨道绑定。

/// <summary>
/// 所有 3D 模型参与者的基类
/// 负责根据 assetPath 加载 Prefab、实例化到自身节点下
/// </summary>
public class ModelElement : BaseCutsceneElement
{
    /// <summary>
    /// 模型资源路径(如 "Assets/Content/Character/xxx.prefab")
    /// </summary>
    public string assetPath;

    /// <summary>
    /// 加载完成后的模型实例引用
    /// </summary>
    public GameObject bindGo;

    /// <summary>
    /// Body 容器:bindGo 的父节点,用于统一控制位姿和重置
    /// </summary>
    protected Transform body;

    /// <summary>
    /// Root.StartLoading() 触发,开始加载资源
    /// CutsceneRoot.LoadAsset 内部委托给 CutsceneLoader:
    ///   运行时走 AssetBundle 异步加载,编辑器下走 AssetDatabase 同步加载
    /// </summary>
    public override void Create()
    {
        if (bindGo == null)
            CutsceneRoot.LoadAsset(assetPath, gameObject, OnAssetLoaded);
        else
            OnCreateDone();
    }

    /// <summary>
    /// 加载回调:把加载到的 Prefab Instantiate 到自身节点下
    /// </summary>
    protected virtual void OnAssetLoaded(object asset)
    {
        if (asset is GameObject prefab)
            bindGo = Instantiate(prefab, transform, false);

        OnCreateDone();
    }

    /// <summary>
    /// 加载完成:把 bindGo 放进 Body 容器、重建挂点、重新绑定相机
    /// </summary>
    public override void OnCreateDone()
    {
        // bindGo 刚 Instantiate 出来是直接挂在 Element 节点下的
        // 这一步把它移到 Body 子节点下,position/rotation/scale 归零
        // Body 是一个中间层容器([Body]xxx),后续所有位移、旋转操作都通过 Body 来做
        // 这样 bindGo 自身的 Transform 始终保持干净,方便重置和还原
        BindGoToBody();

        // 有些 Element 之间有父子挂接关系——比如一个特效 Element 要挂到某个角色的手骨上
        // 模型刚 Instantiate 出来,之前配好的挂点 Transform 引用全失效了
        // 这一步重新遍历子附加列表,按骨骼名查找并重新 SetParent
        ReinitAttachPoints();

        // CameraElement 的虚拟相机可能配了 Follow / LookAt 指向这个角色
        // 模型重建后 Transform 引用也断了,这一步重新把相机的目标指回来
        ReInitBindCamera();
        base.OnCreateDone();
    }

    /// <summary>
    /// 过场开始:设为 AlwaysAnimate,保证 Animator 不被视锥剔除
    /// </summary>
    public override void OnPlayStart()
    {
        if (bindGo != null && bindGo.GetComponent<Animator>() != null)
            bindGo.GetComponent<Animator>().cullingMode = AnimatorCullingMode.AlwaysAnimate;
    }

    /// <summary>
    /// 过场结束:恢复 CullUpdateTransforms
    /// </summary>
    public override void OnPlayComplete()
    {
        if (bindGo != null && bindGo.GetComponent<Animator>() != null)
            bindGo.GetComponent<Animator>().cullingMode = AnimatorCullingMode.CullUpdateTransforms;
    }
}

/// <summary>
/// NPC 角色 Element
/// 在 ModelElement 的基础上多做轨道绑定:
/// 把加载好的模型绑到 Timeline 的动画轨道上
/// </summary>
public class RoleModelElement : ModelElement
{
    /// <summary>
    /// 对应的动画轨道名(和 Timeline 里 AnimationTrack 的 name 一致)
    /// </summary>
    public string animTrackName;

    /// <summary>
    /// 表情轨道名(Face Track),特殊模型才有
    /// </summary>
    public string animTrackFaceName;

    /// <summary>
    /// 加载完成后:先调基类做通用绑定,再把 bindGo 绑到动画轨道上
    /// </summary>
    public override void OnCreateDone()
    {
        // 遍历所有 Director(主 + 各 SubCutscene),把 bindGo 绑到同名动画轨道
        List<PlayableDirector> allDirectors = CutsceneRoot.GetAllDirector();
        for (int i = 0; i < allDirectors.Count; i++)
        {
            allDirectors[i].BindTrack(animTrackName, bindGo);
        }
        base.OnCreateDone();
    }
}

RoleModelElement.OnCreateDone() 里遍历了所有 Director(主 Director + 各 SubCutscene 的子 Director),因为同一个角色可能在多个子 Timeline 里出场,每个 Director 上都有同名的 AnimationTrack 需要绑定。BindTrack 内部就是 Director.SetGenericBinding(track, bindGo) 的封装。

各类型简述

  • CameraElement:在 CameraGroup 下的节点上创建 CinemachineVirtualCamera 组件,设好 FOV、Body、Aim 等参数。如果这台虚拟相机需要沿路径运动,会关联一个 PathElement。前面讲过,运行时 BindCamera() 会把 Camera Track 绑到场景主相机的 CinemachineBrain 上,Timeline 就能在不同虚拟相机之间切换了。

  • ModelElement:所有 3D 模型参与者的基类,持有 bindGoassetPathCreate() 走 CutsceneLoader 加载 Prefab → 实例化 → 挂到 Body 容器下 → OnCreateDone()。上面已经给出了完整伪代码。

  • RoleModelElement(继承 ModelElement):NPC 角色。加载完成后额外把 bindGo 绑到 Timeline 的动画轨道上,如果是特殊模型(有面部表情、耳朵)还会扫描并绑定 Face / Ear 轨道。

  • PlayerModelElement(继承 ModelElement):玩家角色。和 RoleModelElement 不同的是,它不新建模型——场景里已经有玩家对象了,Create() 里通过约定的方式拿到玩家 GameObject,绑好轨道就行。

  • InteractModelElement(继承 ModelElement):交互物件。场景中可交互的物体(如道具、机关等),加载方式和 RoleModelElement 类似,区别在于绑定的轨道类型不同。

  • PathElement:创建 CinemachineSmoothPath 组件(路径曲线),供 CameraElement 沿路径运动,或者供 ModelElement 沿路径移动。一个参与者可以绑多条路径,比如镜头巡游的前半段和后半段用不同曲线。

  • EffectElement:预加载特效 Prefab 并管理挂点(挂到指定骨骼节点或世界坐标下)。和模型不同,特效的显示/隐藏通常由 EffectKeyframeTrack 上的 Clip 控制——Clip 进入时播放特效,退出时销毁。EffectElement 主要负责提前把资源加载好、挂点准备好,真正的播控交给轨道。


5.4 自定义轨道体系

问题:十几种轨道的重复样板代码

回顾一下之前讲过的自定义轨道做法。Playable 系列里讲过,自定义 Playable 的核心是继承 PlayableBehaviour,覆写 OnBehaviourPlay / ProcessFrame / OnBehaviourPause 来写运行时逻辑。源码浅析里看过 Timeline 建图的流程——每条 TrackAsset 通过 CreateTrackMixer() 创建一个 Mixer Playable 作为这条轨道的根节点,然后 CompileClips 遍历轨道上的每个 Clip,调它们的 PlayableAsset.CreatePlayable() 创建叶子 Playable,连到 Mixer 的输入端口上。

所以要自定义一种轨道,得写四个类配合起来:

继承 职责
XxxTrack TrackAsset 轨道定义,打 [TrackClipType] 标记能放什么 Clip,打 [TrackBindingType] 标记绑定什么类型的场景对象
XxxClip PlayableAsset Clip 资产,CreatePlayable() 里创建运行时 Playable 作为叶子节点
XxxBehaviour PlayableBehaviour Clip 的运行时逻辑,OnBehaviourPlay 处理进入、ProcessFrame 处理每帧、OnBehaviourPause 处理退出
XxxMixer PlayableBehaviour Track 级 Mixer,ProcessFrame 里遍历所有输入端口(每个输入对应一个 Clip),根据权重做混合

问题在于:过场系统需要十几种自定义轨道(对话框、字幕、幕帘、特效关键帧、音频、相机抖动、通用事件……),每种都从零写一遍完整四件套的话,大量代码是重复的——获取 Director 引用、获取轨道绑定对象、判断 Clip 是否首次进入、算 Clip 播放进度、在 Mixer 里遍历活跃 Clip 做混合……这些逻辑每种轨道都一样,只有具体的业务行为不同。

解决方案:BaseTrack 四件套

把重复逻辑抽到四个基类里:

classDiagram
    class TrackAsset {
        <>
        +CreateTrackMixer()* Timeline 建图时调用,创建 Mixer Playable
    }

    class PlayableBehaviour {
        <>
        +OnPlayableCreate() Playable 创建时
        +OnBehaviourPlay() 进入播放时
        +ProcessFrame() 每帧调用
        +OnBehaviourPause() 退出播放时
    }

    class PlayableAsset {
        <>
        +CreatePlayable()* 建图时调用,创建叶子 Playable
    }

    class BaseTrack~TMixer_TClipBehaviour~ {
        <<自定义:轨道基类>>
        +CreateTrackMixer() 重写:创建 MixerBehaviour 的 Playable,注入轨道引用
    }

    class MixerBehaviour~T~ {
        <<自定义:Track 级混合基类>>
        +TrackAsset theTrack 所属轨道的引用
        +T previousBehaviour 上一个活跃 Clip 的行为
        +T nextBehaviour 下一个活跃 Clip 的行为
        +ProcessFrame() 重写:遍历输入端口,按权重分发到下面的虚方法
        +OnFirstFrame()* 自定义:首帧回调,做初始化(如缓存绑定对象)
        +OnSingleUpdate(clipPlayable)* 自定义:对每个权重 > 0 的 Clip 逐个回调
        +OnLiveFrame()* 自定义:这一帧有活跃 Clip 时回调
        +OnSilenceFrame()* 自定义:这一帧没有任何活跃 Clip 时回调
    }

    class ClipBehaviour {
        <<自定义:Clip 级行为基类>>
        +TrackAsset TheTrack 所属轨道的引用
        +PlayableDirector director 当前 Director,自动从 Graph 获取
        +CutsceneRoot cutsceneRoot 过场根组件引用
        +GameObject BindGo 轨道绑定的场景对象
        +OnBehaviourPlay() 重写:翻译成 OnClipFirstEnter / OnClipEnter
        +ProcessFrame() 重写:翻译成 OnClipUpdate / OnClipSeek
        +OnBehaviourPause() 重写:翻译成 OnClipExit
        +OnClipFirstEnter()* 自定义:Clip 首次进入时回调(只触发一次)
        +OnClipEnter()* 自定义:每次进入 Clip 时回调
        +OnClipExit()* 自定义:退出 Clip 时回调
        +OnClipUpdate(rate)* 自定义:每帧回调,rate 是 0~1 的归一化进度
        +OnClipSeek()* 自定义:时间跳跃时回调(拖动进度条等)
        +OnClipDestroy()* 自定义:Playable 销毁时兜底清理
    }

    class BaseClip~T~ {
        <<自定义:Clip 资产基类>>
        +T template 持有的 ClipBehaviour 实例(序列化数据)
        +CreatePlayable() 重写:用 template 创建 ScriptPlayable
        +InitClip(TimelineClip) 自定义:把 Clip 的起始时间和时长写入 template
        +SetTrack(TrackAsset) 自定义:把轨道引用写入 template
    }

    TrackAsset <|-- BaseTrack : 继承
    PlayableBehaviour <|-- MixerBehaviour : 继承
    PlayableBehaviour <|-- ClipBehaviour : 继承
    PlayableAsset <|-- BaseClip : 继承
    BaseTrack ..> MixerBehaviour : CreateTrackMixer() 时创建(一条轨道一个 Mixer)
    BaseTrack ..> BaseClip : 轨道上每个 Clip 对应一个 BaseClip
    BaseClip --> "1" ClipBehaviour : 持有 template(一对一)
    MixerBehaviour --> "0..*" ClipBehaviour : ProcessFrame 时遍历所有活跃 Clip 的行为

图里标了 <<Unity 内置>> 的是 Unity 提供的基类,标了 <<自定义>> 的是我们自己写的。每个方法前面也标了是 重写 Unity 的虚方法还是 自定义 的新虚方法——重写的方法里做了一层翻译,把 Unity 原生回调转成语义更清晰的自定义虚方法,子类只需要覆写自定义的那些就行。

逐个看下每个基类做了什么。

ClipBehaviour:Clip 级行为基类

这是每个 Clip 的运行时逻辑基类。它把 PlayableBehaviour 原生的回调翻译成语义更清晰的虚方法。

回忆一下 PlayableBehaviour 的原生回调:

  • OnPlayableCreate:Playable 创建时
  • OnBehaviourPlay:Clip 进入播放时(首次进入,或从 Pause 恢复时)
  • ProcessFrame:每帧调用(源码浅析里看过,由 RuntimeClip.EvaluateAtenable = trueSetTime 后触发)
  • OnBehaviourPause:Clip 退出播放时

ClipBehaviour 在这些原生回调里做了一层翻译:

flowchart TB
    A["OnBehaviourPlay
(原生回调)"] --> B{"是否首次进入?"} B -->|是| C["OnClipFirstEnter() — 只执行一次"] B -->|是/否| D["OnClipEnter(time) — 每次进入都执行"] E["ProcessFrame
(原生回调)"] --> F["OnClipUpdate(rate) — 归一化进度 0~1"] E --> G{"本帧时间与上帧时间
差值 > 0.08s ?"} G -->|是| H["OnClipSeek() — 检测到时间跳跃"] I["OnBehaviourPause
(原生回调)"] --> J{"已经播放过?"} J -->|是| K["OnClipExit() — Clip 退出"] L["OnPlayableDestroy
(原生回调)"] --> M["OnClipDestroy() — 销毁时兜底清理"]

其中 OnClipSeek 的触发条件是:每帧记录当前时间,和上一帧比较,如果差值超过 0.08 秒就认为发生了”时间跳跃”。正常播放时每帧时间差很小(一帧大约 0.016 秒),但如果用户在编辑器里拖动了进度条、或者运行时调了 JumpToSubCutscene 跳到另一个时间点,时间差会突然变大。这时候有些 Clip 需要做额外处理——比如对话框 Clip 需要根据跳转后的时间重新定位到正确的对话内容,特效 Clip 可能需要重新创建或跳过某些阶段。

ClipBehaviour 还自动拿好了常用引用,子类直接用就行:

// ClipBehaviour.cs(伪代码)
public class ClipBehaviour : PlayableBehaviour
{
    /// <summary>
    /// 所属轨道,由 BaseClip 初始化时写入
    /// </summary>
    public TrackAsset TheTrack;

    /// <summary>
    /// Clip 在 Timeline 上的起始时间(秒)
    /// </summary>
    public double startTime;

    /// <summary>
    /// Clip 的时长(秒)
    /// </summary>
    public double durationTime;

    /// <summary>
    /// 当前 PlayableDirector,首次进入时自动从 Graph 获取
    /// </summary>
    public PlayableDirector director;

    /// <summary>
    /// 过场根组件引用,方便子类访问 CutsceneRoot 上的数据
    /// </summary>
    public CutsceneRoot cutsceneRoot;

    /// <summary>
    /// 轨道绑定的场景对象,通过 director.GetGenericBinding(TheTrack) 获取
    /// </summary>
    public GameObject BindGo =>
        director.GetGenericBinding(TheTrack) as GameObject;

    /// <summary>
    /// 是否已经播放过,用于区分首次进入
    /// </summary>
    private bool hasPlayed = false;

    /// <summary>
    /// 重写 OnBehaviourPlay:翻译成 OnClipFirstEnter + OnClipEnter
    /// </summary>
    public override void OnBehaviourPlay(Playable playable, FrameData info)
    {
        // Graph 的 Resolver 就是创建它的 PlayableDirector
        if (director == null)
            director = playable.GetGraph().GetResolver() as PlayableDirector;

        if (!hasPlayed)
        {
            hasPlayed = true;
            OnClipFirstEnter();
        }
        OnClipEnter(playable, info, director.time);
    }

    /// <summary>
    /// 重写 ProcessFrame:算出 0~1 的归一化进度,翻译成 OnClipUpdate
    /// </summary>
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        double elapsed = director.time - startTime;
        double rate = elapsed / durationTime;
        OnClipUpdate(playable, info, rate);
    }

    /// <summary>
    /// 重写 OnBehaviourPause:翻译成 OnClipExit
    /// </summary>
    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        if (hasPlayed)
            OnClipExit(playable, info);
    }

    /// <summary>
    /// 重写 OnPlayableDestroy:翻译成 OnClipDestroy
    /// </summary>
    public override void OnPlayableDestroy(Playable playable)
    {
        OnClipDestroy();
        director = null;
        cutsceneRoot = null;
    }

    /* ========== 子类覆写以下方法实现具体业务逻辑 ========== */

    /// <summary>
    /// Clip 首次进入时回调(整个生命周期只触发一次)
    /// </summary>
    public virtual void OnClipFirstEnter() { }

    /// <summary>
    /// 每次进入 Clip 时回调
    /// </summary>
    public virtual void OnClipEnter(Playable playable, FrameData info, double time) { }

    /// <summary>
    /// 每帧回调,rate 是 0~1 的归一化播放进度
    /// </summary>
    public virtual void OnClipUpdate(Playable playable, FrameData info, double rate) { }

    /// <summary>
    /// 退出 Clip 时回调
    /// </summary>
    public virtual void OnClipExit(Playable playable, FrameData info) { }

    /// <summary>
    /// 时间跳跃时回调(拖动进度条、JumpToSubCutscene 等场景)
    /// </summary>
    public virtual void OnClipSeek(Playable playable, FrameData info, double time) { }

    /// <summary>
    /// Playable 销毁时回调,用于兜底清理(如过场强制结束时补触发事件)
    /// </summary>
    public virtual void OnClipDestroy() { }
}

director.GetGenericBinding(TheTrack) 返回轨道绑定的场景对象。playable.GetGraph().GetResolver() 拿到的就是当前 PlayableDirector——Graph 的 Resolver 就是创建它的 Director。

MixerBehaviour:Track 级混合基类

每条 Track 有一个 Mixer Playable(源码浅析里看过 CreateTrackMixer 是怎么建的),MixerBehaviour 就是这个 Mixer 的运行时逻辑。

它在 ProcessFrame 里每帧做三件事:

  1. 查找前后邻近 Clip:遍历所有 Clip,根据当前时间和每个 Clip 的 startTime / endTime 比较,找出”刚播完的上一段”(previousBehaviour)和”即将播放的下一段”(nextBehaviour)。子类做过渡逻辑时会用到——比如相机优先级轨道在 Clip 间隙需要知道前后分别是哪台相机。

  2. 收集活跃 Clip:通过 GetInputWeight(i) 拿到 Timeline 算好的混合权重,权重 > 0 的 Clip 就是当前帧活跃的。每个活跃 Clip 会触发一次 OnSingleUpdate 回调。Clip 交叉混合(Ease In/Out 重叠)时会同时有多个活跃 Clip,所以用一个 List<int> 收集所有活跃索引。

  3. 分发到 OnLiveFrame / OnSilenceFrame:如果这一帧有活跃 Clip,把索引列表传给 OnLiveFrame;如果没有任何活跃 Clip(比如两段 Clip 之间的空白区域),走 OnSilenceFrame

// MixerBehaviour.cs(伪代码)
public class MixerBehaviour<T> : PlayableBehaviour where T : ClipBehaviour, new()
{
    /// <summary>
    /// 所属轨道引用,由 BaseTrack.CreateTrackMixer() 注入
    /// </summary>
    public TrackAsset theTrack;

    /// <summary>
    /// 是否已执行过首帧回调
    /// </summary>
    private bool firstFrameHappened = false;

    /// <summary>
    /// 离当前时间最近的、已经播完的 Clip 行为(用于过渡/上下文查询)
    /// </summary>
    protected T previousBehaviour;

    /// <summary>
    /// 离当前时间最近的、还没开始的 Clip 行为(用于过渡/上下文查询)
    /// </summary>
    protected T nextBehaviour;

    /// <summary>
    /// 当前帧活跃 Clip 的索引列表,每帧 Clear 后重新收集,复用避免 GC
    /// </summary>
    private List<int> activeClipIndices = new List<int>();

    /// <summary>
    /// 重写 ProcessFrame:遍历所有输入端口,查找前后邻近 Clip,按权重分发到自定义虚方法
    /// </summary>
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        if (!firstFrameHappened)
        {
            OnFirstFrame(playable, info, playerData);
            firstFrameHappened = true;
        }

        int inputCount = playable.GetInputCount();
        float totalWeight = 0f;
        activeClipIndices.Clear();
        previousBehaviour = null;
        nextBehaviour = null;

        double minPreviousOffset = 0;
        double minNextOffset = 0;

        // 遍历 Mixer 的所有输入端口,每个端口对应轨道上的一个 Clip
        for (int i = 0; i < inputCount; i++)
        {
            ScriptPlayable<T> inputPlayable = (ScriptPlayable<T>)playable.GetInput(i);
            T behaviour = inputPlayable.GetBehaviour();
            if (behaviour == null) continue;

            double clipEndTime = behaviour.startTime + behaviour.durationTime;

            // 查找 previousBehaviour:已经播完的 Clip 中离当前时间最近的
            if (playable.GetTime() > clipEndTime)
            {
                double offset = Math.Abs(playable.GetTime() - clipEndTime);
                if (minPreviousOffset == 0 || offset < minPreviousOffset)
                {
                    previousBehaviour = behaviour;
                    minPreviousOffset = offset;
                }
            }

            // 查找 nextBehaviour:还没开始的 Clip 中离当前时间最近的
            if (playable.GetTime() < behaviour.startTime)
            {
                double offset = Math.Abs(playable.GetTime() - (double)behaviour.startTime);
                if (minNextOffset == 0 || offset < minNextOffset)
                {
                    nextBehaviour = behaviour;
                    minNextOffset = offset;
                }
            }

            // GetInputWeight:拿到 Timeline 算好的混合权重(EvaluateAt 里 MixIn/MixOut 的结果)
            float weight = playable.GetInputWeight(i);
            totalWeight += weight;

            if (weight > 0f)
            {
                activeClipIndices.Add(i);
                OnSingleUpdate(inputPlayable);  // 对每个活跃 Clip 逐个回调
            }
        }

        // Clip 交叉混合(Ease In/Out 重叠)时会同时有多个活跃 Clip
        if (totalWeight > 0f)
            OnLiveFrame(playable, info, playerData, activeClipIndices);
        else
            OnSilenceFrame(playable, info, playerData);
    }

    /// <summary>
    /// 重写 OnPlayableDestroy:重置状态,清空列表,调子类收尾逻辑
    /// </summary>
    public override void OnPlayableDestroy(Playable playable)
    {
        firstFrameHappened = false;
        activeClipIndices.Clear();
        OnDestroy(playable);
    }

    /* ========== 子类覆写以下方法实现具体业务逻辑 ========== */

    /// <summary>
    /// 首帧回调,做初始化(如缓存绑定对象引用)
    /// </summary>
    protected virtual void OnFirstFrame(Playable playable, FrameData info, object data) { }

    /// <summary>
    /// 对每个权重 > 0 的 Clip 逐个调用,适合做"活跃 Clip 变了就切换状态"的逻辑
    /// </summary>
    protected virtual void OnSingleUpdate(ScriptPlayable<T> clipPlayable) { }

    /// <summary>
    /// 这一帧有至少一个活跃 Clip 时调用,activeClipIndices 包含所有活跃 Clip 的索引
    /// </summary>
    protected virtual void OnLiveFrame(Playable playable, FrameData info, object data, List<int> activeClipIndices) { }

    /// <summary>
    /// 这一帧没有任何活跃 Clip 时调用,适合做清空/关闭逻辑
    /// </summary>
    protected virtual void OnSilenceFrame(Playable playable, FrameData info, object data) { }

    /// <summary>
    /// Mixer 销毁时的收尾逻辑
    /// </summary>
    protected virtual void OnDestroy(Playable playable) { }
}

源码浅析里看过,EvaluateAt 会把 MixIn/MixOut 算好的权重通过 SetInputWeight 写到 Mixer 上,所以这里 GetInputWeight 拿到的就是 Timeline 帮我们算好的混合权重,不用自己再算一遍。

BaseTrack:轨道胶水层

BaseTrack 是泛型基类 BaseTrack<TMixer, TClipBehaviour>,本身不包含业务逻辑,做的是在 Timeline 建图时把各个部件串起来。

先回顾一下源码浅析里讲过的建图流程,不然容易搞混。Timeline 建图时,每条 Track 会走 CompileClips() 方法(TrackAsset 上的 internal 方法),这个方法内部按顺序做两件事:

  1. **调 CreateTrackMixer()**:创建这条轨道的 Mixer 节点(比如 AnimationTrack 创建 AnimationMixerPlayable,自定义轨道创建 ScriptPlayable)
  2. 遍历轨道上的每个 Clip,调 clip.asset.CreatePlayable() 创建叶子 Playable,连到 Mixer 的输入端口上,同时把 Clip 包成 RuntimeClip 塞进 IntervalTree

我们的 BaseTrack 重写的就是第 1 步的 CreateTrackMixer(),BaseClip 重写的就是第 2 步的 CreatePlayable()

BaseTrack 在 CreateTrackMixer() 里除了创建 Mixer,还额外做了两件事:一是把轨道引用(this)注入 MixerBehaviour,让 Mixer 在运行时能访问轨道信息;二是通过 TrackAsset.GetClips()(源码浅析讲过的方法)拿到轨道上所有 TimelineClip,遍历每个 Clip 把时间信息和轨道引用写进 ClipBehaviour。

这里有个小细节:TimelineClip.asset 返回的是 PlayableAsset,但我们需要调的是自定义的 InitClipSetTrack 方法,这两个方法 Unity 的 PlayableAsset 上没有。所以我们定义了一个 IClip 接口,让 BaseClip 实现它,BaseTrack 通过 is IClip 模式匹配拿到接口后调用:

// 自定义接口,BaseClip 实现,BaseTrack 通过它注入数据
public interface IClip
{
    void InitClip(TimelineClip clip);   // 写入 startTime、durationTime
    void SetTrack(TrackAsset track);    // 写入轨道引用
}
// BaseTrack.cs(伪代码)
public class BaseTrack<TMixer, TClipBehaviour> : TrackAsset
    where TMixer : MixerBehaviour<TClipBehaviour>, new()
    where TClipBehaviour : ClipBehaviour, new()
{
    /// <summary>
    /// Timeline 建图时调用:创建 Mixer 节点,注入轨道引用,
    /// 并遍历所有 Clip 把时间信息和轨道引用写进各 ClipBehaviour
    /// </summary>
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        // 创建 MixerBehaviour 的 Playable 节点
        ScriptPlayable<TMixer> mixerPlayable = ScriptPlayable<TMixer>.Create(graph, inputCount);
        // 注入轨道引用,让 MixerBehaviour 能通过 theTrack 访问轨道信息
        mixerPlayable.GetBehaviour().theTrack = this;

        // 遍历轨道上所有 Clip,提前把数据写进 ClipBehaviour
        foreach (TimelineClip timelineClip in GetClips())
        {
            if (timelineClip.asset is IClip clipAsset) 
            {
                clipAsset.InitClip(timelineClip);   // 写入 startTime、durationTime
                clipAsset.SetTrack(this);            // 写入轨道引用
            }
        }

        return mixerPlayable;
    }
}

BaseClip:Clip 资产胶水层

BaseClip 是泛型基类 BaseClip<T>,里面有个 template 字段,类型就是 ClipBehaviour。子类在 Inspector 里配的业务参数(比如对话框轨道的 dialogIdnpcName)都序列化在 template 上。

CompileClips 遍历轨道上每个 Clip 时,会调它的 CreatePlayable() 创建叶子 Playable 连到 Mixer 上。BaseClip 在这里直接用 template 创建 ScriptPlayable<T> 就行了——因为上面讲的 BaseTrack 在 CreateTrackMixer 里已经把 startTime、durationTime、TheTrack 都写进 template 了,数据都是现成的。

BaseClip 同时实现了前面定义的 IClip 接口,InitClipSetTrack 就是 BaseTrack 在建图时调的那两个方法:

// BaseClip.cs(伪代码)
public class BaseClip<T> : PlayableAsset, IClip where T : ClipBehaviour, new()
{
    /// <summary>
    /// ClipBehaviour 实例,序列化在 Clip 资产上,子类在 Inspector 里配的数据都存在这里
    /// </summary>
    public T template = new T();

    /// <summary>
    /// CompileClips 时调用:用 template 创建叶子 Playable 连到 Mixer 上
    /// </summary>
    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        return ScriptPlayable<T>.Create(graph, template);
    }

    /// <summary>
    /// 由 BaseTrack.CreateTrackMixer() 调用,把 TimelineClip 的起始时间和时长写入 template
    /// </summary>
    public void InitClip(TimelineClip clip)
    {
        template.startTime = clip.start;
        template.durationTime = clip.duration;
    }

    /// <summary>
    /// 由 BaseTrack.CreateTrackMixer() 调用,把轨道引用写入 template
    /// </summary>
    public void SetTrack(TrackAsset track)
    {
        template.TheTrack = track;
    }
}

这样 ClipBehaviour 在运行时拿到的 startTimedurationTimeTheTrackdirector 都是现成的,子类不用自己操心去哪取这些信息。

自定义轨道实现

有了四件套基类,实现一种新轨道只需要三步:

  1. 定义 ClipBehaviour 子类,声明数据字段,覆写 OnClipEnter / OnClipExit 等虚方法写业务逻辑
  2. 如果需要 Track 级混合逻辑,定义 MixerBehaviour 子类;不需要的话空继承就行
  3. 定义 Track 子类,打上 [TrackClipType][TrackColor] 等 Attribute

以”对话框轨道”为例——需求:Clip 进入时弹出对话面板,退出时关闭,策划在 Inspector 上配对话 ID 和 NPC 名字。至于什么时候通过事件中心通知外部、发什么事件,这是程序员在写这条轨道时根据业务需求自己决定的,策划不需要感知事件系统的存在。

/// <summary>
/// 对话框 Clip 行为
/// 策划配置的是业务参数(对话 ID、NPC 名);
/// 程序员在生命周期函数里决定要不要发事件、发什么事件
/// </summary>
[Serializable]
public class DialogClipBehaviour : ClipBehaviour
{
    /// <summary>
    /// 对话 ID,策划在 Inspector 上配置
    /// </summary>
    public int dialogId;

    /// <summary>
    /// NPC 名称,用于面板显示
    /// </summary>
    public string npcName;

    /// <summary>
    /// 当前状态:0=未开始,1=已打开面板,2=已关闭面板,3=已销毁
    /// 用于防止重复触发和处理边界情况
    /// </summary>
    private int state = 0;

    /// <summary>
    /// Clip 首次进入(只触发一次),对话轨道不需要,留空
    /// </summary>
    public override void OnClipFirstEnter() { }

    /// <summary>
    /// Clip 进入时,通过事件中心通知 UI 系统打开对话面板
    /// </summary>
    public override void OnClipEnter(Playable playable, FrameData info, double time)
    {
        if (state == 0 || state == 2)
        {
            state = 1;
            CutsceneEventHub.Instance.Publish(new CutsceneOpenPanelEvent
            {
                PanelName = "DialogPanel",
                Args = new[] { dialogId.ToString(), npcName }
            });
        }
    }

    /// <summary>
    /// 每帧回调,对话轨道不需要帧更新,留空
    /// 其他轨道(如幕帘轨道)会在这里根据 rate 做淡入淡出插值
    /// </summary>
    public override void OnClipUpdate(Playable playable, FrameData info, double rate) { }

    /// <summary>
    /// Clip 退出时,通知 UI 系统关闭对话面板
    /// </summary>
    public override void OnClipExit(Playable playable, FrameData info)
    {
        if (state == 1)
        {
            state = 2;
            CutsceneEventHub.Instance.Publish(new CutsceneClosePanelEvent
            {
                PanelName = "DialogPanel"
            });
        }
    }

    /// <summary>
    /// 时间跳跃时回调,对话轨道可以在这里根据跳转后的时间重新定位对话内容
    /// </summary>
    public override void OnClipSeek(Playable playable, FrameData info, double time) { }

    /// <summary>
    /// Playable 销毁时兜底:
    /// 如果面板已打开但还没关(过场被强制结束),补发关闭事件
    /// </summary>
    public override void OnClipDestroy()
    {
        if (state == 1)
        {
            CutsceneEventHub.Instance.Publish(new CutsceneClosePanelEvent
            {
                PanelName = "DialogPanel"
            });
        }
        state = 3;
    }
}

Clip 资产、Mixer、Track 三个类基本不需要写业务逻辑:

/// <summary>
/// 对话框 Clip 资产,泛型参数指定使用 DialogClipBehaviour
/// </summary>
[Serializable]
public class DialogClip : BaseClip<DialogClipBehaviour> { }

/// <summary>
/// 对话框 Mixer,这条轨道不需要 Track 级混合逻辑,空继承即可
/// </summary>
public class DialogMixer : MixerBehaviour<DialogClipBehaviour> { }

/// <summary>
/// 对话框轨道
/// TrackBindingType:绑定 GameObject,编辑器里会出现绑定槽,ClipBehaviour 通过 BindGo 拿到
/// TrackClipType:这条轨道能放 DialogClip 类型的 Clip
/// TrackColor:编辑器里这条轨道显示的颜色
/// </summary>
[TrackBindingType(typeof(GameObject))]
[TrackClipType(typeof(DialogClip))]
[TrackColor(0.2f, 0.6f, 0.9f)]
public class DialogTrack : BaseTrack<DialogMixer, DialogClipBehaviour> { }

[TrackBindingType(typeof(GameObject))] 打在 Track 类上,告诉 Timeline 这条轨道需要绑定一个 GameObject。Timeline 编辑器里这条轨道右边会出现一个绑定槽。实际项目中不需要策划手动拖——后面会讲到的过场编辑核心(CutsceneEditCore)在创建轨道时会自动调 Director.SetGenericBinding() 把对应的对象绑上去。ClipBehaviour 运行时通过 BindGo(内部调的就是 director.GetGenericBinding(TheTrack))拿到绑定的对象。

外部回调接口

自定义轨道的 ClipBehaviour 在 OnClipEnter / OnClipExit 时需要通知外部系统——弹 UI、播音效、切场景等等。如果直接在 ClipBehaviour 里 FindObjectOfType<XXXManager>() 然后硬调方法,过场系统就和业务逻辑耦死了。

一种常见做法是字符串回调:注册一堆 Action<string>,ClipBehaviour 触发时传个 key 字符串进去,外部按 key 解析执行。这种方式在需要跨语言交互(比如 C# 发事件给 Lua 层)的项目里比较常见,因为 Lua 那边本来就是按字符串做分发的。但如果整个系统都在 C# 侧,字符串方案有几个不舒服的地方:没有编译期检查、不同事件的参数全靠约定、重构时 IDE 帮不上忙。

更干净的做法是搞一个强类型的事件中心——事件用 struct 定义,订阅/发布按类型分发,不经过任何字符串。整体结构参考经典的 EventBus 模式:

基础类型

/// <summary>
/// 事件标记接口,所有事件 struct 的根接口
/// </summary>
public interface IEvent { }

/// <summary>
/// 过场事件领域接口,标识属于过场系统的事件
/// </summary>
public interface ICutsceneEvent : IEvent { }

/// <summary>
/// 事件回调委托
/// in 只读传参避免 struct 值拷贝,保证事件数据不可修改
/// </summary>
public delegate void EventHandler<TEvent>(in TEvent eventData)
    where TEvent : struct, IEvent;

/// <summary>
/// 订阅句柄(值类型),用于退订时 O(1) 定位订阅记录
/// 内部包含槽位下标 + 版本号,防止旧句柄误退订
/// </summary>
public readonly struct SubscriptionHandle
{
    internal readonly int Index;
    internal readonly int Version;
    public bool IsValid => Index >= 0;

    internal SubscriptionHandle(int index, int version)
    {
        Index = index;
        Version = version;
    }
}

事件中心

EventHub 内部按事件类型维护一张 Dictionary<Type, EventBus>,每个 EventBus<TEvent> 管理对应类型的订阅列表。发布时直接拿到对应 Bus 遍历回调,0GC;退订用句柄打墓碑,O(1)。这里只展示对外 API,内部实现(墓碑压缩、发布期安全新增/退订等)属于通用事件框架的范畴,不在过场系统里展开:

/// <summary>
/// 过场事件中心(单例)
/// 基于 EventBus 模式,按事件类型分发,强类型、0GC
/// </summary>
public class CutsceneEventHub
{
    /// <summary>
    /// 全局单例
    /// </summary>
    public static CutsceneEventHub Instance => _instance ??= new CutsceneEventHub();
    private static CutsceneEventHub _instance;

    /// <summary>
    /// 事件总线表:每个事件类型对应一个 EventBus 实例
    /// </summary>
    private readonly Dictionary<Type, IEventBus> _busDict = new Dictionary<Type, IEventBus>();

    /// <summary>
    /// 订阅事件,返回句柄用于退订
    /// </summary>
    public SubscriptionHandle Subscribe<TEvent>(EventHandler<TEvent> handler)
        where TEvent : struct, ICutsceneEvent
    {
        return GetOrCreateBus<TEvent>().Subscribe(handler);
    }

    /// <summary>
    /// 退订事件,传入订阅时返回的句柄
    /// </summary>
    public bool Unsubscribe<TEvent>(in SubscriptionHandle handle)
        where TEvent : struct, ICutsceneEvent
    {
        return TryGetBus<TEvent>(out IEventBus<TEvent> bus) && bus.Unsubscribe(in handle);
    }

    /// <summary>
    /// 发布事件,派发给所有当前存活的订阅者
    /// in 传参避免 struct 拷贝
    /// </summary>
    public void Publish<TEvent>(in TEvent eventData)
        where TEvent : struct, ICutsceneEvent
    {
        if (TryGetBus<TEvent>(out IEventBus<TEvent> bus))
        {
            bus.Publish(in eventData);
        }
    }

    /// <summary>
    /// 清空所有事件订阅(过场结束时调用)
    /// </summary>
    public void ClearAll()
    {
        foreach (IEventBus bus in _busDict.Values) bus.Clear();
        _busDict.Clear();
    }

    private IEventBus<TEvent> GetOrCreateBus<TEvent>() where TEvent : struct, ICutsceneEvent { /* ... */ }
    private bool TryGetBus<TEvent>(out IEventBus<TEvent> bus) where TEvent : struct, ICutsceneEvent { /* ... */ }
}

过场事件定义

每个业务事件是一个 struct,字段就是它携带的数据。需要什么事件就定义什么 struct,不用改 EventHub 本身的代码:

/// <summary>
/// 打开 UI 面板事件
/// </summary>
public struct CutsceneOpenPanelEvent : ICutsceneEvent
{
    /// <summary>
    /// 面板名称
    /// </summary>
    public string PanelName;

    /// <summary>
    /// 传给面板的参数列表
    /// </summary>
    public string[] Args;
}

/// <summary>
/// 关闭 UI 面板事件
/// </summary>
public struct CutsceneClosePanelEvent : ICutsceneEvent
{
    /// <summary>
    /// 面板名称
    /// </summary>
    public string PanelName;
}

/// <summary>
/// 播放音效事件
/// </summary>
public struct CutscenePlaySfxEvent : ICutsceneEvent
{
    /// <summary>
    /// 音效资源 ID
    /// </summary>
    public int SfxId;
}

/// <summary>
/// 相机震动事件
/// </summary>
public struct CutsceneCameraShakeEvent : ICutsceneEvent
{
    /// <summary>
    /// 震动强度
    /// </summary>
    public float Intensity;

    /// <summary>
    /// 震动持续时间(秒)
    /// </summary>
    public float Duration;
}

不同的轨道根据自己的业务需求,在生命周期函数里发布对应类型的事件。策划不需要知道事件系统的存在,他们只关心 Inspector 上暴露的业务参数(对话 ID、特效路径、音效 ID 等)。

使用示例

外部系统启动时按类型订阅,过场轨道触发时发布,两边只共享 struct 定义,完全解耦:

// UI 系统启动时订阅面板相关事件
_openHandle = CutsceneEventHub.Instance.Subscribe<CutsceneOpenPanelEvent>(OnOpenPanel);
_closeHandle = CutsceneEventHub.Instance.Subscribe<CutsceneClosePanelEvent>(OnClosePanel);

private void OnOpenPanel(in CutsceneOpenPanelEvent evt)
{
    UIManager.Instance.Open(evt.PanelName, evt.Args);
}

private void OnClosePanel(in CutsceneClosePanelEvent evt)
{
    UIManager.Instance.Close(evt.PanelName);
}

// 不再需要时退订
CutsceneEventHub.Instance.Unsubscribe<CutsceneOpenPanelEvent>(in _openHandle);
CutsceneEventHub.Instance.Unsubscribe<CutsceneClosePanelEvent>(in _closeHandle);

这套方案的好处:

  • 编译期安全:事件类型写错了编译就报错,不像字符串方案要跑起来才能发现问题
  • 参数明确:每个事件 struct 的字段就是它携带的数据,不需要靠约定去解析 string[]
  • 0GC 发布:struct + in 只读引用传参,发布路径不产生堆分配
  • 扩展方便:新增一种过场事件 = 定义一个新 struct,不用动 EventHub 本身

笔者项目中已实现的轨道类型一览

基于四件套,可以很快扩展出各种轨道:

轨道 功能 ClipBehaviour 关键逻辑
对话框轨道 弹出/关闭对话 UI OnClipEnter → 通过事件中心发布打开对话面板事件,传入对话 ID、NPC 名、对话内容;支持分支选择(Choice Clip)
字幕轨道 显示/隐藏字幕 OnClipEnter → 打开字幕面板;OnClipUpdate → 更新内容
幕帘轨道 淡入淡出黑幕 OnClipUpdate → 根据 rate 计算 alpha,更新幕帘面板
特效关键帧轨道 在指定时间点生成/销毁特效 OnClipEnter → 实例化特效;OnClipExit → 销毁
音频轨道(Wwise) 播放/停止 Wwise 事件 OnClipEnter → PostEvent;OnClipExit → StopEvent
相机噪声轨道 控制 Cinemachine 噪声 MixerBehaviour 做权重混合,按权重 Blend amplitudeGain / frequencyGain
相机优先级轨道 控制虚拟相机优先级 OnClipEnter → 缓存原优先级并设新值;OnClipExit → 恢复
层级控制轨道 切换 GameObject 的 Layer OnClipEnter → 缓存原 Layer 并设新值;OnClipExit → 恢复
动画状态轨道 播放指定动画状态 OnClipEnterAnimator.Play(stateName)OnClipExit → 停止
通用事件轨道 触发任意外部事件 程序员根据业务需求在生命周期函数里通过事件中心发布对应事件

参数化 Signal 扩展

原生 Signal 回顾

Signal机制在 Timeline 源码浅析 的 MarkerTrack 与 Marker部分 里详细讲过 Signal 机制,这里简单回顾一下涉及的三个角色:

  • **SignalAsset**:ScriptableObject 资产,本身没有逻辑,就是一个”事件类型标识”。比如建一个叫 “OnDialogStart” 的 SignalAsset,代表”对话开始”这个类型
  • **SignalEmitter**:放在 MarkerTrack 上,标记一个时间点。继承 Marker,同时实现 INotification 接口,建图时会被编译成 TimeNotificationBehaviour 通知节点。每个 Emitter 引用一个 SignalAsset,表示”在这个时间点发出这个类型的信号”
  • SignalReceiver:MonoBehaviour 组件,实现 INotificationReceiver 接口,绑定到 MarkerTrack 上——MarkerTrack 跟其他轨道一样会产生 PlayableBinding,出现在 Director 的 Bindings 面板上(顶层 MarkerTrack 的绑定类型固定是 Director 所在的 GameObject)。内部维护一张 SignalAsset → UnityEvent 映射字典,在 Inspector 上配好”收到哪个 Signal 就触发哪个回调”

流程如下:

sequenceDiagram
    participant MT as MarkerTrack
    participant SE as SignalEmitter
    participant PD as PlayableDirector
    participant SR as SignalReceiver(绑定在 MarkerTrack 上)
    participant GO as 业务脚本

    MT->>SE: 播放到信号时间点
    SE->>PD: 通过 INotification 发出通知
    PD->>SR: 通过 Track 绑定找到 SignalReceiver,调用 OnNotify
    SR->>SR: 用 SignalAsset 查映射字典
    SR->>GO: 找到匹配项,触发对应的 UnityEvent

这套机制能用,但有个不方便的地方:SignalEmitter 只带一个 SignalAsset 引用,没有数据字段。触发时接收端只知道”哪个 Signal 触发了”,不知道具体要传什么数据。比如想在过场某个时间点触发一句对话,Signal 上没地方填对话内容,只能靠接收端写死或者另想办法传参。

泛型扩展

思路很直接:继承 SignalEmitter 加一个泛型字段带数据,接收端也对应继承,OnNotify 里用 is 做类型匹配取出参数。先看整体类关系:

classDiagram
    class SignalEmitter {
        <>
        +SignalAsset asset
    }

    class SignalEmitterWithParam~T~ {
        <<自定义泛型发射器基类>>
        +T parameter -- 信号携带的参数值,在编辑器中设置
    }

    class SignalEmitterWithString {
        <>
    }

    class SignalEmitterWithFloat {
        <>
    }

    class INotificationReceiver {
        <>
        +OnNotify(Playable, INotification, object)
    }

    class ParameterizedEvent~T~ {
        <<参数化 UnityEvent>>
    }

    class SignalAssetEventPair~T~ {
        <<信号-事件配对记录>>
        +SignalAsset signalAsset -- 对应的信号资源
        +ParameterizedEvent~T~ events -- 触发时执行的事件
    }

    class BaseSignalReceiverWithParam~T~ {
        <<自定义泛型接收器基类>>
        +SignalAssetEventPair~T~[] signalAssetEventPairs -- 信号-事件配对表
        +OnNotify(Playable, INotification, object) -- 类型匹配后分发
        +DoHandle(string signalName, T param) -- 子类可重写的处理函数
    }

    class SignalReceiverWithString {
        <>
        +DoHandle(string, string)
    }

    class SignalReceiverWithFloat {
        <>
        +DoHandle(string, float)
    }

    SignalEmitter <|-- SignalEmitterWithParam~T~ : 继承
    SignalEmitterWithParam~T~ <|-- SignalEmitterWithString : 继承
    SignalEmitterWithParam~T~ <|-- SignalEmitterWithFloat : 继承
    INotificationReceiver <|.. BaseSignalReceiverWithParam~T~ : 实现
    BaseSignalReceiverWithParam~T~ <|-- SignalReceiverWithString : 继承
    BaseSignalReceiverWithParam~T~ <|-- SignalReceiverWithFloat : 继承
    BaseSignalReceiverWithParam~T~ --> SignalAssetEventPair~T~ : 持有数组
    SignalAssetEventPair~T~ --> ParameterizedEvent~T~ : 持有
    UnityEvent~string,T~ <|-- ParameterizedEvent~T~ : 继承

发射端

发射器就是在 SignalEmitter 上加一个泛型字段,代码量很少:

/// <summary>
/// 带参数的信号发射器基类(泛型版本)
/// 继承自 Unity 的 SignalEmitter,在原生信号基础上增加一个泛型参数字段
/// 泛型参数 T 决定了信号可以携带的数据类型
/// </summary>
[Serializable]
public class SignalEmitterWithParam<T> : SignalEmitter
{
    /// <summary>
    /// 信号携带的参数值
    /// 在 Timeline 编辑器中设置,运行时传递给对应的接收器
    /// </summary>
    public T parameter;
}

/// <summary>
/// 字符串参数信号发射器
/// 用于在 Timeline 中传递文本信息,如对话内容、事件名称等
/// </summary>
[Serializable]
public class SignalEmitterWithString : SignalEmitterWithParam<string> { }

/// <summary>
/// 浮点参数信号发射器
/// 用于在 Timeline 中传递数值信息,如音量、速度等
/// </summary>
[Serializable]
public class SignalEmitterWithFloat : SignalEmitterWithParam<float> { }

接收端

接收端要做的事情跟原生 SignalReceiver 类似——维护一张 SignalAsset → 回调 的映射表,区别在于回调要多带一个泛型参数。拆成三个类:

  • **ParameterizedEvent<T>**:继承 UnityEvent<string, T>,让 UnityEvent 能携带信号名和参数两个值
  • **SignalAssetEventPair<T>**:一条配对记录——“收到这个 SignalAsset 就触发这个 ParameterizedEvent<T>
  • **BaseSignalReceiverWithParam<T>**:接收器基类,持有配对表数组,实现 INotificationReceiver,在 OnNotify 里做类型匹配和分发
/// <summary>
/// 参数化 UnityEvent
/// 参数1:信号名称(string),用于区分同类型下不同信号
/// 参数2:信号参数(T),发射器上配的实际数据
/// </summary>
[Serializable]
public class ParameterizedEvent<T> : UnityEvent<string, T> { }

/// <summary>
/// 信号-事件配对记录
/// 跟原生 SignalReceiver 里的 SignalAsset→UnityEvent 映射思路一样
/// 在 Inspector 上配一条 = "收到这个 SignalAsset 时,触发这个 UnityEvent"
/// </summary>
[Serializable]
public class SignalAssetEventPair<T>
{
    /// <summary>
    /// 要匹配的 SignalAsset(Timeline 上的信号资源标识)
    /// </summary>
    public SignalAsset signalAsset;

    /// <summary>
    /// 匹配成功时触发的事件,会传入信号名和参数值
    /// </summary>
    public ParameterizedEvent<T> events;
}

/// <summary>
/// 带参数的信号接收器基类(泛型版本)
/// 挂在 Director 所在的 GameObject 上,绑定到 MarkerTrack
/// 需要和 SignalEmitterWithParam 的 T 类型一致才能收到通知
/// </summary>
public class BaseSignalReceiverWithParam<T> : MonoBehaviour, INotificationReceiver
{
    /// <summary>
    /// 信号-事件配对表
    /// Inspector 上手动配:设数组大小,每条拖入一个 SignalAsset,
    /// 再在 events 的 UnityEvent 槽位上绑要调的方法
    /// </summary>
    public SignalAssetEventPair<T>[] signalAssetEventPairs;

    /// <summary>
    /// INotificationReceiver 接口实现
    /// Timeline 播到信号时间点时 Director 自动调
    /// </summary>
    public void OnNotify(Playable origin, INotification notification, object context)
    {
        // 只处理 SignalEmitterWithParam<T> 类型的通知
        // T 不匹配的(比如发射端是 float、接收端是 string)直接跳过
        if (notification is SignalEmitterWithParam<T> emitter)
        {
            // 遍历配对表,找 SignalAsset 匹配的那条
            foreach (SignalAssetEventPair<T> pair in signalAssetEventPairs)
            {
                if (pair.signalAsset == emitter.asset)
                {
                    // 触发 UnityEvent,传入信号名 + 参数值
                    // Inspector 上绑了什么方法这里就会调到什么(比如下面的 DoHandle)
                    pair.events.Invoke(emitter.asset.name, emitter.parameter);
                }
            }
        }
    }

    /// <summary>
    /// 默认处理函数,子类重写加业务逻辑
    /// 注意这个方法不是 OnNotify 直接调的——
    /// 它是给 Inspector 上的 UnityEvent 当绑定目标用的:
    /// 把 Receiver 自身拖进 events 槽位、选 DoHandle,
    /// 这样 events.Invoke() 的时候就会走到这里
    /// </summary>
    public virtual void DoHandle(string signalName, T param) { }
}

还有个细节:Unity 不能直接序列化泛型 MonoBehaviour,BaseSignalReceiverWithParam<T> 作为泛型类没法挂到 GameObject 上,Inspector 也不会显示字段。所以每种参数类型得写一个非泛型的具体子类,内容可以很少:

/// <summary>
/// 字符串信号接收器
/// 非泛型子类,挂到 GameObject 后 Inspector 能正常显示配对表
/// </summary>
public class SignalReceiverWithString : BaseSignalReceiverWithParam<string>
{
    public override void DoHandle(string signalName, string param)
    {
        base.DoHandle(signalName, param);
        // 根据 signalName 和 param 执行业务逻辑
    }
}

/// <summary>
/// 浮点数信号接收器
/// </summary>
public class SignalReceiverWithFloat : BaseSignalReceiverWithParam<float>
{
    public override void DoHandle(string signalName, float param)
    {
        base.DoHandle(signalName, param);
    }
}

编辑器操作流程

配置分发射端和接收端两步:

发射端(在 Timeline 编辑器里):

  1. 在 MarkerTrack 上添加发射器(比如 SignalEmitterWithString),设定触发时间点
  2. Inspector 上选一个 SignalAsset(标识信号类型),填 parameter 参数值(要传的数据)

接收端(在场景 Inspector 里):

  1. 在 Director 所在的 GameObject 上 Add Component 挂接收器(比如 SignalReceiverWithString
  2. 展开 signalAssetEventPairs 数组,添加一条记录
  3. 把发射端用的同一个 SignalAsset 拖进 signalAsset 槽位,这样接收端才知道要匹配哪个信号
  4. events 的 UnityEvent 槽位上绑定处理方法——把 Receiver 自身拖进去选 DoHandle,或者绑到其他组件的方法上都行

运行时调用链:Timeline 播到信号时间点 → Director 调 OnNotify → 类型匹配 + SignalAsset 匹配 → events.Invoke() → 走到 Inspector 上绑的方法。

小结

整个扩展就做了一件事:SignalEmitter 不带数据 → 继承加一个泛型字段 → 接收端 is 类型匹配取出参数。原生的 MarkerTrack、SignalAssetINotification 通知链路全部复用,只是多了一层参数传递。

不过参数化 Signal 是一种可选方案。如果项目里已经有了前面讲的自定义轨道体系(ClipBehaviour + 事件中心),大部分”在某个时间点触发外部逻辑”的需求都可以用 OnClipEnter / OnClipExit 来做,不一定非要走 Signal。两者定位不同——Signal 适合标记一个瞬时事件点,Clip 代表的是一段时间区间,按需选用即可。


5.5 SubCutscene:子过场管理

为什么要拆

一段完整过场可能跑好几分钟,几十条轨道。全塞在一条 Timeline 里,做着做着就会碰到几个实际问题:

  • 轨道一多 Timeline 编辑器就开始卡,拖拽和预览都不流畅
  • 多人协作时大家改的是同一个 .playable 文件,SVN / Git 合并冲突频率非常高
  • 想做对话分支跳转(”选 A 走第 3 段,选 B 走第 5 段”),单条 Timeline 根本没法处理

优化思路:把一段长过场拆成多个短 Timeline,每段就是一个 SubCutscene,再由主 Timeline 统一串起来。

SubCutsceneTrack:对 ControlTrack 的扩展

前面 Timeline 源码浅析里讲过 ControlTrack——它的 ControlClip 引用一个带 PlayableDirector 的子 GameObject,Clip 时间窗口内自动激活子 Director 播放。SubCutscene 就是利用这个原生机制来做的。

不过项目里没有直接用原生的 ControlTrack / ControlPlayableAsset,而是各继承了一层,接入项目统一的 ITrack / IClip 接口体系。下面伪代码统一叫 SubCutsceneTrack / SubCutsceneClip(项目里可能会是叫 ControlExtendTrack / ControlExtendClip,或者可能有些糟糕的命名,比如Beat,但是含义完全一样,理解即可)。

先看类关系:

classDiagram
    class ControlTrack {
        <>
        +CreateTrackMixer() 创建 Mixer Playable
    }

    class ControlPlayableAsset {
        <>
        +ExposedReference~GameObject~ sourceGameObject -- 引用子 Director 所在的 GameObject
        +CreatePlayable() 创建控制子 Director 的 Playable
    }

    class ITrack {
        <>
        +GetDirector() PlayableDirector
        +SetTrackInShot() bool
    }

    class IClip {
        <>
        +InitClip(TimelineClip)
        +SetTrack(TrackAsset)
    }

    class SubCutsceneTrack {
        <<自定义:子过场轨道>>
        -PlayableDirector director -- 缓存的 Director 引用
        +GatherProperties() 重写:缓存 Director 引用
    }

    class SubCutsceneClip {
        <<自定义:子过场 Clip>>
        +SubCutsceneBehaviour temp -- Behaviour 模板
        +CreatePlayable() 重写:在原生 Playable 外包一层 Behaviour,支持时间传递
    }

    class SubCutsceneBehaviour {
        <<自定义:子过场 Playable 行为>>
    }

    ControlTrack <|-- SubCutsceneTrack
    ITrack <|.. SubCutsceneTrack
    ControlPlayableAsset <|-- SubCutsceneClip
    IClip <|.. SubCutsceneClip
    SubCutsceneClip --> SubCutsceneBehaviour

轨道实现:

/// <summary>
/// 扩展原生 ControlTrack,实现项目的 ITrack 接口
/// 功能上和 ControlTrack 完全一致,只是多了统一接口的适配
/// </summary>
[TrackColor(65/255f, 105/255f, 200/255f)]
[TrackClipType(typeof(SubCutsceneClip))]
public class SubCutsceneTrack : ControlTrack, ITrack
{
    /// <summary>
    /// 缓存的 PlayableDirector 引用,在 GatherProperties 时写入
    /// </summary>
    private PlayableDirector director;

    /// <summary>
    /// Unity 在收集属性时回调,借机缓存 Director 引用
    /// </summary>
    public override void GatherProperties(PlayableDirector director, IPropertyCollector driver)
    {
        this.director = director;
        base.GatherProperties(director, driver);
    }

    /// <summary>
    /// ITrack 接口实现:返回缓存的 Director
    /// </summary>
    PlayableDirector ITrack.GetDirector() => director;

    /// <summary>
    /// ITrack 接口实现:SubCutscene 轨道不参与镜头内判定
    /// </summary>
    bool ITrack.SetTrackInShot() => false;
}

Clip 实现:

/// <summary>
/// 扩展原生 ControlPlayableAsset
/// CreatePlayable 时在原生 Playable 外面包一层 SubCutsceneBehaviour,
/// 通过 SetPropagateSetTime 让主 Timeline 的时间正确传递到子 Director
/// </summary>
public class SubCutsceneClip : ControlPlayableAsset, IClip
{
    /// <summary>
    /// 自定义 Behaviour 模板,序列化在 Clip 资产上
    /// </summary>
    public SubCutsceneBehaviour temp = new SubCutsceneBehaviour();

    /// <summary>
    /// 在原生 ControlPlayableAsset 创建的 Playable 外面包一层自定义 Behaviour,
    /// 形成 wrapperPlayable → basePlayable 的父子结构,
    /// 保证主 Timeline 对时间的操作能正确透传到子 Director
    /// </summary>
    public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
    {
        // 调父类方法,拿到原生 ControlTrack 创建的 Playable
        // 这个 Playable 内部已经持有了对子 PlayableDirector 的控制逻辑
        Playable basePlayable = base.CreatePlayable(graph, go);

        // 创建一个自定义 Behaviour 节点作为包装层
        ScriptPlayable<SubCutsceneBehaviour> wrapperPlayable =
            ScriptPlayable<SubCutsceneBehaviour>.Create(graph, temp);

        // 把原生 Playable 作为输入连到包装层下面,权重 1.0 表示完全透传
        wrapperPlayable.AddInput(basePlayable, 0, 1.0f);

        // 关键:开启时间传播。
        // 默认情况下父节点 SetTime 不会同步到子节点,
        // 开启后主 Timeline 做 Seek(拖进度条、JumpToSubCutscene)时,
        // 时间变化会沿着 wrapperPlayable → basePlayable 一路传递到子 Director,
        // 子 Timeline 才能跳到正确的位置
        wrapperPlayable.SetPropagateSetTime(true);

        // 返回包装层作为这个 Clip 在 PlayableGraph 中的根节点
        return wrapperPlayable;
    }

    /// <summary>
    /// IClip 接口实现:初始化 Clip 时间信息
    /// </summary>
    public void InitClip(TimelineClip clip) {}

    /// <summary>
    /// IClip 接口实现:写入所属轨道引用
    /// </summary>
    public void SetTrack(TrackAsset track) {}
}

Behaviour 本身很简单,就是一个 PlayableBehaviour 占位节点,实际项目里没有额外逻辑:

/// <summary>
/// SubCutsceneClip 的包装层 Behaviour
/// 本身不包含业务逻辑,纯粹作为 PlayableGraph 中的父节点,
/// 配合 SetPropagateSetTime 实现时间透传
/// 如果后续需要在子过场层面做额外控制(如暂停、倍速),可以在这里扩展
/// </summary>
[Serializable]
public class SubCutsceneBehaviour : PlayableBehaviour
{
}

为什么要继承一层而不是直接用原生类型:

  1. 项目所有自定义轨道统一走 ITrack / IClip 接口,SubCutsceneTrack 继承 ControlTrack 的同时实现 ITrack,让 SubCutscene 轨道也能被编辑器 Core 统一管理
  2. SubCutsceneClip 在原生 Playable 外面套了一个 SubCutsceneBehaviour,调 SetPropagateSetTime(true) 保证主 Timeline Seek 时子 Director 的时间也跟着跳转

整体结构

graph TB
    subgraph master["主 Timeline — Tl_MyCutscene.playable"]
        CT["SubCutsceneTrack"]
        CT --> C1["[001]SubCutscene
0s ~ 30s"] CT --> C2["[002]SubCutscene
30s ~ 55s"] CT --> C3["[003]SubCutscene
55s ~ 80s"] end subgraph sub1["Tl_[001]SubCutscene.playable"] S1C[Camera Group] S1R[Role Group] S1U[UI Group — 对话 / 字幕] S1F[Effect Group] S1M[Music Group] end subgraph sub2["Tl_[002]SubCutscene.playable"] S2C[Camera Group] S2R[Role Group] S2U[UI Group — 对话 / 字幕] end C1 -.->|激活子 Director| sub1 C2 -.->|激活子 Director| sub2

Prefab 层级:

CutsceneRoot
├── Timeline/
│   └── MasterDirector → Tl_MyCutscene.playable
│       └── SubCutsceneTrack
│           ├── SubCutsceneClip [001]SubCutscene → 引用下方子 GameObject
│           ├── SubCutsceneClip [002]SubCutscene
│           └── ...
└── SubCutsceneGroup/
    ├── [001]SubCutscene (PlayableDirector → Tl_[001]SubCutscene.playable)
    ├── [002]SubCutscene (PlayableDirector → Tl_[002]SubCutscene.playable)
    └── ...

每个 SubCutscene 节点上挂自己的 PlayableDirector,引用独立的 TimelineAsset。子 TimelineAsset 有完整的轨道组(Camera / Role / Effect / UI / Music / Postprocess / Other),能力和主 Timeline 完全一致,该有的轨道类型都能放。

创建流程

编辑器里点”添加 SubCutscene”时,内部做了这些事:

/// <summary>
/// 添加一个新的 SubCutscene
/// 创建子 GameObject + PlayableDirector + TimelineAsset,
/// 然后在主 Timeline 的 SubCutsceneTrack 上建一个 SubCutsceneClip 指向它
/// </summary>
public void AddSubCutscene()
{
    // 1. 在 SubCutsceneGroup 下创建子节点
    string displayName = $"[{GetNextNumber():D3}]SubCutscene";
    GameObject subNode = new GameObject(displayName);
    subNode.transform.SetParent(subCutsceneGroup, false);

    // 2. 挂 PlayableDirector
    PlayableDirector subDirector = subNode.AddComponent<PlayableDirector>();

    // 3. 创建独立的 TimelineAsset 并关联
    TimelineAsset subTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
    AssetDatabase.CreateAsset(subTimeline, $"{cutsceneDir}/Tl_{displayName}.playable");
    subDirector.playableAsset = subTimeline;
    subDirector.playOnAwake = false;

    // 4. 在主 Timeline 的 SubCutsceneTrack 上建 SubCutsceneClip
    SubCutsceneTrack track = masterTimeline.GetOrCreateTrack<SubCutsceneTrack>("SubCutscene Track");
    TimelineClip timelineClip = track.CreateClip<SubCutsceneClip>();
    timelineClip.displayName = displayName;

    // 5. 通过 ExposedReference 绑定子节点
    SubCutsceneClip clipAsset = timelineClip.asset as SubCutsceneClip;
    clipAsset.sourceGameObject.exposedName = GUID.Generate().ToString();
    masterDirector.SetReferenceValue(clipAsset.sourceGameObject.exposedName, subNode);

    // 6. 给子 Timeline 初始化标准轨道组
    CreateInitTrack(subTimeline, subDirector);
}

CreateInitTrack 做的事情很直接——给新创建的子 Timeline 把标准轨道组建好,这样策划打开子 Timeline 就能直接往里面放 Clip,不用手动一个个建 Group:

/// <summary>
/// 给子 Timeline 初始化标准轨道组
/// 创建完 SubCutscene 后调用,保证子 Timeline 里有完整的 GroupTrack 分组
/// </summary>
private void CreateInitTrack(TimelineAsset timeline, PlayableDirector director)
{
    // 找到过场专用相机上的 CinemachineBrain,绑到子 Timeline 的 CinemachineTrack 上
    Camera cutsceneCamera = CutsceneRoot.transform
        .Find("Cutscene Camera").GetComponent<Camera>();
    CinemachineTrack cameraTrack = timeline
        .GetOrCreateTrack<CinemachineTrack>("Camera Track");
    director.SetGenericBinding(cameraTrack, cutsceneCamera.GetComponent<CinemachineBrain>());

    // 遍历预定义的轨道组名称列表,逐个创建 GroupTrack
    // 创建完后子 Timeline 里就有 Camera / Role / Effect / UI / Music / Postprocess / Other 这些分组
    string[] trackGroups = { "Camera Group", "Role Group", "Effect Group",
                             "UI Group", "Postprocess Group", "Music Group", "Other Group" };
    foreach (string groupName in trackGroups)
    {
        timeline.CreateTrack<GroupTrack>(null, groupName);
    }
}

第 5 步展开说一下,这里涉及到 ExposedReference(PlayableDirector 那篇详细分析过源码和用法,这里快速回顾一下核心思路)。

先回忆一个问题:Clip 的字段为什么不能直接引用场景对象? PlayableDirector 那篇讲过,Timeline 里轨道绑定能拖场景对象、但 Clip Inspector 上拖不进去,根本原因是——Clip / PlayableAsset 的字段最终序列化到 Project 里的 .playable 文件(剧本资产)上。剧本是资产,SubCutscene 的子节点是场景对象,资产直接引用场景对象在换场景、Prefab 化、资源迁移时都会断。Unity 的策略就是:资产字段不直接引用场景对象

SubCutsceneClip(继承自 ControlPlayableAsset)要引用场景里的子 GameObject 怎么办?它用的是 ExposedReference<GameObject> 做间接引用。用之前那篇的”剧本 / 导演 / 演员”比喻来说:

  • 剧本(SubCutsceneClip) 不记演员是谁,只写了一个角色编号(exposedName,就是一个 GUID 字符串)
  • 导演(PlayableDirector) 身上有一张”角色编号 → 演员”的对照表(Director 实现了 IExposedPropertyTable,内部维护 exposedName → Object 映射)
  • 编辑器创建时,调 SetReferenceValue(角色编号, 演员) 把对应关系写进导演的对照表
  • 运行时播放,ControlTrack 内部调 ExposedReference<T>.Resolve(resolver) 拿着角色编号去导演的表里查,拿回真正的 GameObject

对应到代码就是这两行:

// 给这个 SubCutsceneClip 生成一个唯一的角色编号(GUID),写进剧本资产
clipAsset.sourceGameObject.exposedName = GUID.Generate().ToString();

// 在导演(主 Director)的对照表里注册:这个角色编号 → subNode(场景里的子过场节点)
// 运行时 ControlTrack 内部拿这个编号调 Resolve,就能找回 subNode
masterDirector.SetReferenceValue(clipAsset.sourceGameObject.exposedName, subNode);

这样一来,SubCutsceneClip 这个资产里只存了一个 GUID 字符串,真正的场景对象引用全在 Director 身上管着。同一份 .playable 放到不同场景,只要 Director 上重新绑一遍就行,剧本本身不需要改。

编辑器体验

主 Timeline 上 SubCutsceneTrack 里会排着一组 SubCutsceneClip,每个对应一个 SubCutscene。双击某个 Clip,Timeline 窗口会进入子 Timeline 的编辑视图,顶部出现面包屑导航:

Tl_MyCutscene (Timeline)  >  Tl_[001]SubCutscene ([001]SubCutscene)

编辑完点面包屑就能回到主 Timeline。这个导航能力是 ControlTrack 原生就支持的,SubCutsceneTrack 继承后自动具备,不需要写额外的编辑器代码。


对话分支跳转

SubCutscene 的基础架构搭好了,来看一个实际需求:对话分支跳转

场景是这样的:过场播到某段对话,屏幕上弹出选项让玩家选择(比如”帮助 NPC”或”拒绝 NPC”),选完之后过场要跳到不同的后续段落继续播。如果是单条 Timeline,这个需求很难做——你没法在一条时间轴上表达”从这里开始走 A 路线,从那里开始走 B 路线”。但拆成 SubCutscene 之后就很自然了:每个段落是独立的子 Timeline,跳转就是切到不同的段落。

建立 SubCutscene 索引

要实现跳转,首先得知道每个 SubCutscene 在主 Timeline 上的时间位置。CutsceneRoot 里维护一份索引,启动时遍历 SubCutsceneTrack 上的所有 Clip 构建:

/// <summary>
/// SubCutscene 的运行时信息
/// </summary>
public class SubCutsceneInfo
{
    /// <summary>
    /// SubCutscene 名称,如 "[001]SubCutscene"
    /// </summary>
    public string name;

    /// <summary>
    /// 主 Timeline 上对应的 SubCutsceneClip
    /// </summary>
    public TimelineClip clip;

    /// <summary>
    /// 子 PlayableDirector
    /// </summary>
    public PlayableDirector director;

    /// <summary>
    /// SubCutsceneClip 在主 Timeline 上的起止时间
    /// </summary>
    public double startTime;
    public double endTime;

    /// <summary>
    /// 子 Timeline 内的对话 Clip 列表
    /// 跳到目标段落后可以直接定位到第几句对话
    /// </summary>
    public List<TimelineClip> dialogueClips;
}

说实话 dialogueClips 放在通用的 SubCutsceneInfo 里不是很干净——这是对话系统的业务数据,和 SubCutscene 本身的结构信息混在了一起。更合理的做法是 SubCutsceneInfo 只保留通用字段(name / clip / director / startTime / endTime),对话相关的数据由对话系统自己去子 Timeline 里查。实际项目里图省事直接塞进来了,跑起来没问题,但如果后续 SubCutscene 要服务更多业务场景(比如战斗演出、QTE),这个类就会不断膨胀。读者自己项目里可以按需决定要不要拆。

索引字典的管理可以单独做一个 SubCutsceneMgr,也可以直接放在 CutsceneRoot 里——项目规模不大的话放 CutsceneRoot 就够了。这里按放在 CutsceneRoot 里的方式写:

/// <summary>
/// SubCutscene 索引字典,key 为 SubCutscene 名称
/// CutsceneRoot 启动时遍历 SubCutsceneTrack 构建
/// </summary>
private Dictionary<string, SubCutsceneInfo> subCutsceneInfoDict
    = new Dictionary<string, SubCutsceneInfo>();

/// <summary>
/// 遍历主 Timeline 的 SubCutsceneTrack,为每个 SubCutscene 构建运行时索引
/// 在 CutsceneRoot.Awake() 中调用,保证播放前索引已就绪
/// </summary>
public void InitSubCutsceneInfo()
{
    subCutsceneInfoDict.Clear();

    TimelineAsset masterTimeline = GetMasterTimeline();
    if (masterTimeline == null) return;

    // 找到主 Timeline 上的 SubCutsceneTrack
    TrackAsset subCutsceneTrack = masterTimeline.FindTrack<SubCutsceneTrack>("SubCutscene Track");
    if (subCutsceneTrack == null) return;

    // 找到 SubCutsceneGroup 下所有子 PlayableDirector
    Transform subCutsceneGroup = transform.Find("SubCutsceneGroup");
    if (subCutsceneGroup == null) return;
    PlayableDirector[] directors = subCutsceneGroup.GetComponentsInChildren<PlayableDirector>(true);

    // 按时间顺序遍历 SubCutsceneTrack 上的每个 Clip
    foreach (TimelineClip clip in subCutsceneTrack.GetClips().OrderBy(c => c.start))
    {
        string clipName = clip.displayName;

        // 通过子 Timeline 资产名称(Tl_[001]SubCutscene)匹配对应的 PlayableDirector
        string timelineName = $"Tl_{clipName}";
        PlayableDirector subDirector = directors.FirstOrDefault(d =>
        {
            TimelineAsset asset = d.playableAsset as TimelineAsset;
            return asset != null && asset.name == timelineName;
        });

        if (subDirector == null) continue;

        SubCutsceneInfo info = new SubCutsceneInfo
        {
            name = clipName,
            clip = clip,
            director = subDirector,
            startTime = clip.start,
            endTime = clip.end
        };

        // 查找子 Timeline 里的对话轨道,有就收集对话 Clip
        TimelineAsset subTimeline = subDirector.playableAsset as TimelineAsset;
        TrackAsset dialogueTrack = subTimeline.FindTrack<TrackAsset>("Dialogue Track");
        if (dialogueTrack != null)
        {
            info.dialogueClips = dialogueTrack.GetClips()
                .OrderBy(c => c.start).ToList();
        }

        subCutsceneInfoDict[clipName] = info;
    }
}

跳转实现

有了索引字典,跳转就很简单——通过名称查到目标 SubCutscene 的起始时间,把主 Director 的时间设过去就行。底层 ControlTrack 的评估逻辑会根据当前时间自动判断哪个 SubCutsceneClip 在时间窗口内,激活对应的子 Director:

/// <summary>
/// 跳转到指定的 SubCutscene
/// 内部就是改主 Director 的时间,ControlTrack 会自动处理子 Director 的激活
/// </summary>
public bool JumpToSubCutscene(string targetName)
{
    if (!subCutsceneInfoDict.TryGetValue(targetName, out SubCutsceneInfo info))
        return false;

    masterDirector.time = info.startTime;
    masterDirector.Resume();
    return true;
}

不需要手动去停旧的子 Director、启新的子 Director——主 Director 的时间变了,ControlTrack 评估时发现当前时间落在另一个 SubCutsceneClip 的范围内,会自动完成子 Director 的切换。

举个具体的例子

假设过场里有这样一段剧情:NPC 请求玩家帮忙,玩家需要做出选择——“帮助 NPC”或”拒绝 NPC”,选完之后过场走不同的后续段落。

主 Timeline 上 SubCutsceneTrack 的布局大概长这样:

SubCutsceneTrack:
|-- [001]SubCutscene (0s~30s)   ← 剧情铺垫,NPC 发出请求
|-- [002]SubCutscene (30s~55s)  ← 选择 A:帮助 NPC 后的剧情
|-- [003]SubCutscene (55s~80s)  ← 选择 B:拒绝 NPC 后的剧情

[001]SubCutscene 的子 Timeline 里有一个对话轨道,上面放了一个 ChoiceDialogClip,策划在 Inspector 上配了两个选项:

  • 选项 A “帮助他” → 关联 [002]SubCutscene
  • 选项 B “拒绝他” → 关联 [003]SubCutscene

对话 Clip 的实现大致是这样的:

/// <summary>
/// 带选项的对话 Clip
/// 进入时弹出对话 UI,Clip 末尾暂停 Timeline 等玩家选择,
/// 选完后跳转到关联的 SubCutscene
/// </summary>
[Serializable]
public class ChoiceDialogClipBehaviour : ClipBehaviour
{
    /// <summary>
    /// 对话内容 ID
    /// </summary>
    public int dialogId;

    /// <summary>
    /// 选项列表,策划在 Inspector 上配置
    /// 每个选项关联一个 SubCutscene 名称
    /// </summary>
    public List<ChoiceOption> options = new List<ChoiceOption>();

    /// <summary>
    /// 是否已经暂停过,防止重复暂停
    /// </summary>
    private bool hasPaused = false;

    /// <summary>
    /// 进入 Clip 时通知 UI 显示对话和选项
    /// </summary>
    public override void OnClipEnter(Playable playable, FrameData info, double time)
    {
        CutsceneEventHub.Instance.Publish(new CutsceneOpenPanelEvent
        {
            PanelName = "ChoiceDialogPanel",
            Args = new[] { dialogId.ToString() }
        });
        // 选项数据和选择回调通过对话管理器传递,这里省略
    }

    /// <summary>
    /// 每帧检查:快到 Clip 末尾时暂停 Timeline,等玩家做选择
    /// </summary>
    public override void OnClipUpdate(Playable playable, FrameData info, double rate)
    {
        if (!hasPaused && rate > 0.95)
        {
            hasPaused = true;
            director.Pause();
        }
    }

    /// <summary>
    /// 玩家选完后的回调
    /// UI 系统调这个方法,传入玩家选中的选项
    /// </summary>
    private void OnPlayerChoice(ChoiceOption selected)
    {
        // 关闭对话面板
        CutsceneEventHub.Instance.Publish(new CutsceneClosePanelEvent
        {
            PanelName = "ChoiceDialogPanel"
        });

        // 跳转到选项关联的 SubCutscene
        cutsceneRoot.JumpToSubCutscene(selected.targetSubCutscene);
    }
}

/// <summary>
/// 选项数据,策划在 Inspector 上配置
/// </summary>
[Serializable]
public class ChoiceOption
{
    /// <summary>
    /// 选项显示文本
    /// </summary>
    public string text;

    /// <summary>
    /// 跳转目标 SubCutscene 名称,如 "[002]SubCutscene"
    /// </summary>
    public string targetSubCutscene;
}

策划在 Inspector 上配好对话 ID 和两个选项(每个选项填一个 SubCutscene 名称),程序这边的逻辑就是:进入 Clip → 通知 UI 弹面板 → 快到末尾时暂停 → 玩家点选项 → 回调里调 JumpToSubCutscene 跳转。

完整流程

整个链路串起来:

sequenceDiagram
    participant Clip as ChoiceDialogClip
([001]SubCutscene 内) participant Hub as CutsceneEventHub participant UI as 对话 UI participant Root as CutsceneRoot participant Dir as MasterDirector Clip->>Hub: OnClipEnter → Publish(OpenPanelEvent) Hub->>UI: 弹出对话面板,显示两个选项 Note over Clip: Clip 播到末尾,rate > 0.95 Clip->>Dir: director.Pause() 暂停主 Timeline UI->>UI: 玩家点击"拒绝他"(选项 B) UI->>Clip: onChoiceSelected 回调 Clip->>Hub: Publish(ClosePanelEvent) Hub->>UI: 关闭对话面板 Clip->>Root: JumpToSubCutscene("[003]SubCutscene") Root->>Dir: director.time = 55s Root->>Dir: director.Resume() Note over Dir: SubCutsceneTrack 评估时间 55s
落在 [003]SubCutscene 的范围内
自动激活对应的子 Director

走一遍:[001]SubCutscene 播到对话 Clip 时,OnClipEnter 通过事件中心通知 UI 弹出对话面板,显示”帮助他”和”拒绝他”两个按钮。Clip 播到末尾(rate > 0.95)时调 director.Pause() 把主 Timeline 暂停住,画面定格等玩家操作。玩家点了”拒绝他”,UI 调 onChoiceSelected 回调,回调里先关闭对话面板,然后调 cutsceneRoot.JumpToSubCutscene("[003]SubCutscene")。这个方法从索引字典里查到 [003]SubCutscene 的起始时间是 55s,把主 Director 的时间设到 55s 并 Resume。主 Timeline 恢复播放后 SubCutsceneTrack 评估当前时间,发现 55s 落在 [003]SubCutscene 的时间窗口内,自动激活它的子 Director,过场从”拒绝 NPC”的段落开始继续往下播。

拆分带来的好处

  • 文件独立:每个 SubCutscene 是单独的 .playable 文件,多人各编各的,SVN / Git 冲突少很多
  • 编辑流畅:一次只编辑一个子 Timeline 的轨道,不用加载整段过场的全部数据
  • 分支跳转简单:每个 SubCutscene 在主 Timeline 上有明确的时间窗口,跳转就是改 director.time
  • 可复用:同一个 SubCutscene 可以被多个过场引用(实际项目里不常这么做,但能力是有的)

director.Pause() 之后角色动画僵住了?

上面对话分支的流程里反复出现 director.Pause()。对话 clip 快播完的时候把 Timeline 暂停住,等玩家做选择。但这里有个坑——director.Pause() 调完之后,场景里所有角色会直接定在最后一帧的 pose 上不动了。NPC 嘴张着卡住,玩家角色手举着僵住,弹了个选择面板结果全场石化,等选完才恢复。

原因是 AnimationTrack 通过 Playable Graph 驱动 Animator,director.Pause() 让 Graph 停止求值,AnimationTrack 不再往 Animator 输出新的 pose,角色自然就定格了。

Playable Graph 与状态机的优先级

要解决这个问题,先得搞清楚 Animator 身上其实有两套东西在同时驱动它:

  • Playable Graph(来自 AnimationTrack):Timeline 建图时会创建 AnimationPlayableOutput 并绑定到 Animator 上,每帧把 clip 的 pose 推给 Animator
  • RuntimeAnimatorController(状态机):就是 Animator 窗口里连的那些 State 节点(Idle → Walk → Talk),Animator 自身每帧 Update 都在跑状态机逻辑

Graph 为什么能盖住状态机?这跟 Animator 每帧的求值流程有关。Animator 每帧会先走一遍状态机逻辑,算出状态机那边的 pose;接着再对挂在自己身上的 Playable Graph 做一次求值。如果 Graph 产出了结果(AnimationTrack 正在播 clip),这个结果会直接覆盖掉前面状态机算的那份,写入最终骨骼。换句话说,只要 Graph 在跑,状态机的输出就永远到不了骨骼——Animator 窗口里看得到 State 在跳,但那只是状态机在空转。

director.Pause() 之后 Graph 停了,不再产出 pose,覆盖也就不存在了。状态机算出来的结果直接写入骨骼——状态机重新接管了 Animator

那只要在暂停前用 Animator.Play() 把状态机切到 Idle,暂停后状态机接管,角色就能继续播 Idle 而不是僵在最后一帧。

让状态机跑起来的前提

光有思路还不够,得确保状态机在暂停期间真的能正常运行。这里有两件事要注意:

  • Animator 不能被 Culling 跳过更新。Unity 默认的 CullUpdateTransforms 模式下,角色不在相机视锥内时 Animator 整个更新会被跳过,状态机也就跟着停了。过场里相机经常对着别的方向(过肩、远景),所以需要在 ModelElement.OnPlayStart()(前面 CutsceneElement 生命周期里定义的回调)里把 cullingMode 设成 AlwaysAnimate,过场结束再还原。
  • 身体 Animator 的 runtimeAnimatorController 不能清。过场绑定阶段有些特殊部位(头部表情、耳朵等)的 Controller 会被置空,因为这些部位完全靠 AnimationTrack 驱动,不需要状态机。但身体的 Controller 必须保留,否则暂停时状态机没东西可跑。
public class ModelElement : BaseCutsceneElement
{
    /// <summary>
    /// 过场开始:设为 AlwaysAnimate,保证 Animator 不被视锥剔除
    /// </summary>
    public override void OnPlayStart()
    {
        if (bindGo != null && bindGo.GetComponent<Animator>() != null)
            bindGo.GetComponent<Animator>().cullingMode = AnimatorCullingMode.AlwaysAnimate;
    }

    /// <summary>
    /// 过场结束:恢复 CullUpdateTransforms
    /// </summary>
    public override void OnPlayComplete()
    {
        if (bindGo != null && bindGo.GetComponent<Animator>() != null)
            bindGo.GetComponent<Animator>().cullingMode = AnimatorCullingMode.CullUpdateTransforms;
    }
}

AnimStatePlayTrack:控制暂停后播什么动画

上面两步保证了状态机”能跑”,但暂停后它播什么?不干预的话状态机从 Entry 走默认 Transition,最终停在哪个 State 不可控。

AnimStatePlayTrack 就是干这个的。策划在子 Timeline 里放 AnimStatePlayClip,每个 clip 配 aniName(状态机里的 State 名)和 layer(Animator 层级),轨道上的 clip 不应该有重叠——这不是做动画混合的轨道,每个时间点只该有一个状态生效。

先看 Mixer。AnimStatePlayMixerBehaviour override 了 5.4 讲过的 OnSingleUpdate(基类 ProcessFrame 里对每个权重 > 0 的 clip 逐个调用的回调),内部用一个 playingBehaviour 记录当前在播的 clip,发现换了就停旧的、播新的:

public class AnimStatePlayMixerBehaviour : MixerBehaviour<AnimStatePlayBehaviour>
{
    /// <summary>
    /// 记录当前正在播的 clip,用来判断要不要切换
    /// </summary>
    private AnimStatePlayBehaviour playingBehaviour = null;

    /// <summary>
    /// 基类 ProcessFrame 对每个权重 > 0 的 clip 调一次
    /// 活跃 clip 变了就停旧播新
    /// </summary>
    protected override void OnSingleUpdate(ScriptPlayable<AnimStatePlayBehaviour> clipPlayable)
    {
        AnimStatePlayBehaviour behaviour = clipPlayable.GetBehaviour();

        if (behaviour == playingBehaviour)
            return;

        if (playingBehaviour != null)
            playingBehaviour.StopEvent();

        if (behaviour.PlayEvent())
            playingBehaviour = behaviour;
    }

    /// <summary>
    /// 轨道空白区域,没有活跃 clip:停掉还在播的
    /// </summary>
    protected override void OnSilenceFrame(Playable playable, FrameData info, object playerData)
    {
        if (playingBehaviour != null)
            playingBehaviour.StopEvent();
        playingBehaviour = null;
    }
}

再看 AnimStatePlayBehaviour,也就是每个 clip 的运行时逻辑。PlayEvent() 里直接 Animator.Play(aniName, layer, 0) 把状态机切到指定状态——这个调用走的是状态机通道,和 Playable Graph 完全无关:

public class AnimStatePlayBehaviour : ClipBehaviour
{
    /// <summary>
    /// Animator 状态名(如 "Idle"、"Talk"、"Listen1Loop")
    /// </summary>
    public string aniName;

    /// <summary>
    /// 播放在 Animator 的第几层
    /// </summary>
    public int layer;

    /// <summary>
    /// Mixer 在 OnSingleUpdate 里调用
    /// 直接通过 Animator.Play() 切换状态机,不走 Playable Graph
    /// </summary>
    public bool PlayEvent()
    {
        Transform animTrans = GetAnimTrans();
        if (animTrans == null) return false;

        Animator animator = animTrans.GetComponent<Animator>();
        if (animator != null)
            animator.Play(aniName, layer, 0);

        return true;
    }

    /// <summary>
    /// Mixer 在切换 clip 或进入空白区域时调用
    /// layer 0 和 1 不取消,只有 layer > 1 才 cancel
    /// </summary>
    public void StopEvent()
    {
        if (layer > 1)
        {
            animTrans.GetComponent<AnimatorWrapper>().CancelAnim(layer);
        }
    }
}

StopEvent()layer > 1 才 cancel。layer 0(全身基础层)和 layer 1 上通过 Animator.Play() 播出来的动画,clip 结束之后不会被停掉,会一直在状态机里跑。这就是为什么暂停时状态机上一定有动画在播。

编辑器里长什么样

下面是一个子 Timeline 在编辑器中的截图:

Timeline 窗口里每个角色下面都有一条 AnimStatePlayTrack 轨道,上面排了一系列 AnimStatePlayClip。右侧 Inspector 面板里选中的 clip 配的是 Ani Name: IdleLayer: 0,放在子 Timeline 靠后的位置,覆盖对话暂停点附近。

策划编排子 Timeline 时,会按角色的表演节奏在 AnimStatePlayTrack 上依次放不同的动画状态。比如某个 NPC 的 layer 0 轨道上大概是这个顺序:

时间段 aniName 说明
前段 social_talk NPC 说话
中段 Idle 说完等一下
后段(暂停点附近) Idle 暂停后持续播的动画

主角那条轨道类似,layer 0 放 Idle,layer 2(上半身叠加层)可以额外叠 Listen1Loop 之类的反应动画。不过 layer 2 的 clip 结束时会被 StopEvent() cancel(layer > 1),只有 layer 0 的 Idle 留着。

串起来看

对话 clip 快播完 → OnClipUpdaterate > 0.95 触发 director.Pause() → Graph 停止求值,AnimationTrack 不再输出 pose → 但 Animator 状态机已经被最后那个 AnimStatePlayClip 切到了 Idle,AlwaysAnimate 保证 Animator 每帧继续 Update → Idle 在状态机里循环播放,角色不会僵住 → 玩家选完,director.Resume(),Graph 恢复求值,AnimationTrack 重新接管 Animator,过场继续。

sequenceDiagram
    participant Track as AnimStatePlayTrack
    participant SM as Animator 状态机
    participant Graph as Playable Graph
(AnimationTrack) participant Anim as Animator 组件 Note over Anim: 过场开始
ModelElement.OnPlayStart():cullingMode = AlwaysAnimate
身体的 runtimeAnimatorController 保留 rect rgb(220,240,255) Note over Graph,Anim: Timeline 正常播放中 Graph->>Anim: AnimationTrack 每帧输出 pose(优先级高) Track->>SM: Animator.Play("social_talk", 0, 0) Note over SM: 状态机跑着 social_talk,但被 Graph 压制 end Track->>SM: Animator.Play("Idle", 0, 0) Note over SM: 状态机切到 Idle(暂停点附近最后一个 clip) rect rgb(255,235,220) Note over Graph,Anim: 对话 clip 快结束 → director.Pause() Graph--xAnim: Graph 停止求值,不再输出 pose SM->>Anim: 状态机接管,继续播 Idle Note over Anim: 角色正常播 Idle,没有僵住 end rect rgb(220,255,220) Note over Graph,Anim: 玩家选完 → director.Resume() Graph->>Anim: Graph 恢复求值,重新接管 Animator end

5.6 编辑器设计思路

CutsceneRoot、CutsceneElement、自定义轨道这些都属于运行时代码,策划不可能直接去手配 Timeline 轨道和 Prefab 层级。实际工作中,过场动画是策划来制作的,程序提供的是一个编辑器工具——把底层的 Timeline 操作和 Element 管理全部封起来,策划在编辑器里点按钮、选资源、拖时间轴就能把过场拼出来。

技术上,这个编辑器就是用 Unity 的 EditorWindow + IMGUI 写的。主窗口类 CutsceneEditWindow 继承自 EditorWindow,在 OnGUI() 里按当前页面类型调对应 Page 的 DrawGUI() 来绘制界面。整体分两个页面(HomePage / EditPage),所有对 Timeline 和 Element 的操作统一走一个编辑核心 CutsceneEditCore

整体架构

graph TB
    subgraph CutsceneEditWindow["CutsceneEditWindow(主窗口)"]
        HP["HomePage
过场列表"] EP["EditPage
过场编辑"] end HP -->|"双击过场 / 点击进入"| EP EP -->|"保存 & 退出"| HP EP --> TopBar["顶栏
过场名称 · 基础信息 · 保存/退出"] EP --> PanelMenu["左侧功能面板"] EP --> Content["右侧内容区"] PanelMenu --> Z1["演员"] PanelMenu --> Z2["镜头"] PanelMenu --> Z3["特效"] PanelMenu --> Z4["UI"] PanelMenu --> Z5["声音"] PanelMenu --> Z6["SubCutscene"] PanelMenu --> Z7["后处理"] PanelMenu --> Z8["其他"] PanelMenu --> Z9["设置"] Z1 & Z2 & Z3 & Z4 & Z5 & Z6 & Z7 & Z8 & Z9 -->|"统一调用"| Core["CutsceneEditCore
编辑核心"] Core --> TLE["Timeline 扩展方法
查找/创建 Track、Clip"] Core --> ElemOp["CutsceneElement 创建
挂组件、设资源路径"] Core --> Bind["Director 绑定
SetGenericBinding / SetReferenceValue"]

核心思路就一句话:功能面板负责画 UI、收集用户输入,然后调 Core;Core 去操作 Timeline API 和 CutsceneElement。功能面板不直接碰 TimelineAsset,所有底层操作都在 Core 里完成。

代码组织:类图

上面的流程图展示的是”谁调谁”的关系,下面用类图来看代码层面是怎么组织的:

classDiagram
    class EditorWindow {
        <>
        +OnGUI()* 每帧绘制 IMGUI
    }

    class CutsceneEditWindow {
        <<主窗口,继承 EditorWindow>>
        +CutsceneEditWindow Instance 单例
        -HomePage homePage 首页
        -EditPage editPage 编辑页
        -CutsceneEditCore _core 编辑核心
        +EPageType curPageType 当前页面类型
        +ShowWindow() 菜单打开窗口
        +ChangePage(pageType) 切换页面
        -OnGUI() 分发到当前 Page 的 DrawGUI,同时绘制选择器弹窗
        +Update() 驱动 Page 和 Core 的帧更新
    }

    class BasePage {
        <<页面基类>>
        +CutsceneEditWindow window 通过 Instance 拿到主窗口
        +Enter()* 进入页面时调用
        +Exit()* 离开页面时调用
        +DrawGUI()* 绘制页面内容
        +Update()* 帧更新
    }

    class HomePage {
        <<首页:过场列表>>
        -CutsceneTreeView treeView 左侧列表控件
        -List cutsceneList 所有过场配置数据
        -CutsceneConfigModelData selectCutscene 当前选中项
        +Enter() 扫描目录,刷新列表
        +DrawGUI() 绘制顶栏按钮 + 列表 + 右侧详情
        -UpdateCutsceneList() 扫描 Cutscene 目录重建列表
        -Open(data) 调 Core.Init + Load,切到 EditPage
    }

    class EditPage {
        <<编辑页:过场编辑主界面>>
        -BasePanel[] _panels 9 个功能面板
        -int curPanelIndex 当前激活的面板索引
        +Enter() 调 Core.Load 加载过场,激活默认面板
        +DrawGUI() 绘制顶栏 + 左侧面板菜单 + 右侧面板内容
        -DrawTopBar() 绘制顶栏(保存/退出等按钮)
        -DrawPanelMenus() 绘制左侧竖排面板切换标签
    }

    class BasePanel {
        <<功能面板基类>>
        +string title 面板名称(演员/镜头/特效...)
        +BasePage curPage 所属页面
        +CutsceneEditWindow window 通过 curPage 拿到主窗口
        +OnEnter()* 面板激活时调用
        +OnExit()* 面板切走时调用
        +OnGUI(rect)* 在给定区域内绘制
        +DrawBar()* 绘制面板工具栏(操作按钮)
        +DrawContent()* 绘制面板内容区(元素列表等)
    }

    class RolePanel {
        <<演员面板>>
        +DrawBar() +模型 / +主角 / +动作 等按钮
        +DrawContent() 已添加的演员列表
    }
    class CameraPanel {
        <<镜头面板>>
    }
    class EffectPanel {
        <<特效面板>>
    }
    class UIPanel {
        <>
    }
    class AudioPanel {
        <<声音面板>>
    }
    class SubCutscenePanel {
        <>
    }
    class PostprocessPanel {
        <<后处理面板>>
    }
    class OtherPanel {
        <<其他面板>>
    }
    class SettingPanel {
        <<设置面板>>
    }

    class CutsceneEditCore {
        <<编辑核心,所有 Timeline 和 Element 操作的唯一入口>>
        -GameObject root 场景中的过场根节点
        +CutsceneRoot CutsceneRoot 过场根组件
        +CutsceneConfigModelData CurrentModelData 当前编辑的过场配置
        +Action DoWinFunction 选择器弹窗的绘制回调
        +Init(modelData) 打开编辑场景,记录配置
        +Load(modelData) 实例化 Prefab,StartLoading
        +Save(callback) 写回数据,保存 Prefab 和 TimelineAsset
        +Exit() 销毁实例,关闭场景
        +InstanceOne(path, name) 新建过场完整骨架
        +ShowView(callback) 弹出资源选择器
        +AddActorModel(data) 添加演员(创建 Element + 加载模型)
        +AddCameraElement(type) 添加虚拟相机
        +AddEffectModel(data) 添加特效
        +SetupAnimationTrack(model, clip) 添加动画 Clip
    }

    class CutsceneCreateWindow {
        <<新建过场弹窗,继承 EditorWindow>>
        -CutsceneEditWindow parentWindow 父窗口引用
        -string newName 用户输入的过场名称
        +Show(parent, callback) 弹出窗口
        -OnGUI() 绘制输入框和确认按钮
    }

    EditorWindow <|-- CutsceneEditWindow : 继承
    EditorWindow <|-- CutsceneCreateWindow : 继承
    BasePage <|-- HomePage : 继承
    BasePage <|-- EditPage : 继承
    BasePanel <|-- RolePanel : 继承
    BasePanel <|-- CameraPanel : 继承
    BasePanel <|-- EffectPanel : 继承
    BasePanel <|-- UIPanel : 继承
    BasePanel <|-- AudioPanel : 继承
    BasePanel <|-- SubCutscenePanel : 继承
    BasePanel <|-- PostprocessPanel : 继承
    BasePanel <|-- OtherPanel : 继承
    BasePanel <|-- SettingPanel : 继承

    CutsceneEditWindow --> "1" HomePage : 持有 homePage
    CutsceneEditWindow --> "1" EditPage : 持有 editPage
    CutsceneEditWindow --> "1" CutsceneEditCore : 持有 Core
    EditPage --> "9" BasePanel : 持有 _panels 数组
    CutsceneCreateWindow --> CutsceneEditCore : 确认后调 InstanceOne

类图里各个类的职责简要概括:

  • CutsceneEditWindow 是入口,持有两个 Page 和一个 Core。OnGUI() 根据 curPageType 分发给对应 Page 的 DrawGUI(),同时负责画选择器弹窗
  • BasePage 是页面基类,定义了 Enter() / Exit() / DrawGUI() / Update() 生命周期。所有 Page 通过 window 属性拿到 Window 单例,再通过 Core 属性访问编辑核心
  • EditPage 内部维护 BasePanel[] 数组(9 个功能面板),通过 curPanelIndex 切换激活面板
  • BasePanel 定义了功能面板的生命周期(OnEnter() / OnExit() / DrawBar() / DrawContent()),子类各自覆写来画按钮和元素列表,操作 Timeline 时统一走 Core.XXX()
  • CutsceneEditCore 不继承任何基类,标了 [Serializable] 跟随 Window 序列化,是唯一直接操作 TimelineAssetPlayableDirectorCutsceneElement 的地方
  • CutsceneCreateWindow 是点”新建”时弹出的独立小窗口,确认后回调到 Core 的 InstanceOne()

后面按 Window → HomePage → EditPage → Core 的顺序,逐个展开伪代码和具体逻辑。

CutsceneEditWindow:主窗口

CutsceneEditWindow 是整个编辑器的 IMGUI 绘制入口,持有两个 Page 和一个 Core,OnGUI() 里根据当前页面类型分发绘制:

public partial class CutsceneEditWindow : EditorWindow
{
    public static CutsceneEditWindow Instance; // 单例,Page 和 Panel 通过它访问窗口

    [SerializeField] private HomePage homePage;              // 首页(过场列表)
    [SerializeField] private EditPage editPage;              // 编辑页(过场编辑主界面)
    [SerializeField] private CutsceneEditCore _core;   // 编辑核心
    [SerializeField] public EPageType curPageType;           // 当前显示的页面

    public CutsceneEditCore Core => _core; // 外部统一通过这个属性访问 Core

    /// <summary>
    /// 菜单入口,停靠在 Console 窗口旁边
    /// </summary>
    [MenuItem("Window/CutsceneEditor")]
    public static void ShowWindow()
    {
        Type consoleType = typeof(EditorWindow).Assembly.GetType("UnityEditor.ConsoleWindow");
        GetWindow<CutsceneEditWindow>("CutsceneEditor", true, consoleType).Show();
    }

    private void OnEnable()
    {
        Instance = this;
    }

    /// <summary>
    /// IMGUI 绘制入口,每帧调用
    /// 根据 curPageType 分发给对应 Page 的 DrawGUI()
    /// </summary>
    private void OnGUI()
    {
        // 根据当前页面类型拿到对应 Page
        BasePage curPage = (curPageType == EPageType.Home) ? homePage : editPage;

        // 选择器弹窗打开时,主界面置灰禁止交互
        EditorGUI.BeginDisabledGroup(Core.SelectViewShowing());
        curPage?.DrawGUI(); // 分发给当前 Page 绘制
        EditorGUI.EndDisabledGroup();

        // 在主界面右侧绘制选择器弹窗(选模型、选资源等)
        if (Core.SelectViewShowing())
        {
            BeginWindows();
            Rect winRect = new Rect(200, 0, 500, position.height);
            GUILayout.Window(1, winRect, Core.DoWinFunction.Invoke, Core.currentViewName);
            EndWindows();
        }
    }

    /// <summary>
    /// 页面切换:先 Exit 旧页面,再 Enter 新页面
    /// </summary>
    public void ChangePage(EPageType pageType)
    {
        GetCurPage(curPageType)?.Exit();  // 旧页面收尾
        curPageType = pageType;
        GetCurPage(curPageType)?.Enter(); // 新页面初始化
    }
}

OnGUI() 里用了 EditorGUI.BeginDisabledGroup(bool) / EndDisabledGroup(),这是 Unity IMGUI 的 API,传 true 进去会把包裹在中间的所有 GUI 元素置灰、禁止点击。这里的作用是:当选择器弹窗打开时(比如选模型资源的列表),背后的主界面置灰,防止弹窗还没关就误点了后面的按钮。选择器弹窗通过 GUILayout.Window 画在编辑器右侧,选完或关闭后主界面自动恢复交互。

BasePage:页面基类

后面要看的 HomePage 和 EditPage 都继承自 BasePage,所以先把基类交代一下。BasePage 不继承 MonoBehaviour,标 [Serializable] 跟随 Window 一起序列化,定义了页面的生命周期方法:

[Serializable]
public class BasePage
{
    /// <summary>
    /// 通过单例拿到主窗口
    /// </summary>
    public CutsceneEditWindow window => CutsceneEditWindow.Instance;

    /// <summary>
    /// 快捷访问编辑核心,等价于 window.Core
    /// </summary>
    protected CutsceneEditCore Core => window.Core;

    /// <summary>
    /// 构造时调用 ReInit(),给子类一个初始化入口
    /// </summary>
    public BasePage() { ReInit(); }

    public virtual EPageType GetPageType() => EPageType.None;

    /// <summary>
    /// 初始化/重置,构造时自动调用。子类覆写来创建面板数组等
    /// </summary>
    public virtual void ReInit() { }

    /// <summary>
    /// 进入页面时调用(初始化数据、加载资源等)
    /// </summary>
    public virtual void Enter() { }

    /// <summary>
    /// 离开页面时调用(清理状态)
    /// </summary>
    public virtual void Exit() { }

    /// <summary>
    /// 每帧绘制页面内容,由 CutsceneEditWindow.OnGUI() 分发过来
    /// </summary>
    public virtual void DrawGUI() { }

    /// <summary>
    /// 帧更新,由 CutsceneEditWindow.Update() 分发过来
    /// </summary>
    public virtual void Update() { }
}

整个编辑器有一条贯穿始终的引用链:BasePanel.curPage(BasePage)→ BasePage.window(CutsceneEditWindow.Instance)→ Core。不管在哪个面板里,通过 Core 就能操作 Timeline。

过场数据的存储结构

在展开各个页面之前,先搞清楚一条过场的数据存在哪。过场编辑器要管理的数据分两块:

过场资产:约定一个统一的过场资源根目录(比如 Assets/{ProjectName}/Cutscene/,具体路径可以做成可配置的),每条过场在下面有一个同名文件夹,里面放着过场 Prefab 和 TimelineAsset:

{CutsceneRootFolder}/
└── Battle01/
    ├── Battle01.prefab          ← 过场 Prefab(CutsceneRoot + 角色/相机节点 + PlayableDirector)
    ├── Tl_Battle01.playable     ← Master TimelineAsset(主时间线)
    └── ...                      ← 子时间线、模型引用等

节点树、Timeline 轨道/Clip、绑定关系全在 Prefab 里,这是过场的”本体”。编辑器保存时调 PrefabUtility.SaveAsPrefabAsset 把整棵 CutsceneRoot 写回磁盘。编辑器识别过场的方式很简单:扫描根目录下的子文件夹,有同名 .prefab 的就是一条有效过场。

过场配置元数据:除了资产本身,往往还需要一些额外的配置信息——比如剧情描述、绑定哪张地图、过场结束后主角站哪等。编辑器用一个 CutsceneConfigModelData 数据类来承载这些:

// CutsceneConfigModelData.cs
[System.Serializable]
public class CutsceneConfigModelData
{
    public int ID { get; set; }              // 序号
    public string Name { get; set; }         // 过场名称(同时也是文件夹名)
    public string Dec { get; set; }          // 剧情描述
    public string ResourcePath { get; set; } // Prefab 资源路径
    public int BindMapID { get; set; }       // 绑定的场景 ID
    public string PlayerPos { get; set; }    // 过场结束后主角的位置
    // ... 队伍站位、交互物件状态等
}

Name 是关键字段——编辑器通过它拼出 Prefab 路径和 Timeline 路径,串联起资产和配置。

这类元数据怎么持久化,各项目自己定就好——CSV、JSON、XML、ScriptableObject 都行,选团队熟悉的方案即可。核心思路是:过场资产(Prefab + Timeline)是”本体”,配置元数据是”索引和附加信息”,两者通过名称关联

HomePage:过场列表

打开编辑器后先进入 HomePage,它会扫描过场资源根目录,把项目里所有过场 Prefab 列出来:

整个 HomePage 的布局和交互流程可以用下图概括:

flowchart TB
    subgraph HomePage["HomePage 页面布局"]
        direction TB
        TopBar["顶部工具栏
初始化 | 刷新列表 | 新建"] subgraph Body["左右分栏"] direction LR Left["左侧 TreeView 列表
DrawList()
──────────
扫描过场资源根目录
生成 CutsceneTreeView"] Right["右侧选中项详情
DrawContent()
──────────
标题 / 描述 / Ping 定位"] end TopBar --> Body end Left -- "单击:SelectionChange
更新 selectCutscene" --> Right Left -- "双击:Open()
Core.Init() + ChangePage" --> EditPage["进入 EditPage"] TopBar -- "新建按钮" --> CreateWin["CutsceneCreateWindow
Core.InstanceOne()"] CreateWin -- "创建完成回调" --> Left

HomePage 继承 BasePage,核心逻辑就是维护一个 List<CutsceneConfigModelData> 过场列表(数据结构见上一节),处理用户的选中、双击、按钮操作:

public class HomePage : BasePage
{
    /// <summary>
    /// Unity TreeView 的状态(滚动位置、选中项等),序列化后编辑器重编译也不丢
    /// </summary>
    private TreeViewState viewState = new TreeViewState();

    /// <summary>
    /// 左侧过场列表控件(基于 Unity IMGUI TreeView)
    /// </summary>
    private CutsceneTreeView treeView;

    /// <summary>
    /// 内存中的过场条目列表,从文件夹扫描得到
    /// </summary>
    [SerializeField] private List<CutsceneConfigModelData> cutsceneList = new List<CutsceneConfigModelData>();

    /// <summary>
    /// 当前选中的过场条目
    /// </summary>
    private CutsceneConfigModelData selectCutscene;

    /// <summary>
    /// 进入首页时刷新一次数据列表
    /// </summary>
    public override void Enter()
    {
        UpdateCutsceneList();
    }

    /// <summary>
    /// 页面主绘制:顶部工具栏 + 左侧列表 + 右侧详情
    /// </summary>
    public override void DrawGUI()
    {
        DrawTop();       // 顶栏:初始化 / 刷新列表 / 新建 / 设置
        EditorGUILayout.BeginHorizontal();
        DrawList();      // 左侧:过场列表(TreeView)
        DrawContent();   // 右侧:选中条目的标题、描述等信息
        EditorGUILayout.EndHorizontal();
    }
}

DrawGUI() 把页面分成三块:顶部工具栏、左侧 TreeView 列表、右侧选中项详情。下面分别展开。

顶部工具栏

// HomePage.cs
private void DrawTop()
{
    EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);

    // 初始化:关闭残留的编辑状态和弹窗,重新进入首页(相当于软重置)
    if (GUILayout.Button("初始化", EditorStyles.toolbarButton, GUILayout.Width(60f)))
    {
        Core.Exit();
        Enter();
    }

    // 刷新列表:只重新扫描目录、清除选中,不重置编辑状态
    if (GUILayout.Button("刷新列表", EditorStyles.toolbarButton, GUILayout.Width(60f)))
    {
        selectCutscene = null;
        UpdateCutsceneList();
    }

    GUILayout.Space(40f);

    // 新建:弹出 CutsceneCreateWindow,确认后调 Core.InstanceOne(),创建完回调刷新列表
    if (GUILayout.Button("新建", EditorStyles.toolbarButton, GUILayout.Width(60f)))
    {
        CutsceneCreateWindow.Show(window, () => { UpdateCutsceneList(); }, "新建过场");
    }

    EditorGUILayout.EndHorizontal();
}

三个按钮的区别:

  • 初始化:先调 Core.Exit() 关掉残留的弹窗和编辑状态,再重新 Enter() 刷新列表。编辑器状态不对劲的时候点一下就能恢复
  • 刷新列表:只重新扫描目录、清除选中项,不重置编辑状态
  • 新建:弹出 CutsceneCreateWindow(独立的 EditorWindow),用户输入名称确认后,CutsceneCreateWindow 内部会调 parentWindow.Core.InstanceOne(savePath, newName) 创建过场骨架,创建完通过回调触发 UpdateCutsceneList() 刷新列表

左侧面板:过场列表(DrawList)

左侧面板用 Unity 的 IMGUI TreeView 来画过场列表。TreeView 是 Unity 编辑器内置的树形列表控件(UnityEditor.IMGUI.Controls.TreeView),支持单击选中、双击回调、右键菜单、键盘导航等功能,项目里封装了一个 CutsceneTreeView 子类来适配过场数据。

DrawList() 每帧调用,负责确保 TreeView 存在并把它画出来:

// HomePage.cs
/// <summary>
/// 左侧列表面板的滚动位置
/// </summary>
private Vector2 leftViewPos;

/// <summary>
/// 绘制左侧过场列表:固定宽 200,内容放在 ScrollView 里
/// </summary>
private void DrawList()
{
    InitTreeView(); // 首次调用时创建 TreeView 并绑定回调

    // 左侧面板:固定宽度 200,用 box 风格画一个带边框的区域
    EditorGUILayout.BeginVertical(GUILayout.Width(200f));
    leftViewPos = EditorGUILayout.BeginScrollView(leftViewPos, GUI.skin.box);

    // TreeView.OnGUI 需要一个 Rect 区域来绘制
    // 这里给一个极大的 Rect,实际显示会被 ScrollView 裁剪
    treeView.OnGUI(GUILayoutUtility.GetRect(0, 100000, 0, 100000));

    EditorGUILayout.EndScrollView();
    EditorGUILayout.EndVertical();
}

InitTreeView() 做的是 TreeView 的一次性初始化——只在第一次调用时执行。它先调 UpdateCutsceneList() 扫描文件夹填充数据,再 new 出 CutsceneTreeView 并把单击、双击、右键菜单的回调绑上去:

// HomePage.cs
/// <summary>
/// 初始化 TreeView(仅首次调用时创建)
/// CutsceneTreeView 继承自 Unity 的 TreeView,构造时需要传入:
///   - viewState:TreeView 内部状态(滚动位置、展开/折叠、选中项等),
///               Unity 自动序列化,编辑器重编译后不丢
///   - cutsceneList:数据源,TreeView 从这个列表生成每一行
/// </summary>
private void InitTreeView()
{
    if (treeView != null) return; // 已经创建过了,跳过

    UpdateCutsceneList(); // 先扫描文件夹,填充 cutsceneList

    treeView = new CutsceneTreeView(viewState, cutsceneList);
    treeView.SelectionChangeAction = SelectionChange; // 单击某一行:更新右侧详情面板
    treeView.DoubleClickAciton = Open;                // 双击某一行:进入编辑页

    // 右键菜单:两个选项
    treeView.AddMenu("进入", (data) => { Open((CutsceneConfigModelData)data); });
    treeView.AddMenu("删除", (data) => { Delete((CutsceneConfigModelData)data); });
}

CutsceneTreeView 继承 Unity 的 TreeView,封装了列表的绘制和交互逻辑。HomePage 使用时只需要传入数据源、绑定回调即可:

// CutsceneTreeView.cs
public class CutsceneTreeView : TreeView
{
    public Action<CutsceneConfigModelData> DoubleClickAciton;
    public Action<CutsceneConfigModelData> SelectionChangeAction;
    private List<CutsceneConfigModelData> cutsceneModels;
    private SearchField searchField = new SearchField();
    private List<KeyValuePair<string, GenericMenu.MenuFunction2>> newMenus;

    public CutsceneTreeView(TreeViewState state, List<CutsceneConfigModelData> contents) : base(state)
    {
        cutsceneModels = contents;
        Reload();
    }

    /// <summary>
    /// 绘制:上面 20px 画搜索框,剩余区域画列表本体
    /// </summary>
    public override void OnGUI(Rect rect) { /* ... */ }

    protected override TreeViewItem BuildRoot() => new TreeViewItem { id = 0, depth = -1 };

    /// <summary>
    /// 构建行数据:绑定了关卡的按 BindMapID 归为折叠组,未绑定的平铺在根层级
    /// </summary>
    protected override IList<TreeViewItem> BuildRows(TreeViewItem root) { /* ... */ }

    // 事件转发:FindItem -> 取出 CutsceneTreeViewItem -> 调外部 Action
    protected override void DoubleClickedItem(int id) { /* -> DoubleClickAciton */ }
    protected override void SelectionChanged(IList<int> selectedIds) { /* -> SelectionChangeAction */ }
    protected override void ContextClickedItem(int id) { /* 弹出 GenericMenu */ }
    public void AddMenu(string menuName, GenericMenu.MenuFunction2 action) { /* 注册右键菜单项 */ }
}

/// <summary>
/// 自定义行数据项,比 TreeViewItem 多带一个 CutsceneConfigModelData
/// </summary>
public class CutsceneTreeViewItem : TreeViewItem
{
    public CutsceneConfigModelData cutsceneModelData;
    public CutsceneTreeViewItem(int id, int depth, string displayName, CutsceneConfigModelData data)
        : base(id, depth, displayName) { cutsceneModelData = data; }
}

继承 Unity TreeView 写列表控件的套路比较固定,说一下这里的几个要点:

  • **BuildRoot()**:返回一个 depth = -1 的虚拟根节点,TreeView 框架硬性要求,它本身不会显示
  • **BuildRows()**:实际构建列表行的地方。拿到 cutsceneModels 后先按 BindMapID 分一次组——绑定了关卡的过场按 "关卡ID : 地图名" 归到可折叠的父节点下(group 节点 depth=0,子过场 depth=1),没绑定关卡的直接平铺在根层级。策划打开列表就能按关卡分类查看,不用一条条翻
  • 事件转发DoubleClickedItem()SelectionChanged()ContextClickedItem()TreeView 的虚方法,重写后通过 FindItem() 拿到 CutsceneTreeViewItem,取出里面的 cutsceneModelData 丢给外部 Action。右键菜单用 GenericMenu 弹出,菜单项在 InitTreeView() 里通过 AddMenu() 注册
  • 搜索框OnGUI() 里在列表上方画了一个 SearchField,输入内容赋值给基类的 searchString 属性后,TreeView 内部会自动过滤行。过场一多起来按名字搜比滚动翻找快得多
  • **CutsceneTreeViewItem**:继承 TreeViewItem,额外带一个 cutsceneModelData 字段,事件回调里可以直接取到对应的过场数据,不需要再拿 id 去查

TreeView 的数据源通过 UpdateCutsceneList() 获取。这个方法扫描过场资源根目录,把有效过场收集到 cutsceneList 里——每个子文件夹如果包含同名 .prefab,就算一条有效过场:

// HomePage.cs
private void UpdateCutsceneList()
{
    cutsceneList.Clear();

    string cutsceneFolder = CutscenePathConfig.CutsceneDataFolder; // 可配置的过场资源根目录
    if (Directory.Exists(cutsceneFolder))
    {
        // 遍历所有子文件夹
        string[] folders = Directory.GetDirectories(cutsceneFolder);
        foreach (string folder in folders)
        {
            string folderName = Path.GetFileName(folder);
            // 约定:子文件夹里有同名 .prefab 就是一条有效过场
            string prefabPath = Path.Combine(folder, $"{folderName}.prefab");
            if (File.Exists(prefabPath))
            {
                CutsceneConfigModelData modelData = new CutsceneConfigModelData();
                modelData.Name = folderName;
                modelData.ResourcePath = prefabPath.Replace(".prefab", "").Replace("\\", "/");
                modelData.ID = cutsceneList.Count + 1;
                cutsceneList.Add(modelData);
            }
        }
    }

    // 如果 TreeView 已经创建过了,直接刷新它的数据
    if (treeView != null)
        treeView.Reload(cutsceneList);
}

右侧面板:选中项详情(DrawContent)

右侧面板展示当前选中过场的基本信息。selectCutscene 是通过 TreeView 的单击回调 SelectionChange 赋值的:

// HomePage.cs
/// <summary>
/// TreeView 单击回调:更新当前选中的过场条目
/// </summary>
private void SelectionChange(CutsceneConfigModelData data)
{
    selectCutscene = data;
}

有了 selectCutsceneDrawContent() 每帧根据它来决定右侧画什么——选中了就显示标题和描述,没选中就留空:

// HomePage.cs
/// <summary>
/// 绘制右侧详情面板:选中了过场就显示标题+描述,否则留空
/// </summary>
private void DrawContent()
{
    DrawHelper.Vertical(() =>
    {
        if (selectCutscene == null)
        {
            // 没选中任何过场时,占位填充(避免布局塌缩)
            DrawHelper.FullSpaceVertical();
            return;
        }

        // ---- 标题行 ----
        DrawHelper.Horizontal(() =>
        {
            EditorGUILayout.LabelField("标题:", GUILayout.Width(40f));
            EditorGUILayout.LabelField(selectCutscene.Name);
            GUILayout.FlexibleSpace();
            // Ping 按钮:在 Project 窗口高亮定位到这个 Prefab
            DrawHelper.PingButton(18, 18, () =>
            {
                GameObject asset = AssetDatabase.LoadAssetAtPath<GameObject>(
                    $"{selectCutscene.ResourcePath}.prefab");
                EditorGUIUtility.PingObject(asset);
            });
        });

        // ---- 描述区域 ----
        EditorGUILayout.LabelField("描述:");
        GUILayout.Label(selectCutscene.Dec, GUI.skin.textArea, GUILayout.Height(100f));
    });
}

整个交互链路就是:策划单击左侧列表项 → TreeView 触发 SelectionChange 更新 selectCutscene → 下一帧 DrawContent() 自动刷新右侧显示。

双击进入编辑

双击列表项时 TreeView 触发 DoubleClickAction,走到 Open()

// HomePage.cs
private void Open(CutsceneConfigModelData data)
{
    if (data == null) return;
    // Init() 记录当前编辑的过场配置,并打开 CutsceneBuild 编辑场景
    Core.Init(data);
    // 切到 EditPage,EditPage.Enter() 里会调 Core.Load() 加载过场
    CutsceneEditWindow.Instance.ChangePage(EPageType.Edit);
}

新建过场的内部流程


点”新建”按钮后弹出 CutsceneCreateWindow,用户输入过场名称(不能有中文,会做正则校验),确认后 CutsceneCreateWindow 内部调 parentWindow.Core.InstanceOne(savePath, newName) 让 Core 创建过场骨架:

sequenceDiagram
    participant HP as HomePage
    participant CW as CutsceneCreateWindow
    participant Core as CutsceneEditCore
    participant Scene as CutsceneBuild 场景

    HP->>CW: 点"新建",弹出创建窗口
    CW->>CW: 用户输入过场名称(正则校验禁止中文)
    CW->>Core: parentWindow.Core.InstanceOne(savePath, name)
    Core->>Scene: 打开 CutsceneBuild 编辑场景
    Core->>Core: 创建根 GameObject 层级结构
    Note right of Core: Timeline / CameraGroup /
RoleGroup / EffectGroup /
PathGroup / LightingGroup Core->>Core: 挂 CutsceneRoot、PlayableDirector Core->>Core: 创建 TimelineAsset Core->>Core: 创建各 GroupTrack Note right of Core: Camera Group / Role Group /
Effect Group / UI Group /
Music Group / Other Group Core->>Core: 实例化编辑用摄像机,创建 CinemachineTrack 并绑定 Core->>Core: CutsceneRoot.StartLoading() Core->>Core: PrefabUtility.SaveAsPrefabAsset() CW->>CW: Close(),触发 CreateCallBack CW-->>HP: 回调 UpdateCutsceneList() 刷新列表

Core.InstanceOne()CutsceneEditCore 上的方法,负责搭建一个过场的完整骨架:

// CutsceneEditCore.cs
public void InstanceOne(string savePath, string cutsceneName)
{
    OpenBuildScene();

    // 创建根层级——每个分类一个带 Group 后缀的空子节点
    GameObject instanceRoot = new GameObject(cutsceneName);
    // HierarchyGroupList = { "Timeline", "CameraGroup", "RoleGroup", "EffectGroup", "PathGroup", "LightingGroup", "SubCutsceneGroup" }
    foreach (string groupName in HierarchyNaming.HierarchyGroupList)
    {
        new GameObject(groupName).transform.SetParent(instanceRoot.transform);
    }

    // 挂核心组件
    CutsceneRoot cutsceneRoot = instanceRoot.AddComponent<CutsceneRoot>();
    GameObject directorGo = instanceRoot.transform.Find("Timeline").gameObject;
    PlayableDirector director = directorGo.AddComponent<PlayableDirector>();
    director.playOnAwake = false;

    // TimelineAsset 单独保存为 .playable 文件
    TimelineAsset timeline = ScriptableObject.CreateInstance<TimelineAsset>();
    string cutsceneDir = Path.Combine(savePath, cutsceneName);
    AssetDatabase.CreateAsset(timeline, Path.Combine(cutsceneDir, $"Tl_{cutsceneName}.playable"));
    director.playableAsset = timeline;

    // 创建 SubCutscene 用的 SubCutsceneTrack(用于挂载子过场 Clip)
    timeline.GetOrCreateTrack<SubCutsceneTrack>("SubCutscene Track");

    // 创建分组轨道,后续添加的轨道都挂在对应 Group 下
    // TrackGroupsList = { "Camera Group", "Role Group", "Effect Group", "UI Group", "Postprocess Group", "Music Group", "Other Group" }
    foreach (string trackGroup in TrackGroupNaming.TrackGroupsList)
    {
        timeline.CreateTrack<GroupTrack>(null, trackGroup);
    }

    // 创建编辑用摄像机和默认 CinemachineTrack
    GameObject editCamera = Instantiate(cameraPrefab, instanceRoot.transform);
    editCamera.name = "Cutscene Camera";
    CinemachineTrack cameraTrack = timeline.GetOrCreateTrack<CinemachineTrack>("Camera Track");
    director.SetGenericBinding(cameraTrack, editCamera.GetComponent<CinemachineBrain>());

    // 触发一次装配流程
    cutsceneRoot.StartLoading();

    // 保存为 Prefab 并建立连接,然后销毁临时对象
    PrefabUtility.SaveAsPrefabAssetAndConnect(instanceRoot, Path.Combine(cutsceneDir, $"{cutsceneName}.prefab"), InteractionMode.AutomatedAction);
    AssetDatabase.SaveAssets();
    Object.DestroyImmediate(instanceRoot);
}

这样创建出来的 Prefab 就已经有了完整的层级结构和空的 Timeline 骨架,后续双击进去就能往里面加角色、加镜头了。

加载已有过场

双击列表中的某条过场后,Load() 做的事情比较直接:

  1. 打开 CutsceneBuild 场景(后面会解释为什么需要这个专用编辑场景)
  2. 把过场 Prefab 实例化到场景中
  3. CutsceneRoot.StartLoading() 走一遍所有 Element 的 Create() 流程,把模型、特效等资源加载出来
  4. 打开 Timeline 窗口,锁定到当前 Director
// CutsceneEditCore.cs
public void Load(CutsceneConfigModelData modelData)
{
    // 清掉场景里可能残留的旧 CutsceneRoot 实例
    CutsceneRoot[] currents = Object.FindObjectsOfType<CutsceneRoot>();
    foreach (CutsceneRoot old in currents)
        Object.DestroyImmediate(old.gameObject);

    // 按名称拼 Prefab 路径
    string prefabPath = $"{CutscenePathConfig.CutsceneDataFolder}/{modelData.Name}/{modelData.Name}.prefab";
    GameObject asset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);

    // 实例化到 Build 场景,然后解包——解包后才能自由编辑层级结构
    root = PrefabUtility.InstantiatePrefab(asset, buildScene) as GameObject;
    PrefabUtility.UnpackPrefabInstance(root, PrefabUnpackMode.OutermostRoot, InteractionMode.AutomatedAction);

    CutsceneRoot.StartLoading(); // 走一遍所有 Element 的 Create() 流程

    // 打开并锁定 Timeline 窗口到当前 Director
    CutsceneEditorGUIUtility.OpenAndLockTimelineWindow(GetMasterDirector());
}

加载完成后,左边是 Unity 的 Timeline 窗口显示各轨道,右边是编辑器的功能面板——策划就可以开始编辑了。

EditPage:过场编辑主界面

EditPage 是策划工作的主要界面,布局分三块:

图中左侧是 Timeline 窗口(锁定到当前过场的 Director),能看到 Camera Group、Role Group 等分组轨道。右侧是编辑器的 EditPage:

  • 顶栏:过场名称、”添加语言”、”输入主角选择器”、”保存”、”空轨检测”等按钮
  • 左侧竖排标签:演员、镜头、UI、声音、SubCutscene、后处理、设置等分类,点击后右侧切换对应面板
  • 右侧内容区:当前分类的操作面板,列出已添加的元素,提供各种操作按钮

整个 EditPage 的布局和交互流程可以用下图概括:

flowchart TB
    subgraph EditPage["EditPage 页面布局"]
        direction TB
        TopBar["顶部工具栏 DrawTopBar()
过场名称 | 基础信息 | 实时刷新 | 保存 | 退出"] subgraph Body["左右分栏"] direction LR Left["左侧竖排标签
DrawPanelMenus()
──────────
演员 / 镜头 / 特效
UI / 声音 / SubCutscene
后处理 / 其他 / 设置"] Right["右侧内容区
BasePanel.OnGUI()
──────────
DrawBar():操作按钮
DrawContent():元素列表"] end TopBar --> Body end Left -- "点击标签切换
OnExit() → OnEnter()" --> Right Right -- "操作 Timeline" --> Core["Core.XXX()"] TopBar -- "退出按钮" --> Home["回到 HomePage"] TopBar -- "保存按钮" --> Core

EditPage 内部维护一个 BasePanel[] 数组,通过 curPanelIndex 控制当前显示哪个面板:

// EditPage.cs
[Serializable]
internal class EditPage : BasePage
{
    private static BasePanel[] _panels;
    private int curPanelIndex;
    [SerializeField] private CutsceneConfigModelData curTable;

    /// <summary>
    /// 初始化时创建所有功能面板实例,每个面板传入标题和所属 Page
    /// </summary>
    public override void ReInit()
    {
        _panels = new BasePanel[]
        {
            new RolePanel("演员", this),
            new CameraPanel("镜头", this),
            new EffectPanel("特效", this),
            new UIPanel("UI", this),
            new AudioPanel("声音", this),
            new SubCutscenePanel("SubCutscene", this),
            new PostprocessPanel("后处理", this),
            new OtherPanel("其他", this),
            new SettingPanel("设置", this),
        };
    }

    /// <summary>
    /// 进入编辑页时,从 Core 拿到当前编辑的过场配置,加载并激活面板
    /// </summary>
    public override void Enter()
    {
        curTable = Core.CurrentModelData; // Init() 时已存入 Core
        Core.Load(curTable);              // 加载过场 Prefab 到编辑场景
        _panels[curPanelIndex].OnEnter();           // 激活当前面板
    }

    /// <summary>
    /// 绘制三块区域:顶栏 + 左侧面板标签 + 右侧当前面板内容
    /// </summary>
    public override void DrawGUI()
    {
        DrawTopBar();                                  // 顶栏:过场名称、保存、退出
        DrawPanelMenus();                              // 左侧:竖排功能面板标签
        _panels[curPanelIndex].OnGUI(ContentRect);     // 右侧:当前面板的内容
    }

    /// <summary>
    /// 左侧竖排标签,点击后切换面板
    /// 切换时先调旧面板的 OnExit(),再调新面板的 OnEnter()
    /// </summary>
    private void DrawPanelMenus()
    {
        for (int i = 0; i < _panels.Length; i++)
        {
            if (GUILayout.Button(_panels[i].title))
            {
                if (i != curPanelIndex)
                {
                    _panels[curPanelIndex].OnExit();  // 旧面板收尾
                    curPanelIndex = i;
                    _panels[curPanelIndex].OnEnter();  // 新面板激活
                }
            }
        }
    }
}

可以看到 EditPage 自己并不画右侧的具体内容,而是通过 _panels[curPanelIndex].OnGUI(ContentRect) 把整块区域直接甩给当前面板去画。面板切换时走一次 OnExit()OnEnter(),各面板在里面做自己的数据刷新和清理就行。

BasePanel:功能面板基类

BasePanel 是所有功能面板的抽象基类,主要负责两件事:管理面板的进出生命周期(OnEnter / OnExit),以及把 EditPage 给过来的 Rect 拆成工具栏和内容区两块分别绘制:

// BasePanel.cs
public abstract class BasePanel
{
    /// <summary>
    /// 面板名称("演员"/"镜头"/"特效"等),显示在左侧竖排标签上
    /// </summary>
    public string title;

    /// <summary>
    /// 所属的 EditPage
    /// </summary>
    public BasePage curPage;

    /// <summary>
    /// 快捷访问主窗口
    /// </summary>
    public CutsceneEditWindow window => curPage.window;

    /// <summary>
    /// 快捷访问编辑核心,等价于 window.Core
    /// </summary>
    protected CutsceneEditCore Core => window.Core;

    /// <summary>
    /// 当前面板的可绘制区域,由 EditPage 传入
    /// </summary>
    public Rect rect;

    protected BasePanel(string title, BasePage curPage)
    {
        this.title = title;
        this.curPage = curPage;
    }

    /// <summary>
    /// 面板被切换到前台时调用,可以做数据刷新
    /// </summary>
    public virtual void OnEnter() { }

    /// <summary>
    /// 面板被切走时调用,可以做清理
    /// </summary>
    public virtual void OnExit() { }

    /// <summary>
    /// 绘制入口:EditPage 传入整块 Rect,拆成上下两部分
    /// TopBarRect() / ContentRect() 各自开 BeginArea + ScrollView 后调用无参版本
    /// </summary>
    public virtual void OnGUI(Rect rect)
    {
        this.rect = rect;
        DrawBar(TopBarRect());         // 上半部分:在工具栏区域内调 DrawBar()
        DrawContent(ContentRect());    // 下半部分:在内容区域内调 DrawContent()
    }

    /// <summary>
    /// 带 Rect 的版本:在指定区域开 BeginArea,再调无参 DrawBar()
    /// 子类一般不覆写这个
    /// </summary>
    public virtual void DrawBar(Rect rect)
    {
        GUILayout.BeginArea(rect);
        DrawBar();
        GUILayout.EndArea();
    }

    /// <summary>
    /// 子类覆写:画工具栏里的操作按钮(+模型、+动作、+相机等)
    /// </summary>
    public virtual void DrawBar() { }

    /// <summary>
    /// 带 Rect 的版本:同理,子类一般不覆写
    /// </summary>
    public virtual void DrawContent(Rect rect)
    {
        GUILayout.BeginArea(rect);
        DrawContent();
        GUILayout.EndArea();
    }

    /// <summary>
    /// 子类覆写:画内容区(已添加的元素列表、参数面板等)
    /// </summary>
    public virtual void DrawContent() { }
}

子类只需要覆写无参的 DrawBar()DrawContent()。面板本身只管 UI 绘制,需要操作 Timeline 时一律走 Core.XXX()

拿演员面板 RolePanel 举个例子,看看实际子类长什么样:


// RolePanel.cs
public class RolePanel : BasePanel
{
    /// <summary>
    /// 顶部 SelectionGrid 的当前选项(模型 / 主角 / 交互实体)
    /// </summary>
    private int selectRoleType;

    /// <summary>
    /// 演员类型 → 对应的 Element 类型映射
    /// </summary>
    private Dictionary<string, Type> roleTypes = new Dictionary<string, Type>
    {
        { "模型", typeof(RoleModelElement) },
        { "主角", typeof(PlayerModelElement) },
        { "交互实体", typeof(InteractModelElement) },
    };

    public RolePanel(string title, BasePage page) : base(title, page) { }

    /// <summary>
    /// 工具栏:先画类型切换 Grid,再根据当前类型画不同的添加按钮
    /// </summary>
    public override void DrawBar()
    {
        // 类型切换(模型 / 主角 / 交互实体),影响下方列表和按钮
        selectRoleType = GUILayout.SelectionGrid(selectRoleType, roleTypes.Keys.ToArray(), roleTypes.Count);

        EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);

        Type roleType = roleTypes.Values.ToList()[selectRoleType];
        if (roleType == typeof(RoleModelElement))
        {
            if (GUILayout.Button("+模型", EditorStyles.toolbarButton, GUILayout.Width(50f)))
            {
                // 弹出模型选择窗口,选中后调 Core 添加演员
                Core.ShowView<ModelSelectView<ActorModelData>, ActorModelData>((modelData) =>
                {
                    ModelElement element = Core.AddActorModel(modelData);
                    Core.AddPerformerRecordTrack(element.gameObject, TrackGroupNaming.TrackRoleGroup);
                    Core.SetupAnimationTrack(element.gameObject, null);
                });
            }
        }
        else if (roleType == typeof(PlayerModelElement))
        {
            if (GUILayout.Button("+主角", EditorStyles.toolbarButton, GUILayout.Width(90f)))
            {
                // 主角只允许一个
                if (Core.IsValidAddPlayerElement())
                    Core.ShowView<ModelSelectView<ActorModelData>, ActorModelData>((modelData) =>
                    {
                        Core.AddPlayerModel(modelData);
                    });
                else
                    EditorUtility.DisplayDialog("提示", "主角已经添加,不能添加多个", "好的");
            }
        }
        // ... 交互实体等其他类型类似

        EditorGUILayout.EndHorizontal();
    }

    /// <summary>
    /// 内容区:遍历当前类型的所有演员 Element,逐个画折叠组
    /// 每个折叠组内部分为"操作按钮行"和"已有 Clip 分类列表"
    /// </summary>
    public override void DrawContent()
    {
        // 根据顶部 SelectionGrid 的选项,拿到当前要显示的演员类型
        Type menuType = roleTypes.Values.ToList()[selectRoleType];

        // 从场景层级的 RoleGroup 节点下,取出该类型的所有演员 Element
        BaseCutsceneElement[] modelElements = Core.GetElements(menuType, HierarchyNaming.GroupRole);

        for (int i = 0; modelElements != null && i < modelElements.Length; i++)
        {
            ModelElement oneCom = modelElements[i] as ModelElement;
            if (oneCom == null) continue;

            // ========================================
            // 每个演员渲染为一个可折叠组,展开后分两部分:
            //   上半部分:操作按钮行(+动作、+路径位移、+隐藏 ...)
            //   下半部分:已有 Clip 的分类折叠列表
            // 折叠组标题示例:[002]Chiriri_2090101 →
            // 点击标题可 Ping 到 Hierarchy 定位
            // ========================================

            // ---- 操作按钮行 ----
            DrawHelper.Button("+动作", 60, () =>
            {
                // 弹出动画选择窗口,选中后通过 Core 添加到该演员的 AnimationTrack
                Core.ShowView<ObjectSelectView<AnimationClip>, AnimationClip>((clip) =>
                {
                    Core.SetupAnimationTrack(oneCom.gameObject, clip);
                });
            });
            DrawHelper.Button("+路径位移", 78, () => { Core.AddMovePathClip(oneCom); });
            DrawHelper.Button("+隐藏", 60, () =>
            {
                Core.AddDeactiveClip(oneCom.gameObject, TrackGroupNaming.TrackRoleGroup);
            });
            // ... +表情动作、+状态机动画、+录制、居中、贴地、替换、删除等

            // ---- 已有 Clip 按分类折叠展示 ----
            // 面板自身不存 Clip 列表,每帧通过 Core 从 Timeline 实时查询
            // 这样 Timeline 窗口里的任何改动(手动删 Clip 等)下一帧就能自动反映到面板上

            // [动作序列]:用 EditorPrefs 记住折叠状态,编辑器重启也不丢
            string motionKey = $"{oneCom.no}_motion";
            bool motionExpand = EditorGUILayout.BeginFoldoutHeaderGroup(
                EditorPrefs.GetBool(motionKey, true), "[动作序列]");
            EditorPrefs.SetBool(motionKey, motionExpand);
            if (motionExpand)
            {
                // 按演员 GameObject 名字查 Timeline 中绑定的所有动画 Clip
                List<TimelineClip> motionShots = Core.GetAllAnimationShots(oneCom.gameObject.name);
                for (int k = 0; k < motionShots.Count; k++)
                {
                    // 每条 Clip 一行:[X 删除] [Clip 显示名] [⊙ Ping 定位到 Timeline]
                    DrawHelper.Horizontal(() =>
                    {
                        DrawHelper.CloseButton(() => { Core.RemoveClipByClip(motionShots[k]); });
                        GUILayout.Label(motionShots[k].displayName);
                        DrawHelper.PingButton(18, 18, () => { CutsceneEditorGUIUtility.PingClip(motionShots[k]); });
                    });
                }
            }
            EditorGUILayout.EndFoldoutHeaderGroup();

            // 以下各分类画法和 [动作序列] 完全一样:查数据 → FoldoutHeaderGroup → 逐行画 Clip
            // [动画状态机] — Core.GetAllAnimStateClip(oneCom.gameObject.name)
            // [路径-旋转序列] — oneCom.pathElements
            // [隐藏序列] — Core.GetAllDeactiveClip(oneCom.gameObject.name)
            // [录制轨道] — Core.GetRecordTrack(oneCom.gameObject)
        }
    }
}

这里有个值得注意的设计:面板自身不维护 Clip 列表,每帧绘制时都通过 Core 去 Timeline 里实时查。比如 GetAllAnimationShots(actorName) 就是遍历 TimelineAsset 的轨道,找到绑定到该演员 GameObject 的 AnimationTrack,取出里面的 Clip 返回。这样做的好处是面板永远展示 Timeline 的真实状态——策划在 Timeline 窗口里手动删了一条 Clip,面板这边下一帧自动就没了,不需要额外的同步逻辑。

DrawBar() 负责”添加新元素”的按钮,DrawContent() 负责”展示和管理已有元素”的分类折叠列表。按钮回调里统一走 Core,不在面板里直接操作 Timeline。其他面板(镜头、特效、UI、声音……)结构一模一样,区别只在于查询的轨道类型和调用的 Core 方法不同。

各功能面板的职责划分

功能面板 负责的事情
演员 添加/删除 NPC 或主角模型,管理动作 Clip、表情、路径位移、显示/隐藏
镜头 添加虚拟相机、设置 Cinemachine 路径和 Noise,复制相机参数
特效 添加特效模型,创建特效关键帧 Clip
UI 创建字幕、幕布、对话底板等 UI 元素的 Clip
声音 创建音频轨道和音频 Clip
SubCutscene 管理子过场的添加和删除
后处理 创建后处理轨道(Bloom、调色等)
其他 通用事件 Clip(不属于以上分类的杂项)
设置 帧率、过场描述等全局参数,直接改 CutsceneRoot 上的字段

CutsceneEditCore:编辑核心

编辑器最核心的类。之所以把所有操作都收到 Core 里,是因为哪怕”添加一个角色”这么简单的操作,内部要做的事也不少。拿”添加一个 NPC 演员”来举例:

资源选择器

面板里几乎所有”添加”操作都要先弹一个选择器让策划挑资源。Core 封装了一个通用选择器 ShowView<TView, TData>(callback)TView 决定选择器长什么样、数据从哪来,TData 是选中后回调拿到的数据类型。选择器通过 GUILayout.Window 画在编辑器右侧,支持搜索和分页。

选择器的数据来源取决于资源类型,不同项目可以根据自身情况选择方案:

  • 读配置表:从 CSV / JSON / ScriptableObject 等配置里读可用资源列表,适合需要策划手动维护白名单的场景
  • 扫描目录:用 AssetDatabase.FindAssets("t:prefab", paths) 按资源类型扫描约定目录,自动发现所有可用资源,省去维护配置的成本
  • 从组件提取:比如动画列表可以直接从模型身上 AnimatorAnimatorController.animationClips 取,策划只能选这个模型自带的动作,不会选错

实际项目里这几种方式经常混用——模型和特效走目录扫描,动画从 Animator 取,UI 面板走配置表。关键是把资源路径统一收到一个 PathConfig 类里,选择器和 Reader 都从这里取路径,后续调整目录只改一处。

选择器框架本身是通用的——BaseSelectView<T> 定义了搜索框、滚动列表、选中高亮、确认/关闭回调这些基础逻辑,子类只需重写 Create()(数据从哪来)和 GetShowTitle()(每行显示什么文本)就能扩展新类型。

添加 NPC 演员的完整流程

在”演员”面板点”+模型”,Core 弹出模型选择器,列出扫描到的所有可用模型:

策划选好模型后触发回调,回调里分两步走——先创建演员节点,再创建动画轨道:

// RolePanel.cs — 演员面板 "+模型" 按钮的完整回调流程
Core.ShowView<ModelSelectView<ActorModelData>, ActorModelData>((modelData) =>
{
    // 第一步:在 Prefab 层级里创建演员节点并加载模型
    ModelElement element = Core.AddActorModel(modelData);
    // 第二步:为该演员创建一条空的 BodyAnim 动画轨道(传 null 表示不立即放入动画片段)
    Core.SetupAnimationTrack(element.gameObject, null);
});

整个流程的时序:

sequenceDiagram
    participant Panel as 演员面板(RolePanel)
    participant Core as CutsceneEditCore
    participant Prefab as 过场 Prefab 层级
    participant TL as TimelineAsset

    Panel->>Core: ShowView 弹出资源选择器
    Note over Panel: 策划选好模型资源后触发回调

    Panel->>Core: AddActorModel(modelData)
    Core->>Prefab: 在 RoleGroup 节点下创建子 GameObject [001]ModelName
    Core->>Prefab: 挂 RoleModelElement 组件,设 assetPath
    Core->>Prefab: element.Create() 加载模型

    Panel->>Core: SetupAnimationTrack(model, null)
    Core->>TL: 在 Role Group 下创建子 GroupTrack(以模型名为组)
    Core->>TL: 创建 AnimationTrack(BodyAnim 轨道)
    Core->>TL: Director.SetGenericBinding(track, element.bindGo)

    Note over Panel: 后续策划按需点击按钮添加更多轨道:
    Note over Panel: "+状态机动画" → AddAnimStateClip
    Note over Panel: "+隐藏" → AddDeactiveClip
    Note over Panel: "+路径位移" → AddMovePathClip

下面分别看这两个 Core 方法的实现。

AddActorModel() —— 创建节点 + 挂组件 + 加载模型,不涉及轨道:

// CutsceneEditCore.cs
/// <summary>
/// 在过场 Prefab 层级中添加一个 NPC 演员节点,挂载 Element 组件并加载模型
/// 只负责"创建节点 + 挂组件 + 加载模型",不涉及轨道创建
/// </summary>
public ModelElement AddActorModel(ActorModelData modelData)
{
    // 从资源路径提取模型显示名,例如 "Assets/.../Chiriri_2090101.prefab" → "Chiriri_2090101"
    string showName = Path.GetFileNameWithoutExtension(modelData.assetPath);

    // 拿到 Prefab 层级里的 RoleGroup 父节点(没有就自动创建)
    Transform roleGroup = GetOrAddGroup("RoleGroup");
    // 自增编号,保证同组下每个演员名称唯一,例如 [001]Chiriri_2090101、[002]Guard_3010201
    int addNumber = CutsceneRoot.GetGroupNameNumber("RoleGroup");
    string combineName = $"[{addNumber:D3}]{showName}";

    // 创建空 GameObject 挂到 RoleGroup 下
    GameObject node = new GameObject(combineName);
    node.transform.SetParent(roleGroup);

    // 挂 RoleModelElement 组件,记录编号和资源路径,供后续序列化和运行时加载使用
    RoleModelElement element = node.AddComponent<RoleModelElement>();
    element.no = addNumber;
    element.assetPath = modelData.assetPath;

    // Create() 内部调 CutsceneRoot.LoadAsset(),编辑器下走 AssetDatabase 同步加载出模型
    element.Create();

    return element;
}

SetupAnimationTrack() —— 确保动画轨道就绪(有就复用、没有就建),绑定到模型的 Animator,可选地放入一段动画片段:

// CutsceneEditCore.cs
/// <summary>
/// 为指定模型创建或复用 AnimationTrack,并可选地放入一段动画 Clip
/// 传 clip=null 时只建空轨道和绑定关系,策划后续手动添加动画片段
/// </summary>
public void SetupAnimationTrack(GameObject model, AnimationClip clip)
{
    // 在 Timeline 的 "Role Group" 下,按模型名查找或创建子 GroupTrack
    // 这样同一个演员的所有轨道(BodyAnim、状态机、隐藏等)归到一个分组里,Timeline 窗口里一目了然
    GroupTrack groupTrack = currentTimeline.GetOrCreateTrackGroup(model.name, "Role Group");

    // 创建 AnimationTrack,轨道名带模型名前缀方便辨认
    string trackName = $"BodyAnim_{model.name}";
    AnimationTrack animTrack = currentTimeline.GetOrCreateTrack<AnimationTrack>(trackName, groupTrack);

    // 把轨道名记录到 Element 组件上,保存/加载时能重新找回对应关系
    RoleModelElement element = model.GetComponent<RoleModelElement>();
    element.animTrackName = trackName;

    // 绑定:告诉 Director 这条轨道要驱动哪个 Animator(bindGo 是模型根节点上的 Animator 所在对象)
    currentDirector.SetGenericBinding(animTrack, element.bindGo);

    // 两种调用场景:
    //   1. "+模型" 添加演员时传 null —— 只建空轨道 + 绑定,不放动画
    //   2. "+动作" 添加动画时传具体 clip —— 复用已有轨道,在上面创建一段动画片段
    if (clip != null)
    {
        TimelineClip shot = animTrack.CreateClip<AnimationPlayableAsset>();
        shot.displayName = clip.name;
        shot.duration = clip.length;
        (shot.asset as AnimationPlayableAsset).clip = clip;
    }
}

这样的分步设计有个好处:AddActorModel() 专注于创建 Element 节点和加载资源,后续的轨道操作(动作、状态机、隐藏、路径位移等)都是独立的 Core 方法,策划在面板里按需点按钮来调用。一个角色不一定需要所有类型的轨道,按需添加比一次性全建更灵活。

添加虚拟相机的流程

先说一下背景。Cinemachine 的工作方式是:场景里放一个挂了 CinemachineBrain 的主相机,再放若干个 CinemachineVirtualCamera(虚拟相机)。虚拟相机不是真正的 Camera,它只是一组参数(位置、旋转、FOV、Follow、LookAt 等),CinemachineBrain 根据当前激活的虚拟相机来驱动主相机。

在 Timeline 里,Cinemachine 提供了 CinemachineTrack,这条轨道绑定的是主相机上的 CinemachineBrain。轨道上可以放多段 CinemachineShot Clip——**每段 CinemachineShot 就是”在这个时间段内,用哪个虚拟相机拍”**。比如 03 秒用 [001]Camera 拍全景,35 秒用 [002]Camera 拍角色特写,Clip 之间还能自动做混合过渡。这就是过场里”镜头切换”的本质。

下面这张截图能看到完整的结果:

对照截图看几个关键位置:

  • Hierarchy(左上):CameraGroup 下有 [001]Camera ~ [005]Camera 五个子节点,每个挂了 CameraElement(我们自己的组件,记录编号、Follow/LookAt 目标等序列化数据)+ CinemachineVirtualCamera(Cinemachine 的虚拟相机组件)
  • Timeline(左下):最顶部 Cutscene Camera (CinemachineBrain) 就是 CinemachineTrack,上面排着多段 CinemachineShot Clip([001]Camera[003]VideoCamera[005]Camera),每段代表一个”镜头”。下方 Camera Group 里每个相机还有自己的 AnimationTrack,可以 K 相机运动动画
  • Inspector(右侧):选中 [005]Camera,能看到 CameraElement 上的字段和 CinemachineVirtualCamera 组件
  • 编辑器面板(中间):镜头面板列出所有相机,展开后可设置 Follow/LookAt 目标、路径轨道、缩放参数等

镜头面板点”+Virtual Camera”时,Core 的完整流程:

// CutsceneEditCore.cs
/// <summary>
/// 添加一个虚拟相机
/// 创建节点 → 挂 CameraElement + VirtualCamera → 在 CinemachineTrack 上创建 Shot Clip → 绑定
/// </summary>
public CameraElement AddCameraElement()
{
    // ---- 第一步:在 CameraGroup 下创建带编号的子节点 ----
    Transform cameraGroup = GetOrAddGroup("CameraGroup");
    int addNumber = CutsceneRoot.GetGroupNameNumber("CameraGroup");
    string combineName = $"[{addNumber:D3}]Camera";

    GameObject node = new GameObject(combineName);
    node.transform.SetParent(cameraGroup);

    // ---- 第二步:挂两个组件 ----
    // CameraElement:我们自己的组件,记录编号、clipName、Follow/LookAt 目标、路径等序列化数据
    CameraElement element = node.AddComponent<CameraElement>();
    element.no = addNumber;
    element.clipName = combineName;

    // CinemachineVirtualCamera:Cinemachine 的虚拟相机,控制 FOV、跟随、注视等相机参数
    CinemachineVirtualCamera vcam = node.AddComponent<CinemachineVirtualCamera>();
    vcam.Priority = 999;
    vcam.m_Lens.FarClipPlane = 1000;

    // ---- 第三步:在 CinemachineTrack 上创建一段 CinemachineShot Clip ----
    // CinemachineTrack 在 InstanceOne() 新建过场时就建好了,绑定的是主相机的 CinemachineBrain
    // 这里在上面创建一段 Shot Clip,意思是"过场播到这个时间段时,切换到这个虚拟相机"
    CinemachineTrack camTrack = (currentDirector.playableAsset as TimelineAsset)
        .FindTrack<CinemachineTrack>("Camera Track");
    TimelineClip clip = camTrack.CreateClip<CinemachineShot>();
    clip.displayName = combineName;

    // ---- 第四步:把虚拟相机绑定到这段 Clip 上 ----
    // 角色轨道的绑定是 Track 级别的:一条 AnimationTrack 绑一个 Animator
    // 相机轨道不一样——一条 CinemachineTrack 上有多段 Shot Clip,每段 Clip 绑不同的虚拟相机
    // 所以绑定关系在 Clip 级别,通过 ExposedReference<T> + GUID 机制实现:
    //   1. 生成一个唯一的 GUID 作为 key
    //   2. 把 GUID 写进 CinemachineShot 的 ExposedReference 字段
    //   3. 通过 Director.SetReferenceValue(guid, vcam) 建立 GUID → VirtualCamera 的映射
    //   4. 播放时 Director 根据 GUID 查表拿到对应的 VirtualCamera
    CinemachineShot shot = clip.asset as CinemachineShot;
    string uuid = GUID.Generate().ToString();
    shot.VirtualCamera = new ExposedReference<CinemachineVirtualCameraBase>()
    {
        defaultValue = vcam,
        exposedName = uuid
    };
    currentDirector.SetReferenceValue(uuid, vcam);

    return element;
}

总结一下角色和相机在绑定方式上的区别:

角色轨道(AnimationTrack) 相机轨道(CinemachineTrack)
绑定粒度 Track 级别:一条轨道绑一个 Animator Clip 级别:每段 CinemachineShot 各绑一个 VirtualCamera
API Director.SetGenericBinding(track, obj) Director.SetReferenceValue(guid, vcam)
原因 一条轨道只驱动一个角色 一条轨道上多段 Clip 在不同时间段切不同相机

这不是过场编辑器自己定的规则,而是 Cinemachine + Timeline 的标准用法。CinemachineShotExposedReference 都是 Cinemachine 包自带的。

Timeline 扩展方法

前面 Core 的伪代码里反复出现 GetOrCreateTrackGroup()FindTrack()GetOrCreateTrack<T>() 这些调用,它们不是 Timeline 原生 API,而是项目里写的一组扩展方法。封这一层的原因很直接——Timeline 原生查找轨道只有 GetOutputTracks() 返回一个 IEnumerable<TrackAsset>,每次想按名称找一条轨道都要写一遍 GetOutputTracks().Where(t => t.name == xxx).FirstOrDefault() 再判空,写多了就烦了。

// TimelineExtensions.cs
public static class TimelineExtensions
{
    /// <summary>
    /// 按名称查找 GroupTrack,找到就复用,没有就在 parent 下新建一个
    /// 用法:timeline.GetOrCreateTrackGroup("Role Group")
    /// </summary>
    public static GroupTrack GetOrCreateTrackGroup(
        this TimelineAsset timeline, string name, TrackAsset parent = null)
    {
        foreach (TrackAsset track in timeline.GetOutputTracks())
        {
            if (track is GroupTrack group && track.name == name)
                return group;
        }
        GroupTrack newGroup = timeline.CreateTrack<GroupTrack>(parent, name);
        return newGroup;
    }

    /// <summary>
    /// 重载:按名称在指定父 GroupTrack 下查找或创建子 GroupTrack
    /// 先按 parentGroupName 查到父 Group,再在其下查找或创建 name
    /// 用法:timeline.GetOrCreateTrackGroup("Spider_001", "Role Group")
    /// </summary>
    public static GroupTrack GetOrCreateTrackGroup(
        this TimelineAsset timeline, string name, string parentGroupName)
    {
        GroupTrack parent = GetOrCreateTrackGroup(timeline, parentGroupName);
        return GetOrCreateTrackGroup(timeline, name, parent);
    }

    /// <summary>
    /// 按名称和类型查找或创建 Track
    /// 用法:timeline.GetOrCreateTrack&lt;AnimationTrack&gt;("BodyAnim_Spider", groupTrack)
    /// </summary>
    public static T GetOrCreateTrack<T>(
        this TimelineAsset timeline, string trackName, GroupTrack group = null)
        where T : TrackAsset
    { ... }

    /// <summary>
    /// 按名称查找指定类型的 Track,找不到返回 null
    /// 用法:timeline.FindTrack&lt;CinemachineTrack&gt;("Camera Track")
    /// </summary>
    public static T FindTrack<T>(this TimelineAsset timeline, string name)
        where T : TrackAsset
    { ... }

    /// <summary>
    /// 按轨道名 + Clip 名定位某一段 TimelineClip
    /// 用法:timeline.FindTrackClip("Camera Track", "[001]Camera")
    /// </summary>
    public static TimelineClip FindTrackClip(
        this TimelineAsset timeline, string trackName, string clipName)
    { ... }

    /// <summary>
    /// 按轨道名查找 Track 并绑定对象,省掉先 FindTrack 再 SetGenericBinding 的两步操作
    /// 用法:director.BindTrack("BodyAnim_Spider", animator)
    /// </summary>
    public static void BindTrack(
        this PlayableDirector director, string trackName, Object bindObj)
    { ... }
}

这些扩展方法本身没什么复杂逻辑,就是把”查找 + 判空 + 创建/绑定”压成一行调用。但对 Core 里几十个操作方法来说,少写这些样板代码能让业务逻辑清晰不少。

保存与退出

编辑器的帧更新驱动

在讲保存之前,先说一个前提:CutsceneEditWindow 继承 EditorWindow,Unity 的 EditorWindow 在编辑器下也会每帧调 Update()(不需要进 Play 模式)。窗口的 Update() 会把帧更新分发给当前 Page 和 Core:

// CutsceneEditWindow.cs
public void Update()
{
    if (GetCurPage(curPageType) != null)
        GetCurPage(curPageType).Update();

    Core.TimeUpdate();
}

Core.TimeUpdate() 里做两件事:驱动 Timeline 预览推进(后面保存会用到),以及处理延时回调队列:

// CutsceneEditCore.cs
public void TimeUpdate()
{
    // 如果正在做 Timeline 预览推进(保存前触发),每帧推进时间轴
    TimelinePreviewUpdate();

    // 延时回调队列:某些操作需要等几帧再执行
    // ...
}

理解了这个驱动链(Window.Update()Core.TimeUpdate()TimelinePreviewUpdate()),下面的保存流程就好懂了。

保存

为什么不能直接保存

策划拖着时间轴预览到某一秒时,场景里的对象其实处于一个”中间帧”。拿一段 10 秒的过场举例,里面有这些内容:

  • NPC 守卫在第 3 秒被 ActivationTrack 隐藏(SetActive(false)
  • 主角从城门走到酒馆门口,第 5 秒正好在路上
  • 第 8 秒一把剑被动画轨道挪到桌子上

策划预览到第 6 秒,调了几个参数就点保存。PrefabUtility.SaveAsPrefabAsset() 直接把此刻 Scene 里的状态写进 Prefab——守卫是隐藏的、主角卡在路上、剑还在半空中。下次再打开这条过场,角色位置不对、守卫莫名消失,排查半天才发现是保存时机的问题。

那回到第 0 秒再保存行不行?也不靠谱。Timeline 预览模式对 Scene 做的临时修改,拖回第 0 秒后不一定能自动复原(比如上次预览到第 8 秒时挪了剑的位置,拖回去 Transform 可能还是脏的)。

所以保存前要做的事情是:把 Director.time 从 0 推到 duration,让 Timeline 完整走一遍。这里有两个目的:

  • 读取最终位置:过场播完后游戏要把玩家传送到过场终点继续自由行动。上面的例子里主角走到酒馆门口,这个坐标只有在 Director.time = duration 时才能从 Transform 上读到。time=0 读到的是城门口、time=5 读到的是路上,都是错的。推到末尾后由 SaveElementData() 把正确的终点位置写回配置,运行时业务层才知道该把玩家放哪。
  • 跑完再统一清理:推到末尾让所有轨道效果完整求值一遍,然后 SavePrefabData() 里统一做清理——SetActive(true) 恢复被隐藏的对象、Dispose() 释放临时资源——清理完才调 SaveAsPrefabAsset() 写磁盘。Prefab 里最终存的不是某一帧的快照,而是清理后的干净状态(所有对象可见、没有预览残留)。轨道和 Clip 的编排数据存在 .playable 文件里,和 Prefab 是分开的。

还有一点:这个”推到末尾”不能在一帧内同步完成。Timeline 的动画求值、ActivationTrackSetActive、Signal 的回调等都依赖编辑器逐帧 Evaluate,用 for 循环一口气把 Director.time 拉到末尾的话 Scene 里的对象来不及响应,保存出来的数据还是不对。所以只能逐帧推进,利用前面提到的 Window.Update()Core.TimeUpdate() 这条驱动链来完成。

TimelinePreviewUpdate()

Core 里维护了三个字段和一个逐帧推进方法 TimelinePreviewUpdate(),由 Core.TimeUpdate() 每帧调用。当 timelinePreview 标志位为 true 时,它会逐帧往前推 Director.time,推到末尾后触发 timelinePreviewAction 回调:

// CutsceneEditCore.cs

private bool timelinePreview;          // 是否正在逐帧快进
private Action timelinePreviewAction;  // 快进完成后要执行的回调
private float startTimelineTime;       // 帧间累加器,控制 Scene 刷新频率

private void TimelinePreviewUpdate()
{
    if (!timelinePreview) return;

    // 每攒够 0.03 秒,额外跳一个 0.03 的步进,同时刷新 Scene
    // 这样做有两个原因:
    //   1. 加速——光靠 deltaTime 累加是 1 倍速,30 秒的过场要等 30 秒才推完,
    //      额外跳 0.03 让整体大约 2 倍速,体感上几秒就能存完
    //   2. 限频——TimelineEditor.Refresh() 开销不小,每帧都刷编辑器会卡,
    //      攒 0.03 秒刷一次够用了
    if (startTimelineTime > 0.03f)
    {
        GetCurrentDirector().time += 0.03f;
        TimelineEditor.Refresh(RefreshReason.SceneNeedsUpdate);
        startTimelineTime = 0;
    }

    // 到末尾了,触发回调,关闭快进
    if (GetCurrentDirector().time >= GetCurrentDirector().duration)
    {
        timelinePreviewAction?.Invoke();
        timelinePreview = false;
        startTimelineTime = 0;
        return;
    }

    // 常规推进:每帧加 deltaTime
    startTimelineTime += Time.deltaTime;
    GetCurrentDirector().time += Time.deltaTime;
}

这段代码有几个值得留意的点:

  • **为什么要调 TimelineEditor.Refresh()**:手动改 Director.time 只是改了一个数字,Scene 里的对象不会自动跟着动。必须调一次 Refresh() 让 Timeline 重新 Evaluate,动画才会驱动角色 Transform、ActivationTrack 才会执行 SetActive、Cinemachine 才会切镜头。不刷的话,Director.time 推到末尾了但对象还停在原地,后面保存出来的 Prefab 状态还是错的。
  • 三段的执行顺序:先判断要不要刷新 → 再判断有没有到末尾 → 最后才累加时间。这样在到达 duration 的那一帧先触发回调再 return,不会多推一帧。
  • Director.time 会不会被加两次:在触发刷新的帧里确实会(+= 0.03+= deltaTime 都执行了),这不是 bug,就是为了加速。光靠 deltaTime 累加是 1 倍速,30 秒的过场得等 30 秒,额外跳 0.03 让整体推进接近 2 倍速。一段 5 秒的过场大约 2~3 秒就推完了。
Save()

有了上面的 TimelinePreviewUpdate()Save() 本身就很简单了——不做任何序列化,只是归零 Director.time、打开 timelinePreview 标志位、把真正的保存逻辑存进 timelinePreviewAction。后续每帧 TimelinePreviewUpdate() 自动推进,推到末尾时触发回调:

// CutsceneEditCore.cs
/// <summary>
/// 保存过场:归零时间轴、开启逐帧快进,推完后回调里做序列化
/// </summary>
public void Save(Action saveComplete)
{
    EditorUtility.DisplayProgressBar("存储数据", "相关数据正在存储中,请稍候...", 1);

    timelinePreview = true;
    startTimelineTime = 0;
    GetCurrentDirector().time = 0;

    // 这个回调会在 TimelinePreviewUpdate() 推到末尾时被触发
    timelinePreviewAction = () =>
    {
        // 推完后等 200ms 让 Scene 最后一帧的变更刷完,再做序列化
        TimeDelayAction(200f, () =>
        {
            SaveElementData();
            SavePrefabData();
            EditorUtility.ClearProgressBar();
            saveComplete?.Invoke();
        });
    };
}
SaveElementData()

前面说过,推到末尾的一个重要目的就是读取角色的最终位置。SaveElementData() 在这个时机遍历所有 Element,把需要持久化的运行时状态写回配置数据:

// CutsceneEditCore.cs
/// <summary>
/// 把过场结束时 Element 的运行时状态写回配置,供运行时业务层读取
/// </summary>
private void SaveElementData()
{
    BaseCutsceneElement[] elements = CutsceneRoot.GetAllElements();
    foreach (BaseCutsceneElement element in elements)
    {
        if (element is PlayerModelElement playerElement)
        {
            // isKeepTrans:配置开关,表示过场结束后是否保留角色位置
            // 有些过场结束后角色要留在终点,有些则恢复到过场前的位置
            if (playerElement.bindGo != null && playerElement.isKeepTrans)
            {
                Vector3 pos = playerElement.bindGo.transform.position;
                CurrentModelData.PlayerPos = $"{pos.x:F2},{pos.y:F2},{pos.z:F2}";
                CurrentModelData.PlayerOri = playerElement.bindGo.transform.eulerAngles.y.ToString("F2");
            }
        }
        // 队友、交互物等需要记录最终状态的 Element 同理
        // ...
    }
}
SavePrefabData()

配置数据写完之后,才轮到把 Prefab 序列化到磁盘。这里每一步都有对应的坑,跳过任何一步都会出问题:

// CutsceneEditCore.cs
/// <summary>
/// 清理预览残留 → 标脏 TimelineAsset → 保存 Prefab → 重新加载
/// </summary>
private void SavePrefabData()
{
    // 快进过程中"隐藏轨道"可能把某些对象 SetActive(false) 了
    // 不恢复就保存的话,这些对象在 Prefab 里就是隐藏状态,下次打开就看不见
    // Dispose() 让各 Element 释放预览期间创建的临时资源
    foreach (BaseCutsceneElement element in CutsceneRoot.GetAllElements())
    {
        element.gameObject.SetActive(true);
        element.Dispose();
    }

    // TimelineAsset(.playable)在编辑器里改了不一定自动标脏
    // 不 SetDirty() 的话 SaveAssets() 不会把改动写进磁盘
    // 症状就是:加了新轨道或调了 Clip 时长,保存后重开全没了
    foreach (TimelineAsset timeline in CutsceneRoot.GetAllTimelines())
        EditorUtility.SetDirty(timeline);

    // Prefab 覆盖写回 + 所有标脏资产写入磁盘 + 刷新 AssetDatabase
    PrefabUtility.SaveAsPrefabAsset(root, GetAssetFullPath(CurrentModelData));
    AssetDatabase.SaveAssets();
    AssetDatabase.Refresh();

    // 保存完重新 Load 一次
    // 当前 Scene 里的实例是之前 UnpackPrefab 解包出来自由编辑的
    // 和刚写入磁盘的 Prefab 之间可能存在引用差异
    // 重新 Load = 销毁实例 + 从 Prefab 重新实例化 + 重新绑定 Director
    // 相当于"关掉重开",保证后续继续编辑时不会有脏数据
    Load(CurrentModelData);
}

整个保存链路:点击保存 → Save() 归零时间、开启快进标志 → 每帧 TimelinePreviewUpdate() 推进 Director.time → 推到末尾触发 timelinePreviewAction → 等 200ms → SaveElementData() 写回配置 → SavePrefabData() 清理、标脏、写 Prefab、重新加载。

退出

点”退出”走的是 Core.Exit(),把编辑现场清理干净就行,比保存简单多了:

// CutsceneEditCore.cs
public void Exit()
{
    // 销毁场景里的过场实例(Load 时实例化进来的那个 root)
    if (root != null)
        Object.DestroyImmediate(root);
    root = null;
    CutsceneRoot = null;

    // 关闭 buildScene(过场编辑专用场景)
    EditorSceneManager.CloseScene(buildScene, true);

    ClearTimelineWindow();
    DestroyCutSceneEdit();
}

/// <summary>
/// 解锁 Timeline 窗口
/// Load 时通过 GetOrCreateWindow() 打开并锁定了窗口,退出时要解锁 + 清空
/// 不然窗口还指向已销毁的 Director,点什么都没反应
/// </summary>
private void ClearTimelineWindow()
{
    TimelineEditorWindow window = TimelineEditor.GetWindow();
    if (window != null)
    {
        window.locked = false;
        window.ClearTimeline();
    }
}

/// <summary>
/// 清理编辑辅助物体(Load 时在 buildScene 里实例化的预览用 GameObject)
/// </summary>
private void DestroyCutSceneEdit()
{
    GameObject editShow = GameObject.Find("CutSceneEditShow");
    if (editShow != null)
        Object.DestroyImmediate(editShow);
}

编辑器预览:CutsceneBuild 场景

前面多次提到 CutsceneBuild 场景,这里说一下它存在的原因。

过场编辑会实例化 Prefab、加载角色模型、创建虚拟相机,这些东西不能直接丢进策划正在编辑的游戏场景里,不然会互相干扰。所以专门建一个 CutsceneBuild 场景做隔离——进编辑器时 Additive 打开、退出时关掉,里面的东西不会污染游戏场景。

另一个问题是资源加载。5.3 里讲过 CutsceneLoader.LoadAsset() 根据 InGame 走两条路——运行时异步、编辑器下同步。这里的 InGame 判断依据就是 CutsceneTool.InEdit,它的逻辑很简单:检查当前打开的 Scene 列表里有没有 CutsceneBuild——有就说明在编辑器里编辑过场,走 AssetDatabase.LoadAssetAtPath 同步加载。编辑器下加载出的实例会标 HideFlags.DontSave,防止被意外保存到场景文件里。

Element 的 Create() 里只管调 CutsceneRoot.LoadAsset()(内部委托给 CutsceneLoader),不需要自己写环境判断分支。编辑器预览和运行时播放跑的是同一套 Element 逻辑,底层加载方式自动切换。再配合 [ExecuteInEditMode],Element 的 Update() / LateUpdate() 在编辑器下也能每帧执行,拖动 Timeline 时间轴就能看到动画预览效果。


5.7 效果展示

前面讲了一大堆架构和代码,最后放一段实际的编辑效果。下面这个 GIF 是策划同学用这套编辑器制作的一段过场——左上角 Scene 窗口能看到 3D 场景里的角色和相机运动,左下角 Game 窗口是实际的过场画面(带对话字幕),右上方是 Timeline 窗口的轨道编排,右下方是我们自己的编辑器面板:

整段过场的镜头切换、角色动画、对话字幕、特效触发全部在 Timeline 上编排,策划在编辑器面板里操作,不需要写任何代码。工具链搭好之后,做过场演出更多是一个美术和策划发挥的事情——程序提供能力,TA 和策划负责效果。实际项目里有专门做过场的同学,用这套工具做出来的效果比我这个程序员调的好看太多了。


5.8 总结

这篇在 Timeline 之上搭了几个模块:

  • CutsceneRoot:过场总控,一个过场 = 一个 Prefab,管加载 → 播放 → 回收的完整链路,以及运行时相机绑定和自定义镜头混合表
  • CutsceneElement:参与者抽象,统一了相机、角色、特效、路径的资源加载和轨道绑定生命周期
  • 自定义轨道体系:BaseTrack 四件套,把十几种轨道的样板代码压到基类里,子类只写业务逻辑
  • SubCutscene:子过场管理,解决长过场的编辑器卡顿、多人冲突和对话分支跳转
  • 编辑器工具链CutsceneEditWindow + CutsceneEditCore,策划在功能面板里点按钮,Core 在底下操作 Timeline API

回头看,真正自己写的代码量不大,每一块几百行,Timeline 和 Cinemachine 本身提供了大部分能力。要做的就是把资源生命周期、轨道样板代码、过场拆分、可视化工具这几件事想清楚,加一层尽量薄的抽象。前面几篇写的 Playable 基础、Timeline 源码浅析、PlayableDirector API,到这里算是全串起来了——知道底层怎么建图、怎么评估权重、RuntimeClip 和 IntervalTree 怎么调度 Clip,写自定义轨道和排查问题的时候心里有底。



转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏