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 06f05c2..503627c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # Changelog +## [1.3.4] + +### 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). + - 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 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. + - 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/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/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/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/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 24a6354..d846c29 100644 --- a/Fruit.xcodeproj/project.pbxproj +++ b/Fruit.xcodeproj/project.pbxproj @@ -65,6 +65,8 @@ 48D1EF012DEBDA9F00712C23 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + DebugStatsView.swift, + DisplayLinkAnimator.swift, FruitScreensaver.swift, Info.plist, Media.xcassets, @@ -76,6 +78,8 @@ 48F9C7872DEFC5A50023D72F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + DebugStatsView.swift, + DisplayLinkAnimator.swift, Preferences/PreferencesRepository.swift, Preferences/PreferencesViewController.swift, ); @@ -465,7 +469,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 +500,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 +536,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 +574,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/Background.swift b/FruitFarm/Backgrounds/Background.swift index a5df7b6..be3a85b 100644 --- a/FruitFarm/Backgrounds/Background.swift +++ b/FruitFarm/Backgrounds/Background.swift @@ -1,8 +1,43 @@ import Foundation import Cocoa +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() + 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..89bab5a 100644 --- a/FruitFarm/Backgrounds/BackgroundLayer.swift +++ b/FruitFarm/Backgrounds/BackgroundLayer.swift @@ -22,12 +22,12 @@ 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) { - self.frame = frame + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + setFrameWithoutAnimation(frame) updateBezierPath() } diff --git a/FruitFarm/Backgrounds/FruitTypes.swift b/FruitFarm/Backgrounds/FruitTypes.swift index 40bc7e8..53d3166 100644 --- a/FruitFarm/Backgrounds/FruitTypes.swift +++ b/FruitFarm/Backgrounds/FruitTypes.swift @@ -10,4 +10,14 @@ public enum FruitType: String, CaseIterable { case solid 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..f7542a8 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/CaliforniaLayer.swift @@ -0,0 +1,418 @@ +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, 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 + + 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? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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 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) + 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/CircularGradientLayer.swift b/FruitFarm/Backgrounds/Types/CircularGradientLayer.swift index d7c9aaf..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 @@ -207,32 +209,16 @@ 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.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 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() { @@ -290,15 +276,22 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? + // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { - self.frame = frame - self.currentFruitMaxDimension = fruit.maxDimen() + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + self.currentFruitMaxDimension = fruit.maxDimen(including: leaf) setNeedsDisplay() } - func config(fruit: Fruit) { - self.currentFruitMaxDimension = fruit.maxDimen() + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + self.currentFruitMaxDimension = fruit.maxDimen(including: leaf) setNeedsDisplay() } @@ -341,6 +334,19 @@ final class MetalCircularGradientLayer: CAMetalLayer, Background { } configureRenderEncoder(renderEncoder, uniforms: &uniforms) + 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) + 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/GlassLayer.swift b/FruitFarm/Backgrounds/Types/GlassLayer.swift new file mode 100644 index 0000000..6e96f6e --- /dev/null +++ b/FruitFarm/Backgrounds/Types/GlassLayer.swift @@ -0,0 +1,380 @@ +// 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 zoom_factor; +}; + +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)); + 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 / 36.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)); + + 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 + + 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.48)); + + float3 baseGlass = float3(0.085, 0.075, 0.105); + 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)); + 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; + + 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); +} +""" + +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 + var zoom_factor: 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 + private var glassZoomFactor: CGFloat = 5.0 + + var zoomFactor: CGFloat { + get { glassZoomFactor } + set { + glassZoomFactor = max(0.001, newValue) + setNeedsDisplay() + } + } + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + 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 + + 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 + self.glassZoomFactor = other.glassZoomFactor + } + + 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? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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 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 + 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), + zoom_factor: Float(glassZoomFactor) + ) + + 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 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) + 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..3ddf2bb --- /dev/null +++ b/FruitFarm/Backgrounds/Types/GlueLayer.swift @@ -0,0 +1,422 @@ +// 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, 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 + + 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? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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 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)) + 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 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) + 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/LinearGradientLayer.swift b/FruitFarm/Backgrounds/Types/LinearGradientLayer.swift index 80f489c..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 @@ -200,30 +202,14 @@ 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.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.commandQueue = device.makeCommandQueue() - setupPipeline() - createVertexBuffers() - createColorLocationBuffer() - - let currentColors = calculateCurrentInterpolatedColors() - currentInterpolatedColorsBuffer = device.makeBuffer( - bytes: currentColors, - length: MemoryLayout>.stride * colorArray.count, - options: .storageModeShared - ) } private func setupMetal() { @@ -282,14 +268,20 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? + // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { - // Fruit parameter is not directly used by MetalLinearGradientLayer's appearance + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -335,6 +327,19 @@ final class MetalLinearGradientLayer: CAMetalLayer, Background { } configureRenderEncoder(renderEncoder, uniforms: &uniforms) + 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) + 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 new file mode 100644 index 0000000..4cc6b0a --- /dev/null +++ b/FruitFarm/Backgrounds/Types/LiquidLayer.swift @@ -0,0 +1,308 @@ +// 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; +} + +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; + float origR = length(uv); + + // Primary domain-warp chain + 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; + } + + // 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; + } + + // 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 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(q.x * 8.0 + q.y * 5.0 + t * 0.9) * 0.4; + interference *= 0.25; + + // 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: smooth warp-based variation instead of angular + float hue = fract( + (p.x + p.y) * 0.12 + + t * 0.035 + + pl1 * 0.2 + + pl2 * 0.12 + + colorFlow * 0.15 + + uniforms.color_phase + ); + + float sat = 0.8 + 0.2 * sin(length(p) * 6.0 + t * 1.2 + pl1 * 3.0); + + float val = 0.45 + + 0.25 * colorFlow + + 0.15 * pl1 + + 0.1 * pl2 + + interference * 0.12; + + // 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)); + + 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, 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 + + 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 } + 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: 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 + ) + } + + private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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) + 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) + 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..5e93383 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/MetallicLayer.swift @@ -0,0 +1,369 @@ +// 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 zoom_factor; +}; + +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.8; + 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)); + 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; + + 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 + var zoom_factor: 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 + private var metallicZoomFactor: CGFloat = 0.85 + + var zoomFactor: CGFloat { + get { metallicZoomFactor } + set { + metallicZoomFactor = max(0.001, newValue) + setNeedsDisplay() + } + } + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + 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 + + 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 + self.metallicZoomFactor = other.metallicZoomFactor + } + + 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? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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 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)) + 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), + zoom_factor: Float(metallicZoomFactor) + ) + + 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 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) + 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 new file mode 100644 index 0000000..29660a7 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/OceanLayer.swift @@ -0,0 +1,455 @@ +// 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; +}; + +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) { + 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); + + 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_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 p.y - height; +} + +float ocean_map(float3 p, float time) { + float seaTime = 1.0 + time * OCEAN_SPEED; + return ocean_map_with_iterations(p, OCEAN_ITER_GEOMETRY, seaTime); +} + +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); +} + +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); + + if (farHeight > 0.0) { + p = origin + direction * farDistance; + return farDistance; + } + + 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 midDistance; +} + +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 + ); + 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)) + ); + 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 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); +} +""" + +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, 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 + + 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? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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 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) + 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/POGOLayer.swift b/FruitFarm/Backgrounds/Types/POGOLayer.swift new file mode 100644 index 0000000..cd0baf0 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/POGOLayer.swift @@ -0,0 +1,377 @@ +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, 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 + + 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? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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 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) + 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 new file mode 100644 index 0000000..3f8e794 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/PsyLayer.swift @@ -0,0 +1,373 @@ +// 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; + float referenceSize; +}; + +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; +} + +fragment float4 fragment_shader_psy( + VertexOut in [[stage_in]], + constant PsyUniforms &uniforms [[buffer(0)]]) { + + 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; + + // Heavy domain warping - high amplitude, fast + float2 p = uv; + 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++) { + 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; + } + + 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); + + // 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; + + // Harsh color banding - fewer bands = chunkier steps + float bands = floor(psy * 3.0) / 3.0; + psy = mix(psy, bands, 0.5); + + // 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; + + // Fast hue cycling + float hue = fract( + (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 + ); + + float sat = 1.0; + + 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.3, 1.0); + + float3 color = hsv2rgb(float3(hue, sat, val)); + + float vignette = 1.0 - smoothstep(0.7, 1.8, origR); + color *= mix(0.5, 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 + var referenceSize: 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 + + // 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 + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + 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 + + 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 } + + // 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 + self.startSpeed = other.startSpeed + self.targetSpeed = other.targetSpeed + self.speedTimer = other.speedTimer + self.phaseDuration = other.phaseDuration + self.isFastPhase = other.isFastPhase + } + + 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 + ) + } + + private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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 + isFastPhase.toggle() + if isFastPhase { + targetSpeed = CGFloat.random(in: 0.8...1.0) + phaseDuration = CGFloat.random(in: 1.5...3.0) + } else { + targetSpeed = CGFloat.random(in: 0.02...0.12) + 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 + + 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 referenceSize: Float = 200.0 * Float(contentsScale) + + var uniforms = MetalPsyFragmentUniforms( + resolution: SIMD2(Float(texture.width), Float(texture.height)), + time: Float(totalElapsedTime), + color_phase: Float(colorPhase), + referenceSize: referenceSize + ) + + 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 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) + 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/PuppyLayer.swift b/FruitFarm/Backgrounds/Types/PuppyLayer.swift new file mode 100644 index 0000000..b085d73 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/PuppyLayer.swift @@ -0,0 +1,323 @@ +// 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 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); + + // 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 + 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, 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 + + 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 } + 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: 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 + ) + } + + private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? + + // MARK: - Background Protocol + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + 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) + 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) + 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/RainbowsLayer.swift b/FruitFarm/Backgrounds/Types/RainbowsLayer.swift index 219e50b..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) { - self.frame = frame - config(fruit: fruit) + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + setFrameWithoutAnimation(frame) + 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 2fefaa2..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 @@ -95,22 +97,12 @@ 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.metalDevice = other.metalDevice + self.commandQueue = other.commandQueue + self.pipelineState = other.pipelineState + self.vertexBuffer = other.vertexBuffer self.colorIndex = other.colorIndex self.elapsedTime = other.elapsedTime - - self.commandQueue = device.makeCommandQueue() - setupPipeline() - createVertexBuffers() } private func setupMetal() { @@ -154,14 +146,20 @@ final class MetalSolidLayer: CAMetalLayer, Background { ) } + private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? + // MARK: - Background Protocol - func update(frame: NSRect, fruit: Fruit) { - self.frame = frame + func update(frame: NSRect, fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) setNeedsDisplay() } - func config(fruit: Fruit) { - // Fruit parameter is not used by MetalSolidLayer's appearance + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf setNeedsDisplay() } @@ -207,10 +205,23 @@ final class MetalSolidLayer: CAMetalLayer, Background { } renderEncoder.setRenderPipelineState(pipelineState) + 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) + 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 new file mode 100644 index 0000000..5e55e80 --- /dev/null +++ b/FruitFarm/Backgrounds/Types/WarpLayer.swift @@ -0,0 +1,531 @@ +// swiftlint:disable file_length +import Cocoa +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; +}; + +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; + 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; +}; + +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); + return fract((p3.x + p3.y) * p3.z); +} + +float2 warp_hash2(float2 p) { + 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)); +} + +fragment float4 fragment_shader_warp( + VertexOut in [[stage_in]], + 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); + + for (int i = 0; i < WARP_LAYER_COUNT; 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++) { + for (int dx = -1; dx <= 1; dx++) { + float2 off = float2(float(dx), float(dy)); + float2 id = gid + off; + + float h1 = warp_hash(id + ld.fi64); + if (h1 < u.cull) continue; + + float2 sp = warp_hash2(id * 3.14 + ld.fi27) - 0.5; + + 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 * ld.inv_ds2; + float pb = 1.0 / (1.0 + ratio * ratio); + + float sb = 0.0; + if (u.streakMix > 0.01) { + // 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; + float2 seg_v = delta - sba * tp; + float sw = 0.001 * (1.0 + tp * 0.8); + 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) * ld.fade * smoothstep(0.15, 0.8, h1); + if (b < 0.0001) continue; // quartic falloff makes distant stars negligible + + 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( + 0.5 + 0.5 * sin(t * 3.0 + warp_hash(id * 17.0) * 6.283), + 1.0, + u.twinkleSolid + ); + } + + 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); + 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)); + + 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 * u.brightScaled); + } + } + } + + 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; + } + + col *= smoothstep(u.vigRadius, 0.5, dist); + + float3 mapped = col * 3.5; + col = mapped / (1.0 + mapped); + + return float4(col, 1.0); +} +""" + +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 +} + +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 { + + // MARK: - Metal Objects + private var metalDevice: MTLDevice? + 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 + private var lastUpdateTime: CGFloat = 0 + private let minUpdateInterval: CGFloat = 1.0 / 30.0 + + // MARK: - Rendering Area + private weak var currentFruit: Fruit? + private weak var currentLeaf: Leaf? + + // MARK: - Speed Variation + 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 = 15.0 + private var isWarpPhase: Bool = true + + deinit { + vertexBuffer = nil + pipelineState = nil + commandQueue = nil + metalDevice = nil + } + + // MARK: - Initialization + 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 + self.magnificationFilter = .linear + updateDrawableSize(for: frame) + + 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 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 { + 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, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setFrameAndDrawableSizeWithoutAnimation(frame) + updateDrawableSize(for: frame) + setNeedsDisplay() + } + + func config(fruit: Fruit, leaf: Leaf) { + currentFruit = fruit + currentLeaf = leaf + setNeedsDisplay() + } + + 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)) + } + 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 = 1.5 + let t2 = t * 2.0 + if t2 < 1.0 { + return 0.5 * (t2 * t2 * ((s + 1.0) * t2 - s)) + } + let p = t2 - 2.0 + return 0.5 * (p * p * ((s + 1.0) * p + s) + 2.0) + } + + func update(deltaTime: CGFloat) { + speedTimer += deltaTime + if speedTimer >= phaseDuration { + speedTimer = 0 + startSpeed = currentSpeed + isWarpPhase.toggle() + if isWarpPhase { + targetSpeed = CGFloat.random(in: 8.0...14.0) + phaseDuration = CGFloat.random(in: 15.0...30.0) + } else { + targetSpeed = CGFloat.random(in: 0.05...0.15) + phaseDuration = CGFloat.random(in: 25.0...45.0) + } + } + + let progress = min(speedTimer / phaseDuration, 1.0) + let eased = isWarpPhase ? easeWarpEngage(progress) : easeWarpDisengage(progress) + currentSpeed = max(0, startSpeed + (targetSpeed - startSpeed) * eased) + + totalElapsedTime += deltaTime + lastUpdateTime += deltaTime + + if lastUpdateTime >= minUpdateInterval { + lastUpdateTime = 0 + setNeedsDisplay() + } + } + + 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) + } + + 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, + let commandQueue = commandQueue, + let vertexBuffer = vertexBuffer, + let drawable = nextDrawable() else { return } + let texture = drawable.texture + + let spd = Float(currentSpeed) + let time = Float(totalElapsedTime) + let referenceSize: Float = 300.0 * Float(contentsScale * warpRenderScale) + 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(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.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), + glow: ss(7.0, 12.0, spd) * 0.5, + vigRadius: 2.0 + (1.2 - 2.0) * ss(3.0, 10.0, spd) + ) + + for i in 0..(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[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() + 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 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) + 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)) + } + } + 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() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +// swiftlint:enable file_length diff --git a/FruitFarm/FruitView.swift b/FruitFarm/FruitView.swift index 1323ef8..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,13 +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, leaf: leaf, contentsScale: scale) + case .california: + return CaliforniaLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) + case .liquid: + return LiquidLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) + case .puppy: + return PuppyLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) + case .warp: + return WarpLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) + case .ocean: + return OceanLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) + case .glass: + return GlassLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) + case .metallic: + return MetallicLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) + case .pogo: + return POGOLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) + case .glue: + return GlueLayer(frame: self.frame, fruit: fruit, leaf: leaf, contentsScale: scale) } } @@ -276,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) } diff --git a/FruitScreensaver/DebugStatsView.swift b/FruitScreensaver/DebugStatsView.swift new file mode 100644 index 0000000..185b7cc --- /dev/null +++ b/FruitScreensaver/DebugStatsView.swift @@ -0,0 +1,134 @@ +import AppKit +import IOKit + +final class DebugStatsView: NSView { + + static let isEnabled = false + + 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/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 2aa34fa..7734703 100644 --- a/FruitScreensaver/FruitScreensaver.swift +++ b/FruitScreensaver/FruitScreensaver.swift @@ -4,12 +4,6 @@ import FruitFarm // MARK: - FruitView final class FruitScreensaver: ScreenSaverView { - // MARK: Constant - - private enum Constant { - static let secondPerFrame = 1.0 / 60.0 - } - // MARK: Views private var fruitView: FruitView! @@ -17,17 +11,13 @@ 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 - private var lameDuck: Bool = false - // MARK: Preview detection + // MARK: Debug - // 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 + private var debugStatsView: DebugStatsView? + private var lastDebugUpdateTime: TimeInterval = 0 // MARK: Preferences @@ -37,78 +27,55 @@ final class FruitScreensaver: ScreenSaverView { ) deinit { + displayLinkAnimator.stop() 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) - animationTimeInterval = Constant.secondPerFrame - addNewInstanceObserver() - setupFruitView() - if !actualIsPreview { - setupMetalView() - addScreenDidChangeNotification() - } - addObserverWillStopNotification() + super.init(frame: frame, isPreview: isPreview) + animationTimeInterval = .infinity + commonInit(isPreview: isPreview) } required init?(coder decoder: NSCoder) { - actualIsPreview = false super.init(coder: decoder) - animationTimeInterval = Constant.secondPerFrame - addNewInstanceObserver() - setupFruitView() - if !actualIsPreview { + animationTimeInterval = .infinity + commonInit(isPreview: isPreview) + } + + private func commonInit(isPreview: Bool) { + setupFruitView(isPreview: isPreview) + setupDisplayLinkAnimator() + if !isPreview { setupMetalView() addScreenDidChangeNotification() } addObserverWillStopNotification() + if DebugStatsView.isEnabled { + setupDebugView() + } } - // 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 notification.object as? FruitScreensaver !== self 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()) 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, @@ -130,31 +97,30 @@ final class FruitScreensaver: ScreenSaverView { self.addSubview(metalView!) } - override func layout() { - super.layout() - fruitView.frame = self.bounds - metalView?.frame = self.bounds + private func setupDebugView() { + let debugView = DebugStatsView(frame: .zero) + self.addSubview(debugView) + debugStatsView = debugView + debugView.update(fps: 60) + positionDebugView() } - 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 + 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 + ) } - // 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 layout() { + super.layout() + fruitView.frame = self.bounds + metalView?.frame = self.bounds + if DebugStatsView.isEnabled { + positionDebugView() + } } override func viewDidMoveToWindow() { @@ -162,30 +128,26 @@ final class FruitScreensaver: ScreenSaverView { if window == nil { isPaused = true metalView?.isRenderingPaused = true + displayLinkAnimator.stop() } else { isPaused = false metalView?.isRenderingPaused = false + displayLinkAnimator.start(on: window?.screen) } } override func animateOneFrame() { - super.animateOneFrame() - guard !isPaused, !lameDuck else { return } - fruitView.animateOneFrame(framesPerSecond: calculateFps()) - } - - 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 - } + // No-op: rendering is driven by DisplayLinkAnimator at vsync rate. + } + + 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() { @@ -201,31 +163,31 @@ final class FruitScreensaver: ScreenSaverView { private func willStop(_ aNotification: Notification) { isPaused = true metalView?.isRenderingPaused = true + displayLinkAnimator.stop() - // 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) } } 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/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..d521923 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 } @@ -89,11 +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 var debugStatsView: DebugStatsView? + private var lastDebugUpdateTime: TimeInterval = 0 deinit { - stopDisplayLink() + displayLinkAnimator.stop() NotificationCenter.default.removeObserver(self) } @@ -112,7 +113,10 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, configView() addSubviews() configConstraints() - setupDisplayLink() + setupDisplayLinkAnimator() + if DebugStatsView.isEnabled { + setupDebugView() + } } /// Configures the main view with a black background. @@ -145,6 +149,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() { @@ -158,60 +179,29 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, super.viewDidLayout() fruitView.frame = self.view.bounds metalView.frame = self.view.bounds + 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) + private func setupDisplayLinkAnimator() { + displayLinkAnimator.onFrame = { [weak self] fps in + self?.fruitView.animateOneFrame(framesPerSecond: fps) + self?.updateDebugStatsIfNeeded(fps: fps) } - displayLink = nil - displayLinkContext = nil + displayLinkAnimator.start(on: view.window?.screen) } - 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 updateDebugStatsIfNeeded(fps: Int) { + guard DebugStatsView.isEnabled else { return } + let now = CACurrentMediaTime() + if now - lastDebugUpdateTime >= 0.5 { + lastDebugUpdateTime = now + debugStatsView?.update(fps: fps) + positionDebugView() } - - 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) - } - return kCVReturnSuccess - }, Unmanaged.passUnretained(context).toOpaque()) - - CVDisplayLinkStart(displayLink) } // MARK: - EDR @@ -231,7 +221,7 @@ final class PreferencesViewController: NSViewController, NSTableViewDataSource, @objc private func screenDidChange() { checkEDR() - setupDisplayLink() + displayLinkAnimator.start(on: view.window?.screen) } @objc @@ -392,6 +382,26 @@ final class PreferencesControlsView: NSView { return "Linear Gradient" case .circularGradient: return "Circular Gradient" + case .psychedelic: + return "Psychedelic" + case .california: + return "California" + case .liquid: + return "Liquid" + case .puppy: + return "Puppy" + case .warp: + 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 010ac9b..320652e 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,19 @@ 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) + - 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!* @@ -33,7 +40,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`. @@ -62,13 +69,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" ``` 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"