- 스프라이트 파티클의 종횡비 왜곡 방지를 위한 해상도 보정 로직 추가 - SpriteEffectManager 및 Instance의 update 메서드에 해상도 인자 추가 - NDC 좌표계 기준 OrthographicCamera의 종횡비 보정 구현 - 패키지 버전을 1.5.2로 업데이트
329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import * as THREE from 'three';
|
|
import {type DistortionArea, SpriteEffectArea} from '@/types';
|
|
import { ThreeScene } from '@/engine/ThreeScene';
|
|
import { ShaderManager } from '@/engine/ShaderManager';
|
|
import { AnimationLoop } from '@/engine/AnimationLoop';
|
|
import { SpriteEffectManager } from '@/engine/SpriteEffectManager';
|
|
import { useAnimationFrame } from '@/hooks/useAnimationFrame';
|
|
import { useMouseInteraction } from '@/hooks/useMouseInteraction';
|
|
import { SHADER_CONFIG } from '@/utils/constants';
|
|
import { MouseInteractionConfig } from '@/types/interaction';
|
|
|
|
/**
|
|
* ImageDistortion 컴포넌트 Props
|
|
*/
|
|
export interface ImageDistortionProps {
|
|
/** 이미지 소스 URL */
|
|
imageSrc: string;
|
|
/** 왜곡 영역 배열 */
|
|
areas: DistortionArea[];
|
|
/** 버텍스 셰이더 경로 (선택사항) */
|
|
vertexShaderPath?: string;
|
|
/** 프래그먼트 셰이더 경로 (선택사항) */
|
|
fragmentShaderPath?: string;
|
|
/** 컨테이너 스타일 */
|
|
style?: React.CSSProperties;
|
|
/** 컨테이너 클래스명 */
|
|
className?: string;
|
|
/** 마우스 인터랙션 설정 */
|
|
mouseInteraction?: MouseInteractionConfig;
|
|
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
|
spriteEffectAreas?: SpriteEffectArea[];
|
|
}
|
|
|
|
/**
|
|
* GPU 가속 이미지 왜곡 컴포넌트
|
|
* Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 효과를 제공합니다.
|
|
*/
|
|
export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
|
imageSrc,
|
|
areas,
|
|
vertexShaderPath,
|
|
fragmentShaderPath,
|
|
style,
|
|
className,
|
|
mouseInteraction,
|
|
spriteEffectAreas = [],
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const sceneRef = useRef<ThreeScene | null>(null);
|
|
const shaderManagerRef = useRef<ShaderManager>(new ShaderManager());
|
|
const textureRef = useRef<THREE.Texture | null>(null);
|
|
const spriteManagerRef = useRef<SpriteEffectManager | null>(null);
|
|
const currentAreasRef = useRef<DistortionArea[]>(areas);
|
|
|
|
const [isReady, setIsReady] = useState(false);
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
const [currentAreas, setCurrentAreas] = useState<DistortionArea[]>(areas);
|
|
|
|
// 마우스 인터랙션 훅
|
|
const mouseInteractionHook = useMouseInteraction(
|
|
containerRef,
|
|
mouseInteraction || {
|
|
enabled: false,
|
|
physics: {
|
|
stiffness: 100,
|
|
damping: 10,
|
|
mass: 1,
|
|
influenceRadius: 0.2,
|
|
maxStrength: 1.0,
|
|
},
|
|
}
|
|
);
|
|
|
|
// 영역 변경 시 상태 업데이트
|
|
useEffect(() => {
|
|
setCurrentAreas(areas);
|
|
}, [areas]);
|
|
|
|
// currentAreasRef 동기화
|
|
useEffect(() => {
|
|
currentAreasRef.current = currentAreas;
|
|
}, [currentAreas]);
|
|
|
|
// 스프라이트 이펙트 매니저 초기화
|
|
useEffect(() => {
|
|
if (!sceneRef.current || !isReady) return;
|
|
const manager = new SpriteEffectManager();
|
|
manager.attachToScene(sceneRef.current.getScene());
|
|
spriteManagerRef.current = manager;
|
|
return () => {
|
|
manager.dispose();
|
|
spriteManagerRef.current = null;
|
|
};
|
|
}, [isReady]);
|
|
|
|
// 이펙트 영역 변경 또는 매니저 준비 시 스프라이트 이펙트 동기화
|
|
useEffect(() => {
|
|
spriteManagerRef.current?.syncEffects(spriteEffectAreas);
|
|
}, [spriteEffectAreas, isReady]);
|
|
|
|
// 마우스 인터랙션 설정 변경 시 업데이트
|
|
useEffect(() => {
|
|
if (mouseInteraction) {
|
|
mouseInteractionHook.updateConfig(mouseInteraction);
|
|
}
|
|
}, [mouseInteraction, mouseInteractionHook]);
|
|
|
|
// Three.js 씬 초기화
|
|
useEffect(() => {
|
|
console.log('[ImageDistortion] useEffect 실행, containerRef.current:', containerRef.current);
|
|
|
|
if (!containerRef.current) {
|
|
console.warn('[ImageDistortion] containerRef.current가 null입니다. 컴포넌트가 제대로 마운트되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
console.log('[ImageDistortion] v1.5.1 초기화 시작');
|
|
const scene = new ThreeScene(containerRef.current);
|
|
sceneRef.current = scene;
|
|
|
|
// 셰이더 로드
|
|
const vertPath = vertexShaderPath || '/shaders/distortion.vert.glsl';
|
|
const fragPath = fragmentShaderPath || '/shaders/distortion.frag.glsl';
|
|
|
|
console.log('[ImageDistortion] 셰이더 로드 시도:', { vertPath, fragPath });
|
|
|
|
shaderManagerRef.current
|
|
.loadShaders(vertPath, fragPath)
|
|
.then(({ vertex, fragment }) => {
|
|
console.log('[ImageDistortion] 셰이더 로드 성공');
|
|
scene.setShaderMaterial(vertex, fragment);
|
|
setIsReady(true);
|
|
})
|
|
.catch((error) => {
|
|
console.error('[ImageDistortion] 셰이더 로드 실패:', error);
|
|
});
|
|
|
|
return () => {
|
|
scene.dispose();
|
|
if (textureRef.current) {
|
|
textureRef.current.dispose();
|
|
}
|
|
};
|
|
}, [vertexShaderPath, fragmentShaderPath]);
|
|
|
|
// 이미지 텍스처 로드
|
|
useEffect(() => {
|
|
if (!imageSrc || !isReady) {
|
|
console.log('[ImageDistortion] 이미지 로드 스킵:', { imageSrc, isReady });
|
|
return;
|
|
}
|
|
|
|
console.log('[ImageDistortion] 이미지 로드 시작:', imageSrc);
|
|
setImageLoaded(false);
|
|
|
|
const loader = new THREE.TextureLoader();
|
|
loader.load(
|
|
imageSrc,
|
|
(texture) => {
|
|
console.log('[ImageDistortion] 이미지 로드 성공!', {
|
|
width: texture.image.width,
|
|
height: texture.image.height
|
|
});
|
|
textureRef.current = texture;
|
|
setImageLoaded(true);
|
|
if (sceneRef.current) {
|
|
sceneRef.current.updateUniforms({
|
|
u_texture: { value: texture },
|
|
});
|
|
sceneRef.current.render();
|
|
console.log('[ImageDistortion] 텍스처 업데이트 및 렌더링 완료');
|
|
}
|
|
},
|
|
(progress) => {
|
|
console.log('[ImageDistortion] 이미지 로딩 중...',
|
|
Math.round((progress.loaded / progress.total) * 100) + '%'
|
|
);
|
|
},
|
|
(error) => {
|
|
console.error('[ImageDistortion] 이미지 로드 실패:', error);
|
|
setImageLoaded(false);
|
|
}
|
|
);
|
|
|
|
return () => {
|
|
if (textureRef.current) {
|
|
textureRef.current.dispose();
|
|
textureRef.current = null;
|
|
}
|
|
};
|
|
}, [imageSrc, isReady]);
|
|
|
|
// 셰이더 유니폼 업데이트
|
|
useEffect(() => {
|
|
if (!sceneRef.current || !isReady) return;
|
|
|
|
// 현재 해상도 가져오기
|
|
const resolution = sceneRef.current.getResolution();
|
|
|
|
// 포인트 배열 생성
|
|
// UI는 좌상단 (0,0), WebGL은 좌하단 (0,0)이므로 y 좌표를 반전
|
|
const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2);
|
|
currentAreas.forEach((area, areaIndex) => {
|
|
area.basePoints.forEach((point, pointIndex) => {
|
|
const index = (areaIndex * 4 + pointIndex) * 2;
|
|
points[index] = point.x;
|
|
points[index + 1] = 1.0 - point.y; // y 좌표 반전
|
|
});
|
|
});
|
|
|
|
// 드래그 벡터 배열 생성
|
|
// dragVector도 y 좌표계를 맞춰야 하므로 y를 반전
|
|
const dragVectors = new Float32Array(SHADER_CONFIG.MAX_DRAG_VECTORS * 2);
|
|
currentAreas.forEach((area, index) => {
|
|
const baseIndex = index * 2;
|
|
dragVectors[baseIndex] = area.dragVector.x;
|
|
dragVectors[baseIndex + 1] = -area.dragVector.y; // y 방향 반전
|
|
});
|
|
|
|
// 강도 배열 생성
|
|
const strengths = new Float32Array(SHADER_CONFIG.MAX_STRENGTHS);
|
|
currentAreas.forEach((area, index) => {
|
|
strengths[index] = area.distortionStrength;
|
|
});
|
|
|
|
// 렌즈 효과 배열 생성
|
|
const lensEffects = new Float32Array(SHADER_CONFIG.MAX_LENS_EFFECTS);
|
|
currentAreas.forEach((area, index) => {
|
|
lensEffects[index] = area.lensEffect?.strength ?? 0;
|
|
});
|
|
|
|
sceneRef.current.updateUniforms({
|
|
u_numAreas: { value: currentAreas.length },
|
|
u_points: { value: points },
|
|
u_dragVectors: { value: dragVectors },
|
|
u_distortionStrengths: { value: strengths },
|
|
u_lensEffects: { value: lensEffects },
|
|
});
|
|
|
|
sceneRef.current.render();
|
|
}, [currentAreas, isReady]);
|
|
|
|
// 애니메이션 루프
|
|
const animationCallback = useCallback((deltaTime: number) => {
|
|
if (!isReady) return;
|
|
|
|
setCurrentAreas((prevAreas) => {
|
|
// 현재 인터랙션 중인 영역 인덱스 가져오기
|
|
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || new Set<number>();
|
|
|
|
// 1. 자동 애니메이션 업데이트
|
|
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
|
|
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
|
|
|
|
// 인터랙션 중인 영역만 dragVector를 0으로 설정
|
|
if (interactingIndices.size > 0) {
|
|
updatedAreas = updatedAreas.map((area, index) => {
|
|
if (interactingIndices.has(index)) {
|
|
return {
|
|
...area,
|
|
dragVector: { x: 0, y: 0 }
|
|
};
|
|
}
|
|
return area;
|
|
});
|
|
}
|
|
|
|
// 2. 마우스 인터랙션 적용 (기존 dragVector에 스프링 변위 추가)
|
|
if (mouseInteraction?.enabled) {
|
|
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
|
|
}
|
|
|
|
return updatedAreas;
|
|
});
|
|
|
|
// 스프라이트 이펙트 업데이트 (왜곡 영역과 독립적)
|
|
if (spriteManagerRef.current) {
|
|
const mouseState = mouseInteractionHook.getMouseState();
|
|
const resolution = sceneRef.current?.getResolution() ?? { x: 1, y: 1 };
|
|
spriteManagerRef.current.update(
|
|
spriteEffectAreas,
|
|
deltaTime,
|
|
{
|
|
position: mouseState.position ?? null,
|
|
isDragging: mouseState.isDragging,
|
|
},
|
|
resolution,
|
|
);
|
|
|
|
// 스프라이트 메쉬 변경 후 렌더링 필요
|
|
sceneRef.current?.render();
|
|
}
|
|
}, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
|
|
|
|
// 애니메이션 루프 실행
|
|
useAnimationFrame(animationCallback, true);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
position: 'relative',
|
|
...style,
|
|
}}
|
|
className={className}
|
|
>
|
|
{!imageLoaded && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
background: 'rgba(0, 0, 0, 0.7)',
|
|
color: 'white',
|
|
padding: '20px',
|
|
borderRadius: '8px',
|
|
zIndex: 999,
|
|
}}
|
|
>
|
|
이미지 로딩 중...
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |