diff --git a/src/components/scenes/ThreeCanvas.tsx b/src/components/scenes/ThreeCanvas.tsx new file mode 100644 index 0000000..541204e --- /dev/null +++ b/src/components/scenes/ThreeCanvas.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; + +export type SceneTheme = 'light' | 'dark'; + +export interface SceneContext { + renderer: THREE.WebGLRenderer; + pointer: THREE.Vector2; + theme: SceneTheme; + reduceMotion: boolean; +} + +export interface SceneController { + scene: THREE.Scene; + camera: THREE.Camera; + resize: (width: number, height: number) => void; + update?: (elapsed: number, delta: number) => void; + dispose?: () => void; +} + +export type SceneFactory = (context: SceneContext) => SceneController; + +interface ThreeCanvasProps { + className: string; + createScene: SceneFactory; + theme: SceneTheme; +} + +function disposeObject(object: THREE.Object3D) { + const disposable = object as THREE.Object3D & { + geometry?: THREE.BufferGeometry; + material?: THREE.Material | THREE.Material[]; + }; + + disposable.geometry?.dispose(); + const materials = Array.isArray(disposable.material) + ? disposable.material + : disposable.material + ? [disposable.material] + : []; + + materials.forEach((material) => { + Object.values(material).forEach((value) => { + if (value instanceof THREE.Texture) value.dispose(); + }); + material.dispose(); + }); +} + +export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps) { + const hostRef = useRef(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); + const pointer = new THREE.Vector2(); + const controller = createScene({ renderer, pointer, theme, reduceMotion }); + const clock = new THREE.Clock(); + let frame = 0; + let visible = true; + + renderer.setClearColor(0x000000, 0); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.domElement.setAttribute('aria-hidden', 'true'); + host.appendChild(renderer.domElement); + + const render = () => renderer.render(controller.scene, controller.camera); + const resize = () => { + const bounds = host.getBoundingClientRect(); + if (!bounds.width || !bounds.height) return; + + renderer.setPixelRatio(Math.min(window.devicePixelRatio, bounds.width < 640 ? 1.25 : 1.65)); + renderer.setSize(bounds.width, bounds.height, false); + controller.resize(bounds.width, bounds.height); + render(); + }; + + const animate = () => { + if (!visible || reduceMotion) return; + const delta = Math.min(clock.getDelta(), 0.05); + controller.update?.(clock.elapsedTime, delta); + render(); + frame = window.requestAnimationFrame(animate); + }; + + const resizeObserver = new ResizeObserver(resize); + resizeObserver.observe(host); + + const visibilityObserver = new IntersectionObserver( + ([entry]) => { + visible = entry.isIntersecting; + window.cancelAnimationFrame(frame); + if (visible && !reduceMotion) { + clock.getDelta(); + frame = window.requestAnimationFrame(animate); + } + }, + { rootMargin: '160px' }, + ); + visibilityObserver.observe(host); + + const onPointerMove = (event: PointerEvent) => { + if (reduceMotion || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return; + pointer.set( + (event.clientX / window.innerWidth) * 2 - 1, + -((event.clientY / window.innerHeight) * 2 - 1), + ); + }; + window.addEventListener('pointermove', onPointerMove, { passive: true }); + + resize(); + if (!reduceMotion) frame = window.requestAnimationFrame(animate); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener('pointermove', onPointerMove); + resizeObserver.disconnect(); + visibilityObserver.disconnect(); + controller.dispose?.(); + controller.scene.traverse(disposeObject); + renderer.dispose(); + renderer.domElement.remove(); + }; + }, [createScene, theme]); + + return