"use client"; import React, { type ComponentRef, type ReactNode, type RefObject, Suspense, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { AdditiveBlending, type BufferAttribute, type Camera, type Group, type InterleavedBufferAttribute, LinearFilter, MathUtils, type Mesh, type MeshBasicMaterial, OrthographicCamera, type PlaneGeometry, SRGBColorSpace, Scene, type ShaderMaterial, type Texture, Vector2, VideoTexture, } from "three"; import { advance, Canvas, type CanvasProps, createPortal, useFrame, useStore, useThree, } from "@react-three/fiber"; import { useFBO, useTexture } from "@react-three/drei"; import { EffectComposer } from "@react-three/postprocessing"; import { cancelFrame, type FrameData, frame } from "motion"; import { cn } from "@/lib/utils"; const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; export type ObjectFitCrop = { repeatU: number; repeatV: number; fitScaleX: number; fitScaleY: number; }; export function computeObjectFit( planeAspect: number, mediaAspect: number, objectFit: string, ): ObjectFitCrop { const crop: ObjectFitCrop = { repeatU: 1, repeatV: 1, fitScaleX: 1, fitScaleY: 1, }; if (!Number.isFinite(mediaAspect) || mediaAspect <= 0) return crop; if (objectFit === "cover") { if (planeAspect > mediaAspect) { crop.repeatV = mediaAspect / planeAspect; } else { crop.repeatU = planeAspect / mediaAspect; } } else if (objectFit === "contain") { if (planeAspect > mediaAspect) { crop.fitScaleX = mediaAspect / planeAspect; } else { crop.fitScaleY = planeAspect / mediaAspect; } } return crop; } export function applyUvCrop( uvAttribute: BufferAttribute | InterleavedBufferAttribute, segments: number, repeatU: number, repeatV: number, ) { const offsetU = (1 - repeatU) / 2; const offsetV = (1 - repeatV) / 2; for (let iy = 0; iy <= segments; iy++) { for (let ix = 0; ix <= segments; ix++) { const index = iy * (segments + 1) + ix; const u = ix / segments; const v = 1 - iy / segments; uvAttribute.setXY(index, u * repeatU + offsetU, v * repeatV + offsetV); } } uvAttribute.needsUpdate = true; } export type Pointer = { uv: Vector2; texUv: Vector2; repeat: Vector2; fit: Vector2; hover: number; }; export type PointerTarget = Pointer | RefObject; export function getPointerTarget(target: PointerTarget): Pointer { if ("current" in target) { return target.current; } return target; } type UsePointerUvOptions = { enabled: boolean; getRect?: (el: HTMLElement) => DOMRect; }; export function usePointerUv( el: RefObject, { enabled, getRect }: UsePointerUvOptions, ): RefObject { const pointerRef = useRef({ uv: new Vector2(0.5, 0.5), texUv: new Vector2(0.5, 0.5), repeat: new Vector2(1, 1), fit: new Vector2(1, 1), hover: 0, }); useEffect(() => { if (!enabled) return; const target = el.current; if (!target) return; const currentPointer = pointerRef.current; const onMove = (event: PointerEvent) => { const rect = getRect ? getRect(target) : target.getBoundingClientRect(); const width = rect.width || 1; const height = rect.height || 1; const x = (event.clientX - rect.left) / width; const y = 1 - (event.clientY - rect.top) / height; currentPointer.uv.set(x, y); currentPointer.texUv.set( x * currentPointer.fit.x + (1 - currentPointer.fit.x) / 2, y * currentPointer.fit.y + (1 - currentPointer.fit.y) / 2, ); }; const onEnter = () => { currentPointer.hover = 1; }; const onLeave = () => { currentPointer.hover = 0; }; target.addEventListener("pointermove", onMove); target.addEventListener("pointerenter", onEnter); target.addEventListener("pointerleave", onLeave); if (typeof target.matches === "function" && target.matches(":hover")) { currentPointer.hover = 1; } return () => { target.removeEventListener("pointermove", onMove); target.removeEventListener("pointerenter", onEnter); target.removeEventListener("pointerleave", onLeave); }; }, [enabled, el, getRect]); return pointerRef; } function WebglTeleport() { const items = new Map(); const listeners = new Set<() => void>(); let snapshot: [string, ReactNode][] = []; const emit = () => { snapshot = Array.from(items.entries()); for (const listener of listeners) { listener(); } }; const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; const getSnapshot = () => snapshot; function useItems() { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } return { In({ children }: { children: ReactNode }) { const id = useId(); useIsoLayoutEffect(() => { items.set(id, children); emit(); return () => { items.delete(id); emit(); }; }, [id, children]); return null; }, useItems, Out() { const list = useItems(); return ( <> {list.map(([id, node]) => ( {node} ))} ); }, }; } export const webglTeleport = WebglTeleport(); export const effectTeleport = WebglTeleport(); export function WebglPortal() { return ; } const WebglContext = React.createContext(false); type WebglProviderProps = Omit & { children: ReactNode; className?: string; contained?: boolean; }; type WebglReadyOptions = { scene?: Scene; camera?: Camera; enabled?: boolean; onReady?: () => void; }; export function useWebglReady({ scene, camera, enabled = true, onReady, }: WebglReadyOptions = {}) { const [ready, setReady] = useState(false); const gl = useThree((state) => state.gl); const defaultScene = useThree((state) => state.scene); const defaultCamera = useThree((state) => state.camera); const onReadyRef = useRef(onReady); useEffect(() => { onReadyRef.current = onReady; }, [onReady]); const targetScene = scene ?? defaultScene; const targetCamera = camera ?? defaultCamera; useEffect(() => { if (!enabled) return; let active = true; gl.compileAsync(targetScene, targetCamera).then(() => { if (!active) return; requestAnimationFrame(() => { if (!active) return; setReady(true); onReadyRef.current?.(); }); }); return () => { active = false; }; }, [gl, targetScene, targetCamera, enabled]); return ready; } type CanvasStore = ReturnType; const canvasStores = new Set(); let clockStart: number | null = null; function tick(data: FrameData) { if (clockStart === null) clockStart = data.timestamp; const elapsed = (data.timestamp - clockStart) / 1000; let runGlobalEffects = true; for (const store of canvasStores) { const state = store.getState(); if (state.internal.active) { advance(elapsed, runGlobalEffects, state); runGlobalEffects = false; } } } function MotionFrameloop() { const store = useStore(); useEffect(() => { canvasStores.add(store); if (canvasStores.size === 1) frame.postRender(tick, true); return () => { canvasStores.delete(store); if (canvasStores.size === 0) cancelFrame(tick); }; }, [store]); return null; } function RootRender() { const gl = useThree((state) => state.gl); const scene = useThree((state) => state.scene); const camera = useThree((state) => state.camera); useFrame(() => gl.render(scene, camera), 1); return null; } function Effects() { const effects = effectTeleport.useItems(); const gl = useThree((state) => state.gl); const mounted = effects.length > 0; useEffect(() => { if (!mounted) return; return () => { gl.autoClear = true; }; }, [mounted, gl]); if (!mounted) return ; return ( ); } export function WebglProvider({ children, className, style, contained = false, ...canvasProps }: WebglProviderProps) { const [eventSource, setEventSource] = useState | null>( null, ); return (
{children}
); } type WebglImageProps = { src: string; alt: string; material?: (map: Texture, pointer: PointerTarget) => React.ReactNode; webglEnabled?: boolean; segments?: number; zIndex?: number; autoReflow?: boolean; } & Omit, "children" | "src" | "alt">; type ImagePlaneProps = { el: RefObject; src: string; segments: number; material?: (map: Texture, pointer: PointerTarget) => React.ReactNode; pointer: PointerTarget; zIndex: number; autoReflow: boolean; }; function ImagePlane({ el, src, segments, material, pointer, zIndex, autoReflow, }: ImagePlaneProps) { const mesh = useRef(null); const texture = useTexture(src); const fitScale = useRef({ x: 1, y: 1 }); const size = useThree((state) => state.size); const viewport = useThree((state) => state.viewport); const gl = useThree((state) => state.gl); useLayoutEffect(() => { const target = el.current; if (!target) return; const measure = () => { const m = mesh.current; if (!m) return; const rect = target.getBoundingClientRect(); if (!rect || rect.height <= 0) return; const image = texture.image as HTMLImageElement; if (!image || !image.height) return; const crop = computeObjectFit( rect.width / rect.height, image.width / image.height, getComputedStyle(target).objectFit, ); fitScale.current.x = crop.fitScaleX; fitScale.current.y = crop.fitScaleY; const p = getPointerTarget(pointer); p.repeat.set(crop.repeatU, crop.repeatV); p.fit.set( crop.repeatU / crop.fitScaleX, crop.repeatV / crop.fitScaleY, ); applyUvCrop( m.geometry.attributes.uv, segments, crop.repeatU, crop.repeatV, ); }; measure(); const ro = new ResizeObserver(measure); ro.observe(target); ro.observe(document.body); return () => ro.disconnect(); }, [el, texture, segments, pointer]); useFrame(() => { const m = mesh.current; if (!m || !el.current || size.height <= 0) return; const pxToWorld = viewport.height / size.height; const fit = fitScale.current; const canvasRect = gl.domElement && typeof gl.domElement.getBoundingClientRect === "function" ? gl.domElement.getBoundingClientRect() : { left: 0, top: 0 }; if (autoReflow && el.current) { const rect = el.current.getBoundingClientRect(); const relLeft = rect.left - canvasRect.left; const relTop = rect.top - canvasRect.top; m.position.x = (relLeft + rect.width / 2 - size.width / 2) * pxToWorld; m.position.y = -(relTop + rect.height / 2 - size.height / 2) * pxToWorld; m.scale.x = rect.width * pxToWorld * fit.x; m.scale.y = rect.height * pxToWorld * fit.y; } }); return ( {material ? ( material(texture, pointer) ) : ( )} ); } export function WebglImage({ src, alt, className, style, material, webglEnabled = true, segments = 1, zIndex = 0, autoReflow = true, ...rest }: WebglImageProps) { const el = useRef>(null); const pointer = usePointerUv(el, { enabled: webglEnabled }); return ( <> {alt} {webglEnabled && ( )} ); } type WebglVideoProps = { src: string; material?: (map: Texture, pointer: PointerTarget) => React.ReactNode; webglEnabled?: boolean; segments?: number; zIndex?: number; autoReflow?: boolean; } & Omit, "children" | "src">; type VideoPlaneProps = { el: RefObject; segments: number; material?: (map: Texture, pointer: PointerTarget) => React.ReactNode; pointer: PointerTarget; zIndex: number; autoReflow: boolean; }; function VideoPlane({ el, segments, material, pointer, zIndex, autoReflow, }: VideoPlaneProps) { const mesh = useRef(null); const [texture, setTexture] = useState(null); const fitScale = useRef({ x: 1, y: 1 }); const size = useThree((state) => state.size); const viewport = useThree((state) => state.viewport); const gl = useThree((state) => state.gl); useEffect(() => { const video = el.current; if (!video) return; const videoTexture = new VideoTexture(video); videoTexture.colorSpace = SRGBColorSpace; queueMicrotask(() => { setTexture(videoTexture); }); return () => videoTexture.dispose(); }, [el]); useLayoutEffect(() => { const target = el.current; if (!target || !texture) return; const measure = () => { const m = mesh.current; if (!m) return; const rect = target.getBoundingClientRect(); if (!rect || rect.height <= 0) return; const video = texture.image as HTMLVideoElement; if (!video || !video.videoHeight) return; const crop = computeObjectFit( rect.width / rect.height, video.videoWidth / video.videoHeight, getComputedStyle(target).objectFit, ); fitScale.current.x = crop.fitScaleX; fitScale.current.y = crop.fitScaleY; const p = getPointerTarget(pointer); p.repeat.set(crop.repeatU, crop.repeatV); p.fit.set( crop.repeatU / crop.fitScaleX, crop.repeatV / crop.fitScaleY, ); applyUvCrop( m.geometry.attributes.uv, segments, crop.repeatU, crop.repeatV, ); }; measure(); target.addEventListener("loadedmetadata", measure); target.addEventListener("resize", measure); const ro = new ResizeObserver(measure); ro.observe(target); ro.observe(document.body); return () => { ro.disconnect(); target.removeEventListener("loadedmetadata", measure); target.removeEventListener("resize", measure); }; }, [el, texture, segments, pointer]); useFrame(() => { texture?.update(); const m = mesh.current; if (!m || !el.current || size.height <= 0) return; const pxToWorld = viewport.height / size.height; const fit = fitScale.current; const canvasRect = gl.domElement && typeof gl.domElement.getBoundingClientRect === "function" ? gl.domElement.getBoundingClientRect() : { left: 0, top: 0 }; if (autoReflow && el.current) { const rect = el.current.getBoundingClientRect(); const relLeft = rect.left - canvasRect.left; const relTop = rect.top - canvasRect.top; m.position.x = (relLeft + rect.width / 2 - size.width / 2) * pxToWorld; m.position.y = -(relTop + rect.height / 2 - size.height / 2) * pxToWorld; m.scale.x = rect.width * pxToWorld * fit.x; m.scale.y = rect.height * pxToWorld * fit.y; } }); if (!texture) return null; return ( {material ? ( material(texture, pointer) ) : ( )} ); } export function WebglVideo({ src, className, style, material, webglEnabled = true, segments = 1, zIndex = 0, autoReflow = true, autoPlay = true, muted = true, loop = true, playsInline = true, ...rest }: WebglVideoProps) { const el = useRef>(null); const pointer = usePointerUv(el, { enabled: webglEnabled }); return ( <>