图片加载三层优化设计

07/28综合
目标场景:地图编辑器"物品/墙体/地面/房间/人员"调色板弹窗的图片首帧显示 文档目的:沉淀"图片为什么慢、怎么治、发布后玩家体验如何、未来加资源时 preload_groups 怎么取舍"的完整结论,避免重复踩坑。
  1. 一句话结论 WASM 下图片"慢"不是单一问题,而是下载 / 解码 / 上传 GPU 三件事全堆在"玩家点弹窗那一刻"做导致的。优化的本质是把这三件事提前到启动期做(让玩家点的时候图已在 GPU 里),辅以缩图把要搬的货变轻。浏览器磁盘缓存只是"顺手让下次启动也快",不是优化生效的主因。
  2. 问题表象 打开"物品"弹窗时观察到三帧渐进过程:
弹窗骨架(搜索框+分类标签)出现,卡片区域全空; 图片逐批补出来; 全部显示。 缩图后仍复现,说明"解码像素量"不是根因。
  1. 根因:三阶段全卡在操作时刻 WASM 运行时里,一张 PNG 从"文件"变成"屏幕上看得见"要过三道关,且都是同步阻塞:
阶段 干什么 卡在哪 谁负责 ① 下载 从 CDN 把 PNG 字节拉进内存 等网络往返 引擎 DWP / 浏览器 ② 解码 字节 → 像素位图 CPU 同步阻塞 nvgCreateImage 内部 ③ 上传 位图 → GPU 纹理 同步阻塞 nvgCreateImage 内部 ImageCache.Get(path) 第一次调用时一把做完 ②③,而 ① 在此之前必须完成。31 张图 = 31 次串行/并发的"下载+解码+上传",全压在你点弹窗那一帧 → 卡顿 + 渐进显示。
  1. 三层优化(各打掉一层,核心都是"时机前移") 层 1:资源体积层 —— 缩图(让货变轻) 做法:用 PIL Image.LANCZOS 把调色板会用到的大图等比缩到 128px(最长边),PNG optimize=True 保存。
