From e0759424ce7cd84fdb3cc6ce1b6ac0ff64204fba Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 23:34:43 -0700 Subject: [PATCH] Reduce idle CPU and wallpaper memory across fleet Macs Throttle Slab sampling and idle animation, cache process rankings, and share wallpaper geometry with smaller render buffers. Keep disabled MenuBand monitoring closed and release hidden-panel waveform capture so the audio engine can suspend. Add microphone lifecycle regression coverage. --- .../Sources/MenuBand/AppDelegate.swift | 19 ++++- .../Sources/MenuBand/MenuBandPopover.swift | 2 + .../Sources/MenuBand/MenuBandSynth.swift | 32 +++++-- .../CollapsedPianoWaveformView.swift | 7 ++ .../PianoWaveformViewController.swift | 2 +- .../MenuBandTests/MonitorIdleTests.swift | 26 ++++++ .../Sources/SlabMenubar/AppDelegate.swift | 13 ++- .../Sources/SlabMenubar/ResourceGraph.swift | 11 ++- .../Sources/SlabMenubar/StateSnapshot.swift | 15 ++++ toolchain/macos/blueberry-wallpaper/README.md | 11 +-- .../blueberry-wallpaper/Sources/main.swift | 85 +++++++++---------- toolchain/macos/blueberry-wallpaper/build.sh | 1 + 12 files changed, 156 insertions(+), 68 deletions(-) create mode 100644 slab/menuband/Tests/MenuBandTests/MonitorIdleTests.swift diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 39c017939a..c7495de7bf 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -2728,7 +2728,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // when silent they fall to a flat/short floor instead of // vanishing, so the menubar still looks "alive." let frameRate: CGFloat = 24 - let timer = Timer(timeInterval: TimeInterval(1.0 / frameRate), repeats: true) { [weak self] _ in + let timer = Timer(timeInterval: TimeInterval(1.0 / frameRate), repeats: true) { [weak self] timer in guard let self = self else { return } let now = CACurrentMediaTime() let tapeActive = self.menuBand.tape.state == .recording @@ -2744,6 +2744,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // synth remove its tap and suspend the otherwise-idle engine. self.setVisualizerWaveformCaptureEnabled(false) } + // Once all release tails are invisible, skip the RMS/filter work + // and back off this visual-only clock. Note handling never waits + // on it; updateIcon wakes it on the next input/state change. + let meterAtRest = !audioActive && !self.visualizerWaveformCaptureEnabled + && self.visualizerSmoothedLevel < 0.001 + && self.visualizerBars.allSatisfy { $0 < 0.001 } + && KeyboardIconRenderer.midiActivityFlash == 0 + && KeyboardIconRenderer.metronomeFlash == 0 + && !self.menuBand.percussionSplit + if meterAtRest { + timer.fireDate = Date(timeIntervalSinceNow: 0.25) + return + } // Two RMS sources, one consumer: // • While the user is recording (holding `), the bars // pulse with mic-input level — published by @@ -3647,6 +3660,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func updateIcon() { + if let timer = visualizerAnimTimer, + timer.fireDate.timeIntervalSinceNow > 1.0 / 24.0 { + timer.fireDate = Date() + } guard let button = statusItem.button else { return } // Keep the renderer's drum-zone coloring in sync with the live split. KeyboardIconRenderer.percussionLeftActive = menuBand.percussionLeft diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index f4ad347a94..5d77af0020 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -1333,6 +1333,7 @@ final class MenuBandPopoverViewController: NSViewController { // no one can see. Patch contributed by Esteban Uribe. override func viewWillDisappear() { super.viewWillDisappear() + instrumentCluster?.setPresented(false) transportTimer?.invalidate() transportTimer = nil } @@ -1426,6 +1427,7 @@ final class MenuBandPopoverViewController: NSViewController { override func viewDidAppear() { super.viewDidAppear() guard isViewLoaded, let menuBand = self.menuBand else { return } + instrumentCluster?.setPresented(true) // Drive the STOP transport while the popover is on screen. updateTransport() transportTimer?.invalidate() diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index e6137a3020..0fb0e0cf83 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -321,8 +321,12 @@ final class MenuBandSynth { /// so otherwise the engine would pause and the first hit would lag). var keepEngineWarm = false { didSet { - guard keepEngineWarm, started else { return } - _ = resumeAudioEngineIfNeeded() // warm it immediately on arm + guard keepEngineWarm != oldValue, started else { return } + if keepEngineWarm { + _ = resumeAudioEngineIfNeeded() // warm it immediately on arm + } else { + scheduleIdleSuspendIfNeeded() + } } } /// True once MIDISynth has loaded its bank, preloaded all programs, @@ -492,6 +496,11 @@ final class MenuBandSynth { if enabled && !inputMonitor.isAttached { NSLog("MenuBand: monitoring on but no input device could be opened") } + if enabled { + // Cold launch can now suspend the output engine. An explicit + // monitor request must wake it before its new source can render. + _ = resumeAudioEngineIfNeeded() + } inputMonitor.setEnabled(enabled && !monitorFocusMuted) // SampleVoice's own monitoring flag wakes its record engine (a second // duplex client on the interface) — only let it while the direct @@ -531,9 +540,12 @@ final class MenuBandSynth { /// explicit bounce) and hold `engineLock` where one is live. private func syncInputMonitorGraph() { NSLog("MenuBand monitor: sync — wanted=\(inputMonitoringWanted) eligible=\(duplexMonitorEligible()) attached=\(inputMonitor.isAttached) running=\(engine.isRunning)") - // Stay wired whenever an input device exists (see setInputMonitoringEnabled); - // "wanted" only decides audibility. - if duplexMonitorEligible() { + // A disabled monitor must not open the microphone at launch. Once + // explicitly opened, keep the connection on mute: reopening a live + // Scarlett stream can interrupt its output (see the toggle above). + if Self.shouldKeepMonitorOpen(wanted: inputMonitoringWanted, + attached: inputMonitor.isAttached, + eligible: duplexMonitorEligible()) { inputMonitor.preferredIOBufferFrames = Self.monitorIOBufferFrames if inputMonitor.isAttached { // Already wired: this is a device-change recovery or a @@ -557,6 +569,10 @@ final class MenuBandSynth { reconcileMicSource() } + static func shouldKeepMonitorOpen(wanted: Bool, attached: Bool, eligible: Bool) -> Bool { + eligible && (wanted || attached) + } + func ingestMonitoredInput(_ buffer: AVAudioPCMBuffer) { inputMonitor.ingest(buffer) } @@ -1537,7 +1553,8 @@ final class MenuBandSynth { } private func scheduleIdleSuspendIfNeeded() { - guard started, !waveformCaptureEnabled, activeNotes.isEmpty, + guard started, !waveformCaptureEnabled, waveformTapPinReasons.isEmpty, + !keepEngineWarm, activeNotes.isEmpty, !sampleRecordingActive, !inputMonitor.isAttached else { return } idleSuspendWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -1553,7 +1570,8 @@ final class MenuBandSynth { // A live mic monitor is never idle: pausing the engine here would // stop the voice and leave every player node facing a paused engine // (AVAudioPlayerNode.play() then throws — two crashes on 2026-09-25). - guard started, engine.isRunning, !waveformCaptureEnabled, activeNotes.isEmpty, + guard started, engine.isRunning, !waveformCaptureEnabled, + waveformTapPinReasons.isEmpty, activeNotes.isEmpty, !sampleRecordingActive, !keepEngineWarm, !inputMonitor.isAttached else { return } removeWaveformTapIfNeeded() engine.pause() diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift index 520292c54b..cfb31e0e68 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -106,6 +106,10 @@ final class CollapsedPianoWaveformView: NSView { arrowsCluster.setHighlight(direction: direction, on: on) } + func setPresented(_ presented: Bool) { + waveformStrip.isLive = presented + } + private static let arrowsRowHeight: CGFloat = 34 private static let modeRowHeight: CGFloat = 22 private static let edgePadding: CGFloat = 6 @@ -337,6 +341,9 @@ final class CollapsedPianoWaveformView: NSView { ]) keymapButton.toolTip = "Open the full-screen keymap (piano + QWERTY)" + // Panels are prebuilt while hidden. Their attached views must not + // acquire an audio-capture lease until their host presents them. + waveformStrip.isLive = false waveformStrip.menuBand = menuBand // Clicking the live scope blows it up to the full-screen LED wall — // the same display, nothing else on screen. Where no visualizer is diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift index b1e486ab80..0669b95988 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift @@ -161,7 +161,7 @@ final class PianoWaveformViewController: NSViewController { private func updatePresentationState() { expandedView.setPresented(isPresented && presentationMode == .expanded) - // collapsed view no longer hosts a live visualizer; nothing to gate. + collapsedView.setPresented(isPresented && presentationMode == .collapsed) let isExpanded = presentationMode == .expanded closeButton.isHidden = !isExpanded closeButtonGlassView?.isHidden = !isExpanded diff --git a/slab/menuband/Tests/MenuBandTests/MonitorIdleTests.swift b/slab/menuband/Tests/MenuBandTests/MonitorIdleTests.swift new file mode 100644 index 0000000000..9231c35968 --- /dev/null +++ b/slab/menuband/Tests/MenuBandTests/MonitorIdleTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import MenuBand + +final class MonitorIdleTests: XCTestCase { + func testDisabledMonitorDoesNotOpenMicOnLaunchOrDeviceArrival() { + for eligible in [false, true] { + XCTAssertFalse(MenuBandSynth.shouldKeepMonitorOpen( + wanted: false, attached: false, eligible: eligible)) + } + } + + func testExplicitMonitoringOpensAvailableInput() { + XCTAssertTrue(MenuBandSynth.shouldKeepMonitorOpen( + wanted: true, attached: false, eligible: true)) + } + + func testMutingAnEstablishedInterfaceDoesNotReopenUSBStreams() { + XCTAssertTrue(MenuBandSynth.shouldKeepMonitorOpen( + wanted: false, attached: true, eligible: true)) + } + + func testDisconnectedInputIsReleasedEvenWhenMonitoringIsWanted() { + XCTAssertFalse(MenuBandSynth.shouldKeepMonitorOpen( + wanted: true, attached: true, eligible: false)) + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 58d64be06d..c84eca97fc 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -786,25 +786,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // awaiting brightness and stale blink). When the polygon goes away // entirely, stop the timer and reset phases. if !state.claudeSessions.isEmpty || state.idleResting { + let interval: TimeInterval = state.idleResting ? 0.5 : 0.08 + if let timer = animTimer, timer.timeInterval != interval { + timer.invalidate() + animTimer = nil + } if animTimer == nil { - let t = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in + let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in guard let self = self else { return } + let phaseStep = CGFloat(interval / 0.08) // Idle rests at a gentle hue flow; active states cycle // faster (the awaiting throb reads off this phase). let rainbowStep: CGFloat = self.state.idleResting ? 0.006 : 0.025 - self.rainbowPhase = (self.rainbowPhase + rainbowStep).truncatingRemainder(dividingBy: 1.0) + self.rainbowPhase = (self.rainbowPhase + rainbowStep * phaseStep).truncatingRemainder(dividingBy: 1.0) if self.state.anyActive { let rotSpeed = 0.004 + 0.012 * CGFloat(self.state.awaitingCount) self.rotationPhase = (self.rotationPhase + rotSpeed) .truncatingRemainder(dividingBy: .pi * 2) } else if self.state.idleResting { // Slow spin — ~50 s per revolution. Enticing, not busy. - self.rotationPhase = (self.rotationPhase + 0.010) + self.rotationPhase = (self.rotationPhase + 0.010 * phaseStep) .truncatingRemainder(dividingBy: .pi * 2) } self.updateIcon() } RunLoop.main.add(t, forMode: .common) + t.tolerance = interval * 0.15 animTimer = t } } else if let t = animTimer { diff --git a/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift b/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift index 04d319f1bb..831db6123c 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift @@ -208,14 +208,14 @@ final class ResourceGraph: NSObject { private var hoverGlobalMonitor: Any? private var hoverLocalMonitor: Any? - private let sampleInterval: TimeInterval = 1.0 / 4.0 + private let sampleInterval: TimeInterval = 1.0 private let barSectionBaseWidth: CGFloat = 13.5 private let graphInset: CGFloat = 2 private let graphGap: CGFloat = 1 - private let historyLimit = 60 // 15 seconds at 4 Hz. - private let networkStride = 2 // Network reads at 2 Hz to calm short bursts. - private let gpuStride = 8 // IOKit once per two seconds. - private let diskStride = 40 // statfs once per ten seconds. + private let historyLimit = 15 // Keep the same 15-second window at 1 Hz. + private let networkStride = 1 // Network deltas cover a full second. + private let gpuStride = 2 // IOKit once per two seconds. + private let diskStride = 10 // statfs once per ten seconds. /// MenuBand scales its 13.5pt semitone keys with this same factor. private var displayScale: CGFloat { @@ -352,7 +352,6 @@ final class ResourceGraph: NSObject { button.contentTintColor = nil // The same local panel serves hover and click; there is deliberately no // second click-only menu or tooltip path. - guard tickCount == 1 || tickCount % 4 == 0 else { return } button.toolTip = nil refreshHoverCard() } diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift index 700f93aaed..ad12795387 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -400,6 +400,10 @@ struct StateSnapshot { /// prints — and swap comes from vm.swapusage; both are in-process /// sysctls. The hogs list is one `ps` fork (memory-sorted), which is /// in line with the ioreg/pmset/pgrep forks gather() already pays. + private static let hogCacheLock = NSLock() + private static var cachedHogs: [SystemHog] = [] + private static var hogsReadAt: TimeInterval = -.infinity + private static func readSystemStats() -> SystemStats { var stats = SystemStats() @@ -421,6 +425,15 @@ struct StateSnapshot { stats.loadAvg = loads[0] } + // The memory-ranked process submenu does not need a full process + // census on every fleet refresh. Pressure/load remain live above. + hogCacheLock.lock() + defer { hogCacheLock.unlock() } + let now = ProcessInfo.processInfo.systemUptime + if now - hogsReadAt < 30 { + stats.hogs = cachedHogs + return stats + } if let out = ShellRunner.output( "/bin/ps", args: ["axm", "-o", "rss=,pcpu=,pid=,comm="], timeout: 2) { for line in out.split(separator: "\n").prefix(6) { @@ -435,6 +448,8 @@ struct StateSnapshot { stats.hogs.append(SystemHog( pid: pid, rssMB: rssKB / 1024, cpu: cpu, name: name)) } + cachedHogs = stats.hogs + hogsReadAt = now } return stats } diff --git a/toolchain/macos/blueberry-wallpaper/README.md b/toolchain/macos/blueberry-wallpaper/README.md index f2b6162854..fab06bc531 100644 --- a/toolchain/macos/blueberry-wallpaper/README.md +++ b/toolchain/macos/blueberry-wallpaper/README.md @@ -5,13 +5,14 @@ Meshy v6 AC Pals model recovered for the Xbox native app: `xbox/assets/pals-mesh-nat-amethyst.glb`. The app bundles the original GLB and a SceneKit-readable USD conversion of the -same mesh. SceneKit renders that geometry live at each display's native backing -resolution. Varied-size Pals marks rotate independently while buoyant +same mesh. SceneKit renders that geometry at 30 FPS at each display's native +backing resolution, with 2× MSAA and two drawable buffers. Varied-size Pals marks rotate independently while buoyant acceleration and a gentle sway carry them upward. Every instance uses shared -live mesh data rather than a bitmap sprite; its triangle topology is drawn as a -translucent system-accent wireframe without surface textures or lighting. +live mesh data rather than a bitmap sprite. One imported prototype and its +geometry/materials are shared across displays and appearance rebuilds. Imported +texture maps are discarded; the field uses a single opaque accent material. -The model keeps its original amethyst textures over a blueberry field that +The accent-shaded model floats over a blueberry field that responds to macOS appearance. The window sits beneath Finder's desktop icons, ignores input, joins every Space, and pauses while the display sleeps. diff --git a/toolchain/macos/blueberry-wallpaper/Sources/main.swift b/toolchain/macos/blueberry-wallpaper/Sources/main.swift index dbd6c0f0df..e4f4fb669f 100644 --- a/toolchain/macos/blueberry-wallpaper/Sources/main.swift +++ b/toolchain/macos/blueberry-wallpaper/Sources/main.swift @@ -56,13 +56,9 @@ private enum Emblem { } private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { - private struct Placement { - let model: SCNNode - var x: CGFloat - var y: CGFloat - let width: CGFloat - let height: CGFloat - } + // SCNNode.clone shares geometry/materials. Keep one imported prototype + // across displays and appearance rebuilds, not one import per window. + private static var sharedPrototype: SCNNode? private static let stageHalfHeight: CGFloat = 2.35 // Tiny drifting glyphs: roughly quarter-icon to half-icon height on the @@ -141,8 +137,8 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { // instead of against a transparent surface composited later — the // latter left tinted alpha fringes along every silhouette. sceneView.backgroundColor = .black - sceneView.antialiasingMode = .multisampling4X - sceneView.preferredFramesPerSecond = 60 + sceneView.antialiasingMode = .multisampling2X + sceneView.preferredFramesPerSecond = 30 sceneView.rendersContinuously = true sceneView.isPlaying = true addSubview(sceneView) @@ -174,10 +170,14 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { super.viewDidMoveToWindow() updateAppearance(animated: false) guard let scale = window?.backingScaleFactor else { return } - // Render at no less than 2× and let the compositor downsample: on a - // 1× display 4× MSAA alone still shows stair-steps along the - // silhouettes, and supersampling on top of it smooths them out. - sceneView.layer?.contentsScale = max(scale, 2) + // Native backing resolution avoids quadrupling render-target memory + // on 1× external displays. MSAA still smooths the geometry edges. + sceneView.layer?.contentsScale = scale + // The slow desktop field needs only a front/back drawable pair. + // A third full-screen Retina IOSurface costs ~19 MB on Blueberry. + if let metalLayer = sceneView.layer as? CAMetalLayer { + metalLayer.maximumDrawableCount = 2 + } } /// Posted after the system appearance flips. macOS keeps a light/dark @@ -316,7 +316,17 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { private func loadModel() { let prototype: SCNNode - if let emblemPath = Emblem.path, !Emblem.isModel { + if let shared = Self.sharedPrototype { + prototype = shared + prototype.enumerateChildNodes { node, _ in + for material in node.geometry?.materials ?? [] { + if !materials.contains(where: { $0 === material }) { + materials.append(material) + } + } + } + applyAccentColor(animated: false) + } else if let emblemPath = Emblem.path, !Emblem.isModel { guard let emblem = emblemPrototype(path: emblemPath) else { fputs("could not load emblem image at \(emblemPath)\n", stderr) return @@ -348,16 +358,13 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { (lo.x + hi.x) * 0.5, (lo.y + hi.y) * 0.5, (lo.z + hi.z) * 0.5) + Self.sharedPrototype = prototype for spec in Self.specs { let model = prototype.clone() let scale = spec.scale * Self.markScale model.scale = SCNVector3(scale, scale, scale) model.eulerAngles = SCNVector3(-0.055, spec.phase * .pi * 2, 0) - let angle = (spec.reverse ? -1 : 1) * CGFloat.pi * 2 - let turn = SCNAction.rotateBy(x: 0, y: angle, z: 0, duration: spec.turnSeconds) - turn.timingMode = .linear - model.runAction(.repeatForever(turn), forKey: "fullResolutionTurn") scene.rootNode.addChildNode(model) models.append(model) } @@ -372,10 +379,12 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { // side deliberately overscan by almost half their width so the live // field reads full-bleed instead of as an inset/letterboxed stage. let padding: CGFloat = 0.13 - var candidates: [Placement] = [] let swell = 1 + beatPulse(now: Date().timeIntervalSince1970) for (model, spec) in zip(models, Self.specs) { + let turn = CGFloat(elapsed / spec.turnSeconds) * .pi * 2 + model.eulerAngles = SCNVector3(-0.055, + spec.phase * .pi * 2 + (spec.reverse ? -turn : turn), 0) let raw = CGFloat(elapsed / spec.riseSeconds) + spec.phase let progress = raw - floor(raw) let scale = spec.scale * Self.markScale @@ -393,16 +402,7 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { let overscanWidth = halfWidth + width * 0.45 let desiredX = spec.x * overscanWidth + sin(progress * .pi * 2 + spec.phase * .pi) * halfWidth * spec.sway - candidates.append(Placement( - model: model, x: desiredX, y: low + (high - low) * buoyant, - width: width, height: height)) - } - - // Do not solve collisions frame-by-frame: a change in vertical order - // makes that solver jump an entire mark in one frame. Overlap is part - // of the field; independent continuous paths keep every edge stable. - for placement in candidates { - placement.model.position = SCNVector3(placement.x, placement.y, 0) + model.position = SCNVector3(desiredX, low + (high - low) * buoyant, 0) } } @@ -433,25 +433,20 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { } private func applyOriginalGLBMaterials(to model: SCNNode) { + // The field is accent shaded, so retain none of the imported model's + // texture maps. All mesh instances and displays share this material. + let material = SCNMaterial() + material.lightingModel = .lambert + material.diffuse.contents = NSColor.controlAccentColor + material.metalness.contents = 0.0 + material.roughness.contents = 1.0 + material.specular.contents = NSColor.black model.enumerateChildNodes { node, _ in - for material in node.geometry?.materials ?? [] { - material.fillMode = .fill - material.lightingModel = .lambert - material.diffuse.contents = NSColor.controlAccentColor - material.emission.contents = nil - material.multiply.contents = nil - material.normal.contents = nil - material.metalness.contents = 0.0 - material.roughness.contents = 1.0 - material.specular.contents = NSColor.black - // Opaque surfaces avoid SceneKit's transparency sorting and - // the stippled/dancing silhouettes it produces at the crop. - material.transparency = 1.0 - if !materials.contains(where: { $0 === material }) { - materials.append(material) - } + if let geometry = node.geometry { + geometry.materials = [material] } } + materials = [material] applyAccentColor(animated: false) } diff --git a/toolchain/macos/blueberry-wallpaper/build.sh b/toolchain/macos/blueberry-wallpaper/build.sh index 7e5fa25404..ed78598369 100755 --- a/toolchain/macos/blueberry-wallpaper/build.sh +++ b/toolchain/macos/blueberry-wallpaper/build.sh @@ -28,6 +28,7 @@ rm "$MODEL_DIR/pals-mesh-nat-amethyst.usdc" cp "$PALS_GLB" "$APP/Contents/Resources/pals-mesh.glb" swiftc -O \ + -target "$(uname -m)-apple-macos14.0" \ -framework AppKit \ -framework QuartzCore \ -framework SceneKit \ -- 2.51.2