Table of Contents

Content Pipeline

Content Pipeline is an Editor-only, graph-driven build layer on top of Unity Addressables and Scriptable Build Pipeline (SBP). It lets a project describe content from its own source model, resolve transitive Unity dependencies, build without persistent Addressable groups, create automatic incremental updates, and describe releases without duplicating bundle payloads.

The pipeline does not replace Addressables. It creates a transient AddressableAssetSettings model for each build and delegates bundle and catalog generation to Addressables/SBP.

When to Use It

Use Content Pipeline when:

  • a project owns a higher-level content model such as DLC definitions, collections, packs, or generated metadata;
  • persistent Addressable groups would only be intermediate build data;
  • dependencies shared by several content scopes must have one deterministic owner and bundle;
  • an update must infer all changed remote scopes from the previous successful content version;
  • the runtime loads a second Addressables catalog from a local or downloaded content directory;
  • Editor Play Mode needs to resolve the same graph directly through AssetDatabase.

Use the Resource module's ResourceExporter instead when existing persistent Addressable groups are the authoritative source and a group-filtered export is sufficient. Content Pipeline is the graph-driven path; ResourceExporter is the group-driven path.

Assembly and Compatibility

The public APIs are in:

Assembly:  Ceres.ContentPipeline.Editor
Namespace: Ceres.ContentPipeline
Platform:  Editor only

The current transient backend explicitly supports Addressables 2.9.x. It rejects other Addressables versions before building because the backend depends on internal group identity and transient settings behavior that must be validated for each Addressables release.

The Addressables version declared by the Ceres package is the dependency floor for the runtime Resource APIs, not a compatibility promise for this Editor backend. A project using Content Pipeline must resolve Addressables 2.9.x explicitly.

The project's installed SBP version is recorded in every artifact manifest and must match when producing an update.

Pipeline Overview

Build a complete graph from your project sources, validate it, then choose a baseline build, an incremental update, or an Editor AssetDatabase mount. After a successful build, create a release index for your deployment workflow. The examples below follow that order.

Content Pipeline architecture

Core Concepts

Scope

A ContentScopeDefinition is the unit a producer can independently identify, version, and compare. A scope should use a stable, project-independent ID such as a source asset GUID, package ID, or persisted collection ID.

context.AddScope(new ContentScopeDefinition(
    id: "characters.base",
    displayName: "Base Characters",
    version: "3",
    enabled: true,
    defaultLocation: ContentLocation.Remote));

Properties can carry deterministic project metadata. Changing scope metadata changes the scope fingerprint and is considered during update validation. Enabled is also fingerprinted, but the generic backend does not use it as an automatic build filter. A project adapter must omit disabled source content or otherwise define its own inclusion policy before building the graph.

Explicit Asset and Dependency Asset

A contributor adds explicit assets through ContentAssetContribution. The graph builder then discovers their direct dependencies recursively.

An explicit asset can have:

  • a stable asset ID;
  • an AssetDatabase path;
  • a runtime Addressables address;
  • labels;
  • a runtime type name;
  • location and ownership hints;
  • a packing hint.

Dependency assets do not need to be contributed manually. The IContentAssetDependencyResolver discovers them and records every scope that uses them.

For Unity assets, use the exact main-asset GUID as assetId:

<asset-guid>

Explicit sub-assets are not currently supported end to end. Contributors must promote them to standalone Unity assets instead of appending a suffix to the main asset GUID. The graph rejects duplicate explicit paths before the Addressables backend can collapse them onto one GUID entry.

Location

ContentLocation describes delivery intent:

Value Meaning
Local Part of an immutable local or Player baseline.
Remote Eligible for external delivery and content updates.
Unspecified No explicit decision; projects should normally avoid this for production scopes.

When one dependency is used by both Local and Remote partitions, the backend places it in Local content. Remote bundles can then depend on the immutable Player baseline instead of duplicating the asset.

Ownership

ContentOwnership determines which logical partition owns an asset:

Value Meaning
Scope Owned by one scope.
Shared Shared by several scopes and emitted once.
BuiltIn Supplied by Unity or the Player; no explicit content entry is created.
Metadata Generated or descriptive content managed by the pipeline.
Excluded Tracked for analysis but excluded from explicit build entries.
Unspecified Let the graph planner infer ownership.

The planner promotes an asset to Shared when it is explicit in multiple scopes or used by multiple scopes. A dependency shared by several collections is therefore bundled once instead of being copied into every scope bundle.

Packing Hint

packingHint is a stable sub-partition key. Scope-owned assets are grouped by scope and packing hint. Shared assets are grouped by type family, such as textures, materials, shaders, animations, or prefabs.

Packing hints affect bundle layout and should be treated as part of the build contract. Changing them can produce new bundles and invalidate update assumptions.

