资产全生命周期:从创作数据到可见结果

自动关联目录:资产全生命周期:从创作数据到可见结果

资产系统最容易被误解成“根据路径把文件加载进内存”。但在支持创作、远端内容和多运行目标的客户端里,真正的问题是:

怎样让一个逻辑资产经过编辑、依赖收集、构建、交付、缓存、解码和实体化后,在正确的生命周期内以正确的质量可见;任一步失败时,又不破坏已有的可用状态。

因此,资产方案的终点不是 DownloadCompleted 或 Loaded,而是 Visible Ready:玩家需要的最小完整状态已经呈现,交互、碰撞和表现之间没有不可解释的空窗。

业务问题不是“把文件下载下来”

一条资产链路同时服务五类需求:

需求用户真正关心的结果系统必须处理的约束
创作者编辑引用稳定、预览可信、改动可撤销源数据可读、引用可检查、缺失可定位
发布构建相同输入得到可验证结果冻结快照、确定性、依赖闭包、原子提交
玩家进入内容尽快达到可玩和可见优先级、占位、缓存、异步、首帧提交
多平台运行功能一致,质量和成本可降级平台格式、质量档、可选数据、兼容 Reader
故障恢复重试不能让状态更差分类错误、临时文件、校验、回滚、旧版本保留

如果只关注文件下载,下面这些状态会全部被压成一个 bool loaded:

  • 找到了逻辑资产,但没有适合当前平台的制品。
  • 主文件已在缓存中,但依赖缺失。
  • 字节已经下载,但完整性校验失败。
  • 解码完成,但主线程尚未提交运行时对象。
  • 运行时对象已经创建,但材质或纹理还没达到最低可见要求。
  • 请求完成时,原来的世界、角色或 UI 已被销毁。

loaded 无法表达这些差异,也就无法决定重试、降级、告警和回收。

先分清七种“资产”

同一个“资产”在生命周期中至少有七种表示:

flowchart LR
    A[可编辑源数据] --> B[逻辑资产身份]
    B --> C[依赖图与构建输入]
    C --> D[目标平台制品]
    D --> E[交付单元]
    E --> F[本地缓存条目]
    F --> G[内存中的已准备资源]
    G --> H[场景中的可见表现]
表示为谁优化是否适合作为长期真相
可编辑源数据创作者、版本比较、迁移是,但不能直接运行
逻辑资产身份内容引用、脚本、网络消息是,应与物理路径解耦
构建输入快照可重复构建只在一次构建中冻结
目标平台制品特定 Reader 与设备能力不可变,但有版本和目标范围
交付单元下载、安装、校验和淘汰不应成为编辑身份
缓存条目本地复用和恢复是派生数据,可删除重建
运行时对象模拟或呈现生命周期短,不可写回源数据

很多架构问题都来自混用:用编辑器路径充当网络身份、用缓存目录判断资产版本、把运行时对象状态写回发布数据,或者让发布制品反向承担编辑功能。

架构演进

阶段 A:路径与包驱动

早期最自然的方案是:资产跟随工程目录,用路径引用;构建时按目录、表格或业务包分组;进入某个场景或功能时下载并挂载对应包。

这个方案在以下条件下是合理的:

  • 内容主要由内部生产,路径和命名受控。
  • 目标平台少,资产格式相对统一。
  • 大部分依赖在构建期可静态发现。
  • 内容和客户端版本共同发布。
  • 业务包数量可人工维护。

它的优势也很明确:编辑器原生支持好,引用直观,Cook 和打包工具成熟,出问题时可以直接查目录和包。

但这里存在两个不同的依赖系统:

静态依赖:资产 A 的材质、纹理、骨架等引用链
业务依赖:进入某功能时,代码主动要求下载的一组内容

静态依赖分析无法发现所有业务触发;业务分包表又不一定知道真实的资产引用闭包。二者长期并存后,容易出现“包配置看起来完整,运行时仍缺资源”或“为了保险把大量不需要的内容一起下载”。

规模拐点:路径不再是身份

