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
7 changes: 7 additions & 0 deletions .changeset/scene-scroll-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@camp-dev/shaders': minor
---

Add `scrollPush` to `ShaderScene`. With it on, a touch that scrolls the page over the canvas pushes the scene's cursor effects at the finger, and a fling keeps pushing until the page stops. The scene listens passively and never cancels a touch, so the page scrolls and zooms as it would without the scene. A touch then reaches cursor effects only this way, because touch pointer moves no longer move the cursor. A mouse works as before. `CursorRipple` stirs its water with the push. `CursorSpotlight` doesn't read the push, so it ignores touch in a scene with `scrollPush` on.

`CursorInput` gains `setScrollPush`, `getPush`, `isPushing`, and `onPushStart`. The signal `useCursor` returns gains `getPush`, which reads the frame's push stroke. The new `PushStroke` type has the shape of a wave field's stroke. While `scrollPush` is on, `ShaderScene` ticks its shared `CursorInput` once per frame, ahead of the cursor effects that read it, and keeps drawing while a touch pushes.
4 changes: 4 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ _Avoid_: Interaction, cursor ability, hover effect
An Effect driven by the cursor Input that works over any scene with no help from the components beneath it. CursorSpotlight and CursorRipple are two.
_Avoid_: Interactive shader, cursor component, drop-in

**Scroll push**:
The push a scene's cursor effects feel from a touch that scrolls the page over the canvas, at the finger and on through the fling. A scene opts in, and the page scrolls exactly as it would without it.
_Avoid_: Hold to interact, touch drag, scroll drag

### Rendering

**Render on demand**:
Expand Down
78 changes: 78 additions & 0 deletions apps/docs-tests/docs/scroll-push.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { type CDPSession, expect, type Page, test } from '@playwright/test';

/**
* Scroll push in a real browser (SHA-225): a CDP touch that drags on the
* touch probe's opted-in scene scrolls the page, and the probe's recorder
* logs the push frames that reached the scene. SwiftShader draws slowly on
* CI and the push comes once per frame, so the drag takes its time and every
* check polls.
*/
test.use({
hasTouch: true,
isMobile: true,
viewport: { width: 412, height: 839 },
deviceScaleFactor: 1,
});

const scene = (page: Page) => page.locator('[data-scroll-push-scene]');

const scrollY = (page: Page) => page.evaluate(() => window.scrollY);

const touch = (
client: CDPSession,
type: 'touchStart' | 'touchMove' | 'touchEnd',
points: Array<{ x: number; y: number; id: number }>,
) => client.send('Input.dispatchTouchEvent', { type, touchPoints: points });

/**
* Opens the probe with the top scene in view, once it has drawn. A scene
* pauses while it is off screen, and this one starts below the fold, so it
* scrolls in before the wait.
*/
async function open(page: Page): Promise<{ client: CDPSession; x: number; y: number }> {
await page.goto('/dev/touch-probe');
await scene(page).evaluate((element) => element.scrollIntoView({ block: 'center' }));
await expect(scene(page)).toHaveAttribute('data-ready', 'true', { timeout: 60_000 });
const box = await scene(page).boundingBox();

if (!box) throw new Error('The scroll-push scene has no box.');

return {
client: await page.context().newCDPSession(page),
x: box.x + box.width / 2,
y: box.y + box.height * 0.8,
};
}

/** The push frames the probe logged for its first contact, once it settles. */
async function firstContactPushFrames(page: Page): Promise<number | null> {
const report = await page.getByLabel('Report').inputValue();
const match = /#1: .* (\d+) push frames/.exec(report);

return match ? Number(match[1]) : null;
}

test('a drag on an opted-in scene scrolls the page and pushes the scene', async ({ page }) => {
const { client, x, y } = await open(page);
const before = await scrollY(page);

await touch(client, 'touchStart', [{ x, y, id: 1 }]);
for (let step = 1; step <= 20; step += 1) {
await touch(client, 'touchMove', [{ x, y: y - step * 10, id: 1 }]);
await page.waitForTimeout(50);
}
await touch(client, 'touchEnd', []);

await expect.poll(() => scrollY(page)).toBeGreaterThan(before + 50);
await expect.poll(() => firstContactPushFrames(page), { timeout: 30_000 }).toBeGreaterThan(0);
});

