【VFX工具链源码拆解 04】拯救丢失的绑定:Timeline 一键克隆与层级相对路径重映射算法

(本文为《Unity VFX 提效工具源码拆解》系列第 4 篇,承接第 3 篇材质字典映射批处理与智能纹理槽识别,继续讨论 Timeline 变体复制中的对象绑定问题。)

一、业务背景:复制一场演出,需要同时复制什么

节点副本和资产副本不是同一件事

假设一个技能的场景根节点叫 Skill_A,上面挂着 PlayableDirector,子节点 Body/Hand_R/Sword 由时间轴控制。特效师希望复制一份,制作第二段技能:时间、轨道和剑的运动可以调整,但第一段技能应继续使用原来的配置。

只在 Hierarchy 中复制 Skill_A,新节点上的导演仍可能使用原来的 .playable。两名导演共用一份演出资产,修改轨道内容就可能影响两边。只复制 .playable 又不够:新资产产生了新的轨道对象,而导演保存的绑定表仍需要建立"这条新轨道控制哪个场景对象"的关系。

在 Hierarchy 中复制挂有 PlayableDirector 的特效后,对照新旧节点的 Timeline 引用与绑定槽,可以看到"复制节点"和"复制演出资产"是两件事。动图用于对照原生复制流程的引用问题:检查对象时,既要看 .playable 是否独立,也要看轨道槽位是否仍指向旧层级。

先区分三种对象,才能读懂传参

TimelineAsset 是 Project 中的演出资产,保存轨道、片段和时间数据。TrackAsset 是其中的轨道对象,既出现在 Timeline 结构中,也作为 Generic Binding 的键。PlayableDirector 是场景组件:它引用 Timeline 资产,并维护轨道键到场景对象或组件的绑定关系。

因此,本文的输入不是孤立的 .playable 文件,而是已经挂载该文件、具有现场绑定关系的源导演。源导演同时提供"复制哪份资产""复制哪棵节点树""读取哪张绑定表"三个入口。

用户需要提供的输入和最终输出

在 TimelineDuplicatorView 中,用户指定 sourceDirector、outputFolder 和 newTimelineName。源导演变化时,OnSourceDirectorChanged() 会用源资产目录和名称初始化后两个字段;按钮调用 DuplicateAndRebind() 后,先验证导演和 Timeline 资产是否存在。

这次操作预期输出一份新的 .playable、一个场景克隆节点和写入克隆导演的绑定关系。它不会克隆场景里所有被引用的演员,也不会自动复制 AnimationClip、材质等全部外部依赖。理解这一范围,才能判断后面的"复用外部目标"是否符合制作需求。

二、入口如何把面板输入变成两份副本

先保存源资产,再生成不覆盖已有文件的路径

DuplicateAndRebind() 在校验后对源资产调用 SetDirty 和 SaveAssets,再开始磁盘复制。这里的目的,是先提交当前可保存的修改,减少直接复制磁盘文件时漏掉内存修改的风险;这并不是对所有子资产状态的一次独立完整性校验。

下面是路径处理和资产复制的原文。此时 sourceAsset 已从 sourceDirector.playableAsset 转成 TimelineAsset,outputFolder、newTimelineName 则来自面板。

csharp 复制代码
// CopyAsset / ImportAsset:以下保留实际处理语句,外围上下文见正文。
string sourcePath = AssetDatabase.GetAssetPath(sourceAsset);
if (string.IsNullOrEmpty(outputFolder) || !AssetDatabase.IsValidFolder(outputFolder))
{
    outputFolder = Path.GetDirectoryName(sourcePath).Replace("\\", "/");
}

string safeName = string.IsNullOrEmpty(newTimelineName) ? sourceAsset.name + "_Clone" : newTimelineName;
string newPath = AssetDatabase.GenerateUniqueAssetPath($"{outputFolder.TrimEnd('/')}/{safeName}.playable");

if (!AssetDatabase.CopyAsset(sourcePath, newPath))
{
    Debug.LogError("Timeline 资产复制失败!");
    return;
}

AssetDatabase.ImportAsset(newPath, ImportAssetOptions.ForceSynchronousImport);
TimelineAsset clonedAsset = AssetDatabase.LoadAssetAtPath<TimelineAsset>(newPath);
if (clonedAsset == null)
{
    Debug.LogError($"无法加载新复制的 TimelineAsset: {newPath}");
    return;
}

sourcePath 是 Unity 资产路径。若用户填写的输出目录不是有效资产文件夹,代码回退到源资产目录。safeName 只处理空名称,没有额外过滤文件名中的非法字符;GenerateUniqueAssetPath 负责目标重名时换一个可用路径,不能把它当成通用输入校验器。

