Compare commits
No commits in common. "main" and "develop" have entirely different histories.
@ -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
4
.gitignore
vendored
@ -11,7 +11,3 @@ yarn-error.log*
|
||||
.vscode/*
|
||||
.DS_Store
|
||||
*.log
|
||||
nul
|
||||
|
||||
# Demo (템플릿 파일, 실제 데모는 별도 저장소)
|
||||
/demo.npmrc
|
||||
|
||||
6
.idea/AICommit.xml
generated
6
.idea/AICommit.xml
generated
@ -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
1
.npmrc
@ -1 +0,0 @@
|
||||
//git.bnovalab.com/api/packages/baekryang/npm/:_authToken=a2ed709f39e95662493a92305555a4bf70f6fe10
|
||||
@ -1,2 +1 @@
|
||||
- 주석은 한글로 작성
|
||||
- D:\Projects\WebstormProjects\demo-app 에 link 된 데모 앱이 있음
|
||||
901
README.md
901
README.md
@ -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
39
dist/debug.frag.glsl
vendored
@ -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);
|
||||
}
|
||||
125
dist/distortion.frag.glsl
vendored
125
dist/distortion.frag.glsl
vendored
@ -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
328
dist/index.css
vendored
@ -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
1
dist/index.css.map
vendored
File diff suppressed because one or more lines are too long
584
dist/index.d.mts
vendored
584
dist/index.d.mts
vendored
@ -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
584
dist/index.d.ts
vendored
@ -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
1972
dist/index.js
vendored
File diff suppressed because it is too large
Load Diff
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
1953
dist/index.mjs
vendored
1953
dist/index.mjs
vendored
File diff suppressed because it is too large
Load Diff
2
dist/index.mjs.map
vendored
2
dist/index.mjs.map
vendored
File diff suppressed because one or more lines are too long
8
dist/test.frag.glsl
vendored
8
dist/test.frag.glsl
vendored
@ -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
14
package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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',
|
||||
}}
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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)', // 선택 안된 영역 배경 (연한 회색)
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
@ -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; // 루프
|
||||
|
||||
|
||||
@ -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('셰이더 로딩에 실패했습니다');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
62
src/index.ts
62
src/index.ts
@ -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';
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
uniform sampler2D u_texture;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
// 간단한 테스트: 텍스처를 그대로 표시 (왜곡 없음)
|
||||
vec4 color = texture2D(u_texture, vUv);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
export * from './area';
|
||||
export * from './shader';
|
||||
export * from './animation';
|
||||
export * from './spriteEffect';
|
||||
@ -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;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user