test('a tap on an opted-in scene pushes nothing', async ({ page }) => {
const { client, x, y } = await open(page);

await touch(client, 'touchStart', [{ x, y, id: 1 }]);
await page.waitForTimeout(200);
await touch(client, 'touchEnd', []);

await expect.poll(() => firstContactPushFrames(page), { timeout: 30_000 }).toBe(0);
});
3 changes: 2 additions & 1 deletion apps/docs/content/docs/react/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { ShaderScene } from '@camp-dev/shaders'
| `maxFPS` | `number` | — | Sets the frame cap, the most frames per second the scene draws. With no frame cap, the scene draws at the display's refresh rate. Zero, a negative number, or a non-finite number sets no frame cap. A change applies from the next frame. See [the performance guide](/guides/perf#draw-fewer-frames-with-maxfps). |
| `gamut` | `'auto' \| 'srgb' \| 'p3'` | `'auto'` | Output color gamut. `'auto'` detects via `(color-gamut: p3)` and re-resolves when the window moves to another display. See [the color guide](/guides/color#gamut--what-the-output-can-hold). |
| `paused` | `boolean` | `false` | Freezes the scene on its current frame. Time stops while it is paused, so it resumes on the frame it stopped on. |
| `scrollPush` | `boolean` | `false` | Lets a touch that scrolls the page over the canvas push the scene's cursor effects, at the finger and on through the fling. Only effects that read the push, such as `CursorRipple`, feel it. The scene only listens, so the page scrolls and zooms exactly as it would without it. A touch then reaches cursor effects only as a push, so `CursorSpotlight` ignores touch. A mouse works as before. A change applies straight away. |
| `style` | `CSSProperties` | — | Forwarded to the canvas container. |
| `className` | `string` | — | Forwarded to the canvas container. |
| `children` | `ReactNode` | — | One or more Shaders components. |
Expand All @@ -59,7 +60,7 @@ function Plane() {

All three return signal-shaped objects compatible with [animation props](/guides/animation):

- **`useCursor()`** → `CursorSignal` with `value` in `[0, 1]` normalized to the canvas bounding rect, plus a `presence` signal from 0 to 1 that eases in after the pointer enters the canvas and out after it leaves. Calls in one scene share one pointer input. Options: `smoothing`, `initial`, `enabled`, `element`, `target`, `onMove`.
- **`useCursor()`** → `CursorSignal` with `value` in `[0, 1]` normalized to the canvas bounding rect, plus a `presence` signal from 0 to 1 that eases in after the pointer enters the canvas and out after it leaves. Calls in one scene share one pointer input. In a scene with `scrollPush` on, `getPush()` returns the frame's push stroke: a segment in canvas units that ends at the finger, or `null` when no touch pushed. Options: `smoothing`, `initial`, `enabled`, `element`, `target`, `onMove`.
- **`useScroll()`** → `ScrollSignal` with normalized scroll progress for the page.
- **`useResize()`** → `ResizeSignal` with current canvas dimensions in pixels.

Expand Down
11 changes: 11 additions & 0 deletions apps/docs/src/app/dev/touch-probe/page.dev.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client';

import dynamic from 'next/dynamic';

// three/webgpu references `self` at module load and cannot SSR, so the probe
// scene is loaded client-only. Dev route: invisible to production builds.
const ProbeScene = dynamic(() => import('./probe-scene'), { ssr: false });

export default function TouchProbePage() {
return <ProbeScene />;
}
119 changes: 119 additions & 0 deletions apps/docs/src/app/dev/touch-probe/probe-scene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
'use client';

// Real-device probe for scroll push (SHA-225): two Reactive-card-sized
// scenes of LiquidGradient under CursorRipple in a page that scrolls. The
// top one has `scrollPush` on, and a recorder (./push-recorder.tsx) logs each
// contact for the report; the bottom one keeps today's touch behavior.
import { useCallback, useMemo, useRef, useState } from 'react';

import { CursorRipple, LiquidGradient, ShaderScene } from '@camp-dev/shaders';

import styles from './probe.module.css';
import { type PushContactRecord, type PushPhase, PushRecorder } from './push-recorder';
import { formatPushContact } from './report';

/** Most contacts the report keeps, oldest dropped first. */
const MAX_CONTACTS = 60;

// The renderer picks WebGPU when the page can reach it, which needs a secure
// context, and falls back to WebGL2 otherwise.
const describeDevice = () =>
[
navigator.userAgent,
`${window.innerWidth}x${window.innerHeight} CSS px`,
`DPR ${window.devicePixelRatio}`,
`${navigator.maxTouchPoints} touch points`,
window.isSecureContext ? 'secure context' : 'insecure context',
'gpu' in navigator ? 'WebGPU available' : 'no WebGPU, so WebGL2',
].join(' | ');

export default function ProbeScene() {
const [phase, setPhase] = useState<PushPhase>('idle');
const [lines, setLines] = useState<string[]>([]);
const [taps, setTaps] = useState(0);
// Set once the top scene has drawn, so a test knows its input is live.
const [ready, setReady] = useState(false);
const [device] = useState(describeDevice);
const reportRef = useRef<HTMLTextAreaElement>(null);

const handleContact = useCallback((record: PushContactRecord) => {
setLines((previous) => [...previous, formatPushContact(record)].slice(-MAX_CONTACTS));
}, []);

const report = useMemo(() => [`Device: ${device}`, '', ...lines].join('\n'), [device, lines]);

// The page may be served over plain HTTP on the local network, where the
// clipboard API does not exist, so selecting the text for the system copy
// menu is the one way that works everywhere.
const selectReport = () => {
reportRef.current?.focus();
reportRef.current?.select();
};

return (
<div className={styles.page}>
<h1>Touch probe</h1>
<p>Try these on the top scene, and watch the badge in its corner.</p>
<ol className={styles.steps}>
<li>
Scroll by dragging on the scene, slowly, then fast. The water should stir as you go.
</li>
<li>Flick and let go. The water keeps stirring until the page stops.</li>
<li>Tap, and rest a finger without scrolling. Nothing should stir.</li>
<li>Drag sideways, and diagonally. The water should follow the finger.</li>
<li>Pinch on the scene. The page should zoom, and nothing should stir.</li>
<li>
Tap the button over the scene, and swipe from it. It should tap and scroll as normal.
</li>
<li>
Touch the lower scene, which keeps today&apos;s behavior. Neither scene should react to a
finger on the other.
</li>
</ol>
<p>Then select the report and paste it into SHA-225.</p>
<div className={styles.spacer} />
<h2>Scroll push</h2>
<div className={styles.frame} data-ready={ready} data-scroll-push-scene="">
<ShaderScene onFirstPaint={() => setReady(true)} scrollPush>
<LiquidGradient />
<CursorRipple />
<PushRecorder onContact={handleContact} onPhaseChange={setPhase} />
</ShaderScene>
<span className={styles.badge} data-phase={phase}>
{phase}
</span>
<button
className={styles.overlayButton}
onClick={() => setTaps((count) => count + 1)}
type="button"
>
Taps: {taps}
</button>
</div>
<h2>Today&apos;s behavior</h2>
<div className={styles.frame}>
<ShaderScene>
<LiquidGradient />
<CursorRipple />
</ShaderScene>
</div>
<h2>Report</h2>
<div className={styles.buttons}>
<button onClick={selectReport} type="button">
Select report
</button>
<button onClick={() => setLines([])} type="button">
Clear contacts
</button>
</div>
<textarea
aria-label="Report"
className={styles.report}
readOnly
ref={reportRef}
value={report}
/>
<div className={styles.endSpacer} />
</div>
);
}
86 changes: 86 additions & 0 deletions apps/docs/src/app/dev/touch-probe/probe.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
.page {
max-width: 480px;
margin: 0 auto;
padding: 24px 16px;
color: var(--white);
font-size: var(--font-size-sm);
line-height: 1.5;
}

.steps {
padding-left: 20px;
}

.spacer {
height: 40vh;
}

.endSpacer {
height: 100vh;
}

/* The Reactive card's window: 312 by 309 CSS pixels. */
.frame {
position: relative;
width: 312px;
height: 309px;
margin: 8px 0 24px;
border-radius: var(--radius-xl);
overflow: hidden;
}

/* Read-only label over the scene. It takes no touches, so a finger on it
lands on the canvas. */
.badge {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: var(--black-a70);
font:
600 12px/20px ui-monospace,
monospace;
pointer-events: none;
}

/* White text on lime-300 reads at 3.2:1, under AA for 12px. Black reads at 4.9:1. */
.badge[data-phase='down'] {
background: var(--lime-300);
color: var(--black);
}

.badge[data-phase='fling'] {
background: var(--lime-700);
}

.badge[data-phase='pinch'] {
background: var(--gray-500);
}

/* A real control over the scene: a tap on it must stay a tap, and a swipe
starting on it must scroll. */
.overlayButton {
position: absolute;
right: 8px;
bottom: 8px;
padding: 6px 12px;
border: 0;
border-radius: var(--radius-lg);
background: var(--white);
color: var(--black);
font: inherit;
}

.buttons {
display: flex;
gap: 8px;
}

.report {
width: 100%;
height: 240px;
font:
11px/1.4 ui-monospace,
monospace;
}
Loading
Loading