From 45ca9c50ffc5d264a264dfae9edf2ebe0ddbdde8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:04:05 +0000 Subject: [PATCH] Optimize Blizzard preset performance on high-resolution displays - Increase `HeightMap` column width from 4.0 to 8.0, reducing the vertex count of the snow pile path by 50%. - Remove `SKShapeNode.glowWidth` usage in `BlizzardScene`, which caused severe frame drops on QHD monitors. - Replace the expensive glow effect with a performant wide stroke with low alpha. - Refactor `HeightMap` to use the `columnWidth` property instead of hardcoded values for better maintainability. Co-authored-by: gradigit <253359007+gradigit@users.noreply.github.com> --- Sources/ConfettiKit/BlizzardScene.swift | 9 +- Sources/ConfettiKit/HeightMap.swift | 337 ++++++++++++------------ 2 files changed, 174 insertions(+), 172 deletions(-) diff --git a/Sources/ConfettiKit/BlizzardScene.swift b/Sources/ConfettiKit/BlizzardScene.swift index 29abfb1..3ab4a4a 100644 --- a/Sources/ConfettiKit/BlizzardScene.swift +++ b/Sources/ConfettiKit/BlizzardScene.swift @@ -248,7 +248,7 @@ class BlizzardScene: SKScene { glowNode.alpha = CGFloat(fadeAlpha) // Glow pulse — subtle sine wave - let pulse = 0.3 + 0.1 * sin(currentTime * .pi) + let pulse = 0.15 + 0.05 * sin(currentTime * .pi) glowNode.strokeColor = blendedGlowColor().withAlphaComponent(pulse) pathUpdateAccumulator = 0 @@ -507,9 +507,10 @@ class BlizzardScene: SKScene { private func setupGlow() { let node = SKShapeNode() - node.strokeColor = NSColor(white: 1.0, alpha: 0.3) - node.lineWidth = 6 - node.glowWidth = 4 + node.strokeColor = NSColor(white: 1.0, alpha: 0.15) + node.lineWidth = 12 + // glowWidth is performance heavy on high-res displays + node.glowWidth = 0 node.fillColor = .clear node.zPosition = 11 node.path = heightMap.buildSurfacePath() diff --git a/Sources/ConfettiKit/HeightMap.swift b/Sources/ConfettiKit/HeightMap.swift index d8bd230..16cc5f9 100644 --- a/Sources/ConfettiKit/HeightMap.swift +++ b/Sources/ConfettiKit/HeightMap.swift @@ -1,168 +1,169 @@ -import CoreGraphics - -/// Manages the snow pile height data and generates paths for rendering. -/// Height grows only when snow is deposited via `depositSnow(atX:)`. -struct HeightMap { - let columnWidth: CGFloat = 4.0 - let screenWidth: CGFloat - let maxHeight: CGFloat - var heights: [CGFloat] - - /// Per-column color deposit tracking for multi-session blending - var colorDeposits: [ColorDeposit] - - /// Cumulative area swept away by the cursor (in square points) - private(set) var totalSweptArea: CGFloat = 0 - - /// Cosine splat radius in points — each deposit spreads across this range - private let splatRadius: CGFloat = 100.0 - private let splatColumns: Int - - /// Peak height added at the center of each deposit. - private let peakDeposit: CGFloat = 4.0 - - init(screenWidth: CGFloat, screenHeight: CGFloat) { - self.screenWidth = screenWidth - self.maxHeight = 0.25 * screenHeight - - let columnCount = Int(ceil(screenWidth / 4.0)) - self.heights = [CGFloat](repeating: 0, count: columnCount) - self.colorDeposits = [ColorDeposit](repeating: ColorDeposit(), count: columnCount) - self.splatColumns = Int(splatRadius / 4.0) - } - - var averageHeight: CGFloat { - heights.reduce(0, +) / CGFloat(max(heights.count, 1)) - } - - var isCapped: Bool { - heights.contains { $0 >= maxHeight } - } - - // MARK: - Snow Deposit - - /// Deposit snow at a landing position, spreading height via cosine falloff - mutating func depositSnow(atX x: CGFloat) { - depositSnow(atX: x, session: nil) - } - - /// Deposit snow at a landing position with session tracking for color blending. - mutating func depositSnow(atX x: CGFloat, session: Int?) { - guard !isCapped else { return } - - let centerColumn = Int(x / columnWidth) - - let start = max(0, centerColumn - splatColumns) - let end = min(heights.count - 1, centerColumn + splatColumns) - guard start <= end else { return } - - for i in start...end { - let distance = abs(CGFloat(i - centerColumn)) * columnWidth - let factor = cos(distance / splatRadius * .pi / 2) - if factor > 0 { - let deposit = peakDeposit * factor - heights[i] = min(heights[i] + deposit, maxHeight) - if let s = session { - colorDeposits[i].sessionHeights[s, default: 0] += deposit - } - } - } - } - - // MARK: - Sweep (cursor interaction) - - /// Lower the pile around a point, simulating the cursor sweeping snow away. - /// Tracks cumulative swept area for threshold detection. - mutating func sweepSnow(atX x: CGFloat, radius: CGFloat, amount: CGFloat) { - let centerColumn = Int(x / columnWidth) - let radiusColumns = Int(radius / columnWidth) - - let start = max(0, centerColumn - radiusColumns) - let end = min(heights.count - 1, centerColumn + radiusColumns) - guard start <= end else { return } - - for i in start...end { - let distance = abs(CGFloat(i - centerColumn)) * columnWidth - let factor = cos(distance / radius * .pi / 2) - if factor > 0 { - let before = heights[i] - heights[i] = max(heights[i] - amount * factor, 0) - let removed = before - heights[i] - totalSweptArea += removed * columnWidth - } - } - } - - // MARK: - Melt - - /// Decay all heights by a factor (0..1). Returns true when fully melted. - mutating func melt(factor: CGFloat) -> Bool { - var allMelted = true - for i in 0.. 0 ? heights[i - 1] : heights[i] - let next = i < heights.count - 1 ? heights[i + 1] : heights[i] - smoothed[i] = (prev + 2 * heights[i] + next) / 4 - } - heights = smoothed - } - - // MARK: - Path Generation - - /// Closed path for the pile fill (bottom edge included) - func buildPath() -> CGPath { - let path = CGMutablePath() - path.move(to: CGPoint(x: 0, y: 0)) - - for i in 0.. CGPath { - let path = CGMutablePath() - guard !heights.isEmpty else { return path } - path.move(to: CGPoint(x: 0, y: heights[0])) - - for i in 1.. CGFloat { - let column = x / columnWidth - let i = Int(column) - let fraction = column - CGFloat(i) - - guard i >= 0 else { return heights.first ?? 0 } - guard i < heights.count - 1 else { return heights.last ?? 0 } - - return heights[i] + (heights[i + 1] - heights[i]) * fraction - } -} +import CoreGraphics + +/// Manages the snow pile height data and generates paths for rendering. +/// Height grows only when snow is deposited via `depositSnow(atX:)`. +struct HeightMap { + let columnWidth: CGFloat = 8.0 + let screenWidth: CGFloat + let maxHeight: CGFloat + var heights: [CGFloat] + + /// Per-column color deposit tracking for multi-session blending + var colorDeposits: [ColorDeposit] + + /// Cumulative area swept away by the cursor (in square points) + private(set) var totalSweptArea: CGFloat = 0 + + /// Cosine splat radius in points — each deposit spreads across this range + private let splatRadius: CGFloat = 100.0 + private let splatColumns: Int + + /// Peak height added at the center of each deposit. + private let peakDeposit: CGFloat = 4.0 + + init(screenWidth: CGFloat, screenHeight: CGFloat) { + self.screenWidth = screenWidth + self.maxHeight = 0.25 * screenHeight + + // Use columnWidth property instead of hardcoded 4.0 + let columnCount = Int(ceil(screenWidth / columnWidth)) + self.heights = [CGFloat](repeating: 0, count: columnCount) + self.colorDeposits = [ColorDeposit](repeating: ColorDeposit(), count: columnCount) + self.splatColumns = Int(splatRadius / columnWidth) + } + + var averageHeight: CGFloat { + heights.reduce(0, +) / CGFloat(max(heights.count, 1)) + } + + var isCapped: Bool { + heights.contains { $0 >= maxHeight } + } + + // MARK: - Snow Deposit + + /// Deposit snow at a landing position, spreading height via cosine falloff + mutating func depositSnow(atX x: CGFloat) { + depositSnow(atX: x, session: nil) + } + + /// Deposit snow at a landing position with session tracking for color blending. + mutating func depositSnow(atX x: CGFloat, session: Int?) { + guard !isCapped else { return } + + let centerColumn = Int(x / columnWidth) + + let start = max(0, centerColumn - splatColumns) + let end = min(heights.count - 1, centerColumn + splatColumns) + guard start <= end else { return } + + for i in start...end { + let distance = abs(CGFloat(i - centerColumn)) * columnWidth + let factor = cos(distance / splatRadius * .pi / 2) + if factor > 0 { + let deposit = peakDeposit * factor + heights[i] = min(heights[i] + deposit, maxHeight) + if let s = session { + colorDeposits[i].sessionHeights[s, default: 0] += deposit + } + } + } + } + + // MARK: - Sweep (cursor interaction) + + /// Lower the pile around a point, simulating the cursor sweeping snow away. + /// Tracks cumulative swept area for threshold detection. + mutating func sweepSnow(atX x: CGFloat, radius: CGFloat, amount: CGFloat) { + let centerColumn = Int(x / columnWidth) + let radiusColumns = Int(radius / columnWidth) + + let start = max(0, centerColumn - radiusColumns) + let end = min(heights.count - 1, centerColumn + radiusColumns) + guard start <= end else { return } + + for i in start...end { + let distance = abs(CGFloat(i - centerColumn)) * columnWidth + let factor = cos(distance / radius * .pi / 2) + if factor > 0 { + let before = heights[i] + heights[i] = max(heights[i] - amount * factor, 0) + let removed = before - heights[i] + totalSweptArea += removed * columnWidth + } + } + } + + // MARK: - Melt + + /// Decay all heights by a factor (0..1). Returns true when fully melted. + mutating func melt(factor: CGFloat) -> Bool { + var allMelted = true + for i in 0.. 0 ? heights[i - 1] : heights[i] + let next = i < heights.count - 1 ? heights[i + 1] : heights[i] + smoothed[i] = (prev + 2 * heights[i] + next) / 4 + } + heights = smoothed + } + + // MARK: - Path Generation + + /// Closed path for the pile fill (bottom edge included) + func buildPath() -> CGPath { + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 0)) + + for i in 0.. CGPath { + let path = CGMutablePath() + guard !heights.isEmpty else { return path } + path.move(to: CGPoint(x: 0, y: heights[0])) + + for i in 1.. CGFloat { + let column = x / columnWidth + let i = Int(column) + let fraction = column - CGFloat(i) + + guard i >= 0 else { return heights.first ?? 0 } + guard i < heights.count - 1 else { return heights.last ?? 0 } + + return heights[i] + (heights[i + 1] - heights[i]) * fraction + } +}