diff --git a/.changeset/soft-cursors-return.md b/.changeset/soft-cursors-return.md new file mode 100644 index 00000000..27ddd624 --- /dev/null +++ b/.changeset/soft-cursors-return.md @@ -0,0 +1,5 @@ +--- +'@camp-dev/shaders': minor +--- + +Add `CursorSmudge`, a cursor effect that pulls the Sources' image along pointer movement and recovers smoothly. Control the pull with `strength`, `radius`, and `decay`, using numbers or animation signals. Set `decay={0}` to hold the distortion. Enable `scrollPush` on `ShaderScene` for touch scrolling. diff --git a/apps/docs-tests/a11y/component-pages.spec.ts b/apps/docs-tests/a11y/component-pages.spec.ts index 46caff5a..671b72e1 100644 --- a/apps/docs-tests/a11y/component-pages.spec.ts +++ b/apps/docs-tests/a11y/component-pages.spec.ts @@ -10,6 +10,7 @@ const routes = [ '/components/mesh-gradient', '/components/aurora', '/components/blobs', + '/components/cursor-smudge', '/components/dot-field', '/components/fractal-noise', '/components/god-rays', diff --git a/apps/docs-tests/visual/cursor-smudge.spec.ts b/apps/docs-tests/visual/cursor-smudge.spec.ts new file mode 100644 index 00000000..3a8d51a6 --- /dev/null +++ b/apps/docs-tests/visual/cursor-smudge.spec.ts @@ -0,0 +1,141 @@ +// Exercise the mounted effect through pointer input and the real GPU. +// The browser clock fixes stroke timing; no displacement-field internals +// are exposed to the test or used to seed the image. +import type { Page } from '@playwright/test'; + +import { expect, test } from './fixtures'; + +// ---- Controlled browser input and image comparison +async function openProbe(page: Page, source = 'LiquidGradient') { + await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }); + await page.goto('/dev/cursor-smudge-probe'); + const canvas = page.locator('canvas').first(); + + await canvas.waitFor(); + await expect(page.locator('[data-smudge-probe]')).toContainText(/ticks: [1-9]/); + if (source === 'LinearGradient') { + await page.getByRole('combobox', { name: 'Source', exact: true }).click(); + await page.getByRole('option', { name: 'LinearGradient', exact: true }).click(); + } + await page.clock.pauseAt(new Date('2026-01-01T01:00:00Z')); + await page.clock.runFor(64); + // The HTML monitor overlaps the canvas but is not part of the GPU output. + await page.addStyleTag({ content: '[data-testid="shaders-monitor"] { visibility: hidden; }' }); + + return canvas; +} + +async function changedPixels(page: Page, before: Buffer, after: Buffer) { + return page.evaluate( + async ([first, second]) => { + const read = async (png: string) => { + const response = await fetch(`data:image/png;base64,${png}`); + const bitmap = await createImageBitmap(await response.blob()); + const scratch = document.createElement('canvas'); + + scratch.width = bitmap.width; + scratch.height = bitmap.height; + const context = scratch.getContext('2d')!; + + context.drawImage(bitmap, 0, 0); + bitmap.close(); + + return context.getImageData(0, 0, scratch.width, scratch.height).data; + }; + const [left, right] = await Promise.all([read(first!), read(second!)]); + let changed = 0; + + for (let index = 0; index < left.length; index += 4) { + const difference = + Math.abs(left[index]! - right[index]!) + + Math.abs(left[index + 1]! - right[index + 1]!) + + Math.abs(left[index + 2]! - right[index + 2]!); + + if (difference > 3) changed++; + } + + return changed; + }, + [before.toString('base64'), after.toString('base64')], + ); +} + +async function swipe(page: Page) { + const box = await page.locator('canvas').first().boundingBox(); + + if (!box) throw new Error('The smudge canvas has no bounding box'); + for (let step = 0; step <= 12; step++) { + await page.mouse.move(box.x + box.width * (0.2 + step * 0.05), box.y + box.height * 0.6); + await page.clock.runFor(16); + } + // Input is gathered after drawing, so let the last stroke reach the image. + await page.clock.runFor(32); +} + +// ---- GPU output through the mounted component +test('CursorSmudge pulls a still LiquidGradient along a swipe', async ({ page }) => { + const canvas = await openProbe(page); + + await swipe(page); + await expect(canvas).toHaveScreenshot('cursor-smudge-active.png'); +}); + +for (const source of ['LiquidGradient', 'LinearGradient']) { + test(`CursorSmudge distorts ${source} and returns to its original image`, async ({ page }) => { + const canvas = await openProbe(page, source); + const before = await canvas.screenshot(); + + await swipe(page); + expect(await changedPixels(page, before, await canvas.screenshot())).toBeGreaterThan(500); + await page.clock.runFor(4000); + expect(await changedPixels(page, before, await canvas.screenshot())).toBe(0); + }); +} + +test('CursorSmudge at zero strength leaves the GPU image unchanged', async ({ page }) => { + const canvas = await openProbe(page); + const strength = page.getByRole('slider', { name: 'Strength', exact: true }); + + await strength.press('Home'); + await expect(strength).toHaveAttribute('aria-valuenow', '0'); + await page.clock.runFor(32); + const before = await canvas.screenshot(); + + await swipe(page); + expect(await changedPixels(page, before, await canvas.screenshot())).toBe(0); +}); + +test('zero decay holds the GPU distortion until recovery or clearing is requested', async ({ + page, +}) => { + const canvas = await openProbe(page); + const decay = page.getByRole('slider', { name: 'Decay', exact: true }); + + // Keep rendering to prove persistence also holds beside an animated Source. + await page.getByRole('checkbox', { name: 'Keep drawing for GPU measurement' }).check(); + await decay.press('Home'); + await expect(decay).toHaveAttribute('aria-valuenow', '0'); + await page.clock.runFor(32); + const original = await canvas.screenshot(); + + await swipe(page); + const held = await canvas.screenshot(); + + expect(await changedPixels(page, original, held)).toBeGreaterThan(500); + await page.clock.runFor(6000); + expect(await changedPixels(page, held, await canvas.screenshot())).toBe(0); + + await decay.press('End'); + await page.clock.runFor(64); + expect(await changedPixels(page, original, await canvas.screenshot())).toBeGreaterThan(100); + await page.clock.runFor(1000); + expect(await changedPixels(page, original, await canvas.screenshot())).toBe(0); + + await decay.press('Home'); + await page.clock.runFor(32); + await swipe(page); + expect(await changedPixels(page, original, await canvas.screenshot())).toBeGreaterThan(500); + await page.getByRole('slider', { name: 'Strength', exact: true }).press('Home'); + await page.clock.runFor(32); + expect(await changedPixels(page, original, await canvas.screenshot())).toBe(0); +}); diff --git a/apps/docs-tests/visual/cursor-smudge.spec.ts-snapshots/cursor-smudge-active-chromium-darwin.png b/apps/docs-tests/visual/cursor-smudge.spec.ts-snapshots/cursor-smudge-active-chromium-darwin.png new file mode 100644 index 00000000..30f677f3 Binary files /dev/null and b/apps/docs-tests/visual/cursor-smudge.spec.ts-snapshots/cursor-smudge-active-chromium-darwin.png differ diff --git a/apps/docs-tests/visual/cursor-smudge.spec.ts-snapshots/cursor-smudge-active-chromium-linux.png b/apps/docs-tests/visual/cursor-smudge.spec.ts-snapshots/cursor-smudge-active-chromium-linux.png new file mode 100644 index 00000000..ecac4189 Binary files /dev/null and b/apps/docs-tests/visual/cursor-smudge.spec.ts-snapshots/cursor-smudge-active-chromium-linux.png differ diff --git a/apps/docs/public/posters/cursor-smudge.jpg b/apps/docs/public/posters/cursor-smudge.jpg new file mode 100644 index 00000000..c95e1f23 Binary files /dev/null and b/apps/docs/public/posters/cursor-smudge.jpg differ diff --git a/apps/docs/public/posters/cursor-smudge.thumb.webp b/apps/docs/public/posters/cursor-smudge.thumb.webp new file mode 100644 index 00000000..33bf5073 Binary files /dev/null and b/apps/docs/public/posters/cursor-smudge.thumb.webp differ diff --git a/apps/docs/src/app/components/cursor-smudge/demo.tsx b/apps/docs/src/app/components/cursor-smudge/demo.tsx new file mode 100644 index 00000000..6a79a277 --- /dev/null +++ b/apps/docs/src/app/components/cursor-smudge/demo.tsx @@ -0,0 +1,66 @@ +'use client'; + +// The interactive part of the Cursor Smudge page owns its control store. +// The scene loads in the browser because three's renderer cannot run in SSR. +import dynamic from 'next/dynamic'; +import { useMemo } from 'react'; + +import { + ControlPanel, + ControlsProvider, + createControlStore, + DemoLayout, + Section, + SliderInput, + useSnapshot, +} from '@/components/controls'; +import { DemoPoster } from '@/components/DemoPoster'; +import { VisualTestPause } from '@/lib/visualTestHooks'; + +import { type CursorSmudgeParams, INITIAL } from './params'; + +const CursorSmudgeScene = dynamic(() => import('./scene'), { ssr: false }); + +// ---- Live preview +// Only this subscriber and the changed control rerender during a slider drag. +function CursorSmudgeDemo() { + const params = useSnapshot(); + + return ( + + + + + + ); +} + +// ---- Controls and page layout +function CursorSmudgeControls() { + return ( + +
+ + + +
+
+ ); +} + +export function CursorSmudgeIsland() { + const store = useMemo(() => createControlStore(INITIAL), []); + + return ( + + }> +
+ +
+
+
+ ); +} diff --git a/apps/docs/src/app/components/cursor-smudge/params.ts b/apps/docs/src/app/components/cursor-smudge/params.ts new file mode 100644 index 00000000..197352e9 --- /dev/null +++ b/apps/docs/src/app/components/cursor-smudge/params.ts @@ -0,0 +1,13 @@ +// Defaults shared by the docs controls and the poster's scene. +// Keep these aligned with CursorSmudge's public defaults. +export interface CursorSmudgeParams { + strength: number; + radius: number; + decay: number; +} + +export const INITIAL: CursorSmudgeParams = { + strength: 0.12, + radius: 0.18, + decay: 0.4, +}; diff --git a/apps/docs/src/app/components/cursor-smudge/scene.tsx b/apps/docs/src/app/components/cursor-smudge/scene.tsx new file mode 100644 index 00000000..170a351d --- /dev/null +++ b/apps/docs/src/app/components/cursor-smudge/scene.tsx @@ -0,0 +1,25 @@ +'use client'; + +// A still LiquidGradient makes the cursor's directional pull and recovery +// visible. The poster captures this same scene before any pointer movement. +import type { ReactNode } from 'react'; + +import { CursorSmudge, LiquidGradient, ShaderScene } from '@camp-dev/shaders'; + +import { type CursorSmudgeParams, INITIAL } from './params'; + +export default function CursorSmudgeScene({ + params = INITIAL, + children, +}: { + params?: CursorSmudgeParams; + children?: ReactNode; +} = {}) { + return ( + + + + {children} + + ); +} diff --git a/apps/docs/src/app/components/demo-registry.tsx b/apps/docs/src/app/components/demo-registry.tsx index f049342a..24df87be 100644 --- a/apps/docs/src/app/components/demo-registry.tsx +++ b/apps/docs/src/app/components/demo-registry.tsx @@ -12,6 +12,7 @@ import { AuroraIsland } from './aurora/demo'; import { BlobsIsland } from './blobs/demo'; import { ConicGradientIsland } from './conic-gradient/demo'; import { CursorRippleIsland } from './cursor-ripple/demo'; +import { CursorSmudgeIsland } from './cursor-smudge/demo'; import { CursorSpotlightIsland } from './cursor-spotlight/demo'; import { DissolveIsland } from './dissolve/demo'; import { DitherIsland } from './dither/demo'; @@ -108,6 +109,40 @@ export const COMPONENT_PAGES: Record = { ), }, + 'cursor-smudge': { + Island: CursorSmudgeIsland, + copySiblings: [''], + usageSnippet: ` + + +`, + usageNotes: ( + <> +

+ Move the pointer over the canvas without pressing. Higher decay values + recover faster. A still pointer adds no distortion, and the scene can idle after recovery + when its other components are still. Set strength to 0 to disable the effect. +

+

+ Set decay to 0 to keep the distortion after the pointer stops. New strokes + keep changing it. Raise decay to resume recovery, or set strength to 0 to clear it. + Resizing or remounting the scene also clears the distortion. An animated Source keeps + moving beneath the held distortion. +

+

+ Place Cursor Smudge after a Source inside <ShaderScene>. It bends the + image drawn by Sources; other Effects keep their own contributions unbent. Sampling past + the image extends its edge pixels. Cursor Smudge requires Mode 1. +

+

+ Enable scrollPush on the scene to smudge while a finger scrolls across it, + including the fling after release. The page keeps scrolling. Reduced motion slows recovery + to 0.3× speed; the explicit paused motion policy clears the effect. An + ordinary scene pause preserves the smudge until the scene resumes. +

+ + ), + }, 'cursor-spotlight': { Island: CursorSpotlightIsland, copySiblings: [''], diff --git a/apps/docs/src/app/dev/cursor-smudge-probe/page.dev.tsx b/apps/docs/src/app/dev/cursor-smudge-probe/page.dev.tsx new file mode 100644 index 00000000..76df33b5 --- /dev/null +++ b/apps/docs/src/app/dev/cursor-smudge-probe/page.dev.tsx @@ -0,0 +1,11 @@ +'use client'; + +// Phase-review route for SHA-224. Loading the scene only in the browser +// keeps three/webgpu's reference to self out of server rendering. +import dynamic from 'next/dynamic'; + +const ProbeScene = dynamic(() => import('./probe-scene'), { ssr: false }); + +export default function CursorSmudgeProbePage() { + return ; +} diff --git a/apps/docs/src/app/dev/cursor-smudge-probe/probe-scene.tsx b/apps/docs/src/app/dev/cursor-smudge-probe/probe-scene.tsx new file mode 100644 index 00000000..392e8952 --- /dev/null +++ b/apps/docs/src/app/dev/cursor-smudge-probe/probe-scene.tsx @@ -0,0 +1,176 @@ +'use client'; + +// One scene for CursorSmudge phase reviews. The card-sized mode measures +// the complete effect at 312 by 309 CSS pixels and a maximum DPR of 2. +// The tall page also lets a phone push the scene while scrolling it. +import { useEffect, useMemo, useState } from 'react'; + +import { + CursorSmudge, + getReducedMotionPolicy, + LinearGradient, + LiquidGradient, + type ReducedMotionPolicy, + setReducedMotionPolicy, + ShaderMonitor, + ShaderScene, + useStaticSceneHint, +} from '@camp-dev/shaders'; + +import { + ControlPanel, + ControlsProvider, + createControlStore, + Section, + SelectInput, + SliderInput, + useSnapshot, +} from '@/components/controls'; + +// ---- Probe settings and measured scene +interface ProbeParams { + strength: number; + radius: number; + decay: number; + source: 'liquid' | 'linear'; +} + +const INITIAL: ProbeParams = { + strength: 0.12, + radius: 0.18, + decay: 0.4, + source: 'liquid', +}; + +function KeepDrawing() { + useStaticSceneHint(false); + + return null; +} + +function LiveScene({ benchmark, paused }: { benchmark: boolean; paused: boolean }) { + const params = useSnapshot(); + + return ( + + {params.source === 'liquid' ? : } + + {benchmark ? : null} + + + ); +} + +// ---- Effect controls +function Controls() { + return ( + +
+ + + + +
+
+ ); +} + +// ---- Review layout and scene policies +export default function ProbeScene() { + const store = useMemo(() => createControlStore(INITIAL), []); + const [benchmark, setBenchmark] = useState(false); + const [large, setLarge] = useState(false); + const [paused, setPaused] = useState(false); + const [motion, setMotion] = useState(getReducedMotionPolicy); + + // Restore the app's policy when leaving this dev-only probe. + useEffect(() => { + const previous = getReducedMotionPolicy(); + + return () => setReducedMotionPolicy(previous); + }, []); + + return ( +
+

CursorSmudge · phase 3

+

+ Move over the still gradient. Stop to watch it recover. On a phone, scroll with a finger + starting on the image. +

+

+ {large ? '624 × 618' : '312 × 309'} CSS pixels, up to 2× pixel density. The GPU reading + includes the displacement pass. +

+
+ + + + +
+ +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/apps/docs/src/content/components.ts b/apps/docs/src/content/components.ts index bf2d849e..d5e86676 100644 --- a/apps/docs/src/content/components.ts +++ b/apps/docs/src/content/components.ts @@ -38,6 +38,11 @@ export const COMPONENTS = { 'A water surface over the scene. Drag the pointer and it leaves a wake that spreads, catches light, and settles, bending the image beneath it. Stacks on top of any base component inside .', category: 'cursor', }, + 'cursor-smudge': { + description: + 'Pulls the image drawn by Sources along pointer movement, then eases it back without bouncing. Faster swipes pull harder, up to a cap.', + category: 'cursor', + }, 'cursor-spotlight': { description: 'Brightens the scene around the pointer. Invisible until the first move, it fades out when the pointer leaves the canvas. Stacks on top of any base component inside .', diff --git a/packages/shaders/src/components/cursor-smudge/cursor-smudge.test.tsx b/packages/shaders/src/components/cursor-smudge/cursor-smudge.test.tsx new file mode 100644 index 00000000..14487808 --- /dev/null +++ b/packages/shaders/src/components/cursor-smudge/cursor-smudge.test.tsx @@ -0,0 +1,398 @@ +// Exercises CursorSmudge through its mounted component, with the real +// scheduler and shared Input. Only the browser frame clock and three's +// renderer are substitutes; the dev probe checks the actual GPU image. +import { type ReactNode, StrictMode } from 'react'; + +import { cleanup, render } from '@testing-library/react'; +import type { RenderTarget, WebGPURenderer } from 'three/webgpu'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { CursorInput, FrameScheduler, type PrePass, setReducedMotionPolicy } from '../../engine.js'; +import { + ShaderContext, + type ShaderContextValue, + type UvTransform, +} from '../../react/context/shader-context.js'; +import { CursorSmudge } from './cursor-smudge.js'; + +// ---- Mounted scene and browser input +const disposers: Array<() => void> = []; + +afterEach(() => { + cleanup(); + for (const dispose of disposers.splice(0)) dispose(); + setReducedMotionPolicy('auto'); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +// A still Source supplies its idle vote. The host draws pre-passes before +// ticking the shared cursor, in the same order as ShaderScene. +function makeScene({ scrollPush = false, floatTargets = true } = {}) { + let now = 1000; + let nextFrame = 0; + const frames = new Map(); + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + + return nextFrame; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + const canvas = document.createElement('canvas'); + let top = 0; + + document.body.append(canvas); + canvas.getBoundingClientRect = () => ({ left: 0, top, width: 1000, height: 1000 }) as DOMRect; + let target: RenderTarget | null = null; + const draw = vi.fn(); + const renderer = { + domElement: canvas, + getSize: (size: { set: (x: number, y: number) => unknown }) => size.set(1000, 1000), + render: draw, + getRenderTarget: () => target, + setRenderTarget: (next: RenderTarget | null) => { + target = next; + }, + backend: { + isWebGLBackend: true, + extensions: { has: (name: string) => floatTargets && name === 'EXT_color_buffer_float' }, + }, + } as unknown as WebGPURenderer; + const scheduler = new FrameScheduler(); + const prePasses = new Set(); + const transforms = new Set(); + const input = new CursorInput({ element: canvas }); + + input.setScrollPush(scrollPush); + input.onPushStart(() => scheduler.requestRender()); + scheduler.setIdle(true); + scheduler.add(() => { + for (const pass of prePasses) pass(); + }); + scheduler.add(({ delta }) => { + if (!scrollPush) return; + input.tick(delta); + if (input.isPushing()) scheduler.requestRender(); + }); + const registerTransform = vi.fn((transform: UvTransform) => { + transforms.add(transform); + + return () => { + transforms.delete(transform); + }; + }); + const context = { + renderer: { three: renderer }, + scheduler, + getCursorInput: () => input, + registerBaseUvTransform: registerTransform, + registerPrePass: (pass: PrePass) => { + prePasses.add(pass); + + return () => { + prePasses.delete(pass); + }; + }, + canvasSize: { get: () => [1000, 1000, 1] as const, on: () => () => undefined }, + } as unknown as ShaderContextValue; + + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + scheduler.start(); + disposers.push(() => { + scheduler.dispose(); + input.dispose(); + canvas.remove(); + }); + + return { + Wrapper, + canvas, + scheduler, + draw, + transforms, + prePasses, + registerTransform, + scrollTo: (next: number) => { + top = next; + }, + get parked() { + return frames.size === 0; + }, + advance(count = 1) { + for (let frame = 0; frame < count; frame++) { + now += 1000 / 60; + const queued = [...frames.values()]; + + frames.clear(); + for (const callback of queued) callback(now); + } + }, + }; +} + +function move(x: number, y = 500) { + window.dispatchEvent( + new PointerEvent('pointermove', { clientX: x, clientY: y, pointerType: 'mouse' }), + ); +} + +function stroke(scene: ReturnType) { + move(100); + scene.advance(2); + move(300); + scene.advance(3); +} + +// ---- Persistence, recovery, and scene lifecycle +it('parks a persistent smudge and wakes for another stroke', () => { + const scene = makeScene(); + + render(, { wrapper: scene.Wrapper }); + stroke(scene); + expect(scene.parked).toBe(true); + const drawsAtRest = scene.draw.mock.calls.length; + + scene.advance(3600); + expect(scene.draw).toHaveBeenCalledTimes(drawsAtRest); + move(500); + scene.advance(3); + expect(scene.draw.mock.calls.length).toBeGreaterThan(drawsAtRest); + expect(scene.parked).toBe(true); +}); + +it('pulls a fast swipe as hard after the scene parks as before', () => { + const scene = makeScene(); + // Frames until the pull recovers and the scene parks. A stronger pull + // takes longer to fall under the settle threshold. + const settle = () => { + let frames = 0; + + for (; !scene.parked && frames < 600; frames++) scene.advance(); + + return frames; + }; + + render(, { wrapper: scene.Wrapper }); + move(100); + scene.advance(); + move(200); + scene.advance(); + const pulledRunning = settle(); + + scene.advance(600); + move(300); + scene.advance(); + expect(settle()).toBe(pulledRunning); +}); + +it('starts recovery when decay becomes positive without counting the idle gap', () => { + const scene = makeScene(); + const { rerender } = render(, { wrapper: scene.Wrapper }); + + stroke(scene); + scene.advance(3600); + rerender(); + scene.advance(4); + expect(scene.parked).toBe(false); + scene.advance(240); + expect(scene.parked).toBe(true); +}); + +it('continues recovery after an ordinary scene pause', () => { + const scene = makeScene(); + + render(, { wrapper: scene.Wrapper }); + stroke(scene); + expect(scene.parked).toBe(false); + + scene.scheduler.pause(); + const drawsBeforePause = scene.draw.mock.calls.length; + + scene.advance(120); + expect(scene.draw).toHaveBeenCalledTimes(drawsBeforePause); + + scene.scheduler.resume(); + expect(scene.parked).toBe(false); + scene.advance(2); + expect(scene.draw.mock.calls.length).toBeGreaterThan(drawsBeforePause); + scene.advance(240); + expect(scene.parked).toBe(true); +}); + +it.each(['blur', 'pointerleave', 'pointercancel'])('starts a fresh stroke after %s', (event) => { + const scene = makeScene(); + + render(, { wrapper: scene.Wrapper }); + stroke(scene); + (event === 'blur' ? window : scene.canvas).dispatchEvent(new Event(event)); + scene.advance(240); + expect(scene.parked).toBe(true); + + move(800); + scene.advance(3); + expect(scene.parked).toBe(true); + + move(700); + scene.advance(3); + expect(scene.parked).toBe(false); +}); + +it('clears a live smudge at zero strength and accepts fresh movement when enabled', () => { + const scene = makeScene(); + const { rerender } = render(, { wrapper: scene.Wrapper }); + + stroke(scene); + expect(scene.parked).toBe(false); + rerender(); + scene.advance(3); + expect(scene.parked).toBe(true); + stroke(scene); + expect(scene.parked).toBe(true); + + rerender(); + scene.advance(3); + expect(scene.parked).toBe(true); + stroke(scene); + expect(scene.parked).toBe(false); + expect(scene.registerTransform).toHaveBeenCalledTimes(1); +}); + +it('survives Strict Mode and releases its scene work on unmount', () => { + const scene = makeScene(); + const { unmount } = render( + + + , + { wrapper: scene.Wrapper }, + ); + + stroke(scene); + expect(scene.parked).toBe(false); + expect(scene.prePasses.size).toBe(1); + expect(scene.transforms.size).toBe(1); + unmount(); + scene.advance(3); + const drawsAfterUnmount = scene.draw.mock.calls.length; + + stroke(scene); + expect(scene.draw).toHaveBeenCalledTimes(drawsAfterUnmount); + expect(scene.parked).toBe(true); + expect(scene.prePasses.size).toBe(0); + expect(scene.transforms.size).toBe(0); +}); + +it('redraws a parked scene once after removing a held smudge', () => { + const scene = makeScene(); + const { unmount } = render(, { wrapper: scene.Wrapper }); + + stroke(scene); + expect(scene.parked).toBe(true); + unmount(); + expect(scene.parked).toBe(false); + scene.advance(); + expect(scene.parked).toBe(true); +}); + +it('clears instead of freezing when the reduced-motion policy is paused', () => { + const scene = makeScene(); + + render(, { wrapper: scene.Wrapper }); + stroke(scene); + expect(scene.parked).toBe(false); + setReducedMotionPolicy('paused'); + scene.advance(3); + expect(scene.parked).toBe(true); + stroke(scene); + expect(scene.parked).toBe(true); + setReducedMotionPolicy('off'); + scene.advance(3); + expect(scene.parked).toBe(true); + stroke(scene); + expect(scene.parked).toBe(false); +}); + +it.each(['off', 'slow'] as const)('recovers and parks under the %s motion policy', (policy) => { + setReducedMotionPolicy(policy); + const scene = makeScene(); + + render(, { wrapper: scene.Wrapper }); + scene.advance(3); + expect(scene.parked).toBe(true); + stroke(scene); + scene.advance(50); + expect(scene.parked).toBe(policy === 'off'); + scene.advance(120); + expect(scene.parked).toBe(true); + const drawsAtRest = scene.draw.mock.calls.length; + + scene.advance(600); + expect(scene.draw).toHaveBeenCalledTimes(drawsAtRest); +}); + +it.each([true, false])( + 'accepts touch scrolling only with scrollPush enabled (%s)', + (scrollPush) => { + const scene = makeScene({ scrollPush }); + + render(, { wrapper: scene.Wrapper }); + scene.advance(3); + const drawsBeforeTouch = scene.draw.mock.calls.length; + const touch = new Touch({ identifier: 1, target: scene.canvas, clientX: 500, clientY: 500 }); + const event = new TouchEvent('touchstart', { + touches: [touch], + changedTouches: [touch], + bubbles: true, + cancelable: true, + }); + + scene.canvas.dispatchEvent(event); + scene.advance(); + scene.scrollTo(-30); + scene.advance(3); + expect(event.defaultPrevented).toBe(false); + expect(scene.draw.mock.calls.length > drawsBeforeTouch).toBe(scrollPush); + + scene.canvas.dispatchEvent( + new TouchEvent('touchend', { touches: [], changedTouches: [touch], bubbles: true }), + ); + scene.advance(300); + expect(scene.parked).toBe(true); + }, +); + +it('stays inert when floating-point render targets are unavailable', () => { + const scene = makeScene({ floatTargets: false }); + + render(, { wrapper: scene.Wrapper }); + stroke(scene); + expect(scene.draw).not.toHaveBeenCalled(); + expect(scene.parked).toBe(true); +}); + +it('updates strength from an animation signal without rebuilding the UV transform', () => { + const listeners = new Set<(value: number) => void>(); + const strength = { + get: () => 0.12, + on: (_event: 'change', listener: (value: number) => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; + const scene = makeScene(); + const { unmount } = render(, { wrapper: scene.Wrapper }); + + stroke(scene); + for (const listener of listeners) listener(0); + scene.advance(3); + expect(scene.parked).toBe(true); + expect(scene.registerTransform).toHaveBeenCalledTimes(1); + unmount(); + expect(listeners.size).toBe(0); +}); diff --git a/packages/shaders/src/components/cursor-smudge/cursor-smudge.tsx b/packages/shaders/src/components/cursor-smudge/cursor-smudge.tsx new file mode 100644 index 00000000..5424bfbf --- /dev/null +++ b/packages/shaders/src/components/cursor-smudge/cursor-smudge.tsx @@ -0,0 +1,32 @@ +'use client'; + +// Public controls for a directional cursor effect. The shader half owns +// its displacement field and reads the scene's shared cursor Input. +// Numeric controls ride uniforms, so animation signals keep the material stable. +import type { AnimatableProp } from '../../react/hooks/animatable-signal/animatable-signal.js'; +import { CursorSmudgeShader } from './shader.js'; + +export interface CursorSmudgeProps { + /** + * Maximum image pull in canvas heights. 0 disables distortion. Defaults + * to 0.12. Accepts a static value or an animation signal. + */ + strength?: AnimatableProp; + /** + * Brush radius in canvas heights, with a soft edge. 0 accepts no new + * strokes. Defaults to 0.18. Accepts a static value or an animation signal. + */ + radius?: AnimatableProp; + /** + * Recovery speed, 0..1. 0 holds the distortion until decay is raised or + * strength is set to 0. Positive values remove 90% of a pull in about + * four seconds near 0 and 0.2 seconds at 1. Resizing or remounting clears + * the distortion. Defaults to 0.4. + * Accepts a static value or an animation signal. + */ + decay?: AnimatableProp; +} + +export function CursorSmudge({ strength = 0.12, radius = 0.18, decay = 0.4 }: CursorSmudgeProps) { + return ; +} diff --git a/packages/shaders/src/components/cursor-smudge/field.ts b/packages/shaders/src/components/cursor-smudge/field.ts new file mode 100644 index 00000000..eb5cfaf5 --- /dev/null +++ b/packages/shaders/src/components/cursor-smudge/field.ts @@ -0,0 +1,203 @@ +// Stores a directional displacement between frames for CursorSmudge. Two +// textures trade read and write roles, so each pass can shrink the old +// displacement and add the latest stroke without reading its own output. +// This fragment pass works on both WebGPU and the WebGL2 fallback. Lives +// beside the component, as stroke.ts does in cursor-ripple/, until a second +// component needs it. +import { + clamp, + dot, + length, + max, + oneMinus, + smoothstep, + texture, + uniform, + uv, + vec2, + vec4, +} from 'three/tsl'; +import { HalfFloatType, LinearFilter, Texture, Vector2, type WebGPURenderer } from 'three/webgpu'; + +import { createTexturePass, type PushStroke } from '../../engine.js'; + +export interface SmudgeStep { + /** Seconds of simulation time since the last frame. */ + delta: number; + /** Seconds of real pointer movement, before reduced-motion scaling. */ + inputDelta: number; + /** The accepted stroke in top-left canvas coordinates, or null. */ + stroke: PushStroke | null; + /** Brush radius in canvas heights. Zero accepts no new displacement. */ + radius: number; + /** Recovery speed, 0..1. Zero holds displacement; higher values settle sooner. */ + decay: number; + /** Clear the field and accept no stroke while disabled. */ + disabled: boolean; +} + +export interface SmudgeField { + /** Advance and draw once. Call inside the scene's timed pre-pass. */ + step: (step: SmudgeStep) => void; + /** Latest displacement texture, or null when float targets are unavailable. */ + readonly texture: Texture | null; + /** Whether every stored displacement has settled to zero. */ + readonly atRest: boolean; + /** Match the canvas size, clearing displacement after a resize. */ + resize: (width: number, height: number) => void; + /** Release both targets and their materials. */ + dispose: () => void; +} + +// A quarter-resolution field is interpolated when sampled at full size. +// The 512-texel cap bounds memory and fill cost on large canvases. +const FIELD_SCALE = 0.25; +const MAX_FIELD_EDGE = 512; +// Displacement per canvas height travelled. Higher gain pulls harder. +const SWIPE_GAIN = 8; +// Canvas heights per second. Faster movement adds no extra force beyond this. +const SPEED_CAP = 3; +// Seconds to remove 90% of a pull, as positive decay approaches 0 and at 1. +// Exactly zero disables recovery. The 20x range for positive values keeps +// useful slow recovery across the dial instead of crowding it against zero. +const SLOW_RECOVERY_SECONDS = 4; +const FAST_RECOVERY_SECONDS = 0.2; +// Below one thousandth of the maximum pull, clear to exact zero and park. +const SETTLE_THRESHOLD = 0.001; + +// Keep all displacement at zero decay. Otherwise, keep 10% after the +// chosen recovery time, independent of frame rate and parking threshold. +function recoveryRetention(decay: number, delta: number): number { + if (decay <= 0) return 1; + const recoverySeconds = + SLOW_RECOVERY_SECONDS * + Math.pow(FAST_RECOVERY_SECONDS / SLOW_RECOVERY_SECONDS, Math.min(1, decay)); + + return Math.pow(0.1, Math.max(0, delta) / recoverySeconds); +} + +export function createSmudgeField( + renderer: WebGPURenderer, + width: number, + height: number, +): SmudgeField { + const placeholder = new Texture(); + const previous = texture(placeholder, uv()); + const retention = uniform(0); + const aspect = uniform(width / height); + const strokeFrom = uniform(new Vector2()); + const strokeTo = uniform(new Vector2()); + const impulse = uniform(new Vector2()); + const radius = uniform(0.18); + + // ---- A soft brush around the whole stroke + // uv() starts at the bottom-left. Stretch x by width/height so both axes + // measure canvas heights and the brush stays round on a wide canvas. + const stretch = vec2(aspect, 1); + const point = stretch.mul(uv()); + const start = stretch.mul(strokeFrom); + const segment = stretch.mul(strokeTo).sub(start); + // Project the point onto the stroke, then clamp to its endpoints. The + // denominator epsilon keeps a zero-length stroke from dividing by zero. + const along = clamp(dot(point.sub(start), segment).div(max(dot(segment, segment), 1e-8)), 0, 1); + const distance = length(point.sub(start.add(segment.mul(along)))); + // smoothstep eases from 0 at the stroke to 1 at the radius. Flip it so + // the stroke receives full displacement and the edge receives none. + // The radius epsilon keeps smoothstep's edges distinct at radius zero. + const brush = oneMinus(smoothstep(0, max(radius, 1e-5), distance)); + + // ---- Recovery and injection in one draw + // Multiplying by a positive fraction only shrinks displacement; there is + // no velocity or spring force to overshoot. Cap the vector's length at 1 + // so repeated fast strokes cannot exceed the component's strength. + const next = previous.rg.mul(retention).add(vec2(impulse).mul(brush)); + const capped = next.div(max(1, length(next))); + const node = vec4(capped, 0, 1); + const size = (nextWidth: number, nextHeight: number) => { + const scale = Math.min(FIELD_SCALE, MAX_FIELD_EDGE / Math.max(nextWidth, nextHeight)); + + return { + width: Math.max(1, Math.round(nextWidth * scale)), + height: Math.max(1, Math.round(nextHeight * scale)), + }; + }; + const options = { ...size(width, height), type: HalfFloatType, filter: LinearFilter }; + let read = createTexturePass(renderer, node, options); + let write = createTexturePass(renderer, node, options); + let bound = 0; + let needsClear = true; + + const hasTargets = () => read.texture !== null && write.texture !== null; + + // ---- Turn pointer travel into a capped directional impulse + const updateImpulse = (stroke: PushStroke | null, inputDelta: number) => { + impulse.value.set(0, 0); + if (!stroke || inputDelta <= 0) return 0; + const dx = (stroke.to[0] - stroke.from[0]) * aspect.value; + const dy = stroke.from[1] - stroke.to[1]; + const travel = Math.hypot(dx, dy); + + if (travel === 0) return 0; + const speed = Math.min(travel / inputDelta, SPEED_CAP); + const push = speed * inputDelta * SWIPE_GAIN * Math.max(0, Math.min(1, stroke.presence)); + + impulse.value.set((dx / travel) * push, (dy / travel) * push); + strokeFrom.value.set(stroke.from[0], 1 - stroke.from[1]); + strokeTo.value.set(stroke.to[0], 1 - stroke.to[1]); + + return push; + }; + + // ---- Advance, resize, and release the displacement field + return { + step(step) { + if (!hasTargets()) return; + const keep = recoveryRetention(step.decay, step.delta); + const stroke = step.disabled || step.radius <= 0 ? null : step.stroke; + const push = updateImpulse(stroke, step.inputDelta); + + // A held field needs no simulation draw, even when another Source + // keeps the scene animating. Keep its texture for the next stroke. + if (!needsClear && push === 0 && (bound === 0 || (!step.disabled && step.decay <= 0))) return; + // This scalar is an upper bound on every texel's length, so deciding + // when to park needs no synchronous GPU readback. + bound = Math.min(1, (needsClear ? 0 : bound * keep) + push); + const clear = step.disabled || (step.decay > 0 && push === 0 && bound <= SETTLE_THRESHOLD); + + retention.value = needsClear || clear ? 0 : keep; + radius.value = step.radius; + if (clear) { + impulse.value.set(0, 0); + bound = 0; + } + const source = read.texture; + + if (!source) return; + previous.value = source; + write.render(); + [read, write] = [write, read]; + needsClear = false; + }, + get texture() { + return hasTargets() ? read.texture : null; + }, + get atRest() { + return !hasTargets() || bound === 0; + }, + resize(nextWidth, nextHeight) { + if (nextWidth <= 0 || nextHeight <= 0) return; + const nextSize = size(nextWidth, nextHeight); + + read.resize(nextSize.width, nextSize.height); + write.resize(nextSize.width, nextSize.height); + aspect.value = nextWidth / nextHeight; + bound = 0; + needsClear = true; + }, + dispose() { + read.dispose(); + write.dispose(); + placeholder.dispose(); + }, + }; +} diff --git a/packages/shaders/src/components/cursor-smudge/shader.tsx b/packages/shaders/src/components/cursor-smudge/shader.tsx new file mode 100644 index 00000000..0d1b72db --- /dev/null +++ b/packages/shaders/src/components/cursor-smudge/shader.tsx @@ -0,0 +1,181 @@ +'use client'; + +// Bridges the scene's cursor and frame loop to a displacement texture. +// The texture updates inside a pre-pass, so the scene samples the new state +// and ShaderMonitor includes the simulation in its GPU measurement. +import { useEffect, useMemo } from 'react'; + +import { oneMinus, texture, uniform, uv, vec2 } from 'three/tsl'; +import { Texture, Vector2 as ThreeVector2 } from 'three/webgpu'; + +import { + createReducedMotionWatcher, + type PushStroke, + type SchedulerTick, + type Vector2, +} from '../../engine.js'; +import { useAnimatableUniform } from '../../react/hooks/use-animatable-uniform/use-animatable-uniform.js'; +import { useAspectUniform } from '../../react/hooks/use-aspect-uniform/use-aspect-uniform.js'; +import { useBasePassUv } from '../../react/hooks/use-base-pass-uv/use-base-pass-uv.js'; +import { useShaderContext } from '../../react/hooks/use-shader-context/use-shader-context.js'; +import { useStaticSceneHint } from '../../react/hooks/use-static-hint/use-static-hint.js'; +import type { CursorSmudgeProps } from './cursor-smudge.js'; +import { createSmudgeField } from './field.js'; + +// Longest a stroke's first frame of travel counts for, in seconds: one +// frame at 60 Hz. Longer lets a fast first move after the scene parks pull +// harder than the same move mid-stroke; shorter makes it pull softer. +const STROKE_START_SECONDS = 1 / 60; + +export function CursorSmudgeShader({ + strength = 0.12, + radius = 0.18, + decay = 0.4, +}: CursorSmudgeProps) { + const context = useShaderContext(); + const strengthUniform = useAnimatableUniform(strength); + const radiusUniform = useAnimatableUniform(radius); + const decayUniform = useAnimatableUniform(decay); + const aspect = useAspectUniform(); + const fieldTexture = useMemo(() => texture(new Texture(), uv()), []); + const active = useMemo(() => uniform(0), []); + + useStaticSceneHint(true); + + // ---- Read displacement when the Sources are sampled + // Pass coordinates start at the top-left; the field starts at the + // bottom-left, so flip y both when reading and when applying the vector. + // Subtract the pull: to move the image right, show the pixel to the left. + useBasePassUv( + (coordinate) => { + const pull = fieldTexture.uv(vec2(coordinate.x, oneMinus(coordinate.y))).rg; + + return coordinate.sub( + vec2(pull.x.div(aspect), pull.y.negate()).mul(strengthUniform).mul(active), + ); + }, + [fieldTexture, aspect, strengthUniform, active], + ); + + // ---- Create, drive, and dispose together for Strict Mode + useEffect(() => { + if (!context) return; + const { scheduler } = context; + const input = context.getCursorInput(); + const size = context.renderer.three.getSize(new ThreeVector2()); + const field = createSmudgeField(context.renderer.three, size.width, size.height); + const motion = createReducedMotionWatcher(); + let previous: Vector2 | null = null; + let inside = false; + let pendingStroke: PushStroke | null = null; + let stroking = false; + let pendingDelta = 0; + let pendingRecoveryDelta = 0; + let recovering = decayUniform.value > 0; + + const endStroke = () => { + previous = null; + inside = false; + pendingStroke = null; + }; + + const canvas = context.renderer.three.domElement; + + window.addEventListener('blur', endStroke); + canvas.addEventListener('pointerleave', endStroke); + canvas.addEventListener('pointercancel', endStroke); + + // Reset at the actual boundary, not after eased presence reaches zero. + // A move back onto the canvas seeds a new stroke instead of joining it + // to the last exit point, even if both events arrive between frames. + const stopMoves = input.onMove(() => { + const currentInside = input.isInside(); + + if (!currentInside || !inside) previous = currentInside ? input.getTarget() : null; + inside = currentInside; + if (inside) scheduler.requestRender(); + }); + const stopPause = scheduler.onPauseChange((paused) => { + previous = null; + pendingStroke = null; + pendingDelta = 0; + pendingRecoveryDelta = 0; + if (!paused && !field.atRest) scheduler.requestRender(); + }); + const stopMotion = motion.subscribe(() => scheduler.requestRender()); + + // The scene renders before child scheduler clients. Gather input here + // for the next draw; do the GPU work only inside its timed pre-pass. + const gather = ({ delta }: SchedulerTick) => { + const current = input.getTarget(); + const from = previous; + const moved = + inside && + current !== null && + from !== null && + (current[0] !== from[0] || current[1] !== from[1]); + const mouse = moved ? { from, to: current, presence: 1 } : null; + const push = input.getPush(); + + pendingStroke = mouse ?? push; + previous = inside ? current : null; + // A parked scene's first tick reports the whole sleep as its delta, + // but the travel it carries took about one frame. Without the cap, the + // field's speed limit would see a slow move and let a fast one through. + pendingDelta = pendingStroke && !stroking ? Math.min(delta, STROKE_START_SECONDS) : delta; + stroking = pendingStroke !== null; + // A held smudge can sleep for minutes. When recovery is enabled, + // start its clock now instead of applying the time spent asleep. + pendingRecoveryDelta = recovering ? delta : 0; + recovering = decayUniform.value > 0; + if (pendingStroke || (decayUniform.value > 0 && !field.atRest)) scheduler.requestRender(); + }; + + scheduler.add(gather); + + const stopPass = context.registerPrePass(() => { + const scale = motion.scale(); + + field.step({ + delta: pendingRecoveryDelta * scale, + inputDelta: pendingDelta, + stroke: pendingStroke, + radius: radiusUniform.value, + decay: decayUniform.value, + disabled: strengthUniform.value <= 0 || scale <= 0, + }); + pendingStroke = null; + pendingDelta = 0; + pendingRecoveryDelta = 0; + const current = field.texture; + + active.value = current && strengthUniform.value > 0 && scale > 0 ? 1 : 0; + if (current) fieldTexture.value = current; + }); + const stopResize = context.canvasSize.on('change', ([width, height]) => + field.resize(width, height), + ); + + scheduler.requestRender(); + + return () => { + window.removeEventListener('blur', endStroke); + canvas.removeEventListener('pointerleave', endStroke); + canvas.removeEventListener('pointercancel', endStroke); + stopMoves(); + stopPause(); + stopMotion(); + stopPass(); + stopResize(); + scheduler.remove(gather); + motion.dispose(); + field.dispose(); + active.value = 0; + // A parked scene still shows the last smudged frame. Draw once more + // so a held distortion leaves the canvas with the effect. + scheduler.requestRender(); + }; + }, [context, fieldTexture, active, strengthUniform, radiusUniform, decayUniform]); + + return null; +} diff --git a/packages/shaders/src/components/index.ts b/packages/shaders/src/components/index.ts index 7fedb7bc..1f694008 100644 --- a/packages/shaders/src/components/index.ts +++ b/packages/shaders/src/components/index.ts @@ -19,6 +19,9 @@ export type { CursorRippleProps } from './cursor-ripple/cursor-ripple.js'; export { CursorRippleShader } from './cursor-ripple/shader.js'; export type { CursorRippleShaderProps } from './cursor-ripple/shader.js'; +export { CursorSmudge } from './cursor-smudge/cursor-smudge.js'; +export type { CursorSmudgeProps } from './cursor-smudge/cursor-smudge.js'; + export { CursorSpotlight } from './cursor-spotlight/cursor-spotlight.js'; export type { CursorSpotlightProps } from './cursor-spotlight/cursor-spotlight.js'; diff --git a/scripts/build-posters.sh b/scripts/build-posters.sh index ae0a3a27..a57ffc80 100755 --- a/scripts/build-posters.sh +++ b/scripts/build-posters.sh @@ -72,6 +72,7 @@ for pair in \ "blobs:jpg" \ "led-wall:jpg:#0b0f0d" \ "cursor-ripple:jpg" \ + "cursor-smudge:jpg" \ "cursor-spotlight:jpg" \ "radial-wipe:jpg:#0b0f0d" \ "dissolve:jpg:#0b0f0d"; do