Skip to content

Feature: Implements tile throttling and zoom-blur fallback for DisplayList.#1440

Open
richardshan0614 wants to merge 20 commits into
mainfrom
feature/richardshan_resource_cache
Open

Feature: Implements tile throttling and zoom-blur fallback for DisplayList.#1440
richardshan0614 wants to merge 20 commits into
mainfrom
feature/richardshan_resource_cache

Conversation

@richardshan0614
Copy link
Copy Markdown
Collaborator

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

…h dirty-region and viewport-aware protection.
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里看下具体为什么,打log就是为了报错的

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. GL 驱动会"优化掉"程序里没用到的 sampler(比如 shader 声明了 uniform sampler2D u_foo 但实际没采样它),优化后这个 sampler 就不存在了。
  2. setPipelineDescriptor 里查 getUniformLocation 拿到 -1 的,就不会注册到 textureUnits——这是 program 初始化时就决定好的事实。
    3.CPU 这边并不知道哪些被优化掉了,它按照 pipeline descriptor 老老实实给每个声明的 sampler 都来调一次 setTexture。所以查不到的就是那些"已经被驱动判定为死代码的 sampler",安全跳过即可。
    如果这里不注释,小程序调试很多时候打印很多这些无用的日志,太吵了。所以我给关闭了

*/
void setZoomOutTileThrottlePerFrame(int count) {
_zoomOutTileThrottlePerFrame = count;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

建议不要再新增 zoomOutTileThrottlePerFrame 这个独立参数,直接修改 maxTilesRefinedPerFrame 的语义为「每帧最多处理多少个 tile(无论 refine 还是新栅格化)」,并移除 _isZoomingIn 方向追踪逻辑(setZoomScale 里的累加器、deadband 翻转、相关成员变量都可以删掉)。

现在两个独立参数 + 缩放方向追踪带来的复杂度过高:API 多一个、renderTiled 里两套预算、setZoomScale/setZoomScalePrecision 里要维护方向状态、注释还要解释 refine vs rasterize 的区别。合并后只留一个旋钮,使用者心智负担小很多。

代价是 zoom-in / 平移进入新区域时也会节流(可能短暂留空),但既然 zoom-out 已经接受了「留空 + fallback 模糊」的折中,zoom-in / 平移用同样策略在产品上应该是一致的。如果确实有场景必须区别对待,再加参数也不迟,但现在一上来就两个参数 + 方向追踪,复杂度溢出了收益。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这两个参数职责不重叠,不能用 _maxTilesRefinedPerFrame 替代。核心差异在"找不到 fallback 时怎么办":

_maxTilesRefinedPerFrame:控制"用户交互时优先用 fallback 省渲染"。找不到 fallback 必须栅格化,否则会丢内容。
_zoomOutTileThrottlePerFrame(新增):控制"缩小过程中单帧栅格化总量上限"。找不到 fallback 也可以放弃——优先用部分覆盖的降级 fallback 顶一下,最差留空块下一帧再画,目标是保住帧率,画质让步。
具体差异有三点:

语义维度不同:前者计数的是"fallback 命中次数"(每命中 -1),后者计数的是"已栅格化 tile 数"(硬上限),两者不在同一个维度。

配套的 fallback 策略不同:throttle 路径专门走 getThrottleFallbackTasks,接受不完整覆盖;常规路径走 getFallbackDrawTasks,要求完整覆盖。如果合并,常规交互场景也会接受残缺贴图,画质会下降。

方向门控:throttle 用 _isZoomingIn 在放大方向不限速(放大需要清晰度),_maxTilesRefinedPerFrame 没有这层语义。

合并后无法表达"缩小时宁可留空块也不让单帧栅格化超过 N 个"这个核心需求。

Comment thread src/gpu/ResourceCache.cpp
// 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())) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

想确认一下这条改动的真实必要性。

能走到 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 全部释放的瞬间,资源会经过 processUnreferencedResourcespurgeableResources,下一次重建时通过 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。这一句注释跟代码不太对得上,建议一并修正。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我来解释下:

══════════════════════════════════════════════════════════════════════
【必要性 - 概念层】
══════════════════════════════════════════════════════════════════════

你可能把 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 占用峰值变大是真实的,但有两道闸:

  1. byte-capacity sweep(同函数 scratchResourceOnly=false 那条调用,由
    totalBytes > maxBytes 触发):命中时仍能 deleteResource,对带 uniqueKey 且有 scratchKey
    的资源还会先做 downgrade(保留底层 GPU buffer 让 ScratchKey 通道继续复用)。

  2. 显存超过 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.

Comment thread src/gpu/ResourceCache.cpp
// 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())) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

顺带一个相关的疑问,想跟这条改动一起搞清楚:TextureImage 持有的 TextureProxy 内的 TextureView,是否会被计入 ResourceCache 的 totalBytes?

看目前的链路:

  • 所有 TextureView 的创建路径(TextureView::MakeFormatTextureView::MakeFrom(BackendTexture, ...)MakeFrom(HardwareBuffer, ...)ProxyProvider::wrapExternalTexture 等)最终都走 Resource::AddToCache
  • ResourceCache::addResourcetotalBytes += 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 的判断。两个潜在问题:

  1. 这种「不归我管的内存」也算进 cache 预算,会让 cacheLimit 提前触发淘汰,把真正归 TGFX 管的资源挤出去。
  2. 跟当前 PR 的改动叠加:带 uniqueKey 的资源被新逻辑保留在 purgeable 列表里,如果其中包括 wrapExternalTexture 这类「外部内存」资源,那 totalBytes 既「不能淘汰」又「占名额」,会进一步压缩真正可回收资源的预算空间。

想确认两点:

  • 这是不是预期行为?如果是,能否在 wrapExternalTexture / 类似路径上区分「是否真正持有内存」,对外部 adopted=false 的情况返回 0 字节或不计入 totalBytes?
  • 当前 PR 的 ResourceCache 改动有没有考虑过这种交互影响?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

两个问题分开回答。

══════════════════════════════════════════════════════════════════════
【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 并跟到落地。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants