Compare commits

..

No commits in common. "main" and "develop" have entirely different histories.

50 changed files with 474 additions and 9838 deletions

View File

@ -5,18 +5,7 @@
"Bash(mkdir:*)",
"Bash(npm run build:*)",
"Bash(dir:*)",
"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)"
"Bash(tree:*)"
],
"deny": [],
"ask": []

4
.gitignore vendored
View File

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

6
.idea/AICommit.xml generated
View File

@ -1,6 +0,0 @@
<?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
View File

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

View File

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

901
README.md
View File

@ -1,23 +1,20 @@
# Responsive Image Canvas
GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다.
Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션, 마우스/터치 인터랙션, 파티클 이펙트를 구현합니다.
GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다. Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션을 구현합니다.
## 특징
- GPU 가속 렌더링 (Three.js + WebGL)
- 최대 8개의 독립적인 왜곡 영역 지원
- 스프링 물리 기반 마우스/터치 인터랙션
- 이모지 & 스프라이트 시트 파티클 이펙트
- 모션 프리셋 & 커스텀 이징 함수
- 렌즈 왜곡 (볼록/오목) 효과
- 영역 편집을 위한 에디터 컴포넌트
- TypeScript & ESM/CJS 지원
- 🚀 GPU 가속 렌더링 (Three.js + WebGL)
- 🎨 최대 8개의 독립적인 왜곡 영역 지원
- ⚡ 60fps 실시간 애니메이션
- 🎯 정규화된 좌표계 (0.0 - 1.0)
- 🔧 TypeScript 완벽 지원
- 📦 ESM & CommonJS 모두 지원
## 설치
```bash
npm install @baekryang/responsive-image-canvas
npm install responsive-image-canvas
```
### Peer Dependencies
@ -26,38 +23,25 @@ npm install @baekryang/responsive-image-canvas
npm install react react-dom three
```
| 패키지 | 버전 |
|--------|------|
| `react` | `^18.0.0 \|\| ^19.0.0` |
| `react-dom` | `^18.0.0 \|\| ^19.0.0` |
| `three` | `>=0.150.0` |
---
## 기본 사용법
### 이미지 왜곡 표시 (View Mode)
```tsx
import { ImageDistortion } from '@baekryang/responsive-image-canvas';
import type { DistortionArea } from '@baekryang/responsive-image-canvas';
import { ImageDistortion, DistortionArea } from 'responsive-image-canvas';
const areas: DistortionArea[] = [
{
id: 'area-1',
basePoints: [
{ x: 0.3, y: 0.3 }, // 좌상단
{ x: 0.7, y: 0.3 }, // 우상단
{ x: 0.7, y: 0.7 }, // 우하단
{ x: 0.3, y: 0.7 }, // 좌하단
{ x: 0.2, y: 0.2 }, // 좌상단
{ x: 0.4, y: 0.2 }, // 우상단
{ x: 0.4, y: 0.4 }, // 우하단
{ x: 0.2, y: 0.4 }, // 좌하단
],
movement: {
preset: 'horizontal',
vectorA: { x: 0.1, y: 0 },
vectorB: { x: -0.1, y: 0 },
vectorA: { x: 0.1, y: 0.1 },
vectorB: { x: -0.1, y: -0.1 },
duration: 2.0,
easing: 'easeInOut',
strength: 0.15,
},
distortionStrength: 0.5,
progress: 0,
@ -67,745 +51,204 @@ const areas: DistortionArea[] = [
function App() {
return (
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
style={{ width: '100%', height: '100%' }}
/>
);
}
```
> **좌표계**: 모든 좌표는 **정규화 좌표(0.0 ~ 1.0)** 를 사용합니다. `(0, 0)`은 이미지 좌상단, `(1, 1)`은 우하단입니다.
---
## 컴포넌트
### `<ImageDistortion />`
이미지 왜곡 및 인터랙션 렌더링을 담당하는 메인 컴포넌트입니다.
```tsx
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
mouseInteraction={mouseConfig}
spriteEffectAreas={spriteEffectAreas}
style={{ width: '100%', height: '100%' }}
/>
```
| Prop | 타입 | 필수 | 설명 |
|------|------|:----:|------|
| `imageSrc` | `string` | O | 이미지 URL |
| `areas` | `DistortionArea[]` | O | 왜곡 영역 배열 |
| `mouseInteraction` | `MouseInteractionConfig` | | 마우스/터치 인터랙션 설정 |
| `spriteEffectAreas` | `SpriteEffectArea[]` | | 파티클 이펙트 영역 |
| `isPlaying` | `boolean` | | 애니메이션 재생 여부 (기본: `true`) |
| `style` | `CSSProperties` | | 컨테이너 스타일 |
| `className` | `string` | | 컨테이너 클래스 |
### `<EditorCanvas />`
영역 편집을 위한 시각적 에디터 오버레이입니다. 꼭짓점 드래그, 영역 선택 등을 제공합니다.
```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()}`,
basePoints: [
{ x: 0.3, y: 0.3 },
{ x: 0.7, y: 0.3 },
{ x: 0.7, y: 0.7 },
{ x: 0.3, y: 0.7 },
],
movement: {
preset: 'none',
vectorA: { x: 0, y: 0 },
vectorB: { x: 0, y: 0 },
duration: DEFAULT_AREA.DURATION,
easing: DEFAULT_AREA.EASING,
strength: 0.15,
},
distortionStrength: DEFAULT_AREA.DISTORTION_STRENGTH,
progress: 0,
dragVector: { x: 0, y: 0 },
});
};
// 선택된 영역 업데이트
updateArea(state.selectedAreaId, {
distortionStrength: 0.8,
lensEffect: { strength: 0.3 },
});
```
### `useMouseInteraction`
마우스/터치 기반 스프링 물리 인터랙션을 제공합니다.
```tsx
import { useMouseInteraction } from '@baekryang/responsive-image-canvas';
const {
updateInteraction, // (areas, deltaTime) => DistortionArea[]
updateConfig, // (newConfig: Partial<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,
};
<ImageDistortion
imageSrc="/image.jpg"
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 style={{ width: '800px', height: '600px' }}>
<ImageDistortion
imageSrc="/path/to/image.jpg"
areas={areas}
isPlaying={true}
/>
</div>
);
}
```
---
## Props
## 타입 레퍼런스
### `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` | ✗ | - | 컨테이너 클래스명 |
## 타입 정의
### `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
x: number; // 0.0 - 1.0 (정규화된 좌표)
y: number; // 0.0 - 1.0 (정규화된 좌표)
}
```
interface DistortionArea {
id: string;
basePoints: [Point, Point, Point, Point]; // [좌상, 우상, 우하, 좌하]
movement: DistortionMovement;
distortionStrength: number; // 0.0 ~ 1.0
progress: number; // 0.0 ~ 1.0 (런타임)
dragVector: Point; // (런타임)
physics?: SpringPhysicsConfig;
lensEffect?: { strength: number }; // -1.0 ~ 1.0
snapSteps?: number; // 0 ~ 5
}
### `DistortionMovement`
```typescript
interface DistortionMovement {
preset?: MotionPreset;
vectorA: Point;
vectorB: Point;
duration: number; // 초
easing: EasingFunction;
strength?: number;
vectorA: Point; // 시작 벡터
vectorB: Point; // 종료 벡터
duration: number; // 지속 시간 (초)
easing: EasingFunction; // 이징 함수
}
```
### 인터랙션 타입
```typescript
interface SpringPhysicsConfig {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
}
interface MouseInteractionConfig {
enabled: boolean;
physics: SpringPhysicsConfig;
minVelocity?: number;
maxVelocity?: number;
velocityMultiplier?: number;
}
interface MouseState {
position: Point | null;
prevPosition: Point | null;
velocity: Point;
acceleration: Point;
isHovering: boolean;
isDragging: boolean;
}
```
### 파티클 이펙트 타입
```typescript
type SpriteEffectTrigger = 'ambient' | 'touch';
type SpriteBlendMode = 'normal' | 'additive';
interface SpriteEffectConfig {
id: string;
trigger: SpriteEffectTrigger;
emoji?: string; // 이모지 (spriteUrl과 택1)
spriteUrl?: string; // 스프라이트 이미지 URL
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number; // ambient용 (초당 생성 수)
burstCount?: number; // touch용 (클릭당 생성 수)
lifetime: [number, number]; // [최소, 최대] 초
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number]; // 도
emitOffset?: Point;
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}
interface SpriteEffectArea {
id: string;
position: Point;
radius?: number; // 기본: 0.1
effects: SpriteEffectConfig[];
}
interface SpriteParticleOverLifetime {
scale?: number[]; // [시작, 끝] 또는 [시작, 중간, 끝]
opacity?: number[];
rotationSpeed?: number; // 라디안/초
velocityDamping?: number; // 0 ~ 1
}
interface SpriteSheetConfig {
columns: number;
rows: number;
totalFrames: number;
fps: number;
loop?: boolean;
}
```
### 이징 & 프리셋 타입
### `EasingFunction`
```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 & {});
| 'easeIn'
| 'easeOut'
| 'easeInOut'
| 'easeInQuad'
| 'easeOutQuad';
```
---
## 고급 사용법
## 상수
### 영역 동적 추가/제거
```typescript
import { DEFAULT_AREA, SHADER_CONFIG, ANIMATION_CONFIG } from '@baekryang/responsive-image-canvas';
```tsx
function DynamicDistortion() {
const [areas, setAreas] = useState<DistortionArea[]>([]);
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
const addArea = () => {
const newArea: DistortionArea = {
id: `area-${Date.now()}`,
basePoints: [
{ x: 0.3, y: 0.3 },
{ x: 0.7, y: 0.3 },
{ x: 0.7, y: 0.7 },
{ x: 0.3, y: 0.7 },
],
movement: {
vectorA: { x: 0.15, y: 0 },
vectorB: { x: -0.15, y: 0 },
duration: 3.0,
easing: 'easeInOut',
},
distortionStrength: 0.6,
progress: 0,
dragVector: { x: 0, y: 0 },
};
SHADER_CONFIG.MAX_AREAS // 8
ANIMATION_CONFIG.TARGET_FPS // 60
setAreas([...areas, newArea]);
};
return (
<div>
<button onClick={addArea}>영역 추가</button>
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
/>
</div>
);
}
```
---
### 유틸리티 함수 사용
## 유틸리티
```tsx
import { DEFAULT_AREA, applyEasing } from 'responsive-image-canvas';
```typescript
import {
applyEasing,
registerMotionPreset,
registerMotionPresets,
unregisterMotionPreset,
getRegisteredPresets,
presetToVector,
isRotationPreset,
} from '@baekryang/responsive-image-canvas';
// 기본 설정값 사용
const newArea = {
...DEFAULT_AREA,
id: 'my-area',
basePoints: [/* ... */],
};
// 이징 함수 직접 사용
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 }
const easedValue = applyEasing(0.5, 'easeInOut');
console.log(easedValue); // 0.5
```
---
## 셰이더 파일
패키지는 기본 셰이더 파일을 포함하고 있습니다:
- `dist/distortion.vert.glsl` - 버텍스 셰이더
- `dist/distortion.frag.glsl` - 프래그먼트 셰이더
웹 서버에서 이 파일들을 정적 파일로 제공해야 합니다.
### Vite 설정 예시
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
publicDir: 'public',
// node_modules의 셰이더 파일을 복사
build: {
rollupOptions: {
output: {
assetFileNames: 'assets/[name].[ext]',
},
},
},
});
```
셰이더 파일을 public 폴더로 복사:
```bash
cp node_modules/responsive-image-canvas/dist/*.glsl public/shaders/
```
## 성능 최적화
### 1. 영역 수 제한
최대 8개의 영역까지 지원하지만, 성능을 위해 4개 이하를 권장합니다.
### 2. 이미지 크기 최적화
큰 이미지는 성능에 영향을 줄 수 있습니다. 적절한 크기로 리사이징하세요.
### 3. 애니메이션 일시정지
필요하지 않을 때는 `isPlaying={false}`로 설정하세요.
## 제한사항
- WebGL을 지원하지 않는 브라우저에서는 동작하지 않습니다
- 모바일 환경에서는 성능이 제한될 수 있습니다
- 최대 8개의 왜곡 영역만 지원합니다
- `emoji``spriteUrl`은 하나만 사용 가능합니다 (둘 다 없으면 텍스처 로드 실패)
## 브라우저 지원
- Chrome 60+
- Firefox 60+
- Safari 12+
- Edge 79+
## 라이선스
MIT
## 기여
이슈와 PR을 환영합니다!
## 관련 프로젝트
- [Three.js](https://threejs.org/)
- [React Three Fiber](https://github.com/pmndrs/react-three-fiber)

39
dist/debug.frag.glsl vendored
View File

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

328
dist/index.css vendored
View File

@ -1,328 +0,0 @@
/* 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

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$1 from 'react';
import React from 'react';
import * as THREE from 'three';
/**
@ -11,38 +11,19 @@ interface Point {
/**
*
*/
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;
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad';
/**
*
*/
interface DistortionMovement {
/** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
/** 왜곡 시작 벡터 */
vectorA: Point;
/** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
/** 왜곡 종료 벡터 */
vectorB: Point;
/** 애니메이션 지속 시간 (초) */
duration: number;
/** 적용할 이징 함수 */
easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
}
/**
*
@ -60,21 +41,6 @@ interface DistortionArea {
progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */
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;
}
/**
*
@ -90,7 +56,7 @@ interface AreaBounds {
*
*/
interface ShaderUniforms {
[uniform: string]: THREE.IUniform;
[uniform: string]: THREE.IUniform<any>;
/** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */
@ -103,8 +69,6 @@ interface ShaderUniforms {
u_dragVectors: THREE.IUniform<Float32Array>;
/** 각 영역의 왜곡 강도 배열 */
u_distortionStrengths: THREE.IUniform<Float32Array>;
/** 각 영역의 렌즈 효과 강도 배열 */
u_lensEffects: THREE.IUniform<Float32Array>;
}
/**
*
@ -143,179 +107,6 @@ interface AnimationTicker {
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
*/
@ -328,174 +119,18 @@ interface ImageDistortionProps {
vertexShaderPath?: string;
/** 프래그먼트 셰이더 경로 (선택사항) */
fragmentShaderPath?: string;
/** 애니메이션 재생 여부 */
isPlaying?: boolean;
/** 컨테이너 스타일 */
style?: React$1.CSSProperties;
style?: React.CSSProperties;
/** 컨테이너 클래스명 */
className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
}
/**
* GPU
* Three.js와 GLSL .
*/
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;
declare const ImageDistortion: React.FC<ImageDistortionProps>;
/**
*
@ -517,8 +152,6 @@ declare const SHADER_CONFIG: {
readonly MAX_DRAG_VECTORS: 8;
/** 최대 강도 배열 크기 */
readonly MAX_STRENGTHS: 8;
/** 최대 렌즈 효과 배열 크기 */
readonly MAX_LENS_EFFECTS: 8;
};
/**
*
@ -549,80 +182,8 @@ declare const DEFAULT_AREA: {
readonly x: -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
*/
@ -644,10 +205,6 @@ declare class ThreeScene {
* @param fragmentShader
*/
setShaderMaterial(vertexShader: string, fragmentShader: string): void;
/**
* Three.js
*/
getScene(): THREE.Scene;
/**
*
* @param updates
@ -657,13 +214,6 @@ declare class ThreeScene {
*
*/
render(): void;
/**
*
*/
getResolution(): {
x: number;
y: number;
};
/**
*
*/
@ -715,100 +265,6 @@ declare class AnimationLoop {
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을
* @param callback (deltaTime을 )
@ -816,24 +272,4 @@ declare class SpriteEffectManager {
*/
declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void;
/**
* , ,
*/
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 };
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 };

584
dist/index.d.ts vendored
View File

@ -1,4 +1,4 @@
import React$1 from 'react';
import React from 'react';
import * as THREE from 'three';
/**
@ -11,38 +11,19 @@ interface Point {
/**
*
*/
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;
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad';
/**
*
*/
interface DistortionMovement {
/** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
/** 왜곡 시작 벡터 */
vectorA: Point;
/** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
/** 왜곡 종료 벡터 */
vectorB: Point;
/** 애니메이션 지속 시간 (초) */
duration: number;
/** 적용할 이징 함수 */
easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
}
/**
*
@ -60,21 +41,6 @@ interface DistortionArea {
progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */
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;
}
/**
*
@ -90,7 +56,7 @@ interface AreaBounds {
*
*/
interface ShaderUniforms {
[uniform: string]: THREE.IUniform;
[uniform: string]: THREE.IUniform<any>;
/** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */
@ -103,8 +69,6 @@ interface ShaderUniforms {
u_dragVectors: THREE.IUniform<Float32Array>;
/** 각 영역의 왜곡 강도 배열 */
u_distortionStrengths: THREE.IUniform<Float32Array>;
/** 각 영역의 렌즈 효과 강도 배열 */
u_lensEffects: THREE.IUniform<Float32Array>;
}
/**
*
@ -143,179 +107,6 @@ interface AnimationTicker {
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
*/
@ -328,174 +119,18 @@ interface ImageDistortionProps {
vertexShaderPath?: string;
/** 프래그먼트 셰이더 경로 (선택사항) */
fragmentShaderPath?: string;
/** 애니메이션 재생 여부 */
isPlaying?: boolean;
/** 컨테이너 스타일 */
style?: React$1.CSSProperties;
style?: React.CSSProperties;
/** 컨테이너 클래스명 */
className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
}
/**
* GPU
* Three.js와 GLSL .
*/
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;
declare const ImageDistortion: React.FC<ImageDistortionProps>;
/**
*
@ -517,8 +152,6 @@ declare const SHADER_CONFIG: {
readonly MAX_DRAG_VECTORS: 8;
/** 최대 강도 배열 크기 */
readonly MAX_STRENGTHS: 8;
/** 최대 렌즈 효과 배열 크기 */
readonly MAX_LENS_EFFECTS: 8;
};
/**
*
@ -549,80 +182,8 @@ declare const DEFAULT_AREA: {
readonly x: -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
*/
@ -644,10 +205,6 @@ declare class ThreeScene {
* @param fragmentShader
*/
setShaderMaterial(vertexShader: string, fragmentShader: string): void;
/**
* Three.js
*/
getScene(): THREE.Scene;
/**
*
* @param updates
@ -657,13 +214,6 @@ declare class ThreeScene {
*
*/
render(): void;
/**
*
*/
getResolution(): {
x: number;
y: number;
};
/**
*
*/
@ -715,100 +265,6 @@ declare class AnimationLoop {
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을
* @param callback (deltaTime을 )
@ -816,24 +272,4 @@ declare class SpriteEffectManager {
*/
declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void;
/**
* , ,
*/
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 };
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 };

1972
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

1953
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
View File

@ -1,8 +0,0 @@
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,13 +1,12 @@
{
"name": "@baekryang/responsive-image-canvas",
"version": "1.3.0",
"name": "responsive-image-canvas",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@baekryang/responsive-image-canvas",
"version": "1.3.0",
"license": "MIT",
"name": "responsive-image-canvas",
"version": "1.0.0",
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@ -17,11 +16,6 @@
"three": "^0.181.0",
"tsup": "^8.5.0",
"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": {

View File

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

BIN
petal.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

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

@ -1,62 +0,0 @@
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

@ -1,594 +0,0 @@
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

@ -1,132 +0,0 @@
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>
);
};

View File

@ -1,59 +0,0 @@
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)', // 선택 안된 영역 배경 (연한 회색)
},
};