当内容开始独立生产、远端获取并跨多个运行目标复用时,原假设逐步失效:

  • 同一个逻辑资产在不同平台对应不同编码和可选数据。
  • 资产可以改名、迁移或由工具生成,路径不再稳定。
  • 玩家内容引用的资产不一定已经存在于客户端工程中。
  • DS 需要碰撞或规则数据,但不需要纹理、材质和渲染网格。
  • 一个资产的依赖可能继续引用远端资产,构建期无法总是展开完整闭包。
  • 旧内容、旧缓存、新制品和新 Reader 必须同时兼容。
  • 下载、解码和实例化已经成为并行流水线,生命周期竞态成为常态。

此时常见症状不是某一个 API 慢,而是身份和状态失控:

症状更深层原因
改路径后历史内容失效路径被当成长期身份
同一资产重复下载或重复解码请求没有按逻辑身份与目标制品去重
切换下载后端后读不到缓存写入路径、扫描路径和命名规则不是同一合同
下载成功仍显示占位Downloaded 被误当成 VisibleReady
切换世界后旧资源覆盖新对象异步请求没有所有者 Generation
某平台正常、另一平台损坏制品身份未包含目标和格式能力
构建失败留下半个发布包输出直接写共享目录,没有事务提交
缺一个次要资源导致整个内容不可用没有区分必需依赖和可降级依赖

平台化阶段:身份与制品分离

下一阶段通常引入稳定的逻辑资产 ID,再通过 Resolver 选择物理制品:

AssetRef
  -> Resolve(target, quality, readerVersion)
  -> ArtifactDescriptor
  -> Acquire
  -> Verify
  -> Decode
  -> Materialize

这解决了路径漂移和跨平台选择,但也引入了新的状态:

  • 逻辑资产存在,不代表当前目标制品存在。
  • Resolver 成功,不代表下载成功。
  • 下载成功,不代表 hash、大小或签名正确。
  • 字节正确,不代表 Reader 支持格式。
  • Reader 支持,不代表依赖齐全。
  • 对象已准备,不代表请求所有者仍有效。

如果只是把路径替换成数字 ID,却没有补齐错误分类、状态机、缓存键和生命周期,复杂度只会从路径层转移到回调层。

制品化阶段:把发布当成编译

进一步的演进是把发布看成编译系统:

  1. 冻结本次发布输入,而不是让构建过程读取不断变化的编辑目录。
  2. 用可版本化 Processor 把输入转换为不同目标的制品。
  3. 每个 Processor 在私有事务目录中输出,成功后再合并。
  4. 生成覆盖精确文件集、大小、hash、来源摘要和处理器指纹的 Manifest。
  5. 只有完整验证通过后,才原子切换“当前可用版本”。

这能把构建、安装和回滚变成明确合同,但代价是需要维护格式版本、处理器兼容、缓存命名空间、确定性测试和迁移策略。

目标模型:四个稳定身份

一个可演进的资产系统至少要区分四种 identity:

struct AssetRef {
    string logicalId;      // 稳定逻辑身份;跨路径、跨会话
    AssetKind kind;        // Mesh / Texture / Material / Audio / ...
};

struct SourceRevision {
    AssetRef asset;
    Digest sourceDigest;   // 可编辑输入的内容摘要
    SchemaVersion schema;
};

struct ArtifactKey {
    SourceRevision source;
    TargetProfile target;  // 平台、质量档、Reader 能力
    Digest processor;      // 工具、配置和规则的指纹
};

struct RuntimeHandle {
    ArtifactKey artifact;
    Generation owner;
    RuntimeObjectId object;
};

四者不能互相替代:

  • AssetRef 用于内容引用和网络语义。
  • SourceRevision 判断源内容是否变化。
  • ArtifactKey 判断已构建结果能否复用。
  • RuntimeHandle 只对当前世界和对象生命周期有效。

如果逻辑 ID 可能超过某些文本运行时的安全整数范围,JSON 边界应使用十进制字符串或受约束的二进制整数,而不是经过浮点数中转。

目标架构

flowchart TB
    subgraph Authoring[创作域]
        S[可编辑源数据]
        R[类型化资产引用]
        V[编辑期校验]
    end

    subgraph Build[构建域]
        F[冻结快照]
        P[可版本化处理器]
        M[制品清单]
    end

    subgraph Delivery[交付域]
        O[解析与目标选择]
        Q[请求去重与调度]
        C[下载缓存与原子安装]
    end

    subgraph Runtime[运行域]
        D[有界解码]
        L[依赖就绪]
        X[选择性实体化]
        Y[可见就绪]
    end

    S --> V --> F
    R --> F --> P --> M
    M --> O --> Q --> C
    C --> D --> L --> X --> Y

关键边界如下:

边界输入输出必须守住的合同
创作 -> 构建可编辑数据、引用、配置冻结快照输入不可漂移、引用可诊断
构建 -> 交付目标制品Manifest + 文件集确定性、路径安全、完整性
交付 -> 运行已安装制品受验证字节原子切换、旧版本可回退
运行 -> 呈现已准备资源可见对象Generation、主线程预算、最低完整状态

构建:确定性比“成功一次”更重要

冻结输入

构建不能一边读取编辑目录,一边允许保存继续改变文件。需要先冻结 inventory:

Snapshot Freeze(ProjectState state)
{
    Snapshot out;
    for (File file : EnumerateDeclaredInputs(state)) {
        ValidateRelativePath(file.path);
        Bytes bytes = ReadAndRecheck(file);
        out.Add(file.logicalPath, bytes, Hash(bytes), file.role);
    }
    out.sourceDigest = Hash(CanonicalProjection(out.files));
    return out;
}

这里的关键不是复制文件,而是:输入集合、路径、字节和顺序都有稳定定义;构建期间源数据变化不能悄悄混入同一制品。

Processor 与 Core 分责

通用 Builder Core 应负责:

  • 作业隔离、路径边界和资源上限。
  • Processor 依赖图和稳定执行顺序。
  • 私有事务输出、冲突检测和原子合并。
  • 文件大小、hash、来源摘要和最终制品摘要。
  • 精确文件集验证和确定性打包。

具体资产类型的 Processor 负责:

  • 理解业务 schema。
  • 将源格式转换为目标格式。
  • 声明必需、可选或可降级输出。
  • 产生类型相关诊断。

如果 Core 持续增加“某类角色字段”“某种材质文件”“某个特效目录”的判断,它就会成为所有业务变化的汇合点,工具版本也会被迫与内容 schema 同步发布。

事务化输出

Artifact Build(Snapshot input, ProcessorPack pack)
{
    Plan plan = ResolveProcessorGraph(input, pack);
    StagingArea global;

    for (Layer layer : plan.topologicalLayers) {
        parallel_for (Processor processor : layer.processors) {
            Transaction tx = global.BeginPrivate(processor.id);
            Result result = processor.Run(input, tx.outputSink);

            if (!result.ok)
                tx.Rollback();
            else
                tx.Seal();
        }
        global.MergeSealedInStableOrder();
    }

    Manifest manifest = BuildManifest(global.files, input, pack);
    VerifyExactFileSet(global, manifest);
    return AtomicCommit(global, manifest);
}

失败不能留下可被误认成成功的部分制品。确定性也不只意味着 ZIP 字节相同,还包括文件顺序、字段编码、诊断码和 artifact digest 的投影稳定。

Manifest:运行时的信任入口

Manifest 至少应描述:

struct ArtifactDescriptor {
    AssetRef asset;
    FormatVersion format;
    TargetProfile target;
    uint64 byteSize;
    Digest contentHash;
    vector<Dependency> dependencies;
    Optional<Digest> signature;
};

struct Dependency {
    AssetRef asset;
    DependencyPolicy policy; // Required / Degradable / Optional
};

需要区分三种安全能力:

机制解决的问题不能解决的问题
Hash字节是否与期望一致期望 hash 本身是否可信
签名或受信摘要Manifest/制品是否来自受信发布者客户端运行环境是否可信
加密降低静态提取和传输暴露阻止拥有密钥和明文的客户端被逆向

客户端持有的解密能力不能成为权威信任根。安全重点应放在:受信 Manifest、完整性、路径隔离、有界解析、格式兼容和服务端权威,而不是假设“文件加密后客户端就可信”。

获取与下载:请求是一段生命周期

状态机

