Compare commits

..

40 Commits

Author SHA1 Message Date
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
BaekRyang
77f44141a1 Update documentation and bump version to 1.5.0
- README.md에 마우스 인터랙션 및 파티클 이펙트 설명 추가
- 에디터 컴포넌트 및 관련 훅(useDistortionEditor 등) 문서화
- 설치 가이드 및 피어 디펜던시 정보 업데이트
- 패키지 버전을 1.5.0으로 상향 조정
- .claude 로컬 설정의 허용된 Bash 명령어 목록 업데이트
2026-03-13 14:32:33 +09:00
BaekRyang
672dd80b9d Add support for emoji particles in SpriteEffect
- spriteEffect 타입에 emoji 필드 추가 및 spriteUrl을 선택 사항으로 변경
- SpriteEffectInstance 내 이모지 기반 캔버스 텍스처 생성 기능 구현
- SpriteEffectManager 인스턴스 생성 로그에 이모지 정보 출력 대응
- 패키지 버전을 1.4.1으로 업데이트
2026-03-13 13:24:55 +09:00
BaekRyang
f3c5ae3669 Update particle interpolation and effect management
- 파티클 스케일 및 투명도에 다중 키프레임 보간 기능 추가
- 스케일 보간을 초기값 기준 배율 방식으로 변경
- 기존 이펙트 인스턴스의 설정을 실시간으로 업데이트하도록 개선
- 다중 구간 보간을 위한 lerpKeyframes 유틸리티 함수 구현
- SpriteParticle 인터페이스에 초기 스케일 상태 저장 추가
2026-03-11 08:55:34 +09:00
BaekRyang
530e6d0396 Refactor sprite effects to be independent and support sprite sheets
- 스프라이트 이펙트를 왜곡 영역에서 분리하여 독립적인 영역으로 관리
- 스프라이트 시트 애니메이션 기능 추가 및 UV 제어 로직 구현
- 에디터 내 스프라이트 이펙트 영역 시각화 및 드래그 이동 기능 추가
- 이펙트 감지 방식을 다각형에서 원형(Radius) 기반으로 변경
- 관련 타입 정의 및 매니저 클래스 리팩토링
2026-03-11 08:32:36 +09:00
BaekRyang
48fdd5e17c Add sprite particle effects and improve lens distortion
- 스프라이트 기반 파티클 이펙트 관리 기능 추가 (SpriteEffectManager)
- 렌즈 왜곡 셰이더 로직 개선 및 픽셀 공간 기준 등방성 확대 적용
- 파티클 최적화를 위한 오브젝트 풀링(SpriteParticlePool) 도입
- DistortionArea 타입에 spriteEffects 설정 필드 추가
- ThreeScene에 씬 객체 접근 기능 및 렌더 순서 제어 추가
- useMouseInteraction 훅에서 마우스 상태 조회 기능 추가
- 버전 1.3.0 업데이트 및 관련 타입 정의 반영
2026-03-10 13:24:12 +09:00
BaekRyang
c72846b06e Refactor lens distortion calculation in distortion shader
- 렌즈 왜곡 계산을 로컬 UV에서 글로벌 UV 및 픽셀 거리 기반으로 변경
- 영역 중심 기준의 자연스러운 볼록/오목 효과 및 감쇠 로직 구현
2026-02-25 16:52:48 +09:00
BaekRyang
ecf3e81101 Refactor step easing into independent snapSteps property
- EasingFunction에서 step 옵션을 제거하고 독립적인 snapSteps 속성으로 분리
- AnimationLoop에 snapSteps 기반의 움직임 양자화 로직 구현
- 에디터 파라미터 패널에 움직임 단계 조절 슬라이더 추가
- 기본 설정값에 SNAP_STEPS 추가 및 패키지 버전 업데이트 (1.2.10)
2026-02-25 16:14:46 +09:00
BaekRyang
6d9dd082c1 Add lens effect and step easing functions
- 단계별(Step) 이징 함수 추가 (steps2 ~ steps10)
- 영역별 렌즈 왜곡 효과(볼록/오목) 기능 및 셰이더 로직 추가
- 에디터 파라미터 패널에 렌즈 효과 슬라이더 및 스텝 이징 옵션 추가
- 관련 상수, 타입 정의 및 유니폼 변수 업데이트
- 패키지 버전 업데이트 (1.2.7)
2026-02-25 14:35:45 +09:00
BaekRyang
031230bc36 feat: Improve canvas resizing and animation handling
- EditorCanvas:
  - 컨테이너 크기 측정 로직을 ResizeObserver를 사용하여 개선했습니다.
  - 초기 크기 설정 및 크기 변경 감지 기능을 추가했습니다.
  - 컨테이너의 width, height 스타일을 '100%'로 변경하여 부모 요소에 맞게 조절되도록 했습니다.
- ImageDistortion:
  - isPlaying prop을 제거하고 애니메이션을 항상 실행하도록 변경했습니다.
  - useAnimationFrame의 실행 조건을 항상 true로 설정하여 애니메이션 루프가 안정적으로 동작하도록 했습니다.
- package.json:
  - 버전 정보를 1.2.1에서 1.2.6으로 업데이트했습니다.
2025-11-28 16:37:20 +09:00
BaekRyang
6b6c8d8fd0 feat: Add area selection functionality
- EditorCanvas 컴포넌트에 영역 선택 콜백(onSelectArea) 추가
- 비선택 영역 클릭 시 해당 영역을 선택하는 기능 구현
- 드래그 시작 조건에서 showEditor만 확인하도록 수정 (selectedArea 불필요)
- package.json 버전 1.2.0에서 1.2.1로 업데이트
2025-11-26 13:53:15 +09:00
BaekRyang
317c7c5c92 feat: Add cubic easing and improve oscillation motion
- `src/types/area.ts`: cubic easing 함수 타입 추가
- `package.json`: 버전 1.1.0에서 1.2.0으로 업데이트
- `src/engine/AnimationLoop.ts`: 왕복 모션 로직을 sin 함수 기반으로 개선하여 자연스러운 좌우/상하 왕복 구현
- `src/utils/easing.ts`: easeInCubic, easeOutCubic 함수 추가
2025-11-26 13:48:21 +09:00
BaekRyang
4db9839f28 feat: Add motion preset registration API
- 모션 프리셋을 동적으로 등록하고 관리할 수 있는 API를 추가했습니다.
- `registerMotionPreset`, `registerMotionPresets`, `unregisterMotionPreset`, `getRegisteredPresets`, `hasPreset`, `resetToBuiltInPresets` 함수를 제공합니다.
- `MotionPreset` 타입을 `BuiltInMotionPreset`과 사용자 정의 문자열을 포함하도록 확장했습니다.
- `MotionPresetDefinition` 타입을 추가하여 커스텀 프리셋 정의 방식을 명확히 했습니다.
2025-11-26 11:05:36 +09:00
BaekRyang
5f6e780b40 feat: Add AreaList and ParameterPanel components
- 영역 목록과 파라미터 편집 패널을 추가하여 왜곡 영역 관리를 개선했습니다.
- 각 영역의 강도, 애니메이션 지속 시간, 이징 함수 등을 조절할 수 있습니다.
- 새 영역 추가 및 기존 영역 삭제 기능을 제공합니다.
2025-11-24 15:16:48 +09:00
BaekRyang
6babf68c71 feat: Add external control for editor visibility
- 에디터 표시 여부를 외부에서 제어할 수 있도록 `showEditor` prop 추가
- `DistortionEditor` 컴포넌트에서 내부 `showEditor` 상태 제거
- `package.json` 버전 1.0.2에서 1.0.3으로 업데이트
2025-11-24 14:46:29 +09:00
BaekRyang
0af2287a50 feat: Increment version to 1.0.2
- package.json 파일의 버전 정보를 1.0.1에서 1.0.2로 업데이트했습니다.
2025-11-24 14:23:35 +09:00
BaekRyang
e08f34caab feat: Add per-area physics configuration
- 영역별 물리 설정 `physics` 옵션을 추가했습니다.
- `useMouseInteraction` 훅에서 영역별 물리 설정을 적용하도록 수정했습니다.
- `ImageDistortion` 컴포넌트에서 `selectedAreaId` prop을 제거하고, 마우스 인터랙션 시 `interactingAreaIndices`를 활용하도록 변경했습니다.
- 셰이더에서 텍스처 좌표가 범위를 벗어날 때 부드럽게 페이드 아웃되도록 수정했습니다.
2025-11-24 14:23:06 +09:00
BaekRyang
61952ce79c feat: Add selectedAreaId prop to ImageDistortion
- 선택된 영역 ID를 ImageDistortion 컴포넌트에 추가했습니다.
- selectedAreaId prop을 통해 특정 영역의 자동 애니메이션을 제외할 수 있습니다.
- ImageDistortion 컴포넌트의 animationCallback 로직을 수정하여 selectedAreaId를 반영했습니다.
2025-11-24 14:09:05 +09:00
BaekRyang
4bdae13f7f feat: Add isDragging hook to mouse interaction
- useMouseInteraction 훅에 isDragging 함수를 추가했습니다.
- ImageDistortion 컴포넌트의 애니메이션 콜백에서 마우스 드래그 상태를 감지하여,
  드래그 중일 때는 자동 애니메이션의 dragVector를 0으로 설정하도록 로직을 개선했습니다.
2025-11-24 13:48:06 +09:00
BaekRyang
f6ad8b11b0 feat: Add motion presets for distortion animations
- 왜곡 애니메이션에 사용할 수 있는 다양한 모션 프리셋(horizontal, vertical, rotate-cw 등)을 추가했습니다.
- `DistortionMovement` 인터페이스에 `preset`과 `strength` 옵션을 추가하여 모션 프리셋을 설정할 수 있도록 변경했습니다.
- `presetToVector` 함수와 `isRotationPreset` 함수를 추가하여 모션 프리셋 로직을 구현했습니다.
- `AnimationLoop` 클래스에서 모션 프리셋을 적용하여 `vectorA`를 계산하도록 수정했습니다.
2025-11-24 13:41:36 +09:00
BaekRyang
bbbb49aa1d feat: Update package version and description
- `package.json` 파일에서 패키지 버전이 1.0.0에서 1.0.1로 업데이트되었습니다.
- 패키지 이름이 `@baekryang/responsive-image-canvas`로 변경되었습니다.
- `publishConfig`에 npm 레지스트리 주소가 추가되었습니다.
- `.gitignore` 파일에 `demo.npmrc` 파일이 추가되어 불필요한 파일이 추적되지 않도록 수정되었습니다.
2025-11-24 13:27:11 +09:00
BaekRyang
c18f3fffb5 Refactor: Update import paths with alias
- `@/types` 경로를 사용하여 타입 관련 import 경로를 수정했습니다.
- `@/engine` 경로를 사용하여 엔진 관련 import 경로를 수정했습니다.
- `@/editor` 경로를 사용하여 에디터 관련 import 경로를 수정했습니다.
- `@/components` 경로를 사용하여 컴포넌트 관련 import 경로를 수정했습니다.
- `@/hooks` 경로를 사용하여 훅 관련 import 경로를 수정했습니다.
- `@/utils` 경로를 사용하여 유틸리티 관련 import 경로를 수정했습니다.
2025-11-06 09:41:12 +09:00
BaekRyang
d2e83ac9a5 feat: 마우스 드래그 및 터치 이벤트 처리 개선
- 캔버스 드래그 시 스크롤 방지 로직 추가
- 마우스/터치 이벤트 핸들러 통합 및 개선
- 이벤트 리스너 등록/제거 로직 최적화
2025-11-06 09:23:10 +09:00
BaekRyang
ddcf8b463a feat: Improve mouse interaction to affect multiple areas
- 마우스가 닿는 모든 영역에 왜곡 효과가 적용되도록 수정했습니다.
- 기존에는 마우스가 처음 닿는 단일 영역에만 효과가 적용되었으나, 이제는 마우스 커서가 영역을 벗어나도 해당 영역에 대한 스프링 물리 효과가 유지되도록 변경되었습니다.
- `useMouseInteraction` 훅에서 `interactingAreaIndex` 대신 `interactingAreaIndices` (Set)를 사용하여 여러 영역을 동시에 추적합니다.
- 영역 진입 시 스프링이 리셋되고, 영역 이탈 시 평형 상태로 복귀하는 로직이 추가되었습니다.
2025-11-05 15:31:11 +09:00
BaekRyang
7f6a72c058 feat: Add mouse interaction for physics-based distortion
- 마우스 움직임에 따라 왜곡 영역이 튕기는 효과를 추가했습니다.
- `useMouseVelocity` 훅을 사용하여 마우스 속도와 가속도를 추적합니다.
- `SpringPhysics` 클래스를 구현하여 스프링 기반 물리 효과를 시뮬레이션합니다.
- `useMouseInteraction` 훅은 마우스 이벤트를 감지하고 `SpringPhysics`를 제어하여 왜곡 영역의 `dragVector`를 업데이트합니다.
- `ImageDistortion` 컴포넌트에서 `mouseInteraction` prop을 통해 이 기능을 활성화/설정할 수 있습니다.
2025-11-05 14:56:36 +09:00
BaekRyang
e531a7a762 feat: Apply cumulative distortion from all overlapping areas
- 모든 겹치는 영역의 왜곡을 누적하여 적용하도록 변경
- 각 영역별 clamp를 제거하고, 모든 왜곡 계산 후 최종적으로 한 번만 clamp
- 불필요한 `found` 변수 및 `break` 문 제거
2025-11-05 12:48:36 +09:00
BaekRyang
fed9dc7606 feat: Add editor UI toggle functionality
- 캔버스 편집 UI 표시/숨김 기능을 추가했습니다.
- 에디터 툴바에 토글 버튼을 추가하여 UI 표시 상태를 제어할 수 있습니다.
- 에디터 UI가 숨겨졌을 때 캔버스에 마우스 이벤트가 전달되지 않도록 수정했습니다.
2025-11-05 11:52:33 +09:00
BaekRyang
0c3c0b606e feat: Add canvas style customization
- 왜곡 영역 원 레벨 스타일, 중심점, 포인트 핸들, 영역 외곽선 등
  캔버스 스타일을 커스터마이징할 수 있도록 `EditorCanvasStyle` 타입을
  추가했습니다.
- `DistortionEditorProps`에 `canvasStyle` prop을 추가하여
  외부에서 캔버스 스타일을 전달받을 수 있도록 했습니다.
- `EditorCanvas` 컴포넌트에서 `useMemo`를 사용하여
  기본 스타일과 사용자 정의 스타일을 병합하고, 이를 렌더링에
  반영하도록 수정했습니다.
2025-11-05 11:48:05 +09:00
BaekRyang
d621d5b691 chore: Add nul to .gitignore (Windows temp file) 2025-11-05 11:23:44 +09:00
BaekRyang
9f32e2ce8f chore: Add demo directory to .gitignore 2025-11-05 11:23:28 +09:00
BaekRyang
c18115da0e chore: Update project configuration
- Add demo app path reference in CLAUDE.md
- Add bash command permissions for development tools
2025-11-05 11:21:28 +09:00
BaekRyang
e66b078dd8 build: Update compiled distribution files
- Update CJS and ESM bundles with coordinate system fixes
- Update type definitions (d.ts, d.mts)
- Add editor styles (CSS)
- Update shader files
- Update source maps
2025-11-05 11:20:53 +09:00
BaekRyang
f080693d32 chore: Export distortion editor components
- Export DistortionEditor component
- Export DistortionEditorProps, EditorState, EditMode types
- Export useDistortionEditor hook for external use
2025-11-05 11:20:40 +09:00
BaekRyang
63e7bac3c7 feat: Add interactive distortion area editor
- Add EditorCanvas component with visual distortion area editing
  - Point-in-polygon detection for area selection
  - Individual point dragging with visual handles
  - Entire area dragging by clicking inside polygon
  - UV-space distortion circle visualization
- Add AreaList component for managing multiple distortion areas
- Add ParameterPanel for editing distortion properties
  - Base points (normalized coordinates)
  - Drag vectors and distortion strength
  - Animation duration and easing
- Add DistortionEditor main component with sidebar layout
- Add useDistortionEditor hook for state management
- Add editor types and interfaces

사각형 내부를 클릭하여 전체 영역을 드래그할 수 있는 기능 포함
2025-11-05 11:20:20 +09:00
BaekRyang
ef992b5525 fix: Fix coordinate system to match Flutter implementation
- Fix y-coordinate inversion between UI (top-left origin) and WebGL (bottom-left origin)
- Convert UI coordinates to WebGL coordinates when passing to shader
- Invert drag vector y-direction to match coordinate system
- Add getResolution() method to ThreeScene for coordinate conversion
- Update shader to use normalized drag vectors directly

This resolves the issue where distortion appeared at opposite y-position from clicked point.
2025-11-05 11:20:07 +09:00
BaekRyang
e371321fd2 feat: Fix image distortion shader and improve loading state
- Fix distortion.frag.glsl to match Flutter original implementation
  - Update computeUV function with single Newton-Raphson iteration
  - Fix coordinate transformation (normalized to pixel)
  - Fix distortion application logic
  - Add break after first matching area (Flutter behavior)

- Add image loading state management
  - Add imageLoaded state
  - Add loading progress callback
  - Add loading UI indicator
  - Improve error handling

- Add comprehensive debug logging
  - ShaderManager: fetch status and shader lengths
  - ThreeScene: shader compilation check, render calls
  - ImageDistortion: lifecycle and loading status

- Add test/debug shaders for troubleshooting
  - test.frag.glsl: Simple pass-through shader
  - debug.frag.glsl: Area visualization shader

- Fix infinite loop bug in animationCallback
  - Use setState updater function to avoid dependency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:51:39 +09:00
BaekRyang
c3b5aaadcb Debug: Add more detailed logging for containerRef 2025-11-04 10:55:43 +09:00
BaekRyang
d66c43f6f1 Debug: Add console logs for troubleshooting 2025-11-04 10:49:38 +09:00
BaekRyang
ceab8f4332 Fix: Update three.js peer dependency to support newer versions 2025-11-04 10:46:56 +09:00
BaekRyang
05b47fb177 Fix: Resolve infinite loop in animationCallback using setState with updater function 2025-11-04 10:45:44 +09:00
50 changed files with 9840 additions and 476 deletions

View File

@ -5,7 +5,18 @@
"Bash(mkdir:*)", "Bash(mkdir:*)",
"Bash(npm run build:*)", "Bash(npm run build:*)",
"Bash(dir:*)", "Bash(dir:*)",
"Bash(tree:*)" "Bash(tree:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(npm run dev:*)",
"Bash(findstr:*)",
"Bash(npm link:*)",
"Bash(find:*)",
"Bash(nul)",
"Bash(cd:*)",
"Bash(ls -la /d/Projects/WebstormProjects/raonnuri/src/app/\\\\[locale\\\\]/)",
"Bash(find \"D:\\\\Projects\\\\WebstormProjects\\\\raonnuri\\\\src\\\\app\\\\[locale]\\\\interaction\" -type f \\\\\\( -name \"*.tsx\" -o -name \"*.ts\" \\\\\\) 2>/dev/null | head -10)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

4
.gitignore vendored
View File