The build request selects one of two backend packing policies:

  • LogicalPartitions preserves the scope plus packing-hint layout.
  • SizeOptimized keeps location, scene, and semantic families separate, then groups explicit entries toward a configurable soft target size.

Both policies keep Local and Remote entries in separate partitions. A logical scope or packing key is never allowed to make a Remote entry inherit Local delivery accidentally.

Size optimization does not split one explicit asset or its indivisible dependency closure. A single entry can therefore exceed the target. Baseline builds freeze the generated partition plan; updates reuse the last successful plan so a small change cannot rebalance unrelated bundles.

Shared dependency ownership and location planning

Shared ownership removes duplicate payloads. Location planning additionally ensures that a dependency used by Local and Remote content remains in the Local baseline instead of being emitted again for Remote delivery.

Contributing a Build Graph

Implement IContentBuildGraphContributor to translate a project source model into scopes and explicit assets.

#if UNITY_EDITOR
using Ceres.ContentPipeline;
using UnityEditor;
using UnityEngine;

public sealed class CharacterContentContributor : IContentBuildGraphContributor
{
    private const string ScopeId = "characters.base";

    public string Id => "my-game.characters";

    public void Contribute(ContentBuildGraphContributionContext context)
    {
        context.AddScope(new ContentScopeDefinition(
            ScopeId,
            "Base Characters",
            version: "3",
            defaultLocation: ContentLocation.Remote));

        const string prefabPath = "Assets/Content/Characters/Hero.prefab";
        string guid = AssetDatabase.AssetPathToGUID(prefabPath);

        context.AddAsset(new ContentAssetContribution(
            scopeId: ScopeId,
            assetId: guid,
            assetPath: prefabPath,
            address: "Characters/Hero",
            labels: new[] { "Character", "Playable" },
            typeName: typeof(GameObject).AssemblyQualifiedName,
            locationHint: ContentLocation.Remote,
            ownershipHint: ContentOwnership.Scope,
            packingHint: "character-prefabs"));
    }
}
#endif

Contributor IDs, scope IDs, asset IDs, addresses, labels, and packing hints must be deterministic. Do not derive them from enumeration order or transient object instance IDs.

Build the graph with the Unity dependency resolver:

var graph = new ContentBuildGraphBuilder().Build(
    new IContentBuildGraphContributor[]
    {
        new CharacterContentContributor()
    },
    new UnityContentAssetDependencyResolver());

Contributors are evaluated in stable ID order. If a contributor throws, the builder throws ContentBuildGraphContributorException and preserves the contributor ID and original exception.

Inspecting and Validating the Graph

ContentBuildGraph exposes:

  • Scopes: stable content units;
  • Assets: explicit and discovered dependency nodes;
  • Edges: direct dependency edges;
  • Diagnostics: deterministic validation messages;
  • Fingerprint: a deterministic fingerprint of the complete graph;
  • IsBuildable: false when any Error diagnostic exists.

Always stop before invoking a backend when the graph is not buildable.

if (!graph.IsBuildable)
{
    foreach (ContentBuildDiagnostic diagnostic in graph.Diagnostics)
    {
        Debug.LogError(
            $"{diagnostic.Code}: {diagnostic.Message} " +
            $"(scope: {diagnostic.ScopeId}, asset: {diagnostic.AssetId})");
    }

    return;
}

Typical errors include duplicate contributor or scope IDs, one asset ID mapping to several paths, duplicate addresses, missing assets, conflicting type or ownership hints, and dependency resolver failures.

The graph also provides query helpers:

IReadOnlyList<ContentAssetNode> usedByScope =
    graph.GetForwardDependencyClosure("characters.base");

IReadOnlyList<ContentAssetNode> affectedAssets =
    graph.GetReverseImpactClosure("characters.base");

IReadOnlyList<string> affectedScopes =
    graph.GetImpactedScopes("characters.base");

IReadOnlyList<ContentAssetNode> directDependencies =
    graph.GetDirectDependencies(assetGuid);

ContentBuildGraphReport.ToJson produces a deterministic JSON report suitable for build logs, code review, and comparing graph changes:

string json = ContentBuildGraphReport.ToJson(graph, prettyPrint: true);
File.WriteAllText("Library/ContentGraph.json", json);

The JSON report is diagnostic output, not a runtime catalog or a persisted source model.

Building a Baseline

AddressablesContentBuildBackend creates transient Addressables settings and groups in memory. It does not add groups to the project's real AddressableAssetSettings.

using Ceres.ContentPipeline;
using Ceres.Resource;
using UnityEditor;

