1. 项目概述为什么我们需要 BlobAsset如果你已经跟着 DOTS 系列一路走来从 Entity 创建到 System 调度再到 IComponentData 和 IJobEntity你应该已经感受到了 ECS 架构在性能上的巨大潜力。数据紧密排列缓存命中率极高Job 并行处理得飞起。但不知道你有没有遇到过这样的场景你的成千上万个 Enemy 实体都需要引用同一份庞大的、不可变的配置数据比如一个包含所有技能伤害、冷却时间、特效ID的表格。如果给每个 Enemy 都挂一个DynamicBufferSkillConfig组件内存会瞬间爆炸而且这份数据在运行时根本不会改变复制那么多份纯属浪费。又或者你需要一个复杂的、预先计算好的导航网格NavMesh数据供所有寻路实体查询。这份数据同样巨大且只读。在传统的面向对象编程里我们可能会用一个单例Singleton或者静态类来持有这份数据然后在各处引用。但在 ECS 的纯数据世界里我们如何安全、高效地在多个 Job 中共享这样一份“全局”的、只读的数据块呢这就是BlobAsset登场的时刻。你可以把它理解为 ECS 世界里的“不可变数据块”或“二进制大对象”。它的核心设计目标就两个极致的内存效率和安全的线程共享。所有数据在创建后就被“冻结”无法修改因此可以被任意数量的 Job 同时读取无需加锁完美契合 DOTS 的并行哲学。它不是 Entity也不是 Component而是一种特殊的数据资产通过BlobAssetReferenceT这个“智能指针”在组件中被引用。理解 BlobAsset是掌握 DOTS 进行大规模数据驱动开发的关键一步它能将你的游戏从“能跑”优化到“跑得飞快且内存清爽”。2. BlobAsset 核心机制深度拆解2.1 设计哲学为什么是“Blob”Blob 是 Binary Large Object 的缩写但在 DOTS 的语境下它的含义更侧重于“连续的内存块”。与传统的托管对象在 C# 堆上分配由 GC 管理或普通的struct组件在 Chunk 中紧密排列不同BlobAsset 是在一个特殊的、非托管的内存区域BlobBuilder 分配的内存中构建的。它的设计哲学根植于数据导向设计的几个核心原则数据局部性BlobAsset 内部的所有数据包括嵌套结构、字符串、数组都存储在一个连续的内存块中。当你的 Job 通过BlobAssetReference访问它时CPU 可以高效地将相关数据预加载到缓存中减少缓存未命中。不可变性Immutability这是实现无锁并发的基石。一旦 BlobAsset 构建完成并创建了引用其底层内存内容就被视为只读。任何试图修改的操作都会在运行时导致异常。这消除了数据竞争的风险让并行读取变得绝对安全。确定性内存布局BlobAsset 的内存布局在构建时就被完全确定。它使用相对偏移量offset来引用内部的其他数据如数组元素、嵌套结构而不是传统的对象引用。这意味着BlobAssetReference本身只是一个指向这块内存起始位置的小型句柄复制和传递的成本极低。生命周期管理BlobAssetReferenceT实现了引用计数。当最后一个引用被释放Dispose时底层的非托管内存会被自动回收。这提供了比纯非托管内存更安全又比 GC 托管内存更可控、更高效的生命周期管理。简单来说BlobAsset 就是为了解决“一份数据多处只读”这个经典场景而生的高性能解决方案。它把数据变成了一块“石头”谁都可以看但谁也改不了看完也不用负责打扫自动回收。2.2 核心数据结构与内存布局要理解 BlobAsset必须深入其内存布局。我们通过一个例子来剖析。假设我们要为一个 RPG 游戏构建一个技能配置的 BlobAsset。public struct SkillConfigBlob { public BlobString Name; // BlobString 是 BlobAsset 中专用于字符串的类型 public float Damage; public float Cooldown; public BlobArrayBlobString EffectPrefabPaths; // 一个字符串数组表示特效资源路径 }当我们使用BlobBuilder构建这个结构并最终调用CreateBlobAssetReference后在内存中会形成类似下图的结构内存地址低端 ----------------------- | BlobAssetReference | -- 指向 Header 的指针 ----------------------- | ... | ----------------------- | Header 区 | | - Allocation Size | // 整个Blob内存块的大小 | - Type Hash/Info | // 类型信息用于安全校验 ----------------------- | SkillConfigBlob | | - Name (offset) | -- 指向字符串数据区的偏移量 | - Damage (float) | | - Cooldown (float) | | - EffectPrefabPaths | -- 指向 BlobArray 描述符的偏移量 ----------------------- | 数据区 (Data Region) | | - Fireball字符串 | -- Name 指向这里 | - Fx/FireExplode | \ | - Fx/BurningDebuff | |-- EffectPrefabPaths 数组元素 | - ... | / ----------------------- | BlobArray 描述符 | | - Length (int) | // 数组长度 | - Offset (int) | -- 指向数组第一个元素(Fx/FireExplode)的偏移量 ----------------------- 内存地址高端关键点解析偏移量而非指针SkillConfigBlob结构体里的Name和EffectPrefabPaths字段存储的不是实际的内存地址指针而是从 BlobAsset 起始位置Header之后到目标数据位置的字节偏移量。这使得整个 BlobAsset 可以被完整地序列化到磁盘或者通过网络发送然后在另一个完全不同的内存地址中重建所有内部引用依然有效。Header 信息头部存储了元数据如总大小和类型哈希。BlobAssetReference在解引用时会进行安全检查例如防止你误将一个SkillConfigBlob引用当作WeaponConfigBlob来访问。BlobString 与 BlobArray它们是 BlobAsset 生态系统内的特殊类型。BlobString内部就是一个BlobArraybyte用来存储 UTF-8 编码的字符串。BlobArrayT则是一个描述符包含长度和指向第一个元素的偏移量。它们都遵循同样的偏移量寻址规则。注意你无法在 BlobAsset 中直接存储对另一个BlobAssetReference、托管对象、或 Entity 的引用。它只能包含值类型如 int, float、其他 Blob 结构体、BlobString、BlobArrayT和BlobPtrT指向同一 Blob 内数据的指针。这是保证其内存布局紧凑和可重定位的关键限制。2.3 创建流程从 BlobBuilder 到 BlobAssetReference创建 BlobAsset 是一个“构建-冻结”的两步过程必须在主线程或非并行的代码块中完成。// 1. 创建 BlobBuilder。通常使用 Allocator.Temp因为构建过程是短暂的。 using (var blobBuilder new BlobBuilder(Allocator.Temp)) { // 2. 在Builder中为根结构分配内存并获取一个“重构器”ref ref SkillConfigBlob skillConfig ref blobBuilder.ConstructRootSkillConfigBlob(); // 3. 填充简单字段 skillConfig.Damage 100.5f; skillConfig.Cooldown 2.0f; // 4. 分配并设置字符串 blobBuilder.AllocateString(ref skillConfig.Name, Fireball); // 5. 分配数组并填充 var effectArrayBuilder blobBuilder.Allocate(ref skillConfig.EffectPrefabPaths, 2); // 分配长度为2的数组 effectArrayBuilder[0] blobBuilder.AllocateString(Fx/FireExplode); effectArrayBuilder[1] blobBuilder.AllocateString(Fx/BurningDebuff); // 6. 构建完成创建不可变的 BlobAssetReference BlobAssetReferenceSkillConfigBlob blobRef blobBuilder.CreateBlobAssetReferenceSkillConfigBlob(Allocator.Persistent); } // using 块结束blobBuilder 被释放但 blobRef 指向的数据已独立存在。 // 7. 现在可以将 blobRef 存储到组件中供后续System使用 EntityManager.AddComponentData(enemyEntity, new SkillOwnerComponent { Config blobRef });流程详解与避坑指南BlobBuilder的作用它是一个临时的工作区提供了AllocateString、AllocateArray、ConstructRoot等方法帮你以类型安全的方式计算偏移量和布局内存。它内部使用你指定的Allocator通常是Temp来分配工作内存。ref关键字的重要性ConstructRoot和Allocate方法返回的是ref T。你必须使用ref来接收因为你需要修改的是 Builder 内部缓冲区中对应位置的数据。如果漏了ref你修改的只是一个临时副本构建会失败。分配器的选择Allocator.Temp或Allocator.TempJob用于BlobBuilder本身因为构建过程很短。Allocator.Persistent用于CreateBlobAssetReference。因为 BlobAsset 数据通常需要在整个游戏场景或更长时间内存在。这是最常见的选择。Allocator.Domain用于在 Domain Reload编辑器中脚本重载时仍能存活的数据但使用场景较少。CreateBlobAssetReference的魔法这个方法执行了关键操作它将BlobBuilder内部缓冲区的内容完整地拷贝到一个新的、最终的非托管内存块中并为其加上 Header。然后返回一个指向这块新内存的BlobAssetReference。从此BlobBuilder的内容和最终的 BlobAsset 再无关联Builder 可以被安全释放。生命周期绑定BlobAssetReference本身是一个struct但它内部持有对非托管内存的引用计数。当它被复制时引用计数增加。当调用Dispose()或结构体被覆盖时引用计数减少。计数归零时内存被释放。最佳实践是将BlobAssetReference存储在组件中当持有该组件的实体被销毁时在System中或通过DisposeOnDestroy特性来管理其释放。3. 在 ECS System 中使用 BlobAsset创建好 BlobAsset 后如何在并行的 Job 中安全高效地使用它是下一个核心议题。3.1 在组件中存储引用首先你需要一个组件来持有BlobAssetReference。public struct SkillOwnerComponent : IComponentData { public BlobAssetReferenceSkillConfigBlob SkillConfig; } // 或者更常见的你可能有一个共享组件让大量实体引用同一份配置 public struct EnemySharedConfig : ISharedComponentData { public BlobAssetReferenceEnemyConfigBlob Config; }使用ISharedComponentData的考量如果成千上万的实体都引用同一份 BlobAsset比如同一种小兵的配置使用ISharedComponentData是更优选择。因为 ECS 会根据共享组件对实体进行分组Archetype 下的 Chunk 细分这能进一步提升缓存效率。所有引用同一份EnemySharedConfig的实体会被放在相同的 Chunk 集合中。3.2 在 IJobEntity 中安全读取在 Job 中读取 BlobAsset 是非常直接的得益于其不可变性。[BurstCompile] public partial struct ApplySkillDamageJob : IJobEntity { [ReadOnly] public BlobAssetReferenceSkillConfigBlob SkillConfigBlobRef; // 可以通过参数传入 void Execute(ref HealthComponent health, in SkillOwnerComponent skillOwner) { // 方式1通过组件中的引用访问 var config skillOwner.SkillConfig; // 这是一个 BlobAssetReference if (config.IsCreated) // 安全检查 { // 解引用获取实际数据的只读引用 ref readonly var configData ref config.Value; health.Value - configData.Damage; // 访问数组 var firstEffectPath configData.EffectPrefabPaths[0]; // ... 处理逻辑 } // 方式2使用通过参数传入的全局配置如果所有实体都用同一份 // ref readonly var globalConfig ref SkillConfigBlobRef.Value; // health.Value - globalConfig.BaseDamage; } }关键安全提示IsCreated检查在访问.Value之前务必检查IsCreated。一个默认的BlobAssetReference未赋值其IsCreated为 false访问.Value会抛出异常。ref readonlyblobRef.Value返回的是一个ref readonly T。这强调了数据的只读性并允许你无需拷贝直接访问数据同时防止在代码中意外修改编译器会报错。Burst 兼容性BlobAsset 是完全支持 Burst 编译的。因为其数据都在非托管内存中且寻址通过偏移量计算Burst 编译器可以完美地优化相关代码。3.3 动态创建与加载的陷阱虽然 BlobAsset 强调不可变但有时我们需要根据运行时的条件动态创建不同的配置。常见的模式是在System的OnUpdate之外如OnStartRunning或在一个单次执行的 Job 中创建 BlobAsset然后将引用存储起来。public partial class SkillConfigSystem : SystemBase { private BlobAssetReferenceGlobalSkillConfigBlob _globalConfigRef; protected override void OnCreate() { // 在系统启动时根据一些规则如加载的JSON动态构建配置 _globalConfigRef BuildConfigFromRuntimeData(); // 将引用添加到某个单例实体或共享组件中... } private BlobAssetReferenceGlobalSkillConfigBlob BuildConfigFromRuntimeData() { // ... 使用 BlobBuilder 构建 // 注意这个函数不能在 Job 中调用因为 BlobBuilder 不是线程安全的。 } protected override void OnDestroy() { // 系统销毁时释放 BlobAsset 内存 if (_globalConfigRef.IsCreated) _globalConfigRef.Dispose(); } }陷阱在 Job 中创建 BlobAsset绝对不要试图在IJob、IJobEntity或任何并行 Job 中创建BlobBuilder或调用CreateBlobAssetReference。BlobBuilder不是线程安全的且其内部有复杂的偏移量计算状态。这必然会导致数据损坏或运行时崩溃。BlobAsset 的构建必须是一个串行的、有明确顺序的过程。4. 性能对比与最佳实践4.1 性能优势量化让我们通过一个假设场景来感受 BlobAsset 的威力10,000 个敌人每个需要引用一个包含 50 条技能配置的列表。方案A传统组件每个Enemy实体有一个DynamicBufferSkillConfig组件。每个SkillConfig是一个包含FixedString和多个float的 struct。内存DynamicBuffer本身有开销且每个实体的 Buffer 是独立分配的。10,000 实体 * (Buffer开销 50 * SkillConfig大小) ≈ 数十 MB 内存且内存碎片化严重。缓存每个实体访问自己的 Buffer数据分散缓存局部性差。并行每个 Job 需要访问不同的内存区域可能造成缓存抖动。方案BBlobAsset 共享组件创建一个包含BlobArraySkillConfig的 BlobAsset。所有敌人共享一个EnemySharedConfig组件其中包含对该 BlobAsset 的引用。内存只有一份 BlobAsset 数据50条技能配置存储在连续内存中。10,000 个实体只存储 10,000 个轻量的BlobAssetReference每个8或16字节。总内存节省超过 90%。缓存当第一个敌人读取技能配置时很大一部分相关数据会被加载到 CPU 缓存中。后续敌人读取时命中率极高速度极快。并行所有 Job 线程读取同一块只读内存无竞争效率最大化。4.2 最佳实践清单适用场景判断用静态/只读的游戏数据配置表、本地化字符串、导航网格、预计算的动画曲线、音效ID列表。不用频繁修改的数据、每个实体独有的动态数据、需要存储对 Entity 或托管对象引用的数据。生命周期管理明确所有权。谁创建 (CreateBlobAssetReference)谁就应在适当的时候负责释放 (Dispose())。对于场景全局配置可以在一个System的OnCreate中创建在OnDestroy中释放。对于动态生成的配置考虑使用Allocator.Persistent并在确定不再使用时如关卡卸载集中释放。设计 Blob 结构体优先使用值类型字段。使用BlobString代替string或FixedString。使用BlobArrayT存储集合其中T也必须是 Blob 兼容的类型。避免设计过深或过复杂的嵌套虽然支持但会影响可读性和构建复杂度。错误处理与调试始终在访问前检查blobRef.IsCreated。如果遇到访问冲突或数据错误检查构建代码中的ref关键字是否遗漏以及偏移量计算使用Allocate等方法是否正确。在编辑器模式下你可以使用UnityEngine.Debug.Log来输出BlobAssetReference的信息但注意这些代码在 Burst Job 中无法运行。与 Addressables 配合BlobAsset 本身可以被序列化为字节流。这意味着你可以将构建好的 BlobAsset 数据通过BlobAssetReference的GetUnsafePtr和Length属性保存成二进制文件。在运行时通过 Addressables 加载这个二进制文件然后使用BlobAssetReference.Create从一个byte*指针重新创建BlobAssetReference。这是实现配置表热更新的高级模式。5. 常见问题与排查实录在实际项目中踩过一些坑后我总结了一份问题排查清单问题现象可能原因解决方案访问blobRef.Value时抛出NullReferenceException或无效内存访问1.blobRef未初始化 (IsCreated为 false)。2. BlobAsset 内存已被释放 (Dispose()调用过早)。3.BlobBuilder在CreateBlobAssetReference之前被释放或重用。1. 访问前务必检查if(blobRef.IsCreated)。2. 检查生命周期确保在引用使用期间内存有效。使用using或Dispose模式管理BlobAssetReference。3. 确保CreateBlobAssetReference调用在BlobBuilder的using作用域内或在其释放之前。Job 中读取的数据全是0或乱码在 Job 中错误地尝试通过ref而非ref readonly修改了数据或者构建过程本身有误。1. 确认在 Job 中访问时使用ref readonly。2. 回顾构建代码检查所有Allocate和ConstructRoot的调用是否都正确使用了ref关键字来接收返回值。一个常见的错误是var x blobBuilder.Allocate(...)而不是ref var x ref blobBuilder.Allocate(...)。BlobBuilder.AllocateString导致崩溃传递给AllocateString的字符串为null或者BlobBuilder本身已处于无效状态。1. 确保要存储的字符串不为null如果是可空字符串需先做判断。2. 确保BlobBuilder实例有效且没有在多个线程间共享。尝试在 Blob 结构体中存储Entity或BlobAssetReferenceT编译错误或运行时错误。BlobAsset 内存布局不支持存储这类“外部引用”。改为存储Entity的Index和Version但需自行管理有效性或重新设计数据结构将引用关系外置。例如存储一个int ID在 System 中通过 ID 查询另一个 BlobAsset 或 Entity。内存泄漏BlobAsset 未被释放创建了BlobAssetReference但从未调用Dispose()尤其是在动态创建大量临时 BlobAsset 的场景。1. 对于长期存在的配置在场景或游戏模块卸载时统一释放。2. 对于临时使用的 BlobAsset使用using语句包裹BlobAssetReference。3. 可以利用DisposeOnDestroy特性标记组件当实体销毁时自动释放其持有的BlobAssetReference需配合ICleanupComponentData。一个关于“ref遗漏”的深度案例这是我早期踩过的一个大坑。代码如下using (var builder new BlobBuilder(Allocator.Temp)) { ref var root ref builder.ConstructRootMyBlob(); // 正确用了ref // 错误做法 BlobArrayfloat array builder.Allocate(ref root.MyArray, 10); // 这里返回的是 BlobArrayfloat但我们需要的是 ref BlobArrayfloat array[0] 1.0f; // 修改的是临时副本builder 内部的数据没变 // 正确做法 ref var arrayRef ref builder.Allocate(ref root.MyArray, 10); // 注意这里的 ref arrayRef[0] 1.0f; // 现在修改的是 builder 内部的数据 // ... }这个错误非常隐蔽因为代码能编译也不会立即崩溃。但最终创建的 BlobAsset 中数组数据全是默认值。牢记所有修改 BlobBuilder 内部数据的操作都必须通过ref来进行。BlobAsset 是 DOTS 工具箱里的一件利器它用“冻结的数据”换来了极致的并行读取性能。刚开始接触时你可能会觉得它的构建 API 有点绕但一旦理解了其“连续内存偏移量”的核心思想并习惯了ref的使用方式它就会成为你处理静态数据的首选方案。尤其是在配置数据驱动的游戏逻辑、AI行为树、对话系统等场景它能带来的内存和性能收益是立竿见影的。下次当你看到一堆实体需要共享同一份庞大的只读数据时别再犹豫试试用 BlobAsset 来优化它吧。
Unity DOTS BlobAsset:ECS架构下高性能只读数据共享方案详解
1. 项目概述为什么我们需要 BlobAsset如果你已经跟着 DOTS 系列一路走来从 Entity 创建到 System 调度再到 IComponentData 和 IJobEntity你应该已经感受到了 ECS 架构在性能上的巨大潜力。数据紧密排列缓存命中率极高Job 并行处理得飞起。但不知道你有没有遇到过这样的场景你的成千上万个 Enemy 实体都需要引用同一份庞大的、不可变的配置数据比如一个包含所有技能伤害、冷却时间、特效ID的表格。如果给每个 Enemy 都挂一个DynamicBufferSkillConfig组件内存会瞬间爆炸而且这份数据在运行时根本不会改变复制那么多份纯属浪费。又或者你需要一个复杂的、预先计算好的导航网格NavMesh数据供所有寻路实体查询。这份数据同样巨大且只读。在传统的面向对象编程里我们可能会用一个单例Singleton或者静态类来持有这份数据然后在各处引用。但在 ECS 的纯数据世界里我们如何安全、高效地在多个 Job 中共享这样一份“全局”的、只读的数据块呢这就是BlobAsset登场的时刻。你可以把它理解为 ECS 世界里的“不可变数据块”或“二进制大对象”。它的核心设计目标就两个极致的内存效率和安全的线程共享。所有数据在创建后就被“冻结”无法修改因此可以被任意数量的 Job 同时读取无需加锁完美契合 DOTS 的并行哲学。它不是 Entity也不是 Component而是一种特殊的数据资产通过BlobAssetReferenceT这个“智能指针”在组件中被引用。理解 BlobAsset是掌握 DOTS 进行大规模数据驱动开发的关键一步它能将你的游戏从“能跑”优化到“跑得飞快且内存清爽”。2. BlobAsset 核心机制深度拆解2.1 设计哲学为什么是“Blob”Blob 是 Binary Large Object 的缩写但在 DOTS 的语境下它的含义更侧重于“连续的内存块”。与传统的托管对象在 C# 堆上分配由 GC 管理或普通的struct组件在 Chunk 中紧密排列不同BlobAsset 是在一个特殊的、非托管的内存区域BlobBuilder 分配的内存中构建的。它的设计哲学根植于数据导向设计的几个核心原则数据局部性BlobAsset 内部的所有数据包括嵌套结构、字符串、数组都存储在一个连续的内存块中。当你的 Job 通过BlobAssetReference访问它时CPU 可以高效地将相关数据预加载到缓存中减少缓存未命中。不可变性Immutability这是实现无锁并发的基石。一旦 BlobAsset 构建完成并创建了引用其底层内存内容就被视为只读。任何试图修改的操作都会在运行时导致异常。这消除了数据竞争的风险让并行读取变得绝对安全。确定性内存布局BlobAsset 的内存布局在构建时就被完全确定。它使用相对偏移量offset来引用内部的其他数据如数组元素、嵌套结构而不是传统的对象引用。这意味着BlobAssetReference本身只是一个指向这块内存起始位置的小型句柄复制和传递的成本极低。生命周期管理BlobAssetReferenceT实现了引用计数。当最后一个引用被释放Dispose时底层的非托管内存会被自动回收。这提供了比纯非托管内存更安全又比 GC 托管内存更可控、更高效的生命周期管理。简单来说BlobAsset 就是为了解决“一份数据多处只读”这个经典场景而生的高性能解决方案。它把数据变成了一块“石头”谁都可以看但谁也改不了看完也不用负责打扫自动回收。2.2 核心数据结构与内存布局要理解 BlobAsset必须深入其内存布局。我们通过一个例子来剖析。假设我们要为一个 RPG 游戏构建一个技能配置的 BlobAsset。public struct SkillConfigBlob { public BlobString Name; // BlobString 是 BlobAsset 中专用于字符串的类型 public float Damage; public float Cooldown; public BlobArrayBlobString EffectPrefabPaths; // 一个字符串数组表示特效资源路径 }当我们使用BlobBuilder构建这个结构并最终调用CreateBlobAssetReference后在内存中会形成类似下图的结构内存地址低端 ----------------------- | BlobAssetReference | -- 指向 Header 的指针 ----------------------- | ... | ----------------------- | Header 区 | | - Allocation Size | // 整个Blob内存块的大小 | - Type Hash/Info | // 类型信息用于安全校验 ----------------------- | SkillConfigBlob | | - Name (offset) | -- 指向字符串数据区的偏移量 | - Damage (float) | | - Cooldown (float) | | - EffectPrefabPaths | -- 指向 BlobArray 描述符的偏移量 ----------------------- | 数据区 (Data Region) | | - Fireball字符串 | -- Name 指向这里 | - Fx/FireExplode | \ | - Fx/BurningDebuff | |-- EffectPrefabPaths 数组元素 | - ... | / ----------------------- | BlobArray 描述符 | | - Length (int) | // 数组长度 | - Offset (int) | -- 指向数组第一个元素(Fx/FireExplode)的偏移量 ----------------------- 内存地址高端关键点解析偏移量而非指针SkillConfigBlob结构体里的Name和EffectPrefabPaths字段存储的不是实际的内存地址指针而是从 BlobAsset 起始位置Header之后到目标数据位置的字节偏移量。这使得整个 BlobAsset 可以被完整地序列化到磁盘或者通过网络发送然后在另一个完全不同的内存地址中重建所有内部引用依然有效。Header 信息头部存储了元数据如总大小和类型哈希。BlobAssetReference在解引用时会进行安全检查例如防止你误将一个SkillConfigBlob引用当作WeaponConfigBlob来访问。BlobString 与 BlobArray它们是 BlobAsset 生态系统内的特殊类型。BlobString内部就是一个BlobArraybyte用来存储 UTF-8 编码的字符串。BlobArrayT则是一个描述符包含长度和指向第一个元素的偏移量。它们都遵循同样的偏移量寻址规则。注意你无法在 BlobAsset 中直接存储对另一个BlobAssetReference、托管对象、或 Entity 的引用。它只能包含值类型如 int, float、其他 Blob 结构体、BlobString、BlobArrayT和BlobPtrT指向同一 Blob 内数据的指针。这是保证其内存布局紧凑和可重定位的关键限制。2.3 创建流程从 BlobBuilder 到 BlobAssetReference创建 BlobAsset 是一个“构建-冻结”的两步过程必须在主线程或非并行的代码块中完成。// 1. 创建 BlobBuilder。通常使用 Allocator.Temp因为构建过程是短暂的。 using (var blobBuilder new BlobBuilder(Allocator.Temp)) { // 2. 在Builder中为根结构分配内存并获取一个“重构器”ref ref SkillConfigBlob skillConfig ref blobBuilder.ConstructRootSkillConfigBlob(); // 3. 填充简单字段 skillConfig.Damage 100.5f; skillConfig.Cooldown 2.0f; // 4. 分配并设置字符串 blobBuilder.AllocateString(ref skillConfig.Name, Fireball); // 5. 分配数组并填充 var effectArrayBuilder blobBuilder.Allocate(ref skillConfig.EffectPrefabPaths, 2); // 分配长度为2的数组 effectArrayBuilder[0] blobBuilder.AllocateString(Fx/FireExplode); effectArrayBuilder[1] blobBuilder.AllocateString(Fx/BurningDebuff); // 6. 构建完成创建不可变的 BlobAssetReference BlobAssetReferenceSkillConfigBlob blobRef blobBuilder.CreateBlobAssetReferenceSkillConfigBlob(Allocator.Persistent); } // using 块结束blobBuilder 被释放但 blobRef 指向的数据已独立存在。 // 7. 现在可以将 blobRef 存储到组件中供后续System使用 EntityManager.AddComponentData(enemyEntity, new SkillOwnerComponent { Config blobRef });流程详解与避坑指南BlobBuilder的作用它是一个临时的工作区提供了AllocateString、AllocateArray、ConstructRoot等方法帮你以类型安全的方式计算偏移量和布局内存。它内部使用你指定的Allocator通常是Temp来分配工作内存。ref关键字的重要性ConstructRoot和Allocate方法返回的是ref T。你必须使用ref来接收因为你需要修改的是 Builder 内部缓冲区中对应位置的数据。如果漏了ref你修改的只是一个临时副本构建会失败。分配器的选择Allocator.Temp或Allocator.TempJob用于BlobBuilder本身因为构建过程很短。Allocator.Persistent用于CreateBlobAssetReference。因为 BlobAsset 数据通常需要在整个游戏场景或更长时间内存在。这是最常见的选择。Allocator.Domain用于在 Domain Reload编辑器中脚本重载时仍能存活的数据但使用场景较少。CreateBlobAssetReference的魔法这个方法执行了关键操作它将BlobBuilder内部缓冲区的内容完整地拷贝到一个新的、最终的非托管内存块中并为其加上 Header。然后返回一个指向这块新内存的BlobAssetReference。从此BlobBuilder的内容和最终的 BlobAsset 再无关联Builder 可以被安全释放。生命周期绑定BlobAssetReference本身是一个struct但它内部持有对非托管内存的引用计数。当它被复制时引用计数增加。当调用Dispose()或结构体被覆盖时引用计数减少。计数归零时内存被释放。最佳实践是将BlobAssetReference存储在组件中当持有该组件的实体被销毁时在System中或通过DisposeOnDestroy特性来管理其释放。3. 在 ECS System 中使用 BlobAsset创建好 BlobAsset 后如何在并行的 Job 中安全高效地使用它是下一个核心议题。3.1 在组件中存储引用首先你需要一个组件来持有BlobAssetReference。public struct SkillOwnerComponent : IComponentData { public BlobAssetReferenceSkillConfigBlob SkillConfig; } // 或者更常见的你可能有一个共享组件让大量实体引用同一份配置 public struct EnemySharedConfig : ISharedComponentData { public BlobAssetReferenceEnemyConfigBlob Config; }使用ISharedComponentData的考量如果成千上万的实体都引用同一份 BlobAsset比如同一种小兵的配置使用ISharedComponentData是更优选择。因为 ECS 会根据共享组件对实体进行分组Archetype 下的 Chunk 细分这能进一步提升缓存效率。所有引用同一份EnemySharedConfig的实体会被放在相同的 Chunk 集合中。3.2 在 IJobEntity 中安全读取在 Job 中读取 BlobAsset 是非常直接的得益于其不可变性。[BurstCompile] public partial struct ApplySkillDamageJob : IJobEntity { [ReadOnly] public BlobAssetReferenceSkillConfigBlob SkillConfigBlobRef; // 可以通过参数传入 void Execute(ref HealthComponent health, in SkillOwnerComponent skillOwner) { // 方式1通过组件中的引用访问 var config skillOwner.SkillConfig; // 这是一个 BlobAssetReference if (config.IsCreated) // 安全检查 { // 解引用获取实际数据的只读引用 ref readonly var configData ref config.Value; health.Value - configData.Damage; // 访问数组 var firstEffectPath configData.EffectPrefabPaths[0]; // ... 处理逻辑 } // 方式2使用通过参数传入的全局配置如果所有实体都用同一份 // ref readonly var globalConfig ref SkillConfigBlobRef.Value; // health.Value - globalConfig.BaseDamage; } }关键安全提示IsCreated检查在访问.Value之前务必检查IsCreated。一个默认的BlobAssetReference未赋值其IsCreated为 false访问.Value会抛出异常。ref readonlyblobRef.Value返回的是一个ref readonly T。这强调了数据的只读性并允许你无需拷贝直接访问数据同时防止在代码中意外修改编译器会报错。Burst 兼容性BlobAsset 是完全支持 Burst 编译的。因为其数据都在非托管内存中且寻址通过偏移量计算Burst 编译器可以完美地优化相关代码。3.3 动态创建与加载的陷阱虽然 BlobAsset 强调不可变但有时我们需要根据运行时的条件动态创建不同的配置。常见的模式是在System的OnUpdate之外如OnStartRunning或在一个单次执行的 Job 中创建 BlobAsset然后将引用存储起来。public partial class SkillConfigSystem : SystemBase { private BlobAssetReferenceGlobalSkillConfigBlob _globalConfigRef; protected override void OnCreate() { // 在系统启动时根据一些规则如加载的JSON动态构建配置 _globalConfigRef BuildConfigFromRuntimeData(); // 将引用添加到某个单例实体或共享组件中... } private BlobAssetReferenceGlobalSkillConfigBlob BuildConfigFromRuntimeData() { // ... 使用 BlobBuilder 构建 // 注意这个函数不能在 Job 中调用因为 BlobBuilder 不是线程安全的。 } protected override void OnDestroy() { // 系统销毁时释放 BlobAsset 内存 if (_globalConfigRef.IsCreated) _globalConfigRef.Dispose(); } }陷阱在 Job 中创建 BlobAsset绝对不要试图在IJob、IJobEntity或任何并行 Job 中创建BlobBuilder或调用CreateBlobAssetReference。BlobBuilder不是线程安全的且其内部有复杂的偏移量计算状态。这必然会导致数据损坏或运行时崩溃。BlobAsset 的构建必须是一个串行的、有明确顺序的过程。4. 性能对比与最佳实践4.1 性能优势量化让我们通过一个假设场景来感受 BlobAsset 的威力10,000 个敌人每个需要引用一个包含 50 条技能配置的列表。方案A传统组件每个Enemy实体有一个DynamicBufferSkillConfig组件。每个SkillConfig是一个包含FixedString和多个float的 struct。内存DynamicBuffer本身有开销且每个实体的 Buffer 是独立分配的。10,000 实体 * (Buffer开销 50 * SkillConfig大小) ≈ 数十 MB 内存且内存碎片化严重。缓存每个实体访问自己的 Buffer数据分散缓存局部性差。并行每个 Job 需要访问不同的内存区域可能造成缓存抖动。方案BBlobAsset 共享组件创建一个包含BlobArraySkillConfig的 BlobAsset。所有敌人共享一个EnemySharedConfig组件其中包含对该 BlobAsset 的引用。内存只有一份 BlobAsset 数据50条技能配置存储在连续内存中。10,000 个实体只存储 10,000 个轻量的BlobAssetReference每个8或16字节。总内存节省超过 90%。缓存当第一个敌人读取技能配置时很大一部分相关数据会被加载到 CPU 缓存中。后续敌人读取时命中率极高速度极快。并行所有 Job 线程读取同一块只读内存无竞争效率最大化。4.2 最佳实践清单适用场景判断用静态/只读的游戏数据配置表、本地化字符串、导航网格、预计算的动画曲线、音效ID列表。不用频繁修改的数据、每个实体独有的动态数据、需要存储对 Entity 或托管对象引用的数据。生命周期管理明确所有权。谁创建 (CreateBlobAssetReference)谁就应在适当的时候负责释放 (Dispose())。对于场景全局配置可以在一个System的OnCreate中创建在OnDestroy中释放。对于动态生成的配置考虑使用Allocator.Persistent并在确定不再使用时如关卡卸载集中释放。设计 Blob 结构体优先使用值类型字段。使用BlobString代替string或FixedString。使用BlobArrayT存储集合其中T也必须是 Blob 兼容的类型。避免设计过深或过复杂的嵌套虽然支持但会影响可读性和构建复杂度。错误处理与调试始终在访问前检查blobRef.IsCreated。如果遇到访问冲突或数据错误检查构建代码中的ref关键字是否遗漏以及偏移量计算使用Allocate等方法是否正确。在编辑器模式下你可以使用UnityEngine.Debug.Log来输出BlobAssetReference的信息但注意这些代码在 Burst Job 中无法运行。与 Addressables 配合BlobAsset 本身可以被序列化为字节流。这意味着你可以将构建好的 BlobAsset 数据通过BlobAssetReference的GetUnsafePtr和Length属性保存成二进制文件。在运行时通过 Addressables 加载这个二进制文件然后使用BlobAssetReference.Create从一个byte*指针重新创建BlobAssetReference。这是实现配置表热更新的高级模式。5. 常见问题与排查实录在实际项目中踩过一些坑后我总结了一份问题排查清单问题现象可能原因解决方案访问blobRef.Value时抛出NullReferenceException或无效内存访问1.blobRef未初始化 (IsCreated为 false)。2. BlobAsset 内存已被释放 (Dispose()调用过早)。3.BlobBuilder在CreateBlobAssetReference之前被释放或重用。1. 访问前务必检查if(blobRef.IsCreated)。2. 检查生命周期确保在引用使用期间内存有效。使用using或Dispose模式管理BlobAssetReference。3. 确保CreateBlobAssetReference调用在BlobBuilder的using作用域内或在其释放之前。Job 中读取的数据全是0或乱码在 Job 中错误地尝试通过ref而非ref readonly修改了数据或者构建过程本身有误。1. 确认在 Job 中访问时使用ref readonly。2. 回顾构建代码检查所有Allocate和ConstructRoot的调用是否都正确使用了ref关键字来接收返回值。一个常见的错误是var x blobBuilder.Allocate(...)而不是ref var x ref blobBuilder.Allocate(...)。BlobBuilder.AllocateString导致崩溃传递给AllocateString的字符串为null或者BlobBuilder本身已处于无效状态。1. 确保要存储的字符串不为null如果是可空字符串需先做判断。2. 确保BlobBuilder实例有效且没有在多个线程间共享。尝试在 Blob 结构体中存储Entity或BlobAssetReferenceT编译错误或运行时错误。BlobAsset 内存布局不支持存储这类“外部引用”。改为存储Entity的Index和Version但需自行管理有效性或重新设计数据结构将引用关系外置。例如存储一个int ID在 System 中通过 ID 查询另一个 BlobAsset 或 Entity。内存泄漏BlobAsset 未被释放创建了BlobAssetReference但从未调用Dispose()尤其是在动态创建大量临时 BlobAsset 的场景。1. 对于长期存在的配置在场景或游戏模块卸载时统一释放。2. 对于临时使用的 BlobAsset使用using语句包裹BlobAssetReference。3. 可以利用DisposeOnDestroy特性标记组件当实体销毁时自动释放其持有的BlobAssetReference需配合ICleanupComponentData。一个关于“ref遗漏”的深度案例这是我早期踩过的一个大坑。代码如下using (var builder new BlobBuilder(Allocator.Temp)) { ref var root ref builder.ConstructRootMyBlob(); // 正确用了ref // 错误做法 BlobArrayfloat array builder.Allocate(ref root.MyArray, 10); // 这里返回的是 BlobArrayfloat但我们需要的是 ref BlobArrayfloat array[0] 1.0f; // 修改的是临时副本builder 内部的数据没变 // 正确做法 ref var arrayRef ref builder.Allocate(ref root.MyArray, 10); // 注意这里的 ref arrayRef[0] 1.0f; // 现在修改的是 builder 内部的数据 // ... }这个错误非常隐蔽因为代码能编译也不会立即崩溃。但最终创建的 BlobAsset 中数组数据全是默认值。牢记所有修改 BlobBuilder 内部数据的操作都必须通过ref来进行。BlobAsset 是 DOTS 工具箱里的一件利器它用“冻结的数据”换来了极致的并行读取性能。刚开始接触时你可能会觉得它的构建 API 有点绕但一旦理解了其“连续内存偏移量”的核心思想并习惯了ref的使用方式它就会成为你处理静态数据的首选方案。尤其是在配置数据驱动的游戏逻辑、AI行为树、对话系统等场景它能带来的内存和性能收益是立竿见影的。下次当你看到一堆实体需要共享同一份庞大的只读数据时别再犹豫试试用 BlobAsset 来优化它吧。