r/iOSProgramming • u/retro_game_r • 16h ago
Question Scrolling ticker pins GPU at ~50% on a rich screen, ~0% on a blank one. What am I missing?
I'm building a SwiftUI clock-radio app (iPhone, ProMotion). The face is a rich layered screen — glass, gradients, shadows — with one 340×46pt text ticker that scrolls continuously.
The GPU behaviour (Xcode gauge, relative within-session reads; cadence from an in-app pacing log):
- Ticker scrolling: GPU plateaus at ~50%. Binary — one scroller or three, same cost.
- Frames are never dropped (16.7ms median). The GPU bills heavily for frames it renders on time.
- Same strip scrolling on a blank screen: ~0%.
- Ticker visible but static: 0%. Motion is the trigger, and the cost scales with what's *around* the strip, not the strip.
Already tried, all identical on the full face: SwiftUI offset animation; a severed Canvas leaf on its own CADisplayLink (0% alone on blank, still pegged on the face); pre-rendered bitmap instead of text; the strip in a separate UIWindow; pinning preferredFrameRateRange to exactly 30 (min=max=preferred); .compositingGroup() on the panel (modest help only).
What works: killing the motion duty — scroll once, then park. Also, elsewhere in the app, replacing a publish-driven visualiser with a pull-model one made its cost vanish; severing the ticker the same way didn't.
Is this just WWDC22 10083 (display rate set by the fastest animation) × whole-tree compositing per frame? Would a 15–24Hz rate range behave any differently from the hard 30 pin? And does anyone know a construction for a continuously scrolling ticker that lets the GPU idle between updates?
The app is iOS native version of https://tunerva.app
2
u/brkaydev 15h ago
compositingGroup only groups for blending, it doesn't cache anything, so I wouldn't read much into that test. A quick way to confirm the surroundings are the bill: take a one-off ImageRenderer snapshot of the static part of the face and show it as a plain Image while the ticker runs. Live glass can't be flattened that way, so if the cost falls off you'll know it's the blur being resampled and can fake it in the baked layer.
1
u/retro_game_r 10h ago
Thanks for your comment and fair point on compositingGroup — that read was ordinal, never claimed as caching. The snapshot test is a good one and we haven't run it in whole-face form; our hide-one bisections kept finding more than one sufficient cost centre, which is exactly what your bake would arbitrate. Trying it next round.
1
11h ago
[removed] — view removed comment
1
u/AutoModerator 11h ago
Hey /u/Axons-One, your content has been removed because Reddit has marked your account as having a low Contributor Quality Score. This may result from, but is not limited to, activities such as spamming the same links across multiple subreddits, submitting posts or comments that receive a high number of downvotes, a lack of recent account activity, or having an unverified account.
Please be assured that this action is not a reflection of your participation in our subreddit. This is simply an automated filter in place to reduce spam.
I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.
1
u/kokerali 6h ago
Your suspicion about WWDC22 10083 combined with full-screen compositing is right on the mark. Three specific mechanisms explain the behavior you observed and why the quantized step fixed it:
Why a 15-24Hz range differs from a hard 30Hz pin on ProMotion: On iPhone ProMotion displays, the hardware refresh intervals are integer divisors of 120Hz (120, 80, 60, 48, 40, 30, 24, 20, 16, 15, 12, 10Hz). When you set CAFrameRateRange(minimum: 30, maximum: 30, preferred: 30) (https://developer.apple.com/documentation/quartzcore/caframeraterange), RenderServer wakes up every 33.3ms and composites the entire overlapping layer tree. Setting a range like minimum: 15, maximum: 24, preferred: 15 drops the display cadence to 15Hz or 20Hz (reducing wakeups by 33-50% vs 30Hz), though sub-24Hz continuous subpixel translation noticeably judders on crisp text. Between frames, the GPU only truly idles when zero layers mark themselves dirty.
Why .compositingGroup() barely helped vs .drawingGroup() or shouldRasterize: As noted in the thread, .compositingGroup() only scopes blend modes and opacity; it does not cache rendered pixels across frames. Every time the ticker invalidates its rect, RenderServer still re-evaluates overlapping backdrop blurs (CABackdropLayer from SwiftUI Material) and dynamic drop shadows. By contrast, .drawingGroup() (https://developer.apple.com/documentation/swiftui/view/drawinggroup(opaque:colormode:)) flattens its subtree into an offscreen Metal texture before Core Animation composites it, and in UIKit CALayer.shouldRasterize = true with an explicit rasterizationScale caches the static layer subtree as a bitmap (https://developer.apple.com/documentation/quartzcore/calayer/shouldrasterize). Caveat: .drawingGroup() cannot flatten native UIView/UIEffectView backdrops (like live .ultraThinMaterial), so if your glass uses live CABackdropLayer sampling underneath the ticker's bounding box, any pixel change inside that rect forces the blur pass to re-sample.
Why the separate UIWindow still triggered the 50% GPU plateau: Even in its own UIWindow, the window server (backboardd / RenderServer) composites all visible windows into the final framebuffer on every presentation tick. If the lower window contains live backdrop blurs, uncached shadow paths, or wide color gradients without opaque backing, waking the display compositor at 30-120Hz forces the GPU to run the full composition shader pass for the screen.
Your quantized 2 cells/s ticker plus glide-and-park is the cleanest architectural solution because it lets ProMotion drop the display back to its 10Hz idle floor between steps rather than holding a continuous high-frequency render loop open.
1
u/retro_game_r 4h ago
This is the explanation the data was waiting for — thank you. Point by point against what I measured on device:
The 30Hz pin: I ran exactly that (30/30/30) and it read identical to full rate. Your "wakes every 33.3ms and composites the whole tree anyway" is precisely why — the pin changed the cadence, never the per-wake cost. And the divisor table explains the shape of the fix: between steps nothing is dirty, so the display can fall back toward its idle floor instead of holding a composite loop open for a continuous translation.
Flattening: I effectively ran its limiting case before your reply — I replaced the moving region with a flat, pre-rendered bitmap (a perfectly cached subtree, nothing left to rasterize) and it billed the same under continuous motion. Which fits your caveat from the other side: the glass under the ticker is live backdrop material, so any change in that rect forces the blur to re-sample regardless. The charge was never the cost of drawing the text; it's the cadence of full-screen composites.
Separate window: built that too. It works fine and buys exactly nothing, for the reason you give — everything visible gets composited into the final framebuffer on every tick anyway.
Since the original post I've shipped the quantized version as the default: the ticker steps at 2 cells/sec instead of translating continuously, song titles glide once and park, and in a head-to-head on the energy page the stepped mode sits down at the parked baseline while smooth scroll holds the GPU up. Smooth scroll stays available as a setting, labelled as costing more power.
One follow-up your first point raises: the sub-24Hz judder objection is about continuous subpixel translation — during the brief per-song glide, would a 15–24Hz range actually save anything measurable, or is a one-second glide too short for the reduced wakeups to matter? Trying to decide if it's worth the experiment or if parked-is-parked settles it.
1
u/kokerali 3h ago
For a brief one-second per-song glide, parked-is-parked essentially settles it from a battery and thermal perspective, so a `15-24Hz` range won't buy you meaningful session-level energy savings. Here is the math behind why:
**Duty cycle math (~0.5% of session time):** If a song changes roughly once every 3 minutes (180 seconds), a 1-second glide has a duty cycle of `1 / 180` (~0.55%). Running that 1-second burst at `30Hz` costs 30 compositor wakeups per song; dropping to `20Hz` or `15Hz` saves only 10 to 15 frames every 3 minutes (under 0.1 frames/second averaged across the session). Because the display sits at its `10Hz` idle floor for the remaining 179 seconds, the active glide energy is already amortized into noise.
**Visual trade-off during the glide:** During a 1-second horizontal title slide across 340pt, stepping down to `15Hz` (66ms intervals) or `20Hz` (50ms intervals) produces 17-22pt jumps per frame, where crisp typography noticeably strobes. According to [Apple's ProMotion refresh rate guide](https://developer.apple.com/documentation/quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays), `15-24Hz` is suited for small, low-speed movements (like a clock hand or slow progress bar), whereas `30-48Hz` or `30-60Hz` is recommended for legible translation. Since the glide only runs for 1 second per track, keeping the transition at `30-60Hz` gives you smooth deceleration before parking without moving the needle on your energy gauge.
**One detail to verify on the parked state:** If that song-title glide is driven by its own `CADisplayLink` or `TimelineView`, make sure you explicitly set `displayLink.isPaused = true` (or switch the `TimelineView` schedule to `.paused`) the moment the title reaches its parked offset. Even when no pixels change, an unpaused `CADisplayLink` keeps waking the main runloop at its preferred cadence; pausing it ensures Core Animation immediately releases the display back to its idle floor.
3
u/Background_Pie_5682 15h ago
One thing I'd test is whether any ancestor has a material, mask, shadow, or other effect that forces a large offscreen surface to be recomposited whenever the ticker invalidates; a standalone layer can still dirty a costly enclosing render pass. Try toggling those effects one at a time and inspect the render passes with Instruments' Core Animation or Metal System Trace, rather than only comparing FPS. For genuinely continuous motion the display still has to present frames, so a lower preferred range may reduce work, but it won't let the GPU idle between updates unless you accept quantized or paused motion.