Feature: Implements tile throttling and zoom-blur fallback for DisplayList.#1440
Feature: Implements tile throttling and zoom-blur fallback for DisplayList.#1440richardshan0614 wants to merge 36 commits into
Conversation
…nce dead-sampler warnings.
… slow-frame diagnostics.
…h dirty-region and viewport-aware protection.
…iagnostics are no longer needed.
…ch the main-branch style.
…tructure it served.
…er to the main-branch baseline.
…now that tile throttling is stable.
…rim the surrounding comment.
…and trim now-redundant comments.
…condition was equivalent.
…nd add tests for the new behavior.
…uous-fill fast path in cpp.
…ng on allow-blur opt-in.
| auto result = textureUnits.find(binding); | ||
| if (result == textureUnits.end()) { | ||
| LOGE("GLRenderPipeline::setTexture: binding %d not found", binding); | ||
| // The GL driver may have optimized this sampler away when the compiled program does not |
There was a problem hiding this comment.
- GL 驱动会"优化掉"程序里没用到的 sampler(比如 shader 声明了 uniform sampler2D u_foo 但实际没采样它),优化后这个 sampler 就不存在了。
- setPipelineDescriptor 里查 getUniformLocation 拿到 -1 的,就不会注册到 textureUnits——这是 program 初始化时就决定好的事实。
3.CPU 这边并不知道哪些被优化掉了,它按照 pipeline descriptor 老老实实给每个声明的 sampler 都来调一次 setTexture。所以查不到的就是那些"已经被驱动判定为死代码的 sampler",安全跳过即可。
如果这里不注释,小程序调试很多时候打印很多这些无用的日志,太吵了。所以我给关闭了
| */ | ||
| void setZoomOutTileThrottlePerFrame(int count) { | ||
| _zoomOutTileThrottlePerFrame = count; | ||
| } |
There was a problem hiding this comment.
建议不要再新增 zoomOutTileThrottlePerFrame 这个独立参数,直接修改 maxTilesRefinedPerFrame 的语义为「每帧最多处理多少个 tile(无论 refine 还是新栅格化)」,并移除 _isZoomingIn 方向追踪逻辑(setZoomScale 里的累加器、deadband 翻转、相关成员变量都可以删掉)。
现在两个独立参数 + 缩放方向追踪带来的复杂度过高:API 多一个、renderTiled 里两套预算、setZoomScale/setZoomScalePrecision 里要维护方向状态、注释还要解释 refine vs rasterize 的区别。合并后只留一个旋钮,使用者心智负担小很多。
代价是 zoom-in / 平移进入新区域时也会节流(可能短暂留空),但既然 zoom-out 已经接受了「留空 + fallback 模糊」的折中,zoom-in / 平移用同样策略在产品上应该是一致的。如果确实有场景必须区别对待,再加参数也不迟,但现在一上来就两个参数 + 方向追踪,复杂度溢出了收益。
There was a problem hiding this comment.
这两个参数职责不重叠,不能用 _maxTilesRefinedPerFrame 替代。核心差异在"找不到 fallback 时怎么办":
_maxTilesRefinedPerFrame:控制"用户交互时优先用 fallback 省渲染"。找不到 fallback 必须栅格化,否则会丢内容。
_zoomOutTileThrottlePerFrame(新增):控制"缩小过程中单帧栅格化总量上限"。找不到 fallback 也可以放弃——优先用部分覆盖的降级 fallback 顶一下,最差留空块下一帧再画,目标是保住帧率,画质让步。
具体差异有三点:
语义维度不同:前者计数的是"fallback 命中次数"(每命中 -1),后者计数的是"已栅格化 tile 数"(硬上限),两者不在同一个维度。
配套的 fallback 策略不同:throttle 路径专门走 getThrottleFallbackTasks,接受不完整覆盖;常规路径走 getFallbackDrawTasks,要求完整覆盖。如果合并,常规交互场景也会接受残缺贴图,画质会下降。
方向门控:throttle 用 _isZoomingIn 在放大方向不限速(放大需要清晰度),_maxTilesRefinedPerFrame 没有这层语义。
合并后无法表达"缩小时宁可留空块也不让单帧栅格化超过 N 个"这个核心需求。
| // drop in external references does not silently invalidate it; the byte-capacity sweep below | ||
| // still reclaims it via downgrade or deletion when memory pressure demands it. | ||
| if (scratchResourceOnly && | ||
| (resource->hasExternalReferences() || !resource->uniqueKey.empty())) { |
There was a problem hiding this comment.
想确认一下这条改动的真实必要性。
能走到 purgeResourcesByLRU 这条路径的资源,前提是已经进入了 purgeableResources,也就是 Resource::weakThis.expired() == true——意味着所有外部 shared_ptr<Resource> 都已经归零。而所有走 proxy 的资源(TextureProxy、各类 BufferProxy)都通过 ResourceProxy::resource 这个 shared_ptr 强引底层 Resource,只要 proxy 活着、Image / Shape / 缓存层还在持有 proxy,资源根本进不到 purgeable 列表。
按 commit 注释里给的场景「owning Path 在两次 draw 之间被重建」:如果中间真的存在 proxy 全部释放的瞬间,资源会经过 processUnreferencedResources 进 purgeableResources,下一次重建时通过 findUniqueResource → refResource 把它从 purgeable 拉回 nonpurgeable,本来就有这条复用路径,没必要靠 scratch 清扫的多帧缓刑续命。
如果中间根本没有 proxy 全释放的瞬间,那这条改动也不会被命中——!uniqueKey.empty() && !hasExternalReferences() 这个状态本身就要求外部 shared_ptr 全归零、但 uniqueKey 仍挂在 uniqueKeyMap 里。
能否给一个具体的、能稳定命中这条新分支的场景?比如:哪个 draw 流程下,资源短暂掉到 purgeable,且不能靠现有的 refResource 路径拉回来,必须靠这条 scratch-skip 续命?没有这个场景的话,这条改动副作用是 purgeableResources 占用峰值变大(以前 SCRATCH_EXPIRATION_FRAMES 会主动收缩,现在只能等 byte-capacity 触发),代价不小。
另外,注释里说「the byte-capacity sweep below still reclaims it via downgrade or deletion」,但 downgrade 那条分支要求 hasExternalReferences() == true,跟「外部引用归零」的前提矛盾——常见路径下 byte-capacity 那轮会直接 erase,走不到 downgrade。这一句注释跟代码不太对得上,建议一并修正。
There was a problem hiding this comment.
我来解释下:
══════════════════════════════════════════════════════════════════════
【必要性 - 概念层】
══════════════════════════════════════════════════════════════════════
你可能把 Resource::isPurgeable() 和 hasExternalReferences() 当成了同一个语义,
但 tgfx 里这两者是独立的引用计数系统:
- isPurgeable() = weakThis.expired() → 是否还有 shared_ptr
- hasExternalReferences() = uniqueKey.useCount() > 1 → 是否还有外部 UniqueKey 副本
进入 purgeableResources 仅需 isPurgeable()=true,与业务是否还持有 UniqueKey 完全无关。
一个 Resource 可以同时满足 isPurgeable()=true(在 purgeable 列表)+ hasExternalReferences()=true
(业务方还记得它)—— 这正是 UniqueKey 设计的状态 。
══════════════════════════════════════════════════════════════════════
【必要性 - 实证:GPUShapeProxy 场景,正是我们应用里频繁中招的路径】
══════════════════════════════════════════════════════════════════════
reviewer 说「proxy 强引底层 Resource,只要 proxy 活着,资源根本进不到 purgeable」,
这一点对长期持有的 proxy 是事实,但 GPUShapeProxy 的真实生命周期只有一帧。具体调用链:
// OpsCompositor.cpp:620
auto shapeProxy = proxyProvider()->createGPUShapeProxy(shape, ...); // 每帧新建
auto drawOp = ShapeDrawOp::Make(std::move(shapeProxy), ...); // move 给 op
ShapeDrawOp 在本帧 flush 后析构,连锁触发:
shapeProxy 析构 → GPUBufferProxy 析构 → ResourceProxy::resource 析构
→ BufferResource 的 shared_ptr 引用归零 → weakThis.expired()=true
→ 资源进 returnQueue → processUnreferencedResources 把它放进 purgeableResources
整个过程一帧之内发生。
但业务侧的 Shape(例如 ShapePath::_cachedShape)是长期持有的,shape->getUniqueKey() 的 domain
也跟着活着。createGPUShapeProxy 内部用的是 UniqueKey::Append(shape->getUniqueKey(), ...),
Append 复用同一个 domain(domain->addReference()),所以 BufferResource::uniqueKey 与
shape->uniqueKey 共享同一个 UniqueDomain。
绘制完成后的真实状态:
- 持 domain 的 UniqueKey 副本:shape->uniqueKey + BufferResource::uniqueKey ≥ 2
- hasExternalReferences() = (useCount > 1) = true
- shared_ptr = 0 → weakThis.expired() = true
- Resource 在 purgeableResources
下一帧 shape 再次绘制时,createGPUShapeProxy → findOrWrapGPUBufferProxy → findUniqueResource
本应通过 refResource 把 buffer 从 purgeable 拉回 nonpurgeable,复用上次三角化的结果。
但原始 Sweep B(scratchResourceOnly=true)是按 SCRATCH_EXPIRATION_FRAMES 一刀切:
// 改动前的代码
while (item != purgeableResources.end()) {
auto resource = *item;
if (satisfied(resource)) { break; }
item = purgeableResources.erase(item); // ← 直接删除
purgeableBytes -= resource->memoryUsage();
removeResource(resource);
}
如果 shape 连续 SCRATCH_EXPIRATION_FRAMES 帧没绘制(视口外的 PAGX 图层、滚动出屏的 shape,
非常常见),buffer 被 Sweep B 一刀切删掉。下次 shape 重新出现时,findUniqueResource 已经
找不到(uniqueKeyMap 里也清了),被迫重新走完整三角化路径——我们的应用场景里典型代价是
单 shape 200-400ms 的三角化耗时,被反复触发就是用户能感知到的卡顿。
reviewer 提到「refResource 路径拉回」,这条路径正是本改动想要保护的。原始代码下资源在
Sweep B 命中后已经被 removeResource,refResource 根本没机会触发。
══════════════════════════════════════════════════════════════════════
【副作用考虑】
══════════════════════════════════════════════════════════════════════
reviewer 担心的 purgeableResources 占用峰值变大是真实的,但有两道闸:
-
byte-capacity sweep(同函数 scratchResourceOnly=false 那条调用,由
totalBytes > maxBytes 触发):命中时仍能 deleteResource,对带 uniqueKey 且有 scratchKey
的资源还会先做 downgrade(保留底层 GPU buffer 让 ScratchKey 通道继续复用)。 -
显存超过 cacheLimit 时整体 LRU 按字节预算回收,命名资源也参与排序,不会无限期占住内存。
也就是说:scratch-expiration 由「时间」驱动,byte-capacity 由「内存预算」驱动。改动只移除了
「过了 N 帧就强制丢弃带 uniqueKey 的资源」这条与业务无关的时间维度判据 —— 业务还在持有
UniqueKey 表明它主观上觉得这个资源将来还会用,由「内存压力」而不是「时间长短」决定回收时机
更符合 UniqueKey 的语义(参考 ResourceKey.h 里 UniqueKey 注释:「It can become scratch again
if the unique key is removed or no longer has any external references」—— 这里的 external
references 指的就是 UniqueKey 副本,不是 shared_ptr)。
══════════════════════════════════════════════════════════════════════
【注释订正】
══════════════════════════════════════════════════════════════════════
reviewer 关于「downgrade or deletion」措辞不严谨这一点是对的。byte-capacity sweep 在两种
情况下走两条路径:
- 业务仍持有 UniqueKey + 资源带 scratchKey:走 downgrade(摘 uniqueKey,保留 scratchKey 给
其他业务通过 ScratchKey 通道复用) - 否则:直接 deleteResource
注释会更新为:
// A resource still tracked by a UniqueKey is a named cache entry callers expect to look up by
// key, not an anonymous scratch buffer. Skip it during the scratch-expiration sweep so a brief
// drop in external references does not silently invalidate it; the byte-capacity sweep below
// still reclaims it via downgrade or deletion when memory pressure demands it.
| // drop in external references does not silently invalidate it; the byte-capacity sweep below | ||
| // still reclaims it via downgrade or deletion when memory pressure demands it. | ||
| if (scratchResourceOnly && | ||
| (resource->hasExternalReferences() || !resource->uniqueKey.empty())) { |
There was a problem hiding this comment.
顺带一个相关的疑问,想跟这条改动一起搞清楚:TextureImage 持有的 TextureProxy 内的 TextureView,是否会被计入 ResourceCache 的 totalBytes?
看目前的链路:
- 所有 TextureView 的创建路径(
TextureView::MakeFormat、TextureView::MakeFrom(BackendTexture, ...)、MakeFrom(HardwareBuffer, ...)、ProxyProvider::wrapExternalTexture等)最终都走Resource::AddToCache。 ResourceCache::addResource里totalBytes += resource->memoryUsage();src/gpu/ResourceCache.cpp:250是无条件累加的。- TextureView 的
memoryUsage()按width * height * bytesPerPixel(带 mipmap 乘 4/3)算,对外部 adopted=false 的 wrapExternalTexture 也照常算。
也就是说:用户通过 Image::MakeFrom(BackendTexture) 包装的外部 texture,TGFX 并不持有 GPU 内存所有权,但它的 memoryUsage() 仍然进 totalBytes,参与 cacheLimit 的判断。两个潜在问题:
- 这种「不归我管的内存」也算进 cache 预算,会让 cacheLimit 提前触发淘汰,把真正归 TGFX 管的资源挤出去。
- 跟当前 PR 的改动叠加:带 uniqueKey 的资源被新逻辑保留在 purgeable 列表里,如果其中包括 wrapExternalTexture 这类「外部内存」资源,那 totalBytes 既「不能淘汰」又「占名额」,会进一步压缩真正可回收资源的预算空间。
想确认两点:
- 这是不是预期行为?如果是,能否在 wrapExternalTexture / 类似路径上区分「是否真正持有内存」,对外部 adopted=false 的情况返回 0 字节或不计入 totalBytes?
- 当前 PR 的 ResourceCache 改动有没有考虑过这种交互影响?
There was a problem hiding this comment.
两个问题分开回答。
══════════════════════════════════════════════════════════════════════
【1. 这是不是预期行为?— 承认:tgfx 当前实现确实和 Skia 不一致】
══════════════════════════════════════════════════════════════════════
reviewer 描述的事实成立。tgfx 现状(src/gpu/ResourceCache.cpp:255):
totalBytes += resource->memoryUsage(); // 单一账本,所有资源都进
// cacheLimit 判断、按字节预算驱动的清扫也都基于这个 totalBytes
对比 Skia 的 GrResourceCache 设计(src/gpu/ganesh/GrResourceCache.cpp:74-103,
GrGpuResource.cpp:40-47),是「双账本」:
fBytes - 所有资源(含 wrapped backend texture)。仅用于统计 / high water mark
fBudgetedBytes - 仅 GrBudgetedType::kBudgeted 的资源。是 cacheLimit 判断的唯一依据
GrGpuResource.cpp:40 的 registerWithCacheWrapped 注释直接写明:
// Resources referencing wrapped objects are never budgeted. They may be cached or uncached.
也就是说 Skia 对 wrapBackendTexture / wrapRenderableBackendTexture 这类外部所有权资源
明确不计入 budget。reviewer 提议在 wrapExternalTexture / adopted=false 路径上区分账户的
方向是对的,且和 Skia 一致。
这是一个值得做的优化,会沿用 Skia 的 BudgetedType 三态语义(kBudgeted / kUnbudgetedCacheable
/ kUnbudgetedUncacheable)一步到位,但建议另开 issue 跟进,不混入本 PR。理由有三:
(a) 范围漂移:本 PR 是针对 purgeResourcesByLRU 的 scratch-expiration 路径误删 named
资源的明确修复,几行 diff、因果链清晰。混入 budget 重构会让 PR 同时改两件不相关
的事,评审难度上升。
(b) 真实工作量被代码量低估。改 ResourceCache 双账本 + 各 MakeFrom(adopted=false) 路径
标记,确实代码不多;但配套的 PAGX wechat 端预算配比重新论证(fullBudget=256MB 当前
是建立在「tgfx cacheLimit 反映 GPU driver 实际占用」的假设上的)+ 真机回归(特别
是 wechat 大 PAGX 文档的 OOM 边界),是隐性大头,不是这个修复 PR 的合适窗口。
(c) 该重构涉及 ResourceCache 公开 API 语义变化(cacheLimit / getCacheUsage 的语义)、
可能影响其他 SDK 使用者。需要单独的 RFC 讨论。
══════════════════════════════════════════════════════════════════════
【2. 当前 PR 改动有没有考虑过这种交互影响?】
══════════════════════════════════════════════════════════════════════
需要先订正一点:tgfx ResourceCache 这一层并没有针对 wrapped / external-owned 资源做
任何特殊拦截 —— externallyOwned() 这个标记目前只在 RenderTarget 类型上有,且
ResourceCache.cpp 里完全没用过它。也就是说在 tgfx 代码层面,wrapped TextureView 和
普通 TextureView 走完全相同的 LRU 清扫路径,两者是否进 purgeableResources 完全取决
于外部使用者是否仍持有 shared_ptrtgfx::Image(间接 pin 住底层 TextureView 的
shared_ptr)。
在「外部使用者长期持有 shared_ptr 直到主动释放」的使用模式下,wrapped TextureView
的 weakThis 永远不会 expired,TextureView 不会进 purgeableResources,本 PR 改的那
条 skip 自然也看不到它,不会产生 reviewer 担心的「占 totalBytes 又长期不能淘汰」效应。
但这是使用者引用持有方式的天然结果,不是 tgfx 代码层的硬保证。如果有调用方短暂持有
shared_ptr 后立即释放,wrapped TextureView 也会进 purgeableResources,跟普通
资源一样参与清扫。在那种使用模式下,reviewer 担心的「占 totalBytes 又难以高效淘汰」
确实会发生 —— 这正是第 1 节「对齐 Skia 双账本」这个独立改造要从基础库层面解决的
问题,跟本 PR 想解决的 scratch-expiration 误删 named 资源是两件事。
简言之,本 PR 的改动跟 wrapped resource budget 问题是正交的:
- 长期持有的 wrapped 资源 → 永远在 nonpurgeable,本 PR 看不到它们
- 短暂持有的 wrapped 资源 → 进 purgeable,本 PR 的 skip 行为跟普通资源一致
(都按 hasExternalReferences 与 uniqueKey 判断),不会比改之前更糟
══════════════════════════════════════════════════════════════════════
【综合建议】
══════════════════════════════════════════════════════════════════════
- 本 PR 改动跟 wrapped resource budget 问题正交,可以独立 merge。
- reviewer 提到的「对齐 Skia 双账本」是个独立、值得做的优化,建议拆成单独 issue
跟进;我会负责开这个 issue 并跟到落地。
| if (!throttleFallback.empty()) { | ||
| screenTasks.insert(screenTasks.end(), throttleFallback.begin(), throttleFallback.end()); | ||
| hasZoomBlurTiles = true; | ||
| continue; |
There was a problem hiding this comment.
部分覆盖的缺口未填背景色。
当 getThrottleFallbackTasks 返回部分覆盖(有内容但未完全覆盖该 tile 位置)时,这里直接 continue,未覆盖区域没有加入 skippedRects。而完全找不到 fallback 的位置(line 653-657)会走 skippedRects → drawScreenTasks 里填背景色。
结果:部分覆盖的位置有空洞,露出上一帧残影或透明像素,而完全没 fallback 的位置反而干净地填了背景色。这和 PR 描述的"允许剩余区域为空块(露出背景)"不一致。
drawScreenTasks 里的 GetNonIntersectingRects 只填充 screenRect \ tileRect(屏幕上 tile 联合区域之外的部分),不会填补 tileRect 内部的空洞。
建议:对 throttleFallback 返回部分覆盖的情况,计算该 tile 位置中未覆盖的区域并加入 skippedRects;或者先对整个 grid 位置填背景色,再绘制 throttle fallback 内容覆盖在上面。
| tasks.emplace_back(t, _tileSize, drawRect, 1.0f / scaleRatio); | ||
| } | ||
| } | ||
| if (fullyCovered) { |
There was a problem hiding this comment.
多缓存叠加时的重叠问题。
当 fullyCovered = false 时,当前缓存的 tile 加入 tasks 后不 break,继续查下一个缓存。如果两个缓存的 tile 在空间上有重叠,后加入的 tile(ScaleRatio 更远,画质更差)会在 drawScreenTasks 里覆盖先加入的(画质更好的)。
绘制顺序取决于 std::sort by sourceIndex(atlas 表面索引),而非 ScaleRatio 距离。所以重叠区域的最终画质是随机的——可能显示近端缓存也可能显示远端缓存的内容。
建议:在叠加多个缓存时,对重叠区域只保留 ScaleRatio 最近的缓存内容(比如在收集 tasks 时做去重,或在绘制时按 ScaleRatio 排序)。
| // Sort fallback caches strictly by distance from currentZoomScale using ScaleRatio, so the | ||
| // "nearest-first + break on full coverage" logic picks the visually closest scale first. | ||
| auto orderedCaches = fallbackCaches; | ||
| std::sort(orderedCaches.begin(), orderedCaches.end(), |
There was a problem hiding this comment.
ScaleRatio 排序丢失降采样优先策略。
getFallbackTileCaches 的排序策略:scale >= currentZoomScale 排前面(降采样→更清晰),scale < currentZoomScale 排后面(升采样→更模糊)。getFallbackDrawTasks 遵循这个顺序。
但这里用 std::sort 按 ScaleRatio(对称距离)重排,当两个缓存的 ScaleRatio 相等或接近时(如 0.5x 和 2.0x 相对 1.0x),可能选到升采样(模糊)而放弃降采样(清晰)。
改用 std::stable_sort 即可在 ScaleRatio 相同时保留 fallbackCaches 原有的降采样优先顺序,与 getFallbackDrawTasks 的策略保持一致。
| continuous = !freeTiles.empty(); | ||
| dirtyGrids = GenerateGridTiles(startX, endX, startY, endY); | ||
| skippedRects->clear(); | ||
| hasZoomBlurTiles = false; |
There was a problem hiding this comment.
结合这行 hasZoomBlurTiles = false,line 688 的 !hasZoomBlurTiles 条件是冗余的。
continuous 为 true 仅在 screenTasks.empty()(line 662)时进入,而该分支已将 hasZoomBlurTiles 设为 false(此行)。之后也没有代码能将其设回 true。所以 !hasZoomBlurTiles 在 continuous 为 true 时恒成立。
作为防御性代码可以接受,但加个注释说明这个条件目前恒为 true 会更清晰,方便后续维护者理解。
…hed draws clean and overlay nearer-scale tiles on top.
…or byte pressure.
| canvas->setMatrix(Matrix::MakeTrans(_contentOffset.x, _contentOffset.y)); | ||
| Paint paint = {}; | ||
| paint.setAntiAlias(false); | ||
| paint.setBlendMode(BlendMode::Src); |
There was a problem hiding this comment.
强制使用 BlendMode::Src 但未接收 autoClear 参数,与 drawScreenTasks 按 autoClear 决定 Src/SrcOver 的处理不一致。当调用方传 autoClear=false(叠加渲染语义)时,throttle/skipped 区域仍会强制覆盖画布上已有内容,与外层 autoClear=false 的承诺冲突。
这看起来是有意设计(throttle 区域必须破坏底下内容,否则远→近覆盖时近端 tile 边缘的低 alpha 像素会让远端内容透出,产生跨尺度伪影;skippedRects 兜底色也必须彻底覆盖底下脏数据),但代码无任何注释说明,未来维护者可能误以为是 bug 而错误'修复'。
建议二选一:
- (a) 在函数顶部加一段 design intent 注释,说明为什么强制 Src(远→近覆盖防伪影 + 兜底色必须覆盖脏数据)
- (b) 把 autoClear 传进来,与 drawScreenTasks 保持一致的参数语义
主要改动: