Skip to content

[macOS] Liquid animations keep rendering at 60 fps while the window is hidden, costing a quarter to half a core #2393

Description

@nichtlegacy

NOOP version

11.8.0 (build 198)

Platform

macOS

OS version

macOS 27.0 (26A428)

WHOOP strap / Oura ring model

Not strap-dependent. This is decorative UI rendering and reproduces regardless of the connected device or data source.

Where the data is coming from

Not applicable. It reproduces with the app simply left running.

Test Centre profile

Not a test-mode bug

What happens

With "Reduce motion in NOOP" off (the default), the Liquid gauges keep rendering for as long as the app runs, whether or not the window is visible. On an M1 Pro that costs between a quarter and half a CPU core continuously. Hiding the app with Cmd+H does not reduce it. It reproducibly increases it.

I noticed because I was auditing idle CPU on this Mac for unrelated reasons and NOOP was the largest single process on the system, ahead of WindowServer and kernel_task.

A second, much smaller observation about the shipped macOS build is in section 2. I originally assumed it contributed to the CPU cost, measured it, and it does not. It is reported separately because the build config looks unintended rather than because it is expensive.


1. The liquid animations have no visibility condition

Three TimelineView(.animation(...)) drivers in Strand/Liquid/LiquidPrimitives.swift:

Line Cadence View
302 1.0 / 60.0 gauge
348 1.0 / 30.0 liveTube
391 1.0 / 60.0 liveThread

Each one is gated, but only on the motion preference, never on visibility:

// LiquidPrimitives.swift:295
if animated && !motion.poseStill(reduceMotion) { gauge }      else { staticGauge }
// :344
if animated && !motion.poseStill(reduceMotion) { liveTube }   else { staticTube }
// :387
if animated && !motion.poseStill(reduceMotion) { liveThread } else { staticThread }

poseStill(_:) is reduceMotion || isLowPower || quietMotion (NoopMotion.swift:146). None of those three signals has anything to do with whether the window is on screen.

scenePhase is read elsewhere in the app (StrandApp.swift:37, InsightsView.swift:66, AppModel.swift), but nothing under Strand/Liquid/ reads it, and a grep across the repository finds no occlusionState, NSWindowDidChangeOcclusionState or isOnActiveSpace observer anywhere.

To be fair to the original decision, the comment above gauge explains the 60 fps choice and shows it was costed:

60fps: on the 120Hz ProMotion panel a 30fps cap updated the fluid only every 4th refresh, which read as juddery slosh. Only the 3 hero gauges + HR thread run live now (the small ones are static), so the higher rate is affordable and the liquid actually flows.

That reasoning holds for a visible window. The problem is that the same rate keeps running when nobody is looking at it.

Profile

sample, 8 s, on the local Release build with the window visible. Top non-idle leaves:

RB::SurfacePool::collect(bool)                  (RenderBox)    199
_dispatch_timer_heap_resift                     (libdispatch)  195
CAImageQueueConsumeUnconsumedInRange            (QuartzCore)    76
CABackingStoreCollect_(double, bool)            (QuartzCore)    47

App-side frames present in the same profile:

LiquidRender.vessel(_:_:_:now:tint:)
LiquidRender.thread(_:_:values:now:tint:segments:)
LiquidRender.progressGradient(_:)
liquidSkyAt(_:light:)
Color.liquidComponents()
Color.sRGBComponents(hex:)
closure #1 in Color.init(light:dark:)

Secondary: the palette re-parses hex strings on every colour read

The last three frames above are worth a look on their own. On macOS Color(light:dark:) builds a dynamic NSColor whose resolution closure parses the hex string each time it runs (Palette.swift:44):

self.init(nsColor: NSColor(name: nil) { appearance in
    let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
    let c = Color.sRGBComponents(hex: isDark ? dark : light)

and sRGBComponents(hex:) is not cheap (Palette.swift:8):

let raw = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: raw).scanHexInt64(&int)

liquidComponents() (LiquidCore.swift:29) reads components back out with NSColor(self).usingColorSpace(.sRGB), which forces that closure to run again. liquidMix, liquidDarker and liquidLighter all route through it, and LiquidSky.swift:56 calls liquidComponents() twice in one expression. Because the colour is dynamic, none of this can be constant-folded, so the palette is re-parsed from strings while frames are being produced.

I have not isolated how much of the total this accounts for, so treat it as a separate cleanup rather than as part of the measured cost above. Caching the resolved components looks worthwhile regardless of the visibility fix.


2. The shipped macOS build is a Debug build

This is not a performance claim. I measured it and it makes no meaningful difference, see the table below. Reporting it because the inconsistency with iOS looks accidental.

Both release workflows build macOS as Debug and iOS as Release:

Workflow Line Target Configuration
fork-release.yml 218 macOS (Strand) Debug
fork-release.yml 273 iOS (NOOPiOS) Release
fork-testing-build.yml 156 macOS (Strand) Debug
fork-testing-build.yml 202 iOS (NOOPiOS) Release

The comment above the macOS step says this is deliberate ("these command-line settings override the config, so it stays Debug (unchanged shipping behaviour)"). Reading it in context, it is about forcing a universal binary after the Intel-Mac report in #51, and the Debug configuration is preserved as a side effect rather than chosen on its merits.

What ships:

Artefact Contents/MacOS/
NOOP-macos-v11.8.0.zip (stable, v11.8.0) 120 KB stub + NOOP Staging.debug.dylib 195 MB + __preview.dylib
NOOP-macos-v11.8.0.zip (testing-latest) same shape, debug.dylib 196 MB
Local -configuration Release build of the same commit single 53 MB binary, no debug.dylib, no __preview.dylib

Also worth a look: the stable macOS artefact carries the com.noopapp.noop.staging bundle id and a NOOP Staging executable, which may not be intended for a v11.8.0 release.


What I expected instead

The liquid timelines should stop when the window is occluded, hidden or minimised. poseStill(_:) already exists as the single gate, so a fourth signal alongside reduceMotion, isLowPower and quietMotion would fit the existing shape.

Steps to reproduce

  1. Launch NOOP on macOS with "Reduce motion in NOOP" off and leave the Today screen open.
  2. Read ps -p $(pgrep -x "NOOP Staging") -o time= twice, 120 s apart, and divide the delta by 120.
  3. Hide the app with Cmd+H, wait 20 s, measure again. It goes up, not down.
  4. Turn "Reduce motion in NOOP" on and measure again. It drops to roughly zero.

Measurements

MacBook Pro 18,3 (M1 Pro, 6P+2E, 16 GB), macOS 27.0 (26A428), two external displays at 60 Hz and 120 Hz. NOOP Staging 11.8.0 (198) built locally from dab0efe at -configuration Release unless stated otherwise. Every figure is a CPU-time delta over a 120 s window, taken 50 s after launch or 20 s after a window state change.

Run 1, four states, app relaunched between the motion-preference changes:

# Reduce motion in NOOP Window CPU
A off visible 28.76 %
B off hidden (Cmd+H) 49.33 %
C on visible 0.09 %
D on hidden 0.01 %

Run 2, the same A/B inside a single process with no relaunch, to rule out a restart artefact:

# Window CPU
A visible 34.15 %
B hidden (Cmd+H) 41.75 %
A2 visible again 34.11 %

Run 3, the shipped Debug artefact against my Release build of the same version, both visible, motion preference off:

Build CPU
NOOP-macos-v11.8.0.zip as shipped (Debug) 27.51 %
Same version built -configuration Release 26.49 %

So the build configuration is not the story. The animation is.

Two things stand out in runs 1 and 2. Hiding the window costs more than showing it, across two independent runs, and re-showing returns to baseline. And the motion preference is a complete fix rather than a partial one. 0.09 % is the app doing its ordinary background work with the gauges held still.

I have no Instruments trace for the hidden-is-worse part, so treat the explanation as a guess rather than a finding. With the window on screen the display link appears to pace the timeline, and once hidden that pacing is gone and the schedule free-runs. _dispatch_timer_heap_resift at 195 samples is consistent with that, but it is not proof.

For scale, the copy I had been running accumulated 915 min 51 s of CPU over 2 d 18 h 49 m of wall clock. That is a 22.8 % average, and it includes periods where the machine was asleep, so the awake-time figure is higher.

Workaround

Settings → "Reduce motion in NOOP" (SettingsView.swift:1362). It writes noop.quietMotion, which feeds poseStill(_:) and swaps gauge, liveTube and liveThread for their static variants.

I verified the effect by setting the key and relaunching, which is scenarios C and D above. I did not separately time the in-app toggle, though NoopMotion.swift:125 mirrors the key on UserDefaults.didChangeNotification, so it should apply without a restart.

One note if anyone scripts this rather than using the toggle: a defaults write from a shell will not take effect in a running instance, because that notification is intra-process. The value is picked up at the next launch (NoopMotion.swift:115).

The toggle is off by default and its help text presents it as a preference ("Saves battery"). On this machine it is the difference between a third of a core and nothing, and someone hunting a hot fan has no reason to suspect a decorative setting.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions