Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package dev.nucleusframework.window.tao.scene

/*
* Shared policy for the Skia GPU resource cache of the scene hosts'
* `DirectContext`s.
*
* Skia evicts only when a new allocation would push the cache past its budget,
* so a scene that stops drawing keeps its high-water mark for the rest of the
* process' life. `DirectContext` offers no purge of its own — skiko exposes
* `resourceCacheLimit` and nothing else: no `freeGpuResources`, no
* `purgeUnlockedResources`, not even a usage read-back — so the only primitive
* available to us is *toggling the limit*. Writing 0 runs Skia's
* `purgeAsNeeded` inline, releasing every unlocked resource; writing the budget
* back lets the next frame re-mint only what it actually needs.
*
* Two properties of that primitive shape every caller:
*
* - It frees **unlocked** resources only. Compose layers and pictures still
* referenced by live Java objects keep their Skia natives locked, and those
* are released by the skiko `Cleaner` only after a GC — which is why the
* settle paths pair the purge with a `System.gc()` nudge, and why a purge
* alone never returns a drag's or an animation's full peak.
* - It issues backend deletes, so it must run where the context is usable:
* with *that* host's GL context current on the ANGLE/EGL hosts (purging
* against a sibling's binding deletes ids in the sibling's namespace — see
* the KDoc on `TaoComposeSceneHostWindows.purgeGpuResourceCache`), and on
* the owning render thread on Metal, where the context is thread-affine.
*/

/**
* Budget written onto a host `DirectContext` at attach.
*
* Measured, not assumed: Ganesh already hands out exactly 268435456 bytes by
* default, so at the current value this write is a deliberate no-op. It is the
* explicit anchor the limit-toggle purge restores, and the single place to
* change should we ever decide to run the hosts *below* Skia's own default
* (which is the interesting question once several surfaces each own a context).
* Do not read it as "the cache would be unbounded without this line".
*/
internal const val GPU_RESOURCE_CACHE_LIMIT_BYTES: Long = 256L * 1024 * 1024

/**
* Gap between in-drag purges while resize events are streaming. Every frame of
* a drag mints render-target scratch (stencil/attachments) at a size no later
* frame reuses; purging periodically releases that accumulation mid-drag so the
* peak stays bounded even on long drags, without skipping a resize frame (a
* skipped frame is composited as a geometry/content mismatch — trembling).
*/
internal const val GPU_RESIZE_PURGE_INTERVAL_NS: Long = 250_000_000L