var request = new ContentPipelineBuildRequest
{
    Graph = graph,
    OutputRoot = "Export/Content",
    Channel = "development",
    PlayerVersion = "1.0.0",
    RemoteLoadPath = ResourceSystem.DynamicLoadPath,
    Target = EditorUserBuildSettings.activeBuildTarget,
    DevelopmentBuild = false,
    BuildKind = ContentPipelineBuildKind.Baseline,
    Packing = new ContentBundlePackingOptions
    {
        Mode = ContentBundlePackingMode.SizeOptimized,
        TargetBundleSizeBytes = 128L * 1024L * 1024L
    }
};

ContentPipelineBuildResult result =
    new AddressablesContentBuildBackend().Build(request);

if (!result.Succeeded)
{
    throw result.Exception;
}

Debug.Log($"Manifest: {result.ManifestPath}");

The backend catches build exceptions and stores them in ContentPipelineBuildResult.Exception; Build does not rethrow them. Callers must check Succeeded.

Asset Build Dependencies

ContentPipelineBuildRequest.AssetBuildDependencyHashes accepts an optional IReadOnlyDictionary<string, string> keyed by graph AssetId. Each value is a stable, non-empty digest of additional build inputs for that asset, such as a project-owned compilation policy that Unity's asset dependency hash does not represent. The project computes the digest; Ceres does not interpret its policy.

Pass the complete current dictionary on both baseline and update requests. Changed digests participate in incremental change detection and invalidate cached output containing the affected asset. Adding the first entry or removing the last entry requires a new baseline; changing values remains incremental. Null and empty maps leave this feature disabled. Unknown asset IDs, empty digests, and assets without a Unity asset GUID produce a failed build result.

Do not use these digests as a substitute for graph dependencies or packing hints. They describe extra build inputs, not asset ownership or bundle layout.

Baseline Output Layout

The backend writes under a channel and platform boundary:

<OutputRoot>/
  <channel>/
    <BuildTarget>/
      current-baseline.json
      baselines/
        <build-id>/
          artifact-manifest.json
          remote/
          local/
          metadata/

current-baseline.json is updated atomically after commit. Retrieve its target with:

string baselineManifest =
    AddressablesContentBuildBackend.GetCurrentBaselineManifestPath(
        "Export/Content",
        "development",
        EditorUserBuildSettings.activeBuildTarget);

// Returns the latest successful content Manifest compatible with the current
// Baseline, or the Baseline Manifest when no Incremental exists.
string previousManifest =
    AddressablesContentBuildBackend.GetCurrentContentManifestPath(
        "Export/Content",
        "development",
        EditorUserBuildSettings.activeBuildTarget);

Build IDs are content-derived. Repeating the same build can reuse an already committed directory after validating its manifest identity.

Maintaining Artifact History

baselines and updates are immutable build records. Projects that do not need arbitrary local history can preview and prune records not referenced by the current Baseline or latest compatible Update:

ContentBuildStorageCleanupPreview preview =
    ContentBuildStorageMaintenance.Preview(
        "Export/Content",
        "development",
        EditorUserBuildSettings.activeBuildTarget);

ContentBuildStorageCleanupResult cleanup =
    ContentBuildStorageMaintenance.Execute(
        "Export/Content",
        "development",
        EditorUserBuildSettings.activeBuildTarget);

Channels are stable machine-readable identifiers and must match [a-z0-9]+(?:-[a-z0-9]+)*, for example development or preview-android. The build backend, pointer queries, and storage maintenance use the same validated channel-to-directory mapping; display names with spaces or uppercase letters are not accepted as aliases.

Pass the Artifact Manifest paths retained by active releases, deployments, or rollback references to cleanup. Review the preview before executing it. Cleanup refuses invalid storage references and reports deletion failures individually; inspect the result before reporting successful reclamation.

Building an Incremental Update

An update requires:

  • a compatible baseline artifact manifest;
  • the complete current graph, not a producer-filtered subset;
  • a previous successful content manifest used as the comparison head;
  • automatic change detection from the previous successful content manifest.
var updateRequest = new ContentPipelineBuildRequest
{
    Graph = currentGraph,
    OutputRoot = "Export/Content",
    Channel = "development",
    PlayerVersion = "1.0.0",
    RemoteLoadPath = ResourceSystem.DynamicLoadPath,
    Target = EditorUserBuildSettings.activeBuildTarget,
    BuildKind = ContentPipelineBuildKind.Update,
    BaselineManifestPath = baselineManifest,
    PreviousManifestPath = previousManifest,
    Packing = new ContentBundlePackingOptions
    {
        Mode = ContentBundlePackingMode.SizeOptimized,
        TargetBundleSizeBytes = 128L * 1024L * 1024L
    }
};

ContentPipelineBuildResult update =
    new AddressablesContentBuildBackend().Build(updateRequest);

if (!update.Succeeded)
{
    throw update.Exception;
}

Ceres automatically includes changed Remote scopes and the users of changed shared assets. Do not prefilter the graph to selected scopes. Local content changes require a new baseline.

