responsive-image-canvas/src/components/ImageDistortion.tsx
BaekRyang 15144240b7 Fix sprite aspect ratio distortion and bump version to 1.5.2
- 스프라이트 파티클의 종횡비 왜곡 방지를 위한 해상도 보정 로직 추가
- SpriteEffectManager 및 Instance의 update 메서드에 해상도 인자 추가
- NDC 좌표계 기준 OrthographicCamera의 종횡비 보정 구현
- 패키지 버전을 1.5.2로 업데이트
2026-03-13 15:23:12 +09:00

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>
);
};