CopyAsset 返回布尔值,失败便停止后续操作。成功以后同步导入,再加载成 TimelineAsset,让下一步使用的是 Unity 已经识别的新资产对象。这里顺序不能颠倒:只有拿到新轨道所在的资产,才能把它与源轨道配对。

克隆导演所在的节点树,并把新资产交给新导演

资产副本准备好后,仍缺少一个承载新绑定关系的场景导演。源码克隆源导演所在的 GameObject,取得新导演,然后把两端的资产、导演和根节点一起交给 RemapBindings。

csharp 复制代码
// 在 Hierarchy 中创建新节点(克隆源 Director 节点)
GameObject clonedGo = Object.Instantiate(sourceDirector.gameObject, sourceDirector.transform.parent);
clonedGo.name = sourceDirector.gameObject.name + "_Clone";
if (sourceDirector.gameObject.scene.IsValid())
{
    SceneManager.MoveGameObjectToScene(clonedGo, sourceDirector.gameObject.scene);
}
Undo.RegisterCreatedObjectUndo(clonedGo, "Duplicate Timeline Director");

PlayableDirector clonedDirector = clonedGo.GetComponent<PlayableDirector>();
Undo.RecordObject(clonedDirector, "Assign Cloned TimelineAsset");
clonedDirector.playableAsset = clonedAsset;

// 【修复 2】执行完整的主/子轨道重绑定(支持子节点相对路径 + 跨根节点平行查找 + 智能自愈兜底)
int boundCount = RemapBindings(sourceAsset, clonedAsset, sourceDirector, clonedDirector, sourceDirector.transform, clonedGo.transform);

这段调用中有三组容易混淆的参数。sourceAsset/clonedAsset 提供两边轨道;sourceDirector/clonedDirector 分别读旧绑定、写新绑定;sourceDirector.transform/clonedGo.transform 定义层级路径的起点和终点。把这三组对象分开传递,后面的算法就不需要自行猜测"当前正在复制哪一侧"。

Instantiate 带入源父节点,使副本沿用原来的父子位置关系。但 MoveGameObjectToScene 只接受场景根对象;源导演有父节点时,克隆对象也有父节点,当前仅检查 scene.IsValid() 并未满足这一前提。该调用又位于 Undo 登记和重绑定之前,若在此报错,后续流程便无法完成。Undo 记录场景对象创建和导演赋值,但不代表生成的磁盘文件也构成完整可回滚事务。

三、用平行遍历建立"源轨道---新轨道"对应关系

相同索引连接两份结构一致的 Timeline

进入 RemapBindings() 后,方法分别把 GetOutputTracks() 的返回值转成列表。原文件刚被复制,通常保留相同的轨道结构,因此实现选择按索引配对,而不是在每一条轨道上重复搜索名称。

csharp 复制代码
// RemapBindings:以下保留实际处理语句,外围上下文见正文。
private int RemapBindings(TimelineAsset sourceAsset, TimelineAsset clonedAsset, 
                          PlayableDirector sourceDir, PlayableDirector clonedDir, 
                          Transform sourceRoot, Transform clonedRoot)
{
    int successCount = 0;
    var sourceTrackList = new List<TrackAsset>(sourceAsset.GetOutputTracks());
    var clonedTrackList = new List<TrackAsset>(clonedAsset.GetOutputTracks());
    Scene targetScene = clonedDir.gameObject.scene;

    int count = Mathf.Min(sourceTrackList.Count, clonedTrackList.Count);
    for (int i = 0; i < count; i++)
    {
        var sTrack = sourceTrackList[i];
        var cTrack = clonedTrackList[i];

        if (BindSingleTrackPair(sTrack, cTrack, sourceDir, clonedDir, sourceRoot, clonedRoot, targetScene))
        {
            successCount++;
        }

        successCount += RemapChildTracks(sTrack, cTrack, sourceDir, clonedDir, sourceRoot, clonedRoot, targetScene);
    }
    return successCount;
}

sourceTrackList[i] 与 clonedTrackList[i] 是这一轮配对的两个键。BindSingleTrackPair 先执行 sDir.GetGenericBinding(sTrack) 取得旧绑定,再把它送入 ResolveTargetObject;原引用为空或定位失败时,才尝试 SmartFindTargetInScene。找到非空结果后,执行 cDir.SetGenericBinding(cTrack, newBinding),将新目标挂到新轨道键上。

这个返回值只表示方法找到目标并调用了设置绑定。它没有检查演出播放结果,也没有证明每条轨道最终绑定的类型都符合自定义轨道要求。successCount 因而是本次设置成功返回的计数,不能代替逐槽复核。

子轨道沿着对应的父轨道继续递归

主层级配对以后,RemapChildTracks 接收当前的两个父轨道。它取得各自的孩子,再使用同样的索引规则处理下一层;每个孩子又递归进入自己的孩子。这使嵌套轨道不会被当作一个没有上下文的平铺名称列表。

csharp 复制代码
// RemapChildTracks:以下保留实际处理语句,外围上下文见正文。
private int RemapChildTracks(TrackAsset sParent, TrackAsset cParent, 
                             PlayableDirector sDir, PlayableDirector cDir, 
                             Transform sRoot, Transform cRoot, Scene targetScene)
{
    int count = 0;
    var sChildren = new List<TrackAsset>(sParent.GetChildTracks());
    var cChildren = new List<TrackAsset>(cParent.GetChildTracks());

    int minLen = Mathf.Min(sChildren.Count, cChildren.Count);
    for (int i = 0; i < minLen; i++)
    {
        if (BindSingleTrackPair(sChildren[i], cChildren[i], sDir, cDir, sRoot, cRoot, targetScene))
        {
            count++;
        }
        count += RemapChildTracks(sChildren[i], cChildren[i], sDir, cDir, sRoot, cRoot, targetScene);
    }
    return count;
}

这里的 count 是当前递归分支累计的成功次数,minLen 才是本层需要遍历的对子数。一次循环先处理当前孩子,再累计它下面的结果;没有子轨道时,列表长度为零,循环自然结束,递归返回。

两处 Mathf.Min 都只防止访问超出较短列表的索引。它们没有检测两侧轨道是否重排、类型是否改变,也不会报告多出来的轨道。平行遍历适用于"刚复制、结构未改"的两份资产;如果目标时间轴已经被人工重构,就应使用后文的修复入口,并检查匹配依据。

四、把旧绑定转换成新层级中的目标

先保留对象的身份,再寻找它的位置

ResolveTargetObject 接到的是 UnityEngine.Object boundObj,而不是现成的路径字符串。如果它是 GameObject,方法取出 transform;如果它是组件,还会保存 comp.GetType(),记住原来绑定的是哪种组件。其他对象则直接返回原引用,保持共享。

例如源轨道绑定 Sword 上的某个组件,路径负责找新 Sword,组件类型负责找新 Sword 上的对应组件。这两件事分开以后,既能复制层级位置,也尽量保留绑定语义。若目标是 AnimationTrack 且还没有记录组件类型,源码会指定 Animator。

从叶节点向上回溯,构造相对于源根的路径

节点在源导演子树内时,不必扫描整个场景。FindByRelativePath 从源目标向父级回溯,把名称前插到路径中,直到遇到 sourceRoot,再到 clonedRoot 下查同一路径。

csharp 复制代码
if (sourceTarget == null) return null;
if (sourceTarget == sourceRoot) return clonedRoot;

// 1. 尝试回溯到 sourceRoot(检查是否为 sourceDirector 的内部子孙节点)
string relPath = sourceTarget.name;
Transform curr = sourceTarget.parent;
while (curr != null && curr != sourceRoot)
{
    relPath = curr.name + "/" + pathClean(relPath);
    curr = curr.parent;
}

if (curr == sourceRoot && clonedRoot != null)
{
    Transform foundInClone = clonedRoot.Find(relPath);
    if (foundInClone != null) return foundInClone;
}

以 Skill_A/Body/Hand_R/Sword 为例:relPath 起初是 Sword,第一轮变成 Hand_R/Sword,第二轮变成 Body/Hand_R/Sword。遇到 Skill_A 时循环停止,源根名称不会进入相对路径;即使克隆根叫 Skill_A_Clone,仍能执行 clonedRoot.Find("Body/Hand_R/Sword")。Unity 的 Transform.Find 可以按路径定位未激活的子节点,因此被时间轴暂时关闭的特效也能参与映射;它不会在缺少完整路径时自动做递归同名搜索。

sourceTarget == sourceRoot 单独处理,因为目标就是根时应直接返回克隆根,不能再去找一个同名孩子。pathClean 在当前源码中原样返回参数,没有转义或清洗作用。名称中的斜杠、重复的同名兄弟节点以及复制后的层级变动,都会影响路径的唯一性或可达性。

外部对象从场景根路径开始查找

如果一直回溯到 null 都没遇到源根,说明绑定对象不在源导演子树内。例如导演在 Skill_A 下,角色却位于平行根 场景/标准男。这时累积出的 relPath 包含场景根名,源码遍历 targetScene.GetRootGameObjects(),先匹配根,再用剩余子路径定位。

根名比较允许通过 CleanCloneSuffix 去掉 (Clone) 和 _Clone;子路径仍按原字符串查找。整条路径找不到时,再用 FindDeepChildInScene 深度寻找同名节点。最后,若原对象本来就属于目标场景,则可能直接返回原对象。

这个查找顺序是"结构优先,名称兜底,必要时共享原对象"。深度查找返回首个匹配,没有同名候选列表或人工确认。因此,外部角色仍可能由新旧两名导演共同控制。真正要求副本独立的对象,应纳入复制子树,或给工具补充明确的外部对象映射。

找到 Transform 后,还要取出轨道需要的绑定对象

路径方法返回的是节点位置,ExtractBindingFromTransform 才决定写入槽位的对象。下面是类型恢复的原文,输入分别是定位到的 target、目标轨道 track 和源绑定留下的 preferredComponentType。

csharp 复制代码
private Object ExtractBindingFromTransform(Transform target, TrackAsset track, System.Type preferredComponentType)
{
    if (target == null) return null;

    if (track is AnimationTrack)
    {
        Animator anim = target.GetComponent<Animator>();
        if (anim == null) anim = target.GetComponentInChildren<Animator>(true);
        // 若目标模型上忘记挂 Animator,自动为其补齐 Animator 组件以确保插槽成功挂载!
        if (anim == null) anim = Undo.AddComponent<Animator>(target.gameObject);
        return anim;
    }

    if (preferredComponentType != null && typeof(Component).IsAssignableFrom(preferredComponentType))
    {
        Component comp = target.GetComponent(preferredComponentType);
        if (comp != null) return comp;
    }

    return target.gameObject;
}

动画轨道优先取节点自身的 Animator,再查含未激活子节点的层级;仍不存在时,实际添加一个 Animator,并登记组件创建撤销。这不是只读搜索,也不保证新增 Animator 已配置好控制器或 Avatar,制作人员仍需核对角色行为。

非动画轨道只在目标节点自身尝试原组件类型,找不到便返回 GameObject。这一回退有助于对象类绑定继续工作,但并不实现所有自定义轨道的类型适配。代码没有依据每种轨道的绑定声明建立完整类型校验表。

在复制面板指定源 PlayableDirector、输出目录和新名称后,点击"复制 Timeline 并自动重绑定"。流程先生成独立资产与场景副本,再把源绑定解析到对应目标,写回新导演的轨道槽位。完成后检查选中的克隆节点与 Timeline 槽位,确认子树内对象已经指向新层级,并单独核对外部目标。

五、已有场景丢失绑定时,如何进入修复流程

目标导演按明确优先级选取

ReloadBindings() 不再复制资产,而是修改已有导演。它先取面板的 targetDirectorToReload;未填时,从当前选中对象或其父级寻找导演;仍没有时回退到 sourceDirector。目标 Timeline 校验通过后,方法记录导演的 Undo,并递归收集输出轨道及孩子。

若另有一个不同于目标的 sourceDirector,它会成为参考源。修复循环跳过 GroupTrack 和 MarkerTrack,再按三步找目标:参考源绑定、当前绑定对象的位置、轨道与片段名称线索。

修复入口按名称与类型匹配,再回退到索引

下面这段位于修复循环中,此时 tTrack 是待修复的目标轨道,i 是它在递归收集列表中的索引。与新副本的平行遍历相比,这里先寻找同名同类型轨道,再考虑相同索引。

csharp 复制代码
// 优先按同名且同类型轨道匹配,其次按相同索引匹配
TrackAsset matchedSrcTrack = allSourceTracks.FirstOrDefault(st => st.name == tTrack.name && st.GetType() == tTrack.GetType());
if (matchedSrcTrack == null && i < allSourceTracks.Count && allSourceTracks[i].GetType() == tTrack.GetType())
{
    matchedSrcTrack = allSourceTracks[i];
}

FirstOrDefault 找的是第一条满足条件的源轨道,因此同名同类型轨道仍会产生歧义。索引兜底增加了类型判断,但没有验证父子语义。找到参考轨道后,仍调用同一个 ResolveTargetObject,使复制与修复遵循一致的节点定位规则。

若 onlyFixEmptyBindings 为真,代码跳过的是 IsValidSceneBinding 判断为有效的绑定:对象或组件必须属于目标场景,其他非空对象按有效处理。因此名称虽然叫"仅填充空插槽",来自其他场景的非空绑定也可能继续进入修复。跳过的有效绑定会计入 fixedCount,界面数字包含保留的正确绑定,并非全部都是这次新修好的槽位。