/*
* There is deliberately no "settle" constant here. A drag-end purge needs a
* drag-end signal, and only Windows has one (`WM_EXITSIZEMOVE`); standing a
* timer in for it on the other hosts was measured to be a bad trade — see
* `TaoComposeSceneHost.purgeResizeScratchIfDue`.
*/
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,18 @@ internal class TaoComposeSceneHost(
val devicePtr = NativeMetalBridge.nativeDevicePtr(handle)
val queuePtr = NativeMetalBridge.nativeQueuePtr(handle)
// The Skia Metal DirectContext is thread-affine: create it on the render
// thread that will use it for every frame's GPU encode + present.
directContext = runOnRenderThread { DirectContext.makeMetal(devicePtr, queuePtr) }
// thread that will use it for every frame's GPU encode + present. The
// resource-cache budget is anchored in the same hop — writing it purges
// to fit, so it belongs on the owning thread like every other use of
// the context. See GPU_RESOURCE_CACHE_LIMIT_BYTES for why the value
// itself changes nothing today, and [purgeGpuResourceCache] for what
// actually reclaims.
directContext =
runOnRenderThread {
DirectContext.makeMetal(devicePtr, queuePtr).also {
it.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES
}
}

scale = initialMacOsScaleFactor(window)

Expand Down Expand Up @@ -685,6 +695,64 @@ internal class TaoComposeSceneHost(
scene?.size = IntSize(widthPx, heightPx)
updateWindowInfoSize()
window.requestRedraw()
purgeResizeScratchIfDue()
}

private var lastResizePurgeNs: Long = 0

/**
* Reclaims the per-size GPU scratch a live resize mints, while the sizes are
* still streaming — the macOS half of what
* [TaoComposeSceneHostWindows.onResized] does inside the OS modal
* resize/move loop. Skia's budget caps the cache, but a capped cache full of
* scratch no frame will ever ask for again is still 256 MiB resident.
*
* Deliberately only the *in-drag* half of the Windows behaviour. There is no
* settle purge and no `System.gc()` nudge here, because macOS has no
* `WM_EXITSIZEMOVE` to hang them on and a timer standing in for it proved a
* bad trade twice over: the drag's own frames are display-link paced, so
* macOS never accumulates the way Windows' unpaced modal loop does (a
* 60-step storm moved the graphics footprint 68 MB → 72 MB, and a purge + GC
* at the end of it returned essentially none of that), while the pair landed
* on an animating window as a visible stall — a window with a live
* `NativeView` embed dropped below 4 frames per 400 ms right after a storm.
* Cost with no measured benefit. The reclaim that #638 is actually after is
* at rest, not at drag end, and belongs on the idle path.
*/
private fun purgeResizeScratchIfDue() {
val now = System.nanoTime()
if (now - lastResizePurgeNs < GPU_RESIZE_PURGE_INTERVAL_NS) return
lastResizePurgeNs = now
purgeGpuResourceCache()
}

/**
* Frees the GPU resource cache: toggling the limit to 0 runs Skia's
* `purgeAsNeeded` inline, releasing every unlocked resource, and restoring
* the budget lets the next frame re-mint only what it needs. The only purge
* primitive skiko exposes — see [GPU_RESOURCE_CACHE_LIMIT_BYTES].
*
* Metal has no notion of a *current* context, so none of the foreign-context
* hazard the ANGLE/EGL hosts guard against (#514) applies here: the danger
* on this backend is thread affinity instead. The `DirectContext` is created
* on, and only ever touched from, [renderExecutor], so the toggle hops
* there — submitted rather than awaited, because the caller is the Tao main
* thread on the resize path and blocking it would park the drag behind the
* in-flight replay. FIFO ordering puts the purge cleanly between two frames,
* where nothing the host caches is live (each frame wraps the drawable's
* texture in a fresh `BackendRenderTarget`), and once [detach] has nulled
* the context this returns before submitting anything.
*/
private fun purgeGpuResourceCache() {
val ctx = directContext ?: return
// Rejected once detach() shut the executor down; a purge is never worth
// routing to the fatal handler.
runCatching {
renderExecutor.submit {
ctx.resourceCacheLimit = 0
ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,16 @@ internal class TaoComposeSceneHostLinux(
private val postResizeCatchUpFrames = AtomicInteger(0)
private val sceneSizeUpdateIntervalNs = 16_666_667L // 60fps

/**
* In-drag GPU cache purge, deferred to the next render pass. [onResized]
* runs on the event-loop thread with no EGL context bound — the swap thread
* may even hold ours for its `eglSwapBuffers` — so the timing decision is
* taken here and the purge itself happens in [onRedrawRequested], the one
* place this host's context is current on this thread.
*/
private var lastResizePurgeNs: Long = 0L
private var resizePurgeDue: Boolean = false

private var lastPointerX: Float = 0f
private var lastPointerY: Float = 0f

Expand Down Expand Up @@ -671,6 +681,13 @@ internal class TaoComposeSceneHostLinux(
}
val iface = GLAssembledInterface.createFromNativePointers(0L, fnPtr)
val ctx = DirectContext.makeGLWithInterface(iface)
// Anchor the GPU resource cache budget while the fresh EGL context is
// still the one the native attach left current — writing the limit
// purges to fit, so like every other use of the context it belongs
// where the context is usable. The value itself changes nothing today
// (see GPU_RESOURCE_CACHE_LIMIT_BYTES); what reclaims the per-size
// scratch of a drag is [purgeResizeScratchIfDue].
ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES
directContext = ctx
// Publish the TextureView handle for the fresh EGL context / Skia
// context pair (see glTextureHostState).
Expand Down Expand Up @@ -1378,6 +1395,12 @@ internal class TaoComposeSceneHostLinux(
(widthPx / opaqueScale).coerceAtLeast(1),
(heightPx / opaqueScale).coerceAtLeast(1),
)
// Arm the periodic in-drag purge of the per-size GPU scratch — see
// [resizePurgeDue] for why it can't run right here.
if (now - lastResizePurgeNs >= GPU_RESIZE_PURGE_INTERVAL_NS) {
lastResizePurgeNs = now
resizePurgeDue = true
}
requestRedrawCoalesced()
}

Expand Down Expand Up @@ -1539,6 +1562,45 @@ internal class TaoComposeSceneHostLinux(
lastAppliedScale = scale
}

/**
* Reclaims the per-size GPU scratch a live resize mints, while the sizes
* are still streaming — the Linux half of what
* [TaoComposeSceneHostWindows.onResized] does inside the OS modal
* resize/move loop. Toggling the limit to 0 runs Skia's `purgeAsNeeded`
* inline, releasing every unlocked resource; restoring the budget lets the
* next frame re-mint only what it needs. The only purge primitive skiko
* exposes — see [GPU_RESOURCE_CACHE_LIMIT_BYTES].
*
* Called from the render pass, right after [applyPendingNativeResize] has
* closed the [cachedSurface]/[cachedRt] of the previous size: their backing
* render target and stencil are unlocked at exactly this point, so this is
* where the toggle actually returns memory rather than merely walking the
* cache. It is also the only point where this host's EGL context is current
* on this thread — the purge issues `glDelete*`, and the same foreign-context
* hazard the Windows host documents on its own purge applies here, only
* worse: every Linux surface owns a *private*, unshared context (a popup
* layer, a tray panel, a sibling window), so ids collide wholesale and a
* purge against the wrong binding deletes a sibling's live textures.
* Binding from [onResized] instead would be both racy (the swap thread may
* hold our context) and pointless, since the frame that follows re-binds
* anyway.
*
* Deliberately only the *in-drag* half of the Windows behaviour: there is
* no settle purge and no `System.gc()` nudge, for the same reason macOS has
* none (see [TaoComposeSceneHost.purgeResizeScratchIfDue]). GTK gives us no
* drag-end signal to hang them on — the compositor-driven resize grab ends
* with nothing more than pointer events resuming — and a timer standing in
* for it buys a stop-the-world collection after every zoom, snap and
* programmatic resize. The reclaim #638 is really after is at rest, not at
* drag end.
*/
private fun purgeResizeScratchIfDue(ctx: DirectContext) {
if (!resizePurgeDue) return
resizePurgeDue = false
ctx.resourceCacheLimit = 0
ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES
}

/**
* KWin only: after a present, the pending `wl_egl_window_resize` is in
* effect — advance the paint size and re-arm a frame if still behind.
Expand Down Expand Up @@ -1671,6 +1733,7 @@ internal class TaoComposeSceneHostLinux(
// Coalesced size/scale change is committed here, after the GL context
// is current — applyPendingNativeResize closes the stale Skia cache.
applyPendingNativeResize()
purgeResizeScratchIfDue(ctx)
updateResizeBurstSwapInterval()

val paintSize = resolvePaintSize()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,15 +369,21 @@ internal class TaoComposeSceneHostWindows(
attachmentHandle = handle
directContext =
(ctx ?: error("Failed to create Skia DirectContext on the ANGLE ES context")).also {
// Bound the GPU resource cache. Each frame wraps the default
// framebuffer in a fresh BackendRenderTarget + Surface, and Skia
// allocates a stencil/scratch attachment sized to the current
// window for it. During a border drag every new window size mints
// new scratch resources; even with VSync pacing the present (see
// onResizeLoopChanged) an explicit budget forces purgeAsNeeded on
// each flush so the cache stays bounded, and onResizeLoopChanged
// additionally purges the scratch accumulated across the drag.
it.resourceCacheLimit = RESOURCE_CACHE_LIMIT_BYTES
// Anchor the GPU resource cache budget. Each frame wraps the
// default framebuffer in a fresh BackendRenderTarget + Surface,
// and Skia allocates a stencil/scratch attachment sized to the
// current window for it; during a border drag every new window
// size mints scratch no later frame reuses.
//
// This write is a no-op at the current value — Ganesh's own
// default is the same 256 MiB (measured) — and it does NOT, as
// this comment used to claim, "force purgeAsNeeded on each
// flush": Skia purges to fit its budget whether or not we set
// one. What actually reclaims the drag's scratch is the purge,
// in onResized and onResizeLoopChanged. Keep the write anyway:
// it is the value the limit-toggle restores and the one place
// to change if the hosts ever run below Skia's default.
it.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES
}
attachedHostCount.incrementAndGet()

Expand Down Expand Up @@ -1059,7 +1065,7 @@ internal class TaoComposeSceneHostWindows(
// the drag-end path in onResizeLoopChanged reclaims the rest.
if (resizeLoopActive) {
val now = System.nanoTime()
if (now - lastResizePurgeNs >= RESIZE_PURGE_INTERVAL_NS) {
if (now - lastResizePurgeNs >= GPU_RESIZE_PURGE_INTERVAL_NS) {
lastResizePurgeNs = now
purgeGpuResourceCache()
}
Expand Down Expand Up @@ -1091,7 +1097,7 @@ internal class TaoComposeSceneHostWindows(
val ctx = directContext ?: return
if (attachmentHandle != 0L) NativeTaoGlBridge.nativeMakeCurrent(attachmentHandle)
ctx.resourceCacheLimit = 0
ctx.resourceCacheLimit = RESOURCE_CACHE_LIMIT_BYTES
ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES
}

/**
Expand Down Expand Up @@ -2042,27 +2048,6 @@ internal class TaoComposeSceneHostWindows(
/** Half-distance of the synthetic two-finger pair at scale 1.0. */
private const val PINCH_BASE_RADIUS_PX: Float = 120f

/**
* GPU resource cache budget for the host DirectContext. Bounds the
* per-frame scratch (wrapped-framebuffer stencil/attachments) so an
* uncapped resize flood — VSync is dropped during the OS modal
* resize/move loop — can't grow the process unbounded. Sized to cover
* a HiDPI window's render target plus Compose's layer/glyph caches
* with headroom, while still far below the >1 GB the leak reached.
*/
private const val RESOURCE_CACHE_LIMIT_BYTES: Long = 256L * 1024 * 1024

/**
* Gap between in-drag GPU cache purges during the OS modal
* resize/move loop. Every frame of the drag mints render-target
* scratch (stencil/attachments) at a size no later frame reuses;
* the periodic limit-toggle purge in [onResized] releases that
* accumulation mid-drag so the peak stays bounded even for long
* drags, without skipping any resize frame (a skipped frame is
* composited by DWM as a geometry/content mismatch — trembling).
*/
private const val RESIZE_PURGE_INTERVAL_NS: Long = 250_000_000L

// Stable ids well clear of real touch ids (raw WM_POINTER finger ids).
private const val PINCH_POINTER_ID_A: Long = 0xA001L
private const val PINCH_POINTER_ID_B: Long = 0xA002L
Expand Down
Loading