交互物品 21 张:512×512 / 515×768 → 128×128 / 128×192(3.19MB → 241KB) 家具/墙/门/设施 27 张:512–1835px → 128px(5.3MB → 255KB) 地面平铺纹理 4 张:512×512 → 128×128(1.2MB → 75KB) 为什么不伤画质:
卡片缩略图显示区仅 88×50px,128px 仍有 1.5× 余量; 地面平铺 tileSize 仅 40–64px 一格,128px 纹理每格 2–3 倍像素余量,无缝性不变; 运行地图 300% 缩放下物件也才 45–60px。 保留原尺寸的两类(缩了会伤画质,且它们不在弹窗首帧瓶颈上):
角色精灵(320×340,需要清晰朝向); 真·大场景平铺(如有更大 tileSize 需求时再议)。 层 2:解码层 —— 渲染帧内预热 ImageCache(把解码+上传挪到启动后) 关键陷阱(本轮最值钱的发现):
lua
复制 -- urhox-libs/UI/Core/ImageCache.lua function ImageCache.Get(path) if not nvgContext_ then return 0 end -- ★ 上下文为 nil 直接返回,什么都不做 ... end nvgContext_ 只在每帧渲染开始的瞬间有效:
lua
复制 -- UI.lua 内部:渲染开始 SetContext(nvg_),渲染结束 SetContext(nil) eventScriptObject_:SubscribeToEvent(nvg_, "NanoVGRender", function(...) UI.Render() end) → 在 UI 构建阶段(CreateLeftToolPanel 里)调 ImageCache.Get 预热,nvgContext_ 是 nil,31 次调用全部静默返回 0,一张都没预热。这就是早期"加了预加载却没效果"的原因。
正确做法:在渲染帧内预热。侧栏模块用全局命名函数 + 全局事件订阅:
lua
复制 -- scripts/页面/地图编辑/地图编辑侧栏.lua local imagesPreloaded_ = false local paletteItemsRef_ = nil
-- 全局渲染帧回调:NanoVGRender 触发时 nvg 上下文有效,此刻预热才真生效 function SidebarPreloadImages(eventType, eventData) if imagesPreloaded or not paletteItemsRef_ then return end imagesPreloaded_ = true for catId, items in pairs(paletteItemsRef_) do if type(items) == "table" then for i = 1, #items do local img = items[i] and items[i].image if img then ImageCache.Get(img) end -- 此刻解码+上传 GPU end end end end
function MapEditorSidebar.Create(options) paletteItemsRef_ = options.paletteItems if not imagesPreloaded_ then SubscribeToEvent("NanoVGRender", "_SidebarPreloadImages") -- 全局订阅,下一帧触发 end ... end 注:NanoVGRender 在引擎里绑定到 UI 内部 nvg 对象,外部拿不到该 sender,因此用全局函数名订阅(SubscribeToEvent("NanoVGRender", "_SidebarPreloadImages"))而非对象绑定回调。函数必须全局可见(模块顶层 function _SidebarPreloadImages 定义,不能是 local)。
层 3:网络层 —— 启动预下载(把下载挪到启动期,真正的根因层) 配置位置:.project/resources.json
jsonc
复制 { "preload_groups": ["default"], // 由 [] 改为 ["default"] "groups": { "default": [""] } } preload_groups groups 含义 启动行为 [] [""] 全量打包 + 全 DWP 按需下载 启动零下载,每张图首次用才发 HTTP(渐进显示的根因) ["default"] ["**"] 全量打包 + 启动全预下载 启动时整包拉到本地缓存,之后命中本地,零网络等待 为什么这是根因层:层 1 只减小下载字节,层 2 只挪解码时机,但①下载本身要等网络往返这一事实没变——弹窗一开 31 张图各自触发 HTTP,浏览器并发排队 = 渐进显示。预下载把"下载"也前移到启动 loading 里,三件事才全部脱离"操作时刻"。
  1. 为什么"照着重画图标"是负优化(不做) 维度 位图(当前) NanoVG 矢量手画 首次成本 下载+解码(已被层2/3前移到启动期摊薄) 无下载,但要手写 path 运行时成本 GPU 贴一张纹理(极省) 每帧重画十几条 fill/path(更费) 识别度(88×50 卡片) 带木纹/屏幕/齿纹的细节保留 只能火柴人级简化,更难认 维护成本 丢张 PNG 进注册表 31 件逐一调对齐+适配手持尺寸 矢量手画只适合纯色几何符号(箭头/加号/状态点)。本项目物品不属于此类。
  2. 发布后玩家体验预期(诚实版,不给"永不慢"假承诺) 预下载"提前到本地保存"这个理解部分对,要拆成两件事:
时机前移(优化生效主因):哪怕没有持久缓存,只要启动期做完下载+解码+上传,点弹窗时图在 GPU 里,当场不卡。与"存没存本地"无关。 跨会话缓存(决定下次开还快不快):预下载的请求下载完后通常留在浏览器 HTTP 磁盘缓存里,下次重开命中 from disk cache,启动也几乎不下载。引擎没有主动写 IndexedDB 式永久文件,依赖的是浏览器自带缓存。 由此三种玩家场景:
场景 启动期 点弹窗时 首次打开 / 缓存失效 下载整包(缩图后约几百KB,1–2 秒,被 loading 遮罩盖住) 秒开 重开(缓存命中) 命中磁盘缓存,秒进 秒开 清缓存 / 换设备 / 换浏览器 / 隐私模式 / 资源版本更新 退回"首次"重下一次 下载完后秒开 关键体感转换:首次的下载量没消失,只是从"操作中突然冻住"挪进"启动 loading"。玩家预期启动要等,但受不了"正玩着突然卡",所以这是划算交换。首次玩家也会觉得弹窗不卡,代价是启动多等一两秒。
"后面再也不慢"不成立:清缓存/换设备/无痕/CDN 资源哈希变更/磁盘缓存被淘汰,都会重下一次。这是网页缓存常态,非 bug。
  1. preload_groups 取舍边界(未来加资源时必读) 当前 ["default"] = 全量预下载。现在资源总量小(图片约 300KB),启动预下载无感,全下最省心。
危险信号:未来若塞大量大贴图/长音频/3D 模型,整包涨到几 MB–几十 MB,全量预下载会让启动 loading 明显变长,反伤体验。
那时改成"按需 + 精准预下载"(引擎配置 2/4):
jsonc
复制 // 只预下载首屏/高频资源组,冷门资源留 DWP 按需 { "preload_groups": ["core_ui"], "groups": { "core_ui": ["scripts/", "image/交互/", "image/characters/"], "default": [""] } } 规则:preload_groups 里填哪个组名,就预下哪个组。把"弹窗首帧必需"的图放进预下载组,把"玩家可能根本不进的页面"的资源留在按需组。
记忆口诀:包小全下,包大按需;预下载挪的是"等待发生的时刻",不是"等待的总量"。
  1. 验收清单 [ ] 强制刷新清浏览器缓存(Ctrl+Shift+R / Cmd+Shift+R)。否则 WASM 复用旧的"未预下载+未缩图"资源,看起来像没生效——是缓存非代码问题。 [ ] 清缓存后首次进入:观察启动 loading 是否包含资源预下载(应比纯脚本启动略长,但弹窗秒开)。 [ ] 打开"物品/墙体/地面/房间/人员"各分类:首帧即全显,无图1→图3 渐进。 [ ] 切分类标签:局部刷新,无整树重建卡顿。
  2. 关键文件索引 文件 职责 .project/resources.json preload_groups / groups 预下载与打包引用配置 scripts/页面/地图编辑/地图编辑侧栏.lua SidebarPreloadImages 全局渲染帧预热 + 卡片 5 列/去边框/去装饰条 urhox-libs/UI/Core/ImageCache.lua Get 在 nvgContext==nil 时静默 return 0(陷阱源头,只读勿改) urhox-libs/UI/Core/UI.lua SetContext 时机:渲染开始设、渲染结束清 nil scripts/数据/物品注册表.lua heldVisualWidth/Height 手持尺寸(与本优化无关,同期改动)
  3. 版本轨迹 版本 改动 打掉哪一层 v0.10.119 去左栏分隔线(display="none"→visible,Yoga 不处理 display)+ 首次预加载尝试(无效,构建期 nvg=nil) 渲染层 + 失败尝试 v0.10.120 卡片去暗金边框(borderWidth 非选中=0) 渲染层 v0.10.121 卡片顶部装饰条非选中透明且不占位;面板放宽 5 列 渲染层 v0.10.122 预加载覆盖全分类(仍构建期,仍无效) 失败尝试 v0.10.123 27 张家具/墙/门/设施图缩 128px(5.3MB→255KB) 体积层 v0.10.124 预加载改 NanoVGRender 渲染帧内(_SidebarPreloadImages)——解码层首次真生效 解码层 v0.10.125 4 张地面纹理缩 128px(1.2MB→75KB)+ preload_groups=["default"] 启动预下载 体积层 + 网络层(根因)
1