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 20 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 并跟到落地。
标题:为 DisplayList 实现缩小过程中的分块节流与缩放模糊 fallback 机制。
主要改动:
缩小节流:引入 zoomOutTileThrottlePerFrame 与缩放方向追踪,限制连续缩小过程中的单帧最大栅格化块数,保障帧率。
特别指出:在缩小或静止过程中,如果当前区块被节流且 fallback 贴图无法完全覆盖,系统有意允许剩余区域为空块(露出背景),以避免在主线程触发同步栅格化从而阻塞渲染。
ResourceCache 优化:合并了 purgeableResources 淘汰逻辑中清理 scratch 资源的判断分支,精简注释并明确了 UniqueKey 资源延迟回收的语义。