import { useLayoutEffect, useMemo, useRef } from 'react' import { useFrame, useLoader, useThree } from '@react-three/fiber' import * as THREE from 'three' import type { MutableRefObject } from 'react' import type { RavenFx } from './ravenFx' const COUNT = 18 const APEX_Z = -2.8 const CONE_SLOPE = 0.42 const AXIS_SPEED = 5.2 const CAM_Z = 5.2 type Bird = { active: boolean born: boolean depth: number theta: number phase: number flapSpeed: number size: number flip: number delay: number speed: number slope: number } function createBirds(): Bird[] { const birds: Bird[] = [] for (let i = 0; i < COUNT; i++) { const wave = Math.floor(i / 3) const theta = (i / COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.25 birds.push({ active: false, born: false, depth: 0, theta, phase: Math.random() * Math.PI * 2, flapSpeed: 10 + Math.random() * 6, size: 0.2 + Math.random() * 0.28, flip: i % 2 === 0 ? -1 : 1, delay: 0.03 * wave + Math.random() * 0.04, speed: AXIS_SPEED * (0.85 + Math.random() * 0.35), slope: CONE_SLOPE * (0.9 + Math.random() * 0.25), }) } return birds } /** Viewport CSS px → world on the apex plane. Uses the raven canvas rect for correct NDC. */ function screenToApexWorld( sx: number, sy: number, camera: THREE.PerspectiveCamera, glDom: HTMLCanvasElement | null, out: THREE.Vector3, ) { const canvasRect = glDom?.getBoundingClientRect() const left = canvasRect?.left ?? 0 const top = canvasRect?.top ?? 0 const viewW = canvasRect?.width || window.innerWidth const viewH = canvasRect?.height || window.innerHeight const ndcX = ((sx - left) / viewW) * 2 - 1 const ndcY = -((sy - top) / viewH) * 2 + 1 const dist = Math.abs(CAM_Z - APEX_Z) const vFov = THREE.MathUtils.degToRad(camera.fov) const halfH = Math.tan(vFov / 2) * dist const halfW = halfH * (viewW / viewH) out.set(ndcX * halfW, ndcY * halfH, APEX_Z) } type RavenFlockProps = { fx: MutableRefObject } export function RavenFlock({ fx }: RavenFlockProps) { const meshRef = useRef(null) const birds = useMemo(() => createBirds(), []) const dummy = useMemo(() => new THREE.Object3D(), []) const apex = useMemo(() => new THREE.Vector3(0, 0, APEX_Z), []) const texture = useLoader(THREE.TextureLoader, '/raven-silhouette.svg') const gl = useThree((s) => s.gl) useLayoutEffect(() => { texture.colorSpace = THREE.SRGBColorSpace texture.anisotropy = 4 texture.needsUpdate = true }, [texture]) useFrame((state, delta) => { const mesh = meshRef.current if (!mesh) return const cam = state.camera as THREE.PerspectiveCamera // Fixed camera — do NOT recenter on apex (that pinned birds to screen center) cam.position.set(0, 0, CAM_Z) cam.lookAt(0, 0, 0) cam.updateMatrixWorld() const { burst, opacity, apexSx, apexSy } = fx.current if (Number.isFinite(apexSx) && Number.isFinite(apexSy)) { screenToApexWorld(apexSx, apexSy, cam, gl.domElement, apex) } const mat = mesh.material as THREE.MeshBasicMaterial mat.opacity = opacity const dt = Math.min(delta, 0.05) const t = state.clock.elapsedTime for (let i = 0; i < COUNT; i++) { const bird = birds[i] if (!bird.born && burst > bird.delay) { bird.born = true bird.active = true bird.depth = 0.02 + Math.random() * 0.06 } if (!bird.active) { dummy.position.set(0, -40, 0) dummy.scale.set(0, 0, 0) dummy.updateMatrix() mesh.setMatrixAt(i, dummy.matrix) continue } bird.depth += bird.speed * dt // Cone tip at portal; base // screen (XY) const radius = bird.depth * bird.slope const x = apex.x + Math.cos(bird.theta) * radius const y = apex.y + Math.sin(bird.theta) * radius const z = apex.z + bird.depth const flap = 1 + Math.sin(t * bird.flapSpeed + bird.phase) * 0.18 const grow = THREE.MathUtils.smoothstep(bird.depth, 0.15, 3.2) const s = bird.size * flap * (0.15 + grow * 1.55) dummy.position.set(x, y, z) dummy.quaternion.copy(cam.quaternion) dummy.rotateY(bird.flip < 0 ? Math.PI : 0) dummy.rotateZ(Math.sin(t * bird.flapSpeed + bird.phase) * 0.16) dummy.scale.set(s * 1.45, s, 1) dummy.updateMatrix() mesh.setMatrixAt(i, dummy.matrix) if (bird.depth > 7.5) { bird.active = false } } mesh.instanceMatrix.needsUpdate = true }) return ( ) }