- README.md에 마우스 인터랙션 및 파티클 이펙트 설명 추가 - 에디터 컴포넌트 및 관련 훅(useDistortionEditor 등) 문서화 - 설치 가이드 및 피어 디펜던시 정보 업데이트 - 패키지 버전을 1.5.0으로 상향 조정 - .claude 로컬 설정의 허용된 Bash 명령어 목록 업데이트
20 KiB
20 KiB
Responsive Image Canvas
GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다. Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션, 마우스/터치 인터랙션, 파티클 이펙트를 구현합니다.
특징
- GPU 가속 렌더링 (Three.js + WebGL)
- 최대 8개의 독립적인 왜곡 영역 지원
- 스프링 물리 기반 마우스/터치 인터랙션
- 이모지 & 스프라이트 시트 파티클 이펙트
- 모션 프리셋 & 커스텀 이징 함수
- 렌즈 왜곡 (볼록/오목) 효과
- 영역 편집을 위한 에디터 컴포넌트
- TypeScript & ESM/CJS 지원
설치
npm install @baekryang/responsive-image-canvas
Peer Dependencies
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)
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 (
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
style={{ width: '100%', height: '100%' }}
/>
);
}
좌표계: 모든 좌표는 정규화 좌표(0.0 ~ 1.0) 를 사용합니다.
(0, 0)은 이미지 좌상단,(1, 1)은 우하단입니다.
컴포넌트
<ImageDistortion />
이미지 왜곡 및 인터랙션 렌더링을 담당하는 메인 컴포넌트입니다.
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
mouseInteraction={mouseConfig}
spriteEffectAreas={spriteEffectAreas}
style={{ width: '100%', height: '100%' }}
/>
| Prop | 타입 | 필수 | 설명 |
|---|---|---|---|
imageSrc |
string |
O | 이미지 URL |
areas |
DistortionArea[] |
O | 왜곡 영역 배열 |
mouseInteraction |
MouseInteractionConfig |
마우스/터치 인터랙션 설정 | |
spriteEffectAreas |
SpriteEffectArea[] |
파티클 이펙트 영역 | |
isPlaying |
boolean |
애니메이션 재생 여부 (기본: true) |
|
style |
CSSProperties |
컨테이너 스타일 | |
className |
string |
컨테이너 클래스 |
<EditorCanvas />
영역 편집을 위한 시각적 에디터 오버레이입니다. 꼭짓점 드래그, 영역 선택 등을 제공합니다.
<EditorCanvas
areas={areas}
selectedAreaId={selectedId}
imageSrc="/image.jpg"
width={imageWidth}
height={imageHeight}
onUpdatePoint={(areaId, pointIndex, point) => { /* ... */ }}
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[] |
파티클 이펙트 영역 |
<AreaList />, <ParameterPanel />
영역 목록 관리 및 파라미터 편집을 위한 보조 에디터 컴포넌트입니다.
Hooks
useDistortionEditor
에디터 상태 관리를 위한 핵심 훅입니다.
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<DistortionArea>) => void
updatePoint, // (areaId: string, pointIndex: number, point: Point) => void
startDragging, // (pointIndex: number) => void
stopDragging, // () => void
getSelectedArea, // () => DistortionArea | null
} = useDistortionEditor(initialAreas);
사용 예시
// 새 영역 추가
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
마우스/터치 기반 스프링 물리 인터랙션을 제공합니다.
import { useMouseInteraction } from '@baekryang/responsive-image-canvas';
const {
updateInteraction, // (areas, deltaTime) => DistortionArea[]
updateConfig, // (newConfig: Partial<MouseInteractionConfig>) => void
reset, // () => void
isDragging, // () => boolean
getInteractingAreaIndices, // () => Set<number>
getMouseState, // () => MouseState
} = useMouseInteraction(containerRef, mouseConfig);
useAnimationFrame
requestAnimationFrame 기반 애니메이션 루프입니다.
import { useAnimationFrame } from '@baekryang/responsive-image-canvas';
useAnimationFrame((deltaTime) => {
// deltaTime: 초 단위
}, isPlaying);
마우스 인터랙션
스프링 물리 기반의 마우스/터치 인터랙션을 설정합니다.
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,
};
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
mouseInteraction={mouseConfig}
/>
물리 프리셋 예시
// 부드러운 반응
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
};
영역별로 개별 물리 설정도 가능합니다:
const area: DistortionArea = {
// ...
physics: {
stiffness: 150,
damping: 5,
mass: 1.0,
influenceRadius: 0.15,
maxStrength: 0.5,
},
};
파티클 이펙트
이미지 위에 이모지 또는 스프라이트 이미지 기반 파티클 이펙트를 추가합니다.
이모지 파티클
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)
},
},
],
},
];
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
spriteEffectAreas={spriteEffectAreas}
/>
지속 방출 (Ambient) 이펙트
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 기반 스프라이트 이미지를 사용할 수 있습니다:
const effect: SpriteEffectConfig = {
id: 'custom-sprite',
spriteUrl: '/sprites/particle.png', // emoji 대신 spriteUrl 사용
trigger: 'ambient',
// ... 나머지 설정 동일
};
스프라이트 시트
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 |
대각선 (↘↖) |
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,
},
};
커스텀 모션 프리셋 등록
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 |
양쪽 모두 부드럽게 |
렌즈 효과 & 스텝 이징
렌즈 왜곡
영역에 볼록/오목 렌즈 효과를 적용합니다.
const area: DistortionArea = {
// ...
lensEffect: {
strength: 0.5, // 양수: 볼록, 음수: 오목 (-1.0 ~ 1.0)
},
};
스텝 이징
애니메이션을 이산적인 단계로 분할합니다.
const area: DistortionArea = {
// ...
snapSteps: 3, // 0: 부드러운 연속 애니메이션, 1~5: 단계 수
};
에디터 스타일 커스터마이징
EditorCanvas의 시각적 요소를 커스터마이징할 수 있습니다.
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)를 제외하고 저장합니다.
저장
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);
로드
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
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 (
<div style={{ position: 'relative', width: imageWidth, height: imageHeight }}>
{isEditing ? (
<EditorCanvas
areas={state.areas}
selectedAreaId={state.selectedAreaId}
imageSrc={imageSrc}
width={imageWidth}
height={imageHeight}
onUpdatePoint={updatePoint}
onUpdateArea={(id, updates) => updateArea(id, updates)}
draggingPointIndex={state.draggingPointIndex}
onStartDragging={startDragging}
onStopDragging={stopDragging}
onSelectArea={selectArea}
/>
) : (
<ImageDistortion
imageSrc={imageSrc}
areas={state.areas}
mouseInteraction={mouseConfig}
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
);
}
타입 레퍼런스
핵심 타입
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;
}
인터랙션 타입
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;
}
파티클 이펙트 타입
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;
}
이징 & 프리셋 타입
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 & {});
상수
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
유틸리티
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