Skip to content

[KS] Motion: shader effects and particles (#405) - #442

Merged
sethwebster merged 1 commit into
seth/kitchen-sink-gatefrom
seth/ks-405-shaders
Oct 13, 2026
Merged

sethwebster merged 1 commit into
seth/kitchen-sink-gatefrom
seth/ks-405-shaders

Conversation

@sethwebster

@sethwebster sethwebster commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Refs #405

What

  • MotionShader (a MotionView with one built-in effect) and getShaderEffectAvailability. Each effect is one engine property (rippleProgress, distortion, specularProgress, dissolveProgress), so it interrupts, reduces, records and works with the virtual clock like any other property.
    • ripple: CITorusLensDistortion layer filter; distortion: CIBumpDistortion; specular sweep: a CAGradientLayer overlay. These are baked into Core Animation keyframes, so the app does no work per frame.
    • dissolve: a noise-threshold CAMetalLayer mask. A precompiled shader renders it on a display link that only runs while it animates in a visible window. It skips frames when the window is hidden or the view is hidden or offscreen, and renders on virtual-clock advances. Nothing is allocated per frame except Metal's own command buffer and encoder. Device removal or a failed command buffer rebuilds the Metal objects; with no device the API reports host-restriction / E_UNAVAILABLE.
    • Built-ins only, with no user-supplied shader source (docs say why). SparkMotionShaders.metal is compiled ahead of time with bun run motion:shaders into SparkMotionShaders.metallib.h; tests/motion-shaders.test.ts checks it matches the source hash. Apps, the podspec and CI never compile Metal.
  • MotionEmitter, startParticles (durationMs bursts), stopParticles, getParticleAvailability: a CAEmitterLayer capped at 1,000 live particles per emitter. It pauses in hidden windows and stops on unmount or window close.
  • Reduce Motion: ripple, distortion and sweep jump, and the view fades in (the existing fade-alternate rule). Dissolve becomes the 200 ms fade. Particles emit nothing (played: "none") unless reducedMotion: "play".
  • Windows is typed unsupported; the scope is added to [KS][MOT] API: Windows motion engine (Composition) #237.
  • Kitchen Sink: Motion system > Shaders and particles (ar title and summary), checks MOT-SHADER-01, MOT-PARTICLE-01, MOT-SHADREDUCE-01, flow motion-system/shaders.yaml, visual/audit/windows flows updated.
  • Docs: docs/motion.md#shader-effects-and-particles. It covers the renderer choice per effect, budget notes for the M1 8 GB reference (design bounds, not measured on that Mac; developed on an M4 Max), and what captures cannot show.

Captures

Driver captures (cacheDisplayInRect:) draw the sweep and the dissolve's rest states. They do not draw the Core Image filters, the Metal mask mid-dissolve, or particles. Flows check those through engine values, and goldens are at rest. tests/motion-coordinator.native.mm renders frozen virtual-clock frames of ripple, distortion and dissolve with CARenderer, which does draw filters and Metal layers.

Light Dark Arabic

Review fixes (after rebasing onto #439)

  • Rebased onto MotionShape ([KS] Motion: shape morph and stroke drawing (#406) #439): both features' properties and channels are kept, and kIdentity is rebuilt in the final enum order. `useMotionView` now takes one `MotionContent` (native props plus the content properties the component owns), which MotionMaterial, MotionShape, MotionShader and MotionEmitter all use.
  • Shader header records the source SHA-256, the library SHA-256 and `xcrun metal --version`. The test asserts the bytes' hash, and on a Mac with the same compiler it rebuilds the library and compares bytes. Otherwise it skips, with the reason in the test name.
  • Compiled with `-std=macos-metal2.0`, the lowest version that compiles (checked 2.0 through 2.3), and regenerated.
  • Dissolve availability builds the library and pipeline under a lock on whichever thread asks. MTLDevice added, removed or lost resets it.
  • `maximumDrawableCount = 3`. Display frames are skipped while two frames are in flight instead of waiting in `nextDrawable`; virtual-clock and settle frames always render.
  • The display link follows one animating view (held weakly) and is made again when that view stops, unmounts, changes window, or moves on or off screen.
  • A new drawable size resets `renderedValue` and renders again.
  • `hostDidResize` lays out the emitter with actions disabled.
  • The particle budget, rates, lifetimes and the 8-cell limit are enforced natively too.
  • Turning Reduce Motion on (NSWorkspace notification; `simulate:` in driver runs) stops emitters not started with `play`.
  • The static library bytes use a no-op dispatch destructor, so they are not copied. `DISPATCH_DATA_DESTRUCTOR_NONE` does not exist in the SDK.

Verification (run in this session on the rebased head, on an M4 Max with the display asleep)

  • `bun run typecheck`: passed.
  • `bun run test`: 1971 passed, 2 failed, 3 skipped. Both failures are accepted: `node-runtime` (the asdf shim), and in `motion-native` only `motion-coordinator.native.mm:270`, frame recording while the display sleeps. All new checks pass: rendered frames, device loss, drawable resize, link follow, budget and Reduce Motion stop.
  • `bun run e2e:lint`: 0 problems.
  • `e2e:run --backend driver` under `ks-lock native`, with the motion-system area, `accessibility/audit-motion-system.yaml` and `infra/catalog-driver.yaml`: 35 passed, 1 skipped (windows), 1 failed.
    • The failure is `js-block`: `assertFrames` got 0 display-link frames because the display is asleep. It is not related to this change.
    • `motion-system/visual.yaml` passes 4/4 against the existing goldens, including [KS] Motion: shape morph and stroke drawing (#406) #439's `motion-system-shapes` and this PR's `motion-system-shaders`. I viewed both captures; nothing needed re-approving.
  • Goldens added by this PR: `motion-system-shaders` (light/dark × en/ar) and `light/en/shaders-dissolved`.

MotionShader: a MotionView with a built-in ripple (CITorusLensDistortion),
distortion (CIBumpDistortion), specular sweep (gradient overlay) or dissolve
(a precompiled Metal noise-threshold mask rendered on display frames). Each
effect is one engine property, installed only while it shows or animates.
The Metal library is compiled ahead of time (bun run motion:shaders, Metal
2.0) into a header that records the source and library hashes and the
compiler; no user-supplied shader source. Availability builds the pipeline;
devices added, removed or lost make it check again. Display frames are
skipped rather than waiting for a drawable, and the link follows one
animating view, made again when it stops or moves.

MotionEmitter with startParticles/stopParticles: a CAEmitterLayer with a
1,000-particle budget (checked in JavaScript and natively), laid out on
resize, paused in hidden windows, stopped on unmount and when Reduce Motion
turns on (unless started with play). Reduce Motion: ripple, distortion and
sweep jump (fade alternate), dissolve fades, particles do not emit. Windows
is typed unsupported (#237). MotionShape, MotionMaterial, MotionShader and
MotionEmitter pass their content to useMotionView the same way.

Adds the Kitchen Sink Shaders and particles screen (localized title and
summary), checks, flows, at-rest goldens, and CARenderer-rendered frozen
virtual-clock frames in the native coordinator test.
@sethwebster
sethwebster merged commit 3ea9baa into seth/kitchen-sink-gate Oct 13, 2026
2 checks passed
@sethwebster
sethwebster deleted the seth/ks-405-shaders branch October 13, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant