UNPKG

@motion-core/motion-gpu

Version:

Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.

793 lines (721 loc) 19.9 kB
import { createCurrentWritable, type CurrentReadable } from './current-value.js'; import type { RenderMode } from './types.js'; /** * Pointer kind normalized from DOM `PointerEvent.pointerType`. */ export type PointerKind = 'mouse' | 'pen' | 'touch'; /** * 2D tuple used by pointer coordinate payloads. */ export type PointerVec2 = [number, number]; /** * Normalized pointer coordinates exposed to runtime hooks. */ export interface PointerPoint { /** * CSS pixel coordinates relative to canvas top-left corner. */ px: PointerVec2; /** * UV coordinates in shader-friendly orientation (`y` grows upward). */ uv: PointerVec2; /** * Normalized device coordinates (`-1..1`, `y` grows upward). */ ndc: PointerVec2; } /** * Mutable pointer state snapshot exposed by `usePointer`. */ export interface PointerState extends PointerPoint { inside: boolean; pressed: boolean; dragging: boolean; pointerType: PointerKind | null; pointerId: number | null; button: number | null; buttons: number; time: number; downPx: PointerVec2 | null; downUv: PointerVec2 | null; deltaPx: PointerVec2; deltaUv: PointerVec2; velocityPx: PointerVec2; velocityUv: PointerVec2; } /** * Modifier key snapshot attached to pointer click events. */ export interface PointerModifiers { alt: boolean; ctrl: boolean; shift: boolean; meta: boolean; } /** * Click/tap payload produced by `usePointer`. */ export interface PointerClick extends PointerPoint { id: number; time: number; pointerType: PointerKind; pointerId: number; button: number; modifiers: PointerModifiers; } /** * Frame wake-up strategy for pointer-driven interactions. */ export type PointerFrameRequestMode = 'advance' | 'auto' | 'invalidate' | 'none'; /** * Returns a monotonic timestamp in seconds. */ export function getPointerNowSeconds(): number { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now() / 1000; } return Date.now() / 1000; } /** * Creates the initial pointer state snapshot. */ export function createInitialPointerState(): PointerState { return { px: [0, 0], uv: [0, 0], ndc: [-1, -1], inside: false, pressed: false, dragging: false, pointerType: null, pointerId: null, button: null, buttons: 0, time: getPointerNowSeconds(), downPx: null, downUv: null, deltaPx: [0, 0], deltaUv: [0, 0], velocityPx: [0, 0], velocityUv: [0, 0] }; } /** * Normalized coordinate payload for a pointer position against a canvas rect. */ export interface PointerCoordinates extends PointerPoint { inside: boolean; } /** * Converts client coordinates to canvas-relative pointer coordinates. */ export function getPointerCoordinates( clientX: number, clientY: number, rect: Pick<DOMRectReadOnly, 'height' | 'left' | 'top' | 'width'> ): PointerCoordinates { const width = Math.max(rect.width, 1); const height = Math.max(rect.height, 1); const nx = (clientX - rect.left) / width; const ny = (clientY - rect.top) / height; const pxX = clientX - rect.left; const pxY = clientY - rect.top; const uvX = nx; const uvY = 1 - ny; return { px: [pxX, pxY], uv: [uvX, uvY], ndc: [nx * 2 - 1, uvY * 2 - 1], inside: nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1 }; } /** * Resolves frame wake-up strategy for pointer-driven updates. */ export function resolvePointerFrameRequestMode( mode: PointerFrameRequestMode, renderMode: RenderMode ): Exclude<PointerFrameRequestMode, 'auto'> { if (mode !== 'auto') { return mode; } if (renderMode === 'manual') { return 'advance'; } if (renderMode === 'on-demand') { return 'invalidate'; } return 'none'; } /** * Normalizes unknown pointer kind values to the public `PointerKind`. */ export function normalizePointerKind(pointerType: string): PointerKind { if (pointerType === 'mouse' || pointerType === 'pen' || pointerType === 'touch') { return pointerType; } return 'mouse'; } /** * Framework-neutral options consumed by the pointer controller. */ export interface PointerControllerOptions { /** * Enables pointer listeners. * * @default true */ enabled?: boolean; /** * Frame wake-up strategy for pointer-driven state changes. * * @default 'auto' */ requestFrame?: PointerFrameRequestMode; /** * Requests pointer capture on pointer down. * * @default true */ capturePointer?: boolean; /** * Tracks pointer movement outside canvas while pointer is pressed. * Pointer release/cancellation is still observed to keep press state consistent. * * @default true */ trackWhilePressedOutsideCanvas?: boolean; /** * Enables click/tap synthesis on pointer up. * * @default true */ clickEnabled?: boolean; /** * Maximum press duration to consider pointer up a click (milliseconds). * * @default 350 */ clickMaxDurationMs?: number; /** * Maximum pointer travel from down to up to consider pointer up a click (pixels). * * @default 8 */ clickMaxMovePx?: number; /** * Allowed pointer buttons for click synthesis. * * @default [0] */ clickButtons?: number[]; /** * Called after pointer move state update. */ onMove?: (state: PointerState, event: PointerEvent) => void; /** * Called after pointer down state update. */ onDown?: (state: PointerState, event: PointerEvent) => void; /** * Called after pointer up/cancel state update. */ onUp?: (state: PointerState, event: PointerEvent) => void; /** * Called when click/tap is synthesized. */ onClick?: (click: PointerClick, state: PointerState, event: PointerEvent) => void; } /** * Framework runtime operations required by the pointer controller. */ export interface PointerControllerRuntime { advance: () => void; getRenderMode: () => RenderMode; invalidate: () => void; } /** * Public pointer values shared by all framework adapters. */ export interface PointerControllerResult { state: CurrentReadable<PointerState>; lastClick: CurrentReadable<PointerClick | null>; resetClick: () => void; } /** * Framework-neutral pointer lifecycle controller. */ export interface PointerController extends PointerControllerResult { destroy: () => void; mount: (canvas: HTMLCanvasElement) => void; updateOptions: (options: PointerControllerOptions) => void; } interface PointerDownSnapshot { button: number; inside: boolean; pointerId: number; pointerType: PointerKind; px: PointerVec2; timeMs: number; uv: PointerVec2; } interface PointerStateInput { button: number | null; buttons: number; downPx: PointerVec2 | null; downUv: PointerVec2 | null; dragging: boolean; inside: boolean; pointerId: number | null; pointerType: PointerKind | null; pressed: boolean; point: PointerPoint; resetDelta?: boolean; } function resolveClickMaxDurationMs(value: number | undefined): number { if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) { return 350; } return value; } function resolveClickMaxMovePx(value: number | undefined): number { if (typeof value !== 'number' || Number.isNaN(value) || value < 0) { return 8; } return value; } function normalizeClickButtons(buttons: number[] | undefined): Set<number> { const source = buttons && buttons.length > 0 ? buttons : [0]; return new Set(source); } /** * Creates the shared pointer controller used by every framework adapter. */ export function createPointerController( runtime: PointerControllerRuntime, initialOptions: PointerControllerOptions = {} ): PointerController { const state = createCurrentWritable<PointerState>(createInitialPointerState()); const lastClick = createCurrentWritable<PointerClick | null>(null); let options = initialOptions; let canvas: HTMLCanvasElement | null = null; let windowTarget: Window | null = null; let canvasListenersAttached = false; let windowMoveListenerAttached = false; let windowReleaseListenersAttached = false; let activePointerId: number | null = null; let capturedPointerId: number | null = null; let downSnapshot: PointerDownSnapshot | null = null; let clickCounter = 0; let previousPx: PointerVec2 | null = null; let previousUv: PointerVec2 | null = null; let previousTimeSeconds = 0; const handledCanvasMoveEvents = new WeakSet<PointerEvent>(); const isEnabled = (): boolean => options.enabled ?? true; const shouldTrackOutside = (): boolean => options.trackWhilePressedOutsideCanvas ?? true; const requestFrame = (): void => { const mode = resolvePointerFrameRequestMode( options.requestFrame ?? 'auto', runtime.getRenderMode() ); if (mode === 'invalidate') { runtime.invalidate(); return; } if (mode === 'advance') { runtime.advance(); } }; const updatePointerState = (input: PointerStateInput): PointerState => { const nowSeconds = getPointerNowSeconds(); const dt = previousTimeSeconds > 0 ? Math.max(nowSeconds - previousTimeSeconds, 1e-6) : 0; const deltaPx: PointerVec2 = input.resetDelta || !previousPx ? [0, 0] : [input.point.px[0] - previousPx[0], input.point.px[1] - previousPx[1]]; const deltaUv: PointerVec2 = input.resetDelta || !previousUv ? [0, 0] : [input.point.uv[0] - previousUv[0], input.point.uv[1] - previousUv[1]]; const velocityPx: PointerVec2 = dt > 0 ? [deltaPx[0] / dt, deltaPx[1] / dt] : [0, 0]; const velocityUv: PointerVec2 = dt > 0 ? [deltaUv[0] / dt, deltaUv[1] / dt] : [0, 0]; const nextState: PointerState = { px: input.point.px, uv: input.point.uv, ndc: input.point.ndc, inside: input.inside, pressed: input.pressed, dragging: input.dragging, pointerType: input.pointerType, pointerId: input.pointerId, button: input.button, buttons: input.buttons, time: nowSeconds, downPx: input.downPx, downUv: input.downUv, deltaPx, deltaUv, velocityPx, velocityUv }; state.set(nextState); previousPx = input.point.px; previousUv = input.point.uv; previousTimeSeconds = nowSeconds; requestFrame(); return nextState; }; const updateInsideState = (inside: boolean): void => { const current = state.current; state.set({ ...current, inside, time: getPointerNowSeconds(), deltaPx: [0, 0], deltaUv: [0, 0], velocityPx: [0, 0], velocityUv: [0, 0] }); requestFrame(); }; const releaseCapture = (): void => { if (!canvas || capturedPointerId === null) { capturedPointerId = null; return; } try { if ( typeof canvas.hasPointerCapture !== 'function' || canvas.hasPointerCapture(capturedPointerId) ) { canvas.releasePointerCapture(capturedPointerId); } } catch { // Browser rejected release for this pointer id. } capturedPointerId = null; }; const resetTracking = (emitFrame: boolean): void => { releaseCapture(); activePointerId = null; downSnapshot = null; previousPx = null; previousUv = null; previousTimeSeconds = 0; const current = state.current; const shouldResetState = current.inside || current.pressed || current.dragging || current.pointerId !== null || current.button !== null || current.buttons !== 0 || current.downPx !== null || current.downUv !== null; if (!shouldResetState) { return; } state.set({ ...current, inside: false, pressed: false, dragging: false, pointerId: null, button: null, buttons: 0, time: getPointerNowSeconds(), downPx: null, downUv: null, deltaPx: [0, 0], deltaUv: [0, 0], velocityPx: [0, 0], velocityUv: [0, 0] }); if (emitFrame) { requestFrame(); } }; const handlePointerDown = (event: PointerEvent): void => { if (!canvas || activePointerId !== null) { return; } const point = getPointerCoordinates( event.clientX, event.clientY, canvas.getBoundingClientRect() ); const pointerType = normalizePointerKind(event.pointerType); activePointerId = event.pointerId; downSnapshot = { pointerId: event.pointerId, pointerType, button: event.button, timeMs: getPointerNowSeconds() * 1000, px: point.px, uv: point.uv, inside: point.inside }; if (options.capturePointer ?? true) { try { canvas.setPointerCapture(event.pointerId); capturedPointerId = event.pointerId; } catch { // Browser rejected capture (e.g. unsupported pointer state). } } const nextState = updatePointerState({ point, inside: point.inside, pressed: true, dragging: false, pointerType, pointerId: event.pointerId, button: event.button, buttons: event.buttons, downPx: point.px, downUv: point.uv, resetDelta: true }); options.onDown?.(nextState, event); }; const handlePointerMove = (event: PointerEvent): void => { if (!canvas || (activePointerId !== null && event.pointerId !== activePointerId)) { return; } handledCanvasMoveEvents.add(event); const point = getPointerCoordinates( event.clientX, event.clientY, canvas.getBoundingClientRect() ); const pressed = activePointerId !== null && event.pointerId === activePointerId; const downPx = pressed ? (downSnapshot?.px ?? point.px) : null; const downUv = pressed ? (downSnapshot?.uv ?? point.uv) : null; const dragging = pressed && downPx ? Math.hypot(point.px[0] - downPx[0], point.px[1] - downPx[1]) > 0 : false; const nextState = updatePointerState({ point, inside: point.inside, pressed, dragging, pointerType: normalizePointerKind(event.pointerType), pointerId: event.pointerId, button: pressed ? (downSnapshot?.button ?? event.button) : null, buttons: event.buttons, downPx, downUv }); options.onMove?.(nextState, event); }; const handleWindowPointerMove = (event: PointerEvent): void => { if ( handledCanvasMoveEvents.has(event) || !canvas || activePointerId === null || event.pointerId !== activePointerId ) { return; } const point = getPointerCoordinates( event.clientX, event.clientY, canvas.getBoundingClientRect() ); if (point.inside) { return; } const downPx = downSnapshot?.px ?? point.px; const downUv = downSnapshot?.uv ?? point.uv; const nextState = updatePointerState({ point, inside: false, pressed: true, dragging: Math.hypot(point.px[0] - downPx[0], point.px[1] - downPx[1]) > 0, pointerType: downSnapshot?.pointerType ?? normalizePointerKind(event.pointerType), pointerId: event.pointerId, button: downSnapshot?.button ?? event.button, buttons: event.buttons, downPx, downUv }); options.onMove?.(nextState, event); }; const releasePointer = (event: PointerEvent, emitClick: boolean): void => { if (!canvas || activePointerId === null || event.pointerId !== activePointerId) { return; } const point = getPointerCoordinates( event.clientX, event.clientY, canvas.getBoundingClientRect() ); const previous = downSnapshot; const pointerType = previous?.pointerType ?? normalizePointerKind(event.pointerType); const nextState = updatePointerState({ point, inside: point.inside, pressed: false, dragging: false, pointerType, pointerId: null, button: null, buttons: event.buttons, downPx: null, downUv: null }); options.onUp?.(nextState, event); releaseCapture(); if (emitClick && (options.clickEnabled ?? true) && previous) { const allowedButtons = normalizeClickButtons(options.clickButtons); if (allowedButtons.has(previous.button)) { const durationMs = getPointerNowSeconds() * 1000 - previous.timeMs; const moveDistance = Math.hypot(point.px[0] - previous.px[0], point.px[1] - previous.px[1]); if ( previous.inside && point.inside && durationMs <= resolveClickMaxDurationMs(options.clickMaxDurationMs) && moveDistance <= resolveClickMaxMovePx(options.clickMaxMovePx) ) { clickCounter += 1; const click: PointerClick = { id: clickCounter, time: getPointerNowSeconds(), pointerType, pointerId: event.pointerId, button: previous.button, modifiers: { alt: event.altKey, ctrl: event.ctrlKey, shift: event.shiftKey, meta: event.metaKey }, px: point.px, uv: point.uv, ndc: point.ndc }; lastClick.set(click); options.onClick?.(click, nextState, event); requestFrame(); } } } activePointerId = null; downSnapshot = null; }; const handlePointerUp = (event: PointerEvent): void => { releasePointer(event, true); }; const handlePointerCancel = (event: PointerEvent): void => { releasePointer(event, false); }; const handlePointerLeave = (): void => { if (activePointerId === null) { updateInsideState(false); } }; const attachWindowMoveListener = (): void => { if (windowMoveListenerAttached || !windowTarget || !shouldTrackOutside()) { return; } windowTarget.addEventListener('pointermove', handleWindowPointerMove); windowMoveListenerAttached = true; }; const detachWindowMoveListener = (): void => { if (!windowMoveListenerAttached || !windowTarget) { return; } windowTarget.removeEventListener('pointermove', handleWindowPointerMove); windowMoveListenerAttached = false; }; const attachWindowReleaseListeners = (): void => { if (windowReleaseListenersAttached || !windowTarget) { return; } windowTarget.addEventListener('pointerup', handlePointerUp); windowTarget.addEventListener('pointercancel', handlePointerCancel); windowReleaseListenersAttached = true; }; const detachWindowReleaseListeners = (): void => { if (!windowReleaseListenersAttached || !windowTarget) { return; } windowTarget.removeEventListener('pointerup', handlePointerUp); windowTarget.removeEventListener('pointercancel', handlePointerCancel); windowReleaseListenersAttached = false; }; const attachCanvasListeners = (): void => { if (canvasListenersAttached || !canvas || !isEnabled()) { return; } canvas.addEventListener('pointerdown', handlePointerDown); canvas.addEventListener('pointermove', handlePointerMove); canvas.addEventListener('pointerup', handlePointerUp); canvas.addEventListener('pointercancel', handlePointerCancel); canvas.addEventListener('pointerleave', handlePointerLeave); canvasListenersAttached = true; attachWindowReleaseListeners(); attachWindowMoveListener(); }; const detachCanvasListeners = (): void => { detachWindowMoveListener(); detachWindowReleaseListeners(); if (!canvasListenersAttached || !canvas) { return; } canvas.removeEventListener('pointerdown', handlePointerDown); canvas.removeEventListener('pointermove', handlePointerMove); canvas.removeEventListener('pointerup', handlePointerUp); canvas.removeEventListener('pointercancel', handlePointerCancel); canvas.removeEventListener('pointerleave', handlePointerLeave); canvasListenersAttached = false; }; const destroy = (): void => { detachCanvasListeners(); resetTracking(false); canvas = null; windowTarget = null; }; return { state, lastClick, resetClick() { lastClick.set(null); }, mount(nextCanvas) { if (canvas === nextCanvas) { attachCanvasListeners(); return; } destroy(); canvas = nextCanvas; windowTarget = nextCanvas.ownerDocument.defaultView; attachCanvasListeners(); }, updateOptions(nextOptions) { const wasEnabled = isEnabled(); const wasTrackingOutside = shouldTrackOutside(); options = nextOptions; const enabled = isEnabled(); const trackingOutside = shouldTrackOutside(); if (wasEnabled && !enabled) { detachCanvasListeners(); resetTracking(true); return; } if (!wasEnabled && enabled) { attachCanvasListeners(); return; } if (!enabled || wasTrackingOutside === trackingOutside) { return; } if (trackingOutside) { attachWindowMoveListener(); } else { detachWindowMoveListener(); } }, destroy }; }