名称兜底包含项目约定,不是通用语义识别

SmartFindTargetInScene 会读取轨道名和首个有效 AnimationPlayableAsset 的动画片段名,去除 _Track 等约定后缀,随后处理"标准男""标准女""相机"以及带 _m/_f 后缀的挂件。常规分支先在导演子树中找,再在全场景做名称和关键词搜索;最后尝试从动画名中提取模型前缀。

这些规则把项目已有命名惯例编码成搜索线索。迁移到其他项目时,规则需要随资产命名一起调整;它没有置信度评分,也没有在多个候选间进行结构验证。片段名称只是提供线索,不会把片段自身的全部引用同步复制。

六、完成操作以后,哪些结果需要实际检查

保存、评估与重建图不能混为一谈

复制入口最后标记新 Timeline、导演和场景为已修改,调用 SaveAssets(),选中克隆节点并定位新资产。场景只是被标记需要保存,并没有在这里自动写入场景文件。修复入口则在非播放状态下调用 targetDir.Evaluate(),使当前时间点得到评估。

当前两个入口没有调用 RebuildGraph()。源码注释中出现"重建 PlayableGraph"的描述,不能代替实际调用;说明实现时应以执行语句为准。

Generic Binding 不覆盖所有片段引用

本篇遍历的是轨道,并通过 GetGenericBinding/SetGenericBinding 处理导演绑定。ControlPlayableAsset 等片段中的 ExposedReference 不在这条重映射链中;源码没有逐 Clip 读取和重写这些暴露引用。使用外部控制对象、嵌套导演或自定义 Playable 资产的时间轴,需要另行检查对应引用表。

这与第 6 篇的资产深拷贝管线属于两类引用修复:本篇把轨道绑定转到场景对象,第 6 篇把受支持组件的材质与网格引用转到磁盘副本,不能相互替代。

CopyTimelineBoard 当前只提供数据容器

CopyTimelineBoard.cs 只有 15 行业务结构:TimelineBindData 保存 TrackName、TargetHierarchyPath 和 BoundComponentType,CopyTimelineBoard 保存记录列表。当前源码没有围绕它实现"捕获绑定---保存剪贴板资产---解析路径---粘贴绑定"的调用链,因此不能将它描述为已经完成的跨场景复制粘贴工具。

后续若扩展该容器,首先应明确同名轨道如何消歧、组件类型如何解析以及路径失效时如何反馈。这些信息能让人工修复更可检查,也比继续扩大模糊名称搜索更适合跨项目迁移。

用一个具体目标核对整条数据链

执行后可以从源剑节点追踪到终点:源导演读到 Sword 的绑定,源与新轨道按结构配对,路径方法生成 Body/Hand_R/Sword,克隆根找到新剑,类型恢复方法取得需要的对象,最后新导演以新轨道为键保存绑定。确认这条链成立,再抽查外部演员、动画组件和子轨道。

最终要核对的是新资产文件、克隆层级和槽位实际目标三者,而不只是 Console 中的成功数量。它们分别证明文件被复制、场景对象被建立,以及演出关系被接回了预期对象。


下一篇进入资源库查询与浏览:【VFX工具链源码拆解 05】打造流畅的特效资产库:百万级工程防卡顿检索与 IMGUI 虚拟列表。从扫描和缓存到搜索防抖、可视区域裁剪,再看收藏数据如何保存和共享。

相关推荐
1-1=01 小时前
C# 反射
c#·.net·.netcore
ACP广源盛139246256732 小时前
选型笔记:HDMI2.1 四进一出切换中继方案 GSV6705@ACP 评估
笔记·硬件架构·硬件工程·视频·国产芯片
步步为营DotNet2 小时前
.NET 可观测性选型:Metrics、ActivitySource、EventSource 到底该用哪个
c#·.net·metrics·可观测性
小小数媒成员2 小时前
Unity UGUI布局组件(LayoutGroup、ContentSizeFitter、LayoutElement)详解
unity·游戏引擎
tealcwu2 小时前
【Unity基础】Unity输入与Time.deltaTime的使用原则
unity·游戏引擎
chen_22712 小时前
godot做仪表hmi
游戏引擎·godot
吃一碗粉只付一碗钱13 小时前
16-SqlSugar多库ConfigId与按实体选连接
sqlserver·c#
rockey62715 小时前
AScript之字符串模板
c#·.net·script·expression·动态脚本
爱聊灵光的维C15 小时前
从看见世界到重构认知:在视觉与硬件的交汇处,寻找我们这代人的北极星
c语言·物联网·计算机视觉·重构·硬件工程·射频工程