From 0020f9b831dd0d2edab4e4b397e27b13143ebb80 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 12:54:14 +0000 Subject: [PATCH 01/19] Add Psychedelic and Liquid background layers with Metal shaders Introduce two new GPU-rendered background types using MetalKit: - PsyLayer: kaleidoscopic mandala with domain warping, plasma, and HSV color cycling - LiquidLayer: organic flowing effect using fruit-shaped SDF contours Both layers use signed distance fields derived from the Fruit/Leaf bezier paths so concentric rings and spirals follow the apple silhouette. --- CHANGELOG.md | 35 ++ Fruit.xcodeproj/project.pbxproj | 8 +- FruitFarm/Backgrounds/FruitTypes.swift | 2 + FruitFarm/Backgrounds/Types/LiquidLayer.swift | 338 ++++++++++++++++++ FruitFarm/Backgrounds/Types/PsyLayer.swift | 338 ++++++++++++++++++ FruitFarm/FruitView.swift | 4 + FruitScreensaver/FruitScreensaver.swift | 4 +- FruitScreensaver/Info.plist | 2 +- .../PreferencesViewController.swift | 9 +- homebrew/fruit-screensaver.rb | 2 +- 10 files changed, 732 insertions(+), 10 deletions(-) create mode 100644 FruitFarm/Backgrounds/Types/LiquidLayer.swift create mode 100644 FruitFarm/Backgrounds/Types/PsyLayer.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f05c2..1161512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## [1.3.4] + +### Bug Fixes + + - Fixed 13 bugs found during code review: + - Fixed layer leak in `FruitView.setupLayersOrUpdate()` where stale `BackgroundLayer`s accumulated on every mode change. + - Fixed strong `self` capture in `randomlyChangeFruitType()` animation closure that kept torn-down views alive. + - Fixed display link dangling pointer by introducing a `DisplayLinkContext` wrapper with a weak reference. + - Restored Metal `init(layer:)` implementations so presentation layers render correctly. + - Added missing `update(deltaTime:)` to `BackgroundLayer`. + - Replaced double force-unwrap in `PreferencesRepositoryImpl` with `guard let` and a descriptive `fatalError`. + - Made `preferencesRepository` non-optional in `PreferencesViewController`. + - Fixed `MetalSolidLayer` discarding excess elapsed time on color transitions. + - Recreate display link when window moves to a different screen. + - Replaced force-unwraps in `setupLayersOrUpdate()` with `guard let`. + - Replaced deprecated `lockFocus`/`unlockFocus` with `NSImage(size:flipped:drawingHandler:)`. + - Removed `fatalError` default implementations from `Background` protocol extension in favor of compile-time enforcement. + - Replaced `NSApplicationMain` with `app.run()` in `FruitShop/main.swift`. + - Fixed all SwiftLint warnings. + - Added Xcode build verification to CI workflow. + +## [1.3.3] + +### Bug Fixes + + - Fixed macOS Sonoma screensaver lifecycle bugs: + - Detect real `isPreview` state from frame size (FB7486243). + - Replace immediate `terminate` with delayed `exit(0)` to avoid black-screen race. + - Add lame-duck pattern to handle zombie multi-instance stacking (FB19204084). + - Refresh preferences on each `startAnimation` via `synchronize()`. + - Override `startAnimation`/`stopAnimation` for proper lifecycle management. + - Replace `fatalError` in Metal setup with graceful nil-guarded fallback. + - Fixed mask and background layer `contentsScale` for external monitors. The fruit/leaf `CAShapeLayer` masks and `BackgroundLayer` defaulted to 1.0 regardless of display, causing jagged logo edges on HiDPI screens. + - Fixed copyright notice to reflect MIT license. + ## [1.3.2] ### Bug Fixes diff --git a/Fruit.xcodeproj/project.pbxproj b/Fruit.xcodeproj/project.pbxproj index 24a6354..9516faa 100644 --- a/Fruit.xcodeproj/project.pbxproj +++ b/Fruit.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ "@loader_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 11.5; - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; PRODUCT_BUNDLE_IDENTIFIER = io.corkscrews.Fruit; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = ""; @@ -496,7 +496,7 @@ "@loader_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 11.5; - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; PRODUCT_BUNDLE_IDENTIFIER = io.corkscrews.Fruit; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = ""; @@ -532,7 +532,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 11.5; - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; PRODUCT_BUNDLE_IDENTIFIER = io.corkscrews.FruitShop; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -570,7 +570,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 11.5; - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; PRODUCT_BUNDLE_IDENTIFIER = io.corkscrews.FruitShop; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/FruitFarm/Backgrounds/FruitTypes.swift b/FruitFarm/Backgrounds/FruitTypes.swift index 40bc7e8..58a85fb 100644 --- a/FruitFarm/Backgrounds/FruitTypes.swift +++ b/FruitFarm/Backgrounds/FruitTypes.swift @@ -10,4 +10,6 @@ public enum FruitType: String, CaseIterable { case solid case linearGradient case circularGradient + case psychedelic + case liquid } diff --git a/FruitFarm/Backgrounds/Types/LiquidLayer.swift b/FruitFarm/Backgrounds/Types/LiquidLayer.swift new file mode 100644 index 0000000..791eda4 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/LiquidLayer.swift @@ -0,0 +1,338 @@ +// swiftlint:disable file_length +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalLiquidShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_liquid( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct LiquidUniforms { + float2 resolution; + float time; + float color_phase; +}; + +float3 liquid_hsv2rgb(float3 c) { + float3 p = abs(fract(float3(c.x) + float3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0); + return c.z * mix(float3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +float2 liquid_domain_warp(float2 p, float t, float seed) { + return float2( + sin(p.y * 3.7 + t * 0.73 + seed) + cos(p.x * 2.3 - t * 0.51), + cos(p.x * 3.3 - t * 0.67 + seed) + sin(p.y * 2.7 + t * 0.43) + ); +} + +float liquid_plasma(float2 p, float t) { + float v = 0.0; + v += sin(p.x * 10.0 + t); + v += sin((p.y * 10.0 + t) * 0.5); + v += sin((p.x * 10.0 + p.y * 10.0 + t) * 0.33); + float cx = p.x + 0.5 * sin(t * 0.33); + float cy = p.y + 0.5 * cos(t * 0.5); + v += sin(sqrt(cx * cx + cy * cy + 1.0) * 10.0 + t); + return v * 0.25; +} + +// SDF of the fruit body derived from Fruit.swift bezier path. +// Original path bbox: x [35.5, 112.5], y [49.36, 119.61] (77 x 70). +// After the pi-rotation + scale + translate applied by FruitView, +// the bite lands on the right side and the stem at the top. +float liquid_sd_fruit(float2 p) { + // Main body: ellipse matching the 77:70 aspect ratio + float body = length(p * float2(0.91, 1.0)) - 0.5; + + // Bite cutout on the right, centered slightly above midline + float bite = length(p - float2(0.52, -0.04)) - 0.22; + body = max(body, -bite); + + // Heart-shaped indent at the top where the stem sits + float dip = smoothstep(0.12, 0.0, abs(p.x)) * smoothstep(0.0, -0.52, p.y); + body += dip * 0.04; + + return body; +} + +// SDF of the leaf derived from Leaf.swift bezier path. +// Intersection of two offset circles, tilted ~20 deg. +float liquid_sd_leaf(float2 p) { + float2 lp = p - float2(0.1, -0.56); + float ca = cos(-0.35); + float sa = sin(-0.35); + lp = float2(ca * lp.x - sa * lp.y, sa * lp.x + ca * lp.y); + float d1 = length(lp - float2(0.0, 0.04)) - 0.1; + float d2 = length(lp - float2(0.0, -0.04)) - 0.1; + return max(d1, d2); +} + +fragment float4 fragment_shader_liquid( + VertexOut in [[stage_in]], + constant LiquidUniforms &uniforms [[buffer(0)]]) { + + float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / + min(uniforms.resolution.x, uniforms.resolution.y); + float t = uniforms.time; + + // Fruit + leaf signed-distance drives all contour-based effects + float fruit = liquid_sd_fruit(uv); + float leaf = liquid_sd_leaf(uv); + float shape = min(fruit, leaf); + float origR = length(uv); + + // Multi-pass domain warping for organic flow + float2 p = uv; + float amp = 0.65; + for (int i = 0; i < 5; i++) { + p = liquid_domain_warp(p, t * 0.7 + float(i) * 1.37, float(i) * 2.19) * amp; + amp *= 0.82; + } + + // Plasma field + float pl = liquid_plasma(uv * 0.8 + p * 0.3, t * 0.9); + + // Fruit-contour concentric rings (follow the silhouette) + float fruitRings = sin(shape * 30.0 - t * 3.0) * 0.5 + 0.5; + + // Interference from warped coordinates + float interference = 0.0; + interference += sin(p.x * 9.0 + t * 1.3) * cos(p.y * 7.0 - t * 0.8); + interference += sin(length(p) * 14.0 - t * 2.2) * 0.6; + interference += cos(atan2(p.y, p.x) * 6.0 + t * 0.7 + length(p) * 10.0) * 0.4; + interference *= 0.25; + + // Spiral that traces the fruit contour + float angle = atan2(uv.y, uv.x); + float spiral = sin(shape * 25.0 + angle * 4.0 - t * 2.5); + float spiralMask = smoothstep(-0.5, 0.5, shape) * 0.1; + + // Hue: warped angle + shape distance + plasma + float hue = fract( + atan2(p.y, p.x) / (2.0 * M_PI_F) + 0.5 + + t * 0.035 + + pl * 0.18 + + shape * 0.2 + + spiral * spiralMask + + uniforms.color_phase + ); + + // Saturation: vivid, modulated by shape distance + float sat = 0.8 + 0.2 * sin(shape * 8.0 + t * 1.2 + pl * 3.0); + + // Value: layered from fruit-contour rings + plasma + interference + float val = 0.45 + + 0.3 * fruitRings + + 0.1 * pl + + interference * 0.12; + + // Pulsing glow along the fruit edge (zero-crossing of the SDF) + float edgeGlow = exp(-shape * shape * 50.0); + val += edgeGlow * 0.4 * (0.5 + 0.5 * sin(t * 2.0)); + + // Subtle center pulse + float centerGlow = exp(-origR * origR * 3.0); + val = mix(val, 1.0, centerGlow * 0.2 * (0.5 + 0.5 * sin(t * 1.3))); + + sat = clamp(sat, 0.0, 1.0); + val = clamp(val, 0.05, 1.0); + + float3 color = liquid_hsv2rgb(float3(hue, sat, val)); + + // Soft vignette + float vignette = 1.0 - smoothstep(0.8, 2.0, origR); + color *= mix(0.3, 1.0, vignette); + + return float4(color, 1.0); +} +""" + +private struct MetalLiquidFragmentUniforms { + var resolution: SIMD2 + var time: Float + // swiftlint:disable:next identifier_name + var color_phase: Float +} + +// swiftlint:disable:next type_body_length +final class LiquidLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var colorPhase: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? LiquidLayer else { return } + + let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() + guard let device = device else { return } + self.metalDevice = device + self.device = device + + self.pixelFormat = other.pixelFormat != .invalid ? other.pixelFormat : .bgra8Unorm + self.framebufferOnly = other.framebufferOnly + self.isOpaque = other.isOpaque + + self.totalElapsedTime = other.totalElapsedTime + self.colorPhase = other.colorPhase + + self.commandQueue = device.makeCommandQueue() + setupPipeline() + createVertexBuffers() + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalLiquidShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_liquid"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_liquid") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + self.frame = frame + setNeedsDisplay() + } + + func config(fruit: Fruit) { + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + colorPhase = (totalElapsedTime * 0.02).truncatingRemainder(dividingBy: 1.0) + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + var uniforms = MetalLiquidFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + time: Float(totalElapsedTime), + color_phase: Float(colorPhase) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/Backgrounds/Types/PsyLayer.swift b/FruitFarm/Backgrounds/Types/PsyLayer.swift new file mode 100644 index 0000000..428232d --- /dev/null +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -0,0 +1,338 @@ +// swiftlint:disable file_length +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalPsyShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_psy( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct PsyUniforms { + float2 resolution; + float time; + float color_phase; +}; + +float3 hsv2rgb(float3 c) { + float3 p = abs(fract(float3(c.x) + float3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0); + return c.z * mix(float3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +float2 domain_warp(float2 p, float t, float seed) { + return float2( + sin(p.y * 3.7 + t * 0.73 + seed) + cos(p.x * 2.3 - t * 0.51), + cos(p.x * 3.3 - t * 0.67 + seed) + sin(p.y * 2.7 + t * 0.43) + ); +} + +float plasma(float2 p, float t) { + float v = 0.0; + v += sin(p.x * 10.0 + t); + v += sin((p.y * 10.0 + t) * 0.5); + v += sin((p.x * 10.0 + p.y * 10.0 + t) * 0.33); + float cx = p.x + 0.5 * sin(t * 0.33); + float cy = p.y + 0.5 * cos(t * 0.5); + v += sin(sqrt(cx * cx + cy * cy + 1.0) * 10.0 + t); + return v * 0.25; +} + +// SDF of the apple body derived from Fruit.swift bezier path. +// Original path bbox: x [35.5, 112.5], y [49.36, 119.61] (77 x 70). +// After the pi-rotation + scale + translate applied by FruitView, +// the bite lands on the right side and the stem at the top. +float sd_fruit(float2 p) { + // Main body: ellipse matching the 77:70 aspect ratio + float body = length(p * float2(0.91, 1.0)) - 0.5; + + // Bite cutout on the right, centered slightly above midline + float bite = length(p - float2(0.52, -0.04)) - 0.22; + body = max(body, -bite); + + // Heart-shaped indent at the top where the stem sits + float dip = smoothstep(0.12, 0.0, abs(p.x)) * smoothstep(0.0, -0.52, p.y); + body += dip * 0.04; + + return body; +} + +// SDF of the leaf derived from Leaf.swift bezier path. +// Intersection of two offset circles, tilted ~20 deg. +float sd_leaf(float2 p) { + float2 lp = p - float2(0.1, -0.56); + float ca = cos(-0.35); + float sa = sin(-0.35); + lp = float2(ca * lp.x - sa * lp.y, sa * lp.x + ca * lp.y); + float d1 = length(lp - float2(0.0, 0.04)) - 0.1; + float d2 = length(lp - float2(0.0, -0.04)) - 0.1; + return max(d1, d2); +} + +fragment float4 fragment_shader_psy( + VertexOut in [[stage_in]], + constant PsyUniforms &uniforms [[buffer(0)]]) { + + float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / + min(uniforms.resolution.x, uniforms.resolution.y); + float t = uniforms.time; + + // Apple + leaf signed-distance drives all contour-based effects + float apple = sd_fruit(uv); + float leaf = sd_leaf(uv); + float shape = min(apple, leaf); + float origR = length(uv); + + // Multi-pass domain warping for organic flow + float2 p = uv; + float amp = 0.65; + for (int i = 0; i < 5; i++) { + p = domain_warp(p, t * 0.7 + float(i) * 1.37, float(i) * 2.19) * amp; + amp *= 0.82; + } + + // Plasma field + float pl = plasma(uv * 0.8 + p * 0.3, t * 0.9); + + // Apple-contour concentric rings (follow the silhouette) + float appleRings = sin(shape * 30.0 - t * 3.0) * 0.5 + 0.5; + + // Interference from warped coordinates + float interference = 0.0; + interference += sin(p.x * 9.0 + t * 1.3) * cos(p.y * 7.0 - t * 0.8); + interference += sin(length(p) * 14.0 - t * 2.2) * 0.6; + interference += cos(atan2(p.y, p.x) * 6.0 + t * 0.7 + length(p) * 10.0) * 0.4; + interference *= 0.25; + + // Spiral that traces the apple contour + float angle = atan2(uv.y, uv.x); + float spiral = sin(shape * 25.0 + angle * 4.0 - t * 2.5); + float spiralMask = smoothstep(-0.5, 0.5, shape) * 0.1; + + // Hue: warped angle + shape distance + plasma + float hue = fract( + atan2(p.y, p.x) / (2.0 * M_PI_F) + 0.5 + + t * 0.035 + + pl * 0.18 + + shape * 0.2 + + spiral * spiralMask + + uniforms.color_phase + ); + + // Saturation: vivid, modulated by shape distance + float sat = 0.8 + 0.2 * sin(shape * 8.0 + t * 1.2 + pl * 3.0); + + // Value: layered from apple-contour rings + plasma + interference + float val = 0.45 + + 0.3 * appleRings + + 0.1 * pl + + interference * 0.12; + + // Pulsing glow along the apple edge (zero-crossing of the SDF) + float edgeGlow = exp(-shape * shape * 50.0); + val += edgeGlow * 0.4 * (0.5 + 0.5 * sin(t * 2.0)); + + // Subtle center pulse + float centerGlow = exp(-origR * origR * 3.0); + val = mix(val, 1.0, centerGlow * 0.2 * (0.5 + 0.5 * sin(t * 1.3))); + + sat = clamp(sat, 0.0, 1.0); + val = clamp(val, 0.05, 1.0); + + float3 color = hsv2rgb(float3(hue, sat, val)); + + // Soft vignette + float vignette = 1.0 - smoothstep(0.8, 2.0, origR); + color *= mix(0.3, 1.0, vignette); + + return float4(color, 1.0); +} +""" + +private struct MetalPsyFragmentUniforms { + var resolution: SIMD2 + var time: Float + // swiftlint:disable:next identifier_name + var color_phase: Float +} + +// swiftlint:disable:next type_body_length +final class PsyLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var colorPhase: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? PsyLayer else { return } + + let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() + guard let device = device else { return } + self.metalDevice = device + self.device = device + + self.pixelFormat = other.pixelFormat != .invalid ? other.pixelFormat : .bgra8Unorm + self.framebufferOnly = other.framebufferOnly + self.isOpaque = other.isOpaque + + self.totalElapsedTime = other.totalElapsedTime + self.colorPhase = other.colorPhase + + self.commandQueue = device.makeCommandQueue() + setupPipeline() + createVertexBuffers() + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalPsyShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_psy"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_psy") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + self.frame = frame + setNeedsDisplay() + } + + func config(fruit: Fruit) { + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + colorPhase = (totalElapsedTime * 0.02).truncatingRemainder(dividingBy: 1.0) + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + var uniforms = MetalPsyFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + time: Float(totalElapsedTime), + color_phase: Float(colorPhase) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/FruitView.swift b/FruitFarm/FruitView.swift index 1323ef8..ac54f8b 100644 --- a/FruitFarm/FruitView.swift +++ b/FruitFarm/FruitView.swift @@ -206,6 +206,10 @@ public final class FruitView: NSView { return MetalLinearGradientLayer(frame: self.frame, fruit: fruit, contentsScale: scale) case .circularGradient: return MetalCircularGradientLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .psychedelic: + return PsyLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .liquid: + return LiquidLayer(frame: self.frame, fruit: fruit, contentsScale: scale) } } diff --git a/FruitScreensaver/FruitScreensaver.swift b/FruitScreensaver/FruitScreensaver.swift index 2aa34fa..349d32b 100644 --- a/FruitScreensaver/FruitScreensaver.swift +++ b/FruitScreensaver/FruitScreensaver.swift @@ -89,7 +89,9 @@ final class FruitScreensaver: ScreenSaverView { @objc private func neuterOldInstance(_ notification: Notification) { - guard notification.object as? FruitScreensaver !== self else { return } + guard let newInstance = notification.object as? FruitScreensaver, + newInstance !== self, + newInstance.actualIsPreview == self.actualIsPreview else { return } lameDuck = true isPaused = true metalView?.isRenderingPaused = true diff --git a/FruitScreensaver/Info.plist b/FruitScreensaver/Info.plist index d8e5008..2271dac 100644 --- a/FruitScreensaver/Info.plist +++ b/FruitScreensaver/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 1.3.3 + 1.3.4 NSHumanReadableCopyright Copyright © 2026 Corkscrews. MIT License. NSPrincipalClass diff --git a/FruitScreensaver/Preferences/PreferencesViewController.swift b/FruitScreensaver/Preferences/PreferencesViewController.swift index 57fc3b4..226ccfa 100644 --- a/FruitScreensaver/Preferences/PreferencesViewController.swift +++ b/FruitScreensaver/Preferences/PreferencesViewController.swift @@ -10,9 +10,8 @@ func createPreferencesWindow(preferencesRepository: PreferencesRepository) -> NS let window = NSWindow(contentViewController: viewController) window.title = "Preferences" window.styleMask.insert(.resizable) - window.center() - window.makeKeyAndOrderFront(nil) - viewController.window = window // Store the window reference + window.isReleasedWhenClosed = false + viewController.window = window return window } @@ -392,6 +391,10 @@ final class PreferencesControlsView: NSView { return "Linear Gradient" case .circularGradient: return "Circular Gradient" + case .psychedelic: + return "Psychedelic" + case .liquid: + return "Liquid" } }) return items diff --git a/homebrew/fruit-screensaver.rb b/homebrew/fruit-screensaver.rb index ea9c683..43e313a 100644 --- a/homebrew/fruit-screensaver.rb +++ b/homebrew/fruit-screensaver.rb @@ -1,6 +1,6 @@ # This cask below is just a template of what you can find in the official Homebrew cask repository. cask "fruit-screensaver" do - version "1.3.3" + version "1.3.4" # Generate a new sha256 with `shasum -a 256 Fruit.saver.tar.gz` when deploying a new version. # sha256 "871a2973ba6230dc5142a5e56e4465e72ee1ae8e9ea4bcb13255c21124651efe" From 946cc801449990026ee1266a90eb3866c1ca1888 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 13:10:53 +0000 Subject: [PATCH 02/19] Fix star artifact in Liquid and Psychedelic Metal shaders Remove atan2-based angular patterns and SDF contour rings that created a visible star/cross at the center due to the atan2 singularity at the origin. Replace with multi-chain domain warping and plasma fields for smooth organic flow. Make PsyLayer more aggressive with higher frequencies, stronger contrast, and faster animation. --- FruitFarm/Backgrounds/Types/LiquidLayer.swift | 92 ++++-------- FruitFarm/Backgrounds/Types/PsyLayer.swift | 133 ++++++++---------- 2 files changed, 83 insertions(+), 142 deletions(-) diff --git a/FruitFarm/Backgrounds/Types/LiquidLayer.swift b/FruitFarm/Backgrounds/Types/LiquidLayer.swift index 791eda4..4a66673 100644 --- a/FruitFarm/Backgrounds/Types/LiquidLayer.swift +++ b/FruitFarm/Backgrounds/Types/LiquidLayer.swift @@ -52,37 +52,6 @@ float liquid_plasma(float2 p, float t) { return v * 0.25; } -// SDF of the fruit body derived from Fruit.swift bezier path. -// Original path bbox: x [35.5, 112.5], y [49.36, 119.61] (77 x 70). -// After the pi-rotation + scale + translate applied by FruitView, -// the bite lands on the right side and the stem at the top. -float liquid_sd_fruit(float2 p) { - // Main body: ellipse matching the 77:70 aspect ratio - float body = length(p * float2(0.91, 1.0)) - 0.5; - - // Bite cutout on the right, centered slightly above midline - float bite = length(p - float2(0.52, -0.04)) - 0.22; - body = max(body, -bite); - - // Heart-shaped indent at the top where the stem sits - float dip = smoothstep(0.12, 0.0, abs(p.x)) * smoothstep(0.0, -0.52, p.y); - body += dip * 0.04; - - return body; -} - -// SDF of the leaf derived from Leaf.swift bezier path. -// Intersection of two offset circles, tilted ~20 deg. -float liquid_sd_leaf(float2 p) { - float2 lp = p - float2(0.1, -0.56); - float ca = cos(-0.35); - float sa = sin(-0.35); - lp = float2(ca * lp.x - sa * lp.y, sa * lp.x + ca * lp.y); - float d1 = length(lp - float2(0.0, 0.04)) - 0.1; - float d2 = length(lp - float2(0.0, -0.04)) - 0.1; - return max(d1, d2); -} - fragment float4 fragment_shader_liquid( VertexOut in [[stage_in]], constant LiquidUniforms &uniforms [[buffer(0)]]) { @@ -90,14 +59,9 @@ fragment float4 fragment_shader_liquid( float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / min(uniforms.resolution.x, uniforms.resolution.y); float t = uniforms.time; - - // Fruit + leaf signed-distance drives all contour-based effects - float fruit = liquid_sd_fruit(uv); - float leaf = liquid_sd_leaf(uv); - float shape = min(fruit, leaf); float origR = length(uv); - // Multi-pass domain warping for organic flow + // Primary domain-warp chain float2 p = uv; float amp = 0.65; for (int i = 0; i < 5; i++) { @@ -105,57 +69,55 @@ fragment float4 fragment_shader_liquid( amp *= 0.82; } - // Plasma field - float pl = liquid_plasma(uv * 0.8 + p * 0.3, t * 0.9); + // Secondary warp chain for layered depth + float2 q = uv * 1.3; + amp = 0.5; + for (int i = 0; i < 4; i++) { + q = liquid_domain_warp(q, t * 0.5 + float(i) * 2.53, float(i) * 1.77 + 7.0) * amp; + amp *= 0.8; + } - // Fruit-contour concentric rings (follow the silhouette) - float fruitRings = sin(shape * 30.0 - t * 3.0) * 0.5 + 0.5; + // Two plasma layers from differently-warped positions + float pl1 = liquid_plasma(uv * 0.8 + p * 0.3, t * 0.9); + float pl2 = liquid_plasma(uv * 0.6 + q * 0.35, t * 0.65 + 2.5); - // Interference from warped coordinates + // Interference built entirely from warped coordinates (no atan2 singularity) float interference = 0.0; interference += sin(p.x * 9.0 + t * 1.3) * cos(p.y * 7.0 - t * 0.8); interference += sin(length(p) * 14.0 - t * 2.2) * 0.6; - interference += cos(atan2(p.y, p.x) * 6.0 + t * 0.7 + length(p) * 10.0) * 0.4; + interference += cos(q.x * 8.0 + q.y * 5.0 + t * 0.9) * 0.4; interference *= 0.25; - // Spiral that traces the fruit contour - float angle = atan2(uv.y, uv.x); - float spiral = sin(shape * 25.0 + angle * 4.0 - t * 2.5); - float spiralMask = smoothstep(-0.5, 0.5, shape) * 0.1; + // Flowing color bands driven by warped coords + float colorFlow = sin(p.x * 5.0 + q.y * 3.0 - t * 1.1) * 0.5 + 0.5; - // Hue: warped angle + shape distance + plasma + // Hue: smooth warp-based variation instead of angular float hue = fract( - atan2(p.y, p.x) / (2.0 * M_PI_F) + 0.5 + (p.x + p.y) * 0.12 + t * 0.035 - + pl * 0.18 - + shape * 0.2 - + spiral * spiralMask + + pl1 * 0.2 + + pl2 * 0.12 + + colorFlow * 0.15 + uniforms.color_phase ); - // Saturation: vivid, modulated by shape distance - float sat = 0.8 + 0.2 * sin(shape * 8.0 + t * 1.2 + pl * 3.0); + float sat = 0.8 + 0.2 * sin(length(p) * 6.0 + t * 1.2 + pl1 * 3.0); - // Value: layered from fruit-contour rings + plasma + interference float val = 0.45 - + 0.3 * fruitRings - + 0.1 * pl + + 0.25 * colorFlow + + 0.15 * pl1 + + 0.1 * pl2 + interference * 0.12; - // Pulsing glow along the fruit edge (zero-crossing of the SDF) - float edgeGlow = exp(-shape * shape * 50.0); - val += edgeGlow * 0.4 * (0.5 + 0.5 * sin(t * 2.0)); - - // Subtle center pulse - float centerGlow = exp(-origR * origR * 3.0); - val = mix(val, 1.0, centerGlow * 0.2 * (0.5 + 0.5 * sin(t * 1.3))); + // Soft glow where the two warp fields converge + float flowGlow = exp(-length(p - q) * length(p - q) * 2.0); + val += flowGlow * 0.15 * (0.5 + 0.5 * sin(t * 1.5)); sat = clamp(sat, 0.0, 1.0); val = clamp(val, 0.05, 1.0); float3 color = liquid_hsv2rgb(float3(hue, sat, val)); - // Soft vignette float vignette = 1.0 - smoothstep(0.8, 2.0, origR); color *= mix(0.3, 1.0, vignette); diff --git a/FruitFarm/Backgrounds/Types/PsyLayer.swift b/FruitFarm/Backgrounds/Types/PsyLayer.swift index 428232d..9b1f11c 100644 --- a/FruitFarm/Backgrounds/Types/PsyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -52,37 +52,6 @@ float plasma(float2 p, float t) { return v * 0.25; } -// SDF of the apple body derived from Fruit.swift bezier path. -// Original path bbox: x [35.5, 112.5], y [49.36, 119.61] (77 x 70). -// After the pi-rotation + scale + translate applied by FruitView, -// the bite lands on the right side and the stem at the top. -float sd_fruit(float2 p) { - // Main body: ellipse matching the 77:70 aspect ratio - float body = length(p * float2(0.91, 1.0)) - 0.5; - - // Bite cutout on the right, centered slightly above midline - float bite = length(p - float2(0.52, -0.04)) - 0.22; - body = max(body, -bite); - - // Heart-shaped indent at the top where the stem sits - float dip = smoothstep(0.12, 0.0, abs(p.x)) * smoothstep(0.0, -0.52, p.y); - body += dip * 0.04; - - return body; -} - -// SDF of the leaf derived from Leaf.swift bezier path. -// Intersection of two offset circles, tilted ~20 deg. -float sd_leaf(float2 p) { - float2 lp = p - float2(0.1, -0.56); - float ca = cos(-0.35); - float sa = sin(-0.35); - lp = float2(ca * lp.x - sa * lp.y, sa * lp.x + ca * lp.y); - float d1 = length(lp - float2(0.0, 0.04)) - 0.1; - float d2 = length(lp - float2(0.0, -0.04)) - 0.1; - return max(d1, d2); -} - fragment float4 fragment_shader_psy( VertexOut in [[stage_in]], constant PsyUniforms &uniforms [[buffer(0)]]) { @@ -90,74 +59,84 @@ fragment float4 fragment_shader_psy( float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / min(uniforms.resolution.x, uniforms.resolution.y); float t = uniforms.time; - - // Apple + leaf signed-distance drives all contour-based effects - float apple = sd_fruit(uv); - float leaf = sd_leaf(uv); - float shape = min(apple, leaf); float origR = length(uv); - // Multi-pass domain warping for organic flow + // Heavy domain warping - high amplitude, fast float2 p = uv; - float amp = 0.65; + float amp = 0.9; + for (int i = 0; i < 7; i++) { + p = domain_warp(p, t * 1.4 + float(i) * 1.13, float(i) * 1.87) * amp; + amp *= 0.78; + } + + float2 q = uv * 1.4; + amp = 0.75; for (int i = 0; i < 5; i++) { - p = domain_warp(p, t * 0.7 + float(i) * 1.37, float(i) * 2.19) * amp; + q = domain_warp(q, t * 1.0 + float(i) * 2.71, float(i) * 3.14 + 5.5) * amp; + amp *= 0.8; + } + + float2 r = uv * 0.7; + amp = 0.6; + for (int i = 0; i < 4; i++) { + r = domain_warp(r, t * 0.7 + float(i) * 3.33, float(i) * 2.22 + 9.0) * amp; amp *= 0.82; } - // Plasma field - float pl = plasma(uv * 0.8 + p * 0.3, t * 0.9); + float pl1 = plasma(uv * 0.9 + p * 0.5, t * 1.6); + float pl2 = plasma(uv * 0.6 + q * 0.4 + r * 0.2, t * 1.1 + 3.0); - // Apple-contour concentric rings (follow the silhouette) - float appleRings = sin(shape * 30.0 - t * 3.0) * 0.5 + 0.5; + // Aggressive interference - high amplitudes, high frequencies, fast + float psy = 0.0; + psy += sin(p.x * 18.0 + q.y * 12.0 + t * 3.2) * 0.35; + psy += cos(p.y * 15.0 - q.x * 13.0 - t * 2.6) * 0.35; + psy += sin(length(p) * 24.0 + length(q) * 18.0 - t * 4.5) * 0.3; + psy += cos((p.x - q.y) * 20.0 + t * 1.8) + * sin((p.y + q.x) * 17.0 - t * 2.2) * 0.25; + psy += sin(r.x * 22.0 + p.y * 10.0 + t * 3.8) * 0.2; + psy += cos(r.y * 19.0 - q.x * 14.0 + t * 2.9) * 0.2; - // Interference from warped coordinates - float interference = 0.0; - interference += sin(p.x * 9.0 + t * 1.3) * cos(p.y * 7.0 - t * 0.8); - interference += sin(length(p) * 14.0 - t * 2.2) * 0.6; - interference += cos(atan2(p.y, p.x) * 6.0 + t * 0.7 + length(p) * 10.0) * 0.4; - interference *= 0.25; + // Harsh color banding - fewer bands = chunkier steps + float bands = floor(psy * 3.0) / 3.0; + psy = mix(psy, bands, 0.5); - // Spiral that traces the apple contour - float angle = atan2(uv.y, uv.x); - float spiral = sin(shape * 25.0 + angle * 4.0 - t * 2.5); - float spiralMask = smoothstep(-0.5, 0.5, shape) * 0.1; + // Fast ripples + float ripples = sin(length(p * 1.5 + q * 0.9) * 30.0 - t * 5.5) * 0.5 + 0.5; + float ripples2 = sin(length(q * 1.3 - r * 1.1) * 25.0 + t * 4.0) * 0.5 + 0.5; - // Hue: warped angle + shape distance + plasma + // Fast hue cycling float hue = fract( - atan2(p.y, p.x) / (2.0 * M_PI_F) + 0.5 - + t * 0.035 - + pl * 0.18 - + shape * 0.2 - + spiral * spiralMask + (p.x + q.y) * 0.25 + + (r.x - r.y) * 0.15 + + t * 0.12 + + pl1 * 0.3 + + pl2 * 0.2 + + psy * 0.3 + uniforms.color_phase ); - // Saturation: vivid, modulated by shape distance - float sat = 0.8 + 0.2 * sin(shape * 8.0 + t * 1.2 + pl * 3.0); + float sat = 1.0; - // Value: layered from apple-contour rings + plasma + interference - float val = 0.45 - + 0.3 * appleRings - + 0.1 * pl - + interference * 0.12; + // High contrast value with deep darks and bright peaks + float val = 0.2 + + 0.3 * ripples + + 0.2 * ripples2 + + 0.2 * (psy * 0.5 + 0.5) + + 0.15 * pl1 + + 0.1 * pl2; - // Pulsing glow along the apple edge (zero-crossing of the SDF) - float edgeGlow = exp(-shape * shape * 50.0); - val += edgeGlow * 0.4 * (0.5 + 0.5 * sin(t * 2.0)); + // Strong strobing pulse + val *= 0.7 + 0.3 * sin(t * 1.5 + pl1 * 3.0); - // Subtle center pulse - float centerGlow = exp(-origR * origR * 3.0); - val = mix(val, 1.0, centerGlow * 0.2 * (0.5 + 0.5 * sin(t * 1.3))); + // Push contrast: darks darker, brights brighter + val = pow(clamp(val, 0.0, 1.0), 0.75); - sat = clamp(sat, 0.0, 1.0); - val = clamp(val, 0.05, 1.0); + val = clamp(val, 0.02, 1.0); float3 color = hsv2rgb(float3(hue, sat, val)); - // Soft vignette - float vignette = 1.0 - smoothstep(0.8, 2.0, origR); - color *= mix(0.3, 1.0, vignette); + float vignette = 1.0 - smoothstep(0.7, 1.8, origR); + color *= mix(0.2, 1.0, vignette); return float4(color, 1.0); } From cd206bb8134ba77e2fbd9c27e1dd313296c8c67c Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 13:50:22 +0000 Subject: [PATCH 03/19] Add PuppyLayer with animated Apple logo tunnel zoom effect Metal SDF shader renders the Apple logo silhouette using two circles with aspect correction, circular bite, and a rotated ellipse leaf. Multiple concentric copies scale down toward the center with smooth cosine-based color alternation and edge glow, creating an infinite zoom tunnel animation. --- FruitFarm/Backgrounds/FruitTypes.swift | 1 + FruitFarm/Backgrounds/Types/PsyLayer.swift | 67 +++- FruitFarm/Backgrounds/Types/PuppyLayer.swift | 310 ++++++++++++++++++ FruitFarm/FruitView.swift | 2 + .../PreferencesViewController.swift | 2 + 5 files changed, 367 insertions(+), 15 deletions(-) create mode 100644 FruitFarm/Backgrounds/Types/PuppyLayer.swift diff --git a/FruitFarm/Backgrounds/FruitTypes.swift b/FruitFarm/Backgrounds/FruitTypes.swift index 58a85fb..9e37fba 100644 --- a/FruitFarm/Backgrounds/FruitTypes.swift +++ b/FruitFarm/Backgrounds/FruitTypes.swift @@ -12,4 +12,5 @@ public enum FruitType: String, CaseIterable { case circularGradient case psychedelic case liquid + case puppy } diff --git a/FruitFarm/Backgrounds/Types/PsyLayer.swift b/FruitFarm/Backgrounds/Types/PsyLayer.swift index 9b1f11c..4e485e2 100644 --- a/FruitFarm/Backgrounds/Types/PsyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -117,26 +117,23 @@ fragment float4 fragment_shader_psy( float sat = 1.0; - // High contrast value with deep darks and bright peaks - float val = 0.2 - + 0.3 * ripples - + 0.2 * ripples2 - + 0.2 * (psy * 0.5 + 0.5) - + 0.15 * pl1 - + 0.1 * pl2; - - // Strong strobing pulse - val *= 0.7 + 0.3 * sin(t * 1.5 + pl1 * 3.0); - - // Push contrast: darks darker, brights brighter + float val = 0.35 + + 0.25 * ripples + + 0.15 * ripples2 + + 0.15 * (psy * 0.5 + 0.5) + + 0.1 * pl1 + + 0.08 * pl2; + + val *= 0.9 + 0.1 * sin(t * 1.5 + pl1 * 3.0); + val = pow(clamp(val, 0.0, 1.0), 0.75); - val = clamp(val, 0.02, 1.0); + val = clamp(val, 0.3, 1.0); float3 color = hsv2rgb(float3(hue, sat, val)); float vignette = 1.0 - smoothstep(0.7, 1.8, origR); - color *= mix(0.2, 1.0, vignette); + color *= mix(0.5, 1.0, vignette); return float4(color, 1.0); } @@ -164,6 +161,14 @@ final class PsyLayer: CAMetalLayer, Background { private var lastUpdateTime: CGFloat = 0 private let minUpdateInterval: CGFloat = 1.0 / 30.0 + // MARK: - Speed Variation + private var currentSpeed: CGFloat = 1.0 + private var startSpeed: CGFloat = 1.0 + private var targetSpeed: CGFloat = 1.0 + private var speedTimer: CGFloat = 0 + private var phaseDuration: CGFloat = 2.0 + private var isFastPhase: Bool = false + deinit { vertexBuffer = nil pipelineState = nil @@ -204,6 +209,12 @@ final class PsyLayer: CAMetalLayer, Background { self.totalElapsedTime = other.totalElapsedTime self.colorPhase = other.colorPhase + self.currentSpeed = other.currentSpeed + self.startSpeed = other.startSpeed + self.targetSpeed = other.targetSpeed + self.speedTimer = other.speedTimer + self.phaseDuration = other.phaseDuration + self.isFastPhase = other.isFastPhase self.commandQueue = device.makeCommandQueue() setupPipeline() @@ -261,8 +272,34 @@ final class PsyLayer: CAMetalLayer, Background { setNeedsDisplay() } + private func easeInOut(_ t: CGFloat) -> CGFloat { + let p: CGFloat = 4.0 + if t < 0.5 { + return 0.5 * pow(2.0 * t, p) + } else { + return 1.0 - 0.5 * pow(2.0 * (1.0 - t), p) + } + } + func update(deltaTime: CGFloat) { - totalElapsedTime += deltaTime + speedTimer += deltaTime + if speedTimer >= phaseDuration { + speedTimer = 0 + startSpeed = currentSpeed + isFastPhase.toggle() + if isFastPhase { + targetSpeed = CGFloat.random(in: 1.6...2.8) + phaseDuration = CGFloat.random(in: 1.5...3.0) + } else { + targetSpeed = CGFloat.random(in: 0.08...0.25) + phaseDuration = CGFloat.random(in: 20.0...40.0) + } + } + + let progress = min(speedTimer / phaseDuration, 1.0) + currentSpeed = startSpeed + (targetSpeed - startSpeed) * easeInOut(progress) + + totalElapsedTime += deltaTime * currentSpeed colorPhase = (totalElapsedTime * 0.02).truncatingRemainder(dividingBy: 1.0) lastUpdateTime += deltaTime diff --git a/FruitFarm/Backgrounds/Types/PuppyLayer.swift b/FruitFarm/Backgrounds/Types/PuppyLayer.swift new file mode 100644 index 0000000..1822eac --- /dev/null +++ b/FruitFarm/Backgrounds/Types/PuppyLayer.swift @@ -0,0 +1,310 @@ +// swiftlint:disable file_length +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalPuppyShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_puppy( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct PuppyUniforms { + float2 resolution; + float time; +}; + +float3 puppy_hsv2rgb(float3 c) { + float3 p = abs(fract(float3(c.x) + float3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0); + return c.z * mix(float3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +float puppy_smin(float a, float b, float k) { + float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); +} + +float puppy_fruit_sdf(float2 p) { + // Narrow horizontally to get the taller-than-wide Fruit proportions + float ax = 1.45; + float2 q = float2(p.x * ax, p.y); + + // Body: two large circles, tight smooth union for clean silhouette + float dl = length(q - float2(-0.20, -0.04)) - 0.50; + float dr = length(q - float2( 0.20, -0.04)) - 0.50; + float d = puppy_smin(dl, dr, 0.08); + + // Top notch between shoulders + float notch = length(q - float2(0.0, 0.50)) - 0.16; + d = max(d, -notch); + + // Bottom cleft + float bdip = length(q - float2(0.0, -0.60)) - 0.06; + d = max(d, -bdip); + + // Convert body SDF to p-space + d /= ax; + + // Bite (computed in p-space so it stays circular) + float bite = length(p - float2(0.44, 0.04)) - 0.155; + d = max(d, -bite); + + // Leaf (rotated ellipse in p-space) + float2 lp = p - float2(0.04, 0.38); + float la = -0.45; + float ca = cos(la); + float sa = sin(la); + float2 rp = float2(lp.x * ca - lp.y * sa, lp.x * sa + lp.y * ca); + float2 radii = float2(0.05, 0.12); + float leaf = length(rp / radii) - 1.0; + leaf *= min(radii.x, radii.y); + d = min(d, leaf); + + return d; +} + +fragment float4 fragment_shader_puppy( + VertexOut in [[stage_in]], + constant PuppyUniforms &uniforms [[buffer(0)]]) { + + float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / + min(uniforms.resolution.x, uniforms.resolution.y); + uv.y = -uv.y; + uv.y += 0.06; + + float t = uniforms.time; + float3 color = float3(0.0); + float minRes = min(uniforms.resolution.x, uniforms.resolution.y); + + const int numLayers = 16; + const float scaleRatio = 1.28; + const float baseScale = 0.06; + + float animPhase = fract(t * 0.25); + + for (int i = numLayers - 1; i >= 0; i--) { + float fi = float(i) + animPhase; + float scale = baseScale * pow(scaleRatio, fi); + + if (scale > 5.0 || scale < 0.005) continue; + + float2 p = uv / scale; + float d = puppy_fruit_sdf(p); + + float aa = 1.5 / (scale * minRes * 0.5); + float inside = 1.0 - smoothstep(-aa, aa, d); + + float edgeDist = abs(d) * scale; + float edge = exp(-edgeDist * edgeDist * 600.0); + + float depth = clamp(fi / float(numLayers), 0.0, 1.0); + + float hue = 0.58 + fi * 0.018 + t * 0.015; + float sat = 0.7 + 0.3 * depth; + + // Smooth cosine blend replaces the hard i%2 switch so + // the bright/dark alternation travels with the zoom + float blend = 0.5 + 0.5 * cos(fi * 3.14159); + float val = 0.4 + 0.5 * depth; + float3 brightColor = puppy_hsv2rgb(float3(hue, sat, val)); + float3 darkColor = float3(0.01, 0.012, 0.035); + float3 layerColor = mix(darkColor, brightColor, blend); + + float3 edgeColor = puppy_hsv2rgb(float3(hue, 0.5, 1.0)); + + // Wider fade-in so the innermost layer enters at near-zero opacity + float opacity = smoothstep(0.05, 0.12, scale) * smoothstep(5.0, 2.5, scale); + + color = mix(color, layerColor, inside * opacity); + color += edgeColor * edge * opacity * 0.35; + } + + // Vignette + float vignette = 1.0 - smoothstep(0.6, 2.0, length(uv)); + color *= mix(0.35, 1.0, vignette); + + color = clamp(color, 0.0, 1.0); + return float4(color, 1.0); +} +""" + +private struct MetalPuppyFragmentUniforms { + var resolution: SIMD2 + var time: Float +} + +final class PuppyLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? PuppyLayer else { return } + + let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() + guard let device = device else { return } + self.metalDevice = device + self.device = device + + self.pixelFormat = other.pixelFormat != .invalid ? other.pixelFormat : .bgra8Unorm + self.framebufferOnly = other.framebufferOnly + self.isOpaque = other.isOpaque + + self.totalElapsedTime = other.totalElapsedTime + + self.commandQueue = device.makeCommandQueue() + setupPipeline() + createVertexBuffers() + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalPuppyShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_puppy"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_puppy") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + self.frame = frame + setNeedsDisplay() + } + + func config(fruit: Fruit) { + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + var uniforms = MetalPuppyFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + time: Float(totalElapsedTime) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/FruitView.swift b/FruitFarm/FruitView.swift index ac54f8b..03f29f3 100644 --- a/FruitFarm/FruitView.swift +++ b/FruitFarm/FruitView.swift @@ -210,6 +210,8 @@ public final class FruitView: NSView { return PsyLayer(frame: self.frame, fruit: fruit, contentsScale: scale) case .liquid: return LiquidLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .puppy: + return PuppyLayer(frame: self.frame, fruit: fruit, contentsScale: scale) } } diff --git a/FruitScreensaver/Preferences/PreferencesViewController.swift b/FruitScreensaver/Preferences/PreferencesViewController.swift index 226ccfa..57c51b9 100644 --- a/FruitScreensaver/Preferences/PreferencesViewController.swift +++ b/FruitScreensaver/Preferences/PreferencesViewController.swift @@ -395,6 +395,8 @@ final class PreferencesControlsView: NSView { return "Psychedelic" case .liquid: return "Liquid" + case .puppy: + return "Puppy" } }) return items From 84edccd57768bff9850d031ab1a948782a75d59d Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 15:19:54 +0000 Subject: [PATCH 04/19] Add WarpLayer with speed-of-light particle simulation Metal shader renders a 3D star field as dot particles that stretch into radial streaks during warp. Speed oscillates between calm cruise (twinkling dots) and intense warp bursts (dense streaks with central glow). Registered as "Warp Speed" in FruitType enum and preferences UI. --- FruitFarm/Backgrounds/FruitTypes.swift | 1 + FruitFarm/Backgrounds/Types/WarpLayer.swift | 343 ++++++++++++++++++ FruitFarm/FruitView.swift | 2 + .../PreferencesViewController.swift | 2 + 4 files changed, 348 insertions(+) create mode 100644 FruitFarm/Backgrounds/Types/WarpLayer.swift diff --git a/FruitFarm/Backgrounds/FruitTypes.swift b/FruitFarm/Backgrounds/FruitTypes.swift index 9e37fba..3a96cfd 100644 --- a/FruitFarm/Backgrounds/FruitTypes.swift +++ b/FruitFarm/Backgrounds/FruitTypes.swift @@ -13,4 +13,5 @@ public enum FruitType: String, CaseIterable { case psychedelic case liquid case puppy + case warp } diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift new file mode 100644 index 0000000..40495b3 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -0,0 +1,343 @@ +// swiftlint:disable file_length +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalWarpShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_warp( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct WarpUniforms { + float2 resolution; + float time; + float speed; +}; + +float warp_hash(float2 p) { + float3 p3 = fract(float3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fragment float4 fragment_shader_warp( + VertexOut in [[stage_in]], + constant WarpUniforms &uniforms [[buffer(0)]]) { + + float2 uv = (in.position.xy - uniforms.resolution * 0.5) / + min(uniforms.resolution.x, uniforms.resolution.y); + uv.y -= 0.039; + uv.x += 0.005; + + float t = uniforms.time; + float spd = uniforms.speed; + + // 0 = dots only, 1 = full streaks + float streak_mix = smoothstep(0.3, 1.5, spd); + // always visible, brighter at warp + float bright = mix(1.0, 1.3, smoothstep(0.5, 2.0, spd)); + + float3 col = float3(0.0); + + for (int i = 0; i < 20; i++) { + float fi = float(i); + float z = fract(fi / 20.0 + t * 0.25); + float scale = mix(18.0, 0.8, z); + float fade = smoothstep(0.0, 0.1, z) * smoothstep(1.0, 0.8, z); + + float2 st = uv * scale; + float2 gid = floor(st); + float2 gf = fract(st) - 0.5; + + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + float2 off = float2(float(dx), float(dy)); + float2 id = gid + off; + + float h1 = warp_hash(id + fi * 64.0); + if (h1 < 0.15) continue; + + float rx = warp_hash(id * 3.14 + fi * 27.0); + float ry = warp_hash(id * 2.71 + fi * 43.0); + float2 sp = float2(rx, ry) - 0.5; + + // Particle screen position + float2 ss = (gid + off + sp + 0.5) / scale; + float sr = length(ss); + + // --- Dot: tight gaussian, always visible --- + float dd = length(uv - ss); + float ds = 0.0018 + 0.0006 * z; + float pb = exp(-dd * dd / (ds * ds)); + + // --- Streak: line segment toward center, warp only --- + float sb = 0.0; + if (streak_mix > 0.01) { + float slen = spd * sr * 0.15; + float2 sdir = sr > 0.001 ? normalize(ss) : float2(0.0); + float2 sa = ss; + float2 sba = -sdir * slen; + float sba2 = dot(sba, sba); + float2 pa = uv - sa; + float tp = sba2 > 0.0001 + ? clamp(dot(pa, sba) / sba2, 0.0, 1.0) : 0.0; + float seg_d = length(pa - sba * tp); + float sw = 0.001; + sb = exp(-seg_d * seg_d / (sw * sw)) + * streak_mix * (1.0 - tp * 0.6); + } + + float b = max(pb, sb) * fade; + b *= smoothstep(0.15, 0.8, h1); + + // Twinkle at low speed, solid at warp + float tw = mix( + 0.5 + 0.5 * sin(t * 3.0 + warp_hash(id * 17.0) * 6.283), + 1.0, + smoothstep(0.3, 1.0, spd) + ); + b *= tw; + + float cr = warp_hash(id * 7.77 + fi); + float3 sc = mix( + float3(0.5, 0.6, 1.0), + float3(0.95, 0.97, 1.0), + smoothstep(0.3, 0.8, cr) + ); + sc = mix(sc, float3(1.0, 0.9, 0.75), + smoothstep(0.85, 0.95, cr)); + + col += sc * b * bright * 0.15; + } + } + } + + float dist = length(uv); + + // Central glow — only appears near peak warp (70%+ of max ~7.0) + float gf2 = smoothstep(4.5, 6.5, spd) * 0.5; + col += float3(0.15, 0.2, 0.45) * exp(-dist * 5.0) * gf2; + col += float3(0.3, 0.4, 0.65) * exp(-dist * 14.0) * gf2 * 0.5; + col += float3(0.6, 0.65, 0.8) * exp(-dist * 30.0) * gf2 * 0.3; + + col *= smoothstep(2.0, 0.5, dist); + col = pow(1.0 - exp(-col * 3.5), float3(0.9)); + + return float4(col, 1.0); +} +""" + +private struct MetalWarpFragmentUniforms { + var resolution: SIMD2 + var time: Float + var speed: Float +} + +// swiftlint:disable:next type_body_length +final class WarpLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + // MARK: - Speed Variation + private var currentSpeed: CGFloat = 0.3 + private var startSpeed: CGFloat = 0.3 + private var targetSpeed: CGFloat = 5.0 + private var speedTimer: CGFloat = 0 + private var phaseDuration: CGFloat = 2.0 + private var isWarpPhase: Bool = true + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? WarpLayer else { return } + + // Only copy plain Swift stored properties. Do NOT access any CAMetalLayer + // properties (device, pixelFormat, etc.) — this runs inside + // CA::Layer::presentation_copy where Metal state is not valid yet. + self.totalElapsedTime = other.totalElapsedTime + self.currentSpeed = other.currentSpeed + self.startSpeed = other.startSpeed + self.targetSpeed = other.targetSpeed + self.speedTimer = other.speedTimer + self.phaseDuration = other.phaseDuration + self.isWarpPhase = other.isWarpPhase + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalWarpShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_warp"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_warp") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + setFrameWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit) { + setNeedsDisplay() + } + + private func easeInOut(_ t: CGFloat) -> CGFloat { + let p: CGFloat = 4.0 + if t < 0.5 { + return 0.5 * pow(2.0 * t, p) + } else { + return 1.0 - 0.5 * pow(2.0 * (1.0 - t), p) + } + } + + func update(deltaTime: CGFloat) { + speedTimer += deltaTime + if speedTimer >= phaseDuration { + speedTimer = 0 + startSpeed = currentSpeed + isWarpPhase.toggle() + if isWarpPhase { + targetSpeed = CGFloat.random(in: 4.0...7.0) + phaseDuration = CGFloat.random(in: 10.0...25.0) + } else { + targetSpeed = CGFloat.random(in: 0.4...0.7) + phaseDuration = CGFloat.random(in: 3.0...5.0) + } + } + + let progress = min(speedTimer / phaseDuration, 1.0) + currentSpeed = startSpeed + (targetSpeed - startSpeed) * easeInOut(progress) + + totalElapsedTime += deltaTime + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + var uniforms = MetalWarpFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + time: Float(totalElapsedTime), + speed: Float(currentSpeed) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/FruitView.swift b/FruitFarm/FruitView.swift index 03f29f3..72d14c2 100644 --- a/FruitFarm/FruitView.swift +++ b/FruitFarm/FruitView.swift @@ -212,6 +212,8 @@ public final class FruitView: NSView { return LiquidLayer(frame: self.frame, fruit: fruit, contentsScale: scale) case .puppy: return PuppyLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .warp: + return WarpLayer(frame: self.frame, fruit: fruit, contentsScale: scale) } } diff --git a/FruitScreensaver/Preferences/PreferencesViewController.swift b/FruitScreensaver/Preferences/PreferencesViewController.swift index 57c51b9..de579b2 100644 --- a/FruitScreensaver/Preferences/PreferencesViewController.swift +++ b/FruitScreensaver/Preferences/PreferencesViewController.swift @@ -397,6 +397,8 @@ final class PreferencesControlsView: NSView { return "Liquid" case .puppy: return "Puppy" + case .warp: + return "Warp Speed" } }) return items From cd7639dbcad3adb31ac415cc4ccf88b9f579b013 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 15:58:13 +0000 Subject: [PATCH 05/19] Fix Metal layer resolution on resize and WarpLayer scaling Update drawableSize when CAMetalLayer frame changes so the shader renders at the correct resolution instead of stretching a stale texture. Also switch WarpLayer UV normalization to a fixed reference size so particle density stays consistent across view sizes, and simplify init(layer:) presentation copies to avoid accessing invalid Metal state. --- FruitFarm/Backgrounds/Background.swift | 24 +++++++++++++++++ FruitFarm/Backgrounds/BackgroundLayer.swift | 2 +- .../Types/CircularGradientLayer.swift | 24 +---------------- .../Types/LinearGradientLayer.swift | 24 +---------------- FruitFarm/Backgrounds/Types/LiquidLayer.swift | 16 +---------- FruitFarm/Backgrounds/Types/PsyLayer.swift | 18 +++---------- FruitFarm/Backgrounds/Types/PuppyLayer.swift | 27 +++++++------------ .../Backgrounds/Types/RainbowsLayer.swift | 2 +- FruitFarm/Backgrounds/Types/SolidLayer.swift | 16 +---------- FruitFarm/Backgrounds/Types/WarpLayer.swift | 17 ++++++++---- 10 files changed, 55 insertions(+), 115 deletions(-) diff --git a/FruitFarm/Backgrounds/Background.swift b/FruitFarm/Backgrounds/Background.swift index a5df7b6..26710aa 100644 --- a/FruitFarm/Backgrounds/Background.swift +++ b/FruitFarm/Backgrounds/Background.swift @@ -1,8 +1,32 @@ import Foundation import Cocoa +import QuartzCore +import MetalKit protocol Background: AnyObject { func config(fruit: Fruit) func update(frame: NSRect, fruit: Fruit) func update(deltaTime: CGFloat) } + +extension CALayer { + func setFrameWithoutAnimation(_ frame: CGRect) { + CATransaction.begin() + CATransaction.setDisableActions(true) + self.frame = frame + CATransaction.commit() + } +} + +extension CAMetalLayer { + func setFrameAndDrawableSizeWithoutAnimation(_ frame: CGRect) { + CATransaction.begin() + CATransaction.setDisableActions(true) + self.frame = frame + self.drawableSize = CGSize( + width: frame.width * contentsScale, + height: frame.height * contentsScale + ) + CATransaction.commit() + } +} diff --git a/FruitFarm/Backgrounds/BackgroundLayer.swift b/FruitFarm/Backgrounds/BackgroundLayer.swift index dcb3d6a..421689d 100644 --- a/FruitFarm/Backgrounds/BackgroundLayer.swift +++ b/FruitFarm/Backgrounds/BackgroundLayer.swift @@ -27,7 +27,7 @@ final class BackgroundLayer: CAShapeLayer, Background { func update(deltaTime: CGFloat) { } func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + setFrameWithoutAnimation(frame) updateBezierPath() } diff --git a/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift index d7c9aaf..58453be 100644 --- a/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift +++ b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift @@ -207,32 +207,10 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { override init(layer: Any) { super.init(layer: layer) guard let other = layer as? MetalCircularGradientLayer else { return } - - let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() - guard let device = device else { return } - self.metalDevice = device - self.device = device - - self.pixelFormat = other.pixelFormat != .invalid ? other.pixelFormat : .bgra8Unorm - self.framebufferOnly = other.framebufferOnly - self.isOpaque = other.isOpaque - self.colorIndex = other.colorIndex self.elapsedTime = other.elapsedTime self.continuousTotalElapsedTimeForRotation = other.continuousTotalElapsedTimeForRotation self.currentFruitMaxDimension = other.currentFruitMaxDimension - - self.commandQueue = device.makeCommandQueue() - setupPipeline() - createVertexBuffers() - createColorLocationBuffer() - - let currentColors = calculateCurrentInterpolatedColors() - currentInterpolatedColorsBuffer = device.makeBuffer( - bytes: currentColors, - length: MemoryLayout>.stride * colorArray.count, - options: .storageModeShared - ) } private func setupMetal() { @@ -292,7 +270,7 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + setFrameAndDrawableSizeWithoutAnimation(frame) self.currentFruitMaxDimension = fruit.maxDimen() setNeedsDisplay() } diff --git a/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift index 80f489c..3545a86 100644 --- a/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift +++ b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift @@ -200,30 +200,8 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { override init(layer: Any) { super.init(layer: layer) guard let other = layer as? MetalLinearGradientLayer else { return } - - let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() - guard let device = device else { return } - self.metalDevice = device - self.device = device - - self.pixelFormat = other.pixelFormat - self.framebufferOnly = other.framebufferOnly - self.isOpaque = other.isOpaque - self.colorIndex = other.colorIndex self.elapsedTime = other.elapsedTime - - self.commandQueue = device.makeCommandQueue() - setupPipeline() - createVertexBuffers() - createColorLocationBuffer() - - let currentColors = calculateCurrentInterpolatedColors() - currentInterpolatedColorsBuffer = device.makeBuffer( - bytes: currentColors, - length: MemoryLayout>.stride * colorArray.count, - options: .storageModeShared - ) } private func setupMetal() { @@ -284,7 +262,7 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } diff --git a/FruitFarm/Backgrounds/Types/LiquidLayer.swift b/FruitFarm/Backgrounds/Types/LiquidLayer.swift index 4a66673..9fbe219 100644 --- a/FruitFarm/Backgrounds/Types/LiquidLayer.swift +++ b/FruitFarm/Backgrounds/Types/LiquidLayer.swift @@ -175,22 +175,8 @@ final class LiquidLayer: CAMetalLayer, Background { override init(layer: Any) { super.init(layer: layer) guard let other = layer as? LiquidLayer else { return } - - let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() - guard let device = device else { return } - self.metalDevice = device - self.device = device - - self.pixelFormat = other.pixelFormat != .invalid ? other.pixelFormat : .bgra8Unorm - self.framebufferOnly = other.framebufferOnly - self.isOpaque = other.isOpaque - self.totalElapsedTime = other.totalElapsedTime self.colorPhase = other.colorPhase - - self.commandQueue = device.makeCommandQueue() - setupPipeline() - createVertexBuffers() } private func setupMetal() { @@ -236,7 +222,7 @@ final class LiquidLayer: CAMetalLayer, Background { // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } diff --git a/FruitFarm/Backgrounds/Types/PsyLayer.swift b/FruitFarm/Backgrounds/Types/PsyLayer.swift index 4e485e2..d41fabc 100644 --- a/FruitFarm/Backgrounds/Types/PsyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -198,15 +198,9 @@ final class PsyLayer: CAMetalLayer, Background { super.init(layer: layer) guard let other = layer as? PsyLayer else { return } - let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() - guard let device = device else { return } - self.metalDevice = device - self.device = device - - self.pixelFormat = other.pixelFormat != .invalid ? other.pixelFormat : .bgra8Unorm - self.framebufferOnly = other.framebufferOnly - self.isOpaque = other.isOpaque - + // Only copy plain Swift stored properties. Do NOT access any CAMetalLayer + // properties (device, pixelFormat, etc.) — this runs inside + // CA::Layer::presentation_copy where Metal state is not valid yet. self.totalElapsedTime = other.totalElapsedTime self.colorPhase = other.colorPhase self.currentSpeed = other.currentSpeed @@ -215,10 +209,6 @@ final class PsyLayer: CAMetalLayer, Background { self.speedTimer = other.speedTimer self.phaseDuration = other.phaseDuration self.isFastPhase = other.isFastPhase - - self.commandQueue = device.makeCommandQueue() - setupPipeline() - createVertexBuffers() } private func setupMetal() { @@ -264,7 +254,7 @@ final class PsyLayer: CAMetalLayer, Background { // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } diff --git a/FruitFarm/Backgrounds/Types/PuppyLayer.swift b/FruitFarm/Backgrounds/Types/PuppyLayer.swift index 1822eac..9d9ba16 100644 --- a/FruitFarm/Backgrounds/Types/PuppyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PuppyLayer.swift @@ -43,13 +43,18 @@ float puppy_fruit_sdf(float2 p) { float ax = 1.45; float2 q = float2(p.x * ax, p.y); - // Body: two large circles, tight smooth union for clean silhouette + // Body: two large circles for the main mass float dl = length(q - float2(-0.20, -0.04)) - 0.50; float dr = length(q - float2( 0.20, -0.04)) - 0.50; float d = puppy_smin(dl, dr, 0.08); - // Top notch between shoulders - float notch = length(q - float2(0.0, 0.50)) - 0.16; + // Upper shoulder peaks so the top has two distinct bumps + float sl = length(q - float2(-0.24, 0.22)) - 0.28; + float sr = length(q - float2( 0.24, 0.22)) - 0.28; + d = puppy_smin(d, min(sl, sr), 0.03); + + // Top notch carved between the shoulders + float notch = length(q - float2(0.0, 0.54)) - 0.18; d = max(d, -notch); // Bottom cleft @@ -188,21 +193,7 @@ final class PuppyLayer: CAMetalLayer, Background { override init(layer: Any) { super.init(layer: layer) guard let other = layer as? PuppyLayer else { return } - - let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() - guard let device = device else { return } - self.metalDevice = device - self.device = device - - self.pixelFormat = other.pixelFormat != .invalid ? other.pixelFormat : .bgra8Unorm - self.framebufferOnly = other.framebufferOnly - self.isOpaque = other.isOpaque - self.totalElapsedTime = other.totalElapsedTime - - self.commandQueue = device.makeCommandQueue() - setupPipeline() - createVertexBuffers() } private func setupMetal() { @@ -248,7 +239,7 @@ final class PuppyLayer: CAMetalLayer, Background { // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } diff --git a/FruitFarm/Backgrounds/Types/RainbowsLayer.swift b/FruitFarm/Backgrounds/Types/RainbowsLayer.swift index 219e50b..126f079 100644 --- a/FruitFarm/Backgrounds/Types/RainbowsLayer.swift +++ b/FruitFarm/Backgrounds/Types/RainbowsLayer.swift @@ -51,7 +51,7 @@ final class RainbowsLayer: CALayer, Background { } func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + setFrameWithoutAnimation(frame) config(fruit: fruit) } diff --git a/FruitFarm/Backgrounds/Types/SolidLayer.swift b/FruitFarm/Backgrounds/Types/SolidLayer.swift index 2fefaa2..f461ea1 100644 --- a/FruitFarm/Backgrounds/Types/SolidLayer.swift +++ b/FruitFarm/Backgrounds/Types/SolidLayer.swift @@ -95,22 +95,8 @@ final class MetalSolidLayer: CAMetalLayer, Background { override init(layer: Any) { super.init(layer: layer) guard let other = layer as? MetalSolidLayer else { return } - - let device = other.metalDevice ?? MTLCreateSystemDefaultDevice() - guard let device = device else { return } - self.metalDevice = device - self.device = device - - self.pixelFormat = other.pixelFormat - self.framebufferOnly = other.framebufferOnly - self.isOpaque = other.isOpaque - self.colorIndex = other.colorIndex self.elapsedTime = other.elapsedTime - - self.commandQueue = device.makeCommandQueue() - setupPipeline() - createVertexBuffers() } private func setupMetal() { @@ -156,7 +142,7 @@ final class MetalSolidLayer: CAMetalLayer, Background { // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index 40495b3..fecb73c 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -27,6 +27,7 @@ struct WarpUniforms { float2 resolution; float time; float speed; + float referenceSize; }; float warp_hash(float2 p) { @@ -40,9 +41,13 @@ fragment float4 fragment_shader_warp( constant WarpUniforms &uniforms [[buffer(0)]]) { float2 uv = (in.position.xy - uniforms.resolution * 0.5) / - min(uniforms.resolution.x, uniforms.resolution.y); - uv.y -= 0.039; - uv.x += 0.005; + uniforms.referenceSize; + + float fruitScale = min( + min(uniforms.resolution.x, uniforms.resolution.y) / uniforms.referenceSize, + 2.0); + uv.y -= fruitScale * 0.04; + uv.x += fruitScale * 0.005; float t = uniforms.time; float spd = uniforms.speed; @@ -146,6 +151,7 @@ private struct MetalWarpFragmentUniforms { var resolution: SIMD2 var time: Float var speed: Float + var referenceSize: Float } // swiftlint:disable:next type_body_length @@ -254,7 +260,7 @@ final class WarpLayer: CAMetalLayer, Background { // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { - setFrameWithoutAnimation(frame) + setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } @@ -309,7 +315,8 @@ final class WarpLayer: CAMetalLayer, Background { var uniforms = MetalWarpFragmentUniforms( resolution: SIMD2(Float(texture.width), Float(texture.height)), time: Float(totalElapsedTime), - speed: Float(currentSpeed) + speed: Float(currentSpeed), + referenceSize: 300.0 * Float(contentsScale) ) let renderPassDescriptor = MTLRenderPassDescriptor() From 2f712f9421f5173c9c252751d316268fca88ea97 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 16:05:59 +0000 Subject: [PATCH 06/19] Improve WarpLayer speed dynamics and easing curves Make particles nearly stationary at idle (0.05-0.15) with speed-dependent scroll rate, increase warp speed range (8-14) for more dramatic effect, and replace generic easeInOut with back-easing curves that reverse before accelerating and overshoot before settling. --- FruitFarm/Backgrounds/Types/WarpLayer.swift | 42 +++++++++++++-------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index fecb73c..2a990a5 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -57,11 +57,13 @@ fragment float4 fragment_shader_warp( // always visible, brighter at warp float bright = mix(1.0, 1.3, smoothstep(0.5, 2.0, spd)); + float scroll = mix(0.02, 0.5, smoothstep(0.1, 10.0, spd)); + float3 col = float3(0.0); for (int i = 0; i < 20; i++) { float fi = float(i); - float z = fract(fi / 20.0 + t * 0.25); + float z = fract(fi / 20.0 + t * scroll); float scale = mix(18.0, 0.8, z); float fade = smoothstep(0.0, 0.1, z) * smoothstep(1.0, 0.8, z); @@ -134,8 +136,7 @@ fragment float4 fragment_shader_warp( float dist = length(uv); - // Central glow — only appears near peak warp (70%+ of max ~7.0) - float gf2 = smoothstep(4.5, 6.5, spd) * 0.5; + float gf2 = smoothstep(7.0, 12.0, spd) * 0.5; col += float3(0.15, 0.2, 0.45) * exp(-dist * 5.0) * gf2; col += float3(0.3, 0.4, 0.65) * exp(-dist * 14.0) * gf2 * 0.5; col += float3(0.6, 0.65, 0.8) * exp(-dist * 30.0) * gf2 * 0.3; @@ -169,11 +170,11 @@ final class WarpLayer: CAMetalLayer, Background { private let minUpdateInterval: CGFloat = 1.0 / 30.0 // MARK: - Speed Variation - private var currentSpeed: CGFloat = 0.3 - private var startSpeed: CGFloat = 0.3 - private var targetSpeed: CGFloat = 5.0 + private var currentSpeed: CGFloat = 0.1 + private var startSpeed: CGFloat = 0.1 + private var targetSpeed: CGFloat = 10.0 private var speedTimer: CGFloat = 0 - private var phaseDuration: CGFloat = 2.0 + private var phaseDuration: CGFloat = 15.0 private var isWarpPhase: Bool = true deinit { @@ -268,15 +269,23 @@ final class WarpLayer: CAMetalLayer, Background { setNeedsDisplay() } - private func easeInOut(_ t: CGFloat) -> CGFloat { - let p: CGFloat = 4.0 - if t < 0.5 { - return 0.5 * pow(2.0 * t, p) + private func easeWarpEngage(_ t: CGFloat) -> CGFloat { + let s: CGFloat = 3.0 + let t2 = t * 2.0 + if t2 < 1.0 { + return 0.5 * (t2 * t2 * ((s + 1.0) * t2 - s)) } else { - return 1.0 - 0.5 * pow(2.0 * (1.0 - t), p) + let p = t2 - 2.0 + return 0.5 * (p * p * ((s + 1.0) * p + s) + 2.0) } } + private func easeWarpDisengage(_ t: CGFloat) -> CGFloat { + let s: CGFloat = 2.5 + let p = t - 1.0 + return p * p * ((s + 1.0) * p + s) + 1.0 + } + func update(deltaTime: CGFloat) { speedTimer += deltaTime if speedTimer >= phaseDuration { @@ -284,16 +293,17 @@ final class WarpLayer: CAMetalLayer, Background { startSpeed = currentSpeed isWarpPhase.toggle() if isWarpPhase { - targetSpeed = CGFloat.random(in: 4.0...7.0) - phaseDuration = CGFloat.random(in: 10.0...25.0) + targetSpeed = CGFloat.random(in: 8.0...14.0) + phaseDuration = CGFloat.random(in: 15.0...30.0) } else { - targetSpeed = CGFloat.random(in: 0.4...0.7) + targetSpeed = CGFloat.random(in: 0.05...0.15) phaseDuration = CGFloat.random(in: 3.0...5.0) } } let progress = min(speedTimer / phaseDuration, 1.0) - currentSpeed = startSpeed + (targetSpeed - startSpeed) * easeInOut(progress) + let eased = isWarpPhase ? easeWarpEngage(progress) : easeWarpDisengage(progress) + currentSpeed = max(0, startSpeed + (targetSpeed - startSpeed) * eased) totalElapsedTime += deltaTime lastUpdateTime += deltaTime From 1448bfb85cd969cea8e0ff5012088d26cfeeb43e Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 16:25:16 +0000 Subject: [PATCH 07/19] Add relativistic physics to WarpLayer and update docs for 1.3.4 Add aberration, Doppler shift, beaming, streak taper, and dynamic tunnel vignette to the warp shader. Increase star density at idle, add gentle deceleration easing, micro vibrations during speed transitions, and longer idle phases. Update CHANGELOG and README with all 1.3.4 features and fixes. --- CHANGELOG.md | 16 +++++++ FruitFarm/Backgrounds/Types/WarpLayer.swift | 53 ++++++++++++++++----- README.md | 8 +++- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1161512..bc764e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,24 @@ ## [1.3.4] +### Features + + - Added Psychedelic and Liquid background layers with Metal shaders. + - Added PuppyLayer with animated Fruit logo tunnel zoom effect. + - Added Warp Speed background — the most ambitious and complex rendering in this project. A Metal shader simulating relativistic travel through a star field, featuring: + - Relativistic aberration (stars compress toward the center at speed). + - Doppler color shift (blueshift forward, redshift at the edges). + - Relativistic beaming (center brightens, periphery dims). + - Comet-like streak taper with speed-dependent particle motion. + - Dynamic tunnel vignette that narrows at warp. + - Micro vibrations during acceleration and deceleration. + - Back-easing curves with anticipation and overshoot for dramatic speed transitions. + ### Bug Fixes + - Fixed star artifact in Liquid and Psychedelic Metal shaders. + - Fixed Metal layer resolution on resize and WarpLayer scaling. + - Fixed release workflow bugs and added missing permissions. - Fixed 13 bugs found during code review: - Fixed layer leak in `FruitView.setupLayersOrUpdate()` where stale `BackgroundLayer`s accumulated on every mode change. - Fixed strong `self` capture in `randomlyChangeFruitType()` animation closure that kept torn-down views alive. diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index 2a990a5..d17b322 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -52,6 +52,18 @@ fragment float4 fragment_shader_warp( float t = uniforms.time; float spd = uniforms.speed; + float vib = smoothstep(0.3, 1.5, spd) * (1.0 - smoothstep(8.0, 12.0, spd)); + float vib_strength = 0.001; + float vib_amt = vib * vib_strength; + float2 shake = float2( + sin(t * 130.0) * 0.7 + sin(t * 73.0) * 0.3, + cos(t * 110.0) * 0.6 + cos(t * 89.0) * 0.4 + ); + uv += shake * vib_amt; + + float aberration = 1.0 / (1.0 + spd * 0.04); + float2 warp_uv = uv * mix(1.0, aberration, smoothstep(2.0, 8.0, spd)); + // 0 = dots only, 1 = full streaks float streak_mix = smoothstep(0.3, 1.5, spd); // always visible, brighter at warp @@ -61,13 +73,15 @@ fragment float4 fragment_shader_warp( float3 col = float3(0.0); - for (int i = 0; i < 20; i++) { + float cull = mix(0.02, 0.15, smoothstep(0.3, 2.0, spd)); + + for (int i = 0; i < 60; i++) { float fi = float(i); - float z = fract(fi / 20.0 + t * scroll); + float z = fract(fi / 60.0 + t * scroll); float scale = mix(18.0, 0.8, z); float fade = smoothstep(0.0, 0.1, z) * smoothstep(1.0, 0.8, z); - float2 st = uv * scale; + float2 st = warp_uv * scale; float2 gid = floor(st); float2 gf = fract(st) - 0.5; @@ -77,7 +91,7 @@ fragment float4 fragment_shader_warp( float2 id = gid + off; float h1 = warp_hash(id + fi * 64.0); - if (h1 < 0.15) continue; + if (h1 < cull) continue; float rx = warp_hash(id * 3.14 + fi * 27.0); float ry = warp_hash(id * 2.71 + fi * 43.0); @@ -88,7 +102,7 @@ fragment float4 fragment_shader_warp( float sr = length(ss); // --- Dot: tight gaussian, always visible --- - float dd = length(uv - ss); + float dd = length(warp_uv - ss); float ds = 0.0018 + 0.0006 * z; float pb = exp(-dd * dd / (ds * ds)); @@ -100,11 +114,11 @@ fragment float4 fragment_shader_warp( float2 sa = ss; float2 sba = -sdir * slen; float sba2 = dot(sba, sba); - float2 pa = uv - sa; + float2 pa = warp_uv - sa; float tp = sba2 > 0.0001 ? clamp(dot(pa, sba) / sba2, 0.0, 1.0) : 0.0; float seg_d = length(pa - sba * tp); - float sw = 0.001; + float sw = 0.001 * (1.0 + tp * 0.8); sb = exp(-seg_d * seg_d / (sw * sw)) * streak_mix * (1.0 - tp * 0.6); } @@ -120,6 +134,9 @@ fragment float4 fragment_shader_warp( ); b *= tw; + float beaming = mix(1.0, 1.0 / (1.0 + sr * 4.0), smoothstep(2.0, 8.0, spd)); + b *= beaming; + float cr = warp_hash(id * 7.77 + fi); float3 sc = mix( float3(0.5, 0.6, 1.0), @@ -129,6 +146,12 @@ fragment float4 fragment_shader_warp( sc = mix(sc, float3(1.0, 0.9, 0.75), smoothstep(0.85, 0.95, cr)); + float doppler_mix = smoothstep(3.0, 8.0, spd); + float3 blue_tint = float3(0.7, 0.8, 1.0); + float3 red_tint = float3(1.0, 0.75, 0.5); + float radial_factor = smoothstep(0.0, 0.6, sr); + sc *= mix(float3(1.0), mix(blue_tint, red_tint, radial_factor), doppler_mix); + col += sc * b * bright * 0.15; } } @@ -141,7 +164,8 @@ fragment float4 fragment_shader_warp( col += float3(0.3, 0.4, 0.65) * exp(-dist * 14.0) * gf2 * 0.5; col += float3(0.6, 0.65, 0.8) * exp(-dist * 30.0) * gf2 * 0.3; - col *= smoothstep(2.0, 0.5, dist); + float vig_radius = mix(2.0, 1.2, smoothstep(3.0, 10.0, spd)); + col *= smoothstep(vig_radius, 0.5, dist); col = pow(1.0 - exp(-col * 3.5), float3(0.9)); return float4(col, 1.0); @@ -281,9 +305,14 @@ final class WarpLayer: CAMetalLayer, Background { } private func easeWarpDisengage(_ t: CGFloat) -> CGFloat { - let s: CGFloat = 2.5 - let p = t - 1.0 - return p * p * ((s + 1.0) * p + s) + 1.0 + let s: CGFloat = 1.5 + let t2 = t * 2.0 + if t2 < 1.0 { + return 0.5 * (t2 * t2 * ((s + 1.0) * t2 - s)) + } else { + let p = t2 - 2.0 + return 0.5 * (p * p * ((s + 1.0) * p + s) + 2.0) + } } func update(deltaTime: CGFloat) { @@ -297,7 +326,7 @@ final class WarpLayer: CAMetalLayer, Background { phaseDuration = CGFloat.random(in: 15.0...30.0) } else { targetSpeed = CGFloat.random(in: 0.05...0.15) - phaseDuration = CGFloat.random(in: 3.0...5.0) + phaseDuration = CGFloat.random(in: 25.0...45.0) } } diff --git a/README.md b/README.md index 010ac9b..ce5339e 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,16 @@ Compatible with macOS 11.5 and later. ### Options -Now you can select four different fruit types: +Now you can select from multiple fruit types and backgrounds: - Rainbow - Solid - Linear gradient - Circular gradient + - Psychedelic + - Liquid + - Puppy (Fruit logo tunnel zoom) + - Warp Speed (relativistic star field simulation) *You are welcome to create new designs through PRs!* @@ -33,7 +37,7 @@ You can change those under the options from `System Settings` -> `Screen saver` ### Install manually -1. [Click here to Download](https://github.com/Corkscrews/fruit/releases/download/1.3.3/Fruit.saver.tar.gz) +1. [Click here to Download](https://github.com/Corkscrews/fruit/releases/download/1.3.4/Fruit.saver.tar.gz) 2. Open **Fruit.saver** (double click). 3. `"Fruit.saver" can't be opened because it is from an unidentified developer` will appear, press `OK`. 4. Open `Preferences`. From b9183872ec87cbe90a00b9c4934b6e6e323c15f9 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 16:31:09 +0000 Subject: [PATCH 08/19] Add per-layer depth noise to WarpLayer star field Break up uniform layer spacing with a small hash-based offset so stars don't scroll in lockstep across depth layers. --- FruitFarm/Backgrounds/Types/WarpLayer.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index d17b322..16cae51 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -74,10 +74,11 @@ fragment float4 fragment_shader_warp( float3 col = float3(0.0); float cull = mix(0.02, 0.15, smoothstep(0.3, 2.0, spd)); + float iter = 30; - for (int i = 0; i < 60; i++) { + for (int i = 0; i < iter; i++) { float fi = float(i); - float z = fract(fi / 60.0 + t * scroll); + float z = fract(fi / iter + t * scroll + warp_hash(float2(fi, fi * 0.7)) * 0.05); float scale = mix(18.0, 0.8, z); float fade = smoothstep(0.0, 0.1, z) * smoothstep(1.0, 0.8, z); From ca0d2bee9aea76b9b9627f672f5bf836bfa03c36 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 16:33:30 +0000 Subject: [PATCH 09/19] Increase idle star brightness in WarpLayer Raise base brightness from 1.0 to 2.0 so twinkling stars are more visible when not in warp mode. --- FruitFarm/Backgrounds/Types/WarpLayer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index 16cae51..97bf9aa 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -67,7 +67,7 @@ fragment float4 fragment_shader_warp( // 0 = dots only, 1 = full streaks float streak_mix = smoothstep(0.3, 1.5, spd); // always visible, brighter at warp - float bright = mix(1.0, 1.3, smoothstep(0.5, 2.0, spd)); + float bright = mix(4.0, 1.3, smoothstep(0.5, 2.0, spd)); float scroll = mix(0.02, 0.5, smoothstep(0.1, 10.0, spd)); From a53e4066dfdc6f8015c0e6290556f5a2bf84af2b Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 17:18:34 +0000 Subject: [PATCH 10/19] Revert FruitScreensaver.swift to restore multi-screen rendering Reverts all changes to FruitScreensaver.swift that broke multi-monitor support and caused FPS degradation. --- FruitScreensaver/FruitScreensaver.swift | 97 ++++--------------------- 1 file changed, 15 insertions(+), 82 deletions(-) diff --git a/FruitScreensaver/FruitScreensaver.swift b/FruitScreensaver/FruitScreensaver.swift index 349d32b..07d2704 100644 --- a/FruitScreensaver/FruitScreensaver.swift +++ b/FruitScreensaver/FruitScreensaver.swift @@ -20,14 +20,6 @@ final class FruitScreensaver: ScreenSaverView { private var lastFrameTime: TimeInterval? private var lastFps: Int = 60 private var isPaused: Bool = false - private var lameDuck: Bool = false - - // MARK: Preview detection - - // FB7486243: On Sonoma, legacyScreenSaver.appex always passes true for - // isPreview. On Tahoe it is inverted. We detect the real state from the - // frame size — the preview thumbnail is always small (~296x184). - private let actualIsPreview: Bool // MARK: Preferences @@ -37,21 +29,16 @@ final class FruitScreensaver: ScreenSaverView { ) deinit { + // Remove notification observers to prevent memory leaks NotificationCenter.default.removeObserver(self) DistributedNotificationCenter.default.removeObserver(self) } - private static let newInstanceNotification = Notification.Name( - "com.corkscrews.fruit.NewInstance" - ) - override init?(frame: NSRect, isPreview: Bool) { - actualIsPreview = frame.width <= 400 || frame.height <= 300 - super.init(frame: frame, isPreview: actualIsPreview) + super.init(frame: frame, isPreview: isPreview) animationTimeInterval = Constant.secondPerFrame - addNewInstanceObserver() - setupFruitView() - if !actualIsPreview { + setupFruitView(isPreview: isPreview) + if !isPreview { setupMetalView() addScreenDidChangeNotification() } @@ -59,52 +46,20 @@ final class FruitScreensaver: ScreenSaverView { } required init?(coder decoder: NSCoder) { - actualIsPreview = false super.init(coder: decoder) animationTimeInterval = Constant.secondPerFrame - addNewInstanceObserver() - setupFruitView() - if !actualIsPreview { + setupFruitView(isPreview: false) + if !isPreview { setupMetalView() addScreenDidChangeNotification() } addObserverWillStopNotification() } - // FB19204084: legacyScreenSaver.appex creates new ScreenSaverView instances - // on every activation without destroying old ones. Each new instance - // notifies older ones to stop animating and release resources. - private func addNewInstanceObserver() { - NotificationCenter.default.addObserver( - self, - selector: #selector(neuterOldInstance(_:)), - name: Self.newInstanceNotification, - object: nil - ) - NotificationCenter.default.post( - name: Self.newInstanceNotification, - object: self - ) - } - - @objc - private func neuterOldInstance(_ notification: Notification) { - guard let newInstance = notification.object as? FruitScreensaver, - newInstance !== self, - newInstance.actualIsPreview == self.actualIsPreview else { return } - lameDuck = true - isPaused = true - metalView?.isRenderingPaused = true - removeFromSuperview() - // swiftlint:disable:next notification_center_detachment - NotificationCenter.default.removeObserver(self) - DistributedNotificationCenter.default.removeObserver(self) - } - - private func setupFruitView() { + private func setupFruitView(isPreview: Bool) { fruitView = FruitView( frame: self.bounds, - mode: actualIsPreview ? .preview : .default + mode: isPreview ? .preview : .default ) fruitView.autoresizingMask = [.width, .height] fruitView.update(mode: preferencesRepository.defaultFruitMode()) @@ -138,33 +93,14 @@ final class FruitScreensaver: ScreenSaverView { metalView?.frame = self.bounds } - override func startAnimation() { - super.startAnimation() - guard !lameDuck else { return } - - // Flush the ScreenSaverDefaults cache so we pick up preference - // changes made in System Settings while the process was alive. - preferencesRepository.reload() - fruitView.update(mode: preferencesRepository.defaultFruitMode()) - - isPaused = false - metalView?.isRenderingPaused = false - } - - // Only called for the System Settings live preview (broken in Sonoma - // for normal operation), but still worth handling. - override func stopAnimation() { - isPaused = true - metalView?.isRenderingPaused = true - super.stopAnimation() - } - override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + // Pause animations when view is removed from window hierarchy if window == nil { isPaused = true metalView?.isRenderingPaused = true } else { + // Resume animations when added to window isPaused = false metalView?.isRenderingPaused = false } @@ -172,7 +108,8 @@ final class FruitScreensaver: ScreenSaverView { override func animateOneFrame() { super.animateOneFrame() - guard !isPaused, !lameDuck else { return } + // Skip animation if paused to save CPU + guard !isPaused else { return } fruitView.animateOneFrame(framesPerSecond: calculateFps()) } @@ -201,16 +138,12 @@ final class FruitScreensaver: ScreenSaverView { @objc private func willStop(_ aNotification: Notification) { + // Pause all animations to reduce CPU during shutdown isPaused = true metalView?.isRenderingPaused = true - // Delay exit to avoid a race condition with rapid lock/unlock cycles - // that can leave a black screen. Using exit(0) instead of terminate(_:) - // to skip AppKit delegate callbacks inside legacyScreenSaver.appex. - if !actualIsPreview { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - exit(0) - } + if !isPreview { + NSApplication.shared.terminate(nil) } } From 1edef15dc78b6bb9c2c96960231dc3ab50056618 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 28 Feb 2026 17:40:42 +0000 Subject: [PATCH 11/19] Optimize WarpLayer shader: cache uniforms on CPU, reduce GPU ALU Move all speed-dependent smoothstep/mix computations from fragment shader to CPU-side precomputation, passed via expanded uniform buffer. Replace exp() with rational falloff, reduce layers 30->20, add warp_hash2 to halve hash calls, and use Reinhard tonemapping. --- FruitFarm/Backgrounds/Types/WarpLayer.swift | 224 ++++++++++++-------- 1 file changed, 131 insertions(+), 93 deletions(-) diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index 97bf9aa..3ea6d4d 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -25,9 +25,20 @@ vertex VertexOut vertex_shader_warp( struct WarpUniforms { float2 resolution; + float2 uvOffset; float time; float speed; float referenceSize; + float warpFactor; + float streakMix; + float brightScaled; + float tScroll; + float cull; + float dopplerMix; + float beamingMix; + float twinkleSolid; + float glow; + float vigRadius; }; float warp_hash(float2 p) { @@ -36,138 +47,123 @@ float warp_hash(float2 p) { return fract((p3.x + p3.y) * p3.z); } +float2 warp_hash2(float2 p) { + float3 p3 = fract(float3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract(float2((p3.x + p3.y) * p3.z, (p3.x + p3.z) * p3.y)); +} + fragment float4 fragment_shader_warp( VertexOut in [[stage_in]], - constant WarpUniforms &uniforms [[buffer(0)]]) { - - float2 uv = (in.position.xy - uniforms.resolution * 0.5) / - uniforms.referenceSize; + constant WarpUniforms &u [[buffer(0)]]) { - float fruitScale = min( - min(uniforms.resolution.x, uniforms.resolution.y) / uniforms.referenceSize, - 2.0); - uv.y -= fruitScale * 0.04; - uv.x += fruitScale * 0.005; + float2 uv = (in.position.xy - u.resolution * 0.5) / u.referenceSize + u.uvOffset; + float2 warp_uv = uv * u.warpFactor; - float t = uniforms.time; - float spd = uniforms.speed; - - float vib = smoothstep(0.3, 1.5, spd) * (1.0 - smoothstep(8.0, 12.0, spd)); - float vib_strength = 0.001; - float vib_amt = vib * vib_strength; - float2 shake = float2( - sin(t * 130.0) * 0.7 + sin(t * 73.0) * 0.3, - cos(t * 110.0) * 0.6 + cos(t * 89.0) * 0.4 - ); - uv += shake * vib_amt; - - float aberration = 1.0 / (1.0 + spd * 0.04); - float2 warp_uv = uv * mix(1.0, aberration, smoothstep(2.0, 8.0, spd)); - - // 0 = dots only, 1 = full streaks - float streak_mix = smoothstep(0.3, 1.5, spd); - // always visible, brighter at warp - float bright = mix(4.0, 1.3, smoothstep(0.5, 2.0, spd)); - - float scroll = mix(0.02, 0.5, smoothstep(0.1, 10.0, spd)); + bool skip_twinkle = u.twinkleSolid > 0.99; + float t = u.time; float3 col = float3(0.0); - float cull = mix(0.02, 0.15, smoothstep(0.3, 2.0, spd)); - float iter = 30; + constexpr int iter = 20; + constexpr float inv_iter = 1.0 / float(iter); for (int i = 0; i < iter; i++) { float fi = float(i); - float z = fract(fi / iter + t * scroll + warp_hash(float2(fi, fi * 0.7)) * 0.05); + float z = fract(fi * inv_iter + u.tScroll + warp_hash(float2(fi, fi * 0.7)) * 0.05); float scale = mix(18.0, 0.8, z); + float inv_scale = 1.0 / scale; float fade = smoothstep(0.0, 0.1, z) * smoothstep(1.0, 0.8, z); + float ds = 0.0018 + 0.0006 * z; + float inv_ds2 = 1.0 / (ds * ds); + float fi64 = fi * 64.0; + float fi27 = fi * 27.0; float2 st = warp_uv * scale; float2 gid = floor(st); - float2 gf = fract(st) - 0.5; for (int dy = -1; dy <= 1; dy++) { for (int dx = -1; dx <= 1; dx++) { float2 off = float2(float(dx), float(dy)); float2 id = gid + off; - float h1 = warp_hash(id + fi * 64.0); - if (h1 < cull) continue; + float h1 = warp_hash(id + fi64); + if (h1 < u.cull) continue; + + float2 sp = warp_hash2(id * 3.14 + fi27) - 0.5; - float rx = warp_hash(id * 3.14 + fi * 27.0); - float ry = warp_hash(id * 2.71 + fi * 43.0); - float2 sp = float2(rx, ry) - 0.5; + float2 ss = (id + sp + 0.5) * inv_scale; + float2 delta = warp_uv - ss; - // Particle screen position - float2 ss = (gid + off + sp + 0.5) / scale; - float sr = length(ss); + float sr2 = dot(ss, ss); + float dd2 = dot(delta, delta); - // --- Dot: tight gaussian, always visible --- - float dd = length(warp_uv - ss); - float ds = 0.0018 + 0.0006 * z; - float pb = exp(-dd * dd / (ds * ds)); + float ratio = dd2 * inv_ds2; + float pb = 1.0 / (1.0 + ratio * ratio); + + float inv_sr = rsqrt(max(sr2, 1e-6)); + float sr = sr2 * inv_sr; - // --- Streak: line segment toward center, warp only --- float sb = 0.0; - if (streak_mix > 0.01) { - float slen = spd * sr * 0.15; - float2 sdir = sr > 0.001 ? normalize(ss) : float2(0.0); - float2 sa = ss; - float2 sba = -sdir * slen; + if (u.streakMix > 0.01) { + float slen = u.speed * sr * 0.15; + float2 sba = ss * (-inv_sr * slen); float sba2 = dot(sba, sba); - float2 pa = warp_uv - sa; float tp = sba2 > 0.0001 - ? clamp(dot(pa, sba) / sba2, 0.0, 1.0) : 0.0; - float seg_d = length(pa - sba * tp); + ? clamp(dot(delta, sba) / sba2, 0.0, 1.0) : 0.0; + float2 seg_v = delta - sba * tp; float sw = 0.001 * (1.0 + tp * 0.8); - sb = exp(-seg_d * seg_d / (sw * sw)) - * streak_mix * (1.0 - tp * 0.6); + float sratio = dot(seg_v, seg_v) / (sw * sw); + sb = u.streakMix * (1.0 - tp * 0.6) + / (1.0 + sratio * sratio); } - float b = max(pb, sb) * fade; - b *= smoothstep(0.15, 0.8, h1); + float b = max(pb, sb) * fade * smoothstep(0.15, 0.8, h1); - // Twinkle at low speed, solid at warp - float tw = mix( - 0.5 + 0.5 * sin(t * 3.0 + warp_hash(id * 17.0) * 6.283), - 1.0, - smoothstep(0.3, 1.0, spd) - ); - b *= tw; + if (!skip_twinkle) { + b *= mix( + 0.5 + 0.5 * sin(t * 3.0 + warp_hash(id * 17.0) * 6.283), + 1.0, + u.twinkleSolid + ); + } - float beaming = mix(1.0, 1.0 / (1.0 + sr * 4.0), smoothstep(2.0, 8.0, spd)); - b *= beaming; + b *= mix(1.0, 1.0 / (1.0 + sr * 4.0), u.beamingMix); - float cr = warp_hash(id * 7.77 + fi); - float3 sc = mix( - float3(0.5, 0.6, 1.0), - float3(0.95, 0.97, 1.0), - smoothstep(0.3, 0.8, cr) + float cr = fract(sp.x * 7.77 + 0.5); + float cr_s1 = smoothstep(0.3, 0.8, cr); + float3 sc = float3( + 0.5 + 0.45 * cr_s1, + 0.6 + 0.37 * cr_s1, + 1.0 ); - sc = mix(sc, float3(1.0, 0.9, 0.75), - smoothstep(0.85, 0.95, cr)); + sc = mix(sc, float3(1.0, 0.9, 0.75), smoothstep(0.85, 0.95, cr)); - float doppler_mix = smoothstep(3.0, 8.0, spd); - float3 blue_tint = float3(0.7, 0.8, 1.0); - float3 red_tint = float3(1.0, 0.75, 0.5); - float radial_factor = smoothstep(0.0, 0.6, sr); - sc *= mix(float3(1.0), mix(blue_tint, red_tint, radial_factor), doppler_mix); + if (u.dopplerMix > 0.01) { + float radial_factor = smoothstep(0.0, 0.6, sr); + sc *= mix(float3(1.0), + mix(float3(0.7, 0.8, 1.0), float3(1.0, 0.75, 0.5), radial_factor), + u.dopplerMix); + } - col += sc * b * bright * 0.15; + col += sc * (b * u.brightScaled); } } } - float dist = length(uv); + float dist2 = dot(uv, uv); + float dist = sqrt(dist2); + + if (u.glow > 0.001) { + col += float3(0.15, 0.2, 0.45) * exp(-dist * 5.0) * u.glow; + col += float3(0.3, 0.4, 0.65) * exp(-dist * 14.0) * u.glow * 0.5; + col += float3(0.6, 0.65, 0.8) * exp(-dist * 30.0) * u.glow * 0.3; + } - float gf2 = smoothstep(7.0, 12.0, spd) * 0.5; - col += float3(0.15, 0.2, 0.45) * exp(-dist * 5.0) * gf2; - col += float3(0.3, 0.4, 0.65) * exp(-dist * 14.0) * gf2 * 0.5; - col += float3(0.6, 0.65, 0.8) * exp(-dist * 30.0) * gf2 * 0.3; + col *= smoothstep(u.vigRadius, 0.5, dist); - float vig_radius = mix(2.0, 1.2, smoothstep(3.0, 10.0, spd)); - col *= smoothstep(vig_radius, 0.5, dist); - col = pow(1.0 - exp(-col * 3.5), float3(0.9)); + float3 mapped = col * 3.5; + col = mapped / (1.0 + mapped); return float4(col, 1.0); } @@ -175,9 +171,20 @@ fragment float4 fragment_shader_warp( private struct MetalWarpFragmentUniforms { var resolution: SIMD2 + var uvOffset: SIMD2 var time: Float var speed: Float var referenceSize: Float + var warpFactor: Float + var streakMix: Float + var brightScaled: Float + var tScroll: Float + var cull: Float + var dopplerMix: Float + var beamingMix: Float + var twinkleSolid: Float + var glow: Float + var vigRadius: Float } // swiftlint:disable:next type_body_length @@ -344,6 +351,11 @@ final class WarpLayer: CAMetalLayer, Background { } } + private static func glslSmoothstep(_ edge0: Float, _ edge1: Float, _ x: Float) -> Float { + let t = min(max((x - edge0) / (edge1 - edge0), 0), 1) + return t * t * (3 - 2 * t) + } + // MARK: - Drawing override func display() { guard let pipelineState = pipelineState, @@ -352,11 +364,37 @@ final class WarpLayer: CAMetalLayer, Background { let drawable = nextDrawable() else { return } let texture = drawable.texture + let spd = Float(currentSpeed) + let time = Float(totalElapsedTime) + let referenceSize: Float = 300.0 * Float(contentsScale) + let resW = Float(texture.width) + let resH = Float(texture.height) + + let ss = Self.glslSmoothstep + let fruitScale = min(min(resW, resH) / referenceSize, 2.0) + let vib = ss(0.3, 1.5, spd) * (1.0 - ss(8.0, 12.0, spd)) + let aberration = 1.0 / (1.0 + spd * 0.04) + let scroll = 0.02 + (0.5 - 0.02) * ss(0.1, 10.0, spd) + var uniforms = MetalWarpFragmentUniforms( - resolution: SIMD2(Float(texture.width), Float(texture.height)), - time: Float(totalElapsedTime), - speed: Float(currentSpeed), - referenceSize: 300.0 * Float(contentsScale) + resolution: SIMD2(resW, resH), + uvOffset: SIMD2( + fruitScale * 0.005 + sin(time * 130.0) * vib * 0.001, + -fruitScale * 0.04 + cos(time * 110.0) * vib * 0.001 + ), + time: time, + speed: spd, + referenceSize: referenceSize, + warpFactor: 1.0 + (aberration - 1.0) * ss(2.0, 8.0, spd), + streakMix: ss(0.3, 1.5, spd), + brightScaled: (4.0 + (1.3 - 4.0) * ss(0.5, 2.0, spd)) * 0.225, + tScroll: time * scroll, + cull: 0.02 + (0.15 - 0.02) * ss(0.3, 2.0, spd), + dopplerMix: ss(3.0, 8.0, spd), + beamingMix: ss(2.0, 8.0, spd), + twinkleSolid: ss(0.3, 1.0, spd), + glow: ss(7.0, 12.0, spd) * 0.5, + vigRadius: 2.0 + (1.2 - 2.0) * ss(3.0, 10.0, spd) ) let renderPassDescriptor = MTLRenderPassDescriptor() From 9ccb94df01ffee81953073ea559acd94af1913a7 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sun, 1 Mar 2026 14:45:34 +0000 Subject: [PATCH 12/19] Fix diagonal line artifact in warp shader and add early brightness exit Use per-component hash multipliers to prevent warp_hash2 from producing identical x/y outputs when input has x==y, which caused stars to align on the diagonal. Also skip twinkle/color work for negligible-brightness stars. --- FruitFarm/Backgrounds/Types/WarpLayer.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index 3ea6d4d..83e02ff 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -42,13 +42,13 @@ struct WarpUniforms { }; float warp_hash(float2 p) { - float3 p3 = fract(float3(p.xyx) * 0.1031); + float3 p3 = fract(float3(p.xyx) * float3(0.1031, 0.1030, 0.0973)); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } float2 warp_hash2(float2 p) { - float3 p3 = fract(float3(p.xyx) * 0.1031); + float3 p3 = fract(float3(p.xyx) * float3(0.1031, 0.1030, 0.0973)); p3 += dot(p3, p3.yzx + 33.33); return fract(float2((p3.x + p3.y) * p3.z, (p3.x + p3.z) * p3.y)); } @@ -119,6 +119,7 @@ fragment float4 fragment_shader_warp( } float b = max(pb, sb) * fade * smoothstep(0.15, 0.8, h1); + if (b < 0.0001) continue; // quartic falloff makes distant stars negligible; skip twinkle/color work if (!skip_twinkle) { b *= mix( From 222413f40d413d2ea90f16e3d4bef7ca9dd8956a Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sun, 1 Mar 2026 15:25:59 +0000 Subject: [PATCH 13/19] Add scissor rect rendering optimization to all Metal background layers Constrain fragment shader execution to the fruit's bounding area across all layer types (Warp, Puppy, Psy, Liquid, CircularGradient, LinearGradient, Solid), skipping GPU work for pixels outside the visible fruit region. WarpLayer also gains CPU-side pre-computation of per-layer constants. --- .../Types/CircularGradientLayer.swift | 18 +++ .../Types/LinearGradientLayer.swift | 19 ++- FruitFarm/Backgrounds/Types/LiquidLayer.swift | 18 +++ FruitFarm/Backgrounds/Types/PsyLayer.swift | 18 +++ FruitFarm/Backgrounds/Types/PuppyLayer.swift | 18 +++ FruitFarm/Backgrounds/Types/SolidLayer.swift | 21 ++- FruitFarm/Backgrounds/Types/WarpLayer.swift | 134 +++++++++++++----- 7 files changed, 210 insertions(+), 36 deletions(-) diff --git a/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift index 58453be..1892f74 100644 --- a/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift +++ b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift @@ -268,14 +268,18 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit setFrameAndDrawableSizeWithoutAnimation(frame) self.currentFruitMaxDimension = fruit.maxDimen() setNeedsDisplay() } func config(fruit: Fruit) { + currentFruit = fruit self.currentFruitMaxDimension = fruit.maxDimen() setNeedsDisplay() } @@ -319,6 +323,20 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { } configureRenderEncoder(renderEncoder, uniforms: &uniforms) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) renderEncoder.endEncoding() diff --git a/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift index 3545a86..011a365 100644 --- a/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift +++ b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift @@ -260,14 +260,17 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } func config(fruit: Fruit) { - // Fruit parameter is not directly used by MetalLinearGradientLayer's appearance + currentFruit = fruit setNeedsDisplay() } @@ -313,6 +316,20 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { } configureRenderEncoder(renderEncoder, uniforms: &uniforms) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) renderEncoder.endEncoding() diff --git a/FruitFarm/Backgrounds/Types/LiquidLayer.swift b/FruitFarm/Backgrounds/Types/LiquidLayer.swift index 9fbe219..99bb359 100644 --- a/FruitFarm/Backgrounds/Types/LiquidLayer.swift +++ b/FruitFarm/Backgrounds/Types/LiquidLayer.swift @@ -220,13 +220,17 @@ final class LiquidLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } func config(fruit: Fruit) { + currentFruit = fruit setNeedsDisplay() } @@ -270,6 +274,20 @@ final class LiquidLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) renderEncoder.setFragmentBytes( &uniforms, diff --git a/FruitFarm/Backgrounds/Types/PsyLayer.swift b/FruitFarm/Backgrounds/Types/PsyLayer.swift index d41fabc..7a0592e 100644 --- a/FruitFarm/Backgrounds/Types/PsyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -252,13 +252,17 @@ final class PsyLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } func config(fruit: Fruit) { + currentFruit = fruit setNeedsDisplay() } @@ -328,6 +332,20 @@ final class PsyLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) renderEncoder.setFragmentBytes( &uniforms, diff --git a/FruitFarm/Backgrounds/Types/PuppyLayer.swift b/FruitFarm/Backgrounds/Types/PuppyLayer.swift index 9d9ba16..e692171 100644 --- a/FruitFarm/Backgrounds/Types/PuppyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PuppyLayer.swift @@ -237,13 +237,17 @@ final class PuppyLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } func config(fruit: Fruit) { + currentFruit = fruit setNeedsDisplay() } @@ -285,6 +289,20 @@ final class PuppyLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) renderEncoder.setFragmentBytes( &uniforms, diff --git a/FruitFarm/Backgrounds/Types/SolidLayer.swift b/FruitFarm/Backgrounds/Types/SolidLayer.swift index f461ea1..8fa8130 100644 --- a/FruitFarm/Backgrounds/Types/SolidLayer.swift +++ b/FruitFarm/Backgrounds/Types/SolidLayer.swift @@ -140,14 +140,17 @@ final class MetalSolidLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } func config(fruit: Fruit) { - // Fruit parameter is not used by MetalSolidLayer's appearance + currentFruit = fruit setNeedsDisplay() } @@ -193,10 +196,24 @@ final class MetalSolidLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) renderEncoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0) - renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) // Draw a full-screen quad + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) renderEncoder.endEncoding() commandBuffer.present(drawable) diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index 83e02ff..d9df21b 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -41,6 +41,15 @@ struct WarpUniforms { float vigRadius; }; +struct WarpLayerData { + float scale; + float inv_scale; + float fade; + float inv_ds2; + float fi64; + float fi27; +}; + float warp_hash(float2 p) { float3 p3 = fract(float3(p.xyx) * float3(0.1031, 0.1030, 0.0973)); p3 += dot(p3, p3.yzx + 33.33); @@ -55,31 +64,23 @@ float2 warp_hash2(float2 p) { fragment float4 fragment_shader_warp( VertexOut in [[stage_in]], - constant WarpUniforms &u [[buffer(0)]]) { + constant WarpUniforms &u [[buffer(0)]], + constant WarpLayerData *layers [[buffer(1)]]) { float2 uv = (in.position.xy - u.resolution * 0.5) / u.referenceSize + u.uvOffset; float2 warp_uv = uv * u.warpFactor; bool skip_twinkle = u.twinkleSolid > 0.99; float t = u.time; + float streak_k = -u.speed * 0.15; float3 col = float3(0.0); - constexpr int iter = 20; - constexpr float inv_iter = 1.0 / float(iter); - - for (int i = 0; i < iter; i++) { - float fi = float(i); - float z = fract(fi * inv_iter + u.tScroll + warp_hash(float2(fi, fi * 0.7)) * 0.05); - float scale = mix(18.0, 0.8, z); - float inv_scale = 1.0 / scale; - float fade = smoothstep(0.0, 0.1, z) * smoothstep(1.0, 0.8, z); - float ds = 0.0018 + 0.0006 * z; - float inv_ds2 = 1.0 / (ds * ds); - float fi64 = fi * 64.0; - float fi27 = fi * 27.0; - - float2 st = warp_uv * scale; + for (int i = 0; i < 20; i++) { + constant WarpLayerData &ld = layers[i]; + if (ld.fade < 0.001) continue; + + float2 st = warp_uv * ld.scale; float2 gid = floor(st); for (int dy = -1; dy <= 1; dy++) { @@ -87,27 +88,24 @@ fragment float4 fragment_shader_warp( float2 off = float2(float(dx), float(dy)); float2 id = gid + off; - float h1 = warp_hash(id + fi64); + float h1 = warp_hash(id + ld.fi64); if (h1 < u.cull) continue; - float2 sp = warp_hash2(id * 3.14 + fi27) - 0.5; + float2 sp = warp_hash2(id * 3.14 + ld.fi27) - 0.5; - float2 ss = (id + sp + 0.5) * inv_scale; + float2 ss = (id + sp + 0.5) * ld.inv_scale; float2 delta = warp_uv - ss; float sr2 = dot(ss, ss); float dd2 = dot(delta, delta); - float ratio = dd2 * inv_ds2; + float ratio = dd2 * ld.inv_ds2; float pb = 1.0 / (1.0 + ratio * ratio); - float inv_sr = rsqrt(max(sr2, 1e-6)); - float sr = sr2 * inv_sr; - float sb = 0.0; if (u.streakMix > 0.01) { - float slen = u.speed * sr * 0.15; - float2 sba = ss * (-inv_sr * slen); + // inv_sr * sr cancels to 1, so sba = ss * (-speed * 0.15) + float2 sba = ss * streak_k; float sba2 = dot(sba, sba); float tp = sba2 > 0.0001 ? clamp(dot(delta, sba) / sba2, 0.0, 1.0) : 0.0; @@ -118,8 +116,11 @@ fragment float4 fragment_shader_warp( / (1.0 + sratio * sratio); } - float b = max(pb, sb) * fade * smoothstep(0.15, 0.8, h1); - if (b < 0.0001) continue; // quartic falloff makes distant stars negligible; skip twinkle/color work + float b = max(pb, sb) * ld.fade * smoothstep(0.15, 0.8, h1); + if (b < 0.0001) continue; // quartic falloff makes distant stars negligible + + float inv_sr = rsqrt(max(sr2, 1e-6)); + float sr = sr2 * inv_sr; if (!skip_twinkle) { b *= mix( @@ -188,6 +189,15 @@ private struct MetalWarpFragmentUniforms { var vigRadius: Float } +private struct MetalWarpLayerData { + var scale: Float + var inv_scale: Float + var fade: Float + var inv_ds2: Float + var fi64: Float + var fi27: Float +} + // swiftlint:disable:next type_body_length final class WarpLayer: CAMetalLayer, Background { @@ -202,6 +212,9 @@ final class WarpLayer: CAMetalLayer, Background { private var lastUpdateTime: CGFloat = 0 private let minUpdateInterval: CGFloat = 1.0 / 30.0 + // MARK: - Rendering Area + private weak var currentFruit: Fruit? + // MARK: - Speed Variation private var currentSpeed: CGFloat = 0.1 private var startSpeed: CGFloat = 0.1 @@ -294,11 +307,13 @@ final class WarpLayer: CAMetalLayer, Background { // MARK: - Background Protocol func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } func config(fruit: Fruit) { + currentFruit = fruit setNeedsDisplay() } @@ -307,10 +322,9 @@ final class WarpLayer: CAMetalLayer, Background { let t2 = t * 2.0 if t2 < 1.0 { return 0.5 * (t2 * t2 * ((s + 1.0) * t2 - s)) - } else { - let p = t2 - 2.0 - return 0.5 * (p * p * ((s + 1.0) * p + s) + 2.0) } + let p = t2 - 2.0 + return 0.5 * (p * p * ((s + 1.0) * p + s) + 2.0) } private func easeWarpDisengage(_ t: CGFloat) -> CGFloat { @@ -318,10 +332,9 @@ final class WarpLayer: CAMetalLayer, Background { let t2 = t * 2.0 if t2 < 1.0 { return 0.5 * (t2 * t2 * ((s + 1.0) * t2 - s)) - } else { - let p = t2 - 2.0 - return 0.5 * (p * p * ((s + 1.0) * p + s) + 2.0) } + let p = t2 - 2.0 + return 0.5 * (p * p * ((s + 1.0) * p + s) + 2.0) } func update(deltaTime: CGFloat) { @@ -357,6 +370,20 @@ final class WarpLayer: CAMetalLayer, Background { return t * t * (3 - 2 * t) } + private static func glslFract(_ x: Float) -> Float { + return x - floor(x) + } + + private static func warpHash(_ p: SIMD2) -> Float { + let px = p.x, py = p.y + var p3x = glslFract(px * 0.1031) + var p3y = glslFract(py * 0.1030) + var p3z = glslFract(px * 0.0973) + let d = p3x * (p3y + 33.33) + p3y * (p3z + 33.33) + p3z * (p3x + 33.33) + p3x += d; p3y += d; p3z += d + return glslFract((p3x + p3y) * p3z) + } + // MARK: - Drawing override func display() { guard let pipelineState = pipelineState, @@ -398,6 +425,26 @@ final class WarpLayer: CAMetalLayer, Background { vigRadius: 2.0 + (1.2 - 2.0) * ss(3.0, 10.0, spd) ) + var layerData = [MetalWarpLayerData]() + layerData.reserveCapacity(20) + for i in 0..<20 { + let fi = Float(i) + let z = Self.glslFract( + fi / 20.0 + uniforms.tScroll + Self.warpHash(SIMD2(fi, fi * 0.7)) * 0.05 + ) + let scale = 18.0 + (0.8 - 18.0) * z + let fade = ss(0.0, 0.1, z) * ss(1.0, 0.8, z) + let ds: Float = 0.0018 + 0.0006 * z + layerData.append(MetalWarpLayerData( + scale: scale, + inv_scale: 1.0 / scale, + fade: fade, + inv_ds2: 1.0 / (ds * ds), + fi64: fi * 64.0, + fi27: fi * 27.0 + )) + } + let renderPassDescriptor = MTLRenderPassDescriptor() renderPassDescriptor.colorAttachments[0].texture = texture renderPassDescriptor.colorAttachments[0].loadAction = .clear @@ -413,12 +460,33 @@ final class WarpLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), Int(resW) - sx) + let sh = min(Int(fb.height * cs), Int(resH) - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) renderEncoder.setFragmentBytes( &uniforms, length: MemoryLayout.stride, index: 0 ) + layerData.withUnsafeBytes { ptr in + renderEncoder.setFragmentBytes( + ptr.baseAddress!, + length: ptr.count, + index: 1 + ) + } renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) renderEncoder.endEncoding() From 42461fcf23497bc8f6d67722732c06cb0d81a32b Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sun, 1 Mar 2026 16:09:19 +0000 Subject: [PATCH 14/19] Add Irish Ocean background layer with Metal shader Dark North Atlantic ocean simulation featuring rolling wave fronts, deep Titanic-style blue palette, foam and spray on crests, and visible surface current swirls via chained FBM. --- CHANGELOG.md | 6 + FruitFarm/Backgrounds/FruitTypes.swift | 1 + FruitFarm/Backgrounds/Types/OceanLayer.swift | 335 ++++++++++++++++++ FruitFarm/FruitView.swift | 2 + .../PreferencesViewController.swift | 2 + README.md | 1 + 6 files changed, 347 insertions(+) create mode 100644 FruitFarm/Backgrounds/Types/OceanLayer.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bc764e8..99d3a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ### Features - Added Psychedelic and Liquid background layers with Metal shaders. + - Added Irish Ocean background — a Metal shader simulating a dark, moody North Atlantic ocean featuring: + - Rolling wave fronts moving top-to-bottom with natural wobble. + - Deep Titanic-style blue palette from near-black troughs to steel-blue crests. + - Foam and wind-blown spray on steep wave crests. + - Visible surface current swirls via chained FBM domain warping. + - Overcast specular glints and atmospheric vignette. - Added PuppyLayer with animated Fruit logo tunnel zoom effect. - Added Warp Speed background — the most ambitious and complex rendering in this project. A Metal shader simulating relativistic travel through a star field, featuring: - Relativistic aberration (stars compress toward the center at speed). diff --git a/FruitFarm/Backgrounds/FruitTypes.swift b/FruitFarm/Backgrounds/FruitTypes.swift index 3a96cfd..ec55d4a 100644 --- a/FruitFarm/Backgrounds/FruitTypes.swift +++ b/FruitFarm/Backgrounds/FruitTypes.swift @@ -14,4 +14,5 @@ public enum FruitType: String, CaseIterable { case liquid case puppy case warp + case ocean } diff --git a/FruitFarm/Backgrounds/Types/OceanLayer.swift b/FruitFarm/Backgrounds/Types/OceanLayer.swift new file mode 100644 index 0000000..77a6285 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/OceanLayer.swift @@ -0,0 +1,335 @@ +// swiftlint:disable file_length +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalOceanShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_ocean( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct OceanUniforms { + float2 resolution; + float time; +}; + +float ocean_hash(float2 p) { + float3 p3 = fract(float3(p.xyx) * float3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float ocean_noise(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + float2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(ocean_hash(i), ocean_hash(i + float2(1.0, 0.0)), u.x), + mix(ocean_hash(i + float2(0.0, 1.0)), ocean_hash(i + float2(1.0, 1.0)), u.x), + u.y + ); +} + +float ocean_fbm(float2 p, int octaves) { + float v = 0.0; + float a = 0.5; + float2x2 rot = float2x2(0.8, 0.6, -0.6, 0.8); + for (int i = 0; i < octaves; i++) { + v += a * ocean_noise(p); + p = rot * p * 2.01; + a *= 0.49; + } + return v; +} + +float ocean_wave_height(float2 uv, float t) { + // Slight horizontal wobble so wave fronts aren't ruler-straight + float wobble = ocean_noise(float2(uv.x * 0.5, uv.y * 0.3) + t * 0.05) * 0.6; + + // Primary rolling wave fronts — horizontal lines moving top-to-bottom + float waves = sin(uv.y * 1.8 + wobble + t * 0.35) * 0.38; + waves += sin(uv.y * 3.2 + wobble * 0.7 + t * 0.28) * 0.18; + + // Gentle cross-variation so it's not perfectly uniform across X + waves += sin(uv.x * 0.3 + uv.y * 2.4 + t * 0.22) * 0.08; + + // Surface texture — moderate chop riding on the wave fronts + float chop = ocean_fbm(uv * 4.0 + float2(t * 0.15, t * 0.25), 5) * 0.15; + + // Fine detail + float detail = ocean_fbm(uv * 9.0 + float2(t * 0.25, -t * 0.20), 4) * 0.06; + + return waves + chop + detail; +} + +// Visible surface current swirls — sampled independently from the wave field +float ocean_current_pattern(float2 uv, float t) { + float2 p1 = float2( + ocean_fbm(uv * 1.8 + float2(t * 0.12, t * 0.08), 5), + ocean_fbm(uv * 1.8 + float2(t * 0.09, -t * 0.11) + 7.3, 5) + ); + float2 p2 = float2( + ocean_fbm((uv + p1 * 1.2) * 1.6 + float2(t * 0.07, t * 0.05) + 3.1, 5), + ocean_fbm((uv + p1 * 1.2) * 1.6 + float2(-t * 0.06, t * 0.08) + 11.7, 5) + ); + return ocean_fbm(uv + p2 * 1.4, 5); +} + +fragment float4 fragment_shader_ocean( + VertexOut in [[stage_in]], + constant OceanUniforms &uniforms [[buffer(0)]]) { + + float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / + min(uniforms.resolution.x, uniforms.resolution.y); + float t = uniforms.time; + + float2 oceanUV = uv * 2.0; + + float height = ocean_wave_height(oceanUV, t); + + // Finite-difference gradient for slope / foam detection + float e = 0.015; + float hx = ocean_wave_height(oceanUV + float2(e, 0.0), t); + float hy = ocean_wave_height(oceanUV + float2(0.0, e), t); + float slope = length(float2(hx - height, hy - height) / e); + + // ---- Deep North Atlantic blue (Titanic-style) ---- + float3 abyssColor = float3(0.01, 0.02, 0.06); + float3 deepColor = float3(0.02, 0.04, 0.10); + float3 troughColor = float3(0.03, 0.06, 0.14); + float3 bodyColor = float3(0.05, 0.09, 0.20); + float3 faceColor = float3(0.07, 0.13, 0.27); + float3 crestColor = float3(0.10, 0.18, 0.34); + float3 foamColor = float3(0.50, 0.54, 0.58); + float3 sprayColor = float3(0.65, 0.68, 0.72); + + // Height-based colour mapping + float h = smoothstep(-0.6, 0.8, height); + float3 color = mix(abyssColor, deepColor, smoothstep(0.00, 0.15, h)); + color = mix(color, troughColor, smoothstep(0.15, 0.30, h)); + color = mix(color, bodyColor, smoothstep(0.30, 0.50, h)); + color = mix(color, faceColor, smoothstep(0.50, 0.70, h)); + color = mix(color, crestColor, smoothstep(0.70, 0.90, h)); + + // Subsurface glow in mid-wave translucency + float subsurface = smoothstep(0.3, 0.7, h) * (1.0 - smoothstep(0.7, 1.0, h)); + color += float3(0.005, 0.008, 0.020) * subsurface; + + // Visible surface current swirls — lighter/darker eddies flowing across the water + float current = ocean_current_pattern(oceanUV, t); + float currentShift = (current - 0.5) * 0.12; + color += color * currentShift; + + // Foam on steep wave crests + float foamMask = smoothstep(0.6, 1.0, height * 0.7 + slope * 0.25); + float foamDetail = ocean_fbm(oceanUV * 18.0 + float2(t * 0.35, t * 0.2), 5); + foamMask *= smoothstep(0.25, 0.7, foamDetail); + color = mix(color, foamColor, foamMask * 0.55); + + // Wind-blown spray tearing off the highest crests + float spray = smoothstep(0.85, 1.0, height * 0.6 + slope * 0.4); + spray *= ocean_fbm(oceanUV * 30.0 + float2(t * 0.8, t * 0.1), 4); + spray = smoothstep(0.4, 0.9, spray); + color = mix(color, sprayColor, spray * 0.35); + + // Specular glint from an overcast sky + float spec = pow(clamp(slope * 0.4, 0.0, 1.0), 4.0) * 0.10; + color += float3(0.06, 0.08, 0.12) * spec; + + // Deepen the troughs + float troughDark = 1.0 - smoothstep(-0.4, 0.1, height); + color *= 1.0 - troughDark * 0.3; + + // Vignette + float vignette = 1.0 - smoothstep(0.8, 2.0, length(uv)); + color *= mix(0.35, 1.0, vignette); + + return float4(color, 1.0); +} +""" + +private struct MetalOceanFragmentUniforms { + var resolution: SIMD2 + var time: Float +} + +final class OceanLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? OceanLayer else { return } + self.totalElapsedTime = other.totalElapsedTime + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalOceanShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_ocean"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_ocean") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + private weak var currentFruit: Fruit? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit) { + currentFruit = fruit + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + var uniforms = MetalOceanFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + time: Float(totalElapsedTime) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/FruitView.swift b/FruitFarm/FruitView.swift index 72d14c2..002576b 100644 --- a/FruitFarm/FruitView.swift +++ b/FruitFarm/FruitView.swift @@ -214,6 +214,8 @@ public final class FruitView: NSView { return PuppyLayer(frame: self.frame, fruit: fruit, contentsScale: scale) case .warp: return WarpLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .ocean: + return OceanLayer(frame: self.frame, fruit: fruit, contentsScale: scale) } } diff --git a/FruitScreensaver/Preferences/PreferencesViewController.swift b/FruitScreensaver/Preferences/PreferencesViewController.swift index de579b2..2f76658 100644 --- a/FruitScreensaver/Preferences/PreferencesViewController.swift +++ b/FruitScreensaver/Preferences/PreferencesViewController.swift @@ -399,6 +399,8 @@ final class PreferencesControlsView: NSView { return "Puppy" case .warp: return "Warp Speed" + case .ocean: + return "Irish Ocean" } }) return items diff --git a/README.md b/README.md index ce5339e..3edf286 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Now you can select from multiple fruit types and backgrounds: - Liquid - Puppy (Fruit logo tunnel zoom) - Warp Speed (relativistic star field simulation) + - Irish Ocean (dark North Atlantic ocean simulation) *You are welcome to create new designs through PRs!* From 13ada259ea31441059d6119a2f3ec14d4609c587 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sun, 1 Mar 2026 21:58:06 +0000 Subject: [PATCH 15/19] Add debug stats overlay (FPS, CPU, GPU) and fix Metal layer init copies Add DebugStatsView with CATextLayer-based rendering for FPS, per-process CPU usage (Mach thread info), and system GPU utilization (IOKit). Wired into both FruitScreensaver and PreferencesViewController behind a showDebugStats toggle. Fix missing Metal resource copies in CircularGradientLayer, LinearGradientLayer, and SolidLayer init(layer:). Update multiscreen lame-duck docs with window-identity safe variant. --- Docs/Bug-Report.md | 2 +- Docs/macOS-Sonoma-Screensaver-Issue.md | 50 ++++++- Fruit.xcodeproj/project.pbxproj | 2 + .../Types/CircularGradientLayer.swift | 6 + .../Types/LinearGradientLayer.swift | 6 + FruitFarm/Backgrounds/Types/SolidLayer.swift | 4 + FruitScreensaver/DebugStatsView.swift | 132 ++++++++++++++++++ FruitScreensaver/FruitScreensaver.swift | 41 +++++- .../PreferencesViewController.swift | 38 +++++ 9 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 FruitScreensaver/DebugStatsView.swift diff --git a/Docs/Bug-Report.md b/Docs/Bug-Report.md index 193f91e..bd280ef 100644 --- a/Docs/Bug-Report.md +++ b/Docs/Bug-Report.md @@ -6,7 +6,7 @@ Code review of the Fruit screensaver codebase. Findings are grouped by severity. ## Critical -### 1. Layer leak in `FruitView.setupLayersOrUpdate()` -- FIXED +### 1. Layer leak in `FruitView.setupLayersOrUpdate()` **File:** `FruitFarm/FruitView.swift` diff --git a/Docs/macOS-Sonoma-Screensaver-Issue.md b/Docs/macOS-Sonoma-Screensaver-Issue.md index 50014d5..4b7c3c4 100644 --- a/Docs/macOS-Sonoma-Screensaver-Issue.md +++ b/Docs/macOS-Sonoma-Screensaver-Issue.md @@ -193,7 +193,45 @@ activation will create a new `ScreenSaverView` while the old one is still animating. This is confirmed as a major ongoing bug (FB19204084). A more robust approach is to broadcast a local notification from each new -instance and have older instances mark themselves as lame-duck: +instance and have older instances mark themselves as lame-duck. However, the +naive version of this pattern **breaks multiscreen support**. + +##### Multiscreen caveat + +macOS creates one `ScreenSaverView` instance per screen. On a dual-monitor +setup, two `FruitScreensaver` instances are created in rapid succession inside +the same `legacyScreenSaver.appex` process. A naive lame-duck pattern that +neuters any instance that is not `self` cannot distinguish between: + +- A **zombie instance** from a previous activation (should be neutered) +- A **sibling instance** for a different screen in the same activation (should + NOT be neutered) + +The following naive implementation would cause only the last screen to render, +leaving all other screens black: + +```swift +// WARNING: Breaks multiscreen -- do NOT use as-is. +@objc func neuter(_ notification: Notification) { + guard notification.object as? FruitScreensaver !== self else { return } + lameDuck = true + // ... +} +``` + +On a 2-monitor setup the sequence is: + +1. Screen 1 instance created, posts notification -- no observers yet. +2. Screen 1 subscribes. +3. Screen 2 instance created, posts notification. +4. Screen 1 observer fires (`object !== self`), **neuters screen 1**. +5. Only screen 2 survives. + +##### Multiscreen-safe lame-duck via window identity + +Each `ScreenSaverView` is placed in a separate window for its screen. The fix +is to only neuter a previous instance if the new one shares the **same +window** -- meaning macOS replaced the view on the same screen: ```swift static let newInstanceNotification = Notification.Name("com.fruit.NewInstance") @@ -212,7 +250,9 @@ private func setup() { } @objc func neuter(_ notification: Notification) { - guard notification.object as? FruitScreensaver !== self else { return } + guard let other = notification.object as? FruitScreensaver, + other !== self, + other.window == self.window else { return } lameDuck = true isPaused = true metalView?.isRenderingPaused = true @@ -222,6 +262,10 @@ private func setup() { } ``` +Two instances on different screens coexist, but if macOS creates a new +instance for the same screen (the zombie-stacking bug), the old one is +correctly neutered. + Then guard in `animateOneFrame`: ```swift @@ -367,7 +411,7 @@ override func display() { |----------|-------|-----| | **Critical** | `isPreview` always `true` on Sonoma | Detect real state from frame size | | **Critical** | Immediate `terminate` race condition | Use `exit(0)` with 2s delay | -| **High** | No multi-instance handling | Lame-duck pattern via local notification | +| **High** | No multi-instance handling | Lame-duck pattern via window identity (naive version breaks multiscreen) | | **High** | Preferences never refreshed | Re-read in `startAnimation`, call `synchronize()` | | **Medium** | Missing `startAnimation`/`stopAnimation` | Override both for lifecycle management | | **Medium** | `fatalError` in Metal setup | Graceful fallback to `RainbowsLayer` | diff --git a/Fruit.xcodeproj/project.pbxproj b/Fruit.xcodeproj/project.pbxproj index 9516faa..c0b00ee 100644 --- a/Fruit.xcodeproj/project.pbxproj +++ b/Fruit.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 48D1EF012DEBDA9F00712C23 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + DebugStatsView.swift, FruitScreensaver.swift, Info.plist, Media.xcassets, @@ -76,6 +77,7 @@ 48F9C7872DEFC5A50023D72F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + DebugStatsView.swift, Preferences/PreferencesRepository.swift, Preferences/PreferencesViewController.swift, ); diff --git a/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift index 1892f74..ef8bca4 100644 --- a/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift +++ b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift @@ -207,6 +207,12 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { override init(layer: Any) { super.init(layer: layer) guard let other = layer as? MetalCircularGradientLayer else { return } + self.metalDevice = other.metalDevice + self.commandQueue = other.commandQueue + self.pipelineState = other.pipelineState + self.vertexBuffer = other.vertexBuffer + self.currentInterpolatedColorsBuffer = other.currentInterpolatedColorsBuffer + self.gradientLocationsBuffer = other.gradientLocationsBuffer self.colorIndex = other.colorIndex self.elapsedTime = other.elapsedTime self.continuousTotalElapsedTimeForRotation = other.continuousTotalElapsedTimeForRotation diff --git a/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift index 011a365..8dc0c9e 100644 --- a/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift +++ b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift @@ -200,6 +200,12 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { override init(layer: Any) { super.init(layer: layer) guard let other = layer as? MetalLinearGradientLayer else { return } + self.metalDevice = other.metalDevice + self.commandQueue = other.commandQueue + self.pipelineState = other.pipelineState + self.vertexBuffer = other.vertexBuffer + self.currentInterpolatedColorsBuffer = other.currentInterpolatedColorsBuffer + self.gradientLocationsBuffer = other.gradientLocationsBuffer self.colorIndex = other.colorIndex self.elapsedTime = other.elapsedTime } diff --git a/FruitFarm/Backgrounds/Types/SolidLayer.swift b/FruitFarm/Backgrounds/Types/SolidLayer.swift index 8fa8130..fa88e7c 100644 --- a/FruitFarm/Backgrounds/Types/SolidLayer.swift +++ b/FruitFarm/Backgrounds/Types/SolidLayer.swift @@ -95,6 +95,10 @@ final class MetalSolidLayer: CAMetalLayer, Background { override init(layer: Any) { super.init(layer: layer) guard let other = layer as? MetalSolidLayer else { return } + self.metalDevice = other.metalDevice + self.commandQueue = other.commandQueue + self.pipelineState = other.pipelineState + self.vertexBuffer = other.vertexBuffer self.colorIndex = other.colorIndex self.elapsedTime = other.elapsedTime } diff --git a/FruitScreensaver/DebugStatsView.swift b/FruitScreensaver/DebugStatsView.swift new file mode 100644 index 0000000..eac932b --- /dev/null +++ b/FruitScreensaver/DebugStatsView.swift @@ -0,0 +1,132 @@ +import AppKit +import IOKit + +final class DebugStatsView: NSView { + + private let textFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .medium) + private let padding: CGFloat = 8 + private let textLayer = CATextLayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor(white: 0, alpha: 0.7).cgColor + layer?.cornerRadius = 6 + + textLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + textLayer.alignmentMode = .left + textLayer.isWrapped = true + layer?.addSublayer(textLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + func update(fps: Int) { + let cpu = Self.processCPUUsage() + let gpu = Self.gpuUtilization() + + var text = String(format: "FPS %d\nCPU %.1f%%", fps, cpu) + if let gpu = gpu { + text += String(format: "\nGPU %.0f%%", gpu) + } else { + text += "\nGPU N/A" + } + + let attributed = NSAttributedString(string: text, attributes: [ + .font: textFont, + .foregroundColor: NSColor.white + ]) + + let textSize = attributed.boundingRect( + with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ).size + + frame.size = CGSize( + width: ceil(textSize.width) + padding * 2, + height: ceil(textSize.height) + padding * 2 + ) + + CATransaction.begin() + CATransaction.setDisableActions(true) + textLayer.frame = bounds.insetBy(dx: padding, dy: padding) + textLayer.string = attributed + CATransaction.commit() + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + textLayer.contentsScale = window?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor ?? 2.0 + } + + // MARK: - CPU (per-process via Mach thread info) + + private static func processCPUUsage() -> Double { + var threadList: thread_act_array_t? + var threadCount: mach_msg_type_number_t = 0 + let result = task_threads(mach_task_self_, &threadList, &threadCount) + guard result == KERN_SUCCESS, let threads = threadList else { return 0 } + defer { + vm_deallocate( + mach_task_self_, + vm_address_t(bitPattern: threads), + vm_size_t(Int(threadCount) * MemoryLayout.stride) + ) + } + + var totalCPU: Double = 0 + for i in 0...size / MemoryLayout.size + ) + let kr = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(infoCount)) { + thread_info(threads[i], thread_flavor_t(THREAD_BASIC_INFO), $0, &infoCount) + } + } + if kr == KERN_SUCCESS && (info.flags & TH_FLAGS_IDLE) == 0 { + totalCPU += Double(info.cpu_usage) / Double(TH_USAGE_SCALE) * 100.0 + } + } + return totalCPU + } + + // MARK: - GPU (system-wide via IOKit IOAccelerator) + + private static func gpuUtilization() -> Double? { + var iterator: io_iterator_t = 0 + let result = IOServiceGetMatchingServices( + 0, IOServiceMatching("IOAccelerator"), &iterator + ) + guard result == kIOReturnSuccess else { return nil } + defer { IOObjectRelease(iterator) } + + var entry = IOIteratorNext(iterator) + while entry != 0 { + let current = entry + defer { IOObjectRelease(current) } + + var properties: Unmanaged? + if IORegistryEntryCreateCFProperties( + current, &properties, kCFAllocatorDefault, 0 + ) == kIOReturnSuccess, + let dict = properties?.takeRetainedValue() as? [String: Any], + let stats = dict["PerformanceStatistics"] as? [String: Any] + { + if let utilization = stats["GPU Activity(%)"] as? Int { + return Double(utilization) + } + if let utilization = stats["Device Utilization %"] as? Int { + return Double(utilization) + } + } + entry = IOIteratorNext(iterator) + } + return nil + } +} diff --git a/FruitScreensaver/FruitScreensaver.swift b/FruitScreensaver/FruitScreensaver.swift index 07d2704..f068156 100644 --- a/FruitScreensaver/FruitScreensaver.swift +++ b/FruitScreensaver/FruitScreensaver.swift @@ -8,6 +8,7 @@ final class FruitScreensaver: ScreenSaverView { private enum Constant { static let secondPerFrame = 1.0 / 60.0 + static let showDebugStats = true } // MARK: Views @@ -21,6 +22,11 @@ final class FruitScreensaver: ScreenSaverView { private var lastFps: Int = 60 private var isPaused: Bool = false + // MARK: Debug + + private var debugStatsView: DebugStatsView? + private var lastDebugUpdateTime: TimeInterval = 0 + // MARK: Preferences private let preferencesRepository: PreferencesRepository = PreferencesRepositoryImpl() @@ -43,6 +49,9 @@ final class FruitScreensaver: ScreenSaverView { addScreenDidChangeNotification() } addObserverWillStopNotification() + if Constant.showDebugStats { + setupDebugView() + } } required init?(coder decoder: NSCoder) { @@ -54,6 +63,9 @@ final class FruitScreensaver: ScreenSaverView { addScreenDidChangeNotification() } addObserverWillStopNotification() + if Constant.showDebugStats { + setupDebugView() + } } private func setupFruitView(isPreview: Bool) { @@ -87,10 +99,30 @@ final class FruitScreensaver: ScreenSaverView { self.addSubview(metalView!) } + private func setupDebugView() { + let debugView = DebugStatsView(frame: .zero) + self.addSubview(debugView) + debugStatsView = debugView + debugView.update(fps: 60) + positionDebugView() + } + + private func positionDebugView() { + guard let debugView = debugStatsView else { return } + let margin: CGFloat = 12 + debugView.frame.origin = CGPoint( + x: margin, + y: bounds.height - debugView.frame.height - margin + ) + } + override func layout() { super.layout() fruitView.frame = self.bounds metalView?.frame = self.bounds + if Constant.showDebugStats { + positionDebugView() + } } override func viewDidMoveToWindow() { @@ -108,9 +140,16 @@ final class FruitScreensaver: ScreenSaverView { override func animateOneFrame() { super.animateOneFrame() - // Skip animation if paused to save CPU guard !isPaused else { return } fruitView.animateOneFrame(framesPerSecond: calculateFps()) + if Constant.showDebugStats { + let now = CACurrentMediaTime() + if now - lastDebugUpdateTime >= 0.5 { + lastDebugUpdateTime = now + debugStatsView?.update(fps: lastFps) + positionDebugView() + } + } } private func calculateFps() -> Int { diff --git a/FruitScreensaver/Preferences/PreferencesViewController.swift b/FruitScreensaver/Preferences/PreferencesViewController.swift index 2f76658..6bcb193 100644 --- a/FruitScreensaver/Preferences/PreferencesViewController.swift +++ b/FruitScreensaver/Preferences/PreferencesViewController.swift @@ -91,6 +91,10 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, /// Display link for synchronizing animation with screen refresh rate. private var displayLink: CVDisplayLink? + private let showDebugStats = true + private var debugStatsView: DebugStatsView? + private var lastDebugUpdateTime: TimeInterval = 0 + deinit { stopDisplayLink() NotificationCenter.default.removeObserver(self) @@ -112,6 +116,9 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, addSubviews() configConstraints() setupDisplayLink() + if showDebugStats { + setupDebugView() + } } /// Configures the main view with a black background. @@ -144,6 +151,23 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, ]) } + private func setupDebugView() { + let debugView = DebugStatsView(frame: .zero) + self.view.addSubview(debugView) + debugStatsView = debugView + debugView.update(fps: 60) + positionDebugView() + } + + private func positionDebugView() { + guard let debugView = debugStatsView else { return } + let margin: CGFloat = 12 + debugView.frame.origin = CGPoint( + x: margin, + y: view.bounds.height - debugView.frame.height - margin + ) + } + /// Called after the view is loaded. /// Sets up the initial frame for the fruit view. override func viewDidLoad() { @@ -157,6 +181,9 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, super.viewDidLayout() fruitView.frame = self.view.bounds metalView.frame = self.view.bounds + if showDebugStats { + positionDebugView() + } } // MARK: - Display Link @@ -206,6 +233,7 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, DispatchQueue.main.async { [weak controller] in controller?.fruitView.animateOneFrame(framesPerSecond: fps) + controller?.updateDebugStatsIfNeeded(fps: fps) } return kCVReturnSuccess }, Unmanaged.passUnretained(context).toOpaque()) @@ -213,6 +241,16 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, CVDisplayLinkStart(displayLink) } + private func updateDebugStatsIfNeeded(fps: Int) { + guard showDebugStats else { return } + let now = CACurrentMediaTime() + if now - lastDebugUpdateTime >= 0.5 { + lastDebugUpdateTime = now + debugStatsView?.update(fps: fps) + positionDebugView() + } + } + // MARK: - EDR private func addScreenDidChangeNotification() { From 804b60ef460625ab4c470beceeccdef07ecc2a8d Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Fri, 15 May 2026 23:52:11 +0100 Subject: [PATCH 16/19] Correct PreferencesViewController and DisplayLink animator configuration --- .github/scripts/build.sh | 19 ++- CHANGELOG.md | 2 + ...Screensaver-FPS-CVDisplayLink-Migration.md | 152 ++++++++++++++++++ Docs/macOS-Screensaver-Install-Cache-Bug.md | 90 +++++++++++ Fruit.xcodeproj/project.pbxproj | 2 + FruitFarm/Backgrounds/Types/PsyLayer.swift | 15 +- FruitScreensaver/DebugStatsView.swift | 2 + FruitScreensaver/DisplayLinkAnimator.swift | 100 ++++++++++++ FruitScreensaver/FruitScreensaver.swift | 92 +++++------ .../PreferencesViewController.swift | 71 ++------ README.md | 14 +- 11 files changed, 440 insertions(+), 119 deletions(-) create mode 100644 Docs/Screensaver-FPS-CVDisplayLink-Migration.md create mode 100644 Docs/macOS-Screensaver-Install-Cache-Bug.md create mode 100644 FruitScreensaver/DisplayLinkAnimator.swift diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh index 1a6ed72..0ce30c7 100755 --- a/.github/scripts/build.sh +++ b/.github/scripts/build.sh @@ -2,13 +2,28 @@ set -e +INSTALL_DIR="$HOME/Library/Screen Savers" +SAVER_NAME="Fruit.saver" + +rm -rf build/Fruit.xcarchive build/"$SAVER_NAME" + xcodebuild -scheme Fruit -configuration Release -archivePath build/Fruit.xcarchive archive -SAVER_SRC=$(find build/Fruit.xcarchive/Products -type d -name "Fruit.saver" | head -n 1) +SAVER_SRC=$(find build/Fruit.xcarchive/Products -type d -name "$SAVER_NAME" | head -n 1) if [ -z "$SAVER_SRC" ]; then echo "Error: .saver bundle not found in archive." >&2 exit 1 fi -cp -R "$SAVER_SRC" build/Fruit.saver +cp -R "$SAVER_SRC" build/"$SAVER_NAME" rm -rf build/Fruit.xcarchive + +echo "Build: build/$SAVER_NAME" + +if [ "$1" = "--install" ]; then + killall legacyScreenSaver 2>/dev/null || true + rm -rf "$INSTALL_DIR/$SAVER_NAME" + cp -R build/"$SAVER_NAME" "$INSTALL_DIR/$SAVER_NAME" + xattr -dr com.apple.quarantine "$INSTALL_DIR/$SAVER_NAME" 2>/dev/null || true + echo "Installed to: $INSTALL_DIR/$SAVER_NAME" +fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 99d3a59..503627c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ ### Bug Fixes + - Fixed screensaver install cache bug where macOS continues running the old binary after replacement. `cp -R` merges directories instead of replacing them, leaving stale binaries behind. `legacyScreenSaver.appex` and `WallpaperAgent` further cache the loaded bundle in memory and across reboots. The build script now supports `--install` to handle the full replacement sequence automatically. + - Added debug stats overlay (FPS, CPU, GPU) to FruitScreensaver and PreferencesViewController, gated behind a `showDebugStats` toggle. - Fixed star artifact in Liquid and Psychedelic Metal shaders. - Fixed Metal layer resolution on resize and WarpLayer scaling. - Fixed release workflow bugs and added missing permissions. diff --git a/Docs/Screensaver-FPS-CVDisplayLink-Migration.md b/Docs/Screensaver-FPS-CVDisplayLink-Migration.md new file mode 100644 index 0000000..8a05e6b --- /dev/null +++ b/Docs/Screensaver-FPS-CVDisplayLink-Migration.md @@ -0,0 +1,152 @@ +# Screensaver FPS Fix: Migrate to CVDisplayLink + +## Problem + +`FruitScreensaver` renders at roughly **1/3 the frame rate** of the same animation +running inside `PreferencesViewController`. Both views render the identical +`FruitView` content, so the bottleneck is not the drawing code — it is **how each +view drives its animation loop**. + +## Root Cause + +### FruitScreensaver — NSTimer-based (slow path) + +`FruitScreensaver` extends `ScreenSaverView` and relies on the framework's +built-in animation timer: + +```swift +animationTimeInterval = 1.0 / 60.0 // requested 60 fps +``` + +The ScreenSaver engine calls `animateOneFrame()` from an internal `NSTimer`. +This timer: + +- Is **not synchronized** with the display's vertical sync (vsync). +- Is subject to run-loop scheduling delays and macOS system-level throttling. +- Caps at a theoretical maximum of 60 fps regardless of display capability. +- In practice fires at roughly **20–40 fps** on modern macOS, because the + ScreenSaver framework deprioritises timer accuracy for idle-state workloads. + +### PreferencesViewController — CVDisplayLink (fast path) + +`PreferencesViewController` creates a `CVDisplayLink` tied to the physical +display: + +```swift +CVDisplayLinkCreateWithCGDisplay(displayID, &link) +CVDisplayLinkSetOutputCallback(displayLink, callback, context) +CVDisplayLinkStart(displayLink) +``` + +The callback fires once per vsync at the display's **native refresh rate** +(60 Hz on standard panels, 120 Hz on ProMotion). It reads the actual refresh +period from the display link timestamp and passes it straight to +`FruitView.animateOneFrame(framesPerSecond:)`. + +### Observed ratio + +| Display | CVDisplayLink fps | ScreenSaverView timer fps | Ratio | +|------------------|-------------------|---------------------------|-------| +| ProMotion 120 Hz | ~120 | ~40 | ~3× | +| Standard 60 Hz | ~60 | ~20–30 | ~2–3× | + +The `DebugStatsView` overlay confirms this: the FPS value reported by +`calculateFps()` inside `FruitScreensaver.animateOneFrame()` is consistently +2–3× lower than the FPS shown in the preferences preview. + +## Proposed Fix + +Replace the `ScreenSaverView` animation timer with a `CVDisplayLink` inside +`FruitScreensaver`, mirroring the approach already proven in +`PreferencesViewController`. + +### Step 1 — Disable ScreenSaverView's built-in timer + +Set `animationTimeInterval` to a very large value (or leave it at default) so +the framework's `NSTimer` effectively never fires. Override `animateOneFrame()` +to be a no-op (the CVDisplayLink callback will drive rendering instead). + +```swift +animationTimeInterval = .infinity +``` + +### Step 2 — Add CVDisplayLink to FruitScreensaver + +Port the display-link setup from `PreferencesViewController`: + +1. Add `displayLink: CVDisplayLink?` and `DisplayLinkContext` (weak-ref + wrapper) as private properties. +2. In `setupDisplayLink()`, create the link for the current screen's + `CGDirectDisplayID`, set the output callback, and start it. +3. The callback computes `fps` from `inNow.pointee.videoTimeScale / + videoRefreshPeriod` and dispatches to `DispatchQueue.main` to call + `fruitView.animateOneFrame(framesPerSecond:)` and update the debug overlay. + +### Step 3 — Handle display changes + +When the screensaver window moves to a different screen (e.g. multi-monitor), +tear down and recreate the display link for the new display ID. The existing +`NSWindow.didChangeScreenNotification` observer can call `setupDisplayLink()` +(as PreferencesViewController already does via `screenDidChange()`). + +### Step 4 — Handle pause / resume + +- `viewDidMoveToWindow()` — stop the display link when `window == nil`, restart + when re-attached. +- `willStop(_:)` — stop the display link to release resources before the + screensaver engine terminates the process. + +### Step 5 — Extract shared helper (optional refactor) + +Both `FruitScreensaver` and `PreferencesViewController` will now contain +near-identical CVDisplayLink setup and teardown code. Extract a reusable +`DisplayLinkAnimator` helper (or protocol extension) to eliminate duplication: + +``` +DisplayLinkAnimator + ├── start(on screen: NSScreen?) + ├── stop() + └── onFrame: ((_ fps: Int) -> Void)? +``` + +Both call sites would reduce to: + +```swift +animator.onFrame = { [weak self] fps in + self?.fruitView.animateOneFrame(framesPerSecond: fps) + self?.updateDebugStatsIfNeeded(fps: fps) +} +animator.start(on: window?.screen) +``` + +### Step 6 — Validate + +- Confirm via `DebugStatsView` that both paths now report matching FPS on the + same display. +- Test on a ProMotion display (should reach ~120 fps). +- Test on a standard 60 Hz display (should reach ~60 fps). +- Test multi-monitor with mixed refresh rates (display link should rebind). +- Verify CPU/GPU usage does not regress — the extra frames are driven by + hardware vsync so there should be no busy-wait overhead. + +## Files Changed + +| File | Change | +|------|--------| +| `FruitScreensaver/FruitScreensaver.swift` | Replace NSTimer loop with CVDisplayLink | +| `FruitScreensaver/Preferences/PreferencesViewController.swift` | Extract shared display-link code (Step 5) | +| New: `FruitFarm/DisplayLinkAnimator.swift` (optional) | Shared CVDisplayLink helper | + +## Risks & Considerations + +- **ScreenSaverView contract**: Apple's documentation does not explicitly + guarantee that ignoring `animateOneFrame()` is safe. However, the timer is + only a convenience; nothing in the framework enforces that rendering must + happen inside that callback. Setting `animationTimeInterval = .infinity` + effectively turns it off without fighting the framework. +- **Thread safety**: `CVDisplayLink` fires on a dedicated high-priority thread. + All UI and layer mutations must be dispatched to the main thread, which the + current `PreferencesViewController` implementation already does. +- **Legacy macOS**: `CVDisplayLink` is available since macOS 10.4 and is not + deprecated. The newer `CADisplayLink` (macOS 14+) is an alternative but would + raise the deployment target. diff --git a/Docs/macOS-Screensaver-Install-Cache-Bug.md b/Docs/macOS-Screensaver-Install-Cache-Bug.md new file mode 100644 index 0000000..8243f1d --- /dev/null +++ b/Docs/macOS-Screensaver-Install-Cache-Bug.md @@ -0,0 +1,90 @@ +# macOS Screensaver Install Cache Bug + +## Problem + +When replacing a `.saver` bundle in `~/Library/Screen Savers/`, macOS +continues running the old version — even after a full reboot. + +Copying a freshly built `Fruit.saver` over the existing one appears to +succeed (no errors from `cp`), but the screensaver binary, version string, +and behaviour remain unchanged. + +## Root Cause + +Two independent caching mechanisms prevent the new binary from loading: + +### 1. `legacyScreenSaver.appex` keeps the old binary in memory + +On macOS Sonoma and later, screensavers run inside +`legacyScreenSaver.appex`. Once this process loads a `.saver` bundle, the +Mach-O binary stays mapped in memory. Overwriting the file on disk has no +effect on the running process, and macOS may restart the same process +(with the in-memory binary) across screensaver activations. + +### 2. `cp -R` merges instead of replacing directories + +`cp -R src.saver dest.saver` when `dest.saver` already exists does **not** +replace the directory — it copies `src.saver` *into* `dest.saver`, or +merges contents while leaving stale files behind. The result is that old +binaries and resources persist even though the copy appeared to succeed. + +### 3. WallpaperAgent cache (Sequoia) + +On macOS Sequoia, `WallpaperAgent` maintains its own screensaver cache at: + +``` +~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/ + com.apple.wallpaper.caches/screenSaver-/ +``` + +This cache can survive reboots and cause the system to load stale +screensaver bundles. + +## Fix + +The correct replacement sequence is: + +```bash +# 1. Kill the process holding the old binary +killall legacyScreenSaver 2>/dev/null || true + +# 2. DELETE the old bundle — do not overwrite +rm -rf ~/Library/Screen\ Savers/Fruit.saver + +# 3. Copy the fresh build +cp -R build/Fruit.saver ~/Library/Screen\ Savers/Fruit.saver + +# 4. Strip quarantine so Gatekeeper does not block loading +xattr -dr com.apple.quarantine ~/Library/Screen\ Savers/Fruit.saver +``` + +The critical step is **rm -rf before cp**. Without it, `cp -R` merges +directories and stale binaries remain. + +## Build Script + +The project's build script supports `--install` to automate this: + +```bash +bash .github/scripts/build.sh --install +``` + +This performs a clean archive build, kills `legacyScreenSaver`, removes +the old bundle, copies the new one, and strips the quarantine attribute. + +## How to Verify + +Compare the binary hash before and after installation: + +```bash +md5 -q ~/Library/Screen\ Savers/Fruit.saver/Contents/MacOS/Fruit +``` + +If the hash matches the build output, the replacement succeeded. If it +matches the previous install, the stale cache is still active — re-run +the full replacement sequence above. + +## Affected Versions + +- macOS Sonoma 14.x (`legacyScreenSaver.appex` caching) +- macOS Sequoia 15.x (`WallpaperAgent` cache, `legacyScreenSaver.appex`) diff --git a/Fruit.xcodeproj/project.pbxproj b/Fruit.xcodeproj/project.pbxproj index c0b00ee..d846c29 100644 --- a/Fruit.xcodeproj/project.pbxproj +++ b/Fruit.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( DebugStatsView.swift, + DisplayLinkAnimator.swift, FruitScreensaver.swift, Info.plist, Media.xcassets, @@ -78,6 +79,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( DebugStatsView.swift, + DisplayLinkAnimator.swift, Preferences/PreferencesRepository.swift, Preferences/PreferencesViewController.swift, ); diff --git a/FruitFarm/Backgrounds/Types/PsyLayer.swift b/FruitFarm/Backgrounds/Types/PsyLayer.swift index 7a0592e..fa1148c 100644 --- a/FruitFarm/Backgrounds/Types/PsyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -27,6 +27,7 @@ struct PsyUniforms { float2 resolution; float time; float color_phase; + float referenceSize; }; float3 hsv2rgb(float3 c) { @@ -56,10 +57,12 @@ fragment float4 fragment_shader_psy( VertexOut in [[stage_in]], constant PsyUniforms &uniforms [[buffer(0)]]) { - float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / - min(uniforms.resolution.x, uniforms.resolution.y); + float2 vigUV = (in.position.xy * 2.0 - uniforms.resolution) / + min(uniforms.resolution.x, uniforms.resolution.y); + float origR = length(vigUV); + + float2 uv = (in.position.xy - uniforms.resolution * 0.5) / uniforms.referenceSize; float t = uniforms.time; - float origR = length(uv); // Heavy domain warping - high amplitude, fast float2 p = uv; @@ -144,6 +147,7 @@ private struct MetalPsyFragmentUniforms { var time: Float // swiftlint:disable:next identifier_name var color_phase: Float + var referenceSize: Float } // swiftlint:disable:next type_body_length @@ -311,10 +315,13 @@ final class PsyLayer: CAMetalLayer, Background { let drawable = nextDrawable() else { return } let texture = drawable.texture + let referenceSize: Float = 200.0 * Float(contentsScale) + var uniforms = MetalPsyFragmentUniforms( resolution: SIMD2(Float(texture.width), Float(texture.height)), time: Float(totalElapsedTime), - color_phase: Float(colorPhase) + color_phase: Float(colorPhase), + referenceSize: referenceSize ) let renderPassDescriptor = MTLRenderPassDescriptor() diff --git a/FruitScreensaver/DebugStatsView.swift b/FruitScreensaver/DebugStatsView.swift index eac932b..ec2b8c6 100644 --- a/FruitScreensaver/DebugStatsView.swift +++ b/FruitScreensaver/DebugStatsView.swift @@ -3,6 +3,8 @@ import IOKit final class DebugStatsView: NSView { + static let isEnabled = true + private let textFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .medium) private let padding: CGFloat = 8 private let textLayer = CATextLayer() diff --git a/FruitScreensaver/DisplayLinkAnimator.swift b/FruitScreensaver/DisplayLinkAnimator.swift new file mode 100644 index 0000000..f5053d6 --- /dev/null +++ b/FruitScreensaver/DisplayLinkAnimator.swift @@ -0,0 +1,100 @@ +import Cocoa +import QuartzCore + +/// Drives animation in sync with the display's hardware refresh rate, +/// avoiding the throttling and imprecision of NSTimer-based approaches. +/// +/// Uses `CADisplayLink` on macOS 14+ and falls back to `CVDisplayLink` on older versions. +final class DisplayLinkAnimator { + + /// Called on the main thread for each vsync. Parameter is the display's native refresh rate. + var onFrame: ((_ fps: Int) -> Void)? + + private var modernLink: AnyObject? + private var legacyLink: CVDisplayLink? + private var legacyContext: LegacyContext? + + deinit { + stop() + } + + /// Starts (or restarts) the display link bound to the given screen. + /// Falls back to the main display when `screen` is nil. + func start(on screen: NSScreen?) { + stop() + if #available(macOS 14.0, *) { + startModernLink(on: screen) + } else { + startLegacyLink(on: screen) + } + } + + func stop() { + if #available(macOS 14.0, *) { + (modernLink as? CADisplayLink)?.invalidate() + modernLink = nil + } + if let link = legacyLink { + CVDisplayLinkStop(link) + } + legacyLink = nil + legacyContext = nil + } + + // MARK: - macOS 14+ (CADisplayLink) + + @available(macOS 14.0, *) + private func startModernLink(on screen: NSScreen?) { + guard let screen = screen ?? NSScreen.main else { return } + let link = screen.displayLink(target: self, selector: #selector(handleFrame(_:))) + link.add(to: .main, forMode: .common) + modernLink = link + } + + @available(macOS 14.0, *) + @objc private func handleFrame(_ link: CADisplayLink) { + let fps = link.duration > 0 ? Int(round(1.0 / link.duration)) : 60 + onFrame?(fps) + } + + // MARK: - macOS < 14 (CVDisplayLink) + + private func startLegacyLink(on screen: NSScreen?) { + var link: CVDisplayLink? + if let screen = screen { + let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? CGDirectDisplayID ?? CGMainDisplayID() + CVDisplayLinkCreateWithCGDisplay(displayID, &link) + } else { + CVDisplayLinkCreateWithCGDisplay(CGMainDisplayID(), &link) + } + + guard let displayLink = link else { return } + self.legacyLink = displayLink + + let ctx = LegacyContext(self) + self.legacyContext = ctx + + CVDisplayLinkSetOutputCallback( + displayLink, { (_, inNow, _, _, _, userInfo) -> CVReturn in + let ctx = Unmanaged.fromOpaque(userInfo!).takeUnretainedValue() + guard let animator = ctx.animator else { return kCVReturnSuccess } + + let timeScale = Int64(inNow.pointee.videoTimeScale) + let frameDuration = inNow.pointee.videoRefreshPeriod + let fps: Int = frameDuration > 0 ? Int(timeScale / frameDuration) : 60 + + DispatchQueue.main.async { [weak animator] in + animator?.onFrame?(fps) + } + return kCVReturnSuccess + }, Unmanaged.passUnretained(ctx).toOpaque()) + + CVDisplayLinkStart(displayLink) + } + + private final class LegacyContext { + weak var animator: DisplayLinkAnimator? + init(_ animator: DisplayLinkAnimator) { self.animator = animator } + } +} diff --git a/FruitScreensaver/FruitScreensaver.swift b/FruitScreensaver/FruitScreensaver.swift index f068156..7734703 100644 --- a/FruitScreensaver/FruitScreensaver.swift +++ b/FruitScreensaver/FruitScreensaver.swift @@ -4,13 +4,6 @@ import FruitFarm // MARK: - FruitView final class FruitScreensaver: ScreenSaverView { - // MARK: Constant - - private enum Constant { - static let secondPerFrame = 1.0 / 60.0 - static let showDebugStats = true - } - // MARK: Views private var fruitView: FruitView! @@ -18,8 +11,7 @@ final class FruitScreensaver: ScreenSaverView { // MARK: Frame control - private var lastFrameTime: TimeInterval? - private var lastFps: Int = 60 + private let displayLinkAnimator = DisplayLinkAnimator() private var isPaused: Bool = false // MARK: Debug @@ -35,35 +27,32 @@ final class FruitScreensaver: ScreenSaverView { ) deinit { - // Remove notification observers to prevent memory leaks + displayLinkAnimator.stop() NotificationCenter.default.removeObserver(self) DistributedNotificationCenter.default.removeObserver(self) } override init?(frame: NSRect, isPreview: Bool) { super.init(frame: frame, isPreview: isPreview) - animationTimeInterval = Constant.secondPerFrame - setupFruitView(isPreview: isPreview) - if !isPreview { - setupMetalView() - addScreenDidChangeNotification() - } - addObserverWillStopNotification() - if Constant.showDebugStats { - setupDebugView() - } + animationTimeInterval = .infinity + commonInit(isPreview: isPreview) } required init?(coder decoder: NSCoder) { super.init(coder: decoder) - animationTimeInterval = Constant.secondPerFrame - setupFruitView(isPreview: false) + animationTimeInterval = .infinity + commonInit(isPreview: isPreview) + } + + private func commonInit(isPreview: Bool) { + setupFruitView(isPreview: isPreview) + setupDisplayLinkAnimator() if !isPreview { setupMetalView() addScreenDidChangeNotification() } addObserverWillStopNotification() - if Constant.showDebugStats { + if DebugStatsView.isEnabled { setupDebugView() } } @@ -78,6 +67,15 @@ final class FruitScreensaver: ScreenSaverView { self.addSubview(fruitView) } + private func setupDisplayLinkAnimator() { + displayLinkAnimator.onFrame = { [weak self] fps in + guard let self = self, !self.isPaused else { return } + self.fruitView.animateOneFrame(framesPerSecond: fps) + self.updateDebugStatsIfNeeded(fps: fps) + } + displayLinkAnimator.start(on: window?.screen) + } + private func setupMetalView() { metalView = MetalView( frame: self.bounds, @@ -120,50 +118,36 @@ final class FruitScreensaver: ScreenSaverView { super.layout() fruitView.frame = self.bounds metalView?.frame = self.bounds - if Constant.showDebugStats { + if DebugStatsView.isEnabled { positionDebugView() } } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - // Pause animations when view is removed from window hierarchy if window == nil { isPaused = true metalView?.isRenderingPaused = true + displayLinkAnimator.stop() } else { - // Resume animations when added to window isPaused = false metalView?.isRenderingPaused = false + displayLinkAnimator.start(on: window?.screen) } } override func animateOneFrame() { - super.animateOneFrame() - guard !isPaused else { return } - fruitView.animateOneFrame(framesPerSecond: calculateFps()) - if Constant.showDebugStats { - let now = CACurrentMediaTime() - if now - lastDebugUpdateTime >= 0.5 { - lastDebugUpdateTime = now - debugStatsView?.update(fps: lastFps) - positionDebugView() - } - } + // No-op: rendering is driven by DisplayLinkAnimator at vsync rate. } - private func calculateFps() -> Int { - let currentTime = CACurrentMediaTime() - var fps = lastFps - if let lastTime = lastFrameTime { - let delta = currentTime - lastTime - if delta > 0 { - fps = Int(round(1.0 / delta)) - lastFps = fps - } + private func updateDebugStatsIfNeeded(fps: Int) { + guard DebugStatsView.isEnabled else { return } + let now = CACurrentMediaTime() + if now - lastDebugUpdateTime >= 0.5 { + lastDebugUpdateTime = now + debugStatsView?.update(fps: fps) + positionDebugView() } - lastFrameTime = currentTime - return fps } private func addObserverWillStopNotification() { @@ -177,9 +161,9 @@ final class FruitScreensaver: ScreenSaverView { @objc private func willStop(_ aNotification: Notification) { - // Pause all animations to reduce CPU during shutdown isPaused = true metalView?.isRenderingPaused = true + displayLinkAnimator.stop() if !isPreview { NSApplication.shared.terminate(nil) @@ -188,18 +172,22 @@ final class FruitScreensaver: ScreenSaverView { private func addScreenDidChangeNotification() { checkEDR() - // Only observe screen changes for this specific window - // Passing nil would observe ALL windows, causing excessive callbacks if let window = window { NotificationCenter.default.addObserver( self, - selector: #selector(checkEDR), + selector: #selector(screenDidChange), name: NSWindow.didChangeScreenNotification, object: window ) } } + @objc + private func screenDidChange() { + checkEDR() + displayLinkAnimator.start(on: window?.screen) + } + @objc private func checkEDR() { guard let screen = window?.screen else { return } diff --git a/FruitScreensaver/Preferences/PreferencesViewController.swift b/FruitScreensaver/Preferences/PreferencesViewController.swift index 6bcb193..b2b0e6b 100644 --- a/FruitScreensaver/Preferences/PreferencesViewController.swift +++ b/FruitScreensaver/Preferences/PreferencesViewController.swift @@ -88,15 +88,13 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, return label }() - /// Display link for synchronizing animation with screen refresh rate. - private var displayLink: CVDisplayLink? + private let displayLinkAnimator = DisplayLinkAnimator() - private let showDebugStats = true private var debugStatsView: DebugStatsView? private var lastDebugUpdateTime: TimeInterval = 0 deinit { - stopDisplayLink() + displayLinkAnimator.stop() NotificationCenter.default.removeObserver(self) } @@ -115,8 +113,8 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, configView() addSubviews() configConstraints() - setupDisplayLink() - if showDebugStats { + setupDisplayLinkAnimator() + if DebugStatsView.isEnabled { setupDebugView() } } @@ -181,68 +179,23 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, super.viewDidLayout() fruitView.frame = self.view.bounds metalView.frame = self.view.bounds - if showDebugStats { + if DebugStatsView.isEnabled { positionDebugView() } } // MARK: - Display Link - private final class DisplayLinkContext { - weak var controller: PreferencesViewController? - init(_ controller: PreferencesViewController) { self.controller = controller } - } - - private var displayLinkContext: DisplayLinkContext? - - private func stopDisplayLink() { - if let displayLink = displayLink { - CVDisplayLinkStop(displayLink) - } - displayLink = nil - displayLinkContext = nil - } - - private func setupDisplayLink() { - stopDisplayLink() - - var link: CVDisplayLink? - if let screen = view.window?.screen { - let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] - as? CGDirectDisplayID ?? CGMainDisplayID() - CVDisplayLinkCreateWithCGDisplay(displayID, &link) - } else { - CVDisplayLinkCreateWithCGDisplay(CGMainDisplayID(), &link) + private func setupDisplayLinkAnimator() { + displayLinkAnimator.onFrame = { [weak self] fps in + self?.fruitView.animateOneFrame(framesPerSecond: fps) + self?.updateDebugStatsIfNeeded(fps: fps) } - - guard let displayLink = link else { return } - self.displayLink = displayLink - - let context = DisplayLinkContext(self) - self.displayLinkContext = context - - CVDisplayLinkSetOutputCallback( - displayLink, { (_, inNow, _, _, _, userInfo) -> CVReturn in - let ctx = Unmanaged - .fromOpaque(userInfo!).takeUnretainedValue() - guard let controller = ctx.controller else { return kCVReturnSuccess } - - let timeScale = Int64(inNow.pointee.videoTimeScale) - let frameDuration = inNow.pointee.videoRefreshPeriod - let fps: Int = frameDuration > 0 ? Int(timeScale / frameDuration) : 60 - - DispatchQueue.main.async { [weak controller] in - controller?.fruitView.animateOneFrame(framesPerSecond: fps) - controller?.updateDebugStatsIfNeeded(fps: fps) - } - return kCVReturnSuccess - }, Unmanaged.passUnretained(context).toOpaque()) - - CVDisplayLinkStart(displayLink) + displayLinkAnimator.start(on: view.window?.screen) } private func updateDebugStatsIfNeeded(fps: Int) { - guard showDebugStats else { return } + guard DebugStatsView.isEnabled else { return } let now = CACurrentMediaTime() if now - lastDebugUpdateTime >= 0.5 { lastDebugUpdateTime = now @@ -268,7 +221,7 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, @objc private func screenDidChange() { checkEDR() - setupDisplayLink() + displayLinkAnimator.start(on: view.window?.screen) } @objc diff --git a/README.md b/README.md index 3edf286..55573e2 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,23 @@ If the screenshot displays a black screen, you need to do some actions to get it 6. Quit **System Settings**. 7. Install your new screen saver and perform the dialog dance. +### Screensaver not updating after install? + +macOS caches the screensaver binary in `legacyScreenSaver.appex` and (on Sequoia) in `WallpaperAgent`. Copying a new `.saver` over the old one does **not** work — `cp -R` merges directories and the stale binary persists, even across reboots. Use the build script with `--install` to handle this automatically: + +```bash +bash .github/scripts/build.sh --install +``` + +Or do it manually: kill `legacyScreenSaver`, **rm -rf** the old bundle, copy the new one, strip quarantine. See [Docs/macOS-Screensaver-Install-Cache-Bug.md](Docs/macOS-Screensaver-Install-Cache-Bug.md) for full details. + ### Still not working? The Fruit screen saver can be blocked by the system as a malicious software. Sometimes on macOS Big Sur clicking `Open Anyway` in `Security & Privacy` is not fixing the issue. -To bypass this quarantine made by 🍎, you can use this command in your terminal : +To bypass this quarantine made by 🍎, you can use this command in your terminal: -```shellxc +```bash sudo xattr -d com.apple.quarantine ~/"Library/Screen Savers/Fruit.saver" ``` From 019c24e3c454b3ca8c0ef3e8219da907ea5416b3 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 16 May 2026 02:20:45 +0100 Subject: [PATCH 17/19] Add POGO, Glue, Metallic and improved Irish Ocean layers --- Docs/Ocean-GLSL-Spec.md | 311 +++++++++++++ FruitFarm/Backgrounds/FruitTypes.swift | 5 + .../Backgrounds/Types/CaliforniaLayer.swift | 414 +++++++++++++++++ FruitFarm/Backgrounds/Types/GlassLayer.swift | 353 +++++++++++++++ FruitFarm/Backgrounds/Types/GlueLayer.swift | 418 ++++++++++++++++++ .../Backgrounds/Types/MetallicLayer.swift | 351 +++++++++++++++ FruitFarm/Backgrounds/Types/OceanLayer.swift | 318 ++++++++----- FruitFarm/Backgrounds/Types/POGOLayer.swift | 373 ++++++++++++++++ FruitFarm/Backgrounds/Types/PsyLayer.swift | 4 +- FruitFarm/Backgrounds/Types/WarpLayer.swift | 64 ++- FruitFarm/FruitView.swift | 10 + .../PreferencesViewController.swift | 10 + README.md | 2 + 13 files changed, 2513 insertions(+), 120 deletions(-) create mode 100644 Docs/Ocean-GLSL-Spec.md create mode 100644 FruitFarm/Backgrounds/Types/CaliforniaLayer.swift create mode 100644 FruitFarm/Backgrounds/Types/GlassLayer.swift create mode 100644 FruitFarm/Backgrounds/Types/GlueLayer.swift create mode 100644 FruitFarm/Backgrounds/Types/MetallicLayer.swift create mode 100644 FruitFarm/Backgrounds/Types/POGOLayer.swift diff --git a/Docs/Ocean-GLSL-Spec.md b/Docs/Ocean-GLSL-Spec.md new file mode 100644 index 0000000..d69ade1 --- /dev/null +++ b/Docs/Ocean-GLSL-Spec.md @@ -0,0 +1,311 @@ +# Ocean GLSL Shader Spec + +## Overview + +This document describes the GLSL ocean shader used as the reference for the Fruit ocean background. The shader renders a stylized animated sea by ray marching against a procedural height field, computing normals from that field, and mixing sky reflection, water refraction, diffuse lighting, and specular highlights. + +The original shader is written in the Shadertoy style: + +- `mainImage(out vec4 fragColor, in vec2 fragCoord)` is the fragment entry point. +- `iResolution` provides the render target size in pixels. +- `iTime` drives animation. + +In Fruit, the equivalent implementation is hosted by a `CAMetalLayer` background. A Metal shader is compiled at runtime, a full-screen two-triangle rectangle is drawn, and fragment uniforms provide the current drawable resolution and elapsed time. The layer can restrict rendering to the Fruit logo bounds with a Metal scissor rect, so the ocean appears inside the logo rather than across the whole screen. + +## Visual Goal + +The shader produces a moving ocean viewed from a low camera angle: + +- A procedural wave height field defines the sea surface. +- The camera ray is traced until it intersects the height field. +- The sky color is reflected by grazing angles through Fresnel blending. +- The water body is tinted with deep blue-green refraction. +- Specular highlights sharpen on closer waves. +- A final gamma curve brightens the result for display. + +The effect is not a physical ocean simulation. It is a compact procedural approximation designed to look convincing in real time. + +## Shader Inputs + +| Input | Purpose | +| --- | --- | +| `iResolution.xy` | Converts fragment coordinates into normalized viewport coordinates and scales normal sampling. | +| `iTime` | Advances waves and camera motion. | +| `fragCoord` | Pixel coordinate for the current fragment. | + +Fruit's Metal implementation should map these inputs to a uniform struct similar to: + +```metal +struct OceanUniforms { + float2 resolution; + float time; +}; +``` + + +## Constants + +### Ray Marching + +- `NUM_STEPS = 32`: Maximum number of root-finding iterations used to locate the sea surface along a view ray. +- `EPSILON = 1e-3`: Early-exit threshold for the height-field intersection. +- `EPSILON_NRM = 0.1 / iResolution.x`: Resolution-scaled offset used for finite-difference normal sampling. +- `AA`: Optional 3x3 supersampling block. Disabled by default because it multiplies fragment cost by nine. + +### Sea Shape + +- `ITER_GEOMETRY = 3`: Lower-detail height evaluation used while tracing rays. +- `ITER_FRAGMENT = 5`: Higher-detail height evaluation used for normals and final shading. +- `SEA_HEIGHT = 0.6`: Base amplitude for the first octave. +- `SEA_CHOPPY = 4.0`: Controls how sharp and peaked the waves are. +- `SEA_SPEED = 0.8`: Scales time in the wave field. +- `SEA_FREQ = 0.16`: Base spatial frequency. +- `SEA_TIME = 1.0 + iTime * SEA_SPEED`: Shared animation phase for wave movement. +- `octave_m`: Rotates and scales each octave so repeated wave layers do not align. + +### Color + +- `SEA_BASE`: Deep base water color. +- `SEA_WATER_COLOR`: Warm reflected/refracted water tint. + +## Rendering Pipeline + +The shader follows this per-fragment flow: + +1. Convert `fragCoord` into aspect-correct normalized screen coordinates. +2. Build a camera origin and view direction. +3. Rotate the view direction with a time-varying Euler matrix. +4. Ray march from the camera into the procedural sea height field. +5. Estimate the surface normal at the intersection point. +6. Shade the sea from sky reflection, water refraction, diffuse light, attenuation, and specular highlights. +7. Blend between sky and sea based on whether the ray points below the horizon. +8. Apply a final gamma-style color curve. + +## Function Reference + +### `fromEuler(vec3 ang)` + +Builds a 3x3 rotation matrix from Euler angles. `getPixel` uses this to animate the camera orientation: + +- `ang.x` rocks the camera slightly. +- `ang.y` changes pitch. +- `ang.z` slowly rolls/yaws with time. + +When porting to Metal, this can remain as a helper returning `float3x3`. Matrix multiplication order must be checked because GLSL and Metal matrix conventions can differ depending on how values are constructed. + +### `hash(vec2 p)` and `noise(vec2 p)` + +`hash` generates repeatable pseudo-random values from a 2D grid coordinate. `noise` interpolates four hash samples with a smooth cubic curve: + +```glsl +vec2 u = f * f * (3.0 - 2.0 * f); +``` + +The result is value noise in the `[-1, 1]` range. The noise is used to disturb wave coordinates, preventing overly regular sine-wave patterns. + +### `diffuse(vec3 n, vec3 l, float p)` + +Computes a softened diffuse term: + +```glsl +pow(dot(n, l) * 0.4 + 0.6, p) +``` + +The `0.4 + 0.6` bias keeps water from becoming fully black when it faces away from the light. The high exponent makes the contribution subtle and concentrated. + +### `specular(vec3 n, vec3 l, vec3 e, float s)` + +Computes a normalized Phong-style specular highlight using `reflect(e, n)`. The `s` value controls sharpness. In `getSeaColor`, sharpness is scaled by inverse distance so close wave glints are tighter and brighter than distant ones. + +### `getSkyColor(vec3 e)` + +Returns a simple procedural sky gradient from the view direction. The sky is warmer near the horizon and brighter/cooler higher up. This sky is also sampled through reflected water rays. + +### `sea_octave(vec2 uv, float choppy)` + +Creates one choppy wave octave: + +1. Distort `uv` with value noise. +2. Build wave bands from `sin` and `cos`. +3. Blend between those bands to create sharper crests. +4. Raise the result by `choppy`. + +Higher `choppy` values produce pointed, energetic waves. Later octaves blend `choppy` toward `1.0`, making small details softer than the primary waves. + +### `map(vec3 p)` + +Evaluates the signed height difference between a world-space point and the sea surface: + +```glsl +return p.y - h; +``` + +Positive values mean the point is above the water surface. Negative values mean it is below. `map` uses `ITER_GEOMETRY` octaves for speed during ray marching. + +Each octave: + +- Samples waves moving in opposite directions with `(uv + SEA_TIME)` and `(uv - SEA_TIME)`. +- Adds the result to height `h`. +- Rotates/scales `uv` with `octave_m`. +- Increases frequency. +- Reduces amplitude. +- Reduces choppiness. + +### `map_detailed(vec3 p)` + +Same as `map`, but uses `ITER_FRAGMENT` octaves. It is more expensive and more detailed, so it is reserved for final normal calculation. + +### `getNormal(vec3 p, float eps)` + +Approximates the water normal with finite differences: + +- Sample the detailed height field at `p`. +- Sample again slightly offset in `x`. +- Sample again slightly offset in `z`. +- Use those deltas plus `eps` as the normal vector. + +The caller scales `eps` by distance: + +```glsl +dot(dist, dist) * EPSILON_NRM +``` + +This reduces high-frequency shimmer in distant waves by sampling normals over a larger footprint. + +### `heightMapTracing(vec3 ori, vec3 dir, out vec3 p)` + +Finds the intersection between the view ray and the procedural water surface. + +The method is a bounded secant/binary-style search: + +1. Start at the camera with `tm = 0.0`. +2. Set a far point with `tx = 1000.0`. +3. If the far point is still above water, return it because the ray never hits the sea. +4. Otherwise, repeatedly interpolate between the near and far distances using the signed height values. +5. Keep the interval half that crosses the surface. +6. Stop once the height error is below `EPSILON` or `NUM_STEPS` is reached. + +This is efficient because the sea is a height field rather than arbitrary 3D geometry. + +### `getSeaColor(vec3 p, vec3 n, vec3 l, vec3 eye, vec3 dist)` + +Shades the water at the intersection point. + +The function combines: + +- Fresnel reflection: grazing angles reflect more sky. +- Refraction tint: base water color plus a small diffuse lighting term. +- Distance attenuation: nearby wave faces receive more visible water color. +- Specular highlight: sharp reflected light from the wave normal. + +The Fresnel factor is clamped to `0.5`, preventing reflections from completely overpowering the water color. + +### `getPixel(vec2 coord, float time)` + +Builds the camera and returns the final RGB color for one pixel. + +Important steps: + +- Normalizes the pixel coordinate to `[-1, 1]`. +- Corrects `uv.x` by aspect ratio. +- Sets camera origin to `vec3(0.0, 3.5, time * 5.0)`, moving forward over time. +- Builds a ray toward `z = -2.0` and bends it slightly with `length(uv) * 0.14`. +- Rotates the ray by `fromEuler`. +- Traces the sea surface. +- Computes normal and light. +- Blends sea with sky around the horizon: + +```glsl +pow(smoothstep(0.0, -0.02, dir.y), 0.2) +``` + +This keeps upward-facing rays as sky and downward-facing rays as ocean. + +### `mainImage(out vec4 fragColor, in vec2 fragCoord)` + +Entry point for Shadertoy. It computes shader time, optionally performs 3x3 antialiasing, then applies final color correction: + +```glsl +fragColor = vec4(pow(color, vec3(0.65)), 1.0); +``` + +The power curve brightens midtones and gives the ocean a more display-ready contrast. + +## Fruit Implementation Notes + +Fruit backgrounds are layer-based, so the GLSL effect is implemented through a Metal-backed layer rather than a Shadertoy runtime. + +The expected structure is: + +1. Store the translated shader source in a Swift string. +2. Compile the source with `device.makeLibrary(source:options:)`. +3. Create a render pipeline with a pass-through vertex function and the ocean fragment function. +4. Upload six vertices for a full-screen rectangle. +5. On each display pass, send resolution and elapsed time as fragment uniforms. +6. Draw the rectangle into the current drawable. +7. Apply a scissor rect matching the Fruit logo bounds when the background should only fill the logo. + +The existing `OceanLayer` follows this shape: + +- It subclasses `CAMetalLayer`. +- It creates a `MTLCommandQueue`, `MTLRenderPipelineState`, and shared vertex buffer. +- It tracks elapsed time in `update(deltaTime:)`. +- It redraws at a capped interval of 30 FPS. +- It passes `resolution` and `time` to the fragment shader. +- It uses the Fruit path bounds to restrict rendering to the visible logo region. + +## GLSL to Metal Translation Notes + +Most GLSL functions map directly to Metal Shading Language, but these differences matter: + +| GLSL | Metal | +| --- | --- | +| `vec2`, `vec3`, `vec4` | `float2`, `float3`, `float4` | +| `mat2`, `mat3` | `float2x2`, `float3x3` | +| `mix(a, b, t)` | `mix(a, b, t)` | +| `fract(x)` | `fract(x)` | +| `in`, `out` params | Regular parameters or references, depending on use | +| `mainImage` | `fragment` function | +| `iResolution`, `iTime` | Uniform buffer fields | + +The GLSL `out vec3 p` in `heightMapTracing` should become a thread reference in Metal: + +```metal +float heightMapTracing(float3 ori, float3 dir, thread float3 &p) +``` + +Metal fragment shaders receive interpolated vertex output rather than Shadertoy's `fragCoord`. The Fruit vertex shader should output either clip-space position plus pixel position, or the fragment shader should derive pixel coordinates from `in.position.xy`, matching the existing Metal layer pattern. + +## Performance Considerations + +This shader is more expensive than a pure 2D procedural background: + +- Each pixel can run up to `NUM_STEPS` height evaluations. +- Each height evaluation samples multiple wave octaves. +- Final normal calculation uses the more detailed height function three times. +- Enabling `AA` multiplies the whole cost by nine. + +For Fruit, keep antialiasing disabled by default. Prefer controlling quality through: + +- Lowering `NUM_STEPS`. +- Reducing `ITER_FRAGMENT`. +- Capping redraws at 30 FPS. +- Rendering only inside the Fruit logo with a scissor rect. + +## Tuning Guide + +| Parameter | Increase Effect | Decrease Effect | +| --- | --- | --- | +| `SEA_HEIGHT` | Taller waves | Flatter water | +| `SEA_CHOPPY` | Sharper crests | Rounder waves | +| `SEA_SPEED` | Faster wave animation | Slower water | +| `SEA_FREQ` | More frequent waves | Larger wave spacing | +| `ITER_GEOMETRY` | More accurate ray hits | Faster tracing | +| `ITER_FRAGMENT` | More detailed normals | Smoother, cheaper shading | +| `NUM_STEPS` | Fewer intersection artifacts | Better performance | + +## Expected Output + +The final render should look like an animated, reflective ocean with a clear horizon. Near the horizon, sky reflection dominates. Below the horizon, the water shows layered choppy wave shapes, deep base color, subtle green-yellow tinting, and small bright specular flashes. + +Inside Fruit, the same visual can be treated as a dynamic fill for the logo silhouette. The shader does not need scene geometry, textures, or precomputed assets; all motion and detail come from procedural math and elapsed time. diff --git a/FruitFarm/Backgrounds/FruitTypes.swift b/FruitFarm/Backgrounds/FruitTypes.swift index ec55d4a..53d3166 100644 --- a/FruitFarm/Backgrounds/FruitTypes.swift +++ b/FruitFarm/Backgrounds/FruitTypes.swift @@ -11,8 +11,13 @@ public enum FruitType: String, CaseIterable { case linearGradient case circularGradient case psychedelic + case california case liquid case puppy case warp case ocean + case glass + case metallic + case pogo + case glue } diff --git a/FruitFarm/Backgrounds/Types/CaliforniaLayer.swift b/FruitFarm/Backgrounds/Types/CaliforniaLayer.swift new file mode 100644 index 0000000..6770f3e --- /dev/null +++ b/FruitFarm/Backgrounds/Types/CaliforniaLayer.swift @@ -0,0 +1,414 @@ +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalCaliforniaShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_california( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct CaliforniaUniforms { + float2 resolution; + float time; +}; + +float california_hash(float2 p) { + float3 p3 = fract(float3(p.xyx) * float3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float california_noise(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + float2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(california_hash(i), california_hash(i + float2(1.0, 0.0)), u.x), + mix(california_hash(i + float2(0.0, 1.0)), california_hash(i + float2(1.0, 1.0)), u.x), + u.y + ); +} + +float california_fbm(float2 p) { + float value = 0.0; + float amplitude = 0.5; + float2x2 turn = float2x2(0.86, 0.50, -0.50, 0.86); + + for (int i = 0; i < 5; i++) { + value += amplitude * california_noise(p); + p = turn * p * 2.03 + 7.1; + amplitude *= 0.52; + } + + return value; +} + +// Sap pulse: a single coherent flow from leaf base (s=0) outward (s=1). +// Returns a soft bright bump that travels along a curvilinear coordinate. +float california_flow(float s, float speed, float phase, float time) { + float head = fract(time * speed + phase); + float d = s - head; + d = d - floor(d + 0.5); + return exp(-90.0 * d * d); +} + +fragment float4 fragment_shader_california( + VertexOut in [[stage_in]], + constant CaliforniaUniforms &uniforms [[buffer(0)]]) { + + float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / + min(uniforms.resolution.x, uniforms.resolution.y); + // Zoom out: scale the sample space so the whole leaf network sits + // inside the visible frame instead of filling it edge-to-edge. + uv *= 1.75; + float t = uniforms.time; + + // --------------------------------------------------------------- + // Leaf body: low-frequency organic warp so the whole leaf breathes + // as one piece. Every vein system below samples this same warp so + // they all bend together instead of drifting independently. + // --------------------------------------------------------------- + float2 warp = float2( + california_fbm(uv * 1.3 + float2(0.0, t * 0.05)) - 0.5, + california_fbm(uv * 1.3 + float2(5.2, t * 0.05 + 3.1)) - 0.5 + ) * 0.18; + float2 wuv = uv + warp; + + float leafNoise = california_fbm(wuv * 3.2 + float2(1.7, -2.4)); + float fineCells = california_fbm(wuv * 16.0 + leafNoise * 1.6); + float3 leafDark = float3(0.07, 0.21, 0.09); + float3 leafMid = float3(0.18, 0.38, 0.14); + float3 leafLight = float3(0.40, 0.62, 0.22); + float3 color = mix(leafDark, leafMid, smoothstep(0.10, 0.70, leafNoise)); + color = mix(color, leafLight, smoothstep(0.55, 0.95, leafNoise) * 0.6); + color += (fineCells - 0.5) * float3(0.04, 0.06, 0.025); + + // --------------------------------------------------------------- + // Midrib (central spine). Single curve traversing the full height. + // x_mid(y) is the spine's horizontal position. We measure the + // signed distance from the spine and the arc-length s along it. + // --------------------------------------------------------------- + float y = uv.y; + float spineWobble = sin(y * 1.4 + t * 0.18) * 0.06 + + sin(y * 3.1 - t * 0.11) * 0.025 + + (california_fbm(float2(y * 1.1, t * 0.08)) - 0.5) * 0.05; + float spineX = spineWobble + warp.x * 0.4; + float dxSpine = uv.x - spineX; + float distSpine = abs(dxSpine); + // arc-length parameter along the spine, 0 at base (bottom) -> 1 at tip + float sSpine = clamp((y + 1.0) * 0.5, 0.0, 1.0); + + // Spine tapers: thick at base, fine at tip — like a real midrib. + float spineWidth = mix(0.024, 0.006, sSpine); + float spine = 1.0 - smoothstep(spineWidth, spineWidth + 0.020, distSpine); + float spineGlow = 1.0 - smoothstep(spineWidth + 0.010, spineWidth + 0.080, distSpine); + + // --------------------------------------------------------------- + // Lateral veins. Indexed by y-cell along the spine, alternating + // sides. Each lateral STARTS at the spine (u=0) and grows outward + // (u=1). It curves forward (toward tip) as it extends so the leaf + // reads as a pinnate venation pattern, not a ladder. + // --------------------------------------------------------------- + float vein = 0.0; + float veinGlow = 0.0; + float subVein = 0.0; + float flow = 0.0; + float flowCore = 0.0; + + const float lateralCount = 11.0; // laterals visible per side, roughly + float yBase = -1.85; + float yTop = 1.85; + float ySpan = yTop - yBase; + float cellH = ySpan / lateralCount; + + // Search nearby cells so adjacent laterals can overlap softly. + float cellIndex = floor((y - yBase) / cellH); + + for (int k = -1; k <= 1; k++) { + float id = cellIndex + float(k); + // Anchor point on the spine where this lateral emerges. + float yAnchor = yBase + (id + 0.5) * cellH; + float seed = id * 2.137; + // Alternate sides; small jitter so it doesn't look mechanical. + float side = (fmod(id, 2.0) < 0.5) ? 1.0 : -1.0; + side *= (sin(seed * 1.7) > -0.85) ? 1.0 : -1.0; // rare double-side + + // Anchor X tracks the spine exactly at yAnchor so the vein + // physically touches the midrib. + float anchorSpineWobble = + sin(yAnchor * 1.4 + t * 0.18) * 0.06 + + sin(yAnchor * 3.1 - t * 0.11) * 0.025 + + (california_fbm(float2(yAnchor * 1.1, t * 0.08)) - 0.5) * 0.05; + float2 anchor = float2(anchorSpineWobble + warp.x * 0.4, yAnchor); + + // Lateral reach + forward curl. The vein bends toward the tip + // of the leaf as it extends, giving the classic pinnate look. + float reach = 0.42 + 0.10 * sin(seed * 1.3); + float curl = 0.22 + 0.08 * sin(seed * 0.7); + + // Closed-form nearest-point along a quadratic Bezier is heavy; + // instead sample a few points and take the minimum distance. + // Six samples is plenty for a smooth feel. + float bestDist = 1e9; + float bestU = 0.0; + for (int j = 0; j < 7; j++) { + float u = float(j) / 6.0; + // Quadratic curve: starts at anchor, ends at tip-curled point. + float2 end = anchor + float2(side * reach, curl); + float2 ctrl = anchor + float2(side * reach * 0.45, curl * 0.15); + float2 p = mix(mix(anchor, ctrl, u), mix(ctrl, end, u), u); + // Subtle organic wiggle along the lateral. + p += float2(0.0, sin(u * 9.0 + seed) * 0.010); + float d = distance(uv, p); + if (d < bestDist) { bestDist = d; bestU = u; } + } + + // Lateral tapers from spine outward. + float lateralWidth = mix(0.014, 0.004, bestU); + float lateral = 1.0 - smoothstep(lateralWidth, lateralWidth + 0.014, bestDist); + float lateralOuter = 1.0 - smoothstep(lateralWidth + 0.008, lateralWidth + 0.055, bestDist); + + // Tertiary veins: short, fine hairs branching off the lateral. + // They emerge perpendicular to the lateral's local tangent, so + // they too remain physically attached to their parent. + float subStripe = fract(bestU * 5.0 + seed * 0.3); + float subActive = smoothstep(0.20, 0.30, bestU) * + (1.0 - smoothstep(0.80, 0.95, bestU)) * + smoothstep(0.30, 0.50, subStripe) * + (1.0 - smoothstep(0.55, 0.75, subStripe)); + float subDist = bestDist; // proximity to parent vein + float sub = (1.0 - smoothstep(0.006, 0.022, subDist)) * subActive * 0.55; + + vein = max(vein, lateral); + veinGlow = max(veinGlow, lateralOuter); + subVein = max(subVein, sub); + + // Flow continues from the spine onto each lateral. The phase + // is keyed to the lateral's anchor height so sap rises from + // base to tip across the entire leaf as one wave, not as + // isolated blinks. + float lateralPhase = (yAnchor - yBase) / ySpan; // 0..1 along leaf + float fLateral = california_flow(bestU * 0.55 + lateralPhase, + 0.18, lateralPhase * 0.6, t); + flow = max(flow, fLateral * lateral); + flowCore = max(flowCore, fLateral * (1.0 - smoothstep(0.002, lateralWidth, bestDist))); + } + + // Spine flow: same global wave, evaluated along the spine. + float fSpine = california_flow(sSpine, 0.18, 0.0, t); + flow = max(flow, fSpine * spine); + flowCore = max(flowCore, fSpine * (1.0 - smoothstep(0.003, spineWidth, distSpine))); + + // Combine vein masks. Spine + laterals + tertiary all share a + // single mask so they read as one network. + float veinMask = max(spine, max(vein, subVein)); + float veinEdge = max(spineGlow, veinGlow); + + // Micro-texture along the veins (cell walls). + float micro = california_fbm(wuv * 32.0 + float2(t * 0.15, -t * 0.10)); + veinMask *= 0.80 + micro * 0.22; + veinEdge *= 0.75 + micro * 0.18; + + float3 veinColor = float3(0.030, 0.080, 0.030); + float3 veinRim = float3(0.52, 0.74, 0.26); + color = mix(color, veinRim, veinEdge * 0.30); + color = mix(color, veinColor, veinMask * 0.78); + + // Sap glow: warm chartreuse traveling through the connected system. + float3 sap = float3(0.78, 1.00, 0.20); + float3 sapCore = float3(0.98, 1.00, 0.70); + color += sap * flow * 0.70; + color += sapCore * flowCore * 0.95; + + // Soft vignette so the leaf sits in space. + float vignette = 1.0 - smoothstep(1.5, 3.2, length(uv)); + color *= mix(0.60, 1.0, vignette); + + return float4(clamp(color, 0.0, 1.0), 1.0); +} +""" + +private struct MetalCaliforniaFragmentUniforms { + var resolution: SIMD2 + var time: Float +} + +final class CaliforniaLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? CaliforniaLayer else { return } + self.totalElapsedTime = other.totalElapsedTime + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalCaliforniaShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_california"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_california") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + private weak var currentFruit: Fruit? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit) { + currentFruit = fruit + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + var uniforms = MetalCaliforniaFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + time: Float(totalElapsedTime) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} diff --git a/FruitFarm/Backgrounds/Types/GlassLayer.swift b/FruitFarm/Backgrounds/Types/GlassLayer.swift new file mode 100644 index 0000000..34137b4 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/GlassLayer.swift @@ -0,0 +1,353 @@ +// swiftlint:disable file_length +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalGlassShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_glass( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct GlassUniforms { + float2 resolution; + float2 body_center_px; + float2 body_half_px; + float time; + float color_phase; +}; + +float glass_hash(float2 p) { + return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123); +} + +float glass_noise(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + float2 u = f * f * (3.0 - 2.0 * f); + float a = glass_hash(i); + float b = glass_hash(i + float2(1.0, 0.0)); + float c = glass_hash(i + float2(0.0, 1.0)); + float d = glass_hash(i + float2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +float glass_fbm(float2 p) { + float v = 0.0; + float amp = 0.5; + for (int i = 0; i < 4; i++) { + v += amp * glass_noise(p); + p = float2(p.x * 1.82 + p.y * 0.44, + p.y * 1.76 - p.x * 0.39) + 13.0; + amp *= 0.52; + } + return v; +} + +// Anisotropic gaussian bloom in apple-relative space. +float glass_bloom(float2 lp, float2 center, float2 axes, float falloff) { + float2 d = (lp - center) / max(axes, float2(0.001)); + return exp(-falloff * dot(d, d)); +} + +fragment float4 fragment_shader_glass( + VertexOut in [[stage_in]], + constant GlassUniforms &uniforms [[buffer(0)]]) { + + // Apple-relative coordinates: roughly [-1, 1] across the fruit body. + // lp.y > 0 is the lower half of the fruit (Metal y-down). + float2 half_px = max(uniforms.body_half_px, float2(1.0)); + float2 lp = (in.position.xy - uniforms.body_center_px) / half_px; + + float loop = fract(uniforms.time / 14.0); + float a = loop * 6.28318530718; + + float2 skew = float2( + lp.x + 0.22 * lp.y + 0.10 * sin(lp.y * 4.0 + cos(a) * 1.2), + lp.y - 0.15 * lp.x + 0.08 * sin(lp.x * 5.0 + sin(a) * 1.4) + ); + float2 bend = float2( + skew.x + 0.18 * sin(skew.y * 3.4 + sin(a)), + skew.y + 0.14 * sin(skew.x * 4.2 + cos(a)) + ); + + float edgeShape = max(abs(bend.x * 0.82 + 0.08 * sin(bend.y * 6.0)), + abs(bend.y * 0.92 - 0.10 * sin(bend.x * 5.0))); + float edgeWeight = smoothstep(0.52, 1.08, edgeShape); + float rim = smoothstep(0.72, 1.02, edgeShape) * (1.0 - smoothstep(1.02, 1.26, edgeShape)); + + float stressA = sin(bend.x * 5.1 + bend.y * 1.9 + sin(a) * 1.4); + float stressB = sin(bend.y * 4.3 - bend.x * 2.7 + cos(a * 2.0) * 1.1); + float stressC = sin((bend.x + bend.y * 0.6) * 7.2 + sin(a * 3.0) * 0.9); + float baseBand = exp(-pow((lp.y - 0.72 - 0.08 * sin(lp.x * 4.0 + sin(a))) * 4.0, 2.0)); + float retardance = 4.0 + + stressA * 2.0 + + stressB * 1.7 + + stressC * 0.9 + + edgeWeight * 8.5 + + baseBand * 5.0 + + sin(a) * 1.2; + + float3 spectrum = 0.5 + 0.5 * cos( + retardance * float3(0.92, 1.19, 1.55) + float3(0.0, 2.1, 4.35) + ); + spectrum = pow(clamp(spectrum, 0.0, 1.0), float3(0.38)); + + float3 baseGlass = float3(0.085, 0.075, 0.105); + float3 color = baseGlass + spectrum * (0.48 + edgeWeight * 0.78); + color += spectrum * rim * 2.10; + + float2 dRed = float2(0.10 * cos(a), 0.05 * sin(a * 2.0)); + float2 dCyan = float2(0.06 * sin(a * 2.0 + 0.8), 0.10 * cos(a)); + float2 dYellow = float2(0.12 * cos(a * 3.0 + 1.1), 0.05 * sin(a)); + float2 dMagenta = float2(0.08 * sin(a + 2.4), 0.04 * cos(a * 3.0)); + float2 dCyanTop = float2(0.05 * cos(a * 2.0 + 4.1), 0.07 * sin(a + 0.3)); + + float redCorner = glass_bloom(bend, float2(-0.92, 0.78) + dRed, float2(0.52, 0.36), 5.0); + float cyanCorner = glass_bloom(bend, float2( 0.88, 0.56) + dCyan, float2(0.42, 0.52), 5.2); + float yellowBase = glass_bloom(bend, float2(-0.02, 0.98) + dYellow, float2(0.82, 0.24), 4.2); + float magentaTop = glass_bloom(bend, float2( 0.35, -0.88) + dMagenta, float2(0.42, 0.24), 6.0); + float cyanTop = glass_bloom(bend, float2(-0.58, -0.72) + dCyanTop, float2(0.34, 0.26), 6.0); + + float pulseRed = 0.85 + 0.25 * sin(a); + float pulseCyan = 0.85 + 0.25 * sin(a + 2.0); + float pulseYellow = 0.85 + 0.25 * sin(a + 4.0); + + color += float3(1.00, 0.12, 0.03) * redCorner * 2.45 * pulseRed; + color += float3(0.00, 0.95, 1.00) * cyanCorner * 2.25 * pulseCyan; + color += float3(1.00, 0.90, 0.12) * yellowBase * 2.55 * pulseYellow; + color += float3(1.00, 0.12, 0.58) * magentaTop * 1.35 * pulseRed; + color += float3(0.08, 0.88, 1.00) * cyanTop * 1.30 * pulseCyan; + + float stressRibbon = exp(-pow((bend.y - 0.16 + 0.16 * sin(bend.x * 3.0 + a)) * 3.0, 2.0)); + color += spectrum * stressRibbon * 0.45; + + // Soft scanline modulation typical of polarized LCD viewing. + float scan = sin(in.position.y * 1.8) * 0.5 + 0.5; + color *= 1.08 + 0.08 * scan; + color = pow(color, float3(0.88)); + + color = clamp(color, 0.0, 1.0); + return float4(color, 1.0); +} +""" + +private struct MetalGlassFragmentUniforms { + var resolution: SIMD2 + // swiftlint:disable identifier_name + var body_center_px: SIMD2 + var body_half_px: SIMD2 + var time: Float + var color_phase: Float + // swiftlint:enable identifier_name +} + +// swiftlint:disable:next type_body_length +final class GlassLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var colorPhase: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? GlassLayer else { return } + + // Only copy plain Swift state; presentation copies cannot touch Metal layer state safely. + self.totalElapsedTime = other.totalElapsedTime + self.colorPhase = other.colorPhase + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalGlassShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_glass"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_glass") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + private weak var currentFruit: Fruit? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit) { + currentFruit = fruit + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + colorPhase = (totalElapsedTime * 0.08).truncatingRemainder(dividingBy: 1.0) + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + let cs = contentsScale + var bodyCenterPx = SIMD2( + Float(texture.width) * 0.5, + Float(texture.height) * 0.5 + ) + var bodyHalfPx = SIMD2( + Float(texture.width) * 0.25, + Float(texture.height) * 0.25 + ) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let centerX = body.midX * cs + // Convert NSRect (origin bottom-left) to Metal pixel coords (origin top-left). + let centerY = (bounds.height - body.midY) * cs + bodyCenterPx = SIMD2(Float(centerX), Float(centerY)) + bodyHalfPx = SIMD2( + Float((body.width * 0.5) * cs), + Float((body.height * 0.5) * cs) + ) + } + + var uniforms = MetalGlassFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + body_center_px: bodyCenterPx, + body_half_px: bodyHalfPx, + time: Float(totalElapsedTime), + color_phase: Float(colorPhase) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/Backgrounds/Types/GlueLayer.swift b/FruitFarm/Backgrounds/Types/GlueLayer.swift new file mode 100644 index 0000000..f87b913 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/GlueLayer.swift @@ -0,0 +1,418 @@ +// swiftlint:disable file_length +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalGlueShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_glue( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct GlueUniforms { + float2 resolution; + float2 body_center_px; + float2 body_half_px; + float time; +}; + +float glue_hash(float2 p) { + return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123); +} + +float2 glue_hash2(float2 p) { + return fract(sin(float2( + dot(p, float2(269.5, 183.3)), + dot(p, float2(113.5, 271.9)) + )) * 43758.5453); +} + +float glue_noise(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + float2 u = f * f * (3.0 - 2.0 * f); + + float a = glue_hash(i); + float b = glue_hash(i + float2(1.0, 0.0)); + float c = glue_hash(i + float2(0.0, 1.0)); + float d = glue_hash(i + float2(1.0, 1.0)); + + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +float glue_fbm(float2 p) { + float v = 0.0; + float amp = 0.5; + + for (int i = 0; i < 4; i++) { + v += glue_noise(p) * amp; + p = float2(p.x * 1.7 + p.y * 0.3, p.y * 1.8 - p.x * 0.2) + 11.7; + amp *= 0.5; + } + + return v; +} + +float3 glue_cycle_color(float cycleSeed) { + return 0.5 + 0.5 * cos( + 6.2831853 * (cycleSeed + float3(0.00, 0.33, 0.67)) + ); +} + +float3 glue_reactor_palette(float heat, float seed, float cycleSeed) { + float3 idle = float3(0.006, 0.018, 0.011); + float3 reactorColor = glue_cycle_color(cycleSeed); + float3 hotColor = mix(reactorColor, float3(1.0, 0.88, 0.18), 0.45); + float3 white = float3(1.00, 0.96, 0.74); + + float3 color = mix(idle, reactorColor, smoothstep(0.02, 0.48, heat)); + color = mix(color, hotColor, smoothstep(0.38, 0.78, heat)); + color = mix(color, white, smoothstep(0.74, 1.0, heat)); + color *= 0.86 + seed * 0.28; + return color; +} + +float glue_ignition_delay(float2 cell, float hotspot) { + float seed = glue_hash(cell); + float neighborSeed = glue_noise(cell * 0.018); + return mix(0.06, 0.86, seed) - neighborSeed * 0.12 - hotspot; +} + +float glue_cell_preheat(float2 cell, float igniteProgress, float fadeProgress, float hotspot) { + float ignitionDelay = glue_ignition_delay(cell, hotspot); + float preheatIn = smoothstep(ignitionDelay - 0.22, ignitionDelay + 0.02, igniteProgress); + float preheatOut = smoothstep(ignitionDelay - 0.22, ignitionDelay + 0.02, fadeProgress); + return preheatIn * (1.0 - preheatOut) * 0.32; +} + +float glue_cell_heat( + float2 cell, + float igniteProgress, + float fadeProgress, + float fullSaturation, + float hotspot, + float cycle, + float time) { + + float ignitionDelay = glue_ignition_delay(cell, hotspot); + float litIn = smoothstep(ignitionDelay - 0.018, ignitionDelay + 0.030, igniteProgress); + float litOut = smoothstep(ignitionDelay - 0.018, ignitionDelay + 0.030, fadeProgress); + float lit = max(litIn, fullSaturation) * (1.0 - litOut); + float preheat = glue_cell_preheat(cell, igniteProgress, fadeProgress, hotspot); + float flicker = glue_hash(cell + float2(floor(time * 18.0))) * 0.24; + float surge = smoothstep(0.32, 0.50, cycle) * (1.0 - smoothstep(0.58, 0.82, cycle)) * flicker; + return clamp(max(lit, preheat) + surge * lit, 0.0, 1.0); +} + +float2 glue_neighbor_direction(float2 cell) { + float pick = glue_hash(cell + 37.1); + if (pick < 0.25) { + return float2(1.0, 0.0); + } + if (pick < 0.50) { + return float2(-1.0, 0.0); + } + if (pick < 0.75) { + return float2(0.0, 1.0); + } + return float2(0.0, -1.0); +} + +float glue_capsule(float2 p, float2 a, float2 b, float radius) { + float2 ba = b - a; + float h = clamp(dot(p - a, ba) / max(dot(ba, ba), 0.0001), 0.0, 1.0); + return 1.0 - smoothstep(radius, radius + 0.54, length(p - (a + ba * h))); +} + +fragment float4 fragment_shader_glue( + VertexOut in [[stage_in]], + constant GlueUniforms &uniforms [[buffer(0)]]) { + + float2 half_px = max(uniforms.body_half_px, float2(1.0)); + float2 lp = (in.position.xy - uniforms.body_center_px) / half_px; + float loopTime = uniforms.time / 50.0; + float cycle = fract(loopTime); + float cycleSeed = glue_hash(float2(floor(loopTime), 17.3)); + float loopFade = smoothstep(0.00, 0.08, cycle) * (1.0 - smoothstep(0.92, 1.0, cycle)); + float2 component = lp * float2(1.0, half_px.y / max(half_px.x, 1.0)); + + float cellSizePx = 4.0; + float2 gridUv = in.position.xy / cellSizePx; + float2 cell = floor(gridUv); + + float hotspotA = 1.0 - smoothstep(0.0, 1.15, length(component - float2(-0.42, -0.22))); + float hotspotB = 1.0 - smoothstep(0.0, 1.22, length(component - float2(0.48, 0.26))); + float hotspot = max(hotspotA, hotspotB) * 0.16; + float seed = glue_hash(cell); + + float igniteProgress = pow(clamp(cycle / 0.44, 0.0, 1.0), 2.65); + float fadeProgress = pow(clamp((cycle - 0.56) / 0.44, 0.0, 1.0), 2.65); + float fullSaturation = 0.75 * smoothstep(0.40, 0.48, cycle) * (1.0 - smoothstep(0.56, 0.64, cycle)); + float preheat = glue_cell_preheat(cell, igniteProgress, fadeProgress, hotspot); + float heat = glue_cell_heat(cell, igniteProgress, fadeProgress, fullSaturation, hotspot, cycle, uniforms.time); + + float shape = 0.0; + float shapeHeat = 0.0; + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + float2 offset = float2(float(x), float(y)); + float2 sourceCell = cell + offset; + float sourceHeat = glue_cell_heat(sourceCell, igniteProgress, fadeProgress, fullSaturation, hotspot, cycle, uniforms.time); + float2 direction = glue_neighbor_direction(sourceCell); + float neighborHeat = glue_cell_heat( + sourceCell + direction, + igniteProgress, + fadeProgress, + fullSaturation, + hotspot, + cycle, + uniforms.time + ); + float merge = smoothstep(0.10, 0.72, min(sourceHeat, neighborHeat)); + float2 sourceLocalPx = (gridUv - sourceCell) * cellSizePx; + float2 centerPx = float2(cellSizePx * 0.5); + float circleDistance = length(sourceLocalPx - centerPx); + float circle = 1.0 - smoothstep(1.42, 1.95, circleDistance); + float bridge = glue_capsule( + sourceLocalPx, + centerPx, + centerPx + direction * cellSizePx, + mix(0.16, 1.24, merge) + ) * merge; + float sourceShape = max(circle, bridge); + shape = max(shape, sourceShape); + shapeHeat = max(shapeHeat, sourceHeat * sourceShape); + } + } + float pixelCore = shape; + heat = max(heat, shapeHeat); + float3 color = glue_reactor_palette(heat, seed, cycleSeed) * pixelCore; + + float3 background = float3(0.0); + color += background; + + float bloom = heat * smoothstep(0.78, 1.0, heat) * 0.36; + color += glue_cycle_color(cycleSeed) * bloom * pixelCore; + color += float3(glue_fbm(in.position.xy * 0.45 + uniforms.time * 0.4) * 0.018); + + float vignette = smoothstep(1.55, 0.20, length(lp * float2(0.82, 0.94))); + color *= 0.72 + 0.28 * vignette; + color *= 0.75; + color *= loopFade; + color = pow(clamp(color, 0.0, 1.0), float3(0.86)); + return float4(color, 1.0); +} +""" + +private struct MetalGlueFragmentUniforms { + var resolution: SIMD2 + // swiftlint:disable identifier_name + var body_center_px: SIMD2 + var body_half_px: SIMD2 + var time: Float + // swiftlint:enable identifier_name +} + +// swiftlint:disable:next type_body_length +final class GlueLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? GlueLayer else { return } + + self.totalElapsedTime = other.totalElapsedTime + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalGlueShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_glue"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_glue") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + private weak var currentFruit: Fruit? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit) { + currentFruit = fruit + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + let cs = contentsScale + var bodyCenterPx = SIMD2( + Float(texture.width) * 0.5, + Float(texture.height) * 0.5 + ) + var bodyHalfPx = SIMD2( + Float(texture.width) * 0.25, + Float(texture.height) * 0.25 + ) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let centerX = body.midX * cs + let centerY = (bounds.height - body.midY) * cs + bodyCenterPx = SIMD2(Float(centerX), Float(centerY)) + bodyHalfPx = SIMD2( + Float((body.width * 0.5) * cs), + Float((body.height * 0.5) * cs) + ) + } + + var uniforms = MetalGlueFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + body_center_px: bodyCenterPx, + body_half_px: bodyHalfPx, + time: Float(totalElapsedTime) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/Backgrounds/Types/MetallicLayer.swift b/FruitFarm/Backgrounds/Types/MetallicLayer.swift new file mode 100644 index 0000000..95aec4c --- /dev/null +++ b/FruitFarm/Backgrounds/Types/MetallicLayer.swift @@ -0,0 +1,351 @@ +// swiftlint:disable file_length +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +private let metalMetallicShaderSource = """ +using namespace metal; + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_metallic( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct MetallicUniforms { + float2 resolution; + float2 body_center_px; + float2 body_half_px; + float time; +}; + +float metallic_hash(float2 p) { + return fract(sin(dot(p, float2(41.0, 289.0))) * 45758.5453); +} + +float metallic_line(float value, float width) { + return exp(-(value * value) / max(width * width, 0.0001)); +} + +float metallic_bloom(float2 lp, float2 center, float2 axes, float falloff) { + float2 d = (lp - center) / max(axes, float2(0.001)); + return exp(-falloff * dot(d, d)); +} + +// Smooth gallium-like heightfield: overlapping soft blobs + wave folds. +float metallic_gallium(float2 p, float a) { + float2 q = p; + q.x += 0.30 * sin(p.y * 1.8 + sin(a) * 1.1); + q.y += 0.22 * cos(p.x * 2.2 + cos(a) * 0.9); + + float v = 0.0; + float2 c0 = float2(-0.55 + 0.15 * cos(a), -0.30 + 0.12 * sin(a)); + float2 c1 = float2( 0.50 + 0.12 * sin(a * 2.0), 0.25 + 0.10 * cos(a)); + float2 c2 = float2(-0.10 + 0.10 * cos(a * 3.0), 0.72 + 0.06 * sin(a)); + float2 c3 = float2( 0.80 + 0.08 * cos(a), -0.55 + 0.10 * sin(a * 2.0)); + float2 c4 = float2(-0.75 + 0.06 * sin(a * 2.0), 0.45 + 0.08 * cos(a * 3.0)); + float2 c5 = float2( 0.15 + 0.12 * sin(a), -0.78 + 0.06 * cos(a * 2.0)); + + v += exp(-3.0 * dot(q - c0, q - c0)); + v += exp(-2.6 * dot(q - c1, q - c1)); + v += exp(-2.8 * dot(q - c2, q - c2)); + v += exp(-3.5 * dot(q - c3, q - c3)); + v += exp(-4.0 * dot(q - c4, q - c4)); + v += exp(-3.2 * dot(q - c5, q - c5)); + + v += 0.35 * sin(q.x * 2.2 + q.y * 1.5 + sin(a) * 1.4); + v += 0.25 * cos(q.x * 1.6 - q.y * 2.8 + cos(a) * 1.2); + v += 0.18 * sin((q.x + q.y) * 3.2 + sin(a * 2.0)); + return v; +} + +float3 metallic_surface_color(float2 lp, float a) { + float2 p = float2(lp.x + lp.y * 0.12, lp.y - lp.x * 0.08); + + // Heightfield and numeric surface normals + float e = 0.010; + float hx = metallic_gallium(p + float2(e, 0.0), a) - metallic_gallium(p - float2(e, 0.0), a); + float hy = metallic_gallium(p + float2(0.0, e), a) - metallic_gallium(p - float2(0.0, e), a); + float3 n = normalize(float3(-hx * 5.0, -hy * 5.0, 0.28)); + + // Environment: bright top hemisphere, grey sides, dark bottom + float3 envUp = float3(0.72, 0.74, 0.78); + float3 envSide = float3(0.28, 0.32, 0.38); + float3 envDown = float3(0.03, 0.03, 0.04); + float3 envColor = mix(envDown, envUp, smoothstep(-0.75, 0.55, n.y)); + envColor = mix(envColor, envSide, smoothstep(0.15, 0.85, abs(n.x)) * 0.55); + + // Fresnel: steep edges reflect more + float fresnel = pow(1.0 - clamp(n.z, 0.0, 1.0), 3.0); + float3 color = envColor * (0.50 + 0.50 * fresnel); + + // Three specular lights for narrow highlights + float3 L1 = normalize(float3(-0.30, -0.55, 0.78)); + float3 L2 = normalize(float3( 0.50, 0.28, 0.82)); + float3 L3 = normalize(float3( 0.0, -0.80, 0.60)); + float spec1 = pow(clamp(dot(n, L1), 0.0, 1.0), 56.0); + float spec2 = pow(clamp(dot(n, L2), 0.0, 1.0), 42.0); + float spec3 = pow(clamp(dot(n, L3), 0.0, 1.0), 68.0); + color += float3(1.00, 0.99, 0.94) * spec1 * 0.90; + color += float3(0.85, 0.92, 1.00) * spec2 * 0.70; + color += float3(1.00, 0.97, 0.90) * spec3 * 0.55; + + // Deep narrow creases where surface curves away sharply + float crease = smoothstep(0.38, 0.10, n.z); + color *= 1.0 - crease * 0.92; + + // Broad bright areas where surface faces viewer + float facing = smoothstep(0.50, 0.92, n.z); + color += float3(0.90, 0.92, 0.94) * facing * 0.18; + + // Warm highlights / cool shadows tinting + float lum = dot(color, float3(0.299, 0.587, 0.114)); + color = mix(color * float3(0.90, 0.93, 1.06), color * float3(1.04, 1.01, 0.96), smoothstep(0.28, 0.72, lum)); + + // Reduce overall brightness, increase contrast + color *= 0.58; + color = (color - 0.5) * 1.55 + 0.5; + + return color; +} + +fragment float4 fragment_shader_metallic( + VertexOut in [[stage_in]], + constant MetallicUniforms &uniforms [[buffer(0)]]) { + + float2 half_px = max(uniforms.body_half_px, float2(1.0)); + float2 lp = (in.position.xy - uniforms.body_center_px) / half_px; + + float loop = fract(uniforms.time / 14.0); + float a = loop * 6.28318530718; + + float2 chromaDirection = normalize(lp + float2(0.0001)); + float2 chromaOffset = chromaDirection * 0.0055; + + float3 centerColor = metallic_surface_color(lp, a); + float3 splitColor = float3( + metallic_surface_color(lp + chromaOffset, a).r, + centerColor.g, + metallic_surface_color(lp - chromaOffset, a).b + ); + float3 color = mix(centerColor, splitColor, 0.55); + + // Fine film-grain noise (per-pixel, temporal) + float grain = metallic_hash(floor(in.position.xy) + fract(uniforms.time * 37.0)) - 0.5; + color += float3(grain * 0.025); + + color = pow(clamp(color, 0.0, 1.0), float3(0.94)); + return float4(color, 1.0); +} +""" + +private struct MetalMetallicFragmentUniforms { + var resolution: SIMD2 + // swiftlint:disable identifier_name + var body_center_px: SIMD2 + var body_half_px: SIMD2 + var time: Float + // swiftlint:enable identifier_name +} + +// swiftlint:disable:next type_body_length +final class MetallicLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? MetallicLayer else { return } + + // Presentation copies should only copy plain Swift state. + self.totalElapsedTime = other.totalElapsedTime + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalMetallicShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_metallic"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_metallic") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + private weak var currentFruit: Fruit? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit) { + currentFruit = fruit + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + let cs = contentsScale + var bodyCenterPx = SIMD2( + Float(texture.width) * 0.5, + Float(texture.height) * 0.5 + ) + var bodyHalfPx = SIMD2( + Float(texture.width) * 0.25, + Float(texture.height) * 0.25 + ) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let centerX = body.midX * cs + let centerY = (bounds.height - body.midY) * cs + bodyCenterPx = SIMD2(Float(centerX), Float(centerY)) + bodyHalfPx = SIMD2( + Float((body.width * 0.5) * cs), + Float((body.height * 0.5) * cs) + ) + } + + var uniforms = MetalMetallicFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + body_center_px: bodyCenterPx, + body_half_px: bodyHalfPx, + time: Float(totalElapsedTime) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fb = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let cs = contentsScale + let sx = max(0, Int(fb.minX * cs)) + let sy = max(0, Int((bounds.height - fb.maxY) * cs)) + let sw = min(Int(fb.width * cs), texture.width - sx) + let sh = min(Int(fb.height * cs), texture.height - sy) + if sw > 0 && sh > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) + } + } + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/Backgrounds/Types/OceanLayer.swift b/FruitFarm/Backgrounds/Types/OceanLayer.swift index 77a6285..9dbd39d 100644 --- a/FruitFarm/Backgrounds/Types/OceanLayer.swift +++ b/FruitFarm/Backgrounds/Types/OceanLayer.swift @@ -28,137 +28,253 @@ struct OceanUniforms { float time; }; +constant int OCEAN_NUM_STEPS = 32; +constant int OCEAN_ITER_GEOMETRY = 3; +constant int OCEAN_ITER_FRAGMENT = 5; +constant float OCEAN_EPSILON = 0.001; +constant float OCEAN_HEIGHT = 0.6; +constant float OCEAN_CHOPPY = 4.0; +constant float OCEAN_SPEED = 0.8; +constant float OCEAN_FREQ = 0.16; +constant float OCEAN_CONTRAST = 1.22; +constant float OCEAN_CAMERA_TIME_SCALE = 0.08; +constant float OCEAN_BUBBLE_STRENGTH = 0.38; +constant float3 OCEAN_BASE = float3(0.0, 0.09, 0.18); +constant float3 OCEAN_WATER_COLOR = float3(0.48, 0.54, 0.36); + +float3x3 ocean_from_euler(float3 ang) { + float2 a1 = float2(sin(ang.x), cos(ang.x)); + float2 a2 = float2(sin(ang.y), cos(ang.y)); + float2 a3 = float2(sin(ang.z), cos(ang.z)); + + return float3x3( + float3(a1.y * a3.y + a1.x * a2.x * a3.x, + a1.y * a2.x * a3.x + a3.y * a1.x, + -a2.y * a3.x), + float3(-a2.y * a1.x, + a1.y * a2.y, + a2.x), + float3(a3.y * a1.x * a2.x + a1.y * a3.x, + a1.x * a3.x - a1.y * a3.y * a2.x, + a2.y * a3.y) + ); +} + float ocean_hash(float2 p) { - float3 p3 = fract(float3(p.xyx) * float3(0.1031, 0.1030, 0.0973)); - p3 += dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); + float h = dot(p, float2(127.1, 311.7)); + return fract(sin(h) * 43758.5453123); } float ocean_noise(float2 p) { float2 i = floor(p); float2 f = fract(p); float2 u = f * f * (3.0 - 2.0 * f); - return mix( - mix(ocean_hash(i), ocean_hash(i + float2(1.0, 0.0)), u.x), + + float v = mix( + mix(ocean_hash(i + float2(0.0, 0.0)), ocean_hash(i + float2(1.0, 0.0)), u.x), mix(ocean_hash(i + float2(0.0, 1.0)), ocean_hash(i + float2(1.0, 1.0)), u.x), u.y ); + return -1.0 + 2.0 * v; +} + +float ocean_diffuse(float3 n, float3 l, float p) { + return pow(dot(n, l) * 0.4 + 0.6, p); +} + +float ocean_specular(float3 n, float3 l, float3 eye, float s) { + float nrm = (s + 8.0) / (3.14159265 * 8.0); + return pow(max(dot(reflect(eye, n), l), 0.0), s) * nrm; +} + +float3 ocean_sky_color(float3 eye) { + eye.y = max(eye.y, 0.0); + float horizon = 1.0 - eye.y; + return float3( + pow(horizon, 2.0), + horizon, + 0.6 + horizon * 0.4 + ); +} + +float ocean_octave(float2 uv, float choppy) { + uv += float2(ocean_noise(uv)); + float2 wave = float2(1.0) - abs(sin(uv)); + float2 swell = abs(cos(uv)); + wave = mix(wave, swell, wave); + return pow(1.0 - pow(wave.x * wave.y, 0.65), choppy); +} + +float2 ocean_rotate_octave(float2 uv) { + return float2x2( + float2(1.6, 1.2), + float2(-1.2, 1.6) + ) * uv; } -float ocean_fbm(float2 p, int octaves) { - float v = 0.0; - float a = 0.5; - float2x2 rot = float2x2(0.8, 0.6, -0.6, 0.8); - for (int i = 0; i < octaves; i++) { - v += a * ocean_noise(p); - p = rot * p * 2.01; - a *= 0.49; +float ocean_map_with_iterations(float3 p, int iterations, float seaTime) { + float freq = OCEAN_FREQ; + float amp = OCEAN_HEIGHT; + float choppy = OCEAN_CHOPPY; + float height = 0.0; + float2 uv = p.xz; + uv.x *= 0.75; + + for (int i = 0; i < iterations; i++) { + float wave = ocean_octave((uv + seaTime) * freq, choppy); + wave += ocean_octave((uv - seaTime) * freq, choppy); + height += wave * amp; + uv = ocean_rotate_octave(uv); + freq *= 1.9; + amp *= 0.22; + choppy = mix(choppy, 1.0, 0.2); } - return v; + + return p.y - height; } -float ocean_wave_height(float2 uv, float t) { - // Slight horizontal wobble so wave fronts aren't ruler-straight - float wobble = ocean_noise(float2(uv.x * 0.5, uv.y * 0.3) + t * 0.05) * 0.6; +float ocean_map(float3 p, float time) { + float seaTime = 1.0 + time * OCEAN_SPEED; + return ocean_map_with_iterations(p, OCEAN_ITER_GEOMETRY, seaTime); +} - // Primary rolling wave fronts — horizontal lines moving top-to-bottom - float waves = sin(uv.y * 1.8 + wobble + t * 0.35) * 0.38; - waves += sin(uv.y * 3.2 + wobble * 0.7 + t * 0.28) * 0.18; +float ocean_map_detailed(float3 p, float time) { + float seaTime = 1.0 + time * OCEAN_SPEED; + return ocean_map_with_iterations(p, OCEAN_ITER_FRAGMENT, seaTime); +} - // Gentle cross-variation so it's not perfectly uniform across X - waves += sin(uv.x * 0.3 + uv.y * 2.4 + t * 0.22) * 0.08; +float ocean_height_map_tracing(float3 origin, float3 direction, float time, thread float3 &p) { + float nearDistance = 0.0; + float farDistance = 1000.0; + float farHeight = ocean_map(origin + direction * farDistance, time); - // Surface texture — moderate chop riding on the wave fronts - float chop = ocean_fbm(uv * 4.0 + float2(t * 0.15, t * 0.25), 5) * 0.15; + if (farHeight > 0.0) { + p = origin + direction * farDistance; + return farDistance; + } - // Fine detail - float detail = ocean_fbm(uv * 9.0 + float2(t * 0.25, -t * 0.20), 4) * 0.06; + float nearHeight = ocean_map(origin + direction * nearDistance, time); + float midDistance = 0.0; + + for (int i = 0; i < OCEAN_NUM_STEPS; i++) { + midDistance = mix(nearDistance, farDistance, nearHeight / (nearHeight - farHeight)); + p = origin + direction * midDistance; + float midHeight = ocean_map(p, time); + + if (fabs(midHeight) < OCEAN_EPSILON) { + break; + } + + if (midHeight < 0.0) { + farDistance = midDistance; + farHeight = midHeight; + } else { + nearDistance = midDistance; + nearHeight = midHeight; + } + } - return waves + chop + detail; + return midDistance; } -// Visible surface current swirls — sampled independently from the wave field -float ocean_current_pattern(float2 uv, float t) { - float2 p1 = float2( - ocean_fbm(uv * 1.8 + float2(t * 0.12, t * 0.08), 5), - ocean_fbm(uv * 1.8 + float2(t * 0.09, -t * 0.11) + 7.3, 5) +float3 ocean_normal(float3 p, float eps, float time) { + float height = ocean_map_detailed(p, time); + float3 n = float3( + ocean_map_detailed(p + float3(eps, 0.0, 0.0), time) - height, + eps, + ocean_map_detailed(p + float3(0.0, 0.0, eps), time) - height ); - float2 p2 = float2( - ocean_fbm((uv + p1 * 1.2) * 1.6 + float2(t * 0.07, t * 0.05) + 3.1, 5), - ocean_fbm((uv + p1 * 1.2) * 1.6 + float2(-t * 0.06, t * 0.08) + 11.7, 5) + return normalize(n); +} + +float ocean_bubble_cells(float2 uv) { + float2 cell = floor(uv); + float2 local = fract(uv); + float randomValue = ocean_hash(cell); + float2 center = float2( + ocean_hash(cell + float2(13.1, 7.7)), + ocean_hash(cell + float2(3.4, 19.9)) ); - return ocean_fbm(uv + p2 * 1.4, 5); + float radius = mix(0.08, 0.22, randomValue); + float bubble = 1.0 - smoothstep(radius, radius + 0.025, distance(local, center)); + return bubble * smoothstep(0.58, 1.0, randomValue); +} + +float ocean_breaking_bubbles(float3 p, float3 n, float3 dist, float time) { + float crest = smoothstep(0.24, 0.95, p.y); + float steepness = smoothstep(0.08, 0.34, 1.0 - n.y); + float distanceFade = max(1.0 - dot(dist, dist) * 0.0015, 0.0); + float2 flow = p.xz + float2(time * 0.28, -time * 0.12); + + float fineBubbles = ocean_bubble_cells(flow * 26.0); + float clusteredBubbles = ocean_bubble_cells(flow * 13.0 + 4.7); + float streaks = smoothstep(0.42, 0.78, ocean_noise(flow * 8.0)); + + return clamp((fineBubbles * 0.72 + clusteredBubbles * 0.38) * streaks * + crest * steepness * distanceFade, 0.0, 1.0); +} + +float3 ocean_sea_color(float3 p, float3 n, float3 l, float3 eye, float3 dist, float time) { + float fresnel = clamp(1.0 - dot(n, -eye), 0.0, 1.0); + fresnel = min(pow(fresnel, 3.0), 0.5); + + float3 reflected = ocean_sky_color(reflect(eye, n)); + float3 refracted = OCEAN_BASE + OCEAN_WATER_COLOR * ocean_diffuse(n, l, 80.0) * 0.12; + float3 color = mix(refracted, reflected, float3(fresnel)); + + float atten = max(1.0 - dot(dist, dist) * 0.001, 0.0); + color += OCEAN_WATER_COLOR * (p.y - OCEAN_HEIGHT) * 0.18 * atten; + color += float3(ocean_specular(n, l, eye, 60.0)); + + float bubbles = ocean_breaking_bubbles(p, n, dist, time); + color = mix(color, float3(0.82, 0.90, 0.95), bubbles * OCEAN_BUBBLE_STRENGTH); + + return color; +} + +float3 ocean_pixel(float2 fragCoord, float2 resolution, float waveTime, float cameraTime) { + float2 uv = fragCoord / resolution * 2.0 - 1.0; + uv.x *= resolution.x / resolution.y; + + float3 origin = float3(0.0, 3.5, cameraTime * 5.0); + float3 direction = normalize(float3(uv, -2.0)); + direction.z += length(uv) * 0.14; + direction = normalize(direction); + + float3 angle = float3( + sin(cameraTime * 3.0) * 0.1, + sin(cameraTime) * 0.035 + 0.3, + cameraTime * 0.05 + ); + direction = normalize(transpose(ocean_from_euler(angle)) * direction); + + float3 p; + ocean_height_map_tracing(origin, direction, waveTime, p); + float3 dist = p - origin; + float eps = max(dot(dist, dist) * (0.1 / resolution.x), 0.001); + float3 normal = ocean_normal(p, eps, waveTime); + float3 light = normalize(float3(0.0, 1.0, 0.8)); + + float3 sky = ocean_sky_color(direction); + float3 sea = ocean_sea_color(p, normal, light, direction, dist, waveTime); + float horizonMask = pow(smoothstep(0.0, -0.02, direction.y), 0.2); + + float3 color = mix(sky, sea, float3(horizonMask)); + color = pow(max(color, float3(0.0)), float3(0.65)); + return clamp((color - 0.5) * OCEAN_CONTRAST + 0.5, float3(0.0), float3(1.0)); } fragment float4 fragment_shader_ocean( VertexOut in [[stage_in]], constant OceanUniforms &uniforms [[buffer(0)]]) { - float2 uv = (in.position.xy * 2.0 - uniforms.resolution) / - min(uniforms.resolution.x, uniforms.resolution.y); - float t = uniforms.time; - - float2 oceanUV = uv * 2.0; - - float height = ocean_wave_height(oceanUV, t); - - // Finite-difference gradient for slope / foam detection - float e = 0.015; - float hx = ocean_wave_height(oceanUV + float2(e, 0.0), t); - float hy = ocean_wave_height(oceanUV + float2(0.0, e), t); - float slope = length(float2(hx - height, hy - height) / e); - - // ---- Deep North Atlantic blue (Titanic-style) ---- - float3 abyssColor = float3(0.01, 0.02, 0.06); - float3 deepColor = float3(0.02, 0.04, 0.10); - float3 troughColor = float3(0.03, 0.06, 0.14); - float3 bodyColor = float3(0.05, 0.09, 0.20); - float3 faceColor = float3(0.07, 0.13, 0.27); - float3 crestColor = float3(0.10, 0.18, 0.34); - float3 foamColor = float3(0.50, 0.54, 0.58); - float3 sprayColor = float3(0.65, 0.68, 0.72); - - // Height-based colour mapping - float h = smoothstep(-0.6, 0.8, height); - float3 color = mix(abyssColor, deepColor, smoothstep(0.00, 0.15, h)); - color = mix(color, troughColor, smoothstep(0.15, 0.30, h)); - color = mix(color, bodyColor, smoothstep(0.30, 0.50, h)); - color = mix(color, faceColor, smoothstep(0.50, 0.70, h)); - color = mix(color, crestColor, smoothstep(0.70, 0.90, h)); - - // Subsurface glow in mid-wave translucency - float subsurface = smoothstep(0.3, 0.7, h) * (1.0 - smoothstep(0.7, 1.0, h)); - color += float3(0.005, 0.008, 0.020) * subsurface; - - // Visible surface current swirls — lighter/darker eddies flowing across the water - float current = ocean_current_pattern(oceanUV, t); - float currentShift = (current - 0.5) * 0.12; - color += color * currentShift; - - // Foam on steep wave crests - float foamMask = smoothstep(0.6, 1.0, height * 0.7 + slope * 0.25); - float foamDetail = ocean_fbm(oceanUV * 18.0 + float2(t * 0.35, t * 0.2), 5); - foamMask *= smoothstep(0.25, 0.7, foamDetail); - color = mix(color, foamColor, foamMask * 0.55); - - // Wind-blown spray tearing off the highest crests - float spray = smoothstep(0.85, 1.0, height * 0.6 + slope * 0.4); - spray *= ocean_fbm(oceanUV * 30.0 + float2(t * 0.8, t * 0.1), 4); - spray = smoothstep(0.4, 0.9, spray); - color = mix(color, sprayColor, spray * 0.35); - - // Specular glint from an overcast sky - float spec = pow(clamp(slope * 0.4, 0.0, 1.0), 4.0) * 0.10; - color += float3(0.06, 0.08, 0.12) * spec; - - // Deepen the troughs - float troughDark = 1.0 - smoothstep(-0.4, 0.1, height); - color *= 1.0 - troughDark * 0.3; - - // Vignette - float vignette = 1.0 - smoothstep(0.8, 2.0, length(uv)); - color *= mix(0.35, 1.0, vignette); - + float2 fragCoord = float2(in.position.x, uniforms.resolution.y - in.position.y); + float3 color = ocean_pixel( + fragCoord, + uniforms.resolution, + uniforms.time, + uniforms.time * OCEAN_CAMERA_TIME_SCALE + ); return float4(color, 1.0); } """ diff --git a/FruitFarm/Backgrounds/Types/POGOLayer.swift b/FruitFarm/Backgrounds/Types/POGOLayer.swift new file mode 100644 index 0000000..69c6e64 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/POGOLayer.swift @@ -0,0 +1,373 @@ +import Cocoa +import QuartzCore +import Foundation +import MetalKit + +// Portions ported from SahilK-027/0x7444ff organic-pattern shaders. +// +// MIT License +// +// Copyright (c) 2024 SK027 +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +private let metalPOGOShaderSource = """ +using namespace metal; + +// Ported from SahilK-027/0x7444ff organic-pattern shaders. +// Original project: MIT License, Copyright (c) 2024 SK027. +// Classic Perlin 3D Noise by Stefan Gustavson (https://github.com/stegu/webgl-noise). + +struct VertexData { + float2 position; +}; + +struct VertexOut { + float4 position [[position]]; +}; + +vertex VertexOut vertex_shader_pogo( + const device VertexData* vertex_array [[buffer(0)]], + unsigned int vid [[vertex_id]]) { + VertexOut out; + out.position = float4(vertex_array[vid].position, 0.0, 1.0); + return out; +} + +struct POGOUniforms { + float2 resolution; + float time; +}; + +float4 pogo_mod289(float4 x) { + return x - floor(x / 289.0) * 289.0; +} + +float3 pogo_mod289(float3 x) { + return x - floor(x / 289.0) * 289.0; +} + +float4 pogo_permute(float4 x) { + return pogo_mod289(((x * 34.0) + 1.0) * x); +} + +float4 pogo_taylor_inv_sqrt(float4 r) { + return 1.79284291400159 - 0.85373472095314 * r; +} + +float3 pogo_fade(float3 t) { + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); +} + +float pogo_cnoise(float3 point) { + float3 pi0 = floor(point); + float3 pi1 = pi0 + float3(1.0); + pi0 = pogo_mod289(pi0); + pi1 = pogo_mod289(pi1); + float3 pf0 = fract(point); + float3 pf1 = pf0 - float3(1.0); + float4 ix = float4(pi0.x, pi1.x, pi0.x, pi1.x); + float4 iy = float4(pi0.y, pi0.y, pi1.y, pi1.y); + float4 iz0 = float4(pi0.z); + float4 iz1 = float4(pi1.z); + + float4 ixy = pogo_permute(pogo_permute(ix) + iy); + float4 ixy0 = pogo_permute(ixy + iz0); + float4 ixy1 = pogo_permute(ixy + iz1); + + float4 gx0 = ixy0 / 7.0; + float4 gy0 = fract(floor(gx0) / 7.0) - 0.5; + gx0 = fract(gx0); + float4 gz0 = float4(0.5) - abs(gx0) - abs(gy0); + float4 sz0 = step(gz0, float4(0.0)); + gx0 -= sz0 * (step(float4(0.0), gx0) - 0.5); + gy0 -= sz0 * (step(float4(0.0), gy0) - 0.5); + + float4 gx1 = ixy1 / 7.0; + float4 gy1 = fract(floor(gx1) / 7.0) - 0.5; + gx1 = fract(gx1); + float4 gz1 = float4(0.5) - abs(gx1) - abs(gy1); + float4 sz1 = step(gz1, float4(0.0)); + gx1 -= sz1 * (step(float4(0.0), gx1) - 0.5); + gy1 -= sz1 * (step(float4(0.0), gy1) - 0.5); + + float3 g000 = float3(gx0.x, gy0.x, gz0.x); + float3 g100 = float3(gx0.y, gy0.y, gz0.y); + float3 g010 = float3(gx0.z, gy0.z, gz0.z); + float3 g110 = float3(gx0.w, gy0.w, gz0.w); + float3 g001 = float3(gx1.x, gy1.x, gz1.x); + float3 g101 = float3(gx1.y, gy1.y, gz1.y); + float3 g011 = float3(gx1.z, gy1.z, gz1.z); + float3 g111 = float3(gx1.w, gy1.w, gz1.w); + + float4 norm0 = pogo_taylor_inv_sqrt(float4( + dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110) + )); + g000 *= norm0.x; + g010 *= norm0.y; + g100 *= norm0.z; + g110 *= norm0.w; + + float4 norm1 = pogo_taylor_inv_sqrt(float4( + dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111) + )); + g001 *= norm1.x; + g011 *= norm1.y; + g101 *= norm1.z; + g111 *= norm1.w; + + float n000 = dot(g000, pf0); + float n100 = dot(g100, float3(pf1.x, pf0.y, pf0.z)); + float n010 = dot(g010, float3(pf0.x, pf1.y, pf0.z)); + float n110 = dot(g110, float3(pf1.x, pf1.y, pf0.z)); + float n001 = dot(g001, float3(pf0.x, pf0.y, pf1.z)); + float n101 = dot(g101, float3(pf1.x, pf0.y, pf1.z)); + float n011 = dot(g011, float3(pf0.x, pf1.y, pf1.z)); + float n111 = dot(g111, pf1); + + float3 fade_xyz = pogo_fade(pf0); + float4 n_z = mix( + float4(n000, n100, n010, n110), + float4(n001, n101, n011, n111), + fade_xyz.z + ); + float2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y); + float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x); + return 2.2 * n_xyz; +} + +float pogo_pattern(float2 uv, float time) { + float pattern = sin(0.01); + pattern -= abs(pogo_cnoise(float3(uv * 5.0, time * 0.2)) * 0.15); + return pattern; +} + +float pogo_antialiased_pattern(float2 uv, float time, float2 sampleOffset) { + return ( + pogo_pattern(uv + sampleOffset * float2(-1.0, -1.0), time) + + pogo_pattern(uv + sampleOffset * float2( 1.0, -1.0), time) + + pogo_pattern(uv + sampleOffset * float2(-1.0, 1.0), time) + + pogo_pattern(uv + sampleOffset * float2( 1.0, 1.0), time) + ) * 0.25; +} + +fragment float4 fragment_shader_pogo( + VertexOut in [[stage_in]], + constant POGOUniforms &uniforms [[buffer(0)]]) { + + float2 uv = in.position.xy / uniforms.resolution; + float aspect = uniforms.resolution.x / max(uniforms.resolution.y, 1.0); + uv.x *= aspect; + + float zoom = 4.8 + sin(uniforms.time * 0.12) * 0.45; + float2 center = float2(aspect * 0.5, 0.5); + uv = (uv - center) * zoom + center; + + float pixel = 1.0 / max(uniforms.resolution.y, 1.0); + float2 sampleOffset = float2(pixel * 0.35 * zoom); + float2 chromaDirection = normalize((uv - center) + float2(0.0001)); + float2 chromaOffset = chromaDirection * pixel * zoom * 2.2; + + float patternR = pogo_antialiased_pattern(uv + chromaOffset, uniforms.time, sampleOffset); + float patternG = pogo_antialiased_pattern(uv, uniforms.time, sampleOffset); + float patternB = pogo_antialiased_pattern(uv - chromaOffset, uniforms.time, sampleOffset); + + float3 color1 = float3(1.0, 0.0, 0.35); + float3 color2 = float3(0.01, 0.0, 0.0); + + float3 mixStrength = float3(patternR, patternG, patternB) * 2.0 + 0.25; + float3 mixColor = mix(color2, color1, mixStrength); + + float3 edgeWidth = max(fwidth(mixStrength), float3(0.0025)); + float3 highlight = smoothstep(float3(0.24) - edgeWidth, float3(0.24) + edgeWidth, mixStrength); + mixColor += highlight; + + return float4(pow(clamp(mixColor, 0.0, 1.0), float3(1.0 / 2.2)), 1.0); +} +""" + +private struct MetalPOGOFragmentUniforms { + var resolution: SIMD2 + var time: Float +} + +final class POGOLayer: CAMetalLayer, Background { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + private var commandQueue: MTLCommandQueue? + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + // MARK: - Animation + private var totalElapsedTime: CGFloat = 0 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + super.init() + self.frame = frame + self.contentsScale = contentsScale + self.pixelFormat = .bgra8Unorm + self.isOpaque = true + self.framebufferOnly = true + + setupMetal() + setupPipeline() + createVertexBuffers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + guard let other = layer as? POGOLayer else { return } + self.totalElapsedTime = other.totalElapsedTime + } + + private func setupMetal() { + guard let device = MTLCreateSystemDefaultDevice() else { return } + self.metalDevice = device + self.device = device + + guard let commandQueue = device.makeCommandQueue() else { return } + self.commandQueue = commandQueue + } + + private func setupPipeline() { + guard let metalDevice = metalDevice else { return } + do { + let library = try metalDevice.makeLibrary(source: metalPOGOShaderSource, options: nil) + guard let vertexFunction = library.makeFunction(name: "vertex_shader_pogo"), + let fragmentFunction = library.makeFunction(name: "fragment_shader_pogo") else { + return + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat + + pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + return + } + } + + private func createVertexBuffers() { + let vertices: [SIMD2] = [ + SIMD2(-1.0, -1.0), SIMD2( 1.0, -1.0), SIMD2(-1.0, 1.0), + SIMD2( 1.0, -1.0), SIMD2( 1.0, 1.0), SIMD2(-1.0, 1.0) + ] + vertexBuffer = metalDevice?.makeBuffer( + bytes: vertices, + length: MemoryLayout>.stride * vertices.count, + options: .storageModeShared + ) + } + + private weak var currentFruit: Fruit? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit) { + currentFruit = fruit + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit) { + currentFruit = fruit + setNeedsDisplay() + } + + func update(deltaTime: CGFloat) { + totalElapsedTime += deltaTime + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + // MARK: - Drawing + override func display() { + guard let pipelineState = pipelineState, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + var uniforms = MetalPOGOFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + time: Float(totalElapsedTime) + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor( + red: 0, green: 0, blue: 0, alpha: 1 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder( + descriptor: renderPassDescriptor + ) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + if let fruit = currentFruit { + let body = fruit.transformedPath.bounds + let leafExtra = fruit.maxDimen() * 0.231 + let fruitBounds = CGRect(x: body.minX - 4, y: body.minY - 4, + width: body.width + 8, height: body.height + 8 + leafExtra) + let scale = contentsScale + let x = max(0, Int(fruitBounds.minX * scale)) + let y = max(0, Int((bounds.height - fruitBounds.maxY) * scale)) + let width = min(Int(fruitBounds.width * scale), texture.width - x) + let height = min(Int(fruitBounds.height * scale), texture.height - y) + if width > 0 && height > 0 { + renderEncoder.setScissorRect(MTLScissorRect(x: x, y: y, width: width, height: height)) + } + } + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: 0 + ) + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} diff --git a/FruitFarm/Backgrounds/Types/PsyLayer.swift b/FruitFarm/Backgrounds/Types/PsyLayer.swift index fa1148c..45a8be5 100644 --- a/FruitFarm/Backgrounds/Types/PsyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -286,10 +286,10 @@ final class PsyLayer: CAMetalLayer, Background { startSpeed = currentSpeed isFastPhase.toggle() if isFastPhase { - targetSpeed = CGFloat.random(in: 1.6...2.8) + targetSpeed = CGFloat.random(in: 0.8...1.0) phaseDuration = CGFloat.random(in: 1.5...3.0) } else { - targetSpeed = CGFloat.random(in: 0.08...0.25) + targetSpeed = CGFloat.random(in: 0.02...0.12) phaseDuration = CGFloat.random(in: 20.0...40.0) } } diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index d9df21b..2aca9c8 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -4,9 +4,14 @@ import QuartzCore import Foundation import MetalKit +private let warpLayerCount = 14 +private let warpRenderScale: CGFloat = 1.0 + private let metalWarpShaderSource = """ using namespace metal; +#define WARP_LAYER_COUNT \(warpLayerCount) + struct VertexData { float2 position; }; @@ -76,7 +81,7 @@ fragment float4 fragment_shader_warp( float3 col = float3(0.0); - for (int i = 0; i < 20; i++) { + for (int i = 0; i < WARP_LAYER_COUNT; i++) { constant WarpLayerData &ld = layers[i]; if (ld.fade < 0.001) continue; @@ -119,8 +124,11 @@ fragment float4 fragment_shader_warp( float b = max(pb, sb) * ld.fade * smoothstep(0.15, 0.8, h1); if (b < 0.0001) continue; // quartic falloff makes distant stars negligible - float inv_sr = rsqrt(max(sr2, 1e-6)); - float sr = sr2 * inv_sr; + float sr = 0.0; + bool use_radial_effects = u.beamingMix > 0.01 || u.dopplerMix > 0.01; + if (use_radial_effects) { + sr = sqrt(sr2); + } if (!skip_twinkle) { b *= mix( @@ -130,7 +138,9 @@ fragment float4 fragment_shader_warp( ); } - b *= mix(1.0, 1.0 / (1.0 + sr * 4.0), u.beamingMix); + if (u.beamingMix > 0.01) { + b *= mix(1.0, 1.0 / (1.0 + sr * 4.0), u.beamingMix); + } float cr = fract(sp.x * 7.77 + 0.5); float cr_s1 = smoothstep(0.3, 0.8, cr); @@ -206,6 +216,17 @@ final class WarpLayer: CAMetalLayer, Background { private var commandQueue: MTLCommandQueue? private var pipelineState: MTLRenderPipelineState? private var vertexBuffer: MTLBuffer? + private var layerData = Array( + repeating: MetalWarpLayerData( + scale: 1.0, + inv_scale: 1.0, + fade: 0.0, + inv_ds2: 0.0, + fi64: 0.0, + fi27: 0.0 + ), + count: warpLayerCount + ) // MARK: - Animation private var totalElapsedTime: CGFloat = 0 @@ -238,6 +259,8 @@ final class WarpLayer: CAMetalLayer, Background { self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true + self.magnificationFilter = .linear + updateDrawableSize(for: frame) setupMetal() setupPipeline() @@ -273,6 +296,13 @@ final class WarpLayer: CAMetalLayer, Background { self.commandQueue = commandQueue } + private func updateDrawableSize(for frame: CGRect) { + drawableSize = CGSize( + width: max(1, frame.width * contentsScale * warpRenderScale), + height: max(1, frame.height * contentsScale * warpRenderScale) + ) + } + private func setupPipeline() { guard let metalDevice = metalDevice else { return } do { @@ -309,6 +339,7 @@ final class WarpLayer: CAMetalLayer, Background { func update(frame: NSRect, fruit: Fruit) { currentFruit = fruit setFrameAndDrawableSizeWithoutAnimation(frame) + updateDrawableSize(for: frame) setNeedsDisplay() } @@ -394,7 +425,7 @@ final class WarpLayer: CAMetalLayer, Background { let spd = Float(currentSpeed) let time = Float(totalElapsedTime) - let referenceSize: Float = 300.0 * Float(contentsScale) + let referenceSize: Float = 300.0 * Float(contentsScale * warpRenderScale) let resW = Float(texture.width) let resH = Float(texture.height) @@ -417,7 +448,7 @@ final class WarpLayer: CAMetalLayer, Background { streakMix: ss(0.3, 1.5, spd), brightScaled: (4.0 + (1.3 - 4.0) * ss(0.5, 2.0, spd)) * 0.225, tScroll: time * scroll, - cull: 0.02 + (0.15 - 0.02) * ss(0.3, 2.0, spd), + cull: 0.12 + (0.28 - 0.12) * ss(0.3, 2.0, spd), dopplerMix: ss(3.0, 8.0, spd), beamingMix: ss(2.0, 8.0, spd), twinkleSolid: ss(0.3, 1.0, spd), @@ -425,24 +456,22 @@ final class WarpLayer: CAMetalLayer, Background { vigRadius: 2.0 + (1.2 - 2.0) * ss(3.0, 10.0, spd) ) - var layerData = [MetalWarpLayerData]() - layerData.reserveCapacity(20) - for i in 0..<20 { + for i in 0..(fi, fi * 0.7)) * 0.05 + fi / Float(warpLayerCount) + uniforms.tScroll + Self.warpHash(SIMD2(fi, fi * 0.7)) * 0.05 ) let scale = 18.0 + (0.8 - 18.0) * z let fade = ss(0.0, 0.1, z) * ss(1.0, 0.8, z) let ds: Float = 0.0018 + 0.0006 * z - layerData.append(MetalWarpLayerData( + layerData[i] = MetalWarpLayerData( scale: scale, inv_scale: 1.0 / scale, fade: fade, inv_ds2: 1.0 / (ds * ds), fi64: fi * 64.0, fi27: fi * 27.0 - )) + ) } let renderPassDescriptor = MTLRenderPassDescriptor() @@ -465,11 +494,12 @@ final class WarpLayer: CAMetalLayer, Background { let leafExtra = fruit.maxDimen() * 0.231 let fb = CGRect(x: body.minX - 4, y: body.minY - 4, width: body.width + 8, height: body.height + 8 + leafExtra) - let cs = contentsScale - let sx = max(0, Int(fb.minX * cs)) - let sy = max(0, Int((bounds.height - fb.maxY) * cs)) - let sw = min(Int(fb.width * cs), Int(resW) - sx) - let sh = min(Int(fb.height * cs), Int(resH) - sy) + let scaleX = CGFloat(resW) / max(bounds.width, 1) + let scaleY = CGFloat(resH) / max(bounds.height, 1) + let sx = max(0, Int(fb.minX * scaleX)) + let sy = max(0, Int((bounds.height - fb.maxY) * scaleY)) + let sw = max(0, min(Int(ceil(fb.width * scaleX)), Int(resW) - sx)) + let sh = max(0, min(Int(ceil(fb.height * scaleY)), Int(resH) - sy)) if sw > 0 && sh > 0 { renderEncoder.setScissorRect(MTLScissorRect(x: sx, y: sy, width: sw, height: sh)) } diff --git a/FruitFarm/FruitView.swift b/FruitFarm/FruitView.swift index 002576b..749171f 100644 --- a/FruitFarm/FruitView.swift +++ b/FruitFarm/FruitView.swift @@ -208,6 +208,8 @@ public final class FruitView: NSView { return MetalCircularGradientLayer(frame: self.frame, fruit: fruit, contentsScale: scale) case .psychedelic: return PsyLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .california: + return CaliforniaLayer(frame: self.frame, fruit: fruit, contentsScale: scale) case .liquid: return LiquidLayer(frame: self.frame, fruit: fruit, contentsScale: scale) case .puppy: @@ -216,6 +218,14 @@ public final class FruitView: NSView { return WarpLayer(frame: self.frame, fruit: fruit, contentsScale: scale) case .ocean: return OceanLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .glass: + return GlassLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .metallic: + return MetallicLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .pogo: + return POGOLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + case .glue: + return GlueLayer(frame: self.frame, fruit: fruit, contentsScale: scale) } } diff --git a/FruitScreensaver/Preferences/PreferencesViewController.swift b/FruitScreensaver/Preferences/PreferencesViewController.swift index b2b0e6b..d521923 100644 --- a/FruitScreensaver/Preferences/PreferencesViewController.swift +++ b/FruitScreensaver/Preferences/PreferencesViewController.swift @@ -384,6 +384,8 @@ final class PreferencesControlsView: NSView { return "Circular Gradient" case .psychedelic: return "Psychedelic" + case .california: + return "California" case .liquid: return "Liquid" case .puppy: @@ -392,6 +394,14 @@ final class PreferencesControlsView: NSView { return "Warp Speed" case .ocean: return "Irish Ocean" + case .glass: + return "Glass" + case .metallic: + return "Metallic" + case .pogo: + return "POGO" + case .glue: + return "Glue" } }) return items diff --git a/README.md b/README.md index 55573e2..320652e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Now you can select from multiple fruit types and backgrounds: - Puppy (Fruit logo tunnel zoom) - Warp Speed (relativistic star field simulation) - Irish Ocean (dark North Atlantic ocean simulation) + - Glass (polarized glass rainbow simulation) + - Metallic (reflective metal surface simulation) *You are welcome to create new designs through PRs!* From a0fa0c908409f855a1fa1c0c779db8ccf7689f02 Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Sat, 16 May 2026 02:23:13 +0100 Subject: [PATCH 18/19] Remove debug view --- FruitScreensaver/DebugStatsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FruitScreensaver/DebugStatsView.swift b/FruitScreensaver/DebugStatsView.swift index ec2b8c6..185b7cc 100644 --- a/FruitScreensaver/DebugStatsView.swift +++ b/FruitScreensaver/DebugStatsView.swift @@ -3,7 +3,7 @@ import IOKit final class DebugStatsView: NSView { - static let isEnabled = true + static let isEnabled = false private let textFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .medium) private let padding: CGFloat = 8 From cc60b17f1edd6ae8995ae86347ab6caeaf0abf5c Mon Sep 17 00:00:00 2001 From: Pedro Paulo de Amorim Date: Mon, 18 May 2026 19:44:04 +0100 Subject: [PATCH 19/19] Add leaf to height matrix calculation --- FruitFarm/Backgrounds/Background.swift | 15 +++- FruitFarm/Backgrounds/BackgroundLayer.swift | 4 +- .../Backgrounds/Types/CaliforniaLayer.swift | 18 +++-- .../Types/CircularGradientLayer.swift | 24 +++--- FruitFarm/Backgrounds/Types/GlassLayer.swift | 79 +++++++++++++------ FruitFarm/Backgrounds/Types/GlueLayer.swift | 22 +++--- .../Types/LinearGradientLayer.swift | 18 +++-- FruitFarm/Backgrounds/Types/LiquidLayer.swift | 18 +++-- .../Backgrounds/Types/MetallicLayer.swift | 42 +++++++--- FruitFarm/Backgrounds/Types/OceanLayer.swift | 18 +++-- FruitFarm/Backgrounds/Types/POGOLayer.swift | 18 +++-- FruitFarm/Backgrounds/Types/PsyLayer.swift | 18 +++-- FruitFarm/Backgrounds/Types/PuppyLayer.swift | 18 +++-- .../Backgrounds/Types/RainbowsLayer.swift | 16 ++-- FruitFarm/Backgrounds/Types/SolidLayer.swift | 18 +++-- FruitFarm/Backgrounds/Types/WarpLayer.swift | 18 +++-- FruitFarm/FruitView.swift | 36 ++++----- 17 files changed, 250 insertions(+), 150 deletions(-) diff --git a/FruitFarm/Backgrounds/Background.swift b/FruitFarm/Backgrounds/Background.swift index 26710aa..be3a85b 100644 --- a/FruitFarm/Backgrounds/Background.swift +++ b/FruitFarm/Backgrounds/Background.swift @@ -4,11 +4,22 @@ import QuartzCore import MetalKit protocol Background: AnyObject { - func config(fruit: Fruit) - func update(frame: NSRect, fruit: Fruit) + func config(fruit: Fruit, leaf: Leaf) + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) func update(deltaTime: CGFloat) } +extension Fruit { + func bounds(including leaf: Leaf) -> CGRect { + transformedPath.bounds.union(leaf.transformedPath.bounds) + } + + func maxDimen(including leaf: Leaf) -> CGFloat { + let bounds = bounds(including: leaf) + return max(bounds.width, bounds.height) + } +} + extension CALayer { func setFrameWithoutAnimation(_ frame: CGRect) { CATransaction.begin() diff --git a/FruitFarm/Backgrounds/BackgroundLayer.swift b/FruitFarm/Backgrounds/BackgroundLayer.swift index 421689d..89bab5a 100644 --- a/FruitFarm/Backgrounds/BackgroundLayer.swift +++ b/FruitFarm/Backgrounds/BackgroundLayer.swift @@ -22,11 +22,11 @@ final class BackgroundLayer: CAShapeLayer, Background { updateBezierPath() } - func config(fruit: Fruit) { } + func config(fruit: Fruit, leaf: Leaf) { } func update(deltaTime: CGFloat) { } - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { setFrameWithoutAnimation(frame) updateBezierPath() } diff --git a/FruitFarm/Backgrounds/Types/CaliforniaLayer.swift b/FruitFarm/Backgrounds/Types/CaliforniaLayer.swift index 6770f3e..f7542a8 100644 --- a/FruitFarm/Backgrounds/Types/CaliforniaLayer.swift +++ b/FruitFarm/Backgrounds/Types/CaliforniaLayer.swift @@ -269,10 +269,12 @@ final class CaliforniaLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -334,16 +336,19 @@ final class CaliforniaLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -385,11 +390,10 @@ final class CaliforniaLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift index ef8bca4..80df8a3 100644 --- a/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift +++ b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift @@ -175,12 +175,14 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { // Frame is CGRect for CALayer - self.currentFruitMaxDimension = fruit.maxDimen() + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { // Frame is CGRect for CALayer + self.currentFruitMaxDimension = fruit.maxDimen(including: leaf) super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true // Assuming it's a background self.framebufferOnly = true // Performance optimization @@ -275,18 +277,21 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) - self.currentFruitMaxDimension = fruit.maxDimen() + self.currentFruitMaxDimension = fruit.maxDimen(including: leaf) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit - self.currentFruitMaxDimension = fruit.maxDimen() + currentLeaf = leaf + self.currentFruitMaxDimension = fruit.maxDimen(including: leaf) setNeedsDisplay() } @@ -329,11 +334,10 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { } configureRenderEncoder(renderEncoder, uniforms: &uniforms) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/GlassLayer.swift b/FruitFarm/Backgrounds/Types/GlassLayer.swift index 34137b4..6e96f6e 100644 --- a/FruitFarm/Backgrounds/Types/GlassLayer.swift +++ b/FruitFarm/Backgrounds/Types/GlassLayer.swift @@ -29,6 +29,7 @@ struct GlassUniforms { float2 body_half_px; float time; float color_phase; + float zoom_factor; }; float glass_hash(float2 p) { @@ -71,9 +72,10 @@ fragment float4 fragment_shader_glass( // Apple-relative coordinates: roughly [-1, 1] across the fruit body. // lp.y > 0 is the lower half of the fruit (Metal y-down). float2 half_px = max(uniforms.body_half_px, float2(1.0)); - float2 lp = (in.position.xy - uniforms.body_center_px) / half_px; + float zoom = max(uniforms.zoom_factor, 0.001); + float2 lp = ((in.position.xy - uniforms.body_center_px) / half_px) / zoom; - float loop = fract(uniforms.time / 14.0); + float loop = fract(uniforms.time / 36.0); float a = loop * 6.28318530718; float2 skew = float2( @@ -90,26 +92,38 @@ fragment float4 fragment_shader_glass( float edgeWeight = smoothstep(0.52, 1.08, edgeShape); float rim = smoothstep(0.72, 1.02, edgeShape) * (1.0 - smoothstep(1.02, 1.26, edgeShape)); - float stressA = sin(bend.x * 5.1 + bend.y * 1.9 + sin(a) * 1.4); - float stressB = sin(bend.y * 4.3 - bend.x * 2.7 + cos(a * 2.0) * 1.1); - float stressC = sin((bend.x + bend.y * 0.6) * 7.2 + sin(a * 3.0) * 0.9); - float baseBand = exp(-pow((lp.y - 0.72 - 0.08 * sin(lp.x * 4.0 + sin(a))) * 4.0, 2.0)); - float retardance = 4.0 + float2 chromaOffset = bend * (0.08 + edgeWeight * 0.16 + rim * 0.10); + float2 bendR = bend + chromaOffset; + float2 bendG = bend; + float2 bendB = bend - chromaOffset; + float3 channelX = float3(bendR.x, bendG.x, bendB.x); + float3 channelY = float3(bendR.y, bendG.y, bendB.y); + + float3 channelEdgeShape = max(abs(channelX * 0.82 + 0.08 * sin(channelY * 6.0)), + abs(channelY * 0.92 - 0.10 * sin(channelX * 5.0))); + float3 channelEdgeWeight = smoothstep(float3(0.52), float3(1.08), channelEdgeShape); + float3 channelRim = smoothstep(float3(0.72), float3(1.02), channelEdgeShape) + * (float3(1.0) - smoothstep(float3(1.02), float3(1.26), channelEdgeShape)); + float3 stressA = sin(channelX * 5.1 + channelY * 1.9 + sin(a) * 1.4); + float3 stressB = sin(channelY * 4.3 - channelX * 2.7 + cos(a * 2.0) * 1.1); + float3 stressC = sin((channelX + channelY * 0.6) * 7.2 + sin(a * 3.0) * 0.9); + float3 baseBand = exp(-pow((channelY - 0.72 - 0.08 * sin(channelX * 4.0 + sin(a))) * 4.0, float3(2.0))); + float3 retardance = float3(4.0) + stressA * 2.0 + stressB * 1.7 + stressC * 0.9 - + edgeWeight * 8.5 + + channelEdgeWeight * 8.5 + baseBand * 5.0 + sin(a) * 1.2; float3 spectrum = 0.5 + 0.5 * cos( retardance * float3(0.92, 1.19, 1.55) + float3(0.0, 2.1, 4.35) ); - spectrum = pow(clamp(spectrum, 0.0, 1.0), float3(0.38)); + spectrum = pow(clamp(spectrum, 0.0, 1.0), float3(0.48)); float3 baseGlass = float3(0.085, 0.075, 0.105); - float3 color = baseGlass + spectrum * (0.48 + edgeWeight * 0.78); - color += spectrum * rim * 2.10; + float3 color = baseGlass + spectrum * (0.48 + channelEdgeWeight * 0.78); + color += spectrum * channelRim * 2.10; float2 dRed = float2(0.10 * cos(a), 0.05 * sin(a * 2.0)); float2 dCyan = float2(0.06 * sin(a * 2.0 + 0.8), 0.10 * cos(a)); @@ -136,11 +150,8 @@ fragment float4 fragment_shader_glass( float stressRibbon = exp(-pow((bend.y - 0.16 + 0.16 * sin(bend.x * 3.0 + a)) * 3.0, 2.0)); color += spectrum * stressRibbon * 0.45; - // Soft scanline modulation typical of polarized LCD viewing. - float scan = sin(in.position.y * 1.8) * 0.5 + 0.5; - color *= 1.08 + 0.08 * scan; - color = pow(color, float3(0.88)); - + color = pow(max(color, 0.0), float3(0.82)); + color = mix(color, color / (1.0 + color * 0.22), smoothstep(float3(1.0), float3(1.8), color)); color = clamp(color, 0.0, 1.0); return float4(color, 1.0); } @@ -153,6 +164,7 @@ private struct MetalGlassFragmentUniforms { var body_half_px: SIMD2 var time: Float var color_phase: Float + var zoom_factor: Float // swiftlint:enable identifier_name } @@ -170,6 +182,15 @@ final class GlassLayer: CAMetalLayer, Background { private var colorPhase: CGFloat = 0 private var lastUpdateTime: CGFloat = 0 private let minUpdateInterval: CGFloat = 1.0 / 30.0 + private var glassZoomFactor: CGFloat = 5.0 + + var zoomFactor: CGFloat { + get { glassZoomFactor } + set { + glassZoomFactor = max(0.001, newValue) + setNeedsDisplay() + } + } deinit { vertexBuffer = nil @@ -179,10 +200,12 @@ final class GlassLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -203,6 +226,7 @@ final class GlassLayer: CAMetalLayer, Background { // Only copy plain Swift state; presentation copies cannot touch Metal layer state safely. self.totalElapsedTime = other.totalElapsedTime self.colorPhase = other.colorPhase + self.glassZoomFactor = other.glassZoomFactor } private func setupMetal() { @@ -247,16 +271,19 @@ final class GlassLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -288,8 +315,8 @@ final class GlassLayer: CAMetalLayer, Background { Float(texture.width) * 0.25, Float(texture.height) * 0.25 ) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let centerX = body.midX * cs // Convert NSRect (origin bottom-left) to Metal pixel coords (origin top-left). let centerY = (bounds.height - body.midY) * cs @@ -305,7 +332,8 @@ final class GlassLayer: CAMetalLayer, Background { body_center_px: bodyCenterPx, body_half_px: bodyHalfPx, time: Float(totalElapsedTime), - color_phase: Float(colorPhase) + color_phase: Float(colorPhase), + zoom_factor: Float(glassZoomFactor) ) let renderPassDescriptor = MTLRenderPassDescriptor() @@ -323,11 +351,10 @@ final class GlassLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/GlueLayer.swift b/FruitFarm/Backgrounds/Types/GlueLayer.swift index f87b913..3ddf2bb 100644 --- a/FruitFarm/Backgrounds/Types/GlueLayer.swift +++ b/FruitFarm/Backgrounds/Types/GlueLayer.swift @@ -249,10 +249,12 @@ final class GlueLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -315,16 +317,19 @@ final class GlueLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -355,8 +360,8 @@ final class GlueLayer: CAMetalLayer, Background { Float(texture.width) * 0.25, Float(texture.height) * 0.25 ) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let centerX = body.midX * cs let centerY = (bounds.height - body.midY) * cs bodyCenterPx = SIMD2(Float(centerX), Float(centerY)) @@ -388,11 +393,10 @@ final class GlueLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift index 8dc0c9e..bdf0bd7 100644 --- a/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift +++ b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift @@ -170,12 +170,14 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { // Frame is CGRect for CALayer + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { // Frame is CGRect for CALayer // currentFruitMaxDimension is not used for linear gradient super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -267,16 +269,19 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -322,11 +327,10 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { } configureRenderEncoder(renderEncoder, uniforms: &uniforms) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/LiquidLayer.swift b/FruitFarm/Backgrounds/Types/LiquidLayer.swift index 99bb359..4cc6b0a 100644 --- a/FruitFarm/Backgrounds/Types/LiquidLayer.swift +++ b/FruitFarm/Backgrounds/Types/LiquidLayer.swift @@ -155,10 +155,12 @@ final class LiquidLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -221,16 +223,19 @@ final class LiquidLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -274,11 +279,10 @@ final class LiquidLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/MetallicLayer.swift b/FruitFarm/Backgrounds/Types/MetallicLayer.swift index 95aec4c..5e93383 100644 --- a/FruitFarm/Backgrounds/Types/MetallicLayer.swift +++ b/FruitFarm/Backgrounds/Types/MetallicLayer.swift @@ -28,6 +28,7 @@ struct MetallicUniforms { float2 body_center_px; float2 body_half_px; float time; + float zoom_factor; }; float metallic_hash(float2 p) { @@ -114,7 +115,7 @@ float3 metallic_surface_color(float2 lp, float a) { color = mix(color * float3(0.90, 0.93, 1.06), color * float3(1.04, 1.01, 0.96), smoothstep(0.28, 0.72, lum)); // Reduce overall brightness, increase contrast - color *= 0.58; + color *= 0.8; color = (color - 0.5) * 1.55 + 0.5; return color; @@ -125,7 +126,8 @@ fragment float4 fragment_shader_metallic( constant MetallicUniforms &uniforms [[buffer(0)]]) { float2 half_px = max(uniforms.body_half_px, float2(1.0)); - float2 lp = (in.position.xy - uniforms.body_center_px) / half_px; + float zoom = max(uniforms.zoom_factor, 0.001); + float2 lp = ((in.position.xy - uniforms.body_center_px) / half_px) / zoom; float loop = fract(uniforms.time / 14.0); float a = loop * 6.28318530718; @@ -156,6 +158,7 @@ private struct MetalMetallicFragmentUniforms { var body_center_px: SIMD2 var body_half_px: SIMD2 var time: Float + var zoom_factor: Float // swiftlint:enable identifier_name } @@ -172,6 +175,15 @@ final class MetallicLayer: CAMetalLayer, Background { private var totalElapsedTime: CGFloat = 0 private var lastUpdateTime: CGFloat = 0 private let minUpdateInterval: CGFloat = 1.0 / 30.0 + private var metallicZoomFactor: CGFloat = 0.85 + + var zoomFactor: CGFloat { + get { metallicZoomFactor } + set { + metallicZoomFactor = max(0.001, newValue) + setNeedsDisplay() + } + } deinit { vertexBuffer = nil @@ -181,10 +193,12 @@ final class MetallicLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -204,6 +218,7 @@ final class MetallicLayer: CAMetalLayer, Background { // Presentation copies should only copy plain Swift state. self.totalElapsedTime = other.totalElapsedTime + self.metallicZoomFactor = other.metallicZoomFactor } private func setupMetal() { @@ -248,16 +263,19 @@ final class MetallicLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -288,8 +306,8 @@ final class MetallicLayer: CAMetalLayer, Background { Float(texture.width) * 0.25, Float(texture.height) * 0.25 ) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let centerX = body.midX * cs let centerY = (bounds.height - body.midY) * cs bodyCenterPx = SIMD2(Float(centerX), Float(centerY)) @@ -303,7 +321,8 @@ final class MetallicLayer: CAMetalLayer, Background { resolution: SIMD2(Float(texture.width), Float(texture.height)), body_center_px: bodyCenterPx, body_half_px: bodyHalfPx, - time: Float(totalElapsedTime) + time: Float(totalElapsedTime), + zoom_factor: Float(metallicZoomFactor) ) let renderPassDescriptor = MTLRenderPassDescriptor() @@ -321,11 +340,10 @@ final class MetallicLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/OceanLayer.swift b/FruitFarm/Backgrounds/Types/OceanLayer.swift index 9dbd39d..29660a7 100644 --- a/FruitFarm/Backgrounds/Types/OceanLayer.swift +++ b/FruitFarm/Backgrounds/Types/OceanLayer.swift @@ -305,10 +305,12 @@ final class OceanLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -370,16 +372,19 @@ final class OceanLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -421,11 +426,10 @@ final class OceanLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/POGOLayer.swift b/FruitFarm/Backgrounds/Types/POGOLayer.swift index 69c6e64..cd0baf0 100644 --- a/FruitFarm/Backgrounds/Types/POGOLayer.swift +++ b/FruitFarm/Backgrounds/Types/POGOLayer.swift @@ -228,10 +228,12 @@ final class POGOLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -293,16 +295,19 @@ final class POGOLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -344,11 +349,10 @@ final class POGOLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fruitBounds = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let scale = contentsScale let x = max(0, Int(fruitBounds.minX * scale)) let y = max(0, Int((bounds.height - fruitBounds.maxY) * scale)) diff --git a/FruitFarm/Backgrounds/Types/PsyLayer.swift b/FruitFarm/Backgrounds/Types/PsyLayer.swift index 45a8be5..3f8e794 100644 --- a/FruitFarm/Backgrounds/Types/PsyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -181,10 +181,12 @@ final class PsyLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -257,16 +259,19 @@ final class PsyLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -339,11 +344,10 @@ final class PsyLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/PuppyLayer.swift b/FruitFarm/Backgrounds/Types/PuppyLayer.swift index e692171..b085d73 100644 --- a/FruitFarm/Backgrounds/Types/PuppyLayer.swift +++ b/FruitFarm/Backgrounds/Types/PuppyLayer.swift @@ -173,10 +173,12 @@ final class PuppyLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -238,16 +240,19 @@ final class PuppyLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -289,11 +294,10 @@ final class PuppyLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/RainbowsLayer.swift b/FruitFarm/Backgrounds/Types/RainbowsLayer.swift index 126f079..243cd18 100644 --- a/FruitFarm/Backgrounds/Types/RainbowsLayer.swift +++ b/FruitFarm/Backgrounds/Types/RainbowsLayer.swift @@ -31,11 +31,11 @@ final class RainbowsLayer: CALayer, Background { } // MARK: - Init - init(frame: NSRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: NSRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale - config(fruit: fruit) + config(fruit: fruit, leaf: leaf) } required init?(coder: NSCoder) { @@ -50,20 +50,20 @@ final class RainbowsLayer: CALayer, Background { } } - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { setFrameWithoutAnimation(frame) - config(fruit: fruit) + config(fruit: fruit, leaf: leaf) } /// Creates the paths and assigns colors for each colored bar. - func config(fruit: Fruit) { - let fruitPath = fruit.transformedPath + func config(fruit: Fruit, leaf: Leaf) { + let fruitBounds = fruit.bounds(including: leaf) let width = bounds.size.width let originX: CGFloat = 0.0 - let originY = fruitPath.bounds.size.height + let originY = fruitBounds.size.height let middleY = bounds.size.height / 2 - heightOfBars = fruitPath.bounds.size.height / CGFloat(Self.barCountPerCycle) + heightOfBars = fruitBounds.size.height / CGFloat(Self.barCountPerCycle) var lastY = middleY - originY lastY -= heightOfBars * CGFloat(Self.barCountPerCycle) diff --git a/FruitFarm/Backgrounds/Types/SolidLayer.swift b/FruitFarm/Backgrounds/Types/SolidLayer.swift index fa88e7c..6fd431f 100644 --- a/FruitFarm/Backgrounds/Types/SolidLayer.swift +++ b/FruitFarm/Backgrounds/Types/SolidLayer.swift @@ -74,11 +74,13 @@ final class MetalSolidLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { // Frame is CGRect for CALayer + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { // Frame is CGRect for CALayer super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true // Typically true for layers that don't need to be read back @@ -145,16 +147,19 @@ final class MetalSolidLayer: CAMetalLayer, Background { } private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -200,11 +205,10 @@ final class MetalSolidLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let cs = contentsScale let sx = max(0, Int(fb.minX * cs)) let sy = max(0, Int((bounds.height - fb.maxY) * cs)) diff --git a/FruitFarm/Backgrounds/Types/WarpLayer.swift b/FruitFarm/Backgrounds/Types/WarpLayer.swift index 2aca9c8..5e55e80 100644 --- a/FruitFarm/Backgrounds/Types/WarpLayer.swift +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -235,6 +235,7 @@ final class WarpLayer: CAMetalLayer, Background { // MARK: - Rendering Area private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? // MARK: - Speed Variation private var currentSpeed: CGFloat = 0.1 @@ -252,10 +253,12 @@ final class WarpLayer: CAMetalLayer, Background { } // MARK: - Initialization - init(frame: CGRect, fruit: Fruit, contentsScale: CGFloat) { + init(frame: CGRect, fruit: Fruit, leaf: Leaf, contentsScale: CGFloat) { super.init() self.frame = frame self.contentsScale = contentsScale + self.currentFruit = fruit + self.currentLeaf = leaf self.pixelFormat = .bgra8Unorm self.isOpaque = true self.framebufferOnly = true @@ -336,15 +339,17 @@ final class WarpLayer: CAMetalLayer, Background { } // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setFrameAndDrawableSizeWithoutAnimation(frame) updateDrawableSize(for: frame) setNeedsDisplay() } - func config(fruit: Fruit) { + func config(fruit: Fruit, leaf: Leaf) { currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -489,11 +494,10 @@ final class WarpLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) - if let fruit = currentFruit { - let body = fruit.transformedPath.bounds - let leafExtra = fruit.maxDimen() * 0.231 + if let fruit = currentFruit, let leaf = currentLeaf { + let body = fruit.bounds(including: leaf) let fb = CGRect(x: body.minX - 4, y: body.minY - 4, - width: body.width + 8, height: body.height + 8 + leafExtra) + width: body.width + 8, height: body.height + 8) let scaleX = CGFloat(resW) / max(bounds.width, 1) let scaleY = CGFloat(resH) / max(bounds.height, 1) let sx = max(0, Int(fb.minX * scaleX)) diff --git a/FruitFarm/FruitView.swift b/FruitFarm/FruitView.swift index 749171f..f721616 100644 --- a/FruitFarm/FruitView.swift +++ b/FruitFarm/FruitView.swift @@ -150,7 +150,7 @@ public final class FruitView: NSView { guard let layer = self.layer else { return } if let fruitBackground = self.fruitBackground { - fruitBackground.config(fruit: fruit) + fruitBackground.config(fruit: fruit, leaf: leaf) } else { self.backgroundLayer?.removeFromSuperlayer() self.backgroundLayer = nil @@ -165,7 +165,7 @@ public final class FruitView: NSView { let needsAddBackgroundLayer = self.backgroundLayer == nil if let backgroundLayer = self.backgroundLayer { - backgroundLayer.config(fruit: fruit) + backgroundLayer.config(fruit: fruit, leaf: leaf) } else { let newBackground = BackgroundLayer(frame: self.frame) newBackground.contentsScale = displayContentsScale() @@ -199,33 +199,33 @@ public final class FruitView: NSView { switch fruitType { case .rainbow: - return RainbowsLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return RainbowsLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .solid: - return MetalSolidLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return MetalSolidLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .linearGradient: - return MetalLinearGradientLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return MetalLinearGradientLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .circularGradient: - return MetalCircularGradientLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return MetalCircularGradientLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .psychedelic: - return PsyLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return PsyLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .california: - return CaliforniaLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return CaliforniaLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .liquid: - return LiquidLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return LiquidLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .puppy: - return PuppyLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return PuppyLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .warp: - return WarpLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return WarpLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .ocean: - return OceanLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return OceanLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .glass: - return GlassLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return GlassLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .metallic: - return MetallicLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return MetallicLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .pogo: - return POGOLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return POGOLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) case .glue: - return GlueLayer(frame: self.frame, fruit: fruit, contentsScale: scale) + return GlueLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) } } @@ -296,8 +296,8 @@ public final class FruitView: NSView { /// Handles view resizing. Updates all layers and geometry on size change. public override func layout() { super.layout() - backgroundLayer?.update(frame: self.frame, fruit: self.fruit) - fruitBackground?.update(frame: self.frame, fruit: self.fruit) + backgroundLayer?.update(frame: self.frame, fruit: self.fruit, leaf: self.leaf) + fruitBackground?.update(frame: self.frame, fruit: self.fruit, leaf: self.leaf) setNeedsDisplay(bounds) }