@baekryang/responsive-image-canvas (1.5.3)

Published 2026-03-16 16:47:13 +09:00 by baekryang in baekryang/responsive-image-canvas

Installation

@baekryang:registry=
npm install @baekryang/responsive-image-canvas@1.5.3
"@baekryang/responsive-image-canvas": "1.5.3"

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개의 왜곡 영역만 지원합니다
  • emojispriteUrl은 하나만 사용 가능합니다 (둘 다 없으면 텍스처 로드 실패)

라이선스

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
npm
2026-03-16 16:47:13 +09:00
3
MIT
130 KiB
Assets (1)
Versions (27) View all
1.5.4 2026-03-16
1.5.3 2026-03-16
1.5.2 2026-03-13
1.5.1 2026-03-13
1.5.0 2026-03-13