stateDiagram-v2
    [*] --> Resolving
    Resolving --> Queued: descriptor selected
    Queued --> Acquiring: scheduler grants budget
    Acquiring --> Verifying: bytes complete
    Verifying --> Installed: integrity valid
    Installed --> Decoding
    Decoding --> Prepared
    Prepared --> Materialized
    Materialized --> VisibleReady

    Resolving --> Failed
    Acquiring --> Cancelled
    Acquiring --> Failed
    Verifying --> Corrupted
    Decoding --> Unsupported
    Materialized --> Superseded

每个终态都应该有结构化原因,不能只返回字符串。

enum class AssetFailure {
    NotFound,            // 权威确认不存在
    ResolveUnavailable, // 暂时无法确认位置
    NetworkFailure,
    Cancelled,
    IntegrityFailure,
    UnsupportedFormat,
    DependencyFailure,
    BudgetRejected,
    StaleGeneration
};

只有 NotFound 才能驱动“确认缺失”的产品行为。超时、空响应、普通网络错误和格式错误都不能偷偷降级成不存在,否则会掩盖基础设施或兼容故障。

单飞、取消和后端快照

相同 ArtifactKey 的并发请求应共享传输和解码,但每个调用者保留自己的生命周期:

RequestHandle Request(AssetRef ref, RequestContext context)
{
    ArtifactKey key = resolver.Resolve(ref, context.target);
    SharedJob job = jobs.GetOrCreate(key);
    Subscriber sub = job.Subscribe(context.ownerGeneration,
                                   context.priority);

    sub.OnPrepared([context](PreparedAsset value) {
        if (!context.owner.IsCurrent(context.ownerGeneration))
            return;
        context.commitQueue.Enqueue(value);
    });

    return sub.CancellableHandle();
}

如果运行时支持切换下载源或实验开关,请求创建时必须把 Resolver/Backend 选择快照进 Job。飞行中的任务不能一半按旧规则写、一半按新规则读。

临时文件与原子安装

推荐的本地流程:

<key>.partial
  -> 分块写入 / 可选续传
  -> 长度与 hash 校验
  -> Manifest 与目标能力校验
  -> 原子 rename 为不可变缓存文件
  -> 原子更新 current 指针

取消或失败只删除 .partial;旧的 current 保持可用。直接覆盖当前文件会让崩溃、磁盘满或校验失败破坏原本可恢复的状态。

上传与下载优化的顺序

优化前先判断瓶颈在哪一层:

瓶颈优先手段常见误区
重复字节内容寻址、hash 去重、跨引用单飞只压缩,不解决重复传输
大文件失败重来分块、断点续传、临时文件把未验证分块暴露为可用缓存
小文件过多合理聚合、请求批处理、Manifest 预取聚成巨包后失去按需加载和局部更新
首屏等待优先级、关键依赖预取、最低可见集合把所有内容都标成最高优先级
主线程卡顿后台校验/解码、主线程分帧提交只优化网络吞吐
跨平台浪费Resolver 只选择目标制品下载通用源数据后在低端设备现场转换

上传侧同样应该围绕不可变制品和内容摘要设计。客户端可以负责冻结、分块、续传和校验,但最终发布身份应由受信服务确认;本地“上传完成”不等于发布版本已经可解析和可分发。

依赖:不要在“全打包”和“全运行时解析”之间二选一

资产依赖有不同稳定性:

依赖类型更适合何时解析原因
格式内部必需数据构建期放入自包含制品缺失时 Reader 无法正确工作
稳定直接引用构建期记录 Manifest可提前校验、预取和诊断
远端资产的远端依赖运行时按统一 Resolver 展开发布期未必拥有完整内容
脚本条件分支运行时请求,必要时由提示清单预热静态分析无法保证完整
DS 碰撞/规则数据构建期独立 headless 制品不应依赖视觉网格加载

目标不是构建一个理论上的“全依赖闭包”,而是给每类依赖明确责任:

  • 必需依赖缺失:阻止该制品进入可用态。
  • 可降级依赖缺失:保留引用和诊断,以占位或简化表现继续。
  • 可选依赖缺失:不影响最低完整状态。
  • 未知依赖类型:不能猜,应产生 coverage warning 并走运行时回退。