@ -11,3 +11,7 @@ yarn-error.log*
.vscode/* .vscode/*
.DS_Store .DS_Store
*.log *.log
nul
# Demo (템플릿 파일, 실제 데모는 별도 저장소)
/demo.npmrc

6
.idea/AICommit.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.github.blarc.ai.commits.intellij.plugin.settings.ProjectSettings">
<option name="splitButtonActionSelectedLLMClientId" value="2f900cba-1f90-431b-acb2-e4e6ac66b31e" />
</component>
</project>

1
.npmrc Normal file
View File

@ -0,0 +1 @@
//git.bnovalab.com/api/packages/baekryang/npm/:_authToken=a2ed709f39e95662493a92305555a4bf70f6fe10

View File

@ -1 +1,2 @@
- 주석은 한글로 작성 - 주석은 한글로 작성
- D:\Projects\WebstormProjects\demo-app 에 link 된 데모 앱이 있음

869
README.md
View File

@ -1,20 +1,23 @@
# Responsive Image Canvas # Responsive Image Canvas
GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다. Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션을 구현합니다. GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다.
Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션, 마우스/터치 인터랙션, 파티클 이펙트를 구현합니다.
## 특징 ## 특징
- 🚀 GPU 가속 렌더링 (Three.js + WebGL) - GPU 가속 렌더링 (Three.js + WebGL)
- 🎨 최대 8개의 독립적인 왜곡 영역 지원 - 최대 8개의 독립적인 왜곡 영역 지원
- ⚡ 60fps 실시간 애니메이션 - 스프링 물리 기반 마우스/터치 인터랙션
- 🎯 정규화된 좌표계 (0.0 - 1.0) - 이모지 & 스프라이트 시트 파티클 이펙트
- 🔧 TypeScript 완벽 지원 - 모션 프리셋 & 커스텀 이징 함수
- 📦 ESM & CommonJS 모두 지원 - 렌즈 왜곡 (볼록/오목) 효과
- 영역 편집을 위한 에디터 컴포넌트
- TypeScript & ESM/CJS 지원
## 설치 ## 설치
```bash ```bash
npm install responsive-image-canvas npm install @baekryang/responsive-image-canvas
``` ```
### Peer Dependencies ### Peer Dependencies
@ -23,25 +26,38 @@ npm install responsive-image-canvas
npm install react react-dom three 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 ```tsx
import { ImageDistortion, DistortionArea } from 'responsive-image-canvas'; import { ImageDistortion } from '@baekryang/responsive-image-canvas';
import type { DistortionArea } from '@baekryang/responsive-image-canvas';
const areas: DistortionArea[] = [ const areas: DistortionArea[] = [
{ {
id: 'area-1', id: 'area-1',
basePoints: [ basePoints: [
{ x: 0.2, y: 0.2 }, // 좌상단 { x: 0.3, y: 0.3 }, // 좌상단
{ x: 0.4, y: 0.2 }, // 우상단 { x: 0.7, y: 0.3 }, // 우상단
{ x: 0.4, y: 0.4 }, // 우하단 { x: 0.7, y: 0.7 }, // 우하단
{ x: 0.2, y: 0.4 }, // 좌하단 { x: 0.3, y: 0.7 }, // 좌하단
], ],
movement: { movement: {
vectorA: { x: 0.1, y: 0.1 }, preset: 'horizontal',
vectorB: { x: -0.1, y: -0.1 }, vectorA: { x: 0.1, y: 0 },
vectorB: { x: -0.1, y: 0 },
duration: 2.0, duration: 2.0,
easing: 'easeInOut', easing: 'easeInOut',
strength: 0.15,
}, },
distortionStrength: 0.5, distortionStrength: 0.5,
progress: 0, progress: 0,
@ -51,88 +67,119 @@ const areas: DistortionArea[] = [
function App() { function App() {
return ( return (
<div style={{ width: '800px', height: '600px' }}>
<ImageDistortion <ImageDistortion
imageSrc="/path/to/image.jpg" imageSrc="/image.jpg"
areas={areas} areas={areas}
isPlaying={true} style={{ width: '100%', height: '100%' }}
/> />
</div>
); );
} }
``` ```
## Props > **좌표계**: 모든 좌표는 **정규화 좌표(0.0 ~ 1.0)** 를 사용합니다. `(0, 0)`은 이미지 좌상단, `(1, 1)`은 우하단입니다.
### `ImageDistortionProps` ---
| Prop | 타입 | 필수 | 기본값 | 설명 | ## 컴포넌트
|------|------|------|--------|------|
| `imageSrc` | `string` | ✓ | - | 이미지 소스 URL |
| `areas` | `DistortionArea[]` | ✓ | - | 왜곡 영역 배열 |
| `vertexShaderPath` | `string` | ✗ | `/shaders/distortion.vert.glsl` | 커스텀 버텍스 셰이더 경로 |
| `fragmentShaderPath` | `string` | ✗ | `/shaders/distortion.frag.glsl` | 커스텀 프래그먼트 셰이더 경로 |
| `isPlaying` | `boolean` | ✗ | `true` | 애니메이션 재생 여부 |
| `style` | `CSSProperties` | ✗ | - | 컨테이너 스타일 |
| `className` | `string` | ✗ | - | 컨테이너 클래스명 |
## 타입 정의 ### `<ImageDistortion />`
### `DistortionArea` 이미지 왜곡 및 인터랙션 렌더링을 담당하는 메인 컴포넌트입니다.
```typescript
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; // 현재 드래그 벡터
}
```
### `Point`
```typescript
interface Point {
x: number; // 0.0 - 1.0 (정규화된 좌표)
y: number; // 0.0 - 1.0 (정규화된 좌표)
}
```
### `DistortionMovement`
```typescript
interface DistortionMovement {
vectorA: Point; // 시작 벡터
vectorB: Point; // 종료 벡터
duration: number; // 지속 시간 (초)
easing: EasingFunction; // 이징 함수
}
```
### `EasingFunction`
```typescript
type EasingFunction =
| 'linear'
| 'easeIn'
| 'easeOut'
| 'easeInOut'
| 'easeInQuad'
| 'easeOutQuad';
```
## 고급 사용법
### 영역 동적 추가/제거
```tsx ```tsx
function DynamicDistortion() { <ImageDistortion
const [areas, setAreas] = useState<DistortionArea[]>([]); imageSrc="/image.jpg"
areas={areas}
mouseInteraction={mouseConfig}
spriteEffectAreas={spriteEffectAreas}
style={{ width: '100%', height: '100%' }}
/>
```
const addArea = () => { | Prop | 타입 | 필수 | 설명 |
const newArea: DistortionArea = { |------|------|:----:|------|
| `imageSrc` | `string` | O | 이미지 URL |
| `areas` | `DistortionArea[]` | O | 왜곡 영역 배열 |
| `mouseInteraction` | `MouseInteractionConfig` | | 마우스/터치 인터랙션 설정 |
| `spriteEffectAreas` | `SpriteEffectArea[]` | | 파티클 이펙트 영역 |
| `isPlaying` | `boolean` | | 애니메이션 재생 여부 (기본: `true`) |
| `style` | `CSSProperties` | | 컨테이너 스타일 |
| `className` | `string` | | 컨테이너 클래스 |
### `<EditorCanvas />`
영역 편집을 위한 시각적 에디터 오버레이입니다. 꼭짓점 드래그, 영역 선택 등을 제공합니다.
```tsx
<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`
에디터 상태 관리를 위한 핵심 훅입니다.
```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<DistortionArea>) => 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()}`, id: `area-${Date.now()}`,
basePoints: [ basePoints: [
{ x: 0.3, y: 0.3 }, { x: 0.3, y: 0.3 },
@ -141,114 +188,624 @@ function DynamicDistortion() {
{ x: 0.3, y: 0.7 }, { x: 0.3, y: 0.7 },
], ],
movement: { movement: {
vectorA: { x: 0.15, y: 0 }, preset: 'none',
vectorB: { x: -0.15, y: 0 }, vectorA: { x: 0, y: 0 },
duration: 3.0, vectorB: { x: 0, y: 0 },
easing: 'easeInOut', duration: DEFAULT_AREA.DURATION,
easing: DEFAULT_AREA.EASING,
strength: 0.15,
}, },
distortionStrength: 0.6, distortionStrength: DEFAULT_AREA.DISTORTION_STRENGTH,
progress: 0, progress: 0,
dragVector: { x: 0, y: 0 }, dragVector: { x: 0, y: 0 },
});
}; };
setAreas([...areas, newArea]); // 선택된 영역 업데이트
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<MouseInteractionConfig>) => void
reset, // () => void
isDragging, // () => boolean
getInteractingAreaIndices, // () => Set<number>
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,
}; };
return (
<div>
<button onClick={addArea}>영역 추가</button>
<ImageDistortion <ImageDistortion
imageSrc="/image.jpg" imageSrc="/image.jpg"
areas={areas} areas={areas}
mouseInteraction={mouseConfig}
/> />
```
### 물리 프리셋 예시
```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)
},
},
],
},
];
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
spriteEffectAreas={spriteEffectAreas}
/>
```
### 지속 방출 (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 (
<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> </div>
); );
} }
``` ```
### 유틸리티 함수 사용 ---
```tsx ## 타입 레퍼런스
import { DEFAULT_AREA, applyEasing } from 'responsive-image-canvas';
// 기본 설정값 사용 ### 핵심 타입
const newArea = {
...DEFAULT_AREA,
id: 'my-area',
basePoints: [/* ... */],
};
// 이징 함수 직접 사용
const easedValue = applyEasing(0.5, 'easeInOut');
console.log(easedValue); // 0.5
```
## 셰이더 파일
패키지는 기본 셰이더 파일을 포함하고 있습니다:
- `dist/distortion.vert.glsl` - 버텍스 셰이더
- `dist/distortion.frag.glsl` - 프래그먼트 셰이더
웹 서버에서 이 파일들을 정적 파일로 제공해야 합니다.
### Vite 설정 예시
```typescript ```typescript
// vite.config.ts interface Point {
import { defineConfig } from 'vite'; x: number; // 0.0 ~ 1.0
y: number; // 0.0 ~ 1.0
}
export default defineConfig({ interface DistortionArea {
publicDir: 'public', id: string;
// node_modules의 셰이더 파일을 복사 basePoints: [Point, Point, Point, Point]; // [좌상, 우상, 우하, 좌하]
build: { movement: DistortionMovement;
rollupOptions: { distortionStrength: number; // 0.0 ~ 1.0
output: { progress: number; // 0.0 ~ 1.0 (런타임)
assetFileNames: 'assets/[name].[ext]', 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;
}
``` ```
셰이더 파일을 public 폴더로 복사: ### 인터랙션 타입
```bash ```typescript
cp node_modules/responsive-image-canvas/dist/*.glsl public/shaders/ 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;
}
``` ```
## 성능 최적화 ### 파티클 이펙트 타입
### 1. 영역 수 제한 ```typescript
최대 8개의 영역까지 지원하지만, 성능을 위해 4개 이하를 권장합니다. type SpriteEffectTrigger = 'ambient' | 'touch';
type SpriteBlendMode = 'normal' | 'additive';
### 2. 이미지 크기 최적화 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;
}
### 3. 애니메이션 일시정지 interface SpriteEffectArea {
필요하지 않을 때는 `isPlaying={false}`로 설정하세요. 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을 지원하지 않는 브라우저에서는 동작하지 않습니다 - WebGL을 지원하지 않는 브라우저에서는 동작하지 않습니다
- 모바일 환경에서는 성능이 제한될 수 있습니다
- 최대 8개의 왜곡 영역만 지원합니다 - 최대 8개의 왜곡 영역만 지원합니다
- `emoji``spriteUrl`은 하나만 사용 가능합니다 (둘 다 없으면 텍스처 로드 실패)
## 브라우저 지원
- Chrome 60+
- Firefox 60+
- Safari 12+
- Edge 79+
## 라이선스 ## 라이선스
MIT MIT
## 기여
이슈와 PR을 환영합니다!
## 관련 프로젝트
- [Three.js](https://threejs.org/)
- [React Three Fiber](https://github.com/pmndrs/react-three-fiber)

39
dist/debug.frag.glsl vendored Normal file
View File

@ -0,0 +1,39 @@
uniform vec2 u_resolution;
uniform sampler2D u_texture;
uniform vec2 u_points[32];
uniform int u_numAreas;
uniform vec2 u_dragVectors[8];
uniform float u_distortionStrengths[8];
varying vec2 vUv;
void main() {
vec2 texCoord = vUv;
// 디버그: 영역 표시
for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break;
// 포인트를 픽셀 좌표로 변환
vec2 p0 = u_points[i * 4 + 0] * u_resolution;
vec2 p1 = u_points[i * 4 + 1] * u_resolution;
vec2 p2 = u_points[i * 4 + 2] * u_resolution;
vec2 p3 = u_points[i * 4 + 3] * u_resolution;
vec2 pixelCoord = vUv * u_resolution;
// 경계 상자 체크만
vec2 minP = min(min(p0, p1), min(p2, p3));
vec2 maxP = max(max(p0, p1), max(p2, p3));
// 영역 안에 있으면 빨간색으로 표시
if (pixelCoord.x >= minP.x && pixelCoord.x <= maxP.x &&
pixelCoord.y >= minP.y && pixelCoord.y <= maxP.y) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 빨간색
return;
}
}
// 영역 밖은 원본 이미지
gl_FragColor = texture2D(u_texture, texCoord);
}

View File

@ -1,88 +1,119 @@
uniform vec2 u_resolution; uniform vec2 u_resolution;
uniform sampler2D u_texture; uniform sampler2D u_texture;
uniform vec2 u_points[32]; // 최대 8영역 × 4포인트 uniform vec2 u_points[32]; // 최대 8영역 × 4포인트 (정규화된 좌표 0-1)
uniform int u_numAreas; uniform int u_numAreas;
uniform vec2 u_dragVectors[8]; uniform vec2 u_dragVectors[8]; // 드래그 벡터 (정규화된 좌표 0-1)
uniform float u_distortionStrengths[8]; uniform float u_distortionStrengths[8];
uniform float u_lensEffects[8];
varying vec2 vUv; varying vec2 vUv;
// 사각형 내부의 포인트에 대한 UV 좌표 계산 // Flutter 원본 computeUV 함수 (정확히 동일하게 변환)
vec2 computeUV(vec2 xy, vec2 p0, vec2 p1, vec2 p2, vec2 p3) { vec2 computeUV(vec2 xy, vec2 p0, vec2 p1, vec2 p2, vec2 p3) {
// 경계 상자 체크
vec2 minP = min(min(p0, p1), min(p2, p3)); vec2 minP = min(min(p0, p1), min(p2, p3));
vec2 maxP = max(max(p0, p1), max(p2, p3)); vec2 maxP = max(max(p0, p1), max(p2, p3));
if (xy.x < minP.x || xy.x > maxP.x || xy.y < minP.y || xy.y > maxP.y) { if (xy.x < minP.x || xy.x > maxP.x || xy.y < minP.y || xy.y > maxP.y) {
return vec2(-1.0, -1.0); return vec2(-1.0, -1.0); // 외부
} }
// 초기 추정값 (정규화된 좌표)
vec2 rectSize = maxP - minP; vec2 rectSize = maxP - minP;
vec2 rectUV = (xy - minP) / rectSize; if (rectSize.x == 0.0 || rectSize.y == 0.0) {
return vec2(-1.0, -1.0); // 축퇴
}
vec2 rectMin = minP;
vec2 rectUV = (xy - rectMin) / rectSize;
float u0 = rectUV.x; float u0 = rectUV.x;
float v0 = rectUV.y; float v0 = rectUV.y;
// Newton-Raphson 반복법으로 정확한 UV 계산 // 1회 Newton-Raphson (Flutter 원본과 동일)
for (int iter = 0; iter < 3; iter++) { vec2 left = mix(p0, p1, u0);
vec2 xy0 = mix(mix(p0, p1, u0), mix(p3, p2, u0), v0); vec2 right = mix(p3, p2, u0);
vec2 xy0 = mix(left, right, v0);
vec2 dxy = xy - xy0;
vec2 du_vec = mix(p1 - p0, p2 - p3, v0); vec2 du_vec = mix(p1 - p0, p2 - p3, v0);
vec2 dv_vec = mix(p3 - p0, p2 - p1, u0); vec2 dv_vec = mix(p3 - p0, p2 - p1, u0);
vec2 dxy = xy - xy0;
float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x; float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x;
if (abs(det) > 1e-6) {
if (abs(det) < 1e-6) break; float inv_det = 1.0 / det;
float du = (dv_vec.y * dxy.x - dv_vec.x * dxy.y) * inv_det;
float du = (dv_vec.y * dxy.x - dv_vec.x * dxy.y) / det; float dv = (-du_vec.y * dxy.x + du_vec.x * dxy.y) * inv_det;
float dv = (-du_vec.y * dxy.x + du_vec.x * dxy.y) / det;
u0 += du; u0 += du;
v0 += dv; v0 += dv;
} }
// 포인트가 내부에 있는지 확인
if (u0 >= 0.0 && u0 <= 1.0 && v0 >= 0.0 && v0 <= 1.0) {
return vec2(u0, v0); return vec2(u0, v0);
} }
return vec2(-1.0, -1.0);
}
void main() { void main() {
vec2 uv = vUv; vec2 xy = vUv * u_resolution; // 픽셀 좌표
vec2 pixelCoord = vUv * u_resolution; vec2 texCoord = vUv;
// 모든 영역의 왜곡 적용 // 모든 겹치는 영역의 왜곡을 누적 적용
for (int i = 0; i < 8; i++) { for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break; if (i >= u_numAreas) break;
int baseIndex = i * 4; // 포인트는 정규화된 좌표로 전달받았으므로 픽셀 좌표로 변환
vec2 p0 = u_points[baseIndex + 0] * u_resolution; vec2 p0 = u_points[i * 4 + 0] * u_resolution;
vec2 p1 = u_points[baseIndex + 1] * u_resolution; vec2 p1 = u_points[i * 4 + 1] * u_resolution;
vec2 p2 = u_points[baseIndex + 2] * u_resolution; vec2 p2 = u_points[i * 4 + 2] * u_resolution;
vec2 p3 = u_points[baseIndex + 3] * u_resolution; vec2 p3 = u_points[i * 4 + 3] * u_resolution;
vec2 areaUV = computeUV(pixelCoord, p0, p1, p2, p3); vec2 uv_local = computeUV(xy, p0, p1, p2, p3);
if (areaUV.x >= 0.0) { if (uv_local.x >= 0.0 && uv_local.x <= 1.0 && uv_local.y >= 0.0 && uv_local.y <= 1.0) {
// 이 영역 내부에 포인트가 있음 vec2 uvCenter = vec2(0.5, 0.5);
vec2 center = vec2(0.5, 0.5); float distToCenter = distance(uv_local, uvCenter);
float distToCenter = length(areaUV - center); float maxUvRadius = 0.5;
float maxUvRadius = 0.707; // sqrt(0.5^2 + 0.5^2)
// 부드러운 감쇠 if (distToCenter < maxUvRadius) {
float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter); float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter);
// dragVector는 정규화된 좌표(0-1)이므로 바로 사용
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
texCoord += distortion;
// 왜곡 적용 // 렌즈 왜곡 효과 (볼록: 중심 확대, 오목: 중심 축소)
vec2 distortion = (u_dragVectors[i] / u_resolution) * influence * u_distortionStrengths[i]; if (abs(u_lensEffects[i]) > 0.001) {
uv += distortion; // 영역 중심의 글로벌 UV 좌표
vec2 minP_area = min(min(p0, p1), min(p2, p3));
vec2 maxP_area = max(max(p0, p1), max(p2, p3));
vec2 areaSize = maxP_area - minP_area;
vec2 areaCenterUV = (minP_area + maxP_area) * 0.5 / u_resolution;
// 현재 픽셀에서 영역 중심까지의 글로벌 UV 오프셋
vec2 offset = vUv - areaCenterUV;
// 픽셀 공간 거리로 원형 감쇠 (긴 변 기준으로 영역 전체 커버)
float distPx = length(offset * u_resolution);
float maxRadiusPx = max(areaSize.x, areaSize.y) * 0.5;
float normalizedDist = distPx / maxRadiusPx;
if (normalizedDist < 1.0) {
// 중심에서 최대 강도, 가장자리로 갈수록 자연스럽게 0으로 감소
float lensAmount = u_lensEffects[i] * (1.0 - normalizedDist * normalizedDist);
// 볼록(+): 텍스처 좌표를 중심으로 당김 → 확대
// offset은 글로벌 UV이므로 픽셀 공간에서 등방성(isotropic) 확대
texCoord -= offset * lensAmount * u_distortionStrengths[i];
}
}
}
} }
} }
// 텍스처 외부 샘플링 방지를 위한 클램핑 // 경계 근처에서 부드럽게 페이드 아웃
uv = clamp(uv, 0.0, 1.0); // 텍스처 좌표가 0~1 범위를 벗어나면 알파값을 줄여서 자연스럽게 처리
vec2 edgeDist = min(texCoord, 1.0 - texCoord);
float edgeFade = smoothstep(0.0, 0.05, min(edgeDist.x, edgeDist.y));
// 텍스처 샘플링 // 범위를 벗어난 좌표는 fract로 래핑하여 반복 효과 (더 자연스러움)
gl_FragColor = texture2D(u_texture, uv); vec2 wrappedCoord = fract(texCoord);
vec4 color = texture2D(u_texture, wrappedCoord);
// 경계에서 페이드 아웃 적용
color.a *= edgeFade;
gl_FragColor = color;
} }

328
dist/index.css vendored Normal file
View File

