资源
项目里每个资源都有稳定的 UUID。场景与预制体按 UUID 引用资源;构建时引擎只烘焙 (cook)游戏能触达的部分,运行时通过清单(manifest)解析引用,按类型的加载器 把引用变成可用句柄,引用计数再释放不再用的资源。本篇逐一讲清。
在代码里,按资源的项目相对路径引用它——可读,也是你平常会写的:
const tex = await assets.loadTexture('textures/player.png');而编辑器会把引用序列化进场景/预制体时写成稳定 UUID(@uuid:<uuid>),这样文件被
改名或移动后引用仍然有效,并自动解析——@uuid: 不是给你手写的,是编辑器写的。两种
形式所有加载器都认、解析到同一路径。resolveRef(ref) 会返回两者解析后的路径。
Assets 资源为每种资源类型提供一个按类型的加载器。每个返回一个句柄或 id,你把它设到
组件上。例如加载一张贴图并显示在 Sprite 上:
import { defineSystem, Query, Mut, Res, Sprite, Assets } from 'esengine';
const loadArt = defineSystem([Query(Mut(Sprite)), Res(Assets)], async (q, assets) => { const tex = await assets.loadTexture('textures/player.png'); // { handle, width, height } for (const [entity, sprite] of q) { sprite.texture = tex.handle; }});| 加载器 | 返回 |
|---|---|
loadTexture(ref) |
{ handle, width, height } |
loadAudio(ref) |
{ bufferId } |
loadFont(ref) |
{ handle } |
loadMaterial(ref) |
{ handle, shaderHandle } |
loadSpine(skeletonRef, atlasRef?) |
{ skeletonHandle } |
loadTilemap(ref) / loadTileset(ref) |
{ sourceId } / { tilesetId } |
loadTimeline(ref) |
{ timelineId } |
loadPrefab(ref) |
{ data } |
还有一个通用的 load<T>(type, ref),当类型是动态决定时用;以及 register(loader)
为自定义资源类型添加加载器。
清单(manifest)
Section titled “清单(manifest)”运行时通过清单把 @uuid 引用解析为文件路径:
assets.setManifest(manifest); // 通常运行时会替你设好清单由烘焙(见下)产出。getManifest() 返回当前清单。不在清单里的引用会回退到直接
路径查找。
寻址组(懒加载)
Section titled “寻址组(懒加载)”位于 subpackages/<name>/… 文件夹下的资源组成惰性分组 <name>;其余属于会被急切加载的
main 分组。按需加载某个分组——带进度——它会先下载再加载其中的资源(在微信上会触发
wx.loadSubpackage;在 Web 上下载是空操作):
const bundle = await assets.loadGroup('level2', (loaded, total) => { console.log(`${loaded}/${total}`);});
// ...玩家离开这个组对应的区域时:assets.releaseGroup('level2');releaseGroup 是对称的另一半:组里获取过的每个资源都经其类型的规范通道释放,由引用计数
决定实际发生什么——别的场景或组还持有的资源会存活,没人持有的降级为可逐出热缓存。之后玩家
折返该区域时由热缓存吸收,而不是重走网络。
loadByLabel(...) 按 label 标签加载资源,而不是按文件夹分组。正是这种分组映射到
微信分包,实现按需下载。
当你想预热的资源集合不是一个组或标签时——比如玩家还在当前区域,就先加载下一区域的
纹理——preload 接受显式的引用列表,自动推断每个资源的类型(清单优先,其次文件扩展名),
经同一套按类型通道以受限并发加载:
const { failed } = await assets.preload( ['textures/boss.png', 'audio/boss-theme.mp3', 'maps/arena.estilemap'], (loaded, total) => console.log(`${loaded}/${total}`),);它永远不会 reject——无法确定类型或加载失败的引用会记录在 failed 里。之后真正的
加载会命中已预热的缓存(或复活驻留纹理),而不是走网络。
烘焙、压缩与内容寻址
Section titled “烘焙、压缩与内容寻址”构建时,烘焙从构建里的每个场景(打包对话框的构建场景列表)出发遍历依赖图, 剔除一切触达不到的资源,暂存可达文件,并写出清单。过程中它还能:
- 压缩纹理 —— 把 PNG 编码为 GPU 就绪的 KTX2(Basis Universal),运行时再转码为 设备支持的最佳格式(ASTC → ETC2 → S3TC,最后回退 RGBA8),连同 mip 链。这由逐资产的 导入设置决定,每个平台一个页签(见下图),所以一张纹理可以为手机压得比网页端更狠; 打包对话框的资源压缩开关只选择遵循还是全部跳过这些设置。
- 内容寻址 —— 每个发布文件按字节哈希命名,于是 URL 不可变,相同文件去重成一份。 Web/桌面默认开启。
- 自动图集 —— 把零散的 PNG 放进
<name>.atlas/文件夹,烘焙时会把它们打包成图集页 (帧 UV 写进清单),于是许多小精灵作为一张纹理发布、在一次绘制里合批。

一张纹理的平台覆盖:每个目标各有页签;没有覆盖任何东西的平台,沿用上方的默认设置。
只有代码点名的资产
Section titled “只有代码点名的资产”可达性分析正是拦住“什么都往包里塞”的东西——而它对你的代码在运行时才点名的一切是瞎的: 富文本标记里的纹理、按 url 播放的音频、按路径 spawn 的预制体。这些会被剔除,而且没有任何警告, 因为编辑器直接从磁盘伺服项目,压根不需要清单。第一个症状是真实构建里一个没声的按钮或一张缺失的图。
那就把这个文件夹声明出来。在内容浏览器里右键它,选 交付方式 → 总是打进构建——
就是那个设置本地 / 分包 / 远端交付的同一个菜单,写进同一份
.esengine/asset-groups.json:

右键文件夹 → 交付方式。三种模式决定资产从哪里发布;总是打进构建决定它到底发不发。
{ "version": "1.0", "groups": { "markup-images": { "folder": "assets/textures", "mode": "local", "alwaysInclude": true } }}它就是 Unity 的 Resources 文件夹、Unreal 的 additional cook directory,
只不过落在这个项目原本就有的模型里。默认关闭,这是刻意的。
生命周期与引用计数
Section titled “生命周期与引用计数”加载的资源是引用计数的:每次 load*() 计一个引用,需要一次匹配的 release*()
(场景加载会替你完成这两步)。被两个场景共享的资源在第一个场景卸载后仍然存活,只有最后一个
持有者释放时才真正释放。
释放的资源也不会立即销毁:纹理和解码后的音频缓冲区以可逐出缓存条目的形式留驻,同一资源 再次被加载(重进场景、流式加载重新进入某区域)时会被原地复活,而不是重新下载和解码。 一个按字节预算的 LRU 在驻留量超过预算时逐出最旧的未引用条目;收到系统内存警告时整个热缓存 一次性清空(持有中的资源不受影响)。你一般不用手动管理——正常引用和销毁实体即可。
纹理预算默认 64 MB(RuntimeConfig.textureCacheBudget,也可在构建配置里用
textureCacheBudget 设置);解码音频有自己的 32 MB 镜像(audioCacheBudget,
见音频指南)。
纹理内存与预算
Section titled “纹理内存与预算”纹理是真正要紧的预算——它们主宰 GPU 内存。引擎启动时应用
RuntimeConfig.textureCacheBudget;用 setTextureBudget 按你的游戏调它的大小(它是
C++ 池之上唯一面向游戏的表面——没有一个会与之漂移的平行 TS 侧预算),用
getResourceStats 观测驻留:
import { setTextureBudget, getResourceStats, trimTextureCache } from 'esengine';
setTextureBudget(256 * 1024 * 1024); // 256 MB texture budget
const stats = getResourceStats(); // 引擎初始化前为 null// stats.textureBytes — 常驻纹理字节数(持有 + 可逐出)// stats.textureBudget — 当前预算(0 = 逐出关闭)// stats.textureEvictableCount — 等待复活或逐出的热缓存条目数// …还有 shaderCount / textureCount / vertexBufferCount / indexBufferCount// 与 cacheHits / cacheMisses
const freed = trimTextureCache(); // 立即丢弃所有可逐出纹理这里的“GC”就是上文的 LRU:一旦 textureBytes 超出 textureBudget,C++ 池逐出最久未用的
无引用纹理直到装下——持有中的纹理绝不会被动。setTextureBudget(0) 完全关闭热缓存
(每个纹理在最后一个引用释放的瞬间销毁),trimTextureCache() 一次性释放所有可逐出条目并
返回数量——引擎在系统内存警告时会调用它;在已知的内存尖峰之前也可以自己调。
还有两个小帮手补全这个表面:getTextureDimensions(handle) 返回纹理的
{ width, height }(首次查询后缓存在 TS 侧),evictTextureDimensions(handle) 丢弃那条
缓存——资源系统在纹理真正释放时会这么做,所以只有绕过资源系统自己销毁重建纹理时才需要它。
想逐帧观察这些数字(编辑器内和运行时),见性能分析。 逐纹理的采样/过滤参数(像素风 nearest 还是平滑 linear)见 精灵指南。
- 代码里按路径引用,让编辑器写
@uuid:——绝不手写 UUID 引用。 - 在关卡边界用
loadGroup/loadByLabel预加载,别让游戏在场景中途卡顿。 - 把按需内容放到
subpackages/<name>/下,它就成为一个 lazy 组(以及微信子包),而不是 撑大主包。 - 让 cook 去剔除 + 压缩——只暂存可达资源、PNG→KTX2 让发布包更小;别发能被 cook 的裸 PNG。
- 把代码按名字加载的文件夹标记为 交付方式 → 总是打进构建,否则可达性剔除会把它们从构建里丢掉—— 而编辑器从来不需要清单就能跑起来。
- 在内存受限的目标上设纹理预算,其余交给引用计数。