@baekryang/responsive-image-canvas (1.5.2)
Installation
@baekryang:registry=npm install @baekryang/responsive-image-canvas@1.5.2"@baekryang/responsive-image-canvas": "1.5.2"About this package
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
Dependencies
Development Dependencies
| ID | Version |
|---|---|
| @types/react | ^19.2.2 |
| @types/react-dom | ^19.2.2 |
| @types/three | ^0.181.0 |
| react | ^19.2.0 |
| react-dom | ^19.2.0 |
| three | ^0.181.0 |
| tsup | ^8.5.0 |
| typescript | ^5.5.3 |
Peer Dependencies
| ID | Version |
|---|---|
| react | ^18.0.0 || ^19.0.0 |
| react-dom | ^18.0.0 || ^19.0.0 |
| three | >=0.150.0 |
Keywords
react
three.js
webgl
shader
image-distortion
canvas
animation
Details
Assets (1)
Versions (27)
View all