@ -0,0 +1,328 @@
/* src/editor/editor.css */
.distortion-editor {
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
background: #1e1e1e;
color: #e0e0e0;
min-height: 100vh;
padding: 20px;
}
.editor-toolbar {
max-width: 1600px;
margin: 0 auto 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.editor-toggle-btn {
padding: 10px 20px;
background: #383838;
color: #e0e0e0;
border: 2px solid #555;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.editor-toggle-btn:hover {
background: #404040;
border-color: #00aaff;
}
.editor-toggle-btn.active {
background: #2d5a7a;
border-color: #00aaff;
color: #fff;
}
.editor-main {
display: flex;
gap: 20px;
max-width: 1600px;
margin: 0 auto;
}
.editor-canvas-container {
flex: 1;
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.editor-canvas {
position: relative;
background: #000;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
.editor-sidebar {
width: 320px;
display: flex;
flex-direction: column;
gap: 20px;
}
.area-list {
background: #2a2a2a;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.area-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.area-list-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.btn-add {
padding: 6px 12px;
background: #00aaff;
color: white;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-add:hover:not(:disabled) {
background: #0088cc;
}
.btn-add:disabled {
background: #555;
cursor: not-allowed;
opacity: 0.5;
}
.area-list-items {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.area-list-empty {
text-align: center;
color: #888;
padding: 20px;
font-size: 13px;
}
.area-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #383838;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.area-item:hover {
background: #404040;
}
.area-item.selected {
background: #2d5a7a;
border-color: #00aaff;
}
.area-item-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.area-item-name {
font-size: 14px;
font-weight: 500;
color: #fff;
}
.area-item-strength {
font-size: 12px;
color: #aaa;
}
.btn-remove {
width: 24px;
height: 24px;
background: #ff4444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-remove:hover {
background: #cc0000;
}
.parameter-panel {
background: #2a2a2a;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
flex: 1;
overflow-y: auto;
}
.parameter-panel h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.parameter-panel-empty {
text-align: center;
color: #888;
padding: 40px 20px;
font-size: 13px;
}
.parameter-group {
margin-bottom: 20px;
}
.parameter-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #ccc;
margin-bottom: 8px;
}
.slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: #444;
outline: none;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #00aaff;
cursor: pointer;
transition: background 0.2s;
}
.slider::-webkit-slider-thumb:hover {
background: #0088cc;
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #00aaff;
cursor: pointer;
border: none;
transition: background 0.2s;
}
.slider::-moz-range-thumb:hover {
background: #0088cc;
}
.input-number {
width: 100%;
padding: 8px;
background: #383838;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input-number:focus {
border-color: #00aaff;
}
.select {
width: 100%;
padding: 8px;
background: #383838;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
font-size: 14px;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
.select:focus {
border-color: #00aaff;
}
.points-display {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.point-coord {
padding: 8px;
background: #383838;
border-radius: 4px;
font-size: 11px;
font-family: "Courier New", monospace;
color: #aaa;
}
.point-handle {
z-index: 10;
transition: transform 0.1s, box-shadow 0.1s;
}
.point-handle:hover {
transform: translate(-50%, -50%) scale(1.2);
box-shadow: 0 4px 8px rgba(0, 170, 255, 0.5);
}
.point-handle.dragging {
cursor: grabbing;
transform: translate(-50%, -50%) scale(1.3);
box-shadow: 0 6px 12px rgba(0, 170, 255, 0.7);
}
.area-list-items::-webkit-scrollbar,
.parameter-panel::-webkit-scrollbar {
width: 8px;
}
.area-list-items::-webkit-scrollbar-track,
.parameter-panel::-webkit-scrollbar-track {
background: #1e1e1e;
border-radius: 4px;
}
.area-list-items::-webkit-scrollbar-thumb,
.parameter-panel::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.area-list-items::-webkit-scrollbar-thumb:hover,
.parameter-panel::-webkit-scrollbar-thumb:hover {
background: #666;
}
@media (max-width: 1200px) {
.editor-main {
flex-direction: column;
}
.editor-sidebar {
width: 100%;
flex-direction: row;
}
.area-list,
.parameter-panel {
flex: 1;
}
}
@media (max-width: 768px) {
.editor-sidebar {
flex-direction: column;
}
.points-display {
grid-template-columns: 1fr;
}
}
/*# sourceMappingURL=index.css.map */

1
dist/index.css.map vendored Normal file

File diff suppressed because one or more lines are too long

584
dist/index.d.mts vendored
View File

@ -1,4 +1,4 @@
import React from 'react'; import React$1 from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
/** /**
@ -11,19 +11,38 @@ interface Point {
/** /**
* *
*/ */
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad'; type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic';
/**
*
*/
type BuiltInMotionPreset = 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
/**
* ( + )
* registerMotionPreset()
*/
type MotionPreset = BuiltInMotionPreset | (string & {});
/**
*
* @param strength (기본값: 0.1)
* @returns x, y
*/
type MotionPresetDefinition = (strength: number) => Point;
/** /**
* *
*/ */
interface DistortionMovement { interface DistortionMovement {
/** 왜곡 시작 벡터 */ /** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
vectorA: Point; vectorA: Point;
/** 왜곡 종료 벡터 */ /** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
vectorB: Point; vectorB: Point;
/** 애니메이션 지속 시간 (초) */ /** 애니메이션 지속 시간 (초) */
duration: number; duration: number;
/** 적용할 이징 함수 */ /** 적용할 이징 함수 */
easing: EasingFunction; easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
} }
/** /**
* *
@ -41,6 +60,21 @@ interface DistortionArea {
progress: number; progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */ /** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point; dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
/** 렌즈 효과 설정 (선택사항) */
lensEffect?: {
/** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */
strength: number;
};
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number;
} }
/** /**
* *
@ -56,7 +90,7 @@ interface AreaBounds {
* *
*/ */
interface ShaderUniforms { interface ShaderUniforms {
[uniform: string]: THREE.IUniform<any>; [uniform: string]: THREE.IUniform;
/** 화면 해상도 */ /** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>; u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */ /** 이미지 텍스처 */
@ -69,6 +103,8 @@ interface ShaderUniforms {
u_dragVectors: THREE.IUniform<Float32Array>; u_dragVectors: THREE.IUniform<Float32Array>;
/** 각 영역의 왜곡 강도 배열 */ /** 각 영역의 왜곡 강도 배열 */
u_distortionStrengths: THREE.IUniform<Float32Array>; u_distortionStrengths: THREE.IUniform<Float32Array>;
/** 각 영역의 렌즈 효과 강도 배열 */
u_lensEffects: THREE.IUniform<Float32Array>;
} }
/** /**
* *
@ -107,6 +143,179 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/** 이펙트 트리거 타입 */
type SpriteEffectTrigger = 'ambient' | 'touch';
/** 블렌드 모드 */
type SpriteBlendMode = 'normal' | 'additive';
/**
*
*/
interface SpriteParticleOverLifetime {
/** [시작, 끝] 스케일 */
scale?: [number, number];
/** [시작, 끝] 투명도 */
opacity?: [number, number];
/** 회전 속도 (라디안/초) */
rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
velocityDamping?: number;
}
/**
*
*/
interface SpriteSheetConfig {
/** 가로 프레임 수 */
columns: number;
/** 세로 프레임 수 */
rows: number;
/** 총 프레임 수 (columns * rows 보다 적을 수 있음) */
totalFrames: number;
/** 재생 속도 (프레임/초) */
fps: number;
/** 반복 재생 여부 (기본: true) */
loop?: boolean;
}
/**
*
* (DistortionArea)
*/
interface SpriteEffectArea {
/** 고유 식별자 */
id: string;
/** 이펙트 중심 좌표 (정규화 0-1) */
position: Point;
/** 터치 감지 반경 (정규화, 기본: 0.1) */
radius?: number;
/** 이 영역에 연결된 이펙트 설정 배열 */
effects: SpriteEffectConfig[];
}
/**
* SpriteEffectArea
* DB
*/
interface SpriteEffectAreaData {
id: string;
position: {
x: number;
y: number;
};
radius?: number;
effects: Array<{
id: string;
trigger: SpriteEffectTrigger;
spriteUrl: string;
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number;
burstCount?: number;
lifetime: [number, number];
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number];
emitOffset?: {
x: number;
y: number;
};
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}>;
}
/**
*
*/
interface SpriteEffectConfig {
/** 고유 식별자 */
id: string;
/** 트리거 타입 */
trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL */
spriteUrl: string;
/** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */
maxParticles: number;
/** ambient: 초당 방출 수 */
emitRate?: number;
/** touch: 터치 시 방출 수 */
burstCount?: number;
/** [최소, 최대] 수명 (초) */
lifetime: [number, number];
/** [최소, 최대] 초기 스케일 */
initialScale: [number, number];
/** [최소, 최대] 초기 속도 */
initialSpeed: [number, number];
/** 방출 각도 범위 (도) */
emitAngle?: [number, number];
/** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point;
/** 방출 범위 반경 */
emitRadius?: number;
/** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
}
/**
*
*/
interface SpringPhysicsConfig {
/** 스프링 탄성 계수 (높을수록 빠르게 복원) */
stiffness: number;
/** 감쇠 계수 (높을수록 빨리 멈춤) */
damping: number;
/** 질량 (높을수록 느리게 움직임) */
mass: number;
/** 영향 반경 (정규화 좌표, 기본값 0.2) */
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;
}
/**
*
*/
interface SpringState {
/** 현재 변위 (displacement) */
displacement: Point;
/** 현재 속도 */
velocity: Point;
/** 목표 위치 (평형 상태는 {x:0, y:0}) */
target: Point;
}
/** /**
* ImageDistortion Props * ImageDistortion Props
*/ */
@ -119,18 +328,174 @@ interface ImageDistortionProps {
vertexShaderPath?: string; vertexShaderPath?: string;
/** 프래그먼트 셰이더 경로 (선택사항) */ /** 프래그먼트 셰이더 경로 (선택사항) */
fragmentShaderPath?: string; fragmentShaderPath?: string;
/** 애니메이션 재생 여부 */
isPlaying?: boolean;
/** 컨테이너 스타일 */ /** 컨테이너 스타일 */
style?: React.CSSProperties; style?: React$1.CSSProperties;
/** 컨테이너 클래스명 */ /** 컨테이너 클래스명 */
className?: string; className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
* GPU * GPU
* Three.js와 GLSL . * Three.js와 GLSL .
*/ */
declare const ImageDistortion: React.FC<ImageDistortionProps>; declare const ImageDistortion: React$1.FC<ImageDistortionProps>;
/**
*
*/
type EditMode = 'normal' | 'point-edit' | 'parameter-edit';
/**
*
*/
interface EditorState {
/** 현재 선택된 영역 ID */
selectedAreaId: string | null;
/** 모든 왜곡 영역 */
areas: DistortionArea[];
/** 현재 편집 모드 */
editMode: EditMode;
/** 드래그 중인 포인트 인덱스 (0-3) */
draggingPointIndex: number | null;
}
/**
*
*/
interface CircleLevelStyle {
/** 반지름 (0.0 - 1.0, UV 좌표) */
radius: number;
/** 투명도 (0.0 - 1.0) */
opacity: number;
/** 선 두께 (픽셀) */
lineWidth: number;
/** 선 색상 (CSS color) */
color?: string;
/** 대시 패턴 [dash, gap] */
dashPattern?: [number, number];
}
/**
*
*/
interface CenterPointStyle {
/** 반지름 (픽셀) */
radius?: number;
/** 채우기 색상 */
fillColor?: string;
/** 테두리 색상 */
strokeColor?: string;
/** 테두리 두께 */
strokeWidth?: number;
}
/**
*
*/
interface PointHandleStyle {
/** 핸들 크기 (픽셀) */
size?: number;
/** 채우기 색상 */
fillColor?: string;
/** 테두리 색상 */
strokeColor?: string;
/** 테두리 두께 */
strokeWidth?: number;
/** 레이블 색상 */
labelColor?: string;
/** 레이블 폰트 크기 */
labelFontSize?: number;
}
/**
*
*/
interface AreaOutlineStyle {
/** 선택된 영역 색상 */
selectedColor?: string;
/** 선택되지 않은 영역 색상 */
unselectedColor?: string;
/** 선택된 영역 선 두께 */
selectedWidth?: number;
/** 선택되지 않은 영역 선 두께 */
unselectedWidth?: number;
/** 선택되지 않은 영역 대시 패턴 */
unselectedDashPattern?: [number, number];
/** 선택된 영역 배경 채우기 색상 */
selectedFillColor?: string;
/** 선택되지 않은 영역 배경 채우기 색상 */
unselectedFillColor?: string;
}
/**
*
*/
interface EditorCanvasStyle {
/** 왜곡 영역 원 레벨 스타일 배열 (외부 -> 내부 순) */
circleLevels?: CircleLevelStyle[];
/** 왜곡 영역 내부 채우기 색상 */
circleFillColor?: string;
/** 중심점 스타일 */
centerPoint?: CenterPointStyle;
/** 포인트 핸들 스타일 */
pointHandle?: PointHandleStyle;
/** 영역 외곽선 스타일 */
areaOutline?: AreaOutlineStyle;
}
interface EditorCanvasProps {
areas: DistortionArea[];
selectedAreaId: string | null;
imageSrc: string;
width: number;
height: number;
onUpdatePoint: (areaId: string, pointIndex: number, point: Point) => void;
onUpdateArea: (areaId: string, updates: Partial<DistortionArea>) => void;
draggingPointIndex: number | null;
onStartDragging: (pointIndex: number) => void;
onStopDragging: () => void;
/** 에디터 캔버스 스타일 커스터마이징 */
style?: EditorCanvasStyle;
/** 에디터 UI 표시 여부 (기본값: true) */
showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void;
/** 독립 스프라이트 이펙트 영역 */
spriteEffectAreas?: SpriteEffectArea[];
/** 스프라이트 이펙트 영역 업데이트 콜백 */
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
}
declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
interface AreaListProps {
areas: DistortionArea[];
selectedAreaId: string | null;
onSelectArea: (areaId: string) => void;
onRemoveArea: (areaId: string) => void;
onAddArea: () => void;
}
declare const AreaList: React$1.FC<AreaListProps>;
interface ParameterPanelProps {
area: DistortionArea | null;
onUpdateArea: (updates: Partial<DistortionArea>) => void;
}
declare const ParameterPanel: React$1.FC<ParameterPanelProps>;
declare const useDistortionEditor: (initialAreas?: DistortionArea[]) => {
state: EditorState;
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;
setEditMode: (mode: EditorState["editMode"]) => void;
getSelectedArea: () => DistortionArea | null;
};
/**
*
*/
declare const DEFAULT_EDITOR_CANVAS_STYLE: EditorCanvasStyle;
/** /**
* *
@ -152,6 +517,8 @@ declare const SHADER_CONFIG: {
readonly MAX_DRAG_VECTORS: 8; readonly MAX_DRAG_VECTORS: 8;
/** 최대 강도 배열 크기 */ /** 최대 강도 배열 크기 */
readonly MAX_STRENGTHS: 8; readonly MAX_STRENGTHS: 8;
/** 최대 렌즈 효과 배열 크기 */
readonly MAX_LENS_EFFECTS: 8;
}; };
/** /**
* *
@ -182,8 +549,80 @@ declare const DEFAULT_AREA: {
readonly x: -0.1; readonly x: -0.1;
readonly y: -0.1; readonly y: -0.1;
}; };
/** 기본 렌즈 효과 강도 */
readonly LENS_STRENGTH: 0;
/** 기본 스텝 양자화 단계 수 (0=없음) */
readonly SNAP_STEPS: 0;
}; };
/**
*
* @param name
* @param definition (strength를 Point )
* @param options
* @param options.isRotation (true면 )
*
* @example
* // 좌우 진짜 왕복 (좌↔우)
* registerMotionPreset('horizontal-full', (strength) => ({
* x: strength * 2, // 진폭 2배
* y: 0
* }));
*
* // 8자 모양 운동 (회전)
* registerMotionPreset('figure-8', (strength) => ({
* x: strength,
* y: strength * 0.5
* }), { isRotation: true });
*/
declare function registerMotionPreset(name: string, definition: MotionPresetDefinition, options?: {
isRotation?: boolean;
}): void;
/**
*
* @param presets ( )
* @param rotationPresetNames
*
* @example
* registerMotionPresets({
* 'horizontal-full': (s) => ({x: s * 2, y: 0}),
* 'wave': (s) => ({x: s, y: s * 0.3}),
* }, ['wave']); // wave는 회전 애니메이션
*/
declare function registerMotionPresets(presets: Record<string, MotionPresetDefinition>, rotationPresetNames?: string[]): void;
/**
*
* @param name
* @returns
*/
declare function unregisterMotionPreset(name: string): boolean;
/**
*
* @returns
*/
declare function getRegisteredPresets(): string[];
/**
*
* @param name
* @returns
*/
declare function hasPreset(name: string): boolean;
/**
* ( )
*/
declare function resetToBuiltInPresets(): void;
/**
*
* @param preset
* @param strength (기본값: 0.1)
* @returns (vectorA)
*/
declare function presetToVector(preset: MotionPreset, strength?: number): Point;
/**
*
*/
declare function isRotationPreset(preset?: MotionPreset): boolean;
/** /**
* Three.js * Three.js
*/ */
@ -205,6 +644,10 @@ declare class ThreeScene {
* @param fragmentShader * @param fragmentShader
*/ */
setShaderMaterial(vertexShader: string, fragmentShader: string): void; setShaderMaterial(vertexShader: string, fragmentShader: string): void;
/**
* Three.js
*/
getScene(): THREE.Scene;
/** /**
* *
* @param updates * @param updates
@ -214,6 +657,13 @@ declare class ThreeScene {
* *
*/ */
render(): void; render(): void;
/**
*
*/
getResolution(): {
x: number;
y: number;
};
/** /**
* *
*/ */
@ -265,6 +715,100 @@ declare class AnimationLoop {
static updateProgress(areas: DistortionArea[], deltaTime: number): DistortionArea[]; static updateProgress(areas: DistortionArea[], deltaTime: number): DistortionArea[];
} }
/**
*
* Hooke's Law와 -
*/
declare class SpringPhysics {
private config;
private state;
constructor(config: SpringPhysicsConfig);
/**
*
*/
setConfig(config: Partial<SpringPhysicsConfig>): void;
/**
* ( )
*/
setTarget(velocity: Point, velocityMultiplier?: number): void;
/**
* ( )
*
*/
setInitialVelocity(velocity: Point, multiplier?: number): void;
/**
* (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
update(deltaTime: number): Point;
/**
* ( )
*/
applyImpulse(acceleration: Point, multiplier?: number): void;
/**
*
*/
getDisplacement(): Point;
/**
*
*/
getVelocity(): Point;
/**
*
*/
reset(): void;
/**
* 0 ( )
*/
returnToEquilibrium(): void;
}
/**
* / ( )
*/
interface SpriteEffectTouchState {
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
position: Point | null;
/** 드래그 중 여부 */
isDragging: boolean;
}
/**
*
* ImageDistortion
* (DistortionArea)
*/
declare class SpriteEffectManager {
/** 모든 이펙트 메쉬를 담는 그룹 */
private effectGroup;
/** 영역ID+이펙트ID → 인스턴스 맵 */
private instances;
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
private previousTouchingAreas;
constructor();
/**
* Three.js
*/
attachToScene(scene: THREE.Scene): void;
/**
* /
*/
syncEffects(effectAreas: SpriteEffectArea[]): void;
/**
*
* @param effectAreas
* @param deltaTime
* @param touchState /
*/
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
/**
*
*/
dispose(): void;
}
/** /**
* requestAnimationFrame을 * requestAnimationFrame을
* @param callback (deltaTime을 ) * @param callback (deltaTime을 )
@ -272,4 +816,24 @@ declare class AnimationLoop {
*/ */
declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void; declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void;
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, type DistortionMovement, type EasingFunction, ImageDistortion, type ImageDistortionProps, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, ThreeScene, applyEasing, useAnimationFrame }; /**
* , ,
*/
declare const useMouseVelocity: (containerRef: React.RefObject<HTMLElement | null>) => {
getState: () => MouseState;
};
/**
*
*
*/
declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement | null>, config: MouseInteractionConfig) => {
updateInteraction: (areas: DistortionArea[], deltaTime: number) => DistortionArea[];
updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void;
reset: () => void;
isDragging: () => boolean;
getInteractingAreaIndices: () => Set<number>;
getMouseState: () => MouseState;
};
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, type SpriteBlendMode, type SpriteEffectArea, type SpriteEffectAreaData, type SpriteEffectConfig, SpriteEffectManager, type SpriteEffectTrigger, type SpriteParticleOverLifetime, type SpriteSheetConfig, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

584
dist/index.d.ts vendored
View File

@ -1,4 +1,4 @@
import React from 'react'; import React$1 from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
/** /**
@ -11,19 +11,38 @@ interface Point {
/** /**
* *
*/ */
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad'; type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad' | 'easeInCubic' | 'easeOutCubic';
/**
*
*/
type BuiltInMotionPreset = 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
/**
* ( + )
* registerMotionPreset()
*/
type MotionPreset = BuiltInMotionPreset | (string & {});
/**
*
* @param strength (기본값: 0.1)
* @returns x, y
*/
type MotionPresetDefinition = (strength: number) => Point;
/** /**
* *
*/ */
interface DistortionMovement { interface DistortionMovement {
/** 왜곡 시작 벡터 */ /** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
vectorA: Point; vectorA: Point;
/** 왜곡 종료 벡터 */ /** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
vectorB: Point; vectorB: Point;
/** 애니메이션 지속 시간 (초) */ /** 애니메이션 지속 시간 (초) */
duration: number; duration: number;
/** 적용할 이징 함수 */ /** 적용할 이징 함수 */
easing: EasingFunction; easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
} }
/** /**
* *
@ -41,6 +60,21 @@ interface DistortionArea {
progress: number; progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */ /** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point; dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
/** 렌즈 효과 설정 (선택사항) */
lensEffect?: {
/** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */
strength: number;
};
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number;
} }
/** /**
* *
@ -56,7 +90,7 @@ interface AreaBounds {
* *
*/ */
interface ShaderUniforms { interface ShaderUniforms {
[uniform: string]: THREE.IUniform<any>; [uniform: string]: THREE.IUniform;
/** 화면 해상도 */ /** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>; u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */ /** 이미지 텍스처 */
@ -69,6 +103,8 @@ interface ShaderUniforms {
u_dragVectors: THREE.IUniform<Float32Array>; u_dragVectors: THREE.IUniform<Float32Array>;
/** 각 영역의 왜곡 강도 배열 */ /** 각 영역의 왜곡 강도 배열 */
u_distortionStrengths: THREE.IUniform<Float32Array>; u_distortionStrengths: THREE.IUniform<Float32Array>;
/** 각 영역의 렌즈 효과 강도 배열 */
u_lensEffects: THREE.IUniform<Float32Array>;
} }
/** /**
* *
@ -107,6 +143,179 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/** 이펙트 트리거 타입 */
type SpriteEffectTrigger = 'ambient' | 'touch';
/** 블렌드 모드 */
type SpriteBlendMode = 'normal' | 'additive';
/**
*
*/
interface SpriteParticleOverLifetime {
/** [시작, 끝] 스케일 */
scale?: [number, number];
/** [시작, 끝] 투명도 */
opacity?: [number, number];
/** 회전 속도 (라디안/초) */
rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
velocityDamping?: number;
}
/**
*
*/
interface SpriteSheetConfig {
/** 가로 프레임 수 */
columns: number;
/** 세로 프레임 수 */
rows: number;
/** 총 프레임 수 (columns * rows 보다 적을 수 있음) */
totalFrames: number;
/** 재생 속도 (프레임/초) */
fps: number;
/** 반복 재생 여부 (기본: true) */
loop?: boolean;
}
/**
*
* (DistortionArea)
*/
interface SpriteEffectArea {
/** 고유 식별자 */
id: string;
/** 이펙트 중심 좌표 (정규화 0-1) */
position: Point;
/** 터치 감지 반경 (정규화, 기본: 0.1) */
radius?: number;
/** 이 영역에 연결된 이펙트 설정 배열 */
effects: SpriteEffectConfig[];
}
/**
* SpriteEffectArea
* DB
*/
interface SpriteEffectAreaData {
id: string;
position: {
x: number;
y: number;
};
radius?: number;
effects: Array<{
id: string;
trigger: SpriteEffectTrigger;
spriteUrl: string;
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number;
burstCount?: number;
lifetime: [number, number];
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number];
emitOffset?: {
x: number;
y: number;
};
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}>;
}
/**
*
*/
interface SpriteEffectConfig {
/** 고유 식별자 */
id: string;
/** 트리거 타입 */
trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL */
spriteUrl: string;
/** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */
maxParticles: number;
/** ambient: 초당 방출 수 */
emitRate?: number;
/** touch: 터치 시 방출 수 */
burstCount?: number;
/** [최소, 최대] 수명 (초) */
lifetime: [number, number];
/** [최소, 최대] 초기 스케일 */
initialScale: [number, number];
/** [최소, 최대] 초기 속도 */
initialSpeed: [number, number];
/** 방출 각도 범위 (도) */
emitAngle?: [number, number];
/** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point;
/** 방출 범위 반경 */
emitRadius?: number;
/** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
}
/**
*
*/
interface SpringPhysicsConfig {
/** 스프링 탄성 계수 (높을수록 빠르게 복원) */
stiffness: number;
/** 감쇠 계수 (높을수록 빨리 멈춤) */
damping: number;
/** 질량 (높을수록 느리게 움직임) */
mass: number;
/** 영향 반경 (정규화 좌표, 기본값 0.2) */
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;
}
/**
*
*/
interface SpringState {
/** 현재 변위 (displacement) */
displacement: Point;
/** 현재 속도 */
velocity: Point;
/** 목표 위치 (평형 상태는 {x:0, y:0}) */
target: Point;
}
/** /**
* ImageDistortion Props * ImageDistortion Props
*/ */
@ -119,18 +328,174 @@ interface ImageDistortionProps {
vertexShaderPath?: string; vertexShaderPath?: string;
/** 프래그먼트 셰이더 경로 (선택사항) */ /** 프래그먼트 셰이더 경로 (선택사항) */
fragmentShaderPath?: string; fragmentShaderPath?: string;
/** 애니메이션 재생 여부 */
isPlaying?: boolean;
/** 컨테이너 스타일 */ /** 컨테이너 스타일 */
style?: React.CSSProperties; style?: React$1.CSSProperties;
/** 컨테이너 클래스명 */ /** 컨테이너 클래스명 */
className?: string; className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
* GPU * GPU
* Three.js와 GLSL . * Three.js와 GLSL .
*/ */
declare const ImageDistortion: React.FC<ImageDistortionProps>; declare const ImageDistortion: React$1.FC<ImageDistortionProps>;
/**
*
*/
type EditMode = 'normal' | 'point-edit' | 'parameter-edit';
/**
*
*/
interface EditorState {
/** 현재 선택된 영역 ID */
selectedAreaId: string | null;
/** 모든 왜곡 영역 */
areas: DistortionArea[];
/** 현재 편집 모드 */
editMode: EditMode;
/** 드래그 중인 포인트 인덱스 (0-3) */
draggingPointIndex: number | null;
}
/**
*
*/
interface CircleLevelStyle {
/** 반지름 (0.0 - 1.0, UV 좌표) */
radius: number;
/** 투명도 (0.0 - 1.0) */
opacity: number;
/** 선 두께 (픽셀) */
lineWidth: number;
/** 선 색상 (CSS color) */
color?: string;
/** 대시 패턴 [dash, gap] */
dashPattern?: [number, number];
}
/**
*
*/
interface CenterPointStyle {
/** 반지름 (픽셀) */
radius?: number;
/** 채우기 색상 */
fillColor?: string;
/** 테두리 색상 */
strokeColor?: string;
/** 테두리 두께 */
strokeWidth?: number;
}
/**
*
*/
interface PointHandleStyle {
/** 핸들 크기 (픽셀) */
size?: number;
/** 채우기 색상 */
fillColor?: string;
/** 테두리 색상 */
strokeColor?: string;
/** 테두리 두께 */
strokeWidth?: number;
/** 레이블 색상 */
labelColor?: string;
/** 레이블 폰트 크기 */
labelFontSize?: number;
}
/**
*
*/
interface AreaOutlineStyle {
/** 선택된 영역 색상 */
selectedColor?: string;
/** 선택되지 않은 영역 색상 */
unselectedColor?: string;
/** 선택된 영역 선 두께 */
selectedWidth?: number;
/** 선택되지 않은 영역 선 두께 */
unselectedWidth?: number;
/** 선택되지 않은 영역 대시 패턴 */
unselectedDashPattern?: [number, number];
/** 선택된 영역 배경 채우기 색상 */
selectedFillColor?: string;
/** 선택되지 않은 영역 배경 채우기 색상 */
unselectedFillColor?: string;
}
/**
*
*/
interface EditorCanvasStyle {
/** 왜곡 영역 원 레벨 스타일 배열 (외부 -> 내부 순) */
circleLevels?: CircleLevelStyle[];
/** 왜곡 영역 내부 채우기 색상 */
circleFillColor?: string;
/** 중심점 스타일 */
centerPoint?: CenterPointStyle;
/** 포인트 핸들 스타일 */
pointHandle?: PointHandleStyle;
/** 영역 외곽선 스타일 */
areaOutline?: AreaOutlineStyle;
}
interface EditorCanvasProps {
areas: DistortionArea[];
selectedAreaId: string | null;
imageSrc: string;
width: number;
height: number;
onUpdatePoint: (areaId: string, pointIndex: number, point: Point) => void;
onUpdateArea: (areaId: string, updates: Partial<DistortionArea>) => void;
draggingPointIndex: number | null;
onStartDragging: (pointIndex: number) => void;
onStopDragging: () => void;
/** 에디터 캔버스 스타일 커스터마이징 */
style?: EditorCanvasStyle;
/** 에디터 UI 표시 여부 (기본값: true) */
showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void;
/** 독립 스프라이트 이펙트 영역 */
spriteEffectAreas?: SpriteEffectArea[];
/** 스프라이트 이펙트 영역 업데이트 콜백 */
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
}
declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
interface AreaListProps {
areas: DistortionArea[];
selectedAreaId: string | null;
onSelectArea: (areaId: string) => void;
onRemoveArea: (areaId: string) => void;
onAddArea: () => void;
}
declare const AreaList: React$1.FC<AreaListProps>;
interface ParameterPanelProps {
area: DistortionArea | null;
onUpdateArea: (updates: Partial<DistortionArea>) => void;
}
declare const ParameterPanel: React$1.FC<ParameterPanelProps>;
declare const useDistortionEditor: (initialAreas?: DistortionArea[]) => {
state: EditorState;
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;
setEditMode: (mode: EditorState["editMode"]) => void;
getSelectedArea: () => DistortionArea | null;
};
/**
*
*/
declare const DEFAULT_EDITOR_CANVAS_STYLE: EditorCanvasStyle;
/** /**
* *
@ -152,6 +517,8 @@ declare const SHADER_CONFIG: {
readonly MAX_DRAG_VECTORS: 8; readonly MAX_DRAG_VECTORS: 8;
/** 최대 강도 배열 크기 */ /** 최대 강도 배열 크기 */
readonly MAX_STRENGTHS: 8; readonly MAX_STRENGTHS: 8;
/** 최대 렌즈 효과 배열 크기 */
readonly MAX_LENS_EFFECTS: 8;
}; };
/** /**
* *
@ -182,8 +549,80 @@ declare const DEFAULT_AREA: {
readonly x: -0.1; readonly x: -0.1;
readonly y: -0.1; readonly y: -0.1;
}; };
/** 기본 렌즈 효과 강도 */
readonly LENS_STRENGTH: 0;
/** 기본 스텝 양자화 단계 수 (0=없음) */
readonly SNAP_STEPS: 0;
}; };
/**
*
* @param name
* @param definition (strength를 Point )
* @param options
* @param options.isRotation (true면 )
*
* @example
* // 좌우 진짜 왕복 (좌↔우)
* registerMotionPreset('horizontal-full', (strength) => ({
* x: strength * 2, // 진폭 2배
* y: 0
* }));
*
* // 8자 모양 운동 (회전)
* registerMotionPreset('figure-8', (strength) => ({
* x: strength,
* y: strength * 0.5
* }), { isRotation: true });
*/
declare function registerMotionPreset(name: string, definition: MotionPresetDefinition, options?: {
isRotation?: boolean;
}): void;
/**
*
* @param presets ( )
* @param rotationPresetNames
*
* @example
* registerMotionPresets({
* 'horizontal-full': (s) => ({x: s * 2, y: 0}),
* 'wave': (s) => ({x: s, y: s * 0.3}),
* }, ['wave']); // wave는 회전 애니메이션
*/
declare function registerMotionPresets(presets: Record<string, MotionPresetDefinition>, rotationPresetNames?: string[]): void;
/**
*
* @param name
* @returns
*/
declare function unregisterMotionPreset(name: string): boolean;
/**
*
* @returns
*/
declare function getRegisteredPresets(): string[];
/**
*
* @param name
* @returns
*/
declare function hasPreset(name: string): boolean;
/**
* ( )
*/
declare function resetToBuiltInPresets(): void;
/**
*
* @param preset
* @param strength (기본값: 0.1)
* @returns (vectorA)
*/
declare function presetToVector(preset: MotionPreset, strength?: number): Point;
/**
*
*/
declare function isRotationPreset(preset?: MotionPreset): boolean;
/** /**
* Three.js * Three.js
*/ */
@ -205,6 +644,10 @@ declare class ThreeScene {
* @param fragmentShader * @param fragmentShader
*/ */
setShaderMaterial(vertexShader: string, fragmentShader: string): void; setShaderMaterial(vertexShader: string, fragmentShader: string): void;
/**
* Three.js
*/
getScene(): THREE.Scene;
/** /**
* *
* @param updates * @param updates
@ -214,6 +657,13 @@ declare class ThreeScene {
* *
*/ */
render(): void; render(): void;
/**
*
*/
getResolution(): {
x: number;
y: number;
};
/** /**
* *
*/ */
@ -265,6 +715,100 @@ declare class AnimationLoop {
static updateProgress(areas: DistortionArea[], deltaTime: number): DistortionArea[]; static updateProgress(areas: DistortionArea[], deltaTime: number): DistortionArea[];
} }
/**
*
* Hooke's Law와 -
*/
declare class SpringPhysics {
private config;
private state;
constructor(config: SpringPhysicsConfig);
/**
*
*/
setConfig(config: Partial<SpringPhysicsConfig>): void;
/**
* ( )
*/
setTarget(velocity: Point, velocityMultiplier?: number): void;
/**
* ( )
*
*/
setInitialVelocity(velocity: Point, multiplier?: number): void;
/**
* (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
update(deltaTime: number): Point;
/**
* ( )
*/
applyImpulse(acceleration: Point, multiplier?: number): void;
/**
*
*/
getDisplacement(): Point;
/**
*
*/
getVelocity(): Point;
/**
*
*/
reset(): void;
/**
* 0 ( )
*/
returnToEquilibrium(): void;
}
/**
* / ( )
*/
interface SpriteEffectTouchState {
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
position: Point | null;
/** 드래그 중 여부 */
isDragging: boolean;
}
/**
*
* ImageDistortion
* (DistortionArea)
*/
declare class SpriteEffectManager {
/** 모든 이펙트 메쉬를 담는 그룹 */
private effectGroup;
/** 영역ID+이펙트ID → 인스턴스 맵 */
private instances;
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
private previousTouchingAreas;
constructor();
/**
* Three.js
*/
attachToScene(scene: THREE.Scene): void;
/**
* /
*/
syncEffects(effectAreas: SpriteEffectArea[]): void;
/**
*
* @param effectAreas
* @param deltaTime
* @param touchState /
*/
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
/**
*
*/
dispose(): void;
}
/** /**
* requestAnimationFrame을 * requestAnimationFrame을
* @param callback (deltaTime을 ) * @param callback (deltaTime을 )
@ -272,4 +816,24 @@ declare class AnimationLoop {
*/ */
declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void; declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void;
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, type DistortionMovement, type EasingFunction, ImageDistortion, type ImageDistortionProps, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, ThreeScene, applyEasing, useAnimationFrame }; /**
* , ,
*/
declare const useMouseVelocity: (containerRef: React.RefObject<HTMLElement | null>) => {
getState: () => MouseState;
};
/**
*
*
*/
declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement | null>, config: MouseInteractionConfig) => {
updateInteraction: (areas: DistortionArea[], deltaTime: number) => DistortionArea[];
updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void;
reset: () => void;
isDragging: () => boolean;
getInteractingAreaIndices: () => Set<number>;
getMouseState: () => MouseState;
};
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, type SpriteBlendMode, type SpriteEffectArea, type SpriteEffectAreaData, type SpriteEffectConfig, SpriteEffectManager, type SpriteEffectTrigger, type SpriteParticleOverLifetime, type SpriteSheetConfig, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

1964
dist/index.js vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

1945
dist/index.mjs vendored

File diff suppressed because it is too large Load Diff

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

8
dist/test.frag.glsl vendored Normal file
View File

@ -0,0 +1,8 @@
uniform sampler2D u_texture;
varying vec2 vUv;
void main() {
// 간단한 테스트: 텍스처를 그대로 표시 (왜곡 없음)
vec4 color = texture2D(u_texture, vUv);
gl_FragColor = color;
}

14
package-lock.json generated
View File

@ -1,12 +1,13 @@
{ {
"name": "responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.0.0", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.0.0", "version": "1.3.0",
"license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
@ -16,6 +17,11 @@
"three": "^0.181.0", "three": "^0.181.0",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "^5.5.3" "typescript": "^5.5.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"three": ">=0.150.0"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": { "node_modules/@dimforge/rapier3d-compat": {

View File

@ -1,6 +1,9 @@
{ {
"name": "responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.0.0", "version": "1.5.2",
"publishConfig": {
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
},
"description": "React component for interactive image distortion with GPU-accelerated shaders", "description": "React component for interactive image distortion with GPU-accelerated shaders",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
@ -22,7 +25,7 @@
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0",
"three": "^0.150.0" "three": ">=0.150.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.2", "@types/react": "^19.2.2",

BIN
petal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,11 +1,14 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import { DistortionArea } from '../types'; import {type DistortionArea, SpriteEffectArea} from '@/types';
import { ThreeScene } from '../engine/ThreeScene'; import { ThreeScene } from '@/engine/ThreeScene';
import { ShaderManager } from '../engine/ShaderManager'; import { ShaderManager } from '@/engine/ShaderManager';
import { AnimationLoop } from '../engine/AnimationLoop'; import { AnimationLoop } from '@/engine/AnimationLoop';
import { useAnimationFrame } from '../hooks/useAnimationFrame'; import { SpriteEffectManager } from '@/engine/SpriteEffectManager';
import { SHADER_CONFIG } from '../utils/constants'; import { useAnimationFrame } from '@/hooks/useAnimationFrame';
import { useMouseInteraction } from '@/hooks/useMouseInteraction';
import { SHADER_CONFIG } from '@/utils/constants';
import { MouseInteractionConfig } from '@/types/interaction';
/** /**
* ImageDistortion Props * ImageDistortion Props
@ -19,12 +22,14 @@ export interface ImageDistortionProps {
vertexShaderPath?: string; vertexShaderPath?: string;
/** 프래그먼트 셰이더 경로 (선택사항) */ /** 프래그먼트 셰이더 경로 (선택사항) */
fragmentShaderPath?: string; fragmentShaderPath?: string;
/** 애니메이션 재생 여부 */
isPlaying?: boolean;
/** 컨테이너 스타일 */ /** 컨테이너 스타일 */
style?: React.CSSProperties; style?: React.CSSProperties;
/** 컨테이너 클래스명 */ /** 컨테이너 클래스명 */
className?: string; className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
@ -36,27 +41,81 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
areas, areas,
vertexShaderPath, vertexShaderPath,
fragmentShaderPath, fragmentShaderPath,
isPlaying = true,
style, style,
className, className,
mouseInteraction,
spriteEffectAreas = [],
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<ThreeScene | null>(null); const sceneRef = useRef<ThreeScene | null>(null);
const shaderManagerRef = useRef<ShaderManager>(new ShaderManager()); const shaderManagerRef = useRef<ShaderManager>(new ShaderManager());
const textureRef = useRef<THREE.Texture | null>(null); const textureRef = useRef<THREE.Texture | null>(null);
const spriteManagerRef = useRef<SpriteEffectManager | null>(null);
const currentAreasRef = useRef<DistortionArea[]>(areas);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [currentAreas, setCurrentAreas] = useState<DistortionArea[]>(areas); 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(() => { useEffect(() => {
setCurrentAreas(areas); setCurrentAreas(areas);
}, [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 씬 초기화 // Three.js 씬 초기화
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; 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); const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene; sceneRef.current = scene;
@ -64,14 +123,17 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
const vertPath = vertexShaderPath || '/shaders/distortion.vert.glsl'; const vertPath = vertexShaderPath || '/shaders/distortion.vert.glsl';
const fragPath = fragmentShaderPath || '/shaders/distortion.frag.glsl'; const fragPath = fragmentShaderPath || '/shaders/distortion.frag.glsl';
console.log('[ImageDistortion] 셰이더 로드 시도:', { vertPath, fragPath });
shaderManagerRef.current shaderManagerRef.current
.loadShaders(vertPath, fragPath) .loadShaders(vertPath, fragPath)
.then(({ vertex, fragment }) => { .then(({ vertex, fragment }) => {
console.log('[ImageDistortion] 셰이더 로드 성공');
scene.setShaderMaterial(vertex, fragment); scene.setShaderMaterial(vertex, fragment);
setIsReady(true); setIsReady(true);
}) })
.catch((error) => { .catch((error) => {
console.error('셰이더 로드 실패:', error); console.error('[ImageDistortion] 셰이더 로드 실패:', error);
}); });
return () => { return () => {
@ -84,23 +146,40 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
// 이미지 텍스처 로드 // 이미지 텍스처 로드
useEffect(() => { useEffect(() => {
if (!imageSrc || !isReady) return; if (!imageSrc || !isReady) {
console.log('[ImageDistortion] 이미지 로드 스킵:', { imageSrc, isReady });
return;
}
console.log('[ImageDistortion] 이미지 로드 시작:', imageSrc);
setImageLoaded(false);
const loader = new THREE.TextureLoader(); const loader = new THREE.TextureLoader();
loader.load( loader.load(
imageSrc, imageSrc,
(texture) => { (texture) => {
console.log('[ImageDistortion] 이미지 로드 성공!', {
width: texture.image.width,
height: texture.image.height
});
textureRef.current = texture; textureRef.current = texture;
setImageLoaded(true);
if (sceneRef.current) { if (sceneRef.current) {
sceneRef.current.updateUniforms({ sceneRef.current.updateUniforms({
u_texture: { value: texture }, u_texture: { value: texture },
}); });
sceneRef.current.render(); sceneRef.current.render();
console.log('[ImageDistortion] 텍스처 업데이트 및 렌더링 완료');
} }
}, },
undefined, (progress) => {
console.log('[ImageDistortion] 이미지 로딩 중...',
Math.round((progress.loaded / progress.total) * 100) + '%'
);
},
(error) => { (error) => {
console.error('이미지 로드 실패:', error); console.error('[ImageDistortion] 이미지 로드 실패:', error);
setImageLoaded(false);
} }
); );
@ -116,22 +195,27 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
useEffect(() => { useEffect(() => {
if (!sceneRef.current || !isReady) return; 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); const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2);
currentAreas.forEach((area, areaIndex) => { currentAreas.forEach((area, areaIndex) => {
area.basePoints.forEach((point, pointIndex) => { area.basePoints.forEach((point, pointIndex) => {
const index = (areaIndex * 4 + pointIndex) * 2; const index = (areaIndex * 4 + pointIndex) * 2;
points[index] = point.x; points[index] = point.x;
points[index + 1] = point.y; points[index + 1] = 1.0 - point.y; // y 좌표 반전
}); });
}); });
// 드래그 벡터 배열 생성 // 드래그 벡터 배열 생성
// dragVector도 y 좌표계를 맞춰야 하므로 y를 반전
const dragVectors = new Float32Array(SHADER_CONFIG.MAX_DRAG_VECTORS * 2); const dragVectors = new Float32Array(SHADER_CONFIG.MAX_DRAG_VECTORS * 2);
currentAreas.forEach((area, index) => { currentAreas.forEach((area, index) => {
const baseIndex = index * 2; const baseIndex = index * 2;
dragVectors[baseIndex] = area.dragVector.x; dragVectors[baseIndex] = area.dragVector.x;
dragVectors[baseIndex + 1] = area.dragVector.y; dragVectors[baseIndex + 1] = -area.dragVector.y; // y 방향 반전
}); });
// 강도 배열 생성 // 강도 배열 생성
@ -140,11 +224,18 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
strengths[index] = area.distortionStrength; 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({ sceneRef.current.updateUniforms({
u_numAreas: { value: currentAreas.length }, u_numAreas: { value: currentAreas.length },
u_points: { value: points }, u_points: { value: points },
u_dragVectors: { value: dragVectors }, u_dragVectors: { value: dragVectors },
u_distortionStrengths: { value: strengths }, u_distortionStrengths: { value: strengths },
u_lensEffects: { value: lensEffects },
}); });
sceneRef.current.render(); sceneRef.current.render();
@ -154,16 +245,56 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
const animationCallback = useCallback((deltaTime: number) => { const animationCallback = useCallback((deltaTime: number) => {
if (!isReady) return; if (!isReady) return;
// 진행도 업데이트 setCurrentAreas((prevAreas) => {
const updatedAreas = AnimationLoop.updateProgress(currentAreas, deltaTime); // 현재 인터랙션 중인 영역 인덱스 가져오기
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || new Set<number>();
// 드래그 벡터 업데이트 // 1. 자동 애니메이션 업데이트
const areasWithVectors = AnimationLoop.updateAreaDragVectors(updatedAreas); let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
setCurrentAreas(areasWithVectors); // 인터랙션 중인 영역만 dragVector를 0으로 설정
}, [currentAreas, isReady]); if (interactingIndices.size > 0) {
updatedAreas = updatedAreas.map((area, index) => {
if (interactingIndices.has(index)) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
return area;
});
}
useAnimationFrame(animationCallback, isPlaying); // 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 ( return (
<div <div
@ -175,6 +306,24 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
...style, ...style,
}} }}
className={className} 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>
); );
}; };

View File

@ -0,0 +1,62 @@
import React from 'react';
import { DistortionArea } from '../../types/area';
export interface AreaListProps {
areas: DistortionArea[];
selectedAreaId: string | null;
onSelectArea: (areaId: string) => void;
onRemoveArea: (areaId: string) => void;
onAddArea: () => void;
}
export const AreaList: React.FC<AreaListProps> = ({
areas,
selectedAreaId,
onSelectArea,
onRemoveArea,
onAddArea,
}) => {
return (
<div className="area-list">
<div className="area-list-header">
<h3> </h3>
<button
onClick={onAddArea}
disabled={areas.length >= 8}
className="btn-add"
title={areas.length >= 8 ? '최대 8개 영역까지 지원' : '새 영역 추가'}
>
+
</button>
</div>
<div className="area-list-items">
{areas.length === 0 ? (
<div className="area-list-empty"> . + .</div>
) : (
areas.map((area, index) => (
<div
key={area.id}
className={`area-item ${selectedAreaId === area.id ? 'selected' : ''}`}
onClick={() => onSelectArea(area.id)}
>
<div className="area-item-info">
<span className="area-item-name"> {index + 1}</span>
<span className="area-item-strength">: {(area.distortionStrength * 100).toFixed(0)}%</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRemoveArea(area.id);
}}
className="btn-remove"
title="영역 삭제"
>
×
</button>
</div>
))
)}
</div>
</div>
);
};

View File

@ -0,0 +1,594 @@
import React, {useRef, useEffect, useState, useCallback, useMemo} from 'react';
import {DistortionArea, Point} from '@/types';
import type {SpriteEffectArea} from '@/types/spriteEffect';
import {ImageDistortion} from '@/components/ImageDistortion';
import {EditorCanvasStyle} from '../types';
import {DEFAULT_EDITOR_CANVAS_STYLE} from '@/editor';
export interface EditorCanvasProps {
areas: DistortionArea[];
selectedAreaId: string | null;
imageSrc: string;
width: number;
height: number;
onUpdatePoint: (areaId: string, pointIndex: number, point: Point) => void;
onUpdateArea: (areaId: string, updates: Partial<DistortionArea>) => void;
draggingPointIndex: number | null;
onStartDragging: (pointIndex: number) => void;
onStopDragging: () => void;
/** 에디터 캔버스 스타일 커스터마이징 */
style?: EditorCanvasStyle;
/** 에디터 UI 표시 여부 (기본값: true) */
showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void;
/** 독립 스프라이트 이펙트 영역 */
spriteEffectAreas?: SpriteEffectArea[];
/** 스프라이트 이펙트 영역 업데이트 콜백 */
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
}
export const EditorCanvas: React.FC<EditorCanvasProps> = ({
areas,
selectedAreaId,
imageSrc,
width,
height,
onUpdatePoint,
onUpdateArea,
draggingPointIndex,
onStartDragging,
onStopDragging,
style: customStyle,
showEditor = true,
onSelectArea,
spriteEffectAreas = [],
onUpdateSpriteEffectArea,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({width: 0, height: 0});
const [isDraggingArea, setIsDraggingArea] = useState(false);
const [dragStartPos, setDragStartPos] = useState<Point | null>(null);
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = useState<string | null>(null);
// 스타일 병합 (커스텀 스타일 우선)
const editorStyle = useMemo(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle,
circleLevels: customStyle?.circleLevels || DEFAULT_EDITOR_CANVAS_STYLE.circleLevels,
centerPoint: {
...DEFAULT_EDITOR_CANVAS_STYLE.centerPoint,
...customStyle?.centerPoint,
},
pointHandle: {
...DEFAULT_EDITOR_CANVAS_STYLE.pointHandle,
...customStyle?.pointHandle,
},
areaOutline: {
...DEFAULT_EDITOR_CANVAS_STYLE.areaOutline,
...customStyle?.areaOutline,
},
}), [customStyle]);
// 컨테이너 크기 측정 (ResizeObserver 사용)
useEffect(() => {
if (!containerRef.current) return;
const updateSize = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setCanvasSize({width: rect.width, height: rect.height});
};
// 초기 크기 설정
updateSize();
// ResizeObserver로 크기 변경 감지
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
// 선택된 영역 찾기
const selectedArea = areas.find((a) => a.id === selectedAreaId);
// 점이 사각형 내부에 있는지 확인 (Point-in-Polygon test)
const isPointInPolygon = useCallback((point: Point, polygon: Point[]): boolean => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = ((yi > point.y) !== (yj > point.y))
&& (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}, []);
// 포인트 핸들 클릭/터치 핸들러
const handlePointDown = useCallback(
(pointIndex: number) => (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
onStartDragging(pointIndex);
},
[onStartDragging]
);
// 캔버스 다운 (마우스/터치 공통)
const handleCanvasDown = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
// 에디터가 숨겨진 상태면 동작하지 않음
if (!showEditor || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
// 마우스 또는 터치 좌표 추출
let clientX: number, clientY: number;
if ('touches' in e) {
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y };
// 스프라이트 이펙트 영역 클릭 확인 (우선 처리)
if (onUpdateSpriteEffectArea) {
for (let i = spriteEffectAreas.length - 1; i >= 0; i--) {
const sa = spriteEffectAreas[i];
const dx = clickPoint.x - sa.position.x;
const dy = clickPoint.y - sa.position.y;
const radius = sa.radius ?? 0.1;
if (dx * dx + dy * dy <= radius * radius) {
setDraggingSpriteAreaId(sa.id);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
}
}
// 선택된 영역 내부를 클릭했는지 확인 (드래그 시작)
if (selectedArea && isPointInPolygon(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
// 비선택 영역 클릭 시 해당 영역 선택
if (onSelectArea) {
// 역순으로 검사 (위에 그려진 영역 우선)
for (let i = areas.length - 1; i >= 0; i--) {
const area = areas[i];
if (area.id !== selectedAreaId && isPointInPolygon(clickPoint, area.basePoints)) {
onSelectArea(area.id);
e.preventDefault();
return;
}
}
}
},
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea]
);
// 이동 (마우스/터치 공통)
const handleMove = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
// 에디터가 숨겨진 상태면 동작하지 않음
if (!showEditor || !containerRef.current) return;
// 터치 이벤트면 스크롤 방지
if ('touches' in e && (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId)) {
e.preventDefault();
}
const rect = containerRef.current.getBoundingClientRect();
// 마우스 또는 터치 좌표 추출
let clientX: number, clientY: number;
if ('touches' in e) {
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
// 스프라이트 이펙트 영역 드래그 중
if (draggingSpriteAreaId && dragStartPos && onUpdateSpriteEffectArea) {
const sa = spriteEffectAreas.find(a => a.id === draggingSpriteAreaId);
if (sa) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
onUpdateSpriteEffectArea(sa.id, {
position: {
x: Math.max(0, Math.min(1, sa.position.x + deltaX)),
y: Math.max(0, Math.min(1, sa.position.y + deltaY)),
},
});
setDragStartPos({x, y});
}
return;
}
if (!selectedArea) return;
// 포인트 드래그 중
if (draggingPointIndex !== null) {
const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y));
onUpdatePoint(selectedArea.id, draggingPointIndex, {x: clampedX, y: clampedY});
}
// 사각형 전체 드래그 중
else if (isDraggingArea && dragStartPos) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
// 모든 포인트를 delta만큼 이동
const newPoints = selectedArea.basePoints.map((point) => ({
x: Math.max(0, Math.min(1, point.x + deltaX)),
y: Math.max(0, Math.min(1, point.y + deltaY)),
})) as [Point, Point, Point, Point];
onUpdateArea(selectedArea.id, { basePoints: newPoints });
setDragStartPos({ x, y }); // 다음 프레임을 위해 시작 위치 업데이트
}
},
[showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea]
);
// 업 (마우스/터치 공통)
const handleUp = useCallback(() => {
if (draggingPointIndex !== null) {
onStopDragging();
}
if (isDraggingArea) {
setIsDraggingArea(false);
setDragStartPos(null);
}
if (draggingSpriteAreaId) {
setDraggingSpriteAreaId(null);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
// 전역 업 이벤트 (마우스/터치)
useEffect(() => {
if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) {
window.addEventListener('mouseup', handleUp);
window.addEventListener('touchend', handleUp);
window.addEventListener('touchcancel', handleUp);
return () => {
window.removeEventListener('mouseup', handleUp);
window.removeEventListener('touchend', handleUp);
window.removeEventListener('touchcancel', handleUp);
};
}
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]);
// UV 좌표를 픽셀 좌표로 변환 (셰이더와 동일한 bilinear interpolation)
const uvToPixel = (
u: number,
v: number,
points: [Point, Point, Point, Point],
canvasWidth: number,
canvasHeight: number
): { x: number; y: number } => {
// p0=좌상, p1=우상, p2=우하, p3=좌하
const [p0, p1, p2, p3] = points;
// 셰이더 computeUV와 동일한 순서로 bilinear interpolation
// left = mix(p0, p1, u) -> 상단 가장자리
// right = mix(p3, p2, u) -> 하단 가장자리
// position = mix(left, right, v)
const leftX = p0.x * (1 - u) + p1.x * u;
const leftY = p0.y * (1 - u) + p1.y * u;
const rightX = p3.x * (1 - u) + p2.x * u;
const rightY = p3.y * (1 - u) + p2.y * u;
const posX = leftX * (1 - v) + rightX * v;
const posY = leftY * (1 - v) + rightY * v;
return {
x: posX * canvasWidth,
y: posY * canvasHeight,
};
};
// UV 좌표계의 원을 정확히 그리기 (찌그러진 원 형태)
const drawDistortionCircle = useCallback((
ctx: CanvasRenderingContext2D,
points: [Point, Point, Point, Point],
canvasWidth: number,
canvasHeight: number
) => {
const segments = 128; // 원을 128개 세그먼트로 촘촘히 분할
const centerU = 0.5;
const centerV = 0.5;
const circleLevels = editorStyle.circleLevels || [];
// 원 레벨별로 그리기 (외부 -> 내부 순)
circleLevels.forEach((level, index) => {
const levelPoints: { x: number; y: number }[] = [];
for (let i = 0; i <= segments; i++) {
const theta = (i / segments) * 2 * Math.PI;
const u = centerU - level.radius * Math.sin(theta);
const v = centerV + level.radius * Math.cos(theta);
const pixelPos = uvToPixel(u, v, points, canvasWidth, canvasHeight);
levelPoints.push(pixelPos);
}
ctx.beginPath();
ctx.moveTo(levelPoints[0].x, levelPoints[0].y);
for (let i = 1; i < levelPoints.length; i++) {
ctx.lineTo(levelPoints[i].x, levelPoints[i].y);
}
ctx.closePath();
// 원 테두리
const baseColor = level.color || 'rgba(255, 200, 0, 1)';
// baseColor에서 RGB 추출하고 opacity 적용
const colorWithOpacity = baseColor.replace(/rgba?\(([^)]+)\)/, (_, rgb) => {
const parts = rgb.split(',').map((p: string) => p.trim());
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${level.opacity})`;
});
ctx.strokeStyle = colorWithOpacity;
ctx.lineWidth = level.lineWidth;
if (level.dashPattern) {
ctx.setLineDash(level.dashPattern);
}
ctx.stroke();
ctx.setLineDash([]);
// 가장 외부 원만 내부 채우기
if (index === 0 && editorStyle.circleFillColor) {
ctx.fillStyle = editorStyle.circleFillColor;
ctx.fill();
}
});
// 중심점 표시
const centerPointStyle = editorStyle.centerPoint || {};
const centerPixel = uvToPixel(centerU, centerV, points, canvasWidth, canvasHeight);
ctx.beginPath();
ctx.arc(centerPixel.x, centerPixel.y, centerPointStyle.radius || 5, 0, 2 * Math.PI);
if (centerPointStyle.fillColor) {
ctx.fillStyle = centerPointStyle.fillColor;
ctx.fill();
}
if (centerPointStyle.strokeColor) {
ctx.strokeStyle = centerPointStyle.strokeColor;
ctx.lineWidth = centerPointStyle.strokeWidth || 2;
ctx.stroke();
}
}, [editorStyle]);
// 커서 스타일 결정
const getCursorStyle = () => {
if (draggingPointIndex !== null) return 'grabbing';
if (isDraggingArea) return 'grabbing';
if (draggingSpriteAreaId) return 'grabbing';
return 'default';
};
return (
<div
ref={containerRef}
className="editor-canvas"
style={{
width: '100%',
height: '100%',
position: 'relative',
cursor: showEditor ? getCursorStyle() : 'default',
pointerEvents: showEditor ? 'auto' : 'none',
touchAction: 'none', // 터치 시 모든 브라우저 동작 비활성화 (스크롤, 줌 등)
}}
onMouseDown={showEditor ? handleCanvasDown : undefined}
onMouseMove={showEditor ? handleMove : undefined}
onTouchStart={showEditor ? handleCanvasDown : undefined}
onTouchMove={showEditor ? handleMove : undefined}
>
{/* ImageDistortion 컴포넌트 */}
<ImageDistortion imageSrc={imageSrc} areas={areas} spriteEffectAreas={spriteEffectAreas}/>
{/* 오버레이 SVG - 에디터 모드일 때만 표시 */}
{showEditor && (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
>
{/* 모든 영역의 사각형 표시 */}
{areas.map((area) => {
const isSelected = area.id === selectedAreaId;
const points = area.basePoints;
const outlineStyle = editorStyle.areaOutline || {};
return (
<g key={area.id}>
{/* 사각형 배경 및 경계선 */}
<polygon
points={points
.map((p) => `${p.x * canvasSize.width},${p.y * canvasSize.height}`)
.join(' ')}
fill={isSelected ? (outlineStyle.selectedFillColor || 'rgba(0, 170, 255, 0.08)') : (outlineStyle.unselectedFillColor || 'rgba(136, 136, 136, 0.03)')}
stroke={isSelected ? (outlineStyle.selectedColor || '#00aaff') : (outlineStyle.unselectedColor || '#888')}
strokeWidth={isSelected ? (outlineStyle.selectedWidth || 2) : (outlineStyle.unselectedWidth || 1)}
strokeDasharray={isSelected ? '0' : (outlineStyle.unselectedDashPattern?.join(',') || '5,5')}
opacity={isSelected ? 1 : 0.5}
/>
</g>
);
})}
</svg>
)}
{/* 선택된 영역의 타원 가이드 (Canvas로 그리기) - 에디터 모드일 때만 표시 */}
{showEditor && selectedArea && canvasSize.width > 0 && (
<canvas
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
width={canvasSize.width}
height={canvasSize.height}
ref={(canvas) => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvasSize.width, canvasSize.height);
drawDistortionCircle(ctx, selectedArea.basePoints, canvasSize.width, canvasSize.height);
}
}
}}
/>
)}
{/* 선택된 영역의 포인트 핸들 - 에디터 모드일 때만 표시 */}
{showEditor && selectedArea &&
selectedArea.basePoints.map((point, index) => {
const handleStyle = editorStyle.pointHandle || {};
return (
<div
key={index}
className={`point-handle ${draggingPointIndex === index ? 'dragging' : ''}`}
style={{
position: 'absolute',
left: `${point.x * 100}%`,
top: `${point.y * 100}%`,
transform: 'translate(-50%, -50%)',
width: handleStyle.size || 16,
height: handleStyle.size || 16,
borderRadius: '50%',
backgroundColor: handleStyle.fillColor || '#00aaff',
border: `${handleStyle.strokeWidth || 2}px solid ${handleStyle.strokeColor || 'white'}`,
cursor: 'grab',
pointerEvents: 'auto',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
onMouseDown={handlePointDown(index)}
onTouchStart={handlePointDown(index)}
>
<div
style={{
position: 'absolute',
top: -24,
left: '50%',
transform: 'translateX(-50%)',
fontSize: handleStyle.labelFontSize || 11,
color: handleStyle.labelColor || '#00aaff',
fontWeight: 'bold',
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
whiteSpace: 'nowrap',
}}
>
P{index + 1}
</div>
</div>
);
})}
{/* 스프라이트 이펙트 영역 표시 */}
{showEditor && spriteEffectAreas.map((sa) => {
const cx = sa.position.x * 100;
const cy = sa.position.y * 100;
const isDragging = draggingSpriteAreaId === sa.id;
// 반경을 %로 변환 (가로 기준, 종횡비 보정은 SVG에서)
const radiusPx = (sa.radius ?? 0.1) * canvasSize.width;
return (
<div
key={`sprite-${sa.id}`}
style={{
position: 'absolute',
left: `${cx}%`,
top: `${cy}%`,
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
}}
>
{/* 반경 원 */}
<svg
width={radiusPx * 2}
height={radiusPx * 2}
style={{
position: 'absolute',
left: -radiusPx,
top: -radiusPx,
pointerEvents: 'none',
}}
>
<circle
cx={radiusPx}
cy={radiusPx}
r={radiusPx}
fill={isDragging ? 'rgba(255, 170, 0, 0.15)' : 'rgba(255, 170, 0, 0.08)'}
stroke={isDragging ? '#ffaa00' : 'rgba(255, 170, 0, 0.6)'}
strokeWidth={isDragging ? 2 : 1.5}
strokeDasharray={isDragging ? '0' : '4,3'}
/>
</svg>
{/* 중심 핸들 */}
<div
style={{
position: 'absolute',
left: -8,
top: -8,
width: 16,
height: 16,
borderRadius: '50%',
backgroundColor: isDragging ? '#ffaa00' : 'rgba(255, 170, 0, 0.8)',
border: '2px solid white',
cursor: isDragging ? 'grabbing' : 'grab',
pointerEvents: 'auto',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
/>
{/* 라벨 */}
<div
style={{
position: 'absolute',
top: -24,
left: '50%',
transform: 'translateX(-50%)',
fontSize: 10,
color: '#ffaa00',
fontWeight: 'bold',
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
&#x2728;
</div>
</div>
);
})}
</div>
);
};

View File

@ -0,0 +1,132 @@
import React from 'react';
import { DistortionArea, EasingFunction } from '../../types/area';
export interface ParameterPanelProps {
area: DistortionArea | null;
onUpdateArea: (updates: Partial<DistortionArea>) => void;
}
const EASING_OPTIONS: { value: EasingFunction; label: string }[] = [
{ value: 'linear', label: '선형 (Linear)' },
{ value: 'easeIn', label: '가속 (Ease In)' },
{ value: 'easeOut', label: '감속 (Ease Out)' },
{ value: 'easeInOut', label: '가감속 (Ease In Out)' },
{ value: 'easeInQuad', label: '가속² (Ease In Quad)' },
{ value: 'easeOutQuad', label: '감속² (Ease Out Quad)' },
];
export const ParameterPanel: React.FC<ParameterPanelProps> = ({ area, onUpdateArea }) => {
if (!area) {
return (
<div className="parameter-panel">
<div className="parameter-panel-empty"> </div>
</div>
);
}
return (
<div className="parameter-panel">
<h3> </h3>
{/* 왜곡 강도 */}
<div className="parameter-group">
<label>
: {(area.distortionStrength * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={area.distortionStrength}
onChange={(e) => onUpdateArea({ distortionStrength: parseFloat(e.target.value) })}
className="slider"
/>
</div>
{/* 애니메이션 지속 시간 */}
<div className="parameter-group">
<label>
: {area.movement.duration.toFixed(1)}
</label>
<input
type="number"
min="0.1"
max="10"
step="0.1"
value={area.movement.duration}
onChange={(e) =>
onUpdateArea({
movement: { ...area.movement, duration: parseFloat(e.target.value) },
})
}
className="input-number"
/>
</div>
{/* 이징 함수 */}
<div className="parameter-group">
<label> </label>
<select
value={area.movement.easing}
onChange={(e) =>
onUpdateArea({
movement: { ...area.movement, easing: e.target.value as EasingFunction },
})
}
className="select"
>
{EASING_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* 렌즈 효과 */}
<div className="parameter-group">
<label>
: {((area.lensEffect?.strength ?? 0) > 0 ? '볼록 ' : (area.lensEffect?.strength ?? 0) < 0 ? '오목 ' : '')}{((area.lensEffect?.strength ?? 0) * 100).toFixed(0)}%
</label>
<input
type="range"
min="-1"
max="1"
step="0.01"
value={area.lensEffect?.strength ?? 0}
onChange={(e) => onUpdateArea({ lensEffect: { strength: parseFloat(e.target.value) } })}
className="slider"
/>
</div>
{/* 스텝 양자화 */}
<div className="parameter-group">
<label>
: {(area.snapSteps ?? 0) === 0 ? '없음' : `${area.snapSteps}단계`}
</label>
<input
type="range"
min="0"
max="5"
step="1"
value={area.snapSteps ?? 0}
onChange={(e) => onUpdateArea({ snapSteps: parseInt(e.target.value) })}
className="slider"
/>
</div>
{/* 포인트 좌표 (읽기 전용 표시) */}
<div className="parameter-group">
<label> ( )</label>
<div className="points-display">
{area.basePoints.map((point, idx) => (
<div key={idx} className="point-coord">
P{idx + 1}: ({point.x.toFixed(3)}, {point.y.toFixed(3)})
</div>
))}
</div>
</div>
</div>
);
};

59
src/editor/constants.ts Normal file
View File

@ -0,0 +1,59 @@
import { EditorCanvasStyle } from './types';
/**
*
*/
export const DEFAULT_EDITOR_CANVAS_STYLE: EditorCanvasStyle = {
// 3단계 원 스타일 (외부 -> 내부)
circleLevels: [
{
radius: 0.5,
opacity: 0.3,
lineWidth: 2,
color: 'rgba(255, 200, 0, 1)',
dashPattern: [8, 4],
},
{
radius: 0.33,
opacity: 0.6,
lineWidth: 2.5,
color: 'rgba(255, 200, 0, 1)',
dashPattern: [8, 4],
},
{
radius: 0.167,
opacity: 0.9,
lineWidth: 3,
color: 'rgba(255, 200, 0, 1)',
dashPattern: [8, 4],
},
],
// 원 내부 채우기
circleFillColor: 'rgba(255, 200, 0, 0.08)',
// 중심점
centerPoint: {
radius: 5,
fillColor: 'rgba(255, 200, 0, 1)',
strokeColor: 'rgba(255, 255, 255, 0.8)',
strokeWidth: 2,
},
// 포인트 핸들
pointHandle: {
size: 16,
fillColor: '#00aaff',
strokeColor: 'white',
strokeWidth: 2,
labelColor: '#00aaff',
labelFontSize: 11,
},
// 영역 외곽선
areaOutline: {
selectedColor: '#00aaff',
unselectedColor: '#888',
selectedWidth: 2,
unselectedWidth: 1,
unselectedDashPattern: [5, 5],
selectedFillColor: 'rgba(0, 170, 255, 0.08)', // 선택된 영역 배경 (연한 파란색)
unselectedFillColor: 'rgba(136, 136, 136, 0.03)', // 선택 안된 영역 배경 (연한 회색)
},
};

380
src/editor/editor.css Normal file
View File

@ -0,0 +1,380 @@
/* Distortion Editor 메인 레이아웃 */
.distortion-editor {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1e1e1e;
color: #e0e0e0;
min-height: 100vh;
padding: 20px;
}
/* 에디터 툴바 */
.editor-toolbar {
max-width: 1600px;
margin: 0 auto 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.editor-toggle-btn {
padding: 10px 20px;
background: #383838;
color: #e0e0e0;
border: 2px solid #555;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.editor-toggle-btn:hover {
background: #404040;
border-color: #00aaff;
}
.editor-toggle-btn.active {
background: #2d5a7a;
border-color: #00aaff;
color: #fff;
}
.editor-main {
display: flex;
gap: 20px;
max-width: 1600px;
margin: 0 auto;
}
.editor-canvas-container {
flex: 1;
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.editor-canvas {
position: relative;
background: #000;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
.editor-sidebar {
width: 320px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Area List */
.area-list {
background: #2a2a2a;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.area-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.area-list-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.btn-add {
padding: 6px 12px;
background: #00aaff;
color: white;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-add:hover:not(:disabled) {
background: #0088cc;
}
.btn-add:disabled {
background: #555;
cursor: not-allowed;
opacity: 0.5;
}
.area-list-items {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.area-list-empty {
text-align: center;
color: #888;
padding: 20px;
font-size: 13px;
}
.area-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #383838;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.area-item:hover {
background: #404040;
}
.area-item.selected {
background: #2d5a7a;
border-color: #00aaff;
}
.area-item-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.area-item-name {
font-size: 14px;
font-weight: 500;
color: #fff;
}
.area-item-strength {
font-size: 12px;
color: #aaa;
}
.btn-remove {
width: 24px;
height: 24px;
background: #ff4444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-remove:hover {
background: #cc0000;
}
/* Parameter Panel */
.parameter-panel {
background: #2a2a2a;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
flex: 1;
overflow-y: auto;
}
.parameter-panel h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.parameter-panel-empty {
text-align: center;
color: #888;
padding: 40px 20px;
font-size: 13px;
}
.parameter-group {
margin-bottom: 20px;
}
.parameter-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #ccc;
margin-bottom: 8px;
}
.slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: #444;
outline: none;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #00aaff;
cursor: pointer;
transition: background 0.2s;
}
.slider::-webkit-slider-thumb:hover {
background: #0088cc;
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #00aaff;
cursor: pointer;
border: none;
transition: background 0.2s;
}
.slider::-moz-range-thumb:hover {
background: #0088cc;
}
.input-number {
width: 100%;
padding: 8px;
background: #383838;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input-number:focus {
border-color: #00aaff;
}
.select {
width: 100%;
padding: 8px;
background: #383838;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
font-size: 14px;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
.select:focus {
border-color: #00aaff;
}
.points-display {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.point-coord {
padding: 8px;
background: #383838;
border-radius: 4px;
font-size: 11px;
font-family: 'Courier New', monospace;
color: #aaa;
}
/* Point Handle */
.point-handle {
z-index: 10;
transition: transform 0.1s, box-shadow 0.1s;
}
.point-handle:hover {
transform: translate(-50%, -50%) scale(1.2);
box-shadow: 0 4px 8px rgba(0, 170, 255, 0.5);
}
.point-handle.dragging {
cursor: grabbing;
transform: translate(-50%, -50%) scale(1.3);
box-shadow: 0 6px 12px rgba(0, 170, 255, 0.7);
}
/* 스크롤바 스타일 */
.area-list-items::-webkit-scrollbar,
.parameter-panel::-webkit-scrollbar {
width: 8px;
}
.area-list-items::-webkit-scrollbar-track,
.parameter-panel::-webkit-scrollbar-track {
background: #1e1e1e;
border-radius: 4px;
}
.area-list-items::-webkit-scrollbar-thumb,
.parameter-panel::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.area-list-items::-webkit-scrollbar-thumb:hover,
.parameter-panel::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* 반응형 */
@media (max-width: 1200px) {
.editor-main {
flex-direction: column;
}
.editor-sidebar {
width: 100%;
flex-direction: row;
}
.area-list,
.parameter-panel {
flex: 1;
}
}
@media (max-width: 768px) {
.editor-sidebar {
flex-direction: column;
}
.points-display {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,95 @@
import { useState, useCallback } from 'react';
import { DistortionArea, Point } from '../../types/area';
import { EditorState } from '../types';
export const useDistortionEditor = (initialAreas: DistortionArea[] = []) => {
const [state, setState] = useState<EditorState>({
selectedAreaId: initialAreas[0]?.id || null,
areas: initialAreas,
editMode: 'normal',
draggingPointIndex: null,
});
/** 영역 선택 */
const selectArea = useCallback((areaId: string | null) => {
setState((prev) => ({ ...prev, selectedAreaId: areaId }));
}, []);
/** 영역 추가 */
const addArea = useCallback((area: DistortionArea) => {
setState((prev) => ({
...prev,
areas: [...prev.areas, area],
selectedAreaId: area.id,
}));
}, []);
/** 영역 삭제 */
const removeArea = useCallback((areaId: string) => {
setState((prev) => {
const newAreas = prev.areas.filter((a) => a.id !== areaId);
return {
...prev,
areas: newAreas,
selectedAreaId:
prev.selectedAreaId === areaId ? newAreas[0]?.id || null : prev.selectedAreaId,
};
});
}, []);
/** 영역 업데이트 */
const updateArea = useCallback((areaId: string, updates: Partial<DistortionArea>) => {
setState((prev) => ({
...prev,
areas: prev.areas.map((area) => (area.id === areaId ? { ...area, ...updates } : area)),
}));
}, []);
/** 포인트 업데이트 */
const updatePoint = useCallback((areaId: string, pointIndex: number, point: Point) => {
setState((prev) => ({
...prev,
areas: prev.areas.map((area) => {
if (area.id === areaId) {
const newPoints = [...area.basePoints] as [Point, Point, Point, Point];
newPoints[pointIndex] = point;
return { ...area, basePoints: newPoints };
}
return area;
}),
}));
}, []);
/** 드래그 시작 */
const startDragging = useCallback((pointIndex: number) => {
setState((prev) => ({ ...prev, draggingPointIndex: pointIndex }));
}, []);
/** 드래그 종료 */
const stopDragging = useCallback(() => {
setState((prev) => ({ ...prev, draggingPointIndex: null }));
}, []);
/** 편집 모드 변경 */
const setEditMode = useCallback((mode: EditorState['editMode']) => {
setState((prev) => ({ ...prev, editMode: mode }));
}, []);
/** 선택된 영역 가져오기 */
const getSelectedArea = useCallback(() => {
return state.areas.find((a) => a.id === state.selectedAreaId) || null;
}, [state.areas, state.selectedAreaId]);
return {
state,
selectArea,
addArea,
removeArea,
updateArea,
updatePoint,
startDragging,
stopDragging,
setEditMode,
getSelectedArea,
};
};

16
src/editor/index.ts Normal file
View File

@ -0,0 +1,16 @@
export { EditorCanvas } from './components/EditorCanvas';
export { AreaList } from './components/AreaList';
export { ParameterPanel } from './components/ParameterPanel';
export type {
EditorState,
EditMode,
EditorCanvasStyle,
CircleLevelStyle,
CenterPointStyle,
PointHandleStyle,
AreaOutlineStyle,
} from './types';
export { useDistortionEditor } from './hooks/useDistortionEditor';
export { DEFAULT_EDITOR_CANVAS_STYLE } from './constants';
import './editor.css';

139
src/editor/types.ts Normal file
View File

@ -0,0 +1,139 @@
import { DistortionArea } from '../types/area';
/**
*
*/
export type EditMode = 'normal' | 'point-edit' | 'parameter-edit';
/**
*
*/
export interface EditorState {
/** 현재 선택된 영역 ID */
selectedAreaId: string | null;
/** 모든 왜곡 영역 */
areas: DistortionArea[];
/** 현재 편집 모드 */
editMode: EditMode;
/** 드래그 중인 포인트 인덱스 (0-3) */
draggingPointIndex: number | null;
}
/**
*
*/
export interface PointHandle {
/** 포인트 인덱스 (0-3) */
index: number;
/** 화면 좌표 (픽셀) */
x: number;
y: number;
/** 포인트 레이블 */
label: string;
}
/**
*
*/
export interface CircleLevelStyle {
/** 반지름 (0.0 - 1.0, UV 좌표) */
radius: number;
/** 투명도 (0.0 - 1.0) */
opacity: number;
/** 선 두께 (픽셀) */
lineWidth: number;
/** 선 색상 (CSS color) */
color?: string;
/** 대시 패턴 [dash, gap] */
dashPattern?: [number, number];
}
/**
*
*/
export interface CenterPointStyle {
/** 반지름 (픽셀) */
radius?: number;
/** 채우기 색상 */
fillColor?: string;
/** 테두리 색상 */
strokeColor?: string;
/** 테두리 두께 */
strokeWidth?: number;
}
/**
*
*/
export interface PointHandleStyle {
/** 핸들 크기 (픽셀) */
size?: number;
/** 채우기 색상 */
fillColor?: string;
/** 테두리 색상 */
strokeColor?: string;
/** 테두리 두께 */
strokeWidth?: number;
/** 레이블 색상 */
labelColor?: string;
/** 레이블 폰트 크기 */
labelFontSize?: number;
}
/**
*
*/
export interface AreaOutlineStyle {
/** 선택된 영역 색상 */
selectedColor?: string;
/** 선택되지 않은 영역 색상 */
unselectedColor?: string;
/** 선택된 영역 선 두께 */
selectedWidth?: number;
/** 선택되지 않은 영역 선 두께 */
unselectedWidth?: number;
/** 선택되지 않은 영역 대시 패턴 */
unselectedDashPattern?: [number, number];
/** 선택된 영역 배경 채우기 색상 */
selectedFillColor?: string;
/** 선택되지 않은 영역 배경 채우기 색상 */
unselectedFillColor?: string;
}
/**
*
*/
export interface EditorCanvasStyle {
/** 왜곡 영역 원 레벨 스타일 배열 (외부 -> 내부 순) */
circleLevels?: CircleLevelStyle[];
/** 왜곡 영역 내부 채우기 색상 */
circleFillColor?: string;
/** 중심점 스타일 */
centerPoint?: CenterPointStyle;
/** 포인트 핸들 스타일 */
pointHandle?: PointHandleStyle;
/** 영역 외곽선 스타일 */
areaOutline?: AreaOutlineStyle;
}
/**
* Props
*/
export interface DistortionEditorProps {
/** 초기 영역 배열 */
initialAreas?: DistortionArea[];
/** 이미지 소스 */
imageSrc: string;
/** 영역 변경 콜백 */
onAreasChange?: (areas: DistortionArea[]) => void;
/** 선택된 영역 변경 콜백 */
onSelectedAreaChange?: (areaId: string | null) => void;
/** 캔버스 너비 */
width?: number;
/** 캔버스 높이 */
height?: number;
/** 에디터 캔버스 스타일 커스터마이징 */
canvasStyle?: EditorCanvasStyle;
/** 에디터 표시 여부 (외부에서 제어) */
showEditor?: boolean;
}

View File

@ -1,5 +1,6 @@
import { DistortionArea, Point } from '../types';
import { applyEasing } from '../utils/easing'; import { applyEasing } from '../utils/easing';
import { presetToVector, isRotationPreset } from '../utils/motionPresets';
import type {DistortionArea, Point} from "../types";
/** /**
* *
@ -16,27 +17,74 @@ export class AnimationLoop {
return areas.map((area) => { return areas.map((area) => {
const { progress, movement } = area; const { progress, movement } = area;
// duration이 0이거나 프리셋이 'none'이면 애니메이션 없음
if (movement.duration <= 0 || movement.preset === 'none') {
return {
...area,
dragVector: { x: 0, y: 0 },
};
}
// 프리셋이 설정되어 있으면 프리셋 기반 벡터 사용
let baseVector: Point;
if (movement.preset) {
const strength = movement.strength ?? 0.1;
baseVector = presetToVector(movement.preset, strength);
} else {
// 프리셋 없으면 기존 vectorA 사용 (하위 호환성)
baseVector = movement.vectorA;
}
// 이징 적용 // 이징 적용
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
// 벡터 간 보간 // 벡터 계산
let dragVector: Point; let dragVector: Point;
if (easedProgress < 0.5) { // 스텝 양자화 (영역 속성에서 가져옴)
// 0.0 -> 0.5: 0에서 vectorA로 보간 const snapSteps = area.snapSteps ?? 0;
const t = easedProgress * 2;
// 회전 프리셋인 경우 원운동
if (movement.preset && isRotationPreset(movement.preset)) {
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === 'rotate-cw' ? 1 : -1;
if (snapSteps > 0) {
// 스텝 양자화: 각도를 이산적 단계로 양자화
const totalAngleSteps = snapSteps * 4;
const rawAngle = progress * Math.PI * 2;
const quantizedAngle = Math.round(rawAngle / (Math.PI * 2) * totalAngleSteps) / totalAngleSteps * Math.PI * 2;
dragVector = { dragVector = {
x: movement.vectorA.x * t, x: Math.cos(quantizedAngle * direction) * radius,
y: movement.vectorA.y * t, y: Math.sin(quantizedAngle * direction) * radius,
}; };
} else { } else {
// 0.5 -> 1.0: vectorA에서 0으로 보간 const angle = easedProgress * Math.PI * 2;
const t = (easedProgress - 0.5) * 2;
dragVector = { dragVector = {
x: movement.vectorA.x * (1 - t), x: Math.cos(angle * direction) * radius,
y: movement.vectorA.y * (1 - t), y: Math.sin(angle * direction) * radius,
}; };
} }
} else {
// 일반 왕복 모션 (sin 기반으로 진짜 좌↔우/상↔하 왕복)
// sin(0)=0 → sin(π/2)=1 → sin(π)=0 → sin(3π/2)=-1 → sin(2π)=0
if (snapSteps > 0) {
// 스텝 양자화: oscillation 출력을 양자화
// Math.round(sin * N) / N → 한 방향 N단계, 전체 2N+1개 위치
const oscillation = Math.sin(progress * Math.PI * 2);
const quantized = Math.round(oscillation * snapSteps) / snapSteps;
dragVector = {
x: baseVector.x * quantized,
y: baseVector.y * quantized,
};
} else {
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation,
};
}
}
return { return {
...area, ...area,
@ -56,6 +104,11 @@ export class AnimationLoop {
deltaTime: number deltaTime: number
): DistortionArea[] { ): DistortionArea[] {
return areas.map((area) => { return areas.map((area) => {
// duration이 0이면 progress 업데이트 안 함
if (area.movement.duration <= 0) {
return area;
}
let newProgress = area.progress + deltaTime / area.movement.duration; let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1.0; // 루프 newProgress %= 1.0; // 루프

View File

@ -15,12 +15,20 @@ export class ShaderManager {
vertexPath: string, vertexPath: string,
fragmentPath: string fragmentPath: string
): Promise<{ vertex: string; fragment: string }> { ): Promise<{ vertex: string; fragment: string }> {
console.log('[ShaderManager] loadShaders 시작:', { vertexPath, fragmentPath });
try { try {
console.log('[ShaderManager] fetch 시작...');
const [vertexResponse, fragmentResponse] = await Promise.all([ const [vertexResponse, fragmentResponse] = await Promise.all([
fetch(vertexPath), fetch(vertexPath),
fetch(fragmentPath), fetch(fragmentPath),
]); ]);
console.log('[ShaderManager] fetch 완료:', {
vertexStatus: vertexResponse.status,
fragmentStatus: fragmentResponse.status
});
if (!vertexResponse.ok) { if (!vertexResponse.ok) {
throw new Error(`버텍스 셰이더 로드 실패: ${vertexResponse.statusText}`); throw new Error(`버텍스 셰이더 로드 실패: ${vertexResponse.statusText}`);
} }
@ -28,15 +36,21 @@ export class ShaderManager {
throw new Error(`프래그먼트 셰이더 로드 실패: ${fragmentResponse.statusText}`); throw new Error(`프래그먼트 셰이더 로드 실패: ${fragmentResponse.statusText}`);
} }
console.log('[ShaderManager] text() 변환 시작...');
this.vertexShaderSource = await vertexResponse.text(); this.vertexShaderSource = await vertexResponse.text();
this.fragmentShaderSource = await fragmentResponse.text(); this.fragmentShaderSource = await fragmentResponse.text();
console.log('[ShaderManager] 셰이더 로드 완료!', {
vertexLength: this.vertexShaderSource.length,
fragmentLength: this.fragmentShaderSource.length
});
return { return {
vertex: this.vertexShaderSource, vertex: this.vertexShaderSource,
fragment: this.fragmentShaderSource, fragment: this.fragmentShaderSource,
}; };
} catch (error) { } catch (error) {
console.error('셰이더 로드 실패:', error); console.error('[ShaderManager] 셰이더 로드 실패:', error);
throw new Error('셰이더 로딩에 실패했습니다'); throw new Error('셰이더 로딩에 실패했습니다');
} }
} }

149
src/engine/SpringPhysics.ts Normal file
View File

@ -0,0 +1,149 @@
import { Point } from '@/types';
import { SpringPhysicsConfig, SpringState } from '@/types/interaction';
/**
*
* Hooke's Law와 -
*/
export class SpringPhysics {
private config: SpringPhysicsConfig;
private state: SpringState;
constructor(config: SpringPhysicsConfig) {
this.config = config;
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 },
};
}
/**
*
*/
public setConfig(config: Partial<SpringPhysicsConfig>) {
this.config = { ...this.config, ...config };
}
/**
* ( )
*/
public setTarget(velocity: Point, velocityMultiplier: number = 1.0) {
// 속도에 승수를 곱해서 목표 변위로 설정
this.state.target = {
x: velocity.x * velocityMultiplier,
y: velocity.y * velocityMultiplier,
};
}
/**
* ( )
*
*/
public setInitialVelocity(velocity: Point, multiplier: number = 1.0) {
// 현재 속도를 즉시 변경
this.state.velocity = {
x: velocity.x * multiplier,
y: velocity.y * multiplier,
};
// 목표는 0 (평형 상태로 돌아가도록)
this.state.target = { x: 0, y: 0 };
}
/**
* (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
public update(deltaTime: number): Point {
const { stiffness, damping, mass } = this.config;
const { displacement, velocity, target } = this.state;
// 평형 위치로부터의 변위 (target은 마우스 속도에서 계산된 목표)
const dx = displacement.x - target.x;
const dy = displacement.y - target.y;
// 스프링 힘: F = -k * x (복원력)
const springForceX = -stiffness * dx;
const springForceY = -stiffness * dy;
// 감쇠 힘: F = -c * v (마찰력)
const dampingForceX = -damping * velocity.x;
const dampingForceY = -damping * velocity.y;
// 총 힘
const totalForceX = springForceX + dampingForceX;
const totalForceY = springForceY + dampingForceY;
// 가속도: a = F / m
const accelerationX = totalForceX / mass;
const accelerationY = totalForceY / mass;
// 속도 업데이트: v += a * dt
const newVelocityX = velocity.x + accelerationX * deltaTime;
const newVelocityY = velocity.y + accelerationY * deltaTime;
// 위치 업데이트: x += v * dt
const newDisplacementX = displacement.x + newVelocityX * deltaTime;
const newDisplacementY = displacement.y + newVelocityY * deltaTime;
// 상태 저장
this.state = {
displacement: { x: newDisplacementX, y: newDisplacementY },
velocity: { x: newVelocityX, y: newVelocityY },
target,
};
// 매우 작은 움직임은 0으로 처리 (정지 판정)
const isNearlyZero = (val: number) => Math.abs(val) < 0.0001;
if (isNearlyZero(this.state.displacement.x) && isNearlyZero(this.state.displacement.y) &&
isNearlyZero(this.state.velocity.x) && isNearlyZero(this.state.velocity.y)) {
this.reset();
}
return this.state.displacement;
}
/**
* ( )
*/
public applyImpulse(acceleration: Point, multiplier: number = 1.0) {
// 가속도를 속도로 변환하여 즉시 적용
this.state.velocity.x += acceleration.x * multiplier;
this.state.velocity.y += acceleration.y * multiplier;
}
/**
*
*/
public getDisplacement(): Point {
return { ...this.state.displacement };
}
/**
*
*/
public getVelocity(): Point {
return { ...this.state.velocity };
}
/**
*
*/
public reset() {
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 },
};
}
/**
* 0 ( )
*/
public returnToEquilibrium() {
this.state.target = { x: 0, y: 0 };
}
}

View File

@ -0,0 +1,392 @@
import * as THREE from 'three';
import type { Point } from '@/types';
import type { SpriteEffectConfig, SpriteSheetConfig } from '@/types/spriteEffect';
import { SpriteParticlePool, type SpriteParticle } from './SpriteParticlePool';
/**
*
*/
const randomRange = (min: number, max: number): number =>
min + Math.random() * (max - min);
/**
*
*/
const lerp = (a: number, b: number, t: number): number =>
a + (b - a) * t;
/**
* (2개: 선형, 3개: 전반/ )
*/
const lerpKeyframes = (values: number[], t: number): number => {
if (values.length === 2) {
return lerp(values[0], values[1], t);
}
// 3개 이상: 균등 구간 분할
const segments = values.length - 1;
const segT = t * segments;
const idx = Math.min(Math.floor(segT), segments - 1);
const localT = segT - idx;
return lerp(values[idx], values[idx + 1], localT);
};
/**
* SpriteEffectConfig에
* , , /
*/
export class SpriteEffectInstance {
private config: SpriteEffectConfig;
private pool: SpriteParticlePool;
private meshes: THREE.Mesh[];
private geometry: THREE.PlaneGeometry;
private material: THREE.MeshBasicMaterial;
private texture: THREE.Texture | null = null;
private ready = false;
private emitAccumulator = 0;
/** 메쉬를 담는 그룹 (외부에서 씬에 추가) */
readonly group: THREE.Group;
constructor(config: SpriteEffectConfig) {
this.config = config;
this.pool = new SpriteParticlePool(config.maxParticles);
this.group = new THREE.Group();
// 공유 지오메트리 (1x1 평면)
this.geometry = new THREE.PlaneGeometry(1, 1);
// 블렌드 모드 결정
const blending = config.blendMode === 'additive'
? THREE.AdditiveBlending
: THREE.NormalBlending;
// 공유 머티리얼 (텍스처 로드 전까지 투명)
this.material = new THREE.MeshBasicMaterial({
transparent: true,
depthTest: false,
depthWrite: false,
blending,
opacity: 0,
});
// 메쉬 풀 사전 생성
this.meshes = Array.from({ length: config.maxParticles }, () => {
const mesh = new THREE.Mesh(this.geometry, this.material.clone());
mesh.visible = false;
mesh.renderOrder = 1;
this.group.add(mesh);
return mesh;
});
// 이모지 또는 URL 텍스처 로드
if (config.emoji) {
this.createEmojiTexture(config.emoji);
} else if (config.spriteUrl) {
this.loadTexture(config.spriteUrl);
} else {
console.error('[SpriteEffectInstance] spriteUrl 또는 emoji 중 하나는 필수입니다.');
}
}
/** 런타임 설정 업데이트 (maxParticles 제외) */
updateConfig(config: SpriteEffectConfig): void {
this.config = config;
}
/** 텍스처 로드 */
private loadTexture(url: string): void {
const loader = new THREE.TextureLoader();
loader.load(
url,
(texture) => {
this.texture = texture;
const sheet = this.config.spriteSheet;
if (sheet) {
// 스프라이트 시트: 첫 프레임만 표시하도록 UV 설정
texture.repeat.set(1 / sheet.columns, 1 / sheet.rows);
texture.offset.set(0, 1 - 1 / sheet.rows);
}
// 각 메쉬에 독립적인 텍스처 클론 적용 (프레임별 UV 독립 제어)
for (const mesh of this.meshes) {
const mat = mesh.material as THREE.MeshBasicMaterial;
if (sheet) {
const cloned = texture.clone();
cloned.repeat.copy(texture.repeat);
cloned.offset.copy(texture.offset);
mat.map = cloned;
} else {
mat.map = texture;
}
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] 텍스처 로드 성공: ${url}`, texture.image.width, 'x', texture.image.height);
},
undefined,
(error) => {
console.error(`[SpriteEffectInstance] 텍스처 로드 실패: ${url}`, error);
}
);
}
/** 이모지를 Canvas에 렌더링하여 텍스처 생성 */
private createEmojiTexture(emoji: string): void {
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('[SpriteEffectInstance] Canvas 2D 컨텍스트 생성 실패');
return;
}
ctx.font = '100px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, size / 2, size / 2);
const texture = new THREE.CanvasTexture(canvas);
this.texture = texture;
// 각 메쉬에 텍스처 적용 (이모지는 스프라이트 시트 불필요)
for (const mesh of this.meshes) {
const mat = mesh.material as THREE.MeshBasicMaterial;
mat.map = texture;
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] 이모지 텍스처 생성 완료: ${emoji}`);
}
/**
* 1
* @param center ( 0-1)
*/
private emitOne(center: Point): void {
const particle = this.pool.acquire();
if (!particle) return;
const { config } = this;
// 방출 위치 계산 (중심 + 오프셋 + 반경 내 랜덤)
let px = center.x + (config.emitOffset?.x ?? 0);
let py = center.y + (config.emitOffset?.y ?? 0);
if (config.emitRadius && config.emitRadius > 0) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * config.emitRadius;
px += Math.cos(angle) * radius;
py += Math.sin(angle) * radius;
}
particle.position.x = px;
particle.position.y = py;
// 방출 각도 및 속도
const angleRange = config.emitAngle ?? [0, 360];
const angleDeg = randomRange(angleRange[0], angleRange[1]);
const angleRad = (angleDeg * Math.PI) / 180;
const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]);
particle.velocity.x = Math.cos(angleRad) * speed;
particle.velocity.y = Math.sin(angleRad) * speed;
// 초기 속성
particle.initialScale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.scale = particle.initialScale;
particle.rotation = 0;
particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0;
particle.frameTime = 0;
particle.frameIndex = 0;
}
/**
* ambient 모드:
*/
private updateAmbientEmit(deltaTime: number, center: Point): void {
if (!this.config.emitRate || this.config.emitRate <= 0) return;
this.emitAccumulator += deltaTime;
const interval = 1 / this.config.emitRate;
while (this.emitAccumulator >= interval) {
this.emitAccumulator -= interval;
this.emitOne(center);
}
}
/**
* touch 모드: 버스트
*/
triggerBurst(center: Point): void {
if (!this.ready) return;
const count = this.config.burstCount ?? 1;
for (let i = 0; i < count; i++) {
this.emitOne(center);
}
}
/**
*
* @param deltaTime
* @param emitCenter ( 0-1)
*/
private _logCounter = 0;
update(deltaTime: number, emitCenter: Point, resolution?: { x: number; y: number }): void {
if (!this.ready) return;
// ambient 방출
if (this.config.trigger === 'ambient') {
this.updateAmbientEmit(deltaTime, emitCenter);
}
const overLifetime = this.config.overLifetime;
// 활성 파티클 업데이트
const activeParticles = this.pool.getActiveParticles();
if (this._logCounter++ % 60 === 0 && activeParticles.length > 0) {
const p = activeParticles[0];
console.log(`[SpriteEffectInstance] 활성 파티클: ${activeParticles.length}개, 첫 파티클 pos=(${p.position.x.toFixed(3)}, ${p.position.y.toFixed(3)}), scale=${p.scale.toFixed(3)}, opacity=${p.opacity.toFixed(3)}`);
}
for (const particle of activeParticles) {
particle.age += deltaTime;
// 수명 초과 시 회수
if (particle.age >= particle.lifetime) {
this.pool.release(particle);
this.syncMesh(particle);
continue;
}
const lifeRatio = particle.age / particle.lifetime;
// overLifetime 보간 적용
if (overLifetime) {
if (overLifetime.scale) {
// overLifetime.scale은 initialScale에 대한 배율로 적용
particle.scale = particle.initialScale * lerpKeyframes(overLifetime.scale, lifeRatio);
}
if (overLifetime.opacity) {
particle.opacity = lerpKeyframes(overLifetime.opacity, lifeRatio);
}
if (overLifetime.rotationSpeed) {
particle.rotation += overLifetime.rotationSpeed * deltaTime;
}
if (overLifetime.velocityDamping !== undefined) {
const damping = Math.pow(overLifetime.velocityDamping, deltaTime);
particle.velocity.x *= damping;
particle.velocity.y *= damping;
}
}
// 스프라이트 시트 프레임 진행
if (this.config.spriteSheet) {
this.updateSpriteFrame(particle, deltaTime, this.config.spriteSheet);
}
// 위치 업데이트
particle.position.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime;
// Three.js 메쉬 동기화
this.syncMesh(particle, resolution);
}
}
/**
*
*/
private updateSpriteFrame(particle: SpriteParticle, deltaTime: number, sheet: SpriteSheetConfig): void {
particle.frameTime += deltaTime;
const frameDuration = 1 / sheet.fps;
if (particle.frameTime >= frameDuration) {
particle.frameTime -= frameDuration;
const nextFrame = particle.frameIndex + 1;
if (nextFrame >= sheet.totalFrames) {
// 루프 여부 확인 (기본: true)
if (sheet.loop !== false) {
particle.frameIndex = 0;
}
// loop=false면 마지막 프레임 유지
} else {
particle.frameIndex = nextFrame;
}
}
}
/**
* Three.js
* (0-1) NDC(-1~1) , y축
*/
private syncMesh(particle: SpriteParticle, resolution?: { x: number; y: number }): void {
const mesh = this.meshes[particle.index];
if (!mesh) return;
if (!particle.active) {
mesh.visible = false;
return;
}
mesh.visible = true;
// 좌표 변환: 정규화(0-1) → NDC(-1~1), y 반전
mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 2 - 1);
mesh.position.z = -0.01; // 카메라가 -z를 바라보므로 음수가 앞쪽
// 종횡비 보정: OrthographicCamera(-1,1,1,-1) 기준 정사각형 NDC에서 직사각형 뷰포트 왜곡 방지
const aspect = resolution ? resolution.x / resolution.y : 1;
mesh.scale.set(particle.scale / aspect, particle.scale, 1);
mesh.rotation.z = particle.rotation;
const mat = mesh.material as THREE.MeshBasicMaterial;
mat.opacity = particle.opacity;
// 스프라이트 시트 UV 오프셋 업데이트
const sheet = this.config.spriteSheet;
if (sheet && mat.map) {
const col = particle.frameIndex % sheet.columns;
const row = Math.floor(particle.frameIndex / sheet.columns);
mat.map.offset.set(
col / sheet.columns,
1 - (row + 1) / sheet.rows,
);
}
}
/**
*
*/
isReady(): boolean {
return this.ready;
}
/**
*
*/
dispose(): void {
if (this.texture) {
this.texture.dispose();
this.texture = null;
}
this.geometry.dispose();
for (const mesh of this.meshes) {
(mesh.material as THREE.MeshBasicMaterial).dispose();
}
this.material.dispose();
// 그룹에서 모든 메쉬 제거
while (this.group.children.length > 0) {
this.group.remove(this.group.children[0]);
}
}
}

View File

@ -0,0 +1,146 @@
import * as THREE from 'three';
import type { Point } from '@/types';
import type { SpriteEffectArea } from '@/types/spriteEffect';
import { SpriteEffectInstance } from './SpriteEffectInstance';
/**
* / ( )
*/
export interface SpriteEffectTouchState {
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
position: Point | null;
/** 드래그 중 여부 */
isDragging: boolean;
}
/**
*
*/
const isPointInCircle = (point: Point, center: Point, radius: number): boolean => {
const dx = point.x - center.x;
const dy = point.y - center.y;
return dx * dx + dy * dy <= radius * radius;
};
/**
*
* ImageDistortion
* (DistortionArea)
*/
export class SpriteEffectManager {
/** 모든 이펙트 메쉬를 담는 그룹 */
private effectGroup: THREE.Group;
/** 영역ID+이펙트ID → 인스턴스 맵 */
private instances: Map<string, SpriteEffectInstance> = new Map();
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
private previousTouchingAreas: Set<string> = new Set();
constructor() {
this.effectGroup = new THREE.Group();
this.effectGroup.renderOrder = 1;
}
/**
* Three.js
*/
attachToScene(scene: THREE.Scene): void {
scene.add(this.effectGroup);
}
/**
* /
*/
syncEffects(effectAreas: SpriteEffectArea[]): void {
console.log('[SpriteEffectManager] syncEffects 호출:', effectAreas.length, '개 영역');
const activeKeys = new Set<string>();
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key);
// 이미 존재하면 설정만 업데이트
const existing = this.instances.get(key);
if (existing) {
existing.updateConfig(effectConfig);
continue;
}
// 새 인스턴스 생성
console.log('[SpriteEffectManager] 인스턴스 생성:', key, effectConfig.emoji ?? effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance);
this.effectGroup.add(instance.group);
}
}
// 더 이상 사용되지 않는 인스턴스 제거
for (const [key, instance] of this.instances) {
if (!activeKeys.has(key)) {
instance.dispose();
this.effectGroup.remove(instance.group);
this.instances.delete(key);
}
}
}
/**
*
* @param effectAreas
* @param deltaTime
* @param touchState /
*/
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState, resolution?: { x: number; y: number }): void {
// 현재 터치 중인 영역 감지
const currentTouchingAreas = new Set<string>();
if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) {
const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) {
currentTouchingAreas.add(area.id);
}
}
}
// 각 영역의 이펙트 업데이트
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key);
if (!instance) continue;
// touch 이펙트: 새로 터치된 영역이면 버스트
if (effectConfig.trigger === 'touch') {
const isNewTouch = currentTouchingAreas.has(area.id)
&& !this.previousTouchingAreas.has(area.id);
if (isNewTouch) {
instance.triggerBurst(touchState.position ?? area.position);
}
}
// 매 프레임 업데이트 (ambient 방출 + 파티클 물리)
instance.update(deltaTime, area.position, resolution);
}
}
// 터치 상태 갱신
this.previousTouchingAreas = currentTouchingAreas;
}
/**
*
*/
dispose(): void {
for (const [, instance] of this.instances) {
instance.dispose();
}
this.instances.clear();
this.previousTouchingAreas.clear();
// effectGroup을 부모에서 제거
if (this.effectGroup.parent) {
this.effectGroup.parent.remove(this.effectGroup);
}
}
}

View File

@ -0,0 +1,102 @@
import type { Point } from '@/types';
/**
*
*/
export interface SpriteParticle {
/** 풀 내 인덱스 */
index: number;
/** 활성 여부 */
active: boolean;
/** 정규화 좌표 위치 (0-1) */
position: Point;
/** 속도 (정규화 좌표/초) */
velocity: Point;
/** 현재 스케일 */
scale: number;
/** 방출 시 초기 스케일 (overLifetime 배율 기준값) */
initialScale: number;
/** 현재 회전 (라디안) */
rotation: number;
/** 현재 투명도 */
opacity: number;
/** 경과 시간 (초) */
age: number;
/** 수명 (초) */
lifetime: number;
/** 스프라이트 시트 프레임 누적 시간 */
frameTime: number;
/** 현재 프레임 인덱스 */
frameIndex: number;
}
/**
* (GC )
* /
*/
export class SpriteParticlePool {
private particles: SpriteParticle[];
constructor(maxParticles: number) {
// 최대 개수만큼 미리 할당
this.particles = Array.from({ length: maxParticles }, (_, i) => this.createParticle(i));
}
/** 비활성 파티클 생성 */
private createParticle(index: number): SpriteParticle {
return {
index,
active: false,
position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
scale: 1,
initialScale: 1,
rotation: 0,
opacity: 1,
age: 0,
lifetime: 1,
frameTime: 0,
frameIndex: 0,
};
}
/**
*
* null
*/
acquire(): SpriteParticle | null {
for (const particle of this.particles) {
if (!particle.active) {
particle.active = true;
particle.age = 0;
return particle;
}
}
return null;
}
/**
*
*/
release(particle: SpriteParticle): void {
particle.active = false;
}
/**
*
*/
getActiveParticles(): SpriteParticle[] {
return this.particles.filter(p => p.active);
}
/**
*
*/
getActiveCount(): number {
let count = 0;
for (const p of this.particles) {
if (p.active) count++;
}
return count;
}
}

View File

@ -1,5 +1,5 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { ShaderUniforms } from '@/types'; import type { ShaderUniforms } from '@/types';
/** /**
* Three.js * Three.js
@ -15,7 +15,7 @@ export class ThreeScene {
// 씬 생성 // 씬 생성
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
// 2D용 직교 카메라 설정 // 2D용 직교 카메라 설정 (카메라는 -z 방향, near=0 ~ far=1)
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// 렌더러 설정 // 렌더러 설정
@ -34,6 +34,7 @@ export class ThreeScene {
u_numAreas: { value: 0 }, u_numAreas: { value: 0 },
u_dragVectors: { value: new Float32Array(16) }, // 8벡터 × 2(x,y) u_dragVectors: { value: new Float32Array(16) }, // 8벡터 × 2(x,y)
u_distortionStrengths: { value: new Float32Array(8) }, u_distortionStrengths: { value: new Float32Array(8) },
u_lensEffects: { value: new Float32Array(8) },
}; };
this.handleResize(); this.handleResize();
@ -61,6 +62,10 @@ export class ThreeScene {
* @param fragmentShader * @param fragmentShader
*/ */
public setShaderMaterial(vertexShader: string, fragmentShader: string) { public setShaderMaterial(vertexShader: string, fragmentShader: string) {
console.log('[ThreeScene] setShaderMaterial 호출됨');
console.log('[ThreeScene] vertexShader 길이:', vertexShader.length);
console.log('[ThreeScene] fragmentShader 길이:', fragmentShader.length);
const geometry = new THREE.PlaneGeometry(2, 2); const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ const material = new THREE.ShaderMaterial({
uniforms: this.uniforms, uniforms: this.uniforms,
@ -68,12 +73,36 @@ export class ThreeScene {
fragmentShader, fragmentShader,
}); });
console.log('[ThreeScene] ShaderMaterial 생성됨');
// 셰이더 컴파일 에러 확인
const renderer = this.renderer;
const testScene = new THREE.Scene();
const testMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
testScene.add(testMesh);
try {
renderer.compile(testScene, this.camera);
console.log('[ThreeScene] 셰이더 컴파일 성공!');
} catch (e) {
console.error('[ThreeScene] 셰이더 컴파일 에러:', e);
}
if (this.mesh) { if (this.mesh) {
this.scene.remove(this.mesh); this.scene.remove(this.mesh);
} }
this.mesh = new THREE.Mesh(geometry, material); this.mesh = new THREE.Mesh(geometry, material);
this.mesh.renderOrder = 0;
this.scene.add(this.mesh); this.scene.add(this.mesh);
console.log('[ThreeScene] mesh를 씬에 추가함');
}
/**
* Three.js
*/
public getScene(): THREE.Scene {
return this.scene;
} }
/** /**
@ -94,6 +123,16 @@ export class ThreeScene {
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
/**
*
*/
public getResolution(): { x: number; y: number } {
return {
x: this.uniforms.u_resolution.value.x,
y: this.uniforms.u_resolution.value.y,
};
}
/** /**
* *
*/ */

View File

@ -0,0 +1,230 @@
import { useRef, useCallback, useState } from 'react';
import { useMouseVelocity } from './useMouseVelocity';
import { SpringPhysics } from '@/engine/SpringPhysics';
import { DistortionArea, Point } from '@/types';
import { MouseInteractionConfig } from '@/types/interaction';
/**
*
*/
const isPointInPolygon = (point: Point, polygon: Point[]): boolean => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = ((yi > point.y) !== (yj > point.y))
&& (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
};
/**
*
*
*/
export const useMouseInteraction = (
containerRef: React.RefObject<HTMLElement | null>,
config: MouseInteractionConfig
) => {
const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndices, setInteractingAreaIndices] = useState<Set<number>>(new Set());
const springPhysicsMapRef = useRef<Map<number, SpringPhysics>>(new Map());
/**
* ( )
*/
const getSpringPhysics = useCallback((areaIndex: number, area?: DistortionArea): SpringPhysics => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
// 영역별 물리 설정이 있으면 사용, 없으면 전역 설정 사용
const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
}
return springPhysicsMapRef.current.get(areaIndex)!;
}, [config.physics]);
/**
* dragVector를
*/
const updateInteraction = useCallback((areas: DistortionArea[], deltaTime: number): DistortionArea[] => {
if (!config.enabled) return areas;
// 영역별 물리 설정이 변경되었을 수 있으므로 모든 스프링 업데이트
areas.forEach((area, index) => {
if (area.physics && springPhysicsMapRef.current.has(index)) {
const spring = springPhysicsMapRef.current.get(index)!;
spring.setConfig(area.physics);
}
});
const mouseState = getState();
// 마우스 클릭/드래그 중이고 위치가 있으면
if (mouseState.isDragging && mouseState.position) {
// 현재 마우스 위치가 포함된 모든 영역 찾기
const currentlyInAreas = new Set<number>();
for (let i = 0; i < areas.length; i++) {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
currentlyInAreas.add(i);
// 새로 진입한 영역이면 스프링 리셋
if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i, areas[i]).reset();
}
}
}
// 이전에 인터랙션하던 영역에서 벗어났으면 평형으로 복귀
interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
}
});
// 인터랙션 영역 업데이트
setInteractingAreaIndices(currentlyInAreas);
// 현재 위치의 모든 영역에 속도 적용
const velocityMult = config.velocityMultiplier || 1.0;
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
const minVel = config.minVelocity || 0.05;
const maxVel = config.maxVelocity || 5.0;
// 속도 클램핑
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale,
};
}
currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) {
// 드래그 중: 마우스 속도를 목표로 설정
spring.setTarget(clampedVelocity, velocityMult);
} else {
// 드래그 중이지만 마우스가 멈춰있으면 평형으로 복귀
spring.returnToEquilibrium();
}
});
} else {
// 마우스를 놓았으면 인터랙션 중이던 모든 영역에 튕김 효과
if (interactingAreaIndices.size > 0) {
const velocityMult = config.velocityMultiplier || 1.0;
const maxVel = config.maxVelocity || 5.0;
// 속도 클램핑
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale,
};
}
// 모든 인터랙션 영역에 초기 속도 설정
interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult);
});
setInteractingAreaIndices(new Set());
}
}
// 모든 영역의 스프링 물리 업데이트
return areas.map((area, index) => {
const spring = springPhysicsMapRef.current.get(index);
if (!spring) return area;
// 현재 드래그 중인 영역이거나 스프링이 활성 상태일 때만 업데이트
const springVelocity = spring.getVelocity();
const springDisplacement = spring.getDisplacement();
const isSpringActive = Math.sqrt(springVelocity.x ** 2 + springVelocity.y ** 2) > 0.001 ||
Math.sqrt(springDisplacement.x ** 2 + springDisplacement.y ** 2) > 0.001;
// 드래그 중이 아니고 스프링도 비활성이면 업데이트 안 함
if (!interactingAreaIndices.has(index) && !isSpringActive) {
return area;
}
// 스프링 물리 업데이트
const displacement = spring.update(deltaTime);
// 변위가 거의 0이면 원래 dragVector 유지
const displacementMag = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
if (displacementMag < 0.001) {
return area;
}
// 스프링 변위를 dragVector에 추가 (기존 애니메이션과 혼합)
// dragVector는 텍스처 샘플링 좌표 이동이므로,
// 마우스 드래그 방향과 반대로 적용해야 이미지가 드래그 방향으로 밀림
// 예: 우→좌 드래그(velocity < 0) → dragVector > 0 → 이미지 왼쪽으로 밀림
return {
...area,
dragVector: {
x: area.dragVector.x - displacement.x,
y: area.dragVector.y - displacement.y,
},
};
});
}, [config, getState, interactingAreaIndices, getSpringPhysics]);
/**
*
*/
const updateConfig = useCallback((newConfig: Partial<MouseInteractionConfig>) => {
const physicsConfig = newConfig.physics;
if (physicsConfig) {
// 모든 스프링 물리 엔진의 설정 업데이트
springPhysicsMapRef.current.forEach((spring) => {
spring.setConfig(physicsConfig);
});
}
}, []);
/**
*
*/
const reset = useCallback(() => {
springPhysicsMapRef.current.forEach((spring) => {
spring.reset();
});
setInteractingAreaIndices(new Set());
}, []);
/**
*
*/
const isDragging = useCallback((): boolean => {
const mouseState = getState();
return mouseState.isDragging;
}, [getState]);
/**
*
*/
const getInteractingAreaIndices = useCallback((): Set<number> => {
return interactingAreaIndices;
}, [interactingAreaIndices]);
return {
updateInteraction,
updateConfig,
reset,
isDragging,
getInteractingAreaIndices,
getMouseState: getState,
};
};

View File

@ -0,0 +1,204 @@
import { useRef, useCallback, useEffect } from 'react';
import { Point } from '@/types';
import { MouseState } from '@/types/interaction';
/**
* , ,
*/
export const useMouseVelocity = (containerRef: React.RefObject<HTMLElement | null>) => {
const mouseStateRef = useRef<MouseState>({
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false,
});
const lastUpdateTimeRef = useRef<number>(Date.now());
const prevVelocityRef = useRef<Point>({ x: 0, y: 0 });
/**
* (0-1)
*/
const toNormalized = useCallback((clientX: number, clientY: number): Point | null => {
if (!containerRef.current) return null;
const rect = containerRef.current.getBoundingClientRect();
return {
x: (clientX - rect.left) / rect.width,
y: (clientY - rect.top) / rect.height,
};
}, [containerRef]);
/**
* (/ )
*/
const updatePosition = useCallback((clientX: number, clientY: number) => {
const now = Date.now();
const deltaTime = (now - lastUpdateTimeRef.current) / 1000; // 초 단위
lastUpdateTimeRef.current = now;
const normalizedPos = toNormalized(clientX, clientY);
if (!normalizedPos) return;
const state = mouseStateRef.current;
const prevPos = state.position;
// 속도 계산 (변위 / 시간)
let velocity: Point = { x: 0, y: 0 };
if (prevPos && deltaTime > 0) {
velocity = {
x: (normalizedPos.x - prevPos.x) / deltaTime,
y: (normalizedPos.y - prevPos.y) / deltaTime,
};
}
// 가속도 계산 (속도 변화 / 시간)
const prevVel = prevVelocityRef.current;
let acceleration: Point = { x: 0, y: 0 };
if (deltaTime > 0) {
acceleration = {
x: (velocity.x - prevVel.x) / deltaTime,
y: (velocity.y - prevVel.y) / deltaTime,
};
}
// 상태 업데이트
mouseStateRef.current = {
position: normalizedPos,
prevPosition: prevPos,
velocity,
acceleration,
isHovering: true,
isDragging: state.isDragging,
};
prevVelocityRef.current = velocity;
}, [toNormalized]);
/**
*
*/
const handleMouseMove = useCallback((e: MouseEvent) => {
updatePosition(e.clientX, e.clientY);
}, [updatePosition]);
/**
*
*/
const handleMouseEnter = useCallback(() => {
mouseStateRef.current.isHovering = true;
}, []);
/**
*
*/
const handleMouseLeave = useCallback(() => {
mouseStateRef.current = {
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false,
};
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
/**
*
*/
const handleMouseDown = useCallback(() => {
mouseStateRef.current.isDragging = true;
}, []);
/**
*
*/
const handleMouseUp = useCallback(() => {
mouseStateRef.current.isDragging = false;
}, []);
/**
*
*/
const handleTouchMove = useCallback((e: TouchEvent) => {
e.preventDefault(); // 스크롤 방지
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
/**
*
*/
const handleTouchStart = useCallback((e: TouchEvent) => {
e.preventDefault(); // 스크롤 방지
mouseStateRef.current.isDragging = true;
mouseStateRef.current.isHovering = true;
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
/**
*
*/
const handleTouchEnd = useCallback(() => {
mouseStateRef.current.isDragging = false;
mouseStateRef.current.isHovering = false;
mouseStateRef.current.position = null;
mouseStateRef.current.prevPosition = null;
mouseStateRef.current.velocity = { x: 0, y: 0 };
mouseStateRef.current.acceleration = { x: 0, y: 0 };
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
/**
*
*/
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// 마우스 이벤트
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseenter', handleMouseEnter);
container.addEventListener('mouseleave', handleMouseLeave);
container.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
// 터치 이벤트 (passive: false로 스크롤 방지 가능)
container.addEventListener('touchmove', handleTouchMove, { passive: false });
container.addEventListener('touchstart', handleTouchStart, { passive: false });
container.addEventListener('touchend', handleTouchEnd);
container.addEventListener('touchcancel', handleTouchEnd);
return () => {
// 마우스 이벤트 제거
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseenter', handleMouseEnter);
container.removeEventListener('mouseleave', handleMouseLeave);
container.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
// 터치 이벤트 제거
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
};
}, [containerRef, handleMouseMove, handleMouseEnter, handleMouseLeave, handleMouseDown, handleMouseUp, handleTouchMove, handleTouchStart, handleTouchEnd]);
/**
*
*/
const getState = useCallback((): MouseState => {
return { ...mouseStateRef.current };
}, []);
return {
getState,
};
};

View File

@ -2,10 +2,38 @@
export { ImageDistortion } from './components/ImageDistortion'; export { ImageDistortion } from './components/ImageDistortion';
export type { ImageDistortionProps } from './components/ImageDistortion'; export type { ImageDistortionProps } from './components/ImageDistortion';
// 에디터 컴포넌트들 (개별적으로 조합 가능)
export { EditorCanvas } from './editor/components/EditorCanvas';
export type { EditorCanvasProps } from './editor/components/EditorCanvas';
export { AreaList } from './editor/components/AreaList';
export type { AreaListProps } from './editor/components/AreaList';
export { ParameterPanel } from './editor/components/ParameterPanel';
export type { ParameterPanelProps } from './editor/components/ParameterPanel';
// 에디터 상태 관리 훅
export { useDistortionEditor } from './editor/hooks/useDistortionEditor';
// 에디터 타입 및 스타일
export type {
EditorState,
EditMode,
EditorCanvasStyle,
CircleLevelStyle,
CenterPointStyle,
PointHandleStyle,
AreaOutlineStyle,
} from './editor/types';
// 에디터 기본 스타일 상수
export { DEFAULT_EDITOR_CANVAS_STYLE } from './editor/constants';
// 타입 정의 // 타입 정의
export type { export type {
Point, Point,
EasingFunction, EasingFunction,
BuiltInMotionPreset,
MotionPreset,
MotionPresetDefinition,
DistortionMovement, DistortionMovement,
DistortionArea, DistortionArea,
AreaBounds, AreaBounds,
@ -15,14 +43,48 @@ export type {
AnimationTicker, AnimationTicker,
} from './types'; } from './types';
// 마우스 인터랙션 타입
export type {
SpringPhysicsConfig,
MouseInteractionConfig,
MouseState,
SpringState,
} from './types/interaction';
// 스프라이트 이펙트 타입
export type {
SpriteEffectTrigger,
SpriteBlendMode,
SpriteEffectConfig,
SpriteEffectArea,
SpriteEffectAreaData,
SpriteParticleOverLifetime,
SpriteSheetConfig,
} from './types/spriteEffect';
// 유틸리티 함수 // 유틸리티 함수
export { applyEasing } from './utils/easing'; export { applyEasing } from './utils/easing';
export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants'; export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants';
export {
presetToVector,
isRotationPreset,
// 프리셋 레지스트리 API
registerMotionPreset,
registerMotionPresets,
unregisterMotionPreset,
getRegisteredPresets,
hasPreset,
resetToBuiltInPresets,
} from './utils/motionPresets';
// 엔진 클래스 (고급 사용자용) // 엔진 클래스 (고급 사용자용)
export { ThreeScene } from './engine/ThreeScene'; export { ThreeScene } from './engine/ThreeScene';
export { ShaderManager } from './engine/ShaderManager'; export { ShaderManager } from './engine/ShaderManager';
export { AnimationLoop } from './engine/AnimationLoop'; export { AnimationLoop } from './engine/AnimationLoop';
export { SpringPhysics } from './engine/SpringPhysics';
export { SpriteEffectManager } from './engine/SpriteEffectManager';
// 훅 // 훅
export { useAnimationFrame } from './hooks/useAnimationFrame'; export { useAnimationFrame } from './hooks/useAnimationFrame';
export { useMouseVelocity } from './hooks/useMouseVelocity';
export { useMouseInteraction } from './hooks/useMouseInteraction';

View File

@ -0,0 +1,39 @@
uniform vec2 u_resolution;
uniform sampler2D u_texture;
uniform vec2 u_points[32];
uniform int u_numAreas;
uniform vec2 u_dragVectors[8];
uniform float u_distortionStrengths[8];
varying vec2 vUv;
void main() {
vec2 texCoord = vUv;
// 디버그: 영역 표시
for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break;
// 포인트를 픽셀 좌표로 변환
vec2 p0 = u_points[i * 4 + 0] * u_resolution;
vec2 p1 = u_points[i * 4 + 1] * u_resolution;
vec2 p2 = u_points[i * 4 + 2] * u_resolution;
vec2 p3 = u_points[i * 4 + 3] * u_resolution;
vec2 pixelCoord = vUv * u_resolution;
// 경계 상자 체크만
vec2 minP = min(min(p0, p1), min(p2, p3));
vec2 maxP = max(max(p0, p1), max(p2, p3));
// 영역 안에 있으면 빨간색으로 표시
if (pixelCoord.x >= minP.x && pixelCoord.x <= maxP.x &&
pixelCoord.y >= minP.y && pixelCoord.y <= maxP.y) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 빨간색
return;
}
}
// 영역 밖은 원본 이미지
gl_FragColor = texture2D(u_texture, texCoord);
}

View File

@ -1,88 +1,119 @@
uniform vec2 u_resolution; uniform vec2 u_resolution;
uniform sampler2D u_texture; uniform sampler2D u_texture;
uniform vec2 u_points[32]; // 최대 8영역 × 4포인트 uniform vec2 u_points[32]; // 최대 8영역 × 4포인트 (정규화된 좌표 0-1)
uniform int u_numAreas; uniform int u_numAreas;
uniform vec2 u_dragVectors[8]; uniform vec2 u_dragVectors[8]; // 드래그 벡터 (정규화된 좌표 0-1)
uniform float u_distortionStrengths[8]; uniform float u_distortionStrengths[8];
uniform float u_lensEffects[8];
varying vec2 vUv; varying vec2 vUv;
// 사각형 내부의 포인트에 대한 UV 좌표 계산 // Flutter 원본 computeUV 함수 (정확히 동일하게 변환)
vec2 computeUV(vec2 xy, vec2 p0, vec2 p1, vec2 p2, vec2 p3) { vec2 computeUV(vec2 xy, vec2 p0, vec2 p1, vec2 p2, vec2 p3) {
// 경계 상자 체크
vec2 minP = min(min(p0, p1), min(p2, p3)); vec2 minP = min(min(p0, p1), min(p2, p3));
vec2 maxP = max(max(p0, p1), max(p2, p3)); vec2 maxP = max(max(p0, p1), max(p2, p3));
if (xy.x < minP.x || xy.x > maxP.x || xy.y < minP.y || xy.y > maxP.y) { if (xy.x < minP.x || xy.x > maxP.x || xy.y < minP.y || xy.y > maxP.y) {
return vec2(-1.0, -1.0); return vec2(-1.0, -1.0); // 외부
} }
// 초기 추정값 (정규화된 좌표)
vec2 rectSize = maxP - minP; vec2 rectSize = maxP - minP;
vec2 rectUV = (xy - minP) / rectSize; if (rectSize.x == 0.0 || rectSize.y == 0.0) {
return vec2(-1.0, -1.0); // 축퇴
}
vec2 rectMin = minP;
vec2 rectUV = (xy - rectMin) / rectSize;
float u0 = rectUV.x; float u0 = rectUV.x;
float v0 = rectUV.y; float v0 = rectUV.y;
// Newton-Raphson 반복법으로 정확한 UV 계산 // 1회 Newton-Raphson (Flutter 원본과 동일)
for (int iter = 0; iter < 3; iter++) { vec2 left = mix(p0, p1, u0);
vec2 xy0 = mix(mix(p0, p1, u0), mix(p3, p2, u0), v0); vec2 right = mix(p3, p2, u0);
vec2 xy0 = mix(left, right, v0);
vec2 dxy = xy - xy0;
vec2 du_vec = mix(p1 - p0, p2 - p3, v0); vec2 du_vec = mix(p1 - p0, p2 - p3, v0);
vec2 dv_vec = mix(p3 - p0, p2 - p1, u0); vec2 dv_vec = mix(p3 - p0, p2 - p1, u0);
vec2 dxy = xy - xy0;
float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x; float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x;
if (abs(det) > 1e-6) {
if (abs(det) < 1e-6) break; float inv_det = 1.0 / det;
float du = (dv_vec.y * dxy.x - dv_vec.x * dxy.y) * inv_det;
float du = (dv_vec.y * dxy.x - dv_vec.x * dxy.y) / det; float dv = (-du_vec.y * dxy.x + du_vec.x * dxy.y) * inv_det;
float dv = (-du_vec.y * dxy.x + du_vec.x * dxy.y) / det;
u0 += du; u0 += du;
v0 += dv; v0 += dv;
} }
// 포인트가 내부에 있는지 확인
if (u0 >= 0.0 && u0 <= 1.0 && v0 >= 0.0 && v0 <= 1.0) {
return vec2(u0, v0); return vec2(u0, v0);
} }
return vec2(-1.0, -1.0);
}
void main() { void main() {
vec2 uv = vUv; vec2 xy = vUv * u_resolution; // 픽셀 좌표
vec2 pixelCoord = vUv * u_resolution; vec2 texCoord = vUv;
// 모든 영역의 왜곡 적용 // 모든 겹치는 영역의 왜곡을 누적 적용
for (int i = 0; i < 8; i++) { for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break; if (i >= u_numAreas) break;
int baseIndex = i * 4; // 포인트는 정규화된 좌표로 전달받았으므로 픽셀 좌표로 변환
vec2 p0 = u_points[baseIndex + 0] * u_resolution; vec2 p0 = u_points[i * 4 + 0] * u_resolution;
vec2 p1 = u_points[baseIndex + 1] * u_resolution; vec2 p1 = u_points[i * 4 + 1] * u_resolution;
vec2 p2 = u_points[baseIndex + 2] * u_resolution; vec2 p2 = u_points[i * 4 + 2] * u_resolution;
vec2 p3 = u_points[baseIndex + 3] * u_resolution; vec2 p3 = u_points[i * 4 + 3] * u_resolution;
vec2 areaUV = computeUV(pixelCoord, p0, p1, p2, p3); vec2 uv_local = computeUV(xy, p0, p1, p2, p3);
if (areaUV.x >= 0.0) { if (uv_local.x >= 0.0 && uv_local.x <= 1.0 && uv_local.y >= 0.0 && uv_local.y <= 1.0) {
// 이 영역 내부에 포인트가 있음 vec2 uvCenter = vec2(0.5, 0.5);
vec2 center = vec2(0.5, 0.5); float distToCenter = distance(uv_local, uvCenter);
float distToCenter = length(areaUV - center); float maxUvRadius = 0.5;
float maxUvRadius = 0.707; // sqrt(0.5^2 + 0.5^2)
// 부드러운 감쇠 if (distToCenter < maxUvRadius) {
float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter); float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter);
// dragVector는 정규화된 좌표(0-1)이므로 바로 사용
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
texCoord += distortion;
// 왜곡 적용 // 렌즈 왜곡 효과 (볼록: 중심 확대, 오목: 중심 축소)
vec2 distortion = (u_dragVectors[i] / u_resolution) * influence * u_distortionStrengths[i]; if (abs(u_lensEffects[i]) > 0.001) {
uv += distortion; // 영역 중심의 글로벌 UV 좌표
vec2 minP_area = min(min(p0, p1), min(p2, p3));
vec2 maxP_area = max(max(p0, p1), max(p2, p3));
vec2 areaSize = maxP_area - minP_area;
vec2 areaCenterUV = (minP_area + maxP_area) * 0.5 / u_resolution;
// 현재 픽셀에서 영역 중심까지의 글로벌 UV 오프셋
vec2 offset = vUv - areaCenterUV;
// 픽셀 공간 거리로 원형 감쇠 (긴 변 기준으로 영역 전체 커버)
float distPx = length(offset * u_resolution);
float maxRadiusPx = max(areaSize.x, areaSize.y) * 0.5;
float normalizedDist = distPx / maxRadiusPx;
if (normalizedDist < 1.0) {
// 중심에서 최대 강도, 가장자리로 갈수록 자연스럽게 0으로 감소
float lensAmount = u_lensEffects[i] * (1.0 - normalizedDist * normalizedDist);
// 볼록(+): 텍스처 좌표를 중심으로 당김 → 확대
// offset은 글로벌 UV이므로 픽셀 공간에서 등방성(isotropic) 확대
texCoord -= offset * lensAmount * u_distortionStrengths[i];
}
}
}
} }
} }
// 텍스처 외부 샘플링 방지를 위한 클램핑 // 경계 근처에서 부드럽게 페이드 아웃
uv = clamp(uv, 0.0, 1.0); // 텍스처 좌표가 0~1 범위를 벗어나면 알파값을 줄여서 자연스럽게 처리
vec2 edgeDist = min(texCoord, 1.0 - texCoord);
float edgeFade = smoothstep(0.0, 0.05, min(edgeDist.x, edgeDist.y));
// 텍스처 샘플링 // 범위를 벗어난 좌표는 fract로 래핑하여 반복 효과 (더 자연스러움)
gl_FragColor = texture2D(u_texture, uv); vec2 wrappedCoord = fract(texCoord);
vec4 color = texture2D(u_texture, wrappedCoord);
// 경계에서 페이드 아웃 적용
color.a *= edgeFade;
gl_FragColor = color;
} }

View File

@ -0,0 +1,8 @@
uniform sampler2D u_texture;
varying vec2 vUv;
void main() {
// 간단한 테스트: 텍스처를 그대로 표시 (왜곡 없음)
vec4 color = texture2D(u_texture, vUv);
gl_FragColor = color;
}

View File

@ -15,20 +15,57 @@ export type EasingFunction =
| 'easeOut' | 'easeOut'
| 'easeInOut' | 'easeInOut'
| 'easeInQuad' | 'easeInQuad'
| 'easeOutQuad'; | 'easeOutQuad'
| 'easeInCubic'
| 'easeOutCubic';
/**
*
*/
export type BuiltInMotionPreset =
| 'none' // 없음 (애니메이션 없음)
| 'horizontal' // 좌우 왕복
| 'vertical' // 상하 왕복
| 'rotate-cw' // 시계방향 회전
| 'rotate-ccw' // 반시계방향 회전
| 'pulse' // 펄스 (확대/축소)
| 'diagonal-1' // 대각선 (좌상→우하)
| 'diagonal-2'; // 대각선 (우상→좌하)
/**
* ( + )
* registerMotionPreset()
*/
export type MotionPreset = BuiltInMotionPreset | (string & {});
/**
*
* @param strength (기본값: 0.1)
* @returns x, y
*/
export type MotionPresetDefinition = (strength: number) => Point;
/**
*
*/
export type RotationPresetChecker = (preset: MotionPreset) => boolean;
/** /**
* *
*/ */
export interface DistortionMovement { export interface DistortionMovement {
/** 왜곡 시작 벡터 */ /** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
vectorA: Point; vectorA: Point;
/** 왜곡 종료 벡터 */ /** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
vectorB: Point; vectorB: Point;
/** 애니메이션 지속 시간 (초) */ /** 애니메이션 지속 시간 (초) */
duration: number; duration: number;
/** 적용할 이징 함수 */ /** 적용할 이징 함수 */
easing: EasingFunction; easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
} }
/** /**
@ -47,6 +84,21 @@ export interface DistortionArea {
progress: number; progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */ /** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point; dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
/** 렌즈 효과 설정 (선택사항) */
lensEffect?: {
/** 렌즈 강도 (양수: 볼록, 음수: 오목, 0: 없음, 범위: -1.0 ~ 1.0) */
strength: number;
};
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number;
} }
/** /**

View File

@ -1,3 +1,4 @@
export * from './area'; export * from './area';
export * from './shader'; export * from './shader';
export * from './animation'; export * from './animation';
export * from './spriteEffect';

63
src/types/interaction.ts Normal file
View File

@ -0,0 +1,63 @@
import { Point } from './area';
/**
*
*/
export interface SpringPhysicsConfig {
/** 스프링 탄성 계수 (높을수록 빠르게 복원) */
stiffness: number;
/** 감쇠 계수 (높을수록 빨리 멈춤) */
damping: number;
/** 질량 (높을수록 느리게 움직임) */
mass: number;
/** 영향 반경 (정규화 좌표, 기본값 0.2) */
influenceRadius: number;
/** 최대 왜곡 강도 */
maxStrength: number;
}
/**
*
*/
export interface MouseInteractionConfig {
/** 마우스 인터랙션 활성화 여부 */
enabled: boolean;
/** 스프링 물리 파라미터 */
physics: SpringPhysicsConfig;
/** 최소 속도 임계값 (이보다 느리면 효과 없음) */
minVelocity?: number;
/** 최대 속도 제한 (이보다 빠르면 클램핑) */
maxVelocity?: number;
/** 속도 승수 (마우스 속도에 곱해지는 값) */
velocityMultiplier?: number;
}
/**
*
*/
export interface MouseState {
/** 현재 마우스 위치 (정규화 좌표) */
position: Point | null;
/** 이전 마우스 위치 */
prevPosition: Point | null;
/** 속도 벡터 */
velocity: Point;
/** 가속도 벡터 */
acceleration: Point;
/** 마우스가 컨테이너 위에 있는지 */
isHovering: boolean;
/** 드래그 중인지 */
isDragging: boolean;
}
/**
*
*/
export interface SpringState {
/** 현재 변위 (displacement) */
displacement: Point;
/** 현재 속도 */
velocity: Point;
/** 목표 위치 (평형 상태는 {x:0, y:0}) */
target: Point;
}

View File

@ -4,7 +4,7 @@ import * as THREE from 'three';
* *
*/ */
export interface ShaderUniforms { export interface ShaderUniforms {
[uniform: string]: THREE.IUniform<any>; [uniform: string]: THREE.IUniform;
/** 화면 해상도 */ /** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>; u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */ /** 이미지 텍스처 */
@ -17,6 +17,8 @@ export interface ShaderUniforms {
u_dragVectors: THREE.IUniform<Float32Array>; u_dragVectors: THREE.IUniform<Float32Array>;
/** 각 영역의 왜곡 강도 배열 */ /** 각 영역의 왜곡 강도 배열 */
u_distortionStrengths: THREE.IUniform<Float32Array>; u_distortionStrengths: THREE.IUniform<Float32Array>;
/** 각 영역의 렌즈 효과 강도 배열 */
u_lensEffects: THREE.IUniform<Float32Array>;
} }
/** /**

120
src/types/spriteEffect.ts Normal file
View File

@ -0,0 +1,120 @@
import type { Point } from './area';
/** 이펙트 트리거 타입 */
export type SpriteEffectTrigger = 'ambient' | 'touch';
/** 블렌드 모드 */
export type SpriteBlendMode = 'normal' | 'additive';
/**
*
*/
export interface SpriteParticleOverLifetime {
/** 스케일 보간: [시작, 끝] 또는 [시작, 중간, 끝] */
scale?: number[];
/** 투명도 보간: [시작, 끝] 또는 [시작, 중간, 끝] */
opacity?: number[];
/** 회전 속도 (라디안/초) */
rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
velocityDamping?: number;
}
/**
*
*/
export interface SpriteSheetConfig {
/** 가로 프레임 수 */
columns: number;
/** 세로 프레임 수 */
rows: number;
/** 총 프레임 수 (columns * rows 보다 적을 수 있음) */
totalFrames: number;
/** 재생 속도 (프레임/초) */
fps: number;
/** 반복 재생 여부 (기본: true) */
loop?: boolean;
}
/**
*
* (DistortionArea)
*/
export interface SpriteEffectArea {
/** 고유 식별자 */
id: string;
/** 이펙트 중심 좌표 (정규화 0-1) */
position: Point;
/** 터치 감지 반경 (정규화, 기본: 0.1) */
radius?: number;
/** 이 영역에 연결된 이펙트 설정 배열 */
effects: SpriteEffectConfig[];
}
/**
* SpriteEffectArea
* DB
*/
export interface SpriteEffectAreaData {
id: string;
position: { x: number; y: number };
radius?: number;
effects: Array<{
id: string;
trigger: SpriteEffectTrigger;
/** spriteUrl 또는 emoji 중 하나 필수 */
spriteUrl?: string;
/** 이모지 파티클 (spriteUrl 대신 사용 가능) */
emoji?: string;
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number;
burstCount?: number;
lifetime: [number, number];
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number];
emitOffset?: { x: number; y: number };
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}>;
}
/**
*
*/
export interface SpriteEffectConfig {
/** 고유 식별자 */
id: string;
/** 트리거 타입 */
trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL (emoji와 택1) */
spriteUrl?: string;
/** 이모지 파티클 (spriteUrl 대신 사용 가능) */
emoji?: string;
/** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */
maxParticles: number;
/** ambient: 초당 방출 수 */
emitRate?: number;
/** touch: 터치 시 방출 수 */
burstCount?: number;
/** [최소, 최대] 수명 (초) */
lifetime: [number, number];
/** [최소, 최대] 초기 스케일 */
initialScale: [number, number];
/** [최소, 최대] 초기 속도 */
initialSpeed: [number, number];
/** 방출 각도 범위 (도) */
emitAngle?: [number, number];
/** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point;
/** 방출 범위 반경 */
emitRadius?: number;
/** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
}

View File

@ -10,6 +10,8 @@ export const SHADER_CONFIG = {
MAX_DRAG_VECTORS: 8, MAX_DRAG_VECTORS: 8,
/** 최대 강도 배열 크기 */ /** 최대 강도 배열 크기 */
MAX_STRENGTHS: 8, MAX_STRENGTHS: 8,
/** 최대 렌즈 효과 배열 크기 */
MAX_LENS_EFFECTS: 8,
} as const; } as const;
/** /**
@ -36,4 +38,8 @@ export const DEFAULT_AREA = {
VECTOR_A: { x: 0.1, y: 0.1 }, VECTOR_A: { x: 0.1, y: 0.1 },
/** 기본 벡터 B */ /** 기본 벡터 B */
VECTOR_B: { x: -0.1, y: -0.1 }, VECTOR_B: { x: -0.1, y: -0.1 },
/** 기본 렌즈 효과 강도 */
LENS_STRENGTH: 0,
/** 기본 스텝 양자화 단계 수 (0=없음) */
SNAP_STEPS: 0,
} as const; } as const;

View File

@ -1,4 +1,4 @@
import { EasingFunction } from '../types'; import { type EasingFunction } from '../types';
type EasingFunc = (t: number) => number; type EasingFunc = (t: number) => number;
@ -14,6 +14,9 @@ const easingFunctions: Record<EasingFunction, EasingFunc> = {
easeInQuad: (t) => t * t, easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t), easeOutQuad: (t) => t * (2 - t),
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
}; };
/** /**

163
src/utils/motionPresets.ts Normal file
View File

@ -0,0 +1,163 @@
import type {MotionPreset, MotionPresetDefinition, Point, RotationPresetChecker} from '../types';
/**
* ( + )
*/
const presetRegistry = new Map<string, MotionPresetDefinition>();
/**
*
*/
const rotationPresets = new Set<string>(['rotate-cw', 'rotate-ccw']);
/**
*
*/
const BUILT_IN_PRESETS: Record<string, MotionPresetDefinition> = {
'none': () => ({x: 0, y: 0}),
'horizontal': (strength) => ({x: strength, y: 0}),
'vertical': (strength) => ({x: 0, y: strength}),
'rotate-cw': (strength) => ({x: strength, y: 0}),
'rotate-ccw': (strength) => ({x: -strength, y: 0}),
'pulse': (strength) => ({x: strength, y: strength}),
'diagonal-1': (strength) => ({x: strength * 0.707, y: strength * 0.707}),
'diagonal-2': (strength) => ({x: strength * 0.707, y: -strength * 0.707}),
};
// 내장 프리셋 등록
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
/**
*
* @param name
* @param definition (strength를 Point )
* @param options
* @param options.isRotation (true면 )
*
* @example
* // 좌우 진짜 왕복 (좌↔우)
* registerMotionPreset('horizontal-full', (strength) => ({
* x: strength * 2, // 진폭 2배
* y: 0
* }));
*
* // 8자 모양 운동 (회전)
* registerMotionPreset('figure-8', (strength) => ({
* x: strength,
* y: strength * 0.5
* }), { isRotation: true });
*/
export function registerMotionPreset(
name: string,
definition: MotionPresetDefinition,
options?: { isRotation?: boolean }
): void {
presetRegistry.set(name, definition);
if (options?.isRotation) {
rotationPresets.add(name);
} else {
rotationPresets.delete(name);
}
}
/**
*
* @param presets ( )
* @param rotationPresetNames
*
* @example
* registerMotionPresets({
* 'horizontal-full': (s) => ({x: s * 2, y: 0}),
* 'wave': (s) => ({x: s, y: s * 0.3}),
* }, ['wave']); // wave는 회전 애니메이션
*/
export function registerMotionPresets(
presets: Record<string, MotionPresetDefinition>,
rotationPresetNames?: string[]
): void {
Object.entries(presets).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresetNames?.forEach(name => rotationPresets.add(name));
}
/**
*
* @param name
* @returns
*/
export function unregisterMotionPreset(name: string): boolean {
rotationPresets.delete(name);
return presetRegistry.delete(name);
}
/**
*
* @returns
*/
export function getRegisteredPresets(): string[] {
return Array.from(presetRegistry.keys());
}
/**
*
* @param name
* @returns
*/
export function hasPreset(name: string): boolean {
return presetRegistry.has(name);
}
/**
* ( )
*/
export function resetToBuiltInPresets(): void {
presetRegistry.clear();
rotationPresets.clear();
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresets.add('rotate-cw');
rotationPresets.add('rotate-ccw');
}
/**
*
* @param preset
* @param strength (기본값: 0.1)
* @returns (vectorA)
*/
export function presetToVector(preset: MotionPreset, strength: number = 0.1): Point {
const definition = presetRegistry.get(preset);
if (definition) {
return definition(strength);
}
// 등록되지 않은 프리셋은 none으로 처리
console.warn(`Unknown motion preset: "${preset}". Falling back to "none".`);
return {x: 0, y: 0};
}
/**
*
*/
export function isRotationPreset(preset?: MotionPreset): boolean {
if (!preset) return false;
return rotationPresets.has(preset);
}
/**
*
* @param checker
* @deprecated isRotation registerMotionPreset에
*/
export function setRotationChecker(checker: RotationPresetChecker): void {
// Legacy support - 기존 코드 호환성을 위해 유지
console.warn('setRotationChecker is deprecated. Use registerMotionPreset with { isRotation: true } option instead.');
}