From aa5a24d28930f446949240015133000b45ebb031 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 12 Oct 2026 21:47:14 -0400 Subject: [PATCH 1/9] feat(shaders): let a scene push its cursor effects when a touch scrolls it Hold-and-drag felt awkward on an iPhone, so SHA-225 settled on scroll push. ShaderScene gains scrollPush. 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 never cancels a touch, so scrolling and zooming are unchanged. CursorInput feeds a push tracker in src/inputs/scroll-push from passive touch listeners on the canvas, and works out one push stroke per tick, which every reader shares. While scrollPush is on, ShaderScene ticks the shared input once per frame, ahead of the readers. It wakes when a finger lands, keeps drawing while the touch pushes, and ends the push when the loop pauses. useCursor exposes the stroke as getPush, and CursorRipple stirs its water with it. The push is a stroke, not a cursor position. A pointer moved by the scroll would sit where the finger landed, or leave the canvas after one canvas height. --- .changeset/scene-scroll-push.md | 7 + .../cursor-ripple/cursor-ripple.test.tsx | 71 +++++- .../cursor-ripple/cursor-ripple.tsx | 7 +- packages/shaders/src/engine.ts | 1 + .../inputs/cursor-input/cursor-input.test.ts | 143 +++++++++++ .../src/inputs/cursor-input/cursor-input.ts | 159 +++++++++++- .../inputs/scroll-push/scroll-push.test.ts | 160 ++++++++++++ .../src/inputs/scroll-push/scroll-push.ts | 237 ++++++++++++++++++ .../shader-scene/shader-scene.test.tsx | 192 ++++++++++++++ .../components/shader-scene/shader-scene.tsx | 80 +++++- .../hooks/use-cursor/use-cursor.test.tsx | 91 +++++++ .../src/react/hooks/use-cursor/use-cursor.ts | 24 +- 12 files changed, 1151 insertions(+), 21 deletions(-) create mode 100644 .changeset/scene-scroll-push.md create mode 100644 packages/shaders/src/inputs/scroll-push/scroll-push.test.ts create mode 100644 packages/shaders/src/inputs/scroll-push/scroll-push.ts diff --git a/.changeset/scene-scroll-push.md b/.changeset/scene-scroll-push.md new file mode 100644 index 00000000..76f5f986 --- /dev/null +++ b/.changeset/scene-scroll-push.md @@ -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. + +`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. diff --git a/packages/shaders/src/components/cursor-ripple/cursor-ripple.test.tsx b/packages/shaders/src/components/cursor-ripple/cursor-ripple.test.tsx index b915e7a3..cd7be668 100644 --- a/packages/shaders/src/components/cursor-ripple/cursor-ripple.test.tsx +++ b/packages/shaders/src/components/cursor-ripple/cursor-ripple.test.tsx @@ -62,8 +62,10 @@ function makeRenderer({ floatTargets = true }: StubOptions = {}) { // A scene whose scheduler hands its clients back, so a test can tick the // Effect's drive by hand, and whose register functions count what the -// Effect puts on the output stage. -function makeScene(renderer: WebGPURenderer) { +// Effect puts on the output stage. With `scrollPush`, the shared input +// pushes and ticks once per frame ahead of every client, as ShaderScene's +// does. +function makeScene(renderer: WebGPURenderer, { scrollPush = false } = {}) { const clients: SchedulerClient[] = []; const uvTransforms: UvTransform[] = []; const overlays: PostProcessTransform[] = []; @@ -100,9 +102,14 @@ function makeScene(renderer: WebGPURenderer) { }; }, getCursorInput: () => { - sharedInput ??= new CursorInput({ element: renderer.domElement }); + if (sharedInput) return sharedInput; + const input = new CursorInput({ element: renderer.domElement }); - return sharedInput; + sharedInput = input; + input.setScrollPush(scrollPush); + if (scrollPush) clients.push(({ delta }) => input.tick(delta)); + + return input; }, } as unknown as ShaderContextValue; @@ -393,3 +400,59 @@ describe('CursorRipple with an inert field', () => { expect(scene.unregisters).toEqual({ uv: 1, overlay: 1 }); }); }); + +describe('CursorRipple and scroll push', () => { + // Puts one finger down on the canvas at its center. + const touchDown = (canvas: HTMLCanvasElement) => { + const touch = new Touch({ identifier: 1, target: canvas, clientX: 500, clientY: 500 }); + + act(() => { + canvas.dispatchEvent( + new TouchEvent('touchstart', { touches: [touch], changedTouches: [touch], bubbles: true }), + ); + }); + }; + + // Lets the test scroll the canvas, which the shared input reads each tick. + const makeScrollableRenderer = () => { + const renderer = makeRenderer(); + const canvas = renderer.domElement; + let top = 0; + + canvas.getBoundingClientRect = () => ({ left: 0, top, width: 1000, height: 1000 }) as DOMRect; + + return { + renderer, + canvas, + scrollTo: (next: number) => { + top = next; + }, + }; + }; + + it('stirs the water when a touch scrolls the page over it', () => { + const { renderer, canvas, scrollTo } = makeScrollableRenderer(); + const scene = makeScene(renderer, { scrollPush: true }); + + render(, { wrapper: scene.Wrapper }); + touchDown(canvas); + scene.tick(); + scrollTo(-30); + scene.tick(); + + expect(renderer.render).toHaveBeenCalled(); + }); + + it('leaves the water still under the same touch without scroll push', () => { + const { renderer, canvas, scrollTo } = makeScrollableRenderer(); + const scene = makeScene(renderer); + + render(, { wrapper: scene.Wrapper }); + touchDown(canvas); + scene.tick(); + scrollTo(-30); + scene.tick(); + + expect(renderer.render).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shaders/src/components/cursor-ripple/cursor-ripple.tsx b/packages/shaders/src/components/cursor-ripple/cursor-ripple.tsx index bf6ab2a2..14c84d77 100644 --- a/packages/shaders/src/components/cursor-ripple/cursor-ripple.tsx +++ b/packages/shaders/src/components/cursor-ripple/cursor-ripple.tsx @@ -182,7 +182,12 @@ export function CursorRipple({ const drive = ({ delta }: SchedulerTick) => { const current = cursor.get(); - const stroke = deriveStroke(previous, current, cursor.presence.get()); + // The pointer's stroke, or else this frame's scroll push: under the + // scene's `scrollPush`, a touch reaches the water only that way. The + // field takes one stroke a frame, so on the rare frame where a mouse + // and a touch both move, as on a touchscreen laptop, the mouse wins. + const stroke = + deriveStroke(previous, current, cursor.presence.get()) ?? cursor.getPush() ?? undefined; const wasAwake = !field.atRest; previous = current; diff --git a/packages/shaders/src/engine.ts b/packages/shaders/src/engine.ts index 146df9d0..771c2afd 100644 --- a/packages/shaders/src/engine.ts +++ b/packages/shaders/src/engine.ts @@ -14,6 +14,7 @@ export type { export { CursorInput } from './inputs/cursor-input/cursor-input.js'; export type { CursorInputOptions, Vector2 } from './inputs/cursor-input/cursor-input.js'; +export type { PushStroke } from './inputs/scroll-push/scroll-push.js'; export { colorRamp } from './primitives/color-ramp/color-ramp.js'; export type { ColorRampStop, TSLNode } from './primitives/color-ramp/color-ramp.js'; diff --git a/packages/shaders/src/inputs/cursor-input/cursor-input.test.ts b/packages/shaders/src/inputs/cursor-input/cursor-input.test.ts index 843e60a6..89ea6e23 100644 --- a/packages/shaders/src/inputs/cursor-input/cursor-input.test.ts +++ b/packages/shaders/src/inputs/cursor-input/cursor-input.test.ts @@ -257,4 +257,147 @@ describe('CursorInput', () => { expect(staying).toHaveBeenCalledTimes(2); cursor.dispose(); }); + + describe('scroll push', () => { + // A 300 by 300 canvas at (0, 100) whose rect the test moves, standing + // in for the page scrolling it. A 30 pixel scroll is a tenth of it. + const makeCanvas = () => { + const canvas = document.createElement('canvas'); + let rect = { left: 0, top: 100, width: 300, height: 300 }; + + canvas.getBoundingClientRect = () => ({ ...rect }) as DOMRect; + document.body.append(canvas); + + return { + canvas, + scrollTo: (top: number) => { + rect = { ...rect, top }; + }, + }; + }; + + interface Finger { + id: number; + x: number; + y: number; + } + + // Fires a touch event on the canvas: `touches` is every finger still + // down after it, `changed` the fingers it is about. + const fireTouch = ( + target: EventTarget, + type: 'touchstart' | 'touchmove' | 'touchend', + touches: Finger[], + changed: Finger[] = touches, + ) => { + const toTouch = ({ id, x, y }: Finger) => + new Touch({ identifier: id, target, clientX: x, clientY: y }); + + target.dispatchEvent( + new TouchEvent(type, { + touches: touches.map(toTouch), + changedTouches: changed.map(toTouch), + bubbles: true, + }), + ); + }; + + const near = (value: number) => expect.closeTo(value, 9); + + it('pushes at the finger once the page scrolls the canvas under it', () => { + const { canvas, scrollTo } = makeCanvas(); + const cursor = new CursorInput({ element: canvas }); + + cursor.setScrollPush(true); + fireTouch(canvas, 'touchstart', [{ id: 1, x: 100, y: 200 }]); + scrollTo(70); + cursor.tick(1 / 60); + + expect(cursor.getPush()).toEqual({ + from: [near(1 / 3), near(1 / 3 + 0.1)], + to: [near(1 / 3), near(1 / 3)], + presence: 1, + }); + cursor.dispose(); + }); + + it('stays off until a host turns it on', () => { + const { canvas, scrollTo } = makeCanvas(); + const cursor = new CursorInput({ element: canvas }); + + fireTouch(canvas, 'touchstart', [{ id: 1, x: 100, y: 200 }]); + scrollTo(70); + cursor.tick(1 / 60); + + expect(cursor.getPush()).toBeNull(); + expect(cursor.isPushing()).toBe(false); + cursor.dispose(); + }); + + it('leaves the cursor to the mouse, and touch to the push', () => { + const { canvas } = makeCanvas(); + const cursor = new CursorInput({ element: canvas }); + + cursor.setScrollPush(true); + window.dispatchEvent( + new PointerEvent('pointermove', { clientX: 150, clientY: 250, pointerType: 'touch' }), + ); + expect(cursor.getTarget()).toBeNull(); + window.dispatchEvent( + new PointerEvent('pointermove', { clientX: 150, clientY: 250, pointerType: 'mouse' }), + ); + expect(cursor.getTarget()).toEqual([0.5, 0.5]); + cursor.dispose(); + }); + + it('reports pushing from the touch until a fling settles', () => { + const { canvas, scrollTo } = makeCanvas(); + const cursor = new CursorInput({ element: canvas }); + const finger = { id: 1, x: 100, y: 200 }; + + cursor.setScrollPush(true); + fireTouch(canvas, 'touchstart', [finger]); + expect(cursor.isPushing()).toBe(true); + fireTouch(canvas, 'touchend', [], [finger]); + scrollTo(40); + cursor.tick(1 / 60); + expect(cursor.isPushing()).toBe(true); + expect(cursor.getPush()).not.toBeNull(); + for (let frame = 0; frame < 3; frame += 1) cursor.tick(1 / 60); + expect(cursor.isPushing()).toBe(false); + expect(cursor.getPush()).toBeNull(); + cursor.dispose(); + }); + + it('tells a push-start subscriber when a finger lands, so a parked host can wake', () => { + const { canvas } = makeCanvas(); + const cursor = new CursorInput({ element: canvas }); + const onPushStart = vi.fn(); + + cursor.setScrollPush(true); + cursor.onPushStart(onPushStart); + fireTouch(canvas, 'touchstart', [{ id: 1, x: 100, y: 200 }]); + expect(onPushStart).toHaveBeenCalledTimes(1); + cursor.dispose(); + }); + + it('stops listening when turned off or disposed', () => { + const { canvas, scrollTo } = makeCanvas(); + const cursor = new CursorInput({ element: canvas }); + const onPushStart = vi.fn(); + + cursor.onPushStart(onPushStart); + cursor.setScrollPush(true); + cursor.setScrollPush(false); + fireTouch(canvas, 'touchstart', [{ id: 1, x: 100, y: 200 }]); + scrollTo(70); + cursor.tick(1 / 60); + expect(cursor.getPush()).toBeNull(); + + cursor.setScrollPush(true); + cursor.dispose(); + fireTouch(canvas, 'touchstart', [{ id: 2, x: 100, y: 200 }]); + expect(onPushStart).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/shaders/src/inputs/cursor-input/cursor-input.ts b/packages/shaders/src/inputs/cursor-input/cursor-input.ts index 0b419299..a1278c62 100644 --- a/packages/shaders/src/inputs/cursor-input/cursor-input.ts +++ b/packages/shaders/src/inputs/cursor-input/cursor-input.ts @@ -1,8 +1,15 @@ -// The pointer as a framework-free Input. It listens on the window for -// pointermove, normalizes each move to an element's rect, and records the -// raw target and whether the pointer is inside. It also carries its own -// smoothed position, so it stands alone as an animation signal outside -// React, and a ShaderScene owns one that every useCursor call reads raw. +// The pointer as a framework-free Input: it normalizes window pointer moves +// to an element's rect and keeps the raw target, presence, and its own +// smoothed position. A ShaderScene owns one that every useCursor call reads. +// With scroll push on, it also turns a touch that scrolls the page over the +// element into one push stroke per tick, which every reader shares. +import { + type CanvasRect, + createPushTracker, + type PushStroke, + type PushTracker, + type TouchPoint, +} from '../scroll-push/scroll-push.js'; import { Smoother } from './smoother.js'; // ---------------------------------------------------------------------------- @@ -51,6 +58,46 @@ export interface CursorInputOptions { */ export const POSITION_SETTLE_THRESHOLD = 1e-4; +// ---------------------------------------------------------------------------- +// Touch events into scroll push +// ---------------------------------------------------------------------------- + +// Desktop Safari and Firefox define no TouchEvent global, so the handlers +// read the lists off the event instead of checking its class. +interface TouchListLike { + readonly length: number; + [index: number]: { identifier: number; clientX: number; clientY: number }; +} + +interface TouchEventLike extends Event { + touches: TouchListLike; + changedTouches: TouchListLike; +} + +const isTouchEvent = (event: Event): event is TouchEventLike => 'touches' in event; + +const isEventTarget = (value: unknown): value is EventTarget => + typeof value === 'object' && + value !== null && + 'addEventListener' in value && + typeof value.addEventListener === 'function'; + +const toPoints = (list: TouchListLike): TouchPoint[] => + Array.from({ length: list.length }, (_, index) => { + const touch = list[index]; + + return { id: touch?.identifier ?? -1, x: touch?.clientX ?? 0, y: touch?.clientY ?? 0 }; + }); + +const toSnapshot = (event: TouchEventLike) => ({ + touches: toPoints(event.touches), + changed: toPoints(event.changedTouches), +}); + +// Passive, so the page scrolls and zooms without waiting on the scene. +// Scroll push never cancels a touch. +const PASSIVE = { passive: true } as const; + // ---------------------------------------------------------------------------- // The input: listen, normalize, smooth, dispose // ---------------------------------------------------------------------------- @@ -71,6 +118,20 @@ export class CursorInput { private readonly element: CursorInputOptions['element']; private readonly handlePointerMove: (e: Event) => void; private disposed = false; + // Scroll push: the tracker exists while it is on, and the stroke is the + // one the last tick worked out. + private pushTracker: PushTracker | null = null; + private framePush: PushStroke | null = null; + private readonly pushStartListeners = new Set<() => void>(); + private readonly handleTouchStart = (event: Event) => { + if (isTouchEvent(event)) this.pushTracker?.touchStart(toSnapshot(event), this.readRect()); + }; + private readonly handleTouchMove = (event: Event) => { + if (isTouchEvent(event)) this.pushTracker?.touchMove(toSnapshot(event), this.readRect()); + }; + private readonly handleTouchEnd = (event: Event) => { + if (isTouchEvent(event)) this.pushTracker?.touchEnd(toSnapshot(event)); + }; constructor(opts: CursorInputOptions = {}) { const { smoothing = 0.1, initial = [0.5, 0.5], target, element } = opts; @@ -86,6 +147,8 @@ export class CursorInput { if (!(e instanceof MouseEvent)) return; const pointerEvent = e; + if (this.isPushedTouch(pointerEvent)) return; + if (this.element) { // Normalize to 0..1 across the element's bounding rect. Reading the // rect on every move is fine — `getBoundingClientRect` is cheap and @@ -152,6 +215,72 @@ export class CursorInput { return this.inside; } + /** + * Turns scroll push on or off. While on, a finger that lands on the + * element and scrolls the page pushes once per tick at the finger, a + * fling keeps pushing until the element stops, and `getPush` reads the + * stroke. Touch pointer moves stop moving the target. Needs an element + * that takes event listeners, such as the scene's canvas, and does + * nothing without one. + */ + setScrollPush(enabled: boolean): void { + const element = this.element; + + if (this.disposed || enabled === (this.pushTracker !== null)) return; + // Checked by shape rather than class, so an element from another + // realm, such as an iframe, still counts. + if (!isEventTarget(element)) return; + if (!enabled) { + element.removeEventListener('touchstart', this.handleTouchStart); + element.removeEventListener('touchmove', this.handleTouchMove); + element.removeEventListener('touchend', this.handleTouchEnd); + element.removeEventListener('touchcancel', this.handleTouchEnd); + this.pushTracker = null; + this.framePush = null; + + return; + } + this.pushTracker = createPushTracker(() => { + for (const listener of this.pushStartListeners) listener(); + }); + element.addEventListener('touchstart', this.handleTouchStart, PASSIVE); + element.addEventListener('touchmove', this.handleTouchMove, PASSIVE); + element.addEventListener('touchend', this.handleTouchEnd, PASSIVE); + element.addEventListener('touchcancel', this.handleTouchEnd, PASSIVE); + } + + /** + * The push stroke from the last tick, or null when nothing pushed. Every + * reader in a frame gets the same stroke, because only `tick` changes it. + */ + getPush(): PushStroke | null { + return this.framePush; + } + + /** + * Whether a touch is pushing or may push on the next tick: a finger down + * on the element, or a fling still running. A host that draws on demand + * keeps drawing while this is true, so the push keeps arriving. + */ + isPushing(): boolean { + const phase = this.pushTracker?.getPhase(); + + return phase === 'down' || phase === 'fling'; + } + + /** + * Subscribe to the start of each scroll-push contact, when a lone finger + * lands on the element. A parked host uses it to start drawing again, as + * it uses `onMove` for the pointer. Returns an unsubscribe function. + */ + onPushStart(listener: () => void): () => void { + this.pushStartListeners.add(listener); + + return () => { + this.pushStartListeners.delete(listener); + }; + } + /** Subscribe to change events. Returns an unsubscribe function. */ on(_eventType: 'change', changeListener: (value: Vector2) => void): () => void { this.listeners.add(changeListener); @@ -184,6 +313,9 @@ export class CursorInput { */ tick(delta: number, afterIdle = false): boolean { if (this.disposed) return false; + // One push per tick, read from where the element sits now, so a host + // that ticks once per frame gives every reader the same stroke. + this.framePush = this.pushTracker?.frame(this.readRect()) ?? null; const moved = this.position.tick(delta, afterIdle); if (!moved && !this.targetDirty) return false; @@ -199,9 +331,26 @@ export class CursorInput { /** Tear down listeners. */ dispose(): void { if (this.disposed) return; + this.setScrollPush(false); + this.pushStartListeners.clear(); this.disposed = true; this.eventTarget.removeEventListener('pointermove', this.handlePointerMove); this.listeners.clear(); this.moveListeners.clear(); } + + // Under scroll push, touch reaches cursor effects only as push strokes. A + // finger's own pointer moves would also drag the cursor until the browser + // took the scroll. + private isPushedTouch(event: MouseEvent): boolean { + return this.pushTracker !== null && 'pointerType' in event && event.pointerType === 'touch'; + } + + // The element's rect, with a zero size read as 1 so a collapsed element + // never divides by zero. Without an element there is nothing to push on. + private readRect(): CanvasRect { + const rect = this.element?.getBoundingClientRect() ?? { left: 0, top: 0, width: 1, height: 1 }; + + return { left: rect.left, top: rect.top, width: rect.width || 1, height: rect.height || 1 }; + } } diff --git a/packages/shaders/src/inputs/scroll-push/scroll-push.test.ts b/packages/shaders/src/inputs/scroll-push/scroll-push.test.ts new file mode 100644 index 00000000..b249945a --- /dev/null +++ b/packages/shaders/src/inputs/scroll-push/scroll-push.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + type CanvasRect, + createPushTracker, + FLING_SETTLE_FRAMES, + RESTING_WOBBLE_PX, + type TouchPoint, +} from './scroll-push.js'; + +// A finger with a fixed id at a client point, in CSS pixels. +const finger = (x: number, y: number, id = 1): TouchPoint => ({ id, x, y }); + +// The canvas at a client position, 300 CSS pixels square, so a 30 pixel +// move is a tenth of the canvas. +const canvasAt = (left: number, top: number): CanvasRect => ({ + left, + top, + width: 300, + height: 300, +}); + +// A stroke in canvas units, compared to nine decimal places, since the +// expected values are written as fractions of the canvas. +const stroke = (from: [number, number], to: [number, number], presence = 1) => ({ + from: from.map((value) => expect.closeTo(value, 9)), + to: to.map((value) => expect.closeTo(value, 9)), + presence, +}); + +const setUp = () => createPushTracker(); + +describe('createPushTracker', () => { + it('strokes the water under the finger, along the way the page carried it', () => { + const push = setUp(); + const touch = finger(100, 200); + + push.touchStart({ touches: [touch], changed: [touch] }, canvasAt(0, 100)); + expect(push.getPhase()).toBe('down'); + // The page scrolls up 30 pixels, and the finger and the canvas move together. + expect(push.frame(canvasAt(0, 70))).toEqual(stroke([1 / 3, 1 / 3 + 0.1], [1 / 3, 1 / 3])); + }); + + it('strokes nothing while the finger rests and the page stays put', () => { + const push = setUp(); + const touch = finger(100, 200); + + push.touchStart({ touches: [touch], changed: [touch] }, canvasAt(0, 100)); + expect(push.frame(canvasAt(0, 100))).toBeUndefined(); + expect(push.frame(canvasAt(0, 100))).toBeUndefined(); + }); + + it('strokes along a drag across a canvas that does not scroll', () => { + const push = setUp(); + const touch = finger(100, 200); + const dragged = finger(130, 200); + + push.touchStart({ touches: [touch], changed: [touch] }, canvasAt(0, 100)); + push.touchMove({ touches: [dragged], changed: [dragged] }, canvasAt(0, 100)); + expect(push.frame(canvasAt(0, 100))).toEqual(stroke([1 / 3, 1 / 3], [1 / 3 + 0.1, 1 / 3])); + }); + + it('keeps pushing at the last brush point while a fling carries the canvas', () => { + const push = setUp(); + const touch = finger(100, 200); + + push.touchStart({ touches: [touch], changed: [touch] }, canvasAt(0, 100)); + push.touchEnd({ touches: [], changed: [touch] }); + expect(push.getPhase()).toBe('fling'); + expect(push.frame(canvasAt(0, 70))).toEqual(stroke([1 / 3, 1 / 3 + 0.1], [1 / 3, 1 / 3])); + }); + + it('stops a fling once the canvas has held still for the settle frames', () => { + const push = setUp(); + const touch = finger(100, 200); + + push.touchStart({ touches: [touch], changed: [touch] }, canvasAt(0, 100)); + push.touchEnd({ touches: [], changed: [touch] }); + for (let frame = 1; frame < FLING_SETTLE_FRAMES; frame += 1) push.frame(canvasAt(0, 100)); + expect(push.getPhase()).toBe('fling'); + push.frame(canvasAt(0, 100)); + expect(push.getPhase()).toBe('idle'); + expect(push.frame(canvasAt(0, 70))).toBeUndefined(); + }); + + it('pushes nothing while two fingers are down, until every finger lifts', () => { + const push = setUp(); + const first = finger(100, 200, 1); + const second = finger(200, 250, 2); + const next = finger(100, 200, 3); + + push.touchStart({ touches: [first], changed: [first] }, canvasAt(0, 100)); + push.touchStart({ touches: [first, second], changed: [second] }, canvasAt(0, 100)); + expect(push.getPhase()).toBe('pinch'); + expect(push.frame(canvasAt(0, 70))).toBeUndefined(); + push.touchEnd({ touches: [second], changed: [first] }); + expect(push.getPhase()).toBe('pinch'); + expect(push.frame(canvasAt(0, 40))).toBeUndefined(); + push.touchEnd({ touches: [], changed: [second] }); + expect(push.getPhase()).toBe('idle'); + + push.touchStart({ touches: [next], changed: [next] }, canvasAt(0, 100)); + expect(push.frame(canvasAt(0, 70))).toEqual(stroke([1 / 3, 1 / 3 + 0.1], [1 / 3, 1 / 3])); + }); + + it('starts fresh when a finger lands during a fling, with no stroke joining the two', () => { + const push = setUp(); + const first = finger(100, 200, 1); + const second = finger(250, 150, 2); + + push.touchStart({ touches: [first], changed: [first] }, canvasAt(0, 100)); + push.touchEnd({ touches: [], changed: [first] }); + push.frame(canvasAt(0, 70)); + push.touchStart({ touches: [second], changed: [second] }, canvasAt(0, 70)); + expect(push.getPhase()).toBe('down'); + expect(push.frame(canvasAt(0, 70))).toBeUndefined(); + }); + + it('treats a second finger off the canvas as a pinch, seen on the next move', () => { + const push = setUp(); + const touch = finger(100, 200, 1); + const offCanvas = finger(380, 700, 2); + + push.touchStart({ touches: [touch], changed: [touch] }, canvasAt(0, 100)); + push.touchMove({ touches: [touch, offCanvas], changed: [touch] }, canvasAt(0, 100)); + expect(push.getPhase()).toBe('pinch'); + expect(push.frame(canvasAt(0, 70))).toBeUndefined(); + }); + + it("ignores a resting finger's wobble", () => { + const push = setUp(); + const touch = finger(100, 200); + const wobble = finger(100 + RESTING_WOBBLE_PX - 1, 200 + 1); + + push.touchStart({ touches: [touch], changed: [touch] }, canvasAt(0, 100)); + push.touchMove({ touches: [wobble], changed: [wobble] }, canvasAt(0, 100)); + expect(push.frame(canvasAt(0, 100))).toBeUndefined(); + }); + + it('says when a lone finger lands, and not when a pinch starts', () => { + const onContactStart = vi.fn(); + const push = createPushTracker(onContactStart); + const first = finger(100, 200, 1); + const second = finger(200, 250, 2); + + push.touchStart({ touches: [first], changed: [first] }, canvasAt(0, 100)); + push.touchStart({ touches: [first, second], changed: [second] }, canvasAt(0, 100)); + expect(onContactStart).toHaveBeenCalledTimes(1); + }); + + it('gives a stroke no presence once the brush is off the canvas', () => { + const push = setUp(); + const touch = finger(100, 200); + const offCanvas = finger(330, 200); + + push.touchStart({ touches: [touch], changed: [touch] }, canvasAt(0, 100)); + push.touchMove({ touches: [offCanvas], changed: [offCanvas] }, canvasAt(0, 100)); + expect(push.frame(canvasAt(0, 100))).toEqual(stroke([1 / 3, 1 / 3], [1.1, 1 / 3], 0)); + }); +}); diff --git a/packages/shaders/src/inputs/scroll-push/scroll-push.ts b/packages/shaders/src/inputs/scroll-push/scroll-push.ts new file mode 100644 index 00000000..5d4243df --- /dev/null +++ b/packages/shaders/src/inputs/scroll-push/scroll-push.ts @@ -0,0 +1,237 @@ +// Scroll push (SHA-225) as a state machine with no DOM in it: a touch that +// scrolls the page pushes at the finger, once per frame, on through the +// fling. CursorInput feeds it touches from passive listeners, so it never +// decides anything about scrolling. Only the PushStroke type is on the +// engine barrel; the tracker itself is internal. + +// ---------------------------------------------------------------------------- +// Tuning +// ---------------------------------------------------------------------------- + +/** + * Frames the canvas must hold still before a fling counts as over. A fling + * decays smoothly, so a still frame or two can be a frame the browser + * skipped rather than the end. Raise it and the push lingers after the page + * stops; lower it and a slow fling's tail may end early. + */ +export const FLING_SETTLE_FRAMES = 3; + +/** + * How far a finger may wander from where it landed before the brush starts + * following it, in CSS pixels. A resting finger wobbles a pixel or two, and + * without this the wobble would push. Raise it and a slow sideways drag + * starts later; lower it and a resting finger stirs the effect. + */ +export const RESTING_WOBBLE_PX = 4; + +// ---------------------------------------------------------------------------- +// Snapshots in, strokes out +// ---------------------------------------------------------------------------- + +/** One finger: the browser's touch identifier and its client position, in CSS pixels. */ +export interface TouchPoint { + id: number; + x: number; + y: number; +} + +export interface TouchSnapshot { + /** Every finger on the screen after this event, wherever it landed. */ + touches: readonly TouchPoint[]; + /** The fingers this event is about. */ + changed: readonly TouchPoint[]; +} + +/** The canvas's client rect, in CSS pixels. */ +export interface CanvasRect { + left: number; + top: number; + width: number; + height: number; +} + +/** + * One frame's push: the segment the brush swept, in 0..1 canvas units with + * [0, 0] at the top-left and the segment ending at the brush, plus the + * presence that gates it. The shape the wave field's stroke already has. + */ +export interface PushStroke { + from: readonly [number, number]; + to: readonly [number, number]; + /** 1 while the brush is on the canvas, 0 once it is off it. */ + presence: number; +} + +/** + * - `idle`: no contact. + * - `down`: one finger down on the canvas. + * - `fling`: the finger lifted and the page may still be moving. + * - `pinch`: a second finger came down, so nothing pushes until every + * finger lifts. + */ +export type PushPhase = 'idle' | 'down' | 'fling' | 'pinch'; + +export interface PushTracker { + getPhase: () => PushPhase; + touchStart: (snapshot: TouchSnapshot, rect: CanvasRect) => void; + touchMove: (snapshot: TouchSnapshot, rect: CanvasRect) => void; + /** For both touchend and touchcancel. */ + touchEnd: (snapshot: TouchSnapshot) => void; + /** Once per rendered frame: this frame's stroke, or nothing to push. */ + frame: (rect: CanvasRect) => PushStroke | undefined; +} + +interface Point { + x: number; + y: number; +} + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +const toCanvas = (touch: TouchPoint, rect: CanvasRect): Point => ({ + x: touch.x - rect.left, + y: touch.y - rect.top, +}); + +/** + * The stroke for one frame's push: it ends at the brush and starts one + * push behind it, in canvas units, so the effect moves the way the finger + * did. A brush off the canvas pushes with no presence. + */ +function toStroke(brush: Point, push: Point, rect: CanvasRect): PushStroke { + const to = [brush.x / rect.width, brush.y / rect.height] as const; + const from = [to[0] - push.x / rect.width, to[1] - push.y / rect.height] as const; + const onCanvas = to[0] >= 0 && to[0] < 1 && to[1] >= 0 && to[1] < 1; + + return { from, to, presence: onCanvas ? 1 : 0 }; +} + +// ---------------------------------------------------------------------------- +// The tracker +// ---------------------------------------------------------------------------- + +/** + * `onContactStart` runs when a lone finger lands, so a host that draws on + * demand can start drawing again. + */ +export function createPushTracker(onContactStart?: () => void): PushTracker { + let phase: PushPhase = 'idle'; + let fingerId: number | null = null; + // The brush, in CSS pixels from the canvas's top-left corner, and where + // the brush and the canvas were on the last frame. + let brush: Point | null = null; + let previousBrush: Point | null = null; + let previousRect: CanvasRect | null = null; + let stillFrames = 0; + // Where the finger landed, in client pixels, until it wanders far enough + // to count as moving. Null once the brush follows it. + let restingAt: TouchPoint | null = null; + + const reset = () => { + phase = 'idle'; + fingerId = null; + brush = null; + previousBrush = null; + previousRect = null; + restingAt = null; + }; + + // A second finger, anywhere on the screen, makes this a pinch or some + // other gesture the browser owns. A finger that lands off the canvas + // sends the canvas no touchstart, so moves check for it too. + const startPinch = () => { + fingerId = null; + brush = null; + phase = 'pinch'; + }; + + // Whether the finger has wandered far enough from where it landed to move + // the brush. Once it has, it stays moving for the rest of the contact. + const isMoving = (touch: TouchPoint): boolean => { + if (restingAt && Math.hypot(touch.x - restingAt.x, touch.y - restingAt.y) <= RESTING_WOBBLE_PX) + return false; + restingAt = null; + + return true; + }; + + const findTracked = (points: readonly TouchPoint[]) => + points.find((point) => point.id === fingerId); + + // Counts the frames the canvas has held still during a fling, and ends + // the contact once there are enough. Returns whether the fling is over. + const flingSettled = (canvasMoved: boolean): boolean => { + stillFrames = canvasMoved ? 0 : stillFrames + 1; + if (stillFrames < FLING_SETTLE_FRAMES) return false; + reset(); + + return true; + }; + + return { + getPhase: () => phase, + + touchStart(snapshot, rect) { + if (snapshot.touches.length > 1) { + startPinch(); + + return; + } + const [touch] = snapshot.changed; + + if (!touch) return; + // A lone finger ends whatever came before, including a fling: the + // touch stops the page, and its brush must not join the old one. The + // rect is read as the event arrives, so the brush lands where the + // finger is on the canvas even mid-scroll. + fingerId = touch.id; + restingAt = touch; + brush = toCanvas(touch, rect); + previousBrush = brush; + previousRect = rect; + stillFrames = 0; + phase = 'down'; + onContactStart?.(); + }, + + touchMove(snapshot, rect) { + if (phase === 'down' && snapshot.touches.length > 1) startPinch(); + const touch = phase === 'down' ? findTracked(snapshot.changed) : undefined; + + if (touch && isMoving(touch)) brush = toCanvas(touch, rect); + }, + + touchEnd(snapshot) { + if (phase === 'pinch' && snapshot.touches.length === 0) reset(); + if (phase === 'down' && findTracked(snapshot.changed)) { + stillFrames = 0; + phase = 'fling'; + } + }, + + frame(rect) { + if (!brush || !previousBrush || !previousRect) return undefined; + + // How far the finger moved on screen since the last frame: the + // canvas's movement plus the brush's movement across the canvas. + // During a plain scroll the finger is pinned to the canvas, so it is + // all canvas. During a fling it is all canvas, because the brush + // rests where the finger lifted. + const canvas = { x: rect.left - previousRect.left, y: rect.top - previousRect.top }; + const push = { + x: canvas.x + brush.x - previousBrush.x, + y: canvas.y + brush.y - previousBrush.y, + }; + const pushingBrush = brush; + + previousRect = rect; + previousBrush = brush; + if (phase === 'fling' && flingSettled(canvas.x !== 0 || canvas.y !== 0)) return undefined; + if (push.x === 0 && push.y === 0) return undefined; + + return toStroke(pushingBrush, push, rect); + }, + }; +} diff --git a/packages/shaders/src/react/components/shader-scene/shader-scene.test.tsx b/packages/shaders/src/react/components/shader-scene/shader-scene.test.tsx index 11bc866f..da4db1fd 100644 --- a/packages/shaders/src/react/components/shader-scene/shader-scene.test.tsx +++ b/packages/shaders/src/react/components/shader-scene/shader-scene.test.tsx @@ -10,6 +10,7 @@ import { createRenderer, type CursorInput, type GpuRenderer } from '../../../eng import { ShadersError } from '../../errors/shaders-error.js'; import { useResize } from '../../hooks/use-resize/use-resize.js'; import { useShaderContext } from '../../hooks/use-shader-context/use-shader-context.js'; +import { useStaticSceneHint } from '../../hooks/use-static-hint/use-static-hint.js'; import { PosterContext } from '../shader-poster/poster-context.js'; import { ShaderScene } from './shader-scene.js'; @@ -757,4 +758,195 @@ describe('ShaderScene', () => { expect(gpuRenderer.three.render).not.toHaveBeenCalled(); }); }); + + describe('scrollPush', () => { + // Fires a one-finger touch event at the canvas center. + const fireTouch = (canvas: HTMLCanvasElement, type: 'touchstart' | 'touchend') => { + const touch = new Touch({ identifier: 1, target: canvas, clientX: 500, clientY: 500 }); + const down = type === 'touchstart' ? [touch] : []; + + canvas.dispatchEvent( + new TouchEvent(type, { touches: down, changedTouches: [touch], bubbles: true }), + ); + }; + + // A child that reads the shared cursor input into `inputs`. With + // `votesIdle`, it also votes the scene static, so the loop can park. + function makeReader(inputs: CursorInput[], votesIdle = false) { + return function Reader() { + const shaderContext = useShaderContext(); + + useStaticSceneHint(votesIdle); + if (shaderContext) inputs.push(shaderContext.getCursorInput()); + + return null; + }; + } + + // Makes a canvas report a rect the test can scroll, starting at `top`. + function scrollable(canvas: HTMLCanvasElement, top = 0) { + let offset = 0; + + canvas.getBoundingClientRect = () => + ({ left: 0, top: top + offset, width: 1000, height: 1000 }) as DOMRect; + + return (next: number) => { + offset = next; + }; + } + + // Renders one scene with a reader, and hands back its canvas, a way to + // scroll it, and a frame runner on the captured animation frames. + async function renderScene({ scrollPush, votesIdle = false }: SceneOptions = {}) { + const frames = captureFrames(); + const inputs: CursorInput[] = []; + const Reader = makeReader(inputs, votesIdle); + const view = render( + + + , + ); + + await waitFor(() => expect(inputs.length).toBeGreaterThan(0)); + const canvas = view.container.querySelector('canvas')!; + const scrollTo = scrollable(canvas); + let now = 0; + const runFrame = () => { + now += 16; + runFrames(frames, now); + }; + const runUntilParked = () => { + for (let frame = 0; frame < 50 && frames.length > 0; frame += 1) runFrame(); + }; + + // A touch, a still frame, then a 30 pixel scroll and another frame. + const touchAndScroll = () => { + fireTouch(canvas, 'touchstart'); + runFrame(); + scrollTo(-30); + runFrame(); + }; + + return { + ...view, + Reader, + canvas, + frames, + input: () => inputs.at(-1)!, + runFrame, + runUntilParked, + scrollTo, + touchAndScroll, + }; + } + + interface SceneOptions { + scrollPush?: boolean; + votesIdle?: boolean; + } + + it('pushes when a touch scrolls the page over the scene', async () => { + const scene = await renderScene({ scrollPush: true }); + + scene.touchAndScroll(); + + expect(scene.input().getPush()).not.toBeNull(); + }); + + it('stays off by default, and turns on without rebuilding the renderer', async () => { + vi.mocked(createRenderer).mockClear(); + const scene = await renderScene(); + + scene.touchAndScroll(); + expect(scene.input().getPush()).toBeNull(); + + scene.rerender( + + + , + ); + scene.scrollTo(0); + scene.touchAndScroll(); + + expect(scene.input().getPush()).not.toBeNull(); + expect(createRenderer).toHaveBeenCalledTimes(1); + }); + + it('wakes a parked scene when a finger lands, and parks once the fling settles', async () => { + const scene = await renderScene({ scrollPush: true, votesIdle: true }); + + scene.runUntilParked(); + expect(scene.frames).toHaveLength(0); + + fireTouch(scene.canvas, 'touchstart'); + expect(scene.frames.length).toBeGreaterThan(0); + + // The finger lifts mid-scroll, and the fling carries the canvas on + // for a few frames, then stops. + fireTouch(scene.canvas, 'touchend'); + for (const top of [-20, -35, -45]) { + scene.scrollTo(top); + scene.runFrame(); + expect(scene.frames.length).toBeGreaterThan(0); + } + scene.runUntilParked(); + + expect(scene.input().isPushing()).toBe(false); + expect(scene.frames).toHaveLength(0); + }); + + it('never pushes one scene from a touch on another', async () => { + const frames = captureFrames(); + const firstInputs: CursorInput[] = []; + const secondInputs: CursorInput[] = []; + const FirstReader = makeReader(firstInputs); + const SecondReader = makeReader(secondInputs); + const { container } = render( + <> + + + + + + + , + ); + + await waitFor(() => expect(secondInputs.length).toBeGreaterThan(0)); + const [firstCanvas, secondCanvas] = [...container.querySelectorAll('canvas')]; + const scrollFirst = scrollable(firstCanvas!, 0); + const scrollSecond = scrollable(secondCanvas!, 1000); + + fireTouch(firstCanvas!, 'touchstart'); + runFrames(frames, 16); + scrollFirst(-30); + scrollSecond(-30); + runFrames(frames, 32); + + expect(firstInputs.at(-1)!.getPush()).not.toBeNull(); + expect(secondInputs.at(-1)!.getPush()).toBeNull(); + }); + + it('ends the push when the scene pauses, so the frame after the pause pushes nothing', async () => { + const scene = await renderScene({ scrollPush: true }); + + fireTouch(scene.canvas, 'touchstart'); + scene.runFrame(); + scene.rerender( + + + , + ); + // The page scrolls a long way while the loop is paused. + scene.scrollTo(-400); + scene.rerender( + + + , + ); + scene.runFrame(); + + expect(scene.input().getPush()).toBeNull(); + }); + }); }); diff --git a/packages/shaders/src/react/components/shader-scene/shader-scene.tsx b/packages/shaders/src/react/components/shader-scene/shader-scene.tsx index 90736cac..7b6edf35 100644 --- a/packages/shaders/src/react/components/shader-scene/shader-scene.tsx +++ b/packages/shaders/src/react/components/shader-scene/shader-scene.tsx @@ -32,6 +32,7 @@ import { holdRendererClock, type OutputStage, resetRendererClock, + type SchedulerTick, } from '../../../engine.js'; import { createCanvasSize, type ResizeSignal } from '../../../inputs/canvas-size/canvas-size.js'; import { ShaderContext, type ShaderContextValue } from '../../context/shader-context.js'; @@ -72,6 +73,14 @@ export interface ShaderSceneProps { * its first frame draws nothing until it resumes. Defaults to false. */ paused?: boolean; + /** + * Lets a touch that scrolls the page over the canvas push the scene's + * cursor effects, at the finger and on through the fling. The scene only + * listens: the page scrolls and zooms exactly as it would without it. A + * touch then reaches cursor effects only this way, and a mouse works as + * before. A change applies straight away. Defaults to false. + */ + scrollPush?: boolean; } const defaultStyle: CSSProperties = { @@ -92,6 +101,7 @@ export function ShaderScene({ onFirstPaint, onError, paused = false, + scrollPush = false, }: ShaderSceneProps) { const resolvedGamut = useDisplayGamut(gamut); const canvasRef = useRef(null); @@ -104,6 +114,7 @@ export function ShaderScene({ const errorFiredRef = useRef(false); const pausedProp = useLiveProp(paused, applyPaused); const maxFPSProp = useLiveProp(maxFPS, applyMaxFPS); + const scrollPushProp = useLiveProp(scrollPush, applyScrollPush); // Poster boundary controls, when a ShaderPoster wraps this scene. The value // is memoized stable by ShaderPoster, so listing it in the setup effect's // deps does not cause renderer rebuilds. Null (a no-op below) when the @@ -178,11 +189,13 @@ export function ShaderScene({ ); const detachPausedProp = pausedProp.attach(canvasWatch); - const cursorInput = createLazyCursorInput(canvas); + const cursorInput = createLazyCursorInput(canvas, scheduler); + const detachScrollPushProp = scrollPushProp.attach(cursorInput); cleanup = () => { detachMaxFPSProp(); detachPausedProp(); + detachScrollPushProp(); pausedClock.stop(); canvasWatch.stop(); cursorInput.dispose(); @@ -226,7 +239,7 @@ export function ShaderScene({ // so re-arm the enclosing poster until it does. posterControls?.setShaderPainted(false); }; - }, [maxDPR, resolvedGamut, posterControls, pausedProp, maxFPSProp]); + }, [maxDPR, resolvedGamut, posterControls, pausedProp, maxFPSProp, scrollPushProp]); // Mount the children as soon as the context exists so the shader can build // and paint. The children render no visible DOM of their own (they drive @@ -250,7 +263,8 @@ export function ShaderScene({ } // ---------------------------------------------------------------------------- -// Props the running scene takes without a rebuild: `paused` and `maxFPS` +// Props the running scene takes without a rebuild: `paused`, `maxFPS`, and +// `scrollPush` // ---------------------------------------------------------------------------- interface LiveProp { @@ -297,6 +311,9 @@ const applyPaused = (canvasWatch: CanvasWatch, paused: boolean) => canvasWatch.s const applyMaxFPS = (scheduler: FrameScheduler, maxFPS: number | undefined) => scheduler.setMaxFPS(maxFPS); +const applyScrollPush = (cursorInput: LazyCursorInput, scrollPush: boolean) => + cursorInput.setScrollPush(scrollPush); + // ---------------------------------------------------------------------------- // The view: one scene and a camera that frames it edge to edge // ---------------------------------------------------------------------------- @@ -513,25 +530,70 @@ function watchCanvas( // The pointer: one cursor Input per scene, built on first ask // ---------------------------------------------------------------------------- +interface LazyCursorInput { + get: () => CursorInput; + /** Applies the `scrollPush` prop now, or when the input is created. */ + setScrollPush: (enabled: boolean) => void; + dispose: () => void; +} + /** * The scene's one pointer Input, normalized to this canvas, so every useCursor * call inside reads the same pointer. `get` creates it on the first ask, so a * scene with no cursor consumer never attaches a window listener. `dispose` * releases it if it was ever created. + * + * Under `scrollPush`, the scene also drives the Input's push. It ticks the + * Input once per frame, which works out the frame's push a single time for + * every reader, and it draws on demand around the push: it wakes when a + * finger lands, keeps drawing while the touch pushes, and ends the push when + * the loop pauses. */ -function createLazyCursorInput(canvas: HTMLCanvasElement): { - get: () => CursorInput; - dispose: () => void; -} { +function createLazyCursorInput( + canvas: HTMLCanvasElement, + scheduler: FrameScheduler, +): LazyCursorInput { let cursorInput: CursorInput | null = null; + let scrollPush = false; + + // Added when the Input is created, inside the first reader's setup and + // before that reader adds its own client, so the Input always ticks ahead + // of the code that reads the push. Without scroll push it does nothing. + const tick = ({ delta }: SchedulerTick) => { + if (!scrollPush || !cursorInput) return; + cursorInput.tick(delta); + if (cursorInput.isPushing()) scheduler.requestRender(); + }; + + // A paused loop stops reading the canvas while the page may keep moving + // it, so the first frame after the pause would measure one push across + // the whole gap. Ending the push on a pause makes the next touch start a + // fresh stroke instead. + const stopWatchingPause = scheduler.onPauseChange((paused) => { + if (!paused || !scrollPush || !cursorInput) return; + cursorInput.setScrollPush(false); + cursorInput.setScrollPush(true); + }); return { get: () => { - cursorInput ??= new CursorInput({ element: canvas }); + if (cursorInput) return cursorInput; + cursorInput = new CursorInput({ element: canvas }); + cursorInput.setScrollPush(scrollPush); + cursorInput.onPushStart(() => scheduler.requestRender()); + scheduler.add(tick); return cursorInput; }, - dispose: () => cursorInput?.dispose(), + setScrollPush: (enabled) => { + scrollPush = enabled; + cursorInput?.setScrollPush(enabled); + }, + dispose: () => { + stopWatchingPause(); + scheduler.remove(tick); + cursorInput?.dispose(); + }, }; } diff --git a/packages/shaders/src/react/hooks/use-cursor/use-cursor.test.tsx b/packages/shaders/src/react/hooks/use-cursor/use-cursor.test.tsx index d59de762..db777e64 100644 --- a/packages/shaders/src/react/hooks/use-cursor/use-cursor.test.tsx +++ b/packages/shaders/src/react/hooks/use-cursor/use-cursor.test.tsx @@ -705,3 +705,94 @@ describe('useCursor', () => { expect(() => unmount()).not.toThrow(); }); }); + +describe('useCursor with scroll push', () => { + interface Finger { + id: number; + x: number; + y: number; + } + + // Fires a touch event on the canvas: `touches` is every finger still down + // after it, `changed` the fingers it is about. + const fireTouch = ( + target: EventTarget, + type: 'touchstart' | 'touchend', + touches: Finger[], + changed: Finger[] = touches, + ) => { + const toTouch = ({ id, x, y }: Finger) => + new Touch({ identifier: id, target, clientX: x, clientY: y }); + + act(() => { + target.dispatchEvent( + new TouchEvent(type, { + touches: touches.map(toTouch), + changedTouches: changed.map(toTouch), + bubbles: true, + }), + ); + }); + }; + + // A scene whose shared input pushes on a real 300 by 300 canvas at + // (0, 100), ticked once per frame ahead of every call, as ShaderScene + // does. `scrollTo` moves the canvas, standing in for the page scrolling. + const makePushScene = (scheduler: FrameScheduler) => { + const canvas = document.createElement('canvas'); + let rect = { left: 0, top: 100, width: 300, height: 300 }; + + canvas.getBoundingClientRect = () => ({ ...rect }) as DOMRect; + const input = new CursorInput({ element: canvas }); + + input.setScrollPush(true); + scheduler.add(({ delta }) => input.tick(delta)); + const shaderContext = { + scheduler, + getCursorInput: () => input, + renderer: { three: { domElement: canvas } }, + } as unknown as React.ContextType; + + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + return { + Wrapper, + canvas, + input, + scrollTo: (top: number) => { + rect = { ...rect, top }; + }, + }; + }; + + it("hands every call the frame's push stroke", () => { + const frames = captureFrames(); + const runFrame = makeFrameRunner(frames); + const scheduler = new FrameScheduler(); + const scene = makePushScene(scheduler); + + scheduler.start(); + const { result } = renderHook( + () => ({ first: useCursor(), second: useCursor({ smoothing: 0 }) }), + { wrapper: scene.Wrapper }, + ); + + runFrame(); + fireTouch(scene.canvas, 'touchstart', [{ id: 1, x: 100, y: 200 }]); + scene.scrollTo(70); + runFrame(); + + expect(result.current.first.getPush()).not.toBeNull(); + expect(result.current.first.getPush()).toBe(scene.input.getPush()); + expect(result.current.second.getPush()).toBe(scene.input.getPush()); + scene.input.dispose(); + }); + + it('pushes nothing outside a scene', () => { + const { result } = renderHook(() => useCursor()); + + expect(result.current.getPush()).toBeNull(); + }); +}); diff --git a/packages/shaders/src/react/hooks/use-cursor/use-cursor.ts b/packages/shaders/src/react/hooks/use-cursor/use-cursor.ts index 87d5ef61..396abe3e 100644 --- a/packages/shaders/src/react/hooks/use-cursor/use-cursor.ts +++ b/packages/shaders/src/react/hooks/use-cursor/use-cursor.ts @@ -7,7 +7,12 @@ // or with a caller-supplied element, the call owns a private input. import { useEffect, useRef, useState } from 'react'; -import { CursorInput, type CursorInputOptions, type Vector2 } from '../../../engine.js'; +import { + CursorInput, + type CursorInputOptions, + type PushStroke, + type Vector2, +} from '../../../engine.js'; import { POSITION_SETTLE_THRESHOLD } from '../../../inputs/cursor-input/cursor-input.js'; import { Smoother } from '../../../inputs/cursor-input/smoother.js'; import { createSignal } from '../../internal/create-signal.js'; @@ -38,6 +43,16 @@ export interface CursorSignal extends AnimatableSignal { * rather than freezing there. */ presence: AnimatableSignal; + /** + * This frame's scroll-push stroke, or null when no touch pushed. Only a + * ShaderScene with `scrollPush` on pushes: a finger that scrolls the page + * over the canvas strokes at the finger, through the fling. The stroke is + * a segment in 0..1 canvas units ending at the finger, with the presence + * that gates it, the shape a wave field's stroke takes. Read it once per + * frame, from the scene's frame loop. The scene keeps drawing while a + * touch pushes, so the stroke keeps arriving. + */ + getPush: () => PushStroke | null; } // ---------------------------------------------------------------------------- @@ -75,6 +90,7 @@ const createStubSignal = (initial: Vector2): CursorSignal => ({ get: () => 0, on: () => () => undefined, }, + getPush: () => null, }); const noop = () => undefined; @@ -211,7 +227,11 @@ export function useCursor(opts: CursorOptions = {}): CursorSignal { if (cursorBurstActive) scheduler?.requestRender(); }; - setSignal({ ...positionChannel.signal, presence: presenceChannel.signal }); + setSignal({ + ...positionChannel.signal, + presence: presenceChannel.signal, + getPush: () => input.getPush(), + }); // The easing needs a steady tick. Inside a ShaderScene, ride the scene's // frame scheduler; outside one (Mode 2), run a private rAF loop. From 1ff2c4eddeb3e2414f658ec693ba7bd0ee2f951a Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 12 Oct 2026 21:47:14 -0400 Subject: [PATCH 2/9] docs: document scrollPush and add scroll push to the glossary The React API page gains the scrollPush prop and useCursor's getPush. The glossary names scroll push and lists the gesture names it replaced under Avoid. --- GLOSSARY.md | 4 ++++ apps/docs/content/docs/react/api.mdx | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index f7dd2647..e83ad439 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -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**: diff --git a/apps/docs/content/docs/react/api.mdx b/apps/docs/content/docs/react/api.mdx index 265286b5..adfd5084 100644 --- a/apps/docs/content/docs/react/api.mdx +++ b/apps/docs/content/docs/react/api.mdx @@ -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. The scene only listens, so the page scrolls and zooms exactly as it would without it. A touch then reaches cursor effects only this way, and 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. | @@ -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. From d8dbf404772728dbb8fa03bc17492b6c606e5565 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 12 Oct 2026 21:47:14 -0400 Subject: [PATCH 3/9] feat(docs): add a touch probe for scroll push /dev/touch-probe puts a scene with scrollPush beside one without it, at the Reactive card's size, in a page that scrolls. A recorder logs what each contact pushed, for device reports on SHA-225. A Playwright spec drags on the opted-in scene with CDP touch, and checks that the page scrolls, that the push reaches the scene, and that a tap pushes nothing. --- apps/docs-tests/docs/scroll-push.spec.ts | 78 +++++++++ .../docs/src/app/dev/touch-probe/page.dev.tsx | 11 ++ .../src/app/dev/touch-probe/probe-scene.tsx | 119 +++++++++++++ .../src/app/dev/touch-probe/probe.module.css | 84 +++++++++ .../src/app/dev/touch-probe/push-recorder.tsx | 161 ++++++++++++++++++ apps/docs/src/app/dev/touch-probe/report.ts | 17 ++ 6 files changed, 470 insertions(+) create mode 100644 apps/docs-tests/docs/scroll-push.spec.ts create mode 100644 apps/docs/src/app/dev/touch-probe/page.dev.tsx create mode 100644 apps/docs/src/app/dev/touch-probe/probe-scene.tsx create mode 100644 apps/docs/src/app/dev/touch-probe/probe.module.css create mode 100644 apps/docs/src/app/dev/touch-probe/push-recorder.tsx create mode 100644 apps/docs/src/app/dev/touch-probe/report.ts diff --git a/apps/docs-tests/docs/scroll-push.spec.ts b/apps/docs-tests/docs/scroll-push.spec.ts new file mode 100644 index 00000000..acbe0854 --- /dev/null +++ b/apps/docs-tests/docs/scroll-push.spec.ts @@ -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 { + 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); +}); diff --git a/apps/docs/src/app/dev/touch-probe/page.dev.tsx b/apps/docs/src/app/dev/touch-probe/page.dev.tsx new file mode 100644 index 00000000..ce9081a3 --- /dev/null +++ b/apps/docs/src/app/dev/touch-probe/page.dev.tsx @@ -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 ; +} diff --git a/apps/docs/src/app/dev/touch-probe/probe-scene.tsx b/apps/docs/src/app/dev/touch-probe/probe-scene.tsx new file mode 100644 index 00000000..2ef4af1a --- /dev/null +++ b/apps/docs/src/app/dev/touch-probe/probe-scene.tsx @@ -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('idle'); + const [lines, setLines] = useState([]); + 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(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 ( +
+

Touch probe

+

Try these on the top scene, and watch the badge in its corner.

+
    +
  1. + Scroll by dragging on the scene, slowly, then fast. The water should stir as you go. +
  2. +
  3. Flick and let go. The water keeps stirring until the page stops.
  4. +
  5. Tap, and rest a finger without scrolling. Nothing should stir.
  6. +
  7. Drag sideways, and diagonally. The water should follow the finger.
  8. +
  9. Pinch on the scene. The page should zoom, and nothing should stir.
  10. +
  11. + Tap the button over the scene, and swipe from it. It should tap and scroll as normal. +
  12. +
  13. + Touch the lower scene, which keeps today's behavior. Neither scene should react to a + finger on the other. +
  14. +
+

Then select the report and paste it into SHA-225.

+
+

Scroll push

+
+ setReady(true)} scrollPush> + + + + + + {phase} + + +
+

Today's behavior

+
+ + + + +
+

Report

+
+ + +
+