Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-cursors-return.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/docs-tests/a11y/component-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
141 changes: 141 additions & 0 deletions apps/docs-tests/visual/cursor-smudge.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/posters/cursor-smudge.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/posters/cursor-smudge.thumb.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions apps/docs/src/app/components/cursor-smudge/demo.tsx
Original file line number Diff line number Diff line change
@@ -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<CursorSmudgeParams>();

return (
<DemoPoster
alt="Cursor Smudge preview: a liquid gradient at rest before the pointer moves"
src="/posters/cursor-smudge.jpg"
>
<CursorSmudgeScene params={params}>
<VisualTestPause />
</CursorSmudgeScene>
</DemoPoster>
);
}

// ---- Controls and page layout
function CursorSmudgeControls() {
return (
<ControlPanel>
<Section title="Smudge">
<SliderInput label="Strength" max={0.3} min={0} path="strength" step={0.01} />
<SliderInput label="Radius" max={0.5} min={0} path="radius" step={0.01} />
<SliderInput label="Decay" max={1} min={0} path="decay" step={0.01} />
</Section>
</ControlPanel>
);
}

export function CursorSmudgeIsland() {
const store = useMemo(() => createControlStore<CursorSmudgeParams>(INITIAL), []);

return (
<ControlsProvider store={store}>
<DemoLayout controls={<CursorSmudgeControls />}>
<div data-shader-demo>
<CursorSmudgeDemo />
</div>
</DemoLayout>
</ControlsProvider>
);
}
13 changes: 13 additions & 0 deletions apps/docs/src/app/components/cursor-smudge/params.ts
Original file line number Diff line number Diff line change
@@ -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,
};
25 changes: 25 additions & 0 deletions apps/docs/src/app/components/cursor-smudge/scene.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ShaderScene scrollPush>
<LiquidGradient speed={0} />
<CursorSmudge decay={params.decay} radius={params.radius} strength={params.strength} />
{children}
</ShaderScene>
);
}
35 changes: 35 additions & 0 deletions apps/docs/src/app/components/demo-registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -108,6 +109,40 @@ export const COMPONENT_PAGES: Record<string, ComponentPageEntry> = {
</>
),
},
'cursor-smudge': {
Island: CursorSmudgeIsland,
copySiblings: ['<LiquidGradient speed={0} />'],
usageSnippet: `<ShaderScene scrollPush>
<LiquidGradient speed={0} />
<CursorSmudge strength={0.12} radius={0.18} decay={0.4} />
</ShaderScene>`,
usageNotes: (
<>
<p>
Move the pointer over the canvas without pressing. Higher <code>decay</code> values
recover faster. A still pointer adds no distortion, and the scene can idle after recovery
when its other components are still. Set <code>strength</code> to 0 to disable the effect.
</p>
<p>
Set <code>decay</code> 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.
</p>
<p>
Place Cursor Smudge after a Source inside <code>&lt;ShaderScene&gt;</code>. 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.
</p>
<p>
Enable <code>scrollPush</code> 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 <code>paused</code> motion policy clears the effect. An
ordinary scene pause preserves the smudge until the scene resumes.
</p>
</>
),
},
'cursor-spotlight': {
Island: CursorSpotlightIsland,
copySiblings: ['<RadialGradient />'],
Expand Down
11 changes: 11 additions & 0 deletions apps/docs/src/app/dev/cursor-smudge-probe/page.dev.tsx
Original file line number Diff line number Diff line change
@@ -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 <ProbeScene />;
}
Loading
Loading