# Responsive Image Canvas GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다. Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션, 마우스/터치 인터랙션, 파티클 이펙트를 구현합니다. ## 특징 - GPU 가속 렌더링 (Three.js + WebGL) - 최대 8개의 독립적인 왜곡 영역 지원 - 스프링 물리 기반 마우스/터치 인터랙션 - 이모지 & 스프라이트 시트 파티클 이펙트 - 모션 프리셋 & 커스텀 이징 함수 - 렌즈 왜곡 (볼록/오목) 효과 - 영역 편집을 위한 에디터 컴포넌트 - TypeScript & ESM/CJS 지원 ## 설치 ```bash npm install @baekryang/responsive-image-canvas ``` ### Peer Dependencies ```bash npm install react react-dom three ``` | 패키지 | 버전 | |--------|------| | `react` | `^18.0.0 \|\| ^19.0.0` | | `react-dom` | `^18.0.0 \|\| ^19.0.0` | | `three` | `>=0.150.0` | --- ## 기본 사용법 ### 이미지 왜곡 표시 (View Mode) ```tsx import { ImageDistortion } from '@baekryang/responsive-image-canvas'; import type { DistortionArea } from '@baekryang/responsive-image-canvas'; const areas: DistortionArea[] = [ { id: 'area-1', basePoints: [ { x: 0.3, y: 0.3 }, // 좌상단 { x: 0.7, y: 0.3 }, // 우상단 { x: 0.7, y: 0.7 }, // 우하단 { x: 0.3, y: 0.7 }, // 좌하단 ], movement: { preset: 'horizontal', vectorA: { x: 0.1, y: 0 }, vectorB: { x: -0.1, y: 0 }, duration: 2.0, easing: 'easeInOut', strength: 0.15, }, distortionStrength: 0.5, progress: 0, dragVector: { x: 0, y: 0 }, }, ]; function App() { return ( ); } ``` > **좌표계**: 모든 좌표는 **정규화 좌표(0.0 ~ 1.0)** 를 사용합니다. `(0, 0)`은 이미지 좌상단, `(1, 1)`은 우하단입니다. --- ## 컴포넌트 ### `` 이미지 왜곡 및 인터랙션 렌더링을 담당하는 메인 컴포넌트입니다. ```tsx ``` | Prop | 타입 | 필수 | 설명 | |------|------|:----:|------| | `imageSrc` | `string` | O | 이미지 URL | | `areas` | `DistortionArea[]` | O | 왜곡 영역 배열 | | `mouseInteraction` | `MouseInteractionConfig` | | 마우스/터치 인터랙션 설정 | | `spriteEffectAreas` | `SpriteEffectArea[]` | | 파티클 이펙트 영역 | | `isPlaying` | `boolean` | | 애니메이션 재생 여부 (기본: `true`) | | `style` | `CSSProperties` | | 컨테이너 스타일 | | `className` | `string` | | 컨테이너 클래스 | ### `` 영역 편집을 위한 시각적 에디터 오버레이입니다. 꼭짓점 드래그, 영역 선택 등을 제공합니다. ```tsx { /* ... */ }} onUpdateArea={(areaId, updates) => { /* ... */ }} draggingPointIndex={draggingIndex} onStartDragging={(index) => { /* ... */ }} onStopDragging={() => { /* ... */ }} style={editorCanvasStyle} showEditor={true} onSelectArea={(areaId) => { /* ... */ }} spriteEffectAreas={spriteEffectAreas} /> ``` | Prop | 타입 | 필수 | 설명 | |------|------|:----:|------| | `areas` | `DistortionArea[]` | O | 왜곡 영역 배열 | | `selectedAreaId` | `string \| null` | O | 선택된 영역 ID | | `imageSrc` | `string` | O | 이미지 URL | | `width` | `number` | O | 이미지 너비 (px) | | `height` | `number` | O | 이미지 높이 (px) | | `onUpdatePoint` | `(areaId, pointIndex, point) => void` | O | 꼭짓점 이동 콜백 | | `onUpdateArea` | `(areaId, updates) => void` | O | 영역 업데이트 콜백 | | `draggingPointIndex` | `number \| null` | O | 드래그 중인 포인트 인덱스 | | `onStartDragging` | `(index) => void` | O | 드래그 시작 콜백 | | `onStopDragging` | `() => void` | O | 드래그 종료 콜백 | | `style` | `EditorCanvasStyle` | | 에디터 UI 스타일 | | `showEditor` | `boolean` | | 에디터 표시 여부 (기본: `true`) | | `onSelectArea` | `(areaId) => void` | | 영역 선택 콜백 | | `spriteEffectAreas` | `SpriteEffectArea[]` | | 파티클 이펙트 영역 | ### ``, `` 영역 목록 관리 및 파라미터 편집을 위한 보조 에디터 컴포넌트입니다. --- ## Hooks ### `useDistortionEditor` 에디터 상태 관리를 위한 핵심 훅입니다. ```tsx import { useDistortionEditor, DEFAULT_AREA } from '@baekryang/responsive-image-canvas'; const { state, // { areas, selectedAreaId, editMode, draggingPointIndex } selectArea, // (areaId: string | null) => void addArea, // (area: DistortionArea) => void removeArea, // (areaId: string) => void updateArea, // (areaId: string, updates: Partial) => void updatePoint, // (areaId: string, pointIndex: number, point: Point) => void startDragging, // (pointIndex: number) => void stopDragging, // () => void getSelectedArea, // () => DistortionArea | null } = useDistortionEditor(initialAreas); ``` #### 사용 예시 ```tsx // 새 영역 추가 const handleAddArea = () => { addArea({ id: `area-${Date.now()}`, basePoints: [ { x: 0.3, y: 0.3 }, { x: 0.7, y: 0.3 }, { x: 0.7, y: 0.7 }, { x: 0.3, y: 0.7 }, ], movement: { preset: 'none', vectorA: { x: 0, y: 0 }, vectorB: { x: 0, y: 0 }, duration: DEFAULT_AREA.DURATION, easing: DEFAULT_AREA.EASING, strength: 0.15, }, distortionStrength: DEFAULT_AREA.DISTORTION_STRENGTH, progress: 0, dragVector: { x: 0, y: 0 }, }); }; // 선택된 영역 업데이트 updateArea(state.selectedAreaId, { distortionStrength: 0.8, lensEffect: { strength: 0.3 }, }); ``` ### `useMouseInteraction` 마우스/터치 기반 스프링 물리 인터랙션을 제공합니다. ```tsx import { useMouseInteraction } from '@baekryang/responsive-image-canvas'; const { updateInteraction, // (areas, deltaTime) => DistortionArea[] updateConfig, // (newConfig: Partial) => void reset, // () => void isDragging, // () => boolean getInteractingAreaIndices, // () => Set getMouseState, // () => MouseState } = useMouseInteraction(containerRef, mouseConfig); ``` ### `useAnimationFrame` requestAnimationFrame 기반 애니메이션 루프입니다. ```tsx import { useAnimationFrame } from '@baekryang/responsive-image-canvas'; useAnimationFrame((deltaTime) => { // deltaTime: 초 단위 }, isPlaying); ``` --- ## 마우스 인터랙션 스프링 물리 기반의 마우스/터치 인터랙션을 설정합니다. ```tsx import type { MouseInteractionConfig } from '@baekryang/responsive-image-canvas'; const mouseConfig: MouseInteractionConfig = { enabled: true, physics: { stiffness: 80, // 탄성 계수 (높을수록 빠르게 반응) damping: 8, // 감쇠 계수 (높을수록 빠르게 정지) mass: 1.0, // 질량 (높을수록 무겁게 반응) influenceRadius: 0.15, // 영향 반경 (정규화 좌표) maxStrength: 0.5, // 최대 왜곡 강도 }, minVelocity: 0.1, maxVelocity: 1.0, velocityMultiplier: 0.15, }; ``` ### 물리 프리셋 예시 ```tsx // 부드러운 반응 const soft = { stiffness: 30, damping: 20, mass: 2.0, maxStrength: 0.3, influenceRadius: 0.15 }; // 탄성 있는 반응 const bouncy = { stiffness: 150, damping: 5, mass: 1.0, maxStrength: 0.5, influenceRadius: 0.15 }; // 무거운 반응 const heavy = { stiffness: 80, damping: 15, mass: 3.0, maxStrength: 0.4, influenceRadius: 0.15 }; ``` 영역별로 개별 물리 설정도 가능합니다: ```tsx const area: DistortionArea = { // ... physics: { stiffness: 150, damping: 5, mass: 1.0, influenceRadius: 0.15, maxStrength: 0.5, }, }; ``` --- ## 파티클 이펙트 이미지 위에 이모지 또는 스프라이트 이미지 기반 파티클 이펙트를 추가합니다. ### 이모지 파티클 ```tsx import type { SpriteEffectArea, SpriteEffectConfig } from '@baekryang/responsive-image-canvas'; const spriteEffectAreas: SpriteEffectArea[] = [ { id: 'effect-area-1', position: { x: 0.5, y: 0.5 }, // 이펙트 중심 (정규화 좌표) radius: 0.15, // 방출 반경 effects: [ { id: 'hearts', emoji: '❤️', // 이모지 → Canvas 텍스처로 자동 변환 trigger: 'touch', // 'touch': 클릭 시 발사 | 'ambient': 지속 방출 blendMode: 'normal', maxParticles: 40, burstCount: 8, // touch 트리거 시 한번에 생성할 파티클 수 lifetime: [1, 2.5], // [최소, 최대] 수명 (초) initialScale: [0.04, 0.08], // [최소, 최대] 크기 (정규화) initialSpeed: [0.06, 0.12], // [최소, 최대] 초기 속도 emitAngle: [0, 360], // 방출 각도 범위 (도) overLifetime: { scale: [1, 0.3], // 수명에 따른 크기 변화 opacity: [1, 0], // 수명에 따른 투명도 변화 velocityDamping: 0.94, // 속도 감쇠 (0~1) }, }, ], }, ]; ``` ### 지속 방출 (Ambient) 이펙트 ```tsx const fireEffect: SpriteEffectConfig = { id: 'fire', emoji: '🔥', trigger: 'ambient', blendMode: 'normal', maxParticles: 30, emitRate: 3, // 초당 생성 파티클 수 lifetime: [1, 2.5], initialScale: [0.04, 0.08], initialSpeed: [0.02, 0.05], emitAngle: [250, 290], // 위쪽 방향으로 제한 emitRadius: 0.15, overLifetime: { scale: [1, 0.4], opacity: [1, 0], velocityDamping: 0.97, }, }; ``` ### 스프라이트 이미지 사용 이모지 대신 URL 기반 스프라이트 이미지를 사용할 수 있습니다: ```tsx const effect: SpriteEffectConfig = { id: 'custom-sprite', spriteUrl: '/sprites/particle.png', // emoji 대신 spriteUrl 사용 trigger: 'ambient', // ... 나머지 설정 동일 }; ``` ### 스프라이트 시트 ```tsx const effect: SpriteEffectConfig = { id: 'animated-sprite', spriteUrl: '/sprites/explosion-sheet.png', spriteSheet: { columns: 4, rows: 4, totalFrames: 16, fps: 24, loop: false, }, // ... 나머지 설정 }; ``` --- ## 모션 프리셋 영역 애니메이션에 사용할 수 있는 빌트인 모션 프리셋입니다. | 프리셋 | 동작 | |--------|------| | `none` | 움직임 없음 | | `horizontal` | 좌우 왕복 | | `vertical` | 상하 왕복 | | `rotate-cw` | 시계 방향 회전 | | `rotate-ccw` | 반시계 방향 회전 | | `pulse` | 맥동 (확대/축소) | | `diagonal-1` | 대각선 (↗↙) | | `diagonal-2` | 대각선 (↘↖) | ```tsx const area: DistortionArea = { // ... movement: { preset: 'horizontal', vectorA: { x: 0.1, y: 0 }, vectorB: { x: -0.1, y: 0 }, duration: 2.0, easing: 'easeInOut', strength: 0.15, }, }; ``` ### 커스텀 모션 프리셋 등록 ```tsx import { registerMotionPresets } from '@baekryang/responsive-image-canvas'; registerMotionPresets({ 'wave': (strength) => ({ x: strength * 0.5, y: strength * 0.3 }), 'spiral': (strength) => ({ x: strength, y: 0 }), }, ['spiral']); // 두 번째 인자: 회전형 프리셋 이름 목록 ``` ### 이징 함수 | 이징 | 설명 | |------|------| | `linear` | 등속 | | `easeIn` / `easeInQuad` / `easeInCubic` | 느리게 시작 → 빠르게 | | `easeOut` / `easeOutQuad` / `easeOutCubic` | 빠르게 시작 → 느리게 | | `easeInOut` | 양쪽 모두 부드럽게 | --- ## 렌즈 효과 & 스텝 이징 ### 렌즈 왜곡 영역에 볼록/오목 렌즈 효과를 적용합니다. ```tsx const area: DistortionArea = { // ... lensEffect: { strength: 0.5, // 양수: 볼록, 음수: 오목 (-1.0 ~ 1.0) }, }; ``` ### 스텝 이징 애니메이션을 이산적인 단계로 분할합니다. ```tsx const area: DistortionArea = { // ... snapSteps: 3, // 0: 부드러운 연속 애니메이션, 1~5: 단계 수 }; ``` --- ## 에디터 스타일 커스터마이징 `EditorCanvas`의 시각적 요소를 커스터마이징할 수 있습니다. ```tsx import type { EditorCanvasStyle } from '@baekryang/responsive-image-canvas'; const editorStyle: EditorCanvasStyle = { // 가이드 원 (최대 3단계) circleLevels: [ { radius: 0.5, opacity: 0.4, lineWidth: 2.5, color: 'rgba(255,107,157,0.8)', dashPattern: [10, 5] }, { radius: 0.33, opacity: 0.7, lineWidth: 3, color: 'rgba(255,107,157,0.9)', dashPattern: [8, 4] }, { radius: 0.167, opacity: 1.0, lineWidth: 4, color: 'rgba(255,107,157,1)', dashPattern: [6, 3] }, ], circleFillColor: 'rgba(255, 107, 157, 0.08)', // 중심점 centerPoint: { radius: 6, fillColor: 'rgba(255, 107, 157, 1)', strokeColor: 'rgba(255, 255, 255, 0.9)', strokeWidth: 2.5, }, // 꼭짓점 핸들 pointHandle: { size: 18, fillColor: '#FF6B9D', strokeColor: '#ffffff', strokeWidth: 2.5, labelColor: '#FF6B9D', labelFontSize: 12, }, // 영역 외곽선 areaOutline: { selectedColor: '#FF6B9D', unselectedColor: 'rgba(0, 48, 255, 0.55)', selectedWidth: 2.5, unselectedWidth: 4, unselectedDashPattern: [6, 4], selectedFillColor: 'rgba(255, 107, 157, 0.12)', unselectedFillColor: 'rgba(156, 163, 175, 0.05)', }, }; ``` --- ## 데이터 영속화 `DistortionArea`에서 런타임 필드(`progress`, `dragVector`)를 제외하고 저장합니다. ### 저장 ```tsx const areasToSave = state.areas.map(area => ({ id: area.id, basePoints: area.basePoints, movement: area.movement, distortionStrength: area.distortionStrength, physics: area.physics, lensEffect: area.lensEffect, snapSteps: area.snapSteps, })); // DB에 저장 (Firestore, REST API 등) await saveToDatabase(areasToSave); ``` ### 로드 ```tsx const loadedAreas: DistortionArea[] = savedData.map(area => ({ ...area, basePoints: area.basePoints as [Point, Point, Point, Point], movement: { ...area.movement, easing: area.movement.easing as EasingFunction, }, progress: 0, // 런타임 상태 초기화 dragVector: { x: 0, y: 0 }, // 런타임 상태 초기화 })); ``` --- ## 통합 예제: View + Editor ```tsx import { ImageDistortion, EditorCanvas, useDistortionEditor, DEFAULT_AREA, } from '@baekryang/responsive-image-canvas'; import type { DistortionArea, MouseInteractionConfig, SpriteEffectArea, EditorCanvasStyle, } from '@baekryang/responsive-image-canvas'; function ImageEditor({ imageSrc, imageWidth, imageHeight }) { const [isEditing, setIsEditing] = useState(true); const { state, selectArea, addArea, removeArea, updateArea, updatePoint, startDragging, stopDragging, } = useDistortionEditor([]); const mouseConfig: MouseInteractionConfig = { enabled: !isEditing, physics: { stiffness: 80, damping: 8, mass: 1.0, influenceRadius: 0.15, maxStrength: 0.5 }, }; return (
{isEditing ? ( updateArea(id, updates)} draggingPointIndex={state.draggingPointIndex} onStartDragging={startDragging} onStopDragging={stopDragging} onSelectArea={selectArea} /> ) : ( )}
); } ``` --- ## 타입 레퍼런스 ### 핵심 타입 ```typescript interface Point { x: number; // 0.0 ~ 1.0 y: number; // 0.0 ~ 1.0 } interface DistortionArea { id: string; basePoints: [Point, Point, Point, Point]; // [좌상, 우상, 우하, 좌하] movement: DistortionMovement; distortionStrength: number; // 0.0 ~ 1.0 progress: number; // 0.0 ~ 1.0 (런타임) dragVector: Point; // (런타임) physics?: SpringPhysicsConfig; lensEffect?: { strength: number }; // -1.0 ~ 1.0 snapSteps?: number; // 0 ~ 5 } interface DistortionMovement { preset?: MotionPreset; vectorA: Point; vectorB: Point; duration: number; // 초 easing: EasingFunction; strength?: number; } ``` ### 인터랙션 타입 ```typescript interface SpringPhysicsConfig { stiffness: number; damping: number; mass: number; influenceRadius: number; maxStrength: number; } interface MouseInteractionConfig { enabled: boolean; physics: SpringPhysicsConfig; minVelocity?: number; maxVelocity?: number; velocityMultiplier?: number; } interface MouseState { position: Point | null; prevPosition: Point | null; velocity: Point; acceleration: Point; isHovering: boolean; isDragging: boolean; } ``` ### 파티클 이펙트 타입 ```typescript type SpriteEffectTrigger = 'ambient' | 'touch'; type SpriteBlendMode = 'normal' | 'additive'; interface SpriteEffectConfig { id: string; trigger: SpriteEffectTrigger; emoji?: string; // 이모지 (spriteUrl과 택1) spriteUrl?: string; // 스프라이트 이미지 URL blendMode?: SpriteBlendMode; maxParticles: number; emitRate?: number; // ambient용 (초당 생성 수) burstCount?: number; // touch용 (클릭당 생성 수) lifetime: [number, number]; // [최소, 최대] 초 initialScale: [number, number]; initialSpeed: [number, number]; emitAngle?: [number, number]; // 도 emitOffset?: Point; emitRadius?: number; overLifetime?: SpriteParticleOverLifetime; spriteSheet?: SpriteSheetConfig; } interface SpriteEffectArea { id: string; position: Point; radius?: number; // 기본: 0.1 effects: SpriteEffectConfig[]; } interface SpriteParticleOverLifetime { scale?: number[]; // [시작, 끝] 또는 [시작, 중간, 끝] opacity?: number[]; rotationSpeed?: number; // 라디안/초 velocityDamping?: number; // 0 ~ 1 } interface SpriteSheetConfig { columns: number; rows: number; totalFrames: number; fps: number; loop?: boolean; } ``` ### 이징 & 프리셋 타입 ```typescript type EasingFunction = | 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic'; type BuiltInMotionPreset = | 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2'; type MotionPreset = BuiltInMotionPreset | (string & {}); ``` --- ## 상수 ```typescript import { DEFAULT_AREA, SHADER_CONFIG, ANIMATION_CONFIG } from '@baekryang/responsive-image-canvas'; DEFAULT_AREA.DISTORTION_STRENGTH // 0.5 DEFAULT_AREA.DURATION // 2.0 DEFAULT_AREA.EASING // 'easeInOut' DEFAULT_AREA.LENS_STRENGTH // 0 DEFAULT_AREA.SNAP_STEPS // 0 SHADER_CONFIG.MAX_AREAS // 8 ANIMATION_CONFIG.TARGET_FPS // 60 ``` --- ## 유틸리티 ```typescript import { applyEasing, registerMotionPreset, registerMotionPresets, unregisterMotionPreset, getRegisteredPresets, presetToVector, isRotationPreset, } from '@baekryang/responsive-image-canvas'; // 이징 함수 직접 사용 const easedValue = applyEasing(0.5, 'easeInOut'); // 0.5 // 커스텀 모션 프리셋 registerMotionPreset('wobble', (strength) => ({ x: strength * Math.sin(Date.now() * 0.001), y: 0, })); // 프리셋 → 벡터 변환 const vector = presetToVector('horizontal', 0.15); // { x: 0.15, y: 0 } ``` --- ## 제한사항 - WebGL을 지원하지 않는 브라우저에서는 동작하지 않습니다 - 최대 8개의 왜곡 영역만 지원합니다 - `emoji`와 `spriteUrl`은 하나만 사용 가능합니다 (둘 다 없으면 텍스처 로드 실패) ## 라이선스 MIT