循环依赖要在构建期或解析阶段显式拒绝,不能靠加载超时发现。

缓存与驻留是两个问题

磁盘缓存回答“字节是否需要重新获取”,内存驻留回答“资源是否值得继续占用运行预算”。二者应使用不同状态和淘汰策略。

磁盘缓存键

至少包含:逻辑资产 revision、目标平台、格式版本、质量档和处理器指纹。只用逻辑 ID 会让不同平台或新旧工具产物互相污染。

内存驻留

驻留策略需要区分:

  • 正在可见或即将可见。
  • 被多个对象共享。
  • 角色出生、交互或 UI 首屏等关键用途。
  • 后台预热。
  • 已无订阅但仍可短期复用。
ResidencyScore Score(const AssetUsage& usage)
{
    return usage.visibilityWeight
         + usage.gameplayCriticalWeight
         + usage.sharedReferenceWeight
         + usage.predictedNeedWeight
         - usage.memoryCostWeight
         - usage.idleAgeWeight;
}

公式只表达决策维度,权重必须通过目标设备和代表性内容验证,不能把示意参数当成历史预算。

优先级传播

一个近处或高重要性对象请求网格时,它的直接材质和最低 mip 应继承同一需求优先级;共享依赖取所有订阅者中的最高有效优先级。否则主资产先到、关键依赖长期排在后台,用户仍看不到完整结果。

DS 与客户端不应加载同一份东西

“同一内容”不代表“同一制品”。可以共享逻辑身份和功能不变量,但目标产物不同:

目标需要通常不需要
编辑器源元数据、预览资源、诊断、可逆信息只为运行压缩的不可读表示作为唯一来源
游戏客户端渲染、音频、动画、必要碰撞和运行数据编辑器事务和迁移中间数据
DS权威规则、碰撞、导航或查询数据纹理、材质、渲染网格、客户端音频
轻量目标降级网格/纹理/音频和受限格式不支持的高质量可选数据

如果 DS 为了获得碰撞而必须加载完整视觉网格,说明构建产物仍然按源资产组织,而不是按运行目标组织。

缺失资产:阻断还是降级

这是业务与技术必须共同决定的问题。一个通用决策表如下:

情况建议行为理由
Manifest 损坏或被篡改阻断并保留旧版本无法建立制品可信边界
必需运行数据缺失阻断相关对象或发布正确性无法成立
权威确认次要视觉资产不存在占位、记录并允许继续可玩性可能仍成立
网络暂时失败重试或使用旧缓存不能误判永久缺失
Reader 不支持格式选择兼容制品或明确失败重试同一字节没有意义
请求所有者已换代静默拒绝晚到结果不是内容故障,无需污染日志

“允许缺失”不等于吞掉错误。需要保留真实缺失身份、受影响父资产、依赖边和本次结果;下一次内容修复后必须清除过期警告。

可见就绪:重新定义加载性能

资产链路的端到端时间可以写成:

T_visible = T_resolve
          + T_queue
          + T_acquire
          + T_verify
          + T_decode
          + T_dependency_wait
          + T_materialize
          + T_first_present

真实流水线可能并行,所以总时间不一定是简单相加;但每段都必须有独立埋点,才能知道优化网络、解码还是主线程提交。

建议指标:

指标回答的问题
Resolver 成功率与失败分类找不到制品还是服务暂不可用
内存/磁盘缓存命中率重复获取和重复准备是否被消除
单飞合并率多个引用是否共享同一作业
下载有效字节率取消、过期和未使用字节占多少
校验与解码吞吐CPU 是否成为瓶颈
主线程提交峰值后台完成后是否仍造成卡顿
请求到 Prepared / VisibleReady技术完成与用户可见相差多少
峰值内存下载缓冲、解码中间态和运行资源是否叠加
依赖扇出与关键路径深度哪些资产拖长最低完整集合
淘汰后短期重载率驻留策略是否过于激进

每个指标都要按目标平台、冷热缓存、资产类型、质量档、优先级和失败类型切分;一个全局平均值几乎不可行动。

高频失败模式

失败模式表面修复根因修复
下载写一个目录,启动扫描另一个目录增加一次复制由 ArtifactKey 统一命名和读写合同
后端开关切换后飞行任务混用规则重启客户端请求创建时冻结 Resolver 与 Backend
回调访问已销毁对象增加空指针判断所有者 Generation + 可取消订阅
同资源被重复解码扩大线程池按 ArtifactKey 单飞并共享 Prepared 结果
构建结果偶发不同构建后再排序 ZIP从 inventory、处理顺序、编码到 Manifest 全链路确定性
旧可选文件残留进新制品发布前清空目录每次独立事务目录 + 精确文件集校验
缺失依赖只报父资产日志打印更多路径错误跨层保留真实子资产 identity 与依赖边
DS 等视觉资源后才有碰撞提前预加载网格构建独立 headless 碰撞制品
全部最高优先级增加带宽建立关键集合、距离/重要性和公平调度
加密后认为资产可信隐藏密钥受信 Manifest、完整性、有界 Reader 和权威边界

怎样选择交付粒度

方案优点代价适用条件
大包请求少、顺序 IO 友好更新和按需粒度差、峰值高内容集合稳定且总是共同使用
每资产一包独立更新和缓存小文件、请求、Manifest 和打开成本高资产较大、复用高、调度系统成熟
按场景/功能分组贴近业务使用人工配置可能偏离真实依赖业务边界稳定并有自动依赖检查
内容寻址块去重和增量好索引、组装、清理复杂内容规模大且有完整制品服务
混合方案可平衡关键集和长尾规则和验证成本最高有数据支撑的成熟平台

不存在固定最佳粒度。应使用实际访问共现、更新频率、依赖扇出、失败重试成本和目标设备 IO 特性做分组,而不是让目录结构直接决定交付结构。

目标架构的最低合同

在继续扩展网格、纹理、材质和动画管线之前,资产底座至少应满足:

  1. 内容只持有类型化逻辑引用,不依赖缓存或包内物理路径。
  2. 每个制品明确源 revision、目标、格式和处理器指纹。
  3. 构建读取冻结输入,输出确定、隔离、可验证并原子提交。
  4. Manifest 是精确文件集和依赖政策的合同,而不是提示信息。
  5. 获取、校验、解码、实体化和可见就绪是不同状态。
  6. 并发请求单飞;订阅者拥有独立取消和 Generation。
  7. 错误区分不存在、暂时失败、损坏、不兼容、取消和过期。
  8. 磁盘缓存与内存驻留分离,缓存键包含完整目标维度。
  9. DS、客户端、编辑器和轻量目标共享逻辑身份,但消费不同制品。
  10. 所有优化都能回到端到端可见时间、峰值成本和正确性不变量验证。

个人反思

容易忽略的信号

早期看到“路径表越来越多”“同一资源有多个下载入口”“缓存目录带版本后缀”“加载完成还需要补一次材质”时,容易把它们当成局部维护问题。更准确的判断是:资产已经从工程文件变成跨编辑、构建、交付和运行的领域对象,而架构仍把它当作文件。

应该更早问的问题

  • 这个引用表达的是逻辑身份,还是当前实现路径?
  • 谁承诺依赖完整,构建期还是运行时?承诺的边界是什么?
  • “成功”指字节到达、对象创建,还是用户可见?
  • 请求所有者销毁后,谁取消工作、谁拒绝结果?
  • 同一内容进入编辑器、客户端和 DS 时,哪些数据必须相同,哪些必须分离?
  • 新平台或新 Reader 加入时,缓存和制品身份是否会碰撞?
  • 部分失败应该阻断、降级,还是保留旧版本?依据是什么?

可迁移结论

资产平台化的核心不是发明更多加载接口,而是建立三条可验证的边界:

  1. 逻辑身份与物理制品分离。
  2. 不可变构建结果与可变运行生命周期分离。
  3. 技术完成状态与用户可见结果分离。

只要这三条边界稳定,后续的纹理压缩、网格 LOD、材质缓存、动画预取和音频流式都能接入同一套生命周期;否则每增加一种资产,都会复制一套下载、缓存、错误和回调问题。

延伸阅读