下载 App

图片加载三层优化设计

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"] 启动预下载 体积层 + 网络层(根因)
穿透层修复
经验一:CSS transform 穿透层修复模式 问题本质 当容器使用 CSS transform: translateX/Y/scale 平移/缩放子树时,即使父容器有 overflow = "hidden",引擎的命中检测可能不裁剪越界区域。如果 transform 后的子树有更高的 zIndex,它会在视觉范围外拦截其他兄弟节点的指针事件。 受影响的场景 地图编辑器(右键平移/滚轮缩放后)
官方
4 赞
地图编辑器地面交互与视觉优化
一、问题链 用户截图反馈了三个递进的问题: 问题 1:点击地面类型(如食堂地砖),弹窗不关闭,挡住画布无法操作 ↓ 修复 v0.10.94 问题 2:地面贴图太大,一块地砖比小人还宽,比例失调 ↓ 修复 v0.10.96(改配置值) 问题 3:改了配置值后刷新仍然太大——旧存档的 tileSize=256 优先级高于新 config ↓ 修复 v0.10.97(反转渲染优先级) 二、关键代码
官方
4 赞
地图编辑器仿真 tick 重建抑制
UI 重建频率 = 事件分发的可靠性上限。 只要有一帧 SetRoot 销毁了正在处理 pointer 事件的控件,跨重建边界的 Down/Up 对就会被吞。高频仿真 tick 必须在 UI 边界按页面过滤。 关键代码(scripts/main.lua HandleUpdate) 四步走:先原地处理人员 → 聚合所有仿真 changed → 地图页强制压制 → 只放行显式/登录态变化。 -- ma
官方
4 赞
修复"地图编辑器刷新数据丢失"
问题诊断过程 用户报告:编辑器建好房间 → 保存 → 刷新页面 → 进编辑器 → 数据全没了。 关键线索:用户发现"校园日"模式刷新后能看到房间,但一进编辑器就没了。 这直接指向了编辑器页面自身的初始化逻辑,而非持久化链路。 根因 地图编辑模式.lua 的 EnsureBlankEditorLayout() 设计意图是"首次打开编辑器提供空白画布",用模块级布尔 editorLayoutClear
官方
4 赞
右键拖拽平移
一、问题本质 不是平移公式写错,而是 UrhoX UI 库指针事件派发的两个不对称陷阱叠加: 陷阱 现象 根因文件 OnPointerMove 不向父冒泡 右键按下 isPanning_=true,但 move 事件永远到不了 mapView urhox-libs/UI/Core/Widget.lua(OnPointerDown/Up 有 parent 冒泡,OnPointerMove 没有) H
官方
4 赞
槽位变更只走原子方法
经验 下标 = 视觉格子,空槽是合法状态。定长 9 槽,inventory[i]=nil 即第 i 格空着,中间允许空。任何"把物品往前挪/压缩/重排"的操作都会让"你看到的格子"和"数组真实下标"脱节 → 物品消失。渲染直映 inventory[i],数据层绝不重排。 稀疏表禁用 #。带 nil 洞的表 #inv 是未定义的(可能=0 直接读成空包,或=洞前边界)。所有遍历上界一律 Player
官方
4 赞
以鼠标为锚点缩放
核心数学原理 缩放前后,鼠标指向的那个世界坐标点必须保持在同一屏幕位置。 已知:旧缩放 oldZoom、新缩放 newZoom、鼠标锚点屏幕坐标 (ax, ay)、当前偏移 (panX, panY) 步骤1:算出旧缩放下鼠标指向的世界坐标 wx = (ax - panX) / oldZoom wy = (ay - panY) / oldZoom 步骤2:新缩放后反解新偏移,让 wx/wy
官方
1 赞
精灵平滑移动、墙碰撞体积
一、 渲染层用连续坐标(scripts/页面/校园地图.lua,三处统一): -- NPC 定位(第408-410行) local contLeft = (character.x or 0) * 5 - 18 - charLeft local contTop = (character.y or 0) * 5 - 36 - charTop -- 主角定位(第504-506行) local con
官方
1 赞
自动寻路赠送
核心功能 携带模式(点击物品跟鼠标)下: ≤8格:点击小人直接赠送(金色边框) >8格:点击远距离小人 → 自动寻路走向目标 → 到达8格内自动赠送 取消:玩家按方向键/WASD/摇杆手动移动 → 立即取消,恢复物品 关键经验(3条踩坑教训) 经验1:类型标识不是中文,是英文枚举值 地图编辑器放置的角色 type/job 字段是英文标识(teacher_3、doctor、cleaner_2、tea
官方
1 赞
碰撞方案
核心教训 1. 格子像素必须匹配精灵尺寸 5px格子+36px精灵=7.2:1比例,任何碰撞方案(AABB/空气墙/单点/格中心)都无法同时满足"不穿墙+四面间距一致+不跳格"。对齐环世界32px后,36px精灵≈1.1格,问题根本解决。 2. 连续渲染 + 格级碰撞的分层设计 渲染用连续坐标 x*S-18(平滑不跳),碰撞用格级检测(不穿墙),二者解耦。 3. 精灵大于格子时,单点检测必然不对称
官方
1 赞