From 4d8bb36f59dacccf8e76369b292e68bbd7ec05e1 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Sun, 12 Jul 2026 15:26:09 +0900 Subject: [PATCH 1/3] Repaint litter incrementally instead of full-screen Address the converged performance finding (#3, plus #5): the overlay was invalidated wholesale (litterView.needsDisplay = true) and draw() cleared the entire multi-screen bounds every pass, so an active cat forced a full virtual-screen recomposite ~10-60fps. - LitterField.update now returns the global rects of marks that appeared or vanished (added, aged out, or evicted by the cap); onTick invalidates only those via LitterView.invalidateGlobal. fadingRects() drives the ~10fps refresh of marks in their fade window. - draw() clears only dirtyRect and skips marks that don't intersect it. - Cache the two base CGColors and apply per-mark opacity with ctx.setAlpha, so no color object is allocated per mark per frame. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Pp772scLq2mo9cDaybWrLu --- Neco/Litter.swift | 88 ++++++++++++++++++++++++++++++++++------------ Neco/NecoApp.swift | 14 +++++--- 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/Neco/Litter.swift b/Neco/Litter.swift index 27f408f..1a5817e 100644 --- a/Neco/Litter.swift +++ b/Neco/Litter.swift @@ -15,13 +15,18 @@ private struct Mark { } /// Accumulates and ages the cat's marks. `update(neko:)` is called once per 60fps -/// tick; it emits new marks from the cat's motion, culls faded ones, and reports -/// whether the set changed so the view only redraws when it needs to. +/// tick; it emits new marks from the cat's motion and culls faded ones, returning the +/// global-coordinate rects that changed so the view can repaint just those, not the +/// whole multi-screen overlay. @MainActor final class LitterField { var pawsEnabled = false var scratchEnabled = false + /// Half-size of a mark's bounding box, used for partial invalidation and to skip + /// off-screen marks while drawing. Generous enough to cover any rotated paw/scratch. + static let markRadius: CGFloat = 16 + fileprivate private(set) var marks: [Mark] = [] private var tick = 0 private var prevPos: NSPoint? @@ -40,17 +45,25 @@ final class LitterField { kind == .paw ? pawLife : scratchLife } - /// Advance one tick. Returns true if a mark was added or culled (a new/removed - /// mark needs an immediate redraw; ongoing fade is redrawn on a throttle). - @discardableResult - func update(neko: Neko) -> Bool { + private func rect(for mark: Mark) -> NSRect { + let r = Self.markRadius + return NSRect(x: mark.pos.x - r, y: mark.pos.y - r, width: r * 2, height: r * 2) + } + + /// Advance one tick. Returns the global rects of marks that appeared or vanished + /// this tick (added, culled by age, or evicted by the cap) — the caller invalidates + /// just these. Ongoing fade is refreshed separately via `fadingRects()`. + func update(neko: Neko) -> [NSRect] { tick += 1 let pos = neko.pos defer { prevPos = pos } - let before = marks.count - marks.removeAll { tick - $0.birth > life(of: $0.kind) } - var changed = marks.count != before + var dirty: [NSRect] = [] + marks.removeAll { mark in + guard tick - mark.birth > life(of: mark.kind) else { return false } + dirty.append(rect(for: mark)) + return true + } if pawsEnabled, neko.isRunning { if let last = lastPawPos, hypot(pos.x - last.x, pos.y - last.y) >= pawSpacing { @@ -59,10 +72,9 @@ final class LitterField { let off = 6 * pawSide let p = NSPoint(x: pos.x + cos(heading + .pi / 2) * off, y: pos.y + sin(heading + .pi / 2) * off) - add(Mark(kind: .paw, pos: p, angle: heading, birth: tick)) + dirty += add(Mark(kind: .paw, pos: p, angle: heading, birth: tick)) lastPawPos = pos pawSide *= -1 - changed = true } else if lastPawPos == nil { lastPawPos = pos // seed on the first running frame; the first step drops no print } @@ -75,23 +87,33 @@ final class LitterField { let jitter = CGFloat((tick * 37) % 18 - 9) let tilt = CGFloat((tick * 53) % 50 - 25) * .pi / 180 let p = NSPoint(x: pos.x + jitter, y: pos.y + jitter * 0.5) - add(Mark(kind: .scratch, pos: p, angle: tilt, birth: tick)) + dirty += add(Mark(kind: .scratch, pos: p, angle: tilt, birth: tick)) lastScratch = tick - changed = true } - return changed + return dirty } - private func add(_ mark: Mark) { + /// Append a mark and return the rects to invalidate: the new mark's, plus any + /// evicted when the cap is exceeded (so their pixels get cleared too). + private func add(_ mark: Mark) -> [NSRect] { marks.append(mark) + var dirty = [rect(for: mark)] if marks.count > maxMarks { - marks.removeFirst(marks.count - maxMarks) + let overflow = marks.count - maxMarks + dirty += marks.prefix(overflow).map { rect(for: $0) } + marks.removeFirst(overflow) } + return dirty } - var isEmpty: Bool { - marks.isEmpty + /// Global rects of marks currently in their fade window; the caller invalidates + /// these on a throttle so they dim smoothly without full-screen repaints. + func fadingRects() -> [NSRect] { + marks.compactMap { mark in + let fadeStart = Int(Double(life(of: mark.kind)) * 0.75) + return tick - mark.birth > fadeStart ? rect(for: mark) : nil + } } func clear() { @@ -120,13 +142,26 @@ final class LitterView: NSView { false } - override func draw(_: NSRect) { + /// Invalidate a global-coordinate rect, mapped into this view's local space, so + /// only the changed patch repaints instead of the whole overlay. + func invalidateGlobal(_ globalRect: NSRect) { + setNeedsDisplay(NSRect(x: globalRect.minX - originOffset.x, + y: globalRect.minY - originOffset.y, + width: globalRect.width, height: globalRect.height)) + } + + override func draw(_ dirtyRect: NSRect) { + // Clear only the invalidated patch (not the full multi-screen bounds), then + // repaint just the marks that overlap it. AppKit clips drawing to dirtyRect. NSColor.clear.setFill() - bounds.fill(using: .copy) + dirtyRect.fill(using: .copy) guard let field, let ctx = NSGraphicsContext.current?.cgContext else { return } + let r = LitterField.markRadius for mark in field.marks { - let a = field.alpha(of: mark) let p = NSPoint(x: mark.pos.x - originOffset.x, y: mark.pos.y - originOffset.y) + let box = NSRect(x: p.x - r, y: p.y - r, width: r * 2, height: r * 2) + guard box.intersects(dirtyRect) else { continue } + let a = field.alpha(of: mark) switch mark.kind { case .paw: Self.drawPaw(ctx, at: p, angle: mark.angle, alpha: a) case .scratch: Self.drawScratch(ctx, at: p, angle: mark.angle, alpha: a) @@ -134,6 +169,11 @@ final class LitterView: NSView { } } + // Base colors cached once; per-mark opacity is applied with ctx.setAlpha so no + // NSColor/CGColor is allocated per mark per frame. + private static let pawColor = NSColor(calibratedWhite: 0.12, alpha: 0.5).cgColor + private static let scratchColor = NSColor(calibratedWhite: 0.1, alpha: 0.45).cgColor + /// A paw print: one big pad plus four toe beans, drawn pointing +y then rotated /// so it points along the direction of travel. private static func drawPaw(_ ctx: CGContext, at p: NSPoint, angle: CGFloat, alpha: CGFloat) { @@ -141,7 +181,8 @@ final class LitterView: NSView { defer { ctx.restoreGState() } ctx.translateBy(x: p.x, y: p.y) ctx.rotate(by: angle - .pi / 2) // shape points +y; align +y with travel - ctx.setFillColor(NSColor(calibratedWhite: 0.12, alpha: 0.5 * alpha).cgColor) + ctx.setAlpha(alpha) // scales the cached color's alpha (0.5) → 0.5 * alpha + ctx.setFillColor(pawColor) let s: CGFloat = 1.2 ctx.fillEllipse(in: CGRect(x: -5 * s, y: -6 * s, width: 10 * s, height: 8 * s)) let toes = [CGPoint(x: -5, y: 3), CGPoint(x: -2, y: 6.5), CGPoint(x: 2, y: 6.5), CGPoint(x: 5, y: 3)] @@ -157,7 +198,8 @@ final class LitterView: NSView { defer { ctx.restoreGState() } ctx.translateBy(x: p.x, y: p.y) ctx.rotate(by: angle) - ctx.setStrokeColor(NSColor(calibratedWhite: 0.1, alpha: 0.45 * alpha).cgColor) + ctx.setAlpha(alpha) // scales the cached color's alpha (0.45) → 0.45 * alpha + ctx.setStrokeColor(scratchColor) ctx.setLineWidth(1.6) ctx.setLineCap(.round) let len: CGFloat = 16 diff --git a/Neco/NecoApp.swift b/Neco/NecoApp.swift index 2e58304..11bdb3f 100644 --- a/Neco/NecoApp.swift +++ b/Neco/NecoApp.swift @@ -186,11 +186,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { view.needsDisplay = true updateStatusImage() - // Redraw the mess when a mark appears or vanishes; otherwise refresh at ~10fps - // so fading marks dim smoothly without a full-screen repaint every frame. - let changed = litter.update(neko: neko) - if changed || (!litter.isEmpty && tick % 6 == 0) { - litterView.needsDisplay = true + // Invalidate only the patches around marks that appeared or vanished; refresh + // fading marks at ~10fps. Cost scales with changed pixels, not screen area. + for rect in litter.update(neko: neko) { + litterView.invalidateGlobal(rect) + } + if tick % 6 == 0 { + for rect in litter.fadingRects() { + litterView.invalidateGlobal(rect) + } } } From 1436285e257e7eaab8551af8dcbfe5df9fea6fe9 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Sun, 12 Jul 2026 15:39:25 +0900 Subject: [PATCH 2/3] Clear per-invalidated-rect, not the union bounding box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the incremental repaint: draw() cleared the whole dirtyRect, which AppKit hands as the union bounding box of all invalidated rects. When marks are scattered — a long trail, or a whole tail fading at once via fadingRects() — that union spans most of the screen even though the real changed area is tiny, degrading back toward the old full-screen cost. Use getRectsBeingDrawn(_:count:) to clear and hit-test each disjoint sub-rect AppKit actually invalidated, so cost tracks changed pixels rather than how far apart the changes are. Also document that LitterView depends on being non-layer-backed: the incremental model trusts pixels outside the invalidated rects to survive, which a wantsLayer=true view would not guarantee. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Pp772scLq2mo9cDaybWrLu --- Neco/Litter.swift | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/Neco/Litter.swift b/Neco/Litter.swift index 1a5817e..0cc79ad 100644 --- a/Neco/Litter.swift +++ b/Neco/Litter.swift @@ -133,6 +133,12 @@ final class LitterField { /// Draws every current mark onto the full-screen overlay panel. `originOffset` is the /// panel's origin in global coordinates, so a global mark maps to a local point by /// subtracting it. +/// +/// This view relies on incremental drawing: `draw(_:)` clears and repaints only the +/// invalidated rects and trusts that pixels outside them survive from earlier passes. +/// That holds for a non-layer-backed view (the default). Do not set `wantsLayer = true` +/// here without switching to a redraw model that repaints the whole dirty region, or +/// marks outside the invalidated rects would silently vanish. @MainActor final class LitterView: NSView { var field: LitterField? @@ -151,16 +157,23 @@ final class LitterView: NSView { } override func draw(_ dirtyRect: NSRect) { - // Clear only the invalidated patch (not the full multi-screen bounds), then - // repaint just the marks that overlap it. AppKit clips drawing to dirtyRect. + // Clear and repaint only the disjoint sub-rects AppKit actually invalidated, + // not their union bounding box. When marks are scattered (a long trail, or a + // whole tail fading at once), the union can span most of the screen while the + // real changed area is tiny — using getRectsBeingDrawn keeps cost proportional + // to changed pixels, not to how far apart the changes are. + let rects = rectsBeingDrawn(fallback: dirtyRect) NSColor.clear.setFill() - dirtyRect.fill(using: .copy) + for r in rects { + r.fill(using: .copy) + } + guard let field, let ctx = NSGraphicsContext.current?.cgContext else { return } - let r = LitterField.markRadius + let radius = LitterField.markRadius for mark in field.marks { let p = NSPoint(x: mark.pos.x - originOffset.x, y: mark.pos.y - originOffset.y) - let box = NSRect(x: p.x - r, y: p.y - r, width: r * 2, height: r * 2) - guard box.intersects(dirtyRect) else { continue } + let box = NSRect(x: p.x - radius, y: p.y - radius, width: radius * 2, height: radius * 2) + guard rects.contains(where: { $0.intersects(box) }) else { continue } let a = field.alpha(of: mark) switch mark.kind { case .paw: Self.drawPaw(ctx, at: p, angle: mark.angle, alpha: a) @@ -169,6 +182,16 @@ final class LitterView: NSView { } } + /// The individual rects AppKit is redrawing this pass (falling back to the union + /// dirtyRect if none are reported), so clears and hit-tests stay per-patch. + private func rectsBeingDrawn(fallback: NSRect) -> [NSRect] { + var ptr: UnsafePointer? + var count = 0 + getRectsBeingDrawn(&ptr, count: &count) + guard let ptr, count > 0 else { return [fallback] } + return (0 ..< count).map { ptr[$0] } + } + // Base colors cached once; per-mark opacity is applied with ctx.setAlpha so no // NSColor/CGColor is allocated per mark per frame. private static let pawColor = NSColor(calibratedWhite: 0.12, alpha: 0.5).cgColor From 1bdba1b95c010da07dc1583f60e30d890eced058 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Sun, 12 Jul 2026 15:45:32 +0900 Subject: [PATCH 3/3] Revert getRectsBeingDrawn draw path back to single dirtyRect The per-sub-rect approach added overhead (per-mark rects.contains scan, extra region bookkeeping) that raised load in practice rather than lowering it, so drop it and keep the simpler #3 model: clear the single dirtyRect AppKit hands us and repaint marks intersecting it. The non-layer-backed dependency note on LitterView still applies and is kept. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Pp772scLq2mo9cDaybWrLu --- Neco/Litter.swift | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/Neco/Litter.swift b/Neco/Litter.swift index 0cc79ad..bb4683c 100644 --- a/Neco/Litter.swift +++ b/Neco/Litter.swift @@ -157,23 +157,16 @@ final class LitterView: NSView { } override func draw(_ dirtyRect: NSRect) { - // Clear and repaint only the disjoint sub-rects AppKit actually invalidated, - // not their union bounding box. When marks are scattered (a long trail, or a - // whole tail fading at once), the union can span most of the screen while the - // real changed area is tiny — using getRectsBeingDrawn keeps cost proportional - // to changed pixels, not to how far apart the changes are. - let rects = rectsBeingDrawn(fallback: dirtyRect) + // Clear only the invalidated patch (not the full multi-screen bounds), then + // repaint just the marks that overlap it. AppKit clips drawing to dirtyRect. NSColor.clear.setFill() - for r in rects { - r.fill(using: .copy) - } - + dirtyRect.fill(using: .copy) guard let field, let ctx = NSGraphicsContext.current?.cgContext else { return } let radius = LitterField.markRadius for mark in field.marks { let p = NSPoint(x: mark.pos.x - originOffset.x, y: mark.pos.y - originOffset.y) let box = NSRect(x: p.x - radius, y: p.y - radius, width: radius * 2, height: radius * 2) - guard rects.contains(where: { $0.intersects(box) }) else { continue } + guard box.intersects(dirtyRect) else { continue } let a = field.alpha(of: mark) switch mark.kind { case .paw: Self.drawPaw(ctx, at: p, angle: mark.angle, alpha: a) @@ -182,16 +175,6 @@ final class LitterView: NSView { } } - /// The individual rects AppKit is redrawing this pass (falling back to the union - /// dirtyRect if none are reported), so clears and hit-tests stay per-patch. - private func rectsBeingDrawn(fallback: NSRect) -> [NSRect] { - var ptr: UnsafePointer? - var count = 0 - getRectsBeingDrawn(&ptr, count: &count) - guard let ptr, count > 0 else { return [fallback] } - return (0 ..< count).map { ptr[$0] } - } - // Base colors cached once; per-mark opacity is applied with ctx.setAlpha so no // NSColor/CGColor is allocated per mark per frame. private static let pawColor = NSColor(calibratedWhite: 0.12, alpha: 0.5).cgColor