大地图卡顿分析

3 小时前综合
当前架构的好消息和坏消息
已有的优化(好消息):
地形渲染 _RenderTerrain 已经做了视口裁剪。Render() 函数在 1460-1500 行计算了 rowMin/rowMax/colMin/colMax,区分了无旋转(精确裁剪)和有旋转(四角包围盒)两种情况。地形层不是瓶颈。
没有优化的部分(坏消息):
渲染层 是否有视口裁剪 遍历方式
_RenderTerrain 有 只遍历可见格子
_RenderOverlays 有 同上
_RenderBuildings 没有 遍历 mapData.buildings 全部
_RenderNPCs 没有 遍历 mapData.npcs 全部
_RenderBricks 没有 遍历 mapData.bricks 全部
_RenderRoof 没有 遍历全部 wallGroups
_RenderPillarSlots 没有 遍历全部 wallGroups
每个建筑/砖块还执行了 nvgSave → nvgTranslate → nvgRotate → nvgScale → nvgRestore 的完整变换栈(抵消等距投影,让贴图正面朝向玩家),这比普通的矩形绘制昂贵得多。
对你提出的 7 类优化方向的逐一分析
1. 地图渲染优化(分块加载、视锥剔除)
视口裁剪(Frustum Culling)— 最高优先级,收益最大
地形层已经做了,但建筑/砖块/屋檐/柱子完全没做。这是最直接的优化方向:
思路:在 _RenderBuildings 等函数中,先判断物件的内容坐标是否在
      rowMin~rowMax / colMin~colMax 范围内,不在就 skip。
建筑有 b.row, b.col,砖块有 brick.anchorRow, brick.anchorCol,判断非常简单。这能直接砍掉屏幕外物件的全部 NVG 绘制调用。
分块加载(Chunk Loading)— 当前地图尺寸下不必要
你的地图是 32×20 = 640 格,世界尺寸 2048×1280 像素。这个规模并不大,分块加载更适合 Minecraft 那种无限地图(几千×几千格)。对于 640 格的地图,视口裁剪已经足够,分块系统会增加复杂度但收益有限。
结论:做视口裁剪就够了,不需要分块。
2. 角色移动优化(插值、事件驱动)
你提到"角色走动时地图有滞后感"。这有两种可能原因:
原因 A:渲染帧率低(每帧绘制太多东西导致帧时间长)→ 通过视口裁剪解决
原因 B:相机跟随逻辑的时序(角色位置更新和相机平移不在同一帧同步)
如果是原因 B,需要确认:角色移动后,panX_/panY_ 是在同一帧更新还是下一帧才更新?如果相机跟随有一帧延迟,就会出现"地图滞后"的感觉。
事件驱动更新对你的场景不太适用。NanoVG 是 immediate-mode 渲染,每帧都要重绘,不像 retained-mode UI 可以跳过未变化的帧。
3. 资源管理(预加载、缓存)
这部分我们已经解决了。 纹理预加载系统(一帧加载一个)+ 模块级 handle 缓存确保了:
首次进入有加载画面,不会逐片加载
纹理 handle 被长期持有,不会被 GC 回收
TerrainRegistry.texCache_ 和 MapCanvas 的 buildingTextures_ 等缓存避免重复创建
4. 代码性能(减少冗余计算)
几个可优化的点:
三角函数缓存:每个建筑的 counter-rotation 都在执行 nvgRotate(-self.rotation_)。虽然 NVG 内部有矩阵运算,但 self.rotation_ 在一帧内不变,可以考虑将 cos/sin 值缓存
nvgSave/Restore 减少:每个建筑 5 次 NVG 状态操作。如果多个建筑用相同的变换参数,可以批量处理
字符串哈希:_GetBuildingTexture 等函数每次调用都做 table 查找,但 Lua table 的 hash 查找本身是 O(1),这不是瓶颈
多线程 — 不适用。NanoVG 的绘制上下文是单线程的,不能跨线程调用。Lua 也是单线程的。
5. 图形设置优化
LOD(细节层次)— 有一定价值
当缩放很小(zoom < 0.3)时,建筑在屏幕上只有几个像素大小,此时可以:
跳过便签纸片的绘制(砖块上的留言/评论装饰)
跳过柱子/屋檐的细节
用简单色块代替纹理
这是一种"穷人版 LOD",实现简单,效果明显。
6. 测试和分析工具
帧时间监控是最实用的:
思路:在 HandleUpdate 中记录 dt(deltaTime),如果 dt > 33ms(低于 30fps),
      打印当前帧的绘制统计(建筑数、砖块数、可见格子数)。
这能帮助定位到底是哪一层渲染最耗时。
7. 分块加载伪代码
如前所述,对 32×20 的地图,分块不必要。但如果未来扩展到更大的地图(比如 200×200),分块的思路是:
将地图分为 N×N 的 chunk(如 8×8 格一个 chunk)
每帧只加载/渲染与视口重叠的 chunk
chunk 内的建筑/砖块预先按 chunk 分桶
切换 chunk 时预加载相邻 chunk 的资源
已完成的优化汇总:
P0-A 相机跟随时序 — PlayerController.lua
传入真实 dt 替代硬编码 1/60
lerpSpeed 5→10,跟随更紧密
使用 1 - e^(-speed*dt) 指数衰减,帧率无关平滑
P0-B 视口裁剪 — MapCanvas.lua
_RenderBuildings:pad=3,考虑建筑占地面积跳过屏外建筑
_RenderNPCs:pad=2,玩家角色始终渲染不跳过
_RenderBricks:pad=2,屏外砖块跳过
_RenderRoof:pad=3,基于 anchorRow/Col 裁剪
_RenderPillarSlots:pad=3,同上
P1-A LOD(随 P0-B 一并实现)
zoom<0.2:砖块全部跳过
zoom<0.25:建筑退化为色块、屋檐全部跳过
zoom<0.3:柱子槽位跳过
zoom<0.4:建筑跳过文字/描边
zoom<0.5:砖块跳过便签纸
可以预览测试卡顿是否改善。如果需要继续做 P1-B(帧时间分层监控)来精确定位剩余瓶颈,随时告诉我。
全部 P1 任务完成,构建通过。总结一下 P1-B 的实现:
P1-B 帧时间分层监控
修改了 3 个文件:
MapCanvas.lua — Init 新增状态:showPerfOverlay_、perfSmooth_(EMA 系数 0.9)、perfData_、perfFrameMs_
MapCanvas.lua — Render() 核心改造:
用 os.clock() 包裹 8 个渲染层(Terrain、Overlays、Grid、Buildings、NPCs、Bricks、Pillars、Roof)
EMA 滑动平均消除帧间抖动
在画布右上角绘制半透明 overlay 面板:
总帧渲染耗时 + FPS
各层按耗时降序排列,带色条(绿→黄→红)
可见格/总格数 + zoom 级别
perfActive=false 时零开销(perfClock() 直接返回 0)
EditorUI.lua + main.lua — F3 快捷键:
编辑器模式、游戏模式均可按 F3 开关面板
使用方式:进入编辑器或游戏后按 F3 即可看到各层渲染耗时,快速定位瓶颈。
P2 全部优化已完成并构建成功。以下是本轮完成的工作总结:
P2-A 计时器精度修复
os.clock() 在 WASM 上只有 ~10ms 精度,所有层都显示 0.00ms
已替换为 time:GetElapsedTime()(引擎高精度时钟,微秒级)
P2-B 地形 LOD(zoom < 0.5)
跳过 nvgImagePattern 纹理渲染,改用 TerrainRegistry.GetColor(id) 纯色填充
行扫描合并:相邻同 ID 格子合并为一个矩形,~9000 次绘制调用降到数百次
P2-C 叠加层 LOD(zoom < 0.5)
跳过 AutoTiler.RenderTransitions(每格做 8 方向邻居检测 + 渐变绘制)
低缩放时这些过渡边缘本就不可见,跳过零视觉损失
P2-D 批量合并
已在 P2-B 中实现(行扫描合并连续同 ID 格子)
请按 F3 打开性能面板后缩小到全地图,确认:
各层耗时是否显示真实数值(不再全是 0.00ms)
全地图缩放时卡顿是否明显改善
从4张截图中可以提取到非常有价值的性能数据。让我先回答你的问题,再分析整体情况。
关于红框
不是 bug,是 P2-B 的 LOD 优化效果。zoom 0.45 < 0.5 阈值,所以地形用纯色填充代替了纹理贴图,同时 P2-C 跳过了过渡层(AutoTiler 边缘融合)。所以红框中看到的是没有纹理、没有过渡的色块。
如果觉得 0.45 时色块太明显,可以把阈值从 0.5 降到 0.3。
关键发现:Overlays 是最大瓶颈
从4张截图整理出的性能数据:
Zoom Tiles Render 瓶颈
0.21 9408 4.21ms Terrain 2.93ms (70%)
0.45 5183 4.33ms Terrain 2.73ms (63%)
0.88 1444 23.83ms Overlays 18.9ms (79%)
2.28 256 4.25ms Overlays 1.69ms (40%)
zoom 0.88 时 Overlays 占了 18.9ms(79%),是渲染总耗时的绝对主力。1444 个可见格子 × 8方向邻居检测 + 渐变绘制 ≈ 上万次 NVG 绘制调用。
我先看一下 AutoTiler 的渲染逻辑,分析可优化空间。
数据分析非常清晰了。现在我来总结发现并实施 P3 优化。
性能分析结果
Overlays(过渡层)是绝对瓶颈。AutoTiler 每个格子最多 752 次 NVG 调用(4边×86 + 4角×102),1444 可见格子 × 平均 ~200 次 ≈ 28.8 万次 NVG 调用/帧。
根源:每条边 4 层渲染(1 主层 + 3 羽化层),每层 16~20 段多边形 → 调用量爆炸。
P3 全部完成,构建成功。
P3 变更总结
项目 修改 预期效果
P3-A 地形 LOD 阈值 0.5 → 0.3 zoom 0.3~0.5 恢复纹理渲染,消除红框色块
P3-B Overlays 分级 LOD zoom < 0.7 跳过角落过渡(省 ~50% 调用)
P3-C Feather 层数动态调整 zoom < 1.0 时 feather 3层→1层(再省 ~50%)
Overlays 分级 LOD 策略
Zoom 范围 行为 NVG 调用量
< 0.3 完全跳过 0
0.3 ~ 0.7 仅 4 方向边缘 + 1 层 feather 原来的 ~25%
0.7 ~ 1.0 完整 8 方向 + 1 层 feather 原来的 ~50%
≥ 1.0 完整质量(3 层 feather) 100%
请打开 F3 在 zoom 0.88 附近测试,预期 Overlays 从 18.9ms 降到 ~9ms 左右(feather 从 4 层降到 2 层)。同时检查 zoom 0.45 时红框区域是否恢复了正常纹理。
核心问题:即使 lodLevel=2 只渲染边缘,每条边仍需 ~42 次 NVG 调用(16段噪声多边形 × 2层)。4830 个瓦片 × 1.5 条边 × 42 = ~30 万次 NVG 调用。
需要更激进的优化。
构建成功。P4 + P5 已实现,总结变更:
P4: lodLevel=2 简单矩形边缘(已完成)
_RenderEdge 增加快速路径:当 lodLevel >= 2 时,跳过全部噪声多边形/镜像/纹理 paint 逻辑,直接用 nvgRect 画纯色矩形。
之前: 每条边 ~42 NVG 调用(16 段多边形 × 2 层 + save/restore/scale/paint)
之后: 每条边 ~4 NVG 调用(beginPath → rect → fillColor → fill)
预期降幅: ~90%
P5: lodLevel=1 段数缩减(已完成)
_RenderEdge 和 _RenderCorner 在 lodLevel >= 1 时:
lodLevel=0 lodLevel=1 缩减率
边缘段数 16 6 -63%
角落段数 20 8 -60%
Feather 层数 3 1 -67%
当前 LOD 分级表
Zoom 范围 lodLevel 行为
< 0.3 skip Overlays 完全跳过
0.3 - 0.7 2 纯色矩形边缘,无角落,无噪声
0.7 - 1.0 1 噪声多边形但段数减半(6/8),feather=1
>= 1.0 0 完整质量
请在不同 zoom 级别测试,特别关注 zoom 0.3-0.7 区间的 Overlays 时间变化(应该有大幅下降)。
1