Keep the baseline's Unity, Addressables and SBP versions, platform, channel, remote load path and packing configuration. Incompatible requests fail rather than producing an update. Existing size-optimized partitions are retained; create a new baseline when you want to rebalance all content.

An unchanged request returns an up-to-date result without invoking Addressables. A successful changed request writes a new catalog and changed bundles, and updates latest-update-candidate.json without replacing the baseline pointer.

<OutputRoot>/<channel>/<BuildTarget>/
  latest-update-candidate.json
  updates/
    <build-id>/
      artifact-manifest.json
      remote/
      metadata/

Creating a Content Release

Backend Artifact directories own the immutable Bundle payloads. Use DynamicContentReleaseBuilder to create a small release index containing a rewritten Catalog, Catalog Hash, and a direct Bundle-to-Artifact source map.

DynamicContentReleaseResult release =
    new DynamicContentReleaseBuilder().Build(
        new DynamicContentReleaseRequest
        {
            ArtifactManifestPath = result.ManifestPath,
            StorageRoot = platformRoot,
            OutputRoot = Path.Combine(platformRoot, "output"),
            DynamicLoadPath = ResourceSystem.DynamicLoadPath
        });

The result is a release index with the following layout:

<OutputRoot>/
  baselines|updates/
    <first-32-characters-of-build-id>/
      release-manifest.json
      catalog.bin|catalog.json
      catalog.hash

The Release directory contains no Bundle files and is not directly loadable. Deployment uses the source map to transfer files into the final flat content directory. An Editor may instead project a copy of the Catalog whose Bundle IDs point directly to absolute Artifact paths. A standalone export may explicitly materialize a Release as a relocatable Catalog-and-Bundle package.

Indexing an Incremental Update

Supply both the baseline and previous successful Release Manifests when indexing an update:

DynamicContentReleaseResult updateRelease =
    new DynamicContentReleaseBuilder().Build(
        new DynamicContentReleaseRequest
        {
            ArtifactManifestPath = update.ManifestPath,
            StorageRoot = platformRoot,
            OutputRoot = Path.Combine(platformRoot, "output"),
            DynamicLoadPath = ResourceSystem.DynamicLoadPath,
            BaselineReleaseManifestPath = baselineRelease.ManifestPath,
            PreviousReleaseManifestPath = previousRelease.ManifestPath
        });

DynamicContentPackageMaterializer is reserved for workflows that explicitly need a self-contained package. It copies the Release Catalog, Hash, and all mapped Bundle files into an atomic destination. The Release builder itself never duplicates Bundle payloads.

Baseline and Incremental lifecycle

Windows Long Paths

Use ordinary absolute paths in requests, including on Windows. Do not add extended-path prefixes before passing paths to Unity APIs. Ceres handles long paths for its own file operations; limits in Unity and third-party build tools still apply.

Editor AssetDatabase Mount

ContentBuildGraphAssetDatabaseMount makes explicit graph assets resolvable by Addressables in Editor Play Mode and Edit Mode tools without creating persistent Addressable groups or building bundles.

The mount omits explicit assets owned as BuiltIn or Excluded, matching the build contract: built-in content must come from Unity, the Player, or the main Addressables catalog, while excluded content remains available only for graph analysis. This prevents Editor Play Mode from exposing content that the dynamic package will not contain.

Addressables must already be initialized:

using Ceres.ContentPipeline;
using UnityEngine.AddressableAssets;

await Addressables.InitializeAsync().Task;

ContentBuildGraphAssetDatabaseMount mount =
    ContentBuildGraphAssetDatabaseMount.Create(
        graph,
        "MyGame.Content.EditorAssetDatabase");

Debug.Log($"Mounted {mount.LocationCount} explicit assets.");

Load mounted content by address, asset ID, or label. Labels may overlap with another catalog, allowing Addressables.LoadAssetsAsync to merge results. An address or asset ID that resolves to a different path is rejected.

Keep the mount alive for the complete Editor content-source lifetime and dispose it on Play Mode exit, assembly reload, or Editor shutdown:

mount.Dispose();

Dispose is idempotent. Only explicit assets are mounted; Unity loads their dependencies naturally through AssetDatabase.

Do not mount an AssetDatabase graph locator at the same time as the runtime catalog for the same content source. The project-level integration should choose one source for a Play Mode session.

Determinism and Build Safety

Run content builds as exclusive Editor operations. Do not overlap them with Player or other Addressables builds in the same Unity process. Use the backend's successful result and manifest queries instead of assuming a directory's presence means the build completed.

Project Integration Responsibilities

Your adapter supplies the source model, generated metadata, build UI, Player integration and deployment policy. Keep generated assets alive until graph construction, building and any packaging step using their paths have completed.

Use the APIs in this order: contribute the complete graph, check diagnostics, build a baseline or update, check the result, create a release, then publish or install through project-specific code. The release index is not itself a runtime content installation.