View File

@ -1,380 +0,0 @@
/* 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

@ -1,95 +0,0 @@
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,
};
};

View File

@ -1,16 +0,0 @@
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';

View File

@ -1,139 +0,0 @@
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,6 +1,5 @@
import { DistortionArea, Point } from '../types';
import { applyEasing } from '../utils/easing';
import { presetToVector, isRotationPreset } from '../utils/motionPresets';
import type {DistortionArea, Point} from "../types";
/**
*
@ -17,73 +16,26 @@ export class AnimationLoop {
return areas.map((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);
// 벡터 계산
// 벡터 간 보간
let dragVector: Point;
// 스텝 양자화 (영역 속성에서 가져옴)
const snapSteps = area.snapSteps ?? 0;
// 회전 프리셋인 경우 원운동
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 = {
x: Math.cos(quantizedAngle * direction) * radius,
y: Math.sin(quantizedAngle * direction) * radius,
};
} else {
const angle = easedProgress * Math.PI * 2;
dragVector = {
x: Math.cos(angle * direction) * radius,
y: Math.sin(angle * direction) * radius,
};
}
if (easedProgress < 0.5) {
// 0.0 -> 0.5: 0에서 vectorA로 보간
const t = easedProgress * 2;
dragVector = {
x: movement.vectorA.x * t,
y: movement.vectorA.y * t,
};
} 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,
};
}
// 0.5 -> 1.0: vectorA에서 0으로 보간
const t = (easedProgress - 0.5) * 2;
dragVector = {
x: movement.vectorA.x * (1 - t),
y: movement.vectorA.y * (1 - t),
};
}
return {
@ -104,11 +56,6 @@ export class AnimationLoop {
deltaTime: number
): DistortionArea[] {
return areas.map((area) => {
// duration이 0이면 progress 업데이트 안 함
if (area.movement.duration <= 0) {
return area;
}
let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1.0; // 루프

View File

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

View File

@ -1,149 +0,0 @@
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

@ -1,392 +0,0 @@
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

@ -1,146 +0,0 @@
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

@ -1,102 +0,0 @@
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 type { ShaderUniforms } from '@/types';
import { ShaderUniforms } from '@/types';
/**
* Three.js
@ -15,7 +15,7 @@ export class ThreeScene {
// 씬 생성
this.scene = new THREE.Scene();
// 2D용 직교 카메라 설정 (카메라는 -z 방향, near=0 ~ far=1)
// 2D용 직교 카메라 설정
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// 렌더러 설정
@ -34,7 +34,6 @@ export class ThreeScene {
u_numAreas: { value: 0 },
u_dragVectors: { value: new Float32Array(16) }, // 8벡터 × 2(x,y)
u_distortionStrengths: { value: new Float32Array(8) },
u_lensEffects: { value: new Float32Array(8) },
};
this.handleResize();
@ -62,10 +61,6 @@ export class ThreeScene {
* @param fragmentShader
*/
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 material = new THREE.ShaderMaterial({
uniforms: this.uniforms,
@ -73,36 +68,12 @@ export class ThreeScene {
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) {
this.scene.remove(this.mesh);
}
this.mesh = new THREE.Mesh(geometry, material);
this.mesh.renderOrder = 0;
this.scene.add(this.mesh);
console.log('[ThreeScene] mesh를 씬에 추가함');
}
/**
* Three.js
*/
public getScene(): THREE.Scene {
return this.scene;
}
/**
@ -123,16 +94,6 @@ export class ThreeScene {
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

@ -1,230 +0,0 @@
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

@ -1,204 +0,0 @@
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,38 +2,10 @@
export { ImageDistortion } 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 {
Point,
EasingFunction,
BuiltInMotionPreset,
MotionPreset,
MotionPresetDefinition,
DistortionMovement,
DistortionArea,
AreaBounds,
@ -43,48 +15,14 @@ export type {
AnimationTicker,
} 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 { 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 { ShaderManager } from './engine/ShaderManager';
export { AnimationLoop } from './engine/AnimationLoop';
export { SpringPhysics } from './engine/SpringPhysics';
export { SpriteEffectManager } from './engine/SpriteEffectManager';
// 훅
export { useAnimationFrame } from './hooks/useAnimationFrame';
export { useMouseVelocity } from './hooks/useMouseVelocity';
export { useMouseInteraction } from './hooks/useMouseInteraction';

View File

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

View File

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

View File

@ -2,111 +2,59 @@
* 2D (0.0 - 1.0)
*/
export interface Point {
x: number;
y: number;
x: number;
y: number;
}
/**
*
*/
export type EasingFunction =
| 'linear'
| 'easeIn'
| 'easeOut'
| 'easeInOut'
| 'easeInQuad'
| '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;
| 'linear'
| 'easeIn'
| 'easeOut'
| 'easeInOut'
| 'easeInQuad'
| 'easeOutQuad';
/**
*
*/
export interface DistortionMovement {
/** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
vectorA: Point;
/** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
vectorB: Point;
/** 애니메이션 지속 시간 (초) */
duration: number;
/** 적용할 이징 함수 */
easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
/** 왜곡 시작 벡터 */
vectorA: Point;
/** 왜곡 종료 벡터 */
vectorB: Point;
/** 애니메이션 지속 시간 (초) */
duration: number;
/** 적용할 이징 함수 */
easing: EasingFunction;
}
/**
*
*/
export interface DistortionArea {
/** 고유 식별자 */
id: string;
/** 사각형의 네 모서리 포인트 [topLeft, topRight, bottomRight, bottomLeft] */
basePoints: [Point, Point, Point, Point];
/** 움직임 애니메이션 설정 */
movement: DistortionMovement;
/** 왜곡 강도 (0.0 - 1.0) */
distortionStrength: number;
/** 현재 애니메이션 진행도 (0.0 - 1.0) */
progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */
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;
/** 고유 식별자 */
id: string;
/** 사각형의 네 모서리 포인트 [topLeft, topRight, bottomRight, bottomLeft] */
basePoints: [Point, Point, Point, Point];
/** 움직임 애니메이션 설정 */
movement: DistortionMovement;
/** 왜곡 강도 (0.0 - 1.0) */
distortionStrength: number;
/** 현재 애니메이션 진행도 (0.0 - 1.0) */
progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point;
}
/**
*
*/
export interface AreaBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
minX: number;
minY: number;
maxX: number;
maxY: number;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,163 +0,0